Skip to content

Precompile versioning and deprecation lifecycle #2455

@l0r1s

Description

@l0r1s

Problem Statement

EVM smart contracts on Bittensor interact with the chain through precompiles — fixed-address contracts that expose Substrate storage and extrinsics to Solidity callers. Today, these precompiles have no formal versioning or deprecation mechanism, creating several fragility vectors.

1. Raw Storage Access is Brittle

StorageQueryPrecompile (address 0x807) allows contracts to read arbitrary pallet storage by passing raw SCALE-encoded keys. If a contract hardcodes a storage key like twox_128("SubtensorModule") ++ twox_128("Weights"), any of the following changes silently break it:

  • Renaming a pallet or storage item
  • Changing the storage hasher (e.g., Blake2_128Concat to Twox64Concat)
  • Restructuring a StorageMap into a StorageDoubleMap
  • Changing the value encoding (e.g., adding a field to a stored struct)

The contract reads empty or garbage data with no error, no warning - just silently wrong behavior. This is especially dangerous for contracts that cannot be redeployed (e.g., due to legal or governance constraints).

2. Typed Precompile Views Lack an Evolution Strategy

Typed precompiles (e.g., MetagraphPrecompile, AlphaPrecompile) expose clean Solidity interfaces, but there's no strategy for evolving them:

  • If getStake(uint16, uint16) -> uint64 needs to become getStake(uint16, uint16) -> StakeInfo, the signature change breaks all existing callers.
  • The current workaround (creating entirely new precompile contracts like StakingV2 at a new address) forces contracts to update hardcoded addresses — the exact problem we're trying to avoid.
  • There's no way to signal that a function is deprecated and point callers to a replacement.

3. Incomplete Coverage

Not every storage item and extrinsic has a precompile equivalent. This pushes users toward StorageQueryPrecompile for missing items, compounding problem 1. Ideally, the precompile surface should have 1:1 coverage with the Substrate API — every storage item should have a view function, and every extrinsic should have a callable equivalent.

4. No Compile-Time Safety Net

With 1:1 typed views, changing a storage item in a pallet means the precompile won't compile anymore — the compiler forces you to update the precompile side. With StorageQueryPrecompile, nothing breaks at compile time; the breakage is silent and only visible at runtime.

Design Goals

  1. Contracts at rest don't break. A deployed contract calling getStake(uint16, uint16) at address 0x802 should keep working indefinitely, even if the underlying storage layout changes.
  2. Soft deprecation is the default. Old functions keep working. Hard deprecation (returning an error) is only used when the underlying logic is genuinely gone and cannot be replaced.
  3. Deprecation is discoverable. Deprecated functions should be marked in the Solidity interfaces and queryable by tooling, so developers know before they deploy.
  4. Raw storage access is phased out. StorageQueryPrecompile should be deprecated in favor of typed views that abstract storage layout.
  5. Full Substrate parity. Every storage item and extrinsic should have a precompile equivalent.

Proposed Design

A. Per-Function Versioning (Not Per-Contract)

Instead of creating entirely new precompile contracts for breaking changes (StakingV1 -> StakingV2 -> StakingV3...), version individual functions within the same contract address:

// IMetagraph — lives at 0x802 forever
interface IMetagraph {
    // V1 — returns flat u64
    function getStake(uint16 netuid, uint16 uid) external view returns (uint64);

    // V2 — returns rich struct, added when delegation feature lands
    function getStakeV2(uint16 netuid, uint16 uid) external view returns (StakeInfo memory);
}

Why per-function over per-contract:

  • Solidity dispatches by the 4-byte function selector (keccak256 of the signature), not by contract address. Adding getStakeV2 doesn't affect getStake — both selectors route independently.
  • Avoids proliferating separate precompile contracts and INDEX slots for every version (no StakingV1 at 0x801, StakingV2 at 0x805, StakingV3 at 0x80F...).
  • One precompile per domain (metagraph, staking, etc.) is simpler to maintain, document, and audit.

Note: both approaches preserve backward compatibility equally — old precompiles at old addresses would keep working either way. Per-function versioning is about organizational simplicity, not preventing breakage.

Naming convention: functionName (V1, implicit), functionNameV2, functionNameV3, etc.

B. Deprecation Lifecycle

Each precompile function goes through defined lifecycle states:

State Behavior How it's signaled
Active Normal execution
Soft-deprecated Still executes identically, zero runtime overhead Marked @deprecated in Solidity interface. Queryable via a deprecation registry view.
Hard-deprecated Returns PrecompileFailure::Error Only when the underlying logic is genuinely removed and cannot be replaced.
Disabled Returns precompile-disabled error Already supported via PrecompileEnable in pallet-admin-utils.

Soft deprecation should be the overwhelming default. It covers:

  • Adding a field to a returned struct or tuple (old function returns the original subset, new function returns the full version)
  • Changing how a value is computed internally (old function adapts, signature stays the same)
  • Reorganizing storage layout (precompile implementation changes, Solidity interface stays identical)

Hard deprecation is a last resort. It's only appropriate when:

  • A storage item is completely removed with no replacement
  • The semantics changed so fundamentally that the old return type cannot represent the new reality
  • Keeping the old function working would require maintaining dead storage or fake data

