Skip to content

Commit 320b8f4

Browse files
authored
Merge pull request #36 from davieslennox0/feat/c12-receive-payment-flow
feat(receive): implement C12 end-to-end receive payment flow
2 parents 8262e0b + a62630b commit 320b8f4

23 files changed

Lines changed: 1506 additions & 111 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ See [docs/adr-nfc-library.md](docs/adr-nfc-library.md) for platform constraints
116116
- [Product flows & system definition](docs/ding-payments.md)
117117
- [Client MVP build plan](docs/build-plan-client-mvp.md)
118118
- [NFC library ADR](docs/adr-nfc-library.md)
119+
- [Receive payment flow (C12)](docs/receive-flow.md)
119120
- [NFC runtime flow and troubleshooting](docs/nfc-flow.md)
120121
- [NFC device checklist](docs/nfc-device-checklist.md)
121122

docs/receive-flow.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Receive Payment Flow (C12)
2+
3+
The receive flow lets a user request a contactless payment: enter an amount,
4+
broadcast a payment request over NFC, wait for the payer's on-chain payment,
5+
and land on a success or failure screen. It is implemented as an explicit
6+
finite-state machine (FSM) orchestrated by `useReceivePayment` and shared
7+
across screens via `ReceivePaymentProvider`.
8+
9+
## 1. State diagram
10+
11+
```mermaid
12+
stateDiagram-v2
13+
[*] --> idle
14+
idle --> preparing: prepare(amount, asset, recipientPublicKey)
15+
preparing --> failed: trustline check fails (USDC only)
16+
preparing --> broadcasting: startBroadcast()
17+
broadcasting --> waiting: NFC writer reports success\n(payload delivered to payer)
18+
broadcasting --> failed: NFC writer reports error
19+
broadcasting --> cancelled: cancel() / request expiry
20+
waiting --> success: confirmSuccess(txHash?)
21+
waiting --> failed: confirmFailure(reason) / 60s wait timeout
22+
waiting --> cancelled: cancel()
23+
success --> idle: reset() ("Receive Another")
24+
failed --> idle: reset() ("Try Again" / "Change Amount")
25+
cancelled --> idle: reset()
26+
```
27+
28+
Note: NFC delivery success is **not** the same as payment success — it only
29+
means the request payload reached the payer's device. That's why
30+
`broadcasting` moves to `waiting` (not `success`) once the NFC writer session
31+
completes; `waiting` is resolved only by an explicit `confirmSuccess` /
32+
`confirmFailure` call (today: the 60-second wait timeout in
33+
`WaitingForPaymentView`; a future iteration can resolve it early via balance
34+
polling).
35+
36+
## 2. File map
37+
38+
| File | Responsibility |
39+
| --- | --- |
40+
| `src/features/receive/schemas/receiveAmount.ts` | Zod validation for the amount + asset pair (CLI-062) |
41+
| `src/features/receive/services/PaymentRequestBuilder.ts` | Builds a `PaymentRequest` via the shared `createPaymentRequest` (CLI-063) |
42+
| `src/features/receive/services/receiveSession.ts` | Owns the request-expiry and wait-timeout timers, cancellable and ghost-free (CLI-069) |
43+
| `src/features/wallet/services/TrustlineService.ts` | Checks whether the receiver has a USDC trustline before a USDC request is broadcast (CLI-070) |
44+
| `src/features/receive/hooks/useReceivePayment.ts` | FSM orchestrator + `ReceivePaymentProvider`/`useReceivePaymentContext` for cross-screen state (CLI-065) |
45+
| `src/features/receive/views/ReceiveHomeView.tsx` | Amount entry, asset selector, kicks off `prepare()` (CLI-061) |
46+
| `src/features/receive/views/ReceiveListeningView.tsx` | Starts the NFC broadcast, shows countdown + NFC status (CLI-064) |
47+
| `src/features/receive/views/WaitingForPaymentView.tsx` | Waits for payment settlement, 60s timeout (CLI-066) |
48+
| `src/features/receive/views/ReceiveSuccessView.tsx` | Success summary, reset/home actions (CLI-067) |
49+
| `src/features/receive/views/ReceiveFailedView.tsx` | Error-specific messaging, retry/change-amount actions (CLI-068) |
50+
| `src/constants/analytics-events.ts` | Receive funnel event names + sanitized property shape (CLI-071) |
51+
| `src/app/receive/{listening,waiting,success,failed}.tsx` | Expo Router screens for each non-home view |
52+
| `src/app/(tabs)/receive.tsx` | Tab entry point, renders `ReceiveHomeView` |
53+
| `src/app/_layout.tsx` | Mounts `ReceivePaymentProvider` above all routes so orchestrator state survives navigation |
54+
55+
## 3. Timeout model
56+
57+
Two independent timers, both owned by `ReceiveSessionManager` (`receiveSession`):
58+
59+
- **Request expiry**`startRequestExpiry(expiresAtSeconds, onExpire)`. Started
60+
in `startBroadcast()` using the `PaymentRequest.expiresAt` timestamp set at
61+
build time (`DEFAULT_EXPIRY_TTL_SECONDS = 5 * 60`, i.e. 5 minutes from
62+
`prepare()`). If the broadcast is still active when the request expires, the
63+
orchestrator cancels the session (`cancel()`).
64+
- **Wait timeout**`startWaitTimeout(ms, onTimeout)`. Started by
65+
`WaitingForPaymentView` when it mounts (`WAIT_TIMEOUT_MS = 60_000`). If no
66+
success/failure confirmation arrives within 60 seconds, the view calls
67+
`confirmFailure('timeout')`.
68+
69+
**Interaction**: request expiry only matters while broadcasting (the payer
70+
hasn't tapped yet); wait timeout only matters after the NFC handoff succeeded
71+
and we're waiting on settlement. They are mutually exclusive by construction —
72+
`startBroadcast()` cancels any prior expiry timer before starting a new one,
73+
and `cancel()` / `reset()` always call `receiveSession.cancelAll()` so no timer
74+
outlives its screen.
75+
76+
## 4. Error matrix
77+
78+
| Error code | Screen shown | User message | Recovery action |
79+
| --- | --- | --- | --- |
80+
| `timeout` | `ReceiveFailedView` | "Payment timed out. Please try again." | Try Again (same amount) or Change Amount |
81+
| `nfc_error` | `ReceiveFailedView` | "NFC connection was lost." | Try Again (same amount) or Change Amount |
82+
| `trustline_missing` | `ReceiveFailedView` | "USDC trustline not found. Set up your USDC account first." | Try Again (same amount) or Change Amount |
83+
| *(anything else)* | `ReceiveFailedView` | "Payment failed. Please try again." | Try Again (same amount) or Change Amount |
84+
85+
"Try Again" preserves the previously entered amount/asset by forwarding them as
86+
route params back to `ReceiveHomeView`; "Change Amount" resets fully and
87+
returns to a blank form.
88+
89+
## 5. Analytics event mapping
90+
91+
All receive events live in `AnalyticsEvents` (`src/constants/analytics-events.ts`)
92+
and carry only sanitized properties — **no public keys, no raw amounts, no
93+
PII**. Amounts are bucketed via `amount_bucket`: `'<1' | '1-10' | '10-100' | '>100'`.
94+
95+
| State transition | Event | Sanitized properties |
96+
| --- | --- | --- |
97+
| `idle``preparing` (`prepare()` called) | `RECEIVE_STARTED` | `amount_bucket`, `asset` |
98+
| `preparing``broadcasting` (`startBroadcast()`) | `RECEIVE_BROADCAST` | `amount_bucket`, `asset` |
99+
| `broadcasting``waiting` (NFC write succeeded) | `RECEIVE_WAITING` | `amount_bucket`, `asset` |
100+
| `waiting`/`broadcasting``success` (`confirmSuccess()`) | `RECEIVE_COMPLETED` | `amount_bucket`, `asset` |
101+
| any → `failed` (`confirmFailure(reason)`) | `RECEIVE_FAILED` | `amount_bucket`, `asset`, `reason` |
102+
| any → `cancelled` (`cancel()`) | `RECEIVE_CANCELLED` | `amount_bucket`, `asset` |
103+
104+
## 6. Related docs
105+
106+
- [NFC library ADR](adr-nfc-library.md) — payload size/encoding constraints the
107+
payment request payload must respect.
108+
- [Product flows & system definition](ding-payments.md) — payment payload
109+
structure this flow builds on.
110+
- [Client MVP build plan](build-plan-client-mvp.md) — where C12 sits in the
111+
overall build.

src/app/_layout.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AnimatedSplashOverlay } from '@/components/animated-icon';
55
import { ErrorBoundary } from '@/components/ErrorBoundary';
66
import { AuthProvider } from '@/features/auth/hooks/useAuth';
77
import { SessionPolicyMount } from '@/features/auth/components/SessionPolicyMount';
8+
import { ReceivePaymentProvider } from '@/features/receive/hooks/useReceivePayment';
89

910
export default function RootLayout() {
1011
const colorScheme = useColorScheme();
@@ -15,12 +16,20 @@ export default function RootLayout() {
1516
<AuthProvider>
1617
<SessionPolicyMount />
1718
<AnimatedSplashOverlay />
18-
<Stack screenOptions={{ headerShown: false }}>
19-
<Stack.Screen name="index" />
20-
<Stack.Screen name="(tabs)" />
21-
<Stack.Screen name="(onboarding)" />
22-
<Stack.Screen name="c05" />
23-
</Stack>
19+
{/*
20+
ReceivePaymentProvider wraps every route so the FSM orchestrator
21+
(CLI-065) survives navigation between (tabs)/receive and the
22+
receive/* screens — Expo Router unmounts/remounts screens on
23+
navigation, so a per-screen hook instance would lose state.
24+
*/}
25+
<ReceivePaymentProvider>
26+
<Stack screenOptions={{ headerShown: false }}>
27+
<Stack.Screen name="index" />
28+
<Stack.Screen name="(tabs)" />
29+
<Stack.Screen name="(onboarding)" />
30+
<Stack.Screen name="c05" />
31+
</Stack>
32+
</ReceivePaymentProvider>
2433
</AuthProvider>
2534
</ErrorBoundary>
2635
</ThemeProvider>

src/app/receive/failed.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ReceiveFailedView } from '@/features/receive/views/ReceiveFailedView';
2+
3+
export default function ReceiveFailedScreen() {
4+
return <ReceiveFailedView />;
5+
}

src/app/receive/listening.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ReceiveListeningView } from '@/features/receive/views/ReceiveListeningView';
2+
3+
export default function ReceiveListeningScreen() {
4+
return <ReceiveListeningView />;
5+
}

src/app/receive/success.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ReceiveSuccessView } from '@/features/receive/views/ReceiveSuccessView';
2+
3+
export default function ReceiveSuccessScreen() {
4+
return <ReceiveSuccessView />;
5+
}

src/app/receive/waiting.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { WaitingForPaymentView } from '@/features/receive/views/WaitingForPaymentView';
2+
3+
export default function ReceiveWaitingScreen() {
4+
return <WaitingForPaymentView />;
5+
}

src/constants/analytics-events.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,32 @@ export const AnalyticsEvents = {
44
SEND_OPENED: 'send_opened',
55
HISTORY_OPENED: 'history_opened',
66
SETTINGS_OPENED: 'settings_opened',
7+
8+
// Receive funnel (CLI-071) — see docs/receive-flow.md
9+
RECEIVE_STARTED: 'receive_started',
10+
RECEIVE_BROADCAST: 'receive_broadcast',
11+
RECEIVE_WAITING: 'receive_waiting',
12+
RECEIVE_COMPLETED: 'receive_completed',
13+
RECEIVE_FAILED: 'receive_failed',
14+
RECEIVE_CANCELLED: 'receive_cancelled',
715
NFC_READ_SUCCESS: 'nfc_read_success',
816
NFC_READ_FAILURE: 'nfc_read_failure',
917
NFC_WRITE_SUCCESS: 'nfc_write_success',
1018
NFC_WRITE_FAILURE: 'nfc_write_failure',
1119
} as const;
1220

1321
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
22+
23+
/**
24+
* Amount bucket used in receive funnel events instead of raw amounts.
25+
* Never log a raw amount, public key, or other PII in an analytics payload.
26+
*/
27+
export type AmountBucket = '<1' | '1-10' | '10-100' | '>100';
28+
29+
/** Allowed properties for receive funnel events. No pubkeys, no raw amounts, no PII. */
30+
export interface ReceiveEventProperties {
31+
amount_bucket: AmountBucket;
32+
asset: string;
33+
reason?: string;
34+
duration_ms?: number;
35+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
DEFAULT_EXPIRY_TTL_SECONDS,
3+
PaymentRequestBuilder,
4+
} from '@/features/receive/services/PaymentRequestBuilder';
5+
6+
const VALID_RECIPIENT = 'GBBD47IF6LWK7P7MUGHC2XLYUUXV6ZLW75PN7CHLIW2NSIW74UZEST66';
7+
8+
describe('PaymentRequestBuilder', () => {
9+
it('returns a valid PaymentRequest shape', () => {
10+
const builder = new PaymentRequestBuilder();
11+
12+
const request = builder.build({
13+
amount: '25.50',
14+
asset: 'USDC',
15+
recipientPublicKey: VALID_RECIPIENT,
16+
});
17+
18+
expect(request).toMatchObject({
19+
type: 'payment_request',
20+
recipient: VALID_RECIPIENT,
21+
asset: 'USDC',
22+
amount: '25.50',
23+
});
24+
expect(typeof request.timestamp).toBe('number');
25+
expect(typeof request.expiresAt).toBe('number');
26+
});
27+
28+
it('sets expiresAt to timestamp + DEFAULT_EXPIRY_TTL_SECONDS', () => {
29+
const builder = new PaymentRequestBuilder();
30+
31+
const request = builder.build({
32+
amount: '10',
33+
asset: 'XLM',
34+
recipientPublicKey: VALID_RECIPIENT,
35+
});
36+
37+
expect(request.expiresAt - request.timestamp).toBe(DEFAULT_EXPIRY_TTL_SECONDS);
38+
expect(DEFAULT_EXPIRY_TTL_SECONDS).toBe(5 * 60);
39+
});
40+
41+
it('produces correct type/recipient/asset/amount across repeated calls', () => {
42+
const builder = new PaymentRequestBuilder();
43+
44+
const first = builder.build({
45+
amount: '1',
46+
asset: 'XLM',
47+
recipientPublicKey: VALID_RECIPIENT,
48+
});
49+
const second = builder.build({
50+
amount: '1',
51+
asset: 'XLM',
52+
recipientPublicKey: VALID_RECIPIENT,
53+
});
54+
55+
// createPaymentRequest derives its timestamp from Date.now(), not a uuid,
56+
// so two calls within the same second may be identical — what must hold
57+
// is that every field is internally consistent and well-formed.
58+
for (const request of [first, second]) {
59+
expect(request.type).toBe('payment_request');
60+
expect(request.recipient).toBe(VALID_RECIPIENT);
61+
expect(request.asset).toBe('XLM');
62+
expect(request.amount).toBe('1');
63+
expect(request.expiresAt).toBeGreaterThan(request.timestamp);
64+
}
65+
});
66+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { receiveAmountSchema } from '@/features/receive/schemas/receiveAmount';
2+
3+
describe('receiveAmountSchema', () => {
4+
it('accepts a valid XLM amount', () => {
5+
const result = receiveAmountSchema.safeParse({ amount: '125.1234567', asset: 'XLM' });
6+
expect(result.success).toBe(true);
7+
});
8+
9+
it('accepts a valid USDC amount with 2 decimal places', () => {
10+
const result = receiveAmountSchema.safeParse({ amount: '25.50', asset: 'USDC' });
11+
expect(result.success).toBe(true);
12+
});
13+
14+
it('rejects USDC amounts with 3 decimal places', () => {
15+
const result = receiveAmountSchema.safeParse({ amount: '25.123', asset: 'USDC' });
16+
expect(result.success).toBe(false);
17+
});
18+
19+
it('accepts XLM amounts with up to 7 decimal places (not capped at 2)', () => {
20+
const result = receiveAmountSchema.safeParse({ amount: '1.1234567', asset: 'XLM' });
21+
expect(result.success).toBe(true);
22+
});
23+
24+
it('rejects a zero amount', () => {
25+
const result = receiveAmountSchema.safeParse({ amount: '0', asset: 'XLM' });
26+
expect(result.success).toBe(false);
27+
});
28+
29+
it('rejects a negative amount string', () => {
30+
const result = receiveAmountSchema.safeParse({ amount: '-1', asset: 'XLM' });
31+
expect(result.success).toBe(false);
32+
});
33+
34+
it('rejects amounts greater than the maximum', () => {
35+
const result = receiveAmountSchema.safeParse({ amount: '1000000', asset: 'XLM' });
36+
expect(result.success).toBe(false);
37+
});
38+
39+
it('accepts the maximum amount exactly', () => {
40+
const result = receiveAmountSchema.safeParse({ amount: '999999', asset: 'XLM' });
41+
expect(result.success).toBe(true);
42+
});
43+
44+
it('rejects a non-numeric string', () => {
45+
const result = receiveAmountSchema.safeParse({ amount: 'abc', asset: 'XLM' });
46+
expect(result.success).toBe(false);
47+
});
48+
49+
it('rejects an unsupported asset', () => {
50+
const result = receiveAmountSchema.safeParse({ amount: '10', asset: 'BTC' });
51+
expect(result.success).toBe(false);
52+
});
53+
});

0 commit comments

Comments
 (0)