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.
-
Install the generator by either:
cargo install --locked --git https://github.com/kunalabs-io/sui-client-gen.git
(you might have to install some build dependencies)- or downloading a binary from https://github.com/kunalabs-io/sui-client-gen/releases/
-
Create a new directory and in it a
gen.toml
file like so:
[config]
# will be set to mainnet by default if omitted
rpc = "https://fullnode.devnet.sui.io:443"
[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 = "releases/sui-v1.4.0-release" }
AMM = { local = "../move/amm" }
# an on-chain package:
FooPackage = { id = "0x12345" }
- Run the generator from inside the directory:
sui-client-gen
- Run the linter / formatter on the generated code:
pnpm eslint . --fix
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";
const txb = new TransactionBlock();
const [suiCoin] = txb.splitCoin(tx.gas, [tx.pure(1_000_000)]);
const exampleCoin = faucetMint(txb, FAUCET_ID);
const lp = createPoolWithCoins(
txb,
["0x2:sui::SUI", `${EXAMPLE_PACKAGE_ID}::example_coin::EXAMPLE_COIN`],
{
registry: REGISTRY_ID, // or txb.object(REGISTRY_ID)
initA: suiCoin,
initB: exampleCoin,
lpFeeBps: 30n, // or txb.pure(30n)
adminFeePct: 10n, // or txb.pure(10n)
}
);
tx.transferObjects([lp], txb.pure(addresss));
await signer.signAndExecuteTransactionBlock({ transactionBLock: txb });
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);
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.
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;
}
}
The following types:
std::ascii::String
andstd::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 txb.pure(...)
:
const e1 = createExampleStruct(txb);
const e2 = createExampleStruct(txb);
specialTypes(txb, {
asciiString: "example ascii string", // or txb.string('example ascii string')
utf8String: "example utf8 string", // or txb.string('example utf8 string')
vectorOfU64: [1n, 2n], // or txb.pure([1n, 2n], 'vector<u64>')
vectorOfObjects: [e1, e2], // or txb.makeMoveVec({ objects: [e1, e2], type: ExampleStruct.$typeName })
idField: "0x12345", // or txb.address(normalizeSuiAddress('0x12345'), BCS.ADDRESS)
address: "0x12345", // or txb.pure(normalizeSuiAddress('0x12345'), BCS.ADDRESS)
optionSome: 5n, // or txb.pure([5n], 'vector<u64>')
optionNone: null, // or txb.pure([], 'vector<u64>')
});
- When specifying both source and on-chain packages, the generator will currently generate two separate dependency graphs (one for on-chain and one for source). This is due to a technical detail and will be resolved in a future version so that only a single dependency graph is generated (kunalabs-io#1 (comment)).
- Since whitespace detection relies on some Rust nightly features which are currently unstable (udoprog/genco#39 (comment)), the generated code is not formatted nicely. Usage of formatters on the generated code (e.g.,
prettier
,eslint
) is recommended. - Because ESLint renames some types (e.g.,
String
->string
) due to the@typescript-eslint/ban-types
rule which breaks the generated code, an.eslintrc.json
file is generated in the root directory to turn off this rule. - 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).
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.