All pages

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.

PathWhat 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.shTests math, builds everything and copies the results to out/
.cargo/config.tomlSets 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:

  1. Create chain/contracts/<name>/ with a Cargo.toml and src/lib.rs.
  2. Add "<name>" to members in chain/contracts/Cargo.toml.
  3. Add <name> to the for loop in build.sh.
  4. Run cargo update --workspace in chain/contracts/. build.sh builds with --locked, which fails until Cargo.lock knows the new member.
  5. 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(&note_key(&from), &note);
    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(&note_key(&qb::args().address())).unwrap_or_default();
    qb::finish(&note);
}

#[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

ItemSignatureDoes
argsfn args() -> ArgsCopies this call's arguments out of the host
Args::addressfn address(&mut self) -> AddressReads 32 bytes
Args::u128fn u128(&mut self) -> u128Reads 16 bytes, little-endian
Args::u64fn u64(&mut self) -> u64Reads 8 bytes, little-endian
Args::bytesfn bytes(&mut self) -> Vec<u8>Reads a u32 length, then that many bytes
Args::overfn over(bytes: Vec<u8>) -> ArgsReads stored values the same way
packfn pack(parts: &[&[u8]]) -> Vec<u8>Concatenates values in the order a function reads them
finishfn finish(output: &[u8])Sets what the call gives back. It does not return from the function, and the last call wins
finish_u128fn finish_u128(value: u128)finish of 16 little-endian bytes

Context

ItemSignatureDoes
callerfn caller() -> AddressWho called: an account, or a contract
thisfn this() -> AddressThis contract's own address
valuefn value() -> u128The native tokens that came with this call. They are already in this contract's balance
heightfn height() -> u64The block's height
now_msfn now_ms() -> u64The block's time. Validators agree on it to within seconds, so don't lean on seconds
balancefn balance(who: &Address) -> u128Any account's native balance

Storage

ItemSignatureDoes
getfn get(key: &[u8]) -> Option<Vec<u8>>Reads a value
setfn set(key: &[u8], value: &[u8])Writes a value
delfn del(key: &[u8])Deletes a value
get_u128fn get_u128(key: &[u8]) -> u128Reads a 16-byte value. A missing key is 0
set_u128fn 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

ItemSignatureDoes
payfn pay(to: &Address, amount: u128) -> boolSends native tokens from this contract. False if it doesn't have them
emitfn emit(topic: &str, data: &[u8])Adds an event to the receipt
callfn call(contract: &Address, function: &str, args: &[u8], value: u128) -> Result<Vec<u8>, CallFailed>Calls another contract
hashfn hash(data: &[u8]) -> [u8; 32]The chain's contract hash
verifyfn verify(scheme: Scheme, key: &[u8], message: &[u8], signature: &[u8]) -> boolChecks a post-quantum signature

Failure and types

ItemSignatureDoes
revertfn revert(reason: &str) -> !Stops and undoes everything this call did. Unused gas goes back
requirefn require(condition: bool, reason: &str)Reverts unless the condition holds
Addresstype Address = [u8; 32]
NOBODYconst NOBODY: Address32 zero bytes. Contracts use it for the native token, and token for no owner
CallFailedstruct CallFailedWhat call gives back when the callee failed
Schemeenum 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.

ShapeBytesRead with
Address32args.address()
u12816, little-endianargs.u128()
u648, little-endianargs.u64()
Bytes or textu32 length, little-endian, then the bytesargs.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 happensOutcomeReasonUnused gas
qb::revert("…"), or a failed qb::requireRevertedYour textComes back
A panic: expect, an index out of range, arithmetic overflowRevertedpanicComes back
An allocation that does not fit in memoryRevertedpanicComes back
Arguments too shortRevertedmissing argumentsComes back
A reason longer than 256 bytesTrappedBurned
Stack past 32 KiBTrappedBurned
A host limit broken: a key over 128 bytes, a value over 16 KiBTrappedBurned
Gas ran outOutOfGasBurned
No such function, or wrong signatureInvalidComes 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.

OperationGasExample: 33-byte key, 16-byte value
ReadREAD, plus copies20,249
WriteWRITE, 64 a byte of key and value, plus copies18,385
DeleteWRITE, plus a copy of the key15,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_key above. [b't'; 33] fills the array with the prefix, and key[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 call returns Err.
  • At most 8 contracts run at once. Past that, call returns Err.

Three things to design for:

  • Err does 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 Err handler 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_both above, 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:

  1. Runs cargo test --locked -p qubits-contract-math for the host target.
  2. Runs cargo build --release --locked, which targets wasm through .cargo/config.toml.
  3. Copies target/wasm32-unknown-unknown/release/qubits_contract_<name>.wasm to out/<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:

LimitBytes
MAX_CODE_BYTES, the largest Deploy.code163,840
What MAX_TX_GAS lets a deployment of one page and no init store62,443
amm.wasm, the largest shipped contract14,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
OptionDefaultMeaning
--argnoneOne argument of init, repeated in order, in the forms below
--gas5,000,000The gas limit. What isn't used goes back
--outrequiredWhere 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:

FormBytesRead with
qb1…The address, 32 bytesargs.address()
native32 zero bytes, the native token's address in contractsargs.address()
deadline:600Now plus 600 seconds, in milliseconds, as a u64args.u64()
0xabcdThose bytes as they are, with no lengthAnything you lay out by hand
u64:5A u64, 8 bytesargs.u64()
"text"A u32 length, then the textargs.bytes()
5A u128, 16 bytesargs.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:

OptionDefaultMeaning
--amount0Native tokens to send along, in base units
--gas2,000,000The gas limit. What isn't used goes back
--keyThe key file that signs
--accountThe address of --keyThe sending account
--password-filePromptsReads the password from a file
--valid-forAn hour, or a week with --unsigned-outSeconds the transaction stays valid
--unsigned-outWrites the transaction to a file for several signers
--policyThe policy file, for a multisig account's first transaction
--waitOffWaits 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:

--asPrintsOutput must be
hexHex, the defaultAnything
u128A decimal number16 bytes
u128sDecimal numbers separated by spaces, as a pool's reserves come backA non-empty multiple of 16 bytes
u64A decimal number8 bytes
addressqb1…32 bytes
textText, replacing invalid UTF-8Anything
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:

OutcomeMessage
Revertedreverted: <reason>
OutOfGasout of gas; the fee was paid, try a higher --gas
Trappedthe contract crashed; the fee was paid
Invalidno 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/
HelperDoes
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 handler
  • chain/contracts/README.md: the guide in the repository
  • chain/contracts/build.sh, chain/contracts/Cargo.toml, chain/contracts/.cargo/config.toml
  • chain/contracts/token/src/lib.rs, chain/contracts/escrow/src/lib.rs, chain/contracts/amm/src/lib.rs: worked examples
  • chain/crates/qubits-cli/src/contract.rs: the call syntax, argument forms, --as, reporting
  • chain/crates/qubits-cli/src/main.rs: the contract commands and their options
  • chain/crates/qubits-execution/tests/common/mod.rs: the test chain
  • chain/crates/qubits-execution/tests/contracts.rs, chain/crates/qubits-execution/tests/amm.rs