Skip to content

Order action state machine — pure logic (steps 1 + 2) - #4

Merged
gitchadd merged 7 commits into
mainfrom
feat/order-action-state-machine
May 15, 2026
Merged

gitchadd merged 7 commits into
mainfrom
feat/order-action-state-machine

Conversation

@gitchadd

@gitchadd gitchadd commented May 15, 2026 •

Copy link
Copy Markdown
Collaborator

Smart per-row layout for `` — status, conditional action, support button with chat-active vs chat-new differentiation. Default behavior changes; existing visual layout is preserved via opt-in.

What's in

Three layers per row:

Layer Trigger Render
A · Status text always `Placed · awaiting merchant`, `Paid · awaiting merchant completion`, `Completed · dispute opens in 12m`, `Dispute under review`, …
B · Action button only when actionable now `Resume order` or `Raise dispute · h m left`
C · Support button always `Continue support` (chat-active) / `Get help` (chat-new) / `View dispute` / `View resolution`

`` gains a new `actionMode: "chat" | "smart"` prop. Default flips to `"smart"` — existing embedders get the new layout next time they update. Old behavior is preserved via `actionMode="chat"`.

Decisions locked

  1. `actionMode="smart"` default.
  2. Resume button suppressed when no `onResumeOrder` callback is wired (status line still informs the user).
  3. Optimistic flip on tx broadcast (hash known, receipt pending). Snap-back via next chain poll if revert.
  4. Multicall3 hook (`useOrderStates`) ships as a building block at the pinned address `0xcA11bde05977b3631167028862bE2a173976CA11`. PaymentHistoryWithSupport's smart mode uses `PaymentHistory`'s existing subgraph fetch + a 1s tick instead — subgraph lag is negligible against `placedAt`-driven windows.
  5. No emoji glyphs anywhere in rendered widget output.

New exports on `@p2pdotme/widgets/support`

  • `OrderAction` / `OrderActionProps` — per-row composition of the three layers.
  • `RaiseDisputeStep` / `RaiseDisputeStepProps` / `RaiseDisputeSigner` — 2-step confirm → form modal, encodes `raiseDispute(orderId, redactTransId)`, broadcasts via the embedder's tx signer.
  • `computeOrderAction` / `formatRemaining` / `OrderActionState` — pure predicate + countdown formatter (also usable by embedders building their own layout).
  • `useOrderStates` / `MULTICALL3_ADDRESS` — building block for embedders that need fresh on-chain reads (eg. dashboards that don't render `PaymentHistory`).

Surface changes

  • `SupportProps.chatState?: "active" | "new"` — when `disputeStatus="none"`, drives `"Continue support"` vs `"Get help"` label.
  • `DIAMOND_ABI` extends with `raiseDispute(uint256,uint256)` so the browser can encode the tx.

Test plan

  • `npm test` — 126 tests pass (69 node:test on the pure predicate + formatter; 57 vitest on Support / RaiseDisputeStep / OrderAction / useOrderStates / PaymentHistoryWithSupport including a new launcher chat-state matrix).
  • `npm run typecheck` clean.
  • `npm run build` clean — `dist/support` exports the new surface.
  • Manual: drop the new `OrderAction` into the p2p-checkout demo merchant-app, walk through a BUY ACCEPTED row (Resume button) → PAID 15m-24h row (Raise dispute · countdown) → submit dispute → see the row flip optimistically → confirm chain refetch keeps it flipped (step 7 in the plan).

What's NOT in this PR

  • Step 7: demo route in p2p-checkout/merchant-app — coming next.
  • Step 8: opt-in from lotpot + merchant-app-client — follow-up PRs in each consumer repo.

gitchadd added 7 commits May 15, 2026 08:51
Pure state machine that drives the smart per-row layout for
<PaymentHistoryWithSupport>. Maps (Order, now) → three independent
outputs:

  - statusText   : informational line always rendered
  - action       : action button variant (none | resume | raise-dispute)
                   with remaining ms for countdown rendering
  - disputeState : chain's view of the dispute lifecycle

Dispute windows match the on-chain enforcement in
contracts-v4/contracts/facets/OrderProcessorFacet.sol#raiseDispute:
BUY/PAID is disputable [15m, 24h] after placedAt; SELL or PAY/COMPLETED
is disputable [30m, 7d] after placedAt. Dispute lifecycle short-circuits
status flow — a raised or settled dispute is always the most relevant
state.

`now` is injected for deterministic tests and to support a one-time
clock-skew correction at hook init (chain block.timestamp vs browser
Date.now()), wired in step 3.

Inline countdown text in statusText (eg "Paid · dispute opens in 8m")
reuses formatRemaining which lives in the same module — compact
representation optimised for narrow action buttons: <60s as "<n>s",
<60m as "<n>m", <24h as "<h>h <m>m", else "<d>d <h>h". Clamps to "0s"
on non-positive / non-finite input.

No UI in this commit. Steps 3+ build on top.

Tests (node:test): 17 cases on order-action covering BUY/SELL/PAY ×
status × dispute states × elapsed boundaries; 10 cases on
format-remaining covering each format boundary + clamp behaviour. Full
widget test suite: 99/99 (70 pure + 29 vitest).
Steps 3-6 of the smart action stack. Three layers per row:

  A — Status text (always rendered)
  B — Action button: Resume order / Raise dispute · countdown
                     (suppressed when not actionable now)
  C — Support button: dispute-open / dispute-resolved / chat-active /
                       chat-new

PaymentHistoryWithSupport gains actionMode: "chat" | "smart" defaulting
to "smart". Legacy "chat" preserves the prior single-button layout.

New artifacts:

  src/widgets/OrderAction.tsx      composes the three layers per row,
                                    1s tick for countdowns, optimistic
                                    flip on dispute broadcast
  src/widgets/RaiseDisputeStep.tsx confirm → form → submitting → done /
                                    error state machine, encodes the
                                    raiseDispute(orderId, redactTransId)
                                    payload, fires onSubmitted on tx
                                    broadcast (hash known, receipt
                                    pending) for snappy optimistic UI
  src/hooks/useOrderStates.ts      multicall3-batched on-chain reads +
                                    1s sub-tick when any row's
                                    countdown is under 60s; shipped as
                                    a building block for embedders
                                    without a PaymentHistory feed
  src/core/order-action.ts         pure (Order, now) → state predicate
                                    + formatRemaining(ms); merged into
                                    one file so node:test resolves
                                    without an experimental TS loader
                                    for cross-file imports

Support widget gains chatState: "active" | "new" prop. With
disputeStatus="none" the launcher label adapts:
  - chatState="active" → "Continue support" + green pip
  - chatState="new"    → "Get help"
Dispute variants are unchanged: "View dispute" / "View resolution".

DIAMOND_ABI extended with raiseDispute(uint256,uint256) so
RaiseDisputeStep can encode the tx in the browser.

Tests:
  node:test  69 cases (computeOrderAction + formatRemaining)
  vitest     57 cases (Support / RaiseDisputeStep / OrderAction /
              useOrderStates / PaymentHistoryWithSupport / Support
              launcher chat-state matrix)

Backward compat:
  - actionMode default flips to "smart". Existing tests opt back into
    "chat" to keep asserting legacy visuals.
  - SupportProps.chatState is optional; absent → "new" → "Get help".

V1 decisions (locked):
  - smart default; existing embedders see the new layout next update
  - Resume button suppressed when onResumeOrder callback is absent
  - Optimistic flip on tx broadcast; snap back on next poll if revert
  - Multicall3 hook ships but is unused by PaymentHistoryWithSupport's
    smart mode (PaymentHistory's subgraph fetch suffices; multicall is
    a building block for embedders without it). Multicall address
    pinned to 0xcA11bde05977b3631167028862bE2a173976CA11.
When `actionMode="smart"` and `support.onResumeOrder` is not explicitly
set, reuse the embedder's existing `onResume` prop (already passed for
the legacy chat-mode Resume button). Makes the smart-mode flip a
zero-config upgrade for embedders like p2p-checkout/merchant-app that
already wire `onResume={handleResume}` against PaymentHistory.

