From b58046fcd4cf68fc769ec35f0b0caa9185c5b1fa Mon Sep 17 00:00:00 2001 From: Lucas Bertrand Date: Thu, 31 Oct 2024 22:40:30 +0100 Subject: [PATCH 1/8] refactor: improve ZETA deposit check with max supply check (#3074) * refactor: max supply check * create internal func --- testutil/keeper/mocks/fungible/bank.go | 18 +++++ x/fungible/keeper/deposits_test.go | 4 + x/fungible/keeper/zeta.go | 27 +++++++ x/fungible/keeper/zeta_test.go | 78 +++++++++++++++++++ .../keeper/zevm_message_passing_test.go | 6 ++ x/fungible/types/errors.go | 1 + x/fungible/types/expected_keepers.go | 1 + 7 files changed, 135 insertions(+) diff --git a/testutil/keeper/mocks/fungible/bank.go b/testutil/keeper/mocks/fungible/bank.go index db14226310..1c46b35688 100644 --- a/testutil/keeper/mocks/fungible/bank.go +++ b/testutil/keeper/mocks/fungible/bank.go @@ -13,6 +13,24 @@ type FungibleBankKeeper struct { mock.Mock } +// GetSupply provides a mock function with given fields: ctx, denom +func (_m *FungibleBankKeeper) GetSupply(ctx types.Context, denom string) types.Coin { + ret := _m.Called(ctx, denom) + + if len(ret) == 0 { + panic("no return value specified for GetSupply") + } + + var r0 types.Coin + if rf, ok := ret.Get(0).(func(types.Context, string) types.Coin); ok { + r0 = rf(ctx, denom) + } else { + r0 = ret.Get(0).(types.Coin) + } + + return r0 +} + // MintCoins provides a mock function with given fields: ctx, moduleName, amt func (_m *FungibleBankKeeper) MintCoins(ctx types.Context, moduleName string, amt types.Coins) error { ret := _m.Called(ctx, moduleName, amt) diff --git a/x/fungible/keeper/deposits_test.go b/x/fungible/keeper/deposits_test.go index 3958ae191e..836ce0b61a 100644 --- a/x/fungible/keeper/deposits_test.go +++ b/x/fungible/keeper/deposits_test.go @@ -437,6 +437,10 @@ func TestKeeper_DepositCoinZeta(t *testing.T) { b := sdkk.BankKeeper.GetBalance(ctx, zetaToAddress, config.BaseDenom) require.Equal(t, int64(0), b.Amount.Int64()) errorMint := errors.New("", 1, "error minting coins") + + bankMock.On("GetSupply", ctx, mock.Anything, mock.Anything). + Return(sdk.NewCoin(config.BaseDenom, sdk.NewInt(0))). + Once() bankMock.On("MintCoins", ctx, types.ModuleName, mock.Anything).Return(errorMint).Once() err := k.DepositCoinZeta(ctx, to, amount) require.ErrorIs(t, err, errorMint) diff --git a/x/fungible/keeper/zeta.go b/x/fungible/keeper/zeta.go index bc15a06e44..cd4acfcaa7 100644 --- a/x/fungible/keeper/zeta.go +++ b/x/fungible/keeper/zeta.go @@ -1,6 +1,7 @@ package keeper import ( + "fmt" "math/big" sdk "github.com/cosmos/cosmos-sdk/types" @@ -9,9 +10,17 @@ import ( "github.com/zeta-chain/node/x/fungible/types" ) +// ZETAMaxSupplyStr is the maximum mintable ZETA in the fungible module +// 1.85 billion ZETA +const ZETAMaxSupplyStr = "1850000000000000000000000000" + // MintZetaToEVMAccount mints ZETA (gas token) to the given address // NOTE: this method should be used with a temporary context, and it should not be committed if the method returns an error func (k *Keeper) MintZetaToEVMAccount(ctx sdk.Context, to sdk.AccAddress, amount *big.Int) error { + if err := k.validateZetaSupply(ctx, amount); err != nil { + return err + } + coins := sdk.NewCoins(sdk.NewCoin(config.BaseDenom, sdk.NewIntFromBigInt(amount))) // Mint coins if err := k.bankKeeper.MintCoins(ctx, types.ModuleName, coins); err != nil { @@ -23,7 +32,25 @@ func (k *Keeper) MintZetaToEVMAccount(ctx sdk.Context, to sdk.AccAddress, amount } func (k *Keeper) MintZetaToFungibleModule(ctx sdk.Context, amount *big.Int) error { + if err := k.validateZetaSupply(ctx, amount); err != nil { + return err + } + coins := sdk.NewCoins(sdk.NewCoin(config.BaseDenom, sdk.NewIntFromBigInt(amount))) // Mint coins return k.bankKeeper.MintCoins(ctx, types.ModuleName, coins) } + +// validateZetaSupply checks if the minted ZETA amount exceeds the maximum supply +func (k *Keeper) validateZetaSupply(ctx sdk.Context, amount *big.Int) error { + zetaMaxSupply, ok := sdk.NewIntFromString(ZETAMaxSupplyStr) + if !ok { + return fmt.Errorf("failed to parse ZETA max supply: %s", ZETAMaxSupplyStr) + } + + supply := k.bankKeeper.GetSupply(ctx, config.BaseDenom) + if supply.Amount.Add(sdk.NewIntFromBigInt(amount)).GT(zetaMaxSupply) { + return types.ErrMaxSupplyReached + } + return nil +} diff --git a/x/fungible/keeper/zeta_test.go b/x/fungible/keeper/zeta_test.go index 62e41700c1..51e5fe279c 100644 --- a/x/fungible/keeper/zeta_test.go +++ b/x/fungible/keeper/zeta_test.go @@ -2,6 +2,7 @@ package keeper_test import ( "errors" + "github.com/stretchr/testify/mock" "math/big" "testing" @@ -11,6 +12,7 @@ import ( "github.com/zeta-chain/node/cmd/zetacored/config" testkeeper "github.com/zeta-chain/node/testutil/keeper" "github.com/zeta-chain/node/testutil/sample" + "github.com/zeta-chain/node/x/fungible/keeper" "github.com/zeta-chain/node/x/fungible/types" ) @@ -29,6 +31,46 @@ func TestKeeper_MintZetaToEVMAccount(t *testing.T) { require.True(t, bal.Amount.Equal(sdk.NewInt(42))) }) + t.Run("mint the token to reach max supply", func(t *testing.T) { + k, ctx, sdkk, _ := testkeeper.FungibleKeeper(t) + k.GetAuthKeeper().GetModuleAccount(ctx, types.ModuleName) + + acc := sample.Bech32AccAddress() + bal := sdkk.BankKeeper.GetBalance(ctx, acc, config.BaseDenom) + require.True(t, bal.IsZero()) + + zetaMaxSupply, ok := sdk.NewIntFromString(keeper.ZETAMaxSupplyStr) + require.True(t, ok) + + supply := sdkk.BankKeeper.GetSupply(ctx, config.BaseDenom).Amount + + newAmount := zetaMaxSupply.Sub(supply) + + err := k.MintZetaToEVMAccount(ctx, acc, newAmount.BigInt()) + require.NoError(t, err) + bal = sdkk.BankKeeper.GetBalance(ctx, acc, config.BaseDenom) + require.True(t, bal.Amount.Equal(newAmount)) + }) + + t.Run("can't mint more than max supply", func(t *testing.T) { + k, ctx, sdkk, _ := testkeeper.FungibleKeeper(t) + k.GetAuthKeeper().GetModuleAccount(ctx, types.ModuleName) + + acc := sample.Bech32AccAddress() + bal := sdkk.BankKeeper.GetBalance(ctx, acc, config.BaseDenom) + require.True(t, bal.IsZero()) + + zetaMaxSupply, ok := sdk.NewIntFromString(keeper.ZETAMaxSupplyStr) + require.True(t, ok) + + supply := sdkk.BankKeeper.GetSupply(ctx, config.BaseDenom).Amount + + newAmount := zetaMaxSupply.Sub(supply).Add(sdk.NewInt(1)) + + err := k.MintZetaToEVMAccount(ctx, acc, newAmount.BigInt()) + require.ErrorIs(t, err, types.ErrMaxSupplyReached) + }) + coins42 := sdk.NewCoins(sdk.NewCoin(config.BaseDenom, sdk.NewInt(42))) t.Run("should fail if minting fail", func(t *testing.T) { @@ -36,6 +78,9 @@ func TestKeeper_MintZetaToEVMAccount(t *testing.T) { mockBankKeeper := testkeeper.GetFungibleBankMock(t, k) + mockBankKeeper.On("GetSupply", ctx, mock.Anything, mock.Anything). + Return(sdk.NewCoin(config.BaseDenom, sdk.NewInt(0))). + Once() mockBankKeeper.On( "MintCoins", ctx, @@ -55,6 +100,9 @@ func TestKeeper_MintZetaToEVMAccount(t *testing.T) { mockBankKeeper := testkeeper.GetFungibleBankMock(t, k) + mockBankKeeper.On("GetSupply", ctx, mock.Anything, mock.Anything). + Return(sdk.NewCoin(config.BaseDenom, sdk.NewInt(0))). + Once() mockBankKeeper.On( "MintCoins", ctx, @@ -76,3 +124,33 @@ func TestKeeper_MintZetaToEVMAccount(t *testing.T) { mockBankKeeper.AssertExpectations(t) }) } + +func TestKeeper_MintZetaToFungibleModule(t *testing.T) { + t.Run("should mint the token in the specified balance", func(t *testing.T) { + k, ctx, sdkk, _ := testkeeper.FungibleKeeper(t) + acc := k.GetAuthKeeper().GetModuleAccount(ctx, types.ModuleName).GetAddress() + + bal := sdkk.BankKeeper.GetBalance(ctx, acc, config.BaseDenom) + require.True(t, bal.IsZero()) + + err := k.MintZetaToEVMAccount(ctx, acc, big.NewInt(42)) + require.NoError(t, err) + bal = sdkk.BankKeeper.GetBalance(ctx, acc, config.BaseDenom) + require.True(t, bal.Amount.Equal(sdk.NewInt(42))) + }) + + t.Run("can't mint more than max supply", func(t *testing.T) { + k, ctx, sdkk, _ := testkeeper.FungibleKeeper(t) + k.GetAuthKeeper().GetModuleAccount(ctx, types.ModuleName) + + zetaMaxSupply, ok := sdk.NewIntFromString(keeper.ZETAMaxSupplyStr) + require.True(t, ok) + + supply := sdkk.BankKeeper.GetSupply(ctx, config.BaseDenom).Amount + + newAmount := zetaMaxSupply.Sub(supply).Add(sdk.NewInt(1)) + + err := k.MintZetaToFungibleModule(ctx, newAmount.BigInt()) + require.ErrorIs(t, err, types.ErrMaxSupplyReached) + }) +} diff --git a/x/fungible/keeper/zevm_message_passing_test.go b/x/fungible/keeper/zevm_message_passing_test.go index f68fec1f62..f8e1e300d2 100644 --- a/x/fungible/keeper/zevm_message_passing_test.go +++ b/x/fungible/keeper/zevm_message_passing_test.go @@ -146,6 +146,9 @@ func TestKeeper_ZEVMDepositAndCallContract(t *testing.T) { }) require.NoError(t, err) errorMint := errors.New("", 10, "error minting coins") + bankMock.On("GetSupply", ctx, mock.Anything, mock.Anything). + Return(sdk.NewCoin(config.BaseDenom, sdk.NewInt(0))). + Once() bankMock.On("MintCoins", ctx, types.ModuleName, mock.Anything).Return(errorMint).Once() _, err = k.ZETADepositAndCallContract( @@ -296,6 +299,9 @@ func TestKeeper_ZEVMRevertAndCallContract(t *testing.T) { }) require.NoError(t, err) errorMint := errors.New("", 101, "error minting coins") + bankMock.On("GetSupply", ctx, mock.Anything, mock.Anything). + Return(sdk.NewCoin(config.BaseDenom, sdk.NewInt(0))). + Once() bankMock.On("MintCoins", ctx, types.ModuleName, mock.Anything).Return(errorMint).Once() _, err = k.ZETARevertAndCallContract( diff --git a/x/fungible/types/errors.go b/x/fungible/types/errors.go index cf333c9545..cb152f7ffe 100644 --- a/x/fungible/types/errors.go +++ b/x/fungible/types/errors.go @@ -34,4 +34,5 @@ var ( 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") + ErrMaxSupplyReached = cosmoserrors.Register(ModuleName, 1135, "max supply reached") ) diff --git a/x/fungible/types/expected_keepers.go b/x/fungible/types/expected_keepers.go index c8dc02f013..8af00293ed 100644 --- a/x/fungible/types/expected_keepers.go +++ b/x/fungible/types/expected_keepers.go @@ -33,6 +33,7 @@ type BankKeeper interface { amt sdk.Coins, ) error MintCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error + GetSupply(ctx sdk.Context, denom string) sdk.Coin } type ObserverKeeper interface { From 2e5d79435b032d3f83e037bd0333303703dfe11c Mon Sep 17 00:00:00 2001 From: Tanmay Date: Thu, 31 Oct 2024 22:41:05 -0400 Subject: [PATCH 2/8] test: simulation import and export (#3033) * debug sim test * start modifuing for v50 * add sim test v1 * add sim export * move simulation tests to a separate directory * remove unused functions * add comments * enable gov module * enable gov module * fix lint error * add todos for BasicManager * add todos for BasicManager * add cleanup for TestFullAppSimulation * register legacy router * added a doc for simulation testing * undo db close for multi threaded test * add description for tests * remove go module from simulation tests * make basicsmanager private * Update Makefile Co-authored-by: Francisco de Borja Aranda Castillejo * Update changelog.md Co-authored-by: Francisco de Borja Aranda Castillejo * format simulation.md * add test for import export * test sim after import * add bench test * remove bench * add changelog * remove cosmos utils * remove print lines * add comments * ci: Add simulation tests workflow * merge ci workflow * Update .github/workflows/simulation-test.yaml Co-authored-by: semgrep-code-zeta-chain[bot] <181804379+semgrep-code-zeta-chain[bot]@users.noreply.github.com> * Update simulation-test.yaml Update simulation-test workflow * add branch sim-import-export to trigger ci run * format files * uncomment simulation tests from CI * fix defer functions * Rename sim tests file, add result aggregation and merge jobs * ignore simulation tests from codecov * Add runs on to matrix conditional job * add default make-targets * bump go * Update .github/workflows/sim.yaml workflow name Co-authored-by: Alex Gartner * Delete main branch from sim test workflow * improve formatting for simulation.md * move simulation tests to root directory * remove panic for Import Export Test * Trigger Sim Tests on Labeled PRs or when any file from x/**/* is changed * update sim.yaml * Update sim.yaml jobs needs * update sim.yaml * update codecov.yml * fixed some comments * remove directory sim * remove directory sim * format directory * remove utils file * add to codecov --------- Co-authored-by: Francisco de Borja Aranda Castillejo Co-authored-by: Julian Rubino Co-authored-by: semgrep-code-zeta-chain[bot] <181804379+semgrep-code-zeta-chain[bot]@users.noreply.github.com> Co-authored-by: Julian Rubino Co-authored-by: Alex Gartner --- .github/workflows/sim.yaml | 111 ++++ Makefile | 26 +- app/app.go | 4 + app/export.go | 12 +- changelog.md | 1 + codecov.yml | 1 + docs/development/SIMULATION_TESTING.md | 60 +- .../sim/sim_config.go => simulation/config.go | 2 +- .../sim_utils.go => simulation/simulation.go | 33 +- simulation/simulation_test.go | 541 ++++++++++++++++++ .../sim/sim_state.go => simulation/state.go | 2 +- tests/simulation/sim_test.go | 213 ------- 12 files changed, 775 insertions(+), 231 deletions(-) create mode 100644 .github/workflows/sim.yaml rename tests/simulation/sim/sim_config.go => simulation/config.go (99%) rename tests/simulation/sim/sim_utils.go => simulation/simulation.go (62%) create mode 100644 simulation/simulation_test.go rename tests/simulation/sim/sim_state.go => simulation/state.go (99%) delete mode 100644 tests/simulation/sim_test.go diff --git a/.github/workflows/sim.yaml b/.github/workflows/sim.yaml new file mode 100644 index 0000000000..11e6858a81 --- /dev/null +++ b/.github/workflows/sim.yaml @@ -0,0 +1,111 @@ +name: sim + +on: + push: + branches: + - develop + pull_request: + types: [opened, synchronize, labeled] + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + inputs: + make-targets: + description: 'Comma separated list of make targets to run (e.g., test-sim-nondeterminism, test-sim-fullappsimulation)' + required: true + default: 'test-sim-nondeterminism' + +concurrency: + group: simulation-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +jobs: + changed-files: + runs-on: ubuntu-latest + outputs: + modified_files: ${{ steps.changes.outputs.modified_files }} + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Get changed files in x directory + id: changes + run: | + echo "::set-output name=modified_files::$(git diff --name-only --diff-filter=ACMRT ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | grep '^x/' | xargs)" + + matrix-conditionals: + needs: changed-files + if: | + contains(github.event.pull_request.labels.*.name, 'SIM_TESTS') || needs.changed-files.outputs.modified_files + runs-on: ubuntu-22.04 + outputs: + SIM_TEST_NOND: ${{ steps.matrix-conditionals.outputs.SIM_TEST_NOND }} + SIM_TEST_FULL: ${{ steps.matrix-conditionals.outputs.SIM_TEST_FULL }} + SIM_TEST_IMPORT_EXPORT: ${{ steps.matrix-conditionals.outputs.SIM_TEST_IMPORT_EXPORT }} + SIM_TEST_AFTER_IMPORT: ${{ steps.matrix-conditionals.outputs.SIM_TEST_AFTER_IMPORT }} + steps: + - id: matrix-conditionals + uses: actions/github-script@v7 + with: + script: | + const makeTargetsInput = context.payload.inputs ? context.payload.inputs['make-targets'] : null; + const defaultTargets = ['test-sim-nondeterminism', 'test-sim-fullappsimulation', 'test-sim-import-export', 'test-sim-after-import']; + + const makeTargets = makeTargetsInput ? makeTargetsInput.split(',') : defaultTargets; + + core.setOutput('SIM_TEST_NOND', makeTargets.includes('test-sim-nondeterminism')); + core.setOutput('SIM_TEST_FULL', makeTargets.includes('test-sim-fullappsimulation')); + core.setOutput('SIM_TEST_IMPORT_EXPORT', makeTargets.includes('test-sim-import-export')); + core.setOutput('SIM_TEST_AFTER_IMPORT', makeTargets.includes('test-sim-after-import')); + + simulation-tests: + needs: + - matrix-conditionals + if: | + contains(github.event.pull_request.labels.*.name, 'SIM_TESTS') || needs.changed-files.outputs.modified_files + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + include: + - make-target: "test-sim-nondeterminism" + condition: ${{ needs.matrix-conditionals.outputs.SIM_TEST_NOND == 'true' }} + - make-target: "test-sim-fullappsimulation" + condition: ${{ needs.matrix-conditionals.outputs.SIM_TEST_FULL == 'true' }} + - make-target: "test-sim-import-export" + condition: ${{ needs.matrix-conditionals.outputs.SIM_TEST_IMPORT_EXPORT == 'true' }} + - make-target: "test-sim-after-import" + condition: ${{ needs.matrix-conditionals.outputs.SIM_TEST_AFTER_IMPORT == 'true' }} + name: ${{ matrix.make-target }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.22' + + - name: Install dependencies + run: make runsim + + - name: Run ${{ matrix.make-target }} + if: ${{ matrix.condition }} + run: | + make ${{ matrix.make-target }} + + sim-ok: + needs: + - simulation-tests + if: | + contains(github.event.pull_request.labels.*.name, 'SIM_TESTS') || needs.changed-files.outputs.modified_files + runs-on: ubuntu-22.04 + steps: + - name: Aggregate Results + run: | + result="${{ needs.simulation-tests.result }}" + if [[ $result == "success" || $result == "skipped" ]]; then + exit 0 + else + exit 1 + fi diff --git a/Makefile b/Makefile index 61b4c492e5..bcd233b12e 100644 --- a/Makefile +++ b/Makefile @@ -360,7 +360,7 @@ start-upgrade-import-mainnet-test: zetanode-upgrade ############################################################################### BINDIR ?= $(GOPATH)/bin -SIMAPP = ./tests/simulation +SIMAPP = ./simulation # Run sim is a cosmos tool which helps us to run multiple simulations in parallel. @@ -381,16 +381,22 @@ $(BINDIR)/runsim: # Period: Invariant check period # Timeout: Timeout for the simulation test define run-sim-test - @echo "Running $(1)..." + @echo "Running $(1)" @go test -mod=readonly $(SIMAPP) -run $(2) -Enabled=true \ -NumBlocks=$(3) -BlockSize=$(4) -Commit=true -Period=0 -v -timeout $(5) endef test-sim-nondeterminism: - $(call run-sim-test,"non-determinism test",TestAppStateDeterminism,100,200,2h) + $(call run-sim-test,"non-determinism test",TestAppStateDeterminism,100,200,30m) test-sim-fullappsimulation: - $(call run-sim-test,"TestFullAppSimulation",TestFullAppSimulation,100,200,2h) + $(call run-sim-test,"TestFullAppSimulation",TestFullAppSimulation,100,200,30m) + +test-sim-import-export: + $(call run-sim-test,"test-import-export",TestAppImportExport,100,200,30m) + +test-sim-after-import: + $(call run-sim-test,"test-sim-after-import",TestAppSimulationAfterImport,100,200,30m) test-sim-multi-seed-long: runsim @echo "Running long multi-seed application simulation." @@ -400,13 +406,23 @@ test-sim-multi-seed-short: runsim @echo "Running short multi-seed application simulation." @$(BINDIR)/runsim -Jobs=4 -SimAppPkg=$(SIMAPP) -ExitOnFail 50 10 TestFullAppSimulation +test-sim-import-export-long: runsim + @echo "Running application import/export simulation. This may take several minutes" + @$(BINDIR)/runsim -Jobs=4 -SimAppPkg=$(SIMAPP) -ExitOnFail 500 50 TestAppImportExport +test-sim-after-import-long: runsim + @echo "Running application simulation-after-import. This may take several minute" + @$(BINDIR)/runsim -Jobs=4 -SimAppPkg=$(SIMAPP) -ExitOnFail 500 50 TestAppSimulationAfterImport .PHONY: \ test-sim-nondeterminism \ test-sim-fullappsimulation \ test-sim-multi-seed-long \ -test-sim-multi-seed-short +test-sim-multi-seed-short \ +test-sim-import-export \ +test-sim-after-import \ +test-sim-import-export-long \ +test-sim-after-import-long ############################################################################### diff --git a/app/app.go b/app/app.go index 3a42d51aff..0fd9026348 100644 --- a/app/app.go +++ b/app/app.go @@ -1058,6 +1058,10 @@ func (app *App) BasicManager() module.BasicManager { return app.mb } +func (app *App) ModuleManager() *module.Manager { + return app.mm +} + func (app *App) BlockedAddrs() map[string]bool { blockList := make(map[string]bool) diff --git a/app/export.go b/app/export.go index 61bc52659b..5ccb887c5d 100644 --- a/app/export.go +++ b/app/export.go @@ -2,11 +2,13 @@ package app import ( "encoding/json" + "errors" "log" tmproto "github.com/cometbft/cometbft/proto/tendermint/types" servertypes "github.com/cosmos/cosmos-sdk/server/types" sdk "github.com/cosmos/cosmos-sdk/types" + distributiontypes "github.com/cosmos/cosmos-sdk/x/distribution/types" slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" "github.com/cosmos/cosmos-sdk/x/staking" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" @@ -75,7 +77,7 @@ func (app *App) prepForZeroHeightGenesis(ctx sdk.Context, jailAllowedAddrs []str // withdraw all validator commission app.StakingKeeper.IterateValidators(ctx, func(_ int64, val stakingtypes.ValidatorI) (stop bool) { _, err := app.DistrKeeper.WithdrawValidatorCommission(ctx, val.GetOperator()) - if err != nil { + if !errors.Is(err, distributiontypes.ErrNoValidatorCommission) && err != nil { panic(err) } return false @@ -161,7 +163,13 @@ func (app *App) prepForZeroHeightGenesis(ctx sdk.Context, jailAllowedAddrs []str counter := int16(0) for ; iter.Valid(); iter.Next() { - addr := sdk.ValAddress(iter.Key()[1:]) + key := iter.Key() + keyPrefixLength := 2 + if len(key) <= keyPrefixLength { + app.Logger().Error("unexpected key in staking store", "key", key) + continue + } + addr := sdk.ValAddress(key[keyPrefixLength:]) validator, found := app.StakingKeeper.GetValidator(ctx, addr) if !found { panic("expected validator, not found") diff --git a/changelog.md b/changelog.md index 4e58815e99..ce8df87a44 100644 --- a/changelog.md +++ b/changelog.md @@ -51,6 +51,7 @@ * [2894](https://github.com/zeta-chain/node/pull/2894) - increase gas limit for TSS vote tx * [2932](https://github.com/zeta-chain/node/pull/2932) - add gateway upgrade as part of the upgrade test * [2947](https://github.com/zeta-chain/node/pull/2947) - initialize simulation tests +* [3033](https://github.com/zeta-chain/node/pull/3033) - initialize simulation tests for import and export ### Fixes diff --git a/codecov.yml b/codecov.yml index fee85c9c04..da90e44bd9 100644 --- a/codecov.yml +++ b/codecov.yml @@ -81,3 +81,4 @@ ignore: - "precompiles/**/*.json" - "precompiles/**/*.sol" - "precompiles/**/*.gen.go" + - "simulation/*.go" diff --git a/docs/development/SIMULATION_TESTING.md b/docs/development/SIMULATION_TESTING.md index 1f5232911a..e6bb2f2ca7 100644 --- a/docs/development/SIMULATION_TESTING.md +++ b/docs/development/SIMULATION_TESTING.md @@ -1,34 +1,78 @@ # Zetachain simulation testing ## Overview The blockchain simulation tests how the blockchain application would behave under real life circumstances by generating -and sending randomized messages.The goal of this is to detect and debug failures that could halt a live chain,by providing -logs and statistics about the operations run by the simulator as well as exporting the latest application state. - +and sending randomized messages.The goal of this is to detect and debug failures that could halt a live chain by +providing logs and statistics about the operations run by the simulator as well as exporting the latest application +state. ## Simulation tests ### Nondeterminism test Nondeterminism test runs a full application simulation , and produces multiple blocks as per the config It checks the determinism of the application by comparing the apphash at the end of each run to other runs -The test certifies that , for the same set of operations ( irrespective of what the operations are ), we would reach the same final state if the initial state is the same +The test certifies that, for the same set of operations (regardless of what the operations are), we +would reach the same final state if the initial state is the same +Approximate run time is 2 minutes. ```bash make test-sim-nondeterminism ``` + ### Full application simulation test Full application runs a full app simulation test with the provided configuration. -At the end of the run it tries to export the genesis state to make sure the export works. +At the end of the run, it tries to export the genesis state to make sure the export works. +Approximate run time is 2 minutes. ```bash make test-sim-full-app ``` +### Import Export simulation test +The import export simulation test runs a full application simulation +and exports the application state at the end of the run. +This state is then imported into a new simulation. +At the end of the run, we compare the keys for the application state for both the simulations +to make sure they are the same. +Approximate run time is 2 minutes. +```bash +make test-sim-import-export +``` + +### Import and run simulation test +This simulation test exports the application state at the end of the run and imports it into a new simulation. +Approximate run time is 2 minutes. +```bash +make test-sim-after-import +``` + ### Multi seed long test -Multi seed long test runs a full application simulation with multiple seeds and multiple blocks.This runs the test for a longer duration compared to the multi seed short test +Multi seed long test runs a full application simulation with multiple seeds and multiple blocks. +It uses the `runsim` tool to run the same test in parallel threads. +Approximate run time is 30 minutes. ```bash make test-sim-multi-seed-long ``` ### Multi seed short test -Multi seed short test runs a full application simulation with multiple seeds and multiple blocks. This runs the test for a longer duration compared to the multi seed long test +Multi seed short test runs a full application simulation with multiple seeds and multiple blocks. +It uses the `runsim` tool to run the same test in parallel threads. +This test is a shorter version of the Multi seed long test. +Approximate run time is 10 minutes. ```bash make test-sim-multi-seed-short -``` \ No newline at end of file +``` + +### Import Export long test +This test runs the import export simulation test for a longer duration. +It uses the `runsim` tool to run the same test in parallel threads. +Approximate run time is 30 minutes. +```bash +make test-sim-import-export-long +``` + +### Import and run simulation test long +This test runs the import and run simulation test for a longer duration. +It uses the `runsim` tool to run the same test in parallel threads. +Approximate run time is 30 minutes. +```bash +make test-sim-after-import-long +``` + diff --git a/tests/simulation/sim/sim_config.go b/simulation/config.go similarity index 99% rename from tests/simulation/sim/sim_config.go rename to simulation/config.go index 8a6c281db8..254365c856 100644 --- a/tests/simulation/sim/sim_config.go +++ b/simulation/config.go @@ -1,4 +1,4 @@ -package sim +package simulation import ( "flag" diff --git a/tests/simulation/sim/sim_utils.go b/simulation/simulation.go similarity index 62% rename from tests/simulation/sim/sim_utils.go rename to simulation/simulation.go index b310d7cae2..faa727c65f 100644 --- a/tests/simulation/sim/sim_utils.go +++ b/simulation/simulation.go @@ -1,12 +1,16 @@ -package sim +package simulation import ( + "encoding/json" "fmt" + "os" dbm "github.com/cometbft/cometbft-db" "github.com/cometbft/cometbft/libs/log" "github.com/cosmos/cosmos-sdk/baseapp" + "github.com/cosmos/cosmos-sdk/runtime" servertypes "github.com/cosmos/cosmos-sdk/server/types" + simtypes "github.com/cosmos/cosmos-sdk/types/simulation" "github.com/zeta-chain/ethermint/app" evmante "github.com/zeta-chain/ethermint/app/ante" @@ -66,3 +70,30 @@ func PrintStats(db dbm.DB) { fmt.Println(db.Stats()["leveldb.stats"]) fmt.Println("GoLevelDB cached block size", db.Stats()["leveldb.cachedblock"]) } + +// CheckExportSimulation exports the app state and simulation parameters to JSON +// if the export paths are defined. +func CheckExportSimulation(app runtime.AppI, config simtypes.Config, params simtypes.Params) error { + if config.ExportStatePath != "" { + exported, err := app.ExportAppStateAndValidators(false, nil, nil) + if err != nil { + return fmt.Errorf("failed to export app state: %w", err) + } + + if err := os.WriteFile(config.ExportStatePath, exported.AppState, 0o600); err != nil { + return err + } + } + + if config.ExportParamsPath != "" { + paramsBz, err := json.MarshalIndent(params, "", " ") + if err != nil { + return fmt.Errorf("failed to write app state to %s: %w", config.ExportStatePath, err) + } + + if err := os.WriteFile(config.ExportParamsPath, paramsBz, 0o600); err != nil { + return err + } + } + return nil +} diff --git a/simulation/simulation_test.go b/simulation/simulation_test.go new file mode 100644 index 0000000000..3f39c77fa1 --- /dev/null +++ b/simulation/simulation_test.go @@ -0,0 +1,541 @@ +package simulation_test + +import ( + "encoding/json" + "fmt" + "math/rand" + "os" + "runtime/debug" + "testing" + + abci "github.com/cometbft/cometbft/abci/types" + tmproto "github.com/cometbft/cometbft/proto/tendermint/types" + "github.com/cosmos/cosmos-sdk/store" + storetypes "github.com/cosmos/cosmos-sdk/store/types" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" + evidencetypes "github.com/cosmos/cosmos-sdk/x/evidence/types" + govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" + paramtypes "github.com/cosmos/cosmos-sdk/x/params/types" + slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/stretchr/testify/require" + evmtypes "github.com/zeta-chain/ethermint/x/evm/types" + "github.com/zeta-chain/node/app" + zetasimulation "github.com/zeta-chain/node/simulation" + + "github.com/cosmos/cosmos-sdk/baseapp" + "github.com/cosmos/cosmos-sdk/client/flags" + "github.com/cosmos/cosmos-sdk/server" + cosmossimutils "github.com/cosmos/cosmos-sdk/testutil/sims" + cosmossim "github.com/cosmos/cosmos-sdk/types/simulation" + "github.com/cosmos/cosmos-sdk/x/simulation" + cosmossimcli "github.com/cosmos/cosmos-sdk/x/simulation/client/cli" +) + +// AppChainID hardcoded chainID for simulation + +func init() { + zetasimulation.GetSimulatorFlags() +} + +type StoreKeysPrefixes struct { + A storetypes.StoreKey + B storetypes.StoreKey + Prefixes [][]byte +} + +const ( + SimAppChainID = "simulation_777-1" + SimBlockMaxGas = 815000000 + //github.com/zeta-chain/node/issues/3004 + // TODO : Support pebbleDB for simulation tests + SimDBBackend = "goleveldb" + SimDBName = "simulation" +) + +// interBlockCacheOpt returns a BaseApp option function that sets the persistent +// inter-block write-through cache. +func interBlockCacheOpt() func(*baseapp.BaseApp) { + return baseapp.SetInterBlockCache(store.NewCommitKVStoreCacheManager()) +} + +// TestAppStateDeterminism runs a full application simulation , and produces multiple blocks as per the config +// It checks the determinism of the application by comparing the apphash at the end of each run to other runs +// The following test certifies that , for the same set of operations ( irrespective of what the operations are ) , +// we would reach the same final state if the initial state is the same +func TestAppStateDeterminism(t *testing.T) { + if !zetasimulation.FlagEnabledValue { + t.Skip("skipping application simulation") + } + + config := zetasimulation.NewConfigFromFlags() + + config.InitialBlockHeight = 1 + config.ExportParamsPath = "" + config.OnOperation = false + config.AllInvariants = false + config.ChainID = SimAppChainID + config.DBBackend = SimDBBackend + config.BlockMaxGas = SimBlockMaxGas + + numSeeds := 3 + numTimesToRunPerSeed := 5 + + // We will be overriding the random seed and just run a single simulation on the provided seed value + if config.Seed != cosmossimcli.DefaultSeedValue { + numSeeds = 1 + } + + appHashList := make([]json.RawMessage, numTimesToRunPerSeed) + + appOptions := make(cosmossimutils.AppOptionsMap, 0) + appOptions[server.FlagInvCheckPeriod] = zetasimulation.FlagPeriodValue + + t.Log("Running tests for numSeeds: ", numSeeds, " numTimesToRunPerSeed: ", numTimesToRunPerSeed) + + for i := 0; i < numSeeds; i++ { + if config.Seed == cosmossimcli.DefaultSeedValue { + config.Seed = rand.Int63() + } + // For the same seed, the simApp hash produced at the end of each run should be the same + for j := 0; j < numTimesToRunPerSeed; j++ { + db, dir, logger, _, err := cosmossimutils.SetupSimulation( + config, + SimDBBackend, + SimDBName, + zetasimulation.FlagVerboseValue, + zetasimulation.FlagEnabledValue, + ) + require.NoError(t, err) + appOptions[flags.FlagHome] = dir + + simApp, err := zetasimulation.NewSimApp( + logger, + db, + appOptions, + interBlockCacheOpt(), + baseapp.SetChainID(SimAppChainID), + ) + + t.Logf( + "running non-determinism simulation; seed %d: %d/%d, attempt: %d/%d\n", + config.Seed, i+1, numSeeds, j+1, numTimesToRunPerSeed, + ) + + blockedAddresses := simApp.ModuleAccountAddrs() + + // Random seed is used to produce a random initial state for the simulation + _, _, err = simulation.SimulateFromSeed( + t, + os.Stdout, + simApp.BaseApp, + zetasimulation.AppStateFn( + simApp.AppCodec(), + simApp.SimulationManager(), + simApp.BasicManager().DefaultGenesis(simApp.AppCodec()), + ), + cosmossim.RandomAccounts, + cosmossimutils.SimulationOperations(simApp, simApp.AppCodec(), config), + blockedAddresses, + config, + simApp.AppCodec(), + ) + require.NoError(t, err) + + zetasimulation.PrintStats(db) + + appHash := simApp.LastCommitID().Hash + appHashList[j] = appHash + + // Clean up resources + t.Cleanup(func() { + require.NoError(t, db.Close()) + require.NoError(t, os.RemoveAll(dir)) + }) + + if j != 0 { + require.Equal( + t, + string(appHashList[0]), + string(appHashList[j]), + "non-determinism in seed %d: %d/%d, attempt: %d/%d\n", + config.Seed, + i+1, + numSeeds, + j+1, + numTimesToRunPerSeed, + ) + } + } + } +} + +// TestFullAppSimulation runs a full simApp simulation with the provided configuration. +// At the end of the run it tries to export the genesis state to make sure the export works. +func TestFullAppSimulation(t *testing.T) { + + config := zetasimulation.NewConfigFromFlags() + + config.ChainID = SimAppChainID + config.BlockMaxGas = SimBlockMaxGas + config.DBBackend = SimDBBackend + + db, dir, logger, skip, err := cosmossimutils.SetupSimulation( + config, + SimDBBackend, + SimDBName, + zetasimulation.FlagVerboseValue, + zetasimulation.FlagEnabledValue, + ) + if skip { + t.Skip("skipping application simulation") + } + require.NoError(t, err, "simulation setup failed") + + t.Cleanup(func() { + if err := db.Close(); err != nil { + require.NoError(t, err, "Error closing new database") + } + if err := os.RemoveAll(dir); err != nil { + require.NoError(t, err, "Error removing directory") + } + }) + appOptions := make(cosmossimutils.AppOptionsMap, 0) + appOptions[server.FlagInvCheckPeriod] = zetasimulation.FlagPeriodValue + appOptions[flags.FlagHome] = dir + + simApp, err := zetasimulation.NewSimApp( + logger, + db, + appOptions, + interBlockCacheOpt(), + baseapp.SetChainID(SimAppChainID), + ) + require.NoError(t, err) + + blockedAddresses := simApp.ModuleAccountAddrs() + _, _, simErr := simulation.SimulateFromSeed( + t, + os.Stdout, + simApp.BaseApp, + zetasimulation.AppStateFn( + simApp.AppCodec(), + simApp.SimulationManager(), + simApp.BasicManager().DefaultGenesis(simApp.AppCodec()), + ), + cosmossim.RandomAccounts, + cosmossimutils.SimulationOperations(simApp, simApp.AppCodec(), config), + blockedAddresses, + config, + simApp.AppCodec(), + ) + require.NoError(t, simErr) + + // check export works as expected + exported, err := simApp.ExportAppStateAndValidators(false, nil, nil) + require.NoError(t, err) + if config.ExportStatePath != "" { + err := os.WriteFile(config.ExportStatePath, exported.AppState, 0o600) + require.NoError(t, err) + } + + zetasimulation.PrintStats(db) +} + +func TestAppImportExport(t *testing.T) { + config := zetasimulation.NewConfigFromFlags() + + config.ChainID = SimAppChainID + config.BlockMaxGas = SimBlockMaxGas + config.DBBackend = SimDBBackend + + db, dir, logger, skip, err := cosmossimutils.SetupSimulation( + config, + SimDBBackend, + SimDBName, + zetasimulation.FlagVerboseValue, + zetasimulation.FlagEnabledValue, + ) + if skip { + t.Skip("skipping application simulation") + } + require.NoError(t, err, "simulation setup failed") + + t.Cleanup(func() { + if err := db.Close(); err != nil { + require.NoError(t, err, "Error closing new database") + } + if err := os.RemoveAll(dir); err != nil { + require.NoError(t, err, "Error removing directory") + } + }) + + appOptions := make(cosmossimutils.AppOptionsMap, 0) + appOptions[server.FlagInvCheckPeriod] = zetasimulation.FlagPeriodValue + appOptions[flags.FlagHome] = dir + simApp, err := zetasimulation.NewSimApp( + logger, + db, + appOptions, + interBlockCacheOpt(), + baseapp.SetChainID(SimAppChainID), + ) + require.NoError(t, err) + + // Run randomized simulation + blockedAddresses := simApp.ModuleAccountAddrs() + _, simParams, simErr := simulation.SimulateFromSeed( + t, + os.Stdout, + simApp.BaseApp, + zetasimulation.AppStateFn( + simApp.AppCodec(), + simApp.SimulationManager(), + simApp.BasicManager().DefaultGenesis(simApp.AppCodec()), + ), + cosmossim.RandomAccounts, + cosmossimutils.SimulationOperations(simApp, simApp.AppCodec(), config), + blockedAddresses, + config, + simApp.AppCodec(), + ) + require.NoError(t, simErr) + + err = zetasimulation.CheckExportSimulation(simApp, config, simParams) + require.NoError(t, err) + + zetasimulation.PrintStats(db) + + t.Log("exporting genesis") + // export state and simParams + exported, err := simApp.ExportAppStateAndValidators(false, []string{}, []string{}) + require.NoError(t, err) + + t.Log("importing genesis") + newDB, newDir, _, _, err := cosmossimutils.SetupSimulation( + config, + SimDBBackend+"_new", + SimDBName+"_new", + zetasimulation.FlagVerboseValue, + zetasimulation.FlagEnabledValue, + ) + + require.NoError(t, err, "simulation setup failed") + + t.Cleanup(func() { + if err := newDB.Close(); err != nil { + require.NoError(t, err, "Error closing new database") + } + if err := os.RemoveAll(newDir); err != nil { + require.NoError(t, err, "Error removing directory") + } + }) + + newSimApp, err := zetasimulation.NewSimApp( + logger, + newDB, + appOptions, + interBlockCacheOpt(), + baseapp.SetChainID(SimAppChainID), + ) + require.NoError(t, err) + + var genesisState app.GenesisState + err = json.Unmarshal(exported.AppState, &genesisState) + require.NoError(t, err) + + defer func() { + if r := recover(); r != nil { + err := fmt.Sprintf("%v", r) + require.Contains(t, err, "validator set is empty after InitGenesis", "unexpected error: %v", r) + t.Log("Skipping simulation as all validators have been unbonded") + t.Log("err", err, "stacktrace", string(debug.Stack())) + } + }() + + // Create context for the old and the new sim app, which can be used to compare keys + ctxSimApp := simApp.NewContext(true, tmproto.Header{ + Height: simApp.LastBlockHeight(), + ChainID: SimAppChainID, + }).WithChainID(SimAppChainID) + ctxNewSimApp := newSimApp.NewContext(true, tmproto.Header{ + Height: simApp.LastBlockHeight(), + ChainID: SimAppChainID, + }).WithChainID(SimAppChainID) + + // Use genesis state from the first app to initialize the second app + newSimApp.ModuleManager().InitGenesis(ctxNewSimApp, newSimApp.AppCodec(), genesisState) + newSimApp.StoreConsensusParams(ctxNewSimApp, exported.ConsensusParams) + + t.Log("comparing stores") + // The ordering of the keys is not important, we compare the same prefix for both simulations + storeKeysPrefixes := []StoreKeysPrefixes{ + {simApp.GetKey(authtypes.StoreKey), newSimApp.GetKey(authtypes.StoreKey), [][]byte{}}, + { + simApp.GetKey(stakingtypes.StoreKey), newSimApp.GetKey(stakingtypes.StoreKey), + [][]byte{ + stakingtypes.UnbondingQueueKey, stakingtypes.RedelegationQueueKey, stakingtypes.ValidatorQueueKey, + stakingtypes.HistoricalInfoKey, stakingtypes.UnbondingIDKey, stakingtypes.UnbondingIndexKey, stakingtypes.UnbondingTypeKey, stakingtypes.ValidatorUpdatesKey, + }, + }, + {simApp.GetKey(slashingtypes.StoreKey), newSimApp.GetKey(slashingtypes.StoreKey), [][]byte{}}, + {simApp.GetKey(distrtypes.StoreKey), newSimApp.GetKey(distrtypes.StoreKey), [][]byte{}}, + {simApp.GetKey(banktypes.StoreKey), newSimApp.GetKey(banktypes.StoreKey), [][]byte{banktypes.BalancesPrefix}}, + {simApp.GetKey(paramtypes.StoreKey), newSimApp.GetKey(paramtypes.StoreKey), [][]byte{}}, + {simApp.GetKey(govtypes.StoreKey), newSimApp.GetKey(govtypes.StoreKey), [][]byte{}}, + {simApp.GetKey(evidencetypes.StoreKey), newSimApp.GetKey(evidencetypes.StoreKey), [][]byte{}}, + {simApp.GetKey(evmtypes.StoreKey), newSimApp.GetKey(evmtypes.StoreKey), [][]byte{}}, + } + + for _, skp := range storeKeysPrefixes { + storeA := ctxSimApp.KVStore(skp.A) + storeB := ctxNewSimApp.KVStore(skp.B) + + failedKVAs, failedKVBs := sdk.DiffKVStores(storeA, storeB, skp.Prefixes) + require.Equal(t, len(failedKVAs), len(failedKVBs), "unequal sets of key-values to compare") + + t.Logf("compared %d different key/value pairs between %s and %s\n", len(failedKVAs), skp.A, skp.B) + require.Equal( + t, + 0, + len(failedKVAs), + cosmossimutils.GetSimulationLog( + skp.A.Name(), + simApp.SimulationManager().StoreDecoders, + failedKVAs, + failedKVBs, + ), + ) + } +} + +func TestAppSimulationAfterImport(t *testing.T) { + config := zetasimulation.NewConfigFromFlags() + + config.ChainID = SimAppChainID + config.BlockMaxGas = SimBlockMaxGas + config.DBBackend = SimDBBackend + + db, dir, logger, skip, err := cosmossimutils.SetupSimulation( + config, + SimDBBackend, + SimDBName, + zetasimulation.FlagVerboseValue, + zetasimulation.FlagEnabledValue, + ) + if skip { + t.Skip("skipping application simulation") + } + require.NoError(t, err, "simulation setup failed") + + t.Cleanup(func() { + if err := db.Close(); err != nil { + require.NoError(t, err, "Error closing new database") + } + if err := os.RemoveAll(dir); err != nil { + require.NoError(t, err, "Error removing directory") + } + }) + + appOptions := make(cosmossimutils.AppOptionsMap, 0) + appOptions[server.FlagInvCheckPeriod] = zetasimulation.FlagPeriodValue + appOptions[flags.FlagHome] = dir + simApp, err := zetasimulation.NewSimApp( + logger, + db, + appOptions, + interBlockCacheOpt(), + baseapp.SetChainID(SimAppChainID), + ) + require.NoError(t, err) + + // Run randomized simulation + blockedAddresses := simApp.ModuleAccountAddrs() + stopEarly, simParams, simErr := simulation.SimulateFromSeed( + t, + os.Stdout, + simApp.BaseApp, + zetasimulation.AppStateFn( + simApp.AppCodec(), + simApp.SimulationManager(), + simApp.BasicManager().DefaultGenesis(simApp.AppCodec()), + ), + cosmossim.RandomAccounts, + cosmossimutils.SimulationOperations(simApp, simApp.AppCodec(), config), + blockedAddresses, + config, + simApp.AppCodec(), + ) + require.NoError(t, simErr) + + err = zetasimulation.CheckExportSimulation(simApp, config, simParams) + require.NoError(t, err) + + zetasimulation.PrintStats(db) + + if stopEarly { + t.Log("can't export or import a zero-validator genesis, exiting test") + return + } + + t.Log("exporting genesis") + + // export state and simParams + exported, err := simApp.ExportAppStateAndValidators(true, []string{}, []string{}) + require.NoError(t, err) + + t.Log("importing genesis") + + newDB, newDir, _, _, err := cosmossimutils.SetupSimulation( + config, + SimDBBackend+"_new", + SimDBName+"_new", + zetasimulation.FlagVerboseValue, + zetasimulation.FlagEnabledValue, + ) + + require.NoError(t, err, "simulation setup failed") + + t.Cleanup(func() { + if err := newDB.Close(); err != nil { + require.NoError(t, err, "Error closing new database") + } + if err := os.RemoveAll(newDir); err != nil { + require.NoError(t, err, "Error removing directory") + } + }) + + newSimApp, err := zetasimulation.NewSimApp( + logger, + newDB, + appOptions, + interBlockCacheOpt(), + baseapp.SetChainID(SimAppChainID), + ) + require.NoError(t, err) + + newSimApp.InitChain(abci.RequestInitChain{ + ChainId: SimAppChainID, + AppStateBytes: exported.AppState, + }) + + stopEarly, simParams, simErr = simulation.SimulateFromSeed( + t, + os.Stdout, + newSimApp.BaseApp, + zetasimulation.AppStateFn( + simApp.AppCodec(), + simApp.SimulationManager(), + simApp.BasicManager().DefaultGenesis(simApp.AppCodec()), + ), + cosmossim.RandomAccounts, + cosmossimutils.SimulationOperations(newSimApp, newSimApp.AppCodec(), config), + blockedAddresses, + config, + simApp.AppCodec(), + ) + require.NoError(t, err) +} diff --git a/tests/simulation/sim/sim_state.go b/simulation/state.go similarity index 99% rename from tests/simulation/sim/sim_state.go rename to simulation/state.go index c8df9f4f31..540cd0aca7 100644 --- a/tests/simulation/sim/sim_state.go +++ b/simulation/state.go @@ -1,4 +1,4 @@ -package sim +package simulation import ( "encoding/json" diff --git a/tests/simulation/sim_test.go b/tests/simulation/sim_test.go deleted file mode 100644 index 957e92081a..0000000000 --- a/tests/simulation/sim_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package simulation_test - -import ( - "encoding/json" - "math/rand" - "os" - "testing" - - "github.com/stretchr/testify/require" - simutils "github.com/zeta-chain/node/tests/simulation/sim" - - "github.com/cosmos/cosmos-sdk/store" - - "github.com/cosmos/cosmos-sdk/baseapp" - "github.com/cosmos/cosmos-sdk/client/flags" - "github.com/cosmos/cosmos-sdk/server" - cosmossimutils "github.com/cosmos/cosmos-sdk/testutil/sims" - cosmossim "github.com/cosmos/cosmos-sdk/types/simulation" - "github.com/cosmos/cosmos-sdk/x/simulation" - cosmossimcli "github.com/cosmos/cosmos-sdk/x/simulation/client/cli" -) - -// AppChainID hardcoded chainID for simulation - -func init() { - simutils.GetSimulatorFlags() -} - -const ( - SimAppChainID = "simulation_777-1" - SimBlockMaxGas = 815000000 - //github.com/zeta-chain/node/issues/3004 - // TODO : Support pebbleDB for simulation tests - SimDBBackend = "goleveldb" - SimDBName = "simulation" -) - -// interBlockCacheOpt returns a BaseApp option function that sets the persistent -// inter-block write-through cache. -func interBlockCacheOpt() func(*baseapp.BaseApp) { - return baseapp.SetInterBlockCache(store.NewCommitKVStoreCacheManager()) -} - -// TestAppStateDeterminism runs a full application simulation , and produces multiple blocks as per the config -// It checks the determinism of the application by comparing the apphash at the end of each run to other runs -// The following test certifies that , for the same set of operations ( irrespective of what the operations are ) , -// we would reach the same final state if the initial state is the same -func TestAppStateDeterminism(t *testing.T) { - if !simutils.FlagEnabledValue { - t.Skip("skipping application simulation") - } - - config := simutils.NewConfigFromFlags() - - config.InitialBlockHeight = 1 - config.ExportParamsPath = "" - config.OnOperation = false - config.AllInvariants = false - config.ChainID = SimAppChainID - config.DBBackend = SimDBBackend - config.BlockMaxGas = SimBlockMaxGas - - numSeeds := 3 - numTimesToRunPerSeed := 5 - - // We will be overriding the random seed and just run a single simulation on the provided seed value - if config.Seed != cosmossimcli.DefaultSeedValue { - numSeeds = 1 - } - - appHashList := make([]json.RawMessage, numTimesToRunPerSeed) - - appOptions := make(cosmossimutils.AppOptionsMap, 0) - appOptions[server.FlagInvCheckPeriod] = simutils.FlagPeriodValue - - t.Log("Running tests for numSeeds: ", numSeeds, " numTimesToRunPerSeed: ", numTimesToRunPerSeed) - - for i := 0; i < numSeeds; i++ { - if config.Seed == cosmossimcli.DefaultSeedValue { - config.Seed = rand.Int63() - } - // For the same seed, the app hash produced at the end of each run should be the same - for j := 0; j < numTimesToRunPerSeed; j++ { - db, dir, logger, _, err := cosmossimutils.SetupSimulation( - config, - SimDBBackend, - SimDBName, - simutils.FlagVerboseValue, - simutils.FlagEnabledValue, - ) - require.NoError(t, err) - appOptions[flags.FlagHome] = dir - - simApp, err := simutils.NewSimApp( - logger, - db, - appOptions, - interBlockCacheOpt(), - baseapp.SetChainID(SimAppChainID), - ) - - t.Logf( - "running non-determinism simulation; seed %d: %d/%d, attempt: %d/%d\n", - config.Seed, i+1, numSeeds, j+1, numTimesToRunPerSeed, - ) - - blockedAddresses := simApp.ModuleAccountAddrs() - - // Random seed is used to produce a random initial state for the simulation - _, _, err = simulation.SimulateFromSeed( - t, - os.Stdout, - simApp.BaseApp, - simutils.AppStateFn( - simApp.AppCodec(), - simApp.SimulationManager(), - simApp.BasicManager().DefaultGenesis(simApp.AppCodec()), - ), - cosmossim.RandomAccounts, - cosmossimutils.SimulationOperations(simApp, simApp.AppCodec(), config), - blockedAddresses, - config, - simApp.AppCodec(), - ) - require.NoError(t, err) - - simutils.PrintStats(db) - - appHash := simApp.LastCommitID().Hash - appHashList[j] = appHash - - // Clean up resources - require.NoError(t, db.Close()) - require.NoError(t, os.RemoveAll(dir)) - - if j != 0 { - require.Equal( - t, - string(appHashList[0]), - string(appHashList[j]), - "non-determinism in seed %d: %d/%d, attempt: %d/%d\n", - config.Seed, - i+1, - numSeeds, - j+1, - numTimesToRunPerSeed, - ) - } - } - } -} - -// TestFullAppSimulation runs a full app simulation with the provided configuration. -// At the end of the run it tries to export the genesis state to make sure the export works. -func TestFullAppSimulation(t *testing.T) { - - config := simutils.NewConfigFromFlags() - - config.ChainID = SimAppChainID - config.BlockMaxGas = SimBlockMaxGas - config.DBBackend = SimDBBackend - - db, dir, logger, skip, err := cosmossimutils.SetupSimulation( - config, - SimDBBackend, - SimDBName, - simutils.FlagVerboseValue, - simutils.FlagEnabledValue, - ) - if skip { - t.Skip("skipping application simulation") - } - require.NoError(t, err, "simulation setup failed") - - defer func() { - require.NoError(t, db.Close()) - require.NoError(t, os.RemoveAll(dir)) - }() - appOptions := make(cosmossimutils.AppOptionsMap, 0) - appOptions[server.FlagInvCheckPeriod] = simutils.FlagPeriodValue - appOptions[flags.FlagHome] = dir - - simApp, err := simutils.NewSimApp(logger, db, appOptions, interBlockCacheOpt(), baseapp.SetChainID(SimAppChainID)) - require.NoError(t, err) - - blockedAddresses := simApp.ModuleAccountAddrs() - _, _, simerr := simulation.SimulateFromSeed( - t, - os.Stdout, - simApp.BaseApp, - simutils.AppStateFn( - simApp.AppCodec(), - simApp.SimulationManager(), - simApp.BasicManager().DefaultGenesis(simApp.AppCodec()), - ), - cosmossim.RandomAccounts, - cosmossimutils.SimulationOperations(simApp, simApp.AppCodec(), config), - blockedAddresses, - config, - simApp.AppCodec(), - ) - require.NoError(t, simerr) - - // check export works as expected - exported, err := simApp.ExportAppStateAndValidators(false, nil, nil) - require.NoError(t, err) - if config.ExportStatePath != "" { - err := os.WriteFile(config.ExportStatePath, exported.AppState, 0o600) - require.NoError(t, err) - } - - simutils.PrintStats(db) -} From f9dab52cd578729a55e978e2ed8239c9fd7807ba Mon Sep 17 00:00:00 2001 From: Alex Gartner Date: Fri, 1 Nov 2024 02:48:48 -0700 Subject: [PATCH 3/8] chore(e2e): use 2 spaces for yaml (#3070) * e2e: use 2 spaces for yaml * fixes * address feedback --- cmd/zetae2e/init.go | 20 +++++++++++--------- e2e/config/config.go | 15 +++++++++++---- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/cmd/zetae2e/init.go b/cmd/zetae2e/init.go index d8dbfd379f..6f98c24102 100644 --- a/cmd/zetae2e/init.go +++ b/cmd/zetae2e/init.go @@ -9,7 +9,7 @@ import ( "github.com/zeta-chain/node/e2e/config" ) -var initConf = config.Config{} +var initConf = config.DefaultConfig() var configFile = "" func NewInitCmd() *cobra.Command { @@ -19,18 +19,20 @@ func NewInitCmd() *cobra.Command { RunE: initConfig, } - InitCmd.Flags().StringVar(&initConf.RPCs.EVM, "ethURL", "http://eth:8545", "--ethURL http://eth:8545") - InitCmd.Flags().StringVar(&initConf.RPCs.ZetaCoreGRPC, "grpcURL", "zetacore0:9090", "--grpcURL zetacore0:9090") + InitCmd.Flags().StringVar(&initConf.RPCs.EVM, "ethURL", initConf.RPCs.EVM, "--ethURL http://eth:8545") InitCmd.Flags(). - StringVar(&initConf.RPCs.ZetaCoreRPC, "rpcURL", "http://zetacore0:26657", "--rpcURL http://zetacore0:26657") + StringVar(&initConf.RPCs.ZetaCoreGRPC, "grpcURL", initConf.RPCs.ZetaCoreGRPC, "--grpcURL zetacore0:9090") InitCmd.Flags(). - StringVar(&initConf.RPCs.Zevm, "zevmURL", "http://zetacore0:8545", "--zevmURL http://zetacore0:8545") - InitCmd.Flags().StringVar(&initConf.RPCs.Bitcoin.Host, "btcURL", "bitcoin:18443", "--grpcURL bitcoin:18443") + StringVar(&initConf.RPCs.ZetaCoreRPC, "rpcURL", initConf.RPCs.ZetaCoreRPC, "--rpcURL http://zetacore0:26657") InitCmd.Flags(). - StringVar(&initConf.RPCs.Solana, "solanaURL", "http://solana:8899", "--solanaURL http://solana:8899") + StringVar(&initConf.RPCs.Zevm, "zevmURL", initConf.RPCs.Zevm, "--zevmURL http://zetacore0:8545") InitCmd.Flags(). - StringVar(&initConf.RPCs.TONSidecarURL, "tonSidecarURL", "http://ton:8000", "--tonSidecarURL http://ton:8000") - InitCmd.Flags().StringVar(&initConf.ZetaChainID, "chainID", "athens_101-1", "--chainID athens_101-1") + StringVar(&initConf.RPCs.Bitcoin.Host, "btcURL", initConf.RPCs.Bitcoin.Host, "--btcURL bitcoin:18443") + InitCmd.Flags(). + StringVar(&initConf.RPCs.Solana, "solanaURL", initConf.RPCs.Solana, "--solanaURL http://solana:8899") + InitCmd.Flags(). + StringVar(&initConf.RPCs.TONSidecarURL, "tonSidecarURL", initConf.RPCs.TONSidecarURL, "--tonSidecarURL http://ton:8000") + InitCmd.Flags().StringVar(&initConf.ZetaChainID, "chainID", initConf.ZetaChainID, "--chainID athens_101-1") InitCmd.Flags().StringVar(&configFile, local.FlagConfigFile, "e2e.config", "--cfg ./e2e.config") return InitCmd diff --git a/e2e/config/config.go b/e2e/config/config.go index 32a799c027..67685b0dd3 100644 --- a/e2e/config/config.go +++ b/e2e/config/config.go @@ -208,13 +208,20 @@ func WriteConfig(file string, config Config) error { return errors.New("file name cannot be empty") } - b, err := yaml.Marshal(config) + // #nosec G304 -- the variable is expected to be controlled by the user + fHandle, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { - return err + return fmt.Errorf("open file: %w", err) } - err = os.WriteFile(file, b, 0600) + defer fHandle.Close() + + // use a custom encoder so we can set the indentation level + encoder := yaml.NewEncoder(fHandle) + defer encoder.Close() + encoder.SetIndent(2) + err = encoder.Encode(config) if err != nil { - return err + return fmt.Errorf("encode config: %w", err) } return nil } From 6eb85e3a70516213b20ab44b21b48b5861c9d0ef Mon Sep 17 00:00:00 2001 From: Alex Gartner Date: Fri, 1 Nov 2024 09:19:38 -0700 Subject: [PATCH 4/8] refactor(e2e): setup zrc20 in one transaction (#3077) --- cmd/zetae2e/local/local.go | 6 +- e2e/e2etests/test_whitelist_erc20.go | 2 +- e2e/txserver/zeta_tx_server.go | 222 +++++++++++++-------------- 3 files changed, 115 insertions(+), 115 deletions(-) diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index 7cde1a92ad..42ca736de0 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -185,6 +185,11 @@ func localE2ETest(cmd *cobra.Command, _ []string) { // set the authority client to the zeta tx server to be able to query message permissions deployerRunner.ZetaTxServer.SetAuthorityClient(deployerRunner.AuthorityClient) + // run setup steps that do not require tss + if !skipSetup { + noError(deployerRunner.FundEmissionsPool()) + } + // wait for keygen to be completed // if setup is skipped, we assume that the keygen is already completed if !skipSetup { @@ -229,7 +234,6 @@ func localE2ETest(cmd *cobra.Command, _ []string) { if testSolana { deployerRunner.SetupSolana(conf.AdditionalAccounts.UserSolana.SolanaPrivateKey.String()) } - noError(deployerRunner.FundEmissionsPool()) deployerRunner.MintERC20OnEvm(1000000) diff --git a/e2e/e2etests/test_whitelist_erc20.go b/e2e/e2etests/test_whitelist_erc20.go index df34b05597..efd24ef16e 100644 --- a/e2e/e2etests/test_whitelist_erc20.go +++ b/e2e/e2etests/test_whitelist_erc20.go @@ -50,7 +50,7 @@ func TestWhitelistERC20(r *runner.E2ERunner, _ []string) { erc20zrc20Addr, err := txserver.FetchAttributeFromTxResponse(res, "zrc20_address") require.NoError(r, err) - err = r.ZetaTxServer.InitializeLiquidityCap(erc20zrc20Addr) + err = r.ZetaTxServer.InitializeLiquidityCaps(erc20zrc20Addr) require.NoError(r, err) // ensure CCTX created diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index a79c5af098..c86f291432 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -11,6 +11,7 @@ import ( "strings" "time" + abci "github.com/cometbft/cometbft/abci/types" rpchttp "github.com/cometbft/cometbft/rpc/client/http" coretypes "github.com/cometbft/cometbft/rpc/core/types" "github.com/cosmos/cosmos-sdk/client" @@ -32,7 +33,7 @@ import ( slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" upgradetypes "github.com/cosmos/cosmos-sdk/x/upgrade/types" - ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/samber/lo" "github.com/zeta-chain/ethermint/crypto/hd" etherminttypes "github.com/zeta-chain/ethermint/types" evmtypes "github.com/zeta-chain/ethermint/x/evm/types" @@ -192,7 +193,7 @@ func (zts ZetaTxServer) GetAccountMnemonic(index int) string { // BroadcastTx broadcasts a tx to ZetaChain with the provided msg from the account // and waiting for blockTime for tx to be included in the block -func (zts ZetaTxServer) BroadcastTx(account string, msg sdktypes.Msg) (*sdktypes.TxResponse, error) { +func (zts ZetaTxServer) BroadcastTx(account string, msgs ...sdktypes.Msg) (*sdktypes.TxResponse, error) { // Find number and sequence and set it acc, err := zts.clientCtx.Keyring.Key(account) if err != nil { @@ -208,10 +209,13 @@ func (zts ZetaTxServer) BroadcastTx(account string, msg sdktypes.Msg) (*sdktypes } zts.txFactory = zts.txFactory.WithAccountNumber(accountNumber).WithSequence(accountSeq) - txBuilder, err := zts.txFactory.BuildUnsignedTx(msg) + txBuilder, err := zts.txFactory.BuildUnsignedTx(msgs...) if err != nil { return nil, err } + // increase gas and fees if multiple messages are provided + txBuilder.SetGasLimit(zts.txFactory.Gas() * uint64(len(msgs))) + txBuilder.SetFeeAmount(zts.txFactory.Fees().MulInt(sdktypes.NewInt(int64(len(msgs))))) // Sign tx err = tx.Sign(zts.txFactory, account, txBuilder, true) @@ -237,6 +241,9 @@ func broadcastWithBlockTimeout(zts ZetaTxServer, txBytes []byte) (*sdktypes.TxRe TxHash: res.TxHash, }, err } + if res.Code != 0 { + return res, fmt.Errorf("broadcast failed: %s", res.RawLog) + } exitAfter := time.After(zts.blockTimeout) hash, err := hex.DecodeString(res.TxHash) @@ -261,6 +268,9 @@ func mkTxResult( clientCtx client.Context, resTx *coretypes.ResultTx, ) (*sdktypes.TxResponse, error) { + if resTx.TxResult.Code != 0 { + return nil, fmt.Errorf("tx failed: %s", resTx.TxResult.Log) + } txb, err := clientCtx.TxConfig.TxDecoder()(resTx.Tx) if err != nil { return nil, err @@ -442,105 +452,102 @@ func (zts ZetaTxServer) DeployZRC20s( deployerAddr = addrOperational.String() } - deploy := func(msg *fungibletypes.MsgDeployFungibleCoinZRC20) (string, error) { - // noop - if skipChain(msg.ForeignChainId) { - return "", nil - } - - res, err := zts.BroadcastTx(deployerAccount, msg) - if err != nil { - return "", fmt.Errorf("failed to deploy eth zrc20: %w", err) - } - - addr, err := fetchZRC20FromDeployResponse(res) - if err != nil { - return "", fmt.Errorf("unable to fetch zrc20 from deploy response: %w", err) - } - - if err := zts.InitializeLiquidityCap(addr); err != nil { - return "", fmt.Errorf("unable to initialize liquidity cap: %w", err) - } - - return addr, nil - } + deployMsgs := []*fungibletypes.MsgDeployFungibleCoinZRC20{ + fungibletypes.NewMsgDeployFungibleCoinZRC20( + deployerAddr, + "", + chains.GoerliLocalnet.ChainId, + 18, + "ETH", + "gETH", + coin.CoinType_Gas, + 100000, + ), + fungibletypes.NewMsgDeployFungibleCoinZRC20( + deployerAddr, + "", + chains.BitcoinRegtest.ChainId, + 8, + "BTC", + "tBTC", + coin.CoinType_Gas, + 100000, + ), + fungibletypes.NewMsgDeployFungibleCoinZRC20( + deployerAddr, + "", + chains.SolanaLocalnet.ChainId, + 9, + "Solana", + "SOL", + coin.CoinType_Gas, + 100000, + ), + fungibletypes.NewMsgDeployFungibleCoinZRC20( + deployerAddr, + "", + chains.TONLocalnet.ChainId, + 9, + "TON", + "TON", + coin.CoinType_Gas, + 100_000, + ), + fungibletypes.NewMsgDeployFungibleCoinZRC20( + deployerAddr, + erc20Addr, + chains.GoerliLocalnet.ChainId, + 6, + "USDT", + "USDT", + coin.CoinType_ERC20, + 100000, + ), + } + + // apply skipChain filter and convert to sdk.Msg + deployMsgsIface := lo.FilterMap( + deployMsgs, + func(msg *fungibletypes.MsgDeployFungibleCoinZRC20, _ int) (sdktypes.Msg, bool) { + if skipChain(msg.ForeignChainId) { + return nil, false + } + return msg, true + }, + ) - // deploy eth zrc20 - _, err = deploy(fungibletypes.NewMsgDeployFungibleCoinZRC20( - deployerAddr, - "", - chains.GoerliLocalnet.ChainId, - 18, - "ETH", - "gETH", - coin.CoinType_Gas, - 100000, - )) + res, err := zts.BroadcastTx(deployerAccount, deployMsgsIface...) if err != nil { - return "", fmt.Errorf("failed to deploy eth zrc20: %s", err.Error()) + return "", fmt.Errorf("deploy zrc20s: %w", err) } - // deploy btc zrc20 - _, err = deploy(fungibletypes.NewMsgDeployFungibleCoinZRC20( - deployerAddr, - "", - chains.BitcoinRegtest.ChainId, - 8, - "BTC", - "tBTC", - coin.CoinType_Gas, - 100000, - )) - if err != nil { - return "", fmt.Errorf("failed to deploy btc zrc20: %s", err.Error()) - } + deployedEvents := lo.FilterMap(res.Events, func(ev abci.Event, _ int) (*fungibletypes.EventZRC20Deployed, bool) { + pEvent, err := sdktypes.ParseTypedEvent(ev) + if err != nil { + return nil, false + } + deployedEvent, ok := pEvent.(*fungibletypes.EventZRC20Deployed) + return deployedEvent, ok + }) - // deploy sol zrc20 - _, err = deploy(fungibletypes.NewMsgDeployFungibleCoinZRC20( - deployerAddr, - "", - chains.SolanaLocalnet.ChainId, - 9, - "Solana", - "SOL", - coin.CoinType_Gas, - 100000, - )) - if err != nil { - return "", fmt.Errorf("failed to deploy sol zrc20: %s", err.Error()) - } + zrc20Addrs := lo.Map(deployedEvents, func(ev *fungibletypes.EventZRC20Deployed, _ int) string { + return ev.Contract + }) - // deploy ton zrc20 - _, err = deploy(fungibletypes.NewMsgDeployFungibleCoinZRC20( - deployerAddr, - "", - chains.TONLocalnet.ChainId, - 9, - "TON", - "TON", - coin.CoinType_Gas, - 100_000, - )) + err = zts.InitializeLiquidityCaps(zrc20Addrs...) if err != nil { - return "", fmt.Errorf("failed to deploy ton zrc20: %w", err) + return "", fmt.Errorf("initialize liquidity cap: %w", err) } - // deploy erc20 zrc20 - erc20zrc20Addr, err := deploy(fungibletypes.NewMsgDeployFungibleCoinZRC20( - deployerAddr, - erc20Addr, - chains.GoerliLocalnet.ChainId, - 6, - "USDT", - "USDT", - coin.CoinType_ERC20, - 100000, - )) - if err != nil { - return "", fmt.Errorf("failed to deploy erc20 zrc20: %s", err.Error()) + // find erc20 zrc20 + erc20zrc20, ok := lo.Find(deployedEvents, func(ev *fungibletypes.EventZRC20Deployed) bool { + return ev.ChainId == chains.GoerliLocalnet.ChainId && ev.CoinType == coin.CoinType_ERC20 + }) + if !ok { + return "", fmt.Errorf("unable to find erc20 zrc20") } - return erc20zrc20Addr, nil + return erc20zrc20.Contract, nil } // FundEmissionsPool funds the emissions pool with the given amount @@ -588,31 +595,20 @@ func (zts *ZetaTxServer) SetAuthorityClient(authorityClient authoritytypes.Query zts.authorityClient = authorityClient } -// InitializeLiquidityCap initializes the liquidity cap for the given coin with a large value -func (zts ZetaTxServer) InitializeLiquidityCap(zrc20 string) error { +// InitializeLiquidityCaps initializes the liquidity cap for the given coin with a large value +func (zts ZetaTxServer) InitializeLiquidityCaps(zrc20s ...string) error { liquidityCap := sdktypes.NewUint(1e18).MulUint64(1e12) - msg := fungibletypes.NewMsgUpdateZRC20LiquidityCap( - zts.MustGetAccountAddressFromName(utils.OperationalPolicyName), - zrc20, - liquidityCap, - ) - _, err := zts.BroadcastTx(utils.OperationalPolicyName, msg) - return err -} - -// fetchZRC20FromDeployResponse fetches the zrc20 address from the response -func fetchZRC20FromDeployResponse(res *sdktypes.TxResponse) (string, error) { - // fetch the erc20 zrc20 contract address and remove the quotes - zrc20Addr, err := FetchAttributeFromTxResponse(res, "Contract") - if err != nil { - return "", fmt.Errorf("failed to fetch zrc20 contract address: %s, %s", err.Error(), res.String()) - } - if !ethcommon.IsHexAddress(zrc20Addr) { - return "", fmt.Errorf("invalid address in event: %s", zrc20Addr) + msgs := make([]sdktypes.Msg, len(zrc20s)) + for i, zrc20 := range zrc20s { + msgs[i] = fungibletypes.NewMsgUpdateZRC20LiquidityCap( + zts.MustGetAccountAddressFromName(utils.OperationalPolicyName), + zrc20, + liquidityCap, + ) } - - return zrc20Addr, nil + _, err := zts.BroadcastTx(utils.OperationalPolicyName, msgs...) + return err } // fetchMessagePermissions fetches the message permissions for a given message From a35a571c8c8ef53a0d3572c7bfbc521059dc9f3a Mon Sep 17 00:00:00 2001 From: Alex Gartner Date: Fri, 1 Nov 2024 11:58:23 -0700 Subject: [PATCH 5/8] refactor(e2e): use strict event typing (#3079) * refactor(e2e): use strict event typing * use stricter interface on OfType functions --- e2e/e2etests/test_migrate_chain_support.go | 10 +- .../test_migrate_erc20_custody_funds.go | 9 +- e2e/e2etests/test_pause_erc20_custody.go | 22 +-- e2e/e2etests/test_whitelist_erc20.go | 10 +- e2e/runner/v2_migration.go | 12 +- e2e/txserver/zeta_tx_server.go | 128 +++++------------- 6 files changed, 67 insertions(+), 124 deletions(-) diff --git a/e2e/e2etests/test_migrate_chain_support.go b/e2e/e2etests/test_migrate_chain_support.go index 9916c076c2..5c6a53ec13 100644 --- a/e2e/e2etests/test_migrate_chain_support.go +++ b/e2e/e2etests/test_migrate_chain_support.go @@ -167,12 +167,10 @@ func TestMigrateChainSupport(r *runner.E2ERunner, _ []string) { )) require.NoError(r, err) - // retrieve zrc20 and cctx from event - whitelistCCTXIndex, err := txserver.FetchAttributeFromTxResponse(res, "whitelist_cctx_index") - require.NoError(r, err) - - erc20zrc20Addr, err := txserver.FetchAttributeFromTxResponse(res, "zrc20_address") - require.NoError(r, err) + event, ok := txserver.EventOfType[*crosschaintypes.EventERC20Whitelist](res.Events) + require.True(r, ok, "no EventERC20Whitelist in %s", res.TxHash) + erc20zrc20Addr := event.Zrc20Address + whitelistCCTXIndex := event.WhitelistCctxIndex // wait for the whitelist cctx to be mined newRunner.WaitForMinedCCTXFromIndex(whitelistCCTXIndex) diff --git a/e2e/e2etests/test_migrate_erc20_custody_funds.go b/e2e/e2etests/test_migrate_erc20_custody_funds.go index 4ddb0da4e7..8ff5be6327 100644 --- a/e2e/e2etests/test_migrate_erc20_custody_funds.go +++ b/e2e/e2etests/test_migrate_erc20_custody_funds.go @@ -35,18 +35,17 @@ func TestMigrateERC20CustodyFunds(r *runner.E2ERunner, _ []string) { res, err := r.ZetaTxServer.BroadcastTx(utils.AdminPolicyName, msg) require.NoError(r, err) - // fetch cctx index from tx response - cctxIndex, err := txserver.FetchAttributeFromTxResponse(res, "cctx_index") - require.NoError(r, err) + event, ok := txserver.EventOfType[*crosschaintypes.EventERC20CustodyFundsMigration](res.Events) + require.True(r, ok, "no EventERC20CustodyFundsMigration in %s", res.TxHash) - cctxRes, err := r.CctxClient.Cctx(r.Ctx, &crosschaintypes.QueryGetCctxRequest{Index: cctxIndex}) + cctxRes, err := r.CctxClient.Cctx(r.Ctx, &crosschaintypes.QueryGetCctxRequest{Index: event.CctxIndex}) require.NoError(r, err) cctx := cctxRes.CrossChainTx r.Logger.CCTX(*cctx, "migration") // wait for the cctx to be mined - r.WaitForMinedCCTXFromIndex(cctxIndex) + r.WaitForMinedCCTXFromIndex(event.CctxIndex) // check ERC20 balance on new address newAddrBalance, err := r.ERC20.BalanceOf(&bind.CallOpts{}, newAddr) diff --git a/e2e/e2etests/test_pause_erc20_custody.go b/e2e/e2etests/test_pause_erc20_custody.go index a1b0319c76..0c999d91d6 100644 --- a/e2e/e2etests/test_pause_erc20_custody.go +++ b/e2e/e2etests/test_pause_erc20_custody.go @@ -32,18 +32,19 @@ func TestPauseERC20Custody(r *runner.E2ERunner, _ []string) { res, err := r.ZetaTxServer.BroadcastTx(utils.AdminPolicyName, msg) require.NoError(r, err) - // fetch cctx index from tx response - cctxIndex, err := txserver.FetchAttributeFromTxResponse(res, "cctx_index") - require.NoError(r, err) + event, ok := txserver.EventOfType[*crosschaintypes.EventERC20CustodyPausing](res.Events) + require.True(r, ok, "no EventERC20CustodyPausing in %s", res.TxHash) + + require.True(r, event.Pause, "should be paused") - cctxRes, err := r.CctxClient.Cctx(r.Ctx, &crosschaintypes.QueryGetCctxRequest{Index: cctxIndex}) + cctxRes, err := r.CctxClient.Cctx(r.Ctx, &crosschaintypes.QueryGetCctxRequest{Index: event.CctxIndex}) require.NoError(r, err) cctx := cctxRes.CrossChainTx r.Logger.CCTX(*cctx, "pausing") // wait for the cctx to be mined - r.WaitForMinedCCTXFromIndex(cctxIndex) + r.WaitForMinedCCTXFromIndex(event.CctxIndex) // check ERC20 custody contract is paused paused, err = r.ERC20Custody.Paused(&bind.CallOpts{}) @@ -61,18 +62,19 @@ func TestPauseERC20Custody(r *runner.E2ERunner, _ []string) { res, err = r.ZetaTxServer.BroadcastTx(utils.AdminPolicyName, msg) require.NoError(r, err) - // fetch cctx index from tx response - cctxIndex, err = txserver.FetchAttributeFromTxResponse(res, "cctx_index") - require.NoError(r, err) + event, ok = txserver.EventOfType[*crosschaintypes.EventERC20CustodyPausing](res.Events) + require.True(r, ok, "no EventERC20CustodyPausing in %s", res.TxHash) + + require.False(r, event.Pause, "should be unpaused") - cctxRes, err = r.CctxClient.Cctx(r.Ctx, &crosschaintypes.QueryGetCctxRequest{Index: cctxIndex}) + cctxRes, err = r.CctxClient.Cctx(r.Ctx, &crosschaintypes.QueryGetCctxRequest{Index: event.CctxIndex}) require.NoError(r, err) cctx = cctxRes.CrossChainTx r.Logger.CCTX(*cctx, "unpausing") // wait for the cctx to be mined - r.WaitForMinedCCTXFromIndex(cctxIndex) + r.WaitForMinedCCTXFromIndex(event.CctxIndex) // check ERC20 custody contract is unpaused paused, err = r.ERC20Custody.Paused(&bind.CallOpts{}) diff --git a/e2e/e2etests/test_whitelist_erc20.go b/e2e/e2etests/test_whitelist_erc20.go index efd24ef16e..1823947b98 100644 --- a/e2e/e2etests/test_whitelist_erc20.go +++ b/e2e/e2etests/test_whitelist_erc20.go @@ -43,12 +43,10 @@ func TestWhitelistERC20(r *runner.E2ERunner, _ []string) { )) require.NoError(r, err) - // retrieve zrc20 and cctx from event - whitelistCCTXIndex, err := txserver.FetchAttributeFromTxResponse(res, "whitelist_cctx_index") - require.NoError(r, err) - - erc20zrc20Addr, err := txserver.FetchAttributeFromTxResponse(res, "zrc20_address") - require.NoError(r, err) + event, ok := txserver.EventOfType[*crosschaintypes.EventERC20Whitelist](res.Events) + require.True(r, ok, "no EventERC20Whitelist in %s", res.TxHash) + erc20zrc20Addr := event.Zrc20Address + whitelistCCTXIndex := event.WhitelistCctxIndex err = r.ZetaTxServer.InitializeLiquidityCaps(erc20zrc20Addr) require.NoError(r, err) diff --git a/e2e/runner/v2_migration.go b/e2e/runner/v2_migration.go index 1419701887..b150263c6a 100644 --- a/e2e/runner/v2_migration.go +++ b/e2e/runner/v2_migration.go @@ -149,9 +149,9 @@ func (r *E2ERunner) migrateERC20CustodyFunds() { res, err := r.ZetaTxServer.BroadcastTx(utils.AdminPolicyName, msgPausing) require.NoError(r, err) - // fetch cctx index from tx response - cctxIndex, err := txserver.FetchAttributeFromTxResponse(res, "cctx_index") - require.NoError(r, err) + migrationEvent, ok := txserver.EventOfType[*crosschaintypes.EventERC20CustodyFundsMigration](res.Events) + require.True(r, ok, "no EventERC20CustodyFundsMigration in %s", res.TxHash) + cctxIndex := migrationEvent.CctxIndex cctxRes, err := r.CctxClient.Cctx(r.Ctx, &crosschaintypes.QueryGetCctxRequest{Index: cctxIndex}) require.NoError(r, err) @@ -188,9 +188,9 @@ func (r *E2ERunner) migrateERC20CustodyFunds() { res, err = r.ZetaTxServer.BroadcastTx(utils.AdminPolicyName, msgMigration) require.NoError(r, err) - // fetch cctx index from tx response - cctxIndex, err = txserver.FetchAttributeFromTxResponse(res, "cctx_index") - require.NoError(r, err) + migrationEvent, ok = txserver.EventOfType[*crosschaintypes.EventERC20CustodyFundsMigration](res.Events) + require.True(r, ok, "no EventERC20CustodyFundsMigration in %s", res.TxHash) + cctxIndex = migrationEvent.CctxIndex cctxRes, err = r.CctxClient.Cctx(r.Ctx, &crosschaintypes.QueryGetCctxRequest{Index: cctxIndex}) require.NoError(r, err) diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index c86f291432..9b6e2d0b65 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -3,7 +3,6 @@ package txserver import ( "context" "encoding/hex" - "encoding/json" "errors" "fmt" "math/big" @@ -33,6 +32,7 @@ import ( slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" upgradetypes "github.com/cosmos/cosmos-sdk/x/upgrade/types" + "github.com/cosmos/gogoproto/proto" "github.com/samber/lo" "github.com/zeta-chain/ethermint/crypto/hd" etherminttypes "github.com/zeta-chain/ethermint/types" @@ -358,59 +358,25 @@ func (zts ZetaTxServer) DeploySystemContracts( return SystemContractAddresses{}, fmt.Errorf("failed to deploy system contracts: %s", err.Error()) } - systemContractAddress, err := FetchAttributeFromTxResponse(res, "system_contract") - if err != nil { - return SystemContractAddresses{}, fmt.Errorf( - "failed to fetch system contract address: %s; rawlog %s", - err.Error(), - res.RawLog, - ) + deployedEvent, ok := EventOfType[*fungibletypes.EventSystemContractsDeployed](res.Events) + if !ok { + return SystemContractAddresses{}, fmt.Errorf("no EventSystemContractsDeployed in %s", res.TxHash) } // get system contract _, err = zts.BroadcastTx( accountAdmin, - fungibletypes.NewMsgUpdateSystemContract(addrAdmin.String(), systemContractAddress), + fungibletypes.NewMsgUpdateSystemContract(addrAdmin.String(), deployedEvent.SystemContract), ) if err != nil { return SystemContractAddresses{}, fmt.Errorf("failed to set system contract: %s", err.Error()) } - // get uniswap contract addresses - uniswapV2FactoryAddr, err := FetchAttributeFromTxResponse(res, "uniswap_v2_factory") - if err != nil { - return SystemContractAddresses{}, fmt.Errorf("failed to fetch uniswap v2 factory address: %s", err.Error()) - } - uniswapV2RouterAddr, err := FetchAttributeFromTxResponse(res, "uniswap_v2_router") - if err != nil { - return SystemContractAddresses{}, fmt.Errorf("failed to fetch uniswap v2 router address: %s", err.Error()) - } - - // get zevm connector address - zevmConnectorAddr, err := FetchAttributeFromTxResponse(res, "connector_zevm") - if err != nil { - return SystemContractAddresses{}, fmt.Errorf( - "failed to fetch zevm connector address: %s, txResponse: %s", - err.Error(), - res.String(), - ) - } - - // get wzeta address - wzetaAddr, err := FetchAttributeFromTxResponse(res, "wzeta") - if err != nil { - return SystemContractAddresses{}, fmt.Errorf( - "failed to fetch wzeta address: %s, txResponse: %s", - err.Error(), - res.String(), - ) - } - return SystemContractAddresses{ - UniswapV2FactoryAddr: uniswapV2FactoryAddr, - UniswapV2RouterAddr: uniswapV2RouterAddr, - ZEVMConnectorAddr: zevmConnectorAddr, - WZETAAddr: wzetaAddr, + UniswapV2FactoryAddr: deployedEvent.UniswapV2Factory, + UniswapV2RouterAddr: deployedEvent.UniswapV2Router, + ZEVMConnectorAddr: deployedEvent.ConnectorZevm, + WZETAAddr: deployedEvent.Wzeta, }, nil } @@ -521,14 +487,10 @@ func (zts ZetaTxServer) DeployZRC20s( return "", fmt.Errorf("deploy zrc20s: %w", err) } - deployedEvents := lo.FilterMap(res.Events, func(ev abci.Event, _ int) (*fungibletypes.EventZRC20Deployed, bool) { - pEvent, err := sdktypes.ParseTypedEvent(ev) - if err != nil { - return nil, false - } - deployedEvent, ok := pEvent.(*fungibletypes.EventZRC20Deployed) - return deployedEvent, ok - }) + deployedEvents, ok := EventsOfType[*fungibletypes.EventZRC20Deployed](res.Events) + if !ok { + return "", fmt.Errorf("no EventZRC20Deployed in %s", res.TxHash) + } zrc20Addrs := lo.Map(deployedEvents, func(ev *fungibletypes.EventZRC20Deployed, _ int) string { return ev.Contract @@ -699,48 +661,32 @@ func newFactory(clientCtx client.Context) tx.Factory { WithFees("100000000000000000azeta") } -type messageLog struct { - Events []event `json:"events"` -} - -type event struct { - Type string `json:"type"` - Attributes []attribute `json:"attributes"` -} - -type attribute struct { - Key string `json:"key"` - Value string `json:"value"` -} - -// FetchAttributeFromTxResponse fetches the attribute from the tx response -func FetchAttributeFromTxResponse(res *sdktypes.TxResponse, key string) (string, error) { - var logs []messageLog - err := json.Unmarshal([]byte(res.RawLog), &logs) - if err != nil { - return "", fmt.Errorf("failed to unmarshal logs: %s, logs content: %s", err.Error(), res.RawLog) +// EventsOfType gets events of a specified type +func EventsOfType[T proto.Message](events []abci.Event) ([]T, bool) { + var filteredEvents []T + for _, ev := range events { + pEvent, err := sdktypes.ParseTypedEvent(ev) + if err != nil { + continue + } + if typedEvent, ok := pEvent.(T); ok { + filteredEvents = append(filteredEvents, typedEvent) + } } + return filteredEvents, len(filteredEvents) > 0 +} - var attributes []string - for _, log := range logs { - for _, event := range log.Events { - for _, attr := range event.Attributes { - attributes = append(attributes, attr.Key) - if strings.EqualFold(attr.Key, key) { - address := attr.Value - - if len(address) < 2 { - return "", fmt.Errorf("invalid address: %s", address) - } - - // trim the quotes - address = address[1 : len(address)-1] - - return address, nil - } - } +// EventOfType gets one event of a specific type +func EventOfType[T proto.Message](events []abci.Event) (T, bool) { + var event T + for _, ev := range events { + pEvent, err := sdktypes.ParseTypedEvent(ev) + if err != nil { + continue + } + if typedEvent, ok := pEvent.(T); ok { + return typedEvent, true } } - - return "", fmt.Errorf("attribute %s not found, attributes: %+v", key, attributes) + return event, false } From b31794131620b6e0a96fbca07c2b11e6666b22ba Mon Sep 17 00:00:00 2001 From: skosito Date: Mon, 4 Nov 2024 15:46:20 +0000 Subject: [PATCH 6/8] feat: add Whitelist message ability to whitelist SPL tokens on Solana (#2984) * whitelist spl mint wip * whitelist test wip * test fixes * fmt * small refactoring * add protocol contracts solana pkg * bump go idl pkg * revert back to development gateway keypair * fix e2e test * add tss signature to whitelist spl mint * CI fixes * changelog * cleanup parse code instruction for whitelist * cleanup * cleanup comments * add whitelist candidate to msg hash * PR comments * fix after merge * move back to tests solana and add todo * PR comments --- changelog.md | 4 + cmd/zetae2e/local/local.go | 3 + cmd/zetae2e/local/solana.go | 1 + contrib/localnet/solana/gateway.so | Bin 278648 -> 366416 bytes e2e/e2etests/e2etests.go | 7 + e2e/e2etests/test_solana_whitelist_spl.go | 68 ++ e2e/runner/setup_solana.go | 5 +- e2e/runner/solana.go | 46 +- go.mod | 1 + go.sum | 4 + pkg/chains/chain.go | 4 + pkg/contracts/solana/gateway.go | 39 +- pkg/contracts/solana/gateway.json | 681 +++++++++++++++++- pkg/contracts/solana/gateway_message.go | 102 +++ pkg/contracts/solana/gateway_message_test.go | 20 +- pkg/contracts/solana/instruction.go | 59 +- proto/zetachain/zetacore/crosschain/tx.proto | 1 + .../zetachain/zetacore/crosschain/tx_pb.d.ts | 2 + .../keeper/msg_server_whitelist_erc20.go | 46 +- .../keeper/msg_server_whitelist_erc20_test.go | 202 ++++-- x/crosschain/types/message_whitelist_erc20.go | 6 +- .../types/message_whitelist_erc20_test.go | 4 +- x/crosschain/types/tx.pb.go | 1 + zetaclient/chains/solana/observer/inbound.go | 2 +- zetaclient/chains/solana/observer/outbound.go | 4 + .../chains/solana/observer/outbound_test.go | 63 ++ zetaclient/chains/solana/signer/signer.go | 135 +++- zetaclient/chains/solana/signer/whitelist.go | 125 ++++ zetaclient/chains/solana/signer/withdraw.go | 10 +- ...axBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j.json | 10 +- ...vd6G9VbZZQPsEyn6RiTH4YBtqJ89omqfbbNNY.json | 76 ++ zetaclient/testutils/constant.go | 4 +- 32 files changed, 1573 insertions(+), 162 deletions(-) create mode 100644 e2e/e2etests/test_solana_whitelist_spl.go create mode 100644 zetaclient/chains/solana/signer/whitelist.go create mode 100644 zetaclient/testdata/solana/chain_901_outbound_tx_result_phM9bESbiqojmpkkUxgjed8EABkxvPGNau9q31B8Yk1sXUtsxJvd6G9VbZZQPsEyn6RiTH4YBtqJ89omqfbbNNY.json diff --git a/changelog.md b/changelog.md index ce8df87a44..e29e6d26af 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +* [2984](https://github.com/zeta-chain/node/pull/2984) - add Whitelist message ability to whitelist SPL tokens on Solana + ## v21.0.0 ### Features diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index 42ca736de0..e1d579cb0a 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -409,6 +409,9 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestSolanaDepositAndCallRefundName, e2etests.TestSolanaDepositRestrictedName, e2etests.TestSolanaWithdrawRestrictedName, + // TODO move under admin tests + // https://github.com/zeta-chain/node/issues/3085 + e2etests.TestSolanaWhitelistSPLName, } eg.Go(solanaTestRoutine(conf, deployerRunner, verbose, solanaTests...)) } diff --git a/cmd/zetae2e/local/solana.go b/cmd/zetae2e/local/solana.go index 135615051a..3e58418cc4 100644 --- a/cmd/zetae2e/local/solana.go +++ b/cmd/zetae2e/local/solana.go @@ -26,6 +26,7 @@ func solanaTestRoutine( deployerRunner, conf.AdditionalAccounts.UserSolana, runner.NewLogger(verbose, color.FgCyan, "solana"), + runner.WithZetaTxServer(deployerRunner.ZetaTxServer), ) if err != nil { return err diff --git a/contrib/localnet/solana/gateway.so b/contrib/localnet/solana/gateway.so index 185b938c3ca936bfc13c831b38af4b826e8bef8a..0fe82f24f22d1d765fb38561a8c2c37fc75b629b 100755 GIT binary patch literal 366416 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 NC~HE#mx9cC{|gJ8rk?-+ diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index 30c25b33a3..5f6adf9b87 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -130,6 +130,7 @@ const ( TestPauseERC20CustodyName = "pause_erc20_custody" TestMigrateERC20CustodyFundsName = "migrate_erc20_custody_funds" TestMigrateTSSName = "migrate_TSS" + TestSolanaWhitelistSPLName = "solana_whitelist_spl" /* V2 smart contract tests @@ -453,6 +454,12 @@ var AllE2ETests = []runner.E2ETest{ }, TestSolanaWithdrawRestricted, ), + runner.NewE2ETest( + TestSolanaWhitelistSPLName, + "whitelist SPL", + []runner.ArgDefinition{}, + TestSolanaWhitelistSPL, + ), /* TON tests */ diff --git a/e2e/e2etests/test_solana_whitelist_spl.go b/e2e/e2etests/test_solana_whitelist_spl.go new file mode 100644 index 0000000000..259657f72b --- /dev/null +++ b/e2e/e2etests/test_solana_whitelist_spl.go @@ -0,0 +1,68 @@ +package e2etests + +import ( + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/txserver" + "github.com/zeta-chain/node/e2e/utils" + "github.com/zeta-chain/node/pkg/chains" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" +) + +func TestSolanaWhitelistSPL(r *runner.E2ERunner, _ []string) { + // Deploy a new SPL + r.Logger.Info("Deploying new SPL") + + // load deployer private key + privkey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) + require.NoError(r, err) + + spl := r.DeploySPL(&privkey) + + // check that whitelist entry doesn't exist for this spl + seed := [][]byte{[]byte("whitelist"), spl.PublicKey().Bytes()} + whitelistEntryPDA, _, err := solana.FindProgramAddress(seed, r.GatewayProgram) + require.NoError(r, err) + + whitelistEntryInfo, err := r.SolanaClient.GetAccountInfo(r.Ctx, whitelistEntryPDA) + require.Error(r, err) + require.Nil(r, whitelistEntryInfo) + + // whitelist sol zrc20 + r.Logger.Info("whitelisting spl on new network") + res, err := r.ZetaTxServer.BroadcastTx(utils.AdminPolicyName, crosschaintypes.NewMsgWhitelistERC20( + r.ZetaTxServer.MustGetAccountAddressFromName(utils.AdminPolicyName), + spl.PublicKey().String(), + chains.SolanaLocalnet.ChainId, + "TESTSPL", + "TESTSPL", + 6, + 100000, + )) + require.NoError(r, err) + + event, ok := txserver.EventOfType[*crosschaintypes.EventERC20Whitelist](res.Events) + require.True(r, ok, "no EventERC20Whitelist in %s", res.TxHash) + erc20zrc20Addr := event.Zrc20Address + whitelistCCTXIndex := event.WhitelistCctxIndex + + err = r.ZetaTxServer.InitializeLiquidityCaps(erc20zrc20Addr) + require.NoError(r, err) + + // ensure CCTX created + resCCTX, err := r.CctxClient.Cctx(r.Ctx, &crosschaintypes.QueryGetCctxRequest{Index: whitelistCCTXIndex}) + require.NoError(r, err) + + cctx := resCCTX.CrossChainTx + r.Logger.CCTX(*cctx, "whitelist_cctx") + + // wait for the whitelist cctx to be mined + r.WaitForMinedCCTXFromIndex(whitelistCCTXIndex) + + // check that whitelist entry exists for this spl + whitelistEntryInfo, err = r.SolanaClient.GetAccountInfo(r.Ctx, whitelistEntryPDA) + require.NoError(r, err) + require.NotNil(r, whitelistEntryInfo) +} diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go index b8bb309ba1..73a571b2be 100644 --- a/e2e/runner/setup_solana.go +++ b/e2e/runner/setup_solana.go @@ -49,12 +49,11 @@ func (r *E2ERunner) SetupSolana(deployerPrivateKey string) { accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) - accountSlice = append(accountSlice, solana.Meta(r.GatewayProgram)) inst.ProgID = r.GatewayProgram inst.AccountValues = accountSlice inst.DataBytes, err = borsh.Serialize(solanacontracts.InitializeParams{ - Discriminator: solanacontracts.DiscriminatorInitialize(), + Discriminator: solanacontracts.DiscriminatorInitialize, TssAddress: r.TSSAddress, // #nosec G115 chain id always positive ChainID: uint64(chains.SolanaLocalnet.ChainId), @@ -62,7 +61,7 @@ func (r *E2ERunner) SetupSolana(deployerPrivateKey string) { require.NoError(r, err) // create and sign the transaction - signedTx := r.CreateSignedTransaction([]solana.Instruction{&inst}, privkey) + signedTx := r.CreateSignedTransaction([]solana.Instruction{&inst}, privkey, []solana.PrivateKey{}) // broadcast the transaction and wait for finalization _, out := r.BroadcastTxSync(signedTx) diff --git a/e2e/runner/solana.go b/e2e/runner/solana.go index 4d5e6a9b9d..24ea3c3b2f 100644 --- a/e2e/runner/solana.go +++ b/e2e/runner/solana.go @@ -6,6 +6,8 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/gagliardetto/solana-go/programs/token" "github.com/gagliardetto/solana-go/rpc" "github.com/near/borsh-go" "github.com/stretchr/testify/require" @@ -49,7 +51,7 @@ func (r *E2ERunner) CreateDepositInstruction( var err error inst.DataBytes, err = borsh.Serialize(solanacontract.DepositInstructionParams{ - Discriminator: solanacontract.DiscriminatorDeposit(), + Discriminator: solanacontract.DiscriminatorDeposit, Amount: amount, Memo: append(receiver.Bytes(), data...), }) @@ -62,6 +64,7 @@ func (r *E2ERunner) CreateDepositInstruction( func (r *E2ERunner) CreateSignedTransaction( instructions []solana.Instruction, privateKey solana.PrivateKey, + additionalPrivateKeys []solana.PrivateKey, ) *solana.Transaction { // get a recent blockhash recent, err := r.SolanaClient.GetLatestBlockhash(r.Ctx, rpc.CommitmentFinalized) @@ -81,6 +84,11 @@ func (r *E2ERunner) CreateSignedTransaction( if privateKey.PublicKey().Equals(key) { return &privateKey } + for _, apk := range additionalPrivateKeys { + if apk.PublicKey().Equals(key) { + return &apk + } + } return nil }, ) @@ -89,6 +97,40 @@ func (r *E2ERunner) CreateSignedTransaction( return tx } +func (r *E2ERunner) DeploySPL(privateKey *solana.PrivateKey) *solana.Wallet { + lamport, err := r.SolanaClient.GetMinimumBalanceForRentExemption(r.Ctx, token.MINT_SIZE, rpc.CommitmentFinalized) + require.NoError(r, err) + + // to deploy new spl token, create account instruction and initialize mint instruction have to be in the same transaction + tokenAccount := solana.NewWallet() + createAccountInstruction := system.NewCreateAccountInstruction( + lamport, + token.MINT_SIZE, + solana.TokenProgramID, + privateKey.PublicKey(), + tokenAccount.PublicKey(), + ).Build() + + initializeMintInstruction := token.NewInitializeMint2Instruction( + 6, + privateKey.PublicKey(), + privateKey.PublicKey(), + tokenAccount.PublicKey(), + ).Build() + + signedTx := r.CreateSignedTransaction( + []solana.Instruction{createAccountInstruction, initializeMintInstruction}, + *privateKey, + []solana.PrivateKey{tokenAccount.PrivateKey}, + ) + + // broadcast the transaction and wait for finalization + _, out := r.BroadcastTxSync(signedTx) + r.Logger.Info("create spl logs: %v", out.Meta.LogMessages) + + return tokenAccount +} + // BroadcastTxSync broadcasts a transaction and waits for it to be finalized func (r *E2ERunner) BroadcastTxSync(tx *solana.Transaction) (solana.Signature, *rpc.GetTransactionResult) { // broadcast the transaction @@ -134,7 +176,7 @@ func (r *E2ERunner) SOLDepositAndCall( instruction := r.CreateDepositInstruction(signerPrivKey.PublicKey(), receiver, data, amount.Uint64()) // create and sign the transaction - signedTx := r.CreateSignedTransaction([]solana.Instruction{instruction}, *signerPrivKey) + signedTx := r.CreateSignedTransaction([]solana.Instruction{instruction}, *signerPrivKey, []solana.PrivateKey{}) // broadcast the transaction and wait for finalization sig, out := r.BroadcastTxSync(signedTx) diff --git a/go.mod b/go.mod index bb5b93539c..13a35b26bc 100644 --- a/go.mod +++ b/go.mod @@ -336,6 +336,7 @@ require ( github.com/bnb-chain/tss-lib v1.5.0 github.com/showa-93/go-mask v0.6.2 github.com/tonkeeper/tongo v1.9.3 + github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20241025181051-d8d49e4fc85b ) require ( diff --git a/go.sum b/go.sum index 06040e5b57..fec35684fa 100644 --- a/go.sum +++ b/go.sum @@ -2186,6 +2186,8 @@ github.com/fzipp/gocyclo v0.5.1/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlya github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg= github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= +github.com/gagliardetto/gofuzz v1.2.2 h1:XL/8qDMzcgvR4+CyRQW9UGdwPRPMHVJfqQ/uMvSUuQw= +github.com/gagliardetto/gofuzz v1.2.2/go.mod h1:bkH/3hYLZrMLbfYWA0pWzXmi5TTRZnu4pMGZBkqMKvY= github.com/gagliardetto/solana-go v1.10.0 h1:lDuHGC+XLxw9j8fCHBZM9tv4trI0PVhev1m9NAMaIdM= github.com/gagliardetto/solana-go v1.10.0/go.mod h1:afBEcIRrDLJst3lvAahTr63m6W2Ns6dajZxe2irF7Jg= github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= @@ -4212,6 +4214,8 @@ github.com/zeta-chain/keystone/keys v0.0.0-20240826165841-3874f358c138 h1:vck/Fc github.com/zeta-chain/keystone/keys v0.0.0-20240826165841-3874f358c138/go.mod h1:U494OsZTWsU75hqoriZgMdSsgSGP1mUL1jX+wN/Aez8= github.com/zeta-chain/protocol-contracts v1.0.2-athens3.0.20241021075719-d40d2e28467c h1:ZoFxMMZtivRLquXVq1sEVlT45UnTPMO1MSXtc88nDv4= github.com/zeta-chain/protocol-contracts v1.0.2-athens3.0.20241021075719-d40d2e28467c/go.mod h1:SjT7QirtJE8stnAe1SlNOanxtfSfijJm3MGJ+Ax7w7w= +github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20241025181051-d8d49e4fc85b h1:w4YVBbWxk9TI+7HM8hTvK66IgOo5XvEFsmH7n6WgW50= +github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20241025181051-d8d49e4fc85b/go.mod h1:DcDY828o773soiU/h0XpC+naxitrIMFVZqEvq/EJxMA= github.com/zeta-chain/tss-lib v0.0.0-20240916163010-2e6b438bd901 h1:9whtN5fjYHfk4yXIuAsYP2EHxImwDWDVUOnZJ2pfL3w= github.com/zeta-chain/tss-lib v0.0.0-20240916163010-2e6b438bd901/go.mod h1:d2iTC62s9JwKiCMPhcDDXbIZmuzAyJ4lwso0H5QyRbk= github.com/zondax/hid v0.9.1/go.mod h1:l5wttcP0jwtdLjqjMMWFVEE7d1zO0jvSPA9OPZxWpEM= diff --git a/pkg/chains/chain.go b/pkg/chains/chain.go index 0102e74517..6731a70959 100644 --- a/pkg/chains/chain.go +++ b/pkg/chains/chain.go @@ -96,6 +96,10 @@ func (chain Chain) IsEVMChain() bool { return chain.Vm == Vm_evm } +func (chain Chain) IsSolanaChain() bool { + return chain.Consensus == Consensus_solana_consensus +} + func (chain Chain) IsBitcoinChain() bool { return chain.Consensus == Consensus_bitcoin } diff --git a/pkg/contracts/solana/gateway.go b/pkg/contracts/solana/gateway.go index a8f0c571e5..a3adcf5eae 100644 --- a/pkg/contracts/solana/gateway.go +++ b/pkg/contracts/solana/gateway.go @@ -4,6 +4,7 @@ package solana import ( "github.com/gagliardetto/solana-go" "github.com/pkg/errors" + idlgateway "github.com/zeta-chain/protocol-contracts-solana/go-idl/generated" ) const ( @@ -18,30 +19,20 @@ const ( AccountsNumDeposit = 3 ) -// DiscriminatorInitialize returns the discriminator for Solana gateway 'initialize' instruction -func DiscriminatorInitialize() [8]byte { - return [8]byte{175, 175, 109, 31, 13, 152, 155, 237} -} - -// DiscriminatorDeposit returns the discriminator for Solana gateway 'deposit' instruction -func DiscriminatorDeposit() [8]byte { - return [8]byte{242, 35, 198, 137, 82, 225, 242, 182} -} - -// DiscriminatorDepositSPL returns the discriminator for Solana gateway 'deposit_spl_token' instruction -func DiscriminatorDepositSPL() [8]byte { - return [8]byte{86, 172, 212, 121, 63, 233, 96, 144} -} - -// DiscriminatorWithdraw returns the discriminator for Solana gateway 'withdraw' instruction -func DiscriminatorWithdraw() [8]byte { - return [8]byte{183, 18, 70, 156, 148, 109, 161, 34} -} - -// DiscriminatorWithdrawSPL returns the discriminator for Solana gateway 'withdraw_spl_token' instruction -func DiscriminatorWithdrawSPL() [8]byte { - return [8]byte{156, 234, 11, 89, 235, 246, 32} -} +var ( + // DiscriminatorInitialize returns the discriminator for Solana gateway 'initialize' instruction + DiscriminatorInitialize = idlgateway.IDLGateway.GetDiscriminator("initialize") + // DiscriminatorDeposit returns the discriminator for Solana gateway 'deposit' instruction + DiscriminatorDeposit = idlgateway.IDLGateway.GetDiscriminator("deposit") + // DiscriminatorDepositSPL returns the discriminator for Solana gateway 'deposit_spl_token' instruction + DiscriminatorDepositSPL = idlgateway.IDLGateway.GetDiscriminator("deposit_spl_token") + // DiscriminatorWithdraw returns the discriminator for Solana gateway 'withdraw' instruction + DiscriminatorWithdraw = idlgateway.IDLGateway.GetDiscriminator("withdraw") + // DiscriminatorWithdrawSPL returns the discriminator for Solana gateway 'withdraw_spl_token' instruction + DiscriminatorWithdrawSPL = idlgateway.IDLGateway.GetDiscriminator("withdraw_spl_token") + // DiscriminatorWhitelist returns the discriminator for Solana gateway 'whitelist_spl_mint' instruction + DiscriminatorWhitelistSplMint = idlgateway.IDLGateway.GetDiscriminator("whitelist_spl_mint") +) // ParseGatewayAddressAndPda parses the gateway id and program derived address from the given string func ParseGatewayIDAndPda(address string) (solana.PublicKey, solana.PublicKey, error) { diff --git a/pkg/contracts/solana/gateway.json b/pkg/contracts/solana/gateway.json index 8747c2ca0f..b42f29779e 100644 --- a/pkg/contracts/solana/gateway.json +++ b/pkg/contracts/solana/gateway.json @@ -27,7 +27,76 @@ }, { "name": "pda", - "writable": true + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "receiver", + "type": { + "array": [ + "u8", + 20 + ] + } + } + ] + }, + { + "name": "deposit_and_call", + "discriminator": [ + 65, + 33, + 186, + 198, + 114, + 223, + 133, + 57 + ], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } }, { "name": "system_program", @@ -40,7 +109,16 @@ "type": "u64" }, { - "name": "memo", + "name": "receiver", + "type": { + "array": [ + "u8", + 20 + ] + } + }, + { + "name": "message", "type": "bytes" } ] @@ -65,7 +143,97 @@ }, { "name": "pda", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "whitelist_entry", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 119, + 104, + 105, + 116, + 101, + 108, + 105, + 115, + 116 + ] + }, + { + "kind": "account", + "path": "mint_account" + } + ] + } + }, + { + "name": "mint_account" + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "from", + "writable": true + }, + { + "name": "to", + "writable": true + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "receiver", + "type": { + "array": [ + "u8", + 20 + ] + } + } + ] + }, + { + "name": "deposit_spl_token_and_call", + "discriminator": [ + 14, + 181, + 27, + 187, + 171, + 61, + 237, + 147 + ], + "accounts": [ + { + "name": "signer", "writable": true, + "signer": true + }, + { + "name": "pda", "pda": { "seeds": [ { @@ -80,6 +248,34 @@ ] } }, + { + "name": "whitelist_entry", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 119, + 104, + 105, + 116, + 101, + 108, + 105, + 115, + 116 + ] + }, + { + "kind": "account", + "path": "mint_account" + } + ] + } + }, + { + "name": "mint_account" + }, { "name": "token_program", "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" @@ -99,7 +295,16 @@ "type": "u64" }, { - "name": "memo", + "name": "receiver", + "type": { + "array": [ + "u8", + 20 + ] + } + }, + { + "name": "message", "type": "bytes" } ] @@ -153,6 +358,242 @@ 20 ] } + }, + { + "name": "chain_id", + "type": "u64" + } + ] + }, + { + "name": "initialize_rent_payer", + "discriminator": [ + 225, + 73, + 166, + 180, + 25, + 245, + 183, + 96 + ], + "accounts": [ + { + "name": "rent_payer_pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 114, + 101, + 110, + 116, + 45, + 112, + 97, + 121, + 101, + 114 + ] + } + ] + } + }, + { + "name": "authority", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "set_deposit_paused", + "discriminator": [ + 98, + 179, + 141, + 24, + 246, + 120, + 164, + 143 + ], + "accounts": [ + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "signer", + "writable": true, + "signer": true + } + ], + "args": [ + { + "name": "deposit_paused", + "type": "bool" + } + ] + }, + { + "name": "unwhitelist_spl_mint", + "discriminator": [ + 73, + 142, + 63, + 191, + 233, + 238, + 170, + 104 + ], + "accounts": [ + { + "name": "whitelist_entry", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 119, + 104, + 105, + 116, + 101, + 108, + 105, + 115, + 116 + ] + }, + { + "kind": "account", + "path": "whitelist_candidate" + } + ] + } + }, + { + "name": "whitelist_candidate" + }, + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "authority", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "signature", + "type": { + "array": [ + "u8", + 64 + ] + } + }, + { + "name": "recovery_id", + "type": "u8" + }, + { + "name": "message_hash", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "nonce", + "type": "u64" + } + ] + }, + { + "name": "update_authority", + "discriminator": [ + 32, + 46, + 64, + 28, + 149, + 75, + 243, + 88 + ], + "accounts": [ + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "signer", + "writable": true, + "signer": true + } + ], + "args": [ + { + "name": "new_authority_address", + "type": "pubkey" } ] }, @@ -171,7 +612,20 @@ "accounts": [ { "name": "pda", - "writable": true + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } }, { "name": "signer", @@ -191,6 +645,104 @@ } ] }, + { + "name": "whitelist_spl_mint", + "discriminator": [ + 30, + 110, + 162, + 42, + 208, + 147, + 254, + 219 + ], + "accounts": [ + { + "name": "whitelist_entry", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 119, + 104, + 105, + 116, + 101, + 108, + 105, + 115, + 116 + ] + }, + { + "kind": "account", + "path": "whitelist_candidate" + } + ] + } + }, + { + "name": "whitelist_candidate" + }, + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "authority", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "signature", + "type": { + "array": [ + "u8", + 64 + ] + } + }, + { + "name": "recovery_id", + "type": "u8" + }, + { + "name": "message_hash", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "nonce", + "type": "u64" + } + ] + }, { "name": "withdraw", "discriminator": [ @@ -211,7 +763,20 @@ }, { "name": "pda", - "writable": true + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } }, { "name": "to", @@ -287,19 +852,63 @@ } }, { - "name": "from", + "name": "pda_ata", "writable": true }, { - "name": "to", + "name": "mint_account" + }, + { + "name": "recipient" + }, + { + "name": "recipient_ata", + "docs": [ + "the validation will be done in the instruction processor." + ], "writable": true }, + { + "name": "rent_payer_pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 114, + 101, + 110, + 116, + 45, + 112, + 97, + 121, + 101, + 114 + ] + } + ] + } + }, { "name": "token_program", "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "associated_token_program", + "address": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" } ], "args": [ + { + "name": "decimals", + "type": "u8" + }, { "name": "amount", "type": "u64" @@ -346,6 +955,32 @@ 43, 94 ] + }, + { + "name": "RentPayerPda", + "discriminator": [ + 48, + 247, + 192, + 150, + 46, + 218, + 14, + 121 + ] + }, + { + "name": "WhitelistEntry", + "discriminator": [ + 51, + 70, + 173, + 81, + 219, + 192, + 234, + 62 + ] } ], "errors": [ @@ -388,6 +1023,16 @@ "code": 6007, "name": "MemoLengthTooShort", "msg": "MemoLengthTooShort" + }, + { + "code": 6008, + "name": "DepositPaused", + "msg": "DepositPaused" + }, + { + "code": 6009, + "name": "SPLAtaAndMintAddressMismatch", + "msg": "SPLAtaAndMintAddressMismatch" } ], "types": [ @@ -412,9 +1057,31 @@ { "name": "authority", "type": "pubkey" + }, + { + "name": "chain_id", + "type": "u64" + }, + { + "name": "deposit_paused", + "type": "bool" } ] } + }, + { + "name": "RentPayerPda", + "type": { + "kind": "struct", + "fields": [] + } + }, + { + "name": "WhitelistEntry", + "type": { + "kind": "struct", + "fields": [] + } } ] } \ No newline at end of file diff --git a/pkg/contracts/solana/gateway_message.go b/pkg/contracts/solana/gateway_message.go index 021af3cf1f..1c8abaca23 100644 --- a/pkg/contracts/solana/gateway_message.go +++ b/pkg/contracts/solana/gateway_message.go @@ -61,6 +61,8 @@ func (msg *MsgWithdraw) Hash() [32]byte { var message []byte buff := make([]byte, 8) + message = append(message, []byte("withdraw")...) + binary.BigEndian.PutUint64(buff, msg.chainID) message = append(message, buff...) @@ -105,3 +107,103 @@ func (msg *MsgWithdraw) Signer() (common.Address, error) { return RecoverSigner(msgHash[:], msgSig[:]) } + +// MsgWhitelist is the message for the Solana gateway whitelist_spl_mint instruction +type MsgWhitelist struct { + // whitelistCandidate is the SPL token to be whitelisted in gateway program + whitelistCandidate solana.PublicKey + + // whitelistEntry is the entry in gateway program representing whitelisted SPL token + whitelistEntry solana.PublicKey + + // chainID is the chain ID of Solana chain + chainID uint64 + + // Nonce is the nonce for the withdraw/withdraw_spl + nonce uint64 + + // signature is the signature of the message + signature [65]byte +} + +// NewMsgWhitelist returns a new whitelist_spl_mint message +func NewMsgWhitelist( + whitelistCandidate solana.PublicKey, + whitelistEntry solana.PublicKey, + chainID, nonce uint64, +) *MsgWhitelist { + return &MsgWhitelist{ + whitelistCandidate: whitelistCandidate, + whitelistEntry: whitelistEntry, + chainID: chainID, + nonce: nonce, + } +} + +// To returns the recipient address of the message +func (msg *MsgWhitelist) WhitelistCandidate() solana.PublicKey { + return msg.whitelistCandidate +} + +func (msg *MsgWhitelist) WhitelistEntry() solana.PublicKey { + return msg.whitelistEntry +} + +// ChainID returns the chain ID of the message +func (msg *MsgWhitelist) ChainID() uint64 { + return msg.chainID +} + +// Nonce returns the nonce of the message +func (msg *MsgWhitelist) Nonce() uint64 { + return msg.nonce +} + +// Hash packs the whitelist message and computes the hash +func (msg *MsgWhitelist) Hash() [32]byte { + var message []byte + buff := make([]byte, 8) + + message = append(message, []byte("whitelist_spl_mint")...) + + binary.BigEndian.PutUint64(buff, msg.chainID) + message = append(message, buff...) + + message = append(message, msg.whitelistCandidate.Bytes()...) + + binary.BigEndian.PutUint64(buff, msg.nonce) + message = append(message, buff...) + + return crypto.Keccak256Hash(message) +} + +// SetSignature attaches the signature to the message +func (msg *MsgWhitelist) SetSignature(signature [65]byte) *MsgWhitelist { + msg.signature = signature + return msg +} + +// SigRSV returns the full 65-byte [R+S+V] signature +func (msg *MsgWhitelist) SigRSV() [65]byte { + return msg.signature +} + +// SigRS returns the 64-byte [R+S] core part of the signature +func (msg *MsgWhitelist) SigRS() [64]byte { + var sig [64]byte + copy(sig[:], msg.signature[:64]) + return sig +} + +// SigV returns the V part (recovery ID) of the signature +func (msg *MsgWhitelist) SigV() uint8 { + return msg.signature[64] +} + +// Signer returns the signer of the message +func (msg *MsgWhitelist) Signer() (common.Address, error) { + msgHash := msg.Hash() + msgSig := msg.SigRSV() + + return RecoverSigner(msgHash[:], msgSig[:]) +} diff --git a/pkg/contracts/solana/gateway_message_test.go b/pkg/contracts/solana/gateway_message_test.go index 20c4d84ef9..68af93e859 100644 --- a/pkg/contracts/solana/gateway_message_test.go +++ b/pkg/contracts/solana/gateway_message_test.go @@ -20,7 +20,7 @@ func Test_MsgWithdrawHash(t *testing.T) { amount := uint64(1336000) to := solana.MustPublicKeyFromBase58("37yGiHAnLvWZUNVwu9esp74YQFqxU1qHCbABkDvRddUQ") - wantHash := "a20cddb3f888f4064ced892a477101f45469a8c50f783b966d3fec2455887c05" + wantHash := "aa609ef9480303e8d743f6e36fe1bea0cc56b8d27dcbd8220846125c1181b681" wantHashBytes, err := hex.DecodeString(wantHash) require.NoError(t, err) @@ -29,3 +29,21 @@ func Test_MsgWithdrawHash(t *testing.T) { require.True(t, bytes.Equal(hash[:], wantHashBytes)) }) } + +func Test_MsgWhitelistHash(t *testing.T) { + t.Run("should pass for archived inbound, receipt and cctx", func(t *testing.T) { + // #nosec G115 always positive + chainID := uint64(chains.SolanaLocalnet.ChainId) + nonce := uint64(0) + whitelistCandidate := solana.MustPublicKeyFromBase58("37yGiHAnLvWZUNVwu9esp74YQFqxU1qHCbABkDvRddUQ") + whitelistEntry := solana.MustPublicKeyFromBase58("2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s") + + wantHash := "cde8fa3ab24b50320db1c47f30492e789177d28e76208176f0a52b8ed54ce2dd" + wantHashBytes, err := hex.DecodeString(wantHash) + require.NoError(t, err) + + // create new withdraw message + hash := contracts.NewMsgWhitelist(whitelistCandidate, whitelistEntry, chainID, nonce).Hash() + require.True(t, bytes.Equal(hash[:], wantHashBytes)) + }) +} diff --git a/pkg/contracts/solana/instruction.go b/pkg/contracts/solana/instruction.go index f338129c9b..df5db0416b 100644 --- a/pkg/contracts/solana/instruction.go +++ b/pkg/contracts/solana/instruction.go @@ -99,7 +99,7 @@ func ParseInstructionWithdraw(instruction solana.CompiledInstruction) (*Withdraw } // check the discriminator to ensure it's a 'withdraw' instruction - if inst.Discriminator != DiscriminatorWithdraw() { + if inst.Discriminator != DiscriminatorWithdraw { return nil, fmt.Errorf("not a withdraw instruction: %v", inst.Discriminator) } @@ -116,3 +116,60 @@ func RecoverSigner(msgHash []byte, msgSig []byte) (signer common.Address, err er return crypto.PubkeyToAddress(*pubKey), nil } + +var _ OutboundInstruction = (*WhitelistInstructionParams)(nil) + +// WhitelistInstructionParams contains the parameters for a gateway whitelist_spl_mint instruction +type WhitelistInstructionParams struct { + // Discriminator is the unique identifier for the whitelist instruction + Discriminator [8]byte + + // Signature is the ECDSA signature (by TSS) for the whitelist + Signature [64]byte + + // RecoveryID is the recovery ID used to recover the public key from ECDSA signature + RecoveryID uint8 + + // MessageHash is the hash of the message signed by TSS + MessageHash [32]byte + + // Nonce is the nonce for the whitelist + Nonce uint64 +} + +// Signer returns the signer of the signature contained +func (inst *WhitelistInstructionParams) Signer() (signer common.Address, err error) { + var signature [65]byte + copy(signature[:], inst.Signature[:64]) + signature[64] = inst.RecoveryID + + return RecoverSigner(inst.MessageHash[:], signature[:]) +} + +// GatewayNonce returns the nonce of the instruction +func (inst *WhitelistInstructionParams) GatewayNonce() uint64 { + return inst.Nonce +} + +// TokenAmount returns the amount of the instruction +func (inst *WhitelistInstructionParams) TokenAmount() uint64 { + return 0 +} + +// ParseInstructionWhitelist tries to parse the instruction as a 'whitelist_spl_mint'. +// It returns nil if the instruction can't be parsed as a 'whitelist_spl_mint'. +func ParseInstructionWhitelist(instruction solana.CompiledInstruction) (*WhitelistInstructionParams, error) { + // try deserializing instruction as a 'whitelist_spl_mint' + inst := &WhitelistInstructionParams{} + err := borsh.Deserialize(inst, instruction.Data) + if err != nil { + return nil, errors.Wrap(err, "error deserializing instruction") + } + + // check the discriminator to ensure it's a 'whitelist_spl_mint' instruction + if inst.Discriminator != DiscriminatorWhitelistSplMint { + return nil, fmt.Errorf("not a whitelist_spl_mint instruction: %v", inst.Discriminator) + } + + return inst, nil +} diff --git a/proto/zetachain/zetacore/crosschain/tx.proto b/proto/zetachain/zetacore/crosschain/tx.proto index cb76e53d0a..ed8b6b6f59 100644 --- a/proto/zetachain/zetacore/crosschain/tx.proto +++ b/proto/zetachain/zetacore/crosschain/tx.proto @@ -71,6 +71,7 @@ message MsgAddInboundTracker { } message MsgAddInboundTrackerResponse {} +// TODO: https://github.com/zeta-chain/node/issues/3083 message MsgWhitelistERC20 { string creator = 1; string erc20_address = 2; diff --git a/typescript/zetachain/zetacore/crosschain/tx_pb.d.ts b/typescript/zetachain/zetacore/crosschain/tx_pb.d.ts index f830e00a48..db3e7073ea 100644 --- a/typescript/zetachain/zetacore/crosschain/tx_pb.d.ts +++ b/typescript/zetachain/zetacore/crosschain/tx_pb.d.ts @@ -186,6 +186,8 @@ export declare class MsgAddInboundTrackerResponse extends Message { diff --git a/x/crosschain/keeper/msg_server_whitelist_erc20.go b/x/crosschain/keeper/msg_server_whitelist_erc20.go index 4ae98a85b5..197310e16c 100644 --- a/x/crosschain/keeper/msg_server_whitelist_erc20.go +++ b/x/crosschain/keeper/msg_server_whitelist_erc20.go @@ -8,6 +8,7 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" "github.com/zeta-chain/node/pkg/coin" authoritytypes "github.com/zeta-chain/node/x/authority/types" @@ -31,20 +32,44 @@ func (k msgServer) WhitelistERC20( return nil, errorsmod.Wrap(authoritytypes.ErrUnauthorized, err.Error()) } - erc20Addr := ethcommon.HexToAddress(msg.Erc20Address) - if erc20Addr == (ethcommon.Address{}) { + chain, found := k.zetaObserverKeeper.GetSupportedChainFromChainID(ctx, msg.ChainId) + if !found { + return nil, errorsmod.Wrapf(types.ErrInvalidChainID, "chain id (%d) not supported", msg.ChainId) + } + + switch { + case chain.IsEVMChain(): + erc20Addr := ethcommon.HexToAddress(msg.Erc20Address) + if erc20Addr == (ethcommon.Address{}) { + return nil, errorsmod.Wrapf( + sdkerrors.ErrInvalidAddress, + "invalid ERC20 contract address (%s)", + msg.Erc20Address, + ) + } + + case chain.IsSolanaChain(): + _, err := solana.PublicKeyFromBase58(msg.Erc20Address) + if err != nil { + return nil, errorsmod.Wrapf( + sdkerrors.ErrInvalidAddress, + "invalid solana contract address (%s)", + msg.Erc20Address, + ) + } + + default: return nil, errorsmod.Wrapf( - sdkerrors.ErrInvalidAddress, - "invalid ERC20 contract address (%s)", - msg.Erc20Address, + sdkerrors.ErrInvalidChainID, + "whitelist for chain id (%d) not supported", + msg.ChainId, ) } - // check if the erc20 is already whitelisted + // check if the asset is already whitelisted foreignCoins := k.fungibleKeeper.GetAllForeignCoins(ctx) for _, fCoin := range foreignCoins { - assetAddr := ethcommon.HexToAddress(fCoin.Asset) - if assetAddr == erc20Addr && fCoin.ForeignChainId == msg.ChainId { + if fCoin.Asset == msg.Erc20Address && fCoin.ForeignChainId == msg.ChainId { return nil, errorsmod.Wrapf( fungibletypes.ErrForeignCoinAlreadyExist, "ERC20 contract address (%s) already whitelisted on chain (%d)", @@ -59,11 +84,6 @@ func (k msgServer) WhitelistERC20( return nil, errorsmod.Wrapf(types.ErrCannotFindTSSKeys, "Cannot create new admin cmd of type whitelistERC20") } - chain, found := k.zetaObserverKeeper.GetSupportedChainFromChainID(ctx, msg.ChainId) - if !found { - return nil, errorsmod.Wrapf(types.ErrInvalidChainID, "chain id (%d) not supported", msg.ChainId) - } - // use a temporary context for the zrc20 deployment tmpCtx, commit := ctx.CacheContext() diff --git a/x/crosschain/keeper/msg_server_whitelist_erc20_test.go b/x/crosschain/keeper/msg_server_whitelist_erc20_test.go index 3eb18b9931..c82261bd05 100644 --- a/x/crosschain/keeper/msg_server_whitelist_erc20_test.go +++ b/x/crosschain/keeper/msg_server_whitelist_erc20_test.go @@ -18,81 +18,156 @@ import ( ) func TestKeeper_WhitelistERC20(t *testing.T) { - t.Run("can deploy and whitelist an erc20", func(t *testing.T) { - k, ctx, sdkk, zk := keepertest.CrosschainKeeperWithMocks(t, keepertest.CrosschainMockOptions{ + tests := []struct { + name string + tokenAddress string + secondTokenAddress string + chainID int64 + }{ + { + name: "can deploy and whitelist an erc20", + tokenAddress: sample.EthAddress().Hex(), + secondTokenAddress: sample.EthAddress().Hex(), + chainID: getValidEthChainID(), + }, + { + name: "can deploy and whitelist a spl", + tokenAddress: sample.SolanaAddress(t), + secondTokenAddress: sample.SolanaAddress(t), + chainID: getValidSolanaChainID(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + k, ctx, sdkk, zk := keepertest.CrosschainKeeperWithMocks(t, keepertest.CrosschainMockOptions{ + UseAuthorityMock: true, + }) + + msgServer := crosschainkeeper.NewMsgServerImpl(*k) + k.GetAuthKeeper().GetModuleAccount(ctx, fungibletypes.ModuleName) + + setSupportedChain(ctx, zk, tt.chainID) + + admin := sample.AccAddress() + authorityMock := keepertest.GetCrosschainAuthorityMock(t, k) + + deploySystemContracts(t, ctx, zk.FungibleKeeper, sdkk.EvmKeeper) + setupGasCoin(t, ctx, zk.FungibleKeeper, sdkk.EvmKeeper, tt.chainID, "foobar", "FOOBAR") + k.GetObserverKeeper().SetTssAndUpdateNonce(ctx, sample.Tss()) + k.SetGasPrice(ctx, types.GasPrice{ + ChainId: tt.chainID, + MedianIndex: 0, + Prices: []uint64{1}, + }) + + msg := types.MsgWhitelistERC20{ + Creator: admin, + Erc20Address: tt.tokenAddress, + ChainId: tt.chainID, + Name: "foo", + Symbol: "FOO", + Decimals: 18, + GasLimit: 100000, + } + keepertest.MockCheckAuthorization(&authorityMock.Mock, &msg, nil) + res, err := msgServer.WhitelistERC20(ctx, &msg) + require.NoError(t, err) + require.NotNil(t, res) + zrc20 := res.Zrc20Address + cctxIndex := res.CctxIndex + + // check zrc20 and cctx created + assertContractDeployment(t, sdkk.EvmKeeper, ctx, ethcommon.HexToAddress(zrc20)) + fc, found := zk.FungibleKeeper.GetForeignCoins(ctx, zrc20) + require.True(t, found) + require.EqualValues(t, "foo", fc.Name) + require.EqualValues(t, tt.tokenAddress, fc.Asset) + cctx, found := k.GetCrossChainTx(ctx, cctxIndex) + require.True(t, found) + require.EqualValues( + t, + fmt.Sprintf("%s:%s", constant.CmdWhitelistERC20, tt.tokenAddress), + cctx.RelayedMessage, + ) + + // check gas limit is set + gasLimit, err := zk.FungibleKeeper.QueryGasLimit(ctx, ethcommon.HexToAddress(zrc20)) + require.NoError(t, err) + require.Equal(t, uint64(100000), gasLimit.Uint64()) + + msgNew := types.MsgWhitelistERC20{ + Creator: admin, + Erc20Address: tt.secondTokenAddress, + ChainId: tt.chainID, + Name: "bar", + Symbol: "BAR", + Decimals: 18, + GasLimit: 100000, + } + keepertest.MockCheckAuthorization(&authorityMock.Mock, &msgNew, nil) + + // Ensure that whitelist a new erc20 create a cctx with a different index + res, err = msgServer.WhitelistERC20(ctx, &msgNew) + require.NoError(t, err) + require.NotNil(t, res) + require.NotEqual(t, cctxIndex, res.CctxIndex) + }) + } + + t.Run("should fail if not authorized", func(t *testing.T) { + k, ctx, _, _ := keepertest.CrosschainKeeperWithMocks(t, keepertest.CrosschainMockOptions{ UseAuthorityMock: true, }) msgServer := crosschainkeeper.NewMsgServerImpl(*k) k.GetAuthKeeper().GetModuleAccount(ctx, fungibletypes.ModuleName) - chainID := getValidEthChainID() - setSupportedChain(ctx, zk, chainID) - admin := sample.AccAddress() - erc20Address := sample.EthAddress().Hex() authorityMock := keepertest.GetCrosschainAuthorityMock(t, k) - deploySystemContracts(t, ctx, zk.FungibleKeeper, sdkk.EvmKeeper) - setupGasCoin(t, ctx, zk.FungibleKeeper, sdkk.EvmKeeper, chainID, "foobar", "FOOBAR") - k.GetObserverKeeper().SetTssAndUpdateNonce(ctx, sample.Tss()) - k.SetGasPrice(ctx, types.GasPrice{ - ChainId: chainID, - MedianIndex: 0, - Prices: []uint64{1}, - }) - msg := types.MsgWhitelistERC20{ Creator: admin, - Erc20Address: erc20Address, - ChainId: chainID, + Erc20Address: sample.EthAddress().Hex(), + ChainId: getValidEthChainID(), Name: "foo", Symbol: "FOO", Decimals: 18, GasLimit: 100000, } - keepertest.MockCheckAuthorization(&authorityMock.Mock, &msg, nil) - res, err := msgServer.WhitelistERC20(ctx, &msg) - require.NoError(t, err) - require.NotNil(t, res) - zrc20 := res.Zrc20Address - cctxIndex := res.CctxIndex - - // check zrc20 and cctx created - assertContractDeployment(t, sdkk.EvmKeeper, ctx, ethcommon.HexToAddress(zrc20)) - fc, found := zk.FungibleKeeper.GetForeignCoins(ctx, zrc20) - require.True(t, found) - require.EqualValues(t, "foo", fc.Name) - require.EqualValues(t, erc20Address, fc.Asset) - cctx, found := k.GetCrossChainTx(ctx, cctxIndex) - require.True(t, found) - require.EqualValues(t, fmt.Sprintf("%s:%s", constant.CmdWhitelistERC20, erc20Address), cctx.RelayedMessage) - - // check gas limit is set - gasLimit, err := zk.FungibleKeeper.QueryGasLimit(ctx, ethcommon.HexToAddress(zrc20)) - require.NoError(t, err) - require.Equal(t, uint64(100000), gasLimit.Uint64()) - - msgNew := types.MsgWhitelistERC20{ + keepertest.MockCheckAuthorization(&authorityMock.Mock, &msg, authoritytypes.ErrUnauthorized) + _, err := msgServer.WhitelistERC20(ctx, &msg) + require.ErrorIs(t, err, authoritytypes.ErrUnauthorized) + }) + + t.Run("should fail if invalid erc20 address", func(t *testing.T) { + k, ctx, _, _ := keepertest.CrosschainKeeperWithMocks(t, keepertest.CrosschainMockOptions{ + UseAuthorityMock: true, + }) + + msgServer := crosschainkeeper.NewMsgServerImpl(*k) + k.GetAuthKeeper().GetModuleAccount(ctx, fungibletypes.ModuleName) + + admin := sample.AccAddress() + authorityMock := keepertest.GetCrosschainAuthorityMock(t, k) + + msg := types.MsgWhitelistERC20{ Creator: admin, - Erc20Address: sample.EthAddress().Hex(), - ChainId: chainID, - Name: "bar", - Symbol: "BAR", + Erc20Address: "invalid", + ChainId: getValidEthChainID(), + Name: "foo", + Symbol: "FOO", Decimals: 18, GasLimit: 100000, } - keepertest.MockCheckAuthorization(&authorityMock.Mock, &msgNew, nil) + keepertest.MockCheckAuthorization(&authorityMock.Mock, &msg, nil) - // Ensure that whitelist a new erc20 create a cctx with a different index - res, err = msgServer.WhitelistERC20(ctx, &msgNew) - require.NoError(t, err) - require.NotNil(t, res) - require.NotEqual(t, cctxIndex, res.CctxIndex) + _, err := msgServer.WhitelistERC20(ctx, &msg) + require.ErrorIs(t, err, sdkerrors.ErrInvalidAddress) }) - t.Run("should fail if not authorized", func(t *testing.T) { - k, ctx, _, _ := keepertest.CrosschainKeeperWithMocks(t, keepertest.CrosschainMockOptions{ + t.Run("should fail if invalid spl address", func(t *testing.T) { + k, ctx, _, zk := keepertest.CrosschainKeeperWithMocks(t, keepertest.CrosschainMockOptions{ UseAuthorityMock: true, }) @@ -102,22 +177,26 @@ func TestKeeper_WhitelistERC20(t *testing.T) { admin := sample.AccAddress() authorityMock := keepertest.GetCrosschainAuthorityMock(t, k) + chainID := getValidSolanaChainID() + setSupportedChain(ctx, zk, chainID) + msg := types.MsgWhitelistERC20{ Creator: admin, - Erc20Address: sample.EthAddress().Hex(), - ChainId: getValidEthChainID(), + Erc20Address: "invalid", + ChainId: chainID, Name: "foo", Symbol: "FOO", Decimals: 18, GasLimit: 100000, } - keepertest.MockCheckAuthorization(&authorityMock.Mock, &msg, authoritytypes.ErrUnauthorized) + keepertest.MockCheckAuthorization(&authorityMock.Mock, &msg, nil) + _, err := msgServer.WhitelistERC20(ctx, &msg) - require.ErrorIs(t, err, authoritytypes.ErrUnauthorized) + require.ErrorIs(t, err, sdkerrors.ErrInvalidAddress) }) - t.Run("should fail if invalid erc20 address", func(t *testing.T) { - k, ctx, _, _ := keepertest.CrosschainKeeperWithMocks(t, keepertest.CrosschainMockOptions{ + t.Run("should fail if whitelisting not supported for chain", func(t *testing.T) { + k, ctx, _, zk := keepertest.CrosschainKeeperWithMocks(t, keepertest.CrosschainMockOptions{ UseAuthorityMock: true, }) @@ -127,10 +206,13 @@ func TestKeeper_WhitelistERC20(t *testing.T) { admin := sample.AccAddress() authorityMock := keepertest.GetCrosschainAuthorityMock(t, k) + chainID := getValidBtcChainID() + setSupportedChain(ctx, zk, chainID) + msg := types.MsgWhitelistERC20{ Creator: admin, Erc20Address: "invalid", - ChainId: getValidEthChainID(), + ChainId: chainID, Name: "foo", Symbol: "FOO", Decimals: 18, @@ -139,7 +221,7 @@ func TestKeeper_WhitelistERC20(t *testing.T) { keepertest.MockCheckAuthorization(&authorityMock.Mock, &msg, nil) _, err := msgServer.WhitelistERC20(ctx, &msg) - require.ErrorIs(t, err, sdkerrors.ErrInvalidAddress) + require.ErrorIs(t, err, sdkerrors.ErrInvalidChainID) }) t.Run("should fail if foreign coin already exists for the asset", func(t *testing.T) { diff --git a/x/crosschain/types/message_whitelist_erc20.go b/x/crosschain/types/message_whitelist_erc20.go index 3267581662..d27492d7a5 100644 --- a/x/crosschain/types/message_whitelist_erc20.go +++ b/x/crosschain/types/message_whitelist_erc20.go @@ -4,7 +4,6 @@ import ( cosmoserrors "cosmossdk.io/errors" sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" - ethcommon "github.com/ethereum/go-ethereum/common" "github.com/zeta-chain/node/x/fungible/types" ) @@ -53,9 +52,8 @@ func (msg *MsgWhitelistERC20) ValidateBasic() error { if err != nil { return cosmoserrors.Wrapf(sdkerrors.ErrInvalidAddress, "invalid creator address (%s)", err) } - // check if the system contract address is valid - if ethcommon.HexToAddress(msg.Erc20Address) == (ethcommon.Address{}) { - return cosmoserrors.Wrapf(sdkerrors.ErrInvalidAddress, "invalid ERC20 contract address (%s)", msg.Erc20Address) + if msg.Erc20Address == "" { + return cosmoserrors.Wrapf(sdkerrors.ErrInvalidAddress, "empty asset address") } if msg.Decimals > 128 { return cosmoserrors.Wrapf(types.ErrInvalidDecimals, "invalid decimals (%d)", msg.Decimals) diff --git a/x/crosschain/types/message_whitelist_erc20_test.go b/x/crosschain/types/message_whitelist_erc20_test.go index 8219140fd9..d5bf845272 100644 --- a/x/crosschain/types/message_whitelist_erc20_test.go +++ b/x/crosschain/types/message_whitelist_erc20_test.go @@ -32,10 +32,10 @@ func TestMsgWhitelistERC20_ValidateBasic(t *testing.T) { error: true, }, { - name: "invalid erc20", + name: "invalid asset", msg: types.NewMsgWhitelistERC20( sample.AccAddress(), - "0x0", + "", 1, "name", "symbol", diff --git a/x/crosschain/types/tx.pb.go b/x/crosschain/types/tx.pb.go index ffe5c8cec7..52facc862d 100644 --- a/x/crosschain/types/tx.pb.go +++ b/x/crosschain/types/tx.pb.go @@ -337,6 +337,7 @@ func (m *MsgAddInboundTrackerResponse) XXX_DiscardUnknown() { var xxx_messageInfo_MsgAddInboundTrackerResponse proto.InternalMessageInfo +// TODO: https://github.com/zeta-chain/node/issues/3083 type MsgWhitelistERC20 struct { Creator string `protobuf:"bytes,1,opt,name=creator,proto3" json:"creator,omitempty"` Erc20Address string `protobuf:"bytes,2,opt,name=erc20_address,json=erc20Address,proto3" json:"erc20_address,omitempty"` diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go index 1441150ada..4c93d95470 100644 --- a/zetaclient/chains/solana/observer/inbound.go +++ b/zetaclient/chains/solana/observer/inbound.go @@ -281,7 +281,7 @@ func (ob *Observer) ParseInboundAsDeposit( } // check if the instruction is a deposit or not - if inst.Discriminator != solanacontracts.DiscriminatorDeposit() { + if inst.Discriminator != solanacontracts.DiscriminatorDeposit { return nil, nil } diff --git a/zetaclient/chains/solana/observer/outbound.go b/zetaclient/chains/solana/observer/outbound.go index e185b1a27d..60bd70bec7 100644 --- a/zetaclient/chains/solana/observer/outbound.go +++ b/zetaclient/chains/solana/observer/outbound.go @@ -157,6 +157,7 @@ func (ob *Observer) VoteOutboundIfConfirmed(ctx context.Context, cctx *crosschai // the amount and status of the outbound outboundAmount := new(big.Int).SetUint64(inst.TokenAmount()) + // status was already verified as successful in CheckFinalizedTx outboundStatus := chains.ReceiveStatus_success @@ -295,6 +296,7 @@ func (ob *Observer) CheckFinalizedTx( logger.Error().Err(err).Msg("ParseGatewayInstruction error") return nil, false } + txNonce := inst.GatewayNonce() // recover ECDSA signer from instruction @@ -352,6 +354,8 @@ func ParseGatewayInstruction( switch coinType { case coin.CoinType_Gas: return contracts.ParseInstructionWithdraw(instruction) + case coin.CoinType_Cmd: + return contracts.ParseInstructionWhitelist(instruction) default: return nil, fmt.Errorf("unsupported outbound coin type %s", coinType) } diff --git a/zetaclient/chains/solana/observer/outbound_test.go b/zetaclient/chains/solana/observer/outbound_test.go index 5cb2b80a5c..73af8da573 100644 --- a/zetaclient/chains/solana/observer/outbound_test.go +++ b/zetaclient/chains/solana/observer/outbound_test.go @@ -35,6 +35,9 @@ const ( // tssAddressTest is the TSS address for testing tssAddressTest = "0x05C7dBdd1954D59c9afaB848dA7d8DD3F35e69Cd" + + // whitelistTxTest is local devnet tx result for testing + whitelistTxTest = "phM9bESbiqojmpkkUxgjed8EABkxvPGNau9q31B8Yk1sXUtsxJvd6G9VbZZQPsEyn6RiTH4YBtqJ89omqfbbNNY" ) // createTestObserver creates a test observer for testing @@ -294,3 +297,63 @@ func Test_ParseInstructionWithdraw(t *testing.T) { require.Nil(t, inst) }) } + +func Test_ParseInstructionWhitelist(t *testing.T) { + // the test chain and transaction hash + chain := chains.SolanaDevnet + txHash := whitelistTxTest + txAmount := uint64(0) + + t.Run("should parse instruction whitelist", func(t *testing.T) { + // tss address used in local devnet + tssAddress := "0x7E8c7bAcd3c6220DDC35A4EA1141BE14F2e1dFEB" + // load and unmarshal archived transaction + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + instruction := tx.Message.Instructions[0] + inst, err := contracts.ParseInstructionWhitelist(instruction) + require.NoError(t, err) + + // check sender, nonce and amount + sender, err := inst.Signer() + require.NoError(t, err) + require.Equal(t, tssAddress, sender.String()) + require.EqualValues(t, inst.GatewayNonce(), 3) + require.EqualValues(t, inst.TokenAmount(), txAmount) + }) + + t.Run("should return error on invalid instruction data", func(t *testing.T) { + // load and unmarshal archived transaction + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + txFake, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // set invalid instruction data + instruction := txFake.Message.Instructions[0] + instruction.Data = []byte("invalid instruction data") + + inst, err := contracts.ParseInstructionWhitelist(instruction) + require.ErrorContains(t, err, "error deserializing instruction") + require.Nil(t, inst) + }) + + t.Run("should return error on discriminator mismatch", func(t *testing.T) { + // load and unmarshal archived transaction + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + txFake, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // overwrite discriminator (first 8 bytes) + instruction := txFake.Message.Instructions[0] + fakeDiscriminator := "b712469c946da12100980d0000000000" + fakeDiscriminatorBytes, err := hex.DecodeString(fakeDiscriminator) + require.NoError(t, err) + copy(instruction.Data, fakeDiscriminatorBytes) + + inst, err := contracts.ParseInstructionWhitelist(instruction) + require.ErrorContains(t, err, "not a whitelist_spl_mint instruction") + require.Nil(t, inst) + }) +} diff --git a/zetaclient/chains/solana/signer/signer.go b/zetaclient/chains/solana/signer/signer.go index 0d762d9006..8e180f8c7f 100644 --- a/zetaclient/chains/solana/signer/signer.go +++ b/zetaclient/chains/solana/signer/signer.go @@ -2,11 +2,14 @@ package signer import ( "context" + "fmt" + "strings" "cosmossdk.io/errors" ethcommon "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" + "github.com/rs/zerolog" "github.com/zeta-chain/node/pkg/chains" "github.com/zeta-chain/node/pkg/coin" @@ -121,12 +124,71 @@ func (signer *Signer) TryProcessOutbound( chainID := signer.Chain().ChainId nonce := params.TssNonce coinType := cctx.InboundParams.CoinType - if coinType != coin.CoinType_Gas { + + // skip relaying the transaction if this signer hasn't set the relayer key + if !signer.HasRelayerKey() { + logger.Warn().Msgf("TryProcessOutbound: no relayer key configured") + return + } + + var tx *solana.Transaction + + switch coinType { + case coin.CoinType_Cmd: + whitelistTx, err := signer.prepareWhitelistTx(ctx, cctx, height) + if err != nil { + logger.Error().Err(err).Msgf("TryProcessOutbound: Fail to sign whitelist outbound") + return + } + + tx = whitelistTx + + case coin.CoinType_Gas: + withdrawTx, err := signer.prepareWithdrawTx(ctx, cctx, height, logger) + if err != nil { + logger.Error().Err(err).Msgf("TryProcessOutbound: Fail to sign withdraw outbound") + return + } + + tx = withdrawTx + default: + logger.Error(). + Msgf("TryProcessOutbound: can only send SOL to the Solana network") + return + } + + // set relayer balance metrics + signer.SetRelayerBalanceMetrics(ctx) + + // broadcast the signed tx to the Solana network with preflight check + txSig, err := signer.client.SendTransactionWithOpts( + ctx, + tx, + // Commitment "finalized" is too conservative for preflight check and + // it results in repeated broadcast attempts that only 1 will succeed. + // Commitment "processed" will simulate tx against more recent state + // thus fails faster once a tx is already broadcasted and processed by the cluster. + // This reduces the number of "failed" txs due to repeated broadcast attempts. + rpc.TransactionOpts{PreflightCommitment: rpc.CommitmentProcessed}, + ) + if err != nil { logger.Error(). - Msgf("TryProcessOutbound: can only send SOL to the Solana network for chain %d nonce %d", chainID, nonce) + Err(err). + Msgf("TryProcessOutbound: broadcast error") return } + // report the outbound to the outbound tracker + signer.reportToOutboundTracker(ctx, zetacoreClient, chainID, nonce, txSig, logger) +} + +func (signer *Signer) prepareWithdrawTx( + ctx context.Context, + cctx *types.CrossChainTx, + height uint64, + logger zerolog.Logger, +) (*solana.Transaction, error) { + params := cctx.GetCurrentOutboundParam() // compliance check cancelTx := compliance.IsCctxRestricted(cctx) if cancelTx { @@ -134,7 +196,7 @@ func (signer *Signer) TryProcessOutbound( logger, signer.Logger().Compliance, true, - chainID, + signer.Chain().ChainId, cctx.Index, cctx.InboundParams.Sender, params.Receiver, @@ -143,48 +205,55 @@ func (signer *Signer) TryProcessOutbound( } // sign gateway withdraw message by TSS - msg, err := signer.SignMsgWithdraw(ctx, params, height, cancelTx) + msg, err := signer.createAndSignMsgWithdraw(ctx, params, height, cancelTx) if err != nil { - logger.Error().Err(err).Msgf("TryProcessOutbound: SignMsgWithdraw error for chain %d nonce %d", chainID, nonce) - return + return nil, err } - // skip relaying the transaction if this signer hasn't set the relayer key - if !signer.HasRelayerKey() { - return + // sign the withdraw transaction by relayer key + tx, err := signer.signWithdrawTx(ctx, *msg) + if err != nil { + return nil, err } - // set relayer balance metrics - signer.SetRelayerBalanceMetrics(ctx) + return tx, nil +} - // sign the withdraw transaction by relayer key - tx, err := signer.SignWithdrawTx(ctx, *msg) +func (signer *Signer) prepareWhitelistTx( + ctx context.Context, + cctx *types.CrossChainTx, + height uint64, +) (*solana.Transaction, error) { + params := cctx.GetCurrentOutboundParam() + relayedMsg := strings.Split(cctx.RelayedMessage, ":") + if len(relayedMsg) != 2 { + return nil, fmt.Errorf("TryProcessOutbound: invalid relayed msg") + } + + pk, err := solana.PublicKeyFromBase58(relayedMsg[1]) if err != nil { - logger.Error().Err(err).Msgf("TryProcessOutbound: SignGasWithdraw error for chain %d nonce %d", chainID, nonce) - return + return nil, err } - // broadcast the signed tx to the Solana network with preflight check - txSig, err := signer.client.SendTransactionWithOpts( - ctx, - tx, - // Commitment "finalized" is too conservative for preflight check and - // it results in repeated broadcast attempts that only 1 will succeed. - // Commitment "processed" will simulate tx against more recent state - // thus fails faster once a tx is already broadcasted and processed by the cluster. - // This reduces the number of "failed" txs due to repeated broadcast attempts. - rpc.TransactionOpts{PreflightCommitment: rpc.CommitmentProcessed}, - ) + seed := [][]byte{[]byte("whitelist"), pk.Bytes()} + whitelistEntryPDA, _, err := solana.FindProgramAddress(seed, signer.gatewayID) if err != nil { - signer.Logger(). - Std.Warn(). - Err(err). - Msgf("TryProcessOutbound: broadcast error for chain %d nonce %d", chainID, nonce) - return + return nil, err } - // report the outbound to the outbound tracker - signer.reportToOutboundTracker(ctx, zetacoreClient, chainID, nonce, txSig, logger) + // sign gateway whitelist message by TSS + msg, err := signer.createAndSignMsgWhitelist(ctx, params, height, pk, whitelistEntryPDA) + if err != nil { + return nil, err + } + + // sign the whitelist transaction by relayer key + tx, err := signer.signWhitelistTx(ctx, msg) + if err != nil { + return nil, err + } + + return tx, nil } // SetGatewayAddress sets the gateway address diff --git a/zetaclient/chains/solana/signer/whitelist.go b/zetaclient/chains/solana/signer/whitelist.go new file mode 100644 index 0000000000..73ee769039 --- /dev/null +++ b/zetaclient/chains/solana/signer/whitelist.go @@ -0,0 +1,125 @@ +package signer + +import ( + "context" + + "cosmossdk.io/errors" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" + + contracts "github.com/zeta-chain/node/pkg/contracts/solana" + "github.com/zeta-chain/node/x/crosschain/types" +) + +// createAndSignMsgWhitelist creates and signs a whitelist message (for gateway whitelist_spl_mint instruction) with TSS. +func (signer *Signer) createAndSignMsgWhitelist( + ctx context.Context, + params *types.OutboundParams, + height uint64, + whitelistCandidate solana.PublicKey, + whitelistEntry solana.PublicKey, +) (*contracts.MsgWhitelist, error) { + chain := signer.Chain() + // #nosec G115 always positive + chainID := uint64(signer.Chain().ChainId) + nonce := params.TssNonce + + // prepare whitelist msg and compute hash + msg := contracts.NewMsgWhitelist(whitelistCandidate, whitelistEntry, chainID, nonce) + msgHash := msg.Hash() + + // sign the message with TSS to get an ECDSA signature. + // the produced signature is in the [R || S || V] format where V is 0 or 1. + signature, err := signer.TSS().Sign(ctx, msgHash[:], height, nonce, chain.ChainId, "") + if err != nil { + return nil, errors.Wrap(err, "Key-sign failed") + } + signer.Logger().Std.Info().Msgf("Key-sign succeed for chain %d nonce %d", chainID, nonce) + + // attach the signature and return + return msg.SetSignature(signature), nil +} + +// signWhitelistTx wraps the whitelist 'msg' into a Solana transaction and signs it with the relayer key. +func (signer *Signer) signWhitelistTx(ctx context.Context, msg *contracts.MsgWhitelist) (*solana.Transaction, error) { + // create whitelist_spl_mint instruction with program call data + var err error + var inst solana.GenericInstruction + inst.DataBytes, err = borsh.Serialize(contracts.WhitelistInstructionParams{ + Discriminator: contracts.DiscriminatorWhitelistSplMint, + Signature: msg.SigRS(), + RecoveryID: msg.SigV(), + MessageHash: msg.Hash(), + Nonce: msg.Nonce(), + }) + if err != nil { + return nil, errors.Wrap(err, "cannot serialize whitelist_spl_mint instruction") + } + + // attach required accounts to the instruction + privkey := signer.relayerKey + attachWhitelistAccounts( + &inst, + privkey.PublicKey(), + signer.pda, + msg.WhitelistCandidate(), + msg.WhitelistEntry(), + signer.gatewayID, + ) + + // get a recent blockhash + recent, err := signer.client.GetLatestBlockhash(ctx, rpc.CommitmentFinalized) + if err != nil { + return nil, errors.Wrap(err, "GetLatestBlockhash error") + } + + // create a transaction that wraps the instruction + tx, err := solana.NewTransaction( + []solana.Instruction{ + // TODO: outbound now uses 5K lamports as the fixed fee, we could explore priority fee and compute budget + // https://github.com/zeta-chain/node/issues/2599 + // programs.ComputeBudgetSetComputeUnitLimit(computeUnitLimit), + // programs.ComputeBudgetSetComputeUnitPrice(computeUnitPrice), + &inst}, + recent.Value.Blockhash, + solana.TransactionPayer(privkey.PublicKey()), + ) + if err != nil { + return nil, errors.Wrap(err, "NewTransaction error") + } + + // relayer signs the transaction + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(privkey.PublicKey()) { + return privkey + } + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "signer unable to sign transaction") + } + + return tx, nil +} + +// attachWhitelistAccounts attaches the required accounts for the gateway whitelist instruction. +func attachWhitelistAccounts( + inst *solana.GenericInstruction, + signer solana.PublicKey, + pda solana.PublicKey, + whitelistCandidate solana.PublicKey, + whitelistEntry solana.PublicKey, + gatewayID solana.PublicKey, +) { + // attach required accounts to the instruction + var accountSlice []*solana.AccountMeta + accountSlice = append(accountSlice, solana.Meta(whitelistEntry).WRITE()) + accountSlice = append(accountSlice, solana.Meta(whitelistCandidate)) + accountSlice = append(accountSlice, solana.Meta(pda).WRITE()) + accountSlice = append(accountSlice, solana.Meta(signer).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) + inst.ProgID = gatewayID + + inst.AccountValues = accountSlice +} diff --git a/zetaclient/chains/solana/signer/withdraw.go b/zetaclient/chains/solana/signer/withdraw.go index 58411b43bb..51f4cceeea 100644 --- a/zetaclient/chains/solana/signer/withdraw.go +++ b/zetaclient/chains/solana/signer/withdraw.go @@ -13,8 +13,8 @@ import ( "github.com/zeta-chain/node/x/crosschain/types" ) -// SignMsgWithdraw signs a withdraw message (for gateway withdraw/withdraw_spl instruction) with TSS. -func (signer *Signer) SignMsgWithdraw( +// createAndSignMsgWithdraw creates and signs a withdraw message (for gateway withdraw/withdraw_spl instruction) with TSS. +func (signer *Signer) createAndSignMsgWithdraw( ctx context.Context, params *types.OutboundParams, height uint64, @@ -53,13 +53,13 @@ func (signer *Signer) SignMsgWithdraw( return msg.SetSignature(signature), nil } -// SignWithdrawTx wraps the withdraw 'msg' into a Solana transaction and signs it with the relayer key. -func (signer *Signer) SignWithdrawTx(ctx context.Context, msg contracts.MsgWithdraw) (*solana.Transaction, error) { +// signWithdrawTx wraps the withdraw 'msg' into a Solana transaction and signs it with the relayer key. +func (signer *Signer) signWithdrawTx(ctx context.Context, msg contracts.MsgWithdraw) (*solana.Transaction, error) { // create withdraw instruction with program call data var err error var inst solana.GenericInstruction inst.DataBytes, err = borsh.Serialize(contracts.WithdrawInstructionParams{ - Discriminator: contracts.DiscriminatorWithdraw(), + Discriminator: contracts.DiscriminatorWithdraw, Amount: msg.Amount(), Signature: msg.SigRS(), RecoveryID: msg.SigV(), diff --git a/zetaclient/testdata/solana/chain_901_inbound_tx_result_MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j.json b/zetaclient/testdata/solana/chain_901_inbound_tx_result_MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j.json index 210d639ead..cf7edb3b81 100644 --- a/zetaclient/testdata/solana/chain_901_inbound_tx_result_MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j.json +++ b/zetaclient/testdata/solana/chain_901_inbound_tx_result_MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j.json @@ -8,9 +8,9 @@ "message": { "accountKeys": [ "AS48jKNQsDGkEdDvfwu1QpqjtqbCadrAq9nGXjFmdX3Z", - "2f9SLuUNb7TNeM6gzBwT4ZjbL5ZyKzzHg1Ce9yiquEjj", + "9dcAyYG4bawApZocwZSyJBi9Mynf5EuKAJfifXdfkqik", "11111111111111111111111111111111", - "ZETAjseVjuFsxdRxo6MmTCvqFwb3ZHUx56Co3vCmGis" + "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" ], "header": { "numRequiredSignatures": 1, @@ -47,13 +47,13 @@ "preTokenBalances": [], "postTokenBalances": [], "logMessages": [ - "Program ZETAjseVjuFsxdRxo6MmTCvqFwb3ZHUx56Co3vCmGis invoke [1]", + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d invoke [1]", "Program log: Instruction: Deposit", "Program 11111111111111111111111111111111 invoke [2]", "Program 11111111111111111111111111111111 success", "Program log: AS48jKNQsDGkEdDvfwu1QpqjtqbCadrAq9nGXjFmdX3Z deposits 100000 lamports to PDA", - "Program ZETAjseVjuFsxdRxo6MmTCvqFwb3ZHUx56Co3vCmGis consumed 17006 of 200000 compute units", - "Program ZETAjseVjuFsxdRxo6MmTCvqFwb3ZHUx56Co3vCmGis success" + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d consumed 17006 of 200000 compute units", + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d success" ], "status": { "Ok": null }, "rewards": [], diff --git a/zetaclient/testdata/solana/chain_901_outbound_tx_result_phM9bESbiqojmpkkUxgjed8EABkxvPGNau9q31B8Yk1sXUtsxJvd6G9VbZZQPsEyn6RiTH4YBtqJ89omqfbbNNY.json b/zetaclient/testdata/solana/chain_901_outbound_tx_result_phM9bESbiqojmpkkUxgjed8EABkxvPGNau9q31B8Yk1sXUtsxJvd6G9VbZZQPsEyn6RiTH4YBtqJ89omqfbbNNY.json new file mode 100644 index 0000000000..c488facac3 --- /dev/null +++ b/zetaclient/testdata/solana/chain_901_outbound_tx_result_phM9bESbiqojmpkkUxgjed8EABkxvPGNau9q31B8Yk1sXUtsxJvd6G9VbZZQPsEyn6RiTH4YBtqJ89omqfbbNNY.json @@ -0,0 +1,76 @@ +{ + "slot": 1109, + "blockTime": 1730732052, + "transaction": { + "signatures": [ + "phM9bESbiqojmpkkUxgjed8EABkxvPGNau9q31B8Yk1sXUtsxJvd6G9VbZZQPsEyn6RiTH4YBtqJ89omqfbbNNY" + ], + "message": { + "accountKeys": [ + "2qBVcNBZCubcnSR3NyCnFjCfkCVUB3G7ECPoaW5rxVjx", + "3eXQYW8nC9142kJUHRgZ9RggJaMgpAEtnZPrwPT7CdxH", + "9dcAyYG4bawApZocwZSyJBi9Mynf5EuKAJfifXdfkqik", + "GNQPa92uBDem5ZFH16TkmFwN5EN8LAzkqeRrxsxZt4eD", + "11111111111111111111111111111111", + "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" + ], + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 3 + }, + "recentBlockhash": "94KjHcf2zDN6VtbFnVU3vkEqJv4d5jWCzBTvg6PtPkdB", + "instructions": [ + { + "programIdIndex": 5, + "accounts": [ + 1, + 3, + 2, + 0, + 4 + ], + "data": "SDhLNtfumZy7dZ96HRWDWWC9NHtnc54NUDt3XAAY8msc42QtH8rF3nYfFcmFjX64KsoMSYNtkWQTv4iVU3Ly36a5ff3nEU5aPbgeBGAPsMbnEiX1bz51dHoyMJjpKxvWJbmCxEG6Z8tA1Tk4EcY39DTDRH" + } + ] + } + }, + "meta": { + "err": null, + "fee": 5000, + "preBalances": [ + 99999985000, + 14947680, + 1461600, + 1, + 1141440 + ], + "postBalances": [ + 99999033440, + 946560, + 14947680, + 1461600, + 1, + 1141440 + ], + "innerInstructions": [], + "preTokenBalances": [], + "postTokenBalances": [], + "logMessages": [ + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d invoke [1]", + "Program log: Instruction: WhitelistSplMint Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program log: recovered address [126, 140, 123, 172, 211, 198, 34, 13, 220, 53, 164, 234, 17, 65, 190, 20, 242, 225, 223, 235]", + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d consumed 46731 of 200000 compute units", + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d success" + ], + "status": { "Ok": null }, + "rewards": [], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "computeUnitsConsumed": 274896977280 + }, + "version": 0 +} \ No newline at end of file diff --git a/zetaclient/testutils/constant.go b/zetaclient/testutils/constant.go index e9b8b53563..f776c7019f 100644 --- a/zetaclient/testutils/constant.go +++ b/zetaclient/testutils/constant.go @@ -42,7 +42,9 @@ const ( // GatewayAddresses contains constants gateway addresses for testing var GatewayAddresses = map[int64]string{ // Gateway address on Solana devnet - chains.SolanaDevnet.ChainId: "ZETAjseVjuFsxdRxo6MmTCvqFwb3ZHUx56Co3vCmGis", + // NOTE: currently different deployer key pair is used for development compared to live networks + // as live networks key pair is sensitive information at this point, can be unified once we have deployments completed + chains.SolanaDevnet.ChainId: "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d", } // ConnectorAddresses contains constants ERC20 connector addresses for testing From 7474ab5e649a953803fcf60373a0ed99f53bef51 Mon Sep 17 00:00:00 2001 From: Charlie Chen <34498985+ws4charlie@users.noreply.github.com> Date: Mon, 4 Nov 2024 10:07:12 -0600 Subject: [PATCH 7/8] refactor: replace docker-based inscription builder sidecar with Golang implementation (#3082) * initiate bitcoin inscription builder using golang * use the tokenizer in the upgraded btcd package * add changelog entry * fix CI unit test failure * replace 80 with btcd defined constant; update comments * replace hardcoded number with btcd defined constant * fix coderabbit comment and tidy go mod * remove randomness in E2E fee estimation --- changelog.md | 1 + cmd/zetae2e/local/local.go | 1 + contrib/localnet/docker-compose.yml | 12 - e2e/e2etests/e2etests.go | 18 +- ...oin_std_memo_inscribed_deposit_and_call.go | 64 +++++ .../test_extract_bitcoin_inscription_memo.go | 57 ---- e2e/runner/accounting.go | 2 +- e2e/runner/bitcoin.go | 54 ++-- e2e/runner/bitcoin_inscription.go | 259 +++++++++++++----- go.mod | 4 +- go.sum | 2 + zetaclient/chains/bitcoin/observer/witness.go | 3 +- zetaclient/chains/bitcoin/tokenizer.go | 162 ----------- zetaclient/chains/bitcoin/tx_script.go | 6 +- zetaclient/chains/bitcoin/tx_script_test.go | 4 +- 15 files changed, 304 insertions(+), 345 deletions(-) create mode 100644 e2e/e2etests/test_bitcoin_std_memo_inscribed_deposit_and_call.go delete mode 100644 e2e/e2etests/test_extract_bitcoin_inscription_memo.go delete mode 100644 zetaclient/chains/bitcoin/tokenizer.go diff --git a/changelog.md b/changelog.md index e29e6d26af..4ba4b6fcf7 100644 --- a/changelog.md +++ b/changelog.md @@ -42,6 +42,7 @@ * [2899](https://github.com/zeta-chain/node/pull/2899) - remove btc deposit fee v1 and improve unit tests * [2952](https://github.com/zeta-chain/node/pull/2952) - add error_message to cctx.status * [3039](https://github.com/zeta-chain/node/pull/3039) - use `btcd` native APIs to handle Bitcoin Taproot address +* [3082](https://github.com/zeta-chain/node/pull/3082) - replace docker-based bitcoin sidecar inscription build with Golang implementation ### Tests diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index e1d579cb0a..986e9e4689 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -310,6 +310,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestBitcoinStdMemoDepositAndCallName, e2etests.TestBitcoinStdMemoDepositAndCallRevertName, e2etests.TestBitcoinStdMemoDepositAndCallRevertOtherAddressName, + e2etests.TestBitcoinStdMemoInscribedDepositAndCallName, e2etests.TestBitcoinWithdrawSegWitName, e2etests.TestBitcoinWithdrawInvalidAddressName, e2etests.TestZetaWithdrawBTCRevertName, diff --git a/contrib/localnet/docker-compose.yml b/contrib/localnet/docker-compose.yml index eb6453052f..4f36583d91 100644 --- a/contrib/localnet/docker-compose.yml +++ b/contrib/localnet/docker-compose.yml @@ -227,18 +227,6 @@ services: -rpcauth=smoketest:63acf9b8dccecce914d85ff8c044b78b$$5892f9bbc84f4364e79f0970039f88bdd823f168d4acc76099ab97b14a766a99 -txindex=1 - bitcoin-node-sidecar: - image: ghcr.io/zeta-chain/node-localnet-bitcoin-sidecar:e0205d7 - container_name: bitcoin-node-sidecar - hostname: bitcoin-node-sidecar - networks: - mynetwork: - ipv4_address: 172.20.0.111 - environment: - - PORT=8000 - ports: - - "8000:8000" - solana: image: solana-local:latest container_name: solana diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index 5f6adf9b87..9f86120bb0 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -81,6 +81,7 @@ const ( TestBitcoinStdMemoDepositAndCallName = "bitcoin_std_memo_deposit_and_call" TestBitcoinStdMemoDepositAndCallRevertName = "bitcoin_std_memo_deposit_and_call_revert" TestBitcoinStdMemoDepositAndCallRevertOtherAddressName = "bitcoin_std_memo_deposit_and_call_revert_other_address" + TestBitcoinStdMemoInscribedDepositAndCallName = "bitcoin_std_memo_inscribed_deposit_and_call" TestBitcoinWithdrawSegWitName = "bitcoin_withdraw_segwit" TestBitcoinWithdrawTaprootName = "bitcoin_withdraw_taproot" TestBitcoinWithdrawMultipleName = "bitcoin_withdraw_multiple" @@ -89,7 +90,6 @@ const ( TestBitcoinWithdrawP2SHName = "bitcoin_withdraw_p2sh" TestBitcoinWithdrawInvalidAddressName = "bitcoin_withdraw_invalid" TestBitcoinWithdrawRestrictedName = "bitcoin_withdraw_restricted" - TestExtractBitcoinInscriptionMemoName = "bitcoin_memo_from_inscription" /* Application tests @@ -497,13 +497,6 @@ var AllE2ETests = []runner.E2ETest{ }, TestBitcoinDonation, ), - runner.NewE2ETest( - TestExtractBitcoinInscriptionMemoName, - "extract memo from BTC inscription", []runner.ArgDefinition{ - {Description: "amount in btc", DefaultValue: "0.1"}, - }, - TestExtractBitcoinInscriptionMemo, - ), runner.NewE2ETest( TestBitcoinDepositName, "deposit Bitcoin into ZEVM", @@ -559,6 +552,15 @@ var AllE2ETests = []runner.E2ETest{ }, TestBitcoinStdMemoDepositAndCallRevertOtherAddress, ), + runner.NewE2ETest( + TestBitcoinStdMemoInscribedDepositAndCallName, + "deposit Bitcoin into ZEVM and call a contract with inscribed standard memo", + []runner.ArgDefinition{ + {Description: "amount in btc", DefaultValue: "0.1"}, + {Description: "fee rate", DefaultValue: "10"}, + }, + TestBitcoinStdMemoInscribedDepositAndCall, + ), runner.NewE2ETest( TestBitcoinWithdrawSegWitName, "withdraw BTC from ZEVM to a SegWit address", diff --git a/e2e/e2etests/test_bitcoin_std_memo_inscribed_deposit_and_call.go b/e2e/e2etests/test_bitcoin_std_memo_inscribed_deposit_and_call.go new file mode 100644 index 0000000000..c9a5d7af31 --- /dev/null +++ b/e2e/e2etests/test_bitcoin_std_memo_inscribed_deposit_and_call.go @@ -0,0 +1,64 @@ +package e2etests + +import ( + "math/big" + + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/utils" + "github.com/zeta-chain/node/pkg/memo" + testcontract "github.com/zeta-chain/node/testutil/contracts" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + zetabitcoin "github.com/zeta-chain/node/zetaclient/chains/bitcoin" +) + +func TestBitcoinStdMemoInscribedDepositAndCall(r *runner.E2ERunner, args []string) { + // ARRANGE + // Given BTC address + r.SetBtcAddress(r.Name, false) + + // Start mining blocks + stop := r.MineBlocksIfLocalBitcoin() + defer stop() + + // Given amount to send and fee rate + require.Len(r, args, 2) + amount := parseFloat(r, args[0]) + feeRate := parseInt(r, args[1]) + + // deploy an example contract in ZEVM + contractAddr, _, contract, err := testcontract.DeployExample(r.ZEVMAuth, r.ZEVMClient) + require.NoError(r, err) + + // create a standard memo > 80 bytes + memo := &memo.InboundMemo{ + Header: memo.Header{ + Version: 0, + EncodingFmt: memo.EncodingFmtCompactShort, + OpCode: memo.OpCodeDepositAndCall, + }, + FieldsV0: memo.FieldsV0{ + Receiver: contractAddr, + Payload: []byte("for use case that passes a large memo > 80 bytes, inscripting the memo is the way to go"), + }, + } + memoBytes, err := memo.EncodeToBytes() + require.NoError(r, err) + + // ACT + // Send BTC to TSS address with memo + txHash, depositAmount := r.InscribeToTSSFromDeployerWithMemo(amount, memoBytes, int64(feeRate)) + + // ASSERT + // wait for the cctx to be mined + cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, txHash.String(), r.CctxClient, r.Logger, r.CctxTimeout) + r.Logger.CCTX(*cctx, "bitcoin_std_memo_inscribed_deposit_and_call") + utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) + + // check if example contract has been called, 'bar' value should be set to correct amount + depositFeeSats, err := zetabitcoin.GetSatoshis(zetabitcoin.DefaultDepositorFee) + require.NoError(r, err) + receiveAmount := depositAmount - depositFeeSats + utils.MustHaveCalledExampleContract(r, contract, big.NewInt(receiveAmount)) +} diff --git a/e2e/e2etests/test_extract_bitcoin_inscription_memo.go b/e2e/e2etests/test_extract_bitcoin_inscription_memo.go deleted file mode 100644 index eedc24b577..0000000000 --- a/e2e/e2etests/test_extract_bitcoin_inscription_memo.go +++ /dev/null @@ -1,57 +0,0 @@ -package e2etests - -import ( - "encoding/hex" - - "github.com/btcsuite/btcd/btcjson" - "github.com/rs/zerolog/log" - "github.com/stretchr/testify/require" - - "github.com/zeta-chain/node/e2e/runner" - btcobserver "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" -) - -func TestExtractBitcoinInscriptionMemo(r *runner.E2ERunner, args []string) { - r.SetBtcAddress(r.Name, false) - - // obtain some initial fund - stop := r.MineBlocksIfLocalBitcoin() - defer stop() - r.Logger.Info("Mined blocks") - - // list deployer utxos - utxos, err := r.ListDeployerUTXOs() - require.NoError(r, err) - - amount := parseFloat(r, args[0]) - // this is just some random test memo for inscription - memo, err := hex.DecodeString( - "72f080c854647755d0d9e6f6821f6931f855b9acffd53d87433395672756d58822fd143360762109ab898626556b1c3b8d3096d2361f1297df4a41c1b429471a9aa2fc9be5f27c13b3863d6ac269e4b587d8389f8fd9649859935b0d48dea88cdb40f20c", - ) - require.NoError(r, err) - - txid := r.InscribeToTSSFromDeployerWithMemo(amount, utxos, memo) - - _, err = r.GenerateToAddressIfLocalBitcoin(6, r.BTCDeployerAddress) - require.NoError(r, err) - - rawtx, err := r.BtcRPCClient.GetRawTransactionVerbose(txid) - require.NoError(r, err) - r.Logger.Info("obtained reveal txn id %s", txid) - - dummyCoinbaseTxn := rawtx - events, err := btcobserver.FilterAndParseIncomingTx( - r.BtcRPCClient, - []btcjson.TxRawResult{*dummyCoinbaseTxn, *rawtx}, - 0, - r.BTCTSSAddress.String(), - log.Logger, - r.BitcoinParams, - ) - require.NoError(r, err) - - require.Equal(r, 1, len(events)) - event := events[0] - - require.Equal(r, event.MemoBytes, memo) -} diff --git a/e2e/runner/accounting.go b/e2e/runner/accounting.go index 92e120b7fd..c47883c3f0 100644 --- a/e2e/runner/accounting.go +++ b/e2e/runner/accounting.go @@ -122,7 +122,7 @@ func (r *E2ERunner) CheckBtcTSSBalance() error { ) } // #nosec G115 test - always in range - r.Logger.Print( + r.Logger.Info( "BTC: Balance (%d) >= ZRC20 TotalSupply (%d)", int64(tssTotalBalance*1e8), zrc20Supply.Int64()-10000000, diff --git a/e2e/runner/bitcoin.go b/e2e/runner/bitcoin.go index 3d65589fa5..047e97139b 100644 --- a/e2e/runner/bitcoin.go +++ b/e2e/runner/bitcoin.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/hex" "fmt" - "net/http" "sort" "time" @@ -331,44 +330,47 @@ func (r *E2ERunner) sendToAddrFromDeployerWithMemo( // InscribeToTSSFromDeployerWithMemo creates an inscription that is sent to the tss address with the corresponding memo func (r *E2ERunner) InscribeToTSSFromDeployerWithMemo( amount float64, - inputUTXOs []btcjson.ListUnspentResult, memo []byte, -) *chainhash.Hash { - // TODO: replace builder with Go function to enable instructions - // https://github.com/zeta-chain/node/issues/2759 - builder := InscriptionBuilder{sidecarURL: "http://bitcoin-node-sidecar:8000", client: http.Client{}} - - address, err := builder.GenerateCommitAddress(memo) - require.NoError(r, err) - r.Logger.Info("received inscription commit address %s", address) - - receiver, err := chains.DecodeBtcAddress(address, r.GetBitcoinChainID()) + feeRate int64, +) (*chainhash.Hash, int64) { + // list deployer utxos + utxos, err := r.ListDeployerUTXOs() require.NoError(r, err) - txnHash, err := r.sendToAddrFromDeployerWithMemo(amount, receiver, inputUTXOs, []byte(constant.DonationMessage)) + // generate commit address + builder := NewTapscriptSpender(r.BitcoinParams) + receiver, err := builder.GenerateCommitAddress(memo) require.NoError(r, err) - r.Logger.Info("obtained inscription commit txn hash %s", txnHash.String()) + r.Logger.Info("received inscription commit address: %s", receiver) - // sendToAddrFromDeployerWithMemo makes sure index is 0 - outpointIdx := 0 - hexTx, err := builder.GenerateRevealTxn(r.BTCTSSAddress.String(), txnHash.String(), outpointIdx, amount) + // send funds to the commit address + commitTxHash, err := r.sendToAddrFromDeployerWithMemo(amount, receiver, utxos, nil) require.NoError(r, err) + r.Logger.Info("obtained inscription commit txn hash: %s", commitTxHash.String()) - // Decode the hex string into raw bytes - rawTxBytes, err := hex.DecodeString(hexTx) + // parameters to build the reveal transaction + commitOutputIdx := uint32(0) + commitAmount, err := zetabitcoin.GetSatoshis(amount) require.NoError(r, err) - // Deserialize the raw bytes into a wire.MsgTx structure - msgTx := wire.NewMsgTx(wire.TxVersion) - err = msgTx.Deserialize(bytes.NewReader(rawTxBytes)) + // build the reveal transaction to spend above funds + revealTx, err := builder.BuildRevealTxn( + r.BTCTSSAddress, + wire.OutPoint{ + Hash: *commitTxHash, + Index: commitOutputIdx, + }, + commitAmount, + feeRate, + ) require.NoError(r, err) - r.Logger.Info("recovered inscription reveal txn %s", hexTx) - txid, err := r.BtcRPCClient.SendRawTransaction(msgTx, true) + // submit the reveal transaction + txid, err := r.BtcRPCClient.SendRawTransaction(revealTx, true) require.NoError(r, err) - r.Logger.Info("txid: %+v", txid) + r.Logger.Info("reveal txid: %s", txid.String()) - return txid + return txid, revealTx.TxOut[0].Value } // GetBitcoinChainID gets the bitcoin chain ID from the network params diff --git a/e2e/runner/bitcoin_inscription.go b/e2e/runner/bitcoin_inscription.go index 6f90068905..5ff237391a 100644 --- a/e2e/runner/bitcoin_inscription.go +++ b/e2e/runner/bitcoin_inscription.go @@ -1,119 +1,234 @@ package runner import ( - "bytes" - "encoding/hex" - "encoding/json" "fmt" - "io" - "net/http" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/mempool" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/pkg/errors" ) -type commitResponse struct { - Address string `json:"address"` -} +// TapscriptSpender is a utility struct that helps create Taproot address and reveal transaction +type TapscriptSpender struct { + // internalKey is a local-generated private key used for signing the Taproot script path. + internalKey *btcec.PrivateKey -type revealResponse struct { - RawHex string `json:"rawHex"` -} + // taprootOutputKey is the Taproot output key derived from the internal key and the merkle root. + // It is used to create Taproot addresses that can be funded. + taprootOutputKey *btcec.PublicKey + + // taprootOutputAddr is the Taproot address derived from the taprootOutputKey. + taprootOutputAddr *btcutil.AddressTaproot + + // tapLeaf represents the Taproot leaf node script (tapscript) that contains the embedded inscription data. + tapLeaf txscript.TapLeaf + + // ctrlBlockBytes contains the control block data required for spending the Taproot output via the script path. + // This includes the internal key and proof for the tapLeaf used to authenticate spending. + ctrlBlockBytes []byte -type revealRequest struct { - Txn string `json:"txn"` - Idx int `json:"idx"` - Amount int `json:"amount"` - FeeRate int `json:"feeRate"` - To string `json:"to"` + net *chaincfg.Params } -// InscriptionBuilder is a util struct that help create inscription commit and reveal transactions -type InscriptionBuilder struct { - sidecarURL string - client http.Client +// NewTapscriptSpender creates a new NewTapscriptSpender instance +func NewTapscriptSpender(net *chaincfg.Params) *TapscriptSpender { + return &TapscriptSpender{ + net: net, + } } -// GenerateCommitAddress generates a commit p2tr address that one can send funds to this address -func (r *InscriptionBuilder) GenerateCommitAddress(memo []byte) (string, error) { - // Create the payload - postData := map[string]string{ - "memo": hex.EncodeToString(memo), +// GenerateCommitAddress generates a Taproot commit address for the given receiver and payload +func (s *TapscriptSpender) GenerateCommitAddress(memo []byte) (*btcutil.AddressTaproot, error) { + // OP_RETURN is a better choice for memo <= 80 bytes + if len(memo) <= txscript.MaxDataCarrierSize { + return nil, fmt.Errorf("OP_RETURN is a better choice for memo <= 80 bytes") } - // Convert the payload to JSON - jsonData, err := json.Marshal(postData) + // generate internal private key, leaf script and Taproot output key + err := s.genTaprootLeafAndKeys(memo) if err != nil { - return "", err + return nil, errors.Wrap(err, "genTaprootLeafAndKeys failed") } - postURL := r.sidecarURL + "/commit" - req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(jsonData)) + return s.taprootOutputAddr, nil +} + +// BuildRevealTxn returns a signed reveal transaction that spends the commit transaction +func (s *TapscriptSpender) BuildRevealTxn( + to btcutil.Address, + commitTxn wire.OutPoint, + commitAmount int64, + feeRate int64, +) (*wire.MsgTx, error) { + // Step 1: create tx message + revealTx := wire.NewMsgTx(2) + + // Step 2: add input (the commit tx) + outpoint := wire.NewOutPoint(&commitTxn.Hash, commitTxn.Index) + revealTx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) + + // Step 3: add output (to TSS) + pkScript, err := txscript.PayToAddrScript(to) if err != nil { - return "", errors.Wrap(err, "cannot create commit request") + return nil, errors.Wrap(err, "failed to create receiver pkScript") } - req.Header.Set("Content-Type", "application/json") - - // Send the request - resp, err := r.client.Do(req) + fee, err := s.estimateFee(revealTx, to, commitAmount, feeRate) if err != nil { - return "", errors.Wrap(err, "cannot send to sidecar") + return nil, errors.Wrap(err, "failed to estimate fee for reveal txn") } - defer resp.Body.Close() + revealTx.AddTxOut(wire.NewTxOut(commitAmount-fee, pkScript)) - // Read the response body - var response commitResponse - err = json.NewDecoder(resp.Body).Decode(&response) + // Step 4: compute the sighash for the P2TR input to be spent using script path + commitScript, err := txscript.PayToAddrScript(s.taprootOutputAddr) if err != nil { - return "", err + return nil, errors.Wrap(err, "failed to create commit pkScript") + } + prevOutFetcher := txscript.NewCannedPrevOutputFetcher(commitScript, commitAmount) + sigHashes := txscript.NewTxSigHashes(revealTx, prevOutFetcher) + sigHash, err := txscript.CalcTapscriptSignaturehash( + sigHashes, + txscript.SigHashDefault, + revealTx, + int(commitTxn.Index), + prevOutFetcher, + s.tapLeaf, + ) + if err != nil { + return nil, errors.Wrap(err, "failed to calculate tapscript sighash") } - fmt.Print("raw commit response ", response.Address) + // Step 5: sign the sighash with the internal key + sig, err := schnorr.Sign(s.internalKey, sigHash) + if err != nil { + return nil, errors.Wrap(err, "failed to sign sighash") + } + revealTx.TxIn[0].Witness = wire.TxWitness{sig.Serialize(), s.tapLeaf.Script, s.ctrlBlockBytes} - return response.Address, nil + return revealTx, nil } -// GenerateRevealTxn creates the corresponding reveal txn to the commit txn. -func (r *InscriptionBuilder) GenerateRevealTxn(to string, txnHash string, idx int, amount float64) (string, error) { - postData := revealRequest{ - Txn: txnHash, - Idx: idx, - Amount: int(amount * 100000000), - FeeRate: 10, - To: to, +// genTaprootLeafAndKeys generates internal private key, leaf script and Taproot output key +func (s *TapscriptSpender) genTaprootLeafAndKeys(data []byte) error { + // generate an internal private key + internalKey, err := btcec.NewPrivateKey() + if err != nil { + return errors.Wrap(err, "failed to generate internal private key") } - // Convert the payload to JSON - jsonData, err := json.Marshal(postData) + // generate the leaf script + leafScript, err := genLeafScript(internalKey.PubKey(), data) if err != nil { - return "", err + return errors.Wrap(err, "failed to generate leaf script") } - postURL := r.sidecarURL + "/reveal" - req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(jsonData)) + // assemble Taproot tree + tapLeaf := txscript.NewBaseTapLeaf(leafScript) + tapScriptTree := txscript.AssembleTaprootScriptTree(tapLeaf) + + // compute the Taproot output key and address + tapScriptRoot := tapScriptTree.RootNode.TapHash() + taprootOutputKey := txscript.ComputeTaprootOutputKey(internalKey.PubKey(), tapScriptRoot[:]) + taprootOutputAddr, err := btcutil.NewAddressTaproot(schnorr.SerializePubKey(taprootOutputKey), s.net) if err != nil { - return "", errors.Wrap(err, "cannot create reveal request") + return errors.Wrap(err, "failed to create Taproot address") } - req.Header.Set("Content-Type", "application/json") - // Send the request - resp, err := r.client.Do(req) + // construct the control block for the Taproot leaf script. + ctrlBlock := tapScriptTree.LeafMerkleProofs[0].ToControlBlock(internalKey.PubKey()) + ctrlBlockBytes, err := ctrlBlock.ToBytes() if err != nil { - return "", errors.Wrap(err, "cannot send reveal to sidecar") + return errors.Wrap(err, "failed to serialize control block") } - defer resp.Body.Close() - // Read the response body - body, err := io.ReadAll(resp.Body) + // save generated keys, script and control block for later use + s.internalKey = internalKey + s.taprootOutputKey = taprootOutputKey + s.taprootOutputAddr = taprootOutputAddr + s.tapLeaf = tapLeaf + s.ctrlBlockBytes = ctrlBlockBytes + + return nil +} + +// estimateFee estimates the tx fee based given fee rate and estimated tx virtual size +func (s *TapscriptSpender) estimateFee( + tx *wire.MsgTx, + to btcutil.Address, + amount int64, + feeRate int64, +) (int64, error) { + txCopy := tx.Copy() + + // add output to the copied transaction + pkScript, err := txscript.PayToAddrScript(to) if err != nil { - return "", errors.Wrap(err, "cannot read reveal response body") + return 0, err } + txCopy.AddTxOut(wire.NewTxOut(amount, pkScript)) + + // create 64-byte fake Schnorr signature + sigBytes := make([]byte, 64) + + // set the witness for the first input + txWitness := wire.TxWitness{sigBytes, s.tapLeaf.Script, s.ctrlBlockBytes} + txCopy.TxIn[0].Witness = txWitness + + // calculate the fee based on the estimated virtual size + fee := mempool.GetTxVirtualSize(btcutil.NewTx(txCopy)) * feeRate + + return fee, nil +} + +//================================================================================================= +//================================================================================================= + +// LeafScriptBuilder represents a builder for Taproot leaf scripts +type LeafScriptBuilder struct { + script txscript.ScriptBuilder +} + +// NewLeafScriptBuilder initializes a new LeafScriptBuilder with a public key and `OP_CHECKSIG` +func NewLeafScriptBuilder(pubKey *btcec.PublicKey) *LeafScriptBuilder { + builder := txscript.NewScriptBuilder() + builder.AddData(schnorr.SerializePubKey(pubKey)) + builder.AddOp(txscript.OP_CHECKSIG) + + return &LeafScriptBuilder{script: *builder} +} - // Parse the JSON response - var response revealResponse - if err := json.Unmarshal(body, &response); err != nil { - return "", errors.Wrap(err, "cannot parse reveal response body") +// PushData adds a large data to the Taproot leaf script following OP_FALSE and OP_IF structure +func (b *LeafScriptBuilder) PushData(data []byte) { + // start the inscription envelope + b.script.AddOp(txscript.OP_FALSE) + b.script.AddOp(txscript.OP_IF) + + // break data into chunks and push each one + dataLen := len(data) + for i := 0; i < dataLen; i += txscript.MaxScriptElementSize { + if dataLen-i >= txscript.MaxScriptElementSize { + b.script.AddData(data[i : i+txscript.MaxScriptElementSize]) + } else { + b.script.AddData(data[i:]) + } } - // Access the "address" field - return response.RawHex, nil + // end the inscription envelope + b.script.AddOp(txscript.OP_ENDIF) +} + +// Script returns the current script +func (b *LeafScriptBuilder) Script() ([]byte, error) { + return b.script.Script() +} + +// genLeafScript creates a Taproot leaf script using provided pubkey and data +func genLeafScript(pubKey *btcec.PublicKey, data []byte) ([]byte, error) { + builder := NewLeafScriptBuilder(pubKey) + builder.PushData(data) + return builder.Script() } diff --git a/go.mod b/go.mod index 13a35b26bc..c92e2d2767 100644 --- a/go.mod +++ b/go.mod @@ -334,14 +334,16 @@ require ( require ( github.com/bnb-chain/tss-lib v1.5.0 + github.com/montanaflynn/stats v0.7.1 github.com/showa-93/go-mask v0.6.2 github.com/tonkeeper/tongo v1.9.3 github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20241025181051-d8d49e4fc85b ) require ( + github.com/aead/siphash v1.0.1 // indirect github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect - github.com/montanaflynn/stats v0.7.1 // indirect + github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23 // indirect github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect diff --git a/go.sum b/go.sum index fec35684fa..404160ddd4 100644 --- a/go.sum +++ b/go.sum @@ -1417,6 +1417,7 @@ github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ github.com/adlio/schema v1.1.13/go.mod h1:L5Z7tw+7lRK1Fnpi/LT/ooCP1elkXn0krMWBQHUhEDE= github.com/adlio/schema v1.3.3 h1:oBJn8I02PyTB466pZO1UZEn1TV5XLlifBSyMrmHl/1I= github.com/adlio/schema v1.3.3/go.mod h1:1EsRssiv9/Ce2CMzq5DoL7RiMshhuigQxrR4DMV9fHg= +github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= @@ -2987,6 +2988,7 @@ github.com/kisielk/errcheck v1.6.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/errcheck v1.6.2/go.mod h1:nXw/i/MfnvRHqXa7XXmQMUB0oNFGuBrNI8d8NLy0LPw= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkHAIKE/contextcheck v1.1.3/go.mod h1:PG/cwd6c0705/LM0KTr1acO2gORUxkSVWyLJOFW5qoo= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23 h1:FOOIBWrEkLgmlgGfMuZT83xIwfPDxEI2OHu6xUmJMFE= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= diff --git a/zetaclient/chains/bitcoin/observer/witness.go b/zetaclient/chains/bitcoin/observer/witness.go index 22ce75719b..9625ad3caa 100644 --- a/zetaclient/chains/bitcoin/observer/witness.go +++ b/zetaclient/chains/bitcoin/observer/witness.go @@ -6,6 +6,7 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" "github.com/pkg/errors" "github.com/rs/zerolog" @@ -104,7 +105,7 @@ func ParseScriptFromWitness(witness []string, logger zerolog.Logger) []byte { // If there are at least two witness elements, and the first byte of // the last element is 0x50, this last element is called annex a // and is removed from the witness stack. - if length >= 2 && len(lastElement) > 0 && lastElement[0] == 0x50 { + if length >= 2 && len(lastElement) > 0 && lastElement[0] == txscript.TaprootAnnexTag { // account for the extra item removed from the end witness = witness[:length-1] } diff --git a/zetaclient/chains/bitcoin/tokenizer.go b/zetaclient/chains/bitcoin/tokenizer.go deleted file mode 100644 index 5708bfa250..0000000000 --- a/zetaclient/chains/bitcoin/tokenizer.go +++ /dev/null @@ -1,162 +0,0 @@ -package bitcoin - -import ( - "encoding/binary" - "fmt" - - "github.com/btcsuite/btcd/txscript" -) - -func newScriptTokenizer(script []byte) scriptTokenizer { - return scriptTokenizer{ - script: script, - offset: 0, - } -} - -// scriptTokenizer is supposed to be replaced by txscript.ScriptTokenizer. However, -// it seems currently the btcsuite version does not have ScriptTokenizer. A simplified -// version of that is implemented here. This is fully compatible with txscript.ScriptTokenizer -// one should consider upgrading txscript and remove this implementation -type scriptTokenizer struct { - script []byte - offset int - op byte - data []byte - err error -} - -// Done returns true when either all opcodes have been exhausted or a parse -// failure was encountered and therefore the state has an associated error. -func (t *scriptTokenizer) Done() bool { - return t.err != nil || t.offset >= len(t.script) -} - -// Data returns the data associated with the most recently successfully parsed -// opcode. -func (t *scriptTokenizer) Data() []byte { - return t.data -} - -// Err returns any errors currently associated with the tokenizer. This will -// only be non-nil in the case a parsing error was encountered. -func (t *scriptTokenizer) Err() error { - return t.err -} - -// Opcode returns the current opcode associated with the tokenizer. -func (t *scriptTokenizer) Opcode() byte { - return t.op -} - -// Next attempts to parse the next opcode and returns whether or not it was -// successful. It will not be successful if invoked when already at the end of -// the script, a parse failure is encountered, or an associated error already -// exists due to a previous parse failure. -// -// In the case of a true return, the parsed opcode and data can be obtained with -// the associated functions and the offset into the script will either point to -// the next opcode or the end of the script if the final opcode was parsed. -// -// In the case of a false return, the parsed opcode and data will be the last -// successfully parsed values (if any) and the offset into the script will -// either point to the failing opcode or the end of the script if the function -// was invoked when already at the end of the script. -// -// Invoking this function when already at the end of the script is not -// considered an error and will simply return false. -func (t *scriptTokenizer) Next() bool { - if t.Done() { - return false - } - - op := t.script[t.offset] - - // Only the following op_code will be encountered: - // OP_PUSHDATA*, OP_DATA_*, OP_CHECKSIG, OP_IF, OP_ENDIF, OP_FALSE - switch { - // No additional data. Note that some of the opcodes, notably OP_1NEGATE, - // OP_0, and OP_[1-16] represent the data themselves. - case op == txscript.OP_FALSE || op == txscript.OP_IF || op == txscript.OP_CHECKSIG || op == txscript.OP_ENDIF: - t.offset++ - t.op = op - t.data = nil - return true - - // Data pushes of specific lengths -- OP_DATA_[1-75]. - case op >= txscript.OP_DATA_1 && op <= txscript.OP_DATA_75: - script := t.script[t.offset:] - - // The length should be: int(op) - txscript.OP_DATA_1 + 2, i.e. op is txscript.OP_DATA_10, that means - // the data length should be 10, which is txscript.OP_DATA_10 - txscript.OP_DATA_1 + 1. - // Here, 2 instead of 1 because `script` also includes the opcode which means it contains one more byte. - // Since txscript.OP_DATA_1 is 1, then length is just int(op) - 1 + 2 = int(op) + 1 - length := int(op) + 1 - if len(script) < length { - t.err = fmt.Errorf("opcode %d detected, but script only %d bytes remaining", op, len(script)) - return false - } - - // Move the offset forward and set the opcode and data accordingly. - t.offset += length - t.op = op - t.data = script[1:length] - return true - - case op > txscript.OP_PUSHDATA4: - t.err = fmt.Errorf("unexpected op code %d", op) - return false - - // Data pushes with parsed lengths -- OP_PUSHDATA{1,2,4}. - default: - var length int - switch op { - case txscript.OP_PUSHDATA1: - length = 1 - case txscript.OP_PUSHDATA2: - length = 2 - case txscript.OP_PUSHDATA4: - length = 4 - default: - t.err = fmt.Errorf("unexpected op code %d", op) - return false - } - - script := t.script[t.offset+1:] - if len(script) < length { - t.err = fmt.Errorf("opcode %d requires %d bytes, only %d remaining", op, length, len(script)) - return false - } - - // Next -length bytes are little endian length of data. - var dataLen int - switch length { - case 1: - dataLen = int(script[0]) - case 2: - dataLen = int(binary.LittleEndian.Uint16(script[:length])) - case 4: - dataLen = int(binary.LittleEndian.Uint32(script[:length])) - default: - t.err = fmt.Errorf("invalid opcode length %d", length) - return false - } - - // Move to the beginning of the data. - script = script[length:] - - // Disallow entries that do not fit script or were sign extended. - if dataLen > len(script) || dataLen < 0 { - t.err = fmt.Errorf("opcode %d pushes %d bytes, only %d remaining", op, dataLen, len(script)) - return false - } - - // Move the offset forward and set the opcode and data accordingly. - // 1 is the opcode size, which is just 1 byte. int(op) is the opcode value, - // it should not be mixed with the size. - t.offset += 1 + length + dataLen - t.op = op - t.data = script[:dataLen] - return true - } -} diff --git a/zetaclient/chains/bitcoin/tx_script.go b/zetaclient/chains/bitcoin/tx_script.go index 6f394ef81d..816251024a 100644 --- a/zetaclient/chains/bitcoin/tx_script.go +++ b/zetaclient/chains/bitcoin/tx_script.go @@ -216,7 +216,7 @@ func DecodeOpReturnMemo(scriptHex string) ([]byte, bool, error) { // OP_ENDIF // There are no content-type or any other attributes, it's just raw bytes. func DecodeScript(script []byte) ([]byte, bool, error) { - t := newScriptTokenizer(script) + t := txscript.MakeScriptTokenizer(0, script) if err := checkInscriptionEnvelope(&t); err != nil { return nil, false, errors.Wrap(err, "checkInscriptionEnvelope: unable to check the envelope") @@ -306,7 +306,7 @@ func DecodeTSSVout(vout btcjson.Vout, receiverExpected string, chain chains.Chai return receiverVout, amount, nil } -func decodeInscriptionPayload(t *scriptTokenizer) ([]byte, error) { +func decodeInscriptionPayload(t *txscript.ScriptTokenizer) ([]byte, error) { if !t.Next() || t.Opcode() != txscript.OP_FALSE { return nil, fmt.Errorf("OP_FALSE not found") } @@ -335,7 +335,7 @@ func decodeInscriptionPayload(t *scriptTokenizer) ([]byte, error) { // checkInscriptionEnvelope decodes the envelope for the script monitoring. The format is // OP_PUSHBYTES_32 <32 bytes> OP_CHECKSIG -func checkInscriptionEnvelope(t *scriptTokenizer) error { +func checkInscriptionEnvelope(t *txscript.ScriptTokenizer) error { if !t.Next() || t.Opcode() != txscript.OP_DATA_32 { return fmt.Errorf("cannot obtain public key bytes op %d or err %s", t.Opcode(), t.Err()) } diff --git a/zetaclient/chains/bitcoin/tx_script_test.go b/zetaclient/chains/bitcoin/tx_script_test.go index 394a5d8608..6c4724eb9a 100644 --- a/zetaclient/chains/bitcoin/tx_script_test.go +++ b/zetaclient/chains/bitcoin/tx_script_test.go @@ -659,8 +659,8 @@ func TestDecodeScript(t *testing.T) { }) t.Run("decode error due to missing data for public key", func(t *testing.T) { - // missing OP_ENDIF at the end - data := "2001a7bae79bd61c2368fe41a565061d6cf22b4f509fbc1652caea06d98b8fd0" + // require OP_DATA_32 but OP_DATA_31 is given + data := "1f01a7bae79bd61c2368fe41a565061d6cf22b4f509fbc1652caea06d98b8fd0" script, _ := hex.DecodeString(data) memo, isFound, err := bitcoin.DecodeScript(script) From 08eeb7f9c130255bc7199a42aed14ce4190b9c6c Mon Sep 17 00:00:00 2001 From: Alex Gartner Date: Mon, 4 Nov 2024 09:07:36 -0800 Subject: [PATCH 8/8] fix(ci): do not run rpcimportable on forks (#3087) --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 741931df27..52c31eeaa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,9 @@ jobs: rpcimportable: runs-on: ubuntu-20.04 timeout-minutes: 15 + # do not run this on forks as they are not installable + # it will still be check in the merge queue in this case + if: github.repository == 'zeta-chain/node' steps: - uses: actions/checkout@v4 - name: Set up Go