Tested recipes

Each recipe is a complete contract from the examples/ folder with a scenario that checks its behaviour, including the failures. Every scenario runs in continuous integration (cargo test and tccl test examples), so the code on this page is known to work with this release.

Run one yourself:

git clone https://github.com/LucasBolla94/tccl && cd tccl/examples
tccl test counter.scenario

Or open the contract in the playground: the scenario is loaded into the Scenario tab.

Counter

State, actions, views and events — the smallest useful contract. Language version 1, deployable on The Coin today.

counter.tccl# The smallest useful contract: a counter anyone can increase.
contract Counter

state count: int
state last_caller: address

event Increased(by: address, amount: int, total: int)

action increment(amount: int):
    require amount > 0, "amount must be positive"
    require amount <= 100, "at most 100 per call"
    count += amount
    last_caller = caller
    emit Increased(caller, amount, count)

view get() -> int:
    return count

view last() -> address:
    return last_caller
counter.scenario# Recipe: a counter anyone can increase (state, actions, views, events).
deploy counter.tccl as counter --from alice
call counter increment 5 --from bob
expect ok
expect event Increased
view counter get
expect result 5
view counter last
expect result @bob
call counter increment 500
expect fail "at most 100"
call counter increment 1 --value 1tcn
expect fail "not payable"
view counter get
expect result 5

Tip jar

A payable action, an owner-only withdrawal and a view returning several numbers. Version 1.

tip_jar.tccl# A tip jar: anyone can send TCN with a message, only the owner withdraws.
contract TipJar

state owner: address
state total_received: int
state tips: int

event Tip(from: address, amount: int, message: text)
event Withdrawn(to: address, amount: int)

init():
    owner = caller

action tip(message: text) payable:
    require value >= TCN / 100, "minimum tip is 0.01 TCN"
    require len(message) <= 140, "message too long"
    total_received += value
    tips += 1
    emit Tip(caller, value, message)

action withdraw(amount: int):
    require caller == owner, "only the owner can withdraw"
    require amount > 0 and amount <= balance, "invalid amount"
    send(owner, amount)
    emit Withdrawn(owner, amount)

view stats() -> list[int]:
    return [total_received, tips, balance]
tip_jar.scenario# Recipe: a tip jar (payable action, owner-only withdrawal).
deploy tip_jar.tccl as jar --from owner
call jar tip "great work" --from fan --value 3tcn
expect ok
expect balance jar 3tcn
call jar withdraw 1tcn --from fan
expect fail
call jar withdraw 2tcn --from owner
expect ok
expect balance @owner 1000002tcn
expect balance jar 1tcn

Token with a minter role

The standard token module plus a role. transfer, approve, transfer_from and the views come from std.token; the contract only decides who may mint.

cloud_coin.tccl# A fungible token in a few lines: the standard token module plus a minter role.
contract CloudCoin
use std.token

role minter

init(supply: int):
    token.setup("Cloud Coin", "CLD", 8)
    grant minter to caller
    token.mint(caller, supply)

action mint(to: address, amount: int) only minter:
    token.mint(to, amount)

action add_minter(who: address) only minter:
    grant minter to who

action burn(amount: int):
    token.burn(caller, amount)
cloud_coin.scenario# Recipe: a token with the standard module and a minter role.
deploy cloud_coin.tccl as coin 1000 --from issuer
view coin balance_of @issuer
expect result 1000
call coin mint @mallory 5 --from mallory
expect fail "only minter"
call coin add_minter @bob --from issuer
expect event RoleGranted
call coin mint @carol 7 --from bob
expect ok
call coin transfer @dave 3 --from carol
expect event Transfer
call coin transfer @dave 5 --from carol
expect fail "insufficient token balance"
call coin approve @erin 2 --from dave
call coin transfer_from @dave @erin 2 --from erin
expect ok
view coin balance_of @erin
expect result 2
view coin total_supply_of
expect result 1007

Orders with records, transitions and roles

A typed Order record, a Status enum whose allowed transitions stop impossible changes (a shipped order cannot be cancelled), and two roles.

orders.tccl# A small store: typed orders, a status with allowed transitions and staff roles.
contract Orders

enum Status:
    Placed -> Paid, Cancelled
    Paid -> Shipped, Refunded
    Shipped -> Delivered
    Delivered
    Cancelled
    Refunded

record Order:
    buyer: address
    item: text
    price: int
    status: Status

role manager
role shipper

state orders: map[int, Order]
state next_id: int
state prices: map[text, int]

event StatusChanged(id: int, status: text)

init():
    grant manager to caller

action set_price(item: text, price: int) only manager:
    require len(item) >= 1 and len(item) <= 64, "item names have 1 to 64 bytes"
    require price >= 0, "price cannot be negative"
    prices[item] = price

action hire_shipper(who: address) only manager:
    grant shipper to who

action fire_shipper(who: address) only manager:
    revoke shipper from who

action place(item: text) -> int:
    require prices.has(item), "unknown item"
    next_id += 1
    orders[next_id] = Order(buyer: caller, item: item, price: prices[item], status: Status.Placed)
    changed(next_id)
    return next_id

action pay(id: int) payable:
    require orders.has(id), "unknown order"
    require caller == orders[id].buyer, "only the buyer pays"
    require value == orders[id].price, "wrong amount"
    orders[id].status = Status.Paid
    changed(id)

action ship(id: int) only shipper:
    orders[id].status = Status.Shipped
    changed(id)

action confirm(id: int):
    require caller == orders[id].buyer, "only the buyer confirms"
    orders[id].status = Status.Delivered
    changed(id)

action cancel(id: int):
    require caller == orders[id].buyer, "only the buyer cancels"
    orders[id].status = Status.Cancelled
    changed(id)

action refund(id: int) only manager:
    let o: Order = orders[id]
    orders[id].status = Status.Refunded
    send(o.buyer, o.price)
    changed(id)

fn changed(id: int):
    emit StatusChanged(id, to_text(orders[id].status))

view order(id: int) -> Order:
    return orders[id]
orders.scenario# Recipe: typed records, a status with allowed transitions, staff roles.
deploy orders.tccl as shop --from boss
call shop set_price "lamp" 5tcn --from boss
call shop hire_shipper @sam --from boss
call shop place "lamp" --from ann
expect result 1
call shop confirm 1 --from ann
expect fail "Placed to Delivered"
call shop pay 1 --from ann --value 5tcn
expect ok
call shop ship 1 --from ann
expect fail "only shipper"
call shop ship 1 --from sam
expect event StatusChanged
call shop cancel 1 --from ann
expect fail "Shipped to Cancelled"
call shop confirm 1 --from ann
expect ok
view shop order 1
expect result {buyer: @ann, item: "lamp", price: 5tcn, status: Delivered}

Exchange pool

A constant-product exchange (DEX) for two token contracts with a 0.30 % fee. It shows interfaces, caller inside another contract (traders approve the pool), mul_div and isqrt, slippage protection with min_out, and atomicity: a swap without enough allowance fails in the token and reverts the pool's reserve update.

pool.tccl# Constant-product exchange pool (x × y = k) for two token contracts, 0.30 % fee.
#
# Calls other contracts through an interface. Inside the token, `caller` is this
# pool, so traders first `approve` the pool on each token. Every call is atomic:
# if a transfer fails, the whole swap is reverted.
contract Pool

interface Token:
    action transfer(to: address, amount: int) -> bool
    action transfer_from(from: address, to: address, amount: int) -> bool
    view balance_of(who: address) -> int

const FEE_BP: int = 30
const BP: int = 10_000

state token_a: address
state token_b: address
state reserve_a: int
state reserve_b: int
state total_shares: int
state shares: map[address, int]

event Added(provider: address, amount_a: int, amount_b: int, minted: int)
event Removed(provider: address, amount_a: int, amount_b: int, burned: int)
event Swapped(trader: address, sell_a: bool, amount_in: int, amount_out: int)

