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 43f84db..a262d24 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,30 @@ async function resolveIdentity(): Promise { cachedIdentity = id; 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 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 : DECRYPT_FAILED_SENTINEL }); + } catch { + dispatch({ type: "DECRYPTED_UPI", upi: DECRYPT_FAILED_SENTINEL }); + } +} + import type { CheckoutSigner, CheckoutPhase, PlaceOrderResult, PlaceOrderContext, CurrencyOption, PendingOrderSummary, ScreeningConfig } from "../types"; import { OrderStatus } from "../types"; import { DIAMOND_ABI, readSmallOrderFixedFee } from "./contracts"; @@ -271,9 +296,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 +500,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/src/widgets/Checkout.tsx b/src/widgets/Checkout.tsx index c7a91a5..b90abbb 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,38 @@ 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; +} + +// "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; @@ -223,6 +255,9 @@ 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 payableShown = payableAddressShown(addrView); const stepIndex = state.phase === "completed" ? 3 : state.phase === "paid" ? 2 : state.phase === "accepted" ? 1 : 0; const hasPlaceOrder = Boolean(placeOrder); @@ -596,30 +631,38 @@ 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 + }> + +
)} @@ -630,9 +673,9 @@ export function Checkout(props: CheckoutProps) { )} diff --git a/test/checkoutAddressView.test.ts b/test/checkoutAddressView.test.ts new file mode 100644 index 0000000..d693c6a --- /dev/null +++ b/test/checkoutAddressView.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +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 +// 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); + }); +}); + +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); + }); +}); 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" }); + }); +});