Walkthrough: Simulating state with Cashscript & js SDK

0 207
Avatar for Mr-Zwets
2 years ago

One of the coolest things you can do with covenants but that has for the most part been unused is the ability to simulate state in BCH smart contracts. The cashscript website provides a cool example cashscript contract of simulating with a great explanation. However, it has a few things going on at once, doesn't contain example code for how to make such contracts with the SDK and doesn't show the possibility of users adding funds to the contract balance to change the simulated state. That's what this article is for!

note: probably best to take a look at the cashscript getting started and covenant guide before delving into simulated state.

You can find the code in this article on this github repo

Minimal example

Like all React tutorials, this one is going to start with making the simulated state track value of a counter. We start from the cashscript p2pkh example, rename the contract to counter and add a requirement that the counter must be greater than 5.

pragma cashscript ^0.6.0;

contract counter(bytes20 pkh) {
    function spend(pubkey pk, sig s) {
        require(counter>5); // added line
        require(hash160(pk) == pkh);
        require(checkSig(s, pk));
    }
}

Of course the above code will not work because we have to define counter, we should initialize it in the constructor parameters. It's important for the rest of the article to understand conceptually how simulating state is done. The counter value is a simulated state we want to add to our covenant so each time we will cut out the counter parameter from the locking script and require the next contract iteration to have its value incremented by one. Because of this "cutting out" the length of counter needs to be known in advance, like bytes4 or bytes20 (as is also explained on the cashscript website). So lets add out counter as a byte (a simple way of writing bytes1, a number from 0 up to 255)

pragma cashscript ^0.6.0;

contract counter(bytes20 pkh, byte counter) { //add byte counter to constructor
    function spend(pubkey pk, sig s) {
        require(int(counter)>5); // convert byte to int and check inequality
        require(hash160(pk) == pkh);
        require(checkSig(s, pk));
    }
}

Now let's actually add the function for incrementing the counter! Remember we want to first read the value of the counter and increment it by one, then replace the old byte representing the counter value in the locking script with it and finally require that this locking script is used as a P2SH outpoint.

To get access to the bytecode we use tx.bytecode and to restrict the outpoints we use tx.hashOutputs. Constructor parameters are added in reverse order to the bytecode so our counter is actually the first two bytes, one that represents the bytelength of what's to come and the second our one byte counter value. 0x01 is the hexadecimal encoding of the number 1, if you wanted to push 20bytes you'd use 0x14 because that's the hex encoding for the number 20, for a handy converter see this link.

The code below is still a step-in-between and won't actually work.

pragma cashscript ^0.6.0;

contract counter(bytes20 pkh, byte counter) {
    function spend(pubkey pk, sig s) {
        require(int(counter)>5);
        require(hash160(pk) == pkh);
        require(checkSig(s, pk));
    }
    function increment() {
        int newCount = int(counter)+1;
        bytes newContract = 0x01 + byte(newCount) + tx.bytecode.split(2)[1];
        // cut the first two hexadecimals off, adding back push-1byte
        // followed by the new count value as a byte 

        bytes32 output = new OutputP2SH(amount, hash160(newContract));
        require(hash256(output) == tx.hashOutputs);
    }
}

Because of how covenants work we currently need an extra signature check to use tx.bytecode & tx.hashOutputs . We also need to specify an amount for tx.hashOutputs, we'll just use tx.value (the value of one input, so we assume we'll only have one) minus a hard coded miner fee.

note: with native introspection and big integers coming in May it will be simpler than it currently is! For example no weird bytes8(int(bytes(tx.value)) conversions like now, which limit the maximum size of the contract to ~21 BCH!

pragma cashscript ^0.6.0;

contract counter(bytes20 pkh, byte counter) {
    function spend(pubkey pk, sig s) {
        require(int(counter)>5);
        require(hash160(pk) == pkh);
        require(checkSig(s, pk));
    }
    function increment(pubkey pk, sig s) { // requirement for covenants
        require(checkSig(s, pk)); // requirement for covenants
        int newCount = int(counter)+1;
        bytes newContract = 0x01 + byte(newCount) + tx.bytecode.split(2)[1];
        int minerFee = 1000; // hardcoded fee
        bytes8 amount = bytes8(int(bytes(tx.value)) - minerFee);
        // calculation of output amount
        bytes32 output = new OutputP2SH(amount, hash160(newContract));
        require(hash256(output) == tx.hashOutputs);
    }
}

and that's it! The way it's currently written, anyone can invoke the increment() spending function because there is no restriction placed on the public key.

To compile the contract to the artifact that we need to use it with the SDK, type cashc counter.cash -o counter.json in the terminal in the folder were your .cash file is at.

Creating the counter contract with the SDK

Make sure you have installed cashscript in your project folder with npm install cashscript.

Starting with the SDK would look something like this, import the cashscript dependencies, initiate the contract with the necessary arguments and log the contract address & balance. The params [alicePkh, counterValueByte] are not defined yet but that's the next step.

const {
  Contract,
  SignatureTemplate,
  ElectrumNetworkProvider,
} = require("cashscript");

run();

async function run(){
  // Import the counter JSON artifact
  const artifact = require("./counter.json");

  const params = [alicePkh, counterValueByte];
  // Initialise a network provider for network operations on MAINNET
  const provider = new ElectrumNetworkProvider("mainnet");
  // Instantiate a new contract using the compiled artifact, 
  // constructor params & network provider
  const contract = new Contract(artifact, params, provider);

  console.log("contract address:", contract.address);
  const contractBalance = await contract.getBalance();
  console.log("contract balance:", contractBalance);
}

There are many different options for generating a wallet with keypair and pkh, in this tutorial we'll use bch-js a fork of bitbox that's actively maintained. (You can find its documentation here) Install it in your folder using npm i @psf/bch-js

For the second contract parameter, counterValueByte we'll first use the .toString(16) method and then convert the hexadecimal to bytes with libauth.

normally you have to convert the hexadecimals from .toString(16) to little endian to use them in the redeemscript! However, for 1 byte endianess does not apply

-> you can swap the endianess of a hex with libauth's swapEndianness()

const BCHJS = require("@psf/bch-js");
const { hexToBin } = require("@bitauth/libauth");
const {
  Contract,
  SignatureTemplate,
  ElectrumNetworkProvider,
} = require("cashscript");

run();

async function run(){
  // Import the counter JSON artifact
  const artifact = require("./counter.json");
  // Initialise BCHJS
  const bchjs = new BCHJS();

  // Initialise HD node and alice's keypair
  const rootSeed = await bchjs.Mnemonic.toSeed("example-wallet");
  const hdNode = bchjs.HDNode.fromSeed(rootSeed);
  const alice = bchjs.HDNode.toKeyPair(bchjs.HDNode.derive(hdNode, 0));

  // Derive alice's public key and public key hash
  const alicePk = await bchjs.ECPair.toPublicKey(alice);
  const alicePkh = await bchjs.Crypto.hash160(alicePk);
  // Derive alice's address
  const aliceAddress = bchjs.ECPair.toCashAddress(alice);
  console.log(`aliceAddress: `, aliceAddress);

  const counterValue = 0;
  const counterValueHex = counterValue.toString(16);
  const counterValueByte = hexToBin(counterValueHex);

  const params = [alicePkh, counterValueByte];
  // Initialise a network provider for network operations on MAINNET
  const provider = new ElectrumNetworkProvider("mainnet");
  // Instantiate a new contract using the compiled artifact, 
  // constructor params & network provider
  const contract = new Contract(artifact, params, provider);

  console.log("contract address:", contract.address);
  const contractBalance = await contract.getBalance();
  console.log("contract balance:", contractBalance);
}

That's quite some code already! Let's move on to actually writing the spending functions, I won't copy the previous code over for the sake of brevity.

The first spending function is just like for p2pkh contract, the second one requires us to change the constructor parameters of the contract by altering the hexadecimal redeem script. We access the redeemscript with contract.getRedeemScriptHex(). Then we slice off a hexadecimal twice (each being 2 characters) and add the new value of our constructor parameter.

We can either call contract spending functions with .send() at the end to broadcast the transaction or with .meep() to only get the debugging command for meep. Meep is a very handy tool for debugging! Now that we have the newRedeemScriptHex we use BCH-JS to convert it to a p2shAddr, finally we require this address becomes an output with the new balance of the contract.

Instead of parsing the redeemScriptHex, you could also just use cashscript to make a new contract with the new parameters and then use updatedContract.address. This might be the easier method but obscures why in the cashscript file we had to split the redeemScriptHex and add new parameters. Time will show which one is best practice.

You will have to install the meep debugger separately if you want to use the debugging command, install instructions can be found in the readme. To use the go-get command best to install go version 1.16 (or earlier). Stepping through the stack with meep might not work with git bash on windows.

async function run(){
  
  ... // see previous codeblock

  //assemble new redeemscripthex to get the p2shaddr to enforce as output 
  //when changing simulated state 
  const redeemScriptHex= contract.getRedeemScriptHex();
  let newRedeemScriptHex= redeemScriptHex.slice(2*2);
  const newCounterValue = counterValue + 1;
  const newCounterValueHex = newCounterValue.toString(16);
  newRedeemScriptHex= '01'+newCounterValueHex+newRedeemScriptHex;

  const bufferScript = Buffer.from(newRedeemScriptHex, 'hex');
  const p2sh_hash160 = bchjs.Crypto.hash160(bufferScript);
  const scriptPubKey = bchjs.Script.scriptHash.output.encode(p2sh_hash160);
  const p2shAddr = bchjs.Address.fromOutputScript(scriptPubKey);
  console.log(`next state of the covenant will be enforced with outpoint ${p2shAddr}`);

  const meepTx1 = await contract.functions
      .spend(alicePk, new SignatureTemplate(alice))
      .to(aliceAddress,contract.balance-1000)
      .meep();
  
  const meepTx2 = await contract.functions
    .increment(alicePk, new SignatureTemplate(alice))
    .to(p2shAddr, contract.balance-1000)
    .withHardcodedFee(1000)
    .meep();
}

Paying the contract to update the simulated state

It would be useful if you had to increase the balance of the contract to change the simulated state. For this you would have to add an additional input from a regular p2pkh UTXO to the previous outpoint of the contract. This is where the .experimentalFromP2PKH() comes in!

Legend has it that you have to DM the devs before being able to use this well guarded secret function.

You can see in the source code that you have to provide it arguments like so

const aliceUtxos = await new ElectrumNetworkProvider().getUtxos(aliceAddress);

const tx1 = await contract.functions
    .from(utxosContract[0])
    .experimentalFromP2PKH(aliceUtxos[0], new SignatureTemplate(alice))

with the regular .from() because now you have to do manual UTXO selection. Important is also that .from() comes before .experimentalFromP2PKH()

The extra input can be of any size, we will only require a certain increase to the amount sent to the new contract. Because we do this with tx.hashOutputs we will also have to provide the hash of the change output like this:

  bytes32 outputNewContract = new OutputP2SH(newBalance, hash160(newContract));
  bytes34 outputChange = new OutputP2PKH(amountChange, pkhNewRecipient);
  require(hash256(outputNewContract+outputChange) == tx.hashOutputs);

The contract does not know the value of all inputs to calculate the change - tx.value only gives access to the value of ONE input - so it is best to let the sender of the transaction calculate this and add it to the contract as a bytes8 parameter. This also means the contract doesn't necessarily know it's own value! (because tx.value could give the value of the added input) So what I did is keeping the contract balance in the simulated state.

It could be that at this point you'll have to change multiple values in the redeemscripthex. Then you have to be careful about the order of the arguments, the length of each argument and the total length of all values cut off. In this case if we keep the count as last constructor parameter then we have: we have push 1 byte, 1 byte newCount , push 8 bytes, 8 bytes newBalance for a total of 1+1+1+8=11

bytes newContract = 0x01 + byte(newCount) + 0x08 + newBalance + tx.bytecode.split(11)[1];

Then the newBalance variable required here and in the first output is just the old balance from the state plus or minus some amount. Here you have to mind the little endianess of newBalance! Also something to keep in mind: when hardcoding the outputs it can be that your miner fee becomes large enough that the SDK wants to add a change address. Ofcourse in covenant contracts you are exactely trying to restrict this so it can be helpfull to add .withoutChange() to your sending transaction.

Thank you for reading! Hopefully this was helpful!

3
$ 13.67
$ 9.52 from @TheRandomRewarder
$ 1.00 from @sploit
$ 1.00 from @im_uname
+ 3
Avatar for Mr-Zwets
2 years ago

Comments