diff --git a/package-lock.json b/package-lock.json index 500b607..1bd4deb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@vitest/coverage-v8": "^3.1.4", "tsx": "^4.19.4", "typescript": "^5.8.3", - "vitest": "^3.1.4" + "vitest": "^3.2.7" }, "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index 36dec1c..053c374 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@vitest/coverage-v8": "^3.1.4", "tsx": "^4.19.4", "typescript": "^5.8.3", - "vitest": "^3.1.4" + "vitest": "^3.2.7" }, "engines": { "node": ">=18.0.0" diff --git a/src/errors/payment-errors.ts b/src/errors/payment-errors.ts new file mode 100644 index 0000000..48009b5 --- /dev/null +++ b/src/errors/payment-errors.ts @@ -0,0 +1,16 @@ +export enum PaymentErrorCode { + InvalidAddress = 'INVALID_ADDRESS', + InvalidAmount = 'INVALID_AMOUNT', + InvalidAsset = 'INVALID_ASSET', + InvalidMemo = 'INVALID_MEMO', + InvalidMetadata = 'INVALID_METADATA', +} + +export class PaymentParseError extends Error { + code: PaymentErrorCode; + constructor(message: string, code: PaymentErrorCode) { + super(message); + this.name = 'PaymentParseError'; + this.code = code; + } +} \ No newline at end of file diff --git a/src/payments/index.ts b/src/payments/index.ts index 24d7c20..73a27e8 100644 --- a/src/payments/index.ts +++ b/src/payments/index.ts @@ -259,11 +259,11 @@ export type { DestinationValidationOptions, DestinationValidationResult, } from './destination-validation'; - // ─── Send-XLM Input Validation (non-throwing) ─────────────────────────────── export { validateSendXLMParams, } from './validation'; +export { parseQRPayload, QRPayload, QRParseResult } from './qrParser'; export type { ValidationError, ValidationErrorCode, diff --git a/src/payments/qrParser.ts b/src/payments/qrParser.ts new file mode 100644 index 0000000..57759a3 --- /dev/null +++ b/src/payments/qrParser.ts @@ -0,0 +1,170 @@ +// src/payments/qrParser.ts +import { validatePublicKey, validateAmount, validateMemoInput } from '../utils'; +import { validateAssetSpec } from './trustline'; +import type { StellarAssetSpec } from '../types'; +import type { ValidationError } from './validation'; + +/** + * Supported QR payload format (URL query string): + * pocketpay://pay?address=G...&amount=10.5&asset=USD:ISSUER&memo=hello&metadata=key1%3Avalue1%2Ckey2%3Avalue2 + * + * - `address` (required): destination public key (Stellar G... address) + * - `amount` (required): decimal string, positive, up to 7 decimal places + * - `asset` (optional): "CODE:ISSUER" or "XLM"/"native" + * - `memo` (optional): free‑form memo, max 28 bytes when encoded as UTF‑8 + * - `metadata` (optional): URL‑encoded comma‑separated key:value pairs, each value string + */ +export interface QRPayload { + address: string; + amount: string; + asset?: StellarAssetSpec; + memo?: string; + metadata?: Record; +} + +export type QRParseResult = + | { ok: true; payload: QRPayload } + | { ok: false; errors: ValidationError[] }; + +/** + * Parse a QR payload string into structured data, performing validation. + * Returns a structured result rather than throwing. + */ +export function parseQRPayload(input: string): QRParseResult { + const errors: ValidationError[] = []; + + // Strip any scheme prefix (e.g. "pocketpay://pay?") and keep query part + const queryStart = input.indexOf('?'); + const queryString = queryStart >= 0 ? input.slice(queryStart + 1) : input; + const params = new URLSearchParams(queryString); + + const address = params.get('address'); + const amount = params.get('amount'); + const assetRaw = params.get('asset'); + const memo = params.get('memo') ?? undefined; + const metadataRaw = params.get('metadata'); + + // address validation + if (!address) { + errors.push({ + code: 'INVALID_PUBLIC_KEY', + field: 'address', + reason: 'missing', + message: 'Destination address is required', + }); + } else { + try { + validatePublicKey(address); + } catch (e) { + errors.push({ + code: 'INVALID_PUBLIC_KEY', + field: 'address', + reason: 'invalid_format', + message: (e as Error).message, + }); + } + } + + // amount validation + if (!amount) { + errors.push({ + code: 'INVALID_AMOUNT', + field: 'amount', + reason: 'missing', + message: 'Amount is required', + }); + } else { + try { + validateAmount(amount); + } catch (e) { + errors.push({ + code: 'INVALID_AMOUNT', + field: 'amount', + reason: 'invalid_format', + message: (e as Error).message, + }); + } + } + + // asset parsing & validation (optional) + let asset: StellarAssetSpec | undefined = undefined; + if (assetRaw) { + const parts = assetRaw.split(':'); + if (parts.length === 1) { + asset = { code: parts[0] } as StellarAssetSpec; + } else if (parts.length === 2) { + asset = { code: parts[0], issuer: parts[1] } as StellarAssetSpec; + } else { + errors.push({ + code: 'INVALID_ASSET', + field: 'asset', + reason: 'invalid_format', + message: 'Asset must be "CODE" or "CODE:ISSUER"', + }); + } + if (asset) { + try { + validateAssetSpec(asset); + } catch (e) { + errors.push({ + code: 'INVALID_ASSET', + field: 'asset', + reason: 'invalid', + message: (e as Error).message, + }); + } + } + } + + // memo validation (optional) + if (memo !== undefined) { + try { + validateMemoInput(memo); + } catch (e) { + errors.push({ + code: 'INVALID_MEMO', + field: 'memo', + reason: 'invalid_format', + message: (e as Error).message, + }); + } + } + + // metadata parsing (optional). format: "key1:value1,key2:value2" + let metadata: Record | undefined = undefined; + if (metadataRaw) { + metadata = {}; + try { + const decoded = decodeURIComponent(metadataRaw); + const pairs = decoded.split(','); + for (const pair of pairs) { + const [k, v] = pair.split(':'); + if (k && v) { + metadata[k] = v; + } else { + throw new Error('Invalid metadata pair'); + } + } + } catch (e) { + errors.push({ + code: 'INVALID_METADATA', + field: 'metadata', + reason: 'invalid_format', + message: (e as Error).message, + }); + } + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + + const payload: QRPayload = { + address: address!, + amount: amount!, + asset, + memo, + metadata, + }; + return { ok: true, payload }; +} diff --git a/src/types/payment-payload.ts b/src/types/payment-payload.ts new file mode 100644 index 0000000..97e7458 --- /dev/null +++ b/src/types/payment-payload.ts @@ -0,0 +1,23 @@ +// src/types/payment-payload.ts +/** + * Payment payload shape emitted by QR codes. + * Mirrors the URL query parameters parsed by QRParser. + */ +export interface PaymentPayload { + /** Destination Stellar account (G… address) */ + address: string; + /** Amount as a decimal string, positive, up to 7 decimal places */ + amount: string; + /** Optional asset specification; native XLM if omitted */ + asset?: { code: string; issuer?: string }; + /** Optional memo, max 28 UTF‑8 bytes */ + memo?: string; + /** Optional arbitrary key/value pairs */ + metadata?: Record; +} + +/** + * Intent flags for QR‑generated payments. + * Currently unused but reserved for future extensions. + */ +export type PaymentIntent = 'pay' | 'request'; diff --git a/tests/qrParser.test.ts b/tests/qrParser.test.ts new file mode 100644 index 0000000..e63c0b5 --- /dev/null +++ b/tests/qrParser.test.ts @@ -0,0 +1,19 @@ +import { parseQRPayload } from './src/payments/qrParser'; +import { PaymentParseError } from './src/errors/payment-errors'; + +describe('QR Parser', () => { + test('parses valid QR payload', () => { + const url = 'pocketpay://pay?address=GABCDEF1234567890&amount=10.5&asset=USD:ISSUER&memo=hello&metadata=key1%3Avalue1%2Ckey2%3Avalue2'; + const result = parseQRPayload(url); + expect(result.address).toBe('GABCDEF1234567890'); + expect(result.amount).toBe('10.5'); + expect(result.asset).toEqual({ code: 'USD', issuer: 'ISSUER' }); + expect(result.memo).toBe('hello'); + expect(result.metadata).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + test('throws on malformed URL', () => { + const badUrl = 'pocketpay://pay?address=invalid&amount=abc'; + expect(() => parseQRPayload(badUrl)).toThrow(PaymentParseError); + }); +});