feat: Expo merchant POS app (dapps/merchant-pos-app)#510
Conversation
Standalone Expo app where a merchant self-onboards, connects their own settlement wallet (EVM + Solana) via Reown AppKit, and runs crypto POS payments and shareable payment links through the WalletConnect Pay API. - Onboarding (wallet = local identity): business details + logo, settlement networks, AppKit connect with already-registered guard, per-namespace sign-to-verify, token selection; merchant registry persisted in MMKV. - POS: numpad amount entry, real startPayment -> QR (gatewayUrl) + copy, status polling, 15-min countdown, cancel; success/cancelled screens. - Payment links: create via WCPay, list active/expired, native share. - Per-merchant scoped activity/stats; light+dark theme; expo-router. - Dev-only "reset storage" helper to re-onboard the same wallet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hare - StartPaymentRequest: optional expiresAt (epoch seconds). The WCPay API honors a client-supplied expiresAt; default is ~15 min for POS. Payment links now pass now + 10 days so the minted payment actually stays payable for the displayed validity window. - links/index.tsx: send expiresAt = now + 10d to startPayment, store the link using the server's echoed expiresAt, then pop the native share sheet right after generation so the merchant can send the link immediately. - BottomSheet: wrap modal content in GestureHandlerRootView so pressto's PressableScale receives touches (gesture-handler gestures don't fire inside RN <Modal> without a nested root — the Generate button was unresponsive). Also wrap in KeyboardAvoidingView so the action button stays above the keyboard while typing an amount. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Persistent install id (utils/install-id.ts, MMKV-backed) is minted on first launch and used as the merchant id (one merchant per install). - New services/merchant.ts: GET + PUT against pay-core's /v2/internal/merchant. syncMerchantToPayCore sources the next version (and createdAt) from the server, so the upsert is always serverVersion + 1 — never stale. - New services/cognito-auth.ts: OAuth2 client_credentials against the Cognito token endpoint, in-memory cache (50 min TTL, 1 min refresh buffer, in-flight promise lock), automatic retry on 401. - constants/token-contracts.ts: multi-chain CONTRACTS map (Ethereum, Optimism, Polygon, Base, Arbitrum, Celo, Monad + Solana mainnet) driving CAIP-10/-19 expansion. mta = true only for EVM entries; Solana entries are mta = false. providers.turnkey carries the EVM mtaAddresses (null when no EVM is connected). - Tokens screen: builds the upsert request from connected addresses across every supported chain × token and calls syncMerchantToPayCore. - Welcome screen: when a new wallet connects against an existing install merchant, upsert with the new wallet's per-namespace addresses (same merchantId, version bumped) and route Home — no need to re-onboard. - WCPay client now reads Merchant-Id from the active merchant in the store (not env); EXPO_PUBLIC_DEFAULT_MERCHANT_ID is gone. - Branding: use wc_logo_dark.png everywhere the old blue WcLogo SVG appeared (home nav, welcome, QR center, launcher icon). - POS amount: fix decimal-key — `.` now appends to any whole-number value (operator precedence bug made it only work on empty input). - .env.example + README updated with pay-core + Cognito env vars and the new identity/upsert flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Welcome routing simplified to a clear cascade — connected & !signed → verify, signed & !merchant → tokens, signed & merchant → home — using router.dismissTo so the wallet's return deep link pops down to the existing screen instance instead of re-mounting (no flash). - Signing progress (signedNamespaces) persists in the onboarding store so a re-mount of verify (e.g. wallet → Welcome → verify) restores per-namespace state; verify signs one message per click. - Switch-wallet upsert lives in verify.onSign (right after the final signature) instead of in the Welcome resume — keeps the cascade pure. - CONTRACTS tagged with token symbols; settlements now respect the merchant's token selection via getTokensCaip19(chain, symbolFilter). TOKENS list aligned with what's actually settleable (USDC/PYUSD/USDG on EVM; USDC/USDT on Solana). - AppKit instance extracted to services/appkit-instance.ts so utils/network-scope.ts can filter `appkit.namespaces` and the WC proposal only asks for the namespaces the merchant picked on S3. Welcome's Log in path restores the full set before opening. - wallet-accounts: getConnectedAccounts now dev-logs the connections Map shape (size + per-namespace accounts) for diagnosing scoping. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EXPO_PUBLIC_PAY_PARTNER_ID is read at module load in services/merchant.ts and validated inside syncMerchantToPayCore — callers (tokens, verify) no longer pass partnerId or carry their own env check. The thrown error flows into each caller's existing showErrorToast path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @ganchoradkov's task in 4m 42s —— View job 🚨 PR Too Large Files: 93 Lines: 26,357 Severity: HIGH Category: maintainability Found 10 issue(s)Issue 1: Cognito OAuth2 client secret bundled in mobile JS via EXPO_PUBLIC_ID: dcl-cognito-auth-client-secret-bundle-a3f1 Context:
Recommendation: Move the client_credentials exchange to a server-side proxy (Cloudflare Worker, Lambda, etc.) that holds the secret in a proper secrets manager. The mobile app calls the proxy and receives a short-lived app-scoped token. The file's own comment acknowledges this — it must be resolved before shipping to production. Issue 2: WCPay customer API key bundled in mobile JS via EXPO_PUBLIC_ID: dcl-env-example-customer-api-key-bundle-b7e2 Context:
Recommendation: Fetch the API key at runtime from an authenticated backend-for-frontend (BFF) or proxy all payment API calls server-side so the key never reaches the device. Issue 3: Cognito client secret logged to console unconditionally in productionID: dcl-cognito-auth-secret-in-logs-c4d8 Context:
Recommendation: // Remove `clientSecret` from the log. Log only which var names are missing.
if (__DEV__) {
console.error(
'Missing Cognito env vars:',
{ clientId: !!clientId, tokenEndpoint: !!tokenEndpoint }
);
}
throw new PayCoreCognitoAuthError('...');Issue 4:
|
There was a problem hiding this comment.
Pull request overview
Adds a new standalone Expo React Native app under dapps/merchant-pos-app for self-serve merchant onboarding, multi-namespace (EVM + Solana) wallet connect via Reown AppKit, and POS / payment-link flows backed by the real WCPay API and a pay-core merchant upsert.
Changes:
- New self-onboarding flow (business details → settlement networks → AppKit connect → per-namespace sign-to-verify → tokens) that mints a per-install merchant via
PUT /v2/internal/merchant, authed with a Cognito client-credentials token cached in-memory. - POS + payment-link screens wired to
startPayment/getPaymentStatus/cancelPayment, with QR + 15‑min countdown for POS and a 10‑day server expiry + native share sheet for links; runtime AppKit namespace scoping mutates the AppKit instance to filter the WC proposal. - New stores (merchant registry, payments, links, onboarding draft, settings) persisted to MMKV, plus a dev "Reset storage" helper, switch-wallet upsert in Verify, and supporting components/utilities.
Reviewed changes
Copilot reviewed 81 out of 93 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| dapps/merchant-pos-app/app/_layout.tsx | Wagmi/AppKit/QueryClient/Toast root + font + store hydration gating |
| dapps/merchant-pos-app/app/index.tsx | Welcome screen + cascade routing (verify/tokens/home) on connected wallet |
| dapps/merchant-pos-app/app/home.tsx | Home dashboard, address self-heal, disconnect + dev reset |
| dapps/merchant-pos-app/app/activity.tsx | Per-merchant local payment history list |
| dapps/merchant-pos-app/app/onboarding/*.tsx | 5-step onboarding (business details, networks, connect, verify, tokens) |
| dapps/merchant-pos-app/app/pos/*.tsx | POS amount/checkout/success/cancelled flow with QR + countdown + cancel |
| dapps/merchant-pos-app/app/links/index.tsx | Payment links list, create sheet, native share |
| dapps/merchant-pos-app/services/client.ts | WCPay fetch wrapper + Merchant-Id/Api-Key headers from active merchant |
| dapps/merchant-pos-app/services/payment.ts | startPayment / getPaymentStatus / cancelPayment thin wrappers |
| dapps/merchant-pos-app/services/hooks.ts | React Query hooks + status normalization + terminal callback |
| dapps/merchant-pos-app/services/merchant.ts | pay-core PUT/GET merchant, settlement builder, version bump on upsert |
| dapps/merchant-pos-app/services/cognito-auth.ts | Client-credentials Cognito token mint + in-memory + in-flight cache |
| dapps/merchant-pos-app/services/appkit-instance.ts | Reown AppKit instance (EVM + Solana adapters, AsyncStorage adapter) |
| dapps/merchant-pos-app/store/* | Zustand stores for merchants, onboarding draft, payments, links, settings |
| dapps/merchant-pos-app/utils/* | Polyfills, MMKV/AsyncStorage adapters, install-id, network-scope, wallet-accounts, currency, address, share, toast, dev-reset, types, merchant-config |
| dapps/merchant-pos-app/constants/* | Networks, tokens, token contracts (CAIP-19), spacing, theme tokens |
| dapps/merchant-pos-app/components/* | UI primitives (themed text/view, buttons, sheets, QR, cards, icons, etc.) |
| dapps/merchant-pos-app/hooks/* | Theme/color-scheme, countdown, sign-ownership per namespace |
| dapps/merchant-pos-app/{package.json,app.json,tsconfig.json,babel.config.js,metro.config.js,eslint.config.js,index.ts,.gitignore,.env.example,README.md} | App scaffolding, deps, build/runtime config, env template, docs |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!onboardingVerified) { | ||
| target = "/onboarding/verify"; | ||
| } else if (!findByMerchantId(getInstallId())) { | ||
| target = "/onboarding/tokens"; | ||
| } else { | ||
| setActive(address); | ||
| target = "/home"; | ||
| } |
| const env = { | ||
| clientId: process.env.EXPO_PUBLIC_PAY_CORE_COGNITO_CLIENT_ID, | ||
| clientSecret: process.env.EXPO_PUBLIC_PAY_CORE_COGNITO_CLIENT_SECRET, | ||
| tokenEndpoint: process.env.EXPO_PUBLIC_PAY_CORE_COGNITO_TOKEN_ENDPOINT, | ||
| scope: process.env.EXPO_PUBLIC_PAY_CORE_COGNITO_SCOPE, | ||
| }; |
| export const MerchantConfig = { | ||
| getCustomerApiKey: (): string | null => DEFAULT_CUSTOMER_API_KEY, | ||
| hasCustomerApiKey: (): boolean => Boolean(DEFAULT_CUSTOMER_API_KEY), | ||
| }; |
| "start": "expo start", | ||
| "android": "expo run:android", | ||
| "ios": "expo run:ios", | ||
| "web": "expo start --web", |
| > AppKit's network set is fixed at `createAppKit` time, so the Screen-3 network selection is stored | ||
| > as a settlement preference and rendered as scope rather than re-scoping AppKit at runtime. |
| export const EVM_NETWORKS = [mainnet, polygon, arbitrum, base]; | ||
| export const SOLANA_NETWORKS = [solana]; |
| useMerchantStore.getState().upsertMerchant({ | ||
| ...existing, | ||
| address, | ||
| namespace: ns, | ||
| merchantId: existing.merchantId ?? installId, | ||
| version, | ||
| addresses, | ||
| verifiedAt: Date.now(), | ||
| }); | ||
| useMerchantStore.getState().setActive(address); | ||
| showToast("Wallet switched"); | ||
| router.replace("/home"); |
| getKeys: async () => { | ||
| return (await AsyncStorage.getAllKeys()) as string[]; | ||
| }, | ||
| getEntries: async <T = any>(): Promise<[string, T][]> => { | ||
| const keys = await AsyncStorage.getAllKeys(); | ||
| return await Promise.all( | ||
| keys.map( | ||
| async (key) => | ||
| [ | ||
| key, | ||
| safeJsonParse((await AsyncStorage.getItem(key)) ?? "") as T, | ||
| ] as [string, T], | ||
| ), | ||
| ); | ||
| }, |
| <Screen> | ||
| <ScrollView contentContainerStyle={styles.content}> | ||
| <View style={styles.topBar}> | ||
| <WcLogo size={30} radius={9} /> |
| import { | ||
| PaymentStatusResponse, | ||
| StartPaymentRequest, | ||
| StartPaymentResponse, | ||
| } from "@/utils/types"; | ||
| import { useMutation, useQuery } from "@tanstack/react-query"; | ||
| import { useEffect, useRef } from "react"; | ||
| import { cancelPayment, getPaymentStatus, startPayment } from "./payment"; | ||
|
|
||
| const KNOWN_STATUSES: string[] = [ | ||
| "requires_action", | ||
| "processing", | ||
| "succeeded", | ||
| "failed", | ||
| "expired", | ||
| "cancelled", | ||
| ]; | ||
|
|
||
| /** | ||
| * Normalize a status response — unknown statuses become a final "failed" so | ||
| * the UI stops polling and routes through the failure path. | ||
| */ | ||
| export function normalizePaymentStatus( | ||
| data: PaymentStatusResponse, | ||
| ): PaymentStatusResponse { | ||
| if (!KNOWN_STATUSES.includes(data.status as string)) { | ||
| return { ...data, status: "failed", isFinal: true }; | ||
| } | ||
| return data; | ||
| } | ||
|
|
||
| export function useStartPayment() { | ||
| return useMutation<StartPaymentResponse, Error, StartPaymentRequest>({ | ||
| mutationFn: startPayment, | ||
| }); | ||
| } | ||
|
|
||
| export function useCancelPayment() { | ||
| return useMutation<void, Error, string>({ | ||
| mutationFn: cancelPayment, | ||
| }); | ||
| } | ||
|
|
||
| interface UsePaymentStatusOptions { | ||
| enabled?: boolean; | ||
| onTerminalState?: (data: PaymentStatusResponse) => void; | ||
| } | ||
|
|
||
| /** | ||
| * Poll a payment's status until it reaches a final state. Poll cadence follows | ||
| * the API's `pollInMs` (default 2s). Unknown statuses are normalized to failed. | ||
| */ | ||
| export function usePaymentStatus( | ||
| paymentId: string | null | undefined, | ||
| options: UsePaymentStatusOptions = {}, | ||
| ) { | ||
| const { enabled = true, onTerminalState } = options; | ||
|
|
||
| const hasCalledCallback = useRef(false); | ||
| const callbackRef = useRef(onTerminalState); | ||
| const previousPaymentIdRef = useRef(paymentId); | ||
|
|
||
| useEffect(() => { | ||
| callbackRef.current = onTerminalState; | ||
| }, [onTerminalState]); | ||
|
|
||
| useEffect(() => { | ||
| if (previousPaymentIdRef.current !== paymentId) { | ||
| hasCalledCallback.current = false; | ||
| previousPaymentIdRef.current = paymentId; | ||
| } | ||
| }, [paymentId]); | ||
|
|
||
| const query = useQuery<PaymentStatusResponse, Error>({ | ||
| queryKey: ["paymentStatus", paymentId], | ||
| queryFn: async () => { | ||
| if (!paymentId) throw new Error("Payment ID required"); | ||
| const data = await getPaymentStatus(paymentId); | ||
| return normalizePaymentStatus(data); | ||
| }, | ||
| enabled: enabled && !!paymentId, | ||
| refetchOnWindowFocus: false, | ||
| refetchOnMount: false, | ||
| refetchOnReconnect: false, | ||
| refetchInterval: (query) => { | ||
| const data = query.state.data; | ||
| if (data?.isFinal) return false; | ||
| const pollInMs = data?.pollInMs; | ||
| if ( | ||
| typeof pollInMs !== "number" || | ||
| !Number.isFinite(pollInMs) || | ||
| pollInMs <= 0 | ||
| ) { | ||
| return 2000; | ||
| } | ||
| return pollInMs; | ||
| }, | ||
| retry: 3, | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| const data = query.data; | ||
| if (data?.isFinal && !hasCalledCallback.current && callbackRef.current) { | ||
| hasCalledCallback.current = true; | ||
| callbackRef.current(data); | ||
| } | ||
| }, [query.data]); | ||
|
|
||
| return query; | ||
| } |
…ssion Payment links: - Links now store the WCPay paymentId and are reconciled by a new useReconcilePaymentLinks hook (runs on Home + Links while focused, polls every 5s). When a link's payment succeeds it's folded into the payments store (stamped now, so it counts toward today's volume when detected) and the link is marked recorded; other final states stop polling. Link rows now show Paid / Active / Expired. Previously a paid link never reached the payments store, so it was missing from Activity, Volume and the payment count. Sign-once-per-session: - Verification is now persisted per connected address in useMerchantStore (verifiedAddresses + isVerified/markVerified/clearVerified) instead of an in-memory onboarding flag, so an app restart with the same live wallet session no longer re-prompts for a signature. - A SessionWatcher in _layout clears verification on a real connected -> disconnected transition (ref-guarded so cold-start session restore doesn't trip it), so disconnect + reconnect signs again. - Welcome cascade gates on isVerified(address); dev reset clears it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Disconnect now resets the onboarding signing progress (useOnboardingStore.resetVerification) alongside the persisted verifiedAddresses, so reconnecting the same wallet starts verify fresh instead of showing every namespace as already-signed with a disabled Continue button. Also drop the now-dead onboarding flags (`verified`, `started`) — the routing cascade reads merchantStore.isVerified(address), making those write-only. Verification truth lives solely in useMerchantStore.verifiedAddresses; the onboarding store keeps only the draft + per-namespace signing progress. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lows - create-ios-app.yaml: workflow_dispatch to register an app in App Store Connect (fastlane produce) and create its certs on CI via the App Store Connect API key (no Apple ID / 2FA). Certs are pushed to a branch over SSH (--no-pr); a human merges the PR in reown-com/mobile-match. No GitHub token required. - release-merchant-pos.yaml: build + TestFlight/Firebase release dispatcher. - Fastfile: new create_app and create_certs lanes (API-key auth). - create-certificates.sh: API-key auth path + --no-pr mode for CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…param produce has no api_key option; app_store_connect_api_key sets the Spaceship token globally (set_spaceship_token) and produce picks it up implicitly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
produce requires a username config value even when authenticating via the App Store Connect API key (token handles auth, so no 2FA). Wire APPLE_USERNAME into the create_app lane and the Create iOS App workflow step. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fastlane produce only supports Apple ID + 2FA auth (it ignores the App Store Connect API key), so it can't run unattended on CI. Remove the create_app lane and the produce step; Create iOS App now only creates certificates (which also registers the bundle id in the Developer Portal). The App Store Connect app record is created manually once per app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
match does not register the bundle id — the App ID must be registered in the Developer Portal and the App Store Connect record created first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Without pre-creating the branch from master, match builds an orphan branch (no common ancestor, only the new files) and re-mints a duplicate certificate because it can't see the existing shared one. Pre-create the branch from master over SSH (no token) so match adds only the new profile on top, reuses the shared cert, and the branch is a clean, mergeable diff. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expo prebuild needs ios.appleTeamId to write DEVELOPMENT_TEAM into the Xcode project; without it the archive fails with 'requires a development team'. Matches pos-app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The workflow only creates signing certificates now (app-record creation is manual), so the display name reflects that. Filename kept as create-ios-app.yaml to match the dispatch placeholder already on main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
It only creates certificates, not the app — filename now matches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…CI cert workflow Adds docs/releasing-a-new-app.md (manual app/App-ID creation, Create iOS Certificates workflow, cert-PR merge, release, and troubleshooting incl. the multiple-distribution-cert mismatch and TestFlight group setup). Updates the README certificates section to the CI workflow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Internal vs external testing, Beta App Review, enabling the public link, the demo-account requirement, and 90-day build expiry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the Cognito-authed pay-core internal upsert with the public WalletConnect Pay REST API, authed by the partner Api-Key already used for payments. Removes the embedded Cognito client secret and the internal-API dependency. - services/merchant.ts: createMerchant (POST /v1/merchants), settlements via .../settlements/crypto (build/get/sync with add/update/delete diff), provisionMerchant; drop cognito + versioned upsert - services/client.ts: add put/delete, 204/empty-body handling, getMerchantManagementHeaders (no Merchant-Id required), full error-body logging for validation failures - store: persist install-scoped installMerchantId (anchors "already onboarded?" routing now that merchant ids are server-assigned); bump persist version to 2 - onboarding/routing: tokens.tsx provisions + stores id; verify.tsx re-syncs settlements on wallet switch; index.tsx/_layout.tsx drop install-id - appkit-instance.ts: require EXPO_PUBLIC_PROJECT_ID (drop hardcoded id) - delete cognito-auth.ts and install-id.ts; prune pay-core/cognito/partner env keys from .env.example Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a standalone Expo merchant POS app under
dapps/merchant-pos-app— merchant self-onboards in the app, connects their own wallet(s) via Reown AppKit (EVM + Solana), is upserted into pay-core, and runs crypto POS payments + shareable payment links through the WCPay API.TL;DR
utils/install-id.tsapp/onboarding/*PUT /v2/internal/merchantto pay-core, Cognito client_credentials (50-min in-mem cache),version = serverVersion + 1services/merchant.ts,services/cognito-auth.ts/merchant/payment+ status polling + 15-min countdown; QR encodesgatewayUrlapp/pos/*,services/payment.ts/merchant/paymentbut with a 10-dayexpiresAt; native share sheet popsapp/links/index.tsxapp/onboarding/verify.tsxOnboarding flow
flowchart TD W([Welcome]) GS{{Get started}} LI{{Log in}} BD[Business details] NW[Networks] CW[Connect wallet] VF[Verify · sign per namespace] TK[Tokens] HM([Home]) UPS[(pay-core upsert)] W -- Get started --> GS --> BD --> NW --> CW W -- Log in --> LI --> CW CW -- wallet approved --> VF VF -- all signed · merchant exists --> UPS -- success --> HM VF -- all signed · no merchant yet --> TK TK -- Finish setup --> UPS --> HMWelcome routing cascade
The wallet's return deep link (
merchantpos://) lands on Welcome. The cascade decides where to go from the connection + onboarding state —router.dismissTopops down to an existing instance instead of remounting (no flash).flowchart TD S{Welcome focused} C{Connected wallet?} V{All required namespaces signed?} M{findByMerchantId for this install?} Stay([Stay on Welcome]) Verify([/onboarding/verify]) Tokens([/onboarding/tokens]) Home([/home]) S --> C C -- no --> Stay C -- yes --> V V -- no --> Verify V -- yes --> M M -- no --> Tokens M -- yes --> HomeMerchant upsert against pay-core
syncMerchantToPayCoresourcesversion+createdAtfrom the server so we never resend a stale local version. Cognito tokens are minted via client_credentials and cached in-process for 50 min, with a 1-min refresh buffer and a single-flight lock; 401 invalidates + retries once.sequenceDiagram participant App participant Cognito participant PayCore as pay-core App->>App: installId = getInstallId() App->>Cognito: POST /oauth2/token (client_credentials) Cognito-->>App: access_token (expires_in 3600) App->>App: cache token (TTL 50min) App->>PayCore: GET /v2/internal/merchant/{installId} alt 404 PayCore-->>App: null Note over App: version = 1 else 200 PayCore-->>App: { version: n, ... } Note over App: version = n + 1 end App->>PayCore: PUT /v2/internal/merchant<br/>(cryptoSettlements × chain × token,<br/>turnkey.mtaAddresses = EVM CAIP-10s) PayCore-->>App: 204 No ContentPOS payment
Amount entered in fiat (
iso4217/USDor/EUR). The QR encodes the server's returnedgatewayUrl; polling cadence comes from the API'spollInMs.sequenceDiagram participant Merchant participant Mobile participant WCPay participant Customer Merchant->>Mobile: enter amount + currency · tap Charge Mobile->>WCPay: POST /merchant/payment<br/>(amount, referenceId, Merchant-Id = installId) WCPay-->>Mobile: { paymentId, expiresAt, gatewayUrl, pollInMs } Mobile->>Mobile: render QR(gatewayUrl), start 15-min countdown loop until isFinal Mobile->>WCPay: GET /merchant/payment/{paymentId}/status WCPay-->>Mobile: { status, isFinal, pollInMs } end Customer->>WCPay: opens gatewayUrl, pays WCPay-->>Mobile: succeeded (final poll) Mobile->>Mobile: append to local payments + Success screenPayment links
Same
startPaymentcall, but with a client-suppliedexpiresAt = now + 10 days(the API echoes this exactly — confirmed by probe; default is ~15 min). After mint, the native share sheet pops with the gateway URL.Architecture
flowchart LR subgraph UI [UI · expo-router] Welcome Onboarding[Onboarding screens] Home POS[POS · amount/checkout/success] Links[Payment links] Activity end subgraph Stores [Zustand · MMKV-persisted] M[useMerchantStore] O[useOnboardingStore] Pay[usePaymentsStore] L[usePaymentLinksStore] S[useSettingsStore] end subgraph Services Mer[services/merchant.ts] Cog[services/cognito-auth.ts] Pmt[services/payment.ts] AK[services/appkit-instance.ts] end subgraph External WC[Reown AppKit · WC] Wallet[Customer Wallet] Pc[pay-core /v2/internal/merchant] WP[WCPay /merchant/payment] CO[Cognito /oauth2/token] end Welcome --> M & O Onboarding --> M & O & Mer Home --> M & Pay POS --> Pmt & Pay & M Links --> Pmt & L & M Mer --> Cog Mer --> Pc Cog --> CO Pmt --> WP AK --> WC WC --> WalletWhat's in this PR
dapps/merchant-pos-app/(source + assets only;merchant-pos-prototype.html,requirements.md,node_modules/,ios/,android/,.envall excluded).services/appkit-instance.tsextracts the AppKit instance soutils/network-scope.tscan filterappkit.namespaces(andLog inrestores the full set) — the WC connect proposal then asks only for the namespaces the merchant picked on Screen 3.constants/token-contracts.tscarries the multi-chainCONTRACTSmap (USDC on every supported EVM chain + PYUSD/USDG on mainnet; USDC + USDT on Solana mainnet).getTokensCaip19(chainPrefix, allowedSymbols?)filters by the merchant's selection.useOnboardingStore.signedNamespacesso a remount restores per-namespace state.wc_logo_dark.pngmark is used for the launcher icon, top nav, and QR center.Env
.env(see.env.example):Security note.
EXPO_PUBLIC_*values are inlined into the JS bundle at build time, so the Cognito client secret ends up in the APK. The app works as-is with a non-prod Cognito client, but for production-grade the next step is a thin Next.js bridge route indashboard-newso the secret stays server-side and the mobile app only carries a non-secret bridge URL/key.Test plan
cd dapps/merchant-pos-app && npm install.env.example→.envand fill valuesnpx expo run:android(orrun:ios) for a dev build — orcd android && ./gradlew assembleReleasefor a release APK[merchant-api] → PUT … v<n>logs incrementnacross re-onboards (server-driven version)🤖 Generated with Claude Code