Skip to content

Commit a70162b

Browse files
authored
Ignore casing when comparing address (#253)
* Support lower/mixed case addresses * Make e2e test be .feature.ts instead of .test.ts * Fix test
1 parent d2af1a3 commit a70162b

File tree

7 files changed

+116
-16
lines changed

7 files changed

+116
-16
lines changed

jest.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// eslint-disable-next-line @typescript-eslint/no-var-requires
22
const { join } = require('node:path');
33

4-
/*
4+
/**
55
* For a detailed explanation regarding each configuration property and type check, visit:
66
* https://jestjs.io/docs/configuration
77
*/

packages/e2e/jest.config.js

+17-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
// eslint-disable-next-line @typescript-eslint/no-var-requires
2-
const config = require('../../jest.config');
2+
const { join } = require('node:path');
33

4+
/**
5+
* For a detailed explanation regarding each configuration property and type check, visit:
6+
* https://jestjs.io/docs/configuration
7+
*/
48
module.exports = {
5-
...config,
9+
collectCoverage: false, // It doesn't make sense to collect coverage for e2e tests because they target high level features and interaction with other services.
10+
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.
11+
12+
preset: 'ts-jest',
13+
resetMocks: true,
14+
restoreMocks: true,
15+
setupFiles: [join(__dirname, '../../jest.setup.js')],
16+
testEnvironment: 'jest-environment-node',
17+
testMatch: ['**/?(*.)+(feature).[t]s?(x)'],
18+
testPathIgnorePatterns: ['<rootDir>/.build', '<rootDir>/dist/', '<rootDir>/build/'],
19+
testTimeout: 40_000,
20+
verbose: true,
621
};

packages/e2e/src/signed-api/signed-api.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
}
1313
],
1414
"allowedAirnodes": [
15-
{ "address": "0xbF3137b0a7574563a23a8fC8badC6537F98197CC", "authTokens": ["${AIRNODE_FEED_AUTH_TOKEN}"] }
15+
{ "address": "0xbF3137b0a7574563a23a8fC8badC6537F98197CC", "authTokens": ["${AIRNODE_FEED_AUTH_TOKEN}"] },
16+
{ "address": "0xA840740650b832B9480EE56d4e3641f5E57DDE3E", "authTokens": null }
1617
],
1718
"stage": "e2e-test",
1819
"version": "0.6.0"

packages/e2e/src/utils.ts

