From 13cfffed704a9ff80fcd3813b231b8308b5db0c6 Mon Sep 17 00:00:00 2001 From: skosito Date: Fri, 8 Nov 2024 14:31:00 +0000 Subject: [PATCH] feat: integrate SPL deposits (#3124) * deposit spl integration and start with e2e test * test wip for deposit spl and call * fix up creation of ata * add balance assertions for deposit spl e2e tests * CI fixes * lint fix * add inbound parse unit test and cleanup * comment * PR comments * move inbound parsing to solana pkg * refactor e2e test zrc20 deployment * fix e2e tests * Update changelog.md Co-authored-by: Lucas Bertrand --------- Co-authored-by: Dmitry S <11892559+swift1337@users.noreply.github.com> Co-authored-by: Lucas Bertrand --- changelog.md | 1 + cmd/zetae2e/config/config.go | 4 +- cmd/zetae2e/config/contracts.go | 15 ++ cmd/zetae2e/local/local.go | 20 +- cmd/zetae2e/stress.go | 6 +- e2e/config/config.go | 6 +- e2e/e2etests/e2etests.go | 18 ++ e2e/e2etests/test_solana_whitelist_spl.go | 3 +- e2e/e2etests/test_spl_deposit.go | 66 ++++++ e2e/e2etests/test_spl_deposit_and_call.go | 76 +++++++ e2e/runner/runner.go | 6 + e2e/runner/setup_solana.go | 4 + e2e/runner/setup_zeta.go | 17 +- e2e/runner/solana.go | 200 ++++++++++++++++-- e2e/txserver/zeta_tx_server.go | 79 +++++-- pkg/contracts/solana/gateway.go | 6 +- pkg/contracts/solana/inbound.go | 127 +++++++++++ pkg/contracts/solana/inbound_test.go | 105 +++++++++ pkg/contracts/solana/instruction.go | 12 ++ ...axBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j.json | 64 ++++++ ...x5z5JfXs5ed8LZqpXUy4VijoU3x15mBd66ZGE.json | 93 ++++++++ x/fungible/keeper/foreign_coins.go | 9 +- x/fungible/keeper/foreign_coins_test.go | 18 -- zetaclient/chains/solana/observer/inbound.go | 134 +++--------- .../chains/solana/observer/inbound_test.go | 44 ---- 25 files changed, 907 insertions(+), 226 deletions(-) create mode 100644 e2e/e2etests/test_spl_deposit.go create mode 100644 e2e/e2etests/test_spl_deposit_and_call.go create mode 100644 pkg/contracts/solana/inbound.go create mode 100644 pkg/contracts/solana/inbound_test.go create mode 100644 pkg/contracts/solana/testdata/MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j.json create mode 100644 pkg/contracts/solana/testdata/aY8yLDze6nHSRi7L5REozKAZY1aAyPJ6TfibiqQL5JGwgSBkYux5z5JfXs5ed8LZqpXUy4VijoU3x15mBd66ZGE.json diff --git a/changelog.md b/changelog.md index 074a2877aa..d71bded0a0 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ ### Features * [2984](https://github.com/zeta-chain/node/pull/2984) - add Whitelist message ability to whitelist SPL tokens on Solana * [3091](https://github.com/zeta-chain/node/pull/3091) - improve build reproducability. `make release{,-build-only}` checksums should now be stable. +* [3124](https://github.com/zeta-chain/node/pull/3124) - integrate SPL deposits ### Tests * [3075](https://github.com/zeta-chain/node/pull/3075) - ton: withdraw concurrent, deposit & revert. diff --git a/cmd/zetae2e/config/config.go b/cmd/zetae2e/config/config.go index 0cea78af39..65e5418f53 100644 --- a/cmd/zetae2e/config/config.go +++ b/cmd/zetae2e/config/config.go @@ -54,9 +54,10 @@ func RunnerFromConfig( // ExportContractsFromRunner export contracts from the runner to config using a source config func ExportContractsFromRunner(r *runner.E2ERunner, conf config.Config) config.Config { + // copy contracts from deployer runner conf.Contracts.Solana.GatewayProgramID = r.GatewayProgram.String() + conf.Contracts.Solana.SPLAddr = config.DoubleQuotedString(r.SPLAddr.String()) - // copy contracts from deployer runner conf.Contracts.EVM.ZetaEthAddr = config.DoubleQuotedString(r.ZetaEthAddr.Hex()) conf.Contracts.EVM.ConnectorEthAddr = config.DoubleQuotedString(r.ConnectorEthAddr.Hex()) conf.Contracts.EVM.CustodyAddr = config.DoubleQuotedString(r.ERC20CustodyAddr.Hex()) @@ -68,6 +69,7 @@ func ExportContractsFromRunner(r *runner.E2ERunner, conf config.Config) config.C conf.Contracts.ZEVM.ERC20ZRC20Addr = config.DoubleQuotedString(r.ERC20ZRC20Addr.Hex()) conf.Contracts.ZEVM.BTCZRC20Addr = config.DoubleQuotedString(r.BTCZRC20Addr.Hex()) conf.Contracts.ZEVM.SOLZRC20Addr = config.DoubleQuotedString(r.SOLZRC20Addr.Hex()) + conf.Contracts.ZEVM.SPLZRC20Addr = config.DoubleQuotedString(r.SPLZRC20Addr.Hex()) conf.Contracts.ZEVM.TONZRC20Addr = config.DoubleQuotedString(r.TONZRC20Addr.Hex()) conf.Contracts.ZEVM.UniswapFactoryAddr = config.DoubleQuotedString(r.UniswapV2FactoryAddr.Hex()) conf.Contracts.ZEVM.UniswapRouterAddr = config.DoubleQuotedString(r.UniswapV2RouterAddr.Hex()) diff --git a/cmd/zetae2e/config/contracts.go b/cmd/zetae2e/config/contracts.go index 6f2dff72c6..5c46cdc047 100644 --- a/cmd/zetae2e/config/contracts.go +++ b/cmd/zetae2e/config/contracts.go @@ -34,6 +34,10 @@ func setContractsFromConfig(r *runner.E2ERunner, conf config.Config) error { r.GatewayProgram = solana.MustPublicKeyFromBase58(c) } + if c := conf.Contracts.Solana.SPLAddr; c != "" { + r.SPLAddr = solana.MustPublicKeyFromBase58(c.String()) + } + // set EVM contracts if c := conf.Contracts.EVM.ZetaEthAddr; c != "" { r.ZetaEthAddr, err = c.AsEVMAddress() @@ -135,6 +139,17 @@ func setContractsFromConfig(r *runner.E2ERunner, conf config.Config) error { } } + if c := conf.Contracts.ZEVM.SPLZRC20Addr; c != "" { + r.SPLZRC20Addr, err = c.AsEVMAddress() + if err != nil { + return fmt.Errorf("invalid SPLZRC20Addr: %w", err) + } + r.SPLZRC20, err = zrc20.NewZRC20(r.SPLZRC20Addr, r.ZEVMClient) + if err != nil { + return err + } + } + if c := conf.Contracts.ZEVM.TONZRC20Addr; c != "" { r.TONZRC20Addr, err = c.AsEVMAddress() if err != nil { diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index e7b1ee3e29..9b818ea8c0 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -222,12 +222,24 @@ func localE2ETest(cmd *cobra.Command, _ []string) { deployerRunner.SetupEVMV2() + if testSolana { + deployerRunner.SetupSolana(conf.AdditionalAccounts.UserSolana.SolanaPrivateKey.String()) + } + deployerRunner.SetZEVMSystemContracts() // NOTE: v2 (gateway) setup called here because system contract needs to be set first, then gateway, then zrc20 deployerRunner.SetZEVMContractsV2() - deployerRunner.SetZEVMZRC20s() + zrc20Deployment := txserver.ZRC20Deployment{ + ERC20Addr: deployerRunner.ERC20Addr, + SPLAddr: nil, + } + if testSolana { + zrc20Deployment.SPLAddr = deployerRunner.SPLAddr.ToPointer() + } + + deployerRunner.SetZEVMZRC20s(zrc20Deployment) // Update the chain params to use v2 contract for ERC20Custody // TODO: this function should be removed and the chain params should be directly set to use v2 contract @@ -235,10 +247,6 @@ func localE2ETest(cmd *cobra.Command, _ []string) { deployerRunner.UpdateChainParamsV2Contracts() deployerRunner.ERC20CustodyAddr = deployerRunner.ERC20CustodyV2Addr - if testSolana { - deployerRunner.SetupSolana(conf.AdditionalAccounts.UserSolana.SolanaPrivateKey.String()) - } - deployerRunner.MintERC20OnEvm(1000000) logger.Print("✅ setup completed in %s", time.Since(startTime)) @@ -417,6 +425,8 @@ func localE2ETest(cmd *cobra.Command, _ []string) { // TODO move under admin tests // https://github.com/zeta-chain/node/issues/3085 e2etests.TestSolanaWhitelistSPLName, + e2etests.TestSPLDepositName, + e2etests.TestSPLDepositAndCallName, } eg.Go(solanaTestRoutine(conf, deployerRunner, verbose, solanaTests...)) } diff --git a/cmd/zetae2e/stress.go b/cmd/zetae2e/stress.go index 66e8cd700d..b2ab4a9154 100644 --- a/cmd/zetae2e/stress.go +++ b/cmd/zetae2e/stress.go @@ -22,6 +22,7 @@ import ( zetae2econfig "github.com/zeta-chain/node/cmd/zetae2e/config" "github.com/zeta-chain/node/cmd/zetae2e/local" "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/testutil" crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" @@ -142,7 +143,10 @@ func StressTest(cmd *cobra.Command, _ []string) { case "LOCAL": // deploy and set zevm contract e2eTest.SetZEVMSystemContracts() - e2eTest.SetZEVMZRC20s() + e2eTest.SetZEVMZRC20s(txserver.ZRC20Deployment{ + ERC20Addr: e2eTest.ERC20Addr, + SPLAddr: nil, // no stress tests for solana atm + }) // deposit on ZetaChain e2eTest.DepositEther() diff --git a/e2e/config/config.go b/e2e/config/config.go index 67685b0dd3..15ca4a1f2c 100644 --- a/e2e/config/config.go +++ b/e2e/config/config.go @@ -117,9 +117,10 @@ type Contracts struct { Solana Solana `yaml:"solana"` } -// Solana contains the addresses of predeployed contracts on the Solana chain +// Solana contains the addresses of predeployed contracts and accounts on the Solana chain type Solana struct { - GatewayProgramID string `yaml:"gateway_program_id"` + GatewayProgramID string `yaml:"gateway_program_id"` + SPLAddr DoubleQuotedString `yaml:"spl"` } // EVM contains the addresses of predeployed contracts on the EVM chain @@ -141,6 +142,7 @@ type ZEVM struct { ERC20ZRC20Addr DoubleQuotedString `yaml:"erc20_zrc20"` BTCZRC20Addr DoubleQuotedString `yaml:"btc_zrc20"` SOLZRC20Addr DoubleQuotedString `yaml:"sol_zrc20"` + SPLZRC20Addr DoubleQuotedString `yaml:"spl_zrc20"` TONZRC20Addr DoubleQuotedString `yaml:"ton_zrc20"` UniswapFactoryAddr DoubleQuotedString `yaml:"uniswap_factory"` UniswapRouterAddr DoubleQuotedString `yaml:"uniswap_router"` diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index 842abecbe4..a880172830 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -61,6 +61,8 @@ const ( TestSolanaDepositAndCallRefundName = "solana_deposit_and_call_refund" TestSolanaDepositRestrictedName = "solana_deposit_restricted" TestSolanaWithdrawRestrictedName = "solana_withdraw_restricted" + TestSPLDepositName = "spl_deposit" + TestSPLDepositAndCallName = "spl_deposit_and_call" /** * TON tests @@ -463,6 +465,22 @@ var AllE2ETests = []runner.E2ETest{ []runner.ArgDefinition{}, TestSolanaWhitelistSPL, ), + runner.NewE2ETest( + TestSPLDepositName, + "deposit SPL into ZEVM", + []runner.ArgDefinition{ + {Description: "amount of spl tokens", DefaultValue: "500000"}, + }, + TestSPLDeposit, + ), + runner.NewE2ETest( + TestSPLDepositAndCallName, + "deposit SPL into ZEVM and call", + []runner.ArgDefinition{ + {Description: "amount of spl tokens", DefaultValue: "500000"}, + }, + TestSPLDepositAndCall, + ), /* TON tests */ diff --git a/e2e/e2etests/test_solana_whitelist_spl.go b/e2e/e2etests/test_solana_whitelist_spl.go index 259657f72b..fadff22805 100644 --- a/e2e/e2etests/test_solana_whitelist_spl.go +++ b/e2e/e2etests/test_solana_whitelist_spl.go @@ -19,7 +19,8 @@ func TestSolanaWhitelistSPL(r *runner.E2ERunner, _ []string) { privkey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) require.NoError(r, err) - spl := r.DeploySPL(&privkey) + // deploy SPL token, but don't whitelist in gateway + spl := r.DeploySPL(&privkey, false) // check that whitelist entry doesn't exist for this spl seed := [][]byte{[]byte("whitelist"), spl.PublicKey().Bytes()} diff --git a/e2e/e2etests/test_spl_deposit.go b/e2e/e2etests/test_spl_deposit.go new file mode 100644 index 0000000000..ee5013d16c --- /dev/null +++ b/e2e/e2etests/test_spl_deposit.go @@ -0,0 +1,66 @@ +package e2etests + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/utils" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" +) + +func TestSPLDeposit(r *runner.E2ERunner, args []string) { + require.Len(r, args, 1) + amount := parseInt(r, args[0]) + + // load deployer private key + privKey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) + require.NoError(r, err) + + // get SPL balance for pda and sender atas + pda := r.ComputePdaAddress() + pdaAta := r.FindOrCreateAssociatedTokenAccount(privKey, pda, r.SPLAddr) + + pdaBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentConfirmed) + require.NoError(r, err) + + senderAta := r.FindOrCreateAssociatedTokenAccount(privKey, privKey.PublicKey(), r.SPLAddr) + senderBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentConfirmed) + require.NoError(r, err) + + // get zrc20 balance for recipient + zrc20BalanceBefore, err := r.SPLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) + require.NoError(r, err) + + // deposit SPL tokens + // #nosec G115 e2eTest - always in range + sig := r.SPLDepositAndCall(&privKey, uint64(amount), r.SPLAddr, r.EVMAddress(), nil) + + // wait for the cctx to be mined + cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, sig.String(), r.CctxClient, r.Logger, r.CctxTimeout) + r.Logger.CCTX(*cctx, "solana_deposit_spl") + utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) + + // verify balances are updated + pdaBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentConfirmed) + require.NoError(r, err) + + senderBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentConfirmed) + require.NoError(r, err) + + zrc20BalanceAfter, err := r.SPLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) + require.NoError(r, err) + + // verify amount is deposited to pda ata + require.Equal(r, parseInt(r, pdaBalanceBefore.Value.Amount)+amount, parseInt(r, pdaBalanceAfter.Value.Amount)) + + // verify amount is subtracted from sender ata + require.Equal(r, parseInt(r, senderBalanceBefore.Value.Amount)-amount, parseInt(r, senderBalanceAfter.Value.Amount)) + + // verify amount is minted to receiver + require.Zero(r, zrc20BalanceBefore.Add(zrc20BalanceBefore, big.NewInt(int64(amount))).Cmp(zrc20BalanceAfter)) +} diff --git a/e2e/e2etests/test_spl_deposit_and_call.go b/e2e/e2etests/test_spl_deposit_and_call.go new file mode 100644 index 0000000000..cdfc94daa1 --- /dev/null +++ b/e2e/e2etests/test_spl_deposit_and_call.go @@ -0,0 +1,76 @@ +package e2etests + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/utils" + testcontract "github.com/zeta-chain/node/testutil/contracts" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" +) + +func TestSPLDepositAndCall(r *runner.E2ERunner, args []string) { + require.Len(r, args, 1) + amount := parseInt(r, args[0]) + + // deploy an example contract in ZEVM + contractAddr, _, contract, err := testcontract.DeployExample(r.ZEVMAuth, r.ZEVMClient) + require.NoError(r, err) + r.Logger.Info("Example contract deployed at: %s", contractAddr.String()) + + // load deployer private key + privKey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) + require.NoError(r, err) + + // get SPL balance for pda and sender atas + pda := r.ComputePdaAddress() + pdaAta := r.FindOrCreateAssociatedTokenAccount(privKey, pda, r.SPLAddr) + + pdaBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentConfirmed) + require.NoError(r, err) + + senderAta := r.FindOrCreateAssociatedTokenAccount(privKey, privKey.PublicKey(), r.SPLAddr) + senderBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentConfirmed) + require.NoError(r, err) + + // get zrc20 balance for recipient + zrc20BalanceBefore, err := r.SPLZRC20.BalanceOf(&bind.CallOpts{}, contractAddr) + require.NoError(r, err) + + // execute the deposit transaction + data := []byte("hello spl tokens") + // #nosec G115 e2eTest - always in range + sig := r.SPLDepositAndCall(&privKey, uint64(amount), r.SPLAddr, contractAddr, data) + + // wait for the cctx to be mined + cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, sig.String(), r.CctxClient, r.Logger, r.CctxTimeout) + r.Logger.CCTX(*cctx, "solana_deposit_spl_and_call") + utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) + + // check if example contract has been called, bar value should be set to amount + utils.MustHaveCalledExampleContract(r, contract, big.NewInt(int64(amount))) + + // verify balances are updated + pdaBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentConfirmed) + require.NoError(r, err) + + senderBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentConfirmed) + require.NoError(r, err) + + zrc20BalanceAfter, err := r.SPLZRC20.BalanceOf(&bind.CallOpts{}, contractAddr) + require.NoError(r, err) + + // verify amount is deposited to pda ata + require.Equal(r, parseInt(r, pdaBalanceBefore.Value.Amount)+amount, parseInt(r, pdaBalanceAfter.Value.Amount)) + + // verify amount is subtracted from sender ata + require.Equal(r, parseInt(r, senderBalanceBefore.Value.Amount)-amount, parseInt(r, senderBalanceAfter.Value.Amount)) + + // verify amount is minted to receiver + require.Zero(r, zrc20BalanceBefore.Add(zrc20BalanceBefore, big.NewInt(int64(amount))).Cmp(zrc20BalanceAfter)) +} diff --git a/e2e/runner/runner.go b/e2e/runner/runner.go index cf7a8e6f02..ef68abe6a8 100644 --- a/e2e/runner/runner.go +++ b/e2e/runner/runner.go @@ -110,6 +110,7 @@ type E2ERunner struct { // programs on Solana GatewayProgram solana.PublicKey + SPLAddr solana.PublicKey // contracts evm ZetaEthAddr ethcommon.Address @@ -125,6 +126,8 @@ type E2ERunner struct { // contracts zevm ERC20ZRC20Addr ethcommon.Address ERC20ZRC20 *zrc20.ZRC20 + SPLZRC20Addr ethcommon.Address + SPLZRC20 *zrc20.ZRC20 ETHZRC20Addr ethcommon.Address ETHZRC20 *zrc20.ZRC20 BTCZRC20Addr ethcommon.Address @@ -366,6 +369,8 @@ func (r *E2ERunner) Unlock() { func (r *E2ERunner) PrintContractAddresses() { r.Logger.Print(" --- 📜Solana addresses ---") r.Logger.Print("GatewayProgram: %s", r.GatewayProgram.String()) + r.Logger.Print("SPL: %s", r.SPLAddr.String()) + // zevm contracts r.Logger.Print(" --- 📜zEVM contracts ---") r.Logger.Print("SystemContract: %s", r.SystemContractAddr.Hex()) @@ -373,6 +378,7 @@ func (r *E2ERunner) PrintContractAddresses() { r.Logger.Print("ERC20ZRC20: %s", r.ERC20ZRC20Addr.Hex()) r.Logger.Print("BTCZRC20: %s", r.BTCZRC20Addr.Hex()) r.Logger.Print("SOLZRC20: %s", r.SOLZRC20Addr.Hex()) + r.Logger.Print("SPLZRC20: %s", r.SPLZRC20Addr.Hex()) r.Logger.Print("TONZRC20: %s", r.TONZRC20Addr.Hex()) r.Logger.Print("UniswapFactory: %s", r.UniswapV2FactoryAddr.Hex()) r.Logger.Print("UniswapRouter: %s", r.UniswapV2RouterAddr.Hex()) diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go index 73a571b2be..a7589d6af1 100644 --- a/e2e/runner/setup_solana.go +++ b/e2e/runner/setup_solana.go @@ -87,6 +87,10 @@ func (r *E2ERunner) SetupSolana(deployerPrivateKey string) { err = r.ensureSolanaChainParams() require.NoError(r, err) + + // deploy test spl + tokenAccount := r.DeploySPL(&privkey, true) + r.SPLAddr = tokenAccount.PublicKey() } func (r *E2ERunner) ensureSolanaChainParams() error { diff --git a/e2e/runner/setup_zeta.go b/e2e/runner/setup_zeta.go index 1d1db47108..072957346c 100644 --- a/e2e/runner/setup_zeta.go +++ b/e2e/runner/setup_zeta.go @@ -167,7 +167,7 @@ func (r *E2ERunner) SetZEVMSystemContracts() { } // SetZEVMZRC20s set ZRC20 for the ZEVM -func (r *E2ERunner) SetZEVMZRC20s() { +func (r *E2ERunner) SetZEVMZRC20s(zrc20Deployment txserver.ZRC20Deployment) { r.Logger.Print("⚙️ deploying ZRC20s on ZEVM") startTime := time.Now() defer func() { @@ -175,19 +175,24 @@ func (r *E2ERunner) SetZEVMZRC20s() { }() // deploy system contracts and ZRC20 contracts on ZetaChain - erc20zrc20Addr, err := r.ZetaTxServer.DeployZRC20s( - e2eutils.OperationalPolicyName, - e2eutils.AdminPolicyName, - r.ERC20Addr.Hex(), + deployedZRC20Addresses, err := r.ZetaTxServer.DeployZRC20s( + zrc20Deployment, r.skipChainOperations, ) require.NoError(r, err) // Set ERC20ZRC20Addr - r.ERC20ZRC20Addr = ethcommon.HexToAddress(erc20zrc20Addr) + r.ERC20ZRC20Addr = deployedZRC20Addresses.ERC20ZRC20Addr r.ERC20ZRC20, err = zrc20.NewZRC20(r.ERC20ZRC20Addr, r.ZEVMClient) require.NoError(r, err) + // Set SPLZRC20Addr if set + if deployedZRC20Addresses.SPLZRC20Addr != (ethcommon.Address{}) { + r.SPLZRC20Addr = deployedZRC20Addresses.SPLZRC20Addr + r.SPLZRC20, err = zrc20.NewZRC20(r.SPLZRC20Addr, r.ZEVMClient) + require.NoError(r, err) + } + // set ZRC20 contracts r.SetupETHZRC20() r.SetupBTCZRC20() diff --git a/e2e/runner/solana.go b/e2e/runner/solana.go index 24ea3c3b2f..542968938d 100644 --- a/e2e/runner/solana.go +++ b/e2e/runner/solana.go @@ -6,6 +6,7 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" + associatedtokenaccount "github.com/gagliardetto/solana-go/programs/associated-token-account" "github.com/gagliardetto/solana-go/programs/system" "github.com/gagliardetto/solana-go/programs/token" "github.com/gagliardetto/solana-go/rpc" @@ -36,28 +37,77 @@ func (r *E2ERunner) CreateDepositInstruction( data []byte, amount uint64, ) solana.Instruction { - // compute the gateway PDA address - pdaComputed := r.ComputePdaAddress() - programID := r.GatewayProgram - - // create 'deposit' instruction - inst := &solana.GenericInstruction{} - accountSlice := []*solana.AccountMeta{} - accountSlice = append(accountSlice, solana.Meta(signer).WRITE().SIGNER()) - accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) - accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) - inst.ProgID = programID - inst.AccountValues = accountSlice - - var err error - inst.DataBytes, err = borsh.Serialize(solanacontract.DepositInstructionParams{ + depositData, err := borsh.Serialize(solanacontract.DepositInstructionParams{ Discriminator: solanacontract.DiscriminatorDeposit, Amount: amount, Memo: append(receiver.Bytes(), data...), }) require.NoError(r, err) - return inst + return &solana.GenericInstruction{ + ProgID: r.GatewayProgram, + DataBytes: depositData, + AccountValues: []*solana.AccountMeta{ + solana.Meta(signer).WRITE().SIGNER(), + solana.Meta(r.ComputePdaAddress()).WRITE(), + solana.Meta(solana.SystemProgramID), + }, + } +} + +// CreateWhitelistSPLMintInstruction creates a 'whitelist_spl_mint' instruction +func (r *E2ERunner) CreateWhitelistSPLMintInstruction( + signer, whitelistEntry, whitelistCandidate solana.PublicKey, +) solana.Instruction { + data, err := borsh.Serialize(solanacontract.WhitelistInstructionParams{ + Discriminator: solanacontract.DiscriminatorWhitelistSplMint, + }) + require.NoError(r, err) + + return &solana.GenericInstruction{ + ProgID: r.GatewayProgram, + DataBytes: data, + AccountValues: []*solana.AccountMeta{ + solana.Meta(whitelistEntry).WRITE(), + solana.Meta(whitelistCandidate), + solana.Meta(r.ComputePdaAddress()).WRITE(), + solana.Meta(signer).WRITE().SIGNER(), + solana.Meta(solana.SystemProgramID), + }, + } +} + +// CreateDepositSPLInstruction creates a 'deposit_spl' instruction +func (r *E2ERunner) CreateDepositSPLInstruction( + amount uint64, + signer solana.PublicKey, + whitelistEntry solana.PublicKey, + mint solana.PublicKey, + from solana.PublicKey, + to solana.PublicKey, + receiver ethcommon.Address, + data []byte, +) solana.Instruction { + depositSPLData, err := borsh.Serialize(solanacontract.DepositInstructionParams{ + Discriminator: solanacontract.DiscriminatorDepositSPL, + Amount: amount, + Memo: append(receiver.Bytes(), data...), + }) + require.NoError(r, err) + + return &solana.GenericInstruction{ + ProgID: r.GatewayProgram, + DataBytes: depositSPLData, + AccountValues: []*solana.AccountMeta{ + solana.Meta(signer).WRITE().SIGNER(), + solana.Meta(r.ComputePdaAddress()), + solana.Meta(whitelistEntry), + solana.Meta(mint), + solana.Meta(solana.TokenProgramID), + solana.Meta(from).WRITE(), + solana.Meta(to).WRITE(), + }, + } } // CreateSignedTransaction creates a signed transaction from instructions @@ -97,7 +147,76 @@ func (r *E2ERunner) CreateSignedTransaction( return tx } -func (r *E2ERunner) DeploySPL(privateKey *solana.PrivateKey) *solana.Wallet { +// FindOrCreateAssociatedTokenAccount checks if ata exists, and if not creates it +func (r *E2ERunner) FindOrCreateAssociatedTokenAccount( + payer solana.PrivateKey, + owner solana.PublicKey, + tokenAccount solana.PublicKey, +) solana.PublicKey { + pdaAta, _, err := solana.FindAssociatedTokenAddress(owner, tokenAccount) + require.NoError(r, err) + + info, _ := r.SolanaClient.GetAccountInfo(r.Ctx, pdaAta) + if info != nil { + // already exists + return pdaAta + } + // doesn't exist, create it + ataInstruction := associatedtokenaccount.NewCreateInstruction(payer.PublicKey(), owner, tokenAccount).Build() + signedTx := r.CreateSignedTransaction( + []solana.Instruction{ataInstruction}, + payer, + []solana.PrivateKey{}, + ) + // broadcast the transaction and wait for finalization + r.BroadcastTxSync(signedTx) + + return pdaAta +} + +// SPLDepositAndCall deposits an amount of SPL tokens and calls a contract (if data is provided) +func (r *E2ERunner) SPLDepositAndCall( + privateKey *solana.PrivateKey, + amount uint64, + tokenAccount solana.PublicKey, + receiver ethcommon.Address, + data []byte, +) solana.Signature { + // ata for pda + pda := r.ComputePdaAddress() + pdaAta := r.FindOrCreateAssociatedTokenAccount(*privateKey, pda, tokenAccount) + + // deployer ata + ata := r.FindOrCreateAssociatedTokenAccount(*privateKey, privateKey.PublicKey(), tokenAccount) + + // deposit spl + seed := [][]byte{[]byte("whitelist"), tokenAccount.Bytes()} + whitelistEntryPDA, _, err := solana.FindProgramAddress(seed, r.GatewayProgram) + require.NoError(r, err) + + depositSPLInstruction := r.CreateDepositSPLInstruction( + amount, + privateKey.PublicKey(), + whitelistEntryPDA, + tokenAccount, + ata, + pdaAta, + receiver, + data, + ) + signedTx := r.CreateSignedTransaction( + []solana.Instruction{depositSPLInstruction}, + *privateKey, + []solana.PrivateKey{}, + ) + // broadcast the transaction and wait for finalization + sig, out := r.BroadcastTxSync(signedTx) + r.Logger.Info("deposit spl logs: %v", out.Meta.LogMessages) + + return sig +} + +func (r *E2ERunner) DeploySPL(privateKey *solana.PrivateKey, whitelist bool) *solana.Wallet { lamport, err := r.SolanaClient.GetMinimumBalanceForRentExemption(r.Ctx, token.MINT_SIZE, rpc.CommitmentFinalized) require.NoError(r, err) @@ -128,6 +247,53 @@ func (r *E2ERunner) DeploySPL(privateKey *solana.PrivateKey) *solana.Wallet { _, out := r.BroadcastTxSync(signedTx) r.Logger.Info("create spl logs: %v", out.Meta.LogMessages) + // minting some tokens to deployer for testing + ata := r.FindOrCreateAssociatedTokenAccount(*privateKey, privateKey.PublicKey(), tokenAccount.PublicKey()) + + mintToInstruction := token.NewMintToInstruction(uint64(1_000_000), tokenAccount.PublicKey(), ata, privateKey.PublicKey(), []solana.PublicKey{}). + Build() + signedTx = r.CreateSignedTransaction( + []solana.Instruction{mintToInstruction}, + *privateKey, + []solana.PrivateKey{}, + ) + + // broadcast the transaction and wait for finalization + _, out = r.BroadcastTxSync(signedTx) + r.Logger.Info("mint spl logs: %v", out.Meta.LogMessages) + + // optionally whitelist spl token in gateway + if whitelist { + seed := [][]byte{[]byte("whitelist"), tokenAccount.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) + + // already whitelisted + if whitelistEntryInfo != nil { + return tokenAccount + } + + // create 'whitelist_spl_mint' instruction + instruction := r.CreateWhitelistSPLMintInstruction( + privateKey.PublicKey(), + whitelistEntryPDA, + tokenAccount.PublicKey(), + ) + // create and sign the transaction + signedTx := r.CreateSignedTransaction([]solana.Instruction{instruction}, *privateKey, []solana.PrivateKey{}) + + // broadcast the transaction and wait for finalization + _, out := r.BroadcastTxSync(signedTx) + r.Logger.Info("whitelist spl mint logs: %v", out.Meta.LogMessages) + + whitelistEntryInfo, err = r.SolanaClient.GetAccountInfo(r.Ctx, whitelistEntryPDA) + require.NoError(r, err) + require.NotNil(r, whitelistEntryInfo) + } + return tokenAccount } diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index 9b6e2d0b65..146dda4cf2 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -33,6 +33,8 @@ import ( stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" upgradetypes "github.com/cosmos/cosmos-sdk/x/upgrade/types" "github.com/cosmos/gogoproto/proto" + "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" "github.com/samber/lo" "github.com/zeta-chain/ethermint/crypto/hd" etherminttypes "github.com/zeta-chain/ethermint/types" @@ -56,6 +58,18 @@ type SystemContractAddresses struct { UniswapV2FactoryAddr, UniswapV2RouterAddr, ZEVMConnectorAddr, WZETAAddr, ERC20zrc20Addr string } +// ZRC20Deployment configures deployment of ZRC20 contracts +type ZRC20Deployment struct { + ERC20Addr common.Address + SPLAddr *solana.PublicKey // if nil - no SPL ZRC20 is deployed +} + +// ZRC20Addresses contains the addresses of deployed ZRC20 contracts +type ZRC20Addresses struct { + ERC20ZRC20Addr common.Address + SPLZRC20Addr common.Address +} + // EmissionsPoolAddress is the address of the emissions pool // This address is constant for all networks because it is derived from emissions name const EmissionsPoolAddress = "zeta1w43fn2ze2wyhu5hfmegr6vp52c3dgn0srdgymy" @@ -381,40 +395,40 @@ func (zts ZetaTxServer) DeploySystemContracts( } // DeployZRC20s deploys the ZRC20 contracts -// returns the addresses of erc20 zrc20 +// returns the addresses of erc20 and spl zrc20 func (zts ZetaTxServer) DeployZRC20s( - accountOperational, accountAdmin, erc20Addr string, + zrc20Deployment ZRC20Deployment, skipChain func(chainID int64) bool, -) (string, error) { +) (*ZRC20Addresses, error) { // retrieve account - accOperational, err := zts.clientCtx.Keyring.Key(accountOperational) + accOperational, err := zts.clientCtx.Keyring.Key(utils.OperationalPolicyName) if err != nil { - return "", err + return nil, err } addrOperational, err := accOperational.GetAddress() if err != nil { - return "", err + return nil, err } - accAdmin, err := zts.clientCtx.Keyring.Key(accountAdmin) + accAdmin, err := zts.clientCtx.Keyring.Key(utils.AdminPolicyName) if err != nil { - return "", err + return nil, err } addrAdmin, err := accAdmin.GetAddress() if err != nil { - return "", err + return nil, err } // authorization for deploying new ZRC20 has changed from accountOperational to accountAdmin in v19 // we use this query to check the current authorization for the message // if pre v19 the query is not implement and authorization is operational - deployerAccount := accountAdmin + deployerAccount := utils.AdminPolicyName deployerAddr := addrAdmin.String() authorization, preV19, err := zts.fetchMessagePermissions(&fungibletypes.MsgDeployFungibleCoinZRC20{}) if err != nil { - return "", fmt.Errorf("failed to fetch message permissions: %s", err.Error()) + return nil, fmt.Errorf("failed to fetch message permissions: %s", err.Error()) } if preV19 || authorization == authoritytypes.PolicyType_groupOperational { - deployerAccount = accountOperational + deployerAccount = utils.OperationalPolicyName deployerAddr = addrOperational.String() } @@ -461,7 +475,7 @@ func (zts ZetaTxServer) DeployZRC20s( ), fungibletypes.NewMsgDeployFungibleCoinZRC20( deployerAddr, - erc20Addr, + zrc20Deployment.ERC20Addr.Hex(), chains.GoerliLocalnet.ChainId, 6, "USDT", @@ -471,6 +485,19 @@ func (zts ZetaTxServer) DeployZRC20s( ), } + if zrc20Deployment.SPLAddr != nil { + deployMsgs = append(deployMsgs, fungibletypes.NewMsgDeployFungibleCoinZRC20( + deployerAddr, + zrc20Deployment.SPLAddr.String(), + chains.SolanaLocalnet.ChainId, + 9, + "USDT", + "USDT", + coin.CoinType_ERC20, + 100000, + )) + } + // apply skipChain filter and convert to sdk.Msg deployMsgsIface := lo.FilterMap( deployMsgs, @@ -484,12 +511,12 @@ func (zts ZetaTxServer) DeployZRC20s( res, err := zts.BroadcastTx(deployerAccount, deployMsgsIface...) if err != nil { - return "", fmt.Errorf("deploy zrc20s: %w", err) + return nil, fmt.Errorf("deploy zrc20s: %w", err) } deployedEvents, ok := EventsOfType[*fungibletypes.EventZRC20Deployed](res.Events) if !ok { - return "", fmt.Errorf("no EventZRC20Deployed in %s", res.TxHash) + return nil, fmt.Errorf("no EventZRC20Deployed in %s", res.TxHash) } zrc20Addrs := lo.Map(deployedEvents, func(ev *fungibletypes.EventZRC20Deployed, _ int) string { @@ -498,7 +525,7 @@ func (zts ZetaTxServer) DeployZRC20s( err = zts.InitializeLiquidityCaps(zrc20Addrs...) if err != nil { - return "", fmt.Errorf("initialize liquidity cap: %w", err) + return nil, fmt.Errorf("initialize liquidity cap: %w", err) } // find erc20 zrc20 @@ -506,10 +533,26 @@ func (zts ZetaTxServer) DeployZRC20s( return ev.ChainId == chains.GoerliLocalnet.ChainId && ev.CoinType == coin.CoinType_ERC20 }) if !ok { - return "", fmt.Errorf("unable to find erc20 zrc20") + return nil, fmt.Errorf("unable to find erc20 zrc20") } - return erc20zrc20.Contract, nil + // find spl zrc20 + splzrc20Addr := common.Address{} + if zrc20Deployment.SPLAddr != nil { + splzrc20, ok := lo.Find(deployedEvents, func(ev *fungibletypes.EventZRC20Deployed) bool { + return ev.ChainId == chains.SolanaLocalnet.ChainId && ev.CoinType == coin.CoinType_ERC20 + }) + if !ok { + return nil, fmt.Errorf("unable to find spl zrc20") + } + + splzrc20Addr = common.HexToAddress(splzrc20.Contract) + } + + return &ZRC20Addresses{ + ERC20ZRC20Addr: common.HexToAddress(erc20zrc20.Contract), + SPLZRC20Addr: splzrc20Addr, + }, nil } // FundEmissionsPool funds the emissions pool with the given amount diff --git a/pkg/contracts/solana/gateway.go b/pkg/contracts/solana/gateway.go index a3adcf5eae..12e3a10aa8 100644 --- a/pkg/contracts/solana/gateway.go +++ b/pkg/contracts/solana/gateway.go @@ -16,7 +16,11 @@ const ( // AccountsNumberOfDeposit is the number of accounts required for Solana gateway deposit instruction // [signer, pda, system_program] - AccountsNumDeposit = 3 + accountsNumDeposit = 3 + + // AccountsNumberOfDeposit is the number of accounts required for Solana gateway deposit spl instruction + // [signer, pda, whitelist_entry, mint_account, token_program, from, to] + accountsNumberDepositSPL = 7 ) var ( diff --git a/pkg/contracts/solana/inbound.go b/pkg/contracts/solana/inbound.go new file mode 100644 index 0000000000..766f84aa58 --- /dev/null +++ b/pkg/contracts/solana/inbound.go @@ -0,0 +1,127 @@ +package solana + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +const ( + // MaxSignaturesPerTicker is the maximum number of signatures to process on a ticker + MaxSignaturesPerTicker = 100 +) + +type Deposit struct { + Sender string + Amount uint64 + Memo []byte + Slot uint64 + Asset string +} + +// ParseInboundAsDeposit tries to parse an instruction as a 'deposit'. +// It returns nil if the instruction can't be parsed as a 'deposit'. +func ParseInboundAsDeposit( + tx *solana.Transaction, + instructionIndex int, + slot uint64, +) (*Deposit, error) { + // get instruction by index + instruction := tx.Message.Instructions[instructionIndex] + + // try deserializing instruction as a 'deposit' + var inst DepositInstructionParams + err := borsh.Deserialize(&inst, instruction.Data) + if err != nil { + return nil, nil + } + + // check if the instruction is a deposit or not, if not, skip parsing + if inst.Discriminator != DiscriminatorDeposit { + return nil, nil + } + + // get the sender address (skip if unable to parse signer address) + sender, err := getSignerDeposit(tx, &instruction) + if err != nil { + return nil, err + } + + return &Deposit{ + Sender: sender, + Amount: inst.Amount, + Memo: inst.Memo, + Slot: slot, + Asset: "", // no asset for gas token SOL + }, nil +} + +// ParseInboundAsDepositSPL tries to parse an instruction as a 'deposit_spl_token'. +// It returns nil if the instruction can't be parsed as a 'deposit_spl_token'. +func ParseInboundAsDepositSPL( + tx *solana.Transaction, + instructionIndex int, + slot uint64, +) (*Deposit, error) { + // get instruction by index + instruction := tx.Message.Instructions[instructionIndex] + + // try deserializing instruction as a 'deposit_spl_token' + var inst DepositSPLInstructionParams + err := borsh.Deserialize(&inst, instruction.Data) + if err != nil { + return nil, nil + } + + // check if the instruction is a deposit spl or not, if not, skip parsing + if inst.Discriminator != DiscriminatorDepositSPL { + return nil, nil + } + + // get the sender and spl addresses + sender, spl, err := getSignerAndSPLFromDepositSPLAccounts(tx, &instruction) + if err != nil { + return nil, err + } + + return &Deposit{ + Sender: sender, + Amount: inst.Amount, + Memo: inst.Memo, + Slot: slot, + Asset: spl, + }, nil +} + +// GetSignerDeposit returns the signer address of the deposit instruction +// Note: solana-go is not able to parse the AccountMeta 'is_signer' ATM. This is a workaround. +func getSignerDeposit(tx *solana.Transaction, inst *solana.CompiledInstruction) (string, error) { + // there should be 3 accounts for a deposit instruction + if len(inst.Accounts) != accountsNumDeposit { + return "", fmt.Errorf("want %d accounts, got %d", accountsNumDeposit, len(inst.Accounts)) + } + + // sender is the signer account + return tx.Message.AccountKeys[0].String(), nil +} + +func getSignerAndSPLFromDepositSPLAccounts( + tx *solana.Transaction, + inst *solana.CompiledInstruction, +) (string, string, error) { + // there should be 7 accounts for a deposit spl instruction + if len(inst.Accounts) != accountsNumberDepositSPL { + return "", "", fmt.Errorf( + "want %d accounts, got %d", + accountsNumberDepositSPL, + len(inst.Accounts), + ) + } + + // the accounts are [signer, pda, whitelist_entry, mint_account, token_program, from, to] + signer := tx.Message.AccountKeys[0] + spl := tx.Message.AccountKeys[inst.Accounts[3]] + + return signer.String(), spl.String(), nil +} diff --git a/pkg/contracts/solana/inbound_test.go b/pkg/contracts/solana/inbound_test.go new file mode 100644 index 0000000000..00bb17f994 --- /dev/null +++ b/pkg/contracts/solana/inbound_test.go @@ -0,0 +1,105 @@ +package solana + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/testutil/sample" + "github.com/zeta-chain/node/zetaclient/testutils" +) + +func LoadObjectFromJSONFile(t *testing.T, obj interface{}, filename string) { + file, err := os.Open(filepath.Clean(filename)) + require.NoError(t, err) + defer file.Close() + + // read the struct from the file + decoder := json.NewDecoder(file) + err = decoder.Decode(&obj) + require.NoError(t, err) +} + +func LoadSolanaInboundTxResult( + t *testing.T, + txHash string, +) *rpc.GetTransactionResult { + txResult := &rpc.GetTransactionResult{} + LoadObjectFromJSONFile(t, txResult, fmt.Sprintf("testdata/%s.json", txHash)) + return txResult +} + +func Test_ParseInboundAsDeposit(t *testing.T) { + txHash := "MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j" + chain := chains.SolanaDevnet + + txResult := LoadSolanaInboundTxResult(t, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + require.NoError(t, err) + + // create observer + chainParams := sample.ChainParams(chain.ChainId) + chainParams.GatewayAddress = testutils.GatewayAddresses[chain.ChainId] + require.NoError(t, err) + + // expected result + sender := "AS48jKNQsDGkEdDvfwu1QpqjtqbCadrAq9nGXjFmdX3Z" + expectedDeposit := &Deposit{ + Sender: sender, + Amount: 100000, + Memo: []byte("0x7F8ae2ABb69A558CE6bAd546f25F0464D9e09e5B4955a3F38ff86ae92A914445099caa8eA2B9bA32"), + Slot: txResult.Slot, + Asset: "", + } + + t.Run("should parse inbound event deposit SOL", func(t *testing.T) { + deposit, err := ParseInboundAsDeposit(tx, 0, txResult.Slot) + require.NoError(t, err) + + // check result + require.EqualValues(t, expectedDeposit, deposit) + }) +} + +func Test_ParseInboundAsDepositSPL(t *testing.T) { + txHash := "aY8yLDze6nHSRi7L5REozKAZY1aAyPJ6TfibiqQL5JGwgSBkYux5z5JfXs5ed8LZqpXUy4VijoU3x15mBd66ZGE" + chain := chains.SolanaDevnet + + txResult := LoadSolanaInboundTxResult(t, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // create observer + chainParams := sample.ChainParams(chain.ChainId) + chainParams.GatewayAddress = testutils.GatewayAddresses[chain.ChainId] + + // expected result + // solana e2e deployer account + sender := "37yGiHAnLvWZUNVwu9esp74YQFqxU1qHCbABkDvRddUQ" + // solana e2e user evm account + expectedMemo, err := hex.DecodeString("103fd9224f00ce3013e95629e52dfc31d805d68d") + require.NoError(t, err) + expectedDeposit := &Deposit{ + Sender: sender, + Amount: 500000, + Memo: expectedMemo, + Slot: txResult.Slot, + Asset: "4GddKQ7baJpMyKna7bPPnhh7UQtpzfSGL1FgZ31hj4mp", // SPL address + } + + t.Run("should parse inbound event deposit SPL", func(t *testing.T) { + deposit, err := ParseInboundAsDepositSPL(tx, 0, txResult.Slot) + require.NoError(t, err) + + // check result + require.EqualValues(t, expectedDeposit, deposit) + }) +} diff --git a/pkg/contracts/solana/instruction.go b/pkg/contracts/solana/instruction.go index df5db0416b..65b6e6e4c3 100644 --- a/pkg/contracts/solana/instruction.go +++ b/pkg/contracts/solana/instruction.go @@ -34,6 +34,18 @@ type DepositInstructionParams struct { Memo []byte } +// DepositSPLInstructionParams contains the parameters for a gateway deposit spl instruction +type DepositSPLInstructionParams struct { + // Discriminator is the unique identifier for the deposit instruction + Discriminator [8]byte + + // Amount is the lamports amount for the deposit + Amount uint64 + + // Memo is the memo for the deposit + Memo []byte +} + // OutboundInstruction is the interface for all gateway outbound instructions type OutboundInstruction interface { // Signer returns the signer of the instruction diff --git a/pkg/contracts/solana/testdata/MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j.json b/pkg/contracts/solana/testdata/MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j.json new file mode 100644 index 0000000000..cf7edb3b81 --- /dev/null +++ b/pkg/contracts/solana/testdata/MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j.json @@ -0,0 +1,64 @@ +{ + "slot": 321701608, + "blockTime": 1724732369, + "transaction": { + "signatures": [ + "MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j" + ], + "message": { + "accountKeys": [ + "AS48jKNQsDGkEdDvfwu1QpqjtqbCadrAq9nGXjFmdX3Z", + "9dcAyYG4bawApZocwZSyJBi9Mynf5EuKAJfifXdfkqik", + "11111111111111111111111111111111", + "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" + ], + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 2 + }, + "recentBlockhash": "41txNvjedo2eu6aAofQfyLskAcgtrtgch9RpqnrKcv1a", + "instructions": [ + { + "programIdIndex": 3, + "accounts": [0, 1, 2], + "data": "4ALHYcAj3zFsNjmfeq7nDK1E8BsxRQRzhLjrqzmjYzL97Qkiz4rP1iQePmFAehfFEET7uczYLhhEVhtndBYNNm6ekHSkgsLzYDeSD2JSudHa6D5tqhVGjvXZ7qEouPiy9eptZfuYHE9X" + } + ] + } + }, + "meta": { + "err": null, + "fee": 5000, + "preBalances": [9999364000, 1001447680, 1, 1141440], + "postBalances": [9999259000, 1001547680, 1, 1141440], + "innerInstructions": [ + { + "index": 0, + "instructions": [ + { + "programIdIndex": 2, + "accounts": [0, 1], + "data": "3Bxs4ThwQbE4vyj5" + } + ] + } + ], + "preTokenBalances": [], + "postTokenBalances": [], + "logMessages": [ + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d invoke [1]", + "Program log: Instruction: Deposit", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program log: AS48jKNQsDGkEdDvfwu1QpqjtqbCadrAq9nGXjFmdX3Z deposits 100000 lamports to PDA", + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d consumed 17006 of 200000 compute units", + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d success" + ], + "status": { "Ok": null }, + "rewards": [], + "loadedAddresses": { "readonly": [], "writable": [] }, + "computeUnitsConsumed": 17006 + }, + "version": 0 +} diff --git a/pkg/contracts/solana/testdata/aY8yLDze6nHSRi7L5REozKAZY1aAyPJ6TfibiqQL5JGwgSBkYux5z5JfXs5ed8LZqpXUy4VijoU3x15mBd66ZGE.json b/pkg/contracts/solana/testdata/aY8yLDze6nHSRi7L5REozKAZY1aAyPJ6TfibiqQL5JGwgSBkYux5z5JfXs5ed8LZqpXUy4VijoU3x15mBd66ZGE.json new file mode 100644 index 0000000000..2fb146776b --- /dev/null +++ b/pkg/contracts/solana/testdata/aY8yLDze6nHSRi7L5REozKAZY1aAyPJ6TfibiqQL5JGwgSBkYux5z5JfXs5ed8LZqpXUy4VijoU3x15mBd66ZGE.json @@ -0,0 +1,93 @@ +{ + "slot": 539, + "blockTime": 1730986363, + "transaction": { + "signatures": [ + "aY8yLDze6nHSRi7L5REozKAZY1aAyPJ6TfibiqQL5JGwgSBkYux5z5JfXs5ed8LZqpXUy4VijoU3x15mBd66ZGE" + ], + "message": { + "accountKeys": [ + "37yGiHAnLvWZUNVwu9esp74YQFqxU1qHCbABkDvRddUQ", + "HX8BXQKVw1xpoyDXMr9ujyrrpSYRWcFZ4oB3fRHpM8V7", + "5EVPJV5hjwYGYko2pSykSdJG5ZfBbMEDhYci2PrqQmby", + "9dcAyYG4bawApZocwZSyJBi9Mynf5EuKAJfifXdfkqik", + "8vquQi8xNxxBTsohf1u8xQHYbo7Fv8BEWCJz63RJSaeE", + "4GddKQ7baJpMyKna7bPPnhh7UQtpzfSGL1FgZ31hj4mp", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" + ], + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 5 + }, + "recentBlockhash": "5ZB2NWKwp86t47ojCHPjRyd5vMKSWBdhxqDdtSZVPUj", + "instructions": [ + { + "programIdIndex": 7, + "accounts": [ + 0, + 3, + 4, + 5, + 6, + 1, + 2 + ], + "data": "5JndgWCNHDyr2qQccK9VM1NxJFrVbUDvG2hfAxRC5z6nPPAVPsj7q3A" + } + ] + } + }, + "meta": { + "err": null, + "fee": 5000, + "preBalances": [ + 99992030600, + 2039280, + 2039280, + 1447680, + 946560, + 1461600, + 929020800, + 1141440 + ], + "postBalances": [ + 99992025600, + 2039280, + 2039280, + 1447680, + 946560, + 1461600, + 929020800, + 1141440 + ], + "innerInstructions": [ + { + "index": 0, + "instructions": [] + } + ], + "preTokenBalances": [], + "postTokenBalances": [], + "logMessages": [ + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d invoke [1]", + "Program log: Instruction: DepositSplToken", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: Transfer Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 181038 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success Program log: deposit spl token successfully", + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d consumed 24017 of 200000 compute units", + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d success" + ], + "status": { + "Ok": null + }, + "rewards": [], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "computeUnitsConsumed": 274902869112 + }, + "version": 0 +} \ No newline at end of file diff --git a/x/fungible/keeper/foreign_coins.go b/x/fungible/keeper/foreign_coins.go index 4dd5ac72a4..23cd1b6375 100644 --- a/x/fungible/keeper/foreign_coins.go +++ b/x/fungible/keeper/foreign_coins.go @@ -5,7 +5,6 @@ import ( "github.com/cosmos/cosmos-sdk/store/prefix" sdk "github.com/cosmos/cosmos-sdk/types" - ethcommon "github.com/ethereum/go-ethereum/common" "github.com/zeta-chain/node/pkg/coin" "github.com/zeta-chain/node/x/fungible/types" @@ -110,15 +109,9 @@ func (k Keeper) GetGasCoinForForeignCoin(ctx sdk.Context, chainID int64) (types. // GetForeignCoinFromAsset returns the foreign coin for a given asset for a given chain func (k Keeper) GetForeignCoinFromAsset(ctx sdk.Context, asset string, chainID int64) (types.ForeignCoins, bool) { - if !ethcommon.IsHexAddress(asset) { - return types.ForeignCoins{}, false - } - assetAddr := ethcommon.HexToAddress(asset) - foreignCoinList := k.GetAllForeignCoinsForChain(ctx, chainID) for _, coin := range foreignCoinList { - coinAssetAddr := ethcommon.HexToAddress(coin.Asset) - if coinAssetAddr == assetAddr && coin.ForeignChainId == chainID { + if asset == coin.Asset && coin.ForeignChainId == chainID { return coin, true } } diff --git a/x/fungible/keeper/foreign_coins_test.go b/x/fungible/keeper/foreign_coins_test.go index d02c3a29c9..3ebc13f2e6 100644 --- a/x/fungible/keeper/foreign_coins_test.go +++ b/x/fungible/keeper/foreign_coins_test.go @@ -135,24 +135,6 @@ func TestKeeperGetForeignCoinFromAsset(t *testing.T) { fc, found = k.GetForeignCoinFromAsset(ctx, gasAsset, 3) require.False(t, found) }) - - t.Run("can get foreign coin with non-checksum address", func(t *testing.T) { - k, ctx, _, _ := keepertest.FungibleKeeper(t) - - setForeignCoins(ctx, k, - types.ForeignCoins{ - Zrc20ContractAddress: sample.EthAddress().String(), - Asset: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - ForeignChainId: 1, - CoinType: coin.CoinType_ERC20, - Name: "foo", - }, - ) - - fc, found := k.GetForeignCoinFromAsset(ctx, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 1) - require.True(t, found) - require.Equal(t, "foo", fc.Name) - }) } func TestKeeperGetAllForeignCoinMap(t *testing.T) { diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go index 4c93d95470..bd0e9a98b7 100644 --- a/zetaclient/chains/solana/observer/inbound.go +++ b/zetaclient/chains/solana/observer/inbound.go @@ -9,7 +9,6 @@ import ( cosmosmath "cosmossdk.io/math" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" - "github.com/near/borsh-go" "github.com/pkg/errors" "github.com/rs/zerolog" @@ -197,12 +196,24 @@ func (ob *Observer) FilterInboundEvents(txResult *rpc.GetTransactionResult) ([]* // try parsing the instruction as a 'deposit' if not seen yet if !seenDeposit { - event, err := ob.ParseInboundAsDeposit(tx, i, txResult.Slot) + deposit, err := solanacontracts.ParseInboundAsDeposit(tx, i, txResult.Slot) if err != nil { return nil, errors.Wrap(err, "error ParseInboundAsDeposit") - } else if event != nil { + } else if deposit != nil { seenDeposit = true - events = append(events, event) + events = append(events, &clienttypes.InboundEvent{ + SenderChainID: ob.Chain().ChainId, + Sender: deposit.Sender, + Receiver: deposit.Sender, // receiver is pulled out from memo + TxOrigin: deposit.Sender, + Amount: deposit.Amount, + Memo: deposit.Memo, + BlockNumber: deposit.Slot, // instead of using block, Solana explorer uses slot for indexing + TxHash: tx.Signatures[0].String(), + Index: 0, // hardcode to 0 for Solana, not a EVM smart contract call + CoinType: coin.CoinType_Gas, + Asset: deposit.Asset, + }) ob.Logger().Inbound.Info(). Msgf("FilterInboundEvents: deposit detected in sig %s instruction %d", tx.Signatures[0], i) } @@ -213,12 +224,24 @@ func (ob *Observer) FilterInboundEvents(txResult *rpc.GetTransactionResult) ([]* // try parsing the instruction as a 'deposit_spl_token' if not seen yet if !seenDepositSPL { - event, err := ob.ParseInboundAsDepositSPL(tx, i, txResult.Slot) + deposit, err := solanacontracts.ParseInboundAsDepositSPL(tx, i, txResult.Slot) if err != nil { return nil, errors.Wrap(err, "error ParseInboundAsDepositSPL") - } else if event != nil { + } else if deposit != nil { seenDepositSPL = true - events = append(events, event) + events = append(events, &clienttypes.InboundEvent{ + SenderChainID: ob.Chain().ChainId, + Sender: deposit.Sender, + Receiver: deposit.Sender, // receiver is pulled out from memo + TxOrigin: deposit.Sender, + Amount: deposit.Amount, + Memo: deposit.Memo, + BlockNumber: deposit.Slot, // instead of using block, Solana explorer uses slot for indexing + TxHash: tx.Signatures[0].String(), + Index: 0, // hardcode to 0 for Solana, not a EVM smart contract call + CoinType: coin.CoinType_ERC20, + Asset: deposit.Asset, + }) ob.Logger().Inbound.Info(). Msgf("FilterInboundEvents: SPL deposit detected in sig %s instruction %d", tx.Signatures[0], i) } @@ -262,100 +285,3 @@ func (ob *Observer) BuildInboundVoteMsgFromEvent(event *clienttypes.InboundEvent 0, // not a smart contract call ) } - -// ParseInboundAsDeposit tries to parse an instruction as a 'deposit'. -// It returns nil if the instruction can't be parsed as a 'deposit'. -func (ob *Observer) ParseInboundAsDeposit( - tx *solana.Transaction, - instructionIndex int, - slot uint64, -) (*clienttypes.InboundEvent, error) { - // get instruction by index - instruction := tx.Message.Instructions[instructionIndex] - - // try deserializing instruction as a 'deposit' - var inst solanacontracts.DepositInstructionParams - err := borsh.Deserialize(&inst, instruction.Data) - if err != nil { - return nil, nil - } - - // check if the instruction is a deposit or not - if inst.Discriminator != solanacontracts.DiscriminatorDeposit { - return nil, nil - } - - // get the sender address (skip if unable to parse signer address) - sender, err := ob.GetSignerDeposit(tx, &instruction) - if err != nil { - ob.Logger(). - Inbound.Err(err). - Msgf("unable to get signer for sig %s instruction %d", tx.Signatures[0], instructionIndex) - return nil, nil - } - - // build inbound event - event := &clienttypes.InboundEvent{ - SenderChainID: ob.Chain().ChainId, - Sender: sender, - Receiver: sender, - TxOrigin: sender, - Amount: inst.Amount, - Memo: inst.Memo, - BlockNumber: slot, // instead of using block, Solana explorer uses slot for indexing - TxHash: tx.Signatures[0].String(), - Index: 0, // hardcode to 0 for Solana, not a EVM smart contract call - CoinType: coin.CoinType_Gas, - Asset: "", // no asset for gas token SOL - } - - return event, nil -} - -// ParseInboundAsDepositSPL tries to parse an instruction as a 'deposit_spl_token'. -// It returns nil if the instruction can't be parsed as a 'deposit_spl_token'. -func (ob *Observer) ParseInboundAsDepositSPL( - _ *solana.Transaction, - _ int, - _ uint64, -) (*clienttypes.InboundEvent, error) { - // not implemented yet - return nil, nil -} - -// GetSignerDeposit returns the signer address of the deposit instruction -// Note: solana-go is not able to parse the AccountMeta 'is_signer' ATM. This is a workaround. -func (ob *Observer) GetSignerDeposit(tx *solana.Transaction, inst *solana.CompiledInstruction) (string, error) { - // there should be 3 accounts for a deposit instruction - if len(inst.Accounts) != solanacontracts.AccountsNumDeposit { - return "", fmt.Errorf("want %d accounts, got %d", solanacontracts.AccountsNumDeposit, len(inst.Accounts)) - } - - // the accounts are [signer, pda, system_program] - signerIndex, pdaIndex, systemIndex := -1, -1, -1 - - // try to find the indexes of all above accounts - for _, accIndex := range inst.Accounts { - // #nosec G701 always in range - accIndexInt := int(accIndex) - accKey := tx.Message.AccountKeys[accIndexInt] - - switch accKey { - case ob.pda: - pdaIndex = accIndexInt - case solana.SystemProgramID: - systemIndex = accIndexInt - default: - // the last remaining account is the signer - signerIndex = accIndexInt - } - } - - // all above accounts must be found - if signerIndex == -1 || pdaIndex == -1 || systemIndex == -1 { - return "", fmt.Errorf("invalid accounts for deposit instruction") - } - - // sender is the signer account - return tx.Message.AccountKeys[signerIndex].String(), nil -} diff --git a/zetaclient/chains/solana/observer/inbound_test.go b/zetaclient/chains/solana/observer/inbound_test.go index 577c10ee9a..c38d3ae281 100644 --- a/zetaclient/chains/solana/observer/inbound_test.go +++ b/zetaclient/chains/solana/observer/inbound_test.go @@ -163,47 +163,3 @@ func Test_BuildInboundVoteMsgFromEvent(t *testing.T) { require.Nil(t, msg) }) } - -func Test_ParseInboundAsDeposit(t *testing.T) { - // load archived inbound deposit tx result - // https://explorer.solana.com/tx/MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j?cluster=devnet - txHash := "MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j" - chain := chains.SolanaDevnet - - txResult := testutils.LoadSolanaInboundTxResult(t, TestDataDir, chain.ChainId, txHash, false) - tx, err := txResult.Transaction.GetTransaction() - require.NoError(t, err) - - database, err := db.NewFromSqliteInMemory(true) - require.NoError(t, err) - - // create observer - chainParams := sample.ChainParams(chain.ChainId) - chainParams.GatewayAddress = testutils.GatewayAddresses[chain.ChainId] - ob, err := observer.NewObserver(chain, nil, *chainParams, nil, nil, 60, database, base.DefaultLogger(), nil) - require.NoError(t, err) - - // expected result - sender := "AS48jKNQsDGkEdDvfwu1QpqjtqbCadrAq9nGXjFmdX3Z" - eventExpected := &clienttypes.InboundEvent{ - SenderChainID: chain.ChainId, - Sender: sender, - Receiver: sender, - TxOrigin: sender, - Amount: 100000, - Memo: []byte("0x7F8ae2ABb69A558CE6bAd546f25F0464D9e09e5B4955a3F38ff86ae92A914445099caa8eA2B9bA32"), - BlockNumber: txResult.Slot, - TxHash: txHash, - Index: 0, // not a EVM smart contract call - CoinType: coin.CoinType_Gas, - Asset: "", // no asset for gas token SOL - } - - t.Run("should parse inbound event deposit SOL", func(t *testing.T) { - event, err := ob.ParseInboundAsDeposit(tx, 0, txResult.Slot) - require.NoError(t, err) - - // check result - require.EqualValues(t, eventExpected, event) - }) -}