Documentation Index Fetch the complete documentation index at: https://mintlify.com/sovranBitcoin/sovran/llms.txt
Use this file to discover all available pages before exploring further.
Overview
Sovran supports NFC (Near Field Communication) payments for contactless ecash transfers. Users can tap their device to a point-of-sale terminal or another device to send/receive Cashu tokens instantly.
NFC Architecture
The NFC implementation is modular and located in helper/nfc/:
helper/nfc/
├── index.ts # Public API exports
├── payment.ts # POS payment flow
├── write-token.ts # P2P token writing
├── errors.ts # Typed error handling
├── apdu.ts # ISO 7816 APDU commands
├── ndef.ts # NDEF message encoding/decoding
├── mint-selection.ts # Mint selection logic
├── status.ts # NFC availability checks
├── constants.ts # Protocol constants
├── messages.ts # User-facing error messages
└── logger.ts # Debug logging
Payment Hook
The useNfcEcashPayment hook provides a declarative interface for NFC payments:
export type NfcPaymentStatus = 'idle' | 'paying' | 'error' ;
export function useNfcEcashPayment ({
send ,
manager ,
availableMints ,
preferredMint ,
getSelectedMint ,
pubkey ,
usdToSats ,
} : UseNfcEcashPaymentArgs ) {
const [ status , setStatus ] = useState < NfcPaymentStatus >( 'idle' );
const [ error , setError ] = useState < NfcErrorMessage | null >( null );
return useMemo (
() => ({
status ,
error ,
resetError ,
startPayment ,
handleNfcPaymentAlert ,
isIdle: status === 'idle' ,
isPaying: status === 'paying' ,
isError: status === 'error' ,
}),
[ status , error , resetError , startPayment , handleNfcPaymentAlert ]
);
}
From hooks/useNfcEcashPayment.tsx:29-219.
Payment Flow
The NFC payment flow consists of four phases:
Phase 1: Read Payment Request
Acquire IsoDep technology
Select AID (Application Identifier)
Select NDEF file
Read NLEN (NDEF length)
Read NDEF content (chunked if > 250 bytes)
Decode payment request
log ( 'Phase 1: Reading payment request...' );
let r = await sendApdu ( SELECT_AID , 'SELECT AID' );
if ( ! r . ok ) {
throw new NfcError (
`AID not accepted by tag ( ${ getStatusMessage ( r . sw ) } )` ,
'AID_SELECT_FAILED' ,
r . sw
);
}
r = await sendApdu ( SELECT_NDEF , 'SELECT NDEF' );
if ( ! r . ok ) {
throw new NfcError (
`NDEF file not accessible ( ${ getStatusMessage ( r . sw ) } )` ,
'NDEF_SELECT_FAILED' ,
r . sw
);
}
r = await sendApdu ( readBinary ( 0 , 2 ), 'READ NLEN' );
const nlen = ( r . payload [ 0 ] << 8 ) | r . payload [ 1 ];
// Read NDEF content (chunked if necessary)
let ndefBytes : number [] = [];
if ( nlen <= MAX_CHUNK_SIZE ) {
r = await sendApdu ( readBinary ( 2 , nlen ), 'READ NDEF' );
ndefBytes = r . payload ;
} else {
// Chunked reading for large NDEF messages
let offset = 2 ;
let remaining = nlen ;
while ( remaining > 0 ) {
const chunkSize = Math . min ( remaining , MAX_CHUNK_SIZE );
r = await sendApdu ( readBinary ( offset , chunkSize ), `READ chunk @ ${ offset } ` );
ndefBytes . push ( ... r . payload );
offset += chunkSize ;
remaining -= chunkSize ;
}
}
paymentRequest = decodeTextRecord ( ndefBytes );
From helper/nfc/payment.ts:114-183.
Phase 2: Decode and Validate
Decode NUT-18 payment request
Extract amount, unit, allowed mints
Validate amount against limit
Wait for mint availability
Select best mint
log ( 'Phase 2: Decoding payment request...' );
const { decodePaymentRequest } = await import ( '@cashu/cashu-ts' );
const decoded = decodePaymentRequest ( paymentRequest );
amount = decoded . amount ?? 0 ;
const unit = decoded . unit ?? 'sat' ;
const allowedMints = decoded . mints ?? [];
log ( `Payment request: ${ amount } ${ unit } ` );
if ( amount <= 0 ) {
throw new NfcError ( 'Invalid payment amount' , 'INVALID_AMOUNT' );
}
if ( maxAmountSats !== undefined && amount > maxAmountSats ) {
throw new NfcError (
`Amount ${ amount } sats exceeds your limit of ${ maxAmountSats } sats` ,
'AMOUNT_EXCEEDED'
);
}
const liveAvailableMints = await waitForAvailableMints ( resolveAvailableMints );
if ( Object . keys ( liveAvailableMints ). length === 0 ) {
throw new NfcError (
'No mints available. Please add a mint to your wallet first.' ,
'NO_AVAILABLE_MINTS'
);
}
const mintSelection = selectBestMint ( allowedMints , liveAvailableMints , amount , preferredMint );
selectedMint = mintSelection . mintUrl ;
From helper/nfc/payment.ts:214-247.
Phase 3: Create Token
Call createToken callback
Generate ecash token for amount
Encode as V4 token
log ( 'Phase 3: Creating token...' );
try {
createdToken = await createToken ( selectedMint , amount );
} catch ( error ) {
logError ( 'Token creation failed:' , error );
throw new NfcError (
`Failed to create token: ${ error instanceof Error ? error . message : String ( error ) } ` ,
'TOKEN_CREATION_FAILED'
);
}
if ( ! createdToken || createdToken . length === 0 ) {
throw new NfcError ( 'Token creation returned empty token' , 'INVALID_TOKEN' );
}
log ( `Token created ( ${ createdToken . length } chars)` );
From helper/nfc/payment.ts:249-265.
Phase 4: Write Token Back
Re-select NDEF file
Write NLEN
Write NDEF content (chunked if > 250 bytes)
Release NFC technology
log ( 'Phase 4: Writing token back to POS...' );
r = await sendApdu ( SELECT_NDEF , 'SELECT NDEF (write)' );
if ( ! r . ok ) {
throw new NfcError (
`NDEF file not accessible for write ( ${ getStatusMessage ( r . sw ) } )` ,
'NDEF_SELECT_FAILED' ,
r . sw
);
}
const ndef = buildTextNdef ( createdToken );
r = await sendApdu ( updateBinary ( 0 , [ ndef [ 0 ], ndef [ 1 ]]), 'WRITE NLEN' );
if ( ! r . ok ) {
throw new NfcError (
`Failed writing NLEN ( ${ getStatusMessage ( r . sw ) } )` ,
'WRITE_NLEN_FAILED' ,
r . sw
);
}
let offset = 2 ;
const body = ndef . slice ( 2 );
const totalChunks = Math . ceil ( body . length / MAX_CHUNK_SIZE );
for ( let chunkNum = 0 ; offset - 2 < body . length ; chunkNum ++ ) {
const chunk = body . slice ( offset - 2 , offset - 2 + MAX_CHUNK_SIZE );
logDebug ( `Writing chunk ${ chunkNum + 1 } / ${ totalChunks } : ${ chunk . length } bytes` );
r = await sendApdu ( updateBinary ( offset , chunk ), `WRITE chunk ${ chunkNum + 1 } ` );
if ( ! r . ok ) {
throw new NfcError (
`Failed writing chunk ( ${ getStatusMessage ( r . sw ) } )` ,
'WRITE_CHUNK_FAILED' ,
r . sw
);
}
offset += chunk . length ;
}
log ( 'NFC payment completed successfully!' );
await releaseNfc ();
From helper/nfc/payment.ts:267-307.
Token Recovery
If the write fails after token creation, the token is automatically recovered:
try {
const result = await NfcPayment . performPayment ({
createToken : async ( mintUrl , amount ) => {
const { token , historyEntry } = await send ( mintUrl , amount );
lastOperationId = historyEntry . operationId ;
return getEncodedTokenV4 ( token );
},
recoverToken: rollbackPendingSend ,
// ...
});
} catch ( err ) {
const code = isNfcError ? err . code : 'PAYMENT_FAILED' ;
if ( code === 'TAG_LOST' || code === 'TRANSCEIVE_FAILED' || ! isNfcError ) {
await rollbackPendingSend ();
}
}
From hooks/useNfcEcashPayment.tsx:123-176.
Lightning Invoice Detection
NFC can detect Lightning invoices and redirect to Lightning flow:
const trimmedForLightning = lnTrim ( paymentRequest );
if ( isLightningInvoice ( trimmedForLightning )) {
log ( 'Lightning invoice detected via NFC' );
const lnAmount = getLightningAmount ( trimmedForLightning );
if ( onLightningInvoice ) {
await releaseNfc ();
onLightningInvoice ( trimmedForLightning , lnAmount );
return { paymentRequest , mintUrl: '' , amount: lnAmount };
}
throw new NfcError (
'Lightning invoice detected. Please use the Lightning payment flow.' ,
'LIGHTNING_INVOICE_DETECTED'
);
}
From helper/nfc/payment.ts:198-212.
Mint Selection
The selectBestMint function chooses the optimal mint:
export function selectBestMint (
allowedMints : string [],
availableMints : Record < string , number >,
amount : number ,
preferredMint ?: string
) : { mintUrl : string ; balance : number } {
// Priority 1: Preferred mint (if allowed and has balance)
if ( preferredMint && availableMints [ preferredMint ] >= amount ) {
if ( allowedMints . length === 0 || allowedMints . includes ( preferredMint )) {
return { mintUrl: preferredMint , balance: availableMints [ preferredMint ] };
}
}
// Priority 2: Allowed mints with sufficient balance
const validMints = allowedMints . length > 0
? allowedMints . filter (( mint ) => availableMints [ mint ] >= amount )
: Object . entries ( availableMints )
. filter (([ _ , balance ]) => balance >= amount )
. map (([ mint ]) => mint );
if ( validMints . length === 0 ) {
throw new Error ( 'No mints with sufficient balance' );
}
// Select mint with highest balance
const bestMint = validMints . reduce (( best , current ) => {
return availableMints [ current ] > availableMints [ best ] ? current : best ;
});
return { mintUrl: bestMint , balance: availableMints [ bestMint ] };
}
From helper/nfc/mint-selection.ts.
Payment Limit Tiers
Users select a payment limit before tapping:
const handleNfcPaymentAlert = useCallback (() => {
Alert . alert ( 'NFC Payment Limit' , 'Select your payment limit' , [
... PAYMENT_TIERS . map (( tier ) => ({
text: tier . label ,
onPress : () => startPayment ( tier . usdLimit ),
})),
{ text: 'Cancel' , style: 'cancel' },
]);
}, [ startPayment ]);
From hooks/useNfcEcashPayment.tsx:197-205.
P2P Token Writing
Write tokens to NFC tags for peer-to-peer sharing:
export async function writeTokenToNFC ( token : string ) : Promise < NfcTokenWriteResult > {
log ( 'Starting NFC token write...' );
if ( ! ( await isNfcSupported ())) {
return {
success: false ,
errorCode: 'NOT_SUPPORTED' ,
errorMessage: 'NFC is not supported on this device' ,
};
}
if ( ! ( await isNfcEnabled ())) {
return { success: false , errorCode: 'NOT_ENABLED' , errorMessage: 'NFC is disabled' };
}
try {
await NfcManager . requestTechnology ( NfcTech . IsoDep );
let r = await sendApdu ( SELECT_AID , 'SELECT AID' );
r = await sendApdu ( SELECT_NDEF , 'SELECT NDEF' );
const ndef = buildTextNdef ( token );
r = await sendApdu ( updateBinary ( 0 , [ ndef [ 0 ], ndef [ 1 ]]), 'WRITE NLEN' );
// Write NDEF body in chunks
let offset = 2 ;
const body = ndef . slice ( 2 );
for ( let chunkNum = 0 ; offset - 2 < body . length ; chunkNum ++ ) {
const chunk = body . slice ( offset - 2 , offset - 2 + MAX_CHUNK_SIZE );
r = await sendApdu ( updateBinary ( offset , chunk ), `WRITE chunk ${ chunkNum + 1 } ` );
offset += chunk . length ;
}
log ( 'Token written to NFC successfully!' );
return { success: true };
} catch ( error ) {
// Error handling
} finally {
await NfcManager . cancelTechnologyRequest ();
}
}
From helper/nfc/write-token.ts:20-97.
Error Handling
Typed errors with user-friendly messages:
export class NfcError extends Error {
constructor (
message : string ,
public code : string ,
public statusWord ?: number
) {
super ( message );
this . name = 'NfcError' ;
}
}
Error codes include:
NOT_SUPPORTED - NFC not available on device
NOT_ENABLED - NFC disabled in settings
TAG_LOST - Connection lost during operation
AMOUNT_EXCEEDED - Payment exceeds limit
NO_AVAILABLE_MINTS - No mints with balance
INVALID_AMOUNT - Invalid payment amount
TOKEN_CREATION_FAILED - Failed to create token
WRITE_CHUNK_FAILED - Failed to write token
NFC payment button in wallet header:
const nfc = useNfcEcashPayment ({
send ,
manager: manager ?? undefined ,
availableMints ,
preferredMint: selectedMint ,
getSelectedMint ,
pubkey: keys ?. pubkey ,
usdToSats ,
});
// Header right button
< Stack . Screen
options = {{
headerRightIcon : 'wave.3.right' ,
onHeaderRightPress : nfc . handleNfcPaymentAlert ,
}}
/>
From app/(drawer)/(tabs)/index/_layout.tsx:70-97.
Key Features
Contactless Payments Tap-to-pay at NFC-enabled point-of-sale terminals
Automatic Recovery Token rollback if write fails after creation
Lightning Detection Automatic detection and routing for Lightning invoices
Smart Mint Selection Intelligent mint selection based on balance and restrictions