Skip to content

fix(vault): allowance hygiene, Blend ABI correctness, fuzz coverage, pause semantics (#985-988) - #1035

Merged
robertocarlous merged 4 commits into
Neurowealth:mainfrom
Wycode569:fix/neurowealth-princess-batch
Oct 19, 2026
Merged

robertocarlous merged 4 commits into
Neurowealth:mainfrom
Wycode569:fix/neurowealth-princess-batch

Conversation

@Wycode569

@Wycode569 Wycode569 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Addresses 4 issues on the NeuroWealth vault contract: a stale-allowance hygiene gap after partial protocol supplies, a Blend v2 ABI mismatch, extended fuzz coverage for several newer vault flows, and inconsistent pause/emergency_pause semantics. Implementing the fuzz coverage for #987 surfaced 5 real, previously-undetected accounting bugs along the way, all fixed in this PR with dedicated regression tests.

closes #985
closes #986
closes #987
closes #988

Changes

  • security(contract): reset token allowance to zero after supply_to_blend and supply_to_dex #985: allowance hygiene. Added revoke_allowance_if_outstanding(), called after supply_to_blend/supply_to_dex whenever the amount actually supplied is less than requested. It zeroes any leftover USDC approval left on the pool so a later call can't drain it via the stale allowance. Tested in test_allowance_hygiene.rs (full supply, partial Blend supply, partial DEX supply).

  • bug(contract): verify the Blend adapter against the real Blend pool ABI (submit arity, balance query) #986: Blend v2 ABI correctness. The vault's Blend adapter was calling submit/submit_with_allowance with the wrong argument count and reading balances via a balance(asset, user) function that does not exist on the real pool contract. Fixed against the real interface (verified via blend-capital/blend-contracts-v2):

    • submit/submit_with_allowance now pass 4 args: from, spender, to, requests.
    • Balance reads now combine get_reserve(asset) (for the b-rate and reserve index) with get_positions(user) (for the b-token amount), matching how Blend actually exposes a position. Added full field-for-field mirrors of ReserveConfig/ReserveData/Reserve/Positions (#[contracttype] struct decoding requires an exact field count match, not just the fields a caller cares about).
    • The mock Blend pool used in tests is updated to the same shape, plus a blend_pool_balance() test helper that does the two-call read for callers.
  • test(contract): extend share-accounting fuzz targets to lock, migrate, emergency-withdraw and queue flows #987: fuzz coverage for newer flows, plus 5 bugs it found. Extended fuzz/fuzz_targets/share_accounting_invariants.rs to drive lock_shares, unlock_shares, migrate_shares, emergency_withdraw, queue_withdrawal, process_withdrawal_queue, and batch_deposit, and added 3 new invariants (locked-shares never exceed a user's shares, the active-share index matches real holdings, and the vault's own token balance covers total_assets). Running this surfaced:

    1. batch_deposit never updated TotalAssets or registered a first-time depositor in the active-share index, understating the share price for every other holder.
    2. queue_withdrawal's shares-needed calculation used an unconditional + 1 instead of a true ceiling division, so requesting a user's exact full balance was spuriously rejected.
    3. emergency_withdraw could burn a user's locked shares during a pause (the regular withdraw path already protected locks; emergency_withdraw did not).
    4. migrate_shares had no way to exclude a locked position, since it always moves 100% of a user's shares; it now blocks migration outright while any shares are locked, with a defensive clamp on LockedShares as a backstop against any other path hitting the same class of bug.
    5. migrate_shares's cross-contract leg was broken end to end: it transferred USDC directly into the target vault's balance, then called the target's public deposit(user, amount), which tries to pull that same amount from the user's own wallet a second time, funds the user never personally held since they moved vault-to-vault. Added a new receive_migration entry point on the receiving side that credits the already-transferred funds instead, authorized via env.authorize_as_current_contract so only migrate_shares itself (not an arbitrary caller) can invoke it.

    Each bug has a dedicated regression test. The fuzz target itself needed a MAX_OPS_PER_CASE cap (tuned down from 40 to 12 ops) and a smaller process_withdrawal_queue batch size after hitting soroban-sdk 21.7.7's own resource-budget ceiling during long operation sequences, unrelated to the contract logic.

  • enhancement(contract): make pause() reject an already-paused vault and differentiate emergency_pause #988: pause vs emergency_pause semantics. pause() previously had no already-paused guard and silently re-emitted VaultPausedEvent on every call. It now rejects a repeat call with a new VaultError::AlreadyPaused, matching unpause()'s existing NotPaused guard. emergency_pause() is intentionally left as a silent no-op when already paused: an emergency responder racing another caller to pause the vault should never be blocked by an error, since the vault ends up paused either way. Both functions' doc comments now spell out why the two conventions differ. A pre-existing test (pause_matrix_pause_allowed_while_paused) asserted the old behavior and has been rewritten (pause_matrix_pause_rejected_while_already_paused) to match the new contract, plus a new test proving the two functions' asymmetry directly.

Pre-existing issues found and disclosed (not introduced by this PR)

While working on #987, the vault crate and its test suite turned out to have substantial pre-existing problems unrelated to any of these 4 issues:

  • The crate had ~460+ compile errors from duplicate type declarations, a DataKey enum over Soroban's 50-variant #[contracttype] limit, VaultError discriminant collisions, symbol_short! literals over the 9-character limit, several corrupted test files (missing # on #[test], an embedded control character, bare assert/assert_eq missing their !), and roughly 329 call sites across 38 test files using a stale signature for rebalance/set_protocol_apy/rebalance_multi/harvest/submit_mev_report/submit_apy_prediction that predates those functions' agent parameter. All of this is fixed (first 2 commits), restoring the crate to a buildable, green baseline. None of it is new functionality.
  • 5 tests are marked #[ignore] with detailed doc comments: 3 pre-existing (confirmed via isolated reproduction) hit a soroban-sdk 21.7.7 limitation where Env::drop's own snapshot-building can panic during test-process cleanup after enough operations accumulate in one Env, aborting the whole test binary rather than failing just that test. This is an SDK/test-harness limitation, not a vault bug.

Rebase onto current main (post-review update)

This branch was rebased onto main after upstream landed substantial independent work (withdrawal-queue fulfillment, pause/circuit-breaker matrix tests, a hot-standby agent-key rotation feature, CI security fixes, a two-step agent-update timelock, and more). Beyond mechanical conflict resolution, the rebase surfaced and fixed:

  • A real contract bug in minimum_out_for_leg: slippage_bps == 0 (the documented "no minimum-output check" sentinel used throughout rebalance/harvest's public API) was computing expected * (10000 - 0) / 10000 == expected, i.e. silently demanding the exact full amount with zero tolerance instead of "no check." This made require_min_out panic (MinOutNotMet) on any partial fill or incomplete protocol exit whenever a caller legitimately passed min_out=0, defeating the contract's own documented graceful-failure paths (the abort-and-emit-RebalanceFailedEvent behavior on incomplete exit, and harvest's circuit-breaker accounting). Fixed: minimum_out_for_leg now returns 0 immediately when slippage_bps == 0. Verified against every test that legitimately expects MinOutNotMet with a nonzero min_out; none are affected.
  • initialize's zero-address checks used raw panic!(...) instead of panic_with_error!, so #[should_panic(expected = "Error(Contract, #64)")]-style tests could never match (the dedicated VaultError variants DeployerCannotBeZeroAddress/OwnerCannotBeZeroAddress/AgentCannotBeZeroAddress/UsdcTokenCannotBeZeroAddress already existed but were never wired up). Fixed to use them.
  • Several large-ledger-jump tests (set_sequence_number teleporting past a 24h timelock) were missing the TTL-keepalive bump (min_persistent_entry_ttl/max_entry_ttl) that sibling test files already use, causing a sandbox-only "archived storage" panic unrelated to real network behavior. Added the same keepalive pattern those sibling files already establish.
  • A handful of tests were stale against main's now-two-step update_agent → confirm_agent_update timelock (added upstream after this branch originally forked) and asserted the old single-step behavior; updated to exercise the real two-step flow.
  • Several proptest/invariant tests encoded accounting assumptions (mock Blend ledger semantics, ceil/floor rounding bounds, a withdrawal-queue/pause/circuit-breaker fixture that exceeded the vault's own per-tx deposit cap) that didn't match the contract's actual, intentional behavior; fixed the tests to match documented behavior rather than loosen the contract.

Two suspected real bugs found but deliberately left unfixed, pending a maintainer decision (not in scope for #985-988, and risky to guess at without sign-off):

  1. queue_withdrawal never sums previously-queued-but-unfulfilled amounts against a user's share balance, and never checks LockedShares at all — a user can queue overlapping withdrawal requests or queue past a committed lock. process_withdrawal_queue's per-request fulfillment doesn't guard against this either; burn_and_pay panics (not a Result) on insufficient shares, which would abort the entire batch, not just the offending request. Reproduced in test_withdrawal_queue.rs (test_cannot_over_queue_withdrawals, test_cancel_unreserves_shares, test_cannot_queue_locked_shares — left failing/undisturbed as a reproduction).
  2. set_blend_pool/set_dex_pool rotate the pool address without forcing an exit from the old pool first, stranding deployed funds — and this is already pinned as intentional by test_dex_integration.rs::test_set_dex_pool_rotation_while_deployed_strands_funds's own doc comment. Yet test_pool_rotation_with_deployed_funds.rs has 3 tests written against the opposite assumption (issue Add test for pool-address rotation while funds are deployed (fund-stranding scenario) #383: "the vault must ensure the old pool is fully exited") that were apparently never implemented. These two parts of the test suite directly contradict each other about intended behavior; left both sides untouched pending a decision on which one is correct.

Remaining pre-existing failures (34, all confirmed byte-identical to main modulo this PR's own agent-parameter/setup-API changes, and unrelated to #985-988): 9 in test_access_control.rs/test_limits.rs where the function under test takes no explicit caller parameter at all, so the test's "wrong caller should fail" premise can't be exercised under mock_all_auths(); a handful of TTL-decay and allowance-vs-expiry tests whose setup doesn't produce the state their assertions check; 3 CPU/memory benchmark-threshold tests that are environment/toolchain-sensitive; and the two suspected-bug clusters above.

Test plan

  • cargo build -p neurowealth-vault --tests: 0 errors.
  • cargo test -p neurowealth-vault: 891 passing, 34 pre-existing and unrelated failures (see disclosure above), 3 ignored with documented reasons.
  • cargo test --bin share_accounting_invariants -- -max_total_time=45 (fuzz target, run directly since cargo-fuzz's ASan flags need a nightly toolchain not available in this environment; this invokes the same libFuzzer binary without them): clean, thousands of iterations, no crash.
  • cargo fmt --all and cargo build -p neurowealth-vault --tests: clean on every file this PR touches.

…ture

The vault crate and its test suite had ~460+ pre-existing compile errors
unrelated to issues Neurowealth#985-988: duplicate DataKey/BlendPoolClient
declarations, a DataKey enum over the 50-variant #[contracttype] limit,
VaultError discriminant collisions, several symbol_short! literals over
the 9-char limit, corrupted test files (missing # on #[test], an embedded
control character, bare assert/assert_eq missing their !), and ~329 call
sites across 38 test files using a stale 3-4 arg signature for
rebalance/set_protocol_apy/rebalance_multi/harvest/submit_mev_report/
submit_apy_prediction that predates those functions' agent parameter.

Also fixed: a stale mock Blend pool balance() signature replaced with
get_reserve/get_positions (matching the real Blend v2 ABI used by Neurowealth#986),
6 #[should_panic] assertions whose expected messages no longer matched
the real panic text/format, and 3 tests marked #[ignore] with documented
reasons where soroban-sdk 21.7.7's own Env::drop snapshot-building panics
during cleanup on sufficiently large operation counts (a host limitation,
not a vault bug).

None of this is new functionality; it restores the crate and its test
suite to a buildable, green baseline (814 passing, 91 pre-existing and
unrelated test failures, 3 ignored with documented reasons) so the actual
Pure formatting (line wrapping, stray blank lines, import ordering);
no logic changes. Caught while running rustfmt on the files touched for
the compile-break fix.
Remainder of the compile-break/test-repair pass (split from the main
commit by an oversight in staging): 3 more #[should_panic] assertions
updated from a legacy string format to the modern "Error(Contract, #N)"
format, and 2 tests marked #[ignore] for the same documented
soroban-sdk 21.7.7 Env::drop scale limitation as test_tvl_cap_stress.rs's
other 2 ignored tests.
…z coverage, pause semantics)

approval left on a protocol pool after a partial supply_to_blend or
supply_to_dex, closing the window where a stale non-zero allowance could
be drained by the pool contract later.

(verified against blend-capital/blend-contracts-v2). submit and
submit_with_allowance take 4 args (from, spender, to, requests), and
balance reads now go through get_reserve plus get_positions since Blend
has no plain balance(asset, user) function. The mock Blend pool in test
utils is updated to match.

lock_shares, unlock_shares, migrate_shares, emergency_withdraw,
queue_withdrawal, process_withdrawal_queue, and batch_deposit, plus 3 new
invariant checks (locked-shares bound, shares-index consistency, vault
solvency). This surfaced 5 real accounting bugs, all fixed here:

  - batch_deposit never updated TotalAssets or the active-share user
    index on a holder's first deposit through that path, understating
    the share price for every other holder and omitting the depositor
    from get_users_with_shares.
  - queue_withdrawal's shares-needed calculation used an unconditional
    "+ 1" instead of a true ceiling division, so requesting a user's
    exact full balance was spuriously rejected with
    InsufficientSharesForAmount.
  - emergency_withdraw could burn a user's locked shares (from
    lock_shares) during a pause, leaving LockedShares(user) greater than
    Shares(user) afterward; it now respects the same lock the regular
    withdraw path already did.
  - migrate_shares had no way to exclude a locked position (it always
    moves the user's entire balance), so it now blocks migration
    outright while any shares are locked, and defensively clamps
    LockedShares after any burn as a backstop.
  - migrate_shares's cross-contract leg was broken end to end: it
    transferred USDC directly into the target vault's balance, then
    called the target's public deposit(user, amount), which tries to
    pull that same amount from the user's own wallet a second time,
    something the user never held, since the funds moved vault to vault.
    Added a new receive_migration entry point that credits the already
    transferred funds instead, authorized via
    env.authorize_as_current_contract so only migrate_shares itself (not
    an arbitrary caller) can invoke it.

matching unpause()'s existing NotPaused guard, instead of silently
re-emitting VaultPausedEvent every time. emergency_pause() keeps its
existing no-op-when-already-paused behavior unchanged by design, since
an emergency responder racing another caller to pause the vault should
never be blocked by an error: the vault ends up paused either way. Both
functions' doc comments now spell out why the two conventions differ.

All new behavior is covered by dedicated tests (test_allowance_hygiene.rs,
plus migrate_shares, queue_withdrawal, batch_deposit, and
emergency_withdraw regression tests, and a pause vs emergency_pause
asymmetry test), and the full suite plus the extended fuzz target pass
with no regressions: 814 passing, 91 pre-existing and unrelated
failures, 3 ignored, unchanged from before these fixes.
@Wycode569
Wycode569 force-pushed the fix/neurowealth-princess-batch branch from 5f9625b to b023452 Compare October 18, 2026 05:53
@drips-wave

drips-wave Bot commented Oct 18, 2026

Copy link
Copy Markdown

@Wycode569 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@robertocarlous
robertocarlous merged commit 2cddc8c into Neurowealth:main Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment