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.
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.
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
.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.
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.
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.
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.
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.