// Copyright 2017-2025 @pezkuwi/api-contract authors & contributors // SPDX-License-Identifier: Apache-2.0 import type { Bytes, Vec } from '@pezkuwi/types'; import type { ChainProperties, ContractConstructorSpecLatest, ContractEventParamSpecLatest, ContractMessageParamSpecLatest, ContractMessageSpecLatest, ContractMetadata, ContractMetadataV4, ContractMetadataV5, ContractMetadataV6, ContractProjectInfo, ContractTypeSpec, EventRecord } from '@pezkuwi/types/interfaces'; import type { Codec, Registry, TypeDef } from '@pezkuwi/types/types'; import type { AbiConstructor, AbiEvent, AbiEventParam, AbiMessage, AbiMessageParam, AbiParam, DecodedEvent, DecodedMessage } from '../types.js'; import { Option, TypeRegistry } from '@pezkuwi/types'; import { TypeDefInfo } from '@pezkuwi/types-create'; import { assertReturn, compactAddLength, compactStripLength, isBn, isNumber, isObject, isString, isUndefined, logger, stringCamelCase, stringify, u8aConcat, u8aToHex } from '@pezkuwi/util'; import { convertVersions, enumVersions } from './toLatestCompatible.js'; interface AbiJson { version?: string; [key: string]: unknown; } type EventOf = M extends {spec: { events: Vec}} ? E : never export type ContractMetadataSupported = ContractMetadataV4 | ContractMetadataV5 | ContractMetadataV6; type ContractEventSupported = EventOf; const l = logger('Abi'); const PRIMITIVE_ALWAYS = ['AccountId', 'AccountId20', 'AccountIndex', 'Address', 'Balance']; function findMessage (list: T[], messageOrId: T | string | number): T { const message = isNumber(messageOrId) ? list[messageOrId] : isString(messageOrId) ? list.find(({ identifier }) => [identifier, stringCamelCase(identifier)].includes(messageOrId.toString())) : messageOrId; return assertReturn(message, () => `Attempted to call an invalid contract interface, ${stringify(messageOrId)}`); } function getMetadata (registry: Registry, json: AbiJson): ContractMetadataSupported { // this is for V1, V2, V3 const vx = enumVersions.find((v) => isObject(json[v])); // this was added in V4 const jsonVersion = json.version; if (!vx && jsonVersion && !enumVersions.find((v) => v === `V${jsonVersion}`)) { throw new Error(`Unable to handle version ${jsonVersion}`); } const metadata = registry.createType('ContractMetadata', vx ? { [vx]: json[vx] } : jsonVersion ? { [`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 a supported version`); } const upgradedMetadata = converter[1](registry, metadata[`as${converter[0]}`]); return upgradedMetadata; } function isRevive (json: Record): boolean { const source = json['source']; const version = json['version']; const hasContractBinary = typeof source === 'object' && source !== null && 'contract_binary' in source; const hasVersion = typeof version === 'number' && version >= 6; return hasContractBinary || hasVersion; } function parseJson (json: Record, chainProperties?: ChainProperties): [Record, Registry, ContractMetadataSupported, ContractProjectInfo, boolean] { const registry = new TypeRegistry(); const revive = isRevive(json); const typeName = revive ? 'ContractReviveProjectInfo' : 'ContractProjectInfo'; const info = registry.createType(typeName, json) as unknown as ContractProjectInfo; 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); if (chainProperties) { registry.setChainProperties(chainProperties); } // warm-up the actual type, pre-use lookup.types.forEach(({ id }) => lookup.getTypeDef(id) ); return [json, registry, metadata, info, revive]; } /** * @internal * Determines if the given input value is a ContractTypeSpec */ function isTypeSpec (value: Codec): value is ContractTypeSpec { return !!value && value instanceof Map && !isUndefined((value as ContractTypeSpec).type) && !isUndefined((value as ContractTypeSpec).displayName); } /** * @internal * Determines if the given input value is an Option */ function isOption (value: Codec): value is Option { return !!value && value instanceof Option; } export class Abi { readonly events: AbiEvent[]; readonly constructors: AbiConstructor[]; readonly info: ContractProjectInfo; readonly json: Record; readonly messages: AbiMessage[]; readonly metadata: ContractMetadataSupported; readonly registry: Registry; readonly environment = new Map(); readonly isRevive: boolean; constructor (abiJson: Record | string, chainProperties?: ChainProperties) { [this.json, this.registry, this.metadata, this.info, this.isRevive] = parseJson( isString(abiJson) ? JSON.parse(abiJson) as Record : abiJson, chainProperties ); this.constructors = this.metadata.spec.constructors.map((spec: ContractConstructorSpecLatest, index) => this.#createMessage(spec, index, { isConstructor: true, isDefault: spec.default.isTrue, isPayable: spec.payable.isTrue, returnType: spec.returnType.isSome ? this.registry.lookup.getTypeDef(spec.returnType.unwrap().type) : null }) ); 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, { isDefault: spec.default.isTrue, isMutating: spec.mutates.isTrue, isPayable: spec.payable.isTrue, returnType: spec.returnType.isSome ? this.registry.lookup.getTypeDef(spec.returnType.unwrap().type) : null }) ); // NOTE See the rationale for having Option<...> values in the actual // ContractEnvironmentV4 structure definition in interfaces/contractsAbi // (Due to conversions, the fields may not exist) for (const [key, opt] of this.metadata.spec.environment.entries()) { if (isOption(opt)) { if (opt.isSome) { const value = opt.unwrap(); if (isBn(value)) { this.environment.set(key, value); } else if (isTypeSpec(value)) { this.environment.set(key, this.registry.lookup.getTypeDef(value.type)); } else { throw new Error(`Invalid environment definition for ${key}:: Expected either Number or ContractTypeSpec`); } } } else { throw new Error(`Expected Option<*> definition for ${key} in ContractEnvironment`); } } } /** * Warning: Unstable API, bound to change */ public decodeEvent (record: EventRecord): DecodedEvent { switch (this.metadata.version.toString()) { // earlier version are hoisted to v4 case '4': return this.#decodeEventV4(record); case '5': return this.#decodeEventV5(record); // Latest default: return this.#decodeEventV6(record); } } #decodeEventV6 = (record: EventRecord): DecodedEvent => { const topics = record.event.data[2] as unknown as { toHex: () => string }[]; // Try to match by signature topic (first topic) const signatureTopic = 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 = 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'); }; #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]; if (!event) { throw new Error(`Unable to find event with index ${index}`); } return event.fromU8a(data.subarray(1)); }; /** * Warning: Unstable API, bound to change */ public decodeConstructor (data: Uint8Array): DecodedMessage { return this.#decodeMessage('message', this.constructors, data); } /** * Warning: Unstable API, bound to change */ public decodeMessage (data: Uint8Array): DecodedMessage { return this.#decodeMessage('message', this.messages, data); } public findConstructor (constructorOrId: AbiConstructor | string | number): AbiConstructor { return findMessage(this.constructors, constructorOrId); } public findMessage (messageOrId: AbiMessage | string | number): AbiMessage { return findMessage(this.messages, messageOrId); } #createArgs = (args: ContractMessageParamSpecLatest[] | ContractEventParamSpecLatest[], spec: unknown): AbiParam[] => { return args.map(({ label, type }, index): AbiParam => { try { if (!isObject(type)) { throw new Error('Invalid type definition found'); } const displayName = type.displayName.length ? type.displayName[type.displayName.length - 1].toString() : undefined; const camelName = stringCamelCase(label); if (displayName && PRIMITIVE_ALWAYS.includes(displayName)) { return { name: camelName, type: { info: TypeDefInfo.Plain, type: displayName } }; } const typeDef = this.registry.lookup.getTypeDef(type.type); return { name: camelName, type: displayName && !typeDef.type.startsWith(displayName) ? { displayName, ...typeDef } : typeDef }; } catch (error) { l.error(`Error expanding argument ${index} in ${stringify(spec)}`); throw error; } }); }; #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, 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, 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.label.toString(), index }; return event; }; #createMessage = (spec: ContractMessageSpecLatest | ContractConstructorSpecLatest, index: number, add: Partial = {}): AbiMessage => { const args = this.#createMessageParams(spec.args, spec); const identifier = spec.label.toString(); const message = { ...add, args, docs: spec.docs.map((d) => d.toString()), fromU8a: (data: Uint8Array): DecodedMessage => ({ args: this.#decodeArgs(args, data), message }), identifier, index, isDefault: spec.default.isTrue, method: stringCamelCase(identifier), path: identifier.split('::').map((s) => stringCamelCase(s)), selector: spec.selector, toU8a: (params: unknown[]) => this.#encodeMessageArgs(spec, args, params) }; return message; }; #decodeArgs = (args: AbiParam[], data: Uint8Array): Codec[] => { // for decoding we expect the input to be just the arg data, no selectors // no length added (this allows use with events as well) let offset = 0; return args.map(({ type: { lookupName, type } }): Codec => { const value = this.registry.createType(lookupName || type, data.subarray(offset)); offset += value.encodedLength; return value; }); }; #decodeMessage = (type: 'constructor' | 'message', list: AbiMessage[], data: Uint8Array): DecodedMessage => { const [, trimmed] = compactStripLength(data); const selector = trimmed.subarray(0, 4); const message = list.find((m) => m.selector.eq(selector)); if (!message) { throw new Error(`Unable to find ${type} with selector ${u8aToHex(selector)}`); } return message.fromU8a(trimmed.subarray(4)); }; #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}`); } return compactAddLength( u8aConcat( this.registry.createType('ContractSelector', selector).toU8a(), ...args.map(({ type: { lookupName, type } }, index) => this.registry.createType(lookupName || type, data[index]).toU8a() ) ) ); }; }