Skip to content

feat(api-contract): add support for ink! v6 and pallet-revive compatibility #6158

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

Merged
merged 11 commits into from
Jun 9, 2025
34 changes: 27 additions & 7 deletions packages/api-contract/src/Abi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

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 { ChainProperties, ContractConstructorSpecLatest, ContractEventParamSpecLatest, ContractMessageParamSpecLatest, ContractMessageSpecLatest, ContractMetadata, ContractMetadataV4, ContractMetadataV5, ContractMetadataV6, ContractProjectInfo, ContractTypeSpec, EventRecord } from '@polkadot/types/interfaces';
import type { Codec, Registry, TypeDef } from '@polkadot/types/types';
import type { AbiConstructor, AbiEvent, AbiEventParam, AbiMessage, AbiMessageParam, AbiParam, DecodedEvent, DecodedMessage } from '../types.js';

Expand All @@ -19,12 +19,12 @@ interface AbiJson {
}

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

const l = logger('Abi');

const PRIMITIVE_ALWAYS = ['AccountId', 'AccountIndex', 'Address', 'Balance'];
const PRIMITIVE_ALWAYS = ['AccountId', 'AccountId20', 'AccountIndex', 'Address', 'Balance'];

function findMessage <T extends AbiMessage> (list: T[], messageOrId: T | string | number): T {
const message = isNumber(messageOrId)
Expand Down Expand Up @@ -66,9 +66,28 @@ function getMetadata (registry: Registry, json: AbiJson): ContractMetadataSuppor
return upgradedMetadata;
}

function parseJson (json: Record<string, unknown>, chainProperties?: ChainProperties): [Record<string, unknown>, Registry, ContractMetadataSupported, ContractProjectInfo] {
function isRevive (json: Record<string, unknown>): 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<string, unknown>, chainProperties?: ChainProperties): [Record<string, unknown>, Registry, ContractMetadataSupported, ContractProjectInfo, boolean] {
const registry = new TypeRegistry();
const info = registry.createType('ContractProjectInfo', json) as unknown as ContractProjectInfo;

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);

Expand All @@ -84,7 +103,7 @@ function parseJson (json: Record<string, unknown>, chainProperties?: ChainProper
lookup.getTypeDef(id)
);

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

