moxzi
Docs / On-chain builds / What a build costs

What a build costsalpha

How is a build priced, paid for, and refunded?

A paid build is an escrow: the caller approves an amount, the canister pulls the maximum the build could cost, the compile runs unmetered by any per-message payment check, and settlement charges three times what the job actually used and refunds the rest. The constraint that makes this shape necessary is that a compile is thousands of messages — checking payment per message would be absurd, so the money is secured once, up front.

Payment is inert until an operator configures it. A fresh deployment has no ledger and no CMC; it compiles for free and says so on connect. It never silently charges.

The escrow formula#

From Main.mo, and settable by operators because they are business decisions:

constantdefaultmeaning
escrowCyclesPerByte750_000cycles escrowed per uploaded source byte
escrowCyclesFloor500_000_000_0000.5 T — a one-file build still has fixed costs
escrowCycles11_520_000_000_00011.52 T — the flat fallback when size is unknown
chargeMultiplier3settlement charges 3x actual use
ledgerFeeE8s10_000the ICP transfer fee
escrowCyclesFor(0)     = escrowCycles
escrowCyclesFor(bytes) = max(escrowCyclesFloor, bytes * escrowCyclesPerByte)

750_000 is 3x the worst rate ever measured (200,657 cycles/byte, the compiler compiling itself), rounded up, because settlement charges 3x actual and the escrow has to cover that. Over-escrowing costs the customer nothing but a temporarily larger approval.

ICP to cycles, without an oracle#

Cycles are XDR-denominated — 1 T cycles is 1 XDR by definition — so an ICP price needs a rate. It comes from the Cycles Minting Canister's get_icp_xdr_conversion_rate, which is a free query returning xdr_permyriad_per_icp, and crucially it is the same rate the CMC mints cycles at, so the number quoted and the number later converted at cannot drift.

e8s = cycles * 1e4 * 1e8 / (1e12 * permyriad) = cycles / permyriad

The 1e12 cancels exactly against 1e4 * 1e8. Sanity check: 1 T cycles at 30,000 permyriad (3 XDR/ICP) is 33,333,333 e8s — a third of an ICP. No exchange-rate canister and no DEX is involved; those would only be needed for the ck-token legs.

Quoting, and one wrinkle#

methodkindreturns
quoteEscrowFor(sourceBytes)updateescrowE8s, rateXdrPermyriad, ledger, ledgerFeeE8s, escrowCycles for a build of that size
quoteEscrow()updatethe same, from the flat escrowCycles
getPrice()querypriceE8s, ledger, ledgerFeeE8sledger = null means free

Both quotes are updates, not queries, because the rate comes from an inter-canister call. Pricing is server-side on purpose: a client that reimplements the arithmetic drifts from it, and the drift surfaces as InsufficientAllowance at the worst possible moment.

The wrinkle worth knowing: moxzi build --remote calls the flat quoteEscrow() and approves that, while startEscrowed pulls the size-aware amount computed from what you actually uploaded. Approving more than is pulled is harmless — the allowance simply is not fully used — but the number the CLI prints is the ceiling, not the pull.

startEscrowed also refuses up front what the deployment cannot finish: if the canister's own cycle balance cannot cover the quoted work plus a reserve, it returns an error before any money moves. That check exists because a self-compile once ran 1,414 messages and died with out of cycles, burning cycles and the customer's time for nothing.

What you are actually charged#

Billing is from the job's own counters, not from a balance delta, so two jobs cannot absorb each other's consumption:

exec        = messages * 590_000  +  instructionsUsed * 4 / 10
cyclesUsed  = exec * 80_000 / 10_000            // the 8.0x overhead factor
chargeE8s   = min(escrowE8s, e8sForCycles(cyclesUsed, rate) * 3)
refundE8s   = escrowE8s - chargeE8s

