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 68cffd2d84..8cc21c36dc 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,7 @@ ## Unreleased ### Features +* [2984](https://github.com/zeta-chain/node/pull/2984) - add Whitelist message ability to whitelist SPL tokens on Solana ### Refactor @@ -11,6 +12,7 @@ ### Fixes * [3041](https://github.com/zeta-chain/node/pull/3041) - replace libp2p public DHT with private gossip peer discovery and connection gater for inbound connections + ## v21.0.0 ### Features @@ -47,6 +49,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 @@ -60,6 +63,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/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/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index 7cde1a92ad..986e9e4689 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) @@ -306,6 +310,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestBitcoinStdMemoDepositAndCallName, e2etests.TestBitcoinStdMemoDepositAndCallRevertName, e2etests.TestBitcoinStdMemoDepositAndCallRevertOtherAddressName, + e2etests.TestBitcoinStdMemoInscribedDepositAndCallName, e2etests.TestBitcoinWithdrawSegWitName, e2etests.TestBitcoinWithdrawInvalidAddressName, e2etests.TestZetaWithdrawBTCRevertName, @@ -405,6 +410,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/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/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/contrib/localnet/solana/gateway.so b/contrib/localnet/solana/gateway.so index 185b938c3c..0fe82f24f2 100755 Binary files a/contrib/localnet/solana/gateway.so and b/contrib/localnet/solana/gateway.so differ 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/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 } diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index 30c25b33a3..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 @@ -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 */ @@ -490,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", @@ -552,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/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_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/e2etests/test_whitelist_erc20.go b/e2e/e2etests/test_whitelist_erc20.go index df34b05597..1823947b98 100644 --- a/e2e/e2etests/test_whitelist_erc20.go +++ b/e2e/e2etests/test_whitelist_erc20.go @@ -43,14 +43,12 @@ 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.InitializeLiquidityCap(erc20zrc20Addr) + err = r.ZetaTxServer.InitializeLiquidityCaps(erc20zrc20Addr) require.NoError(r, err) // ensure CCTX created 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/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/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 a79c5af098..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" @@ -11,6 +10,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 +32,8 @@ 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/cosmos/gogoproto/proto" + "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 @@ -348,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 } @@ -442,105 +418,98 @@ 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, ok := EventsOfType[*fungibletypes.EventZRC20Deployed](res.Events) + if !ok { + return "", fmt.Errorf("no EventZRC20Deployed in %s", res.TxHash) } - // 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 +557,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 @@ -703,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 } diff --git a/go.mod b/go.mod index fe8e541983..ebc0399379 100644 --- a/go.mod +++ b/go.mod @@ -234,7 +234,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect - github.com/mr-tron/base58 v1.2.0 + github.com/mr-tron/base58 v1.2.0 // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect @@ -327,10 +327,13 @@ require ( 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/kkdai/bstream v0.0.0-20161212061736-f391b8402d23 // indirect github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae // indirect github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect diff --git a/go.sum b/go.sum index 17d329ca53..3382eb438a 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= @@ -2186,6 +2187,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= @@ -2971,6 +2974,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= @@ -4183,6 +4187,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/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) -} 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/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) 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