Custodial markets
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Wikideck
|
||||
|
||||
**Production roadmap:** see the [production delivery plan](docs/production-plan.md) for agreed XMR-native pricing, trustless-settlement research, fixed-price-first launch scope, engineering milestones and decision gates. The current application remains USD-priced/mock-only until the versioned migration is implemented; the plan is not a live-payment or launch authorization.
|
||||
**Production roadmap:** see the [production delivery plan](docs/production-plan.md) for XMR-native pricing, custodial-market implementation, fixed-price-first launch scope and release gates. New v3 listings use exact native-XMR prices; historical v2 USD records keep their original terms. All payments remain simulated. The [custodial design and implementation status](docs/custodial-market-design.md) distinguish the sandbox ledger from future live custody.
|
||||
|
||||
Wikideck is an NFT blockchain where mining a block rewards the miner with a collectible card based on a Wikipedia page. Build collections, trade cards with other collectors, or host a peer with its own marketplace. Trading card games (TCGs), including custom games hosted by peers, are planned for a later release.
|
||||
|
||||
@@ -14,7 +14,7 @@ Mining uses **RandomX, the proof-of-work algorithm also used by Monero**, design
|
||||
- **Build collections.** Assemble a broad cabinet of discoveries or focus on a subject, series or theme. Browse public decks and exchange up to eight cards on each side in one atomic trade.
|
||||
- **Buy and sell in peer-hosted markets.** Running a peer lets you host a market for the shared chain's cards. Your community can be general-purpose or focus on a particular collection. Listings, auctions and orders belong to that market host; card ownership and mined transfers are shared through the blockchain.
|
||||
- **Support the host and project author.** Sales allocate a configurable hosting fee to the peer's market operator (2.5% by default) and a separate hardcoded **1% author fee** to the project's fixed XMR address. Both are deducted from seller proceeds; the default total is 3.5%. In the intended cryptocurrency settlement model the host retains its fee, while the author allocation is paid separately. Today all payment, fee and payout accounting is simulated.
|
||||
- **Cryptocurrency checkout.** Market prices are quoted in USD and designed to settle in **Monero (XMR)**. The current testnet checkout is mock-only: no real cryptocurrency is accepted or sent. The SHKeeper integration remains an offline prototype, not live checkout.
|
||||
- **Cryptocurrency checkout.** New market prices are quoted directly in **Monero (XMR)** atomic units. The current testnet checkout is mock-only: no real cryptocurrency is accepted or sent. The SHKeeper integration remains an offline prototype, not live checkout.
|
||||
- **Play TCGs — planned.** The roadmap is to use these same cards in trading card games and let peers host custom TCG experiences. There is no playable game engine, match system, deck legality or custom rules hosting yet. Current card stats are not a promise of future game balance.
|
||||
|
||||
## Peer ownership and customization
|
||||
@@ -60,6 +60,12 @@ For an existing host install, keep the old data directory intact and set `WIKIDE
|
||||
|
||||
## Run on your host
|
||||
|
||||
### Optional custody accounting preview
|
||||
|
||||
The marketplace's **Custody preview** tab is separate from existing mock checkout. Explicit `WIKIDECK_CUSTODY_MODE=sandbox` on the server enables fictitious test balances, signed fixed-price reservations and withdrawal holds on testnet only. Default is `off`; live modes and mainnet sandbox fail closed. Use a disposable data directory and configure `PUBLIC_ORIGIN` to the exact browser origin. No Monero addresses, wallet RPC or real withdrawals are available.
|
||||
|
||||
Reservations stop at `awaiting-lock` because reviewed NFT sale-lock consensus rules are not implemented. They can be cancelled while their outbox intent is blocked; they do not deliver cards. The isolated custody database is included by stopped-node backup/restore when present. PostgreSQL treasury accounting, real deposit observation, NFT settlement, signing controls and production review remain future work. See [the implementation record](docs/custodial-market-design.md).
|
||||
|
||||
Requirements: Node.js 24, npm, Git, CMake 3.24+, and a C/C++17 toolchain. The first native-engine configuration downloads the pinned RandomX source over HTTPS. On Windows, install a compatible toolchain (for example, MinGW-w64 and make) and use the matching CMake generator. Do not change generators in an existing `native/build` directory; use a fresh build directory when switching toolchains.
|
||||
|
||||
Install dependencies and build the native RandomX engine:
|
||||
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
# ADR 002 — Custodial XMR balances and marketplace settlement
|
||||
|
||||
Status: **implementation authorized; sandbox accounting foundation implemented, live custody disabled**. Updated 2026-10-20. Supersedes the preferred research direction in [ADR 001](settlement-feasibility.md), not its evidence or security warnings. Related: [production plan](production-plan.md).
|
||||
|
||||
## 1. Recommendation and scope
|
||||
|
||||
Use one accountable custodian per market, a pooled Monero treasury with deposit subaddresses, and an internal double-entry XMR ledger. Purchases move liabilities between ledger accounts; only deposits, withdrawals and treasury movements touch Monero. Start with fixed-price sales, one authoritative financial writer and no cross-host credit, user-to-user balance transfers, lending or live auctions.
|
||||
|
||||
The operator has authorized proceeding with implementation instead of the previously preferred arbitrated multisig route. The first increment below is an isolated sandbox; no wallets, payable addresses, live deposits, keys or funds were accessed. Production treasury infrastructure and new NFT consensus rules are not activated. Current mock v3 terms and historical v2 orders remain unchanged.
|
||||
|
||||
### Implemented first increment
|
||||
|
||||
- [Custody command domain](../shared/custody.ts): strict versioned sandbox consent, exact atomic amounts, custodian/network/actor/expiry/nonce binding and fixed author policy. Existing market signatures cannot debit custody balances.
|
||||
- [Custody engine](../server/market/custody.ts): isolated testnet SQLite/WAL/FULL-sync financial model, transactional double-entry postings, immutable-entry triggers, startup replay audits, durable receipts, one-time fictitious credits, exclusive fixed-price reservations, blocked outbox and cancellation of unstarted intents. Resource budgets stop new work without blocking release of existing holds. PostgreSQL remains the planned production store; this SQLite implementation is not approved for real custody.
|
||||
- [Custody UI](../src/CustodyPanel.tsx): marketplace “Custody preview” tab with reviewed signatures, private/paged balances and activity, test funding, listing/reservation and withdrawal-hold cancellation. A lost response retains the original signed envelope for replay rather than creating a new debit nonce.
|
||||
- HTTP endpoints `/api/custody` and `/api/custody/command` are off by default; explicit `WIKIDECK_CUSTODY_MODE=sandbox` enables testnet only. All other enabled modes and mainnet sandbox are rejected before node resources open. Requests use the node's origin/admission/body limits, per-route mutation throttling and sanitized errors. No observer/admin settlement or balance-credit endpoint exists.
|
||||
- [Stopped-node recovery](../server/recovery.ts) includes the optional custody sandbox database and its identity while continuing to accept existing two-database backups. Financial receipts are not pruned. Restore/startup checks do not substitute for production reconciliation.
|
||||
- Offline fixture methods exercise fee allocation and prepared-withdrawal identity/retry/cancellation boundaries. They never access Monero and are not callable through HTTP. Public reservations stop at `awaiting-lock`; there is **no automatic NFT delivery or settled sale** in this increment. No real-address withdrawal, deposit subaddress, wallet RPC, signer, author payout, reorg compensation or PostgreSQL adapter is implemented yet.
|
||||
|
||||
Validation: TypeScript/Vite build and 144 offline tests passed in the serial native-RandomX test run; the initial parallel run encountered a peer-gossip timeout. Browser smoke verified a disposable wallet's signed test credit, withdrawal hold and cancellation restoring the exact balance, plus a 390px viewport without horizontal overflow. No real funds were used. Tests and remaining acceptance gates are below; this is not launch readiness.
|
||||
|
||||
**Security claim:** operator-custodied XMR, seller-authorized and host-attested NFT sale completion, with durable accounting and recoverable coordination. Not trustless, not an atomic Monero/NFT swap, not insured, and not an independently consensus-verified Monero payment. The operator can steal, censor, become insolvent or lose keys; segregation and review reduce but do not remove that risk.
|
||||
|
||||
## 2. Identity and trust boundaries
|
||||
|
||||
- A balance belongs to `(custodianId, Wikideck network, chainId, Wikideck public-key identity, Monero network)`. Identical user keys at different hosts do not create shared balances. The UI must name the custodian and networks before any deposit.
|
||||
- Allocate a deposit subaddress lazily after authenticated proof of Wikideck key possession and custody-term acceptance. Store wallet generation/ID, account index, subaddress index, address and immutable beneficiary mapping **before** displaying it. Never reassign an old address; rotated addresses remain monitored. Bound allocations to prevent lookahead/storage abuse.
|
||||
- Subaddresses identify incoming outputs; wallet RPC can report output balances per subaddress, but these are **not the marketplace entitlement ledger**. After internal sales or pooled withdrawals those figures no longer represent what a customer is owed. There is no on-chain subaddress-to-subaddress purchase transfer.
|
||||
- Keep Wikideck wallet identity keys client-held. Monero spending keys are operator-held, separate from the market attestation key, admin credentials and application deployment credentials. A deposit proof does not authenticate its sender; deposits credit the preassigned beneficiary, not an arbitrary claimant supplying a txid.
|
||||
- Lost Wikideck keys are not recovered by publishing a Monero proof. Recommend an optional separately enrolled recovery credential and delayed, audited dual-control recovery; absent an approved recovery policy, no unilateral support reassignment. Withdrawal authorization requires fresh key proof and an independently enrolled second factor. Browser key storage/session security must be reviewed before custody.
|
||||
|
||||
## 3. Components and storage
|
||||
|
||||
| Component | Responsibility / access |
|
||||
| --- | --- |
|
||||
| Market API | Validates signed, versioned intents; presents balances, deposit details and receipts. No treasury spending key or generic wallet RPC proxy. |
|
||||
| Ledger and order coordinator | Sole authoritative transactional writer for reservations, entries, order transitions, inbox/outbox and receipt/idempotency records. |
|
||||
| Deposit observer | Private wallet scanner and owned Monero daemon; observes output identity, canonical inclusion and unlock state. Prefer view-only operation; spent-state reconciliation requires current signed key images or another reviewed spend-aware source. |
|
||||
| Wikideck observer / relayer | Tracks canonical lock, completion, cancellation and reorg events using durable cursors; retries exact stored messages. Cannot edit financial balances. |
|
||||
| Treasury worker / isolated signer | Serializes spend construction, validates approved withdrawal intents, destination, amount, fee cap and policy; persists prepared transactions before relay. Independent limits and approvals, not blind signing for API requests. |
|
||||
| Reconciler and operations | Independently compares ledger, wallet and chain observations; alerts/halts affected operations; administers reviewed compensations, not arbitrary balance edits. |
|
||||
|
||||
Recommend PostgreSQL for the eventual production financial journal: exact integer `NUMERIC(...,0)` amounts, row locking/serializable transactions, unique constraints and managed point-in-time backups. This is a **proposed database decision**, not a dependency change. SQLite remains suitable for disposable models; the existing market SQLite and disconnected settlement lab are not a production custody ledger. Keep financial order transitions and journal entries in one database transaction; bridge external chain/wallet effects with a durable outbox, never a cross-database pretend transaction. One active writer/treasury worker initially; failover must fence the old worker before spending resumes.
|
||||
|
||||
Minimal entities: custodians/network bindings, authenticated customer identities/recovery enrollment, deposit address mappings, observed outputs and inclusion history, ledger accounts/journals/entries, immutable sale terms, purchase reservations, chain events/cursors, withdrawal intents/attempts/prepared transactions, outbox/inbox, signed receipts and audited cases/approvals. Mutable projections are rebuildable; history and external-effect identity are not discarded.
|
||||
|
||||
Use canonical atomic-unit strings in APIs and signatures and `bigint` internally; never parse RPC monetary JSON through JavaScript `Number`. Pin and test an exact-integer RPC codec. Individual amounts and aggregate balances need explicit bounds; aggregate liabilities must not inherit a per-order bound accidentally.
|
||||
|
||||
## 4. Accounting model and invariants
|
||||
|
||||
Customer balances are liabilities, partitioned into `available`, `purchase-reserved`, `sale-pending` and `withdrawal-reserved`. Show uncredited deposit observations separately. Keep author commission liabilities, operating revenue, treasury assets, withdrawal-in-flight clearing, network expense and operator capital distinct. Pending credits are not spendable.
|
||||
|
||||
Example postings (debits/credits follow normal asset/liability conventions):
|
||||
|
||||
| Event | Debit | Credit |
|
||||
| --- | --- | --- |
|
||||
| Eligible deposit | Treasury XMR asset | Customer available liability |
|
||||
| Purchase reservation | Buyer available liability | Buyer order-reserved liability |
|
||||
| Mature canonical sale | Buyer order-reserved liability | Seller available liability + author payable liability + earned host commission |
|
||||
| Safe cancellation | Buyer order-reserved liability | Buyer available liability |
|
||||
| Withdrawal reserve | Customer available liability | Customer withdrawal-reserved liability |
|
||||
| Confirmed withdrawal | Customer withdrawal-reserved liability | Treasury XMR asset (recipient amount + approved actual network fee) |
|
||||
|
||||
For the withdrawal row, reserve recipient amount plus a signed maximum fee first, release unused fee allowance in a balanced posting, and retain an in-flight asset/liability reconciliation bridge between relay and confirmation. A treasury movement between operator wallets is an asset reclassification, not a customer deposit. The detailed chart of accounts, fee clearing and accounting recognition timing require review before implementation.
|
||||
|
||||
Mandatory invariants:
|
||||
|
||||
- Every posted journal balances exactly in atomic XMR; journals are immutable. Corrections are linked compensating entries with reason and approval.
|
||||
- No spendable customer account goes negative; reservation checks, posting and order state change commit together. Concurrent purchases/withdrawals cannot spend the same available balance.
|
||||
- Every order reserves at most one gross and settles or cancels once. A nonce reused with different content is rejected; retries return the original receipt.
|
||||
- Count each economic deposit once using wallet/network plus transaction/output identity (not txid alone or a reorg-sensitive global index). Pin the supported RPC representation and output-key/index mapping in fixtures; aggregated reports must not duplicate individual outputs. Spending the pooled output later does not erase its customer's credited liability.
|
||||
- Maintain at least 100% backing of recognized customer/author liabilities, plus separately funded operating/fee/loss capital. Reconcile assets and liabilities on the same snapshot with in-flight withdrawals, locked change and internal treasury transfers accounted exactly once. Locked assets can provide backing but not immediate withdrawal liquidity.
|
||||
- Never borrow customer funds to fund expenses, reserves or promised compensation. Revenue withdrawal is blocked if backing/liquidity limits fail. Reserve proofs alone do not prove liabilities or solvency.
|
||||
|
||||
## 5. Deposit lifecycle
|
||||
|
||||
`observed → confirming/locked → available`, or `orphaned / quarantined`.
|
||||
|
||||
Scan authoritative wallet-owned outputs, not buyer screenshots, tx proofs or callbacks. Require healthy daemon/wallet synchronization, accepted canonical inclusion, a versioned confirmation threshold and actual wallet-reported unlock eligibility. No mempool credit; no wall-clock-only maturity. Persist inclusion block hashes and rescan overlapping ranges after restarts/reorgs; maintain wallet allocation lookahead from backed-up mappings. Unknown deposits go to a suspense queue, not operating revenue.
|
||||
|
||||
Multiple/partial deposits simply accumulate when individually eligible; deposits are not invoices and do not reserve a listing. Credit the actual valid amount, with no conversion or hidden deposit fee in the proposed baseline. Tiny output handling must be resource-bounded without silently confiscating legitimate funds. Disabling new deposit displays cannot stop payments to previously issued addresses: continue monitoring and handling those liabilities.
|
||||
|
||||
If a previously credited deposit is orphaned, append a reversal/restricted-balance case and freeze affected spending. Do not silently debit unrelated customers or pretend a completed withdrawal can be undone. Record any already-consumed shortfall as an operator loss/receivable under approved policy, with incident exposure capped by funded capital. Deep reorg risk is residual, not eliminated by confirmations.
|
||||
|
||||
## 6. Fixed-price sale lifecycle and NFT coordination
|
||||
|
||||
Proposed order states: `funds-reserved → lock-pending → locked → completion-submitted → delivery-confirming → settled`; alternatives: `cancel-pending → cancelled`, or `review` at any uncertain boundary. Seller proceeds are not spendable before `settled`.
|
||||
|
||||
1. Seller signs fresh custody-protocol listing terms: exact card/sequence, allowed host settlement key, exact XMR gross, fee split/beneficiaries, protocol/network/order domain and validity rules. Buyer reviews and signs the immutable checkout quote, including the custodian, recipient identity, maturity and cancellation policy. V3 mock signatures do not authorize debiting real balances; use a new domain/version with explicit consent.
|
||||
2. In one financial transaction, validate terms/availability, reserve funded **available** buyer balance, allocate an order ID and store the lock outbox intent. A local reservation protects buyer funds, not the NFT. If the seller has already transferred the card elsewhere, fail safely and release only after no live completion authority remains.
|
||||
3. A new, separately reviewed Wikideck sale-lock transaction binds card/sequence, seller, buyer, order/market/chain IDs, terms digest, host public key and consensus-height deadlines. Ownership remains with the seller, but conflicting transfers and locks are rejected. Obtain the seller's exact authorization; the host must not be able to redirect the card to itself or another buyer.
|
||||
4. After lock maturity and a durable reservation, the host signs a narrowly scoped funding/release attestation and relays the matching sale completion. Validators verify deterministic lock/signature/deadline rules, **not** Monero RPC or internal balances. They trust the host's assertion that customer funds were reserved under its deposit policy. A compromised host can lie about funding; seller assent and host accountability are the remaining protections.
|
||||
5. On canonical completion at the selected Wikideck depth, commit once: consume buyer reservation and allocate seller, author and host amounts. The outbox/inbox and unique completion identity recover a crash after chain delivery but before journal commit. No Monero transaction occurs for this purchase. Display inclusion and maturity separately; the existing roughly 15-minute block cadence can make this slow.
|
||||
6. Cancellation releases buyer funds only after canonical cancellation/expiry is mature and all previously signed completion paths are invalid under consensus. Never refund on a website timer, mempool absence, RPC timeout or current lack of delivery alone. If completion and cancellation race, canonical rules choose one; an uncertain fork remains reserved/reviewed until resolved.
|
||||
|
||||
NFT lock/completion/cancel rules are **not implemented or fully specified here**. Exact boundary heights, expiry, in-block ordering, seller authorization lifetime, host-key rotation/revocation, third-party submission of stored witnesses and fork behavior need a dedicated consensus specification and tests. Existing ordinary signed transfers cannot safely substitute because they can be submitted after a refund.
|
||||
|
||||
If the host disappears, a reviewed height-expiry path should eventually return NFT control without a host signature. It cannot recover custodial XMR; financial recovery still depends on operator backups/keys. A stalled chain delays safe cancellation. This is a recoverable saga with explicit trust, not an atomic commit across chains.
|
||||
|
||||
**Reorg after settlement:** pause affected withdrawals and resale, preserve the original postings, and enter review. If the same completion rematures, resolve without a second credit. If it is invalidated permanently, compensate with linked entries and prevent both a new delivery and buyer reimbursement. If proceeds have already left custody, use approved operator capital/loss procedures; never assume reversing a database entry recovers XMR or automatically confiscate unrelated balances. Confirmation/hold depths and funded exposure limits must reflect measured Wikideck chain security before launch.
|
||||
|
||||
## 7. Fees and small prices — proposed policy change
|
||||
|
||||
Retain exact native-XMR listing prices, no economic listing minimum, current independent downward rounding and fixed 1% author allocation. Gross equals seller credit plus author credit plus host commission. Internal purchase settlement has **zero Monero network fee**; one-atomic-unit sales are possible when rounded commissions are zero. Resource/rate limits still apply.
|
||||
|
||||
Propose: depositor pays their external sending fee; withdrawing beneficiary pays the quoted actual withdrawal fee; operator funds treasury sweeps/consolidation from disclosed operating capital. This replaces the earlier per-sale network-cost model **only if approved**. Do not run the existing per-sale payout-budget rejection unchanged for ledger purchases.
|
||||
|
||||
Author fees accrue as separately identified, fully backed liabilities associated with the fixed policy recipient; do not assume a Monero address is a Wikideck login or let an admin silently redirect it. A reviewed address-control enrollment or fixed-destination payout procedure is required. They may accumulate until economical to pay, but cannot become host revenue; timing, fee payer and small/dormant balance treatment need explicit terms. This accumulation is proposed, not already authorized.
|
||||
|
||||
No hidden withdrawal minimum or flat fee. Baseline: exact recipient amount plus a user-signed maximum fee; decline/defer before signing if it cannot be funded. A tiny balance remains owed even if a standalone withdrawal is currently uneconomic. Define fee-aware full-balance withdrawal, closure and unclaimed-funds handling before launch; do not promise every atomic balance can be withdrawn alone economically. Defer batching initially because it complicates fee allocation, privacy and partial outcomes.
|
||||
|
||||
## 8. Withdrawal protocol
|
||||
|
||||
`requested → reserved → approved → preparing → prepared → relay-unknown/submitted → confirming → complete`, with `review` for uncertainty. Cancellation is straightforward only before creation of an externally usable signed transaction.
|
||||
|
||||
- Fresh signed authorization binds custodian, both network identities, actor, destination, exact recipient amount, max fee, withdrawal ID/nonce, policy version and expiry. Verify Monero network/checksum; initially accept literal standard/subaddresses only, no DNS/OpenAlias or integrated-address ambiguity. Show full destination and total debit; use step-up authentication, new-destination cooling and independent approval above reviewed risk limits.
|
||||
- Atomically reserve amount plus max fee. A single fenced treasury worker allocates an attempt before wallet I/O. Check pooled unlocked liquidity, not the depositor's original outputs. Do not promise immediate withdrawals when change is locked or cold treasury replenishment is required.
|
||||
- Construct without relay, check actual fee/destination/amount/change against authorization, and durably encrypt/store signed transaction identity, metadata, selected-input identifiers and recovery material before broadcasting. `transfer` with `do_not_relay`/`get_tx_metadata` followed by `relay_tx` is an RPC capability, **not** a database transaction or automatic idempotency guarantee. Test the pinned wallet's construction/cache behavior, lost responses and input reservation explicitly.
|
||||
- On relay timeout/crash, reconcile or retransmit the **same** prepared transaction. Do not construct another payment or release the reservation just because an RPC says failed/not found. A signed transaction may still be relayed by someone holding it; replacement/cancellation requires a reviewed proof that the old spend can no longer succeed, including reorg exposure. Uncertain attempts stay quarantined.
|
||||
- After canonical withdrawal maturity, close liability once, account actual fee and release unused allowance. Preserve transaction keys/metadata securely for support; never expose secrets in logs. Outgoing reorgs reopen tracking, not a second withdrawal. No batch/automatic split in the first release; if construction yields multiple transactions, reject before relay or require an explicitly reviewed multi-transaction attempt model.
|
||||
|
||||
## 9. Custody operations, privacy and recovery
|
||||
|
||||
- Recommend a view-only deposit observer plus an isolated, policy-constrained spending service and offline-signing/dual-control cold treasury. Cold storage and hot liquidity caps limit routine exposure; old deposit addresses may continue receiving into a hot wallet, so sweeping/rotation does not create a cryptographic total-deposit cap. A compromised signer can spend its wallet regardless of application limits. Pin and review the actual signing technology; do not assume arbitrary HSM/threshold compatibility with Monero.
|
||||
- Private fixed-origin authenticated RPC only; no public wallet RPC, browser credentials, arbitrary URL fetches or unrestricted proxy. Least privilege, method allowlists, independent signer policy, bounded bodies/timeouts, service authentication and encrypted transport where crossing hosts. Disable production capability by default and fail closed on network/profile mismatch.
|
||||
- Track daemon/wallet lag, canonical tip disagreement, reconciliation differences, queued/uncertain withdrawals, locked liquidity, deposit/reservation concentration and cold/hot exposure. Maintain separate switches for new deposits, new orders, author payouts and withdrawals. During incidents continue monitoring existing liabilities and provide a support/recovery path; blanket freezes are not a substitute for reconciliation.
|
||||
- Back up the financial journal/WAL, immutable customer-to-subaddress mapping and allocation high-water marks, wallet files/cache/seed under separate encrypted custody, policy versions, attestation keys, signed intents and prepared/outgoing transactions. Wallet seed alone cannot restore internal customer balances or all outgoing destination metadata. Restore into an isolated, non-spending environment; reconcile external transactions before enabling the single fenced signer. Drill full recovery and key compromise response.
|
||||
- Custodian learns deposit-to-Wikideck-wallet, purchase and withdrawal linkage despite Monero's public-chain privacy. Keep balances, addresses and history private/actor-scoped; minimize access/retention and redact analytics/logs. Audit exports and legal disclosure need controlled procedures. Hash-chained journal exports help tamper detection but do not prove honest custody or prevent privileged tampering without independent copies/review.
|
||||
- No automatic global wallet bans. Distinguish account-security restrictions, disputed cases and evidence-backed local service decisions; publish response, appeal and correction rules.
|
||||
- Obtain qualified US custody/money-transmission, sanctions, consumer, privacy and tax advice before funds are accepted. Decide customer asset segregation, insolvency treatment, loss allocation, closure and support obligations with counsel. No claim of insurance, legal exemption or guaranteed reimbursement without actual reviewed arrangements and funding.
|
||||
|
||||
## 10. Integration and staged acceptance
|
||||
|
||||
Reuse [exact XMR amounts](../shared/xmr-amount.ts), [pricing splits](../shared/xmr-pricing.ts), [v3 mock domain](../shared/market-v3.ts) and receipt concepts in [market store](../server/market/store.ts). The [settlement lab](../server/market/settlement-lab.ts) provides failure-journal ideas only: its USD invoice and per-order external payout assumptions are not this balance model. Prefer direct pinned Monero wallet/daemon infrastructure for the first custody adapter; SHKeeper is not the customer-liability ledger and its documented USD invoicing is not needed for account deposits. No adapter is connected by this proposal.
|
||||
|
||||
1. **Approve scope and economics:** custodian identity, custody disclosure, account recovery, beneficiary enrollment, withdrawal/author fee policy, balance accumulation, loss/reserve obligations and numerical risk/confirmation/hold policies. These remain open; no fabricated thresholds or support SLA.
|
||||
2. **Offline financial model:** property-test conservation, nonnegative available balances, concurrency, replay, output deduplication, tiny amounts, duplicate workers, crash at every journal/outbox boundary and repeat restoration. Keep mocks isolated; no mock balances become real opening liabilities.
|
||||
3. **Separate NFT consensus specification:** lock/completion/cancel/expiry/host-attestation semantics, cross-market double sales, stale signatures, host disappearance and both fork races. Independent review, versioned network activation and migration approval; no silent changes to existing chain identity.
|
||||
4. **Disposable Monero integration:** only with separate network/budget/access approval. Verify lossless RPC, output identity, wallet restore/lookahead, locked/double-output deposits, reorgs, construction-response loss, signer restrictions, relay ambiguity and withdrawal recovery. Independent security/accounting review and measured load/capacity tests.
|
||||
5. **Operational release:** funded reserves and liquidity, counsel/terms, penetration test, audited restore/incident drills, customer statements/support and operator sign-off. Start with declared low exposure and manual withdrawal approvals; expand only after measured evidence. No live auction release.
|
||||
|
||||
Required failure outcomes: duplicate deposit credits are impossible; insufficient/unlocked-pending funds cannot buy; two concurrent buys/withdrawals cannot overspend; delivery and refund cannot both mature; completed-sale replay cannot mint ledger credit; a lost relay response never causes a second payment; restored journals cannot resend old withdrawals; operator compensation never silently drains another customer; deep reorgs create visible cases rather than imaginary rollback of Monero.
|
||||
|
||||
## Primary sources and remaining evidence
|
||||
|
||||
- [Monero subaddresses](https://docs.getmonero.org/public-address/subaddress/) and [wallet RPC `create_address`](https://docs.getmonero.org/rpc-library/wallet-rpc/#create_address): address allocation/indexing, not an internal financial ledger.
|
||||
- [Wallet-owned outputs](https://docs.getmonero.org/rpc-library/wallet-rpc/#incoming_transfers), [transfers](https://docs.getmonero.org/rpc-library/wallet-rpc/#get_transfers), [balances](https://docs.getmonero.org/rpc-library/wallet-rpc/#get_balance): output identity, subaddress association, synchronization, locking and balance observations. Exact production-version fields and stability must be fixture-tested.
|
||||
- [Constructing transactions](https://docs.getmonero.org/rpc-library/wallet-rpc/#transfer), [relay](https://docs.getmonero.org/rpc-library/wallet-rpc/#relay_tx), [offline signing](https://docs.getmonero.org/rpc-library/wallet-rpc/#sign_transfer): preparation/signing/relay separation; signed payloads must be checked, protected and recovered.
|
||||
- [Transfer history](https://docs.getmonero.org/rpc-library/wallet-rpc/#get_transfer_by_txid) and [rescan](https://docs.getmonero.org/rpc-library/wallet-rpc/#rescan_blockchain): wallet restore can lose outgoing destinations, transaction keys and notes. Blockchain replay is not a backup for customer accounting.
|
||||
|
||||
Source review: 2026-10-20; documentation is capability evidence, not an audited implementation of this design. No wallet/RPC credentials or funds were accessed.
|
||||
@@ -7,17 +7,19 @@ Status: active engineering plan; **not launch or payment authorization**. Update
|
||||
| Topic | Decision / boundary |
|
||||
| --- | --- |
|
||||
| Initial deployment | One operator, the project owner, in the United States; a capable x86 server and low initial user volume. Design for independently operated markets and larger volumes, without claiming capacity before measurement. |
|
||||
| Settlement architecture | **Research trustless XMR/NFT exchange first.** If no practical reviewed design meets requirements, return for explicit approval of a host-custodial fallback. Do not silently select custody, market attestation or consensus escrow. |
|
||||
| Settlement architecture | Trustless and arbitrated routes were investigated; the operator has now authorized **implementing the custodial pooled-balance market**. [ADR 002](custodial-market-design.md) tracks the first isolated sandbox accounting/UI increment and remaining treasury/consensus work. Implementation authorization is not live deposit, key access, consensus activation or launch authorization. |
|
||||
| Host responsibility | Host responsibility, reserves and exposure caps are the selected policy direction. Exact liability, amounts and funding remain undecided. This is not authorization for custody and does not make settlement trustless or guarantee solvency. The author fee does not make the author a guarantor of independent markets. |
|
||||
| First live market | Fixed-price sales first. Live auctions remain disabled until their funding, nonpayment and abuse controls pass a separate release gate. Existing sandbox auctions can remain clearly labeled. |
|
||||
| Pricing | **Native XMR prices**, replacing the earlier USD-price decision. No economic listing minimum. Represent nonzero prices as integer atomic units: the smallest representable positive XMR amount is 10^-12 XMR, not an imposed USD/XMR minimum. Zero-price gifts should use free transfers unless separately designed. |
|
||||
| Sending/network fees | Buyer funds their wallet's XMR payment-sending fee. Seller funds disclosed settlement/disbursement network costs; exact treatment of author-leg costs, fee estimates and overruns must be approved before live checkout. These costs are separate from hosting commission and the fixed 1% author commission. |
|
||||
| Uneconomic small sales | Listing a tiny price is allowed. Before accepting payment, block checkout if disclosed network costs and commissions cannot leave a valid positive seller payout, unless an explicit different fee/subsidy policy is approved. No unannounced subsidy or automatic balance accumulation. |
|
||||
| Sending/network fees | Earlier model: buyer pays sending fee, seller pays disclosed per-sale disbursement costs. Custodial proposal: no per-purchase Monero transfer; depositor pays external sending, withdrawing beneficiary pays approved actual withdrawal fee, operator pays treasury movements. Author-fee withdrawal costs and overruns require explicit policy approval. No fee change is implemented. |
|
||||
| Uneconomic small sales | No economic listing minimum. Custodial proposal allows tiny internal settlements and explicitly disclosed backed balance accumulation; withdrawal economics are separate. This changes the earlier per-sale payout-budget model and requires approval under ADR 002. Never silently discard a tiny liability or subsidize a fee. |
|
||||
| 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. |
|
||||
|
||||
**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.
|
||||
|
||||
**Current design deliverable:** [custodial market design](custodial-market-design.md). Read the workstreams below with this update: pooled customer liabilities replace per-order incoming invoices and external sale payouts in the proposed architecture. Earlier trustless-first research gates are historical, not a prerequisite to investigating custody. Exact ledger, withdrawal, fee, custody/legal and host-attested NFT consensus rules still require approval and evidence; the existing mock domain cannot authorize real balance debits.
|
||||
|
||||
## 2. Resolve the cross-chain requirement before selecting architecture
|
||||
|
||||
### What “confirmed before on-chain completion” requires
|
||||
@@ -53,7 +55,7 @@ Can begin independently of P0, but must not hardwire a settlement architecture.
|
||||
- Migration plan: preserve existing USD/mock order history in its original version. Close/relist old open listings with fresh seller authorization; define disposition of bids/reservations before migration. No automated reinterpretation, deletion or mainnet asset migration.
|
||||
- Resolve SHKeeper's documented USD-denominated invoice input. Verify whether a supported deployed version offers exact native-XMR invoices and stable amounts; otherwise research direct Monero wallet-RPC/payment infrastructure or a reviewed adapter. **Do not reintroduce an approximate USD conversion behind XMR-native pricing.** SHKeeper is the preferred processor where compatible, not a justification to violate exact-price requirements.
|
||||
- Separate payment-sending fees, hosting/author commissions and payout network fees in quotes/accounting. Define who pays author-leg network costs and fee-overrun caps. Handle sub-atomic commissions without overcharging. Preserve the fixed 1% address/rate in the distributed policy.
|
||||
- Checkout preflight determines economic viability before displaying a payable request. Never accept money and only then discover it cannot be disbursed economically. Accumulated balances/batching are not approved and would require a separate custody/privacy/withdrawal design decision.
|
||||
- Checkout preflight determines economic viability under the selected architecture before accepting an order. For the custodial proposal, internal sales have no per-sale Monero payout cost; deposit eligibility, available balance and withdrawal economics are separate checks. Accumulated balances are proposed in ADR 002 but not implemented/approved for funds; batching remains deferred. Disclose uneconomic withdrawals before deposit rather than silently forfeiting balances.
|
||||
|
||||
Acceptance: exact-amount property/boundary tests, versioned signature tests, zero/tiny/large-price and fee-spike tests; migration dry run/rollback on copied data; all displayed totals reconcile; no USD-authoritative live path; user review of fee semantics. No live payments yet.
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: **proposed; research checkpoint, not an approved exchange protocol**. Source review: 2026-10-20. Related: [production plan](production-plan.md). No processor instance, credentials, wallet or funds were accessed. No swap implementation was installed or run.
|
||||
|
||||
**Direction update, 2026-10-20:** the operator has authorized implementing a pooled custodial XMR balance model instead. [ADR 002 — custodial market design](custodial-market-design.md) tracks the implemented isolated sandbox foundation and remaining production work. The recommendations and next gates below are retained as the earlier trustless/arbitrated research record, not a requirement to implement multisig first. Live funds, treasury key access, production fee-policy activation and new consensus activation remain gated. The distinctions between proof, trust, custody and atomicity below still apply.
|
||||
|
||||
## Decisions and recommendation
|
||||
|
||||
- Operator approved preparatory on-chain NFT locks/reservations for design research. They must not transfer completed-sale ownership before the agreed XMR condition. This does **not** approve a custodian, lock authority, timeout or new consensus rule.
|
||||
@@ -9,7 +11,7 @@ Status: **proposed; research checkpoint, not an approved exchange protocol**. So
|
||||
- No practical reviewed Wikideck-compatible trustless design has yet been demonstrated. This is an evidence gap, **not a proof of impossibility**. M0 remains open pending protocol specification, disposable-fixture spike, failure tests, specialist review and operator approval.
|
||||
- Do not connect SHKeeper or replace trustless exchange with host attestation/custody by default. If the researched route fails its gates, return for explicit fallback approval.
|
||||
|
||||
**Recommended path forward:** build proof-backed order evidence and fair, host-scoped reputation as a non-settlement safety layer; for future live fixed-price sales, evaluate per-order 2-of-3 Monero multisig escrow with independent dispute arbitration as the pragmatic buyer-protection candidate. Keep permissionless conditional XMR↔NFT research separate. A payment proof and delivery record cannot return direct-to-seller XMR. The escrow candidate is **arbitrated, not trustless**: the arbitrator and wallet/recovery procedures become material trust and availability dependencies. This recommendation does not authorize live funds, key custody, or new consensus rules; independent security review and explicit release gates remain mandatory.
|
||||
**Earlier recommendation (superseded as the preferred investigation):** build proof-backed order evidence and fair, host-scoped reputation as a non-settlement safety layer; evaluate per-order 2-of-3 Monero multisig escrow with independent dispute arbitration as a live-sale candidate. Keep permissionless conditional XMR↔NFT research separate. A payment proof and delivery record cannot return direct-to-seller XMR. The escrow candidate is **arbitrated, not trustless**: the arbitrator and wallet/recovery procedures become material trust and availability dependencies. No live funds, key custody, or new consensus rules were authorized by that recommendation.
|
||||
|
||||
## Evidence and compatibility boundaries
|
||||
|
||||
@@ -111,7 +113,7 @@ The operator's proposed accountability route is technically plausible as a **mar
|
||||
|
||||
**Implementation recommendation:** proceed with an offline evidence and case-journal prototype for fixed-price abuse prevention; do not require buyers to publish transaction keys/proofs without explicit consent. A buyer can submit Monero proof, txid, order-specific recipient address and exact amount; a named verifier checks it against a synced chain view and later correlates it with the canonical Wikideck transfer. Record evidence and an appealable case; a host may apply a local restriction only under a published review policy. This is not automatic settlement, a global blacklist or buyer compensation. Keep M0's consensus proof and two-sided atomicity questions open.
|
||||
|
||||
## Recommended product path after comparing XMR market patterns
|
||||
## Earlier recommended product path (superseded by custodial investigation)
|
||||
|
||||
**Recommendation: use layered protection, with an opt-in evidence/reputation layer now and an arbitrated multisignature escrow as the first serious live-sale candidate. Do not treat proof/reputation as settlement, and do not make direct-to-seller XMR the default live purchase.**
|
||||
|
||||
|
||||
Executable
+340
@@ -0,0 +1,340 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, chmod } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { IS_MAINNET } from '../../shared/network.ts';
|
||||
import { NETWORK } from '../../shared/testnet.ts';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
||||
import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
|
||||
import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessage,
|
||||
type CustodyCommand, type CustodyActivity, type CustodyInfo, type CustodyListing,
|
||||
type CustodyOrder, type CustodyWithdrawal, type CustodyPage } from '../../shared/custody.ts';
|
||||
import { CHAIN_ID, verifySignature } from '../testnet/consensus.ts';
|
||||
|
||||
export function custodyMode(env: NodeJS.ProcessEnv = process.env): 'off' | 'sandbox' {
|
||||
const mode = env.WIKIDECK_CUSTODY_MODE ?? 'off';
|
||||
if (mode !== 'off' && mode !== 'sandbox') throw new Error('Custody supports off or sandbox only; live custody is disabled');
|
||||
if (IS_MAINNET && mode !== 'off') throw new Error('Custody sandbox cannot run on mainnet');
|
||||
return mode;
|
||||
}
|
||||
export class CustodyError extends Error {}
|
||||
type Card = { title: string };
|
||||
type Options = { feeBps: number; card(cardHash: string, owner: string, sequence: number): Card | undefined; now?: () => number };
|
||||
type Account = { id: string; normal: 'debit' | 'credit'; balance: string };
|
||||
type Line = { account: string; normal: 'debit' | 'credit'; debit?: bigint; credit?: bigint };
|
||||
type Result = CustodyActivity | CustodyListing | CustodyOrder | CustodyWithdrawal;
|
||||
const AGGREGATE_MAX = 10n ** 30n - 1n;
|
||||
const LIMIT = 10000; // Explicit disposable-sandbox resource budget; never archive financial history silently.
|
||||
const available = (actor: string) => `customer:${actor}:available`;
|
||||
const orderAccount = (order: CustodyOrder) => `customer:${order.buyer}:purchase:${order.id}`;
|
||||
const withdrawalAccount = (w: CustodyWithdrawal) => `customer:${w.actor}:withdrawal:${w.id}`;
|
||||
const debit = (account: string, amount: bigint, normal: 'debit' | 'credit' = 'credit'): Line => ({ account, normal, debit: amount });
|
||||
const credit = (account: string, amount: bigint, normal: 'debit' | 'credit' = 'credit'): Line => ({ account, normal, credit: amount });
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
function requireState(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new CustodyError(message);
|
||||
}
|
||||
|
||||
/** Isolated, durable OFFLINE/SANDBOX financial model. No wallet RPC, keys, addresses or NFT signer. */
|
||||
export class CustodyMarket {
|
||||
private readonly db: DatabaseSync;
|
||||
private readonly options: Options;
|
||||
readonly id: string;
|
||||
private readonly now: () => number;
|
||||
private constructor(db: DatabaseSync, options: Options, id: string) {
|
||||
this.db = db; this.options = options; this.id = id; this.now = options.now ?? Date.now;
|
||||
}
|
||||
static async open(directory: string, options: Options): Promise<CustodyMarket> {
|
||||
if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet');
|
||||
if (!Number.isSafeInteger(options.feeBps) || options.feeBps < 0 || options.feeBps > 5000) throw new Error('Invalid custody fee');
|
||||
const folder = join(directory, 'testnet');
|
||||
await mkdir(folder, { recursive: true, mode: 0o700 });
|
||||
const file = join(folder, 'custody-sandbox.sqlite');
|
||||
const db = new DatabaseSync(file);
|
||||
try {
|
||||
db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE IF NOT EXISTS custody_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS accounts(id TEXT PRIMARY KEY,normal TEXT NOT NULL CHECK(normal IN ('debit','credit')),balance TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS journals(id TEXT PRIMARY KEY,kind TEXT NOT NULL,created INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS entries(journal TEXT NOT NULL REFERENCES journals(id),position INTEGER NOT NULL,
|
||||
account TEXT NOT NULL REFERENCES accounts(id),debit TEXT NOT NULL,credit TEXT NOT NULL,PRIMARY KEY(journal,position));
|
||||
CREATE TABLE IF NOT EXISTS listings(id TEXT PRIMARY KEY,card TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS custody_one_card ON listings(card) WHERE status IN ('open','reserved');
|
||||
CREATE TABLE IF NOT EXISTS orders(id TEXT PRIMARY KEY,buyer TEXT NOT NULL,seller TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS custody_orders_buyer ON orders(buyer);
|
||||
CREATE INDEX IF NOT EXISTS custody_orders_seller ON orders(seller);
|
||||
CREATE TABLE IF NOT EXISTS withdrawals(id TEXT PRIMARY KEY,actor TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS custody_withdrawals_actor ON withdrawals(actor);
|
||||
CREATE TABLE IF NOT EXISTS receipts(id TEXT PRIMARY KEY,digest TEXT NOT NULL,data TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS outbox(id TEXT PRIMARY KEY,kind TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS credits(actor TEXT PRIMARY KEY);
|
||||
CREATE TRIGGER IF NOT EXISTS entries_no_update BEFORE UPDATE ON entries BEGIN SELECT RAISE(ABORT,'Immutable financial entry'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS entries_no_delete BEFORE DELETE ON entries BEGIN SELECT RAISE(ABORT,'Immutable financial entry'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS journals_no_update BEFORE UPDATE ON journals BEGIN SELECT RAISE(ABORT,'Immutable financial journal'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS journals_no_delete BEFORE DELETE ON journals BEGIN SELECT RAISE(ABORT,'Immutable financial journal'); END;`);
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
let id: string;
|
||||
try {
|
||||
const row = db.prepare('SELECT value FROM custody_meta WHERE key=?').get('identity') as { value: string } | undefined;
|
||||
if (row) {
|
||||
const identity = JSON.parse(row.value);
|
||||
if (identity.version !== 1 || identity.network !== NETWORK || identity.chainId !== CHAIN_ID ||
|
||||
identity.mode !== 'sandbox' || identity.policy !== CUSTODY_POLICY ||
|
||||
!/^[a-f0-9-]{36}$/.test(identity.id)) throw new Error('Custody identity/schema mismatch');
|
||||
id = identity.id;
|
||||
} else {
|
||||
id = randomUUID();
|
||||
db.prepare('INSERT INTO custody_meta VALUES(?,?)').run('identity', JSON.stringify({ version: 1,
|
||||
mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, chainId: CHAIN_ID, id }));
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
||||
if (process.platform !== 'win32') await chmod(file, 0o600);
|
||||
const market = new CustodyMarket(db, options, id);
|
||||
market.audit(); // Refuse to start if projections no longer match the immutable journal.
|
||||
return market;
|
||||
} catch (error) { db.close(); throw error; }
|
||||
}
|
||||
close() { this.db.close(); }
|
||||
info(): CustodyInfo {
|
||||
return { enabled: true, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, chainId: CHAIN_ID,
|
||||
custodianId: this.id, feeBps: this.options.feeBps, liveDeposits: false, liveWithdrawals: false, nftSettlement: false };
|
||||
}
|
||||
private atomic<T>(fn: () => T): T {
|
||||
this.db.exec('BEGIN IMMEDIATE');
|
||||
try { const result = fn(); this.db.exec('COMMIT'); return result; }
|
||||
catch (error) { this.db.exec('ROLLBACK'); throw error; }
|
||||
}
|
||||
private balance(id: string): bigint {
|
||||
const row = this.db.prepare('SELECT balance FROM accounts WHERE id=?').get(id) as { balance: string } | undefined;
|
||||
return row ? BigInt(row.balance) : 0n;
|
||||
}
|
||||
private post(id: string, kind: string, lines: Line[]) {
|
||||
if (['mock-deposit', 'purchase-reservation', 'withdrawal-reservation'].includes(kind)) {
|
||||
requireState((this.db.prepare('SELECT COUNT(*) AS n FROM journals').get() as { n: number }).n < LIMIT,
|
||||
'Custody sandbox journal budget reached; existing reservations can still be cancelled');
|
||||
}
|
||||
const totals = lines.reduce((s, line) => s + (line.debit ?? 0n) - (line.credit ?? 0n), 0n);
|
||||
if (totals !== 0n || !lines.length) throw new Error('Unbalanced custody journal');
|
||||
this.db.prepare('INSERT INTO journals VALUES(?,?,?)').run(id, kind, this.now());
|
||||
for (const [position, line] of lines.entries()) {
|
||||
const d = line.debit ?? 0n, c = line.credit ?? 0n;
|
||||
if (d < 0n || c < 0n || (d > 0n && c > 0n)) throw new Error('Invalid journal line');
|
||||
if (d === 0n && c === 0n) continue;
|
||||
this.db.prepare('INSERT OR IGNORE INTO accounts VALUES(?,?,?)').run(line.account, line.normal, '0');
|
||||
const account = this.db.prepare('SELECT * FROM accounts WHERE id=?').get(line.account) as Account;
|
||||
if (account.normal !== line.normal) throw new Error('Account normal changed');
|
||||
const balance = BigInt(account.balance) + (line.normal === 'debit' ? d - c : c - d);
|
||||
requireState(balance >= 0n, 'Insufficient available sandbox balance');
|
||||
if (balance > AGGREGATE_MAX) throw new CustodyError('Custody aggregate amount limit reached');
|
||||
this.db.prepare('INSERT INTO entries VALUES(?,?,?,?,?)').run(id, position, line.account, d.toString(), c.toString());
|
||||
this.db.prepare('UPDATE accounts SET balance=? WHERE id=?').run(balance.toString(), line.account);
|
||||
}
|
||||
}
|
||||
private get<T>(table: 'listings' | 'orders' | 'withdrawals', id: string): T {
|
||||
const row = this.db.prepare(`SELECT data FROM ${table} WHERE id=?`).get(id) as { data: string } | undefined;
|
||||
requireState(row, 'Custody record not found');
|
||||
return JSON.parse(row.data) as T;
|
||||
}
|
||||
private saveOrder(order: CustodyOrder) {
|
||||
this.db.prepare('INSERT INTO orders VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data')
|
||||
.run(order.id, order.buyer, order.seller, order.status, JSON.stringify(order));
|
||||
}
|
||||
private saveWithdrawal(w: CustodyWithdrawal) {
|
||||
this.db.prepare('INSERT INTO withdrawals VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data')
|
||||
.run(w.id, w.actor, w.status, JSON.stringify(w));
|
||||
}
|
||||
private saveListing(l: CustodyListing) {
|
||||
this.db.prepare('INSERT INTO listings VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data')
|
||||
.run(l.id, l.cardHash, l.status, JSON.stringify(l));
|
||||
}
|
||||
browse(offset = 0): CustodyPage {
|
||||
requireState(Number.isSafeInteger(offset) && offset >= 0 && offset <= LIMIT, 'Invalid custody page');
|
||||
const rows = this.db.prepare("SELECT data FROM listings WHERE status IN ('open','reserved') ORDER BY rowid DESC LIMIT 25 OFFSET ?")
|
||||
.all(offset) as { data: string }[];
|
||||
return { info: this.info(), listings: rows.slice(0, 24).map(r => JSON.parse(r.data)), next: rows.length > 24 ? offset + 24 : null };
|
||||
}
|
||||
private activity(actor: string, offset: number): CustodyActivity {
|
||||
const orders = this.db.prepare('SELECT data FROM orders WHERE buyer=? OR seller=? ORDER BY rowid DESC LIMIT 25 OFFSET ?')
|
||||
.all(actor, actor, offset) as { data: string }[];
|
||||
const withdrawals = this.db.prepare('SELECT data FROM withdrawals WHERE actor=? ORDER BY rowid DESC LIMIT 25 OFFSET ?')
|
||||
.all(actor, offset) as { data: string }[];
|
||||
const sum = (prefix: string) => (this.db.prepare('SELECT balance FROM accounts WHERE id GLOB ?').all(prefix) as { balance: string }[])
|
||||
.reduce((s, r) => s + BigInt(r.balance), 0n).toString();
|
||||
return { availableAtomicXmr: this.balance(available(actor)).toString(),
|
||||
purchaseReservedAtomicXmr: sum(`customer:${actor}:purchase:*`), withdrawalReservedAtomicXmr: sum(`customer:${actor}:withdrawal:*`),
|
||||
mockCreditClaimed: !!this.db.prepare('SELECT 1 FROM credits WHERE actor=?').get(actor),
|
||||
orders: orders.slice(0, 24).map(r => JSON.parse(r.data)), withdrawals: withdrawals.slice(0, 24).map(r => JSON.parse(r.data)),
|
||||
next: orders.length > 24 || withdrawals.length > 24 ? offset + 24 : null };
|
||||
}
|
||||
execute(raw: unknown): Result {
|
||||
const c = custodyCommandSchema.parse(raw), message = custodyMessage(c), now = this.now();
|
||||
requireState(c.network === NETWORK && c.chainId === CHAIN_ID && c.custodianId === this.id &&
|
||||
c.expiresAt >= now && c.expiresAt <= now + 5 * 60_000 &&
|
||||
verifySignature(c.publicKey, c.actor, message, c.signature), 'Invalid or expired custody signature');
|
||||
if (c.action.type === 'activity') return this.activity(c.actor, c.action.offset);
|
||||
return this.atomic(() => {
|
||||
const key = `${c.actor}:${c.nonce}`, hash = digest(message);
|
||||
const prior = this.db.prepare('SELECT digest,data FROM receipts WHERE id=?').get(key) as { digest: string; data: string } | undefined;
|
||||
if (prior) { requireState(prior.digest === hash, 'Nonce already used for different custody terms'); return JSON.parse(prior.data) as Result; }
|
||||
if (!['cancel-listing', 'cancel-reservation', 'cancel-withdrawal'].includes(c.action.type)) {
|
||||
requireState((this.db.prepare('SELECT COUNT(*) AS n FROM receipts').get() as { n: number }).n < LIMIT,
|
||||
'Custody sandbox command budget reached; existing reservations can still be cancelled');
|
||||
}
|
||||
const result = this.mutate(c);
|
||||
this.db.prepare('INSERT INTO receipts VALUES(?,?,?)').run(key, hash, JSON.stringify(result));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
private mutate(c: CustodyCommand): Result {
|
||||
const a = c.action;
|
||||
if (a.type === 'mock-credit') {
|
||||
requireState(!this.db.prepare('SELECT 1 FROM credits WHERE actor=?').get(c.actor), 'Sandbox credit already claimed');
|
||||
this.post(`credit:${c.actor}`, 'mock-deposit', [debit('treasury:mock', BigInt(MOCK_CREDIT_ATOMIC), 'debit'), credit(available(c.actor), BigInt(MOCK_CREDIT_ATOMIC))]);
|
||||
this.db.prepare('INSERT INTO credits VALUES(?)').run(c.actor);
|
||||
return this.activity(c.actor, 0);
|
||||
}
|
||||
if (a.type === 'list') {
|
||||
requireState(a.feeBps === this.options.feeBps, 'Custody hosting fee changed; review again');
|
||||
const card = this.options.card(a.cardHash, c.actor, a.sequence);
|
||||
requireState(card, 'Card is missing, stale, immature, or pending');
|
||||
requireState(!this.db.prepare("SELECT 1 FROM listings WHERE card=? AND status IN ('open','reserved')").get(a.cardHash), 'Card already has a custody listing');
|
||||
const l: CustodyListing = { id: randomUUID(), seller: c.actor, cardHash: a.cardHash, sequence: a.sequence,
|
||||
title: card.title, priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, status: 'open', createdAt: this.now() };
|
||||
this.saveListing(l); return l;
|
||||
}
|
||||
if (a.type === 'cancel-listing' || a.type === 'reserve') {
|
||||
const l = this.get<CustodyListing>('listings', a.listingId);
|
||||
requireState(l.status === 'open', 'Custody listing is no longer available');
|
||||
if (a.type === 'cancel-listing') {
|
||||
requireState(l.seller === c.actor, 'Only the seller can cancel this listing');
|
||||
l.status = 'cancelled'; this.saveListing(l); return l;
|
||||
}
|
||||
requireState(c.actor !== l.seller, 'Cannot buy your own card');
|
||||
requireState(l.priceAtomicXmr === a.priceAtomicXmr && l.feeBps === a.feeBps, 'Custody sale terms changed');
|
||||
requireState(this.options.card(l.cardHash, l.seller, l.sequence), 'Seller card is no longer available');
|
||||
const allocation = allocateXmrSale(parseAtomicXmr(a.priceAtomicXmr), a.feeBps);
|
||||
const order: CustodyOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: c.actor,
|
||||
cardHash: l.cardHash, sequence: l.sequence, grossAtomicXmr: a.priceAtomicXmr,
|
||||
sellerAtomicXmr: allocation.sellerBeforeNetworkCosts.toString(), authorAtomicXmr: allocation.author.toString(),
|
||||
hostAtomicXmr: allocation.hosting.toString(), authorAddress: ADDITIONAL_MARKET_FEE.address,
|
||||
status: 'awaiting-lock', createdAt: this.now() };
|
||||
this.post(`reserve:${order.id}`, 'purchase-reservation', [debit(available(c.actor), allocation.gross), credit(orderAccount(order), allocation.gross)]);
|
||||
l.status = 'reserved'; this.saveListing(l); this.saveOrder(order);
|
||||
// Durable intent only. No NFT signing/submission worker is enabled until reviewed consensus rules exist.
|
||||
this.db.prepare('INSERT INTO outbox VALUES(?,?,?,?)').run(order.id, 'nft-lock', 'blocked', JSON.stringify(order));
|
||||
return order;
|
||||
}
|
||||
if (a.type === 'cancel-reservation') {
|
||||
const o = this.get<CustodyOrder>('orders', a.orderId);
|
||||
requireState(o.buyer === c.actor && o.status === 'awaiting-lock', 'Only the buyer can cancel an unstarted sandbox reservation');
|
||||
const intent = this.db.prepare('SELECT status FROM outbox WHERE id=?').get(o.id) as { status: string } | undefined;
|
||||
requireState(intent?.status === 'blocked', 'NFT intent may have executed; reservation requires review');
|
||||
// Safe only because this sandbox never emits a custody delivery witness. Do not reuse for live refunds.
|
||||
this.post(`cancel:${o.id}`, 'purchase-cancellation', [debit(orderAccount(o), BigInt(o.grossAtomicXmr)), credit(available(o.buyer), BigInt(o.grossAtomicXmr))]);
|
||||
o.status = 'cancelled'; this.saveOrder(o);
|
||||
const l = this.get<CustodyListing>('listings', o.listingId); l.status = 'cancelled'; this.saveListing(l);
|
||||
this.db.prepare("UPDATE outbox SET status='cancelled' WHERE id=?").run(o.id);
|
||||
return o;
|
||||
}
|
||||
if (a.type === 'withdraw') {
|
||||
const w: CustodyWithdrawal = { id: randomUUID(), actor: c.actor, destination: a.destination,
|
||||
amountAtomicXmr: a.amountAtomicXmr, maxFeeAtomicXmr: a.maxFeeAtomicXmr, status: 'reserved', createdAt: this.now() };
|
||||
const total = parseAtomicXmr(w.amountAtomicXmr) + parseAtomicXmr(w.maxFeeAtomicXmr);
|
||||
this.post(`withdraw:${w.id}`, 'withdrawal-reservation', [debit(available(c.actor), total), credit(withdrawalAccount(w), total)]);
|
||||
this.saveWithdrawal(w);
|
||||
this.db.prepare('INSERT INTO outbox VALUES(?,?,?,?)').run(w.id, 'withdrawal', 'blocked', JSON.stringify(w));
|
||||
return w;
|
||||
}
|
||||
if (a.type === 'cancel-withdrawal') {
|
||||
const w = this.get<CustodyWithdrawal>('withdrawals', a.withdrawalId);
|
||||
requireState(w.actor === c.actor && w.status === 'reserved', 'Only an unprepared withdrawal can be cancelled by its owner');
|
||||
const total = BigInt(w.amountAtomicXmr) + BigInt(w.maxFeeAtomicXmr);
|
||||
this.post(`withdraw-cancel:${w.id}`, 'withdrawal-cancellation', [debit(withdrawalAccount(w), total), credit(available(c.actor), total)]);
|
||||
w.status = 'cancelled'; this.saveWithdrawal(w);
|
||||
this.db.prepare("UPDATE outbox SET status='cancelled' WHERE id=?").run(w.id);
|
||||
return w;
|
||||
}
|
||||
throw new CustodyError('Unsupported custody action');
|
||||
}
|
||||
|
||||
/** Offline fixture seam ONLY: records a simulated canonical event, never reachable from HTTP. */
|
||||
settleFixture(orderId: string, evidenceId: string): CustodyOrder {
|
||||
requireState(/^fixture:[a-f0-9]{64}$/.test(evidenceId), 'Expected offline fixture evidence');
|
||||
return this.atomic(() => {
|
||||
const o = this.get<CustodyOrder>('orders', orderId);
|
||||
if (o.status === 'settled') {
|
||||
const event = this.db.prepare('SELECT data FROM outbox WHERE id=?').get(o.id) as { data: string };
|
||||
requireState(event.data === evidenceId, 'Conflicting fixture settlement evidence'); return o;
|
||||
}
|
||||
requireState(o.status === 'awaiting-lock', 'Order is not eligible for fixture settlement');
|
||||
this.post(`settle:${o.id}`, 'mock-sale-settlement', [debit(orderAccount(o), BigInt(o.grossAtomicXmr)),
|
||||
credit(available(o.seller), BigInt(o.sellerAtomicXmr)), credit(`author:${o.authorAddress}`, BigInt(o.authorAtomicXmr)),
|
||||
credit('revenue:host', BigInt(o.hostAtomicXmr))]);
|
||||
o.status = 'settled'; this.saveOrder(o);
|
||||
const l = this.get<CustodyListing>('listings', o.listingId); l.status = 'sold'; this.saveListing(l);
|
||||
this.db.prepare("UPDATE outbox SET status='fixture-complete',data=? WHERE id=?").run(evidenceId, o.id);
|
||||
return o;
|
||||
});
|
||||
}
|
||||
/** Offline mock treasury: persist one transaction identity; a retry must never prepare another spend. */
|
||||
prepareWithdrawalFixture(id: string, attemptId: string, transactionId: string, feeAtomicXmr: string): CustodyWithdrawal {
|
||||
requireState(/^fixture:[a-f0-9]{64}$/.test(transactionId) && /^[a-f0-9-]{36}$/.test(attemptId), 'Expected offline withdrawal identity');
|
||||
const fee = parseAtomicXmr(feeAtomicXmr);
|
||||
return this.atomic(() => {
|
||||
const w = this.get<CustodyWithdrawal>('withdrawals', id);
|
||||
if (w.status === 'prepared' || w.status === 'complete') {
|
||||
requireState(w.attemptId === attemptId && w.transactionId === transactionId && w.feeAtomicXmr === feeAtomicXmr,
|
||||
'Withdrawal already prepared with different terms'); return w;
|
||||
}
|
||||
requireState(w.status === 'reserved' && fee <= BigInt(w.maxFeeAtomicXmr), 'Withdrawal state or fee cap invalid');
|
||||
w.status = 'prepared'; w.attemptId = attemptId; w.transactionId = transactionId; w.feeAtomicXmr = feeAtomicXmr;
|
||||
this.saveWithdrawal(w);
|
||||
this.db.prepare("UPDATE outbox SET status='fixture-prepared',data=? WHERE id=?").run(JSON.stringify(w), w.id);
|
||||
return w;
|
||||
});
|
||||
}
|
||||
confirmWithdrawalFixture(id: string, transactionId: string): CustodyWithdrawal {
|
||||
return this.atomic(() => {
|
||||
const w = this.get<CustodyWithdrawal>('withdrawals', id);
|
||||
requireState(w.transactionId === transactionId && ['prepared', 'complete'].includes(w.status), 'Withdrawal identity/state mismatch');
|
||||
if (w.status === 'complete') return w;
|
||||
const amount = BigInt(w.amountAtomicXmr), fee = BigInt(w.feeAtomicXmr!), maxFee = BigInt(w.maxFeeAtomicXmr);
|
||||
this.post(`withdraw-confirm:${id}`, 'mock-withdrawal', [debit(withdrawalAccount(w), amount + maxFee),
|
||||
credit('treasury:mock', amount + fee, 'debit'), credit(available(w.actor), maxFee - fee)]);
|
||||
w.status = 'complete'; this.saveWithdrawal(w);
|
||||
this.db.prepare("UPDATE outbox SET status='fixture-complete' WHERE id=?").run(w.id);
|
||||
return w;
|
||||
});
|
||||
}
|
||||
/** No floating SQL SUM: replay canonical strings using bigint, independently of balance projections. */
|
||||
audit(): { journals: number; assetsAtomicXmr: string; claimsAndRevenueAtomicXmr: string } {
|
||||
return this.atomic(() => {
|
||||
const accounts = this.db.prepare('SELECT * FROM accounts').all() as Account[];
|
||||
const balances = new Map(accounts.map(a => [a.id, 0n]));
|
||||
const normals = new Map(accounts.map(a => [a.id, a.normal]));
|
||||
const journals = this.db.prepare('SELECT id FROM journals').all() as { id: string }[];
|
||||
for (const journal of journals) {
|
||||
const lines = this.db.prepare('SELECT account,debit,credit FROM entries WHERE journal=? ORDER BY position').all(journal.id) as { account: string; debit: string; credit: string }[];
|
||||
let total = 0n;
|
||||
if (!lines.length) throw new Error('Empty custody journal');
|
||||
for (const l of lines) {
|
||||
if (!/^(0|[1-9]\d{0,29})$/.test(l.debit) || !/^(0|[1-9]\d{0,29})$/.test(l.credit)) throw new Error('Invalid custody entry encoding');
|
||||
const d = BigInt(l.debit), c = BigInt(l.credit);
|
||||
if (d > 0n && c > 0n || !normals.has(l.account)) throw new Error('Invalid custody entry');
|
||||
total += d - c;
|
||||
balances.set(l.account, balances.get(l.account)! + (normals.get(l.account) === 'debit' ? d - c : c - d));
|
||||
}
|
||||
if (total !== 0n) throw new Error('Unbalanced custody history');
|
||||
}
|
||||
let assets = 0n, claims = 0n;
|
||||
for (const a of accounts) {
|
||||
if (!/^(0|[1-9]\d{0,29})$/.test(a.balance) || balances.get(a.id) !== BigInt(a.balance)) throw new Error('Custody balance projection mismatch');
|
||||
if (a.normal === 'debit') assets += BigInt(a.balance); else claims += BigInt(a.balance);
|
||||
}
|
||||
if (assets !== claims) throw new Error('Custody backing mismatch');
|
||||
return { journals: journals.length, assetsAtomicXmr: assets.toString(), claimsAndRevenueAtomicXmr: claims.toString() };
|
||||
});
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -6,11 +6,14 @@ import { DatabaseSync } from 'node:sqlite';
|
||||
import { z } from 'zod';
|
||||
import { NETWORK, PROFILE } from '../shared/network.ts';
|
||||
import { CHAIN_ID } from './testnet/consensus.ts';
|
||||
import { CUSTODY_POLICY } from '../shared/custody.ts';
|
||||
|
||||
const files=['ledger.sqlite','market.sqlite'] as const;
|
||||
const files=['ledger.sqlite','market.sqlite','custody-sandbox.sqlite'] as const;
|
||||
const manifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(1),
|
||||
network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),profile:z.literal(PROFILE),createdAt:z.string().datetime(),
|
||||
files:z.array(z.object({name:z.enum(files),bytes:z.number().int().positive().max(64*1024**3),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()).length(2)}).strict();
|
||||
files:z.array(z.object({name:z.enum(files),bytes:z.number().int().positive().max(64*1024**3),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()).min(2).max(3)}).strict()
|
||||
.refine(m => new Set(m.files.map(f=>f.name)).size === m.files.length &&
|
||||
m.files.some(f=>f.name==='ledger.sqlite') && m.files.some(f=>f.name==='market.sqlite'), 'Missing or duplicate backup database');
|
||||
async function regular(path:string){const s=await lstat(path);if(!s.isFile()||s.isSymbolicLink())throw new Error('Expected regular non-symlink database file');return s;}
|
||||
async function digest(path:string){const hash=createHash('sha256');for await(const chunk of createReadStream(path))hash.update(chunk);return hash.digest('hex');}
|
||||
async function flush(path:string){const handle=await open(path,'r+');try{await handle.sync();}finally{await handle.close();}}
|
||||
@@ -26,6 +29,11 @@ function inspect(file:string,name:typeof files[number]) {
|
||||
if(name==='ledger.sqlite'){
|
||||
const meta=(key:string)=>(db.prepare('SELECT value FROM metadata WHERE key=?').get(key) as {value:string}|undefined)?.value;
|
||||
if(meta('schema_version')!=='1'||meta('network')!==NETWORK||meta('chain_id')!==CHAIN_ID)throw new Error('Ledger network/schema mismatch');
|
||||
}else if(name==='custody-sandbox.sqlite'){
|
||||
const row=db.prepare("SELECT value FROM custody_meta WHERE key='identity'").get() as {value:string}|undefined;
|
||||
const identity=row?JSON.parse(row.value):undefined;
|
||||
if(identity?.version!==1||identity.network!==NETWORK||identity.chainId!==CHAIN_ID||identity.mode!=='sandbox'||
|
||||
identity.policy!==CUSTODY_POLICY||!z.string().uuid().safeParse(identity.id).success)throw new Error('Custody identity mismatch');
|
||||
}else{
|
||||
const row=db.prepare("SELECT value FROM market_meta WHERE key='identity'").get() as {value:string}|undefined;
|
||||
const identity=row?JSON.parse(row.value):undefined;
|
||||
@@ -50,6 +58,9 @@ export async function createBackup(dataDir:string,destination:string){
|
||||
const entries=[];
|
||||
for(const name of files){
|
||||
const src=join(source,PROFILE,name),target=join(dest,name);
|
||||
if(name==='custody-sandbox.sqlite'){
|
||||
try{await lstat(src);}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')continue;throw e;}
|
||||
}
|
||||
await regular(src);inspect(src,name);
|
||||
const db=new DatabaseSync(src,{readOnly:true});
|
||||
try{db.prepare('VACUUM INTO ?').run(target);}finally{db.close();}
|
||||
@@ -71,7 +82,7 @@ export async function verifyBackup(directory:string){
|
||||
const root=await realpath(directory),manifestFile=join(root,'manifest.json');
|
||||
if((await regular(manifestFile)).size>16384)throw new Error('Backup manifest too large');
|
||||
const manifest=manifestSchema.parse(JSON.parse(await readFile(manifestFile,'utf8')));
|
||||
if(new Set(manifest.files.map(f=>f.name)).size!==2)throw new Error('Duplicate backup database');
|
||||
if(new Set(manifest.files.map(f=>f.name)).size!==manifest.files.length)throw new Error('Duplicate backup database');
|
||||
for(const file of manifest.files){
|
||||
const path=join(root,file.name),info=await regular(path);
|
||||
if(info.size!==file.bytes||await digest(path)!==file.sha256)throw new Error('Backup database hash/size mismatch');
|
||||
|
||||
+35
-3
@@ -5,7 +5,7 @@ import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdir, open, unlink } from 'node:fs/promises';
|
||||
import { z } from 'zod';
|
||||
import { NETWORK } from '../../shared/testnet.ts';
|
||||
import { NETWORK, CONSENSUS } from '../../shared/testnet.ts';
|
||||
import { testnetOwnerOf, transactionCardIds } from '../../shared/testnet.ts';
|
||||
import { CHAIN_ID, blockSchema, tradeSchema, transferSchema, transactionSchema } from './consensus.ts';
|
||||
import { RandomX } from './randomx.ts';
|
||||
@@ -17,6 +17,8 @@ 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 { custodyCommandSchema } from '../../shared/custody.ts';
|
||||
import { CustodyMarket, CustodyError, custodyMode } from '../market/custody.ts';
|
||||
import { IS_MAINNET, PROFILE } from '../../shared/network.ts';
|
||||
import { assertLaunchAllowed, launchManifest } from '../launch.ts';
|
||||
import { resourceConfig, RpcBudget, RpcError } from '../resources.ts';
|
||||
@@ -25,6 +27,7 @@ import { trustedProxies, operationsPort, startOperations } from '../operations.t
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
assertLaunchAllowed();
|
||||
const paymentConfig = marketConfig(); // Fail before opening node resources if live payments are requested.
|
||||
const custodyConfig = custodyMode(); // Explicit opt-in sandbox only. Unknown/live modes fail before opening resources.
|
||||
const limits = resourceConfig();
|
||||
const proxyTrust=trustedProxies();
|
||||
const opsPort=operationsPort();
|
||||
@@ -49,6 +52,17 @@ catch(e) {ledger.close();engine.close();await lock.close();await unlink(lockPath
|
||||
let market: Market;
|
||||
try { market = await Market.open(dataDir, ledger, paymentConfig); }
|
||||
catch(e) {peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
let custody: CustodyMarket | undefined;
|
||||
try {
|
||||
if (custodyConfig === 'sandbox') custody = await CustodyMarket.open(dataDir, { feeBps: paymentConfig.feeBps,
|
||||
card: (hash, owner, sequence) => {
|
||||
const s = ledger.snapshot(), b = s.blocks.find(block => block.tokenId === hash);
|
||||
if (!b || testnetOwnerOf(s, hash) !== owner || s.blocks.length + 1 - b.height < CONSENSUS.coinbaseMaturity ||
|
||||
s.transfers.filter(tx => transactionCardIds(tx).includes(hash)).length !== sequence ||
|
||||
s.pendingTransfers.some(tx => transactionCardIds(tx).includes(hash))) return undefined;
|
||||
return { title: b.article.title };
|
||||
} });
|
||||
} catch(e) {market.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
if(proxyTrust.length)app.set('trust proxy',proxyTrust);
|
||||
@@ -83,6 +97,24 @@ app.get('/api/network',(_req,res)=>res.json(launchManifest()));
|
||||
app.get('/api/chain',(_req,res)=>res.json(ledger.legacySnapshot(limits.legacyBlocks)));
|
||||
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.get('/api/custody', (req,res)=> {
|
||||
if (!custody) { res.json({ enabled: false }); return; }
|
||||
try { res.json(custody.browse(Number(req.query.offset ?? 0))); }
|
||||
catch (error) {
|
||||
if (error instanceof CustodyError) res.status(400).json({ error: error.message });
|
||||
else res.status(503).json({ error: 'Custody sandbox unavailable; operator review required.' });
|
||||
}
|
||||
});
|
||||
app.post('/api/custody/command', rateLimit({windowMs:60000,limit:30,standardHeaders:'draft-8',legacyHeaders:false}), (req,res)=> {
|
||||
if (!custody) { res.status(503).json({ error: 'Custody sandbox is disabled on this host.' }); return; }
|
||||
const parsed = custodyCommandSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid custody command schema.' }); return; }
|
||||
try { res.json(custody.execute(parsed.data)); }
|
||||
catch (error) {
|
||||
if (error instanceof CustodyError) res.status(400).json({ error: error.message });
|
||||
else res.status(503).json({ error: 'Custody state conflict; refresh activity before retrying. Operator review may be required.' });
|
||||
}
|
||||
});
|
||||
app.post('/api/market/command',rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}),async(req,res)=>{
|
||||
const current = marketCommandV3Schema.safeParse(req.body);
|
||||
const command = current.success ? current.data : marketCommandSchema.parse(req.body);
|
||||
@@ -198,6 +230,6 @@ let stopping=false;
|
||||
for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
|
||||
if(stopping)return;stopping=true;peers.close();operations.close();clearInterval(marketTimer);
|
||||
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
|
||||
server.close(async()=>{market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
server.close(async()=>{custody?.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
});
|
||||
server.on('error',async()=>{clearInterval(marketTimer);operations.close();peers.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
server.on('error',async()=>{clearInterval(marketTimer);operations.close();peers.close();custody?.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod';
|
||||
import { NETWORK } from './testnet.ts';
|
||||
import { ADDITIONAL_MARKET_FEE } from './market.ts';
|
||||
import { parseAtomicXmr } from './xmr-amount.ts';
|
||||
|
||||
const hex = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const id = z.string().uuid();
|
||||
const amount = z.string().max(22).refine(value => {
|
||||
try { return parseAtomicXmr(value) > 0n; } catch { return false; }
|
||||
}, 'Expected a positive canonical atomic XMR amount');
|
||||
const nonnegativeAmount = z.string().max(22).refine(value => {
|
||||
try { parseAtomicXmr(value); return true; } catch { return false; }
|
||||
}, 'Expected canonical atomic XMR amount');
|
||||
export const CUSTODY_POLICY = 'custody-sandbox-v1';
|
||||
export const MOCK_CREDIT_ATOMIC = '1000000000000';
|
||||
|
||||
/** Separate consent domain: existing v2/v3 mock signatures never authorize custodial debits. */
|
||||
export const custodyActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('activity'), offset: z.number().int().min(0).max(10000).default(0) }).strict(),
|
||||
z.object({ type: z.literal('mock-credit') }).strict(),
|
||||
z.object({ type: z.literal('list'), cardHash: hex, sequence: z.number().int().nonnegative().safe(),
|
||||
priceAtomicXmr: amount, feeBps: z.number().int().min(0).max(5000) }).strict(),
|
||||
z.object({ type: z.literal('cancel-listing'), listingId: id }).strict(),
|
||||
z.object({ type: z.literal('reserve'), listingId: id, priceAtomicXmr: amount,
|
||||
feeBps: z.number().int().min(0).max(5000) }).strict(),
|
||||
z.object({ type: z.literal('cancel-reservation'), orderId: id }).strict(),
|
||||
// No real Monero address accepted or generated in this phase.
|
||||
z.object({ type: z.literal('withdraw'), destination: z.literal('mock-external'), amountAtomicXmr: amount,
|
||||
maxFeeAtomicXmr: nonnegativeAmount }).strict(),
|
||||
z.object({ type: z.literal('cancel-withdrawal'), withdrawalId: id }).strict(),
|
||||
]);
|
||||
export type CustodyAction = z.infer<typeof custodyActionSchema>;
|
||||
export const custodyCommandSchema = z.object({ version: z.literal(1), mode: z.literal('sandbox'),
|
||||
policy: z.literal(CUSTODY_POLICY), network: z.literal(NETWORK), chainId: hex, custodianId: id,
|
||||
actor: hex, publicKey: z.string().max(256), nonce: id, expiresAt: z.number().int().nonnegative().safe(),
|
||||
action: custodyActionSchema, signature: z.string().max(128) }).strict();
|
||||
export type CustodyCommand = z.infer<typeof custodyCommandSchema>;
|
||||
export function custodyMessage(c: Omit<CustodyCommand, 'signature'>): string {
|
||||
const a = c.action;
|
||||
const terms = a.type === 'list' ? [a.cardHash, a.sequence, 'XMR', a.priceAtomicXmr, a.feeBps]
|
||||
: a.type === 'reserve' ? [a.listingId, 'XMR', a.priceAtomicXmr, a.feeBps]
|
||||
: a.type === 'cancel-listing' ? [a.listingId]
|
||||
: a.type === 'cancel-reservation' ? [a.orderId]
|
||||
: a.type === 'withdraw' ? [a.destination, 'XMR', a.amountAtomicXmr, a.maxFeeAtomicXmr]
|
||||
: a.type === 'cancel-withdrawal' ? [a.withdrawalId]
|
||||
: a.type === 'activity' ? [a.offset] : [MOCK_CREDIT_ATOMIC];
|
||||
return JSON.stringify(['wikideck-custody-command-v1', c.version, c.mode, c.policy, c.network,
|
||||
c.chainId, c.custodianId, c.actor, c.publicKey, c.nonce, c.expiresAt, a.type, terms,
|
||||
[ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address]]);
|
||||
}
|
||||
export type CustodyInfo = { enabled: true; mode: 'sandbox'; policy: typeof CUSTODY_POLICY;
|
||||
network: typeof NETWORK; chainId: string; custodianId: string; feeBps: number;
|
||||
liveDeposits: false; liveWithdrawals: false; nftSettlement: false };
|
||||
export type CustodyListing = { id: string; seller: string; cardHash: string; sequence: number;
|
||||
title: string; priceAtomicXmr: string; feeBps: number; status: 'open' | 'reserved' | 'cancelled' | 'sold'; createdAt: number };
|
||||
export type CustodyOrder = { id: string; listingId: string; seller: string; buyer: string; cardHash: string;
|
||||
sequence: number; grossAtomicXmr: string; sellerAtomicXmr: string; authorAtomicXmr: string;
|
||||
hostAtomicXmr: string; authorAddress: string; status: 'awaiting-lock' | 'cancelled' | 'settled'; createdAt: number };
|
||||
export type CustodyWithdrawal = { id: string; actor: string; destination: 'mock-external';
|
||||
amountAtomicXmr: string; maxFeeAtomicXmr: string; status: 'reserved' | 'prepared' | 'complete' | 'cancelled';
|
||||
attemptId?: string; transactionId?: string; feeAtomicXmr?: string; createdAt: number };
|
||||
export type CustodyActivity = { availableAtomicXmr: string; purchaseReservedAtomicXmr: string;
|
||||
withdrawalReservedAtomicXmr: string; mockCreditClaimed: boolean; orders: CustodyOrder[];
|
||||
withdrawals: CustodyWithdrawal[]; next: number | null };
|
||||
export type CustodyPage = { info: CustodyInfo; listings: CustodyListing[]; next: number | null };
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { NETWORK, CONSENSUS, testnetOwnerOf, transactionCardIds, type TestnetSnapshot } from '../shared/testnet.ts';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts';
|
||||
import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyActivity,
|
||||
type CustodyCommand, type CustodyPage } from '../shared/custody.ts';
|
||||
import { formatXmr, parseXmr } from '../shared/xmr-amount.ts';
|
||||
import { sign, type Wallet } from './wallet.ts';
|
||||
import './custody.css';
|
||||
|
||||
type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean };
|
||||
const short = (s: string) => `${s.slice(0, 8)}…${s.slice(-5)}`;
|
||||
// Aggregate balances have a wider bound than individual sale amounts; format without Number coercion.
|
||||
const display = (value: string) => { const n = BigInt(value); return `${n / 1_000_000_000_000n}.${(n % 1_000_000_000_000n).toString().padStart(12, '0')}`; };
|
||||
async function request<T>(command?: CustodyCommand, offset = 0): Promise<T> {
|
||||
const response = await fetch(command ? '/api/custody/command' : `/api/custody?offset=${offset}`, {
|
||||
cache: 'no-store', signal: AbortSignal.timeout(15000), ...(command ? {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command),
|
||||
} : {}),
|
||||
});
|
||||
if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('Custody API unavailable.');
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Custody request failed.');
|
||||
return data;
|
||||
}
|
||||
|
||||
export function CustodyPanel({ wallet, snapshot, rpcAvailable }: Props) {
|
||||
const [page, setPage] = useState<CustodyPage>();
|
||||
const [activity, setActivity] = useState<CustodyActivity>();
|
||||
const [enabled, setEnabled] = useState<boolean>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [review, setReview] = useState<{ action: CustodyAction; description: string }>();
|
||||
const [retry, setRetry] = useState<CustodyCommand>();
|
||||
const [card, setCard] = useState('');
|
||||
const [price, setPrice] = useState('0.100000000000');
|
||||
const [withdraw, setWithdraw] = useState('0.100000000000');
|
||||
const [maxFee, setMaxFee] = useState('0.001000000000');
|
||||
const generation = useRef(0), pending = useRef(false);
|
||||
const reviewElement = useRef<HTMLElement>(null);
|
||||
const signed = useCallback(async (action: CustodyAction, current: CustodyPage): Promise<CustodyCommand> => {
|
||||
if (!wallet) throw new Error('Open your wallet first.');
|
||||
const c: Omit<CustodyCommand, 'signature'> = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK,
|
||||
chainId: current.info.chainId, custodianId: current.info.custodianId, actor: wallet.address,
|
||||
publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action };
|
||||
return { ...c, signature: await sign(wallet, custodyMessage(c)) };
|
||||
}, [wallet]);
|
||||
const refresh = useCallback(async (offset = 0) => {
|
||||
const epoch = generation.current;
|
||||
const data = await request<CustodyPage | { enabled: false }>(undefined, offset);
|
||||
if (epoch !== generation.current) return;
|
||||
if ('enabled' in data) { setEnabled(false); setPage(undefined); setActivity(undefined); return; }
|
||||
if (data.info.mode !== 'sandbox' || data.info.policy !== CUSTODY_POLICY || data.info.network !== NETWORK ||
|
||||
data.info.liveDeposits !== false || data.info.liveWithdrawals !== false || data.info.nftSettlement !== false ||
|
||||
(snapshot && data.info.chainId !== snapshot.chainId)) throw new Error('Incompatible custody policy or network.');
|
||||
const command = wallet ? await signed({ type: 'activity', offset }, data) : undefined;
|
||||
if (epoch !== generation.current) return;
|
||||
const mine = command ? await request<CustodyActivity>(command) : undefined;
|
||||
if (epoch !== generation.current) return;
|
||||
setEnabled(true); setPage(data); setActivity(mine);
|
||||
}, [wallet, signed, snapshot?.chainId]);
|
||||
useEffect(() => {
|
||||
generation.current++; setPage(undefined); setActivity(undefined); setReview(undefined); setRetry(undefined); setError(''); setBusy(false);
|
||||
if (rpcAvailable) void refresh().catch(e => setError(e instanceof Error ? e.message : 'Custody unavailable.'));
|
||||
return () => { generation.current++; };
|
||||
}, [refresh, rpcAvailable]);
|
||||
useEffect(() => { if (review) reviewElement.current?.focus(); }, [review]);
|
||||
const prepare = (action: CustodyAction, description: string) => { setError(''); setNotice(''); setReview({ action, description }); };
|
||||
const submit = async (again?: CustodyCommand) => {
|
||||
if (!wallet || !page || pending.current || (!review && !again)) return;
|
||||
pending.current = true; setBusy(true); setError('');
|
||||
const epoch = generation.current;
|
||||
let command: CustodyCommand | undefined;
|
||||
try {
|
||||
command = again ?? await signed(review!.action, page);
|
||||
if (epoch !== generation.current) return;
|
||||
setRetry(command); // Retain the EXACT envelope after ambiguous network outcomes; do not mint another debit nonce.
|
||||
await request(command);
|
||||
if (epoch !== generation.current) return;
|
||||
setReview(undefined); setRetry(undefined); setNotice('Sandbox command recorded. No XMR or NFT was transferred.');
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
if (epoch === generation.current) setError(`${e instanceof Error ? e.message : 'Custody request failed.'}${command ? ' Refresh activity before making another request; retry uses the original signed command.' : ''}`);
|
||||
} finally { pending.current = false; if (epoch === generation.current) setBusy(false); }
|
||||
};
|
||||
const owned = snapshot?.blocks.filter(b => wallet && testnetOwnerOf(snapshot, b.tokenId) === wallet.address &&
|
||||
snapshot.blocks.length - b.height + 1 >= CONSENSUS.coinbaseMaturity &&
|
||||
!snapshot.pendingTransfers.some(t => transactionCardIds(t).includes(b.tokenId))) ?? [];
|
||||
const disabled = busy || !wallet || !rpcAvailable || !page || !!retry;
|
||||
return <section className="custody-panel" aria-label="Custodial marketplace sandbox">
|
||||
<div className="market-simulation" role="note"><div><strong>Custodial ledger preview · fictitious balances only</strong>
|
||||
<p>Test funding, fixed-price reservations and withdrawal holds. No deposit address, Monero access, NFT lock or card delivery is enabled. Existing mock checkout is separate. Funds shown here have no monetary value.</p></div></div>
|
||||
<h2>A balance for this market.</h2>
|
||||
<p>A future custodian would hold pooled XMR and track what each customer is owed. Purchases would move ledger balances, not XMR between subaddresses. You would trust the operator with your deposits.</p>
|
||||
{enabled === false && <p className="market-reserved">This host has not enabled the custody sandbox. Real custody is unavailable.</p>}
|
||||
{!rpcAvailable && <p className="market-error">Reconnect to the node to use this preview.</p>}
|
||||
{!wallet && <p>Open your Wikideck wallet to view private balances and authorize sandbox commands.</p>}
|
||||
{page && <p className="muted custody-identity">Sandbox custodian {page.info.custodianId} · {page.info.network} · host {page.info.feeBps / 100}% + author 1%. Fresh custody signatures are required; legacy mock authorizations do not debit balances.</p>}
|
||||
{error && <p role="alert" className="market-error">{error}</p>}
|
||||
{notice && <p role="status">{notice}</p>}
|
||||
<div className="market-actions"><button className="button outline" disabled={busy || !rpcAvailable} onClick={() => { setError(''); void refresh().catch(e => setError(String(e.message))); }}>Refresh balances</button>
|
||||
{retry && <><button className="button outline" disabled={busy || Date.now() > retry.expiresAt} onClick={() => void submit(retry)}>Retry original signature</button>
|
||||
<button className="button outline" disabled={busy} onClick={() => { setRetry(undefined); setReview(undefined); setNotice('Check refreshed activity before creating a new request. The previous command may already have executed.'); }}>Dismiss after checking activity</button></>}</div>
|
||||
{activity && <>
|
||||
<div className="custody-balances">{[
|
||||
['Available', activity.availableAtomicXmr], ['Purchase reserved', activity.purchaseReservedAtomicXmr],
|
||||
['Withdrawal reserved', activity.withdrawalReservedAtomicXmr],
|
||||
].map(([label, value]) => <div key={label}><small>{label}</small><strong>{display(value)} XMR</strong><span>SIMULATED</span></div>)}</div>
|
||||
<button className="button outline" disabled={disabled || activity.mockCreditClaimed} onClick={() => prepare({ type: 'mock-credit' }, 'Claim the one-time 1 XMR fictitious sandbox credit. This is not a deposit and cannot be withdrawn for real money.')}>{activity.mockCreditClaimed ? 'Sandbox credit claimed' : 'Review 1 XMR test credit'}</button>
|
||||
</>}
|
||||
{review && <section className="market-review" ref={reviewElement} tabIndex={-1} aria-label="Review custody signature">
|
||||
<h3>Review sandbox authorization</h3><p>{review.description}</p><div className="market-actions"><button className="button dark" disabled={busy || !!retry} onClick={() => void submit()}>Sign sandbox command</button>
|
||||
<button className="button outline" disabled={busy} onClick={() => setReview(undefined)}>Back</button></div></section>}
|
||||
{page && <>
|
||||
<div className="custody-forms">
|
||||
<form onSubmit={e => { e.preventDefault(); try {
|
||||
if (!snapshot) throw new Error('Chain snapshot unavailable.');
|
||||
const priceAtomicXmr = parseXmr(price).toString();
|
||||
if (BigInt(priceAtomicXmr) === 0n) throw new Error('Use a positive price.');
|
||||
prepare({ type: 'list', cardHash: card, sequence: snapshot.transfers.filter(t => transactionCardIds(t).includes(card)).length,
|
||||
priceAtomicXmr, feeBps: page.info.feeBps }, `List this card for ${formatXmr(BigInt(priceAtomicXmr))} fictitious XMR. Host ${page.info.feeBps / 100}% plus author 1% are deducted from gross if settlement is implemented. This preview does not lock or transfer the card.`);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Invalid listing.'); } }}>
|
||||
<h3>Fixed-price custody listing</h3><label htmlFor="custody-card">Your mature card</label><select id="custody-card" required value={card} onChange={e => setCard(e.target.value)}><option value="">Choose a card…</option>{owned.map(b => <option key={b.tokenId} value={b.tokenId}>{b.article.title}</option>)}</select>
|
||||
<label htmlFor="custody-price">Price · XMR</label><input id="custody-price" inputMode="decimal" required value={price} onChange={e => setPrice(e.target.value)}/>
|
||||
<button className="button dark" disabled={disabled || !card}>Review listing</button>
|
||||
</form>
|
||||
<form onSubmit={e => { e.preventDefault(); try {
|
||||
const amountAtomicXmr = parseXmr(withdraw).toString(), maxFeeAtomicXmr = parseXmr(maxFee).toString();
|
||||
if (BigInt(amountAtomicXmr) === 0n) throw new Error('Use a positive withdrawal amount.');
|
||||
prepare({ type: 'withdraw', destination: 'mock-external', amountAtomicXmr, maxFeeAtomicXmr }, `Reserve ${withdraw} fictitious XMR plus up to ${maxFee} XMR fee allowance for a MOCK destination. No address is accepted, no XMR is sent, and this hold remains cancellable until prepared by an offline test fixture.`);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Invalid withdrawal.'); } }}>
|
||||
<h3>Withdrawal reservation preview</h3><label htmlFor="custody-withdraw">Recipient amount · XMR</label><input id="custody-withdraw" inputMode="decimal" required value={withdraw} onChange={e => setWithdraw(e.target.value)}/>
|
||||
<label htmlFor="custody-fee">Maximum network fee · XMR</label><input id="custody-fee" inputMode="decimal" required value={maxFee} onChange={e => setMaxFee(e.target.value)}/>
|
||||
<button className="button outline" disabled={disabled}>Review simulated hold</button>
|
||||
</form>
|
||||
</div>
|
||||
<h3>Custody listings · no auctions</h3>
|
||||
<div className="custody-records">{page.listings.map(l => <article key={l.id}><h4>{l.title}</h4><p>{display(l.priceAtomicXmr)} XMR · {l.status} · seller {short(l.seller)}</p>
|
||||
{l.status === 'open' && (l.seller === wallet?.address
|
||||
? <button className="button outline" disabled={disabled} onClick={() => prepare({ type: 'cancel-listing', listingId: l.id }, 'Cancel this unreserved custody sandbox listing?')}>Cancel listing</button>
|
||||
: <button className="button dark" disabled={disabled} onClick={() => prepare({ type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: l.feeBps }, `Reserve ${display(l.priceAtomicXmr)} fictitious XMR for ${l.title}. Host ${l.feeBps / 100}% + author 1% deducted from seller proceeds. No purchase network fee. The order will wait for unimplemented NFT locks; no card is delivered. You can cancel this unstarted sandbox reservation.`)}>Review funded reservation</button>)}</article>)}</div>
|
||||
{page.next !== null && <button className="button outline" disabled={busy} onClick={() => void refresh(page.next!).catch(e => setError(String(e.message)))}>Next listings page</button>}
|
||||
</>}
|
||||
{activity && <div className="custody-records"><h3>Your custody activity</h3>
|
||||
{activity.orders.map(o => <article key={o.id}><strong>Order {short(o.id)}</strong><p>{display(o.grossAtomicXmr)} XMR · {o.status} · {o.buyer === wallet?.address ? 'Buyer' : 'Seller'}</p>
|
||||
{o.status === 'awaiting-lock' && <p>Blocked: NFT consensus lock and settlement are not implemented. No delivery signature was issued.</p>}
|
||||
{o.buyer === wallet?.address && o.status === 'awaiting-lock' && <button className="button outline" disabled={disabled} onClick={() => prepare({ type: 'cancel-reservation', orderId: o.id }, 'Cancel this unstarted sandbox order and return its reserved fictitious balance? No NFT intent has executed.')}>Cancel reservation</button>}</article>)}
|
||||
{activity.withdrawals.map(w => <article key={w.id}><strong>Mock withdrawal {short(w.id)}</strong><p>{display(w.amountAtomicXmr)} XMR + max {display(w.maxFeeAtomicXmr)} fee · {w.status}</p>
|
||||
{w.status === 'reserved' && <button className="button outline" disabled={disabled} onClick={() => prepare({ type: 'cancel-withdrawal', withdrawalId: w.id }, 'Cancel the unprepared mock withdrawal and release the full amount and maximum fee allowance?')}>Cancel withdrawal hold</button>}</article>)}
|
||||
{activity.next !== null && <button className="button outline" disabled={busy} onClick={() => void refresh(activity.next!).catch(e => setError(String(e.message)))}>Next activity page</button>}
|
||||
</div>}
|
||||
<p className="muted custody-identity">Author policy recipient (not a deposit address): {ADDITIONAL_MARKET_FEE.address}. No real funds or commissions are collected.</p>
|
||||
</section>;
|
||||
}
|
||||
+4
-2
@@ -8,6 +8,7 @@ import { isXmrListing, isXmrOrder, marketMessageV3, type MarketActionV3, type Ma
|
||||
type MarketCommandV3, type MarketListingRecord, type MarketOrderRecord } from '../shared/market-v3.ts';
|
||||
import { sign, type Wallet } from './wallet.ts';
|
||||
import { imageCredit } from './Card.tsx';
|
||||
import { CustodyPanel } from './CustodyPanel.tsx';
|
||||
import './market.css';
|
||||
|
||||
type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean; onNotice(message: string): void };
|
||||
@@ -48,7 +49,7 @@ function deadline(time: number, now: number) {
|
||||
export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) {
|
||||
const [page, setPage] = useState<Page>();
|
||||
const [activity, setActivity] = useState<MarketActivityV3>({ listings: [], orders: [] });
|
||||
const [tab, setTab] = useState<'browse' | 'sell' | 'orders'>('browse');
|
||||
const [tab, setTab] = useState<'browse' | 'sell' | 'orders' | 'custody'>('browse');
|
||||
const [filter, setFilter] = useState<'all' | 'fixed' | 'auction'>('all');
|
||||
const [cardHash, setCardHash] = useState('');
|
||||
const [kind, setKind] = useState<'fixed' | 'auction'>('fixed');
|
||||
@@ -141,7 +142,8 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props)
|
||||
<header className="market-hero"><div><div className="section-eyebrow">THIS PEER’S COLLECTOR EXCHANGE</div><h1>Your next find.<br/><span>Your kind of market.</span></h1><p>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.</p></div><div className="market-symbol"><ShoppingBag size={52}/><span>PEER-HOSTED · COMMUNITY-DRIVEN</span></div></header>
|
||||
<p className="muted">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.</p>
|
||||
<div className="market-simulation" role="note"><ShieldNote/><div><strong>Mock payments only · never send real XMR</strong><p>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.</p></div><span className="market-mode">SANDBOX</span></div>
|
||||
<div className="market-toolbar"><div className="market-tabs" role="group" aria-label="Marketplace section">{(['browse','sell','orders'] as const).map(t=><button key={t} className={tab===t?'selected':''} onClick={()=>setTab(t)}>{t==='browse'?'Explore market':t==='sell'?'Sell a card':'Your activity'}</button>)}</div><button className="text-button" onClick={()=>{setError('');void refresh().catch(e=>setError(String(e.message)));}} disabled={busy||!rpcAvailable}><RefreshCw size={14}/>Refresh</button></div>
|
||||
<div className="market-toolbar"><div className="market-tabs" role="group" aria-label="Marketplace section">{(['browse','sell','orders','custody'] as const).map(t=><button key={t} className={tab===t?'selected':''} onClick={()=>setTab(t)}>{t==='browse'?'Explore market':t==='sell'?'Sell a card':t==='custody'?'Custody preview':'Your activity'}</button>)}</div><button className="text-button" onClick={()=>{setError('');void refresh().catch(e=>setError(String(e.message)));}} disabled={busy||!rpcAvailable}><RefreshCw size={14}/>Refresh</button></div>
|
||||
{tab==='custody'&&<CustodyPanel wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable}/>}
|
||||
{page&&<><div className="market-meta"><span>Host <strong>{page.info.feeBps/100}%</strong> + author <strong>1%</strong> · {(page.info.feeBps+ADDITIONAL_MARKET_FEE.bps)/100}% total deducted from seller</span><span>Delivery <strong>{page.info.deliveryConfirmations} confirmations</strong> before mock payout</span><span>New listings and orders: <strong>XMR-native</strong></span></div><p className="market-fee-recipient">Hardcoded project author fee recipient · simulated payout only, not a checkout address: <span>{ADDITIONAL_MARKET_FEE.address}</span></p></>}
|
||||
{!rpcAvailable&&<p className="market-error" role="alert">Reconnect to the node before using the marketplace.</p>}
|
||||
{!wallet&&<p className="muted">Open your local wallet to list cards, bid, and check out.</p>}
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
.custody-panel { display: grid; gap: 1.2rem; padding-block: 1rem; }
|
||||
.custody-panel h2, .custody-panel h3, .custody-panel h4, .custody-panel p { margin: 0; }
|
||||
.custody-panel p { line-height: 1.65; }
|
||||
.custody-identity { overflow-wrap: anywhere; font-size: .85rem; }
|
||||
.custody-balances { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }
|
||||
.custody-balances > div, .custody-forms > form, .custody-records > article {
|
||||
padding: 1.25rem; border: 1px solid var(--ctp-surface1); border-radius: 1rem; background: var(--ctp-mantle);
|
||||
}
|
||||
.custody-balances > div { display: grid; gap: .6rem; }
|
||||
.custody-balances strong { overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.custody-balances span { font-size: .65rem; letter-spacing: .13em; opacity: .65; }
|
||||
.custody-forms { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
|
||||
.custody-forms > form, .custody-records { display: grid; gap: .8rem; }
|
||||
.custody-forms input, .custody-forms select { width: 100%; box-sizing: border-box; padding: .75rem; border: 1px solid var(--ctp-surface1); border-radius: .5rem; background: var(--ctp-base); color: inherit; }
|
||||
.custody-records > article { display: grid; gap: .75rem; }
|
||||
@media (max-width: 750px) { .custody-balances, .custody-forms { grid-template-columns: 1fr; } }
|
||||
+33
-2
@@ -20,6 +20,8 @@ import { StateClient } from '../shared/rpc-client.ts';
|
||||
import { createBackup,restoreBackup } from '../server/recovery.ts';
|
||||
import { Ledger } from '../server/testnet/ledger.ts';
|
||||
import { Market } from '../server/market/store.ts';
|
||||
import { CUSTODY_POLICY, custodyMessage, type CustodyCommand, type CustodyPage, type CustodyActivity } from '../shared/custody.ts';
|
||||
import { CustodyMarket } from '../server/market/custody.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const wikipediaMock = resolve(root, 'tests', 'mock-wikipedia.mjs');
|
||||
@@ -88,7 +90,9 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
WIKIDECK_DATA_DIR: dataDir, RANDOMX_BINARY: binary,
|
||||
WIKIDECK_PEERS: peer ?? '', WIKIDECK_ALLOW_PRIVATE_PEERS: peer ? '1' : '0',
|
||||
PUBLIC_ORIGIN: 'http://127.0.0.1:3001', WIKIDECK_PAYMENT_MODE: 'mock',
|
||||
WIKIDECK_MARKET_FEE_BPS: '250', WIKIDECK_MARKET_CONFIRMATIONS: '2' },
|
||||
WIKIDECK_MARKET_FEE_BPS: '250', WIKIDECK_MARKET_CONFIRMATIONS: '2', WIKIDECK_CUSTODY_MODE: peer ? 'off' : 'sandbox',
|
||||
// This integration burst now includes signed custody reads/replay probes; admission limits have dedicated tests.
|
||||
WIKIDECK_RPC_WRITES_PER_SECOND: '50' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let output = '';
|
||||
@@ -207,6 +211,18 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
const alice = wallet(), bob = wallet();
|
||||
assert.notEqual(alice.owner, bob.owner);
|
||||
let first = await start(firstDir);
|
||||
const custody = await json<CustodyPage>(first.base, '/api/custody');
|
||||
assert.equal(custody.info.mode, 'sandbox'); assert.equal(custody.info.liveDeposits, false);
|
||||
const custodyTerms: Omit<CustodyCommand, 'signature'> = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY,
|
||||
network: NETWORK, chainId: CHAIN_ID, custodianId: custody.info.custodianId, actor: bob.owner,
|
||||
publicKey: bob.publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action: { type: 'mock-credit' } };
|
||||
const custodyCredit = { ...custodyTerms, signature: bob.signed(custodyMessage(custodyTerms)) };
|
||||
const credited = await json<CustodyActivity>(first.base, '/api/custody/command', 200, custodyCredit);
|
||||
assert.equal(credited.availableAtomicXmr, '1000000000000');
|
||||
assert.deepEqual(await json(first.base, '/api/custody/command', 200, custodyCredit), credited);
|
||||
await request(first.base, '/api/custody/command', 400, { ...custodyCredit, actor: alice.owner });
|
||||
await request(first.base, '/api/custody/command', 400, { ...custodyCredit, mode: 'live' });
|
||||
assert.match((await request(first.base, '/api/custody/command', 200, custodyCredit)).headers.get('cache-control') ?? '', /no-store/);
|
||||
const health = await request(first.base, '/healthz');
|
||||
assert.deepEqual(await health.json(), { status: 'ok', network: NETWORK });
|
||||
assert.equal(health.headers.get('cache-control'), 'no-store');
|
||||
@@ -289,6 +305,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
|
||||
// A truly independent process reconstructs the canonical chain over HTTP and checks real work.
|
||||
const second = await start(secondDir, first.base);
|
||||
assert.deepEqual(await json(second.base, '/api/custody'), { enabled: false });
|
||||
await request(second.base, '/api/custody/command', 503, custodyCredit);
|
||||
const synced = await waitFor('Second node canonical sync', () => snapshot(second.base),
|
||||
state => state.tip === block2.hash && state.peers === 1);
|
||||
assert.deepEqual(synced.blocks, [block1, block2]);
|
||||
@@ -373,7 +391,13 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
assert.equal(existsSync(join(firstDir, 'testnet-node.lock')), false);
|
||||
}
|
||||
const backupDir=join(directory,'stopped-pair-backup'),drillDir=join(directory,'isolated-restore-drill');
|
||||
await createBackup(firstDir,backupDir);await restoreBackup(backupDir,drillDir);
|
||||
const backupManifest = await createBackup(firstDir,backupDir);await restoreBackup(backupDir,drillDir);
|
||||
assert.ok(backupManifest.files.some(f=>f.name==='custody-sandbox.sqlite'));
|
||||
const drillCustody = await CustodyMarket.open(drillDir, { feeBps: 250, card: () => undefined });
|
||||
try {
|
||||
assert.equal(drillCustody.id, custody.info.custodianId);
|
||||
assert.equal(drillCustody.audit().assetsAtomicXmr, '1000000000000');
|
||||
} finally { drillCustody.close(); }
|
||||
const drillLedger=await Ledger.open(drillDir,engine!); // Revalidate real RandomX proofs from the copied backup.
|
||||
try {
|
||||
assert.deepEqual(drillLedger.snapshot(),{...canonical,peers:0});
|
||||
@@ -387,6 +411,13 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
}finally{db.close();}
|
||||
}finally{drillLedger.close();}
|
||||
first = await start(firstDir); // Ledger.open revalidates every persisted block using native RandomX.
|
||||
const restartedCustody = await json<CustodyPage>(first.base, '/api/custody');
|
||||
assert.equal(restartedCustody.info.custodianId, custody.info.custodianId);
|
||||
const balanceTerms: Omit<CustodyCommand, 'signature'> = { ...custodyTerms, nonce: randomUUID(),
|
||||
expiresAt: Date.now() + 60000, action: { type: 'activity', offset: 0 } };
|
||||
const restoredBalance = await json<CustodyActivity>(first.base, '/api/custody/command', 200,
|
||||
{ ...balanceTerms, signature: bob.signed(custodyMessage(balanceTerms)) });
|
||||
assert.equal(restoredBalance.availableAtomicXmr, '1000000000000');
|
||||
const restored = await snapshot(first.base);
|
||||
assert.deepEqual(await pagedClient.load(),restored);
|
||||
const restoredMarket = await json<{ info: MarketInfo }>(first.base, '/api/market');
|
||||
|
||||
Executable
+197
@@ -0,0 +1,197 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { CustodyMarket, custodyMode } from '../server/market/custody.ts';
|
||||
import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessage,
|
||||
type CustodyAction, type CustodyActivity, type CustodyListing, type CustodyOrder,
|
||||
type CustodyWithdrawal, type CustodyCommand } from '../shared/custody.ts';
|
||||
import { NETWORK } from '../shared/testnet.ts';
|
||||
import { address, CHAIN_ID } from '../server/testnet/consensus.ts';
|
||||
|
||||
function identity() {
|
||||
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
return { publicKey, actor: address(publicKey), sign: (message: string) => sign('sha256', Buffer.from(message),
|
||||
{ key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
|
||||
}
|
||||
const seller = identity(), buyer = identity(), stranger = identity(), cardHash = 'a'.repeat(64);
|
||||
const evidence = `fixture:${'c'.repeat(64)}`, tx = `fixture:${'d'.repeat(64)}`;
|
||||
async function fixture(t: { after(fn: () => Promise<void>): void }) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-custody-'));
|
||||
let now = 1_800_000_000_000, present = true;
|
||||
const options = { feeBps: 250, now: () => now, card: (hash: string, owner: string, sequence: number) =>
|
||||
present && hash === cardHash && owner === seller.actor && sequence === 0 ? { title: 'Custody fixture' } : undefined };
|
||||
let market = await CustodyMarket.open(dir, options);
|
||||
t.after(async () => { market.close(); await rm(dir, { recursive: true, force: true }); });
|
||||
const envelope = (who: typeof buyer, action: CustodyAction, nonce: string = randomUUID()) => {
|
||||
const c: Omit<CustodyCommand, 'signature'> = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK,
|
||||
chainId: CHAIN_ID, custodianId: market.id, actor: who.actor, publicKey: who.publicKey,
|
||||
nonce, expiresAt: now + 60000, action };
|
||||
return { ...c, signature: who.sign(custodyMessage(c)) };
|
||||
};
|
||||
const execute = <T>(who: typeof buyer, action: CustodyAction) => market.execute(envelope(who, action)) as T;
|
||||
const activity = (who = buyer) => execute<CustodyActivity>(who, { type: 'activity', offset: 0 });
|
||||
const credit = (who = buyer) => execute<CustodyActivity>(who, { type: 'mock-credit' });
|
||||
const listing = (price = '100000000000') => execute<CustodyListing>(seller, { type: 'list', cardHash,
|
||||
sequence: 0, priceAtomicXmr: price, feeBps: 250 });
|
||||
const reserve = (l: CustodyListing, who = buyer) => execute<CustodyOrder>(who, { type: 'reserve', listingId: l.id,
|
||||
priceAtomicXmr: l.priceAtomicXmr, feeBps: l.feeBps });
|
||||
return { dir, envelope, execute, activity, credit, listing, reserve, get market() { return market; },
|
||||
changeCard: () => { present = false; }, advance: (ms: number) => { now += ms; },
|
||||
restart: async () => { market.close(); market = await CustodyMarket.open(dir, options); } };
|
||||
}
|
||||
|
||||
test('custody mode is off by default and rejects all live configurations', () => {
|
||||
assert.equal(custodyMode({}), 'off'); assert.equal(custodyMode({ WIKIDECK_CUSTODY_MODE: 'sandbox' }), 'sandbox');
|
||||
for (const mode of ['live', 'mainnet', '', 'shkeeper']) assert.throws(() => custodyMode({ WIKIDECK_CUSTODY_MODE: mode }));
|
||||
});
|
||||
|
||||
test('custody requires a separate consent domain and rejects malformed monetary encodings', async t => {
|
||||
const f = await fixture(t), c = f.envelope(buyer, { type: 'mock-credit' });
|
||||
assert.match(custodyMessage(c), /wikideck-custody-command-v1/);
|
||||
for (const patch of [{ actor: seller.actor }, { chainId: 'f'.repeat(64) }, { custodianId: randomUUID() },
|
||||
{ expiresAt: c.expiresAt + 1 }, { action: { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' } }]) {
|
||||
assert.throws(() => f.market.execute({ ...c, ...patch }));
|
||||
}
|
||||
for (const value of ['0', '-1', '01', '1.0', '1e2', ' 1', '1\n', '999999999999999999999999999999999']) {
|
||||
assert.equal(custodyCommandSchema.safeParse({ ...c, action: { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: value, maxFeeAtomicXmr: '0' } }).success, false);
|
||||
}
|
||||
assert.throws(() => f.market.execute({ ...c, mode: 'live' }));
|
||||
assert.throws(() => f.market.execute({ ...c, action: { type: 'withdraw', destination: 'real-address', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' } }));
|
||||
assert.equal(f.activity().availableAtomicXmr, '0');
|
||||
});
|
||||
|
||||
test('mock funding and replay receipts persist across restart and never credit twice', async t => {
|
||||
const f = await fixture(t), c = f.envelope(buyer, { type: 'mock-credit' });
|
||||
const first = f.market.execute(c);
|
||||
assert.deepEqual(f.market.execute(c), first);
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
assert.throws(() => f.credit(), /already claimed/);
|
||||
const changed = f.envelope(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' }, c.nonce);
|
||||
assert.throws(() => f.market.execute(changed), /Nonce already used/);
|
||||
await f.restart(); assert.deepEqual(f.market.execute(c), first);
|
||||
assert.equal(f.market.audit().journals, 1);
|
||||
f.advance(60001); assert.throws(() => f.market.execute(c), /expired/);
|
||||
});
|
||||
|
||||
test('funded fixed-price reservation and blocked intent commit together with private activity', async t => {
|
||||
const f = await fixture(t), l = f.listing();
|
||||
assert.throws(() => f.reserve(l), /Insufficient/);
|
||||
assert.equal(f.market.browse().listings[0].status, 'open');
|
||||
assert.equal(f.activity().orders.length, 0);
|
||||
f.credit(); const order = f.reserve(l);
|
||||
assert.equal(order.status, 'awaiting-lock'); assert.equal(order.sellerAtomicXmr, '96500000000');
|
||||
assert.equal(order.authorAtomicXmr, '1000000000'); assert.equal(order.hostAtomicXmr, '2500000000');
|
||||
assert.equal(f.activity().availableAtomicXmr, '900000000000');
|
||||
assert.equal(f.activity().purchaseReservedAtomicXmr, l.priceAtomicXmr);
|
||||
assert.equal(f.activity(seller).availableAtomicXmr, '0');
|
||||
assert.equal(f.activity(seller).orders.length, 1); assert.equal(f.activity(stranger).orders.length, 0);
|
||||
assert.throws(() => f.execute(stranger, { type: 'cancel-reservation', orderId: order.id }), /Only the buyer/);
|
||||
await f.restart();
|
||||
f.execute(buyer, { type: 'cancel-reservation', orderId: order.id });
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); assert.equal(f.activity().purchaseReservedAtomicXmr, '0');
|
||||
assert.throws(() => f.market.settleFixture(order.id, evidence), /not eligible/);
|
||||
assert.equal(f.market.audit().assetsAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
});
|
||||
|
||||
test('card changes, self-purchases and changed amounts cannot reserve funds', async t => {
|
||||
const f = await fixture(t), l = f.listing(); f.credit();
|
||||
assert.throws(() => f.reserve(l, seller), /own card/);
|
||||
assert.throws(() => f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: '1', feeBps: 250 }), /terms changed/);
|
||||
f.changeCard(); assert.throws(() => f.reserve(l), /no longer available/);
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
});
|
||||
|
||||
test('offline canonical fixture settles exactly once with conserved fees and one-atomic-unit prices', async t => {
|
||||
const f = await fixture(t); f.credit(); const l = f.listing('1'), o = f.reserve(l);
|
||||
assert.equal(o.sellerAtomicXmr, '1'); assert.equal(o.hostAtomicXmr, '0'); assert.equal(o.authorAtomicXmr, '0');
|
||||
assert.throws(() => f.market.settleFixture(o.id, 'rpc-confirmed'), /offline fixture/);
|
||||
f.market.settleFixture(o.id, evidence);
|
||||
assert.equal(f.activity(seller).availableAtomicXmr, '1');
|
||||
assert.equal(f.activity().availableAtomicXmr, '999999999999');
|
||||
await f.restart(); f.market.settleFixture(o.id, evidence);
|
||||
assert.equal(f.activity(seller).availableAtomicXmr, '1');
|
||||
assert.throws(() => f.market.settleFixture(o.id, `fixture:${'b'.repeat(64)}`), /Conflicting/);
|
||||
assert.throws(() => f.execute(buyer, { type: 'cancel-reservation', orderId: o.id }), /Only the buyer/);
|
||||
assert.deepEqual(f.market.audit(), { journals: 3, assetsAtomicXmr: MOCK_CREDIT_ATOMIC, claimsAndRevenueAtomicXmr: MOCK_CREDIT_ATOMIC });
|
||||
});
|
||||
|
||||
test('competing signed reservations serialize and failed attempts leave no partial writes', async t => {
|
||||
const f = await fixture(t), l = f.listing('800000000000'); f.credit(); f.credit(stranger);
|
||||
const commands = [f.envelope(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }),
|
||||
f.envelope(stranger, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 })];
|
||||
const results = await Promise.allSettled(commands.map(c => Promise.resolve().then(() => f.market.execute(c))));
|
||||
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
|
||||
assert.equal(f.activity().orders.length + f.activity(stranger).orders.length, 1);
|
||||
assert.equal(f.market.audit().journals, 3);
|
||||
assert.throws(() => f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '300000000000', maxFeeAtomicXmr: '0' }), /Insufficient/);
|
||||
assert.equal(f.activity().withdrawals.length, 0);
|
||||
});
|
||||
|
||||
test('withdrawal cancellation releases maximum fee and prepared uncertainty blocks cancellation/replacement', async t => {
|
||||
const f = await fixture(t); f.credit();
|
||||
const make = () => f.execute<CustodyWithdrawal>(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100000000000', maxFeeAtomicXmr: '1000' });
|
||||
const cancelled = make(); f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: cancelled.id });
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
const w = make(), attempt = randomUUID();
|
||||
assert.throws(() => f.market.prepareWithdrawalFixture(w.id, attempt, tx, '1001'), /fee cap/);
|
||||
assert.equal(f.activity().withdrawals[0].status, 'reserved');
|
||||
f.market.prepareWithdrawalFixture(w.id, attempt, tx, '400');
|
||||
await f.restart();
|
||||
assert.throws(() => f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id }), /unprepared/);
|
||||
assert.throws(() => f.market.prepareWithdrawalFixture(w.id, randomUUID(), tx, '400'), /different terms/);
|
||||
assert.equal(f.market.prepareWithdrawalFixture(w.id, attempt, tx, '400').transactionId, tx);
|
||||
assert.throws(() => f.market.confirmWithdrawalFixture(w.id, `fixture:${'e'.repeat(64)}`), /mismatch/);
|
||||
f.market.confirmWithdrawalFixture(w.id, tx); f.market.confirmWithdrawalFixture(w.id, tx);
|
||||
assert.equal(f.activity().availableAtomicXmr, '899999999600'); assert.equal(f.activity().withdrawalReservedAtomicXmr, '0');
|
||||
assert.equal(f.market.audit().assetsAtomicXmr, '899999999600');
|
||||
});
|
||||
|
||||
test('multiple connections cannot overspend a balance and cancelled commands do not double-release', async t => {
|
||||
const f = await fixture(t); f.credit();
|
||||
const second = await CustodyMarket.open(f.dir, { feeBps: 250, now: () => 1_800_000_000_000, card: () => undefined });
|
||||
try {
|
||||
const c = f.envelope(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '0' });
|
||||
const w = second.execute(c) as CustodyWithdrawal;
|
||||
assert.throws(() => f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '0' }), /Insufficient/);
|
||||
const cancel = f.envelope(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id });
|
||||
f.market.execute(cancel); second.execute(cancel);
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); second.audit();
|
||||
} finally { second.close(); }
|
||||
});
|
||||
|
||||
test('sandbox cap stops new work but never blocks release of existing unstarted holds', async t => {
|
||||
const f = await fixture(t); f.credit();
|
||||
const w = f.execute<CustodyWithdrawal>(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100', maxFeeAtomicXmr: '1' });
|
||||
const db = new DatabaseSync(join(f.dir, 'testnet', 'custody-sandbox.sqlite'));
|
||||
try {
|
||||
const insert = db.prepare('INSERT INTO receipts VALUES(?,?,?)');
|
||||
db.exec('BEGIN'); for (let i = 0; i < 10000; i++) insert.run(`fixture:${i}`, 'fixture', '{}'); db.exec('COMMIT');
|
||||
assert.throws(() => f.listing(), /budget/);
|
||||
f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id });
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
assert.throws(() => db.prepare("UPDATE entries SET debit='1'").run(), /Immutable/);
|
||||
assert.throws(() => db.prepare('DELETE FROM journals').run(), /Immutable/);
|
||||
db.prepare("UPDATE accounts SET balance='12' WHERE id=?").run(`customer:${buyer.actor}:available`);
|
||||
assert.throws(() => f.market.audit(), /projection mismatch/);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
test('property-style repeated settlements conserve gross and never round up author/host fees', async t => {
|
||||
const f = await fixture(t); f.credit();
|
||||
let spent = 0n, received = 0n;
|
||||
for (let i = 1n; i <= 40n; i++) {
|
||||
const price = i * 137n, o = f.reserve(f.listing(price.toString()));
|
||||
f.market.settleFixture(o.id, `fixture:${i.toString(16).padStart(64, '0')}`);
|
||||
assert.equal(BigInt(o.authorAtomicXmr), price / 100n);
|
||||
assert.equal(BigInt(o.hostAtomicXmr), price * 250n / 10000n);
|
||||
spent += price; received += BigInt(o.sellerAtomicXmr);
|
||||
assert.equal(BigInt(f.activity().availableAtomicXmr) + spent, BigInt(MOCK_CREDIT_ATOMIC));
|
||||
assert.equal(BigInt(f.activity(seller).availableAtomicXmr), received);
|
||||
assert.equal(f.market.audit().assetsAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user