From 14634bec034ab86e9fabb7289adfb732caa6ad2c Mon Sep 17 00:00:00 2001 From: Victor Oliva Date: Mon, 7 Oct 2024 10:17:41 +0200 Subject: [PATCH] feat(ink): add typescript support for ink interactions (#752) --- .../ink/{ => .papi/contracts}/escrow.json | 0 examples/ink/{ => .papi/contracts}/psp22.json | 0 examples/ink/.papi/polkadot-api.json | 4 + examples/ink/bun.lockb | Bin 3124 -> 0 bytes examples/ink/index.ts | 75 ++--- packages/cli/package.json | 1 + packages/cli/src/cli.ts | 24 +- packages/cli/src/commands/generate.ts | 55 +++- packages/cli/src/commands/index.ts | 1 + packages/cli/src/commands/ink.ts | 69 ++++ packages/cli/src/main.ts | 4 +- packages/cli/src/papiConfig.ts | 1 + packages/client/ink/package.json | 9 + packages/client/package.json | 16 +- packages/client/src/cli.ts | 3 +- packages/client/src/reexports/ink.ts | 1 + packages/client/sync-packages.mjs | 9 +- packages/codegen/package.json | 1 + packages/codegen/src/index.ts | 1 + packages/codegen/src/ink-types.ts | 310 ++++++++++++++++++ .../ink-contracts/src/dynamic-builders.ts | 2 + packages/ink-contracts/src/index.ts | 3 + packages/ink-contracts/src/ink-client.ts | 144 ++++++++ packages/ink-contracts/src/ink-descriptors.ts | 27 ++ .../ink-contracts/src/metadata-pjs-types.ts | 4 +- pnpm-lock.yaml | 9 + 26 files changed, 720 insertions(+), 53 deletions(-) rename examples/ink/{ => .papi/contracts}/escrow.json (100%) rename examples/ink/{ => .papi/contracts}/psp22.json (100%) delete mode 100755 examples/ink/bun.lockb create mode 100644 packages/cli/src/commands/ink.ts create mode 100644 packages/client/ink/package.json create mode 100644 packages/client/src/reexports/ink.ts create mode 100644 packages/codegen/src/ink-types.ts create mode 100644 packages/ink-contracts/src/ink-client.ts create mode 100644 packages/ink-contracts/src/ink-descriptors.ts diff --git a/examples/ink/escrow.json b/examples/ink/.papi/contracts/escrow.json similarity index 100% rename from examples/ink/escrow.json rename to examples/ink/.papi/contracts/escrow.json diff --git a/examples/ink/psp22.json b/examples/ink/.papi/contracts/psp22.json similarity index 100% rename from examples/ink/psp22.json rename to examples/ink/.papi/contracts/psp22.json diff --git a/examples/ink/.papi/polkadot-api.json b/examples/ink/.papi/polkadot-api.json index 92130e9aa..57a5046e5 100644 --- a/examples/ink/.papi/polkadot-api.json +++ b/examples/ink/.papi/polkadot-api.json @@ -6,5 +6,9 @@ "wsUrl": "wss://aleph-zero-testnet-rpc.dwellir.com", "metadata": ".papi/metadata/testAzero.scale" } + }, + "ink": { + "escrow": ".papi/contracts/escrow.json", + "psp22": ".papi/contracts/psp22.json" } } diff --git a/examples/ink/bun.lockb b/examples/ink/bun.lockb deleted file mode 100755 index 67c5227eae460683aa3801a9f95443775a409ccf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3124 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p-nUDk}! z1+FWjcD;8{dEc_g^I^iTbtk<=(!I>>T8riwFX09%0s?jj#lV3^H$eH>Fa->mdD&nw zE+7fS4W2-n6G%@3(m=x+ZUbo^Al(ZU2U1{l_kKS8eLG28=M4hJ>r&=?)dn5y*3=|MkO33`P$+6a!-Hve&hS_+6kG%)~X zzXs6LCP4k57zXJDxq%n})1LvZUFH?W`O{R2Jztu z4af%RAzj};{(m5Ys0o1PF)~2RMK+qnvna62^yLh8^VKrFe_M9^nK(_@=>EeC2eYT2 z-YW2DDeLhS*JXAbwGeb;TGV>h@61wZhi3;ubfg%W<;7fo7rb&uG8Z{KS=N*u=xDfC z`&j7j&p4B_*)C>Ui-O!t#bes*8+ND^6i%JB(roKK!$z}X3*|pv4c}&S^Vid<9iEF4 zrh0g^pV{-I3CUbgc*6k8#*>GiU&&0cFFjVF?Z|X&g8#0djaTf=yHYa}dqig4DOqyJ z>_Tz9-RhDI#Xy6jj1vzR+x5@LG-0-z##&w&r9BJDTo#z2K$=A@pvU_8!V)L0Re^<* z7s-b;h=gSrzddSw#{S{gvXa&2D>f=V3z+J6Rz-hr%Z$iM))@VcnoW}wzdzjXEt>YK zY6+6L$myEp-sOvv{<>^)czG_uNL$HfNj6&wW53|id%GRZ^-nl)ds5W*u)B=yu?b}d zc*R85P8Q7ly6;+C#KBT$tqy0ED~1!1%!Q>hkh57#^BGUIJ9%8#korQg=kALiQ-4|n zezCH!+PFMfV3|eRv&x%RvsI1K`TFYo`D>=??7DL;zq|8awO!h+cd-wCN}dLqJ5T@? z?l77hcMPEO$#L#L>Dd4+i;h5PE}P<_WWCJ1;*!Li96eZ#s~1v~nrf$DWS~%-S(Tcf zrlVk@keHL1o|m5nsv|+*-+u@Iu|etZ1JpncXm!hFQ)+Bv2h@kd1WK_!QEtt?kh;W4y&gH7~@R! z4D<}ZqTm`IR?~AZ?qo{!EN@7J8<7h6W6< zb_J}R0F*H@&@(jBvw*cUU@ZrrjH#ZHnVtz2Jz79tfI|V~TAXvZyp`d zV5UB>)t6CHQc!HAuV0j!o>^Q{RH>I&kegMkmtT~wk5Htq3sR~J3TaEdlJqJN2W$c! z-EcKf&0zIlBaF;JW5dr7>A?^Xdp - v.event.type === "Contracts" && - v.event.value.type === "ContractEmitted", - ) - .map((v) => v.event.value.value as { contract: string; data: Binary }) + if (response.result.success) { + console.log(increaseAllowance.decode(response.result.value)) + console.log(psp22.event.filter(ADDRESS.psp22, response.events)) + } else { console.log( - contractEvents?.map((evt) => psp22Event.dec(evt.data.asBytes())), + response.result.value, + response.gas_consumed, + response.gas_required, ) - } else { - console.log(result.result.value, result.gas_consumed, result.gas_required) } } // Send payable message { console.log("Deposit 100 funds") - const depositFunds = escrowBuilder.buildMessage("deposit_funds") + const depositFunds = escrow.message("deposit_funds") const result = await typedApi.apis.ContractsApi.call( ADDRESS.alice, @@ -94,11 +81,19 @@ const psp22Builder = getInkDynamicBuilder(getInkLookup(psp22 as any)) 100_000_000_000_000n, undefined, undefined, - Binary.fromBytes(depositFunds.call.enc({})), + depositFunds.encode(), ) if (result.result.success) { - console.log(depositFunds.value.dec(result.result.value.data.asBytes())) + const decoded = depositFunds.decode(result.result.value) + if (decoded.success) { + console.log("outer success") + if (!decoded.value.success) { + console.log("inner error", decoded.value.value.type) + } + } else { + console.log("outer error", decoded.value.type) + } } else { console.log(result.result.value, result.gas_required) } diff --git a/packages/cli/package.json b/packages/cli/package.json index a0ef828b0..aa0bd4fca 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,7 @@ "dependencies": { "@commander-js/extra-typings": "^12.1.0", "@polkadot-api/codegen": "workspace:*", + "@polkadot-api/ink-contracts": "workspace:*", "@polkadot-api/json-rpc-provider": "workspace:*", "@polkadot-api/known-chains": "workspace:*", "@polkadot-api/metadata-compatibility": "workspace:*", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cab6727bc..05ef38712 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,5 +1,5 @@ import { Option, program } from "@commander-js/extra-typings" -import type { add, generate, remove, update } from "./commands" +import type { add, generate, ink, remove, update } from "./commands" import * as knownChains from "@polkadot-api/known-chains" export type Commands = { @@ -7,9 +7,10 @@ export type Commands = { generate: typeof generate remove: typeof remove update: typeof update + ink: typeof ink } -export function getCli({ add, generate, remove, update }: Commands) { +export function getCli({ add, generate, remove, update, ink }: Commands) { program.name("polkadot-api").description("Polkadot API CLI") const config = new Option("--config ", "Source for the config file") @@ -63,5 +64,24 @@ export function getCli({ add, generate, remove, update }: Commands) { .option("--skip-codegen", "Skip running codegen after removing") .action(remove) + const inkCommand = program + .command("ink") + .description("Add, update or remove ink contracts") + inkCommand + .command("add") + .description("Add or update an ink contract") + .argument("", ".contract or .json metadata file for the contract") + .option("-k, --key ", "Key identifier for the contract") + .addOption(config) + .option("--skip-codegen", "Skip running codegen after updating") + .action(ink.add) + inkCommand + .command("remove") + .description("Remove an ink contract") + .argument("", "Key identifier for the contract to remove") + .addOption(config) + .option("--skip-codegen", "Skip running codegen after updating") + .action(ink.remove) + return program } diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index dd61fad32..15d736e80 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -1,6 +1,9 @@ import { getMetadata } from "@/metadata" import { readPapiConfig } from "@/papiConfig" -import { generateMultipleDescriptors } from "@polkadot-api/codegen" +import { + generateInkTypes, + generateMultipleDescriptors, +} from "@polkadot-api/codegen" import { EntryPointCodec, TypedefCodec, @@ -26,6 +29,7 @@ import { CommonOptions } from "./commonOptions" import { spawn } from "child_process" import { readPackage } from "read-pkg" import { detectPackageManager } from "../packageManager" +import { getInkLookup } from "@polkadot-api/ink-contracts" export interface GenerateOptions extends CommonOptions { clientLibrary?: string @@ -56,22 +60,28 @@ export async function generate(opts: GenerateOptions) { })), ) + console.log(`Generating descriptors`) await cleanDescriptorsPackage(config.descriptorPath) const descriptorsDir = join(process.cwd(), config.descriptorPath) const clientPath = opts.clientLibrary ?? "polkadot-api" const whitelist = opts.whitelist ? await readWhitelist(opts.whitelist) : null + const descriptorSrcDir = join(descriptorsDir, "src") const hash = await outputCodegen( chains, - join(descriptorsDir, "src"), + descriptorSrcDir, clientPath, whitelist, ) - await replacePackageJson(descriptorsDir, hash) + if (config.ink) { + outputInkCodegen(config.ink, descriptorSrcDir) + } + + await replacePackageJson(descriptorsDir, hash) await compileCodegen(descriptorsDir) - await fs.rm(join(descriptorsDir, "src"), { recursive: true }) + await fs.rm(descriptorSrcDir, { recursive: true }) await runInstall() await flushBundlerCache() } @@ -213,6 +223,43 @@ export default content return hash } +async function outputInkCodegen( + contracts: Record, + outputFolder: string, +) { + console.log("Generating ink! types") + + const contractsFolder = join(outputFolder, "contracts") + if (!existsSync(contractsFolder)) + await fs.mkdir(contractsFolder, { recursive: true }) + + const imports: string[] = [] + for (const [key, metadata] of Object.entries(contracts)) { + try { + const types = generateInkTypes( + getInkLookup(JSON.parse(await fs.readFile(metadata, "utf-8"))), + ) + await fs.writeFile(join(contractsFolder, `${key}.ts`), types) + imports.push(`export { descriptor as ${key} } from './${key}'`) + } catch (ex) { + console.error("Exception when generating descriptors for contract " + key) + console.error(ex) + } + } + + await fs.writeFile( + join(contractsFolder, `index.ts`), + imports.join("\n") + "\n", + ) + + fs.appendFile( + join(outputFolder, "index.ts"), + ` + export * as contracts from './contracts'; + `, + ) +} + async function compileCodegen(packageDir: string) { const srcDir = join(packageDir, "src") const outDir = join(packageDir, "dist") diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 92b5db1ba..ef53d3373 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,4 +1,5 @@ export * from "./add" export * from "./generate" +export * from "./ink" export * from "./remove" export * from "./update" diff --git a/packages/cli/src/commands/ink.ts b/packages/cli/src/commands/ink.ts new file mode 100644 index 000000000..a72ac09de --- /dev/null +++ b/packages/cli/src/commands/ink.ts @@ -0,0 +1,69 @@ +import { + defaultConfig, + papiFolder, + readPapiConfig, + writePapiConfig, +} from "@/papiConfig" +import { existsSync } from "node:fs" +import * as fs from "node:fs/promises" +import { join } from "node:path" +import { CommonOptions } from "./commonOptions" +import { generate } from "./generate" + +export interface InkAddOptions extends CommonOptions { + key?: string +} + +export const ink = { + async add(file: string, options: InkAddOptions) { + const metadata = JSON.parse(await fs.readFile(file, "utf-8")) + // Remove wasm blob if it's there + delete metadata.source?.wasm + + const key = options.key || metadata.contract.name + const config = (await readPapiConfig(options.config)) ?? defaultConfig + const inkConfig = (config.ink ||= {}) + if (key in inkConfig) { + console.warn(`Replacing existing ${key} config`) + } + + const contractsFolder = join(papiFolder, "contracts") + if (!existsSync(contractsFolder)) { + await fs.mkdir(contractsFolder, { recursive: true }) + } + const fileName = join(contractsFolder, key + ".json") + await fs.writeFile(fileName, JSON.stringify(metadata, null, 2)) + + inkConfig[key] = fileName + await writePapiConfig(options.config, config) + + if (!options.skipCodegen) { + generate({ + config: options.config, + }) + } + }, + async remove(key: string, options: CommonOptions) { + const config = (await readPapiConfig(options.config)) ?? defaultConfig + const inkConfig = (config.ink ||= {}) + if (!(key in inkConfig)) { + console.log(`${key} contract not found in config`) + return + } + + const fileName = inkConfig[key] + delete inkConfig[key] + + if (existsSync(fileName)) { + await fs.rm(fileName) + } + + await writePapiConfig(options.config, config) + + if (!options.skipCodegen) { + generate({ + config: options.config, + }) + } + }, +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index f55e753b5..6a9885426 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { getCli } from "./cli" -import { add, generate, remove, update } from "./commands" +import { add, generate, ink, remove, update } from "./commands" -const program = getCli({ add, generate, remove, update }) +const program = getCli({ add, generate, remove, update, ink }) program.parse() diff --git a/packages/cli/src/papiConfig.ts b/packages/cli/src/papiConfig.ts index 16c8292dc..d84fcf0b6 100644 --- a/packages/cli/src/papiConfig.ts +++ b/packages/cli/src/papiConfig.ts @@ -25,6 +25,7 @@ export type PapiConfig = { version: 0 descriptorPath: string entries: Record + ink?: Record } export const papiFolder = ".papi" diff --git a/packages/client/ink/package.json b/packages/client/ink/package.json new file mode 100644 index 000000000..48690b361 --- /dev/null +++ b/packages/client/ink/package.json @@ -0,0 +1,9 @@ +{ + "name": "polkadot-api_ink", + "types": "../dist/reexports/ink.d.ts", + "module": "../dist/esm/reexports/ink.mjs", + "import": "../dist/esm/reexports/ink.mjs", + "browser": "../dist/esm/reexports/ink.mjs", + "require": "../dist/reexports/ink.js", + "default": "../dist/reexports/ink.js" +} diff --git a/packages/client/package.json b/packages/client/package.json index f2d50d8bd..9d1c58933 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -155,6 +155,18 @@ "import": "./dist/esm/reexports/utils.mjs", "require": "./dist/reexports/utils.js", "default": "./dist/reexports/utils.js" + }, + "./ink": { + "types": "./dist/reexports/ink.d.ts", + "node": { + "import": "./dist/esm/reexports/ink.mjs", + "require": "./dist/reexports/ink.js", + "default": "./dist/reexports/ink.js" + }, + "module": "./dist/esm/reexports/ink.mjs", + "import": "./dist/esm/reexports/ink.mjs", + "require": "./dist/reexports/ink.js", + "default": "./dist/reexports/ink.js" } }, "main": "./dist/index.js", @@ -172,7 +184,8 @@ "ws-provider", "chains", "smoldot", - "utils" + "utils", + "ink" ], "scripts": { "build-core": "tsc --noEmit && rollup -c", @@ -187,6 +200,7 @@ }, "dependencies": { "@polkadot-api/cli": "workspace:*", + "@polkadot-api/ink-contracts": "workspace:*", "@polkadot-api/json-rpc-provider": "workspace:*", "@polkadot-api/logs-provider": "workspace:*", "@polkadot-api/polkadot-sdk-compat": "workspace:*", diff --git a/packages/client/src/cli.ts b/packages/client/src/cli.ts index 92f47c27f..c7765513b 100644 --- a/packages/client/src/cli.ts +++ b/packages/client/src/cli.ts @@ -1,10 +1,11 @@ #!/usr/bin/env node -import { add, generate, getCli, remove, update } from "@polkadot-api/cli" +import { add, generate, getCli, remove, update, ink } from "@polkadot-api/cli" getCli({ add, generate, remove, update, + ink, }).parse() diff --git a/packages/client/src/reexports/ink.ts b/packages/client/src/reexports/ink.ts new file mode 100644 index 000000000..b86bd8c25 --- /dev/null +++ b/packages/client/src/reexports/ink.ts @@ -0,0 +1 @@ +export { getInkClient, type InkDescriptors } from "@polkadot-api/ink-contracts" diff --git a/packages/client/sync-packages.mjs b/packages/client/sync-packages.mjs index 91b98f70e..8348af130 100644 --- a/packages/client/sync-packages.mjs +++ b/packages/client/sync-packages.mjs @@ -22,6 +22,11 @@ export const reexports = [ fromId("smoldot/node-worker"), fromId("smoldot/from-node-worker"), fromId("utils"), + [ + "ink", + "@polkadot-api/ink-contracts", + "{ getInkClient, type InkDescriptors }", + ], ] const packageJsonContent = JSON.parse(await readFile("./package.json", "utf-8")) @@ -41,7 +46,7 @@ await mkdir(reexportsDir, { recursive: true }) const newExports = { ".": packageJsonContent.exports["."], } -for (const [packageName, source] of reexports) { +for (const [packageName, source, symbols = "*"] of reexports) { const components = packageName.split("/") const packageNameWithGlob = components.length === 1 @@ -85,7 +90,7 @@ for (const [packageName, source] of reexports) { await writeFile( join(reexportsDir, `${fileName}.ts`), - `export * from "${source}"\n`, + `export ${symbols} from "${source}"\n`, ) } newExports["./package.json"] = packageJsonContent.exports["./packageJson"] diff --git a/packages/codegen/package.json b/packages/codegen/package.json index 3f6eb709a..471c5a2cd 100644 --- a/packages/codegen/package.json +++ b/packages/codegen/package.json @@ -43,6 +43,7 @@ "prepack": "pnpm run build" }, "dependencies": { + "@polkadot-api/ink-contracts": "workspace:*", "@polkadot-api/metadata-builders": "workspace:*", "@polkadot-api/metadata-compatibility": "workspace:*", "@polkadot-api/substrate-bindings": "workspace:*", diff --git a/packages/codegen/src/index.ts b/packages/codegen/src/index.ts index ba9a086c4..99347f132 100644 --- a/packages/codegen/src/index.ts +++ b/packages/codegen/src/index.ts @@ -4,3 +4,4 @@ export * from "./generate-descriptors" export * from "./generate-multiple-descriptors" export * from "./generate-docs-descriptors" export { knownTypesRepository } from "./known-types" +export { generateInkTypes } from "./ink-types" diff --git a/packages/codegen/src/ink-types.ts b/packages/codegen/src/ink-types.ts new file mode 100644 index 000000000..8a95be25d --- /dev/null +++ b/packages/codegen/src/ink-types.ts @@ -0,0 +1,310 @@ +import { + InkMetadataLookup, + Layout, + MessageParamSpec, + TypeSpec, +} from "@polkadot-api/ink-contracts" +import { + EnumVariant, + getInternalTypesBuilder, + isPrimitive, + LookupTypeNode, + StructField, + TupleField, + TypeNode, +} from "./internal-types" +import { getReusedNodes } from "./internal-types/reused-nodes" +import { + CodegenOutput, + generateTypescript, + mergeImports, + nativeNodeCodegen, + processPapiPrimitives, +} from "./internal-types/generate-typescript" +import { anonymizeImports, anonymizeType } from "./anonymize" + +export function generateInkTypes(lookup: InkMetadataLookup) { + const internalBuilder = getInternalTypesBuilder(lookup) + + const buildLayout = (node: Layout): TypeNode | LookupTypeNode => { + if ("root" in node) { + return buildLayout(node.root.layout) + } + if ("leaf" in node) { + return internalBuilder(node.leaf.ty) + } + if ("hash" in node) { + throw new Error("HashLayout not implemented") + } + if ("array" in node) { + return { + type: "array", + value: { + value: buildLayout(node.array.layout), + len: node.array.len, + }, + } + } + if ("struct" in node) { + return { + type: "struct", + value: node.struct.fields.map( + (field): StructField => ({ + label: field.name, + value: buildLayout(field.layout), + docs: [], + }), + ), + } + } + + const variants = Object.values(node.enum.variants) + if ( + node.enum.name === "Option" && + variants.length === 2 && + variants[0].name === "None" && + variants[1].name === "Some" + ) { + const inner: TypeNode = + variants[1].fields.length === 1 + ? buildLayout(variants[1].fields[0].layout) + : { + type: "tuple", + value: variants[1].fields.map( + (v): TupleField => ({ + value: buildLayout(v.layout), + docs: [], + }), + ), + } + return { + type: "option", + value: inner, + } + } + + return { + type: "enum", + value: Object.values(node.enum.variants).map( + (variant): EnumVariant => ({ + label: variant.name, + value: { + type: "struct", + value: variant.fields.map( + (field): StructField => ({ + label: field.name, + value: buildLayout(field.layout), + docs: [], + }), + ), + }, + docs: [], + }), + ), + } + } + const storageRoot = buildLayout(lookup.metadata.storage) + + const buildCallable = (callable: { + args: Array + returnType: TypeSpec + }) => { + const call: TypeNode = { + type: "struct", + value: callable.args.map((param) => ({ + label: param.label, + value: internalBuilder(param.type.type), + docs: [], + })), + } + + return { + call, + value: internalBuilder(callable.returnType.type), + } + } + const constructors = lookup.metadata.spec.constructors.map((ct) => ({ + ...ct, + types: buildCallable(ct), + })) + const messages = lookup.metadata.spec.messages.map((ct) => ({ + ...ct, + types: buildCallable(ct), + })) + + const event: TypeNode = { + type: "enum", + value: lookup.metadata.spec.events.map( + (evt): EnumVariant => ({ + label: evt.label, + value: { + type: "struct", + value: evt.args.map( + (arg): StructField => ({ + label: arg.label, + value: internalBuilder(arg.type.type), + docs: arg.docs, + }), + ), + }, + docs: evt.docs, + }), + ), + } + + const entryPoints: TypeNode[] = [ + storageRoot, + ...constructors.flatMap((v) => [v.types.call, v.types.value]), + ...messages.flatMap((v) => [v.types.call, v.types.value]), + ] + const rootNodes = getReusedNodes(entryPoints, new Set()) + + const assignedNames: Record = {} + let nextAnonymousId = 0 + const getName = (id: number) => { + if (!assignedNames[id]) { + assignedNames[id] = `T${nextAnonymousId++}` + } + return assignedNames[id] + } + + // Exclude primitive types from rootNodes + const filteredRootNodes = Array.from(rootNodes).filter( + (id) => !isPrimitive(internalBuilder(id)), + ) + + const types: Record = {} + const generateNodeType = (node: TypeNode | LookupTypeNode): CodegenOutput => { + const anonymize = (name: string) => `Anonymize<${name}>` + + const result = generateTypescript(node, (node, next): CodegenOutput => { + if (!("id" in node) || isPrimitive(node)) { + return ( + processPapiPrimitives(node, next, true) ?? + nativeNodeCodegen(node, next) + ) + } + if (types[node.id]) { + const cached = types[node.id] + return cached.name + ? { + code: anonymize(cached.name), + imports: { + types: new Set([cached.name]), + }, + } + : cached + } + + const assignedName = + (assignedNames[node.id] as any as null) ?? + (filteredRootNodes.includes(node.id) ? getName(node.id) : null) + if (assignedName) { + // Preassign the type to allow recursion + types[node.id] = { + code: "", + imports: {}, + name: assignedName, + } + } + + const result = + processPapiPrimitives(node, next, true) ?? nativeNodeCodegen(node, next) + if (assignedName) { + types[node.id].code = result.code + types[node.id].imports = result.imports + return { + code: anonymize(assignedName), + imports: { + types: new Set([assignedName]), + }, + } + } + types[node.id] = result + return types[node.id] + }) + + if ("id" in node && types[node.id]?.name) { + const name = types[node.id].name! + return { + code: anonymize(name), + imports: { + types: new Set([name]), + }, + } + } + return result + } + + const storageTypes = generateNodeType(storageRoot) + const createCallableDescriptor = ( + callables: Array<{ + label: string + docs: string[] + types: ReturnType + }>, + ) => + generateNodeType({ + type: "struct", + value: callables.map( + (callable): StructField => ({ + label: callable.label, + value: { + type: "struct", + value: [ + { + label: "message", + value: callable.types.call, + docs: [], + }, + { + label: "response", + value: callable.types.value, + docs: [], + }, + ], + }, + docs: callable.docs, + }), + ), + }) + const constructorsDescriptor = createCallableDescriptor(constructors) + const messagesDescriptor = createCallableDescriptor(messages) + const eventDescriptor = generateNodeType(event) + + const namedTypes = Object.entries(assignedNames) + .filter(([id]) => types[Number(id)]) + .map(([id, value]) => `type ${value} = ${types[Number(id)].code};`) + .join("\n") + + const clientImports = Array.from( + mergeImports([ + storageTypes.imports, + messagesDescriptor.imports, + constructorsDescriptor.imports, + eventDescriptor.imports, + ...Object.values(types).map((v) => v.imports), + { + client: new Set(anonymizeImports), + }, + ]).client, + ) + + const result = ` + import type { ${clientImports.join(", ")} } from 'polkadot-api'; + import type { InkDescriptors } from 'polkadot-api/ink'; + + ${anonymizeType} + + ${namedTypes} + + type StorageDescriptor = ${storageTypes.code}; + type MessagesDescriptor = ${messagesDescriptor.code}; + type ConstructorsDescriptor = ${constructorsDescriptor.code}; + type EventDescriptor = ${eventDescriptor.code}; + + export const descriptor: InkDescriptors = { metadata: ${JSON.stringify(lookup.metadata)} } as any; + ` + + return result +} diff --git a/packages/ink-contracts/src/dynamic-builders.ts b/packages/ink-contracts/src/dynamic-builders.ts index cb4426ea9..ab9e85e82 100644 --- a/packages/ink-contracts/src/dynamic-builders.ts +++ b/packages/ink-contracts/src/dynamic-builders.ts @@ -147,3 +147,5 @@ export const getInkDynamicBuilder = (metadataLookup: InkMetadataLookup) => { buildEvent, } } + +export type InkDynamicBuilder = ReturnType diff --git a/packages/ink-contracts/src/index.ts b/packages/ink-contracts/src/index.ts index a13414873..421455e10 100644 --- a/packages/ink-contracts/src/index.ts +++ b/packages/ink-contracts/src/index.ts @@ -1,2 +1,5 @@ export * from "./dynamic-builders" export * from "./get-lookup" +export * from "./metadata-types" +export * from "./ink-descriptors" +export * from "./ink-client" diff --git a/packages/ink-contracts/src/ink-client.ts b/packages/ink-contracts/src/ink-client.ts new file mode 100644 index 000000000..f81d82e0b --- /dev/null +++ b/packages/ink-contracts/src/ink-client.ts @@ -0,0 +1,144 @@ +import { Binary } from "@polkadot-api/substrate-bindings" +import { InkCallableDescriptor, InkDescriptors, Event } from "./ink-descriptors" +import { getInkLookup, InkMetadataLookup } from "./get-lookup" +import { getInkDynamicBuilder, InkDynamicBuilder } from "./dynamic-builders" + +export type InkCallableInterface = < + L extends string & keyof T, +>( + label: L, +) => { + encode: {} extends T[L]["message"] + ? (value?: T[L]["message"]) => Binary + : (value: T[L]["message"]) => Binary + decode: (value: { data: Binary }) => T[L]["response"] +} + +export interface InkStorageInterface { + rootKey: Binary + decodeRoot: (rootStorage: Binary) => S +} + +export type GenericEvent = + | { + type: "Contracts" + value: + | { + type: "ContractEmitted" + value: { + contract: string + data: Binary + } + } + | { type: string; value: unknown } + } + | { type: string; value: unknown } +export interface InkEventInterface { + decode: (value: { data: Binary }) => E + filter: ( + address: string, + events?: Array, + ) => E[] +} + +export interface InkClient< + D extends InkDescriptors< + unknown, + InkCallableDescriptor, + InkCallableDescriptor, + Event + >, +> { + constructor: InkCallableInterface + message: InkCallableInterface + storage: InkStorageInterface + event: InkEventInterface +} + +export const getInkClient = < + D extends InkDescriptors< + unknown, + InkCallableDescriptor, + InkCallableDescriptor, + Event + >, +>( + inkContract: D, +): InkClient => { + const lookup = getInkLookup(inkContract.metadata) + const builder = getInkDynamicBuilder(lookup) + + return { + constructor: buildCallable(builder.buildConstructor), + message: buildCallable(builder.buildMessage), + storage: buildStorage(lookup, builder.buildStorageRoot), + event: buildEvent(builder.buildEvent), + } +} + +const buildCallable = + ( + builder: + | InkDynamicBuilder["buildConstructor"] + | InkDynamicBuilder["buildMessage"], + ): InkCallableInterface => + (label: L) => { + const codecs = builder(label) + + return { + encode: (value?: T[L]["message"]) => + Binary.fromBytes(codecs.call.enc(value || {})), + decode: (response) => codecs.value.dec(response.data.asBytes()), + } + } + +const buildStorage = ( + lookup: InkMetadataLookup, + builder: InkDynamicBuilder["buildStorageRoot"], +): InkStorageInterface => { + const { metadata } = lookup + const metadataRootKey = Binary.fromHex(metadata.storage.root.root_key) + // On version 4-, the keys in the storage were in big-endian. + // For version 5+, the keys in storage are in scale, which is little-endian. + // https://use.ink/faq/migrating-from-ink-4-to-5#metadata-storage-keys-encoding-change + // https://github.com/use-ink/ink/pull/2048 + const rootKey = + Number(metadata.version) === 4 + ? Binary.fromBytes(metadataRootKey.asBytes().reverse()) + : metadataRootKey + return { + rootKey, + decodeRoot: (rootStorage) => builder().dec(rootStorage.asBytes()), + } +} + +const buildEvent = ( + decoder: InkDynamicBuilder["buildEvent"], +): InkEventInterface => { + const decode: InkEventInterface["decode"] = (value) => + decoder().dec(value.data.asBytes()) as E + + return { + decode, + filter: (address, events = []) => + events + .map((v) => ("event" in v ? v.event : v)) + .filter( + (v: any) => + v.type === "Contracts" && + v.value.type === "ContractEmitted" && + v.value.value.contract === address, + ) + .map((v: any) => { + try { + return decode(v.value.value) + } catch (ex) { + console.error( + `Contract ${address} emitted an incompatible event`, + v.value.value, + ) + throw ex + } + }), + } +} diff --git a/packages/ink-contracts/src/ink-descriptors.ts b/packages/ink-contracts/src/ink-descriptors.ts new file mode 100644 index 000000000..94c32613f --- /dev/null +++ b/packages/ink-contracts/src/ink-descriptors.ts @@ -0,0 +1,27 @@ +import { StringRecord } from "scale-ts" +import { InkMetadata } from "./metadata-types" + +export type Event = { type: string; value: unknown } + +export interface InkDescriptors< + S, + M extends InkCallableDescriptor, + C extends InkCallableDescriptor, + E extends Event, +> { + metadata: InkMetadata + __types: { + storage: S + messages: M + constructors: C + event: E + } +} + +export type InkCallableDescriptor = Record< + string, + { + message: StringRecord + response: StringRecord + } +> diff --git a/packages/ink-contracts/src/metadata-pjs-types.ts b/packages/ink-contracts/src/metadata-pjs-types.ts index d1b974dc7..76109ff1c 100644 --- a/packages/ink-contracts/src/metadata-pjs-types.ts +++ b/packages/ink-contracts/src/metadata-pjs-types.ts @@ -138,11 +138,12 @@ const entry = enhanceCodec( type: { def: CodecType path: CodecType + params?: Array<{ name: string; type: number | undefined }> } }) => ({ id: value.id, path: value.type.path, - params: [], + params: value.type.params ?? [], def: value.type.def, docs: [], }), @@ -151,6 +152,7 @@ const entry = enhanceCodec( type: { def: value.def, path: value.path, + params: value.params, }, }), ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc82250ef..99d1544b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: '@polkadot-api/codegen': specifier: workspace:* version: link:../codegen + '@polkadot-api/ink-contracts': + specifier: workspace:* + version: link:../ink-contracts '@polkadot-api/json-rpc-provider': specifier: workspace:* version: link:../json-rpc/json-rpc-provider @@ -301,6 +304,9 @@ importers: '@polkadot-api/cli': specifier: workspace:* version: link:../cli + '@polkadot-api/ink-contracts': + specifier: workspace:* + version: link:../ink-contracts '@polkadot-api/json-rpc-provider': specifier: workspace:* version: link:../json-rpc/json-rpc-provider @@ -359,6 +365,9 @@ importers: packages/codegen: dependencies: + '@polkadot-api/ink-contracts': + specifier: workspace:* + version: link:../ink-contracts '@polkadot-api/metadata-builders': specifier: workspace:* version: link:../metadata-builders