diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd98069..5e2c5c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,9 @@ jobs: steps: - uses: actions/checkout@v4 - name: sui move test - run: docker run -i -v $(pwd):/sui $SUI_IMAGE sui move test + run: | + cd contracts/gateway + docker run -i -v $(pwd):/sui $SUI_IMAGE sui move test ci-ok: runs-on: ubuntu-22.04 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7d2eab9..5b6a34e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -30,18 +30,19 @@ jobs: - name: sui move build --doc run: | + cd contracts/gateway docker run -i -v $(pwd):/sui $SUI_IMAGE sui move build --doc yarn docs - name: Check if docs/ changed id: changes run: | - git add docs/ - git diff --staged --quiet docs/ || echo "docs_changed=true" >> $GITHUB_OUTPUT + git add contracts/gateway/docs/ + git diff --staged --quiet contracts/gateway/docs/ || echo "docs_changed=true" >> $GITHUB_OUTPUT - name: Print warning if docs changed if: steps.changes.outputs.docs_changed == 'true' run: | - echo "⚠️ Changes detected in docs/" + echo "⚠️ Changes detected in contracts/gateway/docs/" git status - git diff --staged docs/ + git diff --staged contracts/gateway/docs/ diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index fe09f0c..d1b0415 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -17,13 +17,14 @@ jobs: - name: Build and Generate Gateway JSON run: | + cd contracts/gateway docker run -i -v $(pwd):/sui -v ~/.sui:/root/.sui $SUI_IMAGE sh -c "sui client -y && sui move build --dump-bytecode-as-base64 | tee /sui/gateway.json" - name: Fix permissions - run: sudo chown $(whoami) gateway.json + run: sudo chown $(whoami) contracts/gateway/gateway.json - name: Verify gateway.json - run: cat gateway.json + run: cat contracts/gateway/gateway.json - name: Determine NPM Tag id: determine-npm-tag @@ -39,6 +40,7 @@ jobs: - name: Publish to NPM run: | + cd contracts/gateway yarn publish --access public --new-version ${GITHUB_REF#refs/tags/v} --tag ${{ steps.determine-npm-tag.outputs.NPM_TAG }} --no-git-tag-version env: diff --git a/.gitignore b/.gitignore index b131c09..564bad1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .idea .vscode -build/* .DS_Store \ No newline at end of file diff --git a/contracts/example/.gitignore b/contracts/example/.gitignore new file mode 100644 index 0000000..3325821 --- /dev/null +++ b/contracts/example/.gitignore @@ -0,0 +1,7 @@ +.idea +.vscode + +build/* +source/dependencies +*.key +.DS_Store diff --git a/contracts/example/Makefile b/contracts/example/Makefile new file mode 100644 index 0000000..128b918 --- /dev/null +++ b/contracts/example/Makefile @@ -0,0 +1,16 @@ +.PHONY: clean build + +# Clean build directory +clean: + rm -rf Move.lock build/ + +# Build the package and generate bytecode +build: + sui move build + +# Help target +help: + @echo "Available targets:" + @echo " clean - Remove build directory" + @echo " build - Build the example package and generate bytecode" + @echo " help - Show this help message" \ No newline at end of file diff --git a/contracts/example/Move.lock b/contracts/example/Move.lock new file mode 100644 index 0000000..22565ca --- /dev/null +++ b/contracts/example/Move.lock @@ -0,0 +1,68 @@ +# @generated by Move, please check-in and do not edit manually. + +[move] +version = 3 +manifest_digest = "540B90B0BDF4F208EEEE9D76F38DE4C9240A0190640DD30688D3D5E828802CC7" +deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" +dependencies = [ + { id = "Bridge", name = "Bridge" }, + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Sui", name = "Sui" }, + { id = "SuiSystem", name = "SuiSystem" }, + { id = "gateway", name = "gateway" }, +] + +[[move.package]] +id = "Bridge" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "494fa6ede17f366f1cd850f01ccb9f42dc75c470", subdir = "crates/sui-framework/packages/bridge" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Sui", name = "Sui" }, + { id = "SuiSystem", name = "SuiSystem" }, +] + +[[move.package]] +id = "MoveStdlib" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "494fa6ede17f366f1cd850f01ccb9f42dc75c470", subdir = "crates/sui-framework/packages/move-stdlib" } + +[[move.package]] +id = "Sui" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "494fa6ede17f366f1cd850f01ccb9f42dc75c470", 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 = "494fa6ede17f366f1cd850f01ccb9f42dc75c470", subdir = "crates/sui-framework/packages/sui-system" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Sui", name = "Sui" }, +] + +[[move.package]] +id = "gateway" +source = { local = "../gateway" } + +dependencies = [ + { id = "Bridge", name = "Bridge" }, + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Sui", name = "Sui" }, + { id = "SuiSystem", name = "SuiSystem" }, +] + +[move.toolchain-version] +compiler-version = "1.59.1" +edition = "2024.beta" +flavor = "sui" + +[env] + +[env.testnet] +chain-id = "4c78adac" +original-published-id = "0xb8341e97cd48794aad9877297923586c137942f1647dd8b3ce70182113c1008c" +latest-published-id = "0xb8341e97cd48794aad9877297923586c137942f1647dd8b3ce70182113c1008c" +published-version = "1" diff --git a/contracts/example/Move.toml b/contracts/example/Move.toml new file mode 100644 index 0000000..ff84d2f --- /dev/null +++ b/contracts/example/Move.toml @@ -0,0 +1,37 @@ +[package] +name = "example" +edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move +published-at = "0xb8341e97cd48794aad9877297923586c137942f1647dd8b3ce70182113c1008c" +# license = "" # e.g., "MIT", "GPL", "Apache 2.0" +# authors = ["..."] # e.g., ["Joe Smith (joesmith@noemail.com)", "John Snow (johnsnow@noemail.com)"] + +[dependencies] +gateway = { local = "../gateway"} + +# 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/contracts/example/README.md b/contracts/example/README.md new file mode 100644 index 0000000..6489e01 --- /dev/null +++ b/contracts/example/README.md @@ -0,0 +1,95 @@ +# 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. + - **Set Message Context**: The third command in the PTB is `set_message_context` + - It sets the `sender` and `target` in message context object right before calling `on_call` function in the `target` package. + - It allows the `on_call` function to perform authentication checks for the call. + - **Connected Module Call**: The fourth 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 + - **Reset Message Context**: The fifth command in the PTB is `reset_message_context` + - It clears the `sender` and `target` in message context object right after calling `on_call` function in the `target` package. + - This is to ensure that each `withdrawAndCall` use independent message context information. + +4. **Transaction Execution**: The entire PTB is executed atomically on the Sui blockchain. + +## PTB Structure + +The PTB for a `withdrawAndCall` transaction consists of five commands: + +```text +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: Set Message Context + MoveCall { + package: gateway_package_id, + module: gateway_module, + function: set_message_context, + arguments: [ + message_context, + zevm_sender, + target_package_id + ] + } + + // Command 3: 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 + ] + } + + // Command 4: Reset Message Context + MoveCall { + package: gateway_package_id, + module: gateway_module, + function: reset_message_context, + arguments: [message_context] + } +} +``` diff --git a/contracts/example/sources/example.move b/contracts/example/sources/example.move new file mode 100644 index 0000000..8552fec --- /dev/null +++ b/contracts/example/sources/example.move @@ -0,0 +1,130 @@ +module example::connected; + +use std::ascii; +use std::ascii::String; +use sui::address::from_bytes; +use sui::coin::Coin; +use gateway::gateway::{MessageContext, message_context_sender, message_context_target}; + +// === Errors === + +const EInvalidPayload: u64 = 1; + +const EUnauthorizedSender: u64 = 2; + +// ENonceMismatch is a fabricated nonce mismatch error code emitted from the on_call function +// zetaclient should be able to differentiate this error from real withdraw_impl nonce mismatch +const ENonceMismatch: u64 = 3; + +const EPackageMismatch: u64 = 4; + +// 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( + message_context: &MessageContext, + in_coins: Coin, + cetus_config: &mut GlobalConfig, + // Note: this pool type is hardcoded as and therefore causes type mismatch error in the + // fungible token withdrawAndCall test, where the SOURCE_COIN type is FAKE_USDC instead of TOKEN. + // Disabling the pool object for now is the easiest solution to allow the E2E tests to go through. + // _pool: &mut Pool, + _cetus_partner: &mut Partner, + _clock: &Clock, + data: vector, + _ctx: &mut TxContext, +) { + // check if the message is "revert" and revert with faked ENonceMismatch if so + if (data == b"revert") { + assert!(false, ENonceMismatch); + }; + + // decode the sender, target package, and receiver from the payload + let (authenticated_sender, target_package, receiver) = decode_sender_target_and_receiver(data); + + // check if the sender is the authorized sender + let actual_sender = message_context_sender(message_context); + assert!(authenticated_sender == actual_sender, EUnauthorizedSender); + + // check if the target package is my own package + // this prevents other package routing TSS calls to my package + let actual_target = message_context_target(message_context); + assert!(actual_target == target_package, EPackageMismatch); + + // 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; +} + +// decode the sender, target package, and receiver from the payload data +fun decode_sender_target_and_receiver(data: vector): (String, address, address) { + // [42-byte ZEVM sender] + [32-byte Sui target package] + [32-byte Sui receiver] = 106 bytes + assert!(vector::length(&data) >= 106, EInvalidPayload); + + // extract ZEVM sender address (first 42 bytes) + // this allows E2E test to feed a custom authenticated address + let sender_bytes = extract_bytes(&data, 0, 42); + let sender_str = ascii::string(sender_bytes); + + // extract target package address (bytes 42-74) + let target_bytes = extract_bytes(&data, 42, 74); + let target_package = from_bytes(target_bytes); + + // extract receiver address (bytes 74-106) + let receiver_bytes = extract_bytes(&data, 74, 106); + let receiver = from_bytes(receiver_bytes); + + (sender_str, target_package, receiver) +} + +// helper function to extract a subslice of bytes from a vector +fun extract_bytes(data: &vector, start: u64, end: u64): vector { + let mut result = vector::empty(); + let mut i = start; + + while (i < end) { + vector::push_back(&mut result, *vector::borrow(data, i)); + i = i + 1; + }; + + result +} diff --git a/contracts/example/sources/token.move b/contracts/example/sources/token.move new file mode 100644 index 0000000..ac872f6 --- /dev/null +++ b/contracts/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/contracts/gateway/.gitignore b/contracts/gateway/.gitignore new file mode 100644 index 0000000..b131c09 --- /dev/null +++ b/contracts/gateway/.gitignore @@ -0,0 +1,5 @@ +.idea +.vscode + +build/* +.DS_Store \ No newline at end of file diff --git a/Move.lock b/contracts/gateway/Move.lock similarity index 100% rename from Move.lock rename to contracts/gateway/Move.lock diff --git a/Move.toml b/contracts/gateway/Move.toml similarity index 94% rename from Move.toml rename to contracts/gateway/Move.toml index b111373..9e5bc34 100644 --- a/Move.toml +++ b/contracts/gateway/Move.toml @@ -1,6 +1,7 @@ [package] name = "gateway" edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move +published-at = "0x28acc3a03af7658e52456617ac5ba6933ebf8dfb03469697b3673577a4262e24" # license = "" # e.g., "MIT", "GPL", "Apache 2.0" # authors = ["..."] # e.g., ["Joe Smith (joesmith@noemail.com)", "John Snow (johnsnow@noemail.com)"] diff --git a/README.md b/contracts/gateway/README.md similarity index 100% rename from README.md rename to contracts/gateway/README.md diff --git a/docs/evm.md b/contracts/gateway/docs/evm.md similarity index 100% rename from docs/evm.md rename to contracts/gateway/docs/evm.md diff --git a/docs/gateway.md b/contracts/gateway/docs/gateway.md similarity index 100% rename from docs/gateway.md rename to contracts/gateway/docs/gateway.md diff --git a/package.json b/contracts/gateway/package.json similarity index 100% rename from package.json rename to contracts/gateway/package.json diff --git a/scripts/docs.sh b/contracts/gateway/scripts/docs.sh similarity index 100% rename from scripts/docs.sh rename to contracts/gateway/scripts/docs.sh diff --git a/sources/evm.move b/contracts/gateway/sources/evm.move similarity index 100% rename from sources/evm.move rename to contracts/gateway/sources/evm.move diff --git a/sources/gateway.move b/contracts/gateway/sources/gateway.move similarity index 100% rename from sources/gateway.move rename to contracts/gateway/sources/gateway.move diff --git a/tests/evm_tests.move b/contracts/gateway/tests/evm_tests.move similarity index 100% rename from tests/evm_tests.move rename to contracts/gateway/tests/evm_tests.move diff --git a/tests/fake_usdc.move b/contracts/gateway/tests/fake_usdc.move similarity index 100% rename from tests/fake_usdc.move rename to contracts/gateway/tests/fake_usdc.move diff --git a/tests/gateway_tests.move b/contracts/gateway/tests/gateway_tests.move similarity index 99% rename from tests/gateway_tests.move rename to contracts/gateway/tests/gateway_tests.move index a56bedf..f65f603 100644 --- a/tests/gateway_tests.move +++ b/contracts/gateway/tests/gateway_tests.move @@ -858,7 +858,7 @@ fun test_issue_message_context() { issue_message_context(&mut gateway, &admin_cap, scenario.ctx()); - // old message context is no longer active + // old message context no longer exists assert!(active_message_context(&gateway) != object::id(&old_message_context)); ts::return_to_address(@0xA, admin_cap);