Skip to content

feat(CORE-2970): worm-cli sui migration#4880

Open
bengtlofgren wants to merge 16 commits into
wormhole-foundation:mainfrom
wormholelabs-xyz:bengt/CORE-2970-worm-cli
Open

feat(CORE-2970): worm-cli sui migration#4880
bengtlofgren wants to merge 16 commits into
wormhole-foundation:mainfrom
wormholelabs-xyz:bengt/CORE-2970-worm-cli

Conversation

@bengtlofgren

Copy link
Copy Markdown
Contributor

worm CLI: migrate Sui support to @mysten/sui v2 gRPC

Linear Ticket

Ticket: CORE-2970

Summary

Migrates the worm CLI's Sui support from @mysten/sui v1 (SuiClient, JSON-RPC) to v2.19
(SuiGrpcClient, the gRPC fullnode API). The Sui RPC surface changed substantially between
majors — method names, response envelopes, the json representation of Move values, BCS, and
transaction building — so every Sui code path (provider, object/coin reads, registrations,
deploy, init, attest/createWrapped, transfer, governance submit) was updated. Adds a network
safety guard and hermetic unit tests, and resolves the ESM-only nature of the v2 package in the
TypeScript/Jest/esbuild toolchain.

Major Changes

  • SDK bump and provider. @mysten/sui ^1.45.0 → exact 2.19.0; lockfile updated.
    getProviderForChain/getProvider now construct new SuiGrpcClient({ network, baseUrl })
    instead of new SuiClient({ url }). ChainProvider<"Sui"> is now SuiGrpcClient.
  • gRPC response-shape adapters. The gRPC json representation flattens Move structs (no
    nested .fields), collapses UID id.id to a plain id, and encodes vector<u8> as base64.
    getObjectFields, getOriginalAssetSui (wrapped/native branches), getTokenCoinType,
    getPackageId, getOriginalPackageId, and the registrations reader were rewritten against
    this shape.
  • Transaction execution envelope. executeTransactionBlock now unwraps the gRPC
    Transaction | FailedTransaction result, reads status.success, and flattens
    effects.changedObjects together with the objectTypes map into the CLI's normalized
    SuiTransactionResult.
  • BCS and transaction building. Adds CoinTypeKeyBcs/RegistryKeyBcs schemas for
    dynamic-field key lookups; switches VAA arguments to typed tx.pure("vector<u8>", ...) and
    publishing to tx.publish. Pagination is rewritten as explicit listOwnedObjects /
    listDynamicFields / listCoins cursor loops.
  • Deploy path. Devnet uses sui client test-publish --publish-unpublished-deps with a
    fromCliJson normalizer that maps the legacy CLI objectChanges shape onto
    SuiTransactionResult; persistent networks use an SDK tx.publish flow.
  • Network safety guard. getSuiNetwork is now exhaustive (explicit Devnetlocalnet,
    throws on unknown). assertSuiNetwork verifies the endpoint's chain identifier matches the
    selected --network (mainnet/testnet) and is invoked inside executeTransactionBlock, so any
    --network/--rpc mismatch is refused before signing. The signer carries its network.
  • Toolchain for an ESM-only dependency. tsconfig.json adds paths overrides mapping the
    v2 subpath types (@mysten/sui/{grpc,graphql,transactions,...}) to their .d.mts files; a new
    tsconfig.build.json clears paths for esbuild (which resolves the package exports map
    natively); the build script passes --tsconfig=tsconfig.build.json; the Jest transform now
    handles .mjs/.mts and transpiles @mysten/@noble/@scure ESM.
  • Tests. Adds hermetic unit tests: sui-utils.test.ts (object filters, published-package
    extraction, address/type normalization+validation, and a CoinTypeKeyBcs wire-byte assertion
    against on-chain ground truth) and smoke.test.ts (a toolchain canary that loads the v2
    subpath modules).

Files Modified

  • clients/js/src/chains/sui/utils.ts — provider, signer, executeTransactionBlock envelope,
    pagination helpers, getSuiNetwork/assertSuiNetwork guard.
  • clients/js/src/sdk/sui.tsgetOriginalAssetSui/getForeignAssetSui/getTokenCoinType,
    getObjectFields, BCS schemas, type/address validators.
  • clients/js/src/chains/sui/{publish,submit,transfer,registrations,buildCoin,log}.ts — deploy
    normalization, governance submit/createWrapped, transfer PTB, registration listing, coin build.
  • clients/js/src/cmds/sui/{deploy,init,setup,publishMessage,utils}.ts — command wiring to the
    new client and result shape.
  • clients/js/src/chains/generic/{provider,getWrappedAssetAddress}.ts,
    clients/js/src/chains/generic/getOriginalAsset.tsSuiGrpcClient provider type and
    Chain lookups.
  • clients/js/{tsconfig.json,tsconfig.build.json,jest.config.json,package.json,package-lock.json}
    — ESM resolution, build/test config, dependency pin.
  • clients/js/src/chains/sui/__tests__/{sui-utils,smoke}.test.ts — new unit tests.

