diff --git a/Cargo.lock b/Cargo.lock index e6f1c64f..324468c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -948,7 +948,7 @@ dependencies = [ "bcs", "ed25519-dalek", "hex", - "near-mpc-contract-interface", + "near-mpc-contract-interface 0.0.1 (git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a)", "omni-types", "reqwest", "serde", @@ -1264,6 +1264,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -1565,6 +1608,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bnum" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119771309b95163ec7aaf79810da82f7cd0599c19722d48b9c03894dca833966" + [[package]] name = "borsh" version = "1.6.1" @@ -1610,7 +1659,7 @@ dependencies = [ "hypercore-bridge-client", "light-client", "near-bridge-client", - "near-mpc-contract-interface", + "near-mpc-contract-interface 0.0.1 (git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a)", "near-primitives", "near-sdk", "omni-connector", @@ -1622,6 +1671,7 @@ dependencies = [ "solana-rpc-client", "solana-sdk", "starknet-bridge-client", + "sui-bridge-client", "tokio", "tracing", "tracing-subscriber", @@ -1644,6 +1694,7 @@ dependencies = [ "solana-bridge-client", "solana-rpc-client-api", "starknet-bridge-client", + "sui-bridge-client", "thiserror 2.0.18", "utxo-bridge-client", ] @@ -1695,7 +1746,7 @@ dependencies = [ "near-sdk", "schemars 0.8.22", "serde", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] @@ -1764,6 +1815,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + [[package]] name = "c-kzg" version = "2.1.7" @@ -2888,7 +2948,7 @@ dependencies = [ "hex", "near-crypto 0.34.7", "near-jsonrpc-client", - "near-mpc-contract-interface", + "near-mpc-contract-interface 0.0.1 (git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a)", "near-primitives", "near-rpc-client", "near-token", @@ -3258,9 +3318,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -3504,9 +3564,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.8.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -3519,7 +3579,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -3542,6 +3601,19 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -4188,6 +4260,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -4300,6 +4378,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "mpc-primitives" +version = "3.13.0" +source = "git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a#9295cf29ee2761e44e00c67538859a1ef648285a" +dependencies = [ + "borsh", + "derive_more", + "hex", + "near-account-id 2.6.0", + "schemars 0.8.22", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -4554,6 +4646,18 @@ dependencies = [ "time", ] +[[package]] +name = "near-mpc-bounded-collections" +version = "0.0.1" +source = "git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a#9295cf29ee2761e44e00c67538859a1ef648285a" +dependencies = [ + "borsh", + "derive_more", + "hex", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "near-mpc-bounded-collections" version = "0.0.1" @@ -4567,6 +4671,22 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "near-mpc-contract-interface" +version = "0.0.1" +source = "git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a#9295cf29ee2761e44e00c67538859a1ef648285a" +dependencies = [ + "borsh", + "derive_more", + "mpc-primitives 3.13.0", + "near-mpc-bounded-collections 0.0.1 (git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a)", + "near-mpc-crypto-types 0.0.1 (git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a)", + "serde", + "serde_repr", + "serde_with", + "sha2 0.10.9", +] + [[package]] name = "near-mpc-contract-interface" version = "0.0.1" @@ -4574,9 +4694,9 @@ source = "git+https://github.com/near/mpc?rev=dcc9e042cad6784d33cfa736afb61b876a dependencies = [ "borsh", "derive_more", - "mpc-primitives", - "near-mpc-bounded-collections", - "near-mpc-crypto-types", + "mpc-primitives 3.11.0", + "near-mpc-bounded-collections 0.0.1 (git+https://github.com/near/mpc?rev=dcc9e042cad6784d33cfa736afb61b876a14ded0)", + "near-mpc-crypto-types 0.0.1 (git+https://github.com/near/mpc?rev=dcc9e042cad6784d33cfa736afb61b876a14ded0)", "schemars 0.8.22", "serde", "serde_repr", @@ -4584,6 +4704,23 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "near-mpc-crypto-types" +version = "0.0.1" +source = "git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a#9295cf29ee2761e44e00c67538859a1ef648285a" +dependencies = [ + "borsh", + "bs58 0.5.1", + "derive_more", + "mpc-primitives 3.13.0", + "near-account-id 2.6.0", + "near-mpc-bounded-collections 0.0.1 (git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a)", + "serde", + "serde_with", + "sha3 0.10.8", + "thiserror 2.0.18", +] + [[package]] name = "near-mpc-crypto-types" version = "0.0.1" @@ -4592,9 +4729,9 @@ dependencies = [ "borsh", "bs58 0.5.1", "derive_more", - "mpc-primitives", + "mpc-primitives 3.11.0", "near-account-id 2.6.0", - "near-mpc-bounded-collections", + "near-mpc-bounded-collections 0.0.1 (git+https://github.com/near/mpc?rev=dcc9e042cad6784d33cfa736afb61b876a14ded0)", "near-sdk", "schemars 0.8.22", "serde", @@ -4610,8 +4747,8 @@ source = "git+https://github.com/near/mpc?rev=dcc9e042cad6784d33cfa736afb61b876a dependencies = [ "borsh", "derive_more", - "near-mpc-bounded-collections", - "near-mpc-contract-interface", + "near-mpc-bounded-collections 0.0.1 (git+https://github.com/near/mpc?rev=dcc9e042cad6784d33cfa736afb61b876a14ded0)", + "near-mpc-contract-interface 0.0.1 (git+https://github.com/near/mpc?rev=dcc9e042cad6784d33cfa736afb61b876a14ded0)", "near-mpc-signature-verifier", ] @@ -4620,7 +4757,7 @@ name = "near-mpc-signature-verifier" version = "0.0.1" source = "git+https://github.com/near/mpc?rev=dcc9e042cad6784d33cfa736afb61b876a14ded0#dcc9e042cad6784d33cfa736afb61b876a14ded0" dependencies = [ - "near-mpc-contract-interface", + "near-mpc-contract-interface 0.0.1 (git+https://github.com/near/mpc?rev=dcc9e042cad6784d33cfa736afb61b876a14ded0)", "near-sdk", ] @@ -5112,7 +5249,7 @@ dependencies = [ "near-contract-standards", "near-crypto 0.34.7", "near-jsonrpc-client", - "near-mpc-contract-interface", + "near-mpc-contract-interface 0.0.1 (git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a)", "near-primitives", "near-rpc-client", "near-sdk", @@ -5129,6 +5266,7 @@ dependencies = [ "solana-sdk", "starknet", "starknet-bridge-client", + "sui-bridge-client", "tracing", "utxo-bridge-client", "utxo-utils", @@ -5141,8 +5279,8 @@ dependencies = [ [[package]] name = "omni-types" -version = "3.3.3" -source = "git+https://github.com/near-one/omni-bridge?rev=f499d46c2d58869e4cbb5d19cff703ff7d2731d6#f499d46c2d58869e4cbb5d19cff703ff7d2731d6" +version = "3.3.4" +source = "git+https://github.com/near-one/omni-bridge?rev=67b37e6c0277c42b5bc3ab76e202bec1ac4b4845#67b37e6c0277c42b5bc3ab76e202bec1ac4b4845" dependencies = [ "alloy", "borsh", @@ -5672,6 +5810,38 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + [[package]] name = "qstring" version = "0.7.2" @@ -6067,6 +6237,16 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "roaring" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" +dependencies = [ + "bytemuck", + "byteorder", +] + [[package]] name = "ruint" version = "1.17.2" @@ -6168,6 +6348,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -8690,7 +8871,7 @@ version = "0.1.3" dependencies = [ "async-trait", "hex", - "near-mpc-contract-interface", + "near-mpc-contract-interface 0.0.1 (git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a)", "omni-types", "reqwest", "serde", @@ -8925,6 +9106,103 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sui-bridge-client" +version = "0.1.0" +dependencies = [ + "bcs", + "bs58 0.5.1", + "hex", + "near-mpc-contract-interface 0.0.1 (git+https://github.com/near/mpc?rev=9295cf29ee2761e44e00c67538859a1ef648285a)", + "omni-types", + "serde", + "sha3 0.10.8", + "sui-crypto", + "sui-rpc", + "sui-sdk-types", + "sui-transaction-builder", + "thiserror 2.0.18", + "tokio", + "tonic", + "tracing", +] + +[[package]] +name = "sui-crypto" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7b8f54c4405002c9ece67cb848479f80837185a55a59d8c0a105240f737d0da" +dependencies = [ + "base64ct", + "bech32", + "ed25519-dalek", + "rand_core 0.6.4", + "signature", + "sui-sdk-types", +] + +[[package]] +name = "sui-rpc" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617504d5c6b748af0494b73db80fb893840522329788c48c826d8298e7edb0aa" +dependencies = [ + "base64 0.22.1", + "bcs", + "bytes", + "futures", + "http", + "http-body", + "hyper", + "prost", + "prost-types", + "serde", + "serde_json", + "sui-sdk-types", + "tap", + "tokio", + "tonic", + "tonic-prost", + "tower", +] + +[[package]] +name = "sui-sdk-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22266119e43d3da1a8bc9945c8b569a513e7682f561265d70ef8b6e198dd664" +dependencies = [ + "base64ct", + "bcs", + "blake2", + "bnum", + "bs58 0.5.1", + "bytes", + "bytestring", + "itertools 0.14.0", + "roaring", + "serde", + "serde_derive", + "serde_json", + "serde_with", + "winnow 0.7.15", +] + +[[package]] +name = "sui-transaction-builder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39697547ef9a6c03725dca449c33c3b47619b0a76ca5df827e4501944ebdaebb" +dependencies = [ + "async-trait", + "bcs", + "futures", + "serde", + "sui-rpc", + "sui-sdk-types", + "thiserror 2.0.18", +] + [[package]] name = "syn" version = "1.0.109" @@ -9261,6 +9539,49 @@ dependencies = [ "winnow 1.0.0", ] +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", + "webpki-roots 1.0.6", + "zstd", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -9269,11 +9590,15 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.13.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e1b6ac97..af87a90a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "bridge-sdk/bridge-clients/utxo-bridge-client", "bridge-sdk/bridge-clients/starknet-bridge-client", "bridge-sdk/bridge-clients/aptos-bridge-client", + "bridge-sdk/bridge-clients/sui-bridge-client", "bridge-sdk/bridge-clients/hypercore-bridge-client", "bridge-sdk/crypto-utils", "bridge-sdk/utxo-utils", @@ -57,7 +58,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } base64 = "0.22" near-token = "0.3" near-contract-standards = "5.5" -omni-types = { git = "https://github.com/near-one/omni-bridge", package = "omni-types", rev = "f499d46c2d58869e4cbb5d19cff703ff7d2731d6" } +omni-types = { git = "https://github.com/near-one/omni-bridge", package = "omni-types", rev = "67b37e6c0277c42b5bc3ab76e202bec1ac4b4845" } starknet = "0.17.0" serde_with = { version = "3.12.0", features = ["schemars_0_8"] } solana-sdk = "4.0.1" @@ -82,9 +83,14 @@ spl-token = "9.0.0" spl-token-2022-interface = "3.1.1" spl-associated-token-account-interface = "2.0.0" crypto-shared = { git = "https://github.com/near-one/mpc", package = "crypto-shared", rev = "463172481597ee09b12020b72c61d6b01da7b167" } -near-mpc-contract-interface = { git = "https://github.com/near/mpc", package = "near-mpc-contract-interface", rev = "dcc9e042cad6784d33cfa736afb61b876a14ded0" } +near-mpc-contract-interface = { git = "https://github.com/near/mpc", package = "near-mpc-contract-interface", rev = "9295cf29ee2761e44e00c67538859a1ef648285a" } bcs = "0.1" ed25519-dalek = "2" +sui-sdk-types = { version = "=0.3.2", features = ["serde", "hash"] } +sui-crypto = { version = "=0.3.1", features = ["ed25519", "bech32"] } +sui-transaction-builder = "=0.3.2" +sui-rpc = "=0.3.2" +tonic = "0.14" openssl-sys = { version = "*", features = ["vendored"] } light-client = { path = "bridge-sdk/light-client" } zcash_primitives = { git = "https://github.com/Near-One/librustzcash", rev = "3dbb28e2254b309e3097a7995aa6dcf5a6d459c1", default-features = false, features = ["circuits", "test-dependencies", "transparent-inputs"] } diff --git a/bridge-cli/Cargo.toml b/bridge-cli/Cargo.toml index 770b2f74..2e59a172 100644 --- a/bridge-cli/Cargo.toml +++ b/bridge-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bridge-cli" -version = "0.3.66" +version = "0.3.67" edition = "2021" repository = "https://github.com/Near-One/bridge-sdk-rs" rust-version = "1.88.0" @@ -28,6 +28,7 @@ wormhole-bridge-client = { path = "../bridge-sdk/bridge-clients/wormhole-bridge- utxo-bridge-client = { path = "../bridge-sdk/bridge-clients/utxo-bridge-client" } starknet-bridge-client = { path = "../bridge-sdk/bridge-clients/starknet-bridge-client" } aptos-bridge-client = { path = "../bridge-sdk/bridge-clients/aptos-bridge-client" } +sui-bridge-client = { path = "../bridge-sdk/bridge-clients/sui-bridge-client" } hypercore-bridge-client = { path = "../bridge-sdk/bridge-clients/hypercore-bridge-client" } utxo-utils = { path = "../bridge-sdk/utxo-utils" } near-sdk.workspace = true diff --git a/bridge-cli/src/defaults.rs b/bridge-cli/src/defaults.rs index 8d1489e5..1e62f285 100644 --- a/bridge-cli/src/defaults.rs +++ b/bridge-cli/src/defaults.rs @@ -74,6 +74,13 @@ pub const APTOS_RPC_MAINNET: &str = "https://fullnode.mainnet.aptoslabs.com/v1"; pub const APTOS_BRIDGE_TOKEN_FACTORY_ADDRESS_MAINNET: &str = "0xe4ec15f237e5a8c7daa7a34ece28e2bd2c079360763f181bf65862ec148b914a"; +pub const SUI_RPC_MAINNET: &str = "https://fullnode.mainnet.sui.io"; +// No mainnet deployment yet; placeholders until the Sui bridge ships on mainnet. +pub const SUI_BRIDGE_ADDRESS_MAINNET: &str = + "0x0000000000000000000000000000000000000000000000000000000000000000"; +pub const SUI_STATE_OBJECT_ID_MAINNET: &str = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + pub const FOGO_RPC_MAINNET: &str = "https://mainnet.fogo.io"; pub const FOGO_BRIDGE_ADDRESS_MAINNET: &str = "dahPEoZGXfyV58JqqH85okdHmpN8U2q8owgPUXSCPxe"; pub const FOGO_WORMHOLE_ADDRESS_MAINNET: &str = "worm2mrQkG1B1KTz37erMfWN8anHkSK24nzca7UD8BB"; @@ -151,6 +158,12 @@ pub const APTOS_RPC_TESTNET: &str = "https://fullnode.testnet.aptoslabs.com/v1"; pub const APTOS_BRIDGE_TOKEN_FACTORY_ADDRESS_TESTNET: &str = "0x904a7d620944eec42d5d46cf4fe12463f713c8a705d581c10a010672228f967c"; +pub const SUI_RPC_TESTNET: &str = "https://fullnode.testnet.sui.io"; +pub const SUI_BRIDGE_ADDRESS_TESTNET: &str = + "0x587d69879cb01f62a88526a7448424c37cf767387473c1e610bd007dc95fa6ba"; +pub const SUI_STATE_OBJECT_ID_TESTNET: &str = + "0xcbe9d3824224eaa23e2ef70667fff857ddd0929b7c0e42935d509347505fe858"; + pub const FOGO_RPC_TESTNET: &str = "https://testnet.fogo.io"; // pub const FOGO_BRIDGE_ADDRESS_TESTNET: &str = ""; pub const FOGO_WORMHOLE_ADDRESS_TESTNET: &str = "BhnQyKoQQgpuRTRo6D8Emz93PvXCYfVgHhnrR4T3qhw4"; @@ -228,6 +241,12 @@ pub const APTOS_RPC_DEVNET: &str = "https://fullnode.testnet.aptoslabs.com/v1"; pub const APTOS_BRIDGE_TOKEN_FACTORY_ADDRESS_DEVNET: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; +pub const SUI_RPC_DEVNET: &str = "https://fullnode.testnet.sui.io"; +pub const SUI_BRIDGE_ADDRESS_DEVNET: &str = + "0x587d69879cb01f62a88526a7448424c37cf767387473c1e610bd007dc95fa6ba"; +pub const SUI_STATE_OBJECT_ID_DEVNET: &str = + "0xcbe9d3824224eaa23e2ef70667fff857ddd0929b7c0e42935d509347505fe858"; + pub const FOGO_RPC_DEVNET: &str = "https://testnet.fogo.io"; // pub const FOGO_BRIDGE_ADDRESS_DEVNET: &str = ""; pub const FOGO_WORMHOLE_ADDRESS_DEVNET: &str = FOGO_WORMHOLE_ADDRESS_TESTNET; diff --git a/bridge-cli/src/main.rs b/bridge-cli/src/main.rs index 8fbb5054..2e3494ea 100644 --- a/bridge-cli/src/main.rs +++ b/bridge-cli/src/main.rs @@ -186,6 +186,15 @@ struct CliConfig { #[arg(long)] aptos_bridge_address: Option, + #[arg(long)] + sui_rpc: Option, + #[arg(long)] + sui_private_key: Option, + #[arg(long)] + sui_bridge_address: Option, + #[arg(long)] + sui_state_object_id: Option, + #[arg(long)] config: Option, } @@ -311,6 +320,10 @@ impl CliConfig { aptos_private_key: self.aptos_private_key.or(other.aptos_private_key), aptos_account_address: self.aptos_account_address.or(other.aptos_account_address), aptos_bridge_address: self.aptos_bridge_address.or(other.aptos_bridge_address), + sui_rpc: self.sui_rpc.or(other.sui_rpc), + sui_private_key: self.sui_private_key.or(other.sui_private_key), + sui_bridge_address: self.sui_bridge_address.or(other.sui_bridge_address), + sui_state_object_id: self.sui_state_object_id.or(other.sui_state_object_id), config: self.config.or(other.config), } @@ -414,6 +427,10 @@ fn env_config() -> CliConfig { aptos_private_key: env::var("APTOS_PRIVATE_KEY").ok(), aptos_account_address: env::var("APTOS_ACCOUNT_ADDRESS").ok(), aptos_bridge_address: env::var("APTOS_BRIDGE_ADDRESS").ok(), + sui_rpc: env::var("SUI_RPC").ok(), + sui_private_key: env::var("SUI_PRIVATE_KEY").ok(), + sui_bridge_address: env::var("SUI_BRIDGE_ADDRESS").ok(), + sui_state_object_id: env::var("SUI_STATE_OBJECT_ID").ok(), config: None, } @@ -534,6 +551,10 @@ fn default_config(network: Network) -> CliConfig { aptos_bridge_address: Some( defaults::APTOS_BRIDGE_TOKEN_FACTORY_ADDRESS_MAINNET.to_owned(), ), + sui_rpc: Some(defaults::SUI_RPC_MAINNET.to_owned()), + sui_private_key: None, + sui_bridge_address: Some(defaults::SUI_BRIDGE_ADDRESS_MAINNET.to_owned()), + sui_state_object_id: Some(defaults::SUI_STATE_OBJECT_ID_MAINNET.to_owned()), config: None, }, @@ -650,6 +671,10 @@ fn default_config(network: Network) -> CliConfig { aptos_bridge_address: Some( defaults::APTOS_BRIDGE_TOKEN_FACTORY_ADDRESS_TESTNET.to_owned(), ), + sui_rpc: Some(defaults::SUI_RPC_TESTNET.to_owned()), + sui_private_key: None, + sui_bridge_address: Some(defaults::SUI_BRIDGE_ADDRESS_TESTNET.to_owned()), + sui_state_object_id: Some(defaults::SUI_STATE_OBJECT_ID_TESTNET.to_owned()), config: None, }, @@ -766,6 +791,10 @@ fn default_config(network: Network) -> CliConfig { aptos_bridge_address: Some( defaults::APTOS_BRIDGE_TOKEN_FACTORY_ADDRESS_DEVNET.to_owned(), ), + sui_rpc: Some(defaults::SUI_RPC_DEVNET.to_owned()), + sui_private_key: None, + sui_bridge_address: Some(defaults::SUI_BRIDGE_ADDRESS_DEVNET.to_owned()), + sui_state_object_id: Some(defaults::SUI_STATE_OBJECT_ID_DEVNET.to_owned()), config: None, }, diff --git a/bridge-cli/src/omni_connector_command.rs b/bridge-cli/src/omni_connector_command.rs index 8d1c5e65..eea5eba2 100644 --- a/bridge-cli/src/omni_connector_command.rs +++ b/bridge-cli/src/omni_connector_command.rs @@ -1,6 +1,8 @@ use clap::Subcommand; use core::panic; -use near_mpc_contract_interface::types::{AptosFinality, EvmFinality, StarknetFinality}; +use near_mpc_contract_interface::types::{ + AptosFinality, EvmFinality, StarknetFinality, SuiFinality, +}; use std::collections::HashMap; use std::{path::Path, str::FromStr}; @@ -24,6 +26,7 @@ use solana_bridge_client::{SolanaBridgeClientBuilder, SvmSigner}; use solana_rpc_client::nonblocking::rpc_client::RpcClient; use solana_sdk::{signature::Keypair, signature::Signer as SolanaSigner, signer::EncodableKey}; use starknet_bridge_client::StarknetBridgeClientBuilder; +use sui_bridge_client::SuiBridgeClientBuilder; use utxo_bridge_client::{ types::{Bitcoin, PrefetchedTxData, Zcash}, AuthOptions, UTXOBridgeClient, @@ -619,6 +622,46 @@ pub enum OmniConnectorSubCommand { config_cli: CliConfig, }, + #[clap(about = "Initialize a transfer on Sui")] + SuiInitTransfer { + #[clap(short, long, help = "Coin type on Sui (e.g. 0x2::sui::SUI)")] + token: String, + #[clap(short, long, help = "Amount to transfer")] + amount: u128, + #[clap(short, long, help = "Recipient address on the destination chain")] + recipient: OmniAddress, + #[clap(short, long, help = "Fee to charge for the transfer")] + fee: Option, + #[clap(short, long, help = "Native fee (in SUI) to charge for the transfer")] + native_fee: Option, + #[clap(short, long, help = "Additional message")] + message: Option, + #[command(flatten)] + config_cli: CliConfig, + }, + #[clap(about = "Finalize a transfer on Sui")] + SuiFinTransfer { + #[clap( + short, + long, + help = "Transaction hash of the sign_transfer call on NEAR" + )] + tx_hash: String, + #[clap(long, help = "Sender ID of the sign_transfer call on NEAR")] + sender_id: Option, + #[command(flatten)] + config_cli: CliConfig, + }, + #[clap( + about = "Log token metadata on Sui (Sui tokens are types, so this takes the coin type instead of the LogMetadata token address)" + )] + SuiLogMetadata { + #[clap(short, long, help = "Coin type on Sui (e.g. 0x2::sui::SUI)")] + token: String, + #[command(flatten)] + config_cli: CliConfig, + }, + #[clap(about = "Initialize an SVM OmniBridge program (Solana or Fogo)")] SvmInitialize { #[clap(long, help = "SVM chain (sol or fogo)")] @@ -1056,6 +1099,9 @@ fn ensure_dry_run_supported(cmd: &OmniConnectorSubCommand, network: Network) { | Cmd::EvmFinTransfer { config_cli, .. } | Cmd::AptosInitTransfer { config_cli, .. } | Cmd::AptosFinTransfer { config_cli, .. } + | Cmd::SuiInitTransfer { config_cli, .. } + | Cmd::SuiFinTransfer { config_cli, .. } + | Cmd::SuiLogMetadata { config_cli, .. } | Cmd::StarknetInitTransfer { config_cli, .. } | Cmd::StarknetFinTransfer { config_cli, .. } // `initialize` needs the program keypair as a real signer; no dry-run. @@ -1189,7 +1235,7 @@ pub async fn match_subcommand(cmd: OmniConnectorSubCommand, network: Network) { .await .unwrap(); } - ChainKind::Abs | ChainKind::Strk | ChainKind::Aptos => { + ChainKind::Abs | ChainKind::Strk | ChainKind::Aptos | ChainKind::Sui => { omni_connector(network, config_cli) .deploy_token(DeployTokenArgs::NearDeployTokenWithMpcProof { chain_kind: source_chain, @@ -1264,6 +1310,15 @@ pub async fn match_subcommand(cmd: OmniConnectorSubCommand, network: Network) { .await .unwrap(); } + ChainKind::Sui => { + omni_connector(network, config_cli) + .deploy_token(DeployTokenArgs::SuiDeployTokenWithTxHash { + near_tx_hash: CryptoHash::from_str(&tx_hash).expect("Invalid tx_hash"), + sender_id: None, + }) + .await + .unwrap(); + } ChainKind::Zcash | ChainKind::Btc => { panic!("DeployToken is not supported for UTXO chains"); } @@ -1462,7 +1517,7 @@ pub async fn match_subcommand(cmd: OmniConnectorSubCommand, network: Network) { .await .unwrap(); } - ChainKind::Abs | ChainKind::Strk | ChainKind::Aptos => { + ChainKind::Abs | ChainKind::Strk | ChainKind::Aptos | ChainKind::Sui => { connector .fin_transfer(FinTransferArgs::NearFinTransferWithMpcProof { chain_kind: chain, @@ -1683,6 +1738,53 @@ pub async fn match_subcommand(cmd: OmniConnectorSubCommand, network: Network) { .await .unwrap(); } + OmniConnectorSubCommand::SuiInitTransfer { + token, + amount, + recipient, + fee, + native_fee, + message, + config_cli, + } => { + let (fee, native_fee) = match (fee, native_fee) { + (Some(f), Some(nf)) => (f, nf), + (Some(f), None) => (f, 0), + (None, Some(nf)) => (0, nf), + _ => (0, 0), + }; + + omni_connector(network, config_cli) + .init_transfer(InitTransferArgs::SuiInitTransfer { + token, + amount, + recipient: recipient.to_string(), + fee, + native_fee, + message: message.unwrap_or_default(), + }) + .await + .unwrap(); + } + OmniConnectorSubCommand::SuiFinTransfer { + tx_hash, + sender_id, + config_cli, + } => { + omni_connector(network, config_cli) + .fin_transfer(FinTransferArgs::SuiFinTransferWithTxHash { + near_tx_hash: CryptoHash::from_str(&tx_hash).expect("Invalid tx_hash"), + sender_id, + }) + .await + .unwrap(); + } + OmniConnectorSubCommand::SuiLogMetadata { token, config_cli } => { + omni_connector(network, config_cli) + .sui_log_metadata(token) + .await + .unwrap(); + } OmniConnectorSubCommand::SvmInitialize { chain, @@ -1838,7 +1940,7 @@ pub async fn match_subcommand(cmd: OmniConnectorSubCommand, network: Network) { .await .unwrap(); } - ChainKind::Abs | ChainKind::Strk | ChainKind::Aptos => { + ChainKind::Abs | ChainKind::Strk | ChainKind::Aptos | ChainKind::Sui => { omni_connector(network, config_cli) .bind_token(BindTokenArgs::BindTokenWithMpcProofTx { chain_kind: chain, @@ -2474,6 +2576,15 @@ fn omni_connector(network: Network, cli_config: CliConfig) -> OmniConnector { .build() .unwrap(); + let sui_bridge_client = SuiBridgeClientBuilder::default() + .endpoint(combined_config.sui_rpc) + .private_key(combined_config.sui_private_key) + .bridge_address(combined_config.sui_bridge_address) + .state_object_id(combined_config.sui_state_object_id) + .mpc_finality(Some(SuiFinality::Checkpointed)) + .build() + .unwrap(); + OmniConnectorBuilder::default() .network(Some(network.into())) .near_bridge_client(Some(near_bridge_client)) @@ -2489,6 +2600,7 @@ fn omni_connector(network: Network, cli_config: CliConfig) -> OmniConnector { .fogo_bridge_client(Some(fogo_bridge_client)) .starknet_bridge_client(Some(starknet_bridge_client)) .aptos_bridge_client(Some(aptos_bridge_client)) + .sui_bridge_client(Some(sui_bridge_client)) .wormhole_bridge_client(Some(wormhole_bridge_client)) .btc_bridge_client(Some(btc_bridge_client)) .zcash_bridge_client(Some(zcash_bridge_client)) diff --git a/bridge-sdk/bridge-clients/evm-bridge-client/Cargo.toml b/bridge-sdk/bridge-clients/evm-bridge-client/Cargo.toml index a080629a..1495a830 100644 --- a/bridge-sdk/bridge-clients/evm-bridge-client/Cargo.toml +++ b/bridge-sdk/bridge-clients/evm-bridge-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "evm-bridge-client" -version = "0.3.7" +version = "0.3.8" edition = "2021" rust-version = "1.96.0" diff --git a/bridge-sdk/bridge-clients/evm-bridge-client/src/evm_bridge_client.rs b/bridge-sdk/bridge-clients/evm-bridge-client/src/evm_bridge_client.rs index 495856e6..d077cfc6 100644 --- a/bridge-sdk/bridge-clients/evm-bridge-client/src/evm_bridge_client.rs +++ b/bridge-sdk/bridge-clients/evm-bridge-client/src/evm_bridge_client.rs @@ -465,7 +465,8 @@ impl EvmBridgeClient { | ChainKind::Btc | ChainKind::Zcash | ChainKind::Strk - | ChainKind::Aptos => Err(EvmBridgeClientError::InvalidArgument(format!( + | ChainKind::Aptos + | ChainKind::Sui => Err(EvmBridgeClientError::InvalidArgument(format!( "Expected evm chain but got {chain_kind:?}" ))), } @@ -729,7 +730,8 @@ impl EvmBridgeClient { | OmniAddress::Btc(_) | OmniAddress::Zcash(_) | OmniAddress::Strk(_) - | OmniAddress::Aptos(_) => Err(EvmBridgeClientError::InvalidArgument(format!( + | OmniAddress::Aptos(_) + | OmniAddress::Sui(_) => Err(EvmBridgeClientError::InvalidArgument(format!( "Unsupported address type in SignTransferEvent: {address:?}", ))), } diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/Cargo.toml b/bridge-sdk/bridge-clients/sui-bridge-client/Cargo.toml new file mode 100644 index 00000000..d7fdfa09 --- /dev/null +++ b/bridge-sdk/bridge-clients/sui-bridge-client/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "sui-bridge-client" +version = "0.1.0" +edition = "2021" +rust-version = "1.88.0" + +[dependencies] +thiserror.workspace = true +tracing.workspace = true +near-mpc-contract-interface.workspace = true +omni-types.workspace = true +hex.workspace = true +serde.workspace = true +tokio.workspace = true +bcs.workspace = true +bs58.workspace = true +sha3.workspace = true +sui-sdk-types.workspace = true +sui-crypto.workspace = true +sui-transaction-builder.workspace = true +sui-rpc.workspace = true +tonic.workspace = true + +[lib] +path = "src/sui_bridge_client.rs" diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/src/builder.rs b/bridge-sdk/bridge-clients/sui-bridge-client/src/builder.rs new file mode 100644 index 00000000..6f2228ee --- /dev/null +++ b/bridge-sdk/bridge-clients/sui-bridge-client/src/builder.rs @@ -0,0 +1,171 @@ +use near_mpc_contract_interface::types::SuiFinality; +use sui_crypto::ed25519::Ed25519PrivateKey; +use sui_sdk_types::Address; + +use crate::error::{Result, SuiBridgeClientError}; +use crate::{SuiAccount, SuiBridgeClient}; + +/// Builder for [`SuiBridgeClient`]. Only `endpoint` is required; signing and +/// bridge addressing are optional so read-only flows work unsigned. Setters +/// take `Option`s so CLI/config values pass straight through. +#[derive(Default)] +pub struct SuiBridgeClientBuilder { + endpoint: Option, + private_key: Option, + bridge_address: Option, + state_object_id: Option, + mpc_finality: Option, +} + +impl SuiBridgeClientBuilder { + /// gRPC fullnode endpoint, e.g. `https://fullnode.mainnet.sui.io`. + #[must_use] + pub fn endpoint(mut self, endpoint: Option) -> Self { + self.endpoint = endpoint; + self + } + + /// Ed25519 key: either a hex 32-byte seed (`0x` optional) or a Sui CLI + /// `suiprivkey1...` bech32 string. The account address is derived from + /// the public key. + #[must_use] + pub fn private_key(mut self, private_key: Option) -> Self { + self.private_key = private_key; + self + } + + /// Package id the `omni_bridge` Move module is published under. + #[must_use] + pub fn bridge_address(mut self, bridge_address: Option) -> Self { + self.bridge_address = bridge_address; + self + } + + /// Object id of the shared `BridgeState`. + #[must_use] + pub fn state_object_id(mut self, state_object_id: Option) -> Self { + self.state_object_id = state_object_id; + self + } + + /// MPC finality level required before building an MPC sign payload. + #[must_use] + pub fn mpc_finality(mut self, mpc_finality: Option) -> Self { + self.mpc_finality = mpc_finality; + self + } + + pub fn build(self) -> Result { + let endpoint = self + .endpoint + .ok_or_else(|| SuiBridgeClientError::ConfigError("endpoint is required".to_string()))?; + let rpc = sui_rpc::Client::new(endpoint.clone()).map_err(|e| { + SuiBridgeClientError::ConfigError(format!("invalid Sui endpoint {endpoint:?}: {e}")) + })?; + + let account = self + .private_key + .as_deref() + .map(parse_private_key) + .transpose()? + .map(|key| { + let address = key.public_key().derive_address(); + SuiAccount { key, address } + }); + + Ok(SuiBridgeClient { + rpc, + account, + bridge_address: self + .bridge_address + .as_deref() + .map(|s| parse_address(s, "bridge address")) + .transpose()?, + state_object_id: self + .state_object_id + .as_deref() + .map(|s| parse_address(s, "state object id")) + .transpose()?, + mpc_finality: self.mpc_finality, + }) + } +} + +fn parse_private_key(key: &str) -> Result { + let key = key.trim(); + if key.starts_with("suiprivkey") { + return Ed25519PrivateKey::from_suiprivkey(key).map_err(|e| { + SuiBridgeClientError::ConfigError(format!("invalid suiprivkey string: {e}")) + }); + } + let hex_str = key.strip_prefix("0x").unwrap_or(key); + let bytes = hex::decode(hex_str) + .map_err(|e| SuiBridgeClientError::ConfigError(format!("invalid private key hex: {e}")))?; + let seed: [u8; 32] = bytes.try_into().map_err(|_| { + SuiBridgeClientError::ConfigError("private key seed must be exactly 32 bytes".to_string()) + })?; + Ok(Ed25519PrivateKey::new(seed)) +} + +fn parse_address(s: &str, what: &str) -> Result
{ + s.trim() + .parse() + .map_err(|e| SuiBridgeClientError::ConfigError(format!("invalid {what} {s:?}: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn requires_endpoint() { + assert!(matches!( + SuiBridgeClientBuilder::default().build(), + Err(SuiBridgeClientError::ConfigError(_)) + )); + } + + #[tokio::test] + async fn builds_readonly_client_without_signer() { + let client = SuiBridgeClientBuilder::default() + .endpoint(Some("https://fullnode.testnet.sui.io".to_string())) + .build() + .unwrap(); + assert!(client.account.is_none()); + assert!(client.account().is_err()); + } + + #[tokio::test] + async fn parses_hex_private_key_and_derives_address() { + let client = SuiBridgeClientBuilder::default() + .endpoint(Some("https://fullnode.testnet.sui.io".to_string())) + .private_key(Some(format!("0x{}", hex::encode([7u8; 32])))) + .bridge_address(Some( + "0x587d69879cb01f62a88526a7448424c37cf767387473c1e610bd007dc95fa6ba".to_string(), + )) + .state_object_id(Some("0xcbe9".to_string())) + .build() + .unwrap(); + let account = client.account.as_ref().unwrap(); + // Address = Blake2b256(0x00 || pubkey) — deterministic for a fixed seed. + let expected = Ed25519PrivateKey::new([7u8; 32]) + .public_key() + .derive_address(); + assert_eq!(account.address, expected); + assert!(client.bridge_address.is_some()); + } + + #[tokio::test] + async fn rejects_bad_keys_and_addresses() { + let base = || { + SuiBridgeClientBuilder::default() + .endpoint(Some("https://fullnode.testnet.sui.io".to_string())) + }; + assert!(base().private_key(Some("0xzz".into())).build().is_err()); + assert!(base().private_key(Some("0x0102".into())).build().is_err()); + assert!(base() + .bridge_address(Some("not-an-address".into())) + .build() + .is_err()); + } +} diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/src/bytecode.rs b/bridge-sdk/bridge-clients/sui-bridge-client/src/bytecode.rs new file mode 100644 index 00000000..b8c608d9 --- /dev/null +++ b/bridge-sdk/bridge-clients/sui-bridge-client/src/bytecode.rs @@ -0,0 +1,517 @@ +//! Per-token patching of the vendored `token_template` bytecode. +//! +//! Sui cannot create a currency at runtime (`coin::create_currency` needs a +//! one-time witness), so every NEAR-originated token gets its own copy of the +//! tiny `token_template` package with the module/OTW identifiers, the +//! `decimals` constant, and the `[symbol, name, description]` params constant +//! substituted. There is no Move compiler on crates.io, so instead of +//! compiling per token we patch the precompiled module at the binary-format +//! table level: +//! +//! ```text +//! magic(4) | version(u32 LE, flavor in MSB) | table_count(uleb) +//! table headers: (kind: u8, offset: uleb, len: uleb) * count +//! table contents (offsets relative to contents start) +//! trailer (self module handle index) — preserved verbatim +//! ``` +//! +//! Only the IDENTIFIERS (0x7) and CONSTANT_POOL (0x6) tables are rewritten; +//! every other table is treated as an opaque blob and re-emitted with a +//! recomputed offset. Entries are located by their sentinel values from +//! `token_template/sources/template_coin.move`, so the patcher is +//! order-independent. The identity patch is byte-lossless (see tests), and +//! patched output is validated against the real toolchain via +//! `sui move disassemble` (see `tests/fixtures/usdt_coin_patched.mv`). + +use crate::error::{Result, SuiBridgeClientError}; + +/// Compiled `token_template::template_coin`, built with `sui move build` +/// (bytecode v7, Sui flavor). See `token_template/` for source & provenance. +pub const TOKEN_TEMPLATE_BYTECODE: &[u8] = include_bytes!("template/template_coin.mv"); + +/// Addresses of the packages the template links against: MoveStdlib (0x1) +/// and the Sui framework (0x2), as reported by `sui move build`. +pub const TOKEN_TEMPLATE_DEPENDENCIES: [[u8; 32]; 2] = [ + { + let mut a = [0u8; 32]; + a[31] = 1; + a + }, + { + let mut a = [0u8; 32]; + a[31] = 2; + a + }, +]; + +const MOVE_MAGIC: [u8; 4] = [0xA1, 0x1C, 0xEB, 0x0B]; +const TABLE_IDENTIFIERS: u8 = 0x7; +const TABLE_CONSTANT_POOL: u8 = 0x6; + +// Sentinels as compiled from the template source. Symbol/name/description +// live in ONE `vector>` constant (`PARAMS`): the Move verifier +// rejects modules with duplicate constant-pool entries, so separate +// `vector` constants would make publishing fail whenever +// `symbol == name` or either equals the empty description. The combined +// constant and the `u8` decimals constant have different types, so no token +// metadata can ever produce a duplicate. +const TEMPLATE_MODULE_NAME: &str = "template_coin"; +const TEMPLATE_OTW_NAME: &str = "TEMPLATE_COIN"; +const TEMPLATE_DECIMALS: u8 = 9; +const TEMPLATE_SYMBOL: &[u8] = b"TMPL"; +const TEMPLATE_NAME: &[u8] = b"Template Token"; +const TEMPLATE_DESCRIPTION: &[u8] = b""; + +/// Sui protocol `max_move_identifier_len` is 128; stay comfortably below +/// after the `_coin` suffix. +const MAX_SANITIZED_SYMBOL_LEN: usize = 64; + +fn err(msg: impl Into) -> SuiBridgeClientError { + SuiBridgeClientError::BytecodeError(msg.into()) +} + +fn read_uleb(data: &[u8], pos: &mut usize) -> Result { + let mut value: u64 = 0; + let mut shift = 0; + loop { + let byte = *data + .get(*pos) + .ok_or_else(|| err("unexpected end of bytecode in uleb128"))?; + *pos += 1; + value |= u64::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + shift += 7; + if shift > 63 { + return Err(err("uleb128 overflow in bytecode")); + } + } +} + +fn write_uleb(out: &mut Vec, mut value: u64) { + loop { + let byte = (value & 0x7F) as u8; + value >>= 7; + if value == 0 { + out.push(byte); + return; + } + out.push(byte | 0x80); + } +} + +struct ParsedModule { + /// magic + version, verbatim. + header: Vec, + /// (kind, contents) in original directory order. + tables: Vec<(u8, Vec)>, + /// Bytes after the table contents (self module handle index), verbatim. + trailer: Vec, +} + +fn parse_module(data: &[u8]) -> Result { + if data.len() < 8 || data[..4] != MOVE_MAGIC { + return Err(err("template bytecode has invalid magic")); + } + let mut pos = 8; + let table_count = read_uleb(data, &mut pos)?; + let mut headers = Vec::new(); + for _ in 0..table_count { + let kind = *data + .get(pos) + .ok_or_else(|| err("unexpected end of bytecode in table header"))?; + pos += 1; + let offset = read_uleb(data, &mut pos)?; + let len = read_uleb(data, &mut pos)?; + headers.push((kind, offset as usize, len as usize)); + } + let contents_start = pos; + let contents_len = headers + .iter() + .map(|&(_, offset, len)| offset + len) + .max() + .unwrap_or(0); + let mut tables = Vec::with_capacity(headers.len()); + for (kind, offset, len) in headers { + let start = contents_start + offset; + let end = start + len; + if end > data.len() { + return Err(err("table extends past end of bytecode")); + } + tables.push((kind, data[start..end].to_vec())); + } + let trailer_start = contents_start + contents_len; + if trailer_start > data.len() { + return Err(err("table contents extend past end of bytecode")); + } + Ok(ParsedModule { + header: data[..8].to_vec(), + tables, + trailer: data[trailer_start..].to_vec(), + }) +} + +fn serialize_module(module: &ParsedModule) -> Vec { + let mut out = module.header.clone(); + write_uleb(&mut out, module.tables.len() as u64); + let mut offset = 0usize; + for (kind, contents) in &module.tables { + out.push(*kind); + write_uleb(&mut out, offset as u64); + write_uleb(&mut out, contents.len() as u64); + offset += contents.len(); + } + for (_, contents) in &module.tables { + out.extend_from_slice(contents); + } + out.extend_from_slice(&module.trailer); + out +} + +/// IDENTIFIERS table: `(uleb len, utf8 bytes) * n`. +fn parse_identifiers(blob: &[u8]) -> Result> { + let mut identifiers = Vec::new(); + let mut pos = 0; + while pos < blob.len() { + let len = read_uleb(blob, &mut pos)? as usize; + let end = pos + len; + if end > blob.len() { + return Err(err("identifier extends past end of table")); + } + let ident = std::str::from_utf8(&blob[pos..end]) + .map_err(|_| err("identifier is not valid UTF-8"))?; + identifiers.push(ident.to_string()); + pos = end; + } + Ok(identifiers) +} + +fn serialize_identifiers(identifiers: &[String]) -> Vec { + let mut out = Vec::new(); + for ident in identifiers { + write_uleb(&mut out, ident.len() as u64); + out.extend_from_slice(ident.as_bytes()); + } + out +} + +#[derive(PartialEq)] +enum ConstantType { + U8, + /// `vector>` — the combined `[symbol, name, description]` + /// params constant. + VectorVectorU8, +} + +/// CONSTANT_POOL table: `(signature token(s), uleb data len, data) * n`. +/// The template only contains `u8` (0x02) and `vector>` +/// (0x0A 0x0A 0x02) constants; anything else fails loudly so a template +/// change can't be silently mis-patched. +fn parse_constants(blob: &[u8]) -> Result)>> { + let mut constants = Vec::new(); + let mut pos = 0; + while pos < blob.len() { + let ty = match blob.get(pos) { + Some(0x02) => { + pos += 1; + ConstantType::U8 + } + Some(0x0A) if blob.get(pos + 1) == Some(&0x0A) && blob.get(pos + 2) == Some(&0x02) => { + pos += 3; + ConstantType::VectorVectorU8 + } + Some(token) => { + return Err(err(format!("unsupported constant type token {token:#04x}"))) + } + None => return Err(err("unexpected end of constant pool")), + }; + let len = read_uleb(blob, &mut pos)? as usize; + let end = pos + len; + if end > blob.len() { + return Err(err("constant extends past end of table")); + } + constants.push((ty, blob[pos..end].to_vec())); + pos = end; + } + Ok(constants) +} + +fn serialize_constants(constants: &[(ConstantType, Vec)]) -> Vec { + let mut out = Vec::new(); + for (ty, data) in constants { + match ty { + ConstantType::U8 => out.push(0x02), + ConstantType::VectorVectorU8 => out.extend_from_slice(&[0x0A, 0x0A, 0x02]), + } + write_uleb(&mut out, data.len() as u64); + out.extend_from_slice(data); + } + out +} + +/// A `vector` element inside a BCS `vector>`: uleb length + +/// bytes. +fn bcs_bytes(bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + write_uleb(&mut out, bytes.len() as u64); + out.extend_from_slice(bytes); + out +} + +/// The BCS data of the `PARAMS: vector>` constant: +/// `[symbol, name, description]`. +fn params_data(symbol: &[u8], name: &[u8], description: &[u8]) -> Vec { + let mut out = Vec::new(); + write_uleb(&mut out, 3); + out.extend_from_slice(&bcs_bytes(symbol)); + out.extend_from_slice(&bcs_bytes(name)); + out.extend_from_slice(&bcs_bytes(description)); + out +} + +/// Derives the per-token module and OTW identifiers from the token symbol, +/// e.g. `"USDT"` -> `("usdt_coin", "USDT_COIN")`. Mirrors the naming rule in +/// the canonical template's docs (module = symbol, struct = module in ALL +/// CAPS); the `_coin` suffix keeps the identifier valid (non-empty, not a +/// keyword, no leading digit) for any symbol. +pub fn coin_module_identifiers(symbol: &str) -> (String, String) { + let mut module: String = symbol + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_') + .map(|c| c.to_ascii_lowercase()) + .take(MAX_SANITIZED_SYMBOL_LEN) + .collect(); + module = module.trim_matches('_').to_string(); + let module = if module.is_empty() || module.starts_with(|c: char| c.is_ascii_digit()) { + format!("bridge_{module}_coin").replace("__", "_") + } else { + format!("{module}_coin") + }; + let otw = module.to_ascii_uppercase(); + (module, otw) +} + +/// Patches `template` (the compiled `template_coin` module) into a per-token +/// module: renames the module/OTW identifiers and substitutes the +/// `decimals` and `[symbol, name, description]` constants. Both constant +/// sentinels and both identifier sentinels must be found, otherwise the +/// vendored template and this patcher have drifted apart and the call fails. +pub fn patch_token_template( + template: &[u8], + module_name: &str, + otw_name: &str, + symbol: &str, + name: &str, + decimals: u8, +) -> Result> { + let mut module = parse_module(template)?; + + let mut renamed_module = false; + let mut renamed_otw = false; + let mut patched_decimals = false; + let mut patched_params = false; + let template_params = params_data(TEMPLATE_SYMBOL, TEMPLATE_NAME, TEMPLATE_DESCRIPTION); + + for (kind, contents) in &mut module.tables { + match *kind { + TABLE_IDENTIFIERS => { + let mut identifiers = parse_identifiers(contents)?; + for ident in &mut identifiers { + if ident == TEMPLATE_MODULE_NAME { + *ident = module_name.to_string(); + renamed_module = true; + } else if ident == TEMPLATE_OTW_NAME { + *ident = otw_name.to_string(); + renamed_otw = true; + } + } + *contents = serialize_identifiers(&identifiers); + } + TABLE_CONSTANT_POOL => { + let mut constants = parse_constants(contents)?; + for (ty, data) in &mut constants { + if *ty == ConstantType::U8 && data.as_slice() == [TEMPLATE_DECIMALS] { + *data = vec![decimals]; + patched_decimals = true; + } else if *ty == ConstantType::VectorVectorU8 && *data == template_params { + *data = + params_data(symbol.as_bytes(), name.as_bytes(), TEMPLATE_DESCRIPTION); + patched_params = true; + } + } + // The Move verifier rejects duplicate constant-pool entries. + // Impossible with the combined params constant, but guard + // against template drift reintroducing separate constants. + for (i, a) in constants.iter().enumerate() { + if constants[..i].iter().any(|b| a == b) { + return Err(err("patched constant pool contains duplicate entries — \ + the Move verifier would reject this module")); + } + } + *contents = serialize_constants(&constants); + } + _ => {} + } + } + + if !(renamed_module && renamed_otw && patched_decimals && patched_params) { + return Err(err( + "template sentinels not found — vendored bytecode and patcher have drifted", + )); + } + Ok(serialize_module(&module)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Validated against the real toolchain: `sui move disassemble` on this + /// fixture shows `module 0.usdt_coin`, `struct USDT_COIN`, and constants + /// `u8: 6` / `vector>: ["USDT", "Tether USD", ""]`. + const USDT_FIXTURE: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/usdt_coin_patched.mv" + )); + + #[test] + fn identity_patch_is_byte_lossless() { + let patched = patch_token_template( + TOKEN_TEMPLATE_BYTECODE, + TEMPLATE_MODULE_NAME, + TEMPLATE_OTW_NAME, + "TMPL", + "Template Token", + TEMPLATE_DECIMALS, + ) + .unwrap(); + assert_eq!(patched, TOKEN_TEMPLATE_BYTECODE); + } + + #[test] + fn usdt_patch_matches_toolchain_validated_fixture() { + let patched = patch_token_template( + TOKEN_TEMPLATE_BYTECODE, + "usdt_coin", + "USDT_COIN", + "USDT", + "Tether USD", + 6, + ) + .unwrap(); + assert_eq!(patched, USDT_FIXTURE); + } + + /// Patch, parse back, and assert the expected identifiers/constants — + /// including that the pool has no duplicates (the Move verifier would + /// reject the module otherwise). + fn patch_and_verify(module_name: &str, otw_name: &str, symbol: &str, name: &str, decimals: u8) { + let patched = patch_token_template( + TOKEN_TEMPLATE_BYTECODE, + module_name, + otw_name, + symbol, + name, + decimals, + ) + .unwrap(); + let module = parse_module(&patched).unwrap(); + let identifiers = module + .tables + .iter() + .find(|(kind, _)| *kind == TABLE_IDENTIFIERS) + .map(|(_, contents)| parse_identifiers(contents).unwrap()) + .unwrap(); + assert!(identifiers.iter().any(|i| i == module_name)); + assert!(identifiers.iter().any(|i| i == otw_name)); + assert!(!identifiers.iter().any(|i| i == TEMPLATE_MODULE_NAME)); + let constants = module + .tables + .iter() + .find(|(kind, _)| *kind == TABLE_CONSTANT_POOL) + .map(|(_, contents)| parse_constants(contents).unwrap()) + .unwrap(); + assert!(constants + .iter() + .any(|(ty, data)| *ty == ConstantType::U8 && data.as_slice() == [decimals])); + assert!(constants.iter().any(|(ty, data)| { + *ty == ConstantType::VectorVectorU8 + && *data == params_data(symbol.as_bytes(), name.as_bytes(), TEMPLATE_DESCRIPTION) + })); + for (i, a) in constants.iter().enumerate() { + assert!( + !constants[..i].iter().any(|b| a == b), + "duplicate constant-pool entry — the Move verifier rejects this" + ); + } + } + + #[test] + fn patched_module_parses_back_with_expected_values() { + patch_and_verify( + "wbtc_coin", + "WBTC_COIN", + "WBTC", + "Wrapped Bitcoin (bridged)", + 8, + ); + } + + #[test] + fn symbol_equal_to_name_produces_no_duplicate_constants() { + // Common with memecoins / wrapped assets: identical symbol and name. + patch_and_verify("pepe_coin", "PEPE_COIN", "PEPE", "PEPE", 8); + } + + #[test] + fn empty_symbol_produces_no_duplicate_constants() { + // An empty symbol/name must not collide with the empty description. + patch_and_verify("bridge_coin", "BRIDGE_COIN", "", "Wrapped Foo", 6); + } + + #[test] + fn empty_name_produces_no_duplicate_constants() { + patch_and_verify("foo_coin", "FOO_COIN", "FOO", "", 6); + } + + #[test] + fn all_empty_metadata_produces_no_duplicate_constants() { + patch_and_verify("bridge_coin", "BRIDGE_COIN", "", "", 0); + } + + #[test] + fn long_metadata_uses_multibyte_uleb_lengths() { + // A >127-byte name makes both the inner element length and the outer + // constant data length multi-byte ULEBs. + let long_name = "N".repeat(200); + patch_and_verify("long_coin", "LONG_COIN", "LONG", &long_name, 9); + } + + #[test] + fn sanitizes_symbols_into_valid_identifiers() { + assert_eq!( + coin_module_identifiers("USDT"), + ("usdt_coin".to_string(), "USDT_COIN".to_string()) + ); + assert_eq!( + coin_module_identifiers("WBTC.e"), + ("wbtce_coin".to_string(), "WBTCE_COIN".to_string()) + ); + assert_eq!( + coin_module_identifiers("1INCH"), + ( + "bridge_1inch_coin".to_string(), + "BRIDGE_1INCH_COIN".to_string() + ) + ); + assert_eq!( + coin_module_identifiers(""), + ("bridge_coin".to_string(), "BRIDGE_COIN".to_string()) + ); + assert_eq!( + coin_module_identifiers("_"), + ("bridge_coin".to_string(), "BRIDGE_COIN".to_string()) + ); + } +} diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/src/error.rs b/bridge-sdk/bridge-clients/sui-bridge-client/src/error.rs new file mode 100644 index 00000000..0172051f --- /dev/null +++ b/bridge-sdk/bridge-clients/sui-bridge-client/src/error.rs @@ -0,0 +1,19 @@ +pub(crate) type Result = std::result::Result; + +#[derive(thiserror::Error, Debug)] +pub enum SuiBridgeClientError { + #[error("Sui RPC error: {0}")] + RpcError(String), + #[error("Sui transaction error: {0}")] + TransactionError(String), + #[error("Blockchain data error: {0}")] + BlockchainDataError(String), + #[error("Configuration error: {0}")] + ConfigError(String), + #[error("Invalid argument: {0}")] + InvalidArgument(String), + #[error("Token template bytecode error: {0}")] + BytecodeError(String), + #[error("Transaction has not reached the required MPC finality")] + MpcFinalityNotReached, +} diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/src/rpc.rs b/bridge-sdk/bridge-clients/sui-bridge-client/src/rpc.rs new file mode 100644 index 00000000..96624586 --- /dev/null +++ b/bridge-sdk/bridge-clients/sui-bridge-client/src/rpc.rs @@ -0,0 +1,60 @@ +//! Thin wrappers over the `sui-rpc` gRPC client. + +use sui_rpc::field::{FieldMask, FieldMaskUtil}; +use sui_rpc::proto::sui::rpc::v2::{ + ExecutedTransaction, GetObjectRequest, GetTransactionRequest, Object, +}; +use sui_rpc::Client; + +use crate::error::{Result, SuiBridgeClientError}; + +pub(crate) fn rpc_error(status: &tonic::Status) -> SuiBridgeClientError { + SuiBridgeClientError::RpcError(format!("{}: {}", status.code(), status.message())) +} + +/// Fetch a transaction by base58 digest; `Ok(None)` if the node doesn't know +/// it (yet). +pub(crate) async fn get_transaction_opt( + client: &mut Client, + digest: &str, + paths: &[&str], +) -> Result> { + let request = GetTransactionRequest::default() + .with_digest(digest) + .with_read_mask(FieldMask::from_paths(paths)); + match client.ledger_client().get_transaction(request).await { + Ok(response) => Ok(response.into_inner().transaction), + Err(status) if status.code() == tonic::Code::NotFound => Ok(None), + Err(status) => Err(rpc_error(&status)), + } +} + +pub(crate) async fn get_transaction( + client: &mut Client, + digest: &str, + paths: &[&str], +) -> Result { + get_transaction_opt(client, digest, paths) + .await? + .ok_or_else(|| { + SuiBridgeClientError::BlockchainDataError(format!("transaction {digest} not found")) + }) +} + +pub(crate) async fn get_object( + client: &mut Client, + object_id: &str, + paths: &[&str], +) -> Result { + let request = GetObjectRequest::default() + .with_object_id(object_id) + .with_read_mask(FieldMask::from_paths(paths)); + let response = client + .ledger_client() + .get_object(request) + .await + .map_err(|status| rpc_error(&status))?; + response.into_inner().object.ok_or_else(|| { + SuiBridgeClientError::BlockchainDataError(format!("object {object_id} not found")) + }) +} diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/src/sui_bridge_client.rs b/bridge-sdk/bridge-clients/sui-bridge-client/src/sui_bridge_client.rs new file mode 100644 index 00000000..9793a202 --- /dev/null +++ b/bridge-sdk/bridge-clients/sui-bridge-client/src/sui_bridge_client.rs @@ -0,0 +1,947 @@ +//! Sui bridge client for the Omni Bridge. +//! +//! Mirrors `aptos-bridge-client` in shape, but talks to Sui over gRPC via the +//! `sui-rpc` crate (Sui JSON-RPC is decommissioned) and builds programmable +//! transaction blocks with `sui-transaction-builder`. Signing uses +//! `sui-crypto` (raw Ed25519 seed, Sui intent + Blake2b handled internally). + +use std::time::Duration; + +use near_mpc_contract_interface::types::SuiFinality; +use omni_types::near_events::OmniBridgeEvent; +use omni_types::OmniAddress; +use sha3::{Digest as _, Keccak256}; +use sui_crypto::ed25519::Ed25519PrivateKey; +use sui_crypto::SuiSigner; +use sui_rpc::client::ExecuteAndWaitError; +use sui_rpc::field::{FieldMask, FieldMaskUtil}; +use sui_rpc::proto::sui::rpc::v2::changed_object::IdOperation; +use sui_rpc::proto::sui::rpc::v2::owner::OwnerKind; +use sui_rpc::proto::sui::rpc::v2::simulate_transaction_request::TransactionChecks; +use sui_rpc::proto::sui::rpc::v2::{ + ExecuteTransactionRequest, ExecutedTransaction, SimulateTransactionRequest, +}; +use sui_sdk_types::{ + Address, Argument, Command, GasPayment, Identifier, Input, MoveCall, ProgrammableTransaction, + SharedInput, StructTag, Transaction, TransactionExpiration, TransactionKind, TypeTag, +}; +use sui_transaction_builder::{Function, ObjectInput, TransactionBuilder}; + +use crate::error::{Result, SuiBridgeClientError}; + +pub use builder::SuiBridgeClientBuilder; + +pub mod bytecode; +pub mod error; + +mod builder; +mod rpc; + +/// Move module that hosts the bridge functions. +const MODULE_NAME: &str = "omni_bridge"; +/// How long to wait for checkpoint inclusion after execution. Sui checkpoints +/// are sub-second in practice; a timeout here does not mean the transaction +/// failed (see `sign_and_execute`). +const CHECKPOINT_TIMEOUT: Duration = Duration::from_secs(60); +/// Sui coin decimals are capped at 9 (`utils::normalize_decimals` on-chain). +const MAX_SUI_DECIMALS: u8 = 9; + +/// An Ed25519 signing identity for submitting Sui transactions. The Sui +/// address is derived from the public key (Blake2b of `0x00 || pubkey`). +pub struct SuiAccount { + pub(crate) key: Ed25519PrivateKey, + pub(crate) address: Address, +} + +/// Sui bridge client for the `omni_bridge` Move package. +pub struct SuiBridgeClient { + pub(crate) rpc: sui_rpc::Client, + pub(crate) account: Option, + /// Package id the `omni_bridge` module is published under. + pub(crate) bridge_address: Option
, + /// The shared `BridgeState` object. + pub(crate) state_object_id: Option
, + pub(crate) mpc_finality: Option, +} + +/// A decoded `InitTransfer` event (BCS layout matches the Move struct field +/// order), used to derive NEAR storage-deposit actions. +#[derive(Debug, serde::Deserialize)] +#[cfg_attr(test, derive(serde::Serialize))] +pub struct SuiInitTransferEvent { + pub sender: [u8; 32], + /// `keccak256(canonical coin type string)` — the wire token id. + pub token_address: [u8; 32], + pub coin_type: String, + pub origin_nonce: u64, + pub amount: u128, + pub fee: u128, + pub native_fee: u128, + pub recipient: String, + pub message: Vec, +} + +/// A raw bridge event with the metadata the MPC foreign-tx validation payload +/// needs. `type_tag` is normalized to Sui's canonical long form and `bcs` +/// carries the raw BCS event contents — exactly what the MPC node's Sui +/// inspector reconstructs. +#[derive(Debug)] +pub struct SuiEventLog { + pub package_id: [u8; 32], + pub transaction_module: String, + pub sender: [u8; 32], + pub type_tag: String, + pub bcs: Vec, + pub event_index: u64, +} + +impl SuiBridgeClient { + fn account(&self) -> Result<&SuiAccount> { + self.account.as_ref().ok_or_else(|| { + SuiBridgeClientError::ConfigError("Sui private key is not set".to_string()) + }) + } + + fn bridge_address(&self) -> Result
{ + self.bridge_address.ok_or_else(|| { + SuiBridgeClientError::ConfigError("OmniBridge package id is not set".to_string()) + }) + } + + fn state_object_id(&self) -> Result
{ + self.state_object_id.ok_or_else(|| { + SuiBridgeClientError::ConfigError("OmniBridge state object id is not set".to_string()) + }) + } + + fn bridge_function(&self, function: &'static str) -> Result { + Ok(Function::new( + self.bridge_address()?, + Identifier::from_static(MODULE_NAME), + Identifier::from_static(function), + )) + } + + /// The shared `BridgeState` object as a PTB input; fetches the initial + /// shared version from the node. + async fn state_object_input(&self, mutable: bool) -> Result { + let state_id = self.state_object_id()?; + let (version, kind) = self.shared_object_start_version(state_id).await?; + if kind != OwnerKind::Shared { + return Err(SuiBridgeClientError::BlockchainDataError(format!( + "bridge state object {state_id} is not a shared object" + ))); + } + Ok(ObjectInput::shared(state_id, version, mutable)) + } + + async fn shared_object_start_version(&self, object_id: Address) -> Result<(u64, OwnerKind)> { + let object = rpc::get_object( + &mut self.rpc.clone(), + &object_id.to_string(), + &["object_id", "owner"], + ) + .await?; + Ok((object.owner().version(), object.owner().kind())) + } + + /// Build (resolving object versions and gas via the node), sign, submit, + /// and wait for checkpoint inclusion. Returns the executed transaction; + /// fails if execution was unsuccessful. + async fn sign_and_execute( + &self, + transaction_builder: TransactionBuilder, + ) -> Result<(String, ExecutedTransaction)> { + let account = self.account()?; + let mut client = self.rpc.clone(); + + let transaction = transaction_builder + .build(&mut client) + .await + .map_err(|e| SuiBridgeClientError::TransactionError(format!("{e}")))?; + let digest = transaction.digest().to_base58(); + + let signature = account + .key + .sign_transaction(&transaction) + .map_err(|e| SuiBridgeClientError::TransactionError(format!("signing failed: {e}")))?; + + let request = ExecuteTransactionRequest::default() + .with_transaction(transaction) + .with_signatures(vec![signature.into()]) + .with_read_mask(FieldMask::from_paths([ + "digest", + "checkpoint", + "effects.status", + "effects.changed_objects", + "events", + ])); + + tracing::info!(tx_digest = %digest, "Submitting Sui transaction"); + let response = match client + .execute_transaction_and_wait_for_checkpoint(request, CHECKPOINT_TIMEOUT) + .await + { + Ok(response) => response.into_inner(), + Err(ExecuteAndWaitError::RpcError(status)) => return Err(rpc::rpc_error(&status)), + // The transaction executed; only checkpoint tracking fell behind. + // Downstream MPC flows re-verify finality via `check_mpc_finality`. + Err(ExecuteAndWaitError::CheckpointTimeout(response)) => { + tracing::warn!(tx_digest = %digest, "Sui transaction executed but checkpoint inclusion was not observed in time"); + response.into_inner() + } + Err(ExecuteAndWaitError::CheckpointStreamError { response, error }) => { + tracing::warn!(tx_digest = %digest, %error, "Sui transaction executed but the checkpoint stream failed"); + response.into_inner() + } + Err(other) => return Err(SuiBridgeClientError::TransactionError(format!("{other}"))), + }; + + let executed = response.transaction.unwrap_or_default(); + let status = executed.effects().status(); + if !status.success() { + return Err(SuiBridgeClientError::TransactionError(format!( + "transaction {digest} failed: {}", + status.error() + ))); + } + Ok((digest, executed)) + } + + /// Log token metadata on the Sui `omni_bridge` contract (permissionless). + /// `coin_type` is the coin's Move type, e.g. `0x2::sui::SUI`. + #[tracing::instrument(skip_all, name = "SUI LOG METADATA")] + pub async fn log_metadata(&self, coin_type: &str) -> Result { + let account_address = self.account()?.address; + let coin_type_tag = parse_struct_tag(coin_type)?; + + let metadata = self.coin_metadata_object(&coin_type_tag).await?; + let function = if metadata.is_registry_currency { + "log_metadata_registry" + } else { + "log_metadata" + }; + + let mut tx = TransactionBuilder::new(); + tx.set_sender(account_address); + let state = tx.object(self.state_object_input(true).await?); + let metadata_arg = tx.object(metadata.input); + tx.move_call( + self.bridge_function(function)? + .with_type_args(vec![coin_type_tag.into()]), + vec![state, metadata_arg], + ); + + let (digest, _) = self.sign_and_execute(tx).await?; + Ok(digest) + } + + /// Deploy a bridged token on Sui using a `LogMetadataEvent` from NEAR. + /// + /// Sui cannot create a currency at runtime, so this submits two + /// back-to-back transactions: (1) publish a per-token package patched + /// from the vendored `token_template` bytecode, (2) call + /// `deploy_token` with the MPC signature and the created + /// `TreasuryCap` / `UpgradeCap` / `CoinMetadata` objects. Returns the + /// digest of the `deploy_token` transaction. + #[tracing::instrument(skip_all, name = "SUI DEPLOY TOKEN")] + pub async fn deploy_token(&self, event: OmniBridgeEvent) -> Result { + let OmniBridgeEvent::LogMetadataEvent { + signature, + metadata_payload, + } = event + else { + return Err(SuiBridgeClientError::InvalidArgument(format!( + "Expected LogMetadataEvent but got {event:?}" + ))); + }; + + let account_address = self.account()?.address; + let origin_decimals = metadata_payload.decimals; + let clamped_decimals = origin_decimals.min(MAX_SUI_DECIMALS); + let (module_name, otw_name) = bytecode::coin_module_identifiers(&metadata_payload.symbol); + + let patched_module = bytecode::patch_token_template( + bytecode::TOKEN_TEMPLATE_BYTECODE, + &module_name, + &otw_name, + &metadata_payload.symbol, + &metadata_payload.name, + clamped_decimals, + )?; + + // 1. Publish the per-token package. + let mut tx = TransactionBuilder::new(); + tx.set_sender(account_address); + let upgrade_cap = tx.publish( + vec![patched_module], + bytecode::TOKEN_TEMPLATE_DEPENDENCIES + .iter() + .map(|address| Address::new(*address)) + .collect(), + ); + let sender_arg = tx.pure(&account_address); + tx.transfer_objects(vec![upgrade_cap], sender_arg); + let (publish_digest, published) = self.sign_and_execute(tx).await?; + tracing::info!(tx_digest = %publish_digest, "Published Sui token package"); + + let created = CreatedTokenObjects::from_publish_effects(&published, &publish_digest)?; + + // 2. Bind the published coin to the bridge with the MPC signature. + let mut tx = TransactionBuilder::new(); + tx.set_sender(account_address); + let state = tx.object(self.state_object_input(true).await?); + let signature_arg = tx.pure(&signature.to_bytes().to_vec()); + let token_arg = tx.pure(&metadata_payload.token.to_string()); + let name_arg = tx.pure(&metadata_payload.name); + let symbol_arg = tx.pure(&metadata_payload.symbol); + let decimals_arg = tx.pure(&origin_decimals); + let treasury_cap = tx.object(created.treasury_cap); + let upgrade_cap = tx.object(created.upgrade_cap); + let coin_metadata = tx.object(created.coin_metadata); + tx.move_call( + self.bridge_function("deploy_token")? + .with_type_args(vec![created.coin_type.into()]), + vec![ + state, + signature_arg, + token_arg, + name_arg, + symbol_arg, + decimals_arg, + treasury_cap, + upgrade_cap, + coin_metadata, + ], + ); + + let (digest, _) = self.sign_and_execute(tx).await?; + Ok(digest) + } + + /// Initiate a transfer from Sui to another chain (user-signed outbound). + /// `token` is the coin's Move type, e.g. `0x2::sui::SUI`. + #[tracing::instrument(skip_all, name = "SUI INIT TRANSFER")] + pub async fn init_transfer( + &self, + token: String, + amount: u128, + fee: u128, + native_fee: u128, + recipient: String, + message: Vec, + ) -> Result { + let account_address = self.account()?.address; + let coin_type = parse_struct_tag(&token)?; + let amount = to_coin_value(amount, "amount")?; + let fee = to_coin_value(fee, "fee")?; + let native_fee = to_coin_value(native_fee, "native_fee")?; + + let mut tx = TransactionBuilder::new(); + tx.set_sender(account_address); + let state = tx.object(self.state_object_input(true).await?); + let coin_arg = tx.coin(coin_type.clone(), amount); + // The contract always takes a `Coin` native fee; split a + // zero-value coin off gas when there is none. + let native_fee_arg = if native_fee == 0 { + let zero = tx.pure(&0u64); + let gas = tx.gas(); + tx.split_coins(gas, vec![zero]) + .pop() + .expect("split_coins returns one argument per amount") + } else { + tx.coin(StructTag::sui(), native_fee) + }; + let fee_arg = tx.pure(&fee); + let recipient_arg = tx.pure(&recipient); + let message_arg = tx.pure(&message); + tx.move_call( + self.bridge_function("init_transfer")? + .with_type_args(vec![coin_type.into()]), + vec![ + state, + coin_arg, + fee_arg, + native_fee_arg, + recipient_arg, + message_arg, + ], + ); + + let (digest, _) = self.sign_and_execute(tx).await?; + Ok(digest) + } + + /// Finalize a transfer to Sui using a `SignTransferEvent` from NEAR. + #[tracing::instrument(skip_all, name = "SUI FIN TRANSFER")] + pub async fn fin_transfer(&self, event: OmniBridgeEvent) -> Result { + let OmniBridgeEvent::SignTransferEvent { + message_payload, + signature, + } = event + else { + return Err(SuiBridgeClientError::InvalidArgument(format!( + "Expected SignTransferEvent but got {event:?}" + ))); + }; + + let account_address = self.account()?.address; + let token = omni_address_to_sui(&message_payload.token_address)?; + let recipient = omni_address_to_sui(&message_payload.recipient)?; + // Sui coins are types: resolve the wire token id back to the coin + // type through the bridge's on-chain token registry. + let coin_type = parse_struct_tag(&self.get_coin_type(token).await?)?; + let amount: u128 = message_payload.amount.into(); + let fee_recipient = message_payload.fee_recipient.map(|a| a.to_string()); + + let mut tx = TransactionBuilder::new(); + tx.set_sender(account_address); + let state = tx.object(self.state_object_input(true).await?); + let signature_arg = tx.pure(&signature.to_bytes().to_vec()); + let destination_nonce_arg = tx.pure(&message_payload.destination_nonce); + let origin_chain_arg = tx.pure(&u8::from(message_payload.transfer_id.origin_chain)); + let origin_nonce_arg = tx.pure(&message_payload.transfer_id.origin_nonce); + let amount_arg = tx.pure(&amount); + let recipient_arg = tx.pure(&Address::new(recipient)); + let fee_recipient_arg = tx.pure(&fee_recipient); + let message_arg = tx.pure(&message_payload.message); + tx.move_call( + self.bridge_function("fin_transfer")? + .with_type_args(vec![coin_type.into()]), + vec![ + state, + signature_arg, + destination_nonce_arg, + origin_chain_arg, + origin_nonce_arg, + amount_arg, + recipient_arg, + fee_recipient_arg, + message_arg, + ], + ); + + let (digest, _) = self.sign_and_execute(tx).await?; + Ok(digest) + } + + /// Whether a transfer with the given destination nonce has been finalised. + pub async fn is_transfer_finalised(&self, nonce: u64) -> Result { + let output = self + .call_view("is_transfer_finalised", vec![encode_pure(&nonce)?]) + .await?; + bcs::from_bytes(&output).map_err(|e| { + SuiBridgeClientError::BlockchainDataError(format!( + "is_transfer_finalised returned malformed bool: {e}" + )) + }) + } + + /// Resolve a wire token id (`keccak256` of the canonical coin type) back + /// to the coin type string through the bridge's on-chain token registry. + pub async fn get_coin_type(&self, token_address: [u8; 32]) -> Result { + let output = self + .call_view( + "get_coin_type", + vec![encode_pure(&Address::new(token_address))?], + ) + .await?; + let coin_type: Option = bcs::from_bytes(&output).map_err(|e| { + SuiBridgeClientError::BlockchainDataError(format!( + "get_coin_type returned malformed Option: {e}" + )) + })?; + coin_type.ok_or_else(|| { + SuiBridgeClientError::BlockchainDataError(format!( + "token 0x{} is not registered in the Sui bridge token registry", + hex::encode(token_address) + )) + }) + } + + /// Call a read-only `omni_bridge` view function via an unsigned + /// simulation and return the BCS bytes of its return value. + async fn call_view(&self, function: &str, pure_args: Vec>) -> Result> { + let bridge = self.bridge_address()?; + let state_id = self.state_object_id()?; + let (state_version, _) = self.shared_object_start_version(state_id).await?; + let sender = self + .account + .as_ref() + .map_or(Address::ZERO, |account| account.address); + + let mut inputs = vec![Input::Shared(SharedInput::new( + state_id, + state_version, + false, + ))]; + inputs.extend(pure_args.into_iter().map(Input::Pure)); + let arguments = (0..inputs.len()) + .map(|i| { + Argument::Input(u16::try_from(i).expect("view calls have a handful of inputs")) + }) + .collect(); + + // Checks are disabled, so the node synthesizes a mock gas coin for + // the empty payment. The price must be non-zero: `price == 0` with no + // payment classifies the transaction as "gasless" (address-balance + // paid), which only allowlisted functions support. + let transaction = Transaction { + kind: TransactionKind::ProgrammableTransaction(ProgrammableTransaction { + inputs, + commands: vec![Command::MoveCall(MoveCall { + package: bridge, + module: Identifier::from_static(MODULE_NAME), + function: Identifier::new(function).map_err(|e| { + SuiBridgeClientError::InvalidArgument(format!( + "invalid view function name {function:?}: {e}" + )) + })?, + type_arguments: vec![], + arguments, + })], + }), + sender, + gas_payment: GasPayment { + objects: vec![], + owner: sender, + price: 1000, + budget: 1_000_000_000, + }, + expiration: TransactionExpiration::None, + }; + + let request = SimulateTransactionRequest::default() + .with_transaction(transaction) + .with_checks(TransactionChecks::Disabled) + .with_read_mask(FieldMask::from_paths(["command_outputs"])); + + let response = self + .rpc + .clone() + .execution_client() + .simulate_transaction(request) + .await + .map_err(|status| rpc::rpc_error(&status))? + .into_inner(); + + response + .command_outputs + .first() + .and_then(|command| command.return_values.first()) + .and_then(|output| output.value.as_ref()) + .and_then(|bcs| bcs.value.as_ref()) + .map(|bytes| bytes.to_vec()) + .ok_or_else(|| { + SuiBridgeClientError::BlockchainDataError(format!( + "{function} view returned no value" + )) + }) + } + + /// Returns the configured MPC finality level for this chain. + pub fn mpc_finality(&self) -> Result { + self.mpc_finality.clone().ok_or_else(|| { + SuiBridgeClientError::ConfigError("MPC finality is not configured".to_string()) + }) + } + + /// Verifies that `tx_hash` has reached the configured MPC finality level + /// and returns it for embedding in the MPC sign payload. For Sui, + /// `Checkpointed` means the transaction executed successfully and is + /// included in a committee-certified checkpoint. + pub async fn check_mpc_finality(&self, tx_hash: &str) -> Result { + let finality = self.mpc_finality()?; + let Some(executed) = rpc::get_transaction_opt( + &mut self.rpc.clone(), + tx_hash, + &["digest", "checkpoint", "effects.status"], + ) + .await? + else { + return Err(SuiBridgeClientError::MpcFinalityNotReached); + }; + + if !executed.effects().status().success() { + return Err(SuiBridgeClientError::TransactionError(format!( + "transaction {tx_hash} failed: {}", + executed.effects().status().error() + ))); + } + if executed.checkpoint.is_none() { + return Err(SuiBridgeClientError::MpcFinalityNotReached); + } + Ok(finality) + } + + /// Decode the `InitTransfer` event from a transaction. + pub async fn get_transfer_event(&self, tx_hash: &str) -> Result { + let log = self.get_init_transfer_log(tx_hash).await?; + bcs::from_bytes(&log.bcs).map_err(|e| { + SuiBridgeClientError::BlockchainDataError(format!( + "InitTransfer event has malformed BCS contents: {e}" + )) + }) + } + + /// Raw `InitTransfer` log with metadata for MPC proof construction. + pub async fn get_init_transfer_log(&self, tx_hash: &str) -> Result { + self.get_event_log(tx_hash, "InitTransfer").await + } + + /// Raw `DeployToken` log with metadata for MPC proof construction. + pub async fn get_deploy_token_log(&self, tx_hash: &str) -> Result { + self.get_event_log(tx_hash, "DeployToken").await + } + + /// Raw `FinTransfer` log with metadata for MPC proof construction. + pub async fn get_fin_transfer_log(&self, tx_hash: &str) -> Result { + self.get_event_log(tx_hash, "FinTransfer").await + } + + /// Raw `LogMetadata` log with metadata for MPC proof construction. + pub async fn get_log_metadata_log(&self, tx_hash: &str) -> Result { + self.get_event_log(tx_hash, "LogMetadata").await + } + + async fn get_event_log(&self, tx_hash: &str, event_name: &str) -> Result { + let bridge = self.bridge_address()?; + let executed = rpc::get_transaction( + &mut self.rpc.clone(), + tx_hash, + &["digest", "events", "effects.status"], + ) + .await?; + + let events = &executed.events().events; + let (event_index, event) = events + .iter() + .enumerate() + .find(|(_, event)| { + event.event_type().parse::().is_ok_and(|tag| { + *tag.address() == bridge + && tag.module().as_str() == MODULE_NAME + && tag.name().as_str() == event_name + }) + }) + .ok_or_else(|| { + SuiBridgeClientError::BlockchainDataError(format!( + "{event_name} event not found in transaction {tx_hash}" + )) + })?; + + // Canonical long-form type tag — must match the MPC node's + // `normalize_type_tag` (`sui_sdk_types::TypeTag` round-trip) exactly. + let type_tag = event + .event_type() + .parse::() + .map_err(|e| { + SuiBridgeClientError::BlockchainDataError(format!( + "malformed event type tag {:?}: {e}", + event.event_type() + )) + })? + .to_string(); + + Ok(SuiEventLog { + package_id: parse_address_field(event.package_id(), "package_id")?, + transaction_module: event.module().to_string(), + sender: parse_address_field(event.sender(), "sender")?, + type_tag, + bcs: event.contents().value().to_vec(), + event_index: event_index as u64, + }) + } + + /// Locate the `CoinMetadata` (or coin-registry `Currency`) object + /// for a coin type and prepare it as a PTB input. + async fn coin_metadata_object(&self, coin_type: &StructTag) -> Result { + let mut client = self.rpc.clone(); + let response = client + .state_client() + .get_coin_info( + sui_rpc::proto::sui::rpc::v2::GetCoinInfoRequest::default() + .with_coin_type(coin_type.to_string()), + ) + .await + .map_err(|status| rpc::rpc_error(&status))? + .into_inner(); + + let metadata_id = response + .metadata + .as_ref() + .and_then(|metadata| metadata.id.clone()) + .ok_or_else(|| { + SuiBridgeClientError::BlockchainDataError(format!( + "no coin metadata object found for {coin_type}" + )) + })?; + + let object = rpc::get_object( + &mut client, + &metadata_id, + &["object_id", "version", "digest", "owner", "object_type"], + ) + .await?; + let object_id: Address = metadata_id.parse().map_err(|e| { + SuiBridgeClientError::BlockchainDataError(format!( + "invalid coin metadata object id {metadata_id:?}: {e}" + )) + })?; + + let is_registry_currency = object.object_type().parse::().is_ok_and(|tag| { + *tag.address() == Address::TWO + && tag.module().as_str() == "coin_registry" + && tag.name().as_str() == "Currency" + }); + + let input = match object.owner().kind() { + OwnerKind::Immutable => { + let digest = object.digest().parse().map_err(|e| { + SuiBridgeClientError::BlockchainDataError(format!( + "invalid object digest for {metadata_id}: {e}" + )) + })?; + ObjectInput::immutable(object_id, object.version(), digest) + } + OwnerKind::Shared => { + ObjectInput::shared(object_id, object.owner().version(), false) + } + OwnerKind::Address + if self + .account + .as_ref() + .is_some_and(|account| account.address.to_string() == object.owner().address()) => + { + let digest = object.digest().parse().map_err(|e| { + SuiBridgeClientError::BlockchainDataError(format!( + "invalid object digest for {metadata_id}: {e}" + )) + })?; + ObjectInput::owned(object_id, object.version(), digest) + } + other => { + return Err(SuiBridgeClientError::InvalidArgument(format!( + "coin metadata object {metadata_id} has unsupported ownership {other:?} (owner: {})", + object.owner().address() + ))) + } + }; + + Ok(CoinMetadataInput { + input, + is_registry_currency, + }) + } +} + +struct CoinMetadataInput { + input: ObjectInput, + is_registry_currency: bool, +} + +/// The objects created by publishing a token-template package. +struct CreatedTokenObjects { + coin_type: StructTag, + treasury_cap: ObjectInput, + upgrade_cap: ObjectInput, + coin_metadata: ObjectInput, +} + +impl CreatedTokenObjects { + fn from_publish_effects(executed: &ExecutedTransaction, digest: &str) -> Result { + let mut coin_type = None; + let mut treasury_cap = None; + let mut upgrade_cap = None; + let mut coin_metadata = None; + + for changed in &executed.effects().changed_objects { + if changed.id_operation() != IdOperation::Created { + continue; + } + let Ok(tag) = changed.object_type().parse::() else { + continue; // the package itself has object_type "package" + }; + if *tag.address() != Address::TWO { + continue; + } + let input = || -> Result { + let object_id: Address = changed.object_id().parse().map_err(|e| { + SuiBridgeClientError::BlockchainDataError(format!( + "invalid created object id in {digest}: {e}" + )) + })?; + let object_digest = changed.output_digest().parse().map_err(|e| { + SuiBridgeClientError::BlockchainDataError(format!( + "invalid created object digest in {digest}: {e}" + )) + })?; + Ok(ObjectInput::owned( + object_id, + changed.output_version(), + object_digest, + )) + }; + match (tag.module().as_str(), tag.name().as_str()) { + ("coin", "TreasuryCap") => { + coin_type = tag.type_params().first().cloned(); + treasury_cap = Some(input()?); + } + ("package", "UpgradeCap") => upgrade_cap = Some(input()?), + ("coin", "CoinMetadata") => coin_metadata = Some(input()?), + _ => {} + } + } + + let missing = |what: &str| { + SuiBridgeClientError::BlockchainDataError(format!( + "publish transaction {digest} did not create a {what}" + )) + }; + let coin_type = match coin_type.ok_or_else(|| missing("TreasuryCap with a coin type"))? { + TypeTag::Struct(tag) => *tag, + other => { + return Err(SuiBridgeClientError::BlockchainDataError(format!( + "TreasuryCap type parameter is not a struct: {other}" + ))) + } + }; + Ok(Self { + coin_type, + treasury_cap: treasury_cap.ok_or_else(|| missing("TreasuryCap"))?, + upgrade_cap: upgrade_cap.ok_or_else(|| missing("UpgradeCap"))?, + coin_metadata: coin_metadata.ok_or_else(|| missing("CoinMetadata"))?, + }) + } +} + +/// Parse a base58 Sui transaction digest into its raw 32 bytes (for +/// `SuiTxId` in MPC sign payloads). +pub fn parse_transaction_digest(digest: &str) -> std::result::Result<[u8; 32], String> { + sui_sdk_types::Digest::from_base58(digest) + .map(sui_sdk_types::Digest::into_inner) + .map_err(|e| format!("invalid Sui transaction digest {digest:?}: {e}")) +} + +/// The wire token id for a Sui coin: `keccak256` of the canonical coin type +/// string (64-hex defining package id without `0x`, then `::module::NAME`) — +/// mirrors `std::type_name` + keccak on-chain. Generic coin types are not +/// supported (bridge tokens are plain structs). +pub fn token_address_from_coin_type(coin_type: &str) -> std::result::Result<[u8; 32], String> { + let tag: StructTag = coin_type + .trim() + .parse() + .map_err(|e| format!("invalid coin type {coin_type:?}: {e}"))?; + if !tag.type_params().is_empty() { + return Err(format!( + "generic coin types are not supported as bridge tokens: {coin_type}" + )); + } + let canonical = format!( + "{}::{}::{}", + hex::encode(tag.address().into_inner()), + tag.module(), + tag.name() + ); + Ok(Keccak256::digest(canonical.as_bytes()).into()) +} + +fn parse_struct_tag(coin_type: &str) -> Result { + coin_type.trim().parse().map_err(|e| { + SuiBridgeClientError::InvalidArgument(format!("invalid coin type {coin_type:?}: {e}")) + }) +} + +fn parse_address_field(value: &str, field: &str) -> Result<[u8; 32]> { + value + .parse::
() + .map(Address::into_inner) + .map_err(|e| { + SuiBridgeClientError::BlockchainDataError(format!("invalid event {field}: {e}")) + }) +} + +fn omni_address_to_sui(address: &OmniAddress) -> Result<[u8; 32]> { + match address { + OmniAddress::Sui(h256) => Ok(h256.0), + other => Err(SuiBridgeClientError::InvalidArgument(format!( + "Expected Sui address but got {other:?}" + ))), + } +} + +fn to_coin_value(value: u128, what: &str) -> Result { + u64::try_from(value).map_err(|_| { + SuiBridgeClientError::InvalidArgument(format!( + "{what} {value} exceeds u64 (Sui coin values are u64)" + )) + }) +} + +fn encode_pure(value: &T) -> Result> { + bcs::to_bytes(value).map_err(|e| { + SuiBridgeClientError::InvalidArgument(format!("BCS serialization failed: {e}")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_address_of_native_sui_matches_known_value() { + // keccak256(b"0000...0002::sui::SUI") — the omni-types native token + // constant for Sui, identical across networks. + let id = token_address_from_coin_type("0x2::sui::SUI").unwrap(); + assert_eq!( + hex::encode(id), + "6696387aecbb705205026783042f803871c190570dd0a57882d9d35ee0df700c" + ); + // Long-form input canonicalizes to the same id. + let long = token_address_from_coin_type( + "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI", + ) + .unwrap(); + assert_eq!(id, long); + } + + #[test] + fn token_address_rejects_generics_and_garbage() { + assert!(token_address_from_coin_type("0x2::coin::Coin<0x2::sui::SUI>").is_err()); + assert!(token_address_from_coin_type("not-a-type").is_err()); + } + + #[test] + fn init_transfer_event_bcs_roundtrip() { + let event = SuiInitTransferEvent { + sender: [0x11; 32], + token_address: [0x22; 32], + coin_type: "0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + .to_string(), + origin_nonce: 7, + amount: 1_000_000, + fee: 100, + native_fee: 50, + recipient: "near:alice.near".to_string(), + message: vec![0xde, 0xad], + }; + let bytes = bcs::to_bytes(&event).unwrap(); + let decoded: SuiInitTransferEvent = bcs::from_bytes(&bytes).unwrap(); + assert_eq!(decoded.sender, event.sender); + assert_eq!(decoded.token_address, event.token_address); + assert_eq!(decoded.origin_nonce, 7); + assert_eq!(decoded.amount, 1_000_000); + assert_eq!(decoded.fee, 100); + assert_eq!(decoded.native_fee, 50); + assert_eq!(decoded.recipient, "near:alice.near"); + assert_eq!(decoded.message, vec![0xde, 0xad]); + } + + #[test] + fn parses_transaction_digest_base58() { + let digest = sui_sdk_types::Digest::new([0xAB; 32]); + let parsed = parse_transaction_digest(&digest.to_base58()).unwrap(); + assert_eq!(parsed, [0xAB; 32]); + assert!(parse_transaction_digest("not base58 !!!").is_err()); + } +} diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/src/template/template_coin.mv b/bridge-sdk/bridge-clients/sui-bridge-client/src/template/template_coin.mv new file mode 100644 index 00000000..760af23c Binary files /dev/null and b/bridge-sdk/bridge-clients/sui-bridge-client/src/template/template_coin.mv differ diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/tests/fixtures/usdt_coin_patched.mv b/bridge-sdk/bridge-clients/sui-bridge-client/tests/fixtures/usdt_coin_patched.mv new file mode 100644 index 00000000..e20264f0 Binary files /dev/null and b/bridge-sdk/bridge-clients/sui-bridge-client/tests/fixtures/usdt_coin_patched.mv differ diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/tests/testnet.rs b/bridge-sdk/bridge-clients/sui-bridge-client/tests/testnet.rs new file mode 100644 index 00000000..58d4f9e3 --- /dev/null +++ b/bridge-sdk/bridge-clients/sui-bridge-client/tests/testnet.rs @@ -0,0 +1,172 @@ +//! Network tests against the Sui testnet deployment from +//! Near-One/omni-bridge#633 (test MPC key — not production). Run manually: +//! +//! ```sh +//! cargo test -p sui-bridge-client -- --ignored --nocapture +//! ``` + +use sui_bridge_client::error::SuiBridgeClientError; +use sui_bridge_client::{bytecode, token_address_from_coin_type, SuiBridgeClientBuilder}; + +/// Package / state ids of the testnet deployment, and the example +/// `init_transfer` from the PR description (0.001 SUI -> near:frolik.testnet). +const TESTNET_RPC: &str = "https://fullnode.testnet.sui.io"; +/// Fullnodes prune history; the example transaction is only on the archive. +const TESTNET_ARCHIVE_RPC: &str = "https://archive.testnet.sui.io"; +const TESTNET_BRIDGE: &str = "0x587d69879cb01f62a88526a7448424c37cf767387473c1e610bd007dc95fa6ba"; +const TESTNET_STATE: &str = "0xcbe9d3824224eaa23e2ef70667fff857ddd0929b7c0e42935d509347505fe858"; +const EXAMPLE_INIT_TRANSFER_TX: &str = "HcrYCissQ4qS1bF5DXa5zmejYiHVwupPWMuq1t9eYiot"; + +fn client_for(endpoint: &str) -> sui_bridge_client::SuiBridgeClient { + SuiBridgeClientBuilder::default() + .endpoint(Some(endpoint.to_string())) + .bridge_address(Some(TESTNET_BRIDGE.to_string())) + .state_object_id(Some(TESTNET_STATE.to_string())) + .mpc_finality(Some( + near_mpc_contract_interface::types::SuiFinality::Checkpointed, + )) + .build() + .unwrap() +} + +#[tokio::test] +#[ignore = "requires network access to Sui testnet"] +async fn reads_init_transfer_event_log_from_known_tx() { + let client = client_for(TESTNET_ARCHIVE_RPC); + + let log = client + .get_init_transfer_log(EXAMPLE_INIT_TRANSFER_TX) + .await + .unwrap(); + // Canonical long-form type tag with the defining package id. + assert_eq!( + log.type_tag, + format!("{TESTNET_BRIDGE}::omni_bridge::InitTransfer") + ); + assert_eq!(log.transaction_module, "omni_bridge"); + assert!(!log.bcs.is_empty()); + + let event = client + .get_transfer_event(EXAMPLE_INIT_TRANSFER_TX) + .await + .unwrap(); + assert_eq!(event.recipient, "near:frolik.testnet"); + assert_eq!(event.amount, 1_000_000); // 0.001 SUI in MIST + assert_eq!( + event.token_address, + token_address_from_coin_type("0x2::sui::SUI").unwrap() + ); +} + +#[tokio::test] +#[ignore = "requires network access to Sui testnet"] +async fn check_mpc_finality_passes_for_checkpointed_tx() { + let client = client_for(TESTNET_ARCHIVE_RPC); + client + .check_mpc_finality(EXAMPLE_INIT_TRANSFER_TX) + .await + .unwrap(); +} + +/// The Move bytecode verifier runs while a `Publish` command executes, so a +/// successful simulation is the node itself verifying the patched module — +/// no gas or signer needed. `PEPE`/`PEPE` is the case that produced duplicate +/// constant-pool entries (and a verifier rejection) before the combined +/// params constant. +#[tokio::test] +#[ignore = "requires network access to Sui testnet"] +async fn patched_template_publish_passes_move_verifier_in_simulation() { + use sui_rpc::field::{FieldMask, FieldMaskUtil}; + use sui_rpc::proto::sui::rpc::v2::simulate_transaction_request::TransactionChecks; + use sui_rpc::proto::sui::rpc::v2::SimulateTransactionRequest; + use sui_sdk_types::{ + Address, Argument, Command, GasPayment, Input, ProgrammableTransaction, Publish, + Transaction, TransactionExpiration, TransactionKind, TransferObjects, + }; + + for (symbol, name) in [("PEPE", "PEPE"), ("", "Wrapped Foo"), ("FOO", "")] { + let (module_name, otw_name) = bytecode::coin_module_identifiers(symbol); + let patched = bytecode::patch_token_template( + bytecode::TOKEN_TEMPLATE_BYTECODE, + &module_name, + &otw_name, + symbol, + name, + 6, + ) + .unwrap(); + + let sender = Address::ZERO; + let transaction = Transaction { + kind: TransactionKind::ProgrammableTransaction(ProgrammableTransaction { + inputs: vec![Input::Pure(bcs::to_bytes(&sender).unwrap())], + commands: vec![ + Command::Publish(Publish { + modules: vec![patched], + dependencies: bytecode::TOKEN_TEMPLATE_DEPENDENCIES + .iter() + .map(|address| Address::new(*address)) + .collect(), + }), + Command::TransferObjects(TransferObjects { + objects: vec![Argument::Result(0)], + address: Argument::Input(0), + }), + ], + }), + sender, + // Empty payment + non-zero price: the node synthesizes a mock + // gas coin for the simulation (see `call_view`). + gas_payment: GasPayment { + objects: vec![], + owner: sender, + price: 1000, + budget: 1_000_000_000, + }, + expiration: TransactionExpiration::None, + }; + + let mut client = sui_rpc::Client::new(TESTNET_RPC).unwrap(); + let response = client + .execution_client() + .simulate_transaction( + SimulateTransactionRequest::default() + .with_transaction(transaction) + .with_checks(TransactionChecks::Disabled) + .with_read_mask(FieldMask::from_paths(["transaction.effects"])), + ) + .await + .unwrap() + .into_inner(); + + let status = response.transaction().effects().status(); + assert!( + status.success(), + "publish simulation for symbol={symbol:?} name={name:?} failed: {}", + status.error() + ); + } +} + +#[tokio::test] +#[ignore = "requires network access to Sui testnet"] +async fn views_work_via_simulation() { + let client = client_for(TESTNET_RPC); + + // A huge nonce has certainly not been finalised. + let finalised = client.is_transfer_finalised(u64::MAX - 1).await.unwrap(); + assert!(!finalised); + + // Whether SUI is registered depends on the deployment's log_metadata + // history — either outcome proves the on-chain view executed and decoded. + match client + .get_coin_type(token_address_from_coin_type("0x2::sui::SUI").unwrap()) + .await + { + Ok(coin_type) => assert!(coin_type.ends_with("::sui::SUI"), "got {coin_type}"), + Err(SuiBridgeClientError::BlockchainDataError(msg)) => { + assert!(msg.contains("not registered"), "unexpected error: {msg}"); + } + Err(other) => panic!("unexpected error: {other}"), + } +} diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/token_template/.gitignore b/bridge-sdk/bridge-clients/sui-bridge-client/token_template/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/bridge-sdk/bridge-clients/sui-bridge-client/token_template/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/token_template/Move.lock b/bridge-sdk/bridge-clients/sui-bridge-client/token_template/Move.lock new file mode 100644 index 00000000..c0350501 --- /dev/null +++ b/bridge-sdk/bridge-clients/sui-bridge-client/token_template/Move.lock @@ -0,0 +1,23 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "d50b78880fdacb1bbde92e6974ed71a7650c1090" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.OmniBridgeTokenTemplate] +source = { root = true } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "d50b78880fdacb1bbde92e6974ed71a7650c1090" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/token_template/Move.toml b/bridge-sdk/bridge-clients/sui-bridge-client/token_template/Move.toml new file mode 100644 index 00000000..b46f74c6 --- /dev/null +++ b/bridge-sdk/bridge-clients/sui-bridge-client/token_template/Move.toml @@ -0,0 +1,8 @@ +[package] +name = "OmniBridgeTokenTemplate" +edition = "2024.beta" +version = "0.1.0" +authors = ["Near One"] + +[addresses] +token_template = "0x0" diff --git a/bridge-sdk/bridge-clients/sui-bridge-client/token_template/sources/template_coin.move b/bridge-sdk/bridge-clients/sui-bridge-client/token_template/sources/template_coin.move new file mode 100644 index 00000000..7d08800f --- /dev/null +++ b/bridge-sdk/bridge-clients/sui-bridge-client/token_template/sources/template_coin.move @@ -0,0 +1,50 @@ +/// SDK-local variant of the Omni Bridge Sui token template +/// (`omni-bridge/sui/token_template`), used by `sui-bridge-client` for the +/// automated `deploy_token` flow. +/// +/// Differences from the canonical template are deliberate and cosmetic-only: +/// the per-token values are hoisted into named `const`s so they compile into +/// the module's constant pool, where `bytecode.rs` patches them. `symbol`, +/// `name`, and `description` live in a SINGLE `vector>` constant +/// rather than three separate ones: the Move verifier rejects modules whose +/// constant pool contains duplicate entries, so separate constants would make +/// publishing fail for any token where `symbol == name` or where either +/// equals the empty description. One combined constant (plus the `u8` +/// decimals constant of a different type) can never produce a duplicate, +/// whatever the token metadata is. The on-chain `omni_bridge::deploy_token` +/// only inspects the resulting objects (`TreasuryCap` / `CoinMetadata` / +/// `UpgradeCap`), never the package source, so template provenance does not +/// matter for verification. +/// +/// The compiled bytecode is vendored at `src/template/template_coin.mv`; see +/// `src/bytecode.rs` for how identifiers and constants are rewritten per +/// token. Regenerate with: +/// ~/.local/sui-cli/sui move build --dump-bytecode-as-base64 +/// (or any sui CLI; run from this directory) +#[allow(deprecated_usage)] +module token_template::template_coin; + +use sui::coin; + +const DECIMALS: u8 = 9; // min(origin_decimals, 9), from the signed MetadataPayload +// [symbol, name, description]; symbol/name from the signed MetadataPayload. +const PARAMS: vector> = vector[b"TMPL", b"Template Token", b""]; + +public struct TEMPLATE_COIN has drop {} + +fun init(witness: TEMPLATE_COIN, ctx: &mut TxContext) { + let params = PARAMS; + let (treasury_cap, metadata) = coin::create_currency( + witness, + DECIMALS, + *params.borrow(0), // symbol + *params.borrow(1), // name + *params.borrow(2), // description + option::none(), // icon url + ctx, + ); + transfer::public_transfer(treasury_cap, ctx.sender()); + // NOT frozen: `deploy_token` takes the metadata by value and keeps it + // bridge-owned so `set_token_metadata` can update it later. + transfer::public_transfer(metadata, ctx.sender()); +} diff --git a/bridge-sdk/connectors/bridge-connector-common/Cargo.toml b/bridge-sdk/connectors/bridge-connector-common/Cargo.toml index 38031bc8..fa78c1ea 100644 --- a/bridge-sdk/connectors/bridge-connector-common/Cargo.toml +++ b/bridge-sdk/connectors/bridge-connector-common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bridge-connector-common" -version = "0.3.5" +version = "0.3.6" edition = "2021" rust-version = "1.96.0" @@ -18,4 +18,5 @@ utxo-bridge-client = { path = "../../bridge-clients/utxo-bridge-client" } evm-bridge-client = { path = "../../bridge-clients/evm-bridge-client" } starknet-bridge-client = { path = "../../bridge-clients/starknet-bridge-client" } aptos-bridge-client = { path = "../../bridge-clients/aptos-bridge-client" } +sui-bridge-client = { path = "../../bridge-clients/sui-bridge-client" } hypercore-bridge-client = { path = "../../bridge-clients/hypercore-bridge-client" } diff --git a/bridge-sdk/connectors/bridge-connector-common/src/result.rs b/bridge-sdk/connectors/bridge-connector-common/src/result.rs index 420d2c09..beb3f6bf 100644 --- a/bridge-sdk/connectors/bridge-connector-common/src/result.rs +++ b/bridge-sdk/connectors/bridge-connector-common/src/result.rs @@ -11,6 +11,7 @@ use solana_bridge_client::error::SolanaBridgeClientError; use solana_rpc_client_api::client_error::Error as ClientError; use starknet_bridge_client::error::StarknetBridgeClientError; use std::result; +use sui_bridge_client::error::SuiBridgeClientError; use utxo_bridge_client::{self, error::UtxoClientError}; pub type Result = result::Result; @@ -71,6 +72,10 @@ pub enum BridgeSdkError { AptosRpcError(String), #[error("Error working with Aptos: {0}")] AptosOtherError(String), + #[error("Error communicating with Sui RPC: {0}")] + SuiRpcError(String), + #[error("Error working with Sui: {0}")] + SuiOtherError(String), #[error("Transaction has not reached the required MPC finality")] MpcFinalityNotReached, #[error("Error working with HyperCore: {0}")] @@ -161,6 +166,20 @@ impl From for BridgeSdkError { } } +impl From for BridgeSdkError { + fn from(error: SuiBridgeClientError) -> Self { + match error { + SuiBridgeClientError::RpcError(e) => Self::SuiRpcError(e), + SuiBridgeClientError::TransactionError(e) => Self::SuiOtherError(e), + SuiBridgeClientError::BlockchainDataError(e) => Self::SuiOtherError(e), + SuiBridgeClientError::BytecodeError(e) => Self::SuiOtherError(e), + SuiBridgeClientError::ConfigError(e) => Self::ConfigError(e), + SuiBridgeClientError::InvalidArgument(e) => Self::InvalidArgument(e), + SuiBridgeClientError::MpcFinalityNotReached => Self::MpcFinalityNotReached, + } + } +} + impl From for BridgeSdkError { fn from(error: HyperCoreBridgeClientError) -> Self { match error { diff --git a/bridge-sdk/connectors/omni-connector/Cargo.toml b/bridge-sdk/connectors/omni-connector/Cargo.toml index 398a41c0..b6e9ad40 100644 --- a/bridge-sdk/connectors/omni-connector/Cargo.toml +++ b/bridge-sdk/connectors/omni-connector/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "omni-connector" -version = "0.5.0" +version = "0.6.0" edition = "2021" rust-version = "1.96.0" @@ -46,6 +46,7 @@ wormhole-bridge-client = { path = "../../bridge-clients/wormhole-bridge-client" utxo-bridge-client = { path = "../../bridge-clients/utxo-bridge-client" } starknet-bridge-client = { path = "../../bridge-clients/starknet-bridge-client" } aptos-bridge-client = { path = "../../bridge-clients/aptos-bridge-client" } +sui-bridge-client = { path = "../../bridge-clients/sui-bridge-client" } hypercore-bridge-client = { path = "../../bridge-clients/hypercore-bridge-client" } starknet.workspace = true near-mpc-contract-interface.workspace = true diff --git a/bridge-sdk/connectors/omni-connector/src/omni_connector.rs b/bridge-sdk/connectors/omni-connector/src/omni_connector.rs index aa35d02b..e2fd8c33 100644 --- a/bridge-sdk/connectors/omni-connector/src/omni_connector.rs +++ b/bridge-sdk/connectors/omni-connector/src/omni_connector.rs @@ -10,7 +10,7 @@ use near_mpc_contract_interface::types::{ EvmExtractedValue, EvmExtractor, EvmLog, EvmRpcRequest, EvmTxId, ExtractedValue, ForeignChainRpcRequest, ForeignTxSignPayload, ForeignTxSignPayloadV1, Hash160, Hash256, StarknetExtractedValue, StarknetExtractor, StarknetFelt, StarknetLog, StarknetRpcRequest, - StarknetTxId, + StarknetTxId, SuiAddress, SuiEvent, SuiExtractedValue, SuiExtractor, SuiRpcRequest, SuiTxId, }; use near_primitives::hash::CryptoHash; use near_primitives::types::AccountId; @@ -52,6 +52,7 @@ use starknet_bridge_client::{StarknetBridgeClient, StarknetInitTransferEvent}; use std::collections::HashMap; use std::str::FromStr; use std::sync::OnceLock; +use sui_bridge_client::{SuiBridgeClient, SuiInitTransferEvent}; use utxo_bridge_client::{ types::{Bitcoin, PrefetchedTxData, Zcash}, UTXOBridgeClient, @@ -90,6 +91,7 @@ pub struct OmniConnector { zcash_bridge_client: Option>, starknet_bridge_client: Option, aptos_bridge_client: Option, + sui_bridge_client: Option, eth_light_client: Option, btc_light_client: Option, zcash_light_client: Option, @@ -184,6 +186,13 @@ pub enum DeployTokenArgs { near_tx_hash: CryptoHash, sender_id: Option, }, + SuiDeployToken { + event: OmniBridgeEvent, + }, + SuiDeployTokenWithTxHash { + near_tx_hash: CryptoHash, + sender_id: Option, + }, } pub enum BindTokenArgs { @@ -276,6 +285,16 @@ pub enum InitTransferArgs { native_fee: u128, message: String, }, + /// `token` is the Sui coin type, e.g. `0x2::sui::SUI` (Sui tokens are + /// types, not addresses). + SuiInitTransfer { + token: String, + amount: u128, + recipient: String, + fee: u128, + native_fee: u128, + message: String, + }, /// HyperCore -> any destination via `sendToEvmWithData`. The connector /// picks the on-chain action based on `recipient`: /// @@ -395,6 +414,13 @@ pub enum FinTransferArgs { near_tx_hash: CryptoHash, sender_id: Option, }, + SuiFinTransfer { + event: OmniBridgeEvent, + }, + SuiFinTransferWithTxHash { + near_tx_hash: CryptoHash, + sender_id: Option, + }, } pub enum BtcDepositArgs { @@ -1998,6 +2024,48 @@ impl OmniConnector { }) } + pub async fn build_sui_mpc_sign_payload( + &self, + tx_hash: String, + proof_kind: ProofKind, + ) -> Result> { + let sui_client = self.sui_bridge_client()?; + let finality = sui_client.check_mpc_finality(&tx_hash).await?; + + let log = match proof_kind { + ProofKind::InitTransfer => sui_client.get_init_transfer_log(&tx_hash).await?, + ProofKind::DeployToken => sui_client.get_deploy_token_log(&tx_hash).await?, + ProofKind::FinTransfer => sui_client.get_fin_transfer_log(&tx_hash).await?, + ProofKind::LogMetadata => sui_client.get_log_metadata_log(&tx_hash).await?, + }; + + let tx_id = sui_bridge_client::parse_transaction_digest(&tx_hash) + .map_err(BridgeSdkError::InvalidArgument)?; + + let sign_payload = ForeignTxSignPayload::V1(ForeignTxSignPayloadV1 { + request: ForeignChainRpcRequest::Sui(SuiRpcRequest { + tx_id: SuiTxId(tx_id), + finality, + extractors: vec![SuiExtractor::Event { + event_index: log.event_index, + }], + }), + values: vec![ExtractedValue::SuiExtractedValue(SuiExtractedValue::Event( + SuiEvent { + package_id: SuiAddress(log.package_id), + transaction_module: log.transaction_module, + sender: SuiAddress(log.sender), + type_tag: log.type_tag, + bcs: log.bcs, + }, + ))], + }); + + borsh::to_vec(&sign_payload).map_err(|_| { + BridgeSdkError::EthProofError("Failed to serialize MPC sign payload".to_string()) + }) + } + pub async fn near_claim_fee( &self, claim_fee_args: omni_types::locker_args::ClaimFeeArgs, @@ -2986,6 +3054,11 @@ impl OmniConnector { self.aptos_log_metadata(format!("0x{}", hex::encode(aptos_address.0))) .await } + OmniAddress::Sui(_) => Err(BridgeSdkError::InvalidArgument( + "Sui token ids are keccak hashes of the coin type and cannot be reversed; \ + use sui_log_metadata with the coin type (e.g. 0x2::sui::SUI) instead" + .to_string(), + )), OmniAddress::Btc(_) | OmniAddress::Zcash(_) => Err(BridgeSdkError::InvalidArgument( "Log metadata is not supported for this chain".to_string(), )), @@ -3045,6 +3118,10 @@ impl OmniConnector { self.build_aptos_mpc_sign_payload(tx_hash, ProofKind::LogMetadata) .await? } + ChainKind::Sui => { + self.build_sui_mpc_sign_payload(tx_hash, ProofKind::LogMetadata) + .await? + } other => { return Err(BridgeSdkError::InvalidArgument(format!( "MPC proof deploy_token is not supported for chain {other:?}" @@ -3094,6 +3171,16 @@ impl OmniConnector { .starknet_deploy_token_with_tx_hash(near_tx_hash, sender_id) .await .map(|hash| format!("{hash:#066x}")), + DeployTokenArgs::SuiDeployToken { event } => { + self.sui_deploy_token_with_event(event).await + } + DeployTokenArgs::SuiDeployTokenWithTxHash { + near_tx_hash, + sender_id, + } => { + self.sui_deploy_token_with_tx_hash(near_tx_hash, sender_id) + .await + } DeployTokenArgs::AptosDeployToken { event } => { self.aptos_deploy_token_with_event(event).await } @@ -3175,6 +3262,10 @@ impl OmniConnector { self.build_strk_mpc_sign_payload(felt, ProofKind::DeployToken) .await? } + ChainKind::Sui => { + self.build_sui_mpc_sign_payload(tx_hash, ProofKind::DeployToken) + .await? + } ChainKind::Aptos => { self.build_aptos_mpc_sign_payload(tx_hash, ProofKind::DeployToken) .await? @@ -3289,6 +3380,17 @@ impl OmniConnector { self.aptos_init_transfer(token, amount, fee, native_fee, recipient, message) .await } + InitTransferArgs::SuiInitTransfer { + token, + amount, + recipient, + fee, + native_fee, + message, + } => { + self.sui_init_transfer(token, amount, fee, native_fee, recipient, message) + .await + } InitTransferArgs::HyperCoreTransfer { token, hl_bridge_token, @@ -3422,6 +3524,10 @@ impl OmniConnector { self.build_aptos_mpc_sign_payload(tx_hash, ProofKind::InitTransfer) .await? } + ChainKind::Sui => { + self.build_sui_mpc_sign_payload(tx_hash, ProofKind::InitTransfer) + .await? + } other => { return Err(BridgeSdkError::InvalidArgument(format!( "MPC proof fin_transfer is not supported for chain {other:?}" @@ -3515,6 +3621,16 @@ impl OmniConnector { self.aptos_fin_transfer_with_tx_hash(near_tx_hash, sender_id) .await } + FinTransferArgs::SuiFinTransfer { event } => { + self.sui_fin_transfer_with_event(event).await + } + FinTransferArgs::SuiFinTransferWithTxHash { + near_tx_hash, + sender_id, + } => { + self.sui_fin_transfer_with_tx_hash(near_tx_hash, sender_id) + .await + } } } @@ -3563,6 +3679,10 @@ impl OmniConnector { self.build_aptos_mpc_sign_payload(tx_hash, ProofKind::FinTransfer) .await? } + ChainKind::Sui => { + self.build_sui_mpc_sign_payload(tx_hash, ProofKind::FinTransfer) + .await? + } other => { return Err(BridgeSdkError::InvalidArgument(format!( "MPC proof claim_fee is not supported for chain {other:?}" @@ -3611,6 +3731,7 @@ impl OmniConnector { ChainKind::Fogo => self.svm_is_transfer_finalised(ChainKind::Fogo, nonce).await, ChainKind::Strk => self.starknet_is_transfer_finalised(nonce).await, ChainKind::Aptos => self.aptos_is_transfer_finalised(nonce).await, + ChainKind::Sui => self.sui_is_transfer_finalised(nonce).await, ChainKind::Zcash | ChainKind::Btc => Err(BridgeSdkError::ConfigError( "is_transfer_finalised is not supported for UTXO chains".to_string(), )), @@ -3761,7 +3882,8 @@ impl OmniConnector { | ChainKind::Btc | ChainKind::Zcash | ChainKind::Strk - | ChainKind::Aptos => { + | ChainKind::Aptos + | ChainKind::Sui => { return Err(BridgeSdkError::ConfigError(format!( "EVM bridge client is not available for {chain_kind:?}" ))); @@ -3995,6 +4117,92 @@ impl OmniConnector { .await?) } + pub fn sui_bridge_client(&self) -> Result<&SuiBridgeClient> { + self.sui_bridge_client + .as_ref() + .ok_or(BridgeSdkError::ConfigError( + "Sui bridge client is not configured".to_string(), + )) + } + + /// `coin_type` is the Sui coin's Move type, e.g. `0x2::sui::SUI` (Sui + /// tokens are types, not addresses, so the `OmniAddress::Sui` keccak hash + /// cannot be used here). + pub async fn sui_log_metadata(&self, coin_type: String) -> Result { + Ok(self.sui_bridge_client()?.log_metadata(&coin_type).await?) + } + + pub async fn sui_deploy_token_with_event(&self, event: OmniBridgeEvent) -> Result { + Ok(self.sui_bridge_client()?.deploy_token(event).await?) + } + + pub async fn sui_deploy_token_with_tx_hash( + &self, + near_tx_hash: CryptoHash, + sender_id: Option, + ) -> Result { + let near_bridge_client = self.near_bridge_client()?; + let transfer_log = near_bridge_client + .extract_transfer_log(near_tx_hash, sender_id, "LogMetadataEvent") + .await?; + self.sui_deploy_token_with_event(serde_json::from_str(&transfer_log)?) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn sui_init_transfer( + &self, + token: String, + amount: u128, + fee: u128, + native_fee: u128, + recipient: String, + message: String, + ) -> Result { + Ok(self + .sui_bridge_client()? + .init_transfer( + token, + amount, + fee, + native_fee, + recipient, + message.into_bytes(), + ) + .await?) + } + + pub async fn sui_fin_transfer_with_event(&self, event: OmniBridgeEvent) -> Result { + Ok(self.sui_bridge_client()?.fin_transfer(event).await?) + } + + pub async fn sui_fin_transfer_with_tx_hash( + &self, + near_tx_hash: CryptoHash, + sender_id: Option, + ) -> Result { + let near_bridge_client = self.near_bridge_client()?; + let transfer_log = near_bridge_client + .extract_transfer_log(near_tx_hash, sender_id, "SignTransferEvent") + .await?; + self.sui_fin_transfer_with_event(serde_json::from_str(&transfer_log)?) + .await + } + + pub async fn sui_is_transfer_finalised(&self, nonce: u64) -> Result { + Ok(self + .sui_bridge_client()? + .is_transfer_finalised(nonce) + .await?) + } + + pub async fn sui_get_transfer_event(&self, tx_hash: &str) -> Result { + Ok(self + .sui_bridge_client()? + .get_transfer_event(tx_hash) + .await?) + } + pub fn wormhole_bridge_client(&self) -> Result<&WormholeBridgeClient> { self.wormhole_bridge_client .as_ref() @@ -4034,7 +4242,8 @@ impl OmniConnector { | ChainKind::Sol | ChainKind::Fogo | ChainKind::Strk - | ChainKind::Aptos => Err(BridgeSdkError::ConfigError( + | ChainKind::Aptos + | ChainKind::Sui => Err(BridgeSdkError::ConfigError( "UTXO bridge client is not configured".to_string(), )), } @@ -4109,6 +4318,7 @@ impl OmniConnector { self.get_storage_deposit_actions_for_aptos_tx(&tx_hash) .await } + ChainKind::Sui => self.get_storage_deposit_actions_for_sui_tx(&tx_hash).await, ChainKind::Near | ChainKind::Btc | ChainKind::Zcash => { Err(BridgeSdkError::ConfigError( "Storage deposit actions are not supported for this chain".to_string(), @@ -4324,6 +4534,61 @@ impl OmniConnector { .await } + pub async fn get_storage_deposit_actions_for_sui_tx( + &self, + tx_hash: &str, + ) -> Result> { + let transfer_event = self.sui_get_transfer_event(tx_hash).await?; + + let token_address = + OmniAddress::new_from_slice(ChainKind::Sui, &transfer_event.token_address).map_err( + |_| { + BridgeSdkError::InvalidArgument(format!( + "Failed to parse token address: 0x{}", + hex::encode(transfer_event.token_address) + )) + }, + )?; + + let mut recipient = OmniAddress::from_str(&transfer_event.recipient).map_err(|_| { + BridgeSdkError::InvalidArgument(format!( + "Failed to parse recipient: {}", + transfer_event.recipient + )) + })?; + + let mut fee_recipient = self + .near_bridge_client() + .and_then(NearBridgeClient::account_id) + .map_err(|_| { + BridgeSdkError::ConfigError("NEAR bridge client is not configured".to_string()) + })?; + + let message = String::from_utf8_lossy(&transfer_event.message); + self.apply_fast_transfer_override( + ChainKind::Sui, + &token_address, + transfer_event.origin_nonce, + transfer_event.amount, + transfer_event.fee, + transfer_event.native_fee, + &message, + &mut recipient, + &mut fee_recipient, + ) + .await?; + + self.get_storage_deposit_actions( + ChainKind::Sui, + &recipient, + &fee_recipient, + &token_address, + transfer_event.fee, + transfer_event.native_fee, + ) + .await + } + // If the transfer was already fast-finalised on NEAR, the omni-bridge contract routes // both the recipient and the fee recipient to the relayer that executed the fast transfer // (see `process_fin_transfer_to_near` in omni-bridge). Storage-deposit actions emitted by @@ -4684,4 +4949,45 @@ mod tests { let err = validate_zcash_memo_usage(ChainKind::Zcash, false, Some("memo")).unwrap_err(); assert!(format!("{err:?}").contains("memo requires a shielded Zcash recipient")); } + + /// The Sui MPC sign payload must hash exactly as the MPC nodes compute it. + /// Values and expected hash mirror near/mpc's snapshot test + /// `foreign_tx_sign_payload_v1_sui__should_have_consistent_hash`. + #[test] + fn sui_mpc_sign_payload_matches_upstream_snapshot_hash() { + let log = sui_bridge_client::SuiEventLog { + package_id: [0x11; 32], + transaction_module: "omni_bridge".to_string(), + sender: [0x22; 32], + type_tag: format!("0x{}::omni_bridge::InitTransfer", "11".repeat(32)), + bcs: vec![0xde, 0xad, 0xbe, 0xef], + event_index: 0, + }; + + // Same assembly as `build_sui_mpc_sign_payload`. + let sign_payload = ForeignTxSignPayload::V1(ForeignTxSignPayloadV1 { + request: ForeignChainRpcRequest::Sui(SuiRpcRequest { + tx_id: SuiTxId([0xdd; 32]), + finality: near_mpc_contract_interface::types::SuiFinality::Checkpointed, + extractors: vec![SuiExtractor::Event { + event_index: log.event_index, + }], + }), + values: vec![ExtractedValue::SuiExtractedValue(SuiExtractedValue::Event( + SuiEvent { + package_id: SuiAddress(log.package_id), + transaction_module: log.transaction_module, + sender: SuiAddress(log.sender), + type_tag: log.type_tag, + bcs: log.bcs, + }, + ))], + }); + + let hash = sign_payload.compute_msg_hash().unwrap(); + assert_eq!( + hex::encode(hash.0), + "feb837d1f4a762a8a4faaa5d828be88c5b3427a5cc1e553ff27271a10caf15a6" + ); + } }