diff --git a/changelog.md b/changelog.md index 3f8206a9f1..2dd8e75f9a 100644 --- a/changelog.md +++ b/changelog.md @@ -19,6 +19,7 @@ * [2957](https://github.com/zeta-chain/node/pull/2957) - enable Bitcoin inscription support on testnet * [2896](https://github.com/zeta-chain/node/pull/2896) - add TON inbound observation * [2987](https://github.com/zeta-chain/node/pull/2987) - add non-EVM standard inbound memo package +* [2979](https://github.com/zeta-chain/node/pull/2979) - add fungible keeper ability to lock/unlock ZRC20 tokens ### Refactor diff --git a/e2e/e2etests/test_precompiles_bank.go b/e2e/e2etests/test_precompiles_bank.go index 2517410339..308f684572 100644 --- a/e2e/e2etests/test_precompiles_bank.go +++ b/e2e/e2etests/test_precompiles_bank.go @@ -20,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 @@ -29,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, bank.ContractAddress, 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") @@ -59,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, bank.ContractAddress, 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") @@ -72,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, bank.ContractAddress, 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") @@ -103,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}, bank.ContractAddress) + 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") @@ -115,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}, bank.ContractAddress) + 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") @@ -143,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}, bank.ContractAddress) + 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") } @@ -158,7 +159,7 @@ func TestPrecompilesBankNonZRC20(r *runner.E2ERunner, args []string) { r.ZEVMAuth.GasLimit = previousGasLimit }() - spender, bankAddr := r.EVMAddress(), bank.ContractAddress + spender, bankAddress := r.EVMAddress(), bank.ContractAddress // Create a bank contract caller. bankContract, err := bank.NewIBank(bank.ContractAddress, r.ZEVMClient) @@ -179,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 480663284e..6d6384fd9e 100644 --- a/e2e/e2etests/test_precompiles_bank_through_contract.go +++ b/e2e/e2etests/test_precompiles_bank_through_contract.go @@ -18,6 +18,7 @@ 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) @@ -59,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, bank.ContractAddress)) + balanceShouldBe(r, 0, checkZRC20Balance(r, bankAddress)) // Deposit without previous alllowance should fail. receipt = depositThroughTestBank(r, testBank, zrc20Address, oneThousand) @@ -68,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, bank.ContractAddress)) + balanceShouldBe(r, 0, checkZRC20Balance(r, bankAddress)) // Allow 500 ZRC20 to bank precompile. - approveAllowance(r, bank.ContractAddress, 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. @@ -81,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, bank.ContractAddress)) + balanceShouldBe(r, 0, checkZRC20Balance(r, bankAddress)) // Allow 1000 ZRC20 to bank precompile. - approveAllowance(r, bank.ContractAddress, 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. @@ -94,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, bank.ContractAddress)) + 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) @@ -103,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, bank.ContractAddress)) + balanceShouldBe(r, 500, checkZRC20Balance(r, bankAddress)) // Check the deposit event. eventDeposit, err := bankPrecompileCaller.ParseDeposit(*receipt.Logs[0]) @@ -119,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, bank.ContractAddress)) + balanceShouldBe(r, 500, checkZRC20Balance(r, bankAddress)) // Try to withdraw 500 ERC20ZRC20 tokens. Should pass. receipt = withdrawThroughTestBank(r, testBank, zrc20Address, fiveHundred) @@ -128,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, bank.ContractAddress)) + 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 2f57944723..1d5792d8a3 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 bank contract 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. @@ -59,22 +59,6 @@ func (c *Contract) deposit( return nil, err } - // Safety check: token has to be a valid whitelisted ZRC20 and not be paused. - t, found := c.fungibleKeeper.GetForeignCoins(ctx, zrc20Addr.String()) - if !found { - return nil, &ptypes.ErrInvalidToken{ - Got: zrc20Addr.String(), - Reason: "token is not a whitelisted ZRC20", - } - } - - if t.Paused { - return nil, &ptypes.ErrInvalidToken{ - Got: zrc20Addr.String(), - Reason: "token is paused", - } - } - // Check for enough balance. // function balanceOf(address account) public view virtual override returns (uint256) resBalanceOf, err := c.CallContract( @@ -105,71 +89,24 @@ func (c *Contract) deposit( } } - // Check for enough bank's allowance. - // function allowance(address owner, address spender) public view virtual override returns (uint256) - resAllowance, err := c.CallContract( - ctx, - &c.fungibleKeeper, - c.zrc20ABI, - zrc20Addr, - "allowance", - []interface{}{caller, ContractAddress}, - ) - if err != nil { - return nil, &ptypes.ErrUnexpected{ - When: "allowance", - Got: err.Error(), - } - } - - allowance, ok := resAllowance[0].(*big.Int) - if !ok { - return nil, &ptypes.ErrUnexpected{ - Got: "ZRC20 allowance returned an unexpected type", - } - } - - if allowance.Cmp(amount) < 0 || allowance.Cmp(big.NewInt(0)) <= 0 { - return nil, &ptypes.ErrInvalidAmount{ - Got: allowance.String(), - } - } - // The process of creating a new cosmos coin is: // - Generate the new coin denom using ZRC20 address, // this way we map ZRC20 addresses to cosmos denoms "zevm/0x12345". - // - Mint coins. - // - Send coins to the caller. + // - Mint coins to the fungible module. + // - Send coins from fungible to the caller. coinSet, err := createCoinSet(ZRC20ToCosmosDenom(zrc20Addr), amount) if err != nil { return nil, err } // 2. Effect: subtract balance. - // function transferFrom(address sender, address recipient, uint256 amount) public virtual override returns (bool) - resTransferFrom, err := c.CallContract( - ctx, - &c.fungibleKeeper, - c.zrc20ABI, - zrc20Addr, - "transferFrom", - []interface{}{caller, ContractAddress, amount}, - ) - if err != nil { + if err := c.fungibleKeeper.LockZRC20(ctx, c.zrc20ABI, zrc20Addr, c.Address(), caller, c.Address(), amount); err != nil { return nil, &ptypes.ErrUnexpected{ - When: "transferFrom", + When: "LockZRC20InBank", Got: err.Error(), } } - transferred, ok := resTransferFrom[0].(bool) - if !ok || !transferred { - return nil, &ptypes.ErrUnexpected{ - When: "transferFrom", - Got: "transaction not successful", - } - } - // 3. Interactions: create cosmos coin and send. if err := c.bankKeeper.MintCoins(ctx, types.ModuleName, coinSet); err != nil { return nil, &ptypes.ErrUnexpected{ @@ -205,7 +142,7 @@ func unpackDepositArgs(args []interface{}) (zrc20Addr common.Address, amount *bi } amount, ok = args[1].(*big.Int) - if !ok || amount.Sign() < 0 || amount == nil || amount == new(big.Int) { + if !ok || amount == nil || amount.Sign() <= 0 { return common.Address{}, nil, &ptypes.ErrInvalidAmount{ Got: amount.String(), } diff --git a/precompiles/bank/method_test.go b/precompiles/bank/method_test.go index 107439c576..e416187490 100644 --- a/precompiles/bank/method_test.go +++ b/precompiles/bank/method_test.go @@ -130,18 +130,12 @@ func Test_Methods(t *testing.T) { ts.mockVMContract.Input = packInputArgs( t, methodID, - []interface{}{ts.zrc20Address, big.NewInt(0)}..., + []interface{}{ts.zrc20Address, big.NewInt(1000)}..., ) success, err := ts.bankContract.Run(ts.mockEVM, ts.mockVMContract, false) require.Error(t, err) - require.ErrorAs( - t, - ptypes.ErrInvalidAmount{ - Got: "0", - }, - err, - ) + require.Contains(t, err.Error(), "invalid allowance, got 0") res, err := ts.bankABI.Methods[DepositMethodName].Outputs.Unpack(success) require.NoError(t, err) @@ -150,6 +144,33 @@ func Test_Methods(t *testing.T) { require.False(t, ok) }) + t.Run("should fail when trying to deposit 0", func(t *testing.T) { + ts := setupChain(t) + caller := fungibletypes.ModuleAddressEVM + ts.fungibleKeeper.DepositZRC20(ts.ctx, ts.zrc20Address, caller, big.NewInt(1000)) + + methodID := ts.bankABI.Methods[DepositMethodName] + + // Allow bank to spend 500 ZRC20 tokens. + allowBank(t, ts, big.NewInt(500)) + + // Set CallerAddress and evm.Origin to the caller address. + // Caller does not have any balance, and bank does not have any allowance. + ts.mockVMContract.CallerAddress = caller + ts.mockEVM.Origin = caller + + // Set the input arguments for the deposit method. + ts.mockVMContract.Input = packInputArgs( + t, + methodID, + []interface{}{ts.zrc20Address, big.NewInt(0)}..., + ) + + _, err := ts.bankContract.Run(ts.mockEVM, ts.mockVMContract, false) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid token amount: 0") + }) + t.Run("should fail when trying to deposit more than allowed to bank", func(t *testing.T) { ts := setupChain(t) caller := fungibletypes.ModuleAddressEVM @@ -173,12 +194,10 @@ func Test_Methods(t *testing.T) { success, err := ts.bankContract.Run(ts.mockEVM, ts.mockVMContract, false) require.Error(t, err) - require.ErrorAs( + require.Contains( t, - ptypes.ErrInvalidAmount{ - Got: "500", - }, - err, + err.Error(), + "unexpected error in LockZRC20InBank: failed allowance check: invalid allowance, got 500, wanted 501", ) res, err := ts.bankABI.Methods[DepositMethodName].Outputs.Unpack(success) diff --git a/precompiles/bank/method_withdraw.go b/precompiles/bank/method_withdraw.go index 99cb4f763c..e071ed04cd 100644 --- a/precompiles/bank/method_withdraw.go +++ b/precompiles/bank/method_withdraw.go @@ -55,19 +55,11 @@ func (c *Contract) withdraw( return nil, err } - // Safety check: token has to be a valid whitelisted ZRC20 and not be paused. - t, found := c.fungibleKeeper.GetForeignCoins(ctx, zrc20Addr.String()) - if !found { + // Safety check: token has to be a non-paused whitelisted ZRC20. + if err := c.fungibleKeeper.IsValidZRC20(ctx, zrc20Addr); err != nil { return nil, &ptypes.ErrInvalidToken{ Got: zrc20Addr.String(), - Reason: "token is not a whitelisted ZRC20", - } - } - - if t.Paused { - return nil, &ptypes.ErrInvalidToken{ - Got: zrc20Addr.String(), - Reason: "token is paused", + Reason: err.Error(), } } @@ -92,33 +84,11 @@ func (c *Contract) withdraw( return nil, err } - // Check for bank's ZRC20 balance. - // function balanceOf(address account) public view virtual override returns (uint256) - resBalanceOf, err := c.CallContract( - ctx, - &c.fungibleKeeper, - c.zrc20ABI, - zrc20Addr, - "balanceOf", - []interface{}{ContractAddress}, - ) - if err != nil { - return nil, &ptypes.ErrUnexpected{ - When: "balanceOf", - Got: err.Error(), - } - } - - balance, ok := resBalanceOf[0].(*big.Int) - if !ok { - return nil, &ptypes.ErrUnexpected{ - Got: "ZRC20 balanceOf returned an unexpected type", - } - } - - if balance.Cmp(amount) == -1 { - return nil, &ptypes.ErrInvalidAmount{ - Got: balance.String(), + // 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(), } } @@ -137,45 +107,21 @@ func (c *Contract) withdraw( } } - if err := c.addEventLog(ctx, evm.StateDB, WithdrawEventName, eventData{caller, zrc20Addr, fromAddr.String(), coinSet.Denoms()[0], amount}); err != nil { + // 3. Interactions: send ZRC20. + if err := c.fungibleKeeper.UnlockZRC20(ctx, c.zrc20ABI, zrc20Addr, caller, c.Address(), amount); err != nil { return nil, &ptypes.ErrUnexpected{ - When: "AddWithdrawLog", + When: "UnlockZRC20InBank", Got: err.Error(), } } - // 3. Interactions: send to module and burn. - - // function transfer(address recipient, uint256 amount) public virtual override returns (bool) - resTransfer, err := c.CallContract( - ctx, - &c.fungibleKeeper, - c.zrc20ABI, - zrc20Addr, - "transfer", - []interface{}{caller /* sender */, amount}, - ) - if err != nil { + if err := c.addEventLog(ctx, evm.StateDB, WithdrawEventName, eventData{caller, zrc20Addr, fromAddr.String(), coinSet.Denoms()[0], amount}); err != nil { return nil, &ptypes.ErrUnexpected{ - When: "transfer", + When: "AddWithdrawLog", Got: err.Error(), } } - transferred, ok := resTransfer[0].(bool) - if !ok { - return nil, &ptypes.ErrUnexpected{ - Got: "ZRC20 transfer returned an unexpected type", - } - } - - if !transferred { - return nil, &ptypes.ErrUnexpected{ - When: "transfer", - Got: "transaction not successful", - } - } - return method.Outputs.Pack(true) } @@ -188,7 +134,7 @@ func unpackWithdrawArgs(args []interface{}) (zrc20Addr common.Address, amount *b } amount, ok = args[1].(*big.Int) - if !ok || amount.Sign() < 0 || amount == nil || amount == new(big.Int) { + if !ok || amount == nil || amount.Sign() <= 0 { return common.Address{}, nil, &ptypes.ErrInvalidAmount{ Got: amount.String(), } 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..16803a917c --- /dev/null +++ b/x/fungible/keeper/zrc20_cosmos_coin_mapping_test.go @@ -0,0 +1,414 @@ +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.ModuleAddressEVM + 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, locker, 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, locker, 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{}, locker, 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(), locker, 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, + locker, + 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, locker, 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, locker, 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, locker, 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, + locker, + 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.ModuleAddressEVM + 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, locker, 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.ModuleAddressEVM + 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.ModuleAddressEVM, 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 new file mode 100644 index 0000000000..f7d152f749 --- /dev/null +++ b/x/fungible/keeper/zrc20_cosmos_coins_mapping.go @@ -0,0 +1,192 @@ +package keeper + +import ( + "fmt" + "math/big" + + "cosmossdk.io/errors" + 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" +) + +// 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, spender, owner, locker common.Address, + amount *big.Int, +) error { + // 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 errors.Wrap(err, "failed allowance check") + } + + // 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 errors.Wrap(err, "failed totalSupply check") + } + + // The alreadyLocked amount is the amount of ZRC20 tokens that have been locked by the locker. + // TODO: Implement list of whitelisted locker addresses (https://github.com/zeta-chain/node/issues/2991) + alreadyLocked, err := k.ZRC20BalanceOf(ctx, zrc20ABI, zrc20Address, locker) + if err != nil { + return errors.Wrap(err, "failed getting the ZRC20 already locked amount") + } + + if !k.IsValidDepositAmount(totalSupply, alreadyLocked, amount) { + return errors.Wrap(fungibletypes.ErrInvalidAmount, "amount to be locked is not valid") + } + + // 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, spender, owner, locker, amount) + if err != nil { + return errors.Wrap(err, "failed executing transferFrom") + } + + if !transferred { + return fmt.Errorf("transferFrom returned false (no success)") + } + + return nil +} + +// 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, owner, locker common.Address, + amount *big.Int, +) error { + // Check if the account locking the ZRC20 tokens has enough balance. + if err := k.CheckZRC20Balance(ctx, zrc20ABI, zrc20Address, locker, amount); err != nil { + return errors.Wrap(err, "failed balance check") + } + + // transfer from the EOA locking the assets to the owner. + transferred, err := k.ZRC20Transfer(ctx, zrc20ABI, zrc20Address, locker, owner, amount) + if err != nil { + return errors.Wrap(err, "failed executing transfer") + } + + if !transferred { + return fmt.Errorf("transfer returned false (no success)") + } + + return nil +} + +// CheckZRC20Allowance checks if the allowance of ZRC20 tokens, +// is equal or greater than the provided amount. +func (k Keeper) CheckZRC20Allowance( + ctx sdk.Context, + zrc20ABI *abi.ABI, + owner, spender, zrc20Address common.Address, + amount *big.Int, +) error { + if zrc20ABI == nil { + return fungibletypes.ErrZRC20NilABI + } + + if amount.Sign() <= 0 || amount == nil { + return fungibletypes.ErrInvalidAmount + } + + if crypto.IsEmptyAddress(owner) || crypto.IsEmptyAddress(spender) { + return fungibletypes.ErrZeroAddress + } + + if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { + return errors.Wrap(err, "ZRC20 is not valid") + } + + allowanceValue, err := k.ZRC20Allowance(ctx, zrc20ABI, zrc20Address, owner, spender) + if err != nil { + return errors.Wrap(err, "failed while checking spender's allowance") + } + + if allowanceValue.Cmp(amount) < 0 || allowanceValue.Cmp(big.NewInt(0)) <= 0 { + return fmt.Errorf("invalid allowance, got %s, wanted %s", allowanceValue.String(), amount.String()) + } + + return nil +} + +// CheckZRC20Balance checks if the balance of ZRC20 tokens, +// is equal or greater than the provided amount. +func (k Keeper) CheckZRC20Balance( + ctx sdk.Context, + zrc20ABI *abi.ABI, + zrc20Address, owner common.Address, + amount *big.Int, +) error { + if zrc20ABI == nil { + return fungibletypes.ErrZRC20NilABI + } + + if amount.Sign() <= 0 || amount == nil { + return fungibletypes.ErrInvalidAmount + } + + if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { + return errors.Wrap(err, "ZRC20 is not valid") + } + + if crypto.IsEmptyAddress(owner) { + return fungibletypes.ErrZeroAddress + } + + // Check the ZRC20 balance of a given account. + // function balanceOf(address account) + balance, err := k.ZRC20BalanceOf(ctx, zrc20ABI, zrc20Address, owner) + if err != nil { + return errors.Wrap(err, "failed getting owner's ZRC20 balance") + } + + if balance.Cmp(amount) < 0 { + return fmt.Errorf("invalid balance, got %s, wanted %s", balance.String(), amount.String()) + } + + return nil +} + +// 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 fungibletypes.ErrZRC20ZeroAddress + } + + t, found := k.GetForeignCoins(ctx, zrc20Address.String()) + if !found { + return fungibletypes.ErrZRC20NotWhiteListed + } + + if t.Paused { + return fungibletypes.ErrPausedZRC20 + } + + return nil +} + +// IsValidDepositAmount checks "totalSupply >= amount_to_be_locked + 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..5c9f2d4645 --- /dev/null +++ b/x/fungible/keeper/zrc20_methods.go @@ -0,0 +1,305 @@ +package keeper + +import ( + "fmt" + "math/big" + + "cosmossdk.io/errors" + 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. +// The allowance has to be previously approved by the ZRC20 tokens owner. +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.ModuleAddressEVM, + zrc20Address, + big.NewInt(0), + nil, + true, + true, + allowance, + args..., + ) + if err != nil { + return nil, errors.Wrap(err, "EVM error calling ZRC20 allowance function") + } + + 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, errors.Wrap(err, "failed to unpack ZRC20 allowance return value") + } + + 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 + } + + // function balanceOf(address account) + res, err := k.CallEVM( + ctx, + *zrc20ABI, + fungibletypes.ModuleAddressEVM, + zrc20Address, + big.NewInt(0), + nil, + true, + true, + balanceOf, + owner, + ) + if err != nil { + return nil, errors.Wrap(err, "EVM error calling ZRC20 balanceOf function") + } + + 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, errors.Wrap(err, "failed to unpack ZRC20 balanceOf return value") + } + + 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.ModuleAddressEVM, + zrc20Address, + big.NewInt(0), + nil, + true, + true, + totalSupply, + ) + if err != nil { + return nil, errors.Wrap(err, "EVM error calling ZRC20 totalSupply function") + } + + 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, errors.Wrap(err, "failed to unpack ZRC20 totalSupply return value") + } + + 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 + } + + // function transfer(address recipient, uint256 amount) + 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, errors.Wrap(err, "EVM error calling ZRC20 transfer function") + } + + 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, errors.Wrap(err, "failed to unpack ZRC20 transfer return value") + } + + 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" to the EOA "to". +// The transaction is started by the spender. +// Requisite: the original EOA must have approved the spender to spend the tokens. +func (k Keeper) ZRC20TransferFrom( + ctx sdk.Context, + zrc20ABI *abi.ABI, + zrc20Address, spender, from, to common.Address, + amount *big.Int, +) (bool, error) { + if zrc20ABI == nil { + return false, fungibletypes.ErrZRC20NilABI + } + + if crypto.IsEmptyAddress(from) || crypto.IsEmptyAddress(to) || crypto.IsEmptyAddress(spender) { + return false, fungibletypes.ErrZeroAddress + } + + if err := k.IsValidZRC20(ctx, zrc20Address); err != nil { + return false, err + } + + // function transferFrom(address sender, address recipient, uint256 amount) + args := []interface{}{from, to, amount} + res, err := k.CallEVM( + ctx, + *zrc20ABI, + spender, + zrc20Address, + big.NewInt(0), + nil, + true, + true, + transferFrom, + args..., + ) + if err != nil { + return false, errors.Wrap(err, "EVM error calling ZRC20 transferFrom function") + } + + 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, errors.Wrap(err, "failed to unpack ZRC20 transferFrom return value") + } + + 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..7b124f3050 --- /dev/null +++ b/x/fungible/keeper/zrc20_methods_test.go @@ -0,0 +1,341 @@ +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.ModuleAddressEVM, + ) + 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.ModuleAddressEVM, + 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.ModuleAddressEVM, + ) + 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.ModuleAddressEVM, + 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.ModuleAddressEVM, big.NewInt(10)) + transferred, err := ts.fungibleKeeper.ZRC20Transfer( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + fungibletypes.ModuleAddressEVM, + 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{}, + common.Address{}, + big.NewInt(0), + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZRC20NilABI) + }) + + t.Run("should fail when from is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20TransferFrom( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + sample.EthAddress(), + common.Address{}, + sample.EthAddress(), + big.NewInt(0), + ) + require.Error(t, err) + require.ErrorAs(t, err, &fungibletypes.ErrZeroAddress) + }) + + t.Run("should fail when to is zero address", func(t *testing.T) { + _, err := ts.fungibleKeeper.ZRC20TransferFrom( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + sample.EthAddress(), + sample.EthAddress(), + common.Address{}, + 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, + common.Address{}, + sample.EthAddress(), + sample.EthAddress(), + 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(), + sample.EthAddress(), + fungibletypes.ModuleAddressEVM, + 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.ModuleAddressEVM, big.NewInt(1000)) + + // Transferring the tokens with transferFrom without approval should fail. + _, err = ts.fungibleKeeper.ZRC20TransferFrom( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + fungibletypes.ModuleAddressEVM, + sample.EthAddress(), + fungibletypes.ModuleAddressEVM, + 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.ModuleAddressEVM, big.NewInt(1000)) + + // Approve allowance to sample.EthAddress() to spend 10 ZRC20 tokens. + approveAllowance(t, ts, zrc20ABI, fungibletypes.ModuleAddressEVM, sample.EthAddress(), big.NewInt(10)) + + // Transferring the tokens with transferFrom without approval should fail. + _, err = ts.fungibleKeeper.ZRC20TransferFrom( + ts.ctx, + zrc20ABI, + ts.zrc20Address, + fungibletypes.ModuleAddressEVM, + sample.EthAddress(), + fungibletypes.ModuleAddressEVM, + 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") ) diff --git a/x/fungible/types/keys.go b/x/fungible/types/keys.go index 777cfd7c41..4c02d9c2ef 100644 --- a/x/fungible/types/keys.go +++ b/x/fungible/types/keys.go @@ -27,17 +27,10 @@ func KeyPrefix(p string) []byte { } var ( - ModuleAddress = authtypes.NewModuleAddress(ModuleName) - //ModuleAddressEVM common.EVMAddress + ModuleAddress = authtypes.NewModuleAddress(ModuleName) ModuleAddressEVM = common.BytesToAddress(ModuleAddress.Bytes()) - AdminAddress = "zeta1rx9r8hff0adaqhr5tuadkzj4e7ns2ntg446vtt" ) -func init() { - //fmt.Printf("ModuleAddressEVM of %s: %s\n", ModuleName, ModuleAddressEVM.String()) - // 0x735b14BB79463307AAcBED86DAf3322B1e6226aB -} - const ( SystemContractKey = "SystemContract-value-" )