Writing Covenants & Introspection
Covenants are all the rage in Nexa smart contracts. But what are they, and how do you use them? In one sentence: a covenant is a constraint on how money can be spent. A simple example is creating a smart contract that may only send money to one specific address and nowhere else. The term Covenant originates in property law, where it is used to constrain the use of any object - or in the case of Nexa, the use of money.
Bitcoin covenants were first proposed in a paper titled Bitcoin Covenants, but several other proposals have been created over the years. Nexa has implemented so-called Native Introspection which enables efficient and accessible covenants. This was extended with token introspection opcodes with the implementation of OP_PUSH_TX_STATE support.
Accessible introspection data
When using NexScript, you can access a lot of introspection data that can be used to inspect and constrain transaction details, such as inputs and outputs.
int this.activeInputIndex- Index of the input that is currently under evaluation during transaction validation.bytes this.activeBytecode- Contract bytecode of the input that is currently under evaluation during transaction validation.int tx.version- Version of the transaction.int tx.locktime-nLocktimefield of the transaction.int tx.inputs.length- Number of inputs in the transaction.int tx.inputs[i].value- Value of a specific input (in satoshis).bytes tx.inputs[i].lockingBytecode- Locking bytecode (scriptPubKey) of a specific input.bytes tx.inputs[i].unlockingBytecode- Unlocking bytecode (scriptSig) of a specific input.bytes32 tx.inputs[i].outpointTransactionHash- Outpoint transaction hash of a specific input.int tx.inputs[i].outpointIndex- Outpoint index of a specific input.int tx.inputs[i].sequenceNumber-nSequencenumber of a specific input.int tx.inputs[i].type- Type of a specific input (0spends a UTXO,1is read-only).int tx.outputs.length- Number of outputs in the transaction.int tx.outputs[i].value- Value of a specific output (in satoshis).bytes tx.outputs[i].lockingBytecode- Locking bytecode (scriptPubKey) of a specific output.int tx.outputs[i].type- Type of a specific output (1script template,0plain script).
Using introspection data
While we know the individual data fields, it's not immediately clear how this can be used to create useful smart contracts on Nexa. But there are several constraints that can be created using these fields, most important of which are constraints on the recipients of funds, so that is what we discuss.
Restricting P2PKT recipients
One interesting technique in Nexa is called blind escrow, meaning that funds are placed in an escrow contract. This contract can only release the funds to one of the escrow participants, and has no other control over the funds.
contract Escrow(bytes20 arbiter, bytes20 buyer, bytes20 seller) {
function spend(pubkey pk, sig s) {
require(hash160(pk) == arbiter);
require(checkSig(s, pk));
// Check that the correct amount is sent
int minerFee = 1000; // hardcoded fee
int amount = tx.inputs[this.activeInputIndex].value - minerFee;
require(tx.outputs[0].value == amount);
// Check that the transaction sends to either the buyer or the seller
bytes23 buyerLock = new LockingBytecodeP2PKT(buyer);
bytes23 sellerLock = new LockingBytecodeP2PKT(seller);
bool sendsToBuyer = tx.outputs[0].lockingBytecode == buyerLock;
bool sendsToSeller = tx.outputs[0].lockingBytecode == sellerLock;
require(sendsToBuyer || sendsToSeller);
}
}
The contract starts by doing some checks to make sure the transaction is signed by the arbiter. Next up it checks that the full contract balance (tx.inputs[this.activeInputIndex].value) is sent to the first output by accessing tx.outputs[0].value. Finally it checks that the receiver of that money is either the buyer or the seller using LockingBytecodeP2PKT and tx.outputs[0].lockingBytecode. Note that we use a hardcoded fee as it is difficult to calculate the exact transaction fee inside the smart contract.
Restricting P2ST recipients
Besides sending money to P2PKT addresses, it is also possible to send money to a smart contract (P2ST) address. This can be used in the same way as a P2PKT address if the script hash is known beforehand, but this can also be used to make sure that money has to be sent back to the current smart contract.
This is especially effective when used together with time constraints. An example is the Licho's Last Will contract. This contract puts a dead man's switch on the contract's holdings, and requires the owner to send a heartbeat to the contract every 90 days. If the contract hasn't received this heartbeat, an inheritor can claim the funds instead.
contract LastWill(bytes20 inheritor, bytes20 cold, bytes20 hot) {
function inherit(pubkey pk, sig s) {
require(tx.age >= 64800); // 90 days of 2 minute blocks
require(hash160(pk) == inheritor);
require(checkSig(s, pk));
}
function cold(pubkey pk, sig s) {
require(hash160(pk) == cold);
require(checkSig(s, pk));
}
function refresh(pubkey pk, sig s) {
require(hash160(pk) == hot);
require(checkSig(s, pk));
// Check that the correct amount is sent
int minerFee = 1000; // hardcoded fee
int amount = tx.inputs[this.activeInputIndex].value - minerFee;
require(tx.outputs[0].value == amount);
// Check that the funds are sent back to the contract
bytes contractLock = tx.inputs[this.activeInputIndex].lockingBytecode;
require(tx.outputs[0].lockingBytecode == contractLock);
}
}
This contract has three functions, but only the refresh() function uses a covenant. Again it performs necessary checks to verify that the transaction is signed by the owner, after which it checks that the entire contract balance is sent. It then uses tx.inputs[this.activeInputIndex].lockingBytecode to access its own locking bytecode, which can be used as the locking bytecode of this output. Sending the full value back to the same contract effectively resets the tx.age counter, so the owner of the contract needs to do this every 90 days. tx.age counts blocks here: a relative time lock is at most 65535 blocks, about 91 days (see tx.age). The SDK sets the input's age with withAge().
Restricting P2PKT and P2ST
The earlier examples showed sending money to only a single output of either P2PKT or P2ST. But there nothing preventing us from writing a contract that can send to multiple outputs, including a combination of P2PKT and P2ST outputs. A good example is the Licho's Mecenas contract that allows you to set up recurring payments where the recipient is able to claim the same amount every month, while the remainder has to be sent back to the contract.
contract Mecenas(bytes20 recipient, bytes20 funder, int pledge, int period) {
function receive() {
require(tx.age >= period);
// Check that the first output sends to the recipient
bytes23 recipientLockingBytecode = new LockingBytecodeP2PKT(recipient);
require(tx.outputs[0].lockingBytecode == recipientLockingBytecode);
// Calculate the value that's left
int minerFee = 1000;
int currentValue = tx.inputs[this.activeInputIndex].value;
int changeValue = currentValue - pledge - minerFee;
// If there is not enough left for *another* pledge after this one,
// we send the remainder to the recipient. Otherwise we send the
// pledge to the recipient and the change back to the contract
if (changeValue <= pledge + minerFee) {
require(tx.outputs[0].value == currentValue - minerFee);
} else {
require(tx.outputs[0].value == pledge);
bytes changeBytecode = tx.inputs[this.activeInputIndex].lockingBytecode;
require(tx.outputs[1].lockingBytecode == changeBytecode);
require(tx.outputs[1].value == changeValue);
}
}
function reclaim(pubkey pk, sig s) {
require(hash160(pk) == funder);
require(checkSig(s, pk));
}
}
This contract applies similar techniques as the previous two examples to verify the signature, although in this case it does not matter who the signer of the transaction is. Since the outputs are restricted with covenants, there is no way someone could call this function to send money anywhere but to the correct outputs.
Local state
Smart contracts which persist for multiple transactions might want to keep data for later use, this is called local state. A contract cannot change its own parameters, but a covenant can require that the funds go to a new instance of the same contract with different parameters: the same template script, with a new constraint script (hidden parameters) or new visible parameters. The address changes with each update, but the template hash, which identifies the contract code, stays the same.
To demonstrate this we consider the Mecenas contract again, and focus on a drawback of this contract: you have to claim the funds at exactly the right moment or you're leaving money on the table. Every time you claim money from the contract, the tx.age counter is reset, so the next claim is possible period blocks after the previous claim. So if we wait a few days to claim, these days are basically wasted.
Besides these wasted days it can also be inconvenient to claim at set intervals, rather than the "streaming" model that the Ethereum project Sablier employs. Instead of set intervals, you should be able to claim funds at any time during the "money stream". Using local state, we can approach a similar system with Nexa.
contract StreamingMecenas(
int initialBlock,
bytes20 visible recipient,
bytes20 visible funder,
int visible pledgePerBlock
) {
function receive() {
// Check that the first output sends to the recipient
bytes23 recipientLockingBytecode = new LockingBytecodeP2PKT(recipient);
require(tx.outputs[0].lockingBytecode == recipientLockingBytecode);
// Check that the stream has started. tx.time also makes the transaction's
// locktime a block height, which tx.locktime then reads
require(tx.time >= initialBlock);
// Calculate the amount that has accrued since the last claim
int passedBlocks = tx.locktime - initialBlock;
int pledge = passedBlocks * pledgePerBlock;
// Calculate the leftover amount
int minerFee = 1000;
int currentValue = tx.inputs[this.activeInputIndex].value;
int changeValue = currentValue - pledge - minerFee;
// If there is not enough left for *another* block after this one,
// we send the remainder to the recipient. Otherwise we send the
// pledge to the recipient and the change to a new stream
if (changeValue <= pledgePerBlock + minerFee) {
require(tx.outputs[0].value == currentValue - minerFee);
} else {
require(tx.outputs[0].value == pledge);
require(tx.outputs[1].value == changeValue);
// The new stream uses the same template and visible parameters, and
// starts at this transaction's locktime
bytes20 templateHash = hash160(this.activeBytecode);
bytes20 constraintHash = hash160(encodeNumber(tx.locktime));
bytes visibleParameters = encodeData(recipient) + encodeData(funder) + encodeNumber(pledgePerBlock);
require(tx.outputs[1].lockingBytecode
== new LockingBytecodeP2ST(templateHash, constraintHash, visibleParameters));
}
}
function reclaim(pubkey pk, sig s) {
require(hash160(pk) == funder);
require(checkSig(s, pk));
}
}
Instead of having a pledge per period, we define a pledge per block. At any point in time we can calculate how much money the recipient has earned since the stream's initialBlock. Then the covenant enforces that this amount is withdrawn from the contract. The remainder is sent to a new stream that starts at the block of this claim: the template and visible parameters stay the same, and the hidden initialBlock becomes the transaction's locktime.
The new locking bytecode is built from three parts:
hash160(this.activeBytecode)is the template hash of the running contract.- The constraint script of a contract is its hidden constructor parameters, pushed in order. Here that is only
initialBlock, so the new constraint script isencodeNumber(tx.locktime), and its hash is the constraint hash. - The visible parameters are pushed in order too, so they are rebuilt with
encodeData()andencodeNumber(). Visible parameters must be declared after the hidden ones (see constructor).
In the SDK the new stream is just another instance of the contract, so its address is easy to compute when building the claim:
const height = await provider.getBlockHeight();
const pledge = BigInt(height - initialBlock) * pledgePerBlock;
const nextStream = new Contract(artifact, [BigInt(height), recipient, funder, pledgePerBlock], { provider });
await stream.functions.receive()
.to([
{ to: recipientAddress, amount: pledge },
{ to: nextStream.address, amount: balance - pledge - 1000n },
])
.withTime(height)
.withHardcodedFee(1000n)
.send();
Conclusion
We have discussed the main uses for covenants as they exist on Nexa today. We've seen how we can achieve different use case by combining transaction output restrictions to P2ST and P2PKT outputs. We also touched on more advanced subjects such as mutating the local state through the constraints script. Covenants, script templates and Group tokens are the main differentiating factor for Nexa smart contracts when compared to BTC, while keeping the same efficient stateless verification.