From def67a2f88cb5ca1b683a5f91765208ab07ab507 Mon Sep 17 00:00:00 2001 From: gitchadd Date: Thu, 18 Jun 2026 14:46:41 +0530 Subject: [PATCH 1/5] fix(checkout): guard decrypt against identity-resolution throws resolveIdentity() reads localStorage directly, which throws on corrupt identity JSON or when storage is blocked (sandboxed / partitioned iframe). The poll-loop and resume decrypt sites swallow throws in their own catch{}, so a throw left the accepted screen stuck on "Decrypting..." with no recovery. Extract the shared decrypt into decryptAndDispatch() wrapped in try/catch so any failure (Result-Err or thrown) falls back to the existing "Session changed" sentinel. Adds a regression test mocking the store to throw. verify green: 143 tests, typecheck + build clean. Ported from the archived feat/upi-intent branch (2f8ad58); the underlying issue exists independently on main's decrypt path. --- src/core/order-machine.ts | 27 +++++++++++++++++++++------ test/decryptAndDispatch.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 test/decryptAndDispatch.test.ts diff --git a/src/core/order-machine.ts b/src/core/order-machine.ts index 43f84db..462ca62 100644 --- a/src/core/order-machine.ts +++ b/src/core/order-machine.ts @@ -1,4 +1,5 @@ import { useReducer, useCallback, useEffect, useMemo, useRef } from "react"; +import type { Dispatch } from "react"; import { createPublicClient, http, encodeFunctionData, fromHex, stringToHex } from "viem"; import { baseSepolia, base } from "viem/chains"; import { @@ -20,6 +21,24 @@ async function resolveIdentity(): Promise { cachedIdentity = id; return id; } + +// Decrypt the counterparty VPA and dispatch the outcome. Shared by the poll loop +// and the resume path. resolveIdentity() reads localStorage directly, which can +// THROW (corrupt identity JSON, or storage blocked in a sandboxed / partitioned +// iframe). Both call sites swallow throws in their own catch{}, so without this +// guard a throw would leave the accepted screen stuck on "Decrypting…" forever. +// On any failure (Result-Err or a thrown error) fall back to the "Session +// changed" sentinel so the screen progresses. +export async function decryptAndDispatch(encUpi: string, dispatch: Dispatch) { + try { + const recipientIdentity = await resolveIdentity(); + const result = await decryptPaymentAddress({ encrypted: encUpi, recipientIdentity }); + dispatch({ type: "DECRYPTED_UPI", upi: result.isOk() ? result.value : "Session changed" }); + } catch { + dispatch({ type: "DECRYPTED_UPI", upi: "Session changed" }); + } +} + import type { CheckoutSigner, CheckoutPhase, PlaceOrderResult, PlaceOrderContext, CurrencyOption, PendingOrderSummary, ScreeningConfig } from "../types"; import { OrderStatus } from "../types"; import { DIAMOND_ABI, readSmallOrderFixedFee } from "./contracts"; @@ -271,9 +290,7 @@ export function useOrderMachine(opts: UseOrderMachineOpts) { actualUsdcAmount: actualUsdc, acceptedTimestamp: acceptedTs, }); - const recipientIdentity = await resolveIdentity(); - const result = await decryptPaymentAddress({ encrypted: o.encUpi, recipientIdentity }); - dispatch({ type: "DECRYPTED_UPI", upi: result.isOk() ? result.value : "Session changed" }); + await decryptAndDispatch(o.encUpi, dispatch); } if (status === OrderStatus.PAID && state.phase !== "paid" && state.phase !== "completed") { @@ -477,9 +494,7 @@ export function useOrderMachine(opts: UseOrderMachineOpts) { actualUsdcAmount: actualUsdc, acceptedTimestamp: acceptedTs, }); - const recipientIdentity = await resolveIdentity(); - const dec = await decryptPaymentAddress({ encrypted: o.encUpi, recipientIdentity }); - dispatch({ type: "DECRYPTED_UPI", upi: dec.isOk() ? dec.value : "Session changed" }); + await decryptAndDispatch(o.encUpi, dispatch); if (status === OrderStatus.PAID) dispatch({ type: "PAID" }); if (status === OrderStatus.COMPLETED) { diff --git a/test/decryptAndDispatch.test.ts b/test/decryptAndDispatch.test.ts new file mode 100644 index 0000000..31eea70 --- /dev/null +++ b/test/decryptAndDispatch.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from "vitest"; + +// Mock the SDK so resolveIdentity()'s direct localStorage access THROWS — the +// exact production condition (corrupt identity JSON, or storage blocked in a +// sandboxed / partitioned iframe) that previously stranded the accepted screen +// on "Decrypting…" forever (the throw was swallowed by the call sites' catch{}). +vi.mock("@p2pdotme/sdk/orders", () => ({ + createLocalStorageRelayStore: () => ({ + get: () => { + throw new Error("SecurityError: localStorage is blocked"); + }, + set: async () => {}, + }), + createRelayIdentity: () => ({}), + createOrders: () => ({}), + // Would only be reached if resolveIdentity() succeeded; it won't here. + decryptPaymentAddress: async () => ({ isOk: () => true, value: "x@y" }), +})); + +import { decryptAndDispatch } from "../src/core/order-machine"; + +describe("decryptAndDispatch — throw path", () => { + it("falls back to the Session-changed sentinel when identity resolution throws", async () => { + const dispatch = vi.fn(); + await decryptAndDispatch("enc-upi-blob", dispatch); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: "DECRYPTED_UPI", upi: "Session changed" }); + }); +}); From 1d9c88d60b93a99557e2dda01ad542c5590db282 Mon Sep 17 00:00:00 2001 From: gitchadd Date: Thu, 18 Jun 2026 15:36:02 +0530 Subject: [PATCH 2/5] fix(checkout): render decrypt-failure state + client-side INR QR + shared sentinel Consolidates the decrypt-failure hardening (supersedes PRs #43 + #45) and applies the dual-review follow-ups: - Shared DECRYPT_FAILED_SENTINEL constant in order-machine, used by the helper and by Checkout, replacing the cross-file "Session changed" magic string. - Checkout shows "Couldn't load payment details. Please contact support." for the failure sentinel (any currency) instead of rendering it as a copyable/QR-able VPA. - INR QR gated via showInrQr() on a real "@" VPA (restores the dropped guard) + a known amount; replaces the third-party api.qrserver.com with a lazy client-side qrcode.react QR, closing the VPA+amount data leak. - Extracts pure paymentAddressView()/showInrQr() helpers + a unit test, giving the decrypt-failure branch real coverage (Checkout had no render harness). Closes #44. verify green: typecheck + build + 150 tests; no api.qrserver.com in the runtime bundle; sentinel centralized to one constant. --- package-lock.json | 40 +++++++++++++----------- package.json | 3 +- src/core/order-machine.ts | 14 ++++++--- src/widgets/Checkout.tsx | 52 +++++++++++++++++++++++++------- test/checkoutAddressView.test.ts | 37 +++++++++++++++++++++++ 5 files changed, 113 insertions(+), 33 deletions(-) create mode 100644 test/checkoutAddressView.test.ts diff --git a/package-lock.json b/package-lock.json index f658c35..7807894 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.4.0", "license": "MIT", "dependencies": { - "@p2pdotme/sdk": "^1.1.6" + "@p2pdotme/sdk": "^1.1.6", + "qrcode.react": "^4.2.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.0", @@ -66,6 +67,7 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -81,6 +83,7 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -183,7 +186,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -207,7 +209,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1299,7 +1300,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/estree": { "version": "1.0.8", @@ -1321,7 +1323,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1333,7 +1334,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -1515,6 +1515,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -1525,6 +1526,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -1747,7 +1749,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/entities": { "version": "6.0.1", @@ -1776,7 +1779,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1991,7 +1993,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -2088,6 +2089,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -2266,7 +2268,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2316,7 +2317,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2375,6 +2375,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -2394,12 +2395,20 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2413,7 +2422,6 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2427,7 +2435,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/readdirp": { "version": "4.1.2", @@ -2839,7 +2848,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2892,7 +2900,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3563,7 +3570,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 42dda61..439853e 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,8 @@ "viem": ">=2" }, "dependencies": { - "@p2pdotme/sdk": "^1.1.6" + "@p2pdotme/sdk": "^1.1.6", + "qrcode.react": "^4.2.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.0", diff --git a/src/core/order-machine.ts b/src/core/order-machine.ts index 462ca62..a262d24 100644 --- a/src/core/order-machine.ts +++ b/src/core/order-machine.ts @@ -22,20 +22,26 @@ async function resolveIdentity(): Promise { return id; } +// Sentinel value for `decryptedUpi` when the counterparty VPA can't be decrypted +// (a Result-Err, or a thrown identity-resolution error). The accepted screen keys +// on this EXACT value to show an error instead of a fake VPA, so producer (here) +// and consumer (Checkout.tsx) share this constant rather than a bare literal. +export const DECRYPT_FAILED_SENTINEL = "Session changed"; + // Decrypt the counterparty VPA and dispatch the outcome. Shared by the poll loop // and the resume path. resolveIdentity() reads localStorage directly, which can // THROW (corrupt identity JSON, or storage blocked in a sandboxed / partitioned // iframe). Both call sites swallow throws in their own catch{}, so without this // guard a throw would leave the accepted screen stuck on "Decrypting…" forever. -// On any failure (Result-Err or a thrown error) fall back to the "Session -// changed" sentinel so the screen progresses. +// On any failure (Result-Err or a thrown error) fall back to the sentinel so the +// screen progresses (Checkout then renders the failure state). export async function decryptAndDispatch(encUpi: string, dispatch: Dispatch) { try { const recipientIdentity = await resolveIdentity(); const result = await decryptPaymentAddress({ encrypted: encUpi, recipientIdentity }); - dispatch({ type: "DECRYPTED_UPI", upi: result.isOk() ? result.value : "Session changed" }); + dispatch({ type: "DECRYPTED_UPI", upi: result.isOk() ? result.value : DECRYPT_FAILED_SENTINEL }); } catch { - dispatch({ type: "DECRYPTED_UPI", upi: "Session changed" }); + dispatch({ type: "DECRYPTED_UPI", upi: DECRYPT_FAILED_SENTINEL }); } } diff --git a/src/widgets/Checkout.tsx b/src/widgets/Checkout.tsx index c7a91a5..59a74d3 100644 --- a/src/widgets/Checkout.tsx +++ b/src/widgets/Checkout.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import { formatUnits } from "viem"; import type { CheckoutProps } from "../types"; -import { useOrderMachine } from "../core/order-machine"; +import { useOrderMachine, DECRYPT_FAILED_SENTINEL } from "../core/order-machine"; import { resolveCurrencyMeta } from "../core/currency-meta"; import { CurrencyRow } from "../ui/CurrencyRow"; import { DEFAULT_DIAMOND_ADDRESS, USDC_DECIMALS } from "../core/contracts"; @@ -12,6 +12,31 @@ import { CopyRow, Stepper, CountdownRing, Skeleton, injectKeyframes, } from "../ui/components"; +// Client-side UPI QR. Replaces the third-party api.qrserver.com image, which +// leaked the counterparty VPA + amount + order id to a third party on every INR +// accepted screen. Lazy so qrcode.react stays out of the main /checkout chunk. +const LazyQR = React.lazy(() => import("qrcode.react").then((m) => ({ default: m.QRCodeSVG }))); + +// Which payment-address view the accepted screen shows. Pure + exported so the +// branch logic (incl. the decrypt-failure case) is unit-testable without a full +// Checkout render. "error" wins over everything, so the failure sentinel can +// never render as a copyable / QR-able address. +export function paymentAddressView( + decryptedUpi: string | null, + hasCompound: boolean, +): "error" | "compound" | "address" | "decrypting" { + if (decryptedUpi === DECRYPT_FAILED_SENTINEL) return "error"; + if (hasCompound) return "compound"; + if (decryptedUpi) return "address"; + return "decrypting"; +} + +// Whether to render the INR pay QR: a resolved real VPA (contains "@", so never +// the failure sentinel) on an INR order with a known amount. +export function showInrQr(decryptedUpi: string | null, currency: string, hasAmount: boolean): boolean { + return !!decryptedUpi && decryptedUpi.includes("@") && currency === "INR" && hasAmount; +} + // Window the user has to pay after a merchant accepts before auto-cancellation. // Mirrors user-app's 5-minute window. const AUTO_CANCEL_WINDOW_MS = 5 * 60 * 1000; @@ -223,6 +248,8 @@ export function Checkout(props: CheckoutProps) { })(); const compoundFields = acceptedMeta?.compoundFields ?? null; const compoundParts = state.decryptedUpi && compoundFields ? state.decryptedUpi.split("|") : []; + const addrView = paymentAddressView(state.decryptedUpi, !!compoundFields); + const showQr = showInrQr(state.decryptedUpi, state.currency, !!fiatDisplay); const stepIndex = state.phase === "completed" ? 3 : state.phase === "paid" ? 2 : state.phase === "accepted" ? 1 : 0; const hasPlaceOrder = Boolean(placeOrder); @@ -596,30 +623,33 @@ export function Checkout(props: CheckoutProps) { Order #{state.orderId}
- {compoundFields ? ( + {addrView === "error" ? ( +

Couldn't load payment details. Please contact support.

+ ) : addrView === "compound" ? (
- {compoundFields.map((field, i) => ( + {compoundFields!.map((field, i) => (

{field.label}

copy(compoundParts[i], field.key)} />
))}
- ) : state.decryptedUpi ? ( - copy(state.decryptedUpi!, "upi")} /> + ) : addrView === "address" ? ( + copy(state.decryptedUpi!, "upi")} /> ) : (

Decrypting payment details…

)}
{/* QR is INR-only — mirrors user-app behavior. Mercado - Pago / PIX QRs require PSP-generated payloads that - the widget can't synthesize from the bare address. */} - {state.decryptedUpi && state.currency === "INR" && ( + Pago / PIX QRs require PSP-generated payloads that the + widget can't synthesize from the bare address. Client-side + (qrcode.react), gated (showInrQr) on a resolved VPA + amount. */} + {showQr && (
- QR + }> + +
)} diff --git a/test/checkoutAddressView.test.ts b/test/checkoutAddressView.test.ts new file mode 100644 index 0000000..58e214f --- /dev/null +++ b/test/checkoutAddressView.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { paymentAddressView, showInrQr } from "../src/widgets/Checkout"; +import { DECRYPT_FAILED_SENTINEL } from "../src/core/order-machine"; + +// Covers the accepted-screen decrypt-failure rendering decision without a full +// Checkout render: the sentinel must surface an error, never a copyable/QR-able +// address, and the INR QR must require a real "@" VPA (so the sentinel can't be +// QR-encoded as a fake payee). +describe("paymentAddressView", () => { + it("decrypt-failure sentinel -> error, and error wins over compound", () => { + expect(paymentAddressView(DECRYPT_FAILED_SENTINEL, false)).toBe("error"); + expect(paymentAddressView(DECRYPT_FAILED_SENTINEL, true)).toBe("error"); + }); + it("compound currency -> compound", () => { + expect(paymentAddressView("acct|bank", true)).toBe("compound"); + }); + it("resolved VPA -> address", () => { + expect(paymentAddressView("merchant@okhdfc", false)).toBe("address"); + }); + it("not yet decrypted -> decrypting", () => { + expect(paymentAddressView(null, false)).toBe("decrypting"); + }); +}); + +describe("showInrQr", () => { + it("true only for a real VPA on INR with a known amount", () => { + expect(showInrQr("merchant@okhdfc", "INR", true)).toBe(true); + }); + it("false for the failure sentinel (no @)", () => { + expect(showInrQr(DECRYPT_FAILED_SENTINEL, "INR", true)).toBe(false); + }); + it("false for non-INR / missing amount / null", () => { + expect(showInrQr("merchant@okhdfc", "BRL", true)).toBe(false); + expect(showInrQr("merchant@okhdfc", "INR", false)).toBe(false); + expect(showInrQr(null, "INR", true)).toBe(false); + }); +}); From 9fb5e5579aa7e516ef9725707f51465a7abfb5a4 Mon Sep 17 00:00:00 2001 From: gitchadd Date: Thu, 18 Jun 2026 17:40:22 +0530 Subject: [PATCH 3/5] fix(checkout): match decrypt-error to the widget's error-box treatment Per the e2e design critique: render the decrypt-failure message in the existing dangerSoft error box (matching state.error) with a warning icon, instead of bare red text. Pure visual consistency on the error branch; no logic/flow change. --- src/widgets/Checkout.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/widgets/Checkout.tsx b/src/widgets/Checkout.tsx index 59a74d3..9f894ad 100644 --- a/src/widgets/Checkout.tsx +++ b/src/widgets/Checkout.tsx @@ -624,7 +624,12 @@ export function Checkout(props: CheckoutProps) {
{addrView === "error" ? ( -

Couldn't load payment details. Please contact support.

+
+ + Couldn't load payment details. Please contact support. +
) : addrView === "compound" ? (
{compoundFields!.map((field, i) => ( From c41c2277277046b98cb804bb438bda1edeb5affc Mon Sep 17 00:00:00 2001 From: gitchadd Date: Thu, 18 Jun 2026 18:03:40 +0530 Subject: [PATCH 4/5] fix(checkout): disable "I've paid" on decrypt failure When the counterparty VPA can't be decrypted (addrView === "error") the user has no payment details and can't have paid, so disable + dim the "I've paid" button; the remaining actions are Contact support (per the message) and Cancel order. --- src/widgets/Checkout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widgets/Checkout.tsx b/src/widgets/Checkout.tsx index 9f894ad..30bc9f8 100644 --- a/src/widgets/Checkout.tsx +++ b/src/widgets/Checkout.tsx @@ -665,9 +665,9 @@ export function Checkout(props: CheckoutProps) { )} From e19eaa2ee3fa962333951fa91fb40f4ddaf21e29 Mon Sep 17 00:00:00 2001 From: gitchadd Date: Thu, 18 Jun 2026 18:18:25 +0530 Subject: [PATCH 5/5] fix(checkout): also disable "I've paid" while decrypting (not just on failure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the guard via the unit-tested payableAddressShown() helper: "I've paid" is actionable only once a payable address (or compound fields) is on screen, so it is disabled during both the transient "Decrypting..." state and a decrypt failure — the user can't mark-paid before any VPA is shown. Closes the pre-existing decrypting-state gap flagged in the final review. --- src/widgets/Checkout.tsx | 12 ++++++++++-- test/checkoutAddressView.test.ts | 13 ++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/widgets/Checkout.tsx b/src/widgets/Checkout.tsx index 30bc9f8..b90abbb 100644 --- a/src/widgets/Checkout.tsx +++ b/src/widgets/Checkout.tsx @@ -37,6 +37,13 @@ export function showInrQr(decryptedUpi: string | null, currency: string, hasAmou return !!decryptedUpi && decryptedUpi.includes("@") && currency === "INR" && hasAmount; } +// "I've paid" is actionable only once a payable address (or compound fields) is +// on screen — never while still decrypting or on a decrypt failure, since the +// user can't have paid an address they were never shown. +export function payableAddressShown(view: ReturnType): boolean { + return view === "address" || view === "compound"; +} + // Window the user has to pay after a merchant accepts before auto-cancellation. // Mirrors user-app's 5-minute window. const AUTO_CANCEL_WINDOW_MS = 5 * 60 * 1000; @@ -250,6 +257,7 @@ export function Checkout(props: CheckoutProps) { const compoundParts = state.decryptedUpi && compoundFields ? state.decryptedUpi.split("|") : []; const addrView = paymentAddressView(state.decryptedUpi, !!compoundFields); const showQr = showInrQr(state.decryptedUpi, state.currency, !!fiatDisplay); + const payableShown = payableAddressShown(addrView); const stepIndex = state.phase === "completed" ? 3 : state.phase === "paid" ? 2 : state.phase === "accepted" ? 1 : 0; const hasPlaceOrder = Boolean(placeOrder); @@ -665,9 +673,9 @@ export function Checkout(props: CheckoutProps) { )} diff --git a/test/checkoutAddressView.test.ts b/test/checkoutAddressView.test.ts index 58e214f..d693c6a 100644 --- a/test/checkoutAddressView.test.ts +++ b/test/checkoutAddressView.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { paymentAddressView, showInrQr } from "../src/widgets/Checkout"; +import { paymentAddressView, showInrQr, payableAddressShown } from "../src/widgets/Checkout"; import { DECRYPT_FAILED_SENTINEL } from "../src/core/order-machine"; // Covers the accepted-screen decrypt-failure rendering decision without a full @@ -35,3 +35,14 @@ describe("showInrQr", () => { expect(showInrQr(null, "INR", true)).toBe(false); }); }); + +describe("payableAddressShown (drives the 'I've paid' enable)", () => { + it("true once a payable address/compound is shown", () => { + expect(payableAddressShown("address")).toBe(true); + expect(payableAddressShown("compound")).toBe(true); + }); + it("false while decrypting or on a decrypt failure (I've paid disabled)", () => { + expect(payableAddressShown("decrypting")).toBe(false); + expect(payableAddressShown("error")).toBe(false); + }); +});