Resolve "Protocol Parsing: BIP21/PayPro, bch-mnemonic, etc"

This commit is contained in:
2025-07-29 03:36:12 +00:00
committed by Jim Hamill
parent bb326d5b3b
commit 46baf8702d
6 changed files with 584 additions and 7 deletions
+142
View File
@@ -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
}
+2
View File
@@ -1 +1,3 @@
export * from './bch-mnemonic-url.js';
export * from './paypro.js';
export * from './bip21.js';
+226
View File
@@ -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,
};
});
}
+24
View File
@@ -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<string>();
for (const [key] of params.entries()) {
if (seen.has(key)) {
return true;
}
seen.add(key);
}
return false;
}