init(a: address, b: address):
    require a != b, "the two tokens must differ"
    require is_contract(a) and is_contract(b), "both tokens must be contracts"
    token_a = a
    token_b = b

action add_liquidity(amount_a: int, amount_b: int) -> int:
    require amount_a > 0 and amount_b > 0, "amounts must be positive"
    let minted: int = 0
    if total_shares == 0:
        minted = isqrt(amount_a) * isqrt(amount_b)
    else:
        minted = min(mul_div(amount_a, total_shares, reserve_a), mul_div(amount_b, total_shares, reserve_b))
    require minted > 0, "liquidity too small"
    # Effects first, then calls to other contracts.
    reserve_a += amount_a
    reserve_b += amount_b
    total_shares += minted
    shares[caller] += minted
    require Token(token_a).transfer_from(caller, self, amount_a), "token A transfer failed"
    require Token(token_b).transfer_from(caller, self, amount_b), "token B transfer failed"
    emit Added(caller, amount_a, amount_b, minted)
    return minted

action remove_liquidity(burn: int):
    require burn > 0 and burn <= shares[caller], "invalid share amount"
    let out_a: int = mul_div(burn, reserve_a, total_shares)
    let out_b: int = mul_div(burn, reserve_b, total_shares)
    shares[caller] -= burn
    total_shares -= burn
    reserve_a -= out_a
    reserve_b -= out_b
    require Token(token_a).transfer(caller, out_a), "token A transfer failed"
    require Token(token_b).transfer(caller, out_b), "token B transfer failed"
    emit Removed(caller, out_a, out_b, burn)

view quote(sell_a: bool, amount_in: int) -> int:
    return output_for(sell_a, amount_in)

fn output_for(sell_a: bool, amount_in: int) -> int:
    require amount_in > 0, "amount must be positive"
    require reserve_a > 0 and reserve_b > 0, "the pool is empty"
    let with_fee: int = amount_in * (BP - FEE_BP)
    if sell_a:
        return mul_div(with_fee, reserve_b, reserve_a * BP + with_fee)
    return mul_div(with_fee, reserve_a, reserve_b * BP + with_fee)

action swap(sell_a: bool, amount_in: int, min_out: int) -> int:
    let out: int = output_for(sell_a, amount_in)
    require out > 0, "output too small"
    require out >= min_out, "price moved: output below min_out"
    if sell_a:
        reserve_a += amount_in
        reserve_b -= out
        require Token(token_a).transfer_from(caller, self, amount_in), "payment failed"
        require Token(token_b).transfer(caller, out), "payout failed"
    else:
        reserve_b += amount_in
        reserve_a -= out
        require Token(token_b).transfer_from(caller, self, amount_in), "payment failed"
        require Token(token_a).transfer(caller, out), "payout failed"
    emit Swapped(caller, sell_a, amount_in, out)
    return out

view reserves() -> list[int]:
    return [reserve_a, reserve_b, total_shares]
pool.scenario# Recipe: a constant-product exchange (DEX) calling two token contracts.
deploy cloud_coin.tccl as alpha 1000000 --from lp
deploy cloud_coin.tccl as beta 1000000 --from lp
deploy pool.tccl as pool $alpha $beta --from lp
call alpha approve $pool 100000 --from lp
call beta approve $pool 400000 --from lp
call pool add_liquidity 100000 400000 --from lp
expect ok
expect event Added
call alpha transfer @trader 10000 --from lp
call alpha approve $pool 10000 --from trader
view pool quote true 10000
expect result 36264
call pool swap true 10000 40000 --from trader
expect fail "min_out"
call pool swap true 10000 36000 --from trader
expect result 36264
expect event Swapped
view beta balance_of @trader
expect result 36264
call pool swap true 1000 1 --from trader
expect fail "allowance too small"
view pool reserves
expect result [110000, 363736, 199712]

Real exchanges also need protection against price manipulation within one block when other contracts use the pool's price. Do not use quote as a price oracle.

Coin flip game

