A hands-on guide to creating privacy-preserving token sales on Canton Network
Ever wanted to build a token sale where only the buyer, seller, and maybe a regulator can see the details? That's exactly what we're going to create today. We'll build a complete ICO (Initial Coin Offering) smart contract that sells one token for another, with privacy built-in and atomic operations that can't fail halfway through.
What's an ICO Anyway?
ICO's are back! From Monad to MegaETH or projects launching their token on Tally.xyz, capital formation as product market fit for crypto is the meta.
Before we dive into code, let's make sure we're on the same page. An ICO (Initial Coin Offering) is when a project sells their tokens to raise funds. Traditionally this happens on public blockchains where everyone can see:
- Who bought what
- How much they paid
- All the transaction details
But what if you want to keep those details private? That's where Canton Network comes in.
Why Canton Network?
Canton Network is different. It gives you:
🔒 Privacy by Default
Only the parties directly involved in a transaction can see the details. No more public order books!
⚡ Atomic Operations
Either everything happens (buyer pays, seller delivers) or nothing happens. No failed transactions leaving things in a weird state.
🤝 Multi-Party Authorization
Smart contracts can require multiple parties to agree before anything happens. Perfect for regulated token sales.
🏗️ Enterprise Ready
Built for serious applications with proper tooling, monitoring, and compliance features.
What We're Building
We'll create a complete ICO system with three parts:
- Token Contract - A simple but secure token that can be issued and transferred
- ICO Contract - The main sale logic with atomic token-for-token swaps
- Test Suite - Scripts to verify everything works correctly
The flow will be:
Buyer pays with USDC → ICO contract validates → MYCOIN tokens created → All in one atomic transaction
Quick Setup
Let's get your environment ready. You'll need:
- Daml SDK (version 3.3.0-snapshot...)
- Java 21 (for the build tools)
- Docker (for running the local network)
- tmux (for managing long-running processes)
The easiest way is to clone the Canton quickstart repo and run their setup:
git clone https://github.com/digital-asset/cn-quickstart.git
cd cn-quickstart/quickstart
make setup
This gives you a complete development environment with Canton nodes, wallets, and monitoring tools.
Want to get started with AI? Use this llms.txt to get started fast.
Your First ICO Contract
Let's start with a simple token contract. Create a file called Token.daml:
module Token where
import DA.Assert
-- A simple token that can be issued and transferred
template Token
with
issuer : Party -- Who created this token
owner : Party -- Who currently owns it
symbol : Text -- Like "USD" or "MYCOIN"
amount : Decimal -- How much
where
ensure amount > 0.0
signatory issuer, owner -- Both must agree to create it
-- Transfer ownership to someone else
choice Transfer : ContractId Token
with
newOwner : Party
controller owner, newOwner -- Both current and new owner must agree
do
create this with owner = newOwner
-- The authority that can create new tokens
template TokenIssuer
with
issuer : Party
symbol : Text
totalSupply : Decimal
where
signatory issuer
-- Create new tokens for someone (doesn't consume this contract)
nonconsuming choice IssueTokens : ContractId Token
with
recipient : Party
amount : Decimal
controller issuer, recipient -- Both issuer and recipient must agree
do
assert (amount > 0.0)
create Token with
issuer = issuer
owner = recipient
symbol = symbol
amount = amount
Now let's create the ICO contract in Ico.daml:
module Ico where
import DA.Assert
import DA.Time
import Token
-- An active ICO where people can buy tokens
template IcoOffering
with
issuer : Party -- The company running the ICO
saleTokenIssuer : Party -- Who creates the tokens being sold
saleTokenSymbol : Text -- What token is being sold
paymentTokenIssuer : Party -- Who creates the payment tokens
paymentTokenSymbol : Text -- What token buyers pay with
exchangeRate : Decimal -- How many sale tokens per payment token
totalSaleTokens : Decimal -- Total available for sale
tokensSold : Decimal -- How many sold so far
totalRaised : Decimal -- Total payment collected
startTime : Time -- When ICO starts
endTime : Time -- When ICO ends
minPurchase : Decimal -- Minimum purchase amount
maxPurchase : Decimal -- Maximum purchase (0 = no limit)
where
ensure totalSaleTokens > 0.0 && exchangeRate > 0.0
signatory issuer
-- Buy tokens! This is the magic atomic swap
choice Purchase : (ContractId Token.Token, ContractId IcoOffering)
with
buyer : Party
paymentTokenCid : ContractId Token.Token -- Buyer's payment token
paymentAmount : Decimal -- How much they're paying
controller buyer, issuer, saleTokenIssuer -- All three parties must agree!
do
-- Check if ICO is active
now <- getTime
assert (now >= startTime)
assert (now < endTime)
-- Validate purchase amount
assert (paymentAmount >= minPurchase)
assert (maxPurchase == 0.0 || paymentAmount <= maxPurchase)
-- Calculate how many tokens they get
let saleTokenAmount = paymentAmount * exchangeRate
-- Make sure we have enough tokens left
let remainingTokens = totalSaleTokens - tokensSold
assert (saleTokenAmount <= remainingTokens)
-- Verify they own the payment token
paymentToken <- fetch paymentTokenCid
assert (paymentToken.issuer == paymentTokenIssuer)
assert (paymentToken.symbol == paymentTokenSymbol)
assert (paymentToken.owner == buyer)
assert (paymentToken.amount >= paymentAmount)
-- Handle payment (split if needed, then transfer)
if paymentToken.amount == paymentAmount
then do
-- Exact amount: transfer the whole token
_ <- exercise paymentTokenCid Token.Transfer with
newOwner = issuer
return ()
else do
-- Partial payment: split and transfer just the payment amount
(paymentPortionCid, _changeCid) <- exercise paymentTokenCid Token.Split with
splitAmount = paymentAmount
_ <- exercise paymentPortionCid Token.Transfer with
newOwner = issuer
return ()
-- Create the sale tokens for the buyer
saleTokenCid <- create Token.Token with
issuer = saleTokenIssuer
owner = buyer
symbol = saleTokenSymbol
amount = saleTokenAmount
-- Update the ICO with new totals
updatedIcoCid <- create this with
tokensSold = tokensSold + saleTokenAmount
totalRaised = totalRaised + paymentAmount
return (saleTokenCid, updatedIcoCid)
-- Close the ICO when it's done
choice Close : ContractId IcoCompleted
controller issuer
do
now <- getTime
assert (now >= endTime || tokensSold >= totalSaleTokens)
create IcoCompleted with
issuer = issuer
saleTokenIssuer = saleTokenIssuer
saleTokenSymbol = saleTokenSymbol
paymentTokenIssuer = paymentTokenIssuer
paymentTokenSymbol = paymentTokenSymbol
totalSaleTokens = totalSaleTokens
tokensSold = tokensSold
totalRaised = totalRaised
finalExchangeRate = exchangeRate
-- Record of a completed ICO
template IcoCompleted
with
issuer : Party
saleTokenIssuer : Party
saleTokenSymbol : Text
paymentTokenIssuer : Party
paymentTokenSymbol : Text
totalSaleTokens : Decimal
tokensSold : Decimal
totalRaised : Decimal
finalExchangeRate : Decimal
where
signatory issuer
-- Get final statistics
nonconsuming choice GetStats : (Decimal, Decimal, Decimal)
controller issuer
do return (tokensSold, totalRaised, finalExchangeRate)
-- Helper to create ICOs
template IcoFactory
with
issuer : Party
where
signatory issuer
choice CreateIco : ContractId IcoOffering
with
saleTokenIssuer : Party
saleTokenSymbol : Text
paymentTokenIssuer : Party
paymentTokenSymbol : Text
exchangeRate : Decimal
totalSaleTokens : Decimal
startTime : Time
endTime : Time
minPurchase : Decimal
maxPurchase : Decimal
controller issuer
do
assert (totalSaleTokens > 0.0)
assert (exchangeRate > 0.0)
assert (endTime > startTime)
assert (minPurchase > 0.0)
create IcoOffering with
issuer = issuer
saleTokenIssuer = saleTokenIssuer
saleTokenSymbol = saleTokenSymbol
paymentTokenIssuer = paymentTokenIssuer
paymentTokenSymbol = paymentTokenSymbol
exchangeRate = exchangeRate
totalSaleTokens = totalSaleTokens
tokensSold = 0.0
totalRaised = 0.0
startTime = startTime
endTime = endTime
minPurchase = minPurchase
maxPurchase = maxPurchase
Testing It Out
Let's create a test script to verify our ICO works. Create IcoTest.daml:
module IcoTest where
import DA.Assert
import DA.Time
import Daml.Script
import Ico
import Token
test_ico_lifecycle = script do
-- Create all the parties
icoIssuer <- allocateParty "Company"
saleTokenIssuer <- allocateParty "Company"
paymentTokenIssuer <- allocateParty "USDCIssuer"
buyer1 <- allocateParty "Alice"
buyer2 <- allocateParty "Bob"
-- Create token issuers
saleIssuerCid <- submit saleTokenIssuer do
createCmd TokenIssuer with
issuer = saleTokenIssuer
symbol = "MYCOIN"
totalSupply = 1000000.0
paymentIssuerCid <- submit paymentTokenIssuer do
createCmd TokenIssuer with
issuer = paymentTokenIssuer
symbol = "USDC"
totalSupply = 1000000.0
-- Give buyers some USDC to spend
buyer1PaymentCid <- submitMulti [paymentTokenIssuer, buyer1] [] do
exerciseCmd paymentIssuerCid IssueTokens with
recipient = buyer1
amount = 1000.0
buyer2PaymentCid <- submitMulti [paymentTokenIssuer, buyer2] [] do
exerciseCmd paymentIssuerCid IssueTokens with
recipient = buyer2
amount = 500.0
-- Set up the ICO
now <- getTime
factoryCid <- submit icoIssuer do
createCmd IcoFactory with issuer = icoIssuer
icoCid <- submit icoIssuer do
exerciseCmd factoryCid CreateIco with
saleTokenIssuer = saleTokenIssuer
saleTokenSymbol = "MYCOIN"
paymentTokenIssuer = paymentTokenIssuer
paymentTokenSymbol = "USDC"
exchangeRate = 100.0 -- 1 USDC = 100 MYCOIN
totalSaleTokens = 50000.0
startTime = now
endTime = addRelTime now (days 1)
minPurchase = 10.0
maxPurchase = 0.0
-- Alice buys 100 USDC worth (gets 10,000 MYCOIN)
(aliceTokensCid, icoCid1) <- submitMulti [buyer1, icoIssuer, saleTokenIssuer] [] do
exerciseCmd icoCid Purchase with
buyer = buyer1
paymentTokenCid = buyer1PaymentCid
paymentAmount = 100.0
-- Verify Alice got her tokens
Some aliceTokens <- queryContractId buyer1 aliceTokensCid
assert (aliceTokens.amount == 10000.0)
assert (aliceTokens.symbol == "MYCOIN")
-- Bob buys 50 USDC worth (gets 5,000 MYCOIN)
(bobTokensCid, icoCid2) <- submitMulti [buyer2, icoIssuer, saleTokenIssuer] [] do
exerciseCmd icoCid1 Purchase with
buyer = buyer2
paymentTokenCid = buyer2PaymentCid
paymentAmount = 50.0
-- Verify Bob got his tokens
Some bobTokens <- queryContractId buyer2 bobTokensCid
assert (bobTokens.amount == 5000.0)
-- Check ICO totals
Some finalIco <- queryContractId icoIssuer icoCid2
assert (finalIco.tokensSold == 15000.0) -- 10k + 5k
assert (finalIco.totalRaised == 150.0) -- 100 + 50
return ()
Testing It Out
Run the tests to make sure everything works:
# In the cn-quickstart/quickstart directory
cd daml/ico-token
daml test
You should see something like:
Test suite passed with 8 transactions and 9 active contracts
Deploying to LocalNet
Time to see your ICO running on a real Canton network! The build scripts handle all the complexity:
# Build the contracts
cd examples/ico-token
./scripts/build.sh
# Deploy to local network
./scripts/deploy.sh
This will:
- Compile your Daml contracts into a DAR file
- Start Canton participant nodes
- Launch web interfaces for exploration
- Deploy your contracts to the ledger
Once running, you can access:
- Scan UI: http://scan.localhost:4000 (explore contracts)
- Wallet UI: http://wallet.localhost:2000 (manage tokens)
- App Provider: http://app-provider.localhost:3000 (frontend)
Making It Real
Your ICO is now live on LocalNet! Here's how to interact with it:
- Create parties through the wallet interfaces
- Issue tokens using the TokenIssuer contracts
- Set up an ICO using the IcoFactory
- Make purchases through the Purchase choice
- Watch the magic happen atomically
The best part? Only the buyer, seller, and sale token issuer can see the transaction details. Everyone else just sees that a transaction happened.
What's Next?
🎉 Congratulations! You just built a privacy-preserving ICO on Canton Network.
Want to take it further? Here are some ideas:
Add Features
- Tiered pricing: Different rates for different purchase amounts
- Time bonuses: Better rates for early buyers
- Vesting: Lock purchased tokens for a period
- KYC integration: Verify buyer identities
Go Production
- Multi-party deployments: Run across different Canton domains
- Compliance: Add regulator observers
- Monitoring: Set up proper observability
- Scaling: Handle thousands of concurrent buyers
Learn More
- Daml patterns: Explore more Canton-specific smart contract patterns
- Privacy models: Deep dive into sub-transaction privacy
- Integration: Connect with existing DeFi protocols
The Canton ecosystem is growing fast. Your ICO contract is a great foundation for building the next generation of token sales!
About the Author
Dennison Bertram is the co-founder and CEO of Tally.xyz, the infrastructure layer for tokens—from ICO and airdrop to governance, staking, and value accrual. Tally delivers the complete framework for operating tokens at scale, powering the largest teams in the ecosystem with billions under management.
Top comments (0)