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:

  1. Rotate their PQ signing key without changing address — critical for post-quantum key lifecycle management.
  2. 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_getCode returns an empty result.
  • Both contracts produce standard EVM-style logs (topics + data) that appear in eth_getLogs responses.
  • State is stored in the WorldState trie 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).