Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/soft-ties-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@swapkit/toolboxes": minor
"@swapkit/helpers": minor
---

Adds SUI token transaction support
1 change: 0 additions & 1 deletion bun.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "swapkit-monorepo",
Expand Down
3 changes: 3 additions & 0 deletions packages/helpers/src/modules/swapKitError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,9 @@ const errorCodes = {
toolbox_sui_broadcast_error: 90706,
toolbox_sui_no_signer: 90707,
toolbox_sui_no_sender: 90708,
toolbox_sui_missing_coin_type: 90709,
toolbox_sui_no_coins_found: 90710,
toolbox_sui_insufficient_balance: 90711,
/**
* Toolboxes - General
*/
Expand Down
77 changes: 77 additions & 0 deletions packages/toolboxes/src/sui/__tests__/toolbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,81 @@ describe("Sui Toolbox", () => {

expect(signedTx.bytes.length).toBeGreaterThan(0);
});

describe("Token Transfers", () => {
// Native USDC on SUI - https://suiscan.xyz/mainnet/coin/0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC
const USDC_COIN_TYPE = "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC";
// Address with USDC balance: https://suiscan.xyz/mainnet/account/0x48a451b8a98f4e9cda542e4a87ab2449c9d3e53fbe1bac991ae38de4599143a0/portfolio
const ADDRESS_WITH_USDC = "0x48a451b8a98f4e9cda542e4a87ab2449c9d3e53fbe1bac991ae38de4599143a0";

test("should create AssetValue for USDC token with correct address", () => {
const usdcAsset = AssetValue.from({ asset: `${Chain.Sui}.USDC-${USDC_COIN_TYPE}`, value: "1" });

expect(usdcAsset.address).toBe(USDC_COIN_TYPE);
expect(usdcAsset.isGasAsset).toBe(false);
expect(usdcAsset.chain).toBe(Chain.Sui);
});

test(
"should throw error when no coins found for token transfer",
async () => {
const address = context.toolbox.getAddress();
if (!address) throw new Error("No address generated");

const usdcAsset = AssetValue.from({ asset: `${Chain.Sui}.USDC-${USDC_COIN_TYPE}`, value: "1" });

await expect(
context.toolbox.createTransaction({ assetValue: usdcAsset, recipient: KNOWN_SUI_ADDRESS, sender: address }),
).rejects.toThrow("toolbox_sui_no_coins_found");
},
{ timeout: 15000 },
);

test("should throw error when coin type is missing", async () => {
const address = context.toolbox.getAddress();
if (!address) throw new Error("No address generated");

const invalidAsset = AssetValue.from({ chain: Chain.Sui, value: "1" });
Object.defineProperty(invalidAsset, "isGasAsset", { value: false });
Object.defineProperty(invalidAsset, "symbol", { value: "FAKE" });
Object.defineProperty(invalidAsset, "address", { value: undefined });

await expect(
context.toolbox.createTransaction({ assetValue: invalidAsset, recipient: KNOWN_SUI_ADDRESS, sender: address }),
).rejects.toThrow("toolbox_sui_missing_coin_type");
});

test(
"should create USDC transfer transaction for address with USDC balance",
async () => {
const usdcAsset = AssetValue.from({ asset: `${Chain.Sui}.USDC-${USDC_COIN_TYPE}`, value: "0.01" });

const { tx, txBytes } = await context.toolbox.createTransaction({
assetValue: usdcAsset,
recipient: KNOWN_SUI_ADDRESS,
sender: ADDRESS_WITH_USDC,
});

expect(tx).toBeDefined();
expect(txBytes).toBeInstanceOf(Uint8Array);
expect(txBytes.length).toBeGreaterThan(0);

const txData = tx.getData();

expect(txData.sender).toBe(ADDRESS_WITH_USDC);

const commands = txData.commands;
expect(commands.length).toBeGreaterThanOrEqual(2);

const transferCmd = commands.find((cmd) => "$kind" in cmd && cmd.$kind === "TransferObjects");
expect(transferCmd).toBeDefined();

const splitCmd = commands.find((cmd) => "$kind" in cmd && cmd.$kind === "SplitCoins");
expect(splitCmd).toBeDefined();

expect(txData.inputs.length).toBeGreaterThan(0);
},
{ timeout: 30000 },
);
});
});
71 changes: 67 additions & 4 deletions packages/toolboxes/src/sui/toolbox.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,54 @@
import type { SuiClient } from "@mysten/sui/client";
import type { Transaction } from "@mysten/sui/transactions";
import { AssetValue, Chain, getChainConfig, SwapKitError } from "@swapkit/helpers";
import { match, P } from "ts-pattern";
import type { SuiCreateTransactionParams, SuiToolboxParams, SuiTransferParams } from "./types";

