From 83baa1b7295a2bf41c434cd6a41f3dc682685018 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 21 Aug 2026 00:03:37 -0300 Subject: [PATCH 1/3] fix: refuse a trade key index that names the identity key --- lib/data/models/restore_response.dart | 18 ++++- lib/data/models/session.dart | 8 +- lib/features/key_manager/key_manager.dart | 21 +++++ .../models/session_bond_pending_test.dart | 2 +- test/data/restore_response_test.dart | 20 ++--- .../trade_index_monotonicity_test.dart | 80 ++++++++++++++++++- 6 files changed, 133 insertions(+), 16 deletions(-) diff --git a/lib/data/models/restore_response.dart b/lib/data/models/restore_response.dart index f7c185a94..2e2a068bf 100644 --- a/lib/data/models/restore_response.dart +++ b/lib/data/models/restore_response.dart @@ -46,9 +46,18 @@ class RestoredOrder { }); factory RestoredOrder.fromJson(Map json) { + final tradeIndex = json['trade_index'] as int; + // Index 0 is the identity key, so a restore response naming it would have + // the session that gets built here sign and ECDH under the master + // identity. Rejected at the boundary rather than at derivation, so the + // order never becomes a session in the first place. + if (tradeIndex < 1) { + throw FormatException('Trade index must be greater than 0: $tradeIndex'); + } + return RestoredOrder( id: json['order_id'] as String, - tradeIndex: json['trade_index'] as int, + tradeIndex: tradeIndex, status: json['status'] as String, ); } @@ -81,10 +90,15 @@ class RestoredDispute { final rawInitiator = json['initiator'] as String?; final normalizedInitiator = _normalizeInitiator(rawInitiator); + final tradeIndex = json['trade_index'] as int; + if (tradeIndex < 1) { + throw FormatException('Trade index must be greater than 0: $tradeIndex'); + } + return RestoredDispute( disputeId: json['dispute_id'] as String, orderId: json['order_id'] as String, - tradeIndex: json['trade_index'] as int, + tradeIndex: tradeIndex, status: json['status'] as String, initiator: normalizedInitiator, solverPubkey: json['solver_pubkey'] as String?, diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 2534541cf..bc1f8e133 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -88,8 +88,12 @@ class Session { throw FormatException('Invalid key_index type: ${keyIndexValue.runtimeType}'); } - if (keyIndex < 0) { - throw FormatException('Key index cannot be negative: $keyIndex'); + // Not merely non-negative: index 0 is the identity key, and a session + // restored onto it would sign and ECDH under the master identity. The + // counter starts at 1 and setCurrentKeyIndex refuses anything lower, so + // no session this app wrote can be below it. + if (keyIndex < 1) { + throw FormatException('Key index must be greater than 0: $keyIndex'); } // Validate key pair fields diff --git a/lib/features/key_manager/key_manager.dart b/lib/features/key_manager/key_manager.dart index 1ea9dbf28..4a5a18f8f 100644 --- a/lib/features/key_manager/key_manager.dart +++ b/lib/features/key_manager/key_manager.dart @@ -83,6 +83,7 @@ class KeyManager { } NostrKeyPairs deriveTradeKeyPair(int index) { + _requireTradeKeyIndex(index); final tradePrivateHex = _derivator.derivePrivateKey(_masterKeyHex!, index); return NostrKeyPairs(private: tradePrivateHex); @@ -90,6 +91,7 @@ class KeyManager { /// Derive a trade key for a specific index Future deriveTradeKeyFromIndex(int index) async { + _requireTradeKeyIndex(index); final masterKeyHex = await _storage.readMasterKey(); if (masterKeyHex == null) { throw MasterKeyNotFoundException( @@ -119,6 +121,25 @@ class KeyManager { return currentIndex + 1; } + /// Refuses an index that does not name a trade key. + /// + /// Index 0 is the identity key — `_getMasterKey` derives it — so deriving + /// "trade key 0" hands back the master identity, and a session built on it + /// would sign chat events with, and run ECDH under, the key the whole + /// pseudonymity of a trade rests on separating. + /// + /// [setCurrentKeyIndex] has always enforced this for the counter, which is + /// why the normal path never reaches index 0. Derivation is reachable + /// without going through the counter — restore derives straight from an + /// index in the response — so the same rule has to sit here too. + void _requireTradeKeyIndex(int index) { + if (index < 1) { + throw InvalidTradeKeyIndexException( + 'Trade key index must be greater than 0, got $index', + ); + } + } + Future setCurrentKeyIndex(int index) async { if (index < 1) { throw InvalidTradeKeyIndexException( diff --git a/test/data/models/session_bond_pending_test.dart b/test/data/models/session_bond_pending_test.dart index 5cfe15fec..b1515aba5 100644 --- a/test/data/models/session_bond_pending_test.dart +++ b/test/data/models/session_bond_pending_test.dart @@ -34,7 +34,7 @@ void main() { final json = { 'master_key': keyPair, 'trade_key': keyPair, - 'key_index': 0, + 'key_index': 1, 'full_privacy': false, 'start_time': '2026-06-03T12:00:00.000', 'order_id': 'order-1', diff --git a/test/data/restore_response_test.dart b/test/data/restore_response_test.dart index 1e23e8b8d..872ef1b67 100644 --- a/test/data/restore_response_test.dart +++ b/test/data/restore_response_test.dart @@ -7,7 +7,7 @@ void main() { final dispute = RestoredDispute.fromJson({ 'dispute_id': 'dispute-123', 'order_id': 'order-456', - 'trade_index': 0, + 'trade_index': 1, 'status': 'in-progress', 'initiator': 'buyer', }); @@ -31,7 +31,7 @@ void main() { final dispute = RestoredDispute.fromJson({ 'dispute_id': 'dispute-123', 'order_id': 'order-456', - 'trade_index': 0, + 'trade_index': 1, 'status': 'in-progress', 'initiator': 'BUYER', }); @@ -55,7 +55,7 @@ void main() { final dispute = RestoredDispute.fromJson({ 'dispute_id': 'dispute-123', 'order_id': 'order-456', - 'trade_index': 0, + 'trade_index': 1, 'status': 'in-progress', 'initiator': 'BuYeR', }); @@ -67,7 +67,7 @@ void main() { final dispute = RestoredDispute.fromJson({ 'dispute_id': 'dispute-123', 'order_id': 'order-456', - 'trade_index': 0, + 'trade_index': 1, 'status': 'in-progress', 'initiator': ' buyer ', }); @@ -91,7 +91,7 @@ void main() { final dispute = RestoredDispute.fromJson({ 'dispute_id': 'dispute-123', 'order_id': 'order-456', - 'trade_index': 0, + 'trade_index': 1, 'status': 'in-progress', 'initiator': 'admin', }); @@ -103,7 +103,7 @@ void main() { final dispute = RestoredDispute.fromJson({ 'dispute_id': 'dispute-123', 'order_id': 'order-456', - 'trade_index': 0, + 'trade_index': 1, 'status': 'in-progress', 'initiator': 'unknown', }); @@ -115,7 +115,7 @@ void main() { final dispute = RestoredDispute.fromJson({ 'dispute_id': 'dispute-123', 'order_id': 'order-456', - 'trade_index': 0, + 'trade_index': 1, 'status': 'in-progress', 'initiator': '', }); @@ -127,7 +127,7 @@ void main() { final dispute = RestoredDispute.fromJson({ 'dispute_id': 'dispute-123', 'order_id': 'order-456', - 'trade_index': 0, + 'trade_index': 1, 'status': 'in-progress', 'initiator': ' ', }); @@ -139,7 +139,7 @@ void main() { final dispute = RestoredDispute.fromJson({ 'dispute_id': 'dispute-123', 'order_id': 'order-456', - 'trade_index': 0, + 'trade_index': 1, 'status': 'in-progress', }); @@ -150,7 +150,7 @@ void main() { final dispute = RestoredDispute.fromJson({ 'dispute_id': 'dispute-123', 'order_id': 'order-456', - 'trade_index': 0, + 'trade_index': 1, 'status': 'in-progress', 'initiator': null, }); diff --git a/test/features/key_manager/trade_index_monotonicity_test.dart b/test/features/key_manager/trade_index_monotonicity_test.dart index 168589a0e..0f0ac3797 100644 --- a/test/features/key_manager/trade_index_monotonicity_test.dart +++ b/test/features/key_manager/trade_index_monotonicity_test.dart @@ -1,3 +1,4 @@ +import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mostro_mobile/data/models/last_trade_index_response.dart'; import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; @@ -10,6 +11,13 @@ import 'package:mostro_mobile/features/key_manager/key_storage.dart'; class _FakeKeyStorage implements KeyStorage { int index = 1; + /// A real extended key, derived from a fixed mnemonic in [setUp]. The + /// derivator takes a BIP32 xprv, not a raw private key. + String? masterKey; + + @override + Future readMasterKey() async => masterKey; + @override Future readTradeKeyIndex() async => index; @@ -23,13 +31,22 @@ class _FakeKeyStorage implements KeyStorage { throw UnimplementedError('${invocation.memberName}'); } +/// A fixed BIP39 mnemonic, so every derivation here is reproducible. +const _mnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon ' + 'abandon abandon abandon about'; + void main() { late _FakeKeyStorage storage; late KeyManager keyManager; + late KeyDerivator derivator; + setUp(() { + derivator = KeyDerivator("m/44'/1237'/38383'/0"); storage = _FakeKeyStorage(); - keyManager = KeyManager(storage, KeyDerivator("m/44'/1237'/38383'/0")); + storage.masterKey = derivator.extendedKeyFromMnemonic(_mnemonic); + keyManager = KeyManager(storage, derivator); }); // MM-021 / MM-003: the index counts trade keys this device has derived. @@ -112,4 +129,65 @@ void main() { ); }); }); + // MM-003: index 0 is the identity key — `_getMasterKey` derives it — so a + // "trade key 0" is the master identity. The restore path derives straight + // from an index in the daemon's response, without going through the counter + // that has always refused it. + group('trade key index bounds', () { + setUp(() async { + await keyManager.init(); + }); + + test('index 0 is the identity key, not a trade key', () { + // What the guard keeps out of a session: index 0 is what + // KeyManager.masterKeyPair is derived from, so a session built on + // "trade key 0" would sign and ECDH under the master identity. + final atZero = derivator.derivePrivateKey(storage.masterKey!, 0); + + expect(atZero, keyManager.masterKeyPair!.private); + expect(atZero, isNot(derivator.derivePrivateKey(storage.masterKey!, 1))); + }); + + test('deriveTradeKeyPair refuses index 0', () { + expect( + () => keyManager.deriveTradeKeyPair(0), + throwsA(isA()), + ); + }); + + test('deriveTradeKeyPair refuses a negative index', () { + expect( + () => keyManager.deriveTradeKeyPair(-1), + throwsA(isA()), + ); + }); + + test('deriveTradeKeyPair still derives a real trade key', () { + expect(keyManager.deriveTradeKeyPair(1), isA()); + expect(keyManager.deriveTradeKeyPair(9999), isA()); + }); + + test('deriveTradeKeyFromIndex refuses index 0', () async { + await expectLater( + keyManager.deriveTradeKeyFromIndex(0), + throwsA(isA()), + ); + }); + + test('deriveTradeKeyFromIndex refuses before it reads the master key', + () async { + // The argument is wrong whatever storage holds, and saying so without + // touching the key keeps the two failures apart. + storage.masterKey = null; + + await expectLater( + keyManager.deriveTradeKeyFromIndex(0), + throwsA(isA()), + ); + }); + + test('deriveTradeKeyFromIndex still derives a real trade key', () async { + expect(await keyManager.deriveTradeKeyFromIndex(1), isA()); + }); + }); } From c4ff0917a13589eee545be12d1c9a903e0f57bc5 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 21 Aug 2026 00:11:52 -0300 Subject: [PATCH 2/3] fix: stop inbound payloads from restating the trade terms --- lib/data/models/order.dart | 47 +++++- lib/features/order/models/order_state.dart | 113 +++++++++---- .../screens/pay_lightning_invoice_screen.dart | 8 +- .../order/order_terms_freeze_test.dart | 157 ++++++++++++++++++ 4 files changed, 283 insertions(+), 42 deletions(-) create mode 100644 test/features/order/order_terms_freeze_test.dart diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart index 54085e2a9..cfbde39ae 100644 --- a/lib/data/models/order.dart +++ b/lib/data/models/order.dart @@ -20,6 +20,7 @@ class Order implements Payload { final String? buyerTradePubkey; final String? sellerTradePubkey; final String? buyerInvoice; + /// Seconds since the Unix epoch, as the protocol sends it (Nostr /// convention). Convert before handing it to anything that expects /// milliseconds, including [MostroMessage.timestamp]. @@ -166,9 +167,8 @@ class Order implements Payload { return Order( id: parseOptionalStringField('id'), kind: OrderType.fromString(parseStringField('kind')), - status: statusRaw != null - ? Status.fromString(statusRaw) - : Status.pending, + status: + statusRaw != null ? Status.fromString(statusRaw) : Status.pending, amount: amount, fiatCode: parseStringField('fiat_code'), minAmount: minAmount, @@ -200,9 +200,8 @@ class Order implements Payload { paymentMethod: event.paymentMethods.join(','), premium: event.premium as int, createdAt: event.createdAt as int, - expiresAt: event.expiresAt != null - ? int.tryParse(event.expiresAt!) - : null, + expiresAt: + event.expiresAt != null ? int.tryParse(event.expiresAt!) : null, ); } @@ -250,6 +249,42 @@ class Order implements Payload { @override String get type => 'order'; + /// A copy of this order carrying [terms]'s trade terms instead of its own. + /// + /// The four fields taken from [terms] are what the fiat side of the trade + /// is: where the money goes, how much of it, in what currency, at what + /// premium. They are settled when the order is taken, and nothing in the + /// protocol renegotiates them — so a later payload that reports on the + /// trade restates them at best, and redirects them at worst. + Order withTermsFrom(Order terms) { + return Order( + id: id, + kind: kind, + status: status, + amount: amount, + fiatCode: terms.fiatCode, + minAmount: minAmount, + maxAmount: maxAmount, + fiatAmount: terms.fiatAmount, + paymentMethod: terms.paymentMethod, + premium: terms.premium, + masterBuyerPubkey: masterBuyerPubkey, + masterSellerPubkey: masterSellerPubkey, + buyerTradePubkey: buyerTradePubkey, + sellerTradePubkey: sellerTradePubkey, + buyerInvoice: buyerInvoice, + expiresAt: expiresAt, + createdAt: createdAt, + ); + } + + /// Whether [other] states different trade terms than this order. + bool hasDifferentTermsThan(Order other) => + paymentMethod != other.paymentMethod || + fiatAmount != other.fiatAmount || + fiatCode != other.fiatCode || + premium != other.premium; + Order copyWith({String? buyerInvoice, Status? status}) { return Order( id: id, diff --git a/lib/features/order/models/order_state.dart b/lib/features/order/models/order_state.dart index 7cd157338..b0e63d478 100644 --- a/lib/features/order/models/order_state.dart +++ b/lib/features/order/models/order_state.dart @@ -103,9 +103,8 @@ class OrderState { peer: peer ?? this.peer, paymentFailed: paymentFailed ?? this.paymentFailed, fiatWasSent: fiatWasSent ?? this.fiatWasSent, - peerReputation: clearPeerReputation - ? null - : peerReputation ?? this.peerReputation, + peerReputation: + clearPeerReputation ? null : peerReputation ?? this.peerReputation, ); } @@ -217,12 +216,14 @@ class OrderState { effectiveAction = newFiatWasSent ? Action.cooperativeCancelFiatSentByYou : Action.cooperativeCancelNoFiatByYou; - logger.i('Remapped ${message.action} → $effectiveAction (fiatWasSent: $newFiatWasSent)'); + logger.i( + 'Remapped ${message.action} → $effectiveAction (fiatWasSent: $newFiatWasSent)'); } else if (message.action == Action.cooperativeCancelInitiatedByPeer) { effectiveAction = newFiatWasSent ? Action.cooperativeCancelFiatSentByPeer : Action.cooperativeCancelNoFiatByPeer; - logger.i('Remapped ${message.action} → $effectiveAction (fiatWasSent: $newFiatWasSent)'); + logger.i( + 'Remapped ${message.action} → $effectiveAction (fiatWasSent: $newFiatWasSent)'); } // Determine the new status based on the action received @@ -296,24 +297,27 @@ class OrderState { // the dispute list for good. if (message.timestamp != null) { final tsMs = message.timestamp!; - if (updatedDispute.createdAt == null || + if (updatedDispute.createdAt == null || updatedDispute.createdAt!.millisecondsSinceEpoch != tsMs) { updatedDispute = updatedDispute.copyWith( createdAt: DateTime.fromMillisecondsSinceEpoch(tsMs), ); - logger.i('Updated dispute ${updatedDispute.disputeId} createdAt from message timestamp: ${updatedDispute.createdAt}'); + logger.i( + 'Updated dispute ${updatedDispute.disputeId} createdAt from message timestamp: ${updatedDispute.createdAt}'); } } } - + // Add defensive null check - if both message payload and existing dispute are null, // we cannot perform dispute updates - if (updatedDispute == null && - (message.action == Action.adminTookDispute || - message.action == Action.adminSettled || - message.action == Action.adminCanceled)) { - logger.w('Cannot update dispute for action ${message.action}: no dispute found in message payload or existing state'); - } else if (message.action == Action.adminTookDispute && updatedDispute != null) { + if (updatedDispute == null && + (message.action == Action.adminTookDispute || + message.action == Action.adminSettled || + message.action == Action.adminCanceled)) { + logger.w( + 'Cannot update dispute for action ${message.action}: no dispute found in message payload or existing state'); + } else if (message.action == Action.adminTookDispute && + updatedDispute != null) { // When admin takes dispute, update status to in-progress and set admin info // Extract admin pubkey from Peer payload if available String? adminPubkey = updatedDispute.adminPubkey; @@ -324,33 +328,40 @@ class OrderState { logger.i('Extracted admin pubkey from Peer payload: $adminPubkey'); } } - + updatedDispute = updatedDispute.copyWith( status: 'in-progress', adminTookAt: DateTime.now(), adminPubkey: adminPubkey, ); - logger.i('Updated dispute status to in-progress for adminTookDispute action'); - } else if (message.action == Action.adminSettled && updatedDispute != null) { + logger.i( + 'Updated dispute status to in-progress for adminTookDispute action'); + } else if (message.action == Action.adminSettled && + updatedDispute != null) { // When admin settles dispute, update status to resolved with settlement info updatedDispute = updatedDispute.copyWith( status: 'resolved', action: 'admin-settled', // Store the resolution type ); logger.i('Updated dispute status to resolved for adminSettled action'); - } else if (message.action == Action.adminCanceled && updatedDispute != null) { + } else if (message.action == Action.adminCanceled && + updatedDispute != null) { // When admin cancels order, update dispute status to seller-refunded updatedDispute = updatedDispute.copyWith( status: 'seller-refunded', action: 'admin-canceled', // Store the resolution type ); - logger.i('Updated dispute status to seller-refunded for adminCanceled action'); + logger.i( + 'Updated dispute status to seller-refunded for adminCanceled action'); logger.i('Dispute status updated to: ${updatedDispute.status}'); } // Auto-close dispute when order reaches terminal state by user action - final disputeAlreadyTerminal = const ['resolved', 'seller-refunded', 'closed'] - .contains(updatedDispute?.status?.toLowerCase()); + final disputeAlreadyTerminal = const [ + 'resolved', + 'seller-refunded', + 'closed' + ].contains(updatedDispute?.status?.toLowerCase()); if (updatedDispute != null && !disputeAlreadyTerminal && @@ -373,19 +384,14 @@ class OrderState { // Bond acks (3.5) and slash notice (4): their SmallOrder has a null status // and a bond-sized amount; don't let it overwrite the tracked trade order. - final bool isBondPayoutAck = - message.action == Action.bondInvoiceAccepted || - message.action == Action.bondPayoutCompleted || - message.action == Action.bondSlashed; + final bool isBondPayoutAck = message.action == Action.bondInvoiceAccepted || + message.action == Action.bondPayoutCompleted || + message.action == Action.bondSlashed; final newState = copyWith( status: newStatus, action: effectiveAction, - order: (message.payload is Order && !isBondPayoutAck) - ? message.getPayload() - : message.payload is PaymentRequest - ? message.getPayload()!.order - : order, + order: _orderAfter(message, isBondPayoutAck: isBondPayoutAck), paymentRequest: newPaymentRequest, cantDo: message.getPayload() ?? cantDo, dispute: updatedDispute, @@ -399,12 +405,51 @@ class OrderState { ); logger.i('New state: ${newState.status} - ${newState.action}'); - logger - .i('PaymentRequest preserved: ${newState.paymentRequest != null}'); + logger.i('PaymentRequest preserved: ${newState.paymentRequest != null}'); return newState; } + /// The order to track once [message] has been applied. + /// + /// Inbound payloads used to replace the tracked order outright, which made + /// every economic field restatable by any later message. Two rules now + /// stand between a payload and the order: + /// + /// A `PaymentRequest`'s embedded order never becomes the tracked one. Its + /// `amount` is the figure for that particular payment — the hold invoice is + /// the order amount plus the seller's fee, the payout is the order amount + /// less the buyer's — so it is a statement about a payment, not about the + /// trade. It stays reachable on [paymentRequest] for the screens that need + /// it. + /// + /// The fiat terms freeze once the order is under way. While it is pending + /// they are still being settled, so the message that takes it out of + /// pending writes freely; after that, where the fiat goes and how much of + /// it is fixed. The sats amount is deliberately not frozen: a market-price + /// order has none until it is taken, and the node resolves it then. + Order? _orderAfter(MostroMessage message, {required bool isBondPayoutAck}) { + // Bond acks (3.5) and the slash notice (4) carry a bond-sized SmallOrder + // with a null status; it was never the trade order. + if (isBondPayoutAck) return order; + if (message.payload is! Order) return order; + + final incoming = message.getPayload(); + if (incoming == null) return order; + + final current = order; + if (current == null || status == Status.pending) return incoming; + + if (incoming.hasDifferentTermsThan(current)) { + logger.w( + 'Ignoring restated trade terms on ${message.action} for order ' + '${current.id}: payment method, fiat amount, currency and premium ' + 'were settled when the order was taken', + ); + } + return incoming.withTermsFrom(current); + } + /// Maps actions to their corresponding statuses based on mostrod DM messages Status _getStatusFromAction(Action action, Status? payloadStatus) { switch (action) { @@ -420,7 +465,7 @@ class OrderState { // Actions that should set status to waiting-buyer-invoice case Action.waitingBuyerInvoice: return Status.waitingBuyerInvoice; - + case Action.addInvoice: // Mostro reuses this action to ask for a payout invoice after a failed // payment, and only then does the payload carry settled-hold-invoice. @@ -430,7 +475,6 @@ class OrderState { } return Status.waitingBuyerInvoice; - // FIX: Cuando alguien toma una orden, debe cambiar el status inmediatamente case Action.takeBuy: @@ -524,7 +568,6 @@ class OrderState { case Action.newOrder: return payloadStatus ?? status; - // For other actions, keep the current status unless payload has a different one default: return payloadStatus ?? status; diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 35e41587d..26ee82e99 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -33,7 +33,13 @@ class _PayLightningInvoiceScreenState Widget build(BuildContext context) { final orderState = ref.watch(orderNotifierProvider(widget.orderId)); final lnInvoice = orderState.paymentRequest?.lnInvoice ?? ''; - final sats = orderState.order?.amount ?? 0; + // The figure for this payment, not for the order: the hold invoice is the + // order amount plus the seller's half of the fee, and the node states it + // in the payment request. It used to arrive here by overwriting the + // tracked order, which is exactly what any other payload could do too. + final sats = orderState.paymentRequest?.order?.amount ?? + orderState.order?.amount ?? + 0; final fiatAmount = orderState.order?.fiatAmount.toString() ?? '0'; final fiatCode = orderState.order?.fiatCode ?? ''; final orderNotifier = diff --git a/test/features/order/order_terms_freeze_test.dart b/test/features/order/order_terms_freeze_test.dart new file mode 100644 index 000000000..09082e339 --- /dev/null +++ b/test/features/order/order_terms_freeze_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/payment_request.dart'; +import 'package:mostro_mobile/features/order/models/order_state.dart'; + +Order order({ + Status status = Status.active, + int amount = 100000, + int fiatAmount = 500, + String fiatCode = 'USD', + String paymentMethod = 'Bank A', + int premium = 0, +}) => + Order( + id: 'order-1', + kind: OrderType.sell, + status: status, + amount: amount, + fiatCode: fiatCode, + fiatAmount: fiatAmount, + paymentMethod: paymentMethod, + premium: premium, + ); + +MostroMessage orderMessage(Action action, Order payload) => + MostroMessage(action: action, id: 'order-1', payload: payload); + +void main() { + OrderState stateWith(Order tracked, {Status status = Status.active}) => + OrderState(action: Action.buyerTookOrder, status: status, order: tracked); + + // The fiat terms are what the buyer acts on: the trade screen renders the + // payment method as the account to send money to. They are settled when the + // order is taken and nothing renegotiates them. + group('trade terms once the order is under way', () { + test('a later payload cannot redirect the payment method', () { + final state = stateWith(order(paymentMethod: 'Bank A')); + + final next = state.updateWith(orderMessage( + Action.invoiceUpdated, + order(paymentMethod: 'MULE LLC IBAN XX66'), + )); + + expect(next.order!.paymentMethod, 'Bank A'); + }); + + test('nor restate the fiat amount, currency or premium', () { + final state = stateWith(order()); + + final next = state.updateWith(orderMessage( + Action.invoiceUpdated, + order(fiatAmount: 5000, fiatCode: 'EUR', premium: 40), + )); + + expect(next.order!.fiatAmount, 500); + expect(next.order!.fiatCode, 'USD'); + expect(next.order!.premium, 0); + }); + + test('the sats amount is still free to move', () { + // A market-price order has none until it is taken, and the node + // resolves it then. + final state = stateWith(order(amount: 0)); + + final next = state.updateWith( + orderMessage(Action.holdInvoicePaymentAccepted, order(amount: 250000)), + ); + + expect(next.order!.amount, 250000); + }); + + test('everything else on the payload still applies', () { + final state = stateWith(order()); + + final next = state.updateWith(orderMessage( + Action.fiatSentOk, + order(paymentMethod: 'MULE', status: Status.fiatSent), + )); + + expect(next.order!.status, Status.fiatSent); + expect(next.order!.paymentMethod, 'Bank A'); + }); + }); + + group('while the order is still pending', () { + test('terms are written freely', () { + // They are being settled: the message that takes the order out of + // pending is the one that fixes them. + final state = stateWith( + order(status: Status.pending, paymentMethod: 'Bank A'), + status: Status.pending, + ); + + final next = state.updateWith(orderMessage( + Action.buyerTookOrder, + order(paymentMethod: 'Bank B', fiatAmount: 900), + )); + + expect(next.order!.paymentMethod, 'Bank B'); + expect(next.order!.fiatAmount, 900); + }); + + test('the first tracked order is taken as given', () { + final empty = OrderState( + action: Action.newOrder, + status: Status.pending, + order: null, + ); + + final next = empty.updateWith( + orderMessage(Action.newOrder, order(paymentMethod: 'Bank A')), + ); + + expect(next.order!.paymentMethod, 'Bank A'); + }); + }); + + // The amount in a payment request is the figure for that payment — the hold + // invoice is the order plus the seller's fee — so it was never the order. + group('a payment request does not restate the order', () { + test('its embedded order does not become the tracked one', () { + final state = stateWith(order(amount: 100000, paymentMethod: 'Bank A')); + + final next = state.updateWith(MostroMessage( + action: Action.payInvoice, + id: 'order-1', + payload: PaymentRequest( + order: order(amount: 100300, paymentMethod: 'MULE'), + lnInvoice: 'lnbc1003u1example', + ), + )); + + expect(next.order!.amount, 100000); + expect(next.order!.paymentMethod, 'Bank A'); + }); + + test('but stays reachable for the screen that pays it', () { + final state = stateWith(order(amount: 100000)); + + final next = state.updateWith(MostroMessage( + action: Action.payInvoice, + id: 'order-1', + payload: PaymentRequest( + order: order(amount: 100300), + lnInvoice: 'lnbc1003u1example', + ), + )); + + expect(next.paymentRequest!.order!.amount, 100300); + expect(next.paymentRequest!.lnInvoice, 'lnbc1003u1example'); + }); + }); +} From e289634d6b0d2e3b3a92230f178ddfaac67c33a0 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 21 Aug 2026 00:17:55 -0300 Subject: [PATCH 3/3] fix: hold a bond request against the node's published policy --- .../screens/pay_bond_invoice_screen.dart | 268 ++++++++++++------ lib/l10n/intl_de.arb | 5 +- lib/l10n/intl_en.arb | 11 + lib/l10n/intl_es.arb | 5 +- lib/l10n/intl_fr.arb | 5 +- lib/l10n/intl_it.arb | 5 +- lib/l10n/intl_pt.arb | 5 +- lib/shared/utils/bond_amounts.dart | 86 ++++++ test/shared/utils/bond_amounts_test.dart | 112 ++++++++ 9 files changed, 410 insertions(+), 92 deletions(-) create mode 100644 lib/shared/utils/bond_amounts.dart create mode 100644 test/shared/utils/bond_amounts_test.dart diff --git a/lib/features/order/screens/pay_bond_invoice_screen.dart b/lib/features/order/screens/pay_bond_invoice_screen.dart index 29358f085..2391166f2 100644 --- a/lib/features/order/screens/pay_bond_invoice_screen.dart +++ b/lib/features/order/screens/pay_bond_invoice_screen.dart @@ -6,6 +6,9 @@ import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/utils/bond_amounts.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -69,8 +72,7 @@ class PayBondInvoiceScreen extends ConsumerWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), ), child: Text( s.yes, @@ -125,6 +127,25 @@ class PayBondInvoiceScreen extends ConsumerWidget { final orderState = ref.watch(orderNotifierProvider(orderId)); final lnInvoice = orderState.paymentRequest?.lnInvoice ?? ''; final bondAmount = orderState.paymentRequest?.order?.amount; + + // Held against the policy the node published in its kind-38385 event, + // which is where it states that it charges bonds at all and how it sizes + // them. Nothing gated this request on any of it, so the figure was + // whatever the message said. + // + // The order's sats amount is what the node sizes the bond from. It has + // none for a market-priced range order — each taker's bond follows their + // own quote, which the node computes and never sends — so only the + // advertised floor can be checked there. + final info = ref.watch(orderRepositoryProvider).mostroInstance; + final instance = info == null ? null : MostroInstance.fromEvent(info); + final bondProblem = BondAmounts.problemWith( + requestedSats: bondAmount, + orderAmountSats: orderState.order?.amount, + advertised: instance?.bondPolicy == BondPolicy.enabled, + amountPct: instance?.bondAmountPct, + baseAmountSats: instance?.bondBaseAmountSats, + ); // A maker creating an order pays the bond before it is published, so the // copy must warn them to keep the screen open or the order won't be created. final isMakerBond = ref @@ -132,7 +153,8 @@ class PayBondInvoiceScreen extends ConsumerWidget { .getSessionByOrderId(orderId) ?.bondPending ?? false; - final explanation = isMakerBond ? s.bondExplanationMaker : s.bondExplanation; + final explanation = + isMakerBond ? s.bondExplanationMaker : s.bondExplanation; return Scaffold( backgroundColor: AppTheme.dark1, @@ -144,100 +166,172 @@ class PayBondInvoiceScreen extends ConsumerWidget { 16, 16 + MediaQuery.of(context).viewPadding.bottom, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - explanation, - style: const TextStyle( - color: AppTheme.cream1, - fontSize: 15, - height: 1.4, - ), - ), - if (bondAmount != null && bondAmount > 0) ...[ - const SizedBox(height: 16), - Text( - s.bondPayInvoicePrompt(bondAmount), - style: const TextStyle( - color: AppTheme.cream1, - fontSize: 15, - height: 1.4, - fontWeight: FontWeight.w500, - ), - ), - ], - const SizedBox(height: 20), - Center( - child: Container( - padding: const EdgeInsets.all(8.0), - color: AppTheme.cream1, - child: QrImageView( - data: lnInvoice, - version: QrVersions.auto, - size: 250.0, - backgroundColor: AppTheme.cream1, - errorStateBuilder: (cxt, err) { - return Center( - child: Text( - s.failedToGenerateQR, - textAlign: TextAlign.center, + child: bondProblem != null + ? _BondPolicyNotice( + problem: bondProblem, + requestedSats: bondAmount ?? 0, + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + explanation, + style: const TextStyle( + color: AppTheme.cream1, + fontSize: 15, + height: 1.4, + ), + ), + if (bondAmount != null && bondAmount > 0) ...[ + const SizedBox(height: 16), + Text( + s.bondPayInvoicePrompt(bondAmount), + style: const TextStyle( + color: AppTheme.cream1, + fontSize: 15, + height: 1.4, + fontWeight: FontWeight.w500, ), - ); - }, - ), - ), - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton.icon( - onPressed: lnInvoice.isEmpty - ? null - : () { - Clipboard.setData(ClipboardData(text: lnInvoice)); - logger.i('Copied bond invoice to clipboard'); - SnackBarHelper.showTopSnackBar( - context, - s.invoiceCopiedToClipboard, - duration: const Duration(seconds: 2), + ), + ], + const SizedBox(height: 20), + Center( + child: Container( + padding: const EdgeInsets.all(8.0), + color: AppTheme.cream1, + child: QrImageView( + data: lnInvoice, + version: QrVersions.auto, + size: 250.0, + backgroundColor: AppTheme.cream1, + errorStateBuilder: (cxt, err) { + return Center( + child: Text( + s.failedToGenerateQR, + textAlign: TextAlign.center, + ), ); }, - icon: const Icon(Icons.copy), - label: Text(s.copy), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, + ), + ), ), - ), - ElevatedButton.icon( - onPressed: lnInvoice.isEmpty - ? null - : () => _shareInvoice(context, lnInvoice), - icon: const Icon(Icons.share), - label: Text(s.share), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon( + onPressed: lnInvoice.isEmpty + ? null + : () { + Clipboard.setData( + ClipboardData(text: lnInvoice)); + logger.i('Copied bond invoice to clipboard'); + SnackBarHelper.showTopSnackBar( + context, + s.invoiceCopiedToClipboard, + duration: const Duration(seconds: 2), + ); + }, + icon: const Icon(Icons.copy), + label: Text(s.copy), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + ), + ElevatedButton.icon( + onPressed: lnInvoice.isEmpty + ? null + : () => _shareInvoice(context, lnInvoice), + icon: const Icon(Icons.share), + label: Text(s.share), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + ), + ], ), - ), - ], - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => _confirmAndCancel(context, ref), + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.red, + ), + child: Text(s.cancel), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// Shown instead of the bond invoice when the request does not follow the +/// policy the node published. +/// +/// A refusal: a bond is money held against the trade, and there is no +/// confirming a figure the node's own published policy does not yield. +class _BondPolicyNotice extends StatelessWidget { + final BondProblem problem; + final int requestedSats; + + const _BondPolicyNotice({ + required this.problem, + required this.requestedSats, + }); + + @override + Widget build(BuildContext context) { + final s = S.of(context)!; + final body = problem == BondProblem.notAdvertised + ? s.bondPolicyNotAdvertisedBody + : s.bondAmountMismatchBody(requestedSats.toString()); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.statusError.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.statusError.withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.warning_amber_rounded, + color: AppTheme.statusError, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ElevatedButton( - onPressed: () => _confirmAndCancel(context, ref), - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: Colors.red, + Text( + s.bondPolicyMismatchTitle, + style: const TextStyle( + color: AppTheme.statusError, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + body, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, ), - child: Text(s.cancel), ), ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 4a55b8241..e6cb8625b 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1783,5 +1783,8 @@ "type": "int" } } - } + }, + "bondPolicyMismatchTitle": "Diese Kaution passt nicht zur Node-Richtlinie", + "bondPolicyNotAdvertisedBody": "Diese Node hat keine Kautionsrichtlinie veröffentlicht, also gibt es nichts, dem diese Anfrage folgen könnte. Sie wird nicht bezahlt.", + "bondAmountMismatchBody": "Die Kaution fordert {requestedAmount} Sats, was die veröffentlichte Richtlinie dieser Node für diese Order nicht ergibt. Sie wird nicht bezahlt." } diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 849e9712b..460a0acb3 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1828,5 +1828,16 @@ "type": "int" } } + }, + "bondPolicyMismatchTitle": "This bond does not match the node policy", + "bondPolicyNotAdvertisedBody": "This node has not published a bond policy, so there is nothing this request could be following. It will not be paid.", + "bondAmountMismatchBody": "The bond asks for {requestedAmount} sats, which is not what this node’s published policy yields for this order. It will not be paid.", + "@bondAmountMismatchBody": { + "description": "Shown when the bond amount does not match the node published policy", + "placeholders": { + "requestedAmount": { + "type": "String" + } + } } } diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index b5659c649..782134324 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1758,5 +1758,8 @@ "type": "int" } } - } + }, + "bondPolicyMismatchTitle": "Este bond no coincide con la política del nodo", + "bondPolicyNotAdvertisedBody": "Este nodo no publicó una política de bond, así que no hay nada que esta solicitud pueda estar siguiendo. No se pagará.", + "bondAmountMismatchBody": "El bond pide {requestedAmount} sats, que no es lo que la política publicada de este nodo da para esta orden. No se pagará." } diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index a12456c7d..f7ef249b9 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1783,5 +1783,8 @@ "type": "int" } } - } + }, + "bondPolicyMismatchTitle": "Cette caution ne correspond pas à la politique du nœud", + "bondPolicyNotAdvertisedBody": "Ce nœud n’a publié aucune politique de caution, il n’y a donc rien que cette demande puisse suivre. Elle ne sera pas payée.", + "bondAmountMismatchBody": "La caution demande {requestedAmount} sats, ce qui ne correspond pas à ce que la politique publiée de ce nœud donne pour cet ordre. Elle ne sera pas payée." } diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 2c072da57..3bdb63e18 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1824,5 +1824,8 @@ "type": "int" } } - } + }, + "bondPolicyMismatchTitle": "Questo bond non corrisponde alla policy del nodo", + "bondPolicyNotAdvertisedBody": "Questo nodo non ha pubblicato una policy sui bond, quindi non c’è nulla che questa richiesta possa seguire. Non verrà pagato.", + "bondAmountMismatchBody": "Il bond chiede {requestedAmount} sats, che non è quanto la policy pubblicata di questo nodo prevede per questo ordine. Non verrà pagato." } diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 63ff0b5f5..71b14b8ff 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1828,5 +1828,8 @@ "type": "int" } } - } + }, + "bondPolicyMismatchTitle": "Este bond não corresponde à política do nó", + "bondPolicyNotAdvertisedBody": "Este nó não publicou uma política de bond, portanto não há nada que esta solicitação possa estar a seguir. Não será pago.", + "bondAmountMismatchBody": "O bond pede {requestedAmount} sats, que não é o que a política publicada deste nó gera para esta ordem. Não será pago." } diff --git a/lib/shared/utils/bond_amounts.dart b/lib/shared/utils/bond_amounts.dart new file mode 100644 index 000000000..be1418614 --- /dev/null +++ b/lib/shared/utils/bond_amounts.dart @@ -0,0 +1,86 @@ +/// Why a bond the node asked for cannot be reconciled with what it advertised. +enum BondProblem { + /// The node never advertised that it charges bonds, so there is no policy + /// this request could be following. + notAdvertised, + + /// The bond is smaller than the advertised floor, which the node's own + /// computation can never produce. + belowFloor, + + /// The bond is not the figure the advertised percentage yields for this + /// order. + wrongAmount, +} + +/// Holds a bond request against the policy the node published. +/// +/// The bond parameters live in the node's kind-38385 info event, and the node +/// sizes every bond from them: +/// +/// ```rust +/// // app/bond/math.rs — compute_bond_amount +/// bond_amount = max(round(amount_pct * order_amount_sats), base_amount_sats) +/// ``` +/// +/// Nothing gated an inbound `pay-bond-invoice` on any of it, so the figure was +/// whatever the message said — a small trade could be met with an arbitrarily +/// large bond. +class BondAmounts { + const BondAmounts._(); + + /// The bond the node's own formula yields for an order of [orderAmountSats]. + /// + /// Mirrors the daemon: the percentage is rounded to the nearest satoshi and + /// the floor is a floor, so a tiny order never yields a trivial bond. An + /// unresolved order amount yields the floor, which is what the daemon + /// returns for a non-positive notional. + static int expectedFor({ + required int orderAmountSats, + required double amountPct, + required int baseAmountSats, + }) { + final base = baseAmountSats > 0 ? baseAmountSats : 0; + if (orderAmountSats <= 0 || amountPct <= 0 || !amountPct.isFinite) { + return base; + } + + final pct = (orderAmountSats * amountPct).round(); + return pct > base ? pct : base; + } + + /// What stops [requestedSats] from being a bond this node would have asked + /// for, or null when nothing does. + /// + /// [orderAmountSats] is the sats this trade is for, or null when the client + /// does not have it — a market-priced range order is sized from the taker's + /// own quote, which the node computes and the client never sees. Only the + /// floor can be checked then, and that is said rather than assumed: the + /// alternative is refusing bonds that are perfectly correct. + static BondProblem? problemWith({ + required int? requestedSats, + required int? orderAmountSats, + required bool advertised, + required double? amountPct, + required int? baseAmountSats, + }) { + if (!advertised || amountPct == null || baseAmountSats == null) { + return BondProblem.notAdvertised; + } + if (requestedSats == null || requestedSats <= 0) { + return BondProblem.wrongAmount; + } + + final floor = baseAmountSats > 0 ? baseAmountSats : 0; + if (requestedSats < floor) return BondProblem.belowFloor; + + if (orderAmountSats == null || orderAmountSats <= 0) return null; + + final expected = expectedFor( + orderAmountSats: orderAmountSats, + amountPct: amountPct, + baseAmountSats: baseAmountSats, + ); + return requestedSats == expected ? null : BondProblem.wrongAmount; + } +} diff --git a/test/shared/utils/bond_amounts_test.dart b/test/shared/utils/bond_amounts_test.dart new file mode 100644 index 000000000..d5d897ff3 --- /dev/null +++ b/test/shared/utils/bond_amounts_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/shared/utils/bond_amounts.dart'; + +void main() { + // A published policy: 2% of the order, never below 1000 sats. + const pct = 0.02; + const base = 1000; + + group('BondAmounts.expectedFor', () { + test('takes the advertised percentage of the order', () { + expect( + BondAmounts.expectedFor( + orderAmountSats: 500000, amountPct: pct, baseAmountSats: base), + 10000, + ); + }); + + test('never goes below the advertised floor', () { + // 2% of 10000 is 200, which the floor lifts to 1000, so a tiny order + // never yields a trivial bond. + expect( + BondAmounts.expectedFor( + orderAmountSats: 10000, amountPct: pct, baseAmountSats: base), + base, + ); + }); + + test('rounds to the nearest satoshi, as mostrod does', () { + // 0.02 × 12525 = 250.5 + expect( + BondAmounts.expectedFor( + orderAmountSats: 12525, amountPct: pct, baseAmountSats: 0), + 251, + ); + }); + + test('is the floor when the order amount is not resolved', () { + expect( + BondAmounts.expectedFor( + orderAmountSats: 0, amountPct: pct, baseAmountSats: base), + base, + ); + }); + + test('is the floor when the node charges no percentage', () { + expect( + BondAmounts.expectedFor( + orderAmountSats: 500000, amountPct: 0, baseAmountSats: base), + base, + ); + }); + }); + + group('BondAmounts.problemWith', () { + BondProblem? check({ + int? requested = 10000, + int? orderAmount = 500000, + bool advertised = true, + double? amountPct = pct, + int? baseAmountSats = base, + }) => + BondAmounts.problemWith( + requestedSats: requested, + orderAmountSats: orderAmount, + advertised: advertised, + amountPct: amountPct, + baseAmountSats: baseAmountSats, + ); + + test('accepts the figure the published policy yields', () { + expect(check(), isNull); + }); + + // The finding's scenario: a small trade met with an arbitrarily large + // bond, because nothing related the two. + test('refuses a bond out of all proportion to the order', () { + expect(check(requested: 10000000), BondProblem.wrongAmount); + }); + + test('refuses a bond below the advertised floor', () { + expect( + check(requested: 10, orderAmount: 0), + BondProblem.belowFloor, + ); + }); + + test('refuses a request from a node that never advertised bonds', () { + expect(check(advertised: false), BondProblem.notAdvertised); + expect(check(amountPct: null), BondProblem.notAdvertised); + expect(check(baseAmountSats: null), BondProblem.notAdvertised); + }); + + test('refuses a request with no amount at all', () { + expect(check(requested: null), BondProblem.wrongAmount); + expect(check(requested: 0), BondProblem.wrongAmount); + }); + + // A market-priced range order is sized from the taker's own quote, which + // the node computes and never sends. Refusing there would refuse bonds + // that are perfectly correct. + test('checks only the floor when the order amount is unknown', () { + expect(check(requested: base, orderAmount: null), isNull); + expect(check(requested: 999999, orderAmount: null), isNull); + expect(check(requested: base - 1, orderAmount: null), + BondProblem.belowFloor); + }); + + test('the floor alone still holds when the order amount is zero', () { + expect(check(requested: base, orderAmount: 0), isNull); + }); + }); +}