@wormhole-foundation/sdk-sui-tokenbridge
Version:
SDK for Sui chains, used in conjunction with @wormhole-foundation/sdk
423 lines • 21.7 kB
JavaScript
import { Transaction } from "@mysten/sui/transactions";
import { SUI_CLOCK_OBJECT_ID, SUI_TYPE_ARG, normalizeSuiAddress, normalizeSuiObjectId, } from "@mysten/sui/utils";
import { ErrNotWrapped, UniversalAddress, canonicalAddress, encoding, isNative, keccak256, nativeChainIds, serialize, toChain, toChainId, toNative, } from "@wormhole-foundation/sdk-connect";
import { SuiAddress } from "@wormhole-foundation/sdk-sui";
import { SuiPlatform, SuiUnsignedTransaction, bytesVectorName, getDynamicFieldValue, getObjectFields, getOldestEmitterCapObjectId, getOriginalPackageId, getPackageId, isMoveStructObject, isSameType, isValidSuiType, publishPackage, trimSuiType, } from "@wormhole-foundation/sdk-sui";
import { getTokenCoinType, getTokenFromTokenRegistry } from "./utils.js";
import "@wormhole-foundation/sdk-sui-core";
export class SuiTokenBridge {
network;
chain;
provider;
contracts;
coreBridgeObjectId;
tokenBridgeObjectId;
chainId;
constructor(network, chain, provider, contracts) {
this.network = network;
this.chain = chain;
this.provider = provider;
this.contracts = contracts;
this.chainId = nativeChainIds.networkChainToNativeChainId.get(network, chain);
const tokenBridgeAddress = this.contracts.tokenBridge;
if (!tokenBridgeAddress)
throw new Error(`Wormhole Token Bridge contract for domain ${chain} not found`);
const coreBridgeAddress = this.contracts.coreBridge;
if (!coreBridgeAddress)
throw new Error(`Wormhole Token Bridge contract for domain ${chain} not found`);
this.tokenBridgeObjectId = tokenBridgeAddress;
this.coreBridgeObjectId = coreBridgeAddress;
}
static async fromRpc(provider, config) {
const [network, chain] = await SuiPlatform.chainFromRpc(provider);
const conf = config[chain];
if (conf.network !== network)
throw new Error(`Network mismatch: ${conf.network} != ${network}`);
return new SuiTokenBridge(network, chain, provider, conf.contracts);
}
async isWrappedAsset(token) {
try {
await this.getOriginalAsset(token);
return true;
}
catch {
return false;
}
}
async getOriginalAsset(token) {
let coinType = token.getCoinType();
if (!isValidSuiType(coinType))
throw new Error(`Invalid Sui type: ${coinType}`);
const res = await getTokenFromTokenRegistry(this.provider, this.tokenBridgeObjectId, coinType);
if (!res || !res.json || !("value" in res.json))
throw ErrNotWrapped(coinType);
const val = res.json["value"];
// In gRPC flat json the registry-entry struct type isn't on the value; it's encoded
// on the dynamic-field object's top-level type (Field<Key<T>, WrappedAsset<T>>).
const type = trimSuiType(res.type);
coinType = trimSuiType(coinType);
// Check if wrapped or native asset. We check inclusion instead of equality
// because it saves us from making an additional RPC call to fetch the package ID.
if (type.includes(`wrapped_asset::WrappedAsset<${coinType}>`)) {
const info = val["info"];
if (!isMoveStructObject(info))
throw new Error("Expected info to be a MoveStruct");
// info.token_address.value.data is a base64-encoded 32-byte address
const universalAddress = encoding.b64.decode(info["token_address"]["value"]["data"]);
return {
chain: toChain(Number(info["token_chain"])),
address: new UniversalAddress(universalAddress),
};
}
throw ErrNotWrapped(coinType);
}
async getTokenUniversalAddress(token) {
let coinType = token.getCoinType();
if (!isValidSuiType(coinType))
throw new Error(`Invalid Sui type: ${coinType}`);
const res = await getTokenFromTokenRegistry(this.provider, this.tokenBridgeObjectId, coinType);
if (!res || !res.json || !("value" in res.json)) {
throw new Error(`Token of type ${coinType} has not been registered with the token bridge. Has it been attested?`);
}
const val = res.json["value"];
const type = trimSuiType(res.type);
coinType = trimSuiType(coinType);
// Check if wrapped or native asset. We check inclusion instead of equality
// because it saves us from making an additional RPC call to fetch the package ID.
if (type.includes(`native_asset::NativeAsset<${coinType}>`)) {
// value.token_address.value.data is a base64-encoded 32-byte address
const universalAddress = encoding.b64.decode(val["token_address"]["value"]["data"]);
return new UniversalAddress(universalAddress);
}
throw new Error(`Token of type ${coinType} is not a native asset`);
}
async getTokenNativeAddress(originChain, token) {
const address = await getTokenCoinType(this.provider, this.tokenBridgeObjectId, token.toUint8Array(), toChainId(originChain));
if (!address)
throw new Error(`Token ${token.toString()} not found in token registry`);
return new SuiAddress(address);
}
async hasWrappedAsset(token) {
try {
await this.getWrappedAsset(token);
return true;
}
catch (e) { }
return false;
}
async getWrappedAsset(token) {
if (isNative(token.address))
throw new Error("Token Address required, 'native' literal not supported");
const address = await getTokenCoinType(this.provider, this.tokenBridgeObjectId, token.address.toUniversalAddress().toUint8Array(), toChainId(token.chain));
if (!address)
throw ErrNotWrapped(canonicalAddress(token));
return toNative(this.chain, address);
}
async isTransferCompleted(vaa) {
const tokenBridgeStateFields = await getObjectFields(this.provider, this.tokenBridgeObjectId);
if (!tokenBridgeStateFields)
throw new Error("Unable to fetch object fields from token bridge state");
// consumed_vaas.hashes.items is a `Table` whose UID parents the dynamic fields.
const tableObjectId = tokenBridgeStateFields["consumed_vaas"]?.["hashes"]?.["items"]?.["id"];
if (!tableObjectId)
throw new Error("Unable to fetch consumed VAAs table");
// Key is `${coreBridgePackageId}::bytes32::Bytes32` ({ data: vector<u8> }).
const [coreBridgePackageId] = await this.getPackageIds();
const keyType = `${coreBridgePackageId}::bytes32::Bytes32`;
const name = bytesVectorName(keyType, keccak256(vaa.hash));
const value = await getDynamicFieldValue(this.provider, tableObjectId, name);
return value !== null;
}
async *createAttestation(token) {
const feeAmount = 0n;
const nonce = 0;
const coinType = token.toString();
const { coinMetadata } = await this.provider.getCoinMetadata({ coinType });
if (coinMetadata === null || coinMetadata.id === null)
throw new Error(`Coin metadata ID for type ${coinType} not found`);
const [coreBridgePackageId, tokenBridgePackageId] = await this.getPackageIds();
const tx = new Transaction();
const [feeCoin] = tx.splitCoins(tx.gas, [tx.pure.u64(feeAmount)]);
const [messageTicket] = tx.moveCall({
target: `${tokenBridgePackageId}::attest_token::attest_token`,
arguments: [
tx.object(this.tokenBridgeObjectId),
tx.object(coinMetadata.id),
tx.pure.u32(nonce),
],
typeArguments: [coinType],
});
tx.moveCall({
target: `${coreBridgePackageId}::publish_message::publish_message`,
arguments: [
tx.object(this.coreBridgeObjectId),
feeCoin,
messageTicket,
tx.object(SUI_CLOCK_OBJECT_ID),
],
});
yield this.createUnsignedTx(tx, "Sui.TokenBridge.CreateAttestation");
}
async *submitAttestation(vaa, sender) {
const [coreBridgePackageId, tokenBridgePackageId] = await this.getPackageIds();
const senderAddress = sender.toString();
const decimals = Math.min(vaa.payload.decimals, 8);
const build = await this.getCoinBuildOutput(coreBridgePackageId, tokenBridgePackageId, decimals);
const publishTx = await publishPackage(build, senderAddress);
yield this.createUnsignedTx(publishTx, "Sui.TokenBridge.PrepareCreateWrapped");
// After the publish/prepare tx lands, the artifacts we need are owned by the sender:
// the WrappedAssetSetup<COIN, VERSION> (whose type embeds the new coin package + version
// type) and the coin's UpgradeCap. The CoinMetadata is a frozen object fetched by type.
// gRPC has no transaction-query API, so we discover via the sender's owned objects.
let coinType = "";
let wrappedSetupObjectId = "";
let coinUpgradeCapId = "";
let coinMetadataObjectId = "";
let versionType = "";
let found = false;
while (!found) {
// wait for the result of the previous tx to be reflected in owned objects
await new Promise((r) => setTimeout(r, 500));
const owned = await this.provider.listOwnedObjects({
owner: senderAddress,
include: { json: true },
});
const setup = owned.objects.find((o) => o.type.includes("create_wrapped::WrappedAssetSetup"));
if (!setup)
continue;
wrappedSetupObjectId = setup.objectId;
// type: `${tbPkg}::create_wrapped::WrappedAssetSetup<${coinPkg}::coin::COIN, ${versionType}>`
const generics = setup.type.substring(setup.type.indexOf("<") + 1, setup.type.lastIndexOf(">"));
const [coinTypeArg, versionArg] = generics.split(",").map((s) => s.trim());
coinType = coinTypeArg;
versionType = versionArg;
const coinPackageId = new SuiAddress(coinType).getPackageId();
// the coin's UpgradeCap, owned by the sender (match on its `package` field)
const cap = owned.objects.find((o) => o.type.includes("package::UpgradeCap") &&
o.json != null &&
normalizeSuiAddress(o.json["package"]) === normalizeSuiAddress(coinPackageId));
coinUpgradeCapId = cap?.objectId ?? "";
// CoinMetadata is frozen/shared; fetch by coin type
const { coinMetadata } = await this.provider.getCoinMetadata({ coinType });
coinMetadataObjectId = coinMetadata?.id ?? "";
if (wrappedSetupObjectId && coinUpgradeCapId && coinMetadataObjectId)
found = true;
}
const createTx = new Transaction();
const [txVaa] = createTx.moveCall({
target: `${coreBridgePackageId}::vaa::parse_and_verify`,
arguments: [
createTx.object(this.coreBridgeObjectId),
createTx.pure.vector("u8", serialize(vaa)),
createTx.object(SUI_CLOCK_OBJECT_ID),
],
});
const [message] = createTx.moveCall({
target: `${tokenBridgePackageId}::vaa::verify_only_once`,
arguments: [createTx.object(this.tokenBridgeObjectId), txVaa],
});
createTx.moveCall({
target: `${tokenBridgePackageId}::create_wrapped::complete_registration`,
arguments: [
createTx.object(this.tokenBridgeObjectId),
createTx.object(coinMetadataObjectId),
createTx.object(wrappedSetupObjectId),
createTx.object(coinUpgradeCapId),
message,
],
typeArguments: [coinType, versionType],
});
yield this.createUnsignedTx(createTx, "Sui.TokenBridge.SubmitAttestation");
}
async *transfer(sender, recipient, token, amount, payload) {
// TODO:
const feeAmount = 0n;
const relayerFee = 0n;
const nonce = 0;
const senderAddress = sender.toString();
const coinType = (isNative(token) ? SUI_TYPE_ARG : token).toString();
const coins = await SuiPlatform.getCoins(this.provider, sender, coinType);
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, tokenBridgePackageId] = await this.getPackageIds();
const tx = new Transaction();
const [transferCoin] = (() => {
if (coinType === SUI_TYPE_ARG) {
return tx.splitCoins(tx.gas, [tx.pure.u64(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.u64(amount)]);
}
})();
const [feeCoin] = tx.splitCoins(tx.gas, [tx.pure.u64(feeAmount)]);
const [assetInfo] = tx.moveCall({
target: `${tokenBridgePackageId}::state::verified_asset`,
arguments: [tx.object(this.tokenBridgeObjectId)],
typeArguments: [coinType],
});
if (!payload) {
const [transferTicket, dust] = tx.moveCall({
target: `${tokenBridgePackageId}::transfer_tokens::prepare_transfer`,
arguments: [
assetInfo,
transferCoin,
tx.pure.u16(toChainId(recipient.chain)),
tx.pure.vector("u8", recipient.address.toUint8Array()),
tx.pure.u64(relayerFee),
tx.pure.u32(nonce),
],
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(this.tokenBridgeObjectId), transferTicket],
typeArguments: [coinType],
});
tx.moveCall({
target: `${coreBridgePackageId}::publish_message::publish_message`,
arguments: [
tx.object(this.coreBridgeObjectId),
feeCoin,
messageTicket,
tx.object(SUI_CLOCK_OBJECT_ID),
],
});
yield this.createUnsignedTx(tx, "Sui.TokenBridge.Transfer");
}
else {
if (!senderAddress)
throw new Error("senderAddress is required for transfer with payload");
// Get or create a new `EmitterCap`
let isNewEmitterCap = false;
const emitterCap = await (async () => {
const objectId = await getOldestEmitterCapObjectId(this.provider, coreBridgePackageId, senderAddress);
if (objectId !== null) {
return tx.object(objectId);
}
else {
const [emitterCap] = tx.moveCall({
target: `${coreBridgePackageId}::emitter::new`,
arguments: [tx.object(this.coreBridgeObjectId)],
});
isNewEmitterCap = true;
return emitterCap;
}
})();
const [transferTicket, dust] = tx.moveCall({
target: `${tokenBridgePackageId}::transfer_tokens_with_payload::prepare_transfer`,
arguments: [
emitterCap,
assetInfo,
transferCoin,
tx.pure.u16(toChainId(recipient.chain)),
tx.pure.vector("u8", recipient.address.toUint8Array()),
tx.pure.vector("u8", payload),
tx.pure.u32(nonce),
],
typeArguments: [coinType],
});
tx.moveCall({
target: `${tokenBridgePackageId}::coin_utils::return_nonzero`,
arguments: [dust],
typeArguments: [coinType],
});
const [messageTicket] = tx.moveCall({
target: `${tokenBridgePackageId}::transfer_tokens_with_payload::transfer_tokens_with_payload`,
arguments: [tx.object(this.tokenBridgeObjectId), transferTicket],
typeArguments: [coinType],
});
tx.moveCall({
target: `${coreBridgePackageId}::publish_message::publish_message`,
arguments: [
tx.object(this.coreBridgeObjectId),
feeCoin,
messageTicket,
tx.object(SUI_CLOCK_OBJECT_ID),
],
});
if (isNewEmitterCap) {
tx.transferObjects([emitterCap], tx.pure.address(senderAddress));
}
yield this.createUnsignedTx(tx, "Sui.TokenBridge.TransferWithPayload");
}
}
async *redeem(sender, vaa, unwrapNative = true) {
const coinType = await getTokenCoinType(this.provider, this.tokenBridgeObjectId, vaa.payload.token.address.toUint8Array(), toChainId(vaa.payload.token.chain));
if (!coinType) {
throw new Error("Unable to fetch token coinType");
}
const [coreBridgePackageId, tokenBridgePackageId] = await this.getPackageIds();
const tx = new Transaction();
const [verifiedVAA] = tx.moveCall({
target: `${coreBridgePackageId}::vaa::parse_and_verify`,
arguments: [
tx.object(this.coreBridgeObjectId),
tx.pure.vector("u8", serialize(vaa)),
tx.object(SUI_CLOCK_OBJECT_ID),
],
});
const [tokenBridgeMessage] = tx.moveCall({
target: `${tokenBridgePackageId}::vaa::verify_only_once`,
arguments: [tx.object(this.tokenBridgeObjectId), verifiedVAA],
});
const [relayerReceipt] = tx.moveCall({
target: `${tokenBridgePackageId}::complete_transfer::authorize_transfer`,
arguments: [tx.object(this.tokenBridgeObjectId), 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],
});
yield this.createUnsignedTx(tx, "Sui.TokenBridge.Redeem");
}
async getWrappedNative() {
return toNative(this.chain, SUI_TYPE_ARG);
}
async getPackageIds() {
// TODO: can these be cached?
return Promise.all([
getPackageId(this.provider, this.coreBridgeObjectId),
getPackageId(this.provider, this.tokenBridgeObjectId),
]);
}
async getCoinBuildOutput(coreBridgePackageId, tokenBridgePackageId, decimals) {
if (decimals > 8)
throw new Error("Decimals is capped at 8");
// Construct bytecode, parametrized by token bridge package ID and decimals
const strippedTokenBridgePackageId = (await getOriginalPackageId(this.provider, this.tokenBridgeObjectId))?.replace("0x", "");
if (!strippedTokenBridgePackageId) {
throw new Error(`Original token bridge package ID not found for object ID ${this.tokenBridgeObjectId}`);
}
const bytecodeHex = "a11ceb0b060000000901000a020a14031e1704350405392d07669f01088502600ae502050cea02160004010b010c0205020d000002000201020003030c020001000104020700000700010001090801010c020a050600030803040202000302010702080007080100020800080303090002070801010b020209000901010608010105010b0202080008030209000504434f494e095478436f6e7465787408565f5f305f325f3011577261707065644173736574536574757004636f696e0e6372656174655f777261707065640b64756d6d795f6669656c6404696e697414707265706172655f726567697374726174696f6e0f7075626c69635f7472616e736665720673656e646572087472616e736665720a74785f636f6e746578740f76657273696f6e5f636f6e74726f6c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002" +
strippedTokenBridgePackageId +
"00020106010000000001090b0031" +
decimals.toString(16).padStart(2, "0") +
"0a0138000b012e110238010200";
const bytecode = encoding.b64.encode(encoding.hex.decode(bytecodeHex));
return {
modules: [bytecode],
dependencies: ["0x1", "0x2", tokenBridgePackageId, coreBridgePackageId].map((d) => normalizeSuiObjectId(d)),
};
}
createUnsignedTx(txReq, description, parallelizable = false) {
return new SuiUnsignedTransaction(txReq, this.network, this.chain, description, parallelizable);
}
}
//# sourceMappingURL=tokenBridge.js.map