Repository navigation
fix(hooks): [SUP-21367] honour usePrevHookAmount in ApproveERC20Hook; stop value-moving HyperCore leaves forwarding - #1029
Conversation
…ports onward
`_preExecute` gated on `prevHook != address(0)` alone, so mid-chain it ALWAYS delegated to
`BaseHook`'s PASSTHROUGH. `usePrevHookAmount` therefore only ever reached the allowance in
`_buildHookExecutions` -- from a downstream consumer's point of view the flag was ignored.
if (prevHook != address(0)) { // before
if (prevHook != address(0) && _decodeBool(data, USE_PREV_HOOK_AMOUNT_POSITION)) { // after
The rule is now uniform: the hook reports whatever allowance it set.
usePrevHookAmount = true allowance = prev's outAmount, and that value is forwarded
usePrevHookAmount = false allowance = this hook's own amount, and THAT is reported
Position 0 already behaved the second way, so this also removes a positional inconsistency: the
same hook with the same flag no longer reports different kinds of thing depending on where it sits
in the chain.
WHAT THIS FIXES. A chain whose SOURCE produces no output no longer silently zeroes. A multi-token
`BatchTransferHook` reports 0 -- it has no single (amount, token) output, and `decodeAmounts()` /
`amountRoles()` are empty by design -- and 0 is indistinguishable from a real zero. So
`batchTransfer -> approve(usePrev=false) -> swap(usePrev=true)` swapped nothing, and setting
`usePrev=false` on the approve did not help because the flag never reached the pipe.
THE TRADE-OFF, deliberate and pinned by a test. An allowance is an upper bound, not a quantity
held, so a downstream consumer can now inherit a figure the account does not have:
`swap(receives 777) -> approve(1000) -> deposit(usePrev=true)` now carries 1000 and reverts on the
shortfall, where before it carried 777 and worked. Chains with a generous approval cap mid-chain
followed by a `usePrev=true` consumer are the blast radius and should be grepped in OMS before this
ships. `test_..._AllowanceAboveBalanceIsCallersProblem` records the choice so a future change to it
is a conscious one.
Six tests added. None existed for this: the two prior `getOutAmount` assertions only called
`postExecute`, so the behaviour under debate was untested and this change would have passed CI
silently either way.
Bytecode moved, so all three artifacts are regenerated. Same deploy name -- the CREATE2 address is
keccak256(0xff ++ deployer ++ salt ++ keccak256(initcode)), so new code lands at a new address under
the unchanged salt, and the old address stays live for roots already signed against it (same pattern
as SUP-21143's LOAN hook re-pin). New addresses:
PROD1.0.0 0x17DdFE8988cF71BBd672e5daFc4f6D02Cf279fA4
STAGING1.0.0 0x0cEE1a19c3d099f16626678501f4D0F0ACA2F91D
NOT CHANGED, and why: the HyperCore hooks. `HyperCoreSendAssetHook` and
`HyperCoreUsdClassTransferHook` are PASSTHROUGH and move value, so they look like the same problem,
but they cannot report an output at all. Their amounts (`amountWei`, `ntl`) are uint64 HyperCore
units which are not the scale of an EVM token amount -- both hooks already document that this is
exactly why they omit `usePrevHookAmount` -- and `BaseHyperCoreWriterHook` records that CoreWriter
"never reverts, never validates the action id, and never reports", so there is no EVM-observable
delta to measure instead. Adding the flag to them would only let them suppress forwarding while
still having nothing of their own to publish, which returns a downstream `usePrev=true` to 0. Their
case needs the chain-level fix (make "no output" distinct from 0 and revert when consumed), not a
per-hook one.
test/unit/hooks/* 3713, accounting 1312, executors 54, SuperExecutor 11, validators 59, script 35,
invariant 29 -- all green. Nothing in the suite depended on the old behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157RUiyLFJjzULEEq6h6biV
Why the HyperCore half of SUP-21367 is not in this PRThe ticket covers "approve and hypercore hooks". I looked at the HyperCore ones and concluded they need no change — not as a deferral, but because the fix applied here is inapplicable to them and the fix I originally proposed turns out to be impossible. Recording the reasoning so it doesn't get re-litigated. First, only two of the four move value at all
1. They have no
|
subhasishgoswami
left a comment
There was a problem hiding this comment.
This fixes the Bundler incident's approval-output failure: with usePrevHookAmount=false, approval reports its configured amount and token, allowing the following dynamic Kyber swap to consume the adjusted amount.
Verified that the PR's compiled runtime, after immutable substitution, is byte-for-byte identical to the runtime used in the successful historical replay at Base block 52307672. The unchanged control reproduced ZERO_AMOUNT; changing only approval behavior completed both swaps, the combined vault deposit, and the final transfers to the owner. This validates the captured destination build simulation, before later fee-hook setup.
Successful replay: https://dashboard.tenderly.co/superform/v2/simulator/76393756-4600-4eb3-88f8-2c7904845e7c
Control: https://dashboard.tenderly.co/superform/v2/simulator/3dab580b-bcce-471a-9987-fc729647b4f2
No executor scaling or HyperCore change is needed for this incident. Rollout still requires deploying the corrected hook and updating Bundler registry selection/cache while preserving old hook identities. Build and manifest checks have passed; the test job was still running when reviewed.
…ing the previous hook's output
`Hook1 -> HyperCoreSendAssetHook -> Hook3(usePrevHookAmount = true)` had Hook3 operate on HOOK1's
amount. Nothing looked wrong: the number was plausible, just stale -- SendAsset had moved the assets
onto HyperCore in between, so the figure described a balance the account no longer had.
The cause was not the missing `usePrevHookAmount` field. It was `_pipeMode()`. `PASSTHROUGH` is a
CLAIM -- "the upstream amount still describes the value in flight" -- and `BaseHyperCoreWriterHook`
made that claim for every leaf and locked it non-virtual ("no leaf may regress to TRANSFORM"). True
for a leaf that changes no balance; false for one that moves assets.
So `_pipeMode()` is virtual again, `PASSTHROUGH` stays the base default, and the two value-moving
leaves override `TRANSFORM`:
HyperCoreSendAssetHook TRANSFORM sends a spot balance away from the account
HyperCoreUsdClassTransferHook TRANSFORM moves USD between spot and perp classes
HyperCoreAddApiWalletHook PASSTHROUGH unchanged -- registers an agent, moves nothing
HyperCoreApproveBuilderFeeHook PASSTHROUGH unchanged -- a fee rate, moves nothing
`TRANSFORM` makes `BaseHook._preExecute` write nothing, and these two publish nothing of their own
because there is nothing publishable: their amounts (`amountWei`, `ntl`) are uint64 HyperCore units
offset per token by weiDecimals/evmExtraWeiDecimals, so they can never be an EVM-scale `outAmount`,
and `BaseHyperCoreWriterHook` records that CoreWriter "never reverts, never validates the action id,
and never reports", so there is no EVM-observable delta to measure instead.
A downstream consumer therefore reads 0 and FAILS rather than proceeding on a stale figure --
`Swap1InchHook`, `Deposit4626VaultHook`, `ApproveAndDeposit4626VaultHook`, `AaveV4LendHook` and
`TransferERC20Hook` all guard `amount == 0`. Failing is the correct outcome: no hook after these two
can legitimately derive its amount from them, so the chain shape is unsupportable and should say so.
WHY THIS IS CHEAP. `_pipeMode()` is `internal pure` and inlined -- it is not in the calldata, not in
storage, not in the hook's data layout. No new field, no data-length change, no inspector/template/
manifest update, no signed roots invalidated. Only bytecode moves, and only for the two leaves that
changed: adding `virtual` to the base left `AddApiWallet`, `ApproveBuilderFee` and
`ApproveAndHyperCoreDeposit` byte-identical, so they keep their live addresses.
deployed (HyperEVM 999) new (PROD1.0.0)
HyperCoreSendAssetHook 0x65aea557e595b9e841Da9F47aae8EacC4650Ea6a 0x541ce604cED66dFb2FE0F2Cc951Bffb3f36eEd7D
HyperCoreUsdClassTransferHook 0x0E4eDd421D0707A454C3c0435DeCF5D708A8b29C 0xa02f963Af997032D9b5bdaF42BC5A1Db15e1c57C
Staging: 0x035799A5871634F7B24562804273B139Aadb5AE6 and 0xDef5387D00384B5a65a50f7B07ed6455b4F0B93B.
One existing test pinned the old behaviour and is updated rather than deleted:
`test_Passthrough_ForwardsPrevHookOutput` made its assertion with `classTransfer`, which now moves
value; it asserts the same thing with `addAgent`, which does not. Two tests added --
`test_PipeMode_ValueMovingLeavesDoNotForward` for the fix, and
`test_PipeMode_SideEffectLeavesStillForward` so the split is pinned as deliberate and a chain
legitimately relying on transparency through the other leaves keeps working.
Also closes a tooling gap found on the way: the HyperCore family was missing from
`regenerate_bytecode.sh` entirely, so a source change to any of these five left the artifacts stale
and a deploy would have shipped the previous bytecode. Added as its own group.
test/unit/hooks/* 3715, accounting 1312, executors 54, script 35 -- all green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157RUiyLFJjzULEEq6h6biV
…nstead of using a stale amount
The previous commit asserted what the two value-moving leaves REPORT. This asserts what a real
consumer then DOES, which is the actual reported scenario:
Hook1 (outputs 1234) -> HyperCoreSendAssetHook -> TransferERC20Hook(usePrevHookAmount = true)
`TransferERC20Hook` is a production hook, not a stub: it reads `prevHook.getOutAmount` when the flag
is set and reverts `AMOUNT_NOT_VALID` on zero. Before the pipe-mode fix it received Hook1's 1234 and
would have transferred that much -- an amount that predates the HyperCore send. It now receives 0 and
refuses, which is correct: nothing after `SendAsset` can legitimately derive its amount from it.
The second half is the control, and it is what makes the first half mean something. The same
consumer, the same Hook1, routed through `HyperCoreAddApiWalletHook` instead, still receives 1234 and
builds. So the change is scoped to leaves that move value rather than having broken chaining through
the CoreWriter family generally.
Artifacts were verified unchanged while adding this: the four HyperCore JSONs re-emitted by
`regenerate_bytecode.sh` differ from HEAD only in build metadata (`id`, `metadata`, `sourceMap`), and
`bytecode.object` -- the only field `vm.getCode` and CREATE2 consume -- is byte-identical, so nothing
is re-committed. All five HyperCore artifacts and all three ApproveERC20Hook artifacts match a fresh
compile in both `generated-bytecode/` and `locked-bytecode/`.
test/unit/hooks/* 3716, all green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157RUiyLFJjzULEEq6h6biV
…inned hooks Predicted CREATE2 addresses for the contracts whose bytecode moved. 19 files: the 18 prod chain records plus `prod/latest.json`. Staging deliberately untouched. AaveV4LendHook 0x51a2C8c6c427f5EB14b0dF826F87D518c8fFfEa0 -> 0xD39deF3542B33d5b309A8D4e69D7a8075e736Fd5 AaveV4RedeemHook 0x320Fa43658093BA332293AA85D86608fb0ffFEbA -> 0x06fD9C9272a343aab5919fA3041D0a763E3cB4cC AaveV4ReserveOracle 0x1Cc0C873Ac9397D5e0aA301B2f594A95E8d573bC -> 0xFC6a204A1E9bFde616654F7305C978550B59630c ApproveERC20Hook 0x1851A98471ADE4a115B6FB7bd42934a200e58d9E -> 0x17DdFE8988cF71BBd672e5daFc4f6D02Cf279fA4 HyperCoreSendAssetHook 0x65aea557e595b9e841Da9F47aae8EacC4650Ea6a -> 0x90dc9d2BB3cFF8E3b228F4a48Bb9678eCF52CD2E HyperCoreUsdClassTransferHook 0x0E4eDd421D0707A454C3c0435DeCF5D708A8b29C -> 0x50c925F20993E0A1C91623df444b226417866DEE ApproveERC20Hook and the two HyperCore hooks move because of this PR. AaveV4LendHook, AaveV4RedeemHook and AaveV4ReserveOracle move because this branch sits on dev post-#1026, which re-pinned them; their records had not caught up. Applied as a value-based replacement, so each file changed only where the old address actually appeared: 4 contracts on most chains, 1 on Base (which does not carry the Aave trio at those addresses), 6 on HyperEVM, and the aggregate in `latest.json` (Aave x17, ApproveERC20Hook x18). All 19 files re-validated as JSON and no old address remains anywhere under `script/output/prod/`. Note on the HyperCore addresses: these hooks take a `coreWriter_` constructor argument, so their CREATE2 initcode is `bytecode ++ abi.encode(coreWriter)`. Earlier figures quoted in fa59e4d's message and in the PR body were computed from the bare bytecode and are WRONG (0x541ce604... and 0xa02f963A...). The addresses above are correct and match what the deploy script itself computes; ApproveERC20Hook takes no constructor argument, so its figure was already right. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157RUiyLFJjzULEEq6h6biV
…e-pinned hooks Staging counterpart to the prod records. 10 files: 9 staging chain records plus `staging/latest.json`. AaveV4LendHook 0xc8a7E20Cb33aF34345EDF32104F2Ea0A8e46281B -> 0xeb1a79E3a43A13833A3A3D42Cb567E07763D9478 AaveV4RedeemHook 0xcfBAef145343c549a823E2621C4dd205483264BF -> 0x6a8b9495278Af714d5287A8Aa0779Cd6f91cf5f5 HyperCoreSendAssetHook 0xeE0ef54d1BAA59aA1786AaA37B5F38D2D7e26193 -> 0x614dbD83E62F4eC7DEA965125f11AeA306ACdDAC HyperCoreUsdClassTransferHook 0x66194363DDeA94e301D8A128Bb0a1f4E3c605623 -> 0x20A19737c9D2ee8a2F5AC5221A02eA557e5b3491 Same value-based replacement as the prod commit, so each file changed only where the old address appeared: 2 contracts on each of Ethereum, BNB, Arbitrum, Avalanche, Flare, RH, Arc and Plataberget, 4 on HyperEVM, and the aggregate in `latest.json` (Aave x9 each). All 10 re-validated as JSON, with no old address left anywhere under `script/output/staging/`. Two differences from prod, both intentional and worth noting for review: - No `AaveV4ReserveOracle` entry. Staging does not carry it at the address prod moved from. - No `ApproveERC20Hook` entry. Its staging bytecode moved in this PR too, so its staging address will change on redeploy (predicted 0x0cEE1a19c3d099f16626678501f4D0F0ACA2F91D under STAGING1.0.0), but it is not in the provided change set, so it is left alone rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157RUiyLFJjzULEEq6h6biV
Closes SUP-21367.
Origin: the Slack thread on
BatchTransferHook → ApproveERC20Hook → swapproducing a 0-amount swap. Covers both halves of the ticket — but with different fixes, because they turned out to be different defects. The approve hook needed its flag honoured; the HyperCore hooks needed their_pipeMode()corrected.The bug, in one line
ApproveERC20Hook._preExecutegated onprevHook != address(0)alone, so mid-chain it always delegated toBaseHook'sPASSTHROUGH.usePrevHookAmounttherefore only ever reached the allowance in_buildHookExecutions— from a downstream consumer's point of view the flag was ignored.Measured before the change, to show the flag really had no effect on what was reported onward:
usePrevThe rule now
The hook reports whatever allowance it set.
usePrevHookAmountgoverns both halves consistently:true→ allowance is the previous hook'soutAmount, and that same value is forwardedfalse→ allowance is this hook's ownamount, and that is reported onwardPosition 0 already behaved the second way, so this also removes a positional inconsistency: the same hook with the same flag no longer reports different kinds of thing depending on where it sits.
What it fixes
A chain whose source produces no output no longer silently zeroes. A multi-token
BatchTransferHookreports0— it has no single(amount, token)output, and itsdecodeAmounts()/amountRoles()are empty by design — and0is indistinguishable from a real zero. SobatchTransfer → approve(usePrev=false) → swap(usePrev=true)swapped nothing, and settingusePrev=falseon the approve did not help, because the flag never reached the pipe.Read this before approving — the trade-off is real
An allowance is an upper bound, not a quantity held. With
usePrev=false, a downstream consumer now inherits a figure the account may not actually have:Blast radius: any chain with a mid-chain approve at
usePrev=falsefollowed by ausePrev=trueconsumer, where the approve amount ≠ the upstream output. Approving a generous round-number cap is a normal pattern, so this wants grepping in OMS/app templates before it ships. I can't see those templates from the repo.test_PreExecute_UsePrevFalse_ReportsOwnAmount_AllowanceAboveBalanceIsCallersProblemrecords the choice deliberately, so a future change back to 777 is a conscious reversal rather than an accident.Tests
Six added. None existed for this before — the two prior
getOutAmountassertions only callpostExecute, so the behaviour everyone was debating was untested, and this change would have passed CI silently in either direction.test/unit/hooks/*3716 · accounting 1312 · executors 54 · SuperExecutor 11 · validators 59 · script 35 · invariant 29 — all green.Deployment
Bytecode moved, so all three artifacts are regenerated. Same deploy name: the CREATE2 address is
keccak256(0xff ++ deployer ++ salt ++ keccak256(initcode)), so new code lands at a new address under the unchanged salt, and the old address stays live for roots already signed against it — the same pattern as SUP-21143's LOAN hook re-pin.PROD1.0.00x17DdFE8988cF71BBd672e5daFc4f6D02Cf279fA4STAGING1.0.00x0cEE1a19c3d099f16626678501f4D0F0ACA2F91DTwo operational notes:
__deployContractIfNeededskips a name already recorded inscript/output/.../latest.json, so those records need updating or the deploy no-ops; andmanifests/hooks.jsoncarries per-chain addresses for this hook, which regenerate from the deployment output afterwards (unchanged by this PR).Part 2 — HyperCore: the leaves that move value stop forwarding
Same ticket, different defect.
Hook1 → HyperCoreSendAssetHook → Hook3(usePrevHookAmount = true)had Hook3 operate on Hook1's amount. Nothing looked wrong — the number was plausible, just stale, because SendAsset had moved the assets onto HyperCore in between.The cause was not the missing
usePrevHookAmountfield. It was_pipeMode().PASSTHROUGHis a claim — "the upstream amount still describes the value in flight" — andBaseHyperCoreWriterHookmade it for every leaf and locked it non-virtual ("no leaf may regress to TRANSFORM"). True for a leaf that changes no balance; false for one that moves assets.So
_pipeMode()is virtual again,PASSTHROUGHstays the base default, and only the two value-movers override:HyperCoreSendAssetHookHyperCoreUsdClassTransferHookHyperCoreAddApiWalletHookPASSTHROUGHHyperCoreApproveBuilderFeeHookPASSTHROUGHThese two publish nothing of their own, because there is nothing publishable: their amounts (
amountWei,ntl) areuint64HyperCore units offset per token byweiDecimals/evmExtraWeiDecimals, andBaseHyperCoreWriterHookrecords that CoreWriter "never reverts, never validates the action id, and never reports". A downstream consumer therefore reads 0 and fails —Swap1InchHook,Deposit4626VaultHook,ApproveAndDeposit4626VaultHook,AaveV4LendHookandTransferERC20Hookall guardamount == 0. Failing is the right outcome: no hook after these two can legitimately derive its amount from them.Cheap, because
_pipeMode()isinternal pureand inlined — not in the calldata, not in storage, not in the data layout. No new field, no data-length change, no inspector/template/manifest update, no roots invalidated. And addingvirtualto the base left the other three leaves byte-identical, soAddApiWallet,ApproveBuilderFeeandApproveAndHyperCoreDepositkeep their live addresses. Only two move:PROD1.0.0)HyperCoreSendAssetHook0x65aea557e595b9e841Da9F47aae8EacC4650Ea6a0x541ce604cED66dFb2FE0F2Cc951Bffb3f36eEd7DHyperCoreUsdClassTransferHook0x0E4eDd421D0707A454C3c0435DeCF5D708A8b29C0xa02f963Af997032D9b5bdaF42BC5A1Db15e1c57CStaging:
0x035799A5871634F7B24562804273B139Aadb5AE6and0xDef5387D00384B5a65a50f7B07ed6455b4F0B93B.One existing test pinned the old behaviour and is updated rather than deleted —
test_Passthrough_ForwardsPrevHookOutputmade its assertion withclassTransfer, which now moves value; it asserts the same thing withaddAgent, which doesn't. Three tests added, includingtest_PipeMode_Chain_DownstreamFailsInsteadOfUsingAStaleAmount, which runs the reported chain withTransferERC20Hookas a real consumer and asserts it revertsAMOUNT_NOT_VALIDthroughSendAssetbut still builds throughAddApiWallet.Tooling gap closed on the way: the entire HyperCore family was missing from
regenerate_bytecode.sh, so a source change to any of those five left the artifacts stale and a deploy would have shipped the previous bytecode. Added as its own group.Known gap, deliberately not in scope
Six other hooks share the exact same shape —
PASSTHROUGHplus ausePrevHookAmountfield — and still ignore the flag for reporting:RecordPurchasePendlePTHook,RecordRedemptionPendlePTHook, and the four*AmortizedOracleHook/V2variants. They don't even override_preExecute, so they go straight toBaseHook's unconditional forward.I left them because their
usePrev=truepath is pinned to one specific predecessor (if (prevHook != APPROVED_PENDLE_PT_HOOK) revert PREV_HOOK_NOT_VALID(), plus operation/market/token checks), so forwarding is correct there by construction; theirusePrev=falsepath is documented as standalone, where there is no predecessor to forward from; and they record rather than move value, soptAmountmirrors PT thatPendlePTHookalready moved and already reported.The honest cost:
usePrevHookAmountnow governs onward reporting in approve but not in those six. Happy to extend it if we'd rather have uniformity — same one-line pattern, six more hooks re-pinned.🤖 Generated with Claude Code
https://claude.ai/code/session_0157RUiyLFJjzULEEq6h6biV