GiddyVaultV3 was exploited on Ethereum in transaction 0x5edb66a4c2ea55bba95d36d27713e3bb1c67c3c4199a8a1759e754c6f25482e5, mined on 2026-04-23 11:57:47 UTC. The root cause was an authorization-bypass bug in compound() signing: the vault validated a signature that covered only keccak256(swap.data) values, not the aggregator, fromToken, toToken, or amount fields actually executed on-chain. The attacker replayed valid compounding authorizations against attacker-controlled SwapInfo wrappers, redirected execution into a fake aggregator contract, and drained 3.531384449443560254 g(yb-tBTC), 6.935949844931976725 g(yb-cbBTC), and 6.274444990265641146 g(yb-WBTC) from the three affected strategies. A USD equivalent is not directly derivable from the supplied local artifacts alone, but the on-disk evidence shows that the attacker extracted 16.741779284641178125 BTC-denominated gauge shares and paid no flash-loan costs.
The primary vulnerable contract is the verified GiddyVaultV3 implementation at 0x5f0ad32c00641d1d2bb628ff341e0d4bb4494318, reached through the wallet proxies 0x9c247ccd24c23eddba399701cda24051ebf605b7 (tBTC), 0x51b9e3e9871247ed7c2f07539b99cb97ae99d080 (cbBTC), and 0x1d85e7bceac3605d469debe006b46e9062238e67 (WBTC). The trace shows each proxy resolving and delegating into the same implementation before executing compound() (for example, the tBTC path calls implementation() at trace index 4 and DELEGATECALLs into 0x5f0ad32c00641d1d2bb628ff341e0d4bb4494318 at trace index 5). The exploit then reaches the verified StrategyYieldBasis implementation at 0x6163b1820da41aaba4192305371278ae00bda459, which uses the shared verified library GiddyLibraryV3 to perform the attacker-directed swap.
The externally reached vulnerable entry point is compound((bytes,bytes32,uint256,uint256,(address,address,uint256,address,bytes)[],(address,address,uint256,address,bytes)[])) in contracts/giddyVaultV3/vaults/GiddyVaultV3.sol (selector 0xd41ff3d3, verified with cast sig). The authorization flaw itself sits in the internal helper _validateAuthorization(VaultAuth calldata auth) in the same file. The attacker then cashes out through GiddyLibraryV3.executeSwap(SwapInfo calldata swap, address srcAccount, address dstAccount) in contracts/giddyVaultV3/libraries/GiddyLibraryV3.sol, which approves an arbitrary aggregator for an attacker-chosen amount.
// contracts/giddyVaultV3/vaults/GiddyVaultV3.sol
struct VaultAuth {
bytes signature;
bytes32 nonce;
uint256 deadline;
uint256 amount;
SwapInfo[] vaultSwaps;
SwapInfo[] compoundSwaps;
}
bytes32 public constant VAULTAUTH_TYPEHASH =
keccak256("VaultAuth(bytes32 nonce,uint256 deadline,uint256 amount,bytes[] data)");
// ^^^^^^^^^^^
// <-- VULNERABILITY: only the raw `data` blobs are signed.
function compound(VaultAuth calldata auth) external nonReentrant {
_validateAuthorization(auth);
_compound(auth.compoundSwaps);
_recordYield();
}
function _validateAuthorization(VaultAuth calldata auth) internal {
if (block.timestamp > auth.deadline) {
revert AuthorizationExpired(auth.deadline);
}
if (nonceUsed[auth.nonce]) {
revert NonceAlreadyUsed(auth.nonce);
}
bytes memory dataArray;
for (uint256 i = 0; i < auth.vaultSwaps.length; ++i) {
dataArray = abi.encodePacked(dataArray, keccak256(auth.vaultSwaps[i].data));
// <-- VULNERABILITY: does not bind fromToken/toToken/amount/aggregator.
}
for (uint256 i = 0; i < auth.compoundSwaps.length; ++i) {
dataArray = abi.encodePacked(dataArray, keccak256(auth.compoundSwaps[i].data));
// <-- VULNERABILITY: same omission for compound swaps.
}
bytes memory data = abi.encodePacked(
VAULTAUTH_TYPEHASH,
abi.encode(auth.nonce, auth.deadline, auth.amount, keccak256(dataArray))
);
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, keccak256(data)));
address signer = digest.recover(auth.signature);
if (!isAuthorizedSigner(signer)) {
revert InvalidAuthorization("Invalid signature");
}
nonceUsed[auth.nonce] = true;
}
// contracts/giddyVaultV3/libraries/GiddyLibraryV3.sol
function executeSwap(SwapInfo calldata swap, address srcAccount, address dstAccount)
internal
returns (uint256 returnAmount)
{
if (!isFromTokenNative) {
SafeERC20.forceApprove(IERC20(swap.fromToken), swap.aggregator, swap.amount);
// <-- VULNERABILITY: attacker chooses both `aggregator` and `amount`.
}
uint256 srcBalanceBefore = IERC20(swap.fromToken).balanceOf(srcAccount);
uint256 dstBalanceBefore = IERC20(swap.toToken).balanceOf(dstAccount);
(bool swapSuccess, bytes memory swapResult) = swap.aggregator.call(swap.data);
// <-- VULNERABILITY: arbitrary external call to attacker-controlled contract.
if (!swapSuccess) {
_revertSwapExecutionFailed(swap, swapResult);
}
uint256 srcBalanceAfter = IERC20(swap.fromToken).balanceOf(srcAccount);
uint256 actualSrcChange = srcBalanceBefore - srcBalanceAfter;
require(actualSrcChange > 0 && actualSrcChange <= swap.amount, "INVALID_SRC_BALANCE_CHANGE");
// <-- VULNERABILITY: a 1-wei pull satisfies the check when `swap.amount = MAX_UINT256`.
uint256 dstBalanceAfter = IERC20(swap.toToken).balanceOf(dstAccount);
returnAmount = dstBalanceAfter - dstBalanceBefore;
require(returnAmount > 0, "SWAP_NO_TOKENS_RECEIVED");
// <-- VULNERABILITY: a dust fake-token mint satisfies the success condition.
}
Expected behavior: a backend signature for compound() should commit to the full SwapInfo payload that will be executed on-chain, including the swap source token, destination token, amount, and the specific trusted aggregator contract. The swap executor should also grant allowance only to trusted routers, for the exact amount intended, and should clear that approval once the swap finishes.
Actual behavior: GiddyVaultV3 signs only the hashed swap.data byte arrays plus nonce, deadline, and auth.amount. The wrapper fields that materially control execution are unsigned. The trace proves those unsigned fields were changed in flight: each forged compound() call keeps auth.amount = 1000000000000000000000, but its compoundSwaps point fromToken at the strategy’s staked LiquidityGauge token, set toToken = 0x7326a1ab0d696ae317958d136d6e4c693ea34528, set aggregator = 0x7326a1ab0d696ae317958d136d6e4c693ea34528, and set amount = MAX_UINT256.
That matters because executeSwap() blindly honors those unsigned fields. On the tBTC market, the strategy approves the attacker’s contract for MAX_UINT256 at trace index 31, enters the attacker’s fake swap endpoint at trace index 34, and the attacker contract uses only a 1-wei transferFrom at trace index 35 to satisfy actualSrcChange > 0. The fake contract also returns 1 fake token to the strategy (receipt log 4) so returnAmount > 0 holds. After compound() returns, the same attacker-controlled contract remains approved; it checks allowance() and balanceOf() at trace indices 364-365 and drains the rest of the gauge position to the attacker EOA with transferFrom(strategy, attacker, 3531384449443560254) at trace index 366. The cbBTC and WBTC paths repeat the same pattern at trace indices 896-898 and 1282-1284.
Normal flow vs attack flow: under a legitimate compound, the signer authorizes swaps of reward tokens through a trusted router, the strategy receives actual base tokens, and the strategy re-deposits them. In the exploit path, the signature is valid but incomplete, so the attacker swaps the unsigned wrapper fields around the same signed data, tricks the strategy into approving its own gauge token to a malicious contract, and then drains the approved balance after the vault’s internal balance checks have already passed.
0x81fe3d7d35dfefa15b9e6800b6aefc3358e7b156 creates an orchestrator contract at 0x50a5312bf627b6be07e60015ed3d418e992d76eb.0x7326a1ab0d696ae317958d136d6e4c693ea34528.compound() authorizations against the tBTC, cbBTC, and WBTC GiddyVaultV3 wallet proxies, but wraps the signed data with attacker-controlled SwapInfo fields.swapRewardTokens(), and lets GiddyLibraryV3.executeSwap() approve the fake aggregator for MAX_UINT256 of the strategy’s LiquidityGauge token.compound() call unwinds, the fake aggregator uses the leftover allowance to pull the remaining LiquidityGauge balance from the strategy directly to the attacker EOA.CREATE 0x81fe3d7d35dfefa15b9e6800b6aefc3358e7b156 -> 0x50a5312bf627b6be07e60015ed3d418e992d76eb.CREATE 0x50a5312bf627b6be07e60015ed3d418e992d76eb -> 0x7326a1ab0d696ae317958d136d6e4c693ea34528 (trace index 1).CALL 0x50a5312bf627b6be07e60015ed3d418e992d76eb -> 0x7326a1ab0d696ae317958d136d6e4c693ea34528 selector 0x1b30b9d2 (unresolved) three times at trace indices 2, 374, and 906.CALL 0x7326... -> 0x9c247ccd24c23eddba399701cda24051ebf605b7 compound(...) (0xd41ff3d3).DELEGATECALL proxy -> 0x5f0ad32c00641d1d2bb628ff341e0d4bb4494318 compound(...)._validateAuthorization() checks recovered signer 0x07de3dded022e185fcc6d28eda088ed05f4bf93f through isAuthorizedSigner(address) and accepts it.CALLs strategy proxy 0xc99fc715e73294fd03b7c09d9a438a98f6c76ec3 swapRewardTokens(...) (0x98c6ce4c); trace index 14 delegates into 0x6163b1820da41aaba4192305371278ae00bda459.0x7326... for MAX_UINT256 on g(yb-tBTC).0x7326... with selector 0xe21fd0e9; trace index 35 shows 0x7326... pulling 1 wei of g(yb-tBTC) from the strategy.balanceOf() and allowance() on g(yb-tBTC) and then calls transferFrom(strategy, attackerEOA, 3531384449443560254).0x51b9e3e9871247ed7c2f07539b99cb97ae99d080, which delegates into the same GiddyVaultV3 implementation.0x0d5e628a44e7ec94a2054a6c454127cfe5fcb690 swapRewardTokens(...).approve(MAX_UINT256) calls for g(yb-cbBTC) to 0x7326....swapTokensMultipleV3ERC20ToERC20(...) and swapCompact()), and trace indices 407 and 421 show 1-wei transferFroms into the fake aggregator.6935949844931976725 g(yb-cbBTC) to the attacker EOA.0x1d85e7bceac3605d469debe006b46e9062238e67, which delegates into 0x5f0ad32c00641d1d2bb628ff341e0d4bb4494318.0x870fcd63db2c68d8079166e311b1118b8aa26ed7 swapRewardTokens(...).0x7326... for MAX_UINT256 on g(yb-WBTC).0xe21fd0e9 twice; trace indices 939 and 953 show 1-wei transferFroms to the fake aggregator.6274444990265641146 g(yb-WBTC) to the attacker EOA.The supplied funds_flow.json identifies the attacker EOA 0x81fe3d7d35dfefa15b9e6800b6aefc3358e7b156 as the final recipient of three stolen assets: 3.531384449443560254 g(yb-tBTC), 6.935949844931976725 g(yb-cbBTC), and 6.274444990265641146 g(yb-WBTC). In aggregate, the attacker extracted 16.741779284641178125 BTC-denominated LiquidityGauge shares. The local artifact set does not include an authoritative price snapshot for those gauge shares, so a precise USD total cannot be derived without adding outside market data.
The losses come from the three strategy proxies that backed the affected GiddyVaultV3 markets: 0xc99fc715e73294fd03b7c09d9a438a98f6c76ec3, 0x0d5e628a44e7ec94a2054a6c454127cfe5fcb690, and 0x870fcd63db2c68d8079166e311b1118b8aa26ed7. funds_flow.json shows each strategy finishing with a negative net change in its respective gauge token, while the fake aggregator kept only dust and the attacker EOA received the meaningful balances. There is no flash-loan leg in the trace, so attacker profit before gas is effectively identical to the stolen gauge balances.
The solvency impact is localized but severe for the three affected markets. The transaction strips the strategies of the staked LiquidityGauge positions they were supposed to hold on behalf of users, which means the tBTC, cbBTC, and WBTC wallet instances lose the productive assets backing those vault positions. The local artifacts do not show a full protocol shutdown, but they do show that the attacked strategies were drained of the positions that mattered.
0x1, confirming the exploit transaction executed successfully.Approval events granting 0x7326a1ab0d696ae317958d136d6e4c693ea34528 MAX_UINT256 allowance over the stolen LiquidityGauge tokens.Transfer events sending g(yb-tBTC), g(yb-cbBTC), and g(yb-WBTC) from the three strategy proxies to attacker EOA 0x81fe3d7d35dfefa15b9e6800b6aefc3358e7b156.cast sig matched the executed selectors: compound(...) -> 0xd41ff3d3, swapRewardTokens(...) -> 0x98c6ce4c, approve(address,uint256) -> 0x095ea7b3, and transferFrom(address,address,uint256) -> 0x23b872dd.