← Blog
Architecture · Stellar + Soroban

How we built non-custodial card issuance on Soroban.

·10 min read·by Cards402 engineering

The first version of Cards402 was custodial. An agent would send USDC to a Cards402-controlled Stellar wallet, the backend would watch the ledger for deposits, match them to an open order, and fulfil the card. Simple, well-understood, and wrong for the use case. This post is the walk-through of how and why we moved to a Soroban receiver contract the agents pay directly — with no intermediate custody at any point in the flow.

The problem with custodial card issuance

A custodial pipeline has two specific failure modes that don't exist in the non-custodial version, and both are existential for a payment platform aimed at autonomous agents.

First, the custody window. From the moment an agent sends funds to our wallet until the moment those funds are consumed to issue a card, we are a bank. A small bank with one customer, but a bank. That has every regulatory consequence you expect: money transmitter licensing, fiduciary responsibilities, rebuilt from scratch every jurisdiction. We wanted to build infrastructure, not a bank.

Second, the trust model.An agent that signs a payment to a custodial wallet is trusting the wallet operator to honour a commitment (“if I receive X, I will trigger Y”). That commitment is off-chain. The agent has no cryptographic guarantee that Y will happen — only a promise from the operator and some amount of reputation at stake. For an agent that's going to place thousands of these orders autonomously, a reputation check that relies on “surely they wouldn't do that” is not a security property.

What Soroban changes

Soroban is Stellar's smart-contract layer. The primitives it gives us that standard Stellar payments don't:

  • A contract can accept native XLM or any Stellar Asset Contract (SAC) token as part of its invocation parameters.
  • Contract events are emitted on every invocation and are replayable from ledger state. A watcher that listens for events can reconstruct the full history deterministically, without trusting the contract operator to tell it what happened.
  • Events carry structured topics. We can tag every deposit with the order_id so the backend knows exactly which Cards402 order the deposit belongs to — no heuristics, no memo field parsing, no timing windows to reconcile.

The receiver contract

The Cards402 receiver contract has two entry points:

  • pay_usdc(order_id, amount) — accepts USDC via a SAC transfer and emits a pay_usdc event tagged with the order id.
  • pay_xlm(order_id, amount) — same shape, accepts native XLM.

There's no withdraw. There's no owner. There's no upgrade path. The contract holds funds only for as long as it takes the backend to pull them out into the treasury wallet — and the backend has no special authority over the contract beyond what any Stellar address has. If the backend disappeared tomorrow, the deposits sitting in the contract would remain exactly where they are, attributable to their order ids, recoverable via any ledger-reading tool.

When an agent calls pay_usdc(), three things happen atomically in a single Stellar transaction:

  1. USDC is transferred from the agent's account to the contract.
  2. An event is emitted with topic[0] = “pay_usdc”, topic[1] = order_id, and value = amount (micro-USDC i128).
  3. The transaction's txHash is returned to the agent and stored in its local order state.

Everything after that point is the watcher's job.

The watcher

The watcher is a small Node.js process that streams events from the receiver contract via the Soroban RPC. We'd looked at bridging through Horizon first — classic Stellar payment events — but Soroban contract events are a richer primitive: they carry the structured topics we use for order routing, and they're replayable from any ledger height.

For every pay_usdc or pay_xlm event, the watcher:

  1. Parses the order id out of topic[1].
  2. Looks up the matching Cards402 order row. If the order doesn't exist, we push the event to an unmatched_payments queue for manual review — that queue has been used exactly once in production, and it was a test payment.
  3. Compares the deposited amount to the quoted amount. USDC is an exact match; XLM is checked against the quote captured at order creation. Amount mismatches also go to the unmatched queue — never auto-credited and never auto-refunded to a wrong address.
  4. On a clean match, atomically transitions the order frompending_payment to ordering and kicks off Stage 1 fulfilment.

The critical property is that the watcher is a decoration, not a trust anchor.If the watcher crashes for an hour and misses a bunch of events, the Soroban RPC still has them — the watcher catches up on restart by replaying from the last-processed ledger height. If the watcher is replaced tomorrow with a different implementation (say, a Rust rewrite), it doesn't matter: the ledger is the source of truth for every deposit.

How the refund story works

The natural question about a non-custodial system is “what happens when fulfilment fails?” In a custodial system, you just reverse the transfer on your books. Here the money has already left the agent wallet — it sits in the receiver contract or has been swept into the treasury.

The answer is that refunds are separate outbound Stellar payments, not reversed deposits. When an order fails, the backend moves it to refund_pending, looks up the agent's sender address from the original event, and submits a new Stellar payment from the Cards402 treasury wallet back to the agent. The refund transaction hash lands on the order row as refund.stellar_txid, which any integrator can verify on-chain.

This means the refund path depends on the treasury being solvent. If our treasury runs dry, refunds queue and the owner gets a loud alert. It's not the same strong guarantee as custody — but the blast radius is bounded by how much the treasury can hold, and customers can verify live that we aren't over-committed. The balance is on-chain and public. We're considering a proof-of-reserves dashboard but haven't shipped it yet; for now, check status.cards402.com and the security page for the treasury public key.

What this buys us

Four concrete benefits:

  1. No money transmitter exposurefor the inbound side. We don't take agent deposits onto our books at any point.
  2. Ledger-verifiable history. Every agent interaction is a Stellar transaction. Any integrator can reconcile their own order history against the public ledger without trusting Cards402 data at all.
  3. Graceful degradation.If the backend goes down mid-flight, the deposit sits in the receiver contract, attributable to its order id, waiting. There's no state we could lose that would leave funds stranded — only orders that would be slow.
  4. Blast radius containment.A compromise of the Cards402 backend can't drain agent wallets. The worst case is the treasury balance being spent against the wrong refund recipients — which is loud, on-chain, and bounded.

What we gave up

Three things the custodial version did better:

  • Latency on the first ledger.A Stellar ledger closes every ~5 seconds. The custodial model had us batching deposits in seconds anyway, but the receiver-contract path gives us a hard 5s floor we can't reduce.
  • Gas overhead. Soroban contract invocations are pricier than native payments. The marginal cost to an agent is a fraction of a cent, but multiplied by a busy agent it adds up.
  • Upgrade flexibility.A custodial address is just a key — you can rotate it. A contract is code, and we can't upgrade the receiver contract without redeploying and migrating every open order. We traded flexibility for the guarantee that a compromised key can't rewrite the flow.

The trade was worth it for us — non-custodial was the premise of the product, not a feature — but the trade is real.

Further reading

If you want the actual on-chain details, the Soroban docs at developers.stellar.org cover the event model we rely on. The Cards402-side details are in the API reference(specifically the “Create order” and “Stream order” sections), and the companion blog post Anatomy of a Cards402 orderwalks through the full 33-second timeline in the other direction — from the agent's first API call through to the card landing.

Questions on this? Email api@cards402.com. We read every one.

Subscribe

New posts cross-post to the changelog. RSS feed →

All posts →