Delegate Auth
This example builds on the Complex Account example 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.
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 1 test
test test::test ... ok
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,
}
#[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();
// 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
Data storage
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 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
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()))
{
return Err(Error::UnknownDelegate);
}
}
// Forward the current authorization to each delegate.
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. The contract must verify each one is actually allowed to be delegated to 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.
Tests
Open modular_account/src/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 callsaccount.require_auth(), giving the test something to authorize. A real contract would have other logic occur after therequire_authcall.
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.
It then asserts two things. First, that the account authorized the call — delegating to delegate is not recorded as a separate authorization:
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:
let approved: Vec<Context> = 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),
}),
],
);
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.