diff --git a/README.md b/README.md index 03114fda..f10c6c51 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ CashScript is a high-level language that allows you to write Bitcoin Cash smart ## The CashScript Compiler -CashScript features a compiler as a standalone command line tool, called `cashc`. It can be installed through npm and used to compile `.cash` files into `.json` artifact files. These artifact files can be imported into the CashScript TypeScript SDK (or other SDKs in the future). The `cashc` NPM package can also be imported inside JavaScript files to compile `.cash` files without using the command line tool. +CashScript features a compiler as a standalone command line tool, called `cashc`. It can be installed through npm and used to compile `.cash` files into `.json` (or `.ts`) artifact files. These artifact files can be imported into the CashScript TypeScript SDK (or other SDKs in the future). The `cashc` NPM package can also be imported inside JavaScript files to compile `.cash` files without using the command line tool. ### Installation @@ -30,18 +30,19 @@ npm install -g cashc Usage: cashc [options] [source_file] Options: - -V, --version Output the version number. - -o, --output Specify a file to output the generated artifact. - -h, --hex Compile the contract to hex format rather than a full artifact. - -A, --asm Compile the contract to ASM format rather than a full artifact. - -c, --opcount Display the number of opcodes in the compiled bytecode. - -s, --size Display the size in bytes of the compiled bytecode. - -?, --help Display help + -V, --version Output the version number. + -o, --output Specify a file to output the generated artifact. + -h, --hex Compile the contract to hex format rather than a full artifact. + -A, --asm Compile the contract to ASM format rather than a full artifact. + -c, --opcount Display the number of opcodes in the compiled bytecode. + -s, --size Display the size in bytes of the compiled bytecode. + -f, --format Specify the format of the output. (choices: "json", "ts", default: "json") + -?, --help Display help ``` ## The CashScript SDK -The main way to interact with CashScript contracts and integrate them into applications is using the CashScript SDK. This SDK allows you to import `.json` artifact files that were compiled using the `cashc` compiler and convert them to `Contract` objects. These objects are used to create new contract instances. These instances are used to interact with the contracts using the functions that were implemented in the `.cash` file. For more information on the CashScript SDK, refer to the [SDK documentation](https://cashscript.org/docs/sdk/). +The main way to interact with CashScript contracts and integrate them into applications is using the CashScript SDK. This SDK allows you to import `.json` (or `.ts`) artifact files that were compiled using the `cashc` compiler and convert them to `Contract` objects. These objects are used to create new contract instances. These instances are used to interact with the contracts using the functions that were implemented in the `.cash` file. For more information on the CashScript SDK, refer to the [SDK documentation](https://cashscript.org/docs/sdk/). ### Installation diff --git a/packages/cashc/README.md b/packages/cashc/README.md index dd1e0ada..674ef4ba 100644 --- a/packages/cashc/README.md +++ b/packages/cashc/README.md @@ -14,7 +14,7 @@ See the [GitHub repository](https://github.com/CashScript/cashscript) and the [C CashScript is a high-level language that allows you to write Bitcoin Cash smart contracts in a straightforward and familiar way. Its syntax is inspired by Ethereum's Solidity language, but its functionality is different since the underlying systems have very different fundamentals. See the [language documentation](https://cashscript.org/docs/language/) for a full reference of the language. ## The CashScript Compiler -CashScript features a compiler as a standalone command line tool, called `cashc`. It can be installed through npm and used to compile `.cash` files into `.json` artifact files. These artifact files can be imported into the CashScript TypeScript SDK (or other SDKs in the future). The `cashc` NPM package can also be imported inside JavaScript files to compile `.cash` files without using the command line tool. +CashScript features a compiler as a standalone command line tool, called `cashc`. It can be installed through npm and used to compile `.cash` files into `.json` (or `.ts`)artifact files. These artifact files can be imported into the CashScript TypeScript SDK (or other SDKs in the future). The `cashc` NPM package can also be imported inside JavaScript files to compile `.cash` files without using the command line tool. ### Installation ```bash @@ -26,11 +26,12 @@ npm install -g cashc Usage: cashc [options] [source_file] Options: - -V, --version Output the version number. - -o, --output Specify a file to output the generated artifact. - -h, --hex Compile the contract to hex format rather than a full artifact. - -A, --asm Compile the contract to ASM format rather than a full artifact. - -c, --opcount Display the number of opcodes in the compiled bytecode. - -s, --size Display the size in bytes of the compiled bytecode. - -?, --help Display help + -V, --version Output the version number. + -o, --output Specify a file to output the generated artifact. + -h, --hex Compile the contract to hex format rather than a full artifact. + -A, --asm Compile the contract to ASM format rather than a full artifact. + -c, --opcount Display the number of opcodes in the compiled bytecode. + -s, --size Display the size in bytes of the compiled bytecode. + -f, --format Specify the format of the output. (choices: "json", "ts", default: "json") + -?, --help Display help ``` diff --git a/packages/cashc/src/cashc-cli.ts b/packages/cashc/src/cashc-cli.ts index 5ba31a45..7b353790 100644 --- a/packages/cashc/src/cashc-cli.ts +++ b/packages/cashc/src/cashc-cli.ts @@ -5,10 +5,11 @@ import { calculateBytesize, countOpcodes, exportArtifact, + formatArtifact, scriptToAsm, scriptToBytecode, } from '@cashscript/utils'; -import { program } from 'commander'; +import { program, Option } from 'commander'; import fs from 'fs'; import path from 'path'; import { compileFile, version } from './index.js'; @@ -23,6 +24,11 @@ program .option('-A, --asm', 'Compile the contract to ASM format rather than a full artifact.') .option('-c, --opcount', 'Display the number of opcodes in the compiled bytecode.') .option('-s, --size', 'Display the size in bytes of the compiled bytecode.') + .addOption( + new Option('-f, --format ', 'Specify the format of the output.') + .choices(['json', 'ts']) + .default('json'), + ) .helpOption('-?, --help', 'Display help') .parse(); @@ -82,10 +88,10 @@ function run(): void { if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } - exportArtifact(artifact, outputFile); + exportArtifact(artifact, outputFile, opts.format); } else { // Output artifact to STDOUT - console.log(JSON.stringify(artifact, null, 2)); + console.log(formatArtifact(artifact, opts.format)); } } catch (e: any) { abort(e.message); diff --git a/packages/cashscript/README.md b/packages/cashscript/README.md index bd8bdf1f..472c5f70 100644 --- a/packages/cashscript/README.md +++ b/packages/cashscript/README.md @@ -14,7 +14,7 @@ See the [GitHub repository](https://github.com/CashScript/cashscript) and the [C CashScript is a high-level language that allows you to write Bitcoin Cash smart contracts in a straightforward and familiar way. Its syntax is inspired by Ethereum's Solidity language, but its functionality is different since the underlying systems have very different fundamentals. See the [language documentation](https://cashscript.org/docs/language/) for a full reference of the language. ## The CashScript SDK -The main way to interact with CashScript contracts and integrate them into applications is using the CashScript SDK. This SDK allows you to import `.json` artifact files that were compiled using the `cashc` compiler and convert them to `Contract` objects. These objects are used to create new contract instances. These instances are used to interact with the contracts using the functions that were implemented in the `.cash` file. For more information on the CashScript SDK, refer to the [SDK documentation](https://cashscript.org/docs/sdk/). +The main way to interact with CashScript contracts and integrate them into applications is using the CashScript SDK. This SDK allows you to import `.json` (or `.ts`) artifact files that were compiled using the `cashc` compiler and convert them to `Contract` objects. These objects are used to create new contract instances. These instances are used to interact with the contracts using the functions that were implemented in the `.cash` file. For more information on the CashScript SDK, refer to the [SDK documentation](https://cashscript.org/docs/sdk/). ### Installation ```bash diff --git a/packages/cashscript/src/Contract.ts b/packages/cashscript/src/Contract.ts index b6334963..dfe428fd 100644 --- a/packages/cashscript/src/Contract.ts +++ b/packages/cashscript/src/Contract.ts @@ -11,7 +11,9 @@ import { scriptToBytecode, } from '@cashscript/utils'; import { Transaction } from './Transaction.js'; -import { ConstructorArgument, encodeFunctionArgument, encodeConstructorArguments, encodeFunctionArguments, FunctionArgument } from './Argument.js'; +import { + ConstructorArgument, encodeFunctionArgument, encodeConstructorArguments, encodeFunctionArguments, FunctionArgument, +} from './Argument.js'; import { Unlocker, ContractOptions, GenerateUnlockingBytecodeOptions, Utxo, AddressType, @@ -22,8 +24,21 @@ import { } from './utils.js'; import SignatureTemplate from './SignatureTemplate.js'; import { ElectrumNetworkProvider } from './network/index.js'; - -export class Contract { +import { ParamsToTuple, AbiToFunctionMap } from './types/type-inference.js'; + +export class Contract< + TArtifact extends Artifact = Artifact, + TResolved extends { + constructorInputs: ConstructorArgument[]; + functions: Record; + unlock: Record; + } + = { + constructorInputs: ParamsToTuple; + functions: AbiToFunctionMap; + unlock: AbiToFunctionMap; + }, +> { name: string; address: string; tokenAddress: string; @@ -31,8 +46,8 @@ export class Contract { bytesize: number; opcount: number; - functions: Record; - unlock: Record; + functions: TResolved['functions']; + unlock: TResolved['unlock']; redeemScript: Script; public provider: NetworkProvider; @@ -40,8 +55,8 @@ export class Contract { public encodedConstructorArgs: Uint8Array[]; constructor( - public artifact: Artifact, - constructorArgs: ConstructorArgument[], + public artifact: TArtifact, + constructorArgs: TResolved['constructorInputs'], private options?: ContractOptions, ) { this.provider = this.options?.provider ?? new ElectrumNetworkProvider(); @@ -53,7 +68,7 @@ export class Contract { } if (artifact.constructorInputs.length !== constructorArgs.length) { - throw new Error(`Incorrect number of arguments passed to ${artifact.contractName} constructor. Expected ${artifact.constructorInputs.length} arguments (${artifact.constructorInputs.map(input => input.type)}) but got ${constructorArgs.length}`); + throw new Error(`Incorrect number of arguments passed to ${artifact.contractName} constructor. Expected ${artifact.constructorInputs.length} arguments (${artifact.constructorInputs.map((input) => input.type)}) but got ${constructorArgs.length}`); } // Encode arguments (this also performs type checking) @@ -66,9 +81,11 @@ export class Contract { this.functions = {}; if (artifact.abi.length === 1) { const f = artifact.abi[0]; + // @ts-ignore TODO: see if we can use generics to make TypeScript happy this.functions[f.name] = this.createFunction(f); } else { artifact.abi.forEach((f, i) => { + // @ts-ignore TODO: see if we can use generics to make TypeScript happy this.functions[f.name] = this.createFunction(f, i); }); } @@ -78,9 +95,11 @@ export class Contract { this.unlock = {}; if (artifact.abi.length === 1) { const f = artifact.abi[0]; + // @ts-ignore TODO: see if we can use generics to make TypeScript happy this.unlock[f.name] = this.createUnlocker(f); } else { artifact.abi.forEach((f, i) => { + // @ts-ignore TODO: see if we can use generics to make TypeScript happy this.unlock[f.name] = this.createUnlocker(f, i); }); } @@ -105,7 +124,7 @@ export class Contract { private createFunction(abiFunction: AbiFunction, selector?: number): ContractFunction { return (...args: FunctionArgument[]) => { if (abiFunction.inputs.length !== args.length) { - throw new Error(`Incorrect number of arguments passed to function ${abiFunction.name}. Expected ${abiFunction.inputs.length} arguments (${abiFunction.inputs.map(input => input.type)}) but got ${args.length}`); + throw new Error(`Incorrect number of arguments passed to function ${abiFunction.name}. Expected ${abiFunction.inputs.length} arguments (${abiFunction.inputs.map((input) => input.type)}) but got ${args.length}`); } // Encode passed args (this also performs type checking) @@ -126,7 +145,7 @@ export class Contract { private createUnlocker(abiFunction: AbiFunction, selector?: number): ContractUnlocker { return (...args: FunctionArgument[]) => { if (abiFunction.inputs.length !== args.length) { - throw new Error(`Incorrect number of arguments passed to function ${abiFunction.name}. Expected ${abiFunction.inputs.length} arguments (${abiFunction.inputs.map(input => input.type)}) but got ${args.length}`); + throw new Error(`Incorrect number of arguments passed to function ${abiFunction.name}. Expected ${abiFunction.inputs.length} arguments (${abiFunction.inputs.map((input) => input.type)}) but got ${args.length}`); } const bytecode = scriptToBytecode(this.redeemScript); diff --git a/packages/cashscript/src/LibauthTemplate.ts b/packages/cashscript/src/LibauthTemplate.ts index e07faa14..b931c50d 100644 --- a/packages/cashscript/src/LibauthTemplate.ts +++ b/packages/cashscript/src/LibauthTemplate.ts @@ -105,14 +105,14 @@ export const buildTemplate = async ({ template.scripts[unlockScriptName] = { name: unlockScriptName, script: - `<${signatureString}>\n<${placeholderKeyName}.public_key>`, + `<${signatureString}>\n<${placeholderKeyName}.public_key>`, unlocks: lockScriptName, }; template.scripts[lockScriptName] = { lockingType: 'standard', name: lockScriptName, script: - `OP_DUP\nOP_HASH160 <$(<${placeholderKeyName}.public_key> OP_HASH160\n)> OP_EQUALVERIFY\nOP_CHECKSIG`, + `OP_DUP\nOP_HASH160 <$(<${placeholderKeyName}.public_key> OP_HASH160\n)> OP_EQUALVERIFY\nOP_CHECKSIG`, }; }); @@ -358,7 +358,7 @@ const generateTemplateScenarioBytecode = ( }; const generateTemplateScenarioParametersValues = ( - types: AbiInput[], + types: readonly AbiInput[], encodedArgs: EncodedFunctionArgument[], ): Record => { const typesAndArguments = zip(types, encodedArgs); @@ -376,7 +376,7 @@ const generateTemplateScenarioParametersValues = ( }; const generateTemplateScenarioKeys = ( - types: AbiInput[], + types: readonly AbiInput[], encodedArgs: EncodedFunctionArgument[], ): Record => { const typesAndArguments = zip(types, encodedArgs); @@ -388,7 +388,7 @@ const generateTemplateScenarioKeys = ( return Object.fromEntries(entries); }; -const formatParametersForDebugging = (types: AbiInput[], args: EncodedFunctionArgument[]): string => { +const formatParametersForDebugging = (types: readonly AbiInput[], args: EncodedFunctionArgument[]): string => { if (types.length === 0) return '// none'; // We reverse the arguments because the order of the arguments in the bytecode is reversed diff --git a/packages/cashscript/src/types/type-inference.ts b/packages/cashscript/src/types/type-inference.ts new file mode 100644 index 00000000..bef3df50 --- /dev/null +++ b/packages/cashscript/src/types/type-inference.ts @@ -0,0 +1,79 @@ +import type SignatureTemplate from '../SignatureTemplate.js'; + +type TypeMap = { + [k: `bytes${number}`]: Uint8Array | string; // Matches any "bytes" pattern +} & { + byte: Uint8Array | string; + bytes: Uint8Array | string; + bool: boolean; + int: bigint; + string: string; + pubkey: Uint8Array | string; + sig: SignatureTemplate | Uint8Array | string; + datasig: Uint8Array | string; +}; + +// Helper type to process a single parameter by mapping its `type` to a value in `TypeMap`. +// Example: { type: "pubkey" } -> Uint8Array +// Branches: +// - If `Param` is a known type, it maps the `type` to `TypeMap[Type]`. +// - If `Param` has an unknown `type`, it defaults to `any`. +// - If `Param` is not an object with `type`, it defaults to `any`. +type ProcessParam = Param extends { type: infer Type } + ? Type extends keyof TypeMap + ? TypeMap[Type] + : any + : any; + +// Main type to recursively convert an array of parameter definitions into a tuple. +// Example: [{ type: "pubkey" }, { type: "int" }] -> [Uint8Array, bigint] +// Branches: +// - If `Params` is a tuple with a `Head` that matches `ProcessParam`, it processes the head and recurses on the `Tail`. +// - If `Params` is an empty tuple, it returns []. +// - If `Params` is not an array or tuple, it defaults to any[]. +export type ParamsToTuple = Params extends readonly [infer Head, ...infer Tail] + ? [ProcessParam, ...ParamsToTuple] + : Params extends readonly [] + ? [] + : any[]; + +// Processes a single function definition into a function mapping with parameters and return type. +// Example: { name: "transfer", inputs: [{ type: "int" }] } -> { transfer: (arg0: bigint) => ReturnType } +// Branches: +// - Branch 1: If `Function` is an object with `name` and `inputs`, it creates a function mapping. +// - Branch 2: If `Function` does not match the expected shape, it returns an empty object. +type ProcessFunction = Function extends { name: string; inputs: readonly any[] } + ? { + [functionName in Function['name']]: (...functionParameters: ParamsToTuple) => ReturnType; + } + : {}; + +// Recursively converts an ABI into a function map with parameter typings and return type. +// Example: +// [ +// { name: "transfer", inputs: [{ type: "int" }] }, +// { name: "approve", inputs: [{ type: "address" }, { type: "int" }] } +// ] -> +// { transfer: (arg0: bigint) => ReturnType; approve: (arg0: string, arg1: bigint) => ReturnType } +// Branches: +// - Branch 1: If `Abi` is `unknown` or `any`, return a default function map with generic parameters and return type. +// - Branch 2: If `Abi` is a tuple with a `Head`, process `Head` using `ProcessFunction` and recurse on the `Tail`. +// - Branch 3: If `Abi` is an empty tuple, return an empty object. +// - Branch 4: If `Abi` is not an array or tuple, return a generic function map. +type InternalAbiToFunctionMap = + // Check if Abi is typed as `any`, in which case we return a default function map + unknown extends Abi + ? GenericFunctionMap + : Abi extends readonly [infer Head, ...infer Tail] + ? ProcessFunction & InternalAbiToFunctionMap + : Abi extends readonly [] + ? {} + : GenericFunctionMap; + +type GenericFunctionMap = { [functionName: string]: (...functionParameters: any[]) => ReturnType }; + +// Merge intersection type +// Example: {foo: "foo"} & {bar: "bar"} -> {foo: "foo", bar: "bar"} +type Prettify = { [K in keyof T]: T[K] } & {}; + +export type AbiToFunctionMap = Prettify>; diff --git a/packages/cashscript/src/utils.ts b/packages/cashscript/src/utils.ts index b9dca04b..0cbddf65 100644 --- a/packages/cashscript/src/utils.ts +++ b/packages/cashscript/src/utils.ts @@ -345,12 +345,12 @@ export function findLastIndex(array: Array, predicate: (value: T, index: n export const snakeCase = (str: string): string => ( str - && str - .match( - /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g, - )! - .map((s) => s.toLowerCase()) - .join('_') + && str + .match( + /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g, + )! + .map((s) => s.toLowerCase()) + .join('_') ); // JSON.stringify version that can serialize otherwise unsupported types (bigint and Uint8Array) @@ -368,6 +368,6 @@ export const extendedStringify = (obj: any, spaces?: number): string => JSON.str spaces, ); -export const zip = (a: T[], b: U[]): [T, U][] => ( +export const zip = (a: readonly T[], b: readonly U[]): [T, U][] => ( Array.from(Array(Math.max(b.length, a.length)), (_, i) => [a[i], b[i]]) ); diff --git a/packages/cashscript/test/fixture/announcement.artifact.ts b/packages/cashscript/test/fixture/announcement.artifact.ts new file mode 100644 index 00000000..1e148582 --- /dev/null +++ b/packages/cashscript/test/fixture/announcement.artifact.ts @@ -0,0 +1,17 @@ +export default { + contractName: 'Announcement', + constructorInputs: [], + abi: [ + { + name: 'announce', + inputs: [], + }, + ], + bytecode: '6a 6d02 OP_SIZE OP_SWAP OP_CAT OP_CAT 4120636f6e7472616374206d6179206e6f7420696e6a75726520612068756d616e206265696e67206f722c207468726f75676820696e616374696f6e2c20616c6c6f7720612068756d616e206265696e6720746f20636f6d6520746f206861726d2e OP_SIZE OP_DUP 4b OP_GREATERTHAN OP_IF 4c OP_SWAP OP_CAT OP_ENDIF OP_SWAP OP_CAT OP_CAT OP_0 OP_OUTPUTVALUE OP_0 OP_NUMEQUALVERIFY OP_0 OP_OUTPUTBYTECODE OP_EQUALVERIFY e803 OP_INPUTINDEX OP_UTXOVALUE OP_OVER OP_SUB OP_DUP OP_ROT OP_GREATERTHANOREQUAL OP_IF OP_1 OP_OUTPUTBYTECODE OP_INPUTINDEX OP_UTXOBYTECODE OP_EQUALVERIFY OP_1 OP_OUTPUTVALUE OP_OVER OP_NUMEQUALVERIFY OP_ENDIF OP_DROP OP_1', + source: 'pragma cashscript ^0.9.0;\n\n/* This is a contract showcasing covenants outside of regular transactional use.\n * It enforces the contract to make an "announcement" on Memo.cash, and send the\n * remainder of contract funds back to the contract.\n */\ncontract Announcement() {\n function announce() {\n // Create the memo.cash announcement output\n bytes announcement = new LockingBytecodeNullData([\n 0x6d02,\n bytes(\'A contract may not injure a human being or, through inaction, allow a human being to come to harm.\')\n ]);\n\n // Check that the first tx output matches the announcement\n require(tx.outputs[0].value == 0);\n require(tx.outputs[0].lockingBytecode == announcement);\n\n // Calculate leftover money after fee (1000 sats)\n // Check that the second tx output sends the change back if there\'s enough leftover for another announcement\n int minerFee = 1000;\n int changeAmount = tx.inputs[this.activeInputIndex].value - minerFee;\n if (changeAmount >= minerFee) {\n require(tx.outputs[1].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);\n require(tx.outputs[1].value == changeAmount);\n }\n }\n}\n', + compiler: { + name: 'cashc', + version: '0.8.0-next.0', + }, + updatedAt: '2023-02-10T15:22:49.703Z', +} as const; diff --git a/packages/cashscript/test/fixture/hodl_vault.artifact.ts b/packages/cashscript/test/fixture/hodl_vault.artifact.ts new file mode 100644 index 00000000..91d90149 --- /dev/null +++ b/packages/cashscript/test/fixture/hodl_vault.artifact.ts @@ -0,0 +1,47 @@ +export default { + contractName: 'HodlVault', + constructorInputs: [ + { + name: 'ownerPk', + type: 'pubkey', + }, + { + name: 'oraclePk', + type: 'pubkey', + }, + { + name: 'minBlock', + type: 'int', + }, + { + name: 'priceTarget', + type: 'int', + }, + ], + abi: [ + { + name: 'spend', + inputs: [ + { + name: 'ownerSig', + type: 'sig', + }, + { + name: 'oracleSig', + type: 'datasig', + }, + { + name: 'oracleMessage', + type: 'bytes8', + }, + ], + }, + ], + bytecode: 'OP_6 OP_PICK OP_4 OP_SPLIT OP_SWAP OP_BIN2NUM OP_SWAP OP_BIN2NUM OP_OVER OP_5 OP_ROLL OP_GREATERTHANOREQUAL OP_VERIFY OP_SWAP OP_CHECKLOCKTIMEVERIFY OP_DROP OP_3 OP_ROLL OP_GREATERTHANOREQUAL OP_VERIFY OP_3 OP_ROLL OP_4 OP_ROLL OP_3 OP_ROLL OP_CHECKDATASIGVERIFY OP_CHECKSIG', + source: '// This contract forces HODLing until a certain price target has been reached\n// A minimum block is provided to ensure that oracle price entries from before this block are disregarded\n// i.e. when the BCH price was $1000 in the past, an oracle entry with the old block number and price can not be used.\n// Instead, a message with a block number and price from after the minBlock needs to be passed.\n// This contract serves as a simple example for checkDataSig-based contracts.\ncontract HodlVault(\n pubkey ownerPk,\n pubkey oraclePk,\n int minBlock,\n int priceTarget\n) {\n function spend(sig ownerSig, datasig oracleSig, bytes8 oracleMessage) {\n // message: { blockHeight, price }\n bytes4 blockHeightBin, bytes4 priceBin = oracleMessage.split(4);\n int blockHeight = int(blockHeightBin);\n int price = int(priceBin);\n\n // Check that blockHeight is after minBlock and not in the future\n require(blockHeight >= minBlock);\n require(tx.time >= blockHeight);\n\n // Check that current price is at least priceTarget\n require(price >= priceTarget);\n\n // Handle necessary signature checks\n require(checkDataSig(oracleSig, oracleMessage, oraclePk));\n require(checkSig(ownerSig, ownerPk));\n }\n}\n', + compiler: { + name: 'cashc', + version: '0.8.0-next.0', + }, + updatedAt: '2023-02-10T15:22:49.985Z', +} as const; diff --git a/packages/cashscript/test/fixture/p2pkh.artifact.ts b/packages/cashscript/test/fixture/p2pkh.artifact.ts new file mode 100644 index 00000000..d679d513 --- /dev/null +++ b/packages/cashscript/test/fixture/p2pkh.artifact.ts @@ -0,0 +1,31 @@ +export default { + contractName: 'P2PKH', + constructorInputs: [ + { + name: 'pkh', + type: 'bytes20', + }, + ], + abi: [ + { + name: 'spend', + inputs: [ + { + name: 'pk', + type: 'pubkey', + }, + { + name: 's', + type: 'sig', + }, + ], + }, + ], + bytecode: 'OP_OVER OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG', + source: 'contract P2PKH(bytes20 pkh) {\n // Require pk to match stored pkh and signature to match\n function spend(pubkey pk, sig s) {\n require(hash160(pk) == pkh);\n require(checkSig(s, pk));\n }\n}\n', + compiler: { + name: 'cashc', + version: '0.8.0-next.0', + }, + updatedAt: '2023-02-10T15:22:51.429Z', +} as const; diff --git a/packages/cashscript/test/fixture/transfer_with_timeout.artifact.ts b/packages/cashscript/test/fixture/transfer_with_timeout.artifact.ts new file mode 100644 index 00000000..8a16ce38 --- /dev/null +++ b/packages/cashscript/test/fixture/transfer_with_timeout.artifact.ts @@ -0,0 +1,44 @@ +export default { + contractName: 'TransferWithTimeout', + constructorInputs: [ + { + name: 'sender', + type: 'pubkey', + }, + { + name: 'recipient', + type: 'pubkey', + }, + { + name: 'timeout', + type: 'int', + }, + ], + abi: [ + { + name: 'transfer', + inputs: [ + { + name: 'recipientSig', + type: 'sig', + }, + ], + }, + { + name: 'timeout', + inputs: [ + { + name: 'senderSig', + type: 'sig', + }, + ], + }, + ], + bytecode: 'OP_3 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_4 OP_ROLL OP_ROT OP_CHECKSIG OP_NIP OP_NIP OP_NIP OP_ELSE OP_3 OP_ROLL OP_1 OP_NUMEQUALVERIFY OP_3 OP_ROLL OP_SWAP OP_CHECKSIGVERIFY OP_SWAP OP_CHECKLOCKTIMEVERIFY OP_2DROP OP_1 OP_ENDIF', + source: "contract TransferWithTimeout(\n pubkey sender,\n pubkey recipient,\n int timeout\n) {\n // Require recipient's signature to match\n function transfer(sig recipientSig) {\n require(checkSig(recipientSig, recipient));\n }\n\n // Require timeout time to be reached and sender's signature to match\n function timeout(sig senderSig) {\n require(checkSig(senderSig, sender));\n require(tx.time >= timeout);\n }\n}\n", + compiler: { + name: 'cashc', + version: '0.8.0-next.0', + }, + updatedAt: '2023-02-10T15:22:51.144Z', +} as const; diff --git a/packages/cashscript/test/types/Contract.types.test.ts b/packages/cashscript/test/types/Contract.types.test.ts new file mode 100644 index 00000000..ea1cc81c --- /dev/null +++ b/packages/cashscript/test/types/Contract.types.test.ts @@ -0,0 +1,240 @@ +/* eslint-disable */ +import { Artifact, Contract, SignatureTemplate, Transaction, Unlocker } from 'cashscript'; +import p2pkhArtifact from '../fixture/p2pkh.artifact'; +import p2pkhArtifactJsonNotConst from '../fixture/p2pkh.json'; +import announcementArtifact from '../fixture/announcement.artifact'; +import hodlVaultArtifact from '../fixture/hodl_vault.artifact'; +import transferWithTimeoutArtifact from '../fixture/transfer_with_timeout.artifact'; +import { alicePkh, alicePriv, alicePub, bobPub, oraclePub } from '../fixture/vars'; +import { binToHex } from '@bitauth/libauth'; + +interface ManualArtifactType extends Artifact { + constructorInputs: [ + { + name: 'pkh', + type: 'bytes20', + }, + ], + abi: [ + { + name: 'spend', + inputs: [ + { + name: 'pk', + type: 'pubkey', + }, + { + name: 's', + type: 'sig', + }, + ], + }, + ] +} + +// describe('P2PKH contract | single constructor input | single function (2 args)') +{ + // describe('Contructor arguments') + { + // it('should not give type errors when using correct constructor inputs') + new Contract(p2pkhArtifact, [alicePkh]); + new Contract(p2pkhArtifact, [binToHex(alicePkh)]); + + // it('should give type errors when using empty constructor inputs') + // @ts-expect-error + new Contract(p2pkhArtifact, []); + + // it('should give type errors when using incorrect constructor input type') + // @ts-expect-error + new Contract(p2pkhArtifact, [1000n]); + + // it('should give type errors when using incorrect constructor input length') + // @ts-expect-error + new Contract(p2pkhArtifact, [alicePkh, 1000n]); + + // it('should not perform type checking when cast to any') + new Contract(p2pkhArtifact as any, [alicePkh, 1000n]); + + // it('should not perform type checking when cannot infer type') + // Note: would be very nice if it *could* infer the type from static json + new Contract(p2pkhArtifactJsonNotConst, [alicePkh, 1000n]); + + // it('should perform type checking when manually specifying a type + // @ts-expect-error + new Contract(p2pkhArtifactJsonNotConst as any, [alicePkh, 1000n]); + } + + // describe('Contract functions') + { + const contract = new Contract(p2pkhArtifact, [alicePkh]); + + // it('should not give type errors when using correct function inputs') + contract.functions.spend(alicePub, new SignatureTemplate(alicePriv)).build(); + + // it('should give type errors when calling a function that does not exist') + // @ts-expect-error + contract.functions.notAFunction(); + + // it('should give type errors when using incorrect function input types') + // @ts-expect-error + contract.functions.spend(1000n, true); + + // it('should give type errors when using incorrect function input length') + // @ts-expect-error + contract.functions.spend(alicePub, new SignatureTemplate(alicePriv), 100n); + // @ts-expect-error + contract.functions.spend(alicePub); + + // it('should not perform type checking when cast to any') + const contractAsAny = new Contract(p2pkhArtifact as any, [alicePkh, 1000n]); + contractAsAny.functions.notAFunction().build(); + contractAsAny.functions.spend(); + contractAsAny.functions.spend(1000n, true); + + // it('should not perform type checking when cannot infer type') + // Note: would be very nice if it *could* infer the type from static json + const contractFromUnknown = new Contract(p2pkhArtifactJsonNotConst, [alicePkh, 1000n]); + contractFromUnknown.functions.notAFunction().build(); + contractFromUnknown.functions.spend(); + contractFromUnknown.functions.spend(1000n, true); + + // it('should give type errors when calling methods that do not exist on the returned object') + // @ts-expect-error + contract.functions.spend().notAFunction(); + // @ts-expect-error + contractAsAny.functions.spend().notAFunction(); + // @ts-expect-error + contractFromUnknown.functions.spend().notAFunction(); + } + + // describe('Contract unlockers') + { + const contract = new Contract(p2pkhArtifact, [alicePkh]); + + // it('should not give type errors when using correct function inputs') + contract.unlock.spend(alicePub, new SignatureTemplate(alicePriv)).generateLockingBytecode(); + + // it('should give type errors when calling a function that does not exist') + // @ts-expect-error + contract.unlock.notAFunction(); + + // it('should give type errors when using incorrect function input types') + // @ts-expect-error + contract.unlock.spend(1000n, true); + + // it('should give type errors when using incorrect function input length') + // @ts-expect-error + contract.unlock.spend(alicePub, new SignatureTemplate(alicePriv), 100n); + // @ts-expect-error + contract.unlock.spend(alicePub); + + // it('should not perform type checking when cast to any') + const contractAsAny = new Contract(p2pkhArtifact as any, [alicePkh, 1000n]); + contractAsAny.unlock.notAFunction().generateLockingBytecode(); + contractAsAny.unlock.spend(); + contractAsAny.unlock.spend(1000n, true); + + // it('should not perform type checking when cannot infer type') + // Note: would be very nice if it *could* infer the type from static json + const contractFromUnknown = new Contract(p2pkhArtifactJsonNotConst, [alicePkh, 1000n]); + contractFromUnknown.unlock.notAFunction().generateLockingBytecode(); + contractFromUnknown.unlock.spend(); + contractFromUnknown.unlock.spend(1000n, true); + + // it('should give type errors when calling methods that do not exist on the returned object') + // @ts-expect-error + contract.unlock.spend().notAFunction(); + // @ts-expect-error + contractAsAny.unlock.spend().notAFunction(); + // @ts-expect-error + contractFromUnknown.unlock.spend().notAFunction(); + } +} + +// describe('Announcement contract | no constructor inputs | single function (no args)') +{ + // describe('Constructor arguments') + { + // it('should not give type errors when using correct constructor inputs') + new Contract(announcementArtifact, []); + + // it('should give type errors when using incorrect constructor input length') + // @ts-expect-error + new Contract(announcementArtifact, [1000n]); + + // it('should give type errors when passing in completely incorrect type') + // @ts-expect-error + new Contract(announcementArtifact, 'hello'); + } + + // describe('Contract functions') + { + // it('should not give type errors when using correct function inputs') + const contract = new Contract(announcementArtifact, []); + + // it('should not give type errors when using correct function inputs') + contract.functions.announce(); + + // it('should give type errors when calling a function that does not exist') + // @ts-expect-error + contract.functions.notAFunction(); + + // it('should give type errors when using incorrect function input length') + // @ts-expect-error + contract.functions.announce('hello world'); + } +} + +// describe('HodlVault contract | 4 constructor inputs | single function (3 args)') +{ + // describe('Constructor arguments') + { + // it('should not give type errors when using correct constructor inputs') + new Contract(hodlVaultArtifact, [alicePub, binToHex(oraclePub), 1000n, 1000n]); + + // it('should give type errors when using too few constructor inputs') + // @ts-expect-error + new Contract(hodlVaultArtifact, [alicePub, binToHex(oraclePub)]); + + // it('should give type errors when using incorrect constructor input type') + // @ts-expect-error + new Contract(hodlVaultArtifact, [alicePub, binToHex(oraclePub), 1000n, 'hello']); + // @ts-expect-error + new Contract(hodlVaultArtifact, [alicePub, binToHex(oraclePub), true, 1000n]); + } +} + + +// describe('TransferWithTimeout contract | 3 constructor inputs | two functions (1 arg each)') +{ + // describe('Constructor arguments') + { + // it('should not give type errors when using correct constructor inputs') + new Contract(transferWithTimeoutArtifact, [alicePub, bobPub, 100_000n]); + } + + // describe('Contract functions') + { + const contract = new Contract(transferWithTimeoutArtifact, [alicePub, bobPub, 100_000n]); + + // it('should not give type errors when using correct function inputs') + contract.functions.transfer(new SignatureTemplate(alicePriv)); + contract.functions.timeout(new SignatureTemplate(alicePriv)); + + // it('should give type errors when calling a function that does not exist') + // @ts-expect-error + contract.functions.notAFunction(); + + // it('should give type errors when using incorrect function input types') + // @ts-expect-error + contract.functions.transfer(1000n); + // @ts-expect-error + contract.functions.timeout(true); + + // it('should give type errors when using incorrect function input length') + // @ts-expect-error + contract.functions.transfer(new SignatureTemplate(alicePub), 100n); + // @ts-expect-error + contract.functions.timeout(); + } +} diff --git a/packages/utils/src/artifact.ts b/packages/utils/src/artifact.ts index 0c5814ac..14707807 100644 --- a/packages/utils/src/artifact.ts +++ b/packages/utils/src/artifact.ts @@ -8,7 +8,7 @@ export interface AbiInput { export interface AbiFunction { name: string; covenant?: boolean; - inputs: AbiInput[]; + inputs: readonly AbiInput[]; } export interface DebugInformation { @@ -40,8 +40,8 @@ export interface RequireStatement { export interface Artifact { contractName: string; - constructorInputs: AbiInput[]; - abi: AbiFunction[]; + constructorInputs: readonly AbiInput[]; + abi: readonly AbiFunction[]; bytecode: string; source: string; debug?: DebugInformation; @@ -56,7 +56,50 @@ export function importArtifact(artifactFile: PathLike): Artifact { return JSON.parse(fs.readFileSync(artifactFile, { encoding: 'utf-8' })); } -export function exportArtifact(artifact: Artifact, targetFile: string): void { - const jsonString = JSON.stringify(artifact, null, 2); +export function exportArtifact(artifact: Artifact, targetFile: string, format: 'json' | 'ts'): void { + const jsonString = formatArtifact(artifact, format); fs.writeFileSync(targetFile, jsonString); } + +export function formatArtifact(artifact: Artifact, format: 'json' | 'ts'): string { + if (format === 'ts') { + return `export default ${stringifyAsTs(artifact)} as const;\n`; + } + + return JSON.stringify(artifact, null, 2); +} + +const indent = (level: number): string => ' '.repeat(level); + +function stringifyAsTs(obj: any, indentationLevel: number = 1): string { + // For strings, we use JSON.stringify, but we convert double quotes to single quotes + if (typeof obj === 'string') { + return JSON.stringify(obj).replace(/'/g, "\\'").replace(/"/g, "'"); + } + + // Numbers and booleans are just converted to strings + if (typeof obj === 'number' || typeof obj === 'boolean') { + return JSON.stringify(obj); + } + + // Arrays are recursively formatted with indentation + if (Array.isArray(obj)) { + if (obj.length === 0) return '[]'; + const formattedItems = obj.map((item) => `${indent(indentationLevel)}${stringifyAsTs(item, indentationLevel + 1)}`); + return `[\n${formattedItems.join(',\n')},\n${indent(indentationLevel - 1)}]`; + } + + // Objects are recursively formatted with indentation + if (typeof obj === 'object') { + const entries = Object.entries(obj); + + if (entries.length === 0) return '{}'; + + const formattedEntries = entries.map(([key, value]) => ( + `${indent(indentationLevel)}${key}: ${stringifyAsTs(value, indentationLevel + 1)}` + )); + return `{\n${formattedEntries.join(',\n')},\n${indent(indentationLevel - 1)}}`; + } + + throw new Error(`Unsupported type: ${typeof obj}`); +} diff --git a/website/docs/compiler/artifacts.md b/website/docs/compiler/artifacts.md index fcc144f0..3db42ff4 100644 --- a/website/docs/compiler/artifacts.md +++ b/website/docs/compiler/artifacts.md @@ -2,7 +2,7 @@ title: Artifacts --- -Compiled contracts can be represented by so-called artifacts. These artifacts contain all information that is needed to interact with the smart contracts on-chain. Artifacts are stored in `.json` files so they can be shared and stored for later usage without having to recompile the contract. +Compiled contracts can be represented by so-called artifacts. These artifacts contain all information that is needed to interact with the smart contracts on-chain. Artifacts are stored in `.json` (or `.ts`) files so they can be shared and stored for later usage without having to recompile the contract. :::tip Did you know? Artifacts allow any third-party SDKs to be developed, since these SDKs only need to import and use an artifact file, while the compilation of the contract is left to the official `cashc` compiler. diff --git a/website/docs/compiler/compiler.md b/website/docs/compiler/compiler.md index 493900f0..510ea0ba 100644 --- a/website/docs/compiler/compiler.md +++ b/website/docs/compiler/compiler.md @@ -2,12 +2,12 @@ title: Compiler --- -The CashScript compiler is called `cashc` and is used to compile CashScript `.cash` contract files into `.json` artifact files. +The CashScript compiler is called `cashc` and is used to compile CashScript `.cash` contract files into `.json` (or `.ts`) artifact files. These artifact files can be used to instantiate a CashScript contract with the help of the CashScript SDK. For more information on this artifact format refer to [Artifacts](/docs/compiler/artifacts). ## Command Line Interface -The `cashc` command line interface is used to compile CashScript `.cash` files into `.json` artifact files. +The `cashc` command line interface is used to compile CashScript `.cash` files into `.json` (or `.ts`) artifact files. ### Installation You can use `npm` to install the `cashc` command line tool globally. @@ -17,21 +17,26 @@ npm install -g cashc ``` ### CLI Usage -The `cashc` CLI tool can be used to compile `.cash` files to JSON artifact files. +The `cashc` CLI tool can be used to compile `.cash` files to JSON (or `.ts`) artifact files. ```bash Usage: cashc [options] [source_file] Options: - -V, --version Output the version number. - -o, --output Specify a file to output the generated artifact. - -h, --hex Compile the contract to hex format rather than a full artifact. - -A, --asm Compile the contract to ASM format rather than a full artifact. - -c, --opcount Display the number of opcodes in the compiled bytecode. - -s, --size Display the size in bytes of the compiled bytecode. - -?, --help Display help + -V, --version Output the version number. + -o, --output Specify a file to output the generated artifact. + -h, --hex Compile the contract to hex format rather than a full artifact. + -A, --asm Compile the contract to ASM format rather than a full artifact. + -c, --opcount Display the number of opcodes in the compiled bytecode. + -s, --size Display the size in bytes of the compiled bytecode. + -f, --format Specify the format of the output. (choices: "json", "ts", default: "json") + -?, --help Display help ``` +:::tip +To have the best TypeScript integration, we recommend generating the artifact in the `.ts` format and importing it into your TypeScript project from that `.ts` file. +::: + ## JavaScript Compilation Generally CashScript contracts are compiled to an Artifact JSON file using the CLI compiler. As an alternative to this, CashScript contracts can be compiled from within JavaScript apps using the `cashc` package. This package exports two compilation functions. diff --git a/website/docs/releases/release-notes.md b/website/docs/releases/release-notes.md index da27834f..5a9fb560 100644 --- a/website/docs/releases/release-notes.md +++ b/website/docs/releases/release-notes.md @@ -2,6 +2,14 @@ title: Release Notes --- +## v0.10.3 + +#### cashc compiler +- :sparkles: Add `--format ts` option to `cashc` CLI to generate TypeScript typings for the artifact. + +#### CashScript SDK +- :sparkles: Add automatic TypeScript typings for `Contract` class when artifact is generated using the `cashc` CLI with the `--format ts` option. + ## v0.10.2 #### cashc compiler diff --git a/website/docs/sdk/instantiation.md b/website/docs/sdk/instantiation.md index 29bdb91c..ff34a33c 100644 --- a/website/docs/sdk/instantiation.md +++ b/website/docs/sdk/instantiation.md @@ -26,7 +26,7 @@ new Contract( A CashScript contract can be instantiated by providing an `Artifact` object, a list of constructor arguments, and optionally an options object configuring `NetworkProvider` and `addressType`. -An `Artifact` object is the result of compiling a CashScript contract. See the [Language Documentation](/docs/compiler/artifacts) for more information on Artifacts. Compilation can be done using the standalone [`cashc` CLI](/docs/compiler) or programmatically with the `cashc` NPM package (see [CashScript Compiler](/docs/compiler#javascript-compilation)). +An `Artifact` object is the result of compiling a CashScript contract. Compilation can be done using the standalone [`cashc` CLI](/docs/compiler) or programmatically with the `cashc` NPM package (see [CashScript Compiler](/docs/compiler#javascript-compilation)). If compilation is done using the `cashc` CLI with the `--format ts` option, you will get explicit types and type checking for the constructor arguments and function arguments. The `NetworkProvider` option is used to manage network operations for the CashScript contract. By default, a mainnet `ElectrumNetworkProvider` is used, but the network providers can be configured. See the docs on [NetworkProvider](/docs/sdk/network-provider). @@ -54,6 +54,8 @@ const options = { provider, addressType} const contract = new Contract(P2PKH, contractArguments, options); ``` +### TypeScript Typings + ## Contract Properties ### address @@ -175,6 +177,10 @@ const tx = await contract.functions .send() ``` +:::tip +If the contract artifact is generated using the `cashc` CLI with the `--format ts` option, you will get explicit types and type checking for the function name and arguments. +::: + ### Contract unlockers ```ts