From 262b511096758531b3d9cdfe6eacfb18efe44f3e Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Thu, 28 Mar 2024 10:47:50 -0400 Subject: [PATCH] signTypedData support --- .../unit/application-exception.filter.spec.ts | 11 ++- .../__test__/unit/signing.service.spec.ts | 74 ++++++++++++++- .../src/vault/core/service/signing.service.ts | 48 ++++++---- .../http/rest/controller/sign.controller.ts | 14 +-- .../src/lib/__test__/unit/action.type.spec.ts | 91 +++++++++++++++++++ .../src/lib/schema/action.schema.ts | 74 --------------- .../src/lib/type/action.type.ts | 39 +++++++- 7 files changed, 246 insertions(+), 105 deletions(-) create mode 100644 packages/policy-engine-shared/src/lib/__test__/unit/action.type.spec.ts delete mode 100644 packages/policy-engine-shared/src/lib/schema/action.schema.ts diff --git a/apps/vault/src/shared/filter/__test__/unit/application-exception.filter.spec.ts b/apps/vault/src/shared/filter/__test__/unit/application-exception.filter.spec.ts index 8f47ab56c..e1bfbd1ea 100644 --- a/apps/vault/src/shared/filter/__test__/unit/application-exception.filter.spec.ts +++ b/apps/vault/src/shared/filter/__test__/unit/application-exception.filter.spec.ts @@ -1,4 +1,4 @@ -import { ArgumentsHost, HttpStatus } from '@nestjs/common' +import { ArgumentsHost, HttpStatus, Logger } from '@nestjs/common' import { HttpArgumentsHost } from '@nestjs/common/interfaces' import { ConfigService } from '@nestjs/config' import { Response } from 'express' @@ -46,6 +46,15 @@ describe(ApplicationExceptionFilter.name, () => { }) describe('catch', () => { + // Silence the logger in these tests so we don't spam our console w/ errors that are "expected" + beforeAll(() => { + Logger.overrideLogger([]) + }) + + afterAll(() => { + Logger.overrideLogger(new Logger()) + }) + describe('when environment is production', () => { it('responds with exception status and short message', () => { const filter = new ApplicationExceptionFilter(buildConfigServiceMock(Env.PRODUCTION)) diff --git a/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts b/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts index c13b6e675..814e6a6a0 100644 --- a/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts +++ b/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts @@ -1,4 +1,4 @@ -import { Action, Request } from '@narval/policy-engine-shared' +import { Action, Eip712TypedData, Request } from '@narval/policy-engine-shared' import { Test } from '@nestjs/testing' import { Hex, @@ -7,7 +7,8 @@ import { parseTransaction, serializeTransaction, toHex, - verifyMessage + verifyMessage, + verifyTypedData } from 'viem' import { Wallet } from '../../../../../shared/type/domain.type' import { WalletRepository } from '../../../../persistence/repository/wallet.repository' @@ -139,5 +140,74 @@ describe('SigningService', () => { expect(result).toEqual(expectedSignature) expect(isVerified).toEqual(true) }) + + it('signs EIP712 Typed Data', async () => { + const typedData: Eip712TypedData = { + domain: { + chainId: 137, + name: 'Crypto Unicorns Authentication', + version: '1' + }, + message: { + contents: 'UNICOOOORN :)', + wallet: '0xdd4d43575a5eff17ec814da6ea810a0cc39ff23e', + nonce: '0e01c9bd-94a0-4ba1-925d-ab02688e65de' + }, + primaryType: 'Validator', + types: { + EIP712Domain: [ + { + name: 'name', + type: 'string' + }, + { + name: 'version', + type: 'string' + }, + { + name: 'chainId', + type: 'uint256' + } + ], + Validator: [ + { + name: 'contents', + type: 'string' + }, + { + name: 'wallet', + type: 'address' + }, + { + name: 'nonce', + type: 'string' + } + ] + } + } + const tenantId = 'tenantId' + const typedDataRequest: Request = { + action: Action.SIGN_TYPED_DATA, + nonce: 'random-nonce-111', + resourceId: 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1', + typedData + } + + const expectedSignature = + '0x1f6b8ebbd066c5a849e37fc890c1f2f1b6b0a91e3dd3e8279c646948e8f14b030a13a532fd04c6b5d92e11e008558b0b60b6d061c8f34483af7deab0591317da1b' + + // Call the sign method + const result = await signingService.sign(tenantId, typedDataRequest) + + const isVerified = await verifyTypedData({ + address: wallet.address, + signature: result, + ...typedData + }) + + // Assert the result + expect(isVerified).toEqual(true) + expect(result).toEqual(expectedSignature) + }) }) }) diff --git a/apps/vault/src/vault/core/service/signing.service.ts b/apps/vault/src/vault/core/service/signing.service.ts index e41612670..92d38bb49 100644 --- a/apps/vault/src/vault/core/service/signing.service.ts +++ b/apps/vault/src/vault/core/service/signing.service.ts @@ -1,4 +1,11 @@ -import { Action, Hex, Request, SignMessageAction, SignTransactionAction } from '@narval/policy-engine-shared' +import { + Action, + Hex, + Request, + SignMessageAction, + SignTransactionAction, + SignTypedDataAction +} from '@narval/policy-engine-shared' import { HttpStatus, Injectable } from '@nestjs/common' import { TransactionRequest, @@ -23,13 +30,14 @@ export class SigningService { return this.signTransaction(tenantId, request) } else if (request.action === Action.SIGN_MESSAGE) { return this.signMessage(tenantId, request) + } else if (request.action === Action.SIGN_TYPED_DATA) { + return this.signTypedData(tenantId, request) } throw new Error('Action not supported') } - async signTransaction(tenantId: string, action: SignTransactionAction): Promise { - const { transactionRequest, resourceId } = action + async #buildClient(tenantId: string, resourceId: string, chainId?: number) { const wallet = await this.walletRepository.findById(tenantId, resourceId) if (!wallet) { throw new ApplicationException({ @@ -42,7 +50,7 @@ export class SigningService { const account = privateKeyToAccount(wallet.privateKey) const chain = extractChain({ chains: Object.values(chains), - id: transactionRequest.chainId + id: chainId || 1 }) const client = createWalletClient({ @@ -51,8 +59,15 @@ export class SigningService { transport: http('') // clear the RPC so we don't call any chain stuff here. }) + return client + } + + async signTransaction(tenantId: string, action: SignTransactionAction): Promise { + const { transactionRequest, resourceId } = action + const client = await this.#buildClient(tenantId, resourceId, transactionRequest.chainId) + const txRequest: TransactionRequest = { - from: checksumAddress(account.address), + from: checksumAddress(client.account.address), to: transactionRequest.to, nonce: transactionRequest.nonce, data: transactionRequest.data, @@ -83,24 +98,17 @@ export class SigningService { async signMessage(tenantId: string, action: SignMessageAction): Promise { const { message, resourceId } = action - const wallet = await this.walletRepository.findById(tenantId, resourceId) - if (!wallet) { - throw new ApplicationException({ - message: 'Wallet not found', - suggestedHttpStatusCode: HttpStatus.BAD_REQUEST, - context: { clientId: tenantId, resourceId } - }) - } + const client = await this.#buildClient(tenantId, resourceId) - const account = privateKeyToAccount(wallet.privateKey) + const signature = await client.signMessage({ message }) + return signature + } - const client = createWalletClient({ - account, - chain: chains.mainnet, - transport: http('') // clear the RPC so we don't call any chain stuff here. - }) + async signTypedData(tenantId: string, action: SignTypedDataAction): Promise { + const { typedData, resourceId } = action + const client = await this.#buildClient(tenantId, resourceId) - const signature = await client.signMessage({ message }) + const signature = await client.signTypedData(typedData) return signature } } diff --git a/apps/vault/src/vault/http/rest/controller/sign.controller.ts b/apps/vault/src/vault/http/rest/controller/sign.controller.ts index da03d3a43..4f1f3a946 100644 --- a/apps/vault/src/vault/http/rest/controller/sign.controller.ts +++ b/apps/vault/src/vault/http/rest/controller/sign.controller.ts @@ -2,20 +2,20 @@ import { Request } from '@narval/policy-engine-shared' import { Body, Controller, Post, UseGuards } from '@nestjs/common' import { createZodDto } from 'nestjs-zod' import { - SignMessageActionSchema, - SignTransactionActionSchema, - SignTypedDataActionSchema -} from 'packages/policy-engine-shared/src/lib/schema/action.schema' + SignMessageAction, + SignTransactionAction, + SignTypedDataAction +} from 'packages/policy-engine-shared/src/lib/type/action.type' import { z } from 'zod' import { ClientId } from '../../../../shared/decorator/client-id.decorator' import { AuthorizationGuard } from '../../../../shared/guard/authorization.guard' import { SigningService } from '../../../core/service/signing.service' -const SignRequestSchema = z.object({ - request: z.union([SignTransactionActionSchema, SignMessageActionSchema, SignTypedDataActionSchema]) +const SignRequest = z.object({ + request: z.union([SignTransactionAction, SignMessageAction, SignTypedDataAction]) }) -class SignRequestDto extends createZodDto(SignRequestSchema) {} +class SignRequestDto extends createZodDto(SignRequest) {} @Controller('/sign') @UseGuards(AuthorizationGuard) export class SignController { diff --git a/packages/policy-engine-shared/src/lib/__test__/unit/action.type.spec.ts b/packages/policy-engine-shared/src/lib/__test__/unit/action.type.spec.ts new file mode 100644 index 000000000..3f24742ca --- /dev/null +++ b/packages/policy-engine-shared/src/lib/__test__/unit/action.type.spec.ts @@ -0,0 +1,91 @@ +import { toHex } from 'viem' +import { Action, SignTypedDataAction } from '../../type/action.type' + +describe('SignTypedDataAction', () => { + const typedData = { + domain: { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC' + }, + types: { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' } + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' } + ] + }, + primaryType: 'Mail', + message: { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826' + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' + }, + contents: 'Hello, Bob!' + } + } + + it('should validate a valid SignTypedDataAction object', () => { + const validAction = { + action: Action.SIGN_TYPED_DATA, + nonce: 'xxx', + resourceId: 'resourceId', + typedData + } + + const result = SignTypedDataAction.safeParse(validAction) + + expect(result).toEqual({ + success: true, + data: expect.any(Object) + }) + }) + + it('should validate a valid typedData as a string', () => { + const validAction = { + action: Action.SIGN_TYPED_DATA, + nonce: 'xxx', + resourceId: 'resourceId', + typedData: JSON.stringify(typedData) + } + + const result = SignTypedDataAction.safeParse(validAction) + + expect(result.success).toEqual(true) + }) + + it('should validate a valid typedData as a hex-encoded stringified json object', () => { + const validAction = { + action: Action.SIGN_TYPED_DATA, + nonce: 'xxx', + resourceId: 'resourceId', + typedData: toHex(JSON.stringify(typedData)) + } + + const result = SignTypedDataAction.safeParse(validAction) + + expect(result.success).toEqual(true) + }) + + it('should not validate an invalid SignTypedDataAction object with invalid JSON string', () => { + const invalidAction = { + action: Action.SIGN_TYPED_DATA, + nonce: 'xxx', + resourceId: 'resourceId', + typedData: 'invalidJSON' + } + + const result = SignTypedDataAction.safeParse(invalidAction) + + expect(result.success).toEqual(false) + }) +}) diff --git a/packages/policy-engine-shared/src/lib/schema/action.schema.ts b/packages/policy-engine-shared/src/lib/schema/action.schema.ts deleted file mode 100644 index 2b86094a2..000000000 --- a/packages/policy-engine-shared/src/lib/schema/action.schema.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { z } from 'zod' -import { Action } from '../type/action.type' -import { addressSchema } from './address.schema' -import { hexSchema } from './hex.schema' - -export const AccessListSchema = z.array( - z.object({ - address: addressSchema, - storageKeys: z.array(hexSchema) - }) -) -// export type AccessList = z.infer - -export const ActionSchema = z.nativeEnum(Action) - -export const BaseActionSchema = z.object({ - action: ActionSchema, - nonce: z.string() -}) -// export type BaseActionSchema = z.infer - -export const TransactionRequestSchema = z.object({ - chainId: z.number(), - from: addressSchema, - nonce: z.number().optional(), - accessList: AccessListSchema.optional(), - data: hexSchema.optional(), - gas: z.coerce.bigint().optional(), - maxFeePerGas: z.coerce.bigint().optional(), - maxPriorityFeePerGas: z.coerce.bigint().optional(), - to: addressSchema.nullable().optional(), - type: z.literal('2').optional(), - value: hexSchema.optional() -}) -// export type TransactionRequest = z.infer - -export const SignTransactionActionSchema = z.intersection( - BaseActionSchema, - z.object({ - action: z.literal(Action.SIGN_TRANSACTION), - resourceId: z.string(), - transactionRequest: TransactionRequestSchema - }) -) -// export type SignTransactionAction = z.infer - -// Matching viem's SignableMessage options https://viem.sh/docs/actions/wallet/signMessage#message -export const SignableMessageSchema = z.union([ - z.string(), - z.object({ - raw: hexSchema - }) -]) -// export type SignableMessage = z.infer - -export const SignMessageActionSchema = z.intersection( - BaseActionSchema, - z.object({ - action: z.literal(Action.SIGN_MESSAGE), - resourceId: z.string(), - message: SignableMessageSchema - }) -) -// export type SignMessageAction = z.infer - -export const SignTypedDataActionSchema = z.intersection( - BaseActionSchema, - z.object({ - action: z.literal(Action.SIGN_TYPED_DATA), - resourceId: z.string(), - typedData: z.string() - }) -) -// export type SignTypedDataAction = z.infer diff --git a/packages/policy-engine-shared/src/lib/type/action.type.ts b/packages/policy-engine-shared/src/lib/type/action.type.ts index e08d45b45..635b4d760 100644 --- a/packages/policy-engine-shared/src/lib/type/action.type.ts +++ b/packages/policy-engine-shared/src/lib/type/action.type.ts @@ -1,6 +1,8 @@ +import { fromHex } from 'viem' import { z } from 'zod' import { addressSchema } from '../schema/address.schema' import { hexSchema } from '../schema/hex.schema' +import { isHexString } from '../util/typeguards' import { Address, JwtString } from './domain.type' import { AccountClassification, @@ -100,6 +102,30 @@ export const TransactionRequest = z.object({ }) export type TransactionRequest = z.infer +export const Eip712Domain = z.object({ + name: z.string().optional(), + version: z.string().optional(), + chainId: z.number().optional(), + verifyingContract: addressSchema.optional(), + salt: hexSchema.optional() +}) +export type Eip712Domain = z.infer + +export const Eip712TypedData = z.object({ + domain: Eip712Domain, + types: z.record( + z.array( + z.object({ + name: z.string(), // + type: z.string() // TODO: make this more specific to the solidity types allowed + }) + ) + ), + primaryType: z.string(), + message: z.record(z.unknown()) +}) +export type Eip712TypedData = z.infer + export const SignTransactionAction = z.intersection( BaseActionSchema, z.object({ @@ -134,7 +160,18 @@ export const SignTypedDataAction = z.intersection( z.object({ action: z.literal(Action.SIGN_TYPED_DATA), resourceId: z.string(), - typedData: z.string() + // Accept typedData as a JSON object, or a Stringified JSON object, or a hex-encoded stringified json object + typedData: z.preprocess((val) => { + if (typeof val === 'string') { + try { + const decoded = isHexString(val) ? fromHex(val, 'string') : val + return JSON.parse(decoded) + } catch (error) { + return val + } + } + return val + }, Eip712TypedData) }) ) export type SignTypedDataAction = z.infer