From bcd8630a8ef26d89a47e59058e651aeed8248e42 Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Tue, 20 Oct 2026 13:00:08 -0400 Subject: [PATCH] Planning --- compose.testnet.yaml | 1 - compose.yaml | 1 - docs/market-transparency.md | 12 +-- docs/marketplace.md | 23 +++-- docs/production-plan.md | 4 +- docs/settlement-feasibility.md | 84 +++++++++++++-- docs/xmr-domain.md | 22 ++-- server/market/payments.ts | 13 ++- server/market/store.ts | 181 +++++++++++++++++++++------------ server/testnet/http.ts | 4 +- shared/market-v3.ts | 67 ++++++++++++ shared/market.ts | 3 +- shared/xmr-amount.ts | 2 +- shared/xmr-pricing.ts | 2 +- src/Marketplace.tsx | 70 ++++++++----- tests/api.test.ts | 34 ++++--- tests/market.test.ts | 130 +++++++++++++++++++---- tests/xmr-amount.test.ts | 2 +- 18 files changed, 483 insertions(+), 172 deletions(-) create mode 100755 shared/market-v3.ts diff --git a/compose.testnet.yaml b/compose.testnet.yaml index dc3b5d2..a815a4d 100644 --- a/compose.testnet.yaml +++ b/compose.testnet.yaml @@ -4,7 +4,6 @@ x-market-environment: &market-environment WIKIDECK_PAYMENT_MODE: mock WIKIDECK_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}" WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}" - WIKIDECK_MOCK_USD_CENTS_PER_XMR: "${WIKIDECK_MOCK_USD_CENTS_PER_XMR:-15000}" services: node-a: image: wikideck:testnet diff --git a/compose.yaml b/compose.yaml index b9b1f24..866d45b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,7 +14,6 @@ services: WIKIDECK_PAYMENT_MODE: mock WIKIDECK_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}" WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}" - WIKIDECK_MOCK_USD_CENTS_PER_XMR: "${WIKIDECK_MOCK_USD_CENTS_PER_XMR:-15000}" volumes: - wikideck-data:/app/data init: true diff --git a/docs/market-transparency.md b/docs/market-transparency.md index a9b21c0..a61ed84 100755 --- a/docs/market-transparency.md +++ b/docs/market-transparency.md @@ -4,7 +4,7 @@ ## Current status: no real payments -**The running Wikideck market is a testnet sandbox. It does not process real cryptocurrency payments.** XMR amounts, payment confirmations, fees, seller payouts and refunds are simulated. Checkout provides **no payable Monero address**. Do not send cryptocurrency to a host, author-fee address or address someone provides in a message to pay a sandbox order. +**The running Wikideck market is a testnet sandbox. It does not process real cryptocurrency payments.** New listing prices are native XMR amounts; payment confirmations, fees, seller payouts and refunds are simulated. Historical USD orders retain their original terms and are labeled as legacy. Checkout provides **no payable Monero address**. Do not send cryptocurrency to a host, author-fee address or address someone provides in a message to pay a sandbox order. **Card delivery is real on the selected blockchain.** When a seller signs delivery, they authorize an actual NFT transfer, even though the buyer only clicked a simulated-payment button. A `paid` or `mock-paid` label here is **not evidence of real money received**. Do not deliver valuable assets in exchange for mock payment. @@ -35,13 +35,13 @@ The seller may cancel an available fixed-price listing. An auction cannot be can Fixed-price checkout reserves the local listing for **30 minutes from reservation**. An unpaid reservation expires and can reopen if the card is still eligible. A seller cannot cancel the reserved listing through the normal cancellation action. -Auctions last one minute to 30 days. Bids are signed but **unfunded**; each bid must meet the opening price and exceed an existing highest bid by at least $0.01. The seller's own address cannot bid, but alternate-wallet shill bidding is not prevented. There are no funded bid deposits, identity checks or automatic nonpayment penalties. +Sandbox auctions last one minute to 30 days. Bids are signed but **unfunded**; each new v3 bid must meet the opening price and exceed an existing highest bid by at least one atomic unit. Historical USD auctions are read-only and retain their original bid terms. The seller's own address cannot bid, but alternate-wallet shill bidding is not prevented. There are no funded bid deposits, identity checks or automatic nonpayment penalties. Live auctions remain a separate release gate. The auction winner must check out and complete mock payment within **30 minutes after auction close**, not 30 minutes after clicking checkout. Without bids the auction expires at closing. An unpaid winner causes expiry; there is no automatic runner-up sale. The seller can relist later. ### 3. Buyer simulates payment -The buyer sees the USD price, simulated gross XMR, host fee, author fee, seller net and payment deadline. The conversion is a configured test rate—**not a live market quote**. Quote amounts, fee allocations and delivery-confirmation requirements are stored with the order; later configuration changes do not silently reprice it. +For a new version-3 order, the buyer sees the exact signed XMR gross, host fee, author fee, seller net and payment deadline. No USD conversion or exchange rate is applied. Legacy version-2 USD orders retain their previously signed USD price and snapshotted mock-XMR quote; they are not reinterpreted as XMR. Quote amounts, fee allocations and delivery-confirmation requirements remain stored with each order. Only the order's buyer can sign **Simulate XMR payment**, and eligibility is checked again. This action changes an accounting status; it does not verify a Monero transaction or contact SHKeeper. Other users cannot mark the order paid through the normal participant-authorized API, but the buyer's signature alone never proves real payment. @@ -79,7 +79,7 @@ The author recipient is: **This is a payout recipient, not a checkout address.** Setting hosting fees to zero does not disable the author allocation. Checksum verification does not prove recipient ownership. The author fee is source-level policy, not a guarantee that every independently modified host runs the same release; verify the host and its disclosed terms. -USD values use integer cents. XMR uses 12-decimal atomic units. Simulated gross is rounded up to an atomic unit; each fee is rounded down separately; net is exactly gross minus both fees. A fee below one atomic unit rounds to zero. Existing orders retain their agreed accounting, including legacy orders predating the author fee. Old open listings lacking the current policy must be relisted with fresh authorization. +New v3 XMR prices and fees use integer 12-decimal atomic units. Buyer gross is the exact signed listed amount; there is no USD conversion or gross-up. Each fee rounds down separately; seller net is exactly gross minus both fees. A fee below one atomic unit rounds to zero. Historical v2 orders retain their original USD cents, mock-XMR quote and accounting, including legacy orders predating the author fee. Eligible old open listings are read-only until the seller retires and relists with fresh v3 authorization. Fees apply to completed marketplace sales—not listing creation, auction bids, free NFT transfers or card-for-card trades. Failed/expired/refunded orders do not produce successful payout accounting. There is **no finalized live processor/network-fee or refund-cost policy**; one must be disclosed before real payments are enabled. The test numbers are not a promise about future all-in costs. @@ -103,7 +103,7 @@ There is currently **no built-in arbitration service, buyer insurance, chargebac ## What the offline SHKeeper prototype demonstrates—not a live promise -The proposed flow would have the buyer pay gross XMR into the operator's merchant wallet, the host verify it using SHKeeper, the seller sign NFT delivery, and the host pay the seller net plus the separate author allocation after canonical delivery checks. This temporarily entrusts funds to the operator and its processor administration. SHKeeper does not by itself guarantee that a seller delivers or that an operator honors a refund. +The earlier disconnected prototype modeled a USD-denominated SHKeeper invoice, gross XMR into an operator merchant wallet, seller-signed NFT delivery, then operator payout of seller net and the separate author allocation. This would temporarily entrust funds to the operator and its processor administration. SHKeeper does not by itself guarantee that a seller delivers or that an operator honors a refund. This USD invoice prototype is **not connected to the new native-XMR marketplace** and must not be silently used to convert its prices; any future processor path requires verified exact-XMR capability or a separately approved architecture. The disconnected prototype tests: @@ -134,7 +134,7 @@ Those are **requirements for a future offering**, not assurances already fulfill ## A practical checklist for users 1. Check the host's domain, chain and **mock/live status**. Today's release is mock-only: do not pay real XMR. -2. Review the exact card ID, counterparty, USD price, displayed XMR, both fees, net proceeds and deadlines before signing. +2. Review the exact card ID, counterparty, native-XMR price (or clearly labeled historical USD price), both fees, net proceeds and deadlines before signing. 3. Keep the order/listing ID, host origin, agreed terms, status and NFT transfer identity for your records. A screenshot or status label is not independently verified proof of payment; never include secret keys/passwords in evidence. 4. Use **Your activity** on the same market host to inspect progress. Confirmations refer to canonical inclusion, not a money guarantee. Refresh after a timeout; contact the actual host through a verified channel if one is published. 5. Sellers: never equate a simulated or buyer-supplied receipt with real payment. Buyers: do not assume an off-chain reservation locks the card or guarantees a refund. diff --git a/docs/marketplace.md b/docs/marketplace.md index e9f0402..b659387 100755 --- a/docs/marketplace.md +++ b/docs/marketplace.md @@ -8,13 +8,13 @@ The current market provides fixed-price sales and timed auctions. Running a coll ## Cryptocurrency payments and the two sale fees -The intended settlement currency is **Monero (XMR)**, with USD-priced listings and an XMR amount quoted at checkout. Mining and payments are separate: RandomX secures Wikideck's own blockchain, while XMR payments would settle on Monero. +New marketplace terms use **native XMR prices** represented exactly in atomic units (12 decimal places). There is no USD conversion, exchange-rate field or live payment route. Historical version-2 USD orders remain in their original currency and signatures; USD listings are read-only until the seller closes them and signs a fresh XMR listing. Mining and payments are separate: RandomX secures Wikideck's own blockchain, while any future XMR settlement would use Monero. Each sale allocates a configurable hosting fee to the peer's market operator (default **2.5%**) plus a hardcoded **1% author fee** to the project author's fixed XMR address. Both are deducted from the seller's gross proceeds, not compounded or secretly added to the buyer price. At the default rates the seller keeps 96.5% before any future disclosed processor/network-fee policy. The host retains the hosting allocation; the author allocation is a separate payout. **All allocations and payouts are currently simulated.** ## Current testnet sandbox -Open **Marketplace** in the interface. Prices are **USD**; checkout amounts, fees, refunds, and automatic seller payouts are simulated in **XMR**. There is no payable address, payment processor connection, or movement of real Monero in this release. The displayed exchange rate is a deterministic simulation fixture, not a market quote. **Signing delivery transfers the actual card on the selected chain**, even though payment is mocked. +Open **Marketplace** in the interface. New prices and checkout amounts are **XMR-native**; fees, refunds, and automatic seller payouts are simulated. Historical USD orders are explicitly labeled. There is no payable address, payment processor connection, or movement of real Monero in this release. No exchange rate is applied. **Signing delivery transfers the actual card on the selected chain**, even though payment is mocked. ## Try a sale @@ -25,12 +25,12 @@ Open **Marketplace** in the interface. Prices are **USD**; checkout amounts, fee 5. Return as the seller, review the buyer/card/proceeds, and **Sign and confirm** delivery. This creates an ordinary signed NFT transfer; mine it into the canonical chain. 6. After the order's required confirmation depth, reconciliation records automatic **mock-paid** seller and additional-fee payouts. The configured market fee is retained in the simulated accounting. No operator payout click is needed. -Wallet backup/recovery remains local; never send private keys or backup passwords to the node. USD listings do not turn the NFT wallet into a Monero wallet. +Wallet backup/recovery remains local; never send private keys or backup passwords to the node. A Wikideck NFT wallet is not a Monero wallet. ## Sale rules - **Fixed price:** stays listed until sold, cancelled, or invalidated by changing ownership/sequence. Checkout exclusively reserves it for 30 minutes; an unpaid reservation expires and the listing reopens if still valid. -- **Auction:** one minute to 30 days. Bids must meet the start price and exceed the highest bid by at least one cent. Bids are signed but **unfunded**. Sellers cannot bid on their own auction or cancel after its first bid. This does not prevent alternate-wallet shill bidding or nonpayment. +- **Auction:** one minute to 30 days in the sandbox. Bids must meet the start price and exceed the highest bid by at least one atomic unit. Bids are signed but **unfunded**. Sellers cannot bid on their own auction or cancel after its first bid. This does not prevent alternate-wallet shill bidding or nonpayment. Live auctions remain a separate unreleased gate. - No bids are accepted at or after the deadline. If there are bids, the listing remains available only for winner checkout until **30 minutes after auction close**, not 30 minutes after checkout. With no bids it expires at closing. An unpaid winner causes expiry; there is no automatic runner-up sale. The seller may relist. - Seller delivery is due **48 hours after mock payment**. Failed delivery can enter **refund-required**. The buyer can simulate a full refund only if the sale transfer is neither pending nor canonical. Pending/late/ambiguous delivery requires review, not an automatic refund that might also release the card. - Listings are **not consensus escrow**: they do not lock cards on-chain. A seller can transfer or trade elsewhere; availability is rechecked when listing, reserving, paying, and reconciling delivery. Payment and NFT delivery are **not an atomic cross-chain exchange**. @@ -38,6 +38,8 @@ Wallet backup/recovery remains local; never send private keys or backup password - Settlement matches the **exact canonical transfer identity**, not merely the buyer's current holdings. Unconfirmed submission never counts as delivery. A buyer may transfer onward after delivery without confusing its identity. - If a reorganization affects an already settled order, it enters **review** permanently and does not auto-pay again. The operator must investigate; this sandbox has no administrative recovery/payment override endpoint. +New sale prices use canonical atomic-unit strings and integer arithmetic; JSON/signature payloads never use floating-point XMR. Each fee rounds down independently; seller net is gross minus both allocations. No economic listing minimum is imposed. Historical USD records retain original cents and are never converted or repriced. + ## Configuration | Variable | Default | Meaning | @@ -45,11 +47,10 @@ Wallet backup/recovery remains local; never send private keys or backup password | `WIKIDECK_PAYMENT_MODE` | `mock` | Only accepted value. Other values fail startup, including on the mainnet candidate. | | `WIKIDECK_MARKET_FEE_BPS` | `250` | Seller fee: 250 basis points = 2.5%; integer 0–5000. | | `WIKIDECK_MARKET_CONFIRMATIONS` | `10` | Canonical delivery confirmations before simulated payout; integer 2–1000. No finite depth guarantees PoW finality. | -| `WIKIDECK_MOCK_USD_CENTS_PER_XMR` | `15000` | Simulation-only conversion fixture: $150/XMR. Not a live price feed. | -The single-node and three-node testnet Compose configurations pass these settings. Choose one market host and use its origin consistently. Node marketplaces have different identities and journals: **listing, bid, checkout, and order state are not gossiped**. NFT transfers/blocks are. Buyers and sellers must use the same market host. +The single-node and three-node testnet Compose configurations pass these hosting-fee and delivery-confirmation settings. Choose one market host and use its origin consistently. Node marketplaces have different identities and journals: **listing, bid, checkout, and order state are not gossiped**. NFT transfers/blocks are. Buyers and sellers must use the same market host. -Money is stored as integer USD cents and calculated in integer Monero atomic units (12 decimal places). Gross XMR is rounded up to an atomic unit; each fee is rounded down separately; seller net is exactly gross minus both fees. Maximum listing/bid price is $1,000,000.00. +New listings, bids, and checkout store native XMR atomic-unit amounts (12 decimal places); they are not derived from fiat. Historical v2 records retain USD cents and their originally snapshotted mock XMR amounts. The application bound on newly entered atomic gross is documented in the [amount domain specification](xmr-domain.md); it is not a wallet-balance or exposure approval. ## Hardcoded 1% project author fee @@ -63,13 +64,13 @@ This is a payout destination, **not a buyer checkout address**. Do not send real The additional fee is rounded down to an atomic unit separately from the hosting fee. Seller net is exactly gross minus **both** fees. For example, a 0.1 XMR sale with 2.5% hosting allocates 0.0025 XMR hosting fee, 0.001 XMR additional fee, and 0.0965 XMR seller net. Rounding below one atomic unit produces zero rather than increasing the charge above 1%. -The recipient, rate and amount are snapshotted into each new order and disclosed in the UI. The rate and recipient are bound to listing/checkout signatures using marketplace command version 2. Neither is environment-configurable or overridable in a request. **Existing reserved/paid/completed orders are not retroactively repriced.** Old open listings without the additional fee are invalidated and must be relisted with fresh seller authorization; their old bids do not carry over. Old version-1 signed requests are rejected; refresh the browser before signing after an upgrade. +The recipient and percentage are snapshotted into each new order and disclosed in the UI. They are bound to v3 listing/checkout signatures and are neither environment-configurable nor request-overridable. **Existing reserved/paid/completed v2 orders are not retroactively repriced.** Open USD listings remain read-only until eligible sellers close them and relist with fresh v3 XMR authorization; historical bids are not converted. The v2 domain remains for compatibility with historical orders and receipts; new USD listings, bids, and checkout are rejected. ## API and storage - `GET /api/market?offset=0` returns up to 24 public listings, pagination, server clock, fee policy, and market identity. -- `POST /api/market/command` accepts a strict, signed command. Actions: `list`, `bid`, `buy`, `cancel`, `mock-pay`, `deliver`, `refund`, `activity`. -- Every command binds the network, chain ID, **market instance ID**, actor/public key, UUID nonce, short expiry, action, and all terms. Private activity requires an actor signature and includes only orders involving that actor. Signed requests expire within five minutes; keep clocks synchronized. +- `POST /api/market/command` accepts strict, signed commands. New XMR terms use `market-command-v3`; historical `market-command-v2` is retained only to service existing orders and eligible seller cancellation. Actions include `list`, `bid`, `buy`, `cancel`, `retire-legacy-listing`, `mock-pay`, `deliver`, `refund`, and `activity` under their allowed version. +- Every command binds the network, chain ID, **market instance ID**, actor/public key, UUID nonce, short expiry, action, and all versioned terms. New prices sign the currency and canonical atomic XMR gross. Private activity requires a wallet signature and includes only orders involving that actor. Signed requests expire within five minutes; keep clocks synchronized. - Nonce receipts are persisted and replay returns the same result. A different command cannot reuse that nonce. Refresh activity to obtain the latest order state rather than interpreting an old receipt as current state. - Public listings reveal seller/highest bidder wallet addresses and card holdings. Orders reveal counterparties/amounts only to their participants, but the **market operator and its database backups can see all orders**. This is not private Monero trading. - The marketplace journal is stored alongside the ledger in the selected network subdirectory, uses SQLite/WAL and full synchronous commits, and binds its metadata to the active chain. Mutations are serialized. There is one active local listing per card, at most 100 active listings per seller, and 10,000 total listings/orders per journal. Reaching a journal cap fails closed; operator archival work is required, not deleting active records. @@ -89,7 +90,7 @@ For **review**, preserve backups and inspect canonical blocks, pending transfers The intended future live processor is **SHKeeper**, using the operator-provided instance at https://shkeeper.eom.dev/. **It is not contacted or configured by this sandbox; no live adapter is enabled.** Stage 5 adds an [offline wire-contract and durable settlement prototype](shkeeper-settlement.md) with fake-transport tests, separate from the running marketplace. Its live factory always refuses activation. Do not supply payment credentials yet. No secret belongs in browser build variables, source, or version-controlled Compose files. -According to [SHKeeper's API documentation](https://shkeeper.io/api/), creating an XMR invoice uses `POST /api/v1/XMR/payment_request` with a USD amount and an API key, which naturally supports USD listings with an XMR checkout quote. Seller payouts use a separate Basic-authenticated XMR payout endpoint. The merchant wallet receives the gross amount; paying the seller net means the operator temporarily **custodies funds** and retains the market fee. Network fees and any processor fees need an explicit, disclosed policy before enabling this path. +The offline SHKeeper harness still models the provider's documented USD-denominated invoice input. It is disconnected from the native-XMR market and therefore is **not currently compatible with exact XMR-native pricing**. Do not hide a USD conversion behind an XMR price. Seller payouts use a separate Basic-authenticated XMR payout endpoint; the merchant wallet would receive gross funds and the operator would temporarily **custody funds**. Native-XMR invoice capability and settlement architecture remain unverified; no processor path is selected or enabled. Live settlement needs a separate implementation and review, **after** a reviewed production network exists: diff --git a/docs/production-plan.md b/docs/production-plan.md index 203c252..bb2fb8d 100755 --- a/docs/production-plan.md +++ b/docs/production-plan.md @@ -16,7 +16,7 @@ Status: active engineering plan; **not launch or payment authorization**. Update | Payment before sale finalization | A paid sale must not complete or be included as a completed ownership sale on Wikideck until the agreed XMR payment has satisfied its confirmation/unlock policy. Payment notification/mempool presence is insufficient. | | Existing boundaries | No real processor access, credentials, invoices, transfers, genesis change, destructive migration or launch approval is authorized by this plan. Software licensing still requires an operator choice and publication. | -**Current behavior is not changed by these decisions alone.** The public sandbox is still USD-priced with mock XMR amounts; the SHKeeper harness uses USD invoice requests. Those are migration targets, not the intended production pricing model. Existing orders must retain their agreed terms, not be silently reinterpreted as XMR. +**Production settlement remains disabled.** The public sandbox now prices new v3 listings/orders in native XMR atomic units, with mock-only confirmations, fees, refunds and payouts; legacy v2 USD records retain their original terms. The disconnected SHKeeper harness still models USD invoice requests and is not wired to v3. No real XMR invoice, payable address, conversion, transfer or settlement was enabled. Existing records are not silently reinterpreted as XMR. ## 2. Resolve the cross-chain requirement before selecting architecture @@ -136,7 +136,7 @@ Acceptance: signed release evidence packet; recorded unresolved accepted risks w First implementation increment: write the M0 threat model/ADR research questions and P1 XMR domain specification, then build exact amount/property tests. Do not spend the next increment adding another disconnected “live” scaffold. Each milestone must integrate with its actual callers, persistence, recovery, UI and failure tests before being described as delivered. -First increment status: [M0 source-grounded investigation/threat model](settlement-feasibility.md) and [M1 amount/migration specification](xmr-domain.md) recorded. Shared exact amount and commission primitives are reused by existing mock/processor-lab callers; no native-XMR public marketplace migration, approved exchange protocol or live payment integration is claimed. Both milestones remain open against their full acceptance criteria. +Next increment status: M0 source investigation and [concrete settlement/evidence choices](settlement-feasibility.md) remain open. M1 now has a signed v3 XMR-native mock marketplace path; the public USD-to-XMR rate was removed. Version-2 historical USD orders are preserved and can continue under their original terms; open USD listings are legacy read-only until seller retirement/relisting. This is not live settlement, a completed migration dry-run, or production milestone acceptance; payment and payouts remain mock-only, and live auctions remain a separate release gate. ## 5. Acceptance evidence and release discipline diff --git a/docs/settlement-feasibility.md b/docs/settlement-feasibility.md index 1b2cc10..a26482a 100755 --- a/docs/settlement-feasibility.md +++ b/docs/settlement-feasibility.md @@ -15,7 +15,7 @@ Status: **proposed; research checkpoint, not an approved exchange protocol**. So | --- | --- | --- | | [Hoenisch / Soriano del Pino, 2021, v2, sections 3–4](https://arxiv.org/html/2101.12332v2) | Section 3 describes Bitcoin lock/redeem/cancel/refund/punish transactions, adaptor signatures and cross-curve discrete-log equality proofs. Monero is funded into a shared-key output after Bitcoin is locked. Timely chain observation and reaction matter. Section 4 explicitly describes further work, not a completed proof/implementation of its alternative. | Potential structure for NFT-first research, not a ready-made NFT exchange. Cross-curve proof and adaptor implementations must match the chosen curves. Never infer current Monero capabilities solely from a 2021 proposal. | | [COMIT upstream](https://github.com/comit-network/xmr-btc-swap) | Archived/unmaintained; README points to eigenwallet and warns of incompatible peer changes. README states its cryptography was not formally audited. GPL-3.0. | Do not adopt archived upstream as a production dependency. Its historical direction/limitations are not proof of current feasibility or impossibility. | -| [eigenwallet core](https://github.com/eigenwallet/core) | Public successor has ongoing development and a visible 4.16.1 release; describes a Monero–Bitcoin DEX. Repository displayed commit e77e2ecb1ffe43891c06ab2cdb2100a72d2f7aa0 during this review; GPL-3.0. | Maintenance/release evidence only. Not an audit, arbitrary-NFT support or P-256 compatibility evidence. Any reuse needs a pinned version, dependency/license review and review of the actual selected protocol implementation. No audit artifact was evaluated here. | +| [eigenwallet core](https://github.com/eigenwallet/core), [protocol explanation](https://docs.eigenwallet.org/advanced/refund_punish), [4.16.1 release](https://github.com/eigenwallet/core/releases/tag/4.16.1) | Maintained project with 4.16.1 release. Its current docs explicitly define taker as Bitcoin seller and maker as Monero seller; its lock/cancel/refund/punish flow is Bitcoin-timelock centric. The docs and site describe Bitcoin→Monero only. GPL-3.0; historical COMIT README says formal crypto audit absent, but that is not evidence about every current successor version. | Useful comparative protocol and operational lessons, but not the requested XMR-for-NFT direction or an arbitrary-NFT/P-256 integration. No audit artifact for a proposed Wikideck adaptation was found/evaluated. Review exact current source before considering any reuse. | | [Monero wallet RPC: check_tx_proof / check_tx_key](https://docs.getmonero.org/rpc-library/wallet-rpc/#check_tx_proof) | Reports proof validity, recipient amount, in-pool status and confirmations; explicitly warns that proofs do not guarantee spendability (locked, already spent or duplicated/burnt one-time outputs). | A valid proof/txid is not by itself a release condition. Define exact output validity, order binding, chain evidence, confirmation and unlock semantics. Merely requiring that funds remain unspent after a seller legitimately spends them would also be incorrect. | | [Monero wallet RPC: transfer](https://docs.getmonero.org/rpc-library/wallet-rpc/#transfer) | Supports multiple recipients and atomic-unit amounts; warns against subtracting fees from outputs when exact recipient amounts are required. | Native wallet multi-recipient capability is not the same as SHKeeper multi-payout support and does not make NFT exchange atomic. Amounts need lossless JSON encoding, not conversion to JavaScript Number. | | [SHKeeper API 2.1.47](https://shkeeper.io/api/) | Documented invoice creation accepts USD fiat amounts; returns converted crypto amounts. XMR multipayout unsupported. Payout fee input is priority, not an atomic cost cap. | Exact native-XMR invoice and bounded-fee guarantees remain unverified. No hidden USD conversion. Processor status is trusted external observation, not deterministic consensus evidence. | @@ -36,11 +36,80 @@ Mining remains roughly one card per 15-minute Wikideck block. Preparatory-lock i | Route | Possible guarantee | Named assumptions and release blockers | | --- | --- | --- | -| Conditional NFT-first adaptor/shared-key exchange | Potential atomic control exchange without host custody under stated protocol assumptions | Requires new NFT lock/redeem/cancel rules, compatible reviewed cryptography and keys, both-chain reorg/timing analysis, recoverable secrets and funded fee handling. Payment-to-conditional-output versus seller-net-direct semantics unresolved. Participant observations alone are not global payment-proof validation. | +| Conditional NFT-first adaptor/shared-key exchange | Potential atomic control exchange without host custody under stated protocol assumptions | Requires new NFT lock/redeem/cancel rules, compatible reviewed cryptography and keys, both-chain reorg/timing analysis, recoverable secrets and funded fee handling. Payment-to-conditional-output versus seller-net-direct semantics unresolved. Participant observations alone are not global payment-proof validation. The maintained Eigenwallet implementation reviewed is BTC→XMR, not this direction. | | Deterministic Monero evidence/light client | Validators could evaluate a precisely specified payment condition | Need canonical Monero chain selection/work/difficulty/upgrade verification, recipient/amount/output evidence, privacy disclosures, reorg handling and resource bounds. A header/txid or successful wallet-RPC response alone is insufficient. Not implemented or established feasible in scope. | | Host/committee attestation | Deterministic verification of an authorized signature | Verifies what an attestor asserted, not Monero truth. Theft/censorship/key compromise and cross-host trust remain. Not trustless; explicit approval required. | | Host custody plus chain-enforced lock | Coordinated payment/disbursement and refunds under an accountable host | Host can lose/steal/freeze XMR; lock releaser can censor or misrelease depending on rules. Requires explicit fallback approval, legal/policy/reserve controls and full journal/recovery design. Not selected. | +## Operator briefing: concrete flow choices (no choice made) + +These are architecture shapes for discussion, not approved protocol specifications. “Confirmed” must eventually be a named Monero-chain inclusion/depth/unlock policy; none is selected here. + +### A. Direct XMR payment, then seller delivery + +1. Seller commits a card to a preparatory Wikideck lock, with a deadline and no ownership transfer. +2. Buyer sends the signed, exact XMR price to the seller's ordinary address. +3. Buyer (or a service with sufficient view/proof data) supplies payment evidence. Some trusted observer or a future Monero verifier decides whether the XMR transaction is in the accepted chain and meets the policy. +4. Seller signs the ordinary NFT transfer. Wikideck accepts it only if the applicable confirmation evidence has been attached/validated under the selected rules; otherwise the lock eventually expires. + +**Guarantee:** the seller can receive XMR before delivering the card. A malicious seller can keep the payment, let the card lock expire, and never deliver. The NFT lock only prevents a competing card sale while it is live; it cannot reverse Monero. Seller signature or processor status does not fix this. This flow is not a trustless two-sided exchange. It may fit a deliberately seller-trusting product, but that would be a material policy change. + +### B. Custodial/host payment escrow (explicit fallback only) + +1. Seller locks the NFT. Buyer sends exact XMR to a host-controlled invoice/address or wallet. +2. Host observes and accounts for the incoming transaction; after its selected confirmation/unlock policy it directs NFT delivery and then sends seller/author/host allocations, or refunds if policy permits. +3. Wikideck completes the sale only under a host attestation or new host-controlled release rule. + +**Guarantee:** operationally straightforward comparison point; host controls or can censor the funds and may fail, be compromised, refuse a valid action, or become insolvent. Buyers trust its accounting, keys, confirmation observations, recovery process and dispute decisions. A host signature proves only that the host authorized the assertion. Independent market hosts each need their own limits and responsibility policy. This is custody/attestation, not trustless settlement. No approval to build or use it exists. + +### C. Conditional Monero funds + conditional NFT exchange (trustless target) + +1. Seller locks the NFT on Wikideck; it is unavailable for conflicting transfers but remains seller-owned. Buyer and seller commit to one order, exact amount, destinations, fees and two-chain time windows. +2. Buyer funds a Monero output whose spend path is jointly/cryptographically conditional, not an ordinary immediate payment to the seller. The parties verify inclusion and selected finality/unlock condition. +3. A reviewed cross-chain adaptor/shared-secret protocol couples the Monero release path to the NFT completion witness. The NFT sale is included only after the payment condition is satisfied; the secret/redeem path lets the seller claim XMR. A separately secured cancel/refund path must ensure buyer funds return and the lock releases if the sale cannot finish. +4. Wikideck validators check the exact new lock/completion/cancel rules. Monero state is either independently verified by each validator, or a specifically named participant proof/attestation assumption is accepted. These are different security models. + +**Potential guarantee:** no single party should be able to obtain both assets or take one asset while the other is permanently unrecoverable, within the explicitly modeled chain and liveness assumptions. **Not established:** the current chain has none of these transaction rules; no reference implementation was mapped for an NFT↔XMR trade; the maintained software reviewed covers BTC↔XMR, while the paper's XMR-first construction leaves Monero adaptor details as work in progress. Monero's lack of script timelocks means neither the NFT expiry nor a website timer can refund a Monero output by itself. The timeout/recovery construction is a cryptographic hard gate. + +| Failure after conditional funding | Must be solved before this can be called trustless | +| --- | --- | +| Buyer funds, then refuses to complete or disappears | Buyer must have an enforceable recover path after a specified condition, without seller stealing the funds; no unilateral host decision. | +| Seller locks NFT, then refuses to cooperate | Buyer must not lose the conditional funds forever; NFT lock expiry alone is insufficient. | +| One chain stalls or reorgs after the other chain advances | Lock windows and secret revelation order must preserve the option to complete or recover, with a quantified depth/liveness assumption and adversarial race tests. | +| Completion witness/secret leaks in a mempool or losing Wikideck fork | Reorg must not permit a party to take XMR and then restore NFT ownership, or otherwise claim both sides. | +| Refunder/publisher loses local wallet or settlement journal | Required protocol material and recovery authorization must survive; Monero chain replay may not reconstruct wallet-only transaction metadata. | +| Author/host fees or payout network cost consume the conditional amount | Define exact output split, fee payer, quote bounds and whether fee legs are part of the atomic protocol. No automatic subsidy or royalty enforcement outside recognized sale rules. | + +### Who verifies Monero? Separate consensus decision + +| Model | Validator action | Trust / privacy / cost | +| --- | --- | --- | +| Monero light client in every Wikideck validator | Validate headers/work/forks and the relevant payment proof under deterministic, versioned rules. | Strongest independent-chain verification option, if correctly implemented; large cryptographic, maintenance, resource, upgrade and consensus-complexity burden. The public transaction/recipient proof may expose linkage/privacy. Must study whether the evidence is actually succinct and complete for Monero's confidential outputs. | +| Participant-generated conditional authorization | Parties verify Monero in their own wallets and provide a protocol witness; Wikideck validates signatures/witness but not Monero's canonical chain itself. | Reduces node requirements but shifts chain observation/finality and censorship/eclipse assumptions to named parties. A signature cannot prove that the signed observation was true. Must analyze how an honest party can safely refuse/force progress. | +| Market-host/committee/oracle assertion | Validators check a configured public key's attestation. | Explicit third-party trust, censorship/key-compromise risk, and operator governance. Not the trustless option; selecting it is an explicit fallback. | + +No current Monero proof RPC response alone resolves this choice. `check_tx_proof` verifies a proof against the wallet/node's observed chain and reports confirmations/in-pool/received; it is not a proof that every independent Wikideck node can validate canonical Monero history. Its documentation also warns the received funds may not be spendable. A fixed threshold reduces but never eliminates reorg risk. + +## Payment proof + delivery record + reputation: useful deterrence, not atomic settlement + +The operator's proposed accountability route is technically plausible as a **market abuse-control layer**, with important limits: + +1. **Buyer supplies evidence:** Monero's [wallet RPC `get_tx_proof`](https://docs.getmonero.org/rpc-library/wallet-rpc/#get_tx_proof) creates a proof for a transaction and destination address; [`check_tx_proof`](https://docs.getmonero.org/rpc-library/wallet-rpc/#check_tx_proof) can report `good`, `received`, `confirmations` and `in_pool`. A proof's optional message can bind the displayed order ID/domain, helping deter reuse of evidence for a different sale. The verifier still needs a Monero wallet/node view of the chain to determine confirmation state; a copied RPC response is not a trustless proof of canonical chain inclusion to all Wikideck validators. The RPC docs explicitly warn that a valid proof does **not** prove associated funds are spendable. +2. **Delivery can be independently checked:** Wikideck's signed NFT transfer and canonical block are public under its chain rules. A market host can check whether the exact card/sequence moved to the buyer and whether it reached its own configured depth. Proving that delivery did **not** occur by a deadline is harder than showing an inclusion: the observer must have a complete, current canonical view, handle reorgs/partitions and allow for pending or late-signed transfers. A single host's assertion about absence is not consensus evidence. +3. **A host can record non-delivery:** under a disclosed process, one host could record “verified XMR payment; no matching canonical NFT delivery by deadline; seller wallet address flagged.” This may warn that host's users, enable local order throttling/denial and deter repeat abuse. It does not reverse XMR, provide compensation, force another peer to enforce the flag, prove the buyer was the sender, or prevent the seller from changing wallet/host. Monero's proof shows receipt to an address/amount, not a real-world person or necessarily the sender's identity. + +### Reputation and automatic-ban limits + +- Start with **evidence records and opt-in/filterable indicators**, not an automatic global wallet ban or a single opaque emoji score. Show denominators and outcome states: completed orders, seller cancellations, expired buyer checkouts, independently verified non-delivery findings, disputed/reorged/under-review cases and their age. Do not count unresolved or pending cases as proven fraud. +- A marketplace host can ban an address **locally**. The address can create another wallet, and another peer can admit it. A network-wide blacklist would require a shared authority/registry or new consensus and governance rules, be easy to Sybil-evade absent identity, and create censorship/false-positive risks. Do not imply a local block is a chain ban. +- Threshold auto-bans can be gamed: attackers create many new wallets, trade cheaply to build reputation, coordinate false claims, or target a seller with buyer-side mispayments and outage/reorg disputes. A threshold does not establish intent. Any punitive case needs evidence rules, seller response/appeal, expiry/correction, privacy limits and a responsible decision-maker. +- Publicly linking XMR proofs, an XMR destination, Wikideck wallet, card ID, counterparties and order IDs can substantially weaken transaction privacy. Make evidence disclosure explicit and minimize retention/access. Do not require buyers to publish transaction keys or proofs by default without a privacy review. +- A good reputation reduces perceived counterparty risk; it is not escrow, a guarantee of delivery, a refund, or proof for consensus. If a seller keeps direct XMR and withholds the card, a ban is at best an ex-post deterrent. For positive buyer protection, research must still solve conditional exchange or obtain approval for an accountable custodial arrangement. + +**Recommendation:** carry this as a separate reputation/abuse workstream for a future fixed-price product; do not select it as the payment-settlement architecture. First prototype evidence verification offline against disposable Monero fixtures (no live RPC/funds), threat-test both truthful and false disputes, and decide whether any status is host-local or portable. Keep M0's atomicity and validator-evidence decisions open. + +**Decision request remains open:** no live settlement flow or Monero verifier has been selected. Direct seller payment plus proof and reputation (A) may deter abuse but is not buyer-protecting; B is a custodial fallback; C is the researched direction intended to target two-sided atomic control, but remains unproven and may be impractical. The public marketplace now displays native-XMR prices under a mock-only v3 domain; actual XMR payments remain disabled and the separate SHKeeper prototype still takes USD. + ## Threat model and required failure evidence Assets: NFT ownership/availability, buyer gross payment, seller proceeds, host/author allocations, refund liabilities, wallet/conditional secrets, order privacy and durable recovery evidence. Independent hosts, miners, relayers, processors and user wallets are separate trust boundaries. An author commission does not underwrite hosts. @@ -63,9 +132,10 @@ Fixed host and 1% author allocations can be signed and validated for the selecte ## Next decision gates -1. Specify whether the confirmed payment is gross funding of a conditional structure or seller net directly, and whether every validator must verify Monero evidence rather than only selected participants doing so. Present concrete choices and privacy/liveness costs before seeking approval. -2. Map each required NFT-first transition and secret exposure to current maintained protocol/reference behavior; identify compatible crypto libraries, audit artifacts and licenses. No hand-written cryptographic substitute. -3. Build only a disposable, offline protocol/state-model spike after selecting its exact assumptions. Include abort, timeout, mempool leak, double-lock, reorg and recovery counterexamples; a happy-path demo cannot close M0. -4. Obtain specialist cryptographic/consensus review and operator ADR approval before any versioned consensus implementation. If no practical reviewed route meets requirements, request custodial/attested fallback approval with its explicit guarantees and liabilities. +1. **Payment flow:** a direct ordinary XMR payment to the seller followed by seller-authorized NFT delivery cannot protect a buyer from an uncooperative seller: a timeout can unlock the NFT but cannot reverse confirmed payment. Host escrow is simpler but custodial and not approved. Conditional/shared-key funds could target two-sided atomic control but are not seller-spendable proceeds until the protocol releases them. Current Eigenwallet documentation and implementation cover BTC→XMR; the paper's reverse direction leaves Monero adaptor details incomplete. No current reviewed reference was found for conditional XMR↔NFT exchange. Prepare the concrete conditional flow and its failure matrix before a choice is made. +2. **Consensus evidence:** every Wikideck validator can either run a versioned Monero verifier/light client (larger cryptographic, privacy, maintenance and consensus burden), or validate participant-generated signatures/witnesses without independently proving Monero canonicality (explicit participant-observation trust). A host/committee signature is an oracle/attestation trust assumption, not trustless verification. `check_tx_proof` RPC output is not itself a consensus-verifiable Monero canonical-chain proof. The operator asked to see concrete options before choosing either the payment flow or verifier model; both remain open. +3. Map each required NFT-first transition and secret exposure to current maintained protocol/reference behavior; identify compatible crypto libraries, audit artifacts and licenses. No hand-written cryptographic substitute. +4. Build only a disposable, offline protocol/state-model spike after selecting its exact assumptions. Include abort, timeout, mempool leak, double-lock, reorg and recovery counterexamples; a happy-path demo cannot close M0. +5. Obtain specialist cryptographic/consensus review and operator ADR approval before any versioned consensus implementation. If no practical reviewed route meets requirements, request custodial/attested fallback approval with its explicit guarantees and liabilities. -No lock code, consensus version change, production migration or payment activation is authorized by this research checkpoint. \ No newline at end of file +No lock code, consensus version change, production migration or payment activation is authorized by this research checkpoint. diff --git a/docs/xmr-domain.md b/docs/xmr-domain.md index befe374..9ccff57 100755 --- a/docs/xmr-domain.md +++ b/docs/xmr-domain.md @@ -1,6 +1,6 @@ # Native-XMR domain and migration specification — first increment -Status: exact-amount primitives implemented; **native-XMR marketplace migration and fee policy not delivered/approved**. See [production plan](production-plan.md) and [settlement investigation](settlement-feasibility.md). +Status: exact-amount primitives and version-3 **native-XMR mock marketplace pricing** are implemented. No live settlement architecture/payment, production accounting migration or approved network-cost policy is delivered. See [production plan](production-plan.md) and [settlement investigation](settlement-feasibility.md). ## Implemented amount contract @@ -29,9 +29,9 @@ This pure result is not a payable quote, does not estimate real network fees, an Pending operator decisions: author-leg cost allocation/caps, estimator/quote expiry, overrun handling and explicit tiny-checkout policy. The planning assumption is to block uneconomic checkout **before** accepting payment, with no subsidy, pooling or accumulated customer balances. No live checkout currently calls the budget helper. -## Versioned native-XMR terms — integration target, not an active schema +## Versioned terms -The eventual signed terms must use a new domain/schema version, not reinterpret `market-command-v2`. Select exact serialization after M0, binding at least: +New commands use `market-command-v3` and strictly bind network/chain, market, actor/public key, nonce, expiry, action, currency `XMR`, and canonical atomic gross for listing/bid/buy. List and buy terms bind the fixed author rate/address; listing terms bind hosting basis points. New public sale prices no longer use USD or a conversion rate. The current signed model is intentionally a **mock-market terms schema**, not yet the full production economic settlement commitment. The eventual production terms need a reviewed new protocol/domain version (not a silent reinterpretation of v3) binding at least: - network and chain identity, market and order/listing identity, nonce and expiry; - card hash/ownership sequence, seller and buyer authorization identities; @@ -41,17 +41,17 @@ The eventual signed terms must use a new domain/schema version, not reinterpret - payment destination/conditional-output commitment, payment-evidence/confirmation/unlock policy and selected exchange protocol version; - lock identity, release/refund/cancel conditions and relevant deadlines. -The buyer must review immutable exact terms and addresses; the seller must authorize network-cost exposure. Validate the supplied allocations by recomputation, not trust them because they are signed. A changed quote needs fresh authorization; callback status or later exchange-rate changes cannot amend it. No native-XMR signatures are generated by this increment. +The buyer must review immutable exact terms and addresses; the seller must authorize network-cost exposure. Validate supplied allocations by recomputation, not trust them because they are signed. A changed quote needs fresh authorization; callback status cannot amend it. The mock v3 checkout currently derives the quote only from the exact gross and hosting rate; it does not set live addresses or use an exchange rate. ## Non-destructive migration specification -1. Inventory copied, stopped-node market data by schema/order state; record counts, original terms/receipts and hashes. Keep existing recovery contract until a reviewed new schema includes all financial journals. No migration is run here. +1. Inventory copied, stopped-node market data by schema/order state; record counts, original terms/receipts and hashes. Keep existing recovery contract until a reviewed new schema includes all financial journals. The v3 runtime reads mixed old/new JSON rows without rewriting old USD rows. No destructive database migration is run here. 2. Keep historical USD/mock listings/orders and `market-command-v2` authorization material in their original version. Never convert `priceCents` into XMR, infer an old seller XMR address, synthesize a signature or issue a real invoice from old orders. -3. Disable new old-version checkouts during the eventual cutover. Reserved/in-flight mock orders require an explicit drain/cancel policy before migration; do not relist their cards while an old obligation remains active. Auctions/bids require disposition; do not silently convert them to funded live bids. -4. Close old open listings through an audited versioned process. Sellers relist with fresh native-XMR terms and authorization. Preserve old records and references; do not delete receipt/idempotency history. -5. Keep live auctions disabled. New fixed-price routes/UI/storage must integrate exact amounts, selected settlement evidence, index/accounting/recovery controls and disclosures together. Public UI remains explicitly USD/mock until this delivery is tested. -6. Dry-run on disposable copies; compare immutable old records and signatures, assert no legacy record reaches a real processor, test every in-flight state, interruption/restart and rollback. Before external effects, rollback may restore the stopped-node copy. After any real external effect, a local DB rollback alone is unsafe: reconcile external history first and retain authoritative intents/events. +3. New v2 USD listing creation, bids and checkout are rejected. Existing v2 reserved orders can still complete their old **mock** pay/refund/delivery route with v2 signatures. This is compatibility, not an external payment obligation. Do not relist their cards while an order is reserved/in flight. +4. Old open USD listings are displayed as legacy/read-only; where eligible (seller-owned and no bid), the seller can close the listing with a fresh v3 authorization. Auctions with bids cannot be retired through this action and expire under the original sandbox rule. Sellers create a new XMR listing with a new v3 signature. Old records and receipts remain; no price conversion or automatic bid transfer. +5. Fixed-price and sandbox-auction v3 terms are native-XMR mock routes. No real payment processor can use them. Before live fixed-price delivery, selected settlement evidence, index/accounting/recovery controls, policy and disclosures still require integrated implementation and review; live auctions remain a separate later gate. +6. Full stopped-copy migration dry-run, complete mixed-record upgrade/restart tests, rollback rehearsal and journal hash/count evidence remain outstanding. No external effects occur in this mock path. Before any future real external effect, a local DB rollback alone is unsafe: reconcile external history first and retain authoritative intents/events. -Acceptance remains open: actual versioned schema/signature tests, copied-data migration executable/dry run, UI review, recovery/invariant tests and approved fee/settlement semantics. [Amount tests](../tests/xmr-amount.test.ts) cover boundary/coercion/newline/precision cases, RPC range, independent rounding, fee-budget exhaustion/spikes, 4,000 deterministic generated conservation/round-trip cases and original mock caller behavior. They are not settlement feasibility or launch evidence. +Versioned XMR signature tests, direct HTTP v3 integration, one-atomic-unit no-minimum fee tests, and legacy-order/listing compatibility tests cover this increment. A full stop-copy data migration dry-run, interrupted-upgrade/rollback evidence, approved network-cost policy, live processor integration and settlement are not covered. [Amount tests](../tests/xmr-amount.test.ts) cover boundary/coercion/newline/precision cases, RPC range, independent rounding, fee-budget exhaustion/spikes, 4,000 deterministic generated conservation/round-trip cases and original mock caller behavior. They are not settlement feasibility or launch evidence. -Validation for this increment (2026-10-20): Docker test-target build passed TypeScript and Vite compilation; the full suite ran with external networking disabled and passed **130 tests, 0 failures, 0 skips**. Includes adjacent USD processor final-newline hardening. This evidence does not close the remaining M0/M1 integration and policy gates. \ No newline at end of file +Validation for this increment (2026-10-20): Docker test-target build passed TypeScript and Vite compilation; the full suite ran with external networking disabled and passed **133 tests, 0 failures, 0 skips**. Includes API v3 signing, one-atomic minimum, legacy read-only listing retirement, and in-flight USD order restart compatibility. This evidence does not close the remaining M0 settlement, fee-policy, data-copy dry-run or launch gates. diff --git a/server/market/payments.ts b/server/market/payments.ts index e28877b..cf11d8a 100755 --- a/server/market/payments.ts +++ b/server/market/payments.ts @@ -1,5 +1,5 @@ import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; -import { formatXmr, XMR_ATOMIC_UNITS } from '../../shared/xmr-amount.ts'; +import { formatXmr, parseAtomicXmr, XMR_ATOMIC_UNITS } from '../../shared/xmr-amount.ts'; import { allocateXmrSale } from '../../shared/xmr-pricing.ts'; /** No live payment adapter is reachable in this release. Never return a payable mock address. */ @@ -21,3 +21,14 @@ export function mockQuote(priceCents: number, feeBps: number, usdCentsPerXmr: nu feeXmr: xmrString(fee), sellerNetXmr: xmrString(sellerBeforeNetworkCosts), additionalFee: { ...ADDITIONAL_MARKET_FEE, amountXmr: xmrString(additionalFee), payout: 'not-started' as const } }; } + +/** Native-XMR sandbox quote: exact listed gross, with no exchange-rate conversion. */ +export function mockXmrQuote(priceAtomicXmr: string, feeBps: number) { + const gross = parseAtomicXmr(priceAtomicXmr); + if (gross === 0n) throw new Error('Sale price must be positive'); + const { hosting: fee, author: additionalFee, sellerBeforeNetworkCosts } = allocateXmrSale(gross, feeBps); + return { invoice: { provider: 'mock' as const, amountXmr: formatXmr(gross), address: null, + label: 'SIMULATION ONLY — no Monero payment address. Never send real XMR.' }, + feeXmr: formatXmr(fee), sellerNetXmr: formatXmr(sellerBeforeNetworkCosts), + additionalFee: { ...ADDITIONAL_MARKET_FEE, amountXmr: formatXmr(additionalFee), payout: 'not-started' as const } }; +} diff --git a/server/market/store.ts b/server/market/store.ts index d457a8a..260aba1 100755 --- a/server/market/store.ts +++ b/server/market/store.ts @@ -5,12 +5,15 @@ import { join } from 'node:path'; import { NETWORK, CONSENSUS, nftTransferMessage, testnetOwnerOf, transactionCardIds, type TestnetSnapshot, type NftTransfer } from '../../shared/testnet.ts'; import { ADDITIONAL_MARKET_FEE, marketCommandSchema, marketMessage, type MarketCommand, type MarketListing, - type MarketOrder, type MarketInfo, type MarketActivity } from '../../shared/market.ts'; + type MarketOrder, type MarketInfo } from '../../shared/market.ts'; +import { isXmrListing, isXmrOrder, marketCommandV3Schema, marketMessageV3, + type MarketActivityV3, type MarketCommandV3, type MarketListingRecord, type MarketListingV3, + type MarketOrderRecord, type MarketOrderV3 } from '../../shared/market-v3.ts'; import { CHAIN_ID, transactionId, transferSchema, verifySignature } from '../testnet/consensus.ts'; -import { mockQuote, paymentMode } from './payments.ts'; +import { mockQuote, mockXmrQuote, paymentMode } from './payments.ts'; type Chain = { snapshot(): TestnetSnapshot; summary?(): { tip: string; revision: number }; submitTransfer(raw: unknown): Promise }; -type Config = { feeBps: number; confirmations: number; usdCentsPerXmr: number }; +type Config = { feeBps: number; confirmations: number }; export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config { paymentMode(env); const integer = (name: string, fallback: number, min: number, max: number) => { @@ -21,8 +24,7 @@ export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config { return n; }; return { feeBps: integer('WIKIDECK_MARKET_FEE_BPS', 250, 0, 5000), - confirmations: integer('WIKIDECK_MARKET_CONFIRMATIONS', 10, 2, 1000), - usdCentsPerXmr: integer('WIKIDECK_MOCK_USD_CENTS_PER_XMR', 15000, 1, 100_000_000) }; + confirmations: integer('WIKIDECK_MARKET_CONFIRMATIONS', 10, 2, 1000) }; } const PAYMENT_WINDOW = 30 * 60_000; const DELIVERY_WINDOW = 48 * 60 * 60_000; @@ -78,29 +80,29 @@ export class Market { return { queued:this.queued,lastReconciledAt:this.reconciled?.at??null,orderStates:states }; } info(): MarketInfo { - return { network: NETWORK, chainId: CHAIN_ID, marketId: this.id, paymentMode: 'mock', feeBps: this.config.feeBps, + return { network: NETWORK, chainId: CHAIN_ID, marketId: this.id, paymentMode: 'mock', marketVersion: 3, feeBps: this.config.feeBps, additionalFee: { ...ADDITIONAL_MARKET_FEE }, - deliveryConfirmations: this.config.confirmations, mockUsdPerXmr: (this.config.usdCentsPerXmr / 100).toFixed(2), serverTime: this.now() }; + deliveryConfirmations: this.config.confirmations, serverTime: this.now() }; } private rows(table: 'listings' | 'orders'): T[] { return (this.db.prepare(`SELECT data FROM ${table} ORDER BY rowid DESC LIMIT 10000`).all() as { data: string }[]) .map(row => JSON.parse(row.data) as T); } - private listing(id: string): MarketListing { + private listing(id: string): MarketListingRecord { const row = this.db.prepare('SELECT data FROM listings WHERE id=?').get(id) as { data: string } | undefined; if (!row) throw new Error('Listing not found'); - return JSON.parse(row.data); + return JSON.parse(row.data) as MarketListingRecord; } - private order(id: string): MarketOrder { + private order(id: string): MarketOrderRecord { const row = this.db.prepare('SELECT data FROM orders WHERE id=?').get(id) as { data: string } | undefined; if (!row) throw new Error('Order not found'); - return JSON.parse(row.data); + return JSON.parse(row.data) as MarketOrderRecord; } - private saveListing(l: MarketListing) { + private saveListing(l: MarketListingRecord) { this.db.prepare('INSERT INTO listings VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data WHERE listings.data<>excluded.data') .run(l.id, l.cardHash, l.status, JSON.stringify(l)); } - private saveOrder(o: MarketOrder) { + private saveOrder(o: MarketOrderRecord) { this.db.prepare('INSERT INTO orders VALUES(?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE orders.data<>excluded.data').run(o.id, JSON.stringify(o)); } private atomic(fn: () => T): T { @@ -114,7 +116,7 @@ export class Market { snapshot.transfers.filter(tx => transactionCardIds(tx).includes(hash)).length === sequence && snapshot.blocks.length + 1 - block.height >= CONSENSUS.coinbaseMaturity; } - private deliveryId(order: MarketOrder): string { + private deliveryId(order: MarketOrderRecord): string { // Identity excludes signature bytes. Recognize exact sale delivery through ANY submission route, // including peer gossip or a direct /api/transfer, even before the market records delivery intent. return transactionId({ network: NETWORK, chainId: CHAIN_ID, cardHash: order.cardHash, @@ -127,7 +129,7 @@ export class Market { for (const b of s.blocks) for (const tx of b.transactions) deliveries.set(transactionId(tx), b); const pendingIds = new Set(s.pendingTransfers.map(transactionId)); this.atomic(() => { - for (const o of this.rows('orders')) { + for (const o of this.rows('orders')) { const l = this.listing(o.listingId); if (o.status === 'refunded' || o.status === 'expired') continue; const deliveryId = this.deliveryId(o); @@ -162,7 +164,7 @@ export class Market { } this.saveOrder(o); this.saveListing(l); } - for (const l of this.rows('listings')) { + for (const l of this.rows('listings')) { if (l.status !== 'open') continue; if (l.additionalFee?.bps !== ADDITIONAL_MARKET_FEE.bps || l.additionalFee?.address !== ADDITIONAL_MARKET_FEE.address || !this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) l.status = 'invalid'; @@ -175,7 +177,7 @@ export class Market { const summary = this.chain.summary?.(); if (summary) this.reconciled = { ...summary, at: now }; } - browse(offset = 0): { listings: MarketListing[]; next: number | null; info: MarketInfo } { + browse(offset = 0): { listings: MarketListingRecord[]; next: number | null; info: MarketInfo } { if (!Number.isSafeInteger(offset) || offset < 0 || offset > 10000) throw new Error('Invalid marketplace page'); // Reconciliation must not interleave with delivery submission while its chain await is outstanding. const summary = this.chain.summary?.(); @@ -185,8 +187,9 @@ export class Market { .all(offset) as { data: string }[]; return { listings: rows.slice(0,24).map(r=>JSON.parse(r.data)), next: rows.length>24?offset+24:null, info: this.info() }; } - execute(raw: unknown): Promise { - const cmd = marketCommandSchema.parse(raw); + execute(raw: unknown): Promise { + const current = marketCommandV3Schema.safeParse(raw); + const cmd = current.success ? current.data : marketCommandSchema.parse(raw); if (this.queued >= 64) return Promise.reject(new Error('Marketplace busy; retry shortly')); this.queued++; const result = this.queue.then(async () => { @@ -196,11 +199,13 @@ export class Market { this.queue = result.catch(() => undefined); return result; } - private async run(c: MarketCommand): Promise { + private async run(c: MarketCommand | MarketCommandV3): Promise { const now = this.now(); + const isV3 = 'version' in c; + const message = isV3 ? marketMessageV3(c) : marketMessage(c); if (c.chainId !== CHAIN_ID || c.marketId !== this.id || c.expiresAt < now || c.expiresAt > now + 5 * 60_000 || - !verifySignature(c.publicKey, c.actor, marketMessage(c), c.signature)) throw new Error('Invalid or expired marketplace signature'); - const key = `${c.actor}:${c.nonce}`, digest = marketMessage(c); + !verifySignature(c.publicKey, c.actor, message, c.signature)) throw new Error('Invalid or expired marketplace signature'); + const key = `${c.actor}:${c.nonce}`, digest = message; const old = this.db.prepare('SELECT digest,data FROM receipts WHERE id=?').get(key) as { digest: string; data: string } | undefined; if (old) { if (old.digest !== digest) throw new Error('Nonce already used for different terms'); @@ -212,12 +217,12 @@ export class Market { this.now() - last.at >= 1000) this.reconcile(); if (a.type === 'activity') return { orders: (this.db.prepare("SELECT data FROM orders WHERE json_extract(data,'$.seller')=? OR json_extract(data,'$.buyer')=? ORDER BY rowid DESC") - .all(c.actor,c.actor) as { data: string }[]).map(r=>JSON.parse(r.data)), + .all(c.actor,c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketOrderRecord), listings: (this.db.prepare("SELECT data FROM listings WHERE json_extract(data,'$.seller')=? ORDER BY rowid DESC") - .all(c.actor) as { data: string }[]).map(r=>JSON.parse(r.data)) }; + .all(c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketListingRecord) }; if (a.type === 'deliver') { const o = this.order(a.orderId), tx = transferSchema.parse(a.transfer); - if (o.seller !== c.actor || !['paid', 'delivery-pending'].includes(o.status)) throw new Error('Only the paid order seller can deliver'); + if (isV3 && !isXmrOrder(o) || !isV3 && isXmrOrder(o) || o.seller !== c.actor || !['paid', 'delivery-pending'].includes(o.status)) throw new Error('Only the paid order seller can deliver under the order terms'); if (tx.cardHash !== o.cardHash || tx.sequence !== o.sequence || tx.from !== o.seller || tx.to !== o.buyer || !verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)) throw new Error('Delivery does not match this sale'); // Persist intent before the ledger await. A crash/restart reconciles by deterministic tx ID. @@ -226,7 +231,7 @@ export class Market { return this.receipt(key, digest, c.expiresAt, o); } return this.atomic(() => { - const result = this.mutate(c); + const result = isV3 ? this.mutateV3(c) : this.mutateV2(c); return this.receipt(key, digest, c.expiresAt, result); }); } @@ -234,51 +239,20 @@ export class Market { this.db.prepare('INSERT INTO receipts VALUES(?,?,?,?)').run(key, digest, expires, JSON.stringify(value)); return value; } - private mutate(c: MarketCommand): MarketListing | MarketOrder { + private mutateV2(c: MarketCommand): MarketActivityV3 | MarketListingRecord | MarketOrderRecord { const a = c.action, now = this.now(), s = this.chain.snapshot(); if (a.type === 'list') { - if (a.feeBps !== this.config.feeBps) throw new Error('Market fee changed; review and sign again'); - if (!this.card(s, a.cardHash, c.actor, a.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(a.cardHash))) throw new Error('Card is missing, stale, immature, or already pending'); - if (a.kind === 'fixed' ? a.endsAt !== null : a.endsAt === null || a.endsAt < now + 60_000 || a.endsAt > now + 30 * 86400_000) throw new Error('Auction duration must be 1 minute to 30 days; fixed listings have no end'); - if (this.rows('listings').filter(l => l.seller === c.actor && ['open','reserved'].includes(l.status)).length >= 100) throw new Error('Limit: 100 active listings per seller'); - if ((this.db.prepare('SELECT COUNT(*) AS n FROM listings').get() as { n: number }).n >= 10000) throw new Error('Marketplace journal limit reached; operator archival required'); - if (this.db.prepare("SELECT 1 FROM listings WHERE card=? AND status IN ('open','reserved')").get(a.cardHash)) throw new Error('Card already listed'); - const mint = s.blocks.find(b => b.tokenId === a.cardHash)!; - const l: MarketListing = { id: randomUUID(), cardHash: a.cardHash, sequence: a.sequence, seller: c.actor, - title: mint.article.title, image: mint.article.image!, kind: a.kind, priceCents: a.priceCents, - feeBps: a.feeBps, additionalFee: { ...ADDITIONAL_MARKET_FEE }, - createdAt: now, endsAt: a.endsAt, status: 'open', highestBid: null }; - this.saveListing(l); return l; + throw new Error('USD listing terms are retired; create a new native-XMR listing'); } - if (a.type === 'bid' || a.type === 'buy' || a.type === 'cancel') { + if (a.type === 'cancel') { const l = this.listing(a.listingId); - if (l.status !== 'open') throw new Error('Listing is no longer available'); - if (a.type === 'cancel') { - if (l.seller !== c.actor || l.highestBid) throw new Error('Only the seller can cancel, and auctions with bids cannot be cancelled'); - l.status = 'cancelled'; this.saveListing(l); return l; - } - if (l.seller === c.actor) throw new Error('Cannot buy or bid on your own card'); - if (a.type === 'bid') { - if (l.kind !== 'auction' || now >= l.endsAt!) throw new Error('Auction is not accepting bids'); - const minimum = l.highestBid ? l.highestBid.priceCents + 1 : l.priceCents; - if (a.priceCents < minimum) throw new Error('Bid must meet the start price and exceed the current bid by at least $0.01'); - l.highestBid = { bidder: c.actor, priceCents: a.priceCents }; this.saveListing(l); return l; - } - const price = l.kind === 'fixed' ? l.priceCents : l.highestBid?.priceCents; - if (l.kind === 'auction' && (now < l.endsAt! || now >= l.endsAt! + PAYMENT_WINDOW || l.highestBid?.bidder !== c.actor)) throw new Error('Only the auction winner can check out within 30 minutes of closing'); - if (price !== a.priceCents || l.feeBps !== a.feeBps) throw new Error('Sale terms changed; review and sign again'); - if (!this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) throw new Error('Card is no longer available'); - if ((this.db.prepare('SELECT COUNT(*) AS n FROM orders').get() as { n: number }).n >= 10000) throw new Error('Marketplace order limit reached; operator archival required'); - const payBy = l.kind === 'auction' ? l.endsAt! + PAYMENT_WINDOW : now + PAYMENT_WINDOW; - const o: MarketOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: c.actor, - cardHash: l.cardHash, sequence: l.sequence, priceCents: price!, feeBps: l.feeBps, createdAt: now, payBy, - deliverBy: payBy + DELIVERY_WINDOW, status: 'awaiting-payment', - ...mockQuote(price!, l.feeBps, this.config.usdCentsPerXmr), payout: 'not-started', deliveryConfirmations: 0, - requiredConfirmations: this.config.confirmations }; - l.status = 'reserved'; this.saveListing(l); this.saveOrder(o); return o; + if (isXmrListing(l) || l.status !== 'open') throw new Error('USD legacy listing cannot be changed under this command'); + if (l.seller !== c.actor || l.highestBid) throw new Error('Only the seller can cancel, and auctions with bids cannot be cancelled'); + l.status = 'cancelled'; this.saveListing(l); return l; } if (a.type === 'mock-pay' || a.type === 'refund') { const o = this.order(a.orderId); + if (isXmrOrder(o)) throw new Error('Use native-XMR signed terms for this order'); if (o.buyer !== c.actor) throw new Error('Only the order buyer can perform this action'); if (a.type === 'mock-pay') { if (o.status !== 'awaiting-payment' || now >= o.payBy) throw new Error('Invoice is not payable'); @@ -291,6 +265,83 @@ export class Market { } this.saveOrder(o); return o; } - throw new Error('Unsupported market action'); + if (a.type === 'activity') return { + orders: (this.db.prepare("SELECT data FROM orders WHERE json_extract(data,'$.seller')=? OR json_extract(data,'$.buyer')=? ORDER BY rowid DESC") + .all(c.actor,c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketOrderRecord), + listings: (this.db.prepare("SELECT data FROM listings WHERE json_extract(data,'$.seller')=? ORDER BY rowid DESC") + .all(c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketListingRecord) }; + if (a.type === 'deliver') throw new Error('Legacy delivery is handled by the common signed-order delivery path'); + throw new Error('Legacy USD bids and checkout are retired; only already-created orders may proceed'); + } + + private mutateV3(c: MarketCommandV3): MarketActivityV3 | MarketListingRecord | MarketOrderRecord { + const a = c.action, now = this.now(), s = this.chain.snapshot(); + if (a.type === 'list') { + if (a.feeBps !== this.config.feeBps) throw new Error('Market fee changed; review and sign again'); + if (!this.card(s, a.cardHash, c.actor, a.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(a.cardHash))) throw new Error('Card is missing, stale, immature, or already pending'); + if (a.kind === 'fixed' ? a.endsAt !== null : a.endsAt === null || a.endsAt < now + 60_000 || a.endsAt > now + 30 * 86400_000) throw new Error('Auction duration must be 1 minute to 30 days; fixed listings have no end'); + if (this.rows('listings').filter(l => l.seller === c.actor && ['open','reserved'].includes(l.status)).length >= 100) throw new Error('Limit: 100 active listings per seller'); + if ((this.db.prepare('SELECT COUNT(*) AS n FROM listings').get() as { n: number }).n >= 10000) throw new Error('Marketplace journal limit reached; operator archival required'); + if (this.db.prepare("SELECT 1 FROM listings WHERE card=? AND status IN ('open','reserved')").get(a.cardHash)) throw new Error('Card already listed'); + const mint = s.blocks.find(b => b.tokenId === a.cardHash)!; + const l: MarketListingV3 = { id: randomUUID(), termsVersion: 3, cardHash: a.cardHash, sequence: a.sequence, + seller: c.actor, title: mint.article.title, image: mint.article.image!, kind: a.kind, + priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, additionalFee: { ...ADDITIONAL_MARKET_FEE }, + createdAt: now, endsAt: a.endsAt, status: 'open', highestBid: null }; + this.saveListing(l); return l; + } + if (a.type === 'retire-legacy-listing') { + const listing = this.listing(a.listingId); + if (isXmrListing(listing)) throw new Error('Native-XMR listings use the signed cancel action'); + if (listing.seller !== c.actor || !['open','invalid'].includes(listing.status)) throw new Error('Only the seller can retire an unreserved legacy listing'); + if (listing.highestBid) throw new Error('Legacy auction has bids; resolve or wait for its original terms before retirement'); + if (this.db.prepare("SELECT 1 FROM orders WHERE json_extract(data,'$.listingId')=? AND json_extract(data,'$.status') IN ('awaiting-payment','paid','delivery-pending','refund-required','review') LIMIT 1").get(listing.id)) { + throw new Error('Legacy listing has an in-flight order; preserve that order under its original terms'); + } + listing.status = 'cancelled'; this.saveListing(listing); return listing; + } + if (a.type === 'bid' || a.type === 'buy' || a.type === 'cancel') { + const l = this.listing(a.listingId); + if (!isXmrListing(l)) throw new Error('Legacy USD listings are read-only; seller must retire and relist with fresh XMR authorization'); + if (l.status !== 'open') throw new Error('Listing is no longer available'); + if (a.type === 'cancel') { + if (l.seller !== c.actor || l.highestBid) throw new Error('Only the seller can cancel, and auctions with bids cannot be cancelled'); + l.status = 'cancelled'; this.saveListing(l); return l; + } + if (l.seller === c.actor) throw new Error('Cannot buy or bid on your own card'); + if (a.type === 'bid') { + if (l.kind !== 'auction' || now >= l.endsAt!) throw new Error('Auction is not accepting bids'); + const minimum = l.highestBid ? BigInt(l.highestBid.priceAtomicXmr) + 1n : BigInt(l.priceAtomicXmr); + if (BigInt(a.priceAtomicXmr) < minimum) throw new Error('Bid must meet the start price and exceed the current bid by one atomic unit'); + l.highestBid = { bidder: c.actor, priceAtomicXmr: a.priceAtomicXmr }; this.saveListing(l); return l; + } + const price = l.kind === 'fixed' ? l.priceAtomicXmr : l.highestBid?.priceAtomicXmr; + if (l.kind === 'auction' && (now < l.endsAt! || now >= l.endsAt! + PAYMENT_WINDOW || l.highestBid?.bidder !== c.actor)) throw new Error('Only the auction winner can check out within 30 minutes of closing'); + if (price !== a.priceAtomicXmr || l.feeBps !== a.feeBps) throw new Error('Sale terms changed; review and sign again'); + if (!this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) throw new Error('Card is no longer available'); + if ((this.db.prepare('SELECT COUNT(*) AS n FROM orders').get() as { n: number }).n >= 10000) throw new Error('Marketplace order limit reached; operator archival required'); + const payBy = l.kind === 'auction' ? l.endsAt! + PAYMENT_WINDOW : now + PAYMENT_WINDOW; + const order: MarketOrderV3 = { id: randomUUID(), termsVersion: 3, listingId: l.id, seller: l.seller, buyer: c.actor, + cardHash: l.cardHash, sequence: l.sequence, priceAtomicXmr: price!, feeBps: l.feeBps, createdAt: now, payBy, + deliverBy: payBy + DELIVERY_WINDOW, status: 'awaiting-payment', ...mockXmrQuote(price!, l.feeBps), + payout: 'not-started', deliveryConfirmations: 0, requiredConfirmations: this.config.confirmations }; + l.status = 'reserved'; this.saveListing(l); this.saveOrder(order); return order; + } + if (a.type === 'mock-pay' || a.type === 'refund') { + const order = this.order(a.orderId); + if (!isXmrOrder(order)) throw new Error('Legacy USD orders keep their original terms and cannot be changed through native-XMR actions'); + if (order.buyer !== c.actor) throw new Error('Only the order buyer can perform this action'); + if (a.type === 'mock-pay') { + if (order.status !== 'awaiting-payment' || now >= order.payBy) throw new Error('Invoice is not payable'); + if (!this.card(s, order.cardHash, order.seller, order.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(order.cardHash))) throw new Error('Seller card changed; do not pay'); + order.status = 'paid'; order.deliverBy = now + DELIVERY_WINDOW; + } else { + if (order.status !== 'refund-required' || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(order.cardHash)) || + s.blocks.some(b => b.transactions.some(tx => transactionId(tx) === this.deliveryId(order)))) throw new Error('Refund requires failed delivery and no pending/canonical sale transfer'); + order.status = 'refunded'; const listing = this.listing(order.listingId); listing.status = 'cancelled'; this.saveListing(listing); + } + this.saveOrder(order); return order; + } + throw new Error('Unsupported native-XMR market action'); } } diff --git a/server/testnet/http.ts b/server/testnet/http.ts index 38927a7..d988719 100644 --- a/server/testnet/http.ts +++ b/server/testnet/http.ts @@ -16,6 +16,7 @@ import { snapshotStats } from '../../shared/enrichment.ts'; import { wikipediaArticles } from '../wiki.ts'; import { Market, marketConfig } from '../market/store.ts'; import { marketCommandSchema } from '../../shared/market.ts'; +import { marketCommandV3Schema } from '../../shared/market-v3.ts'; import { IS_MAINNET, PROFILE } from '../../shared/network.ts'; import { assertLaunchAllowed, launchManifest } from '../launch.ts'; import { resourceConfig, RpcBudget, RpcError } from '../resources.ts'; @@ -83,7 +84,8 @@ app.get('/api/chain',(_req,res)=>res.json(ledger.legacySnapshot(limits.legacyBlo app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()})); app.get('/api/market', (req,res)=>res.json(market.browse(Number(req.query.offset ?? 0)))); app.post('/api/market/command',rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}),async(req,res)=>{ - const command = marketCommandSchema.parse(req.body); + const current = marketCommandV3Schema.safeParse(req.body); + const command = current.success ? current.data : marketCommandSchema.parse(req.body); try { const result = await market.execute(command); res.json(result); diff --git a/shared/market-v3.ts b/shared/market-v3.ts new file mode 100755 index 0000000..4c3ed31 --- /dev/null +++ b/shared/market-v3.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; +import { NETWORK, type NftTransfer } from './testnet.ts'; +import { ADDITIONAL_MARKET_FEE, type MarketInfo, type MarketListing, type MarketOrder } from './market.ts'; +import { MAX_XMR_AMOUNT } from './xmr-amount.ts'; + +const id = z.string().uuid(); +const hex = z.string().regex(/^[a-f0-9]{64}$/); +const atomicPrice = z.string().regex(/^(0|[1-9]\d{0,21})$/).refine(value => { + try { + const amount = BigInt(value); + return amount > 0n && amount <= MAX_XMR_AMOUNT; + } catch { return false; } +}, 'XMR price must be a positive bounded atomic amount'); +const feeBps = z.number().int().min(0).max(5000); + +export const marketActionV3Schema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('list'), cardHash: hex, sequence: z.number().int().nonnegative().safe(), + kind: z.enum(['fixed', 'auction']), priceAtomicXmr: atomicPrice, endsAt: z.number().int().nonnegative().nullable(), feeBps }).strict(), + z.object({ type: z.literal('bid'), listingId: id, priceAtomicXmr: atomicPrice }).strict(), + z.object({ type: z.literal('buy'), listingId: id, priceAtomicXmr: atomicPrice, feeBps }).strict(), + z.object({ type: z.literal('cancel'), listingId: id }).strict(), + z.object({ type: z.literal('retire-legacy-listing'), listingId: id }).strict(), + z.object({ type: z.literal('mock-pay'), orderId: id }).strict(), + z.object({ type: z.literal('refund'), orderId: id }).strict(), + z.object({ type: z.literal('deliver'), orderId: id, transfer: z.object({ network: z.literal(NETWORK), + chainId: hex, cardHash: hex, from: hex, to: hex, sequence: z.number().int().nonnegative().safe(), + publicKey: z.string().max(256), signature: z.string().max(128) }).strict() }).strict(), + z.object({ type: z.literal('activity') }).strict(), +]); +export type MarketActionV3 = z.infer; +export const marketCommandV3Schema = z.object({ version: z.literal(3), network: z.literal(NETWORK), chainId: hex, + marketId: id, actor: hex, publicKey: z.string().max(256), nonce: id, expiresAt: z.number().int().nonnegative().safe(), + action: marketActionV3Schema, signature: z.string().max(128) }).strict(); +export type MarketCommandV3 = z.infer; + +/** New domain: price and currency are exact XMR atomic units, never fiat-converted. */ +export function marketMessageV3(command: Omit): string { + const a = command.action; + const terms = a.type === 'list' ? [a.cardHash, a.sequence, a.kind, 'XMR', a.priceAtomicXmr, a.endsAt, a.feeBps] + : a.type === 'bid' ? [a.listingId, 'XMR', a.priceAtomicXmr] + : a.type === 'buy' ? [a.listingId, 'XMR', a.priceAtomicXmr, a.feeBps] + : a.type === 'cancel' || a.type === 'retire-legacy-listing' ? [a.listingId] + : a.type === 'activity' ? [] + : a.type === 'deliver' ? [a.orderId, a.transfer.network, a.transfer.chainId, a.transfer.cardHash, + a.transfer.from, a.transfer.to, a.transfer.sequence, a.transfer.publicKey, a.transfer.signature] + : [a.orderId]; + const feeTerms = a.type === 'list' || a.type === 'buy' ? [ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address] : []; + return JSON.stringify([NETWORK, 'market-command-v3', command.version, command.chainId, command.marketId, + command.actor, command.publicKey, command.nonce, command.expiresAt, a.type, terms, feeTerms]); +} + +export type MarketListingV3 = Omit & { + termsVersion: 3; priceAtomicXmr: string; + highestBid: { bidder: string; priceAtomicXmr: string } | null; +}; +export type MarketOrderV3 = Omit & { termsVersion: 3; priceAtomicXmr: string }; +export type MarketListingRecord = MarketListing | MarketListingV3; +export type MarketOrderRecord = MarketOrder | MarketOrderV3; +export type MarketInfoV3 = MarketInfo & { marketVersion: 3 }; +export type MarketActivityV3 = { listings: MarketListingRecord[]; orders: MarketOrderRecord[] }; + +export function isXmrListing(listing: MarketListingRecord): listing is MarketListingV3 { + return 'priceAtomicXmr' in listing; +} +export function isXmrOrder(order: MarketOrderRecord): order is MarketOrderV3 { + return 'priceAtomicXmr' in order; +} diff --git a/shared/market.ts b/shared/market.ts index cc11447..f8d5630 100755 --- a/shared/market.ts +++ b/shared/market.ts @@ -60,7 +60,8 @@ export type MarketOrder = { additionalFee?: AdditionalFeePolicy & { amountXmr: string; payout: 'not-started' | 'mock-paid' | 'review' }; }; export type MarketInfo = { network: string; chainId: string; marketId: string; paymentMode: 'mock'; - feeBps: number; additionalFee: AdditionalFeePolicy; deliveryConfirmations: number; mockUsdPerXmr: string; serverTime: number }; + marketVersion: 3; feeBps: number; additionalFee: AdditionalFeePolicy; deliveryConfirmations: number; + serverTime: number }; export type MarketActivity = { orders: MarketOrder[]; listings: MarketListing[] }; export function formatUsd(cents: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(cents / 100); diff --git a/shared/xmr-amount.ts b/shared/xmr-amount.ts index a4623a9..6e293e6 100755 --- a/shared/xmr-amount.ts +++ b/shared/xmr-amount.ts @@ -52,4 +52,4 @@ export function xmrCommission(gross: bigint, bps: number): bigint { assertXmrAmount(gross); if (!Number.isSafeInteger(bps) || bps < 0 || bps > 10_000) throw new Error('Invalid commission basis points'); return gross * BigInt(bps) / 10_000n; -} \ No newline at end of file +} diff --git a/shared/xmr-pricing.ts b/shared/xmr-pricing.ts index 7471d85..e7f5ce2 100755 --- a/shared/xmr-pricing.ts +++ b/shared/xmr-pricing.ts @@ -27,4 +27,4 @@ export function assessXmrSaleBudget(gross: bigint, hostingBps: number, sellerNet sellerMinimumNet: viable ? available - sellerNetworkBudget : 0n, // Additional gross-independent funding required to retain at least one atomic unit. fundingShortfall: viable ? 0n : sellerNetworkBudget - available + 1n }; -} \ No newline at end of file +} diff --git a/src/Marketplace.tsx b/src/Marketplace.tsx index 91bea54..fd64cb5 100755 --- a/src/Marketplace.tsx +++ b/src/Marketplace.tsx @@ -1,14 +1,17 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Check, Clock3, Gavel, LoaderCircle, RefreshCw, ShoppingBag, Tag } from 'lucide-react'; import { NETWORK, CONSENSUS, nftTransferMessage, testnetOwnerOf, transactionCardIds, type TestnetSnapshot } from '../shared/testnet.ts'; -import { ADDITIONAL_MARKET_FEE, formatUsd, parseUsd, marketMessage, type MarketAction, type MarketInfo, type MarketListing, - type MarketOrder, type MarketActivity } from '../shared/market.ts'; +import { ADDITIONAL_MARKET_FEE, formatUsd, marketMessage, type MarketAction, type MarketInfo, + type MarketListing, type MarketOrder } from '../shared/market.ts'; +import { atomicXmrString, formatXmr, parseAtomicXmr, parseXmr } from '../shared/xmr-amount.ts'; +import { isXmrListing, isXmrOrder, marketMessageV3, type MarketActionV3, type MarketActivityV3, + type MarketCommandV3, type MarketListingRecord, type MarketOrderRecord } from '../shared/market-v3.ts'; import { sign, type Wallet } from './wallet.ts'; import { imageCredit } from './Card.tsx'; import './market.css'; type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean; onNotice(message: string): void }; -type Page = { listings: MarketListing[]; next: number | null; info: MarketInfo }; +type Page = { listings: MarketListingRecord[]; next: number | null; info: MarketInfo }; const short = (v: string) => `${v.slice(0, 8)}…${v.slice(-5)}`; const feeDisclosure = (marketBps: number) => `Hosting peer fee: ${marketBps / 100}% plus a hardcoded 1% project author fee (${(marketBps + ADDITIONAL_MARKET_FEE.bps) / 100}% total), both deducted from seller proceeds. Author fee recipient: ${ADDITIONAL_MARKET_FEE.address}. Payouts are simulated only.`; async function api(body?: unknown, offset = 0): Promise { @@ -21,11 +24,21 @@ async function api(body?: unknown, offset = 0): Promise { if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Marketplace request failed.'); return data; } -async function command(wallet: Wallet, info: MarketInfo, action: MarketAction): Promise { +async function commandV3(wallet: Wallet, info: MarketInfo, action: MarketActionV3): Promise { + const envelope = { network: NETWORK, chainId: info.chainId, marketId: info.marketId, actor: wallet.address, + version: 3 as const, publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action }; + return api({ ...envelope, signature: await sign(wallet, marketMessageV3(envelope as Omit)) }); +} +async function commandV2(wallet: Wallet, info: MarketInfo, action: MarketAction): Promise { const envelope = { network: NETWORK, chainId: info.chainId, marketId: info.marketId, actor: wallet.address, publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action }; return api({ ...envelope, signature: await sign(wallet, marketMessage(envelope)) }); } +const orderPrice = (order: MarketOrderRecord) => isXmrOrder(order) + ? `${formatXmr(parseAtomicXmr(order.priceAtomicXmr))} XMR` + : `${formatUsd(order.priceCents)} USD (legacy)`; +const listingPriceLegacy = (listing: Extract) => + `${formatUsd(listing.highestBid?.priceCents ?? listing.priceCents)} USD (legacy)`; function deadline(time: number, now: number) { if (time <= now) return 'Closed'; const minutes = Math.ceil((time - now) / 60000); @@ -34,17 +47,17 @@ function deadline(time: number, now: number) { export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) { const [page, setPage] = useState(); - const [activity, setActivity] = useState({ listings: [], orders: [] }); + const [activity, setActivity] = useState({ listings: [], orders: [] }); const [tab, setTab] = useState<'browse' | 'sell' | 'orders'>('browse'); const [filter, setFilter] = useState<'all' | 'fixed' | 'auction'>('all'); const [cardHash, setCardHash] = useState(''); const [kind, setKind] = useState<'fixed' | 'auction'>('fixed'); - const [price, setPrice] = useState('5.00'); + const [price, setPrice] = useState('0.100000000000'); const [hours, setHours] = useState('24'); const [bids, setBids] = useState>({}); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); - const [review, setReview] = useState<{ action: MarketAction; text: string }>(); + const [review, setReview] = useState<{ action: MarketAction | MarketActionV3; text: string; legacy?: boolean }>(); const [now, setNow] = useState(Date.now()); const version = useRef(0); const inFlight = useRef(false); @@ -57,10 +70,10 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) const refresh = useCallback(async () => { const current = version.current; const data = await api(); - if (data.info.network !== NETWORK || data.info.paymentMode !== 'mock' || + if (data.info.network !== NETWORK || data.info.marketVersion !== 3 || data.info.paymentMode !== 'mock' || data.info.additionalFee?.bps !== ADDITIONAL_MARKET_FEE.bps || data.info.additionalFee?.address !== ADDITIONAL_MARKET_FEE.address || (snapshot && data.info.chainId !== snapshot.chainId)) throw new Error('Incompatible marketplace network, payment mode, or fee policy.'); - const mine = wallet ? await command(wallet, data.info, { type: 'activity' }) : { listings: [], orders: [] }; + const mine = wallet ? await commandV3(wallet, data.info, { type: 'activity' }) : { listings: [], orders: [] }; if (current !== version.current) return; setPage(data); setActivity(mine); setNow(Date.now()); }, [wallet, snapshot?.chainId]); @@ -72,7 +85,7 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) const timer = setInterval(() => { setNow(Date.now()); if (rpcAvailable && !inFlight.current) load(); }, 15000); return () => { active = false; version.current++; clearInterval(timer); }; }, [refresh, rpcAvailable]); - const prepare = (action: MarketAction, text: string) => { setError(''); setReview({ action, text }); }; + const prepare = (action: MarketAction | MarketActionV3, text: string, legacy = false) => { setError(''); setReview({ action, text, legacy }); }; const submit = async () => { if (!wallet || !page || !review || inFlight.current || !rpcAvailable) return; const currentVersion = version.current; @@ -80,7 +93,8 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) try { const action = review.action.type === 'deliver' ? { ...review.action, transfer: { ...review.action.transfer, signature: await sign(wallet, nftTransferMessage(review.action.transfer)) } } : review.action; - await command(wallet, page.info, action); + if (review.legacy) await commandV2(wallet, page.info, action as MarketAction); + else await commandV3(wallet, page.info, action as MarketActionV3); if (currentVersion !== version.current) return; const type = review.action.type; setReview(undefined); @@ -97,21 +111,21 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) const create = () => { if (!snapshot || !page || !owned.some(b => b.tokenId === cardHash)) { setError('Select an eligible card first.'); return; } try { - const priceCents = parseUsd(price), duration = Number(hours); + const priceAtomicXmr = atomicXmrString(parseXmr(price)), duration = Number(hours); if (kind === 'auction' && (!Number.isFinite(duration) || duration < 1 / 60 || duration > 720)) throw new Error('Choose an auction duration from one minute to 30 days.'); prepare({ type: 'list', cardHash, sequence: snapshot.transfers.filter(tx => transactionCardIds(tx).includes(cardHash)).length, - kind, priceCents, endsAt: kind === 'auction' ? Date.now() + Math.round(duration * 3600_000) : null, feeBps: page.info.feeBps }, - `List ${owned.find(b => b.tokenId === cardHash)!.article.title} ${kind === 'fixed' ? 'for' : 'with a starting bid of'} ${formatUsd(priceCents)}. ${feeDisclosure(page.info.feeBps)} ${kind === 'auction' ? `Auction lasts ${duration} hours. You cannot cancel after the first bid.` : 'The listing stays open until sold or cancelled.'} Listing does not lock the card on-chain.`); + kind, priceAtomicXmr, endsAt: kind === 'auction' ? Date.now() + Math.round(duration * 3600_000) : null, feeBps: page.info.feeBps }, + `List ${owned.find(b => b.tokenId === cardHash)!.article.title} ${kind === 'fixed' ? 'for' : 'with a starting bid of'} ${formatXmr(parseAtomicXmr(priceAtomicXmr))} XMR. ${feeDisclosure(page.info.feeBps)} ${kind === 'auction' ? `Auction lasts ${duration} hours. You cannot cancel after the first bid.` : 'The listing stays open until sold or cancelled.'} Listing does not lock the card on-chain.`); } catch (e) { setError(e instanceof Error ? e.message : 'Invalid price.'); } }; - const deliver = async (o: MarketOrder) => { + const deliver = async (o: MarketOrderRecord) => { if (!wallet || !page) return; try { const tx = { network: NETWORK, chainId: page.info.chainId, cardHash: o.cardHash, from: wallet.address, to: o.buyer, sequence: o.sequence }; const transfer = { ...tx, publicKey: wallet.publicKey, signature: '' }; // Sign only after explicit review confirmation. prepare({ type: 'deliver', orderId: o.id, transfer }, - `Transfer card ${short(o.cardHash)} permanently to ${short(o.buyer)}. Only mock payment has been received. This signature submits a real NFT transfer on the selected network. Simulated net proceeds: ${o.sellerNetXmr} XMR, after ${o.feeXmr} XMR market fee${o.additionalFee ? ` and ${o.additionalFee.amountXmr} XMR additional fee (${o.additionalFee.bps / 100}%) to ${o.additionalFee.address}` : ' (legacy order: no additional fee)'}.`); + `Transfer card ${short(o.cardHash)} permanently to ${short(o.buyer)}. Only mock payment has been received. This signature submits a real NFT transfer on the selected network. Agreed sale price: ${orderPrice(o)}. Simulated net proceeds: ${o.sellerNetXmr} XMR, after ${o.feeXmr} XMR market fee${o.additionalFee ? ` and ${o.additionalFee.amountXmr} XMR additional fee (${o.additionalFee.bps / 100}%) to ${o.additionalFee.address}` : ' (legacy order: no additional fee)'}.`, !isXmrOrder(o)); } catch { setError('Could not prepare delivery signature.'); } }; const loadMore = async () => { @@ -124,34 +138,36 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) }; const disabled = busy || !wallet || !rpcAvailable || !page; return
-
THIS PEER’S COLLECTOR EXCHANGE

