From 43d90b2e1c862f0a69ab14d85fe88dab7ee38109 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 6 Dec 2023 14:47:52 +0100 Subject: [PATCH] feat: add support for ERC-4337 accounts (#213) * docs: add EIP-4337 UserOp support * wip: support 4337 accounts * chore: simplify regex * feat: add `definePattern` helper function * chore: move types to `eth` directory * chore: use `definePattern` to define `UuidV4` type * chore: move user operation sequence diagram * docs: add `eth_signUserOperation` * feat: add `BasicTransaction` type * fix: add index files * feat: add `PreparedUserOperation` type * fix: make `gasLimits` optional * feat: add `EthUserOperationPatch` type * feat: export base ETH types * feat: add `EthKeyring` type * fix: add UserOperation methods to support methods enum * docs: add link to ERC-4337 spec --- docs/architecture.md | 10 + docs/diagrams/user_operation_signing.puml | 115 +++++++++ docs/evm-methods.md | 14 +- docs/evm_methods_userOp.md | 271 ++++++++++++++++++++++ jest.config.js | 6 +- src/api.ts | 12 +- src/eth/erc4337/index.ts | 1 + src/eth/erc4337/types.test-d.ts | 46 ++++ src/eth/erc4337/types.test.ts | 79 +++++++ src/eth/erc4337/types.ts | 71 ++++++ src/eth/index.ts | 2 + src/eth/types.ts | 13 ++ src/index.ts | 1 + src/internal/eth/EthKeyring.ts | 47 ++++ src/internal/eth/index.ts | 1 + src/internal/index.ts | 1 + src/superstruct.ts | 26 ++- src/utils.ts | 9 +- 18 files changed, 709 insertions(+), 16 deletions(-) create mode 100644 docs/diagrams/user_operation_signing.puml create mode 100644 docs/evm_methods_userOp.md create mode 100644 src/eth/erc4337/index.ts create mode 100644 src/eth/erc4337/types.test-d.ts create mode 100644 src/eth/erc4337/types.test.ts create mode 100644 src/eth/erc4337/types.ts create mode 100644 src/eth/index.ts create mode 100644 src/eth/types.ts create mode 100644 src/internal/eth/EthKeyring.ts create mode 100644 src/internal/eth/index.ts diff --git a/docs/architecture.md b/docs/architecture.md index cb297d029..c91ccd93c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -163,6 +163,11 @@ participant Site as Snap Companion Dapp User ->>+ Dapp: Create new sign request Dapp ->>+ MetaMask: ethereum.request(request) +alt Is EIP-4337 account? + MetaMask ->>+ Snap: keyring_prepareRequest(request) + Snap ->> Snap: Custom logic to prepare request + Snap -->>- MetaMask: { request } +end MetaMask ->> MetaMask: Display request to user User ->> MetaMask: Approve request @@ -208,6 +213,11 @@ participant Snap User ->>+ Dapp: Create new sign request Dapp ->>+ MetaMask: ethereum.request(request) +alt Is EIP-4337 account? + MetaMask ->>+ Snap: keyring_prepareRequest(request) + Snap ->> Snap: Custom logic to prepare request + Snap -->>- MetaMask: { request } +end MetaMask ->> MetaMask: Display request to user User ->> MetaMask: Approve request diff --git a/docs/diagrams/user_operation_signing.puml b/docs/diagrams/user_operation_signing.puml new file mode 100644 index 000000000..9987446cf --- /dev/null +++ b/docs/diagrams/user_operation_signing.puml @@ -0,0 +1,115 @@ +@startuml "ERC-4337 Account Support" +autonumber +skinparam fontname Arial + +title "ERC-4337 UserOperation Signing" + +participant Dapp +participant MetaMask +participant Snap + +Dapp -> MetaMask ++: ""{""\n\ +"" chainId, // Ignored by MetaMask""\n\ +"" from,""\n\ +"" to,""\n\ +"" value,""\n\ +"" data,""\n\ +} + +note over MetaMask + Currently, only one transaction + per UserOp will be supported +end note + +MetaMask -> Snap ++: ""prepareUserOperation({""\n\ +"" account: account.id,""\n\ +"" scope: `eip155:${chainId}`,""\n\ +"" transactions: [ // List of transactions""\n\ +"" {""\n\ +"" to,""\n\ +"" value,""\n\ +"" data,""\n\ +"" },""\n\ +"" ]""\n\ +}) + +Snap --> MetaMask --: ""{""\n\ +"" callData,""\n\ +"" initCode?,""\n\ +"" nonce,""\n\ +"" gasLimits?: {""\n\ +"" callGasLimit,""\n\ +"" verificationGasLimit,""\n\ +"" preVerificationGas,""\n\ +"" },""\n\ +"" dummySignature?,""\n\ +"" dummyPaymasterAndData?,""\n\ +"" bundler?,""\n\ +""}"" + +MetaMask -> MetaMask: Check if the account is already deployed + +alt The account is already deployed + MetaMask -> MetaMask: Remove the ""initCode"" if set +else The account is not deployed and the ""initCode"" isn't present + MetaMask -> Dapp: Throw an error (without the exact reason) +end + +alt The ""gas"" isn't set + MetaMask -> MetaMask: Estimate and set gas values +end + +MetaMask -> MetaMask: Estimate and set gas fees + +MetaMask -> Snap ++: ""patchUserOperation({""\n\ +"" sender,""\n\ +"" nonce,""\n\ +"" initCode,""\n\ +"" callData,""\n\ +"" callGasLimit,""\n\ +"" verificationGasLimit,""\n\ +"" preVerificationGas,""\n\ +"" maxFeePerGas,""\n\ +"" maxPriorityFeePerGas,""\n\ +"" paymasterAndData, // Dummy value or empty""\n\ +"" signature, // Dummy value or empty""\n\ +""})"" + +Snap --> MetaMask --: ""{""\n\ +"" paymasterAndData?,""\n\ +""}"" + +MetaMask -> MetaMask: Update ""paymasterAndData"" and\n\ +remove the dummy signature + +MetaMask -> MetaMask: Display approval UI + +MetaMask -> Snap ++: ""signUserOperation([""\n\ +"" {""\n\ +"" sender,""\n\ +"" nonce,""\n\ +"" initCode,""\n\ +"" callData,""\n\ +"" callGasLimit,""\n\ +"" verificationGasLimit,""\n\ +"" preVerificationGas,""\n\ +"" maxFeePerGas,""\n\ +"" maxPriorityFeePerGas,""\n\ +"" paymasterAndData,""\n\ +"" signature, // Empty""\n\ +"" },""\n\ +"" entrypoint, // Entrypoint deployed by""\n\ +"" // the Ethereum Foundation""\n\ +""])"" + +Snap --> MetaMask --: ""{""\n\ +"" signature,""\n\ +""}"" + +MetaMask -> MetaMask: Update UserOp's ""signature"" + +MetaMask -> MetaMask: Submit UserOp to bundler and\n\ +wait for transaction hash + +MetaMask --> Dapp --: ""txHash"" +@enduml diff --git a/docs/evm-methods.md b/docs/evm-methods.md index 554ebc600..d332664a1 100644 --- a/docs/evm-methods.md +++ b/docs/evm-methods.md @@ -105,7 +105,7 @@ Adds support to [`eth_sendTransaction`][eth-send-transaction]. - Pattern: `^0x[0-9a-fA-F]{1,2}$` - `nonce` - Type: `string` - - Pattern: `^0x([1-9a-f]+[0-9a-f]*|0)$` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` - `to` - One-of: - Contract creation @@ -118,22 +118,22 @@ Adds support to [`eth_sendTransaction`][eth-send-transaction]. - Pattern: `^0x[0-9a-fA-F]{40}$` - `value` - Type: `string` - - Pattern: `^0x([1-9a-f]+[0-9a-f]*|0)$` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` - `data` - Type: `string` - Pattern: `^0x[0-9a-f]*$` - `gasLimit` - Type: `string` - - Pattern: `^0x([1-9a-f]+[0-9a-f]*|0)$` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` - `gasPrice` - Type: `string` - - Pattern: `^0x([1-9a-f]+[0-9a-f]*|0)$` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` - `maxPriorityFeePerGas` - Type: `string` - - Pattern: `^0x([1-9a-f]+[0-9a-f]*|0)$` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` - `maxFeePerGas` - Type: `string` - - Pattern: `^0x([1-9a-f]+[0-9a-f]*|0)$` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` - `accessList`: - Description: EIP-2930 access list - Type: `array` @@ -150,7 +150,7 @@ Adds support to [`eth_sendTransaction`][eth-send-transaction]. - Pattern: `^0x[0-9a-f]{64}$` - `chainId` - Type: `string` - - Pattern: `^0x([1-9a-f]+[0-9a-f]*|0)$` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` ### Returns diff --git a/docs/evm_methods_userOp.md b/docs/evm_methods_userOp.md new file mode 100644 index 000000000..5f766d891 --- /dev/null +++ b/docs/evm_methods_userOp.md @@ -0,0 +1,271 @@ +# EVM Methods for ERC-4337 Accounts + +Here we document the methods that an account Snap may implement to support +requests using [ERC-4337][erc-4337] accounts. + +## eth_prepareUserOperation + +Prepare a new UserOperation from transaction data. + +### Parameters (Array) + +1. **Transactions (required)** + - Type: `array` + - Properties: + - Type: `object` + - Properties: + - `to` + - Type: `string` + - Pattern: `^0x[0-9a-fA-F]{40}$` + - `value` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `data` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + +### Returns + +- **UserOperation Details** + - Type: `object` + - Properties: + - `callData` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + - `initCode` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + - `nonce` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `gasLimits` (optional) + - Type: `object` + - Properties + - `callGasLimit` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `verificationGasLimit` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `preVerificationGas` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `dummySignature` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + - `dummyPaymasterAndData` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + - `bundlerUrl` + - Type: `string` + +### Example + +**Request:** + +```json +{ + "method": "eth_prepareUserOperation", + "params": [ + { + "to": "0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb", + "value": "0x0", + "data": "0x" + }, + { + "to": "0x660265edc169bab511a40c0e049cc1e33774443d", + "value": "0x0", + "data": "0x619a309f" + } + ] +} +``` + +**Response:** + +```json +{ + "callData": "0x70641a22000000000000000000000000f3de3c0d654fda23dad170f0f320a921725091270000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000e49871efa4000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec700000000000000000000000000000000000000000000000000000000067fd192000000000000000000000000000000000000000001411a0c3b763237f484fdd70000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000280000000000000003b6d03400d4a11d5eeaac28ec3f61d100daf4d40471f185280000000000000003b6d03408f1b19622a888c53c8ee4f7d7b4dc8f574ff906800000000000000000000000000000000000000000000000000000000", + "initCode": "0x22ff1dc5998258faa1ea45a776b57484f8ab80a2296601cd0000000000000000000000005147ce3947a407c95687131be01a2b8d55fd0a400000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000007d91ea6a0bc4a4238cd72386d935e35e3d8c318400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x1", + "gasLimits": { + "callGasLimit": "0x58a83", + "verificationGasLimit": "0xe8c4", + "preVerificationGas": "0xc57c" + }, + "dummySignature": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "dummyPaymasterAndData": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "bundlerUrl": "https://bundler.example.com/rpc" +} +``` + +## eth_patchUserOperation + +Patch _some_ allowed properties of an UserOperation. + +### Parameters (Array) + +1. **UserOperation object** + - Type: `object` + - Properties: + - `sender` + - Type: `string` + - Pattern: `^0x[0-9a-fA-F]{40}$` + - `nonce` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `initCode` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + - `callData` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + - `callGasLimit` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `verificationGasLimit` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `preVerificationGas` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `maxFeePerGas` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `maxPriorityFeePerGas` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `paymasterAndData` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + - `signature` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + +### Returns + +- **Partial UserOperation object** + - Type: `object` + - Properties: + - `paymasterAndData` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + +### Example + +**Request:** + +```json +{ + "method": "eth_patchUserOperation", + "params": [ + { + "sender": "0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4", + "nonce": "0x1", + "initCode": "0x", + "callData": "0x70641a22000000000000000000000000f3de3c0d654fda23dad170f0f320a921725091270000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000e49871efa4000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec700000000000000000000000000000000000000000000000000000000067fd192000000000000000000000000000000000000000001411a0c3b763237f484fdd70000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000280000000000000003b6d03400d4a11d5eeaac28ec3f61d100daf4d40471f185280000000000000003b6d03408f1b19622a888c53c8ee4f7d7b4dc8f574ff906800000000000000000000000000000000000000000000000000000000", + "callGasLimit": "0x58a83", + "verificationGasLimit": "0xe8c4", + "preVerificationGas": "0xc57c", + "maxFeePerGas": "0x87f0878c0", + "maxPriorityFeePerGas": "0x1dcd6500", + "paymasterAndData": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "signature": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ] +} +``` + +**Response:** + +```json +{ + "paymasterAndData": "0x952514d7cBCB495EACeB86e02154921401dB0Cd9dac17f958d2ee523a2206206994597c13d831ec700000000000000000000000000000000000000000000000000000000779b3fbb00000000000000006565b267000000000000000000000000000000000000000029195b31a9b1c6ccdeff53e359ebbcd5f075a93c1aaed93302e5fde5faf8047028b296b8a3fa4e22b063af5069ae9f656736ffda0ee090c0311155722b905f781b" +} +``` + +## eth_signUserOperation + +Sign an UserOperation. + +### Parameters (Array) + +1. **UserOperation object** + - Type: `object` + - Properties: + - `sender` + - Type: `string` + - Pattern: `^0x[0-9a-fA-F]{40}$` + - `nonce` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `initCode` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + - `callData` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + - `callGasLimit` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `verificationGasLimit` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `preVerificationGas` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `maxFeePerGas` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `maxPriorityFeePerGas` + - Type: `string` + - Pattern: `^0x([1-9a-f][0-9a-f]*|0)$` + - `paymasterAndData` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + - `signature` + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` +2. **Entrypoint** + - Type: `string` + - Pattern: `^0x[0-9a-f]{40}$` + +### Returns + +- **Signature** + - Type: `string` + - Pattern: `^0x[0-9a-f]*$` + +### Example + +**Request:** + +```json +{ + "method": "eth_signUserOperation", + "params": [ + { + "sender": "0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4", + "nonce": "0x1", + "initCode": "0x", + "callData": "0x70641a22000000000000000000000000f3de3c0d654fda23dad170f0f320a921725091270000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000e49871efa4000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec700000000000000000000000000000000000000000000000000000000067fd192000000000000000000000000000000000000000001411a0c3b763237f484fdd70000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000280000000000000003b6d03400d4a11d5eeaac28ec3f61d100daf4d40471f185280000000000000003b6d03408f1b19622a888c53c8ee4f7d7b4dc8f574ff906800000000000000000000000000000000000000000000000000000000", + "callGasLimit": "0x58a83", + "verificationGasLimit": "0xe8c4", + "preVerificationGas": "0xc57c", + "maxFeePerGas": "0x87f0878c0", + "maxPriorityFeePerGas": "0x1dcd6500", + "paymasterAndData": "0x952514d7cBCB495EACeB86e02154921401dB0Cd9dac17f958d2ee523a2206206994597c13d831ec700000000000000000000000000000000000000000000000000000000779b3fbb00000000000000006565b267000000000000000000000000000000000000000029195b31a9b1c6ccdeff53e359ebbcd5f075a93c1aaed93302e5fde5faf8047028b296b8a3fa4e22b063af5069ae9f656736ffda0ee090c0311155722b905f781b", + "signature": "0x" + }, + "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789" + ] +} +``` + +**Response:** + +```json +"0x6565acc7efd3c85e4c0c221c2958ff6c3ae68401b23b33fdcd1a2d49034c30d97b1cfa17487b90253a5dfd54ef5188688592c2fd56ba44ee4d948ea259d636cd550f6dd21b" +``` + +[erc-4337]: https://eips.ethereum.org/EIPS/eip-4337 diff --git a/jest.config.js b/jest.config.js index cef3c14f9..bd7fddb65 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,7 +22,11 @@ module.exports = { collectCoverage: true, // An array of glob patterns indicating a set of files for which coverage information should be collected - collectCoverageFrom: ['./src/**/*.ts', '!./src/**/*.test-d.ts'], + collectCoverageFrom: [ + './src/**/*.ts', + '!./src/**/index.ts', + '!./src/**/*.test-d.ts', + ], // The directory where Jest should output its coverage files coverageDirectory: 'coverage', diff --git a/src/api.ts b/src/api.ts index 915469a6a..c96908411 100644 --- a/src/api.ts +++ b/src/api.ts @@ -10,12 +10,17 @@ import { UuidStruct } from './utils'; * Supported Ethereum methods. */ export enum EthMethod { + // General signing methods PersonalSign = 'personal_sign', Sign = 'eth_sign', SignTransaction = 'eth_signTransaction', SignTypedDataV1 = 'eth_signTypedData_v1', SignTypedDataV3 = 'eth_signTypedData_v3', SignTypedDataV4 = 'eth_signTypedData_v4', + // ERC-4337 methods + PrepareUserOperation = 'eth_prepareUserOperation', + PatchUserOperation = 'eth_patchUserOperation', + SignUserOperation = 'eth_signUserOperation', } /** @@ -23,7 +28,7 @@ export enum EthMethod { */ export enum EthAccountType { Eoa = 'eip155:eoa', - Eip4337 = 'eip155:eip4337', + Erc4337 = 'eip155:erc4337', } export const KeyringAccountStruct = object({ @@ -53,13 +58,16 @@ export const KeyringAccountStruct = object({ `${EthMethod.SignTypedDataV1}`, `${EthMethod.SignTypedDataV3}`, `${EthMethod.SignTypedDataV4}`, + `${EthMethod.PrepareUserOperation}`, + `${EthMethod.PatchUserOperation}`, + `${EthMethod.SignUserOperation}`, ]), ), /** * Account type. */ - type: enums([`${EthAccountType.Eoa}`, `${EthAccountType.Eip4337}`]), + type: enums([`${EthAccountType.Eoa}`, `${EthAccountType.Erc4337}`]), }); /** diff --git a/src/eth/erc4337/index.ts b/src/eth/erc4337/index.ts new file mode 100644 index 000000000..fcb073fef --- /dev/null +++ b/src/eth/erc4337/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/src/eth/erc4337/types.test-d.ts b/src/eth/erc4337/types.test-d.ts new file mode 100644 index 000000000..ddc58c6c1 --- /dev/null +++ b/src/eth/erc4337/types.test-d.ts @@ -0,0 +1,46 @@ +import { expectAssignable, expectNotAssignable } from 'tsd'; + +import type { EthUserOperation } from './types'; + +// Valid UserOperation +expectAssignable({ + sender: '0x2A3e54af44480ad269cca53e3a4d90ce2DbEb23a', + nonce: '0x1', + initCode: '0x', + callData: '0x70641a22000000000000000000000000', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x1234', + signature: '0x1234', +}); + +// Missing `paymasterAndData` property +expectNotAssignable({ + sender: '0x2A3e54af44480ad269cca53e3a4d90ce2DbEb23a', + nonce: '0x1', + initCode: '0x', + callData: '0x70641a22000000000000000000000000', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + signature: '0x1234', +}); + +// Missing `signature` property +expectNotAssignable({ + sender: '0x2A3e54af44480ad269cca53e3a4d90ce2DbEb23a', + nonce: '0x1', + initCode: '0x', + callData: '0x70641a22000000000000000000000000', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x1234', +}); diff --git a/src/eth/erc4337/types.test.ts b/src/eth/erc4337/types.test.ts new file mode 100644 index 000000000..50175e498 --- /dev/null +++ b/src/eth/erc4337/types.test.ts @@ -0,0 +1,79 @@ +import { assert } from 'superstruct'; + +import { EthUserOperationStruct } from './types'; + +describe('types', () => { + it('is a valid UserOperation', () => { + const userOp = { + sender: '0x2A3e54af44480ad269cca53e3a4d90ce2DbEb23a', + nonce: '0x1', + initCode: '0x', + callData: '0x70641a22000000000000000000000000', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x1234', + signature: '0x1234', + }; + expect(() => assert(userOp, EthUserOperationStruct)).not.toThrow(); + }); + + it('has an shorter sender address', () => { + const userOp = { + sender: '0x2A3e54af44480ad269cca53e3a4d90ce2DbEb2', + nonce: '0x1', + initCode: '0x', + callData: '0x70641a22000000000000000000000000', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x1234', + signature: '0x1234', + }; + expect(() => assert(userOp, EthUserOperationStruct)).toThrow( + 'At path: sender -- Expected a value of type `EthAddress`, but received: `"0x2A3e54af44480ad269cca53e3a4d90ce2DbEb2"`', + ); + }); + + it('has an longer sender address', () => { + const userOp = { + sender: '0x2A3e54af44480ad269cca53e3a4d90ce2DbEb23a00', + nonce: '0x1', + initCode: '0x', + callData: '0x70641a22000000000000000000000000', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x1234', + signature: '0x1234', + }; + expect(() => assert(userOp, EthUserOperationStruct)).toThrow( + 'At path: sender -- Expected a value of type `EthAddress`, but received: `"0x2A3e54af44480ad269cca53e3a4d90ce2DbEb23a00"`', + ); + }); + + it('has an nonce that starts with zero', () => { + const userOp = { + sender: '0x2A3e54af44480ad269cca53e3a4d90ce2DbEb23a', + nonce: '0x01', + initCode: '0x', + callData: '0x70641a22000000000000000000000000', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x1234', + signature: '0x1234', + }; + expect(() => assert(userOp, EthUserOperationStruct)).toThrow( + 'At path: nonce -- Expected a value of type `EthUint256`, but received: `"0x01"`', + ); + }); +}); diff --git a/src/eth/erc4337/types.ts b/src/eth/erc4337/types.ts new file mode 100644 index 000000000..4bd3f3c59 --- /dev/null +++ b/src/eth/erc4337/types.ts @@ -0,0 +1,71 @@ +import { string, type Infer } from 'superstruct'; + +import { exactOptional, object } from '../../superstruct'; +import { EthAddressStruct, EthBytesStruct, EthUint256Struct } from '../types'; + +/** + * Struct of a UserOperation as defined by ERC-4337. + * @see https://eips.ethereum.org/EIPS/eip-4337#definitions + */ +export const EthUserOperationStruct = object({ + sender: EthAddressStruct, + nonce: EthUint256Struct, + initCode: EthBytesStruct, + callData: EthBytesStruct, + callGasLimit: EthUint256Struct, + verificationGasLimit: EthUint256Struct, + preVerificationGas: EthUint256Struct, + maxFeePerGas: EthUint256Struct, + maxPriorityFeePerGas: EthUint256Struct, + paymasterAndData: EthBytesStruct, + signature: EthBytesStruct, +}); + +export type EthUserOperation = Infer; + +/** + * Struct containing the most basic transaction information required to + * construct a UserOperation. + */ +export const EthBaseTransactionStruct = object({ + /** + * Address of the transaction recipient. + */ + to: EthAddressStruct, + + /** + * Amount of wei to transfer to the recipient. + */ + value: EthUint256Struct, + + /** + * Data to pass to the recipient. + */ + data: EthBytesStruct, +}); + +export type EthBaseTransaction = Infer; + +export const EthBaseUserOperationStruct = object({ + nonce: EthUint256Struct, + initCode: EthBytesStruct, + callData: EthBytesStruct, + gasLimits: exactOptional( + object({ + callGasLimit: EthUint256Struct, + verificationGasLimit: EthUint256Struct, + preVerificationGas: EthUint256Struct, + }), + ), + dummyPaymasterAndData: EthBytesStruct, + dummySignature: EthBytesStruct, + bundlerUrl: string(), +}); + +export type EthBaseUserOperation = Infer; + +export const EthUserOperationPatchStruct = object({ + paymasterAndData: EthBytesStruct, +}); + +export type EthUserOperationPatch = Infer; diff --git a/src/eth/index.ts b/src/eth/index.ts new file mode 100644 index 000000000..a7e4b147d --- /dev/null +++ b/src/eth/index.ts @@ -0,0 +1,2 @@ +export * from './erc4337'; +export * from './types'; diff --git a/src/eth/types.ts b/src/eth/types.ts new file mode 100644 index 000000000..921419764 --- /dev/null +++ b/src/eth/types.ts @@ -0,0 +1,13 @@ +import { definePattern } from '../superstruct'; + +export const EthBytesStruct = definePattern('EthBytes', /^0x[0-9a-f]*$/iu); + +export const EthAddressStruct = definePattern( + 'EthAddress', + /^0x[0-9a-f]{40}$/iu, +); + +export const EthUint256Struct = definePattern( + 'EthUint256', + /^0x([1-9a-f][0-9a-f]*|0)$/iu, +); diff --git a/src/index.ts b/src/index.ts index 491107036..7aa2fbbd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './api'; +export * from './eth'; export * from './events'; export * from './internal'; export * from './KeyringClient'; diff --git a/src/internal/eth/EthKeyring.ts b/src/internal/eth/EthKeyring.ts new file mode 100644 index 000000000..f921efc49 --- /dev/null +++ b/src/internal/eth/EthKeyring.ts @@ -0,0 +1,47 @@ +import type { Json, Keyring } from '@metamask/utils'; + +import type { + EthBaseTransaction, + EthBaseUserOperation, + EthUserOperation, + EthUserOperationPatch, +} from '../../eth'; + +export type EthKeyring = Keyring & { + /** + * Convert a base transaction to a base UserOperation. + * + * @param address - Address of the sender. + * @param transactions - Base transactions to include in the UserOperation. + * @returns A pseudo-UserOperation that can be used to construct a real. + */ + prepareUserOperation?( + address: string, + transactions: EthBaseTransaction[], + ): Promise; + + /** + * Patches properties of a UserOperation. Currently, only the + * `paymasterAndData` can be patched. + * + * @param address - Address of the sender. + * @param userOp - UserOperation to patch. + * @returns A patch to apply to the UserOperation. + */ + patchUserOperation?( + address: string, + userOp: EthUserOperation, + ): Promise; + + /** + * Signs an UserOperation. + * + * @param address - Address of the sender. + * @param userOp - UserOperation to sign. + * @returns The signature of the UserOperation. + */ + signUserOperation?( + address: string, + userOp: EthUserOperation, + ): Promise; +}; diff --git a/src/internal/eth/index.ts b/src/internal/eth/index.ts new file mode 100644 index 000000000..fa5f8282c --- /dev/null +++ b/src/internal/eth/index.ts @@ -0,0 +1 @@ +export * from './EthKeyring'; diff --git a/src/internal/index.ts b/src/internal/index.ts index 9b0ba884b..3b1199364 100644 --- a/src/internal/index.ts +++ b/src/internal/index.ts @@ -1,4 +1,5 @@ export * from './api'; +export * from './eth'; export * from './events'; export * from './rpc'; export * from './types'; diff --git a/src/superstruct.ts b/src/superstruct.ts index 002b67ca3..a3c6e0c1f 100644 --- a/src/superstruct.ts +++ b/src/superstruct.ts @@ -1,5 +1,5 @@ import type { Infer, Context } from 'superstruct'; -import { Struct, object as stObject } from 'superstruct'; +import { Struct, define, object as stObject } from 'superstruct'; import type { ObjectSchema, OmitBy, @@ -101,3 +101,27 @@ export function exactOptional( !hasOptional(ctx) || struct.refiner(value as Type, ctx), }); } + +/** + * Defines a new string-struct matching a regular expression. + * + * Example: + * + * ```ts + * const EthAddressStruct = definePattern('EthAddress', /^0x[0-9a-f]{40}$/iu); + * ``` + * + * @param name - Type name. + * @param pattern - Regular expression to match. + * @returns A new string-struct that matches the given pattern. + */ +export function definePattern( + name: string, + pattern: RegExp, +): Struct { + return define( + name, + (value: unknown): boolean => + typeof value === 'string' && pattern.test(value), + ); +} diff --git a/src/utils.ts b/src/utils.ts index 75d22dd4d..7e4393c2f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,15 +1,14 @@ -import { assert, define } from 'superstruct'; +import { assert } from 'superstruct'; import type { Struct } from 'superstruct'; -const UUID_V4_REGEX = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +import { definePattern } from './superstruct'; /** * UUIDv4 struct. */ -export const UuidStruct = define( +export const UuidStruct = definePattern( 'UuidV4', - (id: unknown): boolean => typeof id === 'string' && UUID_V4_REGEX.test(id), + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu, ); /**