Skip to content

fix(soroban): prevent low-liquidity withdrawal stalls#425

Draft
carrion256 wants to merge 1 commit into
spr/refactor/vault-ergonomics/4f330057from
audit/withdrawal-recovery-repro
Draft

fix(soroban): prevent low-liquidity withdrawal stalls#425
carrion256 wants to merge 1 commit into
spr/refactor/vault-ergonomics/4f330057from
audit/withdrawal-recovery-repro

Conversation

@carrion256

@carrion256 carrion256 commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • refuses normal queued withdrawal execution when the head can only pay below MIN_WITHDRAWAL_ASSETS, so Soroban no longer creates a new low-liquidity Withdrawing state
  • still allows partial payouts at or above the minimum withdrawal amount
  • exposes Soroban VaultCommand::AbortWithdrawing { caller, op_id } through the compact execute(payload) path as a stale-state recovery backstop
  • enables and reuses the existing kernel AbortWithdrawing recovery transition
  • tightens idle payout planning so it returns a concrete payout plan or an error, with the minimum payout threshold passed explicitly
  • adds deterministic and property coverage for both the new prevention invariant and stale Withdrawing recovery
  • updates Soroban docs/STRIDE notes for the retained recovery command

Audit Mapping

  • Finding: A-002
  • Cluster: withdrawal-recovery
  • Tracker: contracts-audit-clusters/withdrawal-recovery.audit.md in contracts-audit-tracker
  • Base: PR 417 branch spr/refactor/vault-ergonomics/4f330057, not dev

Verification

  • cargo fmt
  • CARGO_TARGET_DIR=/data/projects/contracts/.shared-target cargo test -p templar-vault-kernel low_liquidity -- --nocapture
  • CARGO_TARGET_DIR=/data/projects/contracts/.shared-target cargo test -p templar-vault-kernel execute_withdraw -- --nocapture
  • CARGO_TARGET_DIR=/data/projects/contracts/.shared-target cargo test -p templar-vault-kernel finish_allocating -- --nocapture
  • CARGO_TARGET_DIR=/data/projects/contracts/.shared-target cargo test -p templar-soroban-runtime test_execute_withdraw_respects_min_withdrawal_assets -- --nocapture
  • CARGO_TARGET_DIR=/data/projects/contracts/.shared-target cargo test -p templar-soroban-runtime test_abort_withdrawing -- --nocapture
  • CARGO_TARGET_DIR=/data/projects/contracts/.shared-target cargo test -p templar-soroban-runtime prop_low_liquidity -- --nocapture
  • CARGO_TARGET_DIR=/data/projects/contracts/.shared-target cargo test -p templar-soroban-shared-types -- --nocapture
  • CARGO_TARGET_DIR=/data/projects/contracts/.shared-target just -f contract/vault/soroban/justfile size-budget-check

Size gate: runtime deploy WASM 96956 bytes <= 131072 bytes.

Halborn Finding IDs

Included for Halborn SSC GitHub remediation detection:

  • A-002 / Finding ID 39906066-2ebb-4b87-9e22-844ce7913a9c — Low-liquidity withdrawal execution can trap vault in Withdrawing with no recovery path

This change is Reviewable

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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: 18592ea9-6258-4318-9d82-84edf26bd5df

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

The PR changes the vault kernel's idle-payout planning from returning Ok(None) when insufficient liquidity exists to returning an error (WithdrawalLiquidityBelowMinimum). It introduces a new AbortWithdrawing command to allow allocator actors to recover stalled withdrawals, and updates the Soroban contract implementation to handle this new control flow with comprehensive tests and documentation.

Changes

