diff --git a/src/_tests/types.ts b/src/_tests/types.ts index 6dcec686..58158de0 100644 --- a/src/_tests/types.ts +++ b/src/_tests/types.ts @@ -277,17 +277,11 @@ export interface AddrParams { account: number; zone: Zone; } -export type TestAddresses = Array<{ - params: AddrParams; - expectedAddress: AddressInfo; -}>; - export interface AddressInfo { pubKey: string; address: string; account: number; index: number; - change: boolean; zone: Zone; } @@ -312,12 +306,6 @@ export interface TestCaseQuaiTransaction { signed: string; } -export interface TestCaseQuaiAddresses { - name: string; - mnemonic: string; - addresses: TestAddresses; -} - export interface TestCaseQuaiTypedData { name: string; mnemonic: string; @@ -336,13 +324,6 @@ export interface TestCaseQuaiMessageSign { signature: string; } -export interface TestCaseQiAddresses { - name: string; - mnemonic: string; - addresses: TestAddresses; - changeAddresses: TestAddresses; -} - export interface TxInput { txhash: string; index: number; diff --git a/src/_tests/unit/qihdwallet-address-derivation.unit.test.ts b/src/_tests/unit/qihdwallet-address-derivation.unit.test.ts index 5060bad9..748eaa02 100644 --- a/src/_tests/unit/qihdwallet-address-derivation.unit.test.ts +++ b/src/_tests/unit/qihdwallet-address-derivation.unit.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import assert from 'assert'; import { loadTests } from '../utils.js'; import { Mnemonic, QiHDWallet, Zone, QiAddressInfo } from '../../index.js'; @@ -111,3 +112,244 @@ describe('QiHDWallet Address Derivation', function () { }); } }); + +describe('QiHDWallet Address Getters', function () { + this.timeout(2 * 60 * 1000); + const tests = loadTests<TestCaseQiAddressDerivation>('qi-address-derivation'); + + for (const test of tests) { + const mnemonic = Mnemonic.fromPhrase(test.mnemonic); + const qiWallet = QiHDWallet.fromMnemonic(mnemonic); + + for (const externalAddressesInfo of test.externalAddresses) { + const zone = externalAddressesInfo.zone as Zone; + for (const _ of externalAddressesInfo.addresses) { + qiWallet.getNextAddressSync(0, zone); + } + } + + for (const changeAddressesInfo of test.changeAddresses) { + const zone = changeAddressesInfo.zone as Zone; + for (const _ of changeAddressesInfo.addresses) { + qiWallet.getNextChangeAddressSync(0, zone); + } + } + + const bobMnemonic = Mnemonic.fromPhrase(test.paymentCodeAddresses.bobMnemonic); + const bobQiWallet = QiHDWallet.fromMnemonic(bobMnemonic); + const bobPaymentCode = bobQiWallet.getPaymentCode(0); + qiWallet.openChannel(bobPaymentCode); + + for (const receiveAddressesInfo of test.paymentCodeAddresses.receiveAddresses) { + const zone = receiveAddressesInfo.zone as Zone; + for (const _ of receiveAddressesInfo.addresses) { + qiWallet.getNextReceiveAddress(bobPaymentCode, zone); + } + } + + it('getAddressInfo returns correct address info', function () { + for (const externalAddressesInfo of test.externalAddresses) { + for (const expectedAddressInfo of externalAddressesInfo.addresses) { + const addressInfo = qiWallet.getAddressInfo(expectedAddressInfo.address); + assert.deepEqual( + addressInfo, + expectedAddressInfo, + `External address info mismatch for address ${expectedAddressInfo.address} (got ${JSON.stringify(addressInfo)}, expected ${JSON.stringify(expectedAddressInfo)})`, + ); + } + } + }); + + it('getChangeAddressInfo returns correct address info', function () { + for (const changeAddressesInfo of test.changeAddresses) { + for (const expectedAddressInfo of changeAddressesInfo.addresses) { + const addressInfo = qiWallet.getChangeAddressInfo(expectedAddressInfo.address); + assert.deepEqual( + addressInfo, + expectedAddressInfo, + `Change address info mismatch for address ${expectedAddressInfo.address} (got ${JSON.stringify(addressInfo)}, expected ${JSON.stringify(expectedAddressInfo)})`, + ); + } + } + }); + + it('getAddressesForZone returns all addresses for specified zone', function () { + for (const externalAddressesInfo of test.externalAddresses) { + const zone = externalAddressesInfo.zone as Zone; + const addresses = qiWallet.getAddressesForZone(zone); + assert.deepEqual( + addresses, + externalAddressesInfo.addresses, + `External addresses mismatch for zone ${zone} (got ${JSON.stringify(addresses)}, expected ${JSON.stringify(externalAddressesInfo.addresses)})`, + ); + } + }); + + it('getChangeAddressesForZone returns all change addresses for specified zone', function () { + for (const changeAddressesInfo of test.changeAddresses) { + const zone = changeAddressesInfo.zone as Zone; + const addresses = qiWallet.getChangeAddressesForZone(zone); + assert.deepEqual( + addresses, + changeAddressesInfo.addresses, + `Change addresses mismatch for zone ${zone} (got ${JSON.stringify(addresses)}, expected ${JSON.stringify(changeAddressesInfo.addresses)})`, + ); + } + }); + + it('getPaymentChannelAddressesForZone returns correct addresses', function () { + for (const receiveAddressesInfo of test.paymentCodeAddresses.receiveAddresses) { + const zone = receiveAddressesInfo.zone as Zone; + const addresses = qiWallet.getPaymentChannelAddressesForZone(bobPaymentCode, zone); + assert.deepEqual( + addresses, + receiveAddressesInfo.addresses, + `Payment channel addresses mismatch for zone ${zone} (got ${JSON.stringify(addresses)}, expected ${JSON.stringify(receiveAddressesInfo.addresses)})`, + ); + } + }); + + it('getAddressesForAccount returns all addresses for specified account', function () { + // Test for account 0 (the one used in test data) + const allAddresses = [ + ...test.externalAddresses.flatMap((info) => info.addresses), + ...test.changeAddresses.flatMap((info) => info.addresses), + ...test.paymentCodeAddresses.receiveAddresses.flatMap((info) => info.addresses), + ].filter((addr) => addr.account === 0); + + const addresses = qiWallet.getAddressesForAccount(0); + assert.deepEqual( + addresses, + allAddresses, + `Addresses mismatch for account 0 (got ${JSON.stringify(addresses)}, expected ${JSON.stringify(allAddresses)})`, + ); + }); + + it('returns empty arrays for non-existent zones and accounts', function () { + const nonExistentZone = '0x22' as Zone; + const nonExistentAccount = 999; + + assert.deepEqual(qiWallet.getAddressesForZone(nonExistentZone), []); + assert.deepEqual(qiWallet.getChangeAddressesForZone(nonExistentZone), []); + assert.deepEqual(qiWallet.getPaymentChannelAddressesForZone(bobPaymentCode, nonExistentZone), []); + assert.deepEqual(qiWallet.getAddressesForAccount(nonExistentAccount), []); + }); + } +}); + +describe('Basic Address Management', function () { + const tests = loadTests<TestCaseQiAddressDerivation>('qi-address-derivation'); + + for (const test of tests) { + const mnemonic = Mnemonic.fromPhrase(test.mnemonic); + const qiWallet = QiHDWallet.fromMnemonic(mnemonic); + + it('should add external addresses correctly', function () { + // Test with addresses from the first zone in test data + const zoneAddresses = test.externalAddresses[0].addresses; + const firstAddress = zoneAddresses[0]; + + // Add address using the same account and index from test data + const addedAddress = qiWallet.addAddress(firstAddress.account, firstAddress.index); + + assert.deepEqual( + addedAddress, + firstAddress, + `Added address does not match expected address for index ${firstAddress.index}`, + ); + + // Verify the address was added correctly by retrieving it + const retrievedAddress = qiWallet.getAddressInfo(firstAddress.address); + assert.deepEqual(retrievedAddress, firstAddress, 'Retrieved address does not match added address'); + + // Test adding same address index again should throw error + assert.throws( + () => qiWallet.addAddress(firstAddress.account, firstAddress.index), + Error, + `Address index ${firstAddress.index} already exists in wallet under path BIP44:external`, + ); + }); + + it('should add change addresses correctly', function () { + // Test with change addresses from the first zone in test data + const zoneChangeAddresses = test.changeAddresses[0].addresses; + const firstChangeAddress = zoneChangeAddresses[0]; + + // Add change address using the same account and index from test data + const addedChangeAddress = qiWallet.addChangeAddress(firstChangeAddress.account, firstChangeAddress.index); + + assert.deepEqual( + addedChangeAddress, + firstChangeAddress, + `Added change address does not match expected address for index ${firstChangeAddress.index}`, + ); + + // Verify the change address was added correctly by retrieving it + const retrievedChangeAddress = qiWallet.getChangeAddressInfo(firstChangeAddress.address); + assert.deepEqual( + retrievedChangeAddress, + firstChangeAddress, + 'Retrieved change address does not match added change address', + ); + + // Test adding same change address index again should throw error + assert.throws( + () => qiWallet.addChangeAddress(firstChangeAddress.account, firstChangeAddress.index), + Error, + `Address index ${firstChangeAddress.index} already exists in wallet under path BIP44:change`, + ); + }); + + it('should handle invalid indices correctly', function () { + // Test with negative index + assert.throws(() => qiWallet.addAddress(0, -1), Error, 'Negative index should throw error'); + + assert.throws(() => qiWallet.addChangeAddress(0, -1), Error, 'Negative index should throw error'); + }); + + it('should handle invalid accounts correctly', function () { + // Test with negative account + assert.throws(() => qiWallet.addAddress(-1, 0), Error, 'Negative account should throw error'); + + assert.throws(() => qiWallet.addChangeAddress(-1, 0), Error, 'Negative account should throw error'); + }); + + it('should reject indices that derive invalid addresses', function () { + // For Cyprus1 (0x00) and account 0: + // - Index 384 derives an invalid address (wrong zone or ledger) + // - Index 385 derives the first valid external address + // - Index 4 derives an invalid change address + // - Index 5 derives the first valid change address + + // Test invalid external address index + assert.throws( + () => qiWallet.addAddress(0, 384), + Error, + 'Failed to derive a Qi valid address for the zone 0x00', + ); + + // Test invalid change address index + assert.throws( + () => qiWallet.addChangeAddress(0, 4), + Error, + 'Failed to derive a Qi valid address for the zone 0x00', + ); + }); + + it('should reject indices that derive duplicate addresses', function () { + // Test that adding an existing address index throws error + assert.throws( + () => qiWallet.addAddress(0, 385), + Error, + 'Address index 385 already exists in wallet under path BIP44:external', + ); + + // Test that adding an existing change address index throws error + assert.throws( + () => qiWallet.addChangeAddress(0, 5), + Error, + 'Address index 5 already exists in wallet under path BIP44:change', + ); + }); + } +}); diff --git a/src/_tests/unit/qihdwallet-import-privkey.unit.test.ts b/src/_tests/unit/qihdwallet-import-privkey.unit.test.ts new file mode 100644 index 00000000..725b0c58 --- /dev/null +++ b/src/_tests/unit/qihdwallet-import-privkey.unit.test.ts @@ -0,0 +1,151 @@ +import assert from 'assert'; +import { loadTests } from '../utils.js'; +import { QiHDWallet, Mnemonic, Zone } from '../../index.js'; + +interface TestCaseImportPrivKey { + shouldSucceed: boolean; + privateKey: string; + error?: string; + pubKey?: string; + address?: string; + zone?: string; +} + +describe('QiHDWallet Import Private Key', function () { + const tests = loadTests<TestCaseImportPrivKey>('qi-wallet-import-privkey'); + + let wallet: QiHDWallet; + + beforeEach(function () { + const mnemonic = Mnemonic.fromPhrase('test test test test test test test test test test test junk'); + wallet = QiHDWallet.fromMnemonic(mnemonic); + }); + + for (const test of tests) { + if (test.shouldSucceed) { + it(`should successfully import private key ${test.privateKey}`, async function () { + const addressInfo = await wallet.importPrivateKey(test.privateKey); + + assert.strictEqual( + addressInfo.pubKey, + test.pubKey, + `Public key mismatch, expected: ${test.pubKey}, got: ${addressInfo.pubKey}`, + ); + + assert.strictEqual( + addressInfo.address, + test.address, + `Address mismatch, expected: ${test.address}, got: ${addressInfo.address}`, + ); + + assert.strictEqual( + addressInfo.zone, + test.zone, + `Zone mismatch, expected: ${test.zone}, got: ${addressInfo.zone}`, + ); + + assert.strictEqual( + addressInfo.derivationPath, + test.privateKey, + 'Private key should be stored in derivationPath', + ); + }); + } else { + it(`should fail to import invalid private key ${test.privateKey}`, async function () { + await assert.rejects( + async () => { + await wallet.importPrivateKey(test.privateKey); + }, + (error: Error) => { + assert.ok( + error.message.includes(test.error!), + `Expected error message to include "${test.error}", got "${error.message}"`, + ); + return true; + }, + ); + }); + } + } + + it('should prevent duplicate imports of the same private key', async function () { + const validPrivateKey = tests.find((t) => t.shouldSucceed)!.privateKey; + + // First import should succeed + await wallet.importPrivateKey(validPrivateKey); + + // Second import should fail + await assert.rejects( + async () => { + await wallet.importPrivateKey(validPrivateKey); + }, + (error: Error) => { + assert.ok( + error.message.includes('already exists in wallet'), + 'Expected error message to indicate duplicate address', + ); + return true; + }, + ); + }); + + it('should return all imported addresses when no zone specified', async function () { + const validTests = tests.filter((t) => t.shouldSucceed); + for (const test of validTests) { + await wallet.importPrivateKey(test.privateKey); + } + + const importedAddresses = wallet.getImportedAddresses(); + + assert.strictEqual(importedAddresses.length, validTests.length, 'Should return all imported addresses'); + + for (let i = 0; i < validTests.length; i++) { + assert.strictEqual( + importedAddresses[i].address, + validTests[i].address, + 'Imported address should match test data', + ); + } + }); + + it('should return only addresses for specified zone', async function () { + const validTests = tests.filter((t) => t.shouldSucceed); + for (const test of validTests) { + await wallet.importPrivateKey(test.privateKey); + } + + const testZone = validTests[0].zone; + const zoneAddresses = wallet.getImportedAddresses(testZone as Zone); + + const expectedAddresses = validTests.filter((t) => t.zone === testZone); + + assert.strictEqual( + zoneAddresses.length, + expectedAddresses.length, + `Should return only addresses for zone ${testZone}`, + ); + + for (let i = 0; i < expectedAddresses.length; i++) { + assert.strictEqual( + zoneAddresses[i].address, + expectedAddresses[i].address, + 'Zone-filtered address should match test data', + ); + } + }); + + it('should return empty array when no addresses imported', function () { + const addresses = wallet.getImportedAddresses(); + assert.deepStrictEqual(addresses, [], 'Should return empty array when no addresses imported'); + }); + + it('should return empty array when no addresses in specified zone', async function () { + const validTest = tests.find((t) => t.shouldSucceed)!; + await wallet.importPrivateKey(validTest.privateKey); + + const differentZone = '0x22'; + const addresses = wallet.getImportedAddresses(differentZone as Zone); + + assert.deepStrictEqual(addresses, [], 'Should return empty array when no addresses in specified zone'); + }); +}); diff --git a/src/_tests/unit/qihdwallet.unit.test.ts b/src/_tests/unit/qihdwallet.unit.test.ts index 3582a5a1..9a7dbf3e 100644 --- a/src/_tests/unit/qihdwallet.unit.test.ts +++ b/src/_tests/unit/qihdwallet.unit.test.ts @@ -1,71 +1,13 @@ import assert from 'assert'; -import { loadTests, convertToZone } from '../utils.js'; +import { loadTests } from '../utils.js'; import { schnorr } from '@noble/curves/secp256k1'; import { keccak_256 } from '@noble/hashes/sha3'; import { MuSigFactory } from '@brandonblack/musig'; -import { - TestCaseQiAddresses, - TestCaseQiSignMessage, - TestCaseQiTransaction, - AddressInfo, - TxInput, - TxOutput, - Zone, -} from '../types.js'; +import { TestCaseQiSignMessage, TestCaseQiTransaction, TxInput, TxOutput, Zone } from '../types.js'; import { Mnemonic, QiHDWallet, QiTransaction, getBytes, hexlify, musigCrypto } from '../../index.js'; -describe('QiHDWallet: Test address generation and retrieval', function () { - const tests = loadTests<TestCaseQiAddresses>('qi-addresses'); - for (const test of tests) { - const mnemonic = Mnemonic.fromPhrase(test.mnemonic); - const qiWallet = QiHDWallet.fromMnemonic(mnemonic); - it(`tests addresses generation and retrieval: ${test.name}`, function () { - const generatedAddresses: AddressInfo[] = []; - for (const { params, expectedAddress } of test.addresses) { - const addrInfo = qiWallet.getNextAddressSync(params.account, params.zone); - assert.deepEqual(addrInfo, expectedAddress); - generatedAddresses.push(addrInfo); - - const retrievedAddrInfo = qiWallet.getAddressInfo(expectedAddress.address); - assert.deepEqual(retrievedAddrInfo, expectedAddress); - - const accountMap = new Map<number, AddressInfo[]>(); - for (const addrInfo of generatedAddresses) { - if (!accountMap.has(addrInfo.account)) { - accountMap.set(addrInfo.account, []); - } - accountMap.get(addrInfo.account)!.push(addrInfo); - } - for (const [account, expectedAddresses] of accountMap) { - const retrievedAddresses = qiWallet.getAddressesForAccount(account); - assert.deepEqual(retrievedAddresses, expectedAddresses); - } - - const zoneMap = new Map<string, AddressInfo[]>(); - for (const addrInfo of generatedAddresses) { - if (!zoneMap.has(addrInfo.zone)) { - zoneMap.set(addrInfo.zone, []); - } - zoneMap.get(addrInfo.zone)!.push(addrInfo); - } - for (const [zone, expectedAddresses] of zoneMap) { - const zoneEnum = convertToZone(zone); - const retrievedAddresses = qiWallet.getAddressesForZone(zoneEnum); - assert.deepEqual(retrievedAddresses, expectedAddresses); - } - } - }); - it(`tests change addresses generation and retrieval: ${test.name}`, function () { - for (const { params, expectedAddress } of test.changeAddresses) { - const addrInfo = qiWallet.getNextChangeAddressSync(params.account, params.zone); - assert.deepEqual(addrInfo, expectedAddress); - } - }); - } -}); - describe('QiHDWallet: Test transaction signing', function () { const tests = loadTests<TestCaseQiTransaction>('qi-transaction'); for (const test of tests) { diff --git a/src/_tests/unit/quaihdwallet-address-derivation.unit.test.ts b/src/_tests/unit/quaihdwallet-address-derivation.unit.test.ts new file mode 100644 index 00000000..d2266554 --- /dev/null +++ b/src/_tests/unit/quaihdwallet-address-derivation.unit.test.ts @@ -0,0 +1,173 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import assert from 'assert'; +import { loadTests } from '../utils.js'; +import { Mnemonic, QuaiHDWallet, Zone, NeuteredAddressInfo } from '../../index.js'; + +interface TestCaseQuaiAddressDerivation { + mnemonic: string; + addresses: Array<{ + zone: string; + account: number; + addresses: Array<NeuteredAddressInfo>; + }>; +} + +describe('QuaiHDWallet Address Derivation', function () { + this.timeout(2 * 60 * 1000); + const tests = loadTests<TestCaseQuaiAddressDerivation>('quai-address-derivation'); + + for (const test of tests) { + it('derives addresses correctly', function () { + const mnemonic = Mnemonic.fromPhrase(test.mnemonic); + const quaiWallet = QuaiHDWallet.fromMnemonic(mnemonic); + + for (const addressesInfo of test.addresses) { + const zone = addressesInfo.zone as Zone; + const account = addressesInfo.account; + + for (const expectedAddressInfo of addressesInfo.addresses) { + const derivedAddressInfo = quaiWallet.getNextAddressSync(account, zone); + assert.deepEqual( + derivedAddressInfo, + expectedAddressInfo, + `Address mismatch for zone ${zone}, account ${account}, expected: ${JSON.stringify(expectedAddressInfo)}, derived: ${JSON.stringify(derivedAddressInfo)}`, + ); + } + } + }); + } +}); + +describe('QuaiHDWallet Address Getters', function () { + this.timeout(2 * 60 * 1000); + const tests = loadTests<TestCaseQuaiAddressDerivation>('quai-address-derivation'); + + for (const test of tests) { + const mnemonic = Mnemonic.fromPhrase(test.mnemonic); + const quaiWallet = QuaiHDWallet.fromMnemonic(mnemonic); + + // Generate all addresses first + for (const addressesInfo of test.addresses) { + const zone = addressesInfo.zone as Zone; + const account = addressesInfo.account; + for (const _ of addressesInfo.addresses) { + quaiWallet.getNextAddressSync(account, zone); + } + } + + it('getAddressInfo returns correct address info', function () { + for (const addressesInfo of test.addresses) { + for (const expectedAddressInfo of addressesInfo.addresses) { + const addressInfo = quaiWallet.getAddressInfo(expectedAddressInfo.address); + assert.deepEqual( + addressInfo, + expectedAddressInfo, + `Address info mismatch for address ${expectedAddressInfo.address}`, + ); + } + } + }); + + it('getAddressesForZone returns all addresses for specified zone', function () { + for (const addressesInfo of test.addresses) { + const zone = addressesInfo.zone as Zone; + const addresses = quaiWallet.getAddressesForZone(zone); + const expectedAddresses = test.addresses + .filter((info) => info.zone === zone) + .flatMap((info) => info.addresses); + + assert.deepEqual(addresses, expectedAddresses, `Addresses mismatch for zone ${zone}`); + } + }); + + it('getAddressesForAccount returns all addresses for specified account', function () { + const accountMap = new Map<number, NeuteredAddressInfo[]>(); + + // Group expected addresses by account + for (const addressesInfo of test.addresses) { + const account = addressesInfo.account; + if (!accountMap.has(account)) { + accountMap.set(account, []); + } + accountMap.get(account)!.push(...addressesInfo.addresses); + } + + // Test each account + for (const [account, expectedAddresses] of accountMap) { + const addresses = quaiWallet.getAddressesForAccount(account); + assert.deepEqual(addresses, expectedAddresses, `Addresses mismatch for account ${account}`); + } + }); + + it('returns empty arrays for non-existent zones and accounts', function () { + const nonExistentZone = '0x22' as Zone; + const nonExistentAccount = 999; + + assert.deepEqual(quaiWallet.getAddressesForZone(nonExistentZone), []); + assert.deepEqual(quaiWallet.getAddressesForAccount(nonExistentAccount), []); + }); + } +}); + +describe('Basic Address Management', function () { + const tests = loadTests<TestCaseQuaiAddressDerivation>('quai-address-derivation'); + + for (const test of tests) { + const mnemonic = Mnemonic.fromPhrase(test.mnemonic); + const quaiWallet = QuaiHDWallet.fromMnemonic(mnemonic); + + it('should add addresses correctly', function () { + // Test with addresses from the first zone/account in test data + const firstZoneAddresses = test.addresses[0].addresses; + const firstAddress = firstZoneAddresses[0]; + + // Add address using the same account and index from test data + const addedAddress = quaiWallet.addAddress(firstAddress.account, firstAddress.index); + + assert.deepEqual( + addedAddress, + firstAddress, + `Added address does not match expected address for index ${firstAddress.index}`, + ); + + // Verify the address was added correctly by retrieving it + const retrievedAddress = quaiWallet.getAddressInfo(firstAddress.address); + assert.deepEqual(retrievedAddress, firstAddress, 'Retrieved address does not match added address'); + + // Test adding same address index again should throw error + assert.throws( + () => quaiWallet.addAddress(firstAddress.account, firstAddress.index), + Error, + `Address for index ${firstAddress.index} already exists`, + ); + }); + + it('should handle invalid indices correctly', function () { + // Test with negative index + assert.throws(() => quaiWallet.addAddress(0, -1), Error, 'Negative index should throw error'); + }); + + it('should handle invalid accounts correctly', function () { + // Test with negative account + assert.throws(() => quaiWallet.addAddress(-1, 0), Error, 'Negative account should throw error'); + }); + + it('should reject indices that derive invalid addresses', function () { + // For Cyprus1 (0x00) and account 0: + // Index 518 derives an invalid address (wrong zone or ledger) + // Index 519 derives the first valid address + + // Test invalid address index + assert.throws( + () => quaiWallet.addAddress(0, 518), + Error, + 'Failed to derive a valid address zone for the index 518', + ); + }); + + it('should reject indices that derive duplicate addresses', function () { + // Test that adding an existing address index throws error + assert.throws(() => quaiWallet.addAddress(0, 519), Error, 'Address for index 519 already exists'); + }); + } +}); diff --git a/src/_tests/unit/quaihdwallet.unit.test.ts b/src/_tests/unit/quaihdwallet.unit.test.ts index 88c36c17..3d0afcea 100644 --- a/src/_tests/unit/quaihdwallet.unit.test.ts +++ b/src/_tests/unit/quaihdwallet.unit.test.ts @@ -1,13 +1,11 @@ import assert from 'assert'; -import { loadTests, convertToZone } from '../utils.js'; +import { loadTests } from '../utils.js'; import { TestCaseQuaiTransaction, TestCaseQuaiSerialization, - TestCaseQuaiAddresses, TestCaseQuaiTypedData, - AddressInfo, Zone, TestCaseQuaiMessageSign, } from '../types.js'; @@ -16,50 +14,6 @@ import { recoverAddress } from '../../index.js'; import { Mnemonic, QuaiHDWallet } from '../../index.js'; -describe('Test address generation and retrieval', function () { - const tests = loadTests<TestCaseQuaiAddresses>('quai-addresses'); - for (const test of tests) { - const mnemonic = Mnemonic.fromPhrase(test.mnemonic); - const quaiWallet = QuaiHDWallet.fromMnemonic(mnemonic); - it(`tests addresses generation and retrieval: ${test.name}`, function () { - const generatedAddresses: AddressInfo[] = []; - for (const { params, expectedAddress } of test.addresses) { - const addrInfo = quaiWallet.getNextAddressSync(params.account, params.zone); - assert.deepEqual(addrInfo, expectedAddress); - generatedAddresses.push(addrInfo); - - const retrievedAddrInfo = quaiWallet.getAddressInfo(expectedAddress.address); - assert.deepEqual(retrievedAddrInfo, expectedAddress); - - const accountMap = new Map<number, AddressInfo[]>(); - for (const addrInfo of generatedAddresses) { - if (!accountMap.has(addrInfo.account)) { - accountMap.set(addrInfo.account, []); - } - accountMap.get(addrInfo.account)!.push(addrInfo); - } - for (const [account, expectedAddresses] of accountMap) { - const retrievedAddresses = quaiWallet.getAddressesForAccount(account); - assert.deepEqual(retrievedAddresses, expectedAddresses); - } - - const zoneMap = new Map<string, AddressInfo[]>(); - for (const addrInfo of generatedAddresses) { - if (!zoneMap.has(addrInfo.zone)) { - zoneMap.set(addrInfo.zone, []); - } - zoneMap.get(addrInfo.zone)!.push(addrInfo); - } - for (const [zone, expectedAddresses] of zoneMap) { - const zoneEnum = convertToZone(zone); - const retrievedAddresses = quaiWallet.getAddressesForZone(zoneEnum); - assert.deepEqual(retrievedAddresses, expectedAddresses); - } - } - }); - } -}); - describe('Test transaction signing', function () { const tests = loadTests<TestCaseQuaiTransaction>('quai-transaction'); for (const test of tests) { diff --git a/src/wallet/hdwallet.ts b/src/wallet/hdwallet.ts index 6937c3bd..526a5fe9 100644 --- a/src/wallet/hdwallet.ts +++ b/src/wallet/hdwallet.ts @@ -19,7 +19,6 @@ export interface NeuteredAddressInfo { address: string; account: number; index: number; - change: boolean; zone: Zone; } @@ -42,7 +41,7 @@ export const _guard = {}; /** * Abstract class representing a Hierarchical Deterministic (HD) wallet. */ -export abstract class AbstractHDWallet { +export abstract class AbstractHDWallet<T extends NeuteredAddressInfo = NeuteredAddressInfo> { protected static _version: number = 1; protected static _coinType?: AllowedCoinType; @@ -142,146 +141,92 @@ export abstract class AbstractHDWallet { } /** - * Adds an address to the wallet. + * Adds an address to the wallet for a given account and address index. * - * @param {number} account - The account number. - * @param {number} addressIndex - The address index. - * @param {boolean} [isChange=false] - Whether the address is a change address. Default is `false` - * @returns {NeuteredAddressInfo} The added address info. + * @param {number} account - The account number to add the address to + * @param {number} addressIndex - The index of the address to add + * @returns {T | null} The address info object if successful, null otherwise */ - public addAddress(account: number, addressIndex: number, isChange: boolean = false): NeuteredAddressInfo { - return this._addAddress(this._addresses, account, addressIndex, isChange); - } + abstract addAddress(account: number, addressIndex: number): T | null; /** - * Helper method to add an address to the wallet address map. + * Gets the next available address for a given account and zone. * - * @param {Map<string, NeuteredAddressInfo>} addressMap - The address map. - * @param {number} account - The account number. - * @param {number} addressIndex - The address index. - * @param {boolean} [isChange=false] - Whether the address is a change address. Default is `false` - * @returns {NeuteredAddressInfo} The added address info. - * @throws {Error} If the address for the index already exists. + * @param {number} account - The account number to get the next address for + * @param {Zone} zone - The zone to get the next address in + * @returns {Promise<T>} Promise that resolves to the next address info */ - protected _addAddress( - addressMap: Map<string, NeuteredAddressInfo>, - account: number, - addressIndex: number, - isChange: boolean = false, - ): NeuteredAddressInfo { - // check if address already exists for the index - this._addresses.forEach((addressInfo) => { - if (addressInfo.index === addressIndex) { - throw new Error(`Address for index ${addressIndex} already exists`); - } - }); - - // derive the address node and validate the zone - const changeIndex = isChange ? 1 : 0; - const addressNode = this._root - .deriveChild(account + HARDENED_OFFSET) - .deriveChild(changeIndex) - .deriveChild(addressIndex); - const zone = getZoneForAddress(addressNode.address); - if (!zone) { - throw new Error(`Failed to derive a valid address zone for the index ${addressIndex}`); - } + abstract getNextAddress(account: number, zone: Zone): Promise<T>; - return this.createAndStoreAddressInfo(addressNode, account, zone, isChange, addressMap); - } + /** + * Synchronously gets the next available address for a given account and zone. + * + * @param {number} account - The account number to get the next address for + * @param {Zone} zone - The zone to get the next address in + * @returns {T} The next address info + */ + abstract getNextAddressSync(account: number, zone: Zone): T; /** - * Promise that resolves to the next address for the specified account and zone. + * Gets the address info for a given address string. * - * @param {number} account - The index of the account for which to retrieve the next address. - * @param {Zone} zone - The zone in which to retrieve the next address. - * @returns {Promise<NeuteredAddressInfo>} The next neutered address information. + * @param {string} address - The address to get info for + * @returns {T | null} The address info if found, null otherwise */ - public async getNextAddress(account: number, zone: Zone): Promise<NeuteredAddressInfo> { - return Promise.resolve(this._getNextAddress(account, zone, false, this._addresses)); - } + abstract getAddressInfo(address: string): T | null; /** - * Synchronously retrieves the next address for the specified account and zone. + * Gets the private key for a given address. * - * @param {number} account - The index of the account for which to retrieve the next address. - * @param {Zone} zone - The zone in which to retrieve the next address. - * @returns {NeuteredAddressInfo} The next neutered address information. + * @param {string} address - The address to get the private key for + * @returns {string} The private key as a hex string */ - public getNextAddressSync(account: number, zone: Zone): NeuteredAddressInfo { - return this._getNextAddress(account, zone, false, this._addresses); - } + abstract getPrivateKey(address: string): string; /** - * Derives and returns the next address information for the specified account and zone. + * Gets all addresses belonging to a specific zone. * - * @param {number} accountIndex - The index of the account for which the address is being generated. - * @param {Zone} zone - The zone in which the address is to be used. - * @param {boolean} isChange - A flag indicating whether the address is a change address. - * @param {Map<string, NeuteredAddressInfo>} addressMap - A map storing the neutered address information. - * @returns {NeuteredAddressInfo} The derived neutered address information. - * @throws {Error} If the zone is invalid. + * @param {Zone} zone - The zone to get addresses for + * @returns {T[]} Array of address info objects in the zone */ - protected _getNextAddress( - accountIndex: number, - zone: Zone, - isChange: boolean, - addressMap: Map<string, NeuteredAddressInfo>, - ): NeuteredAddressInfo { - this.validateZone(zone); - const lastIndex = this.getLastAddressIndex(addressMap, zone, accountIndex, isChange); - const addressNode = this.deriveNextAddressNode(accountIndex, lastIndex + 1, zone, isChange); - return this.createAndStoreAddressInfo(addressNode, accountIndex, zone, isChange, addressMap); - } + abstract getAddressesForZone(zone: Zone): T[]; /** - * Gets the address info for a given address. + * Gets all addresses belonging to a specific account. * - * @param {string} address - The address. - * @returns {NeuteredAddressInfo | null} The address info or null if not found. + * @param {number} account - The account number to get addresses for + * @returns {T[]} Array of address info objects in the account */ - public getAddressInfo(address: string): NeuteredAddressInfo | null { - const addressInfo = this._addresses.get(address); - if (!addressInfo) { - return null; - } - return addressInfo; - } + abstract getAddressesForAccount(account: number): T[]; /** - * Returns the private key for a given address. This method should be used with caution as it exposes the private - * key to the user. + * Finds the highest used index for a given account and zone. * - * @param {string} address - The address associated with the desired private key. - * @returns {string} The private key. + * @param {T[] | undefined} addresses - Array of address info objects to search + * @param {number} account - The account number to find the last index for + * @param {Zone} zone - The zone to find the last index in + * @returns {number} The highest used index, or -1 if none found + * @protected */ - public getPrivateKey(address: string): string { - const hdNode = this._getHDNodeForAddress(address); - return hdNode.privateKey; - } + protected abstract _findLastUsedIndex(addresses: T[] | undefined, account: number, zone: Zone): number; /** - * Gets the addresses for a given account. + * Abstract method to sign a message using the private key associated with the given address. * - * @param {number} account - The account number. - * @returns {NeuteredAddressInfo[]} The addresses for the account. + * @param {string} address - The address for which the message is to be signed. + * @param {string | Uint8Array} message - The message to be signed, either as a string or Uint8Array. + * @returns {Promise<string>} A promise that resolves to the signature of the message in hexadecimal string format. + * @throws {Error} If the method is not implemented in the subclass. */ - public getAddressesForAccount(account: number): NeuteredAddressInfo[] { - const addresses = this._addresses.values(); - return Array.from(addresses).filter((addressInfo) => addressInfo.account === account); - } + abstract signMessage(address: string, message: string | Uint8Array): Promise<string>; /** - * Gets the addresses for a given zone. + * Abstract method to sign a transaction. * - * @param {Zone} zone - The zone. - * @returns {NeuteredAddressInfo[]} The addresses for the zone. + * @param {TransactionRequest} tx - The transaction request. + * @returns {Promise<string>} A promise that resolves to the signed transaction. */ - public getAddressesForZone(zone: Zone): NeuteredAddressInfo[] { - this.validateZone(zone); - const addresses = this._addresses.values(); - return Array.from(addresses).filter((addressInfo) => addressInfo.zone === zone); - } + abstract signTransaction(tx: TransactionRequest): Promise<string>; /** * Creates an instance of the HD wallet. @@ -361,22 +306,6 @@ export abstract class AbstractHDWallet { return (this as any).createInstance(mnemonic); } - /** - * Abstract method to sign a transaction. - * - * @param {TransactionRequest} tx - The transaction request. - * @returns {Promise<string>} A promise that resolves to the signed transaction. - */ - abstract signTransaction(tx: TransactionRequest): Promise<string>; - - // /** - // * Abstract method to send a transaction. - // * - // * @param {TransactionRequest} tx - The transaction request. - // * @returns {Promise<TransactionResponse>} A promise that resolves to the transaction response. - // */ - // abstract sendTransaction(tx: TransactionRequest): Promise<TransactionResponse>; - /** * Connects the wallet to a provider. * @@ -398,41 +327,6 @@ export abstract class AbstractHDWallet { } } - /** - * Derives and returns the Hierarchical Deterministic (HD) node wallet associated with a given address. - * - * This method fetches the account and address information from the wallet's internal storage, derives the - * appropriate change node based on whether the address is a change address, and further derives the final HD node - * using the address index. - * - * @param {string} addr - The address for which to derive the HD node. - * @returns {HDNodeWallet} The derived HD node wallet corresponding to the given address. - * @throws {Error} If the given address is not known to the wallet. - * @throws {Error} If the account associated with the address is not found. - */ - protected _getHDNodeForAddress(addr: string): HDNodeWallet { - const addressInfo = this._addresses.get(addr); - if (!addressInfo) { - throw new Error(`Address ${addr} is not known to this wallet`); - } - - const changeIndex = addressInfo.change ? 1 : 0; - return this._root - .deriveChild(addressInfo.account + HARDENED_OFFSET) - .deriveChild(changeIndex) - .deriveChild(addressInfo.index); - } - - /** - * Abstract method to sign a message using the private key associated with the given address. - * - * @param {string} address - The address for which the message is to be signed. - * @param {string | Uint8Array} message - The message to be signed, either as a string or Uint8Array. - * @returns {Promise<string>} A promise that resolves to the signature of the message in hexadecimal string format. - * @throws {Error} If the method is not implemented in the subclass. - */ - abstract signMessage(address: string, message: string | Uint8Array): Promise<string>; - /** * Serializes the HD wallet state into a format suitable for storage or transmission. * @@ -488,10 +382,6 @@ export abstract class AbstractHDWallet { throw new Error(`Invalid NeuteredAddressInfo: index must be a non-negative integer: ${info.index}`); } - if (typeof info.change !== 'boolean') { - throw new Error(`Invalid NeuteredAddressInfo: change must be a boolean: ${info.change}`); - } - if (!Object.values(Zone).includes(info.zone)) { throw new Error(`Invalid NeuteredAddressInfo: zone '${info.zone}' is not a valid Zone`); } @@ -513,103 +403,4 @@ export abstract class AbstractHDWallet { throw new Error(`Invalid coinType ${serialized.coinType} for wallet (expected ${(this as any)._coinType})`); } } - - /** - * Imports addresses from a serialized wallet into the addresses map. Before adding the addresses, a validation is - * performed to ensure the address, public key, and zone match the expected values. - * - * @param {Map<string, NeuteredAddressInfo>} addressMap - The map where the addresses will be imported. - * @param {NeuteredAddressInfo[]} addresses - The array of addresses to be imported, each containing account, index, - * change, address, pubKey, and zone information. - * @throws {Error} If there is a mismatch between the expected and actual address, public key, or zone. - * @protected - */ - protected importSerializedAddresses( - addressMap: Map<string, NeuteredAddressInfo>, - addresses: NeuteredAddressInfo[], - ): void { - for (const addressInfo of addresses) { - const newAddressInfo = this._addAddress( - addressMap, - addressInfo.account, - addressInfo.index, - addressInfo.change, - ); - // validate the address info - if (addressInfo.address !== newAddressInfo.address) { - throw new Error(`Address mismatch: ${addressInfo.address} != ${newAddressInfo.address}`); - } - if (addressInfo.pubKey !== newAddressInfo.pubKey) { - throw new Error(`Public key mismatch: ${addressInfo.pubKey} != ${newAddressInfo.pubKey}`); - } - if (addressInfo.zone !== newAddressInfo.zone) { - throw new Error(`Zone mismatch: ${addressInfo.zone} != ${newAddressInfo.zone}`); - } - } - } - - /** - * Retrieves the highest address index from the given address map for a specified zone, account, and change type. - * - * This method filters the address map based on the provided zone, account, and change type, then determines the - * maximum address index from the filtered addresses. - * - * @param {Map<string, NeuteredAddressInfo>} addressMap - The map containing address information, where the key is - * an address string and the value is a NeuteredAddressInfo object. - * @param {Zone} zone - The specific zone to filter the addresses by. - * @param {number} account - The account number to filter the addresses by. - * @param {boolean} isChange - A boolean indicating whether to filter for change addresses (true) or receiving - * addresses (false). - * @returns {number} - The highest address index for the specified criteria, or -1 if no addresses match. - * @protected - */ - protected getLastAddressIndex( - addressMap: Map<string, NeuteredAddressInfo>, - zone: Zone, - account: number, - isChange: boolean, - ): number { - const addresses = Array.from(addressMap.values()).filter( - (addressInfo) => - addressInfo.account === account && addressInfo.zone === zone && addressInfo.change === isChange, - ); - return addresses.reduce((maxIndex, addressInfo) => Math.max(maxIndex, addressInfo.index), -1); - } - - /** - * Creates and stores address information in the address map for a specified account, zone, and change type. - * - * This method constructs a NeuteredAddressInfo object using the provided HDNodeWallet and other parameters, then - * stores this information in the provided address map. - * - * @param {HDNodeWallet} addressNode - The HDNodeWallet object containing the address and public key information. - * @param {number} account - The account number to associate with the address. - * @param {Zone} zone - The specific zone to associate with the address. - * @param {boolean} isChange - A boolean indicating whether the address is a change address (true) or a receiving - * address (false). - * @param {Map<string, NeuteredAddressInfo>} addressMap - The map to store the created NeuteredAddressInfo, with the - * address as the key. - * @returns {NeuteredAddressInfo} - The created NeuteredAddressInfo object. - * @protected - */ - protected createAndStoreAddressInfo( - addressNode: HDNodeWallet, - account: number, - zone: Zone, - isChange: boolean, - addressMap: Map<string, NeuteredAddressInfo>, - ): NeuteredAddressInfo { - const neuteredAddressInfo: NeuteredAddressInfo = { - pubKey: addressNode.publicKey, - address: addressNode.address, - account, - index: addressNode.index, - change: isChange, - zone, - }; - - addressMap.set(neuteredAddressInfo.address, neuteredAddressInfo); - - return neuteredAddressInfo; - } } diff --git a/src/wallet/qi-hdwallet-legacy.ts b/src/wallet/qi-hdwallet-legacy.ts deleted file mode 100644 index ee66c981..00000000 --- a/src/wallet/qi-hdwallet-legacy.ts +++ /dev/null @@ -1,1567 +0,0 @@ -import { - AbstractHDWallet, - NeuteredAddressInfo, - SerializedHDWallet, - _guard, - MAX_ADDRESS_DERIVATION_ATTEMPTS, - HARDENED_OFFSET, -} from './hdwallet.js'; -import { HDNodeWallet } from './hdnodewallet.js'; -import { QiTransactionRequest, Provider, TransactionResponse } from '../providers/index.js'; -import { computeAddress, isQiAddress } from '../address/index.js'; -import { getBytes, getZoneForAddress, hexlify } from '../utils/index.js'; -import { TransactionLike, QiTransaction, TxInput, FewestCoinSelector } from '../transaction/index.js'; -import { MuSigFactory } from '@brandonblack/musig'; -import { schnorr } from '@noble/curves/secp256k1'; -import { keccak256, musigCrypto } from '../crypto/index.js'; -import { Outpoint, UTXO, denominations } from '../transaction/utxo.js'; -import { AllowedCoinType, Shard, toShard, Zone } from '../constants/index.js'; -import { Mnemonic } from './mnemonic.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'; -import ecc from '@bitcoinerlab/secp256k1'; -import { SelectedCoinsResult } from '../transaction/abstract-coinselector.js'; - -/** - * @property {Outpoint} outpoint - The outpoint object. - * @property {string} address - The address associated with the outpoint. - * @property {Zone} zone - The zone of the outpoint. - * @property {number} [account] - The account number (optional). - * @interface OutpointInfo - */ -export interface OutpointInfo { - outpoint: Outpoint; - address: string; - zone: Zone; - account?: number; -} - -// 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. - * @property {NeuteredAddressInfo[]} changeAddresses - Array of change addresses. - * @property {NeuteredAddressInfo[]} gapAddresses - Array of gap addresses. - * @property {NeuteredAddressInfo[]} gapChangeAddresses - Array of gap change addresses. - * @interface SerializedQiHDWallet - */ -export interface SerializedQiHDWallet extends SerializedHDWallet { - outpoints: OutpointInfo[]; - pendingOutpoints: OutpointInfo[]; - addresses: NeuteredAddressInfo[]; - changeAddresses: NeuteredAddressInfo[]; - gapAddresses: NeuteredAddressInfo[]; - gapChangeAddresses: NeuteredAddressInfo[]; - usedGapAddresses: NeuteredAddressInfo[]; - usedGapChangeAddresses: NeuteredAddressInfo[]; - receiverPaymentCodeInfo: { [key: string]: PaymentChannelAddressInfo[] }; - senderPaymentCodeInfo: { [key: string]: PaymentChannelAddressInfo[] }; -} - -type AddressUsageCallback = (address: string) => Promise<boolean>; - -/** - * 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. - * - * The Qi HD wallet supports: - * - * - Adding accounts to the wallet heierchy - * - Generating addresses for a specific account in any {@link Zone} - * - Signing and sending transactions for any address in the wallet - * - Serializing the wallet to JSON and deserializing it back to a wallet instance. - * - * @category Wallet - * @example - * - * ```ts - * import { QiHDWallet, Zone } from 'quais'; - * - * const wallet = new QiHDWallet(); - * const cyrpus1Address = await wallet.getNextAddress(0, Zone.Cyrpus1); // get the first address in the Cyrpus1 zone - * await wallet.sendTransaction({ txInputs: [...], txOutputs: [...] }); // send a transaction - * const serializedWallet = wallet.serialize(); // serialize current (account/address) state of the wallet - * . - * . - * . - * const deserializedWallet = QiHDWallet.deserialize(serializedWallet); // create a new wallet instance from the serialized data - * ``` - */ -export class QiHDWalletLegacy extends AbstractHDWallet { - /** - * @ignore - * @type {number} - */ - protected static _version: number = 1; - - /** - * @ignore - * @type {number} - */ - protected static _GAP_LIMIT: number = 5; - - /** - * @ignore - * @type {AllowedCoinType} - */ - protected static _coinType: AllowedCoinType = 969; - - /** - * Map of change addresses to address info. - * - * @ignore - * @type {Map<string, NeuteredAddressInfo>} - */ - protected _changeAddresses: Map<string, NeuteredAddressInfo> = new Map(); - - /** - * Array of gap addresses. - * - * @ignore - * @type {NeuteredAddressInfo[]} - */ - protected _gapChangeAddresses: NeuteredAddressInfo[] = []; - - /** - * Array of gap change addresses. - * - * @ignore - * @type {NeuteredAddressInfo[]} - */ - 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 _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 PaymentChannelAddressInfo for the receiver - */ - private _receiverPaymentCodeInfo: Map<string, PaymentChannelAddressInfo[]> = new Map(); - - /** - * Map of paymentcodes to PaymentChannelAddressInfo for the sender - */ - private _senderPaymentCodeInfo: Map<string, PaymentChannelAddressInfo[]> = new Map(); - - /** - * @ignore - * @param {HDNodeWallet} root - The root HDNodeWallet. - * @param {Provider} [provider] - The provider (optional). - */ - constructor(guard: any, root: HDNodeWallet, provider?: Provider) { - 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]: PaymentChannelAddressInfo[] } { - return Object.fromEntries(this._receiverPaymentCodeInfo); - } - - public get senderPaymentCodeInfo(): { [key: string]: PaymentChannelAddressInfo[] } { - return Object.fromEntries(this._senderPaymentCodeInfo); - } - - /** - * Promise that resolves to the next change address for the specified account and zone. - * - * @param {number} account - The index of the account for which to retrieve the next change address. - * @param {Zone} zone - The zone in which to retrieve the next change address. - * @returns {Promise<NeuteredAddressInfo>} The next change neutered address information. - */ - public async getNextChangeAddress(account: number, zone: Zone): Promise<NeuteredAddressInfo> { - return Promise.resolve(this._getNextAddress(account, zone, true, this._changeAddresses)); - } - - /** - * Synchronously retrieves the next change address for the specified account and zone. - * - * @param {number} account - The index of the account for which to retrieve the next change address. - * @param {Zone} zone - The zone in which to retrieve the next change address. - * @returns {NeuteredAddressInfo} The next change neutered address information. - */ - public getNextChangeAddressSync(account: number, zone: Zone): NeuteredAddressInfo { - return this._getNextAddress(account, zone, true, this._changeAddresses); - } - - /** - * Imports an array of outpoints. - * - * @param {OutpointInfo[]} outpoints - The outpoints to import. - */ - public importOutpoints(outpoints: OutpointInfo[]): void { - this.validateOutpointInfo(outpoints); - this._availableOutpoints.push(...outpoints); - } - - /** - * Gets the outpoints for the specified zone. - * - * @param {Zone} zone - The zone. - * @returns {OutpointInfo[]} The outpoints for the zone. - */ - public getOutpoints(zone: Zone): OutpointInfo[] { - this.validateZone(zone); - return this._availableOutpoints.filter((outpoint) => outpoint.zone === zone); - } - - /** - * Signs a Qi transaction and returns the serialized transaction. - * - * @param {QiTransactionRequest} tx - The transaction to sign. - * @returns {Promise<string>} The serialized transaction. - * @throws {Error} If the UTXO transaction is invalid. - */ - public async signTransaction(tx: QiTransactionRequest): Promise<string> { - const txobj = QiTransaction.from(<TransactionLike>tx); - if (!txobj.txInputs || txobj.txInputs.length == 0 || !txobj.txOutputs) - throw new Error('Invalid UTXO transaction, missing inputs or outputs'); - - const hash = getBytes(keccak256(txobj.unsignedSerialized)); - - let signature: string; - if (txobj.txInputs.length === 1) { - signature = this.createSchnorrSignature(txobj.txInputs[0], hash); - } else { - signature = this.createMuSigSignature(txobj, hash); - } - txobj.signature = signature; - 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. - */ - public 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 **total** balance for the specified zone, including locked UTXOs. - * - * @param {Zone} zone - The zone to get the balance for. - * @returns {bigint} The total balance for the zone. - */ - public getBalanceForZone(zone: Zone): bigint { - this.validateZone(zone); - - return this._availableOutpoints - .filter((outpoint) => outpoint.zone === zone) - .reduce((total, outpoint) => { - const denominationValue = denominations[outpoint.outpoint.denomination]; - return total + denominationValue; - }, BigInt(0)); - } - - /** - * Gets the locked balance for the specified zone. - * - * @param {Zone} zone - The zone to get the locked balance for. - * @returns {bigint} The locked balance for the zone. - */ - public async getSpendableBalanceForZone(zone: Zone, blockNumber?: number): Promise<bigint> { - this.validateZone(zone); - if (!this.provider) { - throw new Error('Provider is not set'); - } - if (!blockNumber) { - blockNumber = await this.provider.getBlockNumber(toShard(zone)); - } - return this._availableOutpoints - .filter((utxo) => utxo.outpoint.lock === 0 || utxo.outpoint.lock! < blockNumber!) - .reduce((total, utxo) => { - const denominationValue = denominations[utxo.outpoint.denomination]; - return total + denominationValue; - }, BigInt(0)); - } - - /** - * Gets the locked balance for the specified zone. - * - * @param {Zone} zone - The zone to get the locked balance for. - * @returns {bigint} The locked balance for the zone. - */ - public async getLockedBalanceForZone(zone: Zone, blockNumber?: number): Promise<bigint> { - this.validateZone(zone); - if (!this.provider) { - throw new Error('Provider is not set'); - } - if (!blockNumber) { - blockNumber = await this.provider.getBlockNumber(toShard(zone)); - } - return this._availableOutpoints - .filter((utxo) => utxo.outpoint.lock !== 0 && blockNumber! < utxo.outpoint.lock!) - .reduce((total, utxo) => { - const denominationValue = denominations[utxo.outpoint.denomination]; - return total + denominationValue; - }, BigInt(0)); - } - - /** - * Converts outpoints for a specific zone to UTXO format. - * - * @param {Zone} zone - The zone to filter outpoints for. - * @returns {UTXO[]} An array of UTXO objects. - */ - private outpointsToUTXOs(zone: Zone): UTXO[] { - this.validateZone(zone); - return this._availableOutpoints - .filter((outpointInfo) => outpointInfo.zone === zone) - .map((outpointInfo) => { - const utxo = new UTXO(); - utxo.txhash = outpointInfo.outpoint.txhash; - utxo.index = outpointInfo.outpoint.index; - utxo.address = outpointInfo.address; - utxo.denomination = outpointInfo.outpoint.denomination; - utxo.lock = outpointInfo.outpoint.lock ?? null; - return utxo; - }); - } - - private async prepareAndSendTransaction( - amount: bigint, - originZone: Zone, - getDestinationAddresses: (count: number) => Promise<string[]>, - ): Promise<TransactionResponse> { - if (!this.provider) { - throw new Error('Provider is not set'); - } - - // 1. Check the wallet has enough balance in the originating zone to send the transaction - const currentBlock = await this.provider.getBlockNumber(originZone as unknown as Shard); - const balance = await this.getSpendableBalanceForZone(originZone, currentBlock); - if (balance < amount) { - throw new Error(`Insufficient balance in the originating zone: want ${amount} Qi got ${balance} Qi`); - } - - // 2. Select the UXTOs from the specified zone to use as inputs, and generate the spend and change outputs - const zoneUTXOs = this.outpointsToUTXOs(originZone); - const unlockedUTXOs = zoneUTXOs.filter((utxo) => utxo.lock === 0 || utxo.lock! < currentBlock); - const fewestCoinSelector = new FewestCoinSelector(unlockedUTXOs); - - const spendTarget: bigint = amount; - let selection = fewestCoinSelector.performSelection(spendTarget); - - // 3. Generate as many unused addresses as required to populate the spend outputs - const sendAddresses = await getDestinationAddresses(selection.spendOutputs.length); - - const getChangeAddresses = async (count: number): Promise<string[]> => { - const addresses: string[] = []; - for (let i = 0; i < count; i++) { - if (this._gapChangeAddresses.length > 0) { - const nextChangeAddressInfo = this._gapChangeAddresses.shift()!; - addresses.push(nextChangeAddressInfo.address); - this._usedGapChangeAddresses.push(nextChangeAddressInfo); - } else { - addresses.push((await this.getNextChangeAddress(0, originZone)).address); - } - } - return addresses; - }; - - // 4. Get change addresses - const changeAddresses = await getChangeAddresses(selection.changeOutputs.length); - - // 5. Create the transaction and sign it using the signTransaction method - 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'); - } - - const chainId = (await this.provider.getNetwork()).chainId; - let tx = await this.prepareTransaction( - selection, - inputPubKeys.map((pubkey) => pubkey!), - sendAddresses, - changeAddresses, - Number(chainId), - ); - - const gasLimit = await this.provider.estimateGas(tx); - const gasPrice = denominations[1]; // 0.005 Qi - const minerTip = (gasLimit * gasPrice) / 100n; // 1% extra as tip - // const feeData = await this.provider.getFeeData(originZone, true); - // const conversionRate = await this.provider.getLatestQuaiRate(originZone, feeData.gasPrice!); - - // 5.6 Calculate total fee for the transaction using the gasLimit, gasPrice, and minerTip - const totalFee = gasLimit * gasPrice + minerTip; - - // Get new selection with fee - selection = fewestCoinSelector.performSelection(spendTarget, totalFee); - - // Determine if new addresses are needed for the change and spend outputs - const changeAddressesNeeded = selection.changeOutputs.length - changeAddresses.length; - if (changeAddressesNeeded > 0) { - const newChangeAddresses = await getChangeAddresses(changeAddressesNeeded); - changeAddresses.push(...newChangeAddresses); - } - - const spendAddressesNeeded = selection.spendOutputs.length - sendAddresses.length; - if (spendAddressesNeeded > 0) { - const newSendAddresses = await getDestinationAddresses(spendAddressesNeeded); - sendAddresses.push(...newSendAddresses); - } - - inputPubKeys = selection.inputs.map((input) => this.locateAddressInfo(input.address)?.pubKey); - - tx = await this.prepareTransaction( - selection, - inputPubKeys.map((pubkey) => pubkey!), - sendAddresses, - changeAddresses, - Number(chainId), - ); - - // Move used outpoints to pendingOutpoints - this.moveOutpointsToPending(tx.txInputs); - - // Sign the transaction - const signedTx = await this.signTransaction(tx); - - // Broadcast the transaction to the network using the provider - return this.provider.broadcastTransaction(originZone, signedTx); - } - - /** - * Converts an amount of Qi to Quai and sends it to a specified Quai address. - * - * @param {string} destinationAddress - The Quai address to send the converted Quai to. - * @param {bigint} amount - The amount of Qi to convert to Quai. - * @returns {Promise<TransactionResponse>} A promise that resolves to the transaction response. - * @throws {Error} If the destination address is invalid, the amount is zero, or the conversion fails. - */ - public async convertToQuai(destinationAddress: string, amount: bigint): Promise<TransactionResponse> { - const zone = getZoneForAddress(destinationAddress); - if (!zone) { - throw new Error(`Invalid zone for Quai address: ${destinationAddress}`); - } - - if (isQiAddress(destinationAddress)) { - throw new Error(`Invalid Quai address: ${destinationAddress}`); - } - - if (amount <= 0) { - throw new Error('Amount must be greater than 0'); - } - - const getDestinationAddresses = async (count: number): Promise<string[]> => { - return Array(count).fill(destinationAddress); - }; - - return this.prepareAndSendTransaction(amount, zone, getDestinationAddresses); - } - - /** - * Sends a transaction to a specified recipient payment code in a specified zone. - * - * @param {string} recipientPaymentCode - The payment code of the recipient. - * @param {bigint} amount - The amount of Qi to send. - * @param {Zone} originZone - The zone where the transaction originates. - * @param {Zone} destinationZone - The zone where the transaction is sent. - * @returns {Promise<TransactionResponse>} A promise that resolves to the transaction response. - * @throws {Error} If the payment code is invalid, the amount is zero, or the zones are invalid. - */ - public async sendTransaction( - recipientPaymentCode: string, - amount: bigint, - originZone: Zone, - destinationZone: Zone, - ): Promise<TransactionResponse> { - if (!validatePaymentCode(recipientPaymentCode)) { - throw new Error('Invalid payment code'); - } - if (amount <= 0) { - throw new Error('Amount must be greater than 0'); - } - this.validateZone(originZone); - this.validateZone(destinationZone); - - const getDestinationAddresses = async (count: number): Promise<string[]> => { - const addresses: string[] = []; - while (addresses.length < count) { - const address = this.getNextSendAddress(recipientPaymentCode, destinationZone).address; - const { isUsed } = await this.checkAddressUse(address); - if (!isUsed) { - addresses.push(address); - } - } - return addresses; - }; - - return this.prepareAndSendTransaction(amount, originZone, getDestinationAddresses); - } - - private async prepareTransaction( - selection: SelectedCoinsResult, - inputPubKeys: string[], - sendAddresses: string[], - changeAddresses: string[], - chainId: number, - ): Promise<QiTransaction> { - const tx = new QiTransaction(); - tx.txInputs = selection.inputs.map((input, index) => ({ - txhash: input.txhash!, - index: input.index!, - pubkey: inputPubKeys[index], - })); - // 5.3 Create the "sender" outputs - const senderOutputs = selection.spendOutputs.map((output, index) => ({ - address: sendAddresses[index], - denomination: output.denomination, - })); - - // 5.4 Create the "change" outputs - const changeOutputs = selection.changeOutputs.map((output, index) => ({ - address: changeAddresses[index], - denomination: output.denomination, - })); - - tx.txOutputs = [...senderOutputs, ...changeOutputs].map((output) => ({ - address: output.address, - denomination: output.denomination!, - })); - tx.chainId = chainId; - 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<void> { - // 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<string>(pendingOutpoints.map((info) => info.address)); - let outpointsByAddress: Outpoint[] = []; - try { - outpointsByAddress = ( - await Promise.all(Array.from(uniqueAddresses).map((address) => this.getOutpointsByAddress(address))) - ).flat(); - } catch (error) { - console.error('Error getting outpoints by address', error); - } - - 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. - * - * @ignore - * @param {TxInput} input - The transaction input. - * @param {Uint8Array} hash - The hash of the message. - * @returns {string} The schnorr signature. - */ - private createSchnorrSignature(input: TxInput, hash: Uint8Array): string { - const privKey = this.getPrivateKeyForTxInput(input); - const signature = schnorr.sign(hash, getBytes(privKey)); - return hexlify(signature); - } - - /** - * Returns a MuSig signature for the given message and private keys corresponding to the input addresses. - * - * @ignore - * @param {QiTransaction} tx - The Qi transaction. - * @param {Uint8Array} hash - The hash of the message. - * @returns {string} The MuSig signature. - */ - private createMuSigSignature(tx: QiTransaction, hash: Uint8Array): string { - const musig = MuSigFactory(musigCrypto); - - // Collect private keys corresponding to the pubkeys found on the inputs - const privKeys = tx.txInputs.map((input) => this.getPrivateKeyForTxInput(input)); - - // Create an array of public keys corresponding to the private keys for musig aggregation - const pubKeys: Uint8Array[] = privKeys - .map((privKey) => musigCrypto.getPublicKey(getBytes(privKey!), true)) - .filter((pubKey) => pubKey !== null) as Uint8Array[]; - - // Generate nonces for each public key - const nonces = pubKeys.map((pk) => musig.nonceGen({ publicKey: getBytes(pk!) })); - const aggNonce = musig.nonceAgg(nonces); - - const signingSession = musig.startSigningSession(aggNonce, hash, pubKeys); - - // Create partial signatures for each private key - const partialSignatures = privKeys.map((sk, index) => - musig.partialSign({ - secretKey: getBytes(sk || ''), - publicNonce: nonces[index], - sessionKey: signingSession, - verify: true, - }), - ); - - // Aggregate the partial signatures into a final aggregated signature - const finalSignature = musig.signAgg(partialSignatures, signingSession); - - return hexlify(finalSignature); - } - - /** - * Retrieves the private key for a given transaction input. - * - * 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: - * - * - 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. - * - * @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); - 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 + HARDENED_OFFSET) - .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 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` - * @returns {Promise<void>} A promise that resolves when the scan is complete. - * @throws {Error} If the zone is invalid. - */ - public async scan(zone: Zone, account: number = 0): Promise<void> { - this.validateZone(zone); - // flush the existing addresses and outpoints - this._addresses = new Map(); - this._changeAddresses = new Map(); - this._gapAddresses = []; - this._gapChangeAddresses = []; - 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 external and change BIP44 addresses and payment channel - * addresses. - * - * @param {Zone} zone - The zone in which to sync addresses. - * @param {number} [account=0] - The index of the account to sync. Default is `0` - * @returns {Promise<void>} A promise that resolves when the sync is complete. - * @throws {Error} If the zone is invalid. - */ - public async sync(zone: Zone, account: number = 0): Promise<void> { - this.validateZone(zone); - await this._scan(zone, account); - await this.checkPendingOutpoints(zone); - } - - /** - * Internal method to scan the specified zone for addresses with unspent outputs. This method handles the actual - * scanning logic, generating new addresses until the gap limit is reached for both gap and change 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` - * @returns {Promise<void>} A promise that resolves when the scan is complete. - * @throws {Error} If the provider is not set. - */ - private async _scan(zone: Zone, account: number = 0): Promise<void> { - if (!this.provider) throw new Error('Provider not set'); - - // Start scanning processes for each derivation tree - const scans = [ - this.scanBIP44Addresses(zone, account, false), // External addresses - this.scanBIP44Addresses(zone, account, true), // Change addresses - ]; - - // 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); - } - - /** - * Scans for the next address in the specified zone and account, checking for associated outpoints, and updates the - * address count and gap addresses accordingly. - * - * @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. - * @returns {Promise<void>} 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 scanBIP44Addresses(zone: Zone, account: number, isChange: boolean): Promise<void> { - const addressMap = isChange ? this._changeAddresses : this._addresses; - 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); - } - - // Scan outpoints for every address in the address map - for (const addressInfo of addressMap.values()) { - 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 < QiHDWalletLegacy._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 { - this._gapAddresses = [...updatedGapAddresses, ...newGapAddresses]; - } - } - - /** - * 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<void>} 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<void> { - 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 < QiHDWalletLegacy._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); - } - - /** - * Queries the network node for the outpoints of the specified address. - * - * @ignore - * @param {string} address - The address to query. - * @returns {Promise<Outpoint[]>} The outpoints for the address. - * @throws {Error} If the query fails. - */ - private async getOutpointsByAddress(address: string): Promise<Outpoint[]> { - try { - return await this.provider!.getOutpointsByAddress(address); - } catch (error) { - throw new Error(`Failed to get outpoints for address: ${address} - error: ${error}`); - } - } - - 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. - * - * @param {Zone} zone - The zone. - * @returns {NeuteredAddressInfo[]} The change addresses for the zone. - */ - public getChangeAddressesForZone(zone: Zone): NeuteredAddressInfo[] { - this.validateZone(zone); - const changeAddresses = this._changeAddresses.values(); - return Array.from(changeAddresses).filter((addressInfo) => addressInfo.zone === zone); - } - - /** - * Gets the gap addresses for the specified zone. - * - * @param {Zone} zone - The zone. - * @returns {NeuteredAddressInfo[]} The gap addresses for the zone. - */ - public getGapAddressesForZone(zone: Zone): NeuteredAddressInfo[] { - this.validateZone(zone); - const gapAddresses = this._gapAddresses.filter((addressInfo) => addressInfo.zone === zone); - return gapAddresses; - } - - /** - * Gets the gap change addresses for the specified zone. - * - * @param {Zone} zone - The zone. - * @returns {NeuteredAddressInfo[]} The gap change addresses for the zone. - */ - public getGapChangeAddressesForZone(zone: Zone): NeuteredAddressInfo[] { - this.validateZone(zone); - const gapChangeAddresses = this._gapChangeAddresses.filter((addressInfo) => addressInfo.zone === zone); - return gapChangeAddresses; - } - - /** - * Signs a message using the private key associated with the given address. - * - * @param {string} address - The address for which the message is to be signed. - * @param {string | Uint8Array} message - The message to be signed, either as a string or Uint8Array. - * @returns {Promise<string>} A promise that resolves to the signature of the message in hexadecimal string format. - * @throws {Error} If the address does not correspond to a valid HD node or if signing fails. - */ - public async signMessage(address: string, message: string | Uint8Array): Promise<string> { - const addrNode = this._getHDNodeForAddress(address); - const privKey = addrNode.privateKey; - const digest = keccak256(message); - const signature = schnorr.sign(digest, getBytes(privKey)); - return hexlify(signature); - } - - /** - * Serializes the HD wallet state into a format suitable for storage or transmission. - * - * @returns {SerializedQiHDWallet} An object representing the serialized state of the HD wallet, including - * outpoints, change addresses, gap addresses, and other inherited properties. - */ - public serialize(): SerializedQiHDWallet { - const hdwalletSerialized = super.serialize(); - return { - outpoints: this._availableOutpoints, - pendingOutpoints: this._pendingOutpoints, - addresses: Array.from(this._addresses.values()), - 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, - }; - } - - /** - * Deserializes a serialized QiHDWallet object and reconstructs the wallet instance. - * - * @param {SerializedQiHDWallet} serialized - The serialized object representing the state of a QiHDWallet. - * @returns {Promise<QiHDWallet>} A promise that resolves to a reconstructed QiHDWallet instance. - * @throws {Error} If the serialized data is invalid or if any addresses in the gap addresses or gap change - * addresses do not exist in the wallet. - */ - public static async deserialize(serialized: SerializedQiHDWallet): Promise<QiHDWalletLegacy> { - super.validateSerializedWallet(serialized); - // create the wallet instance - const mnemonic = Mnemonic.fromPhrase(serialized.phrase); - const path = (this as any).parentPath(serialized.coinType); - const root = HDNodeWallet.fromMnemonic(mnemonic, path); - const wallet = new this(_guard, root); - - // import the addresses - wallet.importSerializedAddresses(wallet._addresses, serialized.addresses); - // import the change addresses - wallet.importSerializedAddresses(wallet._changeAddresses, serialized.changeAddresses); - - // import the gap addresses, verifying they already exist in the wallet - for (const gapAddressInfo of serialized.gapAddresses) { - const gapAddress = gapAddressInfo.address; - if (!wallet._addresses.has(gapAddress)) { - throw new Error(`Address ${gapAddress} not found in wallet`); - } - wallet._gapAddresses.push(gapAddressInfo); - } - // import the gap change addresses, verifying they already exist in the wallet - for (const gapChangeAddressInfo of serialized.gapChangeAddresses) { - const gapChangeAddress = gapChangeAddressInfo.address; - if (!wallet._changeAddresses.has(gapChangeAddress)) { - throw new Error(`Address ${gapChangeAddress} not found in wallet`); - } - wallet._gapChangeAddresses.push(gapChangeAddressInfo); - } - - // 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 and import the payment code info - wallet.validateAndImportPaymentCodeInfo(serialized.receiverPaymentCodeInfo, 'receiver'); - wallet.validateAndImportPaymentCodeInfo(serialized.senderPaymentCodeInfo, 'sender'); - - // validate the available outpoints and import them - wallet.validateOutpointInfo(serialized.outpoints); - wallet._availableOutpoints.push(...serialized.outpoints); - - // validate the pending outpoints and import them - wallet.validateOutpointInfo(serialized.pendingOutpoints); - wallet._pendingOutpoints.push(...serialized.pendingOutpoints); - - return wallet; - } - - /** - * Validates and imports a map of payment code info. - * - * @param {Map<string, PaymentChannelAddressInfo[]>} 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]: PaymentChannelAddressInfo[] }, - 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 {PaymentChannelAddressInfo} pcInfo - The payment code info to validate. - * @throws {Error} If the payment code info is invalid. - */ - 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'); - } - 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: - * - * - Validates the zone using the `validateZone` method. - * - Checks if the address exists in the wallet. - * - Checks if the account (if provided) exists in the wallet. - * - Validates the Outpoint by ensuring that `Txhash`, `Index`, and `Denomination` are not null. - * - * @ignore - * @param {OutpointInfo[]} outpointInfo - An array of OutpointInfo objects to be validated. - * @throws {Error} If any of the validations fail, an error is thrown with a descriptive message. - */ - private validateOutpointInfo(outpointInfo: OutpointInfo[]): void { - outpointInfo.forEach((info) => { - // validate zone - this.validateZone(info.zone); - - // validate address and account - 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)} `); - } - }); - } - - 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. - * - * @param {number} account - The account index to derive the payment code from. - * @returns {Promise<string>} A promise that resolves to the Base58-encoded BIP47 payment code. - */ - public getPaymentCode(account: number = 0): string { - const privatePcode = this._getPaymentCodePrivate(account); - return privatePcode.toBase58(); - } - - // helper method to get a bip32 API instance - private _getBIP32API(): BIP32API { - return BIP32Factory(ecc) as BIP32API; - } - - // helper method to decode a base58 string into a Uint8Array - private _decodeBase58(base58: string): Uint8Array { - return bs58check.decode(base58); - } - - /** - * Generates a BIP47 private payment code for the specified account. The payment code is created by combining the - * account's public key and chain code. - * - * @private - * @param {number} account - The account index for which to generate the private payment code. - * @returns {Promise<PaymentCodePrivate>} A promise that resolves to the PaymentCodePrivate instance. - */ - private _getPaymentCodePrivate(account: number): PaymentCodePrivate { - const bip32 = this._getBIP32API(); - - const accountNode = this._root.deriveChild(account + HARDENED_OFFSET); - - // payment code array - const pc = new Uint8Array(80); - - // set version + options - pc.set([1, 0]); - - // set the public key - const pubKey = accountNode.publicKey; - pc.set(getBytes(pubKey), 2); - - // set the chain code - const chainCode = accountNode.chainCode; - pc.set(getBytes(chainCode), 35); - - const adapter = new HDNodeBIP32Adapter(accountNode); - - return new PaymentCodePrivate(adapter, ecc, bip32, pc); - } - - /** - * Generates a payment address for sending funds to the specified receiver's BIP47 payment code. Uses Diffie-Hellman - * key exchange to derive the address from the receiver's public key and sender's private key. - * - * @param {string} receiverPaymentCode - The Base58-encoded BIP47 payment code of the receiver. - * @returns {Promise<string>} A promise that resolves to the payment address for sending funds. - * @throws {Error} Throws an error if the payment code version is invalid. - */ - 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 = this._getPaymentCodePrivate(account); - const senderPCodePublic = new PaymentCodePublic(ecc, bip32, buf.slice(1)); - - const paymentCodeInfoArray = this._senderPaymentCodeInfo.get(receiverPaymentCode); - const filteredPaymentCodeInfoArray = paymentCodeInfoArray?.filter( - (addressInfo) => addressInfo.account === account && addressInfo.zone === zone, - ); - const lastIndex = - filteredPaymentCodeInfoArray && filteredPaymentCodeInfoArray.length > 0 - ? filteredPaymentCodeInfoArray.reduce( - (maxIndex, addressInfo) => Math.max(maxIndex, addressInfo.index), - -1, - ) - : 0; - - let addrIndex = lastIndex; - for (let attempts = 0; attempts < MAX_ADDRESS_DERIVATION_ATTEMPTS; attempts++) { - const address = senderPCodePublic.getPaymentAddress(receiverPCodePrivate, addrIndex++); - if (this.isValidAddressForZone(address, zone)) { - const pubkey = senderPCodePublic.derivePaymentPublicKey(receiverPCodePrivate, addrIndex - 1); - const pcInfo: PaymentChannelAddressInfo = { - address, - pubKey: hexlify(pubkey), - index: addrIndex, - account, - zone, - isUsed: false, - }; - if (paymentCodeInfoArray) { - paymentCodeInfoArray.push(pcInfo); - } else { - this._senderPaymentCodeInfo.set(receiverPaymentCode, [pcInfo]); - } - return pcInfo; - } - } - - throw new Error( - `Failed to derive a valid address for the zone ${zone} after ${MAX_ADDRESS_DERIVATION_ATTEMPTS} attempts.`, - ); - } - - /** - * Generates a payment address for receiving funds from the specified sender's BIP47 payment code. Uses - * Diffie-Hellman key exchange to derive the address from the sender's public key and receiver's private key. - * - * @param {string} senderPaymentCode - The Base58-encoded BIP47 payment code of the sender. - * @returns {Promise<string>} A promise that resolves to the payment address for receiving funds. - * @throws {Error} Throws an error if the payment code version is invalid. - */ - 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 = this._getPaymentCodePrivate(account); - - const paymentCodeInfoArray = this._receiverPaymentCodeInfo.get(senderPaymentCode); - const filteredPaymentCodeInfoArray = paymentCodeInfoArray?.filter( - (addressInfo) => addressInfo.account === account && addressInfo.zone === zone, - ); - const lastIndex = - filteredPaymentCodeInfoArray && filteredPaymentCodeInfoArray.length > 0 - ? filteredPaymentCodeInfoArray.reduce( - (maxIndex, addressInfo) => Math.max(maxIndex, addressInfo.index), - -1, - ) - : 0; - - let addrIndex = lastIndex; - for (let attempts = 0; attempts < MAX_ADDRESS_DERIVATION_ATTEMPTS; attempts++) { - const address = receiverPCodePrivate.getPaymentAddress(senderPCodePublic, addrIndex++); - if (this.isValidAddressForZone(address, zone)) { - const pubkey = receiverPCodePrivate.derivePaymentPublicKey(senderPCodePublic, addrIndex - 1); - const pcInfo: PaymentChannelAddressInfo = { - address, - pubKey: hexlify(pubkey), - index: addrIndex, - account, - zone, - isUsed: false, - }; - if (paymentCodeInfoArray) { - paymentCodeInfoArray.push(pcInfo); - } else { - this._receiverPaymentCodeInfo.set(senderPaymentCode, [pcInfo]); - } - return pcInfo; - } - } - - throw new Error( - `Failed to derive a valid address for the zone ${zone} after ${MAX_ADDRESS_DERIVATION_ATTEMPTS} attempts.`, - ); - } - - /** - * Receives a payment code and stores it in the wallet for future use. If the payment code is already in the wallet, - * it will be ignored. - * - * @param {string} paymentCode - The payment code to store. - * @param {'receiver' | 'sender'} type - The type of payment code ('receiver' or 'sender'). - */ - public openChannel(paymentCode: string, type: 'receiver' | 'sender'): void { - if (!validatePaymentCode(paymentCode)) { - throw new Error(`Invalid payment code: ${paymentCode}`); - } - if (type === 'receiver') { - if (!this._receiverPaymentCodeInfo.has(paymentCode)) { - this._receiverPaymentCodeInfo.set(paymentCode, []); - } - } else { - if (!this._senderPaymentCodeInfo.has(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; - } -} diff --git a/src/wallet/qi-hdwallet.ts b/src/wallet/qi-hdwallet.ts index bef5d5d9..2709dfcd 100644 --- a/src/wallet/qi-hdwallet.ts +++ b/src/wallet/qi-hdwallet.ts @@ -9,11 +9,11 @@ import { import { HDNodeWallet } from './hdnodewallet.js'; import { QiTransactionRequest, Provider, TransactionResponse } from '../providers/index.js'; import { computeAddress, isQiAddress } from '../address/index.js'; -import { getBytes, getZoneForAddress, hexlify, toQuantity } from '../utils/index.js'; +import { getBytes, getZoneForAddress, hexlify, isHexString, toQuantity } from '../utils/index.js'; import { TransactionLike, QiTransaction, TxInput, FewestCoinSelector } from '../transaction/index.js'; import { MuSigFactory } from '@brandonblack/musig'; import { schnorr } from '@noble/curves/secp256k1'; -import { keccak256, musigCrypto } from '../crypto/index.js'; +import { keccak256, musigCrypto, SigningKey } from '../crypto/index.js'; import { Outpoint, UTXO, denominations } from '../transaction/utxo.js'; import { AllowedCoinType, Shard, toShard, Zone } from '../constants/index.js'; import { Mnemonic } from './mnemonic.js'; @@ -64,6 +64,7 @@ type DerivationPath = 'BIP44:external' | 'BIP44:change' | string; // string for * @extends NeuteredAddressInfo */ export interface QiAddressInfo extends NeuteredAddressInfo { + change: boolean; status: AddressStatus; derivationPath: DerivationPath; } @@ -122,7 +123,7 @@ type AddressUsageCallback = (address: string) => Promise<boolean>; * const deserializedWallet = QiHDWallet.deserialize(serializedWallet); // create a new wallet instance from the serialized data * ``` */ -export class QiHDWallet extends AbstractHDWallet { +export class QiHDWallet extends AbstractHDWallet<QiAddressInfo> { /** * @ignore * @type {number} @@ -141,6 +142,12 @@ export class QiHDWallet extends AbstractHDWallet { */ protected static _coinType: AllowedCoinType = 969; + /** + * @ignore + * @type {string} + */ + private static readonly PRIVATE_KEYS_PATH: string = 'privateKeys' as const; + /** * A map containing address information for all addresses known to the wallet. This includes: * @@ -196,6 +203,7 @@ export class QiHDWallet extends AbstractHDWallet { super(guard, root, provider); this._addressesMap.set('BIP44:external', []); this._addressesMap.set('BIP44:change', []); + this._addressesMap.set(QiHDWallet.PRIVATE_KEYS_PATH, []); } /** @@ -224,7 +232,7 @@ export class QiHDWallet extends AbstractHDWallet { * @param {QiAddressInfo[]} addresses - The array of QiAddressInfo objects. * @returns {number} The last used index. */ - private _findLastUsedIndex(addresses: QiAddressInfo[] | undefined, account: number, zone: Zone): number { + protected _findLastUsedIndex(addresses: QiAddressInfo[] | undefined, account: number, zone: Zone): number { const filteredAddresses = addresses?.filter( (addressInfo) => addressInfo.account === account && addressInfo.zone === zone, ); @@ -243,6 +251,14 @@ export class QiHDWallet extends AbstractHDWallet { const addresses = this._addressesMap.get(isChange ? 'BIP44:change' : 'BIP44:external') || []; const lastIndex = this._findLastUsedIndex(addresses, account, zone); const addressNode = this.deriveNextAddressNode(account, lastIndex + 1, zone, isChange); + + const privateKeysArray = this._addressesMap.get(QiHDWallet.PRIVATE_KEYS_PATH) || []; + const existingPrivateKeyIndex = privateKeysArray.findIndex((info) => info.address === addressNode.address); + if (existingPrivateKeyIndex !== -1) { + privateKeysArray.splice(existingPrivateKeyIndex, 1); + this._addressesMap.set(QiHDWallet.PRIVATE_KEYS_PATH, privateKeysArray); + } + const newAddrInfo = { pubKey: addressNode.publicKey, address: addressNode.address, @@ -934,6 +950,11 @@ export class QiHDWallet extends AbstractHDWallet { throw new Error(`Address not found: ${address}`); } + // Handle imported private keys + if (isHexString(addressInfo.derivationPath, 32)) { + return addressInfo.derivationPath; + } + if (addressInfo.derivationPath === 'BIP44:external' || addressInfo.derivationPath === 'BIP44:change') { // (BIP44 addresses) const changeIndex = addressInfo.change ? 1 : 0; @@ -1231,8 +1252,7 @@ export class QiHDWallet extends AbstractHDWallet { * @throws {Error} If the address does not correspond to a valid HD node or if signing fails. */ public async signMessage(address: string, message: string | Uint8Array): Promise<string> { - const addrNode = this._getHDNodeForAddress(address); - const privKey = addrNode.privateKey; + const privKey = this.getPrivateKey(address); const digest = keccak256(message); const signature = schnorr.sign(digest, getBytes(privKey)); return hexlify(signature); @@ -1251,9 +1271,8 @@ export class QiHDWallet extends AbstractHDWallet { ...hdwalletSerialized, outpoints: this._availableOutpoints, pendingOutpoints: this._pendingOutpoints, - addresses: Array.from(this._addressesMap.entries()).flatMap(([key, addresses]) => - addresses.map((address) => ({ ...address, derivationPath: key })), - ), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + addresses: Array.from(this._addressesMap.entries()).flatMap(([_, addresses]) => addresses), senderPaymentCodeInfo: Object.fromEntries( Array.from(this._paymentCodeSendAddressMap.entries()).map(([key, value]) => [key, Array.from(value)]), ), @@ -1296,8 +1315,10 @@ export class QiHDWallet extends AbstractHDWallet { // validate and import all the wallet addresses for (const addressInfo of serialized.addresses) { validateQiAddressInfo(addressInfo); - const key = addressInfo.derivationPath; - if (!wallet._addressesMap.has(key)) { + let key = addressInfo.derivationPath; + if (isHexString(key, 32)) { + key = QiHDWallet.PRIVATE_KEYS_PATH; + } else if (!key.startsWith('BIP44:')) { wallet._addressesMap.set(key, []); } wallet._addressesMap.get(key)!.push(addressInfo); @@ -1530,6 +1551,20 @@ export class QiHDWallet extends AbstractHDWallet { return this._addressesMap.has(paymentCode) && this._paymentCodeSendAddressMap.has(paymentCode); } + /** + * Gets the address info for a given address. + * + * @param {string} address - The address. + * @returns {QiAddressInfo | null} The address info or null if not found. + */ + public getAddressInfo(address: string): QiAddressInfo | null { + const externalAddressInfo = this._addressesMap.get('BIP44:external')?.find((addr) => addr.address === address); + if (!externalAddressInfo) { + return null; + } + return externalAddressInfo; + } + /** * Gets the address info for a given address. * @@ -1543,4 +1578,144 @@ export class QiHDWallet extends AbstractHDWallet { } return changeAddressInfo; } + + /** + * Imports a private key and adds it to the wallet. + * + * @param {string} privateKey - The private key to import (hex string) + * @returns {Promise<QiAddressInfo>} The address information for the imported key + * @throws {Error} If the private key is invalid or the address is already in use + */ + public async importPrivateKey(privateKey: string): Promise<QiAddressInfo> { + if (!isHexString(privateKey, 32)) { + throw new Error(`Invalid private key format: must be 32-byte hex string (got ${privateKey})`); + } + + const pubKey = SigningKey.computePublicKey(privateKey, true); + const address = computeAddress(pubKey); + + // Validate address is for correct zone and ledger + const addressZone = getZoneForAddress(address); + if (!addressZone) { + throw new Error(`Private key does not correspond to a valid address for any zone (got ${address})`); + } + if (!isQiAddress(address)) { + throw new Error(`Private key does not correspond to a valid Qi address (got ${address})`); + } + + for (const [path, addresses] of this._addressesMap.entries()) { + if (addresses.some((info) => info.address === address)) { + throw new Error(`Address ${address} already exists in wallet under path ${path}`); + } + } + + const addressInfo: QiAddressInfo = { + pubKey, + address, + account: 0, + index: -1, + change: false, + zone: addressZone, + status: AddressStatus.UNUSED, + derivationPath: privateKey, // Store private key in derivationPath + }; + + this._addressesMap.get(QiHDWallet.PRIVATE_KEYS_PATH)!.push(addressInfo); + + return addressInfo; + } + + /** + * Gets all addresses that were imported via private keys. + * + * @param {Zone} [zone] - Optional zone to filter addresses by + * @returns {QiAddressInfo[]} Array of address info objects for imported addresses + */ + public getImportedAddresses(zone?: Zone): QiAddressInfo[] { + const importedAddresses = this._addressesMap.get(QiHDWallet.PRIVATE_KEYS_PATH) || []; + + if (zone !== undefined) { + this.validateZone(zone); + return importedAddresses.filter((info) => info.zone === zone); + } + + return [...importedAddresses]; + } + + /** + * Adds a new address to the wallet. + * + * @param {number} account - The account number. + * @param {number} addressIndex - The address index. + * @returns {QiAddressInfo} The address info for the new address. + */ + public addAddress(account: number, addressIndex: number): QiAddressInfo { + return this._addAddress(account, addressIndex, false); + } + + /** + * Adds a new change address to the wallet. + * + * @param {number} account - The account number. + * @param {number} addressIndex - The address index. + * @returns {QiAddressInfo} The address info for the new address. + */ + public addChangeAddress(account: number, addressIndex: number): QiAddressInfo { + return this._addAddress(account, addressIndex, true); + } + + private _addAddress(account: number, addressIndex: number, isChange: boolean): QiAddressInfo { + const derivationPath = isChange ? 'BIP44:change' : 'BIP44:external'; + + const existingAddresses = this._addressesMap.get(derivationPath) || []; + if (existingAddresses.some((info) => info.index === addressIndex)) { + throw new Error(`Address index ${addressIndex} already exists in wallet under path ${derivationPath}`); + } + + const addressNode = this._root + .deriveChild(account + HARDENED_OFFSET) + .deriveChild(isChange ? 1 : 0) + .deriveChild(addressIndex); + const zone = getZoneForAddress(addressNode.address); + if (!zone) { + throw new Error(`Failed to derive a Qi valid address zone for the index ${addressIndex}`); + } + + if (!isQiAddress(addressNode.address)) { + throw new Error(`Address ${addressNode.address} is not a valid Qi address`); + } + + const addressInfo: QiAddressInfo = { + pubKey: addressNode.publicKey, + address: addressNode.address, + account, + index: addressIndex, + change: isChange, + zone, + status: AddressStatus.UNUSED, + derivationPath, + }; + + const addresses = this._addressesMap.get(derivationPath); + if (!addresses) { + this._addressesMap.set(derivationPath, [addressInfo]); + } else { + addresses.push(addressInfo); + } + + return addressInfo; + } + + /** + * Gets the addresses for a given account. + * + * @param {number} account - The account number. + * @returns {QiAddressInfo[]} The addresses for the account. + */ + public getAddressesForAccount(account: number): QiAddressInfo[] { + const addresses = this._addressesMap.values(); + return Array.from(addresses) + .flat() + .filter((info) => info.account === account); + } } diff --git a/src/wallet/quai-hdwallet.ts b/src/wallet/quai-hdwallet.ts index eb0067e6..2e742bc7 100644 --- a/src/wallet/quai-hdwallet.ts +++ b/src/wallet/quai-hdwallet.ts @@ -1,11 +1,12 @@ import { AbstractHDWallet, NeuteredAddressInfo, _guard } from './hdwallet.js'; import { HDNodeWallet } from './hdnodewallet.js'; import { QuaiTransactionRequest, Provider, TransactionResponse } from '../providers/index.js'; -import { resolveAddress } from '../address/index.js'; -import { AllowedCoinType } from '../constants/index.js'; -import { SerializedHDWallet } from './hdwallet.js'; +import { isQuaiAddress, resolveAddress } from '../address/index.js'; +import { AllowedCoinType, Zone } from '../constants/index.js'; +import { SerializedHDWallet, HARDENED_OFFSET } from './hdwallet.js'; import { Mnemonic } from './mnemonic.js'; import { TypedDataDomain, TypedDataField } from '../hash/index.js'; +import { getZoneForAddress } from '../utils/index.js'; export interface SerializedQuaiHDWallet extends SerializedHDWallet { addresses: Array<NeuteredAddressInfo>; @@ -40,7 +41,7 @@ export interface SerializedQuaiHDWallet extends SerializedHDWallet { * const deserializedWallet = QuaiHDWallet.deserialize(serializedWallet); // create a new wallet instance from the serialized data * ``` */ -export class QuaiHDWallet extends AbstractHDWallet { +export class QuaiHDWallet extends AbstractHDWallet<NeuteredAddressInfo> { /** * The version of the wallet. * @@ -115,7 +116,7 @@ export class QuaiHDWallet extends AbstractHDWallet { * This method extends the serialization from the parent class (AbstractHDWallet) and includes additional * QuaiHDWallet-specific data, such as the addresses. * - * @example const wallet = new QuaiHDWallet(); const serializedData = wallet.serialize(); // serializedData can now + * @example Const wallet = new QuaiHDWallet(); const serializedData = wallet.serialize(); // serializedData can now * be stored or transmitted * * @returns {SerializedQuaiHDWallet} An object representing the serialized state of the QuaiHDWallet, including @@ -149,7 +150,7 @@ export class QuaiHDWallet extends AbstractHDWallet { const wallet = new this(_guard, root); // import the addresses - wallet.importSerializedAddresses(wallet._addresses, serialized.addresses); + wallet.importSerializedAddresses(serialized.addresses); return wallet; } @@ -174,4 +175,223 @@ export class QuaiHDWallet extends AbstractHDWallet { const addrNode = this._getHDNodeForAddress(address); return addrNode.signTypedData(domain, types, value); } + + /** + * Adds an address to the wallet. + * + * @param {number} account - The account number. + * @param {number} addressIndex - The address index. + * @returns {NeuteredAddressInfo} The added address info. + */ + public addAddress(account: number, addressIndex: number): NeuteredAddressInfo { + return this._addAddress(account, addressIndex) as NeuteredAddressInfo; + } + + /** + * Helper method to add an address to the wallet address map. + * + * @param {Map<string, NeuteredAddressInfo>} addressMap - The address map. + * @param {number} account - The account number. + * @param {number} addressIndex - The address index. + * @returns {NeuteredAddressInfo} The added address info. + * @throws {Error} If the address for the index already exists. + */ + protected _addAddress(account: number, addressIndex: number): NeuteredAddressInfo { + // check if address already exists for the index + this._addresses.forEach((addressInfo) => { + if (addressInfo.index === addressIndex) { + throw new Error(`Address for index ${addressIndex} already exists`); + } + }); + + // derive the address node and validate the zone + const changeIndex = 0; + const addressNode = this._root + .deriveChild(account + HARDENED_OFFSET) + .deriveChild(changeIndex) + .deriveChild(addressIndex); + const zone = getZoneForAddress(addressNode.address); + if (!zone) { + throw new Error(`Failed to derive a valid address zone for the index ${addressIndex}`); + } + + if (!isQuaiAddress(addressNode.address)) { + throw new Error(`Address ${addressNode.address} is not a valid Quai address`); + } + + return this.createAndStoreAddressInfo(addressNode, account, zone); + } + + /** + * Imports addresses from a serialized wallet into the addresses map. Before adding the addresses, a validation is + * performed to ensure the address, public key, and zone match the expected values. + * + * @param {Map<string, NeuteredAddressInfo>} addressMap - The map where the addresses will be imported. + * @param {NeuteredAddressInfo[]} addresses - The array of addresses to be imported, each containing account, index, + * address, pubKey, and zone information. + * @throws {Error} If there is a mismatch between the expected and actual address, public key, or zone. + * @protected + */ + protected importSerializedAddresses(addresses: NeuteredAddressInfo[]): void { + for (const addressInfo of addresses) { + const newAddressInfo = this._addAddress(addressInfo.account, addressInfo.index); + // validate the address info + if (addressInfo.address !== newAddressInfo.address) { + throw new Error(`Address mismatch: ${addressInfo.address} != ${newAddressInfo.address}`); + } + if (addressInfo.pubKey !== newAddressInfo.pubKey) { + throw new Error(`Public key mismatch: ${addressInfo.pubKey} != ${newAddressInfo.pubKey}`); + } + if (addressInfo.zone !== newAddressInfo.zone) { + throw new Error(`Zone mismatch: ${addressInfo.zone} != ${newAddressInfo.zone}`); + } + } + } + + /** + * Promise that resolves to the next address for the specified account and zone. + * + * @param {number} account - The index of the account for which to retrieve the next address. + * @param {Zone} zone - The zone in which to retrieve the next address. + * @returns {Promise<T>} The next neutered address information. + */ + public async getNextAddress(account: number, zone: Zone): Promise<NeuteredAddressInfo> { + return Promise.resolve(this._getNextAddress(account, zone)); + } + + /** + * Synchronously retrieves the next address for the specified account and zone. + * + * @param {number} account - The index of the account for which to retrieve the next address. + * @param {Zone} zone - The zone in which to retrieve the next address. + * @returns {T} The next neutered address information. + */ + public getNextAddressSync(account: number, zone: Zone): NeuteredAddressInfo { + return this._getNextAddress(account, zone); + } + + /** + * Derives and returns the next address information for the specified account and zone. + * + * @param {number} accountIndex - The index of the account for which the address is being generated. + * @param {Zone} zone - The zone in which the address is to be used. + * @param {Map<string, NeuteredAddressInfo>} addressMap - A map storing the neutered address information. + * @returns {T} The derived neutered address information. + * @throws {Error} If the zone is invalid. + */ + protected _getNextAddress(accountIndex: number, zone: Zone): NeuteredAddressInfo { + this.validateZone(zone); + const lastIndex = this._findLastUsedIndex(Array.from(this._addresses.values()), accountIndex, zone); + const addressNode = this.deriveNextAddressNode(accountIndex, lastIndex + 1, zone, false); + return this.createAndStoreAddressInfo(addressNode, accountIndex, zone); + } + + /** + * Creates and stores address information in the address map for a specified account, zone, and change type. + * + * This method constructs a NeuteredAddressInfo object using the provided HDNodeWallet and other parameters, then + * stores this information in the provided address map. + * + * @param {HDNodeWallet} addressNode - The HDNodeWallet object containing the address and public key information. + * @param {number} account - The account number to associate with the address. + * @param {Zone} zone - The specific zone to associate with the address. + * @param {Map<string, NeuteredAddressInfo>} addressMap - The map to store the created NeuteredAddressInfo, with the + * address as the key. + * @returns {NeuteredAddressInfo} - The created NeuteredAddressInfo object. + * @protected + */ + protected createAndStoreAddressInfo(addressNode: HDNodeWallet, account: number, zone: Zone): NeuteredAddressInfo { + const neuteredAddressInfo: NeuteredAddressInfo = { + pubKey: addressNode.publicKey, + address: addressNode.address, + account, + index: addressNode.index, + zone, + }; + + this._addresses.set(neuteredAddressInfo.address, neuteredAddressInfo); + + return neuteredAddressInfo; + } + + /** + * Gets the address info for a given address. + * + * @param {string} address - The address. + * @returns {T | null} The address info or null if not found. + */ + public getAddressInfo(address: string): NeuteredAddressInfo | null { + const addressInfo = this._addresses.get(address); + if (!addressInfo) { + return null; + } + return addressInfo; + } + + /** + * Returns the private key for a given address. This method should be used with caution as it exposes the private + * key to the user. + * + * @param {string} address - The address associated with the desired private key. + * @returns {string} The private key. + */ + public getPrivateKey(address: string): string { + const hdNode = this._getHDNodeForAddress(address); + return hdNode.privateKey; + } + + /** + * Derives and returns the Hierarchical Deterministic (HD) node wallet associated with a given address. + * + * This method fetches the account and address information from the wallet's internal storage, derives the + * appropriate change node based on whether the address is a change address, and further derives the final HD node + * using the address index. + * + * @param {string} addr - The address for which to derive the HD node. + * @returns {HDNodeWallet} The derived HD node wallet corresponding to the given address. + * @throws {Error} If the given address is not known to the wallet. + * @throws {Error} If the account associated with the address is not found. + */ + protected _getHDNodeForAddress(addr: string): HDNodeWallet { + const addressInfo = this._addresses.get(addr); + if (!addressInfo) { + throw new Error(`Address ${addr} is not known to this wallet`); + } + + const changeIndex = 0; + return this._root + .deriveChild(addressInfo.account + HARDENED_OFFSET) + .deriveChild(changeIndex) + .deriveChild(addressInfo.index); + } + + /** + * Gets the addresses for a given zone. + * + * @param {Zone} zone - The zone. + * @returns {NeuteredAddressInfo[]} The addresses for the zone. + */ + public getAddressesForZone(zone: Zone): NeuteredAddressInfo[] { + this.validateZone(zone); + const addresses = this._addresses.values(); + return Array.from(addresses).filter((addressInfo) => addressInfo.zone === zone); + } + + /** + * Gets the addresses for a given account. + * + * @param {number} account - The account number. + * @returns {NeuteredAddressInfo[]} The addresses for the account. + */ + public getAddressesForAccount(account: number): NeuteredAddressInfo[] { + const addresses = this._addresses.values(); + return Array.from(addresses).filter((addressInfo) => addressInfo.account === account); + } + + protected _findLastUsedIndex(addresses: NeuteredAddressInfo[] | undefined, account: number, zone: Zone): number { + const filteredAddresses = addresses?.filter( + (addressInfo) => addressInfo.account === account && addressInfo.zone === zone, + ); + return filteredAddresses?.reduce((maxIndex, addressInfo) => Math.max(maxIndex, addressInfo.index), -1) || -1; + } } diff --git a/testcases/qi-addresses.json.gz b/testcases/qi-addresses.json.gz deleted file mode 100644 index 22e43036..00000000 Binary files a/testcases/qi-addresses.json.gz and /dev/null differ diff --git a/testcases/qi-wallet-import-privkey.json.gz b/testcases/qi-wallet-import-privkey.json.gz new file mode 100644 index 00000000..4de7f901 Binary files /dev/null and b/testcases/qi-wallet-import-privkey.json.gz differ diff --git a/testcases/quai-address-derivation.json.gz b/testcases/quai-address-derivation.json.gz new file mode 100644 index 00000000..84a20dc0 Binary files /dev/null and b/testcases/quai-address-derivation.json.gz differ diff --git a/testcases/quai-addresses.json.gz b/testcases/quai-addresses.json.gz deleted file mode 100644 index 3ac8cf8b..00000000 Binary files a/testcases/quai-addresses.json.gz and /dev/null differ