Repository navigation
fix(cash): keep a module's authority over its own pending withdrawal after de-whitelisting - #350
Draft
simonedamico wants to merge 5 commits into
Draft
simonedamico wants to merge 5 commits into
simonedamico wants to merge 5 commits into
Conversation
…after de-whitelisting CashModule decided who may process or cancel a module's pending withdrawal from current whitelist membership. Once governance removed the module from the withdraw-requester list or the DataProvider whitelist, anyone could process the hold into the module (bypassing the module's bookkeeping, which strands funds in an escrow like PaymentLinkModule), the module could no longer cancel it, and its cancelBridgeByCashModule callback was skipped. requestWithdrawalByModule now records the request as the module's own (SafeCashConfig.withdrawalRequestedByModule, appended at slot 17 offset 15, packed after usesLendGateway). Processing, owner cancel and module cancel read that record, so removal only blocks new requests. Requests without the record keep the current-membership rules. On cancel, a module still on both whitelists is called as before. A removed module whose request carries the record is still notified, through a call capped at 200k gas whose result and return data are ignored, so a broken or retired module cannot block spends, liquidations or the owners' replacement request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…telist CashModule-level tests with a mock module (both removal orders and single-list removals, reverting and gas-burning callbacks, the record's slot and lifecycle, requests without the record) and StockWithdrawModule tests for both removal orders. The existing test that pinned the old DataProvider-removal revert now asserts the module can still cancel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local CashModuleCore and CashModuleSetters now carry the per-request module withdrawal record, so they no longer match the deployed implementations (both checks pass on master). Same pattern as the EtherFiSafeFactory skip: re-enable once the CashModule upgrade is live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ut gas starvation From the Codex review: the StockWithdrawModule callback gas check now places the order in setUp, so the callback deletes persisted storage in a later transaction (68,160 gas against the 200k cap). A new test binary-searches the outer gas limit of a relayed owner replacement and requires every successful probe to have delivered the capped callback. The natspec now says plainly that a removed module's callback still runs; it can only no longer block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le callback From the second Codex pass: a binary search alone assumes success is monotonic in gas. Both starvation tests now also sweep every outer gas limit from far below the cheapest successful call to 100k above it in 1k steps, and require each success to have delivered the callback. The StockWithdrawModule version runs against the real module with the order and its removal persisted in setUp. A 40k cap mutation fails both StockWithdrawModule callback tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Verification Results
- Group ID: a49ffa13-1154-48c4-a1a7-773e978cb63f
- Commit: 551fc55
| Job | Result | VERIFIED |
|---|---|---|
| EtherFiSafe.… | ✅ | 14 |
| CashModuleCo… | ✅ | 2 |
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.
Risk this closes
When governance removed a withdraw-requesting module from CashModule's withdraw-requester list or from the DataProvider whitelist while one of its withdrawal holds was pending, CashModule stopped treating that hold as the module's:
processWithdrawalthe hold into the module, skipping the module's own bookkeeping. For PaymentLinkModule (draft [PAY-1170] Add PaymentLinkModule and deploy scripts #349) the sender's funds land as unrecorded escrow surplus, recoverable only partly byrescueSurpluswhen the token is in a shortfall;cancelWithdrawalByModuleits own hold;cancelOldWithdrawalskipped thecancelBridgeByCashModulecallback, so the module's record went stale.This affects StockWithdrawModule today and PaymentLinkModule once deployed. The Codex review of #349 found it; #349 mitigated it with
rescueSurplusand a governance rule (never de-whitelist with liveRequestedlinks), and left the real fix to CashModule. This PR is that fix. The retirement preflight inRetireSupersededModules.s.solguards the same hazard operationally ("processWithdrawal is open to anyone").Change
requestWithdrawalByModulerecords the request as the module's own:SafeCashConfig.withdrawalRequestedByModule, set in_requestWithdrawal, cleared wherever the pending request is deleted (process, cancel).CashLendLib.isModuleWithdrawal(safe)= record, or recipient on the requester list (the second half keeps today's rules for requests without the record)._processWithdrawal, ownercancelWithdrawalandcancelWithdrawalByModuleuse it, so removal blocks new requests only:cancelWithdrawalByModulenow applies only to requests without the record);cancelWithdrawala module's request directly (unchanged rule); they cancel through the module, replace it with their ownrequestWithdrawal, or a spend cancels it.cancelOldWithdrawal:_notifyRemovedModule, a call capped at 200k gas whose result is ignored and whose return data is not copied. A removed module may be broken or retired to a reverting implementation, and spends,preLiquidateand owner replacements all cancel through here, so it must not be able to block them. Removing a module from the DataProvider used to stop its callback from running at all; now the callback still runs (with only the authority the module already holds over its own request) but can no longer block the cancel. Measured in a later transaction than the request: StockWithdrawModule's callback 68,160 gas, PaymentLinkModule's 46,190 gas; tests keep both under half the cap. Whoever submits a cancel picks its gas limit but cannot starve the callback while the cancel succeeds: starving it leaves the outer call 1/64 of the gas, too little to finish. Tests sweep the outer gas limit of a relayed owner replacement in 1k steps from far below the cheapest successful call to 100k above it, against a mock and against the real StockWithdrawModule (and PaymentLinkModule, see below), and require every success to have delivered the callback.Requests placed before the upgrade carry no record and keep the current-membership rules until they clear, so the "no live holds" preflight still applies to modules removed while such holds exist.
Storage layout (deploy-proxy rule 5)
Both impls keep everything in ERC-7201 namespaces;
forge inspect … storageLayoutofCashModuleCoreandCashModuleSettersis empty before and after. The namespacedCashModuleStoragewas diffed through a probe contract holding it as a plain state variable (forge inspect, compiler AST ids stripped), master vs this branch:t_struct(SafeCashConfig) numberOfBytes=576 ... slot 17 off 0 incomingMode t_enum(Mode) slot 17 off 1 lendOptedOut t_bool slot 17 off 2 lendOptOutFinalizeTime t_uint96 slot 17 off 14 usesLendGateway t_bool + slot 17 off 15 withdrawalRequestedByModule t_boolNo other line changes: one bool appended at the end of
SafeCashConfig(a mapping value), packed into its existing last slot, struct size unchanged,WithdrawalRequestandSafeData(thegetDataABI) untouched.test_record_packsIntoSafeCashConfigTailSlotpins the slot and offset and checks that writing the record leavesusesLendGatewayintact.Upgrade path (not done here; nothing deployed)
CashLendLibis a linked library, so as inDeployCashRepayFixProd.s.sol: deploy a newCashLendLib(forge links it) and newCashModuleCoreandCashModuleSettersimplementations via CREATE3 with fresh salts, then a 2-day upgrade-timelock batch:CashModule.upgradeToAndCall(core, "")andsetCashModuleSettersAddress(setters), with a verify script per the deploy-proxy rules. Both calls must land in the same batch, with both impls linked to the new library: a mixed old/new pair can create requests without the record or leave a stale one. Holds placed before the upgrade carry no record, so a module should be removed only once its pre-upgrade holds have cleared (the existing preflight check).test_verifyBytecode_CashModuleCore/_CashModuleSettersinVerifyOPMainnet.t.solare skipped (both pass on master) and should be re-enabled once that upgrade is live, the same way as theEtherFiSafeFactoryskip.Size: CashModuleCore runtime 23,806 → 23,883 B (693 B margin), CashModuleSetters 23,130 → 23,226 B.
Tests
New
test/safe/modules/cash/ModuleWithdrawalAuthority.t.sol(mock module, 29 tests): requester list only, DataProvider only and both orders for processing and module cancel; owners' direct cancel still rejected; owner replacement and card spend notify the removed module; reverting callbacks cannot block replacement, spends orpreLiquidate; a gas-burning callback is capped; the outer gas limit cannot starve the callback; a whitelisted module's reverting callback still reverts the cancel; record lifecycle (process, cancel, zero delay, owner request) and slot; requests without the record follow today's rules.test/stock-withdraw/StockWithdrawModule.t.sol: both removal orders, only the module processes the hold andexecuteWithdrawalstill bridges; the expired-order release and the owners' replacement clear the order in both orders; requester-list removal blocks new requests and keeps the owner-signed cancel working. A separate contract with the order and the removal persisted insetUpchecks the callback fits the cap and runs the gas-limit sweep (the shared setup and helpers moved into an abstractStockWithdrawModuleTestBase). Mutations: a 20k cap fails 4 tests in the authority suite, a 40k cap fails both StockWithdrawModule callback tests.Withdrawals.t.sol: the test that pinned the old DataProvider-removal revert now asserts the module can still cancel.Run locally on an Optimism fork (
TEST_CHAIN=10 ENV=mainnet TEST_RPC=https://optimism-rpc.publicnode.com, plusOPTIMISM_RPCfor the suites that read it,-j 4):test/safe/modules/cash/**(default profile): 452 passed, 0 failedtest/stock-withdraw/StockWithdrawModule.t.sol: 59 passedFOUNDRY_PROFILE=lend,test/safe/modules/cash/lend/{CashLendOptOut,StargateGateway,EnsoSwapGateway,AcrossSwapGateway,Spend,RepaySourcing}.t.sol: 131 passedtest/upgrade-bytecode-verification/VerifyOPMainnet.t.sol: 40 passed, 4 skipped (2 existing, 2 above)Not run locally: the rest of the
restanddeployedCI shards and the otherlendsuites.PaymentLinkModule (#349)
#349's tests pin the old CashModule behaviour (third-party processing works, DataProvider-removed cancel reverts), so they fail against this change. Branch
claude/plm-tests-on-cashmodule-fix(no PR) is #349 with this branch merged in andtest/payment-link/PaymentLinkModule.t.solupdated: both removal orders and single-list removals (no third-party processing,fundLinkandclaimwork, owners'cancelreleases the hold, card spends and owner withdrawals still deliver the callback, new links blocked, callback fits the cap and survives the gas-limit sweep with the hold persisted insetUp). The tests that used de-whitelisting to make CashModule skip the callback, and the three hazard tests, now model a hold placed before the upgrade (no record), where the old rules and #349's governance rule still apply.test/payment-link/*there: 101 passed. Whichever of #349 and this PR merges second carries those test changes. #349's governance-rule docs stay correct for pre-upgrade holds; they can be scoped down once this upgrade is live.Review
Codex (
codex exec -s read-only, adversarial), two passes: READY both times, no Blocking findings.reconcile). Not fixed here (module change, below). Before this PR the callback was always skipped after a DataProvider removal, so the record was always stranded then.Follow-ups (not in this PR)
cancelWithdrawaltakes its nonce fromsafe.useNonce(), which only serves enabled modules, so after a DataProvider removal its owners usecancelExpiredWithdrawalor a replacement withdrawal instead (tested).reconcile(clear an order whose CashModule hold is gone), so a missed callback cannot strand the order.VerifyOPMainnetCashModule checks and scope [PAY-1170] Add PaymentLinkModule and deploy scripts #349's governance-rule docs to pre-upgrade holds.🤖 Generated with Claude Code
Note
Medium Risk
Changes CashModule withdrawal authorization and introduces capped external callbacks to de-whitelisted modules; incorrect logic could strand or mis-route reserved funds, though behavior is heavily tested and scoped to module-initiated holds.
Overview
Fixes a gap where de-whitelisting a withdraw-requesting module (from CashModule’s requester list or DataProvider) could strip it of control over an already pending hold: third parties could
processWithdrawalinto the module, the module could notcancelWithdrawalByModule, andcancelBridgeByCashModulewas skipped.requestWithdrawalByModulenow sets a per-request flagwithdrawalRequestedByModuleonSafeCashConfig(packed into the existing tail slot).CashLendLib.isModuleWithdrawaltreats a hold as module-owned when that flag is set or when the recipient is still on the requester list (legacy / pre-upgrade requests). Process, owner cancel, and module cancel paths use this helper so removal only blocks new module requests, not lifecycle of recorded holds.When a competing cancel clears a module hold,
cancelOldWithdrawalstill requires a whitelisted module’s callback to succeed; for removed modules with the record,_notifyRemovedModuleinvokescancelBridgeByCashModulewith a 200k gas cap and ignores success/revert so spends, liquidations, and owner replacement withdrawals cannot be blocked. The flag is cleared whenever the pending request is deleted.Adds
ModuleWithdrawalAuthority.t.sol, extends StockWithdrawModule and Withdrawals tests, and temporarily skips OP bytecode verification for CashModule impls until upgrade.Reviewed by Cursor Bugbot for commit 551fc55. Bugbot is set up for automated code reviews on this repo. Configure here.
Need help on this PR? Tag
@codesmith-botwith what you need.