Cohort / File(s) Summary
Kernel Actions & Error Handling
contract/vault/kernel/src/actions/mod.rs, contract/vault/kernel/src/error.rs
Updated plan_idle_payout API to accept min_withdrawal_assets and return Err(WithdrawalLiquidityBelowMinimum) instead of Ok(None) when idle liquidity is insufficient. Added new error variant InvalidStateCode::WithdrawalLiquidityBelowMinimum with code 41.
Soroban Contract Implementation
contract/vault/soroban/src/contract/curator_vault.rs, contract/vault/soroban/src/contract/entrypoints.rs
Added new public abort_withdrawing method for vault recovery. Updated withdrawal execution paths to use min_withdrawal_assets parameter. Added command dispatcher for new AbortWithdrawing variant with authorization and state transition handling.
Shared Types
contract/vault/soroban/shared-types/src/lib.rs
Introduced new VaultCommand::AbortWithdrawing { caller: String, op_id: u64 } variant with wire tag 11. Added ExecuteWithdrawStatus struct and VaultCommandResult::ExecuteWithdrawStatus variant; added u128 codec helpers and roundtrip tests.
Test Coverage
contract/vault/kernel/src/actions/tests.rs, contract/vault/soroban/src/tests.rs
Added configuration constant MIN_WITHDRAWAL_ASSETS. Kernel tests verify error behavior when idle liquidity is below/at minimum and queue head skip effects. Soroban tests cover low-liquidity error handling, abort recovery flows, property-based invariant checks, and public command execution.
Documentation & Configuration
contract/vault/soroban/README.md, contract/vault/soroban/STRIDE.md, contract/vault/soroban/Cargo.toml
Updated README to document AbortWithdrawing recovery command flow during stalled withdrawals. Clarified in STRIDE that abort_withdrawing remains available as execute recovery path. Added action-recovery feature to templar-vault-kernel dependency.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant Entrypoint as Entrypoints
    participant CuratorVault as CuratorVault
    participant Kernel as Kernel
    participant State as VaultState

    Caller->>Entrypoint: execute(AbortWithdrawing { caller, op_id })
    Entrypoint->>Entrypoint: require_signed()
    Entrypoint->>CuratorVault: abort_withdrawing(caller, op_id, now_ns)
    CuratorVault->>CuratorVault: authorize(caller)
    CuratorVault->>Kernel: KernelAction::abort_withdrawing(op_id)
    Kernel->>State: validate op_id matches active Withdrawing
    Kernel->>State: refund escrowed shares
    Kernel->>State: dequeue withdrawal head
    Kernel->>State: transition op → Idle
    Kernel-->>CuratorVault: EffectSummary { WithdrawalStopped, ... }
    CuratorVault-->>Entrypoint: Result<EffectSummary, RuntimeError>
    Entrypoint-->>Caller: Unit (success)
Loading
sequenceDiagram
    participant Executor
    participant Entrypoint as Entrypoints
    participant CuratorVault as CuratorVault
    participant Kernel as Kernel
    participant State as VaultState

    rect rgba(255, 100, 100, 0.5)
    Executor->>Entrypoint: execute(ExecuteWithdraw { ... })
    Entrypoint->>CuratorVault: execute_withdraw()
    CuratorVault->>Kernel: plan_idle_payout(state, min_withdrawal_assets)
    Kernel->>State: classify queue head with available_assets
    alt idle_liquidity < min_withdrawal_assets
        Kernel-->>CuratorVault: Err(WithdrawalLiquidityBelowMinimum)
        CuratorVault-->>Entrypoint: RuntimeError::KernelError
        Entrypoint-->>Executor: Error (Withdraw blocked)
    else idle_liquidity >= min_withdrawal_assets
        Kernel-->>CuratorVault: IdlePayoutPlan
        CuratorVault->>Kernel: settle & execute effects
        Kernel-->>CuratorVault: EffectSummary
        CuratorVault-->>Entrypoint: ExecuteWithdrawStatus { ... }
        Entrypoint-->>Executor: ExecuteWithdrawStatus
    end
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly Related PRs

Suggested Reviewers

  • peer2f00l
  • royalf00l
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(soroban): prevent low-liquidity withdrawal stalls' directly describes the main change: preventing withdrawal stalls caused by low-liquidity conditions, which is the core problem this PR addresses.
Description check ✅ Passed The description comprehensively covers the changeset, detailing the prevention mechanism, recovery command exposure, API tightening, test coverage, and documentation updates—all present in the file-level summaries.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
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/withdrawal-recovery-repro

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 carrion256 changed the title fix(soroban): expose withdrawal recovery command fix(soroban): prevent low-liquidity withdrawal stalls Apr 30, 2026
@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.

@carrion256 Here is a summary description of this PR and a design usability critique for human reviewers.


PR #425fix(soroban): prevent low-liquidity withdrawal stalls

What Problem It Solves

