mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
Across these files the comments explained a rule by recounting what happened without it — which key was mistaken for which, which payer a full pool released, what a verifier accepted, what a chain did with work it never announced. That tells a reader what changed rather than what holds, and it dates: the account stays in the file long after anyone needs it, while the rule it was meant to convey has to be inferred back out. They now state the rule. A circuit is judged on its own key. A witness is exactly as long as the key's circuit says. A count describes the records it is read from. A full pool releases what pays least. Custody membership costs stake because an identifier can be aimed and stake cannot. Consensus builds when WaitForEvent returns, so accepting work and reporting it are one step. Behaviour is unchanged; the diff is comments, with one exception worth naming: gofmt realigned a block of one-line methods in quantumvm/feegate_test.go that was already unformatted before this commit. Co-authored-by: Hanzo Dev <[email protected]>
1209 lines
39 KiB
Go
1209 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"
|
|
"github.com/luxfi/chains/internal/bridgeattest"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"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]ChainClient
|
|
|
|
// 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
|
|
watcher *watcher
|
|
|
|
// work tells consensus a block can be built. The watcher signals it when a
|
|
// source chain reports a lock.
|
|
work vmcore.Latch
|
|
|
|
// 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 BridgeRequestStatus `json:"status"`
|
|
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
|
|
// IsProcessed asks the destination gateway whether it already released this
|
|
// transfer. The release path calls it before requesting an attestation, so it
|
|
// belongs to the contract that path depends on.
|
|
IsProcessed(ctx context.Context, transfer bridgeattest.BridgeTransfer) (bool, 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]ChainClient)
|
|
|
|
// 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. The bridge's API is the
|
|
// JSON-RPC service in rpc.go: estimate a fee, submit a request, ask after one,
|
|
// read the signer set.
|
|
func (vm *VM) CreateHandlers(ctx context.Context) (map[string]http.Handler, error) {
|
|
return vm.CreateRPCHandlers()
|
|
}
|
|
|
|
// readiness reports whether this node can do what it is configured to do, and
|
|
// says what is missing when it cannot.
|
|
func (vm *VM) readiness() (ready bool, reason string, chains int) {
|
|
vm.mu.RLock()
|
|
chains = len(vm.evmByChainID)
|
|
watching := vm.watcher != nil
|
|
vm.mu.RUnlock()
|
|
|
|
// No chains wired means this node is not a relayer, which is a
|
|
// configuration and not a fault: it validates like any other node and has
|
|
// nothing to release.
|
|
if chains == 0 {
|
|
return true, "healthy", 0
|
|
}
|
|
// A relayer needs a threshold key to attest with and has to be reading the
|
|
// chains it relays from. Answering healthy without either routes transfers
|
|
// at a node that cannot carry them.
|
|
switch {
|
|
case len(vm.mpcGroupPublicKey()) == 0:
|
|
return false, "no threshold signing key", chains
|
|
case !watching:
|
|
return false, "not watching for locks", chains
|
|
default:
|
|
return true, "healthy", chains
|
|
}
|
|
}
|
|
|
|
// HealthCheck implements the common.VM interface, reporting what this node can
|
|
// do so traffic reaches one that can bridge.
|
|
func (vm *VM) HealthCheck(ctx context.Context) (chain.HealthResult, error) {
|
|
ready, reason, chains := vm.readiness()
|
|
return chain.HealthResult{
|
|
Healthy: ready,
|
|
Details: map[string]string{
|
|
"status": reason,
|
|
"chains": strconv.Itoa(chains),
|
|
},
|
|
}, 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 there is a bridge request to build a block from, or
|
|
// the VM stops. Waiting only on the context would mean BuildBlock is never
|
|
// called, so no lock the watcher found would ever reach a block.
|
|
func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
|
|
return vm.work.WaitForEvent(ctx)
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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
|
|
}
|