Modeling Contract Systems - Functional Approach
Introduction
When designing Contract Systems, another approach that is becoming increasingly common is to separate your contracts by function (i.e. most contracts like this will only contain call() method or similar). If BCH gets read-only inputs (see BitJSON's CHIP here), this might become the dominant way to develop cheap and efficient Contract Systems.
Conceptualizing Functional Contract Systems
One way to conceptualize these systems might be to separate state from the function being called. For example, using the Action/States approach in the previous tutorial, as opposed to stashing many unlocks into a single .cash contract, we could instead create a .cash file per each method.
We would then be able treat each .cash contract as its own UTXO that takes in parameters (inputs/state) and returns data (outputs/state). This is much like traditional programming in the sense that could model these like functions with positional arguments and positional returns (input positions and output positions).
Let's imagine a very simple contract that simply adds two numbers:
- One number contained in NFT A.
- Another number contained in NFT B.
... and then emits updated NFT commitments for each which is the sum of both numbers.
pragma cashscript ~0.12.0;
contract AddNumbers() {
function call() {
// Require that this UTXO is at input 0.
require(this.activeInputIndex == 0);
// Get the first number from Input 1.
int firstNumber = int(tx.inputs[1].nftCommitment.split(4)[0]);
// Get the second number from Input 2.
int secondNumber = int(tx.inputs[2].nftCommitment.split(4)[0]);
// Calculate the result of adding both numbers.
const expectedNumber = firstNumber + secondNumber;
// Get the numbers in the NFT commitments of Outputs 1 and 2.
const outputOneNumber = int(tx.outputs[1].nftCommitment.split(4)[0]);
const outputTwoNumber = int(tx.outputs[2].nftCommitment.split(4)[0]);
// Make sure output 1 and 2's NFT commitments match the expected number.
require(outputOneNumber == expectedNumber);
require(outputTwoNumber == expectedNumber);
// Finally, make sure that we re-create this UTXO so that it can be called repetively.
require(tx.outputs[0].lockingBytecode == tx.inputs[0].lockingBytecode);
}
}Though the above is, in practice, useless (and insecure) it demonstrates how we can treat UTXOs as "on-chain functions" that have "positional arguments" in a similar manner to functions in any standard programming language. For example, in a standard programming language, we might represent the above like so:
function addNumbers(this, firstNFT, secondNFT): [this, newUTXO1, newUTXO2] {
// ... function body
}Using On-Chain Programs
When writing contracts using CashScript, one way of leveraging these programs might be by using Lockscript Precommits. For example, imagine we have an NFT that wants to make use of this "AddNumbers" program. The CashScript code for this may look as follows:
pragma cashscript ~0.12.0;
contract UseAddNumbers(
bytes35 addNumbersPrecommitLockingBytecode
) {
function call() {
// Require that input 0 is the precommited Add Numbers program.
require(tx.inputs[0].lockingBytecode == addNumbersPrecommitLockingBytecode);
}
}Again, the above example is useless in practice. But, this approach does allow composing programs together which (in some cases) might make large Contract Systems both easier to manage (e.g. upgradability by simply changing the precommit program) and also more efficient.
In terms of how we might handle this in CashConnect, we can use the resolves parameteter when specifying lockingBytecode and unlockingBytecode within a transaction:
Instruction.transaction({
name: 'tx',
// Use any available inputs from the parent wallet.
inputs: [*],
outputs: [
{
lockingBytecode: {
script: '#UseAddNumbers.lock',
resolve: {
// Set the value of addNumbersPrecommitLockingBytecode to the lockscript of Add Numbers.
'addNumbersPrecommitLockingBytecode': '#AddNumbers.lock',
}
}
}
],
}If a given Lockscript Precommit is being re-used across a given action, this can also be done using a Resolves instruction.
Instruction.resolves({
// Resolve variable "addNumbersPrecommitLockingBytecode" to the "AddNumbers" locking bytecode for all instructions from here on in.
'addNumbersPrecommitLockingBytecode': '#AddNumbers.lock',
})If you need to resolve variables within the #AddNumbers.lock script, the resolve schema can also be used with the resolve instruction itself. For example:
Instruction.resolves({
// Resolve variable "addNumbersPrecommitLockingBytecode" to the "AddNumbers" locking bytecode for all instructions from here on in.
'addNumbersPrecommitLockingBytecode': {
script: '#AddNumbers.lock',
resolve: {
maxNumber: expr`maxNumber`
}
}
})Quirk
Currently, resolve only supports a single level of nesting. Technically, under the hood for anyone familiar with CashASM, it is just appending anything under resolve to CashASM's scripts for the current execution context. The longer term intent is to try to allow recursiveness by resolving these values to actual bytecode, but it becomes very tricky to do with unlockingBytecode as CashASM needs to be able to determine the coveredBytecode for signing. This means if you are using this pattern recursively and have variables of the same name that require different values, you might have to hack around it (e.g. giving these variables different names if the value differs).
Notes on the Resolve Instruction
This instruction is intended to be general purpose. Whatever variables are resolved will be added to the global stack for use in subsequent instructions and returns.
Many actions will require calculations to be derived for correct payout amounts from the given input parameters. For example, the following is the first instruction in the Guru Price Prediction Payout Action and is used to derive figures and values that are used in the subsequent transaction instructions.
INFO
Click Run Code to see how the above transpiles down to CashASM.
In the next tutorial, we'll look at using control-flow statements within templates (if statements, looping, etc).