Skip to content
 
 

Repository files navigation

accensa-contracts

Verifiable receipts and policy-bounded refunds for x402 payments on Stellar

CI Status License soroban-sdk 27 Deployed on testnet

Live on Testnet · Documentation · Dashboard · accensa-app

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.

The Problem

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.

Why Stellar

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_batch call 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.

Contracts

ReceiptAnchor

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 $\le$ 1000 (MAX_BATCH_SIZE).
get_batch(batch_id) -> BatchRecord Reads an anchored batch.
get_batch_count() -> u64 Returns the total number of anchored batches. Read-only.
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.
verify_receipt(batch_id, leaf, proof) -> bool Verifies a receipt against the anchored root. Read-only, free to call.
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.

RefundVault

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.
withdraw(amount, to) Merchant withdraws float.
set_refund_window(ledgers) Updates the window; 0 disables expiry.
get_refund(payment_ref) -> Option<RefundRecord> Looks up a refund.
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.

Emits:

Event Topics Data
DepositEvent ("deposit_event", from) amount
RefundEvent ("refund_event", payment_ref) amount (this call), cumulative_refunded, recipient, ledger
WithdrawEvent ("withdraw_event", to) amount

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. RefundRecord stores the cumulative total (amount_refunded) plus the payment_amount ceiling, the paid_at_ledger the window is measured from, and the recipient.

Cross-Contract Joins (both claims below are pinned by tests in contracts/refund-vault/tests/integration_test.rs):

  • payment_ref ↔ receipt-leaf (covered by readme_claim_payment_ref_is_receipt_leaf): The payment_ref used to key refunds is identical to the leaf hash of the payment receipt anchored in ReceiptAnchor. 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 in ReceiptAnchor has no effect on the RefundVault. 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_ref may be refunded across multiple calls, but cumulative refunds can never exceed the original payment_amount; an over-ceiling call is rejected (ExceedsPayment).
  • Window from the original payment — the refund window is measured from paid_at_ledger (the original payment), never extended by a partial (WindowExpired).
  • 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 (see docs/SECURITY_MODEL.md).
  • Pausable — operations are halted if the vault is paused (Paused).

Error Codes

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 Payment already fully refunded under the legacy rule.
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.
10 MetadataTooLong Metadata payload exceeded the allowed length.
11 AmountExceedsMax Amount exceeded the configured maximum.
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.
100 BatchNotFound The requested batch does not exist (or was pruned).
101 BatchTooLarge A batch larger than MAX_BATCH_SIZE was submitted.

Codes are stable: new variants are appended with fresh values, never renumbered.

Storage Archival

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) on ReceiptAnchor
  • extend_refund_ttl(payment_ref) on RefundVault

For a complete breakdown of what is stored, why it is persistent, and the rent cost implications, read the Storage Audit.

Live on Testnet

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.

Getting Started

Prerequisites

rustup target add wasm32v1-none
cargo install --locked stellar-cli

Build and test

cargo test
cargo build --target wasm32v1-none --release    # wasm artifacts

Deploy your own

./deploy.sh                      # testnet, identity "deployer"
TOKEN=<usdc-sac-id> ./deploy.sh  # settle refunds in USDC instead of XLM

Contract IDs are written to deployments/<network>.env.

For mainnet deployment instructions and fee/rent analysis, see the Mainnet Deployment Guide.

How the Pieces Fit

   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.

Testing

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 longer

See the module headers in contracts/*/src/fuzz_test.rs for the approach and its limits.

Contributing

See CONTRIBUTING.md. Security policy in SECURITY.md and threat model in docs/SECURITY_MODEL.md. For deployment errors, see TROUBLESHOOTING.md.

Contributors

License

MIT — see LICENSE.

About

Soroban contracts for Accensa: on-chain receipt anchoring and a merchant refund vault for x402 payments on Stellar

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages