diff --git a/packages/primitives/src/address.ts b/packages/primitives/src/address.ts index f7d394d..ad2a431 100644 --- a/packages/primitives/src/address.ts +++ b/packages/primitives/src/address.ts @@ -8,18 +8,26 @@ import { decodeCashAddress, disassembleBytecodeBCH, encodeBase58AddressFormat, - encodeCashAddress, sha256, ripemd160, + encodeCashAddressNonStandard, + cashAddressTypeToTypeBits, } from '@bitauth/libauth'; import type { KnownAddressTypeContents } from '@bitauth/libauth'; export type AddressType = 'P2PK' | 'P2PKH' | 'P2SH20' | 'P2SH32'; +export type AddressNetworkPrefix = + | 'bitcoincash' + | 'bchtest' + | 'bchreg' + | (string & {}); + export const DEFAULT_ADDRESS_PREFIXES = [ '', 'bitcoincash', + 'bch', 'bchtest', 'bchreg', ]; @@ -441,7 +449,7 @@ export class Address { * @returns {string} The address in CashAddr format. */ public toCashAddr( - networkPrefix: 'bitcoincash' | 'bchtest' | 'bchreg' = 'bitcoincash', + networkPrefix: AddressNetworkPrefix = 'bitcoincash', ): string { // Define the encode type in advance. let encodeType: 'p2pkh' | 'p2sh'; @@ -461,10 +469,13 @@ export class Address { ); } + // Get the type bits for the encode type. + const typeBits = cashAddressTypeToTypeBits[encodeType]; + // NOTE: This function will throw if the payload is invalid. - return encodeCashAddress({ + return encodeCashAddressNonStandard({ prefix: networkPrefix, - type: encodeType, + typeBits, payload: this.addressContents.payload, }).address; } @@ -479,7 +490,7 @@ export class Address { * @returns {string} The address in Token Address format. */ public toTokenAddress( - networkPrefix: 'bitcoincash' | 'bchtest' | 'bchreg' = 'bitcoincash', + networkPrefix: AddressNetworkPrefix = 'bitcoincash', ): string { // Define the encode type in advance. let encodeType: 'p2pkhWithTokens' | 'p2shWithTokens'; @@ -499,10 +510,13 @@ export class Address { ); } + // Get the type bits for the encode type. + const typeBits = cashAddressTypeToTypeBits[encodeType]; + // NOTE: This function will throw if the payload is invalid. - return encodeCashAddress({ + return encodeCashAddressNonStandard({ prefix: networkPrefix, - type: encodeType, + typeBits, payload: this.addressContents.payload, }).address; } diff --git a/packages/stack/src/protocols/bip21.ts b/packages/stack/src/protocols/bip21.ts new file mode 100644 index 0000000..8ca6aea --- /dev/null +++ b/packages/stack/src/protocols/bip21.ts @@ -0,0 +1,142 @@ +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 +} diff --git a/packages/stack/src/protocols/index.ts b/packages/stack/src/protocols/index.ts index 7311eb3..ae82101 100644 --- a/packages/stack/src/protocols/index.ts +++ b/packages/stack/src/protocols/index.ts @@ -1 +1,3 @@ export * from './bch-mnemonic-url.js'; +export * from './paypro.js'; +export * from './bip21.js'; diff --git a/packages/stack/src/protocols/paypro.ts b/packages/stack/src/protocols/paypro.ts new file mode 100644 index 0000000..8acdd60 --- /dev/null +++ b/packages/stack/src/protocols/paypro.ts @@ -0,0 +1,226 @@ +import { compileOutputTemplate, getDustThreshold } from '@bitauth/libauth'; +import { Address, Bytes, Satoshis } from '@xocash/primitives'; +import { z } from 'zod/v4'; + +import { hasDuplicateParams } from '../utils/misc.js'; + +export type PayProRaw = { + address: Address; + satoshis: Satoshis; + tokenCategoryId?: string; + fungibleTokenAmount?: number; + nftCommitment?: string; + message?: string; + expiry?: number; +}; + +export class PayPro { + static canHandle(url: string | URL): boolean { + try { + PayPro.from(url); + return true; + } catch (error) { + return false; + } + } + + static from(url: string | URL): PayPro { + // If it's a string, convert it to a URL + if (typeof url === 'string') { + url = new URL(url); + } + + // Check the url scheme is bch + if (url.protocol !== 'bch:') { + 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, false, ['bch']); + + // Extract the params + const params = PayPro.schema.parse(Object.fromEntries(url.searchParams)); + + // Pull out the parameters now, so we can validate them instead of grabbing by key each time + const { + amount, + satoshis, + tokenCategoryId, + fungibleTokenAmount, + nftCommitment, + message, + expiry, + } = params; + + // If both amount and satoshis are present, make sure they are the same. + if (amount && satoshis && amount !== satoshis) { + throw new Error('Amount and satoshis cannot be present in the same url'); + } + + // Use either the amount or satoshis param, converting to sats for consistency. + let requestedAmount = Number(satoshis || amount); + + // If no requested amount is present, set it to the dust limit. + if (!requestedAmount) { + const outputTemplate = compileOutputTemplate({ + outputTemplate: { + lockingBytecode: address.toLockscriptBytes(), + token: tokenCategoryId + ? { + category: Bytes.fromHex(tokenCategoryId || '').toUint8Array(), + amount: fungibleTokenAmount ? BigInt(fungibleTokenAmount) : 0n, + nft: nftCommitment + ? { + commitment: Bytes.fromHex(nftCommitment).toUint8Array(), + capability: 'none', + } + : undefined, + } + : undefined, + valueSatoshis: 0n, + }, + index: 0, + }); + + if ('errors' in outputTemplate) { + throw new Error('Invalid output template'); + } + + requestedAmount = Satoshis.fromRaw( + getDustThreshold(outputTemplate), + ).toSats(); + } + + // Category ID with no token amount + if (tokenCategoryId && !(fungibleTokenAmount || nftCommitment)) { + throw new Error( + 'Token category ID (c) must be present with either fungible token amount (f) or non-fungible token amount (n)', + ); + } + + // Fungible token amount with no category ID + if (fungibleTokenAmount && !tokenCategoryId) { + throw new Error( + 'Fungible token amount (f) must be present with token category ID (c)', + ); + } + + // Non-fungible token amount with no category ID + if (nftCommitment && !tokenCategoryId) { + throw new Error( + 'Non-fungible token amount (n) must be present with token category ID (c)', + ); + } + + // Check address version to make sure it supports tokens + if (tokenCategoryId && !Address.isValidCashAddr(address.toCashAddr())) { + throw new Error('Invalid token address'); + } + + return new PayPro({ + address, + satoshis: Satoshis.fromSats(requestedAmount), + tokenCategoryId, + fungibleTokenAmount, + nftCommitment, + message, + expiry, + }); + } + + static fromRaw(raw: PayProRaw): PayPro { + return new PayPro(raw); + } + + constructor(public readonly params: PayProRaw) {} + + toRaw() { + return this.params; + } + + toUrl(): string { + const address = this.params.address; + + const url = new URL(address.toTokenAddress('bch')); + + const paramMappings: [ + keyof typeof PayPro.ENCODING_KEYS, + keyof typeof this.params, + (v: Satoshis | Address | string | number) => string, + ][] = [ + [ + 'satoshis', + 'satoshis', + (v) => (v instanceof Satoshis ? v.toSats().toString() : v.toString()), + ], + ['tokenCategoryId', 'tokenCategoryId', String], + ['fungibleTokenAmount', 'fungibleTokenAmount', String], + ['nftCommitment', 'nftCommitment', String], + ['message', 'message', String], + ['expiry', 'expiry', String], + ]; + + for (const [key, paramKey, transform] of paramMappings) { + const value = this.params[paramKey]; + if (value != null) { + url.searchParams.set(PayPro.ENCODING_KEYS[key], transform(value)); + } + } + + return url.toString(); + } + + static ENCODING_KEYS = { + amount: 'amount', + satoshis: 's', + tokenCategoryId: 'c', + fungibleTokenAmount: 'f', + nftCommitment: 'n', + message: 'm', + expiry: 'e', + } as const; + + static schema = z + .object({ + amount: z.coerce.number().optional(), + s: z.coerce.number().optional(), + // Tokens + c: z.string().optional(), + f: z.coerce.number().optional(), + n: z.string().optional(), + // Message + message: z.string().optional(), + m: z.string().optional(), + // Expiry timestamp + e: z.coerce.number().optional(), + }) + // Allow any unspecified keys to be parsed as a string. + .catchall(z.string()) + // Validate that any unknown keys are optional (prefixed with x-). + .check(({ value }) => { + const keys = Object.values(PayPro.ENCODING_KEYS); + + for (const key of Object.keys(value)) { + if (!keys.some((k) => k === key) && !key.startsWith('x-')) { + throw new Error(`Unexpected key: ${key}`); + } + } + }) + // Transform the keys to the standard keys. + .transform((data) => { + return { + amount: data.amount ? Satoshis.fromBCH(data.amount).toSats() : 0, + satoshis: data.s, + message: data.m || data.message, + tokenCategoryId: data.c, + fungibleTokenAmount: data.f, + nftCommitment: data.n, + expiry: data.e, + }; + }); +} diff --git a/packages/stack/src/utils/misc.ts b/packages/stack/src/utils/misc.ts index e706fb8..7b11795 100644 --- a/packages/stack/src/utils/misc.ts +++ b/packages/stack/src/utils/misc.ts @@ -15,3 +15,27 @@ export function getOutpointId(outpoint: { export function generateActivityId() { return Date.now().toString(); } + +/** + * Checks if a URL has duplicate parameters. + * + * @param url - The URL to check + * @returns True if the URL has duplicate parameters, false otherwise + */ +export function hasDuplicateParams(url: string | URL): boolean { + if (typeof url === 'string') { + url = new URL(url); + } + + const params = url.searchParams; + const seen = new Set(); + + for (const [key] of params.entries()) { + if (seen.has(key)) { + return true; + } + seen.add(key); + } + + return false; +} diff --git a/packages/stack/tests/protocols/paypro.test.ts b/packages/stack/tests/protocols/paypro.test.ts new file mode 100644 index 0000000..4a809f3 --- /dev/null +++ b/packages/stack/tests/protocols/paypro.test.ts @@ -0,0 +1,169 @@ +import { test, expect, describe } from 'vitest'; + +import { PayPro } from '../../src/protocols/paypro.js'; +import { Address, Satoshis } from '@xocash/primitives'; + +const BCH_ADDRESS = 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted'; +const CASH_ADDRESS = 'bitcoincash:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyquzsnp0a'; +const TEST_TOKEN_CATEGORY_ID = + 'f6677f3d3805d70949b375d36e094ff0ec9ece2a2cb1fde6d8b0e90b368f1f63'; + +const BASE_RAW = { + address: Address.fromCashAddr(CASH_ADDRESS), + satoshis: Satoshis.fromSats(546), +}; + +const SUCCESS_CASES = [ + // Basic URI + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted', + raw: BASE_RAW, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=546`, + }, + // Basic URI with amount + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted?amount=0.00000546', + raw: BASE_RAW, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=546`, + }, + // Basic URI with Sats and Amount + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted?s=546&amount=0.00000546', + raw: BASE_RAW, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=546`, + }, + // URI with Tokens and Fungible Tokens + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted?c=f6677f3d3805d70949b375d36e094ff0ec9ece2a2cb1fde6d8b0e90b368f1f63&f=10000', + raw: { + ...BASE_RAW, + satoshis: Satoshis.fromSats(657), + tokenCategoryId: TEST_TOKEN_CATEGORY_ID, + fungibleTokenAmount: 10000, + }, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=657&c=f6677f3d3805d70949b375d36e094ff0ec9ece2a2cb1fde6d8b0e90b368f1f63&f=10000`, + }, + // URI with Tokens and Non-Fungible Tokens + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted?c=f6677f3d3805d70949b375d36e094ff0ec9ece2a2cb1fde6d8b0e90b368f1f63&n=01', + raw: { + ...BASE_RAW, + satoshis: Satoshis.fromSats(654), + tokenCategoryId: TEST_TOKEN_CATEGORY_ID, + nftCommitment: '01', + }, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=654&c=f6677f3d3805d70949b375d36e094ff0ec9ece2a2cb1fde6d8b0e90b368f1f63&n=01`, + }, + // URI with Sats, Token Category and Fungible Tokens + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted?s=546&c=f6677f3d3805d70949b375d36e094ff0ec9ece2a2cb1fde6d8b0e90b368f1f63&f=10000', + raw: { + ...BASE_RAW, + tokenCategoryId: TEST_TOKEN_CATEGORY_ID, + fungibleTokenAmount: 10000, + }, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=546&c=f6677f3d3805d70949b375d36e094ff0ec9ece2a2cb1fde6d8b0e90b368f1f63&f=10000`, + }, + // URI with Sats, Token Category and Non-Fungible Tokens + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted?s=546&c=f6677f3d3805d70949b375d36e094ff0ec9ece2a2cb1fde6d8b0e90b368f1f63&n=01', + raw: { + ...BASE_RAW, + tokenCategoryId: TEST_TOKEN_CATEGORY_ID, + nftCommitment: '01', + }, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=546&c=f6677f3d3805d70949b375d36e094ff0ec9ece2a2cb1fde6d8b0e90b368f1f63&n=01`, + }, + // URI with Expiry + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted?e=1715769600', + raw: { + ...BASE_RAW, + expiry: 1715769600, + }, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=546&e=1715769600`, + }, + // URI with Message + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted?m=Hello', + raw: { + ...BASE_RAW, + message: 'Hello', + }, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=546&m=Hello`, + }, + // URI with Message and Expiry + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted?m=Hello&e=1715769600', + raw: { + ...BASE_RAW, + message: 'Hello', + expiry: 1715769600, + }, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=546&m=Hello&e=1715769600`, + }, + // URI with optional params + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted?x-custom=value', + raw: BASE_RAW, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=546`, + }, + // URI with multiple optional params + { + url: 'bch:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gym58kzted?x-custom=value&x-custom2=value2', + raw: BASE_RAW, + result: `bch:zrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyu75gvdx7?s=546`, + }, +]; + +const FAILURE_CASES = [ + // Duplicate amount + `${BCH_ADDRESS}?amount=1&amount=2`, + // Duplicate sats + `${BCH_ADDRESS}?s=1&s=2`, + // Duplicate token category + `${BCH_ADDRESS}?c=1&c=2`, + // Category without tokens + `${BCH_ADDRESS}?c=1`, + // Fungible tokens without category + `${BCH_ADDRESS}?f=1`, + // Non-fungible tokens without category + `${BCH_ADDRESS}?n=1`, + // Amount and sats are different + `${BCH_ADDRESS}?s=1&amount=2`, + // requestUrl is populated + `${BCH_ADDRESS}?r=https://example.com`, + // Invalid key + `${BCH_ADDRESS}?invalid=1`, + // Invalid amount + `${BCH_ADDRESS}?amount=invalid`, + // Invalid sats + `${BCH_ADDRESS}?s=invalid`, + // Invalid expiry + `${BCH_ADDRESS}?e=invalid`, + // Not an address + `bch:invalid`, + // Bip21 + `bitcoincash:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyquzsnp0a`, + // Bip21 with amount + `bitcoincash:qrlt8kd0gy0z4kcpvr9lmqesjupp0jg4gyquzsnp0a?amount=0.00000546`, +]; + +describe('PayProPaymentUrl', () => { + test.each(SUCCESS_CASES)('should be able to parse a valid url', (url) => { + const paymentUrl = PayPro.from(url.url); + expect(paymentUrl.toRaw()).toEqual(url.raw); + if (url.result) { + expect(paymentUrl.toUrl()).toEqual(url.result); + } else { + expect(paymentUrl.toUrl()).toEqual(url.url); + } + }); + + test.each(FAILURE_CASES)( + 'should throw an error for an invalid url', + (url) => { + expect(() => PayPro.from(url)).toThrow(); + }, + ); +});