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
quoteas 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:
| Contract | Shows |
|---|---|
shop.tccl | Every kind of declaration, payable, helpers |
token.tccl | A token written by hand: maps, allowances, composite keys |
crowdfund.tccl | Deadlines and refunds |
poll.tccl | List arguments, state lists, bounded loops |
savings.tccl | Time locks and storage refunds |
treasury.tccl | M-of-N approvals |
names.tccl | Text keys, validation, expiry |
private_pool.tccl | Ring signatures for private payments (see Privacy) |