Skip to content
This repository was archived by the owner on Oct 7, 2024. It is now read-only.

Migrate to TypeScript#161

Merged
mikesposito merged 33 commits intomainfrom
feat/migrate-to-typescript
Mar 9, 2023
Merged

Migrate to TypeScript#161
mikesposito merged 33 commits intomainfrom
feat/migrate-to-typescript

Conversation

@mikesposito
Copy link
Copy Markdown
Member

@mikesposito mikesposito commented Feb 9, 2023

This PR converts the package from JavaScript to TypeScript, adding needed devDependencies and configurations to make it work. The linting pipeline has also been updated, bringing some changes to the code to make it compliant with the latest module template version.

Resolves #156
Resolves #166

@socket-security
Copy link
Copy Markdown

socket-security bot commented Feb 16, 2023

Socket Security Pull Request Report

👍 No new dependency issues detected in pull request

Pull request report summary
Issue Status
Install scripts ✅ 0 issues
Native code ✅ 0 issues
Bin script shell injection ✅ 0 issues
Unresolved require ✅ 0 issues
Invalid package.json ✅ 0 issues
HTTP dependency ✅ 0 issues
Git dependency ✅ 0 issues
Potential typo squat ✅ 0 issues
Known Malware ✅ 0 issues
Telemetry ✅ 0 issues
Protestware/Troll package ✅ 0 issues
Bot Commands

To ignore an alert, reply with a comment starting with @SocketSecurity ignore followed by a space separated list of package-name@version specifiers. e.g. @SocketSecurity ignore foo@1.0.0 bar@* or ignore all packages with @SocketSecurity ignore-all

Powered by socket.dev

private async _signTransaction(
address: string,
chainId: number,
tx: TypedTransaction | OldEthJsTransaction,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Although it will be a bit verbose, this looks like a good spot for a function overload

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@andrewpeters9 what do you think about a solution with type generics?

async #signTransaction<T extends TypedTransaction | OldEthJsTransaction>(
    address: string,
    chainId: number,
    tx: T,
    handleSigning: (tx: EthereumSignedTx) => T,
): Promise<T>

@mikesposito mikesposito force-pushed the feat/migrate-to-typescript branch from fd0d11e to 75306c8 Compare February 23, 2023 09:44
@mikesposito mikesposito marked this pull request as ready for review February 23, 2023 10:13
@mikesposito mikesposito requested a review from a team as a code owner February 23, 2023 10:13
Copy link
Copy Markdown
Member

@Gudahtt Gudahtt left a comment

Choose a reason for hiding this comment

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

Looks great! Still not finished, I have a bit more left to review. Really appreciate the detailed comments

package.json Outdated
"@metamask/eslint-config": "^8.0.0",
"@metamask/eslint-config-mocha": "^8.0.0",
"@metamask/eslint-config-nodejs": "^8.0.0",
"@metamask/eslint-config-typescript": "^11.1.0",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This version of the package has a peerDependencies conflict; it is asking for a different version of eslint than the other config packages are. Generally it's better to keep these in sync with each other so that they all work more predictably.

To bring this in-line with the other configs, you'd want to change this to ^8.0.0 and downgrade the @typescript-eslint packages to ^4.20.0. We can update the configs together in a separate PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm. I see that this problem goes deeper. The version of ESLint we're using isn't compatible with any TypeScript version beyond v4.4.4, so we'd need to downgrade TypeScript as well, and that would force us to downgrade typedoc.

Maybe we can leave this as-is and update the ESLint configs to resolve this peer dependency conflict in a later PR.

to: this._normalize(tx.to),
};
to: this.#normalize(tx.to as unknown as Buffer),
} as EthereumTransaction | EthereumTransactionEIP1559;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Huh, interesting. This cast seems to be necessary because tx.toJSON claims that a bunch of things are potentially undefined even though they aren't. I looked at the source for toJSON, and this is what it returns:

 {
  nonce: string;
  gasPrice: string;
  gasLimit: string;
  to?: string;
  value: string;
  data: string;
  v?: string;
  r?: string;
  s?: string;
}

I tried casting the return value of toJSON to that value directly, and then the cast on this line wasn't needed. So what we're doing here should be type-safe, if the return value of toJSON was correct.

@mikesposito mikesposito force-pushed the feat/migrate-to-typescript branch from f588990 to 22dfb02 Compare February 27, 2023 09:46
Copy link
Copy Markdown
Member

