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
47 changes: 41 additions & 6 deletions lib/data/models/order.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
);
}

Expand Down Expand Up @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions lib/data/models/restore_response.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,18 @@ class RestoredOrder {
});

factory RestoredOrder.fromJson(Map<String, dynamic> 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,
);
}
Expand Down Expand Up @@ -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?,
Expand Down
8 changes: 6 additions & 2 deletions lib/data/models/session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions lib/features/key_manager/key_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,15 @@ class KeyManager {
}

NostrKeyPairs deriveTradeKeyPair(int index) {
_requireTradeKeyIndex(index);
final tradePrivateHex = _derivator.derivePrivateKey(_masterKeyHex!, index);

return NostrKeyPairs(private: tradePrivateHex);
}

/// Derive a trade key for a specific index
Future<NostrKeyPairs> deriveTradeKeyFromIndex(int index) async {
_requireTradeKeyIndex(index);
final masterKeyHex = await _storage.readMasterKey();
if (masterKeyHex == null) {
throw MasterKeyNotFoundException(
Expand Down Expand Up @@ -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<void> setCurrentKeyIndex(int index) async {
if (index < 1) {
throw InvalidTradeKeyIndexException(
Expand Down
115 changes: 79 additions & 36 deletions lib/features/order/models/order_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

Expand Down Expand Up @@ -136,12 +135,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
Expand Down Expand Up @@ -188,7 +189,7 @@ class OrderState {

// Handle dispute status updates based on action
Dispute? updatedDispute = message.getPayload<Dispute>() ?? dispute;

// If we got a dispute from the message payload, ensure it has the message timestamp
// This is critical for correct sorting in the dispute list
if (updatedDispute != null && message.getPayload<Dispute>() != null) {
Expand All @@ -199,24 +200,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;
Expand All @@ -227,33 +231,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 &&
Expand All @@ -276,19 +287,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<Order>()
: message.payload is PaymentRequest
? message.getPayload<PaymentRequest>()!.order
: order,
order: _orderAfter(message, isBondPayoutAck: isBondPayoutAck),
paymentRequest: newPaymentRequest,
cantDo: message.getPayload<CantDo>() ?? cantDo,
dispute: updatedDispute,
Expand All @@ -302,12 +308,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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the order baseline for payment requests

When a user takes an existing order, AbstractMostroNotifier starts with order: null, the outbound take carries an Amount or PaymentRequest rather than an Order, and the first node response can itself be a pay-invoice or pay-bond-invoice PaymentRequest. This return therefore leaves the tracked order null: the invoice screen renders fiat as 0 with no currency, while bond validation receives a null orderAmountSats and deliberately performs only the floor check, allowing an arbitrarily oversized bond. Seed the state from the public order/local take before discarding the embedded payment order.

Useful? React with 👍 / 👎.


final incoming = message.getPayload<Order>();
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) {
Expand All @@ -323,7 +368,7 @@ class OrderState {
// Actions that should set status to waiting-buyer-invoice
case Action.waitingBuyerInvoice:
return Status.waitingBuyerInvoice;

case Action.addInvoice:
// If current status is paymentFailed, maintain it for UI consistency
// Otherwise, transition to waitingBuyerInvoice for normal flow
Expand All @@ -332,7 +377,6 @@ class OrderState {
}
return Status.waitingBuyerInvoice;


// FIX: Cuando alguien toma una orden, debe cambiar el status inmediatamente

case Action.takeBuy:
Expand Down Expand Up @@ -421,7 +465,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;
Expand Down
Loading
Loading