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): transfer for native to erc20 #2090

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
5 changes: 5 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 Expand Up @@ -158,6 +162,7 @@ reverts inside of a try-catch.
- [#2060](https://github.com/NibiruChain/nibiru/pull/2060) - fix(evm-precompiles): add assertNumArgs validation
- [#2056](https://github.com/NibiruChain/nibiru/pull/2056) - feat(evm): add oracle precompile
- [#2065](https://github.com/NibiruChain/nibiru/pull/2065) - refactor(evm)!: Refactor out dead code from the evm.Params
- [#2073](https://github.com/NibiruChain/nibiru/pull/2073) - fix(evm-keeper): better utilize ERC20 metadata during FunToken creation

### State Machine Breaking (Other)

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)
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Codebase verification

🛠️ Refactor suggestion

Based on the code review, I can now generate the final response:

Add test cases for non-standard ERC20 tokens

The current tests only use standard OpenZeppelin ERC20 implementations (TestERC20 and ERC20Minter), which follow the standard ERC20 interface. Given that the PR aims to handle non-standard tokens, you should add test cases for:

  1. Fee-on-transfer tokens (tokens that take a fee during transfer)
  2. Tokens that don't return a value from transfer
  3. Tokens with non-standard decimals
  4. Tokens that return false without reverting

Example approach:

  • Create mock contracts that implement these non-standard behaviors
  • Add test cases in x/evm/evmmodule/genesis_test.go using these mock contracts
  • Verify the system handles these edge cases correctly

The reason for this categorization is that the codebase only tests standard ERC20 implementations (OpenZeppelin's ERC20), which doesn't cover the non-standard token scenarios mentioned in the PR objectives. This gap in test coverage could lead to issues when handling non-standard tokens in production.

🔗 Analysis chain

Add test cases for non-standard ERC20 tokens.

The test only covers the happy path with a standard ERC20 token. Given the PR objectives to handle non-standard tokens, additional test cases are needed.

Let's verify if there are any test cases for non-standard tokens:

Consider adding test cases for:

  1. Fee-on-transfer tokens
  2. Tokens that don't return values from transfer
  3. Tokens that return false without reverting

Let me try a different approach to verify the test coverage for non-standard ERC20 tokens.

This will help us:

  1. Locate all test files related to ERC20
  2. See the test function implementations to check for non-standard token scenarios

Let me check the actual ERC20 contract implementation and test setup to understand what kind of tokens are being tested.

This will help us understand:

  1. The type of ERC20 tokens being created in tests
  2. The actual contract implementation
  3. Any test utilities that might be using different token implementations

Let me check the ERC20 contract implementation that's being used in the tests.

This will help us understand:

  1. The ERC20 contract implementation being used in tests
  2. Any existing transfer-related test cases
🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Search for test cases involving non-standard ERC20 tokens
rg -l "TestERC20" | xargs rg -l "fee.*transfer|non.*standard"

Length of output: 61


Script:

#!/bin/bash
# First, let's find all ERC20-related test files
fd ".*test.*" | rg -l "ERC20"

# Then, let's examine the test implementations to understand the coverage
rg -A 10 "func.*Test.*ERC20.*" 

Length of output: 2233


Script:

#!/bin/bash
# Let's examine the ERC20 contract implementation and test setup
rg -l "CreateFunToken" | xargs rg -A 10 "CreateFunToken"

# Also check for any ERC20 contract deployments or mocks
fd ".*\.sol" | xargs cat

# Check test utilities and helpers
rg -A 5 "ERC20" x/evm/evmtest/

Length of output: 66063


Script:

#!/bin/bash
# Check the ERC20 contract implementation
cat x/evm/embeds/contracts/ERC20Minter.sol
cat x/evm/embeds/contracts/TestERC20.sol

# Look for any test files that might test non-standard behavior
rg -A 10 "func.*Test.*Transfer" x/evm/

Length of output: 3136


// 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 @@
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
}

Check warning on line 82 in x/evm/keeper/erc20.go

View check run for this annotation

Codecov / codecov/patch

x/evm/keeper/erc20.go#L81-L82

Added lines #L81 - L82 were not covered by tests
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Codebase verification

Based on the search results, I can now generate the final response:

Test coverage needed for fee-on-transfer token handling

The balance tracking implementation in erc20.go lacks specific test coverage for fee-on-transfer scenarios. While there is a test for malicious transfer behavior (TestFunTokenFromERC20MaliciousTransfer), it doesn't verify the balance tracking logic for:

  • Balance retrieval error paths
  • Actual token amount received calculation
  • Zero/negative received amount validation

Suggested test cases to add:

  • Test with a fee-on-transfer token that deducts fees from the transfer amount
  • Test error handling when balance checks fail
  • Test validation when received amount is zero or negative
🔗 Analysis chain

Balance tracking implementation looks good but needs test coverage.

The implementation correctly handles fee-on-transfer tokens by:

  1. Tracking balance before and after transfer
  2. Computing actual received amount
  3. Validating that some tokens were received

However, several critical paths lack test coverage:

  • Error handling for balance retrieval (lines 81-82, 95-96)
  • Validation of received amount (line 104)

Let's verify the test coverage:

Also applies to: 93-96, 98-104


Let me check the actual test implementations to verify the coverage.


Let me search for test files in a broader context to find balance-related tests.


Let me check the test implementations in the funtoken test files since they might contain the balance verification tests.

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Check for existing test cases in the codebase
# Look for test files that might contain relevant test cases
fd -e go -e _test.go | xargs rg -l "TestERC20.*Transfer|TestFeeOnTransfer"

Length of output: 168


Script:

#!/bin/bash
# Check the test implementations for transfer-related tests
rg -A 10 "TestERC20.*Transfer|TestFeeOnTransfer" x/evm/keeper/funtoken_from_erc20_test.go x/evm/embeds/embeds_test.go

# Also search for any test cases that verify balances
rg -B 2 -A 5 "BalanceOf.*ctx" x/evm/keeper/funtoken_from_erc20_test.go x/evm/embeds/embeds_test.go

Length of output: 1147


Script:

#!/bin/bash
# Search for test files related to ERC20 keeper
fd -e go test.go keeper | xargs rg -l "erc20"

# Search for balance verification patterns in test files
rg -B 2 -A 5 "balance.*Before|balance.*After|received.*amount" x/evm/keeper/

Length of output: 1676


Script:

#!/bin/bash
# Check funtoken test files for balance verification tests
rg -B 5 -A 10 "Transfer.*\(" x/evm/keeper/funtoken_from_erc20_test.go x/evm/keeper/funtoken_state_test.go

# Also check for any balance verification in the precompile tests
rg -B 5 -A 10 "Transfer.*\(" x/evm/precompile/funtoken_test.go

Length of output: 1409

🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 81-82: x/evm/keeper/erc20.go#L81-L82
Added lines #L81 - L82 were not covered by tests


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

Check warning on line 86 in x/evm/keeper/erc20.go

View check run for this annotation

Codecov / codecov/patch

x/evm/keeper/erc20.go#L86

Added line #L86 was not covered by tests
}
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
}

Check warning on line 96 in x/evm/keeper/erc20.go

View check run for this annotation

Codecov / codecov/patch

x/evm/keeper/erc20.go#L95-L96

Added lines #L95 - L96 were not covered by tests

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

Check warning on line 104 in x/evm/keeper/erc20.go

View check run for this annotation

Codecov / codecov/patch

x/evm/keeper/erc20.go#L104

Added line #L104 was not covered by tests
}

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

