npm.io
0.1.6 • Published yesterday

@taprails/tap-to-pay

Licence
BUSL-1.1
Version
0.1.6
Deps
5
Size
821 kB
Vulns
0
Weekly
286

@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.create holds 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-context
2. 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
  1. Payment Creation / Confirmation — If amount prop is set: read-only confirmation card. If not: large inline $ input with quick-select chips.
  2. NFC Waiting Screen — Amount hero at top, animated NFC rings as focal point, expiry timer + status badge at bottom
  3. Processing Screen — Amount stays visible, spinner with track+arc animation, live status label from backend
  4. Success Screen — Green-tinted amount card, receipt with dividers (date, time, tx hash, network, status)
Customer Flow
  1. Tap Screen — NFC animation as hero, payment card shown when amount is known, instruction banner
  2. Processing Screen — Step-by-step indicators (grey → primary → success green)
  3. 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'] // 40
Available 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-pay
2. 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 generation
  • buffer: 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-android

The 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_KEY mode:

  • 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 sessionKey object
  • 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):

  1. Confirmation screen — Read-only amount card with network/currency details
  2. Merchant presses "Accept Payment"
  3. NFC Waiting screen — Amount hero at top, animated NFC rings as focal point, expiry timer
  4. Customer taps their phone to the merchant's device
  5. Success screen — Green-tinted amount card + full receipt

What happens (without amount prop):

  1. Amount entry screen — Large Monzo-style $ input + $5 $10 $25 $50 quick chips
  2. Merchant enters amount and presses "Generate Tap Payment"
  3. → Same NFC waiting and success flow as above

The PaymentFlowManager handles 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:

  1. Tap instruction screen with animated NFC icon
  2. Customer taps phone to merchant's NFC tag/device
  3. SDK reads payment request from NFC
  4. Processing screen shows payment progress
  5. SDK processes payment (from pool or user's wallet depending on mode)
  6. Success screen displays transaction receipt

For SESSION_KEY mode, the SDK automatically handles session key setup before the first payment. Just wrap your app with TapRailsThemeProvider and 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 start
Building
# Type check
yarn typecheck

# Lint
yarn lint

# Build the library
yarn prepack

Requirements

  • 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.

Keywords