Your next find.
Your kind of market.

Buy a fixed-price card or join a timed auction. Every peer can host its own market: a broad exchange or a community centered on a collection. Prices are in USD; cryptocurrency checkout is designed for Monero (XMR), currently simulated.

PEER-HOSTED · COMMUNITY-DRIVEN
+
THIS PEER’S COLLECTOR EXCHANGE

Your next find.
Your kind of market.

Buy a fixed-price card or join a timed auction. Prices are listed directly in XMR atomic units (12 decimal places). Every peer can host its own market; listings and orders remain local to that host.

PEER-HOSTED · COMMUNITY-DRIVEN

Sales support the hosting peer’s operator through a configurable fee and the project author through a hardcoded 1% fee. Both are deducted from seller proceeds. Collection-specific curation tools and peer-hosted custom TCGs are planned, not available yet.

Mock payments only · never send real XMR

Quotes, payment confirmations, refunds, fees, and automatic seller payouts are simulated. Card delivery is a real on-chain transfer. There is no payable Monero address. Listings are local to this marketplace node, not shared by peers.

SANDBOX
{(['browse','sell','orders'] as const).map(t=>)}
- {page&&<>
Host {page.info.feeBps/100}% + author 1% · {(page.info.feeBps+ADDITIONAL_MARKET_FEE.bps)/100}% total deducted from sellerDelivery {page.info.deliveryConfirmations} confirmations before mock payoutSimulation rate ${page.info.mockUsdPerXmr}/XMR · not a live quote

Hardcoded project author fee recipient · simulated payout only, not a checkout address: {ADDITIONAL_MARKET_FEE.address}

} + {page&&<>
Host {page.info.feeBps/100}% + author 1% · {(page.info.feeBps+ADDITIONAL_MARKET_FEE.bps)/100}% total deducted from sellerDelivery {page.info.deliveryConfirmations} confirmations before mock payoutNew listings and orders: XMR-native

Hardcoded project author fee recipient · simulated payout only, not a checkout address: {ADDITIONAL_MARKET_FEE.address}

} {!rpcAvailable&&

Reconnect to the node before using the marketplace.

} {!wallet&&

Open your local wallet to list cards, bid, and check out.

} {error&&

{error}

} {review&&
REVIEW BEFORE SIGNING

{review.text}

} {tab==='browse'&&<>
{(['all','fixed','auction'] as const).map(f=>)}
{page?.listings.filter(l=>filter==='all'||l.kind===filter).map(l=>{ const ended=l.kind==='auction'&&now>=l.endsAt!, mine=l.seller===wallet?.address; - const salePrice=l.highestBid?.priceCents??l.priceCents; + const legacy=!isXmrListing(l); + const saleAtomic=isXmrListing(l)?(l.highestBid?.priceAtomicXmr??l.priceAtomicXmr):undefined; + const salePrice=legacy?(l.highestBid?.priceCents??l.priceCents):undefined; const article=snapshot?.blocks.find(b=>b.tokenId===l.cardHash)?.article; - return
{l.kind==='auction'?:} {l.kind==='auction'?'Timed auction':'Fixed price'}
BY {short(l.seller)} {mine?'· YOU':''}

{l.title}

{formatUsd(salePrice)}{l.kind==='fixed'?'XMR at checkout':l.highestBid?'Highest bid':'Starting bid'}
{l.kind==='auction'&&

{deadline(l.endsAt!,now)}{ended&&l.highestBid?` · winner ${short(l.highestBid.bidder)}`:''}

} - {l.status==='reserved'?

Reserved · awaiting settlement

:mine?:l.kind==='fixed'||ended?:
setBids(v=>({...v,[l.id]:e.target.value}))}/>
} + return
{l.kind==='auction'?:} {l.kind==='auction'?'Timed auction':'Fixed price'}
BY {short(l.seller)} {mine?'· YOU':''} {legacy&&· USD LEGACY}

{l.title}

{legacy?formatUsd(salePrice!):`${formatXmr(parseAtomicXmr(saleAtomic!))} XMR`}{legacy?'Legacy USD terms':l.kind==='fixed'?'Native-XMR price':l.highestBid?'Highest bid':'Starting bid'}
{legacy&&l.status==='open'&&!mine&&

Legacy USD listing is read-only during the native-XMR market transition.

}{l.kind==='auction'&&

{deadline(l.endsAt!,now)}{ended&&l.highestBid?` · winner ${short(l.highestBid.bidder)}`:''}

} + {l.status==='reserved'?

Reserved · awaiting settlement

:legacy?mine&&['open','invalid'].includes(l.status)&&!l.highestBid?:

Read-only USD legacy listing · no new bids or checkout

:mine?:l.kind==='fixed'||ended?:
setBids(v=>({...v,[l.id]:e.target.value}))}/>
}

{l.feeBps/100}% host + {(l.additionalFee?.bps ?? 0)/100}% author · deducted from seller · NFT {short(l.cardHash)}

{article&&

Wikipedia · Image & license · NFT metadata

}
; })}
{!page?.listings.length&&

A discovery worth sharing?

The market is waiting for its first listing. Offer a card from your confirmed collection.

}{page?.next!==null&&page?.next!==undefined&&}} - {tab==='sell'&&
MAKE ROOM FOR THE NEXT DISCOVERY

Put a card on the market.

Keep a set price open indefinitely, or let collectors bid until the clock runs out. You retain custody of the card and must return to sign delivery after mock payment.

A listing does not lock a card in consensus. Transfers elsewhere can invalidate it. No cancellation after an auction’s first bid; the winner gets 30 minutes to pay. Seller delivery deadline is 48 hours after mock payment.

{e.preventDefault();create();}}>setPrice(e.target.value)} required/>{kind==='auction'&&<>setHours(e.target.value)} required/>}

