Smart Contract Authorization
Authorization is the process of judging which operations "should" or "should not" be allowed to occur; it is about judging permission.
Authorization differs from authentication, which is the narrower problem of judging whether a person "is who they say they are", or whether a message claiming to come from a person "really" came from them.
Authorization often uses cryptographic authentication (via signatures) to support its judgments, but is a broader, more general process.
Soroban Authorization Framework
Soroban aims to provide a light-weight, but flexible and extensible framework that allows contracts to implement arbitrarily complex authorization rules, while providing built-in implementation for some common tasks (such as replay prevention). The framework consists of the following components:
- Contract-specific authorization - custom authorization rules implemented by contracts using the private contract storage and abstract accounts.
- Account abstraction - allows users to customize their authentication rules and define universal authorization policies via custom account contracts (this includes the built-in support for the Stellar accounts).
- Host-based authorization library - ensures integrity between the custom accounts and regular contracts. Also defines the structured signature payload format, ensures replay prevention and takes care of providing the correct signature contexts.
Soroban host also provides some cryptographic functions (signature verification, hashing) which may be useful for the custom account implementation.
Contracts that use Soroban authorization framework are interoperable with each other. Also it is easier for the client applications to write generic code for interaction with Soroban authorization framework. For example, wallets can implement a generalized way to present and sign Soroban payloads.
We realize that it's not possible to cover each and every case, but we hope that the vast majority of the contracts can operate within the framework and thus contribute to building a more cohesive ecosystem. Custom authorization frameworks are still possible to implement, but are not encouraged (unless there are no alternatives).
Contract-Specific Authorization
Contract storage
Contracts have an exclusive read and write access to their storage in the ledger. This allows contracts to safely control and manage user access to their data. For example, a token contract may ensure that only the administrator can mint more of the token by storing the administrator identity in its storage. Similarly, it can make sure that only an owner of the balance may transfer that balance.
Address
The storage-based approach described in the previous section requires a way to represent the user identities and authenticate them. Address
type is a host-managed type that performs these functions.
From the contract perspective Address
is an opaque identifier type. The contract logic doesn't need to depend on the internal representation of the Address
(see Account Abstraction section below for more details).
Address
type has two similar methods in Soroban SDK: require_auth
and require_auth_for_args
(these methods call the respective Soroban host function). The only difference between the functions is the ability to customize the invocation arguments. See auth example that demonstrates how to use these functions.
Both functions ensure that the Address
has authorized the call of the current function within the current context (where context is defined by require_auth
calls in the current call stack; see more formal definition in the section below). The authentication rules for this authorization are defined by the Address
and are enforced by the Soroban host. Replay protection is also implemented in the host, i.e., there is normally no need for a contract to manage its own nonces.
Authorizing Sub-contract Calls
One of the key features of Soroban Authorization Framework is the ability to easily make authorized sub-contract calls. For example, it is possible for a contract to call require_auth
for an Address
and then call token.xfer
authorized for the same Address
(see timelock example that demonstrates this pattern).
Contracts don't need to do anything special to benefit from this feature. Just calling a sub-contract that calls require_auth
will ensure that the sub-contract call has been properly authorized.
When to require_auth
The main authorization-related decision a contract writer needs to make for any given Address
is whether they need to call require_auth
for it. While the decision needs to be made on case-by-case basis, here are some rules of thumb:
- If the access to the
Address
data in this contract is read-only, thenrequire_auth
is probably not needed. - If the
Address
data in this contract is being modified in a way that's not strictly beneficial to the user, thenrequire_auth
is probably needed (e.g. reducing the user's token balance needs to be authorized, while increasing it doesn't need to be authorized) - If a contract calls another contract that will call
require_auth
for theAddress
(e.g.token.xfer
), then addingrequire_auth
in the caller would ensure that the authorization for the inner call can't be reused outside of your contract. For example, if you want to do something positive for the user, but only when they have transferred some token to your contract, then the contract call itself shouldrequire_auth
.
Authorizing Multiple Address
es
There is no explicit restriction on how many Address
entities the contract uses and how many Address
es have require_auth
called. That means that it is possible to authorize a contract call on behalf of multiple users, which may even have different authorization contexts (customized via arguments in require_auth_for_args
). Atomic swap is an example that deals with authorization of two Address
es.
Note though, that contracts that deal with multiple authorized Address
es need a bit more complex support on the client side (to collect and attach the proper signatures).
Account Abstraction
Account abstraction is a way to decouple the authentication logic from the contract-specific authorization rules. The Address
defined above is in fact an identifier of an 'abstract' account. That is, the contracts know the Address
and can require authorization from it, but they don't know how exactly it is implemented.
For example, imagine a token contract. Its responsibilities are to manage the balances of multiple users (transfer, mint, burn etc.). There is really nothing about these responsibilities that has anything to do with how exactly the user authorized the balance-modifying transaction. The users may want to use some hardware key that supports a new generation of crypto algorithms(which don't even have to exist today) or they may want to have bespoke multisig scheme and none of this really has anything to do with the token logic.
Account abstraction provides a convenient extension point for every contract that uses Address
for authorization. It doesn't solve all the issues automatically - client-side tooling may still need to be adapted to support different authentication schemes or different wallets. But the on-chain state doesn't need to be modified and modifying the on-chain state is a much harder problem.
Types of Account Implementations
Conceptually, every abstract account is a special contract that defines authentication rules and potentially some additional account-specific authorization policies. However, for the sake of optimization and integration with the existing Stellar accounts, Soroban supports 4 different kinds of the account implementations.
Below are the general descriptions of these implementations. See the transaction guide for the concrete information of how different accounts are represented.
Stellar Account
Corresponds to Address::Account
.
This is a special, built-in 'account contract' that handles all the Stellar accounts. It is not a real contract and doesn't need to be deployed.
This supports the Stellar multisig with medium threshold. See Stellar documentation for more details on multisig and thresholds.
Transaction Invoker
Corresponds to Address::Account
.
This is also a Stellar account, but its signature is inferred from the source account of the Stellar transaction (or operation, if it has one).
This is purely an optimization of the Stellar Account that can skip one signature in case the transaction source account also authorizes the contract invocation.
Contract Invoker
Corresponds to Address::Contract
.
This is a special case of an 'account' that may appear only when a contract calls another contract. We consider that since the contract makes a call, then it must be authorizing it (otherwise, it shouldn't have made that call). Hence all the require_auth
calls made on behalf of the direct invoker contract Address
are considered to be authorized (but not any calls on behalf of the contract deeper down the stack).
Custom Account
Corresponds to Address::Contract
.
This is the extension point of account abstraction. Custom account is a special contract that implements __check_auth
method. If any contract calls require_auth
for the Address
of this contract, Soroban host will call __check_auth
with the corresponding arguments.
__check_auth
gets a signature payload, a list of signatures (in any user-defined format) and a list of the contract invocations that are being authorized by these signatures. Its responsibility is to perform the authentication via verifying the signatures and also (optionally) to apply a custom authorization policy. For example, a signature weight system similar to Stellar can be implemented, but it also can have customizable rules for the weights, e.g. to allow spending more than X units of token Y only given signature weight Z.
Custom account can also be treated as a custodial smart wallet. It holds the user's funds (token balances, NFTs etc.) and provides the user(s) with ways to authorize operations on these funds. That said, nothing prevents custom accounts to authorize operations that have nothing to do with any balances, for example, it can be used to perform administrative functions for tokens (don't forget, custom account simply defines what to do when require_auth
is called).
For the exact interface and more details, see the custom account example.
Secp256r1, passkeys and smart wallets
After a successful public validator vote to upgrade Stellar's Mainnet to Protocol 21, the secp256r1 signature scheme was enabled for smart contract transactions. This allows developers to implement passkeys to sign transactions instead of using secret keys or seed phrases. Official documentation is a work in progress, view all passkey and smart wallet information in the Smart Wallets page in the docs.
Advanced Concepts
Most of the contracts shouldn't need the concepts described in this section. Refer to this when developing complex contracts that deal with deep contract call trees and/or multiple Address
es.
require_auth
implementation details
When a Soroban transaction is executed on-chain, the host collects a list of SorobanAuthorizationEntry
entries from the transaction (XDR). These entries contain signed authorizer credentials and authorized invocation trees. The host uses these entries to verify authorization during the contract execution.
Every time require_auth
/require_auth_for_args
host function is called for non-contract-invoker account, the following steps happen:
- Find an authorized invocation tree that matches the
require_auth
call. The matching process is pretty involved and is described in the section below. - If authentication hasn't happened for this tree yet, then perform it:
- Verify signature expiration. Expired signatures are not valid.
- Verify and consume nonce. Nonce is an arbitrary number, that has to be unique among all the non-expired signatures of the address.
- Build the expected signature payload preimage and compute its SHA-256 hash to get the final signature payload
- Call
__check_auth
of the account contract corresponding to theAddress
using the signature payload and the invocations from the authorization tree
- Mark the invocation as 'exhausted' in its authorized invocation tree. 'Exhausted' invocations will be skipped when matching the future
require_auth
calls.
If any of the steps above fails, then the authorization is considered unsuccessful.
Notice, that authentication happens just once per tree, as the whole tree needs to be signed.
Matching Authorized Invocation Trees
In order for authorizations to succeed, all the require_auth
/require_auth_for_args
calls have to be covered by the corresponding SorobanAuthorizedInvocation
trees in a transaction (defined in transaction XDR).
Formally, this correspondence is defined as follows.
Given a top-level contract invocation I
we can build a 'contract invocation tree' T
by tracing all the sub-contract calls (a directed edge A->B
in the tree means 'contract function A calls contract function B). Note, that we only consider the functions that are implemented in different contracts, i.e. any function calls that don't involve a contract invocation via host call
are considered to belong to the same node.
Let's say authorization is required from addresses A_1..A_N
. Then for every address A_i
there are two kinds of nodes in the invocation tree T
: R
-nodes that had a require_auth
call for A_i
and N
-nodes that didn't have such call. Then we remove all the N
-nodes and all the edges from T
and add the directed edges connecting the remaining R
-nodes such that the edge goes from R_j
to R_k
if there was a path between R_j
and R_k
in T
that doesn't contain any other R
-nodes. As a result we get a forest of SorobanAuthorizedInvocation
trees for A_i
. Notice, that these trees don't have to have their root be I
node (i.e. the top-level contract call), so it's possible to e.g. batch the authorized call together without requiring signing the batching function.
In simpler terms, SorobanAuthorizedInvocation
trees for an Address
are subsets of the full invocation tree that are 'condensed' to only contain invocations that have require_auth
call for that Address
.
During the matching process that happens for every require_auth
host tries to match the current path in T
to a SorobanAuthorizedInvocation
tree for the corresponding Address
. The path is considered to be matched only when there is a corresponding path of exhausted R
nodes leading to the current call. This means that if the Address
signs a sequence of calls A.foo->B.bar->C.baz
, then its authorization check will fail in case if A.foo
directly calls C.baz
because C.baz
strictly has to be called from B.bar
.
Duplicate Addresses
In case if the same contract function calls require_auth
for the same Address
multiple times (e.g. when multiple operations from the same user are being batched), every require_auth
call still has to have a corresponding node in the SorobanAuthorizedInvocation
tree. Due to that, there might be multiple valid trees that make all the authorization checks pass. There is nothing wrong about that - the address still must have authorized all the invocations. The only requirement for such cases to be handled correctly is to ensure that the require_auth
calls for an Address
happen before the corresponding sub-contract calls.