A constraint-based UTXO model (4/4)
Here we present some example code of output constraints, as implemented in the EasyUTXO extensions of the standard EasyFL function library. The EasyFL code of function together with Go bindings can be found here.
timestamp
constraint
The timestamp
constraint is one of 3 mandatory constraints in EasyUTXO. The timestamp
constraint is defined in the library the following way:
// $0 - 4 bytes Unix seconds big-endian
func timestamp: and(
equal(selfBlockIndex,1), // must be at block 1
or(
// for 'produced' output $0 must be equal to the transaction timestamp
and( selfIsProducedOutput, equal($0, txTimestampBytes) ),
// for 'consumed' output $0 must be strictly before the
// transaction timestamp
and( selfIsConsumedOutput, lessThan($0, txTimestampBytes) )
)
)
The constraint takes 1 parameter. For example by adding the following expression as a constraint to the output timestamp(u32/1668857462)
(u32/
is a prefix of big-endian uint32 literal number) we are enforcing the following constraint upon the transaction:
- the index of the constraint (
selfBlockIndex
) in the output must be 1. This is mandatory constraint with statically known position. By adding it as, say, as 3rd block, we will make the whole transaction invalid. - if the constraint is evaluated in the produced output ( if
selfIsProducedOutput
returnstrue
) the timestamp must be equal to the one provided at the transaction level (txTimestampBytes
is defined as@Path(0x0004)
, the data element at path (0,4) ) - if the constraint is evaluated in the consumed output (
selfIsConsumedOutput
), the timestamp must be strictly less than the transaction timestamp
The timestamp
constraint enforces consistency of output timestamps in the EasyUTXO ledger.
‘timelock’ constraint
When added to output, the timelock
constraint enforces output to be locked (not possible to consume) until a specified deadline.
For example, constraint timelock(u32/1672531200
will only be satisfied if transaction timestamp will be after January 1, 2023 00:00 UTC.
Source code of the timelock
:
// enforces output can be unlocked only after specified time
// $0 is Unix seconds of the time lock (uint32 big-endian)
func timelock: or(
and(
selfIsProducedOutput,
equal(len8($0), 4), // must be 4-bytes long
// time lock must be after the transaction
// timestamp (not strictly necessary)
lessThan(txTimestampBytes, $0)
),
and(
selfIsConsumedOutput,
// is unlocked if transaction timestamp is strongly after the time lock
lessThan($0, txTimestampBytes)
)
)
Other constraints
The EasyUTXO PoC currently implements the following constraints:
- mandatory
amount
andtimestamp
constraints - several lock constraints:
addressED255199
, a siglock constraint, which requires signature to be unlocked
deadlineLock,
a lock constraint with expiry. After specified time the output becomes unlockable by the sender
chainLock
, a lock constraint which require specified chain to be transited in the same transaction (equivalent of sending to Alias address in the Stardust) chain
constraint. It is similar to Alias and NFT in Stardust. You can only consume the output by producing the successor output with the same chain identity. Thechain
constraint is basis for implementation of aliases, NFTs, identities, all kind of stake delegation outputs, proof of possession, UTXO smart contract chains and many other UTXO output typesimmutable
makes any constraint on the chain-constrained outputs to be repeated on the successor output. Immutable data (also can be a constraint) cannot be delete or modified upon chain transitions as long as the chain is not destroyedroyaltiesED25519
only allows consumption of the output, if at least specified amount of tokens send to specified address. In combination withchain
andimmutable
this can be used for example to enforce NFT royalties paid to the NFT author on each transfer of ownership.
Any other constrains can be added to any output inline, there are no limitations.
Satisfying the constraint in the transaction
When we want to consume outputs O1, … On in a transaction as inputs, each of those outputs brings their own conditions which must be satisfied in order to be able to produce a valid transaction. The transaction must provide data which satisfies the constraints contained in all consumed outputs. In other words, the user-defined produced outputs and other transaction data must satisfy immutable constraints on inputs.
Some examples we already seen: the transaction timestamp must be strictly larger than any timestamp in inputs.
Let’s say, a consumed output (input) O = (P1, .. Pn)
, where each Pi
is a constraint.
Each consumed output requires unlock parameters U1, .. Un
, one for each constraint in the input O
. Each Ui
is a byte array. We say unlock data Ui
satisfies constraint Pi
, and U1, .. Un
all together unlocks input O
.
Some constraints, such as amount
and timestamp
, do not require any unlock data, in this case the corresponding Ui
will be empty.
Some others, like addressED25519
require either signature, or reference to the signature as unlock data.
The very common unlock data are references to other outputs and constraints. By providing those references we are satisfying input constraint pointing it to the data, which is checked by its constraint’s expression. For example, chain
constraint will require unlock parameters to provide index of the successor chain
constraint on the produced output (see example below). The royaltiesED25519
constraint will require to provide an index of the produced output which transfers required amount of tokens to the NFT author. And so on.
So, instead of sending request to a smart contract which will produce transaction from it, we produce the transaction by satisfying its inputs.
Example of a transaction
The transaction below takes 2 inputs: chain-constrained consumed output #0 with 2000 tokens on it. It has 3 constraints, one of it is the chain
constraint. #1 consumed output is a chain-locked output with 1000 tokens on it. So, in total it is 3000 tokens controlled by the chain controller, the address.
The transaction consolidates 2500 tokens on the chain output and 500 tokens sends to some normal ED25519 address. This is a typical transaction for any chain VM.
To validate this transaction, all constraint codes are evaluated: 7 of them for consumed outputs and 7 for consumed outputs. The unlock data ensures that all 14 constraints are satisfied.
For example unlock data of the 3rd constraint in the #0 input (the chain
constraint) points to the successor chain constraint on the produced output.
The following picture displays printout of the example transaction. It illustrates how unlock data and other user-produced transaction data satisfies constraints of immutable inputs (consumed outputs).
The chainLock
constraint in the input #1 is unlocked by pointing to chain
constraint on another input: the condition for the chain-locked tokens to be unlocked is a valid chain transition on the same transaction.
Here we provide just a top part of the chain
source code. Full code can be found here. Note, that many of the EasyFL definitions of functions can be reused between different constraints, so it is a subject of optimization.
// Constraint source: chain($0)
// $0 - 35-bytes data:
// 32 bytes chain id
// 1 byte predecessor output index
// 1 byte predecessor block index
// 1 byte transition mode
// Transition mode:
// 0x00 - state transition
// 0xff - origin state,
func chain: and(
// chain constraint cannot be on output with index 0xff = 255
not(equal(selfOutputIndex, 0xff)),
or(
if(
// if it is produced output with zero-chainID, it is chain origin.
and(
isZero(chainID($0)),
selfIsProducedOutput
),
or(
// enforcing valid constraint data of the origin:
// must be equal to 'concat(repeat(0,32), 0xffffff)'
equal($0, originChainData),
!!!chain_wrong_origin
),
nil
),
// check validity of the chain transition. Unlock data of the
// constraint must point to the valid successor
// (in case of consumed output)
// or predecessor (in case of produced output)
and(
// 'consumed' side case, checking if unlock params and
// successor is valid
selfIsConsumedOutput,
or(
// consumed chain output is being destroyed (no successor)
equal(selfUnlockParameters, destroyUnlockParams),
// or it must be unlocked by pointing to the successor
validSuccessorData($0, chainSuccessorData),
!!!chain_wrong_successor
)
),
and(
// 'produced' side case, checking if predecessor is valid
selfIsProducedOutput,
or(
// 'produced' side case checking if predecessor is valid
validPredecessorData(
$0,
chainPredecessorData( predecessorConstraintIndex($0) )
),
!!!chain_wrong_predecessor
)
),
!!!chain_constraint_failed
)
)
This big formula may look scary, however most of it is comments. It states the following:
- if chain ID is all-0, check if it is a valid origin
- otherwise, if it is in a consumed output, find successor output on the ‘produced’ side (by unlock data) and check its validity
- otherwise, if it is a produced output, check validity of the predecessor on the ‘consumed’ side (referenced in the constraint argument)
chain
constraint ensures unique global identity of any state. Use cases such as NFTs, smart contract chains, digital identities, staking delegation and many other are built using chain constraint.
THE END