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
40 changes: 23 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 27 additions & 6 deletions src/core/order-machine.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -20,6 +21,30 @@ async function resolveIdentity(): Promise<RelayIdentity> {
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<OrderAction>) {
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";
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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) {
Expand Down
69 changes: 56 additions & 13 deletions src/widgets/Checkout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<typeof paymentAddressView>): 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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -596,30 +631,38 @@ export function Checkout(props: CheckoutProps) {
<span style={S.faint}>Order #{state.orderId}</span>
</div>
<div style={{ marginTop: 12 }}>
{compoundFields ? (
{addrView === "error" ? (
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, padding: "10px 12px", background: color.dangerSoft, border: `1px solid ${color.danger}22`, borderRadius: radius.md, color: color.danger, fontSize: font.md }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" style={{ flexShrink: 0, marginTop: 1 }}>
<path d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>Couldn't load payment details. Please contact support.</span>
</div>
) : addrView === "compound" ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{compoundFields.map((field, i) => (
{compoundFields!.map((field, i) => (
<div key={field.key}>
<p style={{ ...S.label, marginBottom: 4 }}>{field.label}</p>
<CopyRow value={compoundParts[i] ?? "…"} copied={copied === field.key} onCopy={() => copy(compoundParts[i], field.key)} />
</div>
))}
</div>
) : state.decryptedUpi ? (
<CopyRow value={state.decryptedUpi} copied={copied === "upi"} onCopy={() => copy(state.decryptedUpi!, "upi")} />
) : addrView === "address" ? (
<CopyRow value={state.decryptedUpi!} copied={copied === "upi"} onCopy={() => copy(state.decryptedUpi!, "upi")} />
) : (
<p style={S.muted}>Decrypting payment details…</p>
)}
</div>
{/* 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 && (
<div style={{ display: "flex", justifyContent: "center", marginTop: 16 }}>
<div style={{ padding: 12, background: "#fff", borderRadius: radius.md, border: `1px solid ${color.border}` }}>
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=0&data=${encodeURIComponent(
`upi://pay?pa=${state.decryptedUpi}&am=${fiatDisplay}&cu=INR&tr=${state.orderId}`
)}`} alt="QR" style={{ width: 180, height: 180, display: "block" }} />
<React.Suspense fallback={<div style={{ width: 180, height: 180 }} />}>
<LazyQR value={`upi://pay?pa=${state.decryptedUpi}&am=${fiatDisplay}&cu=INR&tr=${state.orderId}`} size={180} level="L" />
</React.Suspense>
</div>
</div>
)}
Expand All @@ -630,9 +673,9 @@ export function Checkout(props: CheckoutProps) {
)}

<button
style={{ ...S.primaryBtn, marginTop: 20, opacity: isMarkingPaid || timerExpired ? 0.5 : 1, cursor: timerExpired ? "not-allowed" : "pointer" }}
style={{ ...S.primaryBtn, marginTop: 20, opacity: isMarkingPaid || timerExpired || !payableShown ? 0.5 : 1, cursor: timerExpired || !payableShown ? "not-allowed" : "pointer" }}
onClick={handleMarkPaid}
disabled={isMarkingPaid || timerExpired || isCancelling}
disabled={isMarkingPaid || timerExpired || isCancelling || !payableShown}
>
{timerExpired ? "Payment window expired" : isMarkingPaid ? "Confirming…" : "I've paid"}
</button>
Expand Down
Loading