Skip to content

Commit

Permalink
feat: ink! v5 (#5791)
Browse files Browse the repository at this point in the history
* adds definitions and types according to ink v5 changes

* adds toV5 boilerplate code draft

* adds v5 flipper test contract code

* fix license dates

* adds test v5 toLatest test

* implements new scheme to determine event

* apply linter changes

* adds test result outputs

* change `EventRecord['topics'][0]` type to plain `Hash`

* adds testcases for decoding payload data of a ink!v4 and ink!v5 event

* changes `Abi.decodeEvent(data:Bytes)` method interface to `Abi.decodeEvent(record:EventRecord)` which includes the event and the topic for decoding.

* draft implementation with version metadata

* cleaner implementation of versioned Metadata by actually leveraging the `version` field included since v2 contract metadata

* trying to make linter happy

* makes `ContractMetadataSupported` in internal to `Abi` type and not exposing it externally.

* properly types unused parameter for tsc 🤷

* adds `@polkadot/types-support` dev dependency

* Update yarn.lock

* references `types-support` in `api-contract

* resolving change requests

* resolves linter warnings

* changes ContractMetadataV5 field to `u64` from `Text`

* adds contracts and contract metadata compiled with the most recent ink version

* implements decoding of anonymous events if possible

* removes done todo comments
  • Loading branch information
peetzweg authored Mar 2, 2024
1 parent f9b2d26 commit ca48023
Show file tree
Hide file tree
Showing 39 changed files with 6,245 additions and 93 deletions.
3 changes: 2 additions & 1 deletion packages/api-contract/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
},
"devDependencies": {
"@polkadot/api-augment": "10.11.3",
"@polkadot/keyring": "^12.6.2"
"@polkadot/keyring": "^12.6.2",
"@polkadot/types-support": "10.11.3"
}
}
110 changes: 110 additions & 0 deletions packages/api-contract/src/Abi/Abi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import fs from 'node:fs';
import process from 'node:process';

import { TypeDefInfo } from '@polkadot/types/types';
import rpcMetadata from '@polkadot/types-support/metadata/static-substrate-contracts-node';
import { blake2AsHex } from '@polkadot/util-crypto';

import { Metadata, TypeRegistry } from '../../../types/src/bundle.js';
import abis from '../test/contracts/index.js';
import { Abi } from './index.js';

Expand Down Expand Up @@ -122,4 +124,112 @@ describe('Abi', (): void => {
// the hash as per the actual Abi
expect(bundle.source.hash).toEqual(abi.info.source.wasmHash.toHex());
});

describe('Events', (): void => {
const registry = new TypeRegistry();

beforeAll((): void => {
const metadata = new Metadata(registry, rpcMetadata);

registry.setMetadata(metadata);
});

it('decoding <=ink!v4 event', (): void => {
const abiJson = abis['ink_v4_erc20Metadata'];

expect(abiJson).toBeDefined();
const abi = new Abi(abiJson);

const eventRecordHex =
'0x0001000000080360951b8baf569bca905a279c12d6ce17db7cdce23a42563870ef585129ce5dc64d010001d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d018eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4800505a4f7e9f4eb106000000000000000c0045726332303a3a5472616e7366657200000000000000000000000000000000da2d695d3b5a304e0039e7fc4419c34fa0c1f239189c99bb72a6484f1634782b2b00c7d40fe6d84d660f3e6bed90f218e022a0909f7e1a7ea35ada8b6e003564';
const record = registry.createType('EventRecord', eventRecordHex);

const decodedEvent = abi.decodeEvent(record);

expect(decodedEvent.event.args.length).toEqual(3);
expect(decodedEvent.args.length).toEqual(3);
expect(decodedEvent.event.identifier).toEqual('Transfer');

const decodedEventHuman = decodedEvent.event.args.reduce((prev, cur, index) => {
return {
...prev,
[cur.name]: decodedEvent.args[index].toHuman()
};
}, {});

const expectedEvent = {
from: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
to: '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty',
value: '123.4567 MUnit'
};

expect(decodedEventHuman).toEqual(expectedEvent);
});

it('decoding >=ink!v5 event', (): void => {
const abiJson = abis['ink_v5_erc20Metadata'];

expect(abiJson).toBeDefined();
const abi = new Abi(abiJson);

const eventRecordHex =
'0x00010000000803da17150e96b3955a4db6ad35ddeb495f722f9c1d84683113bfb096bf3faa30f2490101d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d018eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4800505a4f7e9f4eb106000000000000000cb5b61a3e6a21a16be4f044b517c28ac692492f73c5bfd3f60178ad98c767f4cbd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48';
const record = registry.createType('EventRecord', eventRecordHex);

const decodedEvent = abi.decodeEvent(record);

expect(decodedEvent.event.args.length).toEqual(3);
expect(decodedEvent.args.length).toEqual(3);
expect(decodedEvent.event.identifier).toEqual('erc20::erc20::Transfer');

const decodedEventHuman = decodedEvent.event.args.reduce((prev, cur, index) => {
return {
...prev,
[cur.name]: decodedEvent.args[index].toHuman()
};
}, {});

const expectedEvent = {
from: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
to: '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty',
value: '123.4567 MUnit'
};

expect(decodedEventHuman).toEqual(expectedEvent);
});

it('decoding >=ink!v5 anonymous event', (): void => {
const abiJson = abis['ink_v5_erc20AnonymousTransferMetadata'];

expect(abiJson).toBeDefined();
const abi = new Abi(abiJson);

expect(abi.events[0].identifier).toEqual('erc20::erc20::Transfer');
expect(abi.events[0].signatureTopic).toEqual(null);

const eventRecordWithAnonymousEventHex = '0x00010000000803538e726248a9c155911e7d99f4f474c3408630a2f6275dd501d4471c7067ad2c490101d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d018eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4800505a4f7e9f4eb1060000000000000008d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48';
const record = registry.createType('EventRecord', eventRecordWithAnonymousEventHex);

const decodedEvent = abi.decodeEvent(record);

expect(decodedEvent.event.args.length).toEqual(3);
expect(decodedEvent.args.length).toEqual(3);
expect(decodedEvent.event.identifier).toEqual('erc20::erc20::Transfer');

const decodedEventHuman = decodedEvent.event.args.reduce((prev, cur, index) => {
return {
...prev,
[cur.name]: decodedEvent.args[index].toHuman()
};
}, {});

const expectedEvent = {
from: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
to: '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty',
value: '123.4567 MUnit'
};

expect(decodedEventHuman).toEqual(expectedEvent);
});
});
});
142 changes: 120 additions & 22 deletions packages/api-contract/src/Abi/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
// Copyright 2017-2024 @polkadot/api-contract authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { Bytes } from '@polkadot/types';
import type { ChainProperties, ContractConstructorSpecLatest, ContractEventSpecLatest, ContractMessageParamSpecLatest, ContractMessageSpecLatest, ContractMetadata, ContractMetadataLatest, ContractProjectInfo, ContractTypeSpec } from '@polkadot/types/interfaces';
import type { Bytes, Vec } from '@polkadot/types';
import type { ChainProperties, ContractConstructorSpecLatest, ContractEventParamSpecLatest, ContractMessageParamSpecLatest, ContractMessageSpecLatest, ContractMetadata, ContractMetadataV4, ContractMetadataV5, ContractProjectInfo, ContractTypeSpec, EventRecord } from '@polkadot/types/interfaces';
import type { Codec, Registry, TypeDef } from '@polkadot/types/types';
import type { AbiConstructor, AbiEvent, AbiMessage, AbiParam, DecodedEvent, DecodedMessage } from '../types.js';
import type { AbiConstructor, AbiEvent, AbiEventParam, AbiMessage, AbiMessageParam, AbiParam, DecodedEvent, DecodedMessage } from '../types.js';

import { Option, TypeRegistry } from '@polkadot/types';
import { TypeDefInfo } from '@polkadot/types-create';
import { assertReturn, compactAddLength, compactStripLength, isBn, isNumber, isObject, isString, isUndefined, logger, stringCamelCase, stringify, u8aConcat, u8aToHex } from '@polkadot/util';

import { convertVersions, enumVersions } from './toLatest.js';
import { convertVersions, enumVersions } from './toLatestCompatible.js';

interface AbiJson {
version?: string;

[key: string]: unknown;
}

type EventOf<M> = M extends {spec: { events: Vec<infer E>}} ? E : never
export type ContractMetadataSupported = ContractMetadataV4 | ContractMetadataV5;
type ContractEventSupported = EventOf<ContractMetadataSupported>;

const l = logger('Abi');

const PRIMITIVE_ALWAYS = ['AccountId', 'AccountIndex', 'Address', 'Balance'];
Expand All @@ -32,7 +36,7 @@ function findMessage <T extends AbiMessage> (list: T[], messageOrId: T | string
return assertReturn(message, () => `Attempted to call an invalid contract interface, ${stringify(messageOrId)}`);
}

function getLatestMeta (registry: Registry, json: AbiJson): ContractMetadataLatest {
function getMetadata (registry: Registry, json: AbiJson): ContractMetadataSupported {
// this is for V1, V2, V3
const vx = enumVersions.find((v) => isObject(json[v]));

Expand All @@ -50,20 +54,23 @@ function getLatestMeta (registry: Registry, json: AbiJson): ContractMetadataLate
? { [`V${jsonVersion}`]: json }
: { V0: json }
);

const converter = convertVersions.find(([v]) => metadata[`is${v}`]);

if (!converter) {
throw new Error(`Unable to convert ABI with version ${metadata.type} to latest`);
throw new Error(`Unable to convert ABI with version ${metadata.type} to a supported version`);
}

return converter[1](registry, metadata[`as${converter[0]}`]);
const upgradedMetadata = converter[1](registry, metadata[`as${converter[0]}`]);

return upgradedMetadata;
}

function parseJson (json: Record<string, unknown>, chainProperties?: ChainProperties): [Record<string, unknown>, Registry, ContractMetadataLatest, ContractProjectInfo] {
function parseJson (json: Record<string, unknown>, chainProperties?: ChainProperties): [Record<string, unknown>, Registry, ContractMetadataSupported, ContractProjectInfo] {
const registry = new TypeRegistry();
const info = registry.createType('ContractProjectInfo', json) as unknown as ContractProjectInfo;
const latest = getLatestMeta(registry, json as unknown as AbiJson);
const lookup = registry.createType('PortableRegistry', { types: latest.types }, true);
const metadata = getMetadata(registry, json as unknown as AbiJson);
const lookup = registry.createType('PortableRegistry', { types: metadata.types }, true);

// attach the lookup to the registry - now the types are known
registry.setLookup(lookup);
Expand All @@ -77,7 +84,7 @@ function parseJson (json: Record<string, unknown>, chainProperties?: ChainProper
lookup.getTypeDef(id)
);

return [json, registry, latest, info];
return [json, registry, metadata, info];
}

/**
Expand All @@ -102,7 +109,7 @@ export class Abi {
readonly info: ContractProjectInfo;
readonly json: Record<string, unknown>;
readonly messages: AbiMessage[];
readonly metadata: ContractMetadataLatest;
readonly metadata: ContractMetadataSupported;
readonly registry: Registry;
readonly environment = new Map<string, TypeDef | Codec>();

Expand All @@ -123,8 +130,8 @@ export class Abi {
: null
})
);
this.events = this.metadata.spec.events.map((spec: ContractEventSpecLatest, index) =>
this.#createEvent(spec, index)
this.events = this.metadata.spec.events.map((_: ContractEventSupported, index: number) =>
this.#createEvent(index)
);
this.messages = this.metadata.spec.messages.map((spec: ContractMessageSpecLatest, index): AbiMessage =>
this.#createMessage(spec, index, {
Expand Down Expand Up @@ -162,7 +169,59 @@ export class Abi {
/**
* Warning: Unstable API, bound to change
*/
public decodeEvent (data: Bytes | Uint8Array): DecodedEvent {
public decodeEvent (record: EventRecord): DecodedEvent {
switch (this.metadata.version.toString()) {
// earlier version are hoisted to v4
case '4':
return this.#decodeEventV4(record);
// Latest
default:
return this.#decodeEventV5(record);
}
}

#decodeEventV5 = (record: EventRecord): DecodedEvent => {
// Find event by first topic, which potentially is the signature_topic
const signatureTopic = record.topics[0];
const data = record.event.data[1] as Bytes;

if (signatureTopic) {
const event = this.events.find((e) => e.signatureTopic !== undefined && e.signatureTopic !== null && e.signatureTopic === signatureTopic.toHex());

// Early return if event found by signature topic
if (event) {
return event.fromU8a(data);
}
}

// If no event returned yet, it might be anonymous
const amountOfTopics = record.topics.length;
const potentialEvents = this.events.filter((e) => {
// event can't have a signature topic
if (e.signatureTopic !== null && e.signatureTopic !== undefined) {
return false;
}

// event should have same amount of indexed fields as emitted topics
const amountIndexed = e.args.filter((a) => a.indexed).length;

if (amountIndexed !== amountOfTopics) {
return false;
}

// If all conditions met, it's a potential event
return true;
});

if (potentialEvents.length === 1) {
return potentialEvents[0].fromU8a(data);
}

throw new Error('Unable to determine event');
};

#decodeEventV4 = (record: EventRecord): DecodedEvent => {
const data = record.event.data[1] as Bytes;
const index = data[0];
const event = this.events[index];

Expand All @@ -171,7 +230,7 @@ export class Abi {
}

return event.fromU8a(data.subarray(1));
}
};

/**
* Warning: Unstable API, bound to change
Expand All @@ -195,7 +254,7 @@ export class Abi {
return findMessage(this.messages, messageOrId);
}

#createArgs = (args: ContractMessageParamSpecLatest[], spec: unknown): AbiParam[] => {
#createArgs = (args: ContractMessageParamSpecLatest[] | ContractEventParamSpecLatest[], spec: unknown): AbiParam[] => {
return args.map(({ label, type }, index): AbiParam => {
try {
if (!isObject(type)) {
Expand Down Expand Up @@ -233,8 +292,47 @@ export class Abi {
});
};

#createEvent = (spec: ContractEventSpecLatest, index: number): AbiEvent => {
const args = this.#createArgs(spec.args, spec);
#createMessageParams = (args: ContractMessageParamSpecLatest[], spec: unknown): AbiMessageParam[] => {
return this.#createArgs(args, spec);
};

#createEventParams = (args: ContractEventParamSpecLatest[], spec: unknown): AbiEventParam[] => {
const params = this.#createArgs(args, spec);

return params.map((p, index): AbiEventParam => ({ ...p, indexed: args[index].indexed.toPrimitive() }));
};

#createEvent = (index: number): AbiEvent => {
// TODO TypeScript would narrow this type to the correct version,
// but version is `Text` so I need to call `toString()` here,
// which breaks the type inference.
switch (this.metadata.version.toString()) {
case '4':
return this.#createEventV4((this.metadata as ContractMetadataV4).spec.events[index], index);
default:
return this.#createEventV5((this.metadata as ContractMetadataV5).spec.events[index], index);
}
};

#createEventV5 = (spec: EventOf<ContractMetadataV5>, index: number): AbiEvent => {
const args = this.#createEventParams(spec.args, spec);
const event = {
args,
docs: spec.docs.map((d) => d.toString()),
fromU8a: (data: Uint8Array): DecodedEvent => ({
args: this.#decodeArgs(args, data),
event
}),
identifier: [spec.module_path, spec.label].join('::'),
index,
signatureTopic: spec.signature_topic.isSome ? spec.signature_topic.unwrap().toHex() : null
};

return event;
};

#createEventV4 = (spec: EventOf<ContractMetadataV4>, index: number): AbiEvent => {
const args = this.#createEventParams(spec.args, spec);
const event = {
args,
docs: spec.docs.map((d) => d.toString()),
Expand All @@ -250,7 +348,7 @@ export class Abi {
};

#createMessage = (spec: ContractMessageSpecLatest | ContractConstructorSpecLatest, index: number, add: Partial<AbiMessage> = {}): AbiMessage => {
const args = this.#createArgs(spec.args, spec);
const args = this.#createMessageParams(spec.args, spec);
const identifier = spec.label.toString();
const message = {
...add,
Expand All @@ -267,7 +365,7 @@ export class Abi {
path: identifier.split('::').map((s) => stringCamelCase(s)),
selector: spec.selector,
toU8a: (params: unknown[]) =>
this.#encodeArgs(spec, args, params)
this.#encodeMessageArgs(spec, args, params)
};

return message;
Expand Down Expand Up @@ -299,7 +397,7 @@ export class Abi {
return message.fromU8a(trimmed.subarray(4));
};

#encodeArgs = ({ label, selector }: ContractMessageSpecLatest | ContractConstructorSpecLatest, args: AbiParam[], data: unknown[]): Uint8Array => {
#encodeMessageArgs = ({ label, selector }: ContractMessageSpecLatest | ContractConstructorSpecLatest, args: AbiMessageParam[], data: unknown[]): Uint8Array => {
if (data.length !== args.length) {
throw new Error(`Expected ${args.length} arguments to contract message '${label.toString()}', found ${data.length}`);
}
Expand Down
Loading

0 comments on commit ca48023

Please sign in to comment.