Conversation
tradeStatusProvider reads the order book first, and the book holds the order's public view. The trade screen showed that view as the trade's own status, so a take left Canceled by the old optimistic cancel (#434) read as the user's pending order once the daemon put the order back in the book, with a Cancel the daemon refuses (IsNotYourOrder). The My Trades list already kept an ended row's status, so the list said cancelled and the screen said pending. Both now go through one rule, shownTradeStatus. The row wins when it has ended, and when it is a take and the book says pending: a public pending means nobody holds the order, which also covers a take parked at WaitingTakerBond, whose order is still pending in public. Every other live status still wins over an open row. In the trade screen the rule applies wherever the live status was read: the status, the post-cancel decision, the cancel dialog's copy, and the step nudge, so a book change the row outranks is not a step.
The take screen sends a user who already takes part in the order to the trade instead of offering to take it again, and it counted any trade row on the order as taking part. A take left Canceled by the old optimistic cancel (#434) therefore held the order forever: once the daemon put it back in the book, opening it landed on that dead trade, and the take button did the same, so the user could never take it again. tradeRoleLookupProvider now answers through participatingRole: a take whose row has ended does not count. Once its order can be taken again, such a row can only be what a take that never went active left behind, since an order whose trade truly ended never goes back to Pending, and take_order already replaces it with the new take's row. A maker's row always counts, and so does any row still open. Every row of the order is read, because a database from before takes replaced their order's earlier row can hold two.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
grunch
left a comment
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Merge conflict with main — CI has not run on this PR. mergeable: CONFLICTING. GitHub does not run pull_request workflows on a head it cannot merge, so the only check reported is CodeRabbit (skipped). The PR's flutter analyze / flutter test claims are local-only until this is rebased.
The textual conflict is confined to test/features/order/screens/take_order_screen_test.dart: since this branch's base (68ab52b), main added bond-policy overrides to _pump (mostroNodeProvider, bondEstimateProvider, a node / bondEstimate parameter pair) and new bond-notice tests in the same group this PR extends. Keep both sides: the router harness and trades parameter from here, the node overrides from main. trade_rows_provider.dart, trade_detail_screen.dart, trade_rows_provider_test.dart and fake_trades.dart auto-merge, but please re-run the suites after rebasing — main's rows provider now also reads the claim store, and TradeDetailScreen._cancelOrder gained a maker-bond branch keyed on the status this PR reshapes.
🧹 Nitpick / verified-OK notes (3)
isTerminalTradeStatusvs the removed_terminal— verified identical (the same 7 statuses), so the My Trades list's ended-row behaviour is unchanged by the refactor. ✅- Interaction with the bond flow now on
main—shownTradeStatusis correct for both bond windows: a taker atWaitingTakerBondwhose order is publiclypendingkeeps the row's status (previously the trade screen showedpending, so this PR also fixes that); a maker atWaitingMakerBondhas no book entry, so the live status already falls back to the row, and once the bond locks the book'spending(maker,isTake: false) wins as it should. ✅trade_action_listener.dart:59still callsorders_api.getTradeRoledirectly — that bridge call isget_trade_by_order_id(LIMIT 1, noORDER BY), the same "one arbitrary row" lookupparticipatingRolewas written to avoid. Not a regression of this PR (#419 makes duplicate rows unlikely going forward), but worth a follow-up so the two role lookups agree on pre-#419 databases.
📜 Review details
Reviewed commit: c0aa804 (2 commits: 897bbba, c0aa804)
Files reviewed: 9 — trade_state_provider.dart, trade_rows_provider.dart, trade_detail_screen.dart, specs/004…/orders.md, and the 5 test / support files.
Profile: strict
Walkthrough
| Area | Change |
|---|---|
trade_state_provider.dart |
New shownTradeStatus (row vs live), participatingRole / isEndedTake; tradeRoleLookupProvider now scans listTrades() instead of getTradeRole; _isTerminal → public isTerminalTradeStatus. |
trade_rows_provider.dart |
Drops its private _terminal set and delegates to shownTradeStatus. |
trade_detail_screen.dart |
Status, cancel-dialog copy, post-cancel decision and haptic nudge all go through _shown(live, row). |
take_order_screen_test.dart |
Harness moves under a GoRouter so the participant redirect is observable; two new tests. |
specs/004…/orders.md |
Documents both rules. |
| if (!live.hasValue) return TradeStatus.loading; | ||
| final status = tradeStatusFromOrderStatus(live.value!); | ||
| final trade = ref.watch(tradeInfoProvider(widget.orderId)).valueOrNull; | ||
| final status = tradeStatusFromOrderStatus(_shown(live.value!, trade)); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major
The leftover take can still flash pending with Cancel while the row is loading — the exact bug this PR fixes.
_status() resolves as soon as tradeStatusProvider has a value, but trade comes from tradeInfoProvider, which awaits rawTradesProvider (a full listTrades() round trip). While that future is pending, _shown(live, null) returns the live status unchanged — the book's public pending.
Nothing else holds the screen in loading during that window: build() gates on _isBuyer(), which resolves from tradeRoleProvider or tradeRoleFromDbProvider (the indexed getTradeRole), independently of rawTradesProvider. For a leftover Canceled take opened cold (from a notification, the chat header, or after a restart), the role typically arrives first, so for one or more frames the screen renders TradeStatus.pending → TradeView offers trade.cancel → a tap publishes the cancel the daemon answers with CantDo(IsNotYourOrder) and the local cancel removes the live order from the book (#434, third bullet).
The same null-row fallback also applies to the haptic listener (L470): a pending → in-progress book change that lands before the row loads still nudges.
The tests don't catch this because _pumpRoutedTradeDetail resolves loadTrades immediately.
Suggested fix: don't let a live pending through until the row is known.
TradeStatus _status() {
final live = ref.watch(tradeStatusProvider(widget.orderId));
// …
if (!live.hasValue) return TradeStatus.loading;
final tradeAsync = ref.watch(tradeInfoProvider(widget.orderId));
// A public `pending` may be a take's leftover (#434): only the row can
// say, so hold `loading` rather than offer a Cancel the daemon refuses.
if (live.value == OrderStatus.pending && tradeAsync.isLoading) {
return TradeStatus.loading;
}
final status = tradeStatusFromOrderStatus(
_shown(live.value!, tradeAsync.valueOrNull),
);
// …
}and add a widget test where loadTrades completes after the status stream has emitted pending, asserting _cancelButton() is never found.
🤖 Prompt for AI Agents
In lib/features/trades/screens/trade_detail_screen.dart, _status() (around line 429): when the
live status is OrderStatus.pending and tradeInfoProvider(widget.orderId) is still loading, return
TradeStatus.loading instead of passing a null row to _shown(). Apply the same guard to the haptic
listener (around line 470): skip the nudge while the row is unresolved. Add a widget test in
test/features/trades/trade_detail_screen_test.dart where the status stream emits pending before
loadTrades completes with a Canceled take row, and assert the Cancel button never appears.
| (ref) => (orderId) => orders_api.getTradeRole(orderId: orderId), | ||
| (ref) => | ||
| (orderId) async => | ||
| participatingRole(await orders_api.listTrades(), orderId), |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major
The role lookup now throws on a storage error; before it returned null. The take screen doesn't handle that.
The old implementation, orders_api.getTradeRole, never failed: get_trade_role logs a DB error and returns Ok(None). orders_api.listTrades() propagates it (db.list_trades().await?), so this closure can now complete with an error.
Both call sites in take_order_screen.dart assume it can't:
initStatecalls_redirectIfParticipant()unawaited with nocatch, so the error surfaces as an unhandled async exception (reported to the zone / Crashlytics in release)._onTakeOrderawaits it beforesetState(() => _cta = TakeOrderCta.loading)with notry. The future throws,_ctastaysidle, and the Take button silently does nothing — no snackbar, no retry hint.
Suggested fix: keep the old contract at the seam (an unreadable store means "not a known participant", as getTradeRole did), and log it.
final tradeRoleLookupProvider = Provider<Future<TradeRole?> Function(String)>(
(ref) => (orderId) async {
try {
return participatingRole(await orders_api.listTrades(), orderId);
} catch (e, st) {
debugPrint('[tradeRoleLookup] listTrades failed for $orderId: $e\n$st');
return null;
}
},
);Please add a take-screen test with a throwing lookup that asserts the take is still dispatched (or, if you prefer failing closed, that an error snackbar appears — but not a dead button).
🤖 Prompt for AI Agents
In lib/features/order/providers/trade_state_provider.dart, tradeRoleLookupProvider (line ~48):
wrap the listTrades() call in try/catch, log the error, and return null so the lookup keeps the
never-throws contract getTradeRole had. Add a test in
test/features/order/screens/take_order_screen_test.dart overriding tradeRoleLookupProvider with a
throwing function, and assert tapping "Take order" still dispatches takeOrderActionProvider.
| /// Every row for the order is read, not just one: a database from before | ||
| /// takes replaced their order's earlier row can hold two, and a live one | ||
| /// among them still makes the user a participant. | ||
| TradeRole? participatingRole(Iterable<TradeInfo> trades, String orderId) { |
There was a problem hiding this comment.
⚡ Performance | 🟡 Minor
Every take-screen lookup now deserialises the user's whole trade history — twice per take.
tradeRoleLookupProvider runs on initState and again on the Take tap. list_trades reads every trades row, serde_json-decodes each blob, sorts them, and FRB copies the full Vec<TradeInfo> into Dart, just to filter one order.id. get_trade_role hit the idx_trades_order_id expression index instead. For a long-lived account (hundreds of closed trades, each with bond / peer snapshots) that is a noticeable delay right when the user taps Take.
Not blocking for the fix, but since the rule lives in Dart only because Rust has no "all rows for an order" query, consider a narrow bridge call instead (e.g. list_trades_for_order(order_id) using the existing json_extract(data, '$.order.id') = ? index) and keep participatingRole as the pure rule over its result.
| }), | ||
| tradeRoleLookupProvider.overrideWithValue((_) async => null), | ||
| tradeRoleLookupProvider.overrideWithValue( | ||
| (orderId) async => participatingRole(trades, orderId), |
There was a problem hiding this comment.
🧪 Test Coverage | 🟡 Minor
This override re-implements the production provider, so its wiring is untested.
The harness replaces tradeRoleLookupProvider with participatingRole(trades, orderId). The production body (participatingRole(await orders_api.listTrades(), orderId)) is never executed by any test, so a regression back to getTradeRole, a wrong argument order, or the error path from the comment above would all keep these tests green.
Consider putting the trades read behind its own seam (e.g. a tradeRowsLookupProvider = Provider<Future<List<TradeInfo>> Function()>), have tradeRoleLookupProvider compose it, and override that here, so the real composition — including the error handling — is what the take-screen tests exercise.
Closes #434.
Builds before #419 marked a take
Canceledas soon as its cancel was published, even when the trade never went active. The daemon then put the order back in the book aspending, but the row stayed. #419 stops writing those rows; this PR fixes how the ones already on disk are shown and handled:pendingas the trade's status. The user saw their own pending order, with a Cancel the daemon refuses (IsNotYourOrder). Since feat(trades,chat): redesign the trades and chat tabs (handoff 11a/11b) #436 the My Trades list keeps an ended row's status, so the list said cancelled while the screen said pending.No Rust change:
take_orderalready accepts this case (it only needs the order to bependingin the book), andpersist_confirmed_takereplaces every earlier row of the order.Changes
1.
fix(trades): a public pending is never a take's statusOne rule,
shownTradeStatus(trade_state_provider.dart), now decides what the My Trades list andTradeDetailScreenshow. The trade row wins over the live status (tradeStatusProvider, which reads the order book first) in two cases:pending. A publicpendingmeans nobody holds the order, so it is never a take's status. This also covers a take parked atWaitingTakerBond, whose order is stillpendingin public.Every other live status still wins over an open row. In the trade screen the rule applies everywhere the live status was read: the status itself, the post-cancel decision, the cancel dialog's copy, and the haptic nudge on a step change (so a book change the row outranks does not vibrate).
2.
fix(order): a take that already ended no longer blocks taking the ordertradeRoleLookupProvider, which only the take screen uses, now answers throughparticipatingRole:Pendingin mostrod, so once the order can be taken again, such a row can only be a leftover of a take that never went active.take_order_screen.dartitself is untouched.Known trade-off
With the first rule, a cooperative cancel requested on an active trade still marks the row
Canceledright away (the remaining optimistic write inapply_local_cancel). If the peer never agrees and the trade completes, both the list and the screen show it as cancelled. The list already behaved this way since #436. The fix belongs to that optimistic write, and it will be tracked separately.Tests
trade_state_provider_test.dart:shownTradeStatusover every combination of row status, live status and take/maker;participatingRoleandisEndedTake: no row, open takes, ended takes, maker rows, and two rows on the same order.trade_rows_provider_test.dart: a take keeps its own status over a publicpending; a maker's order still follows the book.trade_detail_screen_test.dart:Canceledtake reads cancelled, without Cancel, over a publicpending;pending;pending;take_order_screen_test.dart: an open take lands on its trade; an ended take leaves the order takeable. The test harness now runs under a router, so the redirect is observable.With each rule disabled, the tests that cover it fail.
flutter analyzeis clean andflutter testpasses (1042 tests).Manual test
Against a local mostrod, with the same database for both builds:
40ccde9, in a separate worktree): took and cancelled two orders while they were still waiting. Both rows stayedCanceled(skip replayed Canceled … already Canceled) while the daemon republished the orders aspending.Canceledin the database;superseded by a retake);IsNotYourOrderwas sent from an old key.Notes
feat/bond-1b-pay-screenandfeat/bond-1c-gate-fliptouchtake_order_screen.dartandtrade_detail_screen.dartin other sections. Whichever lands second may need a small merge.Canceledon an active cooperative cancel;Canceledrows of takes that never went active.