diff --git a/jest.config.js b/jest.config.js index 9d41d103..df6544be 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires const { join } = require('node:path'); -/* +/** * For a detailed explanation regarding each configuration property and type check, visit: * https://jestjs.io/docs/configuration */ diff --git a/packages/e2e/jest.config.js b/packages/e2e/jest.config.js index afe021d9..2d2802f9 100644 --- a/packages/e2e/jest.config.js +++ b/packages/e2e/jest.config.js @@ -1,6 +1,21 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires -const config = require('../../jest.config'); +const { join } = require('node:path'); +/** + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ module.exports = { - ...config, + collectCoverage: false, // It doesn't make sense to collect coverage for e2e tests because they target high level features and interaction with other services. + maxWorkers: 1, // We don't want to run tests in parallel because they might interfere with each other. This option is the same as --runInBand. See: https://stackoverflow.com/a/46489246. + + preset: 'ts-jest', + resetMocks: true, + restoreMocks: true, + setupFiles: [join(__dirname, '../../jest.setup.js')], + testEnvironment: 'jest-environment-node', + testMatch: ['**/?(*.)+(feature).[t]s?(x)'], + testPathIgnorePatterns: ['/.build', '/dist/', '/build/'], + testTimeout: 40_000, + verbose: true, }; diff --git a/packages/e2e/src/signed-api/signed-api.json b/packages/e2e/src/signed-api/signed-api.json index 8c010898..f941fda3 100644 --- a/packages/e2e/src/signed-api/signed-api.json +++ b/packages/e2e/src/signed-api/signed-api.json @@ -12,7 +12,8 @@ } ], "allowedAirnodes": [ - { "address": "0xbF3137b0a7574563a23a8fC8badC6537F98197CC", "authTokens": ["${AIRNODE_FEED_AUTH_TOKEN}"] } + { "address": "0xbF3137b0a7574563a23a8fC8badC6537F98197CC", "authTokens": ["${AIRNODE_FEED_AUTH_TOKEN}"] }, + { "address": "0xA840740650b832B9480EE56d4e3641f5E57DDE3E", "authTokens": null } ], "stage": "e2e-test", "version": "0.6.0" diff --git a/packages/e2e/src/utils.ts b/packages/e2e/src/utils.ts index 858efece..988d368f 100644 --- a/packages/e2e/src/utils.ts +++ b/packages/e2e/src/utils.ts @@ -19,3 +19,52 @@ export const formatData = (networkResponse: any) => { export const airnode = ethers.Wallet.fromMnemonic( 'diamond result history offer forest diagram crop armed stumble orchard stage glance' ).address; + +export const deriveBeaconId = (airnode: string, templateId: string) => + ethers.utils.keccak256(ethers.utils.solidityPack(['address', 'bytes32'], [airnode, templateId])); + +export const deriveTemplateId = (endpointId: string, encodedParameters: string) => + ethers.utils.keccak256(ethers.utils.solidityPack(['bytes32', 'bytes'], [endpointId, encodedParameters])); + +export const generateRandomBytes = (len: number) => ethers.utils.hexlify(ethers.utils.randomBytes(len)); + +export const generateRandomWallet = () => ethers.Wallet.createRandom(); + +export const generateRandomEvmAddress = () => generateRandomWallet().address; + +// NOTE: This function (and related helpers) are copied over from signed-api project. Ideally, these would come from +// commons. +export const generateDataSignature = async ( + wallet: ethers.Wallet, + templateId: string, + timestamp: string, + data: string +) => { + return wallet.signMessage( + ethers.utils.arrayify( + ethers.utils.keccak256( + ethers.utils.solidityPack(['bytes32', 'uint256', 'bytes'], [templateId, timestamp, data || '0x']) + ) + ) + ); +}; + +export const createSignedData = async ( + airnodeWallet: ethers.Wallet, + timestamp: string = Math.floor(Date.now() / 1000).toString() +) => { + const airnode = airnodeWallet.address; + const templateId = generateRandomBytes(32); + const beaconId = deriveBeaconId(airnode, templateId); + const encodedValue = '0x00000000000000000000000000000000000000000000005718e3a22ce01f7a40'; + const signature = await generateDataSignature(airnodeWallet, templateId, timestamp, encodedValue); + + return { + airnode, + templateId, + beaconId, + timestamp, + encodedValue, + signature, + }; +}; diff --git a/packages/e2e/src/user.test.ts b/packages/e2e/test/signed-api.feature.ts similarity index 68% rename from packages/e2e/src/user.test.ts rename to packages/e2e/test/signed-api.feature.ts index c0812f6c..79c4c117 100644 --- a/packages/e2e/src/user.test.ts +++ b/packages/e2e/test/signed-api.feature.ts @@ -1,7 +1,8 @@ import { go } from '@api3/promise-utils'; import axios from 'axios'; +import { ethers } from 'ethers'; -import { airnode, formatData } from './utils'; +import { airnode, createSignedData, formatData } from '../src/utils'; test('respects the delay', async () => { const start = Date.now(); @@ -53,3 +54,17 @@ test('ensures Signed API handles requests with huge payloads', async () => { error: { message: 'request entity too large' }, }); }); + +test('handles both EIP-55 and lowercased addresses', async () => { + const airnodeWallet = new ethers.Wallet('28975fdc5c339153fca3c4cb734b1b00bf4176a770d6f60fdc202d03d1ca61bb'); + const airnode = airnodeWallet.address; + const lowercaseAirnode = airnode.toLowerCase(); + const timestamp = (Math.floor(Date.now() / 1000) - 60).toString(); // 1 min ago + const signedData = await createSignedData(airnodeWallet, timestamp); + await axios.post(`http://localhost:8090/${airnode}`, [signedData]); + + const eip55AirnodeResponse = await axios.get(`http://localhost:8090/delayed/${airnode}`); + const lowercasedAirnodeResponse = await axios.get(`http://localhost:8090/delayed/${lowercaseAirnode}`); + + expect(eip55AirnodeResponse.data).toStrictEqual(lowercasedAirnodeResponse.data); +}); diff --git a/packages/signed-api/src/handlers.ts b/packages/signed-api/src/handlers.ts index 60635abc..66d41abd 100644 --- a/packages/signed-api/src/handlers.ts +++ b/packages/signed-api/src/handlers.ts @@ -1,5 +1,5 @@ -import { go } from '@api3/promise-utils'; -import { isEmpty, isNil, omit, pick } from 'lodash'; +import { go, goSync } from '@api3/promise-utils'; +import { isEmpty, omit, pick } from 'lodash'; import { getConfig } from './config/config'; import { loadEnv } from './env'; @@ -19,9 +19,16 @@ const env = loadEnv(); // important for the delayed endpoint which may not be allowed to return the fresh data yet. export const batchInsertData = async ( authorizationHeader: string | undefined, - requestBody: unknown, - airnodeAddress: string + rawRequestBody: unknown, + rawAirnodeAddress: string ): Promise => { + // Make sure the Airnode address is valid. + const goAirnodeAddresses = goSync(() => evmAddressSchema.parse(rawAirnodeAddress)); + if (!goAirnodeAddresses.success) { + return generateErrorResponse(400, 'Invalid request, airnode address must be an EVM address'); + } + const airnodeAddress = goAirnodeAddresses.data; + // Ensure that the batch of signed that comes from a whitelisted Airnode. const { endpoints, allowedAirnodes } = getConfig(); if (allowedAirnodes !== '*') { @@ -40,7 +47,7 @@ export const batchInsertData = async ( } } - const goValidateSchema = await go(async () => batchSignedDataSchema.parseAsync(requestBody)); + const goValidateSchema = await go(async () => batchSignedDataSchema.parseAsync(rawRequestBody)); if (!goValidateSchema.success) { return generateErrorResponse(400, 'Invalid request, body must fit schema for batch of signed data', { detail: goValidateSchema.error.message, @@ -135,14 +142,14 @@ export const batchInsertData = async ( export const getData = async ( endpoint: Endpoint, authorizationHeader: string | undefined, - airnodeAddress: string + rawAirnodeAddress: string ): Promise => { - if (isNil(airnodeAddress)) return generateErrorResponse(400, 'Invalid request, airnode address is missing'); - - const goValidateSchema = await go(async () => evmAddressSchema.parseAsync(airnodeAddress)); - if (!goValidateSchema.success) { + // Make sure the Airnode address is valid. + const goAirnodeAddresses = goSync(() => evmAddressSchema.parse(rawAirnodeAddress)); + if (!goAirnodeAddresses.success) { return generateErrorResponse(400, 'Invalid request, airnode address must be an EVM address'); } + const airnodeAddress = goAirnodeAddresses.data; const { delaySeconds, authTokens } = endpoint; const authToken = extractBearerToken(authorizationHeader); diff --git a/packages/signed-api/src/schema.ts b/packages/signed-api/src/schema.ts index f1091a35..d8ba26f8 100644 --- a/packages/signed-api/src/schema.ts +++ b/packages/signed-api/src/schema.ts @@ -1,10 +1,23 @@ import { type LogFormat, logFormatOptions, logLevelOptions, type LogLevel } from '@api3/commons'; +import { goSync } from '@api3/promise-utils'; +import { ethers } from 'ethers'; import { uniqBy } from 'lodash'; import { z } from 'zod'; import packageJson from '../package.json'; -export const evmAddressSchema = z.string().regex(/^0x[\dA-Fa-f]{40}$/, 'Must be a valid EVM address'); +export const evmAddressSchema = z.string().transform((val, ctx) => { + const goChecksumAddress = goSync(() => ethers.utils.getAddress(val)); + if (!goChecksumAddress.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid EVM address', + path: [], + }); + return ''; + } + return goChecksumAddress.data; +}); export const evmIdSchema = z.string().regex(/^0x[\dA-Fa-f]{64}$/, 'Must be a valid EVM ID');