diff --git a/examples/wallets/qi-send.js b/examples/wallets/qi-send.js index d657fdc4..3caee7e3 100644 --- a/examples/wallets/qi-send.js +++ b/examples/wallets/qi-send.js @@ -1,49 +1,164 @@ const quais = require('../../lib/commonjs/quais'); require('dotenv').config(); -// Descrepancy between our serialized data and go quais in that ours in inlcude extra data at the end -> 201406c186bf3b66571cfdd8c7d9336df2298e4d4a9a2af7fcca36fbdfb0b43459a41c45b6c8885dc1f828d44fd005572cbac4cd72dc598790429255d19ec32f7750e - async function main() { // Create provider console.log('RPC URL: ', process.env.RPC_URL); const provider = new quais.JsonRpcProvider(process.env.RPC_URL); - // Create wallet and connect to provider + // Create Alice's wallet and connect to provider const mnemonic = quais.Mnemonic.fromPhrase(process.env.MNEMONIC); const aliceQiWallet = quais.QiHDWallet.fromMnemonic(mnemonic); aliceQiWallet.connect(provider); - // Initialize Qi wallet + console.log(`Generating Bob's wallet and payment code...`); + const bobMnemonic = quais.Mnemonic.fromPhrase( + 'innocent perfect bus miss prevent night oval position aspect nut angle usage expose grace juice', + ); + + // Create Bob's wallet and connect to provider + const bobQiWallet = quais.QiHDWallet.fromMnemonic(bobMnemonic); + bobQiWallet.connect(provider); + const bobPaymentCode = await bobQiWallet.getPaymentCode(0); + + // Alice opens a channel to send Qi to Bob + aliceQiWallet.openChannel(bobPaymentCode, 'sender'); + + // Initialize Alice's wallet console.log('Initializing Alice wallet...'); await aliceQiWallet.scan(quais.Zone.Cyprus1); console.log('Alice wallet scan complete'); - console.log('Serializing Alice wallet...'); - const serializedWallet = aliceQiWallet.serialize(); + console.log('Alice Wallet Summary:'); + printWalletInfo(aliceQiWallet); + + // Bob open channel with Alice + const alicePaymentCode = await aliceQiWallet.getPaymentCode(0); + bobQiWallet.openChannel(alicePaymentCode, 'receiver'); + + // Bob initializes his wallet + console.log('Initializing Bob wallet...'); + await bobQiWallet.scan(quais.Zone.Cyprus1); + console.log('Bob wallet scan complete'); + + console.log('Bob Wallet Summary:'); + printWalletInfo(bobQiWallet); + + console.log('Alice sends 1 Qi to Bob...'); + + // Alice sends 1 Qi to Bob (value in Qits - 1 Qi = 1000 Qits) + const tx = await aliceQiWallet.sendTransaction(bobPaymentCode, 1000, quais.Zone.Cyprus1, quais.Zone.Cyprus1); + console.log(`Tx contains ${tx.txInputs?.length} inputs`); + console.log(`Tx contains ${tx.txOutputs?.length} outputs`); + console.log('Tx: ', tx); + // wait for the transaction to be confirmed + console.log('Waiting for transaction to be confirmed...'); + // const receipt = await tx.wait(); //! throws 'wait() is not a function' + // console.log('Transaction confirmed: ', receipt); + // sleep for 5 seconds + await new Promise((resolve) => setTimeout(resolve, 5000)); + // const receipt = await provider.getTransactionReceipt(tx.hash); //! throws 'invalid shard' + // console.log('Transaction confirmed: ', receipt); + + // Bob syncs his wallet + console.log('Syncing Bob wallet...'); + await bobQiWallet.sync(quais.Zone.Cyprus1); + console.log('Bob wallet sync complete'); + + console.log('\n******** Bob Wallet Summary (after receiving Qi from Alice):********'); + printWalletInfo(bobQiWallet); + + console.log('Syncing Alice wallet...'); + await aliceQiWallet.sync(quais.Zone.Cyprus1); + console.log('Alice wallet sync complete'); + + console.log('\n******** Alice wallet summary (after sending Qi to Bob):********'); + printWalletInfo(aliceQiWallet); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); + +function printWalletInfo(wallet) { + console.log('Wallet Balance: ', wallet.getBalanceForZone(quais.Zone.Cyprus1)); + const serializedWallet = wallet.serialize(); const summary = { - 'Total Addresses': serializedWallet.addresses.length, + Addresses: serializedWallet.addresses.length, 'Change Addresses': serializedWallet.changeAddresses.length, 'Gap Addresses': serializedWallet.gapAddresses.length, 'Gap Change Addresses': serializedWallet.gapChangeAddresses.length, - Outpoints: serializedWallet.outpoints.length, + 'Used Gap Addresses': serializedWallet.usedGapAddresses.length, + 'Used Gap Change Addresses': serializedWallet.usedGapChangeAddresses.length, + 'Receiver PaymentCode addresses': Object.keys(wallet.receiverPaymentCodeInfo).length, + 'Sender PaymentCode addresses': Object.keys(wallet.senderPaymentCodeInfo).length, + 'Available Outpoints': serializedWallet.outpoints.length, + 'Pending Outpoints': serializedWallet.pendingOutpoints.length, 'Coin Type': serializedWallet.coinType, Version: serializedWallet.version, }; - console.log('Alice Wallet Summary:'); - console.table(summary); + console.log(summary); - const addressTable = serializedWallet.addresses.map((addr) => ({ + console.log('\nWallet Addresses:'); + const addressesTable = serializedWallet.addresses.map((addr) => ({ PubKey: addr.pubKey, Address: addr.address, Index: addr.index, Change: addr.change ? 'Yes' : 'No', Zone: addr.zone, })); + console.table(addressesTable); + + console.log('\nWallet Change Addresses:'); + const changeAddressesTable = serializedWallet.changeAddresses.map((addr) => ({ + PubKey: addr.pubKey, + Address: addr.address, + Index: addr.index, + Zone: addr.zone, + })); + console.table(changeAddressesTable); + + console.log('\nWallet Gap Addresses:'); + const gapAddressesTable = serializedWallet.gapAddresses.map((addr) => ({ + PubKey: addr.pubKey, + Address: addr.address, + Index: addr.index, + Zone: addr.zone, + })); + console.table(gapAddressesTable); - console.log('\nAlice Wallet Addresses (first 10):'); - console.table(addressTable.slice(0, 10)); + console.log('\nWallet Gap Change Addresses:'); + const gapChangeAddressesTable = serializedWallet.gapChangeAddresses.map((addr) => ({ + PubKey: addr.pubKey, + Address: addr.address, + Index: addr.index, + Zone: addr.zone, + })); + console.table(gapChangeAddressesTable); + console.log('\nWallet Used Gap Addresses:'); + const usedGapAddressesTable = serializedWallet.usedGapAddresses.map((addr) => ({ + PubKey: addr.pubKey, + Address: addr.address, + Index: addr.index, + Zone: addr.zone, + })); + console.table(usedGapAddressesTable); + + console.log('\nWallet Used Gap Change Addresses:'); + const usedGapChangeAddressesTable = serializedWallet.usedGapChangeAddresses.map((addr) => ({ + PubKey: addr.pubKey, + Address: addr.address, + Index: addr.index, + Zone: addr.zone, + })); + console.table(usedGapChangeAddressesTable); + + console.log('\nWallet Outpoints:'); const outpointsInfoTable = serializedWallet.outpoints.map((outpoint) => ({ Address: outpoint.address, Denomination: outpoint.outpoint.denomination, @@ -52,29 +167,44 @@ async function main() { Zone: outpoint.zone, Account: outpoint.account, })); + console.table(outpointsInfoTable); - console.log('\nAlice Outpoints Info (first 10):'); - console.table(outpointsInfoTable.slice(0, 10)); - - console.log(`Generating Bob's wallet and payment code...`); - const bobMnemonic = quais.Mnemonic.fromPhrase( - 'innocent perfect bus miss prevent night oval position aspect nut angle usage expose grace juice', - ); - const bobQiWallet = quais.QiHDWallet.fromMnemonic(bobMnemonic); - const bobPaymentCode = await bobQiWallet.getPaymentCode(0); - console.log('Bob Payment code: ', bobPaymentCode); + console.log('\nWallet Pending Outpoints:'); + const pendingOutpointsInfoTable = serializedWallet.pendingOutpoints.map((outpoint) => ({ + Address: outpoint.address, + Denomination: outpoint.outpoint.denomination, + Index: outpoint.outpoint.index, + TxHash: outpoint.outpoint.txhash, + Zone: outpoint.zone, + Account: outpoint.account, + })); + console.table(pendingOutpointsInfoTable); - // Alice opens a channel to send Qi to Bob - aliceQiWallet.openChannel(bobPaymentCode, 'sender'); + // Print receiver payment code info + console.log('\nWallet Receiver Payment Code Info:'); + const receiverPaymentCodeInfo = wallet.receiverPaymentCodeInfo; + for (const [paymentCode, addressInfoArray] of Object.entries(receiverPaymentCodeInfo)) { + console.log(`Payment Code: ${paymentCode}`); + const receiverTable = addressInfoArray.map((info) => ({ + Address: info.address, + PubKey: info.pubKey, + Index: info.index, + Zone: info.zone, + })); + console.table(receiverTable); + } - // Alice sends 1000 Qi to Bob - const tx = await aliceQiWallet.sendTransaction(bobPaymentCode, 750000, quais.Zone.Cyprus1, quais.Zone.Cyprus1); - console.log('Transaction sent: ', tx); + // Print sender payment code info + console.log('\nWallet Sender Payment Code Info:'); + const senderPaymentCodeInfo = wallet.senderPaymentCodeInfo; + for (const [paymentCode, addressInfoArray] of Object.entries(senderPaymentCodeInfo)) { + console.log(`Payment Code: ${paymentCode}`); + const senderTable = addressInfoArray.map((info) => ({ + Address: info.address, + PubKey: info.pubKey, + Index: info.index, + Zone: info.zone, + })); + console.table(senderTable); + } } - -main() - .then(() => process.exit(0)) - .catch((error) => { - console.error(error); - process.exit(1); - }); diff --git a/src/encoding/protoc/proto_block.ts b/src/encoding/protoc/proto_block.ts index 5d9dfbc8..ee599d3b 100644 --- a/src/encoding/protoc/proto_block.ts +++ b/src/encoding/protoc/proto_block.ts @@ -3,7 +3,7 @@ * compiler version: 4.24.3 * source: proto_block.proto * git: https://github.com/thesayyn/protoc-gen-ts */ -import * as dependency_1 from "./proto_common.js"; +import * as dependency_1 from "./proto_common"; import * as pb_1 from "google-protobuf"; export namespace block { export class ProtoHeader extends pb_1.Message { diff --git a/src/transaction/abstract-coinselector.ts b/src/transaction/abstract-coinselector.ts index 41ed4baa..9c94bcf1 100644 --- a/src/transaction/abstract-coinselector.ts +++ b/src/transaction/abstract-coinselector.ts @@ -68,7 +68,7 @@ export abstract class AbstractCoinSelector { * @param {SpendTarget} target - The target address and value to spend. * @returns {SelectedCoinsResult} The selected UTXOs and outputs. */ - abstract performSelection(target: bigint): SelectedCoinsResult; + abstract performSelection(target: bigint, fee: bigint): SelectedCoinsResult; /** * Validates the provided UTXO instance. In order to be valid for coin selection, the UTXO must have a valid address diff --git a/src/transaction/coinselector-fewest.ts b/src/transaction/coinselector-fewest.ts index b8b87463..604c9bd2 100644 --- a/src/transaction/coinselector-fewest.ts +++ b/src/transaction/coinselector-fewest.ts @@ -1,4 +1,4 @@ -import { bigIntAbs } from '../utils/maths.js'; +// import { bigIntAbs } from '../utils/maths.js'; import { AbstractCoinSelector, SelectedCoinsResult } from './abstract-coinselector.js'; import { UTXO, denominate, denominations } from './utxo.js'; @@ -14,151 +14,210 @@ import { UTXO, denominate, denominations } from './utxo.js'; */ export class FewestCoinSelector extends AbstractCoinSelector { /** - * The coin selection algorithm considering transaction fees. + * Performs coin selection to meet the target amount plus fee, using the smallest possible denominations and + * minimizing the number of inputs and outputs. * * @param {bigint} target - The target amount to spend. - * @returns {SelectedCoinsResult} The selected UTXOs and change outputs. + * @param {bigint} fee - The fee amount to include in the selection. + * @returns {SelectedCoinsResult} The selected UTXOs and outputs. */ - performSelection(target: bigint): SelectedCoinsResult { + performSelection(target: bigint, fee: bigint = BigInt(0)): SelectedCoinsResult { if (target <= BigInt(0)) { throw new Error('Target amount must be greater than 0'); } + if (fee < BigInt(0)) { + throw new Error('Fee amount cannot be negative'); + } + this.validateUTXOs(); this.target = target; + const totalRequired = BigInt(target) + BigInt(fee); // Initialize selection state this.selectedUTXOs = []; this.totalInputValue = BigInt(0); - const sortedUTXOs = this.sortUTXOsByDenomination(this.availableUTXOs, 'desc'); + // Sort available UTXOs by denomination in ascending order + const sortedUTXOs = this.sortUTXOsByDenomination(this.availableUTXOs, 'asc'); - let totalValue = BigInt(0); - let selectedUTXOs: UTXO[] = []; - - // Get UTXOs that meets or exceeds the target value - const UTXOsEqualOrGreaterThanTarget = sortedUTXOs.filter( - (utxo) => utxo.denomination !== null && BigInt(denominations[utxo.denomination]) >= target, - ); + // Attempt to find a single UTXO that can cover the total required amount + const singleUTXO = sortedUTXOs.find((utxo) => BigInt(denominations[utxo.denomination!]) >= totalRequired); - if (UTXOsEqualOrGreaterThanTarget.length > 0) { - // Find the smallest UTXO that meets or exceeds the target value - const optimalUTXO = UTXOsEqualOrGreaterThanTarget.reduce((minDenominationUTXO, currentUTXO) => { - if (currentUTXO.denomination === null) return minDenominationUTXO; - return BigInt(denominations[currentUTXO.denomination]) < - BigInt(denominations[minDenominationUTXO.denomination!]) - ? currentUTXO - : minDenominationUTXO; - }, UTXOsEqualOrGreaterThanTarget[0]); - - selectedUTXOs.push(optimalUTXO); - totalValue += BigInt(denominations[optimalUTXO.denomination!]); + if (singleUTXO) { + // Use the smallest UTXO that can cover the total required amount + this.selectedUTXOs.push(singleUTXO); + this.totalInputValue = BigInt(denominations[singleUTXO.denomination!]); } else { - // If no single UTXO meets or exceeds the target, aggregate smaller denominations - // until the target is met/exceeded or there are no more UTXOs to aggregate - while (sortedUTXOs.length > 0 && totalValue < target) { - const nextOptimalUTXO = sortedUTXOs.reduce((closest, utxo) => { - if (utxo.denomination === null) return closest; - - // Prioritize UTXOs that bring totalValue closer to target.value - const absThisDiff = bigIntAbs( - BigInt(target) - (BigInt(totalValue) + BigInt(denominations[utxo.denomination])), - ); - const currentClosestDiff = - closest && closest.denomination !== null - ? bigIntAbs( - BigInt(target) - (BigInt(totalValue) + BigInt(denominations[closest.denomination])), - ) - : BigInt(Number.MAX_SAFE_INTEGER); - - return absThisDiff < currentClosestDiff ? utxo : closest; - }, sortedUTXOs[0]); - - // Add the selected UTXO to the selection and update totalValue - selectedUTXOs.push(nextOptimalUTXO); - totalValue += BigInt(denominations[nextOptimalUTXO.denomination!]); - - // Remove the selected UTXO from the list of available UTXOs - const index = sortedUTXOs.findIndex( - (utxo) => - utxo.denomination === nextOptimalUTXO.denomination && utxo.address === nextOptimalUTXO.address, - ); - sortedUTXOs.splice(index, 1); - } - } + // If no single UTXO can cover the total required amount, find the minimal set + this.selectedUTXOs = this.findMinimalUTXOSet(sortedUTXOs, totalRequired); - // Optimize the selection process - let optimalSelection = selectedUTXOs; - let minExcess = BigInt(totalValue) - BigInt(target); + if (this.selectedUTXOs.length === 0) { + throw new Error('Insufficient funds'); + } - for (let i = 0; i < selectedUTXOs.length; i++) { - const subsetUTXOs = selectedUTXOs.slice(0, i).concat(selectedUTXOs.slice(i + 1)); - const subsetTotal = subsetUTXOs.reduce( - (sum, utxo) => BigInt(sum) + BigInt(denominations[utxo.denomination!]), + // Calculate total input value + this.totalInputValue = this.selectedUTXOs.reduce( + (sum, utxo) => sum + BigInt(denominations[utxo.denomination!]), BigInt(0), ); - - if (subsetTotal >= target) { - const excess = BigInt(subsetTotal) - BigInt(target); - if (excess < minExcess) { - optimalSelection = subsetUTXOs; - minExcess = excess; - totalValue = subsetTotal; - } - } } - selectedUTXOs = optimalSelection; + // Create outputs + const changeAmount = this.totalInputValue - BigInt(target) - BigInt(fee); - // Find the largest denomination used in the inputs + // Create spend outputs (to the recipient) + this.spendOutputs = this.createSpendOutputs(target); - // Store the selected UTXOs and total input value - this.selectedUTXOs = selectedUTXOs; - this.totalInputValue = totalValue; + // Create change outputs (to ourselves), if any + this.changeOutputs = this.createChangeOutputs(changeAmount); - // Check if the selected UTXOs meet or exceed the target amount - if (totalValue < target) { - throw new Error('Insufficient funds'); + // Verify that sum of outputs does not exceed sum of inputs + const totalOutputValue = this.calculateTotalOutputValue(); + if (totalOutputValue > this.totalInputValue) { + throw new Error('Total output value exceeds total input value'); } - // Store spendOutputs and changeOutputs - this.spendOutputs = this.createSpendOutputs(target); - this.changeOutputs = this.createChangeOutputs(BigInt(totalValue) - BigInt(target)); + // Ensure largest output denomination ≤ largest input denomination + const maxInputDenomination = this.getMaxInputDenomination(); + const maxOutputDenomination = this.getMaxOutputDenomination(); + + if (maxOutputDenomination > maxInputDenomination) { + throw new Error('Largest output denomination exceeds largest input denomination'); + } return { - inputs: selectedUTXOs, + inputs: this.selectedUTXOs, spendOutputs: this.spendOutputs, changeOutputs: this.changeOutputs, }; } - // Helper methods to create spend and change outputs + /** + * Finds the minimal set of UTXOs that can cover the total required amount. + * + * @param {UTXO[]} sortedUTXOs - Available UTXOs sorted by denomination (ascending). + * @param {bigint} totalRequired - The total amount required (target + fee). + * @returns {UTXO[]} The minimal set of UTXOs. + */ + private findMinimalUTXOSet(sortedUTXOs: UTXO[], totalRequired: bigint): UTXO[] { + // Use a greedy algorithm to select the fewest UTXOs + // Starting from the largest denominations to minimize the number of inputs + const utxos = [...sortedUTXOs].reverse(); // Largest to smallest + let totalValue = BigInt(0); + const selectedUTXOs: UTXO[] = []; + + for (const utxo of utxos) { + if (totalValue >= totalRequired) { + break; + } + selectedUTXOs.push(utxo); + totalValue += BigInt(denominations[utxo.denomination!]); + } + + if (totalValue >= totalRequired) { + return selectedUTXOs; + } else { + return []; // Insufficient funds + } + } + + /** + * Creates spend outputs based on the target amount and input denominations. + * + * @param {bigint} amount - The target amount to spend. + * @param {UTXO[]} inputs - The selected inputs. + * @returns {UTXO[]} The spend outputs. + */ private createSpendOutputs(amount: bigint): UTXO[] { - const maxDenomination = this.getMaxInputDenomination(); + const maxInputDenomination = this.getMaxInputDenomination(); + + // Denominate the amount using available denominations up to the max input denomination + const spendDenominations = denominate(amount, maxInputDenomination); - const spendDenominations = denominate(amount, maxDenomination); - return spendDenominations.map((denomination) => { + return spendDenominations.map((denominationValue) => { const utxo = new UTXO(); - utxo.denomination = denominations.indexOf(denomination); + utxo.denomination = denominations.indexOf(denominationValue); return utxo; }); } + /** + * Creates change outputs based on the change amount and input denominations. + * + * @param {bigint} change - The change amount to return. + * @param {UTXO[]} inputs - The selected inputs. + * @returns {UTXO[]} The change outputs. + */ private createChangeOutputs(change: bigint): UTXO[] { if (change <= BigInt(0)) { return []; } - const maxDenomination = this.getMaxInputDenomination(); + const maxInputDenomination = this.getMaxInputDenomination(); + + // Denominate the change amount using available denominations up to the max input denomination + const changeDenominations = denominate(change, maxInputDenomination); - const changeDenominations = denominate(change, maxDenomination); - return changeDenominations.map((denomination) => { + return changeDenominations.map((denominationValue) => { const utxo = new UTXO(); - utxo.denomination = denominations.indexOf(denomination); + utxo.denomination = denominations.indexOf(denominationValue); return utxo; }); } + /** + * Calculates the total value of outputs (spend + change). + * + * @returns {bigint} The total output value. + */ + private calculateTotalOutputValue(): bigint { + const spendValue = this.spendOutputs.reduce( + (sum, output) => sum + BigInt(denominations[output.denomination!]), + BigInt(0), + ); + + const changeValue = this.changeOutputs.reduce( + (sum, output) => sum + BigInt(denominations[output.denomination!]), + BigInt(0), + ); + + return spendValue + changeValue; + } + + /** + * Gets the maximum denomination value from the selected UTXOs. + * + * @returns {bigint} The maximum input denomination value. + */ + private getMaxInputDenomination(): bigint { + const inputs = [...this.selectedUTXOs]; + return this.getMaxDenomination(inputs); + } + + /** + * Gets the maximum denomination value from the spend and change outputs. + * + * @returns {bigint} The maximum output denomination value. + */ + private getMaxOutputDenomination(): bigint { + const outputs = [...this.spendOutputs, ...this.changeOutputs]; + return this.getMaxDenomination(outputs); + } + + /** + * Gets the maximum denomination value from a list of UTXOs. + * + * @param {UTXO[]} utxos - The list of UTXOs. + * @returns {bigint} The maximum denomination value. + */ + private getMaxDenomination(utxos: UTXO[]): bigint { + return utxos.reduce((max, utxo) => { + const denomValue = BigInt(denominations[utxo.denomination!]); + return denomValue > max ? denomValue : max; + }, BigInt(0)); + } + /** * Increases the total fee by first reducing change outputs, then selecting additional inputs if necessary. * @@ -200,10 +259,7 @@ export class FewestCoinSelector extends AbstractCoinSelector { if (remainingFee <= BigInt(0)) { // If we have excess, create a new change output if (remainingFee < BigInt(0)) { - const change = - BigInt(this.totalInputValue) - - BigInt(this.target!) - - (BigInt(additionalFeeNeeded) - BigInt(remainingFee)); + const change = BigInt(this.totalInputValue) - BigInt(this.target!) - BigInt(additionalFeeNeeded); this.adjustChangeOutputs(change); } } @@ -255,13 +311,6 @@ export class FewestCoinSelector extends AbstractCoinSelector { }; } - private getMaxInputDenomination(): bigint { - return this.selectedUTXOs.reduce((max, utxo) => { - const denomValue = BigInt(denominations[utxo.denomination!]); - return denomValue > max ? denomValue : max; - }, BigInt(0)); - } - /** * Helper method to adjust change outputs. * diff --git a/src/wallet/qi-hdwallet.ts b/src/wallet/qi-hdwallet.ts index ee6e8e6c..4d547c5c 100644 --- a/src/wallet/qi-hdwallet.ts +++ b/src/wallet/qi-hdwallet.ts @@ -37,14 +37,25 @@ export interface OutpointInfo { account?: number; } -interface paymentCodeInfo { +// enum AddressUseStatus { +// USED, // Address has been used in a transaction and is not available for reuse +// UNUSED, // Address has not been used in any transaction and is available for reuse +// ATTEMPTED, // Address was attempted to be used in a transaction but tx status is unknown +// } + +interface PaymentChannelAddressInfo { address: string; + pubKey: string; index: number; isUsed: boolean; zone: Zone; account: number; } +interface PaymentChannelAddressExtendedInfo extends PaymentChannelAddressInfo { + counterpartyPaymentCode: string; +} + /** * @extends SerializedHDWallet * @property {OutpointInfo[]} outpoints - Array of outpoint information. @@ -55,13 +66,28 @@ interface paymentCodeInfo { */ export interface SerializedQiHDWallet extends SerializedHDWallet { outpoints: OutpointInfo[]; + pendingOutpoints: OutpointInfo[]; changeAddresses: NeuteredAddressInfo[]; gapAddresses: NeuteredAddressInfo[]; gapChangeAddresses: NeuteredAddressInfo[]; - receiverPaymentCodeInfo: { [key: string]: paymentCodeInfo[] }; - senderPaymentCodeInfo: { [key: string]: paymentCodeInfo[] }; + usedGapAddresses: NeuteredAddressInfo[]; + usedGapChangeAddresses: NeuteredAddressInfo[]; + receiverPaymentCodeInfo: { [key: string]: PaymentChannelAddressInfo[] }; + senderPaymentCodeInfo: { [key: string]: PaymentChannelAddressInfo[] }; } +type AddressUsageCallback = (address: string) => Promise; + +/** + * Current known issues: + * + * - When generating send addresses we are not checking if the address has already been used before + * - When syncing is seems like we are adding way too many change addresses + * - Bip44 external and change address maps also have gap addresses in them + * - It is unclear if we have checked if addresses have been used and if they are used + * - We should always check all addresses that were previously included in a transaction to see if they have been used + */ + /** * The Qi HD wallet is a BIP44-compliant hierarchical deterministic wallet used for managing a set of addresses in the * Qi ledger. This is wallet implementation is the primary way to interact with the Qi UTXO ledger on the Quai network. @@ -100,7 +126,7 @@ export class QiHDWallet extends AbstractHDWallet { * @ignore * @type {number} */ - protected static _GAP_LIMIT: number = 20; + protected static _GAP_LIMIT: number = 5; /** * @ignore @@ -132,23 +158,52 @@ export class QiHDWallet extends AbstractHDWallet { */ protected _gapAddresses: NeuteredAddressInfo[] = []; + /** + * This array is used to keep track of gap addresses that have been included in a transaction, but whose outpoints + * have not been imported into the wallet. + * + * @ignore + * @type {NeuteredAddressInfo[]} + */ + protected _usedGapAddresses: NeuteredAddressInfo[] = []; + + /** + * This array is used to keep track of gap change addresses that have been included in a transaction, but whose + * outpoints have not been imported into the wallet. + * + * @ignore + * @type {NeuteredAddressInfo[]} + */ + protected _usedGapChangeAddresses: NeuteredAddressInfo[] = []; + /** * Array of outpoint information. * * @ignore * @type {OutpointInfo[]} */ - protected _outpoints: OutpointInfo[] = []; + protected _availableOutpoints: OutpointInfo[] = []; + + /** + * Map of outpoints that are pending confirmation of being spent. + */ + protected _pendingOutpoints: OutpointInfo[] = []; + + /** + * @ignore + * @type {AddressUsageCallback} + */ + protected _addressUseChecker: AddressUsageCallback | undefined; /** - * Map of paymentcodes to paymentCodeInfo for the receiver + * Map of paymentcodes to PaymentChannelAddressInfo for the receiver */ - private _receiverPaymentCodeInfo: Map = new Map(); + private _receiverPaymentCodeInfo: Map = new Map(); /** - * Map of paymentcodes to paymentCodeInfo for the sender + * Map of paymentcodes to PaymentChannelAddressInfo for the sender */ - private _senderPaymentCodeInfo: Map = new Map(); + private _senderPaymentCodeInfo: Map = new Map(); /** * @ignore @@ -159,12 +214,23 @@ export class QiHDWallet extends AbstractHDWallet { super(guard, root, provider); } + /** + * Sets the address use checker. The provided callback function should accept an address as input and return a + * boolean indicating whether the address is in use. If the callback returns true, the address is considered used + * and if it returns false, the address is considered unused. + * + * @param {AddressUsageCallback} checker - The address use checker. + */ + public setAddressUseChecker(checker: AddressUsageCallback): void { + this._addressUseChecker = checker; + } + // getters for the payment code info maps - public get receiverPaymentCodeInfo(): { [key: string]: paymentCodeInfo[] } { + public get receiverPaymentCodeInfo(): { [key: string]: PaymentChannelAddressInfo[] } { return Object.fromEntries(this._receiverPaymentCodeInfo); } - public get senderPaymentCodeInfo(): { [key: string]: paymentCodeInfo[] } { + public get senderPaymentCodeInfo(): { [key: string]: PaymentChannelAddressInfo[] } { return Object.fromEntries(this._senderPaymentCodeInfo); } @@ -197,7 +263,7 @@ export class QiHDWallet extends AbstractHDWallet { */ public importOutpoints(outpoints: OutpointInfo[]): void { this.validateOutpointInfo(outpoints); - this._outpoints.push(...outpoints); + this._availableOutpoints.push(...outpoints); } /** @@ -208,7 +274,7 @@ export class QiHDWallet extends AbstractHDWallet { */ public getOutpoints(zone: Zone): OutpointInfo[] { this.validateZone(zone); - return this._outpoints.filter((outpoint) => outpoint.zone === zone); + return this._availableOutpoints.filter((outpoint) => outpoint.zone === zone); } /** @@ -232,7 +298,6 @@ export class QiHDWallet extends AbstractHDWallet { }; let signature: string; - if (shouldUseSchnorrSignature(txobj.txInputs)) { signature = this.createSchnorrSignature(txobj.txInputs[0], hash); } else { @@ -242,6 +307,52 @@ export class QiHDWallet extends AbstractHDWallet { return txobj.serialized; } + /** + * Gets the payment channel address info for a given address. + * + * @param {string} address - The address to look up. + * @returns {PaymentChannelAddressInfo | null} The address info or null if not found. + */ + public getPaymentChannelAddressInfo(address: string): PaymentChannelAddressExtendedInfo | null { + for (const [paymentCode, pcInfoArray] of this._receiverPaymentCodeInfo.entries()) { + const pcInfo = pcInfoArray.find((info) => info.address === address); + if (pcInfo) { + return { ...pcInfo, counterpartyPaymentCode: paymentCode }; + } + } + return null; + } + + /** + * Locates the address information for the given address, searching through standard addresses, change addresses, + * and payment channel addresses. + * + * @param {string} address - The address to locate. + * @returns {NeuteredAddressInfo | PaymentChannelAddressInfo | null} The address info or null if not found. + */ + private locateAddressInfo(address: string): NeuteredAddressInfo | PaymentChannelAddressExtendedInfo | null { + // First, try to get standard address info + let addressInfo = this.getAddressInfo(address); + if (addressInfo) { + return addressInfo; + } + + // Next, try to get change address info + addressInfo = this.getChangeAddressInfo(address); + if (addressInfo) { + return addressInfo; + } + + // Finally, try to get payment channel address info + const pcAddressInfo = this.getPaymentChannelAddressInfo(address); + if (pcAddressInfo) { + return pcAddressInfo; + } + + // Address not found + return null; + } + /** * Gets the balance for the specified zone. * @@ -251,7 +362,7 @@ export class QiHDWallet extends AbstractHDWallet { public getBalanceForZone(zone: Zone): bigint { this.validateZone(zone); - return this._outpoints + return this._availableOutpoints .filter((outpoint) => outpoint.zone === zone) .reduce((total, outpoint) => { const denominationValue = denominations[outpoint.outpoint.denomination]; @@ -267,7 +378,7 @@ export class QiHDWallet extends AbstractHDWallet { */ private outpointsToUTXOs(zone: Zone): UTXO[] { this.validateZone(zone); - return this._outpoints + return this._availableOutpoints .filter((outpointInfo) => outpointInfo.zone === zone) .map((outpointInfo) => { const utxo = new UTXO(); @@ -319,18 +430,33 @@ export class QiHDWallet extends AbstractHDWallet { // 3. Generate as many unused addresses as required to populate the spend outputs const sendAddresses: string[] = []; - for (let i = 0; i < selection.spendOutputs.length; i++) { - sendAddresses.push(await this.getNextSendAddress(recipientPaymentCode, destinationZone)); + while (sendAddresses.length < selection.spendOutputs.length) { + const address = this.getNextSendAddress(recipientPaymentCode, destinationZone).address; + const { isUsed } = await this.checkAddressUse(address); + if (!isUsed) { + sendAddresses.push(address); + } } - // 4. Generate as many addresses as required to populate the change outputs + + // 4. get known change addresses, then populate with new ones as needed const changeAddresses: string[] = []; for (let i = 0; i < selection.changeOutputs.length; i++) { - changeAddresses.push((await this.getNextChangeAddress(0, originZone)).address); + if (this._gapChangeAddresses.length > 0) { + // 1. get next change address from gap addresses array + // 2. remove it from the gap change addresses array + // 3. add it to the change addresses array + // 4. add it to the used gap change addresses array + const nextChangeAddressInfo = this._gapChangeAddresses.shift()!; + changeAddresses.push(nextChangeAddressInfo!.address); + this._usedGapChangeAddresses.push(nextChangeAddressInfo); + } else { + changeAddresses.push((await this.getNextChangeAddress(0, originZone)).address); + } } // 5. Create the transaction and sign it using the signTransaction method // 5.1 Fetch the public keys for the input addresses - let inputPubKeys = selection.inputs.map((input) => this.getAddressInfo(input.address)?.pubKey); + let inputPubKeys = selection.inputs.map((input) => this.locateAddressInfo(input.address)?.pubKey); if (inputPubKeys.some((pubkey) => !pubkey)) { throw new Error('Missing public key for input address'); } @@ -351,25 +477,25 @@ export class QiHDWallet extends AbstractHDWallet { const totalFee = gasLimit * (feeData.gasPrice ?? 1n) + (feeData.maxFeePerGas ?? 0n) + (feeData.maxPriorityFeePerGas ?? 0n); - // Get new selection with increased fee - selection = fewestCoinSelector.increaseFee(totalFee); + // Get new selection with fee + selection = fewestCoinSelector.performSelection(spendTarget, totalFee); // 5.7 Determine if new addresses are needed for the change outputs - const changeAddressesNeeded = selection.changeOutputs.length > changeAddresses.length; - if (changeAddressesNeeded) { - for (let i = 0; i < selection.changeOutputs.length; i++) { + const changeAddressesNeeded = selection.changeOutputs.length - changeAddresses.length; + if (changeAddressesNeeded > 0) { + for (let i = 0; i < changeAddressesNeeded; i++) { changeAddresses.push((await this.getNextChangeAddress(0, originZone)).address); } } - const spendAddressesNeeded = selection.spendOutputs.length > sendAddresses.length; - if (spendAddressesNeeded) { - for (let i = 0; i < selection.spendOutputs.length; i++) { - sendAddresses.push(await this.getNextSendAddress(recipientPaymentCode, destinationZone)); + const spendAddressesNeeded = selection.spendOutputs.length - sendAddresses.length; + if (spendAddressesNeeded > 0) { + for (let i = 0; i < spendAddressesNeeded; i++) { + sendAddresses.push(this.getNextSendAddress(recipientPaymentCode, destinationZone).address); } } - inputPubKeys = selection.inputs.map((input) => this.getAddressInfo(input.address)?.pubKey); + inputPubKeys = selection.inputs.map((input) => this.locateAddressInfo(input.address)?.pubKey); tx = await this.prepareTransaction( selection, @@ -379,6 +505,9 @@ export class QiHDWallet extends AbstractHDWallet { Number(chainId), ); + // Move used outpoints to pendingOutpoints + this.moveOutpointsToPending(tx.txInputs); + // 5.6 Sign the transaction const signedTx = await this.signTransaction(tx); @@ -419,6 +548,77 @@ export class QiHDWallet extends AbstractHDWallet { return tx; } + /** + * Checks the status of pending outpoints and updates the wallet's UTXO set accordingly. + * + * @param zone The zone in which to check the pending outpoints. + */ + private async checkPendingOutpoints(zone: Zone): Promise { + // Create a copy to iterate over, as we'll be modifying the _pendingOutpoints array + const pendingOutpoints = [...this._pendingOutpoints.filter((info) => info.zone === zone)]; + + const uniqueAddresses = new Set(pendingOutpoints.map((info) => info.address)); + const outpointsByAddress = await Promise.all( + Array.from(uniqueAddresses).map((address) => this.getOutpointsByAddress(address)), + ); + + const allOutpointsByAddress = outpointsByAddress.flat(); + + for (const outpointInfo of pendingOutpoints) { + const isSpent = !allOutpointsByAddress.some( + (outpoint) => + outpoint.txhash === outpointInfo.outpoint.txhash && outpoint.index === outpointInfo.outpoint.index, + ); + + if (isSpent) { + // Outpoint has been spent; remove it from pendingOutpoints + this.removeOutpointFromPending(outpointInfo.outpoint); + } else { + // Outpoint is still unspent; move it back to available outpoints + this.moveOutpointToAvailable(outpointInfo); + } + } + } + + /** + * Moves specified inputs to pending outpoints. + * + * @param inputs List of inputs used in the transaction. + */ + private moveOutpointsToPending(inputs: TxInput[]): void { + inputs.forEach((input) => { + const index = this._availableOutpoints.findIndex( + (outpointInfo) => + outpointInfo.outpoint.txhash === input.txhash && outpointInfo.outpoint.index === input.index, + ); + if (index !== -1) { + const [outpointInfo] = this._availableOutpoints.splice(index, 1); + this._pendingOutpoints.push(outpointInfo); + } + }); + } + + /** + * Removes an outpoint from the pending outpoints. + * + * @param outpoint The outpoint to remove. + */ + private removeOutpointFromPending(outpoint: Outpoint): void { + this._pendingOutpoints = this._pendingOutpoints.filter( + (info) => !(info.outpoint.txhash === outpoint.txhash && info.outpoint.index === outpoint.index), + ); + } + + /** + * Moves an outpoint from pending back to available outpoints. + * + * @param outpointInfo The outpoint info to move. + */ + private moveOutpointToAvailable(outpointInfo: OutpointInfo): void { + this.removeOutpointFromPending(outpointInfo.outpoint); + this._availableOutpoints.push(outpointInfo); + } + /** * Returns a schnorr signature for the given message and private key. * @@ -482,37 +682,60 @@ export class QiHDWallet extends AbstractHDWallet { /** * Retrieves the private key for a given transaction input. * - * This method derives the private key for a transaction input by following these steps: + * This method derives the private key for a transaction input by locating the address info and then deriving the + * private key based on where the address info was found: * - * 1. Ensures the input contains a public key. - * 2. Computes the address from the public key. - * 3. Fetches address information associated with the computed address. - * 4. Derives the hierarchical deterministic (HD) node corresponding to the address. - * 5. Returns the private key of the derived HD node. + * - For BIP44 addresses (standard or change), it uses the HD wallet to derive the private key. + * - For payment channel addresses (BIP47), it uses PaymentCodePrivate to derive the private key. * - * @ignore * @param {TxInput} input - The transaction input containing the public key. * @returns {string} The private key corresponding to the transaction input. * @throws {Error} If the input does not contain a public key or if the address information cannot be found. */ private getPrivateKeyForTxInput(input: TxInput): string { if (!input.pubkey) throw new Error('Missing public key for input'); + const address = computeAddress(input.pubkey); - // get address info - const addressInfo = this.getAddressInfo(address); - if (!addressInfo) throw new Error(`Address not found: ${address}`); - // derive an HDNode for the address and get the private key - const changeIndex = addressInfo.change ? 1 : 0; - const addressNode = this._root - .deriveChild(addressInfo.account) - .deriveChild(changeIndex) - .deriveChild(addressInfo.index); - return addressNode.privateKey; + const addressInfo = this.locateAddressInfo(address); + + if (!addressInfo) { + throw new Error(`Address not found: ${address}`); + } + + if ('change' in addressInfo) { + // NeuteredAddressInfo (BIP44 addresses) + const changeIndex = addressInfo.change ? 1 : 0; + const addressNode = this._root + .deriveChild(addressInfo.account) + .deriveChild(changeIndex) + .deriveChild(addressInfo.index); + return addressNode.privateKey; + } else { + // PaymentChannelAddressInfo (BIP47 addresses) + const pcAddressInfo = addressInfo as PaymentChannelAddressExtendedInfo; + const account = pcAddressInfo.account; + const index = pcAddressInfo.index - 1; + + const counterpartyPaymentCode = pcAddressInfo.counterpartyPaymentCode; + if (!counterpartyPaymentCode) { + throw new Error('Counterparty payment code not found for payment channel address'); + } + + const bip32 = BIP32Factory(ecc); + const buf = bs58check.decode(counterpartyPaymentCode); + const version = buf[0]; + if (version !== PC_VERSION) throw new Error('Invalid payment code version'); + + const counterpartyPCodePublic = new PaymentCodePublic(ecc, bip32, buf.slice(1)); + const paymentCodePrivate = this._getPaymentCodePrivate(account); + const paymentPrivateKey = paymentCodePrivate.derivePaymentPrivateKey(counterpartyPCodePublic, index); + return hexlify(paymentPrivateKey); + } } /** * Scans the specified zone for addresses with unspent outputs. Starting at index 0, it will generate new addresses - * until the gap limit is reached for both gap and change addresses. + * until the gap limit is reached for external and change BIP44 addresses and payment channel addresses. * * @param {Zone} zone - The zone in which to scan for addresses. * @param {number} [account=0] - The index of the account to scan. Default is `0` @@ -526,40 +749,36 @@ export class QiHDWallet extends AbstractHDWallet { this._changeAddresses = new Map(); this._gapAddresses = []; this._gapChangeAddresses = []; - this._outpoints = []; + this._availableOutpoints = []; + + // Reset each map so that all keys have empty array values but keys are preserved + const resetSenderPaymentCodeInfo = new Map( + Array.from(this._senderPaymentCodeInfo.keys()).map((key) => [key, []]), + ); + const resetReceiverPaymentCodeInfo = new Map( + Array.from(this._receiverPaymentCodeInfo.keys()).map((key) => [key, []]), + ); + + this._senderPaymentCodeInfo = resetSenderPaymentCodeInfo; + this._receiverPaymentCodeInfo = resetReceiverPaymentCodeInfo; await this._scan(zone, account); } /** * Scans the specified zone for addresses with unspent outputs. Starting at the last address index, it will generate - * new addresses until the gap limit is reached for both gap and change addresses. If no account is specified, it - * will scan all accounts known to the wallet. + * new addresses until the gap limit is reached for external and change BIP44 addresses and payment channel + * addresses. * * @param {Zone} zone - The zone in which to sync addresses. - * @param {number} [account] - The index of the account to sync. If not specified, all accounts will be scanned. + * @param {number} [account=0] - The index of the account to sync. Default is `0` * @returns {Promise} A promise that resolves when the sync is complete. * @throws {Error} If the zone is invalid. */ - public async sync(zone: Zone, account?: number): Promise { + public async sync(zone: Zone, account: number = 0): Promise { this.validateZone(zone); - // if no account is specified, scan all accounts. - if (account === undefined) { - const addressInfos = Array.from(this._addresses.values()); - const accounts = addressInfos.reduce((unique, info) => { - if (!unique.includes(info.account)) { - unique.push(info.account); - } - return unique; - }, []); - - for (const acc of accounts) { - await this._scan(zone, acc); - } - } else { - await this._scan(zone, account); - } - return; + await this._scan(zone, account); + await this.checkPendingOutpoints(zone); } /** @@ -574,19 +793,19 @@ export class QiHDWallet extends AbstractHDWallet { private async _scan(zone: Zone, account: number = 0): Promise { if (!this.provider) throw new Error('Provider not set'); - let gapAddressesCount = 0; - let changeGapAddressesCount = 0; + // Start scanning processes for each derivation tree + const scans = [ + this.scanBIP44Addresses(zone, account, false), // External addresses + this.scanBIP44Addresses(zone, account, true), // Change addresses + ]; - while (gapAddressesCount < QiHDWallet._GAP_LIMIT || changeGapAddressesCount < QiHDWallet._GAP_LIMIT) { - [gapAddressesCount, changeGapAddressesCount] = await Promise.all([ - gapAddressesCount < QiHDWallet._GAP_LIMIT - ? this.scanAddress(zone, account, false, gapAddressesCount) - : gapAddressesCount, - changeGapAddressesCount < QiHDWallet._GAP_LIMIT - ? this.scanAddress(zone, account, true, changeGapAddressesCount) - : changeGapAddressesCount, - ]); + // Add scanning processes for each payment channel + for (const paymentCode of this._receiverPaymentCodeInfo.keys()) { + scans.push(this.scanPaymentChannel(zone, account, paymentCode)); } + + // Run all scans in parallel + await Promise.all(scans); } /** @@ -596,30 +815,191 @@ export class QiHDWallet extends AbstractHDWallet { * @param {Zone} zone - The zone in which the address is being scanned. * @param {number} account - The index of the account for which the address is being scanned. * @param {boolean} isChange - A flag indicating whether the address is a change address. - * @param {number} addressesCount - The current count of addresses scanned. - * @returns {Promise} A promise that resolves to the updated address count. + * @returns {Promise} A promise that resolves when the scan is complete. * @throws {Error} If an error occurs during the address scanning or outpoints retrieval process. */ - private async scanAddress(zone: Zone, account: number, isChange: boolean, addressesCount: number): Promise { + private async scanBIP44Addresses(zone: Zone, account: number, isChange: boolean): Promise { const addressMap = isChange ? this._changeAddresses : this._addresses; - const addressInfo = this._getNextAddress(account, zone, isChange, addressMap); - const outpoints = await this.getOutpointsByAddress(addressInfo.address); - if (outpoints.length > 0) { - this.importOutpoints( - outpoints.map((outpoint) => ({ - outpoint, - address: addressInfo.address, - zone, - account, - })), - ); - addressesCount = 0; - isChange ? (this._gapChangeAddresses = []) : (this._gapAddresses = []); + const gapAddresses = isChange ? this._gapChangeAddresses : this._gapAddresses; + const usedGapAddresses = isChange ? this._usedGapChangeAddresses : this._usedGapAddresses; + + // First, add all used gap addresses to the address map and import their outpoints + for (const addressInfo of usedGapAddresses) { + this._addAddress(addressMap, account, addressInfo.index, isChange); + const outpoints = await this.getOutpointsByAddress(addressInfo.address); + if (outpoints.length > 0) { + this.importOutpoints( + outpoints.map((outpoint) => ({ + outpoint, + address: addressInfo.address, + zone, + account, + })), + ); + } + } + + let gapCount = 0; + // Second, re-examine existing gap addresses + const newlyUsedAddresses: NeuteredAddressInfo[] = []; + for (let i: number = 0; i < gapAddresses.length; ) { + const addressInfo = gapAddresses[i]; + const { isUsed, outpoints } = await this.checkAddressUse(addressInfo.address); + if (isUsed) { + // Address has been used since last scan + this._addAddress(addressMap, account, addressInfo.index, isChange); + if (outpoints.length > 0) { + this.importOutpoints( + outpoints.map((outpoint) => ({ + outpoint, + address: addressInfo.address, + zone, + account, + })), + ); + } + // Remove from gap addresses + newlyUsedAddresses.push(addressInfo); + gapCount = 0; + } else { + gapCount++; + i++; + } + } + + // remove addresses that have been used from the gap addresses + const updatedGapAddresses = gapAddresses.filter( + (addressInfo) => !newlyUsedAddresses.some((usedAddress) => usedAddress.address === addressInfo.address), + ); + + // Scan for new gap addresses + const newGapAddresses: NeuteredAddressInfo[] = []; + while (gapCount < QiHDWallet._GAP_LIMIT) { + const addressInfo = this._getNextAddress(account, zone, isChange, addressMap); + const { isUsed, outpoints } = await this.checkAddressUse(addressInfo.address); + if (isUsed) { + if (outpoints.length > 0) { + this.importOutpoints( + outpoints.map((outpoint) => ({ + outpoint, + address: addressInfo.address, + zone, + account, + })), + ); + } + gapCount = 0; + } else { + gapCount++; + // check if the address is already in the updated gap addresses array + if (!updatedGapAddresses.some((usedAddress) => usedAddress.address === addressInfo.address)) { + newGapAddresses.push(addressInfo); + } + } + } + + // update the gap addresses + if (isChange) { + this._gapChangeAddresses = [...updatedGapAddresses, ...newGapAddresses]; } else { - addressesCount++; - isChange ? this._gapChangeAddresses.push(addressInfo) : this._gapAddresses.push(addressInfo); + this._gapAddresses = [...updatedGapAddresses, ...newGapAddresses]; } - return addressesCount; + } + + /** + * Scans the specified payment channel for addresses with unspent outputs. Starting at the last address index, it + * will generate new addresses until the gap limit is reached. + * + * @param {Zone} zone - The zone in which to scan for addresses. + * @param {number} account - The index of the account to scan. + * @param {string} paymentCode - The payment code to scan. + * @returns {Promise} A promise that resolves when the scan is complete. + * @throws {Error} If the zone is invalid. + */ + private async scanPaymentChannel(zone: Zone, account: number, paymentCode: string): Promise { + let gapCount = 0; + + const paymentCodeInfoArray = this._receiverPaymentCodeInfo.get(paymentCode); + if (!paymentCodeInfoArray) { + throw new Error(`Payment code ${paymentCode} not found`); + } + // first, re-examine existing unused addresses + const newlyUsedAddresses: PaymentChannelAddressInfo[] = []; + const unusedAddresses = paymentCodeInfoArray.filter((info) => !info.isUsed); + for (let i: number = 0; i < unusedAddresses.length; ) { + const addressInfo = unusedAddresses[i]; + const { isUsed, outpoints } = await this.checkAddressUse(addressInfo.address); + if (outpoints.length > 0 || isUsed) { + // Address has been used since last scan + addressInfo.isUsed = true; + const pcAddressInfoIndex = paymentCodeInfoArray.findIndex((info) => info.index === addressInfo.index); + paymentCodeInfoArray[pcAddressInfoIndex] = addressInfo; + this.importOutpoints( + outpoints.map((outpoint) => ({ + outpoint, + address: addressInfo.address, + zone, + account, + })), + ); + // Remove from gap addresses + newlyUsedAddresses.push(addressInfo); + gapCount = 0; + } else { + // Address is still unused + gapCount++; + i++; + } + } + // remove the addresses that have been used from the payment code info array + const updatedPaymentCodeInfoArray = paymentCodeInfoArray.filter( + (addressInfo: PaymentChannelAddressInfo) => + !newlyUsedAddresses.some((usedAddress) => usedAddress.index === addressInfo.index), + ); + // Then, scan for new gap addresses + while (gapCount < QiHDWallet._GAP_LIMIT) { + const pcAddressInfo = this.getNextReceiveAddress(paymentCode, zone, account); + const outpoints = await this.getOutpointsByAddress(pcAddressInfo.address); + + let isUsed = false; + if (outpoints.length > 0) { + isUsed = true; + this.importOutpoints( + outpoints.map((outpoint) => ({ + outpoint, + address: pcAddressInfo.address, + zone, + account, + })), + ); + gapCount = 0; + } else if ( + this._addressUseChecker !== undefined && + (await this._addressUseChecker(pcAddressInfo.address)) + ) { + // address checker returned true, so the address is used + isUsed = true; + gapCount = 0; + } else { + gapCount++; + } + + if (isUsed) { + // update the payment code info array if the address has been used + pcAddressInfo.isUsed = isUsed; + const pcAddressInfoIndex = updatedPaymentCodeInfoArray.findIndex( + (info) => info.index === pcAddressInfo.index, + ); + if (pcAddressInfoIndex !== -1) { + updatedPaymentCodeInfoArray[pcAddressInfoIndex] = pcAddressInfo; + } else { + updatedPaymentCodeInfoArray.push(pcAddressInfo); + } + } + } + + // update the payment code info map + this._receiverPaymentCodeInfo.set(paymentCode, updatedPaymentCodeInfoArray); } /** @@ -638,6 +1018,23 @@ export class QiHDWallet extends AbstractHDWallet { } } + private async checkAddressUse(address: string): Promise<{ isUsed: boolean; outpoints: Outpoint[] }> { + let isUsed = false; + let outpoints: Outpoint[] = []; + try { + outpoints = await this.getOutpointsByAddress(address); + if (outpoints.length > 0) { + isUsed = true; + } else if (this._addressUseChecker !== undefined && (await this._addressUseChecker(address))) { + // address checker returned true, so the address is used + isUsed = true; + } + } catch (error) { + throw new Error(`Failed to get outpoints for address: ${address} - error: ${error}`); + } + return { isUsed, outpoints }; + } + /** * Gets the change addresses for the specified zone. * @@ -699,10 +1096,13 @@ export class QiHDWallet extends AbstractHDWallet { public serialize(): SerializedQiHDWallet { const hdwalletSerialized = super.serialize(); return { - outpoints: this._outpoints, + outpoints: this._availableOutpoints, + pendingOutpoints: this._pendingOutpoints, changeAddresses: Array.from(this._changeAddresses.values()), gapAddresses: this._gapAddresses, gapChangeAddresses: this._gapChangeAddresses, + usedGapAddresses: this._usedGapAddresses, + usedGapChangeAddresses: this._usedGapChangeAddresses, receiverPaymentCodeInfo: Object.fromEntries(this._receiverPaymentCodeInfo), senderPaymentCodeInfo: Object.fromEntries(this._senderPaymentCodeInfo), ...hdwalletSerialized, @@ -747,9 +1147,29 @@ export class QiHDWallet extends AbstractHDWallet { wallet._gapChangeAddresses.push(gapChangeAddressInfo); } - // validate the outpoints and import them + // validate the used gap addresses and import them + for (const usedGapAddressInfo of serialized.usedGapAddresses) { + if (!wallet._addresses.has(usedGapAddressInfo.address)) { + throw new Error(`Address ${usedGapAddressInfo.address} not found in wallet`); + } + wallet._usedGapAddresses.push(usedGapAddressInfo); + } + + // validate the used gap change addresses and import them + for (const usedGapChangeAddressInfo of serialized.usedGapChangeAddresses) { + if (!wallet._changeAddresses.has(usedGapChangeAddressInfo.address)) { + throw new Error(`Address ${usedGapChangeAddressInfo.address} not found in wallet`); + } + wallet._usedGapChangeAddresses.push(usedGapChangeAddressInfo); + } + + // validate the available outpoints and import them wallet.validateOutpointInfo(serialized.outpoints); - wallet._outpoints.push(...serialized.outpoints); + wallet._availableOutpoints.push(...serialized.outpoints); + + // validate the pending outpoints and import them + wallet.validateOutpointInfo(serialized.pendingOutpoints); + wallet._pendingOutpoints.push(...serialized.pendingOutpoints); // validate and import the payment code info wallet.validateAndImportPaymentCodeInfo(serialized.receiverPaymentCodeInfo, 'receiver'); @@ -761,12 +1181,13 @@ export class QiHDWallet extends AbstractHDWallet { /** * Validates and imports a map of payment code info. * - * @param {Map} paymentCodeInfoMap - The map of payment code info to validate and import. + * @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[] }, + paymentCodeInfoMap: { [key: string]: PaymentChannelAddressInfo[] }, target: 'receiver' | 'sender', ): void { const targetMap = target === 'receiver' ? this._receiverPaymentCodeInfo : this._senderPaymentCodeInfo; @@ -785,10 +1206,10 @@ export class QiHDWallet extends AbstractHDWallet { /** * Validates a payment code info object. * - * @param {paymentCodeInfo} pcInfo - The payment code info to validate. + * @param {PaymentChannelAddressInfo} pcInfo - The payment code info to validate. * @throws {Error} If the payment code info is invalid. */ - private validatePaymentCodeInfo(pcInfo: paymentCodeInfo): void { + private validatePaymentCodeInfo(pcInfo: PaymentChannelAddressInfo): 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'); } @@ -823,14 +1244,10 @@ export class QiHDWallet extends AbstractHDWallet { outpointInfo.forEach((info) => { // validate zone this.validateZone(info.zone); + // validate address and account - const addressInfo = this.getAddressInfo(info.address); - if (!addressInfo) { - throw new Error(`Address ${info.address} not found in wallet`); - } - if (info.account !== undefined && info.account !== addressInfo.account) { - throw new Error(`Account ${info.account} not found for address ${info.address}`); - } + this.validateAddressAndAccount(info.address, info.account); + // validate Outpoint if (info.outpoint.txhash == null || info.outpoint.index == null || info.outpoint.denomination == null) { throw new Error(`Invalid Outpoint: ${JSON.stringify(info)} `); @@ -838,6 +1255,16 @@ export class QiHDWallet extends AbstractHDWallet { }); } + private validateAddressAndAccount(address: string, account?: number): void { + const addressInfo = this.locateAddressInfo(address); + if (!addressInfo) { + throw new Error(`Address ${address} not found in wallet`); + } + if (account && account !== addressInfo.account) { + throw new Error(`Address ${address} does not match account ${account}`); + } + } + /** * Creates a new BIP47 payment code for the specified account. The payment code is derived from the account's BIP32 * root key. @@ -851,12 +1278,12 @@ export class QiHDWallet extends AbstractHDWallet { } // helper method to get a bip32 API instance - private async _getBIP32API(): Promise { + private _getBIP32API(): BIP32API { return BIP32Factory(ecc) as BIP32API; } // helper method to decode a base58 string into a Uint8Array - private async _decodeBase58(base58: string): Promise { + private _decodeBase58(base58: string): Uint8Array { return bs58check.decode(base58); } @@ -868,8 +1295,8 @@ export class QiHDWallet extends AbstractHDWallet { * @param {number} account - The account index for which to generate the private payment code. * @returns {Promise} A promise that resolves to the PaymentCodePrivate instance. */ - private async _getPaymentCodePrivate(account: number): Promise { - const bip32 = await this._getBIP32API(); + private _getPaymentCodePrivate(account: number): PaymentCodePrivate { + const bip32 = this._getBIP32API(); const accountNode = this._root.deriveChild(account); @@ -900,16 +1327,16 @@ export class QiHDWallet extends AbstractHDWallet { * @returns {Promise} A promise that resolves to the payment address for sending funds. * @throws {Error} Throws an error if the payment code version is invalid. */ - public async getNextSendAddress(receiverPaymentCode: string, zone: Zone, account: number = 0): Promise { - const bip32 = await this._getBIP32API(); - const buf = await this._decodeBase58(receiverPaymentCode); + public getNextSendAddress(receiverPaymentCode: string, zone: Zone, account: number = 0): PaymentChannelAddressInfo { + const bip32 = this._getBIP32API(); + const buf = this._decodeBase58(receiverPaymentCode); const version = buf[0]; if (version !== PC_VERSION) throw new Error('Invalid payment code version'); - const receiverPCodePrivate = await this._getPaymentCodePrivate(account); + const receiverPCodePrivate = this._getPaymentCodePrivate(account); const senderPCodePublic = new PaymentCodePublic(ecc, bip32, buf.slice(1)); - const paymentCodeInfoArray = this._receiverPaymentCodeInfo.get(receiverPaymentCode); + const paymentCodeInfoArray = this._senderPaymentCodeInfo.get(receiverPaymentCode); const lastIndex = paymentCodeInfoArray && paymentCodeInfoArray.length > 0 ? paymentCodeInfoArray[paymentCodeInfoArray.length - 1].index @@ -919,8 +1346,9 @@ export class QiHDWallet extends AbstractHDWallet { for (let attempts = 0; attempts < MAX_ADDRESS_DERIVATION_ATTEMPTS; attempts++) { const address = senderPCodePublic.getPaymentAddress(receiverPCodePrivate, addrIndex++); if (this.isValidAddressForZone(address, zone)) { - const pcInfo: paymentCodeInfo = { + const pcInfo: PaymentChannelAddressInfo = { address, + pubKey: hexlify(senderPCodePublic.pubKey), index: addrIndex, account, zone, @@ -929,9 +1357,9 @@ export class QiHDWallet extends AbstractHDWallet { if (paymentCodeInfoArray) { paymentCodeInfoArray.push(pcInfo); } else { - this._receiverPaymentCodeInfo.set(receiverPaymentCode, [pcInfo]); + this._senderPaymentCodeInfo.set(receiverPaymentCode, [pcInfo]); } - return address; + return pcInfo; } } @@ -948,16 +1376,20 @@ export class QiHDWallet extends AbstractHDWallet { * @returns {Promise} A promise that resolves to the payment address for receiving funds. * @throws {Error} Throws an error if the payment code version is invalid. */ - public async getNextReceiveAddress(senderPaymentCode: string, zone: Zone, account: number = 0): Promise { - const bip32 = await this._getBIP32API(); - const buf = await this._decodeBase58(senderPaymentCode); + public getNextReceiveAddress( + senderPaymentCode: string, + zone: Zone, + account: number = 0, + ): PaymentChannelAddressInfo { + const bip32 = this._getBIP32API(); + const buf = this._decodeBase58(senderPaymentCode); const version = buf[0]; if (version !== PC_VERSION) throw new Error('Invalid payment code version'); const senderPCodePublic = new PaymentCodePublic(ecc, bip32, buf.slice(1)); - const receiverPCodePrivate = await this._getPaymentCodePrivate(account); + const receiverPCodePrivate = this._getPaymentCodePrivate(account); - const paymentCodeInfoArray = this._senderPaymentCodeInfo.get(senderPaymentCode); + const paymentCodeInfoArray = this._receiverPaymentCodeInfo.get(senderPaymentCode); const lastIndex = paymentCodeInfoArray && paymentCodeInfoArray.length > 0 ? paymentCodeInfoArray[paymentCodeInfoArray.length - 1].index @@ -967,8 +1399,9 @@ export class QiHDWallet extends AbstractHDWallet { for (let attempts = 0; attempts < MAX_ADDRESS_DERIVATION_ATTEMPTS; attempts++) { const address = receiverPCodePrivate.getPaymentAddress(senderPCodePublic, addrIndex++); if (this.isValidAddressForZone(address, zone)) { - const pcInfo: paymentCodeInfo = { + const pcInfo: PaymentChannelAddressInfo = { address, + pubKey: hexlify(receiverPCodePrivate.pubKey), index: addrIndex, account, zone, @@ -977,9 +1410,9 @@ export class QiHDWallet extends AbstractHDWallet { if (paymentCodeInfoArray) { paymentCodeInfoArray.push(pcInfo); } else { - this._senderPaymentCodeInfo.set(senderPaymentCode, [pcInfo]); + this._receiverPaymentCodeInfo.set(senderPaymentCode, [pcInfo]); } - return address; + return pcInfo; } } @@ -1000,15 +1433,27 @@ export class QiHDWallet extends AbstractHDWallet { throw new Error(`Invalid payment code: ${paymentCode}`); } if (type === 'receiver') { - if (this._receiverPaymentCodeInfo.has(paymentCode)) { - return; + if (!this._receiverPaymentCodeInfo.has(paymentCode)) { + this._receiverPaymentCodeInfo.set(paymentCode, []); } - this._receiverPaymentCodeInfo.set(paymentCode, []); } else { - if (this._senderPaymentCodeInfo.has(paymentCode)) { - return; + if (!this._senderPaymentCodeInfo.has(paymentCode)) { + this._senderPaymentCodeInfo.set(paymentCode, []); } - this._senderPaymentCodeInfo.set(paymentCode, []); } } + + /** + * Gets the address info for a given address. + * + * @param {string} address - The address. + * @returns {NeuteredAddressInfo | null} The address info or null if not found. + */ + public getChangeAddressInfo(address: string): NeuteredAddressInfo | null { + const changeAddressInfo = this._changeAddresses.get(address); + if (!changeAddressInfo) { + return null; + } + return changeAddressInfo; + } }