A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
8.7 KiB
Financial event export
Migrations 041_financial_event_outbox.sql and 042_immutable_financial_event_snapshots.sql create
financial_event_outbox, a durable outbox for confirmed customer payments and final refund states.
Database triggers append an immutable source snapshot inside the payment or refund transaction, so a
slow or unreachable accounting receiver can never hold a money transaction open.
src/services/financialEventExporter.js performs delivery asynchronously and never reconstructs an
event from later-mutated payment rows.
Event sources
| Event kind | Trigger source | Fires when |
|---|---|---|
payment.confirmed |
payments |
status becomes confirmed and received_amount > 0 |
payment.refund |
payment_refunds |
status reaches recorded (bookkeeping reversal) or completed (wallet refund) |
Refunds in requested, processing, or needs_review are never accounting events. Confirmed
payments with received_amount = 0 lack economic evidence and are never turned into revenue.
Uniqueness on (event_kind, source_type, source_id) and on event_id means one source produces at
most one event; the foreign key to payments is ON DELETE RESTRICT.
Source snapshot
payload_snapshot uses schema financial-event-source/v1. A CHECK constraint restricts it to
exactly this key set: schema, event_id, event_kind, source_type, source_id,
aggregate_id, occurred_at, atomic_amount, payment_type, product_id, provider_id,
receipt_count, refund_status. The constraint also pins event_id, event_kind, source_type,
source_id, and aggregate_id to the matching columns.
The snapshot carries no player ID, wallet address, invoice destination, transaction hash, provider invoice ID, session token, IP address, email address, or authentication material.
Delivery document
On the first delivery attempt the worker builds delivery_payload from the snapshot plus the
deployment's chain profile (src/chain/chainProfile.js) and writes it once. Schema is
financial-event/v1:
id: the immutable event ID, also used as the idempotency key.occurred_at: ISO-8601 timestamp derived from the snapshot.producer/product:wowngeon.activity:customer-payment,customer-refund(completed wallet refund), orcustomer-payment-reversal(recorded bookkeeping reversal).account_refsand each leg'saccount_ref:FINANCIAL_EVENT_ACCOUNT_REF.provenance: one entry of kindwowngeon-paymentorwowngeon-refundreferencingpayment-<id>orpayment-refund-<id>.legs: one leg with the chain profile'sassetsymbol, decimalquantity,directioninfor payments andoutfor refunds, and anetworkidentifier.attributes:valuation_required,atomic_amount,atomic_decimals,source_type,source_id,aggregate_id, plusrefund_statusfor refunds orpayment_type/product_id/provider_id/receipt_countfor payments.
The leg's usd_value is "0" with empty price_source and price_confidence, and memo plus
attributes.valuation_required state that the receiving system must apply USD valuation before
confirming the entry. The exporter deliberately performs no pricing.
The network identifier is wownero:mainnet when CRYPTO_TYPE resolves to the WOW profile, and
monero:<MONERO_NETWORK> otherwise. An unrecognised CRYPTO_TYPE resolves to the XMR-shaped
default profile (12 decimals, 120000 ms mean block time).
Once written, the delivery document is immutable and every retry reuses it byte for byte.
Delivery contract
Delivery is at least once. Each attempt is a POST with a bearer authorization header,
content-type: application/json, idempotency-key set to the immutable event ID, manual redirect
handling, and a 10-second timeout. The receiving system must durably deduplicate that key and
durably record the event before answering 2xx. A timeout or lost acknowledgement otherwise produces
a repeat request. The application marks a row delivered only after a 2xx response.
Row lifecycle
pending → in_flight → delivered, with dead_letter and ignored as the other terminal
states.
- A run first moves rows whose
attemptsalready reachedFINANCIAL_EVENT_MAX_ATTEMPTStodead_letter. - Claiming selects the oldest due row with
FOR UPDATE SKIP LOCKED, incrementsattempts, and takes a five-minute lease. An expired lease makes the row claimable again, so a crashed worker never strands an event. - A failed attempt returns the row to
pendingwith backoff of 30 seconds doubled per attempt and capped at one hour, recording the sanitised error inlast_error. - An attempt that exhausts
FINANCIAL_EVENT_MAX_ATTEMPTSmoves the row todead_letterfor operator review. - Each run processes at most
FINANCIAL_EVENT_BATCH_SIZErows.
Immutability
A BEFORE UPDATE trigger rejects any change to event_kind, aggregate_id, source_type,
source_id, event_id, or payload_snapshot. delivery_payload has exactly one legal transition,
NULL to its final value, and only while the row is in_flight. A row cannot reach delivered
without a delivery document.
Configuration
| Variable | Default | Notes |
|---|---|---|
FINANCIAL_EVENT_SINK_URL |
unset | Enables export together with the token |
FINANCIAL_EVENT_SINK_TOKEN |
unset | Bearer secret, at least 32 characters |
FINANCIAL_EVENT_ACCOUNT_REF |
wowngeon:receipts |
Non-PII account identifier |
FINANCIAL_EVENT_POLL_MS |
60000 |
Clamped to 5000-3600000 |
FINANCIAL_EVENT_BATCH_SIZE |
20 |
Clamped to 1-100 |
FINANCIAL_EVENT_MAX_ATTEMPTS |
8 |
Clamped to 1-100 |
FINANCIAL_EVENT_SINK_URL=https://ledger.operator.example/v1/events
FINANCIAL_EVENT_SINK_TOKEN=<strong protected bearer token>
FINANCIAL_EVENT_ACCOUNT_REF=wowngeon:wow-receipts
FINANCIAL_EVENT_POLL_MS=60000
FINANCIAL_EVENT_BATCH_SIZE=20
FINANCIAL_EVENT_MAX_ATTEMPTS=8
The full environment template is src/.env.example; the mainnet and stagenet profiles are
src/.env.mainnet.example and src/.env.stagenet.example.
Startup validation (src/config/environmentValidator.js) and the exporter constructor both enforce:
- URL and token are set together or not at all.
- The URL parses, uses
http:orhttps:, and embeds no credentials.https:is required whenNODE_ENV=production. - The token is at least 32 characters, contains at least 8 distinct characters, and matches no
placeholder pattern such as
change_me,example,password, orsecret. - The account reference matches
^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$. - Sink configuration is rejected on any non-mainnet
MONERO_NETWORK.
Set the URL and token only in the protected mainnet runtime environment. Never place the token in Git, Ansible inventory, an invocation, a receipt, a log, or a public health check.
Non-mainnet networks
When MONERO_NETWORK is not mainnet, export is disallowed and each run moves every pending,
in_flight, or dead_letter row to ignored with reason non_mainnet_network. This keeps
no-value test activity out of real books. Migration 042 separately marks legacy rows whose
historical amount is unusable as ignored with reason malformed_legacy_snapshot, so they
terminate instead of retrying forever.
Health reporting
The public /health response includes a financialEvents object: the delivery contract string,
enabled, exportAllowed, running, the backlog, deadLetters, and ignoredRows gauges, and
the cumulative delivered, deferred, deadLettered, and ignored counters. It exposes no sink
URL, token, account reference, event ID, error text, payload, source identity, or timestamp.
Accounting export is informational and does not make gameplay readiness fail, so production monitoring must alert on backlog age, dead letters, and the exporter's process logs and metrics in order to detect stale delivery.
The poll loop runs even with no sink configured, so the backlog gauge stays truthful and non-mainnet deployments keep suppressing newly appended rows.
Operations
Before enabling mainnet export, prove in a disposable environment that the sink:
- authenticates the protected bearer token over TLS;
- deduplicates repeated requests by
Idempotency-Key; - durably records the event before returning 2xx;
- rejects malformed events without echoing secrets; and
- supports alerting and reconciliation for a deliberately generated dead letter.
Keep the outbox in database backups. Do not delete or rewrite a dead-letter row to make monitoring green: reconcile it against the receiving ledger, preserve the evidence, and use a separately reviewed repair procedure. Running without a sink is permitted, but confirmed mainnet events then accumulate as pending rows and must be reconciled by another route.