diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index d0202a4b6..56ffbf090 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -86,6 +86,7 @@ jobs: ./packages/nodejs/lib ./packages/common/grpc ./packages/nodejs/grpc + ./packages/ccd-js-gen/lib typecheck-examples: runs-on: ubuntu-22.04 @@ -158,6 +159,7 @@ jobs: lint: runs-on: ubuntu-22.04 + needs: build if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v3 @@ -167,6 +169,11 @@ jobs: node-version: ${{ env.NODE_VERSION }} cache: yarn + - name: Get build-debug + uses: ./.github/actions/download-artifact + with: + name: build-debug + - name: Install dependencies run: yarn install --immutable diff --git a/examples/ccd-js-gen/wCCD/client-tokenMetadata.ts b/examples/ccd-js-gen/wCCD/client-tokenMetadata.ts new file mode 100644 index 000000000..6692991b3 --- /dev/null +++ b/examples/ccd-js-gen/wCCD/client-tokenMetadata.ts @@ -0,0 +1,77 @@ +import { credentials } from '@grpc/grpc-js'; +import * as SDK from '@concordium/node-sdk'; +import meow from 'meow'; +import { parseEndpoint } from '../../shared/util'; + +// The generated module could be imported directly like below, +// but for this example it is imported dynamicly to improve +// the error message when not generated. +// import * as wCCDModule from './lib/wCCD'; + +const cli = meow( + ` + This example uses a generated smart contract client for the wCCD smart contract. + + Usage + $ yarn run-example [options] + + Required + --index, -i The index of the smart contract. Defaults to 2059, which is wCCD on testnet. + + Options + --help, -h Displays this message + --endpoint, -e Specify endpoint of a grpc2 interface of a Concordium node in the format "://
:". Defaults to 'http://localhost:20000' + --subindex, The subindex of the smart contract. Defaults to 0 +`, + { + importMeta: import.meta, + flags: { + endpoint: { + type: 'string', + alias: 'e', + default: 'http://localhost:20000', + }, + index: { + type: 'number', + alias: 'i', + default: 2059, + }, + subindex: { + type: 'number', + default: 0, + }, + }, + } +); + +const [address, port, scheme] = parseEndpoint(cli.flags.endpoint); +const grpcClient = SDK.createConcordiumClient( + address, + Number(port), + scheme === 'https' ? credentials.createSsl() : credentials.createInsecure() +); + +const contractAddress: SDK.ContractAddress = { + index: BigInt(cli.flags.index), + subindex: BigInt(cli.flags.subindex), +}; + +(async () => { + // Importing the generated smart contract module client. + /* eslint-disable import/no-unresolved */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const wCCDModule = await import('./lib/wCCD').catch((e) => { + /* eslint-enable import/no-unresolved */ + console.error( + '\nFailed to load the generated wCCD module, did you run the `generate` script?\n' + ); + throw e; + }); + + const parameter = '010000'; // First 2 bytes for number of tokens to query, 1 byte for the token ID. + const contract = new wCCDModule.Cis2WCCD(grpcClient, contractAddress); + + const responseHex = await contract.dryRun.tokenMetadata(parameter); + console.log({ responseHex }); +})(); diff --git a/examples/ccd-js-gen/wCCD/generate.ts b/examples/ccd-js-gen/wCCD/generate.ts new file mode 100644 index 000000000..888ae997a --- /dev/null +++ b/examples/ccd-js-gen/wCCD/generate.ts @@ -0,0 +1,73 @@ +import { credentials } from '@grpc/grpc-js'; +import * as SDK from '@concordium/node-sdk'; +import * as Gen from '@concordium/ccd-js-gen'; +import * as Path from 'node:path'; +import * as Url from 'node:url'; +import meow from 'meow'; +import { parseEndpoint } from '../../shared/util'; + +const cli = meow( + ` + This example fetches the wCCD smart contract module source from the chain and generates a typescript client for interacting with such a smart contract. + + Usage + $ yarn run-example [options] + + Required + --index, -i The index of the smart contract. Defaults to 2059, which is wCCD on Testnet. + + Options + --help, -h Displays this message + --endpoint, -e Specify endpoint of a grpc2 interface of a Concordium node in the format "address:port". Defaults to 'localhost:20000' + --subindex, The subindex of the smart contract. Defaults to 0 +`, + { + importMeta: import.meta, + flags: { + endpoint: { + type: 'string', + alias: 'e', + default: 'localhost:20000', + }, + index: { + type: 'number', + alias: 'i', + isRequired: true, + default: 2059, + }, + subindex: { + type: 'number', + default: 0, + }, + }, + } +); + +const [address, port] = parseEndpoint(cli.flags.endpoint); +const grpcClient = SDK.createConcordiumClient( + address, + Number(port), + credentials.createInsecure() +); + +const contractAddress: SDK.ContractAddress = { + index: BigInt(cli.flags.index), + subindex: BigInt(cli.flags.subindex), +}; + +(async () => { + console.info(`Fetching instance information for ${contractAddress.index}.`); + const info = await grpcClient.getInstanceInfo(contractAddress); + console.info( + `Fetching smart contract module source with reference '${info.sourceModule.moduleRef}'.` + ); + const moduleSource = await grpcClient.getModuleSource(info.sourceModule); + const mod = SDK.Module.fromModuleSource(moduleSource); + const filePath = Url.fileURLToPath(import.meta.url); + const outDir = Path.join(Path.dirname(filePath), 'lib'); + console.info(`Generating smart contract module client at '${outDir}'.`); + await Gen.generateContractClients(mod, 'wCCD', outDir, { + output: 'TypeScript', + }); + console.info('Code generation was successful.'); +})(); diff --git a/examples/package.json b/examples/package.json index c18e8b798..68896a721 100644 --- a/examples/package.json +++ b/examples/package.json @@ -2,6 +2,8 @@ "name": "@concordium/examples", "type": "module", "dependencies": { + "@concordium/ccd-js-gen": "workspace:^", + "@concordium/common-sdk": "workspace:^", "@concordium/node-sdk": "workspace:^", "@grpc/grpc-js": "^1.3.4", "@noble/ed25519": "^1.7.1", diff --git a/examples/shared/util.ts b/examples/shared/util.ts index a95689ac2..658665c15 100644 --- a/examples/shared/util.ts +++ b/examples/shared/util.ts @@ -9,11 +9,27 @@ export const parseAddress = (input: string): Base58String | ContractAddress => { return { index: BigInt(i), subindex: BigInt(si) }; }; -export const parseEndpoint = (endpoint: string): [string, number] => { +// Regular expression for matching the scheme prefix of a URL. +const schemeRegex = /^(\w+):\/\//; + +/** + * Parse endpoint information from a string, such as 'http://my-concordium-node:20000' + * @param endpoint String with information of an endpoint. + * @returns Triple with ['
', , '']. + */ +export const parseEndpoint = ( + endpoint: string +): [string, number, string | undefined] => { + const result = schemeRegex.exec(endpoint); + const matched = result?.[0]; + const scheme = result?.[1]; + + const noSchemeEndpoint = endpoint.substring(matched?.length ?? 0); + // Split endpoint on last colon - const lastColonIndex = endpoint.lastIndexOf(':'); - const address = endpoint.substring(0, lastColonIndex); - const port = Number(endpoint.substring(lastColonIndex + 1)); + const lastColonIndex = noSchemeEndpoint.lastIndexOf(':'); + const address = noSchemeEndpoint.substring(0, lastColonIndex); + const port = Number(noSchemeEndpoint.substring(lastColonIndex + 1)); - return [address, port]; + return [address, port, scheme]; }; diff --git a/package.json b/package.json index 0cad16afa..33d900797 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "./packages/common", "./packages/nodejs", "./packages/web", + "./packages/ccd-js-gen", "./examples" ] }, diff --git a/packages/ccd-js-gen/bin/ccd-js-gen.js b/packages/ccd-js-gen/bin/ccd-js-gen.js new file mode 100755 index 000000000..76a857b7c --- /dev/null +++ b/packages/ccd-js-gen/bin/ccd-js-gen.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +// eslint-disable-next-line @typescript-eslint/no-var-requires +const cli = require('../lib/src/cli.js'); +cli.main(); diff --git a/packages/ccd-js-gen/package.json b/packages/ccd-js-gen/package.json new file mode 100644 index 000000000..46bc80d56 --- /dev/null +++ b/packages/ccd-js-gen/package.json @@ -0,0 +1,52 @@ +{ + "name": "@concordium/ccd-js-gen", + "version": "1.0.0", + "description": "Generate JS clients for the Concordium Blockchain", + "bin": "bin/ccd-js-gen.js", + "main": "lib/src/lib.js", + "typings": "lib/src/lib.d.ts", + "scripts": { + "build": "tsc", + "build-dev": "yarn build", + "clean": "rm -r lib", + "lint": "eslint src/** bin/** --cache --ext .ts,.js --max-warnings 0", + "lint-fix": "yarn --silent lint --fix; exit 0", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "concordium", + "smart-contracts" + ], + "repository": { + "type": "git", + "url": "https://github.com/Concordium/concordium-node-sdk-js", + "directory": "packages/ccd-js-gen" + }, + "author": { + "name": "Concordium Software", + "email": "support@concordium.software", + "url": "https://concordium.com" + }, + "license": "Apache-2.0", + "dependencies": { + "@concordium/common-sdk": "9.3.0", + "buffer": "^6.0.3", + "commander": "^11.0.0", + "ts-morph": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^20.5.0", + "@typescript-eslint/eslint-plugin": "^4.28.1", + "@typescript-eslint/parser": "^4.28.1", + "eslint": "^7.29.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-import": "^2.23.4", + "eslint-plugin-prettier": "^3.4.0", + "prettier": "^2.3.2", + "typescript": "^4.3.5" + }, + "prettier": { + "singleQuote": true, + "tabWidth": 4 + } +} diff --git a/packages/ccd-js-gen/src/cli.ts b/packages/ccd-js-gen/src/cli.ts new file mode 100644 index 000000000..fe66d0c6a --- /dev/null +++ b/packages/ccd-js-gen/src/cli.ts @@ -0,0 +1,34 @@ +/* +This file contains code for building the command line inferface to the ccd-js-gen library. +*/ +import { Command } from 'commander'; +import packageJson from '../package.json'; +import * as lib from './lib'; + +/** Type representing the CLI options/arguments and needs to match the options set with commander.js */ +type Options = { + /** Smart contract module to generate clients from */ + module: string; + /** The output directory for the generated code */ + outDir: string; +}; + +// Main function, which is called be the executable script in `bin`. +export async function main(): Promise { + const program = new Command(); + program + .name(packageJson.name) + .description(packageJson.description) + .version(packageJson.version) + .requiredOption( + '-m, --module ', + 'Smart contract module to generate clients from' + ) + .requiredOption( + '-o, --out-dir ', + 'The output directory for the generated code' + ) + .parse(process.argv); + const options = program.opts(); + await lib.generateContractClientsFromFile(options.module, options.outDir); +} diff --git a/packages/ccd-js-gen/src/lib.ts b/packages/ccd-js-gen/src/lib.ts new file mode 100644 index 000000000..3a3d5c918 --- /dev/null +++ b/packages/ccd-js-gen/src/lib.ts @@ -0,0 +1,291 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as tsm from 'ts-morph'; +import * as SDK from '@concordium/common-sdk'; +import { Buffer } from 'buffer/'; + +/** + * Output options for the generated code. + * - 'TypeScript' Only produce a module in TypeScript. + * - 'JavaScript' Only produce a module in JavaScript. + * - 'TypedJavaScript' Produce a JavaScript module and TypeScript declarations. + * - 'Everything' Produce all of the above. + */ +export type OutputOptions = + | 'TypeScript' + | 'JavaScript' + | 'TypedJavaScript' + | 'Everything'; + +/** Options for generating clients */ +export type GenerateContractClientsOptions = { + /** Options for the output */ + output?: OutputOptions; +}; + +/** + * Generate smart contract client code for a given smart contract module file. + * @param modulePath Path to the smart contract module. + * @param outDirPath Path to the directory to use for the output. + * @param options Options for generating the clients. + * @throws If unable to: read provided file at `modulePath`, parse the provided smart contract module or write to provided directory `outDirPath`. + */ +export async function generateContractClientsFromFile( + modulePath: string, + outDirPath: string, + options: GenerateContractClientsOptions = {} +): Promise { + const fileBytes = await fs.readFile(modulePath).catch((e) => { + if ('code' in e && e.code === 'ENOENT') { + throw new Error(`No such module '${modulePath}'`); + } + throw e; + }); + const outputName = path.basename(modulePath, '.wasm.v1'); + const scModule = SDK.Module.fromRawBytes(Buffer.from(fileBytes)); + return generateContractClients(scModule, outputName, outDirPath, options); +} + +/** + * Generate smart contract client code for a given smart contract module. + * @param scModule Buffer with bytes for the smart contract module. + * @param outName Name for the output file. + * @param outDirPath Path to the directory to use for the output. + * @param options Options for generating the clients. + * @throws If unable to write to provided directory `outDirPath`. + */ +export async function generateContractClients( + scModule: SDK.Module, + outName: string, + outDirPath: string, + options: GenerateContractClientsOptions = {} +): Promise { + const outputOption = options.output ?? 'Everything'; + const moduleInterface = await scModule.parseModuleInterface(); + const outputFilePath = path.format({ + dir: outDirPath, + name: outName, + ext: '.ts', + }); + + const compilerOptions: tsm.CompilerOptions = { + outDir: outDirPath, + declaration: + outputOption === 'Everything' || outputOption === 'TypedJavaScript', + }; + const project = new tsm.Project({ compilerOptions }); + const sourceFile = project.createSourceFile(outputFilePath, '', { + overwrite: true, + }); + addModuleClients(sourceFile, moduleInterface); + if (outputOption === 'Everything' || outputOption === 'TypeScript') { + await project.save(); + } + if ( + outputOption === 'Everything' || + outputOption === 'JavaScript' || + outputOption === 'TypedJavaScript' + ) { + await project.emit(); + } +} + +/** Iterates a module interface adding code to the provided source file. */ +function addModuleClients( + sourceFile: tsm.SourceFile, + moduleInterface: SDK.ModuleInterface +) { + sourceFile.addImportDeclaration({ + namespaceImport: 'SDK', + moduleSpecifier: '@concordium/common-sdk', + }); + + for (const contract of moduleInterface.values()) { + const contractNameId = 'contractName'; + const genericContractId = 'genericContract'; + const grpcClientId = 'grpcClient'; + const contractAddressId = 'contractAddress'; + const dryRunId = 'dryRun'; + const contractClassId = toPascalCase(contract.contractName); + const contractDryRunClassId = `${contractClassId}DryRun`; + + const classDecl = sourceFile.addClass({ + docs: ['Smart contract client for a contract instance on chain.'], + isExported: true, + name: contractClassId, + properties: [ + { + docs: [ + 'Name of the smart contract supported by this client.', + ], + scope: tsm.Scope.Public, + isReadonly: true, + name: contractNameId, + type: 'string', + initializer: `'${contract.contractName}'`, + }, + { + docs: ['Generic contract client used internally.'], + scope: tsm.Scope.Private, + name: genericContractId, + type: 'SDK.Contract', + }, + { + docs: ['Dry run entrypoints of the smart contract.'], + scope: tsm.Scope.Public, + isReadonly: true, + name: dryRunId, + type: contractDryRunClassId, + }, + ], + }); + + const dryRunClassDecl = sourceFile.addClass({ + docs: [ + `Smart contract client for dry running messages to a contract instance of '${contract.contractName}' on chain.`, + ], + isExported: true, + name: contractDryRunClassId, + }); + + classDecl + .addConstructor({ + docs: ['Contruct a client for a contract instance on chain'], + parameters: [ + { + name: grpcClientId, + type: 'SDK.ConcordiumGRPCClient', + scope: tsm.Scope.Public, + }, + { + name: contractAddressId, + type: 'SDK.ContractAddress', + isReadonly: true, + scope: tsm.Scope.Public, + }, + ], + }) + .setBodyText( + `this.${genericContractId} = new SDK.Contract(${grpcClientId}, ${contractAddressId}, '${contract.contractName}'); +this.${dryRunId} = new ${contractDryRunClassId}(this.${genericContractId});` + ); + + dryRunClassDecl.addConstructor({ + docs: ['Contruct a client for a contract instance on chain'], + parameters: [ + { + name: genericContractId, + type: 'SDK.Contract', + scope: tsm.Scope.Private, + }, + ], + }); + + for (const entrypointName of contract.entrypointNames) { + const transactionMetadataId = 'transactionMetadata'; + const parameterId = 'parameter'; + const signerId = 'signer'; + classDecl + .addMethod({ + docs: [ + `Send an update-contract transaction to the '${entrypointName}' entrypoint of the '${contract.contractName}' contract. + +@param {SDK.ContractTransactionMetadata} ${transactionMetadataId} - Hex encoded parameter for entrypoint +@param {SDK.HexString} ${parameterId} - Hex encoded parameter for entrypoint +@param {SDK.AccountSigner} ${signerId} - The signer of the update contract transaction. + +@throws If the entrypoint is not successfully invoked. + +@returns {SDK.HexString} Transaction hash`, + ], + scope: tsm.Scope.Public, + name: toCamelCase(entrypointName), + parameters: [ + { + name: transactionMetadataId, + type: 'SDK.ContractTransactionMetadata', + }, + { + name: parameterId, + type: 'SDK.HexString', + }, + { + name: signerId, + type: 'SDK.AccountSigner', + }, + ], + returnType: 'Promise', + }) + .setBodyText( + `return this.${genericContractId}.createAndSendUpdateTransaction( + '${entrypointName}', + SDK.encodeHexString, + ${transactionMetadataId}, + ${parameterId}, + ${signerId} +);` + ); + const blockHashId = 'blockHash'; + dryRunClassDecl + .addMethod({ + docs: [ + `Dry run an update-contract transaction to the '${entrypointName}' entrypoint of the '${contract.contractName}' contract. + +@param {SDK.HexString} ${parameterId} - Hex encoded parameter for entrypoint +@param {SDK.HexString} [${blockHashId}] - Block hash of the block to invoke entrypoint at + +@throws If the entrypoint is not successfully invoked. + +returns {SDK.HexString} Hex encoded response`, + ], + scope: tsm.Scope.Public, + name: toCamelCase(entrypointName), + parameters: [ + { + name: parameterId, + type: 'SDK.HexString', + }, + { + name: blockHashId, + type: 'SDK.HexString', + hasQuestionToken: true, + }, + ], + returnType: 'Promise', + }) + .setBodyText( + `return this.${genericContractId}.invokeView( + '${entrypointName}', + SDK.encodeHexString, + (hex: SDK.HexString) => hex, + ${parameterId}, + ${blockHashId} +);` + ); + } + } +} + +/** Make the first character in a string uppercase */ +function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.substring(1); +} + +/** + * Convert a string in snake_case or kebab-case into camelCase. + * This is used to transform entrypoint names in the smart contract to follow formatting javascript convention. + */ +function toCamelCase(str: string): string { + return str + .split(/[-_]/g) + .map((word, index) => (index === 0 ? word : capitalize(word))) + .join(''); +} + +/** + * Convert a string in snake_case or kebab-case into PascalCase. + * This is used to transform contract names in the smart contract to follow formatting javascript convention. + */ +function toPascalCase(str: string): string { + return str.split(/[-_]/g).map(capitalize).join(''); +} diff --git a/packages/ccd-js-gen/tsconfig.eslint.json b/packages/ccd-js-gen/tsconfig.eslint.json new file mode 100644 index 000000000..b189885ac --- /dev/null +++ b/packages/ccd-js-gen/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src/**/*", "bin/**/*"] +} diff --git a/packages/ccd-js-gen/tsconfig.json b/packages/ccd-js-gen/tsconfig.json new file mode 100644 index 000000000..93480aa50 --- /dev/null +++ b/packages/ccd-js-gen/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig-base.json", + "include": ["src/**/*", "package.json"], + "compilerOptions": { + "outDir": "./lib", + "lib": ["ES2020", "dom"], // "dom" is only added to get the typings for the global variable `WebAssembly`. + "target": "ES2020", + "resolveJsonModule": true + } +} diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 7f6f5057c..23640d5a4 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -6,12 +6,15 @@ - `sendUpdateInstruction` to the gRPC Client. - `healthCheck` to the gRPC Client. +- `Module` class for functionality related to smart contract modules, such as parsing the WebAssembly and interface of the module. +- Smart contract related types `ContractName`, `EntrypointName` and helper functions `isInitName`, `isReceiveName`, `getContractNameFromInit` and `getNamesFromReceive`. ### Fixed - Added missing fields to `getBlockChainParameters` response. (rootKeys, level1Keys, level2Keys) ## 9.3.0 + ### Added - `sendRawAccountTransaction` to the gRPC Client. diff --git a/packages/common/src/contractHelpers.ts b/packages/common/src/contractHelpers.ts index 7c14c52cb..399815063 100644 --- a/packages/common/src/contractHelpers.ts +++ b/packages/common/src/contractHelpers.ts @@ -40,3 +40,69 @@ export const isEqualContractAddress = (a: ContractAddress) => (b: ContractAddress): boolean => a.index === b.index && a.subindex === b.subindex; + +/** The name of a smart contract. Note: This does _not_ including the 'init_' prefix. */ +export type ContractName = string; + +/** The name of an entrypoint exposed by a smart contract. Note: This does _not_ include the '.' prefix. */ +export type EntrypointName = string; + +/** Check that every character is an Ascii alpha, numeric or punctuation. */ +function isAsciiAlphaNumericPunctuation(string: string) { + for (let i = 0; i < string.length; i++) { + const charCode = string.charCodeAt(i); + if ( + (32 <= charCode && charCode <= 47) || // Punctuation ! to / + (48 <= charCode && charCode <= 57) || // Numeric + (58 <= charCode && charCode <= 64) || // Punctuation : to @ + (65 <= charCode && charCode <= 90) || // Uppercase alpha + (91 <= charCode && charCode <= 96) || // Punctuation [ to ` + (97 <= charCode && charCode <= 122) || // Lowercase alpha + (123 <= charCode && charCode <= 126) // Punctuation { to ~ + ) { + continue; + } else { + return false; + } + } + return true; +} + +/** Check if a string is a valid smart contract init name. */ +export function isInitName(string: string): boolean { + return ( + string.length <= 100 && + string.startsWith('init_') && + !string.includes('.') && + isAsciiAlphaNumericPunctuation(string) + ); +} + +/** Get the contract name from a string. Assumes the string is a valid init name. */ +export function getContractNameFromInit(initName: string): ContractName { + return initName.substring(5); +} + +/** Check if a string is a valid smart contract receive name. */ +export function isReceiveName(string: string): boolean { + return ( + string.length <= 100 && + string.includes('.') && + isAsciiAlphaNumericPunctuation(string) + ); +} + +/** Get the contract name and entrypoint name from a string. Assumes the string is a valid receive name. */ +export function getNamesFromReceive(receiveName: string): { + contractName: ContractName; + entrypointName: EntrypointName; +} { + const splitPoint = receiveName.indexOf('.'); + if (splitPoint === -1) { + throw new Error('Invalid receive name'); + } + return { + contractName: receiveName.substring(0, splitPoint), + entrypointName: receiveName.substring(splitPoint + 1), + }; +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index e17382a98..bf0a6723e 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -15,6 +15,7 @@ export { serializeAccountTransactionPayload, serializeCredentialDeploymentPayload, } from './serialization'; +export { encodeHexString } from './serializationHelpers'; export { sha256 }; export { CredentialRegistrationId } from './types/CredentialRegistrationId'; export { AccountAddress } from './types/accountAddress'; @@ -22,6 +23,7 @@ export { CcdAmount } from './types/ccdAmount'; export { TransactionExpiry } from './types/transactionExpiry'; export { DataBlob } from './types/DataBlob'; export { ModuleReference } from './types/moduleReference'; +export * from './types/Module'; export { VerifiablePresentation, reviveDateFromTimeStampAttribute, @@ -74,3 +76,4 @@ export * from './uleb128'; export * from './cis2'; export * from './cis0'; export * from './cis4'; +export * from './GenericContract'; diff --git a/packages/common/src/types/Module.ts b/packages/common/src/types/Module.ts new file mode 100644 index 000000000..d9723abce --- /dev/null +++ b/packages/common/src/types/Module.ts @@ -0,0 +1,136 @@ +import { ModuleReference } from './moduleReference'; +import * as H from '../contractHelpers'; +import { sha256 } from '../hash'; +import { Buffer } from 'buffer/'; +import { VersionedModuleSource } from '../types'; + +/** Interface of a smart contract containing the name of the contract and every entrypoint. */ +export type ContractInterface = { + /** The name of the smart contract. Note: This does _not_ including the 'init_' prefix. */ + contractName: H.ContractName; + /** A set of entrypoints exposed by the smart contract. Note: These do _not_ include the '.' prefix. */ + entrypointNames: Set; +}; + +/** Interface of a smart contract module containing the interface of every contract in the module. */ +export type ModuleInterface = Map; + +/** + * A versioned smart contract module. + */ +export class Module { + /** Reference to the parsed WebAssembly module. Used to reuse an already compiled wasm module. */ + private wasmModule: WebAssembly.Module | undefined; + /** Reference to the calculated module reference. Used to reuse an already calculated module ref. */ + private moduleRef: ModuleReference | undefined; + + private constructor( + /** The version of the smart contract module. */ + public version: 0 | 1, + /** Bytes for the WebAssembly module. */ + public moduleSource: Buffer + ) {} + + /** + * Construct a smart contract module object from bytes, potentially read from a file. + * @param bytes Bytes encoding a versioned smart contract module. + * @throws When provided bytes fails to be parsed or are using an unknown smart contract module version. + */ + public static fromRawBytes(bytes: Buffer): Module { + const version = bytes.readUInt32BE(0); + const sourceLength = bytes.readUInt32BE(4); + const moduleSource = bytes.subarray(8, 8 + sourceLength); + if (moduleSource.length !== sourceLength) { + throw new Error('Insufficient bytes provided for module.'); + } + if (version !== 0 && version !== 1) { + throw new Error( + `Unsupported module version ${version}, The only supported versions are 0 and 1.` + ); + } + return new Module(version, Buffer.from(moduleSource)); + } + + /** + * Contruct a smart contract module object from a versioned module source. + * @param versionedModule The versioned module. + */ + public static fromModuleSource( + versionedModule: VersionedModuleSource + ): Module { + return new Module(versionedModule.version, versionedModule.source); + } + + /** + * Calculate the module reference from the module source. + * A reference to the result is reused in future invocations of this method. + */ + public getModuleRef(): ModuleReference { + if (this.moduleRef === undefined) { + const prefix = Buffer.alloc(8); + prefix.writeUInt32BE(this.version, 0); + prefix.writeUInt32BE(this.moduleSource.length, 4); + const hash = sha256([prefix, this.moduleSource]); + this.moduleRef = ModuleReference.fromBytes(hash); + } + return this.moduleRef; + } + + /** + * Parse the WebAssembly module in the smart contract module. The parsed module is cached. + * A reference to the result is reused in future invocations of this method. + */ + public async getWasmModule(): Promise { + if (this.wasmModule === undefined) { + this.wasmModule = await WebAssembly.compile(this.moduleSource); + } + return this.wasmModule; + } + + /** + * Build a module interface based on exports from the WebAssembly module. + * @returns The interface of the smart contract module. + */ + public async parseModuleInterface(): Promise { + const map = new Map(); + const wasmModule = await this.getWasmModule(); + const wasmExports = WebAssembly.Module.exports(wasmModule); + + for (const exp of wasmExports) { + if (exp.kind !== 'function') { + continue; + } + if (H.isInitName(exp.name)) { + const contractName = H.getContractNameFromInit(exp.name); + getOrInsert(map, contractName, { + contractName: contractName, + entrypointNames: new Set(), + }); + } else if (H.isReceiveName(exp.name)) { + const parts = H.getNamesFromReceive(exp.name); + const entry = getOrInsert(map, parts.contractName, { + contractName: parts.contractName, + entrypointNames: new Set(), + }); + entry.entrypointNames.add(parts.entrypointName); + } + } + return map; + } +} + +/** + * Get a key from a map, if not present, insert a new value and return this. + * @param map The map to get or insert into. + * @param key The key to lookup or insert to. + * @param value The value to be inserted if nothing is present. + * @returns The value currently in the map or just insert into it. + */ +function getOrInsert(map: Map, key: K, value: V): V { + const current = map.get(key); + if (current !== undefined) { + return current; + } + map.set(key, value); + return value; +} diff --git a/yarn.lock b/yarn.lock index 7d43791f6..1c9e0d13c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1312,7 +1312,29 @@ __metadata: languageName: node linkType: hard -"@concordium/common-sdk@9.3.0, @concordium/common-sdk@workspace:packages/common": +"@concordium/ccd-js-gen@workspace:^, @concordium/ccd-js-gen@workspace:packages/ccd-js-gen": + version: 0.0.0-use.local + resolution: "@concordium/ccd-js-gen@workspace:packages/ccd-js-gen" + dependencies: + "@concordium/common-sdk": 9.3.0 + "@types/node": ^20.5.0 + "@typescript-eslint/eslint-plugin": ^4.28.1 + "@typescript-eslint/parser": ^4.28.1 + buffer: ^6.0.3 + commander: ^11.0.0 + eslint: ^7.29.0 + eslint-config-prettier: ^8.3.0 + eslint-plugin-import: ^2.23.4 + eslint-plugin-prettier: ^3.4.0 + prettier: ^2.3.2 + ts-morph: ^19.0.0 + typescript: ^4.3.5 + bin: + ccd-js-gen: bin/ccd-js-gen.js + languageName: unknown + linkType: soft + +"@concordium/common-sdk@9.3.0, @concordium/common-sdk@workspace:^, @concordium/common-sdk@workspace:packages/common": version: 0.0.0-use.local resolution: "@concordium/common-sdk@workspace:packages/common" dependencies: @@ -1356,6 +1378,8 @@ __metadata: version: 0.0.0-use.local resolution: "@concordium/examples@workspace:examples" dependencies: + "@concordium/ccd-js-gen": "workspace:^" + "@concordium/common-sdk": "workspace:^" "@concordium/node-sdk": "workspace:^" "@grpc/grpc-js": ^1.3.4 "@noble/ed25519": ^1.7.1 @@ -2180,6 +2204,18 @@ __metadata: languageName: node linkType: hard +"@ts-morph/common@npm:~0.20.0": + version: 0.20.0 + resolution: "@ts-morph/common@npm:0.20.0" + dependencies: + fast-glob: ^3.2.12 + minimatch: ^7.4.3 + mkdirp: ^2.1.6 + path-browserify: ^1.0.1 + checksum: eb02480971fbe045b4dd099d1ddb262d47d657197fefb73a4a2c89523975bfb0b23050207d49d19e853ef23bffdcb9d89a778f52f6b3385ae5bcf63322523700 + languageName: node + linkType: hard + "@tsconfig/node10@npm:^1.0.7": version: 1.0.9 resolution: "@tsconfig/node10@npm:1.0.9" @@ -2392,6 +2428,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^20.5.0": + version: 20.5.3 + resolution: "@types/node@npm:20.5.3" + checksum: fe67a0fd7402218bdf91523a2b1c2e41d619f7294b1a471e0a778b8bc7bb3fcf291aed12041bcbe9622d50a3d1295a9adea0e7e19bb9386a246bf66071404721 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.1": version: 2.4.1 resolution: "@types/normalize-package-data@npm:2.4.1" @@ -3632,6 +3675,13 @@ __metadata: languageName: node linkType: hard +"code-block-writer@npm:^12.0.0": + version: 12.0.0 + resolution: "code-block-writer@npm:12.0.0" + checksum: 9f6505a4d668c9131c6f3f686359079439e66d5f50c236614d52fcfa53aeb0bc615b2c6c64ef05b5511e3b0433ccfd9f7756ad40eb3b9298af6a7d791ab1981d + languageName: node + linkType: hard + "collect-v8-coverage@npm:^1.0.0": version: 1.0.1 resolution: "collect-v8-coverage@npm:1.0.1" @@ -3703,6 +3753,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^11.0.0": + version: 11.0.0 + resolution: "commander@npm:11.0.0" + checksum: 6621954e1e1d078b4991c1f5bbd9439ad37aa7768d6ab4842de1dbd4d222c8a27e1b8e62108b3a92988614af45031d5bb2a2aaa92951f4d0c934d1a1ac564bb4 + languageName: node + linkType: hard + "commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" @@ -4648,6 +4705,19 @@ __metadata: languageName: node linkType: hard +"fast-glob@npm:^3.2.12": + version: 3.3.1 + resolution: "fast-glob@npm:3.3.1" + dependencies: + "@nodelib/fs.stat": ^2.0.2 + "@nodelib/fs.walk": ^1.2.3 + glob-parent: ^5.1.2 + merge2: ^1.3.0 + micromatch: ^4.0.4 + checksum: b6f3add6403e02cf3a798bfbb1183d0f6da2afd368f27456010c0bc1f9640aea308243d4cb2c0ab142f618276e65ecb8be1661d7c62a7b4e5ba774b9ce5432e5 + languageName: node + linkType: hard + "fast-glob@npm:^3.2.9": version: 3.2.11 resolution: "fast-glob@npm:3.2.11" @@ -7065,7 +7135,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^7.1.3": +"minimatch@npm:^7.1.3, minimatch@npm:^7.4.3": version: 7.4.6 resolution: "minimatch@npm:7.4.6" dependencies: @@ -7194,6 +7264,15 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:^2.1.6": + version: 2.1.6 + resolution: "mkdirp@npm:2.1.6" + bin: + mkdirp: dist/cjs/src/bin.js + checksum: 8a1d09ffac585e55f41c54f445051f5bc33a7de99b952bb04c576cafdf1a67bb4bae8cb93736f7da6838771fbf75bc630430a3a59e1252047d2278690bd150ee + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" @@ -7652,6 +7731,13 @@ __metadata: languageName: node linkType: hard +"path-browserify@npm:^1.0.1": + version: 1.0.1 + resolution: "path-browserify@npm:1.0.1" + checksum: c6d7fa376423fe35b95b2d67990060c3ee304fc815ff0a2dc1c6c3cfaff2bd0d572ee67e18f19d0ea3bbe32e8add2a05021132ac40509416459fffee35200699 + languageName: node + linkType: hard + "path-exists@npm:^3.0.0": version: 3.0.0 resolution: "path-exists@npm:3.0.0" @@ -9051,6 +9137,16 @@ __metadata: languageName: node linkType: hard +"ts-morph@npm:^19.0.0": + version: 19.0.0 + resolution: "ts-morph@npm:19.0.0" + dependencies: + "@ts-morph/common": ~0.20.0 + code-block-writer: ^12.0.0 + checksum: c2546da8dcbdfd5f987ef39f30e52de5cc89391b7357ad45e7a09d05d2fd0cabb92c9d1cf14860ba27e9e3476707b854ba9671a1c8e0a925b6457305ef3e23ea + languageName: node + linkType: hard + "ts-node@npm:10.9": version: 10.9.1 resolution: "ts-node@npm:10.9.1"