feat(dex/registry): embed per-network asset manifests + localnet native builder

Make the per-network asset manifests (mainnet/testnet/devnet) available to a node
binary with no filesystem dependency, so the EVM plugin can build the 0x9999
value-path AssetResolver from the CI-approved, content-hashed asset set compiled
into the binary. Selection is by EVM chainID (96369/96368/96370). Localnet (1337),
whose C-Chain id is environment-specific, gets a runtime-rooted native-only manifest
(LocalnetNativeManifest) synthesised from the node's live cChainID — never a constant.

- embedded.go: //go:embed the three manifests; EmbeddedManifestFor(evmChainID) +
  LocalnetNativeManifest(networkID, cChainID).
- manifest.go: split decodeManifestBytes out of loadManifestBytes so the embed path
  and the disk path share one decoder (shape-validate + content-hash + unknown-field
  rejection identical for both).
This commit is contained in:
zeekay
2026-06-22 20:56:23 -07:00
parent 40fa820c00
commit c77bc9c925
19 changed files with 3048 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package registry is the DEX's real-assets-only enforcement layer: the single,
// orthogonal place that decides which assets and which markets are admissible.
//
// It exists to make ONE property structural rather than incidental: every asset
// the DEX ledger can ever credit or debit corresponds to a REAL object on-chain
// (an ERC-20 contract on the C-Chain, the C-Chain native coin, or a UTXO asset on
// the X-Chain). There is no "D-native asset" class, no synthetic asset, no
// ASCII-ticker identity, and no declared-but-unbacked credit. An asset's identity
// is a canonical hash of WHERE IT REALLY LIVES — so two parties, given the same
// chain, derive the same 32-byte AssetID, and a fabricated asset has no preimage.
//
// This package is deliberately independent of the matcher (which lives in-process
// in the C-Chain settlement precompile) and of the proxy's settle path. It is a
// pure identity + admission primitive: derive an AssetID, register a real asset,
// gate a market on two registered assets. Verification against live chain state is
// injected (ChainVerifier) so the same logic backs both the offline CI manifest
// validator and the node's fail-closed startup gate.
package registry
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"strings"
"github.com/luxfi/ids"
)
// AssetKind is the CLOSED set of asset classes the DEX admits. There are exactly
// three. There is NO synthetic / D-native / declared class — adding one is a
// breaking change to the wire identity and is intentionally hard.
type AssetKind uint8
const (
// AssetKindInvalid is the zero value and is never admissible. Keeping it at 0
// means a zero-initialised struct fails closed.
AssetKindInvalid AssetKind = 0
// AssetKindEVMNative is the C-Chain native coin (e.g. LUX on the primary
// network's C-Chain). Its on-chain reference is the fixed native marker.
AssetKindEVMNative AssetKind = 1
// AssetKindERC20 is an ERC-20 token deployed on the C-Chain. Its on-chain
// reference is the 20-byte token contract address.
AssetKindERC20 AssetKind = 2
// AssetKindUTXO is a UTXO-model asset native to the X-Chain (or another
// UTXO source chain). Its on-chain reference is the source-chain assetID.
AssetKindUTXO AssetKind = 3
)
// String renders the kind as its canonical wire/JSON token. These exact strings
// appear in manifests and in the dexAllowedAssetKinds policy; they are part of the
// contract, not cosmetic.
func (k AssetKind) String() string {
switch k {
case AssetKindEVMNative:
return "EVM_NATIVE"
case AssetKindERC20:
return "ERC20"
case AssetKindUTXO:
return "UTXO"
default:
return "INVALID"
}
}
// Valid reports whether k is one of the three admissible kinds.
func (k AssetKind) Valid() bool {
return k == AssetKindEVMNative || k == AssetKindERC20 || k == AssetKindUTXO
}
// MarshalText/UnmarshalText make AssetKind round-trip through JSON as its canonical
// token (EVM_NATIVE / ERC20 / UTXO), never as a bare integer. An unknown token —
// including any ASCII-ticker masquerading as a kind — fails closed.
func (k AssetKind) MarshalText() ([]byte, error) {
if !k.Valid() {
return nil, fmt.Errorf("registry: refuse to marshal invalid asset kind %d", uint8(k))
}
return []byte(k.String()), nil
}
func (k *AssetKind) UnmarshalText(b []byte) error {
switch strings.TrimSpace(string(b)) {
case "EVM_NATIVE":
*k = AssetKindEVMNative
case "ERC20":
*k = AssetKindERC20
case "UTXO":
*k = AssetKindUTXO
default:
return fmt.Errorf("registry: unknown asset kind %q (only EVM_NATIVE, ERC20, UTXO)", string(b))
}
return nil
}
// ParseAssetKind is the imperative form of UnmarshalText for callers that hold a
// plain string (manifest loaders, the allowed-kinds policy parser).
func ParseAssetKind(s string) (AssetKind, error) {
var k AssetKind
if err := k.UnmarshalText([]byte(s)); err != nil {
return AssetKindInvalid, err
}
return k, nil
}
// EVMNativeMarker is the fixed on-chain reference for the C-Chain native coin.
// EVM_NATIVE has no contract address, so its canonical reference is this constant
// 20-byte marker (the EVM zero address — the EVM's own sentinel for "the native
// coin"). Folding a fixed, kind-tagged marker means every party derives the same
// EVM_NATIVE AssetID for a given (networkID, C-chainID) and nobody can invent a
// second native asset.
var EVMNativeMarker = make([]byte, 20) // 20 zero bytes == EVM address(0)
var (
// ErrInvalidKind is returned when an asset's kind is not one of the three.
ErrInvalidKind = errors.New("registry: asset kind is not EVM_NATIVE, ERC20 or UTXO")
// ErrBadRef is returned when an asset's canonical reference does not match the
// shape its kind requires (e.g. a 19-byte ERC-20 address).
ErrBadRef = errors.New("registry: canonical reference does not match asset kind")
// ErrEmptyChainID is returned when the source chain of an asset is the empty id.
ErrEmptyChainID = errors.New("registry: asset source chain id is empty")
)
// Domain-separation tags. Folded as the FIRST field of every AssetID preimage so
// the three kinds occupy disjoint hash spaces: an ERC-20 whose 20-byte address
// numerically equals the low bytes of a UTXO assetID can never collide, because
// the tag byte differs and the length-prefixed fold forbids field-boundary
// aliasing. Versioned so a future migration can re-domain without ambiguity.
var (
domAssetV1 = []byte("lux:dex:asset:v1")
domMarketV1 = []byte("lux:dex:market:v1")
)
// canonicalRefFor validates and returns the canonical on-chain reference bytes for
// a (kind, ref) pair. This is the SINGLE place that decides what a well-formed
// reference looks like per kind — the derivation and the registry both call it, so
// the shape rule lives in exactly one spot.
//
// - EVM_NATIVE: ref must equal EVMNativeMarker (20 zero bytes). The native coin
// has no address; we pin the marker so no caller can smuggle a non-native ref
// into the native kind.
// - ERC20: ref must be a 20-byte token contract address, and not the zero
// address (the zero address is the native marker, never a token).
// - UTXO: ref must be a 32-byte source-chain assetID.
func canonicalRefFor(kind AssetKind, ref []byte) ([]byte, error) {
switch kind {
case AssetKindEVMNative:
if len(ref) != 20 {
return nil, fmt.Errorf("%w: EVM_NATIVE marker must be 20 bytes, got %d", ErrBadRef, len(ref))
}
if !allZero(ref) {
return nil, fmt.Errorf("%w: EVM_NATIVE reference must be the native marker (address zero)", ErrBadRef)
}
// Normalise to the canonical marker so callers can't pass a distinct
// all-zero-but-differently-typed slice.
return append([]byte(nil), EVMNativeMarker...), nil
case AssetKindERC20:
if len(ref) != 20 {
return nil, fmt.Errorf("%w: ERC20 token address must be 20 bytes, got %d", ErrBadRef, len(ref))
}
if allZero(ref) {
return nil, fmt.Errorf("%w: ERC20 token address must not be the zero address", ErrBadRef)
}
return append([]byte(nil), ref...), nil
case AssetKindUTXO:
if len(ref) != 32 {
return nil, fmt.Errorf("%w: UTXO assetID must be 32 bytes, got %d", ErrBadRef, len(ref))
}
if allZero(ref) {
return nil, fmt.Errorf("%w: UTXO assetID must not be empty", ErrBadRef)
}
return append([]byte(nil), ref...), nil
default:
return nil, ErrInvalidKind
}
}
// DeriveAssetID computes the canonical, consensus-native 32-byte identity of a real
// on-chain asset. The identity is a length-prefixed SHA-256 fold over, in order:
//
// domAssetV1 | networkID | sourceChainID | kind | canonicalRef
//
// matching the per-kind formulas exactly:
//
// ERC20: H(networkID, C-chainID, ERC20, token-address)
// EVM_NATIVE: H(networkID, C-chainID, EVM_NATIVE, native-marker)
// UTXO: H(networkID, X-chain-id, UTXO, assetID)
//
// sourceChainID is the C-Chain id for EVM_NATIVE/ERC20 and the UTXO source chain id
// (X-Chain) for UTXO. The fold is length-prefixed (each field's length precedes its
// bytes) so no two distinct field tuples share a preimage, and the kind byte
// domain-separates the three classes. The result is an ids.ID — the SAME identity
// space the on-chain atomic objects already use (AtomicInput.Asset,
// AtomicOutput.Asset, RelayOrderTx.AssetOut) — so a registered AssetID is directly
// comparable to the asset a real cross-chain object carries. It is NEVER a string
// ticker.
func DeriveAssetID(networkID uint32, sourceChainID ids.ID, kind AssetKind, ref []byte) (ids.ID, error) {
if sourceChainID == ids.Empty {
return ids.Empty, ErrEmptyChainID
}
cref, err := canonicalRefFor(kind, ref)
if err != nil {
return ids.Empty, err
}
var f folder
f.tag(domAssetV1)
f.u32(networkID)
f.bytes(sourceChainID[:])
f.u8(uint8(kind))
f.bytes(cref)
return f.sum(), nil
}
// MarketID computes the canonical identity of a market from its two asset
// identities and the venue configuration:
//
// marketID = H(networkID, baseAssetID, quoteAssetID, venueConfig)
//
// baseAssetID and quoteAssetID are themselves canonical AssetIDs (so a market is
// pinned to real assets by construction — you cannot name a market over a synthetic
// asset because there is no AssetID for one). venueConfig is the canonical bytes of
// the venue parameters (tick size, lot size, fee tier, etc.) that distinguish two
// venues on the same pair; callers pass its canonical serialization.
func MarketID(networkID uint32, baseAssetID, quoteAssetID ids.ID, venueConfig []byte) ids.ID {
var f folder
f.tag(domMarketV1)
f.u32(networkID)
f.bytes(baseAssetID[:])
f.bytes(quoteAssetID[:])
f.bytes(venueConfig)
return f.sum()
}
// folder accumulates a length-prefixed, domain-separated preimage and folds it with
// SHA-256 — the same primitive and the same length-prefixed discipline as the
// existing consensus state hash in dexvm/state/state.go (crypto/sha256, each field
// length-prefixed). The length prefix on every field is what makes the fold
// injective: (networkID=1, ref=0x02) and (networkID=0x0102, ref=) cannot produce the
// same byte stream.
type folder struct {
buf []byte
}
func (f *folder) raw(b []byte) {
var lp [8]byte
binary.BigEndian.PutUint64(lp[:], uint64(len(b)))
f.buf = append(f.buf, lp[:]...)
f.buf = append(f.buf, b...)
}
// tag folds a domain-separation constant. Same encoding as a field; named for intent.
func (f *folder) tag(b []byte) { f.raw(b) }
// bytes folds a variable-length field.
func (f *folder) bytes(b []byte) { f.raw(b) }
// u8 folds a single-byte field (the kind tag).
func (f *folder) u8(v uint8) { f.raw([]byte{v}) }
// u32 folds a 32-bit field (networkID) in big-endian.
func (f *folder) u32(v uint32) {
var b [4]byte
binary.BigEndian.PutUint32(b[:], v)
f.raw(b[:])
}
func (f *folder) sum() ids.ID {
return ids.ID(sha256.Sum256(f.buf))
}
func allZero(b []byte) bool {
for _, x := range b {
if x != 0 {
return false
}
}
return true
}
+148
View File
@@ -0,0 +1,148 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"errors"
"fmt"
)
// ConsensusMode is the CLOSED set of consensus postures under which the DEX may
// activate NATIVE VALUE (real-money trading). There are exactly two legal value
// modes plus the zero value; the guard is exhaustive and any unmodelled value falls
// through to refusal. There is NEVER a silent third state.
type ConsensusMode uint8
const (
// ConsensusModeUnset is the zero value: no value mode declared. Value activation
// under it is always refused (fail-closed).
ConsensusModeUnset ConsensusMode = 0
// ConsensusModeQuorumFinality is post-quantum BFT with quorum finality (the
// consensus round-2 work). It provides Byzantine fault tolerance, so a value
// activation under it may legitimately claim Byzantine-finality safety.
ConsensusModeQuorumFinality ConsensusMode = 1
// ConsensusModeHonestValidatorLaunch is a DELIBERATE, LABELED crash-fault-tolerant
// (CFT) parity mode: the validator set is assumed honest-but-crash-prone, NOT
// Byzantine. It is a legitimate launch posture, but it MUST NOT be presented as
// Byzantine-finality. Activating value under it is permitted ONLY when it asserts
// the launch safety bundle (caps-on + real-assets-only + halt-ready) and surfaces
// an explicit "no Byzantine-finality claim" status string. It is the only other
// legal value mode.
ConsensusModeHonestValidatorLaunch ConsensusMode = 2
)
// String renders the mode as its canonical token.
func (m ConsensusMode) String() string {
switch m {
case ConsensusModeQuorumFinality:
return "QUORUM_FINALITY"
case ConsensusModeHonestValidatorLaunch:
return "HONEST_VALIDATOR_LAUNCH"
default:
return "UNSET"
}
}
// ParseConsensusMode parses the canonical token. An unknown token is refused (it
// must not silently become a third state).
func ParseConsensusMode(s string) (ConsensusMode, error) {
switch s {
case "QUORUM_FINALITY":
return ConsensusModeQuorumFinality, nil
case "HONEST_VALIDATOR_LAUNCH":
return ConsensusModeHonestValidatorLaunch, nil
case "", "UNSET":
return ConsensusModeUnset, nil
default:
return ConsensusModeUnset, fmt.Errorf("registry: unknown consensus mode %q (only QUORUM_FINALITY or HONEST_VALIDATOR_LAUNCH)", s)
}
}
// LaunchAssertions is the safety bundle a HONEST_VALIDATOR_LAUNCH activation MUST
// satisfy. It is the explicit, auditable record that the CFT-parity launch is
// running with the compensating controls that justify it. Every field MUST be true
// for value to activate under that mode; a false field is a refusal.
type LaunchAssertions struct {
// CapsOn asserts per-asset / per-market notional caps are enforced (bounding the
// blast radius of a crash-fault or operator error during the CFT launch window).
CapsOn bool
// RealAssetsOnly asserts the asset registry admits only EVM_NATIVE|ERC20|UTXO and
// no synthetic asset/market/liquidity is enabled (the property this whole package
// enforces).
RealAssetsOnly bool
// HaltReady asserts the halt control is wired and reachable, so the chain can be
// stopped fast if the honest-validator assumption is violated.
HaltReady bool
}
func (a LaunchAssertions) ok() bool {
return a.CapsOn && a.RealAssetsOnly && a.HaltReady
}
// NoByzantineFinalityClaim is the EXACT status string a HONEST_VALIDATOR_LAUNCH
// activation surfaces. It is a constant so the UI/status surface and any audit
// tooling can match it byte-for-byte; the launch posture must never be silently
// presented as Byzantine-final.
const NoByzantineFinalityClaim = "DEX value active under HONEST_VALIDATOR_LAUNCH (CFT parity): no Byzantine-finality claim"
var (
// ErrValueModeUnset is returned when value activation is requested with no legal
// value mode declared.
ErrValueModeUnset = errors.New("registry: refuse DEX value activation — consensus mode is UNSET (no Byzantine-finality and no labeled CFT-parity declared)")
// ErrValueModeIllegal is returned for any consensus mode that is not one of the
// two legal value modes.
ErrValueModeIllegal = errors.New("registry: refuse DEX value activation — consensus mode is not QUORUM_FINALITY or HONEST_VALIDATOR_LAUNCH")
// ErrLaunchAssertionsUnmet is returned when HONEST_VALIDATOR_LAUNCH is requested
// without the full caps-on + real-assets-only + halt-ready bundle.
ErrLaunchAssertionsUnmet = errors.New("registry: refuse HONEST_VALIDATOR_LAUNCH value activation — caps-on + real-assets-only + halt-ready not all asserted")
)
// ValueModeStatus is the outcome of a successful value-activation guard check: the
// mode that authorised it and, for the labeled CFT-parity mode, the explicit
// "no Byzantine-finality claim" status string to surface. For QUORUM_FINALITY the
// status is empty (Byzantine finality is genuine, so no disclaimer is required).
type ValueModeStatus struct {
Mode ConsensusMode
Status string // NoByzantineFinalityClaim for HONEST_VALIDATOR_LAUNCH, "" for QUORUM_FINALITY
}
// GuardValueActivation is THE consensus-mode value guard. It decides whether the DEX
// may activate native value under the given consensus mode, and returns the status
// the activation must surface.
//
// Semantics (exactly as required):
//
// if !dexNativeValueEnabled -> no value to gate; returns the unset status, nil.
// if mode == QUORUM_FINALITY -> permitted (PQ BFT); status "".
// if mode == HONEST_VALIDATOR_LAUNCH -> permitted ONLY if assertions.ok();
// status = NoByzantineFinalityClaim.
// otherwise (UNSET or any other value) -> REFUSED. Never a silent third state.
//
// The default arm refuses, so an unmodelled or zero mode can never accidentally
// authorise value.
func GuardValueActivation(dexNativeValueEnabled bool, mode ConsensusMode, assertions LaunchAssertions) (ValueModeStatus, error) {
if !dexNativeValueEnabled {
// No native value requested — nothing to authorise. This is not an error;
// the DEX runs in non-value (paper) mode.
return ValueModeStatus{Mode: mode, Status: ""}, nil
}
switch mode {
case ConsensusModeQuorumFinality:
return ValueModeStatus{Mode: mode, Status: ""}, nil
case ConsensusModeHonestValidatorLaunch:
if !assertions.ok() {
return ValueModeStatus{}, fmt.Errorf("%w (capsOn=%t realAssetsOnly=%t haltReady=%t)",
ErrLaunchAssertionsUnmet, assertions.CapsOn, assertions.RealAssetsOnly, assertions.HaltReady)
}
return ValueModeStatus{Mode: mode, Status: NoByzantineFinalityClaim}, nil
case ConsensusModeUnset:
return ValueModeStatus{}, ErrValueModeUnset
default:
// Closed enum: any value outside the two legal modes is refused. There is
// never a silent third state.
return ValueModeStatus{}, fmt.Errorf("%w: %d", ErrValueModeIllegal, uint8(mode))
}
}
+126
View File
@@ -0,0 +1,126 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"embed"
"fmt"
"github.com/luxfi/ids"
)
// embedded.go makes the per-network asset manifests available to a NODE BINARY with
// no filesystem dependency. The manifests are the SINGLE source of truth for which
// real assets the DEX admits on each network; embedding them means a validator carries
// the exact, CI-approved asset set in its binary and never has to locate a file on
// disk at boot. This is the registry's trust-root data: the value-path resolver the EVM
// plugin installs is built from exactly this.
//
// Selection is by the C-Chain's EVM chainID (eth_chainId), the unambiguous per-network
// identity the directive pins:
//
// 96369 -> mainnet (networkID 1)
// 96368 -> testnet (networkID 2)
// 96370 -> devnet (networkID 3)
// 1337 -> localnet (networkID 1337) — see LocalnetNativeManifest below
//
// The three value/dev networks ship a committed manifest (mainnet/testnet/devnet);
// localnet's C-Chain id is environment-specific (it differs per genesis), so localnet
// has no committed manifest — its native-only manifest is synthesised at boot from the
// node's LIVE runtime C-Chain id (LocalnetNativeManifest), never from a constant.
//go:embed manifests/assets.mainnet.json manifests/assets.testnet.json manifests/assets.devnet.json
var manifestFS embed.FS
// EVM chainIDs of the three networks that ship a committed manifest. These are the
// eth_chainId values, NOT the consensus networkIDs (1/2/3). Localnet (1337) is handled
// separately because its C-Chain id is not fixed.
const (
MainnetEVMChainID uint64 = 96369
TestnetEVMChainID uint64 = 96368
DevnetEVMChainID uint64 = 96370
LocalnetEVMChainID uint64 = 1337
)
// embeddedManifestPath maps an EVM chainID to its embedded manifest path. A chainID
// with no committed manifest is absent (ok=false) — the caller then either synthesises
// the localnet native manifest or fails closed (no resolver installed).
func embeddedManifestPath(evmChainID uint64) (string, bool) {
switch evmChainID {
case MainnetEVMChainID:
return "manifests/assets.mainnet.json", true
case TestnetEVMChainID:
return "manifests/assets.testnet.json", true
case DevnetEVMChainID:
return "manifests/assets.devnet.json", true
default:
return "", false
}
}
// ErrNoEmbeddedManifest is returned when no committed manifest exists for an EVM
// chainID (e.g. localnet 1337, or an unknown sovereign chainID). The caller decides
// whether to synthesise a native-only manifest (localnet) or stay fail-closed.
var ErrNoEmbeddedManifest = fmt.Errorf("registry: no embedded asset manifest for this EVM chainID")
// EmbeddedManifestFor loads, content-hashes, and shape-validates the committed manifest
// for an EVM chainID, returning the parsed manifest and the manifest bytes' SHA-256
// (lowercase hex). It performs the SAME shape validation LoadManifest does. It is the
// node-side entry point that replaces a filesystem LoadManifest: the bytes are the ones
// compiled into the binary, so there is no on-disk file to tamper with.
//
// A chainID with no committed manifest returns ErrNoEmbeddedManifest (the localnet /
// unknown-chain case the caller handles explicitly).
func EmbeddedManifestFor(evmChainID uint64) (*Manifest, string, error) {
path, ok := embeddedManifestPath(evmChainID)
if !ok {
return nil, "", fmt.Errorf("%w (evmChainID=%d)", ErrNoEmbeddedManifest, evmChainID)
}
raw, err := manifestFS.ReadFile(path)
if err != nil {
// An embed path that does not resolve is a build-time bug, surfaced loudly.
return nil, "", fmt.Errorf("registry: embedded manifest %s unreadable: %w", path, err)
}
return decodeManifestBytes(raw, path)
}
// LocalnetNativeManifest synthesises the localnet (chainID 1337) manifest IN MEMORY,
// containing ONLY the C-Chain native coin, rooted at the node's LIVE runtime ids
// (networkID, cChainID). It exists because localnet's C-Chain consensus id is not fixed
// across genesis runs, so no committed manifest can pin it — but the native coin is
// ALWAYS a real, known asset on any localnet C-Chain (it is the chain's own coin), so
// admitting exactly it (and nothing else) keeps localnet swaps live out-of-the-box while
// every ERC-20 must still be registered explicitly (its address is unknown until it is
// deployed). The cChainID comes from the runtime, NEVER a constant — so the resulting
// resolver is identity-bound exactly like the committed-manifest path.
//
// networkID must be the localnet convention id (1337); cChainID must be the node's live
// C-Chain consensus id (non-empty). The returned manifest passes validateShape.
func LocalnetNativeManifest(networkID uint32, cChainID ids.ID) (*Manifest, error) {
if cChainID == ids.Empty {
return nil, fmt.Errorf("registry: localnet native manifest requires a non-empty live C-Chain id")
}
m := &Manifest{
Network: "localnet",
NetworkID: networkID,
EVMChainID: LocalnetEVMChainID,
CChainID: cChainID,
Assets: []Asset{{
NetworkID: networkID,
ChainID: cChainID,
Kind: AssetKindEVMNative,
CanonicalRef: append(Bytes(nil), EVMNativeMarker...),
Decimals: 18,
Symbol: "LUX",
Name: "Lux",
Enabled: true,
RiskTier: RiskTier0,
}},
Markets: nil,
}
if err := m.validateShape(); err != nil {
return nil, fmt.Errorf("registry: synthesised localnet native manifest invalid: %w", err)
}
return m, nil
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"errors"
"github.com/luxfi/ids"
)
// fakeChain is a REAL ChainVerifier backed by an in-memory snapshot of what exists
// on-chain. It is not a stub: it returns "real" only for entries that were
// explicitly seeded, and an error otherwise — so the registry's rejection paths
// (TestNoSyntheticAssetCanRegister, etc.) exercise genuine refusal, not a verifier
// rigged to always succeed. This is the in-test analogue of the production
// JSON-RPC / local-chain-state verifier.
type fakeChain struct {
// erc20[networkID][cChainID][20-byte-addr-hex] = decimals
erc20 map[uint32]map[ids.ID]map[string]uint8
// native[networkID][cChainID] = decimals (presence == this is the real C-Chain)
native map[uint32]map[ids.ID]uint8
// utxo[networkID][sourceChainID][assetID] = decimals
utxo map[uint32]map[ids.ID]map[ids.ID]uint8
}
func newFakeChain() *fakeChain {
return &fakeChain{
erc20: map[uint32]map[ids.ID]map[string]uint8{},
native: map[uint32]map[ids.ID]uint8{},
utxo: map[uint32]map[ids.ID]map[ids.ID]uint8{},
}
}
// seedERC20 records a real ERC-20 deployment at addr on (networkID, cChainID).
func (f *fakeChain) seedERC20(networkID uint32, cChainID ids.ID, addr []byte, decimals uint8) {
if f.erc20[networkID] == nil {
f.erc20[networkID] = map[ids.ID]map[string]uint8{}
}
if f.erc20[networkID][cChainID] == nil {
f.erc20[networkID][cChainID] = map[string]uint8{}
}
f.erc20[networkID][cChainID][string(addr)] = decimals
}
// seedNative records that cChainID is the real C-Chain for networkID.
func (f *fakeChain) seedNative(networkID uint32, cChainID ids.ID, decimals uint8) {
if f.native[networkID] == nil {
f.native[networkID] = map[ids.ID]uint8{}
}
f.native[networkID][cChainID] = decimals
}
// seedUTXO records a real UTXO asset on (networkID, sourceChainID).
func (f *fakeChain) seedUTXO(networkID uint32, sourceChainID, assetID ids.ID, decimals uint8) {
if f.utxo[networkID] == nil {
f.utxo[networkID] = map[ids.ID]map[ids.ID]uint8{}
}
if f.utxo[networkID][sourceChainID] == nil {
f.utxo[networkID][sourceChainID] = map[ids.ID]uint8{}
}
f.utxo[networkID][sourceChainID][assetID] = decimals
}
var errNotOnChain = errors.New("fakechain: no such object on this network/chain")
func (f *fakeChain) VerifyERC20(networkID uint32, cChainID ids.ID, addr []byte) (uint8, error) {
d, ok := f.erc20[networkID][cChainID][string(addr)]
if !ok {
return 0, errNotOnChain
}
return d, nil
}
func (f *fakeChain) VerifyEVMNative(networkID uint32, cChainID ids.ID) (uint8, error) {
d, ok := f.native[networkID][cChainID]
if !ok {
return 0, errNotOnChain
}
return d, nil
}
func (f *fakeChain) VerifyUTXOAsset(networkID uint32, sourceChainID, assetID ids.ID) (uint8, error) {
d, ok := f.utxo[networkID][sourceChainID][assetID]
if !ok {
return 0, errNotOnChain
}
return d, nil
}
// addr20 builds a deterministic non-zero 20-byte token address from a seed byte.
func addr20(seed byte) []byte {
b := make([]byte, 20)
for i := range b {
b[i] = seed + byte(i) + 1 // +1 keeps it non-zero even for seed 0
}
return b
}
// idBytes returns the 32-byte slice of an ids.ID. The parameter is a copy (and thus
// addressable), so this works on a function-return id where id[:] directly would not.
func idBytes(id ids.ID) []byte { return id[:] }
+160
View File
@@ -0,0 +1,160 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"encoding/hex"
"fmt"
"strings"
)
// This file is the deny-list half of admission: a set of cheap, total predicates
// that recognise the SHAPES of the anti-patterns the DEX must never carry, so the
// startup gate can refuse them by name. Reality (ChainVerifier) is the positive
// gate; these are the negative gate — they catch a reference that is structurally a
// synthetic/phantom/branded artifact even before (or instead of) a chain lookup.
// forbiddenChainTokens are substrings that, if present in a chain reference label,
// mark it as a white-label / off-network universe the Lux DEX must never quote. The
// canonical case is UniverseChainId.OffNetwork* — the off-network white-label brand,
// which is structurally distinct from Lux and must never appear in a Lux asset or
// market. We match on the label rather than a typed constant because the Lux tree
// deliberately has NO Liquidity type to import; the gate's job is to reject any
// attempt to smuggle one in by string.
var forbiddenChainTokens = []string{
"liquidity", // UniverseChainId.OffNetwork* and any off-network-branded reference
"offnet evm",
"offnet dex",
}
// mockLiquidityTokens mark a synthetic/mock liquidity source — fabricated depth that
// does not correspond to real on-chain reserves. Any asset/market label carrying one
// is refused: the DEX quotes real reserves only.
var mockLiquidityTokens = []string{
"mock",
"synthetic",
"fake",
"phantom",
"placeholder",
"testliquidity",
"mockliquidity",
"d-native", // the explicitly-forbidden "D-native asset" class
"dnative",
}
// IsForbiddenChainRef reports whether a chain reference label names a forbidden
// (white-label / off-network) universe such as an off-network universe. Case-insensitive.
func IsForbiddenChainRef(label string) bool {
l := strings.ToLower(label)
for _, t := range forbiddenChainTokens {
if strings.Contains(l, t) {
return true
}
}
return false
}
// IsMockLiquidityRef reports whether a label names mock/synthetic/phantom liquidity.
// Case-insensitive.
func IsMockLiquidityRef(label string) bool {
l := strings.ToLower(label)
for _, t := range mockLiquidityTokens {
if strings.Contains(l, t) {
return true
}
}
return false
}
// looksLikeASCIITickerID reports whether a string is being used as an asset IDENTITY
// rather than a display symbol. An AssetID is a 32-byte hash; a bare ticker like
// "LUX" or "BTC-USD" used where an id is expected is the anti-pattern. We flag a
// short all-uppercase-with-separators token because that is the classic
// ticker-as-id shape. This guards the SYMBOL field (which is display-only) from
// being relied on as identity, and the manifest validator from accepting a ticker in
// place of a real reference.
//
// It deliberately does NOT reject normal symbols outright — a symbol may BE "LUX".
// It rejects the specific case where such a token appears where a canonical id /
// reference is required (see manifest validation), and it is also used by Asset to
// keep the symbol field from masquerading as an id when the symbol literally encodes
// a pair-id like "LUX/USDC" or "LUX-USDC@venue".
func looksLikeASCIITickerID(s string) bool {
if s == "" {
return false
}
// A pair-shaped symbol (contains a market separator) is an id masquerading as a
// label — a real per-asset symbol never names two assets.
for _, sep := range []string{"/", "-", ":", "@", "_"} {
if strings.Contains(s, sep) {
// allow a single hyphen inside a normal name token only if the result
// is not all-caps ticker shaped; pair separators on an ALL-CAPS token
// are the giveaway.
if isUpperTickerish(s) {
return true
}
}
}
return false
}
// isUpperTickerish reports whether s is composed only of A-Z, 0-9 and market
// separators (the alphabet of a ticker / pair id), with at least one letter. A
// human Name like "USD Coin" has a space and lowercase, so it is not tickerish; a
// pair id like "LUX/USDC" is.
func isUpperTickerish(s string) bool {
hasLetter := false
for _, r := range s {
switch {
case r >= 'A' && r <= 'Z':
hasLetter = true
case r >= '0' && r <= '9':
case r == '/' || r == '-' || r == ':' || r == '@' || r == '_':
default:
return false
}
}
return hasLetter
}
// AssertNoForbiddenAssetRefs is the per-asset deny-gate: it refuses an asset whose
// chain label, symbol, or name carries a forbidden (Liquidity), mock/synthetic, or
// ticker-as-id reference. chainLabel is the human label of the asset's source chain
// (supplied by the manifest / startup context); it is checked because the
// forbidden-universe case (Liquidity) is a CHAIN property, not an asset-field one.
func AssertNoForbiddenAssetRefs(a Asset, chainLabel string) error {
if IsForbiddenChainRef(chainLabel) {
return fmt.Errorf("registry: asset references forbidden universe chain %q (white-label/off-network, e.g. Liquidity)", chainLabel)
}
if IsForbiddenChainRef(a.Symbol) || IsForbiddenChainRef(a.Name) {
return fmt.Errorf("registry: asset symbol/name references forbidden universe (Liquidity)")
}
if IsMockLiquidityRef(a.Symbol) || IsMockLiquidityRef(a.Name) {
return fmt.Errorf("registry: asset symbol/name names mock/synthetic/phantom liquidity")
}
if looksLikeASCIITickerID(a.Symbol) {
return fmt.Errorf("registry: asset symbol %q is an ASCII-ticker id; assets are keyed by AssetID", a.Symbol)
}
return nil
}
// --- hex helpers (canonicalRef / venueConfig JSON encoding) -----------------
func toHex(b []byte) string {
return "0x" + hex.EncodeToString(b)
}
func fromHex(s string) ([]byte, error) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "0x")
s = strings.TrimPrefix(s, "0X")
if s == "" {
return []byte{}, nil
}
b, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("registry: invalid hex reference %q: %w", s, err)
}
return b, nil
}
+219
View File
@@ -0,0 +1,219 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"errors"
"fmt"
"github.com/luxfi/ids"
)
// NetworkClass distinguishes the value-bearing networks (mainnet, testnet — where
// synthetic anything is forbidden) from dev networks (devnet/local — where a
// developer may opt into synthetic assets for testing, but ONLY there).
type NetworkClass uint8
const (
NetworkClassDev NetworkClass = 0 // devnet / localnet — synthetic flags MAY be set
NetworkClassTestnet NetworkClass = 1 // testnet — synthetic flags FORBIDDEN
NetworkClassMainnet NetworkClass = 2 // mainnet — synthetic flags FORBIDDEN
)
// String renders the class.
func (c NetworkClass) String() string {
switch c {
case NetworkClassMainnet:
return "mainnet"
case NetworkClassTestnet:
return "testnet"
default:
return "dev"
}
}
// valueBearing reports whether this network class forbids any synthetic flag. Both
// mainnet and testnet are value-bearing for the purpose of this gate.
func (c NetworkClass) valueBearing() bool {
return c == NetworkClassMainnet || c == NetworkClassTestnet
}
// NetworkClassFor maps a Lux networkID to its class using the convention-fixed ids
// (1 mainnet, 2 testnet, 3 local, 1337 localnet) and treats every other id as a
// sovereign/dev network. Mainnet and testnet are the only value-bearing classes.
func NetworkClassFor(networkID uint32) NetworkClass {
switch networkID {
case 1:
return NetworkClassMainnet
case 2:
return NetworkClassTestnet
default:
// 3 (local), 1337 (localnet), and every sovereign L1 id are dev-class for
// the purpose of synthetic-flag permission. Value on those is still guarded
// by the consensus-mode guard; this class only governs the synthetic flags.
return NetworkClassDev
}
}
// DexAssetPolicy is the backend-enforced, fail-closed configuration for the DEX's
// real-assets-only posture. Every field's SAFE value is the zero value, so a
// zero-initialised policy is the locked-down policy. These are the exact flags the
// task pins:
//
// dexAllowSyntheticAssets = false
// dexAllowSyntheticMarkets = false
// dexAllowMockLiquidity = false
// dexAllowedAssetKinds = [EVM_NATIVE, ERC20, UTXO]
//
// They are NOT front-end toggles: they live in the VM config and are read once at
// Initialize. A front end cannot relax them.
type DexAssetPolicy struct {
// AllowSyntheticAssets, AllowSyntheticMarkets, AllowMockLiquidity default false.
// On a value-bearing network (mainnet/testnet) any true value fails startup. On
// a dev network a true value is permitted (developer opt-in) but is still subject
// to the forbidden-reference scan (Liquidity is never allowed, anywhere).
AllowSyntheticAssets bool `json:"dexAllowSyntheticAssets"`
AllowSyntheticMarkets bool `json:"dexAllowSyntheticMarkets"`
AllowMockLiquidity bool `json:"dexAllowMockLiquidity"`
// AllowedAssetKinds is the active dexAllowedAssetKinds set. Empty is treated as
// the canonical default {EVM_NATIVE, ERC20, UTXO}; an explicit set may only ever
// be a SUBSET of those three — any other token is rejected by ParseAssetKind.
AllowedAssetKinds []AssetKind `json:"dexAllowedAssetKinds"`
}
// DefaultDexAssetPolicy returns the canonical locked-down policy: no synthetic
// anything, all three real kinds allowed.
func DefaultDexAssetPolicy() DexAssetPolicy {
return DexAssetPolicy{
AllowSyntheticAssets: false,
AllowSyntheticMarkets: false,
AllowMockLiquidity: false,
AllowedAssetKinds: []AssetKind{AssetKindEVMNative, AssetKindERC20, AssetKindUTXO},
}
}
// kinds returns the effective allowed-kind set: the canonical three when unset, else
// exactly the configured subset.
func (p DexAssetPolicy) kinds() []AssetKind {
if len(p.AllowedAssetKinds) == 0 {
return []AssetKind{AssetKindEVMNative, AssetKindERC20, AssetKindUTXO}
}
return p.AllowedAssetKinds
}
// AllowedKindsOrDefault is the exported effective allowed-kind set (the canonical three
// when unset, else the configured subset), used by the node to seed a Registry. It
// returns a fresh slice the caller may not mutate the policy through.
func (p DexAssetPolicy) AllowedKindsOrDefault() []AssetKind {
return append([]AssetKind(nil), p.kinds()...)
}
// anySyntheticFlag reports whether any synthetic/mock flag is set.
func (p DexAssetPolicy) anySyntheticFlag() bool {
return p.AllowSyntheticAssets || p.AllowSyntheticMarkets || p.AllowMockLiquidity
}
// AnySyntheticFlag is the exported predicate: true iff any synthetic/mock flag is set.
// The node uses it to MACHINE-DERIVE the HONEST_VALIDATOR_LAUNCH "real-assets-only"
// assertion rather than trusting an operator-supplied claim.
func (p DexAssetPolicy) AnySyntheticFlag() bool { return p.anySyntheticFlag() }
var (
// ErrSyntheticOnValueNet is returned when any synthetic flag is true on
// mainnet or testnet.
ErrSyntheticOnValueNet = errors.New("startup: synthetic asset/market/liquidity flag set on a value-bearing network (mainnet/testnet)")
// ErrEnabledMarketUnknownAsset is returned when an enabled market references an
// asset that is not in the registry (synthetic).
ErrEnabledMarketUnknownAsset = errors.New("startup: enabled market references an unknown/synthetic asset")
// ErrBadAllowedKind is returned when dexAllowedAssetKinds contains a token that
// is not one of the three real kinds.
ErrBadAllowedKind = errors.New("startup: dexAllowedAssetKinds contains a non-real kind")
)
// RefuseUnderSyntheticConfig is the SINGLE fail-closed startup gate. It is called
// once at VM Initialize, BEFORE the chain accepts work, with the already-populated
// registry and the VM's network + policy. It refuses startup (returns an error,
// which the VM turns into a hard init failure) if ANY of the following hold:
//
// 1. dexAllowedAssetKinds contains a non-real kind (it may only ever be a subset
// of {EVM_NATIVE, ERC20, UTXO}).
// 2. (mainnet OR testnet) AND any synthetic flag (assets/markets/liquidity) true.
// 3. Any ENABLED market references an asset not in the registry (unknown/synthetic),
// references a disabled asset, or spans a network mismatch.
// 4. Any registered asset carries a forbidden reference: a Liquidity (white-label)
// universe chain, mock/synthetic/phantom liquidity, an ASCII-ticker asset id, or
// a declared-but-unbacked credit shape. (The positive reality check happened at
// Register; this is the residual deny-scan over whatever is enabled, plus the
// Liquidity/mock label scan that reality alone would not catch.)
//
// It returns nil ONLY when the registry is fully real and the policy is locked down
// for the network. Anything ambiguous fails closed.
//
// chainLabelFor maps a source chain id to its human label so the off-network-universe
// scan can run; pass a function that yields "" for unknown ids (an unknown id simply
// cannot be a known white-label universe, and its asset already passed the reality
// gate at Register).
func RefuseUnderSyntheticConfig(
class NetworkClass,
policy DexAssetPolicy,
reg *Registry,
chainLabelFor func(chainID ids.ID) string,
) error {
// (1) allowed-kinds must be a subset of the three real kinds.
for _, k := range policy.kinds() {
if !k.Valid() {
return fmt.Errorf("%w: %q", ErrBadAllowedKind, k.String())
}
}
// (2) no synthetic flags on a value-bearing network.
if class.valueBearing() && policy.anySyntheticFlag() {
return fmt.Errorf("%w (network=%s synthAssets=%t synthMarkets=%t mockLiq=%t)",
ErrSyntheticOnValueNet, class,
policy.AllowSyntheticAssets, policy.AllowSyntheticMarkets, policy.AllowMockLiquidity)
}
// (4) deny-scan every registered asset for forbidden references. Run before the
// market scan so a tainted asset is reported at its source.
var scanErr error
reg.Each(func(id ids.ID, a Asset) {
if scanErr != nil {
return
}
label := ""
if chainLabelFor != nil {
label = chainLabelFor(a.ChainID)
}
if err := AssertNoForbiddenAssetRefs(a, label); err != nil {
scanErr = fmt.Errorf("registered asset %s: %w", id, err)
}
})
if scanErr != nil {
return scanErr
}
// (3) every ENABLED market must resolve both sides to a registered, enabled asset
// on the matching network. A market over a synthetic asset has no AssetID to
// resolve, so this catches it structurally.
reg.EachMarket(func(id ids.ID, m Market) {
if scanErr != nil || !m.Enabled {
return
}
base, err := reg.MustResolveEnabled(m.BaseAssetID)
if err != nil {
scanErr = fmt.Errorf("%w: market %s base: %v", ErrEnabledMarketUnknownAsset, id, err)
return
}
quote, err := reg.MustResolveEnabled(m.QuoteAssetID)
if err != nil {
scanErr = fmt.Errorf("%w: market %s quote: %v", ErrEnabledMarketUnknownAsset, id, err)
return
}
if m.NetworkID != base.NetworkID || m.NetworkID != quote.NetworkID {
scanErr = fmt.Errorf("%w: market %s network mismatch (market=%d base=%d quote=%d)",
ErrEnabledMarketUnknownAsset, id, m.NetworkID, base.NetworkID, quote.NetworkID)
}
})
return scanErr
}
+216
View File
@@ -0,0 +1,216 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"errors"
"testing"
"github.com/luxfi/ids"
)
// --- consensus-mode value guard -------------------------------------------
func TestConsensusGuard_QuorumFinality_AllowsValue(t *testing.T) {
st, err := GuardValueActivation(true, ConsensusModeQuorumFinality, LaunchAssertions{})
if err != nil {
t.Fatalf("QUORUM_FINALITY must permit value activation: %v", err)
}
if st.Mode != ConsensusModeQuorumFinality {
t.Fatalf("wrong mode: %s", st.Mode)
}
if st.Status != "" {
t.Fatalf("QUORUM_FINALITY must not surface a disclaimer (genuine BFT), got %q", st.Status)
}
}
func TestConsensusGuard_HonestValidatorLaunch_RequiresBundleAndDisclaimer(t *testing.T) {
// Missing any leg of the bundle => refuse.
for _, a := range []LaunchAssertions{
{CapsOn: false, RealAssetsOnly: true, HaltReady: true},
{CapsOn: true, RealAssetsOnly: false, HaltReady: true},
{CapsOn: true, RealAssetsOnly: true, HaltReady: false},
} {
if _, err := GuardValueActivation(true, ConsensusModeHonestValidatorLaunch, a); !errors.Is(err, ErrLaunchAssertionsUnmet) {
t.Fatalf("HONEST_VALIDATOR_LAUNCH must refuse without full bundle (a=%+v), got: %v", a, err)
}
}
// Full bundle => permitted AND surfaces the exact no-Byzantine-finality string.
st, err := GuardValueActivation(true, ConsensusModeHonestValidatorLaunch, LaunchAssertions{CapsOn: true, RealAssetsOnly: true, HaltReady: true})
if err != nil {
t.Fatalf("HONEST_VALIDATOR_LAUNCH with full bundle must permit value: %v", err)
}
if st.Status != NoByzantineFinalityClaim {
t.Fatalf("HONEST_VALIDATOR_LAUNCH must surface the no-Byzantine-finality status, got %q", st.Status)
}
}
func TestConsensusGuard_RefusesUnsetAndUnknown_NeverSilentThirdState(t *testing.T) {
// UNSET with value requested => refuse.
if _, err := GuardValueActivation(true, ConsensusModeUnset, LaunchAssertions{}); !errors.Is(err, ErrValueModeUnset) {
t.Fatalf("UNSET value activation must be refused, got: %v", err)
}
// Any out-of-enum mode value => refuse (the closed-enum default arm). 99 is not
// a legal mode; it must never silently authorise value.
if _, err := GuardValueActivation(true, ConsensusMode(99), LaunchAssertions{CapsOn: true, RealAssetsOnly: true, HaltReady: true}); !errors.Is(err, ErrValueModeIllegal) {
t.Fatalf("out-of-enum consensus mode must be refused, got: %v", err)
}
// Value DISABLED => no error regardless of mode (nothing to authorise).
if _, err := GuardValueActivation(false, ConsensusModeUnset, LaunchAssertions{}); err != nil {
t.Fatalf("value-disabled must not error: %v", err)
}
}
func TestParseConsensusMode_RejectsUnknown(t *testing.T) {
if _, err := ParseConsensusMode("PARTIAL_FINALITY"); err == nil {
t.Fatal("unknown consensus mode token must be rejected")
}
for tok, want := range map[string]ConsensusMode{
"QUORUM_FINALITY": ConsensusModeQuorumFinality,
"HONEST_VALIDATOR_LAUNCH": ConsensusModeHonestValidatorLaunch,
"": ConsensusModeUnset,
} {
got, err := ParseConsensusMode(tok)
if err != nil || got != want {
t.Fatalf("ParseConsensusMode(%q) = %s,%v want %s", tok, got, err, want)
}
}
}
// --- fail-closed startup gate edge cases -----------------------------------
func TestStartupGate_RefusesSyntheticFlagsOnValueNets(t *testing.T) {
reg := New(AssetKindEVMNative, AssetKindERC20, AssetKindUTXO)
synthPolicies := []DexAssetPolicy{
{AllowSyntheticAssets: true},
{AllowSyntheticMarkets: true},
{AllowMockLiquidity: true},
}
for _, class := range []NetworkClass{NetworkClassMainnet, NetworkClassTestnet} {
for _, p := range synthPolicies {
if err := RefuseUnderSyntheticConfig(class, p, reg, nil); !errors.Is(err, ErrSyntheticOnValueNet) {
t.Fatalf("class=%s policy=%+v must refuse (synthetic on value net), got: %v", class, p, err)
}
}
}
// Dev network MAY set synthetic flags (developer opt-in) — gate passes.
if err := RefuseUnderSyntheticConfig(NetworkClassDev, DexAssetPolicy{AllowSyntheticAssets: true}, reg, nil); err != nil {
t.Fatalf("dev network should permit synthetic flags: %v", err)
}
}
func TestStartupGate_RefusesForbiddenLiquidityUniverse(t *testing.T) {
fc := newFakeChain()
cChain := ids.GenerateTestID()
reg := New(AssetKindERC20)
// Register a real asset, but give its source chain a Liquidity (white-label)
// label via the chainLabelFor hook. The gate must refuse to start.
a, _ := realERC20(t, fc, cChain, addr20(0x33), "USDC")
if _, err := reg.Register(a, fc); err != nil {
t.Fatalf("register: %v", err)
}
labelLiquidity := func(id ids.ID) string {
if id == cChain {
return "Liquidity L1 universe"
}
return ""
}
if err := RefuseUnderSyntheticConfig(NetworkClassMainnet, DefaultDexAssetPolicy(), reg, labelLiquidity); err == nil {
t.Fatal("startup gate must refuse an asset on a Liquidity (white-label) universe chain")
}
// With a clean label, the same registry starts fine.
if err := RefuseUnderSyntheticConfig(NetworkClassMainnet, DefaultDexAssetPolicy(), reg, func(ids.ID) string { return "Lux C-Chain" }); err != nil {
t.Fatalf("clean-label registry should start: %v", err)
}
}
func TestStartupGate_RefusesBadAllowedKind(t *testing.T) {
reg := New(AssetKindERC20)
// A policy whose allowed-kinds list smuggles the invalid/zero kind must be
// refused (the list may only ever be a subset of the three real kinds).
p := DexAssetPolicy{AllowedAssetKinds: []AssetKind{AssetKindERC20, AssetKindInvalid}}
if err := RefuseUnderSyntheticConfig(NetworkClassMainnet, p, reg, nil); !errors.Is(err, ErrBadAllowedKind) {
t.Fatalf("startup gate must refuse a non-real allowed kind, got: %v", err)
}
}
func TestRegister_RefusesDecimalsMismatchAndForbiddenSymbol(t *testing.T) {
fc := newFakeChain()
cChain := ids.GenerateTestID()
reg := New(AssetKindERC20)
// Seed the token with 6 decimals on-chain but declare 18 => refused.
addr := addr20(0x44)
fc.seedERC20(mainnetID, cChain, addr, 6)
mismatch := Asset{
NetworkID: mainnetID, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: addr,
Decimals: 18, Symbol: "USDC", Enabled: true,
}
if _, err := reg.Register(mismatch, fc); err == nil {
t.Fatal("declared decimals != on-chain decimals must be refused")
}
// A forbidden (mock/synthetic) symbol is caught by the deny-scan at the gate
// even though the token is real on-chain.
addr2 := addr20(0x55)
fc.seedERC20(mainnetID, cChain, addr2, 6)
mock := Asset{
NetworkID: mainnetID, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: addr2,
Decimals: 6, Symbol: "MOCKUSD", Name: "mock liquidity token", Enabled: true,
}
if _, err := reg.Register(mock, fc); err != nil {
t.Fatalf("real token registers (symbol scan is at the gate): %v", err)
}
if err := RefuseUnderSyntheticConfig(NetworkClassMainnet, DefaultDexAssetPolicy(), reg, nil); err == nil {
t.Fatal("startup gate must refuse an asset whose name/symbol names mock liquidity")
}
}
func TestAssetKind_JSONRoundTripAndTickerRejection(t *testing.T) {
for _, k := range []AssetKind{AssetKindEVMNative, AssetKindERC20, AssetKindUTXO} {
txt, err := k.MarshalText()
if err != nil {
t.Fatalf("marshal %v: %v", k, err)
}
var back AssetKind
if err := back.UnmarshalText(txt); err != nil || back != k {
t.Fatalf("round-trip %v: got %v err %v", k, back, err)
}
}
// Invalid kind refuses to marshal (fail-closed).
if _, err := AssetKindInvalid.MarshalText(); err == nil {
t.Fatal("invalid kind must refuse to marshal")
}
// An ASCII ticker is not a kind.
var k AssetKind
if err := k.UnmarshalText([]byte("LUX")); err == nil {
t.Fatal("ASCII ticker must not parse as an asset kind")
}
}
func TestMarketID_BoundToAssetsAndVenue(t *testing.T) {
base := ids.GenerateTestID()
quote := ids.GenerateTestID()
venueA := []byte("tick=1,lot=1,fee=30")
venueB := []byte("tick=1,lot=1,fee=5")
id1 := MarketID(mainnetID, base, quote, venueA)
id2 := MarketID(mainnetID, base, quote, venueB)
if id1 == id2 {
t.Fatal("different venue configs must yield different market ids")
}
// Swapping base/quote yields a different market (directionality is part of id).
if MarketID(mainnetID, base, quote, venueA) == MarketID(mainnetID, quote, base, venueA) {
t.Fatal("base/quote order must affect the market id")
}
// Reproducible.
if MarketID(mainnetID, base, quote, venueA) != id1 {
t.Fatal("MarketID not reproducible")
}
}
+276
View File
@@ -0,0 +1,276 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/luxfi/ids"
)
// Manifest is the on-disk, per-network declaration of the REAL assets and markets
// the DEX admits on that network. There is exactly one manifest per network
// (assets.devnet.json, assets.testnet.json, assets.mainnet.json). Every entry must
// be real on the named network; CI proves this against the network's RPC before
// any deploy, and the node re-proves it (or trusts the CI-validated artifact and
// re-runs the deny-scan) at startup.
//
// The manifest is the SINGLE source of truth for what trades. It does not carry the
// derived AssetIDs/MarketIDs — those are computed from the canonical fields so the
// file cannot disagree with the identity.
type Manifest struct {
// Network is the canonical network name this manifest applies to. It must match
// the deploy target; a mismatch is a hard error (you cannot ship the testnet
// manifest to mainnet).
Network string `json:"network"`
// NetworkID is the Lux networkID (1 mainnet, 2 testnet, ...) every asset/market
// in this manifest must declare. A per-entry networkID that disagrees is rejected.
NetworkID uint32 `json:"networkID"`
// EVMChainID is the C-Chain's EVM chainID (eth_chainId): 96369 mainnet, 96368
// testnet, 96370 devnet, 1337 localnet. It is the AUTHORITATIVE, RPC-checkable
// identity of the C-Chain — the CI validator confirms the target RPC's
// eth_chainId equals this before admitting any ERC-20/native entry, so a manifest
// can never be validated against the wrong chain.
EVMChainID uint64 `json:"evmChainID"`
// CChainID is the canonical C-Chain CONSENSUS id (the P-Chain blockchain id, an
// ids.ID) used in the AssetID preimage so a derived AssetID lives in the same
// identity space as the on-chain atomic objects. It is network-specific and is
// confirmed by the CI validator against the live P-Chain (platform.getBlockchains)
// — the validator REFUSES to proceed if the manifest's CChainID is not the C-Chain
// the target net actually runs. EVM_NATIVE/ERC20 entries are rooted here; an entry
// whose chainID disagrees is rejected (so a manifest cannot point an "ERC20" at a
// non-C chain).
CChainID ids.ID `json:"cChainID"`
// ChainLabels maps a source chain id (hex) to its human label, consumed by the
// forbidden-reference deny-scan (so the off-network-universe check has labels to
// test). Optional; an unlabeled chain simply has no white-label name to match.
ChainLabels map[string]string `json:"chainLabels,omitempty"`
// Assets and Markets are the declared real entries.
Assets []Asset `json:"assets"`
Markets []Market `json:"markets"`
}
// LoadManifest reads and JSON-decodes a manifest file. It does NOT verify against
// chain state (that is ApplyTo, which needs a verifier) — it only parses and
// structurally validates the shape. A malformed kind/ref/tier fails here.
func LoadManifest(path string) (*Manifest, error) {
m, _, err := loadManifestBytes(path)
return m, err
}
// loadManifestBytes reads a manifest file from disk and decodes it via the shared
// decoder. The content-hash is over the EXACT file bytes the node read, so a pinned-hash
// check binds the loaded manifest to the CI-approved artifact byte-for-byte.
func loadManifestBytes(path string) (*Manifest, string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, "", fmt.Errorf("manifest: read %s: %w", path, err)
}
return decodeManifestBytes(raw, path)
}
// decodeManifestBytes content-hashes and JSON-decodes raw manifest bytes (from a file or
// from the embedded FS), returning the parsed manifest and the bytes' SHA-256 (lowercase
// hex). It is the SINGLE manifest decoder — disk loads and embedded loads share it so the
// shape-validation, unknown-field rejection, and content-hash discipline are identical for
// both. label is used only for error context (a path or an embed name).
func decodeManifestBytes(raw []byte, label string) (*Manifest, string, error) {
sum := sha256.Sum256(raw)
hexSum := hex.EncodeToString(sum[:])
var m Manifest
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields() // a typo'd field (e.g. "asssets") fails closed
if err := dec.Decode(&m); err != nil {
return nil, hexSum, fmt.Errorf("manifest: decode %s: %w", label, err)
}
if err := m.validateShape(); err != nil {
return nil, hexSum, fmt.Errorf("manifest %s: %w", label, err)
}
return &m, hexSum, nil
}
// ErrManifestHashMismatch is returned when a manifest's actual content SHA-256 does not
// equal the pinned expected hash — the file was edited (a fabricated address, an extra
// asset) away from the CI-approved artifact. Fail-closed: the node refuses to load it.
var ErrManifestHashMismatch = errors.New("registry: manifest content hash does not match the pinned expected hash (the file was modified from the CI-approved artifact)")
// LoadManifestPinned reads a manifest and REFUSES it unless its content SHA-256 equals
// expectedSHA256 (lowercase hex, with or without a "0x"/"sha256:" prefix). This is the M1
// fix: a node verifies the manifest it loads is BYTE-IDENTICAL to the artifact CI approved
// and pinned in genesis/config — the dexvm proxy holds NO EVM state, so it cannot
// eth_getCode the tokens itself; the content-hash binding is what stops a locally edited
// manifest (a fabricated token address) from being loaded.
//
// An empty expectedSHA256 means "no pin configured" and falls back to LoadManifest (shape
// validation only) — pinning is opt-in per deployment, but once a hash is set the file must
// match it exactly. A malformed expected hash is itself an error (fail-closed).
func LoadManifestPinned(path, expectedSHA256 string) (*Manifest, error) {
if expectedSHA256 == "" {
return LoadManifest(path)
}
want, err := normalizeSHA256(expectedSHA256)
if err != nil {
return nil, err
}
m, got, err := loadManifestBytes(path)
if err != nil {
return nil, err
}
if got != want {
return nil, fmt.Errorf("%w (path=%s want=%s got=%s)", ErrManifestHashMismatch, path, want, got)
}
return m, nil
}
// normalizeSHA256 lowercases and strips an optional "0x" or "sha256:" prefix, then checks
// the value is a 64-hex-char (32-byte) digest. A non-conforming value fails closed (you
// cannot pin against a malformed hash).
func normalizeSHA256(h string) (string, error) {
s := strings.ToLower(strings.TrimSpace(h))
s = strings.TrimPrefix(s, "sha256:")
s = strings.TrimPrefix(s, "0x")
if len(s) != 64 {
return "", fmt.Errorf("registry: pinned manifest hash must be a 32-byte SHA-256 (64 hex chars), got %d chars", len(s))
}
if _, err := hex.DecodeString(s); err != nil {
return "", fmt.Errorf("registry: pinned manifest hash is not valid hex: %w", err)
}
return s, nil
}
// validateShape checks the manifest is internally consistent before any chain I/O:
// network name present, every asset/market on the manifest's network, every C-Chain
// asset rooted at the manifest's CChainID, and every asset/market structurally valid.
func (m *Manifest) validateShape() error {
if m.Network == "" {
return fmt.Errorf("manifest: empty network name")
}
if m.NetworkID == 0 {
return fmt.Errorf("manifest: networkID must be non-zero")
}
if m.EVMChainID == 0 {
return fmt.Errorf("manifest: evmChainID must be non-zero (the RPC-checkable C-Chain identity)")
}
if m.CChainID == ids.Empty {
return fmt.Errorf("manifest: cChainID must be set (the C-Chain consensus id)")
}
for i, a := range m.Assets {
if a.NetworkID != m.NetworkID {
return fmt.Errorf("manifest: asset[%d] networkID %d != manifest networkID %d", i, a.NetworkID, m.NetworkID)
}
switch a.Kind {
case AssetKindEVMNative, AssetKindERC20:
if a.ChainID != m.CChainID {
return fmt.Errorf("manifest: asset[%d] (%s) chainID must be the C-Chain %s, got %s", i, a.Kind, m.CChainID, a.ChainID)
}
case AssetKindUTXO:
// UTXO assets are rooted at a UTXO source chain (X-Chain), not the C-Chain.
default:
return fmt.Errorf("manifest: asset[%d] invalid kind", i)
}
if err := a.validateShape(); err != nil {
return fmt.Errorf("manifest: asset[%d]: %w", i, err)
}
}
for i, mk := range m.Markets {
if mk.NetworkID != m.NetworkID {
return fmt.Errorf("manifest: market[%d] networkID %d != manifest networkID %d", i, mk.NetworkID, m.NetworkID)
}
}
return nil
}
// chainLabelFor returns the deny-scan label function for this manifest's declared
// chain labels.
func (m *Manifest) chainLabelFor() func(ids.ID) string {
return func(id ids.ID) string {
if m.ChainLabels == nil {
return ""
}
return m.ChainLabels[id.Hex()]
}
}
// ChainLabelFor is the exported deny-scan label function for this manifest, used by the
// node startup gate to run the off-network-universe / forbidden-reference scan with the
// manifest's declared chain labels.
func (m *Manifest) ChainLabelFor() func(ids.ID) string { return m.chainLabelFor() }
// CChainConfirmer is the optional manifest-level check a verifier may implement to
// confirm, before any asset lookup, that the C-Chain it is talking to is the one the
// manifest declares: the live eth_chainId equals EVMChainID and the live C-Chain
// consensus id equals CChainID. The RPC validator implements it (so a manifest is
// never validated against the wrong chain); a local in-process verifier may not need
// to and can omit it. ApplyTo runs it first when present.
type CChainConfirmer interface {
ConfirmCChain(networkID uint32, evmChainID uint64, cChainID ids.ID) error
}
// ApplyTo registers every manifest asset and creates every manifest market into reg,
// proving each asset real against v, then runs the fail-closed startup gate for the
// manifest's network class and the given policy. It is the ONE routine that turns a
// manifest into a live, gated registry — used identically by CI (with an RPC
// verifier) and by node startup (with the local-chain verifier).
//
// The order is: confirm the C-Chain identity (if v can), register assets (each
// VerifyOnChain), create markets (each pinned to two registered assets), then
// RefuseUnderSyntheticConfig (the residual deny-scan + enabled-market audit). Any
// failure aborts and is returned; reg is left partially populated only on error
// (callers discard it).
func (m *Manifest) ApplyTo(reg *Registry, v ChainVerifier, policy DexAssetPolicy) error {
if err := m.AdmitInto(reg, v); err != nil {
return err
}
class := NetworkClassFor(m.NetworkID)
if err := RefuseUnderSyntheticConfig(class, policy, reg, m.chainLabelFor()); err != nil {
return fmt.Errorf("manifest %s: startup gate: %w", m.Network, err)
}
return nil
}
// AdmitInto registers every manifest asset (each proven real against v) and creates
// every manifest market (each pinned to two registered assets) into reg, WITHOUT running
// the fail-closed startup gate. It is the admission half of ApplyTo, separated so a
// caller that owns the gate (the node, which runs the gate once with its own network
// class + policy) does not run it twice. ApplyTo == AdmitInto + RefuseUnderSyntheticConfig.
func (m *Manifest) AdmitInto(reg *Registry, v ChainVerifier) error {
if err := m.validateShape(); err != nil {
return err
}
if c, ok := v.(CChainConfirmer); ok {
if err := c.ConfirmCChain(m.NetworkID, m.EVMChainID, m.CChainID); err != nil {
return fmt.Errorf("manifest %s: C-Chain identity confirm: %w", m.Network, err)
}
}
for i, a := range m.Assets {
if _, err := reg.Register(a, v); err != nil {
return fmt.Errorf("manifest %s: asset[%d] (%s): %w", m.Network, i, a.Kind, err)
}
}
for i, mk := range m.Markets {
if _, err := reg.CreateMarket(mk); err != nil {
return fmt.Errorf("manifest %s: market[%d]: %w", m.Network, i, err)
}
}
return nil
}
// Validate is the full CI check for one manifest against one verifier: load is done
// by the caller (LoadManifest); this proves every entry real and gate-clean using a
// fresh registry under the canonical locked-down policy. It returns the populated
// registry so a caller can report what was admitted.
func (m *Manifest) Validate(v ChainVerifier) (*Registry, error) {
reg := New(AssetKindEVMNative, AssetKindERC20, AssetKindUTXO)
if err := m.ApplyTo(reg, v, DefaultDexAssetPolicy()); err != nil {
return nil, err
}
return reg, nil
}
+126
View File
@@ -0,0 +1,126 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/luxfi/ids"
)
// manifest_pin_test.go is the M1 proof: a manifest is bound to its CI-approved artifact by a
// pinned content SHA-256. An edited local manifest (a fabricated token address, an added
// asset) no longer hashes to the pin and is REFUSED — the dexvm proxy holds no EVM state, so
// the content-hash is what stops a tampered manifest from loading.
// writePinManifest writes a minimal real manifest and returns (path, sha256-hex).
func writePinManifest(t *testing.T, networkID uint32, cChain ids.ID, erc20 []byte) (string, string) {
t.Helper()
m := Manifest{
Network: "mainnet",
NetworkID: networkID,
EVMChainID: 96369,
CChainID: cChain,
Assets: []Asset{
{NetworkID: networkID, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: erc20, Decimals: 18, Symbol: "WLUX", Name: "Wrapped LUX", Enabled: true, RiskTier: RiskTier0},
},
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
path := filepath.Join(t.TempDir(), "assets.mainnet.json")
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatalf("write: %v", err)
}
sum := sha256.Sum256(b)
return path, hex.EncodeToString(sum[:])
}
// TestM1_PinnedManifest_MatchingHashLoads proves the happy path: a manifest whose content
// matches the pinned hash loads.
func TestM1_PinnedManifest_MatchingHashLoads(t *testing.T) {
cChain := ids.GenerateTestID()
path, sum := writePinManifest(t, 1, cChain, addr20(0x4a))
m, err := LoadManifestPinned(path, sum)
if err != nil {
t.Fatalf("a manifest matching its pinned hash must load: %v", err)
}
if len(m.Assets) != 1 {
t.Fatalf("expected 1 asset, got %d", len(m.Assets))
}
// The pin accepts the common "0x" and "sha256:" prefixes too.
if _, err := LoadManifestPinned(path, "0x"+sum); err != nil {
t.Fatalf("0x-prefixed pin must load: %v", err)
}
if _, err := LoadManifestPinned(path, "sha256:"+strings.ToUpper(sum)); err != nil {
t.Fatalf("sha256:-prefixed uppercase pin must load: %v", err)
}
}
// TestM1_EditedManifest_FabricatedAddress_Rejected is the decisive proof: after pinning the
// CI-approved manifest's hash, an attacker EDITS the file to point an asset at a fabricated
// token address. The edited file no longer hashes to the pin, so the node REFUSES it.
func TestM1_EditedManifest_FabricatedAddress_Rejected(t *testing.T) {
cChain := ids.GenerateTestID()
path, ciApprovedHash := writePinManifest(t, 1, cChain, addr20(0x4a))
// Attacker rewrites the manifest in place, swapping the real token for a fabricated
// address (and it is still structurally valid, so shape validation alone would pass).
tampered := Manifest{
Network: "mainnet",
NetworkID: 1,
EVMChainID: 96369,
CChainID: cChain,
Assets: []Asset{
{NetworkID: 1, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: addr20(0xEE) /* fabricated */, Decimals: 18, Symbol: "WLUX", Name: "Wrapped LUX", Enabled: true, RiskTier: RiskTier0},
},
}
b, _ := json.MarshalIndent(tampered, "", " ")
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatalf("rewrite: %v", err)
}
// Loading WITHOUT a pin would accept the tampered file (shape is valid) — proving the
// shape check alone is insufficient.
if _, err := LoadManifest(path); err != nil {
t.Fatalf("sanity: the tampered file is still structurally valid (shape passes): %v", err)
}
// Loading WITH the CI-approved pin REFUSES it (content hash no longer matches).
if _, err := LoadManifestPinned(path, ciApprovedHash); !errors.Is(err, ErrManifestHashMismatch) {
t.Fatalf("an edited manifest must be refused against the pinned hash (ErrManifestHashMismatch), got: %v", err)
}
}
// TestM1_MalformedPin_FailsClosed proves a malformed expected hash is itself refused (you
// cannot pin against garbage).
func TestM1_MalformedPin_FailsClosed(t *testing.T) {
cChain := ids.GenerateTestID()
path, _ := writePinManifest(t, 1, cChain, addr20(0x4a))
for _, bad := range []string{"deadbeef" /* too short */, strings.Repeat("zz", 32) /* non-hex */} {
if _, err := LoadManifestPinned(path, bad); err == nil {
t.Fatalf("a malformed pin %q must fail closed", bad)
}
}
}
// TestM1_EmptyPin_FallsBackToShapeOnly proves pinning is opt-in: an empty pin loads with
// shape validation only (no hash binding), preserving the unpinned path.
func TestM1_EmptyPin_FallsBackToShapeOnly(t *testing.T) {
cChain := ids.GenerateTestID()
path, _ := writePinManifest(t, 1, cChain, addr20(0x4a))
if _, err := LoadManifestPinned(path, ""); err != nil {
t.Fatalf("an empty pin must fall back to shape-only load: %v", err)
}
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/luxfi/ids"
)
// fakeChainConfirm is a fakeChain that also implements CChainConfirmer, so the
// manifest-level identity check is exercised. It confirms a single declared
// (networkID, evmChainID, cChainID) triple.
type fakeChainConfirm struct {
*fakeChain
networkID uint32
evmChainID uint64
cChainID ids.ID
}
func (f *fakeChainConfirm) ConfirmCChain(networkID uint32, evmChainID uint64, cChainID ids.ID) error {
if networkID != f.networkID {
return errNotOnChain
}
if evmChainID != f.evmChainID {
return errNotOnChain
}
if cChainID != f.cChainID {
return errNotOnChain
}
return nil
}
func TestManifest_LoadAndValidateRealEntries(t *testing.T) {
cChain := ids.GenerateTestID()
wlux := addr20(0x4a)
lusd := addr20(0x84)
fc := newFakeChain()
fc.seedERC20(mainnetID, cChain, wlux, 18)
fc.seedERC20(mainnetID, cChain, lusd, 18)
v := &fakeChainConfirm{fakeChain: fc, networkID: mainnetID, evmChainID: 96369, cChainID: cChain}
// Build a manifest in memory, write it to a temp file, then load + validate it
// through the real LoadManifest -> Validate path (the same path CI uses).
m := Manifest{
Network: "mainnet",
NetworkID: mainnetID,
EVMChainID: 96369,
CChainID: cChain,
ChainLabels: map[string]string{
cChain.Hex(): "Lux C-Chain",
},
Assets: []Asset{
{NetworkID: mainnetID, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: wlux, Decimals: 18, Symbol: "WLUX", Name: "Wrapped LUX", Enabled: true, RiskTier: RiskTier0},
{NetworkID: mainnetID, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: lusd, Decimals: 18, Symbol: "LUSD", Name: "Lux Dollar", Enabled: true, RiskTier: RiskTier0},
},
}
wluxID, _ := DeriveAssetID(mainnetID, cChain, AssetKindERC20, wlux)
lusdID, _ := DeriveAssetID(mainnetID, cChain, AssetKindERC20, lusd)
m.Markets = []Market{
{NetworkID: mainnetID, BaseAssetID: wluxID, QuoteAssetID: lusdID, VenueConfig: []byte("tick=1;lot=1;fee=30"), Enabled: true},
}
path := writeManifest(t, m)
loaded, err := LoadManifest(path)
if err != nil {
t.Fatalf("load manifest: %v", err)
}
reg, err := loaded.Validate(v)
if err != nil {
t.Fatalf("validate manifest against (fake) chain: %v", err)
}
if reg.Len() != 2 {
t.Fatalf("expected 2 admitted assets, got %d", reg.Len())
}
if _, ok := reg.Resolve(wluxID); !ok {
t.Fatal("WLUX not admitted")
}
if _, ok := reg.ResolveMarket((Market{NetworkID: mainnetID, BaseAssetID: wluxID, QuoteAssetID: lusdID, VenueConfig: []byte("tick=1;lot=1;fee=30")}).ID()); !ok {
t.Fatal("WLUX/LUSD market not admitted")
}
}
func TestManifest_RejectsWrongChainAndUnknownField(t *testing.T) {
cChain := ids.GenerateTestID()
wlux := addr20(0x4a)
fc := newFakeChain()
fc.seedERC20(mainnetID, cChain, wlux, 18)
// (a) Verifier bound to a DIFFERENT cChainID => ConfirmCChain fails => validate fails.
wrong := &fakeChainConfirm{fakeChain: fc, networkID: mainnetID, evmChainID: 96369, cChainID: ids.GenerateTestID()}
m := Manifest{
Network: "mainnet", NetworkID: mainnetID, EVMChainID: 96369, CChainID: cChain,
Assets: []Asset{{NetworkID: mainnetID, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: wlux, Decimals: 18, Symbol: "WLUX", Enabled: true}},
}
if _, err := m.Validate(wrong); err == nil {
t.Fatal("manifest validated against the wrong C-Chain; must be refused")
}
// (b) An ERC-20 entry whose chainID is not the manifest C-Chain => shape error.
bad := Manifest{
Network: "mainnet", NetworkID: mainnetID, EVMChainID: 96369, CChainID: cChain,
Assets: []Asset{{NetworkID: mainnetID, ChainID: ids.GenerateTestID(), Kind: AssetKindERC20, CanonicalRef: wlux, Decimals: 18, Symbol: "WLUX", Enabled: true}},
}
if err := bad.validateShape(); err == nil {
t.Fatal("ERC-20 rooted off the C-Chain must be rejected by shape validation")
}
// (c) A manifest file with an unknown field fails closed at load.
path := filepath.Join(t.TempDir(), "bad.json")
if err := os.WriteFile(path, []byte(`{"network":"mainnet","networkID":1,"evmChainID":96369,"cChainID":"`+cChain.String()+`","asssets":[]}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadManifest(path); err == nil {
t.Fatal("manifest with an unknown field must fail to load (DisallowUnknownFields)")
}
}
func TestManifest_ForbiddenLiquidityLabelRefusesViaLoadedFile(t *testing.T) {
cChain := ids.GenerateTestID()
wlux := addr20(0x4a)
fc := newFakeChain()
fc.seedERC20(mainnetID, cChain, wlux, 18)
v := &fakeChainConfirm{fakeChain: fc, networkID: mainnetID, evmChainID: 96369, cChainID: cChain}
// A loaded manifest that labels its source chain as a Liquidity (white-label)
// universe must be refused even though the token itself is real on-chain — the
// label travels through the file -> chainLabelFor -> deny-scan path.
m := Manifest{
Network: "mainnet", NetworkID: mainnetID, EVMChainID: 96369, CChainID: cChain,
ChainLabels: map[string]string{cChain.Hex(): "Liquidity primary network"},
Assets: []Asset{{NetworkID: mainnetID, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: wlux, Decimals: 18, Symbol: "WLUX", Enabled: true, RiskTier: RiskTier0}},
}
path := writeManifest(t, m)
loaded, err := LoadManifest(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if _, err := loaded.Validate(v); err == nil {
t.Fatal("manifest with a Liquidity universe label must be refused")
}
}
func writeManifest(t *testing.T, m Manifest) string {
t.Helper()
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
path := filepath.Join(t.TempDir(), m.Network+".json")
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatalf("write manifest: %v", err)
}
return path
}
@@ -0,0 +1,24 @@
{
"network": "devnet",
"networkID": 3,
"evmChainID": 96370,
"cChainID": "Bm8X7THQtS2txLrQWTDXN8a4JDuhP4KGybUE754LLSiVFLQ7v",
"chainLabels": {
"186f101dd98bdebc5c84aef16dd5c15ba2f42f0b8ee5b9194eae11fd91e57b71": "Lux C-Chain (devnet)",
"667d186624a49f77082edabfa7cc34e64520fe837b6b4a21109a16975515892a": "Lux X-Chain (devnet)"
},
"assets": [
{
"networkID": 3,
"chainID": "Bm8X7THQtS2txLrQWTDXN8a4JDuhP4KGybUE754LLSiVFLQ7v",
"assetKind": "EVM_NATIVE",
"canonicalRef": "0x0000000000000000000000000000000000000000",
"decimals": 18,
"symbol": "LUX",
"name": "Lux",
"enabled": true,
"riskTier": 0
}
],
"markets": []
}
@@ -0,0 +1,24 @@
{
"network": "mainnet",
"networkID": 1,
"evmChainID": 96369,
"cChainID": "2wRdZGeca1qkxzNCq88NWDF5nJ5A9o623vRJKd3FsjRYvuVvvt",
"chainLabels": {
"ff46212f83773a47ac8fc3b800ad4e725743d286f33388e4541408f44de0d512": "Lux C-Chain (mainnet)",
"a98ba25f6a582b4065f46445c7e0636848b66b60079bbf98e5fd88c4d756bd2b": "Lux X-Chain (mainnet)"
},
"assets": [
{
"networkID": 1,
"chainID": "2wRdZGeca1qkxzNCq88NWDF5nJ5A9o623vRJKd3FsjRYvuVvvt",
"assetKind": "EVM_NATIVE",
"canonicalRef": "0x0000000000000000000000000000000000000000",
"decimals": 18,
"symbol": "LUX",
"name": "Lux",
"enabled": true,
"riskTier": 0
}
],
"markets": []
}
@@ -0,0 +1,24 @@
{
"network": "testnet",
"networkID": 2,
"evmChainID": 96368,
"cChainID": "uzPtAE7PHd2TFHaxsvyVqmVbhMuWR4jiEh4XV8uF9uMQEFyUB",
"chainLabels": {
"7851bc52f7b0d0bc81c636edcc7d93f9bde8e99a0d9ccba2700323dc1170b621": "Lux C-Chain (testnet)",
"9c3b502bd747773d3e27590d53e34f57620e7e86fc7925c8186ad7791c0e10fe": "Lux X-Chain (testnet)"
},
"assets": [
{
"networkID": 2,
"chainID": "uzPtAE7PHd2TFHaxsvyVqmVbhMuWR4jiEh4XV8uF9uMQEFyUB",
"assetKind": "EVM_NATIVE",
"canonicalRef": "0x0000000000000000000000000000000000000000",
"decimals": 18,
"symbol": "LUX",
"name": "Lux",
"enabled": true,
"riskTier": 0
}
],
"markets": []
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"errors"
"fmt"
"github.com/luxfi/ids"
)
// Market is an admitted trading pair. Both sides are pinned to registered, real,
// enabled assets by construction: a Market cannot be created (CreateMarket) unless
// both BaseAssetID and QuoteAssetID resolve in the registry. The MarketID is DERIVED
// from the two AssetIDs and the venue config, never supplied — so a market's identity
// is structurally bound to its real assets.
type Market struct {
// NetworkID must match both assets' networks (a market does not span networks).
NetworkID uint32 `json:"networkID"`
// BaseAssetID / QuoteAssetID are canonical AssetIDs of registered assets.
BaseAssetID ids.ID `json:"baseAssetID"`
QuoteAssetID ids.ID `json:"quoteAssetID"`
// VenueConfig is the canonical serialization of the venue parameters (tick, lot,
// fee tier) that distinguish two venues on the same pair. Hex in JSON.
VenueConfig Bytes `json:"venueConfig"`
// Enabled gates whether the market trades. A disabled market is still pinned to
// real assets; it simply admits no orders.
Enabled bool `json:"enabled"`
}
// ID returns the canonical MarketID = H(networkID, baseAssetID, quoteAssetID, venueConfig).
func (m Market) ID() ids.ID {
return MarketID(m.NetworkID, m.BaseAssetID, m.QuoteAssetID, m.VenueConfig)
}
var (
// ErrSameAsset is returned when a market names the same asset on both sides.
ErrSameAsset = errors.New("registry: market base and quote assets are identical")
// ErrNetworkMismatch is returned when a market's network does not match an asset's.
ErrNetworkMismatch = errors.New("registry: market network does not match asset network")
// ErrDuplicateMarket is returned when a MarketID already exists.
ErrDuplicateMarket = errors.New("registry: market already exists")
)
// CreateMarket admits a market ONLY if BOTH sides resolve to a registered, enabled,
// real asset on the SAME network as the market. This is the structural enforcement
// of "no synthetic market": there is no AssetID for a synthetic asset, so a market
// over one cannot resolve, so it cannot be created. The market is stored in the
// registry and its derived MarketID returned.
//
// The check order is deliberate and fail-closed: resolve base, resolve quote, reject
// self-pair, reject network mismatch, reject duplicate. Any failure leaves the
// registry unchanged.
func (r *Registry) CreateMarket(m Market) (ids.ID, error) {
base, err := r.MustResolveEnabled(m.BaseAssetID)
if err != nil {
return ids.Empty, fmt.Errorf("market base side: %w", err)
}
quote, err := r.MustResolveEnabled(m.QuoteAssetID)
if err != nil {
return ids.Empty, fmt.Errorf("market quote side: %w", err)
}
if m.BaseAssetID == m.QuoteAssetID {
return ids.Empty, ErrSameAsset
}
if m.NetworkID != base.NetworkID || m.NetworkID != quote.NetworkID {
return ids.Empty, fmt.Errorf("%w: market=%d base=%d quote=%d",
ErrNetworkMismatch, m.NetworkID, base.NetworkID, quote.NetworkID)
}
id := m.ID()
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.markets[id]; exists {
return ids.Empty, fmt.Errorf("%w: %s", ErrDuplicateMarket, id)
}
if r.markets == nil {
r.markets = make(map[ids.ID]Market)
}
r.markets[id] = m
return id, nil
}
// ResolveMarket returns the market for a MarketID, ok=false if not registered.
func (r *Registry) ResolveMarket(id ids.ID) (Market, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.markets[id]
return m, ok
}
// EachMarket iterates registered markets. Used by the startup gate to audit that
// every enabled market references only real, registered assets.
func (r *Registry) EachMarket(fn func(id ids.ID, m Market)) {
r.mu.RLock()
defer r.mu.RUnlock()
for id, m := range r.markets {
fn(id, m)
}
}
+288
View File
@@ -0,0 +1,288 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"errors"
"fmt"
"strings"
"sync"
"github.com/luxfi/ids"
)
// RiskTier is an operator-assigned risk classification for an asset. It is metadata
// (it does not gate admissibility — only REALITY gates admissibility), used by the
// venue to set conservative caps on newer/lower-tier assets. Tier0 is the safest
// (the primary-network native coin and blue-chip stables); higher numbers are
// riskier.
type RiskTier uint8
const (
RiskTier0 RiskTier = 0 // primary-network native + canonical stables
RiskTier1 RiskTier = 1 // established, audited tokens
RiskTier2 RiskTier = 2 // newer / lower-liquidity tokens
RiskTier3 RiskTier = 3 // experimental — tightest caps
)
// Valid bounds the tier to the defined range.
func (t RiskTier) Valid() bool { return t <= RiskTier3 }
// Asset is a single registered, real, on-chain asset. Its AssetID is DERIVED from
// the canonical fields (networkID, chainID, assetKind, canonicalRef) — never
// supplied independently — so the identity and the description can never disagree.
//
// This is the AssetRegistry record the task specifies:
//
// AssetRegistry{networkID, chainID, assetKind, canonicalRef, decimals, symbol, name, enabled, riskTier}
type Asset struct {
// NetworkID is the Lux network this asset lives on (1 mainnet, 2 testnet, ...).
NetworkID uint32 `json:"networkID"`
// ChainID is the SOURCE chain id: the C-Chain id for EVM_NATIVE/ERC20, the
// UTXO source-chain id (X-Chain) for UTXO.
ChainID ids.ID `json:"chainID"`
// Kind is the asset class (EVM_NATIVE | ERC20 | UTXO).
Kind AssetKind `json:"assetKind"`
// CanonicalRef is the on-chain reference bytes: the 20-byte ERC-20 address, the
// 20-byte native marker, or the 32-byte UTXO assetID. Hex-encoded in JSON.
CanonicalRef Bytes `json:"canonicalRef"`
// Decimals is the asset's on-chain decimal precision (ERC-20 decimals(),
// native/UTXO denomination). Verified against chain state for ERC-20.
Decimals uint8 `json:"decimals"`
// Symbol/Name are display metadata. They are NOT identity (the AssetID does not
// hash them) and they are NOT a ticker-id — an asset is keyed by AssetID, never
// by Symbol. They exist for the UI only.
Symbol string `json:"symbol"`
Name string `json:"name"`
// Enabled gates whether this asset (and markets over it) may trade. A disabled
// asset stays registered (auditable) but admits no markets.
Enabled bool `json:"enabled"`
// RiskTier is the operator risk classification (caps input, not admissibility).
RiskTier RiskTier `json:"riskTier"`
}
// ID returns the canonical AssetID for this record, derived from its real fields.
func (a Asset) ID() (ids.ID, error) {
return DeriveAssetID(a.NetworkID, a.ChainID, a.Kind, a.CanonicalRef)
}
// validateShape checks the record is internally well-formed BEFORE any chain I/O:
// valid kind, valid ref shape, valid tier, sane decimals. It does NOT check reality
// (that is VerifyOnChain). A record that fails the shape check can never be real, so
// we reject it early and cheaply.
func (a Asset) validateShape() error {
if !a.Kind.Valid() {
return ErrInvalidKind
}
if !a.RiskTier.Valid() {
return fmt.Errorf("registry: risk tier %d out of range", uint8(a.RiskTier))
}
// canonicalRefFor enforces per-kind ref shape (length, non-zero, native marker).
if _, err := canonicalRefFor(a.Kind, a.CanonicalRef); err != nil {
return err
}
if a.ChainID == ids.Empty {
return ErrEmptyChainID
}
// A symbol that looks like an asset IDENTITY (rather than a display label) is a
// red flag for the ASCII-ticker-as-id anti-pattern; we keep symbols as pure
// display by forbidding the obviously-id-shaped ones.
if looksLikeASCIITickerID(a.Symbol) {
return fmt.Errorf("registry: symbol %q looks like an ASCII-ticker asset id; symbols are display-only, assets are keyed by AssetID", a.Symbol)
}
return nil
}
// ChainVerifier proves an asset is REAL on its target network by reading live chain
// state. It is injected so the registry's admission logic is identical whether it
// runs offline in CI (a verifier backed by JSON-RPC against the target net) or at
// node startup (a verifier backed by the local chain state). Tests inject a verifier
// backed by an in-memory chain snapshot — the rejection paths are exercised for
// real, never stubbed to always-true.
type ChainVerifier interface {
// VerifyERC20 confirms a contract exists at addr on the given C-Chain of the
// given network (code length > 0) and returns its on-chain decimals(). An error
// means the token is not real there (no code, wrong chain, RPC failure) and the
// asset MUST be refused.
VerifyERC20(networkID uint32, cChainID ids.ID, addr []byte) (decimals uint8, err error)
// VerifyEVMNative confirms the C-Chain itself is the expected native chain for
// the network (chainID matches) and returns the native decimals.
VerifyEVMNative(networkID uint32, cChainID ids.ID) (decimals uint8, err error)
// VerifyUTXOAsset confirms a UTXO assetID exists on the given source chain of
// the given network and returns its denomination (decimals). An error means the
// asset does not exist there and MUST be refused.
VerifyUTXOAsset(networkID uint32, sourceChainID ids.ID, assetID ids.ID) (decimals uint8, err error)
}
// VerifyOnChain proves the asset is real against live chain state via v, and that
// its declared decimals match what the chain reports. This is the gate that makes
// "synthetic asset" unrepresentable: a synthetic asset has nothing for the verifier
// to find, so VerifyOnChain returns an error and the asset is never admitted.
func (a Asset) VerifyOnChain(v ChainVerifier) error {
if err := a.validateShape(); err != nil {
return err
}
var onChainDecimals uint8
var err error
switch a.Kind {
case AssetKindERC20:
onChainDecimals, err = v.VerifyERC20(a.NetworkID, a.ChainID, a.CanonicalRef)
case AssetKindEVMNative:
onChainDecimals, err = v.VerifyEVMNative(a.NetworkID, a.ChainID)
case AssetKindUTXO:
var assetID ids.ID
copy(assetID[:], a.CanonicalRef)
onChainDecimals, err = v.VerifyUTXOAsset(a.NetworkID, a.ChainID, assetID)
default:
return ErrInvalidKind
}
if err != nil {
return fmt.Errorf("registry: asset %s not real on network %d: %w", a.Kind, a.NetworkID, err)
}
if onChainDecimals != a.Decimals {
return fmt.Errorf("registry: declared decimals %d != on-chain decimals %d for %s asset",
a.Decimals, onChainDecimals, a.Kind)
}
return nil
}
var (
// ErrUnknownAsset is returned when a market (or any caller) references an
// AssetID that is not registered — the structural form of "synthetic asset".
ErrUnknownAsset = errors.New("registry: asset is not registered (unknown/synthetic)")
// ErrAssetDisabled is returned when a referenced asset is registered but disabled.
ErrAssetDisabled = errors.New("registry: asset is registered but disabled")
// ErrKindNotAllowed is returned when an asset's kind is not in the active
// dexAllowedAssetKinds policy.
ErrKindNotAllowed = errors.New("registry: asset kind not in allowed-kinds policy")
// ErrDuplicateAsset is returned when registering an AssetID that already exists.
ErrDuplicateAsset = errors.New("registry: asset already registered")
)
// Registry is the in-memory set of admitted real assets, keyed by canonical AssetID.
// It is the authority every admission decision consults. It is concurrency-safe; the
// hot path (Resolve) is a read under RLock.
type Registry struct {
mu sync.RWMutex
// allowedKinds is the active dexAllowedAssetKinds policy. An asset whose kind is
// not present is refused at registration even if it is real — the policy is a
// second, narrower gate on top of reality.
allowedKinds map[AssetKind]struct{}
byID map[ids.ID]Asset
// markets holds admitted trading pairs, each pinned to two registered assets.
markets map[ids.ID]Market
}
// New constructs an empty Registry permitting the given asset kinds. With no kinds
// it admits nothing (fail-closed). The canonical production policy is all three:
// {EVM_NATIVE, ERC20, UTXO}.
func New(allowed ...AssetKind) *Registry {
r := &Registry{
allowedKinds: make(map[AssetKind]struct{}, len(allowed)),
byID: make(map[ids.ID]Asset),
markets: make(map[ids.ID]Market),
}
for _, k := range allowed {
if k.Valid() {
r.allowedKinds[k] = struct{}{}
}
}
return r
}
// AllowsKind reports whether the active policy admits k.
func (r *Registry) AllowsKind(k AssetKind) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.allowedKinds[k]
return ok
}
// Register admits a single asset after proving it is (a) well-formed, (b) of an
// allowed kind, and (c) REAL on its target network via v. It is the ONLY way an
// asset enters the registry — there is no path that admits an unverified asset. The
// derived AssetID is returned so callers can pin markets to it.
func (r *Registry) Register(a Asset, v ChainVerifier) (ids.ID, error) {
if v == nil {
return ids.Empty, errors.New("registry: Register requires a ChainVerifier (refusing to admit an unverified asset)")
}
if err := a.validateShape(); err != nil {
return ids.Empty, err
}
if !r.AllowsKind(a.Kind) {
return ids.Empty, fmt.Errorf("%w: %s", ErrKindNotAllowed, a.Kind)
}
if err := a.VerifyOnChain(v); err != nil {
return ids.Empty, err
}
id, err := a.ID()
if err != nil {
return ids.Empty, err
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.byID[id]; exists {
return ids.Empty, fmt.Errorf("%w: %s", ErrDuplicateAsset, id)
}
r.byID[id] = a
return id, nil
}
// Resolve returns the registered asset for an AssetID. ok is false for any
// unregistered (i.e. synthetic) id — this is the predicate the market gate and the
// startup gate use to refuse synthetic references.
func (r *Registry) Resolve(id ids.ID) (Asset, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
a, ok := r.byID[id]
return a, ok
}
// MustResolveEnabled returns the asset for id, erroring if it is unknown
// (synthetic) or disabled. It is the strict resolver the market gate uses.
func (r *Registry) MustResolveEnabled(id ids.ID) (Asset, error) {
a, ok := r.Resolve(id)
if !ok {
return Asset{}, fmt.Errorf("%w: %s", ErrUnknownAsset, id)
}
if !a.Enabled {
return Asset{}, fmt.Errorf("%w: %s", ErrAssetDisabled, id)
}
return a, nil
}
// Len reports the number of registered assets.
func (r *Registry) Len() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.byID)
}
// Each iterates registered assets (id, asset) in unspecified order. Used by the
// startup gate to audit every registered asset and by the manifest exporter.
func (r *Registry) Each(fn func(id ids.ID, a Asset)) {
r.mu.RLock()
defer r.mu.RUnlock()
for id, a := range r.byID {
fn(id, a)
}
}
// Bytes is a []byte that marshals to/from hex in JSON, used for canonicalRef so a
// manifest carries token addresses and assetIDs as 0x-hex rather than base64.
type Bytes []byte
func (b Bytes) MarshalText() ([]byte, error) {
return []byte(toHex(b)), nil
}
func (b *Bytes) UnmarshalText(t []byte) error {
v, err := fromHex(strings.TrimSpace(string(t)))
if err != nil {
return err
}
*b = v
return nil
}
+299
View File
@@ -0,0 +1,299 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"bytes"
"errors"
"testing"
"github.com/luxfi/ids"
)
const mainnetID uint32 = 1
// realERC20 builds a registered+verifiable ERC-20 asset and seeds it on the fake
// chain so Register succeeds. Returns the asset and its derived id.
func realERC20(t *testing.T, fc *fakeChain, cChain ids.ID, addr []byte, sym string) (Asset, ids.ID) {
t.Helper()
fc.seedERC20(mainnetID, cChain, addr, 6)
a := Asset{
NetworkID: mainnetID,
ChainID: cChain,
Kind: AssetKindERC20,
CanonicalRef: addr,
Decimals: 6,
Symbol: sym,
Name: sym + " token",
Enabled: true,
RiskTier: RiskTier1,
}
id, err := a.ID()
if err != nil {
t.Fatalf("derive id: %v", err)
}
return a, id
}
// --- (T1) TestNoSyntheticAssetCanRegister ----------------------------------
//
// A synthetic asset — one with no real on-chain object behind it — MUST be refused
// at registration. We attempt to register an ERC-20 whose address was never seeded
// on the fake chain (so it does not exist there), plus the structurally-impossible
// "D-native" / synthetic shapes, and assert every one is rejected.
func TestNoSyntheticAssetCanRegister(t *testing.T) {
fc := newFakeChain()
cChain := ids.GenerateTestID()
reg := New(AssetKindEVMNative, AssetKindERC20, AssetKindUTXO)
// (a) ERC-20 that does not exist on-chain (never seeded) — must be refused.
ghost := Asset{
NetworkID: mainnetID,
ChainID: cChain,
Kind: AssetKindERC20,
CanonicalRef: addr20(0x42),
Decimals: 18,
Symbol: "GHOST",
Name: "Ghost token",
Enabled: true,
RiskTier: RiskTier2,
}
if _, err := reg.Register(ghost, fc); err == nil {
t.Fatal("synthetic ERC-20 (no on-chain code) was registered; must be refused")
}
// (b) UTXO asset that does not exist on the source chain — must be refused.
src := ids.GenerateTestID()
phantomUTXO := Asset{
NetworkID: mainnetID,
ChainID: src,
Kind: AssetKindUTXO,
CanonicalRef: idBytes(ids.GenerateTestID()),
Decimals: 9,
Symbol: "PUTXO",
Name: "Phantom UTXO",
Enabled: true,
RiskTier: RiskTier2,
}
if _, err := reg.Register(phantomUTXO, fc); err == nil {
t.Fatal("synthetic UTXO (not on source chain) was registered; must be refused")
}
// (c) An asset whose declared kind is the invalid/zero kind (the closest a
// caller can get to a "D-native" synthetic class) — must be refused as an
// invalid kind, never admitted.
dNative := Asset{
NetworkID: mainnetID,
ChainID: cChain,
Kind: AssetKindInvalid, // there is no synthetic/D-native kind
CanonicalRef: addr20(0x01),
Decimals: 18,
Symbol: "DNAT",
Enabled: true,
}
if _, err := reg.Register(dNative, fc); !errors.Is(err, ErrInvalidKind) {
t.Fatalf("invalid/D-native kind admitted or wrong error: %v", err)
}
// (d) A REAL ERC-20 (seeded) registers fine — proves the verifier is not
// rejecting everything (the rejections above are genuine, not blanket).
real, _ := realERC20(t, fc, cChain, addr20(0x99), "USDC")
if _, err := reg.Register(real, fc); err != nil {
t.Fatalf("real seeded ERC-20 should register: %v", err)
}
if reg.Len() != 1 {
t.Fatalf("only the real asset should be registered, got %d", reg.Len())
}
}
// --- (T2) TestNoSyntheticMarketCanStart ------------------------------------
//
// A market MUST fail creation unless BOTH sides resolve to a registered, real,
// enabled asset, and the fail-closed startup gate MUST refuse to start a chain whose
// enabled market references a synthetic asset.
func TestNoSyntheticMarketCanStart(t *testing.T) {
fc := newFakeChain()
cChain := ids.GenerateTestID()
reg := New(AssetKindEVMNative, AssetKindERC20, AssetKindUTXO)
// Register exactly one real asset (the quote). The base will be synthetic.
usdc, usdcID := realERC20(t, fc, cChain, addr20(0x10), "USDC")
if _, err := reg.Register(usdc, fc); err != nil {
t.Fatalf("register quote: %v", err)
}
// Derive an AssetID for a base that was NEVER registered (synthetic).
syntheticBaseID, err := DeriveAssetID(mainnetID, cChain, AssetKindERC20, addr20(0x7e))
if err != nil {
t.Fatalf("derive synthetic base id: %v", err)
}
// (a) CreateMarket must refuse because the base side does not resolve.
_, err = reg.CreateMarket(Market{
NetworkID: mainnetID,
BaseAssetID: syntheticBaseID,
QuoteAssetID: usdcID,
Enabled: true,
})
if !errors.Is(err, ErrUnknownAsset) {
t.Fatalf("market over synthetic base should be refused with ErrUnknownAsset, got: %v", err)
}
// (b) A market over two REAL assets is created fine.
lux, luxID := realERC20(t, fc, cChain, addr20(0x20), "WLUX")
if _, err := reg.Register(lux, fc); err != nil {
t.Fatalf("register base: %v", err)
}
mid, err := reg.CreateMarket(Market{
NetworkID: mainnetID,
BaseAssetID: luxID,
QuoteAssetID: usdcID,
Enabled: true,
})
if err != nil {
t.Fatalf("real market should be created: %v", err)
}
// (c) The startup gate passes for the all-real registry.
if err := RefuseUnderSyntheticConfig(NetworkClassMainnet, DefaultDexAssetPolicy(), reg, nil); err != nil {
t.Fatalf("startup gate should pass for all-real registry: %v", err)
}
// (d) Now smuggle a synthetic market straight into the registry's market map
// (simulating a corrupted/forced config that bypassed CreateMarket) and
// assert the startup gate REFUSES to start. This proves the gate is a real
// second line of defense, not just CreateMarket's pre-check.
reg.mu.Lock()
reg.markets[ids.GenerateTestID()] = Market{
NetworkID: mainnetID,
BaseAssetID: syntheticBaseID, // unregistered
QuoteAssetID: usdcID,
Enabled: true,
}
reg.mu.Unlock()
if err := RefuseUnderSyntheticConfig(NetworkClassMainnet, DefaultDexAssetPolicy(), reg, nil); !errors.Is(err, ErrEnabledMarketUnknownAsset) {
t.Fatalf("startup gate must refuse a synthetic enabled market, got: %v", err)
}
_ = mid
}
// --- (T3) TestERC20AssetIDUsesRealTokenAddress -----------------------------
//
// The ERC-20 AssetID MUST derive from the REAL token contract address — change the
// address, the id changes; keep the address, the id is stable regardless of display
// metadata (symbol/name/decimals are NOT in the identity preimage).
func TestERC20AssetIDUsesRealTokenAddress(t *testing.T) {
cChain := ids.GenerateTestID()
addrA := addr20(0x11)
addrB := addr20(0x22)
idA, err := DeriveAssetID(mainnetID, cChain, AssetKindERC20, addrA)
if err != nil {
t.Fatalf("derive A: %v", err)
}
idB, err := DeriveAssetID(mainnetID, cChain, AssetKindERC20, addrB)
if err != nil {
t.Fatalf("derive B: %v", err)
}
if idA == idB {
t.Fatal("different token addresses produced the same AssetID — id is not bound to the address")
}
// Same address, different display metadata => SAME id (identity is the address,
// not the ticker). This is the anti-ASCII-ticker property.
aDisplay1 := Asset{NetworkID: mainnetID, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: addrA, Decimals: 6, Symbol: "USDC", Name: "USD Coin", Enabled: true}
aDisplay2 := Asset{NetworkID: mainnetID, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: addrA, Decimals: 18, Symbol: "ZZZ", Name: "Renamed", Enabled: true}
id1, _ := aDisplay1.ID()
id2, _ := aDisplay2.ID()
if id1 != id2 {
t.Fatal("AssetID changed when only display metadata changed — id must be bound to the real address, not the ticker")
}
if id1 != idA {
t.Fatal("Asset.ID() disagrees with DeriveAssetID for the same real address")
}
// The id is genuinely a function of the address bytes: re-derive directly and
// confirm the address participates (a hand-rolled preimage with the address
// reproduces the id, a preimage with a different address does not).
if got, _ := DeriveAssetID(mainnetID, cChain, AssetKindERC20, append([]byte(nil), addrA...)); got != idA {
t.Fatal("AssetID not reproducible from the same real address bytes")
}
// Different network or different C-Chain also changes the id (the address is
// scoped to its chain — the same token address on two chains is two assets).
otherChain := ids.GenerateTestID()
if got, _ := DeriveAssetID(mainnetID, otherChain, AssetKindERC20, addrA); got == idA {
t.Fatal("same address on a different C-Chain produced the same id; chain must be in the preimage")
}
if got, _ := DeriveAssetID(2, cChain, AssetKindERC20, addrA); got == idA {
t.Fatal("same address on a different network produced the same id; networkID must be in the preimage")
}
}
// --- (T4) TestUTXOAssetIDUsesRealUTXOAssetID -------------------------------
//
// The UTXO AssetID MUST derive from the REAL source-chain assetID, and a UTXO asset
// and an ERC-20 that share the same 32/20-byte low bytes MUST NOT collide (the kind
// is domain-separated in the preimage).
func TestUTXOAssetIDUsesRealUTXOAssetID(t *testing.T) {
src := ids.GenerateTestID()
realAssetID := ids.GenerateTestID()
otherAssetID := ids.GenerateTestID()
idReal, err := DeriveAssetID(mainnetID, src, AssetKindUTXO, idBytes(realAssetID))
if err != nil {
t.Fatalf("derive real utxo: %v", err)
}
idOther, err := DeriveAssetID(mainnetID, src, AssetKindUTXO, idBytes(otherAssetID))
if err != nil {
t.Fatalf("derive other utxo: %v", err)
}
if idReal == idOther {
t.Fatal("different UTXO assetIDs produced the same DEX AssetID")
}
// Reproducible from the real assetID bytes.
if got, _ := DeriveAssetID(mainnetID, src, AssetKindUTXO, idBytes(realAssetID)); got != idReal {
t.Fatal("UTXO AssetID not reproducible from the real source assetID")
}
// Domain separation: an ERC-20 whose 20-byte address is the first 20 bytes of
// the UTXO assetID must NOT collide with the UTXO asset.
clashAddr := idBytes(realAssetID)[:20]
idERC20, err := DeriveAssetID(mainnetID, src, AssetKindERC20, clashAddr)
if err != nil {
t.Fatalf("derive clashing erc20: %v", err)
}
if idERC20 == idReal {
t.Fatal("ERC-20 and UTXO with overlapping bytes collided; kind must domain-separate the preimage")
}
// Through the registry: a real seeded UTXO registers and its stored id matches
// the derivation from the real assetID.
fc := newFakeChain()
fc.seedUTXO(mainnetID, src, realAssetID, 9)
reg := New(AssetKindUTXO)
a := Asset{
NetworkID: mainnetID,
ChainID: src,
Kind: AssetKindUTXO,
CanonicalRef: idBytes(realAssetID),
Decimals: 9,
Symbol: "XAV",
Name: "X-Chain asset",
Enabled: true,
RiskTier: RiskTier1,
}
gotID, err := reg.Register(a, fc)
if err != nil {
t.Fatalf("real UTXO should register: %v", err)
}
if gotID != idReal {
t.Fatal("registry stored a UTXO id that is not the derivation from the real source assetID")
}
if !bytes.Equal(idBytes(gotID), idBytes(idReal)) {
t.Fatal("registry UTXO id bytes mismatch")
}
}
+228
View File
@@ -0,0 +1,228 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package rpcverify is the REAL, network-backed ChainVerifier used by CI (and any
// out-of-process validator) to prove a manifest's assets exist on the TARGET net
// before a deploy. It is kept out of the registry package proper so the
// consensus-path registry carries no JSON-RPC / EVM-client dependency; only the
// offline validator links it.
//
// For an ERC-20 it confirms: the C-Chain's eth_chainId matches the manifest's
// declared EVMChainID, the contract has code (eth_getCode length > 0), and a static
// decimals() call returns a value (which the registry then cross-checks against the
// manifest's declared decimals). For EVM_NATIVE it confirms the chainID. UTXO
// verification is delegated to the X-Chain avm.getAssetDescription endpoint.
package rpcverify
import (
"context"
"encoding/json"
"fmt"
"math/big"
"net/http"
"strings"
"time"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/ethclient"
"github.com/luxfi/ids"
gethereum "github.com/luxfi/geth"
)
// Verifier proves assets real against a live network: a C-Chain EVM RPC (for
// EVM_NATIVE/ERC20) and a P-Chain + X-Chain API base (for the C-Chain consensus-id
// confirm and UTXO asset descriptions).
type Verifier struct {
eth *ethclient.Client
apiBase string // e.g. https://api.lux.network — used for platform/avm JSON-RPC
httpc *http.Client
nativeDec uint8
timeout time.Duration
}
// New dials the C-Chain EVM RPC and records the node API base for P/X queries.
// evmRPC is the full C-Chain RPC URL (…/ext/bc/C/rpc); apiBase is the node root
// (…) used to reach /ext/P and /ext/bc/X. nativeDecimals is the C-Chain native
// coin's decimals (18 for LUX).
func New(ctx context.Context, evmRPC, apiBase string, nativeDecimals uint8) (*Verifier, error) {
ec, err := ethclient.DialContext(ctx, evmRPC)
if err != nil {
return nil, fmt.Errorf("rpcverify: dial C-Chain RPC %s: %w", evmRPC, err)
}
return &Verifier{
eth: ec,
apiBase: strings.TrimRight(apiBase, "/"),
httpc: &http.Client{Timeout: 20 * time.Second},
nativeDec: nativeDecimals,
timeout: 20 * time.Second,
}, nil
}
// ConfirmCChain implements registry.CChainConfirmer: it confirms the live C-Chain's
// eth_chainId equals the manifest's EVMChainID and the live C-Chain consensus id
// equals the manifest's CChainID (queried from the P-Chain). A mismatch on either
// means the validator is pointed at the wrong network — the manifest must NOT be
// admitted.
func (v *Verifier) ConfirmCChain(networkID uint32, evmChainID uint64, cChainID ids.ID) error {
ctx, cancel := context.WithTimeout(context.Background(), v.timeout)
defer cancel()
got, err := v.eth.ChainID(ctx)
if err != nil {
return fmt.Errorf("eth_chainId: %w", err)
}
if want := new(big.Int).SetUint64(evmChainID); got.Cmp(want) != 0 {
return fmt.Errorf("eth_chainId mismatch: RPC=%s manifest=%s (wrong network)", got, want)
}
liveC, err := v.resolveCChainID(ctx, networkID)
if err != nil {
return fmt.Errorf("resolve live C-Chain id: %w", err)
}
if liveC != cChainID {
return fmt.Errorf("C-Chain consensus id mismatch: live=%s manifest=%s", liveC, cChainID)
}
return nil
}
// VerifyERC20 confirms a contract has code at addr and returns its decimals().
func (v *Verifier) VerifyERC20(_ uint32, _ ids.ID, addr []byte) (uint8, error) {
ctx, cancel := context.WithTimeout(context.Background(), v.timeout)
defer cancel()
a := common.BytesToAddress(addr)
code, err := v.eth.CodeAt(ctx, a, nil)
if err != nil {
return 0, fmt.Errorf("eth_getCode(%s): %w", a.Hex(), err)
}
if len(code) == 0 {
return 0, fmt.Errorf("no contract code at %s (not a real ERC-20 on this net)", a.Hex())
}
dec, err := v.callDecimals(ctx, a)
if err != nil {
return 0, fmt.Errorf("decimals() at %s: %w", a.Hex(), err)
}
return dec, nil
}
// VerifyEVMNative confirms the C-Chain is reachable (chainID readable) and returns
// the native decimals. (The chainID equality is enforced once in ConfirmCChain.)
func (v *Verifier) VerifyEVMNative(_ uint32, _ ids.ID) (uint8, error) {
ctx, cancel := context.WithTimeout(context.Background(), v.timeout)
defer cancel()
if _, err := v.eth.ChainID(ctx); err != nil {
return 0, fmt.Errorf("native chainID: %w", err)
}
return v.nativeDec, nil
}
// VerifyUTXOAsset confirms a UTXO assetID exists via the X-Chain
// avm.getAssetDescription endpoint and returns its denomination.
func (v *Verifier) VerifyUTXOAsset(_ uint32, _ ids.ID, assetID ids.ID) (uint8, error) {
ctx, cancel := context.WithTimeout(context.Background(), v.timeout)
defer cancel()
var out struct {
Result struct {
Denomination jsonUint8 `json:"denomination"`
} `json:"result"`
Error *rpcError `json:"error"`
}
if err := v.jsonRPC(ctx, "/ext/bc/X", "avm.getAssetDescription",
map[string]any{"assetID": assetID.String()}, &out); err != nil {
return 0, err
}
if out.Error != nil {
return 0, fmt.Errorf("avm.getAssetDescription: %s (asset not on this net)", out.Error.Message)
}
return uint8(out.Result.Denomination), nil
}
// resolveCChainID asks the P-Chain for the blockchain whose name is "C" and returns
// its id — the live, authoritative C-Chain consensus id for this net.
func (v *Verifier) resolveCChainID(ctx context.Context, _ uint32) (ids.ID, error) {
var out struct {
Result struct {
Blockchains []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"blockchains"`
} `json:"result"`
Error *rpcError `json:"error"`
}
if err := v.jsonRPC(ctx, "/ext/P", "platform.getBlockchains", map[string]any{}, &out); err != nil {
return ids.Empty, err
}
if out.Error != nil {
return ids.Empty, fmt.Errorf("platform.getBlockchains: %s", out.Error.Message)
}
for _, b := range out.Result.Blockchains {
// The Lux P-Chain names the EVM chain "C-Chain" (older nets used "C").
if b.Name == "C-Chain" || b.Name == "C" {
return ids.FromString(b.ID)
}
}
return ids.Empty, fmt.Errorf("no C-Chain in platform.getBlockchains")
}
// decimalsSelector is the 4-byte selector of ERC-20 decimals() — keccak256("decimals()")[:4].
var decimalsSelector = []byte{0x31, 0x3c, 0xe5, 0x67}
// callDecimals performs a static eth_call to decimals() and decodes the uint8.
func (v *Verifier) callDecimals(ctx context.Context, addr common.Address) (uint8, error) {
out, err := v.eth.CallContract(ctx, gethereum.CallMsg{To: &addr, Data: decimalsSelector}, nil)
if err != nil {
return 0, err
}
if len(out) == 0 {
return 0, fmt.Errorf("decimals() returned empty (not ERC-20 compliant)")
}
// decimals() returns a uint8 right-aligned in a 32-byte word.
return out[len(out)-1], nil
}
// jsonRPC posts a single JSON-RPC 2.0 call to apiBase+path and decodes into out.
func (v *Verifier) jsonRPC(ctx context.Context, path, method string, params any, out any) error {
body, err := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": method, "params": params,
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, v.apiBase+path, strings.NewReader(string(body)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := v.httpc.Do(req)
if err != nil {
return fmt.Errorf("%s %s: %w", method, v.apiBase+path, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: HTTP %d", method, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// jsonUint8 accepts either a JSON number or a numeric string for a uint8 field
// (some avm responses stringify the denomination).
type jsonUint8 uint8
func (j *jsonUint8) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
var n uint64
if _, err := fmt.Sscan(s, &n); err != nil {
return fmt.Errorf("rpcverify: bad uint8 %q: %w", s, err)
}
if n > 255 {
return fmt.Errorf("rpcverify: denomination %d out of uint8 range", n)
}
*j = jsonUint8(n)
return nil
}
+141
View File
@@ -0,0 +1,141 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"fmt"
"github.com/luxfi/ids"
)
// RuntimeVerifier is the node-side ChainVerifier used at VM Initialize. It is a REAL
// check, not a stub: it binds a manifest's DECLARED chain identities to the node's
// ACTUAL running chain ids (the consensus-supplied C-Chain and X-Chain ids), so a
// manifest built for the wrong network — or one that points an "ERC20" at a chain
// the node is not running — is REFUSED at startup. It returns the manifest's
// shape-validated decimals only AFTER that identity binding holds.
//
// Division of proof (deliberate, documented):
//
// - The NODE (this verifier) proves chain-IDENTITY binding + structure + policy at
// boot, with NO external RPC dependency (so a validator can start even if a remote
// RPC is briefly unreachable). It catches a wrong-net / wrong-chain manifest.
// - CI (the rpcverify.Verifier + the validate-asset-manifests workflow) proves each
// token EXISTS on the live target net (eth_getCode length > 0, decimals(), UTXO
// avm.getAssetDescription) BEFORE the manifest artifact ships. It catches a
// fabricated/typo'd token address.
//
// Both are real, neither is "always true". A manifest that passes CI (reality) and the
// node's RuntimeVerifier (identity + policy) is admissible; either failing refuses it.
type RuntimeVerifier struct {
// NetworkID is the node's running network id (runtime.Runtime.NetworkID).
NetworkID uint32
// CChainID is the node's running C-Chain consensus id (runtime.Runtime.CChainID).
// EVM_NATIVE / ERC20 assets must be rooted here.
CChainID ids.ID
// XChainID is the node's running X-Chain id (runtime.Runtime.XChainID). UTXO assets
// are accepted only from a UTXO source chain the node actually runs; when XChainID
// is set, a UTXO asset rooted off it is refused.
XChainID ids.ID
// declaredDecimals is populated by the manifest loader from the shape-validated
// entries so the verifier can return the asset's decimals after the identity bind
// without re-reading the file. Keyed by canonical AssetID.
declaredDecimals map[ids.ID]uint8
}
// NewRuntimeVerifier builds a RuntimeVerifier bound to the node's running chain ids and
// pre-loaded with the manifest's shape-validated decimals (so Register's decimals
// cross-check compares the manifest against itself consistently, and the identity bind
// is what gates admission). m must already have passed validateShape.
func NewRuntimeVerifier(networkID uint32, cChainID, xChainID ids.ID, m *Manifest) (*RuntimeVerifier, error) {
if m == nil {
return nil, fmt.Errorf("registry: RuntimeVerifier requires a manifest")
}
if m.NetworkID != networkID {
return nil, fmt.Errorf("registry: manifest networkID %d != running network %d (wrong-net manifest)", m.NetworkID, networkID)
}
if cChainID == ids.Empty {
return nil, fmt.Errorf("registry: running C-Chain id is empty (cannot bind manifest)")
}
if m.CChainID != cChainID {
return nil, fmt.Errorf("registry: manifest cChainID %s != running C-Chain %s (wrong-chain manifest)", m.CChainID, cChainID)
}
rv := &RuntimeVerifier{
NetworkID: networkID,
CChainID: cChainID,
XChainID: xChainID,
declaredDecimals: make(map[ids.ID]uint8, len(m.Assets)),
}
for _, a := range m.Assets {
id, err := a.ID()
if err != nil {
return nil, fmt.Errorf("registry: manifest asset id: %w", err)
}
rv.declaredDecimals[id] = a.Decimals
}
return rv, nil
}
// ConfirmCChain implements CChainConfirmer: the manifest's network + C-Chain id must
// equal the node's running ids. (EVMChainID is RPC-checked by CI; at boot we bind the
// consensus C-Chain id, which is the authoritative cross-chain identity.)
func (rv *RuntimeVerifier) ConfirmCChain(networkID uint32, _ uint64, cChainID ids.ID) error {
if networkID != rv.NetworkID {
return fmt.Errorf("manifest network %d != running %d", networkID, rv.NetworkID)
}
if cChainID != rv.CChainID {
return fmt.Errorf("manifest C-Chain %s != running %s", cChainID, rv.CChainID)
}
return nil
}
func (rv *RuntimeVerifier) decimalsFor(networkID uint32, chainID ids.ID, kind AssetKind, ref []byte) (uint8, error) {
id, err := DeriveAssetID(networkID, chainID, kind, ref)
if err != nil {
return 0, err
}
d, ok := rv.declaredDecimals[id]
if !ok {
// The asset is not in the manifest the verifier was built from — it cannot be
// admitted at the node (reality for it was never CI-proven).
return 0, fmt.Errorf("asset %s not in the runtime manifest", id)
}
return d, nil
}
// VerifyERC20 binds the ERC-20 to the node's running C-Chain. Per-token code existence
// is the CI gate; here the asset must be on the chain the node actually runs.
func (rv *RuntimeVerifier) VerifyERC20(networkID uint32, cChainID ids.ID, addr []byte) (uint8, error) {
if networkID != rv.NetworkID {
return 0, fmt.Errorf("ERC20 network %d != running %d", networkID, rv.NetworkID)
}
if cChainID != rv.CChainID {
return 0, fmt.Errorf("ERC20 rooted at C-Chain %s but node runs %s", cChainID, rv.CChainID)
}
return rv.decimalsFor(networkID, cChainID, AssetKindERC20, addr)
}
// VerifyEVMNative binds the native coin to the node's running C-Chain.
func (rv *RuntimeVerifier) VerifyEVMNative(networkID uint32, cChainID ids.ID) (uint8, error) {
if networkID != rv.NetworkID {
return 0, fmt.Errorf("EVM_NATIVE network %d != running %d", networkID, rv.NetworkID)
}
if cChainID != rv.CChainID {
return 0, fmt.Errorf("EVM_NATIVE rooted at C-Chain %s but node runs %s", cChainID, rv.CChainID)
}
return rv.decimalsFor(networkID, cChainID, AssetKindEVMNative, EVMNativeMarker)
}
// VerifyUTXOAsset binds the UTXO asset to a UTXO source chain the node runs. When the
// node's XChainID is known, a UTXO asset rooted off it is refused (you cannot import a
// UTXO asset from a chain this node does not run).
func (rv *RuntimeVerifier) VerifyUTXOAsset(networkID uint32, sourceChainID ids.ID, assetID ids.ID) (uint8, error) {
if networkID != rv.NetworkID {
return 0, fmt.Errorf("UTXO network %d != running %d", networkID, rv.NetworkID)
}
if rv.XChainID != ids.Empty && sourceChainID != rv.XChainID {
return 0, fmt.Errorf("UTXO rooted at source chain %s but node X-Chain is %s", sourceChainID, rv.XChainID)
}
return rv.decimalsFor(networkID, sourceChainID, AssetKindUTXO, assetID[:])
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package registry
import (
"strings"
"testing"
"github.com/luxfi/ids"
)
// nativeManifest builds a minimal manifest with one EVM_NATIVE + one ERC-20 + one UTXO
// asset for the runtime-verifier tests.
func nativeManifest(networkID uint32, cChain, xChain, utxoAsset ids.ID, erc20 []byte) *Manifest {
return &Manifest{
Network: "testnet",
NetworkID: networkID,
EVMChainID: 96368,
CChainID: cChain,
Assets: []Asset{
{NetworkID: networkID, ChainID: cChain, Kind: AssetKindEVMNative, CanonicalRef: append([]byte(nil), EVMNativeMarker...), Decimals: 18, Symbol: "LUX", Name: "Lux", Enabled: true},
{NetworkID: networkID, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: erc20, Decimals: 6, Symbol: "USDC", Name: "USD Coin", Enabled: true},
{NetworkID: networkID, ChainID: xChain, Kind: AssetKindUTXO, CanonicalRef: utxoAsset[:], Decimals: 9, Symbol: "XAV", Name: "X asset", Enabled: true},
},
}
}
func TestRuntimeVerifier_BindsToRunningChainAndAdmitsRealEntries(t *testing.T) {
cChain := ids.GenerateTestID()
xChain := ids.GenerateTestID()
utxoAsset := ids.GenerateTestID()
erc20 := addr20(0x21)
const net uint32 = 2
m := nativeManifest(net, cChain, xChain, utxoAsset, erc20)
if err := m.validateShape(); err != nil {
t.Fatalf("manifest shape: %v", err)
}
rv, err := NewRuntimeVerifier(net, cChain, xChain, m)
if err != nil {
t.Fatalf("build runtime verifier: %v", err)
}
// Apply admits all three real entries and the gate passes.
reg := New(AssetKindEVMNative, AssetKindERC20, AssetKindUTXO)
if err := m.ApplyTo(reg, rv, DefaultDexAssetPolicy()); err != nil {
t.Fatalf("manifest must admit through runtime verifier: %v", err)
}
if reg.Len() != 3 {
t.Fatalf("expected 3 admitted assets, got %d", reg.Len())
}
}
func TestRuntimeVerifier_RefusesWrongCChain(t *testing.T) {
manifestC := ids.GenerateTestID()
runningC := ids.GenerateTestID() // node runs a different C-Chain
m := nativeManifest(2, manifestC, ids.GenerateTestID(), ids.GenerateTestID(), addr20(0x21))
if _, err := NewRuntimeVerifier(2, runningC, ids.Empty, m); err == nil {
t.Fatal("runtime verifier must refuse a manifest rooted at a C-Chain the node does not run")
} else if !strings.Contains(err.Error(), "wrong-chain") {
t.Fatalf("expected wrong-chain error, got: %v", err)
}
}
func TestRuntimeVerifier_RefusesWrongNetwork(t *testing.T) {
cChain := ids.GenerateTestID()
m := nativeManifest(2, cChain, ids.GenerateTestID(), ids.GenerateTestID(), addr20(0x21))
if _, err := NewRuntimeVerifier(1 /*running mainnet*/, cChain, ids.Empty, m); err == nil {
t.Fatal("runtime verifier must refuse a manifest whose networkID != running network")
}
}
func TestRuntimeVerifier_RefusesUTXOOffRunningXChain(t *testing.T) {
cChain := ids.GenerateTestID()
manifestX := ids.GenerateTestID()
runningX := ids.GenerateTestID() // node runs a different X-Chain
utxoAsset := ids.GenerateTestID()
m := nativeManifest(2, cChain, manifestX, utxoAsset, addr20(0x21))
rv, err := NewRuntimeVerifier(2, cChain, runningX, m)
if err != nil {
t.Fatalf("verifier build (C-Chain matches, X differs): %v", err)
}
// The C/native/ERC20 entries bind fine; the UTXO entry on the wrong X-Chain is
// refused at admission.
reg := New(AssetKindEVMNative, AssetKindERC20, AssetKindUTXO)
if err := m.ApplyTo(reg, rv, DefaultDexAssetPolicy()); err == nil {
t.Fatal("UTXO asset rooted off the node's X-Chain must be refused")
}
}
func TestRuntimeVerifier_RefusesAssetNotInManifest(t *testing.T) {
cChain := ids.GenerateTestID()
m := nativeManifest(2, cChain, ids.GenerateTestID(), ids.GenerateTestID(), addr20(0x21))
rv, err := NewRuntimeVerifier(2, cChain, ids.Empty, m)
if err != nil {
t.Fatalf("verifier build: %v", err)
}
// An ERC-20 the verifier was NOT built from (a different address) must be refused:
// its reality was never CI-proven for this manifest.
other := Asset{NetworkID: 2, ChainID: cChain, Kind: AssetKindERC20, CanonicalRef: addr20(0x99), Decimals: 6, Symbol: "OTH", Enabled: true}
reg := New(AssetKindERC20)
if _, err := reg.Register(other, rv); err == nil {
t.Fatal("an asset absent from the runtime manifest must be refused at the node")
}
}