Skip to content

Commit 3c32df8

Browse files
AlexD10Speterwht
andauthored
feat: include support for ink v6 contracts (#571)
Co-authored-by: Peter White <[email protected]>
1 parent 88477b8 commit 3c32df8

33 files changed

+3585
-3710
lines changed

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@
3333
"dependencies": {
3434
"@headlessui/react": "^1.7.18",
3535
"@heroicons/react": "^1.0.6",
36-
"@polkadot/api": "15.8.1",
37-
"@polkadot/api-contract": "15.8.1",
36+
"@polkadot/api": "^16.2.1",
37+
"@polkadot/api-contract": "^16.2.1",
3838
"@polkadot/extension-dapp": "^0.58.6",
39+
"@polkadot/types": "^16.2.1",
3940
"@polkadot/ui-keyring": "^3.12.2",
4041
"@polkadot/ui-shared": "^3.12.2",
4142
"big.js": "^6.2.1",
@@ -44,6 +45,7 @@
4445
"date-fns": "^2.30.0",
4546
"dexie": "^3.2.4",
4647
"dexie-react-hooks": "1.1.7",
48+
"ethers": "^6.13.5",
4749
"json5": "^2.2.3",
4850
"react": "^18.3.1",
4951
"react-dom": "^18.3.1",
@@ -66,7 +68,8 @@
6668
"@types/bcryptjs": "^2.4.6",
6769
"@types/big.js": "^6.2.2",
6870
"@types/node": "^22.5.0",
69-
"@types/react-dom": "^18.3.0",
71+
"@types/react": "^19.1.2",
72+
"@types/react-dom": "^19.1.2",
7073
"@typescript-eslint/eslint-plugin": "^8.2.0",
7174
"@typescript-eslint/parser": "^8.2.0",
7275
"@vitejs/plugin-react": "^4.3.1",

src/constants/index.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const LOCAL_STORAGE_KEY = {
88
CUSTOM_ENDPOINT: 'contractsUiCustomEndpoint',
99
PREFERRED_ENDPOINT: 'contractsUiPreferredEndpoint',
1010
THEME: 'theme',
11+
VERSION: 'inkVersion',
1112
} as const;
1213

1314
export type LocalStorageKey = (typeof LOCAL_STORAGE_KEY)[keyof typeof LOCAL_STORAGE_KEY];
@@ -19,19 +20,18 @@ export const LOCAL = {
1920
rpc: CUSTOM_ENDPOINT ? (JSON.parse(CUSTOM_ENDPOINT) as string) : 'ws://127.0.0.1:9944',
2021
};
2122

22-
// https://docs.peaq.network/networks-overview
23-
// const PEAQ_AGUNG = {
24-
// relay: 'Rococo',
25-
// name: 'Peaq Agung',
26-
// rpc: 'wss://wss.agung.peaq.network',
27-
// };
28-
2923
export const POP_NETWORK_TESTNET = {
3024
relay: 'Paseo',
3125
name: 'Pop Network Testnet',
3226
rpc: 'wss://rpc2.paseo.popnetwork.xyz',
3327
};
3428

29+
export const ASSET_HUB_WESTEND = {
30+
relay: 'Westend',
31+
name: 'Westend Asset Hub',
32+
rpc: 'wss://westend-asset-hub-rpc.polkadot.io',
33+
};
34+
3535
const PHALA_TESTNET = {
3636
relay: undefined,
3737
name: 'Phala PoC-6',
@@ -96,9 +96,13 @@ const ZEITGEIST_BATTERY_STATION = {
9696
rpc: 'wss://bsr.zeitgeist.pm',
9797
};
9898

99-
export const TESTNETS = [
99+
export const TESTNETS_V6 = [
100+
...[ASSET_HUB_WESTEND, POP_NETWORK_TESTNET].sort((a, b) => a.name.localeCompare(b.name)),
101+
LOCAL,
102+
];
103+
104+
export const TESTNETS_V5 = [
100105
...[
101-
// PEAQ_AGUNG,
102106
PHALA_TESTNET,
103107
ASTAR_SHIBUYA,
104108
ALEPH_ZERO_TESTNET,
@@ -111,7 +115,7 @@ export const TESTNETS = [
111115
LOCAL,
112116
];
113117

114-
export const MAINNETS = [ASTAR, SHIDEN, ALEPH_ZERO].sort((a, b) => a.name.localeCompare(b.name));
118+
export const MAINNETS_V5 = [ASTAR, SHIDEN, ALEPH_ZERO].sort((a, b) => a.name.localeCompare(b.name));
115119

116120
export const DEFAULT_DECIMALS = 12;
117121

src/lib/address.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright 2022-2024 use-ink/contracts-ui authors & contributors
2+
// SPDX-License-Identifier: GPL-3.0-only
3+
4+
import { describe, expect, it } from 'vitest';
5+
import { getAddress } from 'ethers';
6+
import { decodeAddress } from '@polkadot/keyring';
7+
import { create1, create2, toEthAddress } from './address';
8+
9+
// Similar to pallet_revive tests: https://github.com/paritytech/polkadot-sdk/blob/65ade498b63bf2216d1c444f28c1b48085417f13/substrate/frame/revive/src/address.rs#L257
10+
describe('address utilities', () => {
11+
const deployer = '0x' + '01'.repeat(20);
12+
const code = Uint8Array.from([0x60, 0x00, 0x60, 0x00, 0x55, 0x60, 0x01, 0x60, 0x00]);
13+
const inputData = Uint8Array.from([0x55]);
14+
const salt = '0x1234567890123456789012345678901234567890123456789012345678901234';
15+
16+
it('should compute correct address with create1', () => {
17+
const address = create1(deployer, 1);
18+
expect(getAddress(address)).toBe(getAddress('0xc851da37e4e8d3a20d8d56be2963934b4ad71c3b'));
19+
});
20+
21+
it('should compute correct address with create2', () => {
22+
const address = create2(deployer, code, inputData, salt);
23+
expect(getAddress(address)).toBe(getAddress('0x7f31e795e5836a19a8f919ab5a9de9a197ecd2b6'));
24+
});
25+
26+
it('should convert Substrate account ID to Ethereum address', () => {
27+
const accountId = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY';
28+
const ethAddress = toEthAddress(decodeAddress(accountId));
29+
30+
expect(ethAddress.startsWith('0x')).toBe(true);
31+
expect(getAddress(ethAddress)).toBe(getAddress('0x9621dde636de098b43efb0fa9b61facfe328f99d'));
32+
});
33+
});

src/lib/address.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright 2022-2024 use-ink/contracts-ui authors & contributors
2+
// SPDX-License-Identifier: GPL-3.0-only
3+
4+
import { BigNumberish, ethers } from 'ethers';
5+
import { hexToU8a, stringToU8a, u8aToHex } from '@polkadot/util';
6+
import { keccak256 } from 'ethers';
7+
8+
/**
9+
* TypeScript equivalent of H160 (20-byte Ethereum address)
10+
*/
11+
type Address = string;
12+
13+
/**
14+
* Determine the address of a contract using CREATE semantics.
15+
* @param deployer The address of the deployer
16+
* @param nonce The nonce value
17+
* @returns The contract address
18+
*/
19+
export function create1(deployer: string, nonce: number): Address {
20+
// Convert deployer to bytes (remove 0x prefix if present)
21+
const deployerBytes = ethers.hexlify(deployer);
22+
ethers.toBeHex(nonce as BigNumberish);
23+
// Convert nonce to hex (minimal encoding)
24+
const nonceBytes = ethers.toBeHex(nonce as BigNumberish);
25+
26+
// RLP encode [deployer, nonce]
27+
const encodedData = ethers.encodeRlp([deployerBytes, nonceBytes]);
28+
29+
// Calculate keccak256 hash of the RLP encoded data
30+
const hash = ethers.keccak256(encodedData);
31+
32+
// Take the last 20 bytes (40 hex chars + 0x prefix)
33+
return ethers.getAddress('0x' + hash.substring(26));
34+
}
35+
36+
/**
37+
* Determine the address of a contract using CREATE2 semantics.
38+
* @param deployer The address of the deployer
39+
* @param code The contract code (WASM or EVM bytecode)
40+
* @param inputData The constructor arguments or init input
41+
* @param salt A 32-byte salt value (as hex string)
42+
* @returns The deterministic contract address
43+
*/
44+
export function create2(
45+
deployer: string,
46+
code: Uint8Array,
47+
inputData: Uint8Array,
48+
salt: string,
49+
): Address {
50+
const initCode = new Uint8Array([...code, ...inputData]);
51+
const initCodeHash = hexToU8a(keccak256(initCode));
52+
53+
const parts = new Uint8Array(1 + 20 + 32 + 32); // 0xff + deployer + salt + initCodeHash
54+
parts[0] = 0xff;
55+
parts.set(hexToU8a(deployer), 1);
56+
parts.set(hexToU8a(salt), 21);
57+
parts.set(initCodeHash, 53);
58+
59+
const hash = keccak256(parts);
60+
61+
// Return last 20 bytes as 0x-prefixed hex string
62+
return ethers.getAddress('0x' + hash.substring(26));
63+
}
64+
65+
/**
66+
* Converts an account ID to an Ethereum address (H160)
67+
* @param accountId The account ID bytes
68+
* @returns The Ethereum address
69+
*/
70+
export function toEthAddress(accountId: Uint8Array | string): string {
71+
// Convert string input to Uint8Array if needed
72+
const accountBytes = typeof accountId === 'string' ? stringToU8a(accountId) : accountId;
73+
74+
// Create a 32-byte buffer and copy account bytes into it
75+
const accountBuffer = new Uint8Array(32);
76+
accountBuffer.set(accountBytes.slice(0, 32));
77+
78+
if (isEthDerived(accountBytes)) {
79+
// This was originally an eth address
80+
// We just strip the 0xEE suffix to get the original address
81+
return '0x' + Buffer.from(accountBuffer.slice(0, 20)).toString('hex');
82+
} else {
83+
// This is an (ed|sr)25519 derived address
84+
// Avoid truncating the public key by hashing it first
85+
const accountHash = ethers.keccak256(accountBuffer);
86+
return ethers.getAddress('0x' + accountHash.slice(2 + 24, 2 + 24 + 40)); // Skip '0x' prefix, then skip 12 bytes, take 20 bytes
87+
}
88+
}
89+
90+
export function fromEthAddress(ethAddress: string): string {
91+
// Remove '0x' prefix if it exists
92+
const cleanAddress = ethAddress.startsWith('0x') ? ethAddress.slice(2) : ethAddress;
93+
94+
// Convert the hex string to bytes
95+
const addressBytes = Buffer.from(cleanAddress, 'hex');
96+
97+
// Check if the address is the expected length (20 bytes)
98+
if (addressBytes.length !== 20) {
99+
throw new Error('Invalid Ethereum address: must be 20 bytes');
100+
}
101+
102+
// Create a 32-byte buffer
103+
const result = new Uint8Array(32).fill(0xee);
104+
105+
// Set the first 20 bytes to the Ethereum address
106+
result.set(addressBytes, 0);
107+
108+
return u8aToHex(result);
109+
}
110+
111+
/**
112+
* Determines if an account ID is derived from an Ethereum address
113+
* @param accountId The account ID bytes
114+
* @returns True if the account is derived from an Ethereum address
115+
*/
116+
export function isEthDerived(accountId: Uint8Array): boolean {
117+
if (accountId.length >= 32) {
118+
return accountId[20] === 0xee && accountId[21] === 0xee;
119+
}
120+
return false;
121+
}

src/lib/getContractFromPatron.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: GPL-3.0-only
33

44
import { Buffer } from 'buffer';
5+
import { getVersion } from 'ui/contexts';
56

67
function getFromPatron(field: string, hash: string) {
78
const options = {
@@ -29,7 +30,8 @@ function getFromPatron(field: string, hash: string) {
2930

3031
export function getContractFromPatron(codeHash: string): Promise<File> {
3132
const metadataPromise = getFromPatron('metadata', codeHash);
32-
const wasmPromise = getFromPatron('wasm', codeHash);
33+
const field = getVersion() === 'v6' ? 'contract_binary' : 'wasm';
34+
const wasmPromise = getFromPatron(field, codeHash);
3335
return Promise.all([metadataPromise, wasmPromise]).then(([metadataResponse, wasmResponse]) => {
3436
const result = Buffer.from(wasmResponse as ArrayBuffer).toString('hex');
3537

src/lib/util.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
// SPDX-License-Identifier: GPL-3.0-only
33

44
import { decodeAddress, encodeAddress } from '@polkadot/keyring';
5-
import { hexToU8a, isHex } from '@polkadot/util';
5+
import { hexToU8a, u8aToHex, isHex } from '@polkadot/util';
66
import { keyring } from '@polkadot/ui-keyring';
77
import format from 'date-fns/format';
88
import parseISO from 'date-fns/parseISO';
99
import { twMerge } from 'tailwind-merge';
10+
import { isAddress as isEthAddress } from 'ethers';
11+
import { InkVersion } from 'ui/contexts';
1012

1113
export const classes = twMerge;
1214

@@ -72,7 +74,16 @@ export function isUndefined(value: unknown): value is undefined {
7274
return value === undefined;
7375
}
7476

75-
export function isValidAddress(address: string | Uint8Array | null | undefined) {
77+
export function isValidAddress(
78+
address: string | Uint8Array | null | undefined,
79+
version: InkVersion,
80+
) {
81+
if (!address) return false;
82+
if (version === 'v6') {
83+
const hex = typeof address === 'string' ? address : u8aToHex(address);
84+
return isEthAddress(hex);
85+
}
86+
// Check for v5 address format
7687
try {
7788
encodeAddress(isHex(address) ? hexToU8a(address) : decodeAddress(address));
7889
return true;

src/services/chain/contract.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
InstantiateData,
1212
SubmittableExtrinsic,
1313
} from 'types';
14+
import { InkVersion } from 'ui/contexts';
1415

1516
export function createInstantiateTx(
1617
api: ApiPromise,
@@ -52,22 +53,38 @@ export function createInstantiateTx(
5253
}
5354
}
5455

55-
export async function getContractInfo(api: ApiPromise, address: string) {
56-
if (isValidAddress(address)) {
57-
return (await api.query.contracts.contractInfoOf(address)).unwrapOr(null);
56+
export async function getContractInfo(api: ApiPromise, address: string, version: InkVersion) {
57+
if (isValidAddress(address, version)) {
58+
if (version === 'v6') {
59+
return (await api.query.revive.contractInfoOf(address)).unwrapOr(null);
60+
} else {
61+
return (await api.query.contracts.contractInfoOf(address)).unwrapOr(null);
62+
}
5863
}
5964
}
6065

61-
export async function checkOnChainCode(api: ApiPromise, codeHash: string): Promise<boolean> {
62-
return isValidCodeHash(codeHash)
63-
? (await api.query.contracts.pristineCode(codeHash)).isSome
64-
: false;
66+
export async function checkOnChainCode(
67+
api: ApiPromise,
68+
codeHash: string,
69+
version: InkVersion,
70+
): Promise<boolean> {
71+
if (!isValidCodeHash(codeHash)) return false;
72+
73+
if (version === 'v6') {
74+
return (await api.query.revive.pristineCode(codeHash)).isSome;
75+
} else {
76+
return (await api.query.contracts.pristineCode(codeHash)).isSome;
77+
}
6578
}
6679

67-
export async function filterOnChainCode(api: ApiPromise, items: CodeBundleDocument[]) {
80+
export async function filterOnChainCode(
81+
api: ApiPromise,
82+
items: CodeBundleDocument[],
83+
version: InkVersion,
84+
) {
6885
const codes: CodeBundleDocument[] = [];
6986
for (const item of items) {
70-
const isOnChain = await checkOnChainCode(api, item.codeHash);
87+
const isOnChain = await checkOnChainCode(api, item.codeHash, version);
7188
isOnChain && codes.push(item);
7289
}
7390
return codes;

src/ui/components/App.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,30 @@ import {
88
DatabaseContextProvider,
99
ThemeContextProvider,
1010
TransactionsContextProvider,
11+
VersionContextProvider,
1112
} from 'ui/contexts';
1213
import { Sidebar } from 'ui/layout/sidebar';
1314

1415
export default function App() {
1516
return (
1617
<ThemeContextProvider>
17-
<ApiContextProvider>
18-
<DatabaseContextProvider>
19-
<TransactionsContextProvider>
20-
{/* we want the sidebar outside the outlet to prevent flickering in quicklinks */}
21-
<div className="relative inset-0 flex min-h-screen overflow-hidden text-black dark:bg-gray-900 dark:text-white md:fixed md:flex-row">
22-
<Sidebar />
23-
<CheckBrowserSupport>
24-
<AwaitApis>
25-
<Outlet />
26-
</AwaitApis>
27-
</CheckBrowserSupport>
28-
</div>
29-
</TransactionsContextProvider>
30-
</DatabaseContextProvider>
31-
</ApiContextProvider>
18+
<VersionContextProvider>
19+
<ApiContextProvider>
20+
<DatabaseContextProvider>
21+
<TransactionsContextProvider>
22+
{/* we want the sidebar outside the outlet to prevent flickering in quicklinks */}
23+
<div className="relative inset-0 flex min-h-screen overflow-hidden text-black dark:bg-gray-900 dark:text-white md:fixed md:flex-row">
24+
<Sidebar />
25+
<CheckBrowserSupport>
26+
<AwaitApis>
27+
<Outlet />
28+
</AwaitApis>
29+
</CheckBrowserSupport>
30+
</div>
31+
</TransactionsContextProvider>
32+
</DatabaseContextProvider>
33+
</ApiContextProvider>
34+
</VersionContextProvider>
3235
</ThemeContextProvider>
3336
);
3437
}

0 commit comments

Comments
 (0)