Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.
Private material moved out of the public tree (docs/private/, git-ignored):
- the wallet keyfile recovery runbook and its helper scripts, which together
published the mainnet wallet file path, the service account, backup snapshot
filenames, and the wallet RPC listener state. The helper duplicated the same
constants, so relocating the document alone would not have removed them.
- the stagenet parallel-candidate runbook and its fixtures.
- the dated production-state record, the internal remediation plan, and the
TODO list. None are reference documentation for an outside reader.
Redacted or genericised:
- operator home-directory paths in shipped scripts and units. The asset build
script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
- private LAN addresses in tests, env examples and deploy docs, replaced with
RFC 5737 documentation addresses.
- the default alert sender and the notification hub example.
- the Monero wallet unit now matches the Wownero one: a dedicated service
account and a config file, instead of the operator's login account with the
wallet password in argv where any local user can read it from /proc.
Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.
Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.
Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.
Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.
127 suites / 1015 tests pass.
10 KiB
Payments
The game does not implement per-chain wallet or daemon code inline. It talks to pluggable payment providers behind one contract, so an operator routes each chain to whichever backend they run.
Provider contract
src/payments/providers/paymentProvider.js defines the interface. All amounts are atomic units for
the chain (BigInt or an integer-valued string), never decimal floats.
createInvoice({chain, amountAtomic, description, userId, orderId})
-> {invoiceId, address, uri, amountAtomic, expiresAt, raw}
getInvoiceStatus(ref)
-> {status: 'pending'|'processing'|'paid'|'expired'|'invalid', complete, paidAtomic, confirmations, raw}
startWatch(ref, onUpdate, intervalMs) / stopWatch(ref)
sendPayout({chain, address, amountAtomic}) -> {txids, raw} (optional)
validateAddress(chain, address) -> {valid, reason} (optional)
supportsChain(chain) -> boolean
PaymentProviderRegistry maps chain -> providerId and is operator-configurable. getProvider(chain)
returns the routed provider; if that provider does not support the chain, the registry falls back to
any registered provider that does, and finally to the default provider.
Providers
| Provider | id | Serves | Implementation |
|---|---|---|---|
| BTCPay Greenfield | btcpay |
BTC, LTC by default | src/payments/providers/btcpayProvider.js, a BTCPay Server client |
| xmrcheckout | xmrcheckout |
XMR | Same class against a checkout app that exposes Greenfield-compatible routes |
| wowcheckout | wowcheckout |
WOW | Same class, matched pair to xmrcheckout |
| Native Monero/Wownero | native-monero |
XMR, WOW | src/payments/providers/nativeMoneroProvider.js, wraps walletRPCService |
BTCPay, xmrcheckout, and wowcheckout all speak the same Greenfield contract, so one BTCPayProvider
class with a constructor-configurable id serves all three; they differ only in base URL, store id,
API key, and chain set. Auth on all three is Authorization: token <apiKey>. Invoices are priced in
the chain's own crypto by passing the chain ticker as the Greenfield currency, and the receiving
address is read from the invoice's payment-methods endpoint.
The native provider keys invoices by their Monero/Wownero subaddress and returns the wallet's
addressIndex, which is what lets monitoring be restored after a restart.
Bootstrap and routing
buildProviderRegistry({env, walletService}) in src/payments/providers/index.js is the single
wiring seam:
- The native provider is registered whenever a
walletServiceis supplied, and claims XMR and WOW. - Each Greenfield gateway activates from its own environment trio:
BTCPAY_URL/BTCPAY_STORE_ID/BTCPAY_API_KEY(chain list fromBTCPAY_CHAINS, defaultBTC,LTC),XMRCHECKOUT_URL/_STORE_ID/_API_KEY(XMR), andWOWCHECKOUT_URL/_STORE_ID/_API_KEY(WOW). - A dedicated checkout gateway wins over native for its own chain.
PAYMENT_ROUTINGoverrides every mapping. It accepts JSON ({"BTC":"btcpay","XMR":"native-monero"}) or a compactCHAIN:provider,CHAIN:providerstring; unparseable values are ignored.- The default provider, used for chains with no explicit route, is the native provider when present, otherwise the first registered gateway.
With no gateway environment set, only the native provider is registered and every chain routes to it,
which is the single-chain wallet-RPC flow. GameModeManager builds this registry in its constructor
unless one is injected.
The general environment template is src/.env.example. The gateway variables above are read straight
from the process environment; add them to the deploy host's environment when enabling a gateway.
Invoice lifecycle
Creation. gameModeManager.createPaymentRequest resolves the provider for CRYPTO_TYPE and calls
createInvoice. Any stale pending payments for the user are expired first, so a user holds at most
one active invoice. The row is inserted into payments with provider_id, provider_invoice_id,
address_index, the expected atomic amount, product grants, and the fairness commitment fields.
Invoices expire at the provider-supplied expiresAt, or 30 minutes from creation when the provider
does not supply one.
Monitoring. network/paymentHandlers._monitorAddress records a socket-to-payment mapping, arms a
30 minute expiry timer, and calls provider.startWatch(watchRef, onStatus, 2000). The watch reference
is the subaddress for the native provider and the invoice id for a gateway. onStatus receives a raw
wallet-style status (in_mempool, confirmed, complete, amount, required, confirmations,
receipts): the native provider passes the wallet's own status through untouched, and
BTCPayProvider.getWalletStatus maps a Greenfield invoice into the same shape (Settled and
Complete become confirmed and complete, Processing becomes in-mempool, paid and required amounts
come from the payment-methods response converted through ChainProfile decimals). Both feed the one
handler, _handlePaymentStatus.
Confirmation. A mempool sighting queues a single-game entry or announces a pending purchase; a
confirmed status applies the entitlement. Underpayment emits payment_underpaid and leaves the
invoice open so the balance can be topped up to the same address. Confirmation is idempotent: an
in-memory set is only a fast path, and the database status transition under FOR UPDATE is the source
of truth, so a restart cannot double-apply. When paid fairness binding is required, an entry whose
invoice carries no bound fairness proof is refused at both the queue and confirm steps and reported as
payment_review_required rather than started.
Receipt evidence. Every confirmation must be backed by durable receipts in payment_receipts
(migration 037_payment_receipt_evidence.sql). Native receipts are chain_output rows identified by
txHash:outputId and carrying the invoice's address_index; gateway receipts are provider_invoice
rows identified by the invoice id. A unique index on (provider_id, evidence_id) makes each piece of
evidence consumable exactly once, and a database trigger rejects any transition into confirmed whose
confirmed receipts do not cover expected_amount. This is why one transaction with several outputs,
or one invoice topped up by several transactions, both settle correctly.
Late payments. src/services/latePaymentReconciler.js scans expired invoices, re-reads status
through the owning provider (getWalletStatus for gateways, wallet RPC for native), and records
durable evidence in payment_late_reviews for manual adjudication. A late receipt never grants an
entitlement on its own. See FINANCIAL_RECONCILIATION.md.
Payouts
Payouts run through the native wallet service, not through the routed provider: the checkout gateways
are receive-only. gameModeManager sends winnings via walletService.processPayout for single
payouts and processBatchPayout for batches, both of which use transfer_split and return
tx_hash_list. src/payments/payoutRetryService.js retries failures. NativeMoneroProvider
implements sendPayout so the contract is complete, and it delegates to the same batch call.
Payout address validation prefers walletService.validateAddress, which is network-aware
(mainnet/stagenet/testnet), and falls back to the provider's validateAddress only when the wallet
service exposes none.
Payout behaviour is gated by PAYOUTS_ENABLED as a master switch with subordinate per-mode flags
(DIRECT_PAYOUTS_ENABLED, CREDITS_PAYOUTS_ENABLED, MATCH_PAYOUTS_ENABLED). See
PRODUCTION_DISCLOSURES.md.
Money and chain parameters
All amounts are atomic integers. Arithmetic goes through src/money/atomic.js (toBig, toSafe,
sum, add, mulByDecimal, format), and decimals come from src/chain/chainProfile.js
(decimalsFor, atomicDivisor). No floating-point money anywhere.
| Chain | Decimals | Mean block time | Family | URI scheme |
|---|---|---|---|---|
| WOW | 11 | 300000 ms | monero | wownero |
| XMR | 12 | 120000 ms | monero | monero |
| BTC | 8 | 600000 ms | utxo | bitcoin |
| LTC | 8 | 150000 ms | utxo | litecoin |
| GRIN | 9 | 60000 ms | mimblewimble | grin |
An unknown or unset chain resolves to an XMR-shaped default (12 decimals, 120000 ms, monero family)
so a mis-set CRYPTO_TYPE degrades instead of crashing. meanBlockTimeMsFor is what block-time-aware
difficulty scales against; per-chain dungeon sizing lives in NETWORK_TUNING in
src/game/difficultyConfig.js.
Payment QR codes are generated by src/payments/qrService.js as monero: or wownero: URIs with
tx_amount and a truncated tx_description. It returns null when the optional qrcode package is
absent, and the address and amount are always shown as text as well.
Enabling a gateway
- Mint a store API key in the gateway's own admin interface.
- Set that gateway's
*_URL,*_STORE_ID, and*_API_KEYon the deploy host, using an address the host can actually reach. Verify reachability from the host itself; an overlay-network address may not route from the service account. - Restart the service. Invoices for that chain are created and confirmed through the gateway with no code change.
File map
src/payments/providers/paymentProvider.js contract + registry
src/payments/providers/btcpayProvider.js Greenfield client (btcpay / xmrcheckout / wowcheckout)
src/payments/providers/nativeMoneroProvider.js wraps walletRPCService
src/payments/providers/index.js buildProviderRegistry (env -> registry)
src/payments/walletRPCService.js Monero/Wownero wallet RPC, payouts
src/payments/payoutRetryService.js payout retry worker
src/payments/qrService.js payment-URI QR codes
src/chain/chainProfile.js decimals, block times, families, URI schemes
src/money/atomic.js atomic-integer money arithmetic
src/game/gameModeManager.js invoice creation, confirmation, receipts, payouts
src/network/paymentHandlers.js monitoring and status handling
src/services/latePaymentReconciler.js post-expiry evidence and review records
src/migrations/037_payment_receipt_evidence.sql receipts, late reviews, confirmation-evidence trigger