resolveCoinBalanceIntents function
Future<void>
resolveCoinBalanceIntents(
- TransactionBlockDataBuilder data,
- TransactionBuilderClient? client
Implementation
Future<void> resolveCoinBalanceIntents(
TransactionBlockDataBuilder data, TransactionBuilderClient? client) async {
if (!data.commands.any(_isCwbIntent)) return;
// Command arguments may hold `TransactionResult` instances — e.g. the value
// returned by `tx.coin()`/`tx.balance()` and passed into another command.
// Normalize them to their `Result`/`NestedResult` map form up front so the
// index remapping below (which matches on those maps) can rewrite references
// to a resolved intent. Left as instances, such an argument would serialize
// as a `Result` pointing at the now-replaced intent command and the node
// would reject it (wrong result arity).
for (final cmd in data.commands) {
_mapCommandArgs(cmd, (arg) => arg is TransactionResult ? arg.toJson() : arg);
}
if (data.sender == null) {
throw ArgumentError('Sender must be set to resolve CoinWithBalance');
}
if (client == null) {
throw ArgumentError('Client must be provided to build transactions '
'with CoinWithBalance intents');
}
final totalByType = <String, BigInt>{};
final intentsByType = <String, List<_Intent>>{};
// First pass: sum required balances per coin type and resolve any
// zero-balance intents in place (they need no funds) — a zero coin or a zero
// balance depending on the intent's output kind.
for (var i = 0; i < data.commands.length; i++) {
final cmd = data.commands[i];
if (!_isCwbIntent(cmd)) continue;
final d = cmd[r'$Intent']['data'] as Map;
final type = d['type'] as String;
final balance = BigInt.parse(d['balance'].toString());
final outputKind = (d['outputKind'] ?? 'coin') as String;
if (balance == BigInt.zero) {
final coinType = type == 'gas' ? _suiType : type;
final target = outputKind == 'balance'
? '$_coinPackage::balance::zero'
: '$_coinPackage::coin::zero';
_replaceCommand(data, i, [_moveCall(target, [coinType], const [])]);
continue;
}
totalByType[type] = (totalByType[type] ?? BigInt.zero) + balance;
(intentsByType[type] ??= <_Intent>[])
.add((balance: balance, outputKind: outputKind));
}
if (totalByType.isEmpty) return; // all intents were zero-balance
if (totalByType.containsKey('gas') && totalByType.containsKey(_suiType)) {
throw ArgumentError('Cannot mix SUI CoinWithBalance intents that use the '
'gas coin with ones that do not (useGasCoin: false).');
}
// Object ids already used by other inputs must not be re-selected as coins.
final usedIds = <String>{};
for (final input in data.inputs) {
final owned = input['Object']?['ImmOrOwnedObject']?['objectId'];
if (owned != null) usedIds.add(normalizeSuiAddress(owned));
final unresolved = input['UnresolvedObject']?['objectId'];
if (unresolved != null) usedIds.add(normalizeSuiAddress(unresolved));
}
// Resolve owned coins + address balance per type. For 'gas', only the SUI
// address balance matters (coins come from the gas coin, not selected here).
final coinsByType = <String, List<CoinStruct>>{};
final addressBalanceByType = <String, BigInt>{};
for (final entry in totalByType.entries) {
if (debugForceAddressBalanceCoverage) {
coinsByType[entry.key] = const [];
addressBalanceByType[entry.key] = entry.value;
} else if (entry.key == 'gas') {
final b = await client.getBalanceBreakdown(data.sender!, _suiType);
addressBalanceByType['gas'] = b.addressBalance;
} else {
final r = await _getCoinsAndBalance(
client, data.sender!, entry.key, entry.value, usedIds);
coinsByType[entry.key] = r.coins;
addressBalanceByType[entry.key] = r.addressBalance;
}
}
final mergedCoins = <String, dynamic>{};
final exactBalanceByType = <String, bool>{};
final usedAddressBalance = <String>{};
// Per-type Path-2 state: the ordered intent results + a cursor.
final typeResults = <String, List<dynamic>>{};
final typeNext = <String, int>{};
var index = 0;
while (index < data.commands.length) {
final cmd = data.commands[index];
if (!_isCwbIntent(cmd)) {
index++;
continue;
}
final d = cmd[r'$Intent']['data'] as Map;
final type = d['type'] as String;
final balance = BigInt.parse(d['balance'].toString());
final coinType = type == 'gas' ? _suiType : type;
final totalRequired = totalByType[type]!;
final addressBalance = addressBalanceByType[type] ?? BigInt.zero;
final intents = intentsByType[type]!;
final allBalance = intents.every((i) => i.outputKind == 'balance');
final commands = <Map<String, dynamic>>[];
dynamic intentResult;
if (allBalance && addressBalance >= totalRequired) {
// Path 1: all balance-output intents with sufficient address balance —
// withdraw this intent's amount directly (no coins; parallel-safe).
commands.add(_moveCall('$_coinPackage::balance::redeem_funds', [coinType], [
data.addInput('withdrawal', _fundsWithdrawal(balance, coinType))
]));
intentResult = {
r'$kind': 'NestedResult',
'NestedResult': [index + commands.length - 1, 0]
};
} else {
// Path 2: merge sources then split all intents of this type at once,
// built the first time the type is seen.
if (!typeResults.containsKey(type)) {
final sources = <dynamic>[];
if (addressBalance >= totalRequired) {
// Source entirely from address balance — no coins needed.
usedAddressBalance.add(type);
commands.add(_moveCall('$_coinPackage::coin::redeem_funds', [coinType], [
data.addInput('withdrawal', _fundsWithdrawal(totalRequired, coinType))
]));
sources.add(
{r'$kind': 'Result', 'Result': index + commands.length - 1});
} else if (type == 'gas') {
sources.add({r'$kind': 'GasCoin', 'GasCoin': true});
} else {
final coins = coinsByType[type]!;
final loaded =
coins.fold(BigInt.zero, (s, c) => s + BigInt.parse(c.balance));
final abNeeded =
totalRequired > loaded ? totalRequired - loaded : BigInt.zero;
exactBalanceByType[type] = loaded + abNeeded == totalRequired;
for (final coin in coins) {
sources.add(data.addInput(
'object',
Inputs.objectRef(SuiObjectRef(
coin.digest, coin.coinObjectId, coin.version))));
}
if (abNeeded > BigInt.zero) {
usedAddressBalance.add(type);
commands.add(
_moveCall('$_coinPackage::coin::redeem_funds', [coinType], [
data.addInput('withdrawal', _fundsWithdrawal(abNeeded, coinType))
]));
sources.add(
{r'$kind': 'Result', 'Result': index + commands.length - 1});
}
}
final baseCoin = sources.first;
final rest = sources.sublist(1);
for (var i = 0; i < rest.length; i += 500) {
commands.add(_mergeCoins(
baseCoin, rest.sublist(i, min(i + 500, rest.length))));
}
// Remember the merged coin so its remainder can be handled after all
// intents are placed (see the remainder pass below).
mergedCoins[type] = baseCoin;
final splitCmdIndex = index + commands.length;
commands.add(_splitCoins(
baseCoin,
intents
.map((it) => data.addInput(
'pure', Inputs.pure(SuiBcs.U64.serialize(it.balance))))
.toList()));
final results = <dynamic>[];
for (var i = 0; i < intents.length; i++) {
final splitResult = {
r'$kind': 'NestedResult',
'NestedResult': [splitCmdIndex, i]
};
if (intents[i].outputKind == 'balance') {
commands.add(_moveCall(
'$_coinPackage::coin::into_balance', [coinType], [splitResult]));
results.add({
r'$kind': 'NestedResult',
'NestedResult': [index + commands.length - 1, 0]
});
} else {
results.add(splitResult);
}
}
typeResults[type] = results;
typeNext[type] = 0;
}
intentResult = typeResults[type]![typeNext[type]!];
typeNext[type] = typeNext[type]! + 1;
}
_replaceCommand(data, index, commands, intentResult);
index += commands.length;
}
// Remainder pass: after every intent is placed, dispose of each merged coin's
// leftover. The merged coin's argument (an object input, a `redeem_funds`
// result, or the gas coin) keeps its position across the earlier splices, so
// these commands can be appended to the end of the list.
for (final entry in mergedCoins.entries) {
final type = entry.key;
final mergedCoin = entry.value;
// Gas coin funded from itself (not the address balance): the leftover simply
// stays in the gas coin, so nothing to return.
if (type == 'gas' && !usedAddressBalance.contains(type)) continue;
final coinType = type == 'gas' ? _suiType : type;
final hasBalanceIntent =
(intentsByType[type] ?? const <_Intent>[]).any((i) => i.outputKind == 'balance');
final sourcedFromAB = usedAddressBalance.contains(type);
if (hasBalanceIntent || sourcedFromAB) {
// Sourced from the address balance, or a balance output was produced:
// return the remainder to the sender's address balance (gasless-eligible,
// and a no-op for a zero remainder).
data.commands.add(_moveCall('$_coinPackage::coin::send_funds', [coinType], [
mergedCoin,
data.addInput('pure', Inputs.pure(SuiBcs.Address.serialize(data.sender!)))
]));
} else if (exactBalanceByType[type] == true) {
// Coin-only sources matched the amount exactly: destroy the zero dust.
data.commands.add(
_moveCall('$_coinPackage::coin::destroy_zero', [coinType], [mergedCoin]));
}
// Coin-only with a surplus: the merged coin stays with the sender as an
// owned object.
}
}