Skip to content

Commit 1dec0f6

Browse files
PHCitizenrkalis
andauthoredDec 3, 2024··
Add generics for contract type-safety (#192)
Co-authored-by: Rosco Kalis <roscokalis@gmail.com>
1 parent 202b649 commit 1dec0f6

18 files changed

+607
-60
lines changed
 

‎README.md

+10-9
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ CashScript is a high-level language that allows you to write Bitcoin Cash smart
1616

1717
## The CashScript Compiler
1818

19-
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.
19+
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.
2020

2121
### Installation
2222

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

3232
Options:
33-
-V, --version Output the version number.
34-
-o, --output <path> Specify a file to output the generated artifact.
35-
-h, --hex Compile the contract to hex format rather than a full artifact.
36-
-A, --asm Compile the contract to ASM format rather than a full artifact.
37-
-c, --opcount Display the number of opcodes in the compiled bytecode.
38-
-s, --size Display the size in bytes of the compiled bytecode.
39-
-?, --help Display help
33+
-V, --version Output the version number.
34+
-o, --output <path> Specify a file to output the generated artifact.
35+
-h, --hex Compile the contract to hex format rather than a full artifact.
36+
-A, --asm Compile the contract to ASM format rather than a full artifact.
37+
-c, --opcount Display the number of opcodes in the compiled bytecode.
38+
-s, --size Display the size in bytes of the compiled bytecode.
39+
-f, --format <format> Specify the format of the output. (choices: "json", "ts", default: "json")
40+
-?, --help Display help
4041
```
4142

4243
## The CashScript SDK
4344

44-
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/).
45+
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/).
4546

4647
### Installation
4748

‎packages/cashc/README.md

+9-8
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ See the [GitHub repository](https://github.com/CashScript/cashscript) and the [C
1414
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.
1515

1616
## The CashScript Compiler
17-
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.
17+
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.
1818

1919
### Installation
2020
```bash
@@ -26,11 +26,12 @@ npm install -g cashc
2626
Usage: cashc [options] [source_file]
2727

2828
Options:
29-
-V, --version Output the version number.
30-
-o, --output <path> Specify a file to output the generated artifact.
31-
-h, --hex Compile the contract to hex format rather than a full artifact.
32-
-A, --asm Compile the contract to ASM format rather than a full artifact.
33-
-c, --opcount Display the number of opcodes in the compiled bytecode.
34-
-s, --size Display the size in bytes of the compiled bytecode.
35-
-?, --help Display help
29+
-V, --version Output the version number.
30+
-o, --output <path> Specify a file to output the generated artifact.
31+
-h, --hex Compile the contract to hex format rather than a full artifact.
32+
-A, --asm Compile the contract to ASM format rather than a full artifact.
33+
-c, --opcount Display the number of opcodes in the compiled bytecode.
34+
-s, --size Display the size in bytes of the compiled bytecode.
35+
-f, --format <format> Specify the format of the output. (choices: "json", "ts", default: "json")
36+
-?, --help Display help
3637
```

‎packages/cashc/src/cashc-cli.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import {
55
calculateBytesize,
66
countOpcodes,
77
exportArtifact,
8+
formatArtifact,
89
scriptToAsm,
910
scriptToBytecode,
1011
} from '@cashscript/utils';
11-
import { program } from 'commander';
12+
import { program, Option } from 'commander';
1213
import fs from 'fs';
1314
import path from 'path';
1415
import { compileFile, version } from './index.js';
@@ -23,6 +24,11 @@ program
2324
.option('-A, --asm', 'Compile the contract to ASM format rather than a full artifact.')
2425
.option('-c, --opcount', 'Display the number of opcodes in the compiled bytecode.')
2526
.option('-s, --size', 'Display the size in bytes of the compiled bytecode.')
27+
.addOption(
28+
new Option('-f, --format <format>', 'Specify the format of the output.')
29+
.choices(['json', 'ts'])
30+
.default('json'),
31+
)
2632
.helpOption('-?, --help', 'Display help')
2733
.parse();
2834

@@ -82,10 +88,10 @@ function run(): void {
8288
if (!fs.existsSync(outputDir)) {
8389
fs.mkdirSync(outputDir, { recursive: true });
8490
}
85-
exportArtifact(artifact, outputFile);
91+
exportArtifact(artifact, outputFile, opts.format);
8692
} else {
8793
// Output artifact to STDOUT
88-
console.log(JSON.stringify(artifact, null, 2));
94+
console.log(formatArtifact(artifact, opts.format));
8995
}
9096
} catch (e: any) {
9197
abort(e.message);

‎packages/cashscript/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ See the [GitHub repository](https://github.com/CashScript/cashscript) and the [C
1414
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.
1515

1616
## The CashScript SDK
17-
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/).
17+
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/).
1818

1919
### Installation
2020
```bash

‎packages/cashscript/src/Contract.ts

+29-10
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import {
1111
scriptToBytecode,
1212
} from '@cashscript/utils';
1313
import { Transaction } from './Transaction.js';
14-
import { ConstructorArgument, encodeFunctionArgument, encodeConstructorArguments, encodeFunctionArguments, FunctionArgument } from './Argument.js';
14+
import {
15+
ConstructorArgument, encodeFunctionArgument, encodeConstructorArguments, encodeFunctionArguments, FunctionArgument,
16+
} from './Argument.js';
1517
import {
1618
Unlocker, ContractOptions, GenerateUnlockingBytecodeOptions, Utxo,
1719
AddressType,
@@ -22,26 +24,39 @@ import {
2224
} from './utils.js';
2325
import SignatureTemplate from './SignatureTemplate.js';
2426
import { ElectrumNetworkProvider } from './network/index.js';
25-
26-
export class Contract {
27+
import { ParamsToTuple, AbiToFunctionMap } from './types/type-inference.js';
28+
29+
export class Contract<
30+
TArtifact extends Artifact = Artifact,
31+
TResolved extends {
32+
constructorInputs: ConstructorArgument[];
33+
functions: Record<string, any>;
34+
unlock: Record<string, any>;
35+
}
36+
= {
37+
constructorInputs: ParamsToTuple<TArtifact['constructorInputs']>;
38+
functions: AbiToFunctionMap<TArtifact['abi'], Transaction>;
39+
unlock: AbiToFunctionMap<TArtifact['abi'], Unlocker>;
40+
},
41+
> {
2742
name: string;
2843
address: string;
2944
tokenAddress: string;
3045
bytecode: string;
3146
bytesize: number;
3247
opcount: number;
3348

34-
functions: Record<string, ContractFunction>;
35-
unlock: Record<string, ContractUnlocker>;
49+
functions: TResolved['functions'];
50+
unlock: TResolved['unlock'];
3651

3752
redeemScript: Script;
3853
public provider: NetworkProvider;
3954
public addressType: AddressType;
4055
public encodedConstructorArgs: Uint8Array[];
4156

4257
constructor(
43-
public artifact: Artifact,
44-
constructorArgs: ConstructorArgument[],
58+
public artifact: TArtifact,
59+
constructorArgs: TResolved['constructorInputs'],
4560
private options?: ContractOptions,
4661
) {
4762
this.provider = this.options?.provider ?? new ElectrumNetworkProvider();
@@ -53,7 +68,7 @@ export class Contract {
5368
}
5469

5570
if (artifact.constructorInputs.length !== constructorArgs.length) {
56-
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}`);
71+
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}`);
5772
}
5873

5974
// Encode arguments (this also performs type checking)
@@ -66,9 +81,11 @@ export class Contract {
6681
this.functions = {};
6782
if (artifact.abi.length === 1) {
6883
const f = artifact.abi[0];
84+
// @ts-ignore TODO: see if we can use generics to make TypeScript happy
6985
this.functions[f.name] = this.createFunction(f);
7086
} else {
7187
artifact.abi.forEach((f, i) => {
88+
// @ts-ignore TODO: see if we can use generics to make TypeScript happy
7289
this.functions[f.name] = this.createFunction(f, i);
7390
});
7491
}
@@ -78,9 +95,11 @@ export class Contract {
7895
this.unlock = {};
7996
if (artifact.abi.length === 1) {
8097
const f = artifact.abi[0];
98+
// @ts-ignore TODO: see if we can use generics to make TypeScript happy
8199
this.unlock[f.name] = this.createUnlocker(f);
82100
} else {
83101
artifact.abi.forEach((f, i) => {
102+
// @ts-ignore TODO: see if we can use generics to make TypeScript happy
84103
this.unlock[f.name] = this.createUnlocker(f, i);
85104
});
86105
}
@@ -105,7 +124,7 @@ export class Contract {
105124
private createFunction(abiFunction: AbiFunction, selector?: number): ContractFunction {
106125
return (...args: FunctionArgument[]) => {
107126
if (abiFunction.inputs.length !== args.length) {
108-
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}`);
127+
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}`);
109128
}
110129

111130
// Encode passed args (this also performs type checking)
@@ -126,7 +145,7 @@ export class Contract {
126145
private createUnlocker(abiFunction: AbiFunction, selector?: number): ContractUnlocker {
127146
return (...args: FunctionArgument[]) => {
128147
if (abiFunction.inputs.length !== args.length) {
129-
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}`);
148+
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}`);
130149
}
131150

132151
const bytecode = scriptToBytecode(this.redeemScript);

‎packages/cashscript/src/LibauthTemplate.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,14 @@ export const buildTemplate = async ({
105105
template.scripts[unlockScriptName] = {
106106
name: unlockScriptName,
107107
script:
108-
`<${signatureString}>\n<${placeholderKeyName}.public_key>`,
108+
`<${signatureString}>\n<${placeholderKeyName}.public_key>`,
109109
unlocks: lockScriptName,
110110
};
111111
template.scripts[lockScriptName] = {
112112
lockingType: 'standard',
113113
name: lockScriptName,
114114
script:
115-
`OP_DUP\nOP_HASH160 <$(<${placeholderKeyName}.public_key> OP_HASH160\n)> OP_EQUALVERIFY\nOP_CHECKSIG`,
115+
`OP_DUP\nOP_HASH160 <$(<${placeholderKeyName}.public_key> OP_HASH160\n)> OP_EQUALVERIFY\nOP_CHECKSIG`,
116116
};
117117
});
118118

@@ -358,7 +358,7 @@ const generateTemplateScenarioBytecode = (
358358
};
359359

360360
const generateTemplateScenarioParametersValues = (
361-
types: AbiInput[],
361+
types: readonly AbiInput[],
362362
encodedArgs: EncodedFunctionArgument[],
363363
): Record<string, string> => {
364364
const typesAndArguments = zip(types, encodedArgs);
@@ -376,7 +376,7 @@ const generateTemplateScenarioParametersValues = (
376376
};
377377

378378
const generateTemplateScenarioKeys = (
379-
types: AbiInput[],
379+
types: readonly AbiInput[],
380380
encodedArgs: EncodedFunctionArgument[],
381381
): Record<string, string> => {
382382
const typesAndArguments = zip(types, encodedArgs);
@@ -388,7 +388,7 @@ const generateTemplateScenarioKeys = (
388388
return Object.fromEntries(entries);
389389
};
390390

391-
const formatParametersForDebugging = (types: AbiInput[], args: EncodedFunctionArgument[]): string => {
391+
const formatParametersForDebugging = (types: readonly AbiInput[], args: EncodedFunctionArgument[]): string => {
392392
if (types.length === 0) return '// none';
393393

394394
// We reverse the arguments because the order of the arguments in the bytecode is reversed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type SignatureTemplate from '../SignatureTemplate.js';
2+
3+
type TypeMap = {
4+
[k: `bytes${number}`]: Uint8Array | string; // Matches any "bytes<number>" pattern
5+
} & {
6+
byte: Uint8Array | string;
7+
bytes: Uint8Array | string;
8+
bool: boolean;
9+
int: bigint;
10+
string: string;
11+
pubkey: Uint8Array | string;
12+
sig: SignatureTemplate | Uint8Array | string;
13+
datasig: Uint8Array | string;
14+
};
15+
16+
// Helper type to process a single parameter by mapping its `type` to a value in `TypeMap`.
17+
// Example: { type: "pubkey" } -> Uint8Array
18+
// Branches:
19+
// - If `Param` is a known type, it maps the `type` to `TypeMap[Type]`.
20+
// - If `Param` has an unknown `type`, it defaults to `any`.
21+
// - If `Param` is not an object with `type`, it defaults to `any`.
22+
type ProcessParam<Param> = Param extends { type: infer Type }
23+
? Type extends keyof TypeMap
24+
? TypeMap[Type]
25+
: any
26+
: any;
27+
28+
// Main type to recursively convert an array of parameter definitions into a tuple.
29+
// Example: [{ type: "pubkey" }, { type: "int" }] -> [Uint8Array, bigint]
30+
// Branches:
31+
// - If `Params` is a tuple with a `Head` that matches `ProcessParam`, it processes the head and recurses on the `Tail`.
32+
// - If `Params` is an empty tuple, it returns [].
33+
// - If `Params` is not an array or tuple, it defaults to any[].
34+
export type ParamsToTuple<Params> = Params extends readonly [infer Head, ...infer Tail]
35+
? [ProcessParam<Head>, ...ParamsToTuple<Tail>]
36+
: Params extends readonly []
37+
? []
38+
: any[];
39+
40+
// Processes a single function definition into a function mapping with parameters and return type.
41+
// Example: { name: "transfer", inputs: [{ type: "int" }] } -> { transfer: (arg0: bigint) => ReturnType }
42+
// Branches:
43+
// - Branch 1: If `Function` is an object with `name` and `inputs`, it creates a function mapping.
44+
// - Branch 2: If `Function` does not match the expected shape, it returns an empty object.
45+
type ProcessFunction<Function, ReturnType> = Function extends { name: string; inputs: readonly any[] }
46+
? {
47+
[functionName in Function['name']]: (...functionParameters: ParamsToTuple<Function['inputs']>) => ReturnType;
48+
}
49+
: {};
50+
51+
// Recursively converts an ABI into a function map with parameter typings and return type.
52+
// Example:
53+
// [
54+
// { name: "transfer", inputs: [{ type: "int" }] },
55+
// { name: "approve", inputs: [{ type: "address" }, { type: "int" }] }
56+
// ] ->
57+
// { transfer: (arg0: bigint) => ReturnType; approve: (arg0: string, arg1: bigint) => ReturnType }
58+
// Branches:
59+
// - Branch 1: If `Abi` is `unknown` or `any`, return a default function map with generic parameters and return type.
60+
// - Branch 2: If `Abi` is a tuple with a `Head`, process `Head` using `ProcessFunction` and recurse on the `Tail`.
61+
// - Branch 3: If `Abi` is an empty tuple, return an empty object.
62+
// - Branch 4: If `Abi` is not an array or tuple, return a generic function map.
63+
type InternalAbiToFunctionMap<Abi, ReturnType> =
64+
// Check if Abi is typed as `any`, in which case we return a default function map
65+
unknown extends Abi
66+
? GenericFunctionMap<ReturnType>
67+
: Abi extends readonly [infer Head, ...infer Tail]
68+
? ProcessFunction<Head, ReturnType> & InternalAbiToFunctionMap<Tail, ReturnType>
69+
: Abi extends readonly []
70+
? {}
71+
: GenericFunctionMap<ReturnType>;
72+
73+
type GenericFunctionMap<ReturnType> = { [functionName: string]: (...functionParameters: any[]) => ReturnType };
74+
75+
// Merge intersection type
76+
// Example: {foo: "foo"} & {bar: "bar"} -> {foo: "foo", bar: "bar"}
77+
type Prettify<T> = { [K in keyof T]: T[K] } & {};
78+
79+
export type AbiToFunctionMap<T, ReturnType> = Prettify<InternalAbiToFunctionMap<T, ReturnType>>;

‎packages/cashscript/src/utils.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -345,12 +345,12 @@ export function findLastIndex<T>(array: Array<T>, predicate: (value: T, index: n
345345

346346
export const snakeCase = (str: string): string => (
347347
str
348-
&& str
349-
.match(
350-
/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g,
351-
)!
352-
.map((s) => s.toLowerCase())
353-
.join('_')
348+
&& str
349+
.match(
350+
/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g,
351+
)!
352+
.map((s) => s.toLowerCase())
353+
.join('_')
354354
);
355355

356356
// 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
368368
spaces,
369369
);
370370

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

0 commit comments

Comments
 (0)
Please sign in to comment.