LPsLux Proposals
Precompiles
LP-11

Onchain Federation Registry

Draft

EVM precompile + smart-contract registry letting apps publish their brand+app identity onchain, with anti-spoofing binding to RFC 8615 `/.well-known/<appId>.json`.

Category
Interface
Created
2026-05-29

Abstract

LP-0011 specifies an onchain federation registry split across two complementary EVM precompiles (ENS-pattern Registry/Resolver):

  • FederationRegistry at canonical address 0x0000000000000000000000000000000000011001 — the resolver. Pure read API (resolve, listByBrand, verifyWellKnown, attestationStatus) plus a write surface that forwards into the store under policy enforcement (commit-reveal, fees, rate-limit, strict-PQ, attestation cadence).
  • BrandConfigStore at canonical address 0x0000000000000000000000000000000000011002 — the storage substrate. Append-only (brandId, appId, nonce) → Record commit log with permissionless writes. Holds no policy logic; the resolver is what decides which records count as authoritative.

Apps publish their (brandId, appId, domain, url, wellKnownHash, owner) tuple via the resolver. Discovery is no longer dependent on a single HTTP authority: any consumer can query the chain for "who claims to be (lux, exchange)" and then verify, byte-for-byte, that the live https://<domain>/.well-known/<appId>.json matches the onchain wellKnownHash. Domain ownership is proven by a 24-hour challenge-response (registrant publishes the registration transaction hash at /.well-known/registration-proof.txt), re-attested every 90 days. The registry is the chain-native successor to LP-0010's HTTP-only federation discovery. Strict-PQ deployments accept ML-DSA-65 (FIPS 204) registration owner keys in addition to secp256k1. The Resolver address (0x...011001) is the stable surface that consumers integrate against; v2/v3 resolver upgrades preserve this address and reuse the same store.

Activation

ParameterValue
Flag stringlp11-onchain-federation-registry
Default in codefalse until activated per network
Deployment branchv0.0.0-lp11
Roll-out criteriaLux primary network (C-Chain mainnet/testnet/devnet)
Back-off planDisable flag; registry becomes read-only via fallback EOA

Motivation

LP-0010 standardised /.well-known/<appId>.json for federated app discovery — a working primitive borrowed from RFC 8615. But HTTP discovery has three structural weaknesses:

  1. No anti-spoofing. A peer aggregator that asks https://hanzo.market/.well-known/market.json trusts whoever's currently serving that hostname. A short-lived DNS hijack, a CDN misconfiguration, or a TLS-terminating proxy can substitute a forged identity record. Consumers have no way to ask "is this document the one the brand owner actually signed off on?".

  2. No discovery without prior knowledge. To find (hanzo, market), you have to know to ask hanzo.market (or guess from a hard-coded peer list). There is no chain-native answer to "list all apps registered under brand hanzo".

  3. No cross-chain composability. Smart contracts cannot read HTTP. Onchain logic that wants to interact with (zoo, bridge) — e.g. an automated market maker that routes liquidity to the canonical bridge — cannot verify that the bridge address it is calling is the one the Zoo team actually deployed.

Onchain registration fixes all three: anti-spoofing via wellKnownHash binding, discovery via getByBrandApp(), and composability because the registry itself is a precompile that any contract can staticcall.

The registry does not replace LP-0010's HTTP layer. The /.well-known/<appId>.json document is still the rich payload (capabilities, chain bindings, peer list, brand metadata). The registry adds a content-addressed pointer to that document, an owner key authorised to update the pointer, and a verifiable chain of custody from brandId/appId to the live HTTP origin.

Specification

1. Canonical addresses

Two precompiles are reserved by LP-0011. They MUST be deployed atomically: a node that activates the lp11-onchain-federation-registry flag MUST have both addresses populated, or bootstrap MUST reject the chain configuration as malformed.

NetworkFederationRegistry (resolver)BrandConfigStore (storage)
Lux mainnet C-Chain (chainId 96369)0x00000000000000000000000000000000000110010x0000000000000000000000000000000000011002
Lux testnet C-Chain (chainId 96368)0x00000000000000000000000000000000000110010x0000000000000000000000000000000000011002
Lux devnet C-Chain (chainId 96370)0x00000000000000000000000000000000000110010x0000000000000000000000000000000000011002

The addresses 0x011001 and 0x011002 encode LP-0011 slots 1 and 2, per the LP-aligned addressing convention used by LP-0120 / LP-4800 (P3Q at 0x012205). L2 EVMs (Hanzo 36963, Zoo 200200, Pars 494949, SPC 36911), per LP-018, reserve the same pair per HIP-0304 / ZIP-0032 and equivalents.

FederationRegistry (0x...011001) is the stable consumer-facing address. Existing LP-0011 v0.1 integrations that hard-coded this address keep working byte-for-byte; the v0.2 amendment is additive at this slot (the read API and the policy-enforced write API both stay; the storage moves out from under the resolver into a sibling precompile).

BrandConfigStore (0x...011002) is the storage substrate. It is permissionless, append-only, and policy-blind. Direct writes to the store are permitted but not authoritative — the resolver decides which records count.

