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

refactor!: migrate HdKeyring to typescript #166

Open
wants to merge 12 commits into
base: main
Choose a base branch
from

Conversation

mikesposito
Copy link
Member

@mikesposito mikesposito commented Jan 28, 2025

The @metamask/eth-hd-keyring is being migrated to Typescript.

The changes applied can be seen as diff in this commit: bc89c46

This package migration can be tested on this extension PR: MetaMask/metamask-extension#29961


There is no consumer-facing change, besides this one:

  • BREAKING: Added types to HdKeyring
    • All methods on HdKeyring retain their existing signatures, but now have types.

Fixes: #92

Copy link

socket-security bot commented Jan 28, 2025

Updated dependencies detected. Learn more about Socket for GitHub ↗︎

Package New capabilities Transitives Size Publisher
npm/@metamask/[email protected] 🔁 npm/@metamask/[email protected] None +39 6.05 MB gudahtt

View full report↗︎

@mikesposito mikesposito changed the title Migrate HdKeyring to typescript refactor!: migrate HdKeyring to typescript Jan 28, 2025
@mikesposito mikesposito marked this pull request as ready for review January 28, 2025 15:31
@mikesposito mikesposito requested a review from a team as a code owner January 28, 2025 15:31
opts: HDKeyringAccountSelectionOptions = {},
): Promise<string> {
const privKey = this.#getPrivateKeyFor(withAccount, opts);
const publicKey = getEncryptionPublicKey(bytesToHex(privKey));
Copy link
Member Author

Choose a reason for hiding this comment

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

In the original implementation privKey was passed to getEncryptionPublicKey directly:

const publicKey = getEncryptionPublicKey(privKey);

Though, the addition of types evidenced that getEncryptionPublicKey only accept hex strings, hence the conversion with bytesToHex

Comment on lines 493 to 511
/**
* Get the wallet for the specified account.
*
* @param account - The address of the account.
* @returns The wallet for the account as HDKey.
*/
#getWalletForAccount(account: string): HDKey;

/**
* Get the wallet for the specified account and app origin.
*
* @param account - The address of the account.
* @param opts - The options for selecting the account.
* @returns A key pair representing the wallet.
*/
#getWalletForAccount(
accounts: string,
opts: { withAppKeyOrigin: string },
): { privateKey: Buffer; publicKey: Buffer };
Copy link
Member Author

Choose a reason for hiding this comment

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

This internal method is overloaded because the objects returned can vary significantly based on whether an app key origin is specified (i.e. a Buffer keypair is returned instead of an entire HDKey instance).

The actual signature of the implementation is left unchanged.

Comment on lines -60 to +64
"@metamask/eth-hd-keyring": "4.0.1",
"@ts-bridge/cli": "^0.6.1",
"@types/jest": "^29.5.12",
"deepmerge": "^4.2.2",
"jest": "^29.5.0"
"jest": "^29.5.0",
"old-hd-keyring": "npm:@metamask/eth-hd-keyring@^4.0.1"
Copy link
Member Author

Choose a reason for hiding this comment

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

This package used for testing backward compatibility was overwriting the current version of @metamask/eth-hd-keyring, causing TS to apply the same types of the new package. With this line we can import the package with a different name.

The version 4.0.1 was pinned, but yarn lint was failing and I guess that ^4.0.1 serves the same purpose

Copy link
Member Author

Choose a reason for hiding this comment

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

There should be no functional change in these test cases, besides the ones to make TS happy

@mikesposito
Copy link
Member Author

@metamaskbot publish-preview

Copy link

Preview builds have been published. See these instructions (from the core monorepo) for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/keyring-api": "15.0.0-15c90e6",
  "@metamask-previews/eth-hd-keyring": "9.0.1-15c90e6",
  "@metamask-previews/eth-ledger-bridge-keyring": "8.0.3-15c90e6",
  "@metamask-previews/eth-simple-keyring": "8.0.1-15c90e6",
  "@metamask-previews/eth-trezor-keyring": "6.0.1-15c90e6",
  "@metamask-previews/keyring-internal-api": "3.0.0-15c90e6",
  "@metamask-previews/keyring-internal-snap-client": "3.0.1-15c90e6",
  "@metamask-previews/eth-snap-keyring": "9.0.0-15c90e6",
  "@metamask-previews/keyring-snap-client": "3.0.1-15c90e6",
  "@metamask-previews/keyring-snap-sdk": "2.1.0-15c90e6",
  "@metamask-previews/keyring-utils": "1.3.0-15c90e6"
}

Comment on lines +290 to +291
// WARN: verify this cast to Buffer
msgSig.v as unknown as Buffer,
Copy link
Member Author

Choose a reason for hiding this comment

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

It looks like @metamask/eth-sig-util only accepts a Buffer here, while msgSig.v seems to be a bigint

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, does that cause an issue? A bigint is not a Buffer.

Copy link
Member Author

Choose a reason for hiding this comment

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

The implementation is unchanged and is the same that is in prod already:

const rawMsgSig = concatSig(msgSig.v, msgSig.r, msgSig.s);
- bigint is not a Buffer but that's what we have always passed to concatSig (which expects a Buffer)

Though now the same code is throwing a type error. I think I left that // WARN there to come back at this and see if we can apply a conversion without breaking things

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, strange, okay.

@@ -154,7 +159,7 @@ describe('hd-keyring', () => {
numberOfAccounts: 2,
});

