❌

Reading view

There are new articles available, click to refresh the page.

Ten EIP-7702 delegates strip their metadata hash. Nine of them publish their source

I’d marked them as the likely hidden code. Of the 46 delegates that keep the hash, fewer than half are verified.

The most delegated contract in my EIP-7702 sample ends in these 12 bytes: a164736f6c6343000822000a. Most Solidity contracts built with default settings end in 53. The longer tail is a small CBOR map the compiler appends to the bytecode: an IPFS hash of the contract’s metadata file, the compiler version, and two bytes giving the map’s length. This one has the version and no hash.

On 8 September I pulled every EIP-7702 authorization from a 200-block window on Ethereum mainnet, 1,178 of them, and read the bytecode of the 59 contracts they delegated to. Nine of those authorizations pointed at the zero address, which is how an account undelegates, so 1,169 went to real code. When I went back over the 59 on 11 October, ten had the short tail. I left a note beside them: no metadata hash, so maybe nobody meant the source to be found.

What the 12 bytes still say

Read as CBOR, a1 opens a map with one entry, 64 says a four-byte string follows (solc), and 43 says a three-byte value follows: 00 08 22, which is version 0.8.34. The last two bytes, 00 0a, give the length of everything before them, ten. So the compiler version survives. What’s gone is the hash that would let anyone fetch the exact metadata file, the one that lists every source file’s own hash and the compiler settings. In solc that’s a single setting, metadata.bytecodeHash set to none, and Foundry exposes it as bytecode_hash.

Two byte strips. The upper one, from the second most delegated contract, is 53 bytes: a CBOR map holding an IPFS hash of 34 bytes and the solc version 0.8.35, then the length 0x33. The lower one, from the most delegated contract, is 12 bytes: a map holding only the solc version 0.8.34, then the length 0x0a.
The last bytes of two EIP-7702 delegates. Both record the compiler version; only one carries the metadata hash.

Checking the note

Today I asked Blockscout’s API whether each of the 59 has verified source, and asked Sourcify the same. Nine of the ten stripped contracts are verified on Blockscout. Of the 46 that kept the full hash, 21 are. The other three have no CBOR tail at all, and one of those is verified.

Counting authorizations instead of contracts widens the gap. In that September window, 369 of the 371 authorizations to stripped delegates went to a contract with published source. For the full-hash contracts it was 324 of 774, about 42%.

Two pairs of horizontal bars. By contract, 9 of 10 stripped delegates are verified against 21 of 46 with the full hash. By authorization, 369 of 371 to stripped delegates went to verified code against 324 of 774.
59 delegate contracts from one 200-block window in September, verification read on 27 October.

The one stripped contract with no source, 0xbaa66503..., drew two authorizations. The second most delegated contract in the whole sample, 0x67b743f3... with 144, carries a full IPFS hash and has no verified source on either service. Neither does 0x912518c2... with 96. Three unverified contracts of 2,140 bytes each carry the same IPFS hash, so they’re one build deployed three times.

Who strips it

The nine verified ones carry six contract names: Uniswap’s CaliburEntry at three addresses, Alchemy’s SemiModularAccount7702 at two, two TokenPocket delegators, UniversalGaslessDelegate, and the most delegated contract of all, with 308 of the window’s authorizations, whose verified source names it Poisoner.

A table of ten rows, one per stripped delegate, giving the address prefix, the verified contract name, the solc version read from the tail, authorizations in the window, Blockscout status and Sourcify status. Nine rows are verified; none is an exact match on Sourcify.
The ten stripped delegates. Blockscout and Sourcify read 27 October; authorizations from the 8 September window.

Calibur’s foundry.toml and the one in Alchemy’s modular-account repository both set bytecode_hash = "none" as the project default. Neither file says why. Five of the ten addresses look mined: two start with eight zeros, one with 0x0000fb7702, one with 0x69007702, and one starts 0x77021100 and ends 0000. The metadata hash changes whenever a comment or a file path changes, even when the compiled code doesn’t, and an address found by searching CREATE2 salts depends on the exact bytecode. Dropping the hash ties the bytecode to the code alone. That’s my reading of the reason, not something either project says.

What Sourcify makes of them

Sourcify answered for eight of the stripped contracts, and all eight came back as match, none as exact_match. Of the full-hash contracts it has, 13 of 17 are exact. Sourcify’s docs say an exact match rests on the metadata hash in the deployed code agreeing with the recompiled one, which is how it proves the source files are byte for byte the ones the author compiled. With no hash in the bytecode there’s nothing for that check to hold on to, so I’d expect every stripped contract to stop at match, whoever wrote it.

What this doesn’t show

The authorization counts come from one 200-block window in September, so they say which delegates were busy then. Verified means Blockscout’s flag and Sourcify’s record as I read them on 27 October. I didn’t check Etherscan, which wants an API key, and a contract could be verified there and nowhere else. I also tried two public IPFS gateways for the hashes on the unverified contracts and both refused the requests, so I can’t say whether any of those metadata files exist somewhere.


Ten EIP-7702 delegates strip their metadata hash. Nine of them publish their source was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

Quill Findings: APY Miscalculation in Staking Rewards

This is the third blog in our series Quill Findings, where we share vulnerabilities our auditors found during client audits, for auditor and developer education. If you missed the earlier ones, you can read the first one here and the second one here.

Today we have a staking protocol, where users lock their tokens for 60 days and earn 8% APY on the staked amount. The reward math missed calculated rewards, and turned an 8% yearly yield into roughly 49%.

How the reward calculation works in general

APY is a yearly rate and a 60 days lock period should only give rewards generated by the 60days part of that 8% yearly calculation, not all of it. In code below you can see that how 60 days rewards calculation should work:

// 8% per year, in basis points
uint256 constant APY_BPS = 800;
uint256 constant LOCK_PERIOD = 60 days;

// reward = amount x yearly rate x (lock time / 1 year)
//
// 10,000 tokens x 8% x (60 / 365)
// = 10,000 x 0.08 x 0.164
// = ~131 tokens

Here a user is staking 10,000 tokens for one lock period(60 days) and should get back about 10,131 tokens.

What was wrong in the contract

The contract calculated the rewards like this:

function calculateReward(uint256 amount) public pure returns (uint256) {
// bug: pays the full yearly 8% on every 60-day lock,
// the lock duration never enters the math
//
// 10,000 tokens -> 800 tokens reward
// expected -> ~131 tokens
// overpaid -> ~6x on every lock
return amount * APY_BPS / 10_000;
}

The yearly rate is treated as a 60 days rate and every lock pays the full 8%, whether the tokens were locked for 60 days or a whole year.

A year has over six 60days periods cycles, so a user who keeps locking again and again earns far more than 8%:

// intended:             8% a year
// actual, re-locking(original deposit only): 8% x 6.08 periods = ~49% a year
// actual, restaking(compunding: deposit + previous reward): (1.08)^6.08 - 1 = ~60% a year

Understand the Vulnerability Flow

Impact

No special access or attacker contract was required. Anyone who stakes normally gets overpaid. This was a business logic implementation bug.

The rewards come from a pool that team funds. That pool is create for 8% a year, but it pays out as if the rate were about six times higher and tt drains much faster than planned, and as TVL grows.

Proof of concept

This is a minimal example to show the bug from the code side. Its a replica of vulnerable code

src/MockToken.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockToken is ERC20 {
constructor() ERC20("Mock Token", "MOCK") {}

function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}

src/StakingVulnerable.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract StakingVulnerable {
IERC20 public immutable token;

uint256 public constant APY_BPS = 800; // 8% a year
uint256 public constant LOCK_PERIOD = 60 days;

struct Stake {
uint256 amount;
uint256 unlockAt;
}

mapping(address => Stake) public stakes;

constructor(IERC20 _token) {
token = _token;
}

function stake(uint256 amount) external {
require(stakes[msg.sender].amount == 0, "already staked");
token.transferFrom(msg.sender, address(this), amount);
stakes[msg.sender] = Stake(amount, block.timestamp + LOCK_PERIOD);
}

// bug: the full yearly rate is paid for a 60-day lock
function calculateReward(uint256 amount) public pure returns (uint256) {
return amount * APY_BPS / 10_000;
}

function withdraw() external {
Stake memory s = stakes[msg.sender];
require(s.amount > 0, "no stake");
require(block.timestamp >= s.unlockAt, "still locked");
delete stakes[msg.sender];
token.transfer(msg.sender, s.amount + calculateReward(s.amount));
}
}

Create the fixed version. It’s the same contract with only calculateReward changed:

sed -e 's/contract StakingVulnerable/contract StakingFixed/' \
-e 's|return amount \* APY_BPS / 10_000;|return amount * APY_BPS * LOCK_PERIOD / (10_000 * 365 days);|' \
src/StakingVulnerable.sol > src/StakingFixed.sol

test/APYOverpay.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test, console2} from "forge-std/Test.sol";
import {MockToken} from "../src/MockToken.sol";
import {StakingVulnerable} from "../src/StakingVulnerable.sol";
import {StakingFixed} from "../src/StakingFixed.sol";

