diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 45cf2c1..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - root: true, - extends: ['universe/native', 'universe/web'], - ignorePatterns: ['build'], -}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69ddaba..ce2c046 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: with: node-version: '20' cache: 'npm' - - run: npm ci --legacy-peer-deps + - run: npm ci - run: npm run lint - run: npm run typecheck - run: npx jest @@ -34,6 +34,12 @@ jobs: with: node-version: '20' cache: 'npm' - - run: npm ci --legacy-peer-deps + - run: npm ci - run: npm run fetch-xcframework + env: + # Cross-repo read of the private onramper/onramper-ios release. + # The auto GITHUB_TOKEN is scoped to this repo only, so a + # fine-grained PAT (Contents: Read-only on onramper-ios) is required. + # Remove this once onramper-ios is public. + GH_TOKEN: ${{ secrets.RELEASE_REPO_TOKEN }} - run: npm run verify-version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 019ed79..a4f60c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version=process.env.VERSION;p.onramperSDK={checksum:'PENDING_FETCH'};fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n');" - name: Fetch xcframework + checksum - run: npm ci --legacy-peer-deps && npm run fetch-xcframework + run: npm ci && npm run fetch-xcframework - name: Verify checksum recorded run: npm run verify-version diff --git a/.gitignore b/.gitignore index 5936ea5..f3c5f70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Node node_modules/ -build/ +lib/ *.log npm-debug.log yarn-debug.log @@ -14,17 +14,17 @@ ios/Pods/ android/build/ android/.gradle/ -# Example app -example/node_modules/ -example/ios/Pods/ -example/ios/build/ -example/android/build/ -example/android/.gradle/ -example/android/app/build/ -example/.expo/ -example/.bundle/ -example/vendor/ -example/env.local.ts +# Expo example app +example-expo/node_modules/ +example-expo/ios/Pods/ +example-expo/ios/build/ +example-expo/android/build/ +example-expo/android/.gradle/ +example-expo/android/app/build/ +example-expo/.expo/ +example-expo/.bundle/ +example-expo/vendor/ +example-expo/env.local.ts # Xcode *.pbxuser @@ -67,5 +67,6 @@ jsconfig.json # Local secrets .env .env.local -example/.env -example/.env.local +example-expo/.env +example-expo/.env.local +example/env.local.ts diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b432fd3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2026 - Onramper Technologies B.V. (https://onramper.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/OnramperReactNative.podspec b/OnramperReactNative.podspec new file mode 100644 index 0000000..024c0f0 --- /dev/null +++ b/OnramperReactNative.podspec @@ -0,0 +1,36 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'OnramperReactNative' + s.version = package['version'] + s.summary = package['description'] + s.description = package['description'] + s.license = { :type => 'UNLICENSED' } + s.author = package['author'] + s.homepage = package['homepage'] + s.platforms = { :ios => '16.0' } + s.swift_version = '5.9' + s.source = { :git => "#{package['repository']}.git", :tag => "v#{s.version}" } + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + # This podspec lives at the package root (not ios/) because nitrogen emits its + # generated source globs (nitrogen/generated/**) relative to the podspec's + # directory via add_nitrogen_files below. Our own sources are ios/-prefixed. + s.source_files = 'ios/*.swift' + s.vendored_frameworks = 'ios/Frameworks/OnramperSDK.xcframework' + + load File.join(__dir__, 'nitrogen', 'generated', 'ios', 'OnramperReactNative+autolinking.rb') + add_nitrogen_files(s) + + # Wires up the React Native new-architecture dependencies and header search + # paths (React-Core, React-RCTFabric, ReactCommon, Yoga, folly, …). Required + # because our Nitro View's generated code includes React Fabric headers, which + # transitively include yoga/style/Style.h — without this the pod fails to + # compile with "'yoga/style/Style.h' file not found". + install_modules_dependencies(s) +end diff --git a/README.md b/README.md index ca93f21..530cc05 100644 --- a/README.md +++ b/README.md @@ -7,18 +7,20 @@ React Native wrapper for the Onramper iOS SDK. ## Install ```bash -npm install @onramper/react-native expo-modules-core +npm install @onramper/react-native react-native-nitro-modules cd ios && pod install ``` -Requires React Native 0.81+, `expo-modules-core` 56+, and iOS deployment target 16.4. Works in bare RN and Expo dev clients (not Expo Go — the package vendors a native binary). +Requires React Native 0.79+, `react-native-nitro-modules` 0.35+, the **New Architecture**, and an iOS **16.0+** deployment target. Works in **bare React Native (no Expo required)** and **Expo** apps — not Expo Go, since the package vendors a native binary. -If your app doesn't yet use Expo Modules: +**Expo apps** — add the bundled config plugin to `app.json`, then prebuild: -```bash -npx install-expo-modules@latest +```json +{ "expo": { "plugins": ["@onramper/react-native"] } } ``` +The plugin applies the required iOS build flag (it does not change your deployment target). Set your app's deployment target to **16.4+** — Expo SDK 56's own minimum (bare RN only needs 16.0). For bare-RN Podfile setup (Xcode 16+/26) and full details, see the [integration guide](docs/INTEGRATION.md). + ## Quick start ```ts @@ -100,13 +102,13 @@ Full code list in `src/errors.ts`. The vendored `OnramperSDK.xcframework` already ships its own `PrivacyInfo.xcprivacy`. Don't add a duplicate. -## Known limitations (v1) +## Known limitations -- `CheckoutEvent.checkoutCancelled` is not surfaced in this release — the bundled `OnramperSDK@1.0.0` xcframework doesn't include the case. It will be available when the SDK is rebuilt with the case. -- Native checkout outcomes flow via the module-level event stream (`client.addEventListener(...)`), not per-view event handlers. The view's `onCheckoutCompleted`/`onCheckoutFailed`/`onCheckoutCancelled` props are declared for forward-compat but only `onCheckoutFailed` fires today (for handle-binding errors). +- Native checkout outcomes are observed via the module-level event stream (`client.addEventListener(...)`); the native checkout button exposes no per-instance callbacks. ## Troubleshooting - **`pod install` fails finding the xcframework** — confirm `node_modules/@onramper/react-native/ios/Frameworks/OnramperSDK.xcframework/` exists. +- **Bare RN build fails with `module map file ... not found` (Xcode 16+/26)** — disable explicit Swift modules in your `Podfile` `post_install` (`SWIFT_ENABLE_EXPLICIT_MODULES = NO`). Expo apps get this automatically via the config plugin. See the [integration guide](docs/INTEGRATION.md). - **App Attest errors in simulator** — expected. App Attest only works on real devices. The SDK surfaces this as `attestationFailed`. - **Buttons appear but sheets don't open** — the host RN screen must be inside a normal `UIViewController` (the default). Modal-presented screens may need extra care; report an issue with a repro. diff --git a/__tests__/OnramperClient.test.ts b/__tests__/OnramperClient.test.ts index 7c1af15..5f423b3 100644 --- a/__tests__/OnramperClient.test.ts +++ b/__tests__/OnramperClient.test.ts @@ -1,27 +1,30 @@ +import { isValidElement } from 'react'; import { OnramperClient } from '../src/OnramperClient'; -import { __mockEmit, __mockNative } from './__mocks__/expo-modules-core'; +import type { CheckoutRequest } from '../src/types'; +import { __lastNative } from './__mocks__/react-native-nitro-modules'; -describe('OnramperClient', () => { - beforeEach(() => { - Object.values(__mockNative).forEach((fn) => { - if (typeof (fn as jest.Mock).mockClear === 'function') (fn as jest.Mock).mockClear(); - }); - __mockNative.configure.mockResolvedValue(undefined); - __mockNative.initialize.mockResolvedValue(undefined); - __mockNative.provideSessionCredentials.mockResolvedValue(undefined); - __mockNative.failSessionRefresh.mockResolvedValue(undefined); - }); +const baseConfig = { + apiKey: 'k', + clientId: 'c', + environment: 'development' as const, +}; - it('calls configure on construction', async () => { - const onSessionExpired = jest.fn().mockReturnValue({ sessionId: 's', sessionToken: 't' }); - new OnramperClient({ - apiKey: 'k', - clientId: 'c', - environment: 'development', - onSessionExpired, - }); - await new Promise((r) => setImmediate(() => r())); - expect(__mockNative.configure).toHaveBeenCalledWith( +const checkoutRequest: CheckoutRequest = { + source: 'usd', + destination: 'eth', + amount: 100, + type: 'buy', + paymentMethod: 'creditcard', + wallet: { network: 'ethereum', address: '0xabc' }, +}; + +const tick = () => new Promise((r) => setImmediate(r)); + +describe('OnramperClient', () => { + it('calls configure on construction with defaulted theme/logLevel', async () => { + new OnramperClient({ ...baseConfig, onSessionExpired: jest.fn() }); + await tick(); + expect(__lastNative().configure).toHaveBeenCalledWith( expect.objectContaining({ apiKey: 'k', clientId: 'c', @@ -33,115 +36,112 @@ describe('OnramperClient', () => { }); it('forwards initialize to native', async () => { - const client = new OnramperClient({ - apiKey: 'k', - clientId: 'c', - environment: 'development', - onSessionExpired: jest.fn().mockReturnValue({ sessionId: 's', sessionToken: 't' }), - }); + const client = new OnramperClient({ ...baseConfig, onSessionExpired: jest.fn() }); + const native = __lastNative(); await client.initialize({ sessionId: 's', sessionToken: 't' }); - expect(__mockNative.initialize).toHaveBeenCalledWith('s', 't'); - }); - - it('returns a button element + quote from getCheckoutRequirements', async () => { - __mockNative.getCheckoutRequirements.mockResolvedValueOnce({ - intentHandle: '01J', - quote: { rate: 1, payout: 0, ramp: 'demo' }, - }); - const client = new OnramperClient({ - apiKey: 'k', - clientId: 'c', - environment: 'development', - onSessionExpired: jest.fn().mockReturnValue({ sessionId: 's', sessionToken: 't' }), - }); - const result = await client.getCheckoutRequirements({ - source: 'usd', - destination: 'eth', - amount: 100, - type: 'buy', - paymentMethod: 'creditcard', - wallet: { network: 'ethereum', address: '0x' }, - }); - expect(result.button).toBeTruthy(); - expect(result.quote.rate).toBe(1); + expect(native.initialize).toHaveBeenCalledWith('s', 't'); }); it('wraps native errors as OnramperError', async () => { - __mockNative.initialize.mockRejectedValueOnce({ code: 'deviceBlocked', message: 'no' }); - const client = new OnramperClient({ - apiKey: 'k', - clientId: 'c', - environment: 'development', - onSessionExpired: jest.fn().mockReturnValue({ sessionId: 's', sessionToken: 't' }), - }); + const client = new OnramperClient({ ...baseConfig, onSessionExpired: jest.fn() }); + __lastNative().initialize.mockRejectedValueOnce({ code: 'deviceBlocked', message: 'no' }); await expect(client.initialize({ sessionId: 's', sessionToken: 't' })).rejects.toMatchObject({ code: 'deviceBlocked', }); }); - it('handles onSessionExpired event via async round-trip', async () => { + it('registers a session handler that returns fresh credentials', async () => { const onSessionExpired = jest.fn().mockResolvedValue({ sessionId: 'fresh-id', sessionToken: 'fresh-tok' }); - new OnramperClient({ - apiKey: 'k', - clientId: 'c', - environment: 'development', - onSessionExpired, - }); - __mockEmit('onSessionExpired', { token: 'session-1' }); - // Let the async callback chain settle. - await new Promise((r) => setImmediate(() => r())); - await new Promise((r) => setImmediate(() => r())); + new OnramperClient({ ...baseConfig, onSessionExpired }); + const handler = __lastNative().__sessionHandler; + expect(handler).toBeDefined(); + await expect(handler?.()).resolves.toEqual({ sessionId: 'fresh-id', sessionToken: 'fresh-tok' }); expect(onSessionExpired).toHaveBeenCalled(); - expect(__mockNative.provideSessionCredentials).toHaveBeenCalledWith('session-1', { - sessionId: 'fresh-id', - sessionToken: 'fresh-tok', - }); }); - it('reports failed onSessionExpired via failSessionRefresh', async () => { + it('propagates session-handler rejection', async () => { const onSessionExpired = jest.fn().mockRejectedValue(new Error('refresh denied')); - new OnramperClient({ - apiKey: 'k', - clientId: 'c', - environment: 'development', - onSessionExpired, - }); - __mockEmit('onSessionExpired', { token: 'session-2' }); - await new Promise((r) => setImmediate(() => r())); - await new Promise((r) => setImmediate(() => r())); - expect(__mockNative.failSessionRefresh).toHaveBeenCalledWith('session-2', 'refresh denied'); + new OnramperClient({ ...baseConfig, onSessionExpired }); + await expect(__lastNative().__sessionHandler?.()).rejects.toThrow('refresh denied'); }); it('signOut() awaits configure then forwards to native', async () => { - const client = new OnramperClient({ - apiKey: 'k', - clientId: 'c', - environment: 'development', - onSessionExpired: jest.fn().mockReturnValue({ sessionId: 's', sessionToken: 't' }), - }); + const client = new OnramperClient({ ...baseConfig, onSessionExpired: jest.fn() }); + const native = __lastNative(); await client.signOut(); - expect(__mockNative.configure).toHaveBeenCalled(); - expect(__mockNative.signOut).toHaveBeenCalledTimes(1); + expect(native.configure).toHaveBeenCalled(); + expect(native.signOut).toHaveBeenCalledTimes(1); }); - it('destroy() removes every listener added via addStateListener / addEventListener', () => { - const client = new OnramperClient({ - apiKey: 'k', - clientId: 'c', - environment: 'development', - onSessionExpired: jest.fn().mockReturnValue({ sessionId: 's', sessionToken: 't' }), + it('getCheckoutRequirements serializes request/style and parses the quote', async () => { + const client = new OnramperClient({ ...baseConfig, onSessionExpired: jest.fn() }); + const native = __lastNative(); + const quote = { quoteId: 'q1', ramp: 'moonpay', payout: 0.03 }; + native.getCheckoutRequirements.mockResolvedValueOnce({ + intentHandle: 'intent-1', + quoteJson: JSON.stringify(quote), }); + + const style = { borderRadius: 12 }; + const result = await client.getCheckoutRequirements(checkoutRequest, style); + + // JSON contract: request and style are serialized as JSON strings. + expect(native.getCheckoutRequirements).toHaveBeenCalledWith(JSON.stringify(checkoutRequest), JSON.stringify(style)); + // Quote is parsed back from quoteJson. + expect(result.quote).toEqual(quote); + // A native checkout button element is returned for rendering. + expect(isValidElement(result.button)).toBe(true); + }); + + it('getCheckoutRequirements defaults buttonStyle to {} when omitted', async () => { + const client = new OnramperClient({ ...baseConfig, onSessionExpired: jest.fn() }); + const native = __lastNative(); + native.getCheckoutRequirements.mockResolvedValueOnce({ intentHandle: 'h', quoteJson: '{}' }); + await client.getCheckoutRequirements(checkoutRequest); + expect(native.getCheckoutRequirements).toHaveBeenCalledWith(JSON.stringify(checkoutRequest), '{}'); + }); + + it('getCheckoutRequirements wraps native errors as OnramperError', async () => { + const client = new OnramperClient({ ...baseConfig, onSessionExpired: jest.fn() }); + __lastNative().getCheckoutRequirements.mockRejectedValueOnce({ code: 'quoteUnavailable', message: 'no quote' }); + await expect(client.getCheckoutRequirements(checkoutRequest)).rejects.toMatchObject({ code: 'quoteUnavailable' }); + }); + + it('cancelPreparedIntent forwards the handle to native', async () => { + const client = new OnramperClient({ ...baseConfig, onSessionExpired: jest.fn() }); + const native = __lastNative(); + await client.cancelPreparedIntent('intent-1'); + expect(native.cancelPreparedIntent).toHaveBeenCalledWith('intent-1'); + }); + + it('fans out parsed state to addStateListener', () => { + const client = new OnramperClient({ ...baseConfig, onSessionExpired: jest.fn() }); + const native = __lastNative(); const stateFn = jest.fn(); - const completedFn = jest.fn(); client.addStateListener(stateFn); + native.__stateListener?.(JSON.stringify({ kind: 'ready' })); + expect(stateFn).toHaveBeenCalledWith({ kind: 'ready' }); + }); + + it('addEventListener fires only for the matching event type', () => { + const client = new OnramperClient({ ...baseConfig, onSessionExpired: jest.fn() }); + const native = __lastNative(); + const completedFn = jest.fn(); client.addEventListener('completed', completedFn); + native.__eventListener?.(JSON.stringify({ type: 'cancelled' })); + expect(completedFn).not.toHaveBeenCalled(); + native.__eventListener?.(JSON.stringify({ type: 'completed', checkoutId: 'abc' })); + expect(completedFn).toHaveBeenCalledWith({ type: 'completed', checkoutId: 'abc' }); + }); + it('destroy() clears listeners and disposes the native instance', () => { + const client = new OnramperClient({ ...baseConfig, onSessionExpired: jest.fn() }); + const native = __lastNative(); + const stateFn = jest.fn(); + client.addStateListener(stateFn); client.destroy(); - - __mockEmit('onStateChanged', { kind: 'ready' }); - __mockEmit('onCheckoutEvent', { type: 'completed', checkoutId: 'abc' }); - + expect(native.dispose).toHaveBeenCalledTimes(1); + native.__stateListener?.(JSON.stringify({ kind: 'ready' })); expect(stateFn).not.toHaveBeenCalled(); - expect(completedFn).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/__mocks__/expo-modules-core.ts b/__tests__/__mocks__/expo-modules-core.ts deleted file mode 100644 index 874e48e..0000000 --- a/__tests__/__mocks__/expo-modules-core.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Simple typed listener registry. In production the native module IS an -// EventEmitter (Expo SDK 56+); tests substitute this lightweight equivalent. -const listeners = new Map void>>(); - -function addListener(name: string, fn: (payload: unknown) => void) { - if (!listeners.has(name)) listeners.set(name, new Set()); - listeners.get(name)?.add(fn); - return { remove: () => listeners.get(name)?.delete(fn) }; -} - -const nativeModuleSingleton = { - configure: jest.fn().mockResolvedValue(undefined), - initialize: jest.fn().mockResolvedValue(undefined), - getCheckoutRequirements: jest.fn(), - cancelPreparedIntent: jest.fn().mockResolvedValue(undefined), - reset: jest.fn().mockResolvedValue(undefined), - signOut: jest.fn().mockResolvedValue(undefined), - provideSessionCredentials: jest.fn().mockResolvedValue(undefined), - failSessionRefresh: jest.fn().mockResolvedValue(undefined), - addListener, -}; - -export const requireNativeModule = jest.fn(() => nativeModuleSingleton); -export const __mockNative = nativeModuleSingleton; - -export const requireNativeViewManager = jest.fn(() => () => null); - -// Helper for tests to push synthetic events through the mock listener registry. -export function __mockEmit(name: string, payload: unknown): void { - listeners.get(name)?.forEach((fn) => fn(payload)); -} diff --git a/__tests__/__mocks__/react-native-nitro-modules.ts b/__tests__/__mocks__/react-native-nitro-modules.ts new file mode 100644 index 0000000..1ec5405 --- /dev/null +++ b/__tests__/__mocks__/react-native-nitro-modules.ts @@ -0,0 +1,62 @@ +// Mocks the native Nitro hybrid object. `createHybridObject` returns a fresh +// instance per call (matching production, where each OnramperClient owns one), +// capturing the registered callbacks so tests can drive state / event / session +// flows. Grab the most-recently-created instance via `__lastNative()`. + +export interface MockNative { + configure: jest.Mock; + initialize: jest.Mock; + reset: jest.Mock; + signOut: jest.Mock; + setStateListener: jest.Mock; + setEventListener: jest.Mock; + setSessionExpirationHandler: jest.Mock; + getCheckoutRequirements: jest.Mock; + cancelPreparedIntent: jest.Mock; + dispose: jest.Mock; + // Captured callbacks (set by the corresponding setters): + __stateListener?: (json: string) => void; + __eventListener?: (json: string) => void; + __sessionHandler?: () => Promise<{ sessionId: string; sessionToken: string }>; +} + +function makeNative(): MockNative { + const native = { + configure: jest.fn().mockResolvedValue(undefined), + initialize: jest.fn().mockResolvedValue(undefined), + reset: jest.fn().mockResolvedValue(undefined), + signOut: jest.fn().mockResolvedValue(undefined), + setStateListener: jest.fn((fn: (json: string) => void) => { + native.__stateListener = fn; + }), + setEventListener: jest.fn((fn: (json: string) => void) => { + native.__eventListener = fn; + }), + setSessionExpirationHandler: jest.fn((fn: () => Promise<{ sessionId: string; sessionToken: string }>) => { + native.__sessionHandler = fn; + }), + getCheckoutRequirements: jest.fn().mockResolvedValue({ intentHandle: 'handle', quoteJson: '{}' }), + cancelPreparedIntent: jest.fn().mockResolvedValue(undefined), + dispose: jest.fn(), + } as MockNative; + return native; +} + +let last: MockNative | undefined; + +export const NitroModules = { + createHybridObject: jest.fn(() => { + last = makeNative(); + return last; + }), +}; + +/** The most-recently-created mock hybrid object (one per OnramperClient). */ +export function __lastNative(): MockNative { + if (!last) throw new Error('no hybrid object created yet'); + return last; +} + +// View host factory. Tests don't render the native button, so a dummy component +// that ignores its props is sufficient. +export const getHostComponent = jest.fn(() => () => null); diff --git a/app.plugin.js b/app.plugin.js new file mode 100644 index 0000000..f2e0bb5 --- /dev/null +++ b/app.plugin.js @@ -0,0 +1,51 @@ +// Expo config plugin for @onramper/react-native. +// +// Applies the one iOS build fix that an Expo prebuild app can't be expected to +// know about: disabling explicit Swift modules. On Xcode 16+/26, CocoaPods + +// Swift pods (NitroModules, OnramperReactNative, RN's RCTSwiftUI) otherwise fail +// the app target's "Emit Swift module" phase with "module map file ... not found". +// +// It deliberately does NOT touch the iOS deployment target — that's a product +// decision (which OS versions you support). The package declares its floor +// (iOS 16) in its podspec; set your app's deployment target to 16.0+ yourself. +// New Architecture (required by Nitro) is the default on the targeted RN/Expo +// versions, so the plugin doesn't force it either. + +const { withDangerousMod } = require('@expo/config-plugins'); +const fs = require('node:fs'); +const path = require('node:path'); + +const MARKER = '# @onramper/react-native: disable explicit Swift modules'; + +function withOnramperDisableExplicitModules(config) { + return withDangerousMod(config, [ + 'ios', + (cfg) => { + const podfile = path.join(cfg.modRequest.platformProjectRoot, 'Podfile'); + let contents = fs.readFileSync(podfile, 'utf8'); + if (!contents.includes(MARKER)) { + const snippet = [ + ` ${MARKER}`, + ' installer.pods_project.targets.each do |t|', + " t.build_configurations.each { |c| c.build_settings['SWIFT_ENABLE_EXPLICIT_MODULES'] = 'NO' }", + ' end', + ' installer.aggregate_targets.each do |at|', + ' next if at.user_project.nil?', + ' at.user_project.native_targets.each do |t|', + " t.build_configurations.each { |c| c.build_settings['SWIFT_ENABLE_EXPLICIT_MODULES'] = 'NO' }", + ' end', + ' at.user_project.save', + ' end', + ].join('\n'); + // Insert at the top of Expo's generated `post_install do |installer|` block. + contents = contents.replace(/(post_install do \|installer\|\n)/, `$1${snippet}\n`); + fs.writeFileSync(podfile, contents); + } + return cfg; + }, + ]); +} + +module.exports = function withOnramper(config) { + return withOnramperDisableExplicitModules(config); +}; diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 854a11c..85d044a 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -7,22 +7,18 @@ app via this wrapper. ## What kind of app can use this? -The wrapper is an **Expo Module**. It needs the Expo Modules layer -(`expo-modules-core` + the `ExpoReactNativeFactory` AppDelegate setup) to -boot. It does **not** require Expo CLI, Expo Go, EAS, or any cloud services -— just the native runtime layer. - -Three consumer scenarios, in order of effort: +The wrapper is built with **Nitro Modules** and requires React Native's +**New Architecture** (the default on the versions it targets). There is **no +Expo Modules dependency** — it works in a plain bare React Native app as well +as an Expo app: | You have… | What you need to do | |---|---| -| **An Expo SDK 56+ app** (managed or with prebuild) | Just `npm install @onramper/react-native` + `pod install`. Skip to §3. | -| **A bare RN app that already uses Expo Modules** (e.g. you previously ran `npx install-expo-modules`) | Same as above — skip to §3. | -| **A pure bare RN app, no Expo Modules at all** | Add the Expo Modules layer first (§2), then install the wrapper (§3). | +| **A bare React Native app** (no Expo) | `npm install @onramper/react-native react-native-nitro-modules`, apply the iOS build settings (§2), then `pod install` (§3). | +| **An Expo app** (prebuild / CNG) | `npm install @onramper/react-native react-native-nitro-modules`, add the bundled config plugin to `app.json` `plugins`, then `npx expo prebuild` — the plugin applies the iOS build settings for you. | -If you're unsure: check your `package.json`. If you see `"expo"` or -`"expo-modules-core"` in dependencies, you're in one of the first two -scenarios. +No `install-expo-modules` step and no AppDelegate changes are required. +Not compatible with Expo Go (the package vendors a native binary). --- @@ -30,156 +26,83 @@ scenarios. | Requirement | Version | Notes | |---|---|---| -| iOS | 16.4+ | Deployment target | -| React Native | 0.85.3 | Exact pin matching Expo SDK 56's RN | -| `expo-modules-core` | 56.0.12+ | Autolinked via the `expo` package | -| Node | 22.11.0+ | Per Expo's engines field | -| Xcode | 16+ (Xcode 26 tested) | New Architecture / Bridgeless mandatory in SDK 56+ | -| Real iOS device | Required for App Attest | Simulator can launch but attestation will return `attestationFailed` | -| Apple Developer account | Required for signing | Free or paid; need App Attest capability under your team | +| iOS | 16.0+ | The package's floor (bare RN). **Expo SDK 56 apps require 16.4** — Expo's own minimum. | +| React Native | 0.79+ | With the New Architecture enabled | +| New Architecture | Required | Nitro Modules run on the New Architecture only | +| `react-native-nitro-modules` | 0.35+ | Peer dependency — the package's native bridge is built with Nitro | +| Node | 22.11.0+ | | +| Xcode | 16+ (Xcode 26 tested) | | +| Real iOS device | Required for App Attest | Simulator can launch but attestation returns `attestationFailed` | +| Apple Developer account | Required for signing | Free or paid; needs the App Attest capability under your team | --- -## 1. Install the wrapper +## 1. Install ```bash -npm install @onramper/react-native -cd ios && pod install +npm install @onramper/react-native react-native-nitro-modules ``` -That's it for the wrapper itself. The native binary -(`OnramperSDK.xcframework`) and the Expo Module autolink in. If you're in -the **first scenario above** (already on Expo SDK 56+), you can skip to §3. - ---- - -## 2. Adding the Expo Modules layer to a bare RN app (only if needed) - -If your `package.json` doesn't yet have `expo-modules-core`, run: - -```bash -npx install-expo-modules@latest -``` - -This patches your `AppDelegate.swift`, `Podfile`, and `Info.plist` to enable -Expo Modules autolinking. After it completes, re-run `npm install` + -`pod install`. - -If `install-expo-modules` doesn't work for you (some setups can't auto-detect -SDK version), hand-apply the changes documented in §3 below: the AppDelegate -template, Podfile snippets, and babel/Metro config. +`react-native-nitro-modules` is a peer dependency — install it in your app. +The native binary (`OnramperSDK.xcframework`) and the Nitro module autolink in +during `pod install`. Ensure the **New Architecture** is enabled (the default +on RN 0.76+). -Your `package.json` should end up with at least: +**Expo apps** — add the bundled config plugin so prebuild applies the iOS +build settings automatically: ```json -{ - "dependencies": { - "@onramper/react-native": "^1.0.0", - "expo": "^56.0.3", - "expo-modules-core": "~56.0.12", - "react": "19.2.3", - "react-native": "0.85.3" - } -} +{ "expo": { "plugins": ["@onramper/react-native"] } } ``` -Run `npx expo-doctor` and resolve any version mismatches it flags. +Set your app's iOS deployment target to **16.4+** — Expo SDK 56's own minimum +(e.g. `ios.deploymentTarget` in `app.json`). Then run `npx expo prebuild` +followed by `npx expo run:ios`. You can skip §2 — the plugin applies the +required build flag for you. --- -## 3. iOS configuration - -The rest of this section applies to **all** consumers. Already-Expo apps -will likely have most of this in place; bare-RN-with-fresh-Expo-Modules -consumers may need to apply some of it manually. +## 2. iOS build settings (bare React Native) -### 2.1 Podfile properties +Bare RN apps need two iOS settings the package requires. (Expo apps get these +from the config plugin above.) -`ios/Podfile.properties.json`: +Set your app's iOS deployment target to **16.0+**, and disable explicit Swift +modules in the `Podfile` `post_install`: -```json -{ - "expo.jsEngine": "hermes", - "ios.deploymentTarget": "16.4" -} -``` - -### 2.2 Podfile post-install +```ruby +platform :ios, '16.0' -Add this to your `Podfile`'s `post_install` block to align deployment targets -and (on Xcode 26+) disable User Script Sandboxing so React's pre-build script -phases can extract framework intermediates: +# ... target block ... -```ruby post_install do |installer| - installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.4' - config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO' - end - end + react_native_post_install(installer, config[:reactNativePath], :mac_catalyst_enabled => false) - user_project = installer.aggregate_targets.first&.user_project - user_project&.targets&.each do |target| - target.build_configurations.each do |config| - config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO' + # Xcode 16+/26: explicit Swift modules break the app target's "Emit Swift + # module" phase for CocoaPods Swift pods (NitroModules, OnramperReactNative, + # RN's RCTSwiftUI) with "module map file ... not found". Build implicit modules. + installer.pods_project.targets.each do |t| + t.build_configurations.each { |c| c.build_settings['SWIFT_ENABLE_EXPLICIT_MODULES'] = 'NO' } + end + installer.aggregate_targets.each do |at| + next if at.user_project.nil? + at.user_project.native_targets.each do |t| + t.build_configurations.each { |c| c.build_settings['SWIFT_ENABLE_EXPLICIT_MODULES'] = 'NO' } end + at.user_project.save end - user_project&.save end ``` -### 2.3 AppDelegate (Expo template, mandatory) - -Your `AppDelegate.swift` **must** use `ExpoReactNativeFactory`, not the bare -RN `RCTReactNativeFactory`. The Expo factory installs the JSI runtime hooks -that initialize `globalThis.expo` and register TurboModules — without it the -JS bundle starts before the bridge is ready and `requireNativeModule` returns -undefined. - -If you scaffolded with `npx create-expo-app` or `expo prebuild` you already -have the right AppDelegate. If you started from bare RN, swap it for: - -```swift -internal import Expo -import React -import ReactAppDependencyProvider - -@main -class AppDelegate: ExpoAppDelegate { - var window: UIWindow? - var reactNativeDelegate: ExpoReactNativeFactoryDelegate? - var reactNativeFactory: RCTReactNativeFactory? - - public override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - let delegate = ReactNativeDelegate() - let factory = ExpoReactNativeFactory(delegate: delegate) - delegate.dependencyProvider = RCTAppDependencyProvider() - reactNativeDelegate = delegate - reactNativeFactory = factory - - window = UIWindow(frame: UIScreen.main.bounds) - factory.startReactNative(withModuleName: "main", in: window, launchOptions: launchOptions) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} +The SDK's minimum is iOS 16.0; set your app target's deployment target to 16.0 +(or higher) as well. Autolinking is automatic — the +package's podspec is discovered without any `react-native.config.js` change. -class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { - override func sourceURL(for bridge: RCTBridge) -> URL? { bridge.bundleURL ?? bundleURL() } - override func bundleURL() -> URL? { -#if DEBUG - return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") -#else - return Bundle.main.url(forResource: "main", withExtension: "jsbundle") -#endif - } -} -``` +--- + +## 3. App Attest & first build -### 2.4 App Attest capability +### 3.1 App Attest capability The SDK attests every session via Apple's `DCAppAttestService`. Without the entitlement, attestation returns `attestationFailed`. @@ -199,53 +122,44 @@ The SDK works with any team; the bundle ID just needs to be registered with Apple Developer Portal (Xcode does this automatically when you enable automatic signing). -### 2.5 Pod install + first build +### 3.2 Pod install + first build + +Bare RN: ```bash cd ios && pod install -cd .. && npx expo run:ios +cd .. && npx react-native run-ios --device ``` -If `pod install` fails with `Unable to find compatibility version string for -object version '70'` (Xcode 26+ project format), downgrade your project file: +Expo: ```bash -sed -i '' 's/objectVersion = 70;/objectVersion = 60;/' \ - ios/.xcodeproj/project.pbxproj +npx expo prebuild +npx expo run:ios --device ``` -Xcode may re-upgrade this each time you open the project in the UI; just -re-run the `sed` before each `pod install`. - --- ## 4. Metro configuration -Use Expo's Metro config (not bare RN's): +Use the standard Metro and Babel config for your app type — no special setup +is required for this package. -`metro.config.js`: +**Bare RN** (the React Native Community CLI defaults): ```js -const { getDefaultConfig } = require('expo/metro-config'); -module.exports = getDefaultConfig(__dirname); +// metro.config.js +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +module.exports = mergeConfig(getDefaultConfig(__dirname), {}); ``` -`babel.config.js`: - ```js -module.exports = { - presets: ['babel-preset-expo'], -}; +// babel.config.js +module.exports = { presets: ['module:@react-native/babel-preset'] }; ``` -Start Metro with Expo's CLI: - -```bash -npx expo start --dev-client -``` - -(Not `react-native start` — bare RN's Metro doesn't know about Expo's virtual -entry or the polyfills required for TurboModule init.) +**Expo** apps use Expo's defaults (`expo/metro-config`, `babel-preset-expo`) — +unchanged. --- @@ -368,8 +282,7 @@ const offState = client.addStateListener((state) => { }); // Each subscribe returns an unsubscribe. Call `client.destroy()` on unmount -// to drop all listeners (including the two internal ones the client uses -// for state mirroring and session refresh). +// to drop all listeners and release the native client. ``` ### 5.6 Re-requesting on input changes @@ -418,7 +331,7 @@ interface CheckoutButtonStyle { } ``` -Only those three fields are styleable in v0. The button label ("Buy"), height, internal padding, font, ToS sentence rendering, and the login / payment sheets are SDK-owned in this release — partners cannot override them. If your design needs more, raise it with the SDK team rather than wrapping the button in a custom container (the underlying SwiftUI view is opaque and may relayout). +Only those three fields are styleable. The button label ("Buy"), height, internal padding, font, ToS sentence rendering, and the login / payment sheets are SDK-owned in this release — partners cannot override them. If your design needs more, raise it with the SDK team rather than wrapping the button in a custom container (the underlying SwiftUI view is opaque and may relayout). ### 5.9 Error handling @@ -544,6 +457,13 @@ Pass `logLevel: 'info'` while integrating to see HTTP method, URL path, and stat | `'completed'` | Terminal success. Carries `checkoutId`. | | `'failed'` | Terminal failure. Carries `error`. | | `'cancelled'` | User dismissed the payment webview. The SDK transparently re-prepares the intent (Onramper backend intent ids are single-use) and lands back in `'readyToCheckout'` — tapping Buy again starts a fresh checkout. | +| `'providerReady'` | A provider checkout page (e.g. Coinbase) loaded and is ready for input. | +| `'paymentAuthorized'` | The user authorized payment in the provider's native sheet (e.g. Apple Pay). Not terminal — settling follows. | +| `'paymentProcessing'` | The provider accepted the payment and is settling it. | +| `'paymentCancelled'` | The provider reported the user cancelled (e.g. dismissed the Apple Pay sheet). Distinct from `'cancelled'`, which is the SDK-level webview dismissal. | +| `'providerError'` | The provider reported a non-recoverable failure. Carries `reason`. | + +These provider-lifecycle events come from third-party checkout webviews; not every provider emits every one. ### 5.12 What you don't need to do @@ -583,52 +503,49 @@ A sample dev-only client-side session mint is in `example/createDemoSession.ts` Always returns `attestationFailed`. Apple's `DCAppAttestService` only works on physical iOS 14+ devices. Use a real device for any end-to-end test. -### Xcode 26 project format -Xcode 26 saves projects in `objectVersion = 70`, which the bundled xcodeproj -gem in CocoaPods 1.16 doesn't yet support. Downgrade to `60` before each -`pod install`: - -```bash -sed -i '' 's/objectVersion = 70;/objectVersion = 60;/' ios/.xcodeproj/project.pbxproj -``` +### Xcode 16+/26 Swift modules +The app target's "Emit Swift module" phase can fail with `module map file ... +not found` for the Swift pods. Disable explicit Swift modules +(`SWIFT_ENABLE_EXPLICIT_MODULES = NO`) — see §2 for the Podfile snippet. Expo +apps get this from the config plugin. ### Android Stub-only. All Android calls throw `platformUnsupported`. A native Android SDK is not part of this release. ### Local development via `file:` symlink (contributors only) -If you're developing the wrapper itself by linking it into a host app via -`"@onramper/react-native": "file:.."`, Metro will resolve `expo-modules-core` -and `react-native` from BOTH the host app and the wrapper repo's own -node_modules. That gives you two parallel `ReactNativeViewConfigRegistry` -Maps and a confusing "View config getter callback for component -`ViewManagerAdapter_OnramperReactNative` must be a function (received -`undefined`)" error. +If you link the wrapper into a host app via `"@onramper/react-native": "file:.."`, +Metro can resolve `react-native-nitro-modules` and `react-native` from BOTH the +host app and the wrapper repo's own node_modules — two parallel module instances, +which break native view/registry lookups. -Fix it in the host app's `metro.config.js`: +Fix it in the host app's `metro.config.js` (force a single copy): ```js config.resolver.disableHierarchicalLookup = true; config.resolver.nodeModulesPaths = [path.resolve(__dirname, 'node_modules')]; ``` -Consumers installing from the npm registry don't see this — there's no -symlink, so only one copy resolves. +On a **bare RN (Community CLI)** host, the `file:..` symlink also hides the +package from CLI autolinking; add a `react-native.config.js` in the host app +pointing at the package root and its podspec. Registry installs hit neither +issue — there's no symlink. --- ## 8. Verifying your setup -After installation, you should be able to: +After installation, you should be able to build and run on a device: ```bash -# JS contract checks -npx tsc --noEmit # types resolve -npx expo-doctor # config validation - -# iOS build +# Bare RN cd ios && pod install -cd .. && npx expo run:ios --device "Your iPhone" +cd .. && npx react-native run-ios --device + +# Expo +npx expo prebuild +npx expo-doctor # validates Expo config +npx expo run:ios --device ``` In Xcode (or via `codesign -dvv path/to/YourApp.app`), confirm: @@ -644,17 +561,17 @@ can't reach the Onramper backend. --- -## 9. Going-live checklist - -- [ ] Production `apiKey` and `clientId` configured per build flavour (no staging credentials in release builds). -- [ ] App Attest entitlement enabled on the production app id; signing team set under **Signing & Capabilities**. -- [ ] Backend session-mint endpoint deployed and reachable from the production app; returns both `sessionId` and `sessionToken`. -- [ ] `onSessionExpired` wired to your auth client and **tested end-to-end** by forcing a session expiry (e.g. revoke the SDK session server-side mid-checkout) and confirming the user sees no error. -- [ ] `logLevel: 'off'` in production JS bundles. -- [ ] Tested on a real device (not just simulator) — attestation path verified. -- [ ] Error UI covers at minimum `amountOutOfRange`, `quoteUnavailable`, `checkoutForbidden`, `temporaryFailure`, `networkError`, `deviceBlocked`, `configurationError`, and `unrecoverable`. -- [ ] Analytics / observability hooked into `addStateListener` or `addEventListener('failed' | 'completed', …)`. -- [ ] `client.destroy()` called on unmount so re-mounting doesn't leak listeners. +## 9. Before going live + +- Production `apiKey` and `clientId` configured per build flavour (no staging credentials in release builds). +- App Attest entitlement enabled on the production app id; signing team set under **Signing & Capabilities**. +- Backend session-mint endpoint deployed and reachable from the production app; returns both `sessionId` and `sessionToken`. +- `onSessionExpired` wired to your auth client and **tested end-to-end** by forcing a session expiry (e.g. revoke the SDK session server-side mid-checkout) and confirming the user sees no error. +- `logLevel: 'off'` in production JS bundles. +- Tested on a real device (not just simulator) — attestation path verified. +- Error UI covers at minimum `amountOutOfRange`, `quoteUnavailable`, `checkoutForbidden`, `temporaryFailure`, `networkError`, `deviceBlocked`, `configurationError`, and `unrecoverable`. +- Analytics / observability hooked into `addStateListener` or `addEventListener('failed' | 'completed', …)`. +- `client.destroy()` called on unmount so re-mounting doesn't leak listeners. --- diff --git a/example-expo/.eslintrc.js b/example-expo/.eslintrc.js new file mode 100644 index 0000000..187894b --- /dev/null +++ b/example-expo/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: '@react-native', +}; diff --git a/example-expo/.gitignore b/example-expo/.gitignore new file mode 100644 index 0000000..de99955 --- /dev/null +++ b/example-expo/.gitignore @@ -0,0 +1,75 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +**/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ +*.keystore +!debug.keystore +.kotlin/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Bundle artifact +*.jsbundle + +# Ruby / CocoaPods +**/Pods/ +/vendor/bundle/ + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* + +# testing +/coverage + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/example-expo/.prettierrc.js b/example-expo/.prettierrc.js new file mode 100644 index 0000000..06860c8 --- /dev/null +++ b/example-expo/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + arrowParens: 'avoid', + singleQuote: true, + trailingComma: 'all', +}; diff --git a/example-expo/.watchmanconfig b/example-expo/.watchmanconfig new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/example-expo/.watchmanconfig @@ -0,0 +1 @@ +{} diff --git a/example-expo/App.tsx b/example-expo/App.tsx new file mode 100644 index 0000000..0b9b8e1 --- /dev/null +++ b/example-expo/App.tsx @@ -0,0 +1,304 @@ +import React, { useRef, useState } from 'react'; +import { + Button, + KeyboardAvoidingView, + Platform, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import * as Application from 'expo-application'; +import { OnramperClient, type OnramperState, type QuoteResponse } from '@onramper/react-native'; +import { ENV } from './env.local'; +import { createDemoSession } from './createDemoSession'; + +type LogEntry = { level: 'info' | 'event' | 'error'; line: string }; + +const TX_DEFAULTS = { + source: 'usd', + destination: 'sol', + amount: '100', + paymentMethod: 'applepay', + walletNetwork: 'solana', + walletAddress: 'Br2jjHYskB1JJikv3Qw2QcmWVQGfZvkJFng4ZEwiGSjv', +}; + +export default function App() { + const [log, setLog] = useState([]); + const [source, setSource] = useState(TX_DEFAULTS.source); + const [destination, setDestination] = useState(TX_DEFAULTS.destination); + const [amount, setAmount] = useState(TX_DEFAULTS.amount); + const [paymentMethod, setPaymentMethod] = useState(TX_DEFAULTS.paymentMethod); + const [walletNetwork, setWalletNetwork] = useState(TX_DEFAULTS.walletNetwork); + const [walletAddress, setWalletAddress] = useState(TX_DEFAULTS.walletAddress); + + const [client, setClient] = useState(null); + const [state, setState] = useState({ kind: 'idle' }); + const [quote, setQuote] = useState(null); + const [button, setButton] = useState(null); + + const scrollRef = useRef(null); + + const append = (entry: LogEntry) => { + setLog((l) => [...l, entry]); + requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true })); + }; + const info = (line: string) => append({ level: 'info', line }); + const event = (line: string) => append({ level: 'event', line }); + const fail = (line: string) => append({ level: 'error', line }); + + const setupClient = (c: OnramperClient) => { + c.addStateListener((s) => { + setState(s); + event(`state → ${s.kind}${s.kind === 'failed' ? `: ${s.error.code}` : ''}`); + }); + c.addEventListener('checkoutStarted', (e) => event(`checkout started: ${e.intentId}`)); + c.addEventListener('loginRequired', () => event('login required')); + c.addEventListener('readyToCheckout', () => event('ready to checkout')); + c.addEventListener('requirementSatisfied', (e) => event(`requirement satisfied: ${e.requirementType}`)); + c.addEventListener('checkoutFinalized', () => event('checkout finalized')); + c.addEventListener('renderingStarted', (e) => event(`rendering: ${e.renderType} ${e.url}`)); + c.addEventListener('completed', (e) => event(`COMPLETED checkoutId=${e.checkoutId}`)); + c.addEventListener('failed', (e) => event(`FAILED: ${e.error.code} ${e.error.message}`)); + }; + + const onConfigureInitialize = async () => { + // Destroy any prior client so its listeners come off the native emitter + // before we create a new one. Otherwise every retry doubles the handlers. + client?.destroy(); + setQuote(null); + setButton(null); + + let inflight: OnramperClient | null = null; + try { + info('minting demo session…'); + const session = await createDemoSession(ENV.demoToken); + info(`minted session: ${session.sessionId}`); + + inflight = new OnramperClient({ + apiKey: ENV.apiKey, + clientId: ENV.clientId, + environment: 'development', + logLevel: 'debug', + onSessionExpired: async () => { + info('onSessionExpired invoked — refreshing'); + return createDemoSession(ENV.demoToken); + }, + }); + setupClient(inflight); + await inflight.initialize({ sessionId: session.sessionId, sessionToken: session.sessionToken }); + setClient(inflight); + info('initialized OK'); + } catch (e: unknown) { + // Tear down the half-initialized client so its listeners don't leak. + inflight?.destroy(); + const err = e as { code?: string; message?: string; info?: Record }; + fail(`init error: ${err.code ?? 'unknown'} — ${err.message ?? String(e)}`); + if (err.info && Object.keys(err.info).length > 0) { + fail(` info: ${JSON.stringify(err.info)}`); + } + } + }; + + const onGetRequirements = async () => { + if (!client) { + fail('configure + initialize first'); + return; + } + try { + const parsed = Number(amount); + if (!Number.isFinite(parsed) || parsed <= 0) { + fail('amount must be a positive number'); + return; + } + const result = await client.getCheckoutRequirements( + { + source, + destination, + amount: parsed, + type: 'buy', + paymentMethod, + wallet: { network: walletNetwork, address: walletAddress }, + }, + { + backgroundColor: '#0A84FF', + foregroundColor: '#FFFFFF', + borderRadius: 12, + }, + ); + setQuote(result.quote); + setButton(result.button); + info(`got intent: rate=${result.quote.rate ?? 'n/a'} payout=${result.quote.payout ?? 'n/a'}`); + } catch (e: unknown) { + const err = e as { code?: string; message?: string }; + fail(`getCheckoutRequirements error: ${err.code ?? 'unknown'} — ${err.message ?? String(e)}`); + } + }; + + const onReset = async () => { + if (!client) return; + try { + await client.reset(); + setQuote(null); + setButton(null); + info('reset OK'); + } catch (e: unknown) { + const err = e as { code?: string; message?: string }; + fail(`reset error: ${err.code ?? 'unknown'} — ${err.message ?? String(e)}`); + } + }; + + const onSignOut = async () => { + if (!client) return; + try { + await client.signOut(); + setQuote(null); + setButton(null); + info('signed out — OIDC tokens cleared, next checkout will re-present login'); + } catch (e: unknown) { + const err = e as { code?: string; message?: string }; + fail(`signOut error: ${err.code ?? 'unknown'} — ${err.message ?? String(e)}`); + } + }; + + const maskedKey = ENV.apiKey ? `${ENV.apiKey.slice(0, 8)}…${ENV.apiKey.slice(-4)}` : '(missing)'; + + return ( + + + + Onramper RN Example + + Build info + bundleId: {Application.applicationId ?? '(unknown)'} + version: {Application.nativeApplicationVersion ?? '(unknown)'} ({Application.nativeBuildVersion ?? '(unknown)'}) + + + Credentials (from env.local.ts) + apiKey: {maskedKey} + clientId: {ENV.clientId || '(missing)'} + demoToken: {ENV.demoToken ? '✓ loaded' : '(missing)'} + +