`support.onResumeOrder` still wins when present — explicit override for
embedders that want different resume semantics in smart mode.
Vocabulary + visual rethink for the smart per-row layout.

Per-row presentation is now ONE Contact Support affordance with chain
state driving the rendering:

  inside the review window, no dispute filed:
    outline chip + draining doughnut + countdown
    click → ReportProblemStep modal

  dispute raised:
    plain button with red dot
    click → chat

  dispute resolved:
    plain button with green dot
    click → chat (read-only thread)

  otherwise (pre-window / post-window / non-disputable):
    not rendered. Chat is gated on the dispute being on chain per the
    protocol design.

User-facing copy avoids "dispute" everywhere: "Contact Support",
"review opens in", "review window closed", "Under review",
"Resolved", "Report submitted", "Could not submit report".

Internal renames:
  - RaiseDisputeStep → ReportProblemStep (file + component + props)
  - action.kind "raise-dispute" → "report-problem"
  - support.onDisputeRaised → support.onReportSubmitted
  - new export: ContactSupport (the row affordance wrapper)

Action variant gains a `filled` field (0.0..1.0) for the doughnut, set
on entry to the disputable window at 1.0 and draining to 0.0 at close.
The 1s tick inside OrderAction recomputes both `remainingMs` and
`filled` so the ring updates without an extra timer.

Bridge typing trade-off: the dispute-raised dot ramp lives in the
plain-button variant; the in-window chip relies solely on the ring
colour + countdown — no dot, the ring is the indicator.

Bridge tests still pass (52 vitest + 69 node:test). Two paths were
renamed:
  test/RaiseDisputeStep.test.tsx → test/ReportProblemStep.test.tsx
  Removed mock of <Support> from OrderAction tests, replaced with
  mock of <ContactSupport>.

V1 production-ready. PR #4 superseded prior commit's design.
Three V1-blocking bugs uncovered during live testing.

(1) State machine wrong for BUY orders.
OrderProcessorFacet#raiseDispute rejects BUY/PAID — the contract gates
the dispute path on status=CANCELLED AND paidTimestamp != 0 (the
user paid but the order auto-cancelled before the merchant completed).
My state machine was showing the Contact Support chip on BUY/PAID
rows, which let users submit a tx the chain would always revert.

  - BUY/paid → no action, status "Paid · awaiting merchant completion"
  - BUY/cancelled + paidAt > 0 → review window [15m, 24h] after place,
    status "Cancelled · contact support to recover funds"
  - BUY/cancelled + paidAt == 0 → no action, status "Cancelled"
  - SELL/PAY paths unchanged (status=COMPLETED + [30m, 7d])

(2) White text on white modal in dark-theme hosts.
The Modal portal renders outside the widget root, so theme CSS vars
applied on an ancestor don't reach inside. ReportProblemStep +
ContactSupport's chat-loading / chat-error views now set
background + colour + padding directly from `color.surface` /
`color.text` so the dark-theme merchant-app's white-on-white is fixed.

(3) Contract reverts surfaced as "Execution reverted for an unknown
    reason."
ReportProblemStep now pre-simulates the on-chain raiseDispute via
viem.simulateContract using the diamond ABI (extended with the seven
custom errors thrown by raiseDispute: NotAuthorized, DisputeTimeNotReached,
DisputeTimeExpired, InvalidOrderType, InvalidOrderStatusToRaiseDispute,
CannotRaiseDisputeTwice, DisputeAlreadySettled). Reverts get decoded
into human-readable messages — no gas spent on a doomed tx. New props
`rpcUrl` + `chainId` flow PaymentHistoryWithSupport → OrderAction →
ContactSupport → ReportProblemStep.

Tests:
  node:test  70 cases (BUY paid no-action; cancelled BUY paths with
              paidAt>0 inside/before/after window; cancelled BUY
              paidAt==0 no-action)
  vitest     53 cases (new: pre-flight revert decodes
              DisputeTimeNotReached without invoking the wallet)

Backwards-compat: kind-name "report-problem" unchanged; public exports
unchanged. SDK Order.paidAt fed verbatim from the subgraph layer is
the source of truth for the BUY/cancelled gating.
Per user feedback memory: in widget user-facing surfaces, drop
protocol-internal terms ("merchant", "dispute", "circle admin"). The
widget embeds in third-party apps where end users don't know or care
about the protocol's role names.

Replacements:
  "Placed · awaiting merchant"          → "Placed · matching"
  "Accepted · awaiting merchant payment" → "Accepted · processing payment"
  "Paid · awaiting merchant completion"  → "Paid · processing payment"
  "Merchant paid · awaiting your confirmation"
                                         → "Payment received · confirm to complete"

Confirm-step copy in ReportProblemStep also de-merchantified:
  "the merchant has not completed the order" → "your paid order has not been completed"

Internal symbol names in code (`acceptedMerchant`, `disputeStatus`,
`raiseDispute`) unchanged — the rule applies only to text the end
user reads.

Tests updated; 70 node:test + 53 vitest green.
…ollback

External code-review pass surfaced five concerns. All addressed.

1. **Mainnet footgun: testnet-only default diamond.**
   Old: `DEFAULT_DIAMOND_ADDRESS` hardcoded Base Sepolia. A Base
   mainnet host that forgets the `diamondAddress` prop silently calls
   a non-existent contract.
   New: `resolveDiamondAddress(chainId, override)` looks up by chainId
   and throws with a clear message if the chain isn't registered.
   Legacy export kept as `@deprecated` for back-compat. Used by
   `ReportProblemStep`.

2. **`Promise.all` aborts both reads on a transient `getBlock`
   failure.** Multicall result was being thrown away when only the
   clock-skew refresh failed. Split: multicall is awaited
   unconditionally; `getBlock` is wrapped in its own try/catch and
   leaves `clockSkewMs` at the prior value on error.

3. **`useOrderStates` hardcoded `paidAt: 0n`.** Structurally killed
   the BUY/CANCELLED dispute eligibility predicate (which gates on
   `paidAt > 0n`). Now multicalls `getAdditionalOrderDetails` in
   parallel with `getOrdersById` and populates `paidAt`, `acceptedAt`,
   `fixedFeePaid`, `tipsPaid`, `actualUsdtAmount`, `actualFiatAmount`
   from it. Doubles the contracts per row (2 per orderId) but stays
   one multicall round-trip per refresh.

4. **Optimistic dispute flip never rolled back on revert.** Once
   `setOptimisticDispute(true)` fired we never cleared it. If the tx
   reverted at confirmation the chip permanently showed `View report`
   pointing at chat that didn't exist. New: spawn a
   `waitForTransactionReceipt` against the read RPC; if the receipt
   reports `status !== "success"`, clear the local flag and let the
   chain poll surface the real state. Timeout 60s; on timeout / not
   found also clear (the chain poll will reconcile either way).

