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
50 changes: 38 additions & 12 deletions lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -148,19 +148,21 @@ class SellBitboxCubit extends Cubit<SellBitboxState> {
final retryState = state;
if (retryState is! SellBitboxDepositRetry) return;

try {
emit(SellBitboxDepositing());
await _broadcastDepositAndConfirm(
emit(SellBitboxDepositing());
final txHash = retryState.broadcastTxHash;
if (txHash != null) {
// The deposit transaction is already on-chain — only the payment
// confirmation failed. Confirm only; re-broadcasting an already-sent tx
// is what caused the perpetual retry loop (issue #657 P4 BB1).
await _confirmDeposit(
retryState.signedSwapTransaction,
retryState.signedDepositTransaction,
txHash,
);
} catch (e) {
emit(
SellBitboxDepositRetry(
retryState.signedSwapTransaction,
retryState.signedDepositTransaction,
e.toString(),
),
} else {
await _broadcastDepositAndConfirm(
retryState.signedSwapTransaction,
retryState.signedDepositTransaction,
);
}
}
Expand All @@ -169,15 +171,39 @@ class SellBitboxCubit extends Cubit<SellBitboxState> {
BroadcastTransactionRequestDto signedSwap,
BroadcastTransactionRequestDto signedDeposit,
) async {
final String txHash;
try {
final txHash = await _sellService.broadcastTransaction(
txHash = await _sellService.broadcastTransaction(
_paymentInfo.id,
signedDeposit,
);
} catch (e) {
// The broadcast itself failed: the tx is not on-chain, so a retry may
// safely broadcast again (broadcastTxHash stays null).
emit(SellBitboxDepositRetry(signedSwap, signedDeposit, e.toString()));
return;
}
// From here the deposit tx is broadcast; confirmation must be idempotent.
await _confirmDeposit(signedSwap, signedDeposit, txHash);
}

Future<void> _confirmDeposit(
BroadcastTransactionRequestDto signedSwap,
BroadcastTransactionRequestDto signedDeposit,
String txHash,
) async {
try {
await _sellService.confirmPaymentWithTxHash(_paymentInfo, txHash);
emit(SellBitboxSuccess());
} catch (e) {
emit(SellBitboxDepositRetry(signedSwap, signedDeposit, e.toString()));
// Confirmation failed AFTER a successful broadcast: carry the txHash so a
// retry confirms the existing tx instead of broadcasting a duplicate.
emit(SellBitboxDepositRetry(
signedSwap,
signedDeposit,
e.toString(),
broadcastTxHash: txHash,
));
}
}

Expand Down
15 changes: 11 additions & 4 deletions lib/screens/sell_bitbox/cubit/sell_bitbox_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,27 @@ class SellBitboxAwaitingDepositConfirm extends SellBitboxDepositState {

class SellBitboxDepositing extends SellBitboxDepositState {}

// Swap was broadcast successfully but deposit broadcast failed
// The swap was broadcast; the deposit step then failed. `broadcastTxHash` tells
// retry which step to resume: null = the deposit was never broadcast (safe to
// broadcast), non-null = the deposit is already on-chain and only the payment
// confirmation failed, so retry must confirm ONLY and never re-broadcast it
// (issue #657 P4 BB1).
class SellBitboxDepositRetry extends SellBitboxDepositState {
final BroadcastTransactionRequestDto signedSwapTransaction;
final BroadcastTransactionRequestDto signedDepositTransaction;
final String? broadcastTxHash;
final String errorMessage;

SellBitboxDepositRetry(
this.signedSwapTransaction,
this.signedDepositTransaction,
this.errorMessage,
);
this.errorMessage, {
this.broadcastTxHash,
});

@override
List<Object?> get props => [signedSwapTransaction, signedDepositTransaction, errorMessage];
List<Object?> get props =>
[signedSwapTransaction, signedDepositTransaction, broadcastTxHash, errorMessage];
}

class SellBitboxSuccess extends SellBitboxDepositState {}
Expand Down
12 changes: 11 additions & 1 deletion test/screens/sell_bitbox/cubit/sell_bitbox_state_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,24 @@ void main() {

expect(a, equals(b));
expect(a.hashCode, b.hashCode);
expect(a.props, [broadcastA, broadcastB, 'boom']);
expect(a.props, [broadcastA, broadcastB, null, 'boom']);
});

test('different error messages are unequal', () {
final a = SellBitboxDepositRetry(broadcastA, broadcastB, 'boom');
final b = SellBitboxDepositRetry(broadcastA, broadcastB, 'other');
expect(a, isNot(equals(b)));
});

test('different broadcastTxHash values are unequal (issue #657 P4 BB1)', () {
// null = deposit not yet on-chain (retry may broadcast); non-null =
// already broadcast (retry must confirm only).
final a = SellBitboxDepositRetry(broadcastA, broadcastB, 'boom');
final b = SellBitboxDepositRetry(broadcastA, broadcastB, 'boom',
broadcastTxHash: '0xtxhash');
expect(a, isNot(equals(b)));
expect(b.props, [broadcastA, broadcastB, '0xtxhash', 'boom']);
});
});

group('SellBitboxError', () {
Expand Down
92 changes: 87 additions & 5 deletions test/screens/sell_bitbox/sell_bitbox_cubit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -586,11 +586,93 @@ void main() {
});
});

