Skip to content

Commit

Permalink
Hooking up a sign-transaction (no auth) flow
Browse files Browse the repository at this point in the history
  • Loading branch information
mattschoch committed Mar 15, 2024
1 parent e548cbc commit 5460064
Show file tree
Hide file tree
Showing 16 changed files with 515 additions and 176 deletions.
2 changes: 1 addition & 1 deletion apps/armory/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const withSwagger = (app: INestApplication): INestApplication => {
* @returns The modified INestApplication instance.
*/
const withGlobalPipes = (app: INestApplication): INestApplication => {
app.useGlobalPipes(new ValidationPipe())
app.useGlobalPipes(new ValidationPipe({ transform: true }))

return app
}
Expand Down
117 changes: 9 additions & 108 deletions apps/policy-engine/src/engine/evaluation-request.dto.ts
Original file line number Diff line number Diff line change
@@ -1,112 +1,13 @@
import { AccessList, AccountId, Action, Address, BaseActionDto, FiatCurrency, Hex } from '@narval/policy-engine-shared'
import {
AccountId,
Action,
FiatCurrency,
SignMessageRequestDataDto,
SignTransactionRequestDataDto
} from '@narval/policy-engine-shared'
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'
import { Transform, Type } from 'class-transformer'
import { IsDefined, IsEthereumAddress, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator'

export class TransactionRequestDto {
@IsString()
@IsDefined()
@IsEthereumAddress()
@Transform(({ value }) => value.toLowerCase())
@ApiProperty({
required: true,
format: 'EthereumAddress'
})
from: Address

@IsString()
@IsEthereumAddress()
@Transform(({ value }) => value.toLowerCase())
@ApiProperty({
format: 'EthereumAddress'
})
to?: Address | null

@IsString()
@ApiProperty({
type: 'string',
format: 'Hexadecimal'
})
data?: Hex

@IsOptional()
@Transform(({ value }) => BigInt(value))
@ApiProperty({
format: 'bigint',
required: false,
type: 'string'
})
gas?: bigint
@IsOptional()
@Transform(({ value }) => BigInt(value))
@ApiProperty({
format: 'bigint',
required: false,
type: 'string'
})
maxFeePerGas?: bigint
@IsOptional()
@Transform(({ value }) => BigInt(value))
@ApiProperty({
format: 'bigint',
required: false,
type: 'string'
})
maxPriorityFeePerGas?: bigint

@ApiProperty()
nonce?: number

value?: Hex

chainId: number

accessList?: AccessList

type?: '2'
}

export class SignTransactionRequestDataDto extends BaseActionDto {
@IsIn(Object.values(Action))
@IsDefined()
@ApiProperty({
enum: Object.values(Action),
default: Action.SIGN_TRANSACTION
})
action: typeof Action.SIGN_TRANSACTION

@IsString()
@IsDefined()
@ApiProperty()
resourceId: string

@ValidateNested()
@IsDefined()
@ApiProperty({
type: TransactionRequestDto
})
transactionRequest: TransactionRequestDto
}

export class SignMessageRequestDataDto extends BaseActionDto {
@IsIn(Object.values(Action))
@IsDefined()
@ApiProperty({
enum: Object.values(Action),
default: Action.SIGN_MESSAGE
})
action: typeof Action.SIGN_MESSAGE

@IsString()
@IsDefined()
@ApiProperty()
resourceId: string

@IsString()
@IsDefined()
@ApiProperty()
message: string // TODO: Is this string hex or raw?
}
import { Type } from 'class-transformer'
import { IsDefined, IsOptional, ValidateNested } from 'class-validator'

export class HistoricalTransferDto {
amount: string
Expand Down
2 changes: 1 addition & 1 deletion apps/policy-engine/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const withSwagger = (app: INestApplication): INestApplication => {
* @returns The modified INestApplication instance.
*/
const withGlobalPipes = (app: INestApplication): INestApplication => {
app.useGlobalPipes(new ValidationPipe())
app.useGlobalPipes(new ValidationPipe({ transform: true }))

return app
}
Expand Down
3 changes: 2 additions & 1 deletion apps/vault/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ const config: Config = {
tsconfig: '<rootDir>/tsconfig.spec.json'
}
]
}
},
workerThreads: true // EXPERIMENTAL; lets BigInt serialization work
}

