Wallet Service
Status
Active
Date
2026-04-28
Owners
- Platform Backend
Last Verified Commit
7a579730
Runtime
API + standalone worker
Purpose
wallet_service is the single money writer in servers_v2.
All balance mutations, approved deposits, approved withdrawals, and wallet-owned
finance side effects must terminate here.
Primary Entry Points
/internal/wallet/*
Main route groups:
- wallet commands
- finance and query endpoints
- topology-driven wallet commands under
/internal/wallet/v2/* - wallet topology and policy seed/read endpoints
- Plisio callback handling
Protection model:
- owner-command and query routes under
/internal/wallet/*requireX-Internal-Service-Token gateway,admin_service,rolling_service,promotion_service,game_service, andrecon_serviceare the intended internal callers- compatibility exception:
POST /internal/wallet/plisio/callbackremains public because it is invoked by the external Plisio gateway - compatibility exception:
POST /player/deposit/plisio/callbackis kept as the old public Plisio webhook URL during cutover POST /internal/wallet/plisio/agreestays protected because it is an operator-triggered internal action
Topology Wallet Model
The topology-driven wallet path is the clean Ruby Wallet implementation. It
supports both the split RUBY_SPLIT_V1 topology and the unified
RUBY_UNIFIED_V1 topology. It does not use legacy flat player money columns
as canonical state.
Current internal endpoints:
GET /internal/wallet/topology/activeGET /internal/wallet/topology/{code}PUT /internal/wallet/topology/{code}PUT /internal/wallet/topology/{code}/activatePUT /internal/wallet/policy/{policy_key}PUT /internal/wallet/policy/{policy_key}/activatePOST /internal/wallet/topology/seed-ruby-split-v1POST /internal/wallet/topology/seed-ruby-unified-v1POST /internal/wallet/v2/bets/authorizePOST /internal/wallet/v2/bets/settlePOST /internal/wallet/v2/bets/rollbackPOST /internal/wallet/v2/deposits/approvePOST /internal/wallet/v2/deposits/approve-by-transactionPOST /internal/wallet/v2/withdrawals/createPOST /internal/wallet/v2/withdrawals/refundPOST /internal/wallet/v2/adjustmentsPOST /internal/wallet/v2/promotions/credit-pointsPOST /internal/wallet/v2/coupons/grantPOST /internal/wallet/v2/promotions/reversePOST /internal/wallet/v2/transfersPOST /internal/wallet/v2/points/transferGET /internal/wallet/v2/players/{player_id}/snapshot
Topology and policy drafts must use a new document version when editing a
published row. A draft save is not allowed to overwrite an ACTIVE or
SUPERSEDED topology/policy version; published rows only change through the
explicit activation flow.
Topology switches must activate the topology and matching policy in the same
wallet_service transaction. Use PUT /internal/wallet/topology/{code}/activate
with document, policy_key, and policy_document; the service validates the
policy against the target topology, supersedes the previous active topology and
policy, upserts bucket types, and activates the new pair atomically. Direct
PUT /internal/wallet/policy/{policy_key}/activate is only valid for replacing
the policy on the currently active topology.
Legacy flat bet writers under /internal/wallet/bets/* are kept only as
compatibility route names and fail closed. New betting integrations must use
the /internal/wallet/v2/bets/* topology endpoints.
Player-facing rebate transfer should continue to proxy through
/internal/wallet/transfer-rebate; that compatibility route supplies the
legacy default target and records rebate_transfer daily stats before calling
the topology points-transfer command.
The topology-wallet bet lifecycle writes:
wallet.wallet_bet_authorizationwallet.wallet_ledger— append-only at the DB layer post-0036; UPDATE/DELETE on a finalised row + TRUNCATE of the table raise loud plpgsql exceptions viawallet.wallet_ledger_append_only(). The legacy compatibility ledgerplayer_balance_loggot the same trigger pair in 0036.wallet.wallet_bucketwallet.wallet_coupon_grantwallet.wallet_idempotency
Other wallet-owned aggregates (read by the same service, written via
the same wallet command paths or worker loops): wallet_account,
wallet_bucket_type, wallet_transfer, wallet outbox table,
wallet_inbox, wallet_dead_letter. All of the above carry brand_id
per ADR-009.
Settlement and rollback must use the authorization's stored policy snapshot and
funding breakdown. They must not recompute sources from the current policy.
Topology settle and rollback also publish the same wallet outbox events as the
legacy path so rolling_service and projections continue receiving bet
progress and rollback signals.
If a winning bet was funded by both withdrawable and non-withdrawable sources,
settlement credits winnings proportionally: the withdrawable-funded share goes
back to the shared withdrawable bucket, and the remaining share follows the
provider group's configured normal-wallet payout destination. Coupon-funded
rolling attribution is published with the explicit COUPON target so coupon
rollings cannot advance unrelated normal or bonus rollings.
Deposit approval, withdrawal create/refund, admin adjustment, promotion,
coupon, points transfer, normal-wallet transfer, Plisio approval, recon
auto-approval, and snapshot commands are available on the topology-wallet
route family. The old /internal/wallet/transfer-withdrawal/{type} entrypoint
is kept only as a player-facing compatibility alias and now performs an
allowed full normal-wallet transfer: sport moves casino normal to sports
normal, while live&slots / live_slots moves sports normal to casino normal.
Normal-wallet transfers inherit unfinished source-bucket rolling
transactionally by transfer ratio; they do not fail open or silently ignore the
inherit_rolling policy.
Compatibility note:
- legacy internal routes such as
/internal/wallet/withdraw,/internal/wallet/withdraw/*/decline,/internal/wallet/adjust, and/internal/wallet/rolling/apply-*now delegate to topology bucket commands - rolling records now carry optional topology/policy metadata so sports, live, and slots progress can be attributed by provider type and source bucket without re-reading current policy
- legacy
/internal/wallet/deposit,/internal/wallet/deposit/agree, and/internal/wallet/deposit/recyclenow create, approve, and recycle deposit orders against topology buckets - manual
deposit/agreebonus overrides are persisted to the deposit order before topology approval, so the credited bucket amount and daily bonus stats match the operator-approved values - cash deposit auto-approval now debits the topology withdrawable bucket and credits the configured target bucket instead of mutating legacy flat cash columns
Dependencies
- PostgreSQL
- Redis
- Plisio secret for fail-closed public callback verification
Background Work
wallet_worker publishes wallet outbox rows to Redis Streams and runs
the hourly ledger-vs-balance reconciliation.
Current worker responsibilities:
- ensure wallet stream exists
- publish unpublished outbox rows
- hourly ledger-vs-balance reconciliation (read-only drift detector
covering buckets + coupon grants;
app/tasks/ledger_reconciliation.py) — supervised here, not in the API process, so multi-replica API deploys do not double-run/alert - expose heartbeat-backed worker health
Owned Data
- wallet idempotency state
- wallet outbox rows
- wallet inbox rows
- wallet dead-letter rows
- wallet topology and policy documents
- wallet bucket type definitions
- wallet account and bucket balances
- wallet transfer records
- wallet coupon grants
- wallet bet authorizations
- wallet ledger rows
- approved deposit and withdrawal write semantics
- balance logs and wallet-owned side effects
Events
Emits (currently in wallet_outbox → wallet:events stream):
BET_SETTLED_CONFIRMEDBET_ROLLED_BACK_CONFIRMEDROLLING_COMPLETED_CONFIRMEDDEPOSIT_APPROVEDWITHDRAW_APPROVED
Reserved (event-type constants declared in
servers_v2/shared/contracts/src/rgb_contracts/events/wallet.py but no
producer wired — consumers MUST NOT rely on these arriving):
BET_AUTHORIZEDPROMOTION_CREDITEDPROMOTION_REVERSEDCOUPON_EXPIRED
Authoritative split is in
docs/architecture/event-catalog.md.
The pathname:// prefix is required because Docusaurus loads
docs/services/ and docs/architecture/ as separate
content-docs plugin instances (see
docs/docusaurus/docusaurus.config.ts) and a plain ../architecture/...
markdown link cannot resolve across plugin boundaries —
onBrokenMarkdownLinks: 'throw' would fail the docs build.
Adding a reserved event to the live stream requires a single PR that
(a) wires the producer (write_outbox_row in the same transaction
as the business write) and (b) flips its row from Reserved to Emits
in both files.
Consumes:
- none as a required upstream stream consumer
Health
- API
/healthchecks DB and Redis wallet_workerhealth is heartbeat-backed and tied to outbox publication supervision
Key Env Vars
DATABASE_URLREDIS_URLDB_SEARCH_PATHENABLE_OUTBOX_POLLERMULTI_BRAND_ENFORCEMENT— required; one ofoff/observe/enforce. Production target isenforcepost-Phase-16. Drivesmulti_brand_enforcement_mode{service="wallet_service"}gauge.WALLET_BRAND_SIGNATURE_REQUIRE—onis the production/default posture;offis only a temporary local or rollback override. Whenon, unsigned brand-scoped writes are rejected with403 brand_signature_missingin enforce mode. See "Brand-signature enforcement flip" indocs/runbooks/multi-brand/multi-brand-isolation-rollout.md.BRAND_SIGNING_KEY— required in production whenMULTI_BRAND_ENFORCEMENT=enforceANDWALLET_BRAND_SIGNATURE_REQUIRE=on. HMAC-SHA256 secret shared with the 6 wallet write callers (gateway,admin_service,game_service,promotion_service,recon_service,rolling_service). Boot guardassert_brand_signing_key_configuredrefuses to start in the bad combination; runtime fail-closed branch +wallet_brand_signature_misconfigured_total{mode="enforce"}is the in-flight defence.BRAND_SIGNING_KEY_PREV— Optional. When provided, wallet_service accepts X-Brand-Signature signed by EITHER the currentBRAND_SIGNING_KEYorBRAND_SIGNING_KEY_PREV. Use this for zero-downtime key rotation: set both, wait one rotation window (signed requests in flight must complete), drop the PREV. Seemulti-brand-isolation-rollout.md:435-447for the full procedure. Leaving PREV empty (default) means no rotation overlap window — direct flip will 403 in-flight signed requests.PER_CALLER_TOKEN_REQUIRED—onis the Phase 16 target; when set, the legacy single-shared-token fallback hard-rejects (403 +internal_caller_token_legacy_rejected_total) instead of accepting (T4-D-I2).INTERNAL_SERVICE_TOKEN_GATEWAY— required in production; per-caller token consumed whengatewaycalls into wallet.INTERNAL_SERVICE_TOKEN_AGENT— required in production; per-caller token foragent_service.INTERNAL_SERVICE_TOKEN_ADMIN— required in production; per-caller token foradmin_service.INTERNAL_SERVICE_TOKEN_PROMOTION— required in production; per-caller token forpromotion_service.INTERNAL_SERVICE_TOKEN_ROLLING— required in production; per-caller token forrolling_service.INTERNAL_SERVICE_TOKEN_GAME— required in production; per-caller token forgame_service.INTERNAL_SERVICE_TOKEN_RECON— required in production; per-caller token forrecon_service.PLISIO_SECRET— required in production; boot guardassert_plisio_secret_configuredrefuses to start the service when missing inproduction/staging/prodenvs; the Plisio webhook HMAC-SHA1 verification keys off this value.INTERNAL_SERVICE_TOKEN— legacy single-shared-token; accepted only whilePER_CALLER_TOKEN_REQUIREDis unset/off. Phase 16 release gate requires this env var to be absent on every wallet pod.
Multi-Brand Constraints
Per ADR-009:
- every wallet-owned aggregate carries
brand_id:wallet_account,wallet_bucket,wallet_bucket_type,wallet_coupon_grant,wallet_bet_authorization,wallet_ledger,wallet_transfer,wallet_idempotency, wallet outbox,wallet_inbox,wallet_dead_letter wallet_topologyandwallet_policydocuments are brand-scoped; uniqueness becomes(brand_id, code, version)and(brand_id, topology_code, version, policy_key)respectively; the partial unique active indexes (uq_wallet_topology_single_active,uq_wallet_policy_active_key) are rebuilt to includebrand_idso each brand has its own active row simultaneouslywallet_bucket_typeuniqueness becomes(brand_id, topology_code, topology_version, code)so two brands may share the sametopology_code(e.g. both inheritRUBY_SPLIT_V1) without bucket-type collisions- topology activation safety checks (per
ADR-005) are scoped to the activating brand; cross-brand state never blocks activation wallet_idempotencyuniqueness becomes(brand_id, idempotency_key);wallet_bet_authorizationprovider-bet uniqueness becomes(brand_id, provider_type, provider_id, bet_id)wallet_servicechecks every command's target row brand against the request brand and behaves perMULTI_BRAND_ENFORCEMENT(the same flag asgateway):observelogs + counts viawallet_cross_brand_rejected_total{command,mode}and proceeds using the request brand as the authoritative scope;enforcehard-rejects. Money mutations never use a brand inferred from the target row alone- internal wallet routes require
X-Brand-Id; the Plisio public callback resolves brand from the deposit transaction record (which carriesbrand_idfrom creation time) - ADR-005's "single money writer" rule is unchanged; brand isolation does not loosen it
- internal-service authentication uses per-caller-service tokens (
INTERNAL_SERVICE_TOKEN_GATEWAY,INTERNAL_SERVICE_TOKEN_AGENT, etc.); a compromise of one caller's token cannot impersonate another caller - brand-scoped wallet write commands carry
X-Brand-Signature: HMAC_SHA256(BRAND_SIGNING_KEY, caller_service|brand_id|request_id|timestamp); missing or invalid signature is rejected inenforcemode (logged + counted inobserveviawallet_brand_signature_failed_total) - wallet outbox events include
brand_idin payload so downstream rolling, promotion, and game services can scope their reactions
Brand-related error envelopes
Two error codes carry the brand-isolation rejection responses (raised
from app/api/routes/wallet.py and app/api/routes/bucket_wallet.py):
WALLET_BRAND_MISMATCH— the wallet command's target row brand does not match the request brand (resolved fromX-Brand-Idplus the per-route brand-context resolver). Returned from bothbucket_walletand legacywalletwrite paths inenforcemode; inobservemode the same condition is logged and counted (wallet_cross_brand_rejected_total{command,mode}) instead of rejected. Operator action: confirm the caller is targeting the correct brand; checkX-Brand-Idpropagation.WALLET_BRAND_SIGNATURE_REJECTED— the inbound brand-scoped command is missingX-Brand-Signatureor the signature does not verify againstBRAND_SIGNING_KEY. Returned inenforcemode; inobservemode the same condition is logged and counted viawallet_brand_signature_failed_total. Operator action: confirmBRAND_SIGNING_KEYis provisioned (boot guardassert_brand_signing_key_configuredrefuses to start if not), and that the calling service is signing with the same key.
Both envelopes carry the standard {error_code, error_message, request_id} shape.
Tests
cd servers_v2/wallet_service && uv run pytest- key suites:
tests/test_outbox_poller.pytests/test_events_health.pytests/test_worker_health.pytests/test_internal_auth.pytests/test_wallet_topology_policy.pytests/test_wallet_topology_store.pytests/test_wallet_topology_routes.pytests/test_wallet_bucket_commands.py