Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

QiHDWallet: implement importPrivateKey() #346

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions src/_tests/unit/qihdwallet-import-privkey.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
98 changes: 91 additions & 7 deletions src/wallet/qi-hdwallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -141,6 +141,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:
*
Expand Down Expand Up @@ -196,6 +202,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, []);
}

/**
Expand Down Expand Up @@ -243,6 +250,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,
Expand Down Expand Up @@ -934,6 +949,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;
Expand Down Expand Up @@ -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)]),
),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1543,4 +1564,67 @@ 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];
}
}
Binary file added testcases/qi-wallet-import-privkey.json.gz
Binary file not shown.
Loading