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
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,25 @@ 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);
expect(isAllowed('DELETE', '/auth/credentials')).toBe(false);
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' });
Expand Down
4 changes: 1 addition & 3 deletions components/grid-wallet-prod/src/app/api/grid/allowlist.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
/** 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$') },
{ method: 'POST', pattern: new RegExp('^/auth/credentials$') },
{ 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$') },
];

Expand Down
1 change: 1 addition & 0 deletions components/grid-wallet-prod/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
15 changes: 15 additions & 0 deletions components/grid-wallet-prod/src/apps/SignInFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -162,6 +165,7 @@ function WalletHost({
depositInstructions,
totalCents,
walletToast,
transferOutcome,
storedBanks,
onSelectStoredBank,
onDepositView,
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -321,6 +334,7 @@ export function SignInFlow({
depositInstructions,
totalCents,
walletToast,
transferOutcome,
storedBanks,
onSelectStoredBank,
onDepositView,
Expand Down Expand Up @@ -446,6 +460,7 @@ export function SignInFlow({
depositInstructions={depositInstructions}
totalCents={totalCents}
walletToast={walletToast}
transferOutcome={transferOutcome}
storedBanks={storedBanks}
onSelectStoredBank={onSelectStoredBank}
onDepositView={onDepositView}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
DEPOSIT_CHAINS,
SEND_NETWORKS,
DEFAULT_SEND_NETWORK,
accountLast4,
accountLabel,
fieldLabel,
type MoneySheet,
type Step,
Expand Down Expand Up @@ -467,7 +467,7 @@ export function AddMoneySheet({
<span className={styles.sourceLabels}>
<span className={styles.rowTitle}>
{selectedBank
? `${selectedBank.bankName} (•••• ${accountLast4(selectedBank.values)})`
? `${selectedBank.bankName} (${accountLabel(selectedBank.values)})`
: 'Bank account'}
</span>
<span className={styles.rowSub}>{localCurrency} bank account</span>
Expand Down Expand Up @@ -551,7 +551,7 @@ export function AddMoneySheet({
<span className={styles.sourceLabels}>
<span className={styles.rowTitle}>{selectedBank.beneficiary}</span>
<span className={styles.rowSub}>
{selectedBank.bankName} •••• {accountLast4(selectedBank.values)}
{selectedBank.bankName} {accountLabel(selectedBank.values)}
</span>
</span>
</div>
Expand Down Expand Up @@ -1191,14 +1191,14 @@ export function AddMoneySheet({
<>
<span className={styles.rowTitle}>{b.beneficiary}</span>
<span className={styles.rowSub}>
{b.bankName} •••• {accountLast4(b.values)}
{b.bankName} {accountLabel(b.values)}
</span>
<span className={styles.rowSub}>{currencyFor(b.country)}</span>
</>
) : (
<>
<span className={styles.rowTitle}>
{b.bankName} (•••• {accountLast4(b.values)})
{b.bankName} ({accountLabel(b.values)})
</span>
<span className={styles.rowSub}>{b.country.name}</span>
<span className={styles.rowSub}>{currencyFor(b.country)}</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export {
DEFAULT_SEND_NETWORK,
BANK_COUNTRIES,
DEMO_BENEFICIARY,
accountLabel,
accountLast4,
sampleValuesFor,
firstNameLastInitial,
Expand Down
14 changes: 14 additions & 0 deletions components/grid-wallet-prod/src/apps/shared/wallet/moneySheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ export function accountLast4(values: Record<string, string>): 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, string>): 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
Expand Down
10 changes: 9 additions & 1 deletion components/grid-wallet-prod/src/apps/shared/wallet/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
RECEIVE_RAIL,
SAVE_MS,
USD_TO_EUR,
accountLabel,
accountLast4,
firstNameLastInitial,
formatRate,
Expand Down Expand Up @@ -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 ?? '',
};

Expand Down
Loading
Loading