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

add new method: signTypedData() #207

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
84 changes: 62 additions & 22 deletions src/wallet/hdwallet.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file looks good. Only thing I noticed that needs to be changed is to remove async from the serialize method signature since it is not async.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed async from serialize

Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import { getZoneForAddress, isQiAddress } from '../utils/index.js';
import { Zone } from '../constants/index.js';
import { TransactionRequest, Provider, TransactionResponse } from '../providers/index.js';
import { AllowedCoinType } from '../constants/index.js';
import { QiHDWallet } from './qi-hdwallet.js';
import { QuaiHDWallet } from './quai-hdwallet.js';

export interface NeuteredAddressInfo {
pubKey: string;
Expand Down Expand Up @@ -253,8 +251,19 @@ export abstract class AbstractHDWallet {
}
}

// Returns the HD node that derives the address.
// If the address is not found in the wallet, an error is thrown.
/**
* 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) {
Expand All @@ -271,9 +280,24 @@ export abstract class AbstractHDWallet {
return changeNode.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>;

public async serialize(): Promise<SerializedHDWallet> {
/**
* Serializes the HD wallet state into a format suitable for storage or transmission.
*
* @returns {SerializedHDWallet} An object representing the serialized state of the HD wallet, including version,
* mnemonic phrase, coin type, and addresses.
*/
public serialize(): SerializedHDWallet {
const addresses = Array.from(this._addresses.values());
return {
version: (this.constructor as any)._version,
Expand All @@ -283,31 +307,47 @@ export abstract class AbstractHDWallet {
};
}

static async deserialize<T extends AbstractHDWallet>(
this: new (root: HDNodeWallet, provider?: Provider) => T,
serialized: SerializedHDWallet,
): Promise<QuaiHDWallet | QiHDWallet> {
// validate the version and coinType
/**
* Deserializes a serialized HD wallet object and reconstructs the wallet instance. This method must be implemented
* in the subclass.
*
* @param {SerializedHDWallet} _serialized - The serialized object representing the state of an HD wallet.
*
* @returns {AbstractHDWallet} An instance of AbstractHDWallet.
* @throws {Error} This method must be implemented in the subclass.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public static async deserialize(_serialized: SerializedHDWallet): Promise<AbstractHDWallet> {
throw new Error('deserialize method must be implemented in the subclass');
}

/**
* Validates the version and coinType of the serialized wallet.
*
* @param {SerializedHDWallet} serialized - The serialized wallet data to be validated.
* @throws {Error} If the version or coinType of the serialized wallet does not match the expected values.
* @protected
* @static
*/
protected static validateSerializedWallet(serialized: SerializedHDWallet): void {
if (serialized.version !== (this as any)._version) {
throw new Error(`Invalid version ${serialized.version} for wallet (expected ${(this as any)._version})`);
}
if (serialized.coinType !== (this as any)._coinType) {
throw new Error(`Invalid coinType ${serialized.coinType} for wallet (expected ${(this as any)._coinType})`);
}
// 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(root);

// import the addresses
wallet.importSerializedAddresses(wallet._addresses, serialized.addresses);

return wallet as T;
}

// This method is used to import addresses from a serialized wallet.
// It validates the addresses and adds them to the wallet.
/**
* 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[],
Expand Down
206 changes: 126 additions & 80 deletions src/wallet/qi-hdwallet.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@


import { AbstractHDWallet, NeuteredAddressInfo, SerializedHDWallet } from './hdwallet';
import { HDNodeWallet } from "./hdnodewallet";
import { HDNodeWallet } from './hdnodewallet';
import { QiTransactionRequest, Provider, TransactionResponse } from '../providers/index.js';
import { computeAddress } from '../address/index.js';
import { getBytes, hexlify } from '../utils/index.js';
Expand All @@ -13,6 +11,7 @@ import { musigCrypto } from '../crypto/index.js';
import { Outpoint } from '../transaction/utxo.js';
import { getZoneForAddress } from '../utils/index.js';
import { AllowedCoinType, Zone } from '../constants/index.js';
import { Mnemonic } from './mnemonic.js';

type OutpointInfo = {
outpoint: Outpoint;
Expand All @@ -21,15 +20,14 @@ type OutpointInfo = {
account?: number;
};

interface SerializedQiHDWallet extends SerializedHDWallet{
outpoints: OutpointInfo[];
changeAddresses: NeuteredAddressInfo[];
gapAddresses: NeuteredAddressInfo[];
gapChangeAddresses: NeuteredAddressInfo[];
interface SerializedQiHDWallet extends SerializedHDWallet {
outpoints: OutpointInfo[];
changeAddresses: NeuteredAddressInfo[];
gapAddresses: NeuteredAddressInfo[];
gapChangeAddresses: NeuteredAddressInfo[];
}

export class QiHDWallet extends AbstractHDWallet {

protected static _version: number = 1;

protected static _GAP_LIMIT: number = 20;
Expand Down Expand Up @@ -81,10 +79,8 @@ export class QiHDWallet extends AbstractHDWallet {
}

public importOutpoints(outpoints: OutpointInfo[]): void {
outpoints.forEach((outpoint) => {
this.validateZone(outpoint.zone);
this._outpoints.push(outpoint);
});
this.validateOutpointInfo(outpoints);
this._outpoints.push(...outpoints);
}

public getOutpoints(zone: Zone): OutpointInfo[] {
Expand Down Expand Up @@ -319,71 +315,121 @@ export class QiHDWallet extends AbstractHDWallet {
return gapAddresses;
}

public getGapChangeAddressesForZone(zone: Zone): NeuteredAddressInfo[] {
this.validateZone(zone);
const gapChangeAddresses = this._gapChangeAddresses.filter((addressInfo) => addressInfo.zone === zone);
return gapChangeAddresses;
}

public async signMessage(address: string, message: string | Uint8Array): Promise<string> {
const addrNode = this._getHDNodeForAddress(address);
const privKey = addrNode.privateKey;
const digest = keccak_256(message);
const signature = schnorr.sign(digest, getBytes(privKey));
return hexlify(signature);
}

public async serialize(): Promise<SerializedQiHDWallet> {
const hdwalletSerialized = await super.serialize();
return {
outpoints: this._outpoints,
changeAddresses: Array.from(this._changeAddresses.values()),
gapAddresses: this._gapAddresses,
gapChangeAddresses: this._gapChangeAddresses,
...hdwalletSerialized,
};
}

public static async deserialize(serialized: SerializedQiHDWallet): Promise<QiHDWallet> {
const wallet = await super.deserialize<QiHDWallet>(serialized) as QiHDWallet;
// import the change addresses
wallet.importSerializedAddresses(wallet._changeAddresses, serialized.changeAddresses);

// import the gap addresses, verifying they 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 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 outpoints and import them
for (const outpointInfo of serialized.outpoints) {
// check the zone is valid
wallet.validateZone(outpointInfo.zone);
// check the outpoint address is known to the wallet
if (!wallet._addresses.has(outpointInfo.address)) {
throw new Error(`Address ${outpointInfo.address} not found in wallet`);
}
const outpoint = outpointInfo.outpoint;
// TODO: implement a more robust check for Outpoint
// check the Outpoint fields are not empty
if (outpoint.Txhash == null || outpoint.Index == null || outpoint.Denomination == null) {
throw new Error(`Invalid Outpoint: ${JSON.stringify(outpoint)} `);
}
wallet._outpoints.push(outpointInfo);
}
return wallet;

}
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 = keccak_256(message);
const signature = schnorr.sign(digest, getBytes(privKey));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets use the keccak256 method exported from crypto/index.js everywhere in this file in place of keccak_256 from noble

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some incompatibilites between the keccak method from noble and the one exported by quais. It does not work straight out of the box and requires a deeper assesment.

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._outpoints,
changeAddresses: Array.from(this._changeAddresses.values()),
gapAddresses: this._gapAddresses,
gapChangeAddresses: this._gapChangeAddresses,
...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<QiHDWallet> {
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(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 outpoints and import them
wallet.validateOutpointInfo(serialized.outpoints);
wallet._outpoints.push(...serialized.outpoints);
return wallet;
}

/**
* 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.
*
* @private
* @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
if (!this._addresses.has(info.address)) {
throw new Error(`Address ${info.address} not found in wallet`);
}
// validate account
if (info.account && !this._accounts.has(info.account)) {
throw new Error(`Account ${info.account} not found in wallet`);
}
// validate Outpoint
if (info.outpoint.Txhash == null || info.outpoint.Index == null || info.outpoint.Denomination == null) {
throw new Error(`Invalid Outpoint: ${JSON.stringify(info)} `);
}
});
}
}
Loading
Loading