Skip to content

Commit

Permalink
feat: 🎸 ABI decoding of tuple arrays
Browse files Browse the repository at this point in the history
This change adds proper support for decoding tuples and structs in
function and event arguments as well as the computation of signatures and
signature hashes for functions/events having types in their parameter
list. Also includes some other minor cleanup.
  • Loading branch information
ziegfried committed Mar 30, 2021
1 parent 479b539 commit cbf3ecf
Show file tree
Hide file tree
Showing 11 changed files with 248 additions and 82 deletions.
2 changes: 1 addition & 1 deletion src/abi/contract.ts
Expand Up @@ -9,7 +9,7 @@ const { debug, trace } = createModuleDebug('abi:contract');
export interface ContractInfo {
/** True if the corresponding account is a smart contract, otherwise false */
isContract: boolean;
/** A unqiue representation of all function and event signatures of a contract */
/** A unique representation of all function and event signatures of a contract */
fingerprint?: string;
/** Name of the contract from configured ABIs */
contractName?: string;
Expand Down
19 changes: 19 additions & 0 deletions src/abi/datatypes.ts
@@ -1,7 +1,11 @@
import { RuntimeError } from '../utils/error';
import { AbiInput } from './item';
import { getDataSize as wasmGetDataSize, isArrayType as wasmIsArrayType, isValidDataType } from './wasm';

export type AbiType = string;

export class DataTypeError extends RuntimeError {}

export function isValidAbiType(typeStr: string): typeStr is AbiType {
if (!typeStr) {
return false;
Expand All @@ -22,3 +26,18 @@ export interface DataSize {
export function getDataSize(typeStr: AbiType): DataSize {
return wasmGetDataSize(typeStr) as any;
}

export const isTupleArray = (inputDef: AbiInput) => inputDef.type === 'tuple[]';
export const isTuple = (inputDef: AbiInput) => inputDef.type === 'tuple' || isTupleArray(inputDef);

export function encodeInputType(inputDef: AbiInput): string {
if (isTuple(inputDef)) {
if (!inputDef.components?.length) {
throw new DataTypeError('Unable to encode tuple datatype without components');
}
const serializedComponentTypes = inputDef.components.map(c => encodeInputType(c)).join(',');
const serializedType = `(${serializedComponentTypes})`;
return isTupleArray(inputDef) ? `${serializedType}[]` : serializedType;
}
return inputDef.type;
}
43 changes: 19 additions & 24 deletions src/abi/decode.ts
@@ -1,11 +1,14 @@
import { createModuleDebug, TRACE_ENABLED } from '../utils/debug';
import { DataSize, getDataSize, isArrayType } from './datatypes';
import { AbiItemDefinition, AbiInput } from './item';
import { computeSignature, encodeParam } from './signature';
import { RuntimeError } from '../utils/error';
import { DataSize, encodeInputType, getDataSize, isArrayType, isTuple, isTupleArray } from './datatypes';
import { AbiInput, AbiItemDefinition } from './item';
import { computeSignature } from './signature';
import { abiDecodeParameters, Value } from './wasm';

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

export class DecodeError extends RuntimeError {}

export interface DecodedParameter {
name?: string;
type: string;
Expand All @@ -29,34 +32,26 @@ export interface DecodedLogEvent {
export function getInputSize(abi: AbiItemDefinition): DataSize {
try {
return abi.inputs
.map(input => getDataSize(encodeParam(input)))
.map(input => getDataSize(encodeInputType(input)))
.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}`);
throw new DecodeError(`Failed to determine input size for ${computeSignature(abi)}: ${e.message}`);
}
}

export const isTuple = (inputDef: AbiInput): boolean => inputDef.type === 'tuple';

export const encodeInputType = (inputDef: AbiInput): string =>
// eslint-disable-next-line @typescript-eslint/no-use-before-define
isTuple(inputDef) ? encodeTupleInputType(inputDef) : inputDef.type;

export function encodeTupleInputType(itemDef: AbiInput): string {
if (!itemDef.components?.length) {
// invalid tuple definition without component types
return 'tuple';
}
const serializedComponentTypes = itemDef.components.map(c => encodeInputType(c)).join(',');
return `(${serializedComponentTypes})`;
}

export type DecodedStruct = { [k: string]: Value | DecodedStruct };
export type DecodedStruct = { [k: string]: Value | DecodedStruct } | DecodedStruct[];

export function reconcileStructFromDecodedTuple(decodedTuple: Value[], inputDef: AbiInput): DecodedStruct | null {
if (isTupleArray(inputDef) && Array.isArray(decodedTuple)) {
const arrayElementInputDef = { ...inputDef, type: 'tuple' };
return decodedTuple
.filter(v => Array.isArray(v))
.map(v => reconcileStructFromDecodedTuple(v as Value[], arrayElementInputDef))
.filter(v => v != null) as DecodedStruct;
}
if (!inputDef.components?.length || !Array.isArray(decodedTuple)) {
return null;
}
Expand Down Expand Up @@ -187,7 +182,7 @@ export function decodeBestMatchingFunctionCall(
}
}

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

export function decodeLogEvent(
Expand Down Expand Up @@ -217,7 +212,7 @@ export function decodeLogEvent(
} else {
const rawValue = topics[topicIndex++];
if (rawValue == null) {
throw new Error(
throw new DecodeError(
`Expected data in topic index=${topicIndex - 1}, but topics length is ${topics.length}`
);
}
Expand Down Expand Up @@ -270,5 +265,5 @@ export function decodeBestMatchingLogEvent(
}
}
}
throw lastError ?? new Error('Unable to decode log event');
throw lastError ?? new DecodeError('Unable to decode log event');
}
7 changes: 5 additions & 2 deletions src/abi/files.ts
Expand Up @@ -8,9 +8,12 @@ import { computeContractFingerprint } from './contract';
import { computeSignature } from './signature';
import { createGunzip } from 'zlib';
import BufferList from 'bl';
import { RuntimeError } from '../utils/error';

const { debug, warn, trace } = createModuleDebug('abi:files');

export class AbiError extends RuntimeError {}

interface TruffleBuild {
contractName: string;
abi: AbiItem[];
Expand Down Expand Up @@ -45,7 +48,7 @@ export function extractDeployedContractAddresses(truffleBuild: TruffleBuild): Ad
export async function* searchAbiFiles(dir: string, config: AbiRepositoryConfig): AsyncIterable<string> {
debug('Searching for ABI files in %s', dir);
const dirContents = await readdir(dir).catch(e =>
Promise.reject(new Error(`Failed to load ABIs from directory ${dir}: ${e}`))
Promise.reject(new AbiError(`Failed to load ABIs from directory ${dir}: ${e}`))
);
dirContents.sort();
const subdirs = [];
Expand Down Expand Up @@ -116,7 +119,7 @@ export function parseAbiFileContents(
abis = abiData;
contractName = basename(fileName).split('.', 1)[0];
} else {
throw new Error(`Invalid contents of ABI file ${fileName}`);
throw new AbiError(`Invalid contents of ABI file ${fileName}`);
}

const entries = abis
Expand Down
34 changes: 14 additions & 20 deletions src/abi/signature.ts
@@ -1,29 +1,23 @@
import { isValidAbiType } from './datatypes';
import { RuntimeError } from '../utils/error';
import { encodeInputType, isValidAbiType } from './datatypes';
import { AbiInput, AbiItemDefinition } from './item';
import { parseEventSignature, parseFunctionSignature, sha3 } from './wasm';

const err = (msg: string): never => {
throw new Error(msg);
};

export const encodeParam = (input: AbiInput): string =>
input.type === 'tuple'
? `(${input.components?.map(encodeParam) ?? err('Failed to encode tuple without components')})`
: input.type;
class InvalidSignatureError extends RuntimeError {}

export function computeSignature(abi: AbiItemDefinition) {
if (abi.name == null) {
throw new Error('Cannot add ABI item without name');
throw new InvalidSignatureError('Cannot serialize ABI definition without name');
}
return `${abi.name}(${(abi.inputs ?? []).map(encodeParam).join(',')})`;
return `${abi.name}(${(abi.inputs ?? []).map(encodeInputType).join(',')})`;
}

export const encodeParamWithIndexedFlag = (input: AbiInput): string =>
input.indexed ? `${encodeParam(input)} indexed` : encodeParam(input);
input.indexed ? `${encodeInputType(input)} indexed` : encodeInputType(input);

export function serializeEventSignature(abi: AbiItemDefinition): string {
if (abi.name == null) {
throw new Error('Cannot add ABI item without name');
throw new InvalidSignatureError('Cannot serialize ABI event definition without name');
}
return `${abi.name}(${(abi.inputs ?? []).map(encodeParamWithIndexedFlag).join(',')})`;
}
Expand All @@ -35,21 +29,21 @@ export function parseSignature(signature: string, type: 'function' | 'event'): A
export function computeSignatureHash(signature: string, type: 'event' | 'function'): string {
const hash = sha3(signature);
if (hash == null) {
throw new Error(`NULL signature hash for signature ${signature}`);
throw new InvalidSignatureError(`NULL signature hash for signature ${signature}`);
}
return type === 'event' ? hash.slice(2) : hash.slice(2, 10);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function validateSignature(signature: string, type: 'event' | 'function') {
const parsed = parseSignature(signature, 'event');
const parsed = parseSignature(signature, type);
for (const input of parsed.inputs) {
if (!isValidAbiType(input.type)) {
throw new Error(`Invalid data type: ${input.type}`);
throw new InvalidSignatureError(`Invalid data type: ${input.type}`);
}
}
computeSignature(parsed);
// if (serialized !== signature) {
// throw new Error(`Serialized signature does not match original`);
// }
const serialized = computeSignature(parsed);
if (type !== 'event' && serialized !== signature) {
throw new InvalidSignatureError(`Serialized function signature does not match original`);
}
}
7 changes: 2 additions & 5 deletions src/abi/wasm.ts
Expand Up @@ -9,17 +9,14 @@ import {
to_checksum_address,
} from '../../wasm/ethabi/pkg';
import { memory } from '../../wasm/ethabi/pkg/ethlogger_ethabi_bg';
import { RuntimeError } from '../utils/error';
import { AbiType } from './datatypes';
import { AbiItem } from './item';

export type ScalarValue = string | number | boolean;
export type Value = ScalarValue | ScalarValue[];

class EthAbiError extends Error {
constructor(msg: string) {
super(msg);
}
}
class EthAbiError extends RuntimeError {}

function unwrapJsResult<T>(result: any): T {
if (result.t === 'Ok') {
Expand Down
10 changes: 10 additions & 0 deletions src/utils/error.ts
@@ -0,0 +1,10 @@
export class RuntimeError extends Error {
public readonly cause?: Error;
constructor(msg: string, cause?: Error) {
super(msg);
this.name = this.constructor.name;
this.message = msg;
this.cause = cause;
Error.captureStackTrace(this, this.constructor);
}
}
8 changes: 4 additions & 4 deletions src/utils/obj.ts
Expand Up @@ -4,14 +4,14 @@ export function removeEmtpyValues<R extends { [k: string]: any }, I extends { [P
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null)) as R;
}

export function prefixKeys<T extends { [k: string]: any }>(obj: T, prefix?: string | null, removeEmtpy?: boolean): T {
export function prefixKeys<T extends { [k: string]: any }>(obj: T, prefix?: string | null, removeEmpty?: boolean): T {
if (prefix == null || prefix === '') {
return removeEmtpy ? removeEmtpyValues(obj) : obj;
return removeEmpty ? removeEmtpyValues(obj) : obj;
}
const entries = Object.entries(obj);
return Object.fromEntries(
(removeEmtpy ? entries.filter(([, v]) => v != null) : entries).map(([k, v]) => [prefix + k, v])
);
(removeEmpty ? entries.filter(([, v]) => v != null) : entries).map(([k, v]) => [prefix + k, v])
) as T;
}

/**
Expand Down
11 changes: 11 additions & 0 deletions test/abi/anonymous-signatures.test.ts
@@ -0,0 +1,11 @@
import { getInputSize } from '../../src/abi/decode';
import { loadSignatureFile } from '../../src/abi/files';

test('getInputSize for all anonymous signatures', async () => {
const sigs = await loadSignatureFile('data/fns.abisigs.gz');
for (const abis of sigs.entries.map(i => i[1])) {
for (const abi of abis) {
expect(() => getInputSize(abi)).not.toThrow();
}
}
});
93 changes: 92 additions & 1 deletion test/abi/datatypes.test.ts
@@ -1,4 +1,4 @@
import { getDataSize, isValidAbiType } from '../../src/abi/datatypes';
import { getDataSize, isValidAbiType, encodeInputType } from '../../src/abi/datatypes';

test('isValidAbiType', () => {
expect(isValidAbiType('foo')).toBe(false);
Expand Down Expand Up @@ -73,3 +73,94 @@ test('getDataSize', () => {
expect(() => getDataSize('string[]')).toThrowErrorMatchingInlineSnapshot(`"Invalid type: string[]"`);
expect(() => getDataSize('bytes[]')).toThrowErrorMatchingInlineSnapshot(`"Invalid type: bytes[]"`);
});

describe('encodeInputType', () => {
it('encodes simple types', () => {
expect(encodeInputType({ type: 'uint256', name: 'foo' })).toMatchInlineSnapshot(`"uint256"`);
});

it('encodes tuple types', () => {
expect(
encodeInputType({
name: 'whatever',
type: 'tuple',
components: [
{ name: 'one', type: 'string' },
{ name: 'two', type: 'uint256' },
],
})
).toMatchInlineSnapshot(`"(string,uint256)"`);
});

it('encodes arrays of typles', () => {
expect(
encodeInputType({
components: [
{
name: 'a',
type: 'string',
},
{
name: 'b',
type: 'string',
},
{
name: 'c',
type: 'string',
},
{
name: 'd',
type: 'string',
},
{
name: 'e',
type: 'string',
},
{
name: 'f',
type: 'string',
},
{
name: 'g',
type: 'string',
},
{
name: 'h',
type: 'uint256',
},
{
name: 'i',
type: 'string',
},
{
name: 'j',
type: 'uint256',
},
{
components: [
{
name: 'x',
type: 'string',
},
{
name: 'y',
type: 'string',
},
{
name: 'z',
type: 'string',
},
],
name: 'k',
type: 'tuple',
},
],
indexed: false,
name: 'details',
type: 'tuple[]',
})
).toMatchInlineSnapshot(
`"(string,string,string,string,string,string,string,uint256,string,uint256,(string,string,string))[]"`
);
});
});

0 comments on commit cbf3ecf

Please sign in to comment.