Skip to content

Commit 3e48eea

Browse files
wired-pastequeethicnology
authored andcommitted
fix(transactions): keep transfer details accurate after sync
1 parent edd6635 commit 3e48eea

7 files changed

Lines changed: 366 additions & 45 deletions

File tree

lib/features/swap/presentation/transfer_bloc.dart

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1530,12 +1530,12 @@ class TransferBloc extends Bloc<TransferEvent, TransferState>
15301530
signedPsbt,
15311531
isPsbt: true,
15321532
);
1533-
if (state.fromWallet != null) {
1534-
unawaited(_syncWalletAfterBroadcast(state.fromWallet!.id));
1535-
}
1536-
if (state.toWallet != null) {
1537-
unawaited(_syncWalletAfterBroadcast(state.toWallet!.id));
1538-
}
1533+
unawaited(
1534+
_syncWalletsAfterBroadcast([
1535+
if (state.fromWallet != null) state.fromWallet!.id,
1536+
if (state.toWallet != null) state.toWallet!.id,
1537+
]),
1538+
);
15391539
} else {
15401540
return;
15411541
}
@@ -1555,12 +1555,14 @@ class TransferBloc extends Bloc<TransferEvent, TransferState>
15551555
Future<void> _syncWalletAfterBroadcast(String walletId) async {
15561556
try {
15571557
await _getWalletUsecase.execute(walletId, sync: true);
1558-
} catch (error, stackTrace) {
1559-
log.warning(
1560-
'Failed to sync wallet after transfer broadcast',
1561-
error: error,
1562-
trace: stackTrace,
1563-
);
1558+
} catch (_) {
1559+
log.warning('Failed to sync wallet after transfer broadcast');
1560+
}
1561+
}
1562+
1563+
Future<void> _syncWalletsAfterBroadcast(List<String> walletIds) async {
1564+
for (final walletId in walletIds) {
1565+
await _syncWalletAfterBroadcast(walletId);
15641566
}
15651567
}
15661568

