diff --git a/examples/ccd-js-gen/wCCD/client-error-message.ts b/examples/ccd-js-gen/wCCD/client-error-message.ts new file mode 100644 index 000000000..aeabebed7 --- /dev/null +++ b/examples/ccd-js-gen/wCCD/client-error-message.ts @@ -0,0 +1,107 @@ +import { credentials } from '@grpc/grpc-js'; +import * as SDK from '@concordium/web-sdk'; +import { ConcordiumGRPCNodeClient } from '@concordium/web-sdk/nodejs'; +import meow from 'meow'; +import { parseEndpoint } from '../../shared/util.js'; + +// 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 wCCDContractClient from './lib/cis2_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 = new ConcordiumGRPCNodeClient( + address, + Number(port), + scheme === 'https' ? credentials.createSsl() : credentials.createInsecure() +); + +const contractAddress = SDK.ContractAddress.create( + cli.flags.index, + 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 wCCDContractClient = await import('./lib/wCCD_cis2_wCCD.js').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 wCCDTokenId = ''; + const fromAddress = SDK.AccountAddress.fromBuffer( + new Uint8Array(32).fill(0) + ); + const toAddress = SDK.AccountAddress.fromBuffer(new Uint8Array(32).fill(1)); + const parameter = [ + { + token_id: wCCDTokenId, + amount: 1000, + from: { type: 'Account', content: fromAddress }, + to: { type: 'Account', content: toAddress }, + data: '', + } as const, + ]; + const contract = await wCCDContractClient.create( + grpcClient, + contractAddress + ); + + const unauthorizedInvoker = SDK.AccountAddress.fromBase58( + '357EYHqrmMiJBmUZTVG5FuaMq4soAhgtgz6XNEAJaXHW3NHaUf' + ); + + const result = await wCCDContractClient.dryRunTransfer( + contract, + parameter, + { + invoker: unauthorizedInvoker, + } + ); + const errorMessage = wCCDContractClient.parseErrorMessageTransfer(result); + console.log('Transfer failed with error: ', errorMessage); +})(); diff --git a/examples/ccd-js-gen/wCCD/client-events.ts b/examples/ccd-js-gen/wCCD/client-events.ts new file mode 100644 index 000000000..9f156f42a --- /dev/null +++ b/examples/ccd-js-gen/wCCD/client-events.ts @@ -0,0 +1,113 @@ +import { credentials } from '@grpc/grpc-js'; +import { ConcordiumGRPCNodeClient } from '@concordium/web-sdk/nodejs'; +import * as SDK from '@concordium/web-sdk'; +import meow from 'meow'; +import { parseEndpoint } from '../../shared/util.js'; + +// 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 wCCDContractClient from './lib/cis2_wCCD.js'; + +const cli = meow( + ` + This example uses a generated smart contract client for the wCCD smart contract to display events. + + 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 = new ConcordiumGRPCNodeClient( + address, + Number(port), + scheme === 'https' ? credentials.createSsl() : credentials.createInsecure() +); + +const contractAddress = SDK.ContractAddress.create( + cli.flags.index, + 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 wCCDContractClient = await import('./lib/wCCD_cis2_wCCD.js').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; + } + ); + + // The sender of the transaction, i.e the one updating an operator. + const senderAccount = SDK.AccountAddress.fromBase58( + '357EYHqrmMiJBmUZTVG5FuaMq4soAhgtgz6XNEAJaXHW3NHaUf' + ); + // The parameter adding the wCCD contract as an operator of sender. + const parameter = [ + { + update: { type: 'Add' }, + operator: { type: 'Contract', content: contractAddress }, + } as const, + ]; + + // The client for the wCCD contract + const contract = await wCCDContractClient.create( + grpcClient, + contractAddress + ); + + // Dry run the update of operator. + const result = await wCCDContractClient.dryRunUpdateOperator( + contract, + parameter, + { invoker: senderAccount } + ); + if (result.tag !== 'success') { + throw new Error('Unexpected failure'); + } + for (const traceEvent of result.events) { + if ( + traceEvent.tag === SDK.TransactionEventTag.Updated || + traceEvent.tag === SDK.TransactionEventTag.Interrupted + ) { + for (const contractEvent of traceEvent.events) { + const parsed = wCCDContractClient.parseEvent(contractEvent); + console.log(parsed); + } + } + } +})(); diff --git a/examples/ccd-js-gen/wCCD/client-tokenMetadata.ts b/examples/ccd-js-gen/wCCD/client-tokenMetadata.ts index 50d3ffb59..262321ff4 100644 --- a/examples/ccd-js-gen/wCCD/client-tokenMetadata.ts +++ b/examples/ccd-js-gen/wCCD/client-tokenMetadata.ts @@ -62,15 +62,18 @@ const contractAddress = SDK.ContractAddress.create( /* eslint-disable import/no-unresolved */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - const wCCDContractClient = await import('./lib/cis2_wCCD.js').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 wCCDContractClient = await import('./lib/wCCD_cis2_wCCD.js').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 = SDK.Parameter.fromHexString('010000'); // First 2 bytes for number of tokens to query, 1 byte for the token ID. + const wCCDTokenId = ''; + const parameter = [wCCDTokenId]; const contract = await wCCDContractClient.create( grpcClient, contractAddress @@ -78,10 +81,12 @@ const contractAddress = SDK.ContractAddress.create( const result = await wCCDContractClient.dryRunTokenMetadata( contract, - SDK.AccountAddress.fromBase58( - '357EYHqrmMiJBmUZTVG5FuaMq4soAhgtgz6XNEAJaXHW3NHaUf' - ), parameter ); - console.log({ result }); + const returnValue = + wCCDContractClient.parseReturnValueTokenMetadata(result); + console.log( + 'The token metadata for wCCD can be found at: ', + returnValue?.[0].url + ); })(); diff --git a/examples/readme.md b/examples/readme.md index 6f0f58a9c..c06c8bb33 100644 --- a/examples/readme.md +++ b/examples/readme.md @@ -3,9 +3,11 @@ This is a collection of scripts/examples that utilizes the SDK. There are three directories with examples: +- `ccd-js-gen` containing examples with generate and using smart contract clients. - `client` containing examples that utilize the client to interact with a Concordium node. - `cis2` containing examples that helps interact with CIS-2 compliant smart contracts. +- `cis4` containing examples that helps interact with CIS-4 compliant smart contracts. - `common` that use various general functions from the library. To run an example call: diff --git a/packages/ccd-js-gen/bin/ccd-js-gen.js b/packages/ccd-js-gen/bin/ccd-js-gen.js index da66d7d08..295dba800 100755 --- a/packages/ccd-js-gen/bin/ccd-js-gen.js +++ b/packages/ccd-js-gen/bin/ccd-js-gen.js @@ -1,4 +1,3 @@ -#!/usr/bin/env node -// eslint-disable-next-line @typescript-eslint/no-var-requires +#!/usr/bin/env -S node --no-warnings import { main } from '../lib/src/cli.js'; main(); diff --git a/packages/ccd-js-gen/package.json b/packages/ccd-js-gen/package.json index 704805932..2f22c04c4 100644 --- a/packages/ccd-js-gen/package.json +++ b/packages/ccd-js-gen/package.json @@ -30,10 +30,14 @@ "url": "https://concordium.com" }, "license": "Apache-2.0", + "peerDependencies": { + "@concordium/web-sdk": "6.x" + }, "dependencies": { - "@concordium/web-sdk": "6.4.0", + "@concordium/web-sdk": "6.x", "buffer": "^6.0.3", "commander": "^11.0.0", + "sanitize-filename": "^1.6.3", "ts-morph": "^19.0.0" }, "devDependencies": { diff --git a/packages/ccd-js-gen/src/cli.ts b/packages/ccd-js-gen/src/cli.ts index 77708863c..a86a82a98 100644 --- a/packages/ccd-js-gen/src/cli.ts +++ b/packages/ccd-js-gen/src/cli.ts @@ -2,7 +2,7 @@ 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 packageJson from '../package.json' assert { type: 'json' }; import * as lib from './lib.js'; /** Type representing the CLI options/arguments and needs to match the options set with commander.js */ @@ -13,11 +13,11 @@ type Options = { outDir: string; }; -// Main function, which is called be the executable script in `bin`. +// Main function, which is called in the executable script in `bin`. export async function main(): Promise { const program = new Command(); program - .name(packageJson.name) + .name('ccd-js-gen') .description(packageJson.description) .version(packageJson.version) .requiredOption( @@ -30,5 +30,18 @@ export async function main(): Promise { ) .parse(process.argv); const options = program.opts(); - await lib.generateContractClientsFromFile(options.module, options.outDir); + console.log('Generating smart contract clients...'); + + const startTime = Date.now(); + await lib.generateContractClientsFromFile(options.module, options.outDir, { + onProgress(update) { + if (update.type === 'Progress') { + console.log( + `[${update.doneItems}/${update.totalItems}] ${update.spentTime}ms` + ); + } + }, + }); + + console.log(`Done in ${Date.now() - startTime}ms`); } diff --git a/packages/ccd-js-gen/src/lib.ts b/packages/ccd-js-gen/src/lib.ts index 394f6a081..1798c9a83 100644 --- a/packages/ccd-js-gen/src/lib.ts +++ b/packages/ccd-js-gen/src/lib.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as tsm from 'ts-morph'; import * as SDK from '@concordium/web-sdk'; +import sanitize from 'sanitize-filename'; /** * Output options for the generated code. @@ -16,17 +17,35 @@ export type OutputOptions = | 'TypedJavaScript' | 'Everything'; -/** Options for generating clients */ +/** Options for generating clients. */ export type GenerateContractClientsOptions = { - /** Options for the output */ + /** Options for the output. */ output?: OutputOptions; + /** Callback for getting notified on progress. */ + onProgress?: NotifyProgress; +}; + +/** Callback for getting notified on progress. */ +export type NotifyProgress = (progress: Progress) => void; + +/** + * Progress notification + */ +export type Progress = { + type: 'Progress'; + /** Total number of 'items' to be generated. */ + totalItems: number; + /** Number of 'items' generated at the time of this notification. */ + doneItems: number; + /** Number of milliseconds spent on the previous item. */ + spentTime: number; }; /** * 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. + * @param {string} modulePath Path to the smart contract module. + * @param {string} outDirPath Path to the directory to use for the output. + * @param {GenerateContractClientsOptions} [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( @@ -52,10 +71,10 @@ export async function generateContractClientsFromFile( /** * Generate smart contract client code for a given smart contract module. - * @param moduleSource 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. + * @param {SDK.VersionedModuleSource} moduleSource Buffer with bytes for the smart contract module. + * @param {string} outName Name for the output file. + * @param {string} outDirPath Path to the directory to use for the output. + * @param {GenerateContractClientsOptions} [options] Options for generating the clients. * @throws If unable to write to provided directory `outDirPath`. */ export async function generateContractClients( @@ -73,7 +92,13 @@ export async function generateContractClients( }; const project = new tsm.Project({ compilerOptions }); - await addModuleClient(project, outName, outDirPath, moduleSource); + await generateCode( + project, + outName, + outDirPath, + moduleSource, + options.onProgress + ); if (outputOption === 'Everything' || outputOption === 'TypeScript') { await project.save(); @@ -87,15 +112,38 @@ export async function generateContractClients( } } -/** Iterates a module interface building source files in the project. */ -async function addModuleClient( +/** + * Iterates a module interface building source files in the project. + * @param {tsm.Project} project The project to use for creating sourcefiles. + * @param {string} outModuleName The name for outputting the module client file. + * @param {string} outDirPath The directory to use for outputting files. + * @param {SDK.VersionedModuleSource} moduleSource The source of the smart contract module. + * @param {NotifyProgress} [notifyProgress] Callback to report progress. + */ +async function generateCode( project: tsm.Project, outModuleName: string, outDirPath: string, - moduleSource: SDK.VersionedModuleSource + moduleSource: SDK.VersionedModuleSource, + notifyProgress?: NotifyProgress ) { - const moduleInterface = await SDK.parseModuleInterface(moduleSource); - const moduleRef = await SDK.calculateModuleReference(moduleSource); + const [moduleInterface, moduleRef, rawModuleSchema] = await Promise.all([ + SDK.parseModuleInterface(moduleSource), + SDK.calculateModuleReference(moduleSource), + SDK.getEmbeddedModuleSchema(moduleSource), + ]); + + let totalItems = 0; + for (const contracts of moduleInterface.values()) { + totalItems += contracts.entrypointNames.size; + } + let doneItems = 0; + notifyProgress?.({ type: 'Progress', totalItems, doneItems, spentTime: 0 }); + + const moduleSchema = + rawModuleSchema === null + ? null + : SDK.parseRawModuleSchema(rawModuleSchema); const outputFilePath = path.format({ dir: outDirPath, @@ -105,11 +153,106 @@ async function addModuleClient( const moduleSourceFile = project.createSourceFile(outputFilePath, '', { overwrite: true, }); + const moduleClientId = 'moduleClient'; + const moduleClientType = `${toPascalCase(outModuleName)}Module`; + const internalModuleClientId = 'internalModuleClient'; + + generateModuleBaseCode( + moduleSourceFile, + moduleRef, + moduleClientId, + moduleClientType, + internalModuleClientId + ); + + for (const contract of moduleInterface.values()) { + const contractSchema: SDK.SchemaContractV3 | undefined = + moduleSchema?.module.contracts.get(contract.contractName); + + generactionModuleContractCode( + moduleSourceFile, + contract.contractName, + moduleClientId, + moduleClientType, + internalModuleClientId, + moduleRef, + contractSchema + ); + + const contractOutputFilePath = path.format({ + dir: outDirPath, + name: `${outModuleName}_${sanitize(contract.contractName, { + replacement: '-', + })}`, + ext: '.ts', + }); + const contractSourceFile = project.createSourceFile( + contractOutputFilePath, + '', + { + overwrite: true, + } + ); + + const contractClientType = `${toPascalCase( + contract.contractName + )}Contract`; + const contractClientId = 'contractClient'; + + generateContractBaseCode( + contractSourceFile, + contract.contractName, + contractClientId, + contractClientType, + moduleRef, + contractSchema + ); + + for (const entrypointName of contract.entrypointNames) { + const startTime = Date.now(); + const entrypointSchema = + contractSchema?.receive.get(entrypointName); + generateContractEntrypointCode( + contractSourceFile, + contract.contractName, + contractClientId, + contractClientType, + entrypointName, + entrypointSchema + ); + const spentTime = Date.now() - startTime; + doneItems++; + notifyProgress?.({ + type: 'Progress', + totalItems, + doneItems, + spentTime, + }); + } + } +} + +/** + * Generate code for a smart contract module client. + * @param moduleSourceFile The sourcefile of the module. + * @param moduleRef The module reference. + * @param moduleClientId The identifier to use for the module client. + * @param moduleClientType The identifier to use for the type of the module client. + * @param internalModuleClientId The identifier to use for referencing the internal module client. + */ +function generateModuleBaseCode( + moduleSourceFile: tsm.SourceFile, + moduleRef: SDK.ModuleReference.Type, + moduleClientId: string, + moduleClientType: string, + internalModuleClientId: string +) { + const moduleRefId = 'moduleReference'; + moduleSourceFile.addImportDeclaration({ namespaceImport: 'SDK', moduleSpecifier: '@concordium/web-sdk', }); - const moduleRefId = 'moduleReference'; moduleSourceFile.addVariableStatement({ isExported: true, @@ -120,15 +263,12 @@ async function addModuleClient( declarations: [ { name: moduleRefId, - type: 'SDK.ModuleReference', - initializer: `new SDK.ModuleReference('${moduleRef.moduleRef}')`, + type: 'SDK.ModuleReference.Type', + initializer: `/*#__PURE__*/ SDK.ModuleReference.fromHexString('${moduleRef.moduleRef}')`, }, ], }); - const moduleClientType = `${toPascalCase(outModuleName)}Module`; - const internalModuleClientId = 'internalModuleClient'; - const moduleClassDecl = moduleSourceFile.addClass({ docs: [ `Client for an on-chain smart contract module with module reference '${moduleRef.moduleRef}', can be used for instantiating new smart contract instances.`, @@ -182,14 +322,13 @@ async function addModuleClient( moduleSourceFile .addFunction({ docs: [ - `Construct a ${moduleClientType} client for interacting with a smart contract module on chain. -This function ensures the smart contract module is deployed on chain. - -@param {SDK.ConcordiumGRPCClient} ${grpcClientId} - The concordium node client to use. - -@throws If failing to communicate with the concordium node or if the module reference is not present on chain. - -@returns {${moduleClientType}} A module client ensured to be deployed on chain.`, + [ + `Construct a ${moduleClientType} client for interacting with a smart contract module on chain.`, + 'This function ensures the smart contract module is deployed on chain.', + `@param {SDK.ConcordiumGRPCClient} ${grpcClientId} - The concordium node client to use.`, + '@throws If failing to communicate with the concordium node or if the module reference is not present on chain.', + `@returns {${moduleClientType}} A module client ensured to be deployed on chain.`, + ].join('\n'), ], isExported: true, isAsync: true, @@ -203,20 +342,20 @@ This function ensures the smart contract module is deployed on chain. returnType: `Promise<${moduleClientType}>`, }) .setBodyText( - `const moduleClient = await SDK.ModuleClient.create(${grpcClientId}, ${moduleRefId}); -return new ${moduleClientType}(moduleClient);` + [ + `const moduleClient = await SDK.ModuleClient.create(${grpcClientId}, ${moduleRefId});`, + `return new ${moduleClientType}(moduleClient);`, + ].join('\n') ); moduleSourceFile .addFunction({ docs: [ - `Construct a ${moduleClientType} client for interacting with a smart contract module on chain. -It is up to the caller to ensure the module is deployed on chain. - -@param {SDK.ConcordiumGRPCClient} ${grpcClientId} - The concordium node client to use. - -@throws If failing to communicate with the concordium node. - -@returns {${moduleClientType}}`, + [ + `Construct a ${moduleClientType} client for interacting with a smart contract module on chain.`, + 'It is up to the caller to ensure the module is deployed on chain.', + `@param {SDK.ConcordiumGRPCClient} ${grpcClientId} - The concordium node client to use.`, + `@returns {${moduleClientType}}`, + ].join('\n'), ], isExported: true, name: 'createUnchecked', @@ -229,22 +368,22 @@ It is up to the caller to ensure the module is deployed on chain. returnType: `${moduleClientType}`, }) .setBodyText( - `const moduleClient = SDK.ModuleClient.createUnchecked(${grpcClientId}, ${moduleRefId}); -return new ${moduleClientType}(moduleClient);` + [ + `const moduleClient = SDK.ModuleClient.createUnchecked(${grpcClientId}, ${moduleRefId});`, + `return new ${moduleClientType}(moduleClient);`, + ].join('\n') ); - const moduleClientId = 'moduleClient'; - moduleSourceFile .addFunction({ docs: [ - `Construct a ${moduleClientType} client for interacting with a smart contract module on chain. -This function ensures the smart contract module is deployed on chain. - -@param {${moduleClientType}} ${moduleClientId} - The client of the on-chain smart contract module with referecence '${moduleRef.moduleRef}'. -@throws If failing to communicate with the concordium node or if the module reference is not present on chain. - -@returns {${moduleClientType}} A module client ensured to be deployed on chain.`, + [ + `Construct a ${moduleClientType} client for interacting with a smart contract module on chain.`, + 'This function ensures the smart contract module is deployed on chain.', + `@param {${moduleClientType}} ${moduleClientId} - The client of the on-chain smart contract module with referecence '${moduleRef.moduleRef}'.`, + '@throws If failing to communicate with the concordium node or if the module reference is not present on chain.', + `@returns {${moduleClientType}} A module client ensured to be deployed on chain.`, + ].join('\n'), ], isExported: true, name: 'checkOnChain', @@ -263,11 +402,12 @@ This function ensures the smart contract module is deployed on chain. moduleSourceFile .addFunction({ docs: [ - `Get the module source of the deployed smart contract module. - -@param {${moduleClientType}} ${moduleClientId} - The client of the on-chain smart contract module with referecence '${moduleRef.moduleRef}'. -@throws {SDK.RpcError} If failing to communicate with the concordium node or module not found. -@returns {SDK.VersionedModuleSource} Module source of the deployed smart contract module.`, + [ + 'Get the module source of the deployed smart contract module.', + `@param {${moduleClientType}} ${moduleClientId} - The client of the on-chain smart contract module with referecence '${moduleRef.moduleRef}'.`, + '@throws {SDK.RpcError} If failing to communicate with the concordium node or module not found.', + '@returns {SDK.VersionedModuleSource} Module source of the deployed smart contract module.', + ].join('\n'), ], isExported: true, name: 'getModuleSource', @@ -282,379 +422,687 @@ This function ensures the smart contract module is deployed on chain. .setBodyText( `return SDK.ModuleClient.getModuleSource(${moduleClientId}.${internalModuleClientId});` ); +} +/** + * Generate code in the module client specific to each contract in the module. + * @param {tsm.SourceFile} moduleSourceFile The sourcefile of the module client. + * @param {string} contractName The name of the contract. + * @param {string} moduleClientId The identifier for the module client. + * @param {string} moduleClientType The identifier for the type of the module client. + * @param {string} internalModuleClientId The identifier for the internal module client. + * @param {SDK.ModuleReference.Type} moduleRef The reference of the module. + * @param {SDK.SchemaContractV3} [contractSchema] The contract schema. + */ +function generactionModuleContractCode( + moduleSourceFile: tsm.SourceFile, + contractName: string, + moduleClientId: string, + moduleClientType: string, + internalModuleClientId: string, + moduleRef: SDK.ModuleReference.Type, + contractSchema?: SDK.SchemaContractV3 +) { const transactionMetadataId = 'transactionMetadata'; const parameterId = 'parameter'; const signerId = 'signer'; - for (const contract of moduleInterface.values()) { - moduleSourceFile - .addFunction({ - docs: [ - `Send transaction for instantiating a new '${contract.contractName}' smart contract instance. + const initParameter = createParameterCode( + parameterId, + contractSchema?.init?.parameter + ); + + const initParameterTypeId = `${toPascalCase(contractName)}Parameter`; -@param {${moduleClientType}} ${moduleClientId} - The client of the on-chain smart contract module with referecence '${moduleRef.moduleRef}'. -@param {SDK.ContractTransactionMetadata} ${transactionMetadataId} - Metadata related to constructing a transaction for a smart contract module. -@param {SDK.Parameter.Type} ${parameterId} - Parameter to provide as part of the transaction for the instantiation of a new smart contract contract. -@param {SDK.AccountSigner} ${signerId} - The signer of the update contract transaction. + const createInitParameterFnId = `create${toPascalCase( + contractName + )}Parameter`; -@throws If failing to communicate with the concordium node. + if (initParameter !== undefined) { + moduleSourceFile.addTypeAlias({ + docs: [ + `Parameter type transaction for instantiating a new '${contractName}' smart contract instance`, + ], + isExported: true, + name: initParameterTypeId, + type: initParameter.type, + }); -@returns {SDK.TransactionHash.Type}`, + moduleSourceFile + .addFunction({ + docs: [ + [ + `Construct Parameter type transaction for instantiating a new '${contractName}' smart contract instance.`, + `@param {${initParameterTypeId}} ${parameterId} The structured parameter to construct from.`, + '@returns {SDK.Parameter.Type} The smart contract parameter.', + ].join('\n'), ], isExported: true, - name: `instantiate${toPascalCase(contract.contractName)}`, + name: createInitParameterFnId, parameters: [ { - name: moduleClientId, - type: moduleClientType, - }, - { - name: transactionMetadataId, - type: 'SDK.ContractTransactionMetadata', - }, - { + type: initParameterTypeId, name: parameterId, - type: 'SDK.Parameter.Type', - }, - { - name: signerId, - type: 'SDK.AccountSigner', }, ], - returnType: 'Promise', + returnType: 'SDK.Parameter.Type', }) .setBodyText( - `return SDK.ModuleClient.createAndSendInitTransaction( - ${moduleClientId}.${internalModuleClientId}, - SDK.ContractName.fromStringUnchecked('${contract.contractName}'), - ${transactionMetadataId}, - ${parameterId}, - ${signerId} -);` + [...initParameter.code, `return ${initParameter.id}`].join('\n') ); + } - const contractOutputFilePath = path.format({ - dir: outDirPath, - name: contract.contractName, - ext: '.ts', - }); - const contractSourceFile = project.createSourceFile( - contractOutputFilePath, - '', - { - overwrite: true, - } - ); - - const moduleRefId = 'moduleReference'; - const grpcClientId = 'grpcClient'; - const contractNameId = 'contractName'; - const genericContractId = 'genericContract'; - const contractAddressId = 'contractAddress'; - const blockHashId = 'blockHash'; - const contractClientType = `${toPascalCase( - contract.contractName - )}Contract`; - - contractSourceFile.addImportDeclaration({ - namespaceImport: 'SDK', - moduleSpecifier: '@concordium/web-sdk', - }); - - contractSourceFile.addVariableStatement({ + moduleSourceFile + .addFunction({ docs: [ - 'The reference of the smart contract module supported by the provided client.', + [ + `Send transaction for instantiating a new '${contractName}' smart contract instance.`, + `@param {${moduleClientType}} ${moduleClientId} - The client of the on-chain smart contract module with referecence '${moduleRef.moduleRef}'.`, + `@param {SDK.ContractTransactionMetadata} ${transactionMetadataId} - Metadata related to constructing a transaction for a smart contract module.`, + ...(initParameter === undefined + ? [] + : [ + `@param {${initParameterTypeId}} ${parameterId} - Parameter to provide as part of the transaction for the instantiation of a new smart contract contract.`, + ]), + `@param {SDK.AccountSigner} ${signerId} - The signer of the update contract transaction.`, + '@throws If failing to communicate with the concordium node.', + '@returns {SDK.TransactionHash.Type}', + ].join('\n'), ], isExported: true, - declarationKind: tsm.VariableDeclarationKind.Const, - declarations: [ + name: `instantiate${toPascalCase(contractName)}`, + parameters: [ + { + name: moduleClientId, + type: moduleClientType, + }, + { + name: transactionMetadataId, + type: 'SDK.ContractTransactionMetadata', + }, + ...(initParameter === undefined + ? [] + : [ + { + name: parameterId, + type: initParameterTypeId, + }, + ]), { - name: moduleRefId, - type: 'SDK.ModuleReference', - initializer: `new SDK.ModuleReference('${moduleRef.moduleRef}')`, + name: signerId, + type: 'SDK.AccountSigner', }, ], - }); + returnType: 'Promise', + }) + .setBodyText( + [ + 'return SDK.ModuleClient.createAndSendInitTransaction(', + ` ${moduleClientId}.${internalModuleClientId},`, + ` SDK.ContractName.fromStringUnchecked('${contractName}'),`, + ` ${transactionMetadataId},`, + ...(initParameter === undefined + ? [] + : [` ${createInitParameterFnId}(${parameterId}),`]), + ` ${signerId}`, + ');', + ].join('\n') + ); +} - contractSourceFile.addVariableStatement({ - docs: ['Name of the smart contract supported by this client.'], - isExported: true, - declarationKind: tsm.VariableDeclarationKind.Const, - declarations: [ +/** + * Generate code for a smart contract instance client. + * @param {tsm.SourceFile} contractSourceFile The sourcefile of the contract client. + * @param {string} contractName The name of the smart contract. + * @param {string} contractClientId The identifier to use for the contract client. + * @param {string} contractClientType The identifier to use for the type of the contract client. + * @param {SDK.ModuleReference.Type} moduleRef The module reference. + * @param {SDK.SchemaContractV3} [contractSchema] The contract schema to use in the client. + */ +function generateContractBaseCode( + contractSourceFile: tsm.SourceFile, + contractName: string, + contractClientId: string, + contractClientType: string, + moduleRef: SDK.ModuleReference.Type, + contractSchema?: SDK.SchemaContractV3 +) { + const moduleRefId = 'moduleReference'; + const grpcClientId = 'grpcClient'; + const contractNameId = 'contractName'; + const genericContractId = 'genericContract'; + const contractAddressId = 'contractAddress'; + const blockHashId = 'blockHash'; + + contractSourceFile.addImportDeclaration({ + namespaceImport: 'SDK', + moduleSpecifier: '@concordium/web-sdk', + }); + + contractSourceFile.addVariableStatement({ + docs: [ + 'The reference of the smart contract module supported by the provided client.', + ], + isExported: true, + declarationKind: tsm.VariableDeclarationKind.Const, + declarations: [ + { + name: moduleRefId, + type: 'SDK.ModuleReference.Type', + initializer: `/*#__PURE__*/ SDK.ModuleReference.fromHexString('${moduleRef.moduleRef}')`, + }, + ], + }); + + contractSourceFile.addVariableStatement({ + docs: ['Name of the smart contract supported by this client.'], + isExported: true, + declarationKind: tsm.VariableDeclarationKind.Const, + declarations: [ + { + name: contractNameId, + type: 'SDK.ContractName.Type', + initializer: `/*#__PURE__*/ SDK.ContractName.fromStringUnchecked('${contractName}')`, + }, + ], + }); + + const contractClassDecl = contractSourceFile.addClass({ + docs: ['Smart contract client for a contract instance on chain.'], + name: contractClientType, + properties: [ + { + docs: [ + 'Having a private field prevents similar structured objects to be considered the same type (similar to nominal typing).', + ], + scope: tsm.Scope.Private, + name: '__nominal', + initializer: 'true', + }, + { + docs: ['The gRPC connection used by this client.'], + scope: tsm.Scope.Public, + isReadonly: true, + name: grpcClientId, + type: 'SDK.ConcordiumGRPCClient', + }, + { + docs: ['The contract address used by this client.'], + scope: tsm.Scope.Public, + isReadonly: true, + name: contractAddressId, + type: 'SDK.ContractAddress.Type', + }, + + { + docs: ['Generic contract client used internally.'], + scope: tsm.Scope.Public, + isReadonly: true, + name: genericContractId, + type: 'SDK.Contract', + }, + ], + }); + + contractClassDecl + .addConstructor({ + parameters: [ + { name: grpcClientId, type: 'SDK.ConcordiumGRPCClient' }, { - name: contractNameId, - type: 'SDK.ContractName.Type', - initializer: `SDK.ContractName.fromStringUnchecked('${contract.contractName}')`, + name: contractAddressId, + type: 'SDK.ContractAddress.Type', }, + { name: genericContractId, type: 'SDK.Contract' }, ], - }); + }) + .setBodyText( + [grpcClientId, contractAddressId, genericContractId] + .map((name) => `this.${name} = ${name};`) + .join('\n') + ); - const contractClassDecl = contractSourceFile.addClass({ - docs: ['Smart contract client for a contract instance on chain.'], - name: contractClientType, - properties: [ + contractSourceFile.addTypeAlias({ + docs: ['Smart contract client for a contract instance on chain.'], + name: 'Type', + isExported: true, + type: contractClientType, + }); + + contractSourceFile + .addFunction({ + docs: [ + [ + `Construct an instance of \`${contractClientType}\` for interacting with a '${contractName}' contract on chain.`, + 'Checking the information instance on chain.', + `@param {SDK.ConcordiumGRPCClient} ${grpcClientId} - The client used for contract invocations and updates.`, + `@param {SDK.ContractAddress.Type} ${contractAddressId} - Address of the contract instance.`, + `@param {SDK.BlockHash.Type} [${blockHashId}] - Hash of the block to check the information at. When not provided the last finalized block is used.`, + '@throws If failing to communicate with the concordium node or if any of the checks fails.', + `@returns {${contractClientType}}`, + ].join('\n'), + ], + isExported: true, + isAsync: true, + name: 'create', + parameters: [ { - docs: [ - 'Having a private field prevents similar structured objects to be considered the same type (similar to nominal typing).', - ], - scope: tsm.Scope.Private, - name: '__nominal', - initializer: 'true', + name: grpcClientId, + type: 'SDK.ConcordiumGRPCClient', }, { - docs: ['The gRPC connection used by this client.'], - scope: tsm.Scope.Public, - isReadonly: true, + name: contractAddressId, + type: 'SDK.ContractAddress.Type', + }, + { + name: blockHashId, + hasQuestionToken: true, + type: 'SDK.BlockHash.Type', + }, + ], + returnType: `Promise<${contractClientType}>`, + }) + .setBodyText( + [ + `const ${genericContractId} = new SDK.Contract(${grpcClientId}, ${contractAddressId}, ${contractNameId});`, + `await ${genericContractId}.checkOnChain({ moduleReference: ${moduleRefId}, blockHash: ${blockHashId} });`, + `return new ${contractClientType}(`, + ` ${grpcClientId},`, + ` ${contractAddressId},`, + ` ${genericContractId}`, + ');', + ].join('\n') + ); + + contractSourceFile + .addFunction({ + docs: [ + [ + `Construct the \`${contractClientType}\` for interacting with a '${contractName}' contract on chain.`, + 'Without checking the instance information on chain.', + `@param {SDK.ConcordiumGRPCClient} ${grpcClientId} - The client used for contract invocations and updates.`, + `@param {SDK.ContractAddress.Type} ${contractAddressId} - Address of the contract instance.`, + `@returns {${contractClientType}}`, + ].join('\n'), + ], + isExported: true, + name: 'createUnchecked', + parameters: [ + { name: grpcClientId, type: 'SDK.ConcordiumGRPCClient', }, { - docs: ['The contract address used by this client.'], - scope: tsm.Scope.Public, - isReadonly: true, name: contractAddressId, type: 'SDK.ContractAddress.Type', }, + ], + returnType: contractClientType, + }) + .setBodyText( + [ + `const ${genericContractId} = new SDK.Contract(${grpcClientId}, ${contractAddressId}, ${contractNameId});`, + `return new ${contractClientType}(`, + ` ${grpcClientId},`, + ` ${contractAddressId},`, + ` ${genericContractId},`, + ');', + ].join('\n') + ); + contractSourceFile + .addFunction({ + docs: [ + [ + 'Check if the smart contract instance exists on the blockchain and whether it uses a matching contract name and module reference.', + `@param {${contractClientType}} ${contractClientId} The client for a '${contractName}' smart contract instance on chain.`, + `@param {SDK.BlockHash.Type} [${blockHashId}] A optional block hash to use for checking information on chain, if not provided the last finalized will be used.`, + '@throws {SDK.RpcError} If failing to communicate with the concordium node or if any of the checks fails.', + ].join('\n'), + ], + isExported: true, + name: 'checkOnChain', + parameters: [ { - docs: ['Generic contract client used internally.'], - scope: tsm.Scope.Public, - isReadonly: true, - name: genericContractId, - type: 'SDK.Contract', + name: contractClientId, + type: contractClientType, + }, + { + name: blockHashId, + hasQuestionToken: true, + type: 'SDK.BlockHash.Type', }, ], + returnType: 'Promise', + }) + .setBodyText( + `return ${contractClientId}.${genericContractId}.checkOnChain({moduleReference: ${moduleRefId}, blockHash: ${blockHashId} });` + ); + + const eventParameterId = 'event'; + const eventParameterTypeId = 'Event'; + const eventParser = parseEventCode(eventParameterId, contractSchema?.event); + if (eventParser !== undefined) { + contractSourceFile.addTypeAlias({ + docs: [`Contract event type for the '${contractName}' contract.`], + isExported: true, + name: eventParameterTypeId, + type: eventParser.type, }); - contractClassDecl - .addConstructor({ + contractSourceFile + .addFunction({ + docs: [ + [ + `Parse the contract events logged by the '${contractName}' contract.`, + `@param {SDK.ContractEvent.Type} ${eventParameterId} The unparsed contract event.`, + `@returns {${eventParameterTypeId}} The structured contract event.`, + ].join('\n'), + ], + isExported: true, + name: 'parseEvent', parameters: [ - { name: grpcClientId, type: 'SDK.ConcordiumGRPCClient' }, { - name: contractAddressId, - type: 'SDK.ContractAddress.Type', + name: eventParameterId, + type: 'SDK.ContractEvent.Type', }, - { name: genericContractId, type: 'SDK.Contract' }, ], + returnType: eventParameterTypeId, }) .setBodyText( - [grpcClientId, contractAddressId, genericContractId] - .map((name) => `this.${name} = ${name};`) - .join('\n') + [...eventParser.code, `return ${eventParser.id};`].join('\n') ); + } +} +/** + * Generate contract client code for each entrypoint. + * @param {tsm.SourceFile} contractSourceFile The sourcefile of the contract. + * @param {string} contractName The name of the contract. + * @param {string} contractClientId The identifier to use for the contract client. + * @param {string} contractClientType The identifier to use for the type of the contract client. + * @param {string} entrypointName The name of the entrypoint. + * @param {SDK.SchemaFunctionV2} [entrypointSchema] The schema to use for the entrypoint. + */ +function generateContractEntrypointCode( + contractSourceFile: tsm.SourceFile, + contractName: string, + contractClientId: string, + contractClientType: string, + entrypointName: string, + entrypointSchema?: SDK.SchemaFunctionV2 +) { + const invokeMetadataId = 'invokeMetadata'; + const parameterId = 'parameter'; + const transactionMetadataId = 'transactionMetadata'; + const signerId = 'signer'; + const genericContractId = 'genericContract'; + const blockHashId = 'blockHash'; + + const receiveParameter = createParameterCode( + parameterId, + entrypointSchema?.parameter + ); + + const receiveParameterTypeId = `${toPascalCase(entrypointName)}Parameter`; + + const createReceiveParameterFnId = `create${toPascalCase( + entrypointName + )}Parameter`; + + if (receiveParameter !== undefined) { contractSourceFile.addTypeAlias({ - docs: ['Smart contract client for a contract instance on chain.'], - name: 'Type', + docs: [ + `Parameter type for update transaction for '${entrypointName}' entrypoint of the '${contractName}' contract.`, + ], isExported: true, - type: contractClientType, + name: receiveParameterTypeId, + type: receiveParameter.type, }); contractSourceFile .addFunction({ docs: [ - `Construct an instance of \`${contractClientType}\` for interacting with a '${contract.contractName}' contract on chain. -Checking the information instance on chain. - -@param {SDK.ConcordiumGRPCClient} ${grpcClientId} - The client used for contract invocations and updates. -@param {SDK.ContractAddress.Type} ${contractAddressId} - Address of the contract instance. -@param {string} [${blockHashId}] - Hash of the block to check the information at. When not provided the last finalized block is used. - -@throws If failing to communicate with the concordium node or if any of the checks fails. - -@returns {${contractClientType}}`, + [ + `Construct Parameter for update transactions for '${entrypointName}' entrypoint of the '${contractName}' contract.`, + `@param {${receiveParameterTypeId}} ${parameterId} The structured parameter to construct from.`, + '@returns {SDK.Parameter.Type} The smart contract parameter.', + ].join('\n'), ], isExported: true, - isAsync: true, - name: 'create', + name: createReceiveParameterFnId, parameters: [ { - name: grpcClientId, - type: 'SDK.ConcordiumGRPCClient', - }, - { - name: contractAddressId, - type: 'SDK.ContractAddress.Type', - }, - { - name: blockHashId, - hasQuestionToken: true, - type: 'SDK.BlockHash.Type', + type: receiveParameterTypeId, + name: parameterId, }, ], - returnType: `Promise<${contractClientType}>`, + returnType: 'SDK.Parameter.Type', }) .setBodyText( - `const ${genericContractId} = new SDK.Contract(${grpcClientId}, ${contractAddressId}, ${contractNameId}); -await ${genericContractId}.checkOnChain({ moduleReference: ${moduleRefId}, blockHash: ${blockHashId} }); -return new ${contractClientType}( - ${grpcClientId}, - ${contractAddressId}, - ${genericContractId} -);` + [ + ...receiveParameter.code, + `return ${receiveParameter.id};`, + ].join('\n') ); + } + + contractSourceFile + .addFunction({ + docs: [ + [ + `Send an update-contract transaction to the '${entrypointName}' entrypoint of the '${contractName}' contract.`, + `@param {${contractClientType}} ${contractClientId} The client for a '${contractName}' smart contract instance on chain.`, + `@param {SDK.ContractTransactionMetadata} ${transactionMetadataId} - Metadata related to constructing a transaction for a smart contract.`, + ...(receiveParameter === undefined + ? [] + : [ + `@param {${receiveParameterTypeId}} ${parameterId} - Parameter to provide the smart contract entrypoint as part of the transaction.`, + ]), + `@param {SDK.AccountSigner} ${signerId} - The signer of the update contract transaction.`, + '@throws If the entrypoint is not successfully invoked.', + '@returns {SDK.TransactionHash.Type} Hash of the transaction.', + ].join('\n'), + ], + isExported: true, + name: `send${toPascalCase(entrypointName)}`, + parameters: [ + { + name: contractClientId, + type: contractClientType, + }, + { + name: transactionMetadataId, + type: 'SDK.ContractTransactionMetadata', + }, + ...(receiveParameter === undefined + ? [] + : [ + { + name: parameterId, + type: receiveParameterTypeId, + }, + ]), + { + name: signerId, + type: 'SDK.AccountSigner', + }, + ], + returnType: 'Promise', + }) + .setBodyText( + [ + `return ${contractClientId}.${genericContractId}.createAndSendUpdateTransaction(`, + ` SDK.EntrypointName.fromStringUnchecked('${entrypointName}'),`, + ' SDK.Parameter.toBuffer,', + ` ${transactionMetadataId},`, + ...(receiveParameter === undefined + ? [] + : [` ${createReceiveParameterFnId}(${parameterId}),`]), + ` ${signerId}`, + ');', + ].join('\n') + ); + + contractSourceFile + .addFunction({ + docs: [ + [ + `Dry-run an update-contract transaction to the '${entrypointName}' entrypoint of the '${contractName}' contract.`, + `@param {${contractClientType}} ${contractClientId} The client for a '${contractName}' smart contract instance on chain.`, + `@param {SDK.ContractAddress.Type | SDK.AccountAddress.Type} ${invokeMetadataId} - The address of the account or contract which is invoking this transaction.`, + ...(receiveParameter === undefined + ? [] + : [ + `@param {${receiveParameterTypeId}} ${parameterId} - Parameter to provide the smart contract entrypoint as part of the transaction.`, + ]), + `@param {SDK.BlockHash.Type} [${blockHashId}] - Optional block hash allowing for dry-running the transaction at the end of a specific block.`, + '@throws {SDK.RpcError} If failing to communicate with the concordium node or if any of the checks fails.', + '@returns {SDK.InvokeContractResult} The result of invoking the smart contract instance.', + ].join('\n'), + ], + isExported: true, + name: `dryRun${toPascalCase(entrypointName)}`, + parameters: [ + { + name: contractClientId, + type: contractClientType, + }, + ...(receiveParameter === undefined + ? [] + : [ + { + name: parameterId, + type: receiveParameterTypeId, + }, + ]), + { + name: invokeMetadataId, + type: 'SDK.ContractInvokeMetadata', + initializer: '{}', + }, + { + name: blockHashId, + hasQuestionToken: true, + type: 'SDK.BlockHash.Type', + }, + ], + returnType: 'Promise', + }) + .setBodyText( + [ + `return ${contractClientId}.${genericContractId}.dryRun.invokeMethod(`, + ` SDK.EntrypointName.fromStringUnchecked('${entrypointName}'),`, + ` ${invokeMetadataId},`, + ' SDK.Parameter.toBuffer,', + ...(receiveParameter === undefined + ? [] + : [` ${createReceiveParameterFnId}(${parameterId}),`]), + ` ${blockHashId}`, + ');', + ].join('\n') + ); + + const invokeResultId = 'invokeResult'; + const returnValueTokens = parseReturnValueCode( + `${invokeResultId}.returnValue`, + entrypointSchema?.returnValue + ); + if (returnValueTokens !== undefined) { + const returnValueTypeId = `ReturnValue${toPascalCase(entrypointName)}`; + + contractSourceFile.addTypeAlias({ + docs: [ + `Return value for dry-running update transaction for '${entrypointName}' entrypoint of the '${contractName}' contract.`, + ], + isExported: true, + name: returnValueTypeId, + type: returnValueTokens.type, + }); contractSourceFile .addFunction({ docs: [ - `Construct the \`${contractClientType}\` for interacting with a '${contract.contractName}' contract on chain. -Without checking the instance information on chain. - -@param {SDK.ConcordiumGRPCClient} ${grpcClientId} - The client used for contract invocations and updates. -@param {SDK.ContractAddress.Type} ${contractAddressId} - Address of the contract instance. - -@returns {${contractClientType}}`, + [ + `Get and parse the return value from dry-running update transaction for '${entrypointName}' entrypoint of the '${contractName}' contract.`, + 'Returns undefined if the result is not successful.', + '@param {SDK.InvokeContractResult} invokeResult The result from dry-running the transaction.', + `@returns {${returnValueTypeId} | undefined} The structured return value or undefined if result was not a success.`, + ].join('\n'), ], isExported: true, - name: 'createUnchecked', + name: `parseReturnValue${toPascalCase(entrypointName)}`, parameters: [ { - name: grpcClientId, - type: 'SDK.ConcordiumGRPCClient', - }, - { - name: contractAddressId, - type: 'SDK.ContractAddress.Type', + name: invokeResultId, + type: 'SDK.InvokeContractResult', }, ], - returnType: contractClientType, + returnType: `${returnValueTypeId} | undefined`, }) .setBodyText( - `const ${genericContractId} = new SDK.Contract(${grpcClientId}, ${contractAddressId}, ${contractNameId}); - return new ${contractClientType}( - ${grpcClientId}, - ${contractAddressId}, - ${genericContractId}, - );` + [ + `if (${invokeResultId}.tag !== 'success') {`, + ' return undefined;', + '}', + `if (${invokeResultId}.returnValue === undefined) {`, + " throw new Error('Unexpected missing \\'returnValue\\' in result of invocation. Client expected a V1 smart contract.');", + '}', + ...returnValueTokens.code, + `return ${returnValueTokens.id};`, + ].join('\n') ); + } + + const errorMessageTokens = parseReturnValueCode( + `${invokeResultId}.returnValue`, + entrypointSchema?.error + ); + if (errorMessageTokens !== undefined) { + const errorMessageTypeId = `ErrorMessage${toPascalCase( + entrypointName + )}`; + + contractSourceFile.addTypeAlias({ + docs: [ + `Error message for dry-running update transaction for '${entrypointName}' entrypoint of the '${contractName}' contract.`, + ], + isExported: true, + name: errorMessageTypeId, + type: errorMessageTokens.type, + }); - const contractClientId = 'contractClient'; contractSourceFile .addFunction({ docs: [ - `Check if the smart contract instance exists on the blockchain and whether it uses a matching contract name and module reference. - -@param {${contractClientType}} ${contractClientId} The client for a '${contract.contractName}' smart contract instance on chain. -@param {SDK.BlockHash.Type} [${blockHashId}] A optional block hash to use for checking information on chain, if not provided the last finalized will be used. - -@throws {SDK.RpcError} If failing to communicate with the concordium node or if any of the checks fails.`, + [ + `Get and parse the error message from dry-running update transaction for '${entrypointName}' entrypoint of the '${contractName}' contract.`, + 'Returns undefined if the result is not a failure.', + '@param {SDK.InvokeContractResult} invokeResult The result from dry-running the transaction.', + `@returns {${errorMessageTypeId} | undefined} The structured error message or undefined if result was not a failure or failed for other reason than contract rejectedReceive.`, + ].join('\n'), ], isExported: true, - name: 'checkOnChain', + name: `parseErrorMessage${toPascalCase(entrypointName)}`, parameters: [ { - name: contractClientId, - type: contractClientType, - }, - { - name: blockHashId, - hasQuestionToken: true, - type: 'SDK.BlockHash.Type', + name: invokeResultId, + type: 'SDK.InvokeContractResult', }, ], - returnType: 'Promise', + returnType: `${errorMessageTypeId} | undefined`, }) .setBodyText( - `return ${contractClientId}.${genericContractId}.checkOnChain({moduleReference: ${moduleRefId}, blockHash: ${blockHashId} });` + [ + `if (${invokeResultId}.tag !== 'failure' || ${invokeResultId}.reason.tag !== 'RejectedReceive') {`, + ' return undefined;', + '}', + `if (${invokeResultId}.returnValue === undefined) {`, + " throw new Error('Unexpected missing \\'returnValue\\' in result of invocation. Client expected a V1 smart contract.');", + '}', + ...errorMessageTokens.code, + `return ${errorMessageTokens.id}`, + ].join('\n') ); - - const invokerId = 'invoker'; - - for (const entrypointName of contract.entrypointNames) { - contractSourceFile - .addFunction({ - docs: [ - `Send an update-contract transaction to the '${entrypointName}' entrypoint of the '${contract.contractName}' contract. - -@param {${contractClientType}} ${contractClientId} The client for a '${contract.contractName}' smart contract instance on chain. -@param {SDK.ContractTransactionMetadata} ${transactionMetadataId} - Metadata related to constructing a transaction for a smart contract. -@param {SDK.Parameter.Type} ${parameterId} - Parameter to provide the smart contract entrypoint as part of the transaction. -@param {SDK.AccountSigner} ${signerId} - The signer of the update contract transaction. - -@throws If the entrypoint is not successfully invoked. - -@returns {SDK.TransactionHash.Type} Transaction hash`, - ], - isExported: true, - name: `send${toPascalCase(entrypointName)}`, - parameters: [ - { - name: contractClientId, - type: contractClientType, - }, - { - name: transactionMetadataId, - type: 'SDK.ContractTransactionMetadata', - }, - { - name: parameterId, - type: 'SDK.Parameter.Type', - }, - { - name: signerId, - type: 'SDK.AccountSigner', - }, - ], - returnType: 'Promise', - }) - .setBodyText( - `return ${contractClientId}.${genericContractId}.createAndSendUpdateTransaction( - SDK.EntrypointName.fromStringUnchecked('${entrypointName}'), - SDK.Parameter.toBuffer, - ${transactionMetadataId}, - ${parameterId}, - ${signerId} -);` - ); - - contractSourceFile - .addFunction({ - docs: [ - `Dry-run an update-contract transaction to the '${entrypointName}' entrypoint of the '${contract.contractName}' contract. - -@param {${contractClientType}} ${contractClientId} The client for a '${contract.contractName}' smart contract instance on chain. -@param {SDK.ContractAddress.Type | SDK.AccountAddress.Type} ${invokerId} - The address of the account or contract which is invoking this transaction. -@param {SDK.Parameter.Type} ${parameterId} - Parameter to include in the transaction for the smart contract entrypoint. -@param {SDK.BlockHash.Type} [${blockHashId}] - Optional block hash allowing for dry-running the transaction at the end of a specific block. - -@throws {SDK.RpcError} If failing to communicate with the concordium node or if any of the checks fails. - -@returns {SDK.InvokeContractResult} The result of invoking the smart contract instance.`, - ], - isExported: true, - name: `dryRun${toPascalCase(entrypointName)}`, - parameters: [ - { - name: contractClientId, - type: contractClientType, - }, - { - name: invokerId, - type: 'SDK.ContractAddress.Type | SDK.AccountAddress.Type', - }, - { - name: parameterId, - type: 'SDK.Parameter.Type', - }, - { - name: blockHashId, - hasQuestionToken: true, - type: 'SDK.BlockHash.Type', - }, - ], - returnType: 'Promise', - }) - .setBodyText( - `return ${contractClientId}.${genericContractId}.dryRun.invokeMethod( - SDK.EntrypointName.fromStringUnchecked('${entrypointName}'), - ${invokerId}, - SDK.Parameter.toBuffer, - ${parameterId}, - ${blockHashId} -);` - ); - } } } @@ -670,3 +1118,948 @@ function capitalize(str: string): string { function toPascalCase(str: string): string { return str.split(/[-_]/g).map(capitalize).join(''); } + +/** Type information from the schema. */ +type SchemaNativeType = { + /** The type to provide for a given schema type. */ + nativeType: string; + /** The type in the Schema JSON format. */ + jsonType: string; + /** + * Provided the identifier for the input (of the above type), this generates + * tokens for converting it into Schema JSON format. + */ + nativeToJson: (nativeId: string) => { code: string[]; id: string }; + /** + * Provided the identifier for the input (Schema JSON format), this generates + * tokens for converting it into a native type (the above type). + */ + jsonToNative: (jsonId: string) => { code: string[]; id: string }; +}; + +/** + * From a schema type construct a 'native' type, a type of the expected JSON format and converter functions + * between this native type and the JSON format expected when serializing using a schema. + * + * @param {SDK.SchemaType} schemaType The schema type + * @returns {SchemaNativeType} native type, JSON type and converters. + */ +function schemaAsNativeType(schemaType: SDK.SchemaType): SchemaNativeType { + switch (schemaType.type) { + case 'Unit': + return { + nativeType: '"Unit"', + jsonType: '[]', + jsonToNative() { + return { code: [], id: '[]' }; + }, + nativeToJson() { + return { code: [], id: '"Unit"' }; + }, + }; + case 'Bool': + return { + nativeType: 'boolean', + jsonType: 'boolean', + nativeToJson(nativeId) { + return { code: [], id: nativeId }; + }, + jsonToNative(jsonId) { + return { code: [], id: jsonId }; + }, + }; + case 'U8': + case 'U16': + case 'U32': + case 'I8': + case 'I16': + case 'I32': + return { + nativeType: 'number', + jsonType: 'number', + nativeToJson(id) { + return { code: [], id }; + }, + jsonToNative(id) { + return { code: [], id }; + }, + }; + case 'U64': + case 'I64': + return { + nativeType: 'number | bigint', + jsonType: 'bigint', + nativeToJson(id) { + const resultId = idGenerator('number'); + return { + code: [`const ${resultId} = BigInt(${id});`], + id: resultId, + }; + }, + jsonToNative(id) { + return { code: [], id }; + }, + }; + case 'U128': + case 'I128': + return { + nativeType: 'number | bigint', + jsonType: 'string', + nativeToJson(id) { + const resultId = idGenerator('number'); + return { + code: [`const ${resultId} = BigInt(${id}).toString();`], + id: resultId, + }; + }, + jsonToNative(id) { + return { code: [], id: `BigInt(${id})` }; + }, + }; + case 'Amount': + return { + nativeType: 'SDK.CcdAmount.Type', + jsonType: 'SDK.CcdAmount.SchemaValue', + nativeToJson(id) { + const resultId = idGenerator('amount'); + return { + code: [ + `const ${resultId} = SDK.CcdAmount.toSchemaValue(${id});`, + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('amount'); + return { + code: [ + `const ${resultId} = SDK.CcdAmount.fromSchemaValue(${id});`, + ], + id: resultId, + }; + }, + }; + case 'AccountAddress': + return { + nativeType: 'SDK.AccountAddress.Type', + jsonType: 'SDK.AccountAddress.SchemaValue', + nativeToJson(id) { + const resultId = idGenerator('accountAddress'); + return { + code: [ + `const ${resultId} = SDK.AccountAddress.toSchemaValue(${id});`, + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('accountAddress'); + return { + code: [ + `const ${resultId} = SDK.AccountAddress.fromSchemaValue(${id});`, + ], + id: resultId, + }; + }, + }; + case 'ContractAddress': + return { + nativeType: 'SDK.ContractAddress.Type', + jsonType: 'SDK.ContractAddress.SchemaValue', + nativeToJson(id) { + const resultId = idGenerator('contractAddress'); + return { + code: [ + `const ${resultId} = SDK.ContractAddress.toSchemaValue(${id});`, + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('contractAddress'); + return { + code: [ + `const ${resultId} = SDK.ContractAddress.fromSchemaValue(${id});`, + ], + id: resultId, + }; + }, + }; + case 'Timestamp': + return { + nativeType: 'SDK.Timestamp.Type', + jsonType: 'SDK.Timestamp.SchemaValue', + nativeToJson(id) { + const resultId = idGenerator('timestamp'); + return { + code: [ + `const ${resultId} = SDK.Timestamp.toSchemaValue(${id});`, + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('timestamp'); + return { + code: [ + `const ${resultId} = SDK.Timestamp.fromSchemaValue(${id});`, + ], + id: resultId, + }; + }, + }; + case 'Duration': + return { + nativeType: 'SDK.Duration.Type', + jsonType: 'SDK.Duration.SchemaValue', + nativeToJson(id) { + const resultId = idGenerator('duration'); + return { + code: [ + `const ${resultId} = SDK.Duration.toSchemaValue(${id});`, + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('duration'); + return { + code: [ + `const ${resultId} = SDK.Duration.fromSchemaValue(${id});`, + ], + id: resultId, + }; + }, + }; + case 'Pair': + const first = schemaAsNativeType(schemaType.first); + const second = schemaAsNativeType(schemaType.second); + + return { + nativeType: `[${first.nativeType}, ${second.nativeType}]`, + jsonType: `[${first.jsonType}, ${second.jsonType}]`, + nativeToJson(id) { + const resultId = idGenerator('pair'); + const firstTokens = first.nativeToJson(`${id}[0]`); + const secondTokens = second.nativeToJson(`${id}[1]`); + return { + code: [ + ...firstTokens.code, + ...secondTokens.code, + `const ${resultId}: ${this.jsonType} = [${firstTokens.id}, ${secondTokens.id}];`, + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('pair'); + const firstTokens = first.jsonToNative(`${id}[0]`); + const secondTokens = second.jsonToNative(`${id}[1]`); + return { + code: [ + ...firstTokens.code, + ...secondTokens.code, + `const ${resultId}: ${this.nativeType} = [${firstTokens.id}, ${secondTokens.id}];`, + ], + id: resultId, + }; + }, + }; + case 'List': { + const item = schemaAsNativeType(schemaType.item); + return { + nativeType: `Array<${item.nativeType}>`, + jsonType: `Array<${item.jsonType}>`, + nativeToJson(id) { + const resultId = idGenerator('list'); + const itemId = idGenerator('item'); + const tokens = item.nativeToJson(itemId); + // Check if any mapping is needed. + if (tokens.id === itemId && tokens.code.length === 0) { + return { + code: [], + id, + }; + } + return { + code: [ + `const ${resultId} = ${id}.map((${itemId}) => {`, + ...tokens.code, + `return ${tokens.id};`, + '});', + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('list'); + const itemId = idGenerator('item'); + const tokens = item.jsonToNative(itemId); + // Check if any mapping is needed. + if (tokens.id === itemId && tokens.code.length === 0) { + return { + code: [], + id, + }; + } + return { + code: [ + `const ${resultId} = ${id}.map((${itemId}) => {`, + ...tokens.code, + `return ${tokens.id};`, + '});', + ], + id: resultId, + }; + }, + }; + } + case 'Set': { + const item = schemaAsNativeType(schemaType.item); + return { + nativeType: `Set<${item.nativeType}>`, + jsonType: `Array<${item.jsonType}>`, + nativeToJson(id) { + const resultId = idGenerator('set'); + const valueId = idGenerator('value'); + const valuesId = idGenerator('values'); + const valueTokens = item.nativeToJson(valueId); + return { + code: [ + `const ${valuesId} = [...${id}.values()]..map((${valueId}) => {`, + ...valueTokens.code, + `return ${valueTokens.id};`, + '});', + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('set'); + const valueId = idGenerator('value'); + const valuesId = idGenerator('values'); + const valueTokens = item.jsonToNative(valueId); + return { + code: [ + `const ${valuesId} = ${id}.map((${valueId}) => {`, + ...valueTokens.code, + `return ${valueTokens.id}; `, + '});', + `const ${resultId} = new Set(${valuesId});`, + ], + id: resultId, + }; + }, + }; + } + case 'Map': { + const key = schemaAsNativeType(schemaType.key); + const value = schemaAsNativeType(schemaType.value); + return { + nativeType: `Map<${key.nativeType}, ${value.nativeType}>`, + jsonType: `[${key.jsonType}, ${value.jsonType}][]`, + nativeToJson(id) { + const resultId = idGenerator('map'); + const keyId = idGenerator('key'); + const valueId = idGenerator('value'); + const keyTokens = key.nativeToJson(keyId); + const valueTokens = value.nativeToJson(valueId); + + return { + code: [ + `const ${resultId}: ${this.jsonType} = [...${id}.entries()].map(([${keyId}, ${valueId}]) => {`, + ...keyTokens.code, + ...valueTokens.code, + ` return [${keyTokens.id}, ${valueTokens.id}];`, + '});', + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('map'); + const entriesId = idGenerator('entries'); + const keyId = idGenerator('key'); + const valueId = idGenerator('value'); + const keyTokens = key.jsonToNative(keyId); + const valueTokens = value.jsonToNative(valueId); + return { + code: [ + `const ${entriesId} = ${id}.map(([${keyId}, ${valueId}]) => {`, + ...keyTokens.code, + ...valueTokens.code, + `return [${keyTokens.id}, ${valueTokens.id}];`, + '});', + `const ${resultId}: ${this.nativeType} = Map.fromEntries(${entriesId});`, + ], + id: resultId, + }; + }, + }; + } + case 'Array': { + const item = schemaAsNativeType(schemaType.item); + return { + nativeType: `[${new Array(schemaType.size) + .fill(item.nativeType) + .join(', ')}]`, + jsonType: `[${new Array(schemaType.size) + .fill(item.jsonType) + .join(', ')}]`, + nativeToJson(id) { + const resultId = idGenerator('array'); + const itemId = idGenerator('item'); + const tokens = item.nativeToJson(itemId); + // Check if any mapping is needed. + if (tokens.id === itemId && tokens.code.length === 0) { + return { + code: [], + id, + }; + } + + return { + code: [ + `const ${resultId} = ${id}.map((${itemId}) => {`, + ...tokens.code, + ` return ${tokens.id};`, + '});', + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('array'); + const itemId = idGenerator('item'); + const tokens = item.jsonToNative(itemId); + // Check if any mapping is needed. + if (tokens.id === itemId && tokens.code.length === 0) { + return { + code: [], + id, + }; + } + return { + code: [ + `const ${resultId} = ${id}.map((${itemId}: any) => {`, + ...tokens.code, + ` return ${tokens.id};`, + '});', + ], + id: resultId, + }; + }, + }; + } + case 'Struct': + return fieldToTypeAndMapper(schemaType.fields); + + case 'Enum': + case 'TaggedEnum': { + const variants = + schemaType.type === 'Enum' + ? schemaType.variants + : [...schemaType.variants.values()]; + + const variantFieldSchemas = variants.map((variant) => ({ + name: variant.name, + ...fieldToTypeAndMapper(variant.fields), + })); + + const variantTypes = variantFieldSchemas.map((variantSchema) => + variantSchema.nativeType === '"no-fields"' + ? `{ type: '${variantSchema.name}'}` + : `{ type: '${variantSchema.name}', content: ${variantSchema.nativeType} }` + ); + + const variantJsonTypes = variantFieldSchemas.map( + (variantSchema) => + `{'${variantSchema.name}' : ${variantSchema.jsonType} }` + ); + + return { + nativeType: variantTypes.join(' | '), + jsonType: variantJsonTypes.join(' | '), + nativeToJson(id) { + const resultId = idGenerator('match'); + + const variantCases = variantFieldSchemas.flatMap( + (variantSchema) => { + const tokens = variantSchema.nativeToJson( + `${id}.content` + ); + return [ + ` case '${variantSchema.name}':`, + ...tokens.code, + ` ${resultId} = { ${defineProp( + variantSchema.name, + tokens.id + )} };`, + ' break;', + ]; + } + ); + return { + code: [ + `let ${resultId}: ${this.jsonType};`, + `switch (${id}.type) {`, + ...variantCases, + '}', + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('match'); + //const variantKeyId = idGenerator('variantKey'); + + const variantIfStatements = variantFieldSchemas.map( + (variantFieldSchema) => { + const variantId = idGenerator('variant'); + const variantTokens = + variantFieldSchema.jsonToNative(variantId); + return [ + `if ('${variantFieldSchema.name}' in ${id}) {`, + ...(variantTokens.id === '"no-fields"' + ? [ + ` ${resultId} = {`, + ` type: '${variantFieldSchema.name}',`, + ' };', + ] + : [ + ` const ${variantId} = ${accessProp( + id, + variantFieldSchema.name + )};`, + ...variantTokens.code, + ` ${resultId} = {`, + ` type: '${variantFieldSchema.name}',`, + ` content: ${variantTokens.id},`, + ' };', + ]), + '}', + ].join('\n'); + } + ); + return { + code: [ + `let ${resultId}: ${this.nativeType};`, + variantIfStatements.join(' else '), + ' else {', + ' throw new Error("Unexpected enum variant");', + '}', + ], + id: resultId, + }; + }, + }; + } + case 'String': + return { + nativeType: 'string', + jsonType: 'string', + nativeToJson(id) { + return { code: [], id }; + }, + jsonToNative(id) { + return { + code: [], + id, + }; + }, + }; + case 'ContractName': + return { + nativeType: 'SDK.ContractName.Type', + jsonType: 'SDK.ContractName.SchemaType', + nativeToJson(id) { + const resultId = idGenerator('contractName'); + return { + code: [ + `const ${resultId} = SDK.ContractName.toSchemaValue(${id});`, + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('contractName'); + return { + code: [ + `const ${resultId} = SDK.ContractName.fromSchemaValue(${id});`, + ], + id: resultId, + }; + }, + }; + case 'ReceiveName': + return { + nativeType: 'SDK.ReceiveName.Type', + jsonType: 'SDK.ReceiveName.SchemaType', + nativeToJson(id) { + const resultId = idGenerator('receiveName'); + return { + code: [ + `const ${resultId} = SDK.ReceiveName.toSchemaValue(${id});`, + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('receiveName'); + return { + code: [ + `const ${resultId} = SDK.ReceiveName.fromSchemaValue(${id});`, + ], + id: resultId, + }; + }, + }; + case 'ULeb128': + case 'ILeb128': + return { + nativeType: 'number | bigint', + jsonType: 'bigint', + nativeToJson(id) { + const resultId = idGenerator('number'); + return { + code: [`const ${resultId} = BigInt(${id}).toString();`], + id: resultId, + }; + }, + jsonToNative(id) { + return { + code: [], + id: `BigInt(${id})`, + }; + }, + }; + case 'ByteList': + case 'ByteArray': + return { + nativeType: 'SDK.HexString', + jsonType: 'string', + nativeToJson(id) { + return { code: [], id }; + }, + jsonToNative(id) { + return { + code: [], + id, + }; + }, + }; + } +} + +function fieldToTypeAndMapper(fields: SDK.SchemaFields): SchemaNativeType { + switch (fields.type) { + case 'Named': { + const schemas = fields.fields.map((named) => ({ + name: named.name, + ...schemaAsNativeType(named.field), + })); + + const objectFieldTypes = schemas.map((s) => + defineProp(s.name, s.nativeType) + ); + const objectFieldJsonTypes = schemas.map((s) => + defineProp(s.name, s.jsonType) + ); + + return { + nativeType: `{\n${objectFieldTypes.join('\n')}\n}`, + jsonType: `{\n${objectFieldJsonTypes.join('\n')}\n}`, + nativeToJson(id) { + const resultId = idGenerator('named'); + const fields = schemas.map((s) => { + const fieldId = idGenerator('field'); + const field = s.nativeToJson(fieldId); + return { + name: s.name, + constructTokens: [ + `const ${fieldId} = ${accessProp(id, s.name)};`, + ...field.code, + ], + id: field.id, + }; + }); + const constructTokens = fields.flatMap( + (tokens) => tokens.constructTokens + ); + + return { + code: [ + ...constructTokens, + `const ${resultId} = {`, + ...fields.map((tokens) => + defineProp(tokens.name, tokens.id) + ), + '};', + ], + id: resultId, + }; + }, + jsonToNative(id) { + const fields = schemas.map((s) => { + const fieldId = idGenerator('field'); + const field = s.jsonToNative(fieldId); + return { + name: s.name, + constructTokens: [ + `const ${fieldId} = ${accessProp(id, s.name)};`, + ...field.code, + ], + id: field.id, + }; + }); + const constructTokens = fields.flatMap( + (tokens) => tokens.constructTokens + ); + const resultId = idGenerator('named'); + return { + code: [ + ...constructTokens, + `const ${resultId} = {`, + ...fields.map((tokens) => + defineProp(tokens.name, tokens.id) + ), + '};', + ], + id: resultId, + }; + }, + }; + } + case 'Unnamed': { + const schemas = fields.fields.flatMap((f) => { + const schema = schemaAsNativeType(f); + return schema === undefined ? [] : [schema]; + }); + if (schemas.length === 1) { + const schema = schemas[0]; + return { + nativeType: schema.nativeType, + jsonType: `[${schema.jsonType}]`, + nativeToJson(id) { + const tokens = schema.nativeToJson(id); + return { code: tokens.code, id: `[${tokens.id}]` }; + }, + jsonToNative(id) { + return schema.jsonToNative(`${id}[0]`); + }, + }; + } else { + return { + nativeType: `[${schemas + .map((s) => s.nativeType) + .join(', ')}]`, + jsonType: `[${schemas.map((s) => s.jsonType).join(', ')}]`, + nativeToJson(id) { + const resultId = idGenerator('unnamed'); + const mapped = schemas.map((schema, index) => + schema.nativeToJson(`${id}[${index}]`) + ); + const constructFields = mapped.flatMap( + (tokens) => tokens.code + ); + const fieldIds = mapped.map((s) => s.id); + return { + code: [ + ...constructFields, + `const ${resultId}: ${ + this.jsonType + } = [${fieldIds.join(', ')}];`, + ], + id: resultId, + }; + }, + jsonToNative(id) { + const resultId = idGenerator('unnamed'); + const mapped = schemas.map((schema, index) => + schema.jsonToNative(`${id}[${index}]`) + ); + const constructFields = mapped.flatMap( + (tokens) => tokens.code + ); + const fieldIds = mapped.map((s) => s.id); + return { + code: [ + ...constructFields, + `const ${resultId} = [${fieldIds.join(', ')}];`, + ], + id: resultId, + }; + }, + }; + } + } + case 'None': + return { + nativeType: '"no-fields"', + jsonType: '[]', + nativeToJson() { + return { code: [], id: '[]' }; + }, + jsonToNative() { + return { code: [], id: '"no-fields"' }; + }, + }; + } +} + +/** + * Information related to conversion between JSON and native type. + */ +type TypeConversionCode = { + /** The native type. */ + type: string; + /** Code to convert JSON either to or from native type. */ + code: string[]; + /** Identifier for the result of the code. */ + id: string; +}; + +/** + * Generate tokens for creating the parameter from input. + * @param {string} parameterId Identifier of the input. + * @param {SDK.SchemaType} [schemaType] The schema type to use for the parameter. + * @returns Undefined if no parameter is expected. + */ +function createParameterCode( + parameterId: string, + schemaType?: SDK.SchemaType +): TypeConversionCode | undefined { + // No schema type is present so fallback to plain parameter. + if (schemaType === undefined) { + return { + type: 'SDK.Parameter.Type', + code: [], + id: parameterId, + }; + } + + if (schemaType.type === 'Unit') { + // No parameter is needed according to the schema. + return undefined; + } + const typeAndMapper = schemaAsNativeType(schemaType); + const buffer = SDK.serializeSchemaType(schemaType); + const base64Schema = Buffer.from(buffer).toString('base64'); + + const mappedParameter = typeAndMapper.nativeToJson(parameterId); + const resultId = 'out'; + return { + type: typeAndMapper.nativeType, + code: [ + ...mappedParameter.code, + `const ${resultId} = SDK.Parameter.fromBase64SchemaType('${base64Schema}', ${mappedParameter.id});`, + ], + id: resultId, + }; +} + +/** + * Generate tokens for parsing a contract event. + * @param {string} eventId Identifier of the event to parse. + * @param {SDK.SchemaType} [schemaType] The schema to take into account when parsing. + * @returns Undefined if no code should be produced. + */ +function parseEventCode( + eventId: string, + schemaType?: SDK.SchemaType +): TypeConversionCode | undefined { + // No schema type is present so generate any code. + if (schemaType === undefined) { + return undefined; + } + const typeAndMapper = schemaAsNativeType(schemaType); + const base64Schema = Buffer.from( + SDK.serializeSchemaType(schemaType) + ).toString('base64'); + + const schemaJsonId = 'schemaJson'; + const tokens = typeAndMapper.jsonToNative(schemaJsonId); + return { + type: typeAndMapper.nativeType, + code: [ + `const ${schemaJsonId} = <${typeAndMapper.jsonType}>SDK.ContractEvent.parseWithSchemaTypeBase64(${eventId}, '${base64Schema}');`, + ...tokens.code, + ], + id: tokens.id, + }; +} + +/** + * Generate tokens for parsing a return type. + * @param {string} returnTypeId Identifier of the return type to parse. + * @param {SDK.SchemaType} [schemaType] The schema to take into account when parsing return type. + * @returns Undefined if no code should be produced. + */ +function parseReturnValueCode( + returnTypeId: string, + schemaType?: SDK.SchemaType +): TypeConversionCode | undefined { + // No schema type is present so don't generate any code. + if (schemaType === undefined) { + return undefined; + } + const typeAndMapper = schemaAsNativeType(schemaType); + const base64Schema = Buffer.from( + SDK.serializeSchemaType(schemaType) + ).toString('base64'); + + const schemaJsonId = 'schemaJson'; + const tokens = typeAndMapper.jsonToNative(schemaJsonId); + return { + type: typeAndMapper.nativeType, + + code: [ + `const ${schemaJsonId} = <${typeAndMapper.jsonType}>SDK.ReturnValue.parseWithSchemaTypeBase64(${returnTypeId}, '${base64Schema}');`, + ...tokens.code, + ], + id: tokens.id, + }; +} + +/** + * Stateful function which suffixes a provided string with a number, which increments everytime this is called. + * Used to ensure identifiers are unique. + * @param {string} name Name of the identifier. + * @returns {string} The name of the identifier suffixed with a number. + */ +const idGenerator = (() => { + let counter = 0; + return (name: string) => `${name}${counter++}`; +})(); + +/** + * Create tokens for accessing a property on an object. + * @param {string} objectId Identifier for the object. + * @param {string} propId Identifier for the property. + * @returns {string} Tokens for accessing the prop. + */ +function accessProp(objectId: string, propId: string): string { + return identifierRegex.test(propId) + ? `${objectId}.${propId}` + : `${objectId}['${propId}']`; +} + +/** + * Create tokens for defining a property in an object + * @param {string} propId Identifier for the property. + * @param {string} valueId Identifier for the value. + * @returns {string} Tokens for defining a property initialized to the value. + */ +function defineProp(propId: string, valueId: string): string { + return identifierRegex.test(propId) + ? `${propId}: ${valueId},` + : `'${propId}': ${valueId},`; +} + +/** + * Regular expression matching the format of valid identifiers in javascript. + * + * > Note: this does not check for collision with keywords, which is not a problem + * when accessing props or defining fields in an object. + */ +const identifierRegex = /^[$A-Z_][0-9A-Z_$]*$/i; diff --git a/packages/ccd-js-gen/tsconfig.json b/packages/ccd-js-gen/tsconfig.json index a17dfa97f..94ceed1ac 100644 --- a/packages/ccd-js-gen/tsconfig.json +++ b/packages/ccd-js-gen/tsconfig.json @@ -1,15 +1,11 @@ { "extends": "../../tsconfig-base.json", - "include": [ - "src/**/*", - "package.json" - ], + "include": ["src/**/*", "package.json"], "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "./lib", - "lib": [ - "ES2020", - "dom" - ], // "dom" is only added to get the typings for the global variable `WebAssembly`. + "lib": ["ES2020", "dom"], // "dom" is only added to get the typings for the global variable `WebAssembly`. "resolveJsonModule": true } } diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index d79ffc679..a81e4b503 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -43,6 +43,7 @@ The API now uses dedicated types instead of language primitives: - Use `SequenceNumber` (formerly called nonce) instead of a bigint. Use `SequenceNumber.create()` to construct it. - Use `Timestamp` instead of a bigint. Can be constructed using `Timestamp.fromMillis()`. - Use `Duration` instead of a bigint. Can be constructed using `Duration.fromMillis()`. +- Use `ContractEvent` instead of a string with hex encoding. Can be constructed using `ContractEvent.fromHexString('')`. - Use `CcdAmount` instead of a bigint. Can be constructed using `CcdAmount.fromMicroCcd()`. - Use `TransactionExpiry` instead of a Date object. Can be constructed using `TransactionExpiry.fromDate()`. - Use `ModuleReference` instead of a string with hex encoding. Can be constructed using `ModuleReference.fromHexString('')`. diff --git a/packages/sdk/src/GenericContract.ts b/packages/sdk/src/GenericContract.ts index 1af80ca60..0e3dc5ee4 100644 --- a/packages/sdk/src/GenericContract.ts +++ b/packages/sdk/src/GenericContract.ts @@ -41,6 +41,23 @@ export type ContractTransactionMetadata = { energy: Energy.Type; }; +/** + * Metadata necessary for invocating a smart contract. + */ +export type ContractInvokeMetadata = { + /** Amount to include in the transaction. Defaults to 0 */ + amount?: CcdAmount.Type; + /** + * Invoker of the contract. + * If this is not supplied then the contract will be invoked by an account with address 0, + * no credentials and sufficient amount of CCD to cover the transfer amount. + * If given, the relevant address (either account or contract) must exist in the blockstate. + */ + invoker?: ContractAddress.Type | AccountAddress.Type; + /** Max energy to be used for the transaction, if not provided the max energy is used. */ + energy?: Energy.Type; +}; + /** * Metadata necessary for creating a {@link UpdateTransaction} */ @@ -115,7 +132,7 @@ export class ContractDryRun { * @template T - The type of the input given * * @param {EntrypointName.Type} entrypoint - The name of the receive function to invoke. - * @param {ContractAddress | AccountAddress.Type} invoker - The address of the invoker. + * @param {ContractInvokeMetadata | ContractAddress | AccountAddress.Type} metaOrInvoker - Metadata for contract invocation of the address of the invoker. * @param {Function} serializer - A function for serializing the input to bytes. * @param {T} input - Input for for contract function. * @param {BlockHash.Type} [blockHash] - The hash of the block to perform the invocation of. Defaults to the latest finalized block on chain. @@ -124,17 +141,25 @@ export class ContractDryRun { */ public invokeMethod( entrypoint: EntrypointName.Type, - invoker: ContractAddress.Type | AccountAddress.Type, + metaOrInvoker: + | ContractInvokeMetadata + | ContractAddress.Type + | AccountAddress.Type, serializer: (input: T) => ArrayBuffer, input: T, blockHash?: BlockHash.Type ): Promise { const parameter = Parameter.fromBuffer(serializer(input)); + const meta = + AccountAddress.instanceOf(metaOrInvoker) || + ContractAddress.instanceOf(metaOrInvoker) + ? { invoker: metaOrInvoker } + : metaOrInvoker; return this.grpcClient.invokeContract( { + ...meta, contract: this.contractAddress, parameter, - invoker, method: ReceiveName.create(this.contractName, entrypoint), }, blockHash diff --git a/packages/sdk/src/grpc/translation.ts b/packages/sdk/src/grpc/translation.ts index f4d646cae..5839f1183 100644 --- a/packages/sdk/src/grpc/translation.ts +++ b/packages/sdk/src/grpc/translation.ts @@ -18,6 +18,7 @@ import * as SequenceNumber from '../types/SequenceNumber.js'; import * as TransactionHash from '../types/TransactionHash.js'; import * as Parameter from '../types/Parameter.js'; import * as ReturnValue from '../types/ReturnValue.js'; +import * as ContractEvent from '../types/ContractEvent.js'; function unwrapToHex(bytes: Uint8Array | undefined): v1.HexString { return Buffer.from(unwrap(bytes)).toString('hex'); @@ -874,7 +875,7 @@ function trContractTraceElement( receiveName: ReceiveName.fromProto( unwrap(element.updated.receiveName) ), - events: element.updated.events.map(unwrapValToHex), + events: element.updated.events.map(ContractEvent.fromProto), }; case 'transferred': return { @@ -893,7 +894,7 @@ function trContractTraceElement( address: ContractAddress.fromProto( unwrap(element.interrupted.address) ), - events: element.interrupted.events.map(unwrapValToHex), + events: element.interrupted.events.map(ContractEvent.fromProto), }; case 'resumed': return { diff --git a/packages/sdk/src/pub/types.ts b/packages/sdk/src/pub/types.ts index 3f5cf2f0a..eeb66cc7d 100644 --- a/packages/sdk/src/pub/types.ts +++ b/packages/sdk/src/pub/types.ts @@ -39,6 +39,7 @@ export { ContractSchema, ContractUpdateTransaction, ContractTransactionMetadata, + ContractInvokeMetadata, CreateContractTransactionMetadata, ContractUpdateTransactionWithSchema, } from '../GenericContract.js'; @@ -59,6 +60,7 @@ import * as ContractAddress from '../types/ContractAddress.js'; import * as EntrypointName from '../types/EntrypointName.js'; import * as Timestamp from '../types/Timestamp.js'; import * as Duration from '../types/Duration.js'; +import * as ContractEvent from '../types/ContractEvent.js'; import * as CcdAmount from '../types/CcdAmount.js'; import * as TransactionExpiry from '../types/TransactionExpiry.js'; import * as ModuleReference from '../types/ModuleReference.js'; @@ -88,6 +90,7 @@ export { EntrypointName, Timestamp, Duration, + ContractEvent, CcdAmount, TransactionExpiry, ModuleReference, diff --git a/packages/sdk/src/schemaTypes.ts b/packages/sdk/src/schemaTypes.ts index daf6b4978..0230eeea7 100644 --- a/packages/sdk/src/schemaTypes.ts +++ b/packages/sdk/src/schemaTypes.ts @@ -460,6 +460,16 @@ function deserializeOption( } } +/** + * Deserialize a schema type. + * @param {ArrayBuffer} buffer The buffer to deserialize. + * @returns {SchemaType} The deserialized schema type. + */ +export function deserializeSchemaType(buffer: ArrayBuffer): SchemaType { + const cursor = Cursor.fromBuffer(buffer); + return deserialSchemaType(cursor); +} + /** * Deserialize a schema type. * @param {Cursor} cursor A cursor over the buffer to deserialize. @@ -1007,12 +1017,12 @@ function serialSize( function serialFields(fields: SchemaFields): Uint8Array { switch (fields.type) { case 'Named': - return Buffer.from([ + return Buffer.concat([ Uint8Array.of(0), serializeList('U32', serialNamedField, fields.fields), ]); case 'Unnamed': - return Buffer.from([ + return Buffer.concat([ Uint8Array.of(1), serializeList('U32', serializeSchemaType, fields.fields), ]); @@ -1027,7 +1037,7 @@ function serialFields(fields: SchemaFields): Uint8Array { * @returns {Uint8Array} Buffer with serialization. */ function serialNamedField(named: SchemaNamedField): Uint8Array { - return Buffer.from([ + return Buffer.concat([ serializeString('U32', named.name), serializeSchemaType(named.field), ]); @@ -1039,7 +1049,7 @@ function serialNamedField(named: SchemaNamedField): Uint8Array { * @returns {Uint8Array} Buffer with serialization. */ function serializeEnumVariant(variant: SchemaEnumVariant): Uint8Array { - return Buffer.from([ + return Buffer.concat([ serializeString('U32', variant.name), serialFields(variant.fields), ]); @@ -1064,7 +1074,7 @@ function serializeList( serialItem: Serializer, list: A[] ): Uint8Array { - return Buffer.from([ + return Buffer.concat([ serialSize(sizeLength, list.length), ...list.map(serialItem), ]); @@ -1079,7 +1089,7 @@ function serializeString( sizeLength: SchemaSizeLength, value: string ): Uint8Array { - return Buffer.from([ + return Buffer.concat([ serialSize(sizeLength, value.length), Buffer.from(value, 'utf8'), ]); @@ -1105,5 +1115,5 @@ function serializeMap( for (const [k, v] of map.entries()) { buffers.push(serialKey(k), serialValue(v)); } - return Buffer.from(buffers); + return Buffer.concat(buffers); } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 0117e5e7b..7dc3ca4be 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -45,18 +45,31 @@ export type ModuleRef = HexString; /** A consensus round */ export type Round = bigint; +/** + * Utility type that takes an object type and makes the hover overlay more readable. + * + * @example + * type ComplexType = {test: string;} & {another: number;}; // Hovering this type shows: {test: string;} & {another: number;} + * type Test = Compute; // Now it shows: {test: string; another: number;} + */ +type Compute = { + [K in keyof T]: T[K]; +} & unknown; + /** * Makes keys of type optional * * @example * type PartiallyOptionalProps = MakeOptional<{test: string; another: number;}, 'another'>; // {test: string; another?: number;} */ -export type MakeOptional = Omit & - Partial>; +export type MakeOptional = Compute< + Omit & Partial> +>; /** Makes keys of type required (i.e. non-optional) */ -export type MakeRequired = Required> & - Omit; +export type MakeRequired = Compute< + Required> & Omit +>; /** * Returns a union of all keys of type T with values matching type V. */ @@ -1430,6 +1443,11 @@ export interface InvokeContractFailedResult { tag: 'failure'; usedEnergy: Energy.Type; reason: RejectReason; + /** + * Return value from smart contract call, used to provide error messages. + * Is only defined when smart contract instance is a V1 smart contract and + * the transaction was rejected by the smart contract logic i.e. `reason.tag === "RejectedReceive"`. + */ returnValue?: ReturnValue.Type; } @@ -1565,6 +1583,7 @@ export type SmartContractTypeValues = | { [key: string]: SmartContractTypeValues } | SmartContractTypeValues[] | number + | bigint | string | boolean; diff --git a/packages/sdk/src/types/AccountAddress.ts b/packages/sdk/src/types/AccountAddress.ts index d5c53ba52..d080adb09 100644 --- a/packages/sdk/src/types/AccountAddress.ts +++ b/packages/sdk/src/types/AccountAddress.ts @@ -104,18 +104,27 @@ export function toBase58(accountAddress: AccountAddress): string { return accountAddress.address; } -/** Type used when encoding the account address using a schema. */ +/** Type used when encoding an account address in the JSON format used when serializing using a smart contract schema type. */ export type SchemaValue = string; /** - * Get account address in the format used by schema. + * Get account address in the JSON format used when serializing using a smart contract schema type. * @param {AccountAddress} accountAddress The account address. - * @returns {SchemaValue} The schema value representation. + * @returns {SchemaValue} The schema JSON representation. */ export function toSchemaValue(accountAddress: AccountAddress): SchemaValue { return accountAddress.address; } +/** + * Convert to account address from JSON format used when serializing using a smart contract schema type. + * @param {SchemaValue} accountAddress The account address in schema JSON format. + * @returns {AccountAddress} The account address. + */ +export function fromSchemaValue(accountAddress: SchemaValue): AccountAddress { + return fromBase58(accountAddress); +} + const addressByteLength = 32; const aliasBytesLength = 3; const commonBytesLength = addressByteLength - aliasBytesLength; diff --git a/packages/sdk/src/types/CcdAmount.ts b/packages/sdk/src/types/CcdAmount.ts index 94697580b..8d95ce04d 100644 --- a/packages/sdk/src/types/CcdAmount.ts +++ b/packages/sdk/src/types/CcdAmount.ts @@ -159,12 +159,36 @@ export function microCcdToCcd(microCcd: BigSource | bigint): Big { return toCcd(fromMicroCcd(microCcd)); } +/** + * Type used when encoding a CCD amount in the JSON format used when serializing using a smart contract schema type. + * String representation of the amount of micro CCD. + */ +export type SchemaValue = string; + +/** + * Get CCD amount in the JSON format used when serializing using a smart contract schema type. + * @param {CcdAmount} amount The amount. + * @returns {SchemaValue} The schema value representation. + */ +export function toSchemaValue(amount: CcdAmount): SchemaValue { + return amount.microCcdAmount.toString(); +} + +/** + * Convert to CCD amount from JSON format used when serializing using a smart contract schema type. + * @param {SchemaValue} microCcdString The amount in schema format. + * @returns {CcdAmount} The amount. + */ +export function fromSchemaValue(microCcdString: SchemaValue): CcdAmount { + return new CcdAmount(BigInt(microCcdString)); +} + /** * Convert an amount of CCD from its protobuf encoding. * @param {Proto.Amount} amount The energy in protobuf. * @returns {CcdAmount} The energy. */ -export function fromProto(amount: Proto.Energy): CcdAmount { +export function fromProto(amount: Proto.Amount): CcdAmount { return new CcdAmount(amount.value); } diff --git a/packages/sdk/src/types/ContractAddress.ts b/packages/sdk/src/types/ContractAddress.ts index ff5667b86..050a36f00 100644 --- a/packages/sdk/src/types/ContractAddress.ts +++ b/packages/sdk/src/types/ContractAddress.ts @@ -60,21 +60,30 @@ export function create( return new ContractAddress(BigInt(index), BigInt(subindex)); } -/** Type used when representing a contract address while using a schema. */ +/** Type used when encoding a contract address in the JSON format used when serializing using a smart contract schema type. */ export type SchemaValue = { index: bigint; subindex: bigint; }; /** - * Get contract address in the format used by schema. + * Get contract address in the JSON format used when serializing using a smart contract schema type. * @param {ContractAddress} contractAddress The contract address. - * @returns {SchemaValue} The schema value representation. + * @returns {SchemaValue} The schema JSON representation. */ export function toSchemaValue(contractAddress: ContractAddress): SchemaValue { return { index: contractAddress.index, subindex: contractAddress.subindex }; } +/** + * Convert to contract address from JSON format used when serializing using a smart contract schema type. + * @param {SchemaValue} contractAddress The contract address in schema JSON format. + * @returns {ContractAddress} The contract address. + */ +export function fromSchemaValue(contractAddress: SchemaValue): ContractAddress { + return create(contractAddress.index, contractAddress.subindex); +} + /** * Convert a smart contract address from its protobuf encoding. * @param {Proto.ContractAddress} contractAddress The contract address in protobuf. diff --git a/packages/sdk/src/types/ContractEvent.ts b/packages/sdk/src/types/ContractEvent.ts new file mode 100644 index 000000000..37cec74eb --- /dev/null +++ b/packages/sdk/src/types/ContractEvent.ts @@ -0,0 +1,104 @@ +import type * as Proto from '../grpc-api/v2/concordium/types.js'; +import { deserializeTypeValue } from '../schema.js'; +import { SchemaType, serializeSchemaType } from '../schemaTypes.js'; +import type { + Base64String, + HexString, + SmartContractTypeValues, +} from '../types.js'; + +/** + * An event logged by a smart contract instance. + */ +class ContractEvent { + /** Having a private field prevents similar structured objects to be considered the same type (similar to nominal typing). */ + private __nominal = true; + constructor( + /** The internal buffer of bytes representing the event. */ + public readonly buffer: Uint8Array + ) {} +} + +/** + * An event logged by a smart contract instance. + */ +export type Type = ContractEvent; + +export function fromBuffer(buffer: ArrayBuffer): ContractEvent { + return new ContractEvent(new Uint8Array(buffer)); +} + +/** + * Create a ContractEvent from a hex string. + * @param {HexString} hex Hex encoding of the event. + * @returns {ContractEvent} + */ +export function fromHexString(hex: HexString): ContractEvent { + return fromBuffer(Buffer.from(hex, 'hex')); +} + +/** + * Hex encode a ContractEvent. + * @param {ContractEvent} event The event to encode. + * @returns {HexString} String containing the hex encoding. + */ +export function toHexString(event: ContractEvent): HexString { + return Buffer.from(event.buffer).toString('hex'); +} + +/** + * Get byte representation of a ContractEvent. + * @param {ContractEvent} event The event. + * @returns {ArrayBuffer} Hash represented as bytes. + */ +export function toBuffer(event: ContractEvent): Uint8Array { + return event.buffer; +} + +/** + * Convert a contract event from its protobuf encoding. + * @param {Proto.ContractEvent} event The protobuf encoding. + * @returns {ContractEvent} + */ +export function fromProto(event: Proto.ContractEvent): ContractEvent { + return fromBuffer(event.value); +} + +/** + * Convert a contract event into its protobuf encoding. + * @param {ContractEvent} event The block hash. + * @returns {Proto.ContractEvent} The protobuf encoding. + */ +export function toProto(event: ContractEvent): Proto.ContractEvent { + return { + value: event.buffer, + }; +} + +/** + * Parse a contract event using a schema type. + * @param {ContractEvent} value The event. + * @param {SchemaType} schemaType The schema type for the event. + * @returns {SmartContractTypeValues} + */ +export function parseWithSchemaType( + event: ContractEvent, + schemaType: SchemaType +): SmartContractTypeValues { + const schemaBytes = serializeSchemaType(schemaType); + return deserializeTypeValue(toBuffer(event), schemaBytes); +} + +/** + * Parse a contract event using a schema type. + * @param {ContractEvent} value The event. + * @param {Base64String} schemaBase64 The schema type for the event encoded as Base64. + * @returns {SmartContractTypeValues} + */ +export function parseWithSchemaTypeBase64( + event: ContractEvent, + schemaBase64: Base64String +): SmartContractTypeValues { + const schemaBytes = Buffer.from(schemaBase64, 'base64'); + return deserializeTypeValue(toBuffer(event), schemaBytes); +} diff --git a/packages/sdk/src/types/ContractName.ts b/packages/sdk/src/types/ContractName.ts index 312e6295a..b94f1e264 100644 --- a/packages/sdk/src/types/ContractName.ts +++ b/packages/sdk/src/types/ContractName.ts @@ -88,20 +88,29 @@ export function toString(contractName: ContractName): string { return contractName.value; } -/** Type used when encoding a contract name for the schema. */ +/** Type used when encoding a contract name in the JSON format used when serializing using a smart contract schema type. */ export type SchemaValue = { contract: string; }; /** - * Get contract name in the format used by schema. + * Get contract name in the JSON format used when serializing using a smart contract schema type. * @param {ContractName} contractName The contract name. - * @returns {SchemaValue} The schema value representation. + * @returns {SchemaValue} The schema JSON representation. */ export function toSchemaValue(contractName: ContractName): SchemaValue { return { contract: contractName.value }; } +/** + * Convert to contract name from JSON format used when serializing using a smart contract schema type. + * @param {SchemaValue} contractName The contract name in schema JSON format. + * @returns {ContractName} The contract name. + */ +export function fromSchemaValue(contractName: SchemaValue): ContractName { + return fromString(contractName.contract); +} + /** * Check if two contract names represent the same name of a contract. * @param {ContractName} left diff --git a/packages/sdk/src/types/Duration.ts b/packages/sdk/src/types/Duration.ts index 8e6db4653..c1ee2bd2c 100644 --- a/packages/sdk/src/types/Duration.ts +++ b/packages/sdk/src/types/Duration.ts @@ -12,7 +12,8 @@ export const JSON_DISCRIMINATOR = TypedJsonDiscriminator.Duration; type Serializable = string; /** - * Type representing a duration of time. + * Type representing a duration of time down to milliseconds. + * Can not be negative. */ class Duration { /** Having a private field prevents similar structured objects to be considered the same type (similar to nominal typing). */ @@ -24,7 +25,8 @@ class Duration { } /** - * Type representing a duration of time. + * Type representing a duration of time down to milliseconds. + * Can not be negative. */ export type Type = Duration; @@ -53,18 +55,100 @@ export function fromMillis(value: number | bigint): Duration { return new Duration(BigInt(value)); } -/** Type used when encoding a duration using a schema. */ +/** + * Regular expression to match a single measure in the duration string format. + * Matches the digits and the unit in separate groups. + */ +const stringMeasureRegexp = /^(\d+)(ms|s|m|h|d)$/; + +/** + * Parse a string containing a list of duration measures separated by whitespaces. + * + * A measure is a number followed by the unit (no whitespace + * between is allowed). Every measure is accumulated into a duration. The + * string is allowed to contain any number of measures with the same unit in no + * particular order. + * + * The supported units are: + * - `ms` for milliseconds + * - `s` for seconds + * - `m` for minutes + * - `h` for hours + * - `d` for days + * + * # Example + * The duration of 10 days, 1 hour, 2 minutes and 7 seconds is: + * ```text + * "10d 1h 2m 7s" + * ``` + * @param {string} durationString string representing a duration. + * @throws The format of the string is not matching the format. + * @returns {Duration} + */ +export function fromString(durationString: string): Duration { + let durationInMillis = 0; + for (const measure of durationString.split(' ')) { + const result = measure.match(stringMeasureRegexp); + if (result === null) { + throw new Error('Invalid duration format'); + } + const [, valueString, unit] = result; + const value = parseInt(valueString, 10); + switch (unit) { + case 'ms': + durationInMillis += value; + break; + case 's': + durationInMillis += value * 1000; + break; + case 'm': + durationInMillis += value * 1000 * 60; + break; + case 'h': + durationInMillis += value * 1000 * 60 * 60; + break; + case 'd': + durationInMillis += value * 1000 * 60 * 60 * 24; + break; + default: + throw new Error( + `Invalid duration format: Unknown unit '${unit}'.` + ); + } + } + return fromMillis(durationInMillis); +} + +/** + * Get the duration in milliseconds. + * @param {Duration} duration The duration. + * @returns {bigint} The duration represented in milliseconds. + */ +export function toMillis(duration: Duration): bigint { + return duration.value; +} + +/** Type used when encoding a duration in the JSON format used when serializing using a smart contract schema type. */ export type SchemaValue = string; /** - * Get duration in the format used by schemas. + * Get duration in the JSON format used when serializing using a smart contract schema type. * @param {Duration} duration The duration. - * @returns {SchemaValue} The schema value representation. + * @returns {SchemaValue} The schema JSON representation. */ export function toSchemaValue(duration: Duration): SchemaValue { return `${duration.value} ms`; } +/** + * Convert to duration from JSON format used when serializing using a smart contract schema type. + * @param {SchemaValue} duration The duration in schema JSON format. + * @returns {Duration} The duration. + */ +export function fromSchemaValue(duration: SchemaValue): Duration { + return fromString(duration); +} + /** * Convert a duration from its protobuf encoding. * @param {Proto.Duration} duration The duration in protobuf. diff --git a/packages/sdk/src/types/ReceiveName.ts b/packages/sdk/src/types/ReceiveName.ts index f5ead2db1..0cfa740a3 100644 --- a/packages/sdk/src/types/ReceiveName.ts +++ b/packages/sdk/src/types/ReceiveName.ts @@ -135,16 +135,16 @@ export function toEntrypointName( return EntrypointName.fromStringUnchecked(entrypointName); } -/** Type used when encoding a receive-name using a schema. */ +/** Type used when encoding a receive-name in the JSON format used when serializing using a smart contract schema type. */ export type SchemaValue = { contract: string; func: string; }; /** - * Get receiveName in the format used by schemas. + * Get receiveName in the JSON format used when serializing using a smart contract schema type. * @param {ReceiveName} receiveName The receive name. - * @returns {SchemaValue} The schema value representation. + * @returns {SchemaValue} The schema JSON representation. */ export function toSchemaValue(receiveName: ReceiveName): SchemaValue { const contract = ContractName.toString(toContractName(receiveName)); @@ -152,6 +152,15 @@ export function toSchemaValue(receiveName: ReceiveName): SchemaValue { return { contract, func }; } +/** + * Convert to smart contract receive name from JSON format used when serializing using a smart contract schema type. + * @param {SchemaValue} receiveName The receive name in schema JSON format. + * @returns {ReceiveName} The receive name. + */ +export function fromSchemaValue(receiveName: SchemaValue): ReceiveName { + return fromString(`${receiveName.contract}.${receiveName.func}`); +} + /** * Convert a smart contract receive name from its protobuf encoding. * @param {Proto.ReceiveName} receiveName The protobuf encoding. diff --git a/packages/sdk/src/types/ReturnValue.ts b/packages/sdk/src/types/ReturnValue.ts index 07521014c..15c8ae8f0 100644 --- a/packages/sdk/src/types/ReturnValue.ts +++ b/packages/sdk/src/types/ReturnValue.ts @@ -86,13 +86,13 @@ export function toBuffer(parameter: ReturnValue): Uint8Array { /** * Convert a return value into a more structured representation using a schema type. - * @param {SchemaType} schemaType The schema type for the return value. * @param {ReturnValue} returnValue The return value. + * @param {SchemaType} schemaType The schema type for the return value. * @returns {SmartContractTypeValues} */ -export function toSchemaType( - schemaType: SchemaType, - returnValue: ReturnValue +export function parseWithSchemaType( + returnValue: ReturnValue, + schemaType: SchemaType ): SmartContractTypeValues { const schemaBytes = serializeSchemaType(schemaType); return deserializeTypeValue(returnValue.buffer, schemaBytes); @@ -100,13 +100,13 @@ export function toSchemaType( /** * Convert a return value into a more structured representation using a schema type. - * @param {Base64String} schemaBase64 The schema type for the return value. * @param {ReturnValue} returnValue The return value. + * @param {Base64String} schemaBase64 The schema type for the return value. * @returns {SmartContractTypeValues} */ -export function toBase64SchemaType( - schemaBase64: Base64String, - returnValue: ReturnValue +export function parseWithSchemaTypeBase64( + returnValue: ReturnValue, + schemaBase64: Base64String ): SmartContractTypeValues { const schemaBytes = Buffer.from(schemaBase64, 'base64'); return deserializeTypeValue(returnValue.buffer, schemaBytes); diff --git a/packages/sdk/src/types/Timestamp.ts b/packages/sdk/src/types/Timestamp.ts index 5ab9f2687..eb8a51067 100644 --- a/packages/sdk/src/types/Timestamp.ts +++ b/packages/sdk/src/types/Timestamp.ts @@ -59,11 +59,11 @@ export function fromDate(date: Date): Timestamp { return fromMillis(date.getTime()); } -/** Type used when encoding the account address using a schema. */ +/** Type used when encoding a timestamp in the JSON format used when serializing using a smart contract schema type. */ export type SchemaValue = string; /** - * Get timestamp in the format used by schemas. + * Get timestamp in the JSON format used when serializing using a smart contract schema type. * @param {Timestamp} timestamp The timestamp. * @returns {SchemaValue} The schema value representation. */ @@ -71,6 +71,15 @@ export function toSchemaValue(timestamp: Timestamp): SchemaValue { return toDate(timestamp).toISOString(); } +/** + * Convert to timestamp from JSON format used when serializing using a smart contract schema type. + * @param {SchemaValue} timestamp The timestamp in schema format. + * @returns {Timestamp} The timestamp + */ +export function fromSchemaValue(timestamp: SchemaValue): Timestamp { + return fromMillis(Date.parse(timestamp)); +} + /** * Get timestamp as a Date. * @param {Timestamp} timestamp The timestamp. diff --git a/packages/sdk/src/types/blockItemSummary.ts b/packages/sdk/src/types/blockItemSummary.ts index 4cb8e9f69..8e24dc4fa 100644 --- a/packages/sdk/src/types/blockItemSummary.ts +++ b/packages/sdk/src/types/blockItemSummary.ts @@ -23,7 +23,6 @@ import { } from './transactionEvent.js'; import { UpdateInstructionPayload } from './chainUpdate.js'; import { - HexString, TransactionSummaryType, TransactionStatusEnum, AccountTransactionType, @@ -36,6 +35,7 @@ import * as AccountAddress from './AccountAddress.js'; import type * as BlockHash from './BlockHash.js'; import type * as TransactionHash from './TransactionHash.js'; import type * as Energy from './Energy.js'; +import type * as ContractEvent from './ContractEvent.js'; export interface BaseBlockItemSummary { index: bigint; @@ -531,11 +531,11 @@ export function affectedAccounts( export type SummaryContractUpdateLog = { address: ContractAddress.Type; - events: HexString[]; + events: ContractEvent.Type[]; }; /** - * Gets a list of update logs, each consisting of a {@link ContractAddress} and a list of {@link HexString} events. + * Gets a list of update logs, each consisting of a {@link ContractAddress.Type} and a list of {@link ContractEvent.Type} events. * The list will be empty for any transaction type but {@link UpdateContractSummary} contract updates. * * @param {BlockItemSummary} summary - The block item summary to check. diff --git a/packages/sdk/src/types/transactionEvent.ts b/packages/sdk/src/types/transactionEvent.ts index 689966aa0..fbdffaf42 100644 --- a/packages/sdk/src/types/transactionEvent.ts +++ b/packages/sdk/src/types/transactionEvent.ts @@ -15,6 +15,7 @@ import type * as AccountAddress from './AccountAddress.js'; import type * as Parameter from './Parameter.js'; import type * as ReceiveName from './ReceiveName.js'; import type * as InitName from './InitName.js'; +import type * as ContractEvent from './ContractEvent.js'; import type * as CcdAmount from './CcdAmount.js'; export enum TransactionEventTag { @@ -92,7 +93,7 @@ export type TransactionEvent = export interface InterruptedEvent { tag: TransactionEventTag.Interrupted; address: ContractAddress.Type; - events: HexString[]; + events: ContractEvent.Type[]; } export interface ResumedEvent { @@ -109,7 +110,7 @@ export interface UpdatedEvent { contractVersion: ContractVersion; message: Parameter.Type; receiveName: ReceiveName.Type; - events: HexString[]; + events: ContractEvent.Type[]; } export interface TransferredEvent { diff --git a/packages/sdk/test/ci/types/Duration.test.ts b/packages/sdk/test/ci/types/Duration.test.ts new file mode 100644 index 000000000..49e6870fe --- /dev/null +++ b/packages/sdk/test/ci/types/Duration.test.ts @@ -0,0 +1,33 @@ +import { Duration } from '../../../src/index.js'; + +describe('fromString', () => { + test('Parsing simple valid string', () => { + const duration = Duration.fromString('100ms'); + const value = Number(Duration.toMillis(duration)); + expect(value).toBe(100); + }); + + test('Parsing valid string', () => { + const duration = Duration.fromString('10d 1h 2m 7s'); + const value = Number(Duration.toMillis(duration)); + expect(value).toBe(867_727_000); + }); + + test('Fails when using invalid unit for a measure', () => { + expect(() => { + Duration.fromString('1w 10d'); + }).toThrow(); + }); + + test('Fails when using decimals in a measure', () => { + expect(() => { + Duration.fromString('10.0d'); + }).toThrow(); + }); + + test('Fails when using negative numbers in a measure', () => { + expect(() => { + Duration.fromString('-10d'); + }).toThrow(); + }); +}); diff --git a/packages/sdk/test/ci/types/blockItemSummary.test.ts b/packages/sdk/test/ci/types/blockItemSummary.test.ts index 9f13b1333..caa0053a4 100644 --- a/packages/sdk/test/ci/types/blockItemSummary.test.ts +++ b/packages/sdk/test/ci/types/blockItemSummary.test.ts @@ -27,6 +27,7 @@ import { InitName, Parameter, ReceiveName, + ContractEvent, CcdAmount, } from '../../../src/index.js'; @@ -107,14 +108,14 @@ const contractUpdate: UpdateContractSummary & BaseAccountTransactionSummary = { ), events: [ 'ff006400c8d4bb7106a96bfa6f069438270bf9748049c24798b13b08f88fc2f46afb435f0087e3bec61b8db2fb7389b57d2be4f7dd95d1088dfeb6ef7352c13d2b2d27bb49', - ], + ].map(ContractEvent.fromHexString), }, { tag: TransactionEventTag.Interrupted, address: ContractAddress.create(3496), events: [ 'ff006400c8d4bb7106a96bfa6f069438270bf9748049c24798b13b08f88fc2f46afb435f0087e3bec61b8db2fb7389b57d2be4f7dd95d1088dfeb6ef7352c13d2b2d27bb49', - ], + ].map(ContractEvent.fromHexString), }, { tag: TransactionEventTag.Updated, @@ -135,7 +136,7 @@ const contractUpdate: UpdateContractSummary & BaseAccountTransactionSummary = { ), events: [ 'ff006400c8d4bb7106a96bfa6f069438270bf9748049c24798b13b08f88fc2f46afb435f0087e3bec61b8db2fb7389b57d2be4f7dd95d1088dfeb6ef7352c13d2b2d27bb49', - ], + ].map(ContractEvent.fromHexString), }, { tag: TransactionEventTag.Transferred, @@ -402,19 +403,19 @@ describe('getSummaryContractUpdateLogs', () => { address: ContractAddress.create(3496), events: [ 'ff006400c8d4bb7106a96bfa6f069438270bf9748049c24798b13b08f88fc2f46afb435f0087e3bec61b8db2fb7389b57d2be4f7dd95d1088dfeb6ef7352c13d2b2d27bb49', - ], + ].map(ContractEvent.fromHexString), }, { address: ContractAddress.create(3496), events: [ 'ff006400c8d4bb7106a96bfa6f069438270bf9748049c24798b13b08f88fc2f46afb435f0087e3bec61b8db2fb7389b57d2be4f7dd95d1088dfeb6ef7352c13d2b2d27bb49', - ], + ].map(ContractEvent.fromHexString), }, { address: ContractAddress.create(4416), events: [ 'ff006400c8d4bb7106a96bfa6f069438270bf9748049c24798b13b08f88fc2f46afb435f0087e3bec61b8db2fb7389b57d2be4f7dd95d1088dfeb6ef7352c13d2b2d27bb49', - ], + ].map(ContractEvent.fromHexString), }, ]); }); diff --git a/packages/sdk/test/client/credentialDeployment.test.ts b/packages/sdk/test/client/credentialDeployment.test.ts index 17dec2ffa..d95210ec2 100644 --- a/packages/sdk/test/client/credentialDeployment.test.ts +++ b/packages/sdk/test/client/credentialDeployment.test.ts @@ -97,4 +97,4 @@ test('test deserialize credentialDeployment ', async () => { expect(BigInt(deployment.transaction.expiry)).toEqual( credentialDeploymentTransaction.expiry.expiryEpochSeconds ); -}); +}, 8000); diff --git a/packages/sdk/test/client/resources/expectedJsons.ts b/packages/sdk/test/client/resources/expectedJsons.ts index 81a327513..891142fbf 100644 --- a/packages/sdk/test/client/resources/expectedJsons.ts +++ b/packages/sdk/test/client/resources/expectedJsons.ts @@ -28,6 +28,7 @@ import { ChainParametersV0, ChainParametersV1, ContractAddress, + ContractEvent, ContractInitializedEvent, ContractTraceEvent, CredentialKeysUpdatedEvent, @@ -948,7 +949,7 @@ export const updateEvent: ContractTraceEvent[] = [ contractVersion: 1, events: [ 'fd00c0843d00e9f89f76878691716298685f21637d86fd8c98de7baa1d67e0ce11241be00083', - ], + ].map(ContractEvent.fromHexString), instigator: { address: ContractAddress.create(865), type: 'AddressContract', diff --git a/yarn.lock b/yarn.lock index 4a0fbe997..ec4c3ab8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -654,13 +654,16 @@ __metadata: version: 0.0.0-use.local resolution: "@concordium/ccd-js-gen@workspace:packages/ccd-js-gen" dependencies: - "@concordium/web-sdk": 6.4.0 + "@concordium/web-sdk": 6.x "@types/node": ^20.5.0 buffer: ^6.0.3 commander: ^11.0.0 eslint: ^8.50.0 + sanitize-filename: ^1.6.3 ts-morph: ^19.0.0 typescript: ^5.2.2 + peerDependencies: + "@concordium/web-sdk": 6.x bin: ccd-js-gen: bin/ccd-js-gen.js languageName: unknown @@ -698,7 +701,7 @@ __metadata: languageName: unknown linkType: soft -"@concordium/web-sdk@6.4.0, @concordium/web-sdk@workspace:^, @concordium/web-sdk@workspace:packages/sdk": +"@concordium/web-sdk@6.x, @concordium/web-sdk@workspace:^, @concordium/web-sdk@workspace:packages/sdk": version: 0.0.0-use.local resolution: "@concordium/web-sdk@workspace:packages/sdk" dependencies: @@ -7258,6 +7261,15 @@ __metadata: languageName: node linkType: hard +"sanitize-filename@npm:^1.6.3": + version: 1.6.3 + resolution: "sanitize-filename@npm:1.6.3" + dependencies: + truncate-utf8-bytes: ^1.0.0 + checksum: aa733c012b7823cf65730603cf3b503c641cee6b239771d3164ca482f22d81a50e434a713938d994071db18e4202625669cc56bccc9d13d818b4c983b5f47fde + languageName: node + linkType: hard + "sax@npm:^1.2.4": version: 1.2.4 resolution: "sax@npm:1.2.4" @@ -7875,6 +7887,15 @@ __metadata: languageName: node linkType: hard +"truncate-utf8-bytes@npm:^1.0.0": + version: 1.0.2 + resolution: "truncate-utf8-bytes@npm:1.0.2" + dependencies: + utf8-byte-length: ^1.0.1 + checksum: ad097314709ea98444ad9c80c03aac8da805b894f37ceb5685c49ad297483afe3a5ec9572ebcaff699dda72b6cd447a2ba2a3fd10e96c2628cd16d94abeb328a + languageName: node + linkType: hard + "ts-api-utils@npm:^1.0.1": version: 1.0.3 resolution: "ts-api-utils@npm:1.0.3" @@ -8220,6 +8241,13 @@ __metadata: languageName: node linkType: hard +"utf8-byte-length@npm:^1.0.1": + version: 1.0.4 + resolution: "utf8-byte-length@npm:1.0.4" + checksum: f188ca076ec094d58e7009fcc32623c5830c7f0f3e15802bfa4fdd1e759454a481fc4ac05e0fa83b7736e77af628a9ee0e57dcc89683d688fde3811473e42143 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2"