From 3c37b9a2e72a31c96cef1a6cc657e6663fde8ada Mon Sep 17 00:00:00 2001 From: Hayden Fowler Date: Wed, 20 Sep 2023 10:02:35 +1000 Subject: [PATCH] feat: ID-746 Passport - ZkEvm message signing --- CHANGELOG.md | 1 + packages/internal/guardian/src/client/api.ts | 1 + packages/internal/guardian/src/client/base.ts | 7 +- .../internal/guardian/src/client/common.ts | 8 +- .../src/client/domain/messages-api.ts | 331 ++++++++++++++++++ .../client/domain/starkex-transactions-api.ts | 11 +- .../src/client/domain/transactions-api.ts | 33 +- .../client/models/eip712-message-domain.ts | 54 +++ ...ip712-message-types-eip712-domain-inner.ts | 36 ++ .../src/client/models/eip712-message-types.ts | 35 ++ .../src/client/models/eip712-message.ts | 56 +++ .../guardian/src/client/models/evmmessage.ts | 57 +++ .../guardian/src/client/models/index.ts | 7 + .../models/message-evaluation-request.ts | 39 +++ .../models/message-evaluation-response.ts | 50 +++ .../models/transaction-evaluation-request.ts | 7 +- .../zkevm/EthSendTransactionExamples.tsx | 147 -------- .../TransferImx.tsx | 138 ++++++++ .../zkevm/EthSendTransactionExamples/index.ts | 7 + .../SignEtherMail.tsx | 85 +++++ .../ValidateEtherMail.tsx | 137 ++++++++ .../ValidateSignature.tsx | 123 +++++++ .../etherMailTypedPayload.ts | 64 ++++ .../zkevm/EthSignTypedDataV4Examples/index.ts | 11 + .../isSignatureValid.ts | 32 ++ .../src/components/zkevm/Request.tsx | 40 ++- .../zkevm/RequestExampleAccordion.tsx | 41 +++ .../sdk-sample-app/src/types/index.ts | 13 +- .../passport/sdk/src/Passport.int.test.ts | 2 +- .../sdk/src/confirmation/confirmation.test.ts | 9 + .../sdk/src/confirmation/confirmation.ts | 62 +++- .../passport/sdk/src/confirmation/types.ts | 2 + .../sdk/src/guardian/guardian.test.ts | 24 ++ .../passport/sdk/src/guardian/guardian.ts | 53 ++- packages/passport/sdk/src/index.ts | 1 + packages/passport/sdk/src/mocks/zkEvm/msw.ts | 3 +- packages/passport/sdk/src/test/mocks.ts | 4 + .../sdk/src/zkEvm/relayerClient.test.ts | 42 ++- .../passport/sdk/src/zkEvm/relayerClient.ts | 36 +- .../sdk/src/zkEvm/sendTransaction.test.ts | 8 +- .../sdk/src/zkEvm/signTypedDataV4.test.ts | 232 ++++++++++++ .../passport/sdk/src/zkEvm/signTypedDataV4.ts | 96 +++++ packages/passport/sdk/src/zkEvm/types.ts | 17 + .../sdk/src/zkEvm/walletHelpers.test.ts | 32 +- .../passport/sdk/src/zkEvm/walletHelpers.ts | 70 +++- .../sdk/src/zkEvm/zkEvmProvider.test.ts | 45 +++ .../passport/sdk/src/zkEvm/zkEvmProvider.ts | 17 + 47 files changed, 2078 insertions(+), 248 deletions(-) create mode 100644 packages/internal/guardian/src/client/domain/messages-api.ts create mode 100644 packages/internal/guardian/src/client/models/eip712-message-domain.ts create mode 100644 packages/internal/guardian/src/client/models/eip712-message-types-eip712-domain-inner.ts create mode 100644 packages/internal/guardian/src/client/models/eip712-message-types.ts create mode 100644 packages/internal/guardian/src/client/models/eip712-message.ts create mode 100644 packages/internal/guardian/src/client/models/evmmessage.ts create mode 100644 packages/internal/guardian/src/client/models/message-evaluation-request.ts create mode 100644 packages/internal/guardian/src/client/models/message-evaluation-response.ts delete mode 100644 packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples.tsx create mode 100644 packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferImx.tsx create mode 100644 packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/index.ts create mode 100644 packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SignEtherMail.tsx create mode 100644 packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateEtherMail.tsx create mode 100644 packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateSignature.tsx create mode 100644 packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/etherMailTypedPayload.ts create mode 100644 packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/index.ts create mode 100644 packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/isSignatureValid.ts create mode 100644 packages/passport/sdk-sample-app/src/components/zkevm/RequestExampleAccordion.tsx create mode 100644 packages/passport/sdk/src/zkEvm/signTypedDataV4.test.ts create mode 100644 packages/passport/sdk/src/zkEvm/signTypedDataV4.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb55f74ea..dfd104e73a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - @imtbl/orderbook: Added `getTrade` and `listTrades` methods for querying trades - @imtbl/blockchain-data: Added method `listCollectionsByNFTOwner` +- @imtbl/passport: Added support for `eth_signTypedData_v4` to Passport zkEvm provider. ## [0.16.0] - 2023-08-31 diff --git a/packages/internal/guardian/src/client/api.ts b/packages/internal/guardian/src/client/api.ts index bc444b18b7..50723da672 100644 --- a/packages/internal/guardian/src/client/api.ts +++ b/packages/internal/guardian/src/client/api.ts @@ -14,6 +14,7 @@ +export * from './domain/messages-api'; export * from './domain/starkex-transactions-api'; export * from './domain/transactions-api'; diff --git a/packages/internal/guardian/src/client/base.ts b/packages/internal/guardian/src/client/base.ts index 41e0843a31..e899ec57d0 100644 --- a/packages/internal/guardian/src/client/base.ts +++ b/packages/internal/guardian/src/client/base.ts @@ -13,11 +13,10 @@ */ -import type { Configuration } from './configuration'; +import { Configuration } from "./configuration"; // Some imports not used depending on template conditions // @ts-ignore -import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios'; -import globalAxios from 'axios'; +import globalAxios, { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios'; export const BASE_PATH = "https://guardian.sandbox.imtbl.com".replace(/\/+$/, ""); @@ -65,8 +64,8 @@ export class BaseAPI { * @extends {Error} */ export class RequiredError extends Error { + name: "RequiredError" = "RequiredError"; constructor(public field: string, msg?: string) { super(msg); - this.name = "RequiredError" } } diff --git a/packages/internal/guardian/src/client/common.ts b/packages/internal/guardian/src/client/common.ts index 8ba76a2768..55e53a50e3 100644 --- a/packages/internal/guardian/src/client/common.ts +++ b/packages/internal/guardian/src/client/common.ts @@ -13,10 +13,9 @@ */ -import type { Configuration } from "./configuration"; -import type { RequestArgs } from "./base"; -import type { AxiosInstance, AxiosResponse } from 'axios'; -import { RequiredError } from "./base"; +import { Configuration } from "./configuration"; +import { RequiredError, RequestArgs } from "./base"; +import { AxiosInstance, AxiosResponse } from 'axios'; /** * @@ -85,7 +84,6 @@ export const setOAuthToObject = async function (object: any, name: string, scope } function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void { - if (parameter == null) return; if (typeof parameter === "object") { if (Array.isArray(parameter)) { (parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key)); diff --git a/packages/internal/guardian/src/client/domain/messages-api.ts b/packages/internal/guardian/src/client/domain/messages-api.ts new file mode 100644 index 0000000000..60ecad6db5 --- /dev/null +++ b/packages/internal/guardian/src/client/domain/messages-api.ts @@ -0,0 +1,331 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Guardian + * Guardian API + * + * The version of the OpenAPI document: 1.0.0 + * Contact: support@immutable.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import globalAxios, { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios'; +import { Configuration } from '../configuration'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError } from '../base'; +// @ts-ignore +import { APIError400 } from '../models'; +// @ts-ignore +import { APIError403 } from '../models'; +// @ts-ignore +import { APIError404 } from '../models'; +// @ts-ignore +import { APIError500 } from '../models'; +// @ts-ignore +import { BasicAPIError } from '../models'; +// @ts-ignore +import { EVMMessage } from '../models'; +// @ts-ignore +import { MessageEvaluationRequest } from '../models'; +// @ts-ignore +import { MessageEvaluationResponse } from '../models'; +/** + * MessagesApi - axios parameter creator + * @export + */ +export const MessagesApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Approve a pending evm message + * @summary Approve a pending evm message + * @param {string} messageID id for the message + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + approvePendingMessage: async (messageID: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'messageID' is not null or undefined + assertParamExists('approvePendingMessage', 'messageID', messageID) + const localVarPath = `/guardian/v1/messages/{messageID}/approve` + .replace(`{${"messageID"}}`, encodeURIComponent(String(messageID))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Check if a given message is valid for EVM + * @summary Evaluate an evm message to sign + * @param {MessageEvaluationRequest} messageEvaluationRequest Specifies the kind of transaction + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + evaluateMessage: async (messageEvaluationRequest: MessageEvaluationRequest, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'messageEvaluationRequest' is not null or undefined + assertParamExists('evaluateMessage', 'messageEvaluationRequest', messageEvaluationRequest) + const localVarPath = `/guardian/v1/messages/evaluate`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(messageEvaluationRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Get an evm message by id + * @summary Info for a specific evm message + * @param {string} messageID The id of the evm message + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getMessageByID: async (messageID: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'messageID' is not null or undefined + assertParamExists('getMessageByID', 'messageID', messageID) + const localVarPath = `/guardian/v1/messages/{messageID}` + .replace(`{${"messageID"}}`, encodeURIComponent(String(messageID))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * MessagesApi - functional programming interface + * @export + */ +export const MessagesApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = MessagesApiAxiosParamCreator(configuration) + return { + /** + * Approve a pending evm message + * @summary Approve a pending evm message + * @param {string} messageID id for the message + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async approvePendingMessage(messageID: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.approvePendingMessage(messageID, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * Check if a given message is valid for EVM + * @summary Evaluate an evm message to sign + * @param {MessageEvaluationRequest} messageEvaluationRequest Specifies the kind of transaction + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async evaluateMessage(messageEvaluationRequest: MessageEvaluationRequest, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.evaluateMessage(messageEvaluationRequest, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * Get an evm message by id + * @summary Info for a specific evm message + * @param {string} messageID The id of the evm message + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getMessageByID(messageID: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getMessageByID(messageID, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * MessagesApi - factory interface + * @export + */ +export const MessagesApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = MessagesApiFp(configuration) + return { + /** + * Approve a pending evm message + * @summary Approve a pending evm message + * @param {string} messageID id for the message + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + approvePendingMessage(messageID: string, options?: any): AxiosPromise { + return localVarFp.approvePendingMessage(messageID, options).then((request) => request(axios, basePath)); + }, + /** + * Check if a given message is valid for EVM + * @summary Evaluate an evm message to sign + * @param {MessageEvaluationRequest} messageEvaluationRequest Specifies the kind of transaction + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + evaluateMessage(messageEvaluationRequest: MessageEvaluationRequest, options?: any): AxiosPromise { + return localVarFp.evaluateMessage(messageEvaluationRequest, options).then((request) => request(axios, basePath)); + }, + /** + * Get an evm message by id + * @summary Info for a specific evm message + * @param {string} messageID The id of the evm message + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getMessageByID(messageID: string, options?: any): AxiosPromise { + return localVarFp.getMessageByID(messageID, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for approvePendingMessage operation in MessagesApi. + * @export + * @interface MessagesApiApprovePendingMessageRequest + */ +export interface MessagesApiApprovePendingMessageRequest { + /** + * id for the message + * @type {string} + * @memberof MessagesApiApprovePendingMessage + */ + readonly messageID: string +} + +/** + * Request parameters for evaluateMessage operation in MessagesApi. + * @export + * @interface MessagesApiEvaluateMessageRequest + */ +export interface MessagesApiEvaluateMessageRequest { + /** + * Specifies the kind of transaction + * @type {MessageEvaluationRequest} + * @memberof MessagesApiEvaluateMessage + */ + readonly messageEvaluationRequest: MessageEvaluationRequest +} + +/** + * Request parameters for getMessageByID operation in MessagesApi. + * @export + * @interface MessagesApiGetMessageByIDRequest + */ +export interface MessagesApiGetMessageByIDRequest { + /** + * The id of the evm message + * @type {string} + * @memberof MessagesApiGetMessageByID + */ + readonly messageID: string +} + +/** + * MessagesApi - object-oriented interface + * @export + * @class MessagesApi + * @extends {BaseAPI} + */ +export class MessagesApi extends BaseAPI { + /** + * Approve a pending evm message + * @summary Approve a pending evm message + * @param {MessagesApiApprovePendingMessageRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof MessagesApi + */ + public approvePendingMessage(requestParameters: MessagesApiApprovePendingMessageRequest, options?: AxiosRequestConfig) { + return MessagesApiFp(this.configuration).approvePendingMessage(requestParameters.messageID, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Check if a given message is valid for EVM + * @summary Evaluate an evm message to sign + * @param {MessagesApiEvaluateMessageRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof MessagesApi + */ + public evaluateMessage(requestParameters: MessagesApiEvaluateMessageRequest, options?: AxiosRequestConfig) { + return MessagesApiFp(this.configuration).evaluateMessage(requestParameters.messageEvaluationRequest, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Get an evm message by id + * @summary Info for a specific evm message + * @param {MessagesApiGetMessageByIDRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof MessagesApi + */ + public getMessageByID(requestParameters: MessagesApiGetMessageByIDRequest, options?: AxiosRequestConfig) { + return MessagesApiFp(this.configuration).getMessageByID(requestParameters.messageID, options).then((request) => request(this.axios, this.basePath)); + } +} diff --git a/packages/internal/guardian/src/client/domain/starkex-transactions-api.ts b/packages/internal/guardian/src/client/domain/starkex-transactions-api.ts index 7230130528..0dadc11b21 100644 --- a/packages/internal/guardian/src/client/domain/starkex-transactions-api.ts +++ b/packages/internal/guardian/src/client/domain/starkex-transactions-api.ts @@ -13,9 +13,8 @@ */ -import type { Configuration } from '../configuration'; -import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios'; -import globalAxios from 'axios'; +import globalAxios, { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios'; +import { Configuration } from '../configuration'; // Some imports not used depending on template conditions // @ts-ignore import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; @@ -107,13 +106,13 @@ export const StarkexTransactionsApiFactory = function (configuration?: Configura /** * Check if it is a valid transaction by payload hash * @summary Evaluate if it is an valid transaction - * @param {StarkexTransactionsApiEvaluateStarkexTransactionRequest} requestParameters Request parameters. + * @param {string} payloadHash Hash for the payload * @param {*} [options] Override http request option. * @deprecated * @throws {RequiredError} */ - evaluateStarkexTransaction(requestParameters: StarkexTransactionsApiEvaluateStarkexTransactionRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.evaluateStarkexTransaction(requestParameters.payloadHash, options).then((request) => request(axios, basePath)); + evaluateStarkexTransaction(payloadHash: string, options?: any): AxiosPromise { + return localVarFp.evaluateStarkexTransaction(payloadHash, options).then((request) => request(axios, basePath)); }, }; }; diff --git a/packages/internal/guardian/src/client/domain/transactions-api.ts b/packages/internal/guardian/src/client/domain/transactions-api.ts index 3435cc0f3b..2dfdd2fa42 100644 --- a/packages/internal/guardian/src/client/domain/transactions-api.ts +++ b/packages/internal/guardian/src/client/domain/transactions-api.ts @@ -13,9 +13,8 @@ */ -import type { Configuration } from '../configuration'; -import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios'; -import globalAxios from 'axios'; +import globalAxios, { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios'; +import { Configuration } from '../configuration'; // Some imports not used depending on template conditions // @ts-ignore import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; @@ -49,7 +48,7 @@ export const TransactionsApiAxiosParamCreator = function (configuration?: Config * Approve a pending transaction * @summary Approve a pending transaction given chain * @param {string} payloadHash Hash for the payload - * @param {TransactionApprovalRequest} transactionApprovalRequest request body for approving a pending transactio + * @param {TransactionApprovalRequest} transactionApprovalRequest request body for approving a pending transaction * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -197,7 +196,7 @@ export const TransactionsApiFp = function(configuration?: Configuration) { * Approve a pending transaction * @summary Approve a pending transaction given chain * @param {string} payloadHash Hash for the payload - * @param {TransactionApprovalRequest} transactionApprovalRequest request body for approving a pending transactio + * @param {TransactionApprovalRequest} transactionApprovalRequest request body for approving a pending transaction * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -243,32 +242,36 @@ export const TransactionsApiFactory = function (configuration?: Configuration, b /** * Approve a pending transaction * @summary Approve a pending transaction given chain - * @param {TransactionsApiApprovePendingTransactionRequest} requestParameters Request parameters. + * @param {string} payloadHash Hash for the payload + * @param {TransactionApprovalRequest} transactionApprovalRequest request body for approving a pending transaction * @param {*} [options] Override http request option. * @throws {RequiredError} */ - approvePendingTransaction(requestParameters: TransactionsApiApprovePendingTransactionRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.approvePendingTransaction(requestParameters.payloadHash, requestParameters.transactionApprovalRequest, options).then((request) => request(axios, basePath)); + approvePendingTransaction(payloadHash: string, transactionApprovalRequest: TransactionApprovalRequest, options?: any): AxiosPromise { + return localVarFp.approvePendingTransaction(payloadHash, transactionApprovalRequest, options).then((request) => request(axios, basePath)); }, /** * Check if the transaction is valid by transaction ID for both StarkEx and EVM * @summary Evaluate a transaction - * @param {TransactionsApiEvaluateTransactionRequest} requestParameters Request parameters. + * @param {string} id Transaction identifier: payloadHash on StarkEx or EVM ID + * @param {TransactionEvaluationRequest} transactionEvaluationRequest Specifies the kind of transaction * @param {*} [options] Override http request option. * @throws {RequiredError} */ - evaluateTransaction(requestParameters: TransactionsApiEvaluateTransactionRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.evaluateTransaction(requestParameters.id, requestParameters.transactionEvaluationRequest, options).then((request) => request(axios, basePath)); + evaluateTransaction(id: string, transactionEvaluationRequest: TransactionEvaluationRequest, options?: any): AxiosPromise { + return localVarFp.evaluateTransaction(id, transactionEvaluationRequest, options).then((request) => request(axios, basePath)); }, /** * Get a transaction by payload hash * @summary Info for a specific transaction - * @param {TransactionsApiGetTransactionByIDRequest} requestParameters Request parameters. + * @param {string} transactionID The id of the starkex transaction to retrieve + * @param {'starkex' | 'evm'} chainType roll up type + * @param {string} [chainID] ID of evm chain * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getTransactionByID(requestParameters: TransactionsApiGetTransactionByIDRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.getTransactionByID(requestParameters.transactionID, requestParameters.chainType, requestParameters.chainID, options).then((request) => request(axios, basePath)); + getTransactionByID(transactionID: string, chainType: 'starkex' | 'evm', chainID?: string, options?: any): AxiosPromise { + return localVarFp.getTransactionByID(transactionID, chainType, chainID, options).then((request) => request(axios, basePath)); }, }; }; @@ -287,7 +290,7 @@ export interface TransactionsApiApprovePendingTransactionRequest { readonly payloadHash: string /** - * request body for approving a pending transactio + * request body for approving a pending transaction * @type {TransactionApprovalRequest} * @memberof TransactionsApiApprovePendingTransaction */ diff --git a/packages/internal/guardian/src/client/models/eip712-message-domain.ts b/packages/internal/guardian/src/client/models/eip712-message-domain.ts new file mode 100644 index 0000000000..f2ecb13d11 --- /dev/null +++ b/packages/internal/guardian/src/client/models/eip712-message-domain.ts @@ -0,0 +1,54 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Guardian + * Guardian API + * + * The version of the OpenAPI document: 1.0.0 + * Contact: support@immutable.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface EIP712MessageDomain + */ +export interface EIP712MessageDomain { + /** + * + * @type {string} + * @memberof EIP712MessageDomain + */ + 'name'?: string; + /** + * + * @type {string} + * @memberof EIP712MessageDomain + */ + 'version'?: string; + /** + * + * @type {number} + * @memberof EIP712MessageDomain + */ + 'chainId'?: number; + /** + * + * @type {string} + * @memberof EIP712MessageDomain + */ + 'verifyingContract'?: string; + /** + * + * @type {string} + * @memberof EIP712MessageDomain + */ + 'salt'?: string; +} + diff --git a/packages/internal/guardian/src/client/models/eip712-message-types-eip712-domain-inner.ts b/packages/internal/guardian/src/client/models/eip712-message-types-eip712-domain-inner.ts new file mode 100644 index 0000000000..c774bee81b --- /dev/null +++ b/packages/internal/guardian/src/client/models/eip712-message-types-eip712-domain-inner.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Guardian + * Guardian API + * + * The version of the OpenAPI document: 1.0.0 + * Contact: support@immutable.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface EIP712MessageTypesEIP712DomainInner + */ +export interface EIP712MessageTypesEIP712DomainInner { + /** + * + * @type {string} + * @memberof EIP712MessageTypesEIP712DomainInner + */ + 'name': string; + /** + * + * @type {string} + * @memberof EIP712MessageTypesEIP712DomainInner + */ + 'type': string; +} + diff --git a/packages/internal/guardian/src/client/models/eip712-message-types.ts b/packages/internal/guardian/src/client/models/eip712-message-types.ts new file mode 100644 index 0000000000..814946e349 --- /dev/null +++ b/packages/internal/guardian/src/client/models/eip712-message-types.ts @@ -0,0 +1,35 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Guardian + * Guardian API + * + * The version of the OpenAPI document: 1.0.0 + * Contact: support@immutable.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import { EIP712MessageTypesEIP712DomainInner } from './eip712-message-types-eip712-domain-inner'; + +/** + * + * @export + * @interface EIP712MessageTypes + */ +export interface EIP712MessageTypes { + [key: string]: Array | any; + + /** + * + * @type {Array} + * @memberof EIP712MessageTypes + */ + 'EIP712Domain': Array; +} + diff --git a/packages/internal/guardian/src/client/models/eip712-message.ts b/packages/internal/guardian/src/client/models/eip712-message.ts new file mode 100644 index 0000000000..040f45c2dc --- /dev/null +++ b/packages/internal/guardian/src/client/models/eip712-message.ts @@ -0,0 +1,56 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Guardian + * Guardian API + * + * The version of the OpenAPI document: 1.0.0 + * Contact: support@immutable.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import { EIP712MessageDomain } from './eip712-message-domain'; +// May contain unused imports in some cases +// @ts-ignore +import { EIP712MessageTypes } from './eip712-message-types'; + +/** + * + * @export + * @interface EIP712Message + */ +export interface EIP712Message { + /** + * + * @type {EIP712MessageTypes} + * @memberof EIP712Message + */ + 'types': EIP712MessageTypes; + /** + * + * @type {string} + * @memberof EIP712Message + */ + 'primaryType': string; + /** + * + * @type {EIP712MessageDomain} + * @memberof EIP712Message + */ + 'domain': EIP712MessageDomain; + /** + * + * @type {object} + * @memberof EIP712Message + */ + 'message': { + [name: string]: any; + }; +} + diff --git a/packages/internal/guardian/src/client/models/evmmessage.ts b/packages/internal/guardian/src/client/models/evmmessage.ts new file mode 100644 index 0000000000..50b856da52 --- /dev/null +++ b/packages/internal/guardian/src/client/models/evmmessage.ts @@ -0,0 +1,57 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Guardian + * Guardian API + * + * The version of the OpenAPI document: 1.0.0 + * Contact: support@immutable.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import { EIP712Message } from './eip712-message'; + +/** + * + * @export + * @interface EVMMessage + */ +export interface EVMMessage { + /** + * Confirmation Candidate ID + * @type {string} + * @memberof EVMMessage + */ + 'id': string; + /** + * candidate\'s ether address + * @type {string} + * @memberof EVMMessage + */ + 'eth_address': string; + /** + * Status of the evm message + * @type {string} + * @memberof EVMMessage + */ + 'status': string; + /** + * Which version is at + * @type {string} + * @memberof EVMMessage + */ + 'version': string; + /** + * + * @type {EIP712Message} + * @memberof EVMMessage + */ + 'data': EIP712Message; +} + diff --git a/packages/internal/guardian/src/client/models/index.ts b/packages/internal/guardian/src/client/models/index.ts index 3b7bbd4292..6914eb1a5a 100644 --- a/packages/internal/guardian/src/client/models/index.ts +++ b/packages/internal/guardian/src/client/models/index.ts @@ -7,6 +7,13 @@ export * from './apierror404-all-of'; export * from './apierror500'; export * from './apierror500-all-of'; export * from './basic-apierror'; +export * from './eip712-message'; +export * from './eip712-message-domain'; +export * from './eip712-message-types'; +export * from './eip712-message-types-eip712-domain-inner'; +export * from './evmmessage'; +export * from './message-evaluation-request'; +export * from './message-evaluation-response'; export * from './meta-transaction'; export * from './meta-transaction-data'; export * from './transaction'; diff --git a/packages/internal/guardian/src/client/models/message-evaluation-request.ts b/packages/internal/guardian/src/client/models/message-evaluation-request.ts new file mode 100644 index 0000000000..01fc3b960f --- /dev/null +++ b/packages/internal/guardian/src/client/models/message-evaluation-request.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Guardian + * Guardian API + * + * The version of the OpenAPI document: 1.0.0 + * Contact: support@immutable.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import { EIP712Message } from './eip712-message'; + +/** + * + * @export + * @interface MessageEvaluationRequest + */ +export interface MessageEvaluationRequest { + /** + * + * @type {EIP712Message} + * @memberof MessageEvaluationRequest + */ + 'payload': EIP712Message; + /** + * rollup chain ID + * @type {string} + * @memberof MessageEvaluationRequest + */ + 'chainID': string; +} + diff --git a/packages/internal/guardian/src/client/models/message-evaluation-response.ts b/packages/internal/guardian/src/client/models/message-evaluation-response.ts new file mode 100644 index 0000000000..ec45ccc8ff --- /dev/null +++ b/packages/internal/guardian/src/client/models/message-evaluation-response.ts @@ -0,0 +1,50 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Guardian + * Guardian API + * + * The version of the OpenAPI document: 1.0.0 + * Contact: support@immutable.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface MessageEvaluationResponse + */ +export interface MessageEvaluationResponse { + /** + * + * @type {boolean} + * @memberof MessageEvaluationResponse + */ + 'confirmationRequired': boolean; + /** + * + * @type {string} + * @memberof MessageEvaluationResponse + */ + 'confirmationMethod': MessageEvaluationResponseConfirmationMethodEnum; + /** + * + * @type {string} + * @memberof MessageEvaluationResponse + */ + 'messageId': string; +} + +export const MessageEvaluationResponseConfirmationMethodEnum = { + Otp: 'otp', + Web: 'web' +} as const; + +export type MessageEvaluationResponseConfirmationMethodEnum = typeof MessageEvaluationResponseConfirmationMethodEnum[keyof typeof MessageEvaluationResponseConfirmationMethodEnum]; + + diff --git a/packages/internal/guardian/src/client/models/transaction-evaluation-request.ts b/packages/internal/guardian/src/client/models/transaction-evaluation-request.ts index e39ddb8889..5633da64c0 100644 --- a/packages/internal/guardian/src/client/models/transaction-evaluation-request.ts +++ b/packages/internal/guardian/src/client/models/transaction-evaluation-request.ts @@ -12,7 +12,6 @@ * Do not edit the class manually. */ - // May contain unused imports in some cases // @ts-ignore import { ZkEvmTransactionData } from './zk-evm-transaction-data'; @@ -24,6 +23,6 @@ import { ZkEvmTransactionEvaluationRequest } from './zk-evm-transaction-evaluati * @type TransactionEvaluationRequest * @export */ -export type TransactionEvaluationRequest = { chainType: 'evm' } & ZkEvmTransactionEvaluationRequest | { chainType: 'starkex' } & StarkExTransactionEvaluationRequest; - - +export type TransactionEvaluationRequest = + | ({ chainType: 'evm' } & ZkEvmTransactionEvaluationRequest) + | { chainType: 'starkex' }; diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples.tsx deleted file mode 100644 index 5abf230d0b..0000000000 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Accordion, Form } from 'react-bootstrap'; -import InputGroup from 'react-bootstrap/InputGroup'; -import { usePassportProvider } from '@/context/PassportProvider'; -import WorkflowButton from '@/components/WorkflowButton'; -import { RequestExampleProps } from '@/types'; -import { parseUnits } from 'ethers/lib/utils'; - -function EthSendTransactionExamples({ disabled, handleExampleSubmitted }: RequestExampleProps) { - const [activeAccordionKey, setActiveAccordionKey] = useState(''); - const [fromAddress, setFromAddress] = useState(''); - const [toAddress, setToAddress] = useState(''); - const [amount, setAmount] = useState('0'); - const { zkEvmProvider } = usePassportProvider(); - const [params, setParams] = useState([]); - const [amountConvertError, setAmountConvertError] = useState(''); - const imxTokenDecimal = 18; - const amountRange = 'Amount should larger than 0 with maximum 18 digits in decimal'; - - useEffect(() => { - setAmountConvertError(''); - const rawAmount = amount.trim() === '' ? '0' : amount; - try { - if (Number(rawAmount) < 0) { - setAmountConvertError(amountRange); - } - const value = parseUnits(rawAmount, imxTokenDecimal).toString(); - setParams([{ - from: fromAddress, - to: toAddress, - value, - }]); - } catch (err) { - setAmountConvertError(amountRange); - setParams([{ - from: fromAddress, - to: toAddress, - value: '0', - }]); - } - }, [fromAddress, toAddress, amount]); - - useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [walletAddress] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - setFromAddress(walletAddress || ''); - } - }; - - getAddress().catch(console.log); - }, [zkEvmProvider, setFromAddress]); - - const handleSubmit = useCallback(async (e: React.FormEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setActiveAccordionKey(''); - - await handleExampleSubmitted({ - method: 'eth_sendTransaction', - params, - }); - }, [params, handleExampleSubmitted]); - - const handleAccordionSelect = (eventKey: string | string[] | undefined | null) => { - setActiveAccordionKey(eventKey); - }; - - return ( - - - Transfer IMX - -
- - - Preview - - - - - - From - - - - - - To - - setToAddress(e.target.value)} - /> - - - - Amount - - - setAmount(e.target.value)} - /> - - {amountConvertError} - - - - - Submit - -
-
-
-
- ); -} - -export default EthSendTransactionExamples; diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferImx.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferImx.tsx new file mode 100644 index 0000000000..3c375a3869 --- /dev/null +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferImx.tsx @@ -0,0 +1,138 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Accordion, Form } from 'react-bootstrap'; +import InputGroup from 'react-bootstrap/InputGroup'; +import { usePassportProvider } from '@/context/PassportProvider'; +import WorkflowButton from '@/components/WorkflowButton'; +import { RequestExampleProps } from '@/types'; +import { parseUnits } from 'ethers/lib/utils'; + +function TransferImx({ disabled, handleExampleSubmitted }: RequestExampleProps) { + const [fromAddress, setFromAddress] = useState(''); + const [toAddress, setToAddress] = useState(''); + const [amount, setAmount] = useState('0'); + const { zkEvmProvider } = usePassportProvider(); + const [params, setParams] = useState([]); + const [amountConvertError, setAmountConvertError] = useState(''); + const imxTokenDecimal = 18; + const amountRange = 'Amount should larger than 0 with maximum 18 digits in decimal'; + + useEffect(() => { + setAmountConvertError(''); + const rawAmount = amount.trim() === '' ? '0' : amount; + try { + if (Number(rawAmount) < 0) { + setAmountConvertError(amountRange); + } + const value = parseUnits(rawAmount, imxTokenDecimal).toString(); + setParams([{ + from: fromAddress, + to: toAddress, + value, + }]); + } catch (err) { + setAmountConvertError(amountRange); + setParams([{ + from: fromAddress, + to: toAddress, + value: '0', + }]); + } + }, [fromAddress, toAddress, amount]); + + useEffect(() => { + const getAddress = async () => { + if (zkEvmProvider) { + const [walletAddress] = await zkEvmProvider.request({ + method: 'eth_requestAccounts', + }); + setFromAddress(walletAddress || ''); + } + }; + + getAddress().catch(console.log); + }, [zkEvmProvider, setFromAddress]); + + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + + await handleExampleSubmitted({ + method: 'eth_sendTransaction', + params, + }); + }, [params, handleExampleSubmitted]); + + return ( + + Transfer IMX + +
+ + + Preview + + + + + + From + + + + + + To + + setToAddress(e.target.value)} + /> + + + + Amount + + + setAmount(e.target.value)} + /> + + {amountConvertError} + + + + + Submit + +
+
+
+ ); +} + +export default TransferImx; diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/index.ts b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/index.ts new file mode 100644 index 0000000000..0a207d28fc --- /dev/null +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/index.ts @@ -0,0 +1,7 @@ +import TransferImx from './TransferImx'; + +const EthSendTransactionExamples = [ + TransferImx, +]; + +export default EthSendTransactionExamples; diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SignEtherMail.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SignEtherMail.tsx new file mode 100644 index 0000000000..e1cfbb88c6 --- /dev/null +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SignEtherMail.tsx @@ -0,0 +1,85 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Accordion, Form } from 'react-bootstrap'; +import { usePassportProvider } from '@/context/PassportProvider'; +import WorkflowButton from '@/components/WorkflowButton'; +import { RequestExampleProps } from '@/types'; +import { getEtherMailTypedPayload } from './etherMailTypedPayload'; + +function SignEtherMail({ disabled, handleExampleSubmitted }: RequestExampleProps) { + const [address, setAddress] = useState(''); + const [params, setParams] = useState([]); + + const { zkEvmProvider } = usePassportProvider(); + + useEffect(() => { + const populateParams = async () => { + if (zkEvmProvider) { + const chainIdHex = await zkEvmProvider.request({ method: 'eth_chainId' }); + const chainId = parseInt(chainIdHex, 16); + const etherMailTypedPayload = getEtherMailTypedPayload(chainId); + + setParams([ + address, + etherMailTypedPayload, + ]); + } + }; + + populateParams().catch(console.log); + }, [address, zkEvmProvider]); + + useEffect(() => { + const getAddress = async () => { + if (zkEvmProvider) { + const [walletAddress] = await zkEvmProvider.request({ + method: 'eth_requestAccounts', + }); + setAddress(walletAddress || ''); + } + }; + + getAddress().catch(console.log); + }, [zkEvmProvider, setAddress]); + + const handleSubmitSignPayload = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + + await handleExampleSubmitted({ + method: 'eth_signTypedData_v4', + params, + }); + }, [params, handleExampleSubmitted]); + + return ( + + Sign Ether Mail Payload + +
+ + + Preview + + + + + Submit + +
+
+
+ ); +} + +export default SignEtherMail; diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateEtherMail.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateEtherMail.tsx new file mode 100644 index 0000000000..9314196dd4 --- /dev/null +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateEtherMail.tsx @@ -0,0 +1,137 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + Accordion, Alert, Form, Spinner, Stack, +} from 'react-bootstrap'; +import InputGroup from 'react-bootstrap/InputGroup'; +import { usePassportProvider } from '@/context/PassportProvider'; +import WorkflowButton from '@/components/WorkflowButton'; +import { RequestExampleProps } from '@/types'; +import { TypedDataPayload } from '@imtbl/passport'; +import { getEtherMailTypedPayload } from './etherMailTypedPayload'; +import { isSignatureValid } from './isSignatureValid'; + +function ValidateEtherMail({ disabled }: RequestExampleProps) { + const [address, setAddress] = useState(''); + const [signature, setSignature] = useState(''); + const [isValidSignature, setIsValidSignature] = useState(); + const [signatureValidationMessage, setSignatureValidationMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [etherMailTypedPayload, setEtherMailTypedPayload] = useState(); + + const { zkEvmProvider } = usePassportProvider(); + + useEffect(() => { + const populateParams = async () => { + if (zkEvmProvider) { + const chainIdHex = await zkEvmProvider.request({ method: 'eth_chainId' }); + const chainId = parseInt(chainIdHex, 16); + setEtherMailTypedPayload(getEtherMailTypedPayload(chainId)); + } + }; + + populateParams().catch(console.log); + }, [zkEvmProvider]); + + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setIsValidSignature(undefined); + setSignatureValidationMessage(''); + setIsLoading(true); + + try { + if (!zkEvmProvider) { + setIsValidSignature(false); + setSignatureValidationMessage('zkEvmProvider cannot be null'); + return; + } + + if (!etherMailTypedPayload) { + setIsValidSignature(false); + setSignatureValidationMessage('Example typedDataPayload cannot be null'); + return; + } + + const isValid = await isSignatureValid(address, etherMailTypedPayload, signature, zkEvmProvider); + + setIsValidSignature(isValid); + setSignatureValidationMessage(isValid ? 'Signature is valid' : 'Signature is invalid'); + } catch (ex) { + setIsValidSignature(false); + setSignatureValidationMessage(`Failed to validate signature: ${ex}`); + } finally { + setIsLoading(false); + } + }, [address, etherMailTypedPayload, signature, zkEvmProvider]); + + return ( + + Validate Ether Mail Signature + + + Note: This functionality is not provided by the Immutable SDK. + +
+ + + Address + + setAddress(e.target.value)} + /> + + + + Payload + + + + + + Signature + + + setSignature(e.target.value)} + /> + + + + + Validate + + { isLoading && } + { + isValidSignature !== undefined + && ( +

+ { isValidSignature ? '✅' : '❌' } + {' '} + { signatureValidationMessage } +

+ ) + } +
+
+
+
+ ); +} + +export default ValidateEtherMail; diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateSignature.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateSignature.tsx new file mode 100644 index 0000000000..8ef35b61d2 --- /dev/null +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateSignature.tsx @@ -0,0 +1,123 @@ +import React, { useCallback, useState } from 'react'; +import { + Accordion, Alert, Form, Spinner, Stack, +} from 'react-bootstrap'; +import InputGroup from 'react-bootstrap/InputGroup'; +import { usePassportProvider } from '@/context/PassportProvider'; +import WorkflowButton from '@/components/WorkflowButton'; +import { RequestExampleProps } from '@/types'; +import { TypedDataPayload } from '@imtbl/passport'; +import { isSignatureValid } from './isSignatureValid'; + +function ValidateSignature({ disabled }: RequestExampleProps) { + const [address, setAddress] = useState(''); + const [signature, setSignature] = useState(''); + const [payload, setPayload] = useState(''); + const [isValidSignature, setIsValidSignature] = useState(); + const [signatureValidationMessage, setSignatureValidationMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const { zkEvmProvider } = usePassportProvider(); + + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setIsValidSignature(undefined); + setSignatureValidationMessage(''); + setIsLoading(true); + + try { + if (!zkEvmProvider) { + setIsValidSignature(false); + setSignatureValidationMessage('zkEvmProvider cannot be null'); + return; + } + + const isValid = await isSignatureValid( + address, + JSON.parse(payload) as TypedDataPayload, + signature, + zkEvmProvider, + ); + + setIsValidSignature(isValid); + setSignatureValidationMessage(isValid ? 'Signature is valid' : 'Signature is invalid'); + } catch (ex) { + setIsValidSignature(false); + setSignatureValidationMessage(`Failed to validate signature: ${ex}`); + } finally { + setIsLoading(false); + } + }, [address, payload, signature, zkEvmProvider]); + + return ( + + Validate Signature + + + Note: This functionality is not provided by the Immutable SDK. + +
+ + + Address + + setAddress(e.target.value)} + /> + + + + Payload + + setPayload(e.target.value)} + type="text" + /> + + + + Signature + + + setSignature(e.target.value)} + /> + + + + + Validate + + { isLoading && } + { + isValidSignature !== undefined + && ( +

+ { isValidSignature ? '✅' : '❌' } + {' '} + { signatureValidationMessage } +

+ ) + } +
+
+
+
+ ); +} + +export default ValidateSignature; diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/etherMailTypedPayload.ts b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/etherMailTypedPayload.ts new file mode 100644 index 0000000000..c07e955448 --- /dev/null +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/etherMailTypedPayload.ts @@ -0,0 +1,64 @@ +export const getEtherMailTypedPayload = (chainId: number) => ({ + domain: { + name: 'Ether Mail', + version: '1', + chainId, + verifyingContract: '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287', + }, + message: { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + }, + contents: 'Hello, Bob!', + }, + primaryType: 'Mail', + types: { + EIP712Domain: [ + { + name: 'name', + type: 'string', + }, + { + name: 'version', + type: 'string', + }, + { + name: 'chainId', + type: 'uint256', + }, + { + name: 'verifyingContract', + type: 'address', + }, + ], + Person: [ + { + name: 'name', + type: 'string', + }, + { + name: 'wallet', + type: 'address', + }, + ], + Mail: [ + { + name: 'from', + type: 'Person', + }, + { + name: 'to', + type: 'Person', + }, + { + name: 'contents', + type: 'string', + }, + ], + }, +}); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/index.ts b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/index.ts new file mode 100644 index 0000000000..20ed76c58b --- /dev/null +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/index.ts @@ -0,0 +1,11 @@ +import SignEtherMail from './SignEtherMail'; +import ValidateEtherMail from './ValidateEtherMail'; +import ValidateSignature from './ValidateSignature'; + +const EthSignTypedDataV4Examples = [ + ValidateSignature, + SignEtherMail, + ValidateEtherMail, +]; + +export default EthSignTypedDataV4Examples; diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/isSignatureValid.ts b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/isSignatureValid.ts new file mode 100644 index 0000000000..d3b5510bde --- /dev/null +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/isSignatureValid.ts @@ -0,0 +1,32 @@ +import { Provider, TypedDataPayload } from '@imtbl/passport'; +import { ethers } from 'ethers'; + +// https://eips.ethereum.org/EIPS/eip-1271#specification +// EIP-1271 states that `isValidSignature` must return the following value if the signature is valid +const ERC_1271_MAGIC_VALUE = '0x1626ba7e'; + +export const isSignatureValid = async ( + address: string, + payload: TypedDataPayload, + signature: string, + zkEvmProvider: Provider, +) => { + const types = { ...payload.types }; + // @ts-ignore + delete types.EIP712Domain; + + // eslint-disable-next-line no-underscore-dangle + const hash = ethers.utils._TypedDataEncoder.hash( + payload.domain, + types, + payload.message, + ); + const contract = new ethers.Contract( + address, + ['function isValidSignature(bytes32, bytes) public view returns (bytes4)'], + new ethers.providers.Web3Provider(zkEvmProvider), + ); + + const isValidSignatureHex = await contract.isValidSignature(hash, signature); + return isValidSignatureHex === ERC_1271_MAGIC_VALUE; +}; diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/Request.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/Request.tsx index b7667e2864..05a6123d9f 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/Request.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/Request.tsx @@ -2,30 +2,34 @@ import React, { useState } from 'react'; import { Form, Offcanvas, Spinner, Stack, } from 'react-bootstrap'; -import { Divider, Heading } from '@biom3/react'; +import { Heading } from '@biom3/react'; import { RequestExampleProps, ModalProps } from '@/types'; import { useStatusProvider } from '@/context/StatusProvider'; import { usePassportProvider } from '@/context/PassportProvider'; import { RequestArguments } from '@imtbl/passport'; import WorkflowButton from '@/components/WorkflowButton'; +import RequestExampleAccordion from '@/components/zkevm/RequestExampleAccordion'; import EthSendTransactionExamples from './EthSendTransactionExamples'; +import EthSignTypedDataV4Examples from './EthSignTypedDataV4Examples'; enum EthereumParamType { string = 'string', flag = 'flag', object = 'object', + json = 'json', } interface EthereumParam { name: string; type?: EthereumParamType; default?: string; + placeholder?: string; } interface EthereumMethod { name: string; params?: Array; - exampleComponent?: React.ComponentType; + exampleComponents?: Array>; } const EthereumMethods: EthereumMethod[] = [ @@ -36,7 +40,15 @@ const EthereumMethods: EthereumMethod[] = [ params: [ { name: 'transaction', type: EthereumParamType.object }, ], - exampleComponent: EthSendTransactionExamples, + exampleComponents: EthSendTransactionExamples, + }, + { + name: 'eth_signTypedData_v4', + params: [ + { name: 'address' }, + { name: 'payload', type: EthereumParamType.json, placeholder: 'A valid JSON string' }, + ], + exampleComponents: EthSignTypedDataV4Examples, }, { name: 'eth_gasPrice' }, { @@ -168,7 +180,6 @@ function Request({ showModal, setShowModal }: ModalProps) { return param === 'true'; } case EthereumParamType.object: { - console.log(param); return JSON.parse(param); } default: { @@ -234,6 +245,7 @@ function Request({ showModal, setShowModal }: ModalProps) { newParams[index] = e.target.value; setParams(newParams); }} + placeholder={param.placeholder} /> )) @@ -249,20 +261,12 @@ function Request({ showModal, setShowModal }: ModalProps) { { loadingRequest && } - { selectedEthMethod?.exampleComponent && ( - - - } - size="medium" - > - Example transactions - - { React.createElement(selectedEthMethod.exampleComponent, { - handleExampleSubmitted, - disabled: loadingRequest, - }) } - + { selectedEthMethod?.exampleComponents && ( + )} diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/RequestExampleAccordion.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/RequestExampleAccordion.tsx new file mode 100644 index 0000000000..0586b3d83f --- /dev/null +++ b/packages/passport/sdk-sample-app/src/components/zkevm/RequestExampleAccordion.tsx @@ -0,0 +1,41 @@ +import { Divider, Heading } from '@biom3/react'; +import React, { useState } from 'react'; +import { Accordion, Stack } from 'react-bootstrap'; +import { RequestExampleAccordionProps } from '@/types'; +import { RequestArguments } from '@imtbl/passport'; + +function RequestExampleAccordion({ disabled, examples, handleExampleSubmitted }: RequestExampleAccordionProps) { + const [activeAccordionKey, setActiveAccordionKey] = useState(''); + + const handleAccordionSelect = (eventKey: string | string[] | undefined | null) => { + setActiveAccordionKey(eventKey); + }; + + return ( + + + } + size="medium" + > + Examples + + + { + examples?.map((component) => ( + React.createElement(component, { + key: component.name, + handleExampleSubmitted: (request: RequestArguments) => { + setActiveAccordionKey(''); + return handleExampleSubmitted(request); + }, + disabled, + }) + )) + } + + + ); +} + +export default RequestExampleAccordion; diff --git a/packages/passport/sdk-sample-app/src/types/index.ts b/packages/passport/sdk-sample-app/src/types/index.ts index ba8cd57b21..08e479ca3a 100644 --- a/packages/passport/sdk-sample-app/src/types/index.ts +++ b/packages/passport/sdk-sample-app/src/types/index.ts @@ -1,4 +1,9 @@ -import { Dispatch, SetStateAction, PropsWithChildren } from 'react'; +import { + Dispatch, + SetStateAction, + PropsWithChildren, + ComponentType, +} from 'react'; import { RequestArguments } from '@imtbl/passport'; import { Order } from '@imtbl/core-sdk'; @@ -30,3 +35,9 @@ export interface RequestExampleProps { handleExampleSubmitted: (request: RequestArguments) => Promise; disabled: boolean; } + +export interface RequestExampleAccordionProps { + disabled: boolean; + handleExampleSubmitted: (request: RequestArguments) => Promise; + examples: Array>; +} diff --git a/packages/passport/sdk/src/Passport.int.test.ts b/packages/passport/sdk/src/Passport.int.test.ts index 675f2faa28..92c8b8c0ea 100644 --- a/packages/passport/sdk/src/Passport.int.test.ts +++ b/packages/passport/sdk/src/Passport.int.test.ts @@ -10,10 +10,10 @@ import { resetMswHandlers, transactionHash, mswHandlers, - chainIdHex, } from './mocks/zkEvm/msw'; import { JsonRpcError, RpcErrorCode } from './zkEvm/JsonRpcError'; import GuardianClient from './guardian/guardian'; +import { chainIdHex } from './test/mocks'; jest.mock('./guardian/guardian'); diff --git a/packages/passport/sdk/src/confirmation/confirmation.test.ts b/packages/passport/sdk/src/confirmation/confirmation.test.ts index 0110107ddb..0cc862c2cb 100644 --- a/packages/passport/sdk/src/confirmation/confirmation.test.ts +++ b/packages/passport/sdk/src/confirmation/confirmation.test.ts @@ -84,4 +84,13 @@ describe('confirmation', () => { expect(mockNewWindow.location.href).toEqual('https://passport.sandbox.immutable.com/transaction-confirmation/transaction.html?transactionId=transactionId123&imxEtherAddress=0x1234&chainType=starkex'); }); }); + + describe('requestMessageConfirmation', () => { + it('should open a window when confirmation is required', async () => { + const res = await confirmationScreen.requestMessageConfirmation('asd123'); + confirmationScreen.loading(); + expect(res.confirmed).toEqual(false); + expect(mockNewWindow.location.href).toEqual('https://passport.sandbox.immutable.com/transaction-confirmation/zkevm/message?messageID=asd123'); + }); + }); }); diff --git a/packages/passport/sdk/src/confirmation/confirmation.ts b/packages/passport/sdk/src/confirmation/confirmation.ts index f68563ba0e..4a591ba504 100644 --- a/packages/passport/sdk/src/confirmation/confirmation.ts +++ b/packages/passport/sdk/src/confirmation/confirmation.ts @@ -12,6 +12,8 @@ const CONFIRMATION_WINDOW_HEIGHT = 380; const CONFIRMATION_WINDOW_WIDTH = 480; const CONFIRMATION_WINDOW_CLOSED_POLLING_DURATION = 1000; +type MessageHandler = (arg0: MessageEvent) => void; + export default class ConfirmationScreen { private config: PassportConfiguration; @@ -51,11 +53,11 @@ export default class ConfirmationScreen { reject(new Error('Unsupported message type')); } }; - window.addEventListener('message', messageHandler); if (!this.confirmationWindow) { resolve({ confirmed: false }); return; } + window.addEventListener('message', messageHandler); let href = ''; if (chainType === TransactionApprovalRequestChainTypeEnum.Starkex) { @@ -65,15 +67,45 @@ export default class ConfirmationScreen { // eslint-disable-next-line max-len href = `${this.config.passportDomain}/transaction-confirmation/zkevm?transactionId=${transactionId}&imxEtherAddress=${imxEtherAddress}&chainType=evm&chainId=${chainId}`; } - this.confirmationWindow.location.href = href; - // https://stackoverflow.com/questions/9388380/capture-the-close-event-of-popup-window-in-javascript/48240128#48240128 - const timer = setInterval(() => { - if (this.confirmationWindow?.closed) { - clearInterval(timer); - window.removeEventListener('message', messageHandler); - resolve({ confirmed: false }); + this.showConfirmationScreen(href, messageHandler, resolve); + }); + } + + requestMessageConfirmation(messageId: string): Promise { + return new Promise((resolve, reject) => { + const messageHandler = ({ data, origin }: MessageEvent) => { + if ( + origin !== this.config.passportDomain + || data.eventType !== PASSPORT_EVENT_TYPE + ) { + return; + } + switch (data.messageType as ReceiveMessage) { + case ReceiveMessage.CONFIRMATION_WINDOW_READY: { + break; + } + case ReceiveMessage.MESSAGE_CONFIRMED: { + resolve({ confirmed: true }); + break; + } + case ReceiveMessage.MESSAGE_REJECTED: { + reject(new Error('Message rejected')); + break; + } + + default: + reject(new Error('Unsupported message type')); } - }, CONFIRMATION_WINDOW_CLOSED_POLLING_DURATION); + }; + if (!this.confirmationWindow) { + resolve({ confirmed: false }); + return; + } + window.addEventListener('message', messageHandler); + + const href = `${this.config.passportDomain}/transaction-confirmation/zkevm/message?messageID=${messageId}`; + + this.showConfirmationScreen(href, messageHandler, resolve); }); } @@ -94,4 +126,16 @@ export default class ConfirmationScreen { closeWindow() { this.confirmationWindow?.close(); } + + showConfirmationScreen(href: string, messageHandler: MessageHandler, resolve: Function) { + this.confirmationWindow!.location.href = href; + // https://stackoverflow.com/questions/9388380/capture-the-close-event-of-popup-window-in-javascript/48240128#48240128 + const timer = setInterval(() => { + if (this.confirmationWindow?.closed) { + clearInterval(timer); + window.removeEventListener('message', messageHandler); + resolve({ confirmed: false }); + } + }, CONFIRMATION_WINDOW_CLOSED_POLLING_DURATION); + } } diff --git a/packages/passport/sdk/src/confirmation/types.ts b/packages/passport/sdk/src/confirmation/types.ts index 3360d5e9d5..9d94af9c73 100644 --- a/packages/passport/sdk/src/confirmation/types.ts +++ b/packages/passport/sdk/src/confirmation/types.ts @@ -2,6 +2,8 @@ export enum ReceiveMessage { CONFIRMATION_WINDOW_READY = 'confirmation_window_ready', TRANSACTION_CONFIRMED = 'transaction_confirmed', TRANSACTION_ERROR = 'transaction_error', + MESSAGE_CONFIRMED = 'message_confirmed', + MESSAGE_REJECTED = 'message_rejected', } export type ConfirmationResult = { diff --git a/packages/passport/sdk/src/guardian/guardian.test.ts b/packages/passport/sdk/src/guardian/guardian.test.ts index f47cd61a30..460854dcd7 100644 --- a/packages/passport/sdk/src/guardian/guardian.test.ts +++ b/packages/passport/sdk/src/guardian/guardian.test.ts @@ -2,6 +2,7 @@ import { ConfirmationScreen } from 'confirmation'; import * as guardian from '@imtbl/guardian'; import { TransactionRequest } from '@ethersproject/providers'; import { ImmutableConfiguration } from '@imtbl/config'; +import { UserZkEvm } from 'types'; import GuardianClient from './guardian'; import { mockUserZkEvm } from '../test/mocks'; import { JsonRpcError, RpcErrorCode } from '../zkEvm/JsonRpcError'; @@ -16,6 +17,7 @@ describe('guardian', () => { afterEach(jest.resetAllMocks); let mockGetTransactionByID: jest.Mock; let mockEvaluateTransaction: jest.Mock; + let mockEvaluateMessage : jest.Mock; const mockAccessToken = 'eyJh1234'; const mockEtherAddress = '0x1234'; @@ -24,10 +26,12 @@ describe('guardian', () => { beforeEach(() => { mockGetTransactionByID = jest.fn(); mockEvaluateTransaction = jest.fn(); + mockEvaluateMessage = jest.fn(); (guardian.TransactionsApi as jest.Mock).mockImplementation(() => ({ getTransactionByID: mockGetTransactionByID, evaluateTransaction: mockEvaluateTransaction, })); + (guardian.MessagesApi as jest.Mock).mockImplementation(() => ({ evaluateMessage: mockEvaluateMessage })); guardianClient = new GuardianClient({ accessToken: mockAccessToken, @@ -248,4 +252,24 @@ describe('guardian', () => { }); }); }); + + describe('validateMessage', () => { + const mockPayload = { chainID: '0x1234', payload: {} as guardian.EIP712Message, user: { accessToken: ':(' } as UserZkEvm }; + it('surfaces error message if message evaluation fails', async () => { + mockEvaluateMessage.mockRejectedValueOnce(new Error('401: Unauthorized')); + await expect(guardianClient.validateMessage(mockPayload)) + .rejects.toThrow('Message failed to validate with error: 401: Unauthorized'); + }); + it('displays confirmation screen if confirmation is required', async () => { + mockEvaluateMessage.mockResolvedValueOnce({ data: { confirmationRequired: true, messageId: 'asd123' } }); + (mockConfirmationScreen.requestMessageConfirmation as jest.Mock).mockResolvedValueOnce({ confirmed: true }); + await guardianClient.validateMessage(mockPayload); + expect(mockConfirmationScreen.requestMessageConfirmation).toBeCalledTimes(1); + }); + it('displays rejection error message if user rejects confirmation', async () => { + mockEvaluateMessage.mockResolvedValueOnce({ data: { confirmationRequired: true, messageId: 'asd123' } }); + (mockConfirmationScreen.requestMessageConfirmation as jest.Mock).mockResolvedValueOnce({ confirmed: false }); + await expect(guardianClient.validateMessage(mockPayload)).rejects.toEqual(new JsonRpcError(RpcErrorCode.TRANSACTION_REJECTED, 'Signature rejected by user')); + }); + }); }); diff --git a/packages/passport/sdk/src/guardian/guardian.ts b/packages/passport/sdk/src/guardian/guardian.ts index 96e13bb307..2330b5a623 100644 --- a/packages/passport/sdk/src/guardian/guardian.ts +++ b/packages/passport/sdk/src/guardian/guardian.ts @@ -4,7 +4,7 @@ import { BigNumber, ethers } from 'ethers'; import { ConfirmationScreen } from '../confirmation'; import { retryWithDelay } from '../network/retry'; import { JsonRpcError, RpcErrorCode } from '../zkEvm/JsonRpcError'; -import { MetaTransaction } from '../zkEvm/types'; +import { MetaTransaction, TypedDataPayload } from '../zkEvm/types'; import { UserZkEvm } from '../types'; import { PassportConfiguration } from '../config'; @@ -26,6 +26,12 @@ type GuardianEVMValidationParams = { metaTransactions: MetaTransaction[]; }; +type GuardianMessageValidationParams = { + chainID: string; + payload: TypedDataPayload; + user: UserZkEvm +}; + const transactionRejectedCrossSdkBridgeError = 'Transaction requires confirmation but this functionality is not' + ' supported in this environment. Please contact Immutable support if you need to enable this feature.'; @@ -57,6 +63,8 @@ const transformGuardianTransactions = ( export default class GuardianClient { private readonly transactionAPI: guardian.TransactionsApi; + private readonly messageAPI: guardian.MessagesApi; + private readonly confirmationScreen: ConfirmationScreen; // TODO: ID-977, make this rollup agnostic @@ -70,15 +78,14 @@ export default class GuardianClient { imxEtherAddress, config, }: GuardianClientParams) { + const guardianConfiguration = new guardian.Configuration({ accessToken, basePath: config.imxPublicApiDomain }); this.confirmationScreen = confirmationScreen; this.transactionAPI = new guardian.TransactionsApi( - new guardian.Configuration({ - accessToken, - basePath: config.imxPublicApiDomain, - }), + new guardian.Configuration(guardianConfiguration), ); this.imxEtherAddress = imxEtherAddress; this.crossSdkBridgeEnabled = config.crossSdkBridgeEnabled; + this.messageAPI = new guardian.MessagesApi(guardianConfiguration); } /** @@ -229,4 +236,40 @@ export default class GuardianClient { this.confirmationScreen.closeWindow(); } } + + private async evaluateMessage( + { chainID, payload, user }:GuardianMessageValidationParams, + ): Promise { + try { + const messageEvalResponse = await this.messageAPI.evaluateMessage( + { messageEvaluationRequest: { chainID, payload } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } }, + ); + return messageEvalResponse.data; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new JsonRpcError(RpcErrorCode.INTERNAL_ERROR, `Message failed to validate with error: ${errorMessage}`); + } + } + + public async validateMessage({ chainID, payload, user }: GuardianMessageValidationParams) { + const { messageId, confirmationRequired } = await this.evaluateMessage({ chainID, payload, user }); + if (confirmationRequired && this.crossSdkBridgeEnabled) { + throw new JsonRpcError(RpcErrorCode.TRANSACTION_REJECTED, transactionRejectedCrossSdkBridgeError); + } + if (confirmationRequired && !!messageId) { + const confirmationResult = await this.confirmationScreen.requestMessageConfirmation( + messageId, + ); + + if (!confirmationResult.confirmed) { + throw new JsonRpcError( + RpcErrorCode.TRANSACTION_REJECTED, + 'Signature rejected by user', + ); + } + } else { + this.confirmationScreen.closeWindow(); + } + } } diff --git a/packages/passport/sdk/src/index.ts b/packages/passport/sdk/src/index.ts index 9af5343472..fbec93ba31 100644 --- a/packages/passport/sdk/src/index.ts +++ b/packages/passport/sdk/src/index.ts @@ -8,6 +8,7 @@ export { Provider, ProviderEvent, AccountsChangedEvent, + TypedDataPayload, } from './zkEvm/types'; export { JsonRpcError, diff --git a/packages/passport/sdk/src/mocks/zkEvm/msw.ts b/packages/passport/sdk/src/mocks/zkEvm/msw.ts index 16357b2bd3..5691c7a204 100644 --- a/packages/passport/sdk/src/mocks/zkEvm/msw.ts +++ b/packages/passport/sdk/src/mocks/zkEvm/msw.ts @@ -3,10 +3,9 @@ import { RequestHandler, rest } from 'msw'; import { SetupServer, setupServer } from 'msw/node'; import { RelayerTransactionRequest } from '../../zkEvm/relayerClient'; import { JsonRpcRequestPayload } from '../../zkEvm/types'; +import { chainId, chainIdHex } from '../../test/mocks'; export const relayerId = '0x745'; -export const chainId = '13472'; -export const chainIdHex = '0x343C'; export const transactionHash = '0x867'; const mandatoryHandlers = [ diff --git a/packages/passport/sdk/src/test/mocks.ts b/packages/passport/sdk/src/test/mocks.ts index b8b539f8e7..f305fd90b6 100644 --- a/packages/passport/sdk/src/test/mocks.ts +++ b/packages/passport/sdk/src/test/mocks.ts @@ -5,6 +5,10 @@ import { PassportConfiguration } from '../config'; export const mockErrorMessage = 'Server is down'; export const mockStarkSignature = 'starkSignature'; +export const chainId = 13472; +export const chainIdHex = '0x34a0'; +export const chainIdEip155 = `eip155:${chainId}`; + const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuYXV0aDAuY29tLyIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tL2NhbGFuZGFyL3YxLyIsInN1YiI6InVzcl8xMjMiLCJpYXQiOjE0NTg3ODU3OTYsImV4cCI6MTQ1ODg3MjE5Nn0.CA7eaHjIHz5NxeIJoFK9krqaeZrPLwmMmgI_XiQiIkQ'; export const testConfig = new PassportConfiguration({ diff --git a/packages/passport/sdk/src/zkEvm/relayerClient.test.ts b/packages/passport/sdk/src/zkEvm/relayerClient.test.ts index 8c9f57fece..63349f7a7f 100644 --- a/packages/passport/sdk/src/zkEvm/relayerClient.test.ts +++ b/packages/passport/sdk/src/zkEvm/relayerClient.test.ts @@ -2,11 +2,11 @@ import { JsonRpcProvider } from '@ethersproject/providers'; import { RelayerClient } from './relayerClient'; import { PassportConfiguration } from '../config'; import { UserZkEvm } from '../types'; -import { RelayerTransactionStatus } from './types'; +import { RelayerTransactionStatus, TypedDataPayload } from './types'; +import { chainId, chainIdEip155 } from '../test/mocks'; describe('relayerClient', () => { const transactionHash = '0x456'; - const chainId = 13371; const config = { relayerUrl: 'https://example.com', }; @@ -61,7 +61,7 @@ describe('relayerClient', () => { params: [{ to, data, - chainId: 'eip155:13371', + chainId: chainIdEip155, }], }); }); @@ -135,7 +135,41 @@ describe('relayerClient', () => { params: [{ userAddress, data, - chainId: 'eip155:13371', + chainId: chainIdEip155, + }], + }); + }); + }); + + describe('imSignTypedData', () => { + it('calls relayer with the correct arguments', async () => { + const address = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; + const eip712Payload = {} as TypedDataPayload; + const relayerSignature = '0x123'; + + (global.fetch as jest.Mock).mockResolvedValue({ + json: () => ({ + result: relayerSignature, + }), + }); + + const result = await relayerClient.imSignTypedData(address, eip712Payload); + expect(result).toEqual(relayerSignature); + expect(global.fetch).toHaveBeenCalledWith(`${config.relayerUrl}/v1/transactions`, expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Bearer ${user.accessToken}`, + 'Content-Type': 'application/json', + }, + })); + expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toMatchObject({ + id: 1, + jsonrpc: '2.0', + method: 'im_signTypedData', + params: [{ + address, + eip712Payload, + chainId: chainIdEip155, }], }); }); diff --git a/packages/passport/sdk/src/zkEvm/relayerClient.ts b/packages/passport/sdk/src/zkEvm/relayerClient.ts index ceba07cc8c..da2c241824 100644 --- a/packages/passport/sdk/src/zkEvm/relayerClient.ts +++ b/packages/passport/sdk/src/zkEvm/relayerClient.ts @@ -1,7 +1,7 @@ import { BytesLike } from 'ethers'; import { JsonRpcProvider } from '@ethersproject/providers'; import { PassportConfiguration } from '../config'; -import { FeeOption, RelayerTransaction } from './types'; +import { FeeOption, RelayerTransaction, TypedDataPayload } from './types'; import { UserZkEvm } from '../types'; import { getEip155ChainId } from './walletHelpers'; @@ -27,7 +27,7 @@ type EthSendTransactionRequest = { }[]; }; -type EthSendTransactionResponse = { +type EthSendTransactionResponse = JsonRpc & { result: string; }; @@ -41,6 +41,7 @@ type ImGetTransactionByHashResponse = JsonRpc & { result: RelayerTransaction; }; +// ImGetFeeOptions types type ImGetFeeOptionsRequest = { method: 'im_getFeeOptions'; params: { @@ -54,10 +55,25 @@ type ImGetFeeOptionsResponse = JsonRpc & { result: FeeOption[] }; +// ImSignTypedData types +type ImSignTypedDataRequest = { + method: 'im_signTypedData'; + params: { + chainId: string; + address: string; + eip712Payload: TypedDataPayload; + }[]; +}; + +type ImSignTypedDataResponse = JsonRpc & { + result: string; +}; + export type RelayerTransactionRequest = | EthSendTransactionRequest | ImGetTransactionByHashRequest - | ImGetFeeOptionsRequest; + | ImGetFeeOptionsRequest + | ImSignTypedDataRequest; export class RelayerClient { private readonly config: PassportConfiguration; @@ -132,4 +148,18 @@ export class RelayerClient { const { result } = await this.postToRelayer(payload); return result; } + + public async imSignTypedData(address: string, eip712Payload: TypedDataPayload): Promise { + const { chainId } = await this.jsonRpcProvider.ready; + const payload: ImSignTypedDataRequest = { + method: 'im_signTypedData', + params: [{ + address, + eip712Payload, + chainId: getEip155ChainId(chainId), + }], + }; + const { result } = await this.postToRelayer(payload); + return result; + } } diff --git a/packages/passport/sdk/src/zkEvm/sendTransaction.test.ts b/packages/passport/sdk/src/zkEvm/sendTransaction.test.ts index 2a1165ae94..ec36c48294 100644 --- a/packages/passport/sdk/src/zkEvm/sendTransaction.test.ts +++ b/packages/passport/sdk/src/zkEvm/sendTransaction.test.ts @@ -1,7 +1,7 @@ import { JsonRpcProvider, TransactionRequest } from '@ethersproject/providers'; import { getEip155ChainId, getNonce, getSignedMetaTransactions } from './walletHelpers'; import { sendTransaction } from './sendTransaction'; -import { mockUserZkEvm } from '../test/mocks'; +import { chainId, chainIdEip155, mockUserZkEvm } from '../test/mocks'; import { RelayerClient } from './relayerClient'; import { retryWithDelay } from '../network/retry'; import { RelayerTransaction, RelayerTransactionStatus } from './types'; @@ -20,8 +20,6 @@ describe('sendTransaction', () => { const transactionHash = 'transactionHash123'; const nonce = '5'; - const chainId = 13472; - const eip155ChainId = `eip155:${chainId}`; const transactionRequest: TransactionRequest = { to: mockUserZkEvm.zkEvm.ethAddress, @@ -55,7 +53,7 @@ describe('sendTransaction', () => { jest.resetAllMocks(); relayerClient.imGetFeeOptions.mockResolvedValue([imxFeeOption]); (getNonce as jest.Mock).mockResolvedValueOnce(nonce); - (getEip155ChainId as jest.Mock).mockReturnValue(eip155ChainId); + (getEip155ChainId as jest.Mock).mockReturnValue(chainIdEip155); (getSignedMetaTransactions as jest.Mock).mockResolvedValueOnce( signedTransaction, ); @@ -109,7 +107,7 @@ describe('sendTransaction', () => { expect(getEip155ChainId).toHaveBeenCalledWith(chainId); expect(guardianClient.validateEVMTransaction).toHaveBeenCalledWith( { - chainId: eip155ChainId, + chainId: chainIdEip155, nonce, user: mockUserZkEvm, metaTransactions: [ diff --git a/packages/passport/sdk/src/zkEvm/signTypedDataV4.test.ts b/packages/passport/sdk/src/zkEvm/signTypedDataV4.test.ts new file mode 100644 index 0000000000..cfc05a4b8b --- /dev/null +++ b/packages/passport/sdk/src/zkEvm/signTypedDataV4.test.ts @@ -0,0 +1,232 @@ +import { JsonRpcProvider, Web3Provider } from '@ethersproject/providers'; +import { BigNumber } from 'ethers'; +import GuardianClient from 'guardian/guardian'; +import { getEip155ChainId, getSignedTypedData } from './walletHelpers'; +import { + chainId, + chainIdHex, + chainIdEip155, + mockUserZkEvm, +} from '../test/mocks'; +import { RelayerClient } from './relayerClient'; +import { signTypedDataV4 } from './signTypedDataV4'; +import { JsonRpcError, RpcErrorCode } from './JsonRpcError'; +import { TypedDataPayload } from './types'; + +jest.mock('@ethersproject/providers'); +jest.mock('./walletHelpers'); + +describe('signTypedDataV4', () => { + const address = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; + const eip712Payload: TypedDataPayload = { + types: { EIP712Domain: [] }, + domain: {}, + primaryType: '', + message: {}, + }; + const relayerSignature = '02011b1d383526a2815d26550eb314b5d7e0551327330043c4d07715346a7d5517ecbc32304fc1ccdcd52fea386c94c3b58b90410f20cd1d5c6db8fa1f03c34e82dce78c3445ce38583e0b0689c69b8fbedbc33d3a2e45431b0103'; + const combinedSignature = '0x000202011b1d383526a2815d26550eb314b5d7e0551327330043c4d07715346a7d5517ecbc32304fc1ccdcd52fea386c94c3b58b90410f20cd1d5c6db8fa1f03c34e82dce78c3445ce38583e0b0689c69b8fbedbc33d3a2e45431b01030001d25acf5eef26fb627f91e02ebd111580030ab8fb0a55567ac8cc66c34de7ae98185125a76adc6ee2fea042c7fce9c85a41e790ce3529f93dfec281bf56620ef21b02'; + + const magicProvider = {}; + const magicSigner = {}; + const jsonRpcProvider = { + ready: Promise.resolve({ chainId }), + }; + const relayerClient = { + imSignTypedData: jest.fn(), + }; + const guardianClient = { + validateMessage: jest.fn(), + withConfirmationScreen: jest.fn(() => (task: () => void) => task()), + loading: jest.fn(), + }; + const withConfirmationScreenStub = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + relayerClient.imSignTypedData.mockResolvedValue(relayerSignature); + (getEip155ChainId as jest.Mock).mockReturnValue(chainIdEip155); + (getSignedTypedData as jest.Mock).mockResolvedValueOnce( + combinedSignature, + ); + (Web3Provider as unknown as jest.Mock).mockImplementation(() => ({ + getSigner: () => magicSigner, + })); + withConfirmationScreenStub.mockImplementation(() => (task: () => void) => task()); + guardianClient.withConfirmationScreen = withConfirmationScreenStub; + }); + + describe('when a valid address and json are provided', () => { + it('returns a signature', async () => { + const result = await signTypedDataV4({ + method: 'eth_signTypedData_v4', + params: [address, JSON.stringify(eip712Payload)], + magicProvider, + jsonRpcProvider: jsonRpcProvider as JsonRpcProvider, + relayerClient: relayerClient as unknown as RelayerClient, + user: mockUserZkEvm, + guardianClient: guardianClient as unknown as GuardianClient, + }); + + expect(result).toEqual(combinedSignature); + expect(relayerClient.imSignTypedData).toHaveBeenCalledWith( + address, + eip712Payload, + ); + expect(getSignedTypedData).toHaveBeenCalledWith( + eip712Payload, + relayerSignature, + BigNumber.from(chainId), + address, + magicSigner, + ); + }); + }); + + describe('when a valid address and object are provided', () => { + it('returns a signature', async () => { + const result = await signTypedDataV4({ + method: 'eth_signTypedData_v4', + params: [address, eip712Payload], + magicProvider, + jsonRpcProvider: jsonRpcProvider as JsonRpcProvider, + relayerClient: relayerClient as unknown as RelayerClient, + user: mockUserZkEvm, + guardianClient: guardianClient as any, + }); + + expect(result).toEqual(combinedSignature); + expect(relayerClient.imSignTypedData).toHaveBeenCalledWith( + address, + eip712Payload, + ); + expect(getSignedTypedData).toHaveBeenCalledWith( + eip712Payload, + relayerSignature, + BigNumber.from(chainId), + address, + magicSigner, + ); + }); + }); + + describe('when an argument is missing', () => { + it('should throw an error', async () => { + await expect(async () => ( + signTypedDataV4({ + method: 'eth_signTypedData_v4', + params: [address], + magicProvider, + jsonRpcProvider: jsonRpcProvider as JsonRpcProvider, + relayerClient: relayerClient as unknown as RelayerClient, + user: mockUserZkEvm, + guardianClient: guardianClient as any, + }) + )).rejects.toThrow( + new JsonRpcError(RpcErrorCode.INVALID_PARAMS, 'eth_signTypedData_v4 requires an address and a typed data JSON'), + ); + }); + }); + + describe('when an invalid JSON is provided', () => { + it('should throw an error', async () => { + await expect(async () => ( + signTypedDataV4({ + method: 'eth_signTypedData_v4', + params: [address, '*~<|8)-/-<'], + magicProvider, + jsonRpcProvider: jsonRpcProvider as JsonRpcProvider, + relayerClient: relayerClient as unknown as RelayerClient, + user: mockUserZkEvm, + guardianClient: guardianClient as any, + }) + )).rejects.toThrow( + new JsonRpcError(RpcErrorCode.INVALID_PARAMS, 'Failed to parse typed data JSON: SyntaxError: Unexpected token * in JSON at position 0'), + ); + }); + }); + + describe('when the typedDataPayload is missing a required property', () => { + it('should throw an error', async () => { + const payload = { + domain: {}, + primaryType: '', + message: {}, + }; + + await expect(async () => ( + signTypedDataV4({ + method: 'eth_signTypedData_v4', + params: [address, payload], + magicProvider, + jsonRpcProvider: jsonRpcProvider as JsonRpcProvider, + relayerClient: relayerClient as unknown as RelayerClient, + user: mockUserZkEvm, + guardianClient: guardianClient as any, + }) + )).rejects.toThrow( + new JsonRpcError(RpcErrorCode.INVALID_PARAMS, 'Invalid typed data argument. The following properties are required: types, domain, primaryType, message'), + ); + }); + }); + + describe('when a different chainId is used', () => { + it('should throw an error', async () => { + await expect(async () => ( + signTypedDataV4({ + method: 'eth_signTypedData_v4', + params: [ + address, + { + ...eip712Payload, + domain: { + chainId: 5, + }, + }, + ], + magicProvider, + jsonRpcProvider: jsonRpcProvider as JsonRpcProvider, + relayerClient: relayerClient as unknown as RelayerClient, + user: mockUserZkEvm, + guardianClient: guardianClient as any, + }) + )).rejects.toThrow( + new JsonRpcError(RpcErrorCode.INVALID_PARAMS, `Invalid chainId, expected ${chainId}`), + ); + }); + }); + + it.each([chainIdHex, `${chainId}`])('converts the chainId to a number and returns a signature', async (testChainId: any) => { + const payload: TypedDataPayload = { + ...eip712Payload, + domain: { + chainId: testChainId, + }, + }; + const result = await signTypedDataV4({ + method: 'eth_signTypedData_v4', + params: [ + address, + payload, + ], + magicProvider, + jsonRpcProvider: jsonRpcProvider as JsonRpcProvider, + relayerClient: relayerClient as unknown as RelayerClient, + user: mockUserZkEvm, + guardianClient: guardianClient as any, + }); + + expect(result).toEqual(combinedSignature); + expect(relayerClient.imSignTypedData).toHaveBeenCalledWith( + address, + payload, + ); + expect(getSignedTypedData).toHaveBeenCalledWith( + payload, + relayerSignature, + BigNumber.from(chainId), + address, + magicSigner, + ); + }); +}); diff --git a/packages/passport/sdk/src/zkEvm/signTypedDataV4.ts b/packages/passport/sdk/src/zkEvm/signTypedDataV4.ts new file mode 100644 index 0000000000..e3810f8c66 --- /dev/null +++ b/packages/passport/sdk/src/zkEvm/signTypedDataV4.ts @@ -0,0 +1,96 @@ +import { + ExternalProvider, + JsonRpcProvider, + Web3Provider, +} from '@ethersproject/providers'; +import { BigNumber } from 'ethers'; +import GuardianClient from 'guardian/guardian'; +import { getSignedTypedData } from './walletHelpers'; +import { TypedDataPayload } from './types'; +import { JsonRpcError, RpcErrorCode } from './JsonRpcError'; +import { RelayerClient } from './relayerClient'; +import { UserZkEvm } from '../types'; + +export type SignTypedDataV4Params = { + magicProvider: ExternalProvider; + jsonRpcProvider: JsonRpcProvider; + relayerClient: RelayerClient; + user: UserZkEvm; + method: string; + params: Array; + guardianClient: GuardianClient; +}; + +const REQUIRED_TYPED_DATA_PROPERTIES = ['types', 'domain', 'primaryType', 'message']; +const isValidTypedDataPayload = (typedData: object): typedData is TypedDataPayload => ( + REQUIRED_TYPED_DATA_PROPERTIES.every((key) => key in typedData) +); + +const transformTypedData = (typedData: string | object, chainId: number): TypedDataPayload => { + let transformedTypedData: object | TypedDataPayload; + + if (typeof typedData === 'string') { + try { + transformedTypedData = JSON.parse(typedData); + } catch (err: any) { + throw new JsonRpcError(RpcErrorCode.INVALID_PARAMS, `Failed to parse typed data JSON: ${err}`); + } + } else if (typeof typedData === 'object') { + transformedTypedData = typedData; + } else { + throw new JsonRpcError(RpcErrorCode.INVALID_PARAMS, `Invalid typed data argument: ${typedData}`); + } + + if (!isValidTypedDataPayload(transformedTypedData)) { + throw new JsonRpcError( + RpcErrorCode.INVALID_PARAMS, + `Invalid typed data argument. The following properties are required: ${REQUIRED_TYPED_DATA_PROPERTIES.join(', ')}`, + ); + } + + const providedChainId: number | string | undefined = (transformedTypedData as any).domain?.chainId; + if (providedChainId) { + // domain.chainId (if defined) can be a number, string, or hex value, but the relayer & guardian only accept a number. + if (typeof providedChainId === 'string') { + if (providedChainId.startsWith('0x')) { + transformedTypedData.domain.chainId = parseInt(providedChainId, 16); + } else { + transformedTypedData.domain.chainId = parseInt(providedChainId, 10); + } + } + + if (transformedTypedData.domain.chainId !== chainId) { + throw new JsonRpcError(RpcErrorCode.INVALID_PARAMS, `Invalid chainId, expected ${chainId}`); + } + } + + return transformedTypedData; +}; + +export const signTypedDataV4 = async ({ + params, + method, + magicProvider, + jsonRpcProvider, + relayerClient, + guardianClient, + user, +}: SignTypedDataV4Params): Promise => guardianClient + .withConfirmationScreen({ width: 480, height: 730 })(async () => { + const fromAddress: string = params[0]; + const typedDataParam: string | object = params[1]; + + if (!fromAddress || !typedDataParam) { + throw new JsonRpcError(RpcErrorCode.INVALID_PARAMS, `${method} requires an address and a typed data JSON`); + } + + const { chainId } = await jsonRpcProvider.ready; + const typedData = transformTypedData(typedDataParam, chainId); + + await guardianClient.validateMessage({ chainID: String(chainId), payload: typedData, user }); + const relayerSignature = await relayerClient.imSignTypedData(fromAddress, typedData); + const magicWeb3Provider = new Web3Provider(magicProvider); + const signer = magicWeb3Provider.getSigner(); + + return getSignedTypedData(typedData, relayerSignature, BigNumber.from(chainId), fromAddress, signer); + }); diff --git a/packages/passport/sdk/src/zkEvm/types.ts b/packages/passport/sdk/src/zkEvm/types.ts index 9f1ca04245..ee2b0ccee5 100644 --- a/packages/passport/sdk/src/zkEvm/types.ts +++ b/packages/passport/sdk/src/zkEvm/types.ts @@ -43,6 +43,23 @@ export interface MetaTransactionNormalised { data: BytesLike } +// https://eips.ethereum.org/EIPS/eip-712 +export interface TypedDataPayload { + types: { + EIP712Domain: Array<{ name: string; type: string }>; + [key: string]: Array<{ name: string; type: string }>; + }; + domain: { + name?: string; + version? :string; + chainId?: number; + verifyingContract?: string; + salt?: string; + }; + primaryType: string; + message: Record; +} + export interface RequestArguments { method: string; params?: Array; diff --git a/packages/passport/sdk/src/zkEvm/walletHelpers.test.ts b/packages/passport/sdk/src/zkEvm/walletHelpers.test.ts index 4c1ef0eaad..cd901aff3e 100644 --- a/packages/passport/sdk/src/zkEvm/walletHelpers.test.ts +++ b/packages/passport/sdk/src/zkEvm/walletHelpers.test.ts @@ -1,5 +1,11 @@ import { BigNumber, Wallet } from 'ethers'; -import { getSignedMetaTransactions } from './walletHelpers'; +import { getSignedMetaTransactions, getSignedTypedData } from './walletHelpers'; +import { TypedDataPayload } from './types'; + +// SCW addr +const walletAddress = '0x7EEC32793414aAb720a90073607733d9e7B0ecD0'; +// User EOA private key +const signer = new Wallet('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'); describe('getSignedMetaTransactions', () => { // NOTE: Generated with https://github.com/immutable/wallet-contracts/blob/348add7d2fde13d8f7f83aae0882ad2d97546d72/tests/ImmutableDeployment.spec.ts#L69 @@ -16,10 +22,6 @@ describe('getSignedMetaTransactions', () => { ]; const nonce = 0; const chainId = 1779; - // SCW addr - const walletAddress = '0x7EEC32793414aAb720a90073607733d9e7B0ecD0'; - // User EOA private key - const signer = new Wallet('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'); const signature = await getSignedMetaTransactions( transactions, @@ -32,3 +34,23 @@ describe('getSignedMetaTransactions', () => { expect(signature).toBe('0x7a9a1628000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000f42400000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004600010001904a25850e09260d88f3fc46fab4901e7c979fc583fe9d30a12c51ba5636355a1351b8ce823f765568d8b88cddd9c8ede9f1cc17dfd7ca953e05ecbbbdf8f51e1c020000000000000000000000000000000000000000000000000000'); }); }); + +describe('getSignedTypedData', () => { + it('should correctly generate the signature for a given typed data payload', async () => { + const typedDataPayload = JSON.parse('{"domain":{"name":"Ether Mail","version":"1","chainId":13472,"verifyingContract":"0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287"},"message":{"from":{"name":"Cow","wallet":"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},"to":{"name":"Bob","wallet":"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},"contents":"Hello, Bob!"},"primaryType":"Mail","types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Person":[{"name":"name","type":"string"},{"name":"wallet","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person"},{"name":"contents","type":"string"}]}}') as TypedDataPayload; + const relayerSignature = '02011b1d383526a2815d26550eb314b5d7e05513273300439b63b94e127c13e1bae9f3f24ab42717c7ae2e25fb82e7fd24afc320690413ca6581c798f91cce8296bd21f4f35a4b33b882a5401499f829481d8ed8d3de23741b0103'; + const expectedSignature = '0x000202011b1d383526a2815d26550eb314b5d7e05513273300439b63b94e127c13e1bae9f3f24ab42717c7ae2e25fb82e7fd24afc320690413ca6581c798f91cce8296bd21f4f35a4b33b882a5401499f829481d8ed8d3de23741b01030001aec95114a3b8cf3c9693177a2abd8321cf775366a6c6aadf5953e082680fd90c6cb44972a1635b5e9f7f02490a47425be37a1965a6cbaaaa64404cb2cf3880f71c02'; + + const chainId = 13472; + + const signature = await getSignedTypedData( + typedDataPayload, + relayerSignature, + BigNumber.from(chainId), + walletAddress, + signer, + ); + + expect(signature).toEqual(expectedSignature); + }); +}); diff --git a/packages/passport/sdk/src/zkEvm/walletHelpers.ts b/packages/passport/sdk/src/zkEvm/walletHelpers.ts index 4bd7cdbae5..88cecd2041 100644 --- a/packages/passport/sdk/src/zkEvm/walletHelpers.ts +++ b/packages/passport/sdk/src/zkEvm/walletHelpers.ts @@ -1,17 +1,13 @@ import { BigNumber, BigNumberish, ethers } from 'ethers'; import { walletContracts } from '@0xsequence/abi'; -import { encodeSignature } from '@0xsequence/config'; +import { decodeSignature, encodeSignature } from '@0xsequence/config'; import { JsonRpcProvider } from '@ethersproject/providers'; import { Signer } from '@ethersproject/abstract-signer'; -import { MetaTransaction, MetaTransactionNormalised } from './types'; +import { MetaTransaction, MetaTransactionNormalised, TypedDataPayload } from './types'; -// These are ignored by the Relayer but for consistency we set them to the -// appropriate values for a 1/1 wallet. -// -// Weight of a single signature in the multisig -const SIGNATURE_WEIGHT = 1; -// Total required weight in the multisig -const SIGNATURE_THRESHOLD = 1; +const SIGNATURE_WEIGHT = 1; // Weight of a single signature in the multi-sig +const TRANSACTION_SIGNATURE_THRESHOLD = 1; // Total required weight in the multi-sig for a transaction +const EIP712_SIGNATURE_THRESHOLD = 2; // Total required weight in the multi-sig for data signing const ETH_SIGN_FLAG = '02'; const ETH_SIGN_PREFIX = '\x19\x01'; @@ -56,6 +52,13 @@ export const getNonce = async (jsonRpcProvider: JsonRpcProvider, smartContractWa return 0; }; +const encodeMessageSubDigest = (chainId: BigNumber, walletAddress: string, digest: string): string => ( + ethers.utils.solidityPack( + ['string', 'uint256', 'address', 'bytes32'], + [ETH_SIGN_PREFIX, chainId, walletAddress, digest], + ) +); + export const getSignedMetaTransactions = async ( metaTransactions: MetaTransaction[], nonce: BigNumberish, @@ -67,10 +70,7 @@ export const getSignedMetaTransactions = async ( // Get the hash const digest = digestOfTransactionsAndNonce(nonce, normalisedMetaTransactions); - const completePayload = ethers.utils.solidityPack( - ['string', 'uint256', 'address', 'bytes32'], - [ETH_SIGN_PREFIX, chainId, walletAddress, digest], - ); + const completePayload = encodeMessageSubDigest(chainId, walletAddress, digest); const hash = ethers.utils.keccak256(completePayload); @@ -81,7 +81,7 @@ export const getSignedMetaTransactions = async ( // Add metadata const encodedSignature = encodeSignature({ - threshold: SIGNATURE_THRESHOLD, + threshold: TRANSACTION_SIGNATURE_THRESHOLD, signers: [ { weight: SIGNATURE_WEIGHT, @@ -99,4 +99,46 @@ export const getSignedMetaTransactions = async ( ]); }; +const decodeRelayerTypedDataSignature = (relayerSignature: string) => { + const signatureWithThreshold = `0000${relayerSignature}`; + return decodeSignature(signatureWithThreshold); +}; + +export const getSignedTypedData = async ( + typedData: TypedDataPayload, + relayerSignature: string, + chainId: BigNumber, + walletAddress: string, + signer: Signer, +): Promise => { + // Ethers auto-generates the EIP712Domain type in the TypedDataEncoder, and so it needs to be removed + const types = { ...typedData.types }; + // @ts-ignore + delete types.EIP712Domain; + + // eslint-disable-next-line no-underscore-dangle + const digest = ethers.utils._TypedDataEncoder.hash(typedData.domain, types, typedData.message); + const completePayload = encodeMessageSubDigest(chainId, walletAddress, digest); + + const hash = ethers.utils.keccak256(completePayload); + + // Sign the digest + const hashArray = ethers.utils.arrayify(hash); + const ethsigNoType = await signer.signMessage(hashArray); + const signedDigest = `${ethsigNoType}${ETH_SIGN_FLAG}`; + + const { signers } = decodeRelayerTypedDataSignature(relayerSignature); + + return encodeSignature({ + threshold: EIP712_SIGNATURE_THRESHOLD, + signers: [ + ...signers, + { + weight: SIGNATURE_WEIGHT, + signature: signedDigest, + }, + ], + }); +}; + export const getEip155ChainId = (chainId: number) => `eip155:${chainId}`; diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts b/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts index 08abbdf2bb..1f38fdce2b 100644 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts +++ b/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts @@ -9,11 +9,13 @@ import { Provider } from './types'; import { PassportEventMap, PassportEvents } from '../types'; import TypedEventEmitter from '../typedEventEmitter'; import { mockUserZkEvm } from '../test/mocks'; +import { signTypedDataV4 } from './signTypedDataV4'; jest.mock('@ethersproject/providers'); jest.mock('./relayerClient'); jest.mock('./user'); jest.mock('./sendTransaction'); +jest.mock('./signTypedDataV4'); describe('ZkEvmProvider', () => { let passportEventEmitter: TypedEventEmitter; @@ -274,6 +276,49 @@ describe('ZkEvmProvider', () => { }); }); + describe('eth_signTypedData_v4', () => { + const address = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; + const typedDataPayload = '{}'; + + it('should throw an error if the user is not logged in', async () => { + const provider = getProvider(); + + await expect(async () => ( + provider.request({ method: 'eth_signTypedData_v4', params: [address, typedDataPayload] }) + )).rejects.toThrow( + new JsonRpcError(ProviderErrorCode.UNAUTHORIZED, 'Unauthorised - call eth_requestAccounts first'), + ); + }); + + it('should call signTypedDataV4 with the correct params', async () => { + const signature = '0x123'; + const mockMagicProvider = {}; + (loginZkEvmUser as jest.Mock).mockResolvedValue({ + user: mockUserZkEvm, + magicProvider: mockMagicProvider, + }); + (signTypedDataV4 as jest.Mock).mockResolvedValue(signature); + + const provider = getProvider(); + await provider.request({ method: 'eth_requestAccounts' }); + const result = await provider.request({ + method: 'eth_signTypedData_v4', + params: [address, typedDataPayload], + }); + + expect(result).toEqual(signature); + expect(signTypedDataV4).toHaveBeenCalledWith({ + method: 'eth_signTypedData_v4', + params: [address, typedDataPayload], + magicProvider: mockMagicProvider, + jsonRpcProvider: expect.any(Object), + relayerClient: expect.any(RelayerClient), + user: mockUserZkEvm, + guardianClient: expect.any(GuardianClient), + }); + }); + }); + describe('isPassport', () => { it('should be set to true', () => { const provider = getProvider(); diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts b/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts index 0ace380521..d921b9ab6a 100644 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts +++ b/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts @@ -20,6 +20,7 @@ import { JsonRpcError, ProviderErrorCode, RpcErrorCode } from './JsonRpcError'; import { loginZkEvmUser } from './user'; import { sendTransaction } from './sendTransaction'; import GuardianClient from '../guardian/guardian'; +import { signTypedDataV4 } from './signTypedDataV4'; export type ZkEvmProviderInput = { authManager: AuthManager; @@ -149,6 +150,22 @@ export class ZkEvmProvider implements Provider { case 'eth_accounts': { return this.isLoggedIn() ? [this.user.zkEvm.ethAddress] : []; } + case 'eth_signTypedData': + case 'eth_signTypedData_v4': { + if (!this.isLoggedIn()) { + throw new JsonRpcError(ProviderErrorCode.UNAUTHORIZED, 'Unauthorised - call eth_requestAccounts first'); + } + + return signTypedDataV4({ + method: request.method, + params: request.params || [], + magicProvider: this.magicProvider, + jsonRpcProvider: this.jsonRpcProvider, + relayerClient: this.relayerClient, + user: this.user, + guardianClient: this.guardianClient, + }); + } // Pass through methods case 'eth_gasPrice': case 'eth_getBalance':