Reducing Template Size
A template’s compiled WASM is stored permanently on the network and shipped to every validator in the committee. Size is therefore priced twice: once when you publish, and again — for your users — on every transaction that calls the template.
Everything on this page was measured against the same ~85 line state template using the current
testnet fee table. Your numbers will differ, but the ratios hold.
Why size matters
Section titled “Why size matters”There is a hard cap. ENGINE_LIMITS.max_template_binary_size_bytes is 1.5 MiB. A larger
binary is rejected at publish time, and a transaction may publish at most one template.
Publishing is priced quadratically above 96 KiB. The publish charge is a flat
250,000 µT, plus the first 96 KiB at the per-byte storage rate, plus 100 µT × units² for every
whole KiB beyond that allowance:
| Binary size | Publish charge (testnet) | TemplateLoad charged to each caller |
|---|---|---|
| 72 KiB | ~0.32 tTARI | 240 µT |
| 96 KiB | ~0.35 tTARI | 320 µT |
| 128 KiB | ~0.45 tTARI | 430 µT |
| 256 KiB | ~2.9 tTARI | 870 µT |
| 512 KiB | ~17.7 tTARI | 1,740 µT |
| 1.5 MiB | ~208 tTARI | 5,240 µT |
The 96 KiB allowance is deliberately set just above the floor for a minimal component template, so
the premium prices your code rather than the fixed tari_template_lib machinery. A template that
stays under it pays the same as ordinary storage.
Your users pay for size on every call. TemplateLoad is charged at 10 µT per 3 KB the first
time each template is loaded in a transaction — a cost carried by whoever calls your template, not
by you. Halving your binary halves that line item forever.
Start with the release profile
Section titled “Start with the release profile”The single highest-leverage change is the [profile.release] section of your template’s
Cargo.toml. The default cargo build --release profile is tuned for native speed, not for WASM
size.
[profile.release]opt-level = 's' # Optimize for size ('z' is more aggressive — measure both)lto = true # Link-time optimization: drops unreachable library codecodegen-units = 1 # One unit lets LTO see the whole crate graphpanic = 'abort' # Unwinding tables are dead weight on wasm32strip = true # Drop the `name`, `producers` and `target_features` sectionsMeasured on the state template (sizes after the same wasm-opt pass the walletd applies):
| Build | Raw .wasm |
After wasm-opt |
|---|---|---|
Default --release |
138 KiB | 95 KiB |
| Profile above | 88 KiB | 78 KiB |
Profile above + no_std |
75 KiB | 67 KiB |
| Profile above + immediate-abort panics | 68 KiB | 60 KiB |
| All three together | 63 KiB | 55 KiB |
A few things worth knowing about those knobs:
ltoandcodegen-units = 1do the heavy lifting. They are also what makesopt-levelpay off: on its own,opt-level = 's'or'z'actually produced a larger binary than the defaultopt-level = 3. Only in combination with LTO did the size profile win.'s'is not always beaten by'z'. In the measurement above's'came out ~1.5 KiB smaller than'z'afterwasm-opt. Try both; it costs one rebuild.panic = 'abort'is near-free either way.wasm32-unknown-unknownhas no unwinder, so this changed the binary by under 100 bytes. Keep it for correctness of intent, not for the savings.strip = trueis about more than bytes. The engine rejects a published template carrying any custom section other thantari_tdef, and the toolchain emitsname,producersandtarget_featuresby default.strip = trueremoves exactly those three and leavestari_tdefintact.
wasm-opt — the walletd already runs it
Section titled “wasm-opt — the walletd already runs it”Binaryen’s wasm-opt rewrites an existing binary to be
smaller. You do not need to run it before publishing: the wallet daemon optimizes every binary
it receives, on both the fee-estimate path and the publish path, before the transaction is built.
That applies to the wallet web UI, tari publish, and any direct JSON-RPC call.
The walletd’s pass is equivalent to:
wasm-opt -Os \ --enable-bulk-memory --enable-reference-types \ --strip-debug --strip-producers --strip-target-features \ template.wasm -o template.optimized.wasmOn an unoptimized default-profile build that pass alone recovered ~31% (138 KiB → 95 KiB). On a binary already built with the size profile it still recovered ~12%.
Running it yourself is still useful when you want to know the published size before you publish —
to check it against the 96 KiB allowance in CI, for example. Install it with
cargo install wasm-opt or from the Binaryen releases.
Going no_std
Section titled “Going no_std”tari_template_lib supports no_std. Dropping std removed a further ~11 KiB (~14%) from the
optimized state binary, most of it Rust’s standard-library startup, panic-unwinding and allocator
machinery.
[dependencies]tari_template_lib = { version = "*", default-features = false, features = ["macro", "alloc"] }
# A small allocator. `talc` is what the engine's own no_std test template uses.talc = { version = "5.0.3", default-features = false }#![no_std]
extern crate alloc;
#[cfg(target_arch = "wasm32")]#[global_allocator]static TALC: talc::wasm::WasmDynamicTalc = talc::wasm::new_wasm_dynamic_allocator();
use tari_template_lib::prelude::*;
#[template]mod counter { use alloc::{format, string::String};
use super::*; // ...}What changes in practice:
- You must supply a global allocator. Templates allocate — every
String,Vecand CBOR decode does.talcis the recommended choice. Avoidwee_alloc, which is unmaintained and leaks. stdandallocare mutually exclusive. Enabling both (or neither) onwasm32is a compile error fromtari_template_lib, so you will find out immediately.- Imports move to
alloc.use alloc::{string::String, vec::Vec, format, collections::BTreeMap}inside your template module. - Panic messages still work.
tari_template_libinstalls a#[panic_handler]forno_stdWASM that forwards the message, line and column to the engine, so a failed template still produces a readable reject reason.
no_std is the right call for a template you expect to publish once and have called often. For a
template you are still iterating on, the release profile plus wasm-opt gets you most of the way
with none of the friction.
Dropping panic messages entirely
Section titled “Dropping panic messages entirely”Most of what remains in a small binary is panic and formatting machinery: the code that builds a
message, and the message text itself. Rust’s immediate-abort panic strategy replaces every panic
site with a bare trap, and the optimizer then deletes the messages and the formatting code that fed
them. On the measured template it removed a further 23% (78 KiB → 60 KiB), shrinking the data
section from ~8.8 KiB to ~2.8 KiB.
It needs a nightly toolchain with the rust-src component, because the standard library has to be
rebuilt with the strategy:
rustup toolchain install nightlyrustup component add rust-src --toolchain nightly
RUSTFLAGS="-Zunstable-options -Cpanic=immediate-abort" \ cargo +nightly build --target wasm32-unknown-unknown --release \ -Z build-std=std,panic_abortFor a no_std template, build core instead: -Z build-std=core,alloc,panic_abort.
Because of that, prefer the RUSTFLAGS form above over baking it into [profile.release]: it is
opt-in per build, so your cargo test runs and your debugging builds keep their messages and only
the binary you publish is stripped of them. Confirm the template behaves identically before and
after — semantics do not change (a trap still aborts execution and rejects the transaction), but
your ability to diagnose a failure in production does.
Code-level habits that cost the most
Section titled “Code-level habits that cost the most”Strings and formatting
Section titled “Strings and formatting”Every string literal lands verbatim in the binary’s data section, and every format!, debug!,
panic! or assert! message drags in core::fmt code for the types it formats. In a minimal
template the data section is already ~9 KiB, most of it CBOR and core error text you have no
control over — what you write is added on top of that.
- A single
debug!("Changing value from {:?} to {}", self, value)cost 1.7 KiB in the measured build. Formatting is not free; treat log lines as a debugging tool you remove before publishing, or gate them. - Prefer short, fixed messages:
expect("no vault")overunwrap_or_else(|| panic!("no vault for resource {resource} in component {addr}")). - Reuse one message rather than writing five near-identical ones — each distinct literal is separate bytes.
- Prefer
&'static stroverStringfor constants, and return&strwhere you can. - Logs are also charged a runtime fee per 64 bytes, so short messages save twice.
Debug and Display
Section titled “Debug and Display”A derived Debug impl is only dead weight if something actually formats it — with LTO enabled,
removing #[derive(Debug)] after removing the last {:?} use changed the binary by zero
bytes. The rule is therefore about the call site, not the derive:
- Remove the formatting, and the derive costs nothing.
- Never derive
Debugon a type just to{:?}it in a log line you plan to keep. - Hand-writing
Displayto emit a fixed&'static stris smaller than derivingDebugon a large enum you format.
Dependencies
Section titled “Dependencies”A single carelessly chosen crate can dwarf everything else on this page.
- Audit with
cargo tree --target wasm32-unknown-unknown. Anything you do not recognise is a candidate for removal. - Avoid the usual heavyweights:
serde_json,regex,chrono,num-bigint. Most have no meaning on-chain anyway — a clock, for instance, isConsensus::current_epoch(). - An RNG crate such as
randis not an option at all: it cannot work inside a template, and adding one only costs you bytes. Usetari_template_lib::randand read Randomness in Templates first. - Depend on crates that are
no_std-capable and default-features-off wherever possible. - Do not enable
tari_template_libfeatures you are not using —extra-mapspulls inindexmapandxxhash-rust, andprecision,extra-arith,serde,borshandtsall add code. The last three exist for host-side tooling, not for templates. dev-dependencies(such astari_template_test_tooling) and#[cfg(test)]code are never compiled into thecdylib, so tests cost you nothing on-chain.
Generics and duplication
Section titled “Generics and duplication”Monomorphisation duplicates a function’s body for every type it is instantiated with. When a generic function is large, keep the generic part thin:
pub fn transfer<A: Into<Amount>>(&mut self, amount: A) { self.transfer_inner(amount.into()); // one instantiation of the big body}
fn transfer_inner(&mut self, amount: Amount) { /* ... */}The same applies to macro-generated code and to copy-pasted method bodies — factor the shared part into one non-generic function.
Collections
Section titled “Collections”Prefer Vec and BTreeMap over HashMap in component state: the standard hasher brings machinery
you are paying for in bytes, and ordered collections give you a deterministic encoding for free.
Reach for extra-maps’ IndexMap only when you genuinely need insertion order plus lookup.
Share code across templates instead of duplicating it
Section titled “Share code across templates instead of duplicating it”Templates can call other templates. If several of your templates share a body of logic, publishing
it once and calling it with TemplateManager::get(address).call(...) beats compiling it into each
binary — you pay the publish premium once instead of N times.
Measuring what is actually big
Section titled “Measuring what is actually big”Guessing is a waste of time; both of these take seconds.
Per-function sizes, from Binaryen:
wasm-opt --func-metrics target/wasm32-unknown-unknown/release/my_template.wasm -o /dev/nullA ranked breakdown, from twiggy:
cargo install twiggytwiggy top -n 20 target/wasm32-unknown-unknown/release/my_template.wasmConclusion
Section titled “Conclusion”Applied together, these steps took the measured template from the 95 KiB (just using cargo build --release)
down to 55 KiB — a 42% smaller binary running the same logic.
Checklist
Section titled “Checklist”- Add the
[profile.release]block above; tryopt-level = 's'and'z'and keep the winner. - Build with
cargo build --target wasm32-unknown-unknown --release. - Check the published size with
wasm-opt -Os ...— the walletd will apply the same pass. - If you are over the 96 KiB allowance, run
twiggy topbefore changing any code. - Remove or shorten log and panic formatting; drop dependencies you do not need.
- If it still matters, switch to
no_std+allocwith atalcglobal allocator. - As a last resort, build the published binary with
immediate-abortpanics — and accept that a production failure will no longer tell you what went wrong.
See also
Section titled “See also”- Fees — the full fee model, including the publish premium and
TemplateLoad - Publishing Templates — compiling and publishing a template
- Templates Overview — the
#[template]macro and component state