The Soroban vault's queued withdrawal executor could previously advance a withdrawal from QueuedWithdrawing even when available idle liquidity was too thin to fund a meaningful payout. This left withdrawers holding a Withdrawing op that could never self-resolve, causing an indefinite state stall (audit finding A-002). The PR plugs the entry point that creates those stuck states, and adds a sanctioned escape hatch to clean up any that already exist.


What Changed

Area Change
Withdrawal gate execute_withdraw now rejects queue execution when the projected payout for the head item is below MIN_WITHDRAWAL_ASSETS, returning an error instead of creating a new Withdrawing state
Partial payout Partial payouts that meet or exceed MIN_WITHDRAWAL_ASSETS are still permitted, preserving progress when only part of a withdrawal can be serviced
Recovery command VaultCommand::AbortWithdrawing { caller, op_id } is wired through the compact execute(payload) entrypoint so a keeper/admin can cancel a stale Withdrawing op and return the escrow to the queue
Idle payout planner Signature tightened: now returns a concrete PayoutPlan or a typed error; min_payout_threshold is passed explicitly rather than being baked into the function body
Kernel reuse No new kernel logic; the existing AbortWithdrawing transition in templar-vault-kernel is reused unchanged
Tests Deterministic tests for the prevention invariant (test_execute_withdraw_respects_min_withdrawal_assets) and recovery (test_abort_withdrawing), plus a property-based test (prop_low_liquidity)
Docs STRIDE threat model and Soroban docs updated to document the retained recovery command

The deployed WASM binary remains under the 128 KiB size gate (96,956 bytes).


Design Usability Critique

Below are observations and open questions human reviewers may want to probe.

1. MIN_WITHDRAWAL_ASSETS is a hard constant — what is its value and who owns it?

The PR passes the threshold explicitly to the planner (good for testability), but reviewers should confirm:

  • What is the actual constant value and in what denomination/precision?
  • Is it governance-configurable or hard-coded? If hard-coded, any future market where the underlying asset's decimals differ could make the threshold wrong by orders of magnitude.
  • Is it the same value used across the idle path, the queue gate, and the AbortWithdrawing authorization check, or are there silent divergences?

2. Who calls AbortWithdrawing and is its authorization model correct?

The command struct carries caller explicitly. Reviewers should verify:

  • Does the entrypoint require caller.require_auth() before executing the abort?
  • Can an arbitrary keeper abort any op, or only their own? The PR description says "stale-state recovery backstop" but doesn't specify the trust model.
  • Is there a time-lock or minimum age on the Withdrawing state before abort is permitted, or can a malicious keeper abort a legitimately in-flight op immediately after it enters Withdrawing?

3. Stuck withdrawers have no on-chain notification

When the queue gate refuses to advance because idle liquidity is too low, the withdrawer stays in Queued silently. There is no event emitted that a frontend or indexer can surface. Human reviewers should check whether a LowLiquidity or WithdrawalDeferred event is emitted so operators and users can detect the condition off-chain.

4. The two-mechanism design (prevent + abort) requires keepers to actively monitor

The prevention path stops new stuck states from being created, but existing or edge-case stuck Withdrawing ops require a keeper to observe and manually call AbortWithdrawing. This is operationally reasonable, but reviewers should confirm:

  • Is AbortWithdrawing idempotent? (i.e., safe to call on an op that is already aborted or already completed)
  • Is there any keeper incentive or obligation to call it, or is it purely a manual governance action?
  • What happens to the withdrawer's escrow between the stall and the abort — is it locked and unavailable for any other op?

5. Interaction with the idle-only atomic path

Per the existing learning on this codebase, withdraw() and redeem() include an idle-only atomic path that bypasses the queued withdrawal lifecycle entirely. Reviewers should confirm whether MIN_WITHDRAWAL_ASSETS is also enforced on that fast path, or whether a small withdrawal can still slip through it below the threshold (creating an asymmetry between the two paths).

6. Error type ergonomics

The idle payout planner now returns a typed error. Reviewers should check that the error type exposed through execute(payload) is actually surfaced as a distinguishable Soroban contract error (not swallowed into a generic InternalError), so off-chain tooling can identify "deferred due to low liquidity" vs. "failed due to a bug."

7. Property test coverage scope

The prop_low_liquidity property test is a positive addition. Reviewers should check whether the property also covers the boundary condition at exactly MIN_WITHDRAWAL_ASSETS (i.e., that amount == threshold is accepted, not rejected), and whether the fuzzer is seeded with edge cases around u128::MAX share amounts.


