Dispenser
The Dispenser is the hosted, self-service path for someone who does not have a Neutron yet. It accepts an ICP deposit, creates a canister, installs the current SushiOS starter, and hands Kernel authorization to the Internet Identity principal created at the new canister's origin.
It is a separate product from the operator-owned
neutron-provision CLI. The Dispenser frontend is
also a separate certified asset canister from its backend.
End-to-end handoff
There is no Internet Identity login at the Dispenser origin. Internet Identity
principals are origin-scoped, so a principal obtained there would not be the
principal later produced at https://<neutron-id>.icp0.io anyway.
Browser provisioning secrets
On first load the frontend uses Web Crypto to create two independent random 32-byte values:
- an Ed25519 seed for the identity that signs ledger and Dispenser calls; and
- a canonical, unpadded base64url activation code.
It synchronously stores both before exposing the deposit address, under a key namespaced by the Dispenser backend principal:
neutron.dispenser.provisioning.v1:<dispenser-canister-id>
A reload reconstructs the same private identity, deposit account, backend registration, and activation link. An invalid stored record is left untouched and the flow stops; silently replacing it could strand a funded account.
The identities have deliberately separate jobs:
| Credential | Purpose | Final authority |
|---|---|---|
| Browser-generated Ed25519 identity | Select the caller-derived backend subaccount and sign provisioning calls | None |
| Raw activation code | One-time bearer used after login at the new origin | Authorizes the authenticated redeemer |
| Internet Identity principal | Normal owner identity at the new Neutron origin | Kernel-authorized after redemption |
The backend receives only SHA-256(activation_code). It never receives the raw
code, the provisioning private key, or an Internet Identity delegation. The
activation code has no time-based expiry and must be treated as a full-owner
bearer until consumed.
:::caution Keep the original browser profile until activation The saved keys are origin-scoped browser data, not a hardware-backed wallet or an exported recovery kit. Clearing site data, losing the profile, or allowing hostile script at the Dispenser origin to read it can lose or expose the handoff. The original Dispenser tab can reproduce the activation link after a reload; the stored record is not automatically deleted when activation succeeds. :::
Funding
The deposit account has:
- owner: the Dispenser backend canister; and
- subaccount:
SubAccount.fromPrincipal(provisioning_identity_principal).
The UI shows the same account in canonical ICRC-1 text and legacy ICP
account-identifier formats. It polls the ICP ledger every ten seconds and starts
provisioning automatically when the cumulative balance reaches
200_000_000 e8s (2 ICP). The backend independently enforces the same minimum.
At the paid boundary the backend reads the current ledger fee and fixes a
transfer of balance - fee to the official Cycles Minting Canister. The whole
accepted balance becomes cycles for the new canister. There is no change output
or intentional-overpayment refund, and ICP deposited after the fixed transfer
amount is not swept by that operation.
Durable provisioning state
The browser-facing backend API is intentionally small:
status() : ProvisioningStatus
find() : ?Principal
provision(activation_hash : Blob) : Result<Principal, Text>
All three methods use the actual signed caller. find() returns an ID only
after the handoff is complete; status() exposes the durable phase and returns
the child ID as soon as one is known.
An unfunded call creates no durable registration. Once the minimum balance and fee are known, the backend binds the caller to the activation hash, the exact starter revision selected before the first balance await, and the transfer record before sending ICP. A retry with another activation hash is rejected. Only one transient execution may advance a caller at a time.
| Phase | Durable fact |
|---|---|
awaiting_payment | No transfer is prepared. This is also the implicit status for an unknown caller. |
transferring | Amount, fee, creation timestamp, activation hash, and starter revision are fixed. |
notifying_cmc | The successful ledger block index is fixed. |
created | The empty child canister ID is known. |
installed | The captured starter Wasm is installed. |
controlled | Controllers are temporarily Dispenser plus the child itself. |
assets_seeded | Starter assets, runtime config, /pkg/id.json, and publication entropy are ready. |
activated | The hash is armed and the Dispenser is no longer Kernel-authorized. |
complete | The Dispenser is no longer an IC controller. |
Irreversible boundaries and their recovery proofs are persisted before the next phase begins. The controlled asset, runtime-config, identity-record, and entropy writes form one idempotent, replayable seeding group; the durable phase advances after that group completes rather than after every call. Important reconciliation cases include:
- ledger retries reuse
created_at_timeand acceptTxDuplicateby continuing fromduplicate_of; - a CMC
Processingresult retains the block index for a later notification; - a lost install reply is accepted only if the observed module hash equals the captured starter Wasm digest;
- starter asset writes can be replayed from their first chunks;
- arming the same hash by the same controller is idempotent; and
- final controller retirement first inspects
canister_info, so a committedupdate_settingswith a lost reply can still be recognized as complete.
After an error the frontend shows the recorded phase and a Resume action. A reload also attempts to resume a non-complete durable phase automatically.
Starter revisions
The live starter is one immutable, atomically committed value containing the compiled Wasm, runtime-config template, file records and chunks, deployment ID, ordered package IDs, metadata, and compiler-derived fixed backend-call target principals.
The maintenance surface is:
begin_starter_upload
add_starter_wasm_chunk
add_starter_file
add_starter_file_chunk
commit_starter_upload
Every mutation verifies the caller against the Dispenser canister's actual IC
controller list. begin_starter_upload allocates a monotonic epoch, and every
later call must present that epoch. The commit verifies completeness, byte
length, and Wasm SHA-256 before publishing a new monotonic revision. A failed,
rejected, or abandoned upload before commit leaves the previous committed
starter active. If the commit reply is lost, it may already have published the
new revision: query starter() and reconcile its digest, size, deployment ID,
package IDs, and counts before retrying. Do not blindly repeat an identical
commit because each successful commit allocates another revision.
Provisioning never rereads “latest” after it crosses the paid boundary. Its captured revision cannot mix one Wasm with another revision's assets if an operator stages a new starter concurrently. Once assets are seeded, the registration releases its heavy payload reference but retains the revision and remaining handoff state.
starter() is a public read-only postflight for the committed revision,
deployment ID, package IDs, Wasm digest and size, fixed backend-call targets,
and asset counts. Staging changes only future Dispenser-created Neutrons; it
does not update existing ones. See Publishing and starter
updates.
Temporary authority and retirement
The CMC initially creates the child with the Dispenser as controller. Installing the generated Kernel also gives that installer initial Kernel authorization. Those are separate kinds of authority.
After installation the Dispenser sets the controller list to:
[Dispenser, Neutron]
That lets it seed the starter while establishing the self-controller needed for
future in-product upgrades. It then calls kernel_activation(#set(hash)).
The Kernel verifies that the setter is an actual, non-anonymous IC controller,
stores the 32-byte hash only once, and removes the setter from Kernel
authorization in the same message. Finally the Dispenser changes the controller
list to:
[Neutron]
The shared Dispenser service and its completed caller registration continue to exist, but they have no authority over the child. “The Dispenser removes itself” means removal from both the Kernel authorization set and the child controller list.
Activation at the new origin
The final URL carries the raw code only in its fragment:
https://<neutron-id>.icp0.io/#activate=<activation-code>
Before loading Internet Identity, agents, or the React application, the Kernel
bootstrap validates the fragment, copies the code into same-tab
sessionStorage, and removes it from the address bar. Query-string activation
fields are rejected. Storage or address-bar cleanup failure stops startup
without using the bearer.
After Internet Identity login, the Kernel checks the actual caller's current
authorization. If it is unauthorized, the browser removes its pending copy and
submits kernel_activation(#use(code)) once. The backend checks
SHA-256(code), adds the actual non-anonymous caller, clears the stored hash and
setter, and marks the code consumed without an await; authorization and
deletion therefore commit or roll back together.
If the update reply is lost, the frontend queries authorization instead of replaying the bearer. A later different caller cannot use a consumed code.
Repository setup handoff
An optional repository setup reference can accompany activation in the same URL fragment. It contains only the repository principal, manifest ID, and exact manifest digest. The Dispenser captures and removes it into short-lived same-tab state, then appends it to the final Neutron link.
No repository field, package list, or manifest digest enters a Dispenser backend call. It grants no provisioning or ownership authority. The authenticated owner must still consent to contact the provider and review the installation inside Neutron. See Repositories.
Production and PocketIC operation
The backend can be compiled for production or PocketIC with different trusted constructor and runtime inputs:
| Deployment | Child placement | Runtime update-source binding |
|---|---|---|
| Production | One operator-reviewed, constructor-bound IC subnet | null; each package source derives its verified https://<principal>.icp0.io origin |
| PocketIC helper | The Application subnet recorded by the attached supervised PocketIC | The provision-owned local update-source fixture origin |
The user cannot choose a subnet. The CMC request supplies the exact configured subnet principal; there is no fallback or multi-subnet routing policy.
The repository contains local deployment and funding helpers:
npm --workspace dispenser run local:deploy
npm --workspace dispenser run local:fund -- <deposit-account>
:::caution The checked-in local flow is not currently end-to-end runnable
local:deploy is hardwired to local.ndeploy.session.json, whose checked-in
config uses the minimal profile. That profile does not enable the fixed-ID ICP
ledger and CMC which the Dispenser backend calls. The helpers can deploy the
frontend, backend, and starter, but the payment/create path cannot complete
against that current runtime.
In addition, local:fund accepts only the 64-character legacy account
identifier while the UI defaults to ICRC-1 text. Switch the UI to Legacy if
using the helper in a compatible full-protocol fixture environment. Never send
real ICP to a local address.
:::
The local deployment records its two support canisters under
.neutron/dispenser-local.json; it is independent of a normal
neutron-provision fleet reinstall. A working PocketIC qualification requires a
compatible session with the fixed ledger and CMC fixtures, plus the current
Application-subnet binding. The repository does not currently provide that
end-to-end invocation through the hardwired helper.
Production maintenance separates initial backend/frontend deployment, frontend replacement, and starter staging:
npm --workspace dispenser run production:deploy
npm --workspace dispenser run production:frontend:deploy
npm --workspace dispenser run production:starter:set
The full production command verifies the selected identity controls the mapped backend and frontend and refuses to treat an unexpected non-empty backend module as an implicit upgrade. The current backend persistent schema is for a fresh deployment; no legacy backend-state compatibility path is provided.
Operational limits
- There is no backup or export UI for the provisioning private key or activation code.
- There is no registration removal, canister deletion, refund, or ownership transfer API.
- A terminal CMC refund or invalid-transaction result requires manual handling; the consumed payment cannot simply be replayed as a new creation.
- The starter uploader does not prove that its selected package bytes are live on an update source. Maintainers must publish and verify those exact bytes before staging a starter.
- Mainnet payment/create/activate remains a release-level qualification; local and focused tests do not prove the production CMC path.