Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(evm): improve safety of ERC20 transfers, accounting for boolean success return values and recipient balance changes that don't match the ERC20 transfer amount. #2090

Merged
merged 11 commits into from
Oct 30, 2024
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ Ethereum transactions, such as in the case of an EthereumTx that influences the
`StateDB`, then calls a precompile that also changes non-EVM state, and then EVM
reverts inside of a try-catch.
- [#2098](https://github.com/NibiruChain/nibiru/pull/2098) - test(evm): statedb tests for race conditions within funtoken precompile
- [#2090](https://github.com/NibiruChain/nibiru/pull/2090) - fix(evm): Account
for (1) ERC20 transfers with tokens that return false success values instead of
throwing an error and (2) ERC20 transfers with other operations that don't bring
about the expected resulting balance for the transfer recipient.

#### Nibiru EVM | Before Audit 1 - 2024-10-18

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@
}
],
"name": "bankSend",
"outputs": [],
"outputs": [
{
"internalType": "uint256",
"name": "sentAmount",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
}
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

17 changes: 12 additions & 5 deletions x/evm/embeds/contracts/IFunToken.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ pragma solidity >=0.8.19;
/// coins to a Nibiru bech32 address using the "FunToken" mapping between the
/// ERC20 and bank.
interface IFunToken {
/// @dev bankSend sends ERC20 tokens as coins to a Nibiru base account
/// @param erc20 the address of the ERC20 token contract
/// @param amount the amount of tokens to send
/// @param to the receiving Nibiru base account address as a string
function bankSend(address erc20, uint256 amount, string memory to) external;
/// @dev bankSend sends ERC20 tokens as coins to a Nibiru base account
/// @param erc20 - the address of the ERC20 token contract
/// @param amount - the amount of tokens to send
/// @param to - the receiving Nibiru base account address as a string
/// @return sentAmount - amount of tokens received by the recipient. This may
/// not be equal to `amount` if the corresponding ERC20 contract has a fee or
/// deduction on transfer.
function bankSend(
address erc20,
uint256 amount,
string memory to
) external returns (uint256 sentAmount);
}

address constant FUNTOKEN_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000800;
Expand Down
40 changes: 35 additions & 5 deletions x/evm/keeper/erc20.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,23 +73,53 @@ Transfer implements "ERC20.transfer"
func (e erc20Calls) Transfer(
contract, from, to gethcommon.Address, amount *big.Int,
ctx sdk.Context,
) (out bool, err error) {
) (balanceIncrease *big.Int, err error) {
recipientBalanceBefore, err := e.BalanceOf(contract, to, ctx)
if err != nil {
return balanceIncrease, errors.Wrap(err, "failed to retrieve recipient balance")
}

input, err := e.ABI.Pack("transfer", to, amount)
if err != nil {
return false, fmt.Errorf("failed to pack ABI args: %w", err)
return balanceIncrease, fmt.Errorf("failed to pack ABI args: %w", err)
}

resp, _, err := e.CallContractWithInput(ctx, from, &contract, true, input)
if err != nil {
return false, err
return balanceIncrease, err
}

var erc20Bool ERC20Bool
err = e.ABI.UnpackIntoInterface(&erc20Bool, "transfer", resp.Ret)
if err != nil {
return false, err
return balanceIncrease, err
}

// Handle the case of success=false: https://github.com/NibiruChain/nibiru/issues/2080
success := erc20Bool.Value
if !success {
return balanceIncrease, fmt.Errorf("transfer executed but returned success=false")
}

recipientBalanceAfter, err := e.BalanceOf(contract, to, ctx)
if err != nil {
return balanceIncrease, errors.Wrap(err, "failed to retrieve recipient balance")
}

balanceIncrease = new(big.Int).Sub(recipientBalanceAfter, recipientBalanceBefore)

// For flexibility with fee on transfer tokens and other types of deductions,
// we cannot assume that the amount received by the recipient is equal to
// the call "amount". Instead, verify that the recipient got tokens and
// return the amount.
if balanceIncrease.Sign() <= 0 {
return balanceIncrease, fmt.Errorf(
"amount of ERC20 tokens received MUST be positive: the balance of recipient %s would've changed by %v for token %s",
to.Hex(), balanceIncrease.String(), contract.Hex(),
)
}

return erc20Bool.Value, nil
return balanceIncrease, err
}

// BalanceOf retrieves the balance of an ERC20 token for a specific account.
Expand Down
15 changes: 11 additions & 4 deletions x/evm/keeper/erc20_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ func (s *Suite) TestERC20Calls() {

s.T().Log("Transfer - Not enough funds")
{
_, err := deps.EvmKeeper.ERC20().Transfer(contract, deps.Sender.EthAddr, evm.EVM_MODULE_ADDRESS, big.NewInt(9_420), deps.Ctx)
amt := big.NewInt(9_420)
_, err := deps.EvmKeeper.ERC20().Transfer(contract, deps.Sender.EthAddr, evm.EVM_MODULE_ADDRESS, amt, deps.Ctx)
Comment on lines +37 to +38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add test cases for fee-on-transfer tokens

While this test covers the basic insufficient funds scenario, we should add test cases for fee-on-transfer tokens where the received amount differs from the sent amount.

Consider adding these test scenarios:

s.T().Log("Transfer - Fee on transfer token")
{
    amt := big.NewInt(1000)
    expectedReceivedAmt := big.NewInt(950) // Assuming 5% fee
    sentAmt, err := deps.EvmKeeper.ERC20().Transfer(
        feeToken, // Mock fee-on-transfer token
        evm.EVM_MODULE_ADDRESS,
        deps.Sender.EthAddr,
        amt,
        deps.Ctx,
    )
    s.Require().NoError(err)
    s.Require().Equal(sentAmt.String(), expectedReceivedAmt.String())
}

s.ErrorContains(err, "ERC20: transfer amount exceeds balance")
// balances unchanged
evmtest.AssertERC20BalanceEqual(s.T(), deps, contract, deps.Sender.EthAddr, big.NewInt(0))
Expand All @@ -43,10 +44,16 @@ func (s *Suite) TestERC20Calls() {

s.T().Log("Transfer - Success (sanity check)")
{
_, err := deps.EvmKeeper.ERC20().Transfer(contract, evm.EVM_MODULE_ADDRESS, deps.Sender.EthAddr, big.NewInt(9_420), deps.Ctx)
amt := big.NewInt(9_420)
sentAmt, err := deps.EvmKeeper.ERC20().Transfer(
contract, evm.EVM_MODULE_ADDRESS, deps.Sender.EthAddr, amt, deps.Ctx,
)
s.Require().NoError(err)
evmtest.AssertERC20BalanceEqual(s.T(), deps, contract, deps.Sender.EthAddr, big.NewInt(9_420))
evmtest.AssertERC20BalanceEqual(s.T(), deps, contract, evm.EVM_MODULE_ADDRESS, big.NewInt(60_000))
evmtest.AssertERC20BalanceEqual(
s.T(), deps, contract, deps.Sender.EthAddr, big.NewInt(9_420))
evmtest.AssertERC20BalanceEqual(
s.T(), deps, contract, evm.EVM_MODULE_ADDRESS, big.NewInt(60_000))
s.Require().Equal(sentAmt.String(), amt.String())
Comment on lines +47 to +56
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Enhance test coverage for non-standard ERC20 tokens

While this test verifies basic transfer functionality, it doesn't cover the specific cases mentioned in issue #2080 and the PR objectives.

  1. Add test cases for tokens that return false instead of reverting:
s.T().Log("Transfer - Handle false return value")
{
    amt := big.NewInt(1000)
    sentAmt, err := deps.EvmKeeper.ERC20().Transfer(
        falseReturningToken, // Mock token that returns false
        evm.EVM_MODULE_ADDRESS,
        deps.Sender.EthAddr,
        amt,
        deps.Ctx,
    )
    s.Require().Error(err) // Should handle false return as error
    s.Require().Nil(sentAmt)
}
  1. Add test cases for tokens without return values:
s.T().Log("Transfer - Handle missing return value")
{
    amt := big.NewInt(1000)
    sentAmt, err := deps.EvmKeeper.ERC20().Transfer(
        noReturnToken, // Mock token without return value
        evm.EVM_MODULE_ADDRESS,
        deps.Sender.EthAddr,
        amt,
        deps.Ctx,
    )
    s.Require().NoError(err)
    s.Require().Equal(sentAmt.String(), amt.String())
}
  1. Make balance assertions more explicit:
-evmtest.AssertERC20BalanceEqual(
-   s.T(), deps, contract, deps.Sender.EthAddr, big.NewInt(9_420))
-evmtest.AssertERC20BalanceEqual(
-   s.T(), deps, contract, evm.EVM_MODULE_ADDRESS, big.NewInt(60_000))
+// Initial balance (0) + transferred amount (9_420) = 9_420
+evmtest.AssertERC20BalanceEqual(
+   s.T(), deps, contract, deps.Sender.EthAddr, amt)
+// Initial balance (69_420) - transferred amount (9_420) = 60_000
+evmtest.AssertERC20BalanceEqual(
+   s.T(), deps, contract, evm.EVM_MODULE_ADDRESS, 
+   new(big.Int).Sub(big.NewInt(69_420), amt))

}

s.T().Log("Burn tokens - Allowed as non-owner")
Expand Down
64 changes: 21 additions & 43 deletions x/evm/keeper/msg_server.go
Unique-Divine marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -491,30 +491,31 @@ func (k *Keeper) ConvertCoinToEvm(
fungibleTokenMapping := funTokens[0]

if fungibleTokenMapping.IsMadeFromCoin {
return k.convertCoinNativeCoin(ctx, sender, msg.ToEthAddr.Address, msg.BankCoin, fungibleTokenMapping)
return k.convertCoinToEvmBornCoin(ctx, sender, msg.ToEthAddr.Address, msg.BankCoin, fungibleTokenMapping)
} else {
return k.convertCoinNativeERC20(ctx, sender, msg.ToEthAddr.Address, msg.BankCoin, fungibleTokenMapping)
return k.convertCoinToEvmBornERC20(ctx, sender, msg.ToEthAddr.Address, msg.BankCoin, fungibleTokenMapping)
}
}

// Converts a native coin to an ERC20 token.
// EVM module owns the ERC-20 contract and can mint the ERC-20 tokens.
func (k Keeper) convertCoinNativeCoin(
// Converts Bank Coins for FunToken mapping that was born from a coin
// (IsMadeFromCoin=true) into the ERC20 tokens. EVM module owns the ERC-20
// contract and can mint the ERC-20 tokens.
func (k Keeper) convertCoinToEvmBornCoin(
ctx sdk.Context,
sender sdk.AccAddress,
recipient gethcommon.Address,
coin sdk.Coin,
funTokenMapping evm.FunToken,
) (*evm.MsgConvertCoinToEvmResponse, error) {
// Step 1: Escrow bank coins with EVM module account
// Step 1: Send Bank Coins to the EVM module
err := k.bankKeeper.SendCoinsFromAccountToModule(ctx, sender, evm.ModuleName, sdk.NewCoins(coin))
if err != nil {
return nil, errors.Wrap(err, "failed to send coins to module account")
}

erc20Addr := funTokenMapping.Erc20Addr.Address

// Step 2: mint ERC-20 tokens for recipient
// Step 2: Mint ERC20 tokens to the recipient
evmResp, err := k.CallContract(
ctx,
embeds.SmartContract_ERC20Minter.ABI,
Expand Down Expand Up @@ -542,10 +543,11 @@ func (k Keeper) convertCoinNativeCoin(
return &evm.MsgConvertCoinToEvmResponse{}, nil
}

// Converts a coin that was originally an ERC20 token, and that was converted to a bank coin, back to an ERC20 token.
// EVM module does not own the ERC-20 contract and cannot mint the ERC-20 tokens.
// EVM module has escrowed tokens in the first conversion from ERC-20 to bank coin.
func (k Keeper) convertCoinNativeERC20(
// Converts a coin that was originally an ERC20 token, and that was converted to
// a bank coin, back to an ERC20 token. EVM module does not own the ERC-20
// contract and cannot mint the ERC-20 tokens. EVM module has escrowed tokens in
// the first conversion from ERC-20 to bank coin.
func (k Keeper) convertCoinToEvmBornERC20(
ctx sdk.Context,
sender sdk.AccAddress,
recipient gethcommon.Address,
Expand All @@ -554,14 +556,6 @@ func (k Keeper) convertCoinNativeERC20(
) (*evm.MsgConvertCoinToEvmResponse, error) {
erc20Addr := funTokenMapping.Erc20Addr.Address

recipientBalanceBefore, err := k.ERC20().BalanceOf(erc20Addr, recipient, ctx)
if err != nil {
return nil, errors.Wrap(err, "failed to retrieve balance")
}
if recipientBalanceBefore == nil {
return nil, fmt.Errorf("failed to retrieve balance, balance is nil")
}

// Escrow Coins on module account
if err := k.bankKeeper.SendCoinsFromAccountToModule(
ctx,
Expand All @@ -583,52 +577,36 @@ func (k Keeper) convertCoinNativeERC20(
return nil, errors.Wrap(err, "failed to retrieve balance")
}
if evmModuleBalance == nil {
return nil, fmt.Errorf("failed to retrieve balance, balance is nil")
return nil, fmt.Errorf("failed to retrieve EVM module account balance, balance is nil")
}
if evmModuleBalance.Cmp(coin.Amount.BigInt()) < 0 {
return nil, fmt.Errorf("insufficient balance in EVM module account")
}

// unescrow ERC-20 tokens from EVM module address
res, err := k.ERC20().Transfer(
// Supply ERC-20 tokens from EVM module address
actualSentAmount, err := k.ERC20().Transfer(
erc20Addr,
evm.EVM_MODULE_ADDRESS,
recipient,
coin.Amount.BigInt(),
ctx,
)
if err != nil {
return nil, errors.Wrap(err, "failed to transfer ERC20 tokens")
}
if !res {
return nil, fmt.Errorf("failed to transfer ERC20 tokens")
}

// Check expected Receiver balance after transfer execution
recipientBalanceAfter, err := k.ERC20().BalanceOf(erc20Addr, recipient, ctx)
if err != nil {
return nil, errors.Wrap(err, "failed to retrieve balance")
}
if recipientBalanceAfter == nil {
return nil, fmt.Errorf("failed to retrieve balance, balance is nil")
return nil, errors.Wrap(err, "failed to transfer ERC-20 tokens")
Unique-Divine marked this conversation as resolved.
Show resolved Hide resolved
}

expectedFinalBalance := big.NewInt(0).Add(recipientBalanceBefore, coin.Amount.BigInt())
if r := recipientBalanceAfter.Cmp(expectedFinalBalance); r != 0 {
return nil, fmt.Errorf("expected balance after transfer to be %s, got %s", expectedFinalBalance, recipientBalanceAfter)
}

// Burn escrowed Coins
err = k.bankKeeper.BurnCoins(ctx, evm.ModuleName, sdk.NewCoins(coin))
burnCoin := sdk.NewCoin(coin.Denom, sdk.NewIntFromBigInt(actualSentAmount))
err = k.bankKeeper.BurnCoins(ctx, evm.ModuleName, sdk.NewCoins(burnCoin))
if err != nil {
return nil, errors.Wrap(err, "failed to burn coins")
}

// Emit event with the actual amount received
_ = ctx.EventManager().EmitTypedEvent(&evm.EventConvertCoinToEvm{
Sender: sender.String(),
Erc20ContractAddress: funTokenMapping.Erc20Addr.String(),
ToEthAddr: recipient.String(),
BankCoin: coin,
BankCoin: burnCoin,
Comment on lines +596 to +607
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Verify actualSentAmount before burning coins.

The code should verify that actualSentAmount is positive before creating and burning coins to handle potential issues with fee-on-transfer tokens.

Apply this diff to add the verification:

+if actualSentAmount.Sign() <= 0 {
+    return nil, fmt.Errorf("invalid actual sent amount: %s", actualSentAmount)
+}
+
 burnCoin := sdk.NewCoin(coin.Denom, sdk.NewIntFromBigInt(actualSentAmount))
 err = k.bankKeeper.BurnCoins(ctx, evm.ModuleName, sdk.NewCoins(burnCoin))
 if err != nil {
     return nil, errors.Wrap(err, "failed to burn coins")
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
burnCoin := sdk.NewCoin(coin.Denom, sdk.NewIntFromBigInt(actualSentAmount))
err = k.bankKeeper.BurnCoins(ctx, evm.ModuleName, sdk.NewCoins(burnCoin))
if err != nil {
return nil, errors.Wrap(err, "failed to burn coins")
}
// Emit event with the actual amount received
_ = ctx.EventManager().EmitTypedEvent(&evm.EventConvertCoinToEvm{
Sender: sender.String(),
Erc20ContractAddress: funTokenMapping.Erc20Addr.String(),
ToEthAddr: recipient.String(),
BankCoin: coin,
BankCoin: burnCoin,
if actualSentAmount.Sign() <= 0 {
return nil, fmt.Errorf("invalid actual sent amount: %s", actualSentAmount)
}
burnCoin := sdk.NewCoin(coin.Denom, sdk.NewIntFromBigInt(actualSentAmount))
err = k.bankKeeper.BurnCoins(ctx, evm.ModuleName, sdk.NewCoins(burnCoin))
if err != nil {
return nil, errors.Wrap(err, "failed to burn coins")
}
// Emit event with the actual amount received
_ = ctx.EventManager().EmitTypedEvent(&evm.EventConvertCoinToEvm{
Sender: sender.String(),
Erc20ContractAddress: funTokenMapping.Erc20Addr.String(),
ToEthAddr: recipient.String(),
BankCoin: burnCoin,

})

return &evm.MsgConvertCoinToEvmResponse{}, nil
Expand Down
11 changes: 5 additions & 6 deletions x/evm/precompile/funtoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,20 +143,19 @@ func (p precompileFunToken) bankSend(

// Caller transfers ERC20 to the EVM account
transferTo := evm.EVM_MODULE_ADDRESS
_, err = p.evmKeeper.ERC20().Transfer(erc20, caller, transferTo, amount, ctx)
gotAmount, err := p.evmKeeper.ERC20().Transfer(erc20, caller, transferTo, amount, ctx)
if err != nil {
return nil, fmt.Errorf("failed to send from caller to the EVM account: %w", err)
return nil, fmt.Errorf("error in ERC20.transfer from caller to EVM account: %w", err)
}

// EVM account mints FunToken.BankDenom to module account
amt := math.NewIntFromBigInt(amount)
coinToSend := sdk.NewCoin(funtoken.BankDenom, amt)
coinToSend := sdk.NewCoin(funtoken.BankDenom, math.NewIntFromBigInt(gotAmount))
if funtoken.IsMadeFromCoin {
// If the FunToken mapping was created from a bank coin, then the EVM account
// owns the ERC20 contract and was the original minter of the ERC20 tokens.
// Since we're sending them away and want accurate total supply tracking, the
// tokens need to be burned.
_, err = p.evmKeeper.ERC20().Burn(erc20, evm.EVM_MODULE_ADDRESS, amount, ctx)
_, err = p.evmKeeper.ERC20().Burn(erc20, evm.EVM_MODULE_ADDRESS, gotAmount, ctx)
if err != nil {
err = fmt.Errorf("ERC20.Burn: %w", err)
return
Expand Down Expand Up @@ -187,7 +186,7 @@ func (p precompileFunToken) bankSend(

// TODO: UD-DEBUG: feat: Emit EVM events

return method.Outputs.Pack()
return method.Outputs.Pack(gotAmount)
}

func SafeMintCoins(
Expand Down
22 changes: 18 additions & 4 deletions x/evm/precompile/funtoken_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,18 +126,32 @@ func (s *FuntokenSuite) TestHappyPath() {
input, err := embeds.SmartContract_FunToken.ABI.Pack(string(precompile.FunTokenMethod_BankSend), callArgs...)
s.NoError(err)

_, resp, err := evmtest.CallContractTx(
_, ethTxResp, err := evmtest.CallContractTx(
&deps,
precompile.PrecompileAddr_FunToken,
input,
deps.Sender,
)
s.Require().NoError(err)
s.Require().Empty(resp.VmError)
s.Require().Empty(ethTxResp.VmError)

evmtest.AssertERC20BalanceEqual(s.T(), deps, erc20, deps.Sender.EthAddr, big.NewInt(69_000))
evmtest.AssertERC20BalanceEqual(s.T(), deps, erc20, evm.EVM_MODULE_ADDRESS, big.NewInt(0))
evmtest.AssertERC20BalanceEqual(
s.T(), deps, erc20, deps.Sender.EthAddr, big.NewInt(69_000),
)
evmtest.AssertERC20BalanceEqual(
s.T(), deps, erc20, evm.EVM_MODULE_ADDRESS, big.NewInt(0),
)
s.Equal(sdk.NewInt(420).String(),
deps.App.BankKeeper.GetBalance(deps.Ctx, randomAcc, funtoken.BankDenom).Amount.String(),
)

s.T().Log("Parse the response contract addr and response bytes")
var sentAmt *big.Int
err = embeds.SmartContract_FunToken.ABI.UnpackIntoInterface(
&sentAmt,
string(precompile.FunTokenMethod_BankSend),
ethTxResp.Ret,
)
s.NoError(err)
s.Require().Equal("420", sentAmt.String())
}
Loading