From 6ef6d915d257ba914fa93c0fcd4ad3f9edc97ba1 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:57:55 +1000 Subject: [PATCH 01/13] docs: add CAP-71 auth delegation section for soroban-sdk v27 --- .../example-contracts/complex-account.mdx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/docs/build/smart-contracts/example-contracts/complex-account.mdx b/docs/build/smart-contracts/example-contracts/complex-account.mdx index ff33f67ece..215cbe5e3b 100644 --- a/docs/build/smart-contracts/example-contracts/complex-account.mdx +++ b/docs/build/smart-contracts/example-contracts/complex-account.mdx @@ -365,6 +365,50 @@ fn verify_authorization_policy( Then we check for the standard token function names and verify that for these function we don't exceed the spending limits. +### Auth Delegation (v27+) + +Auth delegation, introduced in soroban-sdk v27 via CAP-71, allows a custom account contract to forward its `__check_auth` verification context to other registered addresses. This enables "modular" account contracts that do not perform all authentication themselves but instead delegate to one or more external signers (either G- or C-type addresses) that carry out the actual auth logic. + +Two new methods are available on `env.custom_account()`, and both may only be called from within `__check_auth`. Calling them outside of `__check_auth` will panic. + +- **`get_delegated_signers() -> Vec
`** — Returns the list of delegate addresses that the user attached to the auth entry when building the transaction. These are user-supplied and are not sanitized by the host; the contract must verify that each address is actually a registered delegate before acting on it. + +- **`delegate_auth(&address)`** — Forwards the current `__check_auth` authorization context to the given address. The delegate runs its own auth check for the same context. Unlike `require_auth`, this does not count as a new contract invocation and does not require a separate auth entry in the transaction. Delegation can be nested recursively. + +The typical usage pattern is: + +1. Call `env.custom_account().get_delegated_signers()` to retrieve the user-provided delegates. +2. Verify that each delegate is registered with this account (e.g. stored in contract state). +3. Call `env.custom_account().delegate_auth(&delegate)` for each verified delegate. + +```rust +fn __check_auth( + env: Env, + signature_payload: Hash<32>, + signature: BytesN<64>, + auth_contexts: Vec, +) -> Result<(), ModularAccountError> { + // Perform the account's own verification if needed. + let public_key: BytesN<32> = env.storage().instance().get(&DataKey::PublicKey).unwrap(); + env.crypto() + .ed25519_verify(&public_key, &signature_payload.into(), &signature); + + // Retrieve the delegate addresses the user attached to the auth entry. + let delegates = env.custom_account().get_delegated_signers(); + let signers: Vec
= env.storage().instance().get(&DataKey::Signers).unwrap(); + + for delegate in delegates.iter() { + // Reject any delegate that is not registered with this account. + if !signers.contains(&delegate) { + return Err(ModularAccountError::UnknownDelegate); + } + // Forward the current authorization context to the verified delegate. + env.custom_account().delegate_auth(&delegate); + } + Ok(()) +} +``` + ### Tests Open the [`account/src/test.rs`] file to follow along. From af6b811876a2fefb9dd4436d97d20d87ed53c26e Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:22:51 +0000 Subject: [PATCH 02/13] docs(soroban): add Modular Account example page for CAP-71 auth delegation - Add docs/build/smart-contracts/example-contracts/modular-account.mdx as a standalone example covering auth delegation (soroban-sdk v27+). - Remove the auth delegation section that was added to complex-account.mdx and replace it with a link to the new Modular Account page. - Source contract code is taken from the SDK test in soroban-sdk/src/tests/delegate_auth.rs. Claude-Session: https://claude.ai/code/session_01GgpDVzaPCuCzUVUUyvdAke --- .../example-contracts/complex-account.mdx | 49 +---- .../example-contracts/modular-account.mdx | 190 ++++++++++++++++++ 2 files changed, 195 insertions(+), 44 deletions(-) create mode 100644 docs/build/smart-contracts/example-contracts/modular-account.mdx diff --git a/docs/build/smart-contracts/example-contracts/complex-account.mdx b/docs/build/smart-contracts/example-contracts/complex-account.mdx index 215cbe5e3b..8caa3daaa8 100644 --- a/docs/build/smart-contracts/example-contracts/complex-account.mdx +++ b/docs/build/smart-contracts/example-contracts/complex-account.mdx @@ -365,50 +365,6 @@ fn verify_authorization_policy( Then we check for the standard token function names and verify that for these function we don't exceed the spending limits. -### Auth Delegation (v27+) - -Auth delegation, introduced in soroban-sdk v27 via CAP-71, allows a custom account contract to forward its `__check_auth` verification context to other registered addresses. This enables "modular" account contracts that do not perform all authentication themselves but instead delegate to one or more external signers (either G- or C-type addresses) that carry out the actual auth logic. - -Two new methods are available on `env.custom_account()`, and both may only be called from within `__check_auth`. Calling them outside of `__check_auth` will panic. - -- **`get_delegated_signers() -> Vec
`** — Returns the list of delegate addresses that the user attached to the auth entry when building the transaction. These are user-supplied and are not sanitized by the host; the contract must verify that each address is actually a registered delegate before acting on it. - -- **`delegate_auth(&address)`** — Forwards the current `__check_auth` authorization context to the given address. The delegate runs its own auth check for the same context. Unlike `require_auth`, this does not count as a new contract invocation and does not require a separate auth entry in the transaction. Delegation can be nested recursively. - -The typical usage pattern is: - -1. Call `env.custom_account().get_delegated_signers()` to retrieve the user-provided delegates. -2. Verify that each delegate is registered with this account (e.g. stored in contract state). -3. Call `env.custom_account().delegate_auth(&delegate)` for each verified delegate. - -```rust -fn __check_auth( - env: Env, - signature_payload: Hash<32>, - signature: BytesN<64>, - auth_contexts: Vec, -) -> Result<(), ModularAccountError> { - // Perform the account's own verification if needed. - let public_key: BytesN<32> = env.storage().instance().get(&DataKey::PublicKey).unwrap(); - env.crypto() - .ed25519_verify(&public_key, &signature_payload.into(), &signature); - - // Retrieve the delegate addresses the user attached to the auth entry. - let delegates = env.custom_account().get_delegated_signers(); - let signers: Vec
= env.storage().instance().get(&DataKey::Signers).unwrap(); - - for delegate in delegates.iter() { - // Reject any delegate that is not registered with this account. - if !signers.contains(&delegate) { - return Err(ModularAccountError::UnknownDelegate); - } - // Forward the current authorization context to the verified delegate. - env.custom_account().delegate_auth(&delegate); - } - Ok(()) -} -``` - ### Tests Open the [`account/src/test.rs`] file to follow along. @@ -497,3 +453,8 @@ assert_eq!( AccError::NotEnoughSigners ); ``` + + +## Further Reading + +- [Modular Account example](./modular-account.mdx) — extends this example with CAP-71 auth delegation to registered delegate signers. diff --git a/docs/build/smart-contracts/example-contracts/modular-account.mdx b/docs/build/smart-contracts/example-contracts/modular-account.mdx new file mode 100644 index 0000000000..3274985710 --- /dev/null +++ b/docs/build/smart-contracts/example-contracts/modular-account.mdx @@ -0,0 +1,190 @@ +--- +title: Modular Account +sidebar_label: Modular Account +description: A contract account that delegates authentication to registered signer contracts using CAP-71 auth delegation. +sidebar_position: 17 +--- + + + Modular Account + + + + + +This example extends the [Complex Account example](./complex-account.mdx) with **auth delegation**: instead of performing all signature verification itself, the `ModularAccount` contract forwards its `__check_auth` context to one or more registered signer contracts. 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). + +:::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. + +::: + +## How it Works + +The example defines two contracts: + +- **`ModularAccount`** — a custom account that stores a set of registered delegate signers and, on each `__check_auth` call, retrieves the user-attached delegates and forwards the auth context to each verified delegate. +- **`DelegateAccount`** — a simple custom account that performs ed25519 verification; it acts as the delegate signer. + +### Data storage + +```rust +#[contracterror] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ModularAccountError { + UnknownDelegate = 1, +} + +#[contracttype] +pub enum DataKey { + PublicKey, + Signers, + AuthorizedCalls, +} +``` + +`Signers` holds the set of `Address` values that are allowed to act as delegates for this account. `PublicKey` stores the account's own ed25519 key (used for its own verification step). + +### Constructor + +```rust +#[contractimpl] +impl ModularAccount { + pub fn __constructor(env: Env, public_key: BytesN<32>, signers: Vec
) { + env.storage() + .instance() + .set(&DataKey::PublicKey, &public_key); + env.storage().instance().set(&DataKey::Signers, &signers); + } +} +``` + +Both the account's public key and the list of registered delegate addresses are persisted in instance storage at deployment. + +### `__check_auth` with delegation + +```rust +#[contractimpl] +impl CustomAccountInterface for ModularAccount { + type Signature = BytesN<64>; + type Error = ModularAccountError; + + fn __check_auth( + env: Env, + signature_payload: Hash<32>, + signature: BytesN<64>, + auth_contexts: Vec, + ) -> Result<(), ModularAccountError> { + // Perform the account's own verification. + let public_key: BytesN<32> = env.storage().instance().get(&DataKey::PublicKey).unwrap(); + env.crypto() + .ed25519_verify(&public_key, &signature_payload.into(), &signature); + + // Retrieve the delegate addresses the user attached to the auth entry. + let delegates = env.custom_account().get_delegated_signers(); + + let signers: Vec
= env.storage().instance().get(&DataKey::Signers).unwrap(); + for delegate in delegates.iter() { + // The host does not validate delegates — the contract must. + if !signers.contains(&delegate) { + return Err(ModularAccountError::UnknownDelegate); + } + // Forward the current authorization context to the verified delegate. + env.custom_account().delegate_auth(&delegate); + } + Ok(()) + } +} +``` + +Two new methods on `env.custom_account()` drive the delegation: + +- **`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 against its own registered signers 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. + +Both methods may only be called from within `__check_auth`. Calling either outside of `__check_auth` panics. + +The pattern is always: + +1. Call `get_delegated_signers()` to get the user-supplied delegates. +2. Verify each delegate is registered with this account. +3. Call `delegate_auth(&delegate)` for each verified delegate. + +### Delegate account + +```rust +#[contractimpl] +impl CustomAccountInterface for DelegateAccount { + type Signature = BytesN<64>; + type Error = crate::Error; + + fn __check_auth( + env: Env, + signature_payload: Hash<32>, + signature: BytesN<64>, + _auth_contexts: Vec, + ) -> Result<(), crate::Error> { + let public_key: BytesN<32> = env.storage().instance().get(&DataKey::PublicKey).unwrap(); + env.crypto() + .ed25519_verify(&public_key, &signature_payload.into(), &signature); + Ok(()) + } +} +``` + +`DelegateAccount` is a standard ed25519 account. It receives the same `signature_payload` as `ModularAccount`, verifies its own key, and returns `Ok(())`. Any address type (G- or C-) that implements `CustomAccountInterface` can be a delegate. + +## Tests + +The SDK ships a test in [`soroban-sdk/src/tests/delegate_auth.rs`][sdk-delegate-auth-test] that exercises the full delegation flow using `env.set_auths` with `SorobanAddressCredentialsWithDelegates`. + +[sdk-delegate-auth-test]: https://github.com/stellar/rs-soroban-sdk/blob/main/soroban-sdk/src/tests/delegate_auth.rs + +The test covers: + +- **Negative case** — attaching an unregistered delegate causes `ModularAccount` to return `UnknownDelegate`, and the call fails. +- **Positive case** — both registered delegates sign the same payload; `ModularAccount` verifies its own key, then calls `delegate_auth` for each delegate; all three accounts record the protected function name in their `AuthorizedCalls` storage. + +```rust +// Both account and its delegates observe a single call to +// `protected` in their authorization contexts. +let expected = vec![&env, Symbol::new(&env, "protected")]; +for addr in [&account, &delegate_a, &delegate_b] { + let calls: Vec = env.as_contract(addr, || { + env.storage() + .instance() + .get(&DataKey::AuthorizedCalls) + .unwrap() + }); + assert_eq!(calls, expected); +} +``` + +:::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. + +::: + +## 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`. From 2c839a04af86cb6b6e5ccd81af30f9f22e0003c7 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:40:37 +0000 Subject: [PATCH 03/13] docs(soroban): fix CI and align Modular Account page with runnable example - Add the modular-account route to routes.txt (build job). - Remove the extra blank line in complex-account.mdx (mdx-format job). - Point the page at the new runnable modular_account example in soroban-examples, adding a "Run the Example" section and source-file titles on the code blocks. - Correct the DelegateAccount error type to soroban_sdk::Error and note it is defined as a test fixture (a single Wasm exports one __check_auth). - Explain the record_authorized_calls test-observability helper. Claude-Session: https://claude.ai/code/session_01EgjfKqQ1RMvtwQUSx3iDzh --- .../example-contracts/complex-account.mdx | 1 - .../example-contracts/modular-account.mdx | 60 +++++++++++++++---- routes.txt | 1 + 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/docs/build/smart-contracts/example-contracts/complex-account.mdx b/docs/build/smart-contracts/example-contracts/complex-account.mdx index 8caa3daaa8..740724aa6d 100644 --- a/docs/build/smart-contracts/example-contracts/complex-account.mdx +++ b/docs/build/smart-contracts/example-contracts/complex-account.mdx @@ -454,7 +454,6 @@ assert_eq!( ); ``` - ## Further Reading - [Modular Account example](./modular-account.mdx) — extends this example with CAP-71 auth delegation to registered delegate signers. diff --git a/docs/build/smart-contracts/example-contracts/modular-account.mdx b/docs/build/smart-contracts/example-contracts/modular-account.mdx index 3274985710..3a651e628b 100644 --- a/docs/build/smart-contracts/example-contracts/modular-account.mdx +++ b/docs/build/smart-contracts/example-contracts/modular-account.mdx @@ -34,6 +34,43 @@ While contract accounts are supported by the Stellar protocol and Soroban SDK, t ::: +[![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 + +The runnable example lives in the [`modular_account`][modular-account-example] directory of the `soroban-examples` repository. It requires soroban-sdk v27 or later. + +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. If you prefer not to install anything locally, launch the repo in [GitHub Codespaces][open-in-github-codespaces] or [Codeanywhere][open-in-code-anywhere]. + +Run the tests from the `modular_account` directory: + +```sh +cd modular_account +cargo test +``` + +Expected output: + +``` +running 1 test +test test::test_delegate_auth ... ok +``` + +[setup]: ../getting-started/setup.mdx +[modular-account-example]: https://github.com/stellar/soroban-examples/tree/main/modular_account + ## How it Works The example defines two contracts: @@ -43,7 +80,7 @@ The example defines two contracts: ### Data storage -```rust +```rust title="modular_account/src/lib.rs" #[contracterror] #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] #[repr(u32)] @@ -63,7 +100,7 @@ pub enum DataKey { ### Constructor -```rust +```rust title="modular_account/src/lib.rs" #[contractimpl] impl ModularAccount { pub fn __constructor(env: Env, public_key: BytesN<32>, signers: Vec
) { @@ -79,7 +116,7 @@ Both the account's public key and the list of registered delegate addresses are ### `__check_auth` with delegation -```rust +```rust title="modular_account/src/lib.rs" #[contractimpl] impl CustomAccountInterface for ModularAccount { type Signature = BytesN<64>; @@ -128,18 +165,18 @@ The pattern is always: ### Delegate account -```rust +```rust title="modular_account/src/test.rs" #[contractimpl] impl CustomAccountInterface for DelegateAccount { type Signature = BytesN<64>; - type Error = crate::Error; + type Error = soroban_sdk::Error; fn __check_auth( env: Env, signature_payload: Hash<32>, signature: BytesN<64>, - _auth_contexts: Vec, - ) -> Result<(), crate::Error> { + auth_contexts: Vec, + ) -> Result<(), soroban_sdk::Error> { let public_key: BytesN<32> = env.storage().instance().get(&DataKey::PublicKey).unwrap(); env.crypto() .ed25519_verify(&public_key, &signature_payload.into(), &signature); @@ -148,20 +185,23 @@ impl CustomAccountInterface for DelegateAccount { } ``` -`DelegateAccount` is a standard ed25519 account. It receives the same `signature_payload` as `ModularAccount`, verifies its own key, and returns `Ok(())`. Any address type (G- or C-) that implements `CustomAccountInterface` can be a delegate. +`DelegateAccount` is a standard ed25519 account. It receives the same `signature_payload` as `ModularAccount`, verifies its own key, and returns `Ok(())`. Any address type (G- or C-) that implements `CustomAccountInterface` can be a delegate, so in the example crate `DelegateAccount` is defined as a test fixture alongside the test rather than as a second deployable contract — a single Wasm can only export one `__check_auth`, and any account contract (even the [Simple Account example](./simple-account.mdx)) can serve as a delegate. ## Tests -The SDK ships a test in [`soroban-sdk/src/tests/delegate_auth.rs`][sdk-delegate-auth-test] that exercises the full delegation flow using `env.set_auths` with `SorobanAddressCredentialsWithDelegates`. +The example's [`modular_account/src/test.rs`][modular-account-test] exercises the full delegation flow using `env.set_auths` with `SorobanAddressCredentialsWithDelegates`. It is adapted from the SDK's own test at [`soroban-sdk/src/tests/delegate_auth.rs`][sdk-delegate-auth-test]. +[modular-account-test]: https://github.com/stellar/soroban-examples/tree/main/modular_account/src/test.rs [sdk-delegate-auth-test]: https://github.com/stellar/rs-soroban-sdk/blob/main/soroban-sdk/src/tests/delegate_auth.rs +So the test can observe what each account approved, both `ModularAccount` and `DelegateAccount` call a small `record_authorized_calls` helper from their `__check_auth` that appends each context's function name to a per-account `AuthorizedCalls` log. This is purely for test observability and is not part of the delegation pattern. + The test covers: - **Negative case** — attaching an unregistered delegate causes `ModularAccount` to return `UnknownDelegate`, and the call fails. - **Positive case** — both registered delegates sign the same payload; `ModularAccount` verifies its own key, then calls `delegate_auth` for each delegate; all three accounts record the protected function name in their `AuthorizedCalls` storage. -```rust +```rust title="modular_account/src/test.rs" // Both account and its delegates observe a single call to // `protected` in their authorization contexts. let expected = vec![&env, Symbol::new(&env, "protected")]; diff --git a/routes.txt b/routes.txt index dc4813f77d..d3715b995e 100644 --- a/routes.txt +++ b/routes.txt @@ -173,6 +173,7 @@ /docs/build/smart-contracts/example-contracts/liquidity-pool /docs/build/smart-contracts/example-contracts/logging /docs/build/smart-contracts/example-contracts/mint-lock +/docs/build/smart-contracts/example-contracts/modular-account /docs/build/smart-contracts/example-contracts/non-fungible-token /docs/build/smart-contracts/example-contracts/simple-account /docs/build/smart-contracts/example-contracts/single-offer-sale From 72db21e6de6416f012c6344a573fa3e13b4f119c Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:27:39 +0000 Subject: [PATCH 04/13] docs(soroban): restructure Delegate Auth example to match soroban-examples PR #407 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename modular-account.mdx → delegate-auth.mdx (title: "Delegate Auth") - Rewrite code section to use the canonical lib.rs from soroban-examples PR #407: #![no_std], mod test; split, inline comments matching the example repo - Add Run the Example section pointing to soroban-examples/modular_account - Add Build the Contract section with expected wasm output path - Update complex-account.mdx Further Reading link to new filename Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01GgpDVzaPCuCzUVUUyvdAke --- .../example-contracts/complex-account.mdx | 2 +- .../example-contracts/delegate-auth.mdx | 255 ++++++++++++++++++ .../example-contracts/modular-account.mdx | 230 ---------------- 3 files changed, 256 insertions(+), 231 deletions(-) create mode 100644 docs/build/smart-contracts/example-contracts/delegate-auth.mdx delete mode 100644 docs/build/smart-contracts/example-contracts/modular-account.mdx diff --git a/docs/build/smart-contracts/example-contracts/complex-account.mdx b/docs/build/smart-contracts/example-contracts/complex-account.mdx index 740724aa6d..4030ca01c3 100644 --- a/docs/build/smart-contracts/example-contracts/complex-account.mdx +++ b/docs/build/smart-contracts/example-contracts/complex-account.mdx @@ -456,4 +456,4 @@ assert_eq!( ## Further Reading -- [Modular Account example](./modular-account.mdx) — extends this example with CAP-71 auth delegation to registered delegate signers. +- [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 0000000000..f411068875 --- /dev/null +++ b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx @@ -0,0 +1,255 @@ +--- +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: 17 +--- + + + Delegate Auth + + + + + +This example builds on the [Complex Account example](./complex-account.mdx) to show **auth delegation**: instead of performing all signature verification itself, a `ModularAccount` contract forwards its `__check_auth` context to one or more registered delegate signers. Each delegate runs its own `__check_auth` independently. The user chooses which registered signers to authenticate with by attaching them to the transaction's authorization payload. + +Auth delegation was introduced in soroban-sdk v27 via [CAP-71](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0071.md). + +:::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. + +::: + +## 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_delegate_auth ... 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, BytesN, Env, Symbol, Vec, +}; + +#[contracterror] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ModularAccountError { + UnknownDelegate = 1, +} + +#[contracttype] +pub enum DataKey { + // The account's own ed25519 public key. + PublicKey, + // The set of addresses allowed to act as delegates for this account. + Signers, + // A log of the function names this account has approved, used by the tests + // to verify what was authorized. + AuthorizedCalls, +} + +#[contract] +pub struct ModularAccount; + +#[contractimpl] +impl ModularAccount { + pub fn __constructor(env: Env, public_key: BytesN<32>, signers: Vec
) { + env.storage() + .instance() + .set(&DataKey::PublicKey, &public_key); + env.storage().instance().set(&DataKey::Signers, &signers); + } +} + +#[contractimpl] +impl CustomAccountInterface for ModularAccount { + type Signature = BytesN<64>; + type Error = ModularAccountError; + + fn __check_auth( + env: Env, + signature_payload: Hash<32>, + signature: BytesN<64>, + auth_contexts: Vec, + ) -> Result<(), ModularAccountError> { + // Even though we use delegated authentication, the account can still + // perform the regular verification if necessary. + let public_key: BytesN<32> = env.storage().instance().get(&DataKey::PublicKey).unwrap(); + env.crypto() + .ed25519_verify(&public_key, &signature_payload.into(), &signature); + record_authorized_calls(&env, &auth_contexts); + + // The signers the user attached to the auth entry for this account's + // authorization. These are unsanitized user input, so the account must + // verify each one against its own registered signers below. + let delegates = env.custom_account().get_delegated_signers(); + + let signers: Vec
= env.storage().instance().get(&DataKey::Signers).unwrap(); + for delegate in delegates.iter() { + // The host can not validate the delegates, so the account has to + // check that each one is actually a registered signer. + if !signers.contains(&delegate) { + return Err(ModularAccountError::UnknownDelegate); + } + // Forward the current authentication context to the delegate. 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. + env.custom_account().delegate_auth(&delegate); + } + Ok(()) + } +} + +fn record_authorized_calls(env: &Env, auth_contexts: &Vec) { + let mut calls: Vec = env + .storage() + .instance() + .get(&DataKey::AuthorizedCalls) + .unwrap_or_else(|| Vec::new(env)); + for ctx in auth_contexts.iter() { + if let Context::Contract(c) = ctx { + calls.push_back(c.fn_name); + } + } + env.storage() + .instance() + .set(&DataKey::AuthorizedCalls, &calls); +} + +mod test; +``` + +## How it Works + +### Data storage + +`DataKey::Signers` persists the set of `Address` values that are allowed to act as delegates. `DataKey::PublicKey` stores the account's own ed25519 key for its own verification step. `DataKey::AuthorizedCalls` is a test helper — it records which function names this account approved so the test can assert the full delegation chain saw the same invocation. + +### Constructor + +Both the account's public key and the list of registered delegate addresses are written to instance storage at deployment. The delegate list is fixed at construction time; a more complete implementation might expose an admin function to update it. + +### `__check_auth`: own verification + +```rust +let public_key: BytesN<32> = env.storage().instance().get(&DataKey::PublicKey).unwrap(); +env.crypto() + .ed25519_verify(&public_key, &signature_payload.into(), &signature); +``` + +The account first verifies its own ed25519 signature. An account that wants to rely _entirely_ on its delegates could skip this step, but including it lets the account enforce its own key alongside any delegates. + +### `__check_auth`: delegation + +```rust +let delegates = env.custom_account().get_delegated_signers(); + +let signers: Vec
= env.storage().instance().get(&DataKey::Signers).unwrap(); +for delegate in delegates.iter() { + if !signers.contains(&delegate) { + return Err(ModularAccountError::UnknownDelegate); + } + 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 — the contract must verify each one is actually 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 pattern is always: **get → verify → forward**. + +## Tests + +Open [`modular_account/src/test.rs`]. The test defines two helper contracts: + +[`modular_account/src/test.rs`]: https://github.com/stellar/soroban-examples/tree/main/modular_account/src/test.rs + +- **`DelegateAccount`** — a simple ed25519 custom account that serves as a registered delegate signer. +- **`Protected`** — a contract with one function that calls `account.require_auth()`, giving the test something to authorize. + +The test registers one `ModularAccount` with two `DelegateAccount` instances as its signers, then exercises two scenarios: + +**Negative case** — attaching an unregistered delegate address causes `ModularAccount.__check_auth` to return `UnknownDelegate` and the call fails. + +**Positive case** — both registered delegates sign the same payload; `ModularAccount` verifies its own key and then calls `delegate_auth` for each delegate; the test asserts that the account and both delegates each recorded the `protected` function name in their `AuthorizedCalls` log. + +```rust +// Both account and its delegates observe a single call to `protected`. +let expected = vec![&env, Symbol::new(&env, "protected")]; +for addr in [&account, &delegate_a, &delegate_b] { + let calls: Vec = env.as_contract(addr, || { + env.storage().instance().get(&DataKey::AuthorizedCalls).unwrap() + }); + assert_eq!(calls, expected); +} +``` + +:::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/docs/build/smart-contracts/example-contracts/modular-account.mdx b/docs/build/smart-contracts/example-contracts/modular-account.mdx deleted file mode 100644 index 3a651e628b..0000000000 --- a/docs/build/smart-contracts/example-contracts/modular-account.mdx +++ /dev/null @@ -1,230 +0,0 @@ ---- -title: Modular Account -sidebar_label: Modular Account -description: A contract account that delegates authentication to registered signer contracts using CAP-71 auth delegation. -sidebar_position: 17 ---- - - - Modular Account - - - - - -This example extends the [Complex Account example](./complex-account.mdx) with **auth delegation**: instead of performing all signature verification itself, the `ModularAccount` contract forwards its `__check_auth` context to one or more registered signer contracts. 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). - -:::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 - -The runnable example lives in the [`modular_account`][modular-account-example] directory of the `soroban-examples` repository. It requires soroban-sdk v27 or later. - -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. If you prefer not to install anything locally, launch the repo in [GitHub Codespaces][open-in-github-codespaces] or [Codeanywhere][open-in-code-anywhere]. - -Run the tests from the `modular_account` directory: - -```sh -cd modular_account -cargo test -``` - -Expected output: - -``` -running 1 test -test test::test_delegate_auth ... ok -``` - -[setup]: ../getting-started/setup.mdx -[modular-account-example]: https://github.com/stellar/soroban-examples/tree/main/modular_account - -## How it Works - -The example defines two contracts: - -- **`ModularAccount`** — a custom account that stores a set of registered delegate signers and, on each `__check_auth` call, retrieves the user-attached delegates and forwards the auth context to each verified delegate. -- **`DelegateAccount`** — a simple custom account that performs ed25519 verification; it acts as the delegate signer. - -### Data storage - -```rust title="modular_account/src/lib.rs" -#[contracterror] -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] -#[repr(u32)] -pub enum ModularAccountError { - UnknownDelegate = 1, -} - -#[contracttype] -pub enum DataKey { - PublicKey, - Signers, - AuthorizedCalls, -} -``` - -`Signers` holds the set of `Address` values that are allowed to act as delegates for this account. `PublicKey` stores the account's own ed25519 key (used for its own verification step). - -### Constructor - -```rust title="modular_account/src/lib.rs" -#[contractimpl] -impl ModularAccount { - pub fn __constructor(env: Env, public_key: BytesN<32>, signers: Vec
) { - env.storage() - .instance() - .set(&DataKey::PublicKey, &public_key); - env.storage().instance().set(&DataKey::Signers, &signers); - } -} -``` - -Both the account's public key and the list of registered delegate addresses are persisted in instance storage at deployment. - -### `__check_auth` with delegation - -```rust title="modular_account/src/lib.rs" -#[contractimpl] -impl CustomAccountInterface for ModularAccount { - type Signature = BytesN<64>; - type Error = ModularAccountError; - - fn __check_auth( - env: Env, - signature_payload: Hash<32>, - signature: BytesN<64>, - auth_contexts: Vec, - ) -> Result<(), ModularAccountError> { - // Perform the account's own verification. - let public_key: BytesN<32> = env.storage().instance().get(&DataKey::PublicKey).unwrap(); - env.crypto() - .ed25519_verify(&public_key, &signature_payload.into(), &signature); - - // Retrieve the delegate addresses the user attached to the auth entry. - let delegates = env.custom_account().get_delegated_signers(); - - let signers: Vec
= env.storage().instance().get(&DataKey::Signers).unwrap(); - for delegate in delegates.iter() { - // The host does not validate delegates — the contract must. - if !signers.contains(&delegate) { - return Err(ModularAccountError::UnknownDelegate); - } - // Forward the current authorization context to the verified delegate. - env.custom_account().delegate_auth(&delegate); - } - Ok(()) - } -} -``` - -Two new methods on `env.custom_account()` drive the delegation: - -- **`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 against its own registered signers 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. - -Both methods may only be called from within `__check_auth`. Calling either outside of `__check_auth` panics. - -The pattern is always: - -1. Call `get_delegated_signers()` to get the user-supplied delegates. -2. Verify each delegate is registered with this account. -3. Call `delegate_auth(&delegate)` for each verified delegate. - -### Delegate account - -```rust title="modular_account/src/test.rs" -#[contractimpl] -impl CustomAccountInterface for DelegateAccount { - type Signature = BytesN<64>; - type Error = soroban_sdk::Error; - - fn __check_auth( - env: Env, - signature_payload: Hash<32>, - signature: BytesN<64>, - auth_contexts: Vec, - ) -> Result<(), soroban_sdk::Error> { - let public_key: BytesN<32> = env.storage().instance().get(&DataKey::PublicKey).unwrap(); - env.crypto() - .ed25519_verify(&public_key, &signature_payload.into(), &signature); - Ok(()) - } -} -``` - -`DelegateAccount` is a standard ed25519 account. It receives the same `signature_payload` as `ModularAccount`, verifies its own key, and returns `Ok(())`. Any address type (G- or C-) that implements `CustomAccountInterface` can be a delegate, so in the example crate `DelegateAccount` is defined as a test fixture alongside the test rather than as a second deployable contract — a single Wasm can only export one `__check_auth`, and any account contract (even the [Simple Account example](./simple-account.mdx)) can serve as a delegate. - -## Tests - -The example's [`modular_account/src/test.rs`][modular-account-test] exercises the full delegation flow using `env.set_auths` with `SorobanAddressCredentialsWithDelegates`. It is adapted from the SDK's own test at [`soroban-sdk/src/tests/delegate_auth.rs`][sdk-delegate-auth-test]. - -[modular-account-test]: https://github.com/stellar/soroban-examples/tree/main/modular_account/src/test.rs -[sdk-delegate-auth-test]: https://github.com/stellar/rs-soroban-sdk/blob/main/soroban-sdk/src/tests/delegate_auth.rs - -So the test can observe what each account approved, both `ModularAccount` and `DelegateAccount` call a small `record_authorized_calls` helper from their `__check_auth` that appends each context's function name to a per-account `AuthorizedCalls` log. This is purely for test observability and is not part of the delegation pattern. - -The test covers: - -- **Negative case** — attaching an unregistered delegate causes `ModularAccount` to return `UnknownDelegate`, and the call fails. -- **Positive case** — both registered delegates sign the same payload; `ModularAccount` verifies its own key, then calls `delegate_auth` for each delegate; all three accounts record the protected function name in their `AuthorizedCalls` storage. - -```rust title="modular_account/src/test.rs" -// Both account and its delegates observe a single call to -// `protected` in their authorization contexts. -let expected = vec![&env, Symbol::new(&env, "protected")]; -for addr in [&account, &delegate_a, &delegate_b] { - let calls: Vec = env.as_contract(addr, || { - env.storage() - .instance() - .get(&DataKey::AuthorizedCalls) - .unwrap() - }); - assert_eq!(calls, expected); -} -``` - -:::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. - -::: - -## 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`. From 168c3354277693c0db556cf1b3f327e6b4e9eccf Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:35:35 +0000 Subject: [PATCH 05/13] regenerate routes.txt for delegate-auth page rename --- routes.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes.txt b/routes.txt index d3715b995e..afe489e60d 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 @@ -173,7 +174,6 @@ /docs/build/smart-contracts/example-contracts/liquidity-pool /docs/build/smart-contracts/example-contracts/logging /docs/build/smart-contracts/example-contracts/mint-lock -/docs/build/smart-contracts/example-contracts/modular-account /docs/build/smart-contracts/example-contracts/non-fungible-token /docs/build/smart-contracts/example-contracts/simple-account /docs/build/smart-contracts/example-contracts/single-offer-sale From 3d49b291b37d84014a21d3cbeaf38249438e3ae0 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Thu, 18 Jun 2026 04:04:04 +0000 Subject: [PATCH 06/13] align delegate-auth docs with example changes --- .../example-contracts/delegate-auth.mdx | 78 +++++++------------ 1 file changed, 29 insertions(+), 49 deletions(-) diff --git a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx index f411068875..b6a5727fad 100644 --- a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx +++ b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx @@ -18,7 +18,7 @@ sidebar_position: 17 /> -This example builds on the [Complex Account example](./complex-account.mdx) to show **auth delegation**: instead of performing all signature verification itself, a `ModularAccount` contract forwards its `__check_auth` context to one or more registered delegate signers. Each delegate runs its own `__check_auth` independently. The user chooses which registered signers to authenticate with by attaching them to the transaction's authorization payload. +This example builds on the [Complex Account example](./complex-account.mdx) to show **auth delegation**: instead of verifying any signature itself, a `ModularAccount` contract forwards its `__check_auth` context entirely to one or more registered delegate signers. Each delegate runs its own `__check_auth` independently. The user chooses which registered signers to authenticate with by attaching them to the transaction's authorization payload. Auth delegation was introduced in soroban-sdk v27 via [CAP-71](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0071.md). @@ -68,7 +68,7 @@ use soroban_sdk::{ auth::{Context, CustomAccountInterface}, contract, contracterror, contractimpl, contracttype, crypto::Hash, - Address, BytesN, Env, Symbol, Vec, + Address, Env, Vec, }; #[contracterror] @@ -80,13 +80,11 @@ pub enum ModularAccountError { #[contracttype] pub enum DataKey { - // The account's own ed25519 public key. - PublicKey, // The set of addresses allowed to act as delegates for this account. Signers, - // A log of the function names this account has approved, used by the tests + // The authorization contexts this account has approved, used by the tests // to verify what was authorized. - AuthorizedCalls, + AuthorizedContexts, } #[contract] @@ -94,31 +92,25 @@ pub struct ModularAccount; #[contractimpl] impl ModularAccount { - pub fn __constructor(env: Env, public_key: BytesN<32>, signers: Vec
) { - env.storage() - .instance() - .set(&DataKey::PublicKey, &public_key); + pub fn __constructor(env: Env, signers: Vec
) { env.storage().instance().set(&DataKey::Signers, &signers); } } #[contractimpl] impl CustomAccountInterface for ModularAccount { - type Signature = BytesN<64>; + // This account holds no key of its own; it authenticates purely by + // delegating, so it carries no signature. + type Signature = (); type Error = ModularAccountError; fn __check_auth( env: Env, - signature_payload: Hash<32>, - signature: BytesN<64>, + _signature_payload: Hash<32>, + _signature: (), auth_contexts: Vec, ) -> Result<(), ModularAccountError> { - // Even though we use delegated authentication, the account can still - // perform the regular verification if necessary. - let public_key: BytesN<32> = env.storage().instance().get(&DataKey::PublicKey).unwrap(); - env.crypto() - .ed25519_verify(&public_key, &signature_payload.into(), &signature); - record_authorized_calls(&env, &auth_contexts); + record_authorized_contexts(&env, &auth_contexts); // The signers the user attached to the auth entry for this account's // authorization. These are unsanitized user input, so the account must @@ -143,20 +135,10 @@ impl CustomAccountInterface for ModularAccount { } } -fn record_authorized_calls(env: &Env, auth_contexts: &Vec) { - let mut calls: Vec = env - .storage() - .instance() - .get(&DataKey::AuthorizedCalls) - .unwrap_or_else(|| Vec::new(env)); - for ctx in auth_contexts.iter() { - if let Context::Contract(c) = ctx { - calls.push_back(c.fn_name); - } - } +fn record_authorized_contexts(env: &Env, auth_contexts: &Vec) { env.storage() .instance() - .set(&DataKey::AuthorizedCalls, &calls); + .set(&DataKey::AuthorizedContexts, auth_contexts); } mod test; @@ -166,21 +148,11 @@ mod test; ### Data storage -`DataKey::Signers` persists the set of `Address` values that are allowed to act as delegates. `DataKey::PublicKey` stores the account's own ed25519 key for its own verification step. `DataKey::AuthorizedCalls` is a test helper — it records which function names this account approved so the test can assert the full delegation chain saw the same invocation. +`DataKey::Signers` persists the set of `Address` values that are allowed to act as delegates. `DataKey::AuthorizedContexts` is a test helper — it records the authorization contexts this account approved so the test can assert the full delegation chain saw the same invocation. The account holds no key of its own; it authenticates purely by delegating, so `type Signature = ()`. ### Constructor -Both the account's public key and the list of registered delegate addresses are written to instance storage at deployment. The delegate list is fixed at construction time; a more complete implementation might expose an admin function to update it. - -### `__check_auth`: own verification - -```rust -let public_key: BytesN<32> = env.storage().instance().get(&DataKey::PublicKey).unwrap(); -env.crypto() - .ed25519_verify(&public_key, &signature_payload.into(), &signature); -``` - -The account first verifies its own ed25519 signature. An account that wants to rely _entirely_ on its delegates could skip this step, but including it lets the account enforce its own key alongside any delegates. +The list of registered delegate addresses is written to instance storage at deployment. The delegate list is fixed at construction time; a more complete implementation might expose an admin function to update it. ### `__check_auth`: delegation @@ -216,16 +188,24 @@ The test registers one `ModularAccount` with two `DelegateAccount` instances as **Negative case** — attaching an unregistered delegate address causes `ModularAccount.__check_auth` to return `UnknownDelegate` and the call fails. -**Positive case** — both registered delegates sign the same payload; `ModularAccount` verifies its own key and then calls `delegate_auth` for each delegate; the test asserts that the account and both delegates each recorded the `protected` function name in their `AuthorizedCalls` log. +**Positive case** — both registered delegates sign the same payload; `ModularAccount` calls `delegate_auth` for each delegate; the test asserts that the account and both delegates each recorded the same authorization context — the full `Context::Contract`, including the contract address, function name, and arguments, not just the function name. ```rust -// Both account and its delegates observe a single call to `protected`. -let expected = vec![&env, Symbol::new(&env, "protected")]; +// The account and both delegates each observe the same single authorization +// context: the call to `protected` with the account as its argument. +let expected = vec![ + &env, + Context::Contract(ContractContext { + contract: protected.clone(), + fn_name: Symbol::new(&env, "protected"), + args: (account.clone(),).into_val(&env), + }), +]; for addr in [&account, &delegate_a, &delegate_b] { - let calls: Vec = env.as_contract(addr, || { - env.storage().instance().get(&DataKey::AuthorizedCalls).unwrap() + let contexts: Vec = env.as_contract(addr, || { + env.storage().instance().get(&DataKey::AuthorizedContexts).unwrap() }); - assert_eq!(calls, expected); + assert!(contexts == expected); } ``` From bc009132dabdb2055e6c9bb9c24258ad169108f9 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Thu, 18 Jun 2026 04:13:11 +0000 Subject: [PATCH 07/13] align delegate-auth docs with rewritten example --- .../example-contracts/delegate-auth.mdx | 144 ++++++++++-------- 1 file changed, 83 insertions(+), 61 deletions(-) diff --git a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx index b6a5727fad..afd3e819c2 100644 --- a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx +++ b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx @@ -54,7 +54,7 @@ Expected output: ``` running 1 test -test test::test_delegate_auth ... ok +test test::test ... ok ``` [setup]: ../getting-started/setup.mdx @@ -74,17 +74,15 @@ use soroban_sdk::{ #[contracterror] #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] #[repr(u32)] -pub enum ModularAccountError { +pub enum Error { UnknownDelegate = 1, } #[contracttype] -pub enum DataKey { - // The set of addresses allowed to act as delegates for this account. - Signers, - // The authorization contexts this account has approved, used by the tests - // to verify what was authorized. - AuthorizedContexts, +enum ModularAccountDataKey { + // Marks an address as a signer allowed to authenticate for the + // modular account. + Signer(Address), } #[contract] @@ -92,55 +90,51 @@ pub struct ModularAccount; #[contractimpl] impl ModularAccount { + // Registers the addresses allowed to authenticate for this account. pub fn __constructor(env: Env, signers: Vec
) { - env.storage().instance().set(&DataKey::Signers, &signers); + for signer in signers.iter() { + env.storage() + .persistent() + .set(&ModularAccountDataKey::Signer(signer), &()); + } } } #[contractimpl] impl CustomAccountInterface for ModularAccount { - // This account holds no key of its own; it authenticates purely by - // delegating, so it carries no signature. + // The account verifies no signature of its own, so it carries no + // signature to check. type Signature = (); - type Error = ModularAccountError; + type Error = Error; fn __check_auth( env: Env, _signature_payload: Hash<32>, - _signature: (), - auth_contexts: Vec, - ) -> Result<(), ModularAccountError> { - record_authorized_contexts(&env, &auth_contexts); - - // The signers the user attached to the auth entry for this account's - // authorization. These are unsanitized user input, so the account must - // verify each one against its own registered signers below. + _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(); - let signers: Vec
= env.storage().instance().get(&DataKey::Signers).unwrap(); + // Check if the delegates are accepted by the modular account. for delegate in delegates.iter() { - // The host can not validate the delegates, so the account has to - // check that each one is actually a registered signer. - if !signers.contains(&delegate) { - return Err(ModularAccountError::UnknownDelegate); + if !env + .storage() + .persistent() + .has(&ModularAccountDataKey::Signer(delegate.clone())) + { + return Err(Error::UnknownDelegate); } - // Forward the current authentication context to the delegate. 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. + } + // Forward the current authorization to each delegate. + for delegate in delegates.iter() { env.custom_account().delegate_auth(&delegate); } Ok(()) } } -fn record_authorized_contexts(env: &Env, auth_contexts: &Vec) { - env.storage() - .instance() - .set(&DataKey::AuthorizedContexts, auth_contexts); -} - mod test; ``` @@ -148,22 +142,29 @@ mod test; ### Data storage -`DataKey::Signers` persists the set of `Address` values that are allowed to act as delegates. `DataKey::AuthorizedContexts` is a test helper — it records the authorization contexts this account approved so the test can assert the full delegation chain saw the same invocation. The account holds no key of its own; it authenticates purely by delegating, so `type Signature = ()`. +Each allowed signer is stored as a `ModularAccountDataKey::Signer(address)` key in persistent storage. The account checks delegates against these entries before forwarding to them. The account holds no key of its own and verifies no signature, so `type Signature = ()`. ### Constructor -The list of registered delegate addresses is written to instance storage at deployment. The delegate list is fixed at construction time; a more complete implementation might expose an admin function to update it. +The constructor records each allowed signer address as a `Signer(address)` key. The signer set is fixed at construction time; a more complete implementation might expose an admin function to update it. ### `__check_auth`: delegation ```rust let delegates = env.custom_account().get_delegated_signers(); -let signers: Vec
= env.storage().instance().get(&DataKey::Signers).unwrap(); +// Check if the delegates are accepted by the modular account. for delegate in delegates.iter() { - if !signers.contains(&delegate) { - return Err(ModularAccountError::UnknownDelegate); + 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); } ``` @@ -181,36 +182,57 @@ Open [`modular_account/src/test.rs`]. The test defines two helper contracts: [`modular_account/src/test.rs`]: https://github.com/stellar/soroban-examples/tree/main/modular_account/src/test.rs -- **`DelegateAccount`** — a simple ed25519 custom account that serves as a registered delegate signer. +- **`DelegateAccount`** — a custom account that always approves the auth request and stores a copy of the auth context it received, so the test can verify the delegation reached it. It verifies no signature of its own (`type Signature = ()`). - **`Protected`** — a contract with one function that calls `account.require_auth()`, giving the test something to authorize. -The test registers one `ModularAccount` with two `DelegateAccount` instances as its signers, then exercises two scenarios: +The test registers one `ModularAccount` with a single `DelegateAccount` as its signer and calls `protected`. The authorization entry, built with `env.set_auths`, uses `AddressWithDelegates` credentials: the account attaches the delegate as a delegated signer. Because no contract verifies a signature, the signatures are `ScVal::Void`. -**Negative case** — attaching an unregistered delegate address causes `ModularAccount.__check_auth` to return `UnknownDelegate` and the call fails. +It then asserts two things. First, that the account authorized the call — delegating to `delegate` is not recorded as a separate authorization: -**Positive case** — both registered delegates sign the same payload; `ModularAccount` calls `delegate_auth` for each delegate; the test asserts that the account and both delegates each recorded the same authorization context — the full `Context::Contract`, including the contract address, function name, and arguments, not just the function name. +```rust +assert_eq!( + env.auths(), + std::vec![( + account.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + protected.clone(), + Symbol::new(&env, "protected"), + (account.clone(),).into_val(&env), + )), + sub_invocations: std::vec![], + } + )] +); +``` + +Second, that the delegation actually reached `delegate`, which recorded the full authorization context it approved — the entire `Context::Contract`, including the contract address, function name, and arguments, not just the function name: ```rust -// The account and both delegates each observe the same single authorization -// context: the call to `protected` with the account as its argument. -let expected = vec![ - &env, - Context::Contract(ContractContext { - contract: protected.clone(), - fn_name: Symbol::new(&env, "protected"), - args: (account.clone(),).into_val(&env), - }), -]; -for addr in [&account, &delegate_a, &delegate_b] { - let contexts: Vec = env.as_contract(addr, || { - env.storage().instance().get(&DataKey::AuthorizedContexts).unwrap() - }); - assert!(contexts == expected); -} +let approved: Vec = env.as_contract(&delegate, || { + env.storage().instance().get(&DelegateAccountDataKey::ApprovedContexts).unwrap() +}); +assert_eq!( + approved, + vec![ + &env, + Context::Contract(ContractContext { + contract: protected.clone(), + fn_name: Symbol::new(&env, "protected"), + args: (account.clone(),).into_val(&env), + }), + ], +); ``` :::note +`Context` does not implement `Debug` in soroban-sdk 27.0.0-rc.1, so the runnable example compares the approved contexts with `assert!(approved == ...)` rather than `assert_eq!`. + +::: + +:::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. ::: From a35a85c35ab24403b9b3e4b135ebc475a71c3646 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:35:32 +1000 Subject: [PATCH 08/13] Update delegate-auth.mdx --- .../example-contracts/delegate-auth.mdx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx index afd3e819c2..0a1d90afc3 100644 --- a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx +++ b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx @@ -171,19 +171,17 @@ for delegate in delegates.iter() { 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 — the contract must verify each one is actually registered before forwarding. +- **`get_delegated_signers() -> Vec
`** returns the delegate addresses the user attached to the transaction's auth entry. The contract must verify each one is actually allowed to be delegated to 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 pattern is always: **get → verify → forward**. - ## Tests Open [`modular_account/src/test.rs`]. The test defines two helper contracts: [`modular_account/src/test.rs`]: https://github.com/stellar/soroban-examples/tree/main/modular_account/src/test.rs -- **`DelegateAccount`** — a custom account that always approves the auth request and stores a copy of the auth context it received, so the test can verify the delegation reached it. It verifies no signature of its own (`type Signature = ()`). -- **`Protected`** — a contract with one function that calls `account.require_auth()`, giving the test something to authorize. +- **`DelegateAccount`** — a custom account that always approves the auth request and stores a copy of the auth context it received, so the test can verify the delegation reached it. It verifies no signature of its own (`type Signature = ()`). A real custom account would perform signature verification or further delegate to another account. +- **`Protected`** — a contract with one function that calls `account.require_auth()`, giving the test something to authorize. A real contract would have other logic occur after the `require_auth` call. The test registers one `ModularAccount` with a single `DelegateAccount` as its signer and calls `protected`. The authorization entry, built with `env.set_auths`, uses `AddressWithDelegates` credentials: the account attaches the delegate as a delegated signer. Because no contract verifies a signature, the signatures are `ScVal::Void`. @@ -227,12 +225,6 @@ assert_eq!( :::note -`Context` does not implement `Debug` in soroban-sdk 27.0.0-rc.1, so the runnable example compares the approved contexts with `assert!(approved == ...)` rather than `assert_eq!`. - -::: - -:::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. ::: From e1de0801764715c44a731cffc700009d85c9d0f5 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:54:32 +0000 Subject: [PATCH 09/13] docs(soroban): align Delegate Auth example with soroban-examples PR #407 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update delegate-auth.mdx to match the canonical example in stellar/soroban-examples#407 rather than the SDK unit test: - type Signature = () — account carries no own signature, relies entirely on delegates - Per-signer persistent storage (Signer(Address) key) instead of a stored Vec
- Constructor takes only signers, no public key - Two-pass __check_auth: validate all delegates first, then forward - Test section updated to match the simpler DelegateAccount fixture (Signature = (), always approves, stores ApprovedContexts) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01GgpDVzaPCuCzUVUUyvdAke --- .../example-contracts/delegate-auth.mdx | 121 ++++++++++-------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx index 0a1d90afc3..1983bb425d 100644 --- a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx +++ b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx @@ -18,9 +18,9 @@ sidebar_position: 17 /> -This example builds on the [Complex Account example](./complex-account.mdx) to show **auth delegation**: instead of verifying any signature itself, a `ModularAccount` contract forwards its `__check_auth` context entirely to one or more registered delegate signers. Each delegate runs its own `__check_auth` independently. The user chooses which registered signers to authenticate with by attaching them to the transaction's authorization payload. +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). +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 @@ -34,6 +34,13 @@ While contract accounts are supported by the Stellar protocol and Soroban SDK, t ::: +[![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. @@ -127,10 +134,12 @@ impl CustomAccountInterface for ModularAccount { return Err(Error::UnknownDelegate); } } + // Forward the current authorization to each delegate. for delegate in delegates.iter() { env.custom_account().delegate_auth(&delegate); } + Ok(()) } } @@ -140,30 +149,43 @@ mod test; ## How it Works -### Data storage +### Storage layout -Each allowed signer is stored as a `ModularAccountDataKey::Signer(address)` key in persistent storage. The account checks delegates against these entries before forwarding to them. The account holds no key of its own and verifies no signature, so `type Signature = ()`. +`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 -The constructor records each allowed signer address as a `Signer(address)` key. The signer set is fixed at construction time; a more complete implementation might expose an admin function to update it. +```rust +pub fn __constructor(env: Env, signers: Vec
) { + for signer in signers.iter() { + env.storage() + .persistent() + .set(&ModularAccountDataKey::Signer(signer), &()); + } +} +``` -### `__check_auth`: delegation +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(); -// Check if the delegates are accepted by the modular account. for delegate in delegates.iter() { - if !env - .storage() - .persistent() - .has(&ModularAccountDataKey::Signer(delegate.clone())) - { + 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); } @@ -171,57 +193,52 @@ for delegate in delegates.iter() { 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. The contract must verify each one is actually allowed to be delegated to before forwarding. +- **`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. -## Tests +The example 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. -Open [`modular_account/src/test.rs`]. The test defines two helper contracts: +## Tests -[`modular_account/src/test.rs`]: https://github.com/stellar/soroban-examples/tree/main/modular_account/src/test.rs +Open [`modular_account/src/test.rs`][test-rs]. The test defines two helper contracts: -- **`DelegateAccount`** — a custom account that always approves the auth request and stores a copy of the auth context it received, so the test can verify the delegation reached it. It verifies no signature of its own (`type Signature = ()`). A real custom account would perform signature verification or further delegate to another account. -- **`Protected`** — a contract with one function that calls `account.require_auth()`, giving the test something to authorize. A real contract would have other logic occur after the `require_auth` call. +[test-rs]: https://github.com/stellar/soroban-examples/tree/main/modular_account/src/test.rs -The test registers one `ModularAccount` with a single `DelegateAccount` as its signer and calls `protected`. The authorization entry, built with `env.set_auths`, uses `AddressWithDelegates` credentials: the account attaches the delegate as a delegated signer. Because no contract verifies a signature, the signatures are `ScVal::Void`. +- **`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()`. -It then asserts two things. First, that the account authorized the call — delegating to `delegate` is not recorded as a separate authorization: +The test registers one `ModularAccount` with one `DelegateAccount` as its allowed signer, builds an `AddressWithDelegates` auth entry by hand, and calls `Protected::protected`: ```rust -assert_eq!( - env.auths(), - std::vec![( - account.clone(), - AuthorizedInvocation { - function: AuthorizedFunction::Contract(( - protected.clone(), - Symbol::new(&env, "protected"), - (account.clone(),).into_val(&env), - )), - sub_invocations: std::vec![], - } - )] -); +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); ``` -Second, that the delegation actually reached `delegate`, which recorded the full authorization context it approved — the entire `Context::Contract`, including the contract address, function name, and arguments, not just the function name: +After the call, the test asserts two things: -```rust -let approved: Vec = env.as_contract(&delegate, || { - env.storage().instance().get(&DelegateAccountDataKey::ApprovedContexts).unwrap() -}); -assert_eq!( - approved, - vec![ - &env, - Context::Contract(ContractContext { - contract: protected.clone(), - fn_name: Symbol::new(&env, "protected"), - args: (account.clone(),).into_val(&env), - }), - ], -); -``` +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 From 0dd27f8f05ce0cb1f229bb4852e8b41b9bbd2642 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:46:50 +0000 Subject: [PATCH 10/13] add empty-delegate rejection to delegate-auth example --- .../example-contracts/delegate-auth.mdx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx index 1983bb425d..b863e56b17 100644 --- a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx +++ b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx @@ -124,6 +124,12 @@ impl CustomAccountInterface for ModularAccount { // 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 @@ -180,6 +186,10 @@ type Signature = (); ```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); @@ -196,7 +206,7 @@ Two methods on `env.custom_account()` drive delegation. Both may only be called - **`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 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. +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 From ba98591a9fb03b3fe1a4ac7ef6939ba8d5576c11 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:06:11 +0000 Subject: [PATCH 11/13] bump delegate-auth sidebar_position to 18 to avoid collision --- docs/build/smart-contracts/example-contracts/delegate-auth.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx index b863e56b17..5f449f9ea6 100644 --- a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx +++ b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx @@ -2,7 +2,7 @@ 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: 17 +sidebar_position: 18 --- From 18b3dd23f59713e98df31846e052a36b279548c3 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:10:38 +0000 Subject: [PATCH 12/13] use InsufficientDelegates error in delegate-auth example --- .../smart-contracts/example-contracts/delegate-auth.mdx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx index 5f449f9ea6..eea43f2e4b 100644 --- a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx +++ b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx @@ -83,6 +83,7 @@ use soroban_sdk::{ #[repr(u32)] pub enum Error { UnknownDelegate = 1, + InsufficientDelegates = 2, } #[contracttype] @@ -125,9 +126,10 @@ impl CustomAccountInterface for ModularAccount { 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. + // nothing and be effectively unauthenticated, so reject it. A real + // account might require more than one delegate to meet a threshold. if delegates.is_empty() { - return Err(Error::UnknownDelegate); + return Err(Error::InsufficientDelegates); } // Check if the delegates are accepted by the modular account. @@ -187,7 +189,7 @@ type Signature = (); let delegates = env.custom_account().get_delegated_signers(); if delegates.is_empty() { - return Err(Error::UnknownDelegate); + return Err(Error::InsufficientDelegates); } for delegate in delegates.iter() { From 7dfc6e452d4f52846f6a5cd3b3ba8bd68c39dacc Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:23:34 +0000 Subject: [PATCH 13/13] align delegate-auth docs with merged example tests --- .../smart-contracts/example-contracts/delegate-auth.mdx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx index eea43f2e4b..0a46108de3 100644 --- a/docs/build/smart-contracts/example-contracts/delegate-auth.mdx +++ b/docs/build/smart-contracts/example-contracts/delegate-auth.mdx @@ -60,8 +60,12 @@ make test Expected output: ``` -running 1 test +running 3 tests +test test::test_empty_delegates_is_rejected ... ok +test test::test_unknown_delegate_is_rejected ... ok test test::test ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out ``` [setup]: ../getting-started/setup.mdx @@ -252,6 +256,8 @@ 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. +Two further tests cover the rejection paths: `test_unknown_delegate_is_rejected` attaches a delegate the account never registered, and `test_empty_delegates_is_rejected` attaches none at all. In both, `Protected::protected` fails. The caller only sees a generic `Error(Auth, InvalidAction)`, because the host escalates any failed `__check_auth` to that — the account's own error code does not propagate to the return value. To assert the account rejected the call for its own reason, the tests read the host's diagnostic events with `env.host().get_diagnostic_events()` and match the recorded `Error(Contract, #n)` — `UnknownDelegate` and `InsufficientDelegates` respectively. + :::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.