瀏覽代碼

sdk/js: add Sui support

Co-authored-by: Evan Gray <battledingo@gmail.com>
Co-authored-by: Kevin Peters <kpeters@jumptrading.com>
heyitaki 2 年之前
父節點
當前提交
ed733f8e73
共有 84 個文件被更改,包括 5343 次插入743 次删除
  1. 10 15
      Tiltfile
  2. 13 0
      clients/js/.prettierrc.json
  3. 18 0
      clients/js/cmds/Yargs.ts
  4. 31 74
      clients/js/cmds/aptos.ts
  5. 4 4
      clients/js/cmds/evm.ts
  6. 11 14
      clients/js/cmds/generate.ts
  7. 2 2
      clients/js/cmds/recover.ts
  8. 8 5
      clients/js/cmds/submit.ts
  9. 85 0
      clients/js/cmds/sui/build.ts
  10. 82 0
      clients/js/cmds/sui/deploy.ts
  11. 23 0
      clients/js/cmds/sui/index.ts
  12. 368 0
      clients/js/cmds/sui/init.ts
  13. 116 0
      clients/js/cmds/sui/publish_message.ts
  14. 240 0
      clients/js/cmds/sui/setup.ts
  15. 106 0
      clients/js/cmds/sui/utils.ts
  16. 14 14
      clients/js/cmds/update.ts
  17. 58 8
      clients/js/consts.ts
  18. 8 8
      clients/js/injective.ts
  19. 4 1
      clients/js/main.ts
  20. 13 13
      clients/js/networks.ts
  21. 306 13
      clients/js/package-lock.json
  22. 3 1
      clients/js/package.json
  23. 153 0
      clients/js/sui/MoveToml.ts
  24. 129 0
      clients/js/sui/buildCoin.ts
  25. 7 0
      clients/js/sui/error.ts
  26. 7 0
      clients/js/sui/index.ts
  27. 28 0
      clients/js/sui/log.ts
  28. 248 0
      clients/js/sui/publish.ts
  29. 211 0
      clients/js/sui/submit.ts
  30. 9 0
      clients/js/sui/types.ts
  31. 377 0
      clients/js/sui/utils.ts
  32. 34 0
      clients/js/utils.ts
  33. 1 0
      devnet/eth-devnet.yaml
  34. 1 0
      devnet/eth-devnet2.yaml
  35. 23 14
      devnet/sui-devnet.yaml
  36. 1 1
      docs/devnet.md
  37. 1 1
      ethereum/scripts/deploy_test_token.js
  38. 409 458
      scripts/devnet-consts.json
  39. 23 20
      scripts/guardian-set-init.sh
  40. 1 0
      sdk/devnet_consts.go
  41. 306 10
      sdk/js/package-lock.json
  42. 1 0
      sdk/js/package.json
  43. 13 0
      sdk/js/src/bridge/parseSequenceFromLog.ts
  44. 1 1
      sdk/js/src/cosmwasm/query.testnet.test.ts
  45. 1 0
      sdk/js/src/index.ts
  46. 1 1
      sdk/js/src/nft_bridge/__tests__/aptos-integration.ts
  47. 1 1
      sdk/js/src/nft_bridge/__tests__/integration.ts
  48. 0 0
      sdk/js/src/nft_bridge/__tests__/utils/consts.ts
  49. 1 1
      sdk/js/src/nft_bridge/__tests__/utils/getSignedVaa.ts
  50. 7 0
      sdk/js/src/sui/error.ts
  51. 2 0
      sdk/js/src/sui/index.ts
  52. 84 0
      sdk/js/src/sui/publish.ts
  53. 20 0
      sdk/js/src/sui/types.ts
  54. 11 0
      sdk/js/src/sui/utils.test.ts
  55. 414 0
      sdk/js/src/sui/utils.ts
  56. 1 1
      sdk/js/src/token_bridge/__tests__/algorand-integration.ts
  57. 1 1
      sdk/js/src/token_bridge/__tests__/aptos-integration.ts
  58. 1 1
      sdk/js/src/token_bridge/__tests__/eth-integration.ts
  59. 2 2
      sdk/js/src/token_bridge/__tests__/near-integration.ts
  60. 1 1
      sdk/js/src/token_bridge/__tests__/solana-integration.ts
  61. 633 0
      sdk/js/src/token_bridge/__tests__/sui-integration.ts
  62. 2 2
      sdk/js/src/token_bridge/__tests__/terra-integration.ts
  63. 2 2
      sdk/js/src/token_bridge/__tests__/terra2-integration.ts
  64. 6 9
      sdk/js/src/token_bridge/__tests__/utils/consts.ts
  65. 30 1
      sdk/js/src/token_bridge/__tests__/utils/helpers.ts
  66. 53 5
      sdk/js/src/token_bridge/attest.ts
  67. 114 1
      sdk/js/src/token_bridge/createWrapped.ts
  68. 19 2
      sdk/js/src/token_bridge/getForeignAsset.ts
  69. 47 3
      sdk/js/src/token_bridge/getIsTransferCompleted.ts
  70. 28 0
      sdk/js/src/token_bridge/getIsWrappedAsset.ts
  71. 75 9
      sdk/js/src/token_bridge/getOriginalAsset.ts
  72. 63 2
      sdk/js/src/token_bridge/redeem.ts
  73. 98 3
      sdk/js/src/token_bridge/transfer.ts
  74. 62 0
      sdk/js/src/token_bridge/updateWrapped.ts
  75. 12 3
      sdk/js/src/utils/array.ts
  76. 11 7
      sdk/js/src/utils/consts.ts
  77. 1 1
      sdk/js/src/utils/index.ts
  78. 1 1
      sdk/js/tsconfig.json
  79. 2 2
      sui/scripts/wait_for_devnet.sh
  80. 0 3
      sui/token_bridge/Move.lock
  81. 15 0
      sui/token_bridge/Move.mainnet.toml
  82. 12 0
      sui/wormhole/Move.mainnet.toml
  83. 1 1
      sui/wormhole/sources/governance_message.move
  84. 1 0
      wormchain/contracts/tools/deploy_wormchain.ts

+ 10 - 15
Tiltfile

@@ -73,7 +73,7 @@ ci = cfg.get("ci", False)
 algorand = cfg.get("algorand", ci)
 near = cfg.get("near", ci)
 aptos = cfg.get("aptos", ci)
-sui = cfg.get("sui", False)
+sui = cfg.get("sui", ci)
 evm2 = cfg.get("evm2", ci)
 solana = cfg.get("solana", ci)
 pythnet = cfg.get("pythnet", False)
@@ -187,15 +187,11 @@ def build_node_yaml():
             if sui:
                 container["command"] += [
                     "--suiRPC",
-                    "http://sui:9002",
-# In testnet and mainnet, you will need to also specify the suiPackage argument.  In Devnet, we subscribe to
-# event traffic purely based on the account since that is the only thing that is deterministic.
-#                    "--suiPackage",
-#                    "0x.....",
-                    "--suiAccount",
-                    "0x2acab6bb0e4722e528291bc6ca4f097e18ce9331",
+                    "http://sui:9000",
+                    "--suiMoveEventType",
+                    "0x9c967677bdc22d2b7217f3e4c62cf74f0ae272cdea5743bb8f28c06d10cdde9f::publish_message::WormholeMessage",
                     "--suiWS",
-                    "sui:9001",
+                    "sui:9000",
                 ]
 
             if evm2:
@@ -428,7 +424,6 @@ if solana or pythnet:
         port_forwards = [
             port_forward(8899, name = "Solana RPC [:8899]", host = webHost),
             port_forward(8900, name = "Solana WS [:8900]", host = webHost),
-            port_forward(9000, name = "Solana PubSub [:9000]", host = webHost),
         ],
         resource_deps = ["const-gen"],
         labels = ["solana"],
@@ -716,21 +711,21 @@ if sui:
 
     docker_build(
         ref = "sui-node",
-        context = "sui",
+        target = "sui",
+        context = ".",
         dockerfile = "sui/Dockerfile",
         ignore = ["./sui/sui.log*", "sui/sui.log*", "sui.log.*"],
-        only = ["Dockerfile", "scripts"],
+        only = ["./sui", "./clients/js"],
     )
 
     k8s_resource(
         "sui",
         port_forwards = [
-            port_forward(9001, name = "WS [:9001]", host = webHost),
-            port_forward(9002, name = "RPC [:9002]", host = webHost),
+            port_forward(9000, 9000, name = "RPC [:9000]", host = webHost),
             port_forward(5003, name = "Faucet [:5003]", host = webHost),
             port_forward(9184, name = "Prometheus [:9184]", host = webHost),
         ],
-#        resource_deps = ["const-gen"],
+        resource_deps = ["const-gen"],
         labels = ["sui"],
         trigger_mode = trigger_mode,
     )

+ 13 - 0
clients/js/.prettierrc.json

@@ -0,0 +1,13 @@
+{
+  "printWidth": 80,
+  "tabWidth": 2,
+  "useTabs": false,
+  "semi": true,
+  "singleQuote": false,
+  "quoteProps": "as-needed",
+  "jsxSingleQuote": false,
+  "trailingComma": "es5",
+  "bracketSpacing": true,
+  "bracketSameLine": false,
+  "arrowParens": "always"
+}

+ 18 - 0
clients/js/cmds/Yargs.ts

@@ -0,0 +1,18 @@
+import yargs from "yargs";
+
+export class Yargs {
+  yargs: typeof yargs;
+
+  constructor(y: typeof yargs) {
+    this.yargs = y;
+  }
+
+  addCommands = (addCommandsFn: YargsAddCommandsFn) => {
+    this.yargs = addCommandsFn(this.yargs);
+    return this;
+  };
+
+  y = () => this.yargs;
+}
+
+export type YargsAddCommandsFn = (y: typeof yargs) => typeof yargs;

+ 31 - 74
clients/js/cmds/aptos.ts

@@ -1,16 +1,15 @@
-import { assertChain, CHAIN_ID_APTOS, CHAIN_ID_SOLANA, coalesceChainId, CONTRACTS } from "@certusone/wormhole-sdk/lib/cjs/utils/consts";
+import { assertChain, CHAIN_ID_APTOS, coalesceChainId, CONTRACTS } from "@certusone/wormhole-sdk/lib/cjs/utils/consts";
 import { BCS, FaucetClient } from "aptos";
 import { spawnSync } from 'child_process';
 import fs from 'fs';
 import sha3 from 'js-sha3';
 import yargs from "yargs";
 import { callEntryFunc, deriveResourceAccount, deriveWrappedAssetAddress } from "../aptos";
-import { config } from '../config';
+import { GOVERNANCE_CHAIN, GOVERNANCE_EMITTER, NAMED_ADDRESSES_OPTIONS, NETWORK_OPTIONS, RPC_OPTIONS } from "../consts";
 import { NETWORKS } from "../networks";
-import { evm_address, hex } from "../consts";
+import { assertNetwork, checkBinary, evm_address, hex } from "../utils";
 import { runCommand, validator_args } from '../start-validator';
