mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
305 lines
8.8 KiB
Go
305 lines
8.8 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/luxfi/bridge/opnet-watcher/plugins"
|
|
)
|
|
|
|
// Watcher orchestrates the plugin -> signer -> relay pipeline.
|
|
// It runs the block polling loop and the backing attestor as separate goroutines,
|
|
// coordinating graceful shutdown via context cancellation.
|
|
type Watcher struct {
|
|
plugin plugins.ChainPlugin
|
|
signer *Signer
|
|
relay *Relay
|
|
cfg Config
|
|
lastPos uint64 // last processed block/slot/lt
|
|
lastBacking *big.Int // HIGH-03: last attested backing for sanity check
|
|
|
|
// HIGH-01: Deposit nonce deduplication.
|
|
// seen[chainID][nonce] = true means this deposit was already relayed.
|
|
seen map[uint64]map[uint64]bool
|
|
}
|
|
|
|
// checkpoint is the on-disk format for watcher state persistence.
|
|
type checkpoint struct {
|
|
LastPos uint64 `json:"last_pos"`
|
|
Seen map[uint64]map[uint64]bool `json:"seen"`
|
|
}
|
|
|
|
// NewWatcher assembles the full pipeline from config.
|
|
// It loads checkpoint state from disk if available.
|
|
func NewWatcher(cfg Config, plugin plugins.ChainPlugin, signer *Signer, relay *Relay) *Watcher {
|
|
w := &Watcher{
|
|
plugin: plugin,
|
|
signer: signer,
|
|
relay: relay,
|
|
cfg: cfg,
|
|
lastPos: cfg.StartBlock,
|
|
seen: make(map[uint64]map[uint64]bool),
|
|
}
|
|
// HIGH-01: Restore checkpoint from disk.
|
|
if err := w.loadCheckpoint(); err != nil {
|
|
log.Printf("watcher: no checkpoint loaded: %v", err)
|
|
}
|
|
return w
|
|
}
|
|
|
|
// Run starts the watcher. It blocks until ctx is cancelled.
|
|
func (w *Watcher) Run(ctx context.Context) error {
|
|
log.Printf("watcher: starting chain=%s chain_id=%d source=%s lux=%s",
|
|
w.plugin.Name(), w.plugin.ChainID(), w.cfg.SourceRPCURL, w.cfg.LuxRPCURL)
|
|
log.Printf("watcher: teleporter=%s bridge=%s poll=%s backing=%s depth=%d",
|
|
w.cfg.TeleporterAddr, w.cfg.SourceBridgeAddr, w.cfg.BlockPollInterval, w.cfg.BackingInterval, w.cfg.ConfirmationDepth)
|
|
|
|
// Start backing attestor in background.
|
|
go w.runBackingLoop(ctx)
|
|
|
|
// Main event loop: poll blocks, process events.
|
|
ticker := time.NewTicker(w.cfg.BlockPollInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Println("watcher: shutting down")
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
w.pollAndRelay(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// pollAndRelay fetches new deposit events from the plugin and relays them.
|
|
// CRITICAL-02: Only processes blocks with sufficient confirmations to prevent reorg-based drain.
|
|
func (w *Watcher) pollAndRelay(ctx context.Context) {
|
|
events, newPos, err := w.plugin.PollDeposits(ctx, w.lastPos)
|
|
if err != nil {
|
|
log.Printf("watcher: poll error: %v", err)
|
|
return
|
|
}
|
|
|
|
// CRITICAL-02: Only process blocks with confirmationDepth confirmations.
|
|
if newPos <= w.cfg.ConfirmationDepth {
|
|
return
|
|
}
|
|
confirmedPos := newPos - w.cfg.ConfirmationDepth
|
|
|
|
var confirmed []plugins.DepositEvent
|
|
for _, ev := range events {
|
|
if ev.BlockHeight <= confirmedPos {
|
|
confirmed = append(confirmed, ev)
|
|
}
|
|
}
|
|
|
|
for _, ev := range confirmed {
|
|
if err := w.processDeposit(ctx, ev); err != nil {
|
|
log.Printf("watcher: deposit error block=%d nonce=%d: %v", ev.BlockHeight, ev.Nonce, err)
|
|
}
|
|
}
|
|
|
|
if confirmedPos > w.lastPos {
|
|
if len(confirmed) > 0 {
|
|
log.Printf("watcher: processed %d deposits through pos=%d (tip=%d depth=%d)",
|
|
len(confirmed), confirmedPos, newPos, w.cfg.ConfirmationDepth)
|
|
}
|
|
w.lastPos = confirmedPos
|
|
// HIGH-01: Persist checkpoint after advancing position.
|
|
if err := w.saveCheckpoint(); err != nil {
|
|
log.Printf("watcher: checkpoint save error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// processDeposit signs a deposit proof and submits it to Teleporter.mintDeposit.
|
|
// HIGH-01: Skips deposits that have already been relayed (dedup by chainID+nonce).
|
|
func (w *Watcher) processDeposit(ctx context.Context, ev plugins.DepositEvent) error {
|
|
chainID := w.plugin.ChainID()
|
|
|
|
// HIGH-01: Check dedup before signing or relaying.
|
|
if w.seen[chainID] != nil && w.seen[chainID][ev.Nonce] {
|
|
log.Printf("watcher: skipping duplicate deposit chain=%d nonce=%d", chainID, ev.Nonce)
|
|
return nil
|
|
}
|
|
|
|
log.Printf("watcher: deposit block=%d nonce=%d recipient=%x amount=%s",
|
|
ev.BlockHeight, ev.Nonce, ev.Recipient, ev.Amount.String())
|
|
|
|
sig, err := w.signer.SignDeposit(chainID, ev.Nonce, ev.Recipient, ev.Amount)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
txHash, err := w.relay.SubmitDeposit(ctx, chainID, ev.Nonce, ev.Recipient, ev.Amount, sig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// HIGH-01: Mark as seen after successful relay.
|
|
if w.seen[chainID] == nil {
|
|
w.seen[chainID] = make(map[uint64]bool)
|
|
}
|
|
w.seen[chainID][ev.Nonce] = true
|
|
|
|
log.Printf("watcher: deposit relayed nonce=%d tx=%s", ev.Nonce, txHash)
|
|
return nil
|
|
}
|
|
|
|
// runBackingLoop periodically queries total locked from the source chain
|
|
// and submits backing attestations to Teleporter.updateBacking.
|
|
func (w *Watcher) runBackingLoop(ctx context.Context) {
|
|
w.attestBacking(ctx)
|
|
|
|
ticker := time.NewTicker(w.cfg.BackingInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Println("backing: shutting down")
|
|
return
|
|
case <-ticker.C:
|
|
w.attestBacking(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *Watcher) attestBacking(ctx context.Context) {
|
|
totalLocked, err := w.plugin.QueryBacking(ctx)
|
|
if err != nil {
|
|
log.Printf("backing: query failed: %v", err)
|
|
return
|
|
}
|
|
|
|
// HIGH-03: Sanity check — backing cannot change by more than MaxBackingChangePct per attestation.
|
|
if w.lastBacking != nil && w.lastBacking.Sign() > 0 {
|
|
maxChange := new(big.Int).Mul(w.lastBacking, new(big.Int).SetUint64(w.cfg.MaxBackingChangePct))
|
|
maxChange.Div(maxChange, big.NewInt(100))
|
|
diff := new(big.Int).Sub(totalLocked, w.lastBacking)
|
|
if diff.Sign() < 0 {
|
|
diff.Neg(diff)
|
|
}
|
|
if diff.Cmp(maxChange) > 0 {
|
|
log.Printf("backing: REJECTED — change too large: %s -> %s (max %d%% = %s)",
|
|
w.lastBacking.String(), totalLocked.String(), w.cfg.MaxBackingChangePct, maxChange.String())
|
|
return
|
|
}
|
|
// Log significant changes as warnings even within bounds.
|
|
tenPct := new(big.Int).Div(w.lastBacking, big.NewInt(10))
|
|
if diff.Cmp(tenPct) > 0 {
|
|
log.Printf("backing: WARNING — significant change: %s -> %s", w.lastBacking.String(), totalLocked.String())
|
|
}
|
|
}
|
|
|
|
chainID := w.plugin.ChainID()
|
|
timestamp := uint64(time.Now().Unix())
|
|
|
|
sig, err := w.signer.SignBacking(chainID, totalLocked, timestamp)
|
|
if err != nil {
|
|
log.Printf("backing: sign failed: %v", err)
|
|
return
|
|
}
|
|
|
|
// CRITICAL-01: Pass timestamp to SubmitBacking (signed by MPC, validated on-chain).
|
|
txHash, err := w.relay.SubmitBacking(ctx, chainID, totalLocked, timestamp, sig)
|
|
if err != nil {
|
|
log.Printf("backing: submit failed: %v", err)
|
|
return
|
|
}
|
|
|
|
w.lastBacking = totalLocked
|
|
log.Printf("backing: attested chain=%s total=%s tx=%s", w.plugin.Name(), totalLocked.String(), txHash)
|
|
}
|
|
|
|
// loadCheckpoint restores watcher state from the checkpoint file.
|
|
// Returns an error if the file doesn't exist or can't be parsed (not fatal).
|
|
func (w *Watcher) loadCheckpoint() error {
|
|
if w.cfg.CheckpointPath == "" {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(w.cfg.CheckpointPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var cp checkpoint
|
|
if err := json.Unmarshal(data, &cp); err != nil {
|
|
return err
|
|
}
|
|
if cp.LastPos > w.lastPos {
|
|
w.lastPos = cp.LastPos
|
|
}
|
|
if cp.Seen != nil {
|
|
w.seen = cp.Seen
|
|
}
|
|
log.Printf("watcher: checkpoint loaded pos=%d seen_chains=%d", w.lastPos, len(w.seen))
|
|
return nil
|
|
}
|
|
|
|
// maxSeenPerChain limits the seen map to prevent unbounded memory growth.
|
|
// Since nonces are monotonically increasing, keeping the last N is sufficient.
|
|
const maxSeenPerChain = 10000
|
|
|
|
// saveCheckpoint writes current watcher state to the checkpoint file.
|
|
// Uses atomic write (write to tmp, rename) to avoid partial writes on crash.
|
|
// Prunes old nonces to prevent unbounded growth of the seen map.
|
|
func (w *Watcher) saveCheckpoint() error {
|
|
if w.cfg.CheckpointPath == "" {
|
|
return nil
|
|
}
|
|
|
|
// Prune old nonces to cap memory usage.
|
|
w.pruneSeen()
|
|
|
|
cp := checkpoint{
|
|
LastPos: w.lastPos,
|
|
Seen: w.seen,
|
|
}
|
|
data, err := json.Marshal(cp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir := filepath.Dir(w.cfg.CheckpointPath)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := w.cfg.CheckpointPath + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, w.cfg.CheckpointPath)
|
|
}
|
|
|
|
// pruneSeen removes old nonce entries to prevent unbounded map growth.
|
|
// Keeps only the highest maxSeenPerChain nonces per chain.
|
|
func (w *Watcher) pruneSeen() {
|
|
for chainID, nonces := range w.seen {
|
|
if len(nonces) <= maxSeenPerChain {
|
|
continue
|
|
}
|
|
// Find the threshold: keep only nonces above (maxNonce - maxSeenPerChain).
|
|
var maxNonce uint64
|
|
for n := range nonces {
|
|
if n > maxNonce {
|
|
maxNonce = n
|
|
}
|
|
}
|
|
threshold := maxNonce - uint64(maxSeenPerChain) + 1
|
|
for n := range nonces {
|
|
if n < threshold {
|
|
delete(nonces, n)
|
|
}
|
|
}
|
|
w.seen[chainID] = nonces
|
|
}
|
|
}
|