Overview
Cowboy has protocol-native fungible tokens (CIP-20): token state lives in the chain’s state tree and is manipulated through system instructions, not per-token smart contracts. You interact with tokens two ways:
- From the CLI —
cowboy token <subcommand> for scripting and operations
- From actor code —
runtime.token_* host functions, so actors can create and move tokens as part of their logic
Amounts are integers in the token’s base unit (u128); decimals is display metadata only.
From the CLI
The full command surface is specified in cowboy token. A typical lifecycle:
# Create (you become owner, mint and freeze authority)
cowboy token create --name "Demo Token" --symbol DEMO \
--decimals 9 --initial-supply 1000000 --max-supply 2000000
# Inspect and query (read-only — no key required)
cowboy token info --token-id 0x<TOKEN_ID>
cowboy token balance --token-id 0x<TOKEN_ID> --address 0x<ADDR>
cowboy token list
# Move
cowboy token transfer --token-id 0x<TOKEN_ID> --to 0x<ADDR> --amount 100
cowboy token approve --token-id 0x<TOKEN_ID> --spender 0x<ADDR> --amount 50
# Supply management (authorities only)
cowboy token mint --token-id 0x<TOKEN_ID> --to 0x<ADDR> --amount 500
cowboy token burn --token-id 0x<TOKEN_ID> --amount 100
# Compliance controls (freeze authority only)
cowboy token freeze --token-id 0x<TOKEN_ID> --account 0x<ADDR>
cowboy token unfreeze --token-id 0x<TOKEN_ID> --account 0x<ADDR>
From actor code
Actors get the same operations through cowboy_sdk.runtime. The caller is the actor itself — the actor’s address is the token owner / sender of transfers:
from cowboy_sdk import actor, public, runtime
@actor
class Treasury:
@public
def create_token(self, payload):
token_id = runtime.token_create(
b"Demo Token", # name
b"DEMO", # symbol
6, # decimals
1_000_000_000_000, # initial_supply (base units, credited to this actor)
None, # max_supply (None = uncapped)
None, # transfer_hook (None = no hook)
None, # metadata_uri
)
self.storage["token_id"] = token_id.hex()
return b"ok"
@public
def pay(self, payload):
token_id = bytes.fromhex(self.storage["token_id"])
to = bytes.fromhex("...") # 20-byte recipient address
runtime.token_transfer(token_id, to, 1_000_000)
return b"ok"
The query/move surface mirrors the CLI:
| Function | Purpose |
|---|
runtime.token_create(name, symbol, decimals, initial_supply, max_supply, transfer_hook, metadata_uri) | Create a token; returns the 32-byte token_id |
runtime.token_balance_of(token_id, owner) | Balance query |
runtime.token_total_supply(token_id) | Supply query |
runtime.token_transfer(token_id, to, amount) | Transfer from the calling actor |
runtime.token_approve(token_id, spender, amount) | Set an allowance |
runtime.token_allowance(token_id, owner, spender) | Allowance query |
runtime.token_transfer_from(token_id, from_addr, to, amount) | Spend an allowance |
runtime.token_mint(token_id, to, amount) | Mint (requires mint authority) |
runtime.token_burn(token_id, amount) | Burn from the caller’s balance |
Each operation has a fixed gas cost (e.g. a transfer is 1,000 Cycles + 64 Cells) — see CIP-20 for the schedule.
Transfer hooks
A token can name a hook actor at creation (transfer_hook) to enforce custom transfer policy — blocklists, pausing, compliance logic. The hook implements two handlers:
can_transfer(payload) — called before balances move. Runs read-only (state writes are not committed). The check is fail-closed: the transfer proceeds only if the hook returns "true", "1", or JSON true; any other output (including b"ok" or an error) rejects it, reverting with TokenHookRejected.
on_transfer(payload) — called after balances move, and may update the hook actor’s own state. Best-effort: a failure here is logged but does not revert the transfer.
Both receive a JSON payload:
{
"token_id": "<hex>",
"from": "<hex 20-byte address>",
"to": "<hex 20-byte address>",
"amount": "<decimal string>"
}
import json
from cowboy_sdk import actor, public
@actor
class ComplianceHook:
@public
def can_transfer(self, payload):
req = json.loads(payload)
blocked = self.storage.get("blocklist", [])
if req["from"] in blocked or req["to"] in blocked:
return b"false"
return b"true"
@public
def on_transfer(self, payload):
# notification only — a failure here never reverts the transfer
return b"ok"
Hooks run under hard sub-limits (token_hook_max_cycles / token_hook_max_cells, 50,000 each) regardless of the transaction’s gas budget — keep them small. Hook reentrancy into the same token is blocked by the runtime.
Worked example
examples/01-tokens/ in the repo pairs a CLI walkthrough (demo.sh) with an actor that creates and transfers a token programmatically (token_actor_example.py) — a good starting template for both styles.
Further reading