-
-type Network = "MAINNET" | "TESTNET" | "DEVNET"
+import { config } from "../config";
 
 interface Package {
   meta_file: string,
@@ -23,38 +22,6 @@ interface PackageBCS {
   codeHash: Uint8Array
 }
 
-const network_options = {
-  alias: "n",
-  describe: "network",
-  type: "string",
-  choices: ["mainnet", "testnet", "devnet"],
-  required: true,
-} as const;
-
-const rpc_description = {
-  alias: "r",
-  describe: "Override default rpc endpoint url",
-  type: "string",
-  required: false,
-} as const;
-
-const named_addresses = {
-  describe: "Named addresses in the format addr1=0x0,addr2=0x1,...",
-  type: "string",
-  require: false
-} as const;
-
-// TODO(csongor): this could be useful elsewhere
-function assertNetwork(n: string): asserts n is Network {
-  if (
-    n !== "MAINNET" &&
-    n !== "TESTNET" &&
-    n !== "DEVNET"
-  ) {
-    throw Error(`Unknown network: ${n}`);
-  }
-}
-
 exports.command = 'aptos';
 exports.desc = 'Aptos utilities';
 exports.builder = function(y: typeof yargs) {
@@ -64,8 +31,8 @@ exports.builder = function(y: typeof yargs) {
   // gets called automatically)
     .command("init-token-bridge", "Init token bridge contract", (yargs) => {
       return yargs
-        .option("network", network_options)
-        .option("rpc", rpc_description)
+        .option("network", NETWORK_OPTIONS)
+        .option("rpc", RPC_OPTIONS)
     }, async (argv) => {
       const network = argv.network.toUpperCase();
       assertNetwork(network);
@@ -75,8 +42,8 @@ exports.builder = function(y: typeof yargs) {
     })
     .command("init-wormhole", "Init Wormhole core contract", (yargs) => {
       return yargs
-        .option("network", network_options)
-        .option("rpc", rpc_description)
+        .option("network", NETWORK_OPTIONS)
+        .option("rpc", RPC_OPTIONS)
         .option("chain-id", {
           describe: "Chain id",
           type: "number",
@@ -86,13 +53,13 @@ exports.builder = function(y: typeof yargs) {
         .option("governance-chain-id", {
           describe: "Governance chain id",
           type: "number",
-          default: CHAIN_ID_SOLANA,
+          default: GOVERNANCE_CHAIN,
           required: false
         })
         .option("governance-address", {
           describe: "Governance address",
           type: "string",
-          default: "0x0000000000000000000000000000000000000000000000000000000000000004",
+          default: GOVERNANCE_EMITTER,
           required: false
         })
         .option("guardian-address", {
@@ -129,13 +96,13 @@ exports.builder = function(y: typeof yargs) {
         .positional("package-dir", {
           type: "string"
         })
-        .option("network", network_options)
-        .option("rpc", rpc_description)
-        .option("named-addresses", named_addresses)
+        .option("network", NETWORK_OPTIONS)
+        .option("rpc", RPC_OPTIONS)
+        .option("named-addresses", NAMED_ADDRESSES_OPTIONS)
     }, async (argv) => {
       const network = argv.network.toUpperCase();
       assertNetwork(network);
-      checkAptosBinary();
+      checkBinary("aptos", "aptos");
       const p = buildPackage(argv["package-dir"], argv["named-addresses"]);
       const b = serializePackage(p);
       const rpc = argv.rpc ?? NETWORKS[network]["aptos"].rpc;
@@ -150,13 +117,13 @@ exports.builder = function(y: typeof yargs) {
         .positional("package-dir", {
           type: "string"
         })
-        .option("network", network_options)
-        .option("rpc", rpc_description)
-        .option("named-addresses", named_addresses)
+        .option("network", NETWORK_OPTIONS)
+        .option("rpc", RPC_OPTIONS)
+        .option("named-addresses", NAMED_ADDRESSES_OPTIONS)
     }, async (argv) => {
       const network = argv.network.toUpperCase();
       assertNetwork(network);
-      checkAptosBinary();
+      checkBinary("aptos", "aptos");
       const p = buildPackage(argv["package-dir"], argv["named-addresses"]);
       const b = serializePackage(p);
       const seed = Buffer.from(argv["seed"], "ascii")
@@ -185,7 +152,7 @@ exports.builder = function(y: typeof yargs) {
         .positional("message", {
           type: "string"
         })
-        .option("network", network_options)
+        .option("network", NETWORK_OPTIONS)
     }, async (argv) => {
       const network = argv.network.toUpperCase();
       assertNetwork(network);
@@ -216,7 +183,7 @@ exports.builder = function(y: typeof yargs) {
         .positional("origin-address", {
           type: "string"
         })
-        .option("network", network_options)
+        .option("network", NETWORK_OPTIONS)
     }, async (argv) => {
       const network = argv.network.toUpperCase();
       assertNetwork(network);
@@ -236,9 +203,9 @@ exports.builder = function(y: typeof yargs) {
         .positional("package-dir", {
           type: "string"
         })
-        .option("named-addresses", named_addresses)
+        .option("named-addresses", NAMED_ADDRESSES_OPTIONS)
     }, (argv) => {
-      checkAptosBinary();
+      checkBinary("aptos", "aptos");
       const p = buildPackage(argv["package-dir"], argv["named-addresses"]);
       const b = serializePackage(p);
       console.log(Buffer.from(b.codeHash).toString("hex"));
@@ -256,13 +223,13 @@ exports.builder = function(y: typeof yargs) {
           describe: "Address where the wormhole module is deployed",
           type: "string",
         })
-        .option("network", network_options)
-        .option("rpc", rpc_description)
-        .option("named-addresses", named_addresses)
+        .option("network", NETWORK_OPTIONS)
+        .option("rpc", RPC_OPTIONS)
+        .option("named-addresses", NAMED_ADDRESSES_OPTIONS)
     }, async (argv) => {
       const network = argv.network.toUpperCase();
       assertNetwork(network);
-      checkAptosBinary();
+      checkBinary("aptos", "aptos");
       const p = buildPackage(argv["package-dir"], argv["named-addresses"]);
       const b = serializePackage(p);
       const rpc = argv.rpc ?? NETWORKS[network]["aptos"].rpc;
@@ -290,12 +257,12 @@ exports.builder = function(y: typeof yargs) {
           describe: "Address where the wormhole module is deployed",
           type: "string",
         })
-        .option("network", network_options)
-        .option("rpc", rpc_description)
+        .option("network", NETWORK_OPTIONS)
+        .option("rpc", RPC_OPTIONS)
     }, async (argv) => {
       const network = argv.network.toUpperCase();
       assertNetwork(network);
-      checkAptosBinary();
+      checkBinary("aptos", "aptos");
       const rpc = argv.rpc ?? NETWORKS[network]["aptos"].rpc;
       // TODO(csongor): use deployer address from sdk (when it's there)
       const hash = await callEntryFunc(
@@ -310,7 +277,7 @@ exports.builder = function(y: typeof yargs) {
     // TODO - make faucet support testnet in additional to localnet
     .command("faucet", "Request money from the faucet for a given account", (yargs) => {
       return yargs
-        .option("rpc", rpc_description)
+        .option("rpc", RPC_OPTIONS)
         .option("faucet", {
           alias: "f",
           required: false,
@@ -357,23 +324,13 @@ exports.builder = function(y: typeof yargs) {
         .option("validator-args", validator_args)
     }, (argv) => {
         const dir = `${config.wormholeDir}/aptos`;
-        checkAptosBinary();
+        checkBinary("aptos", "aptos");
         const cmd = `cd ${dir} && aptos node run-local-testnet --with-faucet --force-restart --assume-yes`;
         runCommand(cmd, argv['validator-args']);
     })
     .strict().demandCommand();
 }
 
-export function checkAptosBinary(): void {
-  const dir = `${config.wormholeDir}/aptos`;
-  const aptos = spawnSync("aptos", ["--version"]);
-  if (aptos.status !== 0) {
-    console.error("aptos is not installed. Please install aptos and try again.");
-    console.error(`See ${dir}/README.md for instructions.`);
-    process.exit(1);
-  }
-}
-
 function buildPackage(dir: string, addrs?: string): Package {
   const named_addresses =
     addrs

+ 4 - 4
clients/js/cmds/evm.ts

@@ -1,6 +1,3 @@
-import yargs from "yargs";
-import { ethers } from "ethers";
-import { NETWORKS } from "../networks";
 import {
   assertChain,
   assertEVMChain,
@@ -9,7 +6,10 @@ import {
   isEVMChain,
   toChainName,
 } from "@certusone/wormhole-sdk/lib/cjs/utils/consts";
-import { evm_address } from "../consts";
+import { ethers } from "ethers";
+import yargs from "yargs";
+import { NETWORKS } from "../networks";
+import { evm_address } from "../utils";
 import { config } from '../config';
 import { runCommand, validator_args } from '../start-validator';
 

+ 11 - 14
clients/js/cmds/generate.ts

@@ -1,27 +1,28 @@
 import {
-  CHAINS,
   assertChain,
-  toChainId,
   ChainName,
+  CHAINS,
   isCosmWasmChain,
   isEVMChain,
+  toChainId,
 } from "@certusone/wormhole-sdk/lib/cjs/utils/consts";
+import { fromBech32, toHex } from "@cosmjs/encoding";
+import base58 from "bs58";
 import { sha3_256 } from "js-sha3";
 import yargs from "yargs";
+import { GOVERNANCE_CHAIN, GOVERNANCE_EMITTER } from "../consts";
+import { evm_address } from "../utils";
 import {
   ContractUpgrade,
+  impossible,
   Payload,
   PortalRegisterChain,
   RecoverChainId,
-  TokenBridgeAttestMeta,
-  VAA,
-  impossible,
   serialiseVAA,
   sign,
+  TokenBridgeAttestMeta,
+  VAA,
 } from "../vaa";
-import { fromBech32, toHex } from "@cosmjs/encoding";
-import base58 from "bs58";
-import { evm_address, hex } from "../consts";
 
 function makeVAA(
   emitterChain: number,
@@ -45,10 +46,6 @@ function makeVAA(
   return v;
 }
 
-const GOVERNANCE_CHAIN = 1;
-const GOVERNANCE_EMITTER =
-  "0000000000000000000000000000000000000000000000000000000000000004";
-
 exports.command = "generate";
 exports.desc = "generate VAAs (devnet and testnet only)";
 exports.builder = function (y: typeof yargs) {
@@ -286,11 +283,11 @@ function parseAddress(chain: ChainName, address: string): string {
     // TODO: is there a better native format for algorand?
     return "0x" + evm_address(address);
   } else if (chain === "near") {
-    return "0x" + hex(address).substring(2).padStart(64, "0");
+    return "0x" + evm_address(address);
   } else if (chain === "osmosis") {
     throw Error("OSMOSIS is not supported yet");
   } else if (chain === "sui") {
-    throw Error("SUI is not supported yet");
+    return "0x" + evm_address(address);
   } else if (chain === "aptos") {
     if (/^(0x)?[0-9a-fA-F]+$/.test(address)) {
       return "0x" + evm_address(address);

+ 2 - 2
clients/js/cmds/recover.ts

@@ -1,6 +1,6 @@
-import yargs from "yargs";
 import { ethers } from "ethers";
-import { hex } from "../consts";
+import yargs from "yargs";
+import { hex } from "../utils";
 
 exports.command = "recover <digest> <signature>";
 exports.desc = "Recover an address from a signature";

+ 8 - 5
clients/js/cmds/submit.ts

@@ -1,13 +1,13 @@
-import yargs from "yargs";
 import {
-  CHAINS,
   assertChain,
-  toChainName,
   ChainName,
+  CHAINS,
+  coalesceChainName,
   isEVMChain,
   isTerraChain,
-  coalesceChainName,
+  toChainName,
 } from "@certusone/wormhole-sdk/lib/cjs/utils/consts";
+import yargs from "yargs";
 import * as vaa from "../vaa";
 
 exports.command = "submit <vaa>";
@@ -136,7 +136,8 @@ exports.handler = async (argv) => {
   } else if (chain === "osmosis") {
     throw Error("OSMOSIS is not supported yet");
   } else if (chain === "sui") {
-    throw Error("SUI is not supported yet");
+    const sui = require("../sui");
+    await sui.submit(parsed_vaa.payload, buf, network, argv["rpc"]);
   } else if (chain === "aptos") {
     const aptos = require("../aptos");
     await aptos.execute_aptos(
@@ -150,6 +151,8 @@ exports.handler = async (argv) => {
     throw Error("Wormchain is not supported yet");
   } else if (chain === "btc") {
     throw Error("btc is not supported yet");
+  } else if (chain === "sei") {
+    throw Error("sei is not supported yet");
   } else {
     // If you get a type error here, hover over `chain`'s type and it tells you
     // which cases are not handled

+ 85 - 0
clients/js/cmds/sui/build.ts

@@ -0,0 +1,85 @@
+import path from "path";
+import yargs from "yargs";
+import { CONTRACTS, NETWORK_OPTIONS, RPC_OPTIONS } from "../../consts";
+import { NETWORKS } from "../../networks";
+import { buildCoin, getProvider } from "../../sui";
+import { assertNetwork, checkBinary } from "../../utils";
+import { YargsAddCommandsFn } from "../Yargs";
+
+export const addBuildCommands: YargsAddCommandsFn = (y: typeof yargs) =>
+  y.command(
+    "build-coin",
+    `Build wrapped coin and dump bytecode.
+    
+    Example:
+      worm sui build-coin -d 8 -v V__0_1_1 -n testnet -r "https://fullnode.testnet.sui.io:443"`,
+    (yargs) =>
+      yargs
+        .option("decimals", {
+          alias: "d",
+          describe: "Decimals of asset",
+          required: true,
+          type: "number",
+        })
+        // Can't be called version because of a conflict with the native version option
+        .option("version-struct", {
+          alias: "v",
+          describe: "Version control struct name (e.g. V__0_1_0)",
+          required: true,
+          type: "string",
+        })
+        .option("network", NETWORK_OPTIONS)
+        .option("package-path", {
+          alias: "p",
+          describe: "Path to coin module",
+          required: false,
+          type: "string",
+        })
+        .option("wormhole-state", {
+          alias: "w",
+          describe: "Wormhole state object ID",
+          required: false,
+          type: "string",
+        })
+        .option("token-bridge-state", {
+          alias: "t",
+          describe: "Token bridge state object ID",
+          required: false,
+          type: "string",
+        })
+        .option("rpc", RPC_OPTIONS),
+    async (argv) => {
+      checkBinary("sui", "sui");
+
+      const network = argv.network.toUpperCase();
+      assertNetwork(network);
+      const decimals = argv["decimals"];
+      const version = argv["version-struct"];
+      const packagePath =
+        argv["package-path"] ??
+        path.resolve(__dirname, "../../../../../sui/examples");
+      const coreBridgeStateObjectId =
+        argv["wormhole-state"] ?? CONTRACTS[network].sui.core;
+      const tokenBridgeStateObjectId =
+        argv["token-bridge-state"] ?? CONTRACTS[network].sui.token_bridge;
+      const provider = getProvider(
+        network,
+        argv.rpc ?? NETWORKS[network].sui.rpc
+      );
+
+      const build = await buildCoin(
+        provider,
+        network,
+        packagePath,
+        coreBridgeStateObjectId,
+        tokenBridgeStateObjectId,
+        version,
+        decimals
+      );
+      console.log(build);
+      console.log(
+        "Bytecode hex:",
+        Buffer.from(build.modules[0], "base64").toString("hex")
+      );
+    }
+  );

+ 82 - 0
clients/js/cmds/sui/deploy.ts

@@ -0,0 +1,82 @@
+import { SuiTransactionBlockResponse } from "@mysten/sui.js";
+import fs from "fs";
+import yargs from "yargs";
+import {
+  DEBUG_OPTIONS,
+  NETWORK_OPTIONS,
+  PRIVATE_KEY_OPTIONS,
+  RPC_OPTIONS,
+} from "../../consts";
+import { NETWORKS } from "../../networks";
+import {
+  getProvider,
+  getSigner,
+  logCreatedObjects,
+  logPublishedPackageId,
+  logTransactionDigest,
+  logTransactionSender,
+  publishPackage,
+} from "../../sui";
+import { Network, assertNetwork, checkBinary } from "../../utils";
+import { YargsAddCommandsFn } from "../Yargs";
+
+export const addDeployCommands: YargsAddCommandsFn = (y: typeof yargs) =>
+  y.command(
+    "deploy <package-dir>",
+    "Deploy a Sui package",
+    (yargs) => {
+      return yargs
+        .positional("package-dir", {
+          type: "string",
+        })
+        .option("network", NETWORK_OPTIONS)
+        .option("debug", DEBUG_OPTIONS)
+        .option("private-key", PRIVATE_KEY_OPTIONS)
+        .option("rpc", RPC_OPTIONS);
+    },
+    async (argv) => {
+      checkBinary("sui", "sui");
+
+      const packageDir = argv["package-dir"];
+      const network = argv.network.toUpperCase();
+      assertNetwork(network);
+      const debug = argv.debug ?? false;
+      const privateKey = argv["private-key"];
+      const rpc = argv.rpc;
+
+      const res = await deploy(network, packageDir, rpc, privateKey);
+
+      // Dump deployment info to console
+      logTransactionDigest(res);
+      logPublishedPackageId(res);
+      if (debug) {
+        logTransactionSender(res);
+        logCreatedObjects(res);
+      }
+    }
+  );
+
+export const deploy = async (
+  network: Network,
+  packageDir: string,
+  rpc?: string,
+  privateKey?: string
+): Promise<SuiTransactionBlockResponse> => {
+  rpc = rpc ?? NETWORKS[network].sui.rpc;
+  const provider = getProvider(network, rpc);
+  const signer = getSigner(provider, network, privateKey);
+
+  // Allow absolute paths, otherwise assume relative to directory `worm` command is run from
+  const dir = packageDir.startsWith("/")
+    ? packageDir
+    : `${process.cwd()}/${packageDir}`;
+  const packagePath = dir.endsWith("/") ? dir.slice(0, -1) : dir;
+
+  if (!fs.existsSync(packagePath)) {
+    throw new Error(
+      `Package directory ${packagePath} does not exist. Make sure to deploy from the correct directory or provide an absolute path.`
+    );
+  }
+
+  return publishPackage(signer, network, packagePath);
+};

+ 23 - 0
clients/js/cmds/sui/index.ts

@@ -0,0 +1,23 @@
+import yargs from "yargs";
+import { Yargs } from "../Yargs";
+import { addBuildCommands } from "./build";
+import { addDeployCommands } from "./deploy";
+import { addInitCommands } from "./init";
+import { addPublishMessageCommands } from "./publish_message";
+import { addSetupCommands } from "./setup";
+import { addUtilsCommands } from "./utils";
+
+exports.command = "sui";
+exports.desc = "Sui utilities";
+exports.builder = function (y: typeof yargs) {
+  return new Yargs(y)
+    .addCommands(addBuildCommands)
+    .addCommands(addDeployCommands)
+    .addCommands(addInitCommands)
+    .addCommands(addPublishMessageCommands)
+    .addCommands(addSetupCommands)
+    .addCommands(addUtilsCommands)
+    .y()
+    .strict()
+    .demandCommand();
+};

+ 368 - 0
clients/js/cmds/sui/init.ts

@@ -0,0 +1,368 @@
+import { SuiTransactionBlockResponse, TransactionBlock } from "@mysten/sui.js";
+import yargs from "yargs";
+import {
+  DEBUG_OPTIONS,
+  GOVERNANCE_CHAIN,
+  GOVERNANCE_EMITTER,
+  NETWORK_OPTIONS,
+  PRIVATE_KEY_OPTIONS,
+  RPC_OPTIONS,
+} from "../../consts";
+import { NETWORKS } from "../../networks";
+import {
+  executeTransactionBlock,
+  getCreatedObjects,
+  getOwnedObjectId,
+  getPackageId,
+  getProvider,
+  getSigner,
+  getUpgradeCapObjectId,
+  isSameType,
+  logTransactionDigest,
+  logTransactionSender,
+} from "../../sui";
+import { Network, assertNetwork } from "../../utils";
+import { YargsAddCommandsFn } from "../Yargs";
+
+export const addInitCommands: YargsAddCommandsFn = (y: typeof yargs) =>
+  y
+    .command(
+      "init-example-message-app",
+      "Initialize example core message app",
+      (yargs) => {
+        return yargs
+          .option("network", NETWORK_OPTIONS)
+          .option("package-id", {
+            alias: "p",
+            describe: "Example app package ID",
+            required: true,
+            type: "string",
+          })
+          .option("wormhole-state", {
+            alias: "w",
+            describe: "Wormhole state object ID",
+            required: true,
+            type: "string",
+          })
+          .option("private-key", PRIVATE_KEY_OPTIONS)
+          .option("rpc", RPC_OPTIONS);
+      },
+      async (argv) => {
+        const network = argv.network.toUpperCase();
+        assertNetwork(network);
+        const packageId = argv["package-id"];
+        const wormholeStateObjectId = argv["wormhole-state"];
+        const privateKey = argv["private-key"];
+        const rpc = argv.rpc;
+
+        const res = await initExampleApp(
+          network,
+          packageId,
+          wormholeStateObjectId,
+          rpc,
+          privateKey
+        );
+
+        logTransactionDigest(res);
+        logTransactionSender(res);
+        console.log(
+          "Example app state object ID",
+          getCreatedObjects(res).find((e) =>
+            isSameType(e.type, `${packageId}::sender::State`)
+          ).objectId
+        );
+      }
+    )
+    .command(
+      "init-token-bridge",
+      "Initialize token bridge contract",
+      (yargs) => {
+        return yargs
+          .option("network", NETWORK_OPTIONS)
+          .option("package-id", {
+            alias: "p",
+            describe: "Token bridge package ID",
+            required: true,
+            type: "string",
+          })
+          .option("wormhole-state", {
+            alias: "w",
+            describe: "Wormhole state object ID",
+            required: true,
+            type: "string",
+          })
+          .option("governance-chain-id", {
+            alias: "c",
+            describe: "Governance chain ID",
+            default: GOVERNANCE_CHAIN,
+            type: "number",
+            required: false,
+          })
+          .option("governance-address", {
+            alias: "a",
+            describe: "Governance contract address",
+            type: "string",
+            default: GOVERNANCE_EMITTER,
+            required: false,
+          })
+          .option("private-key", PRIVATE_KEY_OPTIONS)
+          .option("rpc", RPC_OPTIONS);
+      },
+      async (argv) => {
+        const network = argv.network.toUpperCase();
+        assertNetwork(network);
+        const packageId = argv["package-id"];
+        const wormholeStateObjectId = argv["wormhole-state"];
+        const governanceChainId = argv["governance-chain-id"];
+        const governanceContract = argv["governance-address"];
+        const privateKey = argv["private-key"];
+        const rpc = argv.rpc ?? NETWORKS[network].sui.rpc;
+
+        const res = await initTokenBridge(
+          network,
+          packageId,
+          wormholeStateObjectId,
+          governanceChainId,
+          governanceContract,
+          rpc,
+          privateKey
+        );
+
+        logTransactionDigest(res);
+        logTransactionSender(res);
+        console.log(
+          "Token bridge state object ID",
+          getCreatedObjects(res).find((e) =>
+            isSameType(e.type, `${packageId}::state::State`)
+          ).objectId
+        );
+      }
+    )
+    .command(
+      "init-wormhole",
+      "Initialize wormhole core contract",
+      (yargs) => {
+        return yargs
+          .option("network", NETWORK_OPTIONS)
+          .option("package-id", {
+            alias: "p",
+            describe: "Core bridge package ID",
+            required: true,
+            type: "string",
+          })
+          .option("initial-guardian", {
+            alias: "i",
+            required: true,
+            describe: "Initial guardian public keys",
+            type: "string",
+          })
+          .option("debug", DEBUG_OPTIONS)
+          .option("governance-chain-id", {
+            alias: "c",
+            describe: "Governance chain ID",
+            default: GOVERNANCE_CHAIN,
+            type: "number",
+            required: false,
+          })
+          .option("guardian-set-index", {
+            alias: "s",
+            describe: "Governance set index",
+            default: 0,
+            type: "number",
+            required: false,
+          })
+          .option("governance-address", {
+            alias: "a",
+            describe: "Governance contract address",
+            type: "string",
+            default: GOVERNANCE_EMITTER,
+            required: false,
+          })
+          .option("private-key", PRIVATE_KEY_OPTIONS)
+          .option("rpc", RPC_OPTIONS);
+      },
+      async (argv) => {
+        const network = argv.network.toUpperCase();
+        assertNetwork(network);
+        const packageId = argv["package-id"];
+        const initialGuardian = argv["initial-guardian"];
+        const debug = argv.debug ?? false;
+        const governanceChainId = argv["governance-chain-id"];
+        const guardianSetIndex = argv["guardian-set-index"];
+        const governanceContract = argv["governance-address"];
+        const privateKey = argv["private-key"];
+        const rpc = argv.rpc;
+
+        const res = await initWormhole(
+          network,
+          packageId,
+          initialGuardian,
+          governanceChainId,
+          guardianSetIndex,
+          governanceContract,
+          rpc,
+          privateKey
+        );
+
+        logTransactionDigest(res);
+        console.log(
+          "Wormhole state object ID",
+          getCreatedObjects(res).find((e) =>
+            isSameType(e.type, `${packageId}::state::State`)
+          ).objectId
+        );
+        if (debug) {
+          logTransactionSender(res);
+        }
+      }
+    );
+
+export const initExampleApp = async (
+  network: Network,
+  packageId: string,
+  wormholeStateObjectId: string,
+  rpc?: string,
+  privateKey?: string
+): Promise<SuiTransactionBlockResponse> => {
+  rpc = rpc ?? NETWORKS[network].sui.rpc;
+  const provider = getProvider(network, rpc);
+  const signer = getSigner(provider, network, privateKey);
+
+  const transactionBlock = new TransactionBlock();
+  if (network === "DEVNET") {
+    // Avoid Error checking transaction input objects: GasBudgetTooHigh { gas_budget: 50000000000, max_budget: 10000000000 }
+    transactionBlock.setGasBudget(10000000000);
+  }
+  transactionBlock.moveCall({
+    target: `${packageId}::sender::init_with_params`,
+    arguments: [transactionBlock.object(wormholeStateObjectId)],
+  });
+  return executeTransactionBlock(signer, transactionBlock);
+};
+
+export const initTokenBridge = async (
+  network: Network,
+  tokenBridgePackageId: string,
+  coreBridgeStateObjectId: string,
+  governanceChainId: number,
+  governanceContract: string,
+  rpc?: string,
+  privateKey?: string
+): Promise<SuiTransactionBlockResponse> => {
+  rpc = rpc ?? NETWORKS[network].sui.rpc;
+  const provider = getProvider(network, rpc);
+  const signer = getSigner(provider, network, privateKey);
+  const owner = await signer.getAddress();
+
+  const deployerCapObjectId = await getOwnedObjectId(
+    provider,
+    owner,
+    tokenBridgePackageId,
+    "setup",
+    "DeployerCap"
+  );
+  if (!deployerCapObjectId) {
+    throw new Error(
+      `Token bridge cannot be initialized because deployer capability cannot be found under ${owner}. Is the package published?`
+    );
+  }
+
+  const upgradeCapObjectId = await getUpgradeCapObjectId(
+    provider,
+    owner,
+    tokenBridgePackageId
+  );
+  if (!upgradeCapObjectId) {
+    throw new Error(
+      `Token bridge cannot be initialized because upgrade capability cannot be found under ${owner}. Is the package published?`
+    );
+  }
+
+  const wormholePackageId = await getPackageId(
+    provider,
+    coreBridgeStateObjectId
+  );
+
+  const transactionBlock = new TransactionBlock();
+  if (network === "DEVNET") {
+    // Avoid Error checking transaction input objects: GasBudgetTooHigh { gas_budget: 50000000000, max_budget: 10000000000 }
+    transactionBlock.setGasBudget(10000000000);
+  }
+  const [emitterCap] = transactionBlock.moveCall({
+    target: `${wormholePackageId}::emitter::new`,
+    arguments: [transactionBlock.object(coreBridgeStateObjectId)],
+  });
+  transactionBlock.moveCall({
+    target: `${tokenBridgePackageId}::setup::complete`,
+    arguments: [
+      transactionBlock.object(deployerCapObjectId),
+      transactionBlock.object(upgradeCapObjectId),
+      emitterCap,
+      transactionBlock.pure(governanceChainId),
+      transactionBlock.pure([...Buffer.from(governanceContract, "hex")]),
+    ],
+  });
+  return executeTransactionBlock(signer, transactionBlock);
+};
+
+export const initWormhole = async (
+  network: Network,
+  coreBridgePackageId: string,
+  initialGuardians: string,
+  governanceChainId: number,
+  guardianSetIndex: number,
+  governanceContract: string,
+  rpc?: string,
+  privateKey?: string
+): Promise<SuiTransactionBlockResponse> => {
+  rpc = rpc ?? NETWORKS[network].sui.rpc;
+  const provider = getProvider(network, rpc);
+  const signer = getSigner(provider, network, privateKey);
+  const owner = await signer.getAddress();
+
+  const deployerCapObjectId = await getOwnedObjectId(
+    provider,
+    owner,
+    coreBridgePackageId,
+    "setup",
+    "DeployerCap"
+  );
+  if (!deployerCapObjectId) {
+    throw new Error(
+      `Wormhole cannot be initialized because deployer capability cannot be found under ${owner}. Is the package published?`
+    );
+  }
+
+  const upgradeCapObjectId = await getUpgradeCapObjectId(
+    provider,
+    owner,
+    coreBridgePackageId
+  );
+  if (!upgradeCapObjectId) {
+    throw new Error(
+      `Wormhole cannot be initialized because upgrade capability cannot be found under ${owner}. Is the package published?`
+    );
+  }
+
+  const transactionBlock = new TransactionBlock();
+  if (network === "DEVNET") {
+    // Avoid Error checking transaction input objects: GasBudgetTooHigh { gas_budget: 50000000000, max_budget: 10000000000 }
+    transactionBlock.setGasBudget(10000000000);
+  }
+  transactionBlock.moveCall({
+    target: `${coreBridgePackageId}::setup::complete`,
+    arguments: [
+      transactionBlock.object(deployerCapObjectId),
+      transactionBlock.object(upgradeCapObjectId),
+      transactionBlock.pure(governanceChainId),
+      transactionBlock.pure([...Buffer.from(governanceContract, "hex")]),
+      transactionBlock.pure(guardianSetIndex),
+      transactionBlock.pure(
+        initialGuardians.split(",").map((g) => [...Buffer.from(g, "hex")])
+      ),
+      transactionBlock.pure(24 * 60 * 60), // Guardian set TTL in seconds
+      transactionBlock.pure("0"), // Message fee
+    ],
+  });
+  return executeTransactionBlock(signer, transactionBlock);
+};

+ 116 - 0
clients/js/cmds/sui/publish_message.ts

@@ -0,0 +1,116 @@
+import {
+  normalizeSuiAddress,
+  SUI_CLOCK_OBJECT_ID,
+  TransactionBlock,
+} from "@mysten/sui.js";
+import yargs from "yargs";
+import { NETWORK_OPTIONS, RPC_OPTIONS } from "../../consts";
+import { NETWORKS } from "../../networks";
+import {
+  executeTransactionBlock,
+  getProvider,
+  getSigner,
+  logTransactionDigest,
+  logTransactionSender,
+} from "../../sui";
+import { assertNetwork } from "../../utils";
+import { YargsAddCommandsFn } from "../Yargs";
+
+export const addPublishMessageCommands: YargsAddCommandsFn = (
+  y: typeof yargs
+) =>
+  y.command(
+    "publish-example-message",
+    "Publish message from example app via core bridge",
+    (yargs) => {
+      return yargs
+        .option("network", NETWORK_OPTIONS)
+        .option("package-id", {
+          alias: "p",
+          describe: "Package ID/module address",
+          required: true,
+          type: "string",
+        })
+        .option("state", {
+          alias: "s",
+          describe: "Core messages app state object ID",
+          required: true,
+          type: "string",
+        })
+        .option("wormhole-state", {
+          alias: "w",
+          describe: "Wormhole state object ID",
+          required: true,
+          type: "string",
+        })
+        .option("message", {
+          alias: "m",
+          describe: "Message payload",
+          required: true,
+          type: "string",
+        })
+        .option("private-key", {
+          alias: "k",
+          describe: "Custom private key to sign txs",
+          required: false,
+          type: "string",
+        })
+        .option("rpc", RPC_OPTIONS);
+    },
+    async (argv) => {
+      const network = argv.network.toUpperCase();
+      assertNetwork(network);
+      const packageId = argv["package-id"];
+      const stateObjectId = argv["state"];
+      const wormholeStateObjectId = argv["wormhole-state"];
+      const message = argv["message"];
+      const privateKey = argv["private-key"];
+      const rpc = argv.rpc ?? NETWORKS[network].sui.rpc;
+
+      const provider = getProvider(network, rpc);
+      const signer = getSigner(provider, network, privateKey);
+
+      // Publish message
+      const transactionBlock = new TransactionBlock();
+      if (network === "DEVNET") {
+        // Avoid Error checking transaction input objects: GasBudgetTooHigh { gas_budget: 50000000000, max_budget: 10000000000 }
+        transactionBlock.setGasBudget(10000000000);
+      }
+      transactionBlock.moveCall({
+        target: `${packageId}::sender::send_message_entry`,
+        arguments: [
+          transactionBlock.object(stateObjectId),
+          transactionBlock.object(wormholeStateObjectId),
+          transactionBlock.pure(message),
+          transactionBlock.object(SUI_CLOCK_OBJECT_ID),
+        ],
+      });
+      const res = await executeTransactionBlock(signer, transactionBlock);
+
+      // Hacky way to grab event since we don't require package ID of the
+      // core bridge as input. Doesn't really matter since this is a test
+      // command.
+      const event = res.events.find(
+        (e) =>
+          normalizeSuiAddress(e.packageId) === normalizeSuiAddress(packageId) &&
+          e.type.includes("publish_message::WormholeMessage")
+      );
+      if (!event) {
+        throw new Error(
+          "Couldn't find publish event. Events: " +
+            JSON.stringify(res.events, null, 2)
+        );
+      }
+
+      logTransactionDigest(res);
+      logTransactionSender(res);
+      console.log("Publish message succeeded:", {
+        sender: event.sender,
+        type: event.type,
+        payload: Buffer.from(event.parsedJson.payload).toString(),
+        emitter: Buffer.from(event.parsedJson.sender).toString("hex"),
+        sequence: event.parsedJson.sequence,
+        nonce: event.parsedJson.nonce,
+      });
+    }
+  );

+ 240 - 0
clients/js/cmds/sui/setup.ts

@@ -0,0 +1,240 @@
+import {
+  ChainId,
+  coalesceChainName,
+  parseTokenBridgeRegisterChainVaa,
+} from "@certusone/wormhole-sdk";
+import {
+  JsonRpcProvider,
+  TransactionBlock,
+  getObjectFields,
+  getTransactionDigest,
+} from "@mysten/sui.js";
+import dotenv from "dotenv";
+import fs from "fs";
+import yargs from "yargs";
+import {
+  GOVERNANCE_CHAIN,
+  GOVERNANCE_EMITTER,
+  INITIAL_GUARDIAN_DEVNET,
+  RPC_OPTIONS,
+} from "../../consts";
+import { NETWORKS } from "../../networks";
+import {
+  assertSuccess,
+  executeTransactionBlock,
+  getCreatedObjects,
+  getProvider,
+  getPublishedPackageId,
+  getSigner,
+  isSameType,
+  logPublishedPackageId,
+  logTransactionDigest,
+  registerChain,
+} from "../../sui";
+import { YargsAddCommandsFn } from "../Yargs";
+import { deploy } from "./deploy";
+import { initExampleApp, initTokenBridge, initWormhole } from "./init";
+
+export const addSetupCommands: YargsAddCommandsFn = (y: typeof yargs) =>
+  y.command(
+    "setup-devnet",
+    "Setup devnet by deploying and initializing core and token bridges and submitting chain registrations.",
+    (yargs) => {
+      return yargs
+        .option("private-key", {
+          alias: "k",
+          describe: "Custom private key to sign txs",
+          required: false,
+          type: "string",
+        })
+        .option("rpc", RPC_OPTIONS);
+    },
+    async (argv) => {
+      const network = "DEVNET";
+      const privateKey = argv["private-key"];
+      const rpc = argv.rpc ?? NETWORKS[network].sui.rpc;
+
+      // Deploy core bridge
+      console.log("[1/4] Deploying core bridge...");
+      const coreBridgeDeployRes = await deploy(
+        network,
+        "wormhole",
+        rpc,
+        privateKey
+      );
+      assertSuccess(coreBridgeDeployRes, "Core bridge deployment failed.");
+      logTransactionDigest(coreBridgeDeployRes);
+      logPublishedPackageId(coreBridgeDeployRes);
+
+      // Init core bridge
+      console.log("\n[2/4] Initializing core bridge...");
+      const coreBridgePackageId = getPublishedPackageId(coreBridgeDeployRes);
+      const coreBridgeInitRes = await initWormhole(
+        network,
+        coreBridgePackageId,
+        INITIAL_GUARDIAN_DEVNET,
+        GOVERNANCE_CHAIN,
+        0,
+        GOVERNANCE_EMITTER,
+        rpc,
+        privateKey
+      );
+      const coreBridgeStateObjectId = getCreatedObjects(coreBridgeInitRes).find(
+        (e) => isSameType(e.type, `${coreBridgePackageId}::state::State`)
+      ).objectId;
+      assertSuccess(coreBridgeInitRes, "Core bridge initialization failed.");
+      logTransactionDigest(coreBridgeInitRes);
+      console.log("Core bridge state object ID", coreBridgeStateObjectId);
+
+      // Deploy token bridge
+      console.log("\n[3/4] Deploying token bridge...");
+      const tokenBridgeDeployRes = await deploy(
+        network,
+        "token_bridge",
+        rpc,
+        privateKey
+      );
+      assertSuccess(tokenBridgeDeployRes, "Token bridge deployment failed.");
+      logTransactionDigest(tokenBridgeDeployRes);
+      logPublishedPackageId(tokenBridgeDeployRes);
+
+      // Init token bridge
+      console.log("\n[4/4] Initializing token bridge...");
+      const tokenBridgePackageId = getPublishedPackageId(tokenBridgeDeployRes);
+      const tokenBridgeInitRes = await initTokenBridge(
+        network,
+        tokenBridgePackageId,
+        coreBridgeStateObjectId,
+        GOVERNANCE_CHAIN,
+        GOVERNANCE_EMITTER,
+        rpc,
+        privateKey
+      );
+      const tokenBridgeStateObjectId = getCreatedObjects(
+        tokenBridgeInitRes
+      ).find((e) =>
+        isSameType(e.type, `${tokenBridgePackageId}::state::State`)
+      ).objectId;
+      assertSuccess(tokenBridgeInitRes, "Token bridge initialization failed.");
+      logTransactionDigest(tokenBridgeInitRes);
+      console.log("Token bridge state object ID", tokenBridgeStateObjectId);
+
+      // Deploy example app
+      console.log("\n[+1/3] Deploying example app...");
+      const exampleAppDeployRes = await deploy(
+        network,
+        "examples/core_messages",
+        rpc,
+        privateKey
+      );
+      logTransactionDigest(tokenBridgeDeployRes);
+      logPublishedPackageId(tokenBridgeDeployRes);
+
+      // Init example app
+      console.log("\n[+2/3] Initializing example app...");
+      const exampleAppPackageId = getPublishedPackageId(exampleAppDeployRes);
+      const exampleAppInitRes = await initExampleApp(
+        network,
+        exampleAppPackageId,
+        coreBridgeStateObjectId,
+        rpc,
+        privateKey
+      );
+      const exampleAppStateObjectId = getCreatedObjects(exampleAppInitRes).find(
+        (e) => isSameType(e.type, `${exampleAppPackageId}::sender::State`)
+      ).objectId;
+      logTransactionDigest(exampleAppInitRes);
+      console.log("Example app state object ID", exampleAppStateObjectId);
+
+      // Deploy example coins
+      console.log("\n[+3/3] Deploying example coins...");
+      const coinsDeployRes = await deploy(
+        network,
+        "examples/coins",
+        rpc,
+        privateKey
+      );
+      logTransactionDigest(coinsDeployRes);
+      logPublishedPackageId(coinsDeployRes);
+
+      // Print publish message command for convenience
+      let publishCommand = `\nPublish message command: worm sui publish-example-message -n devnet -p "${exampleAppPackageId}" -s "${exampleAppStateObjectId}" -w "${coreBridgeStateObjectId}" -m "hello"`;
+      if (argv.rpc) publishCommand += ` -r "${argv.rpc}"`;
+      if (privateKey) publishCommand += ` -k "${privateKey}"`;
+      console.log(publishCommand);
+
+      // Dump summary
+      const provider = getProvider(network, rpc);
+      const emitterCapObjectId = await getEmitterCapObjectId(
+        provider,
+        tokenBridgeStateObjectId
+      );
+      console.log("\nSummary:");
+      console.log("  Core bridge package ID", coreBridgePackageId);
+      console.log("  Core bridge state object ID", coreBridgeStateObjectId);
+      console.log("  Token bridge package ID", tokenBridgePackageId);
+      console.log("  Token bridge state object ID", tokenBridgeStateObjectId);
+      console.log("  Token bridge emitter cap ID", emitterCapObjectId);
+
+      // Chain registrations
+      console.log("\nChain registrations:");
+
+      const envPath = `${process.cwd()}/.env`;
+      if (!fs.existsSync(envPath)) {
+        throw new Error(`Couldn't find .env file at ${envPath}.`);
+      }
+
+      dotenv.config({ path: envPath });
+      const signer = getSigner(provider, network, privateKey);
+      const tx = new TransactionBlock();
+      tx.setGasBudget(10000000000);
+      const registrations = [];
+      for (const key in process.env) {
+        if (/^REGISTER_(.+)_TOKEN_BRIDGE_VAA$/.test(key)) {
+          // Get VAA info
+          const vaa = Buffer.from(String(process.env[key]), "hex");
+          const { foreignChain, module } =
+            parseTokenBridgeRegisterChainVaa(vaa);
+          const chain = coalesceChainName(foreignChain as ChainId);
+          registrations.push({ chain, module });
+
+          // Register
+          await registerChain(
+            provider,
+            network,
+            vaa,
+            coreBridgeStateObjectId,
+            tokenBridgeStateObjectId,
+            tx
+          );
+        }
+      }
+
+      const registerRes = await executeTransactionBlock(signer, tx);
+      assertSuccess(registerRes, "Chain registrations failed.");
+
+      // Log registered bridges
+      for (const registration of registrations) {
+        console.log(`  ${registration.chain} ${registration.module}... done`);
+      }
+
+      console.log("Transaction digest:", getTransactionDigest(registerRes));
+
+      // Done!
+      console.log("\nDone!");
+    }
+  );
+
+const getEmitterCapObjectId = async (
+  provider: JsonRpcProvider,
+  tokenBridgeStateObjectId: string
+): Promise<string> => {
+  return getObjectFields(
+    await provider.getObject({
+      id: tokenBridgeStateObjectId,
+      options: {
+        showContent: true,
+      },
+    })
+  ).emitter_cap.fields.id.id;
+};

+ 106 - 0
clients/js/cmds/sui/utils.ts

@@ -0,0 +1,106 @@
+import yargs from "yargs";
+import { NETWORK_OPTIONS, RPC_OPTIONS } from "../../consts";
+import { NETWORKS } from "../../networks";
+import { getPackageId, getProvider } from "../../sui";
+import { assertNetwork } from "../../utils";
+import { YargsAddCommandsFn } from "../Yargs";
+
+export const addUtilsCommands: YargsAddCommandsFn = (y: typeof yargs) =>
+  y
+    .command(
+      "objects <owner>",
+      "Get owned objects by owner",
+      (yargs) =>
+        yargs
+          .positional("owner", {
+            describe: "Owner address",
+            type: "string",
+          })
+          .option("network", NETWORK_OPTIONS)
+          .option("rpc", RPC_OPTIONS),
+      async (argv) => {
+        const network = argv.network.toUpperCase();
+        assertNetwork(network);
+        const rpc = argv.rpc ?? NETWORKS[network].sui.rpc;
+        const owner = argv.owner;
+
+        const provider = getProvider(network, rpc);
+        const objects = [];
+
+        let cursor = undefined;
+        while (true) {
+          const res = await provider.getOwnedObjects({ owner, cursor });
+          objects.push(...res.data);
+          if (res.hasNextPage) {
+            cursor = res.nextCursor;
+          } else {
+            break;
+          }
+        }
+
+        console.log("Network", network);
+        console.log("Owner", owner);
+        console.log("Objects", JSON.stringify(objects, null, 2));
+      }
+    )
+    .command(
+      "package-id <state-object-id>",
+      "Get package ID from State object ID",
+      (yargs) =>
+        yargs
+          .positional("state-object-id", {
+            describe: "Object ID of State object",
+            type: "string",
+          })
+          .option("network", NETWORK_OPTIONS)
+          .option("rpc", RPC_OPTIONS),
+      async (argv) => {
+        const network = argv.network.toUpperCase();
+        assertNetwork(network);
+        const rpc = argv.rpc ?? NETWORKS[network].sui.rpc;
+        const provider = getProvider(network, rpc);
+        console.log(await getPackageId(provider, argv["state-object-id"]));
+      }
+    )
+    // This command is useful for debugging, especially when the Sui explorer
+    // goes down :)
+    .command(
+      "tx <transaction-digest>",
+      "Get transaction details",
+      (yargs) =>
+        yargs
+          .positional("transaction-digest", {
+            describe: "Digest of transaction to fetch",
+            type: "string",
+          })
+          .option("network", {
+            alias: "n",
+            describe: "Network",
+            type: "string",
+            choices: ["mainnet", "testnet", "devnet"],
+            default: "devnet",
+            required: false,
+          })
+          .option("rpc", RPC_OPTIONS),
+      async (argv) => {
+        const network = argv.network.toUpperCase();
+        assertNetwork(network);
+        const rpc = argv.rpc ?? NETWORKS[network].sui.rpc;
+        const provider = getProvider(network, rpc);
+        console.log(
+          JSON.stringify(
+            await provider.getTransactionBlock({
+              digest: argv["transaction-digest"],
+              options: {
+                showInput: true,
+                showEffects: true,
+                showEvents: true,
+                showObjectChanges: true,
+              },
+            }),
+            null,
+            2
+          )
+        );
+      }
+    );

+ 14 - 14
clients/js/cmds/update.ts

@@ -1,20 +1,20 @@
-import { config } from '../config';
-import { spawnSync } from 'child_process';
+import { spawnSync } from "child_process";
+import { config } from "../config";
 
 let dir = `${config.wormholeDir}/clients/js`;
 
-exports.command = 'update';
-exports.desc = 'Update this tool by rebuilding it';
-exports.handler = function(_argv: any) {
-    if (isOutdated()) {
-        console.log(`Building in ${dir}...`);
-        spawnSync(`make build -C ${dir}`, { shell: true, stdio: 'inherit' });
-    } else {
-        console.log("'worm' is up to date");
-    }
-}
+exports.command = "update";
+exports.desc = "Update this tool by rebuilding it";
+exports.handler = function (_argv: any) {
+  if (isOutdated()) {
+    console.log(`Building in ${dir}...`);
+    spawnSync(`make build -C ${dir}`, { shell: true, stdio: "inherit" });
+  } else {
+    console.log("'worm' is up to date");
+  }
+};
 
 export function isOutdated(): boolean {
-    const result = spawnSync(`make build -C ${dir} --question`, { shell: true });
-    return result.status !== 0;
+  const result = spawnSync(`make build -C ${dir} --question`, { shell: true });
+  return result.status !== 0;
 }

+ 58 - 8
clients/js/consts.ts

@@ -1,8 +1,14 @@
-import { CONTRACTS as SDK_CONTRACTS } from "@certusone/wormhole-sdk/lib/cjs/utils/consts";
-import { ethers } from "ethers";
+import {
+  CHAIN_ID_SOLANA,
+  CONTRACTS as SDK_CONTRACTS,
+} from "@certusone/wormhole-sdk/lib/cjs/utils/consts";
 
 const OVERRIDES = {
   MAINNET: {
+    sui: {
+      core: undefined,
+      token_bridge: undefined,
+    },
     aptos: {
       token_bridge:
         "0x576410486a2da45eee6c949c995670112ddf2fbeedab20350d506328eefc9d4f",
@@ -12,6 +18,11 @@ const OVERRIDES = {
     },
   },
   TESTNET: {
+    sui: {
+      core: "0x69ae41bdef4770895eb4e7aaefee5e4673acc08f6917b4856cf55549c4573ca8",
+      token_bridge:
+        "0x32422cb2f929b6a4e3f81b4791ea11ac2af896b310f3d9442aa1fe924ce0bab4",
+    },
     aptos: {
       token_bridge:
         "0x576410486a2da45eee6c949c995670112ddf2fbeedab20350d506328eefc9d4f",
@@ -21,6 +32,11 @@ const OVERRIDES = {
     },
   },
   DEVNET: {
+    sui: {
+      core: "0x04ca9f568b19c80b4fb429c26f7cc57b1ca97e7519ccd68af436dd2706808e01", // wormhole module State object ID
+      token_bridge:
+        "0x844b3ce3f9b2cd82cb8ad1a1962593f6a340c7bad0b4867b82a49463554883dd", // token_bridge module State object ID
+    },
     aptos: {
       token_bridge:
         "0x84a5f374d29fc77e370014dce4fd6a55b58ad608de8074b0be5571701724da31",
@@ -31,16 +47,50 @@ const OVERRIDES = {
   },
 };
 
+// TODO(aki): move this to SDK at some point
 export const CONTRACTS = {
   MAINNET: { ...SDK_CONTRACTS.MAINNET, ...OVERRIDES.MAINNET },
   TESTNET: { ...SDK_CONTRACTS.TESTNET, ...OVERRIDES.TESTNET },
   DEVNET: { ...SDK_CONTRACTS.DEVNET, ...OVERRIDES.DEVNET },
 };
 
-export function evm_address(x: string): string {
-  return hex(x).substring(2).padStart(64, "0");
-}
+export const DEBUG_OPTIONS = {
+  alias: "d",
+  describe: "Log debug info",
+  type: "boolean",
+  required: false,
+} as const;
+
+export const NAMED_ADDRESSES_OPTIONS = {
+  describe: "Named addresses in the format addr1=0x0,addr2=0x1,...",
+  type: "string",
+  require: false,
+} as const;
+
+export const NETWORK_OPTIONS = {
+  alias: "n",
+  describe: "Network",
+  type: "string",
+  choices: ["mainnet", "testnet", "devnet"],
+  required: true,
+} as const;
+
+export const PRIVATE_KEY_OPTIONS = {
+  alias: "k",
+  describe: "Custom private key to sign transactions",
+  required: false,
+  type: "string",
+} as const;
+
+export const RPC_OPTIONS = {
+  alias: "r",
+  describe: "Override default rpc endpoint url",
+  type: "string",
+  required: false,
+} as const;
 
-export function hex(x: string): string {
-  return ethers.utils.hexlify(x, { allowMissingPrefix: true });
-}
+export const GOVERNANCE_CHAIN = CHAIN_ID_SOLANA;
+export const GOVERNANCE_EMITTER =
+  "0000000000000000000000000000000000000000000000000000000000000004";
+export const INITIAL_GUARDIAN_DEVNET =
+  "befa429d57cd18b7f8a4d91a2da9ab4af05d0fbe";

+ 8 - 8
clients/js/injective.ts

@@ -1,16 +1,16 @@
+import { CONTRACTS } from "@certusone/wormhole-sdk/lib/cjs/utils/consts";
 import { getNetworkInfo, Network } from "@injectivelabs/networks";
-import { getStdFee, DEFAULT_STD_FEE } from "@injectivelabs/utils";
 import {
-  PrivateKey,
-  TxGrpcApi,
   ChainRestAuthApi,
   createTransaction,
   MsgExecuteContractCompat,
+  PrivateKey,
+  TxGrpcApi,
 } from "@injectivelabs/sdk-ts";
+import { DEFAULT_STD_FEE, getStdFee } from "@injectivelabs/utils";
 import { fromUint8Array } from "js-base64";
-import { impossible, Payload } from "./vaa";
 import { NETWORKS } from "./networks";
-import { CONTRACTS } from "@certusone/wormhole-sdk/lib/cjs/utils/consts";
+import { impossible, Payload } from "./vaa";
 
 export async function execute_injective(
   payload: Payload,
@@ -56,7 +56,7 @@ export async function execute_injective(
           console.log("Upgrading core contract");
           break;
         case "RecoverChainId":
-          throw new Error("RecoverChainId not supported on injective")
+          throw new Error("RecoverChainId not supported on injective");
         default:
           impossible(payload);
       }
@@ -80,7 +80,7 @@ export async function execute_injective(
           console.log("Upgrading contract");
           break;
         case "RecoverChainId":
-          throw new Error("RecoverChainId not supported on injective")
+          throw new Error("RecoverChainId not supported on injective");
         case "RegisterChain":
           console.log("Registering chain");
           break;
@@ -108,7 +108,7 @@ export async function execute_injective(
           console.log("Upgrading contract");
           break;
         case "RecoverChainId":
-          throw new Error("RecoverChainId not supported on injective")
+          throw new Error("RecoverChainId not supported on injective");
         case "RegisterChain":
           console.log("Registering chain");
           break;

+ 4 - 1
clients/js/main.ts

@@ -28,4 +28,7 @@ if (isOutdated()) {
   );
 }
 
-yargs(hideBin(process.argv)).commandDir("cmds").strict().demandCommand().argv;
+yargs(hideBin(process.argv))
+  .commandDir("cmds", { recurse: true })
+  .strict()
+  .demandCommand().argv;

+ 13 - 13
clients/js/networks.ts

@@ -111,14 +111,6 @@ const MAINNET = {
     chain_id: "dimension_37-1",
     key: get_env_var("XPLA_KEY"),
   },
-  sei: {
-    rpc: undefined,
-    key: undefined,
-  },
-  sepolia: {
-    rpc: undefined,
-    key: undefined,
-  },
   btc: {
     rpc: undefined,
     key: undefined,
@@ -156,6 +148,14 @@ const MAINNET = {
     rpc: undefined,
     key: get_env_var("ETH_KEY"),
   },
+  sei: {
+    rpc: undefined,
+    key: undefined,
+  },
+  sepolia: {
+    rpc: undefined,
+    key: undefined,
+  },
 };
 
 const TESTNET = {
@@ -241,8 +241,8 @@ const TESTNET = {
     key: get_env_var("APTOS_TESTNET"),
   },
   sui: {
-    rpc: undefined,
-    key: undefined,
+    rpc: "https://fullnode.devnet.sui.io:443",
+    key: get_env_var("SUI_KEY_TESTNET"),
   },
   pythnet: {
     rpc: "https://api.pythtest.pyth.network/",
@@ -396,7 +396,7 @@ const DEVNET = {
   sepolia: {
     rpc: undefined,
     key: undefined,
-  }, 
+  },
   wormchain: {
     rpc: "http://localhost:1319",
     chain_id: "wormchain",
@@ -407,8 +407,8 @@ const DEVNET = {
     key: "537c1f91e56891445b491068f519b705f8c0f1a1e66111816dd5d4aa85b8113d",
   },
   sui: {
-    rpc: undefined,
-    key: undefined,
+    rpc: "http://0.0.0.0:9000",
+    key: "AGA20wtGcwbcNAG4nwapbQ5wIuXwkYQEWFUoSVAxctHb",
   },
   moonbeam: {
     rpc: undefined,

+ 306 - 13
clients/js/package-lock.json

@@ -9,11 +9,12 @@
       "version": "0.0.3",
       "dependencies": {
         "@celo-tools/celo-ethers-wrapper": "^0.1.0",
-        "@certusone/wormhole-sdk": "^0.9.14",
+        "@certusone/wormhole-sdk": "^0.9.15-beta.4",
         "@cosmjs/encoding": "^0.26.2",
         "@injectivelabs/networks": "^1.10.7",
         "@injectivelabs/sdk-ts": "^1.10.47",
         "@injectivelabs/utils": "^1.10.5",
+        "@mysten/sui.js": "^0.32.2",
         "@sei-js/core": "^1.3.2",
         "@solana/web3.js": "^1.22.0",
         "@terra-money/terra.js": "^3.1.3",
@@ -22,6 +23,7 @@
         "algosdk": "^1.15.0",
         "aptos": "^1.3.16",
         "axios": "^0.24.0",
+        "base-64": "^1.0.0",
         "binary-parser": "^2.0.2",
         "bn.js": "^5.2.0",
         "bs58": "^4.0.1",
@@ -489,9 +491,9 @@
       }
     },
     "node_modules/@certusone/wormhole-sdk": {
-      "version": "0.9.14",
-      "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.9.14.tgz",
-      "integrity": "sha512-xR1dkMkzWJz+EfIvlzThQ5AkU6oY1UjRsyxaxvDEcd9NxZMRHfXJSgHFdP8gWjDfg3nUnj4NGY/UeqAxq9l1+g==",
+      "version": "0.9.15-beta.4",
+      "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.9.15-beta.4.tgz",
+      "integrity": "sha512-HZwCK1wGrXjcP7w9qkbXtNNU1p3qgY+Q6RF5Y+6gIV85bOHJSCWkqbvkFTINf/NW6lPuTuKplYmoEaN8F1mFQA==",
       "dependencies": {
         "@certusone/wormhole-sdk-proto-web": "0.0.6",
         "@certusone/wormhole-sdk-wasm": "^0.0.1",
@@ -499,6 +501,7 @@
         "@injectivelabs/networks": "1.10.7",
         "@injectivelabs/sdk-ts": "1.10.47",
         "@injectivelabs/utils": "1.10.5",
+        "@mysten/sui.js": "0.32.2",
         "@project-serum/anchor": "^0.25.0",
         "@solana/spl-token": "^0.3.5",
         "@solana/web3.js": "^1.66.2",
@@ -2200,6 +2203,160 @@
       "integrity": "sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q==",
       "dev": true
     },
+    "node_modules/@mysten/bcs": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/@mysten/bcs/-/bcs-0.7.1.tgz",
+      "integrity": "sha512-wFPb8bkhwrbiStfZMV5rFM7J+umpke59/dNjDp+UYJKykNlW23LCk2ePyEUvGdb62HGJM1jyOJ8g4egE3OmdKA==",
+      "dependencies": {
+        "bs58": "^5.0.0"
+      }
+    },
+    "node_modules/@mysten/bcs/node_modules/base-x": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz",
+      "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw=="
+    },
+    "node_modules/@mysten/bcs/node_modules/bs58": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz",
+      "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==",
+      "dependencies": {
+        "base-x": "^4.0.0"
+      }
+    },
+    "node_modules/@mysten/sui.js": {
+      "version": "0.32.2",
+      "resolved": "https://registry.npmjs.org/@mysten/sui.js/-/sui.js-0.32.2.tgz",
+      "integrity": "sha512-/Hm4xkGolJhqj8FvQr7QSHDTlxIvL52mtbOao9f75YjrBh7y1Uh9kbJSY7xiTF1NY9sv6p5hUVlYRJuM0Hvn9A==",
+      "dependencies": {
+        "@mysten/bcs": "0.7.1",
+        "@noble/curves": "^1.0.0",
+        "@noble/hashes": "^1.3.0",
+        "@scure/bip32": "^1.3.0",
+        "@scure/bip39": "^1.2.0",
+        "@suchipi/femver": "^1.0.0",
+        "jayson": "^4.0.0",
+        "rpc-websockets": "^7.5.1",
+        "superstruct": "^1.0.3",
+        "tweetnacl": "^1.0.3"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@mysten/sui.js/node_modules/@noble/hashes": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz",
+      "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ]
+    },
+    "node_modules/@mysten/sui.js/node_modules/@scure/bip32": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.0.tgz",
+      "integrity": "sha512-bcKpo1oj54hGholplGLpqPHRbIsnbixFtc06nwuNM5/dwSXOq/AAYoIBRsBmnZJSdfeNW5rnff7NTAz3ZCqR9Q==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ],
+      "dependencies": {
+        "@noble/curves": "~1.0.0",
+        "@noble/hashes": "~1.3.0",
+        "@scure/base": "~1.1.0"
+      }
+    },
+    "node_modules/@mysten/sui.js/node_modules/@scure/bip39": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.0.tgz",
+      "integrity": "sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ],
+      "dependencies": {
+        "@noble/hashes": "~1.3.0",
+        "@scure/base": "~1.1.0"
+      }
+    },
+    "node_modules/@mysten/sui.js/node_modules/@types/node": {
+      "version": "12.20.55",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
+      "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="
+    },
+    "node_modules/@mysten/sui.js/node_modules/jayson": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.1.0.tgz",
+      "integrity": "sha512-R6JlbyLN53Mjku329XoRT2zJAE6ZgOQ8f91ucYdMCD4nkGCF9kZSrcGXpHIU4jeKj58zUZke2p+cdQchU7Ly7A==",
+      "dependencies": {
+        "@types/connect": "^3.4.33",
+        "@types/node": "^12.12.54",
+        "@types/ws": "^7.4.4",
+        "commander": "^2.20.3",
+        "delay": "^5.0.0",
+        "es6-promisify": "^5.0.0",
+        "eyes": "^0.1.8",
+        "isomorphic-ws": "^4.0.1",
+        "json-stringify-safe": "^5.0.1",
+        "JSONStream": "^1.3.5",
+        "uuid": "^8.3.2",
+        "ws": "^7.4.5"
+      },
+      "bin": {
+        "jayson": "bin/jayson.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@mysten/sui.js/node_modules/superstruct": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-1.0.3.tgz",
+      "integrity": "sha512-8iTn3oSS8nRGn+C2pgXSKPI3jmpm6FExNazNpjvqS6ZUJQCej3PUXEKM8NjHBOs54ExM+LPW/FBRhymrdcCiSg==",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@mysten/sui.js/node_modules/uuid": {
+      "version": "8.3.2",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+      "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    },
+    "node_modules/@noble/curves": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.0.0.tgz",
+      "integrity": "sha512-2upgEu0iLiDVDZkNLeFV2+ht0BAVgQnEmCk6JsOch9Rp8xfkMCbvbAZlA2pBHQc73dbl+vFOXfqkf4uemdn0bw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ],
+      "dependencies": {
+        "@noble/hashes": "1.3.0"
+      }
+    },
+    "node_modules/@noble/curves/node_modules/@noble/hashes": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz",
+      "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ]
+    },
     "node_modules/@noble/ed25519": {
       "version": "1.7.1",
       "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-1.7.1.tgz",
@@ -2811,6 +2968,11 @@
         "node": ">=12.20.0"
       }
     },
+    "node_modules/@suchipi/femver": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@suchipi/femver/-/femver-1.0.0.tgz",
+      "integrity": "sha512-bprE8+K5V+DPX7q2e2K57ImqNBdfGHDIWaGI5xHxZoxbKOuQZn4wzPiUxOAHnsUr3w3xHrWXwN7gnG/iIuEMIg=="
+    },
     "node_modules/@terra-money/legacy.proto": {
       "name": "@terra-money/terra.proto",
       "version": "0.1.7",
@@ -3425,6 +3587,11 @@
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
     },
+    "node_modules/base-64": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
+      "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="
+    },
     "node_modules/base-x": {
       "version": "3.0.8",
       "license": "MIT",
@@ -6670,9 +6837,9 @@
       "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
     },
     "node_modules/rpc-websockets": {
-      "version": "7.5.0",
-      "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-7.5.0.tgz",
-      "integrity": "sha512-9tIRi1uZGy7YmDjErf1Ax3wtqdSSLIlnmL5OtOzgd5eqPKbsPpwDP5whUDO2LQay3Xp0CcHlcNSGzacNRluBaQ==",
+      "version": "7.5.1",
+      "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-7.5.1.tgz",
+      "integrity": "sha512-kGFkeTsmd37pHPMaHIgN1LVKXMi0JD782v4Ds9ZKtLlwdTKjn+CxM9A9/gLT2LaOuEcEFGL98h1QWQtlOIdW0w==",
       "dependencies": {
         "@babel/runtime": "^7.17.2",
         "eventemitter3": "^4.0.7",
@@ -8065,9 +8232,9 @@
       "requires": {}
     },
     "@certusone/wormhole-sdk": {
-      "version": "0.9.14",
-      "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.9.14.tgz",
-      "integrity": "sha512-xR1dkMkzWJz+EfIvlzThQ5AkU6oY1UjRsyxaxvDEcd9NxZMRHfXJSgHFdP8gWjDfg3nUnj4NGY/UeqAxq9l1+g==",
+      "version": "0.9.15-beta.4",
+      "resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.9.15-beta.4.tgz",
+      "integrity": "sha512-HZwCK1wGrXjcP7w9qkbXtNNU1p3qgY+Q6RF5Y+6gIV85bOHJSCWkqbvkFTINf/NW6lPuTuKplYmoEaN8F1mFQA==",
       "requires": {
         "@certusone/wormhole-sdk-proto-web": "0.0.6",
         "@certusone/wormhole-sdk-wasm": "^0.0.1",
@@ -8075,6 +8242,7 @@
         "@injectivelabs/networks": "1.10.7",
         "@injectivelabs/sdk-ts": "1.10.47",
         "@injectivelabs/utils": "1.10.5",
+        "@mysten/sui.js": "0.32.2",
         "@project-serum/anchor": "^0.25.0",
         "@solana/spl-token": "^0.3.5",
         "@solana/web3.js": "^1.66.2",
@@ -9433,6 +9601,121 @@
       "integrity": "sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q==",
       "dev": true
     },
+    "@mysten/bcs": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/@mysten/bcs/-/bcs-0.7.1.tgz",
+      "integrity": "sha512-wFPb8bkhwrbiStfZMV5rFM7J+umpke59/dNjDp+UYJKykNlW23LCk2ePyEUvGdb62HGJM1jyOJ8g4egE3OmdKA==",
+      "requires": {
+        "bs58": "^5.0.0"
+      },
+      "dependencies": {
+        "base-x": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz",
+          "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw=="
+        },
+        "bs58": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz",
+          "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==",
+          "requires": {
+            "base-x": "^4.0.0"
+          }
+        }
+      }
+    },
+    "@mysten/sui.js": {
+      "version": "0.32.2",
+      "resolved": "https://registry.npmjs.org/@mysten/sui.js/-/sui.js-0.32.2.tgz",
+      "integrity": "sha512-/Hm4xkGolJhqj8FvQr7QSHDTlxIvL52mtbOao9f75YjrBh7y1Uh9kbJSY7xiTF1NY9sv6p5hUVlYRJuM0Hvn9A==",
+      "requires": {
+        "@mysten/bcs": "0.7.1",
+        "@noble/curves": "^1.0.0",
+        "@noble/hashes": "^1.3.0",
+        "@scure/bip32": "^1.3.0",
+        "@scure/bip39": "^1.2.0",
+        "@suchipi/femver": "^1.0.0",
+        "jayson": "^4.0.0",
+        "rpc-websockets": "^7.5.1",
+        "superstruct": "^1.0.3",
+        "tweetnacl": "^1.0.3"
+      },
+      "dependencies": {
+        "@noble/hashes": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz",
+          "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg=="
+        },
+        "@scure/bip32": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.0.tgz",
+          "integrity": "sha512-bcKpo1oj54hGholplGLpqPHRbIsnbixFtc06nwuNM5/dwSXOq/AAYoIBRsBmnZJSdfeNW5rnff7NTAz3ZCqR9Q==",
+          "requires": {
+            "@noble/curves": "~1.0.0",
+            "@noble/hashes": "~1.3.0",
+            "@scure/base": "~1.1.0"
+          }
+        },
+        "@scure/bip39": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.0.tgz",
+          "integrity": "sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg==",
+          "requires": {
+            "@noble/hashes": "~1.3.0",
+            "@scure/base": "~1.1.0"
+          }
+        },
+        "@types/node": {
+          "version": "12.20.55",
+          "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
+          "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="
+        },
+        "jayson": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.1.0.tgz",
+          "integrity": "sha512-R6JlbyLN53Mjku329XoRT2zJAE6ZgOQ8f91ucYdMCD4nkGCF9kZSrcGXpHIU4jeKj58zUZke2p+cdQchU7Ly7A==",
+          "requires": {
+            "@types/connect": "^3.4.33",
+            "@types/node": "^12.12.54",
+            "@types/ws": "^7.4.4",
+            "commander": "^2.20.3",
+            "delay": "^5.0.0",
+            "es6-promisify": "^5.0.0",
+            "eyes": "^0.1.8",
+            "isomorphic-ws": "^4.0.1",
+            "json-stringify-safe": "^5.0.1",
+            "JSONStream": "^1.3.5",
+            "uuid": "^8.3.2",
+            "ws": "^7.4.5"
+          }
+        },
+        "superstruct": {
+          "version": "1.0.3",
+          "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-1.0.3.tgz",
+          "integrity": "sha512-8iTn3oSS8nRGn+C2pgXSKPI3jmpm6FExNazNpjvqS6ZUJQCej3PUXEKM8NjHBOs54ExM+LPW/FBRhymrdcCiSg=="
+        },
+        "uuid": {
+          "version": "8.3.2",
+          "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+          "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
+        }
+      }
+    },
+    "@noble/curves": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.0.0.tgz",
+      "integrity": "sha512-2upgEu0iLiDVDZkNLeFV2+ht0BAVgQnEmCk6JsOch9Rp8xfkMCbvbAZlA2pBHQc73dbl+vFOXfqkf4uemdn0bw==",
+      "requires": {
+        "@noble/hashes": "1.3.0"
+      },
+      "dependencies": {
+        "@noble/hashes": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz",
+          "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg=="
+        }
+      }
+    },
     "@noble/ed25519": {
       "version": "1.7.1",
       "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-1.7.1.tgz",
@@ -9964,6 +10247,11 @@
         "superstruct": "^0.14.2"
       }
     },
+    "@suchipi/femver": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@suchipi/femver/-/femver-1.0.0.tgz",
+      "integrity": "sha512-bprE8+K5V+DPX7q2e2K57ImqNBdfGHDIWaGI5xHxZoxbKOuQZn4wzPiUxOAHnsUr3w3xHrWXwN7gnG/iIuEMIg=="
+    },
     "@terra-money/legacy.proto": {
       "version": "npm:@terra-money/terra.proto@0.1.7",
       "resolved": "https://registry.npmjs.org/@terra-money/terra.proto/-/terra.proto-0.1.7.tgz",
@@ -10496,6 +10784,11 @@
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
     },
+    "base-64": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
+      "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="
+    },
     "base-x": {
       "version": "3.0.8",
       "requires": {
@@ -13214,9 +13507,9 @@
       }
     },
     "rpc-websockets": {
-      "version": "7.5.0",
-      "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-7.5.0.tgz",
-      "integrity": "sha512-9tIRi1uZGy7YmDjErf1Ax3wtqdSSLIlnmL5OtOzgd5eqPKbsPpwDP5whUDO2LQay3Xp0CcHlcNSGzacNRluBaQ==",
+      "version": "7.5.1",
+      "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-7.5.1.tgz",
+      "integrity": "sha512-kGFkeTsmd37pHPMaHIgN1LVKXMi0JD782v4Ds9ZKtLlwdTKjn+CxM9A9/gLT2LaOuEcEFGL98h1QWQtlOIdW0w==",
       "requires": {
         "@babel/runtime": "^7.17.2",
         "bufferutil": "^4.0.1",

+ 3 - 1
clients/js/package.json

@@ -3,11 +3,12 @@
   "version": "0.0.3",
   "dependencies": {
     "@celo-tools/celo-ethers-wrapper": "^0.1.0",
-    "@certusone/wormhole-sdk": "^0.9.14",
+    "@certusone/wormhole-sdk": "^0.9.15-beta.4",
     "@cosmjs/encoding": "^0.26.2",
     "@injectivelabs/networks": "^1.10.7",
     "@injectivelabs/sdk-ts": "^1.10.47",
     "@injectivelabs/utils": "^1.10.5",
+    "@mysten/sui.js": "^0.32.2",
     "@sei-js/core": "^1.3.2",
     "@solana/web3.js": "^1.22.0",
     "@terra-money/terra.js": "^3.1.3",
@@ -16,6 +17,7 @@
     "algosdk": "^1.15.0",
     "aptos": "^1.3.16",
     "axios": "^0.24.0",
+    "base-64": "^1.0.0",
     "binary-parser": "^2.0.2",
     "bn.js": "^5.2.0",
     "bs58": "^4.0.1",

+ 153 - 0
clients/js/sui/MoveToml.ts

@@ -0,0 +1,153 @@
+import fs from "fs";
+import { ParsedMoveToml } from "./types";
+
+export class MoveToml {
+  private toml: ParsedMoveToml;
+
+  constructor(tomlPathOrStr: string) {
+    let tomlStr = tomlPathOrStr;
+    try {
+      tomlStr = fs.readFileSync(tomlPathOrStr, "utf8").toString();
+    } catch (e) {}
+    this.toml = MoveToml.parse(tomlStr);
+  }
+
+  addRow(sectionName: string, key: string, value: string) {
+    if (!MoveToml.isValidValue(value)) {
+      if (/^\S+$/.test(value)) {
+        value = `"${value}"`;
+      } else {
+        throw new Error(`Invalid value "${value}"`);
+      }
+    }
+
+    const section = this.forceGetSection(sectionName);
+    section.rows.push({ key, value });
+    return this;
+  }
+
+  addOrUpdateRow(sectionName: string, key: string, value: string) {
+    if (this.getRow(sectionName, key) === undefined) {
+      this.addRow(sectionName, key, value);
+    } else {
+      this.updateRow(sectionName, key, value);
+    }
+
+    return this;
+  }
+
+  getSectionNames(): string[] {
+    return this.toml.map((s) => s.name);
+  }
+
+  isPublished(): boolean {
+    return !!this.getRow("package", "published-at");
+  }
+
+  removeRow(sectionName: string, key: string) {
+    const section = this.forceGetSection(sectionName);
+    section.rows = section.rows.filter((r) => r.key !== key);
+    return this;
+  }
+
+  serialize(): string {
+    let tomlStr = "";
+    for (let i = 0; i < this.toml.length; i++) {
+      const section = this.toml[i];
+      tomlStr += `[${section.name}]\n`;
+      for (const row of section.rows) {
+        tomlStr += `${row.key} = ${row.value}\n`;
+      }
+
+      if (i !== this.toml.length - 1) {
+        tomlStr += "\n";
+      }
+    }
+
+    return tomlStr;
+  }
+
+  updateRow(sectionName: string, key: string, value: string) {
+    if (!MoveToml.isValidValue(value)) {
+      if (/^\S+$/.test(value)) {
+        value = `"${value}"`;
+      } else {
+        throw new Error(`Invalid value "${value}"`);
+      }
+    }
+
+    const row = this.forceGetRow(sectionName, key);
+    row.value = value;
+    return this;
+  }
+
+  static isValidValue(value: string): boolean {
+    value = value.trim();
+    return (
+      (value.startsWith('"') && value.endsWith('"')) ||
+      (value.startsWith("{") && value.endsWith("}")) ||
+      (value.startsWith("'") && value.endsWith("'"))
+    );
+  }
+
+  static parse(tomlStr: string): ParsedMoveToml {
+    const toml: ParsedMoveToml = [];
+    const lines = tomlStr.split("\n");
+    for (const line of lines) {
+      // Parse new section
+      const sectionMatch = line.trim().match(/^\[(\S+)\]$/);
+      if (sectionMatch && sectionMatch.length === 2) {
+        toml.push({ name: sectionMatch[1], rows: [] });
+        continue;
+      }
+
+      // Otherwise, parse row in section. We must handle two cases:
+      //  1. value is string, e.g. name = "MyPackage"
+      //  2. value is object, e.g. Sui = { local = "../sui-framework" }
+      const rowMatch = line.trim().match(/^([a-zA-Z_\-]+) = (.+)$/);
+      if (rowMatch && rowMatch.length === 3) {
+        toml[toml.length - 1].rows.push({
+          key: rowMatch[1],
+          value: rowMatch[2],
+        });
+      }
+    }
+
+    return toml;
+  }
+
+  private forceGetRow(
+    sectionName: string,
+    key: string
+  ): ParsedMoveToml[number]["rows"][number] {
+    const section = this.forceGetSection(sectionName);
+    const row = section.rows.find((r) => r.key === key);
+    if (row === undefined) {
+      throw new Error(`Row "${key}" not found in section "${sectionName}"`);
+    }
+
+    return row;
+  }
+
+  private forceGetSection(sectionName: string): ParsedMoveToml[number] {
+    const section = this.getSection(sectionName);
+    if (section === undefined) {
+      console.log(this.toml);
+      throw new Error(`Section "${sectionName}" not found`);
+    }
+
+    return section;
+  }
+
+  private getRow(
+    sectionName: string,
+    key: string
+  ): ParsedMoveToml[number]["rows"][number] | undefined {
+    const section = this.getSection(sectionName);
+    return section && section.rows.find((r) => r.key === key);
+  }
+
+  private getSection(sectionName: string): ParsedMoveToml[number] | undefined {
+    return this.toml.find((s) => s.name === sectionName);
+  }
+}

+ 129 - 0
clients/js/sui/buildCoin.ts

@@ -0,0 +1,129 @@
+import { JsonRpcProvider } from "@mysten/sui.js";
+import fs from "fs";
+import { Network } from "../utils";
+import { MoveToml } from "./MoveToml";
+import {
+  buildPackage,
+  cleanupTempToml,
+  getAllLocalPackageDependencyPaths,
+  getDefaultTomlPath,
+  getPackageNameFromPath,
+  setupMainToml,
+} from "./publish";
+import { SuiBuildOutput } from "./types";
+import { getPackageId } from "./utils";
+
+export const buildCoin = async (
+  provider: JsonRpcProvider,
+  network: Network,
+  packagePath: string,
+  coreBridgeStateObjectId: string,
+  tokenBridgeStateObjectId: string,
+  version: string,
+  decimals: number
+): Promise<SuiBuildOutput> => {
+  const coreBridgePackageId = await getPackageId(
+    provider,
+    coreBridgeStateObjectId
+  );
+  const tokenBridgePackageId = await getPackageId(
+    provider,
+    tokenBridgeStateObjectId
+  );
+  try {
+    setupCoin(
+      network,
+      packagePath,
+      coreBridgePackageId,
+      tokenBridgePackageId,
+      version,
+      decimals
+    );
+    return buildPackage(`${packagePath}/wrapped_coin`);
+  } finally {
+    cleanupCoin(`${packagePath}/wrapped_coin`);
+  }
+};
+
+const setupCoin = (
+  network: Network,
+  packagePath: string,
+  coreBridgePackageId: string,
+  tokenBridgePackageId: string,
+  version: string,
+  decimals: number
+): void => {
+  // Check to see if the given version string is valid. We don't include the
+  // end boundary in the regex to accomodate versions such as V__0_1_0_patch,
+  // in the off chance we need such a naming scheme.
+  if (!/^V__[0-9]+_[0-9]+_[0-9]+/.test(version)) {
+    throw new Error(`Invalid version ${version}`);
+  }
+
+  // Assemble package directory
+  fs.rmSync(`${packagePath}/wrapped_coin`, { recursive: true, force: true });
+  fs.mkdirSync(`${packagePath}/wrapped_coin/sources`, { recursive: true });
+
+  // Replace template variables
+  const coinTemplate = fs
+    .readFileSync(
+      `${packagePath}/templates/wrapped_coin/sources/coin.move`,
+      "utf8"
+    )
+    .toString();
+  const coin = coinTemplate
+    .replace(/{{DECIMALS}}/, decimals.toString())
+    .replace(/{{VERSION}}/g, version);
+  fs.writeFileSync(
+    `${packagePath}/wrapped_coin/sources/coin.move`,
+    coin,
+    "utf8"
+  );
+
+  // Substitute dependency package IDs
+  const toml = new MoveToml(`${packagePath}/templates/wrapped_coin/Move.toml`)
+    .updateRow("addresses", "wormhole", coreBridgePackageId)
+    .updateRow("addresses", "token_bridge", tokenBridgePackageId)
+    .serialize();
+  const tomlPath = `${packagePath}/wrapped_coin/Move.toml`;
+  fs.writeFileSync(tomlPath, toml, "utf8");
+
+  // Setup dependencies
+  const paths = getAllLocalPackageDependencyPaths(tomlPath);
+  for (const dependencyPath of paths) {
+    // todo(aki): the 4th param is a hack that makes this work, but doesn't
+    // necessarily make sense. We should probably revisit this later.
+    setupMainToml(dependencyPath, network, false, network !== "DEVNET");
+    if (network === "DEVNET") {
+      const dependencyToml = new MoveToml(getDefaultTomlPath(dependencyPath));
+      switch (getPackageNameFromPath(dependencyPath)) {
+        case "wormhole":
+          dependencyToml
+            .addOrUpdateRow("package", "published-at", coreBridgePackageId)
+            .updateRow("addresses", "wormhole", coreBridgePackageId);
+          break;
+        case "token_bridge":
+          dependencyToml
+            .addOrUpdateRow("package", "published-at", tokenBridgePackageId)
+            .updateRow("addresses", "token_bridge", tokenBridgePackageId);
+          break;
+        default:
+          throw new Error(`Unknown dependency ${dependencyPath}`);
+      }
+      fs.writeFileSync(
+        getDefaultTomlPath(dependencyPath),
+        dependencyToml.serialize(),
+        "utf8"
+      );
+    }
+  }
+};
+
+const cleanupCoin = (packagePath: string) => {
+  const paths = getAllLocalPackageDependencyPaths(
+    getDefaultTomlPath(packagePath)
+  );
+  for (const dependencyPath of paths) {
+    cleanupTempToml(dependencyPath, false);
+  }
+};

+ 7 - 0
clients/js/sui/error.ts

@@ -0,0 +1,7 @@
+export class SuiRpcValidationError extends Error {
+  constructor(response: any) {
+    super(
+      `Sui RPC returned an unexpected response: ${JSON.stringify(response)}`
+    );
+  }
+}

+ 7 - 0
clients/js/sui/index.ts

@@ -0,0 +1,7 @@
+export * from "./MoveToml";
+export * from "./buildCoin";
+export * from "./log";
+export * from "./publish";
+export * from "./submit";
+export * from "./types";
+export * from "./utils";

+ 28 - 0
clients/js/sui/log.ts

@@ -0,0 +1,28 @@
+import {
+  getTransactionDigest,
+  getTransactionSender,
+  SuiTransactionBlockResponse,
+} from "@mysten/sui.js";
+import { getCreatedObjects, getPublishedPackageId } from "./utils";
+
+export const logTransactionDigest = (
+  res: SuiTransactionBlockResponse,
+  ...args: string[]
+) => {
+  console.log("Transaction digest", getTransactionDigest(res), ...args);
+};
+
+export const logTransactionSender = (res: SuiTransactionBlockResponse) => {
+  console.log("Transaction sender", getTransactionSender(res));
+};
+
+export const logPublishedPackageId = (res: SuiTransactionBlockResponse) => {
+  console.log("Published to", getPublishedPackageId(res));
+};
+
+export const logCreatedObjects = (res: SuiTransactionBlockResponse) => {
+  console.log(
+    "Created objects",
+    JSON.stringify(getCreatedObjects(res), null, 2)
+  );
+};

+ 248 - 0
clients/js/sui/publish.ts

@@ -0,0 +1,248 @@
+import {
+  fromB64,
+  getPublishedObjectChanges,
+  normalizeSuiObjectId,
+  RawSigner,
+  TransactionBlock,
+} from "@mysten/sui.js";
+import { execSync } from "child_process";
+import fs from "fs";
+import { resolve } from "path";
+import { Network } from "../utils";
+import { MoveToml } from "./MoveToml";
+import { SuiBuildOutput } from "./types";
+import { executeTransactionBlock } from "./utils";
+
+export const buildPackage = (packagePath: string): SuiBuildOutput => {
+  if (!fs.existsSync(packagePath)) {
+    throw new Error(`Package not found at ${packagePath}`);
+  }
+
+  return JSON.parse(
+    execSync(
+      `sui move build --dump-bytecode-as-base64 --path ${packagePath} 2> /dev/null`,
+      {
+        encoding: "utf-8",
+      }
+    )
+  );
+};
+
+/**
+ * Get Move.toml dependencies by looking for all lines of form 'local = ".*"'.
+ * This works because network-specific Move.toml files should not contain
+ * dev addresses, so the only lines that match this regex are the dependencies
+ * that need to be replaced.
+ * @param packagePath
+ * @returns
+ */
+export const getAllLocalPackageDependencyPaths = (
+  tomlPath: string
+): string[] => {
+  const tomlStr = fs.readFileSync(tomlPath, "utf8").toString();
+  const toml = new MoveToml(tomlStr);
+
+  // Sanity check that Move.toml does not contain dev info since this breaks
+  // building and publishing packages
+  if (
+    toml.getSectionNames().some((name) => name.includes("dev-dependencies")) ||
+    toml.getSectionNames().some((name) => name.includes("dev-addresses"))
+  ) {
+    throw new Error(
+      "Network-specific Move.toml should not contain dev-dependencies or dev-addresses."
+    );
+  }
+
+  const packagePath = getPackagePathFromTomlPath(tomlPath);
+  return [...tomlStr.matchAll(/local = "(.*)"/g)].map((match) =>
+    resolve(packagePath, match[1])
+  );
+};
+
+export const getDefaultTomlPath = (packagePath: string): string =>
+  `${packagePath}/Move.toml`;
+
+export const getPackageNameFromPath = (packagePath: string): string =>
+  packagePath.split("/").pop() || "";
+
+export const publishPackage = async (
+  signer: RawSigner,
+  network: Network,
+  packagePath: string
+) => {
+  try {
+    setupMainToml(packagePath, network);
+    const build = buildPackage(packagePath);
+
+    // Publish contracts
+    const tx = new TransactionBlock();
+    if (network === "DEVNET") {
+      // Avoid Error checking transaction input objects: GasBudgetTooHigh { gas_budget: 50000000000, max_budget: 10000000000 }
+      tx.setGasBudget(10000000000);
+    }
+    const [upgradeCap] = tx.publish({
+      modules: build.modules.map((m) => Array.from(fromB64(m))),
+      dependencies: build.dependencies.map((d) => normalizeSuiObjectId(d)),
+    });
+
+    // Transfer upgrade capability to deployer
+    tx.transferObjects([upgradeCap], tx.pure(await signer.getAddress()));
+
+    // Execute transactions
+    const res = await executeTransactionBlock(signer, tx);
+
+    // Update network-specific Move.toml with package ID
+    const publishEvents = getPublishedObjectChanges(res);
+    if (publishEvents.length !== 1) {
+      throw new Error(
+        "No publish event found in transaction:" +
+          JSON.stringify(res.objectChanges, null, 2)
+      );
+    }
+
+    updateNetworkToml(packagePath, network, publishEvents[0].packageId);
+
+    // Return publish transaction info
+    return res;
+  } finally {
+    cleanupTempToml(packagePath);
+  }
+};
+
+export const cleanupTempToml = (
+  packagePath: string,
+  cleanupDependencies: boolean = true
+): void => {
+  const defaultTomlPath = getDefaultTomlPath(packagePath);
+  const tempTomlPath = getTempTomlPath(packagePath);
+  if (fs.existsSync(tempTomlPath)) {
+    // Clean up Move.toml for dependencies
+    if (cleanupDependencies) {
+      const dependencyPaths =
+        getAllLocalPackageDependencyPaths(defaultTomlPath);
+      for (const path of dependencyPaths) {
+        cleanupTempToml(path);
+      }
+    }
+
+    fs.renameSync(tempTomlPath, defaultTomlPath);
+  }
+};
+
+const getPackagePathFromTomlPath = (tomlPath: string): string =>
+  tomlPath.split("/").slice(0, -1).join("/");
+
+const getTempTomlPath = (packagePath: string): string =>
+  `${packagePath}/Move.temp.toml`;
+
+const getTomlPathByNetwork = (packagePath: string, network: Network): string =>
+  `${packagePath}/Move.${network.toLowerCase()}.toml`;
+
+const resetNetworkToml = (
+  packagePath: string,
+  network: Network,
+  recursive: boolean = false
+): void => {
+  const networkTomlPath = getTomlPathByNetwork(packagePath, network);
+  const tomlStr = fs.readFileSync(networkTomlPath, "utf8").toString();
+  const toml = new MoveToml(tomlStr);
+  if (toml.isPublished()) {
+    if (recursive) {
+      const dependencyPaths =
+        getAllLocalPackageDependencyPaths(networkTomlPath);
+      for (const path of dependencyPaths) {
+        resetNetworkToml(path, network);
+      }
+    }
+
+    const updatedTomlStr = toml
+      .removeRow("package", "published-at")
+      .updateRow("addresses", getPackageNameFromPath(packagePath), "_")
+      .serialize();
+    fs.writeFileSync(networkTomlPath, updatedTomlStr, "utf8");
+  }
+};
+
+export const setupMainToml = (
+  packagePath: string,
+  network: Network,
+  checkDependencies: boolean = true,
+  isDependency: boolean = false
+): void => {
+  const defaultTomlPath = getDefaultTomlPath(packagePath);
+  const tempTomlPath = getTempTomlPath(packagePath);
+  const srcTomlPath = getTomlPathByNetwork(packagePath, network);
+
+  if (fs.existsSync(tempTomlPath)) {
+    // It's possible that this dependency has been set up by another package
+    if (isDependency) {
+      return;
+    }
+
+    throw new Error("Move.temp.toml exists, is there a publish in progress?");
+  }
+
+  // Make deploying on devnet more convenient by resetting Move.toml so we
+  // don't have to manually reset them repeatedly during local development.
+  // This is not recursive because we assume that packages are deployed bottom
+  // up.
+  if (!isDependency && network === "DEVNET") {
+    resetNetworkToml(packagePath, network);
+  }
+
+  // Save default Move.toml
+  if (!fs.existsSync(defaultTomlPath)) {
+    throw new Error(
+      `Invalid package layout. Move.toml not found at ${defaultTomlPath}`
+    );
+  }
+
+  fs.renameSync(defaultTomlPath, tempTomlPath);
+
+  // Set Move.toml from appropriate network
+  if (!fs.existsSync(srcTomlPath)) {
+    throw new Error(`Move.toml for ${network} not found at ${srcTomlPath}`);
+  }
+
+  fs.copyFileSync(srcTomlPath, defaultTomlPath);
+
+  // Replace undefined addresses in base Move.toml
+  const tomlStr = fs.readFileSync(defaultTomlPath, "utf8").toString();
+  const toml = new MoveToml(tomlStr);
+  const packageName = getPackageNameFromPath(packagePath);
+  if (!isDependency) {
+    if (toml.isPublished()) {
+      throw new Error(`Package ${packageName} is already published.`);
+    } else {
+      toml.updateRow("addresses", packageName, "0x0");
+    }
+
+    fs.writeFileSync(defaultTomlPath, toml.serialize());
+  } else if (isDependency && !toml.isPublished()) {
+    throw new Error(
+      `Dependency ${packageName} is not published. Please publish it first.`
+    );
+  }
+
+  // Set up Move.toml for dependencies
+  if (checkDependencies) {
+    const dependencyPaths = getAllLocalPackageDependencyPaths(defaultTomlPath);
+    for (const path of dependencyPaths) {
+      setupMainToml(path, network, checkDependencies, true);
+    }
+  }
+};
+
+const updateNetworkToml = (
+  packagePath: string,
+  network: Network,
+  packageId: string
+): void => {
+  const tomlPath = getTomlPathByNetwork(packagePath, network);
+  const tomlStr = fs.readFileSync(tomlPath, "utf8");
+  const updatedTomlStr = new MoveToml(tomlStr)
+    .addRow("package", "published-at", packageId)
+    .updateRow("addresses", getPackageNameFromPath(packagePath), packageId)
+    .serialize();
+  fs.writeFileSync(tomlPath, updatedTomlStr, "utf8");
+};

+ 211 - 0
clients/js/sui/submit.ts

@@ -0,0 +1,211 @@
+import {
+  assertChain,
+  createWrappedOnSui,
+  createWrappedOnSuiPrepare,
+  getForeignAssetSui,
+  parseAttestMetaVaa,
+} from "@certusone/wormhole-sdk";
+import { getWrappedCoinType } from "@certusone/wormhole-sdk/lib/cjs/sui";
+import {
+  CHAIN_ID_SUI,
+  CHAIN_ID_TO_NAME,
+  CONTRACTS,
+} from "@certusone/wormhole-sdk/lib/cjs/utils/consts";
+import { SUI_CLOCK_OBJECT_ID, TransactionBlock } from "@mysten/sui.js";
+import { Network } from "../utils";
+import { Payload, impossible } from "../vaa";
+import {
+  assertSuccess,
+  executeTransactionBlock,
+  getPackageId,
+  getProvider,
+  getSigner,
+  isSuiCreateEvent,
+  isSuiPublishEvent,
+  registerChain,
+} from "./utils";
+
+export const submit = async (
+  payload: Payload,
+  vaa: Buffer,
+  network: Network,
+  rpc?: string,
+  privateKey?: string
+) => {
+  const consoleWarnTemp = console.warn;
+  console.warn = () => {};
+
+  const chain = CHAIN_ID_TO_NAME[CHAIN_ID_SUI];
+  const provider = getProvider(network, rpc);
+  const signer = getSigner(provider, network, privateKey);
+
+  switch (payload.module) {
+    case "Core": {
+      const coreObjectId = CONTRACTS[network][chain].core;
+      if (!coreObjectId) {
+        throw Error("Core bridge object ID is undefined");
+      }
+
+      const corePackageId = await getPackageId(provider, coreObjectId);
+      switch (payload.type) {
+        case "ContractUpgrade":
+          throw new Error("ContractUpgrade not supported on Sui");
+        case "GuardianSetUpgrade": {
+          console.log("Submitting new guardian set");
+          const tx = new TransactionBlock();
+          setMaxGasBudgetDevnet(network, tx);
+          tx.moveCall({
+            target: `${corePackageId}::wormhole::update_guardian_set`,
+            arguments: [
+              tx.object(coreObjectId),
+              tx.pure([...vaa]),
+              tx.object(SUI_CLOCK_OBJECT_ID),
+            ],
+          });
+          const result = await executeTransactionBlock(signer, tx);
+          console.log(JSON.stringify(result));
+          break;
+        }
+        case "RecoverChainId":
+          throw new Error("RecoverChainId not supported on Sui");
+        default:
+          impossible(payload);
+      }
+      break;
+    }
+    case "NFTBridge": {
+      throw new Error("NFT bridge not supported on Sui");
+    }
+    case "TokenBridge": {
+      const coreBridgeStateObjectId = CONTRACTS[network][chain].core;
+      if (!coreBridgeStateObjectId) {
+        throw Error("Core bridge object ID is undefined");
+      }
+
+      const tokenBridgeStateObjectId = CONTRACTS[network][chain].token_bridge;
+      if (!tokenBridgeStateObjectId) {
+        throw Error("Token bridge object ID is undefined");
+      }
+
+      switch (payload.type) {
+        case "AttestMeta": {
+          // Test attest VAA: 01000000000100d87023087588d8a482d6082c57f3c93649c9a61a98848fc3a0b271f4041394ff7b28abefc8e5e19b83f45243d073d677e122e41425c2dbae3eb5ae1c7c0ac0ee01000000c056a8000000020000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16000000000000000001020000000000000000000000002d8be6bf0baa74e0a907016679cae9190e80dd0a000212544b4e0000000000000000000000000000000000000000000000000000000000457468657265756d205465737420546f6b656e00000000000000000000000000
+          const { tokenChain, tokenAddress } = parseAttestMetaVaa(vaa);
+          assertChain(tokenChain);
+          const coinType = await getForeignAssetSui(
+            provider,
+            tokenBridgeStateObjectId,
+            tokenChain,
+            tokenAddress
+          );
+          if (coinType) {
+            // Coin already exists, so we update it
+            console.log("Updating wrapped asset...");
+            throw new Error("Updating wrapped asset not supported on Sui");
+          } else {
+            // Coin doesn't exist, so create wrapped asset
+            console.log("[1/2] Creating wrapped asset...");
+            const prepareTx = await createWrappedOnSuiPrepare(
+              provider,
+              coreBridgeStateObjectId,
+              tokenBridgeStateObjectId,
+              parseAttestMetaVaa(vaa).decimals,
+              await signer.getAddress()
+            );
+            setMaxGasBudgetDevnet(network, prepareTx);
+            const prepareRes = await executeTransactionBlock(signer, prepareTx);
+            assertSuccess(prepareRes, "Prepare registration failed.");
+            const coinPackageId =
+              prepareRes.objectChanges.find(isSuiPublishEvent).packageId;
+            console.log(`  Digest ${prepareRes.digest}`);
+            console.log(`  Published to ${coinPackageId}`);
+            console.log(`  Type ${getWrappedCoinType(coinPackageId)}`);
+
+            if (!rpc && network !== "DEVNET") {
+              // Wait for wrapped asset creation to be propogated to other
+              // nodes in case this complete registration call is load balanced
+              // to another node.
+              await sleep(5000);
+            }
+
+            console.log("\n[2/2] Registering asset...");
+            const wrappedAssetSetup = prepareRes.objectChanges
+              .filter(isSuiCreateEvent)
+              .find((e) =>
+                /create_wrapped::WrappedAssetSetup/.test(e.objectType)
+              );
+            const completeTx = await createWrappedOnSui(
+              provider,
+              coreBridgeStateObjectId,
+              tokenBridgeStateObjectId,
+              await signer.getAddress(),
+              coinPackageId,
+              wrappedAssetSetup.objectType,
+              vaa
+            );
+            setMaxGasBudgetDevnet(network, completeTx);
+            const completeRes = await executeTransactionBlock(
+              signer,
+              completeTx
+            );
+            assertSuccess(completeRes, "Complete registration failed.");
+            console.log(`  Digest ${completeRes.digest}`);
+            console.log("\nDone!");
+          }
+
+          break;
+        }
+        case "ContractUpgrade":
+          throw new Error("ContractUpgrade not supported on Sui");
+        case "RecoverChainId":
+          throw new Error("RecoverChainId not supported on Sui");
+        case "RegisterChain": {
+          console.log("Registering chain");
+          const tx = await registerChain(
+            provider,
+            network,
+            vaa,
+            coreBridgeStateObjectId,
+            tokenBridgeStateObjectId
+          );
+          setMaxGasBudgetDevnet(network, tx);
+          const res = await executeTransactionBlock(signer, tx);
+          console.log(JSON.stringify(res));
+          break;
+        }
+        case "Transfer":
+          throw new Error("Transfer not supported on Sui");
+        case "TransferWithPayload":
+          throw Error("Can't complete payload 3 transfer from CLI");
+        default:
+          impossible(payload);
+          break;
+      }
+
+      break;
+    }
+    default:
+      impossible(payload);
+  }
+
+  console.warn = consoleWarnTemp;
+};
+
+/**
+ * Currently, (Sui SDK version 0.32.2 and Sui 1.0.0 testnet), there is a
+ * mismatch in the max gas budget that causes an error when executing a
+ * transaction. Because these values are hardcoded, we set the max gas budget
+ * as a temporary workaround.
+ * @param network
+ * @param tx
+ */
+const setMaxGasBudgetDevnet = (network: Network, tx: TransactionBlock) => {
+  if (network === "DEVNET") {
+    // Avoid Error checking transaction input objects: GasBudgetTooHigh { gas_budget: 50000000000, max_budget: 10000000000 }
+    tx.setGasBudget(10000000000);
+  }
+};
+
+const sleep = (ms: number): Promise<void> => {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+};

+ 9 - 0
clients/js/sui/types.ts

@@ -0,0 +1,9 @@
+export type ParsedMoveToml = {
+  name: string;
+  rows: { key: string; value: string }[];
+}[];
+
+export type SuiBuildOutput = {
+  modules: string[];
+  dependencies: string[];
+};

+ 377 - 0
clients/js/sui/utils.ts

@@ -0,0 +1,377 @@
+import {
+  Connection,
+  Ed25519Keypair,
+  JsonRpcProvider,
+  PaginatedObjectsResponse,
+  RawSigner,
+  SUI_CLOCK_OBJECT_ID,
+  SuiTransactionBlockResponse,
+  TransactionBlock,
+  fromB64,
+  getPublishedObjectChanges,
+  normalizeSuiAddress,
+} from "@mysten/sui.js";
+import { NETWORKS } from "../networks";
+import { Network } from "../utils";
+import { Payload, VAA, parse, serialiseVAA } from "../vaa";
+import { SuiRpcValidationError } from "./error";
+
+const UPGRADE_CAP_TYPE = "0x2::package::UpgradeCap";
+
+export const assertSuccess = (
+  res: SuiTransactionBlockResponse,
+  error: string
+): void => {
+  if (res?.effects?.status?.status !== "success") {
+    throw new Error(`${error} Response: ${JSON.stringify(res)}`);
+  }
+};
+
+export const executeTransactionBlock = async (
+  signer: RawSigner,
+  transactionBlock: TransactionBlock
+): Promise<SuiTransactionBlockResponse> => {
+  // As of version 0.32.2, Sui SDK outputs a RPC validation warning when the
+  // SDK falls behind the Sui version used by the RPC. We silence these
+  // warnings since the SDK is often out of sync with the RPC.
+  const consoleWarnTemp = console.warn;
+  console.warn = () => {};
+
+  // Let caller handle parsing and logging info
+  const res = await signer.signAndExecuteTransactionBlock({
+    transactionBlock,
+    options: {
+      showInput: true,
+      showEffects: true,
+      showEvents: true,
+      showObjectChanges: true,
+    },
+  });
+
+  console.warn = consoleWarnTemp;
+  return res;
+};
+
+export const findOwnedObjectByType = async (
+  provider: JsonRpcProvider,
+  owner: string,
+  type: string,
+  cursor?: string
+): Promise<string | null> => {
+  const res: PaginatedObjectsResponse = await provider.getOwnedObjects({
+    owner,
+    filter: undefined, // Filter must be undefined to avoid 504 responses
+    cursor: cursor || undefined,
+    options: {
+      showType: true,
+    },
+  });
+
+  if (!res || !res.data) {
+    throw new SuiRpcValidationError(res);
+  }
+
+  const object = res.data.find((d) => d.data.type === type);
+
+  if (!object && res.hasNextPage) {
+    return findOwnedObjectByType(
+      provider,
+      owner,
+      type,
+      res.nextCursor as string
+    );
+  } else if (!object && !res.hasNextPage) {
+    return null;
+  } else {
+    return object.data.objectId;
+  }
+};
+
+export const getCreatedObjects = (
+  res: SuiTransactionBlockResponse
+): { type: string; objectId: string; owner: string }[] => {
+  return res.objectChanges.filter(isSuiCreateEvent).map((e) => ({
+    type: e.objectType,
+    objectId: e.objectId,
+    owner: e.owner["AddressOwner"] || e.owner["ObjectOwner"] || e.owner,
+  }));
+};
+
+export const getOwnedObjectId = async (
+  provider: JsonRpcProvider,
+  owner: string,
+  packageId: string,
+  moduleName: string,
+  structName: string
+): Promise<string | null> => {
+  const type = `${packageId}::${moduleName}::${structName}`;
+
+  // Upgrade caps are a special case
+  if (normalizeSuiType(type) === normalizeSuiType(UPGRADE_CAP_TYPE)) {
+    throw new Error(
+      "`getOwnedObjectId` should not be used to get the object ID of an `UpgradeCap`. Use `getUpgradeCapObjectId` instead."
+    );
+  }
+
+  try {
+    const res = await provider.getOwnedObjects({
+      owner,
+      filter: { StructType: type },
+      options: {
+        showContent: true,
+      },
+    });
+    if (!res || !res.data) {
+      throw new SuiRpcValidationError(res);
+    }
+
+    const objects = res.data.filter((o) => o.data?.objectId);
+    if (objects.length === 1) {
+      return objects[0].data?.objectId;
+    } else if (objects.length > 1) {
+      const objectsStr = JSON.stringify(objects, null, 2);
+      throw new Error(
+        `Found multiple objects owned by ${owner} of type ${type}. This may mean that we've received an unexpected response from the Sui RPC and \`worm\` logic needs to be updated to handle this. Objects: ${objectsStr}`
+      );
+    } else {
+      return null;
+    }
+  } catch (error) {
+    // Handle 504 error by using findOwnedObjectByType method
+    const is504HttpError = `${error}`.includes("504 Gateway Time-out");
+    if (error && is504HttpError) {
+      return findOwnedObjectByType(provider, owner, type);
+    } else {
+      throw error;
+    }
+  }
+};
+
+// TODO(kp): remove this once it's in the sdk
+export async function getPackageId(
+  provider: JsonRpcProvider,
+  stateObjectId: string
+): Promise<string> {
+  const fields = await provider
+    .getObject({
+      id: stateObjectId,
+      options: {
+        showContent: true,
+      },
+    })
+    .then((result) => {
+      if (result.data?.content?.dataType === "moveObject") {
+        return result.data.content.fields;
+      }
+
+      throw new Error("Not a moveObject");
+    });
+  if ("upgrade_cap" in fields) {
+    return fields.upgrade_cap.fields.package;
+  }
+
+  throw new Error("upgrade_cap not found");
+}
+
+export const getProvider = (
+  network?: Network,
+  rpc?: string
+): JsonRpcProvider => {
+  if (!network && !rpc) {
+    throw new Error("Must provide network or RPC to initialize provider");
+  }
+
+  rpc = rpc || NETWORKS[network]["sui"].rpc;
+  if (!rpc) {
+    throw new Error(`No default RPC found for Sui ${network}`);
+  }
+
+  return new JsonRpcProvider(new Connection({ fullnode: rpc }));
+};
+
+export const getPublishedPackageId = (
+  res: SuiTransactionBlockResponse
+): string => {
+  const publishEvents = getPublishedObjectChanges(res);
+  if (publishEvents.length !== 1) {
+    throw new Error(
+      "Unexpected number of publish events found:" +
+        JSON.stringify(publishEvents, null, 2)
+    );
+  }
+
+  return publishEvents[0].packageId;
+};
+
+export const getSigner = (
+  provider: JsonRpcProvider,
+  network: Network,
+  customPrivateKey?: string
+): RawSigner => {
+  const privateKey: string | undefined =
+    customPrivateKey || NETWORKS[network]["sui"].key;
+  if (!privateKey) {
+    throw new Error(`No private key found for Sui ${network}`);
+  }
+
+  const bytes = fromB64(privateKey);
+  const keypair = Ed25519Keypair.fromSecretKey(bytes.slice(1));
+  return new RawSigner(keypair, provider);
+};
+
+/**
+ * This function returns the object ID of the `UpgradeCap` that belongs to the
+ * given package and owner if it exists.
+ *
+ * Structs created by the Sui framework such as `UpgradeCap`s all have the same
+ * type (e.g. `0x2::package::UpgradeCap`) and have a special field, `package`,
+ * we can use to differentiate them.
+ * @param provider Sui RPC provider
+ * @param owner Address of the current owner of the `UpgradeCap`
+ * @param packageId ID of the package that the `UpgradeCap` was created for
+ * @returns The object ID of the `UpgradeCap` if it exists, otherwise `null`
+ */
+export const getUpgradeCapObjectId = async (
+  provider: JsonRpcProvider,
+  owner: string,
+  packageId: string
+): Promise<string | null> => {
+  const res = await provider.getOwnedObjects({
+    owner,
+    filter: { StructType: UPGRADE_CAP_TYPE },
+    options: {
+      showContent: true,
+    },
+  });
+  if (!res || !res.data) {
+    throw new SuiRpcValidationError(res);
+  }
+
+  const objects = res.data.filter(
+    (o) =>
+      o.data?.objectId &&
+      o.data?.content?.dataType === "moveObject" &&
+      o.data?.content?.fields?.package === packageId
+  );
+  if (objects.length === 1) {
+    // We've found the object we're looking for
+    return objects[0].data?.objectId;
+  } else if (objects.length > 1) {
+    const objectsStr = JSON.stringify(objects, null, 2);
+    throw new Error(
+      `Found multiple upgrade capabilities owned by ${owner} from package ${packageId}. Objects: ${objectsStr}`
+    );
+  } else {
+    return null;
+  }
+};
+
+export const isSameType = (a: string, b: string) => {
+  try {
+    return normalizeSuiType(a) === normalizeSuiType(b);
+  } catch (e) {
+    return false;
+  }
+};
+
+export const isSuiCreateEvent = <
+  T extends SuiTransactionBlockResponse["objectChanges"][number],
+  K extends Extract<T, { type: "created" }>
+>(
+  event: T
+): event is K => {
+  return event.type === "created";
+};
+
+export const isSuiPublishEvent = <
+  T extends SuiTransactionBlockResponse["objectChanges"][number],
+  K extends Extract<T, { type: "published" }>
+>(
+  event: T
+): event is K => {
+  return event.type === "published";
+};
+
+export const isValidSuiAddress = (objectId: string): boolean => {
+  return /^(0x)?[0-9a-f]{1,64}$/.test(objectId);
+};
+
+// todo(aki): this needs to correctly handle types such as
+// 0x2::dynamic_field::Field<0x3c6d386861470e6f9cb35f3c91f69e6c1f1737bd5d217ca06a15f582e1dc1ce3::state::MigrationControl, bool>
+export const normalizeSuiType = (type: string): string => {
+  const tokens = type.split("::");
+  if (tokens.length !== 3 || !isValidSuiAddress(tokens[0])) {
+    throw new Error(`Invalid Sui type: ${type}`);
+  }
+
+  return [normalizeSuiAddress(tokens[0]), tokens[1], tokens[2]].join("::");
+};
+
+export const registerChain = async (
+  provider: JsonRpcProvider,
+  network: Network,
+  vaa: Buffer,
+  coreBridgeStateObjectId: string,
+  tokenBridgeStateObjectId: string,
+  transactionBlock?: TransactionBlock
+): Promise<TransactionBlock> => {
+  if (network === "DEVNET") {
+    // Modify the VAA to only have 1 guardian signature
+    // TODO: remove this when we can deploy the devnet core contract
+    // deterministically with multiple guardians in the initial guardian set
+    // Currently the core contract is setup with only 1 guardian in the set
+    const parsedVaa = parse(vaa);
+    parsedVaa.signatures = [parsedVaa.signatures[0]];
+    vaa = Buffer.from(serialiseVAA(parsedVaa as VAA<Payload>), "hex");
+  }
+
+  // Get package IDs
+  const coreBridgePackageId = await getPackageId(
+    provider,
+    coreBridgeStateObjectId
+  );
+  const tokenBridgePackageId = await getPackageId(
+    provider,
+    tokenBridgeStateObjectId
+  );
+
+  // Register chain
+  let tx = transactionBlock;
+  if (!tx) {
+    tx = new TransactionBlock();
+    tx.setGasBudget(1000000);
+  }
+
+  // Get VAA
+  const [verifiedVaa] = tx.moveCall({
+    target: `${coreBridgePackageId}::vaa::parse_and_verify`,
+    arguments: [
+      tx.object(coreBridgeStateObjectId),
+      tx.pure([...vaa]),
+      tx.object(SUI_CLOCK_OBJECT_ID),
+    ],
+  });
+
+  // Get decree ticket
+  const [decreeTicket] = tx.moveCall({
+    target: `${tokenBridgePackageId}::register_chain::authorize_governance`,
+    arguments: [tx.object(tokenBridgeStateObjectId)],
+  });
+
+  // Get decree receipt
+  const [decreeReceipt] = tx.moveCall({
+    target: `${coreBridgePackageId}::governance_message::verify_vaa`,
+    arguments: [tx.object(coreBridgeStateObjectId), verifiedVaa, decreeTicket],
+    typeArguments: [
+      `${tokenBridgePackageId}::register_chain::GovernanceWitness`,
+    ],
+  });
+
+  // Register chain
+  tx.moveCall({
+    target: `${tokenBridgePackageId}::register_chain::register_chain`,
+    arguments: [tx.object(tokenBridgeStateObjectId), decreeReceipt],
+  });
+
+  return tx;
+};

+ 34 - 0
clients/js/utils.ts

@@ -0,0 +1,34 @@
+import { spawnSync } from "child_process";
+import { ethers } from "ethers";
+import { config } from "./config";
+
+export type Network = "MAINNET" | "TESTNET" | "DEVNET";
+
+export function assertNetwork(n: string): asserts n is Network {
+  if (n !== "MAINNET" && n !== "TESTNET" && n !== "DEVNET") {
+    throw Error(`Unknown network: ${n}`);
+  }
+}
+
+export const checkBinary = (binaryName: string, dirName?: string): void => {
+  const binary = spawnSync(binaryName, ["--version"]);
+  if (binary.status !== 0) {
+    console.error(
+      `${binaryName} is not installed. Please install ${binaryName} and try again.`
+    );
+    if (dirName) {
+      console.error(
+        `See ${config.wormholeDir}/${dirName}/README.md for instructions.`
+      );
+    }
+    process.exit(1);
+  }
+};
+
+export const evm_address = (x: string): string => {
+  return hex(x).substring(2).padStart(64, "0");
+};
+
+export const hex = (x: string): string => {
+  return ethers.utils.hexlify(x, { allowMissingPrefix: true });
+};

+ 1 - 0
devnet/eth-devnet.yaml

@@ -41,6 +41,7 @@ spec:
             - --deterministic
             - --time="1970-01-01T00:00:00+00:00"
             - --host=0.0.0.0
+            - --accounts=11
           ports:
             - containerPort: 8545
               name: rpc

+ 1 - 0
devnet/eth-devnet2.yaml

@@ -42,6 +42,7 @@ spec:
             - --deterministic
             - --time="1970-01-01T00:00:00+00:00"
             - --host=0.0.0.0
+            - --accounts=11
             - --chainId=1397
           ports:
             - containerPort: 8545

+ 23 - 14
devnet/sui-devnet.yaml

@@ -7,11 +7,8 @@ metadata:
 spec:
   ports:
     - name: node
-      port: 9002
+      port: 9000
       targetPort: node
-    - name: ws
-      port: 9001
-      targetPort: ws
     - name: prometheus
       port: 9184
       targetPort: prometheus
@@ -41,17 +38,17 @@ spec:
       containers:
         - name: sui-node
           image: sui-node
+          resources:
+            requests:
+              memory: "2048Mi"
           command:
-            - /bin/sh 
-            - -c 
-            - /tmp/start_node.sh
+            - /bin/sh
+            - -c
+            - /tmp/scripts/start_node.sh
           ports:
-            - containerPort: 9002
+            - containerPort: 9000
               name: node
               protocol: TCP
-            - containerPort: 9001
-              name: ws
-              protocol: TCP
             - containerPort: 9184
               name: prometheus
               protocol: TCP
@@ -60,6 +57,18 @@ spec:
               protocol: TCP
           readinessProbe:
             tcpSocket:
-              port: 9002
-
-      restartPolicy: Always
+              port: 9000
+        - name: sui-contracts
+          image: sui-node
+          command: ["/bin/bash", "-c"]
+          args:
+            [
+              "cd /tmp && ./scripts/wait_for_devnet.sh && worm sui setup-devnet && touch success && sleep infinity",
+            ]
+          readinessProbe:
+            periodSeconds: 5
+            failureThreshold: 300
+            exec:
+              command:
+                - cat
+                - /tmp/success

+ 1 - 1
docs/devnet.md

@@ -6,7 +6,7 @@
 | Test ERC20         |    ETH    |                                                            0x2D8BE6BF0baA74e0A907016679CaE9190e80dD0A | Tokens minted to Test Wallet                                                                                                                                        |
 | Test NFT           |    ETH    |                                                            0x5b9b42d6e4B2e4Bf8d42Eba32D46918e10899B66 | One minted to Test Wallet                                                                                                                                           |
 | Test WETH          |    ETH    |                                                            0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E | Tokens minted to Test Wallet                                                                                                                                        |
-| Test ERC20 GA      |    ETH    |                                                            0xf19A2A01B70519f67ADb309a994Ec8c69A967E8b | Tokens minted to Test Wallet 9                                                                                                                                      |
+| Test ERC20 GA      |    ETH    |                                                            0x4cFB3F70BF6a80397C2e634e5bDd85BC0bb189EE | Tokens minted to Test Wallet 9                                                                                                                                      |
 | Bridge Core        |    ETH    |                                                            0xC89Ce4735882C9F0f0FE26686c53074E09B0D550 |                                                                                                                                                                     |
 | Token Bridge       |    ETH    |                                                            0x0290FB167208Af455bB137780163b7B7a9a10C16 |                                                                                                                                                                     |
 | NFT Bridge         |    ETH    |                                                            0x26b4afb60d6c903165150c6f0aa14f8016be4aec |                                                                                                                                                                     |

+ 1 - 1
ethereum/scripts/deploy_test_token.js

@@ -84,7 +84,7 @@ module.exports = async function(callback) {
 
     console.log("WETH token deployed at: " + wethAddress);
 
-    for (let idx = 2; idx < 10; idx++) {
+    for (let idx = 2; idx < 11; idx++) {
       await token.methods.mint(accounts[idx], "1000000000000000000000").send({
         from: accounts[0],
         gas: 1000000,

+ 409 - 458
scripts/devnet-consts.json

@@ -1,464 +1,415 @@
 {
-    "global": {
-        "governanceChainId": "1",
-        "governanceEmitterAddress": "0000000000000000000000000000000000000000000000000000000000000004"
-    },
-    "urls": {
-        "guardianSetLocalUrl": "http://localhost:7071/v1/guardianset/current"
-    },
-    "chains": {
-        "1": {
-            "rpcUrlTilt": "http://solana-devnet:8899",
-            "rpcUrlLocal": "http://localhost:8899",
-            "rpcPort": "8899",
-            "contracts": {
-                "coreEmitterAddress": "VVStPLdubtbBUnMDhC3kt8fM3AE6NLRv73TAd3FCAen",
-                "coreNativeAddress": "Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o",
-                "tokenBridgeEmitterAddress": "ENG1wQ7CQKH8ibAJ1hSLmJgL9Ucg6DRDbj752ZAfidLA",
-                "tokenBridgeNativeAddress": "B6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE",
-                "nftBridgeEmitterAddress": "BABAnMBgBELTQnHabZqYa1thHKp834RDvud4rJ8EUr3k",
-                "nftBridgeNativeAddress": "NFTWqJR8YnRVqPDvTJrYuLrQDitTG5AScqbeghi4zSA"
-            },
-            "accounts": {
-                "testWallet": {
-                    "public": "6sbzC1eH4FTujJXWj51eQe25cYvr4xfXbJ1vAj7j2k5J",
-                    "private": [
-                        14,
-                        173,
-                        153,
-                        4,
-                        176,
-                        224,
-                        201,
-                        111,
-                        32,
-                        237,
-                        183,
-                        185,
-                        159,
-                        247,
-                        22,
-                        161,
-                        89,
-                        84,
-                        215,
-                        209,
-                        212,
-                        137,
-                        10,
-                        92,
-                        157,
-                        49,
-                        29,
-                        192,
-                        101,
-                        164,
-                        152,
-                        70,
-                        87,
-                        65,
-                        8,
-                        174,
-                        214,
-                        157,
-                        175,
-                        126,
-                        98,
-                        90,
-                        54,
-                        24,
-                        100,
-                        177,
-                        247,
-                        77,
-                        19,
-                        112,
-                        47,
-                        44,
-                        165,
-                        109,
-                        233,
-                        102,
-                        14,
-                        86,
-                        109,
-                        29,
-                        134,
-                        145,
-                        132,
-                        141
-                    ]
-                }
-            },
-            "addresses": {
-                "testToken": {
-                    "address": "2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ",
-                    "name": "Solana Test Token",
-                    "symbol": "SOLT",
-                    "decimals": 6
-                },
-                "testNFT": {
-                    "address": "BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna",
-                    "name": "Not a PUNK🎸",
-                    "symbol": "PUNK🎸",
-                    "decimals": 0
-                },
-                "testNFT2": {
-                    "address": "nftMANh29jbMboVnbYt1AUAWFP9N4Jnckr9Zeq85WUs",
-                    "name": "Not a PUNK2🎸",
-                    "symbol": "PUNK2🎸",
-                    "decimals": 0
-                }
-            }
-        },
-        "2": {
-            "rpcUrlTilt": "http://eth-devnet:8545",
-            "rpcUrlLocal": "http://localhost:8545",
-            "rpcPort": "8545",
-            "contracts": {
-                "coreEmitterAddress": "000000000000000000000000c89ce4735882c9f0f0fe26686c53074e09b0d550",
-                "coreNativeAddress": "0xC89Ce4735882C9F0f0FE26686c53074E09B0D550",
-                "tokenBridgeEmitterAddress": "0000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16",
-                "tokenBridgeNativeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16",
-                "nftBridgeEmitterAddress": "00000000000000000000000026b4afb60d6c903165150c6f0aa14f8016be4aec",
-                "nftBridgeAddress": "0x26b4afb60d6c903165150c6f0aa14f8016be4aec"
-            },
-            "accounts": {
-                "testWallet": {
-                    "public": "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1",
-                    "private": "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d",
-                    "mnemonic": "myth like bonus scare over problem client lizard pioneer submit female collect"
-                }
-            },
-            "addresses": {
-                "testToken": {
-                    "address": "0x2D8BE6BF0baA74e0A907016679CaE9190e80dD0A",
-                    "name": "Ethereum Test Token",
-                    "symbol": "TKN",
-                    "decimals": 18
-                },
-                "testNFT": {
-                    "address": "0x5b9b42d6e4B2e4Bf8d42Eba32D46918e10899B66",
-                    "name": "Not an APE 🐒",
-                    "symbol": "APE🐒",
-                    "decimals": 0
-                },
-                "testWETH": {
-                    "address": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E",
-                    "name": "Wrapped Ether",
-                    "symbol": "WETH",
-                    "decimals": 18
-                },
-                "testGA": {
-                    "address": "0xf19A2A01B70519f67ADb309a994Ec8c69A967E8b",
-                    "name": "Accountant Test Token",
-                    "symbol": "GA",
-                    "decimals": 18
-                }
-            }
-        },
-        "3": {
-            "rpcUrlTilt": "http://terra-terrad:1317",
-            "rpcUrlLocal": "http://localhost:1317",
-            "rpcPort": "1317",
-            "contracts": {
-                "coreEmitterAddress": "terra18vd8fpwxzck93qlwghaj6arh4p7c5n896xzem5",
-                "coreNativeAddress": "terra18vd8fpwxzck93qlwghaj6arh4p7c5n896xzem5",
-                "tokenBridgeEmitterAddress": "terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4",
-                "tokenBridgeNativeAddress": "terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4",
-                "nftBridgeEmitterAddress": "terra1plju286nnfj3z54wgcggd4enwaa9fgf5kgrgzl",
-                "nftBridgeNativeAddress": "terra10nmmwe8r3g99a9newtqa7a75xfgs2e8z87r2sf"
-            },
-            "accounts": {
-                "testWallet": {
-                    "public": "terra1x46rqay4d3cssq8gxxvqz8xt6nwlz4td20k38v",
-                    "private": "",
-                    "mnemonic": "notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius"
-                }
-            },
-            "addresses": {
-                "testToken": {
-                    "address": "terra13nkgqrfymug724h8pprpexqj9h629sa3ncw7sh",
-                    "name": "MOCK",
-                    "symbol": "MCK",
-                    "decimals": 6
-                },
-                "testNFT": {
-                    "address": "terra18dt935pdcn2ka6l0syy5gt20wa48n3mktvdvjj",
-                    "name": "MOCK",
-                    "symbol": "MCK",
-                    "decimals": 0
-                }
-            }
-        },
-        "4": {
-            "rpcUrlTilt": "http://eth-devnet2:8546",
-            "rpcUrlLocal": "http://localhost:8546",
-            "rpcPort": "8546",
-            "contracts": {
-                "coreEmitterAddress": "000000000000000000000000c89ce4735882c9f0f0fe26686c53074e09b0d550",
-                "coreNativeAddress": "0xC89Ce4735882C9F0f0FE26686c53074E09B0D550",
-                "tokenBridgeEmitterAddress": "0000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16",
-                "tokenBridgeNativeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16",
-                "nftBridgeEmitterAddress": "00000000000000000000000026b4afb60d6c903165150c6f0aa14f8016be4aec",
-                "nftBridgeAddress": "0x26b4afb60d6c903165150c6f0aa14f8016be4aec"
-            }
-        },
-        "8": {
-            "contracts": {
-                "tokenBridgeEmitterAddress": "8edf5b0e108c3a1a0a4b704cc89591f2ad8d50df24e991567e640ed720a94be2"
-            }
-        },
-        "15": {
-            "contracts": {
-                "tokenBridgeEmitterAddress": "e83c99874cb2d60921648a438606f5ffcf60c2e26ef13678b2e57fab3def6a30",
-                "nftBridgeEmitterAddress": "11aaad1c851095bf97ee1be9ed1161a47187aba8b71bf6821efac391d8ee95f7"
-            }
-        },
-        "18": {
-            "rpcUrlTilt": "http://terra2-terrad:1317",
-            "rpcUrlLocal": "http://localhost:1318",
-            "rpcPort": "1318",
-            "contracts": {
-                "coreEmitterAddress": "terra14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9ssrc8au",
-                "coreNativeAddress": "terra14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9ssrc8au",
-                "tokenBridgeEmitterAddress": "terra1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrquka9l6",
-                "tokenBridgeNativeAddress": "terra1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrquka9l6"
-            },
-            "accounts": {
-                "testWallet": {
-                    "public": "terra1x46rqay4d3cssq8gxxvqz8xt6nwlz4td20k38v",
-                    "private": "",
-                    "mnemonic": "notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius"
-                }
-            },
-            "addresses": {
-                "testToken": {
-                    "address": "terra1zwv6feuzhy6a9wekh96cd57lsarmqlwxdypdsplw6zhfncqw6ftqynf7kp",
-                    "name": "MOCK",
-                    "symbol": "MCK",
-                    "decimals": 6
-                }
-            }
-        },
-        "3104": {
-            "rpcUrlTilt": "http://wormchain:1317",
-            "rpcUrlLocal": "http://localhost:1319",
-            "tendermintUrlTilt": "http://wormchain:26657",
-            "tendermintUrlLocal": "http://localhost:26659",
-            "rpcPort": "1317",
-            "tendermintPort": "26657",
-            "contracts": {
-                "coreEmitterAddress": "wormhole1ap5vgur5zlgys8whugfegnn43emka567dtq0jl",
-                "coreNativeAddress": "wormhole1ap5vgur5zlgys8whugfegnn43emka567dtq0jl",
-                "tokenBridgeEmitterAddress": "wormhole1zugu6cajc4z7ue29g9wnes9a5ep9cs7yu7rn3z",
-                "tokenBridgeNativeAddress": "wormhole1zugu6cajc4z7ue29g9wnes9a5ep9cs7yu7rn3z",
-                "accountingNativeAddress": "wormhole14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9srrg465"
-            },
-            "accounts": {
-                "wormchainNodeOfGuardian0": {
-                    "address": "C10820983F33456CE7BEB3A046F5A83FA34F027D",
-                    "addressBase64": "wQggmD8zRWznvrOgRvWoP6NPAn0=",
-                    "addressWormhole": "000000000000000000000000c10820983f33456ce7beb3a046f5a83fa34f027d",
-                    "public": "wormhole1cyyzpxplxdzkeea7kwsydadg87357qna3zg3tq",
-                    "privateHex": "48d23cc417a30674e907a2403f109f082d92e197823d02e6a423c6aeb8e41204",
-                    "cosmos.crypto.secp256k1.PubKey": "AuwYyCUBxQiBGSUWebU46c+OrlApVsyGLHd4qhSDZeiG",
-                    "tendermint/PrivKeyEd25519": "DONGe0wxovG1ZuCQ1iMbyBCW/hG5UeKz6ZFfhdZYznRSC48Lc1nwhUwXzHtXfwAOY0mO3mhTy4CMwPeYFvBZ1A==",
-                    "mnemonic": "notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius"
-                },
-                "wormchainValidator0": {
-                    "public": "wormholevaloper1cyyzpxplxdzkeea7kwsydadg87357qna87hzv8",
-                    "tendermint/PubKeyEd25519": "fnfoo/C+i+Ng1J8vct6wfvrTS9JeNIG5UeO87ZHKMkY=",
-                    "tendermint/PrivKeyEd25519": "Zb3gQZSd8qNMyXUQdKmeqM/SSYeVDD80S4XPEsCAgPN+d+ij8L6L42DUny9y3rB++tNL0l40gblR47ztkcoyRg=="
-                },
-                "wormchainNodeOfGuardian1": {
-                    "address": "701C475B19A3F68D3FDEBF09591487FACEF2D636",
-                    "addressWormhole": "000000000000000000000000701c475b19a3f68d3fdebf09591487facef2d636",
-                    "addressBase64": "cBxHWxmj9o0/3r8JWRSH+s7y1jY=",
-                    "public": "wormhole1wqwywkce50mg6077huy4j9y8lt80943ks5udzr",
-                    "privateHex": "7095b73fa951fd117d54f3bca130b8088625db2d60d94d4f064791dc1a792b29",
-                    "cosmos.crypto.secp256k1.PubKey": "ApJi/CY2RGyzA5cQtDwU9c+o7T8OE+SjrgcG5PwLMjTP",
-                    "tendermint/PrivKeyEd25519": "TTdzb3XLJbSXP/5VhzPJCWysCDDH2hEXTqdvLI6RYk7rxPwzCXTprp2ZEfSCfQswYgUUQgO9JKzbAtfyeK2G1A==",
-                    "mnemonic": "maple pudding enjoy pole real rabbit soft make square city wrestle area aisle dwarf spike voice over still post lend genius bitter exit shoot"
-                },
-                "wormchainValidator1": {
-                    "public": "wormholevaloper1wqwywkce50mg6077huy4j9y8lt80943kxgr79y",
-                    "tendermint/PubKeyEd25519": "Zcujkt1sXRWWLfhgxLAm/Q+ioLn4wFim0OnGPLlCG0I=",
-                    "tendermint/PrivKeyEd25519": "SGWIYI3BgC/dxNOk1gYx6LpChAKqWGtAfZSx0SDFWuhly6OS3WxdFZYt+GDEsCb9D6KgufjAWKbQ6cY8uUIbQg=="
-                }
-            },
-            "addresses": {
-                "native": {
-                    "address": "uworm",
-                    "addressWormhole": "010c0ded78f1b69ec7b79b9ee592fbbcacebc97db1c695220a833135bfa74824",
-                    "denom": "uworm",
-                    "name": "worm",
-                    "symbol": "worm",
-                    "decimals": 0
-                },
-                "testToken": {
-                    "address": "wormhole1zwv6feuzhy6a9wekh96cd57lsarmqlwxdypdsplw6zhfncqw6ftqhnev3f",
-                    "addressWormhole": "003f822e9066cfea09b9ce1247e8f79a86a24dda2d8b3d76a608ae7583220411",
-                    "name": "MOCK",
-                    "symbol": "MCK",
-                    "decimals": 6
-                }
-            }
-        },
-        "22": {
-            "contracts": {
-                "tokenBridgeEmitterAddress": "0000000000000000000000000000000000000000000000000000000000000001",
-                "nftBridgeEmitterAddress": "0000000000000000000000000000000000000000000000000000000000000002"
-            }
+  "global": {
+    "governanceChainId": "1",
+    "governanceEmitterAddress": "0000000000000000000000000000000000000000000000000000000000000004"
+  },
+  "urls": {
+    "guardianSetLocalUrl": "http://localhost:7071/v1/guardianset/current"
+  },
+  "chains": {
+    "1": {
+      "rpcUrlTilt": "http://solana-devnet:8899",
+      "rpcUrlLocal": "http://localhost:8899",
+      "rpcPort": "8899",
+      "contracts": {
+        "coreEmitterAddress": "VVStPLdubtbBUnMDhC3kt8fM3AE6NLRv73TAd3FCAen",
+        "coreNativeAddress": "Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o",
+        "tokenBridgeEmitterAddress": "ENG1wQ7CQKH8ibAJ1hSLmJgL9Ucg6DRDbj752ZAfidLA",
+        "tokenBridgeNativeAddress": "B6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE",
+        "nftBridgeEmitterAddress": "BABAnMBgBELTQnHabZqYa1thHKp834RDvud4rJ8EUr3k",
+        "nftBridgeNativeAddress": "NFTWqJR8YnRVqPDvTJrYuLrQDitTG5AScqbeghi4zSA"
+      },
+      "accounts": {
+        "testWallet": {
+          "public": "6sbzC1eH4FTujJXWj51eQe25cYvr4xfXbJ1vAj7j2k5J",
+          "private": [
+            14, 173, 153, 4, 176, 224, 201, 111, 32, 237, 183, 185, 159, 247,
+            22, 161, 89, 84, 215, 209, 212, 137, 10, 92, 157, 49, 29, 192, 101,
+            164, 152, 70, 87, 65, 8, 174, 214, 157, 175, 126, 98, 90, 54, 24,
+            100, 177, 247, 77, 19, 112, 47, 44, 165, 109, 233, 102, 14, 86, 109,
+            29, 134, 145, 132, 141
+          ]
         }
+      },
+      "addresses": {
+        "testToken": {
+          "address": "2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ",
+          "name": "Solana Test Token",
+          "symbol": "SOLT",
+          "decimals": 6
+        },
+        "testNFT": {
+          "address": "BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna",
+          "name": "Not a PUNK🎸",
+          "symbol": "PUNK🎸",
+          "decimals": 0
+        },
+        "testNFT2": {
+          "address": "nftMANh29jbMboVnbYt1AUAWFP9N4Jnckr9Zeq85WUs",
+          "name": "Not a PUNK2🎸",
+          "symbol": "PUNK2🎸",
+          "decimals": 0
+        }
+      }
     },
-    "gancheDefaults": [
-        {
-            "name": "0",
-            "public": "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1",
-            "private": "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d"
-        },
-        {
-            "name": "1",
-            "public": "0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0",
-            "private": "0x6cbed15c793ce57650b9877cf6fa156fbef513c4e6134f022a85b1ffdd59b2a1"
-        },
-        {
-            "name": "2",
-            "public": "0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b",
-            "private": "0x6370fd033278c143179d81c5526140625662b8daa446c22ee2d73db3707e620c"
-        },
-        {
-            "name": "3",
-            "public": "0xE11BA2b4D45Eaed5996Cd0823791E0C93114882d",
-            "private": "0x646f1ce2fdad0e6deeeb5c7e8e5543bdde65e86029e2fd9fc169899c440a7913"
-        },
-        {
-            "name": "4",
-            "public": "0xd03ea8624C8C5987235048901fB614fDcA89b117",
-            "private": "0xadd53f9a7e588d003326d1cbf9e4a43c061aadd9bc938c843a79e7b4fd2ad743"
-        },
-        {
-            "name": "5",
-            "public": "0x95cED938F7991cd0dFcb48F0a06a40FA1aF46EBC",
-            "private": "0x395df67f0c2d2d9fe1ad08d1bc8b6627011959b79c53d7dd6a3536a33ab8a4fd"
-        },
-        {
-            "name": "6",
-            "public": "0x3E5e9111Ae8eB78Fe1CC3bb8915d5D461F3Ef9A9",
-            "private": "0xe485d098507f54e7733a205420dfddbe58db035fa577fc294ebd14db90767a52"
-        },
-        {
-            "name": "7",
-            "public": "0x28a8746e75304c0780E011BEd21C72cD78cd535E",
-            "private": "0xa453611d9419d0e56f499079478fd72c37b251a94bfde4d19872c44cf65386e3"
-        },
-        {
-            "name": "8",
-            "public": "0xACa94ef8bD5ffEE41947b4585a84BdA5a3d3DA6E",
-            "private": "0x829e924fdf021ba3dbbc4225edfece9aca04b929d6e75613329ca6f1d31c0bb4"
-        },
-        {
-            "name": "9",
-            "public": "0x1dF62f291b2E969fB0849d99D9Ce41e2F137006e",
-            "private": "0xb0057716d5917badaf911b193b12b910811c1497b5bada8d7711f758981c3773"
+    "2": {
+      "rpcUrlTilt": "http://eth-devnet:8545",
+      "rpcUrlLocal": "http://localhost:8545",
+      "rpcPort": "8545",
+      "contracts": {
+        "coreEmitterAddress": "000000000000000000000000c89ce4735882c9f0f0fe26686c53074e09b0d550",
+        "coreNativeAddress": "0xC89Ce4735882C9F0f0FE26686c53074E09B0D550",
+        "tokenBridgeEmitterAddress": "0000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16",
+        "tokenBridgeNativeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16",
+        "nftBridgeEmitterAddress": "00000000000000000000000026b4afb60d6c903165150c6f0aa14f8016be4aec",
+        "nftBridgeAddress": "0x26b4afb60d6c903165150c6f0aa14f8016be4aec"
+      },
+      "accounts": {
+        "testWallet": {
+          "public": "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1",
+          "private": "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d",
+          "mnemonic": "myth like bonus scare over problem client lizard pioneer submit female collect"
         }
-    ],
-    "devnetGuardians": [
-        {
-            "name": "guardian-0",
-            "public": "0xbeFA429d57cD18b7F8A4d91A2da9AB4AF05d0FBe",
-            "private": "cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0"
-        },
-        {
-            "name": "guardian-1",
-            "public": "0x88D7D8B32a9105d228100E72dFFe2Fae0705D31c",
-            "private": "c3b2e45c422a1602333a64078aeb42637370b0f48fe385f9cfa6ad54a8e0c47e"
-        },
-        {
-            "name": "guardian-2",
-            "public": "0x58076F561CC62A47087B567C86f986426dFCD000",
-            "private": "9f790d3f08bc4b5cd910d4278f3deb406e57bb5e924906ccd52052bb078ccd47"
-        },
-        {
-            "name": "guardian-3",
-            "public": "0xBd6e9833490F8fA87c733A183CD076a6cBD29074",
-            "private": "b20cc49d6f2c82a5e6519015fc18aa3e562867f85f872c58f1277cfbd2a0c8e4"
-        },
-        {
-            "name": "guardian-4",
-            "public": "0xb853FCF0a5C78C1b56D15fCE7a154e6ebe9ED7a2",
-            "private": "eded5a2fdcb5bbbfa5b07f2a91393813420e7ac30a72fc935b6df36f8294b855"
-        },
-        {
-            "name": "guardian-5",
-            "public": "0xAF3503dBD2E37518ab04D7CE78b630F98b15b78a",
-            "private": "00d39587c3556f289677a837c7f3c0817cb7541ce6e38a243a4bdc761d534c5e"
-        },
-        {
-            "name": "guardian-6",
-            "public": "0x785632deA5609064803B1c8EA8bB2c77a6004Bd1",
-            "private": "da534d61a8da77b232f3a2cee55c0125e2b3e33a5cd8247f3fe9e72379445c3b"
-        },
-        {
-            "name": "guardian-7",
-            "public": "0x09a281a698C0F5BA31f158585B41F4f33659e54D",
-            "private": "cdbabfc2118eb00bc62c88845f3bbd03cb67a9e18a055101588ca9b36387006c"
-        },
-        {
-            "name": "guardian-8",
-            "public": "0x3178443AB76a60E21690DBfB17f7F59F09Ae3Ea1",
-            "private": "c83d36423820e7350428dc4abe645cb2904459b7d7128adefe16472fdac397ba"
-        },
-        {
-            "name": "guardian-9",
-            "public": "0x647ec26ae49b14060660504f4DA1c2059E1C5Ab6",
-            "private": "1cbf4e1388b81c9020500fefc83a7a81f707091bb899074db1bfce4537428112"
-        },
-        {
-            "name": "guardian-10",
-            "public": "0x810AC3D8E1258Bd2F004a94Ca0cd4c68Fc1C0611",
-            "private": "17646a6ba14a541957fc7112cc973c0b3f04fce59484a92c09bb45a0b57eb740"
-        },
-        {
-            "name": "guardian-11",
-            "public": "0x80610e96d645b12f47ae5cf4546b18538739e90F",
-            "private": "eb94ff04accbfc8195d44b45e7c7da4c6993b2fbbfc4ef166a7675a905df9891"
-        },
-        {
-            "name": "guardian-12",
-            "public": "0x2edb0D8530E31A218E72B9480202AcBaeB06178d",
-            "private": "053a6527124b309d914a47f5257a995e9b0ad17f14659f90ed42af5e6e262b6a"
-        },
-        {
-            "name": "guardian-13",
-            "public": "0xa78858e5e5c4705CdD4B668FFe3Be5bae4867c9D",
-            "private": "3fbf1e46f6da69e62aed5670f279e818889aa7d8f1beb7fd730770fd4f8ea3d7"
-        },
-        {
-            "name": "guardian-14",
-            "public": "0x5Efe3A05Efc62D60e1D19fAeB56A80223CDd3472",
-            "private": "53b05697596ba04067e40be8100c9194cbae59c90e7870997de57337497172e9"
-        },
-        {
-            "name": "guardian-15",
-            "public": "0xD791b7D32C05aBB1cc00b6381FA0c4928f0c56fC",
-            "private": "4e95cb2ff3f7d5e963631ad85c28b1b79cb370f21c67cbdd4c2ffb0bf664aa06"
-        },
-        {
-            "name": "guardian-16",
-            "public": "0x14Bc029B8809069093D712A3fd4DfAb31963597e",
-            "private": "01b8c448ce2c1d43cfc5938d3a57086f88e3dc43bb8b08028ecb7a7924f4676f"
-        },
-        {
-            "name": "guardian-17",
-            "public": "0x246Ab29FC6EBeDf2D392a51ab2Dc5C59d0902A03",
-            "private": "1db31a6ba3bcd54d2e8a64f8a2415064265d291593450c6eb7e9a6a986bd9400"
-        },
-        {
-            "name": "guardian-18",
-            "public": "0x132A84dFD920b35a3D0BA5f7A0635dF298F9033e",
-            "private": "70d8f1c9534a0ab61a020366b831a494057a289441c07be67e4288c44bc6cd5d"
+      },
+      "addresses": {
+        "testToken": {
+          "address": "0x2D8BE6BF0baA74e0A907016679CaE9190e80dD0A",
+          "name": "Ethereum Test Token",
+          "symbol": "TKN",
+          "decimals": 18
+        },
+        "testNFT": {
+          "address": "0x5b9b42d6e4B2e4Bf8d42Eba32D46918e10899B66",
+          "name": "Not an APE 🐒",
+          "symbol": "APE🐒",
+          "decimals": 0
+        },
+        "testWETH": {
+          "address": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E",
+          "name": "Wrapped Ether",
+          "symbol": "WETH",
+          "decimals": 18
+        },
+        "testGA": {
+          "address": "0x4cFB3F70BF6a80397C2e634e5bDd85BC0bb189EE",
+          "name": "Accountant Test Token",
+          "symbol": "GA",
+          "decimals": 18
+        }
+      }
+    },
+    "3": {
+      "rpcUrlTilt": "http://terra-terrad:1317",
+      "rpcUrlLocal": "http://localhost:1317",
+      "rpcPort": "1317",
+      "contracts": {
+        "coreEmitterAddress": "terra18vd8fpwxzck93qlwghaj6arh4p7c5n896xzem5",
+        "coreNativeAddress": "terra18vd8fpwxzck93qlwghaj6arh4p7c5n896xzem5",
+        "tokenBridgeEmitterAddress": "terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4",
+        "tokenBridgeNativeAddress": "terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4",
+        "nftBridgeEmitterAddress": "terra1plju286nnfj3z54wgcggd4enwaa9fgf5kgrgzl",
+        "nftBridgeNativeAddress": "terra10nmmwe8r3g99a9newtqa7a75xfgs2e8z87r2sf"
+      },
+      "accounts": {
+        "testWallet": {
+          "public": "terra1x46rqay4d3cssq8gxxvqz8xt6nwlz4td20k38v",
+          "private": "",
+          "mnemonic": "notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius"
+        }
+      },
+      "addresses": {
+        "testToken": {
+          "address": "terra13nkgqrfymug724h8pprpexqj9h629sa3ncw7sh",
+          "name": "MOCK",
+          "symbol": "MCK",
+          "decimals": 6
+        },
+        "testNFT": {
+          "address": "terra18dt935pdcn2ka6l0syy5gt20wa48n3mktvdvjj",
+          "name": "MOCK",
+          "symbol": "MCK",
+          "decimals": 0
         }
-    ]
+      }
+    },
+    "4": {
+      "rpcUrlTilt": "http://eth-devnet2:8546",
+      "rpcUrlLocal": "http://localhost:8546",
+      "rpcPort": "8546",
+      "contracts": {
+        "coreEmitterAddress": "000000000000000000000000c89ce4735882c9f0f0fe26686c53074e09b0d550",
+        "coreNativeAddress": "0xC89Ce4735882C9F0f0FE26686c53074E09B0D550",
+        "tokenBridgeEmitterAddress": "0000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16",
+        "tokenBridgeNativeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16",
+        "nftBridgeEmitterAddress": "00000000000000000000000026b4afb60d6c903165150c6f0aa14f8016be4aec",
+        "nftBridgeAddress": "0x26b4afb60d6c903165150c6f0aa14f8016be4aec"
+      }
+    },
+    "8": {
+      "contracts": {
+        "tokenBridgeEmitterAddress": "8edf5b0e108c3a1a0a4b704cc89591f2ad8d50df24e991567e640ed720a94be2"
+      }
+    },
+    "15": {
+      "contracts": {
+        "tokenBridgeEmitterAddress": "e83c99874cb2d60921648a438606f5ffcf60c2e26ef13678b2e57fab3def6a30",
+        "nftBridgeEmitterAddress": "11aaad1c851095bf97ee1be9ed1161a47187aba8b71bf6821efac391d8ee95f7"
+      }
+    },
+    "18": {
+      "rpcUrlTilt": "http://terra2-terrad:1317",
+      "rpcUrlLocal": "http://localhost:1318",
+      "rpcPort": "1318",
+      "contracts": {
+        "coreEmitterAddress": "terra14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9ssrc8au",
+        "coreNativeAddress": "terra14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9ssrc8au",
+        "tokenBridgeEmitterAddress": "terra1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrquka9l6",
+        "tokenBridgeNativeAddress": "terra1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrquka9l6"
+      },
+      "accounts": {
+        "testWallet": {
+          "public": "terra1x46rqay4d3cssq8gxxvqz8xt6nwlz4td20k38v",
+          "private": "",
+          "mnemonic": "notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius"
+        }
+      },
+      "addresses": {
+        "testToken": {
+          "address": "terra1zwv6feuzhy6a9wekh96cd57lsarmqlwxdypdsplw6zhfncqw6ftqynf7kp",
+          "name": "MOCK",
+          "symbol": "MCK",
+          "decimals": 6
+        }
+      }
+    },
+    "21": {
+      "contracts": {
+        "tokenBridgeEmitterAddress": "b99d994643e71bc6f460e33de1cc5167deedbac1374b9b2158c577d4c114037a"
+      }
+    },
+    "22": {
+      "contracts": {
+        "tokenBridgeEmitterAddress": "0000000000000000000000000000000000000000000000000000000000000001",
+        "nftBridgeEmitterAddress": "0000000000000000000000000000000000000000000000000000000000000002"
+      }
+    },
+    "3104": {
+      "rpcUrlTilt": "http://wormchain:1317",
+      "rpcUrlLocal": "http://localhost:1319",
+      "tendermintUrlTilt": "http://wormchain:26657",
+      "tendermintUrlLocal": "http://localhost:26659",
+      "rpcPort": "1317",
+      "tendermintPort": "26657",
+      "contracts": {
+        "coreEmitterAddress": "wormhole1ap5vgur5zlgys8whugfegnn43emka567dtq0jl",
+        "coreNativeAddress": "wormhole1ap5vgur5zlgys8whugfegnn43emka567dtq0jl",
+        "tokenBridgeEmitterAddress": "wormhole1zugu6cajc4z7ue29g9wnes9a5ep9cs7yu7rn3z",
+        "tokenBridgeNativeAddress": "wormhole1zugu6cajc4z7ue29g9wnes9a5ep9cs7yu7rn3z",
+        "accountingNativeAddress": "wormhole14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9srrg465"
+      },
+      "accounts": {
+        "wormchainNodeOfGuardian0": {
+          "address": "C10820983F33456CE7BEB3A046F5A83FA34F027D",
+          "addressBase64": "wQggmD8zRWznvrOgRvWoP6NPAn0=",
+          "addressWormhole": "000000000000000000000000c10820983f33456ce7beb3a046f5a83fa34f027d",
+          "public": "wormhole1cyyzpxplxdzkeea7kwsydadg87357qna3zg3tq",
+          "privateHex": "48d23cc417a30674e907a2403f109f082d92e197823d02e6a423c6aeb8e41204",
+          "cosmos.crypto.secp256k1.PubKey": "AuwYyCUBxQiBGSUWebU46c+OrlApVsyGLHd4qhSDZeiG",
+          "tendermint/PrivKeyEd25519": "DONGe0wxovG1ZuCQ1iMbyBCW/hG5UeKz6ZFfhdZYznRSC48Lc1nwhUwXzHtXfwAOY0mO3mhTy4CMwPeYFvBZ1A==",
+          "mnemonic": "notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius"
+        },
+        "wormchainValidator0": {
+          "public": "wormholevaloper1cyyzpxplxdzkeea7kwsydadg87357qna87hzv8",
+          "tendermint/PubKeyEd25519": "fnfoo/C+i+Ng1J8vct6wfvrTS9JeNIG5UeO87ZHKMkY=",
+          "tendermint/PrivKeyEd25519": "Zb3gQZSd8qNMyXUQdKmeqM/SSYeVDD80S4XPEsCAgPN+d+ij8L6L42DUny9y3rB++tNL0l40gblR47ztkcoyRg=="
+        },
+        "wormchainNodeOfGuardian1": {
+          "address": "701C475B19A3F68D3FDEBF09591487FACEF2D636",
+          "addressWormhole": "000000000000000000000000701c475b19a3f68d3fdebf09591487facef2d636",
+          "addressBase64": "cBxHWxmj9o0/3r8JWRSH+s7y1jY=",
+          "public": "wormhole1wqwywkce50mg6077huy4j9y8lt80943ks5udzr",
+          "privateHex": "7095b73fa951fd117d54f3bca130b8088625db2d60d94d4f064791dc1a792b29",
+          "cosmos.crypto.secp256k1.PubKey": "ApJi/CY2RGyzA5cQtDwU9c+o7T8OE+SjrgcG5PwLMjTP",
+          "tendermint/PrivKeyEd25519": "TTdzb3XLJbSXP/5VhzPJCWysCDDH2hEXTqdvLI6RYk7rxPwzCXTprp2ZEfSCfQswYgUUQgO9JKzbAtfyeK2G1A==",
+          "mnemonic": "maple pudding enjoy pole real rabbit soft make square city wrestle area aisle dwarf spike voice over still post lend genius bitter exit shoot"
+        },
+        "wormchainValidator1": {
+          "public": "wormholevaloper1wqwywkce50mg6077huy4j9y8lt80943kxgr79y",
+          "tendermint/PubKeyEd25519": "Zcujkt1sXRWWLfhgxLAm/Q+ioLn4wFim0OnGPLlCG0I=",
+          "tendermint/PrivKeyEd25519": "SGWIYI3BgC/dxNOk1gYx6LpChAKqWGtAfZSx0SDFWuhly6OS3WxdFZYt+GDEsCb9D6KgufjAWKbQ6cY8uUIbQg=="
+        }
+      },
+      "addresses": {
+        "native": {
+          "address": "uworm",
+          "addressWormhole": "010c0ded78f1b69ec7b79b9ee592fbbcacebc97db1c695220a833135bfa74824",
+          "denom": "uworm",
+          "name": "worm",
+          "symbol": "worm",
+          "decimals": 0
+        },
+        "testToken": {
+          "address": "wormhole1zwv6feuzhy6a9wekh96cd57lsarmqlwxdypdsplw6zhfncqw6ftqhnev3f",
+          "addressWormhole": "003f822e9066cfea09b9ce1247e8f79a86a24dda2d8b3d76a608ae7583220411",
+          "name": "MOCK",
+          "symbol": "MCK",
+          "decimals": 6
+        }
+      }
+    }
+  },
+  "gancheDefaults": [
+    {
+      "name": "0",
+      "public": "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1",
+      "private": "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d"
+    },
+    {
+      "name": "1",
+      "public": "0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0",
+      "private": "0x6cbed15c793ce57650b9877cf6fa156fbef513c4e6134f022a85b1ffdd59b2a1"
+    },
+    {
+      "name": "2",
+      "public": "0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b",
+      "private": "0x6370fd033278c143179d81c5526140625662b8daa446c22ee2d73db3707e620c"
+    },
+    {
+      "name": "3",
+      "public": "0xE11BA2b4D45Eaed5996Cd0823791E0C93114882d",
+      "private": "0x646f1ce2fdad0e6deeeb5c7e8e5543bdde65e86029e2fd9fc169899c440a7913"
+    },
+    {
+      "name": "4",
+      "public": "0xd03ea8624C8C5987235048901fB614fDcA89b117",
+      "private": "0xadd53f9a7e588d003326d1cbf9e4a43c061aadd9bc938c843a79e7b4fd2ad743"
+    },
+    {
+      "name": "5",
+      "public": "0x95cED938F7991cd0dFcb48F0a06a40FA1aF46EBC",
+      "private": "0x395df67f0c2d2d9fe1ad08d1bc8b6627011959b79c53d7dd6a3536a33ab8a4fd"
+    },
+    {
+      "name": "6",
+      "public": "0x3E5e9111Ae8eB78Fe1CC3bb8915d5D461F3Ef9A9",
+      "private": "0xe485d098507f54e7733a205420dfddbe58db035fa577fc294ebd14db90767a52"
+    },
+    {
+      "name": "7",
+      "public": "0x28a8746e75304c0780E011BEd21C72cD78cd535E",
+      "private": "0xa453611d9419d0e56f499079478fd72c37b251a94bfde4d19872c44cf65386e3"
+    },
+    {
+      "name": "8",
+      "public": "0xACa94ef8bD5ffEE41947b4585a84BdA5a3d3DA6E",
+      "private": "0x829e924fdf021ba3dbbc4225edfece9aca04b929d6e75613329ca6f1d31c0bb4"
+    },
+    {
+      "name": "9",
+      "public": "0x1dF62f291b2E969fB0849d99D9Ce41e2F137006e",
+      "private": "0xb0057716d5917badaf911b193b12b910811c1497b5bada8d7711f758981c3773"
+    },
+    {
+      "name": "10",
+      "public": "0x610bb1573d1046fcb8a70bbbd395754cd57c2b60",
+      "private": "0x77c5495fbb039eed474fc940f29955ed0531693cc9212911efd35dff0373153f"
+    }
+  ],
+  "devnetGuardians": [
+    {
+      "name": "guardian-0",
+      "public": "0xbeFA429d57cD18b7F8A4d91A2da9AB4AF05d0FBe",
+      "private": "cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0"
+    },
+    {
+      "name": "guardian-1",
+      "public": "0x88D7D8B32a9105d228100E72dFFe2Fae0705D31c",
+      "private": "c3b2e45c422a1602333a64078aeb42637370b0f48fe385f9cfa6ad54a8e0c47e"
+    },
+    {
+      "name": "guardian-2",
+      "public": "0x58076F561CC62A47087B567C86f986426dFCD000",
+      "private": "9f790d3f08bc4b5cd910d4278f3deb406e57bb5e924906ccd52052bb078ccd47"
+    },
+    {
+      "name": "guardian-3",
+      "public": "0xBd6e9833490F8fA87c733A183CD076a6cBD29074",
+      "private": "b20cc49d6f2c82a5e6519015fc18aa3e562867f85f872c58f1277cfbd2a0c8e4"
+    },
+    {
+      "name": "guardian-4",
+      "public": "0xb853FCF0a5C78C1b56D15fCE7a154e6ebe9ED7a2",
+      "private": "eded5a2fdcb5bbbfa5b07f2a91393813420e7ac30a72fc935b6df36f8294b855"
+    },
+    {
+      "name": "guardian-5",
+      "public": "0xAF3503dBD2E37518ab04D7CE78b630F98b15b78a",
+      "private": "00d39587c3556f289677a837c7f3c0817cb7541ce6e38a243a4bdc761d534c5e"
+    },
+    {
+      "name": "guardian-6",
+      "public": "0x785632deA5609064803B1c8EA8bB2c77a6004Bd1",
+      "private": "da534d61a8da77b232f3a2cee55c0125e2b3e33a5cd8247f3fe9e72379445c3b"
+    },
+    {
+      "name": "guardian-7",
+      "public": "0x09a281a698C0F5BA31f158585B41F4f33659e54D",
+      "private": "cdbabfc2118eb00bc62c88845f3bbd03cb67a9e18a055101588ca9b36387006c"
+    },
+    {
+      "name": "guardian-8",
+      "public": "0x3178443AB76a60E21690DBfB17f7F59F09Ae3Ea1",
+      "private": "c83d36423820e7350428dc4abe645cb2904459b7d7128adefe16472fdac397ba"
+    },
+    {
+      "name": "guardian-9",
+      "public": "0x647ec26ae49b14060660504f4DA1c2059E1C5Ab6",
+      "private": "1cbf4e1388b81c9020500fefc83a7a81f707091bb899074db1bfce4537428112"
+    },
+    {
+      "name": "guardian-10",
+      "public": "0x810AC3D8E1258Bd2F004a94Ca0cd4c68Fc1C0611",
+      "private": "17646a6ba14a541957fc7112cc973c0b3f04fce59484a92c09bb45a0b57eb740"
+    },
+    {
+      "name": "guardian-11",
+      "public": "0x80610e96d645b12f47ae5cf4546b18538739e90F",
+      "private": "eb94ff04accbfc8195d44b45e7c7da4c6993b2fbbfc4ef166a7675a905df9891"
+    },
+    {
+      "name": "guardian-12",
+      "public": "0x2edb0D8530E31A218E72B9480202AcBaeB06178d",
+      "private": "053a6527124b309d914a47f5257a995e9b0ad17f14659f90ed42af5e6e262b6a"
+    },
+    {
+      "name": "guardian-13",
+      "public": "0xa78858e5e5c4705CdD4B668FFe3Be5bae4867c9D",
+      "private": "3fbf1e46f6da69e62aed5670f279e818889aa7d8f1beb7fd730770fd4f8ea3d7"
+    },
+    {
+      "name": "guardian-14",
+      "public": "0x5Efe3A05Efc62D60e1D19fAeB56A80223CDd3472",
+      "private": "53b05697596ba04067e40be8100c9194cbae59c90e7870997de57337497172e9"
+    },
+    {
+      "name": "guardian-15",
+      "public": "0xD791b7D32C05aBB1cc00b6381FA0c4928f0c56fC",
+      "private": "4e95cb2ff3f7d5e963631ad85c28b1b79cb370f21c67cbdd4c2ffb0bf664aa06"
+    },
+    {
+      "name": "guardian-16",
+      "public": "0x14Bc029B8809069093D712A3fd4DfAb31963597e",
+      "private": "01b8c448ce2c1d43cfc5938d3a57086f88e3dc43bb8b08028ecb7a7924f4676f"
+    },
+    {
+      "name": "guardian-17",
+      "public": "0x246Ab29FC6EBeDf2D392a51ab2Dc5C59d0902A03",
+      "private": "1db31a6ba3bcd54d2e8a64f8a2415064265d291593450c6eb7e9a6a986bd9400"
+    },
+    {
+      "name": "guardian-18",
+      "public": "0x132A84dFD920b35a3D0BA5f7A0635dF298F9033e",
+      "private": "70d8f1c9534a0ab61a020366b831a494057a289441c07be67e4288c44bc6cd5d"
+    }
+  ]
 }

+ 23 - 20
scripts/guardian-set-init.sh

@@ -10,7 +10,7 @@ addressesJson="./scripts/devnet-consts.json"
 
 # working files for accumulating state
 envFile="./scripts/.env.hex" # for generic hex data, for solana, terra, etc
-ethFile="./scripts/.env.0x" # for "0x" prefixed data, for ethereum scripts
+ethFile="./scripts/.env.0x"  # for "0x" prefixed data, for ethereum scripts
 
 # copy the eth defaults so we can override just the things we need
 cp ./ethereum/.env.test $ethFile
@@ -59,7 +59,7 @@ guardiansPublicEth=$(jq -c --argjson lastIndex $numGuardians '.devnetGuardians[:
 # guardiansPublicHex does not have a leading "0x", just hex strings.
 guardiansPublicHex=$(jq -c --argjson lastIndex $numGuardians '.devnetGuardians[:$lastIndex] | [.[].public[2:]]' $addressesJson)
 # also make a CSV string of the hex addresses, so the client scripts that need that format don't have to.
-guardiansPublicHexCSV=$(echo ${guardiansPublicHex} | jq --raw-output -c  '. | join(",")')
+guardiansPublicHexCSV=$(echo ${guardiansPublicHex} | jq --raw-output -c '. | join(",")')
 
 # write the lists of addresses to the env files
 initSigners="INIT_SIGNERS"
@@ -73,7 +73,7 @@ echo "generating guardian set keys"
 # create an array of strings containing the private keys of the devnet guardians in the guardianset
 guardiansPrivate=$(jq -c --argjson lastIndex $numGuardians '.devnetGuardians[:$lastIndex] | [.[].private]' $addressesJson)
 # create a CSV string with the private keys of the guardians in the guardianset, that will be used to create registration VAAs
-guardiansPrivateCSV=$( echo ${guardiansPrivate} | jq --raw-output -c  '. | join(",")')
+guardiansPrivateCSV=$(echo ${guardiansPrivate} | jq --raw-output -c '. | join(",")')
 
 # write the lists of keys to the env files
 upsert_env_file $ethFile "INIT_SIGNERS_KEYS_JSON" $guardiansPrivate
@@ -90,8 +90,9 @@ bscTokenBridge=$(jq --raw-output '.chains."4".contracts.tokenBridgeEmitterAddres
 algoTokenBridge=$(jq --raw-output '.chains."8".contracts.tokenBridgeEmitterAddress' $addressesJson)
 nearTokenBridge=$(jq --raw-output '.chains."15".contracts.tokenBridgeEmitterAddress' $addressesJson)
 terra2TokenBridge=$(jq --raw-output '.chains."18".contracts.tokenBridgeEmitterAddress' $addressesJson)
-wormchainTokenBridge=$(jq --raw-output '.chains."3104".contracts.tokenBridgeEmitterAddress' $addressesJson)
+suiTokenBridge=$(jq --raw-output '.chains."21".contracts.tokenBridgeEmitterAddress' $addressesJson)
 aptosTokenBridge=$(jq --raw-output '.chains."22".contracts.tokenBridgeEmitterAddress' $addressesJson)
+wormchainTokenBridge=$(jq --raw-output '.chains."3104".contracts.tokenBridgeEmitterAddress' $addressesJson)
 
 solNFTBridge=$(jq --raw-output '.chains."1".contracts.nftBridgeEmitterAddress' $addressesJson)
 ethNFTBridge=$(jq --raw-output '.chains."2".contracts.nftBridgeEmitterAddress' $addressesJson)
@@ -102,14 +103,15 @@ aptosNFTBridge=$(jq --raw-output '.chains."22".contracts.nftBridgeEmitterAddress
 # 4) create token bridge registration VAAs
 # invoke CLI commands to create registration VAAs
 solTokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c solana -a ${solTokenBridge} -g ${guardiansPrivateCSV})
-ethTokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c ethereum -a ${ethTokenBridge} -g ${guardiansPrivateCSV} )
+ethTokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c ethereum -a ${ethTokenBridge} -g ${guardiansPrivateCSV})
 terraTokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c terra -a ${terraTokenBridge} -g ${guardiansPrivateCSV})
 bscTokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c bsc -a ${bscTokenBridge} -g ${guardiansPrivateCSV})
 algoTokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c algorand -a ${algoTokenBridge} -g ${guardiansPrivateCSV})
 nearTokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c near -a ${nearTokenBridge} -g ${guardiansPrivateCSV})
 terra2TokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c terra2 -a ${terra2TokenBridge} -g ${guardiansPrivateCSV})
-wormchainTokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c wormchain -a ${wormchainTokenBridge} -g ${guardiansPrivateCSV})
+suiTokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c sui -a ${suiTokenBridge} -g ${guardiansPrivateCSV})
 aptosTokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c aptos -a ${aptosTokenBridge} -g ${guardiansPrivateCSV})
+wormchainTokenBridgeVAA=$(node ./clients/js/build/main.js generate registration -m TokenBridge -c wormchain -a ${wormchainTokenBridge} -g ${guardiansPrivateCSV})
 
 
 # 5) create nft bridge registration VAAs
@@ -131,8 +133,9 @@ bscTokenBridge="REGISTER_BSC_TOKEN_BRIDGE_VAA"
 algoTokenBridge="REGISTER_ALGO_TOKEN_BRIDGE_VAA"
 terra2TokenBridge="REGISTER_TERRA2_TOKEN_BRIDGE_VAA"
 nearTokenBridge="REGISTER_NEAR_TOKEN_BRIDGE_VAA"
-wormchainTokenBridge="REGISTER_WORMCHAIN_TOKEN_BRIDGE_VAA"
+suiTokenBridge="REGISTER_SUI_TOKEN_BRIDGE_VAA"
 aptosTokenBridge="REGISTER_APTOS_TOKEN_BRIDGE_VAA"
+wormchainTokenBridge="REGISTER_WORMCHAIN_TOKEN_BRIDGE_VAA"
 
 solNFTBridge="REGISTER_SOL_NFT_BRIDGE_VAA"
 ethNFTBridge="REGISTER_ETH_NFT_BRIDGE_VAA"
@@ -140,7 +143,6 @@ terraNFTBridge="REGISTER_TERRA_NFT_BRIDGE_VAA"
 nearNFTBridge="REGISTER_NEAR_NFT_BRIDGE_VAA"
 aptosNFTBridge="REGISTER_APTOS_NFT_BRIDGE_VAA"
 
-
 # solana token bridge
 upsert_env_file $ethFile $solTokenBridge $solTokenBridgeVAA
 upsert_env_file $envFile $solTokenBridge $solTokenBridgeVAA
@@ -148,7 +150,6 @@ upsert_env_file $envFile $solTokenBridge $solTokenBridgeVAA
 upsert_env_file $ethFile $solNFTBridge $solNFTBridgeVAA
 upsert_env_file $envFile $solNFTBridge $solNFTBridgeVAA
 
-
 # ethereum token bridge
 upsert_env_file $ethFile $ethTokenBridge $ethTokenBridgeVAA
 upsert_env_file $envFile $ethTokenBridge $ethTokenBridgeVAA
@@ -156,7 +157,6 @@ upsert_env_file $envFile $ethTokenBridge $ethTokenBridgeVAA
 upsert_env_file $ethFile $ethNFTBridge $ethNFTBridgeVAA
 upsert_env_file $envFile $ethNFTBridge $ethNFTBridgeVAA
 
-
 # terra token bridge
 upsert_env_file $ethFile $terraTokenBridge $terraTokenBridgeVAA
 upsert_env_file $envFile $terraTokenBridge $terraTokenBridgeVAA
@@ -164,7 +164,6 @@ upsert_env_file $envFile $terraTokenBridge $terraTokenBridgeVAA
 upsert_env_file $ethFile $terraNFTBridge $terraNFTBridgeVAA
 upsert_env_file $envFile $terraNFTBridge $terraNFTBridgeVAA
 
-
 # bsc token bridge
 upsert_env_file $ethFile $bscTokenBridge $bscTokenBridgeVAA
 upsert_env_file $envFile $bscTokenBridge $bscTokenBridgeVAA
@@ -177,22 +176,24 @@ upsert_env_file $envFile $algoTokenBridge $algoTokenBridgeVAA
 upsert_env_file $ethFile $terra2TokenBridge $terra2TokenBridgeVAA
 upsert_env_file $envFile $terra2TokenBridge $terra2TokenBridgeVAA
 
-# aptos token bridge
-upsert_env_file $ethFile $aptosTokenBridge $aptosTokenBridgeVAA
-upsert_env_file $envFile $aptosTokenBridge $aptosTokenBridgeVAA
-
-# aptos nft bridge
-upsert_env_file $ethFile $aptosNFTBridge $aptosNFTBridgeVAA
-upsert_env_file $envFile $aptosNFTBridge $aptosNFTBridgeVAA
-
 # near token bridge
 upsert_env_file $ethFile $nearTokenBridge $nearTokenBridgeVAA
 upsert_env_file $envFile $nearTokenBridge $nearTokenBridgeVAA
-
 # near nft bridge
 upsert_env_file $ethFile $nearNFTBridge $nearNFTBridgeVAA
 upsert_env_file $envFile $nearNFTBridge $nearNFTBridgeVAA
 
+# sui token bridge
+upsert_env_file $ethFile $suiTokenBridge $suiTokenBridgeVAA
+upsert_env_file $envFile $suiTokenBridge $suiTokenBridgeVAA
+
+# aptos token bridge
+upsert_env_file $ethFile $aptosTokenBridge $aptosTokenBridgeVAA
+upsert_env_file $envFile $aptosTokenBridge $aptosTokenBridgeVAA
+# aptos nft bridge
+upsert_env_file $ethFile $aptosNFTBridge $aptosNFTBridgeVAA
+upsert_env_file $envFile $aptosNFTBridge $aptosNFTBridgeVAA
+
 # wormchain token bridge
 upsert_env_file $ethFile $wormchainTokenBridge $wormchainTokenBridgeVAA
 upsert_env_file $envFile $wormchainTokenBridge $wormchainTokenBridgeVAA
@@ -212,6 +213,8 @@ paths=(
     ./solana/.env
     ./terra/tools/.env
     ./cosmwasm/deployment/terra2/tools/.env
+    ./sui/.env
+    ./aptos/.env
     ./wormchain/contracts/tools/.env
 )
 

+ 1 - 0
sdk/devnet_consts.go

@@ -16,6 +16,7 @@ var knownDevnetTokenbridgeEmitters = map[vaa.ChainID]string{
 	vaa.ChainIDBSC:       "0000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16",
 	vaa.ChainIDAlgorand:  "8edf5b0e108c3a1a0a4b704cc89591f2ad8d50df24e991567e640ed720a94be2",
 	vaa.ChainIDWormchain: "0000000000000000000000001711cd63b2c545ee6545415d3cc0bda6425c43c4",
+	vaa.ChainIDSui:       "b99d994643e71bc6f460e33de1cc5167deedbac1374b9b2158c577d4c114037a",
 }
 
 // KnownDevnetNFTBridgeEmitters is a map of known NFT emitters used during development.

+ 306 - 10
sdk/js/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "@certusone/wormhole-sdk",
-  "version": "0.9.13",
+  "version": "0.9.14",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "@certusone/wormhole-sdk",
-      "version": "0.9.13",
+      "version": "0.9.14",
       "license": "Apache-2.0",
       "dependencies": {
         "@certusone/wormhole-sdk-proto-web": "0.0.6",
@@ -15,6 +15,7 @@
         "@injectivelabs/networks": "1.10.7",
         "@injectivelabs/sdk-ts": "1.10.47",
         "@injectivelabs/utils": "1.10.5",
+        "@mysten/sui.js": "0.32.2",
         "@project-serum/anchor": "^0.25.0",
         "@solana/spl-token": "^0.3.5",
         "@solana/web3.js": "^1.66.2",
@@ -3026,6 +3027,144 @@
         "rlp": "^2.2.3"
       }
     },
+    "node_modules/@mysten/bcs": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/@mysten/bcs/-/bcs-0.7.1.tgz",
+      "integrity": "sha512-wFPb8bkhwrbiStfZMV5rFM7J+umpke59/dNjDp+UYJKykNlW23LCk2ePyEUvGdb62HGJM1jyOJ8g4egE3OmdKA==",
+      "dependencies": {
+        "bs58": "^5.0.0"
+      }
+    },
+    "node_modules/@mysten/bcs/node_modules/base-x": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz",
+      "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw=="
+    },
+    "node_modules/@mysten/bcs/node_modules/bs58": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz",
+      "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==",
+      "dependencies": {
+        "base-x": "^4.0.0"
+      }
+    },
+    "node_modules/@mysten/sui.js": {
+      "version": "0.32.2",
+      "resolved": "https://registry.npmjs.org/@mysten/sui.js/-/sui.js-0.32.2.tgz",
+      "integrity": "sha512-/Hm4xkGolJhqj8FvQr7QSHDTlxIvL52mtbOao9f75YjrBh7y1Uh9kbJSY7xiTF1NY9sv6p5hUVlYRJuM0Hvn9A==",
+      "dependencies": {
+        "@mysten/bcs": "0.7.1",
+        "@noble/curves": "^1.0.0",
+        "@noble/hashes": "^1.3.0",
+        "@scure/bip32": "^1.3.0",
+        "@scure/bip39": "^1.2.0",
+        "@suchipi/femver": "^1.0.0",
+        "jayson": "^4.0.0",
+        "rpc-websockets": "^7.5.1",
+        "superstruct": "^1.0.3",
+        "tweetnacl": "^1.0.3"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@mysten/sui.js/node_modules/@noble/hashes": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz",
+      "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ]
+    },
+    "node_modules/@mysten/sui.js/node_modules/@scure/bip39": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.0.tgz",
+      "integrity": "sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ],
+      "dependencies": {
+        "@noble/hashes": "~1.3.0",
+        "@scure/base": "~1.1.0"
+      }
+    },
+    "node_modules/@mysten/sui.js/node_modules/@types/node": {
+      "version": "12.20.55",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
+      "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="
+    },
+    "node_modules/@mysten/sui.js/node_modules/jayson": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.1.0.tgz",
+      "integrity": "sha512-R6JlbyLN53Mjku329XoRT2zJAE6ZgOQ8f91ucYdMCD4nkGCF9kZSrcGXpHIU4jeKj58zUZke2p+cdQchU7Ly7A==",
+      "dependencies": {
+        "@types/connect": "^3.4.33",
+        "@types/node": "^12.12.54",
+        "@types/ws": "^7.4.4",
+        "commander": "^2.20.3",
+        "delay": "^5.0.0",
+        "es6-promisify": "^5.0.0",
+        "eyes": "^0.1.8",
+        "isomorphic-ws": "^4.0.1",
+        "json-stringify-safe": "^5.0.1",
+        "JSONStream": "^1.3.5",
+        "uuid": "^8.3.2",
+        "ws": "^7.4.5"
+      },
+      "bin": {
+        "jayson": "bin/jayson.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@mysten/sui.js/node_modules/superstruct": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-1.0.3.tgz",
+      "integrity": "sha512-8iTn3oSS8nRGn+C2pgXSKPI3jmpm6FExNazNpjvqS6ZUJQCej3PUXEKM8NjHBOs54ExM+LPW/FBRhymrdcCiSg==",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@mysten/sui.js/node_modules/uuid": {
+      "version": "8.3.2",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+      "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    },
+    "node_modules/@noble/curves": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.0.0.tgz",
+      "integrity": "sha512-2upgEu0iLiDVDZkNLeFV2+ht0BAVgQnEmCk6JsOch9Rp8xfkMCbvbAZlA2pBHQc73dbl+vFOXfqkf4uemdn0bw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ],
+      "dependencies": {
+        "@noble/hashes": "1.3.0"
+      }
+    },
+    "node_modules/@noble/curves/node_modules/@noble/hashes": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz",
+      "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ]
+    },
     "node_modules/@noble/ed25519": {
       "version": "1.7.1",
       "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-1.7.1.tgz",
@@ -3236,6 +3375,33 @@
         }
       ]
     },
+    "node_modules/@scure/bip32": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.0.tgz",
+      "integrity": "sha512-bcKpo1oj54hGholplGLpqPHRbIsnbixFtc06nwuNM5/dwSXOq/AAYoIBRsBmnZJSdfeNW5rnff7NTAz3ZCqR9Q==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ],
+      "dependencies": {
+        "@noble/curves": "~1.0.0",
+        "@noble/hashes": "~1.3.0",
+        "@scure/base": "~1.1.0"
+      }
+    },
+    "node_modules/@scure/bip32/node_modules/@noble/hashes": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz",
+      "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ]
+    },
     "node_modules/@scure/bip39": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.1.0.tgz",
@@ -3402,6 +3568,11 @@
         "node": ">=12.20.0"
       }
     },
+    "node_modules/@suchipi/femver": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@suchipi/femver/-/femver-1.0.0.tgz",
+      "integrity": "sha512-bprE8+K5V+DPX7q2e2K57ImqNBdfGHDIWaGI5xHxZoxbKOuQZn4wzPiUxOAHnsUr3w3xHrWXwN7gnG/iIuEMIg=="
+    },
     "node_modules/@szmarczak/http-timer": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
@@ -8595,7 +8766,6 @@
       "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.5.tgz",
       "integrity": "sha512-HTm14iMQKK2FjFLRTM5lAVcyaUzOnqbPtesFIvREgXpJHdQm8bWS+GkQgIkfaBYRHuCnea7w8UVNfwiAQhlr9A==",
       "dev": true,
-      "hasInstallScript": true,
       "optional": true,
       "dependencies": {
         "node-gyp-build": "^4.3.0"
@@ -8958,7 +9128,6 @@
       "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.7.tgz",
       "integrity": "sha512-vLt1O5Pp+flcArHGIyKEQq883nBt8nN8tVBcoL0qUXj2XT1n7p70yGIq2VK98I5FdZ1YHc0wk/koOnHjnXWk1Q==",
       "dev": true,
-      "hasInstallScript": true,
       "optional": true,
       "dependencies": {
         "node-gyp-build": "^4.3.0"
@@ -15049,9 +15218,9 @@
       }
     },
     "node_modules/rpc-websockets": {
-      "version": "7.5.0",
-      "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-7.5.0.tgz",
-      "integrity": "sha512-9tIRi1uZGy7YmDjErf1Ax3wtqdSSLIlnmL5OtOzgd5eqPKbsPpwDP5whUDO2LQay3Xp0CcHlcNSGzacNRluBaQ==",
+      "version": "7.5.1",
+      "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-7.5.1.tgz",
+      "integrity": "sha512-kGFkeTsmd37pHPMaHIgN1LVKXMi0JD782v4Ds9ZKtLlwdTKjn+CxM9A9/gLT2LaOuEcEFGL98h1QWQtlOIdW0w==",
       "dependencies": {
         "@babel/runtime": "^7.17.2",
         "eventemitter3": "^4.0.7",
@@ -19986,6 +20155,111 @@
         }
       }
     },
+    "@mysten/bcs": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/@mysten/bcs/-/bcs-0.7.1.tgz",
+      "integrity": "sha512-wFPb8bkhwrbiStfZMV5rFM7J+umpke59/dNjDp+UYJKykNlW23LCk2ePyEUvGdb62HGJM1jyOJ8g4egE3OmdKA==",
+      "requires": {
+        "bs58": "^5.0.0"
+      },
+      "dependencies": {
+        "base-x": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz",
+          "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw=="
+        },
+        "bs58": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz",
+          "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==",
+          "requires": {
+            "base-x": "^4.0.0"
+          }
+        }
+      }
+    },
+    "@mysten/sui.js": {
+      "version": "0.32.2",
+      "resolved": "https://registry.npmjs.org/@mysten/sui.js/-/sui.js-0.32.2.tgz",
+      "integrity": "sha512-/Hm4xkGolJhqj8FvQr7QSHDTlxIvL52mtbOao9f75YjrBh7y1Uh9kbJSY7xiTF1NY9sv6p5hUVlYRJuM0Hvn9A==",
+      "requires": {
+        "@mysten/bcs": "0.7.1",
+        "@noble/curves": "^1.0.0",
+        "@noble/hashes": "^1.3.0",
+        "@scure/bip32": "^1.3.0",
+        "@scure/bip39": "^1.2.0",
+        "@suchipi/femver": "^1.0.0",
+        "jayson": "^4.0.0",
+        "rpc-websockets": "^7.5.1",
+        "superstruct": "^1.0.3",
+        "tweetnacl": "^1.0.3"
+      },
+      "dependencies": {
+        "@noble/hashes": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz",
+          "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg=="
+        },
+        "@scure/bip39": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.0.tgz",
+          "integrity": "sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg==",
+          "requires": {
+            "@noble/hashes": "~1.3.0",
+            "@scure/base": "~1.1.0"
+          }
+        },
+        "@types/node": {
+          "version": "12.20.55",
+          "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
+          "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="
+        },
+        "jayson": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.1.0.tgz",
+          "integrity": "sha512-R6JlbyLN53Mjku329XoRT2zJAE6ZgOQ8f91ucYdMCD4nkGCF9kZSrcGXpHIU4jeKj58zUZke2p+cdQchU7Ly7A==",
+          "requires": {
+            "@types/connect": "^3.4.33",
+            "@types/node": "^12.12.54",
+            "@types/ws": "^7.4.4",
+            "commander": "^2.20.3",
+            "delay": "^5.0.0",
+            "es6-promisify": "^5.0.0",
+            "eyes": "^0.1.8",
+            "isomorphic-ws": "^4.0.1",
+            "json-stringify-safe": "^5.0.1",
+            "JSONStream": "^1.3.5",
+            "uuid": "^8.3.2",
+            "ws": "^7.4.5"
+          }
+        },
+        "superstruct": {
+          "version": "1.0.3",
+          "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-1.0.3.tgz",
+          "integrity": "sha512-8iTn3oSS8nRGn+C2pgXSKPI3jmpm6FExNazNpjvqS6ZUJQCej3PUXEKM8NjHBOs54ExM+LPW/FBRhymrdcCiSg=="
+        },
+        "uuid": {
+          "version": "8.3.2",
+          "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+          "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
+        }
+      }
+    },
+    "@noble/curves": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.0.0.tgz",
+      "integrity": "sha512-2upgEu0iLiDVDZkNLeFV2+ht0BAVgQnEmCk6JsOch9Rp8xfkMCbvbAZlA2pBHQc73dbl+vFOXfqkf4uemdn0bw==",
+      "requires": {
+        "@noble/hashes": "1.3.0"
+      },
+      "dependencies": {
+        "@noble/hashes": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz",
+          "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg=="
+        }
+      }
+    },
     "@noble/ed25519": {
       "version": "1.7.1",
       "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-1.7.1.tgz",
@@ -20168,6 +20442,23 @@
       "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
       "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
     },
+    "@scure/bip32": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.0.tgz",
+      "integrity": "sha512-bcKpo1oj54hGholplGLpqPHRbIsnbixFtc06nwuNM5/dwSXOq/AAYoIBRsBmnZJSdfeNW5rnff7NTAz3ZCqR9Q==",
+      "requires": {
+        "@noble/curves": "~1.0.0",
+        "@noble/hashes": "~1.3.0",
+        "@scure/base": "~1.1.0"
+      },
+      "dependencies": {
+        "@noble/hashes": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz",
+          "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg=="
+        }
+      }
+    },
     "@scure/bip39": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.1.0.tgz",
