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 the destination hardcoded to the vault's own address in every code path, cannot 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 vault](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/models.rs) is a real example: its `rebalance()` instruction set is `Unwind`, `Invest`, `SwapExactIn`, and `SwapExactOut`, and none of the four take a destination argument — every branch resolves the transfer to `e.current_contract_address()`.

**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.

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. Composed, an agent's key can only reach one contract, and that contract can't move funds anywhere but back into itself.

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 `Invest`/`Unwind` calls against a live DeFindex vault. The key-wrapping layer, built on the [OpenZeppelin Smart Accounts framework](https://developers.stellar.org/docs/tools/openzeppelin-contracts) 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](https://developers.stellar.org/docs/learn/fundamentals/contract-development/contract-interactions/transaction-simulation#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. To test the deny path, submit the transaction in enforcement mode and confirm it actually fails on-chain — don't infer it from a clean simulation.

### 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, in both authorization modes the RPC exposes ([full repro and both `authMode` results](https://github.com/OpenZeppelin/stellar-contracts/issues/863)) — even though that second signature is genuinely required for the call to succeed. 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

A human signer revokes access once, and the session ends there. A long-running agent process keeps looping regardless of what changed underneath it, so if it caches "am I still authorized" from the start of the process, a revocation made ten minutes into a multi-day run does nothing until the process restarts. Read the current role or permission state from chain at the top of every cycle, not once at boot.

### 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](https://developers.stellar.org/docs/tools/openzeppelin-contracts) — 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