Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: add fungible keeper ability to lock/unlock ZRC20 tokens #2979

Merged
merged 11 commits into from
Oct 17, 2024
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 10 additions & 9 deletions e2e/e2etests/test_precompiles_bank.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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")

Expand All @@ -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")

Expand Down Expand Up @@ -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")
}
Expand All @@ -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)
Expand All @@ -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")

Expand Down
19 changes: 10 additions & 9 deletions e2e/e2etests/test_precompiles_bank_through_contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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])
Expand All @@ -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)
Expand All @@ -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])
Expand Down
75 changes: 6 additions & 69 deletions precompiles/bank/method_deposit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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".
fbac marked this conversation as resolved.
Show resolved Hide resolved
// - 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{
Expand Down Expand Up @@ -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(),
}
Expand Down
45 changes: 32 additions & 13 deletions precompiles/bank/method_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down
Loading
Loading