fix: ordinals by looking up tx info directly
What changed, and why it matters
This commit changes how the Stack Wallet app detects Litecoin Ordinals (a type of digital collectible). Instead of asking a third-party service (litescribe.io) which inscriptions belong to a wallet address, the app now asks its own ord-litecoin.stackwallet.com server directly about each transaction output. The change removes a heuristic that blocked small outputs as 'possible ordinals' only when an address-level check passed, and instead checks each output directly. There is no clear security bug being fixed, but the commit does switch data sources and removes some protective logic around small-value outputs.
Treat this as a routine refactor with supply-chain/trust implications. Review whether the new ord-litecoin.stackwallet.com endpoint is authenticated or pinned, validate that server-returned inscription IDs and content URLs cannot inject malicious data into the wallet's UTXO blocking or UI layers, and complete the TODO to remove or clearly deprecate the orphaned litescribe files. No immediate security patch appears required from the diff alone.
Security signals we found
Removed third-party API dependency (litescribe.io) for inscription data
Added direct queries to vendor-controlled ord-litecoin.stackwallet.com server
UTXO blocking now uses per-output inscription lookup instead of address-level lookup
Small-value UTXO heuristic (<=10000 sats) retained as fallback 'possible ordinal' block
No visible certificate pinning or response authentication in new HTTP client usage
JSON fields cast with as String? / as int? with default fallbacks; missing validation of server-provided txid/address/content
Dead-code TODO left in commit message for litescribe-related files
Evidence from the diff
The patch replaces LitescribeAPI with a new OrdAPI client that queries an in-house ord server (https://ord-litecoin.stackwallet.com). It adds InscriptionData.fromOrdJson to parse /inscription/{id} responses, and refactors refreshInscriptions() to iterate over local UTXOs and call /output/{txid}:{vout} then /inscription/{id}. The UTXO-blocking logic now checks the specific output for inscriptions rather than checking whether the owning address has any inscriptions, and the small-output heuristic (‘May contain ordinal’) is preserved but no longer gated by an address-level inscription check. The litescribe_api.dart, litescribe_response.dart, and address_inscription_response.dart files are noted as orphaned/dead code. No TLS pinning, response signature verification, or input sanitization beyond JSON casting is visible in the diff.
Changed components
lib/services/ord_api.dart (new)lib/dto/ordinals/inscription_data.dartlib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dartlib/wallets/wallet/impl/litecoin_wallet.dartInspect captured patch +172 / −80
diff --git a/lib/dto/ordinals/inscription_data.dart b/lib/dto/ordinals/inscription_data.dart
index 2f12bd6..19d6ae9 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<String, dynamic> 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 0000000..7980086
--- /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<List<String>> 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<String, dynamic>;
+ final inscriptions = json['inscriptions'] as List<dynamic>?;
+
+ if (inscriptions == null || inscriptions.isEmpty) {
+ return [];
+ }
+
+ return inscriptions.cast<String>();
+ }
+
+ /// Fetch full inscription metadata by ID.
+ Future<Map<String, dynamic>> 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<String, dynamic>;
+ }
+
+ /// 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 c9fa52a..32cfe8f 100644
--- a/lib/wallets/wallet/impl/litecoin_wallet.dart
+++ b/lib/wallets/wallet/impl/litecoin_wallet.dart
@@ -35,6 +35,9 @@ class LitecoinWallet<T extends ElectrumXCurrencyInterface>
@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<T extends ElectrumXCurrencyInterface>
// Remove duplicates.
final allAddressesSet = {...receivingAddresses, ...changeAddresses};
- final updateInscriptionsFuture = refreshInscriptions(
- overrideAddressesToCheck: allAddressesSet.toList(),
- );
+ final updateInscriptionsFuture = refreshInscriptions();
// Fetch history from ElectrumX.
final List<Map<String, dynamic>> 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 686d1f9..2ee6f34 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<T extends ElectrumXCurrencyInterface>
on ElectrumXInterface<T> {
- 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 <UTXO> output
- Future<bool> _inscriptionInAddress(String address) async {
+ late final OrdAPI _ordAPI = OrdAPI(baseUrl: ordServerBaseUrl);
+
+ /// Check whether a specific output contains inscriptions.
+ Future<bool> _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<void> refreshInscriptions({
- List<String>? overrideAddressesToCheck,
- }) async {
+ Future<void> refreshInscriptions() async {
try {
- final uniqueAddresses =
- overrideAddressesToCheck ??
- await mainDB
- .getUTXOs(walletId)
- .filter()
- .addressIsNotNull()
- .distinctByAddress()
- .addressProperty()
- .findAll();
- final inscriptions = await _getInscriptionDataFromAddresses(
- uniqueAddresses.cast<String>(),
- );
+ final utxos = await mainDB.getUTXOs(walletId).findAll();
+
+ final List<InscriptionData> 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<T extends ElectrumXCurrencyInterface>
);
}
}
+
// =================== Overrides =============================================
@override
@@ -79,58 +100,20 @@ mixin OrdinalsInterface<T extends ElectrumXCurrencyInterface>
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<bool> 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<List<InscriptionData>> _getInscriptionDataFromAddresses(
- List<String> addresses,
- ) async {
- final List<InscriptionData> 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;
- }
}
Why this scored 35/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.