@@ -20282,6 +20573,11 @@
         "superstruct": "^0.14.2"
       }
     },
+    "@suchipi/femver": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@suchipi/femver/-/femver-1.0.0.tgz",
+      "integrity": "sha512-bprE8+K5V+DPX7q2e2K57ImqNBdfGHDIWaGI5xHxZoxbKOuQZn4wzPiUxOAHnsUr3w3xHrWXwN7gnG/iIuEMIg=="
+    },
     "@szmarczak/http-timer": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
@@ -29573,9 +29869,9 @@
       }
     },
     "rpc-websockets": {
-      "version": "7.5.0",
-      "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-7.5.0.tgz",
-      "integrity": "sha512-9tIRi1uZGy7YmDjErf1Ax3wtqdSSLIlnmL5OtOzgd5eqPKbsPpwDP5whUDO2LQay3Xp0CcHlcNSGzacNRluBaQ==",
+      "version": "7.5.1",
+      "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-7.5.1.tgz",
+      "integrity": "sha512-kGFkeTsmd37pHPMaHIgN1LVKXMi0JD782v4Ds9ZKtLlwdTKjn+CxM9A9/gLT2LaOuEcEFGL98h1QWQtlOIdW0w==",
       "requires": {
         "@babel/runtime": "^7.17.2",
         "bufferutil": "^4.0.1",

+ 1 - 0
sdk/js/package.json

@@ -72,6 +72,7 @@
     "@injectivelabs/networks": "1.10.7",
     "@injectivelabs/sdk-ts": "1.10.47",
     "@injectivelabs/utils": "1.10.5",
+    "@mysten/sui.js": "0.32.2",
     "@project-serum/anchor": "^0.25.0",
     "@solana/spl-token": "^0.3.5",
     "@solana/web3.js": "^1.66.2",

+ 13 - 0
sdk/js/src/bridge/parseSequenceFromLog.ts

@@ -5,6 +5,7 @@ import { AptosClient, Types } from "aptos";
 import { BigNumber, ContractReceipt } from "ethers";
 import { FinalExecutionOutcome } from "near-api-js/lib/providers";
 import { Implementation__factory } from "../ethers-contracts";
+import { SuiTransactionBlockResponse } from "@mysten/sui.js";
 
 export function parseSequenceFromLogEth(
   receipt: ContractReceipt,
@@ -180,3 +181,15 @@ export function parseSequenceFromLogAptos(
 
   return null;
 }
+
+export function parseSequenceFromLogSui(
+  originalCoreBridgePackageId: string,
+  response: SuiTransactionBlockResponse
+): string | null {
+  const event = response.events?.find(
+    (e) =>
+      e.type ===
+      `${originalCoreBridgePackageId}::publish_message::WormholeMessage`
+  );
+  return event?.parsedJson?.sequence || null;
+}

+ 1 - 1
sdk/js/src/cosmwasm/query.testnet.test.ts

@@ -40,7 +40,7 @@ import {
   tryNativeToUint8Array,
   uint8ArrayToHex,
 } from "..";
-import { CLUSTER } from "../token_bridge/__tests__/consts";
+import { CLUSTER } from "../token_bridge/__tests__/utils/consts";
 import algosdk, {
   Account,
   Algodv2,

+ 1 - 0
sdk/js/src/index.ts

@@ -15,5 +15,6 @@ export * as bridge from "./bridge";
 export * as token_bridge from "./token_bridge";
 export * as nft_bridge from "./nft_bridge";
 export * as algorand from "./algorand";
+export * as sui from "./sui";
 
 export { postVaaSolana, postVaaSolanaWithRetry } from "./solana";

+ 1 - 1
sdk/js/src/nft_bridge/__tests__/aptos-integration.ts

@@ -44,7 +44,7 @@ import {
   SOLANA_HOST,
   SOLANA_PRIVATE_KEY2,
   TEST_SOLANA_TOKEN3,
-} from "./consts";
+} from "./utils/consts";
 import {
   deployTestNftOnAptos,
   deployTestNftOnEthereum,

+ 1 - 1
sdk/js/src/nft_bridge/__tests__/integration.ts

@@ -31,7 +31,7 @@ import {
   SOLANA_HOST,
   SOLANA_PRIVATE_KEY,
   TEST_SOLANA_TOKEN,
-} from "./consts";
+} from "./utils/consts";
 import { getSignedVaaEthereum, getSignedVaaSolana } from "./utils/getSignedVaa";
 
 jest.setTimeout(60000);

+ 0 - 0
sdk/js/src/nft_bridge/__tests__/consts.ts → sdk/js/src/nft_bridge/__tests__/utils/consts.ts


+ 1 - 1
sdk/js/src/nft_bridge/__tests__/utils/getSignedVaa.ts

@@ -19,7 +19,7 @@ import {
   CHAIN_ID_SOLANA,
   CONTRACTS,
 } from "../../../utils";
-import { WORMHOLE_RPC_HOSTS } from "../consts";
+import { WORMHOLE_RPC_HOSTS } from "./consts";
 
 // TODO(aki): implement getEmitterAddressAptos and sub here
 export async function getSignedVaaAptos(

+ 7 - 0
sdk/js/src/sui/error.ts

@@ -0,0 +1,7 @@
+export class SuiRpcValidationError extends Error {
+  constructor(response: any) {
+    super(
+      `Sui RPC returned an unexpected response: ${JSON.stringify(response)}`
+    );
+  }
+}

+ 2 - 0
sdk/js/src/sui/index.ts

@@ -0,0 +1,2 @@
+export * from "./publish";
+export * from "./utils";

+ 84 - 0
sdk/js/src/sui/publish.ts

@@ -0,0 +1,84 @@
+import {
+  fromB64,
+  JsonRpcProvider,
+  normalizeSuiObjectId,
+  TransactionBlock,
+} from "@mysten/sui.js";
+import { SuiBuildOutput } from "./types";
+import { getOriginalPackageId, getPackageId } from "./utils";
+
+export const publishCoin = async (
+  provider: JsonRpcProvider,
+  coreBridgeStateObjectId: string,
+  tokenBridgeStateObjectId: string,
+  decimals: number,
+  signerAddress: string
+) => {
+  const coreBridgePackageId = await getPackageId(
+    provider,
+    coreBridgeStateObjectId
+  );
+  const tokenBridgePackageId = await getPackageId(
+    provider,
+    tokenBridgeStateObjectId
+  );
+  const build = await getCoinBuildOutput(
+    provider,
+    coreBridgePackageId,
+    tokenBridgePackageId,
+    tokenBridgeStateObjectId,
+    decimals
+  );
+  return publishPackage(build, signerAddress);
+};
+
+export const getCoinBuildOutput = async (
+  provider: JsonRpcProvider,
+  coreBridgePackageId: string,
+  tokenBridgePackageId: string,
+  tokenBridgeStateObjectId: string,
+  decimals: number
+): Promise<SuiBuildOutput> => {
+  // Decimals is capped at 8
+  decimals = Math.min(decimals, 8);
+
+  // Construct bytecode, parametrized by token bridge package ID and decimals
+  const strippedTokenBridgePackageId = (
+    await getOriginalPackageId(provider, tokenBridgeStateObjectId)
+  )?.replace("0x", "");
+  if (!strippedTokenBridgePackageId) {
+    throw new Error(
+      `Original token bridge package ID not found for object ID ${tokenBridgeStateObjectId}`
+    );
+  }
+
+  const bytecodeHex =
+    "a11ceb0b060000000901000a020a14031e1704350405392d07669f01088502600ae502050cea02160004010b010c0205020d000002000201020003030c020001000104020700000700010001090801010c020a050600030803040202000302010702080007080100020800080303090002070801010b020209000901010608010105010b0202080008030209000504434f494e095478436f6e7465787408565f5f305f325f3011577261707065644173736574536574757004636f696e0e6372656174655f777261707065640b64756d6d795f6669656c6404696e697414707265706172655f726567697374726174696f6e0f7075626c69635f7472616e736665720673656e646572087472616e736665720a74785f636f6e746578740f76657273696f6e5f636f6e74726f6c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002" +
+    strippedTokenBridgePackageId +
+    "00020106010000000001090b0031" +
+    decimals.toString(16).padStart(2, "0") +
+    "0a0138000b012e110238010200";
+  const bytecode = Buffer.from(bytecodeHex, "hex").toString("base64");
+  return {
+    modules: [bytecode],
+    dependencies: ["0x1", "0x2", tokenBridgePackageId, coreBridgePackageId].map(
+      (d) => normalizeSuiObjectId(d)
+    ),
+  };
+};
+
+export const publishPackage = async (
+  buildOutput: SuiBuildOutput,
+  signerAddress: string
+): Promise<TransactionBlock> => {
+  // Publish contracts
+  const tx = new TransactionBlock();
+  const [upgradeCap] = tx.publish({
+    modules: buildOutput.modules.map((m) => Array.from(fromB64(m))),
+    dependencies: buildOutput.dependencies.map((d) => normalizeSuiObjectId(d)),
+  });
+
+  // Transfer upgrade capability to recipient
+  tx.transferObjects([upgradeCap], tx.pure(signerAddress));
+  return tx;
+};

+ 20 - 0
sdk/js/src/sui/types.ts

@@ -0,0 +1,20 @@
+export type ParsedMoveToml = {
+  name: string;
+  rows: { key: string; value: string }[];
+}[];
+
+export type SuiBuildOutput = {
+  modules: string[];
+  dependencies: string[];
+};
+
+export type SuiError = {
+  code: number;
+  message: string;
+  data: any;
+};
+
+export type SuiCoinObject = {
+  coinType: string;
+  coinObjectId: string;
+};

+ 11 - 0
sdk/js/src/sui/utils.test.ts

@@ -0,0 +1,11 @@
+import { unnormalizeSuiAddress } from "./utils";
+
+describe("Sui utils tests", () => {
+  test("Test unnormalizeSuiAddress", () => {
+    const initial =
+      "0x09bc8dd67bbbf59a43a9081d7166f9b41740c3a8ae868c4902d30eb247292ba4::coin::COIN";
+    const expected =
+      "0x9bc8dd67bbbf59a43a9081d7166f9b41740c3a8ae868c4902d30eb247292ba4::coin::COIN";
+    expect(unnormalizeSuiAddress(initial)).toBe(expected);
+  });
+});

+ 414 - 0
sdk/js/src/sui/utils.ts

@@ -0,0 +1,414 @@
+import {
+  getObjectType,
+  isValidSuiAddress as isValidFullSuiAddress,
+  JsonRpcProvider,
+  normalizeSuiAddress,
+  PaginatedObjectsResponse,
+  RawSigner,
+  SuiObjectResponse,
+  SuiTransactionBlockResponse,
+  TransactionBlock,
+} from "@mysten/sui.js";
+import { ensureHexPrefix } from "../utils";
+import { SuiRpcValidationError } from "./error";
+import { SuiError } from "./types";
+
+const UPGRADE_CAP_TYPE = "0x2::package::UpgradeCap";
+
+export const executeTransactionBlock = async (
+  signer: RawSigner,
+  transactionBlock: TransactionBlock
+): Promise<SuiTransactionBlockResponse> => {
+  // Let caller handle parsing and logging info
+  transactionBlock.setGasBudget(100000000);
+  return signer.signAndExecuteTransactionBlock({
+    transactionBlock,
+    options: {
+      showInput: true,
+      showEffects: true,
+      showEvents: true,
+      showObjectChanges: true,
+    },
+  });
+};
+
+// TODO: can we pass in the latest core bridge package Id after an upgrade?
+// or do we have to use the first one?
+// this is the same type that the guardian will look for
+export const getEmitterAddressAndSequenceFromResponseSui = (
+  originalCoreBridgePackageId: string,
+  response: SuiTransactionBlockResponse
+): { emitterAddress: string; sequence: string } => {
+  const wormholeMessageEventType = `${originalCoreBridgePackageId}::publish_message::WormholeMessage`;
+  const event = response.events?.find(
+    (e) => e.type === wormholeMessageEventType
+  );
+  if (event === undefined) {
+    throw new Error(`${wormholeMessageEventType} event type not found`);
+  }
+  const { sender, sequence } = event.parsedJson || {};
+  if (sender === undefined || sequence === undefined) {
+    throw new Error("Can't find sender or sequence");
+  }
+  return { emitterAddress: sender.substring(2), sequence };
+};
+
+export const getFieldsFromObjectResponse = (object: SuiObjectResponse) => {
+  const content = object.data?.content;
+  return content && content.dataType === "moveObject" ? content.fields : null;
+};
+
+export const getInnerType = (type: string): string | null => {
+  if (!type) return null;
+  const match = type.match(/<(.*)>/);
+  if (!match || !isValidSuiType(match[1])) {
+    return null;
+  }
+
+  return match[1];
+};
+
+export const getObjectFields = async (
+  provider: JsonRpcProvider,
+  objectId: string
+): Promise<Record<string, any> | null> => {
+  if (!isValidSuiAddress(objectId)) {
+    throw new Error(`Invalid object ID: ${objectId}`);
+  }
+
+  const res = await provider.getObject({
+    id: objectId,
+    options: {
+      showContent: true,
+    },
+  });
+  return getFieldsFromObjectResponse(res);
+};
+
+export const getOriginalPackageId = async (
+  provider: JsonRpcProvider,
+  stateObjectId: string
+) => {
+  return getObjectType(
+    await provider.getObject({
+      id: stateObjectId,
+      options: { showContent: true },
+    })
+  )?.split("::")[0];
+};
+
+export const getOwnedObjectId = async (
+  provider: JsonRpcProvider,
+  owner: string,
+  type: string
+): Promise<string | null> => {
+  // Upgrade caps are a special case
+  if (normalizeSuiType(type) === normalizeSuiType(UPGRADE_CAP_TYPE)) {
+    throw new Error(
+      "`getOwnedObjectId` should not be used to get the object ID of an `UpgradeCap`. Use `getUpgradeCapObjectId` instead."
+    );
+  }
+
+  try {
+    const res = await provider.getOwnedObjects({
+      owner,
+      filter: { StructType: type },
+      options: {
+        showContent: true,
+      },
+    });
+    if (!res || !res.data) {
+      throw new SuiRpcValidationError(res);
+    }
+
+    const objects = res.data.filter((o) => o.data?.objectId);
+    if (objects.length === 1) {
+      return objects[0].data?.objectId ?? null;
+    } else if (objects.length > 1) {
+      const objectsStr = JSON.stringify(objects, null, 2);
+      throw new Error(
+        `Found multiple objects owned by ${owner} of type ${type}. This may mean that we've received an unexpected response from the Sui RPC and \`worm\` logic needs to be updated to handle this. Objects: ${objectsStr}`
+      );
+    } else {
+      return null;
+    }
+  } catch (error) {
+    // Handle 504 error by using findOwnedObjectByType method
+    const is504HttpError = `${error}`.includes("504 Gateway Time-out");
+    if (error && is504HttpError) {
+      return getOwnedObjectIdPaginated(provider, owner, type);
+    } else {
+      throw error;
+    }
+  }
+};
+
+export const getOwnedObjectIdPaginated = async (
+  provider: JsonRpcProvider,
+  owner: string,
+  type: string,
+  cursor?: string
+): Promise<string | null> => {
+  const res: PaginatedObjectsResponse = await provider.getOwnedObjects({
+    owner,
+    filter: undefined, // Filter must be undefined to avoid 504 responses
+    cursor: cursor || undefined,
+    options: {
+      showType: true,
+    },
+  });
+
+  if (!res || !res.data) {
+    throw new SuiRpcValidationError(res);
+  }
+
+  const object = res.data.find((d) => d.data?.type === type);
+
+  if (!object && res.hasNextPage) {
+    return getOwnedObjectIdPaginated(
+      provider,
+      owner,
+      type,
+      res.nextCursor as string
+    );
+  } else if (!object && !res.hasNextPage) {
+    return null;
+  } else {
+    return object?.data?.objectId ?? null;
+  }
+};
+
+export const getPackageId = async (
+  provider: JsonRpcProvider,
+  objectId: string
+): Promise<string> => {
+  const fields = await getObjectFields(provider, objectId);
+  if (fields && "upgrade_cap" in fields) {
+    return fields.upgrade_cap.fields.package;
+  }
+
+  throw new Error("upgrade_cap not found");
+};
+
+export const getPackageIdFromType = (type: string): string | null => {
+  if (!isValidSuiType(type)) return null;
+  const packageId = type.split("::")[0];
+  if (!isValidSuiAddress(packageId)) return null;
+  return packageId;
+};
+
+export const getTableKeyType = (tableType: string): string | null => {
+  if (!tableType) return null;
+  const match = tableType.match(/0x2::table::Table<(.*)>/);
+  if (!match) return null;
+  const [keyType] = match[1].split(",");
+  if (!isValidSuiType(keyType)) return null;
+  return keyType;
+};
+
+export const getTokenCoinType = async (
+  provider: JsonRpcProvider,
+  tokenBridgeStateObjectId: string,
+  tokenAddress: Uint8Array,
+  tokenChain: number
+): Promise<string | null> => {
+  const tokenBridgeStateFields = await getObjectFields(
+    provider,
+    tokenBridgeStateObjectId
+  );
+  if (!tokenBridgeStateFields) {
+    throw new Error("Unable to fetch object fields from token bridge state");
+  }
+
+  const coinTypes = tokenBridgeStateFields?.token_registry?.fields?.coin_types;
+  const coinTypesObjectId = coinTypes?.fields?.id?.id;
+  if (!coinTypesObjectId) {
+    throw new Error("Unable to fetch coin types");
+  }
+
+  const keyType = getTableKeyType(coinTypes?.type);
+  if (!keyType) {
+    throw new Error("Unable to get key type");
+  }
+
+  const response = await provider.getDynamicFieldObject({
+    parentId: coinTypesObjectId,
+    name: {
+      type: keyType,
+      value: {
+        addr: [...tokenAddress],
+        chain: tokenChain,
+      },
+    },
+  });
+  if (response.error) {
+    if (response.error.code === "dynamicFieldNotFound") {
+      return null;
+    }
+    throw new Error(
+      `Unexpected getDynamicFieldObject response ${response.error}`
+    );
+  }
+  const fields = getFieldsFromObjectResponse(response);
+  return fields?.value
+    ? unnormalizeSuiAddress(ensureHexPrefix(fields.value))
+    : null;
+};
+
+export const getTokenFromTokenRegistry = async (
+  provider: JsonRpcProvider,
+  tokenBridgeStateObjectId: string,
+  tokenType: string
+): Promise<SuiObjectResponse> => {
+  if (!isValidSuiType(tokenType)) {
+    throw new Error(`Invalid Sui type: ${tokenType}`);
+  }
+
+  const tokenBridgeStateFields = await getObjectFields(
+    provider,
+    tokenBridgeStateObjectId
+  );
+  if (!tokenBridgeStateFields) {
+    throw new Error(
+      `Unable to fetch object fields from token bridge state. Object ID: ${tokenBridgeStateObjectId}`
+    );
+  }
+
+  const tokenRegistryObjectId =
+    tokenBridgeStateFields.token_registry?.fields?.id?.id;
+  if (!tokenRegistryObjectId) {
+    throw new Error("Unable to fetch token registry object ID");
+  }
+
+  const tokenRegistryPackageId = getPackageIdFromType(
+    tokenBridgeStateFields.token_registry?.type
+  );
+  if (!tokenRegistryObjectId) {
+    throw new Error("Unable to fetch token registry package ID");
+  }
+
+  return provider.getDynamicFieldObject({
+    parentId: tokenRegistryObjectId,
+    name: {
+      type: `${tokenRegistryPackageId}::token_registry::Key<${tokenType}>`,
+      value: {
+        dummy_field: false,
+      },
+    },
+  });
+};
+
+/**
+ * This function returns the object ID of the `UpgradeCap` that belongs to the
+ * given package and owner if it exists.
+ *
+ * Structs created by the Sui framework such as `UpgradeCap`s all have the same
+ * type (e.g. `0x2::package::UpgradeCap`) and have a special field, `package`,
+ * we can use to differentiate them.
+ * @param provider Sui RPC provider
+ * @param owner Address of the current owner of the `UpgradeCap`
+ * @param packageId ID of the package that the `UpgradeCap` was created for
+ * @returns The object ID of the `UpgradeCap` if it exists, otherwise `null`
+ */
+export const getUpgradeCapObjectId = async (
+  provider: JsonRpcProvider,
+  owner: string,
+  packageId: string
+): Promise<string | null> => {
+  const res = await provider.getOwnedObjects({
+    owner,
+    filter: { StructType: UPGRADE_CAP_TYPE },
+    options: {
+      showContent: true,
+    },
+  });
+  if (!res || !res.data) {
+    throw new SuiRpcValidationError(res);
+  }
+
+  const objects = res.data.filter(
+    (o) =>
+      o.data?.objectId &&
+      o.data?.content?.dataType === "moveObject" &&
+      o.data?.content?.fields?.package === packageId
+  );
+  if (objects.length === 1) {
+    // We've found the object we're looking for
+    return objects[0].data?.objectId ?? null;
+  } else if (objects.length > 1) {
+    const objectsStr = JSON.stringify(objects, null, 2);
+    throw new Error(
+      `Found multiple upgrade capabilities owned by ${owner} from package ${packageId}. Objects: ${objectsStr}`
+    );
+  } else {
+    return null;
+  }
+};
+
+/**
+ * Get the fully qualified type of a wrapped asset published to the given
+ * package ID.
+ *
+ * All wrapped assets that are registered with the token bridge must satisfy
+ * the requirement that module name is `coin` (source: https://github.com/wormhole-foundation/wormhole/blob/a1b3773ee42507122c3c4c3494898fbf515d0712/sui/token_bridge/sources/create_wrapped.move#L88).
+ * As a result, all wrapped assets share the same module name and struct name,
+ * since the struct name is necessarily `COIN` since it is a OTW.
+ * @param coinPackageId packageId of the wrapped asset
+ * @returns Fully qualified type of the wrapped asset
+ */
+export const getWrappedCoinType = (coinPackageId: string): string => {
+  if (!isValidSuiAddress(coinPackageId)) {
+    throw new Error(`Invalid package ID: ${coinPackageId}`);
+  }
+
+  return `${coinPackageId}::coin::COIN`;
+};
+
+export const isSameType = (a: string, b: string) => {
+  try {
+    return normalizeSuiType(a) === normalizeSuiType(b);
+  } catch (e) {
+    return false;
+  }
+};
+
+export const isSuiError = (error: any): error is SuiError => {
+  return (
+    error && typeof error === "object" && "code" in error && "message" in error
+  );
+};
+
+/**
+ * This method validates any Sui address, even if it's not 32 bytes long, i.e.
+ * "0x2". This differs from Mysten's implementation, which requires that the
+ * given address is 32 bytes long.
+ * @param address Address to check
+ * @returns If given address is a valid Sui address or not
+ */
+export const isValidSuiAddress = (address: string): boolean =>
+  isValidFullSuiAddress(normalizeSuiAddress(address));
+
+export const isValidSuiType = (type: string): boolean => {
+  const tokens = type.split("::");
+  if (tokens.length !== 3) {
+    return false;
+  }
+
+  return isValidSuiAddress(tokens[0]) && !!tokens[1] && !!tokens[2];
+};
+
+export const normalizeSuiType = (type: string): string => {
+  const tokens = type.split("::");
+  if (tokens.length < 3 || !isValidSuiAddress(tokens[0])) {
+    throw new Error(`Invalid Sui type: ${type}`);
+  }
+
+  return [normalizeSuiAddress(tokens[0]), ...tokens.slice(1)].join("::");
+};
+
+/**
+ * This method removes leading zeroes for types, as we found some getDynamicFieldObject
+ * value types to be stripped of leading zeroes
+ */
+export const unnormalizeSuiAddress = (type: string): string =>
+  type.replace(/^(0x)(0*)/, "0x");

+ 1 - 1
sdk/js/src/token_bridge/__tests__/algorand-integration.ts

@@ -56,7 +56,7 @@ import {
   ETH_PRIVATE_KEY7,
   TEST_ERC20,
   WORMHOLE_RPC_HOSTS,
-} from "./consts";
+} from "./utils/consts";
 
 const CORE_ID = BigInt(4);
 const TOKEN_BRIDGE_ID = BigInt(6);

+ 1 - 1
sdk/js/src/token_bridge/__tests__/aptos-integration.ts

@@ -53,7 +53,7 @@ import {
   ETH_PRIVATE_KEY6,
   TEST_ERC20,
   WORMHOLE_RPC_HOSTS,
-} from "./consts";
+} from "./utils/consts";
 
 const JEST_TEST_TIMEOUT = 60000;
 jest.setTimeout(JEST_TEST_TIMEOUT);

+ 1 - 1
sdk/js/src/token_bridge/__tests__/eth-integration.ts

@@ -41,7 +41,7 @@ import {
   SOLANA_PRIVATE_KEY,
   TEST_ERC20,
   WORMHOLE_RPC_HOSTS,
-} from "./consts";
+} from "./utils/consts";
 
 jest.setTimeout(60000);
 

+ 2 - 2
sdk/js/src/token_bridge/__tests__/near-integration.ts

@@ -26,8 +26,8 @@ import {
   ETH_PRIVATE_KEY5,
   NEAR_NODE_URL,
   TEST_ERC20,
-} from "./consts";
-import { getSignedVAABySequence } from "./helpers";
+} from "./utils/consts";
+import { getSignedVAABySequence } from "./utils/helpers";
 import { Account, connect, KeyPair, keyStores, Near } from "near-api-js";
 import {
   FinalExecutionOutcome,

+ 1 - 1
sdk/js/src/token_bridge/__tests__/solana-integration.ts

@@ -41,7 +41,7 @@ import {
   SOLANA_PRIVATE_KEY,
   TEST_SOLANA_TOKEN,
   WORMHOLE_RPC_HOSTS,
-} from "./consts";
+} from "./utils/consts";
 
 jest.setTimeout(60000);
 