Check warning on line 122 in x/evm/keeper/erc20.go

View check run for this annotation

Codecov / codecov/patch

x/evm/keeper/erc20.go#L121-L122

Added lines #L121 - L122 were not covered by tests
}

// 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 @@ -34,19 +34,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
Copy link
Member

@Unique-Divine Unique-Divine Oct 24, 2024

Choose a reason for hiding this comment

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

This PR only editing msg_server.go this much is not addressing the issue. The change needed is to use the success boolean returned by ERC20().Transfer.

Ref: ERC20 Specification

"Callers MUST handle false from returns (bool success). Callers MUST NOT assume that false is never returned!"

The following implementation could work, for example. Then you can freely use the function and depend on its error value.

func (e erc20Calls) Transfer(
	contract, from, to gethcommon.Address, amount *big.Int,
	ctx sdk.Context,
) (success bool, err error) {
	input, err := e.ABI.Pack("transfer", to, amount)
	if err != nil {
		return false, fmt.Errorf("failed to pack ABI args: %w", err)
	}
	resp, err := e.CallContractWithInput(ctx, from, &contract, true, input)
	if err != nil {
		return false, err
	}

	var erc20Bool ERC20Bool
	err = e.ABI.UnpackIntoInterface(&erc20Bool, "transfer", resp.Ret)
	// ➡️ This check guarantees that a boolean is parsed out
	if err != nil {
		return false, err
	}

	// ➡️ This check fixes the issue
	success = erc20Bool.Value
	if !success {
		return false, fmt.Errorf("called executed, but returned success=false")
	}
	return success, nil
}

We should also comment on why we treat success=false as an error.

Edit: On second thought, if you write it like this, it removes the need for the boolean return value entirely. You could rewrite the function to only return an error. @matthiasmatt

Original file line number Diff line number Diff line change
Expand Up @@ -554,14 +554,6 @@
) (*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 +575,36 @@
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")

Check warning on line 578 in x/evm/keeper/msg_server.go

View check run for this annotation

Codecov / codecov/patch

x/evm/keeper/msg_server.go#L578

Added line #L578 was not covered by tests
}
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")

Check warning on line 593 in x/evm/keeper/msg_server.go

View check run for this annotation

Codecov / codecov/patch

x/evm/keeper/msg_server.go#L593

Added line #L593 was not covered by tests
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 nil error correctly when transfer fails

If err is nil but success is false, calling errors.Wrap(err, ...) with a nil error may result in an unintended error message. Ensure that the error handling correctly constructs the error message when err is nil.

Apply this diff to fix the error handling:

 if err != nil || !success {
-    return nil, errors.Wrap(err, "failed to transfer ERC-20 tokens")
+    if err != nil {
+        return nil, errors.Wrap(err, "failed to transfer ERC-20 tokens")
+    } else {
+        return nil, fmt.Errorf("failed to transfer ERC-20 tokens")
+    }
 }
📝 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
if err != nil || !success {
return nil, errors.Wrap(err, "failed to transfer ERC-20 tokens")
if err != nil || !success {
if err != nil {
return nil, errors.Wrap(err, "failed to transfer ERC-20 tokens")
} else {
return nil, fmt.Errorf("failed to transfer ERC-20 tokens")
}
}
🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 593-593: x/evm/keeper/msg_server.go#L593
Added line #L593 was not covered by tests

}

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

Ensure actualReceivedAmount is positive before burning coins

Before creating burnCoin and burning coins, verify that actualReceivedAmount is positive to avoid potential errors when dealing with zero or negative amounts.

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 @@ -143,7 +143,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
Loading