Skip to content

kunalabs-io/sui-client-gen

Repository files navigation

sui-client-gen

A tool for generating TS SDKs for Sui Move smart contracts. Supports code generation both for source code and on-chain packages with no IDLs or ABIs required.

Quick Start

  1. Install the generator by either:

  2. Create a new directory and in it a gen.toml file like so:

[config]
environment = "mainnet"  # or "testnet", or a custom environment defined in [environments]
# graphql = "https://..."  # optional: override GraphQL endpoint
# output = "./gen"         # optional: output directory

[packages]
# based on source code (syntax same as in Move.toml):
deepbook = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/deepbook", rev = "mainnet-v1.62.1" }
my_amm = { local = "../move/amm" }
# an on-chain package:
my_onchain_pkg = { on-chain = true }
# MVR package:
my_mvr_pkg = { r.mvr = "@namespace/package" }

# Optional: define custom environments
# [environments]
# staging = "abcd1234"  # string shorthand: just chain-id
# staging_alt = { chain-id = "abcd1234", graphql = "https://..." }  # full form
# testnet = { graphql = "https://my-testnet-endpoint/graphql" }  # override default env's graphql

# Optional: environment-scoped dependency replacements
# [dep-replacements.staging]
# some_dep = { local = "../other", use-environment = "testnet" }
  1. Run the generator from inside the directory: sui-client-gen

Usage Examples

Import generated functions and structs

import { faucetMint } from "./gen/fixture/example-coin/functions";
import { createPoolWithCoins } from "./gen/amm/util/functions";
import {
  createExampleStruct,
  specialTypes,
} from "./gen/examples/examples/functions";
import { Pool } from "./gen/amm/pool/structs";

Create an AMM pool

const tx = new Transaction();

const [suiCoin] = tx.splitCoin(tx.gas, [1_000_000n]);
const exampleCoin = faucetMint(tx, FAUCET_ID);

const lp = createPoolWithCoins(
  tx,
  ["0x2:sui::SUI", `${EXAMPLE_PACKAGE_ID}::example_coin::EXAMPLE_COIN`],
  {
    registry: REGISTRY_ID, // or tx.object(REGISTRY_ID)
    initA: suiCoin,
    initB: exampleCoin,
    lpFeeBps: 30n, // or tx.pure.u64(30n)
    adminFeePct: 10n, // or tx.pure.u64(10n)
  }
);
tx.transferObjects([lp], tx.pure.address(addresss));

await client.signAndExecuteTransaction({
  transaction: tx,
  signer,
});

Fetch Pool object

import { EXAMPLE_COIN } from "./gen/my-coin/example-coin/structs";
import { SUI } from "./gen/sui/sui/structs";

// see following section for explanation about "reified"
const poolReified = Pool.r(SUI.p, EXAMPLE_COIN.p); // or Pool.reified(SUI.phantom(), EXAMPLE_COIN.phantom())

const pool = await poolReified.fetch(client, POOL_ID);

// alternatively
const res = await client.getObject({
  id: POOL_ID,
  options: { showContent: true },
});
const pool = poolReified.fromSuiParsedData(res.data.content);

console.log(pool);

Working with Enums

Move enums are generated as TypeScript discriminated unions. Each variant becomes its own class:

import { Action, isAction } from "./gen/examples/enums/structs";
import { SUI } from "./gen/sui/sui/structs";

// Type checking
if (isAction(someType)) {
  /* ... */
}

// Fetch and discriminate variants using $variantName
const action = await Action.r("u64", SUI.p).fetch(client, ACTION_ID);
switch (action.$variantName) {
  case "Stop": // action is ActionStop (unit variant - no fields)
    break;
  case "Pause": // action is ActionPause (struct variant - named fields)
    console.log(action.duration, action.genericField);
    break;
  case "Jump": // action is ActionJump (tuple variant - positional fields)
    console.log(action[0], action[1]);
    break;
}

For comprehensive enum documentation including variant types and field mappings, see the docs.

Reified

The structs code generated by sui-client-gen is fully type safe, including for structs with type parameters (generics). This means that:

  • when loading objects from external sources (e.g. from the chain via the fetch call), the type of the object is checked against the type parameter at runtime
  • object's generic fields are also type inferred, so the type of the field is known statically at compile time, including for any number of nested generic fields or vectors

This is achieved by using the so-called "reified" types. For example, the Pool struct has two phantom type parameters, A and B, which represent the types of the two assets in the pool. Its Move definition looks like this:

struct Pool<phantom A, phantom B> has key { ... }