The defaults 590_000 cycles per update message and 0.4 cycles per instruction are the published rates for a 13-node application subnet; they scale linearly with node count and are operator-settable because they are external facts that change.

The 80_000 permyriad (8.0x) overhead factor covers what instructions and messages miss: memory is charged per GiB-second, and a compile holding several GB for ten minutes pays a lot of it. Memory cannot be attributed to one job when several share a heap, so it is folded into a calibrated multiplier instead. Every settlement records cyclesObserved (the real balance delta) beside cyclesUsed (the billed figure) so the factor can be recalibrated from data: measured, 5.70x for a small actor, 7.34x for evm_engine.

Do not price from instruction counts#

instructionsUsed reports only what executes inside the canister's own wasm. The IC also bills GC, memory operations and scheduling that the counter cannot see. Measured against real spend it under-reports by 2.3x–3.4x, and the ratio is not constant. Storage and per-message ingress overhead were both ruled out arithmetically as the explanation (0.17% and ~1.4% of the bill respectively).

Price from measured cycle-balance deltas. These are real, mainnet, 2026-08-21:

buildsourcemessagescycles spentcycles/source byte
hello.mo1 file, 217 B52~0.001 T
the compiler, compiling itself177 files, 8.0 MB2,345~1.6 T~200,000
evm_engine661 files, 15.3 MB4,138~1.6 T/attempt~104,000

The compiler is the pathological case because its functions are enormous. Price from the worst case, not the average.

Settlement is a state machine#

settleStep(id) advances a settlement by exactly one step and is idempotent, so a caller can retry indefinitely: a failed step leaves the state untouched and the next call redoes only that step.

statethe step that leaves it
escrowedmeasure — a no-op until the job is done, so it is safe to poll
measuredrefund the unused escrow to the payer
refunded → … → donethe charge is disbursed, one recorded transfer per step

Every leg records its ledger block index before advancing and checks that record first, so no leg ever moves money twice. The top-up is the one step made of two separate transfers of control (transfer, then notify_top_up) and therefore the one place a retry could double-spend; the recorded block index is reused rather than transferring again, and notify_top_up is keyed on that index and is idempotent.

settleStep also takes an in-flight lock, set synchronously before any await. Without it, two concurrent calls both pass the state guard and both fire the refund — the payer double-refunds themselves and drains the canister.

The CLI polls settleStep up to 40 times, then reads getSettlement. If settlement lags, it prints settlement pending and keeps your artifact.

Refunds and walk-aways#

situationwhat happens
build succeedscharged 3x actual, capped at the escrow; the remainder refunds
build fails or OOMsstill settles for what it burned; the rest refunds
customer walks away mid-compilean operator calls abandonSettlement(id), which measures the real burn and hands the rest to the normal refund path
refund smaller than the ledger feethe step advances without transferring
flat-fee path (startPaid)refundJob(id) refunds the recorded charge, once; operator-only, because whether a failed compile deserves a refund is policy

A job with an open settlement is never evicted by retention pressure — settlement reads the job to learn whether it finished, so evicting it would strand the customer's ICP.

Things you cannot do#

Why
Pay in cycles directlyThe client side is entirely ICP over ICRC-2. The canister converts.
Get a firm price before uploadingThe size-aware quote needs the bytes to already be in your VFS; startEscrowed prices from vfsUsed(caller).
Predict the charge from a local --timings runLocal instruction counts do not map to billed cycles at a constant ratio.
Settle two concurrent jobs accuratelycyclesObserved is a balance delta; with two jobs in flight each is attributed the other's consumption. Correct for a serial queue, which is what this is.
Recover an escrow yourself if the job never finishesabandonSettlement is operator-only — deciding a job is abandoned is a judgement call, not a fact the canister can observe.

Next#

On this pageThe escrow formulaICP to cycles, without an oracleQuoting, and one wrinkleWhat you are actually chargedDo not price from instruction countsSettlement is a state machineRefunds and walk-awaysThings you cannot doNext