Policy & rejections
The policy
The policy is stored in the account contract and checked in __check_auth on every authorization the agent signs.
| Field | Meaning |
|---|---|
agent_key | The ed25519 key allowed to sign as the agent. |
asset | The asset the caps are counted in (USDC). |
per_tx_cap | Maximum amount in a single authorization. |
daily_cap | Maximum total per day. The day is derived from the ledger sequence, so it needs no clock. |
payees | Addresses the account may pay or open channels to. |
channel | The one channel contract it may call. |
expires_ledger | After this ledger the agent key stops working. |
dex_router | Optional. The one DEX router it may swap on. None means swaps are off entirely (CONTEXT_NOT_ALLOWED). |
dex_factory | Optional, and required for a swap. The router's factory: the contract asks it for the pair address of the (sell, buy) pair, so a swap can't be routed through a pool the account didn't mean. Without it a swap fails with CONTEXT_NOT_ALLOWED. |
pairs | The allowed (sell, buy) asset pairs. A pair in one direction does not allow the other; list both if you want both. |
The account also stores two things beside the policy: its owner (the G… key that may change all of the above) and an optional controller. While a controller is set, the owner's signature stops authorizing anything — CONTROLLER_LOCKED. That is how Float lends into an account without collateral.
Read it from the SDK:
const policy = await account.getPolicy();
const { day, amount } = await account.getSpent();Owner controls
The owner key (not the agent key) can, each in one transaction:
| Call | Effect | In the console |
|---|---|---|
set_policy(policy) | Replace the policy. | Yes — Reins → policy editor. |
freeze() / unfreeze() | While frozen, every agent authorization fails with ACCOUNT_FROZEN. | Yes — one button. |
recall(asset, to, amount) | Pull funds out of the account. | No. SDK or Stellar CLI. |
set_controller(address) | Delegate owner actions to another contract. | No. SDK or Stellar CLI. |
You don't need a script for the first two. Open the console → Reins with the account address, connect the owner wallet, and edit the caps, the payee list, the validity or the exchange permission, or freeze the account. Each write asks for two signatures: one Sig::Owner authorization of the contract call, and one for the transaction fee. The console never sees a key — it builds the call, your wallet signs it, and it goes straight to Stellar. Our server is not in that path and could not change a limit if it wanted to.
The editor is hidden unless the connected wallet is the account's owner, and it is replaced by a notice when a controller is set.
Rejections come from the chain
When the agent steps outside the policy, the transaction fails on Stellar:
await account.transfer(attacker, 1_000_000n);
// SorobanCallError: PAYEE_NOT_ALLOWEDMost rejections surface during simulation, before anything is submitted, so a well-behaved agent never pays a fee for them. chainReason(error) returns the reason code.
To make a rejection publicly verifiable (a failed transaction with a hash), report it:
POST /v1/report
{ "account": "C…", "tx": "64 hex chars" }The facilitator looks the transaction up on-chain, and only if it really failed publishes a chain.rejected event. Unverifiable reports are dropped.
What the policy does not cover
On-chain limits apply to what happens on-chain: payments, channel deposits and DEX swaps. Trades inside a centralized exchange's internal ledger are invisible to Stellar and cannot be bounded this way.