Skip to content

How to Write a Contract


This section systematically introduces the complete syntax of the UTXO_Compiler contract language. If you prefer to learn from examples, you can start with Tutorial 1 and come back here as a reference.


1. Contract Structure

Each .ct file can only define one contract. The basic skeleton is as follows:

python
import std.p2pkh      # Optional: import libraries before the contract

Contract ContractName:
    # Struct definitions (optional, can have multiple)
    Struct StructName:
        fieldName: type

    # Public functions (at least one, serving as the spending entry point)
    def functionName(param: type):
        ...

    # Private helper functions (optional, prefixed with underscore)
    def _helperFunction(param: type):
        ...

Contract names, function names, and field names are identifiers following the same rules as Python: starting with a letter or underscore, followed by letters, digits, or underscores.

Libraries and imports

A .ct file still defines exactly one Contract, but it can reuse helper code before that contract. There are two common import styles:

python
import std.p2pkh          # Resolve std/p2pkh.ct under the standard-library root
import "./lib/math.ct"    # Resolve relative to the current file

A library is declared with Library <name>:. It can define Structs and helper functions, but it must not define contract-only entries such as main:

python
Library std.p2pkh:
    def verifyP2PKH(signature: hex, pubKey: hex, pubKeyHash: hex):
        EqualVerify(Hash160(pubKey.Clone()), pubKeyHash)
        result = CheckSig(signature, pubKey)
        return result

Imported library members are available to the host contract at compile time. Library functions are treated like private helpers: they can be called from the contract, but they are not exported as public ABI spending entries even when their names do not start with _.

Pass instance data explicitly from the host contract into the library call. For example, std.p2pkh receives pubKeyHash as an argument, and the host contract can pass self.pubKeyHash during the call.

python
import std.p2pkh

Contract Wallet:
    def main(signature: hex, pubKey: hex):
        ok = verifyP2PKH(signature, pubKey, self.pubKeyHash)

import detects circular imports, and duplicate imports of the same library are expanded only once. Use your compiler configuration to set the standard-library search path when needed.


2. Data Types

Primitive Types

TypeDescriptionLiteral Example
int,numberInteger (BVM big integer)0, 42, -100
stringByte string"hello", "world"
hexHexadecimal byte array0x1234, 0xdeadbeef
boolBoolean value1 (true), 0 (false)
addressBitcoin P2PKH address"1RainRzqJtJxHTngafpCejDLfYq2y4KBc"

The address type only supports standard Base58 P2PKH addresses (starting with 1, 34 characters). hex and string are both byte sequences at the underlying level; the difference is only in how the literals are written.

Fixed-length hex Type

In struct fields, you can use hexN to declare fixed-byte-length hexadecimal fields, commonly used to describe fixed-format fields in Bitcoin transactions:

python
Struct TxInput:
    txid:     hex32   # 32-byte transaction ID
    vout:     hex4    # 4-byte output index
    sequence: hex4    # 4-byte sequence number

Fixed-length hexN types are often used together with inline anonymous struct types when you need a temporary view over an exact byte layout.

Array Types

Struct fields, function parameters, and self members support fixed-length arrays Type[N]. Common forms are shown below.

Struct fields can declare the field type as Type[N]:

python
Struct Transaction:
    Inputs:  TxInput[3]    # 3 inputs
    Outputs: TxOutput[3]   # 3 outputs

Function parameters can also be fixed-length arrays:

python
def main(sigs: hex[3], pubKeys: hex[3], masks: bool[3]):
    ...

self members are instance data substituted during deployment, and can also be read through fixed-array subscripts:

python
    for i in Range(2, -1, -1):
        if masks[i] == 1:
            SetMain(total)
            if And(Equal(Hash160(pubKeys[i].Clone()), self.addr[i]), CheckSig(sigs[i], pubKeys[i])):
                total = total + 1
                SetAlt(total)
            else:
                total = total + 0
                SetAlt(total)
        else:
            Delete(pubKeys[i])
            Delete(sigs[i])

Array subscript access includes tx.Inputs[0], pubKeys[i], and self.pubKeyHashes[0]. The index may be an integer literal or a variable produced by a fixed Range loop. If the index is an integer variable, it is usually used together with a struct array field to access the corresponding struct field.

python
    vout = BinToNum(BVM.unlockingInput.Slice(32, 4)) # Get the position of code in the parent transaction output
    vout_copy = vout.Clone()
    # Get code_data
    code_data = pretx.Outputs[vout_copy].LockingScript.SuffixData.Clone()

uint64[] Array

Besides struct arrays, contracts also commonly use uint64[] (written as uint64[N]) to represent fixed-length 64-bit unsigned integer arrays. When not in use, the array field as a whole occupies one stack position; when needed, it is moved to the top via OP_ROLL, then split into individual elements via multiple OP_SPLIT operations.

python
    amount: uint64[6] = temp_data.Slice(3, 48)
    ft_amount_tax = Push(0)
    for i in Range(5, -1, -1):
        ft_amount_tax = BinToNum(amount[i]) + ft_amount_tax

Key points:

  • The N in uint64[N] must be a compile-time-determinable fixed length;
  • Element access uses arr[i], with indices recommended to traverse fixed boundaries with Range;
  • uint64 elements are processed as 8 bytes, suitable for integer sequences like amounts, counters, and indices.

If you need to express a set of count values in a struct, you can also declare directly:

python
Struct BatchData:
    counts: uint64[4]

Inline Anonymous Struct Types

For compound fields used temporarily, you can inline them directly without defining a separate struct:

python
utxoData: {txid: hex32, vout: hex4, sequence: hex4}
utxoData = Push(BVM.unlockingInput)
vout = BinToNum(utxoData.vout)

3. Variables

Assignment

python
count = 10
result = Hash160(pubKey)     # Bind function return value to a variable

Local variables (except arrays and inline anonymous struct types) can be directly assigned and used; struct fields and function parameters must declare types.

Contract Member Variables (self)

Contract member variables can be used directly; they are replaced with fixed constants in the bytecode at compile time and can be read multiple times in both public and private functions, not subject to ownership restrictions:

python
Contract P2PKH:
    def verify(sig: hex, pubKey: hex):
        pubKey_copy = pubKey.Clone()
        pubKeyHash = Hash160(pubKey_copy)
        EqualVerify(pubKeyHash, self.pubKeyHash)
        result = CheckSig(sig, pubKey)

4. Operators

Arithmetic Operators

python
sum   = a + b
diff  = a - b
prod  = a * b
quot  = a / b   # Integer division

There is no modulo operator; use the built-in function Mod(a, b).

Comparison Operators

python
a == b    # Equal to
a != b    # Not equal to
a <  b    # Less than
a >  b    # Greater than
a <= b    # Less than or equal to
a >= b    # Greater than or equal to

Comparison operators return integer 1 (true) or 0 (false), usable directly in if conditions or Return.

Logical Operators

The contract language has no and / or / not keywords; logical operations are all handled by built-in functions:

python
ok = And(condition1, condition2)   # Logical AND
ok = Or(condition1, condition2)    # Logical OR
ok = Not(condition)                # Logical NOT

5. Control Flow

Conditional Statements

python
if amount > threshold:
    CheckSigVerify(sig, pubKey)
else:
    Return (1 == 0)   # Reject

if and else branches must appear in pairs; multiple branches require nested if:

python
if role == 1:
    _handleBuyer(sig, pubKey)
else:
    if role == 2:
        _handleSeller(sig, pubKey)
    else:
        Return (1 == 0)

Loop Statements

Loops use the for ... in Range(start, stop, step) form, with semantics similar to Python's range, but the parameter order is (start, stop_exclusive, step):

python
# Decrement from 2 to 0 (inclusive)
for i in Range(2, -1, -1):
    data = Cat(items[i], data)

# Increment from 0 to 2 (inclusive)
for i in Range(0, 3, 1):
    total = Add(total, values[i])

The loop count must be determined at compile time; primarily used to iterate over fixed-size arrays or perform a fixed number of operations.


6. Functions

Public Functions (Spending Entry Points)

Functions not starting with _ are public functions. They are exported in the ABI and execute serially in declaration order:

python
Contract MultiPath:
    # Step 1: verify previous transaction/state
    def loadPreviousState(pretx: PreTX):
        ...

    # Step 2: verify current transaction
    def verifyCurrent(ctx: CurrentTX):
        ...

