Skip to main content

Custom Account

The custom account example demonstrates how to implement a simple account contract that supports multisig and customizable authorization policies. This account contract can be used with the Soroban auth framework, so that any time an Address pointing at this contract instance is used, the custom logic implemented here is applied.

Custom accounts are exclusive to Soroban and can't be used to perform other Stellar operations.

danger

Implementing a custom account contract 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 custom accounts are supported by the Stellar protocol and Soroban SDK, the full client support (such as transaction simulation) is still under development.

Open in Gitpod

Run the Example

First go through the Setup process to get your development environment configured, then clone the v20.0.0 tag of soroban-examples repository:

git clone -b v20.0.0 https://github.com/stellar/soroban-examples

Or, skip the development environment setup and open this example in Gitpod.

To run the tests for the example use cargo test.

cargo test -p soroban-account-contract

You should see the output:

running 1 test
test test::test_token_auth ... ok

How it Works

Open the account/src/lib.rs file to follow along.

Account contracts implement a special function __check_auth that takes the signature payload, signatures and authorization context. The function should error if auth is declined, otherwise auth will be approved.

This example contract uses ed25519 keys for signature verification and supports multiple equally weighted signers. It also implements a policy that allows setting per-token limits on transfers. The token can be spent beyond the limit only if every signature is provided.

For example, the user may initialize this contract with 2 keys and introduce 100 USDC spend limit. This way they can use a single key to sign their contract invocations and be sure that even if they sign a malicious transaction they won't spend more than 100 USDC.

Initialization

#[contracttype]
#[derive(Clone)]
enum DataKey {
SignerCnt,
Signer(BytesN<32>),
SpendLimit(BytesN<32>),
}
...
// Initialize the contract with a list of ed25519 public key ('signers').
pub fn init(env: Env, signers: Vec<BytesN<32>>) {
// In reality this would need some additional validation on signers
// (deduplication etc.).
for signer in signers.iter() {
env.storage().instance().set(&DataKey::Signer(signer), &());
}
env.storage()
.instance()
.set(&DataKey::SignerCnt, &signers.len());
}

This account contract needs to work with the public keys explicitly. Here we initialize the contract with ed25519 keys.

Policy modification

// Adds a limit on any token transfers that aren't signed by every signer.
pub fn add_limit(env: Env, token: BytesN<32>, limit: i128) {
// The current contract address is the account contract address and has
// the same semantics for `require_auth` call as any other account
// contract address.
// Note, that if a contract *invokes* another contract, then it would
// authorize the call on its own behalf and that wouldn't require any
// user-side verification.
env.current_contract_address().require_auth();
env.storage()
.instance()
.set(&DataKey::SpendLimit(token), &limit);
}

This function allows users to set and modify the per-token spend limit described above. The neat trick here is that require_auth can be used for the current_contract_address(), i.e. the account contract may be used to verify authorization for its own administrative functions. This way there is no need to write duplicate authorization and authentication logic.

__check_auth

pub fn __check_auth(
env: Env,
signature_payload: BytesN<32>,
signatures: Vec<Signature>,
auth_context: Vec<Context>,
) -> Result<(), AccError> {
// Perform authentication.
authenticate(&env, &signature_payload, &signatures)?;

let tot_signers: u32 = env
.storage()
.instance()
.get::<_, u32>(&DataKey::SignerCnt)
.unwrap();
let all_signed = tot_signers == signatures.len();

let curr_contract = env.current_contract_address();

// This is a map for tracking the token spend limits per token. This
// makes sure that if e.g. multiple `transfer` calls are being authorized
// for the same token we still respect the limit for the total
// transferred amount (and not the 'per-call' limits).
let mut spend_left_per_token = Map::<Address, i128>::new(&env);
// Verify the authorization policy.
for context in auth_context.iter() {
verify_authorization_policy(
&env,
&context,
&curr_contract,
all_signed,
&mut spend_left_per_token,
)?;
}
Ok(())
}

__check_auth is a special function that account contracts implement. It will be called by the Soroban environment every time require_auth or require_auth_for_args is called for the address of the account contract.

Here it is implemented in two steps. First, authentication is performed using the signature payload and a vector of signatures. Second, authorization policy is enforced using the auth_context vector. This vector contains all the contract calls that are being authorized by the provided signatures.

__check_auth is a reserved function and can only be called by the Soroban environment in response to a call to require_auth. Any direct call to __check_auth will fail. This makes it safe to write to the account contract storage from __check_auth, as it's guaranteed to not be called in unexpected context. In this example it's possible to persist the spend limits without worrying that they'll be exhausted via a bad actor calling __check_auth directly.

Authentication

