Skip to content

fix(soroban): separate sentinel emergency authority#427

Draft
carrion256 wants to merge 9 commits into
spr/refactor/vault-ergonomics/4f330057from
audit/governance-control-plane
Draft

fix(soroban): separate sentinel emergency authority#427
carrion256 wants to merge 9 commits into
spr/refactor/vault-ergonomics/4f330057from
audit/governance-control-plane

Conversation

@carrion256

@carrion256 carrion256 commented May 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Implements the A-003 Model B authority split: governance is not implicitly Sentinel.
  • Adds explicit Sentinel-facing governance entrypoints for immediate emergency pause and restriction tightening.
  • Keeps unpause and restriction relaxation/removal in the timelocked governance path.
  • Updates runtime execute_governance authorization so production RBAC rejects governance-as-sentinel and sentinel-as-governance flows.
  • Documents the governance/Sentinel boundary in the Soroban README.

Audit Context

  • Cluster: governance-control-plane
  • Finding: A-003 — Governance contract cannot pause, unpause, or update restrictions on deployed RBAC
  • Base: PR 417 branch spr/refactor/vault-ergonomics/4f330057 at a657f92
  • Tracker: /data/projects/contracts-audit-tracker/contracts-audit-clusters/governance-control-plane.audit.md

Verification

  • cargo fmt --all -- --check
  • cargo test -p templar-soroban-governance --lib -- --nocapture — 29 passed
  • cargo test -p templar-soroban-runtime test_execute_governance_separates -- --nocapture — 2 passed
  • git diff --check
  • post-commit hook: Soroban size-budget-check passed at 95798 bytes

Halborn Finding IDs

Included for Halborn SSC GitHub remediation detection:

  • A-003 / Finding ID e1bab0f3-3014-4905-b407-041e82cac5ea — Governance contract cannot pause, unpause, or update restrictions on deployed RBAC
  • A-013 / Finding ID 2e538a21-4b3f-4f54-8cca-5baeee9bc238 — Default deployment recipe initializes all timelocks to zero
  • A-014 / Finding ID 15c2295b-5098-4025-9299-cf4c0af1bfec — Skim is immediate and default recipient is governance contract
  • A-016 / Finding ID df8f422b-ac36-494b-a619-c6f123621abd — Shipped governance contract cannot manage allocators or adapter allowlist
  • A-017 / Finding ID d9db8534-9bac-4a38-9c01-cdb806b1f1a9 — Contract-governance deployments have no routed path for upgrade, migrate, or cancel migration

Folded audit scope

This PR now also folds the governance ABI validation follow-ups:

  • A-056 / Nexus 4ca30771-41fc-4b53-b8b2-95168b12803d: authenticates governance/control-plane callers before governance payload decode, while preserving sentinel-vs-governance command routing.
  • A-066 / Nexus b7898751-488c-4530-a728-d980d83b8a53: rejects duplicate governance list targets while preserving explicit empty-list clear semantics.

Folded source PRs superseded by this PR:

Additional local verification after folding:

  • RUSTUP_TOOLCHAIN=1.89.0 cargo fmt --all --check
  • git diff --check
  • RUSTUP_TOOLCHAIN=1.89.0 cargo test -p templar-soroban-runtime --lib -- --nocapture — 109 passed
  • RUSTUP_TOOLCHAIN=1.89.0 cargo test -p templar-soroban-governance -- --nocapture — 36 passed
  • just -f contract/vault/soroban/justfile build
  • just -f contract/vault/soroban/justfile size-budget-check — 96089 bytes <= 131072

This change is Reviewable

@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 59ec3137-cbeb-4508-bb71-14580565e8f0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds sentinel-only direct setters and new timelocked governance action kinds, refactors vault policy/authorization to separate sentinel immediate actions from governance-timelocked flows, tightens execute_governance routing, and updates tests, docs, and deployment recipes accordingly.

Changes

Governance control-plane & docs

Layer / File(s) Summary
Docs and deploy guidance
contract/vault/soroban/README.md, contract/vault/soroban/STRIDE.md, contract/vault/soroban/justfile, contract/vault/soroban/Cargo.toml
Clarifies governance boundary and Sentinel separation; routes allocator/adapter updates via execute_governance; requires explicit SOROBAN_GOV_TIMELOCK_NS during deploy; adds local governance dev-dep.

Governance contract

Layer / File(s) Summary
Action kinds, entrypoints, timelock decisions
contract/vault/soroban/governance/src/types.rs, contract/vault/soroban/governance/src/lib.rs
Adds new Timelock/GovernanceAction kinds (Allocators, AllowedAdapters, Upgrade, Migration, CancelMigration); adds sentinel-only set_paused/set_restrictions; new submit entrypoints for allocators/adapters/upgrade/migrate/cancel; makes many high-impact actions timelocked; adds execute_vault_governance_action_as_caller, execute_vault_command, and bytes-authorization helpers; enforces uniqueness for supply-queue targets.

Vault contract refactor

Layer / File(s) Summary
Policy application & execution routing
contract/vault/soroban/src/contract/entrypoints.rs, contract/vault/soroban/src/contract/helpers.rs
Refactors apply_restrictions_policy and apply_paused_policy to accept caller + preauthorized flag and branch to sentinel-vault-call vs governance-direct storage write; introduces require_unique_addresses; tightens execute_governance to require governance-control-plane auth; adds ensure/require helpers for governance/sentinel/control-plane.

Tests & MockVault

Layer / File(s) Summary
Governance & vault tests, MockVault updates
contract/vault/soroban/governance/src/tests.rs, contract/vault/soroban/src/tests.rs, contract/vault/soroban/tests/integration_tests.rs
Extends MockVault with upgrade/migrate/execute and allocators/adapters getters; rewrites tests for sentinel immediate actions and timelocked governance flows (allocators, adapters, skim, upgrade/migrate/cancel); adds abdication checks and integration test asserting timelock maturation behavior.

Sequence Diagram(s)

sequenceDiagram
    participant Caller as Caller
    participant Gov as GovernanceContract
    participant Vault as VaultContract
    participant Storage as VaultStorage

    rect rgba(100, 200, 150, 0.5)
    Note over Caller,Storage: Sentinel immediate action
    Caller->>Gov: set_paused(caller, true)
    Gov->>Gov: require_sentinel()
    Gov->>Vault: execute_vault_governance_action_as_caller(paused=true)
    Vault->>Storage: persist paused = true
    end

    rect rgba(100, 150, 200, 0.5)
    Note over Caller,Storage: Timelocked governance flow
    Caller->>Gov: submit_set_paused(caller, false)
    Gov->>Gov: decide_submission() -> TimelockDecision::Timelocked
    Gov->>Storage: enqueue pending proposal
    note right of Storage: after maturity & acceptance
    Gov->>Vault: invoke unpause (governance path)
    Vault->>Storage: persist paused = false
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Templar-Protocol/contracts#401: Directly modifies vault governance pause/restrictions authorization paths and Sentinel role integration with similar changes to governance entrypoints and vault policy application logic.