type CoinData = { coinObjectId: string; balance: string };

async function fetchAllCoins(
suiClient: SuiClient,
owner: string,
coinType: string,
coins: CoinData[] = [],
cursor?: string | null,
): Promise<CoinData[]> {
const response = await suiClient.getCoins({ coinType, cursor, owner });
const allCoins = [...coins, ...response.data];

return response.hasNextPage ? fetchAllCoins(suiClient, owner, coinType, allCoins, response.nextCursor) : allCoins;
}

function prepareCoinForTransfer(tx: Transaction, coins: CoinData[], amountToSend: bigint) {
const totalBalance = coins.reduce((sum, coin) => sum + BigInt(coin.balance), 0n);

if (totalBalance < amountToSend) {
throw new SwapKitError("toolbox_sui_insufficient_balance", {
available: totalBalance.toString(),
required: amountToSend.toString(),
});
}

const { ids } = coins.reduce<{ ids: string[]; total: bigint }>(
(acc, coin) => {
if (acc.total >= amountToSend) return acc;
return { ids: [...acc.ids, coin.coinObjectId], total: acc.total + BigInt(coin.balance) };
},
{ ids: [], total: 0n },
);

const primaryCoinId = ids[0] as string;
const otherCoinIds = ids.slice(1);

if (otherCoinIds.length > 0) {
tx.mergeCoins(primaryCoinId, otherCoinIds);
}

const [coinToTransfer] = tx.splitCoins(primaryCoinId, [amountToSend]);

return coinToTransfer;
}

export async function getSuiAddressValidator() {
const { isValidSuiAddress } = await import("@mysten/sui/utils");

Expand Down Expand Up @@ -37,7 +84,7 @@ export async function getSuiToolbox({ provider: providerParam, ...signerParams }
async function getBalance(targetAddress?: string) {
const addressToQuery = targetAddress || getAddress();
if (!addressToQuery) {
throw new SwapKitError("toolbox_sui_address_required" as any);
throw new SwapKitError("toolbox_sui_address_required");
}

const { baseDecimal: fromBaseDecimal, chain } = getChainConfig(Chain.Sui);
Expand Down Expand Up @@ -105,7 +152,22 @@ export async function getSuiToolbox({ provider: providerParam, ...signerParams }
const [suiCoin] = tx.splitCoins(tx.gas, [assetValue.getBaseValue("string")]);
tx.transferObjects([suiCoin], recipient);
} else {
throw new SwapKitError("toolbox_sui_custom_token_transfer_not_implemented" as any);
// Custom token transfer - need to fetch and merge coin objects
const coinType = assetValue.address;
if (!coinType) {
throw new SwapKitError("toolbox_sui_missing_coin_type");
}

const suiClient = await getSuiClient();
const amountToSend = assetValue.getBaseValue("bigint");

const coins = await fetchAllCoins(suiClient, senderAddress, coinType);
if (!coins.length) {
throw new SwapKitError("toolbox_sui_no_coins_found", { coinType });
}

const coinToSend = prepareCoinForTransfer(tx, coins, amountToSend);
tx.transferObjects([coinToSend], recipient);
}

if (gasBudget) {
Expand All @@ -117,7 +179,8 @@ export async function getSuiToolbox({ provider: providerParam, ...signerParams }

return { tx, txBytes };
} catch (error) {
throw new SwapKitError("toolbox_sui_transaction_creation_error" as any, { error });
if (error instanceof SwapKitError) throw error;
throw new SwapKitError("toolbox_sui_transaction_creation_error", { error });
}
}

Expand All @@ -139,7 +202,7 @@ export async function getSuiToolbox({ provider: providerParam, ...signerParams }

async function transfer({ assetValue, gasBudget, recipient }: SuiTransferParams) {
if (!signer) {
throw new SwapKitError("toolbox_sui_no_signer" as any);
throw new SwapKitError("toolbox_sui_no_signer");
}

const sender = signer.toSuiAddress() || getAddress();
Expand Down
Loading