fn authenticate(
env: &Env,
signature_payload: &BytesN<32>,
signatures: &Vec<Signature>,
) -> Result<(), AccError> {
for i in 0..signatures.len() {
let signature = signatures.get_unchecked(i);
if i > 0 {
let prev_signature = signatures.get_unchecked(i - 1);
if prev_signature.public_key >= signature.public_key {
return Err(AccError::BadSignatureOrder);
}
}
if !env
.storage()
.instance()
.has(&DataKey::Signer(signature.public_key.clone()))
{
return Err(AccError::UnknownSigner);
}
env.crypto().ed25519_verify(
&signature.public_key,
&signature_payload.clone().into(),
&signature.signature,
);
}
Ok(())
}

Authentication here simply checks that the provided signatures are valid given the payload and also that they belong to the signers of this account contract.

Authorization policy

fn verify_authorization_policy(
env: &Env,
context: &Context,
curr_contract: &Address,
all_signed: bool,
spend_left_per_token: &mut Map<Address, i128>,
) -> Result<(), AccError> {
// For the account control every signer must sign the invocation.
let contract_context = match context {
Context::Contract(c) => {
if &c.contract == curr_contract {
if !all_signed {
return Err(AccError::NotEnoughSigners);
}
}
c
}
Context::CreateContractHostFn(_) => return Err(AccError::InvalidContext),
};

We verify the policy per Context. i.e. Per one require_auth call. The policy for the account contract itself enforces every signer to have signed the method call.

// Otherwise, we're only interested in functions that spend tokens.
if contract_context.fn_name != TRANSFER_FN
&& contract_context.fn_name != Symbol::new(env, "approve")
{
return Ok(());
}

let spend_left: Option<i128> =
if let Some(spend_left) = spend_left_per_token.get(contract_context.contract.clone()) {
Some(spend_left)
} else if let Some(limit_left) = env
.storage()
.instance()
.get::<_, i128>(&DataKey::SpendLimit(contract_context.contract.clone()))
{
Some(limit_left)
} else {
None
};

// 'None' means that the contract is outside of the policy.
if let Some(spend_left) = spend_left {
// 'amount' is the third argument in both `approve` and `transfer`.
// If the contract has a different signature, it's safer to panic
// here, as it's expected to have the standard interface.
let spent: i128 = contract_context
.args
.get(2)
.unwrap()
.try_into_val(env)
.unwrap();
if spent < 0 {
return Err(AccError::NegativeAmount);
}
if !all_signed && spent > spend_left {
return Err(AccError::NotEnoughSigners);
}
spend_left_per_token.set(contract_context.contract.clone(), spend_left - spent);
}
Ok(())

Then we check for the standard token function names and verify that for these function we don't exceed the spending limits.

Tests

Open the account/src/test.rs file to follow along.

Refer to another examples for the general information on the test setup.

Here we only look at some points specific to the account contracts.

fn sign(e: &Env, signer: &Keypair, payload: &BytesN<32>) -> RawVal {
Signature {
public_key: signer_public_key(e, signer),
signature: signer
.sign(payload.to_array().as_slice())
.to_bytes()
.into_val(e),
}
.into_val(e)
}

Unlike most of the contracts that may simply use Address, account contracts deal with the signature verification and hence need to actually sign the payloads.

let payload = BytesN::random(&env);
let token = BytesN::random(&env);
env.try_invoke_contract_check_auth::<AccError>(
&account_contract.address.contract_id(),
&payload,
&vec![&env, sign(&env, &signers[0], &payload)],
&vec![
&env,
token_auth_context(&env, &token, Symbol::new(&env, "transfer"), 1000),
],
)
.unwrap();

__check_auth can't be called directly as regular contract functions, hence we need to use try_invoke_contract_check_auth testing utility that emulates being called by the Soroban host during a require_auth call.

// Add a spend limit of 1000 per 1 signer.
account_contract.add_limit(&token, &1000);
// Verify that this call needs to be authorized.
assert_eq!(
env.auths(),
std::vec![(
account_contract.address.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
account_contract.address.clone(),
symbol_short!("add_limit"),
(token.clone(), 1000_i128).into_val(&env),
)),
sub_invocations: std::vec![]
}
)]
);

Asserting the contract-specific error to try_invoke_contract_check_auth allows verifying the exact error code and makes sure that the verification has failed due to not having enough signers and not for any other reason.

It's a good idea for the account contract to have detailed error codes and verify that they are returned when they are expected.

assert_eq!(
env.try_invoke_contract_check_auth::<AccError>(
&account_contract.address.contract_id(),
&payload,
&vec![&env, sign(&env, &signers[0], &payload)],
&vec![
&env,
token_auth_context(&env, &token, Symbol::new(&env, "transfer"), 1001)
],
)
.err()
.unwrap()
.unwrap(),
AccError::NotEnoughSigners
);