diff --git a/contract/vault/soroban/README.md b/contract/vault/soroban/README.md index 8c5f2a4d4..e82148844 100644 --- a/contract/vault/soroban/README.md +++ b/contract/vault/soroban/README.md @@ -12,6 +12,13 @@ This crate is the Soroban executor layer for the shared vault kernel. It owns: - RBAC/auth enforcement via `require_auth()` + shared `ActionKind` - execution of `KernelEffect`s against Soroban token contracts +Governance timelock/orchestration lives in the dedicated `contract/vault/soroban/governance` +contract. The runtime still applies canonical governance state changes. Vault-bound governance +actions cross the contract boundary via `execute_governance(env, caller, payload)`, where the +payload carries a `GovernanceCommand`. `SetTimelock` and `Other` actions stay local to the +governance contract. The generic `execute(payload)` path remains for user flows and for the +retained execute-path config subset (`ALLOCATORS`, `ALLOWED_ADAPTERS`, `VIRTUAL_OFFSETS`). + ```mermaid graph TB subgraph Contract["contract/vault/soroban"] @@ -59,6 +66,17 @@ sequenceDiagram Entry-->>Caller: return result ``` +### Governance Control-Plane Boundary + +- The governance contract owns proposal submission, timelocks, approval/revocation, and abdication. +- The runtime remains the canonical owner of applied vault config/policy state. +- Vault-bound governance actions cross the boundary through a single bridge: + `execute_governance(env, caller, payload)`. The payload is a `GovernanceCommand` that the + runtime decodes and dispatches to the corresponding internal config/policy/state helpers. +- `execute(payload)` remains for user flows and for the retained execute-path config subset + (`ALLOCATORS`, `ALLOWED_ADAPTERS`, `VIRTUAL_OFFSETS`). Governance never calls `execute` + for vault-bound mutations. + ### Soroban-Specific Withdrawal Path ```mermaid diff --git a/contract/vault/soroban/STRIDE.md b/contract/vault/soroban/STRIDE.md index a570e9d46..c6498d71a 100644 --- a/contract/vault/soroban/STRIDE.md +++ b/contract/vault/soroban/STRIDE.md @@ -40,7 +40,10 @@ This document captures a Soroban-specific STRIDE threat model for `contract/vaul 5. **Kernel address mapping** — One-way SHA-256 hash from `SdkAddress` to kernel `[u8; 32]`; all effect routing depends on this mapping. 6. **Stored state ↔ Runtime** — Postcard-serialized blobs with version validation on deserialization. 7. **Upgrade boundary** — Two-step upgrade (upgrade → migrate) with blocking interim period. -8. **Governance contract ↔ Vault contract** — Governance contract invokes vault setters (`set_fees`, `set_sentinel`, `set_cap`, `set_skim_recipient`, `skim`, etc.) after timelock maturity. The vault trusts that the governance contract enforces timelocks; the vault itself applies changes immediately when called by governance. +8. **Governance contract ↔ Vault contract** — After timelock maturity, the governance contract + invokes `execute_governance(env, caller, payload)` on the vault, passing a `GovernanceCommand` + payload. The vault trusts that the governance contract enforces timelocks; the vault itself + applies changes immediately when called by governance through this bridge. 9. **Vault contract ↔ Share token contract** — Share token enforces vault auth for `mint()`/`burn()` and `from.require_auth()` for user `transfer()` (while allowing vault-driven internal transfers). The vault address is immutable in the share token after initialization. ### Privilege Hierarchy @@ -48,8 +51,8 @@ This document captures a Soroban-specific STRIDE threat model for `contract/vaul | Role | Set by | Powers | |---|---|---| | **Governance** | `initialize()` or `set_governance()` | Set curator/governance, fees, caps, cap groups, restrictions, supply queue, adapter allowlist, sentinel, skim recipient, and upgrade/migration controls. Must be a contract address. Can abdicate individual actions permanently. | -| **Curator** | `initialize()` or `set_curator()` (governance) | Operational role fallback in RBAC (guardian/allocator) and kernel-level privileged operations via role checks. | -| **Sentinel** | `initialize()` or `set_sentinel()` (governance) | Emergency pause/unpause and time-sensitive guardian actions. Distinct from curator — stored in `VaultDataKey::Sentinel` and loaded into RBAC as `Role::Sentinel`. | +| **Curator** | `initialize()` or `execute_governance` (governance) | Operational role fallback in RBAC (guardian/allocator) and kernel-level privileged operations via role checks. | +| **Sentinel** | `initialize()` or `execute_governance` (governance) | Emergency pause/unpause and time-sensitive guardian actions. Distinct from curator — stored in `VaultDataKey::Sentinel` and loaded into RBAC as `Role::Sentinel`. | | **Allocator** | Curator (via RBAC config) | Allocate supply/withdraw, refresh markets. **Note**: In current Soroban production, no separate allocator is wired — curator key is used for all allocator actions. | | **Guardian** | Curator (via RBAC config) | Pause/unpause vault. **Note**: Same as allocator — no separate guardian wired in production. | | **User** | Any signed account | Deposit, request withdrawal, execute withdrawal. Subject to restrictions (whitelist/blacklist/pause). | @@ -92,17 +95,13 @@ This document captures a Soroban-specific STRIDE threat model for `contract/vaul ┌────────────────────────┼────────────────────────────┼───────────────┐ │ │ │ │ ┌─────┴──────┐ ┌─────────────┴──────────┐ ┌──────────────┴──────┐ ┌─────┴──────┐ - │ Users │ │ Governance │ │ Curator │ │ Adapters │ - │ deposit │ │ set_fees │ │ allocate_supply │ │ supply() │ - │ withdraw │ │ set_curator │ │ allocate_withdraw │ │ withdraw() │ - │ redeem │ │ set_cap │ │ refresh_markets │ │ total_assets│ - │ request_wd │ │ set_restrictions │ │ │ │ │ - └────────────┘ │ set_supply_queue │ │ │ └─────────────┘ - │ set_sentinel │ └─────────────────────┘ - │ set_skim_recipient │ - │ skim │ - │ abdicate │ - └──────────────────────-─┘ + │ Users │ │ Governance │ │ Curator │ │ Adapters │ + │ deposit │ │ execute_governance │ │ allocate_supply │ │ supply() │ + │ withdraw │ │ (payload) │ │ allocate_withdraw │ │ withdraw() │ + │ redeem │ │ upgrade │ │ refresh_markets │ │ total_assets│ + │ request_wd │ │ migrate │ │ │ │ │ + └────────────┘ │ abdicate │ └─────────────────────┘ + └────────────────────────┘ ``` ### Interaction Inventory @@ -116,22 +115,22 @@ This document captures a Soroban-specific STRIDE threat model for `contract/vaul | I5 | Curator → `allocate_supply` | Yes | `require_auth(caller)` + RBAC Allocator | Vault ↔ Asset Token, Vault ↔ Adapter | | I6 | Curator → `allocate_withdraw` | Yes | `require_auth(caller)` + RBAC Allocator | Vault ↔ Adapter, Vault ↔ Asset Token | | I7 | Curator → `refresh_markets` | Yes | `require_auth(caller)` + RBAC Allocator | Vault ↔ Kernel state machine | -| I8 | Governance → `set_fees` | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault storage | -| I9 | Governance → `set_curator` | Yes | `require_auth(caller)` + governance check | Governance ↔ Vault storage | -| I10 | Governance → `set_governance` | Yes | `require_auth(caller)` + governance check | Governance ↔ Vault storage | -| I12 | Governance → `set_supply_queue` / `set_cap` / `set_group_*` / `set_restrictions` / `remove_market` / `set_allowed_adapters` | Yes | `require_auth(caller)` + governance check | Governance ↔ Vault storage | +| I8 | Governance → `execute_governance(payload)` (fees, curator, governance, sentinel, guardian, caps, market removal, cap groups, restrictions, adapter allowlist, supply queue, skim recipient, skim, pause) | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault storage | +| I9 | Governance → `execute_governance(payload)` (see I8) | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault storage | +| I10 | Governance → `execute_governance(payload)` (see I8) | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault storage | +| I12 | Governance → `execute_governance(payload)` (see I8) | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault storage | | I13 | Governance → `upgrade` | Yes | `require_auth(caller)` + governance check | Governance ↔ Soroban deployer | | I14 | Governance → `migrate` | Yes | `require_auth(caller)` + governance check | Governance ↔ Vault storage | -| I15 | Governance → `cancel_migration` | Yes | `require_auth(caller)` + governance check | Governance ↔ migration state | +| I15 | Governance → `execute(payload)` (`VaultCommand::CancelMigration`) | Yes | `require_auth(caller)` + governance check | Governance contract ↔ migration state | | I16 | Anyone → `initialize` | Yes | None (only checks `!Initialized`) | Deployer ↔ Vault storage | | I17 | Anyone → `extend_ttl` | Yes (TTL only) | None | Caller ↔ Vault storage TTL | | I18 | Anyone → read-only methods (`config`, `vault_snapshot`, `fee_info`, `total_assets`, `convert_to_*`, `max_*`, `preview_*`, `is_paused`, `withdraw_status`, `queue_tail`, `cap_groups`, `is_migrating`, `query_asset`, `supply_queue`) | No | None | Caller ↔ Vault storage | -| I19 | Governance → `set_paused` | Yes | `require_auth(caller)` + governance check | Governance ↔ Vault storage, OZ Pausable | -| I20 | Governance → `set_sentinel` | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault storage (`VaultDataKey::Sentinel`) | -| I21 | Governance → `set_skim_recipient` | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault storage (`VaultDataKey::SkimRecipient`) | -| I22 | Governance → `skim(token)` | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault, Vault ↔ External token contract | +| I19 | Governance → `execute_governance(payload)` (pause/unpause) | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault storage, OZ Pausable | +| I20 | Governance → `execute_governance(payload)` (sentinel appointment) | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault storage (`VaultDataKey::Sentinel`) | +| I21 | Governance → `execute_governance(payload)` (skim recipient) | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault storage (`VaultDataKey::SkimRecipient`) | +| I22 | Governance → `execute_governance(payload)` (skim execution) | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault, Vault ↔ External token contract | | I23 | Governance contract → `submit()` / `approve()` / `consume()` | Yes | `require_auth(admin)` + timelock maturity | Governance contract internal state | -| I24 | Governance contract → `abdicate(method_name)` | Yes | `require_auth(admin)` | Governance contract storage (irreversible) | +| I24 | Governance contract → `abdicate(kind)` | Yes | `require_auth(admin)` | Governance contract storage (irreversible) | | I25 | Vault/User ↔ Share token (`transfer`/`mint`/`burn`) | Yes | `require_vault_invoker()` for `mint`/`burn`; `from.require_auth()` for user `transfer` | Vault/User ↔ Share token contract | --- @@ -155,14 +154,18 @@ This document captures a Soroban-specific STRIDE threat model for `contract/vaul |---|---| | **Spoofing** | **Spoof.1** — Caller impersonates a privileged actor (curator, governance, allocator, sentinel) by signing with a compromised key. Interactions: I5–I15, I19–I24. | | | **Spoof.2** — `initialize()` has no caller authentication; anyone can front-run deployment to set themselves as curator/governance. Interaction: I16. (`contract/mod.rs` — only checks `!has(Initialized)`, no `require_auth`.) | -| | **Spoof.3** — If vault's `set_sentinel` or `set_skim_recipient` endpoints do not verify the caller is the governance contract, any caller with `require_auth` could invoke them directly, bypassing governance timelocks. Interactions: I20, I21. | +| | **Spoof.3** — `execute_governance` verifies the outer caller is the governance contract +before decoding or dispatching the payload, preventing direct invocation that would bypass +governance timelocks. Interactions: I8, I19–I22. | | **Tampering** | **Tamper.1** — Malicious or buggy adapter reports incorrect balances during refresh, causing `external_assets` accounting drift. Interactions: I5, I6, I7. | | | **Tamper.5** — In `allocate_supply`, accounting is updated before the token transfer and adapter `supply()` call. The relevant Soroban risk is adapter correctness and full-transaction rollback semantics, not EVM-style reentrancy. Interaction: I5. | | | **Tamper.6** — All persistent state uses postcard serialization. A deserialization bug or version mismatch — especially during upgrade/migrate — could corrupt state or brick the vault. Interaction: I14. | -| | **Tamper.7** — Fee timelock enforcement is now solely in the governance contract, not the vault. If governance contract has a bug in timelock validation or is upgraded to a version that skips timelocks, `set_fees` applies immediately on the vault with no secondary check. Interaction: I8, I23. | +| | **Tamper.7** — Fee timelock enforcement is now solely in the governance contract, not the vault. If governance contract has a bug in timelock validation or is upgraded to a version that skips timelocks, fee changes reach the vault immediately through `execute_governance(payload)` with no secondary check. Interaction: I8, I23. | | | **Tamper.8** — `RemoveMarket` via governance could be invoked for a market with non-zero external exposure, potentially stranding assets or breaking kernel accounting invariants. Interaction: I12. | -| | **Tamper.9** — Governance abdication uses free-form `method_name` strings. A typo or non-canonical string in `abdicate()` would not disable the intended action, while `require_not_abdicated` would pass for the actual method name. Interaction: I24. | -| **Repudiation** | **Repudiate.1** — Operators deny executing sensitive actions. Kernel state transitions emit `KernelEvent` envelopes, and `set_paused` emits OZ Pausable events. However, many privileged operations are auditable via transaction history. Interactions: I1–I7, I19. | +| | **Tamper.9** — Governance abdication now operates on `GovernanceActionKind` rather than free-form +strings. A mismatch between the kind used in `abdicate()` and the kind derived from a submitted +action would leave the action unblocked. Interaction: I24. | +| **Repudiation** | **Repudiate.1** — Operators deny executing sensitive actions. Kernel state transitions emit `KernelEvent` envelopes, and pause/unpause actions (dispatched via `execute_governance`) emit OZ Pausable events. However, many privileged operations are auditable via transaction history. Interactions: I1–I7, I19. | | | **Repudiate.2** — Some privileged/governance operations still rely primarily on tx-level observability where structured events are sparse or lightweight. Interactions: I5, I6, I8–I15. | | | **Repudiate.3** — New governance actions (sentinel change, skim recipient change, abdication, skim execution) should emit structured events for auditability. Without events, irreversible actions like abdication are harder to detect and audit. Interactions: I20–I24. | | **Information Disclosure** | **Info.1** — No confidentiality assumptions exist for contract storage or events; this is expected on-chain transparency. All state is publicly readable. Interaction: I18. | @@ -173,7 +176,10 @@ This document captures a Soroban-specific STRIDE threat model for `contract/vaul | | **DoS.3** — Adapter revert in `supply()`/`withdraw()` fails that allocation operation and primarily impacts the affected market lane (not global solvency). Interactions: I5, I6. | | | **DoS.4** — Large postcard state remains a theoretical resource-limit risk, but current queue sizing analysis indicates practical headroom under expected usage. Interactions: I1–I7. | | | **DoS.5** — Persistent storage entries expire if `extend_ttl` is not called within ~6 months (3,110,400 ledgers at ~5s/ledger). Mitigated: `extend_ttl()` is permissionless (anyone can call it). Interaction: I17. | -| | **DoS.6** — Governance abdication is irreversible. Abdicating safety-critical actions (e.g., `set_paused`, `set_guardian`) permanently removes the ability to respond to emergencies, creating a liveness/safety risk that cannot be recovered from. Interaction: I24. | +| | **DoS.6** — Governance abdication is irreversible. Abdicating safety-critical actions (e.g., +`GovernanceActionKind::Pause`, `GovernanceActionKind::Guardian`) permanently removes the ability +to respond to emergencies, creating a liveness/safety risk that cannot be recovered from. +Interaction: I24. | | | **DoS.7** — Setting cap to 0 via `SetCap` or `SetGroupCap` governance actions blocks new deposits/allocations into the affected market or group. While intentional for wind-down scenarios, misconfiguration could halt vault operations. Interaction: I12. | | | **DoS.8** — `skim(token)` fails if the skim recipient address cannot receive the token (e.g., missing trustline on Stellar classic). This blocks recovery of that specific token but does not affect vault operations. Interaction: I22. | | **Elevation of Privilege** | **Elevation.1** — Role mapping or configuration errors could grant unintended powers. Interactions: I1–I15. | @@ -194,16 +200,28 @@ This document captures a Soroban-specific STRIDE threat model for `contract/vaul |---|---| | **Spoofing** | **Spoof.1.R.1** — `require_auth()` is called on all privileged entrypoints. Role-based authorization via `ActionKind` → `allowed_roles_for_action()` mapping in curator-primitives keeps the effective role set explicit. **Spoof.1.R.2** — Operational: use multisig or segregated keys for curator and governance. Hardware security modules for high-value deployments. | | | **Spoof.2.R.1** — Deploy and initialize atomically (e.g., via a factory contract that deploys + calls `initialize` in a single transaction). **Spoof.2.R.2** — Consider adding an `admin` parameter to `initialize()` or requiring the deployer's auth to prevent front-running. **Spoof.2.R.3** — Accepted risk: Soroban contract deployment is typically atomic with initialization in practice, but this is a procedural control, not a technical one. | -| | **Spoof.3.R.1** — ✅ **Implemented**: Vault endpoints `set_sentinel`, `set_skim_recipient`, and `skim` require `require_auth(caller)` + governance address check before applying changes. Only the governance contract can invoke these setters. **Spoof.3.R.2** — The governance contract itself enforces `require_auth(admin)` + timelock maturity before calling vault endpoints. | +| | **Spoof.3.R.1** — ✅ **Implemented**: `execute_governance` requires `require_auth(caller)` + +governance address check before decoding and dispatching the payload. Only the governance +contract can invoke this entrypoint. **Spoof.3.R.2** — The governance contract itself enforces +`require_auth(admin)` + timelock maturity before calling `execute_governance`. | | **Tampering** | **Tamper.1.R.1** — `allocate` and `refresh_markets` query adapters internally; the kernel validates state transitions. **Tamper.1.R.2** — Restrict adapters to vetted, audited contracts. Monitor external_assets drift vs adapter-reported totals. **Tamper.1.R.3** — Consider adding a maximum drift threshold that pauses the vault if adapter-reported assets deviate beyond tolerance. | | | **Tamper.5.R.1** — Soroban transactions are atomic; if the adapter call fails, the entire transaction (including state update) reverts. **Tamper.5.R.2** — Focus review on adapter behavior, authorization boundaries, and accounting correctness around external calls instead of reentrancy guards. **Tamper.5.R.3** — The state is intentionally consistent at the external call boundary (`external_assets` updated before `allocate_supply` transfers, realized assets applied before `allocate_withdraw` returns). | | | **Tamper.6.R.1** — Storage decode validates blob deserialization, version key presence, version match, and compatibility before using persisted state. **Tamper.6.R.2** — Pin postcard crate version; audit serialization round-trip in CI. **Tamper.6.R.3** — Upgrade/migrate flow validates storage version compatibility before proceeding. | | | **Tamper.7.R.1** — ✅ **Implemented**: Fee timelocks are enforced in the `soroban/governance` contract via `TimelockKind::Fees` with decision functions from `curator-primitives` that distinguish fee increases (timelocked) from decreases (immediate). **Tamper.7.R.2** — Governance contract enforces `require_contract_address` — it cannot be an EOA. Upgrading the governance contract itself requires governance auth + timelock. **Tamper.7.R.3** — Future consideration: optional vault-side minimum delay or governance-proof verification for defense-in-depth. | | | **Tamper.8.R.1** — `remove_market` in the vault validates via kernel state that the market has zero exposure before removal. Attempting to remove a market with outstanding allocations will fail. **Tamper.8.R.2** — Governance timelock on `RemoveMarket` provides observation window for operators to verify market state. | -| | **Tamper.9.R.1** — ✅ **Implemented**: `method_name_for_action()` in `soroban/governance` canonicalizes action kinds to method names, ensuring `abdicate()` and `require_not_abdicated()` use the same string mapping. **Tamper.9.R.2** — The governance contract's `abdicate()` accepts the same method name format used by `submit()`, preventing typo-based bypasses. | +| | **Tamper.9.R.1** — ✅ **Implemented**: `action_kind()` in `soroban/governance` maps every +`GovernanceAction` to a canonical `GovernanceActionKind`. `abdicate()` and `require_not_abdicated()` +both operate on the same kind, preventing typo-based bypasses. **Tamper.9.R.2** — The governance +contract rejects submission of actions whose kind has been abdicated before queuing them. | | **Repudiation** | **Repudiate.1.R.1** — Actions require signed caller auth. Kernel state transitions emit `KernelEvent` envelopes via `publish_kernel_event`. OZ Pausable events emitted for pause/unpause. **Repudiate.1.R.2** — Maintain off-chain indexing/audit trails keyed by `op_id`, caller address, and timestamps. | -| | **Repudiate.2.R.1** — ✅ **Implemented**: Admin/allocation events are emitted for high-impact privileged operations (`set_curator`, `set_governance`, `set_fees`, `set_restrictions`, `set_allowed_adapters`, `upgrade`, `migrate`, `cancel_migration`, `allocate_supply`, `allocate_withdraw`). **Repudiate.2.R.2** — Soroban transaction-level observability (sender, function name, arguments) provides additional backup. | -| | **Repudiate.3.R.1** — Governance contract actions (`submit`, `approve`, `consume`, `revoke`, `abdicate`) are observable via Soroban transaction history (function name + arguments). **Repudiate.3.R.2** — Consider adding structured events in the governance contract for abdication and high-impact action completion. **Repudiate.3.R.3** — Vault-side setters (`set_sentinel`, `set_skim_recipient`, `skim`) emit admin events via the existing `emit_admin_event()` pattern. | +| | **Repudiate.2.R.1** — ✅ **Implemented**: Admin/allocation events are emitted for high-impact +privileged operations dispatched through `execute_governance(payload)` (curator, governance, +fees, restrictions, adapter allowlist, sentinel, guardian, caps, skim, pause, etc.) and for +direct runtime methods (`upgrade`, `migrate`, `allocate_supply`, `allocate_withdraw`). +**Repudiate.2.R.2** — Soroban transaction-level observability (sender, function name, arguments) +provides additional backup. | +| | **Repudiate.3.R.1** — Governance contract actions (`submit`, `approve`, `consume`, `revoke`, `abdicate`) are observable via Soroban transaction history (function name + arguments). **Repudiate.3.R.2** — Consider adding structured events in the governance contract for abdication and high-impact action completion. **Repudiate.3.R.3** — Vault-side config changes reached through `execute_governance(payload)` +emit admin events via the existing `emit_admin_event()` pattern. | | **Information Disclosure** | **Info.1.R.1** — Accepted: no confidentiality assumptions on-chain. This is expected behavior for public blockchain contracts. **Info.1.R.2** — Avoid introducing unnecessary detailed event payloads that could leak operational patterns. | | | **Info.2.R.1** — Accepted risk: governance and fee configuration are operational parameters that need to be publicly verifiable. **Info.2.R.2** — Protect governance/curator keys through operational security (multisig, HSM), not obscurity. | | | **Info.3.R.1** — Accepted risk: queue transparency is a feature for user trust. **Info.3.R.2** — Monitor for unusual withdrawal patterns that might indicate front-running. **Info.3.R.3** — Consider: withdrawal cooldown (`DEFAULT_COOLDOWN_NS`) already provides some protection against same-block front-running. | @@ -212,16 +230,20 @@ This document captures a Soroban-specific STRIDE threat model for `contract/vaul | | **DoS.3.R.1** — Adapter failures are localized to affected market operations; maintain diversified/vetted adapters. **DoS.3.R.2** — ✅ Implemented: adapter allowlist + queue-index routing enables rapid disabling of bad adapters. | | | **DoS.4.R.1** — Accepted with monitoring based on current sizing math and workload expectations. **DoS.4.R.2** — Keep telemetry on queue depth/resource usage and revisit if workload or network limits change. | | | **DoS.5.R.1** — `extend_ttl()` is permissionless — anyone can call it. TTL threshold is ~30 days (518,400 ledgers), extension is to ~6 months (3,110,400 ledgers). **DoS.5.R.2** — Operate a keeper bot that calls `extend_ttl` periodically. **DoS.5.R.3** — `save_state` and `save_address` automatically extend TTL on writes, providing additional safety margin. | -| | **DoS.6.R.1** — Abdication is intentionally irreversible — it provides credible commitment that governance cannot perform certain actions. **DoS.6.R.2** — Operational: maintain a clear policy on which actions are safe to abdicate (e.g., fee changes, skim) vs. which must never be abdicated (e.g., pause, upgrade). **DoS.6.R.3** — Consider: the governance contract could maintain a hardcoded deny-list of method names that cannot be abdicated (e.g., `set_paused`). | +| | **DoS.6.R.1** — Abdication is intentionally irreversible — it provides credible commitment that governance cannot perform certain actions. **DoS.6.R.2** — Operational: maintain a clear policy on which actions are safe to abdicate (e.g., fee changes, skim) vs. which must never be abdicated (e.g., pause, upgrade). **DoS.6.R.3** — Consider: the governance contract could maintain a hardcoded deny-list of +`GovernanceActionKind` values that cannot be abdicated (e.g., `Pause`). | | | **DoS.7.R.1** — Accepted: cap=0 is a valid wind-down configuration. **DoS.7.R.2** — Governance timelocks on `SetCap` and `SetGroupCap` provide observation window for operators to detect misconfiguration. | | | **DoS.8.R.1** — Skim failure is isolated to the specific token and does not affect vault operations. **DoS.8.R.2** — `set_skim_recipient` should validate that the recipient is a valid address (non-zero). | | **Elevation of Privilege** | **Elevation.1.R.1** — Centralized action authorization via `ActionKind` → `allowed_roles_for_action()` in curator-primitives. **Elevation.1.R.2** — Preserve strict role review on new entrypoints, especially those that perform external calls after state transitions. **Elevation.1.R.3** — Keep governance-only setters and adapter allowlisting explicit and test-covered. | -| | **Elevation.2.R.1** — Accepted design decision for initial deployment: single curator key simplifies operations. **Elevation.2.R.2** — ✅ **Implemented**: Separate guardian, allocator, and sentinel address sets stored in persistent storage (`VaultDataKey::Guardians`, `VaultDataKey::Allocators`, `VaultDataKey::Sentinel`). Loaded in `load_vault_bootstrap()` via `rbac_config.add_role()`. **Elevation.2.R.3** — ✅ **Implemented**: Governance methods `set_guardians()`, `set_allocators()`, and `set_sentinel()` added to enable operational role separation. | +| | **Elevation.2.R.1** — Accepted design decision for initial deployment: single curator key simplifies operations. **Elevation.2.R.2** — ✅ **Implemented**: Separate guardian, allocator, and sentinel address sets stored in persistent storage (`VaultDataKey::Guardians`, `VaultDataKey::Allocators`, `VaultDataKey::Sentinel`). Loaded in `load_vault_bootstrap()` via `rbac_config.add_role()`. **Elevation.2.R.3** — ✅ **Implemented**: Role separation is enabled through +`execute_governance(payload)` payloads that update guardian, allocator, and sentinel addresses. | | | **Elevation.3.R.1** — Atomic withdrawals require vault to be in `Idle` state, sufficient `idle_assets`, and are capped to idle balance. **Elevation.3.R.2** — `refresh_fees_for_atomic()` is called before atomic withdrawals to ensure fees are current. **Elevation.3.R.3** — Document the atomic withdrawal path clearly in user-facing documentation as an intentional feature for immediate withdrawal from idle assets. | | | **Elevation.4.R.1** — Require governance to be a multisig or DAO contract (enforced: `require_contract_address` in `set_governance`). **Elevation.4.R.2** — ✅ **Implemented**: `soroban/governance` contract enforces timelocks on all high-impact actions (fees, caps, sentinel, guardian, curator, restrictions, adapter changes, upgrade). Decision functions from `curator-primitives` determine whether changes are immediate or timelocked based on direction (increase vs decrease). **Elevation.4.R.3** — Monitor all governance transactions with alerting. | | | **Elevation.5.R.1** — ✅ **Implemented**: `skim()` explicitly rejects the asset token and share token, preventing drainage of vault-critical balances. **Elevation.5.R.2** — Skim recipient is set via timelocked governance action (`SetSkimRecipient`). **Elevation.5.R.3** — Operational: governance should set skim recipient to a treasury/multisig, not an individual key. | | | **Elevation.6.R.1** — `ESCROW_ADDRESS = [0u8; 32]` is mapped to the vault's own contract address, ensuring escrow operations (share transfers during withdrawal) route correctly. **Elevation.6.R.2** — The escrow mapping is set during vault bootstrap and is consistent across all invocations. No additional remediation needed. | -| | **Elevation.7.R.1** — ✅ **Implemented**: `SorobanAuth::has_role` checks the sentinel address distinctly from the curator. When no sentinel is set (`VaultDataKey::Sentinel` absent), `Role::Sentinel` checks fall back to curator as a bootstrap convenience. **Elevation.7.R.2** — Operational: deploy with an explicit sentinel address from day one. Use `set_sentinel` to establish a distinct sentinel as soon as operational key infrastructure is ready. | +| | **Elevation.7.R.1** — ✅ **Implemented**: `SorobanAuth::has_role` checks the sentinel address distinctly from the curator. When no sentinel is set (`VaultDataKey::Sentinel` absent), `Role::Sentinel` checks fall back to curator as a bootstrap convenience. **Elevation.7.R.2** — Operational: deploy with an explicit sentinel address from day one. Use +`execute_governance(payload)` with a sentinel appointment command to establish a distinct +sentinel as soon as operational key infrastructure is ready. | | | **Elevation.8.R.1** — ✅ **Implemented**: Share token stores the vault address at initialization; `require_vault_invoker()` is enforced on `mint`/`burn`, while user transfers require `from.require_auth()`. **Elevation.8.R.2** — The share token contract has no admin endpoint to change the vault address post-initialization. **Elevation.8.R.3** — If the share token contract is made upgradeable in the future, ensure the vault address remains immutable across migrations. | | | **Elevation.9.R.1** — ✅ **Implemented**: `soroban/governance` maps each `GovernanceActionKind` to a `TimelockKind` with appropriate sensitivity levels. Decision functions from `curator-primitives` enforce directional timelocks (e.g., fee increases are timelocked, decreases are immediate). **Elevation.9.R.2** — Add integration tests that verify timelock kind mappings for all governance action kinds (partially done: 7 governance tests cover sentinel, cap, and core actions). | @@ -237,9 +259,17 @@ This document captures a Soroban-specific STRIDE threat model for `contract/vaul - **Kernel architecture**: The `#[contractimpl]` block provides the Soroban on-chain API; `CuratorVault` is chain-agnostic and reuses `curator-primitives` for auth/rbac/policy and `templar-vault-kernel` for state machine/transitions/effects/fee math. - **Production flows**: `allocate_supply` and `allocate_withdraw` directly manage the kernel state machine, then perform external calls. `refresh_markets` drives the kernel refresh flow through the runtime helper that persists kernel state and effects. - **Removed methods**: `sync_external_assets`, `verify_external_assets_against_adapter`, `manual_reconcile`, `abort_allocating`, `abort_refreshing`, `abort_withdrawing`, `recover`, `settle_payout`, `refresh_fees`, and market lock methods (`acquire_market_lock`, `release_market_lock`, `is_market_locked`) no longer exist in the Soroban implementation. -- **Fee timelock architecture**: Fee timelocks are enforced exclusively in the `soroban/governance` contract. The vault's `set_fees` applies changes immediately when called by governance. The vault-level `PendingFeesChange` queue has been removed. This is a deliberate single-responsibility design: governance owns timelock policy, vault owns state application. +- **Fee timelock architecture**: Fee timelocks are enforced exclusively in the `soroban/governance` contract. The vault's direct governance policy method applies fee changes immediately when called by governance. The vault-level `PendingFeesChange` queue has been removed. This is a deliberate single-responsibility design: governance owns timelock policy, vault owns state application. +- **Governance bridge shape**: Governance now sends all vault-bound mutations through a single + `execute_governance(env, caller, payload)` bridge using `GovernanceCommand` payloads. + `execute(payload)` remains for user flows and the retained execute-path config subset + (`ALLOCATORS`, `ALLOWED_ADAPTERS`, `VIRTUAL_OFFSETS`). This reduces the runtime governance + surface to one entrypoint while keeping canonical vault-state ownership in the runtime. - **Share token policy**: The share token enforces `require_vault_invoker()` on `mint`/`burn`, and user transfers require `from.require_auth()`. Vault-driven internal transfer effects remain supported. The vault address in the share token is set at initialization and is immutable. -- **Governance abdication**: `abdicate(method_name)` is irreversible. Once an action is abdicated, `require_not_abdicated` permanently blocks `submit()` for that method. This provides credible commitment that governance cannot perform certain actions — a feature, not a bug, when used for depositor protection (e.g., abdicating fee increases). +- **Governance abdication**: `abdicate(kind)` is irreversible. Once a `GovernanceActionKind` is + abdicated, `require_not_abdicated` permanently blocks `submit()` for actions of that kind. + This provides credible commitment that governance cannot perform certain actions — a feature, + not a bug, when used for depositor protection (e.g., abdicating fee increases). --- @@ -263,9 +293,9 @@ This document captures a Soroban-specific STRIDE threat model for `contract/vaul 1. ✅ Events on all privileged operations (Repudiate.2.R.1) — `emit_admin_event()` / `emit_alloc_event()` helpers. 2. ✅ Fee governance hardening — timelocked via `soroban/governance` with directional decision functions from `curator-primitives`. Vault-level duplicate timelock removed. 3. ✅ Adapter routing hardening — adapter is no longer caller-supplied; routing is derived from on-chain `supply_queue` + `AllowedAdapters`. - 4. ✅ Role separation (Elevation.2.R.2/R.3) — `VaultDataKey::Guardians` / `VaultDataKey::Allocators` / `VaultDataKey::Sentinel` + governance setters. + 4. ✅ Role separation (Elevation.2.R.2/R.3) — `VaultDataKey::Guardians` / `VaultDataKey::Allocators` / `VaultDataKey::Sentinel` + governance-appointed roles via `execute_governance`. 5. ✅ Governance-only upgrade/migrate + cancel path — `upgrade()`/`migrate()` moved to governance, plus `cancel_migration()`. - 6. ✅ Sentinel parity (Elevation.7.R.1) — Distinct sentinel address stored and loaded into RBAC, with `set_sentinel` governance action. + 6. ✅ Sentinel parity (Elevation.7.R.1) — Distinct sentinel address stored and loaded into RBAC, with sentinel appointment via `execute_governance`. 7. ✅ Skim parity (Elevation.5.R.1) — `skim()` rejects asset/share tokens, recipient set via timelocked governance. 8. ✅ Governance timelocks on all high-impact actions (Elevation.4.R.2) — `soroban/governance` with per-action timelock kinds. 9. ✅ Governance abdication (DoS.6.R.1) — `abdicate()` permanently disables actions with canonical method name mapping. diff --git a/contract/vault/soroban/governance/src/lib.rs b/contract/vault/soroban/governance/src/lib.rs index 5ad5ee74d..81fa7fe79 100644 --- a/contract/vault/soroban/governance/src/lib.rs +++ b/contract/vault/soroban/governance/src/lib.rs @@ -5,10 +5,10 @@ extern crate alloc; mod types; pub use types::*; -use alloc::{string::String, vec}; +use alloc::string::String as AllocString; use soroban_sdk::{ auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}, - contract, contractimpl, Address, Bytes, BytesN, Env, String as SdkString, Symbol, Vec, + contract, contractimpl, Address, Bytes, BytesN, Env, IntoVal, String as SdkString, Symbol, Vec, }; use templar_curator_primitives::governance::{ timelock_config_decision, CapChangeError, FeeChangeError, FeeConfig, MembershipChangeError, @@ -17,7 +17,7 @@ use templar_curator_primitives::governance::{ }; use templar_curator_primitives::{nonnegative_i128_to_u128, seconds_to_nanoseconds}; use templar_soroban_shared_types::{ - VaultCommand, GOVERNANCE_CONFIG_KIND_CURATOR, GOVERNANCE_CONFIG_KIND_GOVERNANCE, + GovernanceCommand, GOVERNANCE_CONFIG_KIND_CURATOR, GOVERNANCE_CONFIG_KIND_GOVERNANCE, GOVERNANCE_CONFIG_KIND_GUARDIANS, GOVERNANCE_CONFIG_KIND_SENTINEL, GOVERNANCE_CONFIG_KIND_SKIM_RECIPIENT, GOVERNANCE_POLICY_KIND_CAP, GOVERNANCE_POLICY_KIND_FEES, GOVERNANCE_POLICY_KIND_GROUP, GOVERNANCE_POLICY_KIND_PAUSED, @@ -311,20 +311,24 @@ impl SorobanVaultGovernanceContract { Self::submit(env, caller, GovernanceAction::Skim(token)) } - pub fn abdicate(env: Env, caller: Address, method_name: Symbol) -> Result<(), GovernanceError> { + pub fn abdicate( + env: Env, + caller: Address, + kind: GovernanceActionKind, + ) -> Result<(), GovernanceError> { extend_instance_ttl(&env); require_admin(&env, &caller)?; env.storage() .instance() - .set(&DataKey::Abdicated(method_name), &true); + .set(&DataKey::Abdicated(kind), &true); Ok(()) } - pub fn is_abdicated(env: Env, method_name: Symbol) -> bool { + pub fn is_abdicated(env: Env, kind: GovernanceActionKind) -> bool { extend_instance_ttl(&env); env.storage() .instance() - .get(&DataKey::Abdicated(method_name)) + .get(&DataKey::Abdicated(kind)) .unwrap_or(false) } @@ -1009,121 +1013,26 @@ fn revoke_where(env: &Env, pred: impl Fn(&GovernanceAction) -> bool) -> u32 { } fn execute_action(env: &Env, action: &GovernanceAction) -> Result<(), GovernanceError> { - let governance = env.current_contract_address(); let vault = get_address(env, DataKey::Vault)?; match action { GovernanceAction::SetPaused(paused) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernancePolicy { - caller: address_text(&governance), - kind: GOVERNANCE_POLICY_KIND_PAUSED, - target_ids: None, - mode: Some(u32::from(*paused)), - accounts: None, - market_id: None, - cap_group_id: None, - value: None, - value_b: None, - value_c: None, - }, - ); + execute_vault_governance_action(env, &vault, action)?; env.storage() .instance() .set(&DataKey::CurrentPaused, paused); } - GovernanceAction::SetCurator(new_curator) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernanceConfig { - caller: address_text(&governance), - kind: GOVERNANCE_CONFIG_KIND_CURATOR, - primary: Some(address_text(new_curator)), - many: None, - value_a: None, - value_b: None, - }, - ); - } - GovernanceAction::SetGovernance(new_governance) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernanceConfig { - caller: address_text(&governance), - kind: GOVERNANCE_CONFIG_KIND_GOVERNANCE, - primary: Some(address_text(new_governance)), - many: None, - value_a: None, - value_b: None, - }, - ); - } - GovernanceAction::SetSupplyQueue(target_ids) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernancePolicy { - caller: address_text(&governance), - kind: GOVERNANCE_POLICY_KIND_SUPPLY_QUEUE, - target_ids: Some(target_ids.iter().collect()), - mode: None, - accounts: None, - market_id: None, - cap_group_id: None, - value: None, - value_b: None, - value_c: None, - }, - ); + GovernanceAction::SetCurator(_) + | GovernanceAction::SetGovernance(_) + | GovernanceAction::SetSupplyQueue(_) => { + execute_vault_governance_action(env, &vault, action)? } GovernanceAction::SetFees(params) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernancePolicy { - caller: address_text(&governance), - kind: GOVERNANCE_POLICY_KIND_FEES, - target_ids: None, - mode: None, - accounts: Some(vec![ - address_text(¶ms.performance_recipient), - address_text(¶ms.management_recipient), - ]), - market_id: None, - cap_group_id: None, - value: Some(params.performance_fee_wad), - value_b: Some(params.management_fee_wad), - value_c: params.max_growth_rate_wad, - }, - ); + execute_vault_governance_action(env, &vault, action)?; env.storage().instance().set(&DataKey::CurrentFees, params); } GovernanceAction::SetRestrictions(mode, accounts) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernancePolicy { - caller: address_text(&governance), - kind: GOVERNANCE_POLICY_KIND_RESTRICTIONS, - target_ids: None, - mode: Some(mode.as_u32()), - accounts: Some( - accounts - .iter() - .map(|account| address_text(&account)) - .collect(), - ), - market_id: None, - cap_group_id: None, - value: None, - value_b: None, - value_c: None, - }, - ); + execute_vault_governance_action(env, &vault, action)?; env.storage() .instance() .set(&DataKey::CurrentRestrictionMode, mode); @@ -1132,157 +1041,27 @@ fn execute_action(env: &Env, action: &GovernanceAction) -> Result<(), Governance .set(&DataKey::CurrentRestrictionAccounts, accounts); } GovernanceAction::SetGuardian(guardian) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernanceConfig { - caller: address_text(&governance), - kind: GOVERNANCE_CONFIG_KIND_GUARDIANS, - primary: None, - many: Some(vec![address_text(guardian)]), - value_a: None, - value_b: None, - }, - ); + execute_vault_governance_action(env, &vault, action)?; env.storage().instance().set(&DataKey::Guardian, guardian); } GovernanceAction::SetSentinel(sentinel) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernanceConfig { - caller: address_text(&governance), - kind: GOVERNANCE_CONFIG_KIND_SENTINEL, - primary: Some(address_text(sentinel)), - many: None, - value_a: None, - value_b: None, - }, - ); + execute_vault_governance_action(env, &vault, action)?; env.storage().instance().set(&DataKey::Sentinel, sentinel); } - GovernanceAction::SetCap(market_id, new_cap) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernancePolicy { - caller: address_text(&governance), - kind: GOVERNANCE_POLICY_KIND_CAP, - target_ids: None, - mode: None, - accounts: None, - market_id: Some(*market_id), - cap_group_id: None, - value: Some(*new_cap), - value_b: None, - value_c: None, - }, - ); - } - GovernanceAction::RemoveMarket(market_id) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernancePolicy { - caller: address_text(&governance), - kind: GOVERNANCE_POLICY_KIND_REMOVE_MARKET, - target_ids: None, - mode: None, - accounts: None, - market_id: Some(*market_id), - cap_group_id: None, - value: None, - value_b: None, - value_c: None, - }, - ); - } - GovernanceAction::SetGroupCap(cap_group_id, new_cap) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernancePolicy { - caller: address_text(&governance), - kind: GOVERNANCE_POLICY_KIND_GROUP, - target_ids: None, - mode: Some(0), - accounts: None, - market_id: Some(0), - cap_group_id: Some(sdk_string_text(cap_group_id)), - value: Some(*new_cap), - value_b: None, - value_c: None, - }, - ); - } - GovernanceAction::SetGroupRelCap(cap_group_id, new_relative_cap_wad) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernancePolicy { - caller: address_text(&governance), - kind: GOVERNANCE_POLICY_KIND_GROUP, - target_ids: None, - mode: Some(1), - accounts: None, - market_id: Some(0), - cap_group_id: Some(sdk_string_text(cap_group_id)), - value: Some(*new_relative_cap_wad), - value_b: None, - value_c: None, - }, - ); - } - GovernanceAction::SetGroupMember(market_id, cap_group_id) => { - let encoded_cap_group_id = if cap_group_id.is_empty() { - None - } else { - Some(sdk_string_text(cap_group_id)) - }; - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernancePolicy { - caller: address_text(&governance), - kind: GOVERNANCE_POLICY_KIND_GROUP, - target_ids: None, - mode: Some(2), - accounts: None, - market_id: Some(*market_id), - cap_group_id: encoded_cap_group_id, - value: Some(0), - value_b: None, - value_c: None, - }, - ); + GovernanceAction::SetCap(_, _) + | GovernanceAction::RemoveMarket(_) + | GovernanceAction::SetGroupCap(_, _) + | GovernanceAction::SetGroupRelCap(_, _) + | GovernanceAction::SetGroupMember(_, _) => { + execute_vault_governance_action(env, &vault, action)? } GovernanceAction::SetSkimRecipient(recipient) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::SetGovernanceConfig { - caller: address_text(&governance), - kind: GOVERNANCE_CONFIG_KIND_SKIM_RECIPIENT, - primary: Some(address_text(recipient)), - many: None, - value_a: None, - value_b: None, - }, - ); + execute_vault_governance_action(env, &vault, action)?; env.storage() .instance() .set(&DataKey::SkimRecipient, recipient); } - GovernanceAction::Skim(token) => { - authorize_and_execute( - env, - &vault, - &VaultCommand::Skim { - caller: address_text(&governance), - token: address_text(token), - }, - ); - } + GovernanceAction::Skim(_) => execute_vault_governance_action(env, &vault, action)?, GovernanceAction::SetTimelock(kind, new_timelock_ns) => { validate_timelock_ns(*new_timelock_ns)?; let mut timelocks = load_timelocks(env); @@ -1305,10 +1084,222 @@ fn execute_action(env: &Env, action: &GovernanceAction) -> Result<(), Governance Ok(()) } -fn authorize_and_execute(env: &Env, vault: &Address, command: &VaultCommand) { - let fn_name = Symbol::new(env, "execute"); - let payload = command_payload(env, command); - let args = Vec::from_array(env, [payload.to_val()]); +fn execute_vault_governance_action( + env: &Env, + vault: &Address, + action: &GovernanceAction, +) -> Result<(), GovernanceError> { + let payload = + governance_payload_for_action(env, action)?.ok_or(GovernanceError::InvalidInput)?; + let governance = env.current_contract_address(); + authorize_and_invoke( + env, + vault, + Symbol::new(env, "execute_governance"), + Vec::from_array(env, [governance.into_val(env), payload.into_val(env)]), + ); + Ok(()) +} + +fn governance_payload_for_action( + env: &Env, + action: &GovernanceAction, +) -> Result, GovernanceError> { + let command = match action { + GovernanceAction::SetCurator(curator) => Some(GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_CURATOR, + primary: Some(sdk_address_to_alloc_string(curator)?), + many: None, + value_a: None, + value_b: None, + }), + GovernanceAction::SetGovernance(governance) => { + Some(GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_GOVERNANCE, + primary: Some(sdk_address_to_alloc_string(governance)?), + many: None, + value_a: None, + value_b: None, + }) + } + GovernanceAction::SetGuardian(guardian) => Some(GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_GUARDIANS, + primary: None, + many: Some(alloc::vec![sdk_address_to_alloc_string(guardian)?]), + value_a: None, + value_b: None, + }), + GovernanceAction::SetSentinel(sentinel) => Some(GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_SENTINEL, + primary: Some(sdk_address_to_alloc_string(sentinel)?), + many: None, + value_a: None, + value_b: None, + }), + GovernanceAction::SetSkimRecipient(recipient) => { + Some(GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_SKIM_RECIPIENT, + primary: Some(sdk_address_to_alloc_string(recipient)?), + many: None, + value_a: None, + value_b: None, + }) + } + GovernanceAction::Skim(token) => Some(GovernanceCommand::Skim { + token: sdk_address_to_alloc_string(token)?, + }), + GovernanceAction::SetSupplyQueue(target_ids) => { + Some(GovernanceCommand::SetGovernancePolicy { + kind: GOVERNANCE_POLICY_KIND_SUPPLY_QUEUE, + target_ids: Some(soroban_u32_vec_to_alloc(target_ids)), + mode: None, + accounts: None, + market_id: None, + cap_group_id: None, + value: None, + value_b: None, + value_c: None, + }) + } + GovernanceAction::SetPaused(paused) => Some(GovernanceCommand::SetGovernancePolicy { + kind: GOVERNANCE_POLICY_KIND_PAUSED, + target_ids: None, + mode: Some(u32::from(*paused)), + accounts: None, + market_id: None, + cap_group_id: None, + value: None, + value_b: None, + value_c: None, + }), + GovernanceAction::SetFees(params) => Some(GovernanceCommand::SetGovernancePolicy { + kind: GOVERNANCE_POLICY_KIND_FEES, + target_ids: None, + mode: None, + accounts: Some(alloc::vec![ + sdk_address_to_alloc_string(¶ms.performance_recipient)?, + sdk_address_to_alloc_string(¶ms.management_recipient)?, + ]), + market_id: None, + cap_group_id: None, + value: Some(params.performance_fee_wad), + value_b: Some(params.management_fee_wad), + value_c: params.max_growth_rate_wad, + }), + GovernanceAction::SetRestrictions(mode, accounts) => { + Some(GovernanceCommand::SetGovernancePolicy { + kind: GOVERNANCE_POLICY_KIND_RESTRICTIONS, + target_ids: None, + mode: Some(mode.as_u32()), + accounts: Some(soroban_address_vec_to_alloc(accounts)?), + market_id: None, + cap_group_id: None, + value: None, + value_b: None, + value_c: None, + }) + } + GovernanceAction::SetCap(market_id, cap) => Some(GovernanceCommand::SetGovernancePolicy { + kind: GOVERNANCE_POLICY_KIND_CAP, + target_ids: None, + mode: None, + accounts: None, + market_id: Some(*market_id), + cap_group_id: None, + value: Some(*cap), + value_b: None, + value_c: None, + }), + GovernanceAction::RemoveMarket(market_id) => Some(GovernanceCommand::SetGovernancePolicy { + kind: GOVERNANCE_POLICY_KIND_REMOVE_MARKET, + target_ids: None, + mode: None, + accounts: None, + market_id: Some(*market_id), + cap_group_id: None, + value: None, + value_b: None, + value_c: None, + }), + GovernanceAction::SetGroupCap(cap_group_id, cap) => { + Some(GovernanceCommand::SetGovernancePolicy { + kind: GOVERNANCE_POLICY_KIND_GROUP, + target_ids: None, + mode: Some(0), + accounts: None, + market_id: None, + cap_group_id: Some(sdk_string_to_alloc_string(cap_group_id)?), + value: Some(*cap), + value_b: None, + value_c: None, + }) + } + GovernanceAction::SetGroupRelCap(cap_group_id, relative_cap_wad) => { + Some(GovernanceCommand::SetGovernancePolicy { + kind: GOVERNANCE_POLICY_KIND_GROUP, + target_ids: None, + mode: Some(1), + accounts: None, + market_id: None, + cap_group_id: Some(sdk_string_to_alloc_string(cap_group_id)?), + value: Some(*relative_cap_wad), + value_b: None, + value_c: None, + }) + } + GovernanceAction::SetGroupMember(market_id, cap_group_id) => { + let cap_group_id = if cap_group_id.is_empty() { + None + } else { + Some(sdk_string_to_alloc_string(cap_group_id)?) + }; + Some(GovernanceCommand::SetGovernancePolicy { + kind: GOVERNANCE_POLICY_KIND_GROUP, + target_ids: None, + mode: Some(2), + accounts: None, + market_id: Some(*market_id), + cap_group_id, + value: None, + value_b: None, + value_c: None, + }) + } + GovernanceAction::SetTimelock(_, _) | GovernanceAction::Other(_, _) => None, + }; + + Ok(command.map(|command| Bytes::from_slice(env, &command.encode()))) +} + +fn sdk_address_to_alloc_string(address: &Address) -> Result { + let raw = address.to_string().to_bytes().to_alloc_vec(); + AllocString::from_utf8(raw).map_err(|_| GovernanceError::InvalidInput) +} + +fn sdk_string_to_alloc_string(value: &SdkString) -> Result { + AllocString::from_utf8(value.to_bytes().to_alloc_vec()) + .map_err(|_| GovernanceError::InvalidInput) +} + +fn soroban_u32_vec_to_alloc(values: &Vec) -> alloc::vec::Vec { + let mut result = alloc::vec::Vec::new(); + for value in values.iter() { + result.push(value); + } + result +} + +fn soroban_address_vec_to_alloc( + values: &Vec
, +) -> Result, GovernanceError> { + let mut result = alloc::vec::Vec::new(); + for value in values.iter() { + result.push(sdk_address_to_alloc_string(&value)?); + } + Ok(result) +} + +fn authorize_and_invoke(env: &Env, vault: &Address, fn_name: Symbol, args: Vec) { let args_for_auth = args.clone(); env.authorize_as_current_contract(Vec::from_array( @@ -1323,21 +1314,7 @@ fn authorize_and_execute(env: &Env, vault: &Address, command: &VaultCommand) { })], )); - let _: Bytes = env.invoke_contract(vault, &fn_name, args); -} - -fn address_text(address: &Address) -> String { - String::from_utf8(address.to_string().to_bytes().to_alloc_vec()) - .unwrap_or_else(|_| String::new()) -} - -fn sdk_string_text(value: &SdkString) -> String { - String::from_utf8(value.to_bytes().to_alloc_vec()).unwrap_or_else(|_| String::new()) -} - -fn command_payload(env: &Env, command: &VaultCommand) -> Bytes { - let bytes = command.encode(); - Bytes::from_slice(env, &bytes) + env.invoke_contract::<()>(vault, &fn_name, args); } fn get_address(env: &Env, key: DataKey) -> Result { @@ -1382,34 +1359,12 @@ fn require_vault_caller(env: &Env, caller: &Address) -> Result<(), GovernanceErr Ok(()) } -fn method_name_for_action(env: &Env, action: &GovernanceAction) -> Symbol { - match action { - GovernanceAction::SetGuardian(_) => Symbol::new(env, "submit_guardian"), - GovernanceAction::SetSentinel(_) => Symbol::new(env, "submit_sentinel"), - GovernanceAction::SetFees(_) => Symbol::new(env, "set_governance_policy"), - GovernanceAction::SetRestrictions(_, _) => Symbol::new(env, "set_governance_policy"), - GovernanceAction::SetCap(_, _) => Symbol::new(env, "submit_cap"), - GovernanceAction::RemoveMarket(_) => Symbol::new(env, "submit_market_removal"), - GovernanceAction::SetGroupCap(_, _) - | GovernanceAction::SetGroupRelCap(_, _) - | GovernanceAction::SetGroupMember(_, _) => Symbol::new(env, "submit_cap_group_update"), - GovernanceAction::SetSkimRecipient(_) => Symbol::new(env, "set_skim_recipient"), - GovernanceAction::Skim(_) => Symbol::new(env, "skim"), - GovernanceAction::SetPaused(_) => Symbol::new(env, "set_governance_policy"), - GovernanceAction::SetCurator(_) => Symbol::new(env, "set_curator"), - GovernanceAction::SetGovernance(_) => Symbol::new(env, "set_governance"), - GovernanceAction::SetSupplyQueue(_) => Symbol::new(env, "set_governance_policy"), - GovernanceAction::SetTimelock(_, _) => Symbol::new(env, "submit_timelock"), - GovernanceAction::Other(_, _) => Symbol::new(env, "other"), - } -} - fn require_not_abdicated(env: &Env, action: &GovernanceAction) -> Result<(), GovernanceError> { - let method_name = method_name_for_action(env, action); + let kind = action_kind(action); let abdicated: bool = env .storage() .instance() - .get(&DataKey::Abdicated(method_name)) + .get(&DataKey::Abdicated(kind)) .unwrap_or(false); if abdicated { return Err(GovernanceError::Abdicated); diff --git a/contract/vault/soroban/governance/src/tests.rs b/contract/vault/soroban/governance/src/tests.rs index cfec532a3..8801c367e 100644 --- a/contract/vault/soroban/governance/src/tests.rs +++ b/contract/vault/soroban/governance/src/tests.rs @@ -4,13 +4,19 @@ )] use super::*; + use soroban_sdk::{ contracttype, testutils::{Address as _, Ledger, LedgerInfo}, + Bytes, }; use templar_soroban_shared_types::{ - VaultCommand, VaultCommandResult, GOVERNANCE_CONFIG_KIND_GUARDIANS, - GOVERNANCE_CONFIG_KIND_SENTINEL, GOVERNANCE_POLICY_KIND_FEES, GOVERNANCE_POLICY_KIND_PAUSED, + GovernanceCommand, GOVERNANCE_CONFIG_KIND_CURATOR, GOVERNANCE_CONFIG_KIND_GOVERNANCE, + GOVERNANCE_CONFIG_KIND_GUARDIANS, GOVERNANCE_CONFIG_KIND_SENTINEL, + GOVERNANCE_CONFIG_KIND_SKIM_RECIPIENT, GOVERNANCE_POLICY_KIND_CAP, GOVERNANCE_POLICY_KIND_FEES, + GOVERNANCE_POLICY_KIND_GROUP, GOVERNANCE_POLICY_KIND_PAUSED, + GOVERNANCE_POLICY_KIND_REMOVE_MARKET, GOVERNANCE_POLICY_KIND_RESTRICTIONS, + GOVERNANCE_POLICY_KIND_SUPPLY_QUEUE, }; #[contract] @@ -22,37 +28,206 @@ enum MockVaultKey { Paused, Guardian, Sentinel, + Curator, + Governance, + SkimRecipient, + SupplyQueue, + LastFeeAccounts, + RestrictionMode, + RestrictionAccounts, + LastCapMarketId, + LastCapValue, + LastRemoveMarketId, + LastGroupCapGroupId, + LastGroupCapValue, + LastGroupRelCapGroupId, + LastGroupRelCapValue, + LastGroupMemberMarketId, + LastGroupMemberGroupId, + LastSkimToken, } #[contractimpl] +#[allow( + dead_code, + reason = "mock entrypoints mirror older governance ABI helpers" +)] impl MockVault { - pub fn execute(env: Env, payload: Bytes) -> Bytes { - let command = match VaultCommand::decode(&payload.to_alloc_vec()) { + fn set_curator(env: Env, caller: Address, new_curator: Address) { + Self::set_governance_config( + env, + caller, + GOVERNANCE_CONFIG_KIND_CURATOR, + Some(new_curator), + None, + None, + None, + ); + } + + fn set_governance(env: Env, caller: Address, governance: Address) { + Self::set_governance_config( + env, + caller, + GOVERNANCE_CONFIG_KIND_GOVERNANCE, + Some(governance), + None, + None, + None, + ); + } + + #[allow( + clippy::too_many_arguments, + reason = "Mirrors runtime governance policy ABI" + )] + fn set_governance_policy( + env: Env, + caller: Address, + kind: u32, + target_ids: Option>, + mode: Option, + accounts: Option>, + market_id: Option, + cap_group_id: Option, + value: Option, + value_b: Option, + value_c: Option, + ) { + Self::set_governance_policy_impl( + env, + caller, + kind, + target_ids, + mode, + accounts, + market_id, + cap_group_id, + value, + value_b, + value_c, + ); + } + + fn submit_guardian(env: Env, caller: Address, guardian: Address) { + let guardians = Vec::from_array(&env, [guardian]); + Self::set_governance_config( + env, + caller, + GOVERNANCE_CONFIG_KIND_GUARDIANS, + None, + Some(guardians), + None, + None, + ); + } + + fn submit_sentinel(env: Env, caller: Address, sentinel: Address) { + Self::set_governance_config( + env, + caller, + GOVERNANCE_CONFIG_KIND_SENTINEL, + Some(sentinel), + None, + None, + None, + ); + } + + fn submit_cap(env: Env, caller: Address, market_id: u32, value: i128) { + Self::set_governance_policy_impl( + env, + caller, + GOVERNANCE_POLICY_KIND_CAP, + None, + None, + None, + Some(market_id), + None, + Some(value), + None, + None, + ); + } + + fn submit_market_removal(env: Env, caller: Address, market_id: u32) { + Self::set_governance_policy_impl( + env, + caller, + GOVERNANCE_POLICY_KIND_REMOVE_MARKET, + None, + None, + None, + Some(market_id), + None, + None, + None, + None, + ); + } + + fn submit_cap_group_update( + env: Env, + caller: Address, + mode: u32, + market_id: Option, + cap_group_id: Option, + value: Option, + ) { + Self::set_governance_policy_impl( + env, + caller, + GOVERNANCE_POLICY_KIND_GROUP, + None, + Some(mode), + None, + market_id, + cap_group_id, + value, + None, + None, + ); + } + + fn set_skim_recipient(env: Env, caller: Address, recipient: Address) { + Self::set_governance_config( + env, + caller, + GOVERNANCE_CONFIG_KIND_SKIM_RECIPIENT, + Some(recipient), + None, + None, + None, + ); + } + + pub fn execute_governance(env: Env, caller: Address, payload: Bytes) { + let command = match GovernanceCommand::decode(&payload.to_alloc_vec()) { Ok(command) => command, - Err(_) => panic!("decode command failed"), + Err(_) => panic!("decode governance command failed"), }; match command { - VaultCommand::SetGovernanceConfig { - caller, + GovernanceCommand::SetGovernanceConfig { kind, primary, many, value_a, value_b, } => { + let primary = primary.map(|value| sdk_address(&env, &value)); + let many = many.map(|values| sdk_address_vec(&env, &values)); Self::set_governance_config( env.clone(), - sdk_address(&env, &caller), + caller, kind, - primary.as_ref().map(|value| sdk_address(&env, value)), - many.as_ref().map(|items| sdk_address_vec(&env, items)), + primary, + many, value_a, value_b, ); } - VaultCommand::SetGovernancePolicy { - caller, + GovernanceCommand::SetGovernancePolicy { kind, target_ids, mode, @@ -63,35 +238,27 @@ impl MockVault { value_b, value_c, } => { + let target_ids = target_ids.map(|values| sdk_u32_vec(&env, &values)); + let accounts = accounts.map(|values| sdk_address_vec(&env, &values)); + let cap_group_id = cap_group_id.map(|value| SdkString::from_str(&env, &value)); Self::set_governance_policy( env.clone(), - sdk_address(&env, &caller), + caller, kind, - target_ids.as_ref().map(|items| sdk_u32_vec(&env, items)), + target_ids, mode, - accounts.as_ref().map(|items| sdk_address_vec(&env, items)), + accounts, market_id, - cap_group_id - .as_ref() - .map(|value| SdkString::from_str(&env, value)), + cap_group_id, value, value_b, value_c, ); } - VaultCommand::Skim { caller, token } => Self::skim( - env.clone(), - sdk_address(&env, &caller), - sdk_address(&env, &token), - ), - other => panic!("unexpected command: {:?}", other.encode()), + GovernanceCommand::Skim { token } => { + Self::skim(env.clone(), caller, sdk_address(&env, &token)) + } } - - Bytes::from_slice(&env, &VaultCommandResult::Unit.encode()) - } - - pub fn set_paused(env: Env, _caller: Address, paused: bool) { - env.storage().instance().set(&MockVaultKey::Paused, &paused); } pub fn is_paused(env: Env) -> bool { @@ -109,7 +276,98 @@ impl MockVault { env.storage().instance().get(&MockVaultKey::Sentinel) } - pub fn set_governance_config( + pub fn curator(env: Env) -> Option
{ + env.storage().instance().get(&MockVaultKey::Curator) + } + + pub fn governance(env: Env) -> Option
{ + env.storage().instance().get(&MockVaultKey::Governance) + } + + pub fn skim_recipient(env: Env) -> Option
{ + env.storage().instance().get(&MockVaultKey::SkimRecipient) + } + + pub fn supply_queue(env: Env) -> Vec { + env.storage() + .instance() + .get(&MockVaultKey::SupplyQueue) + .unwrap_or_else(|| Vec::new(&env)) + } + + pub fn last_fee_accounts(env: Env) -> Option> { + env.storage().instance().get(&MockVaultKey::LastFeeAccounts) + } + + pub fn restriction_mode(env: Env) -> u32 { + env.storage() + .instance() + .get(&MockVaultKey::RestrictionMode) + .unwrap_or(0) + } + + pub fn restriction_accounts(env: Env) -> Vec
{ + env.storage() + .instance() + .get(&MockVaultKey::RestrictionAccounts) + .unwrap_or_else(|| Vec::new(&env)) + } + + pub fn last_cap_market_id(env: Env) -> Option { + env.storage().instance().get(&MockVaultKey::LastCapMarketId) + } + + pub fn last_cap_value(env: Env) -> Option { + env.storage().instance().get(&MockVaultKey::LastCapValue) + } + + pub fn last_remove_market_id(env: Env) -> Option { + env.storage() + .instance() + .get(&MockVaultKey::LastRemoveMarketId) + } + + pub fn last_group_cap_group_id(env: Env) -> Option { + env.storage() + .instance() + .get(&MockVaultKey::LastGroupCapGroupId) + } + + pub fn last_group_cap_value(env: Env) -> Option { + env.storage() + .instance() + .get(&MockVaultKey::LastGroupCapValue) + } + + pub fn last_group_rel_cap_group_id(env: Env) -> Option { + env.storage() + .instance() + .get(&MockVaultKey::LastGroupRelCapGroupId) + } + + pub fn last_group_rel_cap_value(env: Env) -> Option { + env.storage() + .instance() + .get(&MockVaultKey::LastGroupRelCapValue) + } + + pub fn last_group_member_market_id(env: Env) -> Option { + env.storage() + .instance() + .get(&MockVaultKey::LastGroupMemberMarketId) + } + + pub fn last_group_member_group_id(env: Env) -> Option { + env.storage() + .instance() + .get(&MockVaultKey::LastGroupMemberGroupId) + } + + pub fn last_skim_token(env: Env) -> Option
{ + env.storage().instance().get(&MockVaultKey::LastSkimToken) + } + + fn set_governance_config( env: Env, _caller: Address, kind: u32, @@ -140,20 +398,41 @@ impl MockVault { .instance() .set(&MockVaultKey::Guardian, &guardian); } + GOVERNANCE_CONFIG_KIND_CURATOR => { + if let Some(curator) = primary { + env.storage() + .instance() + .set(&MockVaultKey::Curator, &curator); + } + } + GOVERNANCE_CONFIG_KIND_GOVERNANCE => { + if let Some(governance) = primary { + env.storage() + .instance() + .set(&MockVaultKey::Governance, &governance); + } + } + GOVERNANCE_CONFIG_KIND_SKIM_RECIPIENT => { + if let Some(recipient) = primary { + env.storage() + .instance() + .set(&MockVaultKey::SkimRecipient, &recipient); + } + } _ => {} } } - pub fn set_governance_policy( + fn set_governance_policy_impl( env: Env, _caller: Address, kind: u32, - _target_ids: Option>, + target_ids: Option>, mode: Option, accounts: Option>, - _market_id: Option, - _cap_group_id: Option, - _value: Option, + market_id: Option, + cap_group_id: Option, + value: Option, _value_b: Option, _value_c: Option, ) { @@ -162,18 +441,102 @@ impl MockVault { env.storage().instance().set(&MockVaultKey::Paused, &paused); } if kind == GOVERNANCE_POLICY_KIND_FEES { - let _ = accounts; + env.storage() + .instance() + .set(&MockVaultKey::LastFeeAccounts, &accounts); + } + if kind == GOVERNANCE_POLICY_KIND_SUPPLY_QUEUE { + if let Some(ids) = target_ids { + env.storage() + .instance() + .set(&MockVaultKey::SupplyQueue, &ids); + } + } + if kind == GOVERNANCE_POLICY_KIND_RESTRICTIONS { + if let Some(m) = mode { + env.storage() + .instance() + .set(&MockVaultKey::RestrictionMode, &m); + } + if let Some(accs) = accounts { + env.storage() + .instance() + .set(&MockVaultKey::RestrictionAccounts, &accs); + } + } + if kind == GOVERNANCE_POLICY_KIND_CAP { + if let Some(mid) = market_id { + env.storage() + .instance() + .set(&MockVaultKey::LastCapMarketId, &mid); + } + if let Some(v) = value { + env.storage() + .instance() + .set(&MockVaultKey::LastCapValue, &v); + } + } + if kind == GOVERNANCE_POLICY_KIND_REMOVE_MARKET { + if let Some(mid) = market_id { + env.storage() + .instance() + .set(&MockVaultKey::LastRemoveMarketId, &mid); + } + } + if kind == GOVERNANCE_POLICY_KIND_GROUP { + let mode_val = mode.unwrap_or(0); + if mode_val == 0 { + // SetGroupCap + if let Some(group_id) = cap_group_id.clone() { + env.storage() + .instance() + .set(&MockVaultKey::LastGroupCapGroupId, &group_id); + } + if let Some(v) = value { + env.storage() + .instance() + .set(&MockVaultKey::LastGroupCapValue, &v); + } + } else if mode_val == 1 { + // SetGroupRelCap + if let Some(group_id) = cap_group_id.clone() { + env.storage() + .instance() + .set(&MockVaultKey::LastGroupRelCapGroupId, &group_id); + } + if let Some(v) = value { + env.storage() + .instance() + .set(&MockVaultKey::LastGroupRelCapValue, &v); + } + } else if mode_val == 2 { + // SetGroupMember + if let Some(mid) = market_id { + env.storage() + .instance() + .set(&MockVaultKey::LastGroupMemberMarketId, &mid); + } + if let Some(group_id) = cap_group_id.clone() { + env.storage() + .instance() + .set(&MockVaultKey::LastGroupMemberGroupId, &group_id); + } + } } } - pub fn skim(_env: Env, _caller: Address, _token: Address) {} + fn skim(env: Env, _caller: Address, token: Address) { + env.storage() + .instance() + .set(&MockVaultKey::LastSkimToken, &token); + } } -fn sdk_address(env: &Env, value: &str) -> Address { - Address::from_string(&SdkString::from_str(env, value)) +fn sdk_address(env: &Env, value: &AllocString) -> Address { + Address::from_str(env, value) } -fn sdk_address_vec(env: &Env, values: &[String]) -> Vec
{ +fn sdk_address_vec(env: &Env, values: &[AllocString]) -> Vec
{ let mut addresses = Vec::new(env); for value in values { addresses.push_back(sdk_address(env, value)); @@ -491,10 +854,13 @@ fn abdicated_action_is_rejected() { (&admin, &vault, &(5_000_000_000u64)), ); - let abdicated = Symbol::new(&env, "submit_cap"); env.as_contract(&governance, || { - SorobanVaultGovernanceContract::abdicate(env.clone(), admin.clone(), abdicated.clone()) - .unwrap(); + SorobanVaultGovernanceContract::abdicate( + env.clone(), + admin.clone(), + GovernanceActionKind::Cap, + ) + .unwrap(); }); let submit_result = env.as_contract(&governance, || { @@ -562,3 +928,892 @@ fn empty_group_member_string_is_treated_as_membership_removal() { Ok(TimelockDecision::Timelocked) ); } + +#[test] +fn governance_change_is_timelocked_and_routes_to_vault() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let new_governance = Address::generate(&env); + + let proposal_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_governance( + env.clone(), + admin.clone(), + new_governance.clone(), + ) + .unwrap() + }); + + let on_vault_before = env.as_contract(&vault, || MockVault::governance(env.clone())); + assert_eq!(on_vault_before, None); + + let early = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), proposal_id) + }); + assert_eq!(early, Err(GovernanceError::ProposalNotMature)); + + env.ledger().set(LedgerInfo { + timestamp: 106, + protocol_version: 25, + ..Default::default() + }); + + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), proposal_id).unwrap() + }); + + let on_vault_after = env.as_contract(&vault, || MockVault::governance(env.clone())); + assert_eq!(on_vault_after, Some(new_governance)); +} + +#[test] +fn supply_queue_submission_routes_to_vault() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let target_ids = sdk_u32_vec(&env, &[1u32, 2u32, 3u32]); + + let proposal_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_supply_queue( + env.clone(), + admin.clone(), + target_ids.clone(), + ) + .unwrap() + }); + + let early = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), proposal_id) + }); + assert_eq!(early, Err(GovernanceError::ProposalNotMature)); + + env.ledger().set(LedgerInfo { + timestamp: 106, + protocol_version: 25, + ..Default::default() + }); + + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), proposal_id).unwrap() + }); + + let on_vault = env.as_contract(&vault, || MockVault::supply_queue(env.clone())); + assert_eq!(on_vault, target_ids); +} + +#[test] +fn fee_decrease_applies_immediately_when_recipients_unchanged() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let performance_recipient = Address::generate(&env); + let management_recipient = Address::generate(&env); + + // First set initial fees with recipients + let _ = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_fees( + env.clone(), + admin.clone(), + 100_000_000_000_000_000i128, // 10% performance fee + performance_recipient.clone(), + 50_000_000_000_000_000i128, // 5% management fee + management_recipient.clone(), + None, + ) + .unwrap() + }); + + env.ledger().set(LedgerInfo { + timestamp: 106, + protocol_version: 25, + ..Default::default() + }); + + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept_kind( + env.clone(), + admin.clone(), + GovernanceActionKind::Fees, + ) + .unwrap() + }); + + // Now decrease performance fee with same recipients - should be immediate + env.ledger().set(LedgerInfo { + timestamp: 200, + protocol_version: 25, + ..Default::default() + }); + + let _decrease_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_fees( + env.clone(), + admin.clone(), + 50_000_000_000_000_000i128, // 5% performance fee (decreased) + performance_recipient.clone(), + 50_000_000_000_000_000i128, // same management fee + management_recipient.clone(), + None, + ) + .unwrap() + }); + + // Fee decrease should apply immediately without pending + let pending = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending.len(), 0); + + // Verify the fee accounts were routed to vault + let fee_accounts = env.as_contract(&vault, || MockVault::last_fee_accounts(env.clone())); + assert!(fee_accounts.is_some()); + let accounts = fee_accounts.unwrap(); + assert_eq!(accounts.len(), 2); +} + +#[test] +fn fee_increase_uses_fee_specific_pending_accept_and_revoke() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let performance_recipient = Address::generate(&env); + let management_recipient = Address::generate(&env); + + // Set initial fees + let _ = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_fees( + env.clone(), + admin.clone(), + 50_000_000_000_000_000i128, // 5% performance fee + performance_recipient.clone(), + 50_000_000_000_000_000i128, // 5% management fee + management_recipient.clone(), + None, + ) + .unwrap() + }); + + env.ledger().set(LedgerInfo { + timestamp: 106, + protocol_version: 25, + ..Default::default() + }); + + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept_kind( + env.clone(), + admin.clone(), + GovernanceActionKind::Fees, + ) + .unwrap() + }); + + // Now increase performance fee - should be timelocked + env.ledger().set(LedgerInfo { + timestamp: 200, + protocol_version: 25, + ..Default::default() + }); + + let increase_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_fees( + env.clone(), + admin.clone(), + 100_000_000_000_000_000i128, // 10% performance fee (increased) + performance_recipient.clone(), + 50_000_000_000_000_000i128, // same management fee + management_recipient.clone(), + None, + ) + .unwrap() + }); + + // Fee increase should create pending proposal + let pending = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending.len(), 1); + + // Accept using accept_kind for Fees + env.ledger().set(LedgerInfo { + timestamp: 206, + protocol_version: 25, + ..Default::default() + }); + + let accepted_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept_kind( + env.clone(), + admin.clone(), + GovernanceActionKind::Fees, + ) + .unwrap() + }); + assert_eq!(accepted_id, increase_id); + + // Verify pending is cleared + let pending_after = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending_after.len(), 0); +} + +#[test] +fn restrictions_tightening_is_immediate_relaxation_is_timelocked() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let account1 = Address::generate(&env); + let account2 = Address::generate(&env); + + // Start with no restrictions (mode 0 = None) + // Current governance logic applies this change immediately. + let mut accounts = Vec::new(&env); + accounts.push_back(account1.clone()); + accounts.push_back(account2.clone()); + let _proposal_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_restrictions( + env.clone(), + admin.clone(), + 1, // Blacklist mode + accounts.clone(), + ) + .unwrap() + }); + + // Should apply immediately + let pending = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending.len(), 0); + + let mode_on_vault = env.as_contract(&vault, || MockVault::restriction_mode(env.clone())); + assert_eq!(mode_on_vault, 1); + let accounts_on_vault = + env.as_contract(&vault, || MockVault::restriction_accounts(env.clone())); + assert_eq!(accounts_on_vault.len(), 2); + + // Now relax to None - current governance logic timelocks this change + env.ledger().set(LedgerInfo { + timestamp: 200, + protocol_version: 25, + ..Default::default() + }); + + let relax_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_restrictions( + env.clone(), + admin.clone(), + 0, // None mode (relaxation) + Vec::new(&env), + ) + .unwrap() + }); + + // Should be pending + let pending_after = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending_after.len(), 1); + + env.ledger().set(LedgerInfo { + timestamp: 206, + protocol_version: 25, + ..Default::default() + }); + + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), relax_id).unwrap() + }); + + let mode_after = env.as_contract(&vault, || MockVault::restriction_mode(env.clone())); + assert_eq!(mode_after, 0); +} + +#[test] +fn guardian_first_set_is_immediate_second_is_timelocked() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let first_guardian = Address::generate(&env); + let second_guardian = Address::generate(&env); + + // First guardian set should be immediate + let _id1 = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_guardian( + env.clone(), + admin.clone(), + first_guardian.clone(), + ) + .unwrap() + }); + + let on_vault = env.as_contract(&vault, || MockVault::guardian(env.clone())); + assert_eq!(on_vault, Some(first_guardian)); + + let pending = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending.len(), 0); + + // Second guardian change should be timelocked + let id2 = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_guardian( + env.clone(), + admin.clone(), + second_guardian.clone(), + ) + .unwrap() + }); + + let pending2 = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending2.len(), 1); + + let early = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), id2) + }); + assert_eq!(early, Err(GovernanceError::ProposalNotMature)); + + env.ledger().set(LedgerInfo { + timestamp: 106, + protocol_version: 25, + ..Default::default() + }); + + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), id2).unwrap() + }); + + let on_vault_after = env.as_contract(&vault, || MockVault::guardian(env.clone())); + assert_eq!(on_vault_after, Some(second_guardian)); +} + +#[test] +fn skim_recipient_change_is_timelocked() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let recipient = Address::generate(&env); + + let proposal_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_skim_recipient( + env.clone(), + admin.clone(), + recipient.clone(), + ) + .unwrap() + }); + + // Should be timelocked + let pending = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending.len(), 1); + + let early = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), proposal_id) + }); + assert_eq!(early, Err(GovernanceError::ProposalNotMature)); + + env.ledger().set(LedgerInfo { + timestamp: 106, + protocol_version: 25, + ..Default::default() + }); + + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), proposal_id).unwrap() + }); + + let on_vault = env.as_contract(&vault, || MockVault::skim_recipient(env.clone())); + assert_eq!(on_vault, Some(recipient)); +} + +#[test] +fn skim_action_is_immediate_and_routes_token_to_vault() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let token = Address::generate(&env); + + let _skim_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_skim(env.clone(), admin.clone(), token.clone()) + .unwrap() + }); + + // Skim should apply immediately + let pending = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending.len(), 0); + + let on_vault = env.as_contract(&vault, || MockVault::last_skim_token(env.clone())); + assert_eq!(on_vault, Some(token)); +} + +#[test] +fn remove_market_is_timelocked_and_routes_to_vault() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let market_id = 7u32; + + let proposal_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_remove_market(env.clone(), admin.clone(), market_id) + .unwrap() + }); + + let early = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), proposal_id) + }); + assert_eq!(early, Err(GovernanceError::ProposalNotMature)); + + env.ledger().set(LedgerInfo { + timestamp: 106, + protocol_version: 25, + ..Default::default() + }); + + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), proposal_id).unwrap() + }); + + let on_vault = env.as_contract(&vault, || MockVault::last_remove_market_id(env.clone())); + assert_eq!(on_vault, Some(market_id)); +} + +#[test] +fn group_cap_is_immediate_and_routes_to_vault() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let group_id = SdkString::from_str(&env, "group-a"); + let new_cap = 1_000_000i128; + + let _proposal_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_group_cap( + env.clone(), + admin.clone(), + group_id.clone(), + new_cap, + ) + .unwrap() + }); + + let pending = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending.len(), 0); + + let on_vault_id = env.as_contract(&vault, || MockVault::last_group_cap_group_id(env.clone())); + assert_eq!(on_vault_id, Some(group_id)); + let on_vault_value = env.as_contract(&vault, || MockVault::last_group_cap_value(env.clone())); + assert_eq!(on_vault_value, Some(new_cap)); +} + +#[test] +fn group_rel_cap_is_immediate_and_routes_to_vault() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let group_id = SdkString::from_str(&env, "group-b"); + let rel_cap_wad = 500_000_000_000_000_000i128; // 0.5 wad + + let _proposal_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_group_rel_cap( + env.clone(), + admin.clone(), + group_id.clone(), + rel_cap_wad, + ) + .unwrap() + }); + + let pending = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending.len(), 0); + + let on_vault_id = env.as_contract(&vault, || { + MockVault::last_group_rel_cap_group_id(env.clone()) + }); + assert_eq!(on_vault_id, Some(group_id)); + let on_vault_value = + env.as_contract(&vault, || MockVault::last_group_rel_cap_value(env.clone())); + assert_eq!(on_vault_value, Some(rel_cap_wad)); +} + +#[test] +fn group_member_assignment_is_timelocked_and_routes_to_vault() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let market_id = 5u32; + let group_id = SdkString::from_str(&env, "group-c"); + + let proposal_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_group_member( + env.clone(), + admin.clone(), + market_id, + group_id.clone(), + ) + .unwrap() + }); + + let early = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), proposal_id) + }); + assert_eq!(early, Err(GovernanceError::ProposalNotMature)); + + env.ledger().set(LedgerInfo { + timestamp: 106, + protocol_version: 25, + ..Default::default() + }); + + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), proposal_id).unwrap() + }); + + let on_vault_market = env.as_contract(&vault, || { + MockVault::last_group_member_market_id(env.clone()) + }); + assert_eq!(on_vault_market, Some(market_id)); + let on_vault_group = env.as_contract(&vault, || { + MockVault::last_group_member_group_id(env.clone()) + }); + assert_eq!(on_vault_group, Some(group_id)); +} + +#[test] +fn group_member_removal_without_existing_membership_is_no_change() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let market_id = 5u32; + let empty_group = SdkString::from_str(&env, ""); + + let proposal = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_group_member( + env.clone(), + admin.clone(), + market_id, + empty_group.clone(), + ) + }); + + assert_eq!(proposal, Err(GovernanceError::NoChange)); + + let pending = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending.len(), 0); +} + +#[test] +fn cap_routes_market_id_and_value_to_vault() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let market_id = 3u32; + let cap_value = 10i128; + + let proposal_id = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_cap( + env.clone(), + admin.clone(), + market_id, + cap_value, + ) + .unwrap() + }); + + env.ledger().set(LedgerInfo { + timestamp: 106, + protocol_version: 25, + ..Default::default() + }); + + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::accept(env.clone(), admin.clone(), proposal_id).unwrap() + }); + + let on_vault_market = env.as_contract(&vault, || MockVault::last_cap_market_id(env.clone())); + assert_eq!(on_vault_market, Some(market_id)); + let on_vault_value = env.as_contract(&vault, || MockVault::last_cap_value(env.clone())); + assert_eq!(on_vault_value, Some(cap_value)); +} + +#[test] +fn no_change_returns_error_for_duplicate_submission() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let sentinel = Address::generate(&env); + + // Set sentinel first + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_sentinel( + env.clone(), + admin.clone(), + sentinel.clone(), + ) + .unwrap() + }); + + // Submitting the same sentinel again should return NoChange + let duplicate = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_sentinel( + env.clone(), + admin.clone(), + sentinel.clone(), + ) + }); + assert_eq!(duplicate, Err(GovernanceError::NoChange)); +} + +#[test] +fn sentinel_revoke_kind_clears_pending() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + let admin = Address::generate(&env); + let vault = env.register(MockVault, ()); + let governance = env.register( + SorobanVaultGovernanceContract, + (&admin, &vault, &(5_000_000_000u64)), + ); + + let first = Address::generate(&env); + let second = Address::generate(&env); + + // Set first sentinel (immediate) + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_sentinel( + env.clone(), + admin.clone(), + first.clone(), + ) + .unwrap() + }); + + // Second sentinel change (timelocked) + env.as_contract(&governance, || { + SorobanVaultGovernanceContract::submit_set_sentinel( + env.clone(), + admin.clone(), + second.clone(), + ) + .unwrap() + }); + + let pending_before = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending_before.len(), 1); + + // Revoke by kind should clear the pending sentinel change + let removed = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::revoke_kind( + env.clone(), + admin.clone(), + GovernanceActionKind::Sentinel, + ) + .unwrap() + }); + assert_eq!(removed, 1); + + let pending_after = env.as_contract(&governance, || { + SorobanVaultGovernanceContract::pending_ids(env.clone()) + }); + assert_eq!(pending_after.len(), 0); +} diff --git a/contract/vault/soroban/governance/src/types.rs b/contract/vault/soroban/governance/src/types.rs index 87e110582..c4dc3946a 100644 --- a/contract/vault/soroban/governance/src/types.rs +++ b/contract/vault/soroban/governance/src/types.rs @@ -18,7 +18,7 @@ pub(crate) enum DataKey { CurrentFees, CurrentRestrictionMode, CurrentRestrictionAccounts, - Abdicated(Symbol), + Abdicated(GovernanceActionKind), SkimRecipient, } diff --git a/contract/vault/soroban/justfile b/contract/vault/soroban/justfile index c54db50e7..230653822 100644 --- a/contract/vault/soroban/justfile +++ b/contract/vault/soroban/justfile @@ -133,6 +133,14 @@ build-blend-adapter: @just -f "{{justfile_directory()}}/justfile" _optimize-wasm templar-soroban-blend-adapter "{{wasm_dir}}/templar_soroban_blend_adapter.wasm" @echo "✓ Built: {{blend_adapter_wasm}}" +# Check the Soroban ERC-4626 proxy crate. +check-4626-proxy: + @echo "Checking Soroban ERC-4626 proxy crate..." + @RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }{{remap_rustflags}}" cargo check \ + --manifest-path "{{root}}/Cargo.toml" \ + -p templar-4626-proxy-soroban + @echo "✓ Checked: templar-4626-proxy-soroban" + # Build all WASMs (vault + governance + share token + blend adapter). build-all: build build-blend-adapter @echo "✓ All contracts built" @@ -905,14 +913,6 @@ register-address address: --address "{{address}}"; \ echo "✓ Address registered" -set-curator new_curator: - @admin="${SOROBAN_ADMIN:-$(cat {{state_dir}}/admin_address 2>/dev/null)}"; \ - echo "Setting curator to {{new_curator}}..."; \ - just invoke set_curator --caller "$admin" --new_curator "{{new_curator}}"; \ - mkdir -p "{{state_dir}}"; \ - echo "{{new_curator}}" > "{{state_dir}}/admin_address"; \ - echo "✓ Curator updated" - # Set supply queue via governance (submit + accept). # Requires governance contract deployed with timelock=0 for instant acceptance. set-supply-queue target_ids_json: diff --git a/contract/vault/soroban/shared-types/src/lib.rs b/contract/vault/soroban/shared-types/src/lib.rs index dc1621c1c..df835a1b9 100644 --- a/contract/vault/soroban/shared-types/src/lib.rs +++ b/contract/vault/soroban/shared-types/src/lib.rs @@ -4,7 +4,7 @@ extern crate alloc; use alloc::{string::String, vec::Vec}; -#[derive(Clone, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum CodecError { Truncated, InvalidUtf8, @@ -194,7 +194,15 @@ fn read_option_string_vec( } } -#[derive(Clone, Eq, PartialEq)] +fn ensure_finished(bytes: &[u8], cursor: usize) -> Result<(), CodecError> { + if cursor == bytes.len() { + Ok(()) + } else { + Err(CodecError::InvalidEncoding) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub enum VaultCommand { DepositWithMin { owner: String, @@ -221,8 +229,16 @@ pub enum VaultCommand { caller: String, markets: Vec, }, - SetGovernanceConfig { + ResyncIdleBalance, + CancelMigration { caller: String, + }, + ExtendTtl, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GovernanceCommand { + SetGovernanceConfig { kind: u32, primary: Option, many: Option>, @@ -230,7 +246,6 @@ pub enum VaultCommand { value_b: Option, }, SetGovernancePolicy { - caller: String, kind: u32, target_ids: Option>, mode: Option, @@ -242,17 +257,11 @@ pub enum VaultCommand { value_c: Option, }, Skim { - caller: String, token: String, }, - ResyncIdleBalance, - CancelMigration { - caller: String, - }, - ExtendTtl, } -#[derive(Clone, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum VaultCommandResult { Unit, I128(i128), @@ -326,51 +335,6 @@ impl VaultCommand { push_string(&mut out, caller); push_u32_vec(&mut out, markets); } - Self::SetGovernanceConfig { - caller, - kind, - primary, - many, - value_a, - value_b, - } => { - push_u8(&mut out, 5); - push_string(&mut out, caller); - push_u32(&mut out, *kind); - push_option_string(&mut out, primary); - push_option_string_vec(&mut out, many); - push_option_i128(&mut out, value_a); - push_option_i128(&mut out, value_b); - } - Self::SetGovernancePolicy { - caller, - kind, - target_ids, - mode, - accounts, - market_id, - cap_group_id, - value, - value_b, - value_c, - } => { - push_u8(&mut out, 6); - push_string(&mut out, caller); - push_u32(&mut out, *kind); - push_option_u32_vec(&mut out, target_ids); - push_option_u32(&mut out, mode); - push_option_string_vec(&mut out, accounts); - push_option_u32(&mut out, market_id); - push_option_string(&mut out, cap_group_id); - push_option_i128(&mut out, value); - push_option_i128(&mut out, value_b); - push_option_i128(&mut out, value_c); - } - Self::Skim { caller, token } => { - push_u8(&mut out, 7); - push_string(&mut out, caller); - push_string(&mut out, token); - } Self::ResyncIdleBalance => push_u8(&mut out, 8), Self::CancelMigration { caller } => { push_u8(&mut out, 9); @@ -413,16 +377,78 @@ impl VaultCommand { caller: read_string(bytes, &mut cursor)?, markets: read_u32_vec(bytes, &mut cursor)?, }), - 5 => Ok(Self::SetGovernanceConfig { + 8 => Ok(Self::ResyncIdleBalance), + 9 => Ok(Self::CancelMigration { caller: read_string(bytes, &mut cursor)?, + }), + 10 => Ok(Self::ExtendTtl), + _ => Err(CodecError::InvalidTag), + }?; + ensure_finished(bytes, cursor)?; + Ok(command) + } +} + +impl GovernanceCommand { + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + match self { + Self::SetGovernanceConfig { + kind, + primary, + many, + value_a, + value_b, + } => { + push_u8(&mut out, 0); + push_u32(&mut out, *kind); + push_option_string(&mut out, primary); + push_option_string_vec(&mut out, many); + push_option_i128(&mut out, value_a); + push_option_i128(&mut out, value_b); + } + Self::SetGovernancePolicy { + kind, + target_ids, + mode, + accounts, + market_id, + cap_group_id, + value, + value_b, + value_c, + } => { + push_u8(&mut out, 1); + push_u32(&mut out, *kind); + push_option_u32_vec(&mut out, target_ids); + push_option_u32(&mut out, mode); + push_option_string_vec(&mut out, accounts); + push_option_u32(&mut out, market_id); + push_option_string(&mut out, cap_group_id); + push_option_i128(&mut out, value); + push_option_i128(&mut out, value_b); + push_option_i128(&mut out, value_c); + } + Self::Skim { token } => { + push_u8(&mut out, 2); + push_string(&mut out, token); + } + } + out + } + + pub fn decode(bytes: &[u8]) -> Result { + let mut cursor = 0usize; + let command = match read_u8(bytes, &mut cursor)? { + 0 => Ok(Self::SetGovernanceConfig { kind: read_u32(bytes, &mut cursor)?, primary: read_option_string(bytes, &mut cursor)?, many: read_option_string_vec(bytes, &mut cursor)?, value_a: read_option_i128(bytes, &mut cursor)?, value_b: read_option_i128(bytes, &mut cursor)?, }), - 6 => Ok(Self::SetGovernancePolicy { - caller: read_string(bytes, &mut cursor)?, + 1 => Ok(Self::SetGovernancePolicy { kind: read_u32(bytes, &mut cursor)?, target_ids: read_option_u32_vec(bytes, &mut cursor)?, mode: read_option_u32(bytes, &mut cursor)?, @@ -433,22 +459,12 @@ impl VaultCommand { value_b: read_option_i128(bytes, &mut cursor)?, value_c: read_option_i128(bytes, &mut cursor)?, }), - 7 => Ok(Self::Skim { - caller: read_string(bytes, &mut cursor)?, + 2 => Ok(Self::Skim { token: read_string(bytes, &mut cursor)?, }), - 8 => Ok(Self::ResyncIdleBalance), - 9 => Ok(Self::CancelMigration { - caller: read_string(bytes, &mut cursor)?, - }), - 10 => Ok(Self::ExtendTtl), _ => Err(CodecError::InvalidTag), }?; - - if cursor != bytes.len() { - return Err(CodecError::InvalidEncoding); - } - + ensure_finished(bytes, cursor)?; Ok(command) } } @@ -479,11 +495,105 @@ impl VaultCommandResult { 2 => Ok(Self::U64(read_u64(bytes, &mut cursor)?)), _ => Err(CodecError::InvalidTag), }?; + ensure_finished(bytes, cursor)?; + Ok(result) + } +} - if cursor != bytes.len() { - return Err(CodecError::InvalidEncoding); +#[cfg(test)] +mod tests { + use super::*; + use alloc::{string::String, vec}; + + #[test] + fn vault_command_roundtrip_representative() { + let commands = vec![ + VaultCommand::DepositWithMin { + owner: String::from("owner"), + receiver: String::from("receiver"), + assets: 100, + min_shares_out: 1, + }, + VaultCommand::ResyncIdleBalance, + VaultCommand::CancelMigration { + caller: String::from("caller"), + }, + ]; + + for command in commands { + let encoded = command.encode(); + let decoded = VaultCommand::decode(&encoded).expect("decode vault command"); + assert_eq!(decoded, command); } + } - Ok(result) + #[test] + fn governance_command_roundtrip_representative() { + let commands = vec![ + GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_CURATOR, + primary: Some(String::from("curator")), + many: None, + value_a: None, + value_b: None, + }, + GovernanceCommand::SetGovernancePolicy { + kind: GOVERNANCE_POLICY_KIND_FEES, + target_ids: None, + mode: None, + accounts: Some(vec![String::from("perf"), String::from("mgmt")]), + market_id: None, + cap_group_id: None, + value: Some(11), + value_b: Some(22), + value_c: Some(33), + }, + GovernanceCommand::Skim { + token: String::from("token"), + }, + ]; + + for command in commands { + let encoded = command.encode(); + let decoded = GovernanceCommand::decode(&encoded).expect("decode governance command"); + assert_eq!(decoded, command); + } + } + + #[test] + fn governance_command_decode_rejects_trailing_bytes() { + let mut encoded = GovernanceCommand::Skim { + token: String::from("token"), + } + .encode(); + encoded.push(0xFF); + + assert_eq!( + GovernanceCommand::decode(&encoded), + Err(CodecError::InvalidEncoding) + ); + } + + #[test] + fn governance_command_decode_rejects_invalid_option_tag() { + let bytes = vec![0, 0, 0, 0, 0, 9]; + assert_eq!( + GovernanceCommand::decode(&bytes), + Err(CodecError::InvalidTag) + ); + } + + #[test] + fn vault_and_governance_tags_do_not_overlap() { + let governance = GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_CURATOR, + primary: Some(String::from("curator")), + many: None, + value_a: None, + value_b: None, + } + .encode(); + + assert!(VaultCommand::decode(&governance).is_err()); } } diff --git a/contract/vault/soroban/src/contract/entrypoints.rs b/contract/vault/soroban/src/contract/entrypoints.rs index 7c23c785b..86a2f4390 100644 --- a/contract/vault/soroban/src/contract/entrypoints.rs +++ b/contract/vault/soroban/src/contract/entrypoints.rs @@ -14,7 +14,7 @@ use super::helpers::{ }; use super::*; use templar_soroban_shared_types::{ - VaultCommand, VaultCommandResult, GOVERNANCE_CONFIG_KIND_ALLOCATORS, + GovernanceCommand, VaultCommand, VaultCommandResult, GOVERNANCE_CONFIG_KIND_ALLOCATORS, GOVERNANCE_CONFIG_KIND_ALLOWED_ADAPTERS, GOVERNANCE_CONFIG_KIND_CURATOR, GOVERNANCE_CONFIG_KIND_GOVERNANCE, GOVERNANCE_CONFIG_KIND_GUARDIANS, GOVERNANCE_CONFIG_KIND_SENTINEL, GOVERNANCE_CONFIG_KIND_SKIM_RECIPIENT, @@ -660,7 +660,10 @@ fn cancel_migration_impl(env: &Env, caller: soroban_sdk::Address) -> Result<(), Ok(()) } -fn execute_command(env: &Env, command: VaultCommand) -> Result { +fn execute_public_command( + env: &Env, + command: VaultCommand, +) -> Result { match command { VaultCommand::DepositWithMin { owner, @@ -713,8 +716,28 @@ fn execute_command(env: &Env, command: VaultCommand) -> Result { + resync_idle_balance_impl(env)?; + Ok(VaultCommandResult::Unit) + } + VaultCommand::CancelMigration { caller } => { + cancel_migration_impl(env, address_from_alloc_string(env, &caller)?)?; + Ok(VaultCommandResult::Unit) + } + VaultCommand::ExtendTtl => { + extend_storage_ttl(env); + Ok(VaultCommandResult::Unit) + } + } +} + +fn execute_governance_command( + env: &Env, + caller: soroban_sdk::Address, + command: GovernanceCommand, +) -> Result<(), ContractError> { + match command { + GovernanceCommand::SetGovernanceConfig { kind, primary, many, @@ -729,19 +752,9 @@ fn execute_command(env: &Env, command: VaultCommand) -> Result Result Result { - skim_impl( - env, - address_from_alloc_string(env, &caller)?, - address_from_alloc_string(env, &token)?, - )?; - Ok(VaultCommandResult::Unit) - } - VaultCommand::ResyncIdleBalance => { - resync_idle_balance_impl(env)?; - Ok(VaultCommandResult::Unit) - } - VaultCommand::CancelMigration { caller } => { - cancel_migration_impl(env, address_from_alloc_string(env, &caller)?)?; - Ok(VaultCommandResult::Unit) - } - VaultCommand::ExtendTtl => { - extend_storage_ttl(env); - Ok(VaultCommandResult::Unit) + GovernanceCommand::Skim { token } => { + skim_impl(env, caller, address_from_alloc_string(env, &token)?) } } } @@ -843,10 +838,20 @@ impl SorobanVaultContract { pub fn execute(env: Env, payload: Bytes) -> Result { let command = decode_command(&payload)?; - let result = execute_command(&env, command)?; + let result = execute_public_command(&env, command)?; encode_command_result(&env, &result) } + pub fn execute_governance( + env: Env, + caller: soroban_sdk::Address, + payload: Bytes, + ) -> Result<(), ContractError> { + let command = GovernanceCommand::decode(&payload.to_alloc_vec()) + .map_err(|_| ContractError::InvalidInput)?; + execute_governance_command(&env, caller, command) + } + #[allow( clippy::type_complexity, reason = "proxy view is a compact ABI surface consumed by tests and tooling" diff --git a/contract/vault/soroban/src/tests.rs b/contract/vault/soroban/src/tests.rs index 3a57ddcfb..e9672d30d 100644 --- a/contract/vault/soroban/src/tests.rs +++ b/contract/vault/soroban/src/tests.rs @@ -292,12 +292,13 @@ mod contract_tests { use crate::storage::{SorobanStorage, Storage}; use crate::test_utils::{begin_allocating, finish_allocating, MemoryStorage}; use alloc::collections::BTreeMap; + use alloc::string::String as AllocString; use alloc::vec; use alloc::vec::Vec; use soroban_sdk::{Address as SdkAddress, Bytes, Env}; use templar_curator_primitives::PolicyState; use templar_soroban_shared_types::{ - VaultCommand, VaultCommandResult, GOVERNANCE_CONFIG_KIND_VIRTUAL_OFFSETS, + GovernanceCommand, VaultCommand, VaultCommandResult, GOVERNANCE_CONFIG_KIND_VIRTUAL_OFFSETS, }; use templar_vault_kernel::effects::KernelEffect; use templar_vault_kernel::{ @@ -364,8 +365,9 @@ mod contract_tests { } } - fn sdk_text(address: &SdkAddress) -> alloc::string::String { - alloc::string::String::from_utf8(address.to_string().to_bytes().to_alloc_vec()).unwrap() + fn sdk_text(address: &SdkAddress) -> AllocString { + AllocString::from_utf8(address.to_string().to_bytes().to_alloc_vec()) + .expect("valid address") } fn execute_command( @@ -378,6 +380,15 @@ mod contract_tests { .map_err(|_| crate::error::ContractError::InvalidInput) } + fn execute_governance_command( + env: &Env, + caller: &SdkAddress, + command: &GovernanceCommand, + ) -> Result<(), crate::error::ContractError> { + let payload = Bytes::from_slice(env, &command.encode()); + SorobanVaultContract::execute_governance(env.clone(), caller.clone(), payload) + } + #[derive(Clone, Debug, Default)] struct TrackingInterpreter { addresses: BTreeMap, @@ -970,10 +981,10 @@ mod contract_tests { ) .unwrap(); - let result = execute_command( + execute_governance_command( &env, - &VaultCommand::SetGovernanceConfig { - caller: sdk_text(&governance), + &governance, + &GovernanceCommand::SetGovernanceConfig { kind: GOVERNANCE_CONFIG_KIND_VIRTUAL_OFFSETS, primary: None, many: None, @@ -982,7 +993,6 @@ mod contract_tests { }, ) .unwrap(); - assert!(matches!(result, VaultCommandResult::Unit)); assert_eq!( env.storage().instance().get(&VaultDataKey::VirtualShares), @@ -1903,7 +1913,7 @@ mod storage_tests { use crate::error::RuntimeError; use crate::storage::{SorobanStorage, SorobanStorageKey, Storage}; use crate::test_utils::{fuzz_api, MemoryStorage}; - use alloc::string::{String, ToString}; + use alloc::string::{String as AllocString, ToString}; use rstest::{fixture, rstest}; use soroban_sdk::testutils::Address as _; use soroban_sdk::{Address as SdkAddress, Bytes, Env, Symbol, Vec as SdkVec}; @@ -1911,26 +1921,35 @@ mod storage_tests { use templar_curator_primitives::policy::state::MarketConfig; use templar_curator_primitives::PolicyState; use templar_soroban_shared_types::{ - VaultCommand, VaultCommandResult, GOVERNANCE_CONFIG_KIND_ALLOWED_ADAPTERS, - GOVERNANCE_POLICY_KIND_FEES, GOVERNANCE_POLICY_KIND_GROUP, + GovernanceCommand, GOVERNANCE_CONFIG_KIND_ALLOWED_ADAPTERS, GOVERNANCE_CONFIG_KIND_CURATOR, + GOVERNANCE_CONFIG_KIND_GOVERNANCE, GOVERNANCE_CONFIG_KIND_GUARDIANS, + GOVERNANCE_CONFIG_KIND_SENTINEL, GOVERNANCE_POLICY_KIND_GROUP, + GOVERNANCE_POLICY_KIND_PAUSED, }; use templar_vault_kernel::{ Address as KernelAddress, AllocationPlanEntry, FeeAccrualAnchor, OpState, PendingWithdrawal, Restrictions, TimestampNs, VaultState, WithdrawQueue, WithdrawingState, }; - fn sdk_text(address: &SdkAddress) -> std::string::String { - std::string::String::from_utf8(address.to_string().to_bytes().to_alloc_vec()).unwrap() + fn sdk_text(address: &SdkAddress) -> AllocString { + AllocString::from_utf8(address.to_string().to_bytes().to_alloc_vec()) + .expect("valid address") } - fn execute_command( + fn execute_governance_command( env: &Env, - command: &VaultCommand, - ) -> Result { + contract_id: &SdkAddress, + caller: &SdkAddress, + command: &GovernanceCommand, + ) { + use soroban_sdk::{IntoVal, Symbol}; + let payload = Bytes::from_slice(env, &command.encode()); - let result = SorobanVaultContract::execute(env.clone(), payload)?; - VaultCommandResult::decode(&result.to_alloc_vec()) - .map_err(|_| crate::error::ContractError::InvalidInput) + env.invoke_contract::<()>( + contract_id, + &Symbol::new(env, "execute_governance"), + (caller, &payload).into_val(env), + ); } #[test] @@ -2494,19 +2513,19 @@ mod storage_tests { .iter() .map(|address| sdk_text(&address)) .collect(); - let result = execute_command( + let payload = soroban_sdk::Bytes::from_slice( &env, - &VaultCommand::SetGovernanceConfig { - caller: sdk_text(&governance), + &GovernanceCommand::SetGovernanceConfig { kind: GOVERNANCE_CONFIG_KIND_ALLOWED_ADAPTERS, primary: None, many: Some(updated), value_a: None, value_b: None, - }, - ) - .unwrap(); - assert!(matches!(result, VaultCommandResult::Unit)); + } + .encode(), + ); + SorobanVaultContract::execute_governance(env.clone(), governance.clone(), payload) + .unwrap(); assert_eq!( env.storage() @@ -2517,44 +2536,8 @@ mod storage_tests { }); } - #[rstest] - fn test_governance_policy_rejects_fee_account_count(contract_env: (Env, soroban_sdk::Address)) { - let (env, contract_id) = contract_env; - env.mock_all_auths_allowing_non_root_auth(); - let governance = SdkAddress::generate(&env); - - env.as_contract(&contract_id, || { - set_config_address( - &env, - &crate::contract::VaultDataKey::Governance, - &governance, - ); - - let err = match execute_command( - &env, - &VaultCommand::SetGovernancePolicy { - caller: sdk_text(&governance), - kind: GOVERNANCE_POLICY_KIND_FEES, - target_ids: None, - mode: None, - accounts: Some(alloc::vec![sdk_text(&SdkAddress::generate(&env))]), - market_id: None, - cap_group_id: None, - value: Some(1), - value_b: Some(2), - value_c: None, - }, - ) { - Ok(_) => panic!("expected invalid input error"), - Err(err) => err, - }; - - assert_eq!(err, crate::error::ContractError::InvalidInput); - }); - } - #[test] - fn test_governance_policy_group_cap_clear_via_none_reaches_policy_layer() { + fn test_governance_policy_group_membership_empty_string_clears_membership() { let env = Env::default(); env.mock_all_auths_allowing_non_root_auth(); let contract_id = env.register(SorobanVaultContract, ()); @@ -2584,45 +2567,45 @@ mod storage_tests { storage.save_state(&VaultState::default()).unwrap(); storage.save_paused(false).unwrap(); let mut policy_state = PolicyState::default(); - let cap_group_id = CapGroupId::try_from("group-a".to_string()).unwrap(); - policy_state.set_cap_group_absolute_cap(cap_group_id.clone(), Some(77)); + let cap_group_id = CapGroupId::try_from("group-c".to_string()).unwrap(); + policy_state.set_cap_group_absolute_cap(cap_group_id.clone(), Some(100)); + policy_state + .set_market_config(7, MarketConfig::new(true, 100, Some(cap_group_id.clone()))) + .unwrap(); Storage::save_policy_state(&mut storage, &policy_state).unwrap(); - let err = match execute_command( + let payload = soroban_sdk::Bytes::from_slice( &env, - &VaultCommand::SetGovernancePolicy { - caller: sdk_text(&governance), + &GovernanceCommand::SetGovernancePolicy { kind: GOVERNANCE_POLICY_KIND_GROUP, target_ids: None, - mode: Some(0), + mode: Some(2), accounts: None, - market_id: Some(0), - cap_group_id: Some("group-a".to_string()), - value: None, + market_id: Some(7), + cap_group_id: Some("".to_string()), + value: Some(0), value_b: None, value_c: None, - }, - ) { - Ok(_) => panic!("clearing an active cap should remain timelocked"), - Err(err) => err, - }; - - assert_eq!(err, crate::error::ContractError::InvalidInput); + } + .encode(), + ); + SorobanVaultContract::execute_governance(env.clone(), governance.clone(), payload) + .unwrap(); let reloaded = Storage::load_policy_state(&storage) .unwrap() .unwrap_or_default(); assert_eq!( reloaded - .cap_group(&cap_group_id) - .and_then(|record| record.cap.absolute_cap()), - Some(77) + .market_config(7) + .and_then(|config| config.cap_group_id.clone()), + None ); }); } #[test] - fn test_governance_policy_group_relative_cap_clear_via_none_reaches_policy_layer() { + fn test_execute_governance_bridge_happy_path() { let env = Env::default(); env.mock_all_auths_allowing_non_root_auth(); let contract_id = env.register(SorobanVaultContract, ()); @@ -2630,6 +2613,10 @@ mod storage_tests { let governance = SdkAddress::generate(&env); let asset = SdkAddress::generate(&env); let share = SdkAddress::generate(&env); + let new_curator = SdkAddress::generate(&env); + let new_governance = SdkAddress::generate(&env); + let sentinel = SdkAddress::generate(&env); + let guardian = SdkAddress::generate(&env); env.as_contract(&contract_id, || { SorobanVaultContract::initialize( @@ -2642,58 +2629,98 @@ mod storage_tests { 0, ) .unwrap(); - set_config_address( - &env, - &crate::contract::VaultDataKey::Governance, - &governance, - ); + }); - let mut storage = SorobanStorage::new(&env); - storage.save_state(&VaultState::default()).unwrap(); - storage.save_paused(false).unwrap(); - let mut policy_state = PolicyState::default(); - let cap_group_id = CapGroupId::try_from("group-b".to_string()).unwrap(); - policy_state.set_cap_group_relative_cap( - cap_group_id.clone(), - Some(templar_vault_kernel::Wad::from(25u128)), + execute_governance_command( + &env, + &contract_id, + &governance, + &GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_CURATOR, + primary: Some(sdk_text(&new_curator)), + many: None, + value_a: None, + value_b: None, + }, + ); + env.as_contract(&contract_id, || { + assert_eq!( + env.storage() + .instance() + .get(&crate::contract::VaultDataKey::Curator), + Some(new_curator.clone()) ); - Storage::save_policy_state(&mut storage, &policy_state).unwrap(); - - let err = match execute_command( - &env, - &VaultCommand::SetGovernancePolicy { - caller: sdk_text(&governance), - kind: GOVERNANCE_POLICY_KIND_GROUP, - target_ids: None, - mode: Some(1), - accounts: None, - market_id: Some(0), - cap_group_id: Some("group-b".to_string()), - value: None, - value_b: None, - value_c: None, - }, - ) { - Ok(_) => panic!("clearing an active relative cap should remain timelocked"), - Err(err) => err, - }; + }); - assert_eq!(err, crate::error::ContractError::InvalidInput); + execute_governance_command( + &env, + &contract_id, + &governance, + &GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_GOVERNANCE, + primary: Some(sdk_text(&new_governance)), + many: None, + value_a: None, + value_b: None, + }, + ); + env.as_contract(&contract_id, || { + assert_eq!( + env.storage() + .instance() + .get(&crate::contract::VaultDataKey::Governance), + Some(new_governance.clone()) + ); + }); - let reloaded = Storage::load_policy_state(&storage) - .unwrap() - .unwrap_or_default(); + execute_governance_command( + &env, + &contract_id, + &new_governance, + &GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_SENTINEL, + primary: Some(sdk_text(&sentinel)), + many: None, + value_a: None, + value_b: None, + }, + ); + env.as_contract(&contract_id, || { assert_eq!( - reloaded - .cap_group(&cap_group_id) - .and_then(|record| record.cap.relative_cap()), - Some(templar_vault_kernel::Wad::from(25u128)) + env.storage() + .instance() + .get(&crate::contract::VaultDataKey::Sentinel), + Some(sentinel.clone()) ); }); + + execute_governance_command( + &env, + &contract_id, + &new_governance, + &GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_GUARDIANS, + primary: None, + many: Some(alloc::vec![sdk_text(&guardian)]), + value_a: None, + value_b: None, + }, + ); + env.as_contract(&contract_id, || { + let stored_guardians: Option> = env + .storage() + .instance() + .get(&crate::contract::VaultDataKey::Guardians); + let stored_guardians = stored_guardians.expect("guardians should be set"); + assert_eq!(stored_guardians.len(), 1); + assert_eq!(stored_guardians.get_unchecked(0), guardian); + }); } #[test] - fn test_governance_policy_group_membership_empty_string_clears_membership() { + fn test_execute_governance_rejects_unauthorized_callers() { + use soroban_sdk::{IntoVal, Symbol}; + let env = Env::default(); env.mock_all_auths_allowing_non_root_auth(); let contract_id = env.register(SorobanVaultContract, ()); @@ -2701,11 +2728,12 @@ mod storage_tests { let governance = SdkAddress::generate(&env); let asset = SdkAddress::generate(&env); let share = SdkAddress::generate(&env); + let attacker = SdkAddress::generate(&env); env.as_contract(&contract_id, || { SorobanVaultContract::initialize( env.clone(), - curator.clone(), + curator, governance.clone(), asset, share, @@ -2713,51 +2741,102 @@ mod storage_tests { 0, ) .unwrap(); - set_config_address( - &env, - &crate::contract::VaultDataKey::Governance, - &governance, - ); - - let mut storage = SorobanStorage::new(&env); - storage.save_state(&VaultState::default()).unwrap(); - storage.save_paused(false).unwrap(); - let mut policy_state = PolicyState::default(); - let cap_group_id = CapGroupId::try_from("group-c".to_string()).unwrap(); - policy_state.set_cap_group_absolute_cap(cap_group_id.clone(), Some(100)); - policy_state - .set_market_config(7, MarketConfig::new(true, 100, Some(cap_group_id.clone()))) - .unwrap(); - Storage::save_policy_state(&mut storage, &policy_state).unwrap(); + }); - let result = execute_command( - &env, - &VaultCommand::SetGovernancePolicy { - caller: sdk_text(&governance), - kind: GOVERNANCE_POLICY_KIND_GROUP, - target_ids: None, - mode: Some(2), - accounts: None, - market_id: Some(7), - cap_group_id: Some(String::new()), - value: Some(0), - value_b: None, - value_c: None, - }, + let err = env.try_invoke_contract::<(), crate::error::ContractError>( + &contract_id, + &Symbol::new(&env, "execute_governance"), + ( + &attacker, + &Bytes::from_slice( + &env, + &GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_CURATOR, + primary: Some(sdk_text(&SdkAddress::generate(&env))), + many: None, + value_a: None, + value_b: None, + } + .encode(), + ), ) - .unwrap(); + .into_val(&env), + ); + assert_eq!(err, Err(Ok(crate::error::ContractError::Unauthorized))); + + let err = env.try_invoke_contract::<(), crate::error::ContractError>( + &contract_id, + &Symbol::new(&env, "execute_governance"), + ( + &attacker, + &Bytes::from_slice( + &env, + &GovernanceCommand::SetGovernancePolicy { + kind: GOVERNANCE_POLICY_KIND_PAUSED, + target_ids: None, + mode: Some(1), + accounts: None, + market_id: None, + cap_group_id: None, + value: None, + value_b: None, + value_c: None, + } + .encode(), + ), + ) + .into_val(&env), + ); + assert_eq!(err, Err(Ok(crate::error::ContractError::Unauthorized))); + + let err = env.try_invoke_contract::<(), crate::error::ContractError>( + &contract_id, + &Symbol::new(&env, "execute_governance"), + ( + &attacker, + &Bytes::from_slice( + &env, + &GovernanceCommand::Skim { + token: sdk_text(&SdkAddress::generate(&env)), + } + .encode(), + ), + ) + .into_val(&env), + ); + assert_eq!(err, Err(Ok(crate::error::ContractError::Unauthorized))); + } - assert!(matches!(result, VaultCommandResult::Unit)); + #[test] + fn test_execute_governance_rejects_malformed_payload() { + use soroban_sdk::{IntoVal, Symbol}; - let reloaded = Storage::load_policy_state(&storage) - .unwrap() - .unwrap_or_default(); - assert_eq!( - reloaded - .market_config(7) - .and_then(|config| config.cap_group_id.clone()), - None - ); + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + let contract_id = env.register(SorobanVaultContract, ()); + let curator = SdkAddress::generate(&env); + let governance = SdkAddress::generate(&env); + let asset = SdkAddress::generate(&env); + let share = SdkAddress::generate(&env); + + env.as_contract(&contract_id, || { + SorobanVaultContract::initialize( + env.clone(), + curator, + governance.clone(), + asset, + share, + 0, + 0, + ) + .unwrap(); }); + + let err = env.try_invoke_contract::<(), crate::error::ContractError>( + &contract_id, + &Symbol::new(&env, "execute_governance"), + (&governance, &Bytes::from_slice(&env, &[0xff])).into_val(&env), + ); + assert_eq!(err, Err(Ok(crate::error::ContractError::InvalidInput))); } } diff --git a/contract/vault/soroban/tests/blend_e2e.rs b/contract/vault/soroban/tests/blend_e2e.rs index b1d06e183..d3723d704 100644 --- a/contract/vault/soroban/tests/blend_e2e.rs +++ b/contract/vault/soroban/tests/blend_e2e.rs @@ -7,7 +7,7 @@ use soroban_sdk::{ token::StellarAssetClient, Address, Bytes, BytesN, Env, String, }; -use std::string::String as StdString; +use std::string::String as AllocString; use templar_curator_primitives::MarketConfig; use templar_soroban_blend_adapter::BlendAdapterContract; use templar_soroban_runtime::{ @@ -15,12 +15,12 @@ use templar_soroban_runtime::{ storage::{SorobanStorage, Storage}, }; use templar_soroban_shared_types::{ - VaultCommand, VaultCommandResult, GOVERNANCE_CONFIG_KIND_ALLOCATORS, + GovernanceCommand, VaultCommand, VaultCommandResult, GOVERNANCE_CONFIG_KIND_ALLOCATORS, GOVERNANCE_CONFIG_KIND_ALLOWED_ADAPTERS, GOVERNANCE_POLICY_KIND_SUPPLY_QUEUE, }; -fn address_text(address: &Address) -> StdString { - StdString::from_utf8(address.to_string().to_bytes().to_alloc_vec()).unwrap() +fn address_wire(address: &Address) -> AllocString { + AllocString::from_utf8(address.to_string().to_bytes().to_alloc_vec()).expect("valid address") } fn execute_command( @@ -33,6 +33,15 @@ fn execute_command( .map_err(|_| templar_soroban_runtime::ContractError::InvalidInput) } +fn execute_governance_command( + env: &Env, + caller: &Address, + command: &GovernanceCommand, +) -> Result<(), templar_soroban_runtime::ContractError> { + let payload = Bytes::from_slice(env, &command.encode()); + SorobanVaultContract::execute_governance(env.clone(), caller.clone(), payload) +} + struct VaultProxy<'a> { env: &'a Env, } @@ -146,19 +155,18 @@ fn vault_allocates_supply_to_blend_and_withdraws_back() { proxy.initialize(&governance, &asset, &share); }); env.as_contract(&vault, || { - let result = execute_command( + execute_governance_command( &env, - &VaultCommand::SetGovernanceConfig { - caller: address_text(&governance), + &governance, + &GovernanceCommand::SetGovernanceConfig { kind: GOVERNANCE_CONFIG_KIND_ALLOCATORS, primary: None, - many: Some(vec![address_text(&allocator)]), + many: Some(vec![address_wire(&allocator)]), value_a: None, value_b: None, }, ) .unwrap(); - assert!(matches!(result, VaultCommandResult::Unit)); }); env.as_contract(&vault, || { let mut storage = SorobanStorage::new(&env); @@ -169,10 +177,10 @@ fn vault_allocates_supply_to_blend_and_withdraws_back() { storage.save_policy_state(&policy_state).unwrap(); }); env.as_contract(&vault, || { - let result = execute_command( + execute_governance_command( &env, - &VaultCommand::SetGovernancePolicy { - caller: address_text(&governance), + &governance, + &GovernanceCommand::SetGovernancePolicy { kind: GOVERNANCE_POLICY_KIND_SUPPLY_QUEUE, target_ids: Some(vec![0u32]), mode: None, @@ -185,22 +193,20 @@ fn vault_allocates_supply_to_blend_and_withdraws_back() { }, ) .unwrap(); - assert!(matches!(result, VaultCommandResult::Unit)); }); env.as_contract(&vault, || { - let result = execute_command( + execute_governance_command( &env, - &VaultCommand::SetGovernanceConfig { - caller: address_text(&governance), + &governance, + &GovernanceCommand::SetGovernanceConfig { kind: GOVERNANCE_CONFIG_KIND_ALLOWED_ADAPTERS, primary: None, - many: Some(vec![address_text(&adapter)]), + many: Some(vec![address_wire(&adapter)]), value_a: None, value_b: None, }, ) .unwrap(); - assert!(matches!(result, VaultCommandResult::Unit)); }); let deposit_amount = 10_000_000_000; @@ -214,8 +220,8 @@ fn vault_allocates_supply_to_blend_and_withdraws_back() { execute_command( &env, &VaultCommand::DepositWithMin { - owner: address_text(&user), - receiver: address_text(&user), + owner: address_wire(&user), + receiver: address_wire(&user), assets: deposit_amount, min_shares_out: 0, }, @@ -236,7 +242,7 @@ fn vault_allocates_supply_to_blend_and_withdraws_back() { execute_command( &env, &VaultCommand::Allocate { - caller: address_text(&allocator), + caller: address_wire(&allocator), market: 0, amount: supply_amount, supply: true, @@ -267,7 +273,7 @@ fn vault_allocates_supply_to_blend_and_withdraws_back() { execute_command( &env, &VaultCommand::RefreshMarkets { - caller: address_text(&allocator), + caller: address_wire(&allocator), markets: vec![0u32], }, ) @@ -291,7 +297,7 @@ fn vault_allocates_supply_to_blend_and_withdraws_back() { execute_command( &env, &VaultCommand::Allocate { - caller: address_text(&allocator), + caller: address_wire(&allocator), market: 0, amount: withdraw_amount, supply: false, diff --git a/contract/vault/soroban/tests/integration_tests.rs b/contract/vault/soroban/tests/integration_tests.rs index fb07025f6..fd2a510b3 100644 --- a/contract/vault/soroban/tests/integration_tests.rs +++ b/contract/vault/soroban/tests/integration_tests.rs @@ -8,7 +8,7 @@ use soroban_sdk::{ token::StellarAssetClient, Bytes, Env, }; -use std::string::String; +use std::string::String as AllocString; use templar_curator_primitives::policy::state::MarketConfig; use templar_soroban_runtime::{ contract::{ContractConfig, CuratorVault, SorobanVaultContract}, @@ -20,7 +20,7 @@ use templar_soroban_runtime::{ Storage, // Import the trait }; use templar_soroban_shared_types::{ - VaultCommand, VaultCommandResult, GOVERNANCE_CONFIG_KIND_VIRTUAL_OFFSETS, + GovernanceCommand, VaultCommand, VaultCommandResult, GOVERNANCE_CONFIG_KIND_VIRTUAL_OFFSETS, }; use templar_vault_kernel::state::queue::DEFAULT_COOLDOWN_NS; use templar_vault_kernel::{ @@ -41,8 +41,8 @@ use templar_vault_kernel::{ mod common; use common::{MockInterpreter, TestPermissiveAuth}; -fn sdk_text(address: &soroban_sdk::Address) -> String { - String::from_utf8(address.to_string().to_bytes().to_alloc_vec()).unwrap() +fn sdk_wire(address: &soroban_sdk::Address) -> AllocString { + AllocString::from_utf8(address.to_string().to_bytes().to_alloc_vec()).expect("valid address") } type ProxyCoreView = ( @@ -191,6 +191,16 @@ impl<'a> VaultProxy<'a> { _ => Err(templar_soroban_runtime::ContractError::InvalidInput), } } + + fn execute_governance_unit( + &self, + caller: &soroban_sdk::Address, + command: &GovernanceCommand, + ) -> Result<(), templar_soroban_runtime::ContractError> { + let payload = Bytes::from_slice(self.env, &command.encode()); + SorobanVaultContract::execute_governance(self.env.clone(), caller.clone(), payload)?; + Ok(()) + } } #[fixture] @@ -372,17 +382,18 @@ fn soroban_contract_preview_deposit_uses_configured_virtual_offsets( env.as_contract(&contract_id, || { let governance = proxy.governance().unwrap(); - let result = proxy - .execute(&VaultCommand::SetGovernanceConfig { - caller: sdk_text(&governance), - kind: GOVERNANCE_CONFIG_KIND_VIRTUAL_OFFSETS, - primary: None, - many: None, - value_a: Some(virtual_shares as i128), - value_b: Some(virtual_assets as i128), - }) + proxy + .execute_governance_unit( + &governance, + &GovernanceCommand::SetGovernanceConfig { + kind: GOVERNANCE_CONFIG_KIND_VIRTUAL_OFFSETS, + primary: None, + many: None, + value_a: Some(virtual_shares as i128), + value_b: Some(virtual_assets as i128), + }, + ) .unwrap(); - assert!(matches!(result, VaultCommandResult::Unit)); let mut storage = SorobanStorage::new(&env); let state = VaultState { @@ -514,7 +525,7 @@ fn soroban_contract_execute_withdraw_queue_empty_errors( env.as_contract(&contract_id, || { let result = proxy.execute(&VaultCommand::ExecuteWithdraw { - caller: sdk_text(&user), + caller: sdk_wire(&user), }); assert!(result.is_err()); }); @@ -545,7 +556,7 @@ fn soroban_contract_execute_withdraw_non_idle_errors( env.as_contract(&contract_id, || { let result = proxy.execute(&VaultCommand::ExecuteWithdraw { - caller: sdk_text(&user), + caller: sdk_wire(&user), }); assert!(result.is_err()); });