Skip to content

Commit

Permalink
Add generics for contract type-safety (#192)
Browse files Browse the repository at this point in the history
Co-authored-by: Rosco Kalis <roscokalis@gmail.com>
  • Loading branch information
PHCitizen and rkalis authored Dec 3, 2024
1 parent 202b649 commit 1dec0f6
Show file tree
Hide file tree
Showing 18 changed files with 607 additions and 60 deletions.
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -30,18 +30,19 @@ npm install -g cashc
Usage: cashc [options] [source_file]

Options:
-V, --version Output the version number.
-o, --output <path> 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 <path> 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 <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

Expand Down
17 changes: 9 additions & 8 deletions packages/cashc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,11 +26,12 @@ npm install -g cashc
Usage: cashc [options] [source_file]

Options:
-V, --version Output the version number.
-o, --output <path> 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 <path> 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 <format> Specify the format of the output. (choices: "json", "ts", default: "json")
-?, --help Display help
```
12 changes: 9 additions & 3 deletions packages/cashc/src/cashc-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 <format>', 'Specify the format of the output.')
.choices(['json', 'ts'])
.default('json'),
)
.helpOption('-?, --help', 'Display help')
.parse();

Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/cashscript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 29 additions & 10 deletions packages/cashscript/src/Contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,26 +24,39 @@ 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<string, any>;
unlock: Record<string, any>;
}
= {
constructorInputs: ParamsToTuple<TArtifact['constructorInputs']>;
functions: AbiToFunctionMap<TArtifact['abi'], Transaction>;
unlock: AbiToFunctionMap<TArtifact['abi'], Unlocker>;
},
> {
name: string;
address: string;
tokenAddress: string;
bytecode: string;
bytesize: number;
opcount: number;

functions: Record<string, ContractFunction>;
unlock: Record<string, ContractUnlocker>;
functions: TResolved['functions'];
unlock: TResolved['unlock'];

redeemScript: Script;
public provider: NetworkProvider;
public addressType: AddressType;
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();
Expand All @@ -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)
Expand All @@ -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);
});
}
Expand All @@ -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);
});
}
Expand All @@ -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)
Expand All @@ -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);
Expand Down
10 changes: 5 additions & 5 deletions packages/cashscript/src/LibauthTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
};
});

Expand Down Expand Up @@ -358,7 +358,7 @@ const generateTemplateScenarioBytecode = (
};

const generateTemplateScenarioParametersValues = (
types: AbiInput[],
types: readonly AbiInput[],
encodedArgs: EncodedFunctionArgument[],
): Record<string, string> => {
const typesAndArguments = zip(types, encodedArgs);
Expand All @@ -376,7 +376,7 @@ const generateTemplateScenarioParametersValues = (
};

const generateTemplateScenarioKeys = (
types: AbiInput[],
types: readonly AbiInput[],
encodedArgs: EncodedFunctionArgument[],
): Record<string, string> => {
const typesAndArguments = zip(types, encodedArgs);
Expand All @@ -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
Expand Down
79 changes: 79 additions & 0 deletions packages/cashscript/src/types/type-inference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type SignatureTemplate from '../SignatureTemplate.js';

type TypeMap = {
[k: `bytes${number}`]: Uint8Array | string; // Matches any "bytes<number>" 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> = 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> = Params extends readonly [infer Head, ...infer Tail]
? [ProcessParam<Head>, ...ParamsToTuple<Tail>]
: 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, ReturnType> = Function extends { name: string; inputs: readonly any[] }
? {
[functionName in Function['name']]: (...functionParameters: ParamsToTuple<Function['inputs']>) => 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<Abi, ReturnType> =
// Check if Abi is typed as `any`, in which case we return a default function map
unknown extends Abi
? GenericFunctionMap<ReturnType>
: Abi extends readonly [infer Head, ...infer Tail]
? ProcessFunction<Head, ReturnType> & InternalAbiToFunctionMap<Tail, ReturnType>
: Abi extends readonly []
? {}
: GenericFunctionMap<ReturnType>;

type GenericFunctionMap<ReturnType> = { [functionName: string]: (...functionParameters: any[]) => ReturnType };

// Merge intersection type
// Example: {foo: "foo"} & {bar: "bar"} -> {foo: "foo", bar: "bar"}
type Prettify<T> = { [K in keyof T]: T[K] } & {};

export type AbiToFunctionMap<T, ReturnType> = Prettify<InternalAbiToFunctionMap<T, ReturnType>>;
14 changes: 7 additions & 7 deletions packages/cashscript/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,12 +345,12 @@ export function findLastIndex<T>(array: Array<T>, 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)
Expand All @@ -368,6 +368,6 @@ export const extendedStringify = (obj: any, spaces?: number): string => JSON.str
spaces,
);

export const zip = <T, U>(a: T[], b: U[]): [T, U][] => (
export const zip = <T, U>(a: readonly T[], b: readonly U[]): [T, U][] => (
Array.from(Array(Math.max(b.length, a.length)), (_, i) => [a[i], b[i]])
);
Loading

0 comments on commit 1dec0f6

Please sign in to comment.