Writing contracts
This page is the guide for contract developers: the workspace, a complete contract, the SDK, arguments, failure, events, storage, calls between contracts, building, deploying, calling from the command line, and testing. Contracts are no_std Rust compiled to WebAssembly. Every Rust sample here was compiled against the real SDK, and the session outputs come from a private single-validator chain. The rules a contract runs under are on Contract model, and gas prices and host calls on Virtual machine.
Only the governance account can deploy, so a contract reaches the chain only when a quorum of validators signs it. See why.
Workspace
Contracts live in chain/contracts/, a cargo workspace of its own, for wasm32-unknown-unknown only.
| Path | What it is |
|---|---|
sdk/ | qubits-contract-sdk, what contracts are written with. no_std, no dependencies |
math/ | qubits-contract-math, 256-bit arithmetic for prices. Pure, and tested on the host |
token/, amm/, escrow/ | The standard contracts |
out/ | The built .wasm files, kept in the tree because tests include_bytes! them and their hashes are what gets deployed |
build.sh | Tests math, builds everything and copies the results to out/ |
.cargo/config.toml | Sets the target, and the stack to 32 KiB with -zstack-size=32768, because a run pays for its memory by the page and Rust's default stack is 1 MiB |
The compiler comes from chain/rust-toolchain.toml, which pins 1.97.1 and installs the wasm target. The release profile is part of the output, so it is the same for every contract:
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
overflow-checks = true
To add a contract:
- Create
chain/contracts/<name>/with aCargo.tomlandsrc/lib.rs. - Add
"<name>"tomembersinchain/contracts/Cargo.toml. - Add
<name>to theforloop inbuild.sh. - Run
cargo update --workspaceinchain/contracts/.build.shbuilds with--locked, which fails untilCargo.lockknows the new member. - Run
./build.sh.
A contract's manifest:
[package]
name = "qubits-contract-tipjar"
version.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
qubits-contract-sdk = { path = "../sdk" }
A complete contract
A contract is a no_std cdylib. Each function is an exported extern "C" fn() with no parameters and no result, and reads its arguments from the host. init runs once, at deployment, and no one can call it again. A contract with no init deploys doing nothing.
This one is a tip jar. It compiles to 3,917 bytes.
//! A tip jar. Anyone sends native tokens with `tip`; the owner takes them out with `withdraw`.
//!
//! `init(owner)`, `tip(note)`, `withdraw(to, amount)`, `tipped(who)`, `note_of(who)`.
#![no_std]
use qubits_contract_sdk::{self as qb, Address, pack};
const OWNER: &[u8] = b"owner";
fn tipped_key(who: &Address) -> [u8; 33] {
let mut key = [b't'; 33];
key[1..].copy_from_slice(who);
key
}
fn note_key(who: &Address) -> [u8; 33] {
let mut key = [b'n'; 33];
key[1..].copy_from_slice(who);
key
}
#[unsafe(no_mangle)]
pub extern "C" fn init() {
let owner = qb::args().address();
qb::set(OWNER, &owner);
}
#[unsafe(no_mangle)]
pub extern "C" fn tip() {
let note = qb::args().bytes();
let amount = qb::value();
qb::require(amount > 0, "send something with the call");
qb::require(note.len() <= 280, "the note is too long");
let from = qb::caller();
let key = tipped_key(&from);
qb::set_u128(&key, qb::get_u128(&key) + amount);
qb::set(¬e_key(&from), ¬e);
qb::emit("tip", &pack(&[&from, &amount.to_le_bytes()]));
}
#[unsafe(no_mangle)]
pub extern "C" fn withdraw() {
let mut args = qb::args();
let to = args.address();
let amount = args.u128();
let owner = qb::get(OWNER).expect("set at init");
qb::require(owner == qb::caller(), "only the owner can do that");
qb::require(qb::pay(&to, amount), "not that much in the jar");
qb::emit("withdraw", &pack(&[&to, &amount.to_le_bytes()]));
}
#[unsafe(no_mangle)]
pub extern "C" fn tipped() {
qb::finish_u128(qb::get_u128(&tipped_key(&qb::args().address())));
}
#[unsafe(no_mangle)]
pub extern "C" fn note_of() {
let note = qb::get(¬e_key(&qb::args().address())).unwrap_or_default();
qb::finish(¬e);
}
#[unsafe(no_mangle)] keeps the export's name, and that name is the function name in a call. The SDK provides the allocator and the panic handler on wasm, so a contract needs neither. Use extern crate alloc; only to name Vec or vec! yourself. The compiler exports the memory as memory, which the VM requires.
SDK reference
qubits-contract-sdk is the qb of the samples.
Arguments and results
| Item | Signature | Does |
|---|---|---|
args | fn args() -> Args | Copies this call's arguments out of the host |
Args::address | fn address(&mut self) -> Address | Reads 32 bytes |
Args::u128 | fn u128(&mut self) -> u128 | Reads 16 bytes, little-endian |
Args::u64 | fn u64(&mut self) -> u64 | Reads 8 bytes, little-endian |
Args::bytes | fn bytes(&mut self) -> Vec<u8> | Reads a u32 length, then that many bytes |
Args::over | fn over(bytes: Vec<u8>) -> Args | Reads stored values the same way |
pack | fn pack(parts: &[&[u8]]) -> Vec<u8> | Concatenates values in the order a function reads them |
finish | fn finish(output: &[u8]) | Sets what the call gives back. It does not return from the function, and the last call wins |
finish_u128 | fn finish_u128(value: u128) | finish of 16 little-endian bytes |
Context
| Item | Signature | Does |
|---|---|---|
caller | fn caller() -> Address | Who called: an account, or a contract |
this | fn this() -> Address | This contract's own address |
value | fn value() -> u128 | The native tokens that came with this call. They are already in this contract's balance |
height | fn height() -> u64 | The block's height |
now_ms | fn now_ms() -> u64 | The block's time. Validators agree on it to within seconds, so don't lean on seconds |
balance | fn balance(who: &Address) -> u128 | Any account's native balance |
Storage
| Item | Signature | Does |
|---|---|---|
get | fn get(key: &[u8]) -> Option<Vec<u8>> | Reads a value |
set | fn set(key: &[u8], value: &[u8]) | Writes a value |
del | fn del(key: &[u8]) | Deletes a value |
get_u128 | fn get_u128(key: &[u8]) -> u128 | Reads a 16-byte value. A missing key is 0 |
set_u128 | fn set_u128(key: &[u8], value: u128) | Writes, or deletes when the value is 0, because state is paid for by the byte and a missing key already reads as zero |
Value, events and calls
| Item | Signature | Does |
|---|---|---|
pay | fn pay(to: &Address, amount: u128) -> bool | Sends native tokens from this contract. False if it doesn't have them |
emit | fn emit(topic: &str, data: &[u8]) | Adds an event to the receipt |
call | fn call(contract: &Address, function: &str, args: &[u8], value: u128) -> Result<Vec<u8>, CallFailed> | Calls another contract |
hash | fn hash(data: &[u8]) -> [u8; 32] | The chain's contract hash |
verify | fn verify(scheme: Scheme, key: &[u8], message: &[u8], signature: &[u8]) -> bool | Checks a post-quantum signature |
Failure and types
| Item | Signature | Does |
|---|---|---|
revert | fn revert(reason: &str) -> ! | Stops and undoes everything this call did. Unused gas goes back |
require | fn require(condition: bool, reason: &str) | Reverts unless the condition holds |
Address | type Address = [u8; 32] | |
NOBODY | const NOBODY: Address | 32 zero bytes. Contracts use it for the native token, and token for no owner |
CallFailed | struct CallFailed | What call gives back when the callee failed |
Scheme | enum Scheme { MlDsa65 = 1, SlhDsaSha2_192s = 2 } | The signature scheme for verify |
Arguments
Arguments are bytes the chain does not read. Args reads them as concatenated values, in the order the function asks for them, with no names, tags or framing.
| Shape | Bytes | Read with |
|---|---|---|
| Address | 32 | args.address() |
u128 | 16, little-endian | args.u128() |
u64 | 8, little-endian | args.u64() |
| Bytes or text | u32 length, little-endian, then the bytes | args.bytes() |
The chain has no argument format, and Args is not a borsh decoder. For these four shapes the bytes are what borsh would give for a tuple of them, so a caller may encode them with borsh.
tip("thanks") has these arguments:
06 00 00 00 74 68 61 6e 6b 73
length 6 t h a n k s
transfer(to, 5) is 32 address bytes, then 05 and fifteen zero bytes.
Too few bytes revert with missing arguments. Bytes left over are ignored, so a transfer with 100 extra bytes on the end succeeded when tried. A contract that wants to refuse them has to count.
What comes back is equally bare: output holds whatever the function gave finish, with no type on it. A caller has to know the shape, which is what --as is for in the CLI.
Failure
Returning from the function is success. Everything else below undoes all the call changed, events included, while the fee and nonce stand. See the failure rule.
| What happens | Outcome | Reason | Unused gas |
|---|---|---|---|
qb::revert("…"), or a failed qb::require | Reverted | Your text | Comes back |
A panic: expect, an index out of range, arithmetic overflow | Reverted | panic | Comes back |
| An allocation that does not fit in memory | Reverted | panic | Comes back |
| Arguments too short | Reverted | missing arguments | Comes back |
| A reason longer than 256 bytes | Trapped | Burned | |
| Stack past 32 KiB | Trapped | Burned | |
| A host limit broken: a key over 128 bytes, a value over 16 KiB | Trapped | Burned | |
| Gas ran out | OutOfGas | Burned | |
| No such function, or wrong signature | Invalid | Comes back |
The panic handler discards the message, so every panic has the same five-byte reason. Use qb::require with short reasons for anything a caller might need to tell apart. Reason strings live in the code, and every byte of code costs 16 gas on every call.
overflow-checks = true means qb::get_u128(&key) + amount panics rather than wraps. Measured, a mint that overflowed u128 supply reverted with panic at 152,200 gas.
Events
qb::emit("tip", &pack(&[&from, &amount.to_le_bytes()]));
An event is a topic of at most 64 bytes and data of at most 4 KiB, both paid for at 8 gas a byte on top of 500. It goes into the receipt with the emitting contract's address, not into state. A contract cannot read events, and they expire with history. There is no index of events: a client reads them from GET /v1/transactions/{id}. A failed call's events are gone. See Receipts.
Data has no format. The standard contracts use pack with fixed-width values, so a reader can slice it by position. token's transfer event is from, to and amount, 80 bytes.
Storage
A contract has one flat key space of its own. Keys are bytes, at most 128. Values are bytes, at most 16 KiB. There is no way to iterate: a contract can read only keys it can name, so a list needs its own index.
| Operation | Gas | Example: 33-byte key, 16-byte value |
|---|---|---|
| Read | READ, plus copies | 20,249 |
| Write | WRITE, 64 a byte of key and value, plus copies | 18,385 |
| Delete | WRITE, plus a copy of the key | 15,133 |
State is the expensive thing here. A write costs 64 gas for each byte of key and value, whether the key is new or not, and deleting returns nothing.
qb::get reads into a 64-byte buffer first. A value longer than that needs a second get, so it costs two READs. Keep frequently read values at 64 bytes or less.
The standard contracts lay keys out in two ways, and neither can collide with the other:
- A singleton has a short fixed name, such as
b"owner". - A map entry is a one-byte prefix and fixed-length parts, as
tipped_keyabove.[b't'; 33]fills the array with the prefix, andkey[1..]is then overwritten.
A key made of several parts of variable length can collide unless the parts are length-prefixed. Hash them when they would be too long: amm keys pools by qb::hash(&pack(&[&low, &high])).
A struct is stored as packed values and read back with Args::over, shortened here from escrow:
struct Deal {
payer: Address,
amount: u128,
deadline_ms: u64,
}
impl Deal {
fn encode(&self) -> Vec<u8> {
pack(&[
&self.payer,
&self.amount.to_le_bytes(),
&self.deadline_ms.to_le_bytes(),
])
}
fn decode(bytes: Vec<u8>) -> Deal {
let mut args = qb::Args::over(bytes);
Deal {
payer: args.address(),
amount: args.u128(),
deadline_ms: args.u64(),
}
}
}
Native tokens
qb::value() is already in the contract's balance when the function starts. If the function does not use it, it stays there, and a failed call sends it back. A function not meant to take tokens should say so, as escrow does with qb::require(qb::value() == 0, "native tokens sent with a token deal").
qb::pay sends only from the contract's own balance. It returns false rather than reverting, so check it.
A plain transfer can raise a contract's balance without running any code, so qb::balance(&qb::this()) is not a ledger. Keep your own books, as amm does.
Calling other contracts
qb::call runs a function of another contract. The callee sees this contract as caller, and value comes from this contract's balance. It returns the callee's output.
/// Pays `amount` of `token` to two accounts out of what this contract holds.
#[unsafe(no_mangle)]
pub extern "C" fn pay_both() {
let mut args = qb::args();
let (token, first, second) = (args.address(), args.address(), args.address());
let amount = args.u128();
for to in [&first, &second] {
let call = pack(&[to, &amount.to_le_bytes()]);
qb::require(qb::call(&token, "transfer", &call, 0).is_ok(), "payment failed");
}
}
fn balance_in(token: &Address, who: &Address) -> u128 {
match qb::call(token, "balance_of", who, 0) {
Ok(output) => u128::from_le_bytes(output.try_into().expect("16 bytes")),
Err(_) => qb::revert("balance_of failed"),
}
}
What the chain guarantees, so a contract doesn't have to:
- A running contract can't be called again, at any depth, so a callee cannot come back in here.
- A failed callee is undone alone, and
callreturnsErr. - At most 8 contracts run at once. Past that,
callreturnsErr.
Three things to design for:
Errdoes not say why. The callee's revert reason does not reach the caller.- All remaining gas goes to the callee. A callee that traps or runs out of gas takes it all, so the
Errhandler has none to work with. Only a revert leaves you gas. See Gas across calls. - A call costs 42,000 gas in the caller, then the usual start of a run: 2,000, plus 16 gas per byte of the callee's code and 500 per page.
pay_bothabove, making two token transfers, used 551,758 gas.
A contract cannot pull tokens from an account. The account approves it first, and it calls transfer_from, as amm and escrow do. Update your own books before you call out, and take a record off them before paying it, as escrow's close does. A deal then can only be settled once, even though the chain already refuses re-entry.
Signatures and hashes
qb::verify checks an ML-DSA-65 or SLH-DSA-SHA2-192s signature. This is what no EVM chain can do cheaply.
/// Records a message `key` signed, under the message's hash.
#[unsafe(no_mangle)]
pub extern "C" fn attest() {
let mut args = qb::args();
let key = args.bytes();
let message = args.bytes();
let signature = args.bytes();
qb::require(
qb::verify(Scheme::MlDsa65, &key, &message, &signature),
"bad signature",
);
qb::set(&qb::hash(&message), &qb::hash(&key));
}
With a 9-byte message, in a test module of 4,992 bytes, this used 217,675 gas. A signature must be made in Domain::Message, which is what qubits sign does. qubits sign and qubits key show print tagged hex, with the scheme as the first byte. verify takes raw bytes, so drop that byte first. Passing it along makes the input too long, which traps.
An SLH-DSA key and signature take 16,272 bytes, so inside the 16 KiB of arguments there is room for a message of at most 100 bytes after the three length prefixes. An ML-DSA-65 pair leaves 11,111 bytes.
qb::hash is BLAKE3 with the chain's own context, not plain BLAKE3, so match it off-chain with contract_hash from qubits-types. It takes up to 16 KiB. See Host calls.
Memory and limits
The SDK's allocator is a bump allocator: a run is short and pays for memory by the page, so nothing is ever given back. It starts at __heap_base and grows memory as needed, at 1,024 gas a page. A module starts with one page unless its data needs more.
The heap stops at 4 MiB in total, with the stack and data below it. Measured, a Vec of 100,000 bytes worked, and one of 4,194,304 bytes reverted with panic.
The stack is 32 KiB at the bottom of memory, so going past it is an access outside memory, which traps. Measured with frames of about 512 bytes, recursion 60 deep worked and 100 deep trapped. Wasm calls stop at 256 frames in any case.
What a contract does not get: floats, a clock finer than the block time, randomness, a way to pull tokens from an account, or a way to deploy another contract. Floating point in the compiled code makes the module Invalid, and the deployment fails. A vault's calls wait for its delay, so don't expect a call in the same block as the one before it.
Build
./build.sh # from chain/contracts
build.sh does three things:
- Runs
cargo test --locked -p qubits-contract-mathfor the host target. - Runs
cargo build --release --locked, which targets wasm through.cargo/config.toml. - Copies
target/wasm32-unknown-unknown/release/qubits_contract_<name>.wasmtoout/<name>.wasm.
Tests load out/, so rebuild after changing a contract. A different byte in token.wasm or amm.wasm changes gas counts, and qubits-vm/tests/vm.rs pins those.
The build is reproducible. Building a copy of the workspace in another directory, with rustc 1.97.1 on arm64 macOS, gave files byte-identical to those in out/.
Size
Code is limited in three ways, and the tightest is the one that matters:
| Limit | Bytes |
|---|---|
MAX_CODE_BYTES, the largest Deploy.code | 163,840 |
What MAX_TX_GAS lets a deployment of one page and no init store | 62,443 |
amm.wasm, the largest shipped contract | 14,282 |
A deployment costs 80 gas a byte, and an init that does work leaves less room still. Size also costs on every call: amm pays 231,012 gas before its first instruction, token 109,332 and escrow 87,140. expect messages cost nothing, because the panic handler ignores them and they do not reach the module. qb::require reasons do reach it.
Deployment
qubits contract deploy writes a transaction file for the governance account. Validators sign it and anyone submits it, as with a change of validators. See Governance.
qubits contract deploy contracts/out/tipjar.wasm --arg qb1owner… --out jar.json
qubits tx sign --file jar.json --key validator.json --password-file pw # each validator, up to a quorum
qubits tx submit --file jar.json --wait
wrote jar.json (fee 3198830000000000); 1 of the 1 current validators must sign it with `qubits tx sign --file jar.json`
if nothing else is sent from that account first, the contract will be at qb1w32tl3w33tcc8pn8g4xwpedcpyvrhvzs040cwqs992y7ph2nfu0smd2dv8
transaction 2513ae5673311d51c216e98e84a219095e6028dd44d1de5438b42e1039abf866 submitted (fee 3198830000000000)
included in block 1
contract qb1w32tl3w33tcc8pn8g4xwpedcpyvrhvzs040cwqs992y7ph2nfu0smd2dv8
gas used 336045
| Option | Default | Meaning |
|---|---|---|
--arg | none | One argument of init, repeated in order, in the forms below |
--gas | 5,000,000 | The gas limit. What isn't used goes back |
--out | required | Where to write the file. It refuses to overwrite one |
The fee in the file covers the largest possible signature set, and the file stays valid for a week. The nonce is fixed when the file is written. The governance account pays, so it needs funds. tx submit --wait exits non-zero if init failed, and then no contract exists.
qubits contract show confirms what landed:
{
"address": "qb1w32tl3w33tcc8pn8g4xwpedcpyvrhvzs040cwqs992y7ph2nfu0smd2dv8",
"code": "3dac7ee97302982a34457f802ab31c201640ce77e5c3b2f8a0e799d19baa19d9",
"code_size": 3917,
"balance": "3000000000000000000"
}
Calling from the command line
qubits contract call 'qb1….tip("thanks, Ada")' --amount 5000000000000000000 --key bob.json --password-file pw --wait
qubits contract view 'qb1….tipped(qb1bob…)' --as u128
transaction d882ae7a32550f9ef65befc5cb65ea851d1854851b839a34c79b28f9d7ad7ddc submitted (fee 1304620000000000)
included in block 3
event tip from qb1w32tl3w33tcc8pn8g4xwpedcpyvrhvzs040cwqs992y7ph2nfu0smd2dv8: 8c0e12578ab0e1af9f7edbde1c10ccac68cf4eef715f47941e4119b03d94cd1b0000f444829163450000000000000000
gas used 125086
Quote the call for the shell. It reads address.function(arguments): the text before the first . is the contract, the text up to the first ( is the function, and the call must end in ). Arguments split on commas outside double quotes, and each is trimmed.
Argument forms
An argument's shape says its type. The forms are tried in this order, and they are the shapes Args reads:
| Form | Bytes | Read with |
|---|---|---|
qb1… | The address, 32 bytes | args.address() |
native | 32 zero bytes, the native token's address in contracts | args.address() |
deadline:600 | Now plus 600 seconds, in milliseconds, as a u64 | args.u64() |
0xabcd | Those bytes as they are, with no length | Anything you lay out by hand |
u64:5 | A u64, 8 bytes | args.u64() |
"text" | A u32 length, then the text | args.bytes() |
5 | A u128, 16 bytes | args.u128() |
deadline: uses the clock of the machine running the command. "text" has no escapes. 0x bytes meant for args.bytes() must include the length themselves. Anything else is refused before a transaction is built, such as twelve: not an address, number, "text" or 0x bytes. A number is in base units: 5 is five base units, however many decimals the token has.
--arg on contract deploy takes the same forms, one each, so a name goes in as --arg '"Qubits Dollar"'.
Options
contract call takes these options:
| Option | Default | Meaning |
|---|---|---|
--amount | 0 | Native tokens to send along, in base units |
--gas | 2,000,000 | The gas limit. What isn't used goes back |
--key | The key file that signs | |
--account | The address of --key | The sending account |
--password-file | Prompts | Reads the password from a file |
--valid-for | An hour, or a week with --unsigned-out | Seconds the transaction stays valid |
--unsigned-out | Writes the transaction to a file for several signers | |
--policy | The policy file, for a multisig account's first transaction | |
--wait | Off | Waits for a block and reports the receipt |
contract view runs for free and keeps nothing, with the all-zero address as caller and no value. --as says how to print the output:
--as | Prints | Output must be |
|---|---|---|
hex | Hex, the default | Anything |
u128 | A decimal number | 16 bytes |
u128s | Decimal numbers separated by spaces, as a pool's reserves come back | A non-empty multiple of 16 bytes |
u64 | A decimal number | 8 bytes |
address | qb1… | 32 bytes |
text | Text, replacing invalid UTF-8 | Anything |
qubits contract view 'qb1token….decimals()' --as u64 # 6
qubits contract view 'qb1token….name()' --as text # Qubits Dollar
qubits contract view 'qb1amm….pool(qb1token…, native)' --as u128s # 100000 400000 200000
Reading the result
With --wait, the command prints the contract address of a deployment, the output in hex, each event, and the gas used. A contract that failed makes the command exit non-zero, even though the transaction was included and paid:
| Outcome | Message |
|---|---|
Reverted | reverted: <reason> |
OutOfGas | out of gas; the fee was paid, try a higher --gas |
Trapped | the contract crashed; the fee was paid |
Invalid | no such function, or the code is not a contract |
A vault's call goes into the delay queue, so --wait reports the block and no receipt. After the delay, anyone runs it with qubits vault execute --vault qb1… --id N, and the receipt belongs to that transaction. See Vault calls.
Testing
Pure logic on the host
Host calls exist only inside the VM. Put arithmetic worth testing in a crate of its own with no SDK dependency, as math/ is, and test it for the host target. build.sh does this for math.
A chain in a test
chain/crates/qubits-execution/tests/common/mod.rs is a small chain on MemoryState, with real transactions, signatures and fees. min_fee_per_byte is 1, and each block holds what you submit. A test for the contract above, in tests/tipjar.rs of that crate:
mod common;
use common::*;
use qubits_types::*;
const TIPJAR: &[u8] = include_bytes!("../../../contracts/out/tipjar.wasm");
#[test]
fn only_the_owner_empties_the_jar() {
let (alice, bob) = (key(1), key(2));
let (a, b) = (address(&alice), address(&bob));
let mut chain = Chain::new(&[&alice, &bob]);
let jar = chain.deploy(TIPJAR, a.as_bytes().to_vec());
let note = pack(&[&6u32.to_le_bytes(), b"thanks"]);
chain.done(&bob, jar, "tip", note, 500);
assert_eq!(chain.balance(&jar), 500);
assert_eq!(chain.view_u128(jar, "tipped", b.as_bytes().to_vec()), 500);
let taken = chain.call(&bob, jar, "withdraw", send_args(&b, 1), 0).unwrap();
reverted(&taken, "only the owner can do that");
chain.done(&alice, jar, "withdraw", send_args(&a, 200), 0);
assert_eq!(chain.balance(&jar), 300);
}
cargo test -p qubits-execution --test tipjar # from chain/
| Helper | Does |
|---|---|
key(seed), address(&key) | A deterministic ML-DSA-65 key and its account |
Chain::new(&[&key, …]) | A chain with those accounts and the governance account funded with 1,000,000,000 each |
chain.deploy(code, args) | Deploys as governance, and asserts Done |
chain.call(&key, contract, function, args, amount) | One block with one call. Err for an invalid transaction, Ok(receipt) otherwise |
chain.done(…) | call, asserting Done |
chain.view(contract, function, args), chain.view_u128(…) | A free read |
chain.stored(contract, name) | The raw storage value |
chain.balance(&who), chain.account(&who) | Native balance and account |
chain.now, chain.submit(stx), chain.from(&key, payload), chain.block(&txs) | The clock, and full control over transactions and blocks |
reverted(&receipt, "reason") | Asserts the exact reason |
wasm(text), pack(parts), send_args(&to, amount) | WAT to bytes, and argument building |
Hostile code
Test what a contract does next to code that misbehaves, written in WAT with wasm(...). tests/contracts.rs has a contract that writes then reverts, BOUNCE for chains of calls and re-entry, and a loop for gas. tests/amm.rs has FAKE, a token that says yes to everything and moves nothing.
Exact numbers
For anything with rounding, work the expected value out in unbounded integers outside the code, such as Python, and pin it in the test. a_swap_pays_the_formula and huge_amounts_are_exact do this.
What must always hold
Random operations with a fixed seed find what examples miss, and a failure can be run again. random_trading_never_breaks_the_books runs 250 random operations for each of 3 seeds. After each one it checks that the books equal the balances, every share has an owner, and a share's value never falls.
Gas
qubits-vm/tests/vm.rs pins exact gas for the shipped contracts on a plain host. Add a pin for a contract whose cost matters. Print receipt.gas_used while developing, as everything_the_market_does_fits_in_a_transaction does, and check the call fits the gas you plan to set.
Source
chain/contracts/sdk/src/lib.rs: the SDK, allocator and panic handlerchain/contracts/README.md: the guide in the repositorychain/contracts/build.sh,chain/contracts/Cargo.toml,chain/contracts/.cargo/config.tomlchain/contracts/token/src/lib.rs,chain/contracts/escrow/src/lib.rs,chain/contracts/amm/src/lib.rs: worked exampleschain/crates/qubits-cli/src/contract.rs: the call syntax, argument forms,--as, reportingchain/crates/qubits-cli/src/main.rs: thecontractcommands and their optionschain/crates/qubits-execution/tests/common/mod.rs: the test chainchain/crates/qubits-execution/tests/contracts.rs,chain/crates/qubits-execution/tests/amm.rs