@Gudahtt Gudahtt left a comment

Choose a reason for hiding this comment

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

Great work! One outstanding question on pinning that trezor dep, but everything else looks good to me!

Gudahtt
Gudahtt previously approved these changes Feb 27, 2023
Copy link
Copy Markdown
Member

@Gudahtt Gudahtt left a comment

Choose a reason for hiding this comment

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

LGTM!

Copy link
Copy Markdown

@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.

LGTM as well, aside from one thing.

path: this.#pathFromAddress(address),
data: {
types: { EIP712Domain, ...otherTypes },
types: { EIP712Domain, ...otherTypes } as T,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Forgive me if this has been asked already, but is there any way that we can avoid this typecasting?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@mcmire TS complains that the object might be instantiated with a different subset of MessageTypes, as we destructure dataWithHashes.types to give default values.
We can maybe avoid the cast and assign default values anyway in this way:

const {
      types,
      message = {},
      domain = {},
      primaryType,
      domain_separator_hash,
      message_hash,
    } = dataWithHashes;
    
// ...

data: {
    types: { ...types, EIP712Domain: types.EIP712Domain ?? [] },

What do you think?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yeah I think that would make sense!

*/
async _signTransaction(address, chainId, tx, handleSigning) {
let transaction;
async #signTransaction<T extends TypedTransaction | OldEthJsTransaction>(
Copy link
Copy Markdown

@andrewpeters9 andrewpeters9 Feb 28, 2023

Choose a reason for hiding this comment

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

Hmm, we can use extends here for tersity, but it's technically incorrect (unless we want to be allowing input that's an extension of those objects?) And we would be returning a less narrow type.

The way to overload this would be:

  async #signTransaction(
    address: string,
    chainId: number,
    tx: OldEthJsTransaction,
    handleSigning: (
      tx: EthereumSignedTx
    ) => (OldEthJsTransaction )
  ): Promise<OldEthJsTransaction>;
  async #signTransaction(
    address: string,
    chainId: number,
    tx: TypedTransaction,
    handleSigning: (
      tx: EthereumSignedTx,
    ) => (TypedTransaction)
  ): Promise<TypedTransaction>;
  async #signTransaction(
    address: string,
    chainId: number,
    tx: OldEthJsTransaction | TypedTransaction,
    handleSigning: (
      tx: EthereumSignedTx,
    ) => (OldEthJsTransaction | TypedTransaction)
  ): Promise<OldEthJsTransaction | TypedTransaction> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

but it's technically incorrect (unless we want to be allowing input that's an extension of those objects?

Hmm, it sounds like you're misunderstanding this keyword. It really doesn't imply any sort of OOP-style inheritance. Nothing about using extends with a type union is incorrect. This is a conventional and effective way to ensure that a generic parameter is one of two types.

Copy link
Copy Markdown

@andrewpeters9 andrewpeters9 Feb 28, 2023

Choose a reason for hiding this comment

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

This is a conventional and effective way to ensure that a generic parameter is one of two types.

Yes, but it allows for a different type that happens to extend OldEthJsTransaction | TypedTransaction to be passed in too - which I don't think we want to allow. E.g.:

type Tx1 = {
    name1: string;
}

type Tx2 = {
    name2: number;
}

const test = <A extends Tx1 | Tx2>(a:A, fn: (x: any) => A): A => {
    return a;
}

test({name1: "test", extraKey: "test"}, (x) => ({name1: "test", extraKey: "test"})); // ✅
test({name1: "test", name2: "test"}, (x) => ({name1: "test", name2: "test"})); // ✅

Whereas if we use overloads:

type Tx1 = {
    name1: string;
}

type Tx2 = {
    name2: number;
}

function test (a: Tx1, fn: (x: any) => Tx1): Tx1;
function test (a: Tx2, fn: (x: any) => Tx2): Tx2;
function test (a: any, fn: (x: any) => any): any {
    // last overload is ignored, so we can use 'any' for the type
    return a;
}

test({name1: "test", extraKey: "test"}, (x) => ({name1: "test", extraKey: "test"})); // ❌
test({name1: "test", name2: "test"}, (x) => ({name1: "test", name2: "test"})); // ❌
test({name1: "test"}, (x) => ({name1: "test"})); // ✅
test({name2: 4}, (x) => ({name2: 4})); // ✅

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ahh I see. Interesting, good point.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Using any in the implementation does effectively disable type checking though, which isn't ideal. We should at least use the type union there, getting us at least as much type safety as we had before.

Thinking about this further though, this seems based on the false assumption that we're expecting exact transaction objects here. The keyring interface we're using doesn't demand that, it allows additional properties. That is, the old type union seems more-correct even with the surprising behavior you've demonstrated in this code snippet.

Copy link
Copy Markdown

@andrewpeters9 andrewpeters9 Mar 2, 2023

Choose a reason for hiding this comment

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

Using any in the implementation does effectively disable type checking though

When using overloads, the last type definition is ignored completely, so any doesn't disable type-checking here

Regarding extended types - ok good point. I'll take a brief look into this today

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@Gudahtt I had a look into the code today. Given that the tx objects are being created within the keyring itself, and not publically, is there a scenario where we would want to allow the extension of the types?

I've found another benefit to using overloads here, as old transactions should be passing through a Buffer instead of a number. (See my comment on the overload)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@andrewpeters9 see my comment regarding the chainId coming from the old tx.
Moreover, recently a Keyring type has been added to utils (See https://github.com/MetaMask/utils/blob/main/src/keyring.ts#L137) so we should refactor this package to use that interface in a separate PR, which uses just the TypedTransaction - this should simplify a lot the implementation too as we will not expect an old tx anymore.

Do you think we could maybe track the object extensibility problem you are highlighting in a separate issue?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Given that the tx objects are being created within the keyring itself, and not publically

They're currently being created in the transaction controllers (in the core repo for mobile, and in metamask-extension for the extension).

Copy link
Copy Markdown

@andrewpeters9 andrewpeters9 left a comment

Choose a reason for hiding this comment

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

LGTM, only change I can think of is the function overload change for #signTransaction (see thread with @Gudahtt )

*/
async _signTransaction(address, chainId, tx, handleSigning) {
let transaction;
async #signTransaction<T extends TypedTransaction | OldEthJsTransaction>(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@Gudahtt I had a look into the code today. Given that the tx objects are being created within the keyring itself, and not publically, is there a scenario where we would want to allow the extension of the types?

I've found another benefit to using overloads here, as old transactions should be passing through a Buffer instead of a number. (See my comment on the overload)

function wait(ms) {
export interface TrezorControllerOptions {
hdPath?: string;
accounts?: string[];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: this could be readonly too

@@ -90,7 +151,7 @@ class TrezorKeyring extends EventEmitter {
TrezorConnect.dispose();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Potentially out of scope for this PR, but TrezorConnect.dispose() returns a promise, so making this promise access to outside the class might be handy. E.g.:

   dispose(): Promise<void> {
       return TrezorConnect.dipose():
    }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good catch! Doing it in a subsequent PR would probably be best. Tracked here: #170

// this function return value as Buffer, but the actual
// Transaction._chainId will always be a number.
// See https://github.com/ethereumjs/ethereumjs-tx/blob/v1.3.7/index.js#L126
tx.getChainId() as unknown as number,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We can remove as unknown as number and let the type pass through as a Buffer - see overload comment.


async #signTransaction(
address: string,
chainId: number,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

we can change this to chainId: Buffer,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@Gudahtt This is the other benefit I was mentioning regarding overloads. The chainIds for old tx's have to be Buffers instead of numbers

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@andrewpeters9 that getChainId() method is documented to return a Buffer, but it actually will return always a Number (see https://github.com/ethereumjs/ethereumjs-tx/blob/v1.3.7/index.js#L126).

This cast should be safe and we could leave this #signTransaction method as-is.


async #signTransaction(
address: string,
chainId: number,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This would then have to become chainId: any,

to: this._normalize(tx.to),
};
to: this.#normalize(ethUtil.toBuffer(tx.to)),
} as EthereumTransaction | EthereumTransactionEIP1559;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I just checked, maybe my TS engine is playing up, but it looks like we can remove as EthereumTransaction | EthereumTransactionEIP1559 here too


# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: but if you want to ignore IDE generated files you can add these lines (these are included in metamask-extension too):

# IDEs
.idea
.sublime-project

Copy link
Copy Markdown
Member

@Gudahtt Gudahtt left a comment

Choose a reason for hiding this comment

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

LGTM!

@mikesposito mikesposito merged commit 4d35092 into main Mar 9, 2023
@mikesposito mikesposito deleted the feat/migrate-to-typescript branch March 9, 2023 18:12
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sync linting pipeline with module template Migrate to TypeScript

5 participants