diff --git a/README.md b/README.md index e63bf4f..5918793 100644 --- a/README.md +++ b/README.md @@ -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.** 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. +- **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. - **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 @@ -34,7 +34,7 @@ Market customization does not let one peer rewrite shared NFT ownership or unila | RandomX mining and illustrated Wikipedia NFT rewards | Reviewed production network launch | | Encrypted local wallets, collections and atomic card trades | Playable TCGs and peer-hosted custom games | | Per-peer fixed-price listings and timed auctions | Built-in collection-specific curation/admission controls | -| Mock USD-priced/XMR checkout and host/author fee accounting | Real cryptocurrency settlement and production SHKeeper activation | + | Mock native-XMR checkout and host/author fee accounting | Real cryptocurrency settlement and production custody operations | ## Run the three-node testnet (Docker) @@ -151,7 +151,7 @@ This mines three real blocks using disposable identities. It changes the testnet ## Network and safety limits -[SHKeeper integration groundwork](docs/shkeeper-settlement.md) is an offline, fake-transport settlement prototype only. It tests signed invoice terms and durable seller/1% payout reconciliation; it is not connected to the running market, real processor, production backup or public APIs. Live payment activation remains unavailable. +Live XMR payments are not implemented. The selected custody design uses direct Monero wallet/daemon observation and a pooled liability ledger; no real deposits, credits, withdrawals, or sale settlement are enabled. See the [custodial market design](docs/custodial-market-design.md) and [launch hold points](docs/operator-launch.md). [Deployment and recovery preparation](docs/operations-recovery.md) includes explicit trusted-proxy configuration, opt-in loopback-only diagnostics and `ops:check`, plus stopped-node paired `node:backup` create/verify/new-directory restore tooling. Backups contain sensitive market data and require external encryption/access control. Tools do not configure TLS/alerts or authorize production launch/restore. diff --git a/docs/custodial-market-design.md b/docs/custodial-market-design.md index 29c27a9..4c66794 100755 --- a/docs/custodial-market-design.md +++ b/docs/custodial-market-design.md @@ -132,7 +132,7 @@ No hidden withdrawal minimum or flat fee. Baseline: exact recipient amount plus ## 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. +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). There is no connected live payment adapter. The selected implementation path is direct pinned Monero wallet/daemon observation for deposits, a pooled PostgreSQL liability ledger, and an isolated, policy-constrained treasury signer for withdrawals; each component requires production integration and review before funds are accepted. 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. diff --git a/docs/mainnet-launch.md b/docs/mainnet-launch.md index 752d493..f7c8fdb 100755 --- a/docs/mainnet-launch.md +++ b/docs/mainnet-launch.md @@ -54,4 +54,4 @@ Unknown profile names fail closed. A direct import of the HTTP entry point is al This remains experimental software. Nodes retain the DAG and overlays in memory, rebuild state roots over the full NFT set, and revalidate proofs at startup. The HTTP UI trusts its connected node rather than independently validating the chain. There is no automatic peer discovery, established bootstrap infrastructure, formal finality, independent audit, built-in anti-shill mechanism or production abuse system. The marketplace is node-local, not consensus escrow. Mainnet selection does not remove any of these limitations. -See [marketplace sandbox and future SHKeeper boundary](marketplace.md) for the mock settlement model and the additional work required before any real XMR can be accepted or paid out. +See [marketplace sandbox and future live-custody boundary](marketplace.md) for the mock settlement model and the additional work required before any real XMR can be accepted or paid out. diff --git a/docs/market-transparency.md b/docs/market-transparency.md index a61ed84..e65cca5 100755 --- a/docs/market-transparency.md +++ b/docs/market-transparency.md @@ -8,7 +8,7 @@ **Card delivery is real on the selected blockchain.** When a seller signs delivery, they authorize an actual NFT transfer, even though the buyer only clicked a simulated-payment button. A `paid` or `mock-paid` label here is **not evidence of real money received**. Do not deliver valuable assets in exchange for mock payment. -A separate offline SHKeeper prototype tests a possible future real-payment workflow. It is not connected to public checkout or the current market journal. There is no supported setting that enables live checkout today. +There is no live payment integration or supported setting that enables live checkout today. ## Who controls what? @@ -19,7 +19,7 @@ A separate offline SHKeeper prototype tests a possible future real-payment workf | Confirmed card ownership | The selected network's canonical blockchain, subject to reorganizations. The browser trusts the connected node's reported chain; it is not an independent validating wallet. | | Listings, auction bids, reservations and orders | The particular hosting peer's local marketplace database. They are not replicated as blockchain consensus records or shared with every other market. | | Payment money today | No money is collected, held or sent by this release. Amounts are accounting simulations. | -| Money in the proposed live design | The market operator's merchant wallet managed through SHKeeper would receive gross buyer payments and send net payouts. **That would be custodial**, not trustless escrow or a non-custodial exchange. It is not active. | +| Money in the proposed live design | The selected direction is a pooled Monero treasury and internal liability ledger controlled by the market operator. **That would be custodial**, not trustless escrow or a non-custodial exchange. It is not active. | Your NFT wallet address is not an XMR payment address. Sharing the RandomX algorithm with Monero does not merge the two networks, payments or security budgets. @@ -43,7 +43,7 @@ The auction winner must check out and complete mock payment within **30 minutes For a new version-3 order, the buyer sees the exact signed XMR gross, host fee, author fee, seller net and payment deadline. No USD conversion or exchange rate is applied. Legacy version-2 USD orders retain their previously signed USD price and snapshotted mock-XMR quote; they are not reinterpreted as XMR. Quote amounts, fee allocations and delivery-confirmation requirements remain stored with each order. -Only the order's buyer can sign **Simulate XMR payment**, and eligibility is checked again. This action changes an accounting status; it does not verify a Monero transaction or contact SHKeeper. Other users cannot mark the order paid through the normal participant-authorized API, but the buyer's signature alone never proves real payment. +Only the order's buyer can sign **Simulate XMR payment**, and eligibility is checked again. This action changes an accounting status; it does not verify a Monero transaction. Other users cannot mark the order paid through the normal participant-authorized API, but the buyer's signature alone never proves real payment. ### 4. Seller reviews and signs delivery @@ -101,19 +101,9 @@ Fees apply to completed marketplace sales—not listing creation, auction bids, There is currently **no built-in arbitration service, buyer insurance, chargeback system, guaranteed reimbursement, operator override/payment-recovery endpoint, or dispute-resolution SLA**. A `review` label means investigation is needed—not that a resolver or repayment fund exists. Do not infer those protections from words such as “market,” “confirmation” or “settlement.” -## What the offline SHKeeper prototype demonstrates—not a live promise +## Future live custody is not active -The earlier disconnected prototype modeled a USD-denominated SHKeeper invoice, gross XMR into an operator merchant wallet, seller-signed NFT delivery, then operator payout of seller net and the separate author allocation. This would temporarily entrust funds to the operator and its processor administration. SHKeeper does not by itself guarantee that a seller delivers or that an operator honors a refund. This USD invoice prototype is **not connected to the new native-XMR marketplace** and must not be silently used to convert its prices; any future processor path requires verified exact-XMR capability or a separately approved architecture. - -The disconnected prototype tests: - -- Seller-signed payout destination and buyer-signed exact invoice amount/address/expiry and fee allocations. -- Authoritative invoice lookup rather than trusting a browser claim or callback `paid` flag; matching currency, amount, destination, unique incoming transactions and confirmed status. -- Waiting on partial/unconfirmed payment; holding late, overpaid, contradictory or changed evidence for review. -- Recording payout intent durably **before** sending a payout request. A lost response may mean money was sent; ambiguous requests are not blindly retried. -- Tracking separate seller/author payout tasks. Seller success followed by fee failure preserves the completed seller leg rather than paying it again. Known task IDs can be polled without creating another payout. - -This is fake-processor test evidence, **not** a deployed SHKeeper contract verification, functioning buyer-protection service or live-fund recovery system. No real Monero confirmation threshold, reserve/hold duration, refund rules or dispute policy has been approved. The prototype's journal is separate from production market backups. See [technical prototype and activation holds](shkeeper-settlement.md). +The intended direction is direct wallet/daemon observation into a pooled liability ledger; it does not depend on a third-party invoice processor. That future flow is not implemented end-to-end. There is no customer-facing deposit route, automatic customer credit, treasury withdrawal service, or real sale settlement. No confirmation threshold, funded reserve, loss allocation, refund policy or dispute process has been approved. This document describes current behavior and does not promise future buyer protection. ## Privacy, evidence and host accountability diff --git a/docs/marketplace.md b/docs/marketplace.md index b659387..94253fd 100755 --- a/docs/marketplace.md +++ b/docs/marketplace.md @@ -86,18 +86,8 @@ Canonical and pending sale delivery is recognized by the agreed seller/buyer/car For **review**, preserve backups and inspect canonical blocks, pending transfers, signed order terms and transfer ID. Do not clear a payout marker, auto-refund, or blindly resubmit a payout. This release intentionally has no real-fund recovery controls. -## SHKeeper production boundary +## Future live XMR custody -The intended future live processor is **SHKeeper**, using the operator-provided instance at https://shkeeper.eom.dev/. **It is not contacted or configured by this sandbox; no live adapter is enabled.** Stage 5 adds an [offline wire-contract and durable settlement prototype](shkeeper-settlement.md) with fake-transport tests, separate from the running marketplace. Its live factory always refuses activation. Do not supply payment credentials yet. No secret belongs in browser build variables, source, or version-controlled Compose files. +The selected direction is a pooled custodial ledger, not per-sale processor invoices. Real deposits would be assigned to durable Monero subaddresses and reconciled against wallet-owned outputs; purchases would transfer liabilities internally. Withdrawals would require a separate, policy-constrained treasury signer and durable reconciliation. **None of this is connected to the public marketplace or enabled:** there is no live deposit-address route, customer credit worker, withdrawal signer, or paid-sale settlement path. -The offline SHKeeper harness still models the provider's documented USD-denominated invoice input. It is disconnected from the native-XMR market and therefore is **not currently compatible with exact XMR-native pricing**. Do not hide a USD conversion behind an XMR price. Seller payouts use a separate Basic-authenticated XMR payout endpoint; the merchant wallet would receive gross funds and the operator would temporarily **custody funds**. Native-XMR invoice capability and settlement architecture remain unverified; no processor path is selected or enabled. - -Live settlement needs a separate implementation and review, **after** a reviewed production network exists: - -- Authoritative server-to-server verification of invoice identity, XMR currency, address, amount, confirmation policy and settled status. Never trust an unsigned callback's `paid` field; treat callbacks only as bounded reconciliation hints. -- Stored quote expiry, payment deadlines and explicit rules for late, partial, duplicate and overpayments, seller refusal/non-delivery, and refunds. -- Validated seller XMR payout destinations, server-only secret storage, fixed HTTPS processor origin, no redirects, bounded timeouts/responses, and least-privilege credentials. -- Durable payout intents and task reconciliation for both seller net and the fixed 1% recipient, including partial success across these separate payouts. The documented payout create endpoint does not expose a merchant idempotency key; a timeout or crash after submission is **ambiguous** and must not trigger blind retry/double payout. -- Reorg holds/reserves, operator reconciliation tools, tested backup/restore, dispute/refund procedures, and applicable custody/payment obligations. Confirmation depth alone does not make a small PoW network safe for real money. - -There is deliberately **no environment switch that bypasses these missing protections**. +Do not send real XMR to this sandbox. Live activation requires an end-to-end reviewed implementation, funded-liability and custody policies, production wallet/daemon isolation, withdrawal/reorg recovery, backups and operations evidence. No payment-mode setting is an activation path. diff --git a/docs/operator-launch.md b/docs/operator-launch.md index d6e843e..2dc3863 100755 --- a/docs/operator-launch.md +++ b/docs/operator-launch.md @@ -15,7 +15,7 @@ Use Node.js 24 and a clean reviewed checkout. For mainnet assessment on a host, The preflight: - Selects the pinned profile and reports network, chain/genesis, fixed fee policy and local gate status. -- Does **not** open/create a ledger or wallet, start a RandomX engine/listener, contact peers or SHKeeper, or write approval settings. +- Does **not** open/create a ledger or wallet, start a RandomX engine/listener, contact peers or external payment services, or write approval settings. - Does not print environment values or payment credentials. Reports are still operational information; review them before publishing. - Lists code blockers and missing external evidence. `productionReady` remains false even if startup approval variables are supplied. Fixes and independently reviewed evidence must be reflected in a future reviewed release; do not turn the report green by deleting findings. @@ -27,7 +27,7 @@ The runtime image can execute the entry point with `--launch-preflight` as well; For the proposed **non-monetary testnet beta only**, use a separate scoped decision: the chain/network is explicitly testnet, no valuable cards are offered, real payment/custody settings remain off, mock market delivery is disabled or the deployment is demonstrably valueless, and experimental wallet/data-loss limitations are disclosed. This does not close any mainnet/live-payment hold point below. Apartment-hosting evidence must include ISP/CGNAT/reachability, TLS/proxy/backend isolation, firewall/admin/patch controls, off-host encrypted backup and restore, and external alert delivery. If these cannot be verified, do not expose the apartment host publicly; choose a managed host. -[Stage-5 SHKeeper prototype](shkeeper-settlement.md) is fixture-only and disconnected from the public market. Production payment activation, real processor verification, custody/refund policy, journal/backup/monitor integration and operator reconciliation remain missing. It cannot justify closing the real-money hold point. +The real-money custody path is not implemented end-to-end. Production deposit/credit/withdrawal services, custody and refund policy, journal/backup/monitor integration, operator reconciliation and independent review remain missing. [Stage-4 deployment and recovery tooling](operations-recovery.md) adds explicit proxy trust, private loopback diagnostics/checks and stopped-node pair backup/verification/non-overwriting restore. A local native proof-replay restore drill is automated, but actual-host TLS/proxy verification, off-host recovery, alert delivery and operational ownership still require external evidence. @@ -41,7 +41,7 @@ For each item record **owner, artifact/release ID, date, evidence location, revi | Consensus launch design | Final genesis and manifest; measured starting target/hashpower; reviewed timestamp/retarget/partition/reorg attack analysis | Missing | | Node availability | Bounded public APIs/client; global resource/admission controls; realistic long-chain and hostile traffic capacity report | Missing | | Production wallet | At-rest/unlock/signing policy, same-origin compromise analysis, user confirmation and offline recovery drill | Missing | -| Real-money market, if offered | Reviewed SHKeeper invoice/payment checks, invoice quote authorization, seller destination registration, fee-recipient allocation, custody policy, durable payout intents, timeout/partial-success reconciliation, refund/reorg dispute controls | Not implemented; real payment must stay blocked | +| Real-money market, if offered | Direct wallet deposit observation and exact liability crediting, customer-authorized withdrawals, isolated treasury signing, durable reconciliation, custody policy, refund/reorg dispute controls | Not implemented; real payment must stay blocked | | Mock-market policy | Ensure public users cannot mistake buyer-triggered mock payment for money; disable sandbox selling/delivery for valuable mainnet cards unless explicitly confined to a valueless trial | Unresolved | | Peer/miner topology | Multiple independently administered hosts/operators/failure domains; actual online matching manifests and sufficient independent hashpower, not just two URL strings | Unverified | | Edge and host security | TLS/certificate renewal, firewall, loopback backend, reviewed proxy IP trust, connection/global limits, restricted administration, patched hosts, time synchronization | Unverified | diff --git a/docs/production-plan.md b/docs/production-plan.md index c3560e9..483c37c 100755 --- a/docs/production-plan.md +++ b/docs/production-plan.md @@ -16,7 +16,7 @@ Status: active engineering plan; **not launch or payment authorization**. Update | Payment before sale finalization | A paid sale must not complete or be included as a completed ownership sale on Wikideck until the agreed XMR payment has satisfied its confirmation/unlock policy. Payment notification/mempool presence is insufficient. | | Existing boundaries | No real processor access, credentials, invoices, transfers, genesis change, destructive migration or launch approval is authorized by this plan. Software licensing still requires an operator choice and publication. | -**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. +**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. No real XMR invoice, payable address, deposit, credit, withdrawal, or settlement path is 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. @@ -56,7 +56,7 @@ This scope is a proposed release boundary, not an approval to deploy. The operat ### What “confirmed before on-chain completion” requires -RandomX verifies work on Wikideck; it proves nothing about an XMR transfer. Monero privacy also means a Wikideck validator cannot infer recipient/amount/payment validity from a transaction ID alone. A processor's status is a trusted attestation, not a trustless cross-chain proof. Naively calling SHKeeper or a mutable Monero RPC from block validation would make consensus depend on credentials, service availability and potentially different observations; **do not implement that**. +RandomX verifies work on Wikideck; it proves nothing about an XMR transfer. Monero privacy also means a Wikideck validator cannot infer recipient/amount/payment validity from a transaction ID alone. A payment service's status is a trusted attestation, not a trustless cross-chain proof. Calling an external service or mutable Monero RPC from block validation would make consensus depend on credentials, service availability and potentially different observations; **do not implement that**. The design must specify what every validator can verify deterministically, which Monero chain/confirmation evidence is used, how its recipient/amount/order binding is established, what private information or keys must be disclosed, and how both chains' reorganizations are handled. Any relayer, committee, market operator, oracle or custodian introduces a named trust assumption. “Non-custodial,” “atomic,” and “trustless” are distinct claims and must be justified separately. @@ -85,7 +85,7 @@ Can begin independently of P0, but must not hardwire a settlement architecture. - Define canonical decimal input/output backed by integer atomic units, bounded values/precision, exact comparisons and independent commission rounding. No float-based amounts or implicit USD conversion. Optional fiat display, if added, is informational and never settlement authority. - Replace USD listing/bid/checkout fields and UI with versioned XMR-native terms. Sign currency, gross, commissions, destinations, fee policy/estimate limits, expiry, order/market/network identity and selected settlement protocol version. Update invoice/checkout review and participant disclosures. - 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. +- Use the selected pooled-custody model: direct Monero wallet/daemon observation and durable subaddress allocation for deposits, internal liability transfers for sales, and a separately constrained treasury withdrawal path. No third-party processor is selected or required. Preserve exact native-XMR prices; do not introduce fiat conversion as settlement authority. - 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 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. @@ -134,14 +134,14 @@ Acceptance: safety/liveness matrix covers every state transition and adversary; ### P4 — production payment integration, reconciliation and controls -Depends on selected P0 model; may not require SHKeeper if incompatible with the approved native-XMR/trustless design. +Depends on the pooled-custody decision in ADR 002 and the separately reviewed NFT lock/completion/cancellation protocol. -- Approved exact-XMR processor contract and private infrastructure: credential scoping/rotation, secrets-file/manager loading, restricted fixed-origin TLS/DNS/egress transport, bounded responses/deadlines, no redirects and no sensitive logs. API keys never enter browser/build variables. -- Actual Monero payment confirmation policy: canonical transaction identity, amount/destination/order binding, incoming/outgoing and locked/unlocked/spendable states. Determine whether processor status is adequate or wallet/daemon evidence is required. “Fully confirmed” must have a defined numerical/policy meaning; no finite count eliminates reorg risk. -- Invoice requests are immutable by version and replay-safe. Late, partial, duplicate, overpaid, expired and unattributed incoming payments get defined liabilities/reconciliation outcomes, not disappearance or unconditional refunds. Callbacks, if supported, are bounded hints; re-read authoritative state. -- Outbound seller/author/refund attempts use unique durable allocations and task IDs. Reconcile exact amounts/destinations/transaction IDs; preserve successful legs during partial failure. No blind resubmission after timeout/crash and no manual DB editing to reset an attempt. Use independent approvals and audited reason/evidence for resolution; exact rules depend on protocol/custody decision. -- Embed all authoritative payment state into the production backup/restore schema, monitoring and retention policy. Test processor external history against restored journals before resuming work; local restore cannot undo an XMR transfer. -- Per-order/daily/total unsettled and hot-wallet exposure caps, immediate disable-new-checkout controls and bounded existing-obligation handling. Stopping new orders is not permission to abandon customer funds. Credentials and signing capabilities have separated roles where possible. +- Deposit enrollment and allocation: authenticate the Wikideck beneficiary, create a durable one-to-one mapping to a Monero subaddress before displaying it, and keep old allocations monitored. The spend-capable wallet must be isolated from the public API. +- Observation and credit: consume wallet-owned output records with stable per-output identity, canonical inclusion, versioned confirmation/unlock policy, exact atomic amounts and idempotent ledger writes. Unknown, late, duplicate, partial and reorged outputs require defined suspense/hold treatment; observations alone never authorize spendable credit. +- Marketplace settlement: reserve available customer liability and order state atomically in PostgreSQL; release seller/author/host liabilities only from the agreed canonical sale outcome. A user-signed mock-payment action is never payment evidence. +- Withdrawals: authorize exact destination, amount and maximum fee; reserve liabilities; fence one treasury worker; durably store prepared-transaction identity before relay; reconcile chain status and actual fee. Ambiguous outcomes must not trigger a second transaction or release of the reservation. +- Recovery and operations: include customer-to-subaddress mapping, wallet generation/high-water marks, journal, pending outputs, signed approvals and prepared transactions in encrypted backups. Restore without spending, reconcile with Monero state, then explicitly re-enable the treasury worker. +- Set measured liability/exposure caps and independent alert/stop controls. Stopping new deposits or orders must not abandon existing liabilities. Separate observation, ledger, operator review and spend-signing privileges. Acceptance: full end-to-end staging against the verified supported processor/network, unauthorized-action tests, injected partial sends/outages/timeout/crash/reorg tests, actual-fund reconciliation procedure and reviewed secret-access controls. Any processor access, credentials or fund movement requires a separate explicit authorized test scope, budgets and network selection. This plan authorizes none. @@ -225,6 +225,5 @@ Not every value is needed to begin engineering; each becomes a blocking decision - [Current participant payment disclosures](market-transparency.md) — describes existing behavior, not future promises. - [Security findings](security-review.md) and [operator hold-point register](operator-launch.md). - [Resource controls](node-resources.md), [wallet lifecycle](wallet-security.md), [calibration evidence](consensus-calibration.md), [operations/recovery](operations-recovery.md). -- [Offline SHKeeper contract/state-machine work](shkeeper-settlement.md) — reusable tests/ideas, not the selected production architecture. This plan supersedes the earlier USD production-pricing direction. New mock listings already use native XMR; historical USD terms remain intact. Custody and M2 implementation status are explicitly bounded above; no sandbox or infrastructure milestone implies live settlement or launch readiness. diff --git a/docs/security-review.md b/docs/security-review.md index 96331d5..e3f9ff1 100755 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -8,11 +8,11 @@ This is an internal, source-assisted review with automated regression and dependency checks, **not an independent security audit, penetration test, formal consensus proof, or production certification**. The candidate has not been launched and no approval/attestation was set. Do not interpret passing tests, the existence of a mainnet profile, or the arrival of its candidate genesis date as permission to launch. -Scope: current consensus/ledger/P2P/RandomX integration, HTTP entry point, browser wallet/backups, marketplace authorization/accounting/reconciliation, Docker configuration and candidate startup gates. The operating hosts, real reverse proxy, public peers, SHKeeper instance, wallets and funds were **not** accessed or assessed. No active Internet-facing adversarial load test or independent native-code audit was performed. +Scope: current consensus/ledger/P2P/RandomX integration, HTTP entry point, browser wallet/backups, marketplace authorization/accounting/reconciliation, Docker configuration and candidate startup gates. The operating hosts, real reverse proxy, public peers, wallets and funds were **not** accessed or assessed. No active Internet-facing adversarial load test or independent native-code audit was performed. ## Findings that block production -**Stage-5 follow-up:** [Offline SHKeeper/settlement prototype](shkeeper-settlement.md) adds wire-contract validation and durable signed-quote/payout failure-path tests, without connecting a real processor or changing mock-only runtime behavior. SEC-04/SEC-05 remain open; the prototype is not production accounting/backup/UI integration or an atomic/refund solution. +Real-money settlement is not implemented end-to-end. The selected direction is direct Monero custody with a pooled liability ledger; no production deposit, credit, withdrawal, or customer-facing live settlement path exists. SEC-04/SEC-05 remain open. **Stage-4 follow-up:** [Operations/recovery tooling](operations-recovery.md) provides explicit trusted proxy IP/CIDRs, opt-in separate loopback diagnostics and aggregate monitoring checks, a proxy example (not installed), stopped-node SQLite pair snapshots/verification and new-directory restore. Native integration replays restored reference proofs and preserves marketplace identity/orders/receipts. SEC-07 and SEC-09 remain open pending intended-host deployment, off-host recovery and real alert/on-call evidence. @@ -23,7 +23,7 @@ Scope: current consensus/ledger/P2P/RandomX integration, HTTP entry point, brows | SEC-01 | High · public node availability | [HTTP full-state routes](../server/testnet/http.ts#L63) return [full cloned canonical snapshots](../server/testnet/ledger.ts#L223). The ledger retains [all DAG nodes in memory](../server/testnet/ledger.ts#L29); state-root calculation rebuilds the NFT set. Read cost and startup replay grow with chain/fork history. Per-IP request limits do not impose a global CPU, response-byte, memory or disk budget. | Bounded/paged public RPC and a compatible paged client; tested caching/indexing; validation admission budgets; disk/fork retention or a reviewed archival strategy. Demonstrate the intended workload and malicious traffic do not starve consensus or trigger OOM/disk exhaustion. Do not add an arbitrary consensus height cap. | | SEC-02 | High · chain security | [Initial target](../server/testnet/consensus.ts#L12) succeeds with probability approximately 1/16 per hash, and [retarget](../server/testnet/consensus.ts#L172) uses ten-block windows. This is inherited testnet calibration, not evidence of a secure 15-minute production launch. Timestamp/retarget manipulation, hashpower concentration and private-chain rewrites require quantified analysis. | Independent consensus/economic review; measured launch hashpower; adversarial retarget/clock/partition simulations; choose and freeze final launch parameters and genesis before approval. Changing these changes chain identity. RandomX's shared algorithm does **not** inherit Monero's hashpower or security. | | SEC-03 | High · valuable NFT keys | [Browser wallet creation/storage](../src/wallet.ts#L76) persists an extractable P-256 private key without password encryption in IndexedDB. Encrypted portable backups do not encrypt that stored key. Same-origin script compromise, profile access or a sufficiently privileged extension can sign/export it. | A reviewed production wallet model with encrypted-at-rest storage/unlock lifecycle or isolated/hardware signing, explicit XSS threat model and tested recovery. Non-extractable keys alone do not stop malicious same-origin signing; storage encryption does not protect an already unlocked page. | -| SEC-04 | High · real-money marketplace | [Payment adapter](../server/market/payments.ts) is deliberately mock-only. Buyer-controlled mock payment can lead a seller to sign a **real** card transfer. Listing reservations are not consensus locks. There is no reviewed live invoice verification, seller XMR destination registration, payout reconciliation, fee-recipient payout, refund/dispute system or custody controls. | Keep real money blocked. For valuable cards, disable mock-sale delivery/listings or keep the entire deployment explicitly valueless. Build/review the real SHKeeper settlement path separately; do not replace the mock adapter with a simple HTTP call. | +| SEC-04 | High · real-money marketplace | [Payment adapter](../server/market/payments.ts) is deliberately mock-only. Buyer-controlled mock payment can lead a seller to sign a **real** card transfer. Listing reservations are not consensus locks. There is no reviewed live deposit/credit flow, seller XMR destination registration, treasury withdrawal reconciliation, fee-recipient payout, refund/dispute system or custody controls. | Keep real money blocked. For valuable cards, disable mock-sale delivery/listings or keep the entire deployment explicitly valueless. Implement and review the complete direct-custody ledger and treasury path; do not replace the mock adapter with a simple HTTP call. | | SEC-05 | High · future live refunds/finality | [NFT transfer signatures](../shared/testnet.ts#L54) have no expiry/revocation and payment state is not a consensus commitment. A transfer absent during refund review may be rebroadcast or reappear after a reorg. An off-chain refund cannot cancel a previously signed NFT transfer. Recognizing current canonical delivery (fixed below) does not solve this general issue. | Explicit escrow/atomic-settlement design or a reviewed reserve/dispute/manual risk policy, late-payment/delivery rules, and recovery tools. Never promise trustless exchange or unconditional automatic real refunds. No finite confirmation count guarantees finality on a small PoW network. | | SEC-06 | Medium · market availability | [Reconciliation](../server/market/store.ts#L115) synchronously scans journal rows/chain and writes journal state. Public browsing invokes it. Lifetime [10,000-listing/order caps](../server/market/store.ts) stop new business without a built-in archival workflow; signed unfunded bids and reservations have little economic cost. Nonces/receipts are not an economic spam defense. | Separate/cached read paths and indexed incremental reconciliation; measured event-loop/IO budgets; account/global anti-abuse policy; bounded receipt/storage management and a tested archive process that preserves payout/idempotency history. | | SEC-07 | Medium · deployment availability | The app leaves Express proxy trust at its default. Behind a reverse proxy, IP-based limits may group clients under the proxy IP, allowing one client to throttle everyone. Conversely, blindly enabling proxy trust would permit spoofed forwarded IPs. [Health](../server/testnet/http.ts#L50) reports process liveness, not peer synchronization, native-engine readiness or market reconciliation health. | Configure trusted proxy hops/CIDRs against the real topology; reject direct backend access and untrusted forwarded headers; apply edge/global limits and connection/time/body budgets. Add private monitoring for peer freshness, tip lag, reorgs, queues, RandomX failures, disk/WAL usage and market review states. Test actual TLS/HSTS/certificate renewal. | @@ -68,4 +68,4 @@ USD authorization followed by a separately displayed XMR quote is not itself a c - Fixed fee address decoded independently as **95-character Monero Base58, 69 decoded bytes, prefix 18 (mainnet standard address)**; Keccak-256 checksum matched (`c752185b`). This proves formatting/checksum only, **not** ownership or intended recipient. Verify control out of band before any future live transfer; never share seed/private keys. - Mainnet preflight exercised with approvals absent: reports `NO-GO`, `productionReady: false`, `paymentMode: mock`; no node/ledger/network startup. Mainnet manifest/configuration remains candidate-only. -Not performed: independent audit, production-host penetration test, adversarial sustained load/fuzz campaign, private-key/hardware review, actual XMR invoice/payment/payout, SHKeeper instance verification, real peer/miner independence validation, or a production backup/restore exercise. See [operator launch procedure](operator-launch.md) for evidence required before explicit approval. +Not performed: independent audit, production-host penetration test, adversarial sustained load/fuzz campaign, private-key/hardware review, actual XMR deposit/credit/withdrawal, real peer/miner independence validation, or a production backup/restore exercise. See [operator launch procedure](operator-launch.md) for evidence required before explicit approval. diff --git a/docs/settlement-feasibility.md b/docs/settlement-feasibility.md index e2109e5..6652f60 100755 --- a/docs/settlement-feasibility.md +++ b/docs/settlement-feasibility.md @@ -9,7 +9,7 @@ Status: **proposed; research checkpoint, not an approved exchange protocol**. So - 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. - Continue research into an NFT-first, XMR-compatible conditional exchange. Do not port Bitcoin swap code as though Wikideck were Bitcoin. Current transfers cannot implement the researched protocol. - 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. +- The later approved direction is pooled custodial XMR balances under ADR 002; the trustless/arbitrated analysis below remains historical research, not the active implementation plan. **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. @@ -21,9 +21,8 @@ Status: **proposed; research checkpoint, not an approved exchange protocol**. So | [COMIT upstream](https://github.com/comit-network/xmr-btc-swap) | Archived/unmaintained; README points to eigenwallet and warns of incompatible peer changes. README states its cryptography was not formally audited. GPL-3.0. | Do not adopt archived upstream as a production dependency. Its historical direction/limitations are not proof of current feasibility or impossibility. | | [eigenwallet core](https://github.com/eigenwallet/core), [protocol explanation](https://docs.eigenwallet.org/advanced/refund_punish), [4.16.1 release](https://github.com/eigenwallet/core/releases/tag/4.16.1) | Maintained project with 4.16.1 release. Its current docs explicitly define taker as Bitcoin seller and maker as Monero seller; its lock/cancel/refund/punish flow is Bitcoin-timelock centric. The docs and site describe Bitcoin→Monero only. GPL-3.0; historical COMIT README says formal crypto audit absent, but that is not evidence about every current successor version. | Useful comparative protocol and operational lessons, but not the requested XMR-for-NFT direction or an arbitrary-NFT/P-256 integration. No audit artifact for a proposed Wikideck adaptation was found/evaluated. Review exact current source before considering any reuse. | | [Monero payment-proof guide](https://www.getmonero.org/resources/user-guides/prove-payment.html), [wallet RPC `check_tx_proof`](https://docs.getmonero.org/rpc-library/wallet-rpc/#check_tx_proof) | The payer can give a payee a transaction ID, destination address and transaction proof/key. The payee checks the proof against an up-to-date Monero blockchain view; RPC reports validity, received amount, in-pool status and confirmations. The RPC explicitly warns proof does not guarantee spendability (locked, already spent or duplicated/burnt one-time outputs). | Buyers can prove payment to a seller/market verifier for accountability. This does not identify a real-world person, reverse payment, or give every Wikideck validator an independently verifiable canonical-chain proof. Bind each order to a unique destination and exact amount; treat proof verification/depth as sensitive evidence. | -| [Monero wallet RPC: transfer](https://docs.getmonero.org/rpc-library/wallet-rpc/#transfer) | Supports multiple recipients and atomic-unit amounts; warns against subtracting fees from outputs when exact recipient amounts are required. | Native wallet multi-recipient capability is not the same as SHKeeper multi-payout support and does not make NFT exchange atomic. Amounts need lossless JSON encoding, not conversion to JavaScript Number. | +| [Monero wallet RPC: transfer](https://docs.getmonero.org/rpc-library/wallet-rpc/#transfer) | Supports multiple recipients and atomic-unit amounts; warns against subtracting fees from outputs when exact recipient amounts are required. | Native wallet multi-recipient capability does not make NFT exchange atomic. Amounts need lossless JSON encoding, not conversion to JavaScript Number. | | [Haveno trade protocol](https://docs.haveno.exchange/overview/trade-protocol/), [dispute resolution](https://docs.haveno.exchange/overview/dispute-resolution/), [project status](https://github.com/haveno-dex/haveno) | A maintained XMR P2P exchange documents per-trade 2-of-3 Monero multisig: one key for each trader and one for an arbitrator. Two keys are needed to spend. Parties normally co-release funds; when they disagree, an arbitrator investigates and co-signs with one party. Security deposits are locked to discourage protocol abuse. Its current project README says the upstream project does not support real trades directly; real networks use third-party builds/networks it does not endorse. | Strong prior art for an **arbitrated escrow**, not a trustless/automatic refund and not an NFT cross-chain protocol. The arbitrator is a real trust and availability dependency in disputes; deposits, key ceremony, wallet recovery, multisig fees, network governance and third-party network integrity matter. Its second asset/payment is fiat/other cryptocurrency and does not answer Wikideck card lock or consensus validation. Treat as a design pattern to adapt only after specialist review, not as a drop-in integration or endorsement. | -| [SHKeeper API 2.1.47](https://shkeeper.io/api/) | Documented invoice creation accepts USD fiat amounts; returns converted crypto amounts. XMR multipayout unsupported. Payout fee input is priority, not an atomic cost cap. | Exact native-XMR invoice and bounded-fee guarantees remain unverified. No hidden USD conversion. Processor status is trusted external observation, not deterministic consensus evidence. | The initial Monero atomic-swaps glossary URL returned 404 and is not relied upon. Sources above are primary project/paper documentation, not independent certification. Their live pages can change; a release decision needs archived/pinned evidence against actual dependencies. @@ -146,7 +145,7 @@ This layer improves detection, customer support and deterrence. If direct paymen - Run separate multi-process/multi-host adversarial tests for false proof, wrong recipient/amount, duplicate/replayed proof, stale/out-of-date node, payment reorg, buyer false dispute, delayed NFT transfer/reorg, arbitrator unavailable/colluding, key loss, and partial payout. Require an external Monero multisig/security review before staging. - If the operator's eventual priority is permissionless no-arbitrator exchange, keep pursuing the custom conditional XMR↔NFT protocol separately; no practical current implementation is established. Do not downgrade to direct payment while describing buyer protection. -**Decision request remains open:** no live settlement flow or Monero verifier has been selected. Direct seller payment plus proof and reputation (A) may deter abuse but is not buyer-protecting; B is a custodial fallback; C is the researched direction intended to target two-sided atomic control, but remains unproven and may be impractical. The public marketplace now displays native-XMR prices under a mock-only v3 domain; actual XMR payments remain disabled and the separate SHKeeper prototype still takes USD. +**Historical research status:** this ADR records earlier trustless/arbitrated options. The operator subsequently selected pooled custodial XMR balances in [ADR 002](custodial-market-design.md). The public marketplace displays native-XMR prices under a mock-only v3 domain; actual XMR payments remain disabled. No payment processor is part of the selected path. ## Threat model and required failure evidence diff --git a/docs/shkeeper-settlement.md b/docs/shkeeper-settlement.md deleted file mode 100755 index 7160429..0000000 --- a/docs/shkeeper-settlement.md +++ /dev/null @@ -1,75 +0,0 @@ -# SHKeeper integration prototype — stage 5, disabled - -For the participant-facing explanation of what is protected today and what is not, see [Market payment transparency](market-transparency.md). This technical prototype does not create a live buyer-protection, refund or arbitration service. - -**This is an offline, fixture-only implementation, not live checkout.** The running marketplace remains mock-only on every profile. `paymentMode` still rejects non-mock settings, `createLiveProcessor()` always throws, and mainnet/production approval gates remain unchanged. No real invoice, callback, payout, refund or credential exchange was performed. The operator-provided SHKeeper instance was **not contacted**. Only the public API documentation was read. - -## What is implemented - -- [XMR validation](../server/market/xmr.ts): Monero block-Base58 decoding, mainnet standard/subaddress prefix and Keccak-256 checksum checking, integer atomic-unit amount parsing (12 decimal places), bounded USD decimal parsing. Integrated/payment-ID and testnet addresses are rejected. A valid address/checksum is not proof of curve-key correctness, ownership or recipient control. Monero has tail emission; no invented fixed-supply cap is used as a validator. -- [SHKeeper wire-contract harness](../server/market/shkeeper.ts): explicit injected transport only, fixed HTTPS-shaped processor origin, correct API-key versus Basic authentication, no redirects, five-second deadline, 256 KiB streamed response limit, bounded schemas/arrays and secret-safe request failures. There is no global/default `fetch`, production network transport, credential loader, environment bypass or usable callback route. Tests supply ephemeral fixture credentials and fake `Response` objects. The factory name deliberately says **Harness**. -- [Durable settlement lab](../server/market/settlement-lab.ts): a separate SQLite/WAL journal, serialized operations, strict network identity and exclusive single-process lock; seller authorization of payout address/sale terms; buyer authorization of the exact invoice/address/amount/expiry and both fee allocations; authoritative invoice polling; exact confirmed payment verification; trusted canonical-delivery-reader boundary; durable payout intents; separate seller and hardcoded 1% payout legs; task/transaction allocation uniqueness; partial-success and ambiguous-outcome holds; restart reconciliation. -- No web UI/action, callback handler, background worker or production `Market` path constructs the harness/lab. The prototype must not be connected to real money by simply injecting global `fetch` or changing an environment variable. - -## API contract evidence - -Read from [public SHKeeper API docs](https://shkeeper.io/api/) version **2.1.47** on 2026-10-20, including the embedded OpenAPI definitions for `Invoice`, `Transaction` and `PayoutResultObj`: - -| Operation | Contract used | -| --- | --- | -| Create invoice | `POST /api/v1/XMR/payment_request`, `X-Shkeeper-Api-Key`, USD `amount`, unique `external_id`, required `callback_url`. Response has `id`, XMR `amount`, `wallet`, `exchange_rate`, `recalculate_after`. | -| Invoice lookup | `GET /api/v1/invoices/{external_id}`, API key, `status: success`, `invoices` array. Invoice fields: `external_id`, `fiat`, `amount_fiat`, `balance_fiat`, `status` in UNPAID/PARTIAL/PAID/OVERPAID, `txs`. | -| Invoice transaction | `addr`, `amount`, `crypto`, `status` CONFIRMED/UNCONFIRMED, `txid`. No numeric confirmation count is documented in this schema. | -| XMR payout | `POST /api/v1/XMR/payout`, Basic authentication, `amount`, `destination`, `fee` priority 1–4. Prototype chooses 2. XMR does not support the multipayout endpoint. | -| Payout status | `GET /api/v1/XMR/task/{task_id}`, Basic authentication, PENDING/FAILURE/SUCCESS; result entries contain `amount`, `dest`, success/error, optional message and `txids`. | - -The harness accepts numeric/string invoice IDs as documented examples differ, but requires UUID payout task IDs. It accepts quote `recalculate_after` only 0 or "0"; a floating/recalculating quote is refused rather than silently repriced. The required callback URL uses the reserved **market.invalid** domain and has no receiving route; it is a deliberate fixture placeholder that makes the create operation unsuitable for live use. - -Documentation alone cannot prove the deployed processor returns identical shapes/semantics. The actual instance/version, XMR enablement, precision, quote locking, fees, confirmation policy, task success meaning and failure/partial-send behavior require an explicitly authorized future integration review. A processor's `CONFIRMED` flag is not proof of an agreed XMR confirmation depth until its configured policy is independently established. Callback signing was not established by these schemas; **callback payloads never authorize payment** in this prototype. - -## Authorization and accounting - -Seller signs the lab's domain-separated network/chain/order/card/sequence/buyer terms, USD price, hosting basis points, expiry, NFT delivery depth, payout public key/address, and fixed **1% recipient/rate**. This proof links their NFT identity to the requested XMR destination; it does not prove that they control its private spend key. Invalid checksum/network destinations fail before quote creation. - -After invoice creation, buyer signs the exact invoice ID, currency, address, XMR amount, exchange rate, USD terms, expiry, hosting retention, seller net/destination and fixed-fee amount/destination. Wrong buyer, modified quote and wrong signature are refused. This second authorization is distinct from the existing mock market's USD reservation command; the public marketplace has not been migrated to this protocol. - -Both fees are computed independently from gross XMR in integer atomic units, rounded down. Seller net = gross − hosting retention − 1% allocation. **Hosting stays in the merchant wallet**, as designed; it is not a missing third payout or burned funds. Seller net and 1% recipient require separate XMR payouts because XMR multipayout is unsupported. For 0.1 XMR and 2.5% hosting: 0.0025 retained, 0.0965 seller, 0.001 fixed recipient. A sub-atomic fixed fee rounds to zero and its leg is skipped, not inflated. - -Processor fees included in an invoice can alter actual gross/fiat semantics. The prototype requires exact expected fiat and crypto totals and will hold inconsistent fee-bearing cases rather than assuming compatibility. Network fees, overpayment handling, processor-fee policy and reserves must be designed/disclosed before connecting live. This is a custodial gross-receipt/net-payout design, **not** a trustless XMR/NFT exchange. - -## State machine and failure rules - -1. **Draft → invoice intent:** persist `invoice-submitting` before calling the fixture create endpoint. A response loss, invalid response or restart with unresolved intent enters review. The POST is not automatically repeated, even though invoice external IDs exist, because create/update behavior and outcome reconciliation must be reviewed. -2. **Quote → awaiting payment:** buyer explicitly authorizes the exact quote before payment can be recognized. Expired signatures/quotes fail. No public payable address is exposed by the running app. -3. **Payment polling:** only server-to-server-shaped lookup data is used; exactly one external-ID invoice is required. It must have the expected fiat amount, currency and transaction addresses/currency; no duplicate tx IDs; exact confirmed gross and fiat balance. Partial/unconfirmed payments wait. Overpaid, contradictory, unknown, changed or late observations enter review; there is no blind refund or automatic invoice reuse. -4. **Paid identity:** save the exact incoming tx-ID set and enforce global incoming allocation uniqueness in the lab journal. Later changes to the paid set or amounts suspend settlement. The same observed deposit cannot fund two lab orders. -5. **NFT delivery:** the reader is a trusted consensus-library boundary, never a buyer-supplied HTTP boolean. It must return the exact transfer identity (chain/card/from/to/sequence), required canonical depth and a well-formed tip. Before any payout, missing depth waits; after payout begins, changed delivery enters permanent review. Recheck invoice and delivery before each separate payout. These reads cannot make off-chain money and on-chain delivery atomic; a reorg can occur after any check. -6. **Durable payout intent:** mark a leg `submitting` and commit **before** its create POST. Save its task ID under a unique constraint. Transport timeout, malformed result, task-ID collision, interrupted persistence or restart with no acknowledged task ID becomes `ambiguous`/review. Never retry that payout automatically. -7. **Known tasks:** repeat status GETs only. PENDING waits. SUCCESS must contain exactly one successful result matching amount/destination, at least one well-formed distinct transaction ID and no transaction ID already allocated to another leg/order. FAILURE, malformed success, amount/address mismatch or duplicate evidence enters review. A known task query failure does not create another POST. -8. **Partial success:** seller completion is durably recorded before the fee leg. If fee fails, preserve the seller's success and hold the remainder. There is no rollback, re-payment of the seller or assumption that the entire sale was unpaid. Reorg after completion holds without paying again. - -`expiresAt` is an invoice/payment-authorization deadline, not a deadline that cancels a correctly paid sale while NFT delivery is being mined. Refusing payout solely because a legitimate invoice later expires would not be a safe general settlement policy. Delivery timeouts/disputes and reserves remain unimplemented live-policy requirements. - -## Journal, recovery and activation holds - -The lab uses a **separate settlement-lab.sqlite** and lock under a caller-supplied disposable directory. It is **not** inside the production two-database backup contract, not exposed by current operations diagnostics and not an authoritative production accounting system. Current node backups do not protect it. Test fixtures reopen the lab to verify intent/task persistence; this is not a production processor reconciliation tool. Do not delete a stale lab lock unless its owning process is verified stopped. - -There is no operator override/retry/refund endpoint. `review` is intentionally not auto-recovered. The lab retains privacy-sensitive participant/payout/transaction data and signatures but **not processor credentials**. Trusted local filesystem ownership and external encryption remain necessary. Schema inspection validates identity, but this is not tamper-resistant accounting against an operator who controls the DB. - -Before live integration can be reviewed/activated, the following remain required: - -- Production network readiness and independent custody/consensus/application review; explicit operator payment activation, separate from network startup. **No live approval variable is implemented in this release.** -- A production adapter with restricted server-only secret loading, approved fixed origin/DNS/egress controls, no redirects, TLS review, least-privilege credentials and a verified deployed API contract. Never put credentials in Vite/browser variables or code. -- Real-market journal/schema, backup/restore/monitoring integration and migrations; typed authenticity validation of persisted authorization/amount records; trusted canonical delivery lookup; exposure only through participant-authorized APIs, not direct lab methods. -- Actual quote presentation/authorization UX, seller payout-address registration/change authorization, minimum order/payout policy, exact fees and network-cost/reserve treatment. -- Verified XMR confirmation policy and task success/failure semantics; bounded callback hints only; late/partial/overpayment/refund and non-delivery rules. Previously signed NFT transfers may arrive after a proposed refund—no auto-refund atomicity claim is made. -- Durable operator reconciliation tooling for ambiguous create/payout requests, partial sends, known task failures, reorgs and external wallet/task history. Timeout is **not** failure/no-send evidence. Never erase an intent or re-submit to make tests/operations appear healthy. -- Controlled end-to-end processor tests only after specific approval; this work did not request secrets, create invoices, transfer XMR or access the configured instance. No fund movement is authorized by this document. - -## Tests - -Final Node 24 TypeScript/Vite build passed. The full suite ran in a Docker container with **external networking disabled**: **119 tests passed, 0 failed, 0 skipped**, including existing native RandomX loopback integration. No real SHKeeper endpoint or credential was used; only public documentation retrieval occurred outside those offline tests. - -Offline fake transports cover endpoint auth separation, bounded bytes/redirect/type failures, five-second abort, fixed quote policy, XMR checksum/network/decimal rejection, seller/buyer quote binding, confirmed exact payment/depth, duplicate inbound allocation, duplicate payout tasks/tx IDs, concurrent advancement, response-loss ambiguity, restart quarantine, known-task polling, seller-success/fee-failure, mismatched payouts, changed paid evidence and post-payout reorg hold. Existing mock marketplace/backup/lock/launch and native RandomX tests remain in the full suite. - -This is partial engineering groundwork for SEC-04/SEC-05, not closure of those production findings. Live payments remain hard-disabled. diff --git a/server/market/settlement-lab.ts b/server/market/settlement-lab.ts deleted file mode 100755 index 0ebfa56..0000000 --- a/server/market/settlement-lab.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { DatabaseSync } from 'node:sqlite'; -import { mkdir,open,unlink,chmod } from 'node:fs/promises'; -import { join } from 'node:path'; -import { z } from 'zod'; -import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; -import { allocateXmrSale } from '../../shared/xmr-pricing.ts'; -import { NETWORK } from '../../shared/network.ts'; -import { CHAIN_ID,sha256,verifySignature,transactionId } from '../testnet/consensus.ts'; -import { atomicXmr,fiatCents,validateXmrAddress } from './xmr.ts'; -import { xmrString } from './payments.ts'; -import type { SettlementProcessor,ProcessorQuote,InvoiceObservation } from './shkeeper.ts'; - -const hex=z.string().regex(/^[a-f0-9]{64}$/); -const termsSchema=z.object({id:z.string().uuid(),network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID), - cardHash:hex,sequence:z.number().int().nonnegative().safe(),seller:hex,buyer:hex,sellerPublicKey:z.string().max(256), - buyerPublicKey:z.string().max(256),sellerXmr:z.string().length(95),priceCents:z.number().int().min(1).max(100_000_000), - feeBps:z.number().int().min(0).max(5000),expiresAt:z.number().int().nonnegative().safe(), - deliveryConfirmations:z.number().int().min(2).max(1000)}).strict(); -export type SettlementTerms=z.infer; -export function sellerSettlementMessage(t:SettlementTerms){return JSON.stringify([NETWORK,'settlement-lab-terms-v1', - t.chainId,t.id,t.cardHash,t.sequence,t.seller,t.buyer,t.sellerPublicKey,t.buyerPublicKey,t.sellerXmr,t.priceCents,t.feeBps, - t.expiresAt,t.deliveryConfirmations,ADDITIONAL_MARKET_FEE.bps,ADDITIONAL_MARKET_FEE.address]);} -type Leg={amount:string;destination:string;state:'ready'|'submitting'|'pending'|'complete'|'ambiguous'|'review'|'skipped';taskId?:string;txids?:string[]}; -export type LabSettlement={terms:SettlementTerms;sellerSignature:string; - status:'draft'|'invoice-submitting'|'quote'|'awaiting-payment'|'paid'|'paying'|'complete'|'review'; - quote?:ProcessorQuote;quoteDigest?:string;buyerSignature?:string;paidObservedAt?:number;paidTxids?:string[]; - hostingXmr?:string;seller?:Leg;fee?:Leg;reviewReason?:string}; -export function buyerInvoiceMessage(s:LabSettlement){ - if(!s.quote||!s.seller||!s.fee)throw new Error('No invoice quote to authorize'); - return JSON.stringify([NETWORK,'settlement-lab-invoice-v1',s.terms.chainId,s.terms.id,sha256(sellerSettlementMessage(s.terms)), - s.quote.id,'XMR','USD',s.terms.priceCents,s.quote.address,s.quote.amountXmr,s.quote.exchangeRate,s.terms.expiresAt, - s.hostingXmr,s.seller.amount,s.seller.destination,s.fee.amount,s.fee.destination]); -} -export type DeliveryEvidence={transferId:string|null;confirmations:number;tip:string}; -/** Trusted consensus-reader boundary, NOT a boolean submitted by an HTTP client. */ -export type DeliveryReader=(terms:SettlementTerms)=>Promise; - -/** Offline settlement research journal. NOT imported by app/Market and NOT part of production - * backup format. No public routes, worker, callback or CLI activation. Test fixtures only. */ -export class SettlementLab { - private readonly db:DatabaseSync; - private readonly processor:SettlementProcessor; - private readonly delivery:DeliveryReader; - private readonly now:()=>number; - private readonly release:()=>Promise; - private queue:Promise=Promise.resolve();private queued=0; - private constructor(db:DatabaseSync,processor:SettlementProcessor,delivery:DeliveryReader,now:()=>number,release:()=>Promise){ - this.db=db;this.processor=processor;this.delivery=delivery;this.now=now;this.release=release; - } - static async open(directory:string,processor:SettlementProcessor,delivery:DeliveryReader,now=Date.now){ - await mkdir(directory,{recursive:true,mode:0o700});const file=join(directory,'settlement-lab.sqlite'),lockPath=file+'.lock'; - const lock=await open(lockPath,'wx',0o600);let db:DatabaseSync|undefined; - try{ - db=new DatabaseSync(file); - db.exec(`PRAGMA journal_mode=WAL;PRAGMA synchronous=FULL;PRAGMA busy_timeout=5000; - CREATE TABLE IF NOT EXISTS lab_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS lab_orders(id TEXT PRIMARY KEY,data TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS lab_tasks(id TEXT PRIMARY KEY,order_id TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS lab_txids(id TEXT PRIMARY KEY,order_id TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS lab_incoming(id TEXT PRIMARY KEY,order_id TEXT NOT NULL);`); - const identity=JSON.stringify([NETWORK,CHAIN_ID,'offline-settlement-lab-v1']); - const row=db.prepare("SELECT value FROM lab_meta WHERE key='identity'").get() as {value:string}|undefined; - if(row&&row.value!==identity)throw new Error('Settlement lab network/schema mismatch'); - if(!row)db.prepare('INSERT INTO lab_meta VALUES(?,?)').run('identity',identity); - if(process.platform!=='win32')await chmod(file,0o600); - // A committed intent with no saved task ID must never be replayed after restart. - const update=db.prepare('UPDATE lab_orders SET data=? WHERE id=?'); - db.exec('BEGIN IMMEDIATE'); - try{ - for(const row of db.prepare('SELECT id,data FROM lab_orders').all() as {id:string;data:string}[]){ - const s=JSON.parse(row.data) as LabSettlement;let uncertain=s.status==='invoice-submitting'; - for(const leg of [s.seller,s.fee])if(leg?.state==='submitting'){leg.state='ambiguous';uncertain=true;} - if(uncertain){s.status='review';s.reviewReason='restart-after-unresolved-processor-intent';update.run(JSON.stringify(s),row.id);} - } - db.exec('COMMIT'); - }catch(error){db.exec('ROLLBACK');throw error;} - return new SettlementLab(db,processor,delivery,now,async()=>{await lock.close();await unlink(lockPath);}); - }catch(error){db?.close();await lock.close();await unlink(lockPath);throw error;} - } - async close(){await this.queue;this.db.close();await this.release();} - get(id:string):LabSettlement{ - z.string().uuid().parse(id);const row=this.db.prepare('SELECT data FROM lab_orders WHERE id=?').get(id) as {data:string}|undefined; - if(!row)throw new Error('Unknown lab settlement');return JSON.parse(row.data); - } - private save(s:LabSettlement){this.db.prepare('UPDATE lab_orders SET data=? WHERE id=?').run(JSON.stringify(s),s.terms.id);} - private hold(s:LabSettlement,reason:string){s.status='review';s.reviewReason=reason;this.save(s);return s;} - private serialize(fn:()=>Promise):Promise{ - if(this.queued>=32)return Promise.reject(new Error('Settlement lab busy')); - this.queued++;const run=this.queue.then(async()=>{try{return await fn();}finally{this.queued--;}}); - this.queue=run.catch(()=>undefined);return run; - } - create(raw:unknown,sellerSignature:string){ - const terms=termsSchema.parse(raw); - return this.serialize(async()=>{ - if(terms.seller===terms.buyer||terms.expiresAt<=this.now()||terms.expiresAt>this.now()+30*60000|| - !verifySignature(terms.sellerPublicKey,terms.seller,sellerSettlementMessage(terms),sellerSignature))throw new Error('Invalid seller terms or expiry'); - await validateXmrAddress(terms.sellerXmr);await validateXmrAddress(ADDITIONAL_MARKET_FEE.address); - if((this.db.prepare('SELECT COUNT(*) AS n FROM lab_orders').get() as {n:number}).n>=1000)throw new Error('Lab journal capacity reached'); - const s:LabSettlement={terms,sellerSignature,status:'draft'}; - this.db.prepare('INSERT INTO lab_orders VALUES(?,?)').run(terms.id,JSON.stringify(s));return s; - }); - } - issueInvoice(id:string){return this.serialize(async()=>{ - const s=this.get(id);if(s.status!=='draft')return s; - if(this.now()>=s.terms.expiresAt)return this.hold(s,'invoice-deadline'); - s.status='invoice-submitting';this.save(s); // Durable intent BEFORE network-shaped I/O, no automatic POST retry. - try{ - s.quote=await this.processor.createInvoice(id,s.terms.priceCents); - await validateXmrAddress(s.quote.address);const gross=atomicXmr(s.quote.amountXmr); - if(!s.quote.id||gross<=0n)throw new Error('Invalid quote'); - const {hosting,author:fee,sellerBeforeNetworkCosts}=allocateXmrSale(gross,s.terms.feeBps); - s.hostingXmr=xmrString(hosting); - s.seller={amount:xmrString(sellerBeforeNetworkCosts),destination:s.terms.sellerXmr,state:'ready'}; - s.fee={amount:xmrString(fee),destination:ADDITIONAL_MARKET_FEE.address,state:fee===0n?'skipped':'ready'}; - s.quoteDigest=sha256(buyerInvoiceMessage(s));s.status='quote';this.save(s);return s; - }catch{return this.hold(s,'invoice-create-ambiguous-or-invalid');} - });} - approveQuote(id:string,signature:string){return this.serialize(async()=>{ - const s=this.get(id); - if(s.status!=='quote'||this.now()>=s.terms.expiresAt|| - !verifySignature(s.terms.buyerPublicKey,s.terms.buyer,buyerInvoiceMessage(s),signature))throw new Error('Invalid buyer invoice authorization'); - s.buyerSignature=signature;s.status='awaiting-payment';this.save(s);return s; - });} - private async observe(s:LabSettlement,observation?:InvoiceObservation):Promise<'paid'|'waiting'|'review'>{ - let o:InvoiceObservation; - try{o=observation??await this.processor.invoice(s.terms.id);}catch{return 'review';} - if(!s.quote||o.external_id!==s.terms.id||o.fiat!=='USD'||fiatCents(o.amount_fiat)!==s.terms.priceCents)return 'review'; - let confirmed=0n,all=0n;const seen=new Set(); - for(const tx of o.txs){ - if(tx.addr!==s.quote.address||tx.crypto!=='XMR'||seen.has(tx.txid))return 'review'; - seen.add(tx.txid);const amount=atomicXmr(tx.amount);all+=amount;if(tx.status==='CONFIRMED')confirmed+=amount; - } - const gross=atomicXmr(s.quote.amountXmr); - if(s.paidTxids&&JSON.stringify([...seen].sort())!==JSON.stringify([...s.paidTxids].sort()))return 'review'; - if(o.status==='OVERPAID'||all>gross||fiatCents(o.balance_fiat)>s.terms.priceCents)return 'review'; - if(o.status==='PAID'&&confirmed===gross&&all===gross&&fiatCents(o.balance_fiat)===s.terms.priceCents)return 'paid'; - if(o.status==='UNPAID'&&all===0n&&fiatCents(o.balance_fiat)===0)return 'waiting'; - if(o.status==='PARTIAL'&&all{ - const s=this.get(id);if(s.status!=='awaiting-payment')return s; - if(this.now()>=s.terms.expiresAt)return this.hold(s,'late-or-expired-payment-requires-review'); - let observation:InvoiceObservation; - try{observation=await this.processor.invoice(s.terms.id);}catch{return this.hold(s,'invoice-verification-failed');} - const observed=await this.observe(s,observation); - if(observed==='review')return this.hold(s,'invoice-verification-failed'); - if(this.now()>=s.terms.expiresAt)return this.hold(s,'payment-observed-after-deadline-requires-review'); - if(observed==='paid'){ - // Independent observations must not allocate the same Monero transaction to two orders. - this.db.exec('BEGIN IMMEDIATE'); - try{ - for(const tx of observation.txs)this.db.prepare('INSERT INTO lab_incoming VALUES(?,?)').run(tx.txid,s.terms.id); - s.status='paid';s.paidObservedAt=this.now();s.paidTxids=observation.txs.map(tx=>tx.txid);this.save(s);this.db.exec('COMMIT'); - }catch{this.db.exec('ROLLBACK');return this.hold(s,'incoming-transaction-already-allocated-or-storage-conflict');} - }return s; - });} - private expectedDelivery(t:SettlementTerms){return transactionId({network:NETWORK,chainId:CHAIN_ID,cardHash:t.cardHash, - sequence:t.sequence,from:t.seller,to:t.buyer,publicKey:'',signature:''});} - private async ready(s:LabSettlement){ - const evidence=await this.delivery(structuredClone(s.terms)); - return evidence.transferId===this.expectedDelivery(s.terms)&&Number.isSafeInteger(evidence.confirmations)&& - evidence.confirmations>=s.terms.deliveryConfirmations&&/^[a-f0-9]{64}$/.test(evidence.tip); - } - advance(id:string){return this.serialize(async()=>{ - const s=this.get(id);if(!['paid','paying','complete'].includes(s.status))return s; - // Reads before each payout are risk controls, not cross-chain atomicity or finality. - if(await this.observe(s)!=='paid')return this.hold(s,'paid-invoice-no-longer-verifiable'); - if(!await this.ready(s)){ - if(s.status==='paid')return s; // Payment confirmed; still waiting for adequate NFT depth. - return this.hold(s,'delivery-reorganized-or-insufficient'); - } - if(s.status==='complete')return s; - s.status='paying';this.save(s); - for(const leg of [s.seller!,s.fee!]){ - if(leg.state==='complete'||leg.state==='skipped')continue; - if(leg.state==='ambiguous'||leg.state==='review'||leg.state==='submitting')return this.hold(s,'payout-intent-requires-operator-reconciliation'); - if(leg.state==='ready'){ - if(!await this.ready(s)||await this.observe(s)!=='paid')return this.hold(s,'settlement-evidence-changed-before-payout'); - leg.state='submitting';this.save(s); - try{ - leg.taskId=await this.processor.payout(leg.amount,leg.destination);z.string().uuid().parse(leg.taskId); - this.db.exec('BEGIN IMMEDIATE'); - try{this.db.prepare('INSERT INTO lab_tasks VALUES(?,?)').run(leg.taskId,s.terms.id);leg.state='pending';this.save(s);this.db.exec('COMMIT');} - catch(error){this.db.exec('ROLLBACK');throw error;} - } - catch{leg.state='ambiguous';return this.hold(s,'payout-create-outcome-unknown-never-retry-automatically');} - } - let task; - try{task=await this.processor.task(leg.taskId!);}catch{return s;} // Known task GET can be polled, no new payout POST. - if(task.status==='PENDING')return s; - const result=task.result; - if(task.status!=='SUCCESS'||result.length!==1||result[0].status!=='success'||result[0].dest!==leg.destination|| - atomicXmr(result[0].amount)!==atomicXmr(leg.amount)||!result[0].txids?.length|| - result[0].txids.some(tx=>!(/^[a-f0-9]{64}$/.test(tx)))||new Set(result[0].txids).size!==result[0].txids.length){ - leg.state='review';return this.hold(s,'payout-task-failed-partial-or-mismatched'); - } - this.db.exec('BEGIN IMMEDIATE'); - try{ - for(const txid of result[0].txids)this.db.prepare('INSERT INTO lab_txids VALUES(?,?)').run(txid,s.terms.id); - leg.state='complete';leg.txids=result[0].txids;this.save(s);this.db.exec('COMMIT'); - }catch{this.db.exec('ROLLBACK');leg.state='review';return this.hold(s,'payout-transaction-already-allocated-or-storage-conflict');} - } - s.status='complete';this.save(s);return s; - });} -} diff --git a/server/market/shkeeper.ts b/server/market/shkeeper.ts deleted file mode 100755 index 1441381..0000000 --- a/server/market/shkeeper.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { z } from 'zod'; -import { validateXmrAddress, atomicXmr, fiatCents } from './xmr.ts'; - -const identifier=z.union([z.string().min(1).max(128),z.number().int().nonnegative().safe()]).transform(String); -const decimal=z.string().min(1).max(32); -const external=z.string().uuid(); -const transaction=z.object({addr:z.string().length(95),amount:decimal,crypto:z.literal('XMR'), - status:z.enum(['CONFIRMED','UNCONFIRMED']),txid:z.string().regex(/^[a-f0-9]{64}$/)}); -export const invoiceObservation=z.object({external_id:external,fiat:z.literal('USD'),amount_fiat:decimal, - balance_fiat:decimal,status:z.enum(['UNPAID','PARTIAL','PAID','OVERPAID']),txs:z.array(transaction).max(256)}); -export type InvoiceObservation=z.infer; -export type ProcessorQuote={id:string;address:string;amountXmr:string;exchangeRate:string}; -export type PayoutResult={status:'PENDING'|'FAILURE'|'SUCCESS';result:{amount:string;dest:string;status:'success'|'error';txids?:string[]}[]}; -/** Explicit injection only. There is deliberately NO default/global fetch or credential-env loader. - * This interface exercises wire contracts with offline fake responses, not a live activation gate. */ -export type FixtureTransport=(url:string,init:RequestInit)=>Promise; -export interface SettlementProcessor { - createInvoice(id:string,priceCents:number):Promise; - invoice(id:string):Promise; - payout(amount:string,destination:string):Promise; - task(id:string):Promise; -} -export function createLiveProcessor():never{ - throw new Error('Live SHKeeper activation is not implemented or approved. Mock mode only.'); -} -export function createSHKeeperHarness(transport:FixtureTransport,credentials:{apiKey:string;username:string;password:string}):SettlementProcessor{ - // Ephemeral fixtures are supplied by tests. Application startup never imports/constructs this harness. - if(!transport||typeof transport!=='function')throw new Error('Explicit offline fixture transport required'); - for(const value of Object.values(credentials))if(!value||value.length>1024||/[\r\n\x00]/.test(value))throw new Error('Invalid fixture credentials'); - if(credentials.username.includes(':'))throw new Error('Invalid Basic username'); - const origin='https://shkeeper.eom.dev'; - async function request(path:string,basic:boolean,body?:unknown):Promise{ - const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),5000); - const aborted=new Promise((_,reject)=>{controller.signal.addEventListener('abort',()=>reject(new Error('Processor request deadline exceeded')),{once:true});}); - const operation=(async()=>{ - try{ - const response=await transport(origin+path,{method:body===undefined?'GET':'POST',redirect:'error',signal:controller.signal, - headers:{Accept:'application/json',...(basic?{Authorization:`Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`}:{'X-Shkeeper-Api-Key':credentials.apiKey}), - ...(body===undefined?{}:{'Content-Type':'application/json'})},...(body===undefined?{}:{body:JSON.stringify(body)})}); - if(!response.ok||response.redirected||!response.body||!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type')??'')){ - await response.body?.cancel();throw new Error('Processor response contract rejected'); - } - const length=response.headers.get('content-length'); - if(length!==null&&(!/^\d+$/.test(length)||Number(length)>256*1024)){await response.body.cancel();throw new Error('Processor response limit');} - const reader=response.body.getReader(),chunks:Uint8Array[]=[];let size=0; - try{for(;;){const part=await reader.read();if(part.done)break;size+=part.value.byteLength;if(size>256*1024)throw new Error('Processor response limit');chunks.push(part.value);}} - finally{await reader.cancel();reader.releaseLock();} - return JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(Buffer.concat(chunks))); - }catch{throw new Error('Processor operation failed or response could not be verified');} // Never include credentials/upstream body. - })(); - try{return await Promise.race([operation,aborted]);}finally{clearTimeout(timer);} - } - return { - async createInvoice(id,priceCents){ - external.parse(id);if(!Number.isSafeInteger(priceCents)||priceCents<1||priceCents>100_000_000)throw new Error('Invalid invoice price'); - const amount=`${Math.floor(priceCents/100)}.${String(priceCents%100).padStart(2,'0')}`; - const raw=await request('/api/v1/XMR/payment_request',false,{external_id:id,fiat:'USD',amount, - callback_url:`https://market.invalid/disabled-callback/${id}`}); // No live callback route or token. - const q=z.object({status:z.literal('success'),id:identifier,wallet:z.string().length(95),amount:decimal, - exchange_rate:decimal,recalculate_after:z.union([z.literal(0),z.literal('0')])}).parse(raw); - await validateXmrAddress(q.wallet);if(atomicXmr(q.amount)<=0n||!/^\d{1,12}(\.\d{1,12})?$/.test(q.exchange_rate)||Number(q.exchange_rate)<=0)throw new Error('Invalid processor quote'); - return {id:q.id,address:q.wallet,amountXmr:q.amount,exchangeRate:q.exchange_rate}; - }, - async invoice(id){ - external.parse(id); - const data=z.object({status:z.literal('success'),invoices:z.array(invoiceObservation).max(2)}).parse(await request(`/api/v1/invoices/${encodeURIComponent(id)}`,false)); - if(data.invoices.length!==1||data.invoices[0].external_id!==id)throw new Error('Invoice identity is missing or ambiguous'); - const invoice=data.invoices[0];fiatCents(invoice.amount_fiat);fiatCents(invoice.balance_fiat); - const seen=new Set(); - for(const tx of invoice.txs){await validateXmrAddress(tx.addr);atomicXmr(tx.amount);if(seen.has(tx.txid))throw new Error('Duplicate invoice transaction');seen.add(tx.txid);} - return invoice; - }, - async payout(amount,destination){ - if(atomicXmr(amount)<=0n)throw new Error('Payout must be positive');await validateXmrAddress(destination); - const result=z.object({task_id:z.string().uuid()}).parse(await request('/api/v1/XMR/payout',true,{amount,destination,fee:'2'})); - return result.task_id; - }, - async task(id){ - z.string().uuid().parse(id); - const result=z.object({status:z.enum(['PENDING','FAILURE','SUCCESS']),result:z.array(z.object({amount:decimal,dest:z.string().length(95), - status:z.enum(['success','error']),txids:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(64).optional()})).max(2).default([])}).parse(await request(`/api/v1/XMR/task/${id}`,true)); - for(const row of result.result){atomicXmr(row.amount);await validateXmrAddress(row.dest);} - return result; - }, - }; -} diff --git a/server/preflight.ts b/server/preflight.ts index e945fd3..367b7b8 100755 --- a/server/preflight.ts +++ b/server/preflight.ts @@ -27,7 +27,7 @@ export function launchPreflight(env: NodeJS.ProcessEnv = process.env, now = Date { id: 'public-node-resource-controls', status: 'blocked', detail: 'Stage-1 global RPC budgets, bounded pages/client history and read caching exist. In-memory DAG growth, full-history UI/reconciliation and production-scale load/capacity evidence remain unresolved.' }, { id: 'consensus-launch-calibration', status: 'blocked', detail: 'Stage-3 bounded offline native benchmarks and deterministic retarget/timestamp analysis exist. Representative network hashrate, stochastic/multi-host adversarial evidence, reviewed launch rules/genesis and independent economic-security review remain missing.' }, { id: 'wallet-key-protection', status: 'blocked', detail: 'Stage-2 encrypted vault storage, explicit migration/unlock and lock-aware signing exist. Legacy plaintext copies, unlocked-page compromise, migration/lifecycle coverage and independent production wallet/recovery review remain unresolved.' }, - { id: 'live-market-settlement', status: 'blocked', detail: 'Stage-5 offline SHKeeper wire/settlement prototypes and fake-processor failure tests exist. No live transport, production journal/UI/backup integration, reviewed confirmation/refund/custody policy or payment activation is enabled. Mock payment is not real payment.' }, + { id: 'live-market-settlement', status: 'blocked', detail: 'No end-to-end live Monero custody path is connected. Production deposit allocation/crediting, treasury withdrawal signing, market ledger settlement, recovery integration and reviewed custody/refund policy remain unavailable. Mock payment is not real payment.' }, { id: 'release-security-review', status: 'unverified', detail: 'Independent consensus/application review and release-specific dependency/native/container scans require external evidence; an operator environment flag is not evidence.' }, { id: 'deployment-rehearsal', status: 'unverified', detail: 'Stage-4 explicit proxy trust, private diagnostics/checker and stopped-node paired backup/restore with local native replay tests exist. Actual HTTPS/proxy topology, independent hosts/miners, alert delivery, off-host recovery and on-call procedures remain unverified.' }, { id: 'fee-recipient-control', status: 'unverified', detail: 'Confirm the fixed public XMR fee address, checksum and recipient control out of band before any future live payout. No keys or proof are requested here.' }, diff --git a/tests/custody.test.ts b/tests/custody.test.ts index e4a17e8..8488419 100755 --- a/tests/custody.test.ts +++ b/tests/custody.test.ts @@ -103,7 +103,7 @@ test('paused trading keeps browse and cancellations available, and fee recipient 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 })); + for (const mode of ['live', 'mainnet', '', 'processor']) assert.throws(() => custodyMode({ WIKIDECK_CUSTODY_MODE: mode })); }); test('custody requires a separate consent domain and rejects malformed monetary encodings', async t => { diff --git a/tests/market.test.ts b/tests/market.test.ts index 9029bbb..41cc227 100755 --- a/tests/market.test.ts +++ b/tests/market.test.ts @@ -459,7 +459,7 @@ test('USD and mock XMR accounting use bounded integer amounts and exact fee spli assert.equal(quote.invoice.address, null); assert.equal(mockQuote(1, 0, 3).invoice.amountXmr, '0.333333333334'); assert.equal(paymentMode({}), 'mock'); - for (const mode of ['live','shkeeper','testnet','']) assert.throws(() => paymentMode({ WIKIDECK_PAYMENT_MODE: mode })); + for (const mode of ['live','processor','testnet','']) assert.throws(() => paymentMode({ WIKIDECK_PAYMENT_MODE: mode })); for (const value of ['-1','5001','250.5','x','']) assert.throws(() => marketConfig({ WIKIDECK_MARKET_FEE_BPS: value })); assert.equal(marketConfig({ WIKIDECK_MARKET_FEE_BPS: '0' }).feeBps, 0); const noHostingFee = mockQuote(1500, 0, 15000); diff --git a/tests/network.test.ts b/tests/network.test.ts index 18ae5ea..736aba0 100755 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -55,13 +55,13 @@ test('mainnet gates require genesis time, chain-specific approval, HTTPS, indepe for(const [changes,now] of [[{},0],[{WIKIDECK_MAINNET_LAUNCH:'wrong'},Infinity], [{PUBLIC_ORIGIN:'http://market.example'},Infinity],[{WIKIDECK_PEERS:''},Infinity], [{WIKIDECK_PEERS:'https://peer-a.example,https://peer-a.example/'},Infinity], - [{WIKIDECK_ALLOW_PRIVATE_PEERS:'1'},Infinity],[{WIKIDECK_PAYMENT_MODE:'shkeeper'},Infinity],[{},Infinity]]) { + [{WIKIDECK_ALLOW_PRIVATE_PEERS:'1'},Infinity],[{WIKIDECK_PAYMENT_MODE:'processor'},Infinity],[{},Infinity]]) { try { assertLaunchAllowed({...env,...changes},now); results.push(true); } catch { results.push(false); } } console.log(JSON.stringify(results)); `)); assert.deepEqual(result, [false,false,false,false,false,false,false,true]); - assert.throws(() => paymentMode({ WIKIDECK_NETWORK: 'mainnet', WIKIDECK_PAYMENT_MODE: 'shkeeper' })); + assert.throws(() => paymentMode({ WIKIDECK_NETWORK: 'mainnet', WIKIDECK_PAYMENT_MODE: 'processor' })); }); test('mainnet ledger and market files use isolated folders; testnet backup headers are rejected', async () => { const dir = await mkdtemp(join(tmpdir(), 'wikideck-network-')); @@ -94,7 +94,7 @@ test('operator preflight is read-only, fails closed, and does not disclose crede const secret = 'test-only-sensitive-value-must-not-appear'; const result = spawnSync(process.execPath, ['--experimental-strip-types', 'server/index.ts', '--launch-preflight'], { env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_DATA_DIR: dataDir, - WIKIDECK_MAINNET_LAUNCH: '', WIKIDECK_MAINNET_REVIEW_ACK: '', SHKEEPER_API_KEY: secret }, + WIKIDECK_MAINNET_LAUNCH: '', WIKIDECK_MAINNET_REVIEW_ACK: '', PAYMENT_PROVIDER_API_KEY: secret }, encoding: 'utf8', timeout: 30000, }); assert.equal(result.status, 1); diff --git a/tests/project-copy.test.ts b/tests/project-copy.test.ts index 74e6d4d..b8eae3b 100755 --- a/tests/project-copy.test.ts +++ b/tests/project-copy.test.ts @@ -36,9 +36,9 @@ test('market transparency explains real controls without promising escrow, refun /cannot revoke a signed NFT transfer/, /no built-in arbitration service/, /the hosting operator and anyone with its database\/backups can read orders/, - /ambiguous requests are not blindly retried/, + /no live payment integration or supported setting/, ])assert.match(guide,required); - for(const name of ['../README.md','../docs/marketplace.md','../docs/shkeeper-settlement.md']){ + for(const name of ['../README.md','../docs/marketplace.md']){ assert.match(await readFile(new URL(name,import.meta.url),'utf8'),/market-transparency\.md/); } // Resolve every workspace-relative Markdown link in the new guide, catching missing documentation. diff --git a/tests/shkeeper.test.ts b/tests/shkeeper.test.ts deleted file mode 100755 index 96ee655..0000000 --- a/tests/shkeeper.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { randomUUID,generateKeyPairSync,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 { keccak } from 'hash-wasm'; -import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts'; -import { NETWORK } from '../shared/network.ts'; -import { CHAIN_ID,address,sha256,transactionId } from '../server/testnet/consensus.ts'; -import { atomicXmr,validateXmrAddress } from '../server/market/xmr.ts'; -import { createLiveProcessor,createSHKeeperHarness,type InvoiceObservation,type PayoutResult,type FixtureTransport } from '../server/market/shkeeper.ts'; -import { SettlementLab,sellerSettlementMessage,buyerInvoiceMessage,type SettlementTerms,type DeliveryEvidence } from '../server/market/settlement-lab.ts'; - -const credentials={apiKey:'ephemeral-fixture-key',username:'fixture-user',password:'fixture-password-not-live'}; -const merchant=ADDITIONAL_MARKET_FEE.address; -async function fixtureAddress(prefix=18){ - const bytes=Uint8Array.from([prefix,...Array(64).fill(2)]),checksum=Buffer.from((await keccak(bytes,256)).slice(0,8),'hex'); - const data=Buffer.concat([bytes,checksum]),alphabet='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz',sizes:Record={1:2,2:3,3:5,4:6,5:7,6:9,7:10,8:11}; - let result=''; - for(let i=0;isign('sha256',Buffer.from(s),{key:keys.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64')}; -} -const seller=identity(),buyer=identity(); -function fakeProcessor(){ - const invoices=new Map(),tasks=new Map(); - let payoutMode:'success'|'pending'|'failure'|'mismatch'|'lost-response'='success'; - let duplicateTx=false,duplicateTask=false,recalculate=false,failSecond=false,loseInvoice=false; - const posts:{path:string;body:Record}[]=[]; - const transport:FixtureTransport=async(url,init)=>{ - assert.ok(url.startsWith('https://shkeeper.eom.dev/api/v1/'));assert.equal(init.redirect,'error');assert.ok(init.signal); - const headers=init.headers as Record,path=new URL(url).pathname; - const basic=path.includes('/payout')||path.includes('/task/'); - assert.equal(headers.Accept,'application/json'); - if(basic){assert.equal(headers.Authorization,`Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`);assert.equal(headers['X-Shkeeper-Api-Key'],undefined);} - else{assert.equal(headers['X-Shkeeper-Api-Key'],credentials.apiKey);assert.equal(headers.Authorization,undefined);} - if(init.method==='POST'){ - const body=JSON.parse(init.body as string);posts.push({path,body}); - if(path.endsWith('/payment_request')){ - assert.equal(body.fiat,'USD');assert.equal(body.amount,'15.00'); - invoices.set(body.external_id,{external_id:body.external_id,fiat:'USD',amount_fiat:'15.00',balance_fiat:'0.00',status:'UNPAID',txs:[]}); - if(loseInvoice)throw new Error('created invoice, response lost'); - return Response.json({status:'success',id:randomUUID(),wallet:merchant,amount:'0.100000000000',exchange_rate:'150',recalculate_after:recalculate?60:0}); - } - assert.equal(body.fee,'2');const id=duplicateTask&&tasks.size?[...tasks.keys()][0]:randomUUID();tasks.set(id,{amount:body.amount,destination:body.destination}); - if(payoutMode==='lost-response')throw new Error(`Sensitive upstream detail ${credentials.password}`); - return Response.json({task_id:id}); - } - if(path.includes('/invoices/'))return Response.json({status:'success',invoices:[invoices.get(path.split('/').at(-1)!)]}); - const id=path.split('/').at(-1)!,task=tasks.get(id);assert.ok(task); - const failure=payoutMode==='failure'||(failSecond&&tasks.size===2&&id===[...tasks.keys()][1]); - const result:PayoutResult=payoutMode==='pending'?{status:'PENDING',result:[]}:{status:failure?'FAILURE':'SUCCESS',result:[{ - amount:payoutMode==='mismatch'?'0.2':task.amount,dest:task.destination,status:failure?'error':'success',txids:[duplicateTx?'f'.repeat(64):sha256(id)]}]}; - return Response.json(result); - }; - return {client:createSHKeeperHarness(transport,credentials),transport,invoices,tasks,posts, - mode:(value:typeof payoutMode)=>{payoutMode=value;},duplicateTx:()=>{duplicateTx=true;},duplicateTask:()=>{duplicateTask=true;},recalculate:()=>{recalculate=true;}, - failFee:()=>{failSecond=true;},loseInvoice:()=>{loseInvoice=true;}}; -} -async function fixture(t:{after(fn:()=>Promise):void}){ - const dir=await mkdtemp(join(tmpdir(),'wikideck-settlement-lab-')),processor=fakeProcessor(); - let now=1_800_000_000_000; - let evidence:DeliveryEvidence={transferId:null,confirmations:0,tip:'a'.repeat(64)}; - let journal=await SettlementLab.open(dir,processor.client,async()=>structuredClone(evidence),()=>now); - t.after(async()=>{await journal.close();await rm(dir,{recursive:true,force:true});}); - const terms:SettlementTerms={id:randomUUID(),network:NETWORK,chainId:CHAIN_ID,cardHash:'b'.repeat(64),sequence:0, - seller:seller.address,buyer:buyer.address,sellerPublicKey:seller.publicKey,buyerPublicKey:buyer.publicKey, - sellerXmr:await fixtureAddress(),priceCents:1500,feeBps:250,expiresAt:now+30*60000,deliveryConfirmations:10}; - const create=()=>journal.create(terms,seller.sign(sellerSettlementMessage(terms))); - const quote=async()=>{await create();return journal.issueInvoice(terms.id);}; - const approved=async()=>{const q=await quote();await journal.approveQuote(terms.id,buyer.sign(buyerInvoiceMessage(q)));return q;}; - const markPaid=()=>{processor.invoices.set(terms.id,{external_id:terms.id,fiat:'USD',amount_fiat:'15.00',balance_fiat:'15.00',status:'PAID', - txs:[{addr:merchant,amount:'0.1',crypto:'XMR',status:'CONFIRMED',txid:'c'.repeat(64)}]});}; - const deliver=(confirmations=10)=>{evidence={transferId:transactionId({network:NETWORK,chainId:CHAIN_ID,cardHash:terms.cardHash,sequence:0,from:seller.address,to:buyer.address,publicKey:'',signature:''}),confirmations,tip:'a'.repeat(64)};}; - return {dir,processor,terms,create,quote,approved,markPaid,deliver,get journal(){return journal;}, - advanceTime:(ms:number)=>{now+=ms;},loseDelivery:()=>{evidence={...evidence,transferId:null,confirmations:0};}, - restart:async()=>{await journal.close();journal=await SettlementLab.open(dir,processor.client,async()=>structuredClone(evidence),()=>now);}}; -} - -test('XMR checksum/network and amount validation reject malformed destinations and floating-point syntax',async()=>{ - assert.equal(await validateXmrAddress(merchant),merchant); - assert.equal(await validateXmrAddress(await fixtureAddress(42)),await fixtureAddress(42)); - await assert.rejects(validateXmrAddress(merchant.slice(0,-1)+'1'),/checksum/); - await assert.rejects(validateXmrAddress(await fixtureAddress(53)),/network/); - await assert.rejects(validateXmrAddress('https://merchant.invalid')); - for(const amount of ['-1','1e-3','0.0000000000001','Infinity',' 1','01'])assert.throws(()=>atomicXmr(amount)); - assert.equal(atomicXmr('0.000000000001'),1n); - assert.throws(()=>createLiveProcessor(),/not implemented or approved/); -}); -test('SHKeeper harness authenticates each documented endpoint and rejects unlocked quote rates',async()=>{ - const f=fakeProcessor(),id=randomUUID();const q=await f.client.createInvoice(id,1500); - assert.equal(q.amountXmr,'0.100000000000');assert.equal((await f.client.invoice(id)).status,'UNPAID'); - const task=await f.client.payout('0.001',merchant);assert.equal((await f.client.task(task)).status,'SUCCESS'); - f.recalculate();await assert.rejects(f.client.createInvoice(randomUUID(),1500)); - assert.equal(f.posts[0].body.callback_url.startsWith('https://market.invalid/'),true); -}); -test('processor responses are bounded, redirects/types/errors are refused without credential leakage',async()=>{ - for(const response of [new Response('{}',{status:302,headers:{'Content-Type':'application/json'}}),new Response(''), - new Response('{}',{headers:{'Content-Type':'application/json','Content-Length':'9999999'}}), - new Response('x'.repeat(256*1024+1),{headers:{'Content-Type':'application/json'}})]){ - const client=createSHKeeperHarness(async()=>response,credentials); - await assert.rejects(client.invoice(randomUUID()),error=>error instanceof Error&&!error.message.includes(credentials.password)); - } - const failing=createSHKeeperHarness(async()=>{throw new Error(credentials.apiKey+credentials.password);},credentials); - await assert.rejects(failing.payout('0.1',merchant),error=>error instanceof Error&&!error.message.includes(credentials.password)&&!error.message.includes(credentials.apiKey)); -}); -test('seller destination and buyer exact invoice quote require independent signatures',async t=>{ - const f=await fixture(t); - await assert.rejects(f.journal.create({...f.terms,sellerXmr:merchant},seller.sign(sellerSettlementMessage(f.terms))),/seller terms/); - const q=await f.quote();assert.equal(q.seller?.amount,'0.096500000000');assert.equal(q.fee?.amount,'0.001000000000'); - const altered=structuredClone(q);altered.quote!.amountXmr='0.2'; - await assert.rejects(f.journal.approveQuote(f.terms.id,buyer.sign(buyerInvoiceMessage(altered))),/buyer invoice/); - await assert.rejects(f.journal.approveQuote(f.terms.id,seller.sign(buyerInvoiceMessage(q))),/buyer invoice/); - assert.equal((await f.journal.approveQuote(f.terms.id,buyer.sign(buyerInvoiceMessage(q)))).status,'awaiting-payment'); - assert.equal(f.processor.posts.filter(p=>p.path.endsWith('/payout')).length,0); -}); -test('confirmed exact payment plus canonical depth settles seller and fixed fee separately once',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id); - f.deliver(9);assert.equal((await f.journal.advance(f.terms.id)).status,'paid');assert.equal(f.processor.tasks.size,0); - f.deliver();const done=await f.journal.advance(f.terms.id); - assert.equal(done.status,'complete');assert.equal(done.seller?.state,'complete');assert.equal(done.fee?.state,'complete'); - const payouts=f.processor.posts.filter(p=>p.path.endsWith('/payout')); - assert.deepEqual(payouts.map(p=>[p.body.destination,p.body.amount]),[[f.terms.sellerXmr,'0.096500000000'],[merchant,'0.001000000000']]); - await f.restart();await f.journal.advance(f.terms.id);assert.equal(f.processor.tasks.size,2); -}); -test('partial/unconfirmed payments wait; overpayments, identity mismatch and late receipt are held',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid(); - const o=f.processor.invoices.get(f.terms.id)!;o.txs[0].status='UNCONFIRMED'; - assert.equal((await f.journal.pollPayment(f.terms.id)).status,'awaiting-payment'); - o.status='PARTIAL';o.txs[0].amount='0.05';o.balance_fiat='7.50'; - assert.equal((await f.journal.pollPayment(f.terms.id)).status,'awaiting-payment'); - f.advanceTime(30*60000); - assert.equal((await f.journal.pollPayment(f.terms.id)).status,'review');assert.equal(f.processor.tasks.size,0); -}); -test('authoritative lookup rejects duplicate/mismatched transactions even with PAID status',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid(); - const o=f.processor.invoices.get(f.terms.id)!;o.txs.push({...o.txs[0]}); - assert.equal((await f.journal.pollPayment(f.terms.id)).status,'review');assert.equal(f.processor.tasks.size,0); -}); -test('processor PAID with overpayment or changed fiat does not authorize delivery settlement',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid(); - f.processor.invoices.get(f.terms.id)!.txs[0].amount='0.11'; - assert.equal((await f.journal.pollPayment(f.terms.id)).status,'review'); -}); -test('payout response loss remains ambiguous across retries/restart; no blind second POST',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();f.processor.mode('lost-response'); - const failed=await f.journal.advance(f.terms.id);assert.equal(failed.status,'review');assert.equal(failed.seller?.state,'ambiguous');assert.equal(f.processor.tasks.size,1); - await f.journal.advance(f.terms.id);await f.restart();await f.journal.advance(f.terms.id); - assert.equal(f.processor.posts.filter(p=>p.path.endsWith('/payout')).length,1); -}); -test('known pending task is polled after restart without recreating payout',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();f.processor.mode('pending'); - assert.equal((await f.journal.advance(f.terms.id)).seller?.state,'pending');await f.restart(); - await f.journal.advance(f.terms.id);assert.equal(f.processor.tasks.size,1); - f.processor.mode('success');assert.equal((await f.journal.advance(f.terms.id)).status,'complete');assert.equal(f.processor.tasks.size,2); -}); -test('duplicate payout transaction evidence is held after seller success, never allocated twice',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver(); - // First call: seller task remains pending. Next call confirms seller then submits fee in pending mode. - f.processor.mode('pending');await f.journal.advance(f.terms.id);f.processor.mode('success'); - f.processor.duplicateTx();const result=await f.journal.advance(f.terms.id); - assert.equal(result.status,'review');assert.equal(result.seller?.state,'complete');assert.equal(result.fee?.state,'review'); - await f.restart();await f.journal.advance(f.terms.id);assert.equal(f.processor.tasks.size,2); -}); -test('reorg after payout pauses settlement permanently instead of paying again',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver(); - assert.equal((await f.journal.advance(f.terms.id)).status,'complete');f.loseDelivery(); - assert.equal((await f.journal.advance(f.terms.id)).status,'review');f.deliver();await f.journal.advance(f.terms.id);assert.equal(f.processor.tasks.size,2); -}); -test('persisted submitting intent is quarantined at restart before any transport call',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id); - const s=f.journal.get(f.terms.id);s.status='paying';s.seller!.state='submitting'; - const db=new DatabaseSync(join(f.dir,'settlement-lab.sqlite')); - try{db.prepare('UPDATE lab_orders SET data=? WHERE id=?').run(JSON.stringify(s),f.terms.id);}finally{db.close();} - await f.restart();f.deliver();assert.equal((await f.journal.advance(f.terms.id)).seller?.state,'ambiguous');assert.equal(f.processor.tasks.size,0); -}); -test('seller success followed by fee task failure preserves partial success and refuses retry',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();f.processor.failFee(); - const result=await f.journal.advance(f.terms.id); - assert.equal(result.status,'review');assert.equal(result.seller?.state,'complete');assert.equal(result.fee?.state,'review'); - await f.restart();await f.journal.advance(f.terms.id);assert.equal(f.processor.tasks.size,2); -}); -test('mismatched payout task amount and reused task IDs require review without replay',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();f.processor.mode('mismatch'); - assert.equal((await f.journal.advance(f.terms.id)).status,'review');assert.equal(f.processor.tasks.size,1); -}); -test('reused processor task ID across seller and fee legs is quarantined',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();f.processor.duplicateTask(); - const s=await f.journal.advance(f.terms.id);assert.equal(s.status,'review');assert.equal(s.seller?.state,'complete');assert.equal(s.fee?.state,'ambiguous'); - const calls=f.processor.posts.length;await f.restart();await f.journal.advance(f.terms.id);assert.equal(f.processor.posts.length,calls); -}); -test('invoice creation response loss is held and never repeated automatically',async t=>{ - const f=await fixture(t);await f.create();f.processor.loseInvoice(); - assert.equal((await f.journal.issueInvoice(f.terms.id)).status,'review'); - await f.restart();await f.journal.issueInvoice(f.terms.id); - assert.equal(f.processor.posts.filter(p=>p.path.endsWith('/payment_request')).length,1); -}); -test('changing authoritative paid transaction identity suspends payouts',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver(); - f.processor.invoices.get(f.terms.id)!.txs[0].txid='e'.repeat(64); - assert.equal((await f.journal.advance(f.terms.id)).status,'review');assert.equal(f.processor.tasks.size,0); -}); -test('concurrent settlement advances serialize and cannot duplicate payout POSTs',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver(); - const results=await Promise.all(Array.from({length:8},()=>f.journal.advance(f.terms.id))); - assert.ok(results.every(s=>s.status==='complete')); - assert.equal(f.processor.posts.filter(p=>p.path.endsWith('/payout')).length,2); -}); -test('same incoming transaction cannot fund two separate invoice orders',async t=>{ - const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id); - const second={...f.terms,id:randomUUID()}; - await f.journal.create(second,seller.sign(sellerSettlementMessage(second))); - const quote=await f.journal.issueInvoice(second.id);await f.journal.approveQuote(second.id,buyer.sign(buyerInvoiceMessage(quote))); - const observation=structuredClone(f.processor.invoices.get(f.terms.id)!);observation.external_id=second.id; - f.processor.invoices.set(second.id,observation); - assert.equal((await f.journal.pollPayment(second.id)).status,'review');assert.equal(f.processor.tasks.size,0); -}); -test('processor deadline aborts hung fixture transport without returning secret detail', {timeout:10000},async()=>{ - let aborted=false; - const transport:FixtureTransport=async(_url,init)=>new Promise((_resolve,reject)=>{ - init.signal!.addEventListener('abort',()=>{aborted=true;reject(new Error(credentials.password));},{once:true}); - }); - const processor=createSHKeeperHarness(transport,credentials); - await assert.rejects(processor.invoice(randomUUID()),error=>error instanceof Error&&!error.message.includes(credentials.password)); - assert.equal(aborted,true); -});