5. **`extractRevert` didn't walk viem's `BaseError` chain.** Wallet-
   side reverts surface as `TransactionExecutionError` whose decoded
   `errorName` lives one level deeper than the old probe walked. Use
   viem's `BaseError.walk((e) => e instanceof
   ContractFunctionRevertedError)` first; fall back to the manual
   `.cause` probe for non-viem shapes (tests, raw wallet errors).

Tests added:
  - useOrderStates: getBlock failure no longer erases multicall result.
  - useOrderStates: `paidAt` populated from getAdditionalOrderDetails.
  - useOrderStates partial-failure mock updated for the new 2-call-per-row shape.

70 node:test + 55 vitest still green. Typecheck + build clean.

Pending nits from the review (logged for follow-up, not V1 blockers):
  - N intervals for N rows in `useNowTick` (cosmetic perf).
  - Modal aria-modal duplicated landmarks + no focus trap.
  - `useActiveSupportTickets` deps array uses signer object identity.
  - dead `originApp` prop in ContactSupport.
@gitchadd
gitchadd merged commit 299476f into main May 15, 2026
0 of 2 checks passed
Software-Artist-Aash added a commit that referenced this pull request May 15, 2026
Ships the smart per-row action layout for <PaymentHistoryWithSupport>
(PR #4): pure (Order, now) → state machine, OrderAction +
RaiseDisputeStep + useOrderStates components, and new chatState /
actionMode / txSigner props. All additions are non-breaking; the prior
single-launcher layout is preserved verbatim under actionMode="chat".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
gitchadd added a commit that referenced this pull request Aug 21, 2026
Vocabulary + visual rethink for the smart per-row layout.

Per-row presentation is now ONE Contact Support affordance with chain
state driving the rendering:

  inside the review window, no dispute filed:
    outline chip + draining doughnut + countdown
    click → ReportProblemStep modal

  dispute raised:
    plain button with red dot
    click → chat

  dispute resolved:
    plain button with green dot
    click → chat (read-only thread)

  otherwise (pre-window / post-window / non-disputable):
    not rendered. Chat is gated on the dispute being on chain per the
    protocol design.

User-facing copy avoids "dispute" everywhere: "Contact Support",
"review opens in", "review window closed", "Under review",
"Resolved", "Report submitted", "Could not submit report".

Internal renames:
  - RaiseDisputeStep → ReportProblemStep (file + component + props)
  - action.kind "raise-dispute" → "report-problem"
  - support.onDisputeRaised → support.onReportSubmitted
  - new export: ContactSupport (the row affordance wrapper)

Action variant gains a `filled` field (0.0..1.0) for the doughnut, set
on entry to the disputable window at 1.0 and draining to 0.0 at close.
The 1s tick inside OrderAction recomputes both `remainingMs` and
`filled` so the ring updates without an extra timer.

Bridge typing trade-off: the dispute-raised dot ramp lives in the
plain-button variant; the in-window chip relies solely on the ring
colour + countdown — no dot, the ring is the indicator.

Bridge tests still pass (52 vitest + 69 node:test). Two paths were
renamed:
  test/RaiseDisputeStep.test.tsx → test/ReportProblemStep.test.tsx
  Removed mock of <Support> from OrderAction tests, replaced with
  mock of <ContactSupport>.

V1 production-ready. PR #4 superseded prior commit's design.
vvictor-dev pushed a commit to vvictor-dev/widgets that referenced this pull request Aug 22, 2026
* core: computeOrderAction + formatRemaining (pure logic, no UI)

Pure state machine that drives the smart per-row layout for
<PaymentHistoryWithSupport>. Maps (Order, now) → three independent
outputs:

  - statusText   : informational line always rendered
  - action       : action button variant (none | resume | raise-dispute)
                   with remaining ms for countdown rendering
  - disputeState : chain's view of the dispute lifecycle

Dispute windows match the on-chain enforcement in
contracts-v4/contracts/facets/OrderProcessorFacet.sol#raiseDispute:
BUY/PAID is disputable [15m, 24h] after placedAt; SELL or PAY/COMPLETED
is disputable [30m, 7d] after placedAt. Dispute lifecycle short-circuits
status flow — a raised or settled dispute is always the most relevant
state.

`now` is injected for deterministic tests and to support a one-time
clock-skew correction at hook init (chain block.timestamp vs browser
Date.now()), wired in step 3.

Inline countdown text in statusText (eg "Paid · dispute opens in 8m")
reuses formatRemaining which lives in the same module — compact
representation optimised for narrow action buttons: <60s as "<n>s",
<60m as "<n>m", <24h as "<h>h <m>m", else "<d>d <h>h". Clamps to "0s"
on non-positive / non-finite input.

No UI in this commit. Steps 3+ build on top.

Tests (node:test): 17 cases on order-action covering BUY/SELL/PAY ×
status × dispute states × elapsed boundaries; 10 cases on
format-remaining covering each format boundary + clamp behaviour. Full
widget test suite: 99/99 (70 pure + 29 vitest).

* Smart per-row layout: useOrderStates + OrderAction + RaiseDisputeStep

Steps 3-6 of the smart action stack. Three layers per row:

  A — Status text (always rendered)
  B — Action button: Resume order / Raise dispute · countdown
                     (suppressed when not actionable now)
  C — Support button: dispute-open / dispute-resolved / chat-active /
                       chat-new

PaymentHistoryWithSupport gains actionMode: "chat" | "smart" defaulting
to "smart". Legacy "chat" preserves the prior single-button layout.

New artifacts:

  src/widgets/OrderAction.tsx      composes the three layers per row,
                                    1s tick for countdowns, optimistic
                                    flip on dispute broadcast
  src/widgets/RaiseDisputeStep.tsx confirm → form → submitting → done /
                                    error state machine, encodes the
                                    raiseDispute(orderId, redactTransId)
                                    payload, fires onSubmitted on tx
                                    broadcast (hash known, receipt
                                    pending) for snappy optimistic UI
  src/hooks/useOrderStates.ts      multicall3-batched on-chain reads +
                                    1s sub-tick when any row's
                                    countdown is under 60s; shipped as
                                    a building block for embedders
                                    without a PaymentHistory feed
  src/core/order-action.ts         pure (Order, now) → state predicate
                                    + formatRemaining(ms); merged into
                                    one file so node:test resolves
                                    without an experimental TS loader
                                    for cross-file imports

Support widget gains chatState: "active" | "new" prop. With
disputeStatus="none" the launcher label adapts:
  - chatState="active" → "Continue support" + green pip
  - chatState="new"    → "Get help"
Dispute variants are unchanged: "View dispute" / "View resolution".

DIAMOND_ABI extended with raiseDispute(uint256,uint256) so
RaiseDisputeStep can encode the tx in the browser.

Tests:
  node:test  69 cases (computeOrderAction + formatRemaining)
  vitest     57 cases (Support / RaiseDisputeStep / OrderAction /
              useOrderStates / PaymentHistoryWithSupport / Support
              launcher chat-state matrix)

Backward compat:
  - actionMode default flips to "smart". Existing tests opt back into
    "chat" to keep asserting legacy visuals.
  - SupportProps.chatState is optional; absent → "new" → "Get help".

V1 decisions (locked):
  - smart default; existing embedders see the new layout next update
  - Resume button suppressed when onResumeOrder callback is absent
  - Optimistic flip on tx broadcast; snap back on next poll if revert
  - Multicall3 hook ships but is unused by PaymentHistoryWithSupport's
    smart mode (PaymentHistory's subgraph fetch suffices; multicall is
    a building block for embedders without it). Multicall address
    pinned to 0xcA11bde05977b3631167028862bE2a173976CA11.

* PaymentHistoryWithSupport: smart mode falls back to embedder's onResume

When `actionMode="smart"` and `support.onResumeOrder` is not explicitly
set, reuse the embedder's existing `onResume` prop (already passed for
the legacy chat-mode Resume button). Makes the smart-mode flip a
zero-config upgrade for embedders like p2p-checkout/merchant-app that
already wire `onResume={handleResume}` against PaymentHistory.

`support.onResumeOrder` still wins when present — explicit override for
embedders that want different resume semantics in smart mode.

* Replace separate Raise + Support buttons with one Contact Support chip

Vocabulary + visual rethink for the smart per-row layout.

Per-row presentation is now ONE Contact Support affordance with chain
state driving the rendering:

  inside the review window, no dispute filed:
    outline chip + draining doughnut + countdown
    click → ReportProblemStep modal

  dispute raised:
    plain button with red dot
    click → chat

  dispute resolved:
    plain button with green dot
    click → chat (read-only thread)

  otherwise (pre-window / post-window / non-disputable):
    not rendered. Chat is gated on the dispute being on chain per the
    protocol design.

User-facing copy avoids "dispute" everywhere: "Contact Support",
"review opens in", "review window closed", "Under review",
"Resolved", "Report submitted", "Could not submit report".

Internal renames:
  - RaiseDisputeStep → ReportProblemStep (file + component + props)
  - action.kind "raise-dispute" → "report-problem"
  - support.onDisputeRaised → support.onReportSubmitted
  - new export: ContactSupport (the row affordance wrapper)

Action variant gains a `filled` field (0.0..1.0) for the doughnut, set
on entry to the disputable window at 1.0 and draining to 0.0 at close.
The 1s tick inside OrderAction recomputes both `remainingMs` and
`filled` so the ring updates without an extra timer.

Bridge typing trade-off: the dispute-raised dot ramp lives in the
plain-button variant; the in-window chip relies solely on the ring
colour + countdown — no dot, the ring is the indicator.

Bridge tests still pass (52 vitest + 69 node:test). Two paths were
renamed:
  test/RaiseDisputeStep.test.tsx → test/ReportProblemStep.test.tsx
  Removed mock of <Support> from OrderAction tests, replaced with
  mock of <ContactSupport>.

V1 production-ready. PR p2pdotme#4 superseded prior commit's design.

* Fix BUY dispute eligibility, modal styling, pre-flight revert decode

Three V1-blocking bugs uncovered during live testing.

(1) State machine wrong for BUY orders.
OrderProcessorFacet#raiseDispute rejects BUY/PAID — the contract gates
the dispute path on status=CANCELLED AND paidTimestamp != 0 (the
user paid but the order auto-cancelled before the merchant completed).
My state machine was showing the Contact Support chip on BUY/PAID
rows, which let users submit a tx the chain would always revert.

  - BUY/paid → no action, status "Paid · awaiting merchant completion"
  - BUY/cancelled + paidAt > 0 → review window [15m, 24h] after place,
    status "Cancelled · contact support to recover funds"
  - BUY/cancelled + paidAt == 0 → no action, status "Cancelled"
  - SELL/PAY paths unchanged (status=COMPLETED + [30m, 7d])

(2) White text on white modal in dark-theme hosts.
The Modal portal renders outside the widget root, so theme CSS vars
applied on an ancestor don't reach inside. ReportProblemStep +
ContactSupport's chat-loading / chat-error views now set
background + colour + padding directly from `color.surface` /
`color.text` so the dark-theme merchant-app's white-on-white is fixed.

(3) Contract reverts surfaced as "Execution reverted for an unknown
    reason."
ReportProblemStep now pre-simulates the on-chain raiseDispute via
viem.simulateContract using the diamond ABI (extended with the seven
custom errors thrown by raiseDispute: NotAuthorized, DisputeTimeNotReached,
DisputeTimeExpired, InvalidOrderType, InvalidOrderStatusToRaiseDispute,
CannotRaiseDisputeTwice, DisputeAlreadySettled). Reverts get decoded
into human-readable messages — no gas spent on a doomed tx. New props
`rpcUrl` + `chainId` flow PaymentHistoryWithSupport → OrderAction →
ContactSupport → ReportProblemStep.

Tests:
  node:test  70 cases (BUY paid no-action; cancelled BUY paths with
              paidAt>0 inside/before/after window; cancelled BUY
              paidAt==0 no-action)
  vitest     53 cases (new: pre-flight revert decodes
              DisputeTimeNotReached without invoking the wallet)

Backwards-compat: kind-name "report-problem" unchanged; public exports
unchanged. SDK Order.paidAt fed verbatim from the subgraph layer is
the source of truth for the BUY/cancelled gating.

* copy: drop internal vocab ("merchant") from user-facing status strings

Per user feedback memory: in widget user-facing surfaces, drop
protocol-internal terms ("merchant", "dispute", "circle admin"). The
widget embeds in third-party apps where end users don't know or care
about the protocol's role names.

Replacements:
  "Placed · awaiting merchant"          → "Placed · matching"
  "Accepted · awaiting merchant payment" → "Accepted · processing payment"
  "Paid · awaiting merchant completion"  → "Paid · processing payment"
  "Merchant paid · awaiting your confirmation"
                                         → "Payment received · confirm to complete"

Confirm-step copy in ReportProblemStep also de-merchantified:
  "the merchant has not completed the order" → "your paid order has not been completed"

Internal symbol names in code (`acceptedMerchant`, `disputeStatus`,
`raiseDispute`) unchanged — the rule applies only to text the end
user reads.

Tests updated; 70 node:test + 53 vitest green.

* Fix V1 review blockers — diamond resolver, hook details, optimistic rollback

External code-review pass surfaced five concerns. All addressed.

1. **Mainnet footgun: testnet-only default diamond.**
   Old: `DEFAULT_DIAMOND_ADDRESS` hardcoded Base Sepolia. A Base
   mainnet host that forgets the `diamondAddress` prop silently calls
   a non-existent contract.
   New: `resolveDiamondAddress(chainId, override)` looks up by chainId
   and throws with a clear message if the chain isn't registered.
   Legacy export kept as `@deprecated` for back-compat. Used by
   `ReportProblemStep`.

2. **`Promise.all` aborts both reads on a transient `getBlock`
   failure.** Multicall result was being thrown away when only the
   clock-skew refresh failed. Split: multicall is awaited
   unconditionally; `getBlock` is wrapped in its own try/catch and
   leaves `clockSkewMs` at the prior value on error.

3. **`useOrderStates` hardcoded `paidAt: 0n`.** Structurally killed
   the BUY/CANCELLED dispute eligibility predicate (which gates on
   `paidAt > 0n`). Now multicalls `getAdditionalOrderDetails` in
   parallel with `getOrdersById` and populates `paidAt`, `acceptedAt`,
   `fixedFeePaid`, `tipsPaid`, `actualUsdtAmount`, `actualFiatAmount`
   from it. Doubles the contracts per row (2 per orderId) but stays
   one multicall round-trip per refresh.

4. **Optimistic dispute flip never rolled back on revert.** Once
   `setOptimisticDispute(true)` fired we never cleared it. If the tx
   reverted at confirmation the chip permanently showed `View report`
   pointing at chat that didn't exist. New: spawn a
   `waitForTransactionReceipt` against the read RPC; if the receipt
   reports `status !== "success"`, clear the local flag and let the
   chain poll surface the real state. Timeout 60s; on timeout / not
   found also clear (the chain poll will reconcile either way).

5. **`extractRevert` didn't walk viem's `BaseError` chain.** Wallet-
   side reverts surface as `TransactionExecutionError` whose decoded
   `errorName` lives one level deeper than the old probe walked. Use
   viem's `BaseError.walk((e) => e instanceof
   ContractFunctionRevertedError)` first; fall back to the manual
   `.cause` probe for non-viem shapes (tests, raw wallet errors).

Tests added:
  - useOrderStates: getBlock failure no longer erases multicall result.
  - useOrderStates: `paidAt` populated from getAdditionalOrderDetails.
  - useOrderStates partial-failure mock updated for the new 2-call-per-row shape.

70 node:test + 55 vitest still green. Typecheck + build clean.

Pending nits from the review (logged for follow-up, not V1 blockers):
  - N intervals for N rows in `useNowTick` (cosmetic perf).
  - Modal aria-modal duplicated landmarks + no focus trap.
  - `useActiveSupportTickets` deps array uses signer object identity.
  - dead `originApp` prop in ContactSupport.

---------

Co-authored-by: gitchadd <gitchad@icloud.com>
vvictor-dev pushed a commit to vvictor-dev/widgets that referenced this pull request Aug 22, 2026
Ships the smart per-row action layout for <PaymentHistoryWithSupport>
(PR p2pdotme#4): pure (Order, now) → state machine, OrderAction +
RaiseDisputeStep + useOrderStates components, and new chatState /
actionMode / txSigner props. All additions are non-breaking; the prior
single-launcher layout is preserved verbatim under actionMode="chat".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant