Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/build/guides/contract-accounts/autonomous-agents.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
title: Autonomous agents
description: Bound a long-running agent's authority with layered restrictions, and the pitfalls that only surface once it's actually running.
sidebar_position: 45
---

A contract account restricts _who_ can act and _under what conditions_ (see [Advanced contract account patterns](./advanced-patterns.mdx)). Those guardrails are usually installed once, for a human session. An autonomous agent is different: the same signing key stays live indefinitely, deciding on its own when to act, often many times a day. The restriction has to hold not just at setup, but for as long as the agent keeps running — including the day someone revokes it while the agent is mid-loop.

This page covers two layered ways to bound an agent's authority, and four pitfalls that only show up once the agent is actually operating, not when you install the policy.

## Two ways to bound authority, and why you want both

**Restrict the callee.** The safest scope is a capability the contract you're calling never implements in the first place. A vault that only exposes `Invest(strategy, amount)` and `Unwind(strategy, amount)`, with no destination argument anywhere in the interface, cannot be told to pay out to an arbitrary address no matter what the caller's key can sign. Withdrawal isn't denied by a check; it's absent from the interface. [DeFindex's `rebalance()` entry point](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/models.rs) is a real example of this — not DeFindex's vault as a whole, which also exposes `withdraw` (self-service for depositors, gated by `from.require_auth()` and paying `from` itself, not an attack surface for redirecting funds elsewhere) and several administrative functions gated by the Manager role ([`interface.rs`](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/interface.rs)). The destination-less property is scoped to `rebalance()`'s own instruction set: `Unwind`, `Invest`, `SwapExactIn`, and `SwapExactOut`, none of which take a destination argument. That's a narrower guarantee than "funds only ever move back into the vault," though — a swap's input leg does transfer out to the trading pool mid-execution ([verified directly against the router's own source](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/router.rs)), and the pool address itself is resolved on-chain from the token pair, not supplied by the caller. What the caller genuinely cannot do, calling `rebalance()`, is redirect a payout or a swap's output to an address of their choosing.

**Restrict the key.** The complementary layer is scoping the caller's own credential, using the account-abstraction primitives already covered on this page and the [previous one](./advanced-patterns.mdx): a smart account whose only context rule is `CallContract(the one vault you want)`, with no default/catch-all rule installed. The absence of a fallback rule is what does the work — a context that isn't covered by an explicit rule has no rule that authorizes it. `CallContract` scopes the *contract address*, not which function on it gets called — it says nothing about whether the account invokes `rebalance()` or `set_manager()`. The function-level bound has to come from the callee's own access control instead, gated on which role the callee's contract has assigned to this specific address.