C. Deprecation Registry

Instead of emitting EVM events on every deprecated call (which adds gas cost and is only visible off-chain in transaction receipts — not to the calling contract), a registry precompile exposes deprecation metadata statically:

interface IPrecompileRegistry {
    struct DeprecationInfo {
        bool isDeprecated;
        address newPrecompile;  // replacement precompile address (often the same)
        bytes4 newSelector;     // replacement function selector
        string message;         // human-readable migration guidance
    }

    /// Check if a function at a given precompile is deprecated
    function getDeprecationInfo(
        address precompile,
        bytes4 selector
    ) external view returns (DeprecationInfo memory);
}

This has advantages over EVM events:

  • No runtime overhead on deprecated calls — the function itself runs at zero extra cost.
  • Queryable by tooling — frontends, linters, deployment scripts can check before submitting.
  • Queryable by contracts — a contract can call this view to check if something it depends on is deprecated (useful for upgradeable proxy patterns).

Additionally, Solidity interface files should use @deprecated natspec annotations so developers see warnings at compile time:

interface IMetagraph {
    /// @deprecated Use getStakeV2 instead
    function getStake(uint16 netuid, uint16 uid) external view returns (uint64);

    function getStakeV2(uint16 netuid, uint16 uid) external view returns (StakeInfo memory);
}

D. Replacing StorageQueryPrecompile with Typed Views

Goal: Every storage item in authorized pallets gets a typed precompile view function. The precompile owns the encoding/decoding — if the underlying storage layout changes, the precompile adapts while the Solidity interface stays the same.

Before (fragile):

bytes memory key = abi.encodePacked(
    SUBTENSOR_PREFIX,
    twox128("Weights"),
    blake2_128Concat(netuid),
    blake2_128Concat(uid)
);
(bool success, bytes memory result) = STORAGE_QUERY.staticcall(key);

After (stable):

uint64 weight = IMetagraph(METAGRAPH_ADDRESS).getWeight(netuid, uid);

If we later restructure Weights into a different storage shape, we update the precompile implementation — the Solidity interface stays the same. The contract is insulated from Substrate internals.

Deprecation path for StorageQueryPrecompile:

  1. Achieve typed view coverage for all currently-authorized pallets (SubtensorModule, Balances, Proxy, Scheduler, Drand, Crowdloan, Sudo, Multisig, Timestamp, Swap).
  2. Soft-deprecate StorageQueryPrecompile — mark in registry and Solidity interface.
  3. Hard-deprecate after a migration window.
  4. Eventually disable via PrecompileEnable.

E. Handling Different Change Types

Not all changes are equally breaking. The versioning strategy distinguishes:

Additive (soft-deprecation only):
Adding a field to a returned struct. The old function keeps returning the original subset, a new V2 function returns the full struct. No breakage.

// V1: keeps working, returns (stake, hotkey)
fn get_neuron_lite(netuid: u16, uid: u16) -> (u64, H256) { ... }

// V2: returns (stake, hotkey, delegation) — the new full picture
fn get_neuron_lite_v2(netuid: u16, uid: u16) -> (u64, H256, u64) { ... }

Semantic change (soft-deprecation, precompile adapts):
If getStake used to return total stake and now the chain tracks self-stake and delegated-stake separately, the V1 function can return self_stake + delegated_stake to preserve the old semantics, while V2 returns the breakdown.

Storage restructuring (transparent to callers):
Changing the underlying storage map structure, hasher, or key format. The precompile implementation changes but the Solidity signature stays identical. This is the core value of typed views over raw storage access.

Complete removal (hard-deprecation):
A storage item or extrinsic is removed entirely with no replacement. The precompile function returns PrecompileFailure::Error with a descriptive message. This should be rare.

F. Macro / Tooling (Exploratory)

The boilerplate of maintaining versioned functions, deprecation registry entries, and 1:1 storage mappings may benefit from macro support in the future. Some directions to explore:

  • Version annotations on precompile functions that auto-populate the deprecation registry and generate Solidity @deprecated natspec annotations.
  • Auto-generation of view functions from #[pallet::storage] definitions, reducing the manual surface area.
  • Compile-time checks that every public storage item in an authorized pallet has a corresponding precompile view.

The right abstraction is TBD. The patterns should be established manually first, and macros introduced when the boilerplate patterns become clear and stable.

Open Questions

  1. Coverage scope: Should 1:1 coverage extend to all pallets or only a curated set? Some storage items may not make sense to expose (e.g., internal bookkeeping).

  2. Selector stability: Should we commit to never reusing a function selector once deployed? This prevents a removed function's selector from colliding with a new function.

  3. Registry precompile address: Should the deprecation registry live at a new precompile address, or be a set of view functions added to each existing precompile (e.g., IMetagraph.isDeprecated(bytes4) -> bool)?

Current State for Reference

  • StakingPrecompile (V1, 0x801) and StakingPrecompileV2 (0x805) coexist — both enabled, both callable.
  • No deprecation signal on V1 (just a code comment: "kinda deprecated, but exists for backward compatibility").
  • New version got a new address (per-contract versioning) — this RFC proposes moving to per-function versioning instead.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions