feat: add prepareOrdinalSend to ordinals interface
What changed, and why it matters
This commit adds a new helper function for sending Bitcoin ordinals (special NFT-like assets). It builds a transaction that spends only the single ordinal coin and sends it to a recipient. The change temporarily unblocks a 'blocked' ordinal UTXO so the wallet's coin selection will accept it, then re-blocks it afterward. There is no obvious security bug in the diff, but the code touches sensitive areas: coin control, blocked UTXO state, fee handling, and ordinal preservation. The safety of the function depends heavily on whether the underlying prepareSend correctly handles a single-input send-all and does not accidentally add extra inputs or change outputs that could split or lose the ordinal.
Review the implementation of prepareSend and the coin-selection/fee-construction code to confirm that: (1) only the provided ordinal UTXO is used as input, (2) no change output is added, (3) the recipient receives exactly one output, (4) the fee is deducted from the UTXO value without breaking FIFO ordinal assignment, and (5) the temporary unblock/reblock cannot race with other wallet operations. Consider adding explicit tests for ordinal send-all behavior and failure paths.
Security signals we found
UTXO blocking/unblocking logic around sensitive ordinal assets
Single-input send-all transaction construction for ordinals
Reliance on FIFO ordering to preserve ordinal assignment
Use of ignoreCachedBalanceChecks=true
No explicit change-output suppression visible in the diff
Evidence from the diff
The patch introduces prepareOrdinalSend in lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart. It takes an ordinal UTXO, recipient address, and fee rate; constructs a TxData with one recipient equal to the full UTXO value, one StandardInput, ignoreCachedBalanceChecks=true, and a note; then calls prepareSend. If the original UTXO was blocked, it unblocks it in the local DB before coin selection and re-blocks it in a finally block. The intent is FIFO ordinal transfer: one input, one output, no change. Risks are indirect: if prepareSend ignores the recipient amount and treats this as a send-all to the wallet’s own change, or if it adds additional inputs/outputs, the ordinal could be destroyed or sent to the wrong address. The diff itself does not show those implementations, so classification is speculative.
Changed components
lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dartOrdinal send transaction preparationUTXO blocking state managementInspect captured patch +69 / −0
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart
index 2ee6f34..f9165fb 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart
@@ -1,10 +1,16 @@
import 'package:isar_community/isar.dart';
import '../../../dto/ordinals/inscription_data.dart';
+import '../../../models/input.dart';
+import '../../../models/isar/models/blockchain_data/address.dart';
+import '../../../models/isar/models/blockchain_data/utxo.dart';
import '../../../models/isar/ordinal.dart';
import '../../../services/ord_api.dart';
+import '../../../utilities/amount/amount.dart';
+import '../../../utilities/enums/fee_rate_type_enum.dart';
import '../../../utilities/logger.dart';
import '../../crypto_currency/interfaces/electrumx_currency_interface.dart';
+import '../../models/tx_data.dart';
import 'electrumx_interface.dart';
mixin OrdinalsInterface<T extends ElectrumXCurrencyInterface>
@@ -86,6 +92,69 @@ mixin OrdinalsInterface<T extends ElectrumXCurrencyInterface>
}
}
+ /// Build a transaction that sends the ordinal UTXO to [recipientAddress].
+ ///
+ /// Uses coin-control send-all from the single ordinal UTXO so the ordinal
+ /// (at input offset 0) lands on the only output (the recipient) via FIFO.
+ /// If the UTXO value can't cover the fee, an exception is thrown.
+ Future<TxData> prepareOrdinalSend({
+ required UTXO ordinalUtxo,
+ required String recipientAddress,
+ FeeRateType feeRateType = FeeRateType.average,
+ }) async {
+ // Temporarily unblock so coinSelection accepts it.
+ final wasBlocked = ordinalUtxo.isBlocked;
+ // utxoForTx is the in-memory object passed to coinSelection; it must have
+ // isBlocked=false or the spendable-outputs filter will reject it.
+ UTXO utxoForTx = ordinalUtxo;
+ if (wasBlocked) {
+ final unblocked = ordinalUtxo.copyWith(
+ isBlocked: false,
+ blockedReason: null,
+ );
+ unblocked.id = ordinalUtxo.id;
+ await mainDB.putUTXO(unblocked);
+ utxoForTx = unblocked;
+ }
+
+ try {
+ final utxoValue = Amount(
+ rawValue: BigInt.from(ordinalUtxo.value),
+ fractionDigits: cryptoCurrency.fractionDigits,
+ );
+
+ final txData = TxData(
+ feeRateType: feeRateType,
+ recipients: [
+ TxRecipient(
+ address: recipientAddress,
+ amount: utxoValue,
+ isChange: false,
+ addressType:
+ cryptoCurrency.getAddressType(recipientAddress) ??
+ AddressType.unknown,
+ ),
+ ],
+ utxos: {StandardInput(utxoForTx)},
+ ignoreCachedBalanceChecks: true,
+ note:
+ "Send ordinal #${(await mainDB.isar.ordinals.where().filter().walletIdEqualTo(walletId).and().utxoTXIDEqualTo(ordinalUtxo.txid).and().utxoVOUTEqualTo(ordinalUtxo.vout).findFirst())?.inscriptionNumber ?? "unknown"}",
+ );
+
+ return await prepareSend(txData: txData);
+ } finally {
+ // Re-block regardless of success or failure.
+ if (wasBlocked) {
+ final reblocked = ordinalUtxo.copyWith(
+ isBlocked: true,
+ blockedReason: "Ordinal",
+ );
+ reblocked.id = ordinalUtxo.id;
+ await mainDB.putUTXO(reblocked);
+ }
+ }
+ }
+
// =================== Overrides =============================================
@override
Why this scored 16/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.