contract APYOverpayTest is Test {
MockToken token;
address alice = makeAddr("alice");
address bob = makeAddr("bob");

uint256 constant STAKE = 10_000e18;
uint256 constant POOL = 800e18; // reward pool funded by the team

function setUp() public {
token = new MockToken();
token.mint(alice, STAKE);
token.mint(bob, STAKE);
}

function _stake(address user, address staking) internal {
vm.startPrank(user);
token.approve(staking, STAKE);
StakingVulnerable(staking).stake(STAKE);
vm.stopPrank();
}

function test_RewardIsFullYearlyRate() public {
StakingVulnerable staking = new StakingVulnerable(token);

uint256 paid = staking.calculateReward(STAKE);
uint256 expected = STAKE * 800 * 60 days / (10_000 * 365 days);

console2.log("reward paid for 60 days :", paid / 1e18);
console2.log("reward expected (8% APY):", expected / 1e18);

assertEq(paid, 800e18, "full 8% paid on a 60-day lock");
assertGt(paid, expected * 6, "more than 6x the intended reward");
}

function test_FirstStakerDrainsPool() public {
StakingVulnerable staking = new StakingVulnerable(token);
token.mint(address(staking), POOL);

_stake(alice, address(staking));
_stake(bob, address(staking));
vm.warp(block.timestamp + 60 days);

vm.prank(alice);
staking.withdraw();
console2.log("alice received :", token.balanceOf(alice) / 1e18);

// the pool is already gone, bob can't even get his principal back
vm.prank(bob);
vm.expectRevert();
staking.withdraw();
console2.log("bob withdraw : reverted");
}

function test_FixedPaysOnlySixtyDays() public {
StakingFixed staking = new StakingFixed(token);
token.mint(address(staking), POOL);

_stake(alice, address(staking));
_stake(bob, address(staking));
vm.warp(block.timestamp + 60 days);

vm.prank(alice);
staking.withdraw();
vm.prank(bob);
staking.withdraw();

console2.log("alice received :", token.balanceOf(alice) / 1e18);
console2.log("bob received :", token.balanceOf(bob) / 1e18);
console2.log("pool left :", token.balanceOf(address(staking)) / 1e18);

assertEq(token.balanceOf(alice), STAKE + staking.calculateReward(STAKE));
assertEq(token.balanceOf(bob), STAKE + staking.calculateReward(STAKE));
}
}

Run it:

forge test --match-contract APYOverpayTest -vv

Output:

Ran 3 tests for test/APYOverpay.t.sol:APYOverpayTest
[PASS] test_FirstStakerDrainsPool() (gas: 440263)
Logs:
alice received : 10800
bob withdraw : reverted

[PASS] test_FixedPaysOnlySixtyDays() (gas: 503675)
Logs:
alice received : 10131
bob received : 10131
pool left : 536

[PASS] test_RewardIsFullYearlyRate() (gas: 16956)
Logs:
reward paid for 60 days : 800
reward expected (8% APY): 131

Suite result: ok. 3 passed; 0 failed; 0 skipped

Here’s what the three tests show.

test_RewardIsFullYearlyRate: for 10,000 tokens, the contract pays 800 tokens for a 60-day lock, instead of 131.

test_FirstStakerDrainsPool: the team funds the pool with 800 tokens, which is more than enough for two users at the real reward distribution. Alice and Bob each stake 10,000 and after 60 days, Alice withdraws 10,800 and takes the whole pool. When Bob tries to withdraw, the contract owes him 10,800 but only holds 10,000, so his withdrawal reverts. Bob can't even get his principal back.

test_FixedPaysOnlySixtyDays: the same flow on the fixed contract. Both users get about 10,131 back, and 536 tokens are still left in the pool.

The fix

Scale the yearly rate down to the lock duration.

- return amount * APY_BPS / 10_000;
+ return amount * APY_BPS * LOCK_PERIOD / (10_000 * 365 days);
function calculateReward(uint256 amount) public pure returns (uint256) {
// fix: only pay the 60-day share of the yearly rate
// multiply first, divide once at the end,
// so integer division doesn't round rewards away
//
// 10,000 tokens -> ~131 tokens, matches 8% APY
return amount * APY_BPS * LOCK_PERIOD / (10_000 * 365 days);
}

The order of operations matters here. If you divide first, for example LOCK_PERIOD / 365 days, Solidity's integer division rounds it down to 0, and every user gets nothing. Multiplying everything first and dividing once at the end keeps the result accurate.

If the protocol ever supports more than one lock period, pass the actual lock duration into the function instead of relying on a single constant. With this, the same formula stays correct for every option.

Conclusion

Nothing in this bug reverts, and no test fails unless it checks the reward amount itself. The contract runs smoothly while paying far more than intended. When a rate is yearly, every payout needs to know how long the money was locked, and here, one missing time factor turned 8% into 49%.

Originally published at https://www.quillaudits.com.


Quill Findings: APY Miscalculation in Staking Rewards was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

❌