Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
eca7091
chore(example-bare): scaffold stock RN 0.85 app for Nitro spike
YouCanKeepSilence May 29, 2026
3528b07
chore: add Nitro tooling, drop Expo autolinking + build wiring
YouCanKeepSilence May 29, 2026
88f7fb4
spike: Nitro HybridObject (ping) wired; RN-CLI autolinking via react-…
YouCanKeepSilence May 29, 2026
760aed4
fix(nitro): drop invalid package react-native.config.js + podspec lic…
YouCanKeepSilence May 29, 2026
658ea8a
fix(example-bare): build RN core from source (RCT_USE_PREBUILT_RNCORE=0)
YouCanKeepSilence May 29, 2026
f3c1e19
fix(nitro): move podspec to package root so add_nitrogen_files globs …
YouCanKeepSilence May 29, 2026
d2aff80
chore(nitro): drop unneeded RCT_USE_PREBUILT_RNCORE workaround; publi…
YouCanKeepSilence May 29, 2026
b637e32
spike: proofs #2 SDK interop, #3 SwiftUI Nitro View, #4 event callback
YouCanKeepSilence May 29, 2026
0736cd5
fix(example-bare): adaptive text colors so the on-screen log is reada…
YouCanKeepSilence May 29, 2026
d7b8f2f
feat(nitro): Phase 1 core module — configure/initialize/reset/signOut…
YouCanKeepSilence May 29, 2026
6cf8c28
test(nitro): rewire jest to the Nitro hybrid object mock
YouCanKeepSilence May 29, 2026
7579ca7
feat(nitro): Phase 2 — checkout view as a Nitro View + getCheckoutReq…
YouCanKeepSilence May 29, 2026
b165d01
feat(nitro): map new provider-lifecycle CheckoutEvents from the SDK
YouCanKeepSilence May 29, 2026
c994fea
refactor(types): type checkoutFinalized.response against the SDK
YouCanKeepSilence May 29, 2026
453537c
feat(nitro): Expo config plugin — support Expo prebuild consumers too
YouCanKeepSilence May 29, 2026
9b811a8
fix(example): install react-native-nitro-modules in the Expo example
YouCanKeepSilence May 29, 2026
093453e
docs: update README + integration guide for the Nitro migration
YouCanKeepSilence Jun 1, 2026
f8abe37
fix(nitro): lower iOS floor to 16.0; stop the config plugin forcing d…
YouCanKeepSilence Jun 1, 2026
f7e55d0
build(nitro): replace expo-module-scripts with react-native-builder-bob
YouCanKeepSilence Jun 1, 2026
48940ab
fix(nitro): scope prepared-intent single-flight per client; fix butto…
YouCanKeepSilence Jun 1, 2026
4dd37a5
chore(example): enable App Attest in example-bare; fix NitroModules p…
YouCanKeepSilence Jun 1, 2026
f480de9
Regenerated xcode files
YouCanKeepSilence Jun 2, 2026
4fb024c
ci(ios): pass RELEASE_REPO_TOKEN to fetch-xcframework
YouCanKeepSilence Jun 2, 2026
bdfcad9
Added LICENSE
YouCanKeepSilence Jun 2, 2026
08925c7
refactor(example): promote bare RN app to primary example; rename Exp…
YouCanKeepSilence Jun 2, 2026
5f09710
chore(deps): lower react-native peer floor 0.85 -> 0.79
YouCanKeepSilence Jun 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,4 @@ jsconfig.json
.env.local
example/.env
example/.env.local
example-bare/env.local.ts
36 changes: 36 additions & 0 deletions OnramperReactNative.podspec
Original file line number Diff line number Diff line change
@@ -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.4' }
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
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.85+, `react-native-nitro-modules` 0.35+, the **New Architecture**, and iOS deployment target 16.4. 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 iOS deployment target and required build flags. For bare-RN Podfile setup (Xcode 16+/26) and full details, see the [integration guide](docs/INTEGRATION.md).

## Quick start

```ts
Expand Down Expand Up @@ -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.
158 changes: 53 additions & 105 deletions __tests__/OnramperClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,19 @@
import { OnramperClient } from '../src/OnramperClient';
import { __mockEmit, __mockNative } from './__mocks__/expo-modules-core';
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<void>((r) => setImmediate(() => r()));
expect(__mockNative.configure).toHaveBeenCalledWith(
const tick = () => new Promise<void>((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',
Expand All @@ -33,115 +25,71 @@ 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');
});

Comment thread
YouCanKeepSilence marked this conversation as resolved.
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<void>((r) => setImmediate(() => r()));
await new Promise<void>((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<void>((r) => setImmediate(() => r()));
await new Promise<void>((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('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();
});
});
31 changes: 0 additions & 31 deletions __tests__/__mocks__/expo-modules-core.ts

This file was deleted.

58 changes: 58 additions & 0 deletions __tests__/__mocks__/react-native-nitro-modules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// 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;
dispose: jest.Mock;
// Captured callbacks (set by the corresponding setters):
__stateListener?: (json: string) => void;
__eventListener?: (json: string) => void;
__sessionHandler?: () => Promise<{ sessionId: string; sessionToken: string }>;
}
Comment thread
YouCanKeepSilence marked this conversation as resolved.

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;
}),
dispose: jest.fn(),
} as MockNative;
Comment thread
YouCanKeepSilence marked this conversation as resolved.
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);
Loading
Loading