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 TCC mainnet.
The SDK is the tcc-sdk crate in the TCC repository. It has no
dependencies in the contract build.
Two chains, the same software and the same SDK. Build on the test chain.
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.
Real TCC, real NFTs, real marketplaces. The chain 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 mainnet as the place you ship to after your contract works on 91339.
https://rpc2.tcc-coin.com/rpc
Six rules cover almost everything a contract author needs to know.
A method is a plain fn name() -> i32 listed in export!(…). Clients call it by that name.
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.
ret(status, payload). On the wire: status[4, i32 LE] ‖ payload. Status 0 means success.
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.
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.
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.
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.
revert (or traps) has its writes discarded.
use tcc_sdk::*; — every function below, the host import it uses, and
what it does on chain 91338.
| tcc-sdk | Host import | Behaviour |
|---|---|---|
args() -> Vec<u8> | tcc_get_args | The call's bytes, at any size. |
caller() -> Address | tcc_caller | Signer of the transaction; the calling contract inside invoke. |
self_address() -> Address | tcc_self | This contract's address. |
block_height() -> u64 | tcc_block_height | Height of the block being applied (a view sees the tip). Constant within a transaction. |
storage_get / storage_set / storage_delete / storage_has | tcc_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_revert | Finish the method. Return frame at most 64 KiB. |
Reader / Writer | — | Parse arguments / build payloads: u8 u32 u64 u128 address lp8 bytes. |
| tcc-sdk | Host import | Behaviour |
|---|---|---|
value() -> u128 | tcc_value | Wei 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_native | Pay 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. |
| tcc-sdk | Host import | Behaviour |
|---|---|---|
blake3(&data) -> [u8; 32] | tcc_blake3 | Hashed by the host. |
verify_sig(pubkey, msg, sig) -> bool | tcc_verify_sig | Dilithium3: 1952-byte public key (not the address), 3309-byte signature, message ≤ 64 KiB. Costs 1,000,000 gas. |
address_of(pubkey) -> Address | tcc_blake3 | The address that belongs to a public key. |
invoke(&contract, method, &args, gas) | tcc_invoke | Call another contract; returns its Response { status, data } or a CallError. At most 4 levels deep. Use CPI_GAS (10,000,000) if unsure. |
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.
All RPC parameters are JSON arrays, in the order shown. Endpoint:
https://rpc2.tcc-coin.com/rpc (fallback rpc3).
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.
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.
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.
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.
| What | Value |
|---|---|
| Fee | ≈ 0.001 TCC per transaction (the governed base_fee), whatever the gas limit. A rejected call pays nothing. |
| Gas per transaction | at most 100,000,000; views run with 100,000,000 |
| Storage value | 65,532 bytes per key (SDK limit; any stored value can be returned whole) |
| Return frame | 64 KiB |
| Cross-contract depth | 4 |
| Upload part | 2,048 bytes per BufferWrite |
| Block time | ~10 s — BLOCKS_PER_DAY = 8,640 |
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.
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…
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.
tcc_account_balance, tcc_account_exists, tcc_account_data_*,
tcc_block_hash, tcc_validator_count, tcc_validator_at — they read
storage left over from the pre-v4 chain. A node that synced from a snapshot does not have it,
so two nodes can answer differently, and a contract depending on the answer can split the chain.tcc_log — accepted, but its output is discarded (not in node logs, not returned by
views). Return data instead.tcc_invoke_as — token-bound-account calls; not exercised on v4.