diff --git a/changelog.md b/changelog.md index 6607f62a3c..130ca286c2 100644 --- a/changelog.md +++ b/changelog.md @@ -21,6 +21,7 @@ * [3790](https://github.com/zeta-chain/node/pull/3790) - integrate execute revert * [3797](https://github.com/zeta-chain/node/pull/3797) - integrate execute SPL revert * [3807](https://github.com/zeta-chain/node/pull/3807) - integrate ZEVM to Solana call +* [3793](https://github.com/zeta-chain/node/pull/3793) - support Sui withdrawAndCall using the PTB transaction ### Refactor diff --git a/cmd/zetae2e/config/config.go b/cmd/zetae2e/config/config.go index 92a655f2c2..93ba238af6 100644 --- a/cmd/zetae2e/config/config.go +++ b/cmd/zetae2e/config/config.go @@ -66,6 +66,7 @@ func ExportContractsFromRunner(r *runner.E2ERunner, conf config.Config) config.C conf.Contracts.Sui.FungibleTokenCoinType = config.DoubleQuotedString(r.SuiTokenCoinType) conf.Contracts.Sui.FungibleTokenTreasuryCap = config.DoubleQuotedString(r.SuiTokenTreasuryCap) + conf.Contracts.Sui.Example = r.SuiExample conf.Contracts.EVM.ZetaEthAddr = config.DoubleQuotedString(r.ZetaEthAddr.Hex()) conf.Contracts.EVM.ConnectorEthAddr = config.DoubleQuotedString(r.ConnectorEthAddr.Hex()) diff --git a/cmd/zetae2e/config/contracts.go b/cmd/zetae2e/config/contracts.go index 6b94982e15..5cf108cd1a 100644 --- a/cmd/zetae2e/config/contracts.go +++ b/cmd/zetae2e/config/contracts.go @@ -130,14 +130,15 @@ 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.FungibleTokenCoinType; c != "" { r.SuiTokenCoinType = c.String() } if c := conf.Contracts.Sui.FungibleTokenTreasuryCap; c != "" { r.SuiTokenTreasuryCap = c.String() } + r.SuiExample = conf.Contracts.Sui.Example + // set EVM contracts evmChainID, err := r.EVMClient.ChainID(r.Ctx) require.NoError(r, err, "get evm chain ID") evmChainParams := chainParamsByChainID(chainParams, evmChainID.Int64()) diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index 5f0c138d94..2f6fe4cce0 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -523,12 +523,11 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestSuiWithdrawName, e2etests.TestSuiWithdrawRevertWithCallName, e2etests.TestSuiTokenWithdrawName, + // https://github.com/zeta-chain/node/issues/3742 + e2etests.TestSuiWithdrawAndCallName, + e2etests.TestSuiWithdrawAndCallRevertWithCallName, e2etests.TestSuiDepositRestrictedName, e2etests.TestSuiWithdrawRestrictedName, - - // TODO: enable withdraw and call test - // https://github.com/zeta-chain/node/issues/3742 - //e2etests.TestSuiWithdrawAndCallName, } eg.Go(suiTestRoutine(conf, deployerRunner, verbose, suiTests...)) } diff --git a/e2e/config/config.go b/e2e/config/config.go index 40b3e34d8d..5662fedd20 100644 --- a/e2e/config/config.go +++ b/e2e/config/config.go @@ -144,12 +144,23 @@ type TON struct { GatewayAccountID DoubleQuotedString `yaml:"gateway_account_id"` } +// SuiExample contains the object IDs in the example package +type SuiExample struct { + PackageID DoubleQuotedString `yaml:"package_id"` + TokenType DoubleQuotedString `yaml:"token_type"` + GlobalConfigID DoubleQuotedString `yaml:"global_config_id"` + PartnerID DoubleQuotedString `yaml:"partner_id"` + ClockID DoubleQuotedString `yaml:"clock_id"` + PoolID DoubleQuotedString `yaml:"pool_id"` +} + // 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"` FungibleTokenCoinType DoubleQuotedString `yaml:"fungible_token_coin_type"` FungibleTokenTreasuryCap DoubleQuotedString `yaml:"fungible_token_treasury_cap"` + Example SuiExample `yaml:"example"` } // EVM contains the addresses of predeployed contracts on the EVM chain diff --git a/e2e/contracts/sui/bin.go b/e2e/contracts/sui/bin.go index a83929915d..34b2b0be80 100644 --- a/e2e/contracts/sui/bin.go +++ b/e2e/contracts/sui/bin.go @@ -14,6 +14,12 @@ var fakeUSDC []byte //go:embed evm.mv var evmBinary []byte +//go:embed token.mv +var tokenBinary []byte + +//go:embed connected.mv +var connectedBinary []byte + // GatewayBytecodeBase64 gets the gateway binary encoded as base64 for deployment func GatewayBytecodeBase64() string { return base64.StdEncoding.EncodeToString(gatewayBinary) @@ -28,3 +34,13 @@ func FakeUSDCBytecodeBase64() string { func EVMBytecodeBase64() string { return base64.StdEncoding.EncodeToString(evmBinary) } + +// ExampleFungibleTokenBytecodeBase64 gets the example package's fungible token binary encoded as base64 for deployment +func ExampleFungibleTokenBytecodeBase64() string { + return base64.StdEncoding.EncodeToString(tokenBinary) +} + +// ExampleConnectedBytecodeBase64 gets the example package's connected binary encoded as base64 for deployment +func ExampleConnectedBytecodeBase64() string { + return base64.StdEncoding.EncodeToString(connectedBinary) +} diff --git a/e2e/contracts/sui/connected.mv b/e2e/contracts/sui/connected.mv new file mode 100644 index 0000000000..9923fca1ff Binary files /dev/null and b/e2e/contracts/sui/connected.mv differ diff --git a/e2e/contracts/sui/example/.gitignore b/e2e/contracts/sui/example/.gitignore new file mode 100644 index 0000000000..a007feab07 --- /dev/null +++ b/e2e/contracts/sui/example/.gitignore @@ -0,0 +1 @@ +build/* diff --git a/e2e/contracts/sui/example/Makefile b/e2e/contracts/sui/example/Makefile new file mode 100644 index 0000000000..6fb133a100 --- /dev/null +++ b/e2e/contracts/sui/example/Makefile @@ -0,0 +1,22 @@ +.PHONY: clean build all + +# Default target +all: clean build + +# Clean build directory +clean: + rm -rf build/ + +# Build the package and generate bytecode +build: + sui move build + cp build/example/bytecode_modules/token.mv . + cp build/example/bytecode_modules/connected.mv . + +# Help target +help: + @echo "Available targets:" + @echo " all - Clean and build everything (default)" + @echo " clean - Remove build directory" + @echo " build - Build the package and generate bytecode" + @echo " help - Show this help message" \ No newline at end of file diff --git a/e2e/contracts/sui/example/Move.lock b/e2e/contracts/sui/example/Move.lock new file mode 100644 index 0000000000..8593c3c4da --- /dev/null +++ b/e2e/contracts/sui/example/Move.lock @@ -0,0 +1,58 @@ +# @generated by Move, please check-in and do not edit manually. + +[move] +version = 3 +manifest_digest = "795BC4DE1AC42C1015B85547951A89D7F9AD63EA50982960E095E815C560BFCC" +deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" +dependencies = [ + { id = "Bridge", name = "Bridge" }, + { id = "DeepBook", name = "DeepBook" }, + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Sui", name = "Sui" }, + { id = "SuiSystem", name = "SuiSystem" }, +] + +[[move.package]] +id = "Bridge" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "9c04e1840eb5", subdir = "crates/sui-framework/packages/bridge" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Sui", name = "Sui" }, + { id = "SuiSystem", name = "SuiSystem" }, +] + +[[move.package]] +id = "DeepBook" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "9c04e1840eb5", subdir = "crates/sui-framework/packages/deepbook" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Sui", name = "Sui" }, +] + +[[move.package]] +id = "MoveStdlib" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "9c04e1840eb5", subdir = "crates/sui-framework/packages/move-stdlib" } + +[[move.package]] +id = "Sui" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "9c04e1840eb5", subdir = "crates/sui-framework/packages/sui-framework" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, +] + +[[move.package]] +id = "SuiSystem" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "9c04e1840eb5", subdir = "crates/sui-framework/packages/sui-system" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Sui", name = "Sui" }, +] + +[move.toolchain-version] +compiler-version = "1.45.0" +edition = "2024.beta" +flavor = "sui" diff --git a/e2e/contracts/sui/example/Move.toml b/e2e/contracts/sui/example/Move.toml new file mode 100644 index 0000000000..98d43121ba --- /dev/null +++ b/e2e/contracts/sui/example/Move.toml @@ -0,0 +1,37 @@ +[package] +name = "example" +edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move +# 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 = "mainnet-v1.41.1" } + +# For remote import, use the `{ git = "...", subdir = "...", rev = "..." }`. +# Revision can be a branch, a tag, and a commit hash. +# MyRemotePackage = { git = "https://some.xwremote/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] +example = "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/example/README.md b/e2e/contracts/sui/example/README.md new file mode 100644 index 0000000000..c3666cb965 --- /dev/null +++ b/e2e/contracts/sui/example/README.md @@ -0,0 +1,69 @@ +# SUI WithdrawAndCall with PTB Transactions + +This document explains how the SUI `withdrawAndCall` functionality works using Programmable Transaction Blocks (PTB) in the ZetaChain protocol. + +## Overview + +The `withdrawAndCall` operation in ZetaChain allows users to withdraw tokens from ZEVM to the Sui blockchain and simultaneously calls a `on_call` function in the `connected` module on the Sui side. + +This is implemented as a single atomic transaction using Sui's Programmable Transaction Blocks (PTB). + +## Transaction Flow + +1. **User Initiates Withdrawal**: A user initiates a withdrawal from ZEVM to Sui with a `on_call` payload. + +2. **ZEVM Processing**: The ZEVM gateway processes the withdrawal request and prepares the transaction. + +3. **PTB Construction**: A Programmable Transaction Block is constructed with the following steps: + - **Withdraw**: The first command in the PTB is the `withdraw_impl` function call, which: + - Verifies the withdrawal parameters + - Withdraw and returns two coin objects: the main withdrawn coins and the gas budget coins + - **Gas Budget Transfer**: The second command transfers the gas budget coins to the TSS address to cover transaction fees. + - The gas budget is the SUI coin withdrawn from sui vault, together with withdrawn CCTX's coinType. + - The gas budget needs to be forwarded to TSS address to cover the transaction fee. + - **Connected Module Call**: The third command calls the `on_call` function in the connected module, passing: + - The withdrawn coins + - The call payload from the user + - Any additional parameters required by the connected module + +4. **Transaction Execution**: The entire PTB is executed atomically on the Sui blockchain. + +## PTB Structure + +The PTB for a `withdrawAndCall` transaction consists of three main commands: + +``` +PTB { + // Command 0: Withdraw Implementation + MoveCall { + package: gateway_package_id, + module: gateway_module, + function: withdraw_impl, + arguments: [ + gateway_object_ref, + withdraw_cap_object_ref, + coin_type, + amount, + nonce, + gas_budget + ] + } + + // Command 1: Gas Budget Transfer + TransferObjects { + from: withdraw_impl_result[1], // Gas budget coins + to: tss_address + } + + // Command 2: Connected Module Call + MoveCall { + package: target_package_id, + module: connected_module, + function: on_call, + arguments: [ + withdraw_impl_result[0], // Main withdrawn coins + on_call_payload + ] + } +} +``` \ No newline at end of file diff --git a/e2e/contracts/sui/example/connected.mv b/e2e/contracts/sui/example/connected.mv new file mode 100644 index 0000000000..9923fca1ff Binary files /dev/null and b/e2e/contracts/sui/example/connected.mv differ diff --git a/e2e/contracts/sui/example/sources/example.move b/e2e/contracts/sui/example/sources/example.move new file mode 100644 index 0000000000..6faee61aaf --- /dev/null +++ b/e2e/contracts/sui/example/sources/example.move @@ -0,0 +1,66 @@ +module example::connected; + +use sui::address::from_bytes; +use sui::coin::Coin; + +// stub for shared objects +public struct GlobalConfig has key { + id: UID, + called_count: u64, +} + +public struct Partner has key { + id: UID, +} + +public struct Clock has key { + id: UID, +} + +public struct Pool has key { + id: UID, +} + +// share objects +fun init(ctx: &mut TxContext) { + let global_config = GlobalConfig { + id: object::new(ctx), + called_count: 0, + }; + let pool = Pool { + id: object::new(ctx), + }; + let partner = Partner { + id: object::new(ctx), + }; + let clock = Clock { + id: object::new(ctx), + }; + + transfer::share_object(global_config); + transfer::share_object(pool); + transfer::share_object(partner); + transfer::share_object(clock); +} + +public entry fun on_call( + in_coins: Coin, + cetus_config: &mut GlobalConfig, + _pool: &mut Pool, + _cetus_partner: &mut Partner, + _clock: &Clock, + data: vector, + _ctx: &mut TxContext, +) { + let receiver = decode_receiver(data); + + // transfer the coins to the provided address + transfer::public_transfer(in_coins, receiver); + + // increment the called count + cetus_config.called_count = cetus_config.called_count + 1; +} + +fun decode_receiver(data: vector): address { + from_bytes(data) +} \ No newline at end of file diff --git a/e2e/contracts/sui/example/sources/token.move b/e2e/contracts/sui/example/sources/token.move new file mode 100644 index 0000000000..ac872f6916 --- /dev/null +++ b/e2e/contracts/sui/example/sources/token.move @@ -0,0 +1,29 @@ +module example::token; + +use sui::coin::{Self, TreasuryCap}; + +public struct TOKEN has drop {} + +fun init(witness: TOKEN, ctx: &mut TxContext) { + let (treasury, metadata) = coin::create_currency( + witness, + 6, + b"TOKEN", + b"", + b"", + option::none(), + ctx, + ); + transfer::public_freeze_object(metadata); + transfer::public_transfer(treasury, ctx.sender()) +} + +public entry fun mint( + treasury_cap: &mut TreasuryCap, + amount: u64, + recipient: address, + ctx: &mut TxContext, +) { + let coin = coin::mint(treasury_cap, amount, ctx); + transfer::public_transfer(coin, recipient) +} \ No newline at end of file diff --git a/e2e/contracts/sui/example/token.mv b/e2e/contracts/sui/example/token.mv new file mode 100644 index 0000000000..de33978021 Binary files /dev/null and b/e2e/contracts/sui/example/token.mv differ diff --git a/e2e/contracts/sui/token.mv b/e2e/contracts/sui/token.mv new file mode 100644 index 0000000000..de33978021 Binary files /dev/null and b/e2e/contracts/sui/token.mv differ diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index 2a7d30f9df..d782554527 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -95,18 +95,19 @@ const ( /* Sui tests */ - TestSuiDepositName = "sui_deposit" - TestSuiDepositAndCallName = "sui_deposit_and_call" - TestSuiDepositAndCallRevertName = "sui_deposit_and_call_revert" - TestSuiTokenDepositName = "sui_token_deposit" // #nosec G101: Potential hardcoded credentials (gosec), not a credential - TestSuiTokenDepositAndCallName = "sui_token_deposit_and_call" // #nosec G101: Potential hardcoded credentials (gosec), not a credential - TestSuiTokenDepositAndCallRevertName = "sui_token_deposit_and_call_revert" // #nosec G101: Potential hardcoded credentials (gosec), not a credential - TestSuiWithdrawName = "sui_withdraw" - TestSuiTokenWithdrawName = "sui_token_withdraw" // #nosec G101: Potential hardcoded credentials (gosec), not a credential - TestSuiWithdrawAndCallName = "sui_withdraw_and_call" - TestSuiWithdrawRevertWithCallName = "sui_withdraw_revert_with_call" // #nosec G101: Potential hardcoded credentials (gosec), not a credential - TestSuiDepositRestrictedName = "sui_deposit_restricted" - TestSuiWithdrawRestrictedName = "sui_withdraw_restricted" + TestSuiDepositName = "sui_deposit" + TestSuiDepositAndCallName = "sui_deposit_and_call" + TestSuiDepositAndCallRevertName = "sui_deposit_and_call_revert" + TestSuiTokenDepositName = "sui_token_deposit" // #nosec G101: Potential hardcoded credentials (gosec), not a credential + TestSuiTokenDepositAndCallName = "sui_token_deposit_and_call" // #nosec G101: Potential hardcoded credentials (gosec), not a credential + TestSuiTokenDepositAndCallRevertName = "sui_token_deposit_and_call_revert" // #nosec G101: Potential hardcoded credentials (gosec), not a credential + TestSuiWithdrawName = "sui_withdraw" + TestSuiTokenWithdrawName = "sui_token_withdraw" // #nosec G101: Potential hardcoded credentials (gosec), not a credential + TestSuiWithdrawAndCallName = "sui_withdraw_and_call" + TestSuiWithdrawRevertWithCallName = "sui_withdraw_revert_with_call" // #nosec G101: Potential hardcoded credentials (gosec), not a credential + TestSuiWithdrawAndCallRevertWithCallName = "sui_withdraw_and_call_revert_with_call" // #nosec G101: Potential hardcoded credentials (gosec), not a credential + TestSuiDepositRestrictedName = "sui_deposit_restricted" + TestSuiWithdrawRestrictedName = "sui_withdraw_restricted" /* Bitcoin tests @@ -883,6 +884,15 @@ var AllE2ETests = []runner.E2ETest{ TestSuiWithdrawRevertWithCall, runner.WithMinimumVersion("v30.0.0"), ), + runner.NewE2ETest( + TestSuiWithdrawAndCallRevertWithCallName, + "withdraw SUI from ZEVM and call a contract that reverts with a onRevert call", + []runner.ArgDefinition{ + {Description: "amount in mist", DefaultValue: "1000000"}, + }, + TestSuiWithdrawAndCallRevertWithCall, + runner.WithMinimumVersion("v30.0.0"), + ), runner.NewE2ETest( TestSuiTokenWithdrawName, "withdraw fungible token from ZEVM", diff --git a/e2e/e2etests/test_sui_withdraw_and_call.go b/e2e/e2etests/test_sui_withdraw_and_call.go index f51a9e7839..95fbc94f5e 100644 --- a/e2e/e2etests/test_sui_withdraw_and_call.go +++ b/e2e/e2etests/test_sui_withdraw_and_call.go @@ -2,8 +2,10 @@ package e2etests import ( "encoding/hex" + "math/big" "github.com/stretchr/testify/require" + "github.com/zeta-chain/protocol-contracts/pkg/gatewayzevm.sol" "github.com/zeta-chain/node/e2e/runner" "github.com/zeta-chain/node/e2e/utils" @@ -14,36 +16,59 @@ import ( func TestSuiWithdrawAndCall(r *runner.E2ERunner, args []string) { require.Len(r, args, 1) - signer, err := r.Account.SuiSigner() - require.NoError(r, err, "get deployer signer") - + // ARRANGE + // Given target package ID (example package) and a SUI amount + targetPackageID := r.SuiExample.PackageID.String() amount := utils.ParseBigInt(r, args[0]) - r.ApproveSUIZRC20(r.GatewayZEVMAddr) - - // sample withdrawAndCall payload - // TODO: use real contract - // https://github.com/zeta-chain/node/issues/3742 + // Given example contract on_call function arguments argumentTypes := []string{ - "0xb112f370bc8e3ba6e45ad1a954660099fc3e6de2a203df9d26e11aa0d870f635::token::TOKEN", + r.SuiExample.TokenType.String(), } objects := []string{ - "0x57dd7b5841300199ac87b420ddeb48229523e76af423b4fce37da0cb78604408", - "0xbab1a2d90ea585eab574932e1b3467ff1d5d3f2aee55fed304f963ca2b9209eb", - "0xee6f1f44d24a8bf7268d82425d6e7bd8b9c48d11b2119b20756ee150c8e24ac3", - "0x039ce62b538a0d0fca21c3c3a5b99adf519d55e534c536568fbcca40ee61fb7e", + r.SuiExample.GlobalConfigID.String(), + r.SuiExample.PoolID.String(), + r.SuiExample.PartnerID.String(), + r.SuiExample.ClockID.String(), } - message, err := hex.DecodeString("3573924024f4a7ff8e6755cb2d9fdeef69bdb65329f081d21b0b6ab37a265d06") + + // define a deterministic address and use it for on_call payload message + // the example contract will just forward the withdrawn SUI token to this address + suiAddress := "0x34a30aaee833d649d7313ddfe4ff5b6a9bac48803236b919369e6636fe93392e" + message, err := hex.DecodeString(suiAddress[2:]) // remove 0x prefix require.NoError(r, err) + balanceBefore := r.SuiGetSUIBalance(suiAddress) + + // query the called_count before withdraw and call + calledCountBefore := r.SuiGetConnectedCalledCount() + // create the payload payload := sui.NewCallPayload(argumentTypes, objects, message) + // ACT + // approve SUI ZRC20 token + r.ApproveSUIZRC20(r.GatewayZEVMAddr) + // perform the withdraw and call - tx := r.SuiWithdrawAndCallSUI(signer.Address(), amount, payload) + tx := r.SuiWithdrawAndCallSUI( + targetPackageID, + amount, + payload, + gatewayzevm.RevertOptions{OnRevertGasLimit: big.NewInt(0)}, + ) r.Logger.EVMTransaction(*tx, "withdraw_and_call") + // ASSERT // wait for the cctx to be mined cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, tx.Hash().Hex(), r.CctxClient, r.Logger, r.CctxTimeout) r.Logger.CCTX(*cctx, "withdraw") require.EqualValues(r, crosschaintypes.CctxStatus_OutboundMined, cctx.CctxStatus.Status) + + // balance after + balanceAfter := r.SuiGetSUIBalance(suiAddress) + require.Equal(r, balanceBefore+amount.Uint64(), balanceAfter) + + // verify the called_count increased by 1 + calledCountAfter := r.SuiGetConnectedCalledCount() + require.Equal(r, calledCountBefore+1, calledCountAfter) } diff --git a/e2e/e2etests/test_sui_withdraw_and_call_revert_with_call.go b/e2e/e2etests/test_sui_withdraw_and_call_revert_with_call.go index 4244fa05e7..87d00f15e3 100644 --- a/e2e/e2etests/test_sui_withdraw_and_call_revert_with_call.go +++ b/e2e/e2etests/test_sui_withdraw_and_call_revert_with_call.go @@ -1,98 +1,95 @@ package e2etests import ( + "encoding/hex" "math/big" - "time" - "cosmossdk.io/math" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/stretchr/testify/require" "github.com/zeta-chain/protocol-contracts/pkg/gatewayzevm.sol" "github.com/zeta-chain/node/e2e/runner" "github.com/zeta-chain/node/e2e/utils" + "github.com/zeta-chain/node/pkg/contracts/sui" crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" ) -// TestSuiWithdrawRevertWithCall executes withdraw on zevm gateway. -// The outbound is rejected by Sui network, and 'nonce_increase' is called instead to cancel the tx. -func TestSuiWithdrawRevertWithCall(r *runner.E2ERunner, args []string) { +// TestSuiWithdrawAndCallRevertWithCall executes withdraw and call on zevm gateway. +// The outbound is rejected by the connected module due to invalid payload (invalid address), +// and 'onRevert' is called instead to handle the revert. +func TestSuiWithdrawAndCallRevertWithCall(r *runner.E2ERunner, args []string) { require.Len(r, args, 1) - amount := utils.ParseBigInt(r, args[0]) // ARRANGE - // given signer - signer, err := r.Account.SuiSigner() - require.NoError(r, err, "get deployer signer") - signerBalanceBefore := r.SuiGetSUIBalance(signer.Address()) + // Given target package ID (example package) and a SUI amount + targetPackageID := r.SuiExample.PackageID.String() + amount := utils.ParseBigInt(r, args[0]) + + // Given example contract on_call function arguments + argumentTypes := []string{ + r.SuiExample.TokenType.String(), + } + objects := []string{ + r.SuiExample.GlobalConfigID.String(), + r.SuiExample.PoolID.String(), + r.SuiExample.PartnerID.String(), + r.SuiExample.ClockID.String(), + } + + // define an invalid address to cause 'on_call' failure + invalidAddress := "8f569597ebca884b784d32678a6f" + message, err := hex.DecodeString(invalidAddress) + require.NoError(r, err) // given ZEVM revert address (the dApp) dAppAddress := r.TestDAppV2ZEVMAddr dAppBalanceBefore, err := r.SUIZRC20.BalanceOf(&bind.CallOpts{}, dAppAddress) require.NoError(r, err) - // given random payload - payload := randomPayload(r) - r.AssertTestDAppEVMCalled(false, payload, amount) - - // retrieve current zrc20 gas limit - oldGasLimit, err := r.SUIZRC20.GASLIMIT(&bind.CallOpts{}) - require.NoError(r, err) - r.Logger.Info("current gas limit: %s", oldGasLimit.String()) - - // set a low ZRC20 gas limit so gasBudget will be low: "1000000" - // withdraw tx will be rejected due to execution error "InsufficientGas" - lowGasLimit := math.NewUintFromBigInt(big.NewInt(1000)) - _, err = r.ZetaTxServer.UpdateZRC20GasLimit(r.SUIZRC20Addr, lowGasLimit) - require.NoError(r, err) + // given random payload for 'onRevert' + payloadOnRevert := randomPayload(r) + r.AssertTestDAppEVMCalled(false, payloadOnRevert, amount) - // wait for the new gas limit to take effect - utils.WaitForZetaBlocks(r.Ctx, r, r.ZEVMClient, 1, 10*time.Second) + // create the payload for 'on_call' + payloadOnCall := sui.NewCallPayload(argumentTypes, objects, message) // ACT - // approve the ZRC20 + // approve SUI ZRC20 token r.ApproveSUIZRC20(r.GatewayZEVMAddr) - // perform the withdraw with revert options - tx := r.SuiWithdrawSUI( - signer.Address(), + // perform the withdraw and call with revert options + tx := r.SuiWithdrawAndCallSUI( + targetPackageID, amount, + payloadOnCall, gatewayzevm.RevertOptions{ CallOnRevert: true, RevertAddress: dAppAddress, - RevertMessage: []byte(payload), + RevertMessage: []byte(payloadOnRevert), OnRevertGasLimit: big.NewInt(0), }, ) - r.Logger.EVMTransaction(*tx, "withdraw") + r.Logger.EVMTransaction(*tx, "withdraw_and_call") // ASSERT - // wait for the CCTX to be mined + // wait for the cctx to be reverted cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, tx.Hash().Hex(), r.CctxClient, r.Logger, r.CctxTimeout) + r.Logger.CCTX(*cctx, "withdraw") utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_Reverted) // should have called 'onRevert' - r.AssertTestDAppZEVMCalled(true, payload, big.NewInt(0)) + r.AssertTestDAppZEVMCalled(true, payloadOnRevert, big.NewInt(0)) // sender and message should match sender, err := r.TestDAppV2ZEVM.SenderWithMessage( &bind.CallOpts{}, - []byte(payload), + []byte(payloadOnRevert), ) require.NoError(r, err) require.Equal(r, r.ZEVMAuth.From, sender) - // signer balance should remain unchanged in Sui chain - signerBalanceAfter := r.SuiGetSUIBalance(signer.Address()) - require.Equal(r, signerBalanceBefore, signerBalanceAfter) - // the dApp address should get reverted amount dAppBalanceAfter, err := r.SUIZRC20.BalanceOf(&bind.CallOpts{}, dAppAddress) require.NoError(r, err) require.Equal(r, amount.Int64(), dAppBalanceAfter.Int64()-dAppBalanceBefore.Int64()) - - // TEARDOWN - // restore old gas limit - _, err = r.ZetaTxServer.UpdateZRC20GasLimit(r.SUIZRC20Addr, math.NewUintFromBigInt(oldGasLimit)) - require.NoError(r, err, "failed to restore gas limit") } diff --git a/e2e/e2etests/test_sui_withdraw_revert_with_call.go b/e2e/e2etests/test_sui_withdraw_revert_with_call.go new file mode 100644 index 0000000000..4244fa05e7 --- /dev/null +++ b/e2e/e2etests/test_sui_withdraw_revert_with_call.go @@ -0,0 +1,98 @@ +package e2etests + +import ( + "math/big" + "time" + + "cosmossdk.io/math" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/protocol-contracts/pkg/gatewayzevm.sol" + + "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/utils" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" +) + +// TestSuiWithdrawRevertWithCall executes withdraw on zevm gateway. +// The outbound is rejected by Sui network, and 'nonce_increase' is called instead to cancel the tx. +func TestSuiWithdrawRevertWithCall(r *runner.E2ERunner, args []string) { + require.Len(r, args, 1) + amount := utils.ParseBigInt(r, args[0]) + + // ARRANGE + // given signer + signer, err := r.Account.SuiSigner() + require.NoError(r, err, "get deployer signer") + signerBalanceBefore := r.SuiGetSUIBalance(signer.Address()) + + // given ZEVM revert address (the dApp) + dAppAddress := r.TestDAppV2ZEVMAddr + dAppBalanceBefore, err := r.SUIZRC20.BalanceOf(&bind.CallOpts{}, dAppAddress) + require.NoError(r, err) + + // given random payload + payload := randomPayload(r) + r.AssertTestDAppEVMCalled(false, payload, amount) + + // retrieve current zrc20 gas limit + oldGasLimit, err := r.SUIZRC20.GASLIMIT(&bind.CallOpts{}) + require.NoError(r, err) + r.Logger.Info("current gas limit: %s", oldGasLimit.String()) + + // set a low ZRC20 gas limit so gasBudget will be low: "1000000" + // withdraw tx will be rejected due to execution error "InsufficientGas" + lowGasLimit := math.NewUintFromBigInt(big.NewInt(1000)) + _, err = r.ZetaTxServer.UpdateZRC20GasLimit(r.SUIZRC20Addr, lowGasLimit) + require.NoError(r, err) + + // wait for the new gas limit to take effect + utils.WaitForZetaBlocks(r.Ctx, r, r.ZEVMClient, 1, 10*time.Second) + + // ACT + // approve the ZRC20 + r.ApproveSUIZRC20(r.GatewayZEVMAddr) + + // perform the withdraw with revert options + tx := r.SuiWithdrawSUI( + signer.Address(), + amount, + gatewayzevm.RevertOptions{ + CallOnRevert: true, + RevertAddress: dAppAddress, + RevertMessage: []byte(payload), + OnRevertGasLimit: big.NewInt(0), + }, + ) + r.Logger.EVMTransaction(*tx, "withdraw") + + // ASSERT + // wait for the CCTX to be mined + cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, tx.Hash().Hex(), r.CctxClient, r.Logger, r.CctxTimeout) + utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_Reverted) + + // should have called 'onRevert' + r.AssertTestDAppZEVMCalled(true, payload, big.NewInt(0)) + + // sender and message should match + sender, err := r.TestDAppV2ZEVM.SenderWithMessage( + &bind.CallOpts{}, + []byte(payload), + ) + require.NoError(r, err) + require.Equal(r, r.ZEVMAuth.From, sender) + + // signer balance should remain unchanged in Sui chain + signerBalanceAfter := r.SuiGetSUIBalance(signer.Address()) + require.Equal(r, signerBalanceBefore, signerBalanceAfter) + + // the dApp address should get reverted amount + dAppBalanceAfter, err := r.SUIZRC20.BalanceOf(&bind.CallOpts{}, dAppAddress) + require.NoError(r, err) + require.Equal(r, amount.Int64(), dAppBalanceAfter.Int64()-dAppBalanceBefore.Int64()) + + // TEARDOWN + // restore old gas limit + _, err = r.ZetaTxServer.UpdateZRC20GasLimit(r.SUIZRC20Addr, math.NewUintFromBigInt(oldGasLimit)) + require.NoError(r, err, "failed to restore gas limit") +} diff --git a/e2e/runner/runner.go b/e2e/runner/runner.go index 93d80eed11..eae03218d1 100644 --- a/e2e/runner/runner.go +++ b/e2e/runner/runner.go @@ -133,6 +133,9 @@ type E2ERunner struct { // SuiTokenTreasuryCap is the treasury cap for the SUI token that allows minting, only using in local tests SuiTokenTreasuryCap string + // SuiExample contains the example package information for Sui + SuiExample config.SuiExample + // contracts evm ZetaEthAddr ethcommon.Address ZetaEth *zetaeth.ZetaEth @@ -288,6 +291,7 @@ func (r *E2ERunner) CopyAddressesFrom(other *E2ERunner) (err error) { r.SuiGateway = other.SuiGateway r.SuiTokenCoinType = other.SuiTokenCoinType r.SuiTokenTreasuryCap = other.SuiTokenTreasuryCap + r.SuiExample = other.SuiExample // create instances of contracts r.ZetaEth, err = zetaeth.NewZetaEth(r.ZetaEthAddr, r.EVMClient) diff --git a/e2e/runner/setup_sui.go b/e2e/runner/setup_sui.go index 79dc954036..ab3fd614e1 100644 --- a/e2e/runner/setup_sui.go +++ b/e2e/runner/setup_sui.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/zeta-chain/protocol-contracts/pkg/zrc20.sol" + "github.com/zeta-chain/node/e2e/config" suicontract "github.com/zeta-chain/node/e2e/contracts/sui" "github.com/zeta-chain/node/e2e/txserver" "github.com/zeta-chain/node/e2e/utils" @@ -48,82 +49,18 @@ func (r *E2ERunner) SetupSui(faucetURL string) { // TODO: this step might no longer necessary if a custom solution is implemented for the TSS funding r.RequestSuiFromFaucet(faucetURL, r.SuiTSSAddress) - client := r.Clients.Sui - - publishTx, err := client.Publish(r.Ctx, models.PublishRequest{ - Sender: deployerAddress, - CompiledModules: []string{suicontract.GatewayBytecodeBase64(), suicontract.EVMBytecodeBase64()}, - Dependencies: []string{ - "0x0000000000000000000000000000000000000000000000000000000000000001", - "0x0000000000000000000000000000000000000000000000000000000000000002", - }, - GasBudget: "5000000000", - }) - require.NoError(r, err, "create publish tx") - - signature, err := deployerSigner.SignTxBlock(publishTx) - require.NoError(r, err, "sign transaction") - - resp, err := client.SuiExecuteTransactionBlock(r.Ctx, models.SuiExecuteTransactionBlockRequest{ - TxBytes: publishTx.TxBytes, - Signature: []string{signature}, - Options: models.SuiTransactionBlockOptions{ - ShowEffects: true, - ShowBalanceChanges: true, - ShowEvents: true, - ShowObjectChanges: true, - }, - RequestType: "WaitForLocalExecution", - }) - require.NoError(r, err) - - // find packageID - var packageID, gatewayID, whitelistCapID, withdrawCapID string - for _, change := range resp.ObjectChanges { - if change.Type == "published" { - packageID = change.PackageId - } - } - require.NotEmpty(r, packageID, "packageID not found") - - // find gateway objectID - gatewayType := fmt.Sprintf("%s::gateway::Gateway", packageID) - for _, change := range resp.ObjectChanges { - if change.Type == changeTypeCreated && change.ObjectType == gatewayType { - gatewayID = change.ObjectId - } - } - require.NotEmpty(r, gatewayID, "gatewayID not found") - - // find WhitelistCap objectID - whitelistType := fmt.Sprintf("%s::gateway::WhitelistCap", packageID) - for _, change := range resp.ObjectChanges { - if change.Type == changeTypeCreated && change.ObjectType == whitelistType { - whitelistCapID = change.ObjectId - } - } - require.NotEmpty(r, whitelistCapID, "whitelistID not found") - - // find WithdrawCap objectID - withdrawCapType := fmt.Sprintf("%s::gateway::WithdrawCap", packageID) - for _, change := range resp.ObjectChanges { - if change.Type == changeTypeCreated && change.ObjectType == withdrawCapType { - withdrawCapID = change.ObjectId - } - } - - // set sui gateway - r.SuiGateway = zetasui.NewGateway(packageID, gatewayID) + // deploy gateway package + whitelistCapID, withdrawCapID := r.suiDeployGateway() // deploy SUI zrc20 r.deploySUIZRC20() - // deploy fake USDC - fakeUSDCCoinType, treasuryCap := r.suiDeployFakeUSDC() + // deploy fake USDC and whitelist it + fakeUSDCCoinType := r.suiDeployFakeUSDC() r.whitelistSuiFakeUSDC(deployerSigner, fakeUSDCCoinType, whitelistCapID) - r.SuiTokenCoinType = fakeUSDCCoinType - r.SuiTokenTreasuryCap = treasuryCap + // deploy example contract with on_call function + r.suiDeployExample() // send withdraw cap to TSS r.suiSendWithdrawCapToTSS(deployerSigner, withdrawCapID) @@ -133,6 +70,35 @@ func (r *E2ERunner) SetupSui(faucetURL string) { require.NoError(r, err) } +// 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" + ) + + objectTypeFilters := []string{filterGatewayType, filterWhitelistCapType, filterWithdrawCapType} + packageID, objectIDs := r.suiDeployPackage( + []string{suicontract.GatewayBytecodeBase64(), suicontract.EVMBytecodeBase64()}, + objectTypeFilters, + ) + + gatewayID, ok := objectIDs[filterGatewayType] + require.True(r, ok, "gateway object not found") + + whitelistCapID, ok = objectIDs[filterWhitelistCapType] + require.True(r, ok, "whitelistCap object not found") + + withdrawCapID, ok = objectIDs[filterWithdrawCapType] + require.True(r, ok, "withdrawCap object not found") + + // set sui gateway + r.SuiGateway = zetasui.NewGateway(packageID, gatewayID) + + return whitelistCapID, withdrawCapID +} + // deploySUIZRC20 deploys the SUI zrc20 on ZetaChain func (r *E2ERunner) deploySUIZRC20() { // send message to deploy SUI zrc20 @@ -156,29 +122,88 @@ func (r *E2ERunner) deploySUIZRC20() { } // suiDeployFakeUSDC deploys the FakeUSDC contract on Sui -// it returns the coinType to be used as asset value for zrc20 and treasuryCap object ID that allows to mint tokens -func (r *E2ERunner) suiDeployFakeUSDC() (string, string) { +// it returns the treasuryCap object ID that allows to mint tokens +func (r *E2ERunner) suiDeployFakeUSDC() string { + packageID, objectIDs := r.suiDeployPackage([]string{suicontract.FakeUSDCBytecodeBase64()}, []string{"TreasuryCap"}) + + treasuryCap, ok := objectIDs["TreasuryCap"] + require.True(r, ok, "treasuryCap not found") + + coinType := packageID + "::fake_usdc::FAKE_USDC" + + // strip 0x from packageID + coinType = coinType[2:] + + // set asset value for zrc20 and treasuryCap object ID + r.SuiTokenCoinType = coinType + r.SuiTokenTreasuryCap = treasuryCap + + return coinType +} + +// suiDeployExample deploys the example package on Sui +func (r *E2ERunner) suiDeployExample() { + const ( + filterGlobalConfigType = "connected::GlobalConfig" + filterPartnerType = "connected::Partner" + filterClockType = "connected::Clock" + filterPoolType = "connected::Pool" + ) + + objectTypeFilters := []string{filterGlobalConfigType, filterPartnerType, filterClockType, filterPoolType} + packageID, objectIDs := r.suiDeployPackage( + []string{suicontract.ExampleFungibleTokenBytecodeBase64(), suicontract.ExampleConnectedBytecodeBase64()}, + objectTypeFilters, + ) + r.Logger.Info("deployed example package with packageID: %s", packageID) + + globalConfigID, ok := objectIDs[filterGlobalConfigType] + require.True(r, ok, "globalConfig object not found") + + partnerID, ok := objectIDs[filterPartnerType] + require.True(r, ok, "partner object not found") + + clockID, ok := objectIDs[filterClockType] + require.True(r, ok, "clock object not found") + + poolID, ok := objectIDs[filterPoolType] + require.True(r, ok, "pool object not found") + + r.SuiExample = config.SuiExample{ + PackageID: config.DoubleQuotedString(packageID), + TokenType: config.DoubleQuotedString(packageID + "::token::TOKEN"), + GlobalConfigID: config.DoubleQuotedString(globalConfigID), + PartnerID: config.DoubleQuotedString(partnerID), + ClockID: config.DoubleQuotedString(clockID), + PoolID: config.DoubleQuotedString(poolID), + } +} + +// suiDeployPackage is a helper function that deploys a package on Sui +// It returns the packageID and a map of object types to their IDs +func (r *E2ERunner) suiDeployPackage(bytecodeBase64s []string, objectTypeFilters []string) (string, map[string]string) { client := r.Clients.Sui + deployerSigner, err := r.Account.SuiSigner() require.NoError(r, err, "get deployer signer") deployerAddress := deployerSigner.Address() - publishReq, err := client.Publish(r.Ctx, models.PublishRequest{ + publishTx, err := client.Publish(r.Ctx, models.PublishRequest{ Sender: deployerAddress, - CompiledModules: []string{suicontract.FakeUSDCBytecodeBase64()}, + CompiledModules: bytecodeBase64s, Dependencies: []string{ - "0x0000000000000000000000000000000000000000000000000000000000000001", - "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x1", // Sui Framework + "0x2", // Move Standard Library }, GasBudget: "5000000000", }) require.NoError(r, err, "create publish tx") - signature, err := deployerSigner.SignTxBlock(publishReq) + signature, err := deployerSigner.SignTxBlock(publishTx) require.NoError(r, err, "sign transaction") resp, err := client.SuiExecuteTransactionBlock(r.Ctx, models.SuiExecuteTransactionBlockRequest{ - TxBytes: publishReq.TxBytes, + TxBytes: publishTx.TxBytes, Signature: []string{signature}, Options: models.SuiTransactionBlockOptions{ ShowEffects: true, @@ -190,7 +215,8 @@ func (r *E2ERunner) suiDeployFakeUSDC() (string, string) { }) require.NoError(r, err) - var packageID, treasuryCap string + // find packageID + var packageID string for _, change := range resp.ObjectChanges { if change.Type == "published" { packageID = change.PackageId @@ -198,19 +224,17 @@ func (r *E2ERunner) suiDeployFakeUSDC() (string, string) { } require.NotEmpty(r, packageID, "packageID not found") - for _, change := range resp.ObjectChanges { - if change.Type == changeTypeCreated && strings.Contains(change.ObjectType, "TreasuryCap") { - treasuryCap = change.ObjectId + // find objects by type filters + objectIDs := make(map[string]string) + for _, filter := range objectTypeFilters { + for _, change := range resp.ObjectChanges { + if change.Type == changeTypeCreated && strings.Contains(change.ObjectType, filter) { + objectIDs[filter] = change.ObjectId + } } } - require.NotEmpty(r, treasuryCap, "objectID not found") - - coinType := packageID + "::fake_usdc::FAKE_USDC" - - // strip 0x from packageID - coinType = coinType[2:] - return coinType, treasuryCap + return packageID, objectIDs } // whitelistSuiFakeUSDC deploys the FakeUSDC zrc20 on ZetaChain and whitelist it diff --git a/e2e/runner/sui.go b/e2e/runner/sui.go index eb34a09a4f..ffcf04d635 100644 --- a/e2e/runner/sui.go +++ b/e2e/runner/sui.go @@ -71,6 +71,7 @@ func (r *E2ERunner) SuiWithdrawAndCallSUI( receiver string, amount *big.Int, payload sui.CallPayload, + revertOptions gatewayzevm.RevertOptions, ) *ethtypes.Transaction { receiverBytes, err := hex.DecodeString(receiver[2:]) require.NoError(r, err, "receiver: "+receiver[2:]) @@ -89,7 +90,7 @@ func (r *E2ERunner) SuiWithdrawAndCallSUI( IsArbitraryCall: false, GasLimit: big.NewInt(20000), }, - gatewayzevm.RevertOptions{OnRevertGasLimit: big.NewInt(0)}, + revertOptions, ) require.NoError(r, err) @@ -207,6 +208,35 @@ func (r *E2ERunner) SuiMintUSDC( return r.suiExecuteTx(signer, tx) } +// SuiGetConnectedCalledCount reads the called_count from the GlobalConfig object in connected module +func (r *E2ERunner) SuiGetConnectedCalledCount() uint64 { + // Get object data + resp, err := r.Clients.Sui.SuiGetObject(r.Ctx, models.SuiGetObjectRequest{ + ObjectId: r.SuiExample.GlobalConfigID.String(), + Options: models.SuiObjectDataOptions{ShowContent: true}, + }) + + require.NoError(r, err) + require.Nil(r, resp.Error) + require.NotNil(r, resp.Data) + require.NotNil(r, resp.Data.Content) + + fields := resp.Data.Content.Fields + + // Extract called_count field from the object content + rawValue, ok := fields["called_count"] + require.True(r, ok, "missing called_count field") + + v, ok := rawValue.(string) + require.True(r, ok, "want string, got %T for called_count", rawValue) + + // #nosec G115 always in range + calledCount, err := strconv.ParseUint(v, 10, 64) + require.NoError(r, err) + + return calledCount +} + // suiExecuteDeposit executes a deposit on the SUI contract func (r *E2ERunner) suiExecuteDeposit( signer *sui.SignerSecp256k1, diff --git a/go.mod b/go.mod index b87057c331..57695ae57f 100644 --- a/go.mod +++ b/go.mod @@ -307,14 +307,18 @@ require ( github.com/bnb-chain/tss-lib v1.5.0 github.com/cosmos/cosmos-db v1.1.1 github.com/cosmos/ibc-go/modules/capability v1.0.1 + github.com/fardream/go-bcs v0.7.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/montanaflynn/stats v0.7.1 + github.com/pattonkan/sui-go v0.1.0 github.com/showa-93/go-mask v0.6.2 github.com/tonkeeper/tongo v1.14.8 github.com/zeta-chain/go-tss v0.5.0 github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20250409230544-d88f214f6f46 ) +require github.com/coming-chat/go-aptos v0.0.0-20221013022715-39f91035c785 // indirect + require ( cloud.google.com/go/auth v0.6.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect @@ -323,7 +327,9 @@ require ( github.com/Microsoft/go-winio v0.6.1 // indirect github.com/aead/siphash v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.10.0 // indirect + github.com/btcsuite/btcutil v1.0.2 // indirect github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce // indirect + github.com/coming-chat/go-sui/v2 v2.0.1 github.com/consensys/bavard v0.1.13 // indirect github.com/consensys/gnark-crypto v0.12.1 // indirect github.com/crate-crypto/go-ipa v0.0.0-20231025140028-3c0104f4b233 // indirect @@ -349,6 +355,7 @@ require ( github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.2.2 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mmcloughlin/addchain v0.4.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oasisprotocol/curve25519-voi v0.0.0-20230904125328-1f23a7beb09a // indirect diff --git a/go.sum b/go.sum index 076863fbc3..f038f2bbb1 100644 --- a/go.sum +++ b/go.sum @@ -325,6 +325,8 @@ github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtyd github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts= +github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JGkIguV40SvRY1uliPX8ifOvi6ICsFCw= github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= @@ -399,6 +401,10 @@ github.com/cometbft/cometbft v0.38.17 h1:FkrQNbAjiFqXydeAO81FUzriL4Bz0abYxN/eOHr github.com/cometbft/cometbft v0.38.17/go.mod h1:5l0SkgeLRXi6bBfQuevXjKqML1jjfJJlvI1Ulp02/o4= github.com/cometbft/cometbft-db v0.14.1 h1:SxoamPghqICBAIcGpleHbmoPqy+crij/++eZz3DlerQ= github.com/cometbft/cometbft-db v0.14.1/go.mod h1:KHP1YghilyGV/xjD5DP3+2hyigWx0WTp9X+0Gnx0RxQ= +github.com/coming-chat/go-aptos v0.0.0-20221013022715-39f91035c785 h1:xIOXIW3uXakffHoVqA6qkyUgYYuhJWLPohIyR1tBS38= +github.com/coming-chat/go-aptos v0.0.0-20221013022715-39f91035c785/go.mod h1:HaGBPmQOlKzxkbGancRSX8wcwDxvj9Zs173CSla43vE= +github.com/coming-chat/go-sui/v2 v2.0.1 h1:Mi7IGUvKd8OLP5zA3YhfDN/L5AJTXHsSsJnLb9WX9+4= +github.com/coming-chat/go-sui/v2 v2.0.1/go.mod h1:0/cgsi6HcHEfPFC05mY/ovzWuxxpmKxiY0NIEFgMP4g= github.com/consensys/bavard v0.1.13 h1:oLhMLOFGTLdlda/kma4VOJazblc7IM5y5QPd2A/YjhQ= github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI= github.com/consensys/gnark-crypto v0.12.1 h1:lHH39WuuFgVHONRl3J0LRBtuYdQTumFSDtJF7HpyG8M= @@ -528,6 +534,8 @@ github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go. github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/ethereum/c-kzg-4844 v0.4.0 h1:3MS1s4JtA868KpJxroZoepdV0ZKBp3u/O5HcZ7R3nlY= github.com/ethereum/c-kzg-4844 v0.4.0/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0= +github.com/fardream/go-bcs v0.7.0 h1:4YIiCXrtUFiRT86TsvUx+tIennZBRXQCzrgt8xC2g0c= +github.com/fardream/go-bcs v0.7.0/go.mod h1:UsoxhIoe2GsVexX0s5NDLIChxeb/JUbjw7IWzzgF3Xk= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= @@ -1039,6 +1047,8 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -1166,6 +1176,8 @@ github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIw github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pattonkan/sui-go v0.1.0 h1:95re846OafM6erXSqk53UASESQocavRT/g418ic198E= +github.com/pattonkan/sui-go v0.1.0/go.mod h1:E07Cqy27cBNcef90eXnfi/1T5t4Hyn6RxxeK3+NxQ2A= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= @@ -1494,6 +1506,7 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/pkg/contracts/sui/coin.go b/pkg/contracts/sui/coin.go new file mode 100644 index 0000000000..0b4a019f0e --- /dev/null +++ b/pkg/contracts/sui/coin.go @@ -0,0 +1,17 @@ +package sui + +// CoinType represents the coin type for the SUI token +type CoinType string + +const ( + // SUI is the coin type for SUI, native gas token + SUI CoinType = "0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + + // SUIShort is the short coin type for SUI + SUIShort CoinType = "0x2::sui::SUI" +) + +// IsSUICoinType returns true if the given coin type is SUI +func IsSUICoinType(coinType CoinType) bool { + return coinType == SUI || coinType == SUIShort +} diff --git a/pkg/contracts/sui/coin_test.go b/pkg/contracts/sui/coin_test.go new file mode 100644 index 0000000000..a5db06ec18 --- /dev/null +++ b/pkg/contracts/sui/coin_test.go @@ -0,0 +1,38 @@ +package sui + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsSUICoinType(t *testing.T) { + tests := []struct { + name string + coinType CoinType + want bool + }{ + { + name: "SUI coin type", + coinType: SUI, + want: true, + }, + { + name: "SUI short coin type", + coinType: SUIShort, + want: true, + }, + { + name: "not SUI coin type", + coinType: "0xae330284eefb31f37777c78007fc3bc0e88ca69b1be267e1b803d37c9ea52dc6", + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := IsSUICoinType(test.coinType) + require.Equal(t, test.want, got) + }) + } +} diff --git a/pkg/contracts/sui/errors.go b/pkg/contracts/sui/errors.go index d3e1bbcbf7..a7ef696394 100644 --- a/pkg/contracts/sui/errors.go +++ b/pkg/contracts/sui/errors.go @@ -26,6 +26,9 @@ var ( // ErrParseEvent event parse error ErrParseEvent = errors.New("event parse error") + // ErrObjectOwnership is the error returned when a wrong object ownership is used in withdraw_and_call + ErrObjectOwnership = errors.New("wrong object ownership") + // retryableOutboundErrCodes are the outbound execution (if failed) error codes that are retryable. // The list is used to determine if a withdraw_and_call should fallback if rejected by the network. // Note: keep this list in sync with the actual implementation in `gateway.move` diff --git a/pkg/contracts/sui/gateway.go b/pkg/contracts/sui/gateway.go index f4fb428a58..48fcf394d1 100644 --- a/pkg/contracts/sui/gateway.go +++ b/pkg/contracts/sui/gateway.go @@ -10,11 +10,9 @@ import ( "cosmossdk.io/math" "github.com/block-vision/sui-go-sdk/models" "github.com/pkg/errors" + "golang.org/x/exp/constraints" ) -// CoinType represents the coin type for the inbound -type CoinType string - // EventType represents Gateway event type (both inbound & outbound) type EventType string @@ -38,14 +36,15 @@ type OutboundEventContent interface { TxNonce() uint64 } -// SUI is the coin type for SUI, native gas token -const SUI CoinType = "0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" - // Event types const ( DepositEvent EventType = "DepositEvent" DepositAndCallEvent EventType = "DepositAndCallEvent" WithdrawEvent EventType = "WithdrawEvent" + + // this event does not exist on gateway, we define it to make the outbound processing consistent + WithdrawAndCallEvent EventType = "WithdrawAndCallEvent" + // the gateway.move uses name "NonceIncreaseEvent", but here uses a more descriptive name CancelTxEvent EventType = "NonceIncreaseEvent" ) @@ -227,6 +226,12 @@ func (gw *Gateway) ParseEvent(event models.SuiEventResponse) (Event, error) { func (gw *Gateway) ParseOutboundEvent( res models.SuiTransactionBlockResponse, ) (event Event, content OutboundEventContent, err error) { + // a simple withdraw contains one single command, if it contains 3 commands, + // we try passing the transaction as a withdraw and call with PTB + if len(res.Transaction.Data.Transaction.Transactions) == ptbWithdrawAndCallCmdCount { + return gw.parseWithdrawAndCallPTB(res) + } + if len(res.Events) == 0 { return event, nil, errors.New("missing events") } @@ -312,6 +317,22 @@ func extractStr(kv map[string]any, key string) (string, error) { return v, nil } +// extractInteger extracts a float64 value from a map and converts it to any integer type +func extractInteger[T constraints.Integer](kv map[string]any, key string) (T, error) { + rawValue, ok := kv[key] + if !ok { + return 0, errors.Errorf("missing %s", key) + } + + v, ok := rawValue.(float64) + if !ok { + return 0, errors.Errorf("want float64, got %T for %s", rawValue, key) + } + + // #nosec G115 always in range + return T(v), nil +} + func convertPayload(data []any) ([]byte, error) { payload := make([]byte, len(data)) diff --git a/pkg/contracts/sui/gateway_test.go b/pkg/contracts/sui/gateway_test.go index f7d1d0cc9f..bc26bc4b45 100644 --- a/pkg/contracts/sui/gateway_test.go +++ b/pkg/contracts/sui/gateway_test.go @@ -377,6 +377,25 @@ func Test_ParseOutboundEvent(t *testing.T) { }, }, }, + { + name: "withdrawAndCall with PTB", + response: createPTBResponse(txHash, packageID, "200", "123"), + wantEvent: Event{ + TxHash: txHash, + EventIndex: 0, + EventType: WithdrawAndCallEvent, + content: WithdrawAndCallPTB{ + MoveCall: MoveCall{ + PackageID: packageID, + Module: moduleName, + Function: FuncWithdrawImpl, + ArgIndexes: ptbWithdrawImplArgIndexes, + }, + Amount: math.NewUint(200), + Nonce: 123, + }, + }, + }, { name: "cancelTx", response: models.SuiTransactionBlockResponse{ @@ -456,3 +475,120 @@ func Test_ParseOutboundEvent(t *testing.T) { }) } } + +func Test_extractInteger(t *testing.T) { + tests := []struct { + name string + kv map[string]any + key string + want any + errMsg string + }{ + { + name: "valid int8", + kv: map[string]any{"key": float64(42)}, + key: "key", + want: int8(42), + }, + { + name: "valid int16", + kv: map[string]any{"key": float64(1000)}, + key: "key", + want: int16(1000), + }, + { + name: "valid int32", + kv: map[string]any{"key": float64(100000)}, + key: "key", + want: int32(100000), + }, + { + name: "valid int64", + kv: map[string]any{"key": float64(1000000000)}, + key: "key", + want: int64(1000000000), + }, + { + name: "valid uint8", + kv: map[string]any{"key": float64(42)}, + key: "key", + want: uint8(42), + }, + { + name: "valid uint16", + kv: map[string]any{"key": float64(1000)}, + key: "key", + want: uint16(1000), + }, + { + name: "valid uint32", + kv: map[string]any{"key": float64(100000)}, + key: "key", + want: uint32(100000), + }, + { + name: "valid uint64", + kv: map[string]any{"key": float64(1000000000)}, + key: "key", + want: uint64(1000000000), + }, + { + name: "missing key", + kv: map[string]any{}, + key: "key", + errMsg: "missing key", + }, + { + name: "invalid value type", + kv: map[string]any{"key": "not a number"}, + key: "key", + errMsg: "want float64, got string for key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + switch tt.name { + case "valid int8": + got, err := extractInteger[int8](tt.kv, tt.key) + require.NoError(t, err) + require.Equal(t, tt.want, got) + case "valid int16": + got, err := extractInteger[int16](tt.kv, tt.key) + require.NoError(t, err) + require.Equal(t, tt.want, got) + case "valid int32": + got, err := extractInteger[int32](tt.kv, tt.key) + require.NoError(t, err) + require.Equal(t, tt.want, got) + case "valid int64": + got, err := extractInteger[int64](tt.kv, tt.key) + require.NoError(t, err) + require.Equal(t, tt.want, got) + case "valid uint8": + got, err := extractInteger[uint8](tt.kv, tt.key) + require.NoError(t, err) + require.Equal(t, tt.want, got) + case "valid uint16": + got, err := extractInteger[uint16](tt.kv, tt.key) + require.NoError(t, err) + require.Equal(t, tt.want, got) + case "valid uint32": + got, err := extractInteger[uint32](tt.kv, tt.key) + require.NoError(t, err) + require.Equal(t, tt.want, got) + case "valid uint64": + got, err := extractInteger[uint64](tt.kv, tt.key) + require.NoError(t, err) + require.Equal(t, tt.want, got) + default: + // Test error cases for all types + if tt.errMsg != "" { + // Test with int64 as an example + _, err := extractInteger[int64](tt.kv, tt.key) + require.ErrorContains(t, err, tt.errMsg) + } + } + }) + } +} diff --git a/pkg/contracts/sui/ptb_argument.go b/pkg/contracts/sui/ptb_argument.go new file mode 100644 index 0000000000..fa650c6968 --- /dev/null +++ b/pkg/contracts/sui/ptb_argument.go @@ -0,0 +1,58 @@ +package sui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/pattonkan/sui-go/sui" + "github.com/pattonkan/sui-go/sui/suiptb" + "github.com/pkg/errors" +) + +// PureUint64ArgFromStr converts a string to a uint64 and creates a PTB pure argument +func PureUint64FromString( + ptb *suiptb.ProgrammableTransactionBuilder, + integerStr string, +) (arg suiptb.Argument, value uint64, err error) { + value, err = strconv.ParseUint(integerStr, 10, 64) + if err != nil { + return suiptb.Argument{}, 0, errors.Wrapf(err, "failed to parse amount %s", integerStr) + } + + arg, err = ptb.Pure(value) + if err != nil { + return suiptb.Argument{}, 0, errors.Wrapf(err, "failed to create amount argument") + } + + return arg, value, nil +} + +// TypeTagFromString creates a PTB type argument StructTag from a type string +// Example: "0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" -> +// +// &sui.StructTag{ +// Address: "0x0000000000000000000000000000000000000000000000000000000000000002", +// Module: "sui", +// Name: "SUI", +// } +func TypeTagFromString(t string) (tag sui.StructTag, err error) { + parts := strings.Split(t, typeSeparator) + if len(parts) != 3 { + return tag, fmt.Errorf("invalid type string: %s", t) + } + + address, err := sui.AddressFromHex(parts[0]) + if err != nil { + return tag, errors.Wrapf(err, "invalid address: %s", parts[0]) + } + + module := parts[1] + name := parts[2] + + return sui.StructTag{ + Address: address, + Module: module, + Name: name, + }, nil +} diff --git a/pkg/contracts/sui/ptb_argument_test.go b/pkg/contracts/sui/ptb_argument_test.go new file mode 100644 index 0000000000..2577f4386f --- /dev/null +++ b/pkg/contracts/sui/ptb_argument_test.go @@ -0,0 +1,69 @@ +package sui + +import ( + "testing" + + "github.com/pattonkan/sui-go/sui" + "github.com/stretchr/testify/require" +) + +func Test_TypeTagFromString(t *testing.T) { + suiAddr, err := sui.AddressFromHex("0000000000000000000000000000000000000000000000000000000000000002") + require.NoError(t, err) + + otherAddrStr := "0xae330284eefb31f37777c78007fc3bc0e88ca69b1be267e1b803d37c9ea52dc6" + otherAddr, err := sui.AddressFromHex(otherAddrStr) + require.NoError(t, err) + + tests := []struct { + name string + coinType CoinType + want sui.StructTag + errMsg string + }{ + { + name: "SUI coin type", + coinType: SUI, + want: sui.StructTag{ + Address: suiAddr, + Module: "sui", + Name: "SUI", + }, + }, + { + name: "some other coin type", + coinType: CoinType(otherAddrStr + "::other::TOKEN"), + want: sui.StructTag{ + Address: otherAddr, + Module: "other", + Name: "TOKEN", + }, + }, + { + name: "invalid type string", + coinType: CoinType(otherAddrStr), + want: sui.StructTag{}, + errMsg: "invalid type string", + }, + { + name: "invalid address", + coinType: "invalid::sui::SUI", + want: sui.StructTag{}, + errMsg: "invalid address", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := TypeTagFromString(string(test.coinType)) + if test.errMsg != "" { + require.Empty(t, got) + require.ErrorContains(t, err, test.errMsg) + return + } + + require.NoError(t, err) + require.Equal(t, test.want, got) + }) + } +} diff --git a/pkg/contracts/sui/withdraw_and_call.go b/pkg/contracts/sui/withdraw_and_call.go new file mode 100644 index 0000000000..6877aeefa2 --- /dev/null +++ b/pkg/contracts/sui/withdraw_and_call.go @@ -0,0 +1,234 @@ +package sui + +import ( + "fmt" + "slices" + "strconv" + + "cosmossdk.io/math" + "github.com/block-vision/sui-go-sdk/models" + "github.com/pkg/errors" +) + +const ( + // FuncWithdrawImpl is the gateway function name withdraw_impl + FuncWithdrawImpl = "withdraw_impl" + + // ModuleConnected is the Sui connected module name + ModuleConnected = "connected" + + // FuncOnCall is the Sui connected module function name on_call + FuncOnCall = "on_call" + + // typeSeparator is the separator for Sui package and module + typeSeparator = "::" + + // ptbWithdrawAndCallCmdCount is the number of commands in the PTB withdraw and call + // the three commands are: [withdraw_impl, transfer_objects, on_call] + ptbWithdrawAndCallCmdCount = 3 + + // ptbWithdrawImplInputCount is the number of inputs in the withdraw_impl command + // the inputs are: [gatewayObject, amount, nonce, gasBudget, withdrawCap] + ptbWithdrawImplInputCount = 5 +) + +// ptbWithdrawImplArgIndexes is the indexes of the inputs for the withdraw_impl command +// these are the corresponding indexes for arguments: [gatewayObject, amount, nonce, gasBudget, withdrawCap] +// the withdraw_impl command is the first command in the PTB, so the indexes will always be [0, 1, 2, 3, 4] +var ptbWithdrawImplArgIndexes = []int{0, 1, 2, 3, 4} + +// MoveCall represents a Sui Move call with package ID, module and function +type MoveCall struct { + PackageID string + Module string + Function string + ArgIndexes []int +} + +// WithdrawAndCallPTB represents data for a Sui withdraw and call event +type WithdrawAndCallPTB struct { + MoveCall + Amount math.Uint + Nonce uint64 +} + +// TokenAmount returns the amount of the withdraw and call +func (d WithdrawAndCallPTB) TokenAmount() math.Uint { + return d.Amount +} + +// TxNonce returns the nonce of the withdraw and call +func (d WithdrawAndCallPTB) TxNonce() uint64 { + return d.Nonce +} + +// ExtractInitialSharedVersion extracts the object initial shared version from the object data +// +// Objects referenced for on_call are shared objects, initial shared version is required to build +// the withdraw and call using PTB. +// see: https://docs.sui.io/concepts/transactions/prog-txn-blocks#inputs +func ExtractInitialSharedVersion(objData models.SuiObjectData) (uint64, error) { + owner, ok := objData.Owner.(map[string]any) + if !ok { + return 0, fmt.Errorf("invalid object owner type %T", objData.Owner) + } + + shared, ok := owner["Shared"] + if !ok { + return 0, fmt.Errorf("missing shared object") + } + + sharedMap, ok := shared.(map[string]any) + if !ok { + return 0, fmt.Errorf("invalid shared object type %T", shared) + } + + return extractInteger[uint64](sharedMap, "initial_shared_version") +} + +// parseWithdrawAndCallPTB parses withdraw and call with PTB. +// There is no actual event on gateway for withdraw and call, but we construct our own event to make the logic consistent. +func (gw *Gateway) parseWithdrawAndCallPTB( + res models.SuiTransactionBlockResponse, +) (event Event, content OutboundEventContent, err error) { + tx := res.Transaction.Data.Transaction + + // the number of PTB commands should be 3 + if len(tx.Transactions) != ptbWithdrawAndCallCmdCount { + return event, nil, errors.Wrapf( + ErrParseEvent, + "invalid number of commands(%d) in the PTB", + len(tx.Transactions), + ) + } + + // the number of PTB inputs should be >= 5 + if len(tx.Inputs) < ptbWithdrawImplInputCount { + return event, nil, errors.Wrapf( + ErrParseEvent, + "invalid number of inputs(%d) in the PTB", + len(tx.Inputs), + ) + } + + // parse withdraw_impl at command 0 + moveCall, err := extractMoveCall(tx.Transactions[0]) + if err != nil { + return event, nil, errors.Wrap(ErrParseEvent, "unable to parse withdraw_impl command in the PTB") + } + + if moveCall.PackageID != gw.packageID { + return event, nil, errors.Wrapf(ErrParseEvent, "invalid package id %s in the PTB", moveCall.PackageID) + } + + if moveCall.Module != moduleName { + return event, nil, errors.Wrapf(ErrParseEvent, "invalid module name %s in the PTB", moveCall.Module) + } + + if moveCall.Function != FuncWithdrawImpl { + return event, nil, errors.Wrapf(ErrParseEvent, "invalid function name %s in the PTB", moveCall.Function) + } + + // ensure the argument indexes are matching the expected indexes + if !slices.Equal(moveCall.ArgIndexes, ptbWithdrawImplArgIndexes) { + return event, nil, errors.Wrapf(ErrParseEvent, "invalid argument indexes %v", moveCall.ArgIndexes) + } + + // parse withdraw_impl arguments + // argument1: amount + amountStr, err := extractStr(tx.Inputs[1], "value") + if err != nil { + return Event{}, nil, errors.Wrap(ErrParseEvent, "unable to extract amount") + } + amount, err := math.ParseUint(amountStr) + if err != nil { + return Event{}, nil, errors.Wrap(ErrParseEvent, "unable to parse amount") + } + + // argument2: nonce + nonceStr, err := extractStr(tx.Inputs[2], "value") + if err != nil { + return Event{}, nil, errors.Wrap(ErrParseEvent, "unable to extract nonce") + } + nonce, err := strconv.ParseUint(nonceStr, 10, 64) + if err != nil { + return Event{}, nil, errors.Wrap(ErrParseEvent, "unable to parse nonce") + } + + content = WithdrawAndCallPTB{ + MoveCall: moveCall, + Amount: amount, + Nonce: nonce, + } + + event = Event{ + TxHash: res.Digest, + EventIndex: 0, + EventType: WithdrawAndCallEvent, + content: content, + } + + return event, content, nil +} + +// extractMoveCall extracts the MoveCall information from the PTB transaction command +func extractMoveCall(transaction any) (MoveCall, error) { + commands, ok := transaction.(map[string]any) + if !ok { + return MoveCall{}, errors.Wrap(ErrParseEvent, "invalid command type") + } + + // parse MoveCall info + moveCall, ok := commands["MoveCall"].(map[string]any) + if !ok { + return MoveCall{}, errors.Wrap(ErrParseEvent, "missing MoveCall") + } + + packageID, err := extractStr(moveCall, "package") + if err != nil { + return MoveCall{}, errors.Wrap(ErrParseEvent, "missing package ID") + } + + module, err := extractStr(moveCall, "module") + if err != nil { + return MoveCall{}, errors.Wrap(ErrParseEvent, "missing module name") + } + + function, err := extractStr(moveCall, "function") + if err != nil { + return MoveCall{}, errors.Wrap(ErrParseEvent, "missing function name") + } + + // parse MoveCall data + data, ok := moveCall["arguments"] + if !ok { + return MoveCall{}, errors.Wrap(ErrParseEvent, "missing arguments") + } + + arguments, ok := data.([]any) + if !ok { + return MoveCall{}, errors.Wrap(ErrParseEvent, "arguments should be of slice type") + } + + // extract MoveCall argument indexes + argIndexes := make([]int, len(arguments)) + for i, arg := range arguments { + indexes, ok := arg.(map[string]any) + if !ok { + return MoveCall{}, errors.Wrap(ErrParseEvent, "invalid argument type") + } + + index, err := extractInteger[int](indexes, "Input") + if err != nil { + return MoveCall{}, errors.Wrap(ErrParseEvent, "missing argument index") + } + argIndexes[i] = index + } + + return MoveCall{ + PackageID: packageID, + Module: module, + Function: function, + ArgIndexes: argIndexes, + }, nil +} diff --git a/pkg/contracts/sui/withdraw_and_call_test.go b/pkg/contracts/sui/withdraw_and_call_test.go new file mode 100644 index 0000000000..44c657c10d --- /dev/null +++ b/pkg/contracts/sui/withdraw_and_call_test.go @@ -0,0 +1,293 @@ +package sui + +import ( + "testing" + + "cosmossdk.io/math" + "github.com/block-vision/sui-go-sdk/models" + "github.com/stretchr/testify/require" +) + +func Test_WithdrawAndCallPTB_TokenAmount(t *testing.T) { + event := WithdrawAndCallPTB{ + Amount: math.NewUint(100), + Nonce: 1, + } + require.Equal(t, math.NewUint(100), event.TokenAmount()) +} + +func Test_WithdrawAndCallPTB_TxNonce(t *testing.T) { + event := WithdrawAndCallPTB{ + Amount: math.NewUint(100), + Nonce: 1, + } + require.Equal(t, uint64(1), event.TxNonce()) +} + +func Test_ExtractInitialSharedVersion(t *testing.T) { + tests := []struct { + name string + objData models.SuiObjectData + wantVersion uint64 + errMsg string + }{ + { + name: "successful extraction", + objData: models.SuiObjectData{ + Owner: map[string]any{ + "Shared": map[string]any{ + "initial_shared_version": float64(3), + }, + }, + }, + wantVersion: 3, + }, + { + name: "invalid owner type", + objData: models.SuiObjectData{ + Owner: "invalid", + }, + wantVersion: 0, + errMsg: "invalid object owner type string", + }, + { + name: "missing shared object", + objData: models.SuiObjectData{ + Owner: map[string]any{ + "Owned": map[string]any{}, + }, + }, + wantVersion: 0, + errMsg: "missing shared object", + }, + { + name: "invalid shared object type", + objData: models.SuiObjectData{ + Owner: map[string]any{ + "Shared": "invalid", + }, + }, + wantVersion: 0, + errMsg: "invalid shared object type string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version, err := ExtractInitialSharedVersion(tt.objData) + if tt.errMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantVersion, version) + }) + } +} + +func Test_parseWithdrawAndCallPTB(t *testing.T) { + const ( + txHash = "AvpjaWZmHEJqj6AewiyK3bziGE2RPH5URub9nMx1sNL7" + packageID = "0xa0464ffe0ffb12b2a474e0669e15aeb0b1e2b31a1865cca83f47b42c4707a550" + amountStr = "100" + nonceStr = "2" + ) + + gw := &Gateway{ + packageID: packageID, + } + + tests := []struct { + name string + response models.SuiTransactionBlockResponse + want WithdrawAndCallPTB + errMsg string + }{ + { + name: "valid transaction block", + response: createPTBResponse(txHash, packageID, amountStr, nonceStr), + want: WithdrawAndCallPTB{ + MoveCall: MoveCall{ + PackageID: packageID, + Module: moduleName, + Function: FuncWithdrawImpl, + ArgIndexes: ptbWithdrawImplArgIndexes, + }, + Amount: math.NewUint(100), + Nonce: 2, + }, + }, + { + name: "invalid number of commands", + response: func() models.SuiTransactionBlockResponse { + res := createPTBResponse(txHash, packageID, amountStr, nonceStr) + res.Transaction.Data.Transaction.Transactions = res.Transaction.Data.Transaction.Transactions[:2] + return res + }(), + errMsg: "invalid number of commands", + }, + { + name: "invalid number of inputs", + response: func() models.SuiTransactionBlockResponse { + res := createPTBResponse(txHash, packageID, amountStr, nonceStr) + res.Transaction.Data.Transaction.Inputs = res.Transaction.Data.Transaction.Inputs[:4] + return res + }(), + errMsg: "invalid number of inputs", + }, + { + name: "unable to parse withdraw_impl", + response: func() models.SuiTransactionBlockResponse { + res := createPTBResponse(txHash, packageID, amountStr, nonceStr) + res.Transaction.Data.Transaction.Transactions[0] = "invalid" + return res + }(), + errMsg: "unable to parse withdraw_impl command", + }, + { + name: "invalid package ID", + response: func() models.SuiTransactionBlockResponse { + res := createPTBResponse(txHash, packageID, amountStr, nonceStr) + moveCall := res.Transaction.Data.Transaction.Transactions[0].(map[string]any)["MoveCall"].(map[string]any) + moveCall["package"] = "wrong_package" + return res + }(), + errMsg: "invalid package id", + }, + { + name: "invalid module name", + response: func() models.SuiTransactionBlockResponse { + res := createPTBResponse(txHash, packageID, amountStr, nonceStr) + moveCall := res.Transaction.Data.Transaction.Transactions[0].(map[string]any)["MoveCall"].(map[string]any) + moveCall["module"] = "wrong_module" + return res + }(), + errMsg: "invalid module name", + }, + { + name: "invalid function name", + response: func() models.SuiTransactionBlockResponse { + res := createPTBResponse(txHash, packageID, amountStr, nonceStr) + moveCall := res.Transaction.Data.Transaction.Transactions[0].(map[string]any)["MoveCall"].(map[string]any) + moveCall["function"] = "wrong_function" + return res + }(), + errMsg: "invalid function name", + }, + { + name: "invalid argument indexes", + response: func() models.SuiTransactionBlockResponse { + res := createPTBResponse(txHash, packageID, amountStr, nonceStr) + moveCall := res.Transaction.Data.Transaction.Transactions[0].(map[string]any)["MoveCall"].(map[string]any) + arguments := moveCall["arguments"].([]any) + arguments[0] = map[string]any{"Input": float64(5)} // Change index to make it invalid + return res + }(), + errMsg: "invalid argument indexes", + }, + { + name: "invalid amount format", + response: func() models.SuiTransactionBlockResponse { + res := createPTBResponse(txHash, packageID, amountStr, nonceStr) + res.Transaction.Data.Transaction.Inputs[1] = models.SuiCallArg{ + "value": "invalid_number", + } + return res + }(), + errMsg: "unable to parse amount", + }, + { + name: "invalid nonce format", + response: func() models.SuiTransactionBlockResponse { + res := createPTBResponse(txHash, packageID, amountStr, nonceStr) + res.Transaction.Data.Transaction.Inputs[2] = models.SuiCallArg{ + "value": "invalid_nonce", + } + return res + }(), + errMsg: "unable to parse nonce", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + event, content, err := gw.parseWithdrawAndCallPTB(test.response) + if test.errMsg != "" { + require.ErrorContains(t, err, test.errMsg) + return + } + + require.NoError(t, err) + require.Equal(t, txHash, event.TxHash) + require.Zero(t, event.EventIndex) + require.Equal(t, WithdrawAndCallEvent, event.EventType) + + withdrawCallPTB, ok := content.(WithdrawAndCallPTB) + require.True(t, ok) + require.Equal(t, test.want, withdrawCallPTB) + }) + } +} + +func createPTBResponse(txHash, packageID, amount, nonce string) models.SuiTransactionBlockResponse { + return models.SuiTransactionBlockResponse{ + Digest: txHash, + Transaction: models.SuiTransactionBlock{ + Data: models.SuiTransactionBlockData{ + Transaction: models.SuiTransactionBlockKind{ + Inputs: []models.SuiCallArg{ + { + "initialSharedVersion": "3", + "objectId": "0xb3630c3eba7b1211c12604a4ceade7a5c0811c4a5eb55af227f9943fcef0e24c", + }, + { + "type": "pure", + "value": amount, + }, + { + "type": "pure", + "value": nonce, + }, + { + "type": "pure", + "value": "1000", + }, + { + "digest": "26kdbzHiCt4nBFbMA6DjaazjRw98d6BRbe5gy81Wr1Aj", + "objectId": "0x48a52371089644d30703f726ca5c30cf76e85347b263549e092ccf22ac059c6c", + }, + }, + Transactions: []any{ + map[string]any{ + "MoveCall": map[string]any{ + "arguments": []any{ + map[string]any{"Input": float64(0)}, + map[string]any{"Input": float64(1)}, + map[string]any{"Input": float64(2)}, + map[string]any{"Input": float64(3)}, + map[string]any{"Input": float64(4)}, + }, + "function": FuncWithdrawImpl, + "module": moduleName, + "package": packageID, + }, + }, + map[string]any{ + "TransferObjects": map[string]any{}, + }, + map[string]any{ + "MoveCall": map[string]any{ + "arguments": []any{}, + "function": FuncOnCall, + "module": ModuleConnected, + "package": "target_package_id", + }, + }, + }, + }, + }, + }, + } +} diff --git a/testutil/sample/crypto.go b/testutil/sample/crypto.go index b631520c4e..18e90d84d0 100644 --- a/testutil/sample/crypto.go +++ b/testutil/sample/crypto.go @@ -13,6 +13,8 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/cometbft/cometbft/crypto/secp256k1" + "github.com/coming-chat/go-sui/v2/account" + "github.com/coming-chat/go-sui/v2/sui_types" "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" sdk "github.com/cosmos/cosmos-sdk/types" @@ -21,6 +23,7 @@ import ( ethtypes "github.com/ethereum/go-ethereum/core/types" ethcrypto "github.com/ethereum/go-ethereum/crypto" "github.com/gagliardetto/solana-go" + "github.com/mr-tron/base58" "github.com/stretchr/testify/require" "github.com/zeta-chain/node/pkg/cosmos" @@ -161,6 +164,27 @@ func SolanaSignature(t *testing.T) solana.Signature { return signature } +// SuiAddress returns a sample sui address +func SuiAddress(t require.TestingT) string { + privateKey := ed25519.GenPrivKey() + + // create a new account with ed25519 scheme + scheme, err := sui_types.NewSignatureScheme(0) + require.NoError(t, err) + acc := account.NewAccount(scheme, privateKey.GetKey().Seed()) + + return acc.Address +} + +// SuiDigest returns a sample sui digest +func SuiDigest(t *testing.T) string { + randomBytes := make([]byte, 32) + _, err := rand.Read(randomBytes) + require.NoError(t, err) + + return base58.Encode(randomBytes) +} + // Hash returns a sample hash func Hash() ethcommon.Hash { return ethcommon.BytesToHash(EthAddress().Bytes()) diff --git a/zetaclient/chains/sui/client/client.go b/zetaclient/chains/sui/client/client.go index 1e597d3cc1..256eb79d25 100644 --- a/zetaclient/chains/sui/client/client.go +++ b/zetaclient/chains/sui/client/client.go @@ -10,7 +10,10 @@ import ( "github.com/block-vision/sui-go-sdk/models" "github.com/block-vision/sui-go-sdk/sui" + suiptb "github.com/pattonkan/sui-go/sui" "github.com/pkg/errors" + + zetasui "github.com/zeta-chain/node/pkg/contracts/sui" ) // Client Sui client. @@ -247,6 +250,60 @@ func (c *Client) SuiExecuteTransactionBlock( return parseRPCResponse[models.SuiTransactionBlockResponse]([]byte(resString)) } +// GetSuiCoinObjectRef returns the latest SUI coin object reference for given owner address +// Note: the SUI object may change over time, so we need to get the latest object +func (c *Client) GetSuiCoinObjectRef(ctx context.Context, owner string) (suiptb.ObjectRef, error) { + coins, err := c.SuiXGetCoins(ctx, models.SuiXGetCoinsRequest{ + Owner: owner, + CoinType: string(zetasui.SUI), + }) + if err != nil { + return suiptb.ObjectRef{}, errors.Wrap(err, "unable to get TSS coins") + } + + var ( + suiCoin *models.CoinData + suiCoinVersion uint64 + ) + + // locate the latest version of SUI coin object of given owner + for _, coin := range coins.Data { + if !zetasui.IsSUICoinType(zetasui.CoinType(coin.CoinType)) { + continue + } + + version, err := strconv.ParseUint(coin.Version, 10, 64) + if err != nil { + return suiptb.ObjectRef{}, errors.Wrapf(err, "failed to parse SUI coin version %s", coin.Version) + } + + if version > suiCoinVersion { + suiCoin = &coin + suiCoinVersion = version + } + } + if suiCoin == nil { + return suiptb.ObjectRef{}, errors.New("SUI coin not found") + } + + // convert coin data to object ref + suiCoinID, err := suiptb.ObjectIdFromHex(suiCoin.CoinObjectId) + if err != nil { + return suiptb.ObjectRef{}, errors.Wrapf(err, "failed to parse SUI coin ID: %s", suiCoin.CoinObjectId) + } + + suiCoinDigest, err := suiptb.NewBase58(suiCoin.Digest) + if err != nil { + return suiptb.ObjectRef{}, errors.Wrapf(err, "failed to parse SUI coin digest: %s", suiCoin.Digest) + } + + return suiptb.ObjectRef{ + ObjectId: suiCoinID, + Version: suiCoinVersion, + Digest: suiCoinDigest, + }, nil +} + // EncodeCursor encodes event ID into cursor. func EncodeCursor(id models.EventId) string { return fmt.Sprintf("%s,%s", id.TxDigest, id.EventSeq) @@ -317,28 +374,29 @@ func (c *Client) CheckObjectIDsShared(ctx context.Context, objectIDs []string) e return fmt.Errorf("expected %d objects, but got %d", len(objectIDs), len(res)) } - return checkContainOwnedObject(res) + return CheckContainOwnedObject(res) } -func checkContainOwnedObject(res []*models.SuiObjectResponse) error { +// CheckContainOwnedObject checks if the provided object list represents Sui shared or immmutable objects +func CheckContainOwnedObject(res []*models.SuiObjectResponse) error { for i, obj := range res { if obj.Data == nil { - return fmt.Errorf("object %d is missing data", i) + return errors.Wrapf(zetasui.ErrObjectOwnership, "object %d is missing data", i) } switch owner := obj.Data.Owner.(type) { case string: if owner != immutableOwner { - return fmt.Errorf("object %d has unexpected string owner: %s", i, owner) + return errors.Wrapf(zetasui.ErrObjectOwnership, "object %d has unexpected string owner: %s", i, owner) } // Immutable is valid, continue - case map[string]interface{}: + case map[string]any: if _, isShared := owner[sharedOwner]; !isShared { - return fmt.Errorf("object %d is not shared or immutable: owner = %+v", i, owner) + return errors.Wrapf(zetasui.ErrObjectOwnership, "object %d is not shared or immutable: owner = %+v", i, owner) } // Shared is valid, continue default: - return fmt.Errorf("object %d has unknown owner type: %+v", i, obj.Data.Owner) + return errors.Wrapf(zetasui.ErrObjectOwnership, "object %d has unknown owner type: %+v", i, obj.Data.Owner) } } diff --git a/zetaclient/chains/sui/client/client_test.go b/zetaclient/chains/sui/client/client_test.go index cb5fa09583..75046f465d 100644 --- a/zetaclient/chains/sui/client/client_test.go +++ b/zetaclient/chains/sui/client/client_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestContainsOwnedObject(t *testing.T) { +func TestCheckContainOwnedObject(t *testing.T) { tests := []struct { name string input []*models.SuiObjectResponse @@ -68,7 +68,7 @@ func TestContainsOwnedObject(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := checkContainOwnedObject(tt.input) + err := CheckContainOwnedObject(tt.input) if tt.wantErr { require.Error(t, err) } else { diff --git a/zetaclient/chains/sui/observer/observer.go b/zetaclient/chains/sui/observer/observer.go index ca009a51bf..9075d3dfa9 100644 --- a/zetaclient/chains/sui/observer/observer.go +++ b/zetaclient/chains/sui/observer/observer.go @@ -37,7 +37,6 @@ type RPC interface { QueryModuleEvents(ctx context.Context, q client.EventQuery) ([]models.SuiEventResponse, string, error) SuiXGetReferenceGasPrice(ctx context.Context) (uint64, error) - SuiGetObject(ctx context.Context, req models.SuiGetObjectRequest) (models.SuiObjectResponse, error) SuiGetTransactionBlock( ctx context.Context, req models.SuiGetTransactionBlockRequest, diff --git a/zetaclient/chains/sui/observer/outbound.go b/zetaclient/chains/sui/observer/outbound.go index 9b809b4912..b7adcb5893 100644 --- a/zetaclient/chains/sui/observer/outbound.go +++ b/zetaclient/chains/sui/observer/outbound.go @@ -151,9 +151,6 @@ func (ob *Observer) VoteOutbound(ctx context.Context, cctx *cctypes.CrossChainTx cctypes.ConfirmationMode_SAFE, ) - // TODO compliance checks - // https://github.com/zeta-chain/node/issues/3584 - if err := ob.postVoteOutbound(ctx, msg); err != nil { return errors.Wrap(err, "unable to post vote outbound") } @@ -238,6 +235,7 @@ func (ob *Observer) postVoteOutbound(ctx context.Context, msg *cctypes.MsgVoteOu return errors.Wrap(err, "unable to post vote outbound") case zetaTxHash != "": ob.Logger().Outbound.Info(). + Str(logs.FieldTx, msg.ObservedOutboundHash). Str(logs.FieldZetaTx, zetaTxHash). Str(logs.FieldBallot, ballot). Msg("PostVoteOutbound: posted outbound vote successfully") diff --git a/zetaclient/chains/sui/signer/signer.go b/zetaclient/chains/sui/signer/signer.go index 197b1c8e9f..99c6804221 100644 --- a/zetaclient/chains/sui/signer/signer.go +++ b/zetaclient/chains/sui/signer/signer.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/block-vision/sui-go-sdk/models" + suiptb "github.com/pattonkan/sui-go/sui" "github.com/pkg/errors" "github.com/zeta-chain/node/pkg/bg" @@ -14,6 +15,7 @@ import ( "github.com/zeta-chain/node/zetaclient/chains/base" "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/compliance" + "github.com/zeta-chain/node/zetaclient/logs" ) // Signer Sui outbound transaction signer. @@ -30,6 +32,8 @@ type Signer struct { type RPC interface { SuiXGetLatestSuiSystemState(ctx context.Context) (models.SuiSystemStateSummary, error) GetOwnedObjectID(ctx context.Context, ownerAddress, structType string) (string, error) + SuiMultiGetObjects(ctx context.Context, req models.SuiMultiGetObjectsRequest) ([]*models.SuiObjectResponse, error) + GetSuiCoinObjectRef(ctx context.Context, owner string) (suiptb.ObjectRef, error) MoveCall(ctx context.Context, req models.MoveCallRequest) (models.TxnMetaData, error) SuiExecuteTransactionBlock( @@ -84,6 +88,10 @@ func (s *Signer) ProcessCCTX(ctx context.Context, cctx *cctypes.CrossChainTx, ze return errors.Wrap(err, "unable to create cancel tx builder") } + // prepare logger + logger := s.Logger().Std.With().Uint64(logs.FieldNonce, nonce).Logger() + ctx = logger.WithContext(ctx) + var txDigest string // broadcast tx according to compliance check result diff --git a/zetaclient/chains/sui/signer/signer_tracker.go b/zetaclient/chains/sui/signer/signer_tracker.go index ae606f3974..5e0efdd8a4 100644 --- a/zetaclient/chains/sui/signer/signer_tracker.go +++ b/zetaclient/chains/sui/signer/signer_tracker.go @@ -6,6 +6,7 @@ import ( "github.com/block-vision/sui-go-sdk/models" "github.com/pkg/errors" + "github.com/rs/zerolog" "github.com/zeta-chain/node/zetaclient/chains/sui/client" "github.com/zeta-chain/node/zetaclient/logs" @@ -21,10 +22,8 @@ func (s *Signer) reportOutboundTracker(ctx context.Context, nonce uint64, digest const maxTimeout = time.Minute // prepare logger - logger := s.Logger().Std.With(). + logger := zerolog.Ctx(ctx).With(). Str(logs.FieldMethod, "reportOutboundTracker"). - Int64(logs.FieldChain, s.Chain().ChainId). - Uint64(logs.FieldNonce, nonce). Str(logs.FieldTx, digest). Logger() diff --git a/zetaclient/chains/sui/signer/signer_tx.go b/zetaclient/chains/sui/signer/signer_tx.go index e9de52b5dc..9203d49132 100644 --- a/zetaclient/chains/sui/signer/signer_tx.go +++ b/zetaclient/chains/sui/signer/signer_tx.go @@ -85,7 +85,7 @@ func (s *Signer) buildWithdrawal(ctx context.Context, cctx *cctypes.CrossChainTx if err != nil { return tx, errors.Wrap(err, "unable to parse gas price") } - gasBudget := strconv.FormatUint(gasPrice*params.CallOptions.GasLimit, 10) + gasBudget := gasPrice * params.CallOptions.GasLimit // Retrieve withdraw cap ID withdrawCapID, err := s.getWithdrawCapIDCached(ctx) @@ -105,12 +105,15 @@ func (s *Signer) buildWithdrawal(ctx context.Context, cctx *cctypes.CrossChainTx func (s *Signer) buildWithdrawTx( ctx context.Context, params *cctypes.OutboundParams, - coinType, gasBudget, withdrawCapID string, + coinType string, + gasBudget uint64, + withdrawCapID string, ) (models.TxnMetaData, error) { var ( - nonce = strconv.FormatUint(params.TssNonce, 10) - recipient = params.Receiver - amount = params.Amount.String() + nonce = strconv.FormatUint(params.TssNonce, 10) + recipient = params.Receiver + amount = params.Amount.String() + gasBudgetStr = strconv.FormatUint(gasBudget, 10) ) req := models.MoveCallRequest{ @@ -119,8 +122,8 @@ func (s *Signer) buildWithdrawTx( Module: s.gateway.Module(), Function: funcWithdraw, TypeArguments: []any{coinType}, - Arguments: []any{s.gateway.ObjectID(), amount, nonce, recipient, gasBudget, withdrawCapID}, - GasBudget: gasBudget, + Arguments: []any{s.gateway.ObjectID(), amount, nonce, recipient, gasBudgetStr, withdrawCapID}, + GasBudget: gasBudgetStr, } return s.client.MoveCall(ctx, req) @@ -131,13 +134,13 @@ func (s *Signer) buildWithdrawTx( func (s *Signer) buildWithdrawAndCallTx( ctx context.Context, params *cctypes.OutboundParams, - coinType, - gasBudget, - withdrawCapID, - payload string, + coinType string, + gasBudget uint64, + withdrawCapID string, + payloadHex string, ) (models.TxnMetaData, error) { - // decode and parse the payload to object the on_call arguments - payloadBytes, err := hex.DecodeString(payload) + // decode and parse the payload into object IDs and on_call arguments + payloadBytes, err := hex.DecodeString(payloadHex) if err != nil { return models.TxnMetaData{}, errors.Wrap(err, "unable to decode payload hex bytes") } @@ -147,28 +150,38 @@ func (s *Signer) buildWithdrawAndCallTx( return models.TxnMetaData{}, errors.Wrap(err, "unable to parse withdrawAndCall payload") } - // Note: logs not formatted in standard, it's a temporary log - s.Logger().Std.Info().Msgf( - "WithdrawAndCall called with type arguments %v, object IDs %v, message %v", - cp.TypeArgs, - cp.ObjectIDs, - cp.Message, - ) - - // keep lint quiet without using _ in params - _ = ctx - _ = params - _ = coinType - _ = gasBudget - _ = withdrawCapID - - // TODO: check all object IDs are share object here - // https://github.com/zeta-chain/node/issues/3755 + // get all needed object references + wacRefs, err := s.getWithdrawAndCallObjectRefs(ctx, withdrawCapID, cp.ObjectIDs) + if err != nil { + return models.TxnMetaData{}, errors.Wrap(err, "unable to get object references") + } - // TODO: build PTB here - // https://github.com/zeta-chain/node/issues/3741 + // all PTB arguments + args := withdrawAndCallPTBArgs{ + withdrawAndCallObjRefs: wacRefs, + coinType: coinType, + amount: params.Amount.Uint64(), + nonce: params.TssNonce, + gasBudget: gasBudget, + receiver: params.Receiver, + payload: cp, + } - return models.TxnMetaData{}, errors.New("not implemented") + // print PTB transaction parameters + s.Logger().Std.Info(). + Str(logs.FieldMethod, "buildWithdrawAndCallTx"). + Uint64(logs.FieldNonce, args.nonce). + Str(logs.FieldCoinType, args.coinType). + Uint64("tx.amount", args.amount). + Str("tx.receiver", args.receiver). + Uint64("tx.gas_budget", args.gasBudget). + Strs("tx.type_args", args.payload.TypeArgs). + Strs("tx.object_ids", args.payload.ObjectIDs). + Hex("tx.payload", args.payload.Message). + Msg("calling withdrawAndCallPTB") + + // build the PTB transaction + return s.withdrawAndCallPTB(args) } // createCancelTxBuilder creates a cancel tx builder for given CCTX @@ -236,7 +249,13 @@ func (s *Signer) broadcastWithdrawalWithFallback( } tx, sig, err := withdrawTxBuilder(ctx) - if err != nil { + + // we should cancel withdrawAndCall if user provided objects are not shared or immutable + switch { + case errors.Is(err, sui.ErrObjectOwnership): + logger.Info().Err(err).Msg("cancelling tx due to wrong object ownership") + return s.broadcastCancelTx(ctx, cancelTxBuilder) + case err != nil: return "", errors.Wrap(err, "unable to build withdraw tx") } @@ -284,7 +303,7 @@ func (s *Signer) broadcastWithdrawalWithFallback( // broadcastCancelTx broadcasts a cancel tx and returns the tx digest func (s *Signer) broadcastCancelTx(ctx context.Context, cancelTxBuilder txBuilder) (string, error) { - logger := zerolog.Ctx(ctx) + logger := zerolog.Ctx(ctx).With().Str(logs.FieldMethod, "broadcastCancelTx").Logger() // build cancel tx txCancel, sigCancel, err := cancelTxBuilder(ctx) diff --git a/zetaclient/chains/sui/signer/withdraw_and_call.go b/zetaclient/chains/sui/signer/withdraw_and_call.go new file mode 100644 index 0000000000..0bcf38badf --- /dev/null +++ b/zetaclient/chains/sui/signer/withdraw_and_call.go @@ -0,0 +1,394 @@ +package signer + +import ( + "context" + "encoding/base64" + "fmt" + "strconv" + + "github.com/block-vision/sui-go-sdk/models" + "github.com/fardream/go-bcs/bcs" + "github.com/pattonkan/sui-go/sui" + "github.com/pattonkan/sui-go/sui/suiptb" + "github.com/pattonkan/sui-go/suiclient" + "github.com/pkg/errors" + + zetasui "github.com/zeta-chain/node/pkg/contracts/sui" + "github.com/zeta-chain/node/zetaclient/chains/sui/client" +) + +// withdrawAndCallObjRefs contains all the object references needed for withdraw and call +type withdrawAndCallObjRefs struct { + gateway sui.ObjectRef + withdrawCap sui.ObjectRef + onCall []sui.ObjectRef + suiCoin sui.ObjectRef +} + +// withdrawAndCallPTBArgs contains all the arguments needed for withdraw and call +type withdrawAndCallPTBArgs struct { + withdrawAndCallObjRefs + coinType string + amount uint64 + nonce uint64 + gasBudget uint64 + receiver string + payload zetasui.CallPayload +} + +// withdrawAndCallPTB builds unsigned withdraw and call PTB Sui transaction +// it chains the following calls: +// 1. withdraw_impl on gateway +// 2. gas budget coin transfer to TSS +// 3. on_call on target contract +// The function returns a TxnMetaData object with tx bytes, the other fields are ignored +func (s *Signer) withdrawAndCallPTB(args withdrawAndCallPTBArgs) (tx models.TxnMetaData, err error) { + var ( + tssAddress = s.TSS().PubKey().AddressSui() + gatewayPackageID = s.gateway.PackageID() + gatewayModule = s.gateway.Module() + ptb = suiptb.NewTransactionDataTransactionBuilder() + ) + + // Parse signer address + signerAddr, err := sui.AddressFromHex(tssAddress) + if err != nil { + return tx, errors.Wrapf(err, "invalid signer address %s", tssAddress) + } + + // Add withdraw_impl command and get its command index + if err := ptbAddCmdWithdrawImpl( + ptb, + gatewayPackageID, + gatewayModule, + args.gateway, + args.withdrawCap, + args.coinType, + args.amount, + args.nonce, + args.gasBudget, + ); err != nil { + return tx, err + } + + // Create arguments to access the two results from the withdraw_impl call + cmdIndex := uint16(0) + argWithdrawnCoins := suiptb.Argument{ + NestedResult: &suiptb.NestedResult{ + Cmd: cmdIndex, + Result: 0, // First result (main coins) + }, + } + + argBudgetCoins := suiptb.Argument{ + NestedResult: &suiptb.NestedResult{ + Cmd: cmdIndex, + Result: 1, // Second result (budget coins) + }, + } + + // Add gas budget transfer command + err = ptbAddCmdGasBudgetTransfer(ptb, argBudgetCoins, *signerAddr) + if err != nil { + return tx, err + } + + // Add on_call command + err = ptbAddCmdOnCall( + ptb, + args.receiver, + args.coinType, + argWithdrawnCoins, + args.onCall, + args.payload, + ) + if err != nil { + return tx, err + } + + // Finish building the PTB + pt := ptb.Finish() + + // Wrap the PTB into a transaction data + txData := suiptb.NewTransactionData( + signerAddr, + pt, + []*sui.ObjectRef{ + &args.suiCoin, + }, + args.gasBudget, + suiclient.DefaultGasPrice, + ) + + txBytes, err := bcs.Marshal(txData) + if err != nil { + return tx, errors.Wrapf(err, "failed to marshal transaction data: %v", txData) + } + + // Encode the transaction bytes to base64 + return models.TxnMetaData{ + TxBytes: base64.StdEncoding.EncodeToString(txBytes), + }, nil +} + +// getWithdrawAndCallObjectRefs returns the SUI object references for withdraw and call +// - Initial shared version will be used for shared objects +// - Current version will be used for non-shared objects, e.g. withdraw cap +func (s *Signer) getWithdrawAndCallObjectRefs( + ctx context.Context, + withdrawCapID string, + onCallObjectIDs []string, +) (withdrawAndCallObjRefs, error) { + objectIDs := append([]string{s.gateway.ObjectID(), withdrawCapID}, onCallObjectIDs...) + + // query objects in batch + suiObjects, err := s.client.SuiMultiGetObjects(ctx, models.SuiMultiGetObjectsRequest{ + ObjectIds: objectIDs, + Options: models.SuiObjectDataOptions{ + // show owner info in order to retrieve object initial shared version + ShowOwner: true, + }, + }) + if err != nil { + return withdrawAndCallObjRefs{}, errors.Wrapf(err, "failed to get objects for %v", objectIDs) + } + + // should never mismatch, just a sanity check + if len(suiObjects) != len(objectIDs) { + return withdrawAndCallObjRefs{}, fmt.Errorf("expected %d objects, but got %d", len(objectIDs), len(suiObjects)) + } + + // ensure no owned objects are used for on_call + if err := client.CheckContainOwnedObject(suiObjects[2:]); err != nil { + return withdrawAndCallObjRefs{}, errors.Wrapf(err, "objects used for on_call must be shared") + } + + // convert object data to object references + objectRefs := make([]sui.ObjectRef, len(objectIDs)) + + for i, object := range suiObjects { + objectID, err := sui.ObjectIdFromHex(object.Data.ObjectId) + if err != nil { + return withdrawAndCallObjRefs{}, errors.Wrapf(err, "failed to parse object ID %s", object.Data.ObjectId) + } + + objectVersion, err := strconv.ParseUint(object.Data.Version, 10, 64) + if err != nil { + return withdrawAndCallObjRefs{}, errors.Wrapf(err, "failed to parse object version %s", object.Data.Version) + } + + // must use initial version for shared object, not the current version + // withdraw cap is not a shared object, so we must use current version + if object.Data.ObjectId != withdrawCapID { + objectVersion, err = zetasui.ExtractInitialSharedVersion(*object.Data) + if err != nil { + return withdrawAndCallObjRefs{}, errors.Wrapf( + err, + "failed to extract initial shared version for object %s", + object.Data.ObjectId, + ) + } + } + + objectDigest, err := sui.NewBase58(object.Data.Digest) + if err != nil { + return withdrawAndCallObjRefs{}, errors.Wrapf(err, "failed to parse object digest %s", object.Data.Digest) + } + + objectRefs[i] = sui.ObjectRef{ + ObjectId: objectID, + Version: objectVersion, + Digest: objectDigest, + } + } + + // get latest TSS SUI coin object ref for gas payment + suiCoinObjRef, err := s.client.GetSuiCoinObjectRef(ctx, s.TSS().PubKey().AddressSui()) + if err != nil { + return withdrawAndCallObjRefs{}, errors.Wrap(err, "unable to get TSS SUI coin object") + } + + return withdrawAndCallObjRefs{ + gateway: objectRefs[0], + withdrawCap: objectRefs[1], + onCall: objectRefs[2:], + suiCoin: suiCoinObjRef, + }, nil +} + +// ptbAddCmdWithdrawImpl adds the withdraw_impl command to the PTB +func ptbAddCmdWithdrawImpl( + ptb *suiptb.ProgrammableTransactionBuilder, + gatewayPackageIDStr string, + gatewayModule string, + gatewayObjRef sui.ObjectRef, + withdrawCapObjRef sui.ObjectRef, + coinType string, + amount uint64, + nonce uint64, + gasBudget uint64, +) error { + // Parse gateway package ID + gatewayPackageID, err := sui.PackageIdFromHex(gatewayPackageIDStr) + if err != nil { + return errors.Wrapf(err, "invalid gateway package ID %s", gatewayPackageIDStr) + } + + // Parse coin type + tagCoinType, err := zetasui.TypeTagFromString(coinType) + if err != nil { + return errors.Wrapf(err, "invalid coin type %s", coinType) + } + + // Create gateway object argument + argGatewayObject, err := ptb.Obj(suiptb.ObjectArg{ + SharedObject: &suiptb.SharedObjectArg{ + Id: gatewayObjRef.ObjectId, + InitialSharedVersion: gatewayObjRef.Version, + Mutable: true, + }, + }) + if err != nil { + return errors.Wrap(err, "unable to create gateway object argument") + } + + // Create amount argument + argAmount, err := ptb.Pure(amount) + if err != nil { + return errors.Wrapf(err, "unable to create amount argument") + } + + // Create nonce argument + argNonce, err := ptb.Pure(nonce) + if err != nil { + return errors.Wrapf(err, "unable to create nonce argument") + } + + // Create gas budget argument + argGasBudget, err := ptb.Pure(gasBudget) + if err != nil { + return errors.Wrapf(err, "unable to create gas budget argument") + } + + // Create withdraw cap argument + argWithdrawCap, err := ptb.Obj(suiptb.ObjectArg{ImmOrOwnedObject: &withdrawCapObjRef}) + if err != nil { + return errors.Wrapf(err, "unable to create withdraw cap object argument") + } + + // add Move call for withdraw_impl + // #nosec G115 always in range + ptb.Command(suiptb.Command{ + MoveCall: &suiptb.ProgrammableMoveCall{ + Package: gatewayPackageID, + Module: gatewayModule, + Function: zetasui.FuncWithdrawImpl, + TypeArguments: []sui.TypeTag{ + {Struct: &tagCoinType}, + }, + Arguments: []suiptb.Argument{ + argGatewayObject, + argAmount, + argNonce, + argGasBudget, + argWithdrawCap, + }, + }, + }) + + return nil +} + +// ptbAddCmdGasBudgetTransfer adds the gas budget transfer command to the PTB +func ptbAddCmdGasBudgetTransfer( + ptb *suiptb.ProgrammableTransactionBuilder, + argBudgetCoins suiptb.Argument, + signerAddr sui.Address, +) error { + // create TSS address argument + argTSSAddr, err := ptb.Pure(signerAddr) + if err != nil { + return errors.Wrapf(err, "unable to create tss address argument") + } + + ptb.Command(suiptb.Command{ + TransferObjects: &suiptb.ProgrammableTransferObjects{ + Objects: []suiptb.Argument{argBudgetCoins}, + Address: argTSSAddr, + }, + }) + + return nil +} + +// ptbAddCmdOnCall adds the on_call command to the PTB +func ptbAddCmdOnCall( + ptb *suiptb.ProgrammableTransactionBuilder, + receiver string, + coinTypeStr string, + argWithdrawnCoins suiptb.Argument, + onCallObjectRefs []sui.ObjectRef, + cp zetasui.CallPayload, +) error { + // Parse target package ID + targetPackageID, err := sui.PackageIdFromHex(receiver) + if err != nil { + return errors.Wrapf(err, "invalid target package ID %s", receiver) + } + + // Parse coin type + coinType, err := zetasui.TypeTagFromString(coinTypeStr) + if err != nil { + return errors.Wrapf(err, "invalid coin type %s", coinTypeStr) + } + + // Build the type arguments for on_call in order: [withdrawn coin type, ... payload type arguments] + onCallTypeArgs := make([]sui.TypeTag, 0, len(cp.TypeArgs)+1) + onCallTypeArgs = append(onCallTypeArgs, sui.TypeTag{Struct: &coinType}) + for _, typeArg := range cp.TypeArgs { + typeStruct, err := zetasui.TypeTagFromString(typeArg) + if err != nil { + return errors.Wrapf(err, "invalid type argument %s", typeArg) + } + onCallTypeArgs = append(onCallTypeArgs, sui.TypeTag{Struct: &typeStruct}) + } + + // Build the args for on_call: [withdrawns coins + payload objects + message] + onCallArgs := make([]suiptb.Argument, 0, len(cp.ObjectIDs)+1) + onCallArgs = append(onCallArgs, argWithdrawnCoins) + + // Add the payload objects, objects are all shared + for _, onCallObjectRef := range onCallObjectRefs { + objectArg, err := ptb.Obj(suiptb.ObjectArg{ + SharedObject: &suiptb.SharedObjectArg{ + Id: onCallObjectRef.ObjectId, + InitialSharedVersion: onCallObjectRef.Version, + Mutable: true, + }, + }) + if err != nil { + return errors.Wrapf(err, "unable to create object argument: %v", onCallObjectRef) + } + onCallArgs = append(onCallArgs, objectArg) + } + + // Add any additional message arguments + messageArg, err := ptb.Pure(cp.Message) + if err != nil { + return errors.Wrapf(err, "unable to create message argument: %x", cp.Message) + } + onCallArgs = append(onCallArgs, messageArg) + + // Call the target contract on_call + ptb.Command(suiptb.Command{ + MoveCall: &suiptb.ProgrammableMoveCall{ + Package: targetPackageID, + Module: zetasui.ModuleConnected, + Function: zetasui.FuncOnCall, + TypeArguments: onCallTypeArgs, + Arguments: onCallArgs, + }, + }) + + return nil +} diff --git a/zetaclient/chains/sui/signer/withdraw_and_call_test.go b/zetaclient/chains/sui/signer/withdraw_and_call_test.go new file mode 100644 index 0000000000..27360e2bb5 --- /dev/null +++ b/zetaclient/chains/sui/signer/withdraw_and_call_test.go @@ -0,0 +1,364 @@ +package signer + +import ( + "context" + "testing" + + "github.com/block-vision/sui-go-sdk/models" + "github.com/pattonkan/sui-go/sui" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + zetasui "github.com/zeta-chain/node/pkg/contracts/sui" + "github.com/zeta-chain/node/testutil/sample" +) + +// newTestWACPTBArgs creates a withdrawAndCallPTBArgs struct for testing +func newTestWACPTBArgs( + t *testing.T, + gatewayObjRef, suiCoinObjRef, withdrawCapObjRef sui.ObjectRef, + onCallObjectRefs []sui.ObjectRef, +) withdrawAndCallPTBArgs { + return withdrawAndCallPTBArgs{ + withdrawAndCallObjRefs: withdrawAndCallObjRefs{ + gateway: gatewayObjRef, + withdrawCap: withdrawCapObjRef, + onCall: onCallObjectRefs, + suiCoin: suiCoinObjRef, + }, + coinType: string(zetasui.SUI), + amount: 1000000, + nonce: 1, + gasBudget: 2000000, + receiver: sample.SuiAddress(t), + payload: zetasui.CallPayload{ + TypeArgs: []string{string(zetasui.SUI)}, + ObjectIDs: []string{sample.SuiAddress(t)}, + Message: []byte("test message"), + }, + } +} + +func Test_withdrawAndCallPTB(t *testing.T) { + // Create a test suite + ts := newTestSuite(t) + + // create test objects references + gatewayObjRef := sampleObjectRef(t) + suiCoinObjRef := sampleObjectRef(t) + withdrawCapObjRef := sampleObjectRef(t) + onCallObjRef := sampleObjectRef(t) + + tests := []struct { + name string + args withdrawAndCallPTBArgs + errMsg string + }{ + { + name: "successful withdraw and call", + args: newTestWACPTBArgs(t, gatewayObjRef, suiCoinObjRef, withdrawCapObjRef, []sui.ObjectRef{onCallObjRef}), + }, + { + name: "successful withdraw and call with empty payload", + args: func() withdrawAndCallPTBArgs { + args := newTestWACPTBArgs( + t, + gatewayObjRef, + suiCoinObjRef, + withdrawCapObjRef, + []sui.ObjectRef{onCallObjRef}, + ) + args.payload.Message = []byte{} + return args + }(), + }, + { + name: "invalid coin type", + args: func() withdrawAndCallPTBArgs { + args := newTestWACPTBArgs( + t, + gatewayObjRef, + suiCoinObjRef, + withdrawCapObjRef, + []sui.ObjectRef{onCallObjRef}, + ) + args.coinType = "invalid_coin_type" + return args + }(), + errMsg: "invalid coin type", + }, + { + name: "invalid target package ID", + args: func() withdrawAndCallPTBArgs { + args := newTestWACPTBArgs( + t, + gatewayObjRef, + suiCoinObjRef, + withdrawCapObjRef, + []sui.ObjectRef{onCallObjRef}, + ) + args.receiver = "invalid_target_package_id" + return args + }(), + errMsg: "invalid target package ID", + }, + { + name: "invalid type argument", + args: func() withdrawAndCallPTBArgs { + args := newTestWACPTBArgs( + t, + gatewayObjRef, + suiCoinObjRef, + withdrawCapObjRef, + []sui.ObjectRef{onCallObjRef}, + ) + args.payload.TypeArgs[0] = "invalid_type_argument" + return args + }(), + errMsg: "invalid type argument", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ts.Signer.withdrawAndCallPTB(tt.args) + + if tt.errMsg != "" { + require.ErrorContains(t, err, tt.errMsg) + return + } + + require.NoError(t, err) + require.NotEmpty(t, got.TxBytes) + }) + } +} + +func Test_getWithdrawAndCallObjectRefs(t *testing.T) { + // create test objects references + gatewayID, err := sui.ObjectIdFromHex(sample.SuiAddress(t)) + require.NoError(t, err) + withdrawCapID, err := sui.ObjectIdFromHex(sample.SuiAddress(t)) + require.NoError(t, err) + onCallObjectID, err := sui.ObjectIdFromHex(sample.SuiAddress(t)) + require.NoError(t, err) + suiCoinID, err := sui.ObjectIdFromHex(sample.SuiAddress(t)) + require.NoError(t, err) + + // create test object digests + digest1, err := sui.NewBase58(sample.SuiDigest(t)) + require.NoError(t, err) + digest2, err := sui.NewBase58(sample.SuiDigest(t)) + require.NoError(t, err) + digest3, err := sui.NewBase58(sample.SuiDigest(t)) + require.NoError(t, err) + digest4, err := sui.NewBase58(sample.SuiDigest(t)) + require.NoError(t, err) + + // create SUI coin object reference + suiCoinObjRef := sui.ObjectRef{ + ObjectId: suiCoinID, + Version: 1, + Digest: digest4, + } + + tests := []struct { + name string + gatewayID string + withdrawCapID string + onCallObjectIDs []string + mockObjects []*models.SuiObjectResponse + mockError error + expected withdrawAndCallObjRefs + errMsg string + }{ + { + name: "successful get object refs", + gatewayID: gatewayID.String(), + withdrawCapID: withdrawCapID.String(), + onCallObjectIDs: []string{onCallObjectID.String()}, + mockObjects: []*models.SuiObjectResponse{ + { + Data: &models.SuiObjectData{ + ObjectId: gatewayID.String(), + Version: "3", + Digest: digest1.String(), + Owner: map[string]any{ + "Shared": map[string]any{ + "initial_shared_version": float64(1), + }, + }, + }, + }, + { + Data: &models.SuiObjectData{ + ObjectId: withdrawCapID.String(), + Version: "2", + Digest: digest2.String(), + }, + }, + { + Data: &models.SuiObjectData{ + ObjectId: onCallObjectID.String(), + Version: "3", + Digest: digest3.String(), + Owner: map[string]any{ + "Shared": map[string]any{ + "initial_shared_version": float64(1), + }, + }, + }, + }, + }, + expected: withdrawAndCallObjRefs{ + gateway: sui.ObjectRef{ + ObjectId: gatewayID, + Version: 1, + Digest: digest1, + }, + withdrawCap: sui.ObjectRef{ + ObjectId: withdrawCapID, + Version: 2, + Digest: digest2, + }, + onCall: []sui.ObjectRef{ + { + ObjectId: onCallObjectID, + Version: 1, + Digest: digest3, + }, + }, + suiCoin: suiCoinObjRef, + }, + }, + { + name: "rpc call fails", + gatewayID: gatewayID.String(), + withdrawCapID: withdrawCapID.String(), + onCallObjectIDs: []string{onCallObjectID.String()}, + mockError: sample.ErrSample, + errMsg: "failed to get objects", + }, + { + name: "invalid object ID", + gatewayID: gatewayID.String(), + withdrawCapID: withdrawCapID.String(), + onCallObjectIDs: []string{onCallObjectID.String()}, + mockObjects: []*models.SuiObjectResponse{ + { + Data: &models.SuiObjectData{ + ObjectId: "invalid_id", + Version: "1", + Digest: digest1.String(), + }, + }, + { + Data: sampleSharedObjectData(t), + }, + { + Data: sampleSharedObjectData(t), + }, + }, + errMsg: "failed to parse object ID", + }, + { + name: "invalid object version", + gatewayID: gatewayID.String(), + withdrawCapID: withdrawCapID.String(), + onCallObjectIDs: []string{onCallObjectID.String()}, + mockObjects: []*models.SuiObjectResponse{ + { + Data: &models.SuiObjectData{ + ObjectId: gatewayID.String(), + Version: "invalid_version", + Digest: digest1.String(), + }, + }, + { + Data: sampleSharedObjectData(t), + }, + { + Data: sampleSharedObjectData(t), + }, + }, + errMsg: "failed to parse object version", + }, + { + name: "invalid initial shared version", + gatewayID: gatewayID.String(), + withdrawCapID: withdrawCapID.String(), + onCallObjectIDs: []string{onCallObjectID.String()}, + mockObjects: []*models.SuiObjectResponse{ + { + Data: &models.SuiObjectData{ + ObjectId: gatewayID.String(), + Version: "1", + Digest: digest1.String(), + Owner: map[string]any{ + "Shared": map[string]any{ + "initial_shared_version": "invalid_version", + }, + }, + }, + }, + { + Data: sampleSharedObjectData(t), + }, + { + Data: sampleSharedObjectData(t), + }, + }, + errMsg: "failed to extract initial shared version", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // ARRANGE + ts := newTestSuite(t) + + // setup RPC mock + ctx := context.Background() + ts.SuiMock.On("SuiMultiGetObjects", ctx, mock.Anything).Return(tt.mockObjects, tt.mockError) + ts.SuiMock.On("GetSuiCoinObjectRef", ctx, mock.Anything).Maybe().Return(suiCoinObjRef, nil) + + // ACT + got, err := ts.Signer.getWithdrawAndCallObjectRefs(ctx, tt.withdrawCapID, tt.onCallObjectIDs) + + // ASSERT + if tt.errMsg != "" { + require.ErrorContains(t, err, tt.errMsg) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expected, got) + }) + } +} + +// sampleObjectRef creates a sample Sui object reference +func sampleObjectRef(t *testing.T) sui.ObjectRef { + objectID := sui.MustObjectIdFromHex(sample.SuiAddress(t)) + digest, err := sui.NewBase58(sample.SuiDigest(t)) + require.NoError(t, err) + + return sui.ObjectRef{ + ObjectId: objectID, + Version: 1, + Digest: digest, + } +} + +// sampleSharedObjectData creates a sample Sui object data for a shared object +func sampleSharedObjectData(t *testing.T) *models.SuiObjectData { + return &models.SuiObjectData{ + ObjectId: sample.SuiAddress(t), + Version: "1", + Digest: sample.SuiDigest(t), + Owner: map[string]any{ + "Shared": map[string]any{ + "initial_shared_version": float64(1), + }, + }, + } +} diff --git a/zetaclient/testutils/mocks/sui_client.go b/zetaclient/testutils/mocks/sui_client.go index 493f03f3bf..7f6617266f 100644 --- a/zetaclient/testutils/mocks/sui_client.go +++ b/zetaclient/testutils/mocks/sui_client.go @@ -11,6 +11,8 @@ import ( models "github.com/block-vision/sui-go-sdk/models" + sui "github.com/pattonkan/sui-go/sui" + time "time" ) @@ -75,6 +77,34 @@ func (_m *SuiClient) GetOwnedObjectID(ctx context.Context, ownerAddress string, return r0, r1 } +// GetSuiCoinObjectRef provides a mock function with given fields: ctx, owner +func (_m *SuiClient) GetSuiCoinObjectRef(ctx context.Context, owner string) (sui.ObjectRef, error) { + ret := _m.Called(ctx, owner) + + if len(ret) == 0 { + panic("no return value specified for GetSuiCoinObjectRef") + } + + var r0 sui.ObjectRef + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (sui.ObjectRef, error)); ok { + return rf(ctx, owner) + } + if rf, ok := ret.Get(0).(func(context.Context, string) sui.ObjectRef); ok { + r0 = rf(ctx, owner) + } else { + r0 = ret.Get(0).(sui.ObjectRef) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, owner) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // HealthCheck provides a mock function with given fields: ctx func (_m *SuiClient) HealthCheck(ctx context.Context) (time.Time, error) { ret := _m.Called(ctx) @@ -224,26 +254,26 @@ func (_m *SuiClient) SuiExecuteTransactionBlock(ctx context.Context, req models. return r0, r1 } -// SuiGetObject provides a mock function with given fields: ctx, req -func (_m *SuiClient) SuiGetObject(ctx context.Context, req models.SuiGetObjectRequest) (models.SuiObjectResponse, error) { +// SuiGetTransactionBlock provides a mock function with given fields: ctx, req +func (_m *SuiClient) SuiGetTransactionBlock(ctx context.Context, req models.SuiGetTransactionBlockRequest) (models.SuiTransactionBlockResponse, error) { ret := _m.Called(ctx, req) if len(ret) == 0 { - panic("no return value specified for SuiGetObject") + panic("no return value specified for SuiGetTransactionBlock") } - var r0 models.SuiObjectResponse + var r0 models.SuiTransactionBlockResponse var r1 error - if rf, ok := ret.Get(0).(func(context.Context, models.SuiGetObjectRequest) (models.SuiObjectResponse, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, models.SuiGetTransactionBlockRequest) (models.SuiTransactionBlockResponse, error)); ok { return rf(ctx, req) } - if rf, ok := ret.Get(0).(func(context.Context, models.SuiGetObjectRequest) models.SuiObjectResponse); ok { + if rf, ok := ret.Get(0).(func(context.Context, models.SuiGetTransactionBlockRequest) models.SuiTransactionBlockResponse); ok { r0 = rf(ctx, req) } else { - r0 = ret.Get(0).(models.SuiObjectResponse) + r0 = ret.Get(0).(models.SuiTransactionBlockResponse) } - if rf, ok := ret.Get(1).(func(context.Context, models.SuiGetObjectRequest) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, models.SuiGetTransactionBlockRequest) error); ok { r1 = rf(ctx, req) } else { r1 = ret.Error(1) @@ -252,26 +282,28 @@ func (_m *SuiClient) SuiGetObject(ctx context.Context, req models.SuiGetObjectRe return r0, r1 } -// SuiGetTransactionBlock provides a mock function with given fields: ctx, req -func (_m *SuiClient) SuiGetTransactionBlock(ctx context.Context, req models.SuiGetTransactionBlockRequest) (models.SuiTransactionBlockResponse, error) { +// SuiMultiGetObjects provides a mock function with given fields: ctx, req +func (_m *SuiClient) SuiMultiGetObjects(ctx context.Context, req models.SuiMultiGetObjectsRequest) ([]*models.SuiObjectResponse, error) { ret := _m.Called(ctx, req) if len(ret) == 0 { - panic("no return value specified for SuiGetTransactionBlock") + panic("no return value specified for SuiMultiGetObjects") } - var r0 models.SuiTransactionBlockResponse + var r0 []*models.SuiObjectResponse var r1 error - if rf, ok := ret.Get(0).(func(context.Context, models.SuiGetTransactionBlockRequest) (models.SuiTransactionBlockResponse, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, models.SuiMultiGetObjectsRequest) ([]*models.SuiObjectResponse, error)); ok { return rf(ctx, req) } - if rf, ok := ret.Get(0).(func(context.Context, models.SuiGetTransactionBlockRequest) models.SuiTransactionBlockResponse); ok { + if rf, ok := ret.Get(0).(func(context.Context, models.SuiMultiGetObjectsRequest) []*models.SuiObjectResponse); ok { r0 = rf(ctx, req) } else { - r0 = ret.Get(0).(models.SuiTransactionBlockResponse) + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.SuiObjectResponse) + } } - if rf, ok := ret.Get(1).(func(context.Context, models.SuiGetTransactionBlockRequest) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, models.SuiMultiGetObjectsRequest) error); ok { r1 = rf(ctx, req) } else { r1 = ret.Error(1) diff --git a/zetaclient/testutils/mocks/sui_gen.go b/zetaclient/testutils/mocks/sui_gen.go index a168554605..aa6ac25605 100644 --- a/zetaclient/testutils/mocks/sui_gen.go +++ b/zetaclient/testutils/mocks/sui_gen.go @@ -5,6 +5,7 @@ import ( time "time" models "github.com/block-vision/sui-go-sdk/models" + suiptb "github.com/pattonkan/sui-go/sui" "github.com/zeta-chain/node/zetaclient/chains/sui/client" ) @@ -19,11 +20,12 @@ type suiClient interface { GetLatestCheckpoint(ctx context.Context) (models.CheckpointResponse, error) QueryModuleEvents(ctx context.Context, q client.EventQuery) ([]models.SuiEventResponse, string, error) GetOwnedObjectID(ctx context.Context, ownerAddress, structType string) (string, error) + GetSuiCoinObjectRef(ctx context.Context, owner string) (suiptb.ObjectRef, error) SuiXGetLatestSuiSystemState(ctx context.Context) (models.SuiSystemStateSummary, error) SuiXGetReferenceGasPrice(ctx context.Context) (uint64, error) SuiXQueryEvents(ctx context.Context, req models.SuiXQueryEventsRequest) (models.PaginatedEventsResponse, error) - SuiGetObject(ctx context.Context, req models.SuiGetObjectRequest) (models.SuiObjectResponse, error) + SuiMultiGetObjects(ctx context.Context, req models.SuiMultiGetObjectsRequest) ([]*models.SuiObjectResponse, error) SuiGetTransactionBlock( ctx context.Context, req models.SuiGetTransactionBlockRequest,