Neither layer is sufficient alone for an unattended agent. Restrict only the callee, and a leaked key can still authorize anything the callee's own logic happens to allow — a swap at bad slippage, say, if the callee supports swaps at all. Restrict only the key, and you're trusting that the callee's logic never grows a footgun later — and, per the point above, `CallContract(the vault)` alone doesn't stop that key from *attempting* `set_manager()` or `upgrade()` on the same contract; DeFindex's [`access.rs`](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/access.rs) gates those behind `require_role(&RolesDataKey::Manager)`, a check this address only passes if it was independently granted the Manager role — a mistake in the vault's own setup, not something the smart account's context rules would catch. Composed *correctly* — `CallContract(the vault)` as the sole context rule, and this address holding only the vault's RebalanceManager role (verifiable on-chain: `get_rebalance_manager()` returns this address, `get_manager()`/`get_emergency_manager()`/`get_fee_receiver()` don't) — an agent's key can only reach one contract, and the one entry point DeFindex's own role check actually lets it call leaves no way to redirect a payout or a swap's output to an address of the caller's choosing — the same limited guarantee established above, not the broader "funds only ever move back into the vault" claim that example's own swap-input leg already contradicts.

Worth being precise about what "deployed" proves versus what "operating" proves, since the two layers don't reach the same bar in practice at the same time. Nirium's treasury agent has the callee-side restriction backing real, signed calls against a live DeFindex vault: [an `Unwind`](https://stellar.expert/explorer/public/tx/b7bf6d7079aa128081ac2a5091cbff2da2b1e9f2157232209cf03eab00f840a9) and [an `Invest`](https://stellar.expert/explorer/public/tx/9f0e7e03cf3f17655b7e1b755c6a3fb5e65c499835424e440e601ecc8b68f033), both settled, decoded from the raw operation rather than taken from a label. The key-wrapping layer, built on the [OpenZeppelin Smart Accounts framework](../../../tools/openzeppelin-contracts.mdx) and deployed on testnet at [`CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML`](https://stellar.expert/explorer/testnet/contract/CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML), has its intended rule confirmed by reading `context_rules` on-chain — but hasn't yet authorized a real signed transaction through it. Deployed-with-the-right-rule and exercised-end-to-end are different claims; verify which one you're actually looking at before relying on either.

## What only shows up once the agent is running

### Simulation records authorization, it doesn't verify it

[Recording-mode simulation](../../../learn/fundamentals/contract-development/contract-interactions/transaction-simulation.mdx#recording-mode) records every `require_auth` call as successful, and "never emulates authorization failures... failing authorization is always an 'exceptional' situation." That's the right behavior for building a transaction to sign, and the wrong tool for testing that a restriction actually holds. A policy that looks correctly restrictive under simulation can still pass simulation for an action it should deny, because simulation was never checking the signature in the first place. `simulateTransaction` takes an `authMode` of `enforce`, `record`, or `record_allow_nonroot` — to test the deny path, simulate again in [enforcing mode](../../../learn/fundamentals/contract-development/contract-interactions/transaction-simulation.mdx#enforcing-mode), which validates signatures and actually executes `__check_auth`, "basically equivalent to running the transaction on-chain," per that page's own description. A real on-chain submission would fail the same way, but at the cost of a real fee to prove what a free enforcing-mode simulation already proves. Don't infer a deny path from a clean recording-mode simulation, which skips signature checking entirely.

### A delegated signer's own requirement doesn't show up in signer discovery either

If the key-wrapping layer uses a delegated signer (the account authenticates by requiring a _second_, separate address to also authorize, rather than checking a signature itself), don't rely on the SDK's standard signer-discovery helpers to tell you that second signature is needed. Confirmed empirically against a real deployment: the delegate's address is absent from `AssembledTransaction.needsNonInvokerSigningBy()`'s output and from the simulation's own raw list of authorization entries ([full repro](https://github.com/OpenZeppelin/stellar-contracts/issues/863)), even though that second signature is genuinely required for the call to succeed. Hand-constructing that second entry and submitting it for real settles the point beyond simulation: [a delegated signer authorizing a live call](https://stellar.expert/explorer/testnet/tx/428021a6ef648937bf0edeec96d42f13e44447eac9b036c127c90cf4bebdd71b), confirmed successful on-chain. This lines up with recording-mode simulation never executing the account's own authorization logic during discovery, so a requirement that only arises as a side effect of running that logic has nothing to discover ahead of time — a documented pitfall inferred from that behavior, not something independently confirmed against the host implementation itself. Build and attach that signature by hand; don't trust a clean discovery result to mean nothing else needs signing.

### Re-read authority from chain every cycle, never cache it

An on-chain revocation is effective the moment it lands, regardless of what any process has cached: `__check_auth` re-reads the account's current rules from chain on every check, so the very next transaction the agent tries to submit, or even simulate in enforcing mode, is rejected whether or not its own process noticed anything changed. Signing itself is a local, offline step that never consults `__check_auth`, so it's unaffected either way; revocation only bites once a transaction actually reaches enforcing-mode simulation or the network. What caching actually costs you is time and signal, not security. A long-running agent that only reads "am I still authorized" once at boot keeps looping anyway, spending cycles building and submitting transactions that fail on-chain one after another, instead of noticing the moment its authority changed and stopping or alerting. Read the current role or permission state from chain at the top of every cycle — not to keep the revocation itself effective, but so the agent finds out it's been cut off immediately, not after a string of failed submissions.

### Draw the line between the agent's judgment and the code's limits, and don't let the agent move it

If an LLM or other model is choosing _when_ to act and _what_ to attempt, treat its output like any other untrusted input: it can request an action, but the ceiling on that action — maximum amount, allowed destinations, acceptable slippage — has to be enforced by code the model's own output cannot alter. In practice this means the boundary values live in a small, deterministic, separately reviewed piece of the system, and the model never gets a code path that writes to them.

## What this pattern doesn't resolve

A destination-less instruction set and a policy account with no default rule are technical facts you can verify on-chain. Whether they add up to something like custody or intermediation under a specific jurisdiction's securities or money-transmission law is a separate, non-technical question. Get that answered early with real counsel, not inferred from the contract's design after the fact.

## Where to go next

- [Advanced contract account patterns](./advanced-patterns.mdx) for the individual guardrail primitives this page composes.
- [OpenZeppelin Smart Accounts](../../../tools/openzeppelin-contracts.mdx) — context rules, signers, and policies, audited by OpenZeppelin's security team, with formal verification by Certora in progress.
- [CAP-71](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0071.md), authentication delegation and address-bound Soroban credentials.
- [DeFindex's vault contract](https://github.com/defindex-io/stellar-contracts), a real example of an instruction set with no destination parameter.
Loading