Testing

  • Type-check (tsc --noEmit) and build (esbuild) clean; unit suites green.
  • Read-only decoders validated live against the public Sui gRPC fullnode on testnet and
    mainnet
    (object fields, wrapped/native original-asset decode, coin-type forward/reverse
    round-trip, registration listing, error paths).
  • Write path validated against a local Sui node: executeTransactionBlock envelope mapping
    on a real signed transaction; worm sui deploy end-to-end through real sui client test-publish output; the WormholeMessage-style event decode confirmed (gRPC encodes the
    vector<u8> event fields as base64); and the network guard confirmed to refuse a mismatched
    --network/endpoint before signing.
  • The bundled worm artifact runs read-only Sui subcommands against testnet, confirming the
    ESM-only dependency loads at runtime.

Additional Notes

  • @mysten/sui v2 is ESM-only with engines.node >= 22; the tsconfig.json paths overrides
    are pinned to the exact 2.19.0 dist layout. A follow-up to moduleResolution: "bundler"
    would let tsc read the exports map natively and remove the overrides.
  • The public Sui fullnodes serve gRPC-web on the existing :443 endpoints, so the default RPCs
    in networks.ts are unchanged.
  • Known minor issue (diagnostic command only): queryRegistrationsSui keys results by chain
    name via chainIdToChain, which returns undefined for chain ids newer than the installed
    SDK, collapsing all such rows under a single undefined key. Falling back to the numeric chain
    id as the key would preserve them.

@socket-security

socket-security Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednpm/​@​mysten/​sui@​1.45.2 ⏵ 2.19.09910010098100

View full report

@bengtlofgren bengtlofgren force-pushed the bengt/CORE-2970-worm-cli branch from 35855da to 377840a Compare July 3, 2026 17:07
@bengtlofgren bengtlofgren requested a review from djb15 as a code owner July 3, 2026 18:07
@bengtlofgren bengtlofgren force-pushed the bengt/CORE-2970-worm-cli branch from 882b4d4 to 25be270 Compare July 3, 2026 18:22
transferCoin,
tx.pure("u16", recipientChainId),
tx.pure("vector<u8>", [...recipient]),
tx.pure("u64", BigInt(0)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even that we know the fee is zero, the right would be reading from core bridge state and passing the fee here.

@douglasgalico douglasgalico force-pushed the bengt/CORE-2970-worm-cli branch from 9c4fd9d to 339b307 Compare July 15, 2026 14:31
console.log(`Published package ID: ${mainPackage.packageId}`);

return result;
output = execSync(cmd, { encoding: "utf-8" });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you try to refactor this using execFileSync here unless there's a specific reason to invoke a shell? Using that method instead is more resistant to command injection.

// `--build-env testnet` selects testnet dependency pins (localnet is not a
// pinned environment); `--publish-unpublished-deps` publishes dependencies
// that are not yet on-chain for this network.
const cmd = `sui client test-publish ${packagePath} --publish-unpublished-deps --build-env testnet --json 2>&1`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a check that packagePath is a directory first using a variant of fs's realpath functions? This would make sure that this is actually a directory on the system before invoking the command.

// `--build-env testnet` selects testnet dependency pins (localnet is not a
// pinned environment); `--publish-unpublished-deps` publishes dependencies
// that are not yet on-chain for this network.
const cmd = `sui client test-publish ${packagePath} --publish-unpublished-deps --build-env testnet --json 2>&1`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does using 2>&1 make sense here? It looks like you're merging the stdout with stderr and then working around that using the first-brace parsing above. If you leave these split, can you just grab the pure JSON from one of those output sources?

* This handles dependencies automatically and doesn't require Published.toml manipulation.
* Note: Uses the locally configured Sui CLI signer, not a programmatic signer.
* Extract the transaction digest from `sui client test-publish --json` output.
* The CLI prepends human-readable build lines before the JSON object, so the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure there's nothing like --quiet that would return only the JSON? It would be very unusual for such a tool to require all consumers to manually parse out text from JSON

typeArguments: [coinType],
});

// Random 32-bit transfer nonce.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Random 32-bit transfer nonce.
// Pseudo-random 32-bit transfer nonce.

Note that Math.random() is not cryptographically random and can be predicted

client: SuiGrpcClient,
tokenBridgeStateObjectId: string
): Promise<string> => {
): Promise<string | undefined> => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe better to throw an error here if the emitter cap id is undefined?

Comment thread clients/js/src/sdk/sui.ts
const type = obj.object.type;
const value = (obj.object.json as any)?.value;

if (type.includes("wrapped_asset::WrappedAsset<")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any way to use Sui's functions to do this? My LLM is suggesting the following might help:

isValidStructTag,
  isValidSuiAddress,
  normalizeStructTag,
  normalizeSuiAddress,
  parseStructTag,

Comment thread clients/js/src/sdk/sui.ts
if (!registryId) {
throw new Error("Unable to fetch token registry object ID");
}
const originalPackageId = state.object.type.split("::")[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you able to use the built-in Sui SDK to do this parsing instead?

Comment thread clients/js/src/sdk/sui.ts
// defines `token_registry`, which is the package embedded in the state
// object's type (Move type origins are stable across upgrades), not the
// upgraded package returned by `getPackageId`.
const originalPackageId = state.object.type.split("::")[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, can we use Sui's parsing instead of a manual split?

Comment thread clients/js/src/sdk/sui.ts
fieldId = res.dynamicField.fieldId;
} catch {
throw new Error(
`Token of type ${coinType} has not been registered with the token bridge`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this error message is correct for all instances of getDynamicField errors. It's probably better to surface and log the actual error here instead of assume that it's a registration issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants