The shape of the system
Three processes, one Postgres, one contract per seller, and a clear rule about who holds which key. This chapter is the map: what the pieces are, what talks to what, and why the boundaries fall where they do.
Three processes, three levels of trust
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ GATEWAY │ │ WORKER │ │ OBSERVER │
│ gRPC · x402 │ │ signs, sends │ │ reads events │
│ no keys │ │ holds keys │ │ no keys │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ admits │ settles │ appends findings
└────────────┬───────┴────────────────────┘
┌─────────┴──────────────────────┐
│ POSTGRES │
│ one login role per process, │
│ the narrowest grants each │
└─────────┬──────────────────────┘
│
┌─────────┴──────────────────────┐
│ STELLAR (the chain) │
│ ledger contract · USDC (SAC) · │
│ treasury · cold reserve │
└────────────────────────────────┘The split follows the keys. The gateway faces the internet, so it holds no Stellar signing key at all: it prepares what buyers sign, verifies it, and admits charges. The worker is the only process that signs and sends transactions, and it faces nothing but Postgres and the RPC node. The observer holds no key either, and its database role can append observations and findings but cannot write a balance, a charge or a deposit. A compromise of the internet-facing process reaches no key, and a compromise of the process holding keys cannot silence the one that checks it.
Each process connects with its own Postgres login role, granted only what its job needs. Recorded submissions cannot be altered by the role that wrote them, final states cannot be left, and findings cannot be updated or deleted by any role. So a bug or an injection in any one process is bounded by what its role is allowed to do, not by what its code intends.
There is no message broker. Every queue is a Postgres table, and the work a process claims and the state change that work produces commit together. Adding a broker would split that commit across two systems.
What the worker does
The worker runs one loop, and each round does the same things in order:
resolve every envelope in flight ── by its hash, against the ledger
│
settle what is final ───────────── credit deposits, settle charges,
│ return refused amounts, once
│
send new work from each free source account
│ ├── signed deposits, withdrawals, mandates, revocations
│ ├── admitted charges → charge_batch (up to 98)
│ └── due recurring charges → charge_recurring_batch (up to 35)
│
upkeep
├── extend the contract's instance and code before they could expire
└── sweep the treasury's surplus to the cold reserveSeveral workers can run. A lease in Postgres picks the one that acts, and the others wait on standby, ready to take over within seconds if it stops renewing. The lease is load control, not the safety mechanism: what makes a crashed or duplicated worker harmless is that every envelope is recorded before it is sent and every outcome is decided from the ledger.
Deposits: getting USDC in
gateway: PrepareDeposit ─── builds the entry for the buyer's wallet,
│ the seller's contract and the amount
buyer wallet signs it ───── free, off-chain, no XLM
│
gateway: SubmitDeposit ──── same account, byte-identical call, live and
│ bounded expiration, valid signature;
│ a contract account is also simulated
worker:
├── simulate with the signed entry (enforcing), declare resources
├── record the full fee-bump envelope ─────────── THE CRASH WINDOW CLOSES HERE
├── broadcast
└── decide from the hash; if unknown, wait for the buyer's entry to
lapse and read the contract's marker for that deposit_idOne operation lands on-chain per deposit. deposit moves the buyer’s USDC to the treasury and credits the buyer’s account in the same invocation, under the buyer’s own signature, so there is no second step to forget or to fake.
The crash window is the interesting part. Killed before the envelope is recorded, nothing was sent, and the worker builds it again. Killed after, the worker finds the exact bytes and sends them again, which cannot land twice: the same bytes are the same transaction. And a transaction whose fate stays unknown is decided by the contract itself, once the buyer’s authorisation can no longer be used by anyone: the contract wrote a marker for that deposit_id, or it never processed it.
Settlement: charges to the chain, in batches
gateway: CreateCharge ──── one transaction: lock the buyer's row,
│ check available, debit, record ADMITTED
worker:
├── take the oldest admitted charges that can still land before
│ their last ledger, from any buyers
├── record the envelope with each charge's place in the batch
├── broadcast charge_batch(charges) one tx, up to 98
└── apply each entry's outcome from the contract:
Charged → CHARGED
refused → REFUSED, amount returned
Duplicate → the outcome its record holds
contradicts records → QUARANTINED, for an operatorA charge carries no user signature, so sending a batch again is not a money hazard the way sending a signed authorisation twice would be. What makes it safe is on-chain: the contract records every charge identifier it settles, so a batch that lands twice settles each charge once.
Note what the worker does not do: it never marks a charge settled because it sent it. It reads the outcome the contract recorded. A worker that marked its own submissions settled would be reporting its intent as fact.
Withdrawals and recurring charges
A withdrawal is signed twice, by the buyer for the amount and destination, and by the treasury for the transfer out of it. The worker adds the treasury’s signature to every withdrawal a buyer signed through the API, with no person in the loop, and by default only to the buyer’s own wallet.
Recurring charges follow the same pattern as prepaid charges. The seller asks for a period’s charge, the gateway checks it against the mandate it recorded, and the worker settles it in a batch, deciding each outcome from the contract’s record of that attempt. The USDC moves straight from the buyer’s wallet to the treasury, and the buyer’s prepaid balance is untouched.
Archived state, restored rather than lost
Soroban charges rent for storage, and an entry nobody extends is eventually archived. Every write the contract makes extends what it touches to about 30 days once fewer than about 7 remain, so an active buyer’s account never lapses. A buyer idle long enough to be archived is restored by the network inside the next call that touches it, and the worker sends that call unchanged, so the restoration is paid within the same transaction. And because a contract nobody calls does not extend itself, the worker reads the instance’s and the code’s remaining life and extends both before they could expire.
Where the queues meet the safety nets
deposits ───────► worker ───┐
charges ────────► worker ───┤
withdrawals ────► worker ───┼──► chain ──► observer ──► findings ──► alerts
mandates ───────► worker ───┤ │
recurring ──────► worker ───┘ └──► reconciliation:
treasury vs contract vs DB
quotas, minimums, resource cap ◄── bound what any request can cost
fee-account floor ◄── nothing new is sent below it
quarantine ◄── what cannot be decided waits for an operatorThe quotas and the fee-account floor exist because every deposit, withdrawal and mandate costs the operator a fee whatever its amount. A flood of tiny requests from strangers is bounded per buyer and per deployment, counted in the same transaction as the insert, and below the floor the worker sends nothing new while the work already in flight still resolves.
Quarantine exists because the honest answer to “we cannot tell” is to stop and ask. A charge whose outcome the contract’s records and events cannot establish is held, with everything known about it, for an operator to resolve with the transaction in hand.