Clearly we are not going into the if branch. In the else branch, coin.transfer(dest_, 10) is called. Examine the implementation of Coin::transfer():
functiontransfer(address dest_,uint256 amount_) external {uint256 currentBalance = balances[msg.sender];// transfer only occurs if balance is enoughif(amount_ <= currentBalance) { balances[msg.sender] -= amount_; balances[dest_] += amount_;if(dest_.isContract()) {// notify contract INotifyable(dest_).notify(amount_); } } else {revertInsufficientBalance(currentBalance, amount_); }}
Here INotifyable(dest_).notify(amount_) is interesting. Note that dest_ is msg.sender, that is, we have control over dest_ and we can write custom logic in our own notify() function.
Recall that our goal is to trigger the NotEnoughBalance() error when wallet.donate10() is called, so in our exp we can implement:
Congratulations! You have completed the final level (for now).
Custom errors in Solidity are identified by their 4-byte signature, the same as a function call. They are bubbled up through the call chain until they are caught by a catch statement in a try-catch block, as seen in the GoodSamaritan's requestDonation() function. For these reasons, it is not safe to assume that the error was thrown by the immediate target of the contract call (i.e., Wallet in this case). Any other contract further down in the call chain can declare the same error--which will have the same signature--and throw it at an unexpected location, such as in the notify(uint256 amount) function in your attacker contract.