diff --git a/.changeset/soft-ties-train.md b/.changeset/soft-ties-train.md new file mode 100644 index 0000000000..bd5e0e65e3 --- /dev/null +++ b/.changeset/soft-ties-train.md @@ -0,0 +1,6 @@ +--- +"@swapkit/toolboxes": minor +"@swapkit/helpers": minor +--- + +Adds SUI token transaction support diff --git a/bun.lock b/bun.lock index 7f2b945674..2df0270853 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "swapkit-monorepo", diff --git a/packages/helpers/src/modules/swapKitError.ts b/packages/helpers/src/modules/swapKitError.ts index 5113648dee..53bcaef952 100644 --- a/packages/helpers/src/modules/swapKitError.ts +++ b/packages/helpers/src/modules/swapKitError.ts @@ -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 */ diff --git a/packages/toolboxes/src/sui/__tests__/toolbox.test.ts b/packages/toolboxes/src/sui/__tests__/toolbox.test.ts index 0e490d1283..16b01b2837 100644 --- a/packages/toolboxes/src/sui/__tests__/toolbox.test.ts +++ b/packages/toolboxes/src/sui/__tests__/toolbox.test.ts @@ -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 }, + ); + }); }); diff --git a/packages/toolboxes/src/sui/toolbox.ts b/packages/toolboxes/src/sui/toolbox.ts index 72797a1e74..ce5355c59e 100644 --- a/packages/toolboxes/src/sui/toolbox.ts +++ b/packages/toolboxes/src/sui/toolbox.ts @@ -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 { + 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"); @@ -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); @@ -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) { @@ -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 }); } } @@ -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();