Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ink! v5 #5791

Merged
merged 28 commits into from
Mar 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
db3822d
adds definitions and types according to ink v5 changes
peetzweg Feb 1, 2024
9f93a19
adds toV5 boilerplate code draft
peetzweg Feb 1, 2024
0cf749e
adds v5 flipper test contract code
peetzweg Feb 1, 2024
cfba268
fix license dates
peetzweg Feb 1, 2024
7d404f4
adds test v5 toLatest test
peetzweg Feb 1, 2024
1f9e6f5
implements new scheme to determine event
peetzweg Feb 1, 2024
985365f
apply linter changes
peetzweg Feb 1, 2024
5db72af
adds test result outputs
peetzweg Feb 1, 2024
cc946da
change `EventRecord['topics'][0]` type to plain `Hash`
peetzweg Feb 16, 2024
54fd609
adds testcases for decoding payload data of a ink!v4 and ink!v5 event
peetzweg Feb 23, 2024
013ea59
changes `Abi.decodeEvent(data:Bytes)` method interface to `Abi.decode…
peetzweg Feb 26, 2024
a028c55
draft implementation with version metadata
peetzweg Feb 26, 2024
3e0d8d1
cleaner implementation of versioned Metadata by actually leveraging t…
peetzweg Feb 27, 2024
ada84b3
Merge branch 'polkadot-js:master' into pz/ink-v5
peetzweg Feb 27, 2024
0029e18
trying to make linter happy
peetzweg Feb 27, 2024
f000f24
Merge branch 'pz/ink-v5' of github.com:peetzweg/pjs-api into pz/ink-v5
peetzweg Feb 27, 2024
0124e14
makes `ContractMetadataSupported` in internal to `Abi` type and not e…
peetzweg Feb 27, 2024
fcb684d
properly types unused parameter for tsc :shrug:
peetzweg Feb 27, 2024
64feb63
adds `@polkadot/types-support` dev dependency
peetzweg Feb 27, 2024
df9956c
merge master
peetzweg Feb 27, 2024
f1a1b9d
Update yarn.lock
peetzweg Feb 27, 2024
1cda4b9
references `types-support` in `api-contract
peetzweg Feb 27, 2024
246570d
resolving change requests
peetzweg Feb 28, 2024
f88fcf7
resolves linter warnings
peetzweg Feb 28, 2024
f67a88a
changes ContractMetadataV5 field to `u64` from `Text`
peetzweg Feb 28, 2024
27d6b46
adds contracts and contract metadata compiled with the most recent in…
peetzweg Feb 28, 2024
0e039e7
implements decoding of anonymous events if possible
peetzweg Mar 1, 2024
3b48940
removes done todo comments
peetzweg Mar 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
peetzweg marked this conversation as resolved.
Show resolved Hide resolved

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 => {
peetzweg marked this conversation as resolved.
Show resolved Hide resolved
// 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');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other case to consider is if a cross contract call occurs, which in turn raises an event. Since the event is raised from another contract we don't have the metadata here...so we might not want to raise an error and just give back the raw bytes instead of attempting to decode?

This scenario might also lead to a false positive in the heuristic for above for determining anon events, if the foreign event has the same number of topics 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh okay, so if contract A calls a function on B which emits an event, the event will be emitted by A and not B?

Feels like it makes sense as users might not know anything about B so can only listen to A.

As of now I have to little knowledge on what's happening above this little 'Abi.decodeEvent world' to have an idea how to handle this the best, yet.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh okay, so if contract A calls a function on B which emits an event, the event will be emitted by A and not B?

It depends, using forward_call (contract A calling contract B), the account id topic will be for contract B so would be easy to distinguish

The other case is using delegate_call where the code of contract B is invoked from contract A, in this case the account id topic would be of contract A.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah okay! Will tackle this in a follow up feature pr! 🚀

};

#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
Loading