From ed0d764f7ecf6a7cafadd2f1e01507ba829a6477 Mon Sep 17 00:00:00 2001 From: Francisco de Borja Aranda Castillejo Date: Thu, 10 Oct 2024 14:16:19 +0200 Subject: [PATCH] add full unit testing suite --- e2e/e2etests/test_precompiles_bank.go | 20 +- .../test_precompiles_bank_through_contract.go | 20 +- precompiles/bank/method_deposit.go | 4 +- precompiles/bank/method_test.go | 2 +- precompiles/bank/method_withdraw.go | 6 +- .../keeper/zrc20_cosmos_coin_mapping_test.go | 384 ++++++++++++++++++ .../keeper/zrc20_cosmos_coins_mapping.go | 258 +++--------- x/fungible/keeper/zrc20_methods.go | 301 ++++++++++++++ x/fungible/keeper/zrc20_methods_test.go | 321 +++++++++++++++ x/fungible/types/errors.go | 5 + 10 files changed, 1101 insertions(+), 220 deletions(-) create mode 100644 x/fungible/keeper/zrc20_cosmos_coin_mapping_test.go create mode 100644 x/fungible/keeper/zrc20_methods.go create mode 100644 x/fungible/keeper/zrc20_methods_test.go diff --git a/e2e/e2etests/test_precompiles_bank.go b/e2e/e2etests/test_precompiles_bank.go index 1afd52d9cf..308f684572 100644 --- a/e2e/e2etests/test_precompiles_bank.go +++ b/e2e/e2etests/test_precompiles_bank.go @@ -10,7 +10,6 @@ import ( "github.com/zeta-chain/node/e2e/runner" "github.com/zeta-chain/node/e2e/utils" "github.com/zeta-chain/node/precompiles/bank" - fungibletypes "github.com/zeta-chain/node/x/fungible/types" ) func TestPrecompilesBank(r *runner.E2ERunner, args []string) { @@ -21,6 +20,7 @@ func TestPrecompilesBank(r *runner.E2ERunner, args []string) { higherBalanceAmount := big.NewInt(1001) higherAllowanceAmount := big.NewInt(501) spender := r.EVMAddress() + bankAddress := bank.ContractAddress // Increase the gasLimit. It's required because of the gas consumed by precompiled functions. previousGasLimit := r.ZEVMAuth.GasLimit @@ -30,7 +30,7 @@ func TestPrecompilesBank(r *runner.E2ERunner, args []string) { // Reset the allowance to 0; this is needed when running upgrade tests where // this test runs twice. - tx, err := r.ERC20ZRC20.Approve(r.ZEVMAuth, fungibletypes.ModuleAddressZEVM, big.NewInt(0)) + tx, err := r.ERC20ZRC20.Approve(r.ZEVMAuth, bankAddress, big.NewInt(0)) require.NoError(r, err) receipt := utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout) utils.RequireTxSuccessful(r, receipt, "Resetting allowance failed") @@ -60,7 +60,7 @@ func TestPrecompilesBank(r *runner.E2ERunner, args []string) { require.Equal(r, uint64(0), cosmosBalance.Uint64(), "spender cosmos coin balance should be 0") // Approve allowance of 500 ERC20ZRC20 tokens for the bank contract. Should pass. - tx, err := r.ERC20ZRC20.Approve(r.ZEVMAuth, fungibletypes.ModuleAddressZEVM, depositAmount) + tx, err := r.ERC20ZRC20.Approve(r.ZEVMAuth, bankAddress, depositAmount) require.NoError(r, err) receipt := utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout) utils.RequireTxSuccessful(r, receipt, "Approve ETHZRC20 bank allowance tx failed") @@ -73,7 +73,7 @@ func TestPrecompilesBank(r *runner.E2ERunner, args []string) { utils.RequiredTxFailed(r, receipt, "Depositting an amount higher than allowed should fail") // Approve allowance of 1000 ERC20ZRC20 tokens. - tx, err = r.ERC20ZRC20.Approve(r.ZEVMAuth, fungibletypes.ModuleAddressZEVM, big.NewInt(1e3)) + tx, err = r.ERC20ZRC20.Approve(r.ZEVMAuth, bankAddress, big.NewInt(1e3)) require.NoError(r, err) receipt = utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout) utils.RequireTxSuccessful(r, receipt, "Approve ETHZRC20 bank allowance tx failed") @@ -104,7 +104,7 @@ func TestPrecompilesBank(r *runner.E2ERunner, args []string) { require.Equal(r, uint64(500), cosmosBalance.Uint64(), "spender cosmos coin balance should be 500") // Bank: ERC20ZRC20 balance should be 500 tokens locked. - bankZRC20Balance, err := r.ERC20ZRC20.BalanceOf(&bind.CallOpts{Context: r.Ctx}, fungibletypes.ModuleAddressZEVM) + bankZRC20Balance, err := r.ERC20ZRC20.BalanceOf(&bind.CallOpts{Context: r.Ctx}, bankAddress) require.NoError(r, err, "Call ERC20ZRC20.BalanceOf") require.Equal(r, uint64(500), bankZRC20Balance.Uint64(), "bank ERC20ZRC20 balance should be 500") @@ -116,7 +116,7 @@ func TestPrecompilesBank(r *runner.E2ERunner, args []string) { // Bank: ERC20ZRC20 balance should be 500 tokens locked after a failed withdraw. // No tokens should be unlocked with a failed withdraw. - bankZRC20Balance, err = r.ERC20ZRC20.BalanceOf(&bind.CallOpts{Context: r.Ctx}, fungibletypes.ModuleAddressZEVM) + bankZRC20Balance, err = r.ERC20ZRC20.BalanceOf(&bind.CallOpts{Context: r.Ctx}, bankAddress) require.NoError(r, err, "Call ERC20ZRC20.BalanceOf") require.Equal(r, uint64(500), bankZRC20Balance.Uint64(), "bank ERC20ZRC20 balance should be 500") @@ -144,7 +144,7 @@ func TestPrecompilesBank(r *runner.E2ERunner, args []string) { require.Equal(r, uint64(1000), zrc20Balance.Uint64(), "spender ERC20ZRC20 balance should be 1000") // Bank: ERC20ZRC20 balance should be 0 tokens locked. - bankZRC20Balance, err = r.ERC20ZRC20.BalanceOf(&bind.CallOpts{Context: r.Ctx}, fungibletypes.ModuleAddressZEVM) + bankZRC20Balance, err = r.ERC20ZRC20.BalanceOf(&bind.CallOpts{Context: r.Ctx}, bankAddress) require.NoError(r, err, "Call ERC20ZRC20.BalanceOf") require.Equal(r, uint64(0), bankZRC20Balance.Uint64(), "bank ERC20ZRC20 balance should be 0") } @@ -159,7 +159,7 @@ func TestPrecompilesBankNonZRC20(r *runner.E2ERunner, args []string) { r.ZEVMAuth.GasLimit = previousGasLimit }() - spender, bankAddr := r.EVMAddress(), fungibletypes.ModuleAddressZEVM + spender, bankAddress := r.EVMAddress(), bank.ContractAddress // Create a bank contract caller. bankContract, err := bank.NewIBank(bank.ContractAddress, r.ZEVMClient) @@ -180,13 +180,13 @@ func TestPrecompilesBankNonZRC20(r *runner.E2ERunner, args []string) { ) // Allow the bank contract to spend 25 WZeta tokens. - tx, err := r.WZeta.Approve(r.ZEVMAuth, bankAddr, big.NewInt(25)) + tx, err := r.WZeta.Approve(r.ZEVMAuth, bankAddress, big.NewInt(25)) require.NoError(r, err, "Error approving allowance for bank contract") receipt := utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout) require.EqualValues(r, uint64(1), receipt.Status, "approve allowance tx failed") // Check the allowance of the bank in WZeta tokens. Should be 25. - allowance, err := r.WZeta.Allowance(&bind.CallOpts{Context: r.Ctx}, spender, bankAddr) + allowance, err := r.WZeta.Allowance(&bind.CallOpts{Context: r.Ctx}, spender, bankAddress) require.NoError(r, err, "Error retrieving bank allowance") require.EqualValues(r, uint64(25), allowance.Uint64(), "Error allowance for bank contract") diff --git a/e2e/e2etests/test_precompiles_bank_through_contract.go b/e2e/e2etests/test_precompiles_bank_through_contract.go index 178db56c1d..6d6384fd9e 100644 --- a/e2e/e2etests/test_precompiles_bank_through_contract.go +++ b/e2e/e2etests/test_precompiles_bank_through_contract.go @@ -12,13 +12,13 @@ import ( "github.com/zeta-chain/node/e2e/runner" "github.com/zeta-chain/node/e2e/utils" "github.com/zeta-chain/node/precompiles/bank" - fungibletypes "github.com/zeta-chain/node/x/fungible/types" ) func TestPrecompilesBankThroughContract(r *runner.E2ERunner, args []string) { require.Len(r, args, 0, "No arguments expected") spender := r.EVMAddress() + bankAddress := bank.ContractAddress zrc20Address := r.ERC20ZRC20Addr oneThousand := big.NewInt(1e3) oneThousandOne := big.NewInt(1001) @@ -60,7 +60,7 @@ func TestPrecompilesBankThroughContract(r *runner.E2ERunner, args []string) { // Check initial balances. balanceShouldBe(r, 0, checkCosmosBalance(r, testBank, zrc20Address, spender)) balanceShouldBe(r, 1000, checkZRC20Balance(r, spender)) - balanceShouldBe(r, 0, checkZRC20Balance(r, fungibletypes.ModuleAddressZEVM)) + balanceShouldBe(r, 0, checkZRC20Balance(r, bankAddress)) // Deposit without previous alllowance should fail. receipt = depositThroughTestBank(r, testBank, zrc20Address, oneThousand) @@ -69,10 +69,10 @@ func TestPrecompilesBankThroughContract(r *runner.E2ERunner, args []string) { // Check balances, should be the same. balanceShouldBe(r, 0, checkCosmosBalance(r, testBank, zrc20Address, spender)) balanceShouldBe(r, 1000, checkZRC20Balance(r, spender)) - balanceShouldBe(r, 0, checkZRC20Balance(r, fungibletypes.ModuleAddressZEVM)) + balanceShouldBe(r, 0, checkZRC20Balance(r, bankAddress)) // Allow 500 ZRC20 to bank precompile. - approveAllowance(r, fungibletypes.ModuleAddressZEVM, fiveHundred) + approveAllowance(r, bankAddress, fiveHundred) // Deposit 501 ERC20ZRC20 tokens to the bank contract, through TestBank. // It's higher than allowance but lower than balance, should fail. @@ -82,10 +82,10 @@ func TestPrecompilesBankThroughContract(r *runner.E2ERunner, args []string) { // Balances shouldn't change. balanceShouldBe(r, 0, checkCosmosBalance(r, testBank, zrc20Address, spender)) balanceShouldBe(r, 1000, checkZRC20Balance(r, spender)) - balanceShouldBe(r, 0, checkZRC20Balance(r, fungibletypes.ModuleAddressZEVM)) + balanceShouldBe(r, 0, checkZRC20Balance(r, bankAddress)) // Allow 1000 ZRC20 to bank precompile. - approveAllowance(r, fungibletypes.ModuleAddressZEVM, oneThousand) + approveAllowance(r, bankAddress, oneThousand) // Deposit 1001 ERC20ZRC20 tokens to the bank contract. // It's higher than spender balance but within approved allowance, should fail. @@ -95,7 +95,7 @@ func TestPrecompilesBankThroughContract(r *runner.E2ERunner, args []string) { // Balances shouldn't change. balanceShouldBe(r, 0, checkCosmosBalance(r, testBank, zrc20Address, spender)) balanceShouldBe(r, 1000, checkZRC20Balance(r, spender)) - balanceShouldBe(r, 0, checkZRC20Balance(r, fungibletypes.ModuleAddressZEVM)) + balanceShouldBe(r, 0, checkZRC20Balance(r, bankAddress)) // Deposit 500 ERC20ZRC20 tokens to the bank contract, it's within allowance and balance. Should pass. receipt = depositThroughTestBank(r, testBank, zrc20Address, fiveHundred) @@ -104,7 +104,7 @@ func TestPrecompilesBankThroughContract(r *runner.E2ERunner, args []string) { // Balances should be transferred. Bank now locks 500 ZRC20 tokens. balanceShouldBe(r, 500, checkCosmosBalance(r, testBank, zrc20Address, spender)) balanceShouldBe(r, 500, checkZRC20Balance(r, spender)) - balanceShouldBe(r, 500, checkZRC20Balance(r, fungibletypes.ModuleAddressZEVM)) + balanceShouldBe(r, 500, checkZRC20Balance(r, bankAddress)) // Check the deposit event. eventDeposit, err := bankPrecompileCaller.ParseDeposit(*receipt.Logs[0]) @@ -120,7 +120,7 @@ func TestPrecompilesBankThroughContract(r *runner.E2ERunner, args []string) { // Balances shouldn't change. balanceShouldBe(r, 500, checkCosmosBalance(r, testBank, zrc20Address, spender)) balanceShouldBe(r, 500, checkZRC20Balance(r, spender)) - balanceShouldBe(r, 500, checkZRC20Balance(r, fungibletypes.ModuleAddressZEVM)) + balanceShouldBe(r, 500, checkZRC20Balance(r, bankAddress)) // Try to withdraw 500 ERC20ZRC20 tokens. Should pass. receipt = withdrawThroughTestBank(r, testBank, zrc20Address, fiveHundred) @@ -129,7 +129,7 @@ func TestPrecompilesBankThroughContract(r *runner.E2ERunner, args []string) { // Balances should be reverted to initial state. balanceShouldBe(r, 0, checkCosmosBalance(r, testBank, zrc20Address, spender)) balanceShouldBe(r, 1000, checkZRC20Balance(r, spender)) - balanceShouldBe(r, 0, checkZRC20Balance(r, fungibletypes.ModuleAddressZEVM)) + balanceShouldBe(r, 0, checkZRC20Balance(r, bankAddress)) // Check the withdraw event. eventWithdraw, err := bankPrecompileCaller.ParseWithdraw(*receipt.Logs[0]) diff --git a/precompiles/bank/method_deposit.go b/precompiles/bank/method_deposit.go index b262438b87..ae620c34a9 100644 --- a/precompiles/bank/method_deposit.go +++ b/precompiles/bank/method_deposit.go @@ -18,7 +18,7 @@ import ( // The caller cosmos address will be calculated from the EVM caller address. by executing toAddr := sdk.AccAddress(addr.Bytes()). // This function can be think of a permissionless way of minting cosmos coins. // This is how deposit works: -// - The caller has to allow the Fungible ZEVM address to spend a certain amount ZRC20 token coins on its behalf. This is mandatory. +// - The caller has to allow the bank precompile address to spend a certain amount ZRC20 token coins on its behalf. This is mandatory. // - Then, the caller calls deposit(ZRC20 address, amount), to deposit the amount and receive cosmos coins. // - The bank will check there's enough balance, the caller is not a blocked address, and the token is a not paused ZRC20. // - Then the cosmos coins "zrc20/0x12345" will be minted and sent to the caller's cosmos address. @@ -100,7 +100,7 @@ func (c *Contract) deposit( } // 2. Effect: subtract balance. - if err := c.fungibleKeeper.LockZRC20(ctx, c.zrc20ABI, zrc20Addr, caller, amount); err != nil { + if err := c.fungibleKeeper.LockZRC20(ctx, c.zrc20ABI, zrc20Addr, caller, c.Address(), amount); err != nil { return nil, &ptypes.ErrUnexpected{ When: "LockZRC20InBank", Got: err.Error(), diff --git a/precompiles/bank/method_test.go b/precompiles/bank/method_test.go index e8ad80fb8b..f6601bccbe 100644 --- a/precompiles/bank/method_test.go +++ b/precompiles/bank/method_test.go @@ -572,7 +572,7 @@ func allowBank(t *testing.T, ts testSuite, amount *big.Int) { fungibletypes.ModuleAddressZEVM, ts.zrc20Address, "approve", - []interface{}{fungibletypes.ModuleAddressZEVM, amount}, + []interface{}{ts.bankContract.Address(), amount}, ) require.NoError(t, err, "error allowing bank to spend ZRC20 tokens") diff --git a/precompiles/bank/method_withdraw.go b/precompiles/bank/method_withdraw.go index 32bc570cd3..e071ed04cd 100644 --- a/precompiles/bank/method_withdraw.go +++ b/precompiles/bank/method_withdraw.go @@ -84,8 +84,8 @@ func (c *Contract) withdraw( return nil, err } - // Check if fungible address has enough ZRC20 balance. - if err := c.fungibleKeeper.CheckFungibleZRC20Balance(ctx, c.zrc20ABI, zrc20Addr, amount); err != nil { + // Check if bank address has enough ZRC20 balance. + if err := c.fungibleKeeper.CheckZRC20Balance(ctx, c.zrc20ABI, zrc20Addr, c.Address(), amount); err != nil { return nil, &ptypes.ErrInsufficientBalance{ Requested: amount.String(), Got: err.Error(), @@ -108,7 +108,7 @@ func (c *Contract) withdraw( } // 3. Interactions: send ZRC20. - if err := c.fungibleKeeper.UnlockZRC20(ctx, c.zrc20ABI, zrc20Addr, caller, amount); err != nil { + if err := c.fungibleKeeper.UnlockZRC20(ctx, c.zrc20ABI, zrc20Addr, caller, c.Address(), amount); err != nil { return nil, &ptypes.ErrUnexpected{ When: "UnlockZRC20InBank", Got: err.Error(), diff --git a/x/fungible/keeper/zrc20_cosmos_coin_mapping_test.go b/x/fungible/keeper/zrc20_cosmos_coin_mapping_test.go new file mode 100644 index 0000000000..f73b147e94 --- /dev/null +++ b/x/fungible/keeper/zrc20_cosmos_coin_mapping_test.go @@ -0,0 +1,384 @@ +package keeper_test + +import ( + "math/big" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/testutil/keeper" + "github.com/zeta-chain/node/testutil/sample" + fungiblekeeper "github.com/zeta-chain/node/x/fungible/keeper" + fungibletypes "github.com/zeta-chain/node/x/fungible/types" + "github.com/zeta-chain/protocol-contracts/v2/pkg/zrc20.sol" +) + +func Test_LockZRC20(t *testing.T) { + zrc20ABI, err := zrc20.ZRC20MetaData.GetAbi() + require.NoError(t, err) + + ts := setupChain(t) + + owner := fungibletypes.ModuleAddressZEVM + locker := sample.EthAddress() + depositTotal := big.NewInt(1000) + allowanceTotal := big.NewInt(100) + higherThanAllowance := big.NewInt(101) + smallerThanAllowance := big.NewInt(99) + + // Make sure locker account exists in state. + accAddress := sdk.AccAddress(locker.Bytes()) + ts.fungibleKeeper.GetAuthKeeper().SetAccount(ts.ctx, authtypes.NewBaseAccount(accAddress, nil, 0, 0)) + + // Deposit 1000 ZRC20 tokens into the fungible. + ts.fungibleKeeper.DepositZRC20(ts.ctx, ts.zrc20Address, owner, depositTotal) + + t.Run("should fail when trying to lock zero amount", func(t *testing.T) { + // Check lock with zero amount. + err = ts.fungibleKeeper.LockZRC20(ts.ctx, zrc20ABI, ts.zrc20Address, owner, locker, big.NewInt(0)) + require.Error(t, err) + require.ErrorIs(t, err, fungibletypes.ErrInvalidAmount) + }) + + t.Run("should fail when ZRC20 ABI is not properly initialized", func(t *testing.T) { + // Check lock with nil ABI. + err = ts.fungibleKeeper.LockZRC20(ts.ctx, nil, ts.zrc20Address, owner, locker, big.NewInt(10)) + require.Error(t, err) + require.ErrorIs(t, err, fungibletypes.ErrZRC20NilABI) + }) + + t.Run("should fail when trying to lock a zero address ZRC20", func(t *testing.T) { + // Check lock with ZRC20 zero address. + err = ts.fungibleKeeper.LockZRC20(ts.ctx, zrc20ABI, common.Address{}, owner, locker, big.NewInt(10)) + require.Error(t, err) + require.ErrorIs(t, err, fungibletypes.ErrZRC20ZeroAddress) + }) + + t.Run("should fail when trying to lock a non whitelisted ZRC20", func(t *testing.T) { + // Check lock with non whitelisted ZRC20. + err = ts.fungibleKeeper.LockZRC20(ts.ctx, zrc20ABI, sample.EthAddress(), owner, locker, big.NewInt(10)) + require.Error(t, err) + require.ErrorIs(t, err, fungibletypes.ErrZRC20NotWhiteListed) + }) + + t.Run("should fail when trying to lock a higher amount than totalSupply", func(t *testing.T) { + approveAllowance(t, ts, zrc20ABI, owner, locker, big.NewInt(1000000000000000)) + + // Check lock with higher amount than totalSupply. + err = ts.fungibleKeeper.LockZRC20(ts.ctx, zrc20ABI, ts.zrc20Address, owner, locker, big.NewInt(1000000000000000)) + require.Error(t, err) + require.ErrorIs(t, err, fungibletypes.ErrInvalidAmount) + }) + + t.Run("should fail when trying to lock a higher amount than owned balance", func(t *testing.T) { + approveAllowance(t, ts, zrc20ABI, owner, locker, big.NewInt(1001)) + + // Check allowance smaller, equal and bigger than the amount. + err = ts.fungibleKeeper.LockZRC20(ts.ctx, zrc20ABI, ts.zrc20Address, owner, locker, big.NewInt(1001)) + require.Error(t, err) + + // We do not check in LockZRC20 explicitly if the amount is bigger than the balance. + // Instead, the ERC20 transferFrom function will revert the transaction if the amount is bigger than the balance. + require.Contains(t, err.Error(), "execution reverted") + }) + + t.Run("should fail when trying to lock an amount higher than approved", func(t *testing.T) { + approveAllowance(t, ts, zrc20ABI, owner, locker, allowanceTotal) + + // Check allowance smaller, equal and bigger than the amount. + err = ts.fungibleKeeper.LockZRC20(ts.ctx, zrc20ABI, ts.zrc20Address, owner, locker, higherThanAllowance) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid allowance, got: 100") + }) + + t.Run("should pass when trying to lock a valid approved amount", func(t *testing.T) { + approveAllowance(t, ts, zrc20ABI, owner, locker, allowanceTotal) + + err = ts.fungibleKeeper.LockZRC20(ts.ctx, zrc20ABI, ts.zrc20Address, owner, locker, allowanceTotal) + require.NoError(t, err) + + ownerBalance, err := ts.fungibleKeeper.ZRC20BalanceOf(ts.ctx, zrc20ABI, ts.zrc20Address, owner) + require.NoError(t, err) + require.Equal(t, uint64(900), ownerBalance.Uint64()) + + lockerBalance, err := ts.fungibleKeeper.ZRC20BalanceOf(ts.ctx, zrc20ABI, ts.zrc20Address, locker) + require.NoError(t, err) + require.Equal(t, uint64(100), lockerBalance.Uint64()) + }) + + t.Run("should pass when trying to lock an amount smaller than approved", func(t *testing.T) { + approveAllowance(t, ts, zrc20ABI, owner, locker, allowanceTotal) + + err = ts.fungibleKeeper.LockZRC20(ts.ctx, zrc20ABI, ts.zrc20Address, owner, locker, smallerThanAllowance) + require.NoError(t, err) + + // Note that balances are cumulative for all tests. That's why we check 801 and 199 here. + ownerBalance, err := ts.fungibleKeeper.ZRC20BalanceOf(ts.ctx, zrc20ABI, ts.zrc20Address, owner) + require.NoError(t, err) + require.Equal(t, uint64(801), ownerBalance.Uint64()) + + lockerBalance, err := ts.fungibleKeeper.ZRC20BalanceOf(ts.ctx, zrc20ABI, ts.zrc20Address, locker) + require.NoError(t, err) + require.Equal(t, uint64(199), lockerBalance.Uint64()) + }) +} + +func Test_UnlockZRC20(t *testing.T) { + zrc20ABI, err := zrc20.ZRC20MetaData.GetAbi() + require.NoError(t, err) + + ts := setupChain(t) + + owner := fungibletypes.ModuleAddressZEVM + locker := sample.EthAddress() + depositTotal := big.NewInt(1000) + allowanceTotal := big.NewInt(100) + + // Make sure locker account exists in state. + accAddress := sdk.AccAddress(locker.Bytes()) + ts.fungibleKeeper.GetAuthKeeper().SetAccount(ts.ctx, authtypes.NewBaseAccount(accAddress, nil, 0, 0)) + + // Deposit 1000 ZRC20 tokens into the fungible. + ts.fungibleKeeper.DepositZRC20(ts.ctx, ts.zrc20Address, owner, depositTotal) + + // Approve allowance for locker to spend owner's ZRC20 tokens. + approveAllowance(t, ts, zrc20ABI, owner, locker, allowanceTotal) + + // Lock 100 ZRC20. + err = ts.fungibleKeeper.LockZRC20(ts.ctx, zrc20ABI, ts.zrc20Address, owner, locker, allowanceTotal) + require.NoError(t, err) + + t.Run("should fail when trying to unlock zero amount", func(t *testing.T) { + err = ts.fungibleKeeper.UnlockZRC20(ts.ctx, zrc20ABI, ts.zrc20Address, owner, locker, big.NewInt(0)) + require.Error(t, err) + require.ErrorIs(t, err, fungibletypes.ErrInvalidAmount) + }) + + t.Run("should fail when ZRC20 ABI is not properly initialized", func(t *testing.T) { + err = ts.fungibleKeeper.UnlockZRC20(ts.ctx, nil, ts.zrc20Address, owner, locker, big.NewInt(10)) + require.Error(t, err) + require.ErrorIs(t, err, fungibletypes.ErrZRC20NilABI) + }) + + t.Run("should fail when trying to unlock a zero address ZRC20", func(t *testing.T) { + err = ts.fungibleKeeper.UnlockZRC20(ts.ctx, zrc20ABI, common.Address{}, owner, locker, big.NewInt(10)) + require.Error(t, err) + require.ErrorIs(t, err, fungibletypes.ErrZRC20ZeroAddress) + }) + + t.Run("should fail when trying to unlock a non whitelisted ZRC20", func(t *testing.T) { + err = ts.fungibleKeeper.UnlockZRC20(ts.ctx, zrc20ABI, sample.EthAddress(), owner, locker, big.NewInt(10)) + require.Error(t, err) + require.ErrorIs(t, err, fungibletypes.ErrZRC20NotWhiteListed) + }) + + t.Run("should fail when trying to unlock an amount bigger than locker's balance", func(t *testing.T) { + err = ts.fungibleKeeper.UnlockZRC20(ts.ctx, zrc20ABI, ts.zrc20Address, owner, locker, big.NewInt(1001)) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid balance, got: 100") + }) + + t.Run("should pass when trying to unlock a correct amount", func(t *testing.T) { + err = ts.fungibleKeeper.UnlockZRC20(ts.ctx, zrc20ABI, ts.zrc20Address, owner, locker, allowanceTotal) + require.NoError(t, err) + + ownerBalance, err := ts.fungibleKeeper.ZRC20BalanceOf(ts.ctx, zrc20ABI, ts.zrc20Address, owner) + require.NoError(t, err) + require.Equal(t, uint64(1000), ownerBalance.Uint64()) + + lockerBalance, err := ts.fungibleKeeper.ZRC20BalanceOf(ts.ctx, zrc20ABI, ts.zrc20Address, locker) + require.NoError(t, err) + require.Equal(t, uint64(0), lockerBalance.Uint64()) + }) +} + +func Test_CheckZRC20Allowance(t *testing.T) { + zrc20ABI, err := zrc20.ZRC20MetaData.GetAbi() + require.NoError(t, err) + + ts := setupChain(t) + + owner := fungibletypes.ModuleAddressZEVM + spender := sample.EthAddress() + depositTotal := big.NewInt(1000) + allowanceTotal := big.NewInt(100) + higherThanAllowance := big.NewInt(101) + smallerThanAllowance := big.NewInt(99) + + // Make sure locker account exists in state. + accAddress := sdk.AccAddress(spender.Bytes()) + ts.fungibleKeeper.GetAuthKeeper().SetAccount(ts.ctx, authtypes.NewBaseAccount(accAddress, nil, 0, 0)) + + // Deposit ZRC20 tokens into the fungible. + ts.fungibleKeeper.DepositZRC20(ts.ctx, ts.zrc20Address, fungibletypes.ModuleAddressZEVM, depositTotal) + + t.Run("should fail when checking zero amount", func(t *testing.T) { + err = ts.fungibleKeeper.CheckZRC20Allowance(ts.ctx, zrc20ABI, owner, spender, ts.zrc20Address, big.NewInt(0)) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrInvalidAmount) + }) + + t.Run("should fail when allowance is not approved", func(t *testing.T) { + err = ts.fungibleKeeper.CheckZRC20Allowance(ts.ctx, zrc20ABI, owner, spender, ts.zrc20Address, big.NewInt(10)) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid allowance, got: 0") + }) + + t.Run("should fail when checking a higher amount than approved", func(t *testing.T) { + approveAllowance(t, ts, zrc20ABI, owner, spender, allowanceTotal) + + err = ts.fungibleKeeper.CheckZRC20Allowance(ts.ctx, zrc20ABI, owner, spender, ts.zrc20Address, higherThanAllowance) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid allowance, got: 100") + }) + + t.Run("should pass when checking the same amount as approved", func(t *testing.T) { + approveAllowance(t, ts, zrc20ABI, owner, spender, allowanceTotal) + + err = ts.fungibleKeeper.CheckZRC20Allowance(ts.ctx, zrc20ABI, owner, spender, ts.zrc20Address, allowanceTotal) + require.NoError(t, err) + }) + + t.Run("should pass when checking a lower amount than approved", func(t *testing.T) { + approveAllowance(t, ts, zrc20ABI, owner, spender, allowanceTotal) + + err = ts.fungibleKeeper.CheckZRC20Allowance(ts.ctx, zrc20ABI, owner, spender, ts.zrc20Address, smallerThanAllowance) + require.NoError(t, err) + }) +} + +func Test_IsValidZRC20(t *testing.T) { + ts := setupChain(t) + + t.Run("should fail when zrc20 address is zero", func(t *testing.T) { + err := ts.fungibleKeeper.IsValidZRC20(ts.ctx, common.Address{}) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZeroAddress) + }) + + t.Run("should fail when zrc20 is not whitelisted", func(t *testing.T) { + err := ts.fungibleKeeper.IsValidZRC20(ts.ctx, sample.EthAddress()) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20NotWhiteListed) + }) + + t.Run("should pass when zrc20 is a valid whitelisted token", func(t *testing.T) { + err := ts.fungibleKeeper.IsValidZRC20(ts.ctx, ts.zrc20Address) + require.NoError(t, err) + }) +} + +func Test_IsValidDepositAmount(t *testing.T) { + ts := setupChain(t) + + t.Run("should fail when any input is nil", func(t *testing.T) { + isValid := ts.fungibleKeeper.IsValidDepositAmount(nil, big.NewInt(0), big.NewInt(0)) + require.False(t, isValid) + + isValid = ts.fungibleKeeper.IsValidDepositAmount(big.NewInt(0), nil, big.NewInt(0)) + require.False(t, isValid) + + isValid = ts.fungibleKeeper.IsValidDepositAmount(big.NewInt(0), big.NewInt(0), nil) + require.False(t, isValid) + }) + + t.Run("should fail when alreadyLocked + amountToDeposit > totalSupply", func(t *testing.T) { + isValid := ts.fungibleKeeper.IsValidDepositAmount(big.NewInt(1000), big.NewInt(500), big.NewInt(501)) + require.False(t, isValid) + }) + + t.Run("should pass when alreadyLocked + amountToDeposit = totalSupply", func(t *testing.T) { + isValid := ts.fungibleKeeper.IsValidDepositAmount(big.NewInt(1000), big.NewInt(500), big.NewInt(500)) + require.True(t, isValid) + }) + + t.Run("should pass when alreadyLocked + amountToDeposit < totalSupply", func(t *testing.T) { + isValid := ts.fungibleKeeper.IsValidDepositAmount(big.NewInt(1000), big.NewInt(500), big.NewInt(499)) + require.True(t, isValid) + }) +} + +/* + Test utils. +*/ + +type testSuite struct { + ctx sdk.Context + fungibleKeeper *fungiblekeeper.Keeper + sdkKeepers keeper.SDKKeepers + zrc20Address common.Address +} + +func setupChain(t *testing.T) testSuite { + // Initialize basic parameters to mock the chain. + fungibleKeeper, ctx, sdkKeepers, _ := keeper.FungibleKeeper(t) + chainID := getValidChainID(t) + + // Make sure the account store is initialized. + // This is completely needed for accounts to be created in the state. + fungibleKeeper.GetAuthKeeper().GetModuleAccount(ctx, fungibletypes.ModuleName) + + // Deploy system contracts in order to deploy a ZRC20 token. + deploySystemContracts(t, ctx, fungibleKeeper, sdkKeepers.EvmKeeper) + + zrc20Address := setupGasCoin(t, ctx, fungibleKeeper, sdkKeepers.EvmKeeper, chainID, "ZRC20", "ZRC20") + + return testSuite{ + ctx, + fungibleKeeper, + sdkKeepers, + zrc20Address, + } +} + +func approveAllowance(t *testing.T, ts testSuite, zrc20ABI *abi.ABI, owner, spender common.Address, amount *big.Int) { + resAllowance, err := callEVM( + t, + ts.ctx, + ts.fungibleKeeper, + zrc20ABI, + owner, + ts.zrc20Address, + "approve", + []interface{}{spender, amount}, + ) + require.NoError(t, err, "error allowing bank to spend ZRC20 tokens") + + allowed, ok := resAllowance[0].(bool) + require.True(t, ok) + require.True(t, allowed) +} + +func callEVM( + t *testing.T, + ctx sdk.Context, + fungibleKeeper *fungiblekeeper.Keeper, + abi *abi.ABI, + from common.Address, + dst common.Address, + method string, + args []interface{}, +) ([]interface{}, error) { + res, err := fungibleKeeper.CallEVM( + ctx, // ctx + *abi, // abi + from, // from + dst, // to + big.NewInt(0), // value + nil, // gasLimit + true, // commit + true, // noEthereumTxEvent + method, // method + args..., // args + ) + require.NoError(t, err, "CallEVM error") + require.Equal(t, "", res.VmError, "res.VmError should be empty") + + ret, err := abi.Methods[method].Outputs.Unpack(res.Ret) + require.NoError(t, err, "Unpack error") + + return ret, nil +} diff --git a/x/fungible/keeper/zrc20_cosmos_coins_mapping.go b/x/fungible/keeper/zrc20_cosmos_coins_mapping.go index 4d5e86ae21..2013514758 100644 --- a/x/fungible/keeper/zrc20_cosmos_coins_mapping.go +++ b/x/fungible/keeper/zrc20_cosmos_coins_mapping.go @@ -12,228 +12,110 @@ import ( fungibletypes "github.com/zeta-chain/node/x/fungible/types" ) -const ( - transferFrom = "transferFrom" - transfer = "transfer" - balanceOf = "balanceOf" - allowance = "allowance" -) - -var ( - ErrZRC20ZeroAddress = fmt.Errorf("ZRC20 address cannot be zero") - ErrZRC20NotWhiteListed = fmt.Errorf("ZRC20 is not whitelisted") - ErrZRC20Paused = fmt.Errorf("ZRC20 is paused") - ErrZRC20NilABI = fmt.Errorf("ZRC20 ABI is nil") - ErrZeroAddress = fmt.Errorf("address cannot be zero") - ErrInvalidAmount = fmt.Errorf("amount must be positive") -) - -// LockZRC20 locks ZRC20 tokens in the bank contract. -// The caller must have approved the bank contract to spend the amount of ZRC20 tokens. +// LockZRC20 locks ZRC20 tokens in the specified address +// The caller must have approved the locker contract to spend the amount of ZRC20 tokens. +// Warning: This function does not mint cosmos coins, if the depositor needs to be rewarded +// it has to be implemented by the caller of this function. func (k Keeper) LockZRC20( ctx sdk.Context, zrc20ABI *abi.ABI, - zrc20Address, from common.Address, + zrc20Address, owner, locker common.Address, amount *big.Int, ) error { - if zrc20ABI == nil { - return ErrZRC20NilABI - } - - if amount.Sign() <= 0 || amount == nil { - return ErrInvalidAmount - } - - if crypto.IsEmptyAddress(from) { - return ErrZeroAddress - } - - if crypto.IsEmptyAddress(zrc20Address) { - return ErrZRC20ZeroAddress - } - - if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { + // owner is the EOA owner of the ZRC20 tokens. + // locker is the address that will lock the ZRC20 tokens, i.e: bank precompile. + if err := k.CheckZRC20Allowance(ctx, zrc20ABI, owner, locker, zrc20Address, amount); err != nil { return err } - if err := k.CheckFungibleZRC20Allowance(ctx, zrc20ABI, from, zrc20Address, amount); err != nil { + // Check amount_to_be_locked <= total_erc20_balance - already_locked + // Max amount of ZRC20 tokens that exists in zEVM are the total supply. + totalSupply, err := k.ZRC20TotalSupply(ctx, zrc20ABI, zrc20Address) + if err != nil { return err } - args := []interface{}{from, fungibletypes.ModuleAddressZEVM, amount} - res, err := k.CallEVM( - ctx, - *zrc20ABI, - fungibletypes.ModuleAddressZEVM, - zrc20Address, - big.NewInt(0), - nil, - true, - true, - transferFrom, - args..., - ) + // The alreadyLocked amount is the amount of ZRC20 tokens that have been locked by the locker. + // TODO: Implement list of whitelisted locker addresses. + alreadyLocked, err := k.ZRC20BalanceOf(ctx, zrc20ABI, zrc20Address, locker) if err != nil { return err } - if res.VmError != "" { - return fmt.Errorf("EVM execution error in LockZRC20: %s", res.VmError) + if !k.IsValidDepositAmount(totalSupply, alreadyLocked, amount) { + return fungibletypes.ErrInvalidAmount } - ret, err := zrc20ABI.Methods[transferFrom].Outputs.Unpack(res.Ret) + // Initiate a transferFrom the owner to the locker. This will lock the ZRC20 tokens. + // locker has to initiate the transaction and have enough allowance from owner. + transferred, err := k.ZRC20TransferFrom(ctx, zrc20ABI, zrc20Address, owner, locker, amount) if err != nil { return err } - if len(ret) == 0 { - return fmt.Errorf("no data returned from 'transferFrom' method") - } - - transferred, ok := ret[0].(bool) - if !ok { - return fmt.Errorf("transferFrom returned an unexpected value") - } - if !transferred { - return fmt.Errorf("transferFrom not successful") + return fmt.Errorf("lock ZRC20 not successful") } return nil } -// UnlockZRC20 unlocks ZRC20 tokens and sends them to the "to" address. +// UnlockZRC20 unlocks ZRC20 tokens and sends them to the owner. +// Warning: Before unlocking ZRC20 tokens, the caller must check if +// the owner has enough collateral (cosmos coins) to be exchanged (burnt) for the ZRC20 tokens. func (k Keeper) UnlockZRC20( ctx sdk.Context, zrc20ABI *abi.ABI, - zrc20Address, to common.Address, + zrc20Address, owner, locker common.Address, amount *big.Int, ) error { - if zrc20ABI == nil { - return ErrZRC20NilABI - } - - if amount.Sign() <= 0 || amount == nil { - return ErrInvalidAmount - } - - if crypto.IsEmptyAddress(to) { - return ErrZeroAddress - } - - if crypto.IsEmptyAddress(zrc20Address) { - return ErrZRC20ZeroAddress - } - - if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { - return err - } - - if err := k.CheckFungibleZRC20Balance(ctx, zrc20ABI, zrc20Address, amount); err != nil { + // Check if the account locking the ZRC20 tokens has enough balance. + if err := k.CheckZRC20Balance(ctx, zrc20ABI, zrc20Address, locker, amount); err != nil { return err } - args := []interface{}{to, amount} - res, err := k.CallEVM( - ctx, - *zrc20ABI, - fungibletypes.ModuleAddressZEVM, - zrc20Address, - big.NewInt(0), - nil, - true, - true, - transfer, - args..., - ) + // transfer from the EOA locking the assets to the owner. + transferred, err := k.ZRC20Transfer(ctx, zrc20ABI, zrc20Address, locker, owner, amount) if err != nil { return err } - if res.VmError != "" { - return fmt.Errorf("EVM execution error in UnlockZRC20: %s", res.VmError) - } - - ret, err := zrc20ABI.Methods[transfer].Outputs.Unpack(res.Ret) - if err != nil { - return err - } - - if len(ret) == 0 { - return fmt.Errorf("no data returned from 'transfer' method") - } - - transferred, ok := ret[0].(bool) - if !ok { - return fmt.Errorf("transfer returned an unexpected value") - } - if !transferred { - return fmt.Errorf("transfer not successful") + return fmt.Errorf("unlock ZRC20 not successful") } return nil } -// CheckFungibleZRC20Allowance checks if the allowance of ZRC20 tokens, +// CheckZRC20Allowance checks if the allowance of ZRC20 tokens, // is equal or greater than the provided amount. -func (k Keeper) CheckFungibleZRC20Allowance( +func (k Keeper) CheckZRC20Allowance( ctx sdk.Context, zrc20ABI *abi.ABI, - from, zrc20Address common.Address, + owner, spender, zrc20Address common.Address, amount *big.Int, ) error { if zrc20ABI == nil { - return ErrZRC20NilABI + return fungibletypes.ErrZRC20NilABI } if amount.Sign() <= 0 || amount == nil { - return ErrInvalidAmount + return fungibletypes.ErrInvalidAmount } - if crypto.IsEmptyAddress(from) { - return ErrZeroAddress - } - - if crypto.IsEmptyAddress(zrc20Address) { - return ErrZRC20ZeroAddress + if crypto.IsEmptyAddress(owner) || crypto.IsEmptyAddress(spender) { + return fungibletypes.ErrZeroAddress } - args := []interface{}{from, fungibletypes.ModuleAddressZEVM} - res, err := k.CallEVM( - ctx, - *zrc20ABI, - fungibletypes.ModuleAddressZEVM, - zrc20Address, - big.NewInt(0), - nil, - true, - true, - allowance, - args..., - ) - if err != nil { + if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { return err } - if res.VmError != "" { - return fmt.Errorf("EVM execution error calling allowance: %s", res.VmError) - } - - ret, err := zrc20ABI.Methods[allowance].Outputs.Unpack(res.Ret) + allowanceValue, err := k.ZRC20Allowance(ctx, zrc20ABI, zrc20Address, owner, spender) if err != nil { return err } - if len(ret) == 0 { - return fmt.Errorf("no data returned from 'allowance' method") - } - - allowanceValue, ok := ret[0].(*big.Int) - if !ok { - return fmt.Errorf("ZRC20 allowance returned an unexpected type") - } - if allowanceValue.Cmp(amount) < 0 || allowanceValue.Cmp(big.NewInt(0)) <= 0 { return fmt.Errorf("invalid allowance, got: %s", allowanceValue.String()) } @@ -241,60 +123,37 @@ func (k Keeper) CheckFungibleZRC20Allowance( return nil } -// CheckFungibleZRC20Balance checks if the balance of ZRC20 tokens, +// CheckZRC20Balance checks if the balance of ZRC20 tokens, // is equal or greater than the provided amount. -func (k Keeper) CheckFungibleZRC20Balance( +func (k Keeper) CheckZRC20Balance( ctx sdk.Context, zrc20ABI *abi.ABI, - zrc20Address common.Address, + zrc20Address, owner common.Address, amount *big.Int, ) error { if zrc20ABI == nil { - return ErrZRC20NilABI + return fungibletypes.ErrZRC20NilABI } if amount.Sign() <= 0 || amount == nil { - return ErrInvalidAmount + return fungibletypes.ErrInvalidAmount } - if crypto.IsEmptyAddress(zrc20Address) { - return ErrZRC20ZeroAddress - } - - res, err := k.CallEVM( - ctx, - *zrc20ABI, - fungibletypes.ModuleAddressZEVM, - zrc20Address, - big.NewInt(0), - nil, - true, - true, - balanceOf, - fungibletypes.ModuleAddressZEVM, - ) - if err != nil { + if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { return err } - if res.VmError != "" { - return fmt.Errorf("EVM execution error calling balanceOf: %s", res.VmError) + if crypto.IsEmptyAddress(owner) { + return fungibletypes.ErrZeroAddress } - ret, err := zrc20ABI.Methods[balanceOf].Outputs.Unpack(res.Ret) + // Check the ZRC20 balance of a given account. + // function balanceOf(address account) + balance, err := k.ZRC20BalanceOf(ctx, zrc20ABI, zrc20Address, owner) if err != nil { return err } - if len(ret) == 0 { - return fmt.Errorf("no data returned from 'balanceOf' method") - } - - balance, ok := ret[0].(*big.Int) - if !ok { - return fmt.Errorf("ZRC20 balanceOf returned an unexpected type") - } - if balance.Cmp(amount) == -1 { return fmt.Errorf("invalid balance, got: %s", balance.String()) } @@ -305,17 +164,28 @@ func (k Keeper) CheckFungibleZRC20Balance( // IsValidZRC20 returns an error whenever a ZRC20 is not whitelisted or paused. func (k Keeper) IsValidZRC20(ctx sdk.Context, zrc20Address common.Address) error { if crypto.IsEmptyAddress(zrc20Address) { - return ErrZRC20ZeroAddress + return fungibletypes.ErrZRC20ZeroAddress } t, found := k.GetForeignCoins(ctx, zrc20Address.String()) if !found { - return ErrZRC20NotWhiteListed + return fungibletypes.ErrZRC20NotWhiteListed } if t.Paused { - return ErrZRC20Paused + return fungibletypes.ErrPausedZRC20 } return nil } + +// IsValidDepositAmount checks "totalSupply >= amount_to_be_deposited + amount_already_locked". +// A failure here means the user is trying to lock more than the available ZRC20 supply. +// This suggests that an actor is minting ZRC20 tokens out of thin air. +func (k Keeper) IsValidDepositAmount(totalSupply, alreadyLocked, amountToDeposit *big.Int) bool { + if totalSupply == nil || alreadyLocked == nil || amountToDeposit == nil { + return false + } + + return totalSupply.Cmp(alreadyLocked.Add(alreadyLocked, amountToDeposit)) >= 0 +} diff --git a/x/fungible/keeper/zrc20_methods.go b/x/fungible/keeper/zrc20_methods.go new file mode 100644 index 0000000000..b3b636dfe4 --- /dev/null +++ b/x/fungible/keeper/zrc20_methods.go @@ -0,0 +1,301 @@ +package keeper + +import ( + "fmt" + "math/big" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + + "github.com/zeta-chain/node/pkg/crypto" + fungibletypes "github.com/zeta-chain/node/x/fungible/types" +) + +const ( + allowance = "allowance" + balanceOf = "balanceOf" + totalSupply = "totalSupply" + transfer = "transfer" + transferFrom = "transferFrom" +) + +// ZRC20Allowance returns the ZRC20 allowance for a given spender. +func (k Keeper) ZRC20Allowance( + ctx sdk.Context, + zrc20ABI *abi.ABI, + zrc20Address, owner, spender common.Address, +) (*big.Int, error) { + if zrc20ABI == nil { + return nil, fungibletypes.ErrZRC20NilABI + } + + if crypto.IsEmptyAddress(owner) || crypto.IsEmptyAddress(spender) { + return nil, fungibletypes.ErrZeroAddress + } + + if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { + return nil, err + } + + // function allowance(address owner, address spender) + args := []interface{}{owner, spender} + res, err := k.CallEVM( + ctx, + *zrc20ABI, + fungibletypes.ModuleAddressZEVM, + zrc20Address, + big.NewInt(0), + nil, + true, + true, + allowance, + args..., + ) + if err != nil { + return nil, err + } + + if res.VmError != "" { + return nil, fmt.Errorf("EVM execution error calling allowance: %s", res.VmError) + } + + ret, err := zrc20ABI.Methods[allowance].Outputs.Unpack(res.Ret) + if err != nil { + return nil, err + } + + if len(ret) == 0 { + return nil, fmt.Errorf("no data returned from 'allowance' method") + } + + allowanceValue, ok := ret[0].(*big.Int) + if !ok { + return nil, fmt.Errorf("ZRC20 allowance returned an unexpected type") + } + + return allowanceValue, nil +} + +// ZRC20BalanceOf checks the ZRC20 balance of a given EOA. +func (k Keeper) ZRC20BalanceOf( + ctx sdk.Context, + zrc20ABI *abi.ABI, + zrc20Address, owner common.Address, +) (*big.Int, error) { + if zrc20ABI == nil { + return nil, fungibletypes.ErrZRC20NilABI + } + + if crypto.IsEmptyAddress(owner) { + return nil, fungibletypes.ErrZeroAddress + } + + if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { + return nil, err + } + + res, err := k.CallEVM( + ctx, + *zrc20ABI, + fungibletypes.ModuleAddressZEVM, + zrc20Address, + big.NewInt(0), + nil, + true, + true, + balanceOf, + owner, + ) + if err != nil { + return nil, err + } + + if res.VmError != "" { + return nil, fmt.Errorf("EVM execution error calling balanceOf: %s", res.VmError) + } + + ret, err := zrc20ABI.Methods[balanceOf].Outputs.Unpack(res.Ret) + if err != nil { + return nil, err + } + + if len(ret) == 0 { + return nil, fmt.Errorf("no data returned from 'balanceOf' method") + } + + balance, ok := ret[0].(*big.Int) + if !ok { + return nil, fmt.Errorf("ZRC20 balanceOf returned an unexpected type") + } + + return balance, nil +} + +// ZRC20TotalSupply returns the total supply of a ZRC20 token. +func (k Keeper) ZRC20TotalSupply( + ctx sdk.Context, + zrc20ABI *abi.ABI, + zrc20Address common.Address, +) (*big.Int, error) { + if zrc20ABI == nil { + return nil, fungibletypes.ErrZRC20NilABI + } + + if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { + return nil, err + } + + // function totalSupply() public view virtual override returns (uint256) + res, err := k.CallEVM( + ctx, + *zrc20ABI, + fungibletypes.ModuleAddressZEVM, + zrc20Address, + big.NewInt(0), + nil, + true, + true, + totalSupply, + ) + if err != nil { + return nil, err + } + + if res.VmError != "" { + return nil, fmt.Errorf("EVM execution error calling totalSupply: %s", res.VmError) + } + + ret, err := zrc20ABI.Methods[totalSupply].Outputs.Unpack(res.Ret) + if err != nil { + return nil, err + } + + if len(ret) == 0 { + return nil, fmt.Errorf("no data returned from 'totalSupply' method") + } + + totalSupply, ok := ret[0].(*big.Int) + if !ok { + return nil, fmt.Errorf("ZRC20 totalSupply returned an unexpected type") + } + + return totalSupply, nil +} + +// ZRC20Transfer transfers ZRC20 tokens from the sender to the recipient. +func (k Keeper) ZRC20Transfer( + ctx sdk.Context, + zrc20ABI *abi.ABI, + zrc20Address, from, to common.Address, + amount *big.Int, +) (bool, error) { + if zrc20ABI == nil { + return false, fungibletypes.ErrZRC20NilABI + } + + if crypto.IsEmptyAddress(from) || crypto.IsEmptyAddress(to) { + return false, fungibletypes.ErrZeroAddress + } + + if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { + return false, err + } + + // transfer from the EOA locking the assets to the owner. + args := []interface{}{to, amount} + res, err := k.CallEVM( + ctx, + *zrc20ABI, + from, + zrc20Address, + big.NewInt(0), + nil, + true, + true, + transfer, + args..., + ) + if err != nil { + return false, err + } + + if res.VmError != "" { + return false, fmt.Errorf("EVM execution error in transfer: %s", res.VmError) + } + + ret, err := zrc20ABI.Methods[transfer].Outputs.Unpack(res.Ret) + if err != nil { + return false, err + } + + if len(ret) == 0 { + return false, fmt.Errorf("no data returned from 'transfer' method") + } + + transferred, ok := ret[0].(bool) + if !ok { + return false, fmt.Errorf("transfer returned an unexpected value") + } + + return transferred, nil +} + +// ZRC20TransferFrom transfers ZRC20 tokens from the owner to the spender. +// The transaction is started by the spender. +// This requires the spender to have been approved by the owner. +func (k Keeper) ZRC20TransferFrom( + ctx sdk.Context, + zrc20ABI *abi.ABI, + zrc20Address, from, to common.Address, + amount *big.Int, +) (bool, error) { + if zrc20ABI == nil { + return false, fungibletypes.ErrZRC20NilABI + } + + if crypto.IsEmptyAddress(from) || crypto.IsEmptyAddress(to) { + return false, fungibletypes.ErrZeroAddress + } + + if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { + return false, err + } + + args := []interface{}{from, to, amount} + res, err := k.CallEVM( + ctx, + *zrc20ABI, + to, + zrc20Address, + big.NewInt(0), + nil, + true, + true, + transferFrom, + args..., + ) + if err != nil { + return false, err + } + + if res.VmError != "" { + return false, fmt.Errorf("EVM execution error in transferFrom: %s", res.VmError) + } + + ret, err := zrc20ABI.Methods[transferFrom].Outputs.Unpack(res.Ret) + if err != nil { + return false, err + } + + if len(ret) == 0 { + return false, fmt.Errorf("no data returned from 'transferFrom' method") + } + + transferred, ok := ret[0].(bool) + if !ok { + return false, fmt.Errorf("transferFrom returned an unexpected value") + } + + return transferred, nil +} diff --git a/x/fungible/keeper/zrc20_methods_test.go b/x/fungible/keeper/zrc20_methods_test.go new file mode 100644 index 0000000000..2fb6992508 --- /dev/null +++ b/x/fungible/keeper/zrc20_methods_test.go @@ -0,0 +1,321 @@ +package keeper_test + +import ( + "math/big" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/testutil/sample" + fungibletypes "github.com/zeta-chain/node/x/fungible/types" + "github.com/zeta-chain/protocol-contracts/v2/pkg/zrc20.sol" +) + +func Test_ZRC20Allowance(t *testing.T) { + // Instantiate the ZRC20 ABI only one time. + // This avoids instantiating it every time deposit or withdraw are called. + zrc20ABI, err := zrc20.ZRC20MetaData.GetAbi() + require.NoError(t, err) + + ts := setupChain(t) + + t.Run("should fail when ZRC20ABI is nil", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20Allowance(ts.ctx, nil, ts.zrc20Address, common.Address{}, common.Address{}) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20NilABI) + }) + + t.Run("should fail when owner is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20Allowance( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + common.Address{}, + sample.EthAddress(), + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZeroAddress) + }) + + t.Run("should fail when spender is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20Allowance( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + sample.EthAddress(), + common.Address{}, + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZeroAddress) + }) + + t.Run("should fail when zrc20 address is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20Allowance( + ts.ctx, + zrc20ABI, + common.Address{}, + sample.EthAddress(), + fungibletypes.ModuleAddressZEVM, + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20ZeroAddress) + }) + + t.Run("should pass with correct input", func(t *testing.T) { + allowance, err := ts.fungibleKeeper.ZRC20Allowance( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + fungibletypes.ModuleAddressZEVM, + sample.EthAddress(), + ) + require.NoError(t, err) + require.Equal(t, uint64(0), allowance.Uint64()) + }) +} + +func Test_ZRC20BalanceOf(t *testing.T) { + // Instantiate the ZRC20 ABI only one time. + // This avoids instantiating it every time deposit or withdraw are called. + zrc20ABI, err := zrc20.ZRC20MetaData.GetAbi() + require.NoError(t, err) + + ts := setupChain(t) + + t.Run("should fail when ZRC20ABI is nil", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20BalanceOf(ts.ctx, nil, ts.zrc20Address, common.Address{}) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20NilABI) + }) + + t.Run("should fail when owner is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20BalanceOf(ts.ctx, zrc20ABI, ts.zrc20Address, common.Address{}) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZeroAddress) + }) + + t.Run("should fail when zrc20 address is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20BalanceOf(ts.ctx, zrc20ABI, common.Address{}, sample.EthAddress()) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20ZeroAddress) + }) + + t.Run("should pass with correct input", func(t *testing.T) { + balance, err := ts.fungibleKeeper.ZRC20BalanceOf( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + fungibletypes.ModuleAddressZEVM, + ) + require.NoError(t, err) + require.Equal(t, uint64(0), balance.Uint64()) + }) +} + +func Test_ZRC20TotalSupply(t *testing.T) { + // Instantiate the ZRC20 ABI only one time. + // This avoids instantiating it every time deposit or withdraw are called. + zrc20ABI, err := zrc20.ZRC20MetaData.GetAbi() + require.NoError(t, err) + + ts := setupChain(t) + + t.Run("should fail when ZRC20ABI is nil", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20TotalSupply(ts.ctx, nil, ts.zrc20Address) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20NilABI) + }) + + t.Run("should fail when zrc20 address is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20TotalSupply(ts.ctx, zrc20ABI, common.Address{}) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20ZeroAddress) + }) + + t.Run("should pass with correct input", func(t *testing.T) { + totalSupply, err := ts.fungibleKeeper.ZRC20TotalSupply(ts.ctx, zrc20ABI, ts.zrc20Address) + require.NoError(t, err) + require.Equal(t, uint64(10000000), totalSupply.Uint64()) + }) +} + +func Test_ZRC20Transfer(t *testing.T) { + // Instantiate the ZRC20 ABI only one time. + // This avoids instantiating it every time deposit or withdraw are called. + zrc20ABI, err := zrc20.ZRC20MetaData.GetAbi() + require.NoError(t, err) + + ts := setupChain(t) + + // Make sure sample.EthAddress() exists as an ethermint account in state. + accAddress := sdk.AccAddress(sample.EthAddress().Bytes()) + ts.fungibleKeeper.GetAuthKeeper().SetAccount(ts.ctx, authtypes.NewBaseAccount(accAddress, nil, 0, 0)) + + t.Run("should fail when ZRC20ABI is nil", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20Transfer( + ts.ctx, + nil, + ts.zrc20Address, + common.Address{}, + common.Address{}, + big.NewInt(0), + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20NilABI) + }) + + t.Run("should fail when owner is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20Transfer( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + common.Address{}, + sample.EthAddress(), + big.NewInt(0), + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZeroAddress) + }) + + t.Run("should fail when spender is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20Transfer( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + sample.EthAddress(), + common.Address{}, + big.NewInt(0), + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZeroAddress) + }) + + t.Run("should fail when zrc20 address is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20Transfer( + ts.ctx, + zrc20ABI, + common.Address{}, + sample.EthAddress(), + fungibletypes.ModuleAddressZEVM, + big.NewInt(0), + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20ZeroAddress) + }) + + t.Run("should pass with correct input", func(t *testing.T) { + ts.fungibleKeeper.DepositZRC20(ts.ctx, ts.zrc20Address, fungibletypes.ModuleAddressZEVM, big.NewInt(10)) + transferred, err := ts.fungibleKeeper.ZRC20Transfer( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + fungibletypes.ModuleAddressZEVM, + sample.EthAddress(), + big.NewInt(10), + ) + require.NoError(t, err) + require.True(t, transferred) + }) +} + +func Test_ZRC20TransferFrom(t *testing.T) { + // Instantiate the ZRC20 ABI only one time. + // This avoids instantiating it every time deposit or withdraw are called. + zrc20ABI, err := zrc20.ZRC20MetaData.GetAbi() + require.NoError(t, err) + + ts := setupChain(t) + + // Make sure sample.EthAddress() exists as an ethermint account in state. + accAddress := sdk.AccAddress(sample.EthAddress().Bytes()) + ts.fungibleKeeper.GetAuthKeeper().SetAccount(ts.ctx, authtypes.NewBaseAccount(accAddress, nil, 0, 0)) + + t.Run("should fail when ZRC20ABI is nil", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20TransferFrom( + ts.ctx, + nil, + ts.zrc20Address, + common.Address{}, + common.Address{}, + big.NewInt(0), + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20NilABI) + }) + + t.Run("should fail when owner is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20TransferFrom( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + common.Address{}, + sample.EthAddress(), + big.NewInt(0), + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZeroAddress) + }) + + t.Run("should fail when spender is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20TransferFrom( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + sample.EthAddress(), + common.Address{}, + big.NewInt(0), + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZeroAddress) + }) + + t.Run("should fail when zrc20 address is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20TransferFrom( + ts.ctx, + zrc20ABI, + common.Address{}, + sample.EthAddress(), + fungibletypes.ModuleAddressZEVM, + big.NewInt(0), + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20ZeroAddress) + }) + + t.Run("should fail without an allowance approval", func(t *testing.T) { + // Deposit ZRC20 into fungible EOA. + ts.fungibleKeeper.DepositZRC20(ts.ctx, ts.zrc20Address, fungibletypes.ModuleAddressZEVM, big.NewInt(1000)) + + // Transferring the tokens with transferFrom without approval should fail. + _, err = ts.fungibleKeeper.ZRC20TransferFrom( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + sample.EthAddress(), + fungibletypes.ModuleAddressZEVM, + big.NewInt(10), + ) + require.Error(t, err) + }) + + t.Run("should success with an allowance approval", func(t *testing.T) { + // Deposit ZRC20 into fungible EOA. + ts.fungibleKeeper.DepositZRC20(ts.ctx, ts.zrc20Address, fungibletypes.ModuleAddressZEVM, big.NewInt(1000)) + + // Approve allowance to sample.EthAddress() to spend 10 ZRC20 tokens. + approveAllowance(t, ts, zrc20ABI, fungibletypes.ModuleAddressZEVM, sample.EthAddress(), big.NewInt(10)) + + // Transferring the tokens with transferFrom without approval should fail. + _, err = ts.fungibleKeeper.ZRC20TransferFrom( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + sample.EthAddress(), + fungibletypes.ModuleAddressZEVM, + big.NewInt(10), + ) + require.Error(t, err) + }) +} diff --git a/x/fungible/types/errors.go b/x/fungible/types/errors.go index 7e426a3178..cf333c9545 100644 --- a/x/fungible/types/errors.go +++ b/x/fungible/types/errors.go @@ -29,4 +29,9 @@ var ( ErrNilGasPrice = cosmoserrors.Register(ModuleName, 1127, "nil gas price") ErrAccountNotFound = cosmoserrors.Register(ModuleName, 1128, "account not found") ErrGatewayContractNotSet = cosmoserrors.Register(ModuleName, 1129, "gateway contract not set") + ErrZRC20ZeroAddress = cosmoserrors.Register(ModuleName, 1130, "ZRC20 address cannot be zero") + ErrZRC20NotWhiteListed = cosmoserrors.Register(ModuleName, 1131, "ZRC20 is not whitelisted") + ErrZRC20NilABI = cosmoserrors.Register(ModuleName, 1132, "ZRC20 ABI is nil") + ErrZeroAddress = cosmoserrors.Register(ModuleName, 1133, "address cannot be zero") + ErrInvalidAmount = cosmoserrors.Register(ModuleName, 1134, "invalid amount") )