diff --git a/src/wallet/payment-codes.ts b/src/wallet/payment-codes.ts index c5371ea3..1547602c 100644 --- a/src/wallet/payment-codes.ts +++ b/src/wallet/payment-codes.ts @@ -5,6 +5,7 @@ import { getAddress } from '../address/address.js'; import { bs58check } from './bip32/crypto.js'; import { HDNodeBIP32Adapter } from './bip32/types.js'; import type { TinySecp256k1Interface, BIP32API, BIP32Interface } from './bip32/types.js'; +import { secp256k1 } from '@noble/curves/secp256k1'; export const PC_VERSION = 0x47; @@ -277,3 +278,56 @@ export class PaymentCodePrivate extends PaymentCodePublic { return child.privateKey!; } } + +/** + * Validates a payment code base58 encoded string. + * + * @param {string} paymentCode - The payment code to validate. + * @throws {Error} If the payment code is invalid. + */ +export async function validatePaymentCode(paymentCode: string): Promise { + try { + const decoded = bs58check.decode(paymentCode); + + if (decoded.length !== 82) { + return false; + } + + if (decoded[0] !== 0x47) { + return false; + } + + const payload = decoded.slice(0, -4); + const checksum = decoded.slice(-4); + const calculatedChecksum = sha256(sha256(payload)).slice(0, 4); + if (!checksum.every((b, i) => b === calculatedChecksum[i])) { + return false; + } + + const paymentCodeBytes = decoded.slice(1, -4); + + if (paymentCodeBytes[0] !== 0x01 && paymentCodeBytes[0] !== 0x02) { + return false; + } + if (paymentCodeBytes[2] !== 0x02 && paymentCodeBytes[2] !== 0x03) { + return false; + } + + const xCoordinate = paymentCodeBytes.slice(3, 35); + try { + secp256k1.ProjectivePoint.fromHex(xCoordinate).assertValidity(); + } catch (error) { + console.log('error validating paymentcode x-coordinate: ', error); + return false; + } + + if (!paymentCodeBytes.slice(67).every((byte) => byte === 0)) { + return false; + } + + return true; + } catch (error) { + console.log('error validating paymentcode: ', error); + return false; + } +} diff --git a/src/wallet/qi-hdwallet.ts b/src/wallet/qi-hdwallet.ts index f69995ac..fb7d641b 100644 --- a/src/wallet/qi-hdwallet.ts +++ b/src/wallet/qi-hdwallet.ts @@ -18,7 +18,7 @@ import { Outpoint } from '../transaction/utxo.js'; import { getZoneForAddress } from '../utils/index.js'; import { AllowedCoinType, Zone } from '../constants/index.js'; import { Mnemonic } from './mnemonic.js'; -import { PaymentCodePrivate, PaymentCodePublic, PC_VERSION } from './payment-codes.js'; +import { PaymentCodePrivate, PaymentCodePublic, PC_VERSION, validatePaymentCode } from './payment-codes.js'; import { BIP32Factory } from './bip32/bip32.js'; import { bs58check } from './bip32/crypto.js'; import { type BIP32API, HDNodeBIP32Adapter } from './bip32/types.js'; @@ -59,6 +59,8 @@ interface SerializedQiHDWallet extends SerializedHDWallet { changeAddresses: NeuteredAddressInfo[]; gapAddresses: NeuteredAddressInfo[]; gapChangeAddresses: NeuteredAddressInfo[]; + receiverPaymentCodeInfo: { [key: string]: paymentCodeInfo[] }; + senderPaymentCodeInfo: { [key: string]: paymentCodeInfo[] }; } /** @@ -158,6 +160,15 @@ export class QiHDWallet extends AbstractHDWallet { super(guard, root, provider); } + // getters for the payment code info maps + public get receiverPaymentCodeInfo(): { [key: string]: paymentCodeInfo[] } { + return Object.fromEntries(this._receiverPaymentCodeInfo); + } + + public get senderPaymentCodeInfo(): { [key: string]: paymentCodeInfo[] } { + return Object.fromEntries(this._senderPaymentCodeInfo); + } + /** * Promise that resolves to the next change address for the specified account and zone. * @@ -546,6 +557,8 @@ export class QiHDWallet extends AbstractHDWallet { changeAddresses: Array.from(this._changeAddresses.values()), gapAddresses: this._gapAddresses, gapChangeAddresses: this._gapChangeAddresses, + receiverPaymentCodeInfo: Object.fromEntries(this._receiverPaymentCodeInfo), + senderPaymentCodeInfo: Object.fromEntries(this._senderPaymentCodeInfo), ...hdwalletSerialized, }; } @@ -591,9 +604,62 @@ export class QiHDWallet extends AbstractHDWallet { // validate the outpoints and import them wallet.validateOutpointInfo(serialized.outpoints); wallet._outpoints.push(...serialized.outpoints); + + // validate and import the payment code info + wallet.validateAndImportPaymentCodeInfo(serialized.receiverPaymentCodeInfo, 'receiver'); + wallet.validateAndImportPaymentCodeInfo(serialized.senderPaymentCodeInfo, 'sender'); + return wallet; } + /** + * Validates and imports a map of payment code info. + * + * @param {Map} paymentCodeInfoMap - The map of payment code info to validate and import. + * @param {'receiver' | 'sender'} target - The target map to update ('receiver' or 'sender'). + * @throws {Error} If any of the payment code info is invalid. + */ + private validateAndImportPaymentCodeInfo( + paymentCodeInfoMap: { [key: string]: paymentCodeInfo[] }, + target: 'receiver' | 'sender', + ): void { + const targetMap = target === 'receiver' ? this._receiverPaymentCodeInfo : this._senderPaymentCodeInfo; + + for (const [paymentCode, paymentCodeInfoArray] of Object.entries(paymentCodeInfoMap)) { + if (!validatePaymentCode(paymentCode)) { + throw new Error(`Invalid payment code: ${paymentCode}`); + } + for (const pcInfo of paymentCodeInfoArray) { + this.validatePaymentCodeInfo(pcInfo); + } + targetMap.set(paymentCode, paymentCodeInfoArray); + } + } + + /** + * Validates a payment code info object. + * + * @param {paymentCodeInfo} pcInfo - The payment code info to validate. + * @throws {Error} If the payment code info is invalid. + */ + private validatePaymentCodeInfo(pcInfo: paymentCodeInfo): void { + if (!/^(0x)?[0-9a-fA-F]{40}$/.test(pcInfo.address)) { + throw new Error('Invalid payment code info: address must be a 40-character hexadecimal string'); + } + if (!Number.isInteger(pcInfo.index) || pcInfo.index < 0) { + throw new Error('Invalid payment code info: index must be a non-negative integer'); + } + if (typeof pcInfo.isUsed !== 'boolean') { + throw new Error('Invalid payment code info: isUsed must be a boolean'); + } + if (!Object.values(Zone).includes(pcInfo.zone)) { + throw new Error(`Invalid payment code info: zone '${pcInfo.zone}' is not a valid Zone`); + } + if (!Number.isInteger(pcInfo.account) || pcInfo.account < 0) { + throw new Error('Invalid payment code info: account must be a non-negative integer'); + } + } + /** * Validates an array of OutpointInfo objects. This method checks the validity of each OutpointInfo object by * performing the following validations: