Skip to content

feat: Expo merchant POS app (dapps/merchant-pos-app)#510

Open
ganchoradkov wants to merge 20 commits into
mainfrom
feat/merchant-pos-app
Open

feat: Expo merchant POS app (dapps/merchant-pos-app)#510
ganchoradkov wants to merge 20 commits into
mainfrom
feat/merchant-pos-app

Conversation

@ganchoradkov

@ganchoradkov ganchoradkov commented May 28, 2026

Copy link
Copy Markdown
Member

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

Step What Where
Mint identity Per-install UUID minted on first launch, persisted in MMKV utils/install-id.ts
Onboard Business details → settlement networks → AppKit connect → sign per namespace → tokens → Finish app/onboarding/*
Upsert merchant PUT /v2/internal/merchant to pay-core, Cognito client_credentials (50-min in-mem cache), version = serverVersion + 1 services/merchant.ts, services/cognito-auth.ts
Charge WCPay /merchant/payment + status polling + 15-min countdown; QR encodes gatewayUrl app/pos/*, services/payment.ts
Links Same /merchant/payment but with a 10-day expiresAt; native share sheet pops app/links/index.tsx
Switch wallet Log in with a new wallet → after signing, re-upsert the install's merchant with the new addresses app/onboarding/verify.tsx

Onboarding 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 --> HM
Loading

Welcome routing cascade

The wallet's return deep link (merchantpos://) lands on Welcome. The cascade decides where to go from the connection + onboarding state — router.dismissTo pops 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 --> Home
Loading

Merchant upsert against pay-core

syncMerchantToPayCore sources version + createdAt from 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 Content
Loading

POS payment

Amount entered in fiat (iso4217/USD or /EUR). The QR encodes the server's returned gatewayUrl; polling cadence comes from the API's pollInMs.

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 screen
Loading

Payment links

Same startPayment call, but with a client-supplied expiresAt = 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 --> Wallet
Loading

What's in this PR

  • 5 commits, ~93 files under dapps/merchant-pos-app/ (source + assets only; merchant-pos-prototype.html, requirements.md, node_modules/, ios/, android/, .env all excluded).
  • services/appkit-instance.ts extracts the AppKit instance so utils/network-scope.ts can filter appkit.namespaces (and Log in restores the full set) — the WC connect proposal then asks only for the namespaces the merchant picked on Screen 3.
  • constants/token-contracts.ts carries the multi-chain CONTRACTS map (USDC on every supported EVM chain + PYUSD/USDG on mainnet; USDC + USDT on Solana mainnet). getTokensCaip19(chainPrefix, allowedSymbols?) filters by the merchant's selection.
  • Verify signs one message per click; progress lives in useOnboardingStore.signedNamespaces so a remount restores per-namespace state.
  • Branding: the wc_logo_dark.png mark is used for the launcher icon, top nav, and QR center.

Env

.env (see .env.example):

EXPO_PUBLIC_PROJECT_ID=""              # Reown AppKit
EXPO_PUBLIC_API_URL=""                 # WCPay (include /v1)
EXPO_PUBLIC_DEFAULT_CUSTOMER_API_KEY="" # WCPay partner-scoped key

EXPO_PUBLIC_PAY_CORE_API_URL=""        # pay-core internal
EXPO_PUBLIC_PAY_PARTNER_ID=""

EXPO_PUBLIC_PAY_CORE_COGNITO_TOKEN_ENDPOINT=""
EXPO_PUBLIC_PAY_CORE_COGNITO_CLIENT_ID=""
EXPO_PUBLIC_PAY_CORE_COGNITO_CLIENT_SECRET=""
EXPO_PUBLIC_PAY_CORE_COGNITO_SCOPE=""

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 in dashboard-new so 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
  • Copy .env.example.env and fill values
  • npx expo run:android (or run:ios) for a dev build — or cd android && ./gradlew assembleRelease for a release APK
  • Get started → business details → networks (try EVM-only, Solana-only, both) → connect a wallet → sign per connected namespace → tokens → Finish setup → Home
  • POS from Home → New payment → enter amount → Charge → open the QR URL in another wallet → status flips to "Payment received!"
  • Payment links → Create → native share sheet pops; row shows Active for 10 days
  • Switch wallet: disconnect → Log in with a different wallet → sign → upserts the install's merchant with the new addresses → Home
  • Dev reset wipes registry/payments/links/AppKit session so the same wallet can be re-onboarded
  • Confirm [merchant-api] → PUT … v<n> logs increment n across re-onboards (server-driven version)

🤖 Generated with Claude Code

ganchoradkov and others added 5 commits May 27, 2026 16:58
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>
Copilot AI review requested due to automatic review settings May 28, 2026 14:32
@vercel

vercel Bot commented May 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pos-demo Ready Ready Preview, Comment Jun 5, 2026 9:07am

Request Review

@claude

claude Bot commented May 28, 2026

Copy link
Copy Markdown

Claude finished @ganchoradkov's task in 4m 42s —— View job


🚨 PR Too Large Files: 93 Lines: 26,357 Severity: HIGH Category: maintainability
This is a full new dapp (acceptable for a standalone addition), but note for review overhead.


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
File: dapps/merchant-pos-app/services/cognito-auth.ts:19
Severity: CRITICAL
Category: security / data_classification

Context:

  • Pattern: EXPO_PUBLIC_PAY_CORE_COGNITO_CLIENT_SECRET is statically inlined into the JS bundle at build time by Metro. EXPO_PUBLIC_* vars are Expo's mechanism for client-side env exposure — they land verbatim in the compiled bundle and are trivially extractable from any APK via string extraction.
  • Risk: A Cognito client_credentials secret assumes server-side confidentiality. Any actor who downloads the APK can extract the secret and impersonate the app to request unlimited Cognito access tokens.
  • Impact: Unrestricted access to pay-core's internal /v2/internal/merchant API — unauthorized merchant creation, modification, deletion.
  • Trigger: Immediately on any production APK build with a real client secret.

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
File: dapps/merchant-pos-app/.env.example:9
Severity: CRITICAL
Category: security / data_classification

Context:

  • Pattern: EXPO_PUBLIC_DEFAULT_CUSTOMER_API_KEY is a partner-scoped key for the WCPay payment API, also baked into the bundle.
  • Risk: Allows any actor who extracts it to start/cancel payments under any merchant using this partner key.
  • Impact: Financial disruption or fraudulent payment flows against live merchants.
  • Trigger: On any APK distribution with a real key.

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 production

ID: dcl-cognito-auth-secret-in-logs-c4d8
File: dapps/merchant-pos-app/services/cognito-auth.ts:59-62
Severity: CRITICAL
Category: security / data_classification

Context:

  • Pattern: The console.error block at lines 59–62 passes { clientId, clientSecret, tokenEndpoint } — the raw secret value — without a __DEV__ guard. Every other logging call in this file is correctly gated on __DEV__, but this error path fires in production builds whenever env vars are missing.
  • Risk: Secret exposed in crash reporters (Sentry, Crashlytics), MDM device logs, and any attached diagnostic pipeline.
  • Trigger: Misconfigured production build (missing env vars) or staging/prod config mismatch.

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: cancelPayment uses wrong URL path — missing /merchant prefix

ID: payment-cancelPayment-url-mismatch-b3e1
File: dapps/merchant-pos-app/services/payment.ts:34
Severity: HIGH
Category: bug

Context:

  • Pattern: cancelPayment posts to /payments/${paymentId}/cancel, but startPayment posts to /merchant/payment and getPaymentStatus gets from /merchant/payment/${paymentId}/status. The path is inconsistent — /merchant/ prefix is missing.
  • Risk: Every cancel call will 404. The error is silently swallowed in checkout.tsx:115 (catch { // may 400... fall through }), so merchants will see the checkout navigate away thinking it was cancelled while the payment remains live on the server.
  • Trigger: Anytime a merchant taps Cancel on the checkout screen.

Recommendation:

// services/payment.ts:34
await apiClient.post(`/merchant/payment/${paymentId}/cancel`, {}, { headers });

Fix this →


Issue 5: Ownership signature collected but never verified — merchant upsert accepts any signer

ID: verify-signature-no-server-validation-e9a2
File: dapps/merchant-pos-app/app/onboarding/verify.tsx:107 / dapps/merchant-pos-app/services/merchant.ts:273
Severity: HIGH
Category: security

Context:

  • Pattern: signOwnership in use-sign-ownership.ts produces a signature but it is neither sent to pay-core nor validated anywhere. The MerchantUpsertRequest built in syncMerchantToPayCore contains no signature field. The signature is only used as a UI gate (allSigned) to unblock the "Continue" button.
  • Risk: The signing step provides no actual cryptographic proof to the server that the wallet owner authorized this merchant. An attacker who intercepts or replays the onboarding flow can upsert any address as a settlement address without ever holding the corresponding private key.
  • Impact: Settlement funds can be redirected to addresses not owned by the apparent merchant.
  • Trigger: Any actor who can initiate the onboarding flow for an existing installId without possessing the private keys.

Recommendation: Either send the signatures in the MerchantUpsertRequest to pay-core for server-side EIP-191 / Solana signature verification, or remove the signing step and document that settlement address binding is trusted at the partner level. The current UX implies cryptographic proof that isn't actually enforced.


Issue 6: MMKV storage created without encryption key — PII and financial data at rest unencrypted

ID: dcl-storage-unencrypted-pii-mmkv-f1a3
File: dapps/merchant-pos-app/utils/storage.ts:4
Severity: HIGH
Category: security / data_classification

Context:

  • Pattern: createMMKV() called with no encryptionKey. Persisted stores include merchant email (PII), companyName, wallet addresses, merchantId, and full payment history with amounts.
  • Risk: MMKV files sit in the app's data directory, accessible via ADB backup on non-hardened devices, physical extraction on rooted devices, and cloud backup if not opted out.
  • Trigger: Any device backup or forensic extraction.

Recommendation:

import * as Keychain from 'react-native-keychain';
// Derive a device-bound key from Android Keystore / iOS Secure Enclave
const { password: encryptionKey } = await Keychain.getGenericPassword();
const mmkv = createMMKV({ id: 'merchant-pos', encryptionKey });

Issue 7: isRetriableStatus marks 5xx as RETRIABLE but no 5xx retry logic exists

ID: merchant-isretriablestatus-no-retry-c5d3
File: dapps/merchant-pos-app/services/merchant.ts:64-67
Severity: MEDIUM
Category: bug

Context:

  • Pattern: isRetriableStatus marks status >= 500 as retriable and sets code: "RETRIABLE" on thrown errors, but putMerchant only retries on 401. Nothing in the call stack acts on the RETRIABLE code. 5xx failures propagate immediately to the user as unrecoverable.
  • Risk: Transient pay-core 5xx errors (e.g., a brief deploy) permanently fail the merchant upsert during onboarding with no retry.
  • Trigger: Any transient 500/502/503 from pay-core during onboarding.

Recommendation: Either add a bounded retry loop for 5xx in putMerchant, or remove the code: "RETRIABLE" annotation so the error contract is honest. Dead code misleads future readers into thinking callers handle it.


Issue 8: Client-generated timestamp in ownership message allows replay / has no server-side binding

ID: sign-ownership-timestamp-replay-d4f1
File: dapps/merchant-pos-app/hooks/use-sign-ownership.ts:7
Severity: MEDIUM
Category: security

Context:

  • Pattern: buildOwnershipMessage embeds Math.floor(Date.now() / 1000) but the timestamp is never validated — not on the device (no expiry check before routing to tokens) and not server-side (signature not sent to pay-core, per Issue 5). A captured signature is valid indefinitely.
  • Risk: A one-time captured signature + address can be replayed to re-trigger the switch-wallet upsert path in verify.tsx:102–124 for as long as the wallet's session persists.
  • Trigger: Compounded by Issue 5; if server-side verification is ever added, the lack of a validity window needs to be addressed together.

Recommendation: If server-side verification is added, include a server-issued nonce (not a client timestamp) in the signed message, and enforce a short validity window (e.g., 5 minutes) server-side.


Issue 9: Merchant email PII persisted indefinitely with no retention policy

ID: dcl-onboarding-store-email-pii-memory-d2b9
File: dapps/merchant-pos-app/store/useOnboardingStore.ts:8
Severity: HIGH
Category: data_classification

Context:

  • Pattern: useOnboardingStore.email is captured at business-details and committed to useMerchantStore (MMKV-persisted, unencrypted). The email is stored indefinitely in MerchantConfig with no TTL, no deletion on disconnect, no retention policy.
  • Risk: PII (email) stored unencrypted at rest (see Issue 6) with no documented retention window or deletion path.
  • Impact: Audit gap for data-minimization obligations.

Recommendation: Evaluate whether email needs to persist post-onboarding. If it's display-only, clear it from useMerchantStore after the onboarding confirmation screen. If retained, encrypt storage (Issue 6) and document the retention justification.


Issue 10: Payment records accumulate without TTL or deletion — financial data retained indefinitely

ID: dcl-payments-store-no-retention-ttl-e5c1
File: dapps/merchant-pos-app/store/usePaymentsStore.ts
Severity: LOW
Category: data_classification

Context:

  • Pattern: usePaymentsStore and usePaymentLinksStore append records forever. Expired links are hidden from the UI via isLinkActive() but remain in MMKV. Payment records include amountCents, merchantAddress, timestamps — financial data.
  • Recommendation: Prune records older than a retention window (e.g., 90 days) on app launch; delete expired links from the store (not just hide them). Document the retention period.

Breaking changes: None — this PR only adds new files; no existing contracts are modified.

Comment thread dapps/merchant-pos-app/services/cognito-auth.ts Fixed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread dapps/merchant-pos-app/app/index.tsx Outdated
Comment on lines +42 to +49
if (!onboardingVerified) {
target = "/onboarding/verify";
} else if (!findByMerchantId(getInstallId())) {
target = "/onboarding/tokens";
} else {
setActive(address);
target = "/home";
}
Comment on lines +18 to +23
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,
};
Comment on lines +11 to +14
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",
Comment on lines +55 to +56
> 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.
Comment on lines +24 to +25
export const EVM_NETWORKS = [mainnet, polygon, arbitrum, base];
export const SOLANA_NETWORKS = [solana];
Comment on lines +113 to +124
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");
Comment on lines +7 to +21
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} />
Comment on lines +1 to +110
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants