A constraint-based UTXO model (4/4)

Unlocking the inputs. Examples

Evaldas
6 min readNov 28, 2022

Previous

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 returns true) 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 and timestamp 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. The chain 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 types
  • immutable 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 destroyed
  • royaltiesED25519 only allows consumption of the output, if at least specified amount of tokens send to specified address. In combination with chain and immutable 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

--

--