Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ All notable changes to Bull Bitcoin Mobile will be documented in this file.

## [Unreleased]

_Nothing yet._
### Bug Fixes

- **Chain swaps can no longer be falsely marked completed by someone else's transaction**: the on-chain outspend recovery assumed the swap covenant was always the lockup transaction's first output and treated *any* spend of it as our claim. When Boltz's lockup carried its change at vout 0 (or Boltz refunded its own expired lockup), the swap was stamped `completed` with a stranger's txid, silently excluding it from the watcher forever while the user's own locked funds sat unrefunded. Recovery is now (1) a last resort — consulted only for restored swaps or when a broadcast is rejected because the lockup is already spent, (2) destination-verified — a candidate spender only settles the swap if that transaction actually exists in the receiving wallet, and (3) covenant-agnostic — every lockup output's spend is considered via the new `check_lockup_outspends` API (boltz-dart 0.5.2). A startup verification pass additionally retracts already mis-settled completions (recorded claim txid not found in the receiving wallet) so those swaps re-enter the watch set, pick up their real Boltz status, and drive the pending refund home. Diagnosed from a real user's stuck liquidToBitcoin swap whose ~0.0108 BTC refund never ran; their log census + on-chain forensics confirmed the vout-0 assumption as the root cause.

### Diagnostics

- **Swap census in exported logs**: every app start now logs one `[SwapCensus]` line enumerating all locally stored swaps (status, key index, recorded txids, completion time) plus the app version at FINE level, so a single user log export shows exactly which local state keeps a swap out of the watch set. The census runs from app startup after migrations — the previous watcher-constructor attempt raced SQLite init and silently logged nothing.

---

Expand Down
34 changes: 21 additions & 13 deletions lib/core/swaps/data/datasources/boltz_datasource.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1269,7 +1269,7 @@ class BoltzDatasource {

switch (mapping) {
case SwapStale():
log.info(
log.fine(
'[Boltz] deleting stale pending swap $swapId '
'(no funds at risk, expired upstream)',
);
Expand All @@ -1289,7 +1289,7 @@ class BoltzDatasource {

case SwapUpdated(:final swap):
await _boltzStore.store(swap);
log.info(
log.fine(
'[Boltz] swap $swapId: ${swapModel.status} -> ${swap.status} '
'(event ${boltzStatus.name})',
);
Expand Down Expand Up @@ -1599,8 +1599,12 @@ class BoltzDatasource {
}
}

/// Checks the outspend status of a swap's lockup transaction
Future<SwapTxOutspendModel> checkSwapLockupOutspend({
/// Lists the spends of the swap's lockup transaction outputs (server
/// lockup for claims, our own lockup for refunds): one entry per already
/// spent vout. No entry is proof of OUR claim/refund — the covenant can
/// sit at any vout and Boltz spends its own change/refunds through the
/// same tx — so callers must verify a spender actually paid them.
Future<List<SwapTxOutspendModel>> checkLockupOutspends({
required String swapId,
required swap_entity.SwapType swapType,
required Network network,
Expand Down Expand Up @@ -1630,7 +1634,7 @@ class BoltzDatasource {
}
: null;

final outspendStatus = await checkVout0Outspend(
final outspends = await boltz.checkLockupOutspends(
swapId: swapId,
swapType: boltzSwapType,
txKind: isClaim ? SwapTxKind.claim : SwapTxKind.refund,
Expand All @@ -1639,13 +1643,17 @@ class BoltzDatasource {
chainSwapDirection: chainSwapDirection,
);

return SwapTxOutspendModel(
txid: outspendStatus.txid,
timestamp: outspendStatus.timestamp != null
? DateTime.fromMillisecondsSinceEpoch(
outspendStatus.timestamp!.toInt() * 1000,
)
: null,
);
return [
for (final outspend in outspends)
if (outspend.spenderTxid != null)
SwapTxOutspendModel(
txid: outspend.spenderTxid,
timestamp: outspend.timestamp != null
? DateTime.fromMillisecondsSinceEpoch(
outspend.timestamp!.toInt() * 1000,
)
: null,
),
];
}
}
55 changes: 34 additions & 21 deletions lib/core/swaps/data/repository/boltz_swap_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -517,9 +517,7 @@ class BoltzSwapRepository {
// Reverse and submarine swaps consume 1 index; chain swaps consume 2 (boltz
// derives the refund key at `index` and the claim key at `index + 1`).
Future<int> _reserveSwapKeyIndex(int count) async {
final swapMasterKey = await _boltz.getSwapMasterKey(
isTestnet: _isTestnet,
);
final swapMasterKey = await _boltz.getSwapMasterKey(isTestnet: _isTestnet);
// The index counter is keyed by the swap master key's OWN fingerprint —
// NOT the default wallet's fingerprint (which keys the master key blob).
// Both are 1:1 with the seed, so they stay consistent.
Expand All @@ -541,7 +539,7 @@ class BoltzSwapRepository {
swapMasterKey.fingerprint,
current + count,
);
log.info(
log.fine(
'SWAP_KEY: reserved index $current (count=$count) '
'fp=${swapMasterKey.fingerprint}',
);
Expand All @@ -557,6 +555,8 @@ class BoltzSwapRepository {
}

Future<void> updateSwap({required Swap swap}) {
// Debug: full-model writes must be as visible as updateSwapFields ones.
log.fine('[SwapStore] ${swap.id} full-write status=${swap.status.name}');
return _boltz.storage.store(SwapModel.fromEntity(swap));
}

Expand All @@ -574,6 +574,9 @@ class BoltzSwapRepository {
int? claimFee,
int? refundFee,
DateTime? completionTime,
// Null means "keep" for every field above, so retracting a recorded
// claim tx (un-wedging a mis-settled swap) needs an explicit flag.
bool clearReceiveTxid = false,
}) async {
final swapModel = await _boltz.storage.fetch(swapId);
if (swapModel == null) {
Expand All @@ -588,7 +591,7 @@ class BoltzSwapRepository {
final updated = switch (swap) {
LnReceiveSwap() => swap.copyWith(
status: status ?? swap.status,
receiveTxid: receiveTxid ?? swap.receiveTxid,
receiveTxid: clearReceiveTxid ? null : receiveTxid ?? swap.receiveTxid,
receiveAddress: receiveAddress ?? swap.receiveAddress,
completionTime: completionTime ?? swap.completionTime,
fees: fees,
Expand All @@ -603,7 +606,7 @@ class BoltzSwapRepository {
),
ChainSwap() => swap.copyWith(
status: status ?? swap.status,
receiveTxid: receiveTxid ?? swap.receiveTxid,
receiveTxid: clearReceiveTxid ? null : receiveTxid ?? swap.receiveTxid,
refundTxid: refundTxid ?? swap.refundTxid,
receiveAddress: receiveAddress ?? swap.receiveAddress,
refundAddress: refundAddress ?? swap.refundAddress,
Expand All @@ -612,6 +615,16 @@ class BoltzSwapRepository {
),
};

// Debug: every field mutation in one greppable line — the audit trail
// for how a swap reached a state the watcher no longer acts on.
log.fine(
'[SwapStore] $swapId'
'${status != null ? ' status=${swap.status.name}->${status.name}' : ''}'
'${receiveTxid != null ? ' receiveTxid=$receiveTxid' : ''}'
'${clearReceiveTxid ? ' receiveTxid=CLEARED(was ${swap is ChainSwap ? swap.receiveTxid : swap is LnReceiveSwap ? swap.receiveTxid : null})' : ''}'
'${refundTxid != null ? ' refundTxid=$refundTxid' : ''}'
'${completionTime != null ? ' completed' : ''}',
);
await _boltz.storage.store(SwapModel.fromEntity(updated));
return updated;
}
Expand Down Expand Up @@ -761,17 +774,15 @@ class BoltzSwapRepository {
/// across BTC-LN, LBTC-LN and chain. Identification only (Phase 1); importing
/// them into local storage is handled separately.
Future<List<RestoredSwap>> restoreSwaps({required bool isTestnet}) async {
final swapMasterKey = await _boltz.getSwapMasterKey(
isTestnet: isTestnet,
);
log.info(
final swapMasterKey = await _boltz.getSwapMasterKey(isTestnet: isTestnet);
log.fine(
'SWAP_RESTORE: master key ${swapMasterKey.fingerprint} '
'(${swapMasterKey.network})',
);
final summaries = await _boltz.restoreSwapSummaries(
swapMasterKey: swapMasterKey,
);
log.info('SWAP_RESTORE: restore endpoint returned ${summaries.length}');
log.fine('SWAP_RESTORE: restore endpoint returned ${summaries.length}');
return [
for (final s in summaries)
RestoredSwap(
Expand Down Expand Up @@ -846,9 +857,7 @@ class BoltzSwapRepository {
required String btcElectrumUrl,
required String lbtcElectrumUrl,
}) async {
final swapMasterKey = await _boltz.getSwapMasterKey(
isTestnet: _isTestnet,
);
final swapMasterKey = await _boltz.getSwapMasterKey(isTestnet: _isTestnet);
final creationTime = restored.createdAt.millisecondsSinceEpoch;
// A refund-action swap with funds still locked on-chain is stored as
// refundable (not the terminal failed/expired/refunded the restore status
Expand Down Expand Up @@ -1020,7 +1029,7 @@ class BoltzSwapRepository {
await _boltz.storage.store(model);
subscribeToSwaps([id]);
await reconcileSwaps([id]);
log.info('SWAP_RESTORE: rescued $id as ${model.runtimeType}');
log.fine('SWAP_RESTORE: rescued $id as ${model.runtimeType}');
return model.toEntity();
}

Expand Down Expand Up @@ -1081,8 +1090,9 @@ class BoltzSwapRepository {
}

Future<Invoice> decodeInvoice({required String invoice}) async {
final (sats, expired, bip21, description) =
await _boltz.decodeInvoice(invoice);
final (sats, expired, bip21, description) = await _boltz.decodeInvoice(
invoice,
);
return Invoice(
sats: sats,
isExpired: expired,
Expand Down Expand Up @@ -1250,21 +1260,24 @@ class BoltzSwapRepository {
}
}

/// Checks the outspend status of a swap's lockup transaction
Future<SwapTxOutspend> checkSwapLockupOutspend({
/// Lists the spends of the swap's lockup tx outputs, one per spent vout.
/// An entry proves only that an output was spent — never that we were
/// paid; callers must verify a spender against their own wallet before
/// settling the swap on it.
Future<List<SwapTxOutspend>> checkLockupOutspends({
required String swapId,
required SwapType swapType,
required Network network,
outspend.SwapDirection? swapDirection,
bool isClaim = true,
}) async {
final model = await _boltz.checkSwapLockupOutspend(
final models = await _boltz.checkLockupOutspends(
swapId: swapId,
swapType: swapType,
network: network,
swapDirection: swapDirection,
isClaim: isClaim,
);
return model.toEntity();
return models.map((model) => model.toEntity()).toList();
}
}
15 changes: 13 additions & 2 deletions lib/core/swaps/data/services/swap_status_mapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,25 @@ class SwapStatusMapper {
if (from == to) return true;

if (from == swap_entity.SwapStatus.completed) {
if (to != swap_entity.SwapStatus.claimable) return false;
return switch (swap) {
// "Completed" without a recorded settlement tx is unproven — the row
// may be a mis-settled recovery (e.g. an outspend check that mistook
// Boltz's own spend for our claim). Such a swap may move wherever the
// server's status points: back to claimable, or — when our lockup is
// still out there unrefunded — to refundable, so the funds can come
// home instead of staying stranded behind a bogus terminal state.
final unproven = switch (swap) {
LnReceiveSwapModel(:final receiveTxid, :final wasDirectPayment) =>
receiveTxid == null && !wasDirectPayment,
ChainSwapModel(:final receiveTxid, :final refundTxid) =>
receiveTxid == null && refundTxid == null,
LnSendSwapModel() => false,
};
if (!unproven) return false;
if (to == swap_entity.SwapStatus.claimable) return true;
if (to == swap_entity.SwapStatus.refundable) {
return _sendTxid(swap) != null && _refundTxid(swap) == null;
}
return false;
}
if (from == swap_entity.SwapStatus.refunded) return false;

Expand Down
Loading