UNPKG

@mysten/sui

Version:
477 lines (409 loc) 13.3 kB
// Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 import type { InferInput } from 'valibot'; import { bigint, object, optional, parse, picklist, string } from 'valibot'; import { bcs } from '../../bcs/index.js'; import { normalizeStructTag } from '../../utils/sui-types.js'; import { TransactionCommands } from '../Commands.js'; import type { Argument } from '../data/internal.js'; import { Inputs } from '../Inputs.js'; import type { BuildTransactionOptions } from '../resolve.js'; import type { Transaction, TransactionResult } from '../Transaction.js'; import type { TransactionDataBuilder } from '../TransactionData.js'; import type { ClientWithCoreApi, SuiClientTypes } from '../../client/index.js'; export const COIN_WITH_BALANCE = 'CoinWithBalance'; const SUI_TYPE = normalizeStructTag('0x2::sui::SUI'); export function coinWithBalance({ type = SUI_TYPE, balance, useGasCoin = true, }: { balance: bigint | number; type?: string; useGasCoin?: boolean; }): (tx: Transaction) => TransactionResult { let coinResult: TransactionResult | null = null; return (tx: Transaction) => { if (coinResult) { return coinResult; } tx.addIntentResolver(COIN_WITH_BALANCE, resolveCoinBalance); const coinType = type === 'gas' ? type : normalizeStructTag(type); coinResult = tx.add( TransactionCommands.Intent({ name: COIN_WITH_BALANCE, inputs: {}, data: { type: coinType === SUI_TYPE && useGasCoin ? 'gas' : coinType, balance: BigInt(balance), outputKind: 'coin', } satisfies InferInput<typeof CoinWithBalanceData>, }), ); return coinResult; }; } export function createBalance({ type = SUI_TYPE, balance, useGasCoin = true, }: { balance: bigint | number; type?: string; useGasCoin?: boolean; }): (tx: Transaction) => TransactionResult { let balanceResult: TransactionResult | null = null; return (tx: Transaction) => { if (balanceResult) { return balanceResult; } tx.addIntentResolver(COIN_WITH_BALANCE, resolveCoinBalance); const coinType = type === 'gas' ? type : normalizeStructTag(type); balanceResult = tx.add( TransactionCommands.Intent({ name: COIN_WITH_BALANCE, inputs: {}, data: { type: coinType === SUI_TYPE && useGasCoin ? 'gas' : coinType, balance: BigInt(balance), outputKind: 'balance', } satisfies InferInput<typeof CoinWithBalanceData>, }), ); return balanceResult; }; } const CoinWithBalanceData = object({ type: string(), balance: bigint(), outputKind: optional(picklist(['coin', 'balance'])), }); export async function resolveCoinBalance( transactionData: TransactionDataBuilder, buildOptions: BuildTransactionOptions, next: () => Promise<void>, ) { type IntentInfo = { balance: bigint; outputKind: 'coin' | 'balance' }; const coinTypes = new Set<string>(); const totalByType = new Map<string, bigint>(); const intentsByType = new Map<string, IntentInfo[]>(); if (!transactionData.sender) { throw new Error('Sender must be set to resolve CoinWithBalance'); } // First pass: scan intents, collect per-type data, and resolve zero-balance intents in place. for (const [i, command] of transactionData.commands.entries()) { if (command.$kind !== '$Intent' || command.$Intent.name !== COIN_WITH_BALANCE) { continue; } const { type, balance, outputKind } = parse(CoinWithBalanceData, command.$Intent.data); // Zero-balance intents are resolved immediately — no coins or AB needed. // This is a 1:1 replacement so indices don't shift. if (balance === 0n) { const coinType = type === 'gas' ? SUI_TYPE : type; transactionData.replaceCommand( i, TransactionCommands.MoveCall({ target: (outputKind ?? 'coin') === 'balance' ? '0x2::balance::zero' : '0x2::coin::zero', typeArguments: [coinType], }), ); continue; } if (type !== 'gas') { coinTypes.add(type); } totalByType.set(type, (totalByType.get(type) ?? 0n) + balance); if (!intentsByType.has(type)) intentsByType.set(type, []); intentsByType.get(type)!.push({ balance, outputKind: outputKind ?? 'coin' }); } const usedIds = new Set<string>(); for (const input of transactionData.inputs) { if (input.Object?.ImmOrOwnedObject) { usedIds.add(input.Object.ImmOrOwnedObject.objectId); } if (input.UnresolvedObject?.objectId) { usedIds.add(input.UnresolvedObject.objectId); } } const coinsByType = new Map<string, SuiClientTypes.Coin[]>(); const addressBalanceByType = new Map<string, bigint>(); const client = buildOptions.client; if (!client) { throw new Error( 'Client must be provided to build or serialize transactions with CoinWithBalance intents', ); } await Promise.all([ ...[...coinTypes].map(async (coinType) => { const { coins, addressBalance } = await getCoinsAndBalanceOfType({ coinType, balance: totalByType.get(coinType)!, client, owner: transactionData.sender!, usedIds, }); coinsByType.set(coinType, coins); addressBalanceByType.set(coinType, addressBalance); }), totalByType.has('gas') ? await client.core .getBalance({ owner: transactionData.sender!, coinType: SUI_TYPE, }) .then(({ balance }) => { addressBalanceByType.set('gas', BigInt(balance.addressBalance)); }) : null, ]); const mergedCoins = new Map<string, Argument>(); const exactBalanceByType = new Map<string, boolean>(); // Per-type state for Path 2 combined splits type TypeState = { results: Argument[]; nextIntent: number }; const typeState = new Map<string, TypeState>(); let index = 0; while (index < transactionData.commands.length) { const transaction = transactionData.commands[index]; if (transaction.$kind !== '$Intent' || transaction.$Intent.name !== COIN_WITH_BALANCE) { index++; continue; } const { type, balance } = transaction.$Intent.data as { type: string; balance: bigint; }; const coinType = type === 'gas' ? SUI_TYPE : type; const totalRequired = totalByType.get(type)!; const addressBalance = addressBalanceByType.get(type) ?? 0n; const commands = []; let intentResult: Argument; const intentsForType = intentsByType.get(type) ?? []; const allBalance = intentsForType.every((i) => i.outputKind === 'balance'); if (allBalance && addressBalance >= totalRequired) { // Path 1: All balance intents and AB sufficient — direct per-intent withdrawal. // No coins touched, enables parallel execution. commands.push( TransactionCommands.MoveCall({ target: '0x2::balance::redeem_funds', typeArguments: [coinType], arguments: [ transactionData.addInput( 'withdrawal', Inputs.FundsWithdrawal({ reservation: { $kind: 'MaxAmountU64', MaxAmountU64: String(balance), }, typeArg: { $kind: 'Balance', Balance: coinType }, withdrawFrom: { $kind: 'Sender', Sender: true }, }), ), ], }), ); intentResult = { $kind: 'NestedResult', NestedResult: [index + commands.length - 1, 0], }; } else { // Path 2: Merge and Split — build a merged coin, split all intents at once. if (!typeState.has(type)) { const intents = intentsForType; // Step 1: Build sources and merge const sources: Argument[] = []; if (type === 'gas') { sources.push({ $kind: 'GasCoin', GasCoin: true }); } else { const coins = coinsByType.get(type)!; const loadedCoinBalance = coins.reduce((sum, c) => sum + BigInt(c.balance), 0n); const abNeeded = totalRequired > loadedCoinBalance ? totalRequired - loadedCoinBalance : 0n; exactBalanceByType.set(type, loadedCoinBalance + abNeeded === totalRequired); for (const coin of coins) { sources.push( transactionData.addInput( 'object', Inputs.ObjectRef({ objectId: coin.objectId, digest: coin.digest, version: coin.version, }), ), ); } if (abNeeded > 0n) { commands.push( TransactionCommands.MoveCall({ target: '0x2::coin::redeem_funds', typeArguments: [coinType], arguments: [ transactionData.addInput( 'withdrawal', Inputs.FundsWithdrawal({ reservation: { $kind: 'MaxAmountU64', MaxAmountU64: String(abNeeded), }, typeArg: { $kind: 'Balance', Balance: coinType }, withdrawFrom: { $kind: 'Sender', Sender: true }, }), ), ], }), ); sources.push({ $kind: 'Result', Result: index + commands.length - 1 }); } } const baseCoin = sources[0]; const rest = sources.slice(1); for (let i = 0; i < rest.length; i += 500) { commands.push(TransactionCommands.MergeCoins(baseCoin, rest.slice(i, i + 500))); } mergedCoins.set(type, baseCoin); // Step 2: Combined SplitCoins for all intents of this type const splitCmdIndex = index + commands.length; commands.push( TransactionCommands.SplitCoins( baseCoin, intents.map((i) => transactionData.addInput('pure', Inputs.Pure(bcs.u64().serialize(i.balance))), ), ), ); // Build per-intent results, adding into_balance conversions for balance intents const results: Argument[] = []; for (let i = 0; i < intents.length; i++) { const splitResult: Argument = { $kind: 'NestedResult', NestedResult: [splitCmdIndex, i], }; if (intents[i].outputKind === 'balance') { commands.push( TransactionCommands.MoveCall({ target: '0x2::coin::into_balance', typeArguments: [coinType], arguments: [splitResult], }), ); results.push({ $kind: 'NestedResult', NestedResult: [index + commands.length - 1, 0], }); } else { results.push(splitResult); } } typeState.set(type, { results, nextIntent: 0 }); } const state = typeState.get(type)!; intentResult = state.results[state.nextIntent++]; } transactionData.replaceCommand( index, commands, intentResult as { NestedResult: [number, number] }, ); // Advance past the replacement. When commands is empty (subsequent intents // of a combined split), the command was removed and the next command shifted // into this position — so we stay at the same index. index += commands.length; } // Step 3: Remainder handling for (const [type, mergedCoin] of mergedCoins) { if (type === 'gas') continue; const hasBalanceIntent = intentsByType.get(type)?.some((i) => i.outputKind === 'balance'); if (hasBalanceIntent) { // Balance intents exist: send remainder coin back to sender's address balance. // coin::send_funds is gasless-eligible and handles zero amounts. transactionData.commands.push( TransactionCommands.MoveCall({ target: '0x2::coin::send_funds', typeArguments: [type], arguments: [ mergedCoin, transactionData.addInput( 'pure', Inputs.Pure(bcs.Address.serialize(transactionData.sender!)), ), ], }), ); } else if (exactBalanceByType.get(type)) { // Coin-only with exact match: destroy the zero-value dust coin. transactionData.commands.push( TransactionCommands.MoveCall({ target: '0x2::coin::destroy_zero', typeArguments: [type], arguments: [mergedCoin], }), ); } // Coin-only with surplus: merged coin stays with sender as an owned object } return next(); } async function getCoinsAndBalanceOfType({ coinType, balance, client, owner, usedIds, }: { coinType: string; balance: bigint; client: ClientWithCoreApi; owner: string; usedIds: Set<string>; }): Promise<{ coins: SuiClientTypes.Coin[]; balance: bigint; addressBalance: bigint; coinBalance: bigint; }> { let remainingBalance = balance; const coins: SuiClientTypes.Coin[] = []; const balanceRequest = client.core.getBalance({ owner, coinType }).then(({ balance }) => { remainingBalance -= BigInt(balance.addressBalance); return balance; }); const [allCoins, balanceResponse] = await Promise.all([loadMoreCoins(), balanceRequest]); if (BigInt(balanceResponse.balance) < balance) { throw new Error( `Insufficient balance of ${coinType} for owner ${owner}. Required: ${balance}, Available: ${ balance - remainingBalance }`, ); } return { coins: allCoins, balance: BigInt(balanceResponse.coinBalance), addressBalance: BigInt(balanceResponse.addressBalance), coinBalance: BigInt(balanceResponse.coinBalance), }; async function loadMoreCoins(cursor: string | null = null): Promise<SuiClientTypes.Coin[]> { const { objects, hasNextPage, cursor: nextCursor, } = await client.core.listCoins({ owner, coinType, cursor, }); await balanceRequest; // Always load all coins from the page (except already-used ones). // This merges all available coins rather than leaving dust. for (const coin of objects) { if (usedIds.has(coin.objectId)) { continue; } coins.push(coin); remainingBalance -= BigInt(coin.balance); } // Only paginate if loaded coins + AB are still insufficient if (remainingBalance > 0n && hasNextPage) { return loadMoreCoins(nextCursor); } return coins; } }