So when the generator is run, it will generate classes for each type defined in the package, including for the Pool struct. The Pool class on its cannot be instantiated directly, but instead has to be "reified" with the types of the assets in the pool. This is done by calling the Pool.r (shorthand for Pool.reified) method, which returns a new class with the type parameters filled in:

const reified = Pool.r(SUI.p, EXAMPLE_COIN.p);

// alternatively
const reified = Pool.reified(SUI.phantom(), EXAMPLE_COIN.phantom());
// in case of phantom parameters we can also pass in arbitrary types as strings:
const reified = Pool.reified(
  phantom("0x2::sui::SUI"),
  phantom(`${EXAMPLE_PACKAGE_ID}::example_coin::EXAMPLE_COIN`)
);

The reified class can then be used to fetch objects from the chain (or other things, such as decoding it from BCS or serialized JSON), which will be checked against the type parameters at runtime and decoded:

const pool = await reified.fetch(client, POOL_ID);
const pool = reified.fromBcs(bcsBytes);
const pool = reified.fromJSON(jsonData);

In case our struct recieves non-phantom type parameters, we need to pass in the reified types as instead of phantom. For example, the ExampleStruct struct has a non-phantom type parameter T:

struct ExampleStruct<T> has key { ... }

So when reifying it, we need to pass in reified types as arguments. For example, this will construct a reified type for ExampleStruct with T set to Pool (concretely, ExampleStruct<Pool<SUI, EXAMPLE_COIN>>):

const reified = ExampleStruct.r(Pool.r(SUI.p, EXAMPLE_COIN.p));

Now when we fetch an object of type ExampleStruct<Pool<SUI, EXAMPLE_COIN>> from the chain by ID, its type will be checked against the type parameter T at runtime (so that we're not accidentally fetching an object of different type), and the inherent generic field, in this case Pool, will be correctly decoded and statically inferred.

Wrapping reified types in higher level classes

For each struct, the generator will also generate a handy type alias for the reified type, which can be used to wrap the reified type in a higher level class. For example, the Pool struct has PoolReified alias generated for it.

So now a higher level class that wraps the reified type can be defined like so:

import { PhantomTypeArgument } from "./gen/_framework/reified"; // it's TypeArgument for non-phantom types
import { PoolReified } from "./gen/amm/pool/structs";

class PoolWrapper<
  A extends PhantomTypeArgument,
  B extends PhantomTypeArgument
> {
  readonly reified: PoolReified<A, B>;

  constructor(reified: PoolReified<A, B>) {
    this.reified = reified;
  }
}

And then used like so with the type inferrence being carried over to the higher level class:

const poolWrapper = new PoolWrapper(Pool.r(SUI.p, EXAMPLE_COIN.p));

And if for whatever reason we don't want to define our wrapper classes as generic, we can use the PhantomTypeArgument (or TypeArgument for non-phantom) type to define them as non-generic (which will sever the type inferrence):

class PoolWrapper {
  readonly reified: PoolReified<PhantomTypeArgument, PhantomTypeArgument>;

  constructor(reified: PoolReified<PhantomTypeArgument, PhantomTypeArgument>) {
    this.reified = reified;
  }
}

Environment Switching

The generated SDK supports runtime environment switching, allowing you to use the same codebase for mainnet, testnet, or custom environments:

import { setActiveEnv } from './gen/_envs'

// Switch to testnet
setActiveEnv('testnet')

// Or provide a custom config
import { setActiveEnvWithConfig, type EnvConfig } from './gen/_envs'
setActiveEnvWithConfig(myCustomConfig)

For concurrent code paths that need different configs simultaneously, every generated function also accepts an optional per-call options?: { env?: EnvConfig } — a caller-supplied env bypasses any global overrides without touching module state. Build one with cloneEnv(getEnv('mainnet'), { packages: { 'my-pkg': { publishedAt: '0x...' } } }).

The default environment (from [config].environment in gen.toml) is automatically set on import. For more details, see the docs.

Each environment configuration includes type origins - mappings from type names to the package address where they were originally defined. This is important because when a package is upgraded, the struct types remain associated with their original defining package (v1 address) even though new functions live at a newer address. The SDK handles this automatically via getTypeOrigin(). For more details, see the docs.

Loader

In some situations it may be more convenient to load reified types using a type string instead of passing in reified types as arguments. This can be done by calling loader.reified(type: string):

import { loader } from "./gen/_framework/loader";

const reified = loader.reified(
  "0x555::pool::Pool<0x2::sui::SUI, 0x666::example_coin::EXAMPLE_COIN>"
);

This can be useful in situations where the type is not known at compile time. The loaded reified type can then be used in the same way as before.

Note that if the type is using non-phantom type parameters (generics), the corresponding structs must be available in the generated dependency graph (listed in gen.toml or a transitive depencency) otherwise it will fail due to missing definitions. Same goes for the type itself.

Function binding special type handling

The following types:

  • std::ascii::String and std::string::String
  • sui::object::ID
  • vector<T> where either:
    • T is a valid type for a pure inputs, checked resursively
    • is a vector of objects
    • is a vector of results from previous calls in the same transaction block
  • std::option::Option

Have special handling so that they can be used directly as inputs to function bindings instead of having to manually construct them with tx.pure:

const e1 = createExampleStruct(tx);
const e2 = createExampleStruct(tx);

specialTypes(tx, {
  asciiString: "example ascii string", // or tx.pure.string('example ascii string')
  utf8String: "example utf8 string", // or tx.pure.string('example utf8 string')
  vectorOfU64: [1n, 2n], // or tx.pure.vector('u64', [1n, 2n])
  vectorOfObjects: [e1, e2], // or tx.makeMoveVec({ elements: [e1, e2], type: ExampleStruct.$typeName })
  idField: "0x12345", // or tx.pure.address('0x12345')
  address: "0x12345", // or tx.pure.address('0x12345')
  optionSome: 5n, // or tx.pure.option('u64', 5n)
  optionNone: null, // or tx.pure.option('u64', null)
});

Migrating to @mysten/sui@2.x

Generated SDKs now require @mysten/sui v2 (peer dependency). The breaking changes on the generated surface are:

  • fetch(client, id) now takes any client implementing ClientWithCoreApi (e.g. SuiJsonRpcClient, SuiGrpcClient, SuiGraphQLClient). Internally it calls client.core.getObject({ objectId: id, include: { content: true } }) and parses the returned BCS bytes — the same call path regardless of transport.
  • New fromCoreObject(obj) static method on every generated class. Pass it an object returned by client.core.getObject(...) / client.core.getObjects(...) with include: { content: true }. It performs the same type checks as the old fromSuiObjectData (asserts the response's type matches the class and, for generics, validates type arguments) before decoding from BCS.
  • fromSuiParsedData(content) and fromSuiObjectData(data) are kept but marked @deprecated. Their input types (SuiParsedData, SuiObjectData) moved to @mysten/sui/jsonRpc; their bodies are unchanged. They will be removed when JSON-RPC support is dropped upstream — migrate callers to fromCoreObject.
  • The generator no longer emits the SupportedSuiClient union / fetchObjectBcs dispatcher in _framework/util.ts. Transport selection is handled by the SDK itself via client.core.*.

Example migration:

- import { SuiClient } from '@mysten/sui/client';
- const client = new SuiClient({ url: getFullnodeUrl('testnet') });
+ import { SuiGrpcClient } from '@mysten/sui/grpc';
+ const client = new SuiGrpcClient({ baseUrl: 'https://fullnode.testnet.sui.io', network: 'testnet' });

  // fetch by id (unchanged at the call site):
  const pool = await Pool.r(SUI.p, EXAMPLE_COIN.p).fetch(client, AMM_POOL_ID);

  // batch decode — new pattern:
- const res = await client.multiGetObjects({ ids, options: { showBcs: true } });
- const pools = res.map(r => Pool.fromSuiObjectData([SUI.p, EXAMPLE_COIN.p], r.data!));
+ const { objects } = await client.core.getObjects({
+   objectIds: ids,
+   include: { content: true },
+ });
+ const pools = objects.map(o =>
+   o instanceof Error ? null : Pool.fromCoreObject([SUI.p, EXAMPLE_COIN.p], o),
+ );

If you stay on JSON-RPC, the deprecated methods continue to work — just update the import path for their input types:

- import type { SuiObjectData } from '@mysten/sui/client';
+ import type { SuiObjectData } from '@mysten/sui/jsonRpc';

Caveats

  • When re-running the generator, the files generated on previous run will not be automatically deleted in order to avoid accidental data wipes. The old files can either be deleted manually before re-running the tool (it's safe to delete everything aside from gen.toml) or by running the generator with --clean (use with caution).
  • When running the generator with default GraphQL endpoint, you might get rate limiting errors. In such cases, consider using a private endpoint by setting graphql in [config] or the environment's graphql in [environments].

Docs

For more detailed usage documentation, check out the docs. For technical details on the internals and reasoning behind the design decisions, check out the design doc.

About

A tool for generating TS SDKs for Sui Move smart contracts.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors