Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ See [docs/adr-nfc-library.md](docs/adr-nfc-library.md) for platform constraints
- [Product flows & system definition](docs/ding-payments.md)
- [Client MVP build plan](docs/build-plan-client-mvp.md)
- [NFC library ADR](docs/adr-nfc-library.md)
- [Receive payment flow (C12)](docs/receive-flow.md)
- [NFC runtime flow and troubleshooting](docs/nfc-flow.md)
- [NFC device checklist](docs/nfc-device-checklist.md)

Expand Down
111 changes: 111 additions & 0 deletions docs/receive-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Receive Payment Flow (C12)

The receive flow lets a user request a contactless payment: enter an amount,
broadcast a payment request over NFC, wait for the payer's on-chain payment,
and land on a success or failure screen. It is implemented as an explicit
finite-state machine (FSM) orchestrated by `useReceivePayment` and shared
across screens via `ReceivePaymentProvider`.

## 1. State diagram

```mermaid
stateDiagram-v2
[*] --> idle
idle --> preparing: prepare(amount, asset, recipientPublicKey)
preparing --> failed: trustline check fails (USDC only)
preparing --> broadcasting: startBroadcast()
broadcasting --> waiting: NFC writer reports success\n(payload delivered to payer)
broadcasting --> failed: NFC writer reports error
broadcasting --> cancelled: cancel() / request expiry
waiting --> success: confirmSuccess(txHash?)
waiting --> failed: confirmFailure(reason) / 60s wait timeout
waiting --> cancelled: cancel()
success --> idle: reset() ("Receive Another")
failed --> idle: reset() ("Try Again" / "Change Amount")
cancelled --> idle: reset()
```

Note: NFC delivery success is **not** the same as payment success — it only
means the request payload reached the payer's device. That's why
`broadcasting` moves to `waiting` (not `success`) once the NFC writer session
completes; `waiting` is resolved only by an explicit `confirmSuccess` /
`confirmFailure` call (today: the 60-second wait timeout in
`WaitingForPaymentView`; a future iteration can resolve it early via balance
polling).

## 2. File map

| File | Responsibility |
| --- | --- |
| `src/features/receive/schemas/receiveAmount.ts` | Zod validation for the amount + asset pair (CLI-062) |
| `src/features/receive/services/PaymentRequestBuilder.ts` | Builds a `PaymentRequest` via the shared `createPaymentRequest` (CLI-063) |
| `src/features/receive/services/receiveSession.ts` | Owns the request-expiry and wait-timeout timers, cancellable and ghost-free (CLI-069) |
| `src/features/wallet/services/TrustlineService.ts` | Checks whether the receiver has a USDC trustline before a USDC request is broadcast (CLI-070) |
| `src/features/receive/hooks/useReceivePayment.ts` | FSM orchestrator + `ReceivePaymentProvider`/`useReceivePaymentContext` for cross-screen state (CLI-065) |
| `src/features/receive/views/ReceiveHomeView.tsx` | Amount entry, asset selector, kicks off `prepare()` (CLI-061) |
| `src/features/receive/views/ReceiveListeningView.tsx` | Starts the NFC broadcast, shows countdown + NFC status (CLI-064) |
| `src/features/receive/views/WaitingForPaymentView.tsx` | Waits for payment settlement, 60s timeout (CLI-066) |
| `src/features/receive/views/ReceiveSuccessView.tsx` | Success summary, reset/home actions (CLI-067) |
| `src/features/receive/views/ReceiveFailedView.tsx` | Error-specific messaging, retry/change-amount actions (CLI-068) |
| `src/constants/analytics-events.ts` | Receive funnel event names + sanitized property shape (CLI-071) |
| `src/app/receive/{listening,waiting,success,failed}.tsx` | Expo Router screens for each non-home view |
| `src/app/(tabs)/receive.tsx` | Tab entry point, renders `ReceiveHomeView` |
| `src/app/_layout.tsx` | Mounts `ReceivePaymentProvider` above all routes so orchestrator state survives navigation |

## 3. Timeout model

Two independent timers, both owned by `ReceiveSessionManager` (`receiveSession`):

- **Request expiry** — `startRequestExpiry(expiresAtSeconds, onExpire)`. Started
in `startBroadcast()` using the `PaymentRequest.expiresAt` timestamp set at
build time (`DEFAULT_EXPIRY_TTL_SECONDS = 5 * 60`, i.e. 5 minutes from
`prepare()`). If the broadcast is still active when the request expires, the
orchestrator cancels the session (`cancel()`).
- **Wait timeout** — `startWaitTimeout(ms, onTimeout)`. Started by
`WaitingForPaymentView` when it mounts (`WAIT_TIMEOUT_MS = 60_000`). If no
success/failure confirmation arrives within 60 seconds, the view calls
`confirmFailure('timeout')`.

**Interaction**: request expiry only matters while broadcasting (the payer
hasn't tapped yet); wait timeout only matters after the NFC handoff succeeded
and we're waiting on settlement. They are mutually exclusive by construction —
`startBroadcast()` cancels any prior expiry timer before starting a new one,
and `cancel()` / `reset()` always call `receiveSession.cancelAll()` so no timer
outlives its screen.

## 4. Error matrix

| Error code | Screen shown | User message | Recovery action |
| --- | --- | --- | --- |
| `timeout` | `ReceiveFailedView` | "Payment timed out. Please try again." | Try Again (same amount) or Change Amount |
| `nfc_error` | `ReceiveFailedView` | "NFC connection was lost." | Try Again (same amount) or Change Amount |
| `trustline_missing` | `ReceiveFailedView` | "USDC trustline not found. Set up your USDC account first." | Try Again (same amount) or Change Amount |
| *(anything else)* | `ReceiveFailedView` | "Payment failed. Please try again." | Try Again (same amount) or Change Amount |

"Try Again" preserves the previously entered amount/asset by forwarding them as
route params back to `ReceiveHomeView`; "Change Amount" resets fully and
returns to a blank form.

## 5. Analytics event mapping

All receive events live in `AnalyticsEvents` (`src/constants/analytics-events.ts`)
and carry only sanitized properties — **no public keys, no raw amounts, no
PII**. Amounts are bucketed via `amount_bucket`: `'<1' | '1-10' | '10-100' | '>100'`.

| State transition | Event | Sanitized properties |
| --- | --- | --- |
| `idle` → `preparing` (`prepare()` called) | `RECEIVE_STARTED` | `amount_bucket`, `asset` |
| `preparing` → `broadcasting` (`startBroadcast()`) | `RECEIVE_BROADCAST` | `amount_bucket`, `asset` |
| `broadcasting` → `waiting` (NFC write succeeded) | `RECEIVE_WAITING` | `amount_bucket`, `asset` |
| `waiting`/`broadcasting` → `success` (`confirmSuccess()`) | `RECEIVE_COMPLETED` | `amount_bucket`, `asset` |
| any → `failed` (`confirmFailure(reason)`) | `RECEIVE_FAILED` | `amount_bucket`, `asset`, `reason` |
| any → `cancelled` (`cancel()`) | `RECEIVE_CANCELLED` | `amount_bucket`, `asset` |

## 6. Related docs

- [NFC library ADR](adr-nfc-library.md) — payload size/encoding constraints the
payment request payload must respect.
- [Product flows & system definition](ding-payments.md) — payment payload
structure this flow builds on.
- [Client MVP build plan](build-plan-client-mvp.md) — where C12 sits in the
overall build.
21 changes: 15 additions & 6 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AnimatedSplashOverlay } from '@/components/animated-icon';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { AuthProvider } from '@/features/auth/hooks/useAuth';
import { SessionPolicyMount } from '@/features/auth/components/SessionPolicyMount';
import { ReceivePaymentProvider } from '@/features/receive/hooks/useReceivePayment';

export default function RootLayout() {
const colorScheme = useColorScheme();
Expand All @@ -15,12 +16,20 @@ export default function RootLayout() {
<AuthProvider>
<SessionPolicyMount />
<AnimatedSplashOverlay />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(onboarding)" />
<Stack.Screen name="c05" />
</Stack>
{/*
ReceivePaymentProvider wraps every route so the FSM orchestrator
(CLI-065) survives navigation between (tabs)/receive and the
receive/* screens — Expo Router unmounts/remounts screens on
navigation, so a per-screen hook instance would lose state.
*/}
<ReceivePaymentProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(onboarding)" />
<Stack.Screen name="c05" />
</Stack>
</ReceivePaymentProvider>
</AuthProvider>
</ErrorBoundary>
</ThemeProvider>
Expand Down
5 changes: 5 additions & 0 deletions src/app/receive/failed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ReceiveFailedView } from '@/features/receive/views/ReceiveFailedView';

export default function ReceiveFailedScreen() {
return <ReceiveFailedView />;
}
5 changes: 5 additions & 0 deletions src/app/receive/listening.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ReceiveListeningView } from '@/features/receive/views/ReceiveListeningView';

export default function ReceiveListeningScreen() {
return <ReceiveListeningView />;
}
5 changes: 5 additions & 0 deletions src/app/receive/success.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ReceiveSuccessView } from '@/features/receive/views/ReceiveSuccessView';

export default function ReceiveSuccessScreen() {
return <ReceiveSuccessView />;
}
5 changes: 5 additions & 0 deletions src/app/receive/waiting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { WaitingForPaymentView } from '@/features/receive/views/WaitingForPaymentView';

export default function ReceiveWaitingScreen() {
return <WaitingForPaymentView />;
}
22 changes: 22 additions & 0 deletions src/constants/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,32 @@ export const AnalyticsEvents = {
SEND_OPENED: 'send_opened',
HISTORY_OPENED: 'history_opened',
SETTINGS_OPENED: 'settings_opened',

// Receive funnel (CLI-071) — see docs/receive-flow.md
RECEIVE_STARTED: 'receive_started',
RECEIVE_BROADCAST: 'receive_broadcast',
RECEIVE_WAITING: 'receive_waiting',
RECEIVE_COMPLETED: 'receive_completed',
RECEIVE_FAILED: 'receive_failed',
RECEIVE_CANCELLED: 'receive_cancelled',
NFC_READ_SUCCESS: 'nfc_read_success',
NFC_READ_FAILURE: 'nfc_read_failure',
NFC_WRITE_SUCCESS: 'nfc_write_success',
NFC_WRITE_FAILURE: 'nfc_write_failure',
} as const;

export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];

/**
* Amount bucket used in receive funnel events instead of raw amounts.
* Never log a raw amount, public key, or other PII in an analytics payload.
*/
export type AmountBucket = '<1' | '1-10' | '10-100' | '>100';

/** Allowed properties for receive funnel events. No pubkeys, no raw amounts, no PII. */
export interface ReceiveEventProperties {
amount_bucket: AmountBucket;
asset: string;
reason?: string;
duration_ms?: number;
}
66 changes: 66 additions & 0 deletions src/features/receive/__tests__/PaymentRequestBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
DEFAULT_EXPIRY_TTL_SECONDS,
PaymentRequestBuilder,
} from '@/features/receive/services/PaymentRequestBuilder';

const VALID_RECIPIENT = 'GBBD47IF6LWK7P7MUGHC2XLYUUXV6ZLW75PN7CHLIW2NSIW74UZEST66';

describe('PaymentRequestBuilder', () => {
it('returns a valid PaymentRequest shape', () => {
const builder = new PaymentRequestBuilder();

const request = builder.build({
amount: '25.50',
asset: 'USDC',
recipientPublicKey: VALID_RECIPIENT,
});

expect(request).toMatchObject({
type: 'payment_request',
recipient: VALID_RECIPIENT,
asset: 'USDC',
amount: '25.50',
});
expect(typeof request.timestamp).toBe('number');
expect(typeof request.expiresAt).toBe('number');
});

it('sets expiresAt to timestamp + DEFAULT_EXPIRY_TTL_SECONDS', () => {
const builder = new PaymentRequestBuilder();

const request = builder.build({
amount: '10',
asset: 'XLM',
recipientPublicKey: VALID_RECIPIENT,
});

expect(request.expiresAt - request.timestamp).toBe(DEFAULT_EXPIRY_TTL_SECONDS);
expect(DEFAULT_EXPIRY_TTL_SECONDS).toBe(5 * 60);
});

it('produces correct type/recipient/asset/amount across repeated calls', () => {
const builder = new PaymentRequestBuilder();

const first = builder.build({
amount: '1',
asset: 'XLM',
recipientPublicKey: VALID_RECIPIENT,
});
const second = builder.build({
amount: '1',
asset: 'XLM',
recipientPublicKey: VALID_RECIPIENT,
});

// createPaymentRequest derives its timestamp from Date.now(), not a uuid,
// so two calls within the same second may be identical — what must hold
// is that every field is internally consistent and well-formed.
for (const request of [first, second]) {
expect(request.type).toBe('payment_request');
expect(request.recipient).toBe(VALID_RECIPIENT);
expect(request.asset).toBe('XLM');
expect(request.amount).toBe('1');
expect(request.expiresAt).toBeGreaterThan(request.timestamp);
}
});
});
53 changes: 53 additions & 0 deletions src/features/receive/__tests__/receiveAmount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { receiveAmountSchema } from '@/features/receive/schemas/receiveAmount';

describe('receiveAmountSchema', () => {
it('accepts a valid XLM amount', () => {
const result = receiveAmountSchema.safeParse({ amount: '125.1234567', asset: 'XLM' });
expect(result.success).toBe(true);
});

it('accepts a valid USDC amount with 2 decimal places', () => {
const result = receiveAmountSchema.safeParse({ amount: '25.50', asset: 'USDC' });
expect(result.success).toBe(true);
});

it('rejects USDC amounts with 3 decimal places', () => {
const result = receiveAmountSchema.safeParse({ amount: '25.123', asset: 'USDC' });
expect(result.success).toBe(false);
});

it('accepts XLM amounts with up to 7 decimal places (not capped at 2)', () => {
const result = receiveAmountSchema.safeParse({ amount: '1.1234567', asset: 'XLM' });
expect(result.success).toBe(true);
});

it('rejects a zero amount', () => {
const result = receiveAmountSchema.safeParse({ amount: '0', asset: 'XLM' });
expect(result.success).toBe(false);
});

it('rejects a negative amount string', () => {
const result = receiveAmountSchema.safeParse({ amount: '-1', asset: 'XLM' });
expect(result.success).toBe(false);
});

it('rejects amounts greater than the maximum', () => {
const result = receiveAmountSchema.safeParse({ amount: '1000000', asset: 'XLM' });
expect(result.success).toBe(false);
});

it('accepts the maximum amount exactly', () => {
const result = receiveAmountSchema.safeParse({ amount: '999999', asset: 'XLM' });
expect(result.success).toBe(true);
});

it('rejects a non-numeric string', () => {
const result = receiveAmountSchema.safeParse({ amount: 'abc', asset: 'XLM' });
expect(result.success).toBe(false);
});

it('rejects an unsupported asset', () => {
const result = receiveAmountSchema.safeParse({ amount: '10', asset: 'BTC' });
expect(result.success).toBe(false);
});
});
63 changes: 63 additions & 0 deletions src/features/receive/__tests__/receiveSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ReceiveSessionManager } from '@/features/receive/services/receiveSession';

describe('ReceiveSessionManager', () => {
let manager: ReceiveSessionManager;

beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
manager = new ReceiveSessionManager();
});

afterEach(() => {
jest.useRealTimers();
});

it('startRequestExpiry fires onExpire once the expiry timestamp is reached', () => {
const onExpire = jest.fn();

manager.startRequestExpiry(5, onExpire);

jest.advanceTimersByTime(4_999);
expect(onExpire).not.toHaveBeenCalled();

jest.advanceTimersByTime(1);
expect(onExpire).toHaveBeenCalledTimes(1);
});

it('the cancel function returned by startRequestExpiry prevents onExpire from firing', () => {
const onExpire = jest.fn();

const cancel = manager.startRequestExpiry(5, onExpire);
cancel();

jest.advanceTimersByTime(10_000);
expect(onExpire).not.toHaveBeenCalled();
});

it('cancelAll() stops every active timer', () => {
const onExpire = jest.fn();
const onTimeout = jest.fn();

manager.startRequestExpiry(5, onExpire);
manager.startWaitTimeout(3_000, onTimeout);

manager.cancelAll();

jest.advanceTimersByTime(60_000);
expect(onExpire).not.toHaveBeenCalled();
expect(onTimeout).not.toHaveBeenCalled();
});

it('startWaitTimeout fires onTimeout after the given delay', () => {
const onTimeout = jest.fn();

manager.startWaitTimeout(60_000, onTimeout);

jest.advanceTimersByTime(59_999);
expect(onTimeout).not.toHaveBeenCalled();

jest.advanceTimersByTime(1);
expect(onTimeout).toHaveBeenCalledTimes(1);
});
});
Loading