+49
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,52 @@ export const formatData = (networkResponse: any) => {
1919
export const airnode = ethers.Wallet.fromMnemonic(
2020
'diamond result history offer forest diagram crop armed stumble orchard stage glance'
2121
).address;
22+
23+
export const deriveBeaconId = (airnode: string, templateId: string) =>
24+
ethers.utils.keccak256(ethers.utils.solidityPack(['address', 'bytes32'], [airnode, templateId]));
25+
26+
export const deriveTemplateId = (endpointId: string, encodedParameters: string) =>
27+
ethers.utils.keccak256(ethers.utils.solidityPack(['bytes32', 'bytes'], [endpointId, encodedParameters]));
28+
29+
export const generateRandomBytes = (len: number) => ethers.utils.hexlify(ethers.utils.randomBytes(len));
30+
31+
export const generateRandomWallet = () => ethers.Wallet.createRandom();
32+
33+
export const generateRandomEvmAddress = () => generateRandomWallet().address;
34+
35+
// NOTE: This function (and related helpers) are copied over from signed-api project. Ideally, these would come from
36+
// commons.
37+
export const generateDataSignature = async (
38+
wallet: ethers.Wallet,
39+
templateId: string,
40+
timestamp: string,
41+
data: string
42+
) => {
43+
return wallet.signMessage(
44+
ethers.utils.arrayify(
45+
ethers.utils.keccak256(
46+
ethers.utils.solidityPack(['bytes32', 'uint256', 'bytes'], [templateId, timestamp, data || '0x'])
47+
)
48+
)
49+
);
50+
};
51+
52+
export const createSignedData = async (
53+
airnodeWallet: ethers.Wallet,
54+
timestamp: string = Math.floor(Date.now() / 1000).toString()
55+
) => {
56+
const airnode = airnodeWallet.address;
57+
const templateId = generateRandomBytes(32);
58+
const beaconId = deriveBeaconId(airnode, templateId);
59+
const encodedValue = '0x00000000000000000000000000000000000000000000005718e3a22ce01f7a40';
60+
const signature = await generateDataSignature(airnodeWallet, templateId, timestamp, encodedValue);
61+
62+
return {
63+
airnode,
64+
templateId,
65+
beaconId,
66+
timestamp,
67+
encodedValue,
68+
signature,
69+
};
70+
};

packages/e2e/src/user.test.ts renamed to packages/e2e/test/signed-api.feature.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { go } from '@api3/promise-utils';
22
import axios from 'axios';
3+
import { ethers } from 'ethers';
34

4-
import { airnode, formatData } from './utils';
5+
import { airnode, createSignedData, formatData } from '../src/utils';
56

67
test('respects the delay', async () => {
78
const start = Date.now();
@@ -53,3 +54,17 @@ test('ensures Signed API handles requests with huge payloads', async () => {
5354
error: { message: 'request entity too large' },
5455
});
5556
});
57+
58+
test('handles both EIP-55 and lowercased addresses', async () => {
59+
const airnodeWallet = new ethers.Wallet('28975fdc5c339153fca3c4cb734b1b00bf4176a770d6f60fdc202d03d1ca61bb');
60+
const airnode = airnodeWallet.address;
61+
const lowercaseAirnode = airnode.toLowerCase();
62+
const timestamp = (Math.floor(Date.now() / 1000) - 60).toString(); // 1 min ago
63+
const signedData = await createSignedData(airnodeWallet, timestamp);
64+
await axios.post(`http://localhost:8090/${airnode}`, [signedData]);
65+
66+
const eip55AirnodeResponse = await axios.get(`http://localhost:8090/delayed/${airnode}`);
67+
const lowercasedAirnodeResponse = await axios.get(`http://localhost:8090/delayed/${lowercaseAirnode}`);
68+
69+
expect(eip55AirnodeResponse.data).toStrictEqual(lowercasedAirnodeResponse.data);
70+
});

packages/signed-api/src/handlers.ts

+17-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { go } from '@api3/promise-utils';
2-
import { isEmpty, isNil, omit, pick } from 'lodash';
1+
import { go, goSync } from '@api3/promise-utils';
2+
import { isEmpty, omit, pick } from 'lodash';
33

44
import { getConfig } from './config/config';
55
import { loadEnv } from './env';
@@ -19,9 +19,16 @@ const env = loadEnv();
1919
// important for the delayed endpoint which may not be allowed to return the fresh data yet.
2020
export const batchInsertData = async (
2121
authorizationHeader: string | undefined,
22-
requestBody: unknown,
23-
airnodeAddress: string
22+
rawRequestBody: unknown,
23+
rawAirnodeAddress: string
2424
): Promise<ApiResponse> => {
25+
// Make sure the Airnode address is valid.
26+
const goAirnodeAddresses = goSync(() => evmAddressSchema.parse(rawAirnodeAddress));
27+
if (!goAirnodeAddresses.success) {
28+
return generateErrorResponse(400, 'Invalid request, airnode address must be an EVM address');
29+
}
30+
const airnodeAddress = goAirnodeAddresses.data;
31+
2532
// Ensure that the batch of signed that comes from a whitelisted Airnode.
2633
const { endpoints, allowedAirnodes } = getConfig();
2734
if (allowedAirnodes !== '*') {
@@ -40,7 +47,7 @@ export const batchInsertData = async (
4047
}
4148
}
4249

43-
const goValidateSchema = await go(async () => batchSignedDataSchema.parseAsync(requestBody));
50+
const goValidateSchema = await go(async () => batchSignedDataSchema.parseAsync(rawRequestBody));
4451
if (!goValidateSchema.success) {
4552
return generateErrorResponse(400, 'Invalid request, body must fit schema for batch of signed data', {
4653
detail: goValidateSchema.error.message,
@@ -135,14 +142,14 @@ export const batchInsertData = async (
135142
export const getData = async (
136143
endpoint: Endpoint,
137144
authorizationHeader: string | undefined,
138-
airnodeAddress: string
145+
rawAirnodeAddress: string
139146
): Promise<ApiResponse> => {
140-
if (isNil(airnodeAddress)) return generateErrorResponse(400, 'Invalid request, airnode address is missing');
141-
142-
const goValidateSchema = await go(async () => evmAddressSchema.parseAsync(airnodeAddress));
143-
if (!goValidateSchema.success) {
147+
// Make sure the Airnode address is valid.
148+
const goAirnodeAddresses = goSync(() => evmAddressSchema.parse(rawAirnodeAddress));
149+
if (!goAirnodeAddresses.success) {
144150
return generateErrorResponse(400, 'Invalid request, airnode address must be an EVM address');
145151
}
152+
const airnodeAddress = goAirnodeAddresses.data;
146153

147154
const { delaySeconds, authTokens } = endpoint;
148155
const authToken = extractBearerToken(authorizationHeader);

packages/signed-api/src/schema.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
import { type LogFormat, logFormatOptions, logLevelOptions, type LogLevel } from '@api3/commons';
2+
import { goSync } from '@api3/promise-utils';
3+
import { ethers } from 'ethers';
24
import { uniqBy } from 'lodash';
35
import { z } from 'zod';
46

57
import packageJson from '../package.json';
68

7-
export const evmAddressSchema = z.string().regex(/^0x[\dA-Fa-f]{40}$/, 'Must be a valid EVM address');
9+
export const evmAddressSchema = z.string().transform((val, ctx) => {
10+
const goChecksumAddress = goSync(() => ethers.utils.getAddress(val));
11+
if (!goChecksumAddress.success) {
12+
ctx.addIssue({
13+
code: z.ZodIssueCode.custom,
14+
message: 'Invalid EVM address',
15+
path: [],
16+
});
17+
return '';
18+
}
19+
return goChecksumAddress.data;
20+
});
821

922
export const evmIdSchema = z.string().regex(/^0x[\dA-Fa-f]{64}$/, 'Must be a valid EVM ID');
1023

0 commit comments

Comments
 (0)