Skip to content

[Medium] insurance-withdrawal rate-limit policy is structurally unsettable — the cooldown and deposits-only enforcement from #385/#386/#396 can never fire #427

Description

@0x-SquidSol

Summary

The insurance-withdrawal rate limit — the cooldown from F-1 and the deposits-only ceiling from F-2 — can never be active. Both policy fields are assigned once, to zero, at market initialisation, and no instruction in the program can set them. Both enforcement helpers short-circuit on zero, so the checks that #385, #386 and #396 were closed by adding are unreachable in every deployed market.

Mechanism

The two enforcement helpers are present and correct, and each is a no-op when its policy field is zero.

check_insurance_withdraw_cooldown (src/v16_program.rs:9643-9655):

if cooldown_slots > 0 && last_slot != 0 {
    let earliest = last_slot.checked_add(cooldown_slots)...;
    if now_slot < earliest {
        return Err(PercolatorError::InsuranceWithdrawCooldownActive.into());
    }
}

apply_insurance_withdraw_ceiling (:9664-9669):

if deposits_only == 0 {
    // ceiling not applied

Now the policy fields. insurance_withdraw_deposits_only (:1126) and insurance_withdraw_cooldown_slots (:1133) are each written in exactly one place in the file — the config literal inside handle_init_market (:7597), at :7697 and :7704 respectively:

insurance_withdraw_deposits_only: 0,
...
insurance_withdraw_cooldown_slots: 0,

There is no other assignment to either field anywhere in the program, and no instruction that configures them — there is no ConfigureInsuranceWithdrawPolicy, no SetInsurance*, nothing in the instruction enum that reaches them. InitMarket does not accept them as parameters either, so a market cannot even be created with the policy on.

The deposits-only ceiling is dead twice over. Its budget, insurance_withdraw_deposit_remaining (:1118), is initialised to zero (:7689) and only ever accrues inside if cfg.insurance_withdraw_deposits_only != 0 (:9761-9762) — a branch that can never be taken. So the budget stays zero, and the ceiling that would consume it never applies.

Reproduction

tests/poc_insurance_withdraw_policy_inert.rs, LiteSVM against the real BPF. Passes against main at 93372e4:

cargo test --test poc_insurance_withdraw_policy_inert
test insurance_withdraw_rate_limit_policy_is_permanently_inert ... ok
test result: ok. 1 passed; 0 failed

The test asserts the policy is off at genesis, funds an asset's insurance domain, and then performs two withdrawals in the same slot:

let (cooldown, deposits_only, remaining) = config_policy(&env);
assert_eq!(cooldown, 0, "cooldown_slots is 0 at init and has no setter");
assert_eq!(deposits_only, 0, "deposits_only is 0 at init and has no setter");
assert_eq!(remaining, 0, "the deposits-only budget never accrues");

// ... fund 1,000 into the asset's insurance domain ...

let slot_before = env.svm.get_sysvar::<Clock>().slot;
withdraw_insurance(&mut env, &stake_auth, dest, 600).expect("first withdrawal");
withdraw_insurance(&mut env, &stake_auth, dest, 400)
    .expect("second withdrawal in the SAME slot must be blocked by an active cooldown — it is not");
let slot_after = env.svm.get_sysvar::<Clock>().slot;

assert_eq!(slot_before, slot_after, "both withdrawals executed in one slot");
assert_eq!(token_amount(&env.svm, dest), 1_000);
assert_eq!(market_insurance(&env), 0);

Both succeed, the reserve goes to zero in a single slot, and the policy fields are still zero afterwards. (The two amounts differ only so the transactions do not hash identically — LiteSVM rejects a byte-identical retry as AlreadyProcessed, which is a harness artifact rather than program behaviour.)

Impact

Three issues were closed on the strength of this policy being enforced:

The enforcement those closed with is real code on the real path — handle_withdraw_insurance calls the cooldown check at :10771, and handle_withdraw_insurance_asset calls it at :10959 — but it is gated behind a field that nothing can raise above zero. On every market that exists, the runtime behaviour is identical to having no policy at all, which is the state those issues described.

The helper itself is well tested in isolation — :18948-18974 exercises the boundary, the overflow case and the zero case directly — which is part of why the gap is easy to miss: the unit tests prove the function is correct, and correctness of the function was never the issue.

By itself this is a missing defence-in-depth control rather than a direct exploit: the whole reserve can already be withdrawn by whoever legitimately holds the authority, and this only means it can be done in one slot instead of several. What makes it worth raising now is that it removes the bound on other failures. We have separately reported that an asset's asset_admin can seize insurance_operator and insurance_authority without the holder's consent. A rate limit is exactly the control that would cap the damage from a seized authority and give an operator a window to notice; with the policy structurally off, a seizure converts directly into a single-slot drain of the full reserve.

We rate this medium on that basis — low direct severity, but it silently negates a mitigation that three closed issues assume is active.

Suggested direction

The gap is a missing setter rather than a defect in the enforcement, which reads correctly. Options, roughly in ascending order of change:

We have not opened a pull request, because the choice between a default, a setter, and a documented no-op is a policy decision rather than a code one, and a default value would need to come from you. Happy to implement whichever you prefer.

Environment

  • dcccrypto/percolator-prog main at 93372e4
  • Engine dcccrypto/percolator main at fb1f46d
  • cargo build-sbf --no-default-features --tools-version v1.52, then cargo test --test poc_insurance_withdraw_policy_inert, rustc 1.97.1

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions