diff --git a/frontend/mobile/__tests__/dataScreen.test.tsx b/frontend/mobile/__tests__/dataScreen.test.tsx
new file mode 100644
index 000000000..48b4cbd1e
--- /dev/null
+++ b/frontend/mobile/__tests__/dataScreen.test.tsx
@@ -0,0 +1,320 @@
+/**
+ * Tests for the data bundle purchase screen (#1133).
+ *
+ * All onramp calls are mocked — never against the live Linq provider.
+ *
+ * Covers the issue acceptance criteria:
+ * 1. Bundles listed per operator with size and validity
+ * 2. Empty bundle list reported gracefully (not as a failure)
+ * 3. Fee shown before the passkey prompt (confirm step)
+ * 4. End-to-end purchase flow against a mocked provider
+ * 5. Dashboard tile flipped from 'soon' → 'live' (PayForGrid)
+ */
+
+import React from 'react';
+
+// ── Mock declarations (top-level, matching feePayerScreen.test.tsx pattern) ───
+
+const mockFetchDataBundles = jest.fn();
+const mockGetOnrampRate = jest.fn();
+const mockPayBill = jest.fn();
+const mockSpendAsset = jest.fn();
+const mockGetBillStatus = jest.fn();
+const mockGetCustomerRef = jest.fn();
+const mockGetFeePayerAddress = jest.fn();
+const mockIsNairaVerified = jest.fn();
+const mockPreviewSpendFee = jest.fn();
+const mockRouterPush = jest.fn();
+const mockRouterBack = jest.fn();
+
+jest.mock('../lib/onramp', () => ({
+ fetchDataBundles: (...a: unknown[]) => mockFetchDataBundles(...a),
+ getOnrampRate: (...a: unknown[]) => mockGetOnrampRate(...a),
+ payBill: (...a: unknown[]) => mockPayBill(...a),
+ getBillStatus: (...a: unknown[]) => mockGetBillStatus(...a),
+ getCustomerRef: (...a: unknown[]) => mockGetCustomerRef(...a),
+ isNairaVerified: (...a: unknown[]) => mockIsNairaVerified(...a),
+}));
+
+jest.mock('../lib/spendAsset', () => {
+ class NotEnoughToSend extends Error {
+ available: number;
+ constructor(msg: string, available: number) {
+ super(msg);
+ this.available = available;
+ }
+ }
+ return {
+ spendAsset: (...a: unknown[]) => mockSpendAsset(...a),
+ previewSpendFee: (...a: unknown[]) => mockPreviewSpendFee(...a),
+ NotEnoughToSend,
+ };
+});
+
+jest.mock('../lib/activity', () => ({
+ getFeePayerAddress: (...a: unknown[]) => mockGetFeePayerAddress(...a),
+}));
+
+jest.mock('../lib/assets', () => ({
+ getAssetIssuer: jest.fn(() => 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'),
+}));
+
+jest.mock('../components/WalletProvider', () => ({
+ useWallet: () => ({ wallet: { deploy: jest.fn() } }),
+}));
+
+jest.mock('../hooks/useTheme', () => ({
+ useTheme: () => ({
+ colors: new Proxy({}, {
+ get: (_t: object, prop: string) =>
+ prop === 'border' ? 'rgba(0,0,0,0.1)' :
+ prop === 'danger' ? '#FF0000' :
+ prop === 'dangerSurface' ? 'rgba(255,0,0,0.1)' :
+ '#000000',
+ }),
+ }),
+}));
+
+jest.mock('../hooks/useNetwork', () => ({
+ useNetwork: () => ({ networkName: 'mainnet' }),
+}));
+
+jest.mock('expo-router', () => ({
+ useRouter: () => ({ push: mockRouterPush, back: mockRouterBack }),
+}));
+
+jest.mock('../components/FlowHeader', () => ({
+ FlowHeader: ({ title }: { title: string }) => {
+ const { Text } = require('react-native');
+ return {title};
+ },
+}));
+
+jest.mock('../components/SlideToConfirm', () => ({
+ SlideToConfirm: ({ onConfirm, label }: { onConfirm: () => void; label: string }) => {
+ const { Pressable, Text } = require('react-native');
+ return (
+
+ {label}
+
+ );
+ },
+}));
+
+jest.mock('react-native-safe-area-context', () => ({
+ SafeAreaView: ({ children }: { children: React.ReactNode }) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+}));
+
+// ── Top-level imports (after mocks, matching feePayerScreen pattern) ──────────
+
+import DataScreen from '../app/data';
+import { BILL_SERVICES } from '../components/PayForGrid';
+
+// ── Test renderer (no @types, minimal slice used) ─────────────────────────────
+
+type RNInstance = {
+ props: Record;
+ children: (RNInstance | string)[];
+};
+type TestRenderer = {
+ act: (fn: () => Promise | void) => Promise;
+ create: (el: React.ReactElement) => {
+ root: {
+ findByProps: (p: Record) => RNInstance;
+ findAllByProps: (p: Record) => RNInstance[];
+ };
+ };
+};
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const renderer: TestRenderer = require('react-test-renderer');
+
+function textContent(node: RNInstance): string {
+ return node.children
+ .map((c) => (typeof c === 'string' ? c : textContent(c)))
+ .join('');
+}
+
+// The first render pays for transforming the whole React Native + Expo graph.
+jest.setTimeout(60_000);
+
+// ── Fixtures ──────────────────────────────────────────────────────────────────
+
+const BUNDLES = [
+ { id: 'mtn-1gb-30d', label: '1 GB', sizeMb: 1024, validityDays: 30, amountNgn: 1500 },
+ { id: 'mtn-500mb-7d', label: '500 MB', sizeMb: 500, validityDays: 7, amountNgn: 500 },
+];
+
+// ── Setup ─────────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ mockFetchDataBundles.mockResolvedValue(BUNDLES);
+ mockGetOnrampRate.mockResolvedValue(1500); // ₦1,500 per USDC
+ mockGetCustomerRef.mockResolvedValue('veil_testref001');
+ mockGetFeePayerAddress.mockResolvedValue('GFEE0000000000000000000000000000000000000000000000000000');
+ mockIsNairaVerified.mockResolvedValue(true);
+ mockPreviewSpendFee.mockResolvedValue(null);
+ mockPayBill.mockResolvedValue({
+ id: 'order-abc123',
+ customerRef: 'veil_testref001',
+ wallet: 'GWALLET000000000000000000000000000000000000000000000000000',
+ status: 'pending',
+ });
+ mockSpendAsset.mockResolvedValue('txhash000000000000000000000000000000000000000000000000000000000000');
+ mockGetBillStatus.mockResolvedValue({ status: 'complete' });
+ mockRouterPush.mockReset();
+ mockRouterBack.mockReset();
+});
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+async function renderScreen() {
+ let tree!: ReturnType;
+ await renderer.act(async () => {
+ tree = renderer.create();
+ });
+ return tree.root;
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+it('renders the form step on mount with operator chips and phone input', async () => {
+ const root = await renderScreen();
+
+ // findAllByProps can return both the JS component and host (native) node for the same testID.
+ expect(root.findAllByProps({ testID: 'phone-input' }).length).toBeGreaterThanOrEqual(1);
+
+ const chips = root.findAllByProps({ accessibilityRole: 'radio' });
+ const labels = chips.map((c) => textContent(c));
+ expect(labels).toContain('MTN');
+ expect(labels).toContain('AIRTEL');
+ expect(labels).toContain('GLO');
+ expect(labels).toContain('9MOBILE');
+});
+
+it('lists bundles with size and validity, not price alone', async () => {
+ const root = await renderScreen();
+ await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); });
+
+ const b1 = root.findByProps({ testID: 'bundle-mtn-1gb-30d' });
+ const b2 = root.findByProps({ testID: 'bundle-mtn-500mb-7d' });
+
+ expect(textContent(b1)).toContain('1 GB');
+ expect(textContent(b1)).toContain('30 days');
+ expect(textContent(b2)).toContain('500 MB');
+ expect(textContent(b2)).toContain('7 days');
+
+ // Price is also present (the user needs it), but not the only thing shown.
+ expect(textContent(root.findByProps({ testID: 'bundle-price-mtn-1gb-30d' }))).toContain('₦1,500');
+});
+
+it('shows "No bundles available" gracefully when operator returns empty list', async () => {
+ mockFetchDataBundles.mockResolvedValue([]);
+ const root = await renderScreen();
+ await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); });
+
+ const notice = root.findByProps({ testID: 'empty-bundles-notice' });
+ expect(textContent(notice)).toContain('No bundles available');
+
+ // The continue button is disabled — no bundle to proceed with.
+ const btn = root.findByProps({ testID: 'continue-btn' });
+ expect(btn.props.disabled).toBe(true);
+});
+
+it('does NOT render an error card when the bundle list is simply empty', async () => {
+ mockFetchDataBundles.mockResolvedValue([]);
+ const root = await renderScreen();
+ await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); });
+
+ // Empty is informational, not an error.
+ expect(root.findAllByProps({ testID: 'bundles-error' })).toHaveLength(0);
+});
+
+it('shows cost summary once a bundle is selected', async () => {
+ const root = await renderScreen();
+ await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); });
+
+ // Select the 1 GB bundle.
+ await renderer.act(async () => {
+ (root.findByProps({ testID: 'bundle-mtn-1gb-30d' }).props.onPress as () => void)();
+ });
+
+ const summary = root.findByProps({ testID: 'cost-summary' });
+ expect(textContent(summary)).toContain('1 GB');
+ expect(textContent(summary)).toContain('30 days');
+
+ // Rate = ₦1,500 per USDC; bundle = ₦1,500 → 1.00 USDC.
+ const costNode = root.findByProps({ testID: 'cost-value' });
+ expect(textContent(costNode)).toContain('1.00 USDC');
+});
+
+it('advances to confirm step with the fee shown before the passkey gesture', async () => {
+ mockPreviewSpendFee.mockResolvedValue({ amount: '0.01', assetCode: 'USDC' });
+
+ const root = await renderScreen();
+ await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); });
+
+ // Select bundle + fill valid phone.
+ await renderer.act(async () => {
+ (root.findByProps({ testID: 'bundle-mtn-1gb-30d' }).props.onPress as () => void)();
+ });
+ await renderer.act(async () => {
+ (root.findByProps({ testID: 'phone-input' }).props.onChangeText as (v: string) => void)('08031234567');
+ });
+
+ // Tap continue.
+ await renderer.act(async () => {
+ (root.findByProps({ testID: 'continue-btn' }).props.onPress as () => void)();
+ await new Promise((r) => setTimeout(r, 100));
+ });
+
+ // SlideToConfirm present → we are on the confirm step.
+ // findAllByProps can return both JS and host node instances for the same testID.
+ expect(root.findAllByProps({ testID: 'slide-to-confirm' }).length).toBeGreaterThanOrEqual(1);
+
+ // previewSpendFee must be called before the user can slide (on the confirm step).
+ expect(mockPreviewSpendFee).toHaveBeenCalled();
+});
+
+it('calls payBill with the phone as customerId and the bundle id alongside it, then spendAsset', async () => {
+ const root = await renderScreen();
+ await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); });
+
+ await renderer.act(async () => {
+ (root.findByProps({ testID: 'bundle-mtn-1gb-30d' }).props.onPress as () => void)();
+ });
+ await renderer.act(async () => {
+ (root.findByProps({ testID: 'phone-input' }).props.onChangeText as (v: string) => void)('08031234567');
+ });
+
+ // Advance to confirm.
+ await renderer.act(async () => {
+ (root.findByProps({ testID: 'continue-btn' }).props.onPress as () => void)();
+ await new Promise((r) => setTimeout(r, 50));
+ });
+
+ // Confirm via slide.
+ await renderer.act(async () => {
+ (root.findByProps({ testID: 'slide-to-confirm' }).props.onPress as () => void)();
+ await new Promise((r) => setTimeout(r, 150));
+ });
+
+ expect(mockPayBill).toHaveBeenCalledWith(
+ expect.objectContaining({
+ billCategory: 'data',
+ customerId: '08031234567',
+ bundleId: 'mtn-1gb-30d',
+ provider: 'MTN',
+ }),
+ );
+ expect(mockSpendAsset).toHaveBeenCalled();
+});
+
+it('BILL_SERVICES data tile stays soon until the backend route exists', () => {
+ const dataTile = BILL_SERVICES.find((s) => s.id === 'data');
+ expect(dataTile).toBeDefined();
+ expect(dataTile?.status).toBe('soon');
+ expect(dataTile?.route).toBeUndefined();
+});
diff --git a/frontend/mobile/app/data.tsx b/frontend/mobile/app/data.tsx
new file mode 100644
index 000000000..2eca0f8f8
--- /dev/null
+++ b/frontend/mobile/app/data.tsx
@@ -0,0 +1,916 @@
+/**
+ * Buy a mobile data bundle with crypto from this wallet.
+ *
+ * ## Why this is not airtime
+ *
+ * Airtime is an arbitrary naira amount. A data bundle is a *product* — 1 GB
+ * for 30 days, chosen from the operator's own catalogue. The price comes from
+ * the selected bundle, not from a free-text field, and showing naira amounts
+ * alone tells the user nothing about what they are buying. This screen fetches
+ * the bundle list for the chosen operator and lets the user pick by size and
+ * validity.
+ *
+ * ## Same order-of-operations discipline as airtime
+ *
+ * `payBill` reserves the order and locks the rate before anything is signed.
+ * A reservation that fails costs nothing; a spend with no order behind it is
+ * money sent to an address that was never expecting it.
+ *
+ * ## "Empty list" is not a failure
+ *
+ * An operator with no bundles returns an empty array. The screen says so
+ * clearly rather than treating it as an error — the user has done nothing wrong.
+ *
+ * ## Fee shown before the passkey
+ *
+ * `previewSpendFee` is called on the confirm step, before the slide-to-pay
+ * gesture. A charge a user only finds in their history is a surprise, not a fee.
+ *
+ * ## Test boundary
+ *
+ * Linq has no sandbox. Everything is tested against mocks — never against the
+ * live provider. See `__tests__/dataScreen.test.tsx`.
+ */
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useRouter } from 'expo-router';
+
+import { FlowHeader } from '../components/FlowHeader';
+import { SlideToConfirm } from '../components/SlideToConfirm';
+import { useWallet } from '../components/WalletProvider';
+import { useTheme } from '../hooks/useTheme';
+import { useNetwork } from '../hooks/useNetwork';
+import type { ThemeColors } from '../lib/theme';
+import { fontFamily } from '../theme/typography';
+import { errorMessage } from '../lib/errorMessage';
+import { getAssetIssuer } from '../lib/assets';
+import { getFeePayerAddress } from '../lib/activity';
+import { NotEnoughToSend, previewSpendFee, spendAsset } from '../lib/spendAsset';
+import type { PlatformFee } from '../lib/platformFee';
+import {
+ getBillStatus,
+ getCustomerRef,
+ isNairaVerified,
+ getOnrampRate,
+ payBill,
+ fetchDataBundles,
+ type NairaCoin,
+ type DataBundle,
+} from '../lib/onramp';
+
+type Step = 'form' | 'confirm' | 'paying' | 'done' | 'failed';
+
+/**
+ * Nigerian mobile operators — same set as airtime for consistency.
+ */
+const OPERATORS = ['MTN', 'AIRTEL', 'GLO', '9MOBILE'] as const;
+type Operator = (typeof OPERATORS)[number];
+
+/** Phone-number prefixes by operator, for auto-detection. */
+const PREFIXES: Record = {
+ MTN: ['0803', '0806', '0703', '0706', '0813', '0816', '0810', '0814', '0903', '0906', '0913', '0916'],
+ AIRTEL: ['0802', '0808', '0708', '0812', '0701', '0902', '0901', '0904', '0907', '0912'],
+ GLO: ['0805', '0807', '0705', '0815', '0811', '0905', '0915'],
+ '9MOBILE': ['0809', '0818', '0817', '0909', '0908'],
+};
+
+function detectOperator(phone: string): Operator | null {
+ const digits = phone.replace(/\D/g, '');
+ if (digits.length < 4) return null;
+ const prefix = digits.slice(0, 4);
+ for (const [op, list] of Object.entries(PREFIXES) as [Operator, string[]][]) {
+ if (list.includes(prefix)) return op;
+ }
+ return null;
+}
+
+/** Format megabytes as "500 MB" or "1.5 GB" etc. */
+function formatSize(mb: number): string {
+ if (mb >= 1024) return `${(mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1)} GB`;
+ return `${mb} MB`;
+}
+
+function naira(n: number): string {
+ return `₦${n.toLocaleString('en-NG', { maximumFractionDigits: 2 })}`;
+}
+
+export default function DataScreen() {
+ const router = useRouter();
+ const { colors } = useTheme();
+ const styles = useMemo(() => createStyles(colors), [colors]);
+ const { wallet } = useWallet();
+
+ const { networkName } = useNetwork();
+ const mainnetOnly = networkName !== 'mainnet';
+
+ const [step, setStep] = useState('form');
+ const [error, setError] = useState(null);
+
+ // Form
+ const [operator, setOperator] = useState('MTN');
+ const [operatorTouched, setOperatorTouched] = useState(false);
+ const [phone, setPhone] = useState('');
+ const [coin, setCoin] = useState('usdc');
+ const [selectedBundle, setSelectedBundle] = useState(null);
+
+ // Bundle list
+ const [bundles, setBundles] = useState([]);
+ const [bundlesState, setBundlesState] = useState<'idle' | 'loading' | 'ready' | 'empty' | 'failed'>('idle');
+
+ // Exchange rate
+ const [rate, setRate] = useState(null);
+ const [rateState, setRateState] = useState<'loading' | 'ready' | 'failed'>('loading');
+
+ // Order
+ const [customerRef, setCustomerRef] = useState(null);
+ const [refundAddress, setRefundAddress] = useState(null);
+ const [orderId, setOrderId] = useState(null);
+ const [payHash, setPayHash] = useState(null);
+ const [spent, setSpent] = useState(null);
+ // Shown before the passkey prompt. Null when the Veil smart wallet charges no
+ // fee, which is the usual case — it is shown only when there is something
+ // to show.
+ const [veilFee, setVeilFee] = useState(null);
+ const [waitingForPasskey, setWaitingForPasskey] = useState(false);
+
+ const pollRef = useRef | null>(null);
+
+ // Auto-detect operator from phone prefix. The user can override it — numbers
+ // are ported, and a ported number sent to the wrong biller fails silently.
+ const detected = detectOperator(phone);
+ useEffect(() => {
+ if (detected && !operatorTouched) setOperator(detected);
+ }, [detected, operatorTouched]);
+
+ // Resolve the fee-payer address (refund target) and stable customer reference
+ // once on mount. Neither changes within a session.
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ const [addr, ref] = await Promise.all([
+ getFeePayerAddress().catch(() => null),
+ getCustomerRef().catch(() => null),
+ ]);
+ if (!cancelled) {
+ setRefundAddress(addr);
+ setCustomerRef(ref);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, []);
+
+ // Fetch exchange rate whenever the coin selection changes. Rate is per coin
+ // (XLM ≠ USDC), so switching the toggle must re-fetch rather than reuse.
+ const loadRate = useCallback(async () => {
+ if (mainnetOnly) return;
+ setRateState('loading');
+ try {
+ setRate(await getOnrampRate(coin));
+ setRateState('ready');
+ } catch {
+ setRate(null);
+ setRateState('failed');
+ }
+ }, [mainnetOnly, coin]);
+
+ useEffect(() => { void loadRate(); }, [loadRate]);
+
+ // Fetch bundle catalogue whenever the chosen operator changes.
+ const loadBundles = useCallback(async (op: Operator) => {
+ if (mainnetOnly) return;
+ setBundlesState('loading');
+ setBundles([]);
+ setSelectedBundle(null);
+ try {
+ const result = await fetchDataBundles(op);
+ if (result.length === 0) {
+ setBundlesState('empty');
+ } else {
+ setBundles(result);
+ setBundlesState('ready');
+ }
+ } catch {
+ setBundlesState('failed');
+ }
+ }, [mainnetOnly]);
+
+ useEffect(() => { void loadBundles(operator); }, [loadBundles, operator]);
+
+ // Poll the vend result. Unlike the onramp, nothing on-chain says whether the
+ // bundle activated — asking the status endpoint is the only way to know.
+ useEffect(() => {
+ if (!orderId || !customerRef || step !== 'paying' || !payHash) return;
+ const check = async () => {
+ try {
+ const s = await getBillStatus(customerRef, orderId);
+ if (/complete|success|delivered/i.test(s.status)) setStep('done');
+ if (/fail|refund|revers/i.test(s.status)) setStep('failed');
+ } catch (err) {
+ // Silent to the user: a failed poll is not actionable, and the payment
+ // has already left — an error here reads as if it had not, which risks
+ // a double-spend.
+ console.warn('[data] status poll failed:', errorMessage(err));
+ }
+ };
+ void check();
+ pollRef.current = setInterval(check, 10_000);
+ return () => {
+ if (pollRef.current) clearInterval(pollRef.current);
+ pollRef.current = null;
+ };
+ }, [orderId, customerRef, step, payHash]);
+
+ // Derived
+ const code = coin.toUpperCase();
+ const amountNgn = selectedBundle?.amountNgn ?? 0;
+ const cost = rate && amountNgn > 0 ? amountNgn / rate : 0;
+ const phoneDigits = phone.replace(/\D/g, '');
+ const phoneValid = phoneDigits.length === 11 && phoneDigits.startsWith('0');
+
+ // Preview the Veil platform fee on the confirm step — only then, and only
+ // when there is a real cost to compute. Reading balances on every keystroke
+ // would be a request per digit.
+ useEffect(() => {
+ if (step !== 'confirm' || cost <= 0) return;
+ let cancelled = false;
+ void (async () => {
+ const issuer = coin === 'usdc' ? getAssetIssuer('USDC', 'mainnet') : null;
+ const fee = await previewSpendFee({
+ amount: String(cost),
+ ...(issuer ? { asset: { code: 'USDC', issuer } } : {}),
+ }).catch(() => null);
+ if (!cancelled) setVeilFee(fee);
+ })();
+ return () => { cancelled = true; };
+ }, [step, cost, coin]);
+
+ // KYC gate: check before advancing to confirm. If not verified, redirect to
+ // /verify with enough context to return to this screen after.
+ const continueFromForm = useCallback(async () => {
+ if (await isNairaVerified()) {
+ setStep('confirm');
+ return;
+ }
+ router.push({
+ pathname: '/verify',
+ params: {
+ returnTo: '/data',
+ label: 'Data',
+ summary: `Data · ${selectedBundle?.label ?? ''} to ${phone}`,
+ },
+ });
+ }, [router, selectedBundle, phone]);
+
+ // Reserve the order, then spend. Reserve first — a failed reservation costs
+ // nothing; a spend with no order behind it is money sent into a void.
+ const pay = useCallback(async () => {
+ if (!customerRef || !refundAddress || !rate || !selectedBundle) return;
+ setStep('paying');
+ setError(null);
+ try {
+ const amountStableCoin = Number((selectedBundle.amountNgn / rate).toFixed(7));
+ const order = await payBill({
+ customerRef,
+ billCategory: 'data',
+ provider: operator,
+ // customerId is the subscriber, not the product. The bundle id travels
+ // alongside it in `bundleId` so Linq still knows which phone to credit.
+ customerId: phoneDigits,
+ // Never elide or abbreviate the provider's product id.
+ bundleId: selectedBundle.id,
+ amountNgn: selectedBundle.amountNgn,
+ amountStableCoin,
+ rate,
+ coin,
+ refundAddress,
+ });
+ setOrderId(order.id);
+ setSpent(amountStableCoin);
+
+ setWaitingForPasskey(true);
+ const issuer = coin === 'usdc' ? getAssetIssuer('USDC', 'mainnet') : null;
+ const hash = await spendAsset({
+ to: order.wallet,
+ // From the order, not from the screen: Linq settles at whatever
+ // arrives, so an amount that drifted would quietly buy less data.
+ amount: String(amountStableCoin),
+ ...(issuer ? { asset: { code: 'USDC', issuer } } : {}),
+ deploy: wallet.deploy,
+ });
+ setWaitingForPasskey(false);
+ setPayHash(hash);
+ } catch (err) {
+ setWaitingForPasskey(false);
+ if (err instanceof NotEnoughToSend) {
+ setError(
+ `This wallet holds ${err.available.toLocaleString('en-US', { maximumFractionDigits: 7 })} ${code}, less than this bundle costs.`,
+ );
+ } else {
+ setError(errorMessage(err));
+ }
+ setStep('confirm');
+ }
+ }, [customerRef, refundAddress, rate, selectedBundle, operator, phoneDigits, coin, wallet.deploy, code]);
+
+ const stepNumber = step === 'form' ? 1 : 2;
+
+ return (
+
+
+
+ {!mainnetOnly && (step === 'form' || step === 'confirm') ? (
+
+
+
+
+
+ {stepNumber} / 2
+
+ ) : null}
+
+
+
+
+ {mainnetOnly ? (
+
+ MAINNET ONLY
+ Switch to mainnet to buy data
+
+ Data bundles are vended against real crypto, so this only works on mainnet — there
+ is no test mode. Switch network in Settings, then come back.
+
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {/* ── d1 · form ─────────────────────────────────────────────────── */}
+ {!mainnetOnly && step === 'form' && (
+ <>
+ OPERATOR
+
+ {OPERATORS.map((op) => (
+ {
+ setOperator(op);
+ setOperatorTouched(true);
+ }}
+ style={[styles.chip, operator === op && styles.chipActive]}
+ accessibilityRole="radio"
+ accessibilityState={{ selected: operator === op }}
+ >
+
+ {op}
+
+
+ ))}
+
+
+
+ PHONE NUMBER
+
+ setPhone(t.replace(/[^\d]/g, '').slice(0, 11))}
+ keyboardType="number-pad"
+ placeholder="0803 512 4471"
+ placeholderTextColor={colors.textMuted}
+ testID="phone-input"
+ />
+
+ {detected ? (
+
+ {detected} number, detected from {phoneDigits.slice(0, 4)}
+ {operatorTouched && operator !== detected ? ` — sending to ${operator}` : ''}
+
+ ) : (
+
+ Numbers get ported — check the operator above matches.
+
+ )}
+
+
+ {/* Bundle catalogue ───────────────────────────────────────── */}
+ BUNDLE
+
+ {bundlesState === 'loading' && (
+
+
+ Loading bundles…
+
+ )}
+
+ {bundlesState === 'empty' && (
+
+ No bundles available
+
+ {operator} has no data bundles configured right now. Try another operator or
+ check back later.
+
+
+ )}
+
+ {bundlesState === 'failed' && (
+
+ Could not load bundles.
+ void loadBundles(operator)} hitSlop={8}>
+ Try again
+
+
+ )}
+
+ {bundlesState === 'ready' && bundles.map((b) => (
+ setSelectedBundle(b)}
+ accessibilityRole="radio"
+ accessibilityState={{ selected: selectedBundle?.id === b.id }}
+ accessibilityLabel={`${formatSize(b.sizeMb)}, ${b.validityDays} days, ${naira(b.amountNgn)}`}
+ style={({ pressed }) => [
+ styles.bundleRow,
+ selectedBundle?.id === b.id && styles.bundleRowActive,
+ pressed && styles.pressed,
+ ]}
+ testID={`bundle-${b.id}`}
+ >
+
+
+ {formatSize(b.sizeMb)}
+
+
+ {b.validityDays} day{b.validityDays !== 1 ? 's' : ''}
+
+
+
+ {naira(b.amountNgn)}
+
+
+ ))}
+
+ {selectedBundle ? (
+ <>
+
+ {(['xlm', 'usdc'] as NairaCoin[]).map((c) => (
+ setCoin(c)}
+ style={[styles.segmentItem, coin === c && styles.segmentActive]}
+ >
+
+ {c.toUpperCase()}
+
+
+ ))}
+
+
+
+
+ Bundle
+
+ {formatSize(selectedBundle.sizeMb)} · {selectedBundle.validityDays} day{selectedBundle.validityDays !== 1 ? 's' : ''}
+
+
+
+ Costs
+
+ {rateState === 'ready' && cost > 0
+ ? `${cost.toFixed(coin === 'xlm' ? 4 : 2)} ${code}`
+ : rateState === 'loading'
+ ? 'Checking…'
+ : '—'}
+
+
+ {rateState === 'failed' && (
+ void loadRate()}>
+ Rate unavailable — try again
+
+ )}
+
+ >
+ ) : null}
+
+ void continueFromForm()}
+ disabled={!phoneValid || !selectedBundle || rateState !== 'ready'}
+ style={({ pressed }) => [
+ styles.primaryBtn,
+ (!phoneValid || !selectedBundle || rateState !== 'ready') && styles.disabled,
+ pressed && styles.pressed,
+ ]}
+ testID="continue-btn"
+ >
+ Continue
+
+ >
+ )}
+
+ {/* ── d2 · confirm ──────────────────────────────────────────────── */}
+ {!mainnetOnly && step === 'confirm' && selectedBundle && (
+ <>
+
+
+ {formatSize(selectedBundle.sizeMb)} data for {phoneDigits} costs
+
+
+ {cost.toFixed(coin === 'xlm' ? 4 : 2)} {code}
+
+
+
+
+
+ Operator
+ {operator}
+
+
+ Phone
+ {phoneDigits}
+
+
+ Bundle
+
+ {formatSize(selectedBundle.sizeMb)} · {selectedBundle.validityDays} day{selectedBundle.validityDays !== 1 ? 's' : ''}
+
+
+
+ Paid from
+ {code} balance
+
+
+ Network fee
+ Sponsored
+
+ {veilFee ? (
+
+ Veil fee
+
+ {veilFee.amount} {veilFee.assetCode}
+
+
+ ) : null}
+
+
+
+
+ If {operator} can't activate the bundle, the full amount comes straight back
+ to this wallet.
+
+
+
+ void pay()} />
+ Then confirm with your passkey
+
+ setStep('form')}>
+ Back
+
+ >
+ )}
+
+ {/* ── d3 · passkey / in flight ──────────────────────────────────── */}
+ {!mainnetOnly && step === 'paying' && selectedBundle && (
+ <>
+
+
+ {waitingForPasskey ? 'Confirm with your passkey' : 'Activating your bundle'}
+
+
+ {waitingForPasskey
+ ? 'Face ID, fingerprint or device PIN, whichever this phone uses.'
+ : `${operator} is activating ${formatSize(selectedBundle.sizeMb)} on ${phoneDigits}.`}
+
+
+
+
+
+ Bundle
+
+ {formatSize(selectedBundle.sizeMb)} · {operator} {phoneDigits}
+
+
+
+ You spend
+
+ {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code}
+
+
+
+
+
+
+
+ {waitingForPasskey ? 'Waiting for your passkey…' : 'Waiting for activation…'}
+
+
+ >
+ )}
+
+ {/* ── d4 · done ─────────────────────────────────────────────────── */}
+ {!mainnetOnly && step === 'done' && selectedBundle && (
+ <>
+
+ Bundle activated
+
+ {formatSize(selectedBundle.sizeMb)} is on {phoneDigits}. It usually shows within
+ a minute.
+
+
+
+
+
+ Operator
+ {operator}
+
+
+ Bundle
+
+ {formatSize(selectedBundle.sizeMb)} · {selectedBundle.validityDays} day{selectedBundle.validityDays !== 1 ? 's' : ''}
+
+
+
+ You spent
+
+ {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code}
+
+
+ {orderId ? (
+
+ Reference
+ {orderId.slice(0, 8).toUpperCase()}
+
+ ) : null}
+
+
+ {
+ setStep('form');
+ setSelectedBundle(null);
+ setOrderId(null);
+ setPayHash(null);
+ setSpent(null);
+ void loadRate();
+ }}
+ style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]}
+ >
+ Buy again
+
+ router.back()}
+ style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]}
+ >
+ Done
+
+ >
+ )}
+
+ {/* ── d5 · failed, refunding ────────────────────────────────────── */}
+ {!mainnetOnly && step === 'failed' && selectedBundle && (
+ <>
+ {/*
+ * "We could not confirm this" — not "Payment failed".
+ *
+ * The user's crypto has left. Saying "failed" reads as if it
+ * has not, which could push them to pay again — double-spending
+ * against an order that may still complete. The wording follows
+ * the same discipline as the airtime screen and the issue spec.
+ */}
+
+ We could not confirm this
+
+ Your {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} is being returned
+ to this wallet. No data bundle was activated on {phoneDigits}.
+
+
+
+
+
+ {operator} did not complete the activation
+ —
+
+
+ Refund on its way back
+ Usually a few minutes
+
+
+
+
+ Nothing for you to do. It returns to this wallet and will show in your activity.
+
+
+ {
+ setStep('form');
+ setOrderId(null);
+ setPayHash(null);
+ setError(null);
+ void loadRate();
+ }}
+ style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]}
+ >
+ Try again
+
+ router.back()}
+ style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]}
+ >
+ Back to home
+
+ {orderId ? (
+ Ref {orderId.slice(0, 8).toUpperCase()}
+ ) : null}
+ >
+ )}
+
+
+
+ );
+}
+
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ screen: { flex: 1, backgroundColor: colors.background },
+ flex: { flex: 1 },
+ header: { paddingHorizontal: 20, paddingTop: 16 },
+ body: { padding: 20, paddingBottom: 60, gap: 16 },
+
+ progressRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 10 },
+ progressTrack: { flex: 1, flexDirection: 'row', height: 3, borderRadius: 2, backgroundColor: colors.border },
+ progressFill: { backgroundColor: colors.accent, borderRadius: 2 },
+ progressLabel: { color: colors.textMuted, fontFamily: fontFamily.body, fontSize: 12 },
+
+ card: {
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.border,
+ borderRadius: 16,
+ padding: 16,
+ gap: 8,
+ },
+ noteCard: {
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.border,
+ borderRadius: 16,
+ padding: 16,
+ backgroundColor: colors.surface,
+ },
+ errorCard: {
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.danger,
+ borderRadius: 12,
+ padding: 12,
+ backgroundColor: colors.dangerSurface,
+ },
+ errorText: { color: colors.danger, fontFamily: fontFamily.body, fontSize: 13, lineHeight: 19 },
+
+ eyebrow: { color: colors.textMuted, fontFamily: fontFamily.accent, fontSize: 11, letterSpacing: 0.8 },
+ question: { color: colors.textStrong, fontFamily: fontFamily.heading, fontSize: 20 },
+ hint: { color: colors.textMuted, fontFamily: fontFamily.body, fontSize: 13, lineHeight: 19 },
+ centeredHint: {
+ color: colors.textMuted,
+ fontFamily: fontFamily.body,
+ fontSize: 13,
+ textAlign: 'center',
+ },
+ money: { color: colors.textStrong, fontFamily: fontFamily.heading, fontSize: 28 },
+
+ row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 4, gap: 12 },
+ rowLabel: { color: colors.textMuted, fontFamily: fontFamily.body, fontSize: 13, flexShrink: 1 },
+ rowValue: { color: colors.textPrimary, fontFamily: fontFamily.body, fontSize: 13 },
+ rowValueStrong: { color: colors.textPrimary, fontFamily: fontFamily.bodySemiBold, fontSize: 15 },
+
+ field: { gap: 6 },
+ label: { color: colors.textMuted, fontFamily: fontFamily.accent, fontSize: 11, letterSpacing: 0.8 },
+
+ phoneRow: {
+ paddingTop: 8,
+ paddingBottom: 10,
+ borderBottomWidth: 1.5,
+ borderBottomColor: colors.accent,
+ },
+ phoneInput: {
+ color: colors.textStrong,
+ fontFamily: fontFamily.address,
+ fontSize: 21,
+ letterSpacing: 0.8,
+ padding: 0,
+ },
+
+ chipRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' },
+ chip: {
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ borderRadius: 12,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.border,
+ },
+ chipActive: { backgroundColor: colors.accent, borderColor: colors.accent },
+ chipText: { color: colors.textMuted, fontFamily: fontFamily.bodySemiBold, fontSize: 13 },
+ chipTextActive: { color: colors.onAccent },
+
+ bundleRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.border,
+ borderRadius: 14,
+ padding: 14,
+ gap: 12,
+ },
+ bundleRowActive: {
+ borderColor: colors.accent,
+ backgroundColor: 'rgba(253,218,36,0.06)',
+ },
+ bundleInfo: { gap: 2 },
+ bundleSize: {
+ color: colors.textPrimary,
+ fontFamily: fontFamily.bodySemiBold,
+ fontSize: 15,
+ },
+ bundleSizeActive: { color: colors.accent },
+ bundleValidity: { color: colors.textMuted, fontFamily: fontFamily.body, fontSize: 12 },
+ bundlePrice: {
+ color: colors.textPrimary,
+ fontFamily: fontFamily.bodySemiBold,
+ fontSize: 15,
+ },
+ bundlePriceActive: { color: colors.accent },
+
+ segment: { flexDirection: 'row', gap: 8 },
+ segmentItem: {
+ flex: 1,
+ alignItems: 'center',
+ paddingVertical: 10,
+ borderRadius: 12,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.border,
+ },
+ segmentActive: { backgroundColor: colors.accent, borderColor: colors.accent },
+ segmentText: { color: colors.textMuted, fontFamily: fontFamily.bodySemiBold, fontSize: 14 },
+ segmentTextActive: { color: colors.onAccent },
+
+ centeredRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ justifyContent: 'center',
+ paddingVertical: 8,
+ },
+ waitRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ justifyContent: 'center',
+ paddingVertical: 8,
+ },
+
+ primaryBtn: {
+ backgroundColor: colors.accent,
+ borderRadius: 999,
+ paddingVertical: 16,
+ alignItems: 'center',
+ },
+ primaryText: { color: colors.onAccent, fontFamily: fontFamily.bodySemiBold, fontSize: 16 },
+ secondaryBtn: {
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.border,
+ borderRadius: 999,
+ paddingVertical: 16,
+ alignItems: 'center',
+ },
+ secondaryText: { color: colors.textPrimary, fontFamily: fontFamily.bodySemiBold, fontSize: 16 },
+ linkText: { color: colors.accent, fontFamily: fontFamily.body, fontSize: 13, paddingTop: 6, textAlign: 'center' },
+
+ disabled: { opacity: 0.4 },
+ pressed: { opacity: 0.85 },
+ });
+}
diff --git a/frontend/mobile/lib/onramp.ts b/frontend/mobile/lib/onramp.ts
index 5b50f5ce0..27504ccb9 100644
--- a/frontend/mobile/lib/onramp.ts
+++ b/frontend/mobile/lib/onramp.ts
@@ -500,6 +500,44 @@ export function getOnrampStatus(customerRef: string, orderId: string): Promise {
+ const { bundles } = await call<{ bundles: DataBundle[] }>(
+ `/ngn/bills/data/bundles?operator=${encodeURIComponent(operator)}`,
+ );
+ return Array.isArray(bundles) ? bundles : [];
+}
+
export interface BillOrder {
id: string;
customerRef: string;
@@ -521,6 +559,12 @@ export function payBill(params: {
provider: string;
/** The phone number for airtime, the meter number for electricity. */
customerId: string;
+ /**
+ * The provider's opaque product id for a data purchase. Travelling next to
+ * `customerId` rather than replacing it is the whole point: the phone number
+ * says who, the bundle id says what. Mirrors wraith#305's purchase field.
+ */
+ bundleId?: string;
amountNgn: number;
amountStableCoin: number;
rate: number;