mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
A plugin is its own binary; the daemon that loads it has no business being linked into it. Twelve VMs reached into github.com/luxfi/node for four things, and each one now programs against a home that is actually its own: - fee admission (Policy, FlatPolicy, NoUserTxPolicy, Validate) joins the settlement half already in chains/fee. One fee package, and the nodefee alias that marked the split is gone. - the cross-chain artifact types move to chains/artifacts. - the factory the daemon registers is luxfi/vm/manager.Factory, and the version types are luxfi/version. Both are ordinary modules the daemon reads too, so a VM imports them directly rather than through anything of ours. The GPU switch is deleted, not moved. It was a global set during daemon startup that a separate process could never observe, so every plugin read its default; FHEOptions.Backend is the switch that was really there. go list -deps names no luxfi/node for aivm, bridgevm, dexvm, evm, fhevm, graphvm, identityvm, keyvm, mpcvm, quantumvm, schain or zkvm, and the built plugin binaries carry no such string. oraclevm and relayvm still do: they are re-export shims over luxfi/oracle and luxfi/relay, which build their fee policies upstream. Their tests keep naming the daemon's fee package because that is the type their value has.
1065 lines
37 KiB
Go
1065 lines
37 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"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/chains/chain"
|
|
"github.com/luxfi/chains/fee"
|
|
"github.com/luxfi/chains/internal/bridgeattest"
|
|
"github.com/luxfi/chains/internal/warpmsg"
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/runtime"
|
|
"github.com/luxfi/threshold/pkg/party"
|
|
"github.com/luxfi/threshold/protocols/cmp/config"
|
|
"github.com/luxfi/version"
|
|
vmcore "github.com/luxfi/vm"
|
|
vmchain "github.com/luxfi/vm/chain"
|
|
"github.com/luxfi/warp"
|
|
)
|
|
|
|
var (
|
|
_ vmchain.ChainVM = (*VM)(nil)
|
|
|
|
Version = &version.Semantic{
|
|
Major: 1,
|
|
Minor: 0,
|
|
Patch: 0,
|
|
}
|
|
)
|
|
|
|
// minValidatorBond is the bond a bridge signer puts at risk. It is slashable,
|
|
// NOT staked, and it is declared here once: a chain that names its own
|
|
// minimum in two places eventually enforces the smaller one.
|
|
const minValidatorBond uint64 = 1_000_000 * 1e9 // 1M LUX
|
|
|
|
// BridgeConfig contains VM configuration
|
|
type BridgeConfig struct {
|
|
// MinConfirmations is how deep a lock must be buried on its source chain
|
|
// before this chain will carry it. It is applied where the lock is read,
|
|
// so a lock that is not yet deep enough is left for a later pass rather
|
|
// than admitted and held.
|
|
MinConfirmations uint32 `json:"minConfirmations"`
|
|
BridgeFee uint64 `json:"bridgeFee"` // Fee in LUX for bridge operations
|
|
|
|
// ExternalChains is the ONE declaration of which chains this bridge
|
|
// serves. 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; every other node uses it to name the chains it will
|
|
// carry transfers between.
|
|
ExternalChains []ExternalChainConfig `json:"externalChains,omitempty"`
|
|
|
|
// The spend caps. Both are declared, never defaulted: a bridge whose cap
|
|
// is whatever the code happens to pick is a bridge nobody decided the risk
|
|
// of.
|
|
MaxBridgeAmount uint64 `json:"maxBridgeAmount"` // Maximum amount per transfer
|
|
DailyBridgeLimit uint64 `json:"dailyBridgeLimit"` // Maximum per destination per day
|
|
|
|
RequireValidatorBond uint64 `json:"requireValidatorBond"` // Bond required of a signer (slashable, NOT staked)
|
|
|
|
// MaxSigners is the size the signer set freezes at (LP-333).
|
|
MaxSigners int `json:"maxSigners"`
|
|
}
|
|
|
|
// validate refuses a configuration this chain cannot enforce. A bridge with no
|
|
// declared cap does not fail safe by accident: a zero per-transfer cap refuses
|
|
// every transfer silently at every block, which looks exactly like a bridge
|
|
// nobody is using.
|
|
func (c *BridgeConfig) validate() error {
|
|
switch {
|
|
case c.MinConfirmations == 0:
|
|
return errors.New("bridgevm: minConfirmations must be at least 1")
|
|
case c.MaxBridgeAmount == 0:
|
|
return errors.New("bridgevm: maxBridgeAmount must be declared")
|
|
case c.DailyBridgeLimit < c.MaxBridgeAmount:
|
|
return fmt.Errorf("bridgevm: dailyBridgeLimit %d is below maxBridgeAmount %d",
|
|
c.DailyBridgeLimit, c.MaxBridgeAmount)
|
|
case c.RequireValidatorBond < minValidatorBond:
|
|
return fmt.Errorf("bridgevm: requireValidatorBond %d is below the %d minimum",
|
|
c.RequireValidatorBond, minValidatorBond)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SignerSet tracks the current MPC signer set (LP-333)
|
|
// First MaxSigners validators opt-in without reshare. Reshare ONLY on slot
|
|
// replacement.
|
|
type SignerSet struct {
|
|
Signers []*SignerInfo `json:"signers"` // Active signers
|
|
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"` // t, where t+1 signers are required
|
|
PublicKey []byte `json:"publicKey"` // Combined threshold public key
|
|
}
|
|
|
|
// quorum is how many of n signers must agree, and it is the only definition of
|
|
// that number.
|
|
//
|
|
// ⌈2n/3⌉, in integers. The float it replaced read int(n*0.67), which at n=3
|
|
// asked for all three: a set with no fault tolerance at all, where one signer
|
|
// going away stops the bridge. At n=1 it asked for two of one, which nothing
|
|
// can satisfy. Rounding a security threshold through a configurable float also
|
|
// left the number a matter of taste; it is not.
|
|
func quorum(n int) int {
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
return (2*n + 2) / 3
|
|
}
|
|
|
|
// 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"` // Slashable bond, NOT stake
|
|
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"`
|
|
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
|
|
config BridgeConfig
|
|
log log.Logger
|
|
|
|
// chainID is the chain this VM is running, hashed into every block id so a
|
|
// block of one chain is not a block of another.
|
|
chainID ids.ID
|
|
|
|
// 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 attestations
|
|
// against this config. B-Chain never holds a custody key.
|
|
mpcConfig *config.Config
|
|
|
|
// LP-333: Signer Set Management (opt-in model)
|
|
signerSet *SignerSet
|
|
|
|
// pendingBridges holds the transfers waiting for a block, keyed by the
|
|
// digest that identifies them.
|
|
pendingBridges map[ids.ID]*BridgeRequest
|
|
|
|
// Permissionless settlement: authoritative quote engine + swap
|
|
// state, exposed via bridge_estimateFee / bridge_submitRequest /
|
|
// bridge_getStatus (see rpc.go).
|
|
quoteEngine *QuoteEngine
|
|
swapStore SwapStore
|
|
|
|
// evmByChainID routes a transfer to the client for its chain. Populated by
|
|
// EnableBridgeRelease on relayer nodes and empty everywhere else.
|
|
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
|
|
|
|
// chain is the durable state, the blocks in flight and the tip, under its
|
|
// own lock. What a block decides is written and committed there.
|
|
chain *chain.Store[*Block]
|
|
genesisBlock *Block
|
|
|
|
// fee is what this chain charges to admit a user-submitted transfer, before
|
|
// any M-Chain signing capacity is consumed.
|
|
fee chain.Fee
|
|
|
|
// mu guards the bridge's own state above — the transfers in flight, the
|
|
// signer set, the chain clients, the release plumbing. That is not the
|
|
// chain's state and does not share the chain's lock.
|
|
//
|
|
// LOCK ORDER, without exception: the chain's lock, then this one. A block
|
|
// publishes under the chain's lock and clears its transfers inside it, so
|
|
// any path taking them the other way round would deadlock.
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// BridgeRequest is one cross-chain transfer this chain has been asked to
|
|
// settle.
|
|
//
|
|
// ID is the transfer's digest — the same value M signs and the destination
|
|
// gateway keys its replay guard by — so the request's name IS its contents.
|
|
// Every field but SourceTxID is covered by it, and SourceTxID is a source-chain
|
|
// fact every node reads the same.
|
|
type BridgeRequest struct {
|
|
ID ids.ID `json:"id"`
|
|
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"`
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Initialize implements the chain.ChainVM interface
|
|
func (vm *VM) Initialize(
|
|
ctx context.Context,
|
|
vmInit vmcore.Init,
|
|
) error {
|
|
vm.rt = vmInit.Runtime
|
|
if vm.rt == nil {
|
|
return errors.New("bridgevm: no runtime")
|
|
}
|
|
if vmInit.DB == nil {
|
|
return errors.New("bridgevm: no database")
|
|
}
|
|
|
|
logger, ok := vm.rt.Log.(log.Logger)
|
|
if !ok {
|
|
return errors.New("bridgevm: invalid logger type")
|
|
}
|
|
vm.log = logger
|
|
vm.chainID = vm.rt.ChainID
|
|
|
|
vm.pendingBridges = make(map[ids.ID]*BridgeRequest)
|
|
vm.evmByChainID = make(map[uint32]ChainClient)
|
|
|
|
// B-Chain accepts user-submitted bridge transfers, so it declares the
|
|
// floor; the node's boot-time Validate refuses a zero-fee user-facing chain.
|
|
vm.fee = chain.Floor(vm.rt.NetworkID)
|
|
if err := fee.Validate(vm.fee.Policy()); err != nil {
|
|
return fmt.Errorf("bridgevm: fee policy: %w", err)
|
|
}
|
|
|
|
if len(vmInit.Config) > 0 {
|
|
if err := json.Unmarshal(vmInit.Config, &vm.config); err != nil {
|
|
return fmt.Errorf("bridgevm: parse config: %w", err)
|
|
}
|
|
}
|
|
if vm.config.MaxSigners == 0 {
|
|
vm.config.MaxSigners = 100 // LP-333: the set freezes here
|
|
}
|
|
if err := vm.config.validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
vm.signerSet = &SignerSet{
|
|
Signers: make([]*SignerInfo, 0, vm.config.MaxSigners),
|
|
Waitlist: make([]ids.NodeID, 0),
|
|
}
|
|
|
|
// Authoritative quote engine + swap store. The price feed default
|
|
// seeds the assets the bridge handles at genesis.
|
|
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 never broadcast.
|
|
|
|
genesis := &Genesis{}
|
|
if len(vmInit.Genesis) > 0 {
|
|
if err := json.Unmarshal(vmInit.Genesis, genesis); err != nil {
|
|
return fmt.Errorf("bridgevm: parse genesis: %w", err)
|
|
}
|
|
}
|
|
|
|
vm.chain = chain.New[*Block](vmInit.DB, nil)
|
|
|
|
genesisBlock := &Block{
|
|
BlockHeight: 0,
|
|
BlockTimestamp: genesis.Timestamp,
|
|
ParentID_: ids.Empty,
|
|
BridgeRequests: []*BridgeRequest{},
|
|
vm: vm,
|
|
}
|
|
genesisBlock.ID_ = genesisBlock.computeID()
|
|
vm.genesisBlock = genesisBlock
|
|
|
|
_, fresh, err := vm.chain.Open(genesisBlock, vm.parseBlock)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// A chain that reports no tip while its own settlement records are on disk
|
|
// did not find a fresh database — it failed to read one. Starting from
|
|
// genesis there would commit a new chain over a live one and re-open a
|
|
// daily cap that has already been spent, durably and without a word.
|
|
if fresh {
|
|
settled, err := hasAny(vmInit.DB, settledPrefix)
|
|
if err != nil {
|
|
return fmt.Errorf("bridgevm: read settlement records: %w", err)
|
|
}
|
|
if settled {
|
|
return errors.New("bridgevm: no tip recorded, but this database holds settled transfers — refusing to restart the chain over it")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// hasAny reports whether the database holds even one key under prefix.
|
|
func hasAny(db database.Database, prefix []byte) (bool, error) {
|
|
it := db.NewIteratorWithPrefix(prefix)
|
|
defer it.Release()
|
|
found := it.Next()
|
|
return found, it.Error()
|
|
}
|
|
|
|
// parseBlock decodes a block belonging to this VM. The store reads accepted
|
|
// blocks back through it, so there is one decoder rather than one per caller.
|
|
func (vm *VM) parseBlock(raw []byte) (*Block, error) {
|
|
blk := &Block{vm: vm}
|
|
if err := parseBlockBytes(raw, blk); err != nil {
|
|
return nil, err
|
|
}
|
|
blk.ID_ = blk.computeID()
|
|
return blk, nil
|
|
}
|
|
|
|
// BuildBlock implements the chain.ChainVM interface.
|
|
//
|
|
// The candidates are ordered by id and admitted by the SAME rule Verify
|
|
// applies, over the state as of the same parent. A builder with its own
|
|
// looser rule proposes a block every node refuses, and since a refused
|
|
// transfer goes straight back into the pending set, it proposes it again:
|
|
// block production stops and does not resume.
|
|
func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
|
|
return vm.chain.Propose(func(parent *Block) (*Block, error) {
|
|
state := vm.spendAt(parent)
|
|
|
|
// Chain time only moves forward, so a block is never stamped before
|
|
// its parent even when this node's clock disagrees.
|
|
at := time.Now().Unix()
|
|
if at < parent.BlockTimestamp {
|
|
at = parent.BlockTimestamp
|
|
}
|
|
day := at / dayLength
|
|
|
|
// pendingBridges is a map and Go randomises map iteration, so the
|
|
// candidate list is sorted before anything is chosen from it: the
|
|
// order is hashed into the block id, and the cap has to be a defined
|
|
// prefix rather than a random draw that can starve a transfer forever.
|
|
vm.mu.RLock()
|
|
candidates := make([]*BridgeRequest, 0, len(vm.pendingBridges))
|
|
for _, req := range vm.pendingBridges {
|
|
candidates = append(candidates, req)
|
|
}
|
|
vm.mu.RUnlock()
|
|
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
return bytes.Compare(candidates[i].ID[:], candidates[j].ID[:]) < 0
|
|
})
|
|
|
|
requests := make([]*BridgeRequest, 0, maxRequestsPerBlock)
|
|
for _, req := range candidates {
|
|
if len(requests) == maxRequestsPerBlock {
|
|
break
|
|
}
|
|
if err := state.admit(&vm.config, day, req); err != nil {
|
|
vm.log.Debug("bridgevm: transfer not carried",
|
|
log.Stringer("requestID", req.ID), log.String("reason", err.Error()))
|
|
continue
|
|
}
|
|
requests = append(requests, req)
|
|
}
|
|
if len(requests) == 0 {
|
|
return nil, errors.New("bridgevm: no transfer is ready to be carried")
|
|
}
|
|
|
|
blk := &Block{
|
|
ParentID_: parent.ID(),
|
|
BlockHeight: parent.Height() + 1,
|
|
BlockTimestamp: at,
|
|
BridgeRequests: requests,
|
|
vm: vm,
|
|
spend: state,
|
|
}
|
|
blk.ID_ = blk.computeID()
|
|
|
|
vm.log.Info("bridgevm: block built",
|
|
log.Stringer("blockID", blk.ID()),
|
|
log.Int("transfers", len(requests)),
|
|
)
|
|
return blk, nil
|
|
})
|
|
}
|
|
|
|
// GetBlock implements the chain.ChainVM interface
|
|
func (vm *VM) GetBlock(ctx context.Context, id ids.ID) (vmchain.Block, error) {
|
|
if vm.genesisBlock != nil && id == vm.genesisBlock.ID() {
|
|
return vm.genesisBlock, nil
|
|
}
|
|
return vm.chain.Block(id, vm.parseBlock)
|
|
}
|
|
|
|
// ParseBlock implements the chain.ChainVM interface
|
|
func (vm *VM) ParseBlock(ctx context.Context, raw []byte) (vmchain.Block, error) {
|
|
return vm.parseBlock(raw)
|
|
}
|
|
|
|
// SetPreference implements the chain.ChainVM interface
|
|
func (vm *VM) SetPreference(ctx context.Context, id ids.ID) error {
|
|
vm.chain.Prefer(id)
|
|
return nil
|
|
}
|
|
|
|
// LastAccepted implements the chain.ChainVM interface
|
|
func (vm *VM) LastAccepted(ctx context.Context) (ids.ID, error) {
|
|
id, _ := vm.chain.Tip()
|
|
return id, 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) (vmchain.HealthResult, error) {
|
|
ready, reason, chains := vm.readiness()
|
|
return vmchain.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 *vmchain.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 {
|
|
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 {
|
|
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 answers from the height index the store writes in the
|
|
// same commit as the block itself, so the index can never name a block the
|
|
// chain did not accept.
|
|
func (vm *VM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, error) {
|
|
return vm.chain.IDAtHeight(height)
|
|
}
|
|
|
|
// SetState implements the common.VM interface
|
|
func (vm *VM) SetState(ctx context.Context, state uint32) error {
|
|
return nil
|
|
}
|
|
|
|
// NewHTTPHandler serves the same API CreateHandlers names, mounted where it
|
|
// names it. Building a second route table here is how the two drift.
|
|
func (vm *VM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
|
|
mux := http.NewServeMux()
|
|
mux.Handle(rpcPath, &jsonRPCHandler{service: NewService(vm)})
|
|
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)
|
|
}
|
|
|
|
// Genesis represents the genesis state
|
|
type Genesis struct {
|
|
Timestamp int64 `json:"timestamp"`
|
|
}
|
|
|
|
// =============================================================================
|
|
// LP-333: Opt-In Signer Set Management
|
|
// First MaxSigners validators opt-in without reshare. Reshare ONLY on slot
|
|
// replacement.
|
|
// =============================================================================
|
|
|
|
// renumber makes each signer's slot its position in the set. The slot was
|
|
// stored and left alone through removals, so after the first one it named a
|
|
// position the signer no longer held.
|
|
func (s *SignerSet) renumber() {
|
|
for i, signer := range s.Signers {
|
|
signer.SlotIndex = i
|
|
}
|
|
s.ThresholdT = quorum(len(s.Signers)) - 1
|
|
if s.ThresholdT < 0 {
|
|
s.ThresholdT = 0
|
|
}
|
|
}
|
|
|
|
// RegisterValidator registers a new validator as a bridge signer (opt-in model)
|
|
// LP-333: the first MaxSigners validators are accepted directly - NO reshare on
|
|
// join. After that, new validators go to the waitlist until a slot opens.
|
|
func (vm *VM) RegisterValidator(input *RegisterValidatorInput) (*RegisterValidatorResult, error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
|
|
nodeID, err := ids.NodeIDFromString(input.NodeID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bridgevm: invalid node ID: %w", err)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
for _, wl := range vm.signerSet.Waitlist {
|
|
if wl == nodeID {
|
|
return &RegisterValidatorResult{
|
|
Success: false,
|
|
NodeID: input.NodeID,
|
|
Message: "already on waitlist",
|
|
Waitlisted: true,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// The bond is the whole of a signer's accountability, and the chain
|
|
// declares what it must be. Reading the field and admitting whatever it
|
|
// said left the requirement documented and unapplied.
|
|
bondAmount, err := strconv.ParseUint(input.BondAmount, 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bridgevm: bond amount %q is not a number", input.BondAmount)
|
|
}
|
|
if bondAmount < vm.config.RequireValidatorBond {
|
|
return nil, fmt.Errorf("bridgevm: bond %d is below the required %d",
|
|
bondAmount, vm.config.RequireValidatorBond)
|
|
}
|
|
|
|
if !vm.signerSet.SetFrozen && len(vm.signerSet.Signers) < vm.config.MaxSigners {
|
|
signerInfo := &SignerInfo{
|
|
NodeID: nodeID,
|
|
PartyID: party.ID(nodeID.String()),
|
|
BondAmount: bondAmount,
|
|
Active: true,
|
|
JoinedAt: time.Now(),
|
|
}
|
|
if input.MPCPubKey != "" {
|
|
signerInfo.MPCPubKey = []byte(input.MPCPubKey)
|
|
}
|
|
|
|
vm.signerSet.Signers = append(vm.signerSet.Signers, signerInfo)
|
|
vm.signerSet.renumber()
|
|
if len(vm.signerSet.Signers) >= vm.config.MaxSigners {
|
|
vm.signerSet.SetFrozen = true
|
|
}
|
|
|
|
vm.log.Info("bridgevm: validator registered as signer",
|
|
log.Stringer("nodeID", nodeID),
|
|
log.Int("slot", signerInfo.SlotIndex),
|
|
log.Int("signers", len(vm.signerSet.Signers)),
|
|
log.Int("threshold", vm.signerSet.ThresholdT),
|
|
log.Bool("frozen", vm.signerSet.SetFrozen),
|
|
)
|
|
|
|
return &RegisterValidatorResult{
|
|
Success: true,
|
|
NodeID: input.NodeID,
|
|
Registered: true,
|
|
SignerIndex: signerInfo.SlotIndex,
|
|
TotalSigners: len(vm.signerSet.Signers),
|
|
Threshold: vm.signerSet.ThresholdT,
|
|
CurrentEpoch: vm.signerSet.CurrentEpoch,
|
|
SetFrozen: vm.signerSet.SetFrozen,
|
|
RemainingSlots: vm.config.MaxSigners - len(vm.signerSet.Signers),
|
|
Message: "registered as bridge signer",
|
|
}, nil
|
|
}
|
|
|
|
vm.signerSet.Waitlist = append(vm.signerSet.Waitlist, nodeID)
|
|
waitlistIndex := len(vm.signerSet.Waitlist) - 1
|
|
|
|
vm.log.Info("bridgevm: validator waitlisted, signer set frozen",
|
|
log.Stringer("nodeID", nodeID),
|
|
log.Int("waitlistIndex", waitlistIndex),
|
|
log.Int("signers", len(vm.signerSet.Signers)),
|
|
)
|
|
|
|
return &RegisterValidatorResult{
|
|
Success: true,
|
|
NodeID: input.NodeID,
|
|
Waitlisted: true,
|
|
WaitlistIndex: waitlistIndex,
|
|
TotalSigners: len(vm.signerSet.Signers),
|
|
Threshold: vm.signerSet.ThresholdT,
|
|
CurrentEpoch: vm.signerSet.CurrentEpoch,
|
|
SetFrozen: vm.signerSet.SetFrozen,
|
|
Message: "added to waitlist (signer set frozen)",
|
|
}, nil
|
|
}
|
|
|
|
// GetSignerSetInfo returns information about the current signer set
|
|
func (vm *VM) GetSignerSetInfo() *SignerSetInfo {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
|
|
info := &SignerSetInfo{
|
|
TotalSigners: len(vm.signerSet.Signers),
|
|
Threshold: vm.signerSet.ThresholdT,
|
|
MaxSigners: vm.config.MaxSigners,
|
|
CurrentEpoch: vm.signerSet.CurrentEpoch,
|
|
SetFrozen: vm.signerSet.SetFrozen,
|
|
RemainingSlots: vm.config.MaxSigners - len(vm.signerSet.Signers),
|
|
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, and the epoch
|
|
// increments only here.
|
|
func (vm *VM) RemoveSigner(nodeID ids.NodeID, replacementNodeID *ids.NodeID) *SignerReplacementResult {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
|
|
slot := -1
|
|
for i, signer := range vm.signerSet.Signers {
|
|
if signer.NodeID == nodeID {
|
|
slot = i
|
|
break
|
|
}
|
|
}
|
|
if slot < 0 {
|
|
return &SignerReplacementResult{
|
|
Success: false,
|
|
Message: fmt.Sprintf("signer %s not found in active set", nodeID),
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
if replacement == ids.EmptyNodeID {
|
|
vm.signerSet.Signers = append(vm.signerSet.Signers[:slot], vm.signerSet.Signers[slot+1:]...)
|
|
} else {
|
|
// The replacement takes the vacated slot. Splicing the old signer out
|
|
// and appending the new one moved every signer above it, which is a
|
|
// reshuffle of the set rather than the one-slot replacement LP-333
|
|
// describes.
|
|
vm.signerSet.Signers[slot] = &SignerInfo{
|
|
NodeID: replacement,
|
|
PartyID: party.ID(replacement.String()),
|
|
// The bond is verified during the reshare, not asserted here.
|
|
Active: true,
|
|
JoinedAt: time.Now(),
|
|
}
|
|
}
|
|
vm.signerSet.renumber()
|
|
vm.signerSet.CurrentEpoch++
|
|
|
|
reshareSession := fmt.Sprintf("reshare-epoch-%d-%s", vm.signerSet.CurrentEpoch, time.Now().Format("20060102150405"))
|
|
|
|
vm.log.Info("bridgevm: signer removed, reshare triggered",
|
|
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 the reshare on M-Chain (LP-1340) over warp. A failure here is
|
|
// retryable and does not undo the removal: the signer is out either way.
|
|
if err := vm.triggerReshareProtocol(reshareSession, nodeID, replacement); err != nil {
|
|
vm.log.Warn("bridgevm: reshare protocol not triggered",
|
|
log.String("reshareSession", reshareSession),
|
|
log.String("error", err.Error()),
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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 M-Chain to initiate the
|
|
// MPC key reshare protocol. Called when a signer is replaced; the caller holds
|
|
// vm.mu.
|
|
func (vm *VM) triggerReshareProtocol(sessionID string, removedNodeID ids.NodeID, newNodeID ids.NodeID) error {
|
|
if vm.rt == nil || vm.rt.WarpSigner == nil || vm.rt.Sender == nil {
|
|
// Not a node with warp plumbing (a unit test, or a node that does not
|
|
// relay). Nothing to send, and nothing wrong.
|
|
return nil
|
|
}
|
|
|
|
oldPartyIDs := make([]party.ID, 0, len(vm.signerSet.Signers))
|
|
newPartyIDs := 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)
|
|
}
|
|
newPartyIDs = append(newPartyIDs, signer.PartyID)
|
|
}
|
|
|
|
requestBytes, err := json.Marshal(&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(),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("bridgevm: 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 M-Chain 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("bridgevm: build signed warp message: %w", err)
|
|
}
|
|
msgBytes, err := env.Bytes()
|
|
if err != nil {
|
|
return fmt.Errorf("bridgevm: encode warp envelope: %w", err)
|
|
}
|
|
|
|
if err := vm.rt.Sender.SendGossip(context.Background(), warp.SendConfig{
|
|
Validators: len(vm.signerSet.Signers),
|
|
}, msgBytes); err != nil {
|
|
return fmt.Errorf("bridgevm: broadcast reshare request: %w", err)
|
|
}
|
|
|
|
vm.log.Info("bridgevm: 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 is 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()
|
|
|
|
if input.SlashPercent < 1 || input.SlashPercent > 100 {
|
|
return nil, errors.New("bridgevm: slash percent must be between 1 and 100")
|
|
}
|
|
|
|
var signer *SignerInfo
|
|
signerIndex := -1
|
|
for i, s := range vm.signerSet.Signers {
|
|
if s.NodeID == input.NodeID {
|
|
signer, signerIndex = s, i
|
|
break
|
|
}
|
|
}
|
|
if signer == nil {
|
|
return &SlashSignerResult{
|
|
Success: false,
|
|
NodeID: input.NodeID.String(),
|
|
Message: "signer not found in active set",
|
|
}, nil
|
|
}
|
|
|
|
slashAmount := (signer.BondAmount * uint64(input.SlashPercent)) / 100
|
|
remainingBond := signer.BondAmount - slashAmount
|
|
|
|
signer.BondAmount = remainingBond
|
|
signer.Slashed = true
|
|
signer.SlashCount++
|
|
|
|
vm.log.Warn("bridgevm: 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,
|
|
Message: fmt.Sprintf("slashed %d%% of bond (%d)", input.SlashPercent, slashAmount),
|
|
}
|
|
|
|
// A signer whose bond no longer meets what the chain requires is not a
|
|
// signer. The requirement is read from the same declaration registration
|
|
// applies, so the two cannot drift apart.
|
|
if remainingBond < vm.config.RequireValidatorBond {
|
|
vm.signerSet.Signers = append(vm.signerSet.Signers[:signerIndex], vm.signerSet.Signers[signerIndex+1:]...)
|
|
vm.signerSet.renumber()
|
|
vm.signerSet.CurrentEpoch++
|
|
|
|
result.RemovedFromSet = true
|
|
result.Message = fmt.Sprintf("slashed %d%% of bond, signer removed (bond below the required minimum)", input.SlashPercent)
|
|
|
|
vm.log.Warn("bridgevm: signer removed, bond below the required minimum",
|
|
log.Stringer("nodeID", input.NodeID),
|
|
log.Uint64("remainingBond", remainingBond),
|
|
log.Uint64("newEpoch", vm.signerSet.CurrentEpoch),
|
|
)
|
|
}
|
|
|
|
return result, nil
|
|
}
|