Suggested reviewers

  • peer2f00l
  • vitaliybezz
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(soroban): separate sentinel emergency authority' directly reflects the main change: implementing Model B authority split to separate Sentinel from governance in emergency operations.
Description check ✅ Passed The description comprehensively covers the changeset, explaining the A-003 Model B implementation, new Sentinel-facing entrypoints, authorization changes, and audit findings addressed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch audit/governance-control-plane

Warning

Review ran into problems

🔥 Problems

Errors were encountered while retrieving linked issues.

Errors (1)
  • LINEAR integration encountered authorization issues. Please disconnect and reconnect the integration in the CodeRabbit UI.

Comment @coderabbitai help to get the list of available commands and usage tips.

@carrion256

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@carrion256

Copy link
Copy Markdown
Collaborator Author

@coderabbitai for the benefit of the human reviewers - please output a description of this PR and any critique on the usability of the design when you are reviewing.

@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me pull the changed files from this PR to give a thorough description and design critique.


🏁 Script executed:

#!/bin/bash
# Get the list of changed files and the diff
gh pr view 427 --repo Templar-Protocol/contracts --json files,title,body | jq '.files[].path'

Length of output: 222


🏁 Script executed:

#!/bin/bash
gh pr diff 427 --repo Templar-Protocol/contracts 2>/dev/null | head -2000

Length of output: 30520


@carrion256 — here is a plain-English description and design critique for human reviewers.


PR Description

This PR implements the A-003 "Model B" authority split: previously the governance contract was implicitly treated as capable of performing emergency actions (pause, restriction tightening) by routing them through the same timelocked proposal queue. This PR makes the two authority domains explicit and disjoint:

Operation Authority Path
Pause vault Sentinel governance.set_paused(sentinel, true) → immediate
Unpause vault Governance timelocked submit_set_paused(false)accept()
Tighten restrictions Sentinel governance.set_restrictions(sentinel, mode, accounts) → immediate
Relax/remove restrictions Governance timelocked submit_set_restrictions()accept()

Governance contract changes (governance/src/lib.rs):

  • Two new entrypoints set_paused() and set_restrictions() that call require_sentinel() and bypass the timelock by invoking execute_vault_governance_action_as_caller() — which calls the vault's execute_governance with the Sentinel as the named caller, not the governance contract.
  • decide_submission() for SetPaused(true) now hard-returns Err(InvalidInput) — governance can no longer submit pause proposals at all.
  • decide_submission() for SetRestrictions always returns Timelocked — the old "immediate if tightening" branch is removed.
  • Dead code (to_shared_restrictions, accounts_to_vec) cleaned up.

Runtime changes (entrypoints.rs):

  • apply_paused_policy() and apply_restrictions_policy() now receive the raw Address (not a pre-resolved kernel address). The direction of the operation (pause vs. unpause, tighten vs. relax) determines which auth check fires: require_signed(&caller) for Sentinel-class ops, require_governance() for governance-class ops.
  • set_governance_policy_impl() moved from one upfront governance_caller() call to per-policy resolution, which is necessary to support the mixed-caller model.
  • Fees policy now has an explicit require_governance() guard (previously implicit via upfront resolution — this is a correctness fix).

Tests: Two renamed governance tests, plus two new runtime integration tests (test_execute_governance_separates_sentinel_pause_from_governance_unpause and test_execute_governance_separates_sentinel_tightening_from_governance_relaxation) that directly prove cross-role rejections.


Design Critique

1. Runtime does not enforce "caller must be Sentinel" for emergency operations — only "caller must be signed"

This is the most significant usability/security design point for reviewers to evaluate.

In apply_paused_policy and apply_restrictions_policy, the Sentinel path calls:

require_signed(&caller);

This checks that caller has authorized the transaction (i.e., signed it), but it does not check that caller is the configured Sentinel address. The Sentinel membership check lives exclusively in the governance contract's require_sentinel().

Consequence: Any externally-owned account or contract that can craft a transaction can call execute_governance directly on the vault with a pause or tighten payload, pass their own address as caller, sign the transaction, and succeed — without ever touching the governance contract. The governance contract's sentinel gate is bypassed entirely.

The previous design called governance_caller(env, &caller)? at the top of set_governance_policy_impl(), which ensured the governance contract itself was always the caller for all policy mutations. The new design relaxes this unconditionally for the tightening/pause direction.

A defense-in-depth fix would be to add a require_sentinel(env, &caller)? check in the runtime's apply_paused_policy (pause arm) and apply_restrictions_policy (tighten arm), mirroring how require_governance() is used for the relaxation arm. This keeps the governance contract as a convenience layer but makes the runtime the canonical enforcer, consistent with the learning that auth must be enforced at every privileged entrypoint.

2. Governance's set_restrictions does not guard against Sentinel accidentally requesting a relaxation

governance.set_restrictions(sentinel, mode=0, accounts=[]) will pass require_sentinel() and then call execute_vault_governance_action_as_caller(), which calls the vault's execute_governance with the Sentinel as caller. The runtime will then detect this is a relaxation and call require_governance(env, &caller)?, which will fail with Unauthorized (since the caller is the Sentinel, not governance).

The error is safe but the failure mode is opaque. A sentinel operator who calls set_restrictions to "clear" restrictions will get an unauthorized error with no indication that they need to go through a governance proposal. A pre-flight check in the governance contract (comparing current stored mode/accounts against the proposed values to detect relaxation before forwarding) would improve operator UX.

3. set_paused(sentinel, false) is explicitly blocked — the parallel guard for set_restrictions relaxation is implicit

set_paused correctly hard-errors if !paused:

if !paused {
    return Err(GovernanceError::InvalidInput);
}

The equivalent for set_restrictions (relaxation attempts) produces a runtime Unauthorized from the vault call rather than an early InvalidInput from the governance contract. These two analogous operations have asymmetric error ergonomics.

4. Proposal ID sequence skip in the test is a smell worth noting

The renamed test sentinel_pause_immediate_governance_unpause_timelocked now asserts unpause_id == 3 (was 2) because submit_set_sentinel() consumed proposal ID 1 and the sentinel set_paused() call does not consume a proposal ID. This is correct but the comment in the test doesn't explain the gap — future readers may be confused about why the unpause proposal is #3.

Summary Assessment

The authority model is conceptually sound and a clear improvement over the implicit governance-as-sentinel design. The primary concern for reviewers is whether the runtime's lax require_signed check on the Sentinel path is acceptable given that the Soroban vault's execute_governance is a public entrypoint, or whether the runtime should independently enforce the sentinel role as the canonical trust boundary per the existing guidance that auth must be checked at every privileged entrypoint.


🧠 Learnings used
Learnt from: carrion256
Repo: Templar-Protocol/contracts PR: 417
File: contract/vault/soroban/STRIDE.md:0-0
Timestamp: 2026-04-28T08:36:25.374Z
Learning: In `contract/vault/soroban/src/contract/entrypoints.rs` (Templar-Protocol/contracts, Soroban vault), `execute_governance()` decodes the payload and dispatches to `execute_governance_command()` **without** an auth check at the entrypoint. Authorization is enforced inside each command handler: `set_governance_config_impl`, `set_governance_policy_impl`, and `skim_impl` each call `require_governance(env, &caller)?` as their first action. There is no `require_auth()` wrapping `execute_governance()` itself. Any future `GovernanceCommand` variant added must include its own `require_governance` call; do not assume entrypoint-level auth exists.

Learnt from: carrion256
Repo: Templar-Protocol/contracts PR: 417
File: contract/vault/soroban/governance/src/types.rs:160-174
Timestamp: 2026-04-27T09:36:09.179Z
Learning: In `contract/vault/soroban/governance/src/types.rs` (Templar-Protocol/contracts), the `RestrictionMode` enum's `from_u32`/`as_u32` mapping uses `0=None, 1=Blacklist, 2=Whitelist`. The old `Paused` variant (previously mapped to `1`) has been removed entirely; pause state is now handled separately via `Gate.paused`. This is a greenfield Soroban governance ABI with no legacy clients/relayers, so recycling code `1` for `Blacklist` is intentional and safe. Do not flag this numeric remapping as a migration risk in future reviews.

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Applies to contract/vault/soroban/governance/src/** : Governance abdication is irreversible. Any change to governance action kind mapping or timelock policy needs a high-suspicion review

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Applies to contract/vault/soroban/src/contract/entrypoints.rs : Review auth on every privileged Soroban entrypoint. Do not rely on outer routing alone

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Preserve the existing contract split: runtime, governance, share-token, and blend adapter each have distinct authority boundaries

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Applies to contract/vault/soroban/** : If behavior changes around governance bridging, withdrawal lifecycle, or share-token auth, update the docs in contract/vault/soroban/ in the same change

Learnt from: carrion256
Repo: Templar-Protocol/contracts PR: 417
File: contract/vault/soroban/src/contract/entrypoints.rs:806-836
Timestamp: 2026-04-28T08:29:30.164Z
Learning: In `contract/vault/soroban/src/contract/entrypoints.rs` (Templar-Protocol/contracts, Soroban vault), the `SorobanVaultContract::initialize()` entrypoint intentionally has no caller-authorization check. Deployment is expected to go through a controlled/factory flow where `initialize` is called atomically with contract deployment, eliminating any front-running window. Do not flag the missing auth check on `initialize` in this contract as a security issue.

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Governance proposal submission, timelock maturity, approval/revocation, and abdication must live in the governance contract, but vault-bound mutations must still be applied by the runtime via execute_governance()

Learnt from: carrion256
Repo: Templar-Protocol/contracts PR: 418
File: contract/proxy-4626-soroban/src/contract.rs:281-303
Timestamp: 2026-04-24T14:18:52.424Z
Learning: In `contract/proxy-4626-soroban/src/contract.rs` (Templar-Protocol/contracts), the `initialize` function on `Soroban4626ProxyContract` intentionally has no caller-authorization check. The deployer is expected to call `initialize` atomically with contract deployment (Soroban's `create_contract` + init in a single transaction), which eliminates any front-running window. Do not flag the missing auth check on `initialize` in this contract as a security issue.

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Applies to contract/vault/soroban/src/** : RemoveMarket, skim recipient changes, and share-token authority changes can all become authority drift or asset-loss bugs if altered casually

1 similar comment
@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me pull the changed files from this PR to give a thorough description and design critique.


🏁 Script executed:

#!/bin/bash
# Get the list of changed files and the diff
gh pr view 427 --repo Templar-Protocol/contracts --json files,title,body | jq '.files[].path'

Length of output: 222


🏁 Script executed:

#!/bin/bash
gh pr diff 427 --repo Templar-Protocol/contracts 2>/dev/null | head -2000

Length of output: 30520


@carrion256 — here is a plain-English description and design critique for human reviewers.


PR Description

This PR implements the A-003 "Model B" authority split: previously the governance contract was implicitly treated as capable of performing emergency actions (pause, restriction tightening) by routing them through the same timelocked proposal queue. This PR makes the two authority domains explicit and disjoint:

Operation Authority Path
Pause vault Sentinel governance.set_paused(sentinel, true) → immediate
Unpause vault Governance timelocked submit_set_paused(false)accept()
Tighten restrictions Sentinel governance.set_restrictions(sentinel, mode, accounts) → immediate
Relax/remove restrictions Governance timelocked submit_set_restrictions()accept()

Governance contract changes (governance/src/lib.rs):

  • Two new entrypoints set_paused() and set_restrictions() that call require_sentinel() and bypass the timelock by invoking execute_vault_governance_action_as_caller() — which calls the vault's execute_governance with the Sentinel as the named caller, not the governance contract.
  • decide_submission() for SetPaused(true) now hard-returns Err(InvalidInput) — governance can no longer submit pause proposals at all.
  • decide_submission() for SetRestrictions always returns Timelocked — the old "immediate if tightening" branch is removed.
  • Dead code (to_shared_restrictions, accounts_to_vec) cleaned up.

Runtime changes (entrypoints.rs):

  • apply_paused_policy() and apply_restrictions_policy() now receive the raw Address (not a pre-resolved kernel address). The direction of the operation (pause vs. unpause, tighten vs. relax) determines which auth check fires: require_signed(&caller) for Sentinel-class ops, require_governance() for governance-class ops.
  • set_governance_policy_impl() moved from one upfront governance_caller() call to per-policy resolution, which is necessary to support the mixed-caller model.
  • Fees policy now has an explicit require_governance() guard (previously implicit via upfront resolution — this is a correctness fix).

Tests: Two renamed governance tests, plus two new runtime integration tests (test_execute_governance_separates_sentinel_pause_from_governance_unpause and test_execute_governance_separates_sentinel_tightening_from_governance_relaxation) that directly prove cross-role rejections.


Design Critique

1. Runtime does not enforce "caller must be Sentinel" for emergency operations — only "caller must be signed"

This is the most significant usability/security design point for reviewers to evaluate.

In apply_paused_policy and apply_restrictions_policy, the Sentinel path calls:

require_signed(&caller);

This checks that caller has authorized the transaction (i.e., signed it), but it does not check that caller is the configured Sentinel address. The Sentinel membership check lives exclusively in the governance contract's require_sentinel().

Consequence: Any externally-owned account or contract that can craft a transaction can call execute_governance directly on the vault with a pause or tighten payload, pass their own address as caller, sign the transaction, and succeed — without ever touching the governance contract. The governance contract's sentinel gate is bypassed entirely.

The previous design called governance_caller(env, &caller)? at the top of set_governance_policy_impl(), which ensured the governance contract itself was always the caller for all policy mutations. The new design relaxes this unconditionally for the tightening/pause direction.

A defense-in-depth fix would be to add a require_sentinel(env, &caller)? check in the runtime's apply_paused_policy (pause arm) and apply_restrictions_policy (tighten arm), mirroring how require_governance() is used for the relaxation arm. This keeps the governance contract as a convenience layer but makes the runtime the canonical enforcer, consistent with the learning that auth must be enforced at every privileged entrypoint.

2. Governance's set_restrictions does not guard against Sentinel accidentally requesting a relaxation

governance.set_restrictions(sentinel, mode=0, accounts=[]) will pass require_sentinel() and then call execute_vault_governance_action_as_caller(), which calls the vault's execute_governance with the Sentinel as caller. The runtime will then detect this is a relaxation and call require_governance(env, &caller)?, which will fail with Unauthorized (since the caller is the Sentinel, not governance).

The error is safe but the failure mode is opaque. A sentinel operator who calls set_restrictions to "clear" restrictions will get an unauthorized error with no indication that they need to go through a governance proposal. A pre-flight check in the governance contract (comparing current stored mode/accounts against the proposed values to detect relaxation before forwarding) would improve operator UX.

3. set_paused(sentinel, false) is explicitly blocked — the parallel guard for set_restrictions relaxation is implicit

set_paused correctly hard-errors if !paused:

if !paused {
    return Err(GovernanceError::InvalidInput);
}

The equivalent for set_restrictions (relaxation attempts) produces a runtime Unauthorized from the vault call rather than an early InvalidInput from the governance contract. These two analogous operations have asymmetric error ergonomics.

4. Proposal ID sequence skip in the test is a smell worth noting

The renamed test sentinel_pause_immediate_governance_unpause_timelocked now asserts unpause_id == 3 (was 2) because submit_set_sentinel() consumed proposal ID 1 and the sentinel set_paused() call does not consume a proposal ID. This is correct but the comment in the test doesn't explain the gap — future readers may be confused about why the unpause proposal is #3.

Summary Assessment

The authority model is conceptually sound and a clear improvement over the implicit governance-as-sentinel design. The primary concern for reviewers is whether the runtime's lax require_signed check on the Sentinel path is acceptable given that the Soroban vault's execute_governance is a public entrypoint, or whether the runtime should independently enforce the sentinel role as the canonical trust boundary per the existing guidance that auth must be checked at every privileged entrypoint.


🧠 Learnings used
Learnt from: carrion256
Repo: Templar-Protocol/contracts PR: 417
File: contract/vault/soroban/STRIDE.md:0-0
Timestamp: 2026-04-28T08:36:25.374Z
Learning: In `contract/vault/soroban/src/contract/entrypoints.rs` (Templar-Protocol/contracts, Soroban vault), `execute_governance()` decodes the payload and dispatches to `execute_governance_command()` **without** an auth check at the entrypoint. Authorization is enforced inside each command handler: `set_governance_config_impl`, `set_governance_policy_impl`, and `skim_impl` each call `require_governance(env, &caller)?` as their first action. There is no `require_auth()` wrapping `execute_governance()` itself. Any future `GovernanceCommand` variant added must include its own `require_governance` call; do not assume entrypoint-level auth exists.

Learnt from: carrion256
Repo: Templar-Protocol/contracts PR: 417
File: contract/vault/soroban/governance/src/types.rs:160-174
Timestamp: 2026-04-27T09:36:09.179Z
Learning: In `contract/vault/soroban/governance/src/types.rs` (Templar-Protocol/contracts), the `RestrictionMode` enum's `from_u32`/`as_u32` mapping uses `0=None, 1=Blacklist, 2=Whitelist`. The old `Paused` variant (previously mapped to `1`) has been removed entirely; pause state is now handled separately via `Gate.paused`. This is a greenfield Soroban governance ABI with no legacy clients/relayers, so recycling code `1` for `Blacklist` is intentional and safe. Do not flag this numeric remapping as a migration risk in future reviews.

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Applies to contract/vault/soroban/governance/src/** : Governance abdication is irreversible. Any change to governance action kind mapping or timelock policy needs a high-suspicion review

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Applies to contract/vault/soroban/src/contract/entrypoints.rs : Review auth on every privileged Soroban entrypoint. Do not rely on outer routing alone

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Preserve the existing contract split: runtime, governance, share-token, and blend adapter each have distinct authority boundaries

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Applies to contract/vault/soroban/** : If behavior changes around governance bridging, withdrawal lifecycle, or share-token auth, update the docs in contract/vault/soroban/ in the same change

Learnt from: carrion256
Repo: Templar-Protocol/contracts PR: 417
File: contract/vault/soroban/src/contract/entrypoints.rs:806-836
Timestamp: 2026-04-28T08:29:30.164Z
Learning: In `contract/vault/soroban/src/contract/entrypoints.rs` (Templar-Protocol/contracts, Soroban vault), the `SorobanVaultContract::initialize()` entrypoint intentionally has no caller-authorization check. Deployment is expected to go through a controlled/factory flow where `initialize` is called atomically with contract deployment, eliminating any front-running window. Do not flag the missing auth check on `initialize` in this contract as a security issue.

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Governance proposal submission, timelock maturity, approval/revocation, and abdication must live in the governance contract, but vault-bound mutations must still be applied by the runtime via execute_governance()

Learnt from: carrion256
Repo: Templar-Protocol/contracts PR: 418
File: contract/proxy-4626-soroban/src/contract.rs:281-303
Timestamp: 2026-04-24T14:18:52.424Z
Learning: In `contract/proxy-4626-soroban/src/contract.rs` (Templar-Protocol/contracts), the `initialize` function on `Soroban4626ProxyContract` intentionally has no caller-authorization check. The deployer is expected to call `initialize` atomically with contract deployment (Soroban's `create_contract` + init in a single transaction), which eliminates any front-running window. Do not flag the missing auth check on `initialize` in this contract as a security issue.

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: contract/vault/soroban/AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:42.034Z
Learning: Applies to contract/vault/soroban/src/** : RemoveMarket, skim recipient changes, and share-token authority changes can all become authority drift or asset-loss bugs if altered casually

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contract/vault/soroban/governance/src/lib.rs`:
- Around line 217-251: The set_paused and set_restrictions entrypoints bypass
the abdication guard, so add the same abdication check used by submit to make
those actions irreversible: call the existing require_not_abdicated (or
equivalent) check early in both functions (before executing any actions or
calling execute_vault_governance_action_as_caller) using the appropriate
GovernanceActionKind for each (GovernanceActionKind::Pause in set_paused and
GovernanceActionKind::Restrictions in set_restrictions) so that after
abdicate(...) the sentinel cannot still immediately perform those actions.
- Around line 233-251: The set_restrictions entrypoint currently forwards any
proposed mode/accounts to the vault; modify set_restrictions so it first loads
the current values from storage (DataKey::CurrentRestrictionMode and
DataKey::CurrentRestrictionAccounts) and compares them to the incoming mode and
accounts using RestrictionMode and account-set logic, and return an error
(GovernanceError) if the transition is not a tightening (e.g., disallow
switching to RestrictionMode::None, disallow enlarging a blacklist or enlarging
a whitelist set) before calling execute_vault_governance_action_as_caller;
ensure you use the existing RestrictionMode::from_u32 conversion and
Equality/ordering semantics you define to determine "tightening" and reference
the same DataKey symbols and function names (set_restrictions,
execute_vault_governance_action_as_caller) when implementing the guard.

In `@contract/vault/soroban/README.md`:
- Around line 77-85: The README omits that execute_governance is unauthenticated
at its entrypoint; update the vault/soroban README section to explicitly state
that execute_governance() decodes and dispatches GovernanceCommand variants
without performing an outer auth check, and that each privileged handler inside
the runtime (the per-variant GovernanceCommand handlers) must perform its own
authentication/authorization and timelock checks; mention GovernanceCommand and
execute_governance by name so reviewers know to enforce auth inside handlers
rather than relying on the router.

In `@contract/vault/soroban/src/contract/entrypoints.rs`:
- Around line 611-613: The current branch for GOVERNANCE_POLICY_KIND_PAUSED
treats any non-zero mode as "true", turning malformed values into a valid pause
request; change the code to explicitly validate mode is present and equal to 0
or 1. Replace the inline expression using mode.ok_or(...)? != 0 with: extract
mode via let mode_val = mode.ok_or(ContractError::InvalidInput)?; if mode_val !=
0 && mode_val != 1 { return Err(ContractError::InvalidInput); } then call
apply_paused_policy(env, caller, mode_val != 0). This keeps
GOVERNANCE_POLICY_KIND_PAUSED, apply_paused_policy, mode, caller and env as the
reference points.

In `@contract/vault/soroban/src/tests.rs`:
- Around line 2955-3095: The test currently only asserts None->Blacklist and
Blacklist->None transitions; expand it into a parameterized matrix covering all
GovernanceRestrictions transitions (None, Blacklist, Whitelist) and
subset/superset changes so determine_relaxed(...) behavior is fully exercised.
Add rstest-style cases or a loop that calls execute_governance_command (and
direct contract invoke via Symbol::new("execute_governance")) for each prior/new
Restrictions variant (including empty vs non-empty account lists and
whitelist↔blacklist flips), then assert Storage::load_restrictions(&storage) and
the expected authorized caller (governance vs sentinel) match for each
transition; reference GovernanceRestrictions::determine_relaxed,
GovernanceCommand::SetGovernancePolicy, GOVERNANCE_POLICY_KIND_RESTRICTIONS,
execute_governance_command, and Restrictions/Storage::load_restrictions to
locate and implement the cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a82b41ef-2909-4215-bafb-5da75714cebf

📥 Commits

Reviewing files that changed from the base of the PR and between a657f92 and 8df7588.

📒 Files selected for processing (5)
  • contract/vault/soroban/README.md
  • contract/vault/soroban/governance/src/lib.rs
  • contract/vault/soroban/governance/src/tests.rs
  • contract/vault/soroban/src/contract/entrypoints.rs
  • contract/vault/soroban/src/tests.rs

Comment thread contract/vault/soroban/governance/src/lib.rs
Comment thread contract/vault/soroban/README.md Outdated
Comment on lines 77 to 85
- 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.
- Emergency pause and restriction tightening are immediate Sentinel actions. Unpause and
relaxing/removing restrictions are governance actions and must pass through the configured
timelock before the runtime applies them.
- `execute(payload)` remains for user flows and for the retained execute-path config subset
(`ALLOCATORS`, `ALLOWED_ADAPTERS`, `VIRTUAL_OFFSETS`). Vault-bound governance mutations use
`execute_governance`, not the generic user-flow command path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Spell out that execute_governance is authless at the entrypoint.

This section explains the bridge, but not the easy-to-miss part: the runtime only authenticates inside each GovernanceCommand handler. Making that explicit here would save future reviewers from assuming outer routing protects new variants.

Based on learnings: execute_governance() decodes and dispatches without an auth check at the entrypoint; every privileged GovernanceCommand path must enforce its own auth.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contract/vault/soroban/README.md` around lines 77 - 85, The README omits that
execute_governance is unauthenticated at its entrypoint; update the
vault/soroban README section to explicitly state that execute_governance()
decodes and dispatches GovernanceCommand variants without performing an outer
auth check, and that each privileged handler inside the runtime (the per-variant
GovernanceCommand handlers) must perform its own authentication/authorization
and timelock checks; mention GovernanceCommand and execute_governance by name so
reviewers know to enforce auth inside handlers rather than relying on the
router.

Comment thread contract/vault/soroban/src/contract/entrypoints.rs
Comment on lines +2955 to +3095
#[test]
fn test_execute_governance_separates_sentinel_tightening_from_governance_relaxation() {
use soroban_sdk::{IntoVal, Symbol};

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 sentinel = SdkAddress::generate(&env);
let restricted = 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();
});

execute_governance_command(
&env,
&contract_id,
&governance,
&GovernanceCommand::SetGovernanceConfig {
kind: GOVERNANCE_CONFIG_KIND_SENTINEL,
primary: Some(sdk_text(&sentinel)),
many: None,
value_a: None,
value_b: None,
},
);

let governance_tightening = env.try_invoke_contract::<(), crate::error::ContractError>(
&contract_id,
&Symbol::new(&env, "execute_governance"),
(
&governance,
&Bytes::from_slice(
&env,
&GovernanceCommand::SetGovernancePolicy {
kind: GOVERNANCE_POLICY_KIND_RESTRICTIONS,
target_ids: None,
mode: Some(1),
accounts: Some(alloc::vec![sdk_text(&restricted)]),
market_id: None,
cap_group_id: None,
value: None,
value_b: None,
value_c: None,
}
.encode(),
),
)
.into_val(&env),
);
assert_eq!(
governance_tightening,
Err(Ok(crate::error::ContractError::Unauthorized))
);

execute_governance_command(
&env,
&contract_id,
&sentinel,
&GovernanceCommand::SetGovernancePolicy {
kind: GOVERNANCE_POLICY_KIND_RESTRICTIONS,
target_ids: None,
mode: Some(1),
accounts: Some(alloc::vec![sdk_text(&restricted)]),
market_id: None,
cap_group_id: None,
value: None,
value_b: None,
value_c: None,
},
);
env.as_contract(&contract_id, || {
let storage = SorobanStorage::new(&env);
assert!(matches!(
Storage::load_restrictions(&storage).unwrap(),
Some(Restrictions::Blacklist(_))
));
});

let sentinel_relaxation = env.try_invoke_contract::<(), crate::error::ContractError>(
&contract_id,
&Symbol::new(&env, "execute_governance"),
(
&sentinel,
&Bytes::from_slice(
&env,
&GovernanceCommand::SetGovernancePolicy {
kind: GOVERNANCE_POLICY_KIND_RESTRICTIONS,
target_ids: None,
mode: Some(0),
accounts: Some(alloc::vec![]),
market_id: None,
cap_group_id: None,
value: None,
value_b: None,
value_c: None,
}
.encode(),
),
)
.into_val(&env),
);
assert_eq!(
sentinel_relaxation,
Err(Ok(crate::error::ContractError::Unauthorized))
);

execute_governance_command(
&env,
&contract_id,
&governance,
&GovernanceCommand::SetGovernancePolicy {
kind: GOVERNANCE_POLICY_KIND_RESTRICTIONS,
target_ids: None,
mode: Some(0),
accounts: Some(alloc::vec![]),
market_id: None,
cap_group_id: None,
value: None,
value_b: None,
value_c: None,
},
);
env.as_contract(&contract_id, || {
let storage = SorobanStorage::new(&env);
assert_eq!(Storage::load_restrictions(&storage).unwrap(), None);
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a relaxation/tightening matrix instead of only the None/Blacklist path.

The new branch hinges on GovernanceRestrictions::determine_relaxed(...), but these tests only cover None -> Blacklist and Blacklist -> None. They will not catch whitelist/blacklist flips or subset/superset changes, which are exactly where the authorized caller changes.

As per coding guidelines: "Write comprehensive unit tests for new, non-trivial Rust logic; prefer #[rstest] parameterization when it improves coverage and keeps cases readable."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contract/vault/soroban/src/tests.rs` around lines 2955 - 3095, The test
currently only asserts None->Blacklist and Blacklist->None transitions; expand
it into a parameterized matrix covering all GovernanceRestrictions transitions
(None, Blacklist, Whitelist) and subset/superset changes so
determine_relaxed(...) behavior is fully exercised. Add rstest-style cases or a
loop that calls execute_governance_command (and direct contract invoke via
Symbol::new("execute_governance")) for each prior/new Restrictions variant
(including empty vs non-empty account lists and whitelist↔blacklist flips), then
assert Storage::load_restrictions(&storage) and the expected authorized caller
(governance vs sentinel) match for each transition; reference
GovernanceRestrictions::determine_relaxed,
GovernanceCommand::SetGovernancePolicy, GOVERNANCE_POLICY_KIND_RESTRICTIONS,
execute_governance_command, and Restrictions/Storage::load_restrictions to
locate and implement the cases.

@carrion256 carrion256 force-pushed the audit/governance-control-plane branch 2 times, most recently from dc05a58 to 488efb4 Compare May 5, 2026 10:45
@carrion256 carrion256 force-pushed the audit/governance-control-plane branch from f8bde99 to 36503d8 Compare May 18, 2026 14:43
@carrion256

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
contract/vault/soroban/governance/src/tests.rs (1)

1674-1696: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject governance-side restrictions tightening at submission time.

This test currently treats a governance-authored tightening as a valid pending proposal that must be manually revoked. But the runtime tests in contract/vault/soroban/src/tests.rs already prove governance callers are rejected for tightening with Unauthorized, so this is a dead-end proposal path. Please fail fast here or route tightenings exclusively through the sentinel-only entrypoint instead of queueing something that can never execute.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contract/vault/soroban/governance/src/tests.rs` around lines 1674 - 1696, The
test incorrectly assumes a governance-submitted tightening will create a pending
proposal; instead change the submit_set_restrictions call
(SorobanVaultGovernanceContract::submit_set_restrictions invoked in the
env.as_contract closure that sets governance_tightening_id) to expect failure:
call it and assert it returns an Err/Unauthorized (unwrap_err or pattern-match
the specific error) and remove the subsequent pending_ids and revoke logic for
that tightening; alternatively, if you intend to exercise a valid tightening
path, route the call through the sentinel-only entrypoint rather than
SorobanVaultGovernanceContract::submit_set_restrictions so no dead pending_id is
produced.
contract/vault/soroban/src/contract/entrypoints.rs (1)

573-603: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Keep config changes and skim governance-only.

execute_governance() now admits any control-plane caller, but set_governance_config_impl() and skim_impl() no longer re-check governance identity. The new sentinel-path tests show a sentinel can reach this entrypoint, so a configured sentinel can now call execute_governance directly to rotate curator/governance/allocators/adapters or skim arbitrary tokens immediately, bypassing the timelocked governance path.

Suggested fix
 fn execute_governance_command(
     env: &Env,
     caller: soroban_sdk::Address,
     command: GovernanceCommand,
     caller_preauthorized: bool,
 ) -> Result<(), ContractError> {
+    let ensure_governance = || -> Result<(), ContractError> {
+        if caller_preauthorized {
+            ensure_governance_identity(env, &caller)
+        } else {
+            require_governance(env, &caller)
+        }
+    };
+
     match command {
         GovernanceCommand::SetGovernanceConfig {
             kind,
             primary,
             many,
             value_a,
             value_b,
         } => {
+            ensure_governance()?;
             let primary = primary
                 .as_ref()
                 .map(|value| address_from_alloc_string(env, value))
                 .transpose()?;
             let many = many
@@
             set_governance_config_impl(env, caller, kind, primary, many, value_a, value_b)
         }
         GovernanceCommand::SetGovernancePolicy {
             kind,
             target_ids,
@@
                 caller_preauthorized,
             )
         }
         GovernanceCommand::Skim { token } => {
+            ensure_governance()?;
             skim_impl(env, caller, address_from_alloc_string(env, &token)?)
         }
     }
 }

Also applies to: 692-713, 945-953

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contract/vault/soroban/src/contract/entrypoints.rs` around lines 573 - 603,
The set_governance_config_impl and skim_impl paths are missing an internal
governance identity check, letting sentinels or other control-plane callers
bypass timelocked governance via execute_governance; fix by enforcing that only
the on-chain governance address can perform these sensitive changes: add a
require_governance-style check (compare caller to stored governance address) at
the top of set_governance_config_impl and skim_impl (and the similar entrypoint
blocks referenced around the other ranges), or alternatively move the governance
check into execute_governance so it rejects non-governance callers before
delegating to apply_* functions (apply_curator_config, apply_governance_config,
apply_allocators_config, apply_allowed_adapters_config, apply_sentinel_config,
apply_guardians_config, apply_skim_recipient_config,
apply_virtual_offsets_config); ensure the check uses the same canonical
governance lookup used elsewhere to prevent privilege escalation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contract/vault/soroban/governance/src/lib.rs`:
- Around line 228-243: The set_paused function currently accepts a bool but
immediately rejects false, making half the input space impossible; change the
ABI to use explicit entrypoints/types instead: replace set_paused(env, caller,
paused: bool) and the GovernanceAction::SetPaused variant with two distinct
functions (e.g., pause(env, caller) and submit_unpause(env, caller)) or with
separate action enums (e.g., GovernanceAction::Pause and
GovernanceAction::SubmitUnpause) so callers cannot pass an invalid boolean;
update calls to execute_vault_governance_action_as_caller and any storage keys
like DataKey::CurrentPaused to use the new functions/variants and similarly
refactor the timelocked path (the logic currently around the other branch) to
accept only the valid transition.
- Around line 228-243: In set_paused, avoid dispatching the vault call for a
no-op: read the current paused flag from storage (DataKey::CurrentPaused) at the
start of the function and if the requested paused value equals the current value
return Err(GovernanceError::NoChange) immediately; do this before calling
execute_vault_governance_action_as_caller and before writing storage so
redundant pauses/unpauses are short-circuited.
- Around line 245-267: The code stores and compares raw Vec<Address> for
restriction members which allows reorders/duplicates to be treated as changes;
in set_restrictions canonicalize the accounts before any equality checks, action
construction, and persistence: collapse duplicates and sort (or otherwise
produce a deterministic set) and if mode == RestrictionMode::None replace the
canonical accounts with an empty Vec; use that canonicalized accounts value for
GovernanceAction::SetRestrictions, for the call to
restrictions_change_is_relaxed, and when writing
DataKey::CurrentRestrictionAccounts (and mirror this canonicalization in the
other equivalent places referenced so storage/comparison logic is consistent).

In `@contract/vault/soroban/justfile`:
- Around line 388-397: The current justfile deploy snippet accepts non-numeric
SOROBAN_GOV_TIMELOCK_NS and fails later; add a numeric validation step after
reading timelock_ns to ensure it is a non-negative integer (e.g. use a regex
like '^[0-9]+$' or a case statement) and if the value is non-numeric print an
explanatory error mentioning SOROBAN_GOV_TIMELOCK_NS and
SOROBAN_ALLOW_ZERO_GOV_TIMELOCK and exit 1; update the block that sets
timelock_ns and checks for empty/zero to first test numericness (then continue
with the existing zero-check that relies on SOROBAN_ALLOW_ZERO_GOV_TIMELOCK).

In `@contract/vault/soroban/STRIDE.md`:
- Line 120: Update the I8 table row for execute_governance(payload) to reflect
the sentinel split: change the auth column from just “require_auth(caller) +
governance check” to explicitly state both governance and sentinel-authorized
paths (e.g., “require_auth(caller) + governance OR sentinel-authorized checks
(pause/tighten paths)”), and ensure the description mentions which actions
(pause, tightening, etc.) can be invoked via sentinel authorization so the
privileged surface accurately reflects execute_governance(payload) and related
governance/sentinel behavior.

---

Outside diff comments:
In `@contract/vault/soroban/governance/src/tests.rs`:
- Around line 1674-1696: The test incorrectly assumes a governance-submitted
tightening will create a pending proposal; instead change the
submit_set_restrictions call
(SorobanVaultGovernanceContract::submit_set_restrictions invoked in the
env.as_contract closure that sets governance_tightening_id) to expect failure:
call it and assert it returns an Err/Unauthorized (unwrap_err or pattern-match
the specific error) and remove the subsequent pending_ids and revoke logic for
that tightening; alternatively, if you intend to exercise a valid tightening
path, route the call through the sentinel-only entrypoint rather than
SorobanVaultGovernanceContract::submit_set_restrictions so no dead pending_id is
produced.

In `@contract/vault/soroban/src/contract/entrypoints.rs`:
- Around line 573-603: The set_governance_config_impl and skim_impl paths are
missing an internal governance identity check, letting sentinels or other
control-plane callers bypass timelocked governance via execute_governance; fix
by enforcing that only the on-chain governance address can perform these
sensitive changes: add a require_governance-style check (compare caller to
stored governance address) at the top of set_governance_config_impl and
skim_impl (and the similar entrypoint blocks referenced around the other
ranges), or alternatively move the governance check into execute_governance so
it rejects non-governance callers before delegating to apply_* functions
(apply_curator_config, apply_governance_config, apply_allocators_config,
apply_allowed_adapters_config, apply_sentinel_config, apply_guardians_config,
apply_skim_recipient_config, apply_virtual_offsets_config); ensure the check
uses the same canonical governance lookup used elsewhere to prevent privilege
escalation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: eb48b16a-06ea-45dc-b1e0-59b64f73e6be

📥 Commits

Reviewing files that changed from the base of the PR and between 8df7588 and 36503d8.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • contract/vault/soroban/Cargo.toml
  • contract/vault/soroban/README.md
  • contract/vault/soroban/STRIDE.md
  • contract/vault/soroban/governance/src/lib.rs
  • contract/vault/soroban/governance/src/tests.rs
  • contract/vault/soroban/governance/src/types.rs
  • contract/vault/soroban/justfile
  • contract/vault/soroban/src/contract/entrypoints.rs
  • contract/vault/soroban/src/contract/helpers.rs
  • contract/vault/soroban/src/tests.rs
  • contract/vault/soroban/tests/integration_tests.rs

Comment on lines +228 to +243
pub fn set_paused(env: Env, caller: Address, paused: bool) -> Result<(), GovernanceError> {
extend_instance_ttl(&env);
require_sentinel(&env, &caller)?;
if !paused {
return Err(GovernanceError::InvalidInput);
}

let action = GovernanceAction::SetPaused(paused);
require_not_abdicated(&env, &action)?;
let vault = get_address(&env, DataKey::Vault)?;
execute_vault_governance_action_as_caller(&env, &vault, &caller, &action)?;
env.storage()
.instance()
.set(&DataKey::CurrentPaused, &paused);
Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Remove the impossible half of the pause ABI.

set_paused rejects false, while the timelocked path now rejects true. That leaves both public paths exposing a bool where half of the domain is intentionally invalid. Split this into distinct pause() / submit_unpause() entrypoints, or separate action variants, so callers cannot discover the rule only via an on-chain failure.

As per coding guidelines: "Prefer parsing over validation in Rust code; express invariants in types wherever practical, and make invalid states unrepresentable."

Also applies to: 759-774

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contract/vault/soroban/governance/src/lib.rs` around lines 228 - 243, The
set_paused function currently accepts a bool but immediately rejects false,
making half the input space impossible; change the ABI to use explicit
entrypoints/types instead: replace set_paused(env, caller, paused: bool) and the
GovernanceAction::SetPaused variant with two distinct functions (e.g.,
pause(env, caller) and submit_unpause(env, caller)) or with separate action
enums (e.g., GovernanceAction::Pause and GovernanceAction::SubmitUnpause) so
callers cannot pass an invalid boolean; update calls to
execute_vault_governance_action_as_caller and any storage keys like
DataKey::CurrentPaused to use the new functions/variants and similarly refactor
the timelocked path (the logic currently around the other branch) to accept only
the valid transition.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Short-circuit redundant pauses before calling the vault.

This path never checks CurrentPaused, so calling set_paused(true) while already paused still dispatches execute_governance instead of returning NoChange like the timelocked path does at Line 769. Load the current flag and reject the no-op locally before making the privileged external call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contract/vault/soroban/governance/src/lib.rs` around lines 228 - 243, In
set_paused, avoid dispatching the vault call for a no-op: read the current
paused flag from storage (DataKey::CurrentPaused) at the start of the function
and if the requested paused value equals the current value return
Err(GovernanceError::NoChange) immediately; do this before calling
execute_vault_governance_action_as_caller and before writing storage so
redundant pauses/unpauses are short-circuited.

Comment on lines +245 to +267
pub fn set_restrictions(
env: Env,
caller: Address,
mode: u32,
accounts: Vec<Address>,
) -> Result<(), GovernanceError> {
extend_instance_ttl(&env);
require_sentinel(&env, &caller)?;
let mode = RestrictionMode::from_u32(mode)?;
let action = GovernanceAction::SetRestrictions(mode, accounts.clone());
require_not_abdicated(&env, &action)?;
if restrictions_change_is_relaxed(&env, mode, &accounts) {
return Err(GovernanceError::InvalidInput);
}
let vault = get_address(&env, DataKey::Vault)?;
execute_vault_governance_action_as_caller(&env, &vault, &caller, &action)?;
env.storage()
.instance()
.set(&DataKey::CurrentRestrictionMode, &mode);
env.storage()
.instance()
.set(&DataKey::CurrentRestrictionAccounts, &accounts);
Ok(())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Canonicalize restriction members before equality and persistence.

The effective restriction policy is set-like, but these paths still compare and store a raw Vec<Address>. Reordering the same addresses, submitting duplicates, or changing accounts while mode == RestrictionMode::None can be treated as a fresh governance change even though restriction_snapshot collapses those cases to the same effective policy. Normalize to a canonical set form first, and clear accounts entirely for None.

As per coding guidelines: "Prefer stronger types over loosely constrained values in Rust: enums over well-known strings, sets over vectors when uniqueness matters, dedicated newtypes over primitive obsession, and structured state machines over ad hoc flags."

Also applies to: 837-854, 1617-1648

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contract/vault/soroban/governance/src/lib.rs` around lines 245 - 267, The
code stores and compares raw Vec<Address> for restriction members which allows
reorders/duplicates to be treated as changes; in set_restrictions canonicalize
the accounts before any equality checks, action construction, and persistence:
collapse duplicates and sort (or otherwise produce a deterministic set) and if
mode == RestrictionMode::None replace the canonical accounts with an empty Vec;
use that canonicalized accounts value for GovernanceAction::SetRestrictions, for
the call to restrictions_change_is_relaxed, and when writing
DataKey::CurrentRestrictionAccounts (and mirror this canonicalization in the
other equivalent places referenced so storage/comparison logic is consistent).

Comment on lines +388 to +397
timelock_ns="${SOROBAN_GOV_TIMELOCK_NS:-}"; \
if [ -z "$timelock_ns" ]; then \
echo "Error: set SOROBAN_GOV_TIMELOCK_NS before deploying governance."; \
echo "For ephemeral local testing only, set SOROBAN_GOV_TIMELOCK_NS=0 and SOROBAN_ALLOW_ZERO_GOV_TIMELOCK=1."; \
exit 1; \
fi; \
if [ "$timelock_ns" = "0" ] && [ "${SOROBAN_ALLOW_ZERO_GOV_TIMELOCK:-0}" != "1" ]; then \
echo "Error: zero governance timelock requires SOROBAN_ALLOW_ZERO_GOV_TIMELOCK=1."; \
exit 1; \
fi; \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate SOROBAN_GOV_TIMELOCK_NS is numeric before deploy.

Right now a non-numeric value passes these checks and fails later with a less actionable CLI error.

Suggested patch
 	timelock_ns="${SOROBAN_GOV_TIMELOCK_NS:-}"; \
 	if [ -z "$timelock_ns" ]; then \
 		echo "Error: set SOROBAN_GOV_TIMELOCK_NS before deploying governance."; \
 		echo "For ephemeral local testing only, set SOROBAN_GOV_TIMELOCK_NS=0 and SOROBAN_ALLOW_ZERO_GOV_TIMELOCK=1."; \
 		exit 1; \
 	fi; \
+	if ! printf '%s' "$timelock_ns" | grep -Eq '^[0-9]+$'; then \
+		echo "Error: SOROBAN_GOV_TIMELOCK_NS must be an unsigned integer (nanoseconds)."; \
+		exit 1; \
+	fi; \
 	if [ "$timelock_ns" = "0" ] && [ "${SOROBAN_ALLOW_ZERO_GOV_TIMELOCK:-0}" != "1" ]; then \
 		echo "Error: zero governance timelock requires SOROBAN_ALLOW_ZERO_GOV_TIMELOCK=1."; \
 		exit 1; \
 	fi; \
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
timelock_ns="${SOROBAN_GOV_TIMELOCK_NS:-}"; \
if [ -z "$timelock_ns" ]; then \
echo "Error: set SOROBAN_GOV_TIMELOCK_NS before deploying governance."; \
echo "For ephemeral local testing only, set SOROBAN_GOV_TIMELOCK_NS=0 and SOROBAN_ALLOW_ZERO_GOV_TIMELOCK=1."; \
exit 1; \
fi; \
if [ "$timelock_ns" = "0" ] && [ "${SOROBAN_ALLOW_ZERO_GOV_TIMELOCK:-0}" != "1" ]; then \
echo "Error: zero governance timelock requires SOROBAN_ALLOW_ZERO_GOV_TIMELOCK=1."; \
exit 1; \
fi; \
timelock_ns="${SOROBAN_GOV_TIMELOCK_NS:-}"; \
if [ -z "$timelock_ns" ]; then \
echo "Error: set SOROBAN_GOV_TIMELOCK_NS before deploying governance."; \
echo "For ephemeral local testing only, set SOROBAN_GOV_TIMELOCK_NS=0 and SOROBAN_ALLOW_ZERO_GOV_TIMELOCK=1."; \
exit 1; \
fi; \
if ! printf '%s' "$timelock_ns" | grep -Eq '^[0-9]+$'; then \
echo "Error: SOROBAN_GOV_TIMELOCK_NS must be an unsigned integer (nanoseconds)."; \
exit 1; \
fi; \
if [ "$timelock_ns" = "0" ] && [ "${SOROBAN_ALLOW_ZERO_GOV_TIMELOCK:-0}" != "1" ]; then \
echo "Error: zero governance timelock requires SOROBAN_ALLOW_ZERO_GOV_TIMELOCK=1."; \
exit 1; \
fi; \
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contract/vault/soroban/justfile` around lines 388 - 397, The current justfile
deploy snippet accepts non-numeric SOROBAN_GOV_TIMELOCK_NS and fails later; add
a numeric validation step after reading timelock_ns to ensure it is a
non-negative integer (e.g. use a regex like '^[0-9]+$' or a case statement) and
if the value is non-numeric print an explanatory error mentioning
SOROBAN_GOV_TIMELOCK_NS and SOROBAN_ALLOW_ZERO_GOV_TIMELOCK and exit 1; update
the block that sets timelock_ns and checks for empty/zero to first test
numericness (then continue with the existing zero-check that relies on
SOROBAN_ALLOW_ZERO_GOV_TIMELOCK).

| 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 → `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 |
| I8 | Governance → `execute_governance(payload)` (fees, curator, governance, sentinel, guardian, allocators, caps, market removal, cap groups, restrictions, adapter allowlist, supply queue, skim recipient, skim, pause) | Yes | `require_auth(caller)` + governance check | Governance contract ↔ Vault storage |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

I8 auth description is stale after the sentinel split.

Line 120 still says “governance check,” but pause/tightening now include sentinel-authorized paths. Update the auth column so the threat model reflects the real privileged surface.

As per coding guidelines, “If behavior changes around governance bridging… update the docs in contract/vault/soroban/ in the same change.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contract/vault/soroban/STRIDE.md` at line 120, Update the I8 table row for
execute_governance(payload) to reflect the sentinel split: change the auth
column from just “require_auth(caller) + governance check” to explicitly state
both governance and sentinel-authorized paths (e.g., “require_auth(caller) +
governance OR sentinel-authorized checks (pause/tighten paths)”), and ensure the
description mentions which actions (pause, tightening, etc.) can be invoked via
sentinel authorization so the privileged surface accurately reflects
execute_governance(payload) and related governance/sentinel behavior.

@carrion256 carrion256 force-pushed the audit/governance-control-plane branch from 36503d8 to be0475b Compare May 19, 2026 08:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant