Status: Draft Type: Standards Track Category: Core Created:
2025-11-14
Abstract
This CIP defines two complementary approaches for fungible tokens on Cowboy:
- Platform-Level Tokens: Runtime-native token support with Solana-level efficiency (recommended for standard tokens)
- Actor-Based Tokens: Fully programmable token actors for custom logic (for advanced use cases)
Both approaches provide standard interfaces for transferring tokens and managing approvals, enabling interoperable applications from wallets and exchanges to complex DeFi protocols. The dual-mode design allows developers to choose the right tradeoff between efficiency and programmability for their specific use case.
Motivation
A standard interface for fungible tokens is critical for the health and growth of the Cowboy ecosystem. It allows any
application—such as wallets, decentralized exchanges, or other actors—to interact with any token on the platform in a predictable and
reusable way without needing to write custom code for each new token. This composability is what enabled the explosion of applications on
Ethereum with ERC-20, and a similar standard is necessary for Cowboy to flourish.
However, implementing every token as a separate actor (like Ethereum) has significant drawbacks:
- High Cost: Each token transfer requires a full actor message (~50,000 Cycles)
- Redundant Implementation: Every token reimplements the same balance tracking logic
- Limited Optimization: Can’t leverage runtime-level optimizations for common operations
- No Batch Operations: Can’t efficiently process multiple token transfers atomically
Solana’s approach—platform-level token support via the SPL Token program—demonstrates 50-100x better performance for standard tokens.
CIP-20’s Solution: Support both approaches. Developers choose based on their needs:
| Token Type | Recommended Mode | Rationale |
|---|
| Stablecoins (USDC, USDT) | Platform | High frequency, need efficiency |
| Wrapped assets (WETH, WBTC) | Platform | Simple logic, benefit from batching |
| Utility tokens | Platform | Standard behavior, lower costs |
| Governance tokens | Actor | Need custom voting logic |
| Rebasing tokens | Actor | Complex state changes |
| Tokens with timers | Actor | Leverage CIP-1 autonomous execution |
Specification
CIP-20 defines two implementation modes:
Platform tokens are managed directly by the Cowboy runtime through native host functions, similar to Solana’s SPL Token program.
from cowboy_sdk import PlatformToken
# Create a new platform-level token
usdc_id = PlatformToken.create(
name="USD Coin",
symbol="USDC",
decimals=6,
initial_supply=1_000_000 * 10**6,
max_supply=None # Optional supply cap
)
# Transfer (very cheap - ~1,000 Cycles)
PlatformToken.transfer(token_id=usdc_id, to=recipient, amount=1000)
# Batch transfer (even more efficient)
PlatformToken.transfer_batch([
(usdc_id, alice, 100),
(weth_id, bob, 50),
(dai_id, charlie, 200)
])
# Check balance
balance = PlatformToken.balance_of(token_id=usdc_id, owner=address)
# Approve spender
PlatformToken.approve(token_id=usdc_id, spender=dex_address, amount=1000)
# Transfer from (using allowance)
PlatformToken.transfer_from(
token_id=usdc_id,
from_address=user,
to=recipient,
amount=500
)
# Mint (only mint_authority)
PlatformToken.mint(token_id=usdc_id, to=user, amount=1000)
# Burn
PlatformToken.burn(token_id=usdc_id, amount=500)
| Operation | Platform Token | Actor Token | Improvement |
|---|
| Create token | ~10,000 Cycles | ~500,000 Cycles | 50x cheaper |
| Single transfer | ~1,000 Cycles | ~50,000 Cycles | 50x cheaper |
| Batch 100 transfers | ~50,000 Cycles | ~5,000,000 Cycles | 100x cheaper |
| Balance query | ~100 Cycles | ~10,000 Cycles | 100x cheaper |
✅ Use platform tokens for:
- Stablecoins (USDC, USDT, DAI)
- Wrapped assets (WETH, WBTC)
- Simple utility/payment tokens
- High-frequency trading tokens
- Tokens where gas efficiency matters
- Tokens that benefit from batch operations
# Emitted automatically by runtime
TokenTransfer(token_id: bytes32, from: address, to: address, amount: u256)
TokenApproval(token_id: bytes32, owner: address, spender: address, amount: u256)
TokenMint(token_id: bytes32, to: address, amount: u256)
TokenBurn(token_id: bytes32, from: address, amount: u256)
See Platform Token Implementation Details below for full specification.
Mode 2: Actor-Based Tokens
Actor-based tokens are fully programmable smart contracts that implement the CIP-20 interface. This mode provides maximum flexibility for complex token logic.
When to Use Actor Tokens
✅ Use actor tokens for:
- Governance tokens: Custom voting power calculation, delegation logic
- Rebasing tokens: Balances that change automatically (e.g., stETH)
- Tokens with timers: Leverage CIP-1 for vesting, staking rewards, etc.
- Fee-on-transfer tokens: Take a % on every transfer
- Pausable tokens: Admin can freeze all transfers
- Tiered tokens: Different rules for different holder classes
- Experimental designs: Novel token mechanics
State
The token actor must maintain its state using the protocol’s key-value storage mechanism, as outlined in CIP-4. The following state
variables are required:
- Balances: A mapping from an owner’s address to their token balance.
- Suggested Key:
keccak256("balances" || address)
- Allowances: A mapping from an owner’s address to another spender’s address and the amount they are allowed to withdraw.
- Suggested Key:
keccak256("allowances" || owner_address || spender_address)
- Total Supply: A single value representing the total number of tokens in circulation.
- Suggested Key:
keccak256("total_supply")
Methods
The following methods MUST be implemented as public handlers in the Actor.
class CIP20Token:
"""
A conceptual interface for a CIP-20 compliant token actor.
Actual implementation will use the Cowboy SDK and actor model.
"""
def name(self) -> str:
"""
Returns the name of the token (e.g., "MyToken").
This method is optional.
"""
pass
def symbol(self) -> str:
"""
Returns the symbol of the token (e.g., "MTK").
This method is optional.
"""
pass
def decimals(self) -> int:
"""
Returns the number of decimals the token uses.
e.g., 18 for many tokens, which means that a balance of 10^18 represents 1 token.
This method is optional.
"""
pass
def total_supply(self) -> int:
"""
Returns the total token supply.
"""
pass
def balance_of(self, owner: address) -> int:
"""
Returns the account balance of another account with address `owner`.
"""
pass
def transfer(self, to: address, amount: int) -> bool:
"""
Transfers `amount` tokens from the message sender's account to the `to` address.
MUST emit a `Transfer` event.
MUST return `True` on success.
"""
pass
def approve(self, spender: address, amount: int) -> bool:
"""
Allows `spender` to withdraw from the message sender's account multiple times, up to the `amount`.
If this function is called again, it overwrites the current allowance with `amount`.
MUST emit an `Approval` event.
MUST return `True` on success.
"""
pass
def allowance(self, owner: address, spender: address) -> int:
"""
Returns the amount which `spender` is still allowed to withdraw from `owner`.
"""
pass
def transfer_from(self, from_address: address, to: address, amount: int) -> bool:
"""
Transfers `amount` tokens from `from_address` to `to` address using the allowance mechanism. The caller of this method must have
an allowance from `from_address` of at least `amount`.
MUST emit a `Transfer` event.
MUST return `True` on success.
"""
pass
Events
The following events MUST be emitted by the actor when the corresponding state changes occur. An emit_event host function is assumed
to be available for this purpose.
Transfer
This event MUST be emitted when tokens are transferred, including zero-value transfers. This includes minting (where from is the zero
address) and burning (where to is the zero address).
# Event signature
Transfer(from_address: address, to: address, amount: int)
Approval
This event MUST be emitted on any successful call to approve.
# Event signature
Approval(owner: address, spender: address, amount: int)
This section defines the runtime-level implementation for platform tokens (Mode 1).
Token Registry Data Structures
The Cowboy runtime maintains a canonical token registry with the following structures:
TokenMint
@dataclass
class TokenMint:
"""Platform-level token mint definition (stored in runtime state)"""
token_id: bytes32 # Unique identifier (keccak256(creator || symbol || nonce))
name: str # Token name (e.g., "USD Coin")
symbol: str # Token symbol (e.g., "USDC")
decimals: u8 # Decimal precision (0-18)
total_supply: u256 # Current circulating supply
max_supply: u256 | None # Optional supply cap
mint_authority: address # Who can mint new tokens
freeze_authority: address | None # Who can freeze accounts (optional)
created_at: u64 # Block timestamp of creation
metadata_uri: str | None # Optional URI to metadata (logo, etc.)
TokenAccount
@dataclass
class TokenAccount:
"""User balance for a specific token (stored in runtime state)"""
owner: address # Account owner
token_id: bytes32 # Which token this account holds
balance: u256 # Current balance
allowances: dict[address, u256] # Approved spenders
frozen: bool # Is this account frozen?
Host Functions
The Cowboy VM exposes the following native host functions for platform tokens:
create_token
def create_token(
name: str,
symbol: str,
decimals: u8,
initial_supply: u256,
max_supply: u256 | None = None,
metadata_uri: str | None = None
) -> bytes32:
"""
Create a new platform-level token.
Returns: token_id (bytes32)
Cost:
- Cycles: 10,000 (one-time creation cost)
- Cells: len(name) + len(symbol) + 256 bytes (mint record + metadata)
The caller becomes the mint_authority and freeze_authority.
initial_supply is minted to the caller's account.
Token ID is deterministically generated:
token_id = keccak256(caller_address || symbol || creation_nonce)
"""
token_transfer
def token_transfer(
token_id: bytes32,
to: address,
amount: u256
) -> bool:
"""
Transfer tokens from caller to recipient.
Cost:
- Cycles: 1,000 (50x cheaper than actor message!)
- Cells: 64 bytes (2 balance updates)
Emits: TokenTransfer(token_id, msg.sender, to, amount)
Returns: True on success
Reverts if: insufficient balance, account frozen
"""
token_transfer_batch
def token_transfer_batch(
transfers: list[tuple[bytes32, address, u256]]
) -> bool:
"""
Transfer multiple tokens in a single transaction.
Cost:
- Cycles: 500 + (500 * len(transfers))
- Cells: 32 * len(transfers)
Example:
token_transfer_batch([
(usdc_id, alice, 100 * 10**6),
(weth_id, bob, 5 * 10**18),
(dai_id, charlie, 1000 * 10**18)
])
This is much more efficient than 3 separate transfers:
- Batch: ~2,000 Cycles
- Separate: ~3,000 Cycles + 3x transaction overhead
Returns: True if all transfers succeed
Reverts if: any transfer fails (atomicity guaranteed)
"""
token_approve
def token_approve(
token_id: bytes32,
spender: address,
amount: u256
) -> bool:
"""
Approve spender to transfer up to amount on behalf of caller.
Cost:
- Cycles: 500
- Cells: 32 bytes
Emits: TokenApproval(token_id, msg.sender, spender, amount)
Note: Overwrites existing allowance (ERC-20 race condition applies)
"""
token_transfer_from
def token_transfer_from(
token_id: bytes32,
from_address: address,
to: address,
amount: u256
) -> bool:
"""
Transfer tokens using allowance mechanism.
Requires: caller has allowance from from_address >= amount
Cost:
- Cycles: 1,500 (allowance check + transfer)
- Cells: 96 bytes (allowance update + 2 balance updates)
Emits: TokenTransfer(token_id, from_address, to, amount)
Returns: True on success
Reverts if: insufficient allowance, insufficient balance
"""
token_balance_of
def token_balance_of(
token_id: bytes32,
owner: address
) -> u256:
"""
Get token balance for an account.
Cost:
- Cycles: 100 (read-only, very cheap)
- Cells: 0 (no state mutation)
Returns: Balance (0 if account doesn't exist)
"""
token_mint
def token_mint(
token_id: bytes32,
to: address,
amount: u256
) -> bool:
"""
Mint new tokens (only callable by mint_authority).
Requires: msg.sender == token.mint_authority
Cost:
- Cycles: 1,000
- Cells: 64 bytes (total_supply + recipient balance)
Emits: TokenMint(token_id, to, amount)
Returns: True on success
Reverts if:
- Caller is not mint_authority
- Would exceed max_supply (if set)
"""
token_burn
def token_burn(
token_id: bytes32,
amount: u256
) -> bool:
"""
Burn tokens from caller's balance.
Cost:
- Cycles: 500
- Cells: 64 bytes (total_supply + caller balance)
Emits: TokenBurn(token_id, msg.sender, amount)
Returns: True on success
Reverts if: insufficient balance
"""
Storage Layout
Platform tokens are stored in a dedicated section of the runtime state tree:
Runtime State Tree:
├── accounts/
│ └── {address}/
│ └── balance (CBY native token)
├── actors/
│ └── {actor_address}/
│ └── code, storage, etc.
└── platform_tokens/ ← New section
├── mints/
│ └── {token_id}/
│ └── TokenMint struct
└── accounts/
└── {owner_address}/
└── {token_id}/
└── TokenAccount struct
Benefits of this layout:
- ✅ Separate from actor storage (no Cells cost for platform token operations in actors)
- ✅ Optimized for batch operations (all accounts for a user are grouped)
- ✅ Efficient queries (get all tokens owned by an address)
- ✅ Direct memory access (no KV store overhead)
Rationale
Dual-Mode Design
CIP-20’s dual-mode design combines the best aspects of Ethereum (programmability) and Solana (efficiency):
- Platform tokens provide Solana-level performance for 99% of tokens (stablecoins, wrapped assets, utility tokens)
- Actor tokens provide Ethereum-level programmability for complex use cases (governance, rebasing, timers)
This approach is superior to either extreme:
- Pure actor-based (Ethereum): Expensive, redundant, no batch operations
- Pure platform-based (Solana): Less flexible, can’t customize logic
ERC-20 Compatibility
Both modes closely mirror Ethereum’s ERC-20 standard, which is the most widely adopted and battle-tested fungible token standard
in the blockchain space. By adopting a similar interface, Cowboy can leverage the vast body of existing knowledge, tooling, and developer
experience from the Ethereum ecosystem.
- Python-centric Interface: The methods are defined in a Pythonic way, aligning with Cowboy’s core design principle of being
developer-friendly for the AI and Python communities.
- Optional Methods:
name, symbol, and decimals are optional to keep the core interface minimal, but are strongly recommended
for usability.
- Events: The
Transfer and Approval events are crucial for off-chain applications, indexers, and block explorers to track token
movements and allowances efficiently.
The 50-100x performance improvement of platform tokens is achieved through:
- No actor invocation overhead: Direct runtime calls vs. message passing
- Optimized storage layout: Dedicated token state vs. generic KV store
- Batch operations: Amortized costs across multiple transfers
- Single implementation: No redundant balance tracking code per token
Backwards Compatibility
This CIP defines a new standard and does not introduce any breaking changes to the Cowboy protocol itself. Existing actors are
unaffected.
Security Considerations
- Approval Race Condition: A well-known issue with ERC-20’s
approve function exists. A malicious user can trick a token owner into
approving a new amount before a previous transaction with an old amount is processed, potentially allowing the attacker to spend both
allowances. Implementers SHOULD be aware of this. Optional extensions like increase_allowance and decrease_allowance can be added
to mitigate this risk.
- Integer Overflows/Underflows: While Python 3’s integers have arbitrary precision, which prevents typical overflow issues on
addition or multiplication, subtraction can still lead to underflows (e.g., a balance going negative if not checked).
Implementations MUST validate that an account has a sufficient balance before a transfer and a sufficient allowance before a
transfer_from.
- Re-entrancy: The Cowboy actor model’s single-threaded, sequential message processing provides strong protection against
traditional re-entrancy attacks within a single actor. However, developers should still be cautious about call chains involving
multiple actors, ensuring that state is updated correctly before external calls are made.
- Zero Address Transfers: Implementations should decide whether to allow transfers to the zero address, which is often used to
signify burning tokens. If so, this should be handled explicitly.