resolveCoinBalanceIntents function

Future<void> resolveCoinBalanceIntents(
  1. TransactionBlockDataBuilder data,
  2. 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.
  }
}