Skip to main content
Every contract address and transaction hash on this page is real and independently checkable on Stellar Expert. Nothing here is test-only output presented as more than it is.

What this replaces

Without this layer, the spend cap is only as trustworthy as nymor-server’s own code — a bug in the ledger logic, or a client that skips it entirely, could overspend with nothing stopping it. nymor-policy moves the cap into the Stellar network’s own authorization path: a transfer over the cap doesn’t get a chance to settle, because the smart account’s __check_auth panics before the transfer executes.

Deployed contracts

Both wrap OpenZeppelin’s audited stellar-accounts smart-account primitives rather than reimplementing signature verification or spend tracking. nymor-account is deployed with a single CallContract(usdc_sac) context rule holding the buyer’s Delegated signer and a nymor-spending-limit-policy instance, both installed in one constructor call — configured with a 1.00 USDC / ~1 day cap.

Real on-chain proof

Two independently verifiable transactions, not test output:

Under cap — accepted

0.10 USDC transferred from nymor-account. successful: true on Horizon.e4358552e77b46c87a5ff408bd42cd63efbf26a276e41806148b364a6bd1c4b2

Over cap — rejected on-chain

0.90 USDC attempted (would push total spend over the daily cap). successful: false — rejected with Error(Contract, #3221) SpendingLimitExceeded, raised from inside the policy’s enforce().d0f3e128df2bd2d2582a532c32e119dedd1957afdaa79f50412eebca76965f74
Both were cross-checked against the policy contract’s own get_spending_limit_data read: the rejected transaction left cached_total_spent unchanged, confirming the failure rolled back cleanly with no phantom write.
Getting a real signed transaction through required hand-crafting two things that neither stellar-sdk’s authorizeEntry() helper nor @x402/stellar know how to build:
  1. The custom auth digest. OZ smart accounts require signers to sign auth_digest = sha256(signature_payload || context_rule_ids.to_xdr()), not the raw payload the Soroban host provides — this binds the selected context rule into the signature to prevent rule-selection downgrade attacks. The signature then gets wrapped in an AuthPayload{context_rule_ids, signers} ScVal structure, not the classic {public_key, signature} shape stellar-sdk defaults to.
  2. A second, separate auth entry. The buyer’s signer is a Signer::Delegated(Address), which authenticates via buyer.require_auth_for_args((auth_digest,)) — called inside nymor-account’s own __check_auth. The Soroban host matches this against a distinct top-level authorization entry whose invocation is ContractFn(contract=nymor-account, function="__check_auth", args=[auth_digest]) — the current call-stack frame at the moment require_auth_for_args runs, not a normal contract call. OpenZeppelin’s own documentation confirms this can’t be discovered by transaction simulation: “this model requires manual authorization entry crafting, because it is not returned in a simulation mode.” No reference client implementation exists anywhere in their repository for this case. The exact invocation shape came from reading soroban-env-host’s auth.rs and account_contract.rs source directly, not from documentation.
The full implementation is packages/server/scripts/onchain-proof.mjs in the repository.

Enforcement, proven two ways

Besides the live transactions above, packages/policy/account/src/test.rs drives the same NymorAccount::__check_auth entry point — the exact function the Soroban host calls in production — against the real deployed-shape contracts in a soroban-sdk test environment, as a fast repeatable regression check:

Why the buyer still signs with a raw key

This is the one place in the project where “built” and “wired into production” diverge. Read this section before assuming the smart account protects real agent payments today — it doesn’t yet.
nymor-server’s buyer signer is still createEd25519Signer — a raw Ed25519 keypair, not the smart account. The reason is a real, traced library limitation, not an oversight: @x402/stellar’s ExactStellarScheme hardcodes a call to stellar-sdk’s default authorizeEntry() helper, which only knows how to produce the classic {public_key, signature} credential shape and exposes no configuration hook to override it. Routing real agent payments through nymor-account would mean forking @x402/stellar’s internals — not implementing a custom signer, which its ClientStellarSigner interface does genuinely support, but patching the library’s own transaction-assembly code. That’s a materially larger, riskier scope than a signer implementation, and it stays explicitly out of scope. Net effect: the local ledger (nymor.ledger.json) is what actually gates real agent spending today. The on-chain cap is real, deployed, and its enforcement logic is proven correct by both a live rejected transaction and a passing test — but nothing currently routes production payments through it.