/**
Expand Down Expand Up @@ -112,9 +131,10 @@ export class Abi {
readonly metadata: ContractMetadataSupported;
readonly registry: Registry;
readonly environment = new Map<string, TypeDef | Codec>();
readonly isRevive: boolean;

constructor (abiJson: Record<string, unknown> | string, chainProperties?: ChainProperties) {
[this.json, this.registry, this.metadata, this.info] = parseJson(
[this.json, this.registry, this.metadata, this.info, this.isRevive] = parseJson(
isString(abiJson)
? JSON.parse(abiJson) as Record<string, unknown>
: abiJson,
Expand Down
28 changes: 27 additions & 1 deletion packages/api-contract/src/Abi/toLatestCompatible.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { TypeRegistry } from '@polkadot/types';

import abis from '../test/contracts/index.js';
import { v0ToLatestCompatible, v1ToLatestCompatible, v2ToLatestCompatible, v3ToLatestCompatible, v4ToLatestCompatible, v5ToLatestCompatible } from './toLatestCompatible.js';
import { v0ToLatestCompatible, v1ToLatestCompatible, v2ToLatestCompatible, v3ToLatestCompatible, v4ToLatestCompatible, v5ToLatestCompatible, v6ToLatestCompatible } from './toLatestCompatible.js';

describe('v0ToLatestCompatible', (): void => {
const registry = new TypeRegistry();
Expand Down Expand Up @@ -191,3 +191,29 @@ describe('v5ToLatestCompatible', (): void => {
expect(latest.version.toString()).toEqual('5');
});
});

describe('v6ToLatestCompatible', (): void => {
const registry = new TypeRegistry();
const contract = registry.createType('ContractMetadata', { V6: abis['ink_v6_erc20Metadata'] });
const latest = v6ToLatestCompatible(registry, contract.asV6);

it('has the correct messages', (): void => {
expect(
latest.spec.messages.map(({ label }) => label.toString())
).toEqual(['total_supply', 'balance_of', 'allowance', 'transfer', 'approve', 'transfer_from']);
});

it('has H160 as the type of balance_of argument', (): void => {
const arg = latest.spec.messages.find(
(m) => m.label.toString() === 'balance_of'
)?.args[0];

const name = arg?.type.displayName?.[0]?.toString();

expect(name).toBe('H160');
});

it('has the latest compatible version number', (): void => {
expect(latest.version.toString()).toEqual('6');
});
});
9 changes: 7 additions & 2 deletions packages/api-contract/src/Abi/toLatestCompatible.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2017-2025 @polkadot/api-contract authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { ContractMetadataV4, ContractMetadataV5 } from '@polkadot/types/interfaces';
import type { ContractMetadataV4, ContractMetadataV5, ContractMetadataV6 } from '@polkadot/types/interfaces';
import type { Registry } from '@polkadot/types/types';
import type { ContractMetadataSupported } from './index.js';

Expand All @@ -12,7 +12,7 @@ import { v3ToV4 } from './toV4.js';

// The versions where an enum is used, aka V0 is missing
// (Order from newest, i.e. we expect more on newest vs oldest)
export const enumVersions = ['V5', 'V4', 'V3', 'V2', 'V1'] as const;
export const enumVersions = ['V6', 'V5', 'V4', 'V3', 'V2', 'V1'] as const;

type Versions = typeof enumVersions[number] | 'V0';

Expand All @@ -24,6 +24,10 @@ function createConverter <I, O> (next: (registry: Registry, input: O) => Contrac
next(registry, step(registry, input));
}

export function v6ToLatestCompatible (_registry: Registry, v6: ContractMetadataV6): ContractMetadataV6 {
return v6;
}

export function v5ToLatestCompatible (_registry: Registry, v5: ContractMetadataV5): ContractMetadataV5 {
return v5;
}
Expand All @@ -38,6 +42,7 @@ export const v1ToLatestCompatible = /*#__PURE__*/ createConverter(v2ToLatestComp
export const v0ToLatestCompatible = /*#__PURE__*/ createConverter(v1ToLatestCompatible, v0ToV1);

export const convertVersions: [Versions, Converter][] = [
['V6', v6ToLatestCompatible],
['V5', v5ToLatestCompatible],
['V4', v4ToLatestCompatible],
['V3', v3ToLatestCompatible],
Expand Down
20 changes: 16 additions & 4 deletions packages/api-contract/src/base/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,11 @@ export abstract class Base<ApiType extends ApiTypes> {

protected readonly _decorateMethod: DecorateMethod<ApiType>;
protected readonly _isWeightV1: boolean;
protected readonly _isRevive: boolean;

constructor (api: ApiBase<ApiType>, abi: string | Record<string, unknown> | Abi, decorateMethod: DecorateMethod<ApiType>) {
if (!api || !api.isConnected || !api.tx) {
throw new Error('Your API has not been initialized correctly and is not connected to a chain');
} else if (!api.tx.contracts || !isFunction(api.tx.contracts.instantiateWithCode) || api.tx.contracts.instantiateWithCode.meta.args.length !== 6) {
throw new Error('The runtime does not expose api.tx.contracts.instantiateWithCode with storageDepositLimit');
} else if (!api.call.contractsApi || !isFunction(api.call.contractsApi.call)) {
throw new Error('Your runtime does not expose the api.call.contractsApi.call runtime interfaces');
}

this.abi = abi instanceof Abi
Expand All @@ -32,6 +29,21 @@ export abstract class Base<ApiType extends ApiTypes> {
this.api = api;
this._decorateMethod = decorateMethod;
this._isWeightV1 = !api.registry.createType<WeightV2>('Weight').proofSize;
this._isRevive = this.abi.isRevive;

if (this._isRevive) {
if (!api.tx.revive || !isFunction(api.tx.revive.instantiateWithCode) || api.tx.revive.instantiateWithCode.meta.args.length !== 6) {
throw new Error('The runtime does not expose api.tx.revive.instantiateWithCode with storageDepositLimit');
} else if (!api.call.reviveApi || !isFunction(api.call.reviveApi.call)) {
throw new Error('Your runtime does not expose the api.call.reviveApi.call runtime interfaces');
}
} else {
if (!api.tx.contracts || !isFunction(api.tx.contracts.instantiateWithCode) || api.tx.contracts.instantiateWithCode.meta.args.length !== 6) {
throw new Error('The runtime does not expose api.tx.contracts.instantiateWithCode with storageDepositLimit');
} else if (!api.call.contractsApi || !isFunction(api.call.contractsApi.call)) {
throw new Error('Your runtime does not expose the api.call.contractsApi.call runtime interfaces');
}
}
}

public get registry (): Registry {
Expand Down
26 changes: 22 additions & 4 deletions packages/api-contract/src/base/Blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ export class Blueprint<ApiType extends ApiTypes> extends Base<ApiType> {
}

#deploy = (constructorOrId: AbiConstructor | string | number, { gasLimit = BN_ZERO, salt, storageDepositLimit = null, value = BN_ZERO }: BlueprintOptions, params: unknown[]): SubmittableExtrinsic<ApiType, BlueprintSubmittableResult<ApiType>> => {
return this.api.tx.contracts.instantiate(
const palletTx = this._isRevive
? this.api.tx.revive
: this.api.tx.contracts;

return palletTx.instantiate(
value,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore jiggle v1 weights, metadata points to latest
Expand All @@ -67,9 +71,23 @@ export class Blueprint<ApiType extends ApiTypes> extends Base<ApiType> {
this.abi.findConstructor(constructorOrId).toU8a(params),
encodeSalt(salt)
).withResultTransform((result: ISubmittableResult) =>
new BlueprintSubmittableResult(result, applyOnEvent(result, ['Instantiated'], ([record]: EventRecord[]) =>
new Contract<ApiType>(this.api, this.abi, record.event.data[1] as AccountId, this._decorateMethod)
))
new BlueprintSubmittableResult(result,
this._isRevive
? (
(result.status.isInBlock || result.status.isFinalized)
? new Contract<ApiType>(
this.api,
this.abi,
// your fixed address for revive deployments
this.registry.createType('AccountId', '0x'),
this._decorateMethod
)
: undefined
)
: applyOnEvent(result, ['Instantiated'], ([record]: EventRecord[]) =>
new Contract<ApiType>(this.api, this.abi, record.event.data[1] as AccountId, this._decorateMethod)
)
)
);
};
}
Expand Down
13 changes: 12 additions & 1 deletion packages/api-contract/src/base/Code.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import { toPromiseMethod } from '@polkadot/api';
import v0contractFlipper from '../test/contracts/ink/v0/flipper.contract.json' assert { type: 'json' };
import v0abiFlipper from '../test/contracts/ink/v0/flipper.json' assert { type: 'json' };
import v1contractFlipper from '../test/contracts/ink/v1/flipper.contract.json' assert { type: 'json' };
import v6contractErc20 from '../test/contracts/ink/v6/erc20.contract.json' assert { type: 'json' };
import { Code } from './Code.js';
import { mockApi } from './mock.js';
import { mockApi, mockReviveApi } from './mock.js';

const v0wasmFlipper = fs.readFileSync(new URL('../test/contracts/ink/v0/flipper.wasm', import.meta.url), 'utf-8');

Expand All @@ -33,4 +34,14 @@ describe('Code', (): void => {
() => new Code(mockApi, v1contractFlipper as Record<string, unknown>, null, toPromiseMethod)
).not.toThrow();
});

it('can construct a revive compatible contract (v6)', (): void => {
expect(
() => new Code(mockApi, v6contractErc20 as Record<string, unknown>, null, toPromiseMethod)
).toThrow('The runtime does not expose api.tx.revive.instantiateWithCode with storageDepositLimit');

expect(
() => new Code(mockReviveApi, v6contractErc20 as Record<string, unknown>, null, toPromiseMethod)
).not.toThrow();
});
});
25 changes: 24 additions & 1 deletion packages/api-contract/src/base/Code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,30 @@ export class Code<ApiType extends ApiTypes> extends Base<ApiType> {
}

#instantiate = (constructorOrId: AbiConstructor | string | number, { gasLimit = BN_ZERO, salt, storageDepositLimit = null, value = BN_ZERO }: BlueprintOptions, params: unknown[]): SubmittableExtrinsic<ApiType, CodeSubmittableResult<ApiType>> => {
return this.api.tx.contracts.instantiateWithCode(
const palletTx = this._isRevive ? this.api.tx.revive : this.api.tx.contracts;

if (this._isRevive) {
return palletTx.instantiateWithCode(
value,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore jiggle v1 weights, metadata points to latest
this._isWeightV1
? convertWeight(gasLimit).v1Weight
: convertWeight(gasLimit).v2Weight,
storageDepositLimit,
compactAddLength(this.code),
this.abi.findConstructor(constructorOrId).toU8a(params),
encodeSalt(salt)
).withResultTransform((result: ISubmittableResult) =>
new CodeSubmittableResult(
result,
new Blueprint<ApiType>(this.api, this.abi, this.abi.info.source.hash, this._decorateMethod),
new Contract<ApiType>(this.api, this.abi, '0x', this._decorateMethod)
)
);
}

return palletTx.instantiateWithCode(
value,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore jiggle v1 weights, metadata points to latest
Expand Down
16 changes: 10 additions & 6 deletions packages/api-contract/src/base/Contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import type { ApiBase } from '@polkadot/api/base';
import type { SubmittableExtrinsic } from '@polkadot/api/submittable/types';
import type { ApiTypes, DecorateMethod } from '@polkadot/api/types';
import type { AccountId, ContractExecResult, EventRecord, Weight, WeightV2 } from '@polkadot/types/interfaces';
import type { AccountId, AccountId20, ContractExecResult, EventRecord, Weight, WeightV2 } from '@polkadot/types/interfaces';
import type { ISubmittableResult } from '@polkadot/types/types';
import type { Abi } from '../Abi/index.js';
import type { AbiMessage, ContractCallOutcome, ContractOptions, DecodedEvent, WeightAll } from '../types.js';
Expand Down Expand Up @@ -52,15 +52,15 @@ export class Contract<ApiType extends ApiTypes> extends Base<ApiType> {
/**
* @description The on-chain address for this contract
*/
readonly address: AccountId;
readonly address: AccountId | AccountId20;

readonly #query: MapMessageQuery<ApiType> = {};
readonly #tx: MapMessageTx<ApiType> = {};

constructor (api: ApiBase<ApiType>, abi: string | Record<string, unknown> | Abi, address: string | AccountId, decorateMethod: DecorateMethod<ApiType>) {
constructor (api: ApiBase<ApiType>, abi: string | Record<string, unknown> | Abi, address: string | AccountId | AccountId20, decorateMethod: DecorateMethod<ApiType>) {
super(api, abi, decorateMethod);

this.address = this.registry.createType('AccountId', address);
this.address = this.registry.createType(this._isRevive ? 'AccountId20' : 'AccountId', address);

this.abi.messages.forEach((m): void => {
if (isUndefined(this.#tx[m.method])) {
Expand Down Expand Up @@ -100,7 +100,9 @@ export class Contract<ApiType extends ApiTypes> extends Base<ApiType> {
};

#exec = (messageOrId: AbiMessage | string | number, { gasLimit = BN_ZERO, storageDepositLimit = null, value = BN_ZERO }: ContractOptions, params: unknown[]): SubmittableExtrinsic<ApiType> => {
return this.api.tx.contracts.call(
const palletTx = this._isRevive ? this.api.tx.revive : this.api.tx.contracts;

return palletTx.call(
this.address,
value,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
Expand Down Expand Up @@ -134,7 +136,9 @@ export class Contract<ApiType extends ApiTypes> extends Base<ApiType> {
return {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
send: this._decorateMethod((origin: string | AccountId | Uint8Array) =>
this.api.rx.call.contractsApi.call<ContractExecResult>(
(this._isRevive
? this.api.rx.call.reviveApi.call
: this.api.rx.call.contractsApi.call)<ContractExecResult>(
origin,
this.address,
value,
Expand Down
17 changes: 17 additions & 0 deletions packages/api-contract/src/base/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,20 @@ export const mockApi = {
}
}
} as unknown as ApiBase<'promise'>;

export const mockReviveApi = {
call: {
reviveApi: {
call: (): never => {
throw new Error('mock');
}
}
},
isConnected: true,
registry,
tx: {
revive: {
instantiateWithCode
}
}
} as unknown as ApiBase<'promise'>;
4 changes: 2 additions & 2 deletions packages/api-contract/src/promise/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import type { ApiPromise } from '@polkadot/api';
import type { AccountId, Hash } from '@polkadot/types/interfaces';
import type { AccountId, AccountId20, Hash } from '@polkadot/types/interfaces';
import type { Abi } from '../Abi/index.js';

import { toPromiseMethod } from '@polkadot/api';
Expand All @@ -22,7 +22,7 @@ export class CodePromise extends Code<'promise'> {
}

export class ContractPromise extends Contract<'promise'> {
constructor (api: ApiPromise, abi: string | Record<string, unknown> | Abi, address: string | AccountId) {
constructor (api: ApiPromise, abi: string | Record<string, unknown> | Abi, address: string | AccountId | AccountId20) {
super(api, abi, address, toPromiseMethod);
}
}
Loading