export default config
2 changes: 1 addition & 1 deletion apps/vault/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const withSwagger = (app: INestApplication): INestApplication => {
* @returns The modified INestApplication instance.
*/
const withGlobalPipes = (app: INestApplication): INestApplication => {
app.useGlobalPipes(new ValidationPipe())
app.useGlobalPipes(new ValidationPipe({ transform: true }))

return app
}
Expand Down
11 changes: 9 additions & 2 deletions apps/vault/src/shared/schema/wallet.schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { Hex } from '@narval/policy-engine-shared'
import { z } from 'zod'

export const walletSchema = z.object({
id: z.string().min(1),
privateKey: z.string().regex(/^(0x)?([A-Fa-f0-9]{64})$/),
address: z.string().regex(/^0x([A-Fa-f0-9]{40})$/)
privateKey: z
.string()
.regex(/^(0x)?([A-Fa-f0-9]{64})$/)
.transform((val: string): Hex => val as Hex),
address: z
.string()
.regex(/^0x([A-Fa-f0-9]{40})$/)
.transform((val: string): Hex => val as Hex)
})
162 changes: 162 additions & 0 deletions apps/vault/src/vault/__test__/e2e/sign.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { EncryptionModuleOptionProvider } from '@narval/encryption-module'
import { HttpStatus, INestApplication, ValidationPipe } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { Test, TestingModule } from '@nestjs/testing'
import request from 'supertest'
import { v4 as uuid } from 'uuid'
import { load } from '../../../main.config'
import { REQUEST_HEADER_API_KEY, REQUEST_HEADER_CLIENT_ID } from '../../../main.constant'
import { KeyValueRepository } from '../../../shared/module/key-value/core/repository/key-value.repository'
import { InMemoryKeyValueRepository } from '../../../shared/module/key-value/persistence/repository/in-memory-key-value.repository'
import { getTestRawAesKeyring } from '../../../shared/testing/encryption.testing'
import { Tenant, Wallet } from '../../../shared/type/domain.type'
import { TenantService } from '../../../tenant/core/service/tenant.service'
import { TenantModule } from '../../../tenant/tenant.module'
import { WalletRepository } from '../../persistence/repository/wallet.repository'

describe('Sign', () => {
let app: INestApplication
let module: TestingModule

const adminApiKey = 'test-admin-api-key'
const clientId = uuid()
const tenant: Tenant = {
clientId,
clientSecret: adminApiKey,
createdAt: new Date(),
updatedAt: new Date()
}

const wallet: Wallet = {
id: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157',
address: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157',
privateKey: '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5'
}

beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
load: [load],
isGlobal: true
}),
TenantModule
]
})
.overrideProvider(KeyValueRepository)
.useValue(new InMemoryKeyValueRepository())
.overrideProvider(EncryptionModuleOptionProvider)
.useValue({
keyring: getTestRawAesKeyring()
})
.overrideProvider(TenantService)
.useValue({
findAll: jest.fn().mockResolvedValue([tenant]),
findByClientId: jest.fn().mockResolvedValue(tenant)
})
.overrideProvider(WalletRepository)
.useValue({
findById: jest.fn().mockResolvedValue(wallet)
})
.compile()

app = module.createNestApplication()

// Use global pipes
// THIS IS NEEDED to make sure it parses/transforms properly.
app.useGlobalPipes(new ValidationPipe({ transform: true }))

await app.init()
})

afterAll(async () => {
// await testPrismaService.truncateAll()
await module.close()
await app.close()
})

describe('POST /sign', () => {
it('has client secret guard', async () => {
const { status } = await request(app.getHttpServer())
.post('/sign')
.set(REQUEST_HEADER_API_KEY, adminApiKey)
// .set(REQUEST_HEADER_CLIENT_ID, clientId) NO CLIENT SECRET
.send({})

expect(status).toEqual(HttpStatus.UNAUTHORIZED)
})

it('validates nested txn data', async () => {
// ValidationPipe & Transforms can easily be implemented incorrectly, so make sure this is running.

const payload = {
request: {
action: 'signTransaction',
nonce: 'random-nonce-111',
transactionRequest: {
from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157',
to: '04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B', // INVALID
chainId: 137,
value: '0x5af3107a4000',
data: '0x',
nonce: 317,
type: '2',
gas: '21004',
maxFeePerGas: '291175227375',
maxPriorityFeePerGas: '81000000000'
},
resourceId: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157'
}
}

const { status, body } = await request(app.getHttpServer())
.post('/sign')
.set(REQUEST_HEADER_API_KEY, adminApiKey)
.set(REQUEST_HEADER_CLIENT_ID, clientId)
.send(payload)

expect(status).toEqual(HttpStatus.BAD_REQUEST)

expect(body).toEqual({
error: 'Bad Request',
message: ['request.transactionRequest.to must be an Ethereum address'],
statusCode: HttpStatus.BAD_REQUEST
})
})

it('signs', async () => {
const payload = {
request: {
action: 'signTransaction',
nonce: 'random-nonce-111',
transactionRequest: {
from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157',
to: '0x04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B',
chainId: 137,
value: '0x5af3107a4000',
data: '0x',
nonce: 317,
type: '2',
gas: '21004',
maxFeePerGas: '291175227375',
maxPriorityFeePerGas: '81000000000'
},
resourceId: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157'
}
}

const { status, body } = await request(app.getHttpServer())
.post('/sign')
.set(REQUEST_HEADER_API_KEY, adminApiKey)
.set(REQUEST_HEADER_CLIENT_ID, clientId)
.send(payload)

expect(status).toEqual(HttpStatus.CREATED)

expect(body).toEqual({
signature:
'0x02f875818982013d8512dbf9ea008543cb655fef82520c9404b12f0863b83c7162429f0ebb0dfda20e1aa97b865af3107a400080c080a00de78cbb96f83ef1b8d6be4d55b4046b2706c7d63ce0a815bae2b1ea4f891e6ba06f7648a9c9710b171d55e056c4abca268857f607a8a4a257d945fc44ace9f076'
})
})
})
})
Loading

0 comments on commit 5460064

Please sign in to comment.