Skip to content

Commit

Permalink
fix: 🐛 deterministic signature order for anonymous ABI decoding
Browse files Browse the repository at this point in the history
Added deterministic order to anonymous signature to avoid inconsisencies
when decoding ABIs with them. Added mechanism to check the expected
input size of a function against the size of the data in a transaction
to find the best matching ABI instead of blindly attempting to decode in
a less predictable manner.
  • Loading branch information
ziegfried committed Feb 14, 2020
1 parent bbf2e49 commit 8f0c380
Show file tree
Hide file tree
Showing 9 changed files with 465 additions and 44 deletions.
4 changes: 3 additions & 1 deletion scripts/buildsigs.ts
@@ -1,8 +1,9 @@
import { debug as createDebug } from 'debug';
import { createWriteStream, readFile } from 'fs-extra';
import { createGzip } from 'zlib';
import { computeSignatureHash, validateSignature, parseSignature } from '../src/abi/signature';
import { getInputSize } from '../src/abi/decode';
import { AbiItemDefinition } from '../src/abi/item';
import { computeSignatureHash, parseSignature, validateSignature } from '../src/abi/signature';

const debug = createDebug('buildsigs');
debug.enabled = true;
Expand All @@ -25,6 +26,7 @@ async function buildSignatureFile(sourceFile: string, destFile: string, type: 'f
for (const sig of fns) {
try {
validateSignature(sig);
getInputSize(parseSignature(sig, 'function'));
} catch (e) {
debug('Ignoring invalid function signature %o (%s)', sig, e.message);
continue;
Expand Down
86 changes: 81 additions & 5 deletions src/abi/datatypes.ts
Expand Up @@ -12,7 +12,7 @@ export const checkDynamicArrayType = (typeStr: string): [true, string] | [false,

export const checkFixedSizeArrayType = (typeStr: string): [true, string, number] | [false, undefined, undefined] => {
if (typeStr.endsWith(']')) {
const start = typeStr.indexOf('[');
const start = typeStr.lastIndexOf('[');
const size = parseInt(typeStr.slice(start + 1, -1), 10);
if (start > -1 && !isNaN(size)) {
return [true, typeStr.slice(0, start), size];
Expand All @@ -21,17 +21,17 @@ export const checkFixedSizeArrayType = (typeStr: string): [true, string, number]
return [false, undefined, undefined];
};

export function isValidAbiType(typeStr: string): typeStr is AbiType {
export function isValidAbiType(typeStr: string, isArrayType: boolean = false): typeStr is AbiType {
if (!typeStr) {
return false;
}
const [isDynamicArray, dynamicArrayBaseType] = checkDynamicArrayType(typeStr);
if (isDynamicArray) {
return isValidAbiType(dynamicArrayBaseType!);
return isValidAbiType(dynamicArrayBaseType!, true);
}
const [isFixedSizeArray, fixedSizedArrayType, size] = checkFixedSizeArrayType(typeStr);
if (isFixedSizeArray) {
return isValidAbiType(fixedSizedArrayType!) && size! > 0;
return isValidAbiType(fixedSizedArrayType!, true) && size! > 0;
}
switch (typeStr) {
case 'bool':
Expand All @@ -41,10 +41,15 @@ export function isValidAbiType(typeStr: string): typeStr is AbiType {
case 'fixed':
case 'ufixed':
case 'function':
return true;
case 'bytes':
case 'string':
return true;
return !isArrayType;
default:
if (typeStr.startsWith('bytes')) {
const bytes = +typeStr.slice('bytes'.length);
return !isNaN(bytes) && bytes > 0 && bytes <= 32;
}
for (const intType of ['int', 'uint']) {
if (typeStr.startsWith(intType)) {
const bits = intBits(typeStr, intType as 'int' | 'uint');
Expand Down Expand Up @@ -76,3 +81,74 @@ export function elementType(type: AbiType): AbiType {
}
throw new Error(`Invalid array type: ${type}`);
}

export interface DataSize {
length: number;
/** `false` if a variable-size data type. `length` is the minimum size in this case */
exact: boolean;
}

export function getDataSize(typeStr: AbiType, isArrayType: boolean = false): DataSize {
if (!typeStr) {
throw new Error(`Invalid ABI data type: ${typeStr}`);
}
const [isDynamicArray, dynamicArrayBaseType] = checkDynamicArrayType(typeStr);
if (isDynamicArray) {
getDataSize(dynamicArrayBaseType as AbiType, true);
return {
length: 64,
exact: false,
};
}
const [isFixedSizeArray, fixedSizedArrayType, size] = checkFixedSizeArrayType(typeStr);
if (isFixedSizeArray) {
const { length: elementSize, exact } = getDataSize(fixedSizedArrayType as AbiType, true);
return { length: size! * elementSize, exact };
}
switch (typeStr) {
case 'bool':
case 'address':
case 'int':
case 'uint':
case 'fixed':
case 'ufixed':
case 'function':
return { length: 32, exact: true };
case 'bytes':
case 'string':
if (isArrayType) {
throw new Error(`Type ${typeStr} cannot be in an array`);
}
return { length: 64, exact: false };
default:
if (typeStr.startsWith('bytes')) {
const bytes = +typeStr.slice('bytes'.length);
if (!(!isNaN(bytes) && bytes > 0 && bytes <= 32)) {
throw new Error(`Invalid ABI data type: ${typeStr}`);
} else {
return { length: 32, exact: true };
}
}
for (const intType of ['int', 'uint']) {
if (typeStr.startsWith(intType)) {
const bits = intBits(typeStr, intType as 'int' | 'uint');
if (!(!isNaN(bits) && bits > 0 && bits <= 256 && bits % 8 === 0)) {
throw new Error(`Invalid ABI data type: ${typeStr}`);
} else {
return { length: 32, exact: true };
}
}
}
for (const fixedType of ['fixed', 'ufixed']) {
if (typeStr.startsWith(fixedType)) {
const [m, n] = fixedBits(typeStr, fixedType as 'fixed' | 'ufixed');
if (!(!isNaN(m) && !isNaN(n) && m >= 8 && m <= 256 && m % 8 === 0 && n >= 0 && n <= 80)) {
throw new Error(`Invalid ABI data type: ${typeStr}`);
} else {
return { length: 32, exact: true };
}
}
}
throw new Error(`Invalid ABI data type: ${typeStr}`);
}
}
103 changes: 102 additions & 1 deletion src/abi/decode.ts
@@ -1,8 +1,12 @@
import { AbiCoder } from 'web3-eth-abi';
import { toChecksumAddress } from 'web3-utils';
import { parseBigInt } from '../utils/bn';
import { createModuleDebug, TRACE_ENABLED } from '../utils/debug';
import { DataSize, elementType, getDataSize, intBits, isArrayType } from './datatypes';
import { AbiItemDefinition } from './item';
import { elementType, intBits, isArrayType } from './datatypes';
import { computeSignature } from './signature';

const { trace } = createModuleDebug('abi:decode');

export type ScalarValue = string | number | boolean;
export type Value = ScalarValue | ScalarValue[];
Expand All @@ -27,6 +31,7 @@ export interface DecodedLogEvent {
args?: { [name: string]: Value };
}

/** Translates decoded value (by abicoder) to the form we want to emit to the output */
export function parseParameterValue(value: string | number | boolean, type: string): ScalarValue {
if (type === 'bool') {
if (typeof value === 'boolean') {
Expand Down Expand Up @@ -67,6 +72,19 @@ export function parseParameterValue(value: string | number | boolean, type: stri
return value;
}

export function getInputSize(abi: AbiItemDefinition): DataSize {
try {
return abi.inputs
.map(input => getDataSize(input.type))
.reduce((total, cur) => ({ length: total.length + cur.length, exact: total.exact && cur.exact }), {
length: 0,
exact: true,
});
} catch (e) {
throw new Error(`Failed to determine input size for ${computeSignature(abi)}: ${e.message}`);
}
}

export function decodeFunctionCall(
data: string,
abi: AbiItemDefinition,
Expand Down Expand Up @@ -104,6 +122,66 @@ export function decodeFunctionCall(
};
}

export function decodeBestMatchingFunctionCall(
data: string,
abis: AbiItemDefinition[],
abiCoder: AbiCoder,
anonymous: boolean
): DecodedFunctionCall {
if (abis.length === 1) {
// short-circut most common case
return decodeFunctionCall(data, abis[0], computeSignature(abis[0]), abiCoder, anonymous);
}
const abisWithSize = abis.map(abi => [abi, getInputSize(abi)] as const);
const dataLength = (data.length - 10) / 2;
let lastError: Error | undefined;
// Attempt to find function signature with exact match of input data length
for (const [abi, { length, exact }] of abisWithSize) {
if (dataLength === length && exact) {
try {
return decodeFunctionCall(data, abi, computeSignature(abi), abiCoder, anonymous);
} catch (e) {
lastError = e;
if (TRACE_ENABLED) {
trace(
'Failed to decode function call using signature %s with exact size match of %d bytes',
computeSignature(abi),
length
);
}
}
}
}
// Consider dynamaic data types
for (const [abi, { length, exact }] of abisWithSize) {
if (dataLength >= length && !exact) {
try {
return decodeFunctionCall(data, abi, computeSignature(abi), abiCoder, anonymous);
} catch (e) {
lastError = e;
if (TRACE_ENABLED) {
trace(
'Failed to decode function call using signature %s with size match of %d bytes (min size %d bytes)',
computeSignature(abi),
dataLength,
length
);
}
}
}
}
// Brute-force try all ABI signatures, use the first one that doesn't throw on decode
for (const abi of abis) {
try {
return decodeFunctionCall(data, abi, computeSignature(abi), abiCoder, anonymous);
} catch (e) {
lastError = e;
}
}

throw lastError ?? new Error('Unable to decode');
}

export function decodeLogEvent(
data: string,
topics: string[],
Expand Down Expand Up @@ -152,3 +230,26 @@ export function decodeLogEvent(
args: anonymous ? undefined : args,
};
}

export function decodeBestMatchingLogEvent(
data: string,
topics: string[],
abis: AbiItemDefinition[],
abiCoder: AbiCoder,
anonymous: boolean
): DecodedFunctionCall {
// No need to prioritize and check event logs for hash collisions since with the longer hash
// collisions are very unlikely
let lastError: Error | undefined;
for (const abi of abis) {
try {
return decodeLogEvent(data, topics, abi, computeSignature(abi), abiCoder, anonymous);
} catch (e) {
lastError = e;
if (TRACE_ENABLED) {
trace('Failed to decode log event', e);
}
}
}
throw lastError ?? new Error('Unable to decode log event');
}

0 comments on commit 8f0c380

Please sign in to comment.