const accounts = await keyring.getAccounts();
const accounts = keyring.getAccounts();
Copy link
Member Author

Choose a reason for hiding this comment

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

getAccounts() was awaited everywhere in the tests, but the function implementation was not marked as async

Copy link
Member Author

Choose a reason for hiding this comment

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

we can change the tests here, and then change them back along with fixing the implementation when HDKeyring will implement the Keyring type

Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

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

Looks like a good conversion overall, just had some questions on specific lines.

this.#wallets.push(wallet);
}
const hexWallets = newWallets.map((wallet) => {
return this.#addressfromPublicKey(wallet.publicKey as Uint8Array);
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we have to make this type assertion?

*/
getAccounts(): string[] {
return this.#wallets.map((wallet) => {
assert(wallet.publicKey, 'Expected public key to be set');
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we simply throw an error instead of using assert? Otherwise this library is Node-only (or else, assert needs to be polyfilled). Unless it's already Node-only?

Suggested change
assert(wallet.publicKey, 'Expected public key to be set');
if (wallet.publicKey === undefined) {
throw new Error('Expected public key to be set');
}

Copy link
Member Author

@mikesposito mikesposito Jan 29, 2025

Choose a reason for hiding this comment

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

Hmm, this assert comes from @metamask/utils instead of the built-in Node.js' assert. Looking at its implementation it seems to be compatible with a browser environment as well: https://github.com/MetaMask/utils/blob/90837bad1d85277aa5b8f16fe40beb81e0a1e09d/src/assert.ts#L81

Having this in mind, do you still think we should substitute these with explicit if undefined ?

const wallet = this.#getWalletForAccount(address, {
withAppKeyOrigin: origin,
});
assert(wallet.publicKey, 'Expected public key to be set');
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar as above?

Suggested change
assert(wallet.publicKey, 'Expected public key to be set');
if (wallet.publicKey === undefined) {
throw new Error('Expected public key to be set');
}

? this.#getWalletForAccount(address, opts)
: this.#getWalletForAccount(address);
const { privateKey } = wallet;
assert(
Copy link
Contributor

Choose a reason for hiding this comment

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

Another case where we could possibly throw an error manually instead of using assert? (You get the idea)

const msgSig = ecsign(Buffer.from(message, 'hex'), Buffer.from(privKey));
const rawMsgSig = concatSig(
// WARN: verify this cast to Buffer
msgSig.v as unknown as Buffer,
Copy link
Contributor

@mcmire mcmire Jan 28, 2025

Choose a reason for hiding this comment

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

Curious, why do we need to make a type assertion here? I think you answered this question above


const privateKey = this.#getPrivateKeyFor(withAccount, opts);
return signTypedData({
privateKey: Buffer.from(privateKey),
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like we are converting privateKey to a Buffer when we weren't before. Is this a breaking change or was this supposed to be this way all along?

Copy link
Member Author

@mikesposito mikesposito Jan 29, 2025

Choose a reason for hiding this comment

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

Since #getPrivateKeyFor returns a Uint8Array or a Buffer depending on whether we are passing { withAppKeyOrigin: string } as opts, signTypedData could receive something that is not a Buffer.

This is to say that it was supposed to be this way all along. Using Buffer.from(privateKey) should convert the Uint8Array to a Buffer or just return another Buffer with the same content as the original privateKey

mnemonic: Buffer | JsCastedBuffer | string | Uint8Array | number[],
): Uint8Array {
let mnemonicData: unknown = mnemonic;
// when encrypted/decrypted, buffers get cast into js object with a property type set to buffer
Copy link
Contributor

Choose a reason for hiding this comment

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

This explanation sounds a bit vague to me. What does it mean to cast a buffer into a JS object? Are we trying to serialize the buffer? I guess we could leave this comment here for now but I'm curious if there is a more descriptive name for this function.

Copy link
Member Author

Choose a reason for hiding this comment

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

The comment is left unchanged from the original repo, though I think that it is referring to the output of buffer.toJSON() which returns an object with this shape:

{
  type: 'Buffer',
  data: [
     71, 101, 101, 107,
    115, 102, 111, 114,
     71, 101, 101, 107,
    115
  ]
}

Perhaps we can change it to be something like this:

Suggested change
// when encrypted/decrypted, buffers get cast into js object with a property type set to buffer
// When using Buffer.toJSON(), the Buffer is serialized into an object
// with the structure `{ type: 'Buffer', data: [...] }`

* @param publicKey - The public key of the account.
* @returns The address of the account.
*/
#addressfromPublicKey(publicKey: Uint8Array): string {
Copy link
Contributor

Choose a reason for hiding this comment

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

Does it make sense for this return type to be Hex? Or does that screw things up elsewhere?

(I think we have a function in @metamask/utils that we can use instead of bufferToHex which already returns a Hex, so if we want to / plan on using that later, that's fine, too.)

Copy link
Member Author

@mikesposito mikesposito Jan 29, 2025

Choose a reason for hiding this comment

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

I changed all types related to addresses from string to Hex. I guess you were referring to add0x which is now used to normalize addresses when needed (added HdKeyring.#normalizeAddress): 4d86d8e

@mikesposito
Copy link
Member Author

The following extension PR is using a package preview from this PR, which appears to be working fine: MetaMask/metamask-extension#29961

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Migrate @metamask/eth-hd-keyring to TypeScript
2 participants