This is different from SDKs where the caller selects one public method. All public functions form one continuous locking-script flow. For mutually exclusive paths, prefer one public entry with a path argument and dispatch internally:

python
def main(sig: hex, pubKey: hex, path: int):
    if path == 0:
        _spend(sig, pubKey)
    else:
        _refund(sig, pubKey)

This keeps the unlocking interface clear: the caller supplies one path selector plus the parameters needed by that path.

Private Helper Functions

Functions starting with _ can only be called internally within the contract, used to encapsulate repetitive logic:

python
def _verifyOwner(sig: hex, pubKey: hex, expectedHash: hex):
    pubKeyCopy = pubKey.Clone()
    pubKeyHash = Hash160(pubKeyCopy)
    EqualVerify(pubKeyHash, expectedHash)
    CheckSigVerify(sig, pubKey)

def spend(sig: hex, pubKey: hex, ownerHash: hex):
    ...
    _verifyOwner(sig, pubKey, ownerHash)
    ...

Return Statement

Return (capitalized) pushes the expression result onto the execution stack and generates an "OP_RETURN":

python
Return (1 == 1)               # Always pass
Return (1 == 0)               # Always reject
Return CheckSig(sig, pubKey)  # Signature verification result as return value
Return And(cond1, cond2)      # Combined condition

Lowercase return is used in private functions to return a value to the caller:

python
def _computeHash(data: hex):
    result = Hash160(data)
    return result

7. Structs

Structs describe the byte layout of composite data, typically corresponding to the format of a segment of transaction data:

python
Struct Script:
    SuffixData:  string
    PartialHash: string
    Size:        int

Struct Output:
    Value:         int
    LockingScript: Script    # Structs can be nested

Struct PreTX:
    VLIO:                string
    Inputs:              Input[3]
    UnlockingScriptHash: string
    Outputs:             Output[3]

Struct field access uses the . operator, supporting multi-level chained access:

python
scriptSize = pretx.Outputs[0].LockingScript.Size

Note: Field access consumes the ownership of that field. Accessing the same field twice requires .Clone() before the first access. See Ownership System for details.


8. Destructuring Assignment

The {} syntax is used to receive results from functions that return multiple values, or to initialize structs:

python
# Receive two return values from Split
{header, body} = Split(rawData, 4)

# Receive multiple return values from a private function
{x, y} = _getCoords(encoded)

# Struct literal initialization (in field order)
point: Point = {10, 20}

9. Common Contract Patterns

Most contracts fall into a few practical families: signature locks, branch locks, state-continuation contracts, and transaction-structure verification contracts. When writing a new contract, first decide which family it belongs to, then start from the closest skeleton.

Minimal Signature Lock: Prefer Libraries

For a simple P2PKH wallet, avoid rewriting the script template. Import the standard helper instead:

python
import std.p2pkh

Contract MyWallet:
    def main(signature: hex, pubKey: hex):
        ok = verifyP2PKH(signature, pubKey, self.pubKeyHash)

The host contract passes self.pubKeyHash into the library function; deployment replaces that placeholder with the real 20-byte Hash160. The contract file keeps the business entry small while the reusable standard check lives in the library.

Multi-Path Contracts: One Entry Plus path

Because public functions run serially, mutually exclusive paths should not be represented as multiple public methods. Use a main function that receives path, executes the selected branch, and clears branch-unused parameters with Delete.

python
Contract AtomicSwap:
    def main(x: hex, sig: hex, path: number):
        if path == 1:
            EqualVerify(Sha256(x), self.hashX)
            CheckSigVerify(sig, self.receiver)
        else:
            Delete(x)
            NumEqualVerify(GreaterOrEqual(BVM.locktime, self.timeout), 1)
            CheckSigVerify(sig, self.sender)

This structure fits timeout refunds, auction closing, swap cancellation, and similar flows. As branches grow, keep main as the dispatcher and move each path into a private function.

State Continuation: Verify Parent and Current Outputs

State-continuation contracts spend an old state UTXO and require the new transaction to create a valid next state. A common structure is:

python
Struct Script:
    SuffixData: string
    PartialHash: string
    Size: number

Struct Output:
    Value: number
    LockingScript: Script

