React Native SDK for PERS Platform - Tourism Loyalty System with Blockchain Transaction Signing and WebAuthn Authentication
npm install @explorins/pers-sdk-react-nativereact-native-quick-crypto.
bash
npm install @explorins/pers-sdk-react-native
`
$3
You must install async-storage manually as it is a peer dependency:
`bash
npm install @react-native-async-storage/async-storage
`
$3
The SDK automatically installs the following native libraries. You must rebuild your native app (e.g., npx expo run:android or cd ios && pod install) after installing the SDK to link these native modules:
- react-native-quick-crypto (High-performance Crypto)
- react-native-keychain (Secure Storage)
- react-native-passkey (WebAuthn/Passkeys)
- react-native-get-random-values (Crypto Primitives)
$3
`bash
For URL Polyfills (Recommended if not already included in your app)
npm install react-native-url-polyfill
`
Critical Setup Requirement: Passkeys
To enable Passkey authentication (WebAuthn) on iOS and Android, you must complete the setup in REACT_NATIVE_PASSKEY_SETUP.md.
This includes:
- Registering your app with the PERS backend (required for OS trust)
- Native configuration (Info.plist, AndroidManifest.xml)
- Expo/development build setup
Quick Start
$3
`typescript
import React from 'react';
import { PersSDKProvider } from '@explorins/pers-sdk-react-native';
export default function App() {
return (
apiUrl: 'https://api.pers.ninja',
tenantId: 'your-tenant-id' // Optional
}}>
);
}
`
$3
`typescript
import {
useAuth,
useTokens,
useRedemptions,
useTransactionSigner
} from '@explorins/pers-sdk-react-native';
function RewardScreen() {
const { user, isAuthenticated, login } = useAuth();
const { getTokens } = useTokens();
const { redeem } = useRedemptions();
const { signAndSubmitTransactionWithJWT, isSignerAvailable } = useTransactionSigner();
const handleLogin = async () => {
try {
await login('your-jwt-token');
console.log('Authenticated:', user?.identifier);
} catch (error) {
console.error('Login failed:', error);
}
};
const handleLoadTokens = async () => {
try {
const tokens = await getTokens();
console.log('User tokens:', tokens);
} catch (error) {
console.error('Failed to load tokens:', error);
}
};
const handleRedemption = async () => {
try {
// Step 1: Create redemption
const redemptionId = 'redemption-id-from-ui';
const result = await redeem(redemptionId);
// Note: The redeem method automatically handles blockchain signing if required
// and if the signer is available.
if (result.isSigned) {
console.log('Redemption completed with blockchain signature!');
console.log('Transaction Hash:', result.transactionHash);
}
} catch (error) {
console.error('Redemption failed:', error);
}
};
return (
{!isAuthenticated ? (
Login with PERS
) : (
Welcome, {user?.identifier}!
Load Tokens
onPress={handleRedemption}
disabled={!isSignerAvailable}
style={[styles.button, !isSignerAvailable && styles.disabled]}
>
{isSignerAvailable ? 'Redeem Rewards' : 'Signer Loading...'}
)}
);
}
`
Core Hooks
$3
`typescript
// Authentication management
const {
isInitialized,
isAuthenticated,
user,
login,
loginWithRawData,
logout,
refreshUserData,
getCurrentUser,
checkIsAuthenticated,
refreshTokens,
clearAuth,
hasValidAuth
} = useAuth();
// User profile operations
const {
getCurrentUser,
updateCurrentUser,
getUserById,
getAllUsersPublic,
getAllUsers, // Admin
updateUser, // Admin
toggleUserStatus // Admin
} = useUsers();
`
$3
`typescript
// Token management
const {
getTokens,
getActiveCreditToken,
getRewardTokens,
getTokenTypes,
getStatusTokens,
getTokenByContract
} = useTokens();
// Token balance loading (NEW in v2.1.1)
const {
tokenBalances, // Array of balances with token metadata
isLoading, // Loading state
error, // Error state
refresh // Manual refresh function
} = useTokenBalances({
availableTokens, // From useTokens()
autoLoad: true, // Auto-load on mount
refreshInterval: 30000 // Optional: refresh every 30s
});
// Transaction history
const {
createTransaction,
getTransactionById,
getUserTransactionHistory,
getTenantTransactions, // Admin
getPaginatedTransactions, // Admin
exportTransactionsCSV, // Admin
signingStatus, // UI feedback during blockchain signing
signingStatusMessage
} = useTransactions();
// Blockchain transaction signing
const {
signAndSubmitTransactionWithJWT,
isSignerAvailable,
isSignerInitialized,
currentStatus,
statusMessage
} = useTransactionSigner();
`
$3
`typescript
// Business operations
const {
getActiveBusinesses,
getBusinessTypes,
getBusinesses,
getBusinessById,
getBusinessByAccount,
getBusinessesByType,
createBusiness, // Admin
updateBusiness, // Admin
toggleBusinessStatus // Admin
} = useBusiness();
// Campaign management
const {
getActiveCampaigns,
getCampaignById,
claimCampaign,
getUserClaims,
getCampaignTriggers,
getAllCampaigns, // Admin
getCampaignClaims, // Admin
getCampaignClaimsByUserId, // Admin
getCampaignClaimsByBusinessId // Admin
} = useCampaigns();
// Redemption system
const {
getActiveRedemptions,
getUserRedemptions,
redeem,
getRedemptionTypes,
signingStatus, // UI feedback during blockchain signing
signingStatusMessage,
createRedemption, // Admin
getAllRedemptions, // Admin
updateRedemption, // Admin
toggleRedemptionStatus // Admin
} = useRedemptions();
`
$3
`typescript
// Purchase processing
const {
createPaymentIntent,
getActivePurchaseTokens,
getAllUserPurchases
} = usePurchases();
// Multi-tenant support
const {
getTenantInfo,
getClientConfig,
getLoginToken,
getAdmins
} = useTenants();
// Analytics & reporting
const {
getTransactionAnalytics
} = useAnalytics();
// Web3 & blockchain (wallet addresses from user.wallets)
const {
getTokenBalance,
getTokenMetadata,
getTokenCollection,
resolveIPFSUrl,
fetchAndProcessMetadata,
getChainDataById,
getExplorerUrl,
// Helper methods for token collections
extractTokenIds, // Extract tokenIds from TokenDTO metadata
getAccountOwnedTokensFromContract, // Recommended: Get owned tokens automatically
buildCollectionRequest // Build request for getTokenCollection
} = useWeb3();
// User status & achievements
const {
getUserStatusTypes,
getEarnedUserStatus,
createUserStatusType // Admin
} = useUserStatus();
// File management
const {
getSignedPutUrl,
getSignedGetUrl,
getSignedUrl,
optimizeMedia
} = useFiles();
// Donations
const {
getDonationTypes
} = useDonations();
// Event subscriptions (notifications, logging)
const {
subscribe, // Subscribe to SDK events
once, // One-time event listener
clear, // Clear all subscriptions
isAvailable, // Event system available
subscriberCount // Active subscriber count
} = useEvents();
`
Event System
The useEvents hook provides access to SDK-wide events for showing notifications, logging, and reacting to SDK operations. All events include a userMessage field ready for UI display.
$3
`typescript
import { useEvents } from '@explorins/pers-sdk-react-native';
import { useEffect } from 'react';
function NotificationHandler() {
const { subscribe, isAvailable } = useEvents();
useEffect(() => {
if (!isAvailable) return;
// Subscribe to all events
const unsubscribe = subscribe((event) => {
showNotification(event.userMessage, event.level);
});
return () => unsubscribe(); // Cleanup on unmount
}, [subscribe, isAvailable]);
}
`
$3
`typescript
// Only transaction successes
subscribe(
(event) => {
playSuccessSound();
showConfetti();
},
{ domain: 'transaction', level: 'success' }
);
// Only errors (for logging)
subscribe(
(event) => {
logToSentry(event);
},
{ level: 'error' }
);
// One-time event (auto-unsubscribes)
once(
(event) => {
console.log('First transaction completed!');
},
{ domain: 'transaction', level: 'success' }
);
`
$3
| Domain | Events |
|--------|--------|
| auth | Login, logout, token refresh |
| user | Profile updates |
| transaction | Created, completed, failed |
| campaign | Claimed, activated |
| redemption | Redeemed, expired |
| business | Created, updated, membership |
| api | Network errors, validation errors |
$3
`typescript
interface PersEvent {
id: string; // Unique event ID
timestamp: number; // Unix timestamp (ms)
domain: string; // Event domain (transaction, auth, etc.)
type: string; // Event type within domain
level: 'success' | 'error';
userMessage: string; // Ready for UI display
action?: string; // Suggested action
details?: object; // Additional data
}
`
---
POS Transaction Flow
For Point-of-Sale scenarios where a business submits a transaction on behalf of a user, use the buildPOSTransferRequest helper:
`typescript
import {
useTransactions,
buildPOSTransferRequest,
useEvents
} from '@explorins/pers-sdk-react-native';
function POSScreen() {
const { createTransaction, signingStatus } = useTransactions();
const { subscribe, isAvailable } = useEvents();
// Listen for transaction events
useEffect(() => {
if (!isAvailable) return;
const unsubscribe = subscribe(
(event) => {
if (event.level === 'success') {
Alert.alert('Success', event.userMessage);
}
},
{ domain: 'transaction' }
);
return () => unsubscribe();
}, [subscribe, isAvailable]);
const handlePOSTransaction = async (
userId: string,
businessId: string,
amount: number,
token: TokenDTO
) => {
// Build POS transfer request
const request = buildPOSTransferRequest({
amount,
contractAddress: token.contractAddress,
chainId: token.chainId,
userId, // User sending tokens
businessId // Business receiving & authorized to submit
});
// Create and sign transaction
const result = await createTransaction(request, (status, message) => {
console.log(Signing: ${status} - ${message});
});
console.log('Transaction created:', result.transaction?.id);
};
}
`
$3
The buildPOSTransferRequest helper automatically sets:
| Field | Value | Purpose |
|-------|-------|---------|
| engagedBusinessId | Business ID | Business commercially involved (for reporting) |
| authorizedSubmitterId | Business ID | Entity authorized to submit the signed tx |
| authorizedSubmitterType | BUSINESS | Type of authorized submitter |
For custom scenarios, use buildTransferRequest with manual POS fields:
`typescript
import { buildTransferRequest, AccountOwnerType } from '@explorins/pers-sdk-react-native';
const request = buildTransferRequest({
amount: 100,
contractAddress: '0x...',
chainId: 137,
senderAccountId: 'user-123',
senderAccountType: AccountOwnerType.USER,
recipientAccountId: 'business-456',
recipientAccountType: AccountOwnerType.BUSINESS,
// POS authorization
engagedBusinessId: 'business-456',
authorizedSubmitterId: 'business-456',
authorizedSubmitterType: AccountOwnerType.BUSINESS
});
`
---
Token Collection Helper Methods
The useWeb3 hook includes helper methods for querying token balances from any blockchain address. These work with all token standards (ERC-20, ERC-721, ERC-1155).
$3
A unified API to get all tokens owned by any blockchain address:
`typescript
import { useWeb3, useTokens, usePersSDK } from '@explorins/pers-sdk-react-native';
function RewardTokensScreen() {
const { user } = usePersSDK();
const { getRewardTokens } = useTokens();
const { getAccountOwnedTokensFromContract } = useWeb3();
const loadUserRewards = async () => {
const walletAddress = user?.wallets?.[0]?.address;
if (!walletAddress) return;
const rewardTokens = await getRewardTokens();
for (const token of rewardTokens) {
// Works with ERC-20, ERC-721, and ERC-1155 automatically
const result = await getAccountOwnedTokensFromContract(walletAddress, token);
console.log(Token: ${token.symbol});
console.log(Owned: ${result.totalOwned});
result.ownedTokens.forEach(owned => {
console.log( - ${owned.metadata?.name}: ${owned.balance});
});
}
};
return (
Load My Rewards
);
}
`
$3
| Token Type | What It Does |
|------------|--------------|
| ERC-20 | Returns balance for fungible tokens |
| ERC-721 | Enumerates all owned NFTs |
| ERC-1155 | Extracts tokenIds from metadata, queries balances |
The helper abstracts away the complexity - especially for ERC-1155 which requires specific tokenIds that the helper extracts from token.metadata[].tokenMetadataIncrementalId.
$3
`typescript
interface AccountOwnedTokensResult {
token: TokenDTO; // The token definition
ownedTokens: TokenBalance[]; // Tokens with balance > 0
totalOwned: number; // Count of owned tokens
}
`
EVM Blockchain Transaction Signing
The useTransactionSigner hook provides secure EVM blockchain transaction signing:
`typescript
import { useTransactionSigner } from '@explorins/pers-sdk-react-native';
function BlockchainRedemption() {
const {
signAndSubmitTransactionWithJWT,
isSignerAvailable,
isSignerInitialized
} = useTransactionSigner();
const handleBlockchainRedemption = async (jwtToken: string) => {
if (!isSignerAvailable) {
console.error('EVM blockchain signer not available');
return;
}
try {
// This handles the complete flow:
// 1. User authentication via WebAuthn
// 2. Transaction data fetching from PERS backend
// 3. Secure EVM transaction signing using device biometrics
// 4. EVM blockchain submission
const result = await signAndSubmitTransactionWithJWT(jwtToken);
if (result.success) {
console.log('Transaction completed!');
console.log('Hash:', result.transactionHash);
console.log('View on blockchain explorer: [Chain-specific URL]');
// Handle post-transaction flow
if (result.shouldRedirect && result.redirectUrl) {
// Navigate to success page or external URL
Linking.openURL(result.redirectUrl);
}
}
} catch (error) {
if (error.message.includes('expired')) {
// JWT token expired - redirect to login
navigation.navigate('Login');
} else if (error.message.includes('cancelled')) {
// User cancelled WebAuthn authentication
console.log('User cancelled transaction');
} else {
// Other errors
Alert.alert('Transaction Failed', error.message);
}
}
};
// Show loading state while signer initializes
if (!isSignerInitialized) {
return (
Initializing EVM blockchain signer...
);
}
return (
onPress={() => handleBlockchainRedemption(redemptionJWT)}
disabled={!isSignerAvailable}
style={[styles.button, !isSignerAvailable && styles.disabled]}
>
Sign & Submit Transaction
);
}
`
$3
The SDK provides comprehensive structured error handling with utilities for consistent error management:
`typescript
import {
PersApiError,
ErrorUtils
} from '@explorins/pers-sdk-react-native';
import { Alert } from 'react-native';
// Structured error handling
try {
await sdk.campaigns.claimCampaign({ campaignId });
} catch (error) {
if (error instanceof PersApiError) {
// Structured error with backend details
console.log('Error code:', error.code); // 'CAMPAIGN_BUSINESS_REQUIRED'
console.log('Status:', error.status); // 400
console.log('User message:', error.userMessage); // User-friendly message
// Show user-friendly error
Alert.alert('Error', error.userMessage || error.message);
// Check if retryable
if (error.retryable) {
Alert.alert('Error', error.message, [
{ text: 'Cancel' },
{ text: 'Retry', onPress: () => retry() }
]);
}
} else {
// Generic error fallback with ErrorUtils
const message = ErrorUtils.getMessage(error);
Alert.alert('Error', message);
}
}
// Error utilities for any error type
const status = ErrorUtils.getStatus(error); // Extract HTTP status
const message = ErrorUtils.getMessage(error); // Extract user message
const retryable = ErrorUtils.isRetryable(error); // Check if retryable
const tokenExpired = ErrorUtils.isTokenExpiredError(error); // Detect token expiration
`
$3
NEW in v2.1.1: Factory functions for client-side transaction workflows:
`typescript
import {
ClientTransactionType,
buildPendingTransactionData,
extractDeadlineFromSigningData,
type PendingTransactionParams
} from '@explorins/pers-sdk-react-native';
// Build pending transaction data for QR codes, NFC, deep links, etc.
const qrData = buildPendingTransactionData(
signingResult.transactionId,
signingResult.signature,
'EIP-712' // Default format
);
console.log(qrData);
// {
// transactionId: '...',
// signature: '0x...',
// transactionFormat: 'EIP-712',
// txType: 'PENDING_SUBMISSION'
// }
// Serialize for transfer (QR code, NFC, etc.)
const qrCodeValue = JSON.stringify(qrData);
// Extract deadline from EIP-712 signing data
const deadline = extractDeadlineFromSigningData(signingResult.signingData);
if (deadline) {
const expiryTime = new Date(deadline * 1000);
console.log('Transaction expires at:', expiryTime);
}
// Client transaction types
if (transaction.txType === ClientTransactionType.PENDING_SUBMISSION) {
// Handle pending submission flow
}
`
Security Features
$3
- Device biometric authentication (fingerprint, face ID, PIN)
- No passwords or private keys stored locally
- Secure hardware-backed authentication
$3
- React Native Keychain integration for sensitive data
- Automatic token refresh and expiration handling
- Multiple storage strategies (AsyncStorage, Keychain, Memory)
$3
- JWT token validation and expiration checking
- Secure transaction signing without exposing private keys
- Automatic session management with secure caching
Platform Support
- iOS: Full support with Keychain integration
- Android: Full support with Keystore integration
- Expo: Compatible with Expo managed workflow
- Web: React Native Web compatibility
Advanced Configuration
$3
The PersSDKProvider accepts a config object allowing you to customize behavior, including DPoP (Distributed Proof of Possession).
DPoP Behavior:
- Enabled by Default: Automatically active on iOS/Android using a high-performance C++ crypto bridge (react-native-quick-crypto).
- Web Support: Uses standard WebCrypto API.
- Security: Binds access tokens to the device, preventing replay attacks.
Customizing Initialization:
`typescript
apiProjectKey: 'your-project-key',
// DPoP is enabled by default. To disable (not recommended):
dpop: {
enabled: false
}
}}>
`
$3
The SDK implements a robust Hybrid Storage Strategy:
1. Primary: Attempts to use Android Keystore / iOS Keychain (via react-native-keychain) for maximum security.
2. Fallback: If hardware storage is unavailable or fails, it automatically falls back to AsyncStorage.
3. Web: Uses localStorage automatically.
This ensures your app works reliably across all devices while prioritizing security where available.
$3
The SDK automatically uses ReactNativeSecureStorage for React Native (iOS/Android) and LocalStorageTokenStorage for Web. You can customize this if needed:
`typescript
import { createReactNativeAuthProvider, ReactNativeSecureStorage } from '@explorins/pers-sdk-react-native';
const authProvider = createReactNativeAuthProvider('your-project-key', {
keyPrefix: 'my_app_tokens_',
debug: true,
// Optional: Provide custom storage implementation
// customStorage: new ReactNativeSecureStorage('custom_prefix_')
});
`
$3
`typescript
import { useTokens } from '@explorins/pers-sdk-react-native';
function ErrorHandlingExample() {
const { getTokens } = useTokens();
const [error, setError] = useState(null);
const handleLoadTokensWithErrorHandling = async () => {
try {
setError(null);
await getTokens();
} catch (err) {
// Handle specific error types
if (err.code === 'NETWORK_ERROR') {
setError('Network connection required');
} else if (err.code === 'INVALID_TOKEN') {
setError('Please log in again');
} else {
setError(err.message || 'An unexpected error occurred');
}
}
};
}
`
Analytics Integration
`typescript
import { useAnalytics } from '@explorins/pers-sdk-react-native';
function AnalyticsExample() {
const { getTransactionAnalytics } = useAnalytics();
const loadAnalytics = async () => {
try {
const txAnalytics = await getTransactionAnalytics({
groupBy: ['day'],
metrics: ['count', 'sum'],
startDate: '2023-01-01',
endDate: '2023-12-31'
});
console.log('Transaction analytics:', txAnalytics.results);
console.log('Execution time:', txAnalytics.metadata?.executionTime);
} catch (error) {
console.error('Failed to load analytics:', error);
}
};
}
`
Migration Guide
$3
Version 2.0.0 introduces standardized pagination across all hooks that return lists. Previously, hooks returned raw arrays. Now they return PaginatedResponseDTO with pagination metadata.
#### What Changed
All hooks returning lists now return paginated responses:
`typescript
// ❌ OLD (v1.x) - Direct array
const businesses: BusinessDTO[] = await getActiveBusinesses();
// ✅ NEW (v2.x) - Paginated response
const response: PaginatedResponseDTO = await getActiveBusinesses();
const businesses: BusinessDTO[] = response.data;
`
#### Affected Hooks
| Hook | Method | Return Type |
|------|--------|-------------|
| useBusiness | getActiveBusinesses() | PaginatedResponseDTO |
| useBusiness | getBusinessTypes() | PaginatedResponseDTO |
| useCampaigns | getActiveCampaigns() | PaginatedResponseDTO |
| useCampaigns | getUserClaims() | PaginatedResponseDTO |
| useTokens | getTokens() | PaginatedResponseDTO |
| useTokens | getRewardTokens() | PaginatedResponseDTO |
| useTokens | getStatusTokens() | PaginatedResponseDTO |
| useRedemptions | getActiveRedemptions() | PaginatedResponseDTO |
| useRedemptions | getUserRedemptions() | PaginatedResponseDTO |
| useTransactions | getUserTransactionHistory() | PaginatedResponseDTO |
| usePurchases | getAllUserPurchases() | PaginatedResponseDTO |
| usePurchases | getActivePurchaseTokens() | PaginatedResponseDTO |
| useDonations | getDonationTypes() | PaginatedResponseDTO |
#### PaginatedResponseDTO Structure
`typescript
import type { PaginatedResponseDTO } from '@explorins/pers-shared';
interface PaginatedResponseDTO {
data: T[]; // Array of results
pagination: {
currentPage: number; // Current page number (1-indexed)
pageSize: number; // Items per page
totalItems: number; // Total number of items across all pages
totalPages: number; // Total number of pages
};
}
`
#### Migration Examples
Before (v1.x):
`typescript
const businesses = await getActiveBusinesses();
console.log('Business count:', businesses.length);
businesses.forEach(b => console.log(b.name));
`
After (v2.x):
`typescript
const response = await getActiveBusinesses();
console.log('Business count:', response.data.length);
console.log('Total businesses:', response.pagination.totalItems);
response.data.forEach(b => console.log(b.name));
`
With Pagination Parameters:
`typescript
// Fetch page 2 with 20 items per page
const response = await getActiveBusinesses({ page: 2, pageSize: 20 });
console.log(Page ${response.pagination.currentPage} of ${response.pagination.totalPages});
console.log(Showing ${response.data.length} businesses);
`
#### React Native Component Example
`typescript
import { useState, useEffect } from 'react';
import { useBusiness } from '@explorins/pers-sdk-react-native';
import { View, Text, FlatList, ActivityIndicator } from 'react-native';
function BusinessListScreen() {
const { getActiveBusinesses } = useBusiness();
const [businesses, setBusinesses] = useState([]);
const [pagination, setPagination] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadBusinesses();
}, []);
const loadBusinesses = async (page = 1) => {
try {
setLoading(true);
const response = await getActiveBusinesses({ page, pageSize: 20 });
setBusinesses(response.data);
setPagination(response.pagination);
} catch (error) {
console.error('Failed to load businesses:', error);
} finally {
setLoading(false);
}
};
if (loading) return ;
return (
Showing {businesses.length} of {pagination?.totalItems} businesses
data={businesses}
keyExtractor={(item) => item.id}
renderItem={({ item }) => }
/>
Page {pagination?.currentPage} of {pagination?.totalPages}
);
}
`
#### Quick Fix for Existing Code
If you want minimal code changes, extract .data immediately:
`typescript
// Quick adaptation in your hooks
const businesses = (await getActiveBusinesses()).data;
const campaigns = (await getActiveCampaigns()).data;
const tokens = (await getTokens()).data;
`
#### Benefits of Pagination
- Performance: Load only what you need, not entire datasets
- Consistency: All list endpoints follow the same pattern
- Metadata: Access total counts without loading all items
- Better UX: Build proper pagination UI components
- Memory Efficiency: Reduced memory footprint for large datasets
---
Documentation
$3
- AUTH_STATE_HANDLING.md - Comprehensive guide for handling authentication state changes
- Pattern examples for observing isAuthenticated` state