lib/features/transactions/domain/entities/transaction.dart

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,8 @@ sealed class Transaction with _$Transaction {
179179
swap?.type == SwapType.liquidToBitcoin ||
180180
(orderSwap?.inNetwork == OrderSwapNetwork.liquid &&
181181
orderSwap?.outNetwork == OrderSwapNetwork.bitcoin);
182-
183182
// Internal swaps are outgoing from one wallet and incoming to the other.
184-
bool isIncomingWallet(String? walletId) {
183+
bool isReceivingWallet(String? walletId, {bool isCounterpart = false}) {
185184
final orderSwap = this.orderSwap;
186185
if (walletId != null && orderSwap != null) {
187186
return orderSwap.destinationWalletId == walletId &&
@@ -191,7 +190,7 @@ sealed class Transaction with _$Transaction {
191190
if (walletId != null && swap is ChainSwap) {
192191
return swap.receiveWalletId == walletId && swap.sendWalletId != walletId;
193192
}
194-
return isIncoming;
193+
return isCounterpart ? isOutgoing : isIncoming;
195194
}
196195

197196
DateTime? get timestamp =>

lib/features/transactions/presentation/blocs/transaction_details/transaction_details_cubit.dart

Lines changed: 103 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ class TransactionDetailsCubit extends Cubit<TransactionDetailsState> {
7676
StreamSubscription? _payjoinTxSubscription;
7777
StreamSubscription? _payjoinOriginalTxSubscription;
7878
StreamSubscription? _orderSwapSubscription;
79+
String? _watchedOrderSwapTransactionId;
80+
WalletTransaction? _pendingOrderSwapWalletTransaction;
81+
int _orderSwapTransactionWatchGeneration = 0;
7982

8083
// The payjoin id _payjoinSubscription is currently listening to on the
8184
// by-wallet-tx path, so reloads triggered by its own events don't
@@ -149,25 +152,7 @@ class TransactionDetailsCubit extends Cubit<TransactionDetailsState> {
149152
.execute(localId)
150153
.listen(
151154
(orderSwap) {
152-
if (isClosed) return;
153-
final transaction = state.transaction;
154-
if (transaction?.orderSwap?.localId != orderSwap.localId) return;
155-
final walletTransaction = transaction?.walletTransaction;
156-
final canonicalTransactionChanged =
157-
walletTransaction != null &&
158-
walletTransaction.txId !=
159-
orderSwap.canonicalWalletTransactionId;
160-
emit(
161-
state.copyWith(
162-
transaction: transaction!.copyWith(
163-
walletTransaction: canonicalTransactionChanged
164-
? null
165-
: walletTransaction,
166-
orderSwap: orderSwap,
167-
),
168-
swapCounterpartTxId: orderSwap.counterpartTransactionId,
169-
),
170-
);
155+
unawaited(_handleOrderSwapUpdate(orderSwap));
171156
},
172157
onError: (Object error) {
173158
if (isClosed) return;
@@ -176,10 +161,88 @@ class TransactionDetailsCubit extends Cubit<TransactionDetailsState> {
176161
);
177162
}
178163

164+
Future<void> _handleOrderSwapUpdate(OrderSwapRecord orderSwap) async {
165+
if (isClosed) return;
166+
final transaction = state.transaction;
167+
if (transaction?.orderSwap?.localId != orderSwap.localId) {
168+
await _loadDetailsByOrderSwapLocalId(orderSwap.localId);
169+
await _watchOrderSwapWalletTransaction(state.transaction?.orderSwap);
170+
return;
171+
}
172+
final walletTransaction = transaction?.walletTransaction;
173+
final canonicalTransactionChanged =
174+
walletTransaction != null &&
175+
walletTransaction.txId != orderSwap.canonicalWalletTransactionId;
176+
emit(
177+
state.copyWith(
178+
transaction: transaction!.copyWith(
179+
walletTransaction: canonicalTransactionChanged
180+
? null
181+
: walletTransaction,
182+
orderSwap: orderSwap,
183+
),
184+
swapCounterpartTxId: orderSwap.counterpartTransactionId,
185+
),
186+
);
187+
await _watchOrderSwapWalletTransaction(orderSwap);
188+
}
189+
190+
Future<void> _watchOrderSwapWalletTransaction(
191+
OrderSwapRecord? orderSwap,
192+
) async {
193+
final transactionId = orderSwap?.canonicalWalletTransactionId;
194+
final walletId = orderSwap?.canonicalWalletId;
195+
if (transactionId == null || walletId == null) return;
196+
if (_watchedOrderSwapTransactionId == transactionId) return;
197+
final generation = ++_orderSwapTransactionWatchGeneration;
198+
_pendingOrderSwapWalletTransaction = null;
199+
await _walletTransactionSubscription?.cancel();
200+
if (isClosed || generation != _orderSwapTransactionWatchGeneration) return;
201+
try {
202+
_walletTransactionSubscription = _watchWalletTransactionByTxIdUsecase
203+
.execute(txId: transactionId, walletId: walletId)
204+
.listen(
205+
(walletTransaction) {
206+
if (isClosed) return;
207+
final latestOrderSwap = state.transaction?.orderSwap;
208+
if (latestOrderSwap == null) {
209+
_pendingOrderSwapWalletTransaction = walletTransaction;
210+
return;
211+
}
212+
if (latestOrderSwap.canonicalWalletTransactionId !=
213+
transactionId) {
214+
return;
215+
}
216+
emit(
217+
state.copyWith(
218+
transaction: state.transaction?.copyWith(
219+
walletTransaction: walletTransaction,
220+
),
221+
),
222+
);
223+
},
224+
onError: (_) {
225+
if (_watchedOrderSwapTransactionId == transactionId) {
226+
_watchedOrderSwapTransactionId = null;
227+
}
228+
log.warning('Order swap wallet transaction watcher failed');
229+
},
230+
);
231+
_watchedOrderSwapTransactionId = transactionId;
232+
} catch (_) {
233+
_watchedOrderSwapTransactionId = null;
234+
log.warning('Order swap wallet transaction watcher failed');
235+
}
236+
}
237+
179238
Future<void> _loadDetailsByOrderSwapLocalId(String localId) async {
180239
try {
181240
final orderSwap = await _getTransactionOrderSwapUsecase.execute(localId);
241+
await _watchOrderSwapWalletTransaction(orderSwap);
182242
await _loadOrderSwapDetails(orderSwap);
243+
} on ParallelWaitError catch (error) {
244+
if (isClosed) return;
245+
emit(state.copyWith(err: _firstParallelError(error)));
183246
} on TransactionNotFoundError catch (error) {
184247
if (isClosed) return;
185248
emit(state.copyWith(notFoundError: error));
@@ -216,10 +279,22 @@ class TransactionDetailsCubit extends Cubit<TransactionDetailsState> {
216279
walletId: walletId,
217280
),
218281
).wait;
219-
final walletTransaction = switch (walletTransactionResult) {
282+
final loadedWalletTransaction = switch (walletTransactionResult) {
220283
Ok(:final value) => value,
221-
Err() => null,
284+
Err() => () {
285+
log.warning('Order swap wallet transaction lookup failed');
286+
return null;
287+
}(),
222288
};
289+
final pendingWalletTransaction = _pendingOrderSwapWalletTransaction;
290+
_pendingOrderSwapWalletTransaction = null;
291+
final matchingPendingWalletTransaction =
292+
pendingWalletTransaction?.txId == transactionId &&
293+
pendingWalletTransaction?.walletId == walletId
294+
? pendingWalletTransaction
295+
: null;
296+
final walletTransaction =
297+
matchingPendingWalletTransaction ?? loadedWalletTransaction;
223298
if (isClosed) return;
224299
emit(
225300
state.copyWith(
@@ -230,10 +305,17 @@ class TransactionDetailsCubit extends Cubit<TransactionDetailsState> {
230305
wallet: wallet,
231306
counterpartWallet: counterpartWallet,
232307
swapCounterpartTxId: orderSwap.counterpartTransactionId,
308+
err: null,
309+
notFoundError: null,
233310
),
234311
);
235312
}
236313

314+
Object _firstParallelError(ParallelWaitError error) {
315+
final errors = error.errors as (AsyncError?, AsyncError?, AsyncError?);
316+
return (errors.$1 ?? errors.$2 ?? errors.$3)?.error ?? error;
317+
}
318+
237319
Future<void> _loadDetailsByWalletTxId(
238320
String txId, {
239321
required String walletId,

lib/features/transactions/ui/widgets/transaction_details_table.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,19 @@ class TransactionDetailsTable extends StatelessWidget {
118118
),
119119
if (walletLabel.isNotEmpty)
120120
DetailsTableItem(
121-
label: transaction?.isIncomingWallet(wallet?.id) == true
121+
label: transaction?.isReceivingWallet(wallet?.id) == true
122122
? context.loc.transactionDetailLabelToWallet
123123
: context.loc.transactionDetailLabelFromWallet,
124124
displayValue: walletLabel,
125125
),
126126
if (counterpartWalletLabel.isNotEmpty && !recovered)
127127
DetailsTableItem(
128-
label: transaction?.isIncomingWallet(counterpartWallet?.id) == true
128+
label:
129+
transaction?.isReceivingWallet(
130+
counterpartWallet?.id,
131+
isCounterpart: true,
132+
) ==
133+
true
129134
? context.loc.transactionDetailLabelToWallet
130135
: context.loc.transactionDetailLabelFromWallet,
131136
displayValue: counterpartWalletLabel,

test/features/transactions/domain/entities/transaction_test.dart

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ void main() {
5252
),
5353
);
5454

55-
expect(transaction.isIncomingWallet('bitcoin-wallet'), isFalse);
56-
expect(transaction.isIncomingWallet('liquid-wallet'), isTrue);
55+
expect(transaction.isReceivingWallet('bitcoin-wallet'), isFalse);
56+
expect(transaction.isReceivingWallet('liquid-wallet'), isTrue);
5757
});
5858

5959
test('identifies Liquid to Bitcoin transfer wallets', () {
@@ -64,8 +64,23 @@ void main() {
6464
),
6565
);
6666

67-
expect(transaction.isIncomingWallet('liquid-wallet'), isFalse);
68-
expect(transaction.isIncomingWallet('bitcoin-wallet'), isTrue);
67+
expect(transaction.isReceivingWallet('liquid-wallet'), isFalse);
68+
expect(transaction.isReceivingWallet('bitcoin-wallet'), isTrue);
69+
});
70+
71+
test('ordinary transfer reverses direction for the counterpart', () {
72+
final outgoing = Transaction(walletTransaction: _walletTx(txId: 'txid'));
73+
final incoming = Transaction(
74+
walletTransaction: _walletTx(
75+
txId: 'incoming-txid',
76+
direction: WalletTransactionDirection.incoming,
77+
),
78+
);
79+
80+
expect(outgoing.isReceivingWallet('w1'), isFalse);
81+
expect(outgoing.isReceivingWallet('w2', isCounterpart: true), isTrue);
82+
expect(incoming.isReceivingWallet('w1'), isTrue);
83+
expect(incoming.isReceivingWallet('w2', isCounterpart: true), isFalse);
6984
});
7085
});
7186

0 commit comments

Comments
 (0)