An agent that holds a key can spend everything behind it, and a limit that lives on your server is only a limit until something goes around it. Here is the same limit enforced by the Stellar ledger instead, and the testnet transactions where it refuses.
The problem with handing an agent a key
Once software holds a key that can move money, every bad prompt, every bug and every compromise is a payment. This is not a hypothetical failure mode, it is the default one.
The usual answer is a check in the application: before sending, ask the server whether this payment is allowed. That works right up to the moment something goes around it. A compromised agent, a second code path, a direct call to the token, and the limit turns out never to have been a limit. It was a suggestion, running on a machine you were trusting.
Stellar already has the pieces an agent needs to pay for things: native USDC, fees small enough that per-call pricing is not absurd, and x402 for buying an API without an account or a card. What it did not have was a way to cap what that agent may spend, enforced by the ledger rather than by a server.
The vault, in one paragraph
We deployed a Soroban contract on Stellar testnet that holds the agent's USDC. The agent no longer holds the money. It holds permission to ask the vault to pay.
The vault knows two parties. A human owner, who sets the rules, can freeze everything and can withdraw, and an agent operator, which may only call pay and only inside those rules. The contract refuses to let one address be both, so a single stolen key cannot both spend past the policy and rewrite the policy.
Four rules run on every payment: a cap for the day, a ceiling on any single payment, a list of who may be paid at all, and a freeze switch. A payment that breaks one of them does not get flagged for review. It does not happen.
Why the ledger and not the server
The distinction sounds academic and is not. A check on your server is enforced by whoever is running that server. A check in the contract is enforced by the network, including against the person who wrote the agent.
Concretely, the vault moves its own balance. There is no allowance the agent can quietly raise, no admin endpoint that skips the gate, and no upgrade entrypoint on the deployed contract to add one later. It was deployed immutable on purpose: the rules it shipped with are the only rules it will ever have.
That cost is real and worth stating. A bug in it cannot be patched, only replaced by a new vault the owner moves the funds into. We took the trade, because a guardrail with an upgrade button is a guardrail with a back door.
What actually happened on testnet
The vault was funded with 15 USDC and given a 10 USDC cap for the day and a 2 USDC ceiling on any single payment.
A 1 USDC payment from the agent settled and is in the ledger, transaction 3da74634. A payment past the day's cap was refused with a typed error, Error(Contract, #5), which is the vault saying DailyCapExceeded, transaction 12df418f.
The owner then froze the vault, paid an address that was not on the allowlist through the owner path while it was still frozen, and unfroze it again. All three are on the ledger. That override is deliberate rather than a hole: the human path is meant to work exactly when the agent path does not. It still counted against the day's cap, so the owner bypasses the gates and not the budget.
The honest bit about refusals
Most of the refusals have no transaction hash at all, and that is worth explaining before anyone counts it as a gap in the evidence.
On Soroban a call is simulated before it is submitted. When the vault says no, the answer comes back during simulation and nothing is ever sent to the network. A refused payment normally leaves no trace, which is good engineering and inconvenient evidence.
To produce one refusal a reviewer can actually open, the limit was tightened while a payment was in flight, so it failed at apply time instead of during simulation. That is not a stunt for the demo. It is precisely the race a real agent hits when a human lowers a limit while the agent is mid-purchase.
How it meets x402, the payment rail
x402 is the part where an agent buys an API call without a subscription. It requests a resource, gets HTTP 402 back with a price and an address, pays, and asks again with the payment attached.
Wiring the two together means the purchase money leaves the vault rather than the agent's own wallet. The seller has to be on the allowlist, the price has to sit under the ceiling, and the day has to have room left. All of that runs before any transaction exists.
One such purchase is on the ledger, transaction fab5c864. The same call aimed at a seller who was not on the allowlist was refused with Error(Contract, #3), PayeeNotAllowed, and produced no transaction, for the reason in the previous section.
The buyer also paid no network fee. Horizon records the 22,973 stroop fee against our operator account rather than the buyer's, which is what gasless means when you can look it up instead of taking our word for it.
What we are not claiming
Testnet only. We have no contract and no account on Stellar mainnet, and our proof page states that outright rather than leaving it as a silence for someone to misread.
Not audited. There are 52 unit tests, and separately a runner that deletes each safety check in turn and requires the suite to go red. That second part carries more weight than the count: a green suite proves the contract passes its tests, and only the deletion runner proves the tests would notice if the contract stopped being safe. It is still not an audit and we will not call it one.
No agent identity on Stellar yet. The agent's passport lives on an EVM chain and is referenced from here, because there is no Soroban registry to anchor it in. The spending guardrail shipped before the identity layer, which is the opposite of the order we expected and is fine: a cap the ledger enforces is useful even against an agent nobody has vouched for.
Where to look
Every transaction named here is on stellar.expert on testnet, and the full list, with what each one is supposed to prove, is on our Stellar proof page. That page re-reads the chain when you load it and checks the contract is still there, rather than replaying an answer we saved earlier.
The contract source is in the public repo under soroban/contracts/agent-spend-policy, together with the tests and the runner that deletes the guards.