System Contracts
Shell Chain ships two native system contracts. They live at well-known addresses and are executed as native Rust code — no Solidity bytecode, no compiler needed. The PQVM executor intercepts direct native transactions to these addresses before bytecode execution. Contract-originated calls use a different dispatch path; use the read view below for ValidatorRegistry access from Solidity.
Addresses
| Contract | Address | Description |
|---|---|---|
ValidatorRegistry |
0x0000000000000000000000000000000000000000000000000000000000000001 |
Manages the active validator set |
AccountManager |
0x0000000000000000000000000000000000000000000000000000000000000002 |
Per-account PQ key rotation and custom validation code |
ValidatorRegistry
Purpose
Maintains the canonical set of block-producing validators. All writes go through
governance transactions (see shell_proposeAddValidator / shell_proposeRemoveValidator)
to prevent split-brain scenarios.
These RPC methods use the validator node's proposer signer. On non-loopback RPC
listeners they are available only when the node is configured with
--rpc-api-key, and clients must send Authorization: Bearer <key>.
Direct account transactions at algorithm activation
The direct-signature activation upgrade
adds the optional algorithm_activation_admission_height setting in
genesis.json. This is an unreleased source capability. Missing or null preserves
the previous rule: a pending algorithm becomes usable after its activation block
commits. It does not enable a public network automatically.
With this upgrade enabled, direct account transactions may execute in the algorithm's activation block when the persisted proposal is pending, approved by quorum, and mature at that candidate height. Admission still verifies the real signature and account identity; it does not activate the process-wide registry before block commitment. Existing chains must schedule a future upgrade height consistently across nodes; the stored schedule cannot subsequently be changed.
The node regression uses genuine SLH signatures and checks the transfer, receipt, nonce, public key, and matching produced/imported state at the boundary. It seeds a shortened maturity rather than demonstrating the full proposal voting period. Session, paymaster, custom-validation and consensus signatures retain their own rules. See the node compatibility details for the applicable source revision and schedule requirements.
Emergency algorithm governance
Clients containing the emergency governance upgrade can use a genesis-bound SLH-DSA-SHA2-256f fallback key for algorithm-registry governance. This is an opt-in source capability, not an activation announcement for the public testnet or a promise that an older downloadable binary supports it.
A new genesis must bind every validator's separate fallback key to its ML-DSA-65
primary address and explicitly set emergency_governance_height. ML-DSA and
SLH-DSA votes form separate quorums; votes from the two algorithms cannot be
combined to meet the threshold. The fallback key preserves the primary account
identity and cannot authorize ordinary transfers or non-registry governance.
Adding these bindings to an already initialized legacy chain is rejected.
Follow the key-binding and transaction guide for configuration and limitations. From a shell-chain checkout containing that upgrade, this isolated acceptance entry creates fresh keys, executes a real fallback-signed registry transaction, rejects an ordinary transfer, and verifies persisted state after restarting the node:
cargo build -p shell-cli
NODE_BIN=target/debug/shell-node python3 tests/e2e/run-emergency-governance.py
It requires Python 3 and the normal node build dependencies. It retires Dilithium3 on a temporary single-validator chain while ML-DSA consensus remains active; it does not establish a live-network consensus-key retirement procedure.
Native transaction interface
The signatures below describe native transaction selectors. Native account
arguments and returned validator identifiers occupy full 32-byte words; standard
Solidity address values carry only 20 bytes. Do not use this interface as a
Solidity contract binding. The contract-call example below uses bytes32.
AA inner calls to the native Registry additionally require the independent
aa_validator_registry_height activation. See the
native AA execution rules.
interface IValidatorRegistry {
// ── Write (validator-only) ──────────────────────────────────────────────
function addValidator(address validator) external;
function removeValidator(address validator) external;
// ── Read (anyone) ───────────────────────────────────────────────────────
function getValidators() external view returns (address[] memory);
function isValidator(address account) external view returns (bool);
}
Function selectors
| Function | Selector (keccak256) | Access |
|---|---|---|
addValidator(address) |
computed at compile time | validators only |
removeValidator(address) |
computed at compile time | validators only |
getValidators() |
computed at compile time | anyone |
isValidator(address) |
computed at compile time | anyone |
Calling from Solidity
Source and activation requirements: this example requires the
NativeRegistryView implementation
and a network whose configured native_registry_view_height has been reached.
The feature defaults to disabled and is independent of the native AA activation
schedules. A source merge does not establish availability in a released binary
or activation on a public network. Confirm both before using this example.
Existing databases accept only a future activation; the persisted schedule is
immutable. Operators must account for the view address reservation when choosing
genesis allocations or an upgrade schedule.
Call NativeRegistryView at
0x0000000000000000000000000000000000000000000000000000000100000001
(ID 2^32 + 1). Contract calls to low address 0x01 invoke ML-DSA verification,
although direct native Registry transactions use that address. Calling the
cryptographic precompile with Registry calldata can return misleading data;
it does not read the validator set.
The view provides getValidators() and isValidator(bytes32). Pass the complete
32-byte account identifier explicitly. Padding Solidity msg.sender cannot
recover the missing bytes of a native PQ address. Membership reports whether
the supplied identifier is a validator; it does not authenticate the caller.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface INativeRegistryView {
function getValidators() external view returns (bytes32[] memory);
function isValidator(bytes32 account) external view returns (bool);
}
contract ValidatorCheck {
// The native Registry view is separate from PQ verification at address 1.
INativeRegistryView constant REGISTRY = INativeRegistryView(address(uint160(0x100000001)));
function currentValidators() external view returns (bytes32[] memory) {
return REGISTRY.getValidators();
}
function isActiveValidator(bytes32 account) external view returns (bool) {
return REGISTRY.isValidator(account);
}
}
Compile for Cancun (solc 0.8.35 tested). The gateway constant fits the
Solidity call-address operand; validator identities remain bytes32 throughout
the ABI. The view accepts zero-value CALL/STATICCALL and rejects writes, unknown
selectors, malformed calldata, attached value and delegate calls. Reads charge
21,000 gas plus ordinary PQVM call overhead. Each PQVM execution captures the
current bounded validator set; historical execution reads the corresponding
historical state. Native governance writes still use direct transactions or
the separately activated AA native dispatcher.
Reproduce the contract view
The node repository includes the exact
Solidity fixture
and a two-node acceptance script.
Use a checkout containing the implementation above, Node.js 20 or later, a
compatible built source shell-sdk exposing transaction wire format v2
(0.14.0-rc.1 tested), and a local solc package (0.8.35 tested).
A compatible source SDK is a prerequisite for this acceptance run, not evidence
that the same version is available from npm.
From the node repository, set the two paths to your installed dependencies:
cargo build -p shell-cli --features libp2p
SHELL_SDK_ENTRY=/path/to/shell-sdk/dist/index.js \
SOLC_ENTRY=/path/to/solc/index.js \
node tests/e2e/native-registry-view.mjs
The script compiles and deploys the example, checks activation-boundary reads, full-identifier membership, rejection paths and AA calls on an isolated producer and follower. It also checks valid and tampered ML-DSA input at the original precompile, historical replay without state mutation, finality and follower restart catch-up. It stops child processes on exit and retains fresh test keys, node data and results in a private temporary directory. This one-authority test does not establish multi-authority quorum behavior or public network activation.
Events
This format requires a node revision implementing the activation option.
The optional native_validator_events_height enables the indexed event format
below at and after the configured block. It defaults to disabled; existing
schedules can only be added at a future height and cannot subsequently change.
The same schedule must be used by producers, importers and historical replay;
trusted snapshots must match. This upgrade changes receipt logs and blooms,
so a source update alone does not activate it on any network.
After activation, topic 0 is the signature hash and topic 1 is the complete
32-byte native validator identifier; event data is empty. The signature keeps
its native address spelling. Direct transactions and activated native AA calls
use the same encoding. Only a quorum-reaching, committed membership change
emits an event; an accepted pending vote, rejected operation or reverted AA
bundle emits none. Log order follows successful inner-call order.
Before activation, direct calls retain the legacy single-topic event with a
zero-padded, truncated 20-byte member in the data word; AA calls retain their
legacy lack of native membership logs. Historical receipts retain the format
selected by their execution height. Do not query legacy events using topic 1.
The new encoding preserves full native identities for eth_getLogs filters;
standard Solidity address decoders cannot recover a native identity from a
legacy truncated word.
| Event | Signature | Emitted when |
|---|---|---|
ValidatorAdded |
ValidatorAdded(address indexed validator) |
Addition reaches quorum and commits |
ValidatorRemoved |
ValidatorRemoved(address indexed validator) |
Removal reaches quorum and commits |
The existing two-node Registry acceptance
checks the event activation boundary, direct and AA full-address topics, failed
batch rollback, filtered RPC logs, block/receipt agreement, finality, historical
nonmutation and follower restart. Build with
cargo build -p shell-cli --features libp2p, then run:
SHELL_SDK_ENTRY=/path/to/shell-sdk/dist/index.js \
node tests/e2e/native-aa-registry.mjs
Use Node.js 20 or later and a compatible built source SDK (0.14.0-rc.1 tested,
including its viem dependency). The fixture uses isolated nodes and fresh test
keys, stops child processes on exit and retains private test data. Its initially
single-authority producer keeps quorum weight when a candidate is added; it is
not a multi-authority availability test. Public SDK availability and network
activation are separate requirements. See the event format decision.
Access control
Only existing validators can call addValidator / removeValidator. Calls from
non-validators revert with SystemContractError::Unauthorised.
AccountManager
Purpose
Allows accounts to:
- Rotate their PQ signing key without changing address — critical for post-quantum key lifecycle management.
- Set a custom validation contract — enables account abstraction patterns where transaction validation is handled by on-chain code.
Interface
interface IAccountManager {
/// Rotate the caller's PQ public key.
/// pubkey: raw ML-DSA-65 / Dilithium3-legacy or SLH-DSA-SHA2-256f public key bytes
/// algo: implementation-defined PQ signature identifier
function rotateKey(bytes calldata pubkey, uint8 algo) external;
/// Set a custom validator contract for this account.
/// validationCodeHash: keccak256 hash of the deployed validator bytecode.
/// Runtime bytecode with this hash must implement IAccountValidator.
function setValidationCode(bytes32 validationCodeHash) external;
/// Remove the custom validator — revert to default PQ signature check.
function clearValidationCode() external;
}
Function selectors
| Function | Access |
|---|---|
rotateKey(bytes,uint8) |
self only (msg.sender == tx.origin account) |
setValidationCode(bytes32) |
self only |
clearValidationCode() |
self only |
Key rotation example
Use a compatible shell-sdk native calldata encoder and a signed transaction.
The node binary is shell-node; it has no encode-rotate-key,
encode-set-validation-code or encode-clear-validation-code subcommands.
shell_sendTransaction accepts the signed Shell transaction structure, not an
unsigned Ethereum-style { from, to, data, gas } object. The SDK constructs the
signed wire format for you.
Before broadcasting, securely store the replacement key. Load currentSigner
from the account's current keystore and nextSigner from the replacement
keystore, explicitly binding the replacement signer to the existing full
32-byte account address. Deriving a new address from the new public key would
create a different account. Construct the bound signer with
new ShellSigner(loadedReplacement.signatureType, loadedReplacement.adapter, currentSigner.getAddress()),
where loadedReplacement is the decrypted replacement signer and ShellSigner
is imported from shell-sdk. Keep the replacement keystore unchanged and store
the existing account address separately so you can restore this binding on reload.
The two signer objects share the adapter; dispose them only after signing is done.
This example assumes the existing account is funded and uses the built-in PQ
validation policy; an installed custom policy must also authorize the operation.
The following SDK code submits the rotation after provider, currentSigner
and nextSigner have been initialized for the intended network:
import {
accountManagerAddress,
buildTransaction,
encodeRotateKeyCalldata,
} from "shell-sdk";
const account = currentSigner.getAddress();
if (nextSigner.getAddress() !== account) {
throw new Error("Bind the replacement signer to the existing account address");
}
const chainId = Number(BigInt(await provider.client.request({
method: "eth_chainId", params: [],
})));
const nonce = Number(BigInt(await provider.client.request({
method: "eth_getTransactionCount", params: [account, "pending"],
})));
const tx = buildTransaction({
chainId,
nonce,
to: accountManagerAddress,
value: 0n,
data: encodeRotateKeyCalldata(nextSigner.getPublicKey(), nextSigner.algorithmId),
gasLimit: 3000000,
});
const signed = await currentSigner.buildSignedTransaction({
tx,
includePublicKey: true,
});
const hash = await provider.sendTransaction(signed);
Use the current key to authorize rotation. Poll eth_getTransactionReceipt
for hash and require status === "0x1"; submission alone is not success.
Then query shell_getPqPubkey(account) and compare it with the replacement public
key. Only after successful inclusion should subsequent transactions use
nextSigner and a freshly queried nonce. The address stays unchanged. With the
built-in validation policy, the old key is rejected after replacement.
The signed wire format must match the node version. Source SDK 0.14.0-rc.1
was used for this acceptance; availability from npm is a separate prerequisite.
Algorithm binding, key commitments and legacy compatibility depend on the
configured rotation activation. No network upgrade is activated
by running these examples.
Custom validation code
The native setValidationCode(bytes32) method takes the Keccak-256 hash of
already deployed runtime bytecode, not a contract address or creation bytecode
hash. Use a policy that implements the account validation ABI and authorizes its
own update or removal. A permissive test policy is not account authentication.
Use the same signed transaction construction above, replacing data with one of
these SDK encoder results:
import {
encodeSetValidationCodeCalldata,
encodeClearValidationCodeCalldata,
} from "shell-sdk";
const setData = encodeSetValidationCodeCalldata(validationCodeHash);
const clearData = encodeClearValidationCodeCalldata();
Submit each operation separately with the current signer and nonce, wait for a successful receipt, then verify the intended allowed and rejected behavior. Setting a policy does not change the root key; clearing it restores built-in PQ verification using the currently registered key. A policy that rejects its own clear operation cannot be bypassed merely by supplying that calldata. See the account validation guide for the ABI and execution rules.
Reproduce the AccountManager lifecycle
From a node checkout containing the
lifecycle acceptance script, use Node.js 20 or later and a compatible built
source SDK (0.14.0-rc.1 tested):
cargo build -p shell-cli
SHELL_SDK_ENTRY=/path/to/shell-sdk/dist/index.js \
node tests/e2e/native-aa-lifecycle.mjs
This isolated test provisions fresh funded accounts, exercises direct signed rotation, rejects the unsigned request shape, verifies replacement-key success and stale-key rejection before and after restart, and replays historical transactions without changing current state. It also tests native AA guardian recovery and custom policy set/replace/clear, including rollback after a later inner call fails. Its value-filter policies are test-only and do not authenticate users. The test uses explicit dev mining and synthetic timestamps, stops its node on exit, and retains keys, node data and results in a private temporary directory. It does not demonstrate public SDK availability or network activation.
Gas costs
System contract calls use a flat base gas charge:
| Operation | Gas |
|---|---|
addValidator |
SYSTEM_CALL_BASE_GAS + state write |
removeValidator |
SYSTEM_CALL_BASE_GAS + state write |
getValidators |
SYSTEM_CALL_BASE_GAS (21,000) |
isValidator |
SYSTEM_CALL_BASE_GAS (21,000) |
rotateKey |
SYSTEM_CALL_BASE_GAS + pubkey write |
setValidationCode |
SYSTEM_CALL_BASE_GAS + hash write |
clearValidationCode |
SYSTEM_CALL_BASE_GAS + delete |
SYSTEM_CALL_BASE_GAS is a constant defined in shell-pqvm — use
shell_estimateGovernanceGas to get accurate estimates before submitting.
Implementation notes
- System contracts are intercepted by the PQVM executor before PQVM bytecode
execution. There is no bytecode at these addresses —
eth_getCodereturns an empty result. - Both contracts produce standard EVM-style
logs(topics + data) that appear ineth_getLogsresponses. - State is stored in the
WorldStatetrie alongside regular account state — system contract storage is persistent and survives node restarts. - System contracts do not use ABI-encoded reverts. Errors are translated to EVM-style failures (empty returndata, gas consumed).