// retryDeposit's outer catch is reached only if _broadcastDepositAndConfirm
// itself raises — which in practice means an emit() against a closed cubit
// (the inner branch already swallows all broadcast/confirm errors into a
// DepositRetry state). Pin that defensive path so a regression that turned
// the helper async-throwy would surface here, not in prod.
group('retryDeposit idempotency (issue #657 P4 BB1)', () {
test(
'a retry after broadcast-succeeded/confirm-failed confirms only and '
'must NOT re-broadcast the on-chain deposit tx',
() async {
final creds = FakeBitboxCredentials();
when(() => account.primaryAddress).thenReturn(creds);
when(() => sellService.createUnsignedTransactions(any())).thenAnswer(
(_) async => const RealUnitUnsignedTransactionsRequestDto(
swap: '0xdeadbeef',
deposit: '0xcafebabe',
),
);
// Both broadcasts (swap + deposit) succeed …
when(() => sellService.broadcastTransaction(any(), any()))
.thenAnswer((_) async => '0xtxhash');
// … but the payment confirmation fails once, then succeeds.
var confirms = 0;
when(() => sellService.confirmPaymentWithTxHash(any(), any()))
.thenAnswer((_) async {
confirms++;
if (confirms == 1) throw Exception('confirm backend down');
});

final cubit = build();
await cubit.stream.firstWhere((s) => s is SellBitboxEthReady);
await cubit.proceedToSwap();
await cubit.confirmSwap();
await cubit.confirmDeposit();

// The deposit IS on-chain; the retry state must carry its txHash.
final retry = cubit.state;
expect(retry, isA<SellBitboxDepositRetry>());
expect((retry as SellBitboxDepositRetry).broadcastTxHash, '0xtxhash');
verify(() => sellService.broadcastTransaction(any(), any())).called(2);
verify(() => sellService.confirmPaymentWithTxHash(any(), any())).called(1);

await cubit.retryDeposit();

// Success via confirm-only — not a single additional broadcast (the
// old behaviour re-broadcast the already-sent tx, looping forever).
expect(cubit.state, isA<SellBitboxSuccess>());
verifyNever(() => sellService.broadcastTransaction(any(), any()));
verify(() => sellService.confirmPaymentWithTxHash(any(), any())).called(1);
},
);

test('a retry after a FAILED broadcast may safely broadcast again', () async {
final creds = FakeBitboxCredentials();
when(() => account.primaryAddress).thenReturn(creds);
when(() => sellService.createUnsignedTransactions(any())).thenAnswer(
(_) async => const RealUnitUnsignedTransactionsRequestDto(
swap: '0xdeadbeef',
deposit: '0xcafebabe',
),
);
// Swap broadcast succeeds, the first deposit broadcast fails, the
// re-broadcast on retry succeeds.
var broadcasts = 0;
when(() => sellService.broadcastTransaction(any(), any())).thenAnswer((_) async {
broadcasts++;
if (broadcasts == 2) throw Exception('mempool hiccup');
return '0xok';
});
when(() => sellService.confirmPaymentWithTxHash(any(), any()))
.thenAnswer((_) async {});

final cubit = build();
await cubit.stream.firstWhere((s) => s is SellBitboxEthReady);
await cubit.proceedToSwap();
await cubit.confirmSwap();
await cubit.confirmDeposit();

// Deposit broadcast failed → nothing on-chain → no txHash carried.
final retry = cubit.state;
expect(retry, isA<SellBitboxDepositRetry>());
expect((retry as SellBitboxDepositRetry).broadcastTxHash, isNull);

await cubit.retryDeposit();

expect(cubit.state, isA<SellBitboxSuccess>());
});
});

// A StateError from an emit() against a closed cubit must surface to the
// caller (retryDeposit performs no emit-gating of its own); pin that the
// closed-cubit path throws instead of silently corrupting state.
group('retryDeposit defensive outer-catch', () {
test(
'StateError from emit-after-close is caught and the cubit is left intact',
Expand Down
Loading