Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 65 additions & 44 deletions content/stellar-contracts/accounts/authorization-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,30 @@ pub struct AuthPayload {

Each entry in `context_rule_ids` specifies the rule ID to validate against for the corresponding auth context (by index). Its length must equal `auth_contexts.len()`.

<Callout type="warning">
The `context_rule_ids` are bound into the signed digest: `sha256(signature_payload || context_rule_ids.to_xdr())`. This prevents rule-selection downgrade attacks where an attacker could redirect a signature to a less restrictive rule.
</Callout>
## AuthDigestPreimage

Signers never sign the raw `signature_payload` that the host passes to `__check_auth`. Instead, the smart account wraps it in a structured message that every signer commits to:

```rust
#[contracttype]
pub struct AuthDigestPreimage {
/// The smart account performing the authorization check.
pub account: Address,
/// The 32-byte payload provided by the host to `__check_auth`.
pub signature_payload: BytesN<32>,
/// Per-context rule IDs, aligned by index with `auth_contexts`.
pub context_rule_ids: Vec<u32>,
}
```

How a signer commits to the preimage depends on its type:

- **External signers** sign the **auth digest**, `sha256(preimage.to_xdr())`, which the smart account passes to their verifier contract.
- **Delegated signers** authorize the preimage itself through `require_auth_for_args((preimage,))`.

Binding `context_rule_ids` prevents rule-selection downgrade attacks, where an attacker collects signatures under a strict rule and then swaps `context_rule_ids` to point to a less restrictive one: any change to the rule IDs invalidates every signature. Binding `account` scopes each signature to a single smart account.

See [Auth Digest Computation](/stellar-contracts/accounts/signers-and-verifiers#auth-digest-computation) for how off-chain clients build the preimage and sign it.

## Detailed Flow
```mermaid
Expand All @@ -35,25 +56,29 @@ sequenceDiagram
participant Policy

User->>SmartAccount: AuthPayload (signers + context_rule_ids)
SmartAccount->>SmartAccount: Compute auth_digest<br/>sha256(payload || rule_ids.to_xdr())

loop Each auth context
SmartAccount->>ContextRule: Look up rule by ID<br/>from context_rule_ids
ContextRule->>ContextRule: Validate not expired<br/>and matches context type
ContextRule->>ContextRule: Validate not expired,<br/>context type and signers
ContextRule-->>SmartAccount: Rule + matched signers
end

Note over ContextRule,DelegatedSigner: Built-in authorization <br/>for delegated signers
ContextRule->>DelegatedSigner: require_auth_for_args()
DelegatedSigner-->>ContextRule: Authorized
SmartAccount->>SmartAccount: Build AuthDigestPreimage<br/>(account, payload, rule_ids)

Note over ContextRule,Verifier: Signature verification for external signers
ContextRule->>Verifier: verify()
Verifier-->>ContextRule: Valid
loop Each signer in AuthPayload (must belong to a selected rule)
alt Delegated signer
SmartAccount->>DelegatedSigner: require_auth_for_args(preimage)
DelegatedSigner-->>SmartAccount: Authorized
else External signer
SmartAccount->>Verifier: verify(sha256(preimage.to_xdr()))
Verifier-->>SmartAccount: Valid
end
end

Note over ContextRule,Policy: Policy enforcement (panics on failure)
ContextRule->>Policy: enforce()
loop Each auth context
Note over SmartAccount,Policy: Policy enforcement (panics on failure)
SmartAccount->>Policy: enforce(matched signers)
Policy->>Policy: Validate + update state

ContextRule-->>SmartAccount: ✓ Authorized
end

SmartAccount-->>User: Success
Expand All @@ -67,48 +92,43 @@ For each auth context, the corresponding rule ID is used to look up the context

- Exist in the account's storage
- Not be expired (if `valid_until` is set, it must be ≥ current ledger sequence)
- Match the context type: a `CallContract(address)` rule matches a `CallContract(address)` context, and `Default` rules match any context
- Match the context type: a `CallContract(address)` rule matches a call to that contract, a `CreateContract(wasm_hash)` rule matches a deployment from that WASM hash, a `CreateContractExternalRef(owner, tag)` rule matches a deployment from that executable reference, and `Default` rules match any context

### 2. Signer Matching

### 2. Rule Evaluation
For each (context, rule) pair, the smart account determines the rule's **matched signers**: the signers of the rule that appear as keys in `AuthPayload.signers`. No signature is checked at this stage.

For each (context, rule_id) pair:
- **Rule without policies**: every signer of the rule must be present in the payload. Otherwise, the context is rejected with `UnvalidatedContext`.
- **Rule with policies**: any subset of the rule's signers can be present. The policies decide in step 4 whether the matched signers are sufficient.

#### Step 2.1: Signer Filtering
### 3. Signature Authentication

Extract authenticated signers from the rule's signer list. A signer is considered authenticated if:
The smart account then builds the [`AuthDigestPreimage`](#authdigestpreimage) and goes through every entry of `AuthPayload.signers` once, across all auth contexts:

- **Delegated Signer**: The address has authorized the operation via `require_auth_for_args(payload)`
- **External Signer**: The verifier contract confirms the signature is valid for the public key
- A signer that is not part of any selected rule is rejected with `UnauthorizedSigner`. This prevents a payload from making the smart account call arbitrary verifier contracts.
- **Delegated Signer**: The address must have authorized `require_auth_for_args((preimage,))`.
- **External Signer**: The verifier contract must confirm that the signature over the auth digest, `sha256(preimage.to_xdr())`, is valid for the public key. Otherwise, authorization fails with `ExternalVerificationFailed`.

Only authenticated signers proceed to the next step.
Invalid signatures are not skipped: a single failed signature aborts the whole authorization, even if the remaining signers would satisfy every rule.

#### Step 2.2: Policy Enforcement
### 4. Policy Enforcement

If the rule has attached policies, the smart account calls `enforce()` on each policy. The `enforce()` method both validates conditions and applies state changes — it panics if the policy conditions are not satisfied:
Once all signatures are authenticated, the smart account calls `enforce()` on each policy of each selected rule, passing the rule's matched signers. The `enforce()` method both validates conditions and applies state changes — it panics if the policy conditions are not satisfied:

```rust
for policy in rule.policies {
policy.enforce(e, context, authenticated_signers, context_rule, smart_account);
// Panics if policy conditions aren't satisfied, causing the rule to fail
policy.enforce(e, context, matched_signers, context_rule, smart_account);
// Panics if policy conditions aren't satisfied, causing authorization to fail
}
```

If any policy panics, authorization fails for that context.
If any policy panics, authorization fails.

Policy enforcement requires the smart account's authorization, ensuring that policies can only be enforced by the account itself.

#### Step 2.3: Authorization Check

The authorization check depends on whether policies are present:

**With Policies:**
- Success if all policies' `enforce()` calls completed without panicking

**Without Policies:**
- Success if all signers in the rule are authenticated
- At least one signer must be authenticated for the rule to match
A rule without policies has no further checks: all of its signers are present (step 2) and authenticated (step 3).

### 3. Result
### 5. Result

**Success:** Authorization is granted and the transaction proceeds. All policy state changes are committed.

Expand Down Expand Up @@ -146,9 +166,9 @@ ContextRule {
1. Lookup: Client specifies rule ID 2 in `context_rule_ids`
2. Evaluate Rule 2:
- Rule matches `CallContract(dex_address)` context and is not expired
- Signer filtering: Passkey authenticated
- Policy enforcement: Spending limit validates and updates counters
- Authorization check: All policies enforced successfully → Success
- Signer matching: Passkey is a signer of rule 2
- Signature authentication: Passkey signature over the auth digest is valid
- Policy enforcement: Spending limit validates and updates counters → Success
3. Result: Authorized


Expand Down Expand Up @@ -180,7 +200,7 @@ ContextRule {

**Flow:**
1. Lookup: Client specifies rule ID 1 in `context_rule_ids` (rule 2 is known to be expired)
2. Evaluate Rule 1: Both Alice and Bob authenticated, no policies to enforce → Success
2. Evaluate Rule 1: Alice and Bob, the rule's only signers, are both present and their signatures are valid; no policies to enforce → Success
3. Result: Authorized

### Authorization Failure
Expand All @@ -203,7 +223,8 @@ ContextRule {
**Flow:**
1. Lookup: Client specifies default rule ID in `context_rule_ids`
2. Evaluate:
- Signer filtering: Only Alice authenticated
- Signer matching: Only Alice is present
- Signature authentication: Alice's signature is valid
- Policy enforcement: Threshold policy requires 2 signers, only 1 present → Panics
3. Result: Denied (transaction reverts)

Expand Down
56 changes: 33 additions & 23 deletions content/stellar-contracts/accounts/context-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ Defines the scope where the rule applies:
- `Default`: Applies to any context. Used for admin-like authorization that spans all operations.
- `CallContract(Address)`: Applies to specific contract calls. Useful for scoped permissions like session logins to a particular dApp.
- `CreateContract(BytesN<32>)`: Applies to contract deployments with a specific WASM hash. Enables control over which contracts can be deployed.
- `CreateContractExternalRef(Address, String)`: Applies to contract deployments from the executable reference that an owner contract stores under a tag (Protocol 28, [CAP-85](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0085.md)).

<Callout type="warning">
With `CreateContractExternalRef(owner, tag)`, the WASM hash is resolved at deployment time and the `owner` contract can change it later, so the rule trusts the owner contract to point the tag at acceptable code. A deployment is matched by how its executable is referenced, not by the code it resolves to: a `CreateContract(hash)` rule does not cover a deployment through an external reference, even if that reference resolves to the same hash.
</Callout>

#### Valid Until
Optional expiration defined by a ledger sequence. Rules with expiration automatically become invalid after the specified ledger, enabling time-limited permissions like 24-hour sessions.
Expand Down Expand Up @@ -94,22 +99,27 @@ graph TD


```rust
use soroban_sdk::{map, vec, Env, String}
use stellar_accounts::smart_account::{self as smart_account, ContextRuleType};
use soroban_sdk::{map, vec, Env, String};
use stellar_accounts::smart_account::{self as smart_account, ContextRuleType, Signer};

// ~5 seconds per ledger
const DAY_IN_LEDGERS: u32 = 17_280;

let current_ledger = e.ledger().sequence();

// This rule applies to all contexts and requires 2-of-3 signatures from Alice, Bob, or Dave.
smart_account::add_context_rule(
e,
ContextRuleType::Default,
String::from_str(e, "Sudo"),
&ContextRuleType::Default,
&String::from_str(e, "Sudo"),
None, // No expiration
vec![
&vec![
e,
Signer::External(bls_verifier, alice_key),
Signer::External(bls_verifier.clone(), alice_key),
Signer::External(bls_verifier, bob_key),
Signer::Delegated(dave_addr)
],
map![
&map![
e,
(threshold_policy, threshold_params) // 2-of-3 Threshold
],
Expand All @@ -119,14 +129,14 @@ smart_account::add_context_rule(
// requires a dapp1 key signature, and enforces spending limits.
smart_account::add_context_rule(
e,
ContextRuleType::CallContract(usdc_addr),
String::from_str(e, "Dapp1 Subscription"),
Some(current_ledger + 1_year),
vec![
&ContextRuleType::CallContract(usdc_addr),
&String::from_str(e, "Dapp1 Subscription"),
Some(current_ledger + 365 * DAY_IN_LEDGERS),
&vec![
e,
Signer::External(ed25519_verifier, dapp1_key)
Signer::External(ed25519_verifier.clone(), dapp1_key)
],
map![
&map![
e,
(spending_limit_policy, spending_params)
],
Expand All @@ -136,14 +146,14 @@ smart_account::add_context_rule(
// requires a dapp2 key signature, and enforces rate limiting and time window policies.
smart_account::add_context_rule(
e,
ContextRuleType::CallContract(dapp_addr),
String::from_str(e, "Dapp2 Session"),
Some(current_ledger + 7_days),
vec![
&ContextRuleType::CallContract(dapp_addr),
&String::from_str(e, "Dapp2 Session"),
Some(current_ledger + 7 * DAY_IN_LEDGERS),
&vec![
e,
Signer::External(ed25519_verifier, dapp2_key)
],
map![
&map![
e,
(rate_limit_policy, rate_limit_params),
(time_window_policy, time_window_params)
Expand All @@ -154,14 +164,14 @@ smart_account::add_context_rule(
// requires an AI agent key signature, and enforces volume caps.
smart_account::add_context_rule(
e,
ContextRuleType::CallContract(some_addr),
String::from_str(e, "AI Agent"),
Some(current_ledger + 12_hours),
vec![
&ContextRuleType::CallContract(some_addr),
&String::from_str(e, "AI Agent"),
Some(current_ledger + DAY_IN_LEDGERS / 2),
&vec![
e,
Signer::External(secp256r1_verifier, agent_key)
],
map![
&map![
e,
(volume_cap_policy, volume_cap_params)
],
Expand Down
25 changes: 20 additions & 5 deletions content/stellar-contracts/accounts/policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ fn remove_policy(
);
```

Removes a policy (identified by its numeric ID) from an existing context rule and calls its `uninstall()` function. The rule must maintain at least one signer OR one policy after removal.
Removes a policy (identified by its numeric ID) from an existing context rule and calls its `uninstall()` function. The rule must maintain at least one signer OR one policy after removal. The ID is returned by `add_policy`, and the `get_policy_id(policy)` view looks it up for an already registered policy.

### Caveats

Expand All @@ -149,10 +149,12 @@ Threshold policies (both simple and weighted) store authorization requirements t
**Adding Signers:** Conversely, if signers are added without updating the threshold, the security guarantee silently weakens. A strict 3-of-3 multisig becomes a 3-of-5 multisig after adding two signers, reducing the required approval from 100% to 60% without any explicit warning.

**Resolution:** Administrators must manually update thresholds and weights when modifying signer sets:
1. Before removing signers, verify that the threshold remains achievable
1. Before removing signers, verify that the threshold remains achievable, lowering it first if needed
2. After adding signers, adjust thresholds or assign weights to maintain the desired security level
3. Ideally, bundle these updates in the same transaction as the signer modifications

For weighted thresholds, these updates must also happen in a specific order; see [Required Operation Ordering](#required-operation-ordering).

## Example Policies

The OpenZeppelin Stellar Contracts library provides the necessary utilities for three policy implementations:
Expand Down Expand Up @@ -190,18 +192,31 @@ The policy requires two configuration parameters:
When using threshold policies, be aware of signer set divergence issues. See the [Caveats](#caveats) section above for details on how adding or removing signers affects threshold policies and how to properly manage these changes.
</Callout>

#### Required Operation Ordering

Both `set_threshold()` and `set_signer_weight()` enforce `threshold <= total signer weight` on every individual call. Because each call is validated in isolation, reconfiguration is order-sensitive:

- **Adding weight and raising the threshold**: call `set_signer_weight()` first to increase the total weight, then `set_threshold()`. Raising the threshold first reverts if it exceeds the current total weight.
- **Removing a signer or reducing weight**: call `set_threshold()` first to lower the threshold, then `set_signer_weight()`. Reducing the weight first reverts if the total weight drops below the current threshold.

A call that would leave `threshold > total signer weight` reverts with `InvalidThreshold` (error `3211`), so a wrong-order sequence fails at the first step whose intermediate state violates the invariant.

### Spending Limit

[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/accounts/src/policies/spending_limit.rs)

The `spending_limit` policy enforces spending caps over time periods, enabling budget controls, allowances, and rate limiting for smart accounts. This is particularly useful for session keys, sub-accounts, and automated operations that need spending constraints.

The policy tracks cumulative spending within a rolling time window and rejects transactions that would exceed the configured limit. The policy maintains state to track spending and automatically resets when the time window expires.
The policy tracks cumulative spending of `transfer` calls within a rolling window and rejects transactions that would exceed the configured limit. Entries older than the window are evicted before each new transfer is evaluated, so the available budget frees up as the window rolls forward. Any other call under a rule with this policy is rejected.

The policy requires two configuration parameters:

- **Limit Amount**: Maximum spending allowed in the time window
- **Time Window**: Duration in seconds for the spending period
- **Spending Limit** (`spending_limit`): Maximum amount that can be spent within the window
- **Period** (`period_ledgers`): Length of the rolling window, in ledgers (e.g. `17280` for roughly one day)

Zero-amount transfers move no funds and are always permitted, even when the amount already spent exceeds a limit that was lowered in the meantime.

The spending history is capped at 815 entries (`MAX_HISTORY_ENTRIES`), the largest history that still fits in a single ledger entry. Once the window holds that many transfers, further transfers fail with `HistoryCapacityExceeded` until older entries fall out of the window.

## Best Practices

Expand Down
Loading
Loading