diff --git a/package.json b/package.json index fd6fc94..22e27e9 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "peerDependencies": { "@craftzdog/react-native-buffer": "^6.1.0", "@react-native-async-storage/async-storage": "^2.2.0", + "@scure/base": "^1.2.0", "@tetherto/pear-wrk-wdk": "^1.0.0-beta.4", "@tetherto/wdk-secret-manager": "^1.0.0-beta.3", "b4a": "^1.7.2", diff --git a/src/contexts/ocp-context.tsx b/src/contexts/ocp-context.tsx new file mode 100644 index 0000000..5514900 --- /dev/null +++ b/src/contexts/ocp-context.tsx @@ -0,0 +1,331 @@ +import type { ReactNode } from 'react'; +import { createContext, useContext, useReducer } from 'react'; +import { OcpError, ocpService } from '../services/ocp'; +import { WDKService } from '../services/wdk-service'; +import type { NetworkType } from '../services/wdk-service/types'; +import { AssetTicker } from '../services/wdk-service/types'; +import type { + OcpDetection, + OcpPaymentDetails, + OcpStatus, + OcpSubmitResult, + OcpTransactionDetails, +} from '../services/ocp/types'; + +// Mappings + +const OCP_METHOD_TO_NETWORK: Record = { + Bitcoin: 'bitcoin' as NetworkType, + Ethereum: 'ethereum' as NetworkType, + Arbitrum: 'arbitrum' as NetworkType, + Polygon: 'polygon' as NetworkType, + Lightning: 'lightning' as NetworkType, + Solana: 'solana' as NetworkType, + Tron: 'tron' as NetworkType, +}; + +const OCP_ASSET_TO_TICKER: Record = { + BTC: AssetTicker.BTC, + USDT: AssetTicker.USDT, + XAUT: AssetTicker.XAUT, +}; + +const SUPPORTED_METHODS = Object.keys(OCP_METHOD_TO_NETWORK); + +// State + +interface OcpState { + status: OcpStatus; + detection: OcpDetection | null; + paymentDetails: OcpPaymentDetails | null; + transactionDetails: OcpTransactionDetails | null; + txHash: string | null; + result: OcpSubmitResult | null; + error: string | null; +} + +const INITIAL_STATE: OcpState = { + status: 'idle', + detection: null, + paymentDetails: null, + transactionDetails: null, + txHash: null, + result: null, + error: null, +}; + +// Actions + +type OcpAction = + | { type: 'SET_STATUS'; payload: OcpStatus } + | { type: 'SET_DETECTION'; payload: OcpDetection } + | { type: 'SET_PAYMENT_DETAILS'; payload: OcpPaymentDetails } + | { type: 'SET_TRANSACTION_DETAILS'; payload: OcpTransactionDetails } + | { type: 'SET_TX_HASH'; payload: string } + | { type: 'SET_RESULT'; payload: OcpSubmitResult } + | { type: 'SET_ERROR'; payload: string } + | { type: 'RESET' }; + +function reducer(state: OcpState, action: OcpAction): OcpState { + switch (action.type) { + case 'SET_STATUS': + return { ...state, status: action.payload }; + + case 'SET_DETECTION': + return { ...state, detection: action.payload, error: null }; + + case 'SET_PAYMENT_DETAILS': + return { + ...state, + paymentDetails: action.payload, + status: 'ready', + }; + + case 'SET_TRANSACTION_DETAILS': + return { ...state, transactionDetails: action.payload }; + + case 'SET_TX_HASH': + return { ...state, txHash: action.payload }; + + case 'SET_RESULT': + return { ...state, result: action.payload, status: 'success' }; + + case 'SET_ERROR': + return { ...state, error: action.payload, status: 'error' }; + + case 'RESET': + return INITIAL_STATE; + + default: + return state; + } +} + +// Context + +interface OcpContextType extends OcpState { + supportedMethods: string[]; + detect: (qrData: string) => OcpDetection | null; + fetchPaymentDetails: ( + apiUrl: string, + timeout?: number + ) => Promise; + pay: ( + method: string, + asset: string, + accountIndex?: number + ) => Promise; + reset: () => void; +} + +const OcpContext = createContext(undefined); + +// Provider + +export function OcpProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(reducer, INITIAL_STATE); + + const reset = () => { + dispatch({ type: 'RESET' }); + }; + + const detect = (qrData: string): OcpDetection | null => { + const detection = ocpService.detect(qrData); + if (detection) { + dispatch({ type: 'SET_DETECTION', payload: detection }); + } + return detection; + }; + + const fetchPaymentDetails = async ( + apiUrl: string, + timeout?: number + ): Promise => { + dispatch({ type: 'SET_STATUS', payload: 'loading' }); + + try { + const details = await ocpService.getPaymentDetails(apiUrl, timeout); + dispatch({ type: 'SET_PAYMENT_DETAILS', payload: details }); + return details; + } catch (e) { + console.error('Failed to fetch payment details:', e); + const msg = + e instanceof Error ? e.message : 'Failed to fetch payment details'; + dispatch({ type: 'SET_ERROR', payload: msg }); + throw e; + } + }; + + const pay = async ( + method: string, + asset: string, + accountIndex: number = 0 + ): Promise => { + if (!state.paymentDetails) { + throw new OcpError( + 'no_payment', + 'No payment details. Call fetchPaymentDetails first.' + ); + } + + if (state.status === 'paying' || state.status === 'submitting') { + throw new OcpError( + 'payment_in_progress', + 'A payment is already in progress' + ); + } + + // Check quote expiration before proceeding + const expiration = new Date(state.paymentDetails.quote.expiration); + if (expiration.getTime() < Date.now()) { + throw new OcpError( + 'quote_expired', + 'Quote has expired. Fetch new payment details' + ); + } + + const network = OCP_METHOD_TO_NETWORK[method]; + if (!network) { + throw new OcpError( + 'unsupported_method', + `Method "${method}" is not supported by WDK` + ); + } + + const wdkAsset = OCP_ASSET_TO_TICKER[asset]; + if (!wdkAsset) { + throw new OcpError( + 'unsupported_asset', + `Asset "${asset}" is not supported by WDK` + ); + } + + const transferMethod = state.paymentDetails.transferAmounts.find( + (t) => t.method === method && t.available + ); + if (!transferMethod) { + throw new OcpError( + 'method_unavailable', + `Method "${method}" is not available for this payment` + ); + } + + const transferAsset = transferMethod.assets.find( + (a) => a.asset === asset + ); + if (!transferAsset) { + throw new OcpError( + 'asset_unavailable', + `Asset "${asset}" is not available for method "${method}"` + ); + } + + const amount = parseFloat(transferAsset.amount); + + try { + // Get transaction details + dispatch({ type: 'SET_STATUS', payload: 'paying' }); + const txDetails = await ocpService.getTransactionDetails( + state.paymentDetails.callback, + state.paymentDetails.quote.id, + method, + asset + ); + dispatch({ type: 'SET_TRANSACTION_DETAILS', payload: txDetails }); + + // Send via WDK + const response = await WDKService.sendByNetwork( + network, + accountIndex, + amount, + extractRecipient(txDetails, method), + wdkAsset + ); + + const txHash = extractTxHash(response); + if (!txHash) { + throw new OcpError( + 'no_tx_hash', + 'WDK did not return a transaction hash' + ); + } + + // Persist tx hash so the app can retry submission if it fails + dispatch({ type: 'SET_TX_HASH', payload: txHash }); + + // Submit tx hash to OCP API + dispatch({ type: 'SET_STATUS', payload: 'submitting' }); + const submitResult = await ocpService.submitTxHash( + state.paymentDetails.callback, + state.paymentDetails.quote.id, + state.paymentDetails.quote.payment, + method, + txHash + ); + + dispatch({ type: 'SET_RESULT', payload: submitResult }); + return submitResult; + } catch (e) { + console.error('OCP payment failed:', e); + const msg = e instanceof Error ? e.message : 'Payment failed'; + dispatch({ type: 'SET_ERROR', payload: msg }); + throw e; + } + }; + + const value: OcpContextType = { + ...state, + supportedMethods: SUPPORTED_METHODS, + detect, + fetchPaymentDetails, + pay, + reset, + }; + + return {children}; +} + +// Hook + +export function useOcp() { + const context = useContext(OcpContext); + if (context === undefined) { + throw new Error('useOcp must be used within an OcpProvider'); + } + return context; +} + +// Helpers + +function extractRecipient( + txDetails: OcpTransactionDetails, + method: string +): string { + const { uri } = txDetails; + if (!uri) { + throw new OcpError( + 'no_uri', + `No payment URI for method "${method}"` + ); + } + + // ethereum:0x1234...@chainId?value=... | bitcoin:bc1...?amount=... + const match = uri.match(/^[a-zA-Z]+:([^?@/]+)/); + if (!match || !match[1]) { + throw new OcpError('invalid_uri', `Cannot parse recipient from URI: ${uri}`); + } + + return match[1] as string; +} + +function extractTxHash(response: unknown): string | null { + if (!response) return null; + if (typeof response === 'string') return response; + if (typeof response !== 'object') return null; + + const r = response as Record; + const hash = r.txHash ?? r.hash ?? r.transactionHash ?? r.txId; + return typeof hash === 'string' ? hash : null; +} + +export default OcpContext; diff --git a/src/index.tsx b/src/index.tsx index 70aab67..d09135f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -26,3 +26,22 @@ export { AssetTicker, NetworkType, } from './services/wdk-service/types'; + +// Export OpenCryptoPay +export { + useOcp, + default as OcpContext, + OcpProvider, +} from './contexts/ocp-context'; +export { OcpService, OcpError, ocpService } from './services/ocp'; +export type { + OcpPaymentDetails, + OcpTransactionDetails, + OcpTransferMethod, + OcpTransferAsset, + OcpRecipient, + OcpQuote, + OcpSubmitResult, + OcpDetection, + OcpStatus, +} from './services/ocp/types'; diff --git a/src/services/ocp/index.ts b/src/services/ocp/index.ts new file mode 100644 index 0000000..a30e9c3 --- /dev/null +++ b/src/services/ocp/index.ts @@ -0,0 +1,138 @@ +import { detectOcp } from './uri-parser'; +import type { + OcpDetection, + OcpPaymentDetails, + OcpSubmitResult, + OcpTransactionDetails, +} from './types'; + +class OcpService { + private static instance: OcpService; + + private constructor() {} + + static getInstance(): OcpService { + if (!OcpService.instance) { + OcpService.instance = new OcpService(); + } + return OcpService.instance; + } + + detect(qrData: string): OcpDetection | null { + return detectOcp(qrData); + } + + // Step 2: Fetch payment details from decoded LNURL. + // Per LUD-01: HTTP status codes have no meaning — always parse body as JSON. + async getPaymentDetails( + apiUrl: string, + timeout?: number + ): Promise { + const url = new URL(apiUrl); + if (timeout !== undefined) { + url.searchParams.set('timeout', timeout.toString()); + } + + const body = await this.fetchJson(url.toString()); + + // Not an OpenCryptoPay response + if (body.standard !== 'OpenCryptoPay') { + throw new OcpError( + 'not_ocp', + `Unsupported LNURL standard: ${body.standard ?? body.tag ?? 'unknown'}` + ); + } + + // No pending payment (404 body still parsed as JSON per LUD-01) + if (body.error || body.statusCode === 404) { + throw new OcpError( + 'no_pending_payment', + body.message ?? 'No pending payment found' + ); + } + + return body as OcpPaymentDetails; + } + + // Step 3: Fetch transaction details for selected method + asset + async getTransactionDetails( + callbackUrl: string, + quoteId: string, + method: string, + asset: string + ): Promise { + const url = new URL(callbackUrl); + url.searchParams.set('quote', quoteId); + url.searchParams.set('method', method); + url.searchParams.set('asset', asset); + + const body = await this.fetchJson(url.toString()); + return body as OcpTransactionDetails; + } + + // Steps 2+3 combined (simplified flow) + async getTransactionDetailsDirect( + apiUrl: string, + method: string, + asset: string + ): Promise { + const url = new URL(apiUrl); + url.searchParams.set('method', method); + url.searchParams.set('asset', asset); + + const body = await this.fetchJson(url.toString()); + return body as OcpTransactionDetails; + } + + // Step 4: Submit tx hash after wallet has broadcast the transaction + async submitTxHash( + callbackUrl: string, + quoteId: string, + paymentId: string, + method: string, + txHash: string + ): Promise { + // TX URL uses quote.payment as path ID, not the payment link ID + const txUrl = callbackUrl.replace(/\/cb\/.*$/, `/tx/${paymentId}`); + const url = new URL(txUrl); + url.searchParams.set('quote', quoteId); + url.searchParams.set('method', method); + url.searchParams.set('tx', txHash); + + const body = await this.fetchJson(url.toString()); + return body as OcpSubmitResult; + } + + // Per LUD-01: always parse response body as JSON, ignore HTTP status codes. + // Checks for LNURL error responses ({ status: "ERROR", reason: "..." }). + private async fetchJson(url: string): Promise { + const response = await fetch(url); + + let body: any; + try { + body = await response.json(); + } catch { + throw new OcpError('invalid_response', 'Failed to parse response as JSON'); + } + + if (body.status === 'ERROR') { + throw new OcpError('lnurl_error', body.reason ?? 'LNURL service error'); + } + + return body; + } +} + +export class OcpError extends Error { + public code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'OcpError'; + this.code = code; + } +} + +export const ocpService = OcpService.getInstance(); + +export { OcpService }; diff --git a/src/services/ocp/lnurl-decode.ts b/src/services/ocp/lnurl-decode.ts new file mode 100644 index 0000000..1c54b37 --- /dev/null +++ b/src/services/ocp/lnurl-decode.ts @@ -0,0 +1,17 @@ +import { bech32 } from '@scure/base'; + +// Decodes an LNURL bech32 string to the original URL (LUD-01). +// Validates that the decoded URL is https:// or an .onion http:// link. +export function decodeLnurl(lnurl: string): string { + const decoded = bech32.decodeToBytes(lnurl.toLowerCase()); + const url = Buffer.from(decoded.bytes).toString('utf8'); + + // LUD-01: only https:// clearnet or http:// .onion links are valid + const isHttps = url.startsWith('https://'); + const isOnion = url.startsWith('http://') && url.includes('.onion'); + if (!isHttps && !isOnion) { + throw new Error(`Invalid LNURL: must be https or onion, got ${url.substring(0, 30)}`); + } + + return url; +} diff --git a/src/services/ocp/types.ts b/src/services/ocp/types.ts new file mode 100644 index 0000000..d880992 --- /dev/null +++ b/src/services/ocp/types.ts @@ -0,0 +1,85 @@ +export interface OcpRecipient { + name: string; + address?: { + street: string; + houseNumber: string; + zip: string; + city: string; + country: string; + }; + phone?: string; + mail?: string; + website?: string; + registrationNumber?: string; + storeType?: string; + merchantCategory?: string; + goodsType?: string; + goodsCategory?: string; +} + +export interface OcpTransferAsset { + asset: string; + amount: string; +} + +export interface OcpTransferMethod { + method: string; + minFee: number; + assets: OcpTransferAsset[]; + available: boolean; +} + +export interface OcpQuote { + id: string; + expiration: string; + payment: string; +} + +export interface OcpPaymentDetails { + id: string; + externalId?: string; + mode?: string; + tag?: string; + callback: string; + minSendable?: number; + maxSendable?: number; + metadata?: string; + displayName: string; + standard: string; + possibleStandards?: string[]; + displayQr?: boolean; + recipient: OcpRecipient; + route?: string; + quote: OcpQuote; + requestedAmount: { + asset: string; + amount: number; + }; + transferAmounts: OcpTransferMethod[]; +} + +export interface OcpTransactionDetails { + expiryDate?: string; + blockchain?: string; + uri?: string; + pr?: string; + hint?: string; +} + +export interface OcpSubmitResult { + txId?: string; +} + +export type OcpStatus = + | 'idle' + | 'loading' + | 'ready' + | 'paying' + | 'submitting' + | 'success' + | 'error'; + +export interface OcpDetection { + apiUrl: string; + raw: string; +} diff --git a/src/services/ocp/uri-parser.ts b/src/services/ocp/uri-parser.ts new file mode 100644 index 0000000..4d72477 --- /dev/null +++ b/src/services/ocp/uri-parser.ts @@ -0,0 +1,45 @@ +import { decodeLnurl } from './lnurl-decode'; +import type { OcpDetection } from './types'; + +// Detects whether a scanned QR string contains an LNURL that could be +// an OpenCryptoPay payment link. Follows LUD-01 detection rules. +// +// Supported formats: +// - Direct LNURL string: LNURL1... +// - Any URL with a 'lightning' query parameter containing an LNURL +// +// Returns the decoded API URL for further verification via getPaymentDetails(). +// The actual OCP check happens when the API response contains standard: "OpenCryptoPay". +export function detectOcp(raw: string): OcpDetection | null { + const trimmed = raw.trim(); + + // Direct LNURL string (LUD-01: can be uppercase or lowercase, not mixed) + if (/^lnurl1/i.test(trimmed)) { + try { + const apiUrl = decodeLnurl(trimmed); + return { apiUrl, raw: trimmed }; + } catch { + return null; + } + } + + // URL with lightning query parameter (standard LNURL embedding) + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + + const lightning = url.searchParams.get('lightning'); + if (!lightning) { + return null; + } + + try { + const apiUrl = decodeLnurl(lightning); + return { apiUrl, raw: trimmed }; + } catch { + return null; + } +}