Skip to content

Commit a3329c4

Browse files
authored
Merge pull request #139 from OpenZeppelin/v0.7.0-smart-acounts
Stellar: smart accounts and access control update
2 parents 8199d83 + 55d5936 commit a3329c4

File tree

8 files changed

+314
-239
lines changed

8 files changed

+314
-239
lines changed

content/stellar-contracts/access/access-control.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,22 +53,22 @@ impl MyContract {
5353

5454
// 1. Set MANAGER_ROLE as the admin role for GUARDIAN_ROLE:
5555
// accounts with MANAGER_ROLE can manage accounts with GUARDIAN_ROLE
56-
access_control::set_role_admin_no_auth(e, &admin, &GUARDIAN_ROLE, &MANAGER_ROLE);
56+
access_control::set_role_admin_no_auth(e, &GUARDIAN_ROLE, &MANAGER_ROLE);
5757

5858
// 2. Admin grants MANAGER_ROLE to the manager account
59-
access_control::grant_role_no_auth(e, &admin, &manager, &MANAGER_ROLE);
59+
access_control::grant_role_no_auth(e, &manager, &MANAGER_ROLE, &admin);
6060
}
6161

6262
pub fn manage_guardians(e: &Env, manager: Address, guardian1: Address, guardian2: Address) {
6363
// Manager must be authorized
6464
manager.require_auth();
6565

6666
// 3. Now the manager can grant GUARDIAN_ROLE to other accounts
67-
access_control::grant_role_no_auth(e, &manager, &guardian1, &GUARDIAN_ROLE);
68-
access_control::grant_role_no_auth(e, &manager, &guardian2, &GUARDIAN_ROLE);
67+
access_control::grant_role_no_auth(e, &guardian1, &GUARDIAN_ROLE, &manager);
68+
access_control::grant_role_no_auth(e, &guardian2, &GUARDIAN_ROLE, &manager);
6969

7070
// Manager can also revoke GUARDIAN_ROLE
71-
access_control::revoke_role_no_auth(e, &manager, &guardian1, &GUARDIAN_ROLE);
71+
access_control::revoke_role_no_auth(e, &guardian1, &GUARDIAN_ROLE, &manager);
7272
}
7373
}
7474
```

content/stellar-contracts/accounts/authorization-flow.mdx

Lines changed: 62 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,27 @@
22
title: Authorization Flow
33
---
44

5-
Authorization in smart accounts is determined by matching the current context against the account's context rules. Rules are gathered, ordered by recency, and evaluated until one satisfies the requirements. If a matching rule is found, its policies (if any) are enforced. Otherwise, authorization fails.
5+
Authorization in smart accounts is determined by matching each auth context against explicitly selected context rules. The caller supplies `context_rule_ids` in the `AuthPayload`, specifying exactly one rule per auth context. If the selected rule passes all checks, its policies (if any) are enforced. Otherwise, authorization fails.
6+
7+
## AuthPayload
8+
9+
The `AuthPayload` structure is passed as the signature data in `__check_auth`:
10+
11+
```rust
12+
#[contracttype]
13+
pub struct AuthPayload {
14+
/// Signature data mapped to each signer.
15+
pub signers: Map<Signer, Bytes>,
16+
/// Per-context rule IDs, aligned by index with `auth_contexts`.
17+
pub context_rule_ids: Vec<u32>,
18+
}
19+
```
20+
21+
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()`.
22+
23+
<Callout type="warning">
24+
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.
25+
</Callout>
626

727
## Detailed Flow
828
```mermaid
@@ -14,11 +34,13 @@ sequenceDiagram
1434
participant Verifier
1535
participant Policy
1636
17-
User->>SmartAccount: Signatures
18-
SmartAccount->>ContextRule: Match context<br/>(CallContract, Default, ...)
19-
ContextRule->>ContextRule: Filter expired rules<br/>Sort newest first
37+
User->>SmartAccount: AuthPayload (signers + context_rule_ids)
38+
SmartAccount->>SmartAccount: Compute auth_digest<br/>sha256(payload || rule_ids.to_xdr())
39+
40+
loop Each auth context
41+
SmartAccount->>ContextRule: Look up rule by ID<br/>from context_rule_ids
42+
ContextRule->>ContextRule: Validate not expired<br/>and matches context type
2043
21-
loop Each rule until match
2244
Note over ContextRule,DelegatedSigner: Built-in authorization <br/>for delegated signers
2345
ContextRule->>DelegatedSigner: require_auth_for_args()
2446
DelegatedSigner-->>ContextRule: Authorized
@@ -27,41 +49,29 @@ sequenceDiagram
2749
ContextRule->>Verifier: verify()
2850
Verifier-->>ContextRule: Valid
2951
30-
Note over ContextRule,Policy: Policy pre-checks
31-
ContextRule->>Policy: can_enforce()
32-
Policy-->>ContextRule: True/False
33-
34-
alt All checks pass
35-
ContextRule->>Policy: enforce()
36-
Policy->>Policy: Update state
37-
ContextRule-->>SmartAccount: ✓ Authorized
38-
else Any check fails
39-
ContextRule->>ContextRule: Try next rule
40-
end
52+
Note over ContextRule,Policy: Policy enforcement (panics on failure)
53+
ContextRule->>Policy: enforce()
54+
Policy->>Policy: Validate + update state
55+
56+
ContextRule-->>SmartAccount: ✓ Authorized
4157
end
4258
4359
SmartAccount-->>User: Success
4460
```
4561

46-
### 1. Rule Collection
62+
### 1. Rule Lookup
4763

48-
The smart account gathers all relevant context rules for evaluation:
64+
The smart account reads the `context_rule_ids` from the `AuthPayload`. There must be exactly one rule ID per auth context — a mismatch is rejected with `ContextRuleIdsLengthMismatch`.
4965

50-
- Retrieve all non-expired rules for the specific context type
51-
- Include default rules that apply to any context
52-
- Sort specific and default rules by creation time (newest first)
66+
For each auth context, the corresponding rule ID is used to look up the context rule directly. The rule must:
5367

54-
**Context Type Matching:**
55-
- For a `CallContract(address)` context, both specific `CallContract(address)` rules and `Default` rules are collected
56-
- For a `CreateContract(wasm_hash)` context, both specific `CreateContract(wasm_hash)` rules and `Default` rules are collected
57-
- For any other context, only `Default` rules are collected
58-
59-
**Expiration Filtering:**
60-
Rules with `valid_until` set to a ledger sequence that has passed are automatically filtered out during collection.
68+
- Exist in the account's storage
69+
- Not be expired (if `valid_until` is set, it must be ≥ current ledger sequence)
70+
- Match the context type: a `CallContract(address)` rule matches a `CallContract(address)` context, and `Default` rules match any context
6171

6272
### 2. Rule Evaluation
6373

64-
For each rule in order (newest and most specific first):
74+
For each (context, rule_id) pair:
6575

6676
#### Step 2.1: Signer Filtering
6777

@@ -72,51 +82,33 @@ Extract authenticated signers from the rule's signer list. A signer is considere
7282

7383
Only authenticated signers proceed to the next step.
7484

75-
#### Step 2.2: Policy Validation
85+
#### Step 2.2: Policy Enforcement
7686

77-
If the rule has attached policies, verify that all can be enforced:
87+
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:
7888

7989
```rust
8090
for policy in rule.policies {
81-
if !policy.can_enforce(e, account, rule_id, signers, auth_context) {
82-
// This rule fails, try the next rule
83-
}
91+
policy.enforce(e, context, authenticated_signers, context_rule, smart_account);
92+
// Panics if policy conditions aren't satisfied, causing the rule to fail
8493
}
8594
```
8695

87-
If any policy's `can_enforce()` returns false, the rule fails and evaluation moves to the next rule.
96+
If any policy panics, authorization fails for that context.
97+
98+
Policy enforcement requires the smart account's authorization, ensuring that policies can only be enforced by the account itself.
8899

89100
#### Step 2.3: Authorization Check
90101

91102
The authorization check depends on whether policies are present:
92103

93104
**With Policies:**
94-
- Success if all policies passed `can_enforce()`
95-
- The presence of authenticated signers is verified during policy evaluation
105+
- Success if all policies' `enforce()` calls completed without panicking
96106

97107
**Without Policies:**
98108
- Success if all signers in the rule are authenticated
99109
- At least one signer must be authenticated for the rule to match
100110

101-
#### Step 2.4: Rule Precedence
102-
103-
The first matching rule wins. Newer rules take precedence over older rules for the same context type. This allows overwriting old rules.
104-
105-
### 3. Policy Enforcement
106-
107-
If authorization succeeds, the smart account calls `enforce()` on all matched policies in order:
108-
109-
```rust
110-
for policy in matched_rule.policies {
111-
policy.enforce(e, account, rule_id, signers, auth_context);
112-
}
113-
```
114-
115-
This triggers any necessary state changes such as updating spending counters, recording timestamps, emitting audit events, or modifying allowances.
116-
117-
Policy enforcement requires the smart account's authorization, ensuring that policies can only be enforced by the account itself.
118-
119-
### 4. Result
111+
### 3. Result
120112

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

@@ -151,15 +143,14 @@ ContextRule {
151143
**Authorization Entries:** `[passkey_signature]`
152144

153145
**Flow:**
154-
1. Collect: Rules 2 (specific) and 1 (default)
146+
1. Lookup: Client specifies rule ID 2 in `context_rule_ids`
155147
2. Evaluate Rule 2:
148+
- Rule matches `CallContract(dex_address)` context and is not expired
156149
- Signer filtering: Passkey authenticated
157-
- Policy validation: Spending limit check passes
158-
- Authorization check: All policies enforceable → Success
159-
3. Enforce: Update spending counters, emit events
160-
4. Result: Authorized
150+
- Policy enforcement: Spending limit validates and updates counters
151+
- Authorization check: All policies enforced successfully → Success
152+
3. Result: Authorized
161153

162-
If the spending limit had been exceeded, Rule 2 would fail and evaluation would continue to Rule 1 (which would also fail since the passkey doesn't match Alice or Bob).
163154

164155
### Fallback to Default
165156

@@ -188,12 +179,9 @@ ContextRule {
188179
**Authorization Entries:** `[ed25519_alice_signature, ed25519_bob_signature]`
189180

190181
**Flow:**
191-
1. Collect: Rule 2 filtered out (expired), only Rule 1 collected
192-
2. Evaluate Rule 1: Both Alice and Bob authenticated → Success
193-
3. Enforce: No policies to enforce
194-
4. Result: Authorized
195-
196-
The expired session rule is automatically filtered out, and authorization falls back to the default admin rule.
182+
1. Lookup: Client specifies rule ID 1 in `context_rule_ids` (rule 2 is known to be expired)
183+
2. Evaluate Rule 1: Both Alice and Bob authenticated, no policies to enforce → Success
184+
3. Result: Authorized
197185

198186
### Authorization Failure
199187

@@ -213,24 +201,24 @@ ContextRule {
213201
**Authorization Entries:** `[alice_signature]`
214202

215203
**Flow:**
216-
1. Collect: Default rule retrieved
204+
1. Lookup: Client specifies default rule ID in `context_rule_ids`
217205
2. Evaluate:
218206
- Signer filtering: Only Alice authenticated
219-
- Policy validation: Threshold policy requires 2 signers, only 1 present → Fail
220-
3. No more rules to evaluate
221-
4. Result: Denied (transaction reverts)
207+
- Policy enforcement: Threshold policy requires 2 signers, only 1 present → Panics
208+
3. Result: Denied (transaction reverts)
222209

223210
## Performance Considerations
224211

225212
Protocol 23 optimizations make the authorization flow efficient:
226213
- **Marginal storage read costs**: Reading multiple context rules has negligible cost
227214
- **Cheaper cross-contract calls**: Calling verifiers and policies is substantially cheaper
228215

229-
The framework enforces limits to maintain predictability:
230-
- Maximum context rules per smart account: 15
216+
The framework enforces per-rule limits to maintain predictability:
231217
- Maximum signers per context rule: 15
232218
- Maximum policies per context rule: 5
233219

220+
There is no upper limit on the total number of context rules per smart account.
221+
234222
## See Also
235223

236224
- [Smart Account](/stellar-contracts/accounts/smart-account)

content/stellar-contracts/accounts/context-rules.mdx

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,28 +44,29 @@ Each rule must contain at least one signer OR one policy. This enables pure poli
4444
### Multiple Rules Per Context
4545
Multiple rules can exist for the same context type with different signer sets and policies. This allows progressive authorization models where different combinations of credentials grant access to the same operations.
4646

47-
### Rule Precedence
48-
Rules are evaluated in reverse chronological order (newest first). The first matching rule wins. This enables seamless permission updates: adding a new rule with different requirements immediately takes precedence over older rules for the same context.
47+
### Explicit Rule Selection
48+
Rules are not iterated or auto-discovered. Instead, off-chain clients specify exactly which context rule to use for each operation via the `context_rule_ids` field in `AuthPayload`. This explicit selection prevents downgrade attacks where an attacker could force the system to fall back to a weaker rule.
4949

5050
### Automatic Expiration
51-
Expired rules are automatically filtered out during authorization evaluation.
51+
Expired rules are rejected during authorization evaluation.
5252

5353
## Context Rule Limits
5454

55-
The framework enforces limits to keep costs predictable and encourage proactive context rule management (remove expired or non-valid rules):
56-
57-
- Maximum context rules per smart account: 15
5855
- Maximum signers per context rule: 15
5956
- Maximum policies per context rule: 5
57+
- Maximum context rule name size: 20 bytes
58+
- Maximum external signer key size: 256 bytes
59+
60+
There is no upper limit on the total number of context rules per smart account.
6061

6162
## Authorization Matching
6263

6364
During authorization, the framework:
6465

65-
1. Gathers all non-expired rules matching the context type plus default rules
66-
2. Sorts rules by creation time (newest first)
67-
3. Evaluates rules in order until one matches
68-
4. Returns the first matching rule or fails if none match
66+
1. Reads the `context_rule_ids` from the `AuthPayload` (one rule ID per auth context)
67+
2. Looks up each rule directly by ID
68+
3. Validates the rule is not expired and matches the context type
69+
4. Authenticates signers and enforces policies, or fails if conditions aren't met
6970

7071
For detailed documentation on the authorization flow, see [Authorization Flow](/stellar-contracts/accounts/authorization-flow).
7172

@@ -88,7 +89,7 @@ graph TD
8889
SA --- CR3
8990
SA --- CR4
9091
91-
style SA fill:#E8E8E8,stroke:#333,stroke-width:2px
92+
style SA fill:transparent,stroke:#888,stroke-width:2px
9293
```
9394

9495

@@ -101,6 +102,7 @@ smart_account::add_context_rule(
101102
e,
102103
ContextRuleType::Default,
103104
String::from_str(e, "Sudo"),
105+
None, // No expiration
104106
vec![
105107
e,
106108
Signer::External(bls_verifier, alice_key),
@@ -111,7 +113,6 @@ smart_account::add_context_rule(
111113
e,
112114
(threshold_policy, threshold_params) // 2-of-3 Threshold
113115
],
114-
None, // No expiration
115116
);
116117

117118
// This rule applies only to calls to the USDC contract, expires in 1 year,
@@ -120,6 +121,7 @@ smart_account::add_context_rule(
120121
e,
121122
ContextRuleType::CallContract(usdc_addr),
122123
String::from_str(e, "Dapp1 Subscription"),
124+
Some(current_ledger + 1_year),
123125
vec![
124126
e,
125127
Signer::External(ed25519_verifier, dapp1_key)
@@ -128,7 +130,6 @@ smart_account::add_context_rule(
128130
e,
129131
(spending_limit_policy, spending_params)
130132
],
131-
Some(current_ledger + 1_year)
132133
);
133134

134135
// This rule applies only to calls to the dApp contract, expires in 7 days,
@@ -137,6 +138,7 @@ smart_account::add_context_rule(
137138
e,
138139
ContextRuleType::CallContract(dapp_addr),
139140
String::from_str(e, "Dapp2 Session"),
141+
Some(current_ledger + 7_days),
140142
vec![
141143
e,
142144
Signer::External(ed25519_verifier, dapp2_key)
@@ -146,7 +148,6 @@ smart_account::add_context_rule(
146148
(rate_limit_policy, rate_limit_params),
147149
(time_window_policy, time_window_params)
148150
],
149-
Some(current_ledger + 7_days)
150151
);
151152

152153
// This rule applies only to calls to a specific contract, expires in 12 hours,
@@ -155,6 +156,7 @@ smart_account::add_context_rule(
155156
e,
156157
ContextRuleType::CallContract(some_addr),
157158
String::from_str(e, "AI Agent"),
159+
Some(current_ledger + 12_hours),
158160
vec![
159161
e,
160162
Signer::External(secp256r1_verifier, agent_key)
@@ -163,7 +165,6 @@ smart_account::add_context_rule(
163165
e,
164166
(volume_cap_policy, volume_cap_params)
165167
],
166-
Some(current_ledger + 12_hours)
167168
);
168169
```
169170

0 commit comments

Comments
 (0)