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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ reflected on all occurences of "base fee" in the codebase. This fixes [#2059](ht
and the [related comments from @Unique-Divine and @berndartmueller](https://github.com/NibiruChain/nibiru/issues/2059#issuecomment-2408625724).
- [#2084](https://github.com/NibiruChain/nibiru/pull/2084) - feat(evm-forge): foundry support and template for Nibiru EVM develoment
- [#2088](https://github.com/NibiruChain/nibiru/pull/2088) - refactor(evm): remove outdated comment and improper error message text
- [#2090](https://github.com/NibiruChain/nibiru/pull/2090) - fix(evm): transfer for native to erc20 for specific tokens


#### Dapp modules: perp, spot, oracle, etc
Expand Down
4 changes: 2 additions & 2 deletions x/evm/evmmodule/genesis_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ func (s *Suite) TestExportInitGenesis() {
s.Require().NoError(err)

// Transfer ERC-20 tokens to user A
_, err = deps.EvmKeeper.ERC20().Transfer(erc20Addr, fromUser, toUserA, amountToSendA, deps.Ctx)
_, err, _ = deps.EvmKeeper.ERC20().Transfer(erc20Addr, fromUser, toUserA, amountToSendA, deps.Ctx)
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

Update transfer result handling for fee-on-transfer tokens.

The test ignores the actual received amount (third return value) from Transfer(). For fee-on-transfer tokens, the received amount could be less than the sent amount, which this test wouldn't catch.

Consider updating the test to handle fee-on-transfer scenarios:

-  _, err, _ = deps.EvmKeeper.ERC20().Transfer(erc20Addr, fromUser, toUserA, amountToSendA, deps.Ctx)
+  _, err, receivedAmount := deps.EvmKeeper.ERC20().Transfer(erc20Addr, fromUser, toUserA, amountToSendA, deps.Ctx)
   s.Require().NoError(err)
+  // For fee-on-transfer tokens, verify actual received amount
+  balance, err := deps.EvmKeeper.ERC20().BalanceOf(erc20Addr, toUserA, deps.Ctx)
+  s.Require().NoError(err)
+  s.Require().Equal(receivedAmount, balance)

Also applies to: 61-61

s.Require().NoError(err)

// Transfer ERC-20 tokens to user B
_, err = deps.EvmKeeper.ERC20().Transfer(erc20Addr, fromUser, toUserB, amountToSendB, deps.Ctx)
_, err, _ = deps.EvmKeeper.ERC20().Transfer(erc20Addr, fromUser, toUserB, amountToSendB, deps.Ctx)
s.Require().NoError(err)
Unique-Divine marked this conversation as resolved.
Show resolved Hide resolved

// Create fungible token from bank coin
Expand Down
42 changes: 36 additions & 6 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) {
) (success bool, err error, received *big.Int) {
received = big.NewInt(0)

recipientBalanceBefore, err := e.BalanceOf(contract, to, ctx)
if err != nil {
return false, errors.Wrap(err, "failed to retrieve recipient balance"), received
}
Unique-Divine marked this conversation as resolved.
Show resolved Hide resolved

input, err := e.ABI.Pack("transfer", to, amount)
if err != nil {
return false, fmt.Errorf("failed to pack ABI args: %w", err)
return false, fmt.Errorf("failed to pack ABI args: %w", err), received
}
resp, err := e.CallContractWithInput(ctx, from, &contract, true, input)
if err != nil {
return false, err
return false, err, received
}

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

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

// we can't check that received = amount because the recipient could have
// a transfer fee or other deductions. We can only check that the recipient
// received some tokens
if received.Sign() <= 0 {
return false, fmt.Errorf("no (or negative) ERC20 tokens were received by the recipient"), received
}

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

// per erc20 standard, the transfer function should return a boolean value
// indicating whether the operation succeeded. If the unpacking failed, we
// need to check the recipient balance to determine if the transfer was successful.
if err == nil {
// should be true if the transfer was successful but we do it anyway
// to respect the contract's return value
success = erc20Bool.Value

return success, nil, received
}

return erc20Bool.Value, nil
success = true
return
}

// BalanceOf retrieves the balance of an ERC20 token for a specific account.
Expand Down
6 changes: 4 additions & 2 deletions x/evm/keeper/erc20_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,21 @@ 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)
_, err, received := deps.EvmKeeper.ERC20().Transfer(contract, deps.Sender.EthAddr, evm.EVM_MODULE_ADDRESS, big.NewInt(9_420), deps.Ctx)
s.ErrorContains(err, "ERC20: transfer amount exceeds balance")
// balances unchanged
evmtest.AssertERC20BalanceEqual(s.T(), deps, contract, deps.Sender.EthAddr, big.NewInt(0))
evmtest.AssertERC20BalanceEqual(s.T(), deps, contract, evm.EVM_MODULE_ADDRESS, big.NewInt(69_420))
s.Require().Equal(received, big.NewInt(0))
}

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)
_, err, received := deps.EvmKeeper.ERC20().Transfer(contract, evm.EVM_MODULE_ADDRESS, deps.Sender.EthAddr, big.NewInt(9_420), 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))
s.Require().Equal(received, big.NewInt(9_420))
}

s.T().Log("Burn tokens - Allowed as non-owner")
Expand Down
40 changes: 8 additions & 32 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 @@ -590,14 +590,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 @@ -619,52 +611,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(
success, err, actualReceivedAmount := 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")
if err != nil || !success {
return nil, errors.Wrap(err, "failed to transfer ERC-20 tokens")
Unique-Divine marked this conversation as resolved.
Show resolved Hide resolved
}

// 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")
}

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(actualReceivedAmount))
err = k.bankKeeper.BurnCoins(ctx, evm.ModuleName, sdk.NewCoins(burnCoin))
Unique-Divine marked this conversation as resolved.
Show resolved Hide resolved
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,
})

return &evm.MsgConvertCoinToEvmResponse{}, nil
Expand Down
2 changes: 1 addition & 1 deletion x/evm/precompile/funtoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ 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)
_, err, _ = p.evmKeeper.ERC20().Transfer(erc20, caller, transferTo, amount, ctx)
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

Handle the Received Amount from Transfer Method

At line 162:

_, err, _ = p.evmKeeper.ERC20().Transfer(erc20, caller, transferTo, amount, ctx)

The Transfer method now returns an additional value received *big.Int, which represents the actual amount transferred. Ignoring this value may lead to incorrect accounting, especially for tokens with fee-on-transfer mechanisms where the received amount could be less than the requested amount.

Consider capturing and utilizing the received amount to ensure accurate processing:

-receivedAmount, err, _ := p.evmKeeper.ERC20().Transfer(erc20, caller, transferTo, amount, ctx)
+receivedAmount, 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)
 }
+// Update the amount to reflect the actual received amount
+amount = receivedAmount

By handling receivedAmount, you ensure that subsequent operations use the correct token amount, which is crucial for maintaining consistency and correctness in transactions involving tokens with special transfer behaviors.

if err != nil {
return nil, fmt.Errorf("failed to send from caller to the EVM account: %w", err)
}
Expand Down