mirror of
https://github.com/luxfi/chains.git
synced 2026-07-27 03:39:41 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:] }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user