diff --git a/changelog.md b/changelog.md index ed257a98ab..0c3541c10e 100644 --- a/changelog.md +++ b/changelog.md @@ -52,6 +52,7 @@ * [3692](https://github.com/zeta-chain/node/pull/3692) - e2e staking test for `MsgUndelegate` tx, to test observer staking hooks * [3831](https://github.com/zeta-chain/node/pull/3831) - e2e tests for sui fungible token withdraw and call * [3582](https://github.com/zeta-chain/node/pull/3852) - add solana to tss migration e2e tests +* [3866](https://github.com/zeta-chain/node/pull/3866) - add e2e test for upgrading sui gateway package ### Refactor diff --git a/cmd/zetae2e/config/config.go b/cmd/zetae2e/config/config.go index 93ba238af6..e131c141a8 100644 --- a/cmd/zetae2e/config/config.go +++ b/cmd/zetae2e/config/config.go @@ -63,6 +63,7 @@ func ExportContractsFromRunner(r *runner.E2ERunner, conf config.Config) config.C conf.Contracts.Sui.GatewayPackageID = config.DoubleQuotedString(r.SuiGateway.PackageID()) conf.Contracts.Sui.GatewayObjectID = config.DoubleQuotedString(r.SuiGateway.ObjectID()) } + conf.Contracts.Sui.GatewayUpgradeCap = config.DoubleQuotedString(r.SuiGatewayUpgradeCap) conf.Contracts.Sui.FungibleTokenCoinType = config.DoubleQuotedString(r.SuiTokenCoinType) conf.Contracts.Sui.FungibleTokenTreasuryCap = config.DoubleQuotedString(r.SuiTokenTreasuryCap) diff --git a/cmd/zetae2e/config/contracts.go b/cmd/zetae2e/config/contracts.go index 5cf108cd1a..4570f26b62 100644 --- a/cmd/zetae2e/config/contracts.go +++ b/cmd/zetae2e/config/contracts.go @@ -130,6 +130,9 @@ func setContractsFromConfig(r *runner.E2ERunner, conf config.Config) error { if suiPackageID != "" && suiGatewayID != "" { r.SuiGateway = sui.NewGateway(suiPackageID.String(), suiGatewayID.String()) } + if c := conf.Contracts.Sui.GatewayUpgradeCap; c != "" { + r.SuiGatewayUpgradeCap = c.String() + } if c := conf.Contracts.Sui.FungibleTokenCoinType; c != "" { r.SuiTokenCoinType = c.String() } diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index dfb63a3579..0e6f80477f 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -14,7 +14,6 @@ import ( "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/stretchr/testify/require" zetae2econfig "github.com/zeta-chain/node/cmd/zetae2e/config" "github.com/zeta-chain/node/e2e/config" @@ -611,6 +610,12 @@ func localE2ETest(cmd *cobra.Command, _ []string) { noError(deployerRunner.WithdrawEmissions()) + // Run gateway upgrade tests for external chains + deployerRunner.RunGatewayUpgradeTestsExternalChains(conf, runner.UpgradeGatewayOptions{ + TestSolana: testSolana, + TestSui: testSui, + }) + // if all tests pass, cancel txs priority monitoring and check if tx priority is not correct in some blocks logger.Print("⏳ e2e tests passed, checking tx priority") monitorPriorityCancel() @@ -625,13 +630,6 @@ func localE2ETest(cmd *cobra.Command, _ []string) { logger.Print("✅ e2e tests completed in %s", time.Since(testStartTime).String()) - if testSolana { - require.True( - deployerRunner, - deployerRunner.VerifySolanaContractsUpgrade(conf.AdditionalAccounts.UserSolana.SolanaPrivateKey.String()), - ) - } - if testTSSMigration { TSSMigration(deployerRunner, logger, verbose, conf) } diff --git a/contrib/localnet/orchestrator/Dockerfile.fastbuild b/contrib/localnet/orchestrator/Dockerfile.fastbuild index b6539e94a9..554c0a7d72 100644 --- a/contrib/localnet/orchestrator/Dockerfile.fastbuild +++ b/contrib/localnet/orchestrator/Dockerfile.fastbuild @@ -2,16 +2,21 @@ # check=error=true FROM ghcr.io/zeta-chain/ethereum-client-go:v1.10.26 AS geth FROM ghcr.io/zeta-chain/solana-docker:1.18.15 AS solana +FROM ghcr.io/zeta-chain/sui-docker:mainnet-v1.41.1 AS sui FROM zetanode:latest COPY --from=geth /usr/local/bin/geth /usr/local/bin/ COPY --from=solana /usr/bin/solana /usr/local/bin/ +COPY --from=sui /usr/local/bin/sui /usr/local/bin/ COPY contrib/localnet/orchestrator/start-zetae2e.sh /work/ COPY contrib/localnet/orchestrator/proposals_e2e_start/ /work/proposals_e2e_start/ COPY contrib/localnet/orchestrator/proposals_e2e_end/ /work/proposals_e2e_end/ COPY contrib/localnet/scripts/wait-for-ton.sh /work/ +COPY contrib/localnet/sui/sui_client.yaml /root/.sui/sui_config/client.yaml +COPY e2e/contracts/sui/protocol-contracts-sui-upgrade /work/protocol-contracts-sui-upgrade COPY cmd/zetae2e/config/localnet.yml /work/config.yml + RUN chmod +x /work/*.sh WORKDIR /work diff --git a/contrib/localnet/sui/sui_client.yaml b/contrib/localnet/sui/sui_client.yaml new file mode 100644 index 0000000000..14b977eade --- /dev/null +++ b/contrib/localnet/sui/sui_client.yaml @@ -0,0 +1,9 @@ +--- +keystore: + File: /root/.sui/sui_config/sui.keystore +envs: + - alias: localnet + rpc: "http://sui:9000" + ws: ~ + basic_auth: ~ +active_env: localnet diff --git a/e2e/config/config.go b/e2e/config/config.go index e071d4f867..f988efa5ce 100644 --- a/e2e/config/config.go +++ b/e2e/config/config.go @@ -155,8 +155,10 @@ type SuiExample struct { // Sui contains the addresses of predeployed contracts on the Sui chain type Sui struct { - GatewayPackageID DoubleQuotedString `yaml:"gateway_package_id"` - GatewayObjectID DoubleQuotedString `yaml:"gateway_object_id"` + GatewayPackageID DoubleQuotedString `yaml:"gateway_package_id"` + GatewayObjectID DoubleQuotedString `yaml:"gateway_object_id"` + // GatewayUpgradeCap is the capability object used to upgrade the gateway + GatewayUpgradeCap DoubleQuotedString `yaml:"gateway_upgrade_cap"` FungibleTokenCoinType DoubleQuotedString `yaml:"fungible_token_coin_type"` FungibleTokenTreasuryCap DoubleQuotedString `yaml:"fungible_token_treasury_cap"` Example SuiExample `yaml:"example"` diff --git a/e2e/contracts/sui/gateway.mv b/e2e/contracts/sui/gateway.mv index 71af98d961..65a9832be0 100644 Binary files a/e2e/contracts/sui/gateway.mv and b/e2e/contracts/sui/gateway.mv differ diff --git a/e2e/contracts/sui/protocol-contracts-sui-upgrade/.gitignore b/e2e/contracts/sui/protocol-contracts-sui-upgrade/.gitignore new file mode 100644 index 0000000000..4862a3d148 --- /dev/null +++ b/e2e/contracts/sui/protocol-contracts-sui-upgrade/.gitignore @@ -0,0 +1,8 @@ +.idea +.vscode + +build/ +source/dependencies +Move.lock +*.key +.DS_Store diff --git a/e2e/contracts/sui/protocol-contracts-sui-upgrade/Move.toml b/e2e/contracts/sui/protocol-contracts-sui-upgrade/Move.toml new file mode 100644 index 0000000000..93b3682d01 --- /dev/null +++ b/e2e/contracts/sui/protocol-contracts-sui-upgrade/Move.toml @@ -0,0 +1,38 @@ +[package] +name = "gateway" +edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move +published-at = "ORIGINAL-PACKAGE-ID" +# license = "" # e.g., "MIT", "GPL", "Apache 2.0" +# authors = ["..."] # e.g., ["Joe Smith (joesmith@noemail.com)", "John Snow (johnsnow@noemail.com)"] + +[dependencies] +Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet" } + +# For remote import, use the `{ git = "...", subdir = "...", rev = "..." }`. +# Revision can be a branch, a tag, and a commit hash. +# MyRemotePackage = { git = "https://some.remote/host.git", subdir = "remote/path", rev = "main" } + +# For local dependencies use `local = path`. Path is relative to the package root +# Local = { local = "../path/to" } + +# To resolve a version conflict and force a specific version for dependency +# override use `override = true` +# Override = { local = "../conflicting/version", override = true } + +[addresses] +gateway = "0x0" + +# Named addresses will be accessible in Move as `@name`. They're also exported: +# for example, `std = "0x1"` is exported by the Standard Library. +# alice = "0xA11CE" + +[dev-dependencies] +# The dev-dependencies section allows overriding dependencies for `--test` and +# `--dev` modes. You can introduce test-only dependencies here. +# Local = { local = "../path/to/dev-build" } + +[dev-addresses] +# The dev-addresses section allows overwriting named addresses for the `--test` +# and `--dev` modes. +# alice = "0xB0B" + diff --git a/e2e/contracts/sui/protocol-contracts-sui-upgrade/README.md b/e2e/contracts/sui/protocol-contracts-sui-upgrade/README.md new file mode 100644 index 0000000000..293412f038 --- /dev/null +++ b/e2e/contracts/sui/protocol-contracts-sui-upgrade/README.md @@ -0,0 +1,16 @@ +# Zetachain x Sui Gateway + +This package is only used for gateway upgrade test. + +The `sui client upgrade` command requires the presence of the whole `gateway` package for re-publishing, +so we have a minimized copy of sui gateway project in here to help the upgrading process. + + +The source code is copied from [protocol-contracts-sui](https://github.com/zeta-chain/protocol-contracts-sui) with one single additional function added: + +``` +// upgraded returns true to indicate gateway has been upgraded +entry fun upgraded(): bool { + true +} +``` \ No newline at end of file diff --git a/e2e/contracts/sui/protocol-contracts-sui-upgrade/sources/evm.move b/e2e/contracts/sui/protocol-contracts-sui-upgrade/sources/evm.move new file mode 100644 index 0000000000..f9847971b7 --- /dev/null +++ b/e2e/contracts/sui/protocol-contracts-sui-upgrade/sources/evm.move @@ -0,0 +1,46 @@ +module gateway::evm; + +use std::ascii::{String, into_bytes}; + +/// Check if a given string is a valid Ethereum address. +public fun is_valid_evm_address(addr: String): bool { + if (addr.length() != 42) { + return false + }; + + let mut addrBytes = addr.into_bytes(); + + // check prefix 0x, 0=48, x=120 + if (addrBytes[0] != 48 || addrBytes[1] != 120) { + return false + }; + + // remove 0x prefix + addrBytes.remove(0); + addrBytes.remove(0); + + // check if remaining characters are hex (0-9, a-f, A-F) + is_hex_vec(addrBytes) +} + +/// Check that vector contains only hex chars (0-9, a-f, A-F). +fun is_hex_vec(input: vector): bool { + let mut i = 0; + let len = input.length(); + + while (i < len) { + let c = input[i]; + + let is_hex = (c >= 48 && c <= 57) || // '0' to '9' + (c >= 97 && c <= 102) || // 'a' to 'f' + (c >= 65 && c <= 70); // 'A' to 'F' + + if (!is_hex) { + return false + }; + + i = i + 1; + }; + + true +} diff --git a/e2e/contracts/sui/protocol-contracts-sui-upgrade/sources/gateway.move b/e2e/contracts/sui/protocol-contracts-sui-upgrade/sources/gateway.move new file mode 100644 index 0000000000..5397aca4e1 --- /dev/null +++ b/e2e/contracts/sui/protocol-contracts-sui-upgrade/sources/gateway.move @@ -0,0 +1,417 @@ +module gateway::gateway; + +use gateway::evm; +use std::ascii::String; +use std::type_name::{get, into_string}; +use sui::bag::{Self, Bag}; +use sui::balance::{Self, Balance}; +use sui::coin::{Self, Coin}; +use sui::event; +use sui::sui::SUI; + +// === Errors === + +const EAlreadyWhitelisted: u64 = 0; +const EInvalidReceiverAddress: u64 = 1; +const ENotWhitelisted: u64 = 2; +const ENonceMismatch: u64 = 3; +const EPayloadTooLong: u64 = 4; +const EInactiveWithdrawCap: u64 = 5; +const EInactiveWhitelistCap: u64 = 6; +const EDepositPaused: u64 = 7; + +const PayloadMaxLength: u64 = 1024; + +// === Structs === + +// Vault stores the balance of a specific coin type +public struct Vault has store { + balance: Balance, + whitelisted: bool, +} + +// Gateway stores the vaults and the nonce for withdrawals +public struct Gateway has key { + id: UID, + vaults: Bag, + nonce: u64, + active_withdraw_cap: ID, + active_whitelist_cap: ID, + deposit_paused: bool, +} + +// WithdrawCap is a capability object that allows the caller to withdraw tokens from the gateway +public struct WithdrawCap has key, store { + id: UID, +} + +// WhitelistCap is a capability object that allows the caller to whitelist a new vault +public struct WhitelistCap has key, store { + id: UID, +} + +// AdminCap is a capability object that allows to issue new capabilities +public struct AdminCap has key, store { + id: UID, +} + +// === Events === + +// DepositEvent is emitted when a user deposits tokens into the gateway +public struct DepositEvent has copy, drop { + coin_type: String, + amount: u64, + sender: address, + receiver: String, // 0x hex address +} + +// DepositAndCallEvent is emitted when a user deposits tokens into the gateway with a call +public struct DepositAndCallEvent has copy, drop { + coin_type: String, + amount: u64, + sender: address, + receiver: String, // 0x hex address + payload: vector, +} + +public struct WithdrawEvent has copy, drop { + coin_type: String, + amount: u64, + sender: address, + receiver: address, + nonce: u64, +} + +public struct NonceIncreaseEvent has copy, drop { + sender: address, + nonce: u64, +} + +// === Initialization === + +fun init(ctx: &mut TxContext) { + // to withdraw tokens from the gateway, the caller must have the WithdrawCap + let withdraw_cap = WithdrawCap { + id: object::new(ctx), + }; + + // to whitelist a new vault, the caller must have the WhitelistCap + let whitelist_cap = WhitelistCap { + id: object::new(ctx), + }; + + // to whitelist a new vault, the caller must have the AdminCap + let admin_cap = AdminCap { + id: object::new(ctx), + }; + + // create and share the gateway object + let mut gateway = Gateway { + id: object::new(ctx), + vaults: bag::new(ctx), + nonce: 0, + active_withdraw_cap: object::id(&withdraw_cap), + active_whitelist_cap: object::id(&whitelist_cap), + deposit_paused: false, + }; + + // whitelist SUI by default + whitelist_impl(&mut gateway, &whitelist_cap); + + transfer::transfer(withdraw_cap, tx_context::sender(ctx)); + transfer::transfer(whitelist_cap, tx_context::sender(ctx)); + transfer::transfer(admin_cap, tx_context::sender(ctx)); + transfer::share_object(gateway); +} + +// === Entrypoints === + +// upgraded returns true to indicate gateway has been upgraded +entry fun upgraded(): bool { + true +} + +// increase_nonce increases the nonce of the gateway +// it is used when a failed outbound needs to be reported to ZetaChain +// it is sent by the tss and therefore requires the withdraw cap +entry fun increase_nonce(gateway: &mut Gateway, nonce: u64, cap: &WithdrawCap, ctx: &TxContext) { + assert!(gateway.active_withdraw_cap == object::id(cap), EInactiveWithdrawCap); + assert!(nonce == gateway.nonce, ENonceMismatch); + gateway.nonce = nonce + 1; + + // Emit event + event::emit(NonceIncreaseEvent { + sender: tx_context::sender(ctx), + nonce: gateway.nonce, + }); +} + +// withdraw allows the TSS to withdraw tokens from the gateway +entry fun withdraw( + gateway: &mut Gateway, + amount: u64, + nonce: u64, + receiver: address, + gas_budget: u64, + cap: &WithdrawCap, + ctx: &mut TxContext, +) { + let (coins, coins_gas_budget) = withdraw_impl(gateway, amount, nonce, gas_budget, cap, ctx); + + transfer::public_transfer(coins, receiver); + transfer::public_transfer(coins_gas_budget, tx_context::sender(ctx)); + + // Emit event + event::emit(WithdrawEvent { + coin_type: coin_name(), + amount: amount, + sender: tx_context::sender(ctx), + receiver: receiver, + nonce: nonce, + }); +} + +// whitelist whitelists a new coin by creating a new vault for the coin type +entry fun whitelist(gateway: &mut Gateway, cap: &WhitelistCap) { + whitelist_impl(gateway, cap) +} + +// unwhitelist unwhitelists a coin by setting the whitelisted flag to false +entry fun unwhitelist(gateway: &mut Gateway, cap: &AdminCap) { + unwhitelist_impl(gateway, cap) +} + +// issue_withdraw_and_whitelist_cap issues a new WithdrawCap and WhitelistCap and revokes the old ones +entry fun issue_withdraw_and_whitelist_cap( + gateway: &mut Gateway, + _cap: &AdminCap, + ctx: &mut TxContext, +) { + let (withdraw_cap, whitelist_cap) = issue_withdraw_and_whitelist_cap_impl(gateway, _cap, ctx); + transfer::transfer(withdraw_cap, tx_context::sender(ctx)); + transfer::transfer(whitelist_cap, tx_context::sender(ctx)); +} + +// pause pauses the deposit functionality +entry fun pause(gateway: &mut Gateway, cap: &AdminCap) { + pause_impl(gateway, cap) +} + +// unpause unpauses the deposit functionality +entry fun unpause(gateway: &mut Gateway, cap: &AdminCap) { + unpause_impl(gateway, cap) +} + +// reset_nonce resets the nonce to a new value in case it gets corrupted +entry fun reset_nonce(gateway: &mut Gateway, nonce: u64, _cap: &AdminCap) { + gateway.nonce = nonce; +} + +// === Deposit Functions === + +// deposit allows the user to deposit tokens into the gateway +public entry fun deposit( + gateway: &mut Gateway, + coins: Coin, + receiver: String, + ctx: &mut TxContext, +) { + let amount = coins.value(); + let coin_name = coin_name(); + + check_receiver_and_deposit_to_vault(gateway, coins, receiver); + + // Emit deposit event + event::emit(DepositEvent { + coin_type: coin_name, + amount: amount, + sender: tx_context::sender(ctx), + receiver: receiver, + }); +} + +// deposit_and_call allows the user to deposit tokens into the gateway and call a contract +public entry fun deposit_and_call( + gateway: &mut Gateway, + coins: Coin, + receiver: String, + payload: vector, + ctx: &mut TxContext, +) { + assert!(payload.length() <= PayloadMaxLength, EPayloadTooLong); + + let amount = coins.value(); + let coin_name = coin_name(); + + check_receiver_and_deposit_to_vault(gateway, coins, receiver); + + // Emit deposit event + event::emit(DepositAndCallEvent { + coin_type: coin_name, + amount: amount, + sender: tx_context::sender(ctx), + receiver: receiver, + payload: payload, + }); +} + +// check_receiver_and_deposit_to_vault is a helper function that checks the receiver address and deposits the coin +fun check_receiver_and_deposit_to_vault( + gateway: &mut Gateway, + coins: Coin, + receiver: String, +) { + assert!(evm::is_valid_evm_address(receiver), EInvalidReceiverAddress); + assert!(is_whitelisted(gateway), ENotWhitelisted); + assert!(!gateway.deposit_paused, EDepositPaused); + + // Deposit the coin into the vault + let coin_name = coin_name(); + let vault = bag::borrow_mut>(&mut gateway.vaults, coin_name); + balance::join(&mut vault.balance, coins.into_balance()); +} + +// === Withdraw Functions === + +public fun withdraw_impl( + gateway: &mut Gateway, + amount: u64, + nonce: u64, + gas_budget: u64, + cap: &WithdrawCap, + ctx: &mut TxContext, +): (Coin, Coin) { + assert!(gateway.active_withdraw_cap == object::id(cap), EInactiveWithdrawCap); + assert!(is_whitelisted(gateway), ENotWhitelisted); + assert!(nonce == gateway.nonce, ENonceMismatch); // prevent replay + gateway.nonce = nonce + 1; + + // Withdraw the coin from the vault + let coin_name = coin_name(); + let vault = bag::borrow_mut>(&mut gateway.vaults, coin_name); + let coins_out = coin::take(&mut vault.balance, amount, ctx); + + // Withdraw SUI to cover the gas budget + let sui_vault = bag::borrow_mut>( + &mut gateway.vaults, + coin_name(), + ); + let coins_gas_budget = coin::take(&mut sui_vault.balance, gas_budget, ctx); + + (coins_out, coins_gas_budget) +} + +// === Admin Functions === + +public fun whitelist_impl(gateway: &mut Gateway, cap: &WhitelistCap) { + assert!(gateway.active_whitelist_cap == object::id(cap), EInactiveWhitelistCap); + assert!(is_whitelisted(gateway) == false, EAlreadyWhitelisted); + + // if the vault already exists, set it to whitelisted, otherwise create a new vault + if (bag::contains_with_type>(&gateway.vaults, coin_name())) { + let vault = bag::borrow_mut>(&mut gateway.vaults, coin_name()); + vault.whitelisted = true; + } else { + let vault_name = coin_name(); + let vault = Vault { + balance: balance::zero(), + whitelisted: true, + }; + bag::add(&mut gateway.vaults, vault_name, vault); + } +} + +public fun unwhitelist_impl(gateway: &mut Gateway, _cap: &AdminCap) { + assert!(is_whitelisted(gateway), ENotWhitelisted); + let vault = bag::borrow_mut>(&mut gateway.vaults, coin_name()); + vault.whitelisted = false; +} + +public fun issue_withdraw_and_whitelist_cap_impl( + gateway: &mut Gateway, + _cap: &AdminCap, + ctx: &mut TxContext, +): (WithdrawCap, WhitelistCap) { + let withdraw_cap = WithdrawCap { + id: object::new(ctx), + }; + let whitelist_cap = WhitelistCap { + id: object::new(ctx), + }; + gateway.active_withdraw_cap = object::id(&withdraw_cap); + gateway.active_whitelist_cap = object::id(&whitelist_cap); + (withdraw_cap, whitelist_cap) +} + +public fun pause_impl(gateway: &mut Gateway, _cap: &AdminCap) { + gateway.deposit_paused = true; +} + +public fun unpause_impl(gateway: &mut Gateway, _cap: &AdminCap) { + gateway.deposit_paused = false; +} + +// === View Functions === + +public fun nonce(gateway: &Gateway): u64 { + gateway.nonce +} + +public fun active_withdraw_cap(gateway: &Gateway): ID { + gateway.active_withdraw_cap +} + +public fun active_whitelist_cap(gateway: &Gateway): ID { + gateway.active_whitelist_cap +} + +public fun vault_balance(gateway: &Gateway): u64 { + if (!is_whitelisted(gateway)) { + return 0 + }; + let coin_name = coin_name(); + let vault = bag::borrow>(&gateway.vaults, coin_name); + balance::value(&vault.balance) +} + +public fun is_paused(gateway: &Gateway): bool { + gateway.deposit_paused +} + +// is_whitelisted returns true if a given coin type is whitelisted +public fun is_whitelisted(gateway: &Gateway): bool { + let vault_name = coin_name(); + if (!bag::contains_with_type>(&gateway.vaults, vault_name)) { + return false + }; + let vault = bag::borrow>(&gateway.vaults, vault_name); + vault.whitelisted +} + +// === Helpers === + +// coin_name returns the name of the coin type to index the vault +fun coin_name(): String { + into_string(get()) +} + +// === Test Helpers === + +#[test_only] +public fun init_for_testing(ctx: &mut TxContext) { + init(ctx) +} + +#[test_only] +public fun create_test_withdraw_cap(ctx: &mut TxContext): WithdrawCap { + WithdrawCap { + id: object::new(ctx), + } +} + +#[test_only] +public fun create_test_whitelist_cap(ctx: &mut TxContext): WhitelistCap { + WhitelistCap { + id: object::new(ctx), + } +} diff --git a/e2e/runner/runner.go b/e2e/runner/runner.go index eae03218d1..56507755b3 100644 --- a/e2e/runner/runner.go +++ b/e2e/runner/runner.go @@ -127,6 +127,9 @@ type E2ERunner struct { // contract Sui SuiGateway *sui.Gateway + // SuiGatewayUpgradeCap is the upgrade cap used for upgrading the Sui gateway package + SuiGatewayUpgradeCap string + // SuiTokenCoinType is the coin type identifying the fungible token for SUI SuiTokenCoinType string @@ -289,6 +292,7 @@ func (r *E2ERunner) CopyAddressesFrom(other *E2ERunner) (err error) { r.TONGateway = other.TONGateway r.SuiGateway = other.SuiGateway + r.SuiGatewayUpgradeCap = other.SuiGatewayUpgradeCap r.SuiTokenCoinType = other.SuiTokenCoinType r.SuiTokenTreasuryCap = other.SuiTokenTreasuryCap r.SuiExample = other.SuiExample @@ -425,6 +429,7 @@ func (r *E2ERunner) PrintContractAddresses() { if r.SuiGateway != nil { r.Logger.Print("GatewayPackageID: %s", r.SuiGateway.PackageID()) r.Logger.Print("GatewayObjectID: %s", r.SuiGateway.ObjectID()) + r.Logger.Print("GatewayUpgradeCap: %s", r.SuiGatewayUpgradeCap) } else { r.Logger.Print("💤 Sui tests disabled") } diff --git a/e2e/runner/setup_sui.go b/e2e/runner/setup_sui.go index f06cf44018..65f3726f1e 100644 --- a/e2e/runner/setup_sui.go +++ b/e2e/runner/setup_sui.go @@ -2,6 +2,7 @@ package runner import ( "fmt" + "os/exec" "strings" "time" @@ -45,6 +46,9 @@ func (r *E2ERunner) SetupSui(faucetURL string) { // fund deployer r.RequestSuiFromFaucet(faucetURL, deployerAddress) + // import deployer private key and select it as active address + r.suiSetupDeployerAccount() + // fund the TSS // request twice from the faucet to ensure TSS has enough funds for the first withdraw // TODO: this step might no longer necessary if a custom solution is implemented for the TSS funding @@ -54,6 +58,9 @@ func (r *E2ERunner) SetupSui(faucetURL string) { // deploy gateway package whitelistCapID, withdrawCapID := r.suiDeployGateway() + // update gateway package ID in Move.toml + r.suiPatchMoveConfig() + // deploy SUI zrc20 r.deploySUIZRC20() @@ -72,15 +79,54 @@ func (r *E2ERunner) SetupSui(faucetURL string) { require.NoError(r, err) } +// suiSetupDeployerAccount imports a Sui deployer private key using the sui keytool import command +// and sets the deployer address as the active address. +func (r *E2ERunner) suiSetupDeployerAccount() { + deployerSigner, err := r.Account.SuiSigner() + require.NoError(r, err, "unable to get deployer signer") + + var ( + deployerAddress = deployerSigner.Address() + deployerPrivKeyHex = r.Account.RawPrivateKey.String() + ) + + // convert private key to bech32 + deployerPrivKeySecp256k1, err := zetasui.PrivateKeyBech32Secp256k1FromHex(deployerPrivKeyHex) + require.NoError(r, err) + + // import deployer private key using sui keytool import + // #nosec G204, inputs are controlled in E2E test + cmdImport := exec.Command("sui", "keytool", "import", deployerPrivKeySecp256k1, "secp256k1") + require.NoError(r, cmdImport.Run(), "unable to import sui deployer private key") + + // switch to deployer address using sui client switch + // #nosec G204, inputs are controlled in E2E test + cmdSwitch := exec.Command("sui", "client", "switch", "--address", deployerAddress) + require.NoError(r, cmdSwitch.Run(), "unable to switch to deployer address") + + // ensure the deployer address is active + // #nosec G204, inputs are controlled in E2E test + cmdList := exec.Command("sui", "client", "active-address") + output, err := cmdList.Output() + require.NoError(r, err) + require.Equal(r, deployerAddress, strings.TrimSpace(string(output))) +} + // suiDeployGateway deploys the SUI gateway package on Sui func (r *E2ERunner) suiDeployGateway() (whitelistCapID, withdrawCapID string) { const ( filterGatewayType = "gateway::Gateway" filterWithdrawCapType = "gateway::WithdrawCap" filterWhitelistCapType = "gateway::WhitelistCap" + filterUpgradeCapType = "0x2::package::UpgradeCap" ) - objectTypeFilters := []string{filterGatewayType, filterWhitelistCapType, filterWithdrawCapType} + objectTypeFilters := []string{ + filterGatewayType, + filterWhitelistCapType, + filterWithdrawCapType, + filterUpgradeCapType, + } packageID, objectIDs := r.suiDeployPackage( []string{suicontract.GatewayBytecodeBase64(), suicontract.EVMBytecodeBase64()}, objectTypeFilters, @@ -95,6 +141,9 @@ func (r *E2ERunner) suiDeployGateway() (whitelistCapID, withdrawCapID string) { withdrawCapID, ok = objectIDs[filterWithdrawCapType] require.True(r, ok, "withdrawCap object not found") + r.SuiGatewayUpgradeCap, ok = objectIDs[filterUpgradeCapType] + require.True(r, ok, "upgradeCap object not found") + // set sui gateway r.SuiGateway = zetasui.NewGateway(packageID, gatewayID) diff --git a/e2e/runner/contract_upgrade_solana.go b/e2e/runner/solana_gateway_upgrade.go similarity index 90% rename from e2e/runner/contract_upgrade_solana.go rename to e2e/runner/solana_gateway_upgrade.go index 9daeec001c..2bfa7b1d53 100644 --- a/e2e/runner/contract_upgrade_solana.go +++ b/e2e/runner/solana_gateway_upgrade.go @@ -16,8 +16,10 @@ import ( solanacontracts "github.com/zeta-chain/node/pkg/contracts/solana" ) -// VerifySolanaContractsUpgrade checks if the Solana contracts are upgraded -func (r *E2ERunner) VerifySolanaContractsUpgrade(deployerPrivateKey string) bool { +// SolanaVerifyGatewayContractsUpgrade upgrades the Solana contracts and verifies the upgrade +func (r *E2ERunner) SolanaVerifyGatewayContractsUpgrade(deployerPrivateKey string) { + r.Logger.Print("🏃 Upgrading Solana gateway contracts") + pdaComputed := r.ComputePdaAddress() pdaInfo, err := r.SolanaClient.GetAccountInfoWithOpts(r.Ctx, pdaComputed, &rpc.GetAccountInfoOpts{ Commitment: rpc.CommitmentConfirmed, @@ -29,10 +31,8 @@ func (r *E2ERunner) VerifySolanaContractsUpgrade(deployerPrivateKey string) bool err = borsh.Deserialize(&pdaDataBefore, pdaInfo.Bytes()) require.NoError(r, err) - if err := triggerSolanaUpgrade(); err != nil { - r.Logger.Error("failed to trigger Solana upgrade: %v", err) - return false - } + err = triggerSolanaUpgrade() + require.NoError(r, err, "failed to trigger Solana upgrade") r.Logger.Print("⚙️ Solana upgrade completed") pdaInfo, err = r.SolanaClient.GetAccountInfoWithOpts(r.Ctx, pdaComputed, &rpc.GetAccountInfoOpts{ @@ -55,10 +55,11 @@ func (r *E2ERunner) VerifySolanaContractsUpgrade(deployerPrivateKey string) bool require.Equal(r, pdaDataBefore.Authority, pdaDataAfter.Authority) require.Equal(r, pdaDataBefore.ChainID, pdaDataAfter.ChainID) require.Equal(r, pdaDataBefore.DepositPaused, pdaDataAfter.DepositPaused) - return r.VerifyUpgradedInstruction(deployerPrivateKey) + + r.VerifyUpgradedInstruction(deployerPrivateKey) } -func (r *E2ERunner) VerifyUpgradedInstruction(deployerPrivateKey string) bool { +func (r *E2ERunner) VerifyUpgradedInstruction(deployerPrivateKey string) { privkey, err := solana.PrivateKeyFromBase58(deployerPrivateKey) require.NoError(r, err) // Calculate the instruction discriminator for "upgraded" @@ -84,7 +85,7 @@ func (r *E2ERunner) VerifyUpgradedInstruction(deployerPrivateKey string) bool { decoded, err := base64.StdEncoding.DecodeString(out.Meta.ReturnData.Data.String()) require.NoError(r, err) - return decoded[0] == 1 + require.True(r, decoded[0] == 1) } func getAnchorDiscriminator(methodName string) []byte { diff --git a/e2e/runner/sui.go b/e2e/runner/sui.go index 8c1a1735dd..67747f3735 100644 --- a/e2e/runner/sui.go +++ b/e2e/runner/sui.go @@ -15,6 +15,7 @@ import ( "github.com/zeta-chain/protocol-contracts/pkg/gatewayzevm.sol" "github.com/zeta-chain/node/pkg/contracts/sui" + "github.com/zeta-chain/node/zetaclient/chains/sui/client" ) const ( @@ -453,9 +454,11 @@ func (r *E2ERunner) suiExecuteTx( resp, err := r.Clients.Sui.SuiExecuteTransactionBlock(r.Ctx, models.SuiExecuteTransactionBlockRequest{ TxBytes: tx.TxBytes, Signature: []string{signature}, + Options: models.SuiTransactionBlockOptions{ShowEffects: true}, RequestType: "WaitForLocalExecution", }) require.NoError(r, err) + require.Equal(r, resp.Effects.Status.Status, client.TxStatusSuccess) return resp } diff --git a/e2e/runner/sui_gateway_upgrade.go b/e2e/runner/sui_gateway_upgrade.go new file mode 100644 index 0000000000..76b4d08f9d --- /dev/null +++ b/e2e/runner/sui_gateway_upgrade.go @@ -0,0 +1,136 @@ +package runner + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "strings" + "time" + + "github.com/block-vision/sui-go-sdk/models" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +const ( + // suiGatewayUpgradedPath is the path to the upgraded Sui gateway package + suiGatewayUpgradedPath = "/work/protocol-contracts-sui-upgrade" +) + +// SuiVerifyGatewayPackageUpgrade upgrades the Sui gateway package and verifies the upgrade +func (r *E2ERunner) SuiVerifyGatewayPackageUpgrade() { + r.Logger.Print("🏃 Upgrading Sui gateway package") + + // retrieve original gateway object data + gatewayDataBefore, err := r.suiGetObjectData(r.Ctx, r.SuiGateway.ObjectID()) + require.NoError(r, err) + + // upgrade the Sui gateway package + newGatewayPackageID, err := r.suiUpgradeGatewayPackage() + require.NoError(r, err) + + r.Logger.Print("⚙️ Sui gateway package upgrade completed") + + // call the new method 'upgraded' in the new gateway package + r.moveCallUpgraded(r.Ctx, newGatewayPackageID) + + // retrieve new gateway object data + gatewayDataAfter, err := r.suiGetObjectData(r.Ctx, r.SuiGateway.ObjectID()) + require.NoError(r, err) + + // gateway data should remain unchanged + require.Equal(r, gatewayDataBefore, gatewayDataAfter) +} + +// suiUpgradeGatewayPackage upgrades the Sui gateway package by deploying new compiled gateway package +func (r *E2ERunner) suiUpgradeGatewayPackage() (packageID string, err error) { + // build the CLI command for package upgrade + cmdBuild := exec.Command("sui", "move", "build") + cmdBuild.Dir = suiGatewayUpgradedPath + require.NoError(r, cmdBuild.Run(), "unable to build sui gateway package") + + // construct the CLI command for package upgrade + // #nosec G204, inputs are controlled in E2E test + cmdUpgrade := exec.Command("sui", []string{ + "client", + "upgrade", + "--json", // output in JSON format for easier parsing + "--skip-dependency-verification", + "--upgrade-capability", + r.SuiGatewayUpgradeCap, + }...) + cmdUpgrade.Dir = suiGatewayUpgradedPath + + // run command and show output + startTime := time.Now() + output, err := cmdUpgrade.Output() + require.NoError(r, err) + + r.Logger.Info("Sui gateway package upgrade took %f seconds: \n%s", time.Since(startTime).Seconds(), string(output)) + + // convert output to transaction block response struct + response := &models.SuiTransactionBlockResponse{} + err = json.Unmarshal(output, response) + require.NoError(r, err) + + // find packageID + for _, change := range response.ObjectChanges { + if change.Type == "published" { + return change.PackageId, nil + } + } + + return "", errors.New("new gateway package ID not found") +} + +// moveCallUpgraded performs a move call to 'upgraded' method on the new Sui gateway package +func (r *E2ERunner) moveCallUpgraded(ctx context.Context, gatewayPackageID string) { + signer, err := r.Account.SuiSigner() + require.NoError(r, err, "unable to get deployer signer") + + tx, err := r.Clients.Sui.MoveCall(ctx, models.MoveCallRequest{ + Signer: signer.Address(), + PackageObjectId: gatewayPackageID, + Module: r.SuiGateway.Module(), + Function: "upgraded", + TypeArguments: []any{}, + Arguments: []any{}, + GasBudget: "5000000000", + }) + require.NoError(r, err) + + r.suiExecuteTx(signer, tx) +} + +// suiPatchMoveConfig updates the 'published-at' field in the 'Move.toml' file +// with the original published gateway package ID +func (r *E2ERunner) suiPatchMoveConfig() { + const moveTomlPath = suiGatewayUpgradedPath + "/Move.toml" + + // read the entire Move.toml file + content, err := os.ReadFile(moveTomlPath) + require.NoError(r, err, "unable to read Move.toml") + contentStr := string(content) + + // Replace the placeholder with the actual published gateway package ID + publishedAt := r.SuiGateway.PackageID() + updatedContent := strings.Replace(contentStr, "ORIGINAL-PACKAGE-ID", publishedAt, 1) + + // Write the updated content back to the file + err = os.WriteFile(moveTomlPath, []byte(updatedContent), 0600) + require.NoError(r, err, "unable to write to Move.toml") +} + +// suiGetObjectData retrieves the object data for the given object ID +func (r *E2ERunner) suiGetObjectData(ctx context.Context, objectID string) (models.SuiParsedData, error) { + object, err := r.Clients.Sui.SuiGetObject(ctx, models.SuiGetObjectRequest{ + ObjectId: objectID, + Options: models.SuiObjectDataOptions{ShowContent: true}, + }) + require.NoError(r, err) + require.NotNil(r, object.Data) + require.NotNil(r, object.Data.Content) + + return *object.Data.Content, nil +} diff --git a/e2e/runner/upgrade.go b/e2e/runner/upgrade.go index 6b1cdba6be..a41637f8d8 100644 --- a/e2e/runner/upgrade.go +++ b/e2e/runner/upgrade.go @@ -7,9 +7,16 @@ import ( "github.com/zeta-chain/protocol-contracts/pkg/gatewayevm.sol" "github.com/zeta-chain/protocol-contracts/pkg/gatewayzevm.sol" + "github.com/zeta-chain/node/e2e/config" "github.com/zeta-chain/node/e2e/utils" ) +// UpgradeGatewayOptions is the options for the gateway upgrade tests +type UpgradeGatewayOptions struct { + TestSolana bool + TestSui bool +} + // UpgradeGatewaysAndERC20Custody upgrades gateways and ERC20Custody contracts // It deploys new contract implementation with the current imported artifacts and upgrades the contract func (r *E2ERunner) UpgradeGatewaysAndERC20Custody() { @@ -18,6 +25,17 @@ func (r *E2ERunner) UpgradeGatewaysAndERC20Custody() { r.UpgradeERC20Custody() } +// RunGatewayUpgradeTestsExternalChains runs the gateway upgrade tests for external chains +func (r *E2ERunner) RunGatewayUpgradeTestsExternalChains(conf config.Config, opts UpgradeGatewayOptions) { + if opts.TestSolana { + r.SolanaVerifyGatewayContractsUpgrade(conf.AdditionalAccounts.UserSolana.SolanaPrivateKey.String()) + } + + if opts.TestSui { + r.SuiVerifyGatewayPackageUpgrade() + } +} + // UpgradeGatewayZEVM upgrades the GatewayZEVM contract func (r *E2ERunner) UpgradeGatewayZEVM() { ensureTxReceipt := func(tx *ethtypes.Transaction, failMessage string) { diff --git a/pkg/contracts/sui/crypto.go b/pkg/contracts/sui/crypto.go index c9f9eb67f3..c28754d3d6 100644 --- a/pkg/contracts/sui/crypto.go +++ b/pkg/contracts/sui/crypto.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "github.com/block-vision/sui-go-sdk/models" + "github.com/cosmos/cosmos-sdk/types/bech32" "github.com/decred/dcrd/dcrec/secp256k1/v4" secp256k1signer "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" "github.com/ethereum/go-ethereum/crypto" @@ -15,7 +16,13 @@ import ( "golang.org/x/crypto/blake2b" ) -const flagSecp256k1 = 0x01 +const ( + // flagSecp256k1 is the flag to indicate secp256k1 key scheme. + flagSecp256k1 = 0x01 + + // suiPrivateKeyPrefix is the prefix for Sui private key string. + suiPrivateKeyPrefix = "suiprivkey" +) // Digest calculates tx digest (hash) for further signing by TSS. func Digest(tx models.TxnMetaData) ([32]byte, error) { @@ -125,6 +132,23 @@ func DeserializeSignatureECDSA(sigBase64 string) (*ecdsa.PublicKey, [64]byte, er return pk, sig, nil } +// PrivateKeyBech32Secp256k1FromHex converts private key in hex into bech32 format using secp256k1 scheme. +func PrivateKeyBech32Secp256k1FromHex(privKeyHex string) (string, error) { + privKeyBytes, err := hex.DecodeString(privKeyHex) + if err != nil { + return "", errors.Wrap(err, "failed to decode private key hex") + } + + if len(privKeyBytes) != 32 { + return "", errors.Errorf("invalid private key length %d)", len(privKeyBytes)) + } + + // add secp256k1 flag + privKeyBytes = append([]byte{flagSecp256k1}, privKeyBytes...) + + return bech32.ConvertAndEncode(suiPrivateKeyPrefix, privKeyBytes) +} + // SignerSecp256k1 represents Sui Secp256k1 signer. type SignerSecp256k1 struct { pk *secp256k1.PrivateKey diff --git a/pkg/contracts/sui/crypto_test.go b/pkg/contracts/sui/crypto_test.go index 34a471b78f..4aef72b96a 100644 --- a/pkg/contracts/sui/crypto_test.go +++ b/pkg/contracts/sui/crypto_test.go @@ -111,6 +111,38 @@ func TestCrypto(t *testing.T) { assert.Equal(t, signature[:64], signature2[:]) }) + t.Run("PrivateKeySecp256k1FromHex", func(t *testing.T) { + for _, tt := range []struct { + privKeyHex string + privKeyBech32Secp256k1 string + errMsg string + }{ + { + privKeyHex: "d87baf7bf6dc560a252596678c12e41f7d1682837f05b29d411bc3f78ae2c263", + privKeyBech32Secp256k1: "suiprivkey1q8v8htmm7mw9vz39yktx0rqjus0h695zsdlstv5agydu8au2utpxxgjwf3h", + }, + { + privKeyHex: "invalid", + errMsg: "failed to decode private key hex", + }, + { + privKeyHex: "abcdef", + errMsg: "invalid private key length", + }, + } { + t.Run(tt.privKeyHex, func(t *testing.T) { + privKey, err := PrivateKeyBech32Secp256k1FromHex(tt.privKeyHex) + if tt.errMsg != "" { + require.Empty(t, privKey) + require.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + require.Equal(t, tt.privKeyBech32Secp256k1, privKey) + } + }) + } + }) + t.Run("SignerSecp256k1", func(t *testing.T) { for _, tt := range []struct { privKey string