Skip to main content

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:

CredentialPurposeFinal authority
Browser-generated Ed25519 identitySelect the caller-derived backend subaccount and sign provisioning callsNone
Raw activation codeOne-time bearer used after login at the new originAuthorizes the authenticated redeemer
Internet Identity principalNormal owner identity at the new Neutron originKernel-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.

PhaseDurable fact
awaiting_paymentNo transfer is prepared. This is also the implicit status for an unknown caller.
transferringAmount, fee, creation timestamp, activation hash, and starter revision are fixed.
notifying_cmcThe successful ledger block index is fixed.
createdThe empty child canister ID is known.
installedThe captured starter Wasm is installed.
controlledControllers are temporarily Dispenser plus the child itself.
assets_seededStarter assets, runtime config, /pkg/id.json, and publication entropy are ready.
activatedThe hash is armed and the Dispenser is no longer Kernel-authorized.
completeThe 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_time and accept TxDuplicate by continuing from duplicate_of;
  • a CMC Processing result 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 committed update_settings with 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:

DeploymentChild placementRuntime update-source binding
ProductionOne operator-reviewed, constructor-bound IC subnetnull; each package source derives its verified https://<principal>.icp0.io origin
PocketIC helperThe Application subnet recorded by the attached supervised PocketICThe 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.