From 3f9b52ca55953c00afdecf85a731d70cd1bf1425 Mon Sep 17 00:00:00 2001 From: Mathieu Geukens Date: Mon, 14 Sep 2026 12:34:12 +0200 Subject: [PATCH] docs: refresh optimisation guide for v0.14 (#430) The optimisation guide predated user-defined functions, global constants and imports, and framed optimisation as a bytesize-only concern. - Frame the guide around both budgets: bytesize and op-cost, and note that they pull in opposite directions for P2SH contracts. - Add a section on measuring op-cost with getVmResourceUsage(), which was documented on the TransactionBuilder but never linked from here. - Describe what the compiler already does: peephole optimisations and the inline-vs-OP_DEFINE decision for functions and constants. - Add a "Sharing code with functions and constants" section covering global constants, user-defined functions and imports. - Demote the "Combining Functions" workaround: extracting shared logic into user-defined functions is now the better first step. - Add a tip on returning multiple values from one function instead of recomputing shared work in two. Also document the nested-function call stack in the debugging guide, which was user-visible since #429 but undocumented, and fix a link to the HashType anchor that broke when it was renamed to SighashType. Correct the tuple destructuring guidance in contracts.md. It said that listing declarations before reassignments compiles smaller, but the opposite holds: visitTupleAssignment processes targets right-to-left and parks declarations on the altstack, so trailing declarations keep the stack shallower for the emitReplace calls, whose cost grows with depth. Measured at 58 / 55 / 52 bytes as the declaration moves rightwards. Every byte count quoted in the guide was measured against the exact code shown. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QbNNwhTXMdRuRBvwbjGSei --- website/docs/guides/debugging.md | 38 +++++ website/docs/guides/optimization.md | 207 ++++++++++++++++++++++++++-- website/docs/language/contracts.md | 2 +- website/docs/language/types.md | 2 +- 4 files changed, 232 insertions(+), 17 deletions(-) diff --git a/website/docs/guides/debugging.md b/website/docs/guides/debugging.md index 17f4cf28f..557df1534 100644 --- a/website/docs/guides/debugging.md +++ b/website/docs/guides/debugging.md @@ -34,6 +34,44 @@ Bitauth IDE: [link] Read the error message to see which line in the CashScript contract causes the transaction validation to fail. Investigate whether the contract function invocation is the issue (on the TypeScript SDK side) or whether the issue is in the CashScript contract itself (so you'd need to update your contract and recompile the artifact). If it is not clear **why** the CashScript contract is failing on that line, then you can use the following two strategies: console logging & Bitauth IDE stack trace. +### Call stacks + +When the failing `require` statement sits inside a [user-defined function](/docs/language/contracts#user-defined-functions), the error message also includes a call stack showing how execution reached it. The innermost frame is listed first, the contract function that started the call last. + +```solidity title="Test.cash" +function assertPositive(int value) { + require(value > 0, "value must be positive"); +} + +function validate(int amount) { + assertPositive(amount); + require(amount < 1000, "amount too large"); +} + +contract Test() { + function spend(int x) { + validate(x); + require(x < 100); + } +} +``` + +Spending this contract with `x = 0` produces: + +```bash +Test.cash:2 Require statement failed at input 0 in contract Test, function assertPositive (Test.cash, line 2) with the following message: value must be positive. +Failing statement: require(value > 0, "value must be positive"); + at assertPositive (Test.cash:2) — require(value > 0, "value must be positive"); + at validate (Test.cash:6) — assertPositive(amount) + at Test.cash:12 — validate(x) +``` + +This tells you not just which `require` failed, but which call site passed it the bad value. Functions imported from another file are reported with the file they are defined in, so a failure inside a shared library points at that library's source rather than at your contract. + +:::note +The call stack is only shown for nested calls. When a `require` fails directly in a contract function, the headline already says where it failed, so no call stack is added. +::: + ### Console Logging To help with debugging you can add `console.log` statements to your CashScript contract file to log variables. This way you investigate whether the variables have the expected values when they get to the failing `require` statement in the CashScript file. After adding the `console.log` statements, recompile your contract so they are added to your contract's Artifact. diff --git a/website/docs/guides/optimization.md b/website/docs/guides/optimization.md index e19e0b09f..2af564fa0 100644 --- a/website/docs/guides/optimization.md +++ b/website/docs/guides/optimization.md @@ -5,9 +5,14 @@ sidebar_label: Optimization CashScript contracts are transpiled from the high-level CashScript code to [BCH Script](https://reference.cash/protocol/blockchain/script) by the `cashc` compiler. BCH Script is the low-level language used for the Bitcoin Cash Virtual Machine (BCH VM) to evaluate contracts. -Because transaction fees are based on the bytesize of a transaction, it may be useful to optimize the compiled size of your smart contract by tweaking your CashScript code. +There are two separate budgets worth optimizing for: -## Example Workflow +- **Bytesize**: transaction fees are based on the bytesize of a transaction, and contract bytecode has a hard [size limit](/docs/compiler/script-limits#maximum-contract-size-p2sh) of 10,000 bytes for P2SH contracts. +- **Operation cost**: the BCH VM gives each input a compute budget based on the length of its unlocking bytecode. Contracts that do a lot of hashing or arithmetic can run out of [op-cost budget](/docs/compiler/script-limits#operation-cost-limit) before they run out of bytes. + +These two budgets pull in opposite directions. Shrinking a contract also shrinks its op-cost budget, because the budget is derived from the script length. Most contracts only need to care about bytesize, but it's worth knowing which limit you are actually up against before you start tweaking code. + +## Measuring Contract Size When optimizing your contract, you will need to continuously compare the contract size to see if the changes have a positive impact. With the compiler CLI, you can easily check the bytesize and opcode count directly from the generated contract artifact. @@ -20,12 +25,141 @@ The compiler calculates the size from the contract's bytecode without constructo The compiler `bytesize` output is still helpful to compare the effect of changes, given that the contract constructor arguments stay the same. :::tip -To get the exact contract bytesize including constructor parameters, initialise the contract with the TypScript SDK and check the value of `contract.bytesize`. +To get the exact contract bytesize including constructor parameters, initialise the contract with the TypeScript SDK and check the value of [`contract.bytesize`](/docs/sdk/instantiation#bytesize). +::: + +## Measuring Operation Cost + +Op-cost can only be measured for a concrete transaction, because the budget depends on the unlocking bytecode of the specific input. The [`getVmResourceUsage()`](/docs/sdk/transaction-builder#getvmresourceusage) method on the `TransactionBuilder` reports the usage per input against each of the VM limits. + +```ts +transactionBuilder.getVmResourceUsage(true); +``` + +``` +VM Resource usage by inputs: +┌─────────┬─────────────────────┬─────┬───────────────────────┬───────────┬──────────┐ +│ (index) │ Contract - Function │ Ops │ Op Cost Budget Usage │ SigChecks │ Hashes │ +├─────────┼─────────────────────┼─────┼───────────────────────┼───────────┼──────────┤ +│ 0 │ 'Vault - withdraw' │ 13 │ '2,180 / 41,600 (5%)' │ '0 / 1' │ '4 / 26' │ +└─────────┴─────────────────────┴─────┴───────────────────────┴───────────┴──────────┘ +``` + +If an input is close to its op-cost budget, see [buying compute budget](/docs/compiler/script-limits#buying-compute-budget) for how to use `unused` parameters to raise the budget. + +## What the Compiler Optimizes for You + +Before hand-tuning your code, it helps to know what `cashc` already does: + +- **Peephole optimizations**: the compiler rewrites common opcode sequences into shorter equivalents, for example `OP_SHA256 OP_SHA256` into `OP_HASH256`, or `OP_1 OP_ADD` into `OP_1ADD`. +- **Inlining of functions and global constants**: the compiler decides per function and per constant whether to inline it or to share it with the VM's `OP_DEFINE` and `OP_INVOKE` opcodes, picking whichever produces fewer bytes. See [Sharing code](#sharing-code-with-functions-and-constants) below. + +What the compiler does *not* do is reorder your declarations. The order of constructor arguments, of contract functions and of local variable declarations is preserved as you wrote it, so this ordering remains something you control by hand. + +:::note +Function parameters are the one exception: they may be reordered by the compiler when enforcing [function parameter types](/docs/language/contracts#function-arguments). +::: + +## Sharing Code with Functions and Constants + +The largest wins in most contracts come from removing duplication, and since v0.14 the compiler gives you two tools for that: [global constants](/docs/language/contracts#global-constants) and [user-defined functions](/docs/language/contracts#user-defined-functions). Both are declared at the top level of a `.cash` file and both can be shared across every function in the contract. + +### Global constants + +A local variable can only deduplicate a value within a single contract function. A global constant deduplicates it across the whole contract, which matters most for large values like token categories or public keys. + +```solidity title="Example CashScript code" + // do this + bytes constant TOKEN_ID = 0x8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf; + + contract Example() { + function first() { + require(tx.outputs[0].tokenCategory == TOKEN_ID); + require(tx.outputs[1].tokenCategory == TOKEN_ID); + } + function second() { + require(tx.inputs[0].tokenCategory == TOKEN_ID); + require(tx.inputs[1].tokenCategory == TOKEN_ID); + } + } + + // not this + contract Example() { + function first() { + bytes tokenId = 0x8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf; + require(tx.outputs[0].tokenCategory == tokenId); + require(tx.outputs[1].tokenCategory == tokenId); + } + function second() { + bytes tokenId = 0x8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf; + require(tx.inputs[0].tokenCategory == tokenId); + require(tx.inputs[1].tokenCategory == tokenId); + } + } +``` + +The version using the global constant compiles to 65 bytes, against 89 bytes for the version repeating the value in each function, because the 32-byte category is stored once and invoked from both functions. + +:::note +Global constants are not automatically cheaper than a local variable. The compiler compares the cost of a shared definition against the cost of inlining the value at each use, and picks the smaller one. Small values such as `int constant MIN_VALUE = 1000;` are simply inlined everywhere, and a constant used only once is inlined too. There is no overhead for writing a constant that turns out not to be worth sharing. ::: +### User-defined functions + +Logic that is repeated across contract functions can be extracted into a user-defined function. As with constants, the compiler inlines the body when that is smaller, and shares it with `OP_DEFINE` and `OP_INVOKE` when sharing is smaller. + +```solidity title="Example CashScript code" + // do this + function continuesVault(int index, bytes32 category) returns (bool) { + require(tx.outputs[index].tokenCategory == category); + require(tx.outputs[index].lockingBytecode == tx.inputs[0].lockingBytecode); + bool result = tx.outputs[index].value >= tx.inputs[0].value; + return result; + } + + contract Vault(bytes32 category) { + function deposit() { require(continuesVault(0, category)); } + function withdraw() { require(continuesVault(1, category)); } + function rollover() { require(continuesVault(2, category)); } + } + + // not this + contract Vault(bytes32 category) { + function deposit() { + require(tx.outputs[0].tokenCategory == category); + require(tx.outputs[0].lockingBytecode == tx.inputs[0].lockingBytecode); + require(tx.outputs[0].value >= tx.inputs[0].value); + } + function withdraw() { + require(tx.outputs[1].tokenCategory == category); + require(tx.outputs[1].lockingBytecode == tx.inputs[0].lockingBytecode); + require(tx.outputs[1].value >= tx.inputs[0].value); + } + function rollover() { + require(tx.outputs[2].tokenCategory == category); + require(tx.outputs[2].lockingBytecode == tx.inputs[0].lockingBytecode); + require(tx.outputs[2].value >= tx.inputs[0].value); + } + } +``` + +The shared version compiles to 44 bytes against 56 bytes for the spelled-out version, and the gap widens as the shared body grows or gains more call sites. + +:::tip +Extracting a helper function is free when it turns out not to be worth sharing. A small function that is used once compiles to exactly the same bytecode as writing its body inline, so you can structure your contract for readability first and let the compiler decide. +::: + +:::caution +A contract function must still end in a `require` statement, so a call to a void user-defined function cannot be the final statement of a contract function. Either have the helper return a `bool` and wrap the call in a `require`, as in the example above, or follow the call with another `require`. +::: + +### Importing shared code + +Functions and constants can be moved into separate files and pulled in with an [`import` directive](/docs/language/contracts#importing-functions-and-constants-from-other-files), including from npm packages. Imports do not change the compiled output compared to declaring the same functions locally, so this is purely a way to reuse and organise code across contracts. + ## Optimization Tips -The `cashc` compiler does some optimisations automatically. By writing your CashScript code in a specific way, the compiler is better able to optimise it. +By writing your CashScript code in a specific way, the compiler is better able to optimise it. ### 1. Declare variables @@ -39,7 +173,7 @@ Declare variables instead of hardcoding the same values in multiple places: // not this require(tx.outputs[0].tokenCategory == 0x8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf); - require(tx.inputs[1].tokenCategory == 0x8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf); + require(tx.outputs[1].tokenCategory == 0x8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf); ``` Also declare variables when re-using certain common introspection items to avoid duplicate expressions: @@ -55,6 +189,8 @@ Also declare variables when re-using certain common introspection items to avoid require(tx.outputs[1].tokenCategory == tx.inputs[0].tokenCategory.split(32)[0]); ``` +If the same value is needed in more than one contract function, use a [global constant](#global-constants) instead of repeating the declaration in each function. + ### 2. Consume stack items It's best to "consume" values (i.e. their final use in the contract) as soon as possible. This frees up space on the stack. @@ -71,6 +207,29 @@ When using `.split()` to use both sides of a `bytes` element, declare both parts bytes firstPart = tx.inputs[0].nftCommitment.split(10)[0]; bytes secondPart = tx.inputs[0].nftCommitment.split(10)[1]; ``` + +The same idea applies to user-defined functions. When two values are derived from the same intermediate work, return both from a single function with [multiple return values](/docs/language/contracts#user-defined-functions) rather than writing two functions that each redo that work. + +```solidity title="Example CashScript code" + // do this + function parse(bytes commitment) returns (int, int) { + bytes payload = commitment.split(4)[1]; + bytes amountBytes, bytes nonceBytes = payload.split(8); + return int(amountBytes), int(nonceBytes); + } + + // not this + function amountOf(bytes commitment) returns (int) { + bytes payload = commitment.split(4)[1]; + return int(payload.split(8)[0]); + } + + function nonceOf(bytes commitment) returns (int) { + bytes payload = commitment.split(4)[1]; + return int(payload.split(8)[1]); + } +``` + ### 4. Avoid if-else Avoid if-statements when possible. Instead, try to "inline" them. This is because the compiler cannot know which branches will be taken, and therefore cannot optimise those branches as well. This [example](https://gitlab.com/GeneralProtocols/anyhedge/contracts/-/blob/development/contracts/v0.11/contract.cash#L128-130) from AnyHedge illustrates inlining flow control: @@ -88,15 +247,33 @@ Avoid if-statements when possible. Instead, try to "inline" them. This is becaus } ``` -### 5. Trial & Error +### 5. Reassign before you declare -When the contract logic is finished, that is a great time to revisit the order of the contract's constructor argument, the different contract functions and even the contract parameters. Currently the compiler does not change/optimize the user-defined order, so in addition to the guidelines above, it can still be helpful to trial and error different ordering for the items. +When destructuring into a mix of new and existing variables inside a loop or a branch, listing the reassignments before the declarations compiles to smaller bytecode. -## Avoid Many Functions +```solidity title="Example CashScript code" + // do this + (current, next, int fresh) = step(current, next); -When a contract has many different functions or has a lot duplicate code shared across two functions, this can be a natural indication that contract optimization is possible. There's a different optimization strategy for each: + // not this + (int fresh, current, next) = step(current, next); +``` -### Modular Contract Design +Reassigning an existing variable inside a loop or branch means rolling the new value back down to the variable's slot, which gets more expensive the deeper that slot is. Fresh declarations are parked on the altstack as soon as they are handled, so putting them last keeps the stack shallower for the reassignments that follow. + +### 6. Trial & Error + +When the contract logic is finished, that is a great time to revisit the order of the contract's constructor arguments, the different contract functions and even the contract parameters. The compiler does not change the user-defined order, so in addition to the guidelines above, it can still be helpful to trial and error different ordering for the items. + +## Structural Optimizations + +When a contract has many different functions, or a lot of duplicate code shared across functions, this can be a natural indication that contract optimization is possible. + +### Extract shared logic first + +Before reaching for the more advanced strategies below, check whether the duplication can simply be removed with [user-defined functions and global constants](#sharing-code-with-functions-and-constants). This is by far the cheapest option, both in bytes and in complexity, and it does not require stepping away from the CashScript abstraction for contract structure. + +### Modular contract design Modular contract design avoids the added size of having many functions, instead the contract logic is separated out in to different components which we will call 'function contracts'. By only adding the function contract you are actually using in the transaction, and not all the other unused functions, you can drastically shrink the size of your contracts used in a transaction. @@ -107,14 +284,16 @@ The concept of having NFT functions was first introduced by the [Jedex demo](htt By using function NFTs you can use a modular contract design where the contract functions are offloaded to different UTXOs, each identifiable by the main contract by using the same tokenId. ::: -### Combining Functions +### Combining functions -If there is a lot of duplicate code across different functions in your contract, you could consider combining the functions into one, where the logic of the different functions are conditionally executed based on the function arguments, removing duplicate code. +If duplicate code remains after extracting it into user-defined functions, you could consider combining several contract functions into one, where the logic of the different functions is conditionally executed based on the function arguments. + +In CashScript, when defining multiple functions, a `selectorIndex` parameter is added under-the-hood to select which of the contract's functions you want to use, this wraps your functions in big `if-else` cases. When combining multiple functions into one you will have to think about the function conditions and `if-else` branching yourself. The difficulty with this approach is that CashScript functions expect a fixed number of arguments for each function. So when trying to combine two functions into one it might prove very difficult due to the different arguments they each expect. There is no notion of optional arguments or function overloading in CashScript currently. :::caution -This optimization is considered advanced, as it steps away from the CashScript abstraction for contract structure and often requires workarounds. +This optimization is considered advanced, as it steps away from the CashScript abstraction for contract structure and often requires workarounds. Since the introduction of user-defined functions, extracting the shared logic is usually the better first step. ::: ```solidity title="Example CashScript code" @@ -137,8 +316,6 @@ contract Example(){ } ``` -In Cashscript, when defining multiple functions, a `selectorIndex` parameter is added under-the-hood to select which of the contract's functions you want to use, this wraps your functions in big `if-else` cases. However when combining multiple functions in one cases you will have to think about the function conditions and `if-else` branching yourself. - ## Advanced: Hand-optimizing Bytecode You can still use the CashScript TypeScript SDK while using a hand-optimized or hand-written contract, although this is considered advanced functionality. diff --git a/website/docs/language/contracts.md b/website/docs/language/contracts.md index 4bec99458..9850bd85e 100644 --- a/website/docs/language/contracts.md +++ b/website/docs/language/contracts.md @@ -152,7 +152,7 @@ contract Example() { } ``` -Declarations and reassignments can be mixed freely in a single destructuring (e.g. `(int fresh, current, next) = step(current, next);`). Inside loops and branches, listing declarations before reassignments compiles to slightly smaller bytecode. +Declarations and reassignments can be mixed freely in a single destructuring (e.g. `(current, next, int fresh) = step(current, next);`). Inside loops and branches, listing reassignments before declarations compiles to smaller bytecode, as explained in the [optimization guide](/docs/guides/optimization#5-reassign-before-you-declare). :::info `checkSig`, `checkMultiSig` and `this.activeBytecode` cannot be used inside a user-defined function, since they would apply to the function body rather than the contract. Use them in a contract function instead (`checkDataSig` is allowed). diff --git a/website/docs/language/types.md b/website/docs/language/types.md index 4263202f0..1d1b18f92 100644 --- a/website/docs/language/types.md +++ b/website/docs/language/types.md @@ -127,7 +127,7 @@ Operators: - `!=` (inequality) ### Transaction Signature -`sig`: Byte sequence representing a transaction signature. Generally 65 bytes long: a 64-byte Schnorr signature followed by a single sighash flag byte that indicates which parts of the transaction were signed. See [HashType](/docs/sdk/signature-templates#hashtype) for the meaning of this byte. +`sig`: Byte sequence representing a transaction signature. Generally 65 bytes long: a 64-byte Schnorr signature followed by a single sighash flag byte that indicates which parts of the transaction were signed. See [SighashType](/docs/sdk/signature-templates#sighashtype) for the meaning of this byte. Operators: