Run a validator on Scalar
This page takes you from an empty Linux machine to a validator in the Scalar Commons testnet's active set, authoring blocks. Every step was performed from outside — a fresh base path, the public chain spec, the public RPC for every signed transaction, a stash funded by the public faucet — and the example outputs come from that run. Its dated record (transactions, the election it won, the blocks it authored, and what it found) is in docs/history/VALIDATOR-WALKTHROUGH-2026-09.md.
Read this first — validators on this network are unpaid
- Validation pays nothing. Era payout is disabled (
EraPayout = ()→(0, 0)) and transaction fees are burned. There is no validator reward, no commission income and no nominator reward, by design: spec 305 removed staking inflation because it minted outside the emissions pallet and past the supply-cap principle. Whether and how external validators are ever paid is an open decision: #172 (tier:T0). - Slashing arrives with spec 316; the live testnet does not slash yet. The live testnet runs spec 309 (per
landing/runtime-history.jsonand #123), which does not process equivocation reports. Spec 316, merged on main (VERSIONinruntime/src/lib.rs) and awaiting enactment, reports BABE and GRANDPA equivocation (double-signing) to staking; the offender is slashed to Treasury after a 27-era deferral (about 20 days), during which Root can cancel the slash. Genesis validators are exempt:dev_genesismarks them invulnerable (node/src/chain_spec.rs:368), and pallet-staking skips invulnerables when it handles an offence (substrate/frame/staking/src/pallet/impls.rs:1302-1310at polkadot-stable2606-3). There is no offline slashing on this chain — noim-onlinepallet — but run one node per set of session keys, always. - Leaving takes about 21 days.
BondingDurationis 28 staking eras of ~18 hours. - This is a testnet, and it may be reset without notice. Your bond, your keys' registration and your node's database go with it. What to do on a reset.
- CMN on this network has no value. Run a validator to test the software and the network, not to earn.
The numbers, read from the live chain
Set on 2026-09-24 by root (decision D11, issue #159), read back over wss://rpc.scalarnet.io:
| Value | Where | |
|---|---|---|
staking.validatorCount | 7 — five operator validators, two open seats | storage, set at block #750 511 |
staking.minValidatorBond | 1 000 CMN | storage, set at block #750 515 |
staking.minNominatorBond | 100 CMN | storage, set at block #750 515 |
staking.minimumValidatorCount | 1 | storage |
staking.minCommission | 0 % | storage |
| staking era | 6 sessions × 1 800 blocks = 10 800 blocks ≈ 18 h | SessionsPerEra, babe.epochDuration |
| block time | 6 s | babe.expectedBlockTime |
BondingDuration | 28 eras ≈ 21 days | const |
| session keys | babe, grandpa, authority_discovery | SessionKeys |
| election | on-chain sequential Phragmén, once per staking era | ElectionProvider |
| each operator validator's bond | 1 000 000 CMN | staking.ledger |
The election fills up to validatorCount seats from everyone who has declared intent. With seven seats and five operator validators, the first two outside validators with at least 1 000 CMN bonded are elected — bond size only matters once there are more candidates than seats, and then the operators' 1 000 000 CMN bonds win.
What you need in CMN: the 1 000 CMN bond, plus the existential deposit (0.01 CMN), plus about 0.000108 CMN per transaction for bond, setKeys, validate and later chill, unbond and withdrawUnbonded — 1 000.011 CMN in all. The bond is a staking hold: it moves out of free into reserved (after bonding, the stash read free 99.999676, reserved 1 000, frozen 0), and fees can only come from free, so do not bond everything you have. One faucet drip (1 100 CMN) covers it with about 100 CMN to spare.
1. The machine
Measured on the operator's own validator alice (which is also the archive node behind the public RPC, so it is the heavy case), and on the outside validator built for this page:
| Measured | Recommendation | |
|---|---|---|
| CPU | alice: 1.7 % of one core averaged over 15 days (AMD EPYC 9645, 16 cores); outside node sync peaked at ~1 000 blocks/s | 2 cores |
| RAM | alice: 3.2 GiB resident (peak RSS 3.2 GiB; 7.1 GiB cgroup peak including page cache) | 4 GiB minimum, 8 GiB comfortable |
| Disk | alice, archive: 7.8 GB at #750 000 (du -sh of its base path). Outside validator, default pruning: 2.6 GB after full sync | 50 GB SSD, grows with the chain |
| Sync | 19 min from genesis to #750 909 over the public bootnodes | — |
| OS | Linux x86_64, glibc ≥ 2.35 for the release binary (Ubuntu 22.04+, Debian 12+) | — |
Ports
| Port | Bind | Reachable from | Why |
|---|---|---|---|
| p2p (30333 by default; 30338 on this page) | 0.0.0.0 | the internet, TCP inbound | peers and the other validators dial you here |
| RPC (9944 by default; 9949 on this page) | 127.0.0.1 only | this machine only | session-key rotation and your own monitoring |
| Prometheus (9615 by default; 9620 on this page) | 127.0.0.1 | this machine only | metrics |
The node binds RPC and Prometheus to loopback unless you pass --rpc-external or --prometheus-external. Do not. Open only the p2p port in your firewall.
2. Get the node
Primary: the release binary
Every v* tag builds scalar-node on the pinned toolchain in CI and publishes it with a checksum (.github/workflows/release.yml). The current release is v0.306.0, for runtime spec 306.
mkdir -p ~/scalar && cd ~/scalar
curl -sSLO https://github.com/tejaspatil1936/scalar-commons-v4/releases/download/v0.306.0/scalar-node-linux-x86_64
curl -sSLO https://github.com/tejaspatil1936/scalar-commons-v4/releases/download/v0.306.0/scalar-node-linux-x86_64.sha256
sha256sum -c scalar-node-linux-x86_64.sha256
chmod +x scalar-node-linux-x86_64 && mv scalar-node-linux-x86_64 scalar-node
./scalar-node --version7f6efa029e3f94f8f100f2d9f9797d865789e532dc753bf853f317877bfa455d scalar-node-linux-x86_64
scalar-node-linux-x86_64: OK
scalar-node-linux-x86_64 4.0.0-206936fdd49Do not run it if sha256sum -c does not print OK. The binary is 53.7 MB, built on Ubuntu 22.04; the newest glibc symbol it needs is GLIBC_2.34. The validator on this page synced from genesis on a source build (next section), then was switched to this exact binary with a restart — same base path, same keys — and carried on from #751 343.
Fallback: build from source
The toolchain pin is load-bearing: the workspace is on Polkadot SDK tag polkadot-stable2606-3, and the repository pins the toolchain that SDK line is built and tested with — rust 1.93.0 with the wasm32v1-none target — in rust-toolchain.toml. rustup picks the pin up automatically inside the checkout.
sudo apt-get install -y build-essential clang protobuf-compiler git curl # Debian/Ubuntu
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain none
. "$HOME/.cargo/env"
git clone https://github.com/ScalarCommons/Scalar-CommonsV5
cd Scalar-CommonsV5
rustc --version # rustup installs 1.93.0 + wasm32v1-none from rust-toolchain.toml
cargo build --release # builds the WASM runtime and target/release/scalar-node
./target/release/scalar-node --versionVerified in a clean directory with a fresh CARGO_HOME (so every crate, including the whole Polkadot SDK, was fetched and compiled from scratch):
real 16m33.213s (-j 8 on an AMD EPYC 9645)
EXIT 0
scalar-node 4.0.0-37d0e4d2da7
target/ 5.0G cargo-home/ 2.1GBudget ~8 GB of disk and 15–60 minutes depending on cores.
3. Chain spec and node key
mkdir -p ~/scalar && cd ~/scalar
curl -s -o chainspec.json -w 'http=%{http_code} ct=%{content_type} bytes=%{size_download}\n' \
https://scalarnet.io/docs/chainspec.jsonhttp=200 ct=application/json bytes=2356581Check the content type, not the status: the docs site answers 200 with an HTML page for any path, so text/html means you did not get the spec. The spec carries the five public bootnodes, so no --bootnodes flag is needed:
/ip4/152.53.113.104/tcp/30333/p2p/12D3KooWEyoppNCUx8Yx66oV9fJnriXwCcXwDDUA2kj6vnc6iDEp
/ip4/152.53.113.104/tcp/30334/p2p/12D3KooWHdiAxVd8uMQR1hGWXccidmfCwLqcMpGwR6QcTP6QRMuD
/ip4/152.53.113.104/tcp/30335/p2p/12D3KooWSCufgHzV4fCwRijfH2k3abrpAJxTKxEvN1FDuRXA2U9x
/ip4/152.53.113.104/tcp/30336/p2p/12D3KooWSsChzF81YDUKpe9Uk5AHV5oqAaXAcWNSPYgoLauUk4st
/ip4/152.53.113.104/tcp/30337/p2p/12D3KooWSuTq6MG9gPt7qZqLFKkYrfxMewTZhj9nmRHJkPwzWDG2Generate the node's network key before the first start. A node started with --validator refuses to create one itself — this is what the first start printed here, in a loop:
Error: NetworkKeyNotFound("/home/dev/outsider-validator.SQkD/base/chains/scalar-local/network/secret_ed25519")./scalar-node key generate-node-key --chain ./chainspec.json --base-path ~/scalar/base
chmod 700 ~/scalar/base
chmod 600 ~/scalar/base/chains/scalar-local/network/secret_ed25519Generating key in "/home/dev/outsider-validator.SQkD/base/chains/scalar-local/network/secret_ed25519"
12D3KooWRq7TcUUgp9ZzgpLxYNks1o5KA4k3sg6z9t9Y2Y97nJE6The key is created mode 664 — readable by every user on the machine — hence the chmod. The last line is your node's peer id.
4. Run it under systemd
deploy/validator/ in the repository holds a systemd user unit and an env file. No root is needed; loginctl enable-linger keeps it running after you log out.
install -D -m 644 deploy/validator/scalar-validator.service ~/.config/systemd/user/scalar-validator.service
install -D -m 600 deploy/validator/validator.env.example ~/.config/scalar-validator/validator.env
$EDITOR ~/.config/scalar-validator/validator.env
systemctl --user daemon-reload
systemctl --user enable --now scalar-validator
loginctl enable-linger "$USER"
journalctl --user -u scalar-validator -fThe env file used for this page (paths are this run's temp directory; NODE_NAME is the operator's choice):
NODE_BIN=/home/dev/outsider-validator.SQkD/scalar-node
CHAIN_SPEC=/home/dev/outsider-validator.SQkD/chainspec.json
BASE_PATH=/home/dev/outsider-validator.SQkD/base
NODE_NAME=outsider-guide-test
P2P_PORT=30338
RPC_PORT=9949
RPC_METHODS=safe
PROMETHEUS_PORT=9620
EXTRA_ARGS=--no-telemetrywhich the unit turns into:
scalar-node --chain chainspec.json --base-path base --name outsider-guide-test --validator \
--port 30338 --rpc-port 9949 --rpc-methods safe --prometheus-port 9620 --no-mdns --no-telemetry🏷 Node name: outsider-guide-test
👤 Role: AUTHORITY
🔨 Initializing Genesis block/state (state: 0xe20e…abd1, header-hash: 0xff68…03d1)
Using default protocol ID "sup" because none is configured in the chain specs
🏷 Local node identity is: 12D3KooWRq7TcUUgp9ZzgpLxYNks1o5KA4k3sg6z9t9Y2Y97nJE6
Running JSON-RPC server: addr=127.0.0.1:9949,[::1]:9949
〽️ Prometheus exporter started at 127.0.0.1:9620
👶 Starting BABE Authorship worker
⚙️ Syncing, target=#750722 (5 peers), best: #5120 (0x3e52…6333), finalized #4608 (0xd372…74c6)
…
💤 Idle (5 peers), best: #750909 (0x785f…c21b), finalized #750907 (0xe9ae…7577)Five peers within five seconds, and fully synced 19 minutes later. What is listening:
0.0.0.0:30338 [::]:30338 scalar-node <- p2p, the only public port
127.0.0.1:9949 [::1]:9949 scalar-node <- RPC, loopback only
127.0.0.1:9620 scalar-node <- Prometheus, loopback onlygenesis must match the public chain — 0xff6882b4…f803d1 above is the same hash chain_getBlockHash(0) returns on wss://rpc.scalarnet.io.
5. Session keys — on your own node, never the public RPC
Session keys are generated inside a node's keystore. The public endpoint refuses, by design — author_rotateKeysWithOwner (or author_rotateKeys) there would put keys in the operator's keystore, useless to you. Both are in the unsafe set and get the same refusal:
curl -s -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"author_rotateKeys","params":[]}' https://rpc.scalarnet.io{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"RPC call is unsafe to be called externally"}}Your own node refuses too, while RPC_METHODS=safe — safe withholds unsafe methods even on loopback. So flip it for the one call, then flip it back. While it is flipped, nothing may forward to port 9949: an SSH tunnel or a reverse proxy arrives at the node as loopback too, so unsafe would hand author_insertKey to whoever is on the far end.
From spec 313 on, session.setKeys checks a proof that the keys were generated for the account submitting it, so the call takes one parameter, owner: the stash that will send setKeys in §6, as its raw 32-byte account id in hex (0x + 64 hex chars). Create the stash first (§6) if you have not, then derive the hex from its address:
node -e "const{decodeAddress}=require('@polkadot/util-crypto');const{u8aToHex}=require('@polkadot/util');console.log(u8aToHex(decodeAddress(process.argv[1])))" <stash address>STASH_HEX=0x… # the 0x + 64 hex chars printed above
sed -i 's/^RPC_METHODS=safe$/RPC_METHODS=unsafe/' ~/.config/scalar-validator/validator.env
systemctl --user restart scalar-validator
curl -s -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"author_rotateKeysWithOwner","params":["'"$STASH_HEX"'"]}' \
http://127.0.0.1:9949
sed -i 's/^RPC_METHODS=unsafe$/RPC_METHODS=safe/' ~/.config/scalar-validator/validator.env
systemctl --user restart scalar-validatorThe result is an object, {"keys":"0x…","proof":"0x…"}. Keep both: §6 submits them together.
This walkthrough ran on spec 306, before the ownership proof, so it called author_rotateKeys with no params, which returns the keys alone:
{"jsonrpc":"2.0","id":1,"result":"0x6a4fcc900adfe988b6ec188fdd9c31dd75c93fa011a7f918c5cb791395212a52645ae4c5545609f461c0b5b0e7f2e159e9237d4df6543f987c940be275b9fdbe9482e4cc5da3e3730fdc5d32edab932207205014da1106c3f2c3b73e5a54c82e"}96 bytes: the babe, grandpa and authority_discovery public keys, in that order — the same layout as keys from author_rotateKeysWithOwner. The private halves are now three 0600 files in base/chains/scalar-local/keystore/. After the flip back, confirm the window is closed with a read-only unsafe call — not the rotate call, which would write a second key set if the flip did not take:
curl -s -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"author_hasSessionKeys","params":["0x<keys>"]}' \
http://127.0.0.1:9949It must answer -32601. Anything else means the node is still unsafe: fix RPC_METHODS and restart before going on.
Runtimes before spec 313
Until spec 313 is enacted, use author_rotateKeys (no params) as above and submit setKeys(keys, '0x') — an empty proof. Keys registered that way stay valid after the upgrade; only new registrations and rotations need the proof.
6. Stash, bond, keys, intent
The stash is the account that bonds CMN and owns the validator on chain. Generate it somewhere other than the validator machine if you can — the node only ever needs the session keys — and fund it from the faucet:
// stash.mjs (npm install @polkadot/api@15.10.2)
import { Keyring } from '@polkadot/keyring';
import { cryptoWaitReady, mnemonicGenerate } from '@polkadot/util-crypto';
import { writeFileSync } from 'node:fs';
await cryptoWaitReady();
const mnemonic = mnemonicGenerate(24);
writeFileSync('stash.seed', mnemonic + '\n', { mode: 0o600 });
console.log(new Keyring({ type: 'sr25519', ss58Format: 42 }).addFromUri(mnemonic).address);curl -s -X POST https://faucet.scalarnet.io/drip -H 'content-type: application/json' \
-d '{"address":"5FADXQgabx5SaZg43YwZs3Q3XMfMUENvW49gW1Vjx1xZjmpe"}'{"ok":true,"address":"5FADXQgabx5SaZg43YwZs3Q3XMfMUENvW49gW1Vjx1xZjmpe","amountPlancks":"1100000000000000",
"blockHash":"0x852cfde6efa4988715a1ff719faab93f225c2a5df725c97b8d9b19cc03f184a8",
"txHash":"0x0d52c9015c212544ed9a6b5045deefde0f17da373ee44ed4e4666c7b9c8ea19e"}Then three transactions, all signed by the stash and sent over the public RPC — nothing here touches your node:
// stake.mjs (excerpt)
const api = await ApiPromise.create({ provider: new WsProvider('wss://rpc.scalarnet.io') });
await api.tx.staking.bond(1000n * 10n ** 12n, { Stash: null }).signAndSend(stash); // 1 000 CMN
await api.tx.session.setKeys(rotated.keys, rotated.proof).signAndSend(stash); // from step 5
await api.tx.staking.validate({ commission: 50_000_000, blocked: false }).signAndSend(stash); // 5 %rotated is the { keys, proof } object from §5. The proof is bound to the owner you passed, so setKeys must be signed by that same stash; a different signer, or '0x' as the proof, fails with session.InvalidProof on spec 313 and later.
commission is a Perbill: 10_000_000 = 1 %. On this network it is a statement of intent only — there is nothing to take a commission from. Payee Stash is the honest choice for the same reason.
Each of the three should end in system.ExtrinsicSuccess (bond also emits staking.Bonded, validate emits staking.ValidatorPrefsSet). Then read your state back over the public RPC: staking.ledger(stash) shows the bond as active, staking.validators(stash) your prefs, and session.nextKeys(stash) must be exactly the keys from §5, split into babe, grandpa and authorityDiscovery. You are not in session.validators yet, as it should be: declaring intent does not put you in the set; an election does. Bond, set keys and validate back to back — there is no reason to wait between them.
7. Wait for the election, and prove it
Intent is not membership. The set is chosen by an on-chain Phragmén election at the start of the last session of each staking era, and the winners take their seats one session later, when the next era begins. So after validate you wait for one election and one session: between ~3 and ~21 hours, depending on where in the ~18-hour era you declare.
Three things to watch for, over the public RPC:
- Elected — after the election,
staking.erasStakersOverview(era, stash)atstaking.currentErareturns an exposure for your stash. It reads slightly under your bond (999.999999999712 CMN for a 1 000 CMN bond): stake is converted to au64vote weight for the election, and the conversion rounds. - Active — from the first block of the next era, your stash is in
session.validators. - Authoring — read each block's BABE pre-digest at the finalized hash: its
authorityIndexpoints into the session's validator list, and that entry must be your stash. Your node's log shows the other side:Prepared block for proposing at <n>andPre-sealed block for proposal at <n>.
An occasional proposed block that loses the fork race is ordinary BABE behaviour when a secondary slot and a primary slot collide, not a fault. Blocks run slightly slower than the nominal 6 s, so watch session.currentIndex, not the clock.
The explorer serves every block, e.g. https://explorer.scalarnet.io/block/766726, but its block page does not show the author; use the pre-digest check above, or api.derive.chain.getHeader(hash).author.
Operating it
Monitoring
journalctl --user -u scalar-validator -f # 💤 Idle (N peers), best/finalized
curl -s -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"system_health","params":[]}' http://127.0.0.1:9949
curl -s http://127.0.0.1:9620/metrics | grep -E '^substrate_block_height|^substrate_sub_libp2p_peers_count'and from anywhere, over the public RPC: is your stash in session.validators? Is best − finalized staying within a few blocks? Do blocks' pre-digests name your stash as author (§7)? A validator that is elected but not authoring is either not synced, has lost its keystore, or has keys that do not match session.nextKeys(stash).
Upgrades
Runtime upgrades are forkless and need nothing from you. The runtime is WASM stored on chain; a set_code upgrade switches every node at the same block, without a restart. Spec 306 was applied that way at block #543 472 with all five validators running and none restarted (docs/history/UPGRADE-306.md). Node (client) upgrades — a new scalar-node binary — are only needed when a release says so; they are published as GitHub Releases with a checksum. Swap the binary, sha256sum -c, restart.
Leaving
await api.tx.staking.chill().signAndSend(stash); // stop validating; out of the set at the next era
await api.tx.staking.unbond(1000n * 10n ** 12n).signAndSend(stash);
// … BondingDuration = 28 eras ≈ 21 days …
await api.tx.staking.withdrawUnbonded(0).signAndSend(stash);Keep the node running until chill has taken effect — session.validators no longer lists your stash — or you are an elected validator that is not producing blocks. Then stop the service and delete the base path.
If the testnet is reset
A reset is a new genesis. Your stash's balance, bond and session-key registration are gone with the old chain; your node's database belongs to a chain that no longer exists.
systemctl --user stop scalar-validator- Download the new
chainspec.jsonand check its genesis againstchain_getBlockHash(0)onwss://rpc.scalarnet.io. - Delete
base/chains/scalar-local/dbandkeystore(new chain, new keys — do not carry session keys across), keep or regenerate the network key. - Start, sync, and redo steps 5–7: rotate keys, drip, bond, set keys, validate.
Security
- Never expose RPC. Not your own port, and never 9944–9948 on a host that also runs the operator's nodes. Unsafe methods (
author_rotateKeysWithOwner,author_rotateKeys,author_insertKey) on a reachable RPC hand your keystore to anyone who can connect. KeepRPC_METHODS=safeexcept for the one-off rotation, and never pass--rpc-externalor--unsafe-rpc-external. - File permissions.
chmod 700the base path; the network key is created 664 — make it 600; the keystore files are created 600; the env file 600. - One node per session key set. Two nodes with the same keys double-sign, and from spec 316 equivocation is slashable for every validator outside the genesis set (see the warning at the top of this page).
- The stash seed never lives on the validator if you can avoid it, and is never reused — not as an agent key, not for another validator, not on another network. The node needs only the session keys; the stash signs four transactions in the validator's lifetime.
- p2p is the only public port. Everything else stays on loopback.
What the walkthrough found
The run behind this page worked end to end from outside. The places where it worked only because the walkthrough already knew something, or could not be fully verified, are listed in docs/history/VALIDATOR-WALKTHROUGH-2026-09.md. The ones that change what you do are already in the steps above: the node key is created mode 664 (§3), rotation needs a brief switch to unsafe (§5), the bond is a hold, not a lock (the cost section), and the explorer does not show block authors (§7). One more: open your p2p port inbound (§1) — the recorded run could not verify inbound reachability, and with only outbound connections you depend on peers you dial.