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 3 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
44 changes: 23 additions & 21 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 Down Expand Up @@ -283,31 +292,24 @@ 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
// 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');
}

// This method is used to validate the version and coinType of the serialized wallet.
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.
// This method is used to import 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.
Copy link
Member

Choose a reason for hiding this comment

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

Going forward if we are adding comments to methods or classes to use JSDoc format like you did for _getHDNodeForAddress

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 added a new commit with JSDOC for mostly all new methods added in this PR. Will continue adding more JSDOC documentation on subsequent PRs

protected importSerializedAddresses(
addressMap: Map<string, NeuteredAddressInfo>,
addresses: NeuteredAddressInfo[],
Expand Down
168 changes: 88 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,83 @@ 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;
}

public async signMessage(address: string, message: string | Uint8Array): Promise<string> {
const addrNode = this._getHDNodeForAddress(address);
const privKey = addrNode.privateKey;
const digest = keccak_256(message);
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.

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> {
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;
}

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)} `);
}
});
}
}
44 changes: 41 additions & 3 deletions src/wallet/quai-hdwallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ 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 { Mnemonic } from './mnemonic.js';
import { TypedDataDomain, TypedDataField } from '../hash/index.js';

export class QuaiHDWallet extends AbstractHDWallet {

protected static _version: number = 1;

protected static _coinType: AllowedCoinType = 994;

private constructor(root: HDNodeWallet, provider?: Provider) {
Expand All @@ -31,8 +33,44 @@ export class QuaiHDWallet extends AbstractHDWallet {
return await fromNodeConnected.sendTransaction(tx);
}

public async signMessage(address: string, message: string | Uint8Array): Promise<string> {
public async signMessage(address: string, message: string | Uint8Array): Promise<string> {
const addrNode = this._getHDNodeForAddress(address);
return await addrNode.signMessage(message);
}

public static async deserialize(serialized: SerializedHDWallet): Promise<QuaiHDWallet> {
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);

return wallet;
}

/**
* Signs typed data using the private key associated with the given address.
*
* @param {string} address - The address for which the typed data is to be signed.
* @param {TypedDataDomain} domain - The domain information of the typed data, defining the scope of the signature.
* @param {Record<string, TypedDataField[]>} types - The types of the data to be signed, mapping each data type name
* to its fields.
* @param {Record<string, unknown>} value - The actual data to be signed.
*
* @returns {Promise<string>} A promise that resolves to the signed data in string format.
* @throws {Error} If the address does not correspond to a valid HD node or if signing fails.
*/
public async signTypedData(
address: string,
domain: TypedDataDomain,
types: Record<string, Array<TypedDataField>>,
value: Record<string, unknown>,
): Promise<string> {
const addrNode = this._getHDNodeForAddress(address);
return addrNode.signTypedData(domain, types, value);
}
}