import { Address, Satoshis } from '@xocash/primitives'; import { z } from 'zod/v4'; import { hasDuplicateParams } from '../utils/misc.js'; export type BIP21Raw = { address: Address; satoshis: Satoshis; message?: string; }; export class BIP21 { /** * Checks if the URL is a valid BIP21 URL. * * @param url - The URL to check * @returns True if the URL is a valid BIP21 URL, false otherwise */ static canHandle(url: string | URL): boolean { try { BIP21.from(url); return true; } catch (error) { return false; } } /** * Creates a Bip21 instance from a URL. * * @param url - The URL to create a Bip21 instance from * @returns A Bip21 instance * @throws {Error} If the URL is invalid, has duplicate parameters, or has a request URL (r) */ static from(url: string | URL): BIP21 { // If it's a string, convert it to a URL if (typeof url === 'string') { url = new URL(url); } // Check the url scheme is bitcoincash if (url.protocol !== 'bitcoincash:') { throw new Error('Invalid url scheme'); } // Check for duplicate params if (hasDuplicateParams(url)) { throw new Error('Duplicate params'); } // Make sure the address is valid const address = Address.fromCashAddr(url.pathname); // Get the params const params = BIP21.schema.parse(Object.fromEntries(url.searchParams)); // If we have a request URL (for JPP, BIP70, etc), we don't want to handle it here. if (params.r) { throw new Error( 'Request URL (r) is not supported in the BIP21 Payment URL protocol', ); } // Convert the amount to sats const requestedAmount = params.amount ? Satoshis.fromBCH(params.amount) : Satoshis.fromSats(0); return new BIP21({ address, satoshis: requestedAmount, message: params.message, }); } /** * Creates a Bip21 instance from a raw object. * * @param raw - The raw object to create a Bip21 instance from * @returns A Bip21 instance */ static fromRaw(raw: BIP21Raw): BIP21 { return new BIP21(raw); } constructor(public readonly params: BIP21Raw) {} /** * Converts the Bip21 instance to a raw object. * * @returns A raw object with the address, satoshis, and message */ toRaw() { return this.params; } /** * Converts the Bip21 instance to a URL. * * @returns A URL string with the bitcoincash: protocol */ toUrl(): string { const address = this.params.address; const url = new URL(address.toCashAddr('bitcoincash')); const paramMappings: [ keyof typeof BIP21.ENCODING_KEYS, keyof typeof this.params, (v: Satoshis | Address | string) => string, ][] = [ [ 'amount', 'satoshis', (v) => (v instanceof Satoshis ? v.toBCH().toString() : v.toString()), ], ['message', 'message', String], ]; for (const [key, paramKey, transform] of paramMappings) { const value = this.params[paramKey]; if (value != null) { url.searchParams.set(BIP21.ENCODING_KEYS[key], transform(value)); } } return url.toString(); } static ENCODING_KEYS = { amount: 'amount', message: 'message', }; static schema = z .object({ amount: z.coerce.number().optional(), message: z.string().optional(), t: z.string().optional(), }) .catchall(z.string()); // Allow any unknown keys with string values }