Verifiable receipts and policy-bounded refunds for x402 payments on Stellar
Part of the Accensa merchant back-office for x402 sellers on Stellar. This repo holds the on-chain half; the indexer, dashboard, and SDK live in
accensa-app.
x402 turns any HTTP endpoint into a paid resource: an AI agent hits your API, gets a
402 Payment Required, pays, and retries. That works — but it leaves both sides
without recourse.
The agent cannot prove it was charged correctly. Its receipt comes from the seller's own API, attesting to the seller's own behaviour. When an autonomous agent makes thousands of sub-cent calls a day across dozens of vendors, "trust the seller's dashboard" is not an auditing story. Any disagreement is unresolvable, because the only record is held by the party with an interest in it.
The merchant cannot offer refunds without becoming a custodian. Manual refunds don't scale to per-request payments, and an unbounded refund key over merchant float is exactly the thing a seller does not want sitting in a web backend.
accensa-contracts fixes both on-chain. Receipts are anchored in Merkle batches that
anyone can verify without asking the merchant. Refunds run through a vault with an
enforced time window and double-refund protection, so the policy lives in the contract
rather than in a support inbox.
Both contracts are immutable: they ship with no upgrade entry point and no
update_current_contract_wasm, so once deployed, nobody — not even the merchant —
can change the refund policy or how receipts verify. This is a deliberate security
property (see ADR 003); a logic change means a
new contract ID and the migration procedure documented there.
This design is only economical on Stellar:
- Sub-cent fees make per-request payments viable at all. x402 is about micropayments; on most chains the settlement fee exceeds the payment itself.
- Batched anchoring amortises to near zero. One
anchor_batchcall covers an entire billing period, so verifiability costs a fraction of a cent per receipt. - USDC is native. Merchant float and refunds settle in the asset merchants actually price in, through the Stellar Asset Contract, with no bridge.
- Soroban's fee model is predictable, so a merchant can bound the cost of their refund policy in advance rather than guessing at gas.
Stores Merkle roots of batched payment receipts so agents can independently verify they were charged correctly, with no trusted API in the path.
| Function | Purpose |
|---|---|
initialize(merchant) |
Binds the contract to a merchant admin address. |
anchor_batch(root, count, period_start, period_end) -> u64 |
Anchors a batch root, returns its batch_id. Merchant auth required. count must be MAX_BATCH_SIZE). Rate-limited if min_anchor_interval > 0. |
get_batch(batch_id) -> BatchRecord |
Reads an anchored batch. |
get_batch_count() -> u64 |
Returns the total number of anchored batches. Read-only. |
get_admin() -> Address |
Returns the configured merchant admin address. Read-only; fails with NotInitialized before initialize. |
get_pruned_up_to() -> u64 |
Returns the internal PrunedUpTo cursor: the lower bound of the pruned prefix. Read-only; fails with NotInitialized before initialize. |
get_max_batch_size() -> u32 |
Returns MAX_BATCH_SIZE (currently 1000). Read-only; clients should discover the limit via this getter rather than hard-coding it. |
set_min_anchor_interval(interval) |
Sets the minimum seconds between anchors (0 = disabled, max 86,400). Merchant auth required. |
get_min_anchor_interval() -> u32 |
Returns the current minimum anchor interval in seconds. Read-only. |
verify_receipt(batch_id, leaf, proof) -> bool |
Verifies a receipt against the anchored root. Read-only, free to call. Returns ProofTooLong if the proof exceeds MAX_PROOF_LEN (10). |
verify_receipt_by_root(root, leaf, proof) -> bool |
Verifies a receipt against any root in the historical ring buffer. Returns ProofTooLong if the proof exceeds MAX_PROOF_LEN. |
get_root_buffer() -> Vec<BytesN<32>> |
Returns the current ring buffer of historical roots. Read-only. |
get_root_buffer_size() -> u32 |
Returns ROOT_BUFFER_SIZE (currently 100). Read-only. |
get_max_proof_len() -> u32 |
Returns MAX_PROOF_LEN (currently 10). Read-only; clients should discover the limit via this getter. |
extend_batch_ttl(batch_id) |
Extends the TTL of a batch to prevent archival. Publicly callable. |
prune_batches(before_ledger) |
Deletes anchored batches older than before_ledger to reclaim rent. Merchant auth required. |
Pruning walks forward from an internal PrunedUpTo cursor and stops at the first batch
that is not old enough, so the deleted range always stays a contiguous prefix — a batch
is never removed from the middle while older ones remain readable.
MAX_BATCH_SIZE (1000) caps how many receipts may appear in one anchor_batch. Call get_max_batch_size to discover the limit at runtime instead of hard-coding it.
Emits:
| Event | Topics | Data |
|---|---|---|
AnchorEvent |
("anchor_event", batch_id) |
root, count, period_start, period_end |
PruneEvent |
("prune_event", start_batch_id) |
end_batch_id |
The AnchorEvent data map mirrors BatchRecord, so an indexer decodes it with the same
shape get_batch returns.
Proofs use sorted-pair SHA-256: siblings are concatenated smaller-hash-first, so
proofs carry no left/right position flags. The TypeScript SDK in
accensa-app implements the identical
convention, and both are checked against the same anchored batch on testnet — see
DEPLOYMENTS.md.
Holds merchant float and executes refunds bounded by an on-chain policy.
| Function | Purpose |
|---|---|
initialize(merchant, token, refund_window_ledgers) |
Sets admin, settlement token, and refund window. |
deposit(from, amount) |
Merchant tops up float. |
refund(payment_ref, recipient, amount, paid_at_ledger, payment_amount) |
Refunds part or all of a payment, subject to policy. amount is added to the cumulative total for payment_ref; payment_amount is the original payment amount and the hard ceiling on cumulative refunds. A configured fee (if any) is deducted before the payout. |
claim_batch(claims) |
Refunds multiple claims in one transaction (Vec<RefundClaim>, one struct per refund call). Atomic: one failing claim reverts the whole batch. One merchant signature, one reentrancy lock, and a RefundEvent per claim. Per-element float checks mean it can never overdraw the vault. |
process_batch(refunds) |
Best-effort batch refunds (Vec<RefundParam>, same shape as RefundClaim). Returns Vec<bool> — one entry per claim (true = applied), and a failing claim does not roll back the others. Capped at 100 claims per call (BatchTooLarge). Every claim runs the identical per-claim logic as refund, including the policy deadline check and the configured fee. Non-atomic by design: use claim_batch when all-or-nothing semantics are required. |
withdraw(amount, to) |
Merchant withdraws float. |
propose_policy(ledgers, deadline) |
Proposes a new refund policy — a window (in ledgers) plus a wall-clock deadline (Unix timestamp; 0 = no deadline); subject to timelock. |
execute_policy() |
Executes a pending policy change after the timelock. Applies both the new window and the new deadline. |
get_pending_policy() |
Returns the current pending policy proposal, if any. |
get_policy_timelock() |
Returns the policy timelock delay in ledgers (read-only). |
get_refund_deadline() |
Returns the configured policy deadline as a Unix timestamp (0 = none, read-only). |
set_fee_bps(bps) |
Sets the refund fee rate in basis points (0–10_000, default 0). Merchant auth, emits FeeConfigUpdatedEvent. |
set_fee_recipient(recipient) |
Sets the address that collects the refund fee; rejects the vault's own address. Merchant auth, emits FeeConfigUpdatedEvent. |
get_fee_bps() |
Returns the configured fee rate in basis points (read-only). |
get_fee_recipient() |
Returns the configured fee recipient, if any (read-only; falls back to the merchant at claim time). |
get_refund(payment_ref) -> Option<RefundRecord> |
Looks up a refund. |
get_admin() -> Address |
Returns the admin (merchant) address. Read-only; fails with NotInitialized before initialize. |
get_token() -> Address |
Returns the settlement token address. Read-only; fails with NotInitialized before initialize. |
get_refund_window() -> u32 |
Returns the refund window in ledgers (0 = no time bound). Read-only; fails with NotInitialized before initialize. |
is_paused() -> bool |
Returns whether the vault is paused. Read-only; fails with NotInitialized before initialize, false otherwise. |
pause() |
Pauses operations for emergency stops. Merchant auth required. |
unpause() |
Resumes paused operations. Merchant auth required. |
extend_refund_ttl(payment_ref) |
Extends the TTL of a refund record to prevent archival. Publicly callable. |
Config getters are individual, not a batch get_config. Exposing the four
stored values as separate read-only calls (get_admin, get_token,
get_refund_window, is_paused) — rather than a single struct-returning
get_config — keeps the publish ABI compositional and stable as new
configuration is added: a client that only needs one value reads exactly one
storage key, the #[contracttype] payload does not change shape when config
grows, and the is_paused distinction (missing admin ⇒ NotInitialized,
initialized ⇒ false) could not be expressed faithfully in one struct anyway.
The status quo is the supported way to read config; do not decode raw ledger
entries by storage key (see issue #195).
Emits:
| Event | Topics | Data |
|---|---|---|
DepositEvent |
("deposit_event", from) |
amount |
RefundEvent |
("refund_event", payment_ref) |
amount (this call), fee (this call), cumulative_refunded, recipient, ledger |
WithdrawEvent |
("withdraw_event", to) |
amount |
PauseEvent |
("pause_event", ledger) |
— |
UnpauseEvent |
("unpause_event", ledger) |
— |
RefundWindowUpdatedEvent |
("refund_window_updated_event", previous_window, new_window) |
— |
PolicyProposedEvent |
("policy_proposed_event", window) |
deadline, proposed_at_ledger, execute_after_ledger |
PolicyExecutedEvent |
("policy_executed_event", window) |
deadline |
FeeConfigUpdatedEvent |
("fee_config_updated_event", field) |
fee_bps, fee_recipient (full effective config) |
Each partial refund emits its own RefundEvent carrying both the amount for
that call (amount) and the running total (cumulative_refunded), so an indexer
knows the state of a payment without summing history. A batch of claims emits one
RefundEvent per item, in claim order. RefundRecord stores the
cumulative total (amount_refunded) plus the payment_amount ceiling, the
paid_at_ledger the window is measured from, and the recipient. When a fee is
configured, each RefundEvent also carries the fee deducted from the claim,
and the fee is paid to the fee_recipient alongside the recipient's payout.
Cross-Contract Joins (both claims below are pinned by tests in
contracts/refund-vault/tests/integration_test.rs):
payment_ref↔ receipt-leaf (covered byreadme_claim_payment_ref_is_receipt_leaf): Thepayment_refused to key refunds is identical to theleafhash of the payment receipt anchored inReceiptAnchor. This 1:1 mapping guarantees that the on-chain refund explicitly corresponds to the exact payment record provided to the agent.- Refunds outlive pruned batches (covered by
readme_claim_refunds_outlive_pruned_batches): Archiving or pruning a batch inReceiptAnchorhas no effect on theRefundVault. A payment can be successfully refunded even if its original anchor batch has been pruned, provided it still falls within the refund window.
Enforced invariants, each covered by a test:
- Partial refunds within a ceiling — a
payment_refmay be refunded across multiple calls, but cumulative refunds can never exceed the originalpayment_amount; an over-ceiling call is rejected (ExceedsPayment). A batch accumulates against the same ceiling across its own elements. - Atomic batches —
claim_batchis all-or-nothing: a single failing claim reverts the transfers, records and events of every claim in the call. - Per-item float bound — the float is read from the token contract before
every claim (single or batched), so a batch can never overdraw the vault any
more than the equivalent set of single refunds (
InsufficientFloat). - Window from the original payment — the refund window is measured from
paid_at_ledger(the original payment), never extended by a partial (WindowExpired). - Deadline from the policy — refunds stop being claimable once the
configured wall-clock deadline has strictly passed (
RefundExpired); a deadline of0disables expiry. - Fee-bounded split — when configured, each claim is split into the
recipient's payout and a fee that rounds up (sub-unit remainders accrue
to the fee recipient);
payout + fee == amountexactly, so the fee never expands the claim, thepayment_amountceiling, or the float check. Without a configured recipient the fee defaults to the merchant. - Float-bounded — a refund can never exceed vault balance (
InsufficientFloat). - Merchant-only — every state-changing call requires merchant auth
(
Unauthorized); the admin may be a contract account (seedocs/SECURITY_MODEL.md). - Pausable — operations are halted if the vault is paused (
Paused).
Both contracts return errors from a single, shared enum in
contracts/common (issue #98). Every variant has
an explicit, distinct u32 value, so a frontend keeps one mapping across both
contracts instead of per-contract tables.
| Code | Variant | Meaning |
|---|---|---|
| 1 | AlreadyInitialized |
initialize called twice. |
| 2 | NotInitialized |
State-changing call before initialize. |
| 3 | Unauthorized |
Caller is not the authorized merchant/admin. |
| 4 | AlreadyRefunded |
Legacy single-refund marker (pre-#99). Retained for interface stability; the vault reports ExceedsPayment (19) for over-ceiling and legacy records since cumulative partial refunds. |
| 5 | WindowExpired |
Refund window (from the original payment) has expired. |
| 6 | InsufficientFloat |
Vault float is insufficient. |
| 7 | InvalidAmount |
Amount was not strictly positive. |
| 8 | Paused |
Vault is paused. |
| 9 | RefundNotFound |
No refund record for the payment ref. |
| 12 | NoPendingTransfer |
No admin transfer pending. |
| 13 | StrategyNotSet |
No yield strategy configured. |
| 14 | InsufficientReserve |
Yield deployment would breach the minimum reserve. |
| 15 | DeploymentExceedsMax |
Yield deployment would exceed the max ratio. |
| 16 | NothingToWithdraw |
Nothing to withdraw from the yield strategy. |
| 17 | NothingToHarvest |
Nothing to harvest from the yield strategy. |
| 18 | InvalidRatio |
A configured ratio was out of range. |
| 19 | ExceedsPayment |
Cumulative refunds would exceed the payment ceiling. |
| 23 | RefundExpired |
A refund claim was submitted after the policy deadline passed. |
| 100 | BatchNotFound |
The requested batch does not exist (or was pruned). |
| 101 | BatchTooLarge |
A batch larger than MAX_BATCH_SIZE was submitted. |
| 102 | ShardCallFailed |
A shard call returned an unexpected shape. |
| 103 | DuplicateRoot |
The anchored Merkle root equals the currently active root. |
| 200 | RootNotFound |
The Merkle root is not in the historical ring buffer. |
| 201 | ProofTooLong |
The Merkle proof exceeds MAX_PROOF_LEN. |
| 202 | AnchorRateLimited |
An anchor was submitted before the minimum interval elapsed. |
| 300 | NoPendingPolicy |
No pending policy change exists to execute. |
| 301 | TimelockNotExpired |
The policy timelock period has not yet elapsed. |
| 302 | FuturePaidAtLedger |
A refund reported paid_at_ledger in the future (greater than the current ledger sequence). |
Codes are stable: new variants are appended with fresh values, never renumbered.
Note that 10/11 are deliberately unassigned (MetadataTooLong and
AmountExceedsMax were dead variants removed in #170), and 4
(AlreadyRefunded) is reserved after the RefundV2 migration — surviving codes
keep their published values.
Soroban uses state archival to manage ledger bloat. The contracts are configured with a Time-To-Live (TTL) strategy that ensures active records remain in persistent storage for approximately 30 days (~518,400 ledgers) before they become eligible for archival.
If a BatchRecord or RefundRecord is archived, it must be restored by submitting a restore transaction before it can be read again. Anyone can proactively prevent archival and reset the 30-day window by calling the public TTL extension functions:
extend_batch_ttl(batch_id)onReceiptAnchorextend_refund_ttl(payment_ref)onRefundVault
For a complete breakdown of what is stored, why it is persistent, and the rent cost implications, read the Storage Audit.
| Contract | ID |
|---|---|
ReceiptAnchor |
CBHRJU7CF4XIFRNDITFHNQHABKBMFM2FYFHLGWN3JGSFYYCDSMDAWPRV |
RefundVault |
CCMBM44EJUGD52G4LSMGHSXMAH2KSAQZX7VOYY4TTBF5BK4D7M4IHRQA |
Batch #1 is anchored and live. You can verify a receipt against it — and watch a forged receipt get rejected — with two read-only commands that cost nothing: see DEPLOYMENTS.md.
rustup target add wasm32v1-none
cargo install --locked stellar-clicargo test
cargo build --target wasm32v1-none --release # wasm artifacts./deploy.sh # testnet, identity "deployer"
TOKEN=<usdc-sac-id> ./deploy.sh # settle refunds in USDC instead of XLMContract IDs are written to deployments/<network>.env.
For mainnet deployment instructions and fee/rent analysis, see the Mainnet Deployment Guide.
agent pays ──▶ x402 endpoint (SDK middleware)
│
▼
Go indexer ──reads SAC transfers──▶ Stellar
│
batches receipts, builds Merkle root
│
▼
ReceiptAnchor.anchor_batch ──▶ on-chain root
│
agent ──verify_receipt(leaf, proof)──▶ true / false
For a full visual walkthrough including the refund flow and cross-contract relationship, see the Architecture Guide.
The dashboard, indexer, and SDK that drive these contracts live in
accensa-app.
Tests run against the Soroban test environment on every push, alongside
cargo fmt --check and cargo clippy -D warnings. CI does not swallow failures.
Both contracts carry property-based fuzz suites (src/fuzz_test.rs) that generate
random operation sequences and assert invariants after every step — pruning stays a
contiguous prefix, Merkle verification rejects every wrong proof shape, vault float
always equals deposits - refunds - withdrawals, and a payment_ref can never be
refunded twice. CI runs a bounded budget; a longer profile is available locally:
cargo test -- --ignored # longer profile
FUZZ_CASES=2000 FUZZ_SEQ_LEN=256 cargo test -- --ignored # even longerSee the module headers in contracts/*/src/fuzz_test.rs for the approach and its
limits.
See CONTRIBUTING.md. Security policy in SECURITY.md and threat model in docs/SECURITY_MODEL.md. For deployment errors, see TROUBLESHOOTING.md.
MIT — see LICENSE.