Files
chains/bridgevm/release.go
T
zeekayandHanzo Dev 4ba9544b51 bridgevm: B<->M release path + canonical attestation seam
Completes the bridge cross-chain release leg the recon flagged as missing.

- internal/bridgeattest: the ONE canonical definition of the bridge-transfer
  attestation seam between B (bridgevm) and M (mpcvm). M threshold-signs a
  domain-bound bridge-transfer digest; B and every EVM gateway holding the group
  key's address verifies before mint/release. Domain-bound + replay-safe; test
  proves a signature over one transfer verifies for THAT transfer and no other.
- evmclient.go: the concrete EVM ChainClient B instantiates per external EVM
  (Zoo 200201, Lux testnet C 96368) — chainClients was never populated because
  the interface had SendTransaction but no concrete EVM client; B could not
  broadcast a release. Now wired via evmByChainID in factory.
- release.go: on a confirmed lock/burn in an accepted block, B builds the
  destination release call, obtains + verifies M's threshold attestation, and
  broadcasts to the destination gateway. Both directions (Zoo<->Lux).
- attest.go: injects the two runtime deps (Warp->M, KMS relayer key) as clean
  interface boundaries rather than hard-wiring transports.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-12 16:44:17 -07:00

317 lines
11 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
// release.go — the release path. On a confirmed lock/burn (a bridge request in
// an accepted block), B builds the destination-chain release call, obtains a
// threshold-signed attestation from M-Chain, verifies it, and broadcasts the
// release to the destination gateway. Both directions (Zoo->Lux and Lux->Zoo)
// are the same code — the transfer carries src/dst chain ids and the releaser
// routes by them.
//
// This subsystem is ORTHOGONAL to consensus boot: Initialize brings up the VM;
// EnableBridgeRelease wires the EVM plumbing with its runtime dependencies (the
// KMS-backed relayer keys and the Warp-backed attestation client). A node that
// is not a bridge relayer never calls it, and Block.Accept then behaves exactly
// as before. The broadcast itself happens OFF the consensus path in a worker, so
// Accept never blocks on network I/O; every relayer validator broadcasts and the
// on-chain nonce replay-guard collapses the duplicates to a single mint.
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/luxfi/chains/internal/bridgeattest"
ethereum "github.com/luxfi/geth"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
var (
errNoDestClient = errors.New("bridgevm: no EVM client for destination chain")
errBadAttestation = errors.New("bridgevm: attestation failed local verification")
errInsufficientConfirmations = errors.New("bridgevm: source confirmations below minimum")
errAlreadyReleased = errors.New("bridgevm: transfer already released on destination")
)
// releaseMaxRetries bounds how hard the worker retries a single transfer before
// logging and dropping it. Dropping is safe: the transfer can be re-observed and
// re-enqueued, and other relayer validators broadcast independently.
const releaseMaxRetries = 5
// transfer maps a consensus bridge request to the canonical, domain-bound value
// M signs and the gateway verifies. ids.ID is a [32]byte, so Asset maps directly.
func (r *BridgeRequest) transfer() (bridgeattest.BridgeTransfer, error) {
if len(r.Recipient) != 20 {
return bridgeattest.BridgeTransfer{}, fmt.Errorf("bridgevm: recipient must be 20 bytes, got %d", len(r.Recipient))
}
if r.DstChainID == 0 {
return bridgeattest.BridgeTransfer{}, errors.New("bridgevm: request missing destination chain id")
}
var recip [20]byte
copy(recip[:], r.Recipient)
return bridgeattest.BridgeTransfer{
SrcChainID: r.SrcChainID,
DstChainID: r.DstChainID,
Asset: [32]byte(r.Asset),
Amount: r.Amount,
Recipient: recip,
Nonce: r.Nonce,
}, nil
}
// releaser drives releases off the consensus path.
type releaser struct {
vm *VM
queue chan *BridgeRequest
quit chan struct{}
wg sync.WaitGroup
once sync.Once
}
func newReleaser(vm *VM) *releaser {
r := &releaser{
vm: vm,
queue: make(chan *BridgeRequest, 1024),
quit: make(chan struct{}),
}
r.wg.Add(1)
go r.run()
return r
}
// enqueue is a non-blocking submit called from Block.Accept (under vm.mu). If the
// queue is full the request is dropped with a warning; it will be re-observed.
func (r *releaser) enqueue(req *BridgeRequest) {
select {
case r.queue <- req:
default:
if r.vm.log != nil {
r.vm.log.Warn("bridgevm: release queue full, dropping (will re-observe)",
log.Stringer("requestID", req.ID))
}
}
}
func (r *releaser) stop() {
r.once.Do(func() { close(r.quit) })
r.wg.Wait()
}
func (r *releaser) run() {
defer r.wg.Done()
for {
select {
case <-r.quit:
return
case req := <-r.queue:
r.handle(req)
}
}
}
func (r *releaser) handle(req *BridgeRequest) {
transfer, err := req.transfer()
if err != nil {
r.vm.log.Warn("bridgevm: skip release, bad request", log.Stringer("requestID", req.ID), log.Any("err", err))
return
}
backoff := 250 * time.Millisecond
for attempt := 0; attempt < releaseMaxRetries; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := r.releaseOnce(ctx, req, transfer)
cancel()
if err == nil {
return
}
if errors.Is(err, errAlreadyReleased) {
r.vm.log.Info("bridgevm: release already processed on-chain",
log.Stringer("requestID", req.ID), log.Uint64("nonce", transfer.Nonce))
return
}
r.vm.log.Warn("bridgevm: release attempt failed",
log.Stringer("requestID", req.ID),
log.Int("attempt", attempt+1),
log.Any("err", err))
select {
case <-r.quit:
return
case <-time.After(backoff):
backoff *= 2
}
}
r.vm.log.Error("bridgevm: release gave up after retries",
log.Stringer("requestID", req.ID), log.Uint64("nonce", transfer.Nonce))
}
// releaseOnce re-confirms the source lock from B's OWN view (the trust-gap fix)
// then runs the release. Confirmation re-check needs the source tx hash, which
// lives on the request — so it happens here, not in releaseTransfer.
func (r *releaser) releaseOnce(ctx context.Context, req *BridgeRequest, transfer bridgeattest.BridgeTransfer) error {
if src := r.vm.evmClientByID(transfer.SrcChainID); src != nil && req.SourceTxID != ids.Empty {
conf, err := src.GetConfirmations(ctx, req.SourceTxID)
if err != nil {
return fmt.Errorf("bridgevm: source confirmation check: %w", err)
}
if conf < r.vm.config.MinConfirmations {
return fmt.Errorf("%w: %d < %d", errInsufficientConfirmations, conf, r.vm.config.MinConfirmations)
}
}
destTx, err := r.vm.releaseTransfer(ctx, transfer)
if err != nil {
return err
}
r.vm.recordRelease(req, destTx)
return nil
}
// releaseTransfer runs the destination-side release for one transfer: idempotency
// check, attestation from M, local verification, then broadcast to the gateway.
// Direction-agnostic: it routes purely by transfer.DstChainID.
func (vm *VM) releaseTransfer(ctx context.Context, transfer bridgeattest.BridgeTransfer) (ids.ID, error) {
dst := vm.evmClientByID(transfer.DstChainID)
if dst == nil {
return ids.Empty, fmt.Errorf("%w: dstChainId=%d", errNoDestClient, transfer.DstChainID)
}
// 1. Idempotent convergence: if the destination gateway already processed
// this exact transfer (keyed by its digest, so cross-source nonces never
// collide), we are done — no attestation request, no broadcast.
if done, err := dst.IsProcessed(ctx, transfer); err == nil && done {
return ids.Empty, errAlreadyReleased
}
// 2. Obtain the threshold-signed attestation from M-Chain.
if vm.attestClient == nil {
return ids.Empty, errors.New("bridgevm: attestation client not configured")
}
att, err := vm.attestClient.AttestBridgeTransfer(ctx, transfer)
if err != nil {
return ids.Empty, fmt.Errorf("bridgevm: attest: %w", err)
}
// 3. Verify M's attestation locally before spending gas — defense in depth.
if att == nil || !att.Verify() {
return ids.Empty, errBadAttestation
}
// 4. Broadcast the release to the destination gateway.
return dst.SendTransaction(ctx, &ReleaseCall{Transfer: transfer, Signature: att.Signature})
}
// recordRelease persists the completed cross-chain settlement.
func (vm *VM) recordRelease(req *BridgeRequest, destTx ids.ID) {
vm.bridgeRegistry.mu.Lock()
defer vm.bridgeRegistry.mu.Unlock()
cb := vm.bridgeRegistry.CompletedBridges[req.ID]
if cb == nil {
cb = &CompletedBridge{RequestID: req.ID, SourceTxID: req.SourceTxID, CompletedAt: time.Now()}
vm.bridgeRegistry.CompletedBridges[req.ID] = cb
}
cb.DestTxID = destTx
vm.log.Info("bridgevm: release settled",
log.Stringer("requestID", req.ID),
log.Stringer("destTxID", destTx))
}
func (vm *VM) evmClientByID(id uint32) *evmChainClient {
vm.mu.RLock()
defer vm.mu.RUnlock()
return vm.evmByChainID[id]
}
// EnableBridgeRelease wires the EVM release plumbing: it dials each external
// chain, resolves that chain's gas-paying relayer key from KMS via kp, records
// the clients in BOTH the name-keyed ChainClient map (the field the recon
// flagged as never populated) and the id-keyed routing index, sets the
// attestation client, and starts the release worker. Idempotent per process:
// calling it twice replaces the wiring. Fail secure: any misconfigured chain or
// unresolved key aborts the whole enable — no partial, half-wired bridge.
func (vm *VM) EnableBridgeRelease(ctx context.Context, chains []ExternalChainConfig, kp KeyProvider, ac AttestationClient) error {
if len(chains) == 0 {
return errors.New("bridgevm: EnableBridgeRelease: no external chains configured")
}
if kp == nil {
return errors.New("bridgevm: EnableBridgeRelease: KeyProvider required (KMS-backed; no plaintext fallback)")
}
if ac == nil {
return errors.New("bridgevm: EnableBridgeRelease: AttestationClient required")
}
byName := make(map[string]ChainClient, len(chains))
byID := make(map[uint32]*evmChainClient, len(chains))
for _, cfg := range chains {
if cfg.GasKeyKMSPath == "" {
return fmt.Errorf("bridgevm: chain %q: gasKeyKmsPath required (KMS only)", cfg.Name)
}
key, err := kp(ctx, cfg.GasKeyKMSPath)
if err != nil {
return fmt.Errorf("bridgevm: chain %q: resolve gas key from KMS %q: %w", cfg.Name, cfg.GasKeyKMSPath, err)
}
client, err := newEVMChainClient(ctx, cfg, key, vm.log)
if err != nil {
return err
}
if _, dup := byID[uint32(cfg.ChainID)]; dup {
return fmt.Errorf("bridgevm: duplicate chain id %d", cfg.ChainID)
}
byName[cfg.Name] = client
byID[uint32(cfg.ChainID)] = client
}
// Swap in the new wiring, then (re)start the worker.
vm.mu.Lock()
old := vm.releaser
vm.chainClients = byName
vm.evmByChainID = byID
vm.attestClient = ac
vm.mu.Unlock()
if old != nil {
old.stop()
}
r := newReleaser(vm)
vm.mu.Lock()
vm.releaser = r
vm.mu.Unlock()
return nil
}
// stopReleaser halts the worker if running (called from Shutdown).
func (vm *VM) stopReleaser() {
vm.mu.Lock()
r := vm.releaser
vm.releaser = nil
vm.mu.Unlock()
if r != nil {
r.stop()
}
}
// IsProcessed reports whether the destination gateway has already released this
// exact transfer — the on-chain replay guard keyed by the transfer digest (so
// two source chains reusing the same nonce never collide), read via eth_call.
func (c *evmChainClient) IsProcessed(ctx context.Context, transfer bridgeattest.BridgeTransfer) (bool, error) {
digest := transfer.Digest()
data, err := parsedGatewayABI.Pack("processed", digest)
if err != nil {
return false, fmt.Errorf("bridgevm: chain %q: pack processed: %w", c.name, err)
}
gateway := c.gateway
out, err := c.primary().CallContract(ctx, ethereum.CallMsg{To: &gateway, Data: data}, nil)
if err != nil {
return false, fmt.Errorf("bridgevm: chain %q: eth_call processed: %w", c.name, err)
}
vals, err := parsedGatewayABI.Unpack("processed", out)
if err != nil || len(vals) != 1 {
return false, fmt.Errorf("bridgevm: chain %q: unpack processed: %w", c.name, err)
}
done, _ := vals[0].(bool)
return done, nil
}