Skip to content

Add generics for contract type-safety #192

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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