From dd8be539e0c59a281d09709455ba61b9b038cc3f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 14:22:22 -0600 Subject: [PATCH 1/8] fix: ordinals by looking up tx info directly TODO: remove (comment?) dead code litescribe_api.dart, litescribe_response.dart, address_inscription_response.dart are now orphaned --- lib/dto/ordinals/inscription_data.dart | 38 +++++ lib/services/ord_api.dart | 70 +++++++++ lib/wallets/wallet/impl/litecoin_wallet.dart | 7 +- .../ordinals_interface.dart | 137 ++++++++---------- 4 files changed, 172 insertions(+), 80 deletions(-) create mode 100644 lib/services/ord_api.dart diff --git a/lib/dto/ordinals/inscription_data.dart b/lib/dto/ordinals/inscription_data.dart index 2f12bd670a..19d6ae9a92 100644 --- a/lib/dto/ordinals/inscription_data.dart +++ b/lib/dto/ordinals/inscription_data.dart @@ -51,6 +51,44 @@ class InscriptionData { ); } + /// Parse the response from an ord server's /inscription/{id} endpoint. + /// [contentUrl] should be pre-built as `$baseUrl/content/$inscriptionId`. + factory InscriptionData.fromOrdJson( + Map json, + String contentUrl, + ) { + final inscriptionId = json['inscription_id'] as String; + final satpoint = json['satpoint'] as String? ?? ''; + // satpoint format: "txid:vout:offset" + final satpointParts = satpoint.split(':'); + if (satpointParts.length < 2 || satpointParts[0].isEmpty) { + throw FormatException( + 'Invalid satpoint for inscription $inscriptionId: "$satpoint"', + ); + } + final output = '${satpointParts[0]}:${satpointParts[1]}'; + final offset = satpointParts.length >= 3 + ? int.tryParse(satpointParts[2]) ?? 0 + : 0; + + return InscriptionData( + inscriptionId: inscriptionId, + inscriptionNumber: json['inscription_number'] as int? ?? 0, + address: json['address'] as String? ?? '', + preview: contentUrl, + content: contentUrl, + contentLength: json['content_length'] as int? ?? 0, + contentType: json['content_type'] as String? ?? '', + contentBody: '', + timestamp: json['timestamp'] as int? ?? 0, + genesisTransaction: inscriptionId.split('i').first, + location: satpoint, + output: output, + outputValue: json['output_value'] as int? ?? 0, + offset: offset, + ); + } + @override String toString() { return 'InscriptionData {' diff --git a/lib/services/ord_api.dart b/lib/services/ord_api.dart new file mode 100644 index 0000000000..79800860fd --- /dev/null +++ b/lib/services/ord_api.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../app_config.dart'; +import '../networking/http.dart'; +import '../utilities/prefs.dart'; +import 'tor_service.dart'; + +class OrdAPI { + final String baseUrl; + final HTTP _client = const HTTP(); + + OrdAPI({required this.baseUrl}); + + static const _jsonHeaders = {'Accept': 'application/json'}; + + ({InternetAddress host, int port})? get _proxyInfo => + !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null; + + /// Check an output for inscriptions. + /// Returns the list of inscription IDs found on the output, or empty list. + Future> getInscriptionIdsForOutput(String txid, int vout) async { + final response = await _client.get( + url: Uri.parse('$baseUrl/output/$txid:$vout'), + headers: _jsonHeaders, + proxyInfo: _proxyInfo, + ); + + if (response.code != 200) { + throw Exception( + 'OrdAPI getInscriptionIdsForOutput failed: ' + 'status=${response.code}', + ); + } + + final json = jsonDecode(response.body) as Map; + final inscriptions = json['inscriptions'] as List?; + + if (inscriptions == null || inscriptions.isEmpty) { + return []; + } + + return inscriptions.cast(); + } + + /// Fetch full inscription metadata by ID. + Future> getInscriptionData(String inscriptionId) async { + final response = await _client.get( + url: Uri.parse('$baseUrl/inscription/$inscriptionId'), + headers: _jsonHeaders, + proxyInfo: _proxyInfo, + ); + + if (response.code != 200) { + throw Exception( + 'OrdAPI getInscriptionData failed: ' + 'status=${response.code}', + ); + } + + return jsonDecode(response.body) as Map; + } + + /// Build the content URL for an inscription. + String contentUrl(String inscriptionId) => '$baseUrl/content/$inscriptionId'; +} diff --git a/lib/wallets/wallet/impl/litecoin_wallet.dart b/lib/wallets/wallet/impl/litecoin_wallet.dart index c9fa52a330..32cfe8fe6b 100644 --- a/lib/wallets/wallet/impl/litecoin_wallet.dart +++ b/lib/wallets/wallet/impl/litecoin_wallet.dart @@ -35,6 +35,9 @@ class LitecoinWallet @override int get isarTransactionVersion => 2; + @override + String get ordServerBaseUrl => 'https://ord-litecoin.stackwallet.com'; + LitecoinWallet(CryptoCurrencyNetwork network) : super(Litecoin(network) as T); @override @@ -86,9 +89,7 @@ class LitecoinWallet // Remove duplicates. final allAddressesSet = {...receivingAddresses, ...changeAddresses}; - final updateInscriptionsFuture = refreshInscriptions( - overrideAddressesToCheck: allAddressesSet.toList(), - ); + final updateInscriptionsFuture = refreshInscriptions(); // Fetch history from ElectrumX. final List> allTxHashes = await fetchHistory( diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart index 686d1f90a9..2ee6f34ad6 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart @@ -1,53 +1,73 @@ import 'package:isar_community/isar.dart'; import '../../../dto/ordinals/inscription_data.dart'; -import '../../../models/isar/models/blockchain_data/utxo.dart'; import '../../../models/isar/ordinal.dart'; -import '../../../services/litescribe_api.dart'; +import '../../../services/ord_api.dart'; import '../../../utilities/logger.dart'; import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; import 'electrumx_interface.dart'; mixin OrdinalsInterface on ElectrumXInterface { - final LitescribeAPI _litescribeAPI = LitescribeAPI( - baseUrl: 'https://litescribe.io/api', - ); + /// Subclasses must provide the base URL for their ord server. + /// e.g. 'https://ord-litecoin.stackwallet.com' + String get ordServerBaseUrl; - // check if an inscription is in a given output - Future _inscriptionInAddress(String address) async { + late final OrdAPI _ordAPI = OrdAPI(baseUrl: ordServerBaseUrl); + + /// Check whether a specific output contains inscriptions. + Future _inscriptionInOutput(String txid, int vout) async { try { - return (await _litescribeAPI.getInscriptionsByAddress( - address, - )).isNotEmpty; + final ids = await _ordAPI.getInscriptionIdsForOutput(txid, vout); + return ids.isNotEmpty; } catch (e, s) { - Logging.instance.e("Litescribe api failure!", error: e, stackTrace: s); - + Logging.instance.e( + "Ord API output check failure!", + error: e, + stackTrace: s, + ); return false; } } - Future refreshInscriptions({ - List? overrideAddressesToCheck, - }) async { + Future refreshInscriptions() async { try { - final uniqueAddresses = - overrideAddressesToCheck ?? - await mainDB - .getUTXOs(walletId) - .filter() - .addressIsNotNull() - .distinctByAddress() - .addressProperty() - .findAll(); - final inscriptions = await _getInscriptionDataFromAddresses( - uniqueAddresses.cast(), - ); + final utxos = await mainDB.getUTXOs(walletId).findAll(); + + final List allInscriptions = []; - final ords = - inscriptions - .map((e) => Ordinal.fromInscriptionData(e, walletId)) - .toList(); + for (final utxo in utxos) { + try { + final ids = await _ordAPI.getInscriptionIdsForOutput( + utxo.txid, + utxo.vout, + ); + + for (final inscriptionId in ids) { + try { + final json = await _ordAPI.getInscriptionData(inscriptionId); + allInscriptions.add( + InscriptionData.fromOrdJson( + json, + _ordAPI.contentUrl(inscriptionId), + ), + ); + } catch (e) { + Logging.instance.w( + "Failed to fetch inscription $inscriptionId: $e", + ); + } + } + } catch (e) { + Logging.instance.w( + "Failed to check output ${utxo.txid}:${utxo.vout}: $e", + ); + } + } + + final ords = allInscriptions + .map((e) => Ordinal.fromInscriptionData(e, walletId)) + .toList(); await mainDB.isar.writeTxn(() async { await mainDB.isar.ordinals @@ -65,6 +85,7 @@ mixin OrdinalsInterface ); } } + // =================== Overrides ============================================= @override @@ -79,58 +100,20 @@ mixin OrdinalsInterface String? blockReason; String? label; + final txid = jsonTX["txid"] as String; + final vout = jsonUTXO["tx_pos"] as int; final utxoAmount = jsonUTXO["value"] as int; - // TODO: [prio=med] check following 3 todos - - // TODO check the specific output, not just the address in general - // TODO optimize by freezing output in OrdinalsInterface, so one ordinal API calls is made (or at least many less) - if (utxoOwnerAddress != null && - await _inscriptionInAddress(utxoOwnerAddress)) { + if (await _inscriptionInOutput(txid, vout)) { shouldBlock = true; blockReason = "Ordinal"; - label = "Ordinal detected at address"; - } else { - // TODO implement inscriptionInOutput - if (utxoAmount <= 10000) { - shouldBlock = true; - blockReason = "May contain ordinal"; - label = "Possible ordinal"; - } + label = "Ordinal detected at output"; + } else if (utxoAmount <= 10000) { + shouldBlock = true; + blockReason = "May contain ordinal"; + label = "Possible ordinal"; } return (blockedReason: blockReason, blocked: shouldBlock, utxoLabel: label); } - - @override - Future updateUTXOs() async { - final newUtxosAdded = await super.updateUTXOs(); - if (newUtxosAdded) { - try { - await refreshInscriptions(); - } catch (_) { - // do nothing but do not block/fail this updateUTXOs call based on litescribe call failures - } - } - - return newUtxosAdded; - } - - // ===================== Private ============================================= - Future> _getInscriptionDataFromAddresses( - List addresses, - ) async { - final List allInscriptions = []; - for (final String address in addresses) { - try { - final inscriptions = await _litescribeAPI.getInscriptionsByAddress( - address, - ); - allInscriptions.addAll(inscriptions); - } catch (e) { - throw Exception("Error fetching inscriptions for address $address: $e"); - } - } - return allInscriptions; - } } From dceae420ca30a360160875902e232a8b9a7d5956 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 14:22:27 -0600 Subject: [PATCH 2/8] fix: use tor for ordinal image preview --- lib/pages/ordinals/ordinal_details_view.dart | 8 +- lib/pages/ordinals/widgets/ordinal_card.dart | 14 +--- .../desktop_ordinal_details_view.dart | 10 +-- lib/widgets/ordinal_image.dart | 81 +++++++++++++++++++ 4 files changed, 88 insertions(+), 25 deletions(-) create mode 100644 lib/widgets/ordinal_image.dart diff --git a/lib/pages/ordinals/ordinal_details_view.dart b/lib/pages/ordinals/ordinal_details_view.dart index 7ea7c2d342..04ad0bbf55 100644 --- a/lib/pages/ordinals/ordinal_details_view.dart +++ b/lib/pages/ordinals/ordinal_details_view.dart @@ -30,6 +30,7 @@ import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/ordinal_image.dart'; import '../../widgets/rounded_white_container.dart'; class OrdinalDetailsView extends ConsumerStatefulWidget { @@ -298,12 +299,7 @@ class _OrdinalImageGroup extends ConsumerWidget { aspectRatio: 1, child: Container( color: Colors.transparent, - child: Image.network( - ordinal.content, // Use the preview URL as the image source - fit: BoxFit.cover, - filterQuality: - FilterQuality.none, // Set the filter mode to nearest - ), + child: OrdinalImage(url: ordinal.content), ), ), ), diff --git a/lib/pages/ordinals/widgets/ordinal_card.dart b/lib/pages/ordinals/widgets/ordinal_card.dart index 31eeb57337..8662e7dddf 100644 --- a/lib/pages/ordinals/widgets/ordinal_card.dart +++ b/lib/pages/ordinals/widgets/ordinal_card.dart @@ -5,14 +5,11 @@ import '../../../pages_desktop_specific/ordinals/desktop_ordinal_details_view.da import '../../../utilities/constants.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../widgets/ordinal_image.dart'; import '../../../widgets/rounded_white_container.dart'; class OrdinalCard extends StatelessWidget { - const OrdinalCard({ - super.key, - required this.walletId, - required this.ordinal, - }); + const OrdinalCard({super.key, required this.walletId, required this.ordinal}); final String walletId; final Ordinal ordinal; @@ -38,12 +35,7 @@ class OrdinalCard extends StatelessWidget { borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), - child: Image.network( - ordinal.content, // Use the preview URL as the image source - fit: BoxFit.cover, - filterQuality: - FilterQuality.none, // Set the filter mode to nearest - ), + child: OrdinalImage(url: ordinal.content), ), ), const Spacer(), diff --git a/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart b/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart index f503d0bee3..e971716829 100644 --- a/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart +++ b/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart @@ -27,6 +27,7 @@ import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_app_bar.dart'; import '../../widgets/desktop/desktop_scaffold.dart'; import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/ordinal_image.dart'; import '../../widgets/rounded_white_container.dart'; class DesktopOrdinalDetailsView extends ConsumerStatefulWidget { @@ -141,14 +142,7 @@ class _DesktopOrdinalDetailsViewState borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), - child: Image.network( - widget - .ordinal - .content, // Use the preview URL as the image source - fit: BoxFit.cover, - filterQuality: - FilterQuality.none, // Set the filter mode to nearest - ), + child: OrdinalImage(url: widget.ordinal.content), ), ), const SizedBox(width: 16), diff --git a/lib/widgets/ordinal_image.dart b/lib/widgets/ordinal_image.dart new file mode 100644 index 0000000000..abad5bd0c1 --- /dev/null +++ b/lib/widgets/ordinal_image.dart @@ -0,0 +1,81 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../app_config.dart'; +import '../networking/http.dart'; +import '../utilities/prefs.dart'; +import '../services/tor_service.dart'; + +/// Fetches and displays an image through the app's HTTP client, +/// respecting Tor proxy settings. Use this instead of [Image.network] +/// when the request must route through Tor. +class OrdinalImage extends StatefulWidget { + const OrdinalImage({ + super.key, + required this.url, + this.fit = BoxFit.cover, + this.filterQuality = FilterQuality.none, + }); + + final String url; + final BoxFit fit; + final FilterQuality filterQuality; + + @override + State createState() => _OrdinalImageState(); +} + +class _OrdinalImageState extends State { + late Future _future; + + @override + void initState() { + super.initState(); + _future = _fetchImage(); + } + + @override + void didUpdateWidget(OrdinalImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.url != widget.url) { + _future = _fetchImage(); + } + } + + Future _fetchImage() async { + final response = await const HTTP().get( + url: Uri.parse(widget.url), + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + + if (response.code != 200) { + throw Exception('Failed to load image: status=${response.code}'); + } + + return Uint8List.fromList(response.bodyBytes); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Image.memory( + snapshot.data!, + fit: widget.fit, + filterQuality: widget.filterQuality, + ); + } else if (snapshot.hasError) { + return const Center(child: Icon(Icons.broken_image)); + } + return const Center(child: CircularProgressIndicator()); + }, + ); + } +} From 63aa9ef2cf354c1a4c78cede1c2b462b6c7f3cb0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 09:22:48 -0600 Subject: [PATCH 3/8] feat: add prepareOrdinalSend to ordinals interface w a single input send-all --- .../ordinals_interface.dart | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart index 2ee6f34ad6..f9165fb697 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 @@ -86,6 +92,69 @@ mixin OrdinalsInterface } } + /// 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 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 From c18ff61c7b30ba55ddaf81e0b6d3e0f692e2c10f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 09:23:18 -0600 Subject: [PATCH 4/8] feat: add ordinal send dialogs with desktop variants --- lib/pages/ordinals/widgets/dialogs.dart | 265 ++++++++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/lib/pages/ordinals/widgets/dialogs.dart b/lib/pages/ordinals/widgets/dialogs.dart index fca607961d..cb51fca1a8 100644 --- a/lib/pages/ordinals/widgets/dialogs.dart +++ b/lib/pages/ordinals/widgets/dialogs.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; + import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../widgets/desktop/desktop_dialog.dart'; import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/desktop/secondary_button.dart'; import '../../../widgets/stack_dialog.dart'; @@ -11,6 +16,61 @@ class SendOrdinalUnfreezeDialog extends StatelessWidget { @override Widget build(BuildContext context) { + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 450, + maxHeight: 220, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "This ordinal is frozen", + style: STextStyles.desktopH3(context), + ), + SvgPicture.asset( + Assets.svg.coinControl.blocked, + width: 24, + height: 24, + color: Theme.of(context).extension()!.textDark, + ), + ], + ), + const SizedBox(height: 12), + Text( + "To send this ordinal, you must unfreeze it first.", + style: STextStyles.desktopTextMedium(context), + ), + const Spacer(), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Unfreeze", + onPressed: () { + Navigator.of(context).pop("unfreeze"); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + return StackDialog( title: "This ordinal is frozen", icon: SvgPicture.asset( @@ -39,6 +99,56 @@ class UnfreezeOrdinalDialog extends StatelessWidget { @override Widget build(BuildContext context) { + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 450, + maxHeight: 200, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Unfreeze ordinal?", + style: STextStyles.desktopH3(context), + ), + SvgPicture.asset( + Assets.svg.coinControl.blocked, + width: 24, + height: 24, + color: Theme.of(context).extension()!.textDark, + ), + ], + ), + const Spacer(), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Unfreeze", + onPressed: () { + Navigator.of(context).pop("unfreeze"); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + return StackDialog( title: "Are you sure you want to unfreeze this ordinal?", icon: SvgPicture.asset( @@ -60,3 +170,158 @@ class UnfreezeOrdinalDialog extends StatelessWidget { ); } } + +class OrdinalRecipientAddressDialog extends StatefulWidget { + const OrdinalRecipientAddressDialog({ + super.key, + required this.inscriptionNumber, + }); + + final int inscriptionNumber; + + @override + State createState() => + _OrdinalRecipientAddressDialogState(); +} + +class _OrdinalRecipientAddressDialogState + extends State { + late final TextEditingController _controller; + + @override + void initState() { + _controller = TextEditingController(); + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _buildTextField(BuildContext context) { + return TextField( + controller: _controller, + decoration: InputDecoration( + hintText: "Paste address", + hintStyle: STextStyles.fieldLabel(context), + suffixIcon: IconButton( + icon: SvgPicture.asset( + Assets.svg.clipboard, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.textFieldDefaultSearchIconLeft, + ), + onPressed: () async { + final data = await Clipboard.getData("text/plain"); + if (data?.text != null) { + _controller.text = data!.text!; + setState(() {}); + } + }, + ), + ), + style: STextStyles.field(context), + autofocus: true, + ); + } + + @override + Widget build(BuildContext context) { + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 500, + maxHeight: 300, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Send ordinal #${widget.inscriptionNumber}", + style: STextStyles.desktopH3(context), + ), + const SizedBox(height: 12), + Text( + "Enter the recipient address", + style: STextStyles.desktopTextMedium(context), + ), + const SizedBox(height: 8), + _buildTextField(context), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Continue", + onPressed: () { + final address = _controller.text.trim(); + if (address.isNotEmpty) { + Navigator.of(context).pop(address); + } + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + return StackDialogBase( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Send ordinal #${widget.inscriptionNumber}", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 12), + Text( + "Enter the recipient address", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 8), + _buildTextField(context), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Continue", + onPressed: () { + final address = _controller.text.trim(); + if (address.isNotEmpty) { + Navigator.of(context).pop(address); + } + }, + ), + ), + ], + ), + ], + ), + ); + } +} From 5a9ebe2aadd516fc5a542931fb7d76640f742610 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 09:23:30 -0600 Subject: [PATCH 5/8] feat: wire up send button in mobile ordinal details --- lib/pages/ordinals/ordinal_details_view.dart | 156 +++++++++++++++---- 1 file changed, 129 insertions(+), 27 deletions(-) diff --git a/lib/pages/ordinals/ordinal_details_view.dart b/lib/pages/ordinals/ordinal_details_view.dart index 04ad0bbf55..958aa8f37d 100644 --- a/lib/pages/ordinals/ordinal_details_view.dart +++ b/lib/pages/ordinals/ordinal_details_view.dart @@ -15,8 +15,11 @@ import '../../models/isar/models/blockchain_data/utxo.dart'; import '../../models/isar/ordinal.dart'; import '../../networking/http.dart'; import '../../notifications/show_flush_bar.dart'; +import '../../pages/send_view/confirm_transaction_view.dart'; import '../../providers/db/main_db_provider.dart'; import '../../providers/global/prefs_provider.dart'; +import '../../providers/global/wallets_provider.dart'; +import '../../route_generator.dart'; import '../../services/tor_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; @@ -27,11 +30,14 @@ import '../../utilities/fs.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/ordinal_image.dart'; import '../../widgets/rounded_white_container.dart'; +import 'widgets/dialogs.dart'; class OrdinalDetailsView extends ConsumerStatefulWidget { const OrdinalDetailsView({ @@ -350,33 +356,129 @@ class _OrdinalImageGroup extends ConsumerWidget { }, ), ), - // const SizedBox( - // width: _spacing, - // ), - // Expanded( - // child: PrimaryButton( - // label: "Send", - // icon: SvgPicture.asset( - // Assets.svg.send, - // width: 10, - // height: 10, - // color: Theme.of(context) - // .extension()! - // .buttonTextPrimary, - // ), - // buttonHeight: ButtonHeight.l, - // iconSpacing: 4, - // onPressed: () async { - // final response = await showDialog( - // context: context, - // builder: (_) => const SendOrdinalUnfreezeDialog(), - // ); - // if (response == "unfreeze") { - // // TODO: unfreeze and go to send ord screen - // } - // }, - // ), - // ), + const SizedBox(width: _spacing), + Expanded( + child: PrimaryButton( + label: "Send", + icon: SvgPicture.asset( + Assets.svg.send, + width: 10, + height: 10, + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, + ), + buttonHeight: ButtonHeight.l, + iconSpacing: 4, + onPressed: () async { + final utxo = ordinal.getUTXO(ref.read(mainDBProvider)); + if (utxo == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not find ordinal UTXO", + context: context, + ), + ); + return; + } + + // Step 1: Confirm unfreeze + if (utxo.isBlocked) { + final unfreezeResponse = await showDialog( + context: context, + builder: (_) => const SendOrdinalUnfreezeDialog(), + ); + if (unfreezeResponse != "unfreeze") return; + } + + if (!context.mounted) return; + + // Step 2: Get recipient address + final address = await showDialog( + context: context, + builder: (_) => OrdinalRecipientAddressDialog( + inscriptionNumber: ordinal.inscriptionNumber, + ), + ); + if (address == null || address.isEmpty) return; + + // Validate address + final wallet = ref.read(pWallets).getWallet(walletId); + if (!wallet.cryptoCurrency.validateAddress(address)) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid address", + context: context, + ), + ); + } + return; + } + + if (!context.mounted) return; + + // Step 3: Prepare the transaction + final OrdinalsInterface? ordinalsWallet = + wallet is OrdinalsInterface ? wallet : null; + if (ordinalsWallet == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Wallet does not support ordinals", + context: context, + ), + ); + return; + } + + bool didError = false; + final txData = await showLoading( + whileFuture: ordinalsWallet.prepareOrdinalSend( + ordinalUtxo: utxo, + recipientAddress: address, + ), + context: context, + rootNavigator: true, + message: "Preparing transaction...", + onException: (e) { + didError = true; + String msg = e.toString(); + while (msg.isNotEmpty && msg.startsWith("Exception:")) { + msg = msg.substring(10).trim(); + } + if (context.mounted) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: msg, + context: context, + ); + } + }, + ); + + if (didError || txData == null || !context.mounted) return; + + // Step 4: Navigate to confirm transaction view + await Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator.useMaterialPageRoute, + builder: (_) => ConfirmTransactionView( + walletId: walletId, + txData: txData, + onSuccess: () {}, + ), + settings: const RouteSettings( + name: ConfirmTransactionView.routeName, + ), + ), + ); + }, + ), + ), ], ), ], From 54d21e9ae79d22e891707b2c5db674430ef55a20 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 09:24:09 -0600 Subject: [PATCH 6/8] feat: wire up send button in desktop ordinal details --- .../desktop_ordinal_details_view.dart | 168 +++++++++++++++--- 1 file changed, 141 insertions(+), 27 deletions(-) diff --git a/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart b/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart index e971716829..600ebc9c52 100644 --- a/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart +++ b/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -11,10 +12,13 @@ import '../../models/isar/models/blockchain_data/utxo.dart'; import '../../models/isar/ordinal.dart'; import '../../networking/http.dart'; import '../../notifications/show_flush_bar.dart'; +import '../../pages/ordinals/widgets/dialogs.dart'; +import '../../pages/send_view/confirm_transaction_view.dart'; import '../../pages/wallet_view/transaction_views/transaction_details_view.dart'; import '../../providers/db/main_db_provider.dart'; import '../../providers/global/wallets_provider.dart'; import '../../services/tor_service.dart'; +import '../desktop_home_view.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; @@ -23,9 +27,12 @@ import '../../utilities/constants.dart'; import '../../utilities/prefs.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_scaffold.dart'; +import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/ordinal_image.dart'; import '../../widgets/rounded_white_container.dart'; @@ -169,33 +176,140 @@ class _DesktopOrdinalDetailsViewState ), ), const SizedBox(width: 16), - // PrimaryButton( - // width: 150, - // label: "Send", - // icon: SvgPicture.asset( - // Assets.svg.send, - // width: 18, - // height: 18, - // color: Theme.of(context) - // .extension()! - // .buttonTextPrimary, - // ), - // buttonHeight: ButtonHeight.l, - // iconSpacing: 8, - // onPressed: () async { - // final response = await showDialog( - // context: context, - // builder: (_) => - // const SendOrdinalUnfreezeDialog(), - // ); - // if (response == "unfreeze") { - // // TODO: unfreeze and go to send ord screen - // } - // }, - // ), - // const SizedBox( - // width: 16, - // ), + PrimaryButton( + width: 150, + label: "Send", + icon: SvgPicture.asset( + Assets.svg.send, + width: 18, + height: 18, + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, + ), + buttonHeight: ButtonHeight.l, + iconSpacing: 8, + onPressed: () async { + final utxo = widget.ordinal.getUTXO( + ref.read(mainDBProvider), + ); + if (utxo == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not find ordinal UTXO", + context: context, + ), + ); + return; + } + + if (utxo.isBlocked) { + final unfreezeResponse = + await showDialog( + context: context, + builder: (_) => + const SendOrdinalUnfreezeDialog(), + ); + if (unfreezeResponse != "unfreeze") return; + } + + if (!context.mounted) return; + + final address = await showDialog( + context: context, + builder: (_) => OrdinalRecipientAddressDialog( + inscriptionNumber: + widget.ordinal.inscriptionNumber, + ), + ); + if (address == null || address.isEmpty) return; + + final wallet = ref + .read(pWallets) + .getWallet(widget.walletId); + if (!wallet.cryptoCurrency.validateAddress( + address, + )) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid address", + context: context, + ), + ); + } + return; + } + + if (!context.mounted) return; + + final OrdinalsInterface? ordinalsWallet = + wallet is OrdinalsInterface ? wallet : null; + if (ordinalsWallet == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Wallet does not support ordinals", + context: context, + ), + ); + return; + } + + bool didError = false; + final txData = await showLoading( + whileFuture: ordinalsWallet + .prepareOrdinalSend( + ordinalUtxo: utxo, + recipientAddress: address, + ), + context: context, + rootNavigator: true, + message: "Preparing transaction...", + onException: (e) { + didError = true; + String msg = e.toString(); + while (msg.isNotEmpty && + msg.startsWith("Exception:")) { + msg = msg.substring(10).trim(); + } + if (context.mounted) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: msg, + context: context, + ); + } + }, + ); + + if (didError || + txData == null || + !context.mounted) { + return; + } + + await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxHeight: + MediaQuery.of(context).size.height - 64, + maxWidth: 580, + child: ConfirmTransactionView( + walletId: widget.walletId, + txData: txData, + routeOnSuccessName: + DesktopHomeView.routeName, + onSuccess: () {}, + ), + ), + ); + }, + ), + const SizedBox(width: 16), SecondaryButton( width: 150, label: "Download", From e673f614d2d15bbfe0cf07308bdebe68d96d5e3b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 09:24:22 -0600 Subject: [PATCH 7/8] feat: add ordinal spend warning to confirm transaction view --- .../send_view/confirm_transaction_view.dart | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index dfd6c98bd0..bdce989b5b 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -14,10 +14,13 @@ import 'dart:io'; import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; +import 'package:isar_community/isar.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../models/input.dart'; import '../../models/isar/models/transaction_note.dart'; +import '../../models/isar/ordinal.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; @@ -1418,6 +1421,71 @@ class _ConfirmTransactionViewState ), ), ), + // Ordinal UTXO spend warning + Builder( + builder: (context) { + final usedUtxos = widget.txData.usedUTXOs; + if (usedUtxos == null || usedUtxos.isEmpty) { + return const SizedBox.shrink(); + } + + final db = ref.read(mainDBProvider); + bool hasOrdinal = false; + for (final input in usedUtxos) { + if (input is StandardInput) { + final ordinal = db.isar.ordinals + .where() + .filter() + .walletIdEqualTo(walletId) + .and() + .utxoTXIDEqualTo(input.utxo.txid) + .and() + .utxoVOUTEqualTo(input.utxo.vout) + .findFirstSync(); + if (ordinal != null) { + hasOrdinal = true; + break; + } + } + } + + if (!hasOrdinal) return const SizedBox.shrink(); + + return Padding( + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32, vertical: 8) + : const EdgeInsets.symmetric(vertical: 8), + child: RoundedContainer( + color: Theme.of( + context, + ).extension()!.warningBackground, + child: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Theme.of( + context, + ).extension()!.warningForeground, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "This transaction spends a UTXO containing " + "an ordinal inscription.", + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of( + context, + ).extension()!.warningForeground, + ), + ), + ), + ], + ), + ), + ); + }, + ), SizedBox(height: isDesktop ? 28 : 16), Padding( padding: isDesktop From 1e5cf6e201771e294d1ed179f0e60733f06383fa Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 09:38:31 -0600 Subject: [PATCH 8/8] feat: never peg ordinals into MWEB it destroys them --- .../wallet_mixin_interfaces/mweb_interface.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart index e183be63b6..bfeb24e72f 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart @@ -14,6 +14,7 @@ import '../../../models/input.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; +import '../../../models/isar/ordinal.dart'; import '../../../services/event_bus/events/global/blocks_remaining_event.dart'; import '../../../services/event_bus/events/global/refresh_percent_changed_event.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; @@ -649,6 +650,20 @@ mixin MwebInterface ), ); + // Never peg ordinal UTXOs into MWEB. + spendableUtxos.removeWhere((e) { + final ord = mainDB.isar.ordinals + .where() + .filter() + .walletIdEqualTo(walletId) + .and() + .utxoTXIDEqualTo(e.txid) + .and() + .utxoVOUTEqualTo(e.vout) + .findFirstSync(); + return ord != null; + }); + if (spendableUtxos.isEmpty) { throw Exception("No available UTXOs found to anonymize"); }