Skip to content

Commit

Permalink
feat: add fungible keeper ability to lock/unlock ZRC20 tokens (#2979)
Browse files Browse the repository at this point in the history
* feat: add lockZRC20 capability to fungible keeper

* extract functions to fungible

* cleaning up

* modify e2e tests

* fix unit testing

* make deposit and withdraw fail with amount 0

* first round of review

* add full unit testing suite

* add reviewed changes

* add review suggestions
  • Loading branch information
Francisco de Borja Aranda Castillejo authored Oct 17, 2024
1 parent 05c0769 commit 72b517d
Show file tree
Hide file tree
Showing 12 changed files with 1,331 additions and 176 deletions.
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".
// - 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

0 comments on commit 72b517d

Please sign in to comment.