Struct Input:
    Data: {txid: hex32, vout: hex4, sequence: hex4}

Struct PreTX:
    VLIO: string
    Inputs: Input[3]
    UnlockingScriptHash: string
    Outputs: Output[3]

Struct CurrentTX:
    Outputs: Output[3]

Parent transaction checks usually do three things:

  • Read the txid and vout referenced by the current input from BVM.unlockingInput;
  • Rebuild the parent transaction hash from pretx and compare it with the input txid;
  • Read old state, old value, and old script PartialHash/Size from pretx.Outputs[vout].

Current transaction checks usually do two things:

  • Ensure an output continues the same contract code segment, for example unchanged PartialHash and Size;
  • Concatenate every committed output, compute Sha256(outputs_data), and compare it with BVM.outputsHash.
python
outputs_data = Push(0)
SetAlt(outputs_data)
for i in Range(2, -1, -1):
    size = ctx.Outputs[i].LockingScript.Size.Clone()
    if size != 0:
        temp = PartialHash(ctx.Outputs[i].LockingScript.SuffixData, ctx.Outputs[i].LockingScript.PartialHash, ctx.Outputs[i].LockingScript.Size)
        temp = Cat(ctx.Outputs[i].Value, temp)
        SetMain(outputs_data)
        outputs_data = Cat(temp, outputs_data)
        SetAlt(outputs_data)
    else:
        Delete(ctx.Outputs[i].LockingScript.Size)
        Delete(ctx.Outputs[i].LockingScript.PartialHash)
        Delete(ctx.Outputs[i].LockingScript.SuffixData)
        Delete(ctx.Outputs[i].Value)

SetMain(outputs_data)
EqualVerify(Sha256(outputs_data), BVM.outputsHash)

Oracles and External Messages: Verify, Then Parse

For oracle or external-message contracts, clone the message once for parsing, then pass the original message into signature verification.

python
msg_for_parse = msg.Clone()
schnorrVerify(msg, R, s, self.oraclePubKey, self.generator, self.modulus)

{asset_id, rest1} = Split(msg_for_parse, 32)
{timestamp_b, price_b} = Split(rest1, 8)
EqualVerify(asset_id, self.assetId)

For unsigned little-endian integer fields, a common pattern is to append 0x00 before numeric conversion so the highest bit is not interpreted as a negative sign:

python
price_num = BinToNum(Cat(price_b, 0x00))

Writing Checklist

  • Decide the entry model first: use main for a single path, main(path, ...) for mutually exclusive paths, and multiple public functions only for serial verification steps;
  • Describe every transaction fragment with Struct instead of scattering magic offsets through the contract;
  • If a local variable or struct field is read twice, call .Clone() before the first consuming use;
  • Clear parameters, fields, or structs unused by a branch with Delete(...);
  • Keep loop bounds fixed; use Range for batch signatures, batch outputs, and fixed-field parsing;
  • Use SetAlt / SetMain for intermediate values that must survive loops or branches;
  • When checking current outputs, finish with an aggregate BVM.outputsHash commitment;
  • When checking a parent transaction, rebuild preTXID and compare it with BVM.unlockingInput.Slice(0, 32).

10. Comments

Use # for line comments:

python
# This is a full-line comment
count: int = 0    # This is an end-of-line comment

Multi-line comments are not currently supported; longer comments need # on each line.


Beginner Pitfalls

  • Putting multiple contracts in one file: each .ct file can contain only one Contract.
  • Using multiple public functions for mutually exclusive paths: public functions run serially; use main(path, ...) for branch dispatch.
  • Reusing a consumed variable: local variables are often consumed after being passed to built-ins; call .Clone() before reuse.
  • Using runtime values as loop bounds: Range() bounds must be known at compile time.
  • Serializing struct fields in a different order: transaction data must follow the order declared by the Struct.

Quick Review

  • A contract file consists of one Contract, typed declarations, functions, and optional Structs.
  • Public functions are spending entries; private functions are for internal decomposition.
  • self.X is instance data and can be read repeatedly without normal local-variable ownership limits.
  • Clone copies reusable values, while Delete clears unused values.
  • Stateful contracts usually verify both the parent transaction and the current transaction outputs.

Next Steps


🇨🇳 中文版