@wormhole-foundation/sdk-sui-tokenbridge
Version:
SDK for Sui chains, used in conjunction with @wormhole-foundation/sdk
474 lines • 23.6 kB
JavaScript
import { Transaction } from "@mysten/sui/transactions";
import { SUI_CLOCK_OBJECT_ID, SUI_TYPE_ARG, 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, getCoinTypeFromPackageId, getFieldsFromObjectResponse, getObjectFields, getOldestEmitterCapObjectId, getOriginalPackageId, getPackageId, getTableKeyType, isMoveStructObject, isMoveStructStruct, isSameType, isSuiCreateEvent, isSuiPublishEvent, 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);
const fields = getFieldsFromObjectResponse(res);
if (!fields)
throw ErrNotWrapped(coinType);
if (!isMoveStructObject(fields))
throw new Error("Expected fields to be a MoveStruct");
if (!("value" in fields))
throw new Error("Expected a `value` key in fields of MoveStruct");
const val = fields["value"];
if (!isMoveStructStruct(val))
throw new Error("Expected fields to be a MoveStruct");
// Normalize types
const type = trimSuiType(val.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.fields["info"];
if (!isMoveStructStruct(info))
throw new Error("Expected fields to be a MoveStruct");
const address = info.fields["token_address"];
if (!isMoveStructStruct(address))
throw new Error("Expected fields to be a MoveStruct");
if (!isMoveStructObject(address.fields))
throw new Error("Expected address data to be a MoveObject");
if (!("value" in address.fields))
throw new Error("Expected a `value` key in fields of MoveStruct");
const addressVal = address.fields["value"];
if (!isMoveStructStruct(addressVal))
throw new Error("Expected fields to be a MoveStruct");
const universalAddress = new Uint8Array(addressVal.fields["data"]);
return {
chain: toChain(Number(info.fields["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);
const fields = getFieldsFromObjectResponse(res);
if (!fields) {
throw new Error(`Token of type ${coinType} has not been registered with the token bridge. Has it been attested?`);
}
if (!isMoveStructObject(fields))
throw new Error("Expected fields to be a MoveStruct");
if (!("value" in fields))
throw new Error("Expected a `value` key in fields of MoveStruct");
const val = fields["value"];
if (!isMoveStructStruct(val))
throw new Error("Expected fields to be a MoveStruct");
// Normalize types
const type = trimSuiType(val.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}>`)) {
// fields.value.fields.token_address.fields.value.fields.data
const address = val.fields["token_address"];
if (!isMoveStructStruct(address))
throw new Error("Expected fields to be a MoveStruct");
if (!("value" in address.fields))
throw new Error("Expected a `value` key in fields of MoveStruct");
const addressVal = address.fields["value"];
if (!isMoveStructStruct(addressVal))
throw new Error("Expected fields to be a MoveStruct");
const universalAddress = new Uint8Array(addressVal.fields["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");
const hashes = tokenBridgeStateFields["consumed_vaas"]?.fields?.hashes;
const keyType = getTableKeyType(hashes?.fields?.items?.type);
if (!keyType)
throw new Error("Unable to get key type");
const tableObjectId = hashes?.fields?.items?.fields?.id?.id;
if (!tableObjectId)
throw new Error("Unable to fetch consumed VAAs table");
const response = await this.provider.getDynamicFieldObject({
parentId: tableObjectId,
name: {
type: keyType,
value: {
data: [...keccak256(vaa.hash)],
},
},
});
if (!response.error)
return true;
if (response.error.code === "dynamicFieldNotFound")
return false;
throw new Error(`Unexpected getDynamicFieldObject response ${response.error}`);
}
async *createAttestation(token) {
const feeAmount = 0n;
const nonce = 0;
const coinType = token.toString();
const metadata = await this.provider.getCoinMetadata({ coinType });
if (metadata === null || metadata.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(metadata.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");
// TODO: refactor this to something less embarassing
let coinPackageId = "";
let wrappedSetupObjectId = "";
let coinUpgradeCapId = "";
let coinMetadataObjectId = "";
let versionType = "";
let found = false;
while (!found) {
// wait for the result of the previous tx to fetch the new coinPackageId
await new Promise((r) => setTimeout(r, 500));
const txBlocks = await this.provider.queryTransactionBlocks({
filter: { FromAddress: senderAddress },
options: { showObjectChanges: true },
limit: 3,
});
// Find the txblock with both the coinPackageId and wrappedType
for (const txb of txBlocks.data) {
if (!("objectChanges" in txb))
continue;
for (const change of txb.objectChanges) {
if (isSuiPublishEvent(change) && change.packageId !== undefined) {
coinPackageId = change.packageId;
}
else if (isSuiCreateEvent(change) && change.objectType.includes("WrappedAssetSetup")) {
wrappedSetupObjectId = change.objectId;
// TODO: what
versionType = change.objectType.split(", ")[1].replace(">", ""); // ugh
}
else if (isSuiCreateEvent(change) && change.objectType.includes("UpgradeCap")) {
coinUpgradeCapId = change.objectId;
}
else if (isSuiCreateEvent(change) && change.objectType.includes("CoinMetadata")) {
coinMetadataObjectId = change.objectId;
}
}
if (coinPackageId !== "" &&
wrappedSetupObjectId !== "" &&
coinUpgradeCapId !== "" &&
coinMetadataObjectId !== "") {
found = true;
break;
}
else {
coinPackageId = "";
wrappedSetupObjectId = "";
coinUpgradeCapId = "";
coinMetadataObjectId = "";
}
}
}
const coinType = getCoinTypeFromPackageId(coinPackageId);
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