diff --git a/docs/build/smart-contracts/example-contracts/complex-account.mdx b/docs/build/smart-contracts/example-contracts/complex-account.mdx index ff33f67ec..4030ca01c 100644 --- a/docs/build/smart-contracts/example-contracts/complex-account.mdx +++ b/docs/build/smart-contracts/example-contracts/complex-account.mdx @@ -453,3 +453,7 @@ assert_eq!( AccError::NotEnoughSigners ); ``` + +## Further Reading + +- [Delegate Auth example](./delegate-auth.mdx) — extends this example with CAP-71 auth delegation to registered delegate signers. diff --git a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx new file mode 100644 index 000000000..5f449f9ea --- /dev/null +++ b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx @@ -0,0 +1,276 @@ +--- +title: Delegate Auth +sidebar_label: Delegate Auth +description: A custom account contract that uses CAP-71 auth delegation to forward __check_auth to registered delegate signers. +sidebar_position: 18 +--- + + + Delegate Auth + + + + + +This example shows **auth delegation**: a `ModularAccount` contract performs no signature verification itself. Instead, it stores a set of registered signer addresses and, when `__check_auth` is called, forwards the authorization context to whichever of those signers the user attached to the transaction. Each delegate runs its own `__check_auth` independently. + +Auth delegation was introduced in soroban-sdk v27 via [CAP-71](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0071.md). For a single-key account see the [Simple Account example](./simple-account.mdx), and for a multi-sig account with spend-limit policies see the [Complex Account example](./complex-account.mdx). + +:::danger + +Implementing a contract account requires a very good understanding of authentication and authorization and requires rigorous testing and review. The example here is _not_ a full-fledged account contract — use it as an API reference only. + +::: + +:::caution + +While contract accounts are supported by the Stellar protocol and Soroban SDK, the full client support (such as transaction simulation) is still under development. + +::: + +[![Open in Codespaces](https://github.com/codespaces/badge.svg)][open-in-github-codespaces] + +[![Open in Codeanywhere](https://codeanywhere.com/img/open-in-codeanywhere-btn.svg)][open-in-code-anywhere] + +[open-in-github-codespaces]: https://github.com/codespaces/new?repo=stellar/soroban-examples&editor=web +[open-in-code-anywhere]: https://app.codeanywhere.com/#https://github.com/stellar/soroban-examples + +## Run the Example + +1. Finish the [Setup] checklist to install the Stellar CLI, Rust target, and required environment variables. +2. Clone the `soroban-examples` repository: + +```sh +git clone https://github.com/stellar/soroban-examples +``` + +3. Run the tests from the `modular_account` directory: + +```sh +cd modular_account +make test +``` + +Expected output: + +``` +running 1 test +test test::test ... ok +``` + +[setup]: ../getting-started/setup.mdx + +## Code + +```rust title="modular_account/src/lib.rs" +#![no_std] + +use soroban_sdk::{ + auth::{Context, CustomAccountInterface}, + contract, contracterror, contractimpl, contracttype, + crypto::Hash, + Address, Env, Vec, +}; + +#[contracterror] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + UnknownDelegate = 1, +} + +#[contracttype] +enum ModularAccountDataKey { + // Marks an address as a signer allowed to authenticate for the + // modular account. + Signer(Address), +} + +#[contract] +pub struct ModularAccount; + +#[contractimpl] +impl ModularAccount { + // Registers the addresses allowed to authenticate for this account. + pub fn __constructor(env: Env, signers: Vec
) { + for signer in signers.iter() { + env.storage() + .persistent() + .set(&ModularAccountDataKey::Signer(signer), &()); + } + } +} + +#[contractimpl] +impl CustomAccountInterface for ModularAccount { + // The account verifies no signature of its own, so it carries no + // signature to check. + type Signature = (); + type Error = Error; + + fn __check_auth( + env: Env, + _signature_payload: Hash<32>, + _signatures: (), + _auth_contexts: Vec, + ) -> Result<(), Error> { + // The signers the user attached to the auth entry for this + // account's authorization. + let delegates = env.custom_account().get_delegated_signers(); + + // With no delegates to forward to, the account would authenticate + // nothing and be effectively unauthenticated, so reject it. + if delegates.is_empty() { + return Err(Error::UnknownDelegate); + } + + // Check if the delegates are accepted by the modular account. + for delegate in delegates.iter() { + if !env + .storage() + .persistent() + .has(&ModularAccountDataKey::Signer(delegate.clone())) + { + return Err(Error::UnknownDelegate); + } + } + + // Forward the current authorization to each delegate. + for delegate in delegates.iter() { + env.custom_account().delegate_auth(&delegate); + } + + Ok(()) + } +} + +mod test; +``` + +## How it Works + +### Storage layout + +`ModularAccountDataKey::Signer(Address)` uses one persistent storage entry per allowed signer. Using a per-key entry makes registration and revocation O(1) lookups rather than scanning a list. The `()` value signals presence. + +### Constructor + +```rust +pub fn __constructor(env: Env, signers: Vec
) { + for signer in signers.iter() { + env.storage() + .persistent() + .set(&ModularAccountDataKey::Signer(signer), &()); + } +} +``` + +Each allowed delegate address is persisted individually at deployment. A production account would also expose an admin function to add or remove signers after deployment. + +### `type Signature = ()` + +```rust +type Signature = (); +``` + +`ModularAccount` verifies no signature of its own — it relies entirely on its delegates. Setting `Signature = ()` tells the Soroban host that no signature data needs to be passed to `__check_auth`. The user's wallet still builds a full `SorobanAddressCredentialsWithDelegates` auth entry (see the test), but the account-level signature field is empty. + +### `__check_auth`: get → verify → forward + +```rust +let delegates = env.custom_account().get_delegated_signers(); + +if delegates.is_empty() { + return Err(Error::UnknownDelegate); +} + +for delegate in delegates.iter() { + if !env.storage().persistent().has(&ModularAccountDataKey::Signer(delegate.clone())) { + return Err(Error::UnknownDelegate); + } +} + +for delegate in delegates.iter() { + env.custom_account().delegate_auth(&delegate); +} +``` + +Two methods on `env.custom_account()` drive delegation. Both may only be called from within `__check_auth`; calling either outside of it panics. + +- **`get_delegated_signers() -> Vec
`** returns the delegate addresses the user attached to the transaction's auth entry. These are unsanitized user input — the contract must verify each one is registered before forwarding. +- **`delegate_auth(&address)`** forwards the current `__check_auth` authorization context to `address`. Unlike `require_auth`, this does not start a new contract invocation and does not require a separate auth entry for the delegate in the transaction. Delegation is nestable: a delegate may further delegate. + +The example first rejects an empty delegate list — since the account verifies no signature of its own, forwarding to nobody would leave it effectively unauthenticated. It then makes two passes: first it validates all delegates, then it forwards to each. This ensures that an invalid delegate causes the whole check to fail before any forwarding occurs. + +## Tests + +Open [`modular_account/src/test.rs`][test-rs]. The test defines two helper contracts: + +[test-rs]: https://github.com/stellar/soroban-examples/tree/main/modular_account/src/test.rs + +- **`DelegateAccount`** — a simple custom account with `type Signature = ()` that always approves, and stores the received `auth_contexts` in instance storage for later assertion. +- **`Protected`** — a contract with one function that calls `account.require_auth()`. + +The test registers one `ModularAccount` with one `DelegateAccount` as its allowed signer, builds an `AddressWithDelegates` auth entry by hand, and calls `Protected::protected`: + +```rust +env.set_auths(&[SorobanAuthorizationEntry { + credentials: SorobanCredentials::AddressWithDelegates( + SorobanAddressCredentialsWithDelegates { + address_credentials: SorobanAddressCredentials { + address: account_addr.clone(), + nonce: 1, + signature_expiration_ledger: 100, + // The account verifies no signature of its own. + signature: ScVal::Void, + }, + delegates: std::vec![SorobanDelegateSignature { + address: delegate_addr, + signature: ScVal::Void, + nested_delegates: VecM::default(), + }] + .try_into() + .unwrap(), + }, + ), + root_invocation: SorobanAuthorizedInvocation { /* ... */ }, +}]); + +ProtectedClient::new(&env, &protected).protected(&account); +``` + +After the call, the test asserts two things: + +1. `env.auths()` shows only the account authorizing `protected` — delegating to `DelegateAccount` is not recorded as a separate top-level authorization. +2. `DelegateAccount`'s `ApprovedContexts` storage contains the same contract context, confirming the delegation actually reached it. + +:::note + +Testing delegated auth via `env.try_invoke_contract_check_auth` is not supported. Use `env.set_auths` with `SorobanAddressCredentialsWithDelegates` and a wrapper contract call instead, as shown in the test. + +::: + +## Build the Contract + +```sh +stellar contract build +``` + +A `.wasm` file will be output in the `target` directory: + +``` +target/wasm32v1-none/release/soroban_modular_account_contract.wasm +``` + +## Further Reading + +- [Simple Account example](./simple-account.mdx) — the minimal single-key baseline. +- [Complex Account example](./complex-account.mdx) — multisig and spend-limit policies. +- [CAP-71 specification](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0071.md) — the protocol change that introduced auth delegation. +- [`CustomAccount` API docs](https://docs.rs/soroban-sdk/latest/soroban_sdk/custom_account/struct.CustomAccount.html) — reference for `get_delegated_signers` and `delegate_auth`. diff --git a/routes.txt b/routes.txt index dc4813f77..afe489e60 100644 --- a/routes.txt +++ b/routes.txt @@ -165,6 +165,7 @@ /docs/build/smart-contracts/example-contracts/complex-account /docs/build/smart-contracts/example-contracts/cross-contract-call /docs/build/smart-contracts/example-contracts/custom-types +/docs/build/smart-contracts/example-contracts/delegate-auth /docs/build/smart-contracts/example-contracts/deployer /docs/build/smart-contracts/example-contracts/errors /docs/build/smart-contracts/example-contracts/events