+ 633 - 0
sdk/js/src/token_bridge/__tests__/sui-integration.ts

@@ -0,0 +1,633 @@
+import { NodeHttpTransport } from "@improbable-eng/grpc-web-node-http-transport";
+import {
+  afterAll,
+  beforeAll,
+  describe,
+  expect,
+  jest,
+  test,
+} from "@jest/globals";
+import {
+  Connection,
+  Ed25519Keypair,
+  JsonRpcProvider,
+  RawSigner,
+  fromB64,
+  getMoveObjectType,
+  getPublishedObjectChanges,
+} from "@mysten/sui.js";
+import { ethers } from "ethers";
+import { parseUnits } from "ethers/lib/utils";
+import {
+  approveEth,
+  attestFromEth,
+  attestFromSui,
+  createWrappedOnSui,
+  createWrappedOnSuiPrepare,
+  getEmitterAddressEth,
+  getForeignAssetSui,
+  getIsTransferCompletedEth,
+  getIsTransferCompletedSui,
+  getIsWrappedAssetSui,
+  getOriginalAssetSui,
+  getSignedVAAWithRetry,
+  parseAttestMetaVaa,
+  parseSequenceFromLogEth,
+  redeemOnEth,
+  redeemOnSui,
+  transferFromEth,
+  transferFromSui,
+  updateWrappedOnSui,
+} from "../..";
+import { MockTokenBridge } from "../../mock/tokenBridge";
+import { MockGuardians } from "../../mock/wormhole";
+import {
+  executeTransactionBlock,
+  getEmitterAddressAndSequenceFromResponseSui,
+  getInnerType,
+  getPackageId,
+  getWrappedCoinType,
+} from "../../sui";
+import {
+  CHAIN_ID_ETH,
+  CHAIN_ID_SUI,
+  CONTRACTS,
+  hexToUint8Array,
+  tryNativeToHexString,
+  tryNativeToUint8Array,
+} from "../../utils";
+import { Payload, VAA, parse, serialiseVAA } from "../../vaa/generic";
+import {
+  ETH_NODE_URL,
+  ETH_PRIVATE_KEY10,
+  SUI_FAUCET_URL,
+  SUI_NODE_URL,
+  TEST_ERC20,
+  WORMHOLE_RPC_HOSTS,
+} from "./utils/consts";
+import {
+  assertIsNotNull,
+  assertIsNotNullOrUndefined,
+  mintAndTransferCoinSui,
+} from "./utils/helpers";
+
+jest.setTimeout(60000);
+
+// Sui constants
+const SUI_CORE_BRIDGE_STATE_OBJECT_ID = CONTRACTS.DEVNET.sui.core;
+const SUI_TOKEN_BRIDGE_STATE_OBJECT_ID = CONTRACTS.DEVNET.sui.token_bridge;
+const SUI_DEPLOYER_PRIVATE_KEY = "AGA20wtGcwbcNAG4nwapbQ5wIuXwkYQEWFUoSVAxctHb";
+
+const suiKeypair: Ed25519Keypair = Ed25519Keypair.fromSecretKey(
+  fromB64(SUI_DEPLOYER_PRIVATE_KEY).slice(1)
+);
+const suiAddress: string = suiKeypair.getPublicKey().toSuiAddress();
+const suiProvider: JsonRpcProvider = new JsonRpcProvider(
+  new Connection({
+    fullnode: SUI_NODE_URL,
+    faucet: SUI_FAUCET_URL,
+  })
+);
+const suiSigner: RawSigner = new RawSigner(suiKeypair, suiProvider);
+
+// Eth constants
+const ETH_CORE_BRIDGE_ADDRESS = CONTRACTS.DEVNET.ethereum.core;
+const ETH_TOKEN_BRIDGE_ADDRESS = CONTRACTS.DEVNET.ethereum.token_bridge;
+
+const ethProvider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
+const ethSigner = new ethers.Wallet(ETH_PRIVATE_KEY10, ethProvider);
+
+let suiCoreBridgePackageId: string;
+let suiTokenBridgePackageId: string;
+
+beforeAll(async () => {
+  suiCoreBridgePackageId = await getPackageId(
+    suiProvider,
+    SUI_CORE_BRIDGE_STATE_OBJECT_ID
+  );
+  suiTokenBridgePackageId = await getPackageId(
+    suiProvider,
+    SUI_TOKEN_BRIDGE_STATE_OBJECT_ID
+  );
+});
+
+afterAll(async () => {
+  await ethProvider.destroy();
+});
+
+// Modify the VAA to only have 1 guardian signature
+// TODO: remove this when we can deploy the devnet core contract
+// deterministically with multiple guardians in the initial guardian set
+// Currently the core contract is setup with only 1 guardian in the set
+function sliceVAASignatures(vaa: Uint8Array) {
+  const parsedVAA = parse(Buffer.from([...vaa]));
+  parsedVAA.guardianSetIndex = 0;
+  parsedVAA.signatures = [parsedVAA.signatures[0]];
+  return hexToUint8Array(serialiseVAA(parsedVAA as VAA<Payload>));
+}
+
+describe("Sui SDK tests", () => {
+  test("Test prebuilt coin build output", async () => {
+    // const vaa =
+    //   "0100000000010026ff86c07ef853ef955a63c58a8d08eeb2ac232b91e725bd41baeb3c05c5c18d07aef3c02dc3d5ca8ad0600a447c3d55386d0a0e85b23378d438fbb1e207c3b600000002c3a86f000000020000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16000000000000000001020000000000000000000000002d8be6bf0baa74e0a907016679cae9190e80dd0a000212544b4e0000000000000000000000000000000000000000000000000000000000457468657265756d205465737420546f6b656e00000000000000000000000000";
+    // const build = getCoinBuildOutput(
+    //   suiCoreBridgePackageId,
+    //   suiTokenBridgePackageId,
+    //   vaa
+    // );
+    // const buildManual = await getCoinBuildOutputManual(
+    //   "DEVNET",
+    //   suiCoreBridgePackageId,
+    //   suiTokenBridgePackageId,
+    //   vaa
+    // );
+    // expect(build).toMatchObject(buildManual);
+    // expect(buildManual).toMatchObject(build);
+  });
+  test("Transfer native ERC-20 from Ethereum to Sui and back", async () => {
+    // Attest on Ethereum
+    const ethAttestTxRes = await attestFromEth(
+      ETH_TOKEN_BRIDGE_ADDRESS,
+      ethSigner,
+      TEST_ERC20
+    );
+
+    // Get attest VAA
+    const attestSequence = parseSequenceFromLogEth(
+      ethAttestTxRes,
+      ETH_CORE_BRIDGE_ADDRESS
+    );
+    expect(attestSequence).toBeTruthy();
+    let { vaaBytes: attestVAA }: { vaaBytes: Uint8Array } =
+      await getSignedVAAWithRetry(
+        WORMHOLE_RPC_HOSTS,
+        CHAIN_ID_ETH,
+        getEmitterAddressEth(ETH_TOKEN_BRIDGE_ADDRESS),
+        attestSequence,
+        {
+          transport: NodeHttpTransport(),
+        },
+        1000,
+        5
+      );
+    const slicedAttestVAA = sliceVAASignatures(attestVAA);
+    console.log(Buffer.from(slicedAttestVAA).toString("hex"));
+    expect(slicedAttestVAA).toBeTruthy();
+
+    // Start create wrapped on Sui
+    const suiPrepareRegistrationTxPayload = await createWrappedOnSuiPrepare(
+      suiProvider,
+      SUI_CORE_BRIDGE_STATE_OBJECT_ID,
+      SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+      parseAttestMetaVaa(slicedAttestVAA).decimals,
+      suiAddress
+    );
+    const suiPrepareRegistrationTxRes = await executeTransactionBlock(
+      suiSigner,
+      suiPrepareRegistrationTxPayload
+    );
+    suiPrepareRegistrationTxRes.effects?.status.status === "failure" &&
+      console.log(JSON.stringify(suiPrepareRegistrationTxRes.effects, null, 2));
+    expect(suiPrepareRegistrationTxRes.effects?.status.status).toBe("success");
+
+    // Complete create wrapped on Sui
+    const wrappedAssetSetupEvent =
+      suiPrepareRegistrationTxRes.objectChanges?.find(
+        (oc) =>
+          oc.type === "created" && oc.objectType.includes("WrappedAssetSetup")
+      );
+    const wrappedAssetSetupType =
+      (wrappedAssetSetupEvent?.type === "created" &&
+        wrappedAssetSetupEvent.objectType) ||
+      undefined;
+    assertIsNotNullOrUndefined(wrappedAssetSetupType);
+    const publishEvents = getPublishedObjectChanges(
+      suiPrepareRegistrationTxRes
+    );
+    expect(publishEvents.length).toBe(1);
+    const coinPackageId = publishEvents[0].packageId;
+    const suiCompleteRegistrationTxPayload = await createWrappedOnSui(
+      suiProvider,
+      SUI_CORE_BRIDGE_STATE_OBJECT_ID,
+      SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+      suiAddress,
+      coinPackageId,
+      wrappedAssetSetupType,
+      slicedAttestVAA
+    );
+    const suiCompleteRegistrationTxRes = await executeTransactionBlock(
+      suiSigner,
+      suiCompleteRegistrationTxPayload
+    );
+    suiCompleteRegistrationTxRes.effects?.status.status === "failure" &&
+      console.log(
+        JSON.stringify(suiCompleteRegistrationTxRes.effects, null, 2)
+      );
+    expect(suiCompleteRegistrationTxRes.effects?.status.status).toBe("success");
+
+    // Generate new VAA
+    const {
+      emitterAddress: ethEmitter,
+      emitterChain,
+      tokenAddress,
+      decimals,
+      symbol,
+    } = parseAttestMetaVaa(slicedAttestVAA);
+    const mockTokenBridge = new MockTokenBridge(
+      ethEmitter.toString("hex"),
+      emitterChain,
+      1
+    );
+    const updatedAttestPayload = mockTokenBridge.publishAttestMeta(
+      tokenAddress.toString("hex"),
+      decimals,
+      symbol,
+      "HELLO"
+    );
+    const mockGuardians = new MockGuardians(0, [
+      "cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0",
+    ]);
+    const updatedAttestVAA = new Uint8Array(
+      mockGuardians.addSignatures(updatedAttestPayload, [0])
+    );
+
+    // Update wrapped
+    const updateWrappedTxPayload = await updateWrappedOnSui(
+      suiProvider,
+      SUI_CORE_BRIDGE_STATE_OBJECT_ID,
+      SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+      coinPackageId,
+      updatedAttestVAA
+    );
+    const updateWrappedTxRes = await executeTransactionBlock(
+      suiSigner,
+      updateWrappedTxPayload
+    );
+    updateWrappedTxRes.effects?.status.status === "failure" &&
+      console.log(JSON.stringify(updateWrappedTxRes.effects, null, 2));
+    expect(updateWrappedTxRes.effects?.status.status).toBe("success");
+
+    // Check if update was propogated to coin metadata
+    const newCoinMetadata = await suiProvider.getCoinMetadata({
+      coinType: getWrappedCoinType(coinPackageId),
+    });
+    expect(newCoinMetadata?.name).toContain("HELLO");
+
+    // Get foreign asset
+    const originAssetHex = tryNativeToHexString(TEST_ERC20, CHAIN_ID_ETH);
+    if (!originAssetHex) {
+      throw new Error("originAssetHex is null");
+    }
+    const foreignAsset = await getForeignAssetSui(
+      suiProvider,
+      SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+      CHAIN_ID_ETH,
+      hexToUint8Array(originAssetHex)
+    );
+    assertIsNotNull(foreignAsset);
+    expect(
+      await getIsWrappedAssetSui(
+        suiProvider,
+        SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+        foreignAsset
+      )
+    ).toBe(true);
+
+    const originalAsset = await getOriginalAssetSui(
+      suiProvider,
+      SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+      foreignAsset
+    );
+    expect(originalAsset).toMatchObject({
+      isWrapped: true,
+      chainId: CHAIN_ID_ETH,
+      assetAddress: hexToUint8Array(originAssetHex),
+    });
+
+    const transferAmount = parseUnits("1", 18);
+    const returnAmount = parseUnits("1", 8);
+
+    // Transfer to Sui
+    await approveEth(
+      CONTRACTS.DEVNET.ethereum.token_bridge,
+      TEST_ERC20,
+      ethSigner,
+      transferAmount
+    );
+    const transferReceipt = await transferFromEth(
+      CONTRACTS.DEVNET.ethereum.token_bridge,
+      ethSigner,
+      TEST_ERC20,
+      transferAmount,
+      CHAIN_ID_SUI,
+      tryNativeToUint8Array(suiAddress, CHAIN_ID_SUI)
+    );
+    const ethSequence = parseSequenceFromLogEth(
+      transferReceipt,
+      ETH_CORE_BRIDGE_ADDRESS
+    );
+    let { vaaBytes: transferFromEthVAA } = await getSignedVAAWithRetry(
+      WORMHOLE_RPC_HOSTS,
+      CHAIN_ID_ETH,
+      getEmitterAddressEth(ETH_TOKEN_BRIDGE_ADDRESS),
+      ethSequence,
+      {
+        transport: NodeHttpTransport(),
+      },
+      1000,
+      5
+    );
+    const slicedTransferFromEthVAA = sliceVAASignatures(transferFromEthVAA);
+    expect(slicedTransferFromEthVAA).toBeTruthy();
+
+    // Redeem on Sui
+    const redeemPayload = await redeemOnSui(
+      suiProvider,
+      SUI_CORE_BRIDGE_STATE_OBJECT_ID,
+      SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+      slicedTransferFromEthVAA
+    );
+    const suiRedeemTxResult = await executeTransactionBlock(
+      suiSigner,
+      redeemPayload
+    );
+    suiRedeemTxResult.effects?.status.status === "failure" &&
+      console.error(suiRedeemTxResult.effects?.status.error);
+    expect(suiRedeemTxResult.effects?.status.status).toBe("success");
+    expect(
+      await getIsTransferCompletedSui(
+        suiProvider,
+        SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+        slicedTransferFromEthVAA
+      )
+    ).toBe(true);
+
+    // Transfer back to Eth
+    const coinType = await getForeignAssetSui(
+      suiProvider,
+      SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+      CHAIN_ID_ETH,
+      originalAsset.assetAddress
+    );
+    assertIsNotNull(coinType);
+    const coins = (
+      await suiProvider.getCoins({
+        owner: suiAddress,
+        coinType: coinType,
+      })
+    ).data;
+    console.log({ coins, coinType });
+    const suiTransferTxPayload = await transferFromSui(
+      suiProvider,
+      SUI_CORE_BRIDGE_STATE_OBJECT_ID,
+      SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+      coins,
+      coinType,
+      returnAmount.toBigInt(),
+      CHAIN_ID_ETH,
+      tryNativeToUint8Array(ethSigner.address, CHAIN_ID_ETH)
+    );
+    const suiTransferTxResult = await executeTransactionBlock(
+      suiSigner,
+      suiTransferTxPayload
+    );
+    suiTransferTxResult.effects?.status.status === "failure" &&
+      console.error(suiTransferTxResult.effects?.status.error);
+    expect(suiTransferTxResult.effects?.status.status).toBe("success");
+    const { sequence, emitterAddress } =
+      getEmitterAddressAndSequenceFromResponseSui(
+        suiCoreBridgePackageId,
+        suiTransferTxResult
+      );
+
+    // Fetch the transfer VAA
+    const { vaaBytes: transferFromSuiVAA } = await getSignedVAAWithRetry(
+      WORMHOLE_RPC_HOSTS,
+      CHAIN_ID_SUI,
+      emitterAddress,
+      sequence,
+      {
+        transport: NodeHttpTransport(),
+      },
+      1000,
+      5
+    );
+    expect(transferFromSuiVAA).toBeTruthy();
+
+    // Redeem on Ethereum
+    await redeemOnEth(ETH_TOKEN_BRIDGE_ADDRESS, ethSigner, transferFromSuiVAA);
+    expect(
+      await getIsTransferCompletedEth(
+        ETH_TOKEN_BRIDGE_ADDRESS,
+        ethProvider,
+        transferFromSuiVAA
+      )
+    ).toBe(true);
+  });
+  test.only("Transfer non-SUI Sui token to Ethereum and back", async () => {
+    // Get COIN_8 coin type
+    const res = await suiProvider.getOwnedObjects({
+      owner: suiAddress,
+      options: { showContent: true, showType: true },
+    });
+    const coins = res.data.filter((o) => {
+      const type = o.data?.type ?? "";
+      return type.includes("TreasuryCap") && type.includes("COIN_8");
+    });
+    expect(coins.length).toBeGreaterThan(0);
+
+    const coin8 = coins[0];
+    const coin8Type = getInnerType(getMoveObjectType(coin8) ?? "");
+    const coin8TreasuryCapObjectId = coin8.data?.objectId;
+    assertIsNotNullOrUndefined(coin8Type);
+    assertIsNotNullOrUndefined(coin8TreasuryCapObjectId);
+    expect(
+      await getIsWrappedAssetSui(
+        suiProvider,
+        SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+        coin8Type
+      )
+    ).toBe(false);
+
+    // Mint coins
+    const transferAmount = parseUnits("1", 8).toBigInt();
+    const suiMintTxPayload = mintAndTransferCoinSui(
+      coin8TreasuryCapObjectId,
+      coin8Type,
+      transferAmount,
+      suiAddress
+    );
+    let result = await executeTransactionBlock(suiSigner, suiMintTxPayload);
+    result.effects?.status.status === "failure" &&
+      console.log(JSON.stringify(result.effects, null, 2));
+    expect(result.effects?.status.status).toBe("success");
+
+    // Attest on Sui
+    const suiAttestTxPayload = await attestFromSui(
+      suiProvider,
+      SUI_CORE_BRIDGE_STATE_OBJECT_ID,
+      SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+      coin8Type
+    );
+    result = await executeTransactionBlock(suiSigner, suiAttestTxPayload);
+    result.effects?.status.status === "failure" &&
+      console.log(JSON.stringify(result.effects, null, 2));
+    expect(result.effects?.status.status).toBe("success");
+    const { sequence: attestSequence, emitterAddress: attestEmitterAddress } =
+      getEmitterAddressAndSequenceFromResponseSui(
+        suiCoreBridgePackageId,
+        result
+      );
+    expect(attestSequence).toBeTruthy();
+    expect(attestEmitterAddress).toBeTruthy();
+    const { vaaBytes: attestVAA } = await getSignedVAAWithRetry(
+      WORMHOLE_RPC_HOSTS,
+      CHAIN_ID_SUI,
+      attestEmitterAddress,
+      attestSequence,
+      {
+        transport: NodeHttpTransport(),
+      },
+      1000,
+      30
+    );
+    console.log(parseAttestMetaVaa(attestVAA));
+    expect(attestVAA).toBeTruthy();
+
+    //   // Create wrapped on Ethereum
+    //   try {
+    //     await createWrappedOnEth(ETH_TOKEN_BRIDGE_ADDRESS, ethSigner, attestVAA);
+    //   } catch (e) {
+    //     // this could fail because the token is already attested (in an unclean env)
+    //   }
+    //   const { tokenAddress } = parseAttestMetaVaa(attestVAA);
+    //   expect(
+    //     await getOriginalAssetSui(
+    //       suiProvider,
+    //       SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+    //       coin8Type
+    //     )
+    //   ).toMatchObject({
+    //     isWrapped: false,
+    //     chainId: CHAIN_ID_SUI,
+    //     assetAddress: new Uint8Array(tokenAddress),
+    //   });
+    //   const coin8Coins = await suiProvider.getCoins({
+    //     owner: suiAddress,
+    //     coinType: coin8Type,
+    //   });
+    //   expect(coin8Coins.data.length).toBeGreaterThan(0);
+
+    //   // Transfer to Ethereum
+    //   const suiTransferTxPayload = await transferFromSui(
+    //     suiProvider,
+    //     SUI_CORE_BRIDGE_STATE_OBJECT_ID,
+    //     SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+    //     coin8Coins.data,
+    //     coin8Type,
+    //     transferAmount,
+    //     CHAIN_ID_ETH,
+    //     tryNativeToUint8Array(ethSigner.address, CHAIN_ID_ETH)
+    //   );
+    //   result = await executeTransactionBlock(suiSigner, suiTransferTxPayload);
+    //   result.effects?.status.status === "failure" &&
+    //     console.log(JSON.stringify(result.effects, null, 2));
+    //   expect(result.effects?.status.status).toBe("success");
+    //   const { sequence, emitterAddress } =
+    //     getEmitterAddressAndSequenceFromResponseSui(
+    //       suiCoreBridgePackageId,
+    //       result
+    //     );
+    //   expect(sequence).toBeTruthy();
+    //   expect(emitterAddress).toBeTruthy();
+
+    //   // Fetch the transfer VAA
+    //   const { vaaBytes: transferVAA } = await getSignedVAAWithRetry(
+    //     WORMHOLE_RPC_HOSTS,
+    //     CHAIN_ID_SUI,
+    //     emitterAddress,
+    //     sequence!,
+    //     {
+    //       transport: NodeHttpTransport(),
+    //     },
+    //     1000,
+    //     30
+    //   );
+
+    //   // Redeem on Ethereum
+    //   await redeemOnEth(ETH_TOKEN_BRIDGE_ADDRESS, ethSigner, transferVAA);
+    //   expect(
+    //     await getIsTransferCompletedEth(
+    //       ETH_TOKEN_BRIDGE_ADDRESS,
+    //       ethProvider,
+    //       transferVAA
+    //     )
+    //   ).toBe(true);
+
+    //   // Transfer back to Sui
+    //   const ethTokenAddress = await getForeignAssetEth(
+    //     ETH_TOKEN_BRIDGE_ADDRESS,
+    //     ethProvider,
+    //     CHAIN_ID_SUI,
+    //     tokenAddress
+    //   );
+    //   expect(ethTokenAddress).toBeTruthy();
+    //   await approveEth(
+    //     ETH_TOKEN_BRIDGE_ADDRESS,
+    //     ethTokenAddress!,
+    //     ethSigner,
+    //     transferAmount
+    //   );
+    //   const transferReceipt = await transferFromEth(
+    //     ETH_TOKEN_BRIDGE_ADDRESS,
+    //     ethSigner,
+    //     ethTokenAddress!,
+    //     transferAmount,
+    //     CHAIN_ID_SUI,
+    //     tryNativeToUint8Array(suiAddress, CHAIN_ID_SUI)
+    //   );
+    //   const ethSequence = parseSequenceFromLogEth(
+    //     transferReceipt,
+    //     ETH_CORE_BRIDGE_ADDRESS
+    //   );
+    //   expect(ethSequence).toBeTruthy();
+    //   const { vaaBytes: ethTransferVAA } = await getSignedVAAWithRetry(
+    //     WORMHOLE_RPC_HOSTS,
+    //     CHAIN_ID_ETH,
+    //     getEmitterAddressEth(ETH_TOKEN_BRIDGE_ADDRESS),
+    //     ethSequence,
+    //     {
+    //       transport: NodeHttpTransport(),
+    //     },
+    //     1000,
+    //     30
+    //   );
+    //   const slicedVAA = sliceVAASignatures(ethTransferVAA);
+
+    //   // Redeem on Sui
+    //   expect(
+    //     await getIsTransferCompletedSui(
+    //       suiProvider,
+    //       SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+    //       slicedVAA
+    //     )
+    //   ).toBe(false);
+    //   const redeemPayload = await redeemOnSui(
+    //     suiProvider,
+    //     SUI_CORE_BRIDGE_STATE_OBJECT_ID,
+    //     SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+    //     slicedVAA
+    //   );
+    //   result = await executeTransactionBlock(suiSigner, redeemPayload);
+    //   result.effects?.status.status === "failure" &&
+    //     console.log(JSON.stringify(result.effects, null, 2));
+    //   expect(result.effects?.status.status).toBe("success");
+    //   expect(
+    //     await getIsTransferCompletedSui(
+    //       suiProvider,
+    //       SUI_TOKEN_BRIDGE_STATE_OBJECT_ID,
+    //       slicedVAA
+    //     )
+    //   ).toBe(true);
+  });
+});

+ 2 - 2
sdk/js/src/token_bridge/__tests__/terra-integration.ts

@@ -45,13 +45,13 @@ import {
   TERRA_PUBLIC_KEY,
   TEST_ERC20,
   WORMHOLE_RPC_HOSTS,
-} from "./consts";
+} from "./utils/consts";
 import {
   getSignedVAABySequence,
   getTerraGasPrices,
   queryBalanceOnTerra,
   waitForTerraExecution,
-} from "./helpers";
+} from "./utils/helpers";
 
 jest.setTimeout(60000);
 

+ 2 - 2
sdk/js/src/token_bridge/__tests__/terra2-integration.ts

@@ -40,8 +40,8 @@ import {
   TERRA_NODE_URL,
   TERRA_PRIVATE_KEY2,
   TEST_ERC20,
-} from "./consts";
-import { getSignedVAABySequence, waitForTerraExecution } from "./helpers";
+} from "./utils/consts";
+import { getSignedVAABySequence, waitForTerraExecution } from "./utils/helpers";
 
 const lcd = new LCDClient({
   URL: TERRA2_NODE_URL,

+ 6 - 9
sdk/js/src/token_bridge/__tests__/consts.ts → sdk/js/src/token_bridge/__tests__/utils/consts.ts

@@ -22,6 +22,8 @@ export const ETH_PRIVATE_KEY7 =
   "0xa453611d9419d0e56f499079478fd72c37b251a94bfde4d19872c44cf65386e3"; // account 7 - algorand tests
 export const ETH_PRIVATE_KEY9 =
   "0xb0057716d5917badaf911b193b12b910811c1497b5bada8d7711f758981c3773"; // account 9 - accountant tests
+export const ETH_PRIVATE_KEY10 =
+  "0x77c5495fbb039eed474fc940f29955ed0531693cc9212911efd35dff0373153f"; // account 10 - sui tests
 export const SOLANA_HOST = ci
   ? "http://solana-devnet:8899"
   : "http://localhost:8899";
@@ -95,12 +97,7 @@ export const APTOS_FAUCET_URL = ci
 export const APTOS_PRIVATE_KEY =
   "537c1f91e56891445b491068f519b705f8c0f1a1e66111816dd5d4aa85b8113d";
 
-describe("consts should exist", () => {
-  it("has Solana test token", () => {
-    expect.assertions(1);
-    const connection = new Connection(SOLANA_HOST, "confirmed");
-    return expect(
-      connection.getAccountInfo(new PublicKey(TEST_SOLANA_TOKEN))
-    ).resolves.toBeTruthy();
-  });
-});
+export const SUI_NODE_URL = ci ? "http://sui:9000" : "http://localhost:9000";
+export const SUI_FAUCET_URL = ci
+  ? "http://sui:5003/gas"
+  : "http://localhost:5003/gas";

+ 30 - 1
sdk/js/src/token_bridge/__tests__/helpers.ts → sdk/js/src/token_bridge/__tests__/utils/helpers.ts

@@ -1,7 +1,9 @@
 import { NodeHttpTransport } from "@improbable-eng/grpc-web-node-http-transport";
+import { expect } from "@jest/globals";
+import { TransactionBlock } from "@mysten/sui.js";
 import { LCDClient, MnemonicKey, TxInfo } from "@terra-money/terra.js";
 import axios from "axios";
-import { ChainId, getSignedVAAWithRetry } from "../..";
+import { ChainId, getSignedVAAWithRetry } from "../../..";
 import {
   TERRA_CHAIN_ID,
   TERRA_GAS_PRICES_URL,
@@ -98,3 +100,30 @@ export async function queryBalanceOnTerra(asset: string): Promise<number> {
 export async function getTerraGasPrices() {
   return axios.get(TERRA_GAS_PRICES_URL).then((result) => result.data);
 }
+
+// https://github.com/microsoft/TypeScript/issues/34523
+export const assertIsNotNull: <T>(x: T | null) => asserts x is T = (x) => {
+  expect(x).not.toBeNull();
+};
+
+export const assertIsNotNullOrUndefined: <T>(
+  x: T | null | undefined
+) => asserts x is T = (x) => {
+  expect(x).not.toBeNull();
+  expect(x).not.toBeUndefined();
+};
+
+export function mintAndTransferCoinSui(
+  treasuryCap: string,
+  coinType: string,
+  amount: bigint,
+  recipient: string
+) {
+  const tx = new TransactionBlock();
+  tx.moveCall({
+    target: "0x2::coin::mint_and_transfer",
+    arguments: [tx.object(treasuryCap), tx.pure(amount), tx.pure(recipient)],
+    typeArguments: [coinType],
+  });
+  return tx;
+}

+ 53 - 5
sdk/js/src/token_bridge/attest.ts

@@ -1,3 +1,8 @@
+import {
+  JsonRpcProvider,
+  SUI_CLOCK_OBJECT_ID,
+  TransactionBlock,
+} from "@mysten/sui.js";
 import {
   Commitment,
   Connection,
@@ -10,30 +15,31 @@ import { MsgExecuteContract } from "@terra-money/terra.js";
 import { MsgExecuteContract as XplaMsgExecuteContract } from "@xpla/xpla.js";
 import {
   Algodv2,
-  OnApplicationComplete,
-  SuggestedParams,
   bigIntToBytes,
   decodeAddress,
   getApplicationAddress,
   makeApplicationCallTxnFromObject,
   makePaymentTxnWithSuggestedParamsFromObject,
+  OnApplicationComplete,
+  SuggestedParams,
 } from "algosdk";
 import { Types } from "aptos";
 import BN from "bn.js";
-import { PayableOverrides, ethers } from "ethers";
+import { ethers, PayableOverrides } from "ethers";
 import { FunctionCallOptions } from "near-api-js/lib/account";
 import { Provider } from "near-api-js/lib/providers";
 import { getIsWrappedAssetNear } from ".";
-import { TransactionSignerPair, getMessageFee, optin } from "../algorand";
+import { getMessageFee, optin, TransactionSignerPair } from "../algorand";
 import { attestToken as attestTokenAptos } from "../aptos";
 import { isNativeDenomXpla } from "../cosmwasm";
 import { Bridge__factory } from "../ethers-contracts";
 import { createBridgeFeeTransferInstruction } from "../solana";
 import { createAttestTokenInstruction } from "../solana/tokenBridge";
+import { getPackageId } from "../sui/utils";
 import { isNativeDenom } from "../terra";
 import {
-  ChainId,
   callFunctionNear,
+  ChainId,
   hashAccount,
   textToHexString,
   textToUint8Array,
@@ -306,3 +312,45 @@ export function attestFromAptos(
 ): Types.EntryFunctionPayload {
   return attestTokenAptos(tokenBridgeAddress, tokenChain, tokenAddress);
 }
+
+export async function attestFromSui(
+  provider: JsonRpcProvider,
+  coreBridgeStateObjectId: string,
+  tokenBridgeStateObjectId: string,
+  coinType: string,
+  feeAmount: BigInt = BigInt(0)
+): Promise<TransactionBlock> {
+  const metadata = await provider.getCoinMetadata({ coinType });
+  if (metadata === null || metadata.id === null) {
+    throw new Error(`Coin metadata ID for type ${coinType} not found`);
+  }
+  const coreBridgePackageId = await getPackageId(
+    provider,
+    coreBridgeStateObjectId
+  );
+  const tokenBridgePackageId = await getPackageId(
+    provider,
+    tokenBridgeStateObjectId
+  );
+  const tx = new TransactionBlock();
+  const [feeCoin] = tx.splitCoins(tx.gas, [tx.pure(feeAmount)]);
+  const [messageTicket] = tx.moveCall({
+    target: `${tokenBridgePackageId}::attest_token::attest_token`,
+    arguments: [
+      tx.object(tokenBridgeStateObjectId),
+      tx.object(metadata.id),
+      tx.pure(createNonce().readUInt32LE()),
+    ],
+    typeArguments: [coinType],
+  });
+  tx.moveCall({
+    target: `${coreBridgePackageId}::publish_message::publish_message`,
+    arguments: [
+      tx.object(coreBridgeStateObjectId),
+      feeCoin,
+      messageTicket,
+      tx.object(SUI_CLOCK_OBJECT_ID),
+    ],
+  });
+  return tx;
+}

+ 114 - 1
sdk/js/src/token_bridge/createWrapped.ts

@@ -1,3 +1,8 @@
+import {
+  JsonRpcProvider,
+  SUI_CLOCK_OBJECT_ID,
+  TransactionBlock,
+} from "@mysten/sui.js";
 import {
   Commitment,
   Connection,
@@ -10,7 +15,7 @@ import { MsgExecuteContract as XplaMsgExecuteContract } from "@xpla/xpla.js";
 import { Algodv2 } from "algosdk";
 import { Types } from "aptos";
 import BN from "bn.js";
-import { Overrides, ethers } from "ethers";
+import { ethers, Overrides } from "ethers";
 import { fromUint8Array } from "js-base64";
 import { FunctionCallOptions } from "near-api-js/lib/account";
 import { Provider } from "near-api-js/lib/providers";
@@ -21,6 +26,13 @@ import {
 } from "../aptos";
 import { Bridge__factory } from "../ethers-contracts";
 import { createCreateWrappedInstruction } from "../solana/tokenBridge";
+import {
+  getOwnedObjectId,
+  getPackageId,
+  getUpgradeCapObjectId,
+  getWrappedCoinType,
+  publishCoin,
+} from "../sui";
 import { callFunctionNear } from "../utils";
 import { SignedVaa } from "../vaa";
 
@@ -157,3 +169,104 @@ export function createWrappedOnAptos(
 ): Types.EntryFunctionPayload {
   return createWrappedCoinAptos(tokenBridgeAddress, attestVAA);
 }
+
+export async function createWrappedOnSuiPrepare(
+  provider: JsonRpcProvider,
+  coreBridgeStateObjectId: string,
+  tokenBridgeStateObjectId: string,
+  decimals: number,
+  signerAddress: string
+): Promise<TransactionBlock> {
+  return publishCoin(
+    provider,
+    coreBridgeStateObjectId,
+    tokenBridgeStateObjectId,
+    decimals,
+    signerAddress
+  );
+}
+
+export async function createWrappedOnSui(
+  provider: JsonRpcProvider,
+  coreBridgeStateObjectId: string,
+  tokenBridgeStateObjectId: string,
+  signerAddress: string,
+  coinPackageId: string,
+  wrappedAssetSetupType: string,
+  attestVAA: Uint8Array
+): Promise<TransactionBlock> {
+  // WrappedAssetSetup looks like
+  // 0x92d81f28c167d90f84638c654b412fe7fa8e55bdfac7f638bdcf70306289be86::create_wrapped::WrappedAssetSetup<0xa40e0511f7d6531dd2dfac0512c7fd4a874b76f5994985fb17ee04501a2bb050::coin::COIN, 0x4eb7c5bca3759ab3064b46044edb5668c9066be8a543b28b58375f041f876a80::version_control::V__0_1_1>
+
+  // ugh
+  const versionType = wrappedAssetSetupType.split(", ")[1].replace(">", "");
+
+  const coreBridgePackageId = await getPackageId(
+    provider,
+    coreBridgeStateObjectId
+  );
+  const tokenBridgePackageId = await getPackageId(
+    provider,
+    tokenBridgeStateObjectId
+  );
+
+  // Get coin metadata
+  const coinType = getWrappedCoinType(coinPackageId);
+  const coinMetadataObjectId = (await provider.getCoinMetadata({ coinType }))
+    ?.id;
+  if (!coinMetadataObjectId) {
+    throw new Error(
+      `Coin metadata object not found for coin type ${coinType}.`
+    );
+  }
+
+  const wrappedAssetSetupObjectId = await getOwnedObjectId(
+    provider,
+    signerAddress,
+    wrappedAssetSetupType
+  );
+  if (!wrappedAssetSetupObjectId) {
+    throw new Error(`WrappedAssetSetup not found`);
+  }
+
+  // Get coin upgrade capability
+  const coinUpgradeCapObjectId = await getUpgradeCapObjectId(
+    provider,
+    signerAddress,
+    coinPackageId
+  );
+  if (!coinUpgradeCapObjectId) {
+    throw new Error(
+      `Coin upgrade cap not found for ${coinType} under owner ${signerAddress}. You must call 'createWrappedOnSuiPrepare' first.`
+    );
+  }
+
+  // Get TokenBridgeMessage
+  const tx = new TransactionBlock();
+  const [vaa] = tx.moveCall({
+    target: `${coreBridgePackageId}::vaa::parse_and_verify`,
+    arguments: [
+      tx.object(coreBridgeStateObjectId),
+      tx.pure([...attestVAA]),
+      tx.object(SUI_CLOCK_OBJECT_ID),
+    ],
+  });
+  const [message] = tx.moveCall({
+    target: `${tokenBridgePackageId}::vaa::verify_only_once`,
+    arguments: [tx.object(tokenBridgeStateObjectId), vaa],
+  });
+
+  // Construct complete registration payload
+  tx.moveCall({
+    target: `${tokenBridgePackageId}::create_wrapped::complete_registration`,
+    arguments: [
+      tx.object(tokenBridgeStateObjectId),
+      tx.object(coinMetadataObjectId),
+      tx.object(wrappedAssetSetupObjectId),
+      tx.object(coinUpgradeCapObjectId),
+      message,
+    ],
+    typeArguments: [coinType, versionType],
+  });
+  return tx;
+}

+ 19 - 2
sdk/js/src/token_bridge/getForeignAsset.ts

@@ -1,3 +1,4 @@
+import { JsonRpcProvider } from "@mysten/sui.js";
 import { Commitment, Connection, PublicKeyInitData } from "@solana/web3.js";
 import { LCDClient } from "@terra-money/terra.js";
 import { LCDClient as XplaLCDClient } from "@xpla/xpla.js";
@@ -13,11 +14,12 @@ import {
 } from "../algorand";
 import { Bridge__factory } from "../ethers-contracts";
 import { deriveWrappedMintKey, getWrappedMeta } from "../solana/tokenBridge";
+import { getTokenCoinType } from "../sui";
 import {
-  CHAIN_ID_ALGORAND,
+  callFunctionNear,
   ChainId,
   ChainName,
-  callFunctionNear,
+  CHAIN_ID_ALGORAND,
   coalesceChainId,
   coalesceModuleAddress,
   getAssetFullyQualifiedType,
@@ -202,3 +204,18 @@ export async function getForeignAssetAptos(
     return null;
   }
 }
+
+export async function getForeignAssetSui(
+  provider: JsonRpcProvider,
+  tokenBridgeStateObjectId: string,
+  originChain: ChainId | ChainName,
+  originAddress: Uint8Array
+): Promise<string | null> {
+  const originChainId = coalesceChainId(originChain);
+  return getTokenCoinType(
+    provider,
+    tokenBridgeStateObjectId,
+    originAddress,
+    originChainId
+  );
+}

+ 47 - 3
sdk/js/src/token_bridge/getIsTransferCompleted.ts

@@ -1,3 +1,4 @@
+import { JsonRpcProvider } from "@mysten/sui.js";
 import { Commitment, Connection, PublicKeyInitData } from "@solana/web3.js";
 import { LCDClient } from "@terra-money/terra.js";
 import { LCDClient as XplaLCDClient } from "@xpla/xpla.js";
@@ -8,20 +9,21 @@ import { ethers } from "ethers";
 import { fromUint8Array } from "js-base64";
 import { Provider } from "near-api-js/lib/providers";
 import { redeemOnTerra } from ".";
-import { TERRA_REDEEMED_CHECK_WALLET_ADDRESS, ensureHexPrefix } from "..";
+import { ensureHexPrefix, TERRA_REDEEMED_CHECK_WALLET_ADDRESS } from "..";
 import {
   BITS_PER_KEY,
+  calcLogicSigAccount,
   MAX_BITS,
   _parseVAAAlgorand,
-  calcLogicSigAccount,
 } from "../algorand";
 import { TokenBridgeState } from "../aptos/types";
 import { getSignedVAAHash } from "../bridge";
 import { Bridge__factory } from "../ethers-contracts";
 import { getClaim } from "../solana/wormhole";
+import { getObjectFields, getTableKeyType } from "../sui/utils";
 import { safeBigIntToNumber } from "../utils/bigint";
 import { callFunctionNear } from "../utils/near";
-import { SignedVaa, parseVaa } from "../vaa/wormhole";
+import { parseVaa, SignedVaa } from "../vaa/wormhole";
 
 export async function getIsTransferCompletedEth(
   tokenBridgeAddress: string,
@@ -265,3 +267,45 @@ export async function getIsTransferCompletedAptos(
     return false;
   }
 }
+
+export async function getIsTransferCompletedSui(
+  provider: JsonRpcProvider,
+  tokenBridgeStateObjectId: string,
+  transferVAA: Uint8Array
+): Promise<boolean> {
+  const tokenBridgeStateFields = await getObjectFields(
+    provider,
+    tokenBridgeStateObjectId
+  );
+  if (!tokenBridgeStateFields) {
+    throw new Error("Unable to fetch object fields from token bridge state");
+  }
+  const hashes = tokenBridgeStateFields.consumed_vaas?.fields?.hashes;
+  const tableObjectId = hashes?.fields?.items?.fields?.id?.id;
+  if (!tableObjectId) {
+    throw new Error("Unable to fetch consumed VAAs table");
+  }
+  const keyType = getTableKeyType(hashes?.fields?.items?.type);
+  if (!keyType) {
+    throw new Error("Unable to get key type");
+  }
+  const hash = getSignedVAAHash(transferVAA);
+  const response = await provider.getDynamicFieldObject({
+    parentId: tableObjectId,
+    name: {
+      type: keyType,
+      value: {
+        data: [...Buffer.from(hash.slice(2), "hex")],
+      },
+    },
+  });
+  if (!response.error) {
+    return true;
+  }
+  if (response.error.code === "dynamicFieldNotFound") {
+    return false;
+  }
+  throw new Error(
+    `Unexpected getDynamicFieldObject response ${response.error}`
+  );
+}

+ 28 - 0
sdk/js/src/token_bridge/getIsWrappedAsset.ts

@@ -1,3 +1,4 @@
+import { JsonRpcProvider } from "@mysten/sui.js";
 import { Commitment, Connection, PublicKeyInitData } from "@solana/web3.js";
 import { LCDClient } from "@terra-money/terra.js";
 import { Algodv2, getApplicationAddress } from "algosdk";
@@ -5,6 +6,7 @@ import { AptosClient } from "aptos";
 import { ethers } from "ethers";
 import { Bridge__factory } from "../ethers-contracts";
 import { getWrappedMeta } from "../solana/tokenBridge";
+import { getTokenFromTokenRegistry } from "../sui";
 import { coalesceModuleAddress, ensureHexPrefix } from "../utils";
 import { safeBigIntToNumber } from "../utils/bigint";
 
@@ -112,3 +114,29 @@ export async function getIsWrappedAssetAptos(
     return false;
   }
 }
+
+export async function getIsWrappedAssetSui(
+  provider: JsonRpcProvider,
+  tokenBridgeStateObjectId: string,
+  type: string
+): Promise<boolean> {
+  // // An easy way to determine if given asset isn't a wrapped asset is to ensure
+  // // module name and struct name are coin and COIN respectively.
+  // if (!type.endsWith("::coin::COIN")) {
+  //   return false;
+  // }
+  const response = await getTokenFromTokenRegistry(
+    provider,
+    tokenBridgeStateObjectId,
+    type
+  );
+  if (!response.error) {
+    return response.data?.type?.includes("WrappedAsset") || false;
+  }
+  if (response.error.code === "dynamicFieldNotFound") {
+    return false;
+  }
+  throw new Error(
+    `Unexpected getDynamicFieldObject response ${response.error}`
+  );
+}

+ 75 - 9
sdk/js/src/token_bridge/getOriginalAsset.ts

@@ -1,3 +1,4 @@
+import { JsonRpcProvider } from "@mysten/sui.js";
 import {
   Commitment,
   Connection,
@@ -18,21 +19,28 @@ import { canonicalAddress } from "../cosmos";
 import { buildTokenId, isNativeCosmWasmDenom } from "../cosmwasm/address";
 import { TokenImplementation__factory } from "../ethers-contracts";
 import { getWrappedMeta } from "../solana/tokenBridge";
+import {
+  getFieldsFromObjectResponse,
+  getTokenFromTokenRegistry,
+  isValidSuiType,
+  unnormalizeSuiAddress,
+} from "../sui";
 import { buildNativeId } from "../terra";
 import {
+  assertChain,
+  callFunctionNear,
+  ChainId,
+  ChainName,
   CHAIN_ID_ALGORAND,
   CHAIN_ID_APTOS,
   CHAIN_ID_NEAR,
   CHAIN_ID_SOLANA,
+  CHAIN_ID_SUI,
   CHAIN_ID_TERRA,
-  ChainId,
-  ChainName,
-  CosmWasmChainId,
-  CosmWasmChainName,
-  assertChain,
-  callFunctionNear,
   coalesceChainId,
   coalesceCosmWasmChainId,
+  CosmWasmChainId,
+  CosmWasmChainName,
   hexToUint8Array,
   isValidAptosType,
 } from "../utils";
@@ -267,13 +275,13 @@ export async function getOriginalAssetNear(
 /**
  * Gets the origin chain ID and address of an asset on Aptos, given its fully qualified type.
  * @param client Client used to transfer data to/from Aptos node
- * @param tokenBridgeAddress Address of token bridge
+ * @param tokenBridgePackageId Address of token bridge
  * @param fullyQualifiedType Fully qualified type of asset
  * @returns Original chain ID and address of asset
  */
 export async function getOriginalAssetAptos(
   client: AptosClient,
-  tokenBridgeAddress: string,
+  tokenBridgePackageId: string,
   fullyQualifiedType: string
 ): Promise<WormholeWrappedInfo> {
   if (!isValidAptosType(fullyQualifiedType)) {
@@ -285,7 +293,7 @@ export async function getOriginalAssetAptos(
     originInfo = (
       await client.getAccountResource(
         fullyQualifiedType.split("::")[0],
-        `${tokenBridgeAddress}::state::OriginInfo`
+        `${tokenBridgePackageId}::state::OriginInfo`
       )
     ).data as OriginInfo;
   } catch {
@@ -317,3 +325,61 @@ export async function getOriginalAssetAptos(
     };
   }
 }
+
+export async function getOriginalAssetSui(
+  provider: JsonRpcProvider,
+  tokenBridgeStateObjectId: string,
+  coinType: string
+): Promise<WormholeWrappedInfo> {
+  if (!isValidSuiType(coinType)) {
+    throw new Error(`Invalid Sui type: ${coinType}`);
+  }
+
+  const res = await getTokenFromTokenRegistry(
+    provider,
+    tokenBridgeStateObjectId,
+    coinType
+  );
+  const fields = getFieldsFromObjectResponse(res);
+  if (!fields) {
+    throw new Error(
+      `Token of type ${coinType} has not been registered with the token bridge`
+    );
+  }
+
+  if (
+    fields.value.type.includes(`wrapped_asset::WrappedAsset<${coinType}>`) ||
+    fields.value.type.includes(
+      `wrapped_asset::WrappedAsset<${unnormalizeSuiAddress(coinType)}>`
+    )
+  ) {
+    return {
+      isWrapped: true,
+      chainId: Number(fields.value.fields.info.fields.token_chain) as ChainId,
+      assetAddress: new Uint8Array(
+        fields.value.fields.info.fields.token_address.fields.value.fields.data
+      ),
+    };
+  } else if (
+    fields.value.type.includes(`native_asset::NativeAsset<${coinType}>`) ||
+    fields.value.type.includes(
+      `native_asset::NativeAsset<${unnormalizeSuiAddress(coinType)}>`
+    )
+  ) {
+    return {
+      isWrapped: false,
+      chainId: CHAIN_ID_SUI,
+      assetAddress: new Uint8Array(
+        fields.value.fields.token_address.fields.value.fields.data
+      ),
+    };
+  }
+
+  throw new Error(
+    `Unrecognized token metadata: ${JSON.stringify(
+      fields,
+      null,
+      2
+    )}, ${coinType}`
+  );
+}

+ 63 - 2
sdk/js/src/token_bridge/redeem.ts

@@ -1,3 +1,8 @@
+import {
+  JsonRpcProvider,
+  SUI_CLOCK_OBJECT_ID,
+  TransactionBlock,
+} from "@mysten/sui.js";
 import {
   ACCOUNT_SIZE,
   createCloseAccountInstruction,
@@ -27,9 +32,9 @@ import { fromUint8Array } from "js-base64";
 import { FunctionCallOptions } from "near-api-js/lib/account";
 import { Provider } from "near-api-js/lib/providers";
 import {
+  TransactionSignerPair,
   _parseVAAAlgorand,
   _submitVAAAlgorand,
-  TransactionSignerPair,
 } from "../algorand";
 import { completeTransferAndRegister } from "../aptos";
 import { Bridge__factory } from "../ethers-contracts";
@@ -37,11 +42,12 @@ import {
   createCompleteTransferNativeInstruction,
   createCompleteTransferWrappedInstruction,
 } from "../solana/tokenBridge";
+import { getPackageId, getTokenCoinType } from "../sui";
 import {
   callFunctionNear,
+  ChainId,
   CHAIN_ID_NEAR,
   CHAIN_ID_SOLANA,
-  ChainId,
   hashLookup,
   MAX_VAA_DECIMALS,
   uint8ArrayToHex,
@@ -356,3 +362,58 @@ export function redeemOnAptos(
 ): Promise<Types.EntryFunctionPayload> {
   return completeTransferAndRegister(client, tokenBridgeAddress, transferVAA);
 }
+
+export async function redeemOnSui(
+  provider: JsonRpcProvider,
+  coreBridgeStateObjectId: string,
+  tokenBridgeStateObjectId: string,
+  transferVAA: Uint8Array
+): Promise<TransactionBlock> {
+  const { tokenAddress, tokenChain } = parseTokenTransferVaa(transferVAA);
+  const coinType = await getTokenCoinType(
+    provider,
+    tokenBridgeStateObjectId,
+    tokenAddress,
+    tokenChain
+  );
+  if (!coinType) {
+    throw new Error("Unable to fetch token coinType");
+  }
+  const coreBridgePackageId = await getPackageId(
+    provider,
+    coreBridgeStateObjectId
+  );
+  const tokenBridgePackageId = await getPackageId(
+    provider,
+    tokenBridgeStateObjectId
+  );
+  const tx = new TransactionBlock();
+  const [verifiedVAA] = tx.moveCall({
+    target: `${coreBridgePackageId}::vaa::parse_and_verify`,
+    arguments: [
+      tx.object(coreBridgeStateObjectId),
+      tx.pure([...transferVAA]),
+      tx.object(SUI_CLOCK_OBJECT_ID),
+    ],
+  });
+  const [tokenBridgeMessage] = tx.moveCall({
+    target: `${tokenBridgePackageId}::vaa::verify_only_once`,
+    arguments: [tx.object(tokenBridgeStateObjectId), verifiedVAA],
+  });
+  const [relayerReceipt] = tx.moveCall({
+    target: `${tokenBridgePackageId}::complete_transfer::authorize_transfer`,
+    arguments: [tx.object(tokenBridgeStateObjectId), tokenBridgeMessage],
+    typeArguments: [coinType],
+  });
+  const [coins] = tx.moveCall({
+    target: `${tokenBridgePackageId}::complete_transfer::redeem_relayer_payout`,
+    arguments: [relayerReceipt],
+    typeArguments: [coinType],
+  });
+  tx.moveCall({
+    target: `${tokenBridgePackageId}::coin_utils::return_nonzero`,
+    arguments: [coins],
+    typeArguments: [coinType],
+  });
+  return tx;
+}

+ 98 - 3
sdk/js/src/token_bridge/transfer.ts

@@ -1,3 +1,9 @@
+import {
+  JsonRpcProvider,
+  SUI_CLOCK_OBJECT_ID,
+  SUI_TYPE_ARG,
+  TransactionBlock,
+} from "@mysten/sui.js";
 import {
   ACCOUNT_SIZE,
   createCloseAccountInstruction,
@@ -12,14 +18,13 @@ import {
   Keypair,
   PublicKey,
   PublicKeyInitData,
-  Transaction as SolanaTransaction,
   SystemProgram,
+  Transaction as SolanaTransaction,
 } from "@solana/web3.js";
 import { MsgExecuteContract } from "@terra-money/terra.js";
 import { MsgExecuteContract as XplaMsgExecuteContract } from "@xpla/xpla.js";
 import {
   Algodv2,
-  Transaction as AlgorandTransaction,
   bigIntToBytes,
   getApplicationAddress,
   makeApplicationCallTxnFromObject,
@@ -27,6 +32,7 @@ import {
   makePaymentTxnWithSuggestedParamsFromObject,
   OnApplicationComplete,
   SuggestedParams,
+  Transaction as AlgorandTransaction,
 } from "algosdk";
 import { Types } from "aptos";
 import BN from "bn.js";
@@ -57,12 +63,14 @@ import {
   createTransferWrappedInstruction,
   createTransferWrappedWithPayloadInstruction,
 } from "../solana/tokenBridge";
+import { getPackageId, isSameType } from "../sui";
+import { SuiCoinObject } from "../sui/types";
 import { isNativeDenom } from "../terra";
 import {
   callFunctionNear,
-  CHAIN_ID_SOLANA,
   ChainId,
   ChainName,
+  CHAIN_ID_SOLANA,
   coalesceChainId,
   createNonce,
   hexToUint8Array,
@@ -913,3 +921,90 @@ export function transferFromAptos(
     createNonce().readUInt32LE(0)
   );
 }
+
+export async function transferFromSui(
+  provider: JsonRpcProvider,
+  coreBridgeStateObjectId: string,
+  tokenBridgeStateObjectId: string,
+  coins: SuiCoinObject[],
+  coinType: string,
+  amount: bigint,
+  recipientChain: ChainId | ChainName,
+  recipient: Uint8Array,
+  feeAmount: bigint = BigInt(0),
+  relayerFee: bigint = BigInt(0),
+  payload: Uint8Array | null = null
+) {
+  if (payload !== null) {
+    throw new Error("Sui transfer with payload not implemented");
+  }
+  const [primaryCoin, ...mergeCoins] = coins.filter((coin) =>
+    isSameType(coin.coinType, coinType)
+  );
+  if (primaryCoin === undefined) {
+    throw new Error(
+      `Coins array doesn't contain any coins of type ${coinType}`
+    );
+  }
+  const coreBridgePackageId = await getPackageId(
+    provider,
+    coreBridgeStateObjectId
+  );
+  const tokenBridgePackageId = await getPackageId(
+    provider,
+    tokenBridgeStateObjectId
+  );
+  const tx = new TransactionBlock();
+  const [transferCoin] = (() => {
+    if (coinType === SUI_TYPE_ARG) {
+      return tx.splitCoins(tx.gas, [tx.pure(amount)]);
+    } else {
+      const primaryCoinInput = tx.object(primaryCoin.coinObjectId);
+      if (mergeCoins.length) {
+        tx.mergeCoins(
+          primaryCoinInput,
+          mergeCoins.map((coin) => tx.object(coin.coinObjectId))
+        );
+      }
+      return tx.splitCoins(primaryCoinInput, [tx.pure(amount)]);
+    }
+  })();
+  const [feeCoin] = tx.splitCoins(tx.gas, [tx.pure(feeAmount)]);
+  const [assetInfo] = tx.moveCall({
+    target: `${tokenBridgePackageId}::state::verified_asset`,
+    arguments: [tx.object(tokenBridgeStateObjectId)],
+    typeArguments: [coinType],
+  });
+  const [transferTicket, dust] = tx.moveCall({
+    target: `${tokenBridgePackageId}::transfer_tokens::prepare_transfer`,
+    arguments: [
+      assetInfo,
+      transferCoin,
+      tx.pure(coalesceChainId(recipientChain)),
+      tx.pure([...recipient]),
+      tx.pure(relayerFee),
+      tx.pure(createNonce().readUInt32LE()),
+    ],
+    typeArguments: [coinType],
+  });
+  tx.moveCall({
+    target: `${tokenBridgePackageId}::coin_utils::return_nonzero`,
+    arguments: [dust],
+    typeArguments: [coinType],
+  });
+  const [messageTicket] = tx.moveCall({
+    target: `${tokenBridgePackageId}::transfer_tokens::transfer_tokens`,
+    arguments: [tx.object(tokenBridgeStateObjectId), transferTicket],
+    typeArguments: [coinType],
+  });
+  tx.moveCall({
+    target: `${coreBridgePackageId}::publish_message::publish_message`,
+    arguments: [
+      tx.object(coreBridgeStateObjectId),
+      feeCoin,
+      messageTicket,
+      tx.object(SUI_CLOCK_OBJECT_ID),
+    ],
+  });
+  return tx;
+}

+ 62 - 0
sdk/js/src/token_bridge/updateWrapped.ts

@@ -1,3 +1,8 @@
+import {
+  JsonRpcProvider,
+  SUI_CLOCK_OBJECT_ID,
+  TransactionBlock,
+} from "@mysten/sui.js";
 import { ethers, Overrides } from "ethers";
 import {
   createWrappedOnAlgorand,
@@ -8,6 +13,7 @@ import {
   createWrappedOnXpla,
 } from ".";
 import { Bridge__factory } from "../ethers-contracts";
+import { getPackageId, getWrappedCoinType } from "../sui";
 
 export async function updateWrappedOnEth(
   tokenBridgeAddress: string,
@@ -32,3 +38,59 @@ export const updateWrappedOnAlgorand = createWrappedOnAlgorand;
 export const updateWrappedOnNear = createWrappedOnNear;
 
 export const updateWrappedOnAptos = createWrappedOnAptos;
+
+export async function updateWrappedOnSui(
+  provider: JsonRpcProvider,
+  coreBridgeStateObjectId: string,
+  tokenBridgeStateObjectId: string,
+  coinPackageId: string,
+  attestVAA: Uint8Array
+): Promise<TransactionBlock> {
+  const coreBridgePackageId = await getPackageId(
+    provider,
+    coreBridgeStateObjectId
+  );
+  const tokenBridgePackageId = await getPackageId(
+    provider,
+    tokenBridgeStateObjectId
+  );
+
+  // Get coin metadata
+  const coinType = getWrappedCoinType(coinPackageId);
+  const coinMetadataObjectId = (await provider.getCoinMetadata({ coinType }))
+    ?.id;
+  if (!coinMetadataObjectId) {
+    throw new Error(
+      `Coin metadata object not found for coin type ${coinType}.`
+    );
+  }
+
+  // Get verified VAA
+  const tx = new TransactionBlock();
+  const [vaa] = tx.moveCall({
+    target: `${coreBridgePackageId}::vaa::parse_and_verify`,
+    arguments: [
+      tx.object(coreBridgeStateObjectId),
+      tx.pure([...attestVAA]),
+      tx.object(SUI_CLOCK_OBJECT_ID),
+    ],
+  });
+
+  // Get TokenBridgeMessage
+  const [message] = tx.moveCall({
+    target: `${tokenBridgePackageId}::vaa::verify_only_once`,
+    arguments: [tx.object(tokenBridgeStateObjectId), vaa],
+  });
+
+  // Construct complete registration payload
+  tx.moveCall({
+    target: `${tokenBridgePackageId}::create_wrapped::update_attestation`,
+    arguments: [
+      tx.object(tokenBridgeStateObjectId),
+      tx.object(coinMetadataObjectId),
+      message,
+    ],
+    typeArguments: [coinType],
+  });
+  return tx;
+}

+ 12 - 3
sdk/js/src/utils/array.ts

@@ -35,6 +35,8 @@ import {
 } from "./consts";
 import { hashLookup } from "./near";
 import { getExternalAddressFromType, isValidAptosType } from "./aptos";
+import { isValidSuiAddress } from "@mysten/sui.js";
+import { isValidSuiType } from "../sui";
 
 /**
  *
@@ -67,7 +69,7 @@ export const uint8ArrayToHex = (a: Uint8Array): string =>
 export const hexToUint8Array = (h: string): Uint8Array => {
   if (h.startsWith("0x")) h = h.slice(2);
   return new Uint8Array(Buffer.from(h, "hex"));
-}
+};
 
 /**
  *
@@ -250,7 +252,12 @@ export const tryNativeToHexString = (
   } else if (chainId === CHAIN_ID_OSMOSIS) {
     throw Error("hexToNativeString: Osmosis not supported yet.");
   } else if (chainId === CHAIN_ID_SUI) {
-    throw Error("hexToNativeString: Sui not supported yet.");
+    if (!isValidSuiType(address) && isValidSuiAddress(address)) {
+      return uint8ArrayToHex(
+        zeroPad(arrayify(address, { allowMissingPrefix: true }), 32)
+      );
+    }
+    throw Error("hexToNativeString: Sui types not supported yet.");
   } else if (chainId === CHAIN_ID_BTC) {
     throw Error("hexToNativeString: Btc not supported yet.");
   } else if (chainId === CHAIN_ID_APTOS) {
@@ -258,7 +265,9 @@ export const tryNativeToHexString = (
       return getExternalAddressFromType(address);
     }
 
-    return uint8ArrayToHex(zeroPad(arrayify(address, { allowMissingPrefix:true }), 32));
+    return uint8ArrayToHex(
+      zeroPad(arrayify(address, { allowMissingPrefix: true }), 32)
+    );
   } else if (chainId === CHAIN_ID_UNSET) {
     throw Error("hexToNativeString: Chain id unset");
   } else {

+ 11 - 7
sdk/js/src/utils/consts.ts

@@ -181,8 +181,9 @@ const MAINNET = {
       "0x1bdffae984043833ed7fe223f7af7a3f8902d04129b14f801823e64827da7130",
   },
   sui: {
-    core: undefined,
-    token_bridge: undefined,
+    core: "0xaeab97f96cf9877fee2883315d459552b2b921edc16d7ceac6eab944dd88919c",
+    token_bridge:
+      "0xc57508ee0d4595e5a8728974a4a93a787d38f339757230d441e895422c07aba9",
     nft_bridge: undefined,
   },
   moonbeam: {
@@ -247,7 +248,8 @@ const MAINNET = {
     token_bridge: undefined,
     nft_bridge: undefined,
   },
-  sepolia: { // This is testnet only.
+  sepolia: {
+    // This is testnet only.
     core: undefined,
     token_bridge: undefined,
     nft_bridge: undefined,
@@ -352,8 +354,9 @@ const TESTNET = {
     nft_bridge: undefined,
   },
   sui: {
-    core: undefined,
-    token_bridge: undefined,
+    core: "0x69ae41bdef4770895eb4e7aaefee5e4673acc08f6917b4856cf55549c4573ca8",
+    token_bridge:
+      "0x32422cb2f929b6a4e3f81b4791ea11ac2af896b310f3d9442aa1fe924ce0bab4",
     nft_bridge: undefined,
   },
   moonbeam: {
@@ -524,8 +527,9 @@ const DEVNET = {
       "0x46da3d4c569388af61f951bdd1153f4c875f90c2991f6b2d0a38e2161a40852c",
   },
   sui: {
-    core: undefined,
-    token_bridge: undefined,
+    core: "0x04ca9f568b19c80b4fb429c26f7cc57b1ca97e7519ccd68af436dd2706808e01", // wormhole module State object ID
+    token_bridge:
+      "0x844b3ce3f9b2cd82cb8ad1a1962593f6a340c7bad0b4867b82a49463554883dd", // token_bridge module State object ID
     nft_bridge: undefined,
   },
   moonbeam: {

+ 1 - 1
sdk/js/src/utils/index.ts

@@ -4,6 +4,6 @@ export * from "./bigint";
 export * from "./consts";
 export * from "./createNonce";
 export * from "./injective";
+export * from "./keccak";
 export * from "./near";
 export * from "./parseVaa";
-export * from "./keccak";

+ 1 - 1
sdk/js/tsconfig.json

@@ -1,6 +1,6 @@
 {
   "compilerOptions": {
-    "target": "es5",
+    "target": "es6",
     "module": "esnext",
     "moduleResolution": "node",
     "declaration": true,

+ 2 - 2
sui/scripts/wait_for_devnet.sh

@@ -3,7 +3,7 @@
 set -e
 
 # Wait for sui to start
-while [[ "$(curl -X POST -H "Content-Type: application/json" -d '{ "jsonrpc":"2.0", "method":"rpc.discover","id":1 }' -s -o /dev/null -w '%{http_code}' 0.0.0.0:9000/)" != "200" ]]; do sleep 5; done
+while [[ "$(curl -X POST -H "Content-Type: application/json" -d '{ "jsonrpc":"2.0", "method":"rpc.discover","id":1 }' -s -o /dev/null -w '%{http_code}' 0.0.0.0:9000/)" != "200" ]]; do sleep 1; done
 
 # Wait for sui-faucet to start
-while [[ "$(curl -s -o /dev/null -w '%{http_code}' 0.0.0.0:5003/)" != "200" ]]; do sleep 5; done
+while [[ "$(curl -s -o /dev/null -w '%{http_code}' 0.0.0.0:5003/)" != "200" ]]; do sleep 1; done

+ 0 - 3
sui/token_bridge/Move.lock

@@ -5,9 +5,6 @@ version = 0
 
 dependencies = [
   { name = "Sui" },
-]
-
-dev-dependencies = [
   { name = "Wormhole" },
 ]
 

+ 15 - 0
sui/token_bridge/Move.mainnet.toml

@@ -0,0 +1,15 @@
+[package]
+name = "TokenBridge"
+version = "0.2.0"
+published-at = "0x26efee2b51c911237888e5dc6702868abca3c7ac12c53f76ef8eba0697695e3d"
+
+[dependencies.Sui]
+git = "https://github.com/MystenLabs/sui.git"
+subdir = "crates/sui-framework/packages/sui-framework"
+rev = "09b2081498366df936abae26eea4b2d5cafb2788"
+
+[dependencies.Wormhole]
+local = "../wormhole"
+
+[addresses]
+token_bridge = "0x26efee2b51c911237888e5dc6702868abca3c7ac12c53f76ef8eba0697695e3d"

+ 12 - 0
sui/wormhole/Move.mainnet.toml

@@ -0,0 +1,12 @@
+[package]
+name = "Wormhole"
+version = "0.2.0"
+published-at = "0x5306f64e312b581766351c07af79c72fcb1cd25147157fdc2f8ad76de9a3fb6a"
+
+[dependencies.Sui]
+git = "https://github.com/MystenLabs/sui.git"
+subdir = "crates/sui-framework/packages/sui-framework"
+rev = "09b2081498366df936abae26eea4b2d5cafb2788"
+
+[addresses]
+wormhole = "0x5306f64e312b581766351c07af79c72fcb1cd25147157fdc2f8ad76de9a3fb6a"

+ 1 - 1
sui/wormhole/sources/governance_message.move

@@ -15,7 +15,7 @@ module wormhole::governance_message {
 
     /// Guardian set used to sign VAA did not use current Guardian set.
     const E_OLD_GUARDIAN_SET_GOVERNANCE: u64 = 0;
-    /// Governance chain disagrees does not match.
+    /// Governance chain does not match.
     const E_INVALID_GOVERNANCE_CHAIN: u64 = 1;
     /// Governance emitter address does not match.
     const E_INVALID_GOVERNANCE_EMITTER: u64 = 2;

+ 1 - 0
wormchain/contracts/tools/deploy_wormchain.ts

@@ -232,6 +232,7 @@ async function main() {
         near: String(process.env.REGISTER_NEAR_TOKEN_BRIDGE_VAA),
         terra2: String(process.env.REGISTER_TERRA2_TOKEN_BRIDGE_VAA),
         aptos: String(process.env.REGISTER_APTOS_TOKEN_BRIDGE_VAA),
+        sui: String(process.env.REGISTER_SUI_TOKEN_BRIDGE_VAA),
     };
 
     const instantiateMsg = {};