A two-player game without fake randomness: the host commits to a hidden choice, the guest guesses, the host reveals. A host who refuses to reveal loses after 20 blocks. See Randomness.

coin_flip.tccl# Two-player coin flip with commit–reveal.
#
# Nothing on a blockchain is random: block data can be predicted or influenced.
# The host commits to a hidden choice (sha256 of a 32-byte secret and the choice),
# the guest guesses in public, then the host reveals. A host who refuses to reveal
# loses after REVEAL_BLOCKS.
contract CoinFlip

const REVEAL_BLOCKS: int = 20

enum Stage:
    Open -> Joined, Cancelled
    Joined -> Settled, Forfeited
    Settled
    Cancelled
    Forfeited

record Game:
    host: address
    guest: address
    stake: int
    commitment: bytes
    guess: bool
    deadline: int
    stage: Stage

state games: map[int, Game]
state count: int

event Created(id: int, host: address, stake: int)
event Joined(id: int, guest: address, guess: bool)
event Won(id: int, winner: address, prize: int, how: text)

action create(commitment: bytes) payable -> int:
    require value > 0, "attach the stake"
    require len(commitment) == 32, "commitment must be a sha256 hash"
    count += 1
    games[count] = Game(host: caller, guest: zero_address(), stake: value, commitment: commitment, guess: false, deadline: 0, stage: Stage.Open)
    emit Created(count, caller, value)
    return count

action join(id: int, guess: bool) payable:
    require games.has(id), "unknown game"
    let g: Game = games[id]
    require g.stage == Stage.Open, "game is not open"
    require caller != g.host, "the host cannot join"
    require value == g.stake, "stake must match"
    games[id].guest = caller
    games[id].guess = guess
    games[id].deadline = height + REVEAL_BLOCKS
    games[id].stage = Stage.Joined
    emit Joined(id, caller, guess)

action reveal(id: int, secret: bytes, choice: bool):
    let g: Game = games[id]
    require g.stage == Stage.Joined, "nothing to reveal"
    require caller == g.host, "only the host reveals"
    require len(secret) == 32, "secret must have 32 bytes"
    require sha256(secret + to_bytes(choice)) == g.commitment, "secret does not match the commitment"
    let winner: address = g.host
    if g.guess == choice:
        winner = g.guest
    games[id].stage = Stage.Settled
    send(winner, g.stake * 2)
    emit Won(id, winner, g.stake * 2, "reveal")

action claim_timeout(id: int):
    let g: Game = games[id]
    require g.stage == Stage.Joined, "game is not waiting for a reveal"
    require height > g.deadline, "the host can still reveal"
    games[id].stage = Stage.Forfeited
    send(g.guest, g.stake * 2)
    emit Won(id, g.guest, g.stake * 2, "timeout")

action cancel(id: int):
    let g: Game = games[id]
    require caller == g.host, "only the host can cancel"
    games[id].stage = Stage.Cancelled
    send(g.host, g.stake)

view stage_of(id: int) -> text:
    return to_text(games[id].stage)
coin_flip.scenario# Recipe: a two-player game with commit-reveal (no fake randomness).
# The host picked `true` and a secret of 32 bytes 0x07:
#   commitment = sha256(0x0707…07 ++ 0x01)
deploy coin_flip.tccl as game --from house
call game create 0x842ef3c3b4e4a5b477257cff946cdaf69fbe8c739395f2b2e092039b830eb690 --from host --value 2tcn
expect result 1
call game join 1 true --from guest --value 1tcn
expect fail "stake must match"
call game join 1 true --from guest --value 2tcn
expect event Joined
call game reveal 1 0x0707070707070707070707070707070707070707070707070707070707070707 false --from host
expect fail "does not match the commitment"
call game reveal 1 0x0707070707070707070707070707070707070707070707070707070707070707 true --from host
expect event Won
expect balance @guest 1000002tcn
view game stage_of 1
expect result "Settled"
# A host who never reveals loses after 20 blocks.
call game create 0x842ef3c3b4e4a5b477257cff946cdaf69fbe8c739395f2b2e092039b830eb690 --from host --value 1tcn
call game join 2 false --from guest --value 1tcn
call game claim_timeout 2 --from guest
expect fail "can still reveal"
advance 21
call game claim_timeout 2 --from guest
expect ok
view game stage_of 2
expect result "Forfeited"

