Skip to content

Commit

Permalink
Ignore casing when comparing address (#253)
Browse files Browse the repository at this point in the history
* Support lower/mixed case addresses

* Make e2e test be .feature.ts instead of .test.ts

* Fix test
  • Loading branch information
Siegrift authored Mar 7, 2024
1 parent d2af1a3 commit a70162b
Show file tree
Hide file tree
Showing 7 changed files with 116 additions and 16 deletions.
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down
19 changes: 17 additions & 2 deletions packages/e2e/jest.config.js
Original file line number Diff line number Diff line change
@@ -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: ['<rootDir>/.build', '<rootDir>/dist/', '<rootDir>/build/'],
testTimeout: 40_000,
verbose: true,
};
3 changes: 2 additions & 1 deletion packages/e2e/src/signed-api/signed-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 49 additions & 0 deletions packages/e2e/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
};
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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);
});
27 changes: 17 additions & 10 deletions packages/signed-api/src/handlers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<ApiResponse> => {
// 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 !== '*') {
Expand All @@ -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,
Expand Down Expand Up @@ -135,14 +142,14 @@ export const batchInsertData = async (
export const getData = async (
endpoint: Endpoint,
authorizationHeader: string | undefined,
airnodeAddress: string
rawAirnodeAddress: string
): Promise<ApiResponse> => {
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);
Expand Down
15 changes: 14 additions & 1 deletion packages/signed-api/src/schema.ts
Original file line number Diff line number Diff line change
@@ -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');

Expand Down

0 comments on commit a70162b

Please sign in to comment.