Fees: {page?(page.info.feeBps/100)+'% market + 1% additional':'—'} · both subtracted from seller proceeds, never added to the buyer price.

} + {tab==='sell'&&
MAKE ROOM FOR THE NEXT DISCOVERY

Put a card on the market.

Choose a fixed XMR price or let collectors bid until the clock runs out. Enter up to 12 decimal places; one atomic unit is the smallest representable positive price. You retain custody of the card and must return to sign delivery after mock payment.

A listing does not lock a card in consensus. Transfers elsewhere can invalidate it. No cancellation after an auction’s first bid; the winner gets 30 minutes to pay. Seller delivery deadline is 48 hours after mock payment.

{e.preventDefault();create();}}>setPrice(e.target.value)} required/>

12 decimal places maximum. No USD conversion or minimum-price rule. Fees: {page?(page.info.feeBps/100)+'% host + 1% author':'—'} · rounded independently from gross proceeds.

{kind==='auction'&&<>setHours(e.target.value)} required/>}
} {tab==='orders'&&

Orders & settlement

-

Private order details require your wallet signature. No private keys leave this browser. Simulated payouts are automatic only after confirmed card delivery.

+

Private order details require your wallet signature. New prices use native XMR amounts; legacy orders retain their original USD terms. No private keys leave this browser. Payments and payouts remain simulated.

{activity.orders.length===0&&

No orders yet. Explore the market to find your next discovery.

} {activity.orders.map(o=>{ const buyer=o.buyer===wallet?.address; return
{snapshot?.blocks.find(b=>b.tokenId===o.cardHash)?.article.title??short(o.cardHash)}{buyer?'Buying from':'Selling to'} {short(buyer?o.seller:o.buyer)} · {short(o.id)}
{o.status.replaceAll('-',' ')}
-
Sale
{formatUsd(o.priceCents)}
+
{isXmrOrder(o)?'Sale price':'Sale price · USD legacy'}
{orderPrice(o)}
Mock checkout
{o.invoice.amountXmr} XMR
Hosting peer fee ({o.feeBps/100}%) · simulated
{o.feeXmr} XMR
{o.additionalFee &&
Project author fee ({o.additionalFee.bps/100}%) · simulated
{o.additionalFee.amountXmr} XMR · {o.additionalFee.payout}Recipient: {o.additionalFee.address}
} @@ -159,14 +175,14 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props)

{o.status==='awaiting-payment'?`Pay by ${new Date(o.payBy).toLocaleString()}`:o.status==='paid'?`Seller signs delivery by ${new Date(o.deliverBy).toLocaleString()}`:`Delivery: ${o.deliveryConfirmations}/${o.requiredConfirmations} confirmations · payout: ${o.payout}`}

- {buyer&&o.status==='awaiting-payment'&&} + {buyer&&o.status==='awaiting-payment'&&} {!buyer&&(o.status==='paid'||o.status==='delivery-pending')&&} - {buyer&&o.status==='refund-required'&&} + {buyer&&o.status==='refund-required'&&}
{o.status==='review'&&

A chain reorganization affected settled delivery. Operator review required; no second payout is submitted.

}
; })} -

Your listing history

{activity.listings.map(l=>
{l.title}{formatUsd(l.highestBid?.priceCents??l.priceCents)}{l.status}
)} +

Your listing history

{activity.listings.map(l=>
{l.title}{isXmrListing(l)?`${formatXmr(parseAtomicXmr(l.highestBid?.priceAtomicXmr??l.priceAtomicXmr))} XMR`:listingPriceLegacy(l)}{l.status}
)}
}
; } diff --git a/tests/api.test.ts b/tests/api.test.ts index ba43b24..0f8f7be 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -14,8 +14,8 @@ import { address, CHAIN_ID, GENESIS_HASH, MAX_TARGET, miningBlob, mintId, sha256 transferId, workFor } from '../server/testnet/consensus.ts'; import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, type MiningTemplate, type NftTransfer, type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts'; -import { marketMessage, type MarketAction, type MarketInfo, type MarketListing, type MarketOrder, - type MarketActivity } from '../shared/market.ts'; +import { type MarketAction, type MarketInfo } from '../shared/market.ts'; +import { marketMessageV3, type MarketActionV3, type MarketActivityV3, type MarketListingV3, type MarketOrderV3 } from '../shared/market-v3.ts'; import { StateClient } from '../shared/rpc-client.ts'; import { createBackup,restoreBackup } from '../server/recovery.ts'; import { Ledger } from '../server/testnet/ledger.ts'; @@ -139,9 +139,17 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du } const snapshot = (base: string) => json(base, '/api/chain'); async function marketCommand(base: string, info: MarketInfo, who: ReturnType, action: MarketAction) { - const terms = { network: NETWORK, chainId: CHAIN_ID, marketId: info.marketId, actor: who.owner, - publicKey: who.publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action }; - return json(base, '/api/market/command', 200, { ...terms, signature: who.signed(marketMessage(terms)) }); + const atomicFromCents = (cents: number) => ((BigInt(cents) * 1_000_000_000_000n + 14999n) / 15000n).toString(); + let current: MarketActionV3; + if (action.type === 'list') current = { type: 'list', cardHash: action.cardHash, sequence: action.sequence, + kind: action.kind, priceAtomicXmr: atomicFromCents(action.priceCents), endsAt: action.endsAt, feeBps: action.feeBps }; + else if (action.type === 'buy') current = { type: 'buy', listingId: action.listingId, + priceAtomicXmr: atomicFromCents(action.priceCents), feeBps: action.feeBps }; + else if (action.type === 'bid') current = { type: 'bid', listingId: action.listingId, priceAtomicXmr: atomicFromCents(action.priceCents) }; + else current = action; + const terms = { version: 3 as const, network: NETWORK, chainId: CHAIN_ID, marketId: info.marketId, actor: who.owner, + publicKey: who.publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action: current }; + return json(base, '/api/market/command', 200, { ...terms, signature: who.signed(marketMessageV3(terms)) }); } async function waitFor(label: string, read: () => Promise, predicate: (value: T) => boolean): Promise { const until = Date.now() + 25_000; @@ -264,15 +272,15 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du assert.equal(aliceDeck.tip, block2.hash); assert.equal(aliceDeck.total, 2); assert.deepEqual(aliceDeck.nfts.map(item => item.block.tokenId), [block1.tokenId, block2.tokenId]); - const market = await json<{ info: MarketInfo; listings: MarketListing[] }>(first.base, '/api/market'); + const market = await json<{ info: MarketInfo; listings: MarketListingV3[] }>(first.base, '/api/market'); assert.equal(market.info.paymentMode, 'mock'); assert.deepEqual(market.listings, []); await (await request(first.base, '/api/market/command', 400, {})).text(); await (await request(first.base, '/api/market/command', 403, {}, { Origin: 'https://evil.example' })).text(); - const sale = await marketCommand(first.base, market.info, alice, { type: 'list', + const sale = await marketCommand(first.base, market.info, alice, { type: 'list', cardHash: block1.tokenId, sequence: 0, kind: 'fixed', priceCents: 1500, endsAt: null, feeBps: 250 }); - assert.equal((await json<{ listings: MarketListing[] }>(first.base, '/api/market')).listings[0].id, sale.id); - const order = await marketCommand(first.base, market.info, bob, { type: 'buy', listingId: sale.id, + assert.equal((await json<{ listings: MarketListingV3[] }>(first.base, '/api/market')).listings[0].id, sale.id); + const order = await marketCommand(first.base, market.info, bob, { type: 'buy', listingId: sale.id, priceCents: 1500, feeBps: 250 }); assert.equal(order.invoice.address, null); assert.equal(order.invoice.amountXmr, '0.100000000000'); @@ -292,7 +300,7 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du { ...transfer, signature: tamperSignature(transfer.signature) }); assert.match(badTransfer.error, /signature/i); await marketCommand(first.base, market.info, alice, { type: 'deliver', orderId: order.id, transfer }); - assert.equal((await marketCommand(first.base, market.info, bob, { type: 'activity' })).orders[0].status, 'delivery-pending'); + assert.equal((await marketCommand(first.base, market.info, bob, { type: 'activity' })).orders[0].status, 'delivery-pending'); const pending = { id: transferId(transfer), status: 'pending', message: 'Ownership changes only when this transaction is mined.' }; assert.deepEqual(await json(second.base, '/api/transfer', 202, transfer), pending); @@ -330,10 +338,10 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du assert.deepEqual(canonical.blocks, [block1, block2, block3]); assert.deepEqual(canonical.transfers, [transfer]); assert.deepEqual(canonical.pendingTransfers, []); - const settling = await marketCommand(first.base, market.info, bob, { type: 'activity' }); + const settling = await marketCommand(first.base, market.info, bob, { type: 'activity' }); assert.equal(settling.orders[0].deliveryConfirmations, 1); assert.equal(settling.orders[0].payout, 'not-started'); - assert.equal((await json<{ listings: MarketListing[] }>(second.base, '/api/market')).listings.length, 0); + assert.equal((await json<{ listings: MarketListingV3[] }>(second.base, '/api/market')).listings.length, 0); assert.equal(canonical.cumulativeWork, (3n * workFor(MAX_TARGET)).toString()); for (const node of [first, second]) { const replay = await json<{ error: string }>(node.base, '/api/transfer', 400, transfer); @@ -383,7 +391,7 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du assert.deepEqual(await pagedClient.load(),restored); const restoredMarket = await json<{ info: MarketInfo }>(first.base, '/api/market'); assert.equal(restoredMarket.info.marketId, market.info.marketId); - assert.equal((await marketCommand(first.base, market.info, bob, { type: 'activity' })).orders[0].id, order.id); + assert.equal((await marketCommand(first.base, market.info, bob, { type: 'activity' })).orders[0].id, order.id); assert.deepEqual(restored, { ...canonical, peers: 0 }); assert.deepEqual(await json(first.base, `/api/nfts/${block1.tokenId}`), { network: NETWORK, chainId: CHAIN_ID, tokenId: block1.tokenId, owner: bob.owner, sequence: 1, diff --git a/tests/market.test.ts b/tests/market.test.ts index cbb3cae..5112de1 100755 --- a/tests/market.test.ts +++ b/tests/market.test.ts @@ -7,8 +7,9 @@ import { tmpdir } from 'node:os'; import { DatabaseSync } from 'node:sqlite'; import { Market, marketConfig } from '../server/market/store.ts'; import { mockQuote, paymentMode } from '../server/market/payments.ts'; -import { ADDITIONAL_MARKET_FEE, marketMessage, parseUsd, type MarketAction, type MarketListing, type MarketOrder, - type MarketActivity } from '../shared/market.ts'; +import { ADDITIONAL_MARKET_FEE, marketMessage, parseUsd, type MarketAction } from '../shared/market.ts'; +import { marketActionV3Schema, marketMessageV3, type MarketActionV3, type MarketActivityV3, + type MarketListingV3, type MarketOrderV3 } from '../shared/market-v3.ts'; import { NETWORK, nftTransferMessage, type NftTransfer, type TestnetSnapshot, type TestnetBlock } from '../shared/testnet.ts'; import { address, CHAIN_ID, GENESIS_HASH, transferId, verifySignature } from '../server/testnet/consensus.ts'; @@ -38,20 +39,35 @@ async function fixture(t: { after(fn: () => Promise): void }) { const dir = await mkdtemp(join(tmpdir(), 'wikideck-market-')); const chain = chainFixture(); let time = 1_800_000_000_000; - let market = await Market.open(dir, chain, { feeBps: 250, confirmations: 2, usdCentsPerXmr: 15000 }, () => time); + let market = await Market.open(dir, chain, { feeBps: 250, confirmations: 2 }, () => time); t.after(async () => { market.close(); await rm(dir, { recursive: true, force: true }); }); const envelope = (who: typeof alice, action: MarketAction) => { const terms = { network: NETWORK, chainId: CHAIN_ID, marketId: market.id, actor: who.actor, publicKey: who.publicKey, nonce: randomUUID(), expiresAt: time + 60000, action }; return { ...terms, signature: who.signed(marketMessage(terms)) }; }; - const run = (who: typeof alice, action: MarketAction) => market.execute(envelope(who, action)) as Promise; - const list = (kind: 'fixed' | 'auction' = 'fixed') => run(alice, { type: 'list', cardHash: chain.state.blocks[0].tokenId, + const atomicFromCents = (cents: number) => ((BigInt(cents) * 1_000_000_000_000n + 14999n) / 15000n).toString(); + const toV3 = (action: MarketAction): MarketActionV3 => { + if (action.type === 'list') return { type: 'list', cardHash: action.cardHash, sequence: action.sequence, + kind: action.kind, priceAtomicXmr: atomicFromCents(action.priceCents), endsAt: action.endsAt, feeBps: action.feeBps }; + if (action.type === 'bid') return { type: 'bid', listingId: action.listingId, priceAtomicXmr: atomicFromCents(action.priceCents) }; + if (action.type === 'buy') return { type: 'buy', listingId: action.listingId, + priceAtomicXmr: atomicFromCents(action.priceCents), feeBps: action.feeBps }; + return action; + }; + const envelopeV3 = (who: typeof alice, action: MarketActionV3) => { + const terms = { version: 3 as const, network: NETWORK, chainId: CHAIN_ID, marketId: market.id, actor: who.actor, + publicKey: who.publicKey, nonce: randomUUID(), expiresAt: time + 60000, action }; + return { ...terms, signature: who.signed(marketMessageV3(terms)) }; + }; + const run = (who: typeof alice, action: MarketAction) => + market.execute(envelopeV3(who, toV3(action))) as Promise; + const list = (kind: 'fixed' | 'auction' = 'fixed') => run(alice, { type: 'list', cardHash: chain.state.blocks[0].tokenId, sequence: 0, kind, priceCents: 1500, endsAt: kind === 'auction' ? time + 60000 : null, feeBps: 250 }); - const buy = (l: MarketListing, who = bob, priceCents = l.priceCents) => run(who, + const buy = (l: MarketListingV3, who = bob, priceCents = 1500) => run(who, { type: 'buy', listingId: l.id, priceCents, feeBps: l.feeBps }); - const orders = async (who = bob) => (await run(who, { type: 'activity' })).orders; - const delivery = (o: MarketOrder, to = bob) => { + const orders = async (who = bob) => (await run(who, { type: 'activity' })).orders; + const delivery = (o: Pick, to = bob) => { const terms = { network: NETWORK, chainId: CHAIN_ID, cardHash: o.cardHash, from: alice.actor, to: to.actor, sequence: o.sequence }; return { ...terms, publicKey: alice.publicKey, signature: alice.signed(nftTransferMessage(terms)) }; }; @@ -60,10 +76,10 @@ async function fixture(t: { after(fn: () => Promise): void }) { chain.state.blocks.push({ ...chain.state.blocks[0], height, transactions: [tx] }); chain.state.transfers.push(tx); chain.state.pendingTransfers = []; }; - return { dir, chain, envelope, run, list, buy, orders, delivery, confirm, + return { dir, chain, envelope, envelopeV3, run, list, buy, orders, delivery, confirm, get market() { return market; }, get now() { return time; }, advance: (ms: number) => { time += ms; }, restart: async () => { market.close(); market = await Market.open(dir, chain, - { feeBps: 500, confirmations: 5, usdCentsPerXmr: 20000 }, () => time); } }; + { feeBps: 500, confirmations: 5 }, () => time); } }; } test('USD and mock XMR accounting use bounded integer amounts and exact fee splitting', () => { @@ -95,29 +111,99 @@ test('USD and mock XMR accounting use bounded integer amounts and exact fee spli } }); -test('market commands bind actor, instance, network, expiry, and every listing term', async t => { +test('versioned market commands bind actor, instance, network, expiry, currency, and every price term', async t => { const f = await fixture(t); - const original = f.envelope(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId, sequence: 0, - kind: 'fixed', priceCents: 1500, endsAt: null, feeBps: 250 }); + const original = f.envelopeV3(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId, sequence: 0, + kind: 'fixed', priceAtomicXmr: '100000000000', endsAt: null, feeBps: 250 }); for (const bad of [ { ...original, actor: bob.actor }, { ...original, marketId: randomUUID() }, { ...original, chainId: '0'.repeat(64) }, { ...original, expiresAt: f.now - 1 }, { ...original, expiresAt: f.now + 600_000 }, - { ...original, action: { ...original.action, priceCents: 1 } }, + { ...original, action: { ...original.action, priceAtomicXmr: '1' } }, ]) await assert.rejects(async () => f.market.execute(bad), /signature/); await assert.rejects(async () => f.market.execute({ ...original, network: 'wrong-network' })); const first = await f.market.execute(original); - const message = JSON.parse(marketMessage(original)); - assert.equal(message[1], 'market-command-v2'); + const message = JSON.parse(marketMessageV3(original)); + assert.equal(message[1], 'market-command-v3'); assert.deepEqual(message.at(-1), [100, ADDITIONAL_MARKET_FEE.address]); message[message.length - 1] = [0, ADDITIONAL_MARKET_FEE.address]; await assert.rejects(async () => f.market.execute({ ...original, signature: alice.signed(JSON.stringify(message)) }), /signature/); assert.deepEqual(await f.market.execute(original), first); - const changed = { ...original, action: { ...original.action, priceCents: 1600 } }; - await assert.rejects(async () => f.market.execute({ ...changed, signature: alice.signed(marketMessage(changed)) }), /Nonce/); + const changed = { ...original, action: { ...original.action, priceAtomicXmr: '200000000000' } }; + await assert.rejects(async () => f.market.execute({ ...changed, signature: alice.signed(marketMessageV3(changed)) }), /Nonce/); + const legacy = f.envelope(alice, { type: 'list', cardHash: f.chain.state.blocks[1].tokenId, sequence: 0, + kind: 'fixed', priceCents: 1500, endsAt: null, feeBps: 250 }); + assert.equal(JSON.parse(marketMessage(legacy))[1], 'market-command-v2'); + await assert.rejects(f.market.execute(legacy), /USD listing terms are retired/); assert.equal(f.market.browse().listings.length, 1); }); +test('legacy USD listings are read-only until seller retires them with v3 authorization', async t => { + const f = await fixture(t), cardHash = f.chain.state.blocks[0].tokenId, id = randomUUID(); + const legacy = { id, cardHash, sequence: 0, seller: alice.actor, title: 'Historical USD listing', + image: 'https://upload.wikimedia.org/test.jpg', kind: 'fixed' as const, priceCents: 1500, + feeBps: 250, createdAt: f.now, endsAt: null, status: 'open' as const, highestBid: null }; + const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); + try { db.prepare('INSERT INTO listings VALUES(?,?,?,?)').run(id, cardHash, 'open', JSON.stringify(legacy)); } + finally { db.close(); } + const activity = await f.run(alice, { type: 'activity' }); + assert.equal(activity.listings[0].id, id); + assert.equal((activity.listings[0] as typeof legacy).priceCents, 1500); + assert.equal(activity.listings[0].status, 'invalid'); + await assert.rejects(f.run(bob, { type: 'buy', listingId: id, priceCents: 1500, feeBps: 250 }), /Legacy USD listings are read-only/); + await assert.rejects(f.run(alice, { type: 'bid', listingId: id, priceCents: 1500 }), /Legacy USD listings are read-only/); + const retired = await f.market.execute(f.envelopeV3(alice, { type: 'retire-legacy-listing', listingId: id })) as MarketListingV3; + assert.equal(retired.status, 'cancelled'); + assert.equal((retired as unknown as typeof legacy).priceCents, 1500); +}); + +test('legacy reserved USD orders retain signed terms across current-XMR upgrade and restart', async t => { + const f = await fixture(t), listingId = randomUUID(), orderId = randomUUID(), cardHash = f.chain.state.blocks[0].tokenId; + const listing = { id: listingId, cardHash, sequence: 0, seller: alice.actor, title: 'Legacy in-flight order', + image: 'https://upload.wikimedia.org/test.jpg', kind: 'fixed' as const, priceCents: 1500, feeBps: 250, + createdAt: f.now, endsAt: null, status: 'reserved' as const, highestBid: null, + additionalFee: { bps: 100, address: ADDITIONAL_MARKET_FEE.address } }; + const quote = mockQuote(1500, 250, 15000); + const order = { id: orderId, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0, + priceCents: 1500, feeBps: 250, createdAt: f.now, payBy: f.now + 30 * 60_000, + deliverBy: f.now + 30 * 60_000 + 48 * 60 * 60_000, status: 'awaiting-payment' as const, + ...quote, payout: 'not-started' as const, deliveryConfirmations: 0, requiredConfirmations: 2 }; + const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); + try { + db.prepare('INSERT INTO listings VALUES(?,?,?,?)').run(listingId, cardHash, 'reserved', JSON.stringify(listing)); + db.prepare('INSERT INTO orders VALUES(?,?)').run(orderId, JSON.stringify(order)); + } finally { db.close(); } + await assert.rejects(f.run(bob, { type: 'mock-pay', orderId }), /Legacy USD orders keep their original terms/); + const historical = await f.market.execute(f.envelope(bob, { type: 'mock-pay', orderId })) as typeof order; + assert.equal(historical.status, 'paid'); + assert.equal(historical.priceCents, 1500); + assert.equal(historical.invoice.amountXmr, '0.100000000000'); + await f.restart(); + const restored = (await f.run(bob, { type: 'activity' })).orders[0] as typeof order; + assert.equal(restored.priceCents, 1500); + assert.equal(restored.invoice.amountXmr, '0.100000000000'); + assert.equal(restored.status, 'paid'); +}); + +test('native-XMR listing and checkout accept one atomic unit without conversion or fee minimums', async t => { + const f = await fixture(t); + const listing = await f.market.execute(f.envelopeV3(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId, + sequence: 0, kind: 'fixed', priceAtomicXmr: '1', endsAt: null, feeBps: 250 })) as MarketListingV3; + assert.equal(listing.priceAtomicXmr, '1'); + const order = await f.market.execute(f.envelopeV3(bob, { type: 'buy', listingId: listing.id, + priceAtomicXmr: '1', feeBps: 250 })) as MarketOrderV3; + assert.equal(order.invoice.amountXmr, '0.000000000001'); + assert.equal(order.feeXmr, '0.000000000000'); + assert.equal(order.additionalFee?.amountXmr, '0.000000000000'); + assert.equal(order.sellerNetXmr, '0.000000000001'); + const terms = { type: 'list', cardHash: f.chain.state.blocks[1].tokenId, sequence: 0, + kind: 'fixed', priceAtomicXmr: '0', endsAt: null, feeBps: 250 }; + assert.equal(marketActionV3Schema.safeParse(terms).success, false); + for (const invalid of ['01', '+1', '1.0', '1e0', '9'.repeat(23)]) { + assert.equal(marketActionV3Schema.safeParse({ ...terms, priceAtomicXmr: invalid }).success, false); + } +}); + test('listings reject unknown, immature, stale, foreign, duplicate, and pending cards', async t => { const f = await fixture(t); const base = { type: 'list' as const, cardHash: f.chain.state.blocks[0].tokenId, sequence: 0, @@ -129,7 +215,7 @@ test('listings reject unknown, immature, stale, foreign, duplicate, and pending f.chain.state.blocks.pop(); await assert.rejects(f.run(alice, base), /Card/); f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 2 }); - f.chain.state.pendingTransfers.push(f.delivery({ cardHash: base.cardHash, sequence: 0 } as MarketOrder)); + f.chain.state.pendingTransfers.push(f.delivery({ cardHash: base.cardHash, sequence: 0 })); await assert.rejects(f.run(alice, base), /pending/); f.chain.state.pendingTransfers = []; const listing = await f.list(); @@ -143,10 +229,10 @@ test('listings reject unknown, immature, stale, foreign, duplicate, and pending test('fixed-price reservations are exclusive, idempotent, private and expire without charging', async t => { const f = await fixture(t), listing = await f.list(); await assert.rejects(f.buy(listing, alice), /own card/); - const request = f.envelope(bob, { type: 'buy', listingId: listing.id, priceCents: 1500, feeBps: 250 }); + const request = f.envelopeV3(bob, { type: 'buy', listingId: listing.id, priceAtomicXmr: '100000000000', feeBps: 250 }); const results = await Promise.allSettled([f.market.execute(request), f.buy(listing, carol)]); assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); - const order = (results[0] as PromiseFulfilledResult).value; + const order = (results[0] as PromiseFulfilledResult).value; assert.deepEqual(await f.market.execute(request), order); assert.equal((await f.orders(carol)).length, 0); assert.equal((await f.orders(alice)).length, 1); @@ -172,7 +258,7 @@ test('auction bids are signed, increasing, deadline-bound, uncancellable, and wi await assert.rejects(f.buy(l, bob, 1600), /winner/); await assert.rejects(f.buy(l, carol, 1500), /terms changed/); const order = await f.buy(l, carol, 1600); - assert.equal(order.priceCents, 1600); assert.equal(order.payBy, f.now + 30 * 60000); + assert.equal(order.priceAtomicXmr, '106666666667'); assert.equal(order.payBy, f.now + 30 * 60000); assert.equal(order.additionalFee?.bps, 100); assert.equal(order.additionalFee?.address, ADDITIONAL_MARKET_FEE.address); }); diff --git a/tests/xmr-amount.test.ts b/tests/xmr-amount.test.ts index f844f00..bff0ab5 100755 --- a/tests/xmr-amount.test.ts +++ b/tests/xmr-amount.test.ts @@ -109,4 +109,4 @@ test('mock USD caller retains its original independently rounded amounts', () => assert.equal(parseXmr(quote.sellerNetXmr), gross - gross * BigInt(bps) / 10000n - gross / 100n); assert.equal(quote.invoice.address, null); } -}); \ No newline at end of file +});