Tickets as unique items

Tickets issued by an organizer with std.items, limited capacity, transfers and check-in (burning the ticket).

tickets.tccl# Event tickets as unique items (std.items). The organizer issues them; holders
# can transfer or approve someone else (e.g. a resale contract).
contract Tickets
use std.items

state organizer: address
state capacity: int

init(seats: int):
    require seats > 0, "capacity must be positive"
    organizer = caller
    capacity = seats

action issue(to: address, seat: text) -> int only organizer:
    require items.count < capacity, "sold out"
    return items.create(to, "ticket", seat)

action check_in(id: int) only organizer:
    items.burn_item(id)
tickets.scenario# Recipe: tickets as unique items (std.items).
deploy tickets.tccl as tickets 2 --from org
call tickets issue @ann "A1" --from org
expect result 1
call tickets issue @ben "A2" --from org
call tickets issue @cat "A3" --from org
expect fail "sold out"
call tickets issue @cat "A3" --from ann
expect fail "only organizer"
call tickets transfer_item 1 @cat --from ann
expect event ItemTransferred
view tickets owner_of 1
expect result @cat
call tickets check_in 1 --from org
expect event ItemBurned

Conditional payments

All actions come from std.payments: a hash-locked payment released by whoever reveals the secret, and a payment the buyer can take back after a deadline.

deals.tccl# Conditional payments (escrow with an arbiter, time locks and hash locks) using
# the standard payments module. All actions come from std.payments.
contract Deals
use std.payments

view about() -> text:
    return "conditional payments: release, claim after a height, reveal a secret, refund"
deals.scenario# Recipe: conditional payments — a hash lock, an arbiter and a refund deadline.
deploy deals.tccl as deals --from anyone
# hashlock = sha256("open sesame"); whoever reveals the secret pays the payee.
call deals create_payment @seller @judge 0 0 0x41ef4bb0b23661e66301aac36066912dac037827b4ae63a7b1165a5aa93ed4eb --from buyer --value 5tcn
expect result 1
call deals reveal_payment 1 0x6775657373 --from courier
expect fail "wrong secret"
call deals reveal_payment 1 0x6f70656e20736573616d65 --from courier
expect event PaymentReleased
expect balance @seller 1000005tcn
call deals refund_payment 1 --from buyer
expect fail "no longer pending"
# No hash lock, refundable by the buyer after height 50.
call deals create_payment @seller @judge 0 50 0x --from buyer --value 3tcn
expect result 2
call deals refund_payment 2 --from buyer
expect fail "not allowed yet"
height 60
call deals refund_payment 2 --from buyer
expect event PaymentRefunded
view deals payment_status 2
expect result "Refunded"
view deals locked_total
expect result 0

Upgrading a contract

The counter above, upgraded to a second version by its upgrade authority. The new state variable is initialized by upgrade(), the old values are kept, strangers cannot upgrade, and a final contract can never change again.

counter_v2.tccl# Second version of counter.tccl, installed with an upgrade by the upgrade authority.
# Existing state (count, last_caller) is kept; a new variable is added at the end.
contract Counter

state count: int
state last_caller: address
state step: int

event Increased(by: address, amount: int, total: int)

upgrade():
    step = 10

action increment(amount: int):
    require amount > 0, "amount must be positive"
    require amount <= 100, "at most 100 per call"
    count += amount * step
    last_caller = caller
    emit Increased(caller, amount * step, count)

view get() -> int:
    return count

view last() -> address:
    return last_caller
upgrade.scenario# Recipe: upgrading a contract with its upgrade authority (the deployer).
deploy counter.tccl as counter --from dev
call counter increment 5 --from bob
upgrade counter counter_v2.tccl --from bob
expect fail
upgrade counter counter_v2.tccl --from dev
expect ok
call counter increment 2 --from bob
view counter get
expect result 25
expect state counter step 10
authority counter none --from dev
upgrade counter counter_v2.tccl --from dev
expect fail

Escrow with an arbiter

Roles expressed with require, deadlines, a dispute and destroy. Version 1.

escrow.tccl# Escrow with an arbiter.
#
# The buyer deploys the contract with the payment attached. The buyer releases
# the money when the goods arrive; if buyer and seller disagree, either can
# open a dispute and the arbiter decides. If nobody acts before the deadline,
# the buyer can take the money back.
contract Escrow

const MIN_DURATION: int = 60           # about 1 hour (1 block = 1 minute)
const MAX_DURATION: int = 525_600      # about 1 year

state buyer: address
state seller: address
state arbiter: address
state amount: int
state deadline: int
state disputed: bool
state settled: bool

event Funded(buyer: address, seller: address, amount: int, deadline: int)
event Disputed(by: address)
event Settled(to: address, amount: int)

init(seller_address: address, arbiter_address: address, duration_blocks: int) payable:
    require value > 0, "attach the payment with --value"
    require seller_address != caller, "buyer and seller must be different"
    require arbiter_address != caller, "the arbiter must be a third party"
    require arbiter_address != seller_address, "the arbiter must be a third party"
    require duration_blocks >= MIN_DURATION, "duration too short (min 60 blocks)"
    require duration_blocks <= MAX_DURATION, "duration too long (max 525600 blocks)"
    buyer = caller
    seller = seller_address
    arbiter = arbiter_address
    amount = value
    deadline = height + duration_blocks
    emit Funded(caller, seller_address, value, deadline)

# The buyer is happy: pay the seller.
action release():
    require caller == buyer, "only the buyer can release"
    pay(seller)

# The seller cannot deliver: give the money back.
action cancel():
    require caller == seller, "only the seller can cancel"
    pay(buyer)

action dispute():
    require caller == buyer or caller == seller, "only the buyer or the seller"
    require not settled, "already settled"
    require not disputed, "already disputed"
    disputed = true
    emit Disputed(caller)

action resolve(pay_seller: bool):
    require caller == arbiter, "only the arbiter"
    require disputed, "there is no dispute"
    if pay_seller:
        pay(seller)
    else:
        pay(buyer)

action reclaim():
    require caller == buyer, "only the buyer"
    require height > deadline, "the deadline has not passed"
    require not disputed, "a dispute is open: the arbiter decides"
    pay(buyer)

# After settlement the buyer removes the contract and recovers its storage deposit.
action close():
    require caller == buyer, "only the buyer"
    require settled, "settle the escrow first"
    destroy(buyer)

view status() -> text:
    if settled:
        return "settled"
    elif disputed:
        return "disputed"
    elif height > deadline:
        return "expired"
    return "open"

view locked() -> int:
    if settled:
        return 0
    return amount

fn pay(to: address):
    require not settled, "already settled"
    settled = true
    send(to, amount)
    emit Settled(to, amount)
escrow.scenario# Recipe (language 1 contract): escrow with an arbiter.
deploy escrow.tccl as deal @seller @arbiter 100 --from buyer --value 10tcn
call deal release --from seller
expect fail "only the buyer"
call deal dispute --from seller
expect event Disputed
call deal resolve true --from arbiter
expect event Settled
expect balance @seller 1000010tcn

More recipes

These version 1 contracts are tested in crates/tccl/tests/examples.rs:

ContractShows
shop.tcclEvery kind of declaration, payable, helpers
token.tcclA token written by hand: maps, allowances, composite keys
crowdfund.tcclDeadlines and refunds
poll.tcclList arguments, state lists, bounded loops
savings.tcclTime locks and storage refunds
treasury.tcclM-of-N approvals
names.tcclText keys, validation, expiry
private_pool.tcclRing signatures for private payments (see Privacy)

Improve this page on GitHub