mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
BuildBlock collected the block's bridge requests by ranging vm.pendingBridges, a
map. Go randomises map iteration, and that iteration decided two things that must
be functions of the pending set:
- the ORDER of BridgeRequests, which is hashed into the block id
(block.go computeID writes each req.ID in slice order) and serialised to the
wire. The same pending set therefore produced a different block on every
build. The sibling map in the same wire format is already sorted by NodeID
(wire.go:140) — BridgeRequests was the one that was missed.
- WHICH requests the block carries, because the 100-request cap broke out of
the range. With more than 100 ready, the included subset was a random draw,
so a request could be passed over indefinitely for no reason.
Proven: two builds over one 24-request pending set produced different orders, and
the capped build over 140 requests carried a random subset.
Candidates are now sorted by request id and the cap takes a prefix of that order.
Tests: TestBuildBlockOrdersRequestsDeterministically,
TestBuildBlockCapIsADeterministicPrefix.
Co-authored-by: Hanzo Dev <[email protected]>
1192 lines
39 KiB
Go
1192 lines
39 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package bridgevm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/chains/internal/warpmsg"
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/node/version"
|
|
"github.com/luxfi/node/vms/types/fee"
|
|
"github.com/luxfi/runtime"
|
|
"github.com/luxfi/threshold/pkg/party"
|
|
"github.com/luxfi/threshold/pkg/pool"
|
|
"github.com/luxfi/threshold/protocols/cmp/config"
|
|
vmcore "github.com/luxfi/vm"
|
|
"github.com/luxfi/vm/chain"
|
|
"github.com/luxfi/warp"
|
|
)
|
|
|
|
var (
|
|
_ chain.ChainVM = (*VM)(nil)
|
|
|
|
Version = &version.Semantic{
|
|
Major: 1,
|
|
Minor: 0,
|
|
Patch: 0,
|
|
}
|
|
|
|
errNotImplemented = errors.New("not implemented")
|
|
)
|
|
|
|
// Silence unused variable warning
|
|
var _ = errNotImplemented
|
|
|
|
// BridgeConfig contains VM configuration
|
|
type BridgeConfig struct {
|
|
// MPC configuration for secure cross-chain operations
|
|
MPCThreshold int `json:"mpcThreshold"` // t: Threshold (t+1 parties needed)
|
|
MPCTotalParties int `json:"mpcTotalParties"` // n: Total number of MPC nodes
|
|
|
|
// Bridge parameters
|
|
MinConfirmations uint32 `json:"minConfirmations"` // Confirmations required before bridging
|
|
BridgeFee uint64 `json:"bridgeFee"` // Fee in LUX for bridge operations
|
|
|
|
// Supported chains
|
|
SupportedChains []string `json:"supportedChains"` // Chain IDs that can be bridged
|
|
|
|
// ExternalChains configures the concrete EVM chains B releases to/from
|
|
// (Zoo 200201, Lux testnet C 96368). It carries endpoints, gateway + custody
|
|
// addresses, and KMS paths for the relayer keys — never key material. A
|
|
// relayer node reads this and calls EnableBridgeRelease with a KMS-backed
|
|
// KeyProvider and a Warp-backed AttestationClient.
|
|
ExternalChains []ExternalChainConfig `json:"externalChains,omitempty"`
|
|
|
|
// Security settings
|
|
MaxBridgeAmount uint64 `json:"maxBridgeAmount"` // Maximum amount per bridge transaction
|
|
DailyBridgeLimit uint64 `json:"dailyBridgeLimit"` // Daily limit for bridge operations
|
|
RequireValidatorBond uint64 `json:"requireValidatorBond"` // 1M LUX bond required (slashable, NOT staked)
|
|
|
|
// LP-333: Opt-in Signer Set Management
|
|
MaxSigners int `json:"maxSigners"` // Maximum signers before set is frozen (default: 100)
|
|
ThresholdRatio float64 `json:"thresholdRatio"` // Threshold as ratio of signers (default: 0.67 = 2/3)
|
|
}
|
|
|
|
// SignerSet tracks the current MPC signer set (LP-333)
|
|
// First 100 validators opt-in without reshare. Reshare ONLY on slot replacement.
|
|
type SignerSet struct {
|
|
Signers []*SignerInfo `json:"signers"` // Active signers (max 100)
|
|
Waitlist []ids.NodeID `json:"waitlist"` // Validators waiting for a slot
|
|
CurrentEpoch uint64 `json:"currentEpoch"` // Increments ONLY on reshare (slot replacement)
|
|
SetFrozen bool `json:"setFrozen"` // True when len(Signers) >= MaxSigners
|
|
ThresholdT int `json:"thresholdT"` // Current t value (T+1 required to sign)
|
|
PublicKey []byte `json:"publicKey"` // Combined threshold public key
|
|
}
|
|
|
|
// SignerInfo contains information about a signer in the set
|
|
type SignerInfo struct {
|
|
NodeID ids.NodeID `json:"nodeId"`
|
|
PartyID party.ID `json:"partyId"`
|
|
BondAmount uint64 `json:"bondAmount"` // 1M LUX bond (slashable, NOT staked)
|
|
MPCPubKey []byte `json:"mpcPubKey"`
|
|
Active bool `json:"active"`
|
|
JoinedAt time.Time `json:"joinedAt"`
|
|
SlotIndex int `json:"slotIndex"`
|
|
Slashed bool `json:"slashed"` // True if this signer has been slashed
|
|
SlashCount int `json:"slashCount"` // Number of times slashed
|
|
}
|
|
|
|
// RegisterValidatorInput is the input for registering as a bridge signer
|
|
type RegisterValidatorInput struct {
|
|
NodeID string `json:"nodeId"`
|
|
BondAmount string `json:"bondAmount,omitempty"` // 1M LUX bond (slashable)
|
|
MPCPubKey string `json:"mpcPubKey,omitempty"`
|
|
}
|
|
|
|
// RegisterValidatorResult is the result of registering as a bridge signer
|
|
type RegisterValidatorResult struct {
|
|
Success bool `json:"success"`
|
|
NodeID string `json:"nodeId"`
|
|
Registered bool `json:"registered"`
|
|
Waitlisted bool `json:"waitlisted"`
|
|
SignerIndex int `json:"signerIndex"`
|
|
WaitlistIndex int `json:"waitlistIndex,omitempty"`
|
|
TotalSigners int `json:"totalSigners"`
|
|
Threshold int `json:"threshold"`
|
|
ReshareNeeded bool `json:"reshareNeeded"` // Always false for opt-in (LP-333)
|
|
CurrentEpoch uint64 `json:"currentEpoch"`
|
|
SetFrozen bool `json:"setFrozen"`
|
|
RemainingSlots int `json:"remainingSlots"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// SignerSetInfo is the result of getting signer set information
|
|
type SignerSetInfo struct {
|
|
TotalSigners int `json:"totalSigners"`
|
|
Threshold int `json:"threshold"`
|
|
MaxSigners int `json:"maxSigners"`
|
|
CurrentEpoch uint64 `json:"currentEpoch"`
|
|
SetFrozen bool `json:"setFrozen"`
|
|
RemainingSlots int `json:"remainingSlots"`
|
|
WaitlistSize int `json:"waitlistSize"`
|
|
Signers []*SignerInfo `json:"signers"`
|
|
PublicKey string `json:"publicKey,omitempty"`
|
|
}
|
|
|
|
// SignerReplacementResult is the result of replacing a failed signer
|
|
type SignerReplacementResult struct {
|
|
Success bool `json:"success"`
|
|
RemovedNodeID string `json:"removedNodeId,omitempty"`
|
|
ReplacementNodeID string `json:"replacementNodeId,omitempty"`
|
|
ReshareSession string `json:"reshareSession,omitempty"`
|
|
NewEpoch uint64 `json:"newEpoch"`
|
|
ActiveSigners int `json:"activeSigners"`
|
|
Threshold int `json:"threshold"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// CrossChainMPCRequest represents a cross-chain request to ThresholdVM for MPC operations
|
|
type CrossChainMPCRequest struct {
|
|
Type MPCRequestType `json:"type"`
|
|
SessionID string `json:"sessionId"`
|
|
Epoch uint64 `json:"epoch"`
|
|
OldPartyIDs []party.ID `json:"oldPartyIds"`
|
|
NewPartyIDs []party.ID `json:"newPartyIds"`
|
|
Threshold int `json:"threshold"`
|
|
SourceChainID []byte `json:"sourceChainId"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
}
|
|
|
|
// MPCRequestType defines the type of MPC cross-chain request
|
|
type MPCRequestType uint8
|
|
|
|
const (
|
|
// MPCRequestReshare triggers a key reshare protocol
|
|
MPCRequestReshare MPCRequestType = iota
|
|
// MPCRequestSign triggers a threshold signing operation
|
|
MPCRequestSign
|
|
// MPCRequestRefresh triggers a proactive key refresh
|
|
MPCRequestRefresh
|
|
)
|
|
|
|
// maxRequestsPerBlock caps how many bridge requests one block carries. Applied to
|
|
// the id-sorted candidate list, so the cap is a deterministic prefix.
|
|
const maxRequestsPerBlock = 100
|
|
|
|
// VM implements the Bridge VM for cross-chain interoperability
|
|
type VM struct {
|
|
rt *runtime.Runtime
|
|
db database.Database
|
|
config BridgeConfig
|
|
toEngine chan<- vmcore.Message
|
|
log log.Logger
|
|
|
|
// Custody threshold signing runs on M-Chain (mpcvm) via dealerless
|
|
// CGGMP21/FROST DKG — no trusted dealer, secret never assembled by any
|
|
// validator. B-Chain requests keygen/reshare/sign from M-Chain over
|
|
// Warp (CrossChainMPCRequest) and VERIFIES the resulting CMP threshold
|
|
// signatures against this config. B-Chain never holds a custody key.
|
|
mpcConfig *config.Config // CMP group config for verification (from M-Chain keygen)
|
|
mpcPartyID party.ID // This party's ID in the MPC group
|
|
mpcPartyIDs []party.ID // All party IDs in the MPC group
|
|
mpcPool *pool.Pool // Worker pool for MPC operations
|
|
|
|
// LP-333: Signer Set Management (opt-in model)
|
|
signerSet *SignerSet // Active signer set with opt-in management
|
|
|
|
// Bridge state
|
|
pendingBridges map[ids.ID]*BridgeRequest
|
|
bridgeRegistry *BridgeRegistry
|
|
|
|
// Permissionless settlement: authoritative quote engine + swap
|
|
// state, exposed via bridge_estimateFee / bridge_submitRequest /
|
|
// bridge_getStatus (see rpc.go).
|
|
quoteEngine *QuoteEngine
|
|
swapStore SwapStore
|
|
|
|
// Chain connectivity. chainClients is the name-keyed ChainClient map (the
|
|
// field the recon flagged as never populated); evmByChainID is the id-keyed
|
|
// routing index the release path uses. Both are populated by
|
|
// EnableBridgeRelease and point at the same *evmChainClient objects.
|
|
chainClients map[string]ChainClient
|
|
evmByChainID map[uint32]*evmChainClient
|
|
|
|
// EVM release plumbing (injected via EnableBridgeRelease; nil until a
|
|
// relayer node wires it). attestClient is B's boundary to M-Chain; releaser
|
|
// drives broadcasts off the consensus path.
|
|
attestClient AttestationClient
|
|
releaser *releaser
|
|
|
|
// Block management
|
|
preferred ids.ID
|
|
lastAcceptedID ids.ID
|
|
pendingBlocks map[ids.ID]*Block
|
|
|
|
// Fee policy gating user-submitted bridge transfers
|
|
// (InitiateBridgeTransfer). FlatPolicy at MinTxFeeFloor;
|
|
// consensus-internal paths bypass.
|
|
feePolicy fee.Policy
|
|
networkID uint32
|
|
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// BridgeRequest represents a cross-chain bridge request
|
|
type BridgeRequest struct {
|
|
ID ids.ID `json:"id"`
|
|
SourceChain string `json:"sourceChain"`
|
|
DestChain string `json:"destChain"`
|
|
// SrcChainID / DstChainID are the numeric EVM chain ids (200201, 96368) that
|
|
// the canonical attestation digest binds; SourceChain / DestChain remain the
|
|
// human labels used to key clients. Nonce is the per-route replay guard
|
|
// assigned by the source lock and enforced by the destination gateway.
|
|
SrcChainID uint32 `json:"srcChainId"`
|
|
DstChainID uint32 `json:"dstChainId"`
|
|
Nonce uint64 `json:"nonce"`
|
|
Asset ids.ID `json:"asset"`
|
|
Amount uint64 `json:"amount"`
|
|
Recipient []byte `json:"recipient"`
|
|
SourceTxID ids.ID `json:"sourceTxId"`
|
|
Confirmations uint32 `json:"confirmations"`
|
|
Status string `json:"status"` // pending, signing, completed, failed
|
|
MPCSignatures [][]byte `json:"mpcSignatures"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// ChainClient interface for interacting with different chains
|
|
type ChainClient interface {
|
|
GetTransaction(ctx context.Context, txID ids.ID) (interface{}, error)
|
|
GetConfirmations(ctx context.Context, txID ids.ID) (uint32, error)
|
|
SendTransaction(ctx context.Context, tx interface{}) (ids.ID, error)
|
|
ValidateAddress(address []byte) error
|
|
}
|
|
|
|
// BridgeRegistry tracks bridge operations and validators
|
|
type BridgeRegistry struct {
|
|
Validators map[ids.NodeID]*BridgeValidator
|
|
CompletedBridges map[ids.ID]*CompletedBridge
|
|
DailyVolume map[string]uint64 // chainID -> volume
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// BridgeValidator represents a bridge validator node
|
|
type BridgeValidator struct {
|
|
NodeID ids.NodeID
|
|
StakeAmount uint64
|
|
MPCPublicKey []byte
|
|
Active bool
|
|
TotalBridged uint64
|
|
SuccessRate float64
|
|
}
|
|
|
|
// CompletedBridge represents a completed bridge operation
|
|
type CompletedBridge struct {
|
|
RequestID ids.ID
|
|
SourceTxID ids.ID
|
|
DestTxID ids.ID
|
|
CompletedAt time.Time
|
|
MPCSignature []byte
|
|
}
|
|
|
|
// Initialize implements the chain.ChainVM interface
|
|
func (vm *VM) Initialize(
|
|
ctx context.Context,
|
|
vmInit vmcore.Init,
|
|
) error {
|
|
// Convert chain runtime to Runtime.
|
|
vm.rt = vmInit.Runtime
|
|
vm.db = vmInit.DB
|
|
vm.toEngine = vmInit.ToEngine
|
|
|
|
if logger, ok := vm.rt.Log.(log.Logger); ok {
|
|
vm.log = logger
|
|
} else {
|
|
return errors.New("invalid logger type")
|
|
}
|
|
|
|
vm.pendingBlocks = make(map[ids.ID]*Block)
|
|
vm.pendingBridges = make(map[ids.ID]*BridgeRequest)
|
|
vm.chainClients = make(map[string]ChainClient)
|
|
vm.evmByChainID = make(map[uint32]*evmChainClient)
|
|
|
|
// Pin fee policy. B-Chain accepts user-submitted bridge transfers
|
|
// so attach the canonical FlatPolicy at MinTxFeeFloor; fee.Validate
|
|
// refuses zero-fee user-facing chains at boot.
|
|
if vm.rt != nil {
|
|
vm.networkID = vm.rt.NetworkID
|
|
}
|
|
vm.feePolicy = newFeePolicy(vm.networkID)
|
|
if err := fee.Validate(vm.feePolicy); err != nil {
|
|
return fmt.Errorf("bridgevm: fee policy: %w", err)
|
|
}
|
|
|
|
// Parse configuration (use JSON like other VMs)
|
|
if len(vmInit.Config) > 0 {
|
|
if err := json.Unmarshal(vmInit.Config, &vm.config); err != nil {
|
|
return fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
}
|
|
|
|
// Set LP-333 defaults for signer set management
|
|
if vm.config.MaxSigners == 0 {
|
|
vm.config.MaxSigners = 100 // First 100 validators opt-in, then set freezes
|
|
}
|
|
if vm.config.ThresholdRatio == 0 {
|
|
vm.config.ThresholdRatio = 0.67 // 2/3 threshold for BFT safety
|
|
}
|
|
if vm.config.RequireValidatorBond == 0 {
|
|
vm.config.RequireValidatorBond = 1_000_000 * 1e9 // Default: 1M LUX bond
|
|
}
|
|
|
|
// Validate configuration - Bridge validators require 1M LUX BOND (slashable, not stake)
|
|
if vm.config.RequireValidatorBond < 1_000_000*1e9 { // 1M LUX bond
|
|
return errors.New("B-chain requires 1M LUX bond (slashable)")
|
|
}
|
|
|
|
// Initialize LP-333 signer set (opt-in model)
|
|
vm.signerSet = &SignerSet{
|
|
Signers: make([]*SignerInfo, 0, vm.config.MaxSigners),
|
|
Waitlist: make([]ids.NodeID, 0),
|
|
CurrentEpoch: 0,
|
|
SetFrozen: false,
|
|
ThresholdT: 0,
|
|
}
|
|
|
|
// Initialize MPC components using threshold protocol
|
|
// Party ID is derived from node ID
|
|
vm.mpcPartyID = party.ID(vm.rt.NodeID.String())
|
|
|
|
// Create worker pool for MPC operations (8 workers)
|
|
vm.mpcPool = pool.NewPool(8)
|
|
|
|
// mpcConfig / mpcPartyIDs are populated from M-Chain's dealerless keygen
|
|
// when validators join the bridge signer set (CrossChainMPCRequest). No
|
|
// key material is generated on B-Chain.
|
|
|
|
// Initialize bridge registry
|
|
vm.bridgeRegistry = &BridgeRegistry{
|
|
Validators: make(map[ids.NodeID]*BridgeValidator),
|
|
CompletedBridges: make(map[ids.ID]*CompletedBridge),
|
|
DailyVolume: make(map[string]uint64),
|
|
}
|
|
|
|
// Authoritative quote engine + swap store. The price feed default
|
|
// seeds the assets the bridge handles at genesis; a future PR adds
|
|
// a quorum-signed oracle feed without changing the RPC surface.
|
|
vm.quoteEngine = &QuoteEngine{Feed: defaultPriceFeed()}
|
|
vm.swapStore = newInMemorySwapStore()
|
|
|
|
// EVM chain clients are wired by EnableBridgeRelease (relayer nodes only),
|
|
// which needs the KMS-backed KeyProvider and the Warp-backed
|
|
// AttestationClient — runtime deps not available at consensus boot. Config
|
|
// carries the chain list; a relayer reads vm.config.ExternalChains and calls
|
|
// EnableBridgeRelease. Non-relayer nodes leave chainClients empty and never
|
|
// broadcast.
|
|
if len(vm.config.ExternalChains) > 0 {
|
|
vm.log.Info("bridgevm: external chains configured; awaiting EnableBridgeRelease",
|
|
log.Int("count", len(vm.config.ExternalChains)),
|
|
)
|
|
}
|
|
|
|
// Parse genesis - use JSON for simple genesis configuration
|
|
genesis := &Genesis{}
|
|
if len(vmInit.Genesis) > 0 {
|
|
if err := json.Unmarshal(vmInit.Genesis, genesis); err != nil {
|
|
return fmt.Errorf("failed to parse genesis: %w", err)
|
|
}
|
|
}
|
|
|
|
// Create genesis block
|
|
genesisBlock := &Block{
|
|
BlockHeight: 0,
|
|
BlockTimestamp: genesis.Timestamp,
|
|
ParentID_: ids.Empty,
|
|
BridgeRequests: []*BridgeRequest{},
|
|
vm: vm,
|
|
}
|
|
|
|
genesisBlock.ID_ = genesisBlock.computeID()
|
|
vm.lastAcceptedID = genesisBlock.ID()
|
|
|
|
return vm.putBlock(genesisBlock)
|
|
}
|
|
|
|
// BuildBlock implements the chain.ChainVM interface
|
|
func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
|
|
// Check if we have pending bridge requests
|
|
if len(vm.pendingBridges) == 0 {
|
|
return nil, errors.New("no pending bridge requests")
|
|
}
|
|
|
|
// Get parent block
|
|
parentID := vm.preferred
|
|
if parentID == ids.Empty {
|
|
parentID = vm.lastAcceptedID
|
|
}
|
|
|
|
parent, err := vm.getBlock(parentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get parent block: %w", err)
|
|
}
|
|
|
|
// Collect bridge requests that are ready, then order them by request id.
|
|
// pendingBridges is a map, and Go randomises map iteration: taking the first
|
|
// maxRequestsPerBlock straight out of the range made both the ORDER of the
|
|
// block's requests and, once more than that many are ready, WHICH requests it
|
|
// carries vary run to run. That order is hashed into the block id
|
|
// (block.go computeID) and written to the wire, so the same pending set
|
|
// produced a different block every time and a request could be starved at
|
|
// random. Sorting first makes the block a function of its inputs and the cap a
|
|
// deterministic prefix.
|
|
requests := make([]*BridgeRequest, 0, len(vm.pendingBridges))
|
|
for _, req := range vm.pendingBridges {
|
|
if req.Confirmations >= vm.config.MinConfirmations {
|
|
requests = append(requests, req)
|
|
}
|
|
}
|
|
sort.Slice(requests, func(i, j int) bool {
|
|
return bytes.Compare(requests[i].ID[:], requests[j].ID[:]) < 0
|
|
})
|
|
if len(requests) > maxRequestsPerBlock {
|
|
requests = requests[:maxRequestsPerBlock]
|
|
}
|
|
|
|
if len(requests) == 0 {
|
|
return nil, errors.New("no ready bridge requests")
|
|
}
|
|
|
|
// Create new block
|
|
blk := &Block{
|
|
ParentID_: parentID,
|
|
BlockHeight: parent.Height() + 1,
|
|
BlockTimestamp: time.Now().Unix(),
|
|
BridgeRequests: requests,
|
|
vm: vm,
|
|
}
|
|
|
|
blk.ID_ = blk.computeID()
|
|
|
|
// Store pending block
|
|
vm.pendingBlocks[blk.ID()] = blk
|
|
|
|
vm.log.Info("built bridge block",
|
|
log.Stringer("blockID", blk.ID()),
|
|
log.Int("numRequests", len(requests)),
|
|
)
|
|
|
|
return blk, nil
|
|
}
|
|
|
|
// GetBlock implements the chain.ChainVM interface
|
|
func (vm *VM) GetBlock(ctx context.Context, id ids.ID) (chain.Block, error) {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
|
|
// Check pending blocks first (nil-safe for early calls before initialization)
|
|
if vm.pendingBlocks != nil {
|
|
if blk, exists := vm.pendingBlocks[id]; exists {
|
|
return blk, nil
|
|
}
|
|
}
|
|
|
|
return vm.getBlock(id)
|
|
}
|
|
|
|
// ParseBlock implements the chain.ChainVM interface
|
|
func (vm *VM) ParseBlock(ctx context.Context, bytes []byte) (chain.Block, error) {
|
|
blk := &Block{vm: vm}
|
|
if err := parseBlockBytes(bytes, blk); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
blk.ID_ = blk.computeID()
|
|
return blk, nil
|
|
}
|
|
|
|
// SetPreference implements the chain.ChainVM interface
|
|
func (vm *VM) SetPreference(ctx context.Context, id ids.ID) error {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
|
|
vm.preferred = id
|
|
return nil
|
|
}
|
|
|
|
// LastAccepted implements the chain.ChainVM interface
|
|
func (vm *VM) LastAccepted(ctx context.Context) (ids.ID, error) {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
|
|
return vm.lastAcceptedID, nil
|
|
}
|
|
|
|
// CreateHandlers implements the common.VM interface
|
|
func (vm *VM) CreateHandlers(ctx context.Context) (map[string]http.Handler, error) {
|
|
handlers := map[string]http.Handler{
|
|
"/bridge": http.HandlerFunc(vm.handleBridgeRequest),
|
|
"/status": http.HandlerFunc(vm.handleStatus),
|
|
"/validators": http.HandlerFunc(vm.handleValidators),
|
|
}
|
|
return handlers, nil
|
|
}
|
|
|
|
// HealthCheck implements the common.VM interface
|
|
func (vm *VM) HealthCheck(ctx context.Context) (chain.HealthResult, error) {
|
|
return chain.HealthResult{
|
|
Healthy: true,
|
|
Details: map[string]string{"status": "healthy"},
|
|
}, nil
|
|
}
|
|
|
|
// Shutdown implements the common.VM interface
|
|
func (vm *VM) Shutdown(ctx context.Context) error {
|
|
// Stop the release worker first (it takes vm.mu internally); do NOT hold the
|
|
// lock across stop() or the worker's own vm.mu use would deadlock.
|
|
vm.stopReleaser()
|
|
return nil
|
|
}
|
|
|
|
// CreateStaticHandlers implements the common.VM interface
|
|
func (vm *VM) CreateStaticHandlers(ctx context.Context) (map[string]http.Handler, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// Connected implements the common.VM interface
|
|
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, nodeVersion *chain.VersionInfo) error {
|
|
return nil
|
|
}
|
|
|
|
// Disconnected implements the common.VM interface
|
|
func (vm *VM) Disconnected(ctx context.Context, nodeID ids.NodeID) error {
|
|
return nil
|
|
}
|
|
|
|
// Request implements the common.VM interface
|
|
func (vm *VM) Request(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, request []byte) error {
|
|
// Bridge VMs may use this for cross-chain communication
|
|
return nil
|
|
}
|
|
|
|
// Response implements the common.VM interface
|
|
func (vm *VM) Response(ctx context.Context, nodeID ids.NodeID, requestID uint32, response []byte) error {
|
|
return nil
|
|
}
|
|
|
|
// RequestFailed implements the common.VM interface
|
|
func (vm *VM) RequestFailed(ctx context.Context, nodeID ids.NodeID, requestID uint32, appErr *warp.Error) error {
|
|
return nil
|
|
}
|
|
|
|
// Gossip implements the common.VM interface
|
|
func (vm *VM) Gossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error {
|
|
return nil
|
|
}
|
|
|
|
// Version implements the common.VM interface
|
|
func (vm *VM) Version(ctx context.Context) (string, error) {
|
|
return Version.String(), nil
|
|
}
|
|
|
|
// CrossChainRequest implements the common.VM interface
|
|
func (vm *VM) CrossChainRequest(ctx context.Context, chainID ids.ID, requestID uint32, deadline time.Time, request []byte) error {
|
|
// Bridge VMs handle cross-chain requests
|
|
return nil
|
|
}
|
|
|
|
// CrossChainResponse implements the common.VM interface
|
|
func (vm *VM) CrossChainResponse(ctx context.Context, chainID ids.ID, requestID uint32, response []byte) error {
|
|
return nil
|
|
}
|
|
|
|
// CrossChainRequestFailed implements the common.VM interface
|
|
func (vm *VM) CrossChainRequestFailed(ctx context.Context, chainID ids.ID, requestID uint32, appErr *warp.Error) error {
|
|
return nil
|
|
}
|
|
|
|
// GetBlockIDAtHeight implements the chain.HeightIndexedChainVM interface
|
|
func (vm *VM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, error) {
|
|
// For now, return not implemented
|
|
// In production, maintain a height index
|
|
return ids.Empty, errors.New("height index not implemented")
|
|
}
|
|
|
|
// SetState implements the common.VM interface
|
|
func (vm *VM) SetState(ctx context.Context, state uint32) error {
|
|
// For now, no-op
|
|
// In production, handle state transitions
|
|
return nil
|
|
}
|
|
|
|
// NewHTTPHandler returns HTTP handlers for the VM
|
|
func (vm *VM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
|
|
handlers, err := vm.CreateHandlers(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
for path, handler := range handlers {
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
mux.Handle(path, handler)
|
|
}
|
|
return mux, nil
|
|
}
|
|
|
|
// WaitForEvent blocks until an event occurs that should trigger block building
|
|
func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
|
|
// Block until context is cancelled
|
|
// In production, this would wait for bridge requests, etc.
|
|
// CRITICAL: Must block here to avoid notification flood loop in chains/manager.go
|
|
<-ctx.Done()
|
|
return vmcore.Message{}, ctx.Err()
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
func (vm *VM) putBlock(blk *Block) error {
|
|
bytes, err := blk.Marshal()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := blk.ID()
|
|
return vm.db.Put(id[:], bytes)
|
|
}
|
|
|
|
func (vm *VM) getBlock(id ids.ID) (*Block, error) {
|
|
bytes, err := vm.db.Get(id[:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
blk := &Block{vm: vm}
|
|
if err := parseBlockBytes(bytes, blk); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
blk.ID_ = id
|
|
return blk, nil
|
|
}
|
|
|
|
// HTTP handler methods
|
|
|
|
func (vm *VM) handleBridgeRequest(w http.ResponseWriter, r *http.Request) {
|
|
// Handle bridge request
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "bridge request handler"}`))
|
|
}
|
|
|
|
func (vm *VM) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
// Handle status request
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "operational"}`))
|
|
}
|
|
|
|
func (vm *VM) handleValidators(w http.ResponseWriter, r *http.Request) {
|
|
// Handle validators request
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"validators": []}`))
|
|
}
|
|
|
|
// Genesis represents the genesis state
|
|
type Genesis struct {
|
|
Timestamp int64 `json:"timestamp"`
|
|
}
|
|
|
|
// =============================================================================
|
|
// LP-333: Opt-In Signer Set Management
|
|
// First 100 validators opt-in without reshare. Reshare ONLY on slot replacement.
|
|
// =============================================================================
|
|
|
|
// RegisterValidator registers a new validator as a bridge signer (opt-in model)
|
|
// LP-333: First 100 validators are accepted directly - NO reshare on join.
|
|
// After 100 signers, new validators go to waitlist until a slot opens.
|
|
func (vm *VM) RegisterValidator(input *RegisterValidatorInput) (*RegisterValidatorResult, error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
|
|
// Parse node ID
|
|
nodeID, err := ids.NodeIDFromString(input.NodeID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid node ID: %w", err)
|
|
}
|
|
|
|
// Check if already a signer
|
|
for _, signer := range vm.signerSet.Signers {
|
|
if signer.NodeID == nodeID {
|
|
return &RegisterValidatorResult{
|
|
Success: false,
|
|
NodeID: input.NodeID,
|
|
Message: "already registered as signer",
|
|
TotalSigners: len(vm.signerSet.Signers),
|
|
Threshold: vm.signerSet.ThresholdT,
|
|
CurrentEpoch: vm.signerSet.CurrentEpoch,
|
|
SetFrozen: vm.signerSet.SetFrozen,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// Check if already on waitlist
|
|
for _, wl := range vm.signerSet.Waitlist {
|
|
if wl == nodeID {
|
|
return &RegisterValidatorResult{
|
|
Success: false,
|
|
NodeID: input.NodeID,
|
|
Message: "already on waitlist",
|
|
Waitlisted: true,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// Parse bond amount (1M LUX required, slashable)
|
|
var bondAmount uint64
|
|
if input.BondAmount != "" {
|
|
if _, err := fmt.Sscanf(input.BondAmount, "%d", &bondAmount); err != nil {
|
|
bondAmount = 0
|
|
}
|
|
}
|
|
|
|
// If set is NOT frozen (under max signers), add directly - NO RESHARE
|
|
if !vm.signerSet.SetFrozen && len(vm.signerSet.Signers) < vm.config.MaxSigners {
|
|
// Create signer info
|
|
signerInfo := &SignerInfo{
|
|
NodeID: nodeID,
|
|
PartyID: party.ID(nodeID.String()),
|
|
BondAmount: bondAmount, // 1M LUX bond (slashable)
|
|
Active: true,
|
|
JoinedAt: time.Now(),
|
|
SlotIndex: len(vm.signerSet.Signers),
|
|
Slashed: false,
|
|
SlashCount: 0,
|
|
}
|
|
|
|
// Parse MPC public key if provided
|
|
if input.MPCPubKey != "" {
|
|
signerInfo.MPCPubKey = []byte(input.MPCPubKey)
|
|
}
|
|
|
|
// Add to signer set
|
|
vm.signerSet.Signers = append(vm.signerSet.Signers, signerInfo)
|
|
|
|
// Update threshold: t = floor(n * ratio)
|
|
vm.signerSet.ThresholdT = int(float64(len(vm.signerSet.Signers)) * vm.config.ThresholdRatio)
|
|
if vm.signerSet.ThresholdT < 1 {
|
|
vm.signerSet.ThresholdT = 1
|
|
}
|
|
|
|
// Check if set should freeze (reached max signers)
|
|
if len(vm.signerSet.Signers) >= vm.config.MaxSigners {
|
|
vm.signerSet.SetFrozen = true
|
|
}
|
|
|
|
remainingSlots := vm.config.MaxSigners - len(vm.signerSet.Signers)
|
|
|
|
if vm.log != nil && !vm.log.IsZero() {
|
|
vm.log.Info("validator registered as bridge signer (LP-333 opt-in)",
|
|
log.Stringer("nodeID", nodeID),
|
|
log.Int("signerIndex", signerInfo.SlotIndex),
|
|
log.Int("totalSigners", len(vm.signerSet.Signers)),
|
|
log.Int("threshold", vm.signerSet.ThresholdT),
|
|
log.Bool("setFrozen", vm.signerSet.SetFrozen),
|
|
)
|
|
}
|
|
|
|
return &RegisterValidatorResult{
|
|
Success: true,
|
|
NodeID: input.NodeID,
|
|
Registered: true,
|
|
Waitlisted: false,
|
|
SignerIndex: signerInfo.SlotIndex,
|
|
TotalSigners: len(vm.signerSet.Signers),
|
|
Threshold: vm.signerSet.ThresholdT,
|
|
ReshareNeeded: false, // LP-333: NO reshare on join
|
|
CurrentEpoch: vm.signerSet.CurrentEpoch,
|
|
SetFrozen: vm.signerSet.SetFrozen,
|
|
RemainingSlots: remainingSlots,
|
|
Message: "registered as bridge signer",
|
|
}, nil
|
|
}
|
|
|
|
// Set is frozen - add to waitlist
|
|
vm.signerSet.Waitlist = append(vm.signerSet.Waitlist, nodeID)
|
|
waitlistIndex := len(vm.signerSet.Waitlist) - 1
|
|
|
|
if vm.log != nil && !vm.log.IsZero() {
|
|
vm.log.Info("validator added to waitlist (signer set frozen)",
|
|
log.Stringer("nodeID", nodeID),
|
|
log.Int("waitlistIndex", waitlistIndex),
|
|
log.Int("totalSigners", len(vm.signerSet.Signers)),
|
|
)
|
|
}
|
|
|
|
return &RegisterValidatorResult{
|
|
Success: true,
|
|
NodeID: input.NodeID,
|
|
Registered: false,
|
|
Waitlisted: true,
|
|
WaitlistIndex: waitlistIndex,
|
|
TotalSigners: len(vm.signerSet.Signers),
|
|
Threshold: vm.signerSet.ThresholdT,
|
|
ReshareNeeded: false,
|
|
CurrentEpoch: vm.signerSet.CurrentEpoch,
|
|
SetFrozen: vm.signerSet.SetFrozen,
|
|
RemainingSlots: 0,
|
|
Message: "added to waitlist (signer set frozen at 100)",
|
|
}, nil
|
|
}
|
|
|
|
// GetSignerSetInfo returns information about the current signer set
|
|
func (vm *VM) GetSignerSetInfo() *SignerSetInfo {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
|
|
remainingSlots := vm.config.MaxSigners - len(vm.signerSet.Signers)
|
|
if remainingSlots < 0 {
|
|
remainingSlots = 0
|
|
}
|
|
|
|
info := &SignerSetInfo{
|
|
TotalSigners: len(vm.signerSet.Signers),
|
|
Threshold: vm.signerSet.ThresholdT,
|
|
MaxSigners: vm.config.MaxSigners,
|
|
CurrentEpoch: vm.signerSet.CurrentEpoch,
|
|
SetFrozen: vm.signerSet.SetFrozen,
|
|
RemainingSlots: remainingSlots,
|
|
WaitlistSize: len(vm.signerSet.Waitlist),
|
|
Signers: vm.signerSet.Signers,
|
|
}
|
|
|
|
if len(vm.signerSet.PublicKey) > 0 {
|
|
info.PublicKey = fmt.Sprintf("%x", vm.signerSet.PublicKey)
|
|
}
|
|
|
|
return info
|
|
}
|
|
|
|
// RemoveSigner removes a failed/stopped signer and triggers replacement
|
|
// LP-333: This is the ONLY operation that triggers a reshare.
|
|
// Epoch increments only when a signer is replaced.
|
|
func (vm *VM) RemoveSigner(nodeID ids.NodeID, replacementNodeID *ids.NodeID) (*SignerReplacementResult, error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
|
|
// Find and remove the signer
|
|
found := false
|
|
var removedSigner *SignerInfo
|
|
for i, signer := range vm.signerSet.Signers {
|
|
if signer.NodeID == nodeID {
|
|
removedSigner = signer
|
|
// Remove from slice
|
|
vm.signerSet.Signers = append(vm.signerSet.Signers[:i], vm.signerSet.Signers[i+1:]...)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return &SignerReplacementResult{
|
|
Success: false,
|
|
Message: fmt.Sprintf("signer %s not found in active set", nodeID),
|
|
}, nil
|
|
}
|
|
|
|
// Determine replacement (from parameter or waitlist)
|
|
var replacement ids.NodeID
|
|
var replacementSource string
|
|
if replacementNodeID != nil && *replacementNodeID != ids.EmptyNodeID {
|
|
replacement = *replacementNodeID
|
|
replacementSource = "explicit"
|
|
} else if len(vm.signerSet.Waitlist) > 0 {
|
|
replacement = vm.signerSet.Waitlist[0]
|
|
vm.signerSet.Waitlist = vm.signerSet.Waitlist[1:]
|
|
replacementSource = "waitlist"
|
|
}
|
|
|
|
// Add replacement signer if available
|
|
if replacement != ids.EmptyNodeID {
|
|
newSigner := &SignerInfo{
|
|
NodeID: replacement,
|
|
PartyID: party.ID(replacement.String()),
|
|
BondAmount: 0, // Will be verified during reshare (1M LUX required)
|
|
Active: true,
|
|
JoinedAt: time.Now(),
|
|
SlotIndex: removedSigner.SlotIndex,
|
|
Slashed: false,
|
|
SlashCount: 0,
|
|
}
|
|
vm.signerSet.Signers = append(vm.signerSet.Signers, newSigner)
|
|
}
|
|
|
|
// Update threshold
|
|
vm.signerSet.ThresholdT = int(float64(len(vm.signerSet.Signers)) * vm.config.ThresholdRatio)
|
|
if vm.signerSet.ThresholdT < 1 && len(vm.signerSet.Signers) > 0 {
|
|
vm.signerSet.ThresholdT = 1
|
|
}
|
|
|
|
// INCREMENT EPOCH - This is the ONLY reshare trigger (LP-333)
|
|
vm.signerSet.CurrentEpoch++
|
|
|
|
// Generate reshare session ID
|
|
reshareSession := fmt.Sprintf("reshare-epoch-%d-%s", vm.signerSet.CurrentEpoch, time.Now().Format("20060102150405"))
|
|
|
|
if vm.log != nil && !vm.log.IsZero() {
|
|
vm.log.Info("signer removed and reshare triggered (LP-333)",
|
|
log.Stringer("removedNodeID", nodeID),
|
|
log.Stringer("replacementNodeID", replacement),
|
|
log.String("replacementSource", replacementSource),
|
|
log.Uint64("newEpoch", vm.signerSet.CurrentEpoch),
|
|
log.Int("activeSigners", len(vm.signerSet.Signers)),
|
|
log.String("reshareSession", reshareSession),
|
|
)
|
|
}
|
|
|
|
// Trigger actual reshare protocol via M-Chain (ThresholdVM-MPC, per LP-134) using warp messaging
|
|
if err := vm.triggerReshareProtocol(reshareSession, nodeID, replacement); err != nil {
|
|
if vm.log != nil && !vm.log.IsZero() {
|
|
vm.log.Warn("failed to trigger reshare protocol",
|
|
log.String("reshareSession", reshareSession),
|
|
log.String("error", err.Error()),
|
|
)
|
|
}
|
|
// Continue anyway - reshare can be retried
|
|
}
|
|
|
|
result := &SignerReplacementResult{
|
|
Success: true,
|
|
RemovedNodeID: nodeID.String(),
|
|
NewEpoch: vm.signerSet.CurrentEpoch,
|
|
ActiveSigners: len(vm.signerSet.Signers),
|
|
Threshold: vm.signerSet.ThresholdT,
|
|
Message: "signer removed, reshare initiated",
|
|
}
|
|
|
|
if replacement != ids.EmptyNodeID {
|
|
result.ReplacementNodeID = replacement.String()
|
|
result.ReshareSession = reshareSession
|
|
result.Message = fmt.Sprintf("signer replaced from %s, reshare initiated", replacementSource)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// HasSigner checks if a node ID is in the active signer set
|
|
func (vm *VM) HasSigner(nodeID ids.NodeID) bool {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
|
|
for _, signer := range vm.signerSet.Signers {
|
|
if signer.NodeID == nodeID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// triggerReshareProtocol sends a cross-chain request to ThresholdVM to initiate
|
|
// the MPC key reshare protocol. This is triggered when a signer is replaced.
|
|
func (vm *VM) triggerReshareProtocol(sessionID string, removedNodeID ids.NodeID, newNodeID ids.NodeID) error {
|
|
// Check if runtime is available (may not be in unit tests)
|
|
if vm.rt == nil {
|
|
if vm.log != nil && !vm.log.IsZero() {
|
|
vm.log.Debug("skipping reshare protocol trigger - runtime not initialized")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Check required warp infrastructure
|
|
if vm.rt.WarpSigner == nil || vm.rt.Sender == nil {
|
|
if vm.log != nil && !vm.log.IsZero() {
|
|
vm.log.Debug("skipping reshare protocol trigger - warp infrastructure not available")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Build the list of old party IDs (current signers excluding the removed one)
|
|
oldPartyIDs := make([]party.ID, 0, len(vm.signerSet.Signers))
|
|
for _, signer := range vm.signerSet.Signers {
|
|
if signer.NodeID != removedNodeID && signer.NodeID != newNodeID {
|
|
oldPartyIDs = append(oldPartyIDs, signer.PartyID)
|
|
}
|
|
}
|
|
|
|
// Build the list of new party IDs (current signers after replacement)
|
|
newPartyIDs := make([]party.ID, 0, len(vm.signerSet.Signers))
|
|
for _, signer := range vm.signerSet.Signers {
|
|
newPartyIDs = append(newPartyIDs, signer.PartyID)
|
|
}
|
|
|
|
// Create the cross-chain MPC request
|
|
mpcRequest := &CrossChainMPCRequest{
|
|
Type: MPCRequestReshare,
|
|
SessionID: sessionID,
|
|
Epoch: vm.signerSet.CurrentEpoch,
|
|
OldPartyIDs: oldPartyIDs,
|
|
NewPartyIDs: newPartyIDs,
|
|
Threshold: vm.signerSet.ThresholdT,
|
|
SourceChainID: vm.rt.ChainID[:],
|
|
Timestamp: time.Now().Unix(),
|
|
}
|
|
|
|
// Serialize the request
|
|
requestBytes, err := json.Marshal(mpcRequest)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal MPC request: %w", err)
|
|
}
|
|
|
|
// Build, sign, and wrap the reshare request as a single-signer Warp
|
|
// envelope. The node signs with its BLS key over the Beam domain; the
|
|
// receiving ThresholdVM nodes aggregate and verify the signature against
|
|
// the canonical validator set. warpmsg.BuildSigned is the one place that
|
|
// performs this build→sign→wrap sequence.
|
|
env, err := warpmsg.BuildSigned(vm.rt.WarpSigner, vm.rt.NetworkID, vm.rt.ChainID, requestBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build signed warp message: %w", err)
|
|
}
|
|
|
|
// Broadcast the reshare request to all signers via gossip
|
|
// The ThresholdVM nodes will receive this and participate in the reshare protocol
|
|
msgBytes, err := env.Bytes()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encode warp envelope: %w", err)
|
|
}
|
|
|
|
config := warp.SendConfig{
|
|
Validators: len(vm.signerSet.Signers), // Send to all validators in signer set
|
|
Peers: 0,
|
|
}
|
|
|
|
if err := vm.rt.Sender.SendGossip(context.Background(), config, msgBytes); err != nil {
|
|
return fmt.Errorf("failed to broadcast reshare request: %w", err)
|
|
}
|
|
|
|
if vm.log != nil && !vm.log.IsZero() {
|
|
vm.log.Info("reshare protocol triggered",
|
|
log.String("sessionID", sessionID),
|
|
log.Uint64("epoch", vm.signerSet.CurrentEpoch),
|
|
log.Int("oldParties", len(oldPartyIDs)),
|
|
log.Int("newParties", len(newPartyIDs)),
|
|
log.Int("threshold", vm.signerSet.ThresholdT),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SlashSignerInput is the input for slashing a bridge signer
|
|
type SlashSignerInput struct {
|
|
NodeID ids.NodeID `json:"nodeId"`
|
|
Reason string `json:"reason"`
|
|
SlashPercent int `json:"slashPercent"` // Percentage of bond to slash (1-100)
|
|
Evidence []byte `json:"evidence"` // Proof of misbehavior
|
|
}
|
|
|
|
// SlashSignerResult is the result of slashing a bridge signer
|
|
type SlashSignerResult struct {
|
|
Success bool `json:"success"`
|
|
NodeID string `json:"nodeId"`
|
|
SlashedAmount uint64 `json:"slashedAmount"`
|
|
RemainingBond uint64 `json:"remainingBond"`
|
|
TotalSlashCount int `json:"totalSlashCount"`
|
|
RemovedFromSet bool `json:"removedFromSet"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// SlashSigner slashes a misbehaving bridge signer's bond
|
|
// The bond is NOT stake - it's a slashable deposit that can be partially or fully seized
|
|
func (vm *VM) SlashSigner(input *SlashSignerInput) (*SlashSignerResult, error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
|
|
// Validate slash percentage
|
|
if input.SlashPercent < 1 || input.SlashPercent > 100 {
|
|
return nil, errors.New("slash percent must be between 1 and 100")
|
|
}
|
|
|
|
// Find the signer
|
|
var signer *SignerInfo
|
|
var signerIndex int
|
|
for i, s := range vm.signerSet.Signers {
|
|
if s.NodeID == input.NodeID {
|
|
signer = s
|
|
signerIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if signer == nil {
|
|
return &SlashSignerResult{
|
|
Success: false,
|
|
NodeID: input.NodeID.String(),
|
|
Message: "signer not found in active set",
|
|
}, nil
|
|
}
|
|
|
|
// Calculate slash amount
|
|
slashAmount := (signer.BondAmount * uint64(input.SlashPercent)) / 100
|
|
remainingBond := signer.BondAmount - slashAmount
|
|
|
|
// Update signer state
|
|
signer.BondAmount = remainingBond
|
|
signer.Slashed = true
|
|
signer.SlashCount++
|
|
|
|
if vm.log != nil && !vm.log.IsZero() {
|
|
vm.log.Warn("bridge signer slashed",
|
|
log.Stringer("nodeID", input.NodeID),
|
|
log.String("reason", input.Reason),
|
|
log.Int("slashPercent", input.SlashPercent),
|
|
log.Uint64("slashedAmount", slashAmount),
|
|
log.Uint64("remainingBond", remainingBond),
|
|
log.Int("slashCount", signer.SlashCount),
|
|
)
|
|
}
|
|
|
|
result := &SlashSignerResult{
|
|
Success: true,
|
|
NodeID: input.NodeID.String(),
|
|
SlashedAmount: slashAmount,
|
|
RemainingBond: remainingBond,
|
|
TotalSlashCount: signer.SlashCount,
|
|
RemovedFromSet: false,
|
|
Message: fmt.Sprintf("slashed %d%% of bond (%d LUX)", input.SlashPercent, slashAmount/1e9),
|
|
}
|
|
|
|
// If bond drops below minimum (1M LUX), remove from signer set
|
|
minBond := uint64(1_000_000 * 1e9) // 1M LUX
|
|
if remainingBond < minBond {
|
|
// Remove signer
|
|
vm.signerSet.Signers = append(vm.signerSet.Signers[:signerIndex], vm.signerSet.Signers[signerIndex+1:]...)
|
|
|
|
// Update threshold
|
|
vm.signerSet.ThresholdT = int(float64(len(vm.signerSet.Signers)) * vm.config.ThresholdRatio)
|
|
if vm.signerSet.ThresholdT < 1 && len(vm.signerSet.Signers) > 0 {
|
|
vm.signerSet.ThresholdT = 1
|
|
}
|
|
|
|
// Increment epoch (removal triggers reshare)
|
|
vm.signerSet.CurrentEpoch++
|
|
|
|
result.RemovedFromSet = true
|
|
result.Message = fmt.Sprintf("slashed %d%% of bond, signer removed (bond below 1M LUX minimum)", input.SlashPercent)
|
|
|
|
if vm.log != nil && !vm.log.IsZero() {
|
|
vm.log.Warn("bridge signer removed due to insufficient bond after slashing",
|
|
log.Stringer("nodeID", input.NodeID),
|
|
log.Uint64("remainingBond", remainingBond),
|
|
log.Uint64("newEpoch", vm.signerSet.CurrentEpoch),
|
|
)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|