@taprails/tap-to-pay
A React Native SDK for contactless stablecoin payments using NFC tap-to-pay with USDC on Base network.
Features
- Easy integration into React Native apps
- NFC tap-to-pay for contactless payments (HCE supported)
- USDC stablecoin on Base network
- Backend API integration for secure blockchain operations
- Beautiful TapRails branded UI (optional, built-in)
- No private keys or blockchain code in the SDK
- TypeScript support with full type definitions
- Built with Expo modules
- Automatic retry logic and error handling
Platform Limitations Due to iOS restrictions on Host Card Emulation (HCE), the device acting as the Merchant (emitting the payment request) MUST be an Android device. The Customer (reading the request and processing payment) can use either iOS or Android.
TapRails UI — Neobank Design System
The SDK ships a production-ready UI layer built to the standard of modern neobank apps (Monzo, Revolut, Starling). Every screen is clean, focused, and animated — zero design work required.
UI Highlights
- Neobank-grade design — Pill-shaped buttons, bold amount heroes, bottom-sheet modal with drag handle
- Full Payment Flows — Merchant and customer screens handled end-to-end
- Fully Themeable — Every color, spacing value, and border radius reads from the theme context at render time;
StyleSheet.createholds zero hardcoded color or spacing values - Accessible — Screen reader labels, 44pt touch targets, WCAG AA contrast
- Responsive — Safe area aware, adapts to all screen sizes
Quick Start with UI
1. Install Additional Dependencies
The TapRails UI requires these additional packages:
npm install react-native-reanimated react-native-safe-area-context
# or
yarn add react-native-reanimated react-native-safe-area-context2. Wrap Your App with Theme Provider
import { TapRailsThemeProvider } from '@taprails/tap-to-pay';
function App() {
return (
<TapRailsThemeProvider
colorOverrides={{
primary: '#8B5CF6', // Optional: customize purple brand color
}}
>
{/* Your app content */}
</TapRailsThemeProvider>
);
}3. Use PaymentFlowManager
The PaymentFlowManager component handles the entire payment UI flow automatically:
import { PaymentFlowManager } from '@taprails/tap-to-pay';
import { useState } from 'react';
function MerchantScreen() {
const [showFlow, setShowFlow] = useState(false);
return (
<>
<Button title="Accept Payment" onPress={() => setShowFlow(true)} />
{showFlow && (
<PaymentFlowManager
config={{
type: 'merchant',
onComplete: (data) => {
console.log('Payment complete!', data);
setShowFlow(false);
},
onCancel: () => setShowFlow(false),
onError: (error) => {
console.error('Payment error:', error);
}
}}
onMerchantCancel={() => setShowFlow(false)}
/>
)}
</>
);
}That's it! The component handles amount entry (or skips it if you pass amount), NFC operations, and all payment states.
Merchant Flow — Pre-filling the Amount
If your app already knows the charge amount (e.g. from a cart or POS system), pass it directly to PaymentFlowManager. The SDK skips the manual entry screen and shows a clean confirmation card instead. The merchant must still press "Accept Payment" to proceed.
// Pre-filled: shows a confirmation card — no keypad needed
<PaymentFlowManager
config={{ type: 'merchant', onComplete: ..., onCancel: ... }}
amount="25.00"
onMerchantCancel={() => setShowFlow(false)}
/>
// No amount: shows the large inline input + quick-select chips
<PaymentFlowManager
config={{ type: 'merchant', onComplete: ..., onCancel: ... }}
onMerchantCancel={() => setShowFlow(false)}
/>amount prop |
Merchant sees |
|---|---|
| Provided | Read-only amount card + "Accept Payment" CTA |
| Omitted | Large inline $ input + $5 $10 $25 $50 quick chips |
Payment Flow Screens
Merchant Flow
- Payment Creation / Confirmation — If
amountprop is set: read-only confirmation card. If not: large inline$input with quick-select chips. - NFC Waiting Screen — Amount hero at top, animated NFC rings as focal point, expiry timer + status badge at bottom
- Processing Screen — Amount stays visible, spinner with track+arc animation, live status label from backend
- Success Screen — Green-tinted amount card, receipt with dividers (date, time, tx hash, network, status)
Customer Flow
- Tap Screen — NFC animation as hero, payment card shown when amount is known, instruction banner
- Processing Screen — Step-by-step indicators (grey → primary → success green)
- Success Screen — Identical receipt layout to merchant success
Error Screen
- Smart error type detection (timeout, insufficient funds, network, NFC, cancelled)
- Tinted error icon circle
- Inline debug trace in
__DEV__builds (left-accent box) - Retry / Cancel action buttons
Theme Customization
Every color token in the table below can be overridden via colorOverrides. All SDK screens and components read values from the theme context at render time — there are no hardcoded colors anywhere in the UI layer.
<TapRailsThemeProvider
colorOverrides={{
// Brand
primary: '#8B5CF6', // Purple (TapRails default)
primaryDark: '#7C3AED',
primaryLight: '#A78BFA',
// Semantic states
success: '#10B981',
error: '#EF4444',
warning: '#F59E0B',
info: '#3B82F6',
// Surfaces
background: '#FFFFFF', // Main screen / modal background
backgroundSecondary:'#F9FAFB', // Cards (PaymentCard, receipt, steps)
backgroundTertiary: '#F3F4F6', // Feature highlight boxes
// Text
text: '#1F2937',
textSecondary: '#6B7280',
textTertiary: '#9CA3AF',
textInverse: '#FFFFFF', // Text on primary-colored backgrounds
// Borders
border: '#E5E7EB',
borderLight: '#F3F4F6',
// Overlays
overlay: 'rgba(0, 0, 0, 0.5)', // Modal backdrop
overlayLight: 'rgba(0, 0, 0, 0.3)',
}}
>
{/* App */}
</TapRailsThemeProvider>To implement a dark mode, override the surface and text tokens:
colorOverrides={{ background: '#0F0F0F', backgroundSecondary: '#1A1A1A', backgroundTertiary: '#252525', text: '#F9FAFB', textSecondary: '#9CA3AF', border: '#2D2D2D', overlay: 'rgba(0, 0, 0, 0.75)', }}
Accessing Theme in Your Own Components
import { useTheme } from '@taprails/tap-to-pay';
function CustomComponent() {
const theme = useTheme();
return (
<View
style={{
backgroundColor: theme.colors.backgroundSecondary,
borderRadius: theme.borderRadius.xl,
padding: theme.spacing.lg,
}}
>
<Text style={[theme.typography.styles.h2, { color: theme.colors.text }]}>
$25.00
</Text>
</View>
);
}Available Typography Styles
| Token | Size | Weight | Use for |
|---|---|---|---|
h1 |
32px | Bold | Large amount displays |
h2 |
28px | Bold | Screen titles |
h3 |
24px | Semibold | Section headings |
h4 |
20px | Semibold | Sub-headings |
bodyLarge |
18px | Regular | Prominent body copy |
body |
16px | Regular | Standard body text |
bodySmall |
14px | Regular | Card labels, metadata |
caption |
12px | Regular | Timestamps, fine print |
button |
16px | Semibold | Button labels |
buttonLarge |
18px | Semibold | Large button labels |
Available Spacing Tokens
theme.spacing.xs // 4
theme.spacing.sm // 8
theme.spacing.md // 12
theme.spacing.lg // 16
theme.spacing.xl // 20
theme.spacing['2xl'] // 24
theme.spacing['3xl'] // 32
theme.spacing['4xl'] // 40Available Border Radius Tokens
theme.borderRadius.sm // 4
theme.borderRadius.md // 8
theme.borderRadius.lg // 12
theme.borderRadius.xl // 16
theme.borderRadius['2xl'] // 20
theme.borderRadius['3xl'] // 24 (modal sheet corners)
theme.borderRadius.full // 9999 (pill buttons)Installation
1. Install the SDK
npm install @taprails/tap-to-pay
# or
yarn add @taprails/tap-to-pay2. Install Peer Dependencies
npm install \
buffer \
react-native-get-random-values@^1.11.0 \
react-native-keychain@^8.2.0 \
react-native-nfc-manager \
react-native-device-info \
react-native-svg \
react-native-reanimated \
react-native-safe-area-context
# For iOS - link native modules
cd ios && pod install && cd ..React Native Version Compatibility
- This SDK is tested with React Native 0.72
- Use
react-native-keychain@^8.2.0(v10+ requires AndroidX DataStore which conflicts with RN 0.72)- Use
react-native-get-random-values@^1.11.0(v2+ requires RN 0.81+)
3. Add Polyfill Imports
Add these lines to the very top of your app entry point (index.js or App.tsx):
import 'react-native-get-random-values';
import { Buffer } from 'buffer';
global.Buffer = Buffer;Why these are needed:
react-native-get-random-values: Required by TweetNaCl for secure random number generationbuffer: Required by tweetnacl-util for encoding/decoding operations
4. Android Configuration
Add Required Dependencies
In android/app/build.gradle, add these dependencies:
dependencies {
// ... other dependencies
// Required by react-native-keychain v8
implementation "androidx.datastore:datastore-core:1.0.0"
implementation "androidx.datastore:datastore-preferences:1.0.0"
implementation "androidx.datastore:datastore-preferences-core:1.0.0"
// Fix Kotlin stdlib version conflicts
configurations.all {
resolutionStrategy {
force "org.jetbrains.kotlin:kotlin-stdlib:1.8.0"
force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0"
force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0"
}
}
}
Update build.gradle (Project Level)
In android/build.gradle, ensure Kotlin is configured:
buildscript {
ext {
kotlinVersion = "1.8.0"
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
}
}
5. Platform-Specific Notes
iOS
After installing dependencies, always run:
cd ios && pod install && cd ..Android
No additional linking required - autolinking handles native modules.
This SDK uses native modules (
react-native-keychain,react-native-nfc-manager, etc.). It works with bare React Native CLI projects. For Expo projects, you must use Development Builds (not Expo Go).
Verification
To verify your installation:
# iOS
npm run ios
# or
react-native run-ios
# Android
npm run android
# or
react-native run-androidThe app should build and launch without errors.
Quick Start
1. Initialize the SDK
Initialize the SDK in your app's entry point with your API credentials:
import { ContactlessCryptoSDK, PaymentMode, TapRailsThemeProvider } from '@taprails/tap-to-pay';
// For Custodial fintechs & payment mode (app pays from pool)
ContactlessCryptoSDK.initialize({
apiKey: 'pk_live_your_api_key',
apiUrl: 'https://api.yourbackend.com',
environment: 'production',
merchantId: 'merchant_123', // Required when acting as merchant
aid: 'YOUR_UNIQUE_AID', // Unique AID assigned from your dashboard
mode: PaymentMode.POOL,
pool: {
customerId: 'customer_456', // Customer identifier
}
});
// For non-custodial fintechs & payment providers session key mode (user's wallet, instant payments)
ContactlessCryptoSDK.initialize({
apiKey: 'pk_live_your_api_key',
apiUrl: 'https://api.yourbackend.com',
environment: 'production',
aid: 'YOUR_UNIQUE_AID', // Unique AID assigned from your dashboard
mode: PaymentMode.SESSION_KEY,
sessionKey: {
walletAddress: '0x1234567890abcdef...', // User's wallet address
defaultDailyLimit: '100.00', // Daily spending limit
autoRenew: true, // Auto-renew expired keys
onSignTransaction: async (tx) => {
// Integrate with your wallet solution to sign
const signature = await walletProvider.signTransaction(tx);
return signature;
},
// Optional: Callbacks for session key setup flow
onSetupComplete: () => {
console.log('Session key setup completed successfully!');
},
onSetupCancel: () => {
console.log('User cancelled session key setup');
}
}
});
// Wrap your app with TapRailsThemeProvider for automatic session key setup
function App() {
return (
<TapRailsThemeProvider>
<YourApp />
</TapRailsThemeProvider>
);
}Payment Modes:
Custodial (POOL): App pays from a pooled wallet (requires to fund your company's pool on the TapRails dashboard)Non-Custodial (SESSION_KEY): User's wallet with session keys for instant, gasless tap-to-paypayments (session setup handled automatically)
When using
SESSION_KEYmode:
- Session key setup flow is automatically managed by
TapRailsThemeProvider- The SDK detects when a session key is needed and shows the setup UI
- All session key configuration is in the
sessionKeyobject- No manual state management needed!
2. Merchant Flow: Accept Payments
Use PaymentFlowManager for a complete, branded merchant payment experience:
import { PaymentFlowManager } from '@taprails/tap-to-pay';
import { useState } from 'react';
function MerchantScreen() {
const [showFlow, setShowFlow] = useState(false);
return (
<>
<Button title="Accept Payment" onPress={() => setShowFlow(true)} />
{showFlow && (
<PaymentFlowManager
config={{
type: 'merchant',
onComplete: (data) => {
console.log('Payment complete!', data);
setShowFlow(false);
},
onCancel: () => setShowFlow(false),
onError: (error) => console.error('Payment error:', error),
}}
// Optional: pass the amount from your POS/cart.
// If omitted, the SDK shows a large inline amount input.
amount="25.00"
onMerchantCancel={() => setShowFlow(false)}
/>
)}
</>
);
}What happens (with amount prop):
- Confirmation screen — Read-only amount card with network/currency details
- Merchant presses "Accept Payment"
- NFC Waiting screen — Amount hero at top, animated NFC rings as focal point, expiry timer
- Customer taps their phone to the merchant's device
- Success screen — Green-tinted amount card + full receipt
What happens (without amount prop):
- Amount entry screen — Large Monzo-style
$input +$5 $10 $25 $50quick chips - Merchant enters amount and presses "Generate Tap Payment"
- → Same NFC waiting and success flow as above
The
PaymentFlowManagerhandles the entire UI flow including amount input, NFC writing, waiting for customer tap, and success/error states.
3. Customer Flow: Make Payments
For customers paying merchants:
import { PaymentFlowManager } from '@taprails/tap-to-pay';
import { useState } from 'react';
function CustomerScreen() {
const [showFlow, setShowFlow] = useState(false);
return (
<>
<Button title="Pay Now" onPress={() => setShowFlow(true)} />
{showFlow && (
<PaymentFlowManager
config={{
type: 'customer',
onComplete: (data) => {
console.log('Payment successful!', data);
setShowFlow(false);
},
onCancel: () => setShowFlow(false),
onError: (error) => {
console.error('Payment failed:', error);
}
}}
onCustomerCancel={() => setShowFlow(false)}
/>
)}
</>
);
}What happens:
- Tap instruction screen with animated NFC icon
- Customer taps phone to merchant's NFC tag/device
- SDK reads payment request from NFC
- Processing screen shows payment progress
- SDK processes payment (from pool or user's wallet depending on mode)
- Success screen displays transaction receipt
For
SESSION_KEYmode, the SDK automatically handles session key setup before the first payment. Just wrap your app withTapRailsThemeProviderand the setup flow appears when needed.
SDK Configuration
ContactlessCryptoSDK.initialize(config)
Initialize the SDK with your configuration.
interface SDKConfig {
apiKey: string; // Your API key
apiUrl: string; // Backend API base URL
environment: 'sandbox' | 'production';
aid: string; // Unique AID from your dashboard
merchantId?: string; // Required when app acts as merchant
mode?: PaymentMode; // POOL or SESSION_KEY
// Pool payment configuration (for POOL mode)
pool?: {
customerId: string; // Customer identifier
};
// Session key configuration (for SESSION_KEY mode)
sessionKey?: {
walletAddress: string; // User's wallet address
defaultDailyLimit: string; // Daily spending limit (e.g., "100.00")
autoRenew: boolean; // Auto-renew expired session keys
onSignTransaction: (tx: ApprovalTransaction) => Promise<string>; // Transaction signer
onSetupComplete?: () => void; // Optional: Called when setup completes
onSetupCancel?: () => void; // Optional: Called when setup is cancelled
};
}
enum PaymentMode {
POOL = 'POOL', // App pays from pooled wallet
SESSION_KEY = 'SESSION_KEY' // User's wallet with session keys
}ContactlessCryptoSDK.getConfig()
Get current SDK configuration. Throws error if not initialized.
ContactlessCryptoSDK.isInitialized()
Check if SDK has been initialized.
Hooks
useNFCMerchant()
Hook for merchants to create payment requests.
Returns:
{
createPaymentRequest: (params: { amount: string; merchantId: string }) => Promise<PaymentRequest | null>;
isWriting: boolean;
lastPaymentRequest: PaymentRequest | null;
error: NFCPaymentError | null;
clearError: () => void;
}useNFCCustomer()
Hook for customers to scan and process payments.
Returns:
{
scanPaymentRequest: () => Promise<PaymentRequest | null>;
processPaymentRequest: (paymentId: string, txHash: string, customerWallet?: string) => Promise<ProcessPaymentResponse | null>;
isReading: boolean;
isProcessing: boolean;
paymentRequest: PaymentRequest | null;
processedPayment: ProcessPaymentResponse | null;
error: NFCPaymentError | null;
clearPayment: () => void;
clearError: () => void;
}usePayment()
Low-level hook for direct API calls.
Returns:
{
createPaymentRequest: (request: CreatePaymentRequest) => Promise<CreatePaymentResponse | null>;
processPaymentRequest: (request: ProcessPaymentRequest) => Promise<ProcessPaymentResponse | null>;
isCreating: boolean;
isProcessing: boolean;
createdPayment: CreatePaymentResponse | null;
processedPayment: ProcessPaymentResponse | null;
error: APIError | null;
clearError: () => void;
reset: () => void;
}usePaymentStatus(options?)
Hook for polling payment status.
Options:
{
paymentId?: string; // Payment ID to poll
pollingInterval?: number; // Polling interval in ms (default: 2000)
autoStart?: boolean; // Auto-start polling (default: false)
}Returns:
{
startPolling: (paymentId?: string) => void;
stopPolling: () => void;
fetchStatus: (paymentId: string) => Promise<PaymentStatusResponse>;
isPolling: boolean;
status: PaymentStatusResponse | null;
isConfirmed: boolean;
isFailed: boolean;
txHash?: string;
confirmedAt?: number;
error: APIError | null;
clearError: () => void;
reset: () => void;
}Types
interface PaymentRequest {
paymentId: string;
amount: string;
merchantWallet: string;
transactionId: string;
currency: 'USDC';
network: 'base';
timestamp: number;
expiresAt?: number;
}
enum PaymentStatus {
PENDING = 'pending',
PROCESSING = 'processing',
CONFIRMED = 'confirmed',
FAILED = 'failed',
}
class NFCPaymentError extends Error {
code: NFCErrorCode;
}
enum NFCErrorCode {
NOT_SUPPORTED = 'NFC_NOT_SUPPORTED',
NOT_ENABLED = 'NFC_NOT_ENABLED',
READ_FAILED = 'READ_FAILED',
WRITE_FAILED = 'WRITE_FAILED',
INVALID_DATA = 'INVALID_DATA',
USER_CANCELLED = 'USER_CANCELLED',
API_ERROR = 'API_ERROR',
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
UNAUTHORIZED = 'UNAUTHORIZED',
}
class APIError extends Error {
code: APIErrorCode;
statusCode?: number;
details?: Record<string, any>;
}Error Handling
The SDK provides comprehensive error handling:
const { createPaymentRequest, error } = useNFCMerchant();
const handlePayment = async () => {
const result = await createPaymentRequest({
amount: '10.50',
merchantId: 'merchant_123',
});
if (error) {
switch (error.code) {
case NFCErrorCode.NOT_SUPPORTED:
Alert.alert('NFC not supported on this device');
break;
case NFCErrorCode.NOT_ENABLED:
Alert.alert('Please enable NFC in device settings');
break;
case NFCErrorCode.API_ERROR:
Alert.alert('Backend API error', error.message);
break;
case NFCErrorCode.NETWORK_ERROR:
Alert.alert('Network error', 'Please check your connection');
break;
case NFCErrorCode.UNAUTHORIZED:
Alert.alert('Invalid API key');
break;
default:
Alert.alert('Error', error.message);
}
}
};Development
Running the Example App
# Install dependencies
yarn bootstrap
# Run the example app
yarn example startBuilding
# Type check
yarn typecheck
# Lint
yarn lint
# Build the library
yarn prepackRequirements
- React Native 0.60+
- iOS 13+ / Android 5.0+
- Device with NFC capability
- Backend API service with required endpoints
Permissions
iOS
Add to your Info.plist:
<key>NFCReaderUsageDescription</key>
<string>We need access to NFC to process contactless payments</string>Android
Ensure your app targets minSdkVersion 23 or higher in android/build.gradle:
buildscript {
ext {
minSdkVersion = 23
}
}
Add to your AndroidManifest.xml:
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="true" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="true" />
<application>
<!-- Other components -->
<service
android:name="com.taprails.hce.TapRailsHCEService"
android:exported="true"
android:enabled="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/aid_list" />
</service>
</application>Then create android/app/src/main/res/xml/aid_list.xml:
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_name"
android:requireDeviceUnlock="true">
<aid-group android:category="other" android:description="@string/app_name">
<!-- REPLACE WITH THE UNIQUE AID FROM YOUR DASHBOARD -->
<aid-filter android:name="YOUR_UNIQUE_AID" />
</aid-group>
</host-apdu-service>License
Business Source License (BSL 1.1)
Contributing
Contributions are welcome! Please open an issue or submit a pull request.
Support
For issues and questions, please open an issue on GitHub.