TCC · Contract SDK

Build on TCC.
Rust in, WebAssembly on-chain.

Write a contract in plain Rust with tcc-sdk, test it on your machine under the chain's own rules, and deploy a 10–20 KB WebAssembly module to the live TCC chain.

live chain 91338 test chain 91339 Rust → WASM Dilithium3 · post-quantum ~10 s blocks fee ≈ 0.001 TCC / tx tip

Quickstart

The SDK is the tcc-sdk crate in the TCC repository. It has no dependencies in the contract build.

Cargo.toml
src/lib.rs
terminal

Where to deploy

Two chains, the same software and the same SDK. Build on the test chain.

Test chain — 91339

Start here. Coins are worth nothing, so a mistake costs nothing, and it already runs the rules that bound what a contract may do: 64 MiB of memory per call, storage priced by the byte with values capped at 65,532, a program that does not compile refused at deploy, and deploy-plus-initialise in one transaction.

RPC https://rpc-test.tcc-coin.com/rpc · faucet https://faucet-test.tcc-coin.com/rpc — 5 TCC per wallet per hour, which is about five thousand transactions. Both answer over https with CORS open, so they work from curl, from Node and from a page in the browser.

Live chain — 91338

The chain the TCC wallet, the NFT market and the file market already run on: the coins here are the ones people hold, and what you deploy stays. It accepts a deploy from anyone today, but the limits above are not live on it yet — they arrive with a scheduled upgrade (PLAN-untrusted-contracts.md). Until then a contract here can be written carelessly and take a node with it, so treat the live chain as the place you ship to after your contract works on 91339.

https://rpc2.tcc-coin.com/rpc

Test coins

The faucet hands out 5 TCC an hour per wallet, and the wallet must SIGN for them — so a claim proves who is asking, and an address alone is not enough. Sign the blake3 of "TCC-FAUCET-CLAIM-v1" ‖ chain_id(u64 LE) ‖ recipient(32) ‖ timestamp(i64 LE) with your Dilithium3 key (the timestamp must be within a few minutes of now), then call tcc_faucet([recipient, sig_hex, pubkey_hex, timestamp]).

What differs between them is only what a contract may DO, never how you write it. Same methods, same host functions, same SDK, same deploy flow — so a contract that behaves on 91339 behaves on 91338. What 91339 will refuse and the live chain today will not: an oversize value, a program that is not valid WebAssembly, an instance that asks for gigabytes of memory.

Shipping it to the live chain

There is no export, no migration and no porting step. You deploy the same bytes a second time, on the other chain.

1
Keep the exact .wasm you tested. A contract's address is blake3(code), so the same bytes land on the same address on both chains — and a rebuild that differs by one byte is a different contract at a different address. Ship the artifact, do not rebuild it.
2
Fund a wallet on the live chain. A deploy is not one transaction: BufferInit, then one BufferWrite per 2,048 bytes, then ContractDeploy, then your initialize. A 20 KB program is about 13 transactions — on the order of 0.013 TCC at the current flat fee.
3
Point at the live chain and sign again. In the Contract Tools that is the network selector in the header. By hand, it is the RPC URL. Either way the transactions must be rebuilt and re-signed: chain_id is inside the signed message, so nothing you signed for 91339 can be replayed on 91338. That is the protection working, not an obstacle.
4
Deploy and initialise as two transactions, for now. ContractDeployInit, which does both atomically, runs on 91339 today but the live chain rejects it until the scheduled upgrade height. Before that, deploy first and call initialize second — and whoever sends the second transaction owns the contract, so send it yourself, immediately.
Want a second instance of the same template? You cannot deploy identical bytes twice — identical bytes are the same address, already taken. Change the module so it differs: the tools page does it for you by appending a random salt to a custom section, which leaves the code's behaviour untouched and gives it a new address.

Contract model

Six rules cover almost everything a contract author needs to know.

1 · Methods

A method is a plain fn name() -> i32 listed in export!(…). Clients call it by that name.

2 · Arguments are bytes

args() returns the call's bytes exactly as sent; you define the layout. Reader parses it and returns None on short input, so bad input becomes a status code.

3 · Return = status + payload

ret(status, payload). On the wire: status[4, i32 LE] ‖ payload. Status 0 means success.

4 · Storage

Each contract has its own key-value store (raw bytes). Values up to 65,532 bytes. There is no iteration: keep your own counters and indexes.

5 · Addresses

An account address is blake3(public key); a contract address is blake3(code). caller() is the signer, or the calling contract inside a cross-contract call.

6 · Determinism

Every node re-runs your method and must get the same bytes: no clock, no randomness, no floats in state. Use block_height() for time.

A non-zero status rejects the whole transaction. On TCC, a method that returns anything but 0 (or calls revert, or panics) does not "fail with an error code": the transaction is dropped. Nothing is written, no TCC moves, it is not included in a block, no fee is charged and the sender's nonce does not advance. The status is not stored anywhere on chain. To show a user why, run the same call first as a read-only view (tcc_callContract with the user as caller), which returns the status. The Contract Tools page does exactly this before every signature.
Inside a cross-contract call the rule differs: a callee that returns a non-zero status keeps its writes and hands the status back to the caller. Only a callee that calls revert (or traps) has its writes discarded.

API

use tcc_sdk::*; — every function below, the host import it uses, and what it does on chain 91338.

Call context & storage

tcc-sdkHost importBehaviour
args() -> Vec<u8>tcc_get_argsThe call's bytes, at any size.
caller() -> Addresstcc_callerSigner of the transaction; the calling contract inside invoke.
self_address() -> Addresstcc_selfThis contract's address.
block_height() -> u64tcc_block_heightHeight of the block being applied (a view sees the tip). Constant within a transaction.
storage_get / storage_set / storage_delete / storage_hastcc_storage_*This contract's key-value store. Values over 65,532 bytes abort the call.
get_u64 / set_u64 / get_u128 / set_u128 / get_address / key(…)Typed helpers, little-endian; absent reads as 0.
ret(status, payload) / ok() / revert(status)tcc_set_return, tcc_revertFinish the method. Return frame at most 64 KiB.
Reader / WriterParse arguments / build payloads: u8 u32 u64 u128 address lp8 bytes.

Native TCC

tcc-sdkHost importBehaviour
value() -> u128tcc_valueWei sent with this call (1018 wei = 1 TCC). Already in the contract's balance when the method starts. 0 inside invoke.
transfer_native(&to, wei)tcc_transfer_nativePay from this contract's balance. Settled after the method returns 0; paying out more than the contract holds rejects the transaction. The recipient runs no code.

A contract cannot reliably read balances on v4, so a contract that holds money keeps its own ledger of what value() brought in and what it paid out. The escrow example shows the pattern.

Crypto & cross-contract calls

tcc-sdkHost importBehaviour
blake3(&data) -> [u8; 32]tcc_blake3Hashed by the host.
verify_sig(pubkey, msg, sig) -> booltcc_verify_sigDilithium3: 1952-byte public key (not the address), 3309-byte signature, message ≤ 64 KiB. Costs 1,000,000 gas.
address_of(pubkey) -> Addresstcc_blake3The address that belongs to a public key.
invoke(&contract, method, &args, gas)tcc_invokeCall another contract; returns its Response { status, data } or a CallError. At most 4 levels deep. Use CPI_GAS (10,000,000) if unsure.
List every contract you invoke in the transaction's accounts — the 8th parameter of tcc_buildUnsignedContractCall. A callee that is not listed is not found or reads its storage as empty, and the call fails. The badge example shows it both ways.

Deploy & call

All RPC parameters are JSON arrays, in the order shown. Endpoint: https://rpc2.tcc-coin.com/rpc (fallback rpc3).

1

Upload

tcc_buildUnsignedBufferInit(owner, size, parts) — the answer carries the buffer address: use it, don't derive it. Then one tcc_buildUnsignedBufferWrite(owner, buffer, index, data_hex, gas_price, nonce) per 2,048-byte part.

2

Deploy

tcc_buildUnsignedContractDeploy(owner, buffer, gas_price, nonce). The contract lives at blake3(wasm). The same bytes always land at the same address — to deploy a second instance, append a WASM custom section with a random salt.

3

Call

tcc_buildUnsignedContractCall(from, contract, method, args_hex, value_wei, gas_limit, gas_price, accounts). Views: tcc_callContract(contract, method, args_hex, caller) — free, nothing kept.

Deploy and initialise in one transaction. Between a deploy and its first call, anyone watching the mempool can call initialize on your fresh contract and own it. tcc_buildUnsignedContractDeployInit(owner, buffer, method, args_hex, value_wei, gas_limit, gas_price, accounts, nonce) does both at once. The chain accepts it only from the untrusted-code activation height (see PLAN-untrusted-contracts.md); until then, deploy and initialise back to back from the same wallet and check the result.

From a browser

Do not write this by hand: web3-app/tcc-client.js is the shared client — RPC with failover, the wallet derivation the TCC wallet uses, sign and submit, confirmation by nonce, read-only calls, and publishing a program (which takes the buffer address from the node instead of deriving it — the bug that left the tools page broken for months). import * as tcc from './tcc-client.js', then tcc.configure({ rpcs, pkgDir }).

Signing

Every tcc_buildUnsigned… answer holds signing_message_hex, unsigned_tx_base64 and nonce. Sign the message with the wallet's Dilithium3 key and submit tcc_submitSignedTransfer(unsigned_tx_base64, signature_hex, public_key_hex). A transaction has been applied when tcc_getNextNonce(address) passes its nonce; if it never does, the call was rejected (see the status rule) — run it as a view to learn why.

a view call

Answer: {"status": 0, "data": "…"}. data is the hex of the whole return frame, so the payload starts at byte 4.

Limits & costs

WhatValue
Fee≈ 0.001 TCC per transaction (the governed base_fee), whatever the gas limit. A rejected call pays nothing.
Gas per transactionat most 100,000,000; views run with 100,000,000
Storage value65,532 bytes per key (SDK limit; any stored value can be returned whole)
Return frame64 KiB
Cross-contract depth4
Upload part2,048 bytes per BufferWrite
Block time~10 s — BLOCKS_PER_DAY = 8,640

Testing on your machine

tcc_sdk::testing is a mock host with the chain's rules: a non-zero status or revert leaves no trace, payouts are settled against what the contract holds, and invoke reaches only contracts you mock and list in accounts.

tests

Run cargo test (the debug profile — the release profile aborts on panic).

Examples

Six complete contracts with tests, from tcc-sdk/examples/. All six were run on the v4 VM with the exact .wasm they build to.

loading…

Do not use

The VM still exports these, but on chain 91338 they are not safe. tcc-sdk does not wrap them; a hand-written import must not use them either.