Note: The usability analysis above is based on the PR description, objectives, and the stored learnings about this vault's architecture (idle-only atomic path, AbortWithdrawing lifecycle, OpState transitions). Reviewers with access to the actual diff should verify each point against the code directly.


🧠 Learnings used
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 : withdraw() and redeem() include an idle-only atomic path that bypasses the queued withdrawal lifecycle. Do not change queue semantics without checking both paths

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: Serialized VaultState remains a practical resource boundary because Soroban persists a single StateBlob. Pending withdrawals are the main long-lived growth vector

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:16.597Z
Learning: Watch for `OpState` transitions, escrow accounting, keeper-routed withdrawals, callback ordering, idle-balance resync, fee accrual, and reconciliation after partial failures in the vault contract

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/** : Read README.md, STRIDE.md, SIZE_BUDGET.md, src/contract/entrypoints.rs, src/effects/mod.rs, and src/storage/mod.rs before making non-trivial changes to the Soroban vault runtime

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

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: peer2f00l
Repo: Templar-Protocol/contracts PR: 404
File: contract/vault/kernel/src/actions/mod.rs:1869-1871
Timestamp: 2026-04-14T07:35:09.869Z
Learning: In `contract/vault/kernel/src/actions/mod.rs` (Templar-Protocol/contracts, Rust), the vault kernel crate is compiled for Soroban (wasm32 target). Extracting helper functions from large dispatcher functions like `dispatch::apply_action` risks increasing wasm binary size due to additional call frames and reduced compiler inlining. Therefore, `#[allow(clippy::too_many_lines)]` suppressions on large dispatch/entrypoint functions in this file are intentional and should not be flagged as needing refactoring.

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: carrion256
Repo: Templar-Protocol/contracts PR: 417
File: contract/vault/curator-primitives/src/policy/cooldown/mod.rs:17-23
Timestamp: 2026-04-27T09:33:55.992Z
Learning: In Templar-Protocol/contracts, the Soroban vault (`contract/vault/soroban/`) and NEAR vault (`contract/vault/near/`) are greenfield deployments with no live persisted state. Do not flag internal serialization format changes (e.g., field type changes in structs like `Cooldown`, `RefreshThrottle`, or policy state) as breaking migration risks — there is no existing stored data to break.

Learnt from: CR
Repo: Templar-Protocol/contracts PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-24T14:44:16.597Z
Learning: Check invariants around refunds, withdrawals, account deletion, authorization, and replay or double-execution risks in smart contract code

@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: 1

🤖 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/README.md`:
- Around line 113-119: Clarify the recovery semantics: explicitly state that
VaultCommand::AbortWithdrawing { caller, op_id } may be invoked only by actors
in the allocator-emergency role set, and that when processed the handler
restores any amount in Withdrawing.collected back to the vault's idle liquidity
before emitting the kernel WithdrawalStopped event and dequeuing the request,
returning the vault to Idle; update the README paragraph describing the
AbortWithdrawing transition to name the allowed caller role
(allocator-emergency) and to explicitly mention the restoration of
Withdrawing.collected to idle prior to dequeuing and returning to Idle.
🪄 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: 22bf469b-fc98-4083-a69c-099d1353af4f

📥 Commits

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

📒 Files selected for processing (10)
  • contract/vault/kernel/src/actions/mod.rs
  • contract/vault/kernel/src/actions/tests.rs
  • contract/vault/kernel/src/error.rs
  • contract/vault/soroban/Cargo.toml
  • contract/vault/soroban/README.md
  • contract/vault/soroban/STRIDE.md
  • contract/vault/soroban/shared-types/src/lib.rs
  • contract/vault/soroban/src/contract/curator_vault.rs
  • contract/vault/soroban/src/contract/entrypoints.rs
  • contract/vault/soroban/src/tests.rs

Comment thread contract/vault/soroban/README.md
@carrion256 carrion256 force-pushed the audit/withdrawal-recovery-repro branch from be90ea5 to 76a0240 Compare May 5, 2026 10:45
@carrion256 carrion256 force-pushed the audit/withdrawal-recovery-repro branch from 76a0240 to 66e68f8 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.

@carrion256 carrion256 force-pushed the audit/withdrawal-recovery-repro branch from 66e68f8 to 1b2c519 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