Resolve "Protocol Parsing: BIP21/PayPro, bch-mnemonic, etc"
This commit is contained in:
@@ -8,18 +8,26 @@ import {
|
|||||||
decodeCashAddress,
|
decodeCashAddress,
|
||||||
disassembleBytecodeBCH,
|
disassembleBytecodeBCH,
|
||||||
encodeBase58AddressFormat,
|
encodeBase58AddressFormat,
|
||||||
encodeCashAddress,
|
|
||||||
sha256,
|
sha256,
|
||||||
ripemd160,
|
ripemd160,
|
||||||
|
encodeCashAddressNonStandard,
|
||||||
|
cashAddressTypeToTypeBits,
|
||||||
} from '@bitauth/libauth';
|
} from '@bitauth/libauth';
|
||||||
|
|
||||||
import type { KnownAddressTypeContents } from '@bitauth/libauth';
|
import type { KnownAddressTypeContents } from '@bitauth/libauth';
|
||||||
|
|
||||||
export type AddressType = 'P2PK' | 'P2PKH' | 'P2SH20' | 'P2SH32';
|
export type AddressType = 'P2PK' | 'P2PKH' | 'P2SH20' | 'P2SH32';
|
||||||
|
|
||||||
|
export type AddressNetworkPrefix =
|
||||||
|
| 'bitcoincash'
|
||||||
|
| 'bchtest'
|
||||||
|
| 'bchreg'
|
||||||
|
| (string & {});
|
||||||
|
|
||||||
export const DEFAULT_ADDRESS_PREFIXES = [
|
export const DEFAULT_ADDRESS_PREFIXES = [
|
||||||
'',
|
'',
|
||||||
'bitcoincash',
|
'bitcoincash',
|
||||||
|
'bch',
|
||||||
'bchtest',
|
'bchtest',
|
||||||
'bchreg',
|
'bchreg',
|
||||||
];
|
];
|
||||||
@@ -441,7 +449,7 @@ export class Address {
|
|||||||
* @returns {string} The address in CashAddr format.
|
* @returns {string} The address in CashAddr format.
|
||||||
*/
|
*/
|
||||||
public toCashAddr(
|
public toCashAddr(
|
||||||
networkPrefix: 'bitcoincash' | 'bchtest' | 'bchreg' = 'bitcoincash',
|
networkPrefix: AddressNetworkPrefix = 'bitcoincash',
|
||||||
): string {
|
): string {
|
||||||
// Define the encode type in advance.
|
// Define the encode type in advance.
|
||||||
let encodeType: 'p2pkh' | 'p2sh';
|
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.
|
// NOTE: This function will throw if the payload is invalid.
|
||||||
return encodeCashAddress({
|
return encodeCashAddressNonStandard({
|
||||||
prefix: networkPrefix,
|
prefix: networkPrefix,
|
||||||
type: encodeType,
|
typeBits,
|
||||||
payload: this.addressContents.payload,
|
payload: this.addressContents.payload,
|
||||||
}).address;
|
}).address;
|
||||||
}
|
}
|
||||||
@@ -479,7 +490,7 @@ export class Address {
|
|||||||
* @returns {string} The address in Token Address format.
|
* @returns {string} The address in Token Address format.
|
||||||
*/
|
*/
|
||||||
public toTokenAddress(
|
public toTokenAddress(
|
||||||
networkPrefix: 'bitcoincash' | 'bchtest' | 'bchreg' = 'bitcoincash',
|
networkPrefix: AddressNetworkPrefix = 'bitcoincash',
|
||||||
): string {
|
): string {
|
||||||
// Define the encode type in advance.
|
// Define the encode type in advance.
|
||||||
let encodeType: 'p2pkhWithTokens' | 'p2shWithTokens';
|
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.
|
// NOTE: This function will throw if the payload is invalid.
|
||||||
return encodeCashAddress({
|
return encodeCashAddressNonStandard({
|
||||||
prefix: networkPrefix,
|
prefix: networkPrefix,
|
||||||
type: encodeType,
|
typeBits,
|
||||||
payload: this.addressContents.payload,
|
payload: this.addressContents.payload,
|
||||||
}).address;
|
}).address;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
export * from './bch-mnemonic-url.js';
|
export * from './bch-mnemonic-url.js';
|
||||||
|
export * from './paypro.js';
|
||||||
|
export * from './bip21.js';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,3 +15,27 @@ export function getOutpointId(outpoint: {
|
|||||||
export function generateActivityId() {
|
export function generateActivityId() {
|
||||||
return Date.now().toString();
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user