Both precompiles are implemented as stateful EVM precompiles (state held in the EVM trie under each precompile's storage namespace; not in a separate contract slot). The reference Solidity contracts at the same addresses are the canonical ABI: callers MUST be able to substitute either implementation pair without observable behaviour change.

2. Schema

The v0.2 schema is decomposed across two interfaces: a storage substrate (IBrandConfigStore) and a resolver/policy layer (IFederationRegistry). Both MUST be implemented verbatim at their respective canonical addresses.

2.1. Storage model — BrandConfigStore Record

// SPDX-License-Identifier: BSD-3-Clause
pragma solidity ^0.8.26;

/// @title IBrandConfigStore — LP-0011 v0.2 storage substrate
/// @notice Permissionless append-only commit log; precompile at 0x...011002.
///         Holds no policy: any caller may write. The resolver
///         (FederationRegistry at 0x...011001) decides which records
///         are authoritative for a given (brandId, appId).
interface IBrandConfigStore {
    struct Record {
        bytes32 brandId;
        bytes32 appId;
        uint64  nonce;
        uint64  blockNumber;
        uint64  timestamp;
        address writer;
        bytes   payload;
        bytes   signature;
    }

    event RecordWritten(bytes32 indexed brandId, bytes32 indexed appId, uint64 nonce, address indexed writer);
    event RecordRevoked(bytes32 indexed brandId, bytes32 indexed appId, uint64 nonce);

    function write(bytes32 brandId, bytes32 appId, bytes calldata payload, bytes calldata signature) external returns (uint64 nonce);
    function revoke(bytes32 brandId, bytes32 appId, uint64 nonce) external;

    function get(bytes32 brandId, bytes32 appId, uint64 nonce) external view returns (Record memory);
    function latestNonce(bytes32 brandId, bytes32 appId) external view returns (uint64);
    function isRevoked(bytes32 brandId, bytes32 appId, uint64 nonce) external view returns (bool);
}

Semantics:

  • write(brandId, appId, payload, signature) MUST always succeed for a caller that pays gas; the store assigns nonce = latestNonce(brandId, appId) + 1, fills blockNumber/timestamp/writer from the EVM context, and stores payload + signature verbatim. No payload parsing, no signature verification.
  • revoke(brandId, appId, nonce) is restricted to the original writer of that nonce. Revocation flips a flag; the bytes remain readable.
  • get / latestNonce / isRevoked are free read views (no gas if called via eth_call; precompile cost only when invoked from a smart contract).
  • The store MUST bind block.chainid into the canonical message a signature is computed over (see §6 for the exact preimage); the store itself does not check this, but the resolver MUST reject records whose signature was computed for a different chain.

2.2. Resolver model — FederationRegistry (AppRegistration)

// SPDX-License-Identifier: BSD-3-Clause
pragma solidity ^0.8.26;

/// @title IFederationRegistry — LP-0011 v0.2 resolver
/// @notice Pure resolver + policy-bearing write surface. Precompile at
///         0x...011001 (UNCHANGED from v0.1). All persistent storage
///         lives in IBrandConfigStore at 0x...011002; this contract
///         interprets that storage under policy (commit-reveal, fees,
///         rate-limit, strict-PQ, attestation cadence).
interface IFederationRegistry {
    struct AppRegistration {
        bytes32 brandId;
        bytes32 appId;
        string  domain;
        string  url;
        bytes32 wellKnownHash;
        address owner;
        bytes   ownerPubKey;
        uint64  registeredAt;
        uint64  updatedAt;
        uint64  lastAttestedAt;
        bool    revoked;
    }

    // Read plane
    function resolve(bytes32 brandId, bytes32 appId) external view returns (AppRegistration memory);
    function listByBrand(bytes32 brandId) external view returns (AppRegistration[] memory);
    function resolveByDomain(string calldata domain) external view returns (AppRegistration memory);
    function verifyWellKnown(bytes32 brandId, bytes32 appId, bytes32 claimedHash) external view returns (bool);
    function attestationStatus(bytes32 brandId, bytes32 appId) external view returns (bool current, uint64 deadline);

    // Write plane — forwards to BrandConfigStore with policy enforcement
    function commit(bytes32 commitHash) external payable;
    function register(bytes32 brandId, bytes32 appId, string calldata domain, string calldata url,
                      bytes32 wellKnownHash, bytes calldata ownerPubKey, bytes32 salt)
        external payable returns (bytes32 registrationId);
    function update(bytes32 registrationId, string calldata url, bytes32 wellKnownHash) external;
    function attest(bytes32 registrationId, bytes32 domainProofTxHash) external;
    function transferOwnership(bytes32 registrationId, address newOwner) external;
    function revoke(bytes32 registrationId) external;

    function STORE() external view returns (address);  // returns 0x...011002
}

Semantics:

  • STORE() MUST return 0x0000000000000000000000000000000000011002 on every chain that activates LP-0011 v0.2; it is a static identity binding.
  • Write-plane methods (register, update, attest, transferOwnership, revoke) MUST enforce all v0.1 policy (commit-reveal window, registration fee, rate limit, strict-PQ when active, 90-day attestation cadence) and then forward the resulting record to BrandConfigStore.write (or revoke).
  • resolve(brandId, appId) returns the authoritative current AppRegistration for that pair. The resolver MUST:
    1. Read latestNonce(brandId, appId) from the store.
    2. Walk back from latestNonce until it finds the highest-nonce record that (a) is not revoked, (b) has a signature that verifies under the policy active when the record was written, (c) passes the resolver's current policy filters (e.g. strict-PQ requires ML-DSA on the latest record).
    3. Decode that record's payload into AppRegistration and return it.
    4. If no record satisfies the predicate, return a zero AppRegistration (caller MUST treat owner == address(0) as "no registration").
  • Read views are free (STATICCALL-safe, no state mutation, deterministic per block).
  • The v0.1 RegistrationCommit struct and RegistrationCommitted / Registered / Updated / Attested / OwnershipTransferred / Revoked events are retained in the resolver, unchanged in shape, so v0.1 indexer infrastructure keeps working.

2.3. Constants (resolver)

ConstantValueNotes
COMMIT_WINDOW_MIN1 minuteMinimum delay between commit() and register()
COMMIT_WINDOW_MAX24 hoursCommit expiry
ATTESTATION_PERIOD90 daysRe-attestation cadence; lapses go to LAPSED
REGISTRATION_FEE10¹⁶ wei (0.01 LUX)Anti-sybil fee for register(). Refundable within 30d on revoke().
STORE_WRITE_GAS_PER_BYTEnetwork-policy (default 16 gas/byte)Per-byte cost the resolver charges on top of BrandConfigStore.write (see §6/§8)

3. Domain ownership proof

The registry binds registrationId (an onchain identity) to a DNS-rooted HTTP origin. Two proofs are required:

  1. Initial proof (within COMMIT_WINDOW_MAX of registration). Registrant publishes the registration tx hash, hex-encoded with 0x prefix and trailing newline, at:

    https://<domain>/.well-known/registration-proof.txt
    

    A bounty-incentivised watcher network (or any third party) calls attest(registrationId, txHash) once they verify the proof matches. The first attestation transitions registration from PENDING to ACTIVE.

  2. Re-attestation (every ATTESTATION_PERIOD, 90 days). Same mechanism — registrant rotates the published tx hash to the most recent re-attestation tx, watcher network re-checks. Lapsed registrations become LAPSED (queryable but verifyWellKnown returns false) at deadline +24h.

A future LP MAY add a TXT-record proof path (_registration.<domain> TXT containing the registration id) routed through an L1 DNS oracle. v0 of LP-0011 ships only the HTTP path.

4. wellKnownHash canonicalisation

wellKnownHash MUST be keccak256 of the JSON document after:

  1. UTF-8 decoding
  2. Strict JSON parse (RFC 8259)
  3. Re-serialisation in JCS form (RFC 8785) — sorted keys, no insignificant whitespace, \uXXXX escaping for non-ASCII

The canonicalisation step makes the hash stable across servers that pretty-print, change key order, or emit different whitespace. Implementations MUST use the JCS implementation in luxfi/well-known-canonical (Go + TS reference).

5. Strict-PQ profile

When the EVM is in strict-PQ profile (LP-3520 / contract.RefuseUnderStrictPQ), the registry enforces:

  • ownerPubKey MUST be a non-empty ML-DSA-65 public key (LP-4400, 1952 bytes).
  • All owner-side mutating calls (update, transferOwnership, revoke, attest) MUST be authorised by a co-submitted ML-DSA-65 signature over (registrationId, chainId, nonce, methodSelector, args). The signature MUST verify against ownerPubKey via the ML-DSA precompile.
  • secp256k1-only registrations remain queryable but mutating calls revert with STRICT_PQ_REQUIRES_MLDSA.

In non-strict profiles, ownerPubKey MAY be empty and authorisation falls back to msg.sender == owner.

6. Replay protection

Registration messages are bound to:

keccak256("LP-0011/REGISTER\x00" || chainId || brandId || appId || domain || salt || msg.sender)

with chainId from block.chainid. Same registrant deploying the same (brandId, appId, domain) on Lux mainnet, Hanzo L2, and Zoo L2 MUST submit three distinct commit/reveal pairs, each with a fresh salt.

7. Anti-sybil economics

  • REGISTRATION_FEE = 10^16 wei (0.01 LUX) per register(). Funded to the treasury / burn address per network policy.
  • One active registration per (brandId, appId, owner) triple. Re-registration with the same owner replaces the prior entry; ownership remains stable.
  • Rate limit: a single tx.origin MAY commit at most 8 registrations per 24-hour window. Excess commits revert with RATE_LIMITED.

The fee is intentionally small (not a barrier to legitimate brands) but non-zero (prices out drive-by squatters who would otherwise register thousands of (<random-brand>, exchange) tuples).

8. Querying from another contract

import {IFederationRegistry} from "@luxfi/lps/contracts/IFederationRegistry.sol";

contract MarketRouter {
    IFederationRegistry constant REGISTRY =
        IFederationRegistry(0x0000000000000000000000000000000000011001);

    function discoverHanzoMarket() external view returns (string memory url) {
        IFederationRegistry.AppRegistration memory r =
            REGISTRY.resolve(bytes32("hanzo"), bytes32("market"));
        require(r.owner != address(0), "no hanzo.market registered");
        return r.url;
    }
}

Consumers integrate against the resolver address only (0x...011001). The underlying store (0x...011002) is reachable via REGISTRY.STORE() for tooling that wants to walk the raw commit log (indexers, audit pipelines), but application code SHOULD always go through the resolver so the policy filter (strict-PQ, attestation, revocation) is applied.

Rationale

Architecture rationale — Registry/Resolver split

The v0.2 amendment splits the single LP-0011 v0.1 contract surface into two precompiles: a thin storage substrate (BrandConfigStore at 0x...011002) and a policy-bearing resolver (FederationRegistry at 0x...011001). This mirrors the design ENS settled on after several years of production use: the Registry holds the minimal (node → resolver, owner) mapping, and Resolvers are the contracts that actually answer "what's the address for vitalik.eth?". The Registry is intentionally small, frozen, and rarely upgraded; resolvers are pluggable and may be replaced per-name as needs evolve. Our split is the same pattern with the names swapped (the small frozen thing is the Store; the upgradeable interpreter is the Registry / Resolver), but the structural property — storage substrate independent of policy interpretation — is identical.

The split gives the implementation a sharper audit posture. BrandConfigStore is ~80 lines of Solidity: append a record, mark revoked, read by (brandId, appId, nonce). It has no concept of signatures, fees, commit-reveal, strict-PQ, or attestation. A formal-verification audit of the store fits on a single page and need never be re-run when policy evolves. The resolver, by contrast, is where all the security-sensitive logic lives: commit-reveal scheduling, fee accounting, rate-limit bookkeeping, ML-DSA verification, JCS canonicalisation, attestation cadence. The resolver can be replaced (with the same address, via the precompile activation flag mechanism) to add or rotate policy without ever touching the storage substrate.

Read views become free. resolve(brandId, appId) is a pure precompile read against the store's KV namespace — no SSTORE replay, no gas if called via eth_call, deterministic per block. Indexers, peer aggregators, and discovery UIs can pull state in tight loops without paying L1 gas. v0.1's getByBrandApp returned an array of every registration regardless of revocation; v0.2's resolve returns the single authoritative current AppRegistration after the resolver applies its filter, which is what every real consumer wanted in the first place.

Multiple resolvers can coexist on the same store. The v0.2 resolver is the canonical one for the primary network's strict-PQ + commit-reveal + 90-day attestation regime. A v3 resolver could ship later that adds cross-chain attestation (consuming Warp-message proofs from sister L1s), an IPFS-based payload pointer (storing only a CID in payload and resolving the document offchain), or a brand-DAO gating layer (allowlisting brandId claims behind a multisig). All three would live at different addresses, would read the same BrandConfigStore, and would produce different answers from the same underlying records — exactly the way ENS's PublicResolver, OffchainResolver, and custom resolvers coexist today.

Migration from v0.1 is mechanical. v0.1 deployments held all state inline at 0x...011001. v0.2 deployments are atomic: both addresses populated at activation height with a fresh store. Existing chains that already activated v0.1 (none in production at the time of this amendment — the activation flag has not flipped on any production network) MAY use the transitional shim described in §10. New chains skip v0.1 entirely and activate at v0.2. The consumer-facing address (0x...011001) is unchanged, so client libraries that hard-coded the v0.1 address continue to work byte-for-byte against v0.2 — the ABI change from getByBrandApp (array return) to resolve (single struct) is the only call-site update consumers need to make, and the v0.1 method names are retained as deprecated aliases for one further LP revision.

Why a precompile, not just a contract?

A pure Solidity contract would work for L1 use, but a precompile lets us:

  1. Gas-stabilise queries. getByBrandApp over a precompile costs a fixed amount per registration returned (RAM access, not SSTORE replay); a Solidity registry pays SLOAD per registration per query, which scales poorly as brand-app cardinality grows.
  2. Add native ML-DSA verification. Strict-PQ ownership authorisation calls into the LP-4400 ML-DSA precompile internally — cheaper and easier than re-implementing in Solidity assembly.
  3. Survive EVM upgrades. Address-stable across all forks; no proxy admin key, no selfdestruct blast radius.

The reference Solidity contract at the same address is the spec; the precompile is the optimisation. Any node that disables the precompile flag MUST fall back to the contract bytecode at the same address.

Why commit-reveal?

Front-running registrations is the obvious attack: an observer in the mempool sees register(brandId="hanzo", appId="market", ...) from a real Hanzo dev's address, and submits the same tuple with a higher gas price from their own address. Even though they don't control hanzo.market DNS, they win the (brandId, appId, owner) slot until the legit registrant burns more gas to displace them. Commit-reveal closes that hole: the attacker sees commit(hash) but learns nothing about the tuple until register(), by which time the legit registrant already has temporal precedence in the mempool.

COMMIT_WINDOW_MIN = 1 minute makes flash-loan-driven commit-reveal-in-same-block attacks impossible. COMMIT_WINDOW_MAX = 24 hours accommodates DNS propagation delays for fresh .well-known/registration-proof.txt deployments.

Why 90-day re-attestation?

DNS ownership changes. A brand that lapses their domain registration, sells it, or has it seized by a registrar should not retain onchain (brandId, appId) ownership indefinitely. 90 days is the upper bound used by most enterprise TLS rotation policies; we adopt the same cadence. A brand that proactively re-attests every 90 days incurs negligible gas; a lapsed brand's registration silently becomes LAPSED (queryable, but verifyWellKnown returns false), at which point any other party can commit + register the now-vacated slot.

Why JCS canonicalisation?

The wellKnownHash must be reproducible. RFC 8785 (JSON Canonicalization Scheme) is the IETF-blessed canonical form: sorted keys, no whitespace, normalised numeric forms. Without canonicalisation, a CDN that re-serialises the JSON for caching would change the hash and break verification. JCS is small (under 1k LOC in Go) and standardised; we depend on cyberphone/json-canonicalization semantics.

Why ML-DSA-65 in strict-PQ profile, not Falcon or SLH-DSA?

LP-4400 standardises ML-DSA-65 as Lux's primary PQ signature. Reuse over additional choice — the registry inherits the L1's PQ signature primitive rather than introducing a second one. SLH-DSA (LP-4500) and Falcon are available for future LP migrations.

Why is the registry separate from LP-0010, not an extension of it?

LP-0010 ships today and provides 100% of the brand sovereignty + HTTP discovery surface for apps that have no onchain presence. LP-0011 is an opt-in upgrade: an app that wants chain-native discovery + anti-spoofing registers; an app that doesn't, doesn't. Folding LP-0011 into LP-0010 would make it a hard dependency for every white-label fork, including those that ship behind HTTP-only auth and never touch a chain. Decomplecting them keeps each layer independently complete.

Backwards Compatibility

LP-0011 is additive. Apps continue to serve /.well-known/<appId>.json per LP-0010 regardless of onchain registration. Consumers MAY:

  1. Trust HTTP alone (LP-0010 v0 behaviour).
  2. Trust HTTP only when its hash matches an onchain registration (LP-0011 v0 behaviour).
  3. Trust only onchain registrations and ignore HTTP entirely (not recommended — the rich payload still lives in the JSON).

Registries deployed on the Lux primary network do not migrate from any prior scheme; there is no v(-1) — but see "Migration from monolithic v0.1" below for the v0.1 → v0.2 split.

Migration from monolithic v0.1

The v0.1 spec colocated storage and policy at a single precompile address (0x0000000000000000000000000000000000011001). The v0.2 spec splits these into the Store/Resolver pair described above. Three migration scenarios are recognised:

Pre-v0.1 (no chain has activated v0.1)

This is the current state on every Lux primary and L1 / L2 network at the time of this amendment: the activation flag lp11-onchain-federation-registry has not been flipped on any production network. These chains skip v0.1 entirely and activate at v0.2 directly. No migration is necessary; both precompile addresses are reserved from the activation height with the Store empty.

v0.1-active chains (none at amendment time)

A hypothetical chain that has already activated v0.1 — i.e. has a populated monolithic registry at 0x...011001 with live AppRegistration records — performs an atomic v0.2 upgrade at a coordinated activation height:

  1. At the upgrade height, the v0.1 precompile bytecode at 0x...011001 is replaced by the v0.2 resolver bytecode.
  2. The v0.2 BrandConfigStore precompile is activated at 0x...011002, initially empty.
  3. A one-shot migration routine (part of the v0.2 precompile's activation hook) iterates the v0.1 registry's AppRegistration map and replays each non-revoked entry as a BrandConfigStore.write with the v0.1 record's existing signature material. The resolver MUST accept these "migration" records as authoritative for one block (the activation block) without re-checking the signature — they are imported from the same chain's prior state and any signature they bore was previously validated by the v0.1 contract.
  4. After the activation block, all new writes go through the v0.2 resolver's policy-enforced write plane, and the store grows append-only from there.

Transitional shim (v0.1 implementation that hasn't been upgraded yet)

For client code that needs to handle both v0.1 and v0.2 chains during the rollout window, the v0.1 contract MAY expose a STORE() view returning its own address (STORE() == address(this)). The TS/Go client library uses this as a discriminator: STORE() == address(this) ⇒ v0.1 monolithic ⇒ use getByBrandApp array semantics; STORE() == 0x...011002 ⇒ v0.2 split ⇒ use resolve single-struct semantics. The shim's only requirement is the one-line STORE() view; no other v0.1 behaviour need change. The shim is to be removed in LP-0011 v0.3 once all production chains have completed v0.2 atomic deployment.

Test Cases

Reference test vectors live at assets/lp-0011/:

  • registration.json — canonical registration tuple and expected registrationId.
  • well-known-hash.json — three equivalent JSON serialisations + the single JCS-canonicalised wellKnownHash.
  • commit-reveal-flow.json — full commit / reveal traces with salt material.
  • pq-signature.json — ML-DSA-65 signed update() example for strict-PQ.
function testRegisterCommitReveal() public {
    bytes32 salt = bytes32(uint256(0xdead));
    bytes32 commitHash = keccak256(abi.encode(
        bytes32("lux"), bytes32("exchange"),
        "lux.exchange", salt, address(this)));
    REGISTRY.commit{value: 0.01 ether}(commitHash);
    vm.warp(block.timestamp + 70);  // > COMMIT_WINDOW_MIN
    REGISTRY.register{value: 0}(
        bytes32("lux"), bytes32("exchange"),
        "lux.exchange", "https://lux.exchange",
        WELL_KNOWN_HASH, "", salt);
    // v0.2: resolve returns the authoritative current AppRegistration.
    assertEq(REGISTRY.resolve(bytes32("lux"), bytes32("exchange")).owner, address(this));
    // The same record is visible in the underlying BrandConfigStore.
    IBrandConfigStore store = IBrandConfigStore(REGISTRY.STORE());
    assertEq(store.latestNonce(bytes32("lux"), bytes32("exchange")), 1);
}

function testFrontRunRejected() public {
    bytes32 salt = bytes32(uint256(0xbeef));
    bytes32 commitHash = keccak256(abi.encode(
        bytes32("lux"), bytes32("exchange"),
        "lux.exchange", salt, victim));
    vm.prank(victim);
    REGISTRY.commit{value: 0.01 ether}(commitHash);
    vm.warp(block.timestamp + 70);
    vm.prank(attacker);
    // attacker has the salt (from mempool) but msg.sender differs => hash mismatch
    vm.expectRevert("COMMIT_HASH_MISMATCH");
    REGISTRY.register{value: 0}(
        bytes32("lux"), bytes32("exchange"),
        "lux.exchange", "https://attacker.example",
        FAKE_HASH, "", salt);
}

function testDirectStoreWriteIgnoredByResolver() public {
    // Attacker writes a forged record directly to BrandConfigStore.
    IBrandConfigStore store = IBrandConfigStore(REGISTRY.STORE());
    vm.prank(attacker);
    bytes memory forgedPayload = abi.encode(
        bytes32("hanzo"), bytes32("market"),
        "attacker.example", "https://attacker.example",
        FAKE_HASH, attacker, bytes(""));
    store.write(bytes32("hanzo"), bytes32("market"), forgedPayload, bytes("not-a-valid-sig"));
    // The store accepts the bytes...
    assertEq(store.latestNonce(bytes32("hanzo"), bytes32("market")), 1);
    // ...but the resolver filters the unsigned record out.
    assertEq(REGISTRY.resolve(bytes32("hanzo"), bytes32("market")).owner, address(0));
}

Reference Implementation

The Store and Resolver are deployed atomically (same activation height, same activation flag); partial deployment MUST be rejected by node bootstrap.

ComponentAddressPathNotes
BrandConfigStore precompile (Go)0x...011002~/work/lux/node/vms/evm/precompile/contracts/brandconfigstore/ (forthcoming)Storage substrate; no policy
FederationRegistry precompile (Go)0x...011001~/work/lux/node/vms/evm/precompile/contracts/federationregistry/ (forthcoming)Resolver + policy; calls into store
BrandConfigStore Solidity reference0x...011002~/work/lux/standard/contracts/registry/BrandConfigStore.sol (forthcoming)Byte-equal to precompile semantics
FederationRegistry Solidity reference0x...011001~/work/lux/standard/contracts/registry/FederationRegistry.sol (forthcoming)Byte-equal to precompile semantics
TS/Go clientn/a@luxfi/federation-registry (forthcoming)Wraps resolver only; STORE() exposed for indexers
Watcher daemonn/a~/work/lux/federation-watcher (forthcoming, MIT)Calls attest() once /.well-known/registration-proof.txt verifies

Security Considerations

Domain hijack via DNS or TLS compromise

Attack. Attacker seizes <domain> via registrar takeover, DNS rebinding, or TLS-stripping proxy. They serve a forged /.well-known/<appId>.json that fingerprint-matches a different hash.

Mitigation. wellKnownHash is mutable only by the onchain owner key. A DNS hijack alone cannot change the onchain record. Until the legitimate owner publishes an attacker-served document AND signs an update() call from their own key, the chain still points at the old (correct) hash and verifyWellKnown returns false on the forged document. The 90-day re-attestation requirement bounds the window during which a lapsed brand's registration retains validity — at the deadline, an attacker who has held the domain for 90 days can commit + register the slot, but cannot impersonate the prior owner without compromising that key.

Replay attacks across chains

Attack. Attacker observes a register tx on Lux mainnet, replays the same calldata on Hanzo L2 to claim the same (brandId, appId) there.

Mitigation. commitHash and the registration message both bind block.chainid. The same calldata on a different chain produces a different commit hash, so the reveal fails on the second chain. Registrants MUST submit independent commit/reveal flows per chain.

Front-running registration

Attack. Attacker watches the mempool for a register() call and submits the same tuple from their own address with higher gas.

Mitigation. Commit-reveal (above). Commit hash binds msg.sender, so the attacker would need to learn the salt AND submit from the victim's address; without the private key, the salt alone is useless.

Sybil registration

Attack. Attacker registers thousands of (<random-brand>, exchange) tuples to consume slots / inflate getByBrand result sets.

Mitigation. REGISTRATION_FEE (0.01 LUX) per registration + rate limit (8 commits / tx.origin / 24h) + 90-day re-attestation requirement (lapsed sybil registrations naturally clear). The economic cost scales linearly with the squatter's set size; the rate limit caps the burst rate; the re-attestation cadence ensures non-attested registrations don't accumulate indefinitely.

Brand-squatting (legitimate-looking name claim)

Attack. Attacker registers (hanzo, market) before the real Hanzo team does, then ransoms the slot.

Mitigation. v0 has no curated brand list — first valid registration wins. Mitigations:

  1. Brands SHOULD pre-register at launch (well-publicised in LP-0010 and follow-on docs).
  2. A future LP MAY add a brand-allowlist gate (e.g. only addresses authorised by a brand-DAO multisig can register brandId="hanzo"); v0 ships without this to avoid a centralisation point.
  3. Off-chain reputation systems (peer aggregator UIs) can de-prioritise unattested or recently-registered slots.

This is a deliberate trade-off: full decentralisation now, optional gating later via LP follow-up. Brands that anticipate squatting risk SHOULD pre-register on launch day.

Quantum migration

secp256k1 owner keys are vulnerable to Shor's algorithm in a CRQC era. Apps registering today SHOULD provide ownerPubKey (ML-DSA-65) alongside their secp256k1 address. At strict-PQ profile activation, registrations without ML-DSA fall to read-only; mutating calls require the PQ signature. This gives operators a multi-year grace window to migrate keys.

Precompile-vs-contract divergence

The precompile and the reference contract MUST be observationally equivalent. A divergence between the native Go implementation and the Solidity reference would create a chain split. Reference implementation tests MUST run both implementations against the same test vectors and assert byte-equal state transitions.

Storage as a direct-write attack surface (split-specific)

Attack. BrandConfigStore.write is permissionless: any caller with gas can append a record at (any brandId, any appId, latestNonce + 1) with arbitrary payload bytes. An attacker writes thousands of forged records claiming brands they don't own, hoping a naive indexer or downstream consumer treats them as authoritative.

Mitigation. The store is deliberately policy-blind; authority is decided by the resolver, not by the storage substrate. FederationRegistry.resolve(brandId, appId) walks back from latestNonce and skips any record whose signature does not verify under the active policy (secp256k1 in non-strict, ML-DSA-65 in strict-PQ) and whose preimage does not bind the current block.chainid. Forged records therefore never appear in resolver answers. Consumers MUST go through resolve; they MUST NOT consume BrandConfigStore.get output directly without re-applying the resolver's signature predicate. To bound the spam vector itself, the resolver's register / update / attest calls (which are the primary legitimate writers) charge a per-byte fee on top of the v0.1 REGISTRATION_FEE; direct writers to BrandConfigStore still pay EVM intrinsic gas (~16 gas per nonzero calldata byte) which gives a fixed sybil-cost floor without the resolver needing to police the store.

Resolver upgradeability (split-specific)

Attack. An L1 / L2 operator silently swaps the resolver bytecode at 0x...011001 for a permissive variant that classifies forged records as valid, while leaving the store untouched. Consumers querying resolve then get attacker-controlled answers without any visible state migration.

Mitigation. Resolver replacement is a precompile upgrade, which MUST go through the same governance path as any L1 hard fork: a forward-only LP amendment (numbered LP-0011-v0.3 or higher), a fresh activation flag, and a coordinated network upgrade. An L1 / L2 that diverges from the canonical resolver bytecode is forking the spec — and is detectable: any node that runs the canonical bytecode against that chain's state will observe diverging resolve answers and refuse to follow the chain past the divergence height. Resolver rotation is therefore loud, not silent. The store remains untouched across resolver upgrades, so no historical data is lost; only the interpretation of "authoritative" changes, under explicit community review.

Cross-resolver registrations (split-specific)

Attack. Two resolvers (canonical v0.2 and an experimental v0.3) are simultaneously active on the same chain, each pointing at the same BrandConfigStore. They classify the same set of records differently — v0.2 might mark a record valid that v0.3 marks invalid (e.g. because v0.3 adds a new signature-domain field). Consumers that pick the "wrong" resolver get inconsistent answers, and a sophisticated attacker can deliberately write records that fork resolver opinion.

Mitigation. Exactly one canonical resolver per chain. The primary network operator (Lux primary for the main C-Chain; the L1 / L2 validator set for HIP-0304 / ZIP-0032 L2s) enforces this by binding the LP-0011 activation flag to one resolver bytecode hash; running two resolver implementations on the same chain is a misconfiguration that bootstrap MUST reject. Experimental resolvers MAY be deployed at non-canonical addresses for testnet evaluation, but 0x...011001 is the single source of truth on any production chain. Consumers MUST integrate against 0x...011001 and MUST NOT shop between resolver addresses.

Quantum migration (split-specific)

Attack. A pre-quantum (secp256k1-signed) record sits in BrandConfigStore from the pre-strict-PQ era. After strict-PQ activates, the resolver should reject it for authoritative lookups. A naive implementation continues to return the secp256k1 record as authoritative, exposing all consumers to a Shor-class break of the owner's secp256k1 key.

Mitigation. Under strict-PQ profile (LP-3520 / contract.RefuseUnderStrictPQ), the resolver MUST filter records whose signature is not ML-DSA-65 (FIPS 204) over the v0.2 preimage. Pre-quantum records remain readable via BrandConfigStore.get (the store is policy-blind) and via attestationStatus(brandId, appId).current == false, but resolve MUST return a zero AppRegistration until the owner submits a fresh ML-DSA-signed update() whose ownerPubKey matches an LP-4400 public key. The store keeps the historical record as audit trail; the resolver's predicate is what's quantum-aware.

Cross-chain replay (split-specific)

Attack. Attacker observes BrandConfigStore.write(...) from a Lux mainnet tx, replays the same payload + signature bytes against the BrandConfigStore on a sister chain (Hanzo L2, Zoo L2, or vice versa) hoping the signature verifies and the record is accepted as authoritative on the second chain.

Mitigation. BrandConfigStore.write MUST require that the signature be over a preimage containing block.chainid:

keccak256("LP-0011/STORE\x00" || chainId || brandId || appId || nonce || payload)

The store itself does not check this (it stores (payload, signature) verbatim per the policy-blind contract), but the canonical resolver MUST reject any record whose signature does not verify against its chain's chainId. Replays therefore land in the store but never resolve. This mirrors the v0.1 keccak256("LP-0011/REGISTER\x00" || chainId || ...) binding from §6.

Economic Impact

  • Per-registration fee: 0.01 LUX. At 10k brand-app pairs (generous over-estimate for the v0 window), aggregate fee revenue ≈ 100 LUX. Negligible at network scale; a hard floor against sybil.
  • Refund path: registrations revoked within 30 days are eligible for fee refund minus gas (claimable via revoke event). This prevents the fee from being a tax on legitimate experimentation.
  • No staking, no slashing, no economic security assumption at the registry layer — security derives from the L1's consensus, not from registry-internal economics.

Scope decisions (v0.2 final)

The 4 questions listed in v0.1 are resolved as follows. Each rejected alternative would add governance, configuration, or attack surface; the forward-only LP amendment path (see §10) is the escape hatch if any of these decisions later proves wrong.

  1. Brand registration: FIRST-COME-FIRST-SERVED. No brand-DAO allowlist, no brandId-token holder gating. Brands SHOULD pre-register every appId they intend to use, even speculatively, at launch. Rejected alternative: brand-DAO gating introduces a registry of brand ownership separate from the federation registry — a meta-registry with its own governance theatre. FCFS keeps the registry single-layered.

  2. Domain ownership proof: HTTP-only. No TXT-record alternative in v0.2. The .well-known paradigm assumes HTTP; registrants without an HTTP origin should not be in the federation in the first place (an onchain registration with no servable identity is dead weight). Status: explicit scope decision, not deferred. A future LP-0011-v0.3 MAY add TXT-record proof as an alternative path if a real-world registrant requires it.

  3. Pagination: getByBrandPaged(brandId, offset, limit) added in v0.2. The unpaged getByBrand / getByBrandApp view methods retain their current shape for back-compat but resolvers MAY revert with BrandSetTooLarge if the response would exceed MAX_UNPAGED_RESULTS = 256 AppRegistrations; callers MUST fall back to the paged variant. Pagination uses cursor-style offsets (uint256) and a hard MAX_PAGE_SIZE = 100 per call to bound gas/response size.

  4. L2 mirror: out of scope, will never be in scope. Downstream tenant registrations on tenant EVMs are deliberately NOT discoverable from Lux mainnet. Per LP-0010 §7 (tenant isolation rule), cross-chain mirroring would re-introduce the regulatory crossover the spec exists to prevent. Cross-chain consumers that genuinely need to query both Lux mainnet and a tenant EVM MUST do so from outside the registry layer — at the federation HTTP layer (LP-0010 /.well-known/<appId>.json + peers), where each app explicitly opts in to who it federates with.

These are normative for v0.2; forward-only LP amendment per §10 is the only path to change them.

See Also

  • LP-0010 — Brand Sovereignty and Federation Discovery (HTTP /.well-known/ predecessor)
  • LP-0120 — Quasar Mainnet Defaults (LP-aligned precompile addressing convention, P3Q at 0x012205)
  • LP-3520 — Precompile Suite Overview
  • LP-4400 — ML-DSA Post-Quantum Digital Signatures (FIPS 204)
  • LP-4500 — SLH-DSA (FIPS 205)
  • LP-9015 — Precompile Registry (DeFi/core precompile address map)
  • HIP-0304 — Hanzo L2 adoption pointer (records Hanzo chainId, canonical appId table, MCP discovery binding)
  • ZIP-0032 — Zoo L2 adoption pointer (records Zoo chainId, DeSci consumer flow, species/conservation app IDs)
  • IETF RFC 8615 — Well-Known URIs
  • IETF RFC 8785 — JSON Canonicalization Scheme (JCS)
  • IETF RFC 8259 — The JavaScript Object Notation (JSON) Data Interchange Format

Copyright and related rights waived via CC0.