diff --git a/components/grid-wallet-prod/src/app/api/grid/allowlist.test.ts b/components/grid-wallet-prod/src/app/api/grid/allowlist.test.ts index 29a47d1e..ca31373c 100644 --- a/components/grid-wallet-prod/src/app/api/grid/allowlist.test.ts +++ b/components/grid-wallet-prod/src/app/api/grid/allowlist.test.ts @@ -8,13 +8,13 @@ describe('proxy allow-list', () => { expect(isAllowed('POST', '/auth/credentials/AuthMethod:abc/challenge')).toBe(true); expect(isAllowed('POST', '/auth/credentials/AuthMethod:abc/verify')).toBe(true); expect(isAllowed('GET', '/customers/internal-accounts')).toBe(true); - expect(isAllowed('GET', '/platform/internal-accounts')).toBe(true); expect(isAllowed('POST', '/customers/external-accounts')).toBe(true); expect(isAllowed('GET', '/transactions')).toBe(true); expect(isAllowed('GET', '/transactions/Transaction:abc')).toBe(true); expect(isAllowed('POST', '/quotes')).toBe(true); expect(isAllowed('POST', '/quotes/Quote:abc/execute')).toBe(true); - expect(isAllowed('POST', '/sandbox/internal-accounts/InternalAccount:abc/fund')).toBe(true); + expect(isAllowed('GET', '/customers/external-accounts')).toBe(true); + expect(isAllowed('POST', '/sandbox/send')).toBe(true); }); it('rejects everything else (incl. cards + wrong method)', () => { expect(isAllowed('POST', '/cards')).toBe(false); @@ -22,6 +22,11 @@ describe('proxy allow-list', () => { expect(isAllowed('POST', '/transactions/Transaction:abc')).toBe(false); // GET only expect(isAllowed('GET', '/quotes')).toBe(false); // POST only expect(isAllowed('POST', '/customers')).toBe(false); + // Dropped with the platform on-ramp: the demo funds via a real-time funding + // quote + /sandbox/send, so the proxy no longer needs to reach either of these + // with the platform's own credentials. + expect(isAllowed('GET', '/platform/internal-accounts')).toBe(false); + expect(isAllowed('POST', '/sandbox/internal-accounts/InternalAccount:abc/fund')).toBe(false); }); it('redacts Authorization only', () => { const r = redactHeaders({ Authorization: 'Basic secret', 'Request-Id': 'Request:1' }); diff --git a/components/grid-wallet-prod/src/app/api/grid/allowlist.ts b/components/grid-wallet-prod/src/app/api/grid/allowlist.ts index 5df3625d..61457bbd 100644 --- a/components/grid-wallet-prod/src/app/api/grid/allowlist.ts +++ b/components/grid-wallet-prod/src/app/api/grid/allowlist.ts @@ -1,6 +1,6 @@ /** Pure helpers for the Grid proxy — no Next runtime, unit-tested directly. */ -const ID = '[^/?]+'; // a path segment (e.g. AuthMethod:..., Quote:..., InternalAccount:...) +const ID = '[^/?]+'; // a path segment (e.g. AuthMethod:..., Quote:..., Transaction:...) export const GRID_ALLOWLIST: { method: string; pattern: RegExp }[] = [ { method: 'GET', pattern: new RegExp('^/auth/credentials$') }, @@ -8,14 +8,12 @@ export const GRID_ALLOWLIST: { method: string; pattern: RegExp }[] = [ { method: 'POST', pattern: new RegExp(`^/auth/credentials/${ID}/challenge$`) }, { method: 'POST', pattern: new RegExp(`^/auth/credentials/${ID}/verify$`) }, { method: 'GET', pattern: new RegExp('^/customers/internal-accounts$') }, - { method: 'GET', pattern: new RegExp('^/platform/internal-accounts$') }, { method: 'GET', pattern: new RegExp('^/customers/external-accounts$') }, { method: 'POST', pattern: new RegExp('^/customers/external-accounts$') }, { method: 'GET', pattern: new RegExp('^/transactions$') }, { method: 'GET', pattern: new RegExp(`^/transactions/${ID}$`) }, { method: 'POST', pattern: new RegExp('^/quotes$') }, { method: 'POST', pattern: new RegExp(`^/quotes/${ID}/execute$`) }, - { method: 'POST', pattern: new RegExp(`^/sandbox/internal-accounts/${ID}/fund$`) }, { method: 'POST', pattern: new RegExp('^/sandbox/send$') }, ]; diff --git a/components/grid-wallet-prod/src/app/page.tsx b/components/grid-wallet-prod/src/app/page.tsx index 405dedd0..585a601d 100644 --- a/components/grid-wallet-prod/src/app/page.tsx +++ b/components/grid-wallet-prod/src/app/page.tsx @@ -61,6 +61,7 @@ export default function Page() { depositInstructions={logic.depositInstructions} totalCents={logic.totalCents} walletToast={logic.walletToast} + transferOutcome={logic.transferOutcome} storedBanks={logic.storedBanks} onSelectStoredBank={logic.onSelectStoredBank} onDepositView={logic.onDepositView} diff --git a/components/grid-wallet-prod/src/apps/SignInFlow.tsx b/components/grid-wallet-prod/src/apps/SignInFlow.tsx index 1143072f..8ca73fa0 100644 --- a/components/grid-wallet-prod/src/apps/SignInFlow.tsx +++ b/components/grid-wallet-prod/src/apps/SignInFlow.tsx @@ -92,6 +92,8 @@ interface SignInFlowProps { totalCents?: number; /** One-shot toast raised by an arrival webhook (nonce bumps per delivery). */ walletToast?: { nonce: number; text: string } | null; + /** Terminal outcome of the pending outbound transfer (poll or webhook). */ + transferOutcome?: { nonce: number; ok: boolean } | null; /** Accounts Grid already holds, for the saved-banks list. */ storedBanks?: SavedBank[]; /** A stored account was picked — quote against that ExternalAccount id. */ @@ -130,6 +132,7 @@ interface WalletHostProps { depositInstructions?: DepositInstructions | null; totalCents?: number; walletToast?: { nonce: number; text: string } | null; + transferOutcome?: { nonce: number; ok: boolean } | null; storedBanks?: SavedBank[]; onSelectStoredBank?: (externalAccountId: string | null) => void; onDepositView?: (view: { label: string; currency: string; cents?: number } | null) => void; @@ -162,6 +165,7 @@ function WalletHost({ depositInstructions, totalCents, walletToast, + transferOutcome, storedBanks, onSelectStoredBank, onDepositView, @@ -252,6 +256,15 @@ function WalletHost({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [onDetails, detailsCountry, onCryptoAddress, cryptoChain, cryptoCents, onDepositView]); + // The pending outbound transfer reached a terminal status: settle its row. + const lastOutcome = useRef(0); + useEffect(() => { + if (!transferOutcome || transferOutcome.nonce === lastOutcome.current) return; + lastOutcome.current = transferOutcome.nonce; + home.settleTransfer(transferOutcome.ok); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [transferOutcome]); + // An arrival webhook landed: show it on the phone (the brain owns the toast). const lastToast = useRef(0); useEffect(() => { @@ -321,6 +334,7 @@ export function SignInFlow({ depositInstructions, totalCents, walletToast, + transferOutcome, storedBanks, onSelectStoredBank, onDepositView, @@ -446,6 +460,7 @@ export function SignInFlow({ depositInstructions={depositInstructions} totalCents={totalCents} walletToast={walletToast} + transferOutcome={transferOutcome} storedBanks={storedBanks} onSelectStoredBank={onSelectStoredBank} onDepositView={onDepositView} diff --git a/components/grid-wallet-prod/src/apps/aurora/wallet/AddMoneySheet.tsx b/components/grid-wallet-prod/src/apps/aurora/wallet/AddMoneySheet.tsx index 676f3bcf..3ecaeaf6 100644 --- a/components/grid-wallet-prod/src/apps/aurora/wallet/AddMoneySheet.tsx +++ b/components/grid-wallet-prod/src/apps/aurora/wallet/AddMoneySheet.tsx @@ -37,7 +37,7 @@ import { DEPOSIT_CHAINS, SEND_NETWORKS, DEFAULT_SEND_NETWORK, - accountLast4, + accountLabel, fieldLabel, type MoneySheet, type Step, @@ -467,7 +467,7 @@ export function AddMoneySheet({ {selectedBank - ? `${selectedBank.bankName} (•••• ${accountLast4(selectedBank.values)})` + ? `${selectedBank.bankName} (${accountLabel(selectedBank.values)})` : 'Bank account'} {localCurrency} bank account @@ -551,7 +551,7 @@ export function AddMoneySheet({ {selectedBank.beneficiary} - {selectedBank.bankName} •••• {accountLast4(selectedBank.values)} + {selectedBank.bankName} {accountLabel(selectedBank.values)} @@ -1191,14 +1191,14 @@ export function AddMoneySheet({ <> {b.beneficiary} - {b.bankName} •••• {accountLast4(b.values)} + {b.bankName} {accountLabel(b.values)} {currencyFor(b.country)} ) : ( <> - {b.bankName} (•••• {accountLast4(b.values)}) + {b.bankName} ({accountLabel(b.values)}) {b.country.name} {currencyFor(b.country)} diff --git a/components/grid-wallet-prod/src/apps/shared/wallet/accountLabel.test.ts b/components/grid-wallet-prod/src/apps/shared/wallet/accountLabel.test.ts new file mode 100644 index 00000000..8aecf07f --- /dev/null +++ b/components/grid-wallet-prod/src/apps/shared/wallet/accountLabel.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { accountLabel } from './moneySheet'; + +describe('accountLabel', () => { + // A masked tail only means something for a number. + it('masks a numeric account', () => { + expect(accountLabel({ accountNumber: '3095972310', routingNumber: '758139375' })).toBe( + '•••• 9375', + ); + }); + it('masks an IBAN', () => { + expect(accountLabel({ iban: 'DE89370400440532013000' })).toBe('•••• 3000'); + }); + + // The bug this fixes: a UPI id rendered as "•••• @upi". + it('shows a UPI handle whole', () => { + expect(accountLabel({ vpa: 'pat@upi', bankName: 'HDFC Bank' })).toBe('pat@upi'); + }); + it('shows a PIX key whole', () => { + expect(accountLabel({ pixKey: 'pat@example.com', taxId: '12345678901' })).toBe( + 'pat@example.com', + ); + }); + // A numeric PIX key (CPF) is a number, so it masks like one. + it('masks a numeric PIX key', () => { + expect(accountLabel({ pixKey: '12345678901', pixKeyType: 'CPF' })).toBe('•••• 8901'); + }); + it('falls back when there is nothing to show', () => { + expect(accountLabel({})).toBe('account'); + }); +}); diff --git a/components/grid-wallet-prod/src/apps/shared/wallet/activity.ts b/components/grid-wallet-prod/src/apps/shared/wallet/activity.ts index d72e9be4..54a59592 100644 --- a/components/grid-wallet-prod/src/apps/shared/wallet/activity.ts +++ b/components/grid-wallet-prod/src/apps/shared/wallet/activity.ts @@ -78,7 +78,7 @@ export function makeTransferRow( }; } const flag = dest ? `/assets/flags/${dest.countryCode}.svg` : '/assets/flags/us.svg'; - const bankLabel = dest ? `${dest.bankName} (•••• ${dest.last4})` : 'Bank account'; + const bankLabel = dest ? `${dest.bankName} (${dest.accountLabel})` : 'Bank account'; if (mode === 'send') { // Sent to a person → contact avatar (initials + flag); only a destination-less // edge case falls back to the flag tile. diff --git a/components/grid-wallet-prod/src/apps/shared/wallet/index.ts b/components/grid-wallet-prod/src/apps/shared/wallet/index.ts index d6ba52f6..7d4e9cfb 100644 --- a/components/grid-wallet-prod/src/apps/shared/wallet/index.ts +++ b/components/grid-wallet-prod/src/apps/shared/wallet/index.ts @@ -35,6 +35,7 @@ export { DEFAULT_SEND_NETWORK, BANK_COUNTRIES, DEMO_BENEFICIARY, + accountLabel, accountLast4, sampleValuesFor, firstNameLastInitial, diff --git a/components/grid-wallet-prod/src/apps/shared/wallet/moneySheet.ts b/components/grid-wallet-prod/src/apps/shared/wallet/moneySheet.ts index d6bed405..2aa35650 100644 --- a/components/grid-wallet-prod/src/apps/shared/wallet/moneySheet.ts +++ b/components/grid-wallet-prod/src/apps/shared/wallet/moneySheet.ts @@ -93,6 +93,20 @@ export function accountLast4(values: Record): string { return digits.slice(-4) || raw.slice(-4); } +/** + * How to name an account in a row. A masked tail only means something for a + * NUMBER — UPI (`name@upi`), PIX keys and phone-based rails are handles, and + * masking them produced labels like "•••• @upi". Handles are shown whole (they + * aren't secret; the payer types them by hand), numbers keep the mask. + */ +export function accountLabel(values: Record): string { + const HANDLE_FIELDS = ['vpa', 'pixKey', 'phoneNumber']; + const handle = HANDLE_FIELDS.map((k) => values[k]).find((v) => v && /[^0-9\s-]/.test(v)); + if (handle) return handle; + const digits = Object.values(values).join('').replace(/\D/g, ''); + return digits ? `•••• ${digits.slice(-4)}` : 'account'; +} + /** * A Grid-stored external account → a saved-bank row. `id` IS the ExternalAccount * id, so selecting one of these quotes against the real account instead of diff --git a/components/grid-wallet-prod/src/apps/shared/wallet/types.ts b/components/grid-wallet-prod/src/apps/shared/wallet/types.ts index cb8178b5..9b2c604a 100644 --- a/components/grid-wallet-prod/src/apps/shared/wallet/types.ts +++ b/components/grid-wallet-prod/src/apps/shared/wallet/types.ts @@ -45,7 +45,15 @@ export type ReceivedPayment = /** What a just-confirmed transfer is going to — lets the Activity row + toast * reflect the real bank/recipient instead of a placeholder. */ export type TransferActivity = - | { kind: 'bank'; countryCode: string; bankName: string; last4: string; recipientName: string } + | { + kind: 'bank'; + countryCode: string; + bankName: string; + /** Display label for the account — "•••• 1234" for a number, the handle + * itself for UPI/PIX (see moneySheet.accountLabel). */ + accountLabel: string; + recipientName: string; + } | { kind: 'crypto'; address: string; network: string; logo: string }; /** Merchant category for a tap-to-pay / transaction row. Each skin's WalletListItem diff --git a/components/grid-wallet-prod/src/apps/shared/wallet/useMoneySheet.ts b/components/grid-wallet-prod/src/apps/shared/wallet/useMoneySheet.ts index 8f81a894..236f7fd6 100644 --- a/components/grid-wallet-prod/src/apps/shared/wallet/useMoneySheet.ts +++ b/components/grid-wallet-prod/src/apps/shared/wallet/useMoneySheet.ts @@ -31,6 +31,7 @@ import { RECEIVE_RAIL, SAVE_MS, USD_TO_EUR, + accountLabel, accountLast4, firstNameLastInitial, formatRate, @@ -490,7 +491,7 @@ export function useMoneySheet({ kind: 'bank', countryCode: selectedBank?.country.code ?? 'us', bankName: selectedBank?.bankName ?? 'Bank account', - last4: selectedBank ? accountLast4(selectedBank.values) : '0000', + accountLabel: selectedBank ? accountLabel(selectedBank.values) : 'account', recipientName: selectedBank?.beneficiary ?? '', }; diff --git a/components/grid-wallet-prod/src/apps/shared/wallet/useWalletHome.ts b/components/grid-wallet-prod/src/apps/shared/wallet/useWalletHome.ts index f5889f13..71d52c3b 100644 --- a/components/grid-wallet-prod/src/apps/shared/wallet/useWalletHome.ts +++ b/components/grid-wallet-prod/src/apps/shared/wallet/useWalletHome.ts @@ -135,6 +135,17 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { const [activity, setActivity] = useState([]); const pendingCents = useRef(0); const pendingActivity = useRef(null); + // The outbound row awaiting a real outcome (see settleTransfer). + const pendingRow = useRef<{ + id: string; + mode: WalletTransferMode; + cents: number; + sentTo: string; + } | null>(null); + // A failure can land BEFORE the row exists: the quote is created when the amount + // is committed, while the row appears on Face ID confirm. Remember it so the + // confirm reports the failure instead of opening a pending row nothing settles. + const failedBeforeConfirm = useRef(false); const availableCents = parseCents(balance) + deltaCents; // `balance` is the host's source of truth (the real book balance). // `deltaCents` is ONLY the optimistic bridge for Add money's real (async) @@ -173,6 +184,7 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { const weeklySpentCents = spendBars.reduce((sum, cents) => sum + cents, 0); const openSheet = (mode: MoneySheetMode) => { + failedBeforeConfirm.current = false; setSheetMode(mode); setSheetOpen(true); }; @@ -202,13 +214,16 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { // WalletListCard instance keeps its own fresh-row bookkeeping — the grow-in // insert still runs per list. Server rows carry Grid transaction ids and this // session's carry local ones, so the id-keyed reveal can't collide. - const homeActivity = useMemo( - () => - [...activity, ...transactions, ...(serverActivity ?? [])].sort( - (a, b) => b.timestamp - a.timestamp, - ), - [activity, transactions, serverActivity], - ); + const homeActivity = useMemo(() => { + // Deduped by id: a local row and a server row for the same transaction would + // otherwise both render (the local one is dropped on settle, but a refresh + // can land first). + const byId = new Map(); + for (const row of [...activity, ...transactions, ...(serverActivity ?? [])]) { + if (!byId.has(row.id)) byId.set(row.id, row); + } + return Array.from(byId.values()).sort((a, b) => b.timestamp - a.timestamp); + }, [activity, transactions, serverActivity]); const isOpen = cardView !== 'closed'; const isIssuance = cardView === 'intro' || cardView === 'creating' || cardView === 'ready'; @@ -484,16 +499,72 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { ? dest.recipientName || dest.bankName : 'recipient'; // 'add' returned above — arrivals are announced by the webhook, not here. + // + // An outbound transfer is SUBMITTED at this point, not done: the quote still + // has to execute and the transaction still has to reach a terminal status + // (~60s in sandbox), and either can fail. So the row goes in as PENDING and + // the toast says submitted; `settleTransfer` below resolves it from the real + // outcome. Claiming "withdrawn from balance" here left a row behind for + // transfers whose quote failed. + // The quote already failed (before this confirm) — say so and add no row. + if (failedBeforeConfirm.current) { + failedBeforeConfirm.current = false; + showToast( + mode === 'withdraw' + ? 'Withdrawal failed — your balance is unchanged' + : 'Payment failed — your balance is unchanged', + ); + return; + } + const pendingId = `pending-${mode}-${Date.now()}`; + pendingRow.current = { id: pendingId, mode, cents, sentTo }; showToast( mode === 'withdraw' - ? `${toastUsd(cents)} withdrawn from balance` - : `${toastUsd(cents)} sent to ${sentTo}`, + ? `${toastUsd(cents)} withdrawal submitted` + : `${toastUsd(cents)} payment submitted`, ); window.clearTimeout(sheetInsertTimer.current); sheetInsertTimer.current = window.setTimeout(() => { - setActivity((prev) => [makeTransferRow(mode, cents, dest), ...prev]); + const row = makeTransferRow(mode, cents, dest); + setActivity((prev) => [{ ...row, id: pendingId, detail: `${row.detail} · Pending` }, ...prev]); }, SHEET_INSERT_DELAY_MS); }; + + /** + * The real outcome of the outbound transfer that is currently pending — driven + * by the host's execute + transaction poll, and by Grid's OUTGOING_PAYMENT + * webhook. Settles the pending row instead of leaving the optimistic one: + * success drops the "Pending" suffix, failure REMOVES the row (nothing left the + * balance) and says so. Idempotent — whichever signal arrives first wins. + */ + const settleTransfer = (ok: boolean) => { + const pending = pendingRow.current; + if (!pending) { + // Nothing pending yet: a quote failed between committing the amount and the + // confirm. Hold it so the confirm reports the failure (see finishTransfer). + if (!ok) failedBeforeConfirm.current = true; + return; + } + pendingRow.current = null; + if (ok) { + // Drop the local row: the host refreshed the ledger before reporting + // success, so Grid's own row for this transaction is already in + // `serverActivity`. Keeping both would show the transfer twice. + setActivity((prev) => prev.filter((r) => r.id !== pending.id)); + showToast( + pending.mode === 'withdraw' + ? `${toastUsd(pending.cents)} withdrawn from balance` + : `${toastUsd(pending.cents)} sent to ${pending.sentTo}`, + ); + return; + } + setActivity((prev) => prev.filter((r) => r.id !== pending.id)); + showToast( + pending.mode === 'withdraw' + ? 'Withdrawal failed — your balance is unchanged' + : 'Payment failed — your balance is unchanged', + ); + }; useEffect(() => () => window.clearTimeout(sheetInsertTimer.current), []); // Success-screen skins settle on the HOME screen: once the success sheet has @@ -547,7 +618,7 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { pendingActivity.current = { kind: 'bank', bankName: 'Bank transfer', - last4: accountLast4, + accountLabel: accountLast4 ? `•••• ${accountLast4}` : 'account', countryCode: 'us', recipientName: '', }; @@ -611,6 +682,7 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { setToast, showToast, simulateBankDeposit, + settleTransfer, // Derived money / activity availableCents, earningsTodayCents, diff --git a/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.module.scss b/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.module.scss index 0c9024b0..b4a03005 100644 --- a/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.module.scss +++ b/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.module.scss @@ -287,6 +287,13 @@ color: var(--text-primary); } +/* Not a tab — the single label an inbound webhook card shows in place of the + Request/Response pair. Same type and metrics, no pointer affordance. */ +.tabStatic { + cursor: default; + pointer-events: none; +} + .copyBtn { all: unset; corner-shape: var(--corner-shape); diff --git a/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.tsx b/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.tsx index dd6422ae..6b193efd 100644 --- a/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.tsx +++ b/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.tsx @@ -233,11 +233,12 @@ function ApiCallBlock({ entry, now, isNew }: { entry: Entry; now: number; isNew: const curl = useMemo(() => formatCurlString(entry), [entry]); const response = useMemo(() => formatResponseString(entry), [entry]); - const copyText = tab === 'request' ? curl : response; + const copyText = tab === 'response' && !entry.inbound ? response : curl; + const showResponse = tab === 'response' && !entry.inbound; const highlighted = useMemo( - () => (tab === 'request' ? highlightCurl(curl, syntaxClasses) : highlightJson(response, syntaxClasses)), - [tab, curl, response], + () => (showResponse ? highlightJson(response, syntaxClasses) : highlightCurl(curl, syntaxClasses)), + [showResponse, curl, response], ); return ( @@ -260,7 +261,22 @@ function ApiCallBlock({ entry, now, isNew }: { entry: Entry; now: number; isNew:
- + {/* An inbound webhook has no response worth a tab: the interesting side + is what Grid DELIVERED, and our end of it is just the 200 ack. So + the payload is labelled instead of offered as a Request/Response + choice. */} + {entry.inbound ? ( + // Inside .tabGroup, not loose in the toolbar: the group is what + // supplies the row height (--toolbar-tab-height) that .tab centers + // against. On its own the label collapsed to the toolbar's top edge + // and stretched full width, so it read as centered horizontally and + // misaligned with the copy button. +
+ Payload +
+ ) : ( + + )}
diff --git a/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.module.scss b/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.module.scss index e508bd31..c6ba56e6 100644 --- a/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.module.scss +++ b/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.module.scss @@ -94,3 +94,74 @@ :global(html:not([data-layout='stacked'])) .headerStacked { display: none; } + +/* ── Kind filter ────────────────────────────────────────────────────────────── + Sits under the header, above the feed: which kinds of traffic to show. Chips + rather than form rows — this is a view control, not input. */ +.filterRow { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: 0 $layout-laptop-content-padding var(--spacing-md); + + @media (max-width: $breakpoint-layout-mobile) { + padding-left: var(--spacing-xl); + padding-right: var(--spacing-xl); + } +} + +.filterChip { + all: unset; + corner-shape: var(--corner-shape); + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: 5px 10px 5px 6px; + border: 0.5px solid var(--border-tertiary); + border-radius: var(--corner-radius-sm); + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease; + + &:hover { + background: var(--color-alpha-black-04); + + :global([data-theme='dark']) & { + background: var(--color-alpha-white-06); + } + } + + &:focus-visible { + box-shadow: inset 0 0 0 var(--stroke-xs) var(--border-secondary); + } + + /* Unchecked reads as muted, so what's hidden is obvious at a glance. */ + &[aria-pressed='false'] { + .filterLabel, + .filterCount { + color: var(--text-tertiary); + } + } +} + +.filterCheck { + flex-shrink: 0; +} + +.filterLabel { + @include label-sm; + color: var(--text-primary); +} + +.filterCount { + @include label-sm; + color: var(--text-tertiary); + font-feature-settings: 'tnum' 1; +} + +.filteredEmpty { + @include body; + margin: 0; + padding: var(--spacing-lg) $layout-laptop-content-padding; + color: var(--text-secondary); +} diff --git a/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.tsx b/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.tsx index d2ab5cbf..42f8139c 100644 --- a/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.tsx +++ b/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx'; import { AnimatePresence, motion, useAnimationControls } from 'motion/react'; import { IconCode } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconCode'; import { TextMorph } from 'torph/react'; +import { Checkbox } from '@lightsparkdev/origin/checkbox'; import { PanelHeader } from '@/components/PanelHeader/PanelHeader'; import { cubicBezierCss, easeOutSnappy, easeOutSwift, motionTransition } from '@/lib/easing'; import { ApiCallList } from './ApiCallList'; @@ -12,6 +13,32 @@ import { ApiPanelEmpty } from './ApiPanelEmpty'; import type { Entry } from './types'; import styles from './ApiPanel.module.scss'; +/** A filter chip: the Origin check indicator plus a label and a live count. */ +function FilterToggle({ + label, + count, + checked, + onChange, +}: { + label: string; + count: number; + checked: boolean; + onChange: (next: boolean) => void; +}) { + return ( + + ); +} + interface ApiPanelProps { entries: Entry[]; /** Runs an action card's button (the sandbox funding stand-in). */ @@ -37,6 +64,21 @@ const PILL_MORPH_MS = 280; export function ApiPanel({ entries, onAction }: ApiPanelProps) { const isEmpty = entries.length === 0; + // Two kinds of traffic share the feed: calls the app made (outbound, via the + // proxy) and webhooks Grid delivered. Filter by kind. Action cards are controls + // rather than traffic, so they are never filtered out. + const [showRequests, setShowRequests] = useState(true); + const [showWebhooks, setShowWebhooks] = useState(true); + const requestCount = entries.filter((e) => !e.inbound && !e.action).length; + const webhookCount = entries.filter((e) => e.inbound).length; + const visibleEntries = useMemo( + () => + entries.filter((e) => + e.action ? true : e.inbound ? showWebhooks : showRequests, + ), + [entries, showRequests, showWebhooks], + ); + const filteredToNothing = !isEmpty && visibleEntries.length === 0; // New-call signifiers (stacked layout only): a count pill on the header + a dot // per unseen call. "Seen" = the calls have scrolled into view — an // IntersectionObserver on the body marks everything present (and anything that @@ -149,6 +191,22 @@ export function ApiPanel({ entries, onAction }: ApiPanelProps) { } /> + {!isEmpty && ( +
+ + +
+ )}
{isEmpty ? ( @@ -169,7 +227,15 @@ export function ApiPanel({ entries, onAction }: ApiPanelProps) { animate={SWAP_REST} transition={SWAP_IN} > - + {filteredToNothing ? ( +

+ {webhookCount === 0 && !showRequests + ? 'No webhooks delivered yet — Grid pushes them to /api/webhooks.' + : 'Nothing matches the filter above.'} +

+ ) : ( + + )} )}
diff --git a/components/grid-wallet-prod/src/components/AppPanel/AppPanel.tsx b/components/grid-wallet-prod/src/components/AppPanel/AppPanel.tsx index e2ff2f2e..82216854 100644 --- a/components/grid-wallet-prod/src/components/AppPanel/AppPanel.tsx +++ b/components/grid-wallet-prod/src/components/AppPanel/AppPanel.tsx @@ -41,6 +41,7 @@ export interface DemoLogicPhoneSlice { depositInstructions: DepositInstructions | null; totalCents: number; walletToast: { nonce: number; text: string } | null; + transferOutcome: { nonce: number; ok: boolean } | null; storedBanks: SavedBank[]; onSelectStoredBank: (externalAccountId: string | null) => void; onDepositView: (view: { label: string; currency: string; cents?: number } | null) => void; @@ -103,6 +104,7 @@ function toPhoneProps(p: DemoLogicPhoneSlice): PhoneProps { depositInstructions: p.depositInstructions, totalCents: p.totalCents, walletToast: p.walletToast, + transferOutcome: p.transferOutcome, storedBanks: p.storedBanks, onSelectStoredBank: p.onSelectStoredBank, onDepositView: p.onDepositView, diff --git a/components/grid-wallet-prod/src/components/DemoPhone/DemoPhone.tsx b/components/grid-wallet-prod/src/components/DemoPhone/DemoPhone.tsx index 10113a93..514ca28c 100644 --- a/components/grid-wallet-prod/src/components/DemoPhone/DemoPhone.tsx +++ b/components/grid-wallet-prod/src/components/DemoPhone/DemoPhone.tsx @@ -124,6 +124,7 @@ function DemoScreen(props: PhoneProps, skin: AppSkin) { depositInstructions={props.depositInstructions} totalCents={props.totalCents} walletToast={props.walletToast} + transferOutcome={props.transferOutcome} storedBanks={props.storedBanks} onSelectStoredBank={props.onSelectStoredBank} onDepositView={props.onDepositView} diff --git a/components/grid-wallet-prod/src/components/Phone.tsx b/components/grid-wallet-prod/src/components/Phone.tsx index 00e903bf..8ead3714 100644 --- a/components/grid-wallet-prod/src/components/Phone.tsx +++ b/components/grid-wallet-prod/src/components/Phone.tsx @@ -47,6 +47,8 @@ export interface PhoneProps { totalCents?: number; /** One-shot toast raised by an arrival webhook (nonce bumps per delivery). */ walletToast?: { nonce: number; text: string } | null; + /** Terminal outcome of the pending outbound transfer (poll or webhook). */ + transferOutcome?: { nonce: number; ok: boolean } | null; /** Accounts Grid already holds, seeding the saved-banks list. */ storedBanks?: SavedBank[]; /** A stored account was picked — quote against that ExternalAccount id. */ diff --git a/components/grid-wallet-prod/src/hooks/useWalletDemoLogic.ts b/components/grid-wallet-prod/src/hooks/useWalletDemoLogic.ts index 90e50e77..406aa55d 100644 --- a/components/grid-wallet-prod/src/hooks/useWalletDemoLogic.ts +++ b/components/grid-wallet-prod/src/hooks/useWalletDemoLogic.ts @@ -34,15 +34,16 @@ import { fetchActivity, fetchDepositInstructions, fetchExternalAccounts, + fetchFiatBalance, transactionToRow, type DepositInstructions, type RawTransaction, } from '@/lib/gridReads'; import { - resolvePlatformUsdAccountId, - sandboxFundPlatform, - onRampQuoteBodyFor, + realtimeFundingQuoteBodyFor, + SANDBOX_FUNDING_CURRENCY, sandboxSendForQuote, + sweepQuoteBodyFor, } from '@/lib/gridFunding'; import { ensureExternalAccount, @@ -150,6 +151,14 @@ export function useWalletDemoLogic() { // Toast the wallet should show, bumped by an arrival webhook (the brain owns // the toast surface, so it rides a nonce down like the other one-shots). const [walletToast, setWalletToast] = useState<{ nonce: number; text: string } | null>(null); + // Terminal outcome of the outbound transfer the phone is showing as pending — + // from the execute + transaction poll, or from an OUTGOING_PAYMENT webhook. + const [transferOutcome, setTransferOutcome] = useState<{ nonce: number; ok: boolean } | null>( + null, + ); + const reportTransfer = useCallback((ok: boolean) => { + setTransferOutcome((prev) => ({ nonce: (prev?.nonce ?? 0) + 1, ok })); + }, []); // Accounts Grid already holds for this customer — the saved-banks list starts // from these rather than from an empty session. const [storedBanks, setStoredBanks] = useState([]); @@ -388,9 +397,13 @@ export function useWalletDemoLogic() { ...prev, { method: 'POST', - path: '/sandbox/internal-accounts/{id}/fund', + path: '/sandbox/send', title: 'Simulate an inbound transfer', - note: `No real ${view.currency} transfer is going to arrive in sandbox. Run the platform on-ramp that stands in for one — the calls it makes appear below.`, + note: + `No real ${view.currency} transfer is going to arrive in sandbox. Stand in for one with a real-time funding quote, settled the way a pushed wire would be — the calls appear below.` + + (view.currency === SANDBOX_FUNDING_CURRENCY + ? '' + : ` This platform only has ${SANDBOX_FUNDING_CURRENCY} real-time funding enabled, so the stand-in arrives as ${SANDBOX_FUNDING_CURRENCY}.`), simulateCents: view.cents, status: '200', action: 'simulate-funding' as EntryAction, @@ -432,8 +445,11 @@ export function useWalletDemoLogic() { if (pull.transactionId) { status = await pollTransaction(pull.transactionId, logEnvelope(TRANSFER_LABEL.add, gid)); } - await refreshBalance(TRANSFER_LABEL.add, gid); + await refreshLedger(TRANSFER_LABEL.add, gid); if (isCompletionStatus(200, status)) setCompleted((c) => ({ ...c, add: true })); + // If the funds landed as USD in the fiat account rather than as USDB, + // convert them straight away — the phone's balance IS the wallet. + await sweepUsdToWallet(gid); } catch (e) { console.error('[grid-demo] simulate push', e); } @@ -468,16 +484,31 @@ export function useWalletDemoLogic() { const data = event.data as RawTransaction | undefined; const completed = event.type?.endsWith('.COMPLETED') && data?.status === 'COMPLETED'; const credit = data?.direction ? data.direction === 'CREDIT' : data?.type === 'INCOMING'; + // Outbound: Grid's OUTGOING_PAYMENT.* is the authoritative word on a + // transfer the phone is showing as pending. settleTransfer is idempotent, + // so whichever arrives first — this or the poll — wins. + if (event.type?.startsWith('OUTGOING_PAYMENT.') && data) { + if (data.status === 'COMPLETED') reportTransfer(true); + else if (['FAILED', 'REJECTED', 'REFUNDED', 'EXPIRED'].includes(data.status)) { + reportTransfer(false); + } + } + // USD landing in the fiat account is money that hasn't reached the wallet + // yet: convert it. The sweep re-reads the real balance, so a duplicate or + // out-of-order delivery can't move the same dollars twice. + if (completed && credit && data?.receivedAmount?.currency.code === 'USD') { + void sweepUsdToWallet(newGroupId()); + } if (completed && data && credit) { - const row = transactionToRow(data); + const row = transactionToRow(data, getAccount()?.accountId); setWallet((w) => ({ ...w, activity: [row, ...w.activity.filter((r) => r.id !== row.id)], })); setWalletToast((t) => ({ nonce: (t?.nonce ?? 0) + 1, text: `${row.amount} added to balance` })); setCompleted((c) => ({ ...c, add: true })); - void refreshBalance(TRANSFER_LABEL.add, newGroupId()).catch((e) => - console.error('[grid-demo] webhook balance refresh', e), + void refreshLedger(TRANSFER_LABEL.add, newGroupId()).catch((e) => + console.error('[grid-demo] webhook ledger refresh', e), ); } pushCalls( @@ -560,7 +591,7 @@ export function useWalletDemoLogic() { // details (what Add money → Bank transfer shows) into the same group. const [walletBalance, activity, deposit, externalAccounts] = await Promise.all([ fetchBalance(logEnvelope(SIGN_IN_GROUP, gid)), - fetchActivity(logEnvelope(SIGN_IN_GROUP, gid)), + fetchActivity(logEnvelope(SIGN_IN_GROUP, gid), s.accountId), fetchDepositInstructions(logEnvelope(SIGN_IN_GROUP, gid)), fetchExternalAccounts(logEnvelope(SIGN_IN_GROUP, gid)), ]); @@ -695,7 +726,12 @@ export function useWalletDemoLogic() { transactionId: quote.transactionId, idem, }; - })().catch((e) => console.error('[grid-demo] create quote', e)); + })().catch((e) => { + // The quote never came back, so there is nothing to execute: clear the + // pending row rather than let it read as a completed transfer. + reportTransfer(false); + console.error('[grid-demo] create quote', e); + }); }, [pushCalls, logEnvelope], ); @@ -704,6 +740,54 @@ export function useWalletDemoLogic() { // logged the moment "Add bank account" / "Add recipient" is confirmed. Real // POST /customers/external-accounts (reused, not recreated, per destination // for the rest of the session). + /** + * Money that arrives by bank transfer lands as USD in the customer's fiat + * account, not in the USDB wallet the phone shows. Convert it the moment it + * lands: quote the customer's own USD account into their wallet and execute it + * (no wallet signature — the wallet is the destination, not the source). + * + * Reads the real balance rather than trusting an amount from elsewhere, so it is + * safe to call speculatively: nothing there means nothing to do. Guarded against + * overlapping runs so two triggers (the arrival webhook and the simulated + * funding) can't quote the same dollars twice. + */ + const sweepInFlight = useRef(false); + const sweepUsdToWallet = useCallback( + async (groupId: string) => { + if (sweepInFlight.current) return; + const acct = getAccount(); + if (!acct) return; + sweepInFlight.current = true; + const log = logEnvelope(TRANSFER_LABEL.add, groupId); + try { + const fiat = await fetchFiatBalance(log); + if (!fiat || fiat.cents <= 0) return; + const idem = crypto.randomUUID(); + const quote = await createQuote( + sweepQuoteBodyFor(fiat.accountId, acct.accountId, fiat.cents), + log, + idem, + ); + const execEnv = await executeQuoteUnsigned(quote.quoteId, log, idem); + if (execEnv.response.status !== 200) { + throw new Error(`sweep execute failed: ${execEnv.response.status}`); + } + const status = quote.transactionId ? await pollTransaction(quote.transactionId, log) : null; + await refreshLedger(TRANSFER_LABEL.add, groupId); + if (isCompletionStatus(200, status)) setCompleted((c) => ({ ...c, add: true })); + } catch (e) { + // Truthful failure: the USD stays in the fiat account and the panel has + // every envelope. Nothing is faked on the phone. + console.error('[grid-demo] usd → usdb sweep', e); + } finally { + sweepInFlight.current = false; + } + }, + // refreshBalance is declared below (same TDZ pattern as the transfer paths). + // eslint-disable-next-line react-hooks/exhaustive-deps + [logEnvelope], + ); + /** A stored account was picked in the sheet: quote against ITS id (no create). */ const onSelectStoredBank = useCallback((externalAccountId: string | null) => { if (externalAccountId) pendingExternalAccountId.current = externalAccountId; @@ -798,66 +882,50 @@ export function useWalletDemoLogic() { // catch / 403) — calls `onAddSettled` exactly once, undoing this // add's own optimistic bump by exactly its own cents. // - // `POST /sandbox/internal-accounts/{id}/fund` only mints BOOK - // balance — a direct fund of the customer's own wallet leaves it - // with no on-chain USDB, so any later outbound quote fails with - // INSUFFICIENT_FUNDS. The real on-ramp (fund the platform's USD - // account, then quote+execute platform -> customer wallet) is the - // only way "Add money" lands real, spendable balance. + // The stand-in for a real inbound transfer is a REAL-TIME FUNDING + // quote settled with `POST /sandbox/send` — the same settlement a + // pushed wire gets. Two earlier approaches were wrong: + // - `POST /sandbox/internal-accounts/{id}/fund` on the customer's + // own wallet mints BOOK balance only, so later outbound quotes + // fail with INSUFFICIENT_FUNDS. + // - funding the PLATFORM's USD account and on-ramping from it does + // land spendable balance, but the transaction belongs to the + // platform, so it never shows up in the customer's + // `GET /transactions` — the arrival was missing from the phone's + // activity list even though the balance moved. try { - const platformId = await resolvePlatformUsdAccountId( + const idem = crypto.randomUUID(); + const quote = await createQuote( + realtimeFundingQuoteBodyFor(acct.accountId, cents), logEnvelope(TRANSFER_LABEL[mode], gid), + idem, ); - const res = await sandboxFundPlatform( - platformId, + const res = await sandboxSendForQuote( + quote.quoteId, + SANDBOX_FUNDING_CURRENCY, cents, logEnvelope(TRANSFER_LABEL[mode], gid), ); if (res.ok) { - const idem = crypto.randomUUID(); - const quote = await createQuote( - onRampQuoteBodyFor(platformId, acct.accountId, cents), - logEnvelope(TRANSFER_LABEL[mode], gid), - idem, - ); - const execEnv = await executeQuoteUnsigned( - quote.quoteId, - logEnvelope(TRANSFER_LABEL[mode], gid), - idem, - ); - let transactionStatus: string | null = null; - if (execEnv.response.status === 200 && quote.transactionId) { - // Polls to a terminal status (COMPLETED expected — the - // platform-sourced on-ramp settles fast in sandbox). - transactionStatus = await pollTransaction( - quote.transactionId, - logEnvelope(TRANSFER_LABEL[mode], gid), - ); - } - if (isCompletionStatus(execEnv.response.status, transactionStatus)) { - // The fund + on-ramp genuinely reached COMPLETED - // server-side — the add DID happen, so `completed.add` - // reflects that regardless of whether the balance - // re-read below succeeds. + // Settled server-side; poll the transaction to a terminal status + // rather than assuming the 200 means the money landed. + const transactionStatus = quote.transactionId + ? await pollTransaction(quote.transactionId, logEnvelope(TRANSFER_LABEL[mode], gid)) + : null; + if (isCompletionStatus(200, transactionStatus)) { setCompleted((c) => ({ ...c, add: true })); } else { - // Execute itself failed, or the transaction never - // confirmed COMPLETED (FAILED/REJECTED/REFUNDED/EXPIRED, - // still PROCESSING at the poll deadline, or no - // transactionId to track). A real, truthful outcome — - // every envelope is already logged in the panel, so - // don't fabricate the checkmark. `onAddSettled` below - // still rolls back the optimistic bump regardless, same - // as every other failure path in this branch. + // Real, truthful outcome: the send was accepted but the + // transaction never confirmed COMPLETED (or there was no + // transactionId to track). Every envelope is in the panel, so + // don't fabricate the checkmark. console.error( '[grid-demo]', - new Error( - `on-ramp did not complete: execute ${execEnv.response.status}, transaction ${transactionStatus ?? 'untracked'}`, - ), + new Error(`funding did not complete: transaction ${transactionStatus ?? 'untracked'}`), ); } try { - await refreshBalance(TRANSFER_LABEL[mode], gid); // real GET /customers/internal-accounts + await refreshLedger(TRANSFER_LABEL[mode], gid); // real balance + history } catch (e) { // One retry after a short delay before giving up — a // transient read failure right after a real, successful @@ -865,25 +933,22 @@ export function useWalletDemoLogic() { // if a second try would have landed fine. await sleep(REFRESH_RETRY_DELAY_MS); try { - await refreshBalance(TRANSFER_LABEL[mode], gid); + await refreshLedger(TRANSFER_LABEL[mode], gid); } catch (e2) { - // Both reads failed: truthful log, no fabricated - // balance. `onAddSettled` below still fires — the - // optimistic bump must not outlive the flow either way — - // so the display will under-count this add's real money - // until some LATER balance change (another flow, or a - // fresh sign-in) catches it up. That's the truthful - // choice, not a bug: we don't know the exact new total, - // so we stop pretending we do. + // Both reads failed: truthful log, no fabricated balance. + // `onAddSettled` below still fires — the optimistic bump must + // not outlive the flow either way — so the display will + // under-count this add's real money until some LATER balance + // change (another flow, or a fresh sign-in) catches it up. console.error('[grid-demo]', e2); } } - // Settle THIS add's bump now, in the SAME continuation as - // the refresh above (whether it landed or exhausted its - // retry) — same React batch as refreshBalance's setWallet, - // so `balance` and `deltaCents` move together with no - // intermediate frame where both count (the bug a concurrent - // second add's still-pending bump would otherwise hit). + // Settle THIS add's bump now, in the SAME continuation as the + // refresh above (whether it landed or exhausted its retry) — same + // React batch as refreshLedger's setWallet, so `balance` and + // `deltaCents` move together with no intermediate frame where both + // count (the bug a concurrent second add's still-pending bump + // would otherwise hit). onAddSettled?.(); } else if (res.status === 403) { // Production keys: sandbox fund is forbidden — no real money @@ -895,7 +960,7 @@ export function useWalletDemoLogic() { setCompleted((c) => ({ ...c, add: true })); onAddSettled?.(); } else { - console.error('[grid-demo]', new Error(`sandbox fund failed: ${res.status}`)); + console.error('[grid-demo]', new Error(`sandbox send failed: ${res.status}`)); onAddSettled?.(); } } catch (e) { @@ -942,16 +1007,20 @@ export function useWalletDemoLogic() { logEnvelope(TRANSFER_LABEL[mode], gid), ); } - await refreshBalance(TRANSFER_LABEL[mode], gid); // real GET /customers/internal-accounts + // Outbound: refresh BEFORE reporting success, so the server's own row + // is present when the phone drops its local pending one. + await refreshLedger(TRANSFER_LABEL[mode], gid); if (isCompletionStatus(execEnv.response.status, transactionStatus)) { setCompleted((c) => ({ ...c, [mode]: true })); + reportTransfer(true); } else { // FAILED/REJECTED/REFUNDED/EXPIRED, still PROCESSING at the // poll deadline, or no transactionId to track — a real, // truthful outcome. The panel already logged every envelope; - // don't fabricate the checkmark. The sheet already closed - // optimistically before this callback ran, so the phone has - // no busy state to unstick — it's already recoverable. + // don't fabricate the checkmark, and tell the phone so the + // pending row comes back OFF rather than standing as a transfer + // that never happened. + reportTransfer(false); console.error( '[grid-demo]', new Error(`transfer did not complete: transaction ${transactionStatus ?? 'untracked'}`), @@ -960,14 +1029,19 @@ export function useWalletDemoLogic() { } else { // Real error (e.g. insufficient funds, an expired quote). The // panel already logged the truthful error via logEnvelope; the - // phone recovers with no balance change and no checkmark. + // phone drops the pending row and keeps the balance as it was. + reportTransfer(false); console.warn('[grid-demo] execute failed', execEnv.response.status); } } catch (e) { + reportTransfer(false); console.error('[grid-demo] execute', e); } })(); } else { + // Nothing to execute — most often the quote itself failed, which is + // exactly the case that used to leave a "Withdrawn from balance" row. + reportTransfer(false); console.warn('[grid-demo] no pending quote/payload for outbound transfer'); } }, @@ -991,6 +1065,27 @@ export function useWalletDemoLogic() { [logEnvelope], ); + /** + * Money moved: re-read the balance AND the history. The Activity row for a + * completed transfer comes from `GET /transactions` — the real ledger — so it + * appears whether or not a webhook reached this machine. Without this, a + * simulated ACH pull moved the balance while the list stayed as it was at + * sign-in (webhooks are the only other row source, and they need a tunnel). + */ + const refreshLedger = useCallback( + async (groupLabel: string, groupId: string) => { + const log = logEnvelope(groupLabel, groupId); + const walletAccountId = getAccount()?.accountId; + const [balance, activity] = await Promise.all([ + fetchBalance(log), + fetchActivity(log, walletAccountId), + ]); + setWallet((w) => ({ ...w, balanceCents: balance.spendableCents, activity })); + setTotalCents(balance.totalCents); + }, + [logEnvelope], + ); + /** * "Add a passkey", from inside the wallet — the docs' bootstrap order: the * EMAIL_OTP session that got you in is what authorizes the new credential @@ -1210,6 +1305,7 @@ export function useWalletDemoLogic() { depositInstructions, totalCents, walletToast, + transferOutcome, storedBanks, onSelectStoredBank, onDepositView, diff --git a/components/grid-wallet-prod/src/lib/cryptoAddresses.test.ts b/components/grid-wallet-prod/src/lib/cryptoAddresses.test.ts new file mode 100644 index 00000000..378d9671 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/cryptoAddresses.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { detectAddressNetworks, randomNetworkAddress } from './cryptoAddresses'; + +describe('detectAddressNetworks', () => { + // Round-trip against the generator: whatever this module can produce for a + // chain must be recognised as valid for that chain. + it.each([ + ['Solana', 'solana'], + ['Tron', 'tron'], + ['Bitcoin', 'btc'], + ['Spark', 'spark'], + ])('recognises a generated %s address', (network, id) => { + expect(detectAddressNetworks(randomNetworkAddress(network))).toEqual([id]); + }); + + // The same 20 bytes are a valid account on both chains — the encoding cannot + // distinguish them, so the caller has to ask rather than guess. + it('reports an EVM address as ambiguous between Ethereum and Base', () => { + expect(detectAddressNetworks(randomNetworkAddress('Base'))).toEqual(['ethereum', 'base']); + expect(detectAddressNetworks(randomNetworkAddress('Ethereum'))).toEqual(['ethereum', 'base']); + }); + + it('accepts a lowercase (non-EIP-55) EVM address', () => { + expect(detectAddressNetworks('0x4d765018e8f5c6f592260c95cbcadf5f5951fd15')).toEqual([ + 'ethereum', + 'base', + ]); + }); + + it('tolerates surrounding whitespace from a clipboard', () => { + const address = randomNetworkAddress('Solana'); + expect(detectAddressNetworks(` ${address}\n`)).toEqual(['solana']); + }); + + // Rejecting is the point: a truncated or mistyped address should fail here + // rather than reach Grid and fail there. + it.each([ + ['empty', ''], + ['prose', 'send me money'], + ['EVM one hex digit short', '0x4d765018e8f5c6f592260c95cbcadf5f5951fd1'], + ['EVM with a non-hex digit', '0x4d765018e8f5c6f592260c95cbcadf5f5951fd1z'], + ['bare hex without 0x', '4d765018e8f5c6f592260c95cbcadf5f5951fd15'], + ['a bitcoin address with a broken checksum', 'bc1qsu2qrhp5vq5csy97qv3w8eku8wrh2l7dtenv7q'], + ['a URL', 'https://example.com/pay'], + ])('rejects %s', (_label, value) => { + expect(detectAddressNetworks(value)).toEqual([]); + }); + + it('rejects a Solana-looking string that is not 32 bytes', () => { + expect(detectAddressNetworks('3JZ4hmYF6u5es6Ztfpuv')).toEqual([]); + }); +}); diff --git a/components/grid-wallet-prod/src/lib/cryptoAddresses.ts b/components/grid-wallet-prod/src/lib/cryptoAddresses.ts index 83f0e017..35abc3a9 100644 --- a/components/grid-wallet-prod/src/lib/cryptoAddresses.ts +++ b/components/grid-wallet-prod/src/lib/cryptoAddresses.ts @@ -87,3 +87,73 @@ export function randomNetworkAddress(network: string): string { return randomSolanaAddress(); // Solana } } + +/* ── Detection ───────────────────────────────────────────────────────────────── + Which chains a PASTED address could belong to. Decodes with the same + primitives used above rather than matching on shape alone, so a mistyped or + truncated address is rejected instead of accepted and then failing at Grid. + + An EVM address is genuinely ambiguous — the same 20 bytes are valid on + Ethereum and Base — so this returns a LIST and the caller asks which chain. */ + +function isEvmAddress(value: string): boolean { + return /^0x[0-9a-fA-F]{40}$/.test(value); +} + +function isSolanaAddress(value: string): boolean { + try { + return base58.decode(value).length === 32; + } catch { + return false; + } +} + +function isTronAddress(value: string): boolean { + try { + const bytes = tronBase58Check.decode(value); + return bytes.length === 21 && bytes[0] === 0x41; + } catch { + return false; + } +} + +function isSparkAddress(value: string): boolean { + try { + const { prefix } = bech32m.decode(value as `${string}1${string}`, 200); + // Mainnet is `spark`; the sandbox/regtest prefix is `sparkrt`. + return prefix === 'spark' || prefix === 'sparkrt'; + } catch { + return false; + } +} + +function isBtcAddress(value: string): boolean { + try { + const { prefix } = bech32.decode(value as `${string}1${string}`, 200); + if (prefix === 'bc') return true; + } catch { + /* not bech32 — try legacy below */ + } + try { + const bytes = tronBase58Check.decode(value); // same base58check construction + return bytes.length === 21 && (bytes[0] === 0x00 || bytes[0] === 0x05); + } catch { + return false; + } +} + +/** + * Network ids (matching the wallet's chain list) a pasted address is valid for. + * Empty means it isn't an address this wallet can pay. More than one means the + * encoding can't tell the chains apart — ask the user. + */ +export function detectAddressNetworks(address: string): string[] { + const value = address.trim(); + if (!value) return []; + if (isEvmAddress(value)) return ['ethereum', 'base']; + if (isTronAddress(value)) return ['tron']; + if (isSparkAddress(value)) return ['spark']; + if (isBtcAddress(value)) return ['btc']; + if (isSolanaAddress(value)) return ['solana']; + return []; +} diff --git a/components/grid-wallet-prod/src/lib/gridFunding.test.ts b/components/grid-wallet-prod/src/lib/gridFunding.test.ts index e5fc5158..952a14e3 100644 --- a/components/grid-wallet-prod/src/lib/gridFunding.test.ts +++ b/components/grid-wallet-prod/src/lib/gridFunding.test.ts @@ -1,25 +1,38 @@ import { describe, it, expect } from 'vitest'; -import { platformFundAmountForCents, onRampQuoteBodyFor } from './gridFunding'; +import { realtimeFundingQuoteBodyFor, SANDBOX_FUNDING_CURRENCY } from './gridFunding'; +import { CUSTOMER } from './gridTransfer'; -describe('platformFundAmountForCents', () => { - it('maps cents 1:1 to the USD platform account smallest unit (no 6-decimal conversion)', () => { - expect(platformFundAmountForCents(200)).toEqual({ amount: 200 }); - expect(platformFundAmountForCents(5000)).toEqual({ amount: 5000 }); - expect(platformFundAmountForCents(0)).toEqual({ amount: 0 }); - }); -}); - -describe('onRampQuoteBodyFor', () => { - it('builds a platform -> customer-wallet USD->USDB quote locked on the sending (USD) side', () => { - expect(onRampQuoteBodyFor('InternalAccount:platform', 'InternalAccount:customer', 2000)).toEqual({ - source: { sourceType: 'ACCOUNT', accountId: 'InternalAccount:platform' }, +describe('realtimeFundingQuoteBodyFor', () => { + it('builds an inbound USD -> USDB quote locked on the sending (USD) side', () => { + expect(realtimeFundingQuoteBodyFor('InternalAccount:wallet', 2000)).toEqual({ + source: { sourceType: 'REALTIME_FUNDING', currency: 'USD', customerId: CUSTOMER }, destination: { destinationType: 'ACCOUNT', - accountId: 'InternalAccount:customer', + accountId: 'InternalAccount:wallet', currency: 'USDB', }, lockedCurrencySide: 'SENDING', lockedCurrencyAmount: 2000, }); }); + + // The amount is the SENDING currency's minor units, so cents map through + // unchanged — no USDB 6-decimal conversion (contrast gridUnits.centsToAmount). + it('passes cents through without converting to USDB micro-units', () => { + expect(realtimeFundingQuoteBodyFor('InternalAccount:wallet', 1).lockedCurrencyAmount).toBe(1); + expect(realtimeFundingQuoteBodyFor('InternalAccount:wallet', 10_000).lockedCurrencyAmount).toBe( + 10_000, + ); + }); + + // The proxy substitutes the real id; the client never sees the customer id. + it('sources from the customer placeholder rather than a hardcoded id', () => { + const body = realtimeFundingQuoteBodyFor('InternalAccount:wallet', 100); + expect(body.source.customerId).toBe('{customerId}'); + }); + + it('defaults to the only currency this platform funds in sandbox', () => { + expect(SANDBOX_FUNDING_CURRENCY).toBe('USD'); + expect(realtimeFundingQuoteBodyFor('InternalAccount:wallet', 100).source.currency).toBe('USD'); + }); }); diff --git a/components/grid-wallet-prod/src/lib/gridFunding.ts b/components/grid-wallet-prod/src/lib/gridFunding.ts index 42e1c8bb..a80b7610 100644 --- a/components/grid-wallet-prod/src/lib/gridFunding.ts +++ b/components/grid-wallet-prod/src/lib/gridFunding.ts @@ -2,71 +2,41 @@ import { gridFetch } from './gridClient'; import type { LogFn } from './gridSession'; -import type { QuoteBody } from './gridTransfer'; +import { CUSTOMER, type QuoteBody } from './gridTransfer'; /** - * Sandbox fund body for the PLATFORM's USD internal account. USD's smallest - * unit is cents, so the app's "cents" map straight through — unlike the - * embedded wallet's own USDB balance (6 decimals, see gridUnits.ts), there is - * no conversion here. + * The only currency this platform has real-time funding enabled for in sandbox + * (verified: an EUR quote comes back INVALID_INPUT, "Sending currency 'EUR' is + * not configured for your platform"). The euro deposit section is placeholder + * details anyway (see placeholderDeposit.ts), so the stand-in deposits USD and + * the action card says so rather than implying euros arrived. */ -export function platformFundAmountForCents(cents: number): { amount: number } { - return { amount: cents }; -} - -// Resolved once per session (module-level — the platform account doesn't -// change between "Add money" runs). -let platformUsdAccountId: string | null = null; +export const SANDBOX_FUNDING_CURRENCY = 'USD'; /** - * Resolve (and cache) the platform's USD internal account id — the real - * money source for "Add money"'s on-ramp leg. `POST /sandbox/.../fund` only - * mints BOOK balance on the customer's own wallet (no on-chain USDB), so - * outbound quotes from it fail with INSUFFICIENT_FUNDS; funding the platform - * account and on-ramping from it into the customer's wallet is the only way - * to land real, spendable balance in sandbox. + * Real-time funding quote: money arriving from OUTSIDE Grid straight into the + * customer's embedded wallet. This is what the deposit instructions on the phone + * describe, and `POST /sandbox/send` settles it (see sandboxSendForQuote) the way + * a real inbound transfer would. + * + * `lockedCurrencyAmount` is in the SENDING currency's minor units (USD cents). + * Executing is not involved — an inbound quote is settled by the sender, not by us. + * + * Why not fund the platform's own USD account and on-ramp from it: that DOES land + * spendable balance, but the resulting transaction belongs to the PLATFORM, so it + * never appears in `GET /transactions?customerId=...` and the arrival was missing + * from the phone's activity list. A real-time-funding quote is the customer's own + * transaction — verified against the sandbox: COMPLETED, and listed with the + * wallet as its destination. */ -export async function resolvePlatformUsdAccountId(log: LogFn): Promise { - if (platformUsdAccountId) return platformUsdAccountId; - const env = await gridFetch('GET', '/platform/internal-accounts?currency=USD'); - log(env); - if (env.response.status !== 200) { - throw new Error(`list platform internal accounts: ${env.response.status}`); - } - const body = env.response.body as { data: { id: string }[] }; - const acct = body.data[0]; - if (!acct) throw new Error('No platform USD internal account found'); - platformUsdAccountId = acct.id; - return acct.id; -} - -/** Sandbox-fund the platform's USD account (the on-ramp's source leg). */ -export async function sandboxFundPlatform( - platformAccountId: string, - cents: number, - log: LogFn, -): Promise<{ ok: boolean; status: number }> { - const env = await gridFetch('POST', `/sandbox/internal-accounts/${platformAccountId}/fund`, { - body: platformFundAmountForCents(cents), - }); - log(env); - return { ok: env.response.status === 200, status: env.response.status }; -} - -/** - * Platform -> customer-wallet on-ramp quote: USD in (2 decimals, same cents - * as the fund above), USDB out to the customer's embedded wallet. Executing - * this quote (see gridTransfer.executeQuoteUnsigned) needs no wallet - * signature — the source is the platform account, not the customer's. - */ -export function onRampQuoteBodyFor( - platformAccountId: string, - customerAccountId: string, +export function realtimeFundingQuoteBodyFor( + walletAccountId: string, cents: number, + currency: string = SANDBOX_FUNDING_CURRENCY, ): QuoteBody { return { - source: { sourceType: 'ACCOUNT', accountId: platformAccountId }, - destination: { destinationType: 'ACCOUNT', accountId: customerAccountId, currency: 'USDB' }, + source: { sourceType: 'REALTIME_FUNDING', currency, customerId: CUSTOMER }, + destination: { destinationType: 'ACCOUNT', accountId: walletAccountId, currency: 'USDB' }, lockedCurrencySide: 'SENDING', lockedCurrencyAmount: cents, }; @@ -90,3 +60,25 @@ export async function sandboxSendForQuote( log(env); return { ok: env.response.status === 200, status: env.response.status }; } + +/** + * Convert USD sitting in the customer's own fiat account into USDB in their + * wallet. Money pushed to the deposit instructions arrives as USD there; this is + * the leg that gets it to the balance the phone shows. + * + * `lockedCurrencyAmount` is USD cents (the SENDING side), and executing needs no + * `Grid-Wallet-Signature`: the embedded wallet is the destination, not the source. + * Verified against the sandbox — 300 USD in, 3000000 USDB out, COMPLETED. + */ +export function sweepQuoteBodyFor( + fiatAccountId: string, + walletAccountId: string, + cents: number, +): QuoteBody { + return { + source: { sourceType: 'ACCOUNT', accountId: fiatAccountId }, + destination: { destinationType: 'ACCOUNT', accountId: walletAccountId, currency: 'USDB' }, + lockedCurrencySide: 'SENDING', + lockedCurrencyAmount: cents, + }; +} diff --git a/components/grid-wallet-prod/src/lib/gridReads.test.ts b/components/grid-wallet-prod/src/lib/gridReads.test.ts index 56495e1c..987a55db 100644 --- a/components/grid-wallet-prod/src/lib/gridReads.test.ts +++ b/components/grid-wallet-prod/src/lib/gridReads.test.ts @@ -4,6 +4,8 @@ import { transactionToRow, fetchActivity, fetchDepositInstructions, + fetchExternalAccounts, + externalAccountKey, fundingInstructionRows, type RawTransaction, } from './gridReads'; @@ -196,3 +198,134 @@ describe('fetchActivity', () => { expect(await fetchActivity(() => {})).toEqual([]); }); }); + +describe('fetchExternalAccounts', () => { + beforeEach(() => mockedFetch.mockReset()); + + const usd = (id: string, accountNumber: string, routingNumber = '021000021') => ({ + id, + status: 'ACTIVE', + currency: 'USD', + accountInfo: { accountType: 'USD_ACCOUNT', accountNumber, routingNumber }, + }); + const reply = (data: unknown[]) => + mockedFetch.mockResolvedValue({ + request: { method: 'GET' as const, path: '/customers/external-accounts', headers: {} }, + response: { status: 200, body: { data } }, + }); + + // The same bank account registered twice is one account, and the row should + // point at the most recent registration. + it('collapses accounts that share routing + account number, keeping the newest', async () => { + reply([ + usd('ExternalAccount:old', '1234567890'), + usd('ExternalAccount:other', '9999999999'), + usd('ExternalAccount:new', '1234567890'), + ]); + const rows = await fetchExternalAccounts(() => {}); + expect(rows.map((r) => r.id)).toEqual(['ExternalAccount:new', 'ExternalAccount:other']); + }); + + it('treats formatting differences as the same account', () => { + expect(externalAccountKey(usd('a', '1234 5678 90', '021-000-021'))).toBe( + externalAccountKey(usd('b', '1234567890', '021000021')), + ); + }); + + it('keeps accounts that differ only by routing number', async () => { + reply([usd('ExternalAccount:a', '1234567890', '111111111'), usd('ExternalAccount:b', '1234567890', '222222222')]); + expect((await fetchExternalAccounts(() => {})).length).toBe(2); + }); + + it('collapses IBANs case- and space-insensitively', () => { + const iban = (id: string, value: string) => ({ + id, + accountInfo: { accountType: 'EUR_ACCOUNT', iban: value }, + }); + expect(externalAccountKey(iban('a', 'DE89 3704 0044 0532 0130 00'))).toBe( + externalAccountKey(iban('b', 'de89370400440532013000')), + ); + }); + + it('drops unsupported corridors and inactive accounts', async () => { + reply([ + usd('ExternalAccount:ok', '1111111111'), + { id: 'ExternalAccount:mxn', status: 'ACTIVE', accountInfo: { accountType: 'MXN_ACCOUNT', clabeNumber: '012180001234567895' } }, + { ...usd('ExternalAccount:closed', '2222222222'), status: 'CLOSED' }, + ]); + expect((await fetchExternalAccounts(() => {})).map((r) => r.id)).toEqual(['ExternalAccount:ok']); + }); +}); + +describe('wallet-relative direction', () => { + const WALLET = 'InternalAccount:wallet'; + const FIAT = 'InternalAccount:fiat'; + + // Grid records an inbound PULL as a DEBIT (it debits the external source), so + // direction alone labelled arriving money "Money sent". + const pull: RawTransaction = { + id: 'Transaction:pull', + type: 'OUTGOING', + direction: 'DEBIT', + status: 'COMPLETED', + createdAt: '2026-07-25T15:44:28Z', + sentAmount: usdb(100_000_000), + receivedAmount: { amount: 100_000_000, currency: { code: 'USDB', decimals: 6 } }, + source: { accountId: 'ExternalAccount:bank' }, + destination: { accountId: WALLET }, + }; + + it('reads a pull into the wallet as money added', () => { + const row = transactionToRow(pull, WALLET); + expect(row.title).toBe('Money added'); + expect(row.flow).toBe('in'); + expect(row.amount).toBe('+$100.00'); + }); + + it('still reads it by direction when the wallet is unknown', () => { + expect(transactionToRow(pull).title).toBe('Money sent'); + }); + + it('reads a transfer out of the wallet as money sent', () => { + const out: RawTransaction = { + ...pull, + id: 'Transaction:out', + source: { accountId: WALLET }, + destination: { accountId: 'ExternalAccount:bank' }, + sentAmount: usdb(5_000_000), + receivedAmount: mxn(8389), + }; + const row = transactionToRow(out, WALLET); + expect(row.flow).toBe('out'); + expect(row.amount).toBe('$5.00'); + expect(row.detail).toBe('Sent as MXN'); + }); + + // Funding arrives in two legs; listing both would show the same money twice. + it('lists only the legs that touch the wallet', async () => { + mockedFetch.mockReset(); + mockedFetch.mockResolvedValue({ + request: { method: 'GET' as const, path: '/transactions', headers: {} }, + response: { + status: 200, + body: { + data: [ + pull, + { + id: 'Transaction:wire', + type: 'INCOMING', + direction: 'CREDIT', + status: 'COMPLETED', + createdAt: '2026-07-25T15:38:06Z', + receivedAmount: { amount: 250, currency: { code: 'USD', decimals: 2 } }, + source: { sourceType: 'UMA_ADDRESS' }, + destination: { accountId: FIAT }, + }, + ], + }, + }, + }); + const rows = await fetchActivity(() => {}, WALLET); + expect(rows.map((r) => r.id)).toEqual(['Transaction:pull']); + }); +}); diff --git a/components/grid-wallet-prod/src/lib/gridReads.ts b/components/grid-wallet-prod/src/lib/gridReads.ts index 5138f730..8f853d18 100644 --- a/components/grid-wallet-prod/src/lib/gridReads.ts +++ b/components/grid-wallet-prod/src/lib/gridReads.ts @@ -21,6 +21,8 @@ export interface RawTransaction { createdAt?: string; sentAmount?: RawCurrencyAmount; receivedAmount?: RawCurrencyAmount; + source?: { accountId?: string; sourceType?: string }; + destination?: { accountId?: string; destinationType?: string }; // Grid's CounterpartyInformation is a free-form key/value bag // (openapi/components/schemas/transactions/CounterpartyInformation.yaml); // FULL_NAME is the field the API actually populates for a person's name. @@ -164,6 +166,31 @@ export interface WalletBalance { totalCents: number; } +/** + * The customer's fiat account and what is sitting in it. Money pushed to the + * deposit instructions lands here as USD, NOT in the USDB wallet the phone shows, + * so the demo sweeps it across (see sweepUsdToWallet in the demo hook). + */ +export async function fetchFiatBalance( + log: LogFn, +): Promise<{ accountId: string; cents: number } | null> { + const env = await gridFetch( + 'GET', + `/customers/internal-accounts?customerId=${CUSTOMER}&type=INTERNAL_FIAT`, + ); + log(env); + if (env.response.status !== 200) return null; + const body = env.response.body as { + data: { id: string; balance: { amount: number; currency: { decimals: number } } }[]; + }; + const account = body.data[0]; + if (!account) return null; + return { + accountId: account.id, + cents: amountToCents(account.balance.amount, account.balance.currency.decimals), + }; +} + export async function fetchBalance(log: LogFn): Promise { const env = await gridFetch( 'GET', @@ -209,11 +236,26 @@ function statusLabel(status: string): string | null { return status.charAt(0) + status.slice(1).toLowerCase(); } -/** Pure: one Grid transaction -> an Activity row (the shape every skin renders). */ -export function transactionToRow(t: RawTransaction): WalletListItemData { - const credit = t.direction ? t.direction === 'CREDIT' : t.type === 'INCOMING'; - // Inbound: what landed. Outbound: what left the wallet (the destination leg - // can be another currency entirely — a USDB → MXN cash-out). +/** + * Pure: one Grid transaction -> an Activity row (the shape every skin renders). + * + * `walletAccountId` decides the direction when given, and it has to: Grid records + * an inbound PULL as a `DEBIT` (it debits the external source account), so keying + * off `direction` alone labelled money arriving in the wallet as "Money sent". + * What matters for a wallet row is which side of the transfer the WALLET is on. + */ +export function transactionToRow(t: RawTransaction, walletAccountId?: string): WalletListItemData { + const intoWallet = !!walletAccountId && t.destination?.accountId === walletAccountId; + const outOfWallet = !!walletAccountId && t.source?.accountId === walletAccountId; + const credit = intoWallet + ? true + : outOfWallet + ? false + : t.direction + ? t.direction === 'CREDIT' + : t.type === 'INCOMING'; + // Inbound: what landed in the wallet. Outbound: what left it (the destination + // leg can be another currency entirely — a USDB → MXN cash-out). const money = credit ? t.receivedAmount : t.sentAmount; const cents = money ? amountToCents(money.amount, money.currency.decimals) : 0; const settledAs = credit ? undefined : t.receivedAmount?.currency.code; @@ -234,14 +276,29 @@ export function transactionToRow(t: RawTransaction): WalletListItemData { }; } -export async function fetchActivity(log: LogFn): Promise { +/** + * The wallet's history. When the wallet account id is known, only transactions + * that TOUCH it are listed: funding arrives in two legs (a credit into the fiat + * account, then the conversion into the wallet) and listing both would show the + * same money twice. The leg that credits the wallet is the one the balance moved + * on, so that's the row. + */ +export async function fetchActivity( + log: LogFn, + walletAccountId?: string, +): Promise { const env = await gridFetch('GET', `/transactions?customerId=${CUSTOMER}&limit=20`); log(env); if (env.response.status !== 200) return []; const body = env.response.body as { data: RawTransaction[] }; + const touchesWallet = (t: RawTransaction) => + !walletAccountId || + t.destination?.accountId === walletAccountId || + t.source?.accountId === walletAccountId; return (body.data ?? []) .filter(isDisplayableTransaction) - .map(transactionToRow) + .filter(touchesWallet) + .map((t) => transactionToRow(t, walletAccountId)) .sort((a, b) => b.timestamp - a.timestamp); } @@ -266,12 +323,28 @@ export interface RawExternalAccount { * may have older accounts on rails the app no longer offers, and a row that * can't be quoted is worse than no row. Newest first. */ +/** + * Identity of a payout account: the numbers a payment is actually addressed to. + * Two records with the same routing + account number (or the same IBAN) are the + * same bank account however many times they were registered, so the list shows + * one row for them. + */ +export function externalAccountKey(account: RawExternalAccount): string { + const info = account.accountInfo ?? {}; + const digits = (v?: string) => (v ?? '').replace(/[\s-]/g, '').toLowerCase(); + const identity = info.iban + ? `iban:${digits(info.iban)}` + : `acct:${digits(info.routingNumber)}/${digits(info.accountNumber)}`; + return `${info.accountType ?? ''}|${identity}`; +} + export async function fetchExternalAccounts(log: LogFn): Promise { const env = await gridFetch('GET', `/customers/external-accounts?customerId=${CUSTOMER}`); log(env); if (env.response.status !== 200) return []; const body = env.response.body as { data: RawExternalAccount[] }; const supported = new Set(['USD_ACCOUNT', 'EUR_ACCOUNT']); + const seen = new Set(); return (body.data ?? []) .filter( (a) => @@ -279,5 +352,11 @@ export async function fetchExternalAccounts(log: LogFn): Promise { + const key = externalAccountKey(a); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); } diff --git a/components/grid-wallet-prod/src/lib/gridTransfer.ts b/components/grid-wallet-prod/src/lib/gridTransfer.ts index a714ddba..9e417d71 100644 --- a/components/grid-wallet-prod/src/lib/gridTransfer.ts +++ b/components/grid-wallet-prod/src/lib/gridTransfer.ts @@ -15,7 +15,8 @@ export interface RawQuote { paymentInstructions?: { accountOrWalletInfo?: { accountType?: string; payloadToSign?: string } }[]; } -const CUSTOMER = '{customerId}'; +/** Substituted with the real id by the proxy (allowlist.substituteCustomerId). */ +export const CUSTOMER = '{customerId}'; const externalAccountCache = new Map(); // destSignature -> ExternalAccount id /** Stable signature for a destination so we create it at most once per session. */