Repository navigation
fix(vault): allowance hygiene, Blend ABI correctness, fuzz coverage, pause semantics (#985-988) - #1035
Merged
robertocarlous merged 4 commits intoOct 19, 2026
Conversation
…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
force-pushed
the
fix/neurowealth-princess-batch
branch
from
October 18, 2026 05:53
5f9625b to
b023452
Compare
|
@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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 aftersupply_to_blend/supply_to_dexwhenever 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 intest_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_allowancewith the wrong argument count and reading balances via abalance(asset, user)function that does not exist on the real pool contract. Fixed against the real interface (verified viablend-capital/blend-contracts-v2):submit/submit_with_allowancenow pass 4 args:from, spender, to, requests.get_reserve(asset)(for the b-rate and reserve index) withget_positions(user)(for the b-token amount), matching how Blend actually exposes a position. Added full field-for-field mirrors ofReserveConfig/ReserveData/Reserve/Positions(#[contracttype]struct decoding requires an exact field count match, not just the fields a caller cares about).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.rsto drivelock_shares,unlock_shares,migrate_shares,emergency_withdraw,queue_withdrawal,process_withdrawal_queue, andbatch_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 coverstotal_assets). Running this surfaced:batch_depositnever updatedTotalAssetsor registered a first-time depositor in the active-share index, understating the share price for every other holder.queue_withdrawal's shares-needed calculation used an unconditional+ 1instead of a true ceiling division, so requesting a user's exact full balance was spuriously rejected.emergency_withdrawcould burn a user's locked shares during a pause (the regularwithdrawpath already protected locks;emergency_withdrawdid not).migrate_shareshad 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 onLockedSharesas a backstop against any other path hitting the same class of bug.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 publicdeposit(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 newreceive_migrationentry point on the receiving side that credits the already-transferred funds instead, authorized viaenv.authorize_as_current_contractso onlymigrate_sharesitself (not an arbitrary caller) can invoke it.Each bug has a dedicated regression test. The fuzz target itself needed a
MAX_OPS_PER_CASEcap (tuned down from 40 to 12 ops) and a smallerprocess_withdrawal_queuebatch size after hittingsoroban-sdk21.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-emittedVaultPausedEventon every call. It now rejects a repeat call with a newVaultError::AlreadyPaused, matchingunpause()'s existingNotPausedguard.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:
DataKeyenum over Soroban's 50-variant#[contracttype]limit,VaultErrordiscriminant collisions,symbol_short!literals over the 9-character limit, several corrupted test files (missing#on#[test], an embedded control character, bareassert/assert_eqmissing their!), and roughly 329 call sites across 38 test files using a stale signature forrebalance/set_protocol_apy/rebalance_multi/harvest/submit_mev_report/submit_apy_predictionthat predates those functions'agentparameter. All of this is fixed (first 2 commits), restoring the crate to a buildable, green baseline. None of it is new functionality.#[ignore]with detailed doc comments: 3 pre-existing (confirmed via isolated reproduction) hit asoroban-sdk21.7.7 limitation whereEnv::drop's own snapshot-building can panic during test-process cleanup after enough operations accumulate in oneEnv, 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
mainafter 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:minimum_out_for_leg:slippage_bps == 0(the documented "no minimum-output check" sentinel used throughoutrebalance/harvest's public API) was computingexpected * (10000 - 0) / 10000 == expected, i.e. silently demanding the exact full amount with zero tolerance instead of "no check." This maderequire_min_outpanic (MinOutNotMet) on any partial fill or incomplete protocol exit whenever a caller legitimately passedmin_out=0, defeating the contract's own documented graceful-failure paths (the abort-and-emit-RebalanceFailedEventbehavior on incomplete exit, and harvest's circuit-breaker accounting). Fixed:minimum_out_for_legnow returns0immediately whenslippage_bps == 0. Verified against every test that legitimately expectsMinOutNotMetwith a nonzeromin_out; none are affected.initialize's zero-address checks used rawpanic!(...)instead ofpanic_with_error!, so#[should_panic(expected = "Error(Contract, #64)")]-style tests could never match (the dedicatedVaultErrorvariantsDeployerCannotBeZeroAddress/OwnerCannotBeZeroAddress/AgentCannotBeZeroAddress/UsdcTokenCannotBeZeroAddressalready existed but were never wired up). Fixed to use them.set_sequence_numberteleporting 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.main's now-two-stepupdate_agent→confirm_agent_updatetimelock (added upstream after this branch originally forked) and asserted the old single-step behavior; updated to exercise the real two-step flow.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):
queue_withdrawalnever sums previously-queued-but-unfulfilled amounts against a user's share balance, and never checksLockedSharesat 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_paypanics (not aResult) on insufficient shares, which would abort the entire batch, not just the offending request. Reproduced intest_withdrawal_queue.rs(test_cannot_over_queue_withdrawals,test_cancel_unreserves_shares,test_cannot_queue_locked_shares— left failing/undisturbed as a reproduction).set_blend_pool/set_dex_poolrotate the pool address without forcing an exit from the old pool first, stranding deployed funds — and this is already pinned as intentional bytest_dex_integration.rs::test_set_dex_pool_rotation_while_deployed_strands_funds's own doc comment. Yettest_pool_rotation_with_deployed_funds.rshas 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
mainmodulo this PR's ownagent-parameter/setup-API changes, and unrelated to #985-988): 9 intest_access_control.rs/test_limits.rswhere the function under test takes no explicit caller parameter at all, so the test's "wrong caller should fail" premise can't be exercised undermock_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 sincecargo-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 --allandcargo build -p neurowealth-vault --tests: clean on every file this PR touches.