From d2dbf6ff2a1f88cb079acdd5fa86d41a0533d52d Mon Sep 17 00:00:00 2001 From: Ozgun Ozerk Date: Thu, 2 Apr 2026 13:36:39 +0300 Subject: [PATCH 1/7] release 0.7.0 updates --- .../accounts/authorization-flow.mdx | 161 +++++------ .../accounts/context-rules.mdx | 23 +- .../stellar-contracts/accounts/policies.mdx | 59 ++-- .../accounts/signers-and-verifiers.mdx | 44 ++- .../accounts/smart-account.mdx | 10 +- content/stellar-contracts/changelog.mdx | 45 +++ .../stellar-contracts/governance/governor.mdx | 272 ++++++++++++++++++ .../governance/timelock-controller.mdx | 2 +- .../stellar-contracts/governance/votes.mdx | 116 ++++++++ content/stellar-contracts/index.mdx | 7 + .../tokens/fungible/fungible.mdx | 5 + .../tokens/non-fungible/non-fungible.mdx | 5 + content/stellar-contracts/tokens/rwa/rwa.mdx | 24 ++ .../stellar-contracts/utils/upgradeable.mdx | 238 +++++++++------ content/stellar-contracts/zk-email.mdx | 118 ++++++++ 15 files changed, 882 insertions(+), 247 deletions(-) create mode 100644 content/stellar-contracts/governance/governor.mdx create mode 100644 content/stellar-contracts/governance/votes.mdx create mode 100644 content/stellar-contracts/zk-email.mdx diff --git a/content/stellar-contracts/accounts/authorization-flow.mdx b/content/stellar-contracts/accounts/authorization-flow.mdx index 1ae843ea..9385c555 100644 --- a/content/stellar-contracts/accounts/authorization-flow.mdx +++ b/content/stellar-contracts/accounts/authorization-flow.mdx @@ -2,9 +2,30 @@ title: Authorization Flow --- -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. +Authorization in smart accounts is determined by matching the current 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. + +## AuthPayload + +The `AuthPayload` structure is passed as the signature data in `__check_auth`: + +```rust +#[contracttype] +pub struct AuthPayload { + /// Signature data mapped to each signer. + pub signers: Map, + /// Per-context rule IDs, aligned by index with `auth_contexts`. + pub context_rule_ids: Vec, +} +``` + +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()`. + + +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. + ## Detailed Flow + ```mermaid sequenceDiagram participant User @@ -14,105 +35,67 @@ sequenceDiagram participant Verifier participant Policy - User->>SmartAccount: Signatures - SmartAccount->>ContextRule: Match context
(CallContract, Default, ...) - ContextRule->>ContextRule: Filter expired rules
Sort newest first - - loop Each rule until match - Note over ContextRule,DelegatedSigner: Built-in authorization
for delegated signers - ContextRule->>DelegatedSigner: require_auth_for_args() - DelegatedSigner-->>ContextRule: Authorized + User->>SmartAccount: AuthPayload (signers + context_rule_ids) + SmartAccount->>SmartAccount: Authenticate all provided signers - Note over ContextRule,Verifier: Signature verification for external signers - ContextRule->>Verifier: verify() - Verifier-->>ContextRule: Valid + loop Each (context, rule_id) pair + SmartAccount->>ContextRule: Look up rule by ID + ContextRule->>ContextRule: Reject if expired or
context type mismatch - Note over ContextRule,Policy: Policy pre-checks - ContextRule->>Policy: can_enforce() - Policy-->>ContextRule: True/False + Note over ContextRule,DelegatedSigner: Identify authenticated signers
from rule's signer list + Note over ContextRule,Verifier: External signers verified
during initial authentication - alt All checks pass + alt Rule has policies ContextRule->>Policy: enforce() - Policy->>Policy: Update state - ContextRule-->>SmartAccount: ✓ Authorized - else Any check fails - ContextRule->>ContextRule: Try next rule + Policy->>Policy: Validate + update state + Policy-->>ContextRule: Success (or panic) + else No policies + ContextRule->>ContextRule: All rule signers
must be authenticated end end - SmartAccount-->>User: Success + SmartAccount-->>User: Success or Denied ``` -### 1. Rule Collection +### 1. Signer Authentication -The smart account gathers all relevant context rules for evaluation: +All signers in the `AuthPayload` are authenticated upfront: -- Retrieve all non-expired rules for the specific context type -- Include default rules that apply to any context -- Sort specific and default rules by creation time (newest first) - -**Context Type Matching:** -- For a `CallContract(address)` context, both specific `CallContract(address)` rules and `Default` rules are collected -- For a `CreateContract(wasm_hash)` context, both specific `CreateContract(wasm_hash)` rules and `Default` rules are collected -- For any other context, only `Default` rules are collected +- **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 -**Expiration Filtering:** -Rules with `valid_until` set to a ledger sequence that has passed are automatically filtered out during collection. +Any signer in the `AuthPayload` that is not part of any selected context rule is rejected. ### 2. Rule Evaluation -For each rule in order (newest and most specific first): - -#### Step 2.1: Signer Filtering +For each (context, rule_id) pair: -Extract authenticated signers from the rule's signer list. A signer is considered authenticated if: +#### Step 2.1: Rule Lookup and Validation -- **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 +The rule is looked up by its explicit ID. The rule is rejected if: +- It does not exist +- It is expired (`valid_until` has passed) +- Its context type does not match the actual context (`Default` rules match any context) -Only authenticated signers proceed to the next step. +#### Step 2.2: Signer Matching -#### Step 2.2: Policy Validation - -If the rule has attached policies, verify that all can be enforced: - -```rust -for policy in rule.policies { - if !policy.can_enforce(e, account, rule_id, signers, auth_context) { - // This rule fails, try the next rule - } -} -``` - -If any policy's `can_enforce()` returns false, the rule fails and evaluation moves to the next rule. +Authenticated signers are identified from the rule's signer list. #### Step 2.3: Authorization Check The authorization check depends on whether policies are present: **With Policies:** -- Success if all policies passed `can_enforce()` -- The presence of authenticated signers is verified during policy evaluation +- `enforce()` is called on each policy. If any `enforce()` panics, the authorization fails. +- Signer validation is deferred to the policies (e.g., threshold checks). **Without Policies:** -- Success if all signers in the rule are authenticated -- At least one signer must be authenticated for the rule to match - -#### Step 2.4: Rule Precedence - -The first matching rule wins. Newer rules take precedence over older rules for the same context type. This allows overwriting old rules. +- All signers in the rule must be authenticated. +- At least one signer must be present. ### 3. Policy Enforcement -If authorization succeeds, the smart account calls `enforce()` on all matched policies in order: - -```rust -for policy in matched_rule.policies { - policy.enforce(e, account, rule_id, signers, auth_context); -} -``` - -This triggers any necessary state changes such as updating spending counters, recording timestamps, emitting audit events, or modifying allowances. +Policy enforcement happens during rule evaluation. When `enforce()` is called, policies both validate conditions and perform state changes (updating spending counters, recording timestamps, emitting audit events, etc.). Policy enforcement requires the smart account's authorization, ensuring that policies can only be enforced by the account itself. @@ -148,19 +131,14 @@ ContextRule { **Call Context:** `CallContract(dex_address)` -**Authorization Entries:** `[passkey_signature]` +**AuthPayload:** `{ signers: [passkey_signature], context_rule_ids: [2] }` **Flow:** -1. Collect: Rules 2 (specific) and 1 (default) -2. Evaluate Rule 2: - - Signer filtering: Passkey authenticated - - Policy validation: Spending limit check passes - - Authorization check: All policies enforceable → Success -3. Enforce: Update spending counters, emit events +1. Authenticate: Passkey signature verified +2. Look up Rule 2: Not expired, context type matches +3. Enforce: Spending limit policy validates and updates counters 4. Result: Authorized -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). - ### Fallback to Default **Configuration:** @@ -185,16 +163,14 @@ ContextRule { **Call Context:** `CallContract(dex_address)` -**Authorization Entries:** `[ed25519_alice_signature, ed25519_bob_signature]` +**AuthPayload:** `{ signers: [alice_sig, bob_sig], context_rule_ids: [1] }` **Flow:** -1. Collect: Rule 2 filtered out (expired), only Rule 1 collected -2. Evaluate Rule 1: Both Alice and Bob authenticated → Success -3. Enforce: No policies to enforce +1. Authenticate: Alice and Bob signatures verified +2. Look up Rule 1: Default rule matches any context, not expired +3. No policies: Both Alice and Bob authenticated → Success 4. Result: Authorized -The expired session rule is automatically filtered out, and authorization falls back to the default admin rule. - ### Authorization Failure **Configuration:** @@ -210,24 +186,21 @@ ContextRule { **Call Context:** `CallContract(any_address)` -**Authorization Entries:** `[alice_signature]` +**AuthPayload:** `{ signers: [alice_signature], context_rule_ids: [1] }` **Flow:** -1. Collect: Default rule retrieved -2. Evaluate: - - Signer filtering: Only Alice authenticated - - Policy validation: Threshold policy requires 2 signers, only 1 present → Fail -3. No more rules to evaluate +1. Authenticate: Alice signature verified +2. Look up Rule 1: Default rule, not expired +3. Enforce: Threshold policy requires 2 signers, only 1 present → panics 4. Result: Denied (transaction reverts) ## Performance Considerations Protocol 23 optimizations make the authorization flow efficient: -- **Marginal storage read costs**: Reading multiple context rules has negligible cost +- **Marginal storage read costs**: Reading context rules has negligible cost - **Cheaper cross-contract calls**: Calling verifiers and policies is substantially cheaper -The framework enforces limits to maintain predictability: -- Maximum context rules per smart account: 15 +The framework enforces per-rule limits to maintain predictability: - Maximum signers per context rule: 15 - Maximum policies per context rule: 5 diff --git a/content/stellar-contracts/accounts/context-rules.mdx b/content/stellar-contracts/accounts/context-rules.mdx index 6c136584..d22f14d8 100644 --- a/content/stellar-contracts/accounts/context-rules.mdx +++ b/content/stellar-contracts/accounts/context-rules.mdx @@ -32,7 +32,7 @@ List of authorized signers (maximum 15 per rule). Signers can be either delegate For detailed documentation on signers, see [Signers](/stellar-contracts/accounts/signers-and-verifiers). #### Policies -List of policy contracts (maximum 5 per rule). Policies act as enforcement modules that perform read-only prechecks and state-changing enforcement logic. +List of policy contracts (maximum 5 per rule). Policies act as enforcement modules that validate and enforce authorization constraints. For detailed documentation on policies, see [Policies](/stellar-contracts/accounts/policies). @@ -44,28 +44,29 @@ Each rule must contain at least one signer OR one policy. This enables pure poli ### Multiple Rules Per Context 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. -### Rule Precedence -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. +### Explicit Rule Selection +The caller explicitly selects which rule to validate against for each auth context via `AuthPayload::context_rule_ids`. No automatic iteration or rule precedence is applied — the caller chooses the exact rule to use. ### Automatic Expiration -Expired rules are automatically filtered out during authorization evaluation. +Expired rules are rejected during authorization evaluation. ## Context Rule Limits -The framework enforces limits to keep costs predictable and encourage proactive context rule management (remove expired or non-valid rules): +The framework enforces per-rule limits to maintain predictability: -- Maximum context rules per smart account: 15 - Maximum signers per context rule: 15 - Maximum policies per context rule: 5 +There is no upper limit on the total number of context rules per smart account. + ## Authorization Matching -During authorization, the framework: +During authorization, the caller supplies `context_rule_ids` in the `AuthPayload`, one per auth context. For each (context, rule_id) pair: -1. Gathers all non-expired rules matching the context type plus default rules -2. Sorts rules by creation time (newest first) -3. Evaluates rules in order until one matches -4. Returns the first matching rule or fails if none match +1. The rule is looked up by its explicit ID +2. The rule is validated: must not be expired, context type must match +3. Signers are authenticated and policies enforced +4. Authorization succeeds if all checks pass, otherwise fails For detailed documentation on the authorization flow, see [Authorization Flow](/stellar-contracts/accounts/authorization-flow). diff --git a/content/stellar-contracts/accounts/policies.mdx b/content/stellar-contracts/accounts/policies.mdx index 33572eec..83da7a49 100644 --- a/content/stellar-contracts/accounts/policies.mdx +++ b/content/stellar-contracts/accounts/policies.mdx @@ -6,7 +6,7 @@ title: Policies Policies are enforcement modules that add constraints to context rules in smart accounts. While signers determine who can authorize actions, policies determine how those authorizations are enforced, enabling sophisticated patterns like multi-signature thresholds, spending limits, and time-based restrictions. -Policies attach to context rules and execute during the authorization flow. A context rule can have up to **5 policies** attached, and policies are executed in the order they were added. If policies are present in a context rule, **all of them must be enforceable** (i.e., `can_enforce` must return `true`) for the rule to be considered matched and authorized. +Policies attach to context rules and execute during the authorization flow. A context rule can have up to **5 policies** attached, and policies are executed in the order they were added. If policies are present in a context rule, **all of them must succeed** (i.e., `enforce` must not panic) for the rule to be authorized. ## The Policy Trait @@ -16,20 +16,9 @@ All policies must implement the `Policy` trait: pub trait Policy { type AccountParams: FromVal; - /// Read-only pre-check to validate conditions - /// Must be idempotent and side-effect free - /// Returns true if the policy would allow the action - fn can_enforce( - e: &Env, - context: Context, - authenticated_signers: Vec, - rule: ContextRule, - smart_account: Address, - ) -> bool; - - /// State-changing enforcement hook - /// Called when a context rule successfully matches and all can_enforce checks pass - /// Requires smart account authorization + /// Validates and enforces the policy's authorization logic. + /// Must panic if conditions are not met. + /// Requires smart account authorization. fn enforce( e: &Env, context: Context, @@ -37,18 +26,18 @@ pub trait Policy { rule: ContextRule, smart_account: Address, ); - - /// Initialize policy-specific storage and configuration - /// Called when a new context rule with attached policies is created + + /// Initialize policy-specific storage and configuration. + /// Called when a new context rule with attached policies is created. fn install( e: &Env, param: Self::AccountParams, rule: ContextRule, smart_account: Address, ); - - /// Clean up policy data when removed - /// Called when a context rule is removed + + /// Clean up policy data when removed. + /// Called when a context rule is removed. fn uninstall( e: &Env, rule: ContextRule, @@ -59,7 +48,7 @@ pub trait Policy { ## Policy Lifecycle -The four trait methods form a complete lifecycle for policy management: +The three trait methods form a complete lifecycle for policy management: ### Installation @@ -71,28 +60,20 @@ This initialization step allows policies to configure their logic. For example: Installation ensures that each policy has the necessary state and configuration ready before authorization checks begin. -### Pre-check Validation - -Pre-check validation happens during authorization. When the matching algorithm iterates over context rules and their associated policies, it calls `can_enforce()` on each policy as a read-only pre-check. - -This function examines the current state without modifying it, for instance: -- Verifying that a spending limit has not been exceeded -- Checking that enough signers are present -- Validating that time-based restrictions are met - -Policies that fail this check cause the algorithm to move to the next context rule. - ### Enforcement -Enforcement is triggered when a context rule successfully matches. Once all policies in the matched rule pass their `can_enforce()` checks, the smart account calls `enforce()` on each policy. +Enforcement is called during authorization when a context rule is being evaluated. The smart account calls `enforce()` on each policy attached to the rule. Each `enforce()` call both validates that the policy conditions are met and performs any necessary state changes. + +If any policy's `enforce()` call panics, the entire authorization fails. -This state-changing hook allows policies to: -- Update counters +Enforcement allows policies to: +- Validate conditions (e.g., spending limits, signer thresholds, time restrictions) +- Update counters and state - Emit events - Record timestamps - Track authorization activity -For example, a spending limit policy might deduct from the available balance and emit an event documenting the transaction. +For example, a spending limit policy verifies the spending limit has not been exceeded, deducts from the available balance, and emits an event documenting the transaction. ### Uninstallation @@ -150,7 +131,7 @@ Adds a policy to an existing context rule and calls its `install()` function. Th fn remove_policy( e: &Env, context_rule_id: u32, - policy: Address, + policy_id: u32, ); ``` @@ -228,7 +209,7 @@ The policy requires two configuration parameters: 1. **Order Matters**: Policies execute in order; place cheaper checks first 2. **Keep Policies Focused**: Each policy should enforce one concern 3. **Test Policy Combinations**: Ensure multiple policies work together correctly -4. **Handle Errors Gracefully**: Return clear error messages from `enforce` +4. **Handle Errors Clearly**: Panic with descriptive error messages in `enforce` when conditions are not met 5. **Clean Up Storage**: Always implement `uninstall` to free storage 6. **Document Configuration**: Clearly document policy configuration parameters diff --git a/content/stellar-contracts/accounts/signers-and-verifiers.mdx b/content/stellar-contracts/accounts/signers-and-verifiers.mdx index 1196cd97..6fa15e5a 100644 --- a/content/stellar-contracts/accounts/signers-and-verifiers.mdx +++ b/content/stellar-contracts/accounts/signers-and-verifiers.mdx @@ -60,7 +60,10 @@ When `require_auth_for_args` is called from within `__check_auth` (as with deleg ```rust #[contracttype] -pub struct Signatures(pub Map); +pub struct AuthPayload { + pub signers: Map, + pub context_rule_ids: Vec, +} #[contract] pub struct MySmartAccount; @@ -70,10 +73,10 @@ impl CustomAccountInterface for MySmartAccount { fn __check_auth( e: Env, payload: Hash<32>, - signatures: Signatures, + signatures: AuthPayload, auth_contexts: Vec, ) -> Result<(), SmartAccountError> { - for (signer, _) in signatures.0.iter() { + for (signer, _) in signatures.signers.iter() { match signer { // ... Signer::Delegated(addr) => { @@ -89,7 +92,7 @@ impl CustomAccountInterface for MySmartAccount { **Example Scenario** -Consider a scenario where a target contract `CAGCDFLG4WKPYG...` requires authorization from a smart account `CBH6XACZFDCJUHX...`. The smart account will grant authorization only if the G-account `GBDZXYMJ3SLYXCY...` has signed, meaning `Map` has to contain one element that is `Signer::Delegated("GBDZXYMJ3SLYXCY...")`. +Consider a scenario where a target contract `CAGCDFLG4WKPYG...` requires authorization from a smart account `CBH6XACZFDCJUHX...`. The smart account will grant authorization only if the G-account `GBDZXYMJ3SLYXCY...` has signed, meaning `AuthPayload.signers` has to contain one element with key `Signer::Delegated("GBDZXYMJ3SLYXCY...")`. ```mermaid graph LR @@ -139,7 +142,7 @@ When simulating this transaction, the following authorization entry is returned. The client implementation requires constructing two authorization entries: -1. Replace `"signature": "void"` with the proper `Signatures(Map)` structure +1. Replace `"signature": "void"` with the proper `AuthPayload` structure (containing `signers` map and `context_rule_ids`) 2. Create the missing authorization entry for the delegated signer's `__check_auth` call The following typescript code demonstrates this process: @@ -164,7 +167,7 @@ async function signAndSendTx( const signedAuths: SorobanAuthorizationEntry[] = []; - // 1) Construct the 1st auth entry: `Signatures(pub Map)` with `Signer::Delegated(Address)` + // 1) Construct the 1st auth entry: `AuthPayload` with `Signer::Delegated(Address)` const sigInnerMap = ScVal.scvMap([ new xdr.ScMapEntry({ key: ScVal.scvVec([ @@ -326,7 +329,10 @@ In the case of external signers verification is offloaded to specialized verifie ```rust #[contracttype] -pub struct Signatures(pub Map); +pub struct AuthPayload { + pub signers: Map, + pub context_rule_ids: Vec, +} #[contract] pub struct MySmartAccount; @@ -336,10 +342,10 @@ impl CustomAccountInterface for MySmartAccount { fn __check_auth( e: Env, payload: Hash<32>, - signatures: Signatures, + signatures: AuthPayload, auth_contexts: Vec, ) -> Result<(), SmartAccountError> { - for (signer, sig_data) in signatures.0.iter() { + for (signer, sig_data) in signatures.signers.iter() { match signer { Signer::External(verifier, key_data) => { let sig_payload = Bytes::from_array(e, &signature_payload.to_bytes().to_array()); @@ -365,7 +371,7 @@ This separation provides forward compatibility: when new cryptographic curves ar **Example Scenario** -Consider again a target contract `CAGCDFLG4WKPYG...` that requires authorization from a smart account `CBH6XACZFDCJUHX...`. This time the smart account will grant authorization only if the Ed25519 public key `2b6bad0cfdb3d4b6f2cd...` has signed, meaning `Map` has to contain one element that is `Signer::External("CDLDYJWEZSM6IAI4...", "2b6bad0cfdb3d4b6f2cd...")` and its signature. +Consider again a target contract `CAGCDFLG4WKPYG...` that requires authorization from a smart account `CBH6XACZFDCJUHX...`. This time the smart account will grant authorization only if the Ed25519 public key `2b6bad0cfdb3d4b6f2cd...` has signed, meaning `AuthPayload.signers` has to contain one element with key `Signer::External("CDLDYJWEZSM6IAI4...", "2b6bad0cfdb3d4b6f2cd...")` and its signature as the value. ```mermaid graph LR @@ -444,10 +450,22 @@ fn add_signer( e: &Env, context_rule_id: u32, signer: Signer, +) -> u32; +``` + +Adds a signer to an existing context rule and returns the assigned signer ID. The rule must not exceed the maximum of 15 signers. + +### Batch Adding Signers + +```rust +fn batch_add_signer( + e: &Env, + context_rule_id: u32, + signers: Vec, ); ``` -Adds a signer to an existing context rule. The rule must not exceed the maximum of 15 signers. +Adds multiple signers to an existing context rule at once. **Important:** @@ -460,11 +478,11 @@ When adding signers to rules with threshold policies, administrators must manual fn remove_signer( e: &Env, context_rule_id: u32, - signer: Signer, + signer_id: u32, ); ``` -Removes a signer from an existing context rule. The rule must maintain at least one signer OR one policy after removal. +Removes a signer by its ID from an existing context rule. The rule must maintain at least one signer OR one policy after removal. **Important:** diff --git a/content/stellar-contracts/accounts/smart-account.mdx b/content/stellar-contracts/accounts/smart-account.mdx index e236be6f..b31d3fac 100644 --- a/content/stellar-contracts/accounts/smart-account.mdx +++ b/content/stellar-contracts/accounts/smart-account.mdx @@ -71,7 +71,7 @@ Signers define who can authorize operations. The framework supports both **deleg For detailed documentation, see [Signers and Verifiers](/stellar-contracts/accounts/signers-and-verifiers). ### Policies -Policies act as enforcement modules attached to context rules. They perform read-only prechecks and can update state to enforce limits or workflows. +Policies act as enforcement modules attached to context rules. They validate and enforce constraints during authorization, panicking if conditions are not met. For detailed documentation, see [Policies](/stellar-contracts/accounts/policies). @@ -79,11 +79,11 @@ This separation allows for clean composition of authorization requirements while ## Authorization Flow -Authorization is determined by matching the current call context against the account's context rules: +Authorization is determined by explicitly selecting context rules via `AuthPayload::context_rule_ids`: -1. **Rule Collection**: Retrieve all non-expired rules for the specific context type and default rules -2. **Rule Evaluation**: For each rule (newest first), authenticate signers and validate policies -3. **Policy Enforcement**: If enough signers are authenticated and policy prechecks succeed, trigger policy state changes +1. **Rule Selection**: The caller supplies one rule ID per auth context in the `AuthPayload` +2. **Rule Evaluation**: For each (context, rule_id) pair, verify the rule is not expired and context type matches, then authenticate signers +3. **Policy Enforcement**: If policies are present, call `enforce()` on each. If no policies, all rule signers must be authenticated. 4. **Result**: Grant or deny authorization For detailed documentation, see [Authorization Flow](/stellar-contracts/accounts/authorization-flow). diff --git a/content/stellar-contracts/changelog.mdx b/content/stellar-contracts/changelog.mdx index 62a2751b..4b737221 100644 --- a/content/stellar-contracts/changelog.mdx +++ b/content/stellar-contracts/changelog.mdx @@ -2,6 +2,50 @@ title: Changelog --- + +# [v0.7.0](https://github.com/OpenZeppelin/stellar-contracts/releases/tag/v0.7.0) - 2026-04-XX + +## What's New + +* **Governor**: On-chain governance with proposals, voting, counting, and execution +* **Votes**: Delegation-based voting power tracking with historical checkpoints +* **Fungible Votes Extension**: Voting power tracking for fungible tokens +* **Non-Fungible Votes Extension**: Voting power tracking for NFTs +* **ZK Email DKIM Registry**: On-chain DKIM public key hash registry for zkEmail verification +* **RWA Compliance Modules**: Modular compliance system with hook-based architecture + +## Breaking Changes + +* **Policy trait**: `can_enforce()` method removed; `enforce()` now performs both validation and state changes (must panic if conditions not met) +* **Smart Account**: Automatic rule iteration removed; callers must supply `context_rule_ids` in `AuthPayload` to explicitly select rules. `remove_signer` and `remove_policy` now take IDs (`u32`) instead of `Signer`/`Address`. Global signer/policy registry with ID-based references. +* **Upgradeable**: `#[derive(Upgradeable)]` and `#[derive(UpgradeableMigratable)]` macros removed; implement `Upgradeable` trait directly with migration pattern guidelines +* **Math**: `SorobanMulDiv` trait removed; replaced with free functions in `i128_fixed_point` and `i256_fixed_point` modules. `Rounding::Truncate` variant added. +* **Timelock**: Delays now use ledger sequence numbers instead of timestamps +* **Capped extension**: `check_cap` now takes explicit `total_supply` parameter +* **Soroban SDK**: Updated from 23.4.0 to 25.3.0 + +## What's Changed + +* Governor implementation in https://github.com/OpenZeppelin/stellar-contracts/pull/563 +* Votes module in https://github.com/OpenZeppelin/stellar-contracts/pull/552 +* Dynamic quorum for governor in https://github.com/OpenZeppelin/stellar-contracts/pull/647 +* Queue for governance in https://github.com/OpenZeppelin/stellar-contracts/pull/659 +* Governor timelock example in https://github.com/OpenZeppelin/stellar-contracts/pull/665 +* ZK Email DKIM Registry in https://github.com/OpenZeppelin/stellar-contracts/pull/592 +* Fixed-point math refactor in https://github.com/OpenZeppelin/stellar-contracts/pull/580 +* Upgradeable simplification in https://github.com/OpenZeppelin/stellar-contracts/pull/585 +* Ledger-based timelock timing in https://github.com/OpenZeppelin/stellar-contracts/pull/569 +* RWA compliance modules in https://github.com/OpenZeppelin/stellar-contracts/pull/607 +* Smart account: remove fingerprints in https://github.com/OpenZeppelin/stellar-contracts/pull/663 +* Smart account: new sign digest in https://github.com/OpenZeppelin/stellar-contracts/pull/655 +* Smart account: canonical signers in https://github.com/OpenZeppelin/stellar-contracts/pull/657 +* Smart account: weighted threshold example in https://github.com/OpenZeppelin/stellar-contracts/pull/666 +* Fungible: explicit total_supply in capped in https://github.com/OpenZeppelin/stellar-contracts/pull/662 +* Soroban SDK update in https://github.com/OpenZeppelin/stellar-contracts/pull/615 + +[Changes][v0.7.0] + + # [v0.6.0](https://github.com/OpenZeppelin/stellar-contracts/releases/tag/v0.6.0) - 2026-01-26 @@ -157,6 +201,7 @@ In this release, you can find: [Changes][v0.1.0] +[v0.7.0]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.6.0...v0.7.0 [v0.6.0]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.5.1...v0.6.0 [v0.5.1]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.5.0...v0.5.1 [v0.5.0]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.4.1...v0.5.0 diff --git a/content/stellar-contracts/governance/governor.mdx b/content/stellar-contracts/governance/governor.mdx new file mode 100644 index 00000000..0ae87ec7 --- /dev/null +++ b/content/stellar-contracts/governance/governor.mdx @@ -0,0 +1,272 @@ +--- +title: Governor +--- + +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/governance/src/governor) + +The Governor module implements on-chain governance for Soroban contracts, providing the core primitives for decentralized decision-making: proposal creation, voting, counting, and execution. + +## Governance Flow + +The standard governance lifecycle follows this sequence: + +1. **Propose**: A user with sufficient voting power creates a proposal +2. **Vote**: Token holders vote during the voting period +3. **Execute**: Successful proposals (meeting quorum and vote thresholds) can be executed +4. **Cancel**: Proposals can be canceled by authorized accounts unless already Executed, Expired, or Cancelled + +When using the optional **Queue** mechanism (e.g., `TimelockControl`), an additional step is added: + +1. **Propose** → 2. **Vote** → 3. **Queue** → 4. **Execute** + +To enable queuing, override `proposals_need_queuing` to return `true`. + +## The Governor Trait + +The `Governor` trait defines the core governance interface: + +### Configuration + +```rust +fn name(e: &Env) -> String; +fn version(e: &Env) -> String; +fn voting_delay(e: &Env) -> u32; +fn voting_period(e: &Env) -> u32; +fn proposal_threshold(e: &Env) -> u128; +fn get_token_contract(e: &Env) -> Address; +fn counting_mode(e: &Env) -> Symbol; +``` + +- **`voting_delay`**: Number of ledgers between proposal creation and the start of voting. +- **`voting_period`**: Number of ledgers during which voting is open. +- **`proposal_threshold`**: Minimum voting power required to create a proposal. +- **`get_token_contract`**: Address of the token contract that implements the [Votes](/stellar-contracts/governance/votes) trait. + +### Query Methods + +```rust +fn has_voted(e: &Env, proposal_id: BytesN<32>, account: Address) -> bool; +fn quorum(e: &Env, ledger: u32) -> u128; +fn proposal_state(e: &Env, proposal_id: BytesN<32>) -> ProposalState; +fn proposal_snapshot(e: &Env, proposal_id: BytesN<32>) -> u32; +fn proposal_deadline(e: &Env, proposal_id: BytesN<32>) -> u32; +fn proposal_proposer(e: &Env, proposal_id: BytesN<32>) -> Address; +fn get_proposal_id( + e: &Env, + targets: Vec
, + functions: Vec, + args: Vec>, + description_hash: BytesN<32>, +) -> BytesN<32>; +``` + +The proposal ID is a deterministic keccak256 hash of the XDR-serialized targets, functions, args, and description hash, allowing anyone to compute the ID without storing the full proposal data. + +### Lifecycle Methods + +```rust +fn propose( + e: &Env, + targets: Vec
, + functions: Vec, + args: Vec>, + description: String, + proposer: Address, +) -> BytesN<32>; + +fn cast_vote( + e: &Env, + proposal_id: BytesN<32>, + vote_type: u32, + reason: String, + voter: Address, +) -> u128; + +fn queue( + e: &Env, + targets: Vec
, + functions: Vec, + args: Vec>, + description_hash: BytesN<32>, + eta: u32, + operator: Address, +) -> BytesN<32>; + +fn execute( + e: &Env, + targets: Vec
, + functions: Vec, + args: Vec>, + description_hash: BytesN<32>, + executor: Address, +) -> BytesN<32>; + +fn cancel( + e: &Env, + targets: Vec
, + functions: Vec, + args: Vec>, + description_hash: BytesN<32>, + operator: Address, +) -> BytesN<32>; + +fn proposals_need_queuing(e: &Env) -> bool; // defaults to false +``` + + +**`execute` and `cancel` have no default implementation.** The implementer must define who is authorized to call these functions. For example, open execution (anyone can trigger a succeeded proposal) or restricted execution (only a timelock contract or specific role). + + +## Proposal States + +```rust +pub enum ProposalState { + Pending = 0, // Voting has not started + Active = 1, // Voting is ongoing + Defeated = 2, // Voting ended without success + Canceled = 3, // Cancelled by authorized account + Succeeded = 4, // Met quorum and vote thresholds + Queued = 5, // Queued for execution (via extension) + Expired = 6, // Expired after queuing (via extension) + Executed = 7, // Successfully executed +} +``` + +States are divided into: +- **Time-based** (Pending, Active, Defeated): Derived from the current ledger relative to the voting schedule. Never stored explicitly. +- **Explicit** (Canceled, Succeeded, Queued, Expired, Executed): Persisted in storage. Once set, they take precedence over time-based derivation. + +## Default Counting + +The default counting implementation provides **simple counting**: + +- **Vote types**: Against (0), For (1), Abstain (2) +- **Vote success**: `for` votes strictly exceed `against` votes +- **Quorum**: Sum of `for` and `abstain` votes meets or exceeds the quorum value in effect at the proposal's `vote_snapshot` ledger + +Quorum values are stored as checkpoints, so updates do not retroactively affect existing proposals. + +## Dynamic Quorum + +The default `quorum()` implementation uses checkpoint-based fixed quorum. For supply-relative quorum (e.g., "10% of total supply"), override `quorum()` to compute the value dynamically from on-chain state at the requested ledger. + + +When overriding `quorum()`, do not use `set_quorum`/`get_quorum` as those are designed for fixed checkpoints. Also ensure that configurable parameters are themselves queried at the historical ledger to avoid retroactively changing the outcome of existing proposals. + + +## Extensions + +### GovernorSettings + +Provides configurable parameters for `voting_delay`, `voting_period`, and `proposal_threshold`. Override the corresponding trait methods to read from storage initialized during construction. + +### TimelockControl + +Integrates the Governor with a [Timelock Controller](/stellar-contracts/governance/timelock-controller) for delayed execution. When enabled: +- Override `proposals_need_queuing` to return `true` +- `queue()` transitions proposals from `Succeeded` to `Queued` +- `execute()` requires proposals to be in `Queued` state +- The timelock contract enforces the delay before execution + +## Security Considerations + +### Flash Loan Voting Attack + +An attacker could borrow voting tokens, vote, and return them within the same transaction. This implementation mitigates this with **snapshot-based voting power**: + +1. **Proposer snapshot** (`current_ledger - 1`): Prevents flash-loaning tokens and creating a proposal in the same transaction. +2. **Voter snapshot** (`vote_start`): Voters' power is looked up at the `vote_start` ledger. Since checkpoints record state after all transactions in a ledger are finalized, a flash loan within the same ledger shows a net-zero balance. + +### Proposal Spam + +The **proposal threshold** requires proposers to hold a minimum amount of voting power, making spam attacks economically costly. + +### Governance Capture + +- **Quorum requirements** ensure minimum participation +- **Voting delay** gives token holders time to position themselves before voting starts + +## Events + +| Event | Topics | Data | +|-------|--------|------| +| `ProposalCreated` | `proposal_id`, `proposer` | `targets`, `functions`, `args`, `vote_snapshot`, `vote_end`, `description` | +| `VoteCast` | `voter`, `proposal_id` | `vote_type`, `weight`, `reason` | +| `ProposalQueued` | `proposal_id` | `eta` | +| `ProposalExecuted` | `proposal_id` | — | +| `ProposalCancelled` | `proposal_id` | — | +| `QuorumChanged` | — | `old_quorum`, `new_quorum` | + +## Errors + +| Error | Code | Description | +|-------|------|-------------| +| `ProposalNotFound` | 5000 | The proposal was not found | +| `ProposalAlreadyExists` | 5001 | A proposal with the same parameters already exists | +| `InsufficientProposerVotes` | 5002 | The proposer lacks sufficient voting power | +| `EmptyProposal` | 5003 | The proposal contains no actions | +| `InvalidProposalLength` | 5004 | Mismatched targets/functions/args vector lengths | +| `ProposalNotActive` | 5005 | Voting is not currently open | +| `ProposalNotSuccessful` | 5006 | The proposal has not succeeded | +| `ProposalNotQueued` | 5007 | The proposal has not been queued | +| `ProposalAlreadyExecuted` | 5008 | The proposal was already executed | +| `ProposalNotCancellable` | 5009 | The proposal is in a non-cancellable state | +| `AlreadyVoted` | 5016 | The account has already voted on this proposal | +| `InvalidVoteType` | 5017 | Invalid vote type (must be 0, 1, or 2) | +| `QuorumNotSet` | 5018 | Quorum has not been configured | +| `QueueNotEnabled` | 5022 | Queuing is not enabled for this governor | + +## Example + +```rust +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Symbol, Val, Vec}; +use stellar_governance::governor::{self, storage, Governor, hash_proposal, get_proposal_snapshot}; +use stellar_macros::only_role; + +#[contract] +pub struct MyGovernor; + +#[contractimpl] +impl Governor for MyGovernor { + // Open execution — anyone can trigger a succeeded proposal + fn execute( + e: &Env, + targets: Vec
, + functions: Vec, + args: Vec>, + description_hash: BytesN<32>, + _executor: Address, + ) -> BytesN<32> { + let proposal_id = hash_proposal(e, &targets, &functions, &args, &description_hash); + let quorum = Self::quorum(e, get_proposal_snapshot(e, &proposal_id)); + storage::execute( + e, targets, functions, args, &description_hash, + Self::proposals_need_queuing(e), quorum, + ) + } + + // Only the original proposer can cancel + fn cancel( + e: &Env, + targets: Vec
, + functions: Vec, + args: Vec>, + description_hash: BytesN<32>, + operator: Address, + ) -> BytesN<32> { + let proposal_id = storage::hash_proposal( + e, &targets, &functions, &args, &description_hash, + ); + let proposer = storage::get_proposal_proposer(e, &proposal_id); + assert!(operator == proposer); + operator.require_auth(); + storage::cancel(e, targets, functions, args, &description_hash) + } +} +``` + +## See Also + +- [Votes](/stellar-contracts/governance/votes) +- [Timelock Controller](/stellar-contracts/governance/timelock-controller) +- [Fungible Token](/stellar-contracts/tokens/fungible/fungible) diff --git a/content/stellar-contracts/governance/timelock-controller.mdx b/content/stellar-contracts/governance/timelock-controller.mdx index a07fd022..4c3230b9 100644 --- a/content/stellar-contracts/governance/timelock-controller.mdx +++ b/content/stellar-contracts/governance/timelock-controller.mdx @@ -37,7 +37,7 @@ Timelocked operations follow a specific lifecycle: ### Schedule -When a proposer calls `schedule_operation`, the `OperationState` moves from `Unset` to `Waiting`. This starts a timer that must be greater than or equal to the minimum delay. The timer expires at a timestamp accessible through `get_timestamp`. Once the timer expires, the `OperationState` automatically moves to the `Ready` state. At this point, it can be executed. +When a proposer calls `schedule_operation`, the `OperationState` moves from `Unset` to `Waiting`. This starts a timer that must be greater than or equal to the minimum delay (specified in ledger sequence numbers). The timer expires at a ledger sequence accessible through `get_operation_ledger`. Once the current ledger passes that point, the `OperationState` automatically moves to the `Ready` state. At this point, it can be executed. ### Execute diff --git a/content/stellar-contracts/governance/votes.mdx b/content/stellar-contracts/governance/votes.mdx new file mode 100644 index 00000000..9b2584b1 --- /dev/null +++ b/content/stellar-contracts/governance/votes.mdx @@ -0,0 +1,116 @@ +--- +title: Votes +--- + +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/governance/src/votes) + +The Votes module provides utilities for tracking voting power per account with historical checkpoints. It supports delegation and historical vote queries at any past ledger sequence number. + +## Core Concepts + +### Voting Units + +The base unit of voting power, typically mapped 1:1 with token balance. Voting units are tracked independently from token balances — the implementing contract must call `transfer_voting_units` on every balance change (mint, burn, transfer) to keep them in sync. + +### Delegation + +Only **delegated** voting power counts as votes. Undelegated voting units are not counted. An account must call `delegate` (even to itself) before its votes are counted. There is no explicit "undelegate" operation — to reclaim voting power, an account delegates to itself (self-delegation). + +### Checkpoints + +Historical snapshots of voting power at specific ledger sequence numbers. These enable querying past voting power for governance proposals, preventing manipulation through flash loans. + +## The Votes Trait + +```rust +#[contracttrait] +pub trait Votes { + /// Returns the current voting power (delegated votes) of an account. + fn get_votes(e: &Env, account: Address) -> u128; + + /// Returns the voting power at a specific past ledger sequence number. + fn get_votes_at_checkpoint(e: &Env, account: Address, ledger: u32) -> u128; + + /// Returns the current total supply of voting units. + fn get_total_supply(e: &Env) -> u128; + + /// Returns the total supply at a specific past ledger sequence number. + fn get_total_supply_at_checkpoint(e: &Env, ledger: u32) -> u128; + + /// Returns the current delegate for an account (None if never delegated). + fn get_delegate(e: &Env, account: Address) -> Option
; + + /// Delegates voting power from `account` to `delegatee`. + fn delegate(e: &Env, account: Address, delegatee: Address); +} +``` + +All query methods return `0` if the account has no voting power or does not exist. + +## Integration with Token Contracts + +The implementing contract must call `transfer_voting_units` on every balance change to keep voting power in sync with token balances: + +```rust +use stellar_governance::votes::{ + delegate, get_votes, get_votes_at_checkpoint, transfer_voting_units, +}; + +// Override the token contract's transfer to update voting units: +pub fn transfer(e: &Env, from: Address, to: Address, amount: i128) { + // ... perform transfer logic ... + transfer_voting_units(e, Some(&from), Some(&to), amount as u128); +} + +// For minting (no sender): +pub fn mint(e: &Env, to: Address, amount: i128) { + // ... perform mint logic ... + transfer_voting_units(e, None, Some(&to), amount as u128); +} + +// For burning (no receiver): +pub fn burn(e: &Env, from: Address, amount: i128) { + // ... perform burn logic ... + transfer_voting_units(e, Some(&from), None, amount as u128); +} + +// Expose delegation: +pub fn delegate(e: &Env, account: Address, delegatee: Address) { + votes::delegate(e, &account, &delegatee); +} +``` + +## Free Functions + +In addition to the trait, the module exports free functions for direct use: + +| Function | Description | +|----------|-------------| +| `transfer_voting_units(e, from, to, amount)` | Updates voting units on balance changes. `from`/`to` are `Option<&Address>` for mint/burn. | +| `get_voting_units(e, account)` | Returns the raw voting units (not delegated votes) for an account. | +| `get_checkpoint(e, account, pos)` | Returns a specific checkpoint by position index. | +| `num_checkpoints(e, account)` | Returns the number of checkpoints for an account. | +| `delegate(e, account, delegatee)` | Delegates voting power. Requires authorization from `account`. | + +## Events + +| Event | Topics | Data | +|-------|--------|------| +| `DelegateChanged` | `delegator` | `from_delegate: Option
`, `to_delegate: Address` | +| `DelegateVotesChanged` | `delegate` | `previous_votes: u128`, `new_votes: u128` | + +## Errors + +| Error | Code | Description | +|-------|------|-------------| +| `FutureLookup` | 4100 | The queried ledger is in the future | +| `MathOverflow` | 4101 | Arithmetic overflow occurred | +| `InsufficientVotingUnits` | 4102 | Attempting to transfer more voting units than available | +| `SameDelegate` | 4103 | Attempting to delegate to the already-set delegate | +| `CheckpointNotFound` | 4104 | A checkpoint that was expected to exist was not found | + +## See Also + +- [Governor](/stellar-contracts/governance/governor) +- [Fungible Token](/stellar-contracts/tokens/fungible/fungible) (Votes extension) +- [Non-Fungible Token](/stellar-contracts/tokens/non-fungible/non-fungible) (Votes extension) diff --git a/content/stellar-contracts/index.mdx b/content/stellar-contracts/index.mdx index 25e9c1a2..066c82b0 100644 --- a/content/stellar-contracts/index.mdx +++ b/content/stellar-contracts/index.mdx @@ -24,6 +24,8 @@ for access control and contract management. ## Governance +* **[Governor](/stellar-contracts/governance/governor)**: On-chain governance with proposals, voting, and execution. +* **[Votes](/stellar-contracts/governance/votes)**: Delegation-based voting power tracking with historical checkpoints. * **[Timelock Controller](/stellar-contracts/governance/timelock-controller)**: Enforce time delays on transaction execution, allowing users to exit the system if they disagree with a decision before it is executed. ## Utilities @@ -36,6 +38,10 @@ for access control and contract management. * **[Fee Abstraction](/stellar-contracts/fee-abstraction)**: Enable users to pay transaction fees in tokens (e.g. USDC) while relayers cover XLM fees. +## ZK Email + +* **[DKIM Registry](/stellar-contracts/zk-email)**: On-chain DKIM public key hash registry for zkEmail verification systems. + ## Security and Audits Our contracts are built with security as a top priority. You can find our audit reports [here](https://github.com/OpenZeppelin/stellar-contracts/tree/main/audits). @@ -66,6 +72,7 @@ Similarly, utilities and other modules have their own error codes: * Accounts: `3XXX` * Governance: `4XXX` * Fee Abstraction: `5XXX` +* ZK Email: `6XXX` ## Important Notes As a deliberate design choice, this library manages the TTL for temporary and persistent storage items. diff --git a/content/stellar-contracts/tokens/fungible/fungible.mdx b/content/stellar-contracts/tokens/fungible/fungible.mdx index da4666db..b1c12891 100644 --- a/content/stellar-contracts/tokens/fungible/fungible.mdx +++ b/content/stellar-contracts/tokens/fungible/fungible.mdx @@ -101,6 +101,11 @@ The `FungibleBlockList` trait extends the `FungibleToken` trait to provide a blo can be managed by an authorized account. This extension ensures that blocked accounts cannot transfer/receive tokens, or approve token transfers. +### - Votes +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/fungible/extensions/votes) + +The `FungibleVotes` extension integrates with the governance [Votes](/stellar-contracts/governance/votes) module to track voting power based on token balances. It overrides `transfer`, `transfer_from`, `burn`, and `burn_from` to call `transfer_voting_units` after each balance change, enabling delegation-based governance. Token holders must call `delegate` (even to themselves) before their voting power is counted. + ## Stellar Asset Contract (SAC) The Stellar Asset Contract (SAC) is a special built-in implementation of [CAP-46-6 Smart Contract Standardized Asset](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0046-06.md) and [SEP-41 Token Interface](https://developers.stellar.org/docs/tokens/token-interface). The SAC acts as a bridge between traditional Stellar assets and Soroban smart contracts. diff --git a/content/stellar-contracts/tokens/non-fungible/non-fungible.mdx b/content/stellar-contracts/tokens/non-fungible/non-fungible.mdx index c40c3f0f..ff6fdceb 100644 --- a/content/stellar-contracts/tokens/non-fungible/non-fungible.mdx +++ b/content/stellar-contracts/tokens/non-fungible/non-fungible.mdx @@ -103,3 +103,8 @@ This extension is build around the contract variant `Enumerable`. Here is an exa The `NonFungibleRoyalties` trait extends the `NonFungibleToken` trait to provide royalty information for tokens, similar to ERC-2981 standard. This allows marketplaces to query royalty information and pay appropriate fees to creators. Note: The royalties extension allows both collection-wide default royalties and per-token royalty settings. + +### - Votes +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/non_fungible/extensions/votes) + +The `NonFungibleVotes` extension integrates with the governance [Votes](/stellar-contracts/governance/votes) module. Each NFT represents one voting unit. It overrides `transfer`, `transfer_from`, `burn`, and `burn_from` to call `transfer_voting_units` after each balance change. Token holders must call `delegate` (even to themselves) before their voting power is counted. diff --git a/content/stellar-contracts/tokens/rwa/rwa.mdx b/content/stellar-contracts/tokens/rwa/rwa.mdx index b208b777..3f8d7919 100644 --- a/content/stellar-contracts/tokens/rwa/rwa.mdx +++ b/content/stellar-contracts/tokens/rwa/rwa.mdx @@ -294,6 +294,30 @@ graph TD The compliance contract is designed to be shared across multiple RWA tokens, with each hook function accepting a `token` parameter to identify the calling token. +### - Compliance Modules +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/compliance/modules) + +Individual compliance modules implement the `ComplianceModule` trait, which defines hooks that the compliance contract forwards to: + +```rust +pub trait ComplianceModule { + fn on_transfer(e: &Env, token: Address, from: Address, to: Address, amount: i128); + fn on_created(e: &Env, token: Address, to: Address, amount: i128); + fn on_destroyed(e: &Env, token: Address, from: Address, amount: i128); + fn can_transfer(e: &Env, token: Address, from: Address, to: Address, amount: i128) -> bool; + fn can_create(e: &Env, token: Address, to: Address, amount: i128) -> bool; + fn name(e: &Env) -> String; + fn get_compliance_address(e: &Env) -> Address; + fn set_compliance_address(e: &Env, compliance: Address); +} +``` + +Modules can be registered for specific hooks via the compliance contract. The `ComplianceHook` enum defines which hooks a module handles: `Transferred`, `Created`, `Destroyed`, `CanTransfer`, `CanCreate`. + + +State-modifying hooks (`on_transfer`, `on_created`, `on_destroyed`) should restrict their caller to the compliance contract address. Read-only hooks (`can_transfer`, `can_create`) can be exposed more broadly. + + ### - Identity Verifier [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/identity_verifier) diff --git a/content/stellar-contracts/utils/upgradeable.mdx b/content/stellar-contracts/utils/upgradeable.mdx index 5a8407af..3992e0c4 100644 --- a/content/stellar-contracts/utils/upgradeable.mdx +++ b/content/stellar-contracts/utils/upgradeable.mdx @@ -13,29 +13,18 @@ contract developers who can choose to make the contract immutable by simply not the other hand, providing upgradability on a protocol level significantly reduces the risk surface, compared to other smart contract platforms, which lack native support for upgradability. -While Soroban’s built-in upgradability eliminates many of the challenges, related to managing smart contract upgrades +While Soroban's built-in upgradability eliminates many of the challenges, related to managing smart contract upgrades and migrations, certain caveats must still be considered. ## Overview The [upgradeable](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/contract-utils/src/upgradeable) module -provides a lightweight upgradeability framework with additional support for structured and safe migrations. - -It consists of two main components: - -1. ***[`Upgradeable`](#upgrade-only)*** for cases where only the WASM binary needs to be updated. -2. ***[`UpgradeableMigratable`](#upgrade-and-migrate)*** for more advanced scenarios where, in addition to the WASM binary, specific storage entries -must be modified (migrated) during the upgrade process. - -The recommended way to use this module is through the `#[derive(Upgradeable)]` and `#[derive(UpgradeableMigratable)]` -macros. - -They handle the implementation of the necessary functions, allowing developers to focus solely on managing authorizations -and access control. These derive macros also leverage the crate version from the contract’s `Cargo.toml` and set it as -the binary version in the WASM metadata, aligning with the guidelines outlined in -[SEP-49](https://github.com/stellar/stellar-protocol/blob/master/ecosystem%2Fsep-0049.md). +provides a lightweight upgradeability framework with additional guidance for structured and safe migrations. +It consists of two components: +1. **The `Upgradeable` trait** — a standardized entry point for contract upgrades, generating a client (`UpgradeableClient`) for calling upgrades from other contracts. +2. **Migration pattern guidelines** — three documented patterns for handling storage changes during upgrades. While the framework structures the upgrade flow, it does NOT perform deeper checks and verifications such as: @@ -45,112 +34,193 @@ While the framework structures the upgrade flow, it does NOT perform deeper chec * Checking for storage consistency, ensuring that the new contract does not inadvertently introduce storage mismatches. -## Usage - -### Upgrade Only -#### `Upgradeable` +## The Upgradeable Trait -When only the WASM binary needs to be upgraded and no additional migration logic is required, developers should implement -the `UpgradeableInternal` trait. This trait is where authorization and custom access control logic are defined, -specifying who can perform the upgrade. This minimal implementation keeps the focus solely on controlling upgrade -permissions. +The `Upgradeable` trait defines a standardized upgrade entry point: ```rust -use soroban_sdk::{ - contract, contracterror, contractimpl, panic_with_error, symbol_short, Address, Env, -}; -use stellar_contract_utils::upgradeable::UpgradeableInternal; -use stellar_macros::Upgradeable; - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum ExampleContractError { - Unauthorized = 1, +#[contractclient(name = "UpgradeableClient")] +pub trait Upgradeable { + fn upgrade(e: &Env, new_wasm_hash: BytesN<32>, operator: Address); } +``` + +All access control and authorization checks are the implementor's responsibility. Implement this trait directly using `#[contractimpl]` and call the `upgradeable::upgrade()` free function inside: +```rust +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env}; +use stellar_contract_utils::upgradeable::{self as upgradeable, Upgradeable}; +use stellar_macros::only_role; -#[derive(Upgradeable)] #[contract] pub struct ExampleContract; #[contractimpl] -impl ExampleContract { - pub fn __constructor(e: &Env, admin: Address) { - e.storage().instance().set(&symbol_short!("OWNER"), &admin); +impl Upgradeable for ExampleContract { + #[only_role(operator, "admin")] + fn upgrade(e: &Env, new_wasm_hash: BytesN<32>, operator: Address) { + upgradeable::upgrade(e, &new_wasm_hash); } } +``` -impl UpgradeableInternal for ExampleContract { - fn _require_auth(e: &Env, operator: &Address) { - operator.require_auth(); - // `operator` is the invoker of the upgrade function and can be used - // to perform a role-based access control if implemented - let owner: Address = e.storage().instance().get(&symbol_short!("OWNER")).unwrap(); - if *operator != owner { - panic_with_error!(e, ExampleContractError::Unauthorized) - } - } -} +## Storage Migration + +When upgrading contracts, data structures may change (e.g., adding new fields, removing old ones, or restructuring data). This section explains how to handle those changes safely. + +### Why There Is No Migratable Trait + +Migration is deliberately not standardized into a trait: + +- Migration rarely has a single entrypoint: a contract may need to migrate several independent storage structures at different times. +- A fixed trait signature would force all migration arguments into a single `#[contracttype]` struct, removing the flexibility to choose argument types, authorization roles, or split migration across multiple functions. +- Lazy migration (Pattern 2) has no discrete migration call at all. + +The patterns below are therefore guidelines rather than enforced interfaces. + +### The Problem: Host-Level Type Validation + +Soroban validates types at the host level when reading from storage. If a data structure's shape changes between versions, the host traps before the SDK can handle the mismatch: + +```rust +// V1 stored this type: +#[contracttype] +pub struct Config { pub rate: u32 } + +// V2 adds a field. Reading old storage with the new type traps, because +// the host validates field count before the SDK sees the value. +#[contracttype] +pub struct Config { pub rate: u32, pub active: bool } + +// Traps with Error(Object, UnexpectedSize) +let config: Config = e.storage().instance().get(&key).unwrap(); ``` -### Upgrade and Migrate -#### `UpgradeableMigratable` +### Pattern 1: Eager Migration (Bounded Data) -When both the WASM binary and specific storage entries need to be modified as part of the upgrade process, the -`UpgradeableMigratableInternal` trait should be implemented. In addition to defining access control and migration -logic, the developer must specify an associated type that represents the data required for the migration. +For bounded data in instance storage (config, metadata, settings), add a `migrate` function to the upgraded contract that reads old-format data and converts it. Use `set_schema_version` / `get_schema_version` to guard against double invocation. -The `#[derive(UpgradeableMigratable)]` macro manages the sequencing of operations, ensuring that the migration can -only be invoked after a successful upgrade, preventing potential state inconsistencies and storage corruption. +The old type must be defined in the new contract code so the host can deserialize it correctly. ```rust -use soroban_sdk::{ - contract, contracterror, contracttype, panic_with_error, symbol_short, Address, Env, -}; -use stellar_contract_utils::upgradeable::UpgradeableMigratableInternal; -use stellar_macros::UpgradeableMigratable; - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum ExampleContractError { - Unauthorized = 1, +// Old type (matches what v1 stored, field names and types must match) +#[contracttype] +pub struct ConfigV1 { + pub rate: u32, } +// New type #[contracttype] -pub struct Data { - pub num1: u32, - pub num2: u32, +pub struct Config { + pub rate: u32, + pub active: bool, } -#[derive(UpgradeableMigratable)] -#[contract] -pub struct ExampleContract; +const CONFIG_KEY: Symbol = symbol_short!("CONFIG"); -impl UpgradeableMigratableInternal for ExampleContract { - type MigrationData = Data; +pub fn migrate(e: &Env, operator: Address) { + assert!(upgradeable::get_schema_version(e) < 2, "already migrated"); - fn _require_auth(e: &Env, operator: &Address) { - operator.require_auth(); - let owner: Address = e.storage().instance().get(&symbol_short!("OWNER")).unwrap(); - if *operator != owner { - panic_with_error!(e, ExampleContractError::Unauthorized) + let old: ConfigV1 = e.storage().instance().get(&CONFIG_KEY).unwrap(); + let new = Config { rate: old.rate, active: true }; + e.storage().instance().set(&CONFIG_KEY, &new); + + upgradeable::set_schema_version(e, 2); +} +``` + +Migration must happen in a separate transaction after the upgrade completes, or atomically via a third-party upgrader contract (see [Atomic Upgrade and Migration](#atomic-upgrade-and-migration) below). + +### Pattern 2: Lazy Migration (Unbounded Data) + +For unbounded persistent storage (user balances, approvals, etc.), eager migration is impractical as it's impossible to iterate all entries in one transaction without hitting resource limits. + +Instead, use version markers alongside each entry and convert lazily on read: + +```rust +// Old type must match what v1 stored exactly. +#[contracttype] +pub struct BalanceV1 { pub amount: i128 } + +// New type with an added field. +#[contracttype] +pub struct Balance { pub amount: i128, pub frozen: bool } + +#[contracttype] +pub enum StorageKey { + Balance(Address), + BalanceVersion(Address), +} + +fn get_balance(e: &Env, account: &Address) -> Balance { + let version: u32 = e.storage().persistent() + .get(&StorageKey::BalanceVersion(account.clone())) + .unwrap_or(1); + + match version { + 1 => { + let v1: BalanceV1 = e.storage().persistent() + .get(&StorageKey::Balance(account.clone())).unwrap(); + let migrated = Balance { amount: v1.amount, frozen: false }; + set_balance(e, account, &migrated); + migrated } + _ => e.storage().persistent() + .get(&StorageKey::Balance(account.clone())).unwrap(), } +} + +fn set_balance(e: &Env, account: &Address, balance: &Balance) { + e.storage().persistent() + .set(&StorageKey::BalanceVersion(account.clone()), &2u32); + e.storage().persistent() + .set(&StorageKey::Balance(account.clone()), balance); +} +``` + +### Pattern 3: Enum Wrapper (Plan-Ahead) - fn _migrate(e: &Env, data: &Self::MigrationData) { - e.storage().instance().set(&symbol_short!("DATA_KEY"), data); +For contracts that anticipate future migrations from the start, wrap stored data in a versioned enum. Soroban serializes enum variants as `(tag, data)`, so the host can distinguish between versions without trapping. + +```rust +#[contracttype] +pub enum ConfigEntry { + V1(ConfigV1), +} + +// Store wrapped from day one: +e.storage().instance().set(&key, &ConfigEntry::V1(config)); +``` + +When v2 comes, add a variant and a converter: + +```rust +#[contracttype] +pub enum ConfigEntry { + V1(ConfigV1), + V2(ConfigV2), +} + +impl ConfigEntry { + pub fn into_latest(self) -> ConfigV2 { + match self { + ConfigEntry::V1(v1) => ConfigV2 { rate: v1.rate, active: true }, + ConfigEntry::V2(v2) => v2, + } } } ``` + +This pattern cannot work retroactively — reading old bare-struct data as an enum would trap. + + If a rollback is required, the contract can be upgraded to a newer version where the rollback-specific logic is defined and performed as a migration. -#### Atomic upgrade and migration +## Atomic Upgrade and Migration When performing an upgrade, the new implementation only becomes effective after the current invocation completes. This means that if migration logic is included in the new implementation, it cannot be executed within the same diff --git a/content/stellar-contracts/zk-email.mdx b/content/stellar-contracts/zk-email.mdx new file mode 100644 index 00000000..6e27400a --- /dev/null +++ b/content/stellar-contracts/zk-email.mdx @@ -0,0 +1,118 @@ +--- +title: ZK Email (DKIM Registry) +--- + +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/zk-email/src/dkim_registry) + +The ZK Email module provides an on-chain DKIM registry — the trust anchor for zkEmail systems on Soroban. + +## What is DKIM? + +**DKIM (DomainKeys Identified Mail)** is an email authentication standard. When a server sends an email, it signs the message with a private RSA key. The corresponding public key is published in the domain's DNS records. Email clients verify the signature to confirm the email genuinely came from that domain and was not tampered with. + +## What is zkEmail? + +**zkEmail** enables proving facts about an email without revealing the email itself by using zero-knowledge proofs. Since emails are DKIM-signed, a ZK proof can assert that a valid DKIM-signed email from a specific domain contains certain information — provable on-chain without exposing the raw email content. + +Use cases include: +- Proving ownership of a specific email address +- Proving receipt of a payment confirmation from a bank +- Proving an email from a DAO's domain authorized an action + +## The DKIM Registry + +This module stores which DKIM public key hashes are considered valid for which domains. When a ZK proof is verified on-chain, the verifier calls `is_key_hash_valid(domain_hash, public_key_hash)` to confirm the key used to sign the email is still trusted (not rotated or compromised). + +The registry is **hash-function agnostic**: it stores pre-hashed `BytesN<32>` values for both domain names and public keys. Callers hash these off-chain using whatever hash function their system requires (Poseidon, SHA256, Keccak256, etc.). + +## The DKIMRegistry Trait + +```rust +pub trait DKIMRegistry { + /// Returns true if the key hash is registered for the domain AND not revoked. + fn is_key_hash_valid(e: &Env, domain_hash: BytesN<32>, public_key_hash: BytesN<32>) -> bool; + + /// Returns true if the key hash has been globally revoked. + fn is_key_hash_revoked(e: &Env, public_key_hash: BytesN<32>) -> bool; + + /// Registers a DKIM public key hash for a domain. + fn set_dkim_public_key_hash( + e: &Env, + domain_hash: BytesN<32>, + public_key_hash: BytesN<32>, + operator: Address, + ); + + /// Batch registers DKIM public key hashes for a domain. + fn set_dkim_public_key_hashes( + e: &Env, + domain_hash: BytesN<32>, + public_key_hashes: Vec>, + operator: Address, + ); + + /// Globally revokes a DKIM public key hash. Once revoked, it cannot be re-registered. + fn revoke_dkim_public_key_hash(e: &Env, public_key_hash: BytesN<32>, operator: Address); +} +``` + +The read methods (`is_key_hash_valid`, `is_key_hash_revoked`) have default implementations. The write methods (`set_dkim_public_key_hash`, `set_dkim_public_key_hashes`, `revoke_dkim_public_key_hash`) **do not** — they must be implemented with proper access control. + + +**Security Warning**: The base implementations of the write functions intentionally lack authorization controls. Contracts **must** add their own access control (e.g., using `#[only_role]` macros from `stellar_macros`) to restrict who can register and revoke key hashes. + + +## Usage Example + +```rust +use stellar_macros::only_role; +use stellar_zk_email::dkim_registry::{self, DKIMRegistry}; + +#[contract] +pub struct MyRegistry; + +impl DKIMRegistry for MyRegistry { + #[only_role(operator, "governance")] + fn set_dkim_public_key_hash( + e: &Env, + domain_hash: BytesN<32>, + public_key_hash: BytesN<32>, + operator: Address, + ) { + dkim_registry::set_dkim_public_key_hash(e, &domain_hash, &public_key_hash); + } + + #[only_role(operator, "governance")] + fn set_dkim_public_key_hashes( + e: &Env, + domain_hash: BytesN<32>, + public_key_hashes: Vec>, + operator: Address, + ) { + dkim_registry::set_dkim_public_key_hashes(e, &domain_hash, &public_key_hashes); + } + + #[only_role(operator, "governance")] + fn revoke_dkim_public_key_hash( + e: &Env, + public_key_hash: BytesN<32>, + operator: Address, + ) { + dkim_registry::revoke_dkim_public_key_hash(e, &public_key_hash); + } +} +``` + +## Events + +| Event | Topics | Data | +|-------|--------|------| +| `KeyHashRegistered` | `domain_hash` | `public_key_hash` | +| `KeyHashRevoked` | `public_key_hash` | — | + +## Errors + +| Error | Code | Description | +|-------|------|-------------| +| `KeyHashRevoked` | 6000 | The public key hash has been revoked and cannot be re-registered | +| `KeyHashAlreadyRegistered` | 6001 | The public key hash is already registered for the given domain | From a26408c984eb9c244337740bd9779fde5de570e3 Mon Sep 17 00:00:00 2001 From: Ozgun Ozerk Date: Thu, 2 Apr 2026 13:53:15 +0300 Subject: [PATCH 2/7] sidebar entries --- src/navigation/stellar.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/navigation/stellar.json b/src/navigation/stellar.json index 0e03182c..c6441ce6 100644 --- a/src/navigation/stellar.json +++ b/src/navigation/stellar.json @@ -90,6 +90,16 @@ "type": "folder", "name": "Governance", "children": [ + { + "type": "page", + "name": "Governor", + "url": "/stellar-contracts/governance/governor" + }, + { + "type": "page", + "name": "Votes", + "url": "/stellar-contracts/governance/votes" + }, { "type": "page", "name": "Timelock Controller", @@ -145,6 +155,11 @@ "name": "Fee Abstraction", "url": "/stellar-contracts/fee-abstraction" }, + { + "type": "page", + "name": "ZK Email (DKIM Registry)", + "url": "/stellar-contracts/zk-email" + }, { "type": "page", "name": "Changelog", From a751e8ab2dcc533a4a0bc28db909395ebb322c9f Mon Sep 17 00:00:00 2001 From: Ozgun Ozerk Date: Thu, 2 Apr 2026 14:09:27 +0300 Subject: [PATCH 3/7] governor design decisions --- .../stellar-contracts/governance/governor.mdx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/content/stellar-contracts/governance/governor.mdx b/content/stellar-contracts/governance/governor.mdx index 0ae87ec7..b46fc5a9 100644 --- a/content/stellar-contracts/governance/governor.mdx +++ b/content/stellar-contracts/governance/governor.mdx @@ -168,6 +168,28 @@ Integrates the Governor with a [Timelock Controller](/stellar-contracts/governan - `execute()` requires proposals to be in `Queued` state - The timelock contract enforces the delay before execution +## Design Rationale + +### Voting Power Lives on the Token + +The `Governor` trait does not include vote-querying methods like `get_votes`. Instead, voting power is managed entirely by a separate token contract implementing the [Votes](/stellar-contracts/governance/votes) trait. The Governor references this token via `get_token_contract()` and queries it for voting power at specific ledgers. This keeps the Governor focused on proposal lifecycle and counting, while the token handles delegation and checkpointing. + +### Counting is Pluggable + +The default counting mode uses three vote types — Against (0), For (1), Abstain (2) — with simple majority. However, the counting-related methods (`count_vote`, `tally_succeeded`, `quorum_reached`) can all be overridden to implement alternative strategies such as fractional voting, weighted quorum based on total supply, or NFT-based voting schemes. The `counting_mode()` method returns a symbol identifying the active strategy for UI consumption. + +### Queue Logic is Built-In but Disabled by Default + +Queuing is integrated into the base `Governor` trait rather than being a loosely coupled external module. This is a deliberate choice: queue state transitions are tightly coupled with the proposal lifecycle — `execute` must know whether to expect a `Succeeded` or `Queued` state, and `proposal_state` must be able to return `Queued` and `Expired` variants. Extracting this into a separate module would force implementers to manually wire these interactions. + +By default, `proposals_need_queuing()` returns `false`, making queue logic inert. Extensions like `TimelockControl` simply override this to return `true`, which activates the full queuing flow — `queue()` transitions proposals from `Succeeded` to `Queued`, and `execute()` then requires the `Queued` state. This approach means enabling queuing is a single-line override, with no need to modify proposal creation, voting, or execution logic. + +### Execution and Cancellation Require Implementation + +The `execute` and `cancel` functions have no default implementation. This is intentional: access control for these operations varies significantly between deployments. An open governance system may allow anyone to trigger execution of a succeeded proposal, while a guarded system may restrict it to a timelock contract or admin role. Forcing an explicit implementation ensures that the developer consciously decides their authorization model rather than inheriting a default that may not fit. + +Note that the `executor` parameter in `execute` represents the account *triggering* execution, not the entity performing the underlying calls — the Governor contract itself is the caller of the target contracts. + ## Security Considerations ### Flash Loan Voting Attack From 90d36a3b9d8901f124c7327406353647be0f13f1 Mon Sep 17 00:00:00 2001 From: Ozgun Ozerk Date: Thu, 2 Apr 2026 17:01:20 +0300 Subject: [PATCH 4/7] rework governor docs --- .../stellar-contracts/governance/governor.mdx | 267 ++++++++---------- .../stellar-contracts/governance/votes.mdx | 94 +++--- 2 files changed, 182 insertions(+), 179 deletions(-) diff --git a/content/stellar-contracts/governance/governor.mdx b/content/stellar-contracts/governance/governor.mdx index b46fc5a9..9f409eaa 100644 --- a/content/stellar-contracts/governance/governor.mdx +++ b/content/stellar-contracts/governance/governor.mdx @@ -4,189 +4,102 @@ title: Governor [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/governance/src/governor) -The Governor module implements on-chain governance for Soroban contracts, providing the core primitives for decentralized decision-making: proposal creation, voting, counting, and execution. +## Overview -## Governance Flow +The Governor module brings on-chain governance to Soroban contracts. It enables a community of token holders to collectively decide on protocol changes — proposing actions, debating them through votes, and executing the result on-chain — all without a centralized authority. -The standard governance lifecycle follows this sequence: +A typical governance system involves two contracts working together: -1. **Propose**: A user with sufficient voting power creates a proposal -2. **Vote**: Token holders vote during the voting period -3. **Execute**: Successful proposals (meeting quorum and vote thresholds) can be executed -4. **Cancel**: Proposals can be canceled by authorized accounts unless already Executed, Expired, or Cancelled +- A **token contract** with the [Votes](/stellar-contracts/governance/votes) extension, which tracks who holds voting power and allows delegation. +- A **governor contract** implementing the `Governor` trait, which manages the proposal lifecycle: creation, voting, counting, and execution. -When using the optional **Queue** mechanism (e.g., `TimelockControl`), an additional step is added: +The Governor does not store or manage voting power directly. Instead, it references the token contract and queries it for historical voting power at specific ledgers. This separation keeps each contract focused on its own concern. -1. **Propose** → 2. **Vote** → 3. **Queue** → 4. **Execute** +## How Governance Works -To enable queuing, override `proposals_need_queuing` to return `true`. +### The Proposal Lifecycle -## The Governor Trait +Every governance action starts as a **proposal** — a bundle of on-chain calls (targets, functions, arguments) paired with a human-readable description. Here is how a proposal moves through the system: -The `Governor` trait defines the core governance interface: +```mermaid +stateDiagram-v2 + [*] --> Pending: propose() + Pending --> Active: voting delay passes + Active --> Defeated: voting ends, quorum/majority not met + Active --> Succeeded: voting ends, quorum + majority met + Succeeded --> Executed: execute() + Succeeded --> Queued: queue() (if enabled) + Queued --> Executed: execute() + Queued --> Expired: expiration passes + Pending --> Canceled: cancel() + Active --> Canceled: cancel() + Succeeded --> Canceled: cancel() + Queued --> Canceled: cancel() +``` -### Configuration +1. **Propose**: Anyone with enough voting power (above the `proposal_threshold`) creates a proposal. A **voting delay** begins — a buffer period that gives token holders time to acquire tokens or delegate before the vote opens. -```rust -fn name(e: &Env) -> String; -fn version(e: &Env) -> String; -fn voting_delay(e: &Env) -> u32; -fn voting_period(e: &Env) -> u32; -fn proposal_threshold(e: &Env) -> u128; -fn get_token_contract(e: &Env) -> Address; -fn counting_mode(e: &Env) -> Symbol; -``` +2. **Vote**: Once the delay passes, the proposal becomes **Active** and token holders can vote: Against (0), For (1), or Abstain (2). Each voter's power is looked up at the snapshot ledger (when the voting period started), not at the moment they vote. This prevents flash loan attacks. -- **`voting_delay`**: Number of ledgers between proposal creation and the start of voting. -- **`voting_period`**: Number of ledgers during which voting is open. -- **`proposal_threshold`**: Minimum voting power required to create a proposal. -- **`get_token_contract`**: Address of the token contract that implements the [Votes](/stellar-contracts/governance/votes) trait. +3. **Succeed or Defeat**: When the **voting period** ends, the system checks two conditions: + - **Majority**: Do `for` votes strictly exceed `against` votes? + - **Quorum**: Does the sum of `for` and `abstain` votes meet or exceed the required quorum? -### Query Methods + If both conditions are met, the proposal moves to **Succeeded**. Otherwise, it is **Defeated**. -```rust -fn has_voted(e: &Env, proposal_id: BytesN<32>, account: Address) -> bool; -fn quorum(e: &Env, ledger: u32) -> u128; -fn proposal_state(e: &Env, proposal_id: BytesN<32>) -> ProposalState; -fn proposal_snapshot(e: &Env, proposal_id: BytesN<32>) -> u32; -fn proposal_deadline(e: &Env, proposal_id: BytesN<32>) -> u32; -fn proposal_proposer(e: &Env, proposal_id: BytesN<32>) -> Address; -fn get_proposal_id( - e: &Env, - targets: Vec
, - functions: Vec, - args: Vec>, - description_hash: BytesN<32>, -) -> BytesN<32>; -``` +4. **Execute**: A succeeded proposal can be executed, which triggers the on-chain calls it contains. Who can call `execute` is up to the implementer — it can be open to anyone or restricted to a specific role. -The proposal ID is a deterministic keccak256 hash of the XDR-serialized targets, functions, args, and description hash, allowing anyone to compute the ID without storing the full proposal data. +5. **Cancel**: The proposer (or another authorized role) can cancel a proposal at any point before it is executed, expired, or already cancelled. -### Lifecycle Methods +### Optional: Queuing with a Timelock -```rust -fn propose( - e: &Env, - targets: Vec
, - functions: Vec, - args: Vec>, - description: String, - proposer: Address, -) -> BytesN<32>; - -fn cast_vote( - e: &Env, - proposal_id: BytesN<32>, - vote_type: u32, - reason: String, - voter: Address, -) -> u128; - -fn queue( - e: &Env, - targets: Vec
, - functions: Vec, - args: Vec>, - description_hash: BytesN<32>, - eta: u32, - operator: Address, -) -> BytesN<32>; - -fn execute( - e: &Env, - targets: Vec
, - functions: Vec, - args: Vec>, - description_hash: BytesN<32>, - executor: Address, -) -> BytesN<32>; - -fn cancel( - e: &Env, - targets: Vec
, - functions: Vec, - args: Vec>, - description_hash: BytesN<32>, - operator: Address, -) -> BytesN<32>; +For systems that need a safety delay between a successful vote and execution, the Governor supports an optional **queue** step. When enabled, succeeded proposals must first be queued, which starts a timelock delay. During this delay, community members can review the upcoming change and exit the protocol if they disagree. -fn proposals_need_queuing(e: &Env) -> bool; // defaults to false -``` +This flow becomes: **Propose → Vote → Queue → Execute** - -**`execute` and `cancel` have no default implementation.** The implementer must define who is authorized to call these functions. For example, open execution (anyone can trigger a succeeded proposal) or restricted execution (only a timelock contract or specific role). - +To enable queuing, override `proposals_need_queuing` to return `true`. See [Design Rationale](#queue-logic-is-built-in-but-disabled-by-default) for why this is built into the base trait. -## Proposal States +## Voting and Counting -```rust -pub enum ProposalState { - Pending = 0, // Voting has not started - Active = 1, // Voting is ongoing - Defeated = 2, // Voting ended without success - Canceled = 3, // Cancelled by authorized account - Succeeded = 4, // Met quorum and vote thresholds - Queued = 5, // Queued for execution (via extension) - Expired = 6, // Expired after queuing (via extension) - Executed = 7, // Successfully executed -} -``` +### How Votes Are Counted -States are divided into: -- **Time-based** (Pending, Active, Defeated): Derived from the current ledger relative to the voting schedule. Never stored explicitly. -- **Explicit** (Canceled, Succeeded, Queued, Expired, Executed): Persisted in storage. Once set, they take precedence over time-based derivation. +The default counting mode is **simple counting**: -## Default Counting +| Vote Type | Value | Meaning | +|-----------|-------|---------| +| Against | 0 | Opposes the proposal | +| For | 1 | Supports the proposal | +| Abstain | 2 | Counted toward quorum but not toward majority | -The default counting implementation provides **simple counting**: +A proposal succeeds when `for > against` **and** the quorum is reached. Quorum values are stored as checkpoints, so updating the quorum does not retroactively change the outcome of existing proposals. -- **Vote types**: Against (0), For (1), Abstain (2) -- **Vote success**: `for` votes strictly exceed `against` votes -- **Quorum**: Sum of `for` and `abstain` votes meets or exceeds the quorum value in effect at the proposal's `vote_snapshot` ledger +### Custom Counting Strategies -Quorum values are stored as checkpoints, so updates do not retroactively affect existing proposals. +The counting logic is fully pluggable. The default three-type system works for most cases, but you can override the counting methods (`count_vote`, `tally_succeeded`, `quorum_reached`) to implement alternative strategies such as fractional voting, weighted quorum relative to total supply, or NFT-based voting schemes. The `counting_mode()` method returns a symbol identifying the active strategy, which UIs can use for display purposes. -## Dynamic Quorum +### Dynamic Quorum -The default `quorum()` implementation uses checkpoint-based fixed quorum. For supply-relative quorum (e.g., "10% of total supply"), override `quorum()` to compute the value dynamically from on-chain state at the requested ledger. +The default `quorum()` uses a fixed checkpoint-based value. For supply-relative quorum (e.g., "10% of total supply"), override `quorum()` to compute the value dynamically. -When overriding `quorum()`, do not use `set_quorum`/`get_quorum` as those are designed for fixed checkpoints. Also ensure that configurable parameters are themselves queried at the historical ledger to avoid retroactively changing the outcome of existing proposals. +When overriding `quorum()`, ensure that configurable parameters are themselves queried at the historical ledger to avoid retroactively changing the outcome of existing proposals. -## Extensions - -### GovernorSettings - -Provides configurable parameters for `voting_delay`, `voting_period`, and `proposal_threshold`. Override the corresponding trait methods to read from storage initialized during construction. - -### TimelockControl - -Integrates the Governor with a [Timelock Controller](/stellar-contracts/governance/timelock-controller) for delayed execution. When enabled: -- Override `proposals_need_queuing` to return `true` -- `queue()` transitions proposals from `Succeeded` to `Queued` -- `execute()` requires proposals to be in `Queued` state -- The timelock contract enforces the delay before execution - ## Design Rationale ### Voting Power Lives on the Token -The `Governor` trait does not include vote-querying methods like `get_votes`. Instead, voting power is managed entirely by a separate token contract implementing the [Votes](/stellar-contracts/governance/votes) trait. The Governor references this token via `get_token_contract()` and queries it for voting power at specific ledgers. This keeps the Governor focused on proposal lifecycle and counting, while the token handles delegation and checkpointing. - -### Counting is Pluggable - -The default counting mode uses three vote types — Against (0), For (1), Abstain (2) — with simple majority. However, the counting-related methods (`count_vote`, `tally_succeeded`, `quorum_reached`) can all be overridden to implement alternative strategies such as fractional voting, weighted quorum based on total supply, or NFT-based voting schemes. The `counting_mode()` method returns a symbol identifying the active strategy for UI consumption. +The `Governor` trait does not include vote-querying methods. Instead, voting power is managed entirely by a separate token contract implementing the [Votes](/stellar-contracts/governance/votes) trait. The Governor references this token via `get_token_contract()` and queries it for voting power at specific ledgers. This keeps the Governor focused on proposal lifecycle and counting, while the token handles delegation and checkpointing. ### Queue Logic is Built-In but Disabled by Default -Queuing is integrated into the base `Governor` trait rather than being a loosely coupled external module. This is a deliberate choice: queue state transitions are tightly coupled with the proposal lifecycle — `execute` must know whether to expect a `Succeeded` or `Queued` state, and `proposal_state` must be able to return `Queued` and `Expired` variants. Extracting this into a separate module would force implementers to manually wire these interactions. +Queuing is integrated into the base `Governor` trait rather than being an external module. This is a deliberate choice: queue state transitions are tightly coupled with the proposal lifecycle — `execute` must know whether to expect a `Succeeded` or `Queued` state, and `proposal_state` must be able to return `Queued` and `Expired` variants. Extracting this into a separate module would force implementers to manually wire these interactions. -By default, `proposals_need_queuing()` returns `false`, making queue logic inert. Extensions like `TimelockControl` simply override this to return `true`, which activates the full queuing flow — `queue()` transitions proposals from `Succeeded` to `Queued`, and `execute()` then requires the `Queued` state. This approach means enabling queuing is a single-line override, with no need to modify proposal creation, voting, or execution logic. +By default, `proposals_need_queuing()` returns `false`, making queue logic inert. To enable queuing (e.g., for integration with a [Timelock Controller](/stellar-contracts/governance/timelock-controller)), simply override this single method to return `true`. This activates the full queuing flow — `queue()` transitions proposals from `Succeeded` to `Queued`, and `execute()` then requires the `Queued` state — without touching proposal creation, voting, or execution logic. ### Execution and Cancellation Require Implementation -The `execute` and `cancel` functions have no default implementation. This is intentional: access control for these operations varies significantly between deployments. An open governance system may allow anyone to trigger execution of a succeeded proposal, while a guarded system may restrict it to a timelock contract or admin role. Forcing an explicit implementation ensures that the developer consciously decides their authorization model rather than inheriting a default that may not fit. +The `execute` and `cancel` functions have no default implementation. Access control for these operations varies significantly between deployments — an open governance system may allow anyone to trigger execution, while a guarded system may restrict it to a timelock contract or admin role. Forcing an explicit implementation ensures the developer consciously decides their authorization model rather than inheriting a default that may not fit. Note that the `executor` parameter in `execute` represents the account *triggering* execution, not the entity performing the underlying calls — the Governor contract itself is the caller of the target contracts. @@ -208,6 +121,77 @@ The **proposal threshold** requires proposers to hold a minimum amount of voting - **Quorum requirements** ensure minimum participation - **Voting delay** gives token holders time to position themselves before voting starts +## The Governor Trait + +The `Governor` trait defines the full governance interface. Most methods have default implementations — you only need to implement `execute` and `cancel` (for access control) and optionally override configuration methods. + +### Configuration + +```rust +fn name(e: &Env) -> String; +fn version(e: &Env) -> String; +fn voting_delay(e: &Env) -> u32; // ledgers before voting starts +fn voting_period(e: &Env) -> u32; // ledgers during which voting is open +fn proposal_threshold(e: &Env) -> u128; // minimum voting power to propose +fn get_token_contract(e: &Env) -> Address; // the Votes-enabled token contract +fn counting_mode(e: &Env) -> Symbol; // identifies the counting strategy +fn proposals_need_queuing(e: &Env) -> bool; // defaults to false +``` + +### Proposal Lifecycle + +```rust +fn propose(e: &Env, targets: Vec
, functions: Vec, + args: Vec>, description: String, proposer: Address) -> BytesN<32>; + +fn cast_vote(e: &Env, proposal_id: BytesN<32>, vote_type: u32, + reason: String, voter: Address) -> u128; + +fn queue(e: &Env, targets: Vec
, functions: Vec, + args: Vec>, description_hash: BytesN<32>, + eta: u32, operator: Address) -> BytesN<32>; + +fn execute(e: &Env, targets: Vec
, functions: Vec, + args: Vec>, description_hash: BytesN<32>, + executor: Address) -> BytesN<32>; // no default — must implement + +fn cancel(e: &Env, targets: Vec
, functions: Vec, + args: Vec>, description_hash: BytesN<32>, + operator: Address) -> BytesN<32>; // no default — must implement +``` + +### Query Methods + +```rust +fn has_voted(e: &Env, proposal_id: BytesN<32>, account: Address) -> bool; +fn quorum(e: &Env, ledger: u32) -> u128; +fn proposal_state(e: &Env, proposal_id: BytesN<32>) -> ProposalState; +fn proposal_snapshot(e: &Env, proposal_id: BytesN<32>) -> u32; +fn proposal_deadline(e: &Env, proposal_id: BytesN<32>) -> u32; +fn proposal_proposer(e: &Env, proposal_id: BytesN<32>) -> Address; +fn get_proposal_id(e: &Env, targets: Vec
, functions: Vec, + args: Vec>, description_hash: BytesN<32>) -> BytesN<32>; +``` + +The proposal ID is a deterministic keccak256 hash of the proposal parameters, allowing anyone to compute it without storing the full proposal data. + +### Proposal States + +```rust +pub enum ProposalState { + Pending = 0, // Voting has not started + Active = 1, // Voting is ongoing + Defeated = 2, // Voting ended without success + Canceled = 3, // Cancelled by authorized account + Succeeded = 4, // Met quorum and vote thresholds + Queued = 5, // Queued for execution (via extension) + Expired = 6, // Expired after queuing (via extension) + Executed = 7, // Successfully executed +} +``` + +States are divided into **time-based** (Pending, Active, Defeated) — derived from the current ledger, never stored — and **explicit** (all others) — persisted in storage, taking precedence once set. + ## Events | Event | Topics | Data | @@ -243,7 +227,6 @@ The **proposal threshold** requires proposers to hold a minimum amount of voting ```rust use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Symbol, Val, Vec}; use stellar_governance::governor::{self, storage, Governor, hash_proposal, get_proposal_snapshot}; -use stellar_macros::only_role; #[contract] pub struct MyGovernor; diff --git a/content/stellar-contracts/governance/votes.mdx b/content/stellar-contracts/governance/votes.mdx index 9b2584b1..4be00217 100644 --- a/content/stellar-contracts/governance/votes.mdx +++ b/content/stellar-contracts/governance/votes.mdx @@ -4,59 +4,46 @@ title: Votes [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/governance/src/votes) -The Votes module provides utilities for tracking voting power per account with historical checkpoints. It supports delegation and historical vote queries at any past ledger sequence number. +## Overview -## Core Concepts +The Votes module adds voting power tracking to a token contract. It is the foundation that makes governance possible — without it, the [Governor](/stellar-contracts/governance/governor) has no way to know how much influence each account holds. -### Voting Units - -The base unit of voting power, typically mapped 1:1 with token balance. Voting units are tracked independently from token balances — the implementing contract must call `transfer_voting_units` on every balance change (mint, burn, transfer) to keep them in sync. - -### Delegation +In a governance system, you typically have two contracts: a **token** that tracks voting power (this module) and a **governor** that manages proposals. The Votes module is implemented on the token side, where it hooks into balance changes and maintains a history of who held how much power at each point in time. -Only **delegated** voting power counts as votes. Undelegated voting units are not counted. An account must call `delegate` (even to itself) before its votes are counted. There is no explicit "undelegate" operation — to reclaim voting power, an account delegates to itself (self-delegation). +## How Voting Power Works -### Checkpoints +### Voting Units -Historical snapshots of voting power at specific ledger sequence numbers. These enable querying past voting power for governance proposals, preventing manipulation through flash loans. +Every token can carry one **voting unit** per unit of balance. When tokens are minted, transferred, or burned, the corresponding voting units move with them. The implementing contract must call `transfer_voting_units` on every balance change to keep voting power in sync with token balances. -## The Votes Trait +### Delegation: Activating Your Votes -```rust -#[contracttrait] -pub trait Votes { - /// Returns the current voting power (delegated votes) of an account. - fn get_votes(e: &Env, account: Address) -> u128; +Holding tokens alone does not give you voting power. You must **delegate** your voting units to an address — either to yourself or to someone else — before they count as active votes. This is a deliberate design choice: - /// Returns the voting power at a specific past ledger sequence number. - fn get_votes_at_checkpoint(e: &Env, account: Address, ledger: u32) -> u128; +- **Self-delegation**: An account delegates to itself to activate its own votes. +- **Delegating to another**: An account transfers its voting power to a trusted representative, who votes on its behalf. +- **Undelegating**: There is no separate undelegate operation. To reclaim delegated power, simply delegate back to yourself. - /// Returns the current total supply of voting units. - fn get_total_supply(e: &Env) -> u128; +Until an account calls `delegate` at least once, its voting units exist but are not counted in any vote. - /// Returns the total supply at a specific past ledger sequence number. - fn get_total_supply_at_checkpoint(e: &Env, ledger: u32) -> u128; +### Checkpoints: Snapshots of the Past - /// Returns the current delegate for an account (None if never delegated). - fn get_delegate(e: &Env, account: Address) -> Option
; +Every time voting power changes — through delegation, transfers, mints, or burns — the module records a **checkpoint**: a snapshot of that account's voting power at the current ledger sequence number. These checkpoints allow the Governor to query an account's voting power at any past ledger, which is critical for: - /// Delegates voting power from `account` to `delegatee`. - fn delegate(e: &Env, account: Address, delegatee: Address); -} -``` +- **Fair voting**: A proposal records a snapshot ledger when it is created. All votes are counted based on power held at that snapshot, not at the time of voting. This prevents vote manipulation through last-minute token purchases. +- **Flash loan protection**: Since checkpoints record state after all transactions in a ledger are finalized, borrowing and returning tokens within the same ledger leaves a net-zero checkpoint. -All query methods return `0` if the account has no voting power or does not exist. +## Integrating Votes into a Token Contract -## Integration with Token Contracts +The Votes module is designed to be layered onto an existing fungible or non-fungible token. The integration requires two things: -The implementing contract must call `transfer_voting_units` on every balance change to keep voting power in sync with token balances: +1. **Hook into balance changes** — call `transfer_voting_units` after every mint, burn, and transfer. +2. **Expose delegation** — let users delegate their voting power. ```rust -use stellar_governance::votes::{ - delegate, get_votes, get_votes_at_checkpoint, transfer_voting_units, -}; +use stellar_governance::votes::{self, transfer_voting_units}; -// Override the token contract's transfer to update voting units: +// After every transfer, update voting units: pub fn transfer(e: &Env, from: Address, to: Address, amount: i128) { // ... perform transfer logic ... transfer_voting_units(e, Some(&from), Some(&to), amount as u128); @@ -74,15 +61,48 @@ pub fn burn(e: &Env, from: Address, amount: i128) { transfer_voting_units(e, Some(&from), None, amount as u128); } -// Expose delegation: +// Expose delegation to users: pub fn delegate(e: &Env, account: Address, delegatee: Address) { votes::delegate(e, &account, &delegatee); } ``` + +If you are using the Fungible or Non-Fungible token from this library, the **Votes extension** handles all of this automatically. You only need to enable the extension — no manual integration required. See [Fungible Token](/stellar-contracts/tokens/fungible/fungible) or [Non-Fungible Token](/stellar-contracts/tokens/non-fungible/non-fungible). + + +## The Votes Trait + +The `Votes` trait defines the interface that the Governor queries. All methods have default implementations. + +```rust +#[contracttrait] +pub trait Votes { + /// Current voting power (delegated votes) of an account. + fn get_votes(e: &Env, account: Address) -> u128; + + /// Voting power at a specific past ledger. + fn get_votes_at_checkpoint(e: &Env, account: Address, ledger: u32) -> u128; + + /// Total voting units in circulation (regardless of delegation). + fn get_total_supply(e: &Env) -> u128; + + /// Total supply at a specific past ledger. + fn get_total_supply_at_checkpoint(e: &Env, ledger: u32) -> u128; + + /// The current delegate for an account (None if never delegated). + fn get_delegate(e: &Env, account: Address) -> Option
; + + /// Delegate voting power from `account` to `delegatee`. + fn delegate(e: &Env, account: Address, delegatee: Address); +} +``` + +All query methods return `0` if the account has no voting power or does not exist. + ## Free Functions -In addition to the trait, the module exports free functions for direct use: +In addition to the trait, the module exports free functions for lower-level use: | Function | Description | |----------|-------------| From eae01294bf9255c500047390e8d0c3e44a62a744 Mon Sep 17 00:00:00 2001 From: Ozgun Ozerk Date: Fri, 3 Apr 2026 13:28:55 +0300 Subject: [PATCH 5/7] remove zkemail and changelog, remove events and errors --- content/stellar-contracts/changelog.mdx | 212 ------------------ .../stellar-contracts/governance/governor.mdx | 30 --- .../stellar-contracts/governance/votes.mdx | 17 -- content/stellar-contracts/index.mdx | 5 - content/stellar-contracts/zk-email.mdx | 118 ---------- src/navigation/stellar.json | 10 - 6 files changed, 392 deletions(-) delete mode 100644 content/stellar-contracts/changelog.mdx delete mode 100644 content/stellar-contracts/zk-email.mdx diff --git a/content/stellar-contracts/changelog.mdx b/content/stellar-contracts/changelog.mdx deleted file mode 100644 index 4b737221..00000000 --- a/content/stellar-contracts/changelog.mdx +++ /dev/null @@ -1,212 +0,0 @@ ---- -title: Changelog ---- - - -# [v0.7.0](https://github.com/OpenZeppelin/stellar-contracts/releases/tag/v0.7.0) - 2026-04-XX - -## What's New - -* **Governor**: On-chain governance with proposals, voting, counting, and execution -* **Votes**: Delegation-based voting power tracking with historical checkpoints -* **Fungible Votes Extension**: Voting power tracking for fungible tokens -* **Non-Fungible Votes Extension**: Voting power tracking for NFTs -* **ZK Email DKIM Registry**: On-chain DKIM public key hash registry for zkEmail verification -* **RWA Compliance Modules**: Modular compliance system with hook-based architecture - -## Breaking Changes - -* **Policy trait**: `can_enforce()` method removed; `enforce()` now performs both validation and state changes (must panic if conditions not met) -* **Smart Account**: Automatic rule iteration removed; callers must supply `context_rule_ids` in `AuthPayload` to explicitly select rules. `remove_signer` and `remove_policy` now take IDs (`u32`) instead of `Signer`/`Address`. Global signer/policy registry with ID-based references. -* **Upgradeable**: `#[derive(Upgradeable)]` and `#[derive(UpgradeableMigratable)]` macros removed; implement `Upgradeable` trait directly with migration pattern guidelines -* **Math**: `SorobanMulDiv` trait removed; replaced with free functions in `i128_fixed_point` and `i256_fixed_point` modules. `Rounding::Truncate` variant added. -* **Timelock**: Delays now use ledger sequence numbers instead of timestamps -* **Capped extension**: `check_cap` now takes explicit `total_supply` parameter -* **Soroban SDK**: Updated from 23.4.0 to 25.3.0 - -## What's Changed - -* Governor implementation in https://github.com/OpenZeppelin/stellar-contracts/pull/563 -* Votes module in https://github.com/OpenZeppelin/stellar-contracts/pull/552 -* Dynamic quorum for governor in https://github.com/OpenZeppelin/stellar-contracts/pull/647 -* Queue for governance in https://github.com/OpenZeppelin/stellar-contracts/pull/659 -* Governor timelock example in https://github.com/OpenZeppelin/stellar-contracts/pull/665 -* ZK Email DKIM Registry in https://github.com/OpenZeppelin/stellar-contracts/pull/592 -* Fixed-point math refactor in https://github.com/OpenZeppelin/stellar-contracts/pull/580 -* Upgradeable simplification in https://github.com/OpenZeppelin/stellar-contracts/pull/585 -* Ledger-based timelock timing in https://github.com/OpenZeppelin/stellar-contracts/pull/569 -* RWA compliance modules in https://github.com/OpenZeppelin/stellar-contracts/pull/607 -* Smart account: remove fingerprints in https://github.com/OpenZeppelin/stellar-contracts/pull/663 -* Smart account: new sign digest in https://github.com/OpenZeppelin/stellar-contracts/pull/655 -* Smart account: canonical signers in https://github.com/OpenZeppelin/stellar-contracts/pull/657 -* Smart account: weighted threshold example in https://github.com/OpenZeppelin/stellar-contracts/pull/666 -* Fungible: explicit total_supply in capped in https://github.com/OpenZeppelin/stellar-contracts/pull/662 -* Soroban SDK update in https://github.com/OpenZeppelin/stellar-contracts/pull/615 - -[Changes][v0.7.0] - - - -# [v0.6.0](https://github.com/OpenZeppelin/stellar-contracts/releases/tag/v0.6.0) - 2026-01-26 - -## What's New - -* Timelock (governor sub module) -* WAD (fixed point arithmetic) -* Power Function (fixed point arithmetic) -* Fee Forwarder -* Added `get_existing_roles` for Access Control - -## What's Changed - -* Inconsistencies across mint and caller in https://github.com/OpenZeppelin/stellar-contracts/pull/495 -* Use muxed addresses in fungible transfers in https://github.com/OpenZeppelin/stellar-contracts/pull/493 -* Renounce admin rejects when transfer in progress in https://github.com/OpenZeppelin/stellar-contracts/pull/502 -* Uncaught panics on policy uninstall in https://github.com/OpenZeppelin/stellar-contracts/pull/504 -* NFT name and symbol bound for metadata in https://github.com/OpenZeppelin/stellar-contracts/pull/508 -* Bound metadata entries in rwa in https://github.com/OpenZeppelin/stellar-contracts/pull/511 -* Allowance bug fix in https://github.com/OpenZeppelin/stellar-contracts/pull/513 -* Vault consistency fix in https://github.com/OpenZeppelin/stellar-contracts/pull/523 -* Contract trait, deprecate `default_impl` macro in https://github.com/OpenZeppelin/stellar-contracts/pull/525 - -[Changes][v0.6.0] - - - -# [v0.5.1](https://github.com/OpenZeppelin/stellar-contracts/releases/tag/v0.5.1) - 2025-10-31 - -## What's Changed - -* fix the javascript compilation issue for `examples` package https://github.com/OpenZeppelin/stellar-contracts/issues/484 by renaming the generic datakey enum - -[Changes][v0.5.1] - - - -# [v0.5.0](https://github.com/OpenZeppelin/stellar-contracts/releases/tag/v0.5.0) - 2025-10-28 - -## What's Changed - -### Major -* Added Real-World Asset token implementation based on ERC-3643 (T-Rex) -* Added Smart Accounts: context-centric framework to compose authorization intents with signers and policies -* Added Token Vault implementation compatible with ERC-4626 - -### Minor -* Prevent test_snapshots from being checked in; make PRs less noisy in https://github.com/OpenZeppelin/stellar-contracts/pull/335 -* Remove cdylib crate type from default-impl-macro-test Cargo.toml in https://github.com/OpenZeppelin/stellar-contracts/pull/468 -* Migrate to target wasmv1-none in https://github.com/OpenZeppelin/stellar-contracts/pull/441 -* Remove antora docs in https://github.com/OpenZeppelin/stellar-contracts/pull/483 - -[Changes][v0.5.0] - - - -# [v0.4.1](https://github.com/OpenZeppelin/stellar-contracts/releases/tag/v0.4.1) - 2025-07-22 - -Added `readme.md` for each new package category: -- access -- contract-utils -- macros -- tokens - -[Changes][v0.4.1] - - - -# [v0.4.0](https://github.com/OpenZeppelin/stellar-contracts/releases/tag/v0.4.0) - 2025-07-22 - -Restructures the crates to group them under: -- `access` -- `tokens` -- `macros` -- `contract-utils` - -None of the audited code has changed. This release consists only of moving things around and restructuring - -[Changes][v0.4.0] - - - -# [v0.3.0](https://github.com/OpenZeppelin/stellar-contracts/releases/tag/v0.3.0) - 2025-07-03 - -This release is audited, you can find the audit report [here](https://github.com/OpenZeppelin/stellar-contracts/blob/main/audits/Stellar%20Contracts%20Library%20v0.3.0-rc.2%20Audit.pdf). - -## Breaking Changes -- Fungible module got reworked, see: [#234](https://github.com/OpenZeppelin/stellar-contracts/pull/234) - -## What's Changed -* Access control by [@ozgunozerk](https://github.com/ozgunozerk) in [#214](https://github.com/OpenZeppelin/stellar-contracts/pull/214) -* Ownable by [@ozgunozerk](https://github.com/ozgunozerk) in [#216](https://github.com/OpenZeppelin/stellar-contracts/pull/216) -* Error numbering by [@ozgunozerk](https://github.com/ozgunozerk) in [#226](https://github.com/OpenZeppelin/stellar-contracts/pull/226) -* Merkle Proofs by [@brozorec](https://github.com/brozorec) in [#222](https://github.com/OpenZeppelin/stellar-contracts/pull/222) -* SacAdmin by [@brozorec](https://github.com/brozorec) in [#215](https://github.com/OpenZeppelin/stellar-contracts/pull/215) -* Merkle Distributor by [@brozorec](https://github.com/brozorec) in [#229](https://github.com/OpenZeppelin/stellar-contracts/pull/229) -* Royalty by [@ozgunozerk](https://github.com/ozgunozerk) in [#221](https://github.com/OpenZeppelin/stellar-contracts/pull/221) -* SAC Admin Generic by [@brozorec](https://github.com/brozorec) in [#232](https://github.com/OpenZeppelin/stellar-contracts/pull/232) -* Fungible refactor by [@ozgunozerk](https://github.com/ozgunozerk) in [#234](https://github.com/OpenZeppelin/stellar-contracts/pull/234) -* Fix binver by [@brozorec](https://github.com/brozorec) in [#224](https://github.com/OpenZeppelin/stellar-contracts/pull/224) -* Allowlist and blocklist by [@ozgunozerk](https://github.com/ozgunozerk) in [#237](https://github.com/OpenZeppelin/stellar-contracts/pull/237) - -## New Contributors -* [@orangelash](https://github.com/orangelash) made their first contribution in [#331](https://github.com/OpenZeppelin/stellar-contracts/pull/331) - -**Full Changelog**: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.2.0...v0.3.0 - -[Changes][v0.3.0] - - - -# [v0.2.0](https://github.com/OpenZeppelin/stellar-contracts/releases/tag/v0.2.0) - 2025-05-12 - -This release is audited, you can find the audit report [here](https://github.com/OpenZeppelin/stellar-contracts/blob/main/audits/2025-05-v0.2.0.pdf). - -In this release, you can find: - -- Non-Fungible Token base implementation with the following extensions: - - enumerable - - consecutive - - burnable -- Upgradeable and Migrations utilities -- Capped extension for Fungible Token -- Showcase examples: - - nft-consecutive - - nft-enumerable - - nft-sequential-minting - - fungible-capped - - upgradeable - -[Changes][v0.2.0] - - - -# [v0.1.0](https://github.com/OpenZeppelin/stellar-contracts/releases/tag/v0.1.0) - 2025-02-21 - -The first release of OpenZeppelin Contracts for Stellar Soroban. - -This release is audited, you can find the audit report [here](https://github.com/OpenZeppelin/stellar-contracts/blob/main/audits/2025-02-v0.1.0-rc.pdf) - -In this release, you can find: -- Fungible Token standard (similar to ERC20) implemented for Stellar Soroban, compliant with SEP-41 -- The following extensions for the Fungible Token standard: - - Mintable - - Burnable - - Metadata -- `Pausable` utility for your contracts. -- Examples folder to showcase what's possible: - - fungible-pausable - - fungible-token-interface - - pausable - -[Changes][v0.1.0] - - -[v0.7.0]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.6.0...v0.7.0 -[v0.6.0]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.5.1...v0.6.0 -[v0.5.1]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.5.0...v0.5.1 -[v0.5.0]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.4.1...v0.5.0 -[v0.4.1]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.4.0...v0.4.1 -[v0.4.0]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.3.0...v0.4.0 -[v0.3.0]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.2.0...v0.3.0 -[v0.2.0]: https://github.com/OpenZeppelin/stellar-contracts/compare/v0.1.0...v0.2.0 -[v0.1.0]: https://github.com/OpenZeppelin/stellar-contracts/tree/v0.1.0 diff --git a/content/stellar-contracts/governance/governor.mdx b/content/stellar-contracts/governance/governor.mdx index 9f409eaa..ddaaf467 100644 --- a/content/stellar-contracts/governance/governor.mdx +++ b/content/stellar-contracts/governance/governor.mdx @@ -192,36 +192,6 @@ pub enum ProposalState { States are divided into **time-based** (Pending, Active, Defeated) — derived from the current ledger, never stored — and **explicit** (all others) — persisted in storage, taking precedence once set. -## Events - -| Event | Topics | Data | -|-------|--------|------| -| `ProposalCreated` | `proposal_id`, `proposer` | `targets`, `functions`, `args`, `vote_snapshot`, `vote_end`, `description` | -| `VoteCast` | `voter`, `proposal_id` | `vote_type`, `weight`, `reason` | -| `ProposalQueued` | `proposal_id` | `eta` | -| `ProposalExecuted` | `proposal_id` | — | -| `ProposalCancelled` | `proposal_id` | — | -| `QuorumChanged` | — | `old_quorum`, `new_quorum` | - -## Errors - -| Error | Code | Description | -|-------|------|-------------| -| `ProposalNotFound` | 5000 | The proposal was not found | -| `ProposalAlreadyExists` | 5001 | A proposal with the same parameters already exists | -| `InsufficientProposerVotes` | 5002 | The proposer lacks sufficient voting power | -| `EmptyProposal` | 5003 | The proposal contains no actions | -| `InvalidProposalLength` | 5004 | Mismatched targets/functions/args vector lengths | -| `ProposalNotActive` | 5005 | Voting is not currently open | -| `ProposalNotSuccessful` | 5006 | The proposal has not succeeded | -| `ProposalNotQueued` | 5007 | The proposal has not been queued | -| `ProposalAlreadyExecuted` | 5008 | The proposal was already executed | -| `ProposalNotCancellable` | 5009 | The proposal is in a non-cancellable state | -| `AlreadyVoted` | 5016 | The account has already voted on this proposal | -| `InvalidVoteType` | 5017 | Invalid vote type (must be 0, 1, or 2) | -| `QuorumNotSet` | 5018 | Quorum has not been configured | -| `QueueNotEnabled` | 5022 | Queuing is not enabled for this governor | - ## Example ```rust diff --git a/content/stellar-contracts/governance/votes.mdx b/content/stellar-contracts/governance/votes.mdx index 4be00217..49027868 100644 --- a/content/stellar-contracts/governance/votes.mdx +++ b/content/stellar-contracts/governance/votes.mdx @@ -112,23 +112,6 @@ In addition to the trait, the module exports free functions for lower-level use: | `num_checkpoints(e, account)` | Returns the number of checkpoints for an account. | | `delegate(e, account, delegatee)` | Delegates voting power. Requires authorization from `account`. | -## Events - -| Event | Topics | Data | -|-------|--------|------| -| `DelegateChanged` | `delegator` | `from_delegate: Option
`, `to_delegate: Address` | -| `DelegateVotesChanged` | `delegate` | `previous_votes: u128`, `new_votes: u128` | - -## Errors - -| Error | Code | Description | -|-------|------|-------------| -| `FutureLookup` | 4100 | The queried ledger is in the future | -| `MathOverflow` | 4101 | Arithmetic overflow occurred | -| `InsufficientVotingUnits` | 4102 | Attempting to transfer more voting units than available | -| `SameDelegate` | 4103 | Attempting to delegate to the already-set delegate | -| `CheckpointNotFound` | 4104 | A checkpoint that was expected to exist was not found | - ## See Also - [Governor](/stellar-contracts/governance/governor) diff --git a/content/stellar-contracts/index.mdx b/content/stellar-contracts/index.mdx index 066c82b0..0bb78d77 100644 --- a/content/stellar-contracts/index.mdx +++ b/content/stellar-contracts/index.mdx @@ -38,10 +38,6 @@ for access control and contract management. * **[Fee Abstraction](/stellar-contracts/fee-abstraction)**: Enable users to pay transaction fees in tokens (e.g. USDC) while relayers cover XLM fees. -## ZK Email - -* **[DKIM Registry](/stellar-contracts/zk-email)**: On-chain DKIM public key hash registry for zkEmail verification systems. - ## Security and Audits Our contracts are built with security as a top priority. You can find our audit reports [here](https://github.com/OpenZeppelin/stellar-contracts/tree/main/audits). @@ -72,7 +68,6 @@ Similarly, utilities and other modules have their own error codes: * Accounts: `3XXX` * Governance: `4XXX` * Fee Abstraction: `5XXX` -* ZK Email: `6XXX` ## Important Notes As a deliberate design choice, this library manages the TTL for temporary and persistent storage items. diff --git a/content/stellar-contracts/zk-email.mdx b/content/stellar-contracts/zk-email.mdx deleted file mode 100644 index 6e27400a..00000000 --- a/content/stellar-contracts/zk-email.mdx +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: ZK Email (DKIM Registry) ---- - -[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/zk-email/src/dkim_registry) - -The ZK Email module provides an on-chain DKIM registry — the trust anchor for zkEmail systems on Soroban. - -## What is DKIM? - -**DKIM (DomainKeys Identified Mail)** is an email authentication standard. When a server sends an email, it signs the message with a private RSA key. The corresponding public key is published in the domain's DNS records. Email clients verify the signature to confirm the email genuinely came from that domain and was not tampered with. - -## What is zkEmail? - -**zkEmail** enables proving facts about an email without revealing the email itself by using zero-knowledge proofs. Since emails are DKIM-signed, a ZK proof can assert that a valid DKIM-signed email from a specific domain contains certain information — provable on-chain without exposing the raw email content. - -Use cases include: -- Proving ownership of a specific email address -- Proving receipt of a payment confirmation from a bank -- Proving an email from a DAO's domain authorized an action - -## The DKIM Registry - -This module stores which DKIM public key hashes are considered valid for which domains. When a ZK proof is verified on-chain, the verifier calls `is_key_hash_valid(domain_hash, public_key_hash)` to confirm the key used to sign the email is still trusted (not rotated or compromised). - -The registry is **hash-function agnostic**: it stores pre-hashed `BytesN<32>` values for both domain names and public keys. Callers hash these off-chain using whatever hash function their system requires (Poseidon, SHA256, Keccak256, etc.). - -## The DKIMRegistry Trait - -```rust -pub trait DKIMRegistry { - /// Returns true if the key hash is registered for the domain AND not revoked. - fn is_key_hash_valid(e: &Env, domain_hash: BytesN<32>, public_key_hash: BytesN<32>) -> bool; - - /// Returns true if the key hash has been globally revoked. - fn is_key_hash_revoked(e: &Env, public_key_hash: BytesN<32>) -> bool; - - /// Registers a DKIM public key hash for a domain. - fn set_dkim_public_key_hash( - e: &Env, - domain_hash: BytesN<32>, - public_key_hash: BytesN<32>, - operator: Address, - ); - - /// Batch registers DKIM public key hashes for a domain. - fn set_dkim_public_key_hashes( - e: &Env, - domain_hash: BytesN<32>, - public_key_hashes: Vec>, - operator: Address, - ); - - /// Globally revokes a DKIM public key hash. Once revoked, it cannot be re-registered. - fn revoke_dkim_public_key_hash(e: &Env, public_key_hash: BytesN<32>, operator: Address); -} -``` - -The read methods (`is_key_hash_valid`, `is_key_hash_revoked`) have default implementations. The write methods (`set_dkim_public_key_hash`, `set_dkim_public_key_hashes`, `revoke_dkim_public_key_hash`) **do not** — they must be implemented with proper access control. - - -**Security Warning**: The base implementations of the write functions intentionally lack authorization controls. Contracts **must** add their own access control (e.g., using `#[only_role]` macros from `stellar_macros`) to restrict who can register and revoke key hashes. - - -## Usage Example - -```rust -use stellar_macros::only_role; -use stellar_zk_email::dkim_registry::{self, DKIMRegistry}; - -#[contract] -pub struct MyRegistry; - -impl DKIMRegistry for MyRegistry { - #[only_role(operator, "governance")] - fn set_dkim_public_key_hash( - e: &Env, - domain_hash: BytesN<32>, - public_key_hash: BytesN<32>, - operator: Address, - ) { - dkim_registry::set_dkim_public_key_hash(e, &domain_hash, &public_key_hash); - } - - #[only_role(operator, "governance")] - fn set_dkim_public_key_hashes( - e: &Env, - domain_hash: BytesN<32>, - public_key_hashes: Vec>, - operator: Address, - ) { - dkim_registry::set_dkim_public_key_hashes(e, &domain_hash, &public_key_hashes); - } - - #[only_role(operator, "governance")] - fn revoke_dkim_public_key_hash( - e: &Env, - public_key_hash: BytesN<32>, - operator: Address, - ) { - dkim_registry::revoke_dkim_public_key_hash(e, &public_key_hash); - } -} -``` - -## Events - -| Event | Topics | Data | -|-------|--------|------| -| `KeyHashRegistered` | `domain_hash` | `public_key_hash` | -| `KeyHashRevoked` | `public_key_hash` | — | - -## Errors - -| Error | Code | Description | -|-------|------|-------------| -| `KeyHashRevoked` | 6000 | The public key hash has been revoked and cannot be re-registered | -| `KeyHashAlreadyRegistered` | 6001 | The public key hash is already registered for the given domain | diff --git a/src/navigation/stellar.json b/src/navigation/stellar.json index c6441ce6..be1994a8 100644 --- a/src/navigation/stellar.json +++ b/src/navigation/stellar.json @@ -155,16 +155,6 @@ "name": "Fee Abstraction", "url": "/stellar-contracts/fee-abstraction" }, - { - "type": "page", - "name": "ZK Email (DKIM Registry)", - "url": "/stellar-contracts/zk-email" - }, - { - "type": "page", - "name": "Changelog", - "url": "/stellar-contracts/changelog" - }, { "type": "separator", "name": "Open Source Tools" From dd6b3672e86911f38a98003739def58d6a9e3e7c Mon Sep 17 00:00:00 2001 From: Ozgun Ozerk Date: Fri, 3 Apr 2026 13:35:41 +0300 Subject: [PATCH 6/7] remove free functions, and add code snippet for votes integration --- .../stellar-contracts/governance/governor.mdx | 2 +- .../stellar-contracts/governance/votes.mdx | 28 +++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/content/stellar-contracts/governance/governor.mdx b/content/stellar-contracts/governance/governor.mdx index ddaaf467..d5f9b7b9 100644 --- a/content/stellar-contracts/governance/governor.mdx +++ b/content/stellar-contracts/governance/governor.mdx @@ -75,7 +75,7 @@ A proposal succeeds when `for > against` **and** the quorum is reached. Quorum v ### Custom Counting Strategies -The counting logic is fully pluggable. The default three-type system works for most cases, but you can override the counting methods (`count_vote`, `tally_succeeded`, `quorum_reached`) to implement alternative strategies such as fractional voting, weighted quorum relative to total supply, or NFT-based voting schemes. The `counting_mode()` method returns a symbol identifying the active strategy, which UIs can use for display purposes. +The counting logic is fully pluggable. The default three-type system works for most cases, but you can override the counting methods (`count_vote`, `tally_succeeded`, `quorum_reached`) to implement alternative strategies such as fractional voting or weighted quorum relative to total supply. The `counting_mode()` method returns a symbol identifying the active strategy, which UIs can use for display purposes. ### Dynamic Quorum diff --git a/content/stellar-contracts/governance/votes.mdx b/content/stellar-contracts/governance/votes.mdx index 49027868..765b692d 100644 --- a/content/stellar-contracts/governance/votes.mdx +++ b/content/stellar-contracts/governance/votes.mdx @@ -69,6 +69,22 @@ pub fn delegate(e: &Env, account: Address, delegatee: Address) { If you are using the Fungible or Non-Fungible token from this library, the **Votes extension** handles all of this automatically. You only need to enable the extension — no manual integration required. See [Fungible Token](/stellar-contracts/tokens/fungible/fungible) or [Non-Fungible Token](/stellar-contracts/tokens/non-fungible/non-fungible). + +```rust +use stellar_governance::votes::Votes; +use stellar_tokens::fungible::{votes::FungibleVotes, FungibleToken}; + +// 1. Set ContractType to FungibleVotes — this hooks into all balance +// changes (transfers, mints, burns) and updates voting units automatically. +#[contractimpl(contracttrait)] +impl FungibleToken for MyToken { + type ContractType = FungibleVotes; +} + +// 2. Implement the Votes trait to expose voting queries to the Governor. +#[contractimpl(contracttrait)] +impl Votes for MyToken {} +``` ## The Votes Trait @@ -100,18 +116,6 @@ pub trait Votes { All query methods return `0` if the account has no voting power or does not exist. -## Free Functions - -In addition to the trait, the module exports free functions for lower-level use: - -| Function | Description | -|----------|-------------| -| `transfer_voting_units(e, from, to, amount)` | Updates voting units on balance changes. `from`/`to` are `Option<&Address>` for mint/burn. | -| `get_voting_units(e, account)` | Returns the raw voting units (not delegated votes) for an account. | -| `get_checkpoint(e, account, pos)` | Returns a specific checkpoint by position index. | -| `num_checkpoints(e, account)` | Returns the number of checkpoints for an account. | -| `delegate(e, account, delegatee)` | Delegates voting power. Requires authorization from `account`. | - ## See Also - [Governor](/stellar-contracts/governance/governor) From d0ef264cfd29cea09572f9dc43468394f444e815 Mon Sep 17 00:00:00 2001 From: Ozgun Ozerk Date: Fri, 3 Apr 2026 16:06:36 +0300 Subject: [PATCH 7/7] revert account related changes --- .../accounts/authorization-flow.mdx | 161 ++++++++++-------- .../accounts/context-rules.mdx | 23 ++- .../stellar-contracts/accounts/policies.mdx | 59 ++++--- .../accounts/signers-and-verifiers.mdx | 44 ++--- .../accounts/smart-account.mdx | 10 +- 5 files changed, 162 insertions(+), 135 deletions(-) diff --git a/content/stellar-contracts/accounts/authorization-flow.mdx b/content/stellar-contracts/accounts/authorization-flow.mdx index 9385c555..1ae843ea 100644 --- a/content/stellar-contracts/accounts/authorization-flow.mdx +++ b/content/stellar-contracts/accounts/authorization-flow.mdx @@ -2,30 +2,9 @@ title: Authorization Flow --- -Authorization in smart accounts is determined by matching the current 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. - -## AuthPayload - -The `AuthPayload` structure is passed as the signature data in `__check_auth`: - -```rust -#[contracttype] -pub struct AuthPayload { - /// Signature data mapped to each signer. - pub signers: Map, - /// Per-context rule IDs, aligned by index with `auth_contexts`. - pub context_rule_ids: Vec, -} -``` - -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()`. - - -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. - +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. ## Detailed Flow - ```mermaid sequenceDiagram participant User @@ -35,67 +14,105 @@ sequenceDiagram participant Verifier participant Policy - User->>SmartAccount: AuthPayload (signers + context_rule_ids) - SmartAccount->>SmartAccount: Authenticate all provided signers + User->>SmartAccount: Signatures + SmartAccount->>ContextRule: Match context
(CallContract, Default, ...) + ContextRule->>ContextRule: Filter expired rules
Sort newest first + + loop Each rule until match + Note over ContextRule,DelegatedSigner: Built-in authorization
for delegated signers + ContextRule->>DelegatedSigner: require_auth_for_args() + DelegatedSigner-->>ContextRule: Authorized - loop Each (context, rule_id) pair - SmartAccount->>ContextRule: Look up rule by ID - ContextRule->>ContextRule: Reject if expired or
context type mismatch + Note over ContextRule,Verifier: Signature verification for external signers + ContextRule->>Verifier: verify() + Verifier-->>ContextRule: Valid - Note over ContextRule,DelegatedSigner: Identify authenticated signers
from rule's signer list - Note over ContextRule,Verifier: External signers verified
during initial authentication + Note over ContextRule,Policy: Policy pre-checks + ContextRule->>Policy: can_enforce() + Policy-->>ContextRule: True/False - alt Rule has policies + alt All checks pass ContextRule->>Policy: enforce() - Policy->>Policy: Validate + update state - Policy-->>ContextRule: Success (or panic) - else No policies - ContextRule->>ContextRule: All rule signers
must be authenticated + Policy->>Policy: Update state + ContextRule-->>SmartAccount: ✓ Authorized + else Any check fails + ContextRule->>ContextRule: Try next rule end end - SmartAccount-->>User: Success or Denied + SmartAccount-->>User: Success ``` -### 1. Signer Authentication +### 1. Rule Collection -All signers in the `AuthPayload` are authenticated upfront: +The smart account gathers all relevant context rules for evaluation: -- **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 +- Retrieve all non-expired rules for the specific context type +- Include default rules that apply to any context +- Sort specific and default rules by creation time (newest first) -Any signer in the `AuthPayload` that is not part of any selected context rule is rejected. +**Context Type Matching:** +- For a `CallContract(address)` context, both specific `CallContract(address)` rules and `Default` rules are collected +- For a `CreateContract(wasm_hash)` context, both specific `CreateContract(wasm_hash)` rules and `Default` rules are collected +- For any other context, only `Default` rules are collected + +**Expiration Filtering:** +Rules with `valid_until` set to a ledger sequence that has passed are automatically filtered out during collection. ### 2. Rule Evaluation -For each (context, rule_id) pair: +For each rule in order (newest and most specific first): + +#### Step 2.1: Signer Filtering -#### Step 2.1: Rule Lookup and Validation +Extract authenticated signers from the rule's signer list. A signer is considered authenticated if: -The rule is looked up by its explicit ID. The rule is rejected if: -- It does not exist -- It is expired (`valid_until` has passed) -- Its context type does not match the actual context (`Default` rules match any context) +- **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 -#### Step 2.2: Signer Matching +Only authenticated signers proceed to the next step. -Authenticated signers are identified from the rule's signer list. +#### Step 2.2: Policy Validation + +If the rule has attached policies, verify that all can be enforced: + +```rust +for policy in rule.policies { + if !policy.can_enforce(e, account, rule_id, signers, auth_context) { + // This rule fails, try the next rule + } +} +``` + +If any policy's `can_enforce()` returns false, the rule fails and evaluation moves to the next rule. #### Step 2.3: Authorization Check The authorization check depends on whether policies are present: **With Policies:** -- `enforce()` is called on each policy. If any `enforce()` panics, the authorization fails. -- Signer validation is deferred to the policies (e.g., threshold checks). +- Success if all policies passed `can_enforce()` +- The presence of authenticated signers is verified during policy evaluation **Without Policies:** -- All signers in the rule must be authenticated. -- At least one signer must be present. +- Success if all signers in the rule are authenticated +- At least one signer must be authenticated for the rule to match + +#### Step 2.4: Rule Precedence + +The first matching rule wins. Newer rules take precedence over older rules for the same context type. This allows overwriting old rules. ### 3. Policy Enforcement -Policy enforcement happens during rule evaluation. When `enforce()` is called, policies both validate conditions and perform state changes (updating spending counters, recording timestamps, emitting audit events, etc.). +If authorization succeeds, the smart account calls `enforce()` on all matched policies in order: + +```rust +for policy in matched_rule.policies { + policy.enforce(e, account, rule_id, signers, auth_context); +} +``` + +This triggers any necessary state changes such as updating spending counters, recording timestamps, emitting audit events, or modifying allowances. Policy enforcement requires the smart account's authorization, ensuring that policies can only be enforced by the account itself. @@ -131,14 +148,19 @@ ContextRule { **Call Context:** `CallContract(dex_address)` -**AuthPayload:** `{ signers: [passkey_signature], context_rule_ids: [2] }` +**Authorization Entries:** `[passkey_signature]` **Flow:** -1. Authenticate: Passkey signature verified -2. Look up Rule 2: Not expired, context type matches -3. Enforce: Spending limit policy validates and updates counters +1. Collect: Rules 2 (specific) and 1 (default) +2. Evaluate Rule 2: + - Signer filtering: Passkey authenticated + - Policy validation: Spending limit check passes + - Authorization check: All policies enforceable → Success +3. Enforce: Update spending counters, emit events 4. Result: Authorized +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). + ### Fallback to Default **Configuration:** @@ -163,14 +185,16 @@ ContextRule { **Call Context:** `CallContract(dex_address)` -**AuthPayload:** `{ signers: [alice_sig, bob_sig], context_rule_ids: [1] }` +**Authorization Entries:** `[ed25519_alice_signature, ed25519_bob_signature]` **Flow:** -1. Authenticate: Alice and Bob signatures verified -2. Look up Rule 1: Default rule matches any context, not expired -3. No policies: Both Alice and Bob authenticated → Success +1. Collect: Rule 2 filtered out (expired), only Rule 1 collected +2. Evaluate Rule 1: Both Alice and Bob authenticated → Success +3. Enforce: No policies to enforce 4. Result: Authorized +The expired session rule is automatically filtered out, and authorization falls back to the default admin rule. + ### Authorization Failure **Configuration:** @@ -186,21 +210,24 @@ ContextRule { **Call Context:** `CallContract(any_address)` -**AuthPayload:** `{ signers: [alice_signature], context_rule_ids: [1] }` +**Authorization Entries:** `[alice_signature]` **Flow:** -1. Authenticate: Alice signature verified -2. Look up Rule 1: Default rule, not expired -3. Enforce: Threshold policy requires 2 signers, only 1 present → panics +1. Collect: Default rule retrieved +2. Evaluate: + - Signer filtering: Only Alice authenticated + - Policy validation: Threshold policy requires 2 signers, only 1 present → Fail +3. No more rules to evaluate 4. Result: Denied (transaction reverts) ## Performance Considerations Protocol 23 optimizations make the authorization flow efficient: -- **Marginal storage read costs**: Reading context rules has negligible cost +- **Marginal storage read costs**: Reading multiple context rules has negligible cost - **Cheaper cross-contract calls**: Calling verifiers and policies is substantially cheaper -The framework enforces per-rule limits to maintain predictability: +The framework enforces limits to maintain predictability: +- Maximum context rules per smart account: 15 - Maximum signers per context rule: 15 - Maximum policies per context rule: 5 diff --git a/content/stellar-contracts/accounts/context-rules.mdx b/content/stellar-contracts/accounts/context-rules.mdx index d22f14d8..6c136584 100644 --- a/content/stellar-contracts/accounts/context-rules.mdx +++ b/content/stellar-contracts/accounts/context-rules.mdx @@ -32,7 +32,7 @@ List of authorized signers (maximum 15 per rule). Signers can be either delegate For detailed documentation on signers, see [Signers](/stellar-contracts/accounts/signers-and-verifiers). #### Policies -List of policy contracts (maximum 5 per rule). Policies act as enforcement modules that validate and enforce authorization constraints. +List of policy contracts (maximum 5 per rule). Policies act as enforcement modules that perform read-only prechecks and state-changing enforcement logic. For detailed documentation on policies, see [Policies](/stellar-contracts/accounts/policies). @@ -44,29 +44,28 @@ Each rule must contain at least one signer OR one policy. This enables pure poli ### Multiple Rules Per Context 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. -### Explicit Rule Selection -The caller explicitly selects which rule to validate against for each auth context via `AuthPayload::context_rule_ids`. No automatic iteration or rule precedence is applied — the caller chooses the exact rule to use. +### Rule Precedence +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. ### Automatic Expiration -Expired rules are rejected during authorization evaluation. +Expired rules are automatically filtered out during authorization evaluation. ## Context Rule Limits -The framework enforces per-rule limits to maintain predictability: +The framework enforces limits to keep costs predictable and encourage proactive context rule management (remove expired or non-valid rules): +- Maximum context rules per smart account: 15 - Maximum signers per context rule: 15 - Maximum policies per context rule: 5 -There is no upper limit on the total number of context rules per smart account. - ## Authorization Matching -During authorization, the caller supplies `context_rule_ids` in the `AuthPayload`, one per auth context. For each (context, rule_id) pair: +During authorization, the framework: -1. The rule is looked up by its explicit ID -2. The rule is validated: must not be expired, context type must match -3. Signers are authenticated and policies enforced -4. Authorization succeeds if all checks pass, otherwise fails +1. Gathers all non-expired rules matching the context type plus default rules +2. Sorts rules by creation time (newest first) +3. Evaluates rules in order until one matches +4. Returns the first matching rule or fails if none match For detailed documentation on the authorization flow, see [Authorization Flow](/stellar-contracts/accounts/authorization-flow). diff --git a/content/stellar-contracts/accounts/policies.mdx b/content/stellar-contracts/accounts/policies.mdx index 83da7a49..33572eec 100644 --- a/content/stellar-contracts/accounts/policies.mdx +++ b/content/stellar-contracts/accounts/policies.mdx @@ -6,7 +6,7 @@ title: Policies Policies are enforcement modules that add constraints to context rules in smart accounts. While signers determine who can authorize actions, policies determine how those authorizations are enforced, enabling sophisticated patterns like multi-signature thresholds, spending limits, and time-based restrictions. -Policies attach to context rules and execute during the authorization flow. A context rule can have up to **5 policies** attached, and policies are executed in the order they were added. If policies are present in a context rule, **all of them must succeed** (i.e., `enforce` must not panic) for the rule to be authorized. +Policies attach to context rules and execute during the authorization flow. A context rule can have up to **5 policies** attached, and policies are executed in the order they were added. If policies are present in a context rule, **all of them must be enforceable** (i.e., `can_enforce` must return `true`) for the rule to be considered matched and authorized. ## The Policy Trait @@ -16,9 +16,20 @@ All policies must implement the `Policy` trait: pub trait Policy { type AccountParams: FromVal; - /// Validates and enforces the policy's authorization logic. - /// Must panic if conditions are not met. - /// Requires smart account authorization. + /// Read-only pre-check to validate conditions + /// Must be idempotent and side-effect free + /// Returns true if the policy would allow the action + fn can_enforce( + e: &Env, + context: Context, + authenticated_signers: Vec, + rule: ContextRule, + smart_account: Address, + ) -> bool; + + /// State-changing enforcement hook + /// Called when a context rule successfully matches and all can_enforce checks pass + /// Requires smart account authorization fn enforce( e: &Env, context: Context, @@ -26,18 +37,18 @@ pub trait Policy { rule: ContextRule, smart_account: Address, ); - - /// Initialize policy-specific storage and configuration. - /// Called when a new context rule with attached policies is created. + + /// Initialize policy-specific storage and configuration + /// Called when a new context rule with attached policies is created fn install( e: &Env, param: Self::AccountParams, rule: ContextRule, smart_account: Address, ); - - /// Clean up policy data when removed. - /// Called when a context rule is removed. + + /// Clean up policy data when removed + /// Called when a context rule is removed fn uninstall( e: &Env, rule: ContextRule, @@ -48,7 +59,7 @@ pub trait Policy { ## Policy Lifecycle -The three trait methods form a complete lifecycle for policy management: +The four trait methods form a complete lifecycle for policy management: ### Installation @@ -60,20 +71,28 @@ This initialization step allows policies to configure their logic. For example: Installation ensures that each policy has the necessary state and configuration ready before authorization checks begin. -### Enforcement +### Pre-check Validation + +Pre-check validation happens during authorization. When the matching algorithm iterates over context rules and their associated policies, it calls `can_enforce()` on each policy as a read-only pre-check. + +This function examines the current state without modifying it, for instance: +- Verifying that a spending limit has not been exceeded +- Checking that enough signers are present +- Validating that time-based restrictions are met -Enforcement is called during authorization when a context rule is being evaluated. The smart account calls `enforce()` on each policy attached to the rule. Each `enforce()` call both validates that the policy conditions are met and performs any necessary state changes. +Policies that fail this check cause the algorithm to move to the next context rule. -If any policy's `enforce()` call panics, the entire authorization fails. +### Enforcement + +Enforcement is triggered when a context rule successfully matches. Once all policies in the matched rule pass their `can_enforce()` checks, the smart account calls `enforce()` on each policy. -Enforcement allows policies to: -- Validate conditions (e.g., spending limits, signer thresholds, time restrictions) -- Update counters and state +This state-changing hook allows policies to: +- Update counters - Emit events - Record timestamps - Track authorization activity -For example, a spending limit policy verifies the spending limit has not been exceeded, deducts from the available balance, and emits an event documenting the transaction. +For example, a spending limit policy might deduct from the available balance and emit an event documenting the transaction. ### Uninstallation @@ -131,7 +150,7 @@ Adds a policy to an existing context rule and calls its `install()` function. Th fn remove_policy( e: &Env, context_rule_id: u32, - policy_id: u32, + policy: Address, ); ``` @@ -209,7 +228,7 @@ The policy requires two configuration parameters: 1. **Order Matters**: Policies execute in order; place cheaper checks first 2. **Keep Policies Focused**: Each policy should enforce one concern 3. **Test Policy Combinations**: Ensure multiple policies work together correctly -4. **Handle Errors Clearly**: Panic with descriptive error messages in `enforce` when conditions are not met +4. **Handle Errors Gracefully**: Return clear error messages from `enforce` 5. **Clean Up Storage**: Always implement `uninstall` to free storage 6. **Document Configuration**: Clearly document policy configuration parameters diff --git a/content/stellar-contracts/accounts/signers-and-verifiers.mdx b/content/stellar-contracts/accounts/signers-and-verifiers.mdx index 6fa15e5a..1196cd97 100644 --- a/content/stellar-contracts/accounts/signers-and-verifiers.mdx +++ b/content/stellar-contracts/accounts/signers-and-verifiers.mdx @@ -60,10 +60,7 @@ When `require_auth_for_args` is called from within `__check_auth` (as with deleg ```rust #[contracttype] -pub struct AuthPayload { - pub signers: Map, - pub context_rule_ids: Vec, -} +pub struct Signatures(pub Map); #[contract] pub struct MySmartAccount; @@ -73,10 +70,10 @@ impl CustomAccountInterface for MySmartAccount { fn __check_auth( e: Env, payload: Hash<32>, - signatures: AuthPayload, + signatures: Signatures, auth_contexts: Vec, ) -> Result<(), SmartAccountError> { - for (signer, _) in signatures.signers.iter() { + for (signer, _) in signatures.0.iter() { match signer { // ... Signer::Delegated(addr) => { @@ -92,7 +89,7 @@ impl CustomAccountInterface for MySmartAccount { **Example Scenario** -Consider a scenario where a target contract `CAGCDFLG4WKPYG...` requires authorization from a smart account `CBH6XACZFDCJUHX...`. The smart account will grant authorization only if the G-account `GBDZXYMJ3SLYXCY...` has signed, meaning `AuthPayload.signers` has to contain one element with key `Signer::Delegated("GBDZXYMJ3SLYXCY...")`. +Consider a scenario where a target contract `CAGCDFLG4WKPYG...` requires authorization from a smart account `CBH6XACZFDCJUHX...`. The smart account will grant authorization only if the G-account `GBDZXYMJ3SLYXCY...` has signed, meaning `Map` has to contain one element that is `Signer::Delegated("GBDZXYMJ3SLYXCY...")`. ```mermaid graph LR @@ -142,7 +139,7 @@ When simulating this transaction, the following authorization entry is returned. The client implementation requires constructing two authorization entries: -1. Replace `"signature": "void"` with the proper `AuthPayload` structure (containing `signers` map and `context_rule_ids`) +1. Replace `"signature": "void"` with the proper `Signatures(Map)` structure 2. Create the missing authorization entry for the delegated signer's `__check_auth` call The following typescript code demonstrates this process: @@ -167,7 +164,7 @@ async function signAndSendTx( const signedAuths: SorobanAuthorizationEntry[] = []; - // 1) Construct the 1st auth entry: `AuthPayload` with `Signer::Delegated(Address)` + // 1) Construct the 1st auth entry: `Signatures(pub Map)` with `Signer::Delegated(Address)` const sigInnerMap = ScVal.scvMap([ new xdr.ScMapEntry({ key: ScVal.scvVec([ @@ -329,10 +326,7 @@ In the case of external signers verification is offloaded to specialized verifie ```rust #[contracttype] -pub struct AuthPayload { - pub signers: Map, - pub context_rule_ids: Vec, -} +pub struct Signatures(pub Map); #[contract] pub struct MySmartAccount; @@ -342,10 +336,10 @@ impl CustomAccountInterface for MySmartAccount { fn __check_auth( e: Env, payload: Hash<32>, - signatures: AuthPayload, + signatures: Signatures, auth_contexts: Vec, ) -> Result<(), SmartAccountError> { - for (signer, sig_data) in signatures.signers.iter() { + for (signer, sig_data) in signatures.0.iter() { match signer { Signer::External(verifier, key_data) => { let sig_payload = Bytes::from_array(e, &signature_payload.to_bytes().to_array()); @@ -371,7 +365,7 @@ This separation provides forward compatibility: when new cryptographic curves ar **Example Scenario** -Consider again a target contract `CAGCDFLG4WKPYG...` that requires authorization from a smart account `CBH6XACZFDCJUHX...`. This time the smart account will grant authorization only if the Ed25519 public key `2b6bad0cfdb3d4b6f2cd...` has signed, meaning `AuthPayload.signers` has to contain one element with key `Signer::External("CDLDYJWEZSM6IAI4...", "2b6bad0cfdb3d4b6f2cd...")` and its signature as the value. +Consider again a target contract `CAGCDFLG4WKPYG...` that requires authorization from a smart account `CBH6XACZFDCJUHX...`. This time the smart account will grant authorization only if the Ed25519 public key `2b6bad0cfdb3d4b6f2cd...` has signed, meaning `Map` has to contain one element that is `Signer::External("CDLDYJWEZSM6IAI4...", "2b6bad0cfdb3d4b6f2cd...")` and its signature. ```mermaid graph LR @@ -450,22 +444,10 @@ fn add_signer( e: &Env, context_rule_id: u32, signer: Signer, -) -> u32; -``` - -Adds a signer to an existing context rule and returns the assigned signer ID. The rule must not exceed the maximum of 15 signers. - -### Batch Adding Signers - -```rust -fn batch_add_signer( - e: &Env, - context_rule_id: u32, - signers: Vec, ); ``` -Adds multiple signers to an existing context rule at once. +Adds a signer to an existing context rule. The rule must not exceed the maximum of 15 signers. **Important:** @@ -478,11 +460,11 @@ When adding signers to rules with threshold policies, administrators must manual fn remove_signer( e: &Env, context_rule_id: u32, - signer_id: u32, + signer: Signer, ); ``` -Removes a signer by its ID from an existing context rule. The rule must maintain at least one signer OR one policy after removal. +Removes a signer from an existing context rule. The rule must maintain at least one signer OR one policy after removal. **Important:** diff --git a/content/stellar-contracts/accounts/smart-account.mdx b/content/stellar-contracts/accounts/smart-account.mdx index b31d3fac..e236be6f 100644 --- a/content/stellar-contracts/accounts/smart-account.mdx +++ b/content/stellar-contracts/accounts/smart-account.mdx @@ -71,7 +71,7 @@ Signers define who can authorize operations. The framework supports both **deleg For detailed documentation, see [Signers and Verifiers](/stellar-contracts/accounts/signers-and-verifiers). ### Policies -Policies act as enforcement modules attached to context rules. They validate and enforce constraints during authorization, panicking if conditions are not met. +Policies act as enforcement modules attached to context rules. They perform read-only prechecks and can update state to enforce limits or workflows. For detailed documentation, see [Policies](/stellar-contracts/accounts/policies). @@ -79,11 +79,11 @@ This separation allows for clean composition of authorization requirements while ## Authorization Flow -Authorization is determined by explicitly selecting context rules via `AuthPayload::context_rule_ids`: +Authorization is determined by matching the current call context against the account's context rules: -1. **Rule Selection**: The caller supplies one rule ID per auth context in the `AuthPayload` -2. **Rule Evaluation**: For each (context, rule_id) pair, verify the rule is not expired and context type matches, then authenticate signers -3. **Policy Enforcement**: If policies are present, call `enforce()` on each. If no policies, all rule signers must be authenticated. +1. **Rule Collection**: Retrieve all non-expired rules for the specific context type and default rules +2. **Rule Evaluation**: For each rule (newest first), authenticate signers and validate policies +3. **Policy Enforcement**: If enough signers are authenticated and policy prechecks succeed, trigger policy state changes 4. **Result**: Grant or deny authorization For detailed documentation, see [Authorization Flow](/stellar-contracts/accounts/authorization-flow).