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. For a single-key account see the Simple Account example, and for a multi-sig account with spend-limit policies see the Complex Account example.
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.
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
- Finish the Setup checklist to install the Stellar CLI, Rust target, and required environment variables.
- Clone the
soroban-examplesrepository:
git clone https://github.com/stellar/soroban-examples
- Run the tests from the
modular_accountdirectory:
cd modular_account
make test
Expected output:
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
Code
#![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,
InsufficientDelegates = 2,
}
#[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<Address>) {
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<Context>,
) -> 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. A real
// account might require more than one delegate to meet a threshold.
if delegates.is_empty() {
return Err(Error::InsufficientDelegates);
}
// 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
pub fn __constructor(env: Env, signers: Vec<Address>) {
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 = ()
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
let delegates = env.custom_account().get_delegated_signers();
if delegates.is_empty() {
return Err(Error::InsufficientDelegates);
}
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<Address>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_authauthorization context toaddress. Unlikerequire_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. The test defines two helper contracts:
DelegateAccount— a simple custom account withtype Signature = ()that always approves, and stores the receivedauth_contextsin instance storage for later assertion.Protected— a contract with one function that callsaccount.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:
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:
env.auths()shows only the account authorizingprotected— delegating toDelegateAccountis not recorded as a separate top-level authorization.DelegateAccount'sApprovedContextsstorage 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.
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
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 — the minimal single-key baseline.
- Complex Account example — multisig and spend-limit policies.
- CAP-71 specification — the protocol change that introduced auth delegation.
CustomAccountAPI docs — reference forget_delegated_signersanddelegate_auth.