mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
bridgevm: drive the release path, the EVM client and the plugin ABI
The EVM client had no test at all, so the thing that broadcasts a release was described rather than exercised; it now runs against a JSON-RPC endpoint. The release worker's retry policy is data on the worker rather than literals in its loop. The GPU plugin loader is driven against a shared object built for the test, which is the only thing that can catch the Go and C sides disagreeing about where a field sits — the drift its own comments say would silently corrupt every state root. The skip it replaced could not fail.
This commit is contained in:
+84
-116
@@ -5,42 +5,42 @@
|
||||
|
||||
package bridgevm
|
||||
|
||||
// Round-trip test for the dlopen + dlsym BridgeVM GPU substrate.
|
||||
//
|
||||
// We do not require a plugin to be present — the test is structured to SKIP
|
||||
// cleanly when AutoBackend() == BackendNone (no libluxgpu_backend_*.so on
|
||||
// the search path), which is the common case in a public chains checkout.
|
||||
//
|
||||
// When a plugin IS present (e.g. dev box with the GPU plugin install tree
|
||||
// built and $LUX_GPU_PLUGIN_DIR set to the build/backends/<bk>/ dir), the
|
||||
// test exercises signer_apply with a zero descriptor + zero ops + a sized
|
||||
// signers arena and asserts:
|
||||
//
|
||||
// 1. The launcher returns code 0 (success) — the zero descriptor has
|
||||
// signer_op_count=0 so the kernel walks no ops and writes applied=0
|
||||
// without touching the signers arena. This is the smallest non-trivial
|
||||
// round-trip that exercises dlopen → dlsym → cgo call → kernel dispatch
|
||||
// → C ABI return → Go slice readback.
|
||||
// 2. applied == 0 — the zero ops pile shouldn't apply anything.
|
||||
// 3. The signers arena is unchanged (sentinel-byte check on a single slot).
|
||||
// The GPU substrate's contract, without a plugin present. The other half —
|
||||
// a real shared object, loaded, called, and read back — is in
|
||||
// gpu_plugin_test.go, which builds one.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestActiveBackend just verifies AutoBackend() is callable and returns
|
||||
// either BackendNone (no plugin) or a known backend tag. Always runs.
|
||||
// A backend tag names itself, including one that names nothing.
|
||||
func TestABackendNamesItself(t *testing.T) {
|
||||
require.Equal(t, "none", BackendNone.String())
|
||||
require.Equal(t, "cuda", BackendCUDA.String())
|
||||
require.Equal(t, "hip", BackendHIP.String())
|
||||
require.Equal(t, "metal", BackendMetal.String())
|
||||
require.Equal(t, "vulkan", BackendVulkan.String())
|
||||
require.Equal(t, "webgpu", BackendWebGPU.String())
|
||||
require.Equal(t, "unknown(42)", Backend(42).String())
|
||||
}
|
||||
|
||||
// TestActiveBackend verifies AutoBackend() is callable and returns either
|
||||
// BackendNone (no plugin) or a known backend tag.
|
||||
func TestActiveBackend(t *testing.T) {
|
||||
bk := AutoBackend()
|
||||
switch bk {
|
||||
case BackendNone, BackendCUDA, BackendHIP, BackendMetal, BackendVulkan, BackendWebGPU:
|
||||
t.Logf("AutoBackend() = %s", bk)
|
||||
default:
|
||||
t.Fatalf("unexpected backend: %v", bk)
|
||||
}
|
||||
require.Contains(t,
|
||||
[]Backend{BackendNone, BackendCUDA, BackendHIP, BackendMetal, BackendVulkan, BackendWebGPU},
|
||||
AutoBackend())
|
||||
|
||||
// The probe records exactly one result, and it is the one read back.
|
||||
t.Cleanup(func() { setActiveBackend(BackendNone) })
|
||||
setActiveBackend(BackendVulkan)
|
||||
require.Equal(t, BackendVulkan, AutoBackend())
|
||||
setActiveBackend(BackendNone)
|
||||
require.Equal(t, BackendNone, AutoBackend())
|
||||
}
|
||||
|
||||
// TestStubReturnsErrGPUNotAvailable asserts the contract: when the plugin
|
||||
@@ -49,29 +49,29 @@ func TestActiveBackend(t *testing.T) {
|
||||
// plugin happens to be on the search path.
|
||||
func TestStubReturnsErrGPUNotAvailable(t *testing.T) {
|
||||
b := cgoBackend{tag: BackendNone}
|
||||
if _, err := b.SignerApply(&BridgeVMRoundDescriptor{}, nil,
|
||||
make([]Signer, 1)); !errors.Is(err, ErrGPUNotAvailable) {
|
||||
t.Errorf("SignerApply: got %v, want ErrGPUNotAvailable", err)
|
||||
}
|
||||
if _, _, _, err := b.LiquidityApply(&BridgeVMRoundDescriptor{}, nil,
|
||||
make([]LiquidityEntry, 1)); !errors.Is(err, ErrGPUNotAvailable) {
|
||||
t.Errorf("LiquidityApply: got %v, want ErrGPUNotAvailable", err)
|
||||
}
|
||||
if _, _, _, err := b.MessageInbox(&BridgeVMRoundDescriptor{}, nil,
|
||||
make([]Signer, 1), make([]DailyLimit, 1), make([]Message, 1)); !errors.Is(err, ErrGPUNotAvailable) {
|
||||
t.Errorf("MessageInbox: got %v, want ErrGPUNotAvailable", err)
|
||||
}
|
||||
if _, _, _, err := b.MessageOutbox(&BridgeVMRoundDescriptor{}, nil,
|
||||
make([]DailyLimit, 1), make([]Message, 1),
|
||||
&BridgeVMEpochState{}); !errors.Is(err, ErrGPUNotAvailable) {
|
||||
t.Errorf("MessageOutbox: got %v, want ErrGPUNotAvailable", err)
|
||||
}
|
||||
if err := b.BridgeTransition(&BridgeVMRoundDescriptor{},
|
||||
require.Equal(t, BackendNone, b.Backend())
|
||||
|
||||
_, err := b.SignerApply(&BridgeVMRoundDescriptor{}, nil, make([]Signer, 1))
|
||||
require.ErrorIs(t, err, ErrGPUNotAvailable)
|
||||
|
||||
_, _, _, err = b.LiquidityApply(&BridgeVMRoundDescriptor{}, nil, make([]LiquidityEntry, 1))
|
||||
require.ErrorIs(t, err, ErrGPUNotAvailable)
|
||||
|
||||
_, _, _, err = b.MessageInbox(&BridgeVMRoundDescriptor{}, nil,
|
||||
make([]Signer, 1), make([]DailyLimit, 1), make([]Message, 1))
|
||||
require.ErrorIs(t, err, ErrGPUNotAvailable)
|
||||
|
||||
_, _, _, err = b.MessageOutbox(&BridgeVMRoundDescriptor{}, nil,
|
||||
make([]DailyLimit, 1), make([]Message, 1), &BridgeVMEpochState{})
|
||||
require.ErrorIs(t, err, ErrGPUNotAvailable)
|
||||
|
||||
err = b.BridgeTransition(&BridgeVMRoundDescriptor{},
|
||||
make([]Signer, 1), make([]LiquidityEntry, 1), make([]DailyLimit, 1),
|
||||
make([]Message, 1), make([]Message, 1),
|
||||
&BridgeVMEpochState{}, &BridgeVMTransitionResult{}); !errors.Is(err, ErrGPUNotAvailable) {
|
||||
t.Errorf("BridgeTransition: got %v, want ErrGPUNotAvailable", err)
|
||||
}
|
||||
&BridgeVMEpochState{}, &BridgeVMTransitionResult{})
|
||||
require.ErrorIs(t, err, ErrGPUNotAvailable)
|
||||
|
||||
require.True(t, errors.Is(ErrGPUNotAvailable, ErrGPUNotAvailable))
|
||||
}
|
||||
|
||||
// TestLayoutSizesMatchHeader is the runtime-equivalent of the C++
|
||||
@@ -80,80 +80,48 @@ func TestStubReturnsErrGPUNotAvailable(t *testing.T) {
|
||||
// visible to `go test -v` output for CI dashboards and named per-struct so
|
||||
// a diff narrows immediately to the offending type.
|
||||
func TestLayoutSizesMatchHeader(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
got uintptr
|
||||
want uintptr
|
||||
}{
|
||||
{"Signer", sizeOf[Signer](), 208},
|
||||
{"LiquidityEntry", sizeOf[LiquidityEntry](), 80},
|
||||
{"DailyLimit", sizeOf[DailyLimit](), 64},
|
||||
{"Message", sizeOf[Message](), 240},
|
||||
{"BridgeVMEpochState", sizeOf[BridgeVMEpochState](), 240},
|
||||
{"BridgeVMRoundDescriptor", sizeOf[BridgeVMRoundDescriptor](), 112},
|
||||
{"SignerOp", sizeOf[SignerOp](), 224},
|
||||
{"LiquidityOp", sizeOf[LiquidityOp](), 64},
|
||||
{"OutboundReq", sizeOf[OutboundReq](), 112},
|
||||
{"BridgeVMTransitionResult", sizeOf[BridgeVMTransitionResult](), 304},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s: sizeof=%d, want %d", c.name, c.got, c.want)
|
||||
}
|
||||
for name, sizes := range map[string][2]uintptr{
|
||||
"Signer": {sizeOf[Signer](), 208},
|
||||
"LiquidityEntry": {sizeOf[LiquidityEntry](), 80},
|
||||
"DailyLimit": {sizeOf[DailyLimit](), 64},
|
||||
"Message": {sizeOf[Message](), 240},
|
||||
"BridgeVMEpochState": {sizeOf[BridgeVMEpochState](), 240},
|
||||
"BridgeVMRoundDescriptor": {sizeOf[BridgeVMRoundDescriptor](), 112},
|
||||
"SignerOp": {sizeOf[SignerOp](), 224},
|
||||
"LiquidityOp": {sizeOf[LiquidityOp](), 64},
|
||||
"OutboundReq": {sizeOf[OutboundReq](), 112},
|
||||
"BridgeVMTransitionResult": {sizeOf[BridgeVMTransitionResult](), 304},
|
||||
} {
|
||||
require.Equal(t, sizes[1], sizes[0], "%s size", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignerApplyZeroFixture is the round-trip we promised: dlopen, dlsym,
|
||||
// call lux_<best>_bridgevm_signer_apply with a zero-initialised descriptor +
|
||||
// zero ops + sized signers arena, assert no error. SKIPs when no plugin is
|
||||
// loaded so the test passes in a public checkout without gpu-kernels built.
|
||||
func TestSignerApplyZeroFixture(t *testing.T) {
|
||||
bk := AutoBackend()
|
||||
if bk == BackendNone {
|
||||
t.Skip("no GPU plugin loaded; set LUX_GPU_PLUGIN_DIR=" +
|
||||
os.Getenv("HOME") + "/work/the lux GPU plugin build/backends/<bk> to exercise")
|
||||
}
|
||||
// The offsets the C side reads, asserted here rather than only implied by the
|
||||
// sizes. A struct can keep its total width while a field moves inside it,
|
||||
// which is the drift a size check cannot see.
|
||||
func TestLayoutOffsetsMatchHeader(t *testing.T) {
|
||||
var desc BridgeVMRoundDescriptor
|
||||
require.Equal(t, uintptr(32), unsafe.Offsetof(desc.Height))
|
||||
require.Equal(t, uintptr(40), unsafe.Offsetof(desc.Mode))
|
||||
require.Equal(t, uintptr(80), unsafe.Offsetof(desc.ParentStateRoot))
|
||||
|
||||
// Zero descriptor → signer_op_count=0, kernel walks no ops. The mode
|
||||
// field stays at kModeInbox (0) which is what the kernel checks
|
||||
// to early-out from anything other than signer_apply when it's not
|
||||
// the requested mode — but signer_apply ignores mode (it's the
|
||||
// dispatcher's job, not the kernel's, per bridgevm_signer.cu).
|
||||
desc := BridgeVMRoundDescriptor{}
|
||||
// Sized arena, byte-zero. Sentinel: set occupied=0 explicitly so we
|
||||
// can read it back unchanged.
|
||||
signers := make([]Signer, 16)
|
||||
for i := range signers {
|
||||
signers[i].Occupied = 0
|
||||
}
|
||||
// Sized but zero-init ops slice. The descriptor says signer_op_count=0
|
||||
// so the kernel walks none of them, but launchers (Vulkan in particular)
|
||||
// require ops to be non-nil — they pass it through as a storage-buffer
|
||||
// binding even when bytes_ops is zero. One element is enough to give
|
||||
// the launcher a valid pointer to bind without committing the caller
|
||||
// to any non-trivial work.
|
||||
ops := make([]SignerOp, 1)
|
||||
var signer Signer
|
||||
require.Equal(t, uintptr(196), unsafe.Offsetof(signer.Occupied))
|
||||
require.Equal(t, uintptr(72), unsafe.Offsetof(signer.BLSPubKey))
|
||||
|
||||
b := ActiveGPUBackend()
|
||||
applied, err := b.SignerApply(&desc, ops, signers)
|
||||
if err != nil {
|
||||
t.Fatalf("SignerApply(zero fixture) on %s: %v", bk, err)
|
||||
}
|
||||
if applied != 0 {
|
||||
t.Errorf("SignerApply(zero fixture): applied=%d, want 0", applied)
|
||||
}
|
||||
// Arena should be unchanged.
|
||||
for i, s := range signers {
|
||||
if s.Occupied != 0 {
|
||||
t.Errorf("signers[%d].Occupied = %d, want 0 (kernel touched the arena on no-op input)",
|
||||
i, s.Occupied)
|
||||
}
|
||||
}
|
||||
var epoch BridgeVMEpochState
|
||||
require.Zero(t, unsafe.Offsetof(epoch.CurrentEpoch))
|
||||
require.Equal(t, uintptr(208), unsafe.Offsetof(epoch.BridgeVMStateRoot))
|
||||
|
||||
var result BridgeVMTransitionResult
|
||||
require.Equal(t, uintptr(96), unsafe.Offsetof(result.Epoch))
|
||||
require.Equal(t, uintptr(272), unsafe.Offsetof(result.BridgeVMStateRoot))
|
||||
|
||||
var msg Message
|
||||
require.Equal(t, uintptr(64), unsafe.Offsetof(msg.AggSignature))
|
||||
}
|
||||
|
||||
// sizeOf is a tiny generic helper to make the layout-asserts read naturally
|
||||
// in TestLayoutSizesMatchHeader. Keeping unsafe.Sizeof out of the test
|
||||
// callsites makes each line a one-liner; the unsafe is centralised here.
|
||||
// sizeOf is a tiny generic helper to make the layout-asserts read naturally.
|
||||
func sizeOf[T any]() uintptr {
|
||||
var z T
|
||||
return unsafe.Sizeof(z)
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package bridgevm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/chains/internal/bridgeattest"
|
||||
"github.com/luxfi/crypto"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/geth/common/hexutil"
|
||||
"github.com/luxfi/geth/core/types"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// The EVM client is where this chain touches money: it is the thing that
|
||||
// broadcasts a release. These tests drive it against a real JSON-RPC endpoint
|
||||
// — an httptest server speaking the subset of the protocol the client uses —
|
||||
// so the wire encoding, the signing, and the endpoint fan-out are exercised
|
||||
// rather than described.
|
||||
|
||||
// evmNode is an Ethereum JSON-RPC endpoint answering exactly what this client
|
||||
// asks it.
|
||||
type evmNode struct {
|
||||
mu sync.Mutex
|
||||
|
||||
chainID *big.Int
|
||||
head uint64
|
||||
nonce uint64
|
||||
gasPrice *big.Int
|
||||
receipt map[common.Hash]*types.Receipt
|
||||
logs []types.Log
|
||||
processed bool
|
||||
|
||||
sent []*types.Transaction
|
||||
sendErr error
|
||||
fail map[string]string // method -> error message
|
||||
}
|
||||
|
||||
func newEVMNode(chainID uint64) *evmNode {
|
||||
return &evmNode{
|
||||
chainID: new(big.Int).SetUint64(chainID),
|
||||
head: 1000,
|
||||
gasPrice: big.NewInt(1_000_000_000),
|
||||
receipt: map[common.Hash]*types.Receipt{},
|
||||
fail: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (n *evmNode) serve(t *testing.T) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(n.handle))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
type rpcCall struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params []json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
func (n *evmNode) handle(w http.ResponseWriter, r *http.Request) {
|
||||
var call rpcCall
|
||||
if err := json.NewDecoder(r.Body).Decode(&call); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, rpcErr := n.answer(&call)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
out := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(call.ID)}
|
||||
if rpcErr != "" {
|
||||
out["error"] = map[string]any{"code": -32000, "message": rpcErr}
|
||||
} else {
|
||||
out["result"] = result
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
func (n *evmNode) answer(call *rpcCall) (any, string) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
|
||||
if msg, bad := n.fail[call.Method]; bad {
|
||||
return nil, msg
|
||||
}
|
||||
switch call.Method {
|
||||
case "eth_chainId":
|
||||
return hexutil.EncodeBig(n.chainID), ""
|
||||
case "eth_blockNumber":
|
||||
return hexutil.Uint64(n.head).String(), ""
|
||||
case "eth_gasPrice":
|
||||
return hexutil.EncodeBig(n.gasPrice), ""
|
||||
case "eth_getTransactionCount":
|
||||
return hexutil.Uint64(n.nonce).String(), ""
|
||||
case "eth_getTransactionReceipt":
|
||||
var h common.Hash
|
||||
_ = json.Unmarshal(call.Params[0], &h)
|
||||
rcpt, ok := n.receipt[h]
|
||||
if !ok {
|
||||
return nil, ""
|
||||
}
|
||||
return marshalReceipt(rcpt), ""
|
||||
case "eth_getLogs":
|
||||
return n.logs, ""
|
||||
case "eth_call":
|
||||
out := make([]byte, 32)
|
||||
if n.processed {
|
||||
out[31] = 1
|
||||
}
|
||||
return hexutil.Encode(out), ""
|
||||
case "eth_sendRawTransaction":
|
||||
if n.sendErr != nil {
|
||||
return nil, n.sendErr.Error()
|
||||
}
|
||||
var raw hexutil.Bytes
|
||||
_ = json.Unmarshal(call.Params[0], &raw)
|
||||
tx := new(types.Transaction)
|
||||
if err := tx.UnmarshalBinary(raw); err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
n.sent = append(n.sent, tx)
|
||||
return tx.Hash(), ""
|
||||
}
|
||||
return nil, "unsupported method " + call.Method
|
||||
}
|
||||
|
||||
// marshalReceipt renders the receipt fields ethclient reads back.
|
||||
func marshalReceipt(r *types.Receipt) map[string]any {
|
||||
return map[string]any{
|
||||
"type": hexutil.Uint64(0).String(),
|
||||
"status": hexutil.Uint64(r.Status).String(),
|
||||
"cumulativeGasUsed": hexutil.Uint64(0).String(),
|
||||
"logsBloom": hexutil.Bytes(make([]byte, 256)),
|
||||
"logs": []any{},
|
||||
"transactionHash": r.TxHash,
|
||||
"contractAddress": nil,
|
||||
"gasUsed": hexutil.Uint64(0).String(),
|
||||
"blockHash": common.Hash{1},
|
||||
"blockNumber": hexutil.EncodeBig(r.BlockNumber),
|
||||
"transactionIndex": hexutil.Uint64(0).String(),
|
||||
"effectiveGasPrice": hexutil.Uint64(1).String(),
|
||||
}
|
||||
}
|
||||
|
||||
func relayerKey(t *testing.T) *ecdsa.PrivateKey {
|
||||
t.Helper()
|
||||
key, err := crypto.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
return key
|
||||
}
|
||||
|
||||
func chainCfg(name string, id uint64, endpoints ...string) ExternalChainConfig {
|
||||
return ExternalChainConfig{
|
||||
Name: name,
|
||||
ChainID: id,
|
||||
RPCEndpoints: endpoints,
|
||||
Gateway: "0x00000000000000000000000000000000000000aa",
|
||||
CustodySigner: "0x00000000000000000000000000000000000000bb",
|
||||
MinGasPrice: "2000000000",
|
||||
GasKeyKMSPath: "kms://bridge/relayer",
|
||||
}
|
||||
}
|
||||
|
||||
func dial(t *testing.T, node *evmNode, cfg ExternalChainConfig) *evmChainClient {
|
||||
t.Helper()
|
||||
c, err := newEVMChainClient(context.Background(), cfg, relayerKey(t), log.NewNoOpLogger())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
for _, ep := range c.endpoints {
|
||||
ep.Close()
|
||||
}
|
||||
})
|
||||
_ = node
|
||||
return c
|
||||
}
|
||||
|
||||
// A client refuses a configuration it cannot act on, before it is wired to
|
||||
// anything: a half-configured relayer that dials anyway broadcasts to the
|
||||
// wrong place.
|
||||
func TestAnEVMClientRefusesAConfigurationItCannotAct(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
node := newEVMNode(uint64(dstChain))
|
||||
url := node.serve(t)
|
||||
key := relayerKey(t)
|
||||
|
||||
base := chainCfg("zoo", uint64(dstChain), url)
|
||||
|
||||
noID := base
|
||||
noID.ChainID = 0
|
||||
_, err := newEVMChainClient(ctx, noID, key, nil)
|
||||
require.ErrorContains(t, err, "chainId required")
|
||||
|
||||
noEndpoints := base
|
||||
noEndpoints.RPCEndpoints = nil
|
||||
_, err = newEVMChainClient(ctx, noEndpoints, key, nil)
|
||||
require.ErrorContains(t, err, "at least one RPC endpoint")
|
||||
|
||||
badGateway := base
|
||||
badGateway.Gateway = "not-an-address"
|
||||
_, err = newEVMChainClient(ctx, badGateway, key, nil)
|
||||
require.ErrorContains(t, err, "invalid gateway address")
|
||||
|
||||
badCustody := base
|
||||
badCustody.CustodySigner = "not-an-address"
|
||||
_, err = newEVMChainClient(ctx, badCustody, key, nil)
|
||||
require.ErrorContains(t, err, "invalid custody signer")
|
||||
|
||||
_, err = newEVMChainClient(ctx, base, nil, nil)
|
||||
require.ErrorContains(t, err, "gas-paying key not resolved")
|
||||
|
||||
badGas := base
|
||||
badGas.MinGasPrice = "twelve"
|
||||
_, err = newEVMChainClient(ctx, badGas, key, nil)
|
||||
require.ErrorContains(t, err, "invalid minGasPrice")
|
||||
|
||||
unreachable := base
|
||||
unreachable.RPCEndpoints = []string{"http://127.0.0.1:1/"}
|
||||
_, err = newEVMChainClient(ctx, unreachable, key, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestAnEVMClientRefusesTheWrongNetwork. A relayer pointed at the wrong RPC
|
||||
// would sign releases for one chain and broadcast them on another.
|
||||
func TestAnEVMClientRefusesTheWrongNetwork(t *testing.T) {
|
||||
node := newEVMNode(999)
|
||||
cfg := chainCfg("zoo", uint64(dstChain), node.serve(t))
|
||||
_, err := newEVMChainClient(context.Background(), cfg, relayerKey(t), nil)
|
||||
require.ErrorContains(t, err, "endpoint reports chainId")
|
||||
|
||||
node.fail["eth_chainId"] = "no"
|
||||
_, err = newEVMChainClient(context.Background(), cfg, relayerKey(t), nil)
|
||||
require.ErrorContains(t, err, "eth_chainId")
|
||||
}
|
||||
|
||||
// A release is broadcast to every endpoint, because a Lux-family destination
|
||||
// may not gossip its mempool: one accept is success.
|
||||
func TestAReleaseIsBroadcastToEveryEndpoint(t *testing.T) {
|
||||
node := newEVMNode(uint64(dstChain))
|
||||
url := node.serve(t)
|
||||
cfg := chainCfg("zoo", uint64(dstChain), url, url, url)
|
||||
c := dial(t, node, cfg)
|
||||
|
||||
transfer := transferFor(1, 5_000)
|
||||
txID, err := c.SendTransaction(context.Background(), &ReleaseCall{
|
||||
Transfer: transfer,
|
||||
Signature: make([]byte, 65),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, ids.Empty, txID)
|
||||
|
||||
node.mu.Lock()
|
||||
defer node.mu.Unlock()
|
||||
require.Len(t, node.sent, 3, "one endpoint was not told")
|
||||
tx := node.sent[0]
|
||||
require.Equal(t, c.gateway, *tx.To())
|
||||
require.Equal(t, ids.ID(tx.Hash()), txID)
|
||||
|
||||
// The gas price is at least the configured floor.
|
||||
require.GreaterOrEqual(t, tx.GasPrice().Int64(), int64(2_000_000_000))
|
||||
|
||||
// And the calldata is the transfer, so what was signed is what is released.
|
||||
args, err := parsedGatewayABI.Methods["release"].Inputs.Unpack(tx.Data()[4:])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, transfer.SrcChainID, args[0])
|
||||
require.Equal(t, transfer.DstChainID, args[1])
|
||||
require.Equal(t, transfer.Amount, args[3].(*big.Int).Uint64())
|
||||
require.Equal(t, common.BytesToAddress(transfer.Recipient[:]), args[4])
|
||||
require.Equal(t, transfer.Nonce, args[5])
|
||||
}
|
||||
|
||||
// TestAReleaseIsNotBroadcastToTheWrongChain. The client holds its own chain
|
||||
// id, so a transfer routed to it by mistake is refused rather than released
|
||||
// on a network the attestation does not authorise.
|
||||
func TestAReleaseIsNotBroadcastToTheWrongChain(t *testing.T) {
|
||||
node := newEVMNode(uint64(dstChain))
|
||||
c := dial(t, node, chainCfg("zoo", uint64(dstChain), node.serve(t)))
|
||||
|
||||
misrouted := transferFor(1, 5_000)
|
||||
misrouted.DstChainID = 4242
|
||||
_, err := c.SendTransaction(context.Background(), &ReleaseCall{
|
||||
Transfer: misrouted, Signature: make([]byte, 65),
|
||||
})
|
||||
require.ErrorContains(t, err, "wrong client")
|
||||
require.Empty(t, node.sent)
|
||||
}
|
||||
|
||||
func TestAReleaseNeedsASignatureOfTheRightShape(t *testing.T) {
|
||||
node := newEVMNode(uint64(dstChain))
|
||||
c := dial(t, node, chainCfg("zoo", uint64(dstChain), node.serve(t)))
|
||||
|
||||
for _, n := range []int{0, 64, 66} {
|
||||
_, err := c.SendTransaction(context.Background(), &ReleaseCall{
|
||||
Transfer: transferFor(1, 5_000), Signature: make([]byte, n),
|
||||
})
|
||||
require.ErrorContains(t, err, "must be 65 bytes")
|
||||
}
|
||||
|
||||
_, err := c.SendTransaction(context.Background(), "not a release call")
|
||||
require.ErrorContains(t, err, "expects *ReleaseCall")
|
||||
require.Empty(t, node.sent)
|
||||
}
|
||||
|
||||
// An endpoint that already has the transaction is not a failure: the signed
|
||||
// transaction has one hash, so a duplicate submit is a no-op success.
|
||||
func TestAlreadyKnownIsSuccess(t *testing.T) {
|
||||
require.True(t, isAlreadyKnown(nil))
|
||||
for _, msg := range []string{
|
||||
"already known", "ALREADY KNOWN", "already in the pool",
|
||||
"nonce too low", "known transaction: 0xabc",
|
||||
} {
|
||||
require.True(t, isAlreadyKnown(errors.New(msg)), msg)
|
||||
}
|
||||
require.False(t, isAlreadyKnown(errors.New("insufficient funds")))
|
||||
}
|
||||
|
||||
func TestAReleaseRefusedByEveryEndpointIsAnError(t *testing.T) {
|
||||
node := newEVMNode(uint64(dstChain))
|
||||
url := node.serve(t)
|
||||
c := dial(t, node, chainCfg("zoo", uint64(dstChain), url, url))
|
||||
|
||||
node.mu.Lock()
|
||||
node.sendErr = errors.New("insufficient funds for gas")
|
||||
node.mu.Unlock()
|
||||
|
||||
_, err := c.SendTransaction(context.Background(), &ReleaseCall{
|
||||
Transfer: transferFor(1, 5_000), Signature: make([]byte, 65),
|
||||
})
|
||||
require.ErrorContains(t, err, "rejected by all 2 endpoints")
|
||||
|
||||
// One endpoint saying it already has it is enough.
|
||||
node.mu.Lock()
|
||||
node.sendErr = errors.New("already known")
|
||||
node.mu.Unlock()
|
||||
_, err = c.SendTransaction(context.Background(), &ReleaseCall{
|
||||
Transfer: transferFor(1, 5_000), Signature: make([]byte, 65),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestABroadcastNeedsANonceAndAGasPrice(t *testing.T) {
|
||||
node := newEVMNode(uint64(dstChain))
|
||||
c := dial(t, node, chainCfg("zoo", uint64(dstChain), node.serve(t)))
|
||||
call := &ReleaseCall{Transfer: transferFor(1, 5_000), Signature: make([]byte, 65)}
|
||||
|
||||
node.mu.Lock()
|
||||
node.fail["eth_getTransactionCount"] = "no"
|
||||
node.mu.Unlock()
|
||||
_, err := c.SendTransaction(context.Background(), call)
|
||||
require.ErrorContains(t, err, "pending nonce")
|
||||
|
||||
node.mu.Lock()
|
||||
delete(node.fail, "eth_getTransactionCount")
|
||||
node.fail["eth_gasPrice"] = "no"
|
||||
node.mu.Unlock()
|
||||
_, err = c.SendTransaction(context.Background(), call)
|
||||
require.ErrorContains(t, err, "suggest gas price")
|
||||
}
|
||||
|
||||
// TestConfirmationsComeFromThisNodesOwnView is the trust gap this client
|
||||
// closed: the depth of a source lock is read here, not taken from whoever
|
||||
// submitted the request.
|
||||
func TestConfirmationsComeFromThisNodesOwnView(t *testing.T) {
|
||||
node := newEVMNode(uint64(srcChain))
|
||||
c := dial(t, node, chainCfg("lux", uint64(srcChain), node.serve(t)))
|
||||
|
||||
txHash := common.Hash{0xAB}
|
||||
node.mu.Lock()
|
||||
node.head = 1_000
|
||||
node.receipt[txHash] = &types.Receipt{
|
||||
Status: types.ReceiptStatusSuccessful, TxHash: txHash, BlockNumber: big.NewInt(990),
|
||||
}
|
||||
node.mu.Unlock()
|
||||
|
||||
conf, err := c.GetConfirmations(context.Background(), ids.ID(txHash))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint32(11), conf)
|
||||
|
||||
rcpt, err := c.GetTransaction(context.Background(), ids.ID(txHash))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rcpt)
|
||||
|
||||
head, err := c.HeadBlock(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(1_000), head)
|
||||
|
||||
// A receipt this node has not seen is not zero confirmations, it is an
|
||||
// unanswerable question.
|
||||
_, err = c.GetConfirmations(context.Background(), ids.GenerateTestID())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// A reverted lock never happened, so its depth is not a number to compare.
|
||||
func TestARevertedSourceTransactionHasNoConfirmations(t *testing.T) {
|
||||
node := newEVMNode(uint64(srcChain))
|
||||
c := dial(t, node, chainCfg("lux", uint64(srcChain), node.serve(t)))
|
||||
|
||||
txHash := common.Hash{0xCD}
|
||||
node.mu.Lock()
|
||||
node.receipt[txHash] = &types.Receipt{
|
||||
Status: types.ReceiptStatusFailed, TxHash: txHash, BlockNumber: big.NewInt(10),
|
||||
}
|
||||
node.mu.Unlock()
|
||||
|
||||
_, err := c.GetConfirmations(context.Background(), ids.ID(txHash))
|
||||
require.ErrorContains(t, err, "reverted")
|
||||
}
|
||||
|
||||
// A chain whose head this node cannot read reports nothing rather than zero.
|
||||
func TestAnUnreadableHeadIsAnError(t *testing.T) {
|
||||
node := newEVMNode(uint64(srcChain))
|
||||
c := dial(t, node, chainCfg("lux", uint64(srcChain), node.serve(t)))
|
||||
|
||||
txHash := common.Hash{0xEF}
|
||||
node.mu.Lock()
|
||||
node.receipt[txHash] = &types.Receipt{
|
||||
Status: types.ReceiptStatusSuccessful, TxHash: txHash, BlockNumber: big.NewInt(10),
|
||||
}
|
||||
node.fail["eth_blockNumber"] = "no"
|
||||
node.mu.Unlock()
|
||||
|
||||
_, err := c.GetConfirmations(context.Background(), ids.ID(txHash))
|
||||
require.Error(t, err)
|
||||
_, err = c.HeadBlock(context.Background())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// A receipt from a block the node has not caught up to yet is zero deep, not
|
||||
// a negative number wrapped into a very large one.
|
||||
func TestAReceiptAheadOfTheHeadIsZeroDeep(t *testing.T) {
|
||||
node := newEVMNode(uint64(srcChain))
|
||||
c := dial(t, node, chainCfg("lux", uint64(srcChain), node.serve(t)))
|
||||
|
||||
txHash := common.Hash{0x11}
|
||||
node.mu.Lock()
|
||||
node.head = 5
|
||||
node.receipt[txHash] = &types.Receipt{
|
||||
Status: types.ReceiptStatusSuccessful, TxHash: txHash, BlockNumber: big.NewInt(99),
|
||||
}
|
||||
node.mu.Unlock()
|
||||
|
||||
conf, err := c.GetConfirmations(context.Background(), ids.ID(txHash))
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, conf)
|
||||
}
|
||||
|
||||
func TestValidateAddress(t *testing.T) {
|
||||
node := newEVMNode(uint64(dstChain))
|
||||
c := dial(t, node, chainCfg("zoo", uint64(dstChain), node.serve(t)))
|
||||
require.NoError(t, c.ValidateAddress(make([]byte, 20)))
|
||||
require.ErrorContains(t, c.ValidateAddress(make([]byte, 19)), "20 bytes")
|
||||
require.ErrorContains(t, c.ValidateAddress(nil), "20 bytes")
|
||||
}
|
||||
|
||||
// TestLockedEventsAreDecodedFromTheChain is the inbound half: a Locked event
|
||||
// becomes the exact transfer the digest binds.
|
||||
func TestLockedEventsAreDecodedFromTheChain(t *testing.T) {
|
||||
node := newEVMNode(uint64(srcChain))
|
||||
c := dial(t, node, chainCfg("lux", uint64(srcChain), node.serve(t)))
|
||||
|
||||
want := transferFor(77, 4_242)
|
||||
node.mu.Lock()
|
||||
node.logs = []types.Log{lockedLog(t, want, 900, common.Hash{0x55})}
|
||||
node.mu.Unlock()
|
||||
|
||||
locks, err := c.FetchLockEvents(context.Background(), big.NewInt(1), big.NewInt(1000))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, locks, 1)
|
||||
require.Equal(t, want, locks[0].Transfer)
|
||||
require.Equal(t, uint64(900), locks[0].Block)
|
||||
require.Equal(t, ids.ID(common.Hash{0x55}), locks[0].TxID)
|
||||
|
||||
node.mu.Lock()
|
||||
node.fail["eth_getLogs"] = "no"
|
||||
node.mu.Unlock()
|
||||
_, err = c.FetchLockEvents(context.Background(), big.NewInt(1), big.NewInt(2))
|
||||
require.ErrorContains(t, err, "filter Locked logs")
|
||||
}
|
||||
|
||||
// An amount that does not fit the width the attestation digest binds cannot be
|
||||
// carried, and reading it as its low 64 bits would authorise a different
|
||||
// transfer than the one that was locked.
|
||||
func TestALockedAmountMustFitTheDigest(t *testing.T) {
|
||||
node := newEVMNode(uint64(srcChain))
|
||||
c := dial(t, node, chainCfg("lux", uint64(srcChain), node.serve(t)))
|
||||
|
||||
huge := new(big.Int).Lsh(big.NewInt(1), 70)
|
||||
node.mu.Lock()
|
||||
node.logs = []types.Log{lockedLogAmount(t, transferFor(1, 0), huge, 900, common.Hash{0x66})}
|
||||
node.mu.Unlock()
|
||||
|
||||
_, err := c.FetchLockEvents(context.Background(), big.NewInt(1), big.NewInt(1000))
|
||||
require.ErrorContains(t, err, "decode Locked log")
|
||||
|
||||
// And bytes that are not a Locked event at all.
|
||||
_, err = decodeLockedLog(&types.Log{Data: []byte{1, 2, 3}})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestIsProcessedAsksTheGateway is the on-chain replay guard, read before any
|
||||
// attestation is requested.
|
||||
func TestIsProcessedAsksTheGateway(t *testing.T) {
|
||||
node := newEVMNode(uint64(dstChain))
|
||||
c := dial(t, node, chainCfg("zoo", uint64(dstChain), node.serve(t)))
|
||||
transfer := transferFor(1, 5_000)
|
||||
|
||||
done, err := c.IsProcessed(context.Background(), transfer)
|
||||
require.NoError(t, err)
|
||||
require.False(t, done)
|
||||
|
||||
node.mu.Lock()
|
||||
node.processed = true
|
||||
node.mu.Unlock()
|
||||
done, err = c.IsProcessed(context.Background(), transfer)
|
||||
require.NoError(t, err)
|
||||
require.True(t, done)
|
||||
|
||||
node.mu.Lock()
|
||||
node.fail["eth_call"] = "no"
|
||||
node.mu.Unlock()
|
||||
_, err = c.IsProcessed(context.Background(), transfer)
|
||||
require.ErrorContains(t, err, "eth_call processed")
|
||||
}
|
||||
|
||||
// An answer the gateway ABI cannot read is not a "no": it is an unanswered
|
||||
// question, and treating it as "not processed" would release again.
|
||||
func TestAnUnreadableProcessedAnswerIsAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var call rpcCall
|
||||
_ = json.NewDecoder(r.Body).Decode(&call)
|
||||
result := any(hexutil.Encode(make([]byte, 32)))
|
||||
if call.Method == "eth_chainId" {
|
||||
result = hexutil.EncodeBig(new(big.Int).SetUint64(uint64(dstChain)))
|
||||
} else if call.Method == "eth_call" {
|
||||
result = "0x00" // one byte: not a bool
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0", "id": json.RawMessage(call.ID), "result": result,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := newEVMChainClient(context.Background(),
|
||||
chainCfg("zoo", uint64(dstChain), srv.URL), relayerKey(t), log.NewNoOpLogger())
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
for _, ep := range c.endpoints {
|
||||
ep.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = c.IsProcessed(context.Background(), transferFor(1, 5_000))
|
||||
require.ErrorContains(t, err, "unpack processed")
|
||||
}
|
||||
|
||||
// lockedLog renders a Locked event the way the gateway emits it.
|
||||
func lockedLog(t *testing.T, bt bridgeattest.BridgeTransfer, block uint64, tx common.Hash) types.Log {
|
||||
t.Helper()
|
||||
return lockedLogAmount(t, bt, new(big.Int).SetUint64(bt.Amount), block, tx)
|
||||
}
|
||||
|
||||
func lockedLogAmount(t *testing.T, bt bridgeattest.BridgeTransfer, amount *big.Int, block uint64, tx common.Hash) types.Log {
|
||||
t.Helper()
|
||||
ev := parsedGatewayABI.Events["Locked"]
|
||||
data, err := ev.Inputs.Pack(bt.SrcChainID, bt.DstChainID, bt.Asset, amount,
|
||||
common.BytesToAddress(bt.Recipient[:]), bt.Nonce)
|
||||
require.NoError(t, err)
|
||||
return types.Log{
|
||||
Address: common.HexToAddress("0x00000000000000000000000000000000000000aa"),
|
||||
Topics: []common.Hash{ev.ID},
|
||||
Data: data,
|
||||
BlockNumber: block,
|
||||
TxHash: tx,
|
||||
}
|
||||
}
|
||||
|
||||
// The gateway ABI this client packs against must be the one the contract
|
||||
// exposes; a drift here is a release call the gateway rejects, or worse, one
|
||||
// it reads differently.
|
||||
func TestTheGatewayABIIsTheContractsABI(t *testing.T) {
|
||||
require.Contains(t, parsedGatewayABI.Methods, "release")
|
||||
require.Contains(t, parsedGatewayABI.Methods, "processed")
|
||||
require.Contains(t, parsedGatewayABI.Events, "Locked")
|
||||
|
||||
release := parsedGatewayABI.Methods["release"]
|
||||
var got []string
|
||||
for _, in := range release.Inputs {
|
||||
got = append(got, in.Type.String())
|
||||
}
|
||||
require.Equal(t, []string{"uint32", "uint32", "bytes32", "uint256", "address", "uint64", "bytes"}, got)
|
||||
|
||||
locked := parsedGatewayABI.Events["Locked"]
|
||||
got = got[:0]
|
||||
for _, in := range locked.Inputs {
|
||||
got = append(got, in.Type.String())
|
||||
require.False(t, in.Indexed, "an indexed field is not in the log data this client decodes")
|
||||
}
|
||||
require.Equal(t, []string{"uint32", "uint32", "bytes32", "uint256", "address", "uint64"}, got)
|
||||
}
|
||||
|
||||
func TestReleaseCallStringer(t *testing.T) {
|
||||
// The client names itself in every error, so an operator reading a log
|
||||
// knows which chain refused.
|
||||
node := newEVMNode(uint64(dstChain))
|
||||
c := dial(t, node, chainCfg("zoo-testnet", uint64(dstChain), node.serve(t)))
|
||||
_, err := c.SendTransaction(context.Background(), 42)
|
||||
require.ErrorContains(t, err, "zoo-testnet")
|
||||
require.Equal(t, fmt.Sprintf("%s", "zoo-testnet"), c.name)
|
||||
require.NotNil(t, c.primary())
|
||||
require.False(t, strings.Contains(c.name, "kms"))
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build cgo
|
||||
|
||||
package bridgevm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The plugin loader, driven against a real shared object.
|
||||
//
|
||||
// The GPU kernels this ABI calls live in another repository, so nothing here
|
||||
// can test what they compute. What it CAN test — and what nothing tested
|
||||
// before — is the boundary: that the loader finds a plugin, that it refuses
|
||||
// one missing a launcher, that a call crosses into C and back, and that the
|
||||
// Go structs land at the byte offsets the C side reads. The comment on those
|
||||
// structs says drift would "silently corrupt every round's state root", and a
|
||||
// stub that reads a field at a fixed offset and hands the value back is what
|
||||
// turns that from a claim into a measurement.
|
||||
//
|
||||
// The stub answers the five launcher signatures and nothing else:
|
||||
// - it reads Height out of the descriptor and reports it as the applied
|
||||
// count, so a Go/C disagreement about where Height sits shows up as a
|
||||
// wrong number rather than as nothing;
|
||||
// - it writes into each output arena at a fixed offset, so the same holds
|
||||
// in the other direction;
|
||||
// - it returns the descriptor's Mode as its status code, so the error path
|
||||
// is a real launcher refusal.
|
||||
const stubLauncherSource = `
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Offsets into BridgeVMRoundDescriptor (112 bytes, see backend.go). */
|
||||
#define DESC_HEIGHT_U64 4 /* byte 32 */
|
||||
#define DESC_MODE_U32 10 /* byte 40 */
|
||||
|
||||
static uint64_t desc_height(const void* d) { return ((const uint64_t*)d)[DESC_HEIGHT_U64]; }
|
||||
static int desc_status(const void* d) { return (int)((const uint32_t*)d)[DESC_MODE_U32]; }
|
||||
|
||||
int lux_cuda_bridgevm_signer_apply(
|
||||
const void* desc, const void* ops, void* signers, void* applied_out,
|
||||
uint32_t signer_count, void* stream) {
|
||||
(void)ops; (void)stream;
|
||||
*(uint32_t*)applied_out = (uint32_t)desc_height(desc);
|
||||
if (signer_count) ((uint32_t*)signers)[49] = 1; /* Signer.Occupied, byte 196 */
|
||||
return desc_status(desc);
|
||||
}
|
||||
|
||||
int lux_cuda_bridgevm_liquidity_apply(
|
||||
const void* desc, const void* ops, void* liquidity, void* applied_out,
|
||||
void* total_fees_lo_out, void* total_fees_hi_out,
|
||||
uint32_t liquidity_count, void* stream) {
|
||||
(void)ops; (void)liquidity; (void)liquidity_count; (void)stream;
|
||||
*(uint32_t*)applied_out = (uint32_t)desc_height(desc);
|
||||
*(uint64_t*)total_fees_lo_out = 11;
|
||||
*(uint64_t*)total_fees_hi_out = 22;
|
||||
return desc_status(desc);
|
||||
}
|
||||
|
||||
int lux_cuda_bridgevm_message_inbox(
|
||||
const void* desc, const void* in_msgs, void* signers, void* daily,
|
||||
void* inbox, void* applied_out, void* total_in_lo_out, void* total_in_hi_out,
|
||||
uint32_t signer_count, uint32_t daily_count, uint32_t inbox_count,
|
||||
void* stream) {
|
||||
(void)in_msgs; (void)signers; (void)daily; (void)inbox;
|
||||
(void)signer_count; (void)daily_count; (void)inbox_count; (void)stream;
|
||||
*(uint32_t*)applied_out = (uint32_t)desc_height(desc);
|
||||
*(uint64_t*)total_in_lo_out = 33;
|
||||
*(uint64_t*)total_in_hi_out = 44;
|
||||
return desc_status(desc);
|
||||
}
|
||||
|
||||
int lux_cuda_bridgevm_message_outbox(
|
||||
const void* desc, const void* reqs, void* daily, void* outbox, void* epoch,
|
||||
void* applied_out, void* total_out_lo_out, void* total_out_hi_out,
|
||||
uint32_t daily_count, uint32_t outbox_count, void* stream) {
|
||||
(void)reqs; (void)daily; (void)outbox; (void)daily_count;
|
||||
(void)outbox_count; (void)stream;
|
||||
*(uint32_t*)applied_out = (uint32_t)desc_height(desc);
|
||||
*(uint64_t*)total_out_lo_out = 55;
|
||||
*(uint64_t*)total_out_hi_out = 66;
|
||||
*(uint64_t*)epoch = 99; /* BridgeVMEpochState.CurrentEpoch, byte 0 */
|
||||
return desc_status(desc);
|
||||
}
|
||||
|
||||
int lux_cuda_bridgevm_transition(
|
||||
const void* desc, void* signers, void* liquidity, void* daily,
|
||||
void* inbox, void* outbox, void* epoch, void* result,
|
||||
uint32_t signer_count, uint32_t liquidity_count, uint32_t daily_count,
|
||||
uint32_t inbox_count, uint32_t outbox_count, void* stream) {
|
||||
(void)signers; (void)liquidity; (void)daily; (void)inbox; (void)outbox;
|
||||
(void)epoch; (void)signer_count; (void)liquidity_count; (void)daily_count;
|
||||
(void)inbox_count; (void)outbox_count; (void)stream;
|
||||
((uint64_t*)result)[12] = desc_height(desc); /* Result.Epoch, byte 96 */
|
||||
memset((char*)result + 272, 0xAB, 32); /* Result.BridgeVMStateRoot */
|
||||
return desc_status(desc);
|
||||
}
|
||||
`
|
||||
|
||||
// buildStub compiles src into dir/name and returns its path.
|
||||
func buildStub(t *testing.T, dir, name, src string) string {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
csrc := filepath.Join(t.TempDir(), name+".c")
|
||||
require.NoError(t, os.WriteFile(csrc, []byte(src), 0o644))
|
||||
|
||||
out := filepath.Join(dir, name)
|
||||
cc := os.Getenv("CC")
|
||||
if cc == "" {
|
||||
cc = "cc"
|
||||
}
|
||||
cmd := exec.Command(cc, "-shared", "-fPIC", "-o", out, csrc)
|
||||
combined, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "compiling the stub launcher: %s", combined)
|
||||
return out
|
||||
}
|
||||
|
||||
// loadStub points the loader at a plugin tree and re-runs the probe, putting
|
||||
// the package back the way it found it afterwards.
|
||||
func loadStub(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
t.Setenv("LUX_GPU_PLUGIN_DIR", dir)
|
||||
t.Setenv("LUXCPP_PREFIX", filepath.Join(t.TempDir(), "no-such-prefix"))
|
||||
t.Cleanup(func() {
|
||||
setActiveBackend(BackendNone)
|
||||
pluginHandle = nil
|
||||
fnSignerApply, fnLiquidityApply = nil, nil
|
||||
fnMessageInbox, fnMessageOutbox, fnBridgeTransit = nil, nil, nil
|
||||
})
|
||||
probePlugin()
|
||||
}
|
||||
|
||||
// TestThePluginLoaderFindsAPluginAndRefusesAPartialOne. The search list is
|
||||
// walked in order; a shared object that opens but does not carry all five
|
||||
// launchers is not a backend, and the walk goes on rather than committing to
|
||||
// half an ABI.
|
||||
func TestThePluginLoaderFindsAPluginAndRefusesAPartialOne(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
name := dsoBareName(BackendCUDA)
|
||||
|
||||
// First candidate: opens, carries none of the launchers.
|
||||
buildStub(t, root, name, "int lux_unrelated_symbol(void) { return 0; }\n")
|
||||
// Second candidate: the real thing.
|
||||
buildStub(t, filepath.Join(root, BackendCUDA.String()), name, stubLauncherSource)
|
||||
|
||||
loadStub(t, root)
|
||||
|
||||
require.Equal(t, BackendCUDA, AutoBackend(),
|
||||
"the loader passed over a complete plugin, or committed to an incomplete one")
|
||||
require.Equal(t, BackendCUDA, ActiveGPUBackend().Backend())
|
||||
require.NotNil(t, pluginHandle)
|
||||
}
|
||||
|
||||
// TestTheLauncherABICarriesValuesBothWays is the measurement the layout
|
||||
// comments promise. The stub reads Height out of the descriptor at the byte
|
||||
// offset the C header puts it at and hands it back; a Go struct that disagreed
|
||||
// would return a different number, which is the drift that would otherwise
|
||||
// show up as a wrong state root.
|
||||
func TestTheLauncherABICarriesValuesBothWays(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
buildStub(t, root, dsoBareName(BackendCUDA), stubLauncherSource)
|
||||
loadStub(t, root)
|
||||
require.Equal(t, BackendCUDA, AutoBackend())
|
||||
|
||||
b := ActiveGPUBackend()
|
||||
desc := &BridgeVMRoundDescriptor{Height: 7}
|
||||
|
||||
signers := make([]Signer, 4)
|
||||
applied, err := b.SignerApply(desc, make([]SignerOp, 1), signers)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint32(7), applied, "the descriptor's Height is not where C reads it")
|
||||
require.Equal(t, uint32(1), signers[0].Occupied, "Signer.Occupied is not where C writes it")
|
||||
|
||||
applied, feesLo, feesHi, err := b.LiquidityApply(desc, make([]LiquidityOp, 1), make([]LiquidityEntry, 2))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint32(7), applied)
|
||||
require.Equal(t, uint64(11), feesLo)
|
||||
require.Equal(t, uint64(22), feesHi)
|
||||
|
||||
applied, inLo, inHi, err := b.MessageInbox(desc, make([]Message, 1),
|
||||
make([]Signer, 1), make([]DailyLimit, 1), make([]Message, 1))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint32(7), applied)
|
||||
require.Equal(t, uint64(33), inLo)
|
||||
require.Equal(t, uint64(44), inHi)
|
||||
|
||||
epoch := &BridgeVMEpochState{}
|
||||
applied, outLo, outHi, err := b.MessageOutbox(desc, make([]OutboundReq, 1),
|
||||
make([]DailyLimit, 1), make([]Message, 1), epoch)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint32(7), applied)
|
||||
require.Equal(t, uint64(55), outLo)
|
||||
require.Equal(t, uint64(66), outHi)
|
||||
require.Equal(t, uint64(99), epoch.CurrentEpoch, "EpochState.CurrentEpoch is not where C writes it")
|
||||
|
||||
result := &BridgeVMTransitionResult{}
|
||||
require.NoError(t, b.BridgeTransition(desc, make([]Signer, 1), make([]LiquidityEntry, 1),
|
||||
make([]DailyLimit, 1), make([]Message, 1), make([]Message, 1), epoch, result))
|
||||
require.Equal(t, uint64(7), result.Epoch, "TransitionResult.Epoch is not where C writes it")
|
||||
require.Equal(t, byte(0xAB), result.BridgeVMStateRoot[0],
|
||||
"TransitionResult.BridgeVMStateRoot is not where C writes it")
|
||||
require.Equal(t, byte(0xAB), result.BridgeVMStateRoot[31])
|
||||
}
|
||||
|
||||
// A launcher that refuses reports why, with the code it returned.
|
||||
func TestALauncherRefusalIsAnError(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
buildStub(t, root, dsoBareName(BackendCUDA), stubLauncherSource)
|
||||
loadStub(t, root)
|
||||
|
||||
b := ActiveGPUBackend()
|
||||
refuse := &BridgeVMRoundDescriptor{Height: 1, Mode: 3}
|
||||
|
||||
_, err := b.SignerApply(refuse, make([]SignerOp, 1), make([]Signer, 1))
|
||||
require.ErrorContains(t, err, "signer_apply launcher returned code 3")
|
||||
|
||||
_, _, _, err = b.LiquidityApply(refuse, nil, make([]LiquidityEntry, 1))
|
||||
require.ErrorContains(t, err, "liquidity_apply launcher returned code 3")
|
||||
|
||||
_, _, _, err = b.MessageInbox(refuse, nil, make([]Signer, 1), make([]DailyLimit, 1), make([]Message, 1))
|
||||
require.ErrorContains(t, err, "message_inbox launcher returned code 3")
|
||||
|
||||
_, _, _, err = b.MessageOutbox(refuse, nil, make([]DailyLimit, 1), make([]Message, 1), &BridgeVMEpochState{})
|
||||
require.ErrorContains(t, err, "message_outbox launcher returned code 3")
|
||||
|
||||
err = b.BridgeTransition(refuse, make([]Signer, 1), make([]LiquidityEntry, 1),
|
||||
make([]DailyLimit, 1), make([]Message, 1), make([]Message, 1),
|
||||
&BridgeVMEpochState{}, &BridgeVMTransitionResult{})
|
||||
require.ErrorContains(t, err, "transition launcher returned code 3")
|
||||
}
|
||||
|
||||
// A launcher call with nothing to work on is refused here rather than handed
|
||||
// to C as a null pointer.
|
||||
func TestALauncherIsNotCalledWithNothing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
buildStub(t, root, dsoBareName(BackendCUDA), stubLauncherSource)
|
||||
loadStub(t, root)
|
||||
|
||||
b := ActiveGPUBackend()
|
||||
_, err := b.SignerApply(nil, nil, make([]Signer, 1))
|
||||
require.ErrorContains(t, err, "requires non-nil desc")
|
||||
_, err = b.SignerApply(&BridgeVMRoundDescriptor{}, nil, nil)
|
||||
require.ErrorContains(t, err, "non-empty signers")
|
||||
|
||||
_, _, _, err = b.LiquidityApply(&BridgeVMRoundDescriptor{}, nil, nil)
|
||||
require.ErrorContains(t, err, "non-empty liquidity")
|
||||
|
||||
_, _, _, err = b.MessageInbox(&BridgeVMRoundDescriptor{}, nil, nil, nil, nil)
|
||||
require.ErrorContains(t, err, "non-empty signers/daily/inbox")
|
||||
|
||||
_, _, _, err = b.MessageOutbox(&BridgeVMRoundDescriptor{}, nil, nil, nil, nil)
|
||||
require.ErrorContains(t, err, "non-empty daily/outbox")
|
||||
|
||||
err = b.BridgeTransition(&BridgeVMRoundDescriptor{}, nil, nil, nil, nil, nil, nil, nil)
|
||||
require.ErrorContains(t, err, "non-empty arrays")
|
||||
}
|
||||
|
||||
// With nothing on the search path the loader commits to nothing, and every
|
||||
// call says so rather than dispatching into a plugin that is not there.
|
||||
func TestNoPluginMeansNoBackend(t *testing.T) {
|
||||
loadStub(t, filepath.Join(t.TempDir(), "empty"))
|
||||
require.Equal(t, BackendNone, AutoBackend())
|
||||
require.Nil(t, pluginHandle)
|
||||
|
||||
_, err := ActiveGPUBackend().SignerApply(&BridgeVMRoundDescriptor{}, nil, make([]Signer, 1))
|
||||
require.ErrorIs(t, err, ErrGPUNotAvailable)
|
||||
}
|
||||
|
||||
// The search list is what the documented one says it is, in order.
|
||||
func TestThePluginSearchList(t *testing.T) {
|
||||
t.Setenv("LUX_GPU_PLUGIN_DIR", "/plugins")
|
||||
t.Setenv("LUXCPP_PREFIX", "/opt/lux")
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
name := dsoBareName(BackendCUDA)
|
||||
require.Equal(t, []string{
|
||||
filepath.Join("/plugins", name),
|
||||
filepath.Join("/plugins", "cuda", name),
|
||||
filepath.Join("/opt/lux", "lib", "lux-gpu", name),
|
||||
filepath.Join("/opt/lux", "lib", name),
|
||||
filepath.Join(cwd, name),
|
||||
}, candidatePluginPaths(BackendCUDA))
|
||||
|
||||
t.Setenv("LUX_GPU_PLUGIN_DIR", "")
|
||||
t.Setenv("LUXCPP_PREFIX", "")
|
||||
require.Equal(t, []string{filepath.Join(cwd, name)}, candidatePluginPaths(BackendCUDA))
|
||||
|
||||
// An absolute path that is not there is skipped without a dlopen; a bare
|
||||
// name is left to the loader's own search.
|
||||
require.False(t, plausiblePath("/no/such/plugin.so"))
|
||||
require.True(t, plausiblePath(name))
|
||||
require.True(t, plausiblePath(cwd))
|
||||
}
|
||||
|
||||
// Every backend names one shared object, and the names do not collide.
|
||||
func TestEveryBackendNamesOneObject(t *testing.T) {
|
||||
seen := map[string]Backend{}
|
||||
for _, bk := range []Backend{BackendCUDA, BackendHIP, BackendMetal, BackendVulkan, BackendWebGPU} {
|
||||
name := dsoBareName(bk)
|
||||
require.Contains(t, name, bk.String())
|
||||
prior, dup := seen[name]
|
||||
require.False(t, dup, "%s and %s name the same object", bk, prior)
|
||||
seen[name] = bk
|
||||
}
|
||||
require.Len(t, seen, 5)
|
||||
}
|
||||
+25
-15
@@ -38,10 +38,14 @@ var (
|
||||
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
|
||||
// How hard the worker retries a single transfer before logging and dropping
|
||||
// it, and how long it waits between attempts (doubling each time). Dropping is
|
||||
// safe: the transfer can be re-observed and re-enqueued, and other relayer
|
||||
// validators broadcast independently.
|
||||
const (
|
||||
releaseMaxRetries = 5
|
||||
releaseBackoff = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// 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.
|
||||
@@ -64,20 +68,26 @@ func (r *BridgeRequest) transfer() (bridgeattest.BridgeTransfer, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// releaser drives releases off the consensus path.
|
||||
// releaser drives releases off the consensus path. The retry policy is data on
|
||||
// the worker rather than literals inside its loop, so what it will do is one
|
||||
// thing to read.
|
||||
type releaser struct {
|
||||
vm *VM
|
||||
queue chan *BridgeRequest
|
||||
quit chan struct{}
|
||||
wg sync.WaitGroup
|
||||
once sync.Once
|
||||
vm *VM
|
||||
queue chan *BridgeRequest
|
||||
quit chan struct{}
|
||||
retries int
|
||||
backoff time.Duration
|
||||
wg sync.WaitGroup
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newReleaser(vm *VM) *releaser {
|
||||
r := &releaser{
|
||||
vm: vm,
|
||||
queue: make(chan *BridgeRequest, 1024),
|
||||
quit: make(chan struct{}),
|
||||
vm: vm,
|
||||
queue: make(chan *BridgeRequest, 1024),
|
||||
quit: make(chan struct{}),
|
||||
retries: releaseMaxRetries,
|
||||
backoff: releaseBackoff,
|
||||
}
|
||||
r.wg.Add(1)
|
||||
go r.run()
|
||||
@@ -122,8 +132,8 @@ func (r *releaser) handle(req *BridgeRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
backoff := 250 * time.Millisecond
|
||||
for attempt := 0; attempt < releaseMaxRetries; attempt++ {
|
||||
backoff := r.backoff
|
||||
for attempt := 0; attempt < r.retries; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
err := r.releaseOnce(ctx, req, transfer)
|
||||
cancel()
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package bridgevm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/chains/internal/bridgeattest"
|
||||
"github.com/luxfi/crypto/secp256k1"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/threshold/pkg/math/curve"
|
||||
"github.com/luxfi/threshold/pkg/party"
|
||||
"github.com/luxfi/threshold/protocols/cmp/config"
|
||||
)
|
||||
|
||||
// The release worker: what leaves the chain, and under what authority.
|
||||
|
||||
// countingClient is a destination gateway that records every broadcast and can
|
||||
// be told to fail a number of times first.
|
||||
type countingClient struct {
|
||||
mu sync.Mutex
|
||||
sent int
|
||||
confirms uint32
|
||||
failFirst int
|
||||
processed bool
|
||||
confErr error
|
||||
}
|
||||
|
||||
func (c *countingClient) GetTransaction(context.Context, ids.ID) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *countingClient) GetConfirmations(context.Context, ids.ID) (uint32, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.confirms, c.confErr
|
||||
}
|
||||
func (c *countingClient) ValidateAddress([]byte) error { return nil }
|
||||
|
||||
func (c *countingClient) IsProcessed(context.Context, bridgeattest.BridgeTransfer) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.processed, nil
|
||||
}
|
||||
|
||||
func (c *countingClient) SendTransaction(context.Context, interface{}) (ids.ID, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.failFirst > 0 {
|
||||
c.failFirst--
|
||||
return ids.Empty, errors.New("endpoint refused")
|
||||
}
|
||||
c.sent++
|
||||
return ids.GenerateTestID(), nil
|
||||
}
|
||||
|
||||
func (c *countingClient) broadcasts() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.sent
|
||||
}
|
||||
|
||||
// custodyRig wires a VM that holds a real custody group key, an M-Chain that
|
||||
// signs with it, and a destination gateway — the shape a relayer runs in.
|
||||
func custodyRig(t *testing.T) (*VM, *countingClient, *secp256k1.PrivateKey) {
|
||||
t.Helper()
|
||||
custody, err := secp256k1.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
vm := boot(t)
|
||||
vm.mpcConfig = groupConfig(t, custody)
|
||||
dst := &countingClient{confirms: 64}
|
||||
vm.mu.Lock()
|
||||
vm.evmByChainID[dstChain] = dst
|
||||
vm.evmByChainID[srcChain] = dst
|
||||
vm.attestClient = signingAttester{key: custody}
|
||||
vm.mu.Unlock()
|
||||
return vm, dst, custody
|
||||
}
|
||||
|
||||
// groupConfig is a CMP config whose group public point is the custody key, so
|
||||
// mpcGroupPublicKey returns exactly what M signs with.
|
||||
func groupConfig(t *testing.T, key *secp256k1.PrivateKey) *config.Config {
|
||||
t.Helper()
|
||||
group := curve.Secp256k1{}
|
||||
secret := group.NewScalar()
|
||||
require.NoError(t, secret.UnmarshalBinary(key.Bytes()))
|
||||
return &config.Config{
|
||||
Group: group,
|
||||
Threshold: 0,
|
||||
Public: map[party.ID]*config.Public{"m": {ECDSA: secret.ActOnBase()}},
|
||||
}
|
||||
}
|
||||
|
||||
// signingAttester is M-Chain: it signs the transfer's digest with the custody
|
||||
// key.
|
||||
type signingAttester struct{ key *secp256k1.PrivateKey }
|
||||
|
||||
func (a signingAttester) AttestBridgeTransfer(_ context.Context, bt bridgeattest.BridgeTransfer) (*bridgeattest.Attestation, error) {
|
||||
digest := bt.Digest()
|
||||
sig, err := secp256k1.Sign(digest[:], a.key.Bytes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &bridgeattest.Attestation{Transfer: bt, Digest: digest, Signature: sig}, nil
|
||||
}
|
||||
|
||||
// TestTheChainVerifiesTheKeyItWasConfiguredWith end to end: a genuine
|
||||
// attestation over the configured group key is released, and the same
|
||||
// attestation is worthless to a chain configured with a different key.
|
||||
func TestTheChainVerifiesTheKeyItWasConfiguredWith(t *testing.T) {
|
||||
vm, dst, _ := custodyRig(t)
|
||||
require.NotEmpty(t, vm.mpcGroupPublicKey())
|
||||
|
||||
_, err := vm.releaseTransfer(context.Background(), transferFor(1, 5_000))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, dst.broadcasts())
|
||||
|
||||
// A chain holding a different group key refuses the same attestation.
|
||||
other, err := secp256k1.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
vm.mpcConfig = groupConfig(t, other)
|
||||
_, err = vm.releaseTransfer(context.Background(), transferFor(2, 5_000))
|
||||
require.ErrorIs(t, err, errBadAttestation)
|
||||
require.Equal(t, 1, dst.broadcasts(), "a second release on an unverifiable attestation")
|
||||
}
|
||||
|
||||
// A destination with no client is a transfer with nowhere to go.
|
||||
func TestAReleaseNeedsAClientForItsDestination(t *testing.T) {
|
||||
vm, _, _ := custodyRig(t)
|
||||
unknown := transferFor(1, 5_000)
|
||||
unknown.DstChainID = 4242
|
||||
_, err := vm.releaseTransfer(context.Background(), unknown)
|
||||
require.ErrorIs(t, err, errNoDestClient)
|
||||
}
|
||||
|
||||
// M returning an error is not consent either.
|
||||
func TestAReleaseStopsWhenMWillNotAttest(t *testing.T) {
|
||||
vm, dst, _ := custodyRig(t)
|
||||
vm.mu.Lock()
|
||||
vm.attestClient = refusingAttester{}
|
||||
vm.mu.Unlock()
|
||||
|
||||
_, err := vm.releaseTransfer(context.Background(), transferFor(1, 5_000))
|
||||
require.ErrorContains(t, err, "attest")
|
||||
require.Zero(t, dst.broadcasts())
|
||||
|
||||
vm.mu.Lock()
|
||||
vm.attestClient = nil
|
||||
vm.mu.Unlock()
|
||||
_, err = vm.releaseTransfer(context.Background(), transferFor(1, 5_000))
|
||||
require.ErrorContains(t, err, "attestation client not configured")
|
||||
}
|
||||
|
||||
type refusingAttester struct{}
|
||||
|
||||
func (refusingAttester) AttestBridgeTransfer(context.Context, bridgeattest.BridgeTransfer) (*bridgeattest.Attestation, error) {
|
||||
return nil, errors.New("M is not signing today")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The worker
|
||||
// =============================================================================
|
||||
|
||||
// A transfer the chain cannot turn into a release is dropped where it is
|
||||
// found, not retried forever.
|
||||
func TestTheWorkerSkipsATransferItCannotBuild(t *testing.T) {
|
||||
vm, dst, _ := custodyRig(t)
|
||||
r := &releaser{vm: vm, queue: make(chan *BridgeRequest, 1), quit: make(chan struct{}), retries: 1}
|
||||
|
||||
broken := requestFor(1, 5_000)
|
||||
broken.Recipient = nil
|
||||
r.handle(broken)
|
||||
require.Zero(t, dst.broadcasts())
|
||||
}
|
||||
|
||||
// The source lock is re-confirmed from this node's OWN view before anything is
|
||||
// released: the depth on the request is the proposer's claim.
|
||||
func TestTheWorkerReconfirmsTheSourceLock(t *testing.T) {
|
||||
vm, dst, _ := custodyRig(t)
|
||||
r := &releaser{vm: vm, queue: make(chan *BridgeRequest, 1), quit: make(chan struct{}),
|
||||
retries: 1, backoff: time.Millisecond}
|
||||
|
||||
dst.mu.Lock()
|
||||
dst.confirms = 1 // shallower than the chain requires
|
||||
dst.mu.Unlock()
|
||||
|
||||
req := requestFor(1, 5_000)
|
||||
require.ErrorIs(t, r.releaseOnce(context.Background(), req, mustTransfer(t, req)),
|
||||
errInsufficientConfirmations)
|
||||
require.Zero(t, dst.broadcasts())
|
||||
|
||||
dst.mu.Lock()
|
||||
dst.confErr = errors.New("endpoint down")
|
||||
dst.mu.Unlock()
|
||||
require.ErrorContains(t, r.releaseOnce(context.Background(), req, mustTransfer(t, req)),
|
||||
"source confirmation check")
|
||||
|
||||
dst.mu.Lock()
|
||||
dst.confErr = nil
|
||||
dst.confirms = 64
|
||||
dst.mu.Unlock()
|
||||
require.NoError(t, r.releaseOnce(context.Background(), req, mustTransfer(t, req)))
|
||||
require.Equal(t, 1, dst.broadcasts())
|
||||
}
|
||||
|
||||
// A transfer the gateway has already released converges: no attestation is
|
||||
// requested and nothing is broadcast again.
|
||||
func TestTheWorkerConvergesOnAnAlreadyReleasedTransfer(t *testing.T) {
|
||||
vm, dst, _ := custodyRig(t)
|
||||
dst.mu.Lock()
|
||||
dst.processed = true
|
||||
dst.mu.Unlock()
|
||||
|
||||
r := &releaser{vm: vm, queue: make(chan *BridgeRequest, 1), quit: make(chan struct{}),
|
||||
retries: 3, backoff: time.Millisecond}
|
||||
r.handle(requestFor(1, 5_000))
|
||||
require.Zero(t, dst.broadcasts())
|
||||
}
|
||||
|
||||
// A refusal is retried, and a transfer that never lands is dropped rather than
|
||||
// held forever: it can be re-observed, and other relayers broadcast on their
|
||||
// own.
|
||||
func TestTheWorkerRetriesThenGivesUp(t *testing.T) {
|
||||
vm, dst, _ := custodyRig(t)
|
||||
dst.mu.Lock()
|
||||
dst.failFirst = 2
|
||||
dst.mu.Unlock()
|
||||
|
||||
r := &releaser{vm: vm, queue: make(chan *BridgeRequest, 1), quit: make(chan struct{}),
|
||||
retries: 5, backoff: time.Millisecond}
|
||||
r.handle(requestFor(1, 5_000))
|
||||
require.Equal(t, 1, dst.broadcasts(), "the third attempt should land")
|
||||
|
||||
dst.mu.Lock()
|
||||
dst.failFirst = 1_000
|
||||
dst.mu.Unlock()
|
||||
r.handle(requestFor(2, 5_000))
|
||||
require.Equal(t, 1, dst.broadcasts(), "a transfer that never lands must not be released")
|
||||
}
|
||||
|
||||
// Stopping ends a retry in progress rather than waiting out the backoff.
|
||||
func TestStoppingEndsARetryInFlight(t *testing.T) {
|
||||
vm, dst, _ := custodyRig(t)
|
||||
dst.mu.Lock()
|
||||
dst.failFirst = 1_000
|
||||
dst.mu.Unlock()
|
||||
|
||||
r := &releaser{vm: vm, queue: make(chan *BridgeRequest, 1), quit: make(chan struct{}),
|
||||
retries: 5, backoff: time.Hour}
|
||||
done := make(chan struct{})
|
||||
go func() { r.handle(requestFor(1, 5_000)); close(done) }()
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
close(r.quit)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the worker waited out an hour-long backoff after being told to stop")
|
||||
}
|
||||
}
|
||||
|
||||
// The worker runs off the consensus path: what a block hands it is picked up
|
||||
// and released without the block waiting.
|
||||
func TestTheWorkerReleasesWhatABlockHandsIt(t *testing.T) {
|
||||
vm, dst, _ := custodyRig(t)
|
||||
r := newReleaser(vm)
|
||||
vm.mu.Lock()
|
||||
vm.releaser = r
|
||||
vm.mu.Unlock()
|
||||
|
||||
pend(vm, requestFor(1, 5_000))
|
||||
buildAndAccept(t, vm)
|
||||
|
||||
require.Eventually(t, func() bool { return dst.broadcasts() == 1 },
|
||||
5*time.Second, 5*time.Millisecond, "the settled transfer was never released")
|
||||
|
||||
vm.stopReleaser()
|
||||
// Stopping twice is not an error: Shutdown may follow a re-enable.
|
||||
vm.stopReleaser()
|
||||
r.stop()
|
||||
}
|
||||
|
||||
// A full queue drops rather than blocking a block's commit. The transfer is
|
||||
// re-observed, and every other relayer is broadcasting anyway.
|
||||
func TestAFullQueueDoesNotBlockConsensus(t *testing.T) {
|
||||
vm := boot(t)
|
||||
r := &releaser{vm: vm, queue: make(chan *BridgeRequest, 1), quit: make(chan struct{})}
|
||||
r.enqueue(requestFor(1, 5_000))
|
||||
r.enqueue(requestFor(2, 5_000))
|
||||
require.Len(t, r.queue, 1)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Wiring
|
||||
// =============================================================================
|
||||
|
||||
// TestEnableBridgeReleaseWiresBothDirections. Both chains are dialled, both
|
||||
// workers start, and the node reports itself ready to relay.
|
||||
func TestEnableBridgeReleaseWiresBothDirections(t *testing.T) {
|
||||
vm, _, custody := custodyRig(t)
|
||||
|
||||
src := newEVMNode(uint64(srcChain))
|
||||
dst := newEVMNode(uint64(dstChain))
|
||||
chains := []ExternalChainConfig{
|
||||
chainCfg("lux-testnet-c", uint64(srcChain), src.serve(t)),
|
||||
chainCfg("zoo-testnet", uint64(dstChain), dst.serve(t)),
|
||||
}
|
||||
|
||||
asked := map[string]int{}
|
||||
kp := func(_ context.Context, path string) (*ecdsa.PrivateKey, error) {
|
||||
asked[path]++
|
||||
return relayerKey(t), nil
|
||||
}
|
||||
require.NoError(t, vm.EnableBridgeRelease(context.Background(), chains, kp, signingAttester{key: custody}))
|
||||
t.Cleanup(vm.stopReleaser)
|
||||
|
||||
require.Equal(t, map[string]int{"kms://bridge/relayer": 2}, asked,
|
||||
"each chain's relayer key is resolved from KMS, once")
|
||||
|
||||
vm.mu.RLock()
|
||||
require.Len(t, vm.evmByChainID, 2)
|
||||
require.NotNil(t, vm.releaser)
|
||||
require.NotNil(t, vm.watcher)
|
||||
vm.mu.RUnlock()
|
||||
|
||||
ready, reason, count := vm.readiness()
|
||||
require.True(t, ready, reason)
|
||||
require.Equal(t, 2, count)
|
||||
|
||||
// Enabling again replaces the wiring rather than stacking a second pair of
|
||||
// workers on the same queues.
|
||||
require.NoError(t, vm.EnableBridgeRelease(context.Background(), chains, kp, signingAttester{key: custody}))
|
||||
vm.mu.RLock()
|
||||
require.Len(t, vm.evmByChainID, 2)
|
||||
vm.mu.RUnlock()
|
||||
|
||||
require.NoError(t, vm.Shutdown(context.Background()))
|
||||
vm.mu.RLock()
|
||||
require.Nil(t, vm.releaser)
|
||||
require.Nil(t, vm.watcher)
|
||||
vm.mu.RUnlock()
|
||||
}
|
||||
|
||||
// Fail secure: anything the enable cannot complete leaves the node relaying
|
||||
// nothing rather than half-wired.
|
||||
func TestEnableBridgeReleaseIsAllOrNothing(t *testing.T) {
|
||||
vm := boot(t)
|
||||
node := newEVMNode(uint64(dstChain))
|
||||
url := node.serve(t)
|
||||
good := chainCfg("zoo", uint64(dstChain), url)
|
||||
kp := func(context.Context, string) (*ecdsa.PrivateKey, error) { return relayerKey(t), nil }
|
||||
ac := signingAttester{}
|
||||
|
||||
require.ErrorContains(t, vm.EnableBridgeRelease(context.Background(), nil, kp, ac),
|
||||
"no external chains")
|
||||
require.ErrorContains(t, vm.EnableBridgeRelease(context.Background(), []ExternalChainConfig{good}, nil, ac),
|
||||
"KeyProvider required")
|
||||
require.ErrorContains(t, vm.EnableBridgeRelease(context.Background(), []ExternalChainConfig{good}, kp, nil),
|
||||
"AttestationClient required")
|
||||
|
||||
// No KMS path is not a licence to look for the key somewhere else.
|
||||
inline := good
|
||||
inline.GasKeyKMSPath = ""
|
||||
require.ErrorContains(t, vm.EnableBridgeRelease(context.Background(), []ExternalChainConfig{inline}, kp, ac),
|
||||
"gasKeyKmsPath required")
|
||||
|
||||
// A key KMS will not hand over stops the whole enable.
|
||||
refusing := func(context.Context, string) (*ecdsa.PrivateKey, error) {
|
||||
return nil, errors.New("no such secret")
|
||||
}
|
||||
require.ErrorContains(t, vm.EnableBridgeRelease(context.Background(), []ExternalChainConfig{good}, refusing, ac),
|
||||
"resolve gas key from KMS")
|
||||
|
||||
// Two chains claiming one id would make routing a coin flip.
|
||||
require.ErrorContains(t, vm.EnableBridgeRelease(context.Background(),
|
||||
[]ExternalChainConfig{good, good}, kp, ac), "duplicate chain id")
|
||||
|
||||
// A chain that cannot be dialled stops it too.
|
||||
unreachable := chainCfg("dead", 4242, "http://127.0.0.1:1/")
|
||||
require.ErrorContains(t, vm.EnableBridgeRelease(context.Background(),
|
||||
[]ExternalChainConfig{unreachable}, kp, ac), "dial")
|
||||
|
||||
vm.mu.RLock()
|
||||
defer vm.mu.RUnlock()
|
||||
require.Empty(t, vm.evmByChainID, "a failed enable left this node half-wired")
|
||||
require.Nil(t, vm.releaser)
|
||||
}
|
||||
|
||||
func TestATransferIsBuiltFromTheRequestOrNotAtAll(t *testing.T) {
|
||||
good := requestFor(9, 1234)
|
||||
transfer, err := good.transfer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ids.ID(transfer.Digest()), good.ID)
|
||||
|
||||
short := requestFor(1, 1)
|
||||
short.Recipient = short.Recipient[:10]
|
||||
_, err = short.transfer()
|
||||
require.ErrorContains(t, err, "20 bytes")
|
||||
|
||||
noDst := requestFor(1, 1)
|
||||
noDst.DstChainID = 0
|
||||
_, err = noDst.transfer()
|
||||
require.ErrorContains(t, err, "destination chain id")
|
||||
}
|
||||
|
||||
func mustTransfer(t *testing.T, req *BridgeRequest) bridgeattest.BridgeTransfer {
|
||||
t.Helper()
|
||||
transfer, err := req.transfer()
|
||||
require.NoError(t, err)
|
||||
return transfer
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package bridgevm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/consensus/core/choices"
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/runtime"
|
||||
vmcore "github.com/luxfi/vm"
|
||||
"github.com/luxfi/warp"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Booting
|
||||
// =============================================================================
|
||||
|
||||
// TestTheVMBoots is the whole of what was wrong: the chain store was never
|
||||
// built, so Initialize dereferenced a nil store and B-Chain could not start.
|
||||
func TestTheVMBoots(t *testing.T) {
|
||||
vm := boot(t)
|
||||
require.NotNil(t, vm.chain)
|
||||
require.NotNil(t, vm.genesisBlock)
|
||||
require.NotNil(t, vm.signerSet)
|
||||
require.NotNil(t, vm.quoteEngine)
|
||||
require.NotNil(t, vm.swapStore)
|
||||
require.Equal(t, testChainID, vm.chainID)
|
||||
require.NotNil(t, vm.FeePolicy())
|
||||
|
||||
version, err := vm.Version(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Version.String(), version)
|
||||
}
|
||||
|
||||
// TestInitializeRefusesWhatItCannotRunOn. A VM that boots without the things
|
||||
// it needs is a chain that fails later, somewhere less obvious.
|
||||
func TestInitializeRefusesWhatItCannotRunOn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
require.ErrorContains(t, (&VM{}).Initialize(ctx, vmcore.Init{}), "no runtime")
|
||||
|
||||
noDB := initFor(memdb.New(), testConfig())
|
||||
noDB.DB = nil
|
||||
require.ErrorContains(t, (&VM{}).Initialize(ctx, noDB), "no database")
|
||||
|
||||
noLog := initFor(memdb.New(), testConfig())
|
||||
noLog.Runtime = &runtime.Runtime{NetworkID: 96369}
|
||||
require.ErrorContains(t, (&VM{}).Initialize(ctx, noLog), "invalid logger")
|
||||
|
||||
badConfig := initFor(memdb.New(), testConfig())
|
||||
badConfig.Config = []byte(`{"maxBridgeAmount": "not a number"}`)
|
||||
require.ErrorContains(t, (&VM{}).Initialize(ctx, badConfig), "parse config")
|
||||
|
||||
badGenesis := initFor(memdb.New(), testConfig())
|
||||
badGenesis.Genesis = []byte(`{`)
|
||||
require.ErrorContains(t, (&VM{}).Initialize(ctx, badGenesis), "parse genesis")
|
||||
}
|
||||
|
||||
// TestABridgeDeclaresItsCaps. A zero per-transfer cap refuses every transfer,
|
||||
// silently, at every block — which looks exactly like a bridge nobody is
|
||||
// using. A bridge whose limits nobody decided does not get to run.
|
||||
func TestABridgeDeclaresItsCaps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
for name, mutate := range map[string]func(*BridgeConfig){
|
||||
"minConfirmations must be at least 1": func(c *BridgeConfig) { c.MinConfirmations = 0 },
|
||||
"maxBridgeAmount must be declared": func(c *BridgeConfig) { c.MaxBridgeAmount = 0 },
|
||||
"is below maxBridgeAmount": func(c *BridgeConfig) { c.DailyBridgeLimit = c.MaxBridgeAmount - 1 },
|
||||
"is below the": func(c *BridgeConfig) { c.RequireValidatorBond = minValidatorBond - 1 },
|
||||
} {
|
||||
cfg := testConfig()
|
||||
mutate(&cfg)
|
||||
require.ErrorContains(t, (&VM{}).Initialize(ctx, initFor(memdb.New(), cfg)), name)
|
||||
}
|
||||
|
||||
// An empty configuration declares nothing, so it runs nothing.
|
||||
empty := initFor(memdb.New(), testConfig())
|
||||
empty.Config = nil
|
||||
require.Error(t, (&VM{}).Initialize(ctx, empty))
|
||||
}
|
||||
|
||||
// The signer set freezes at a size, and that size has a default because it is a
|
||||
// capacity rather than a risk decision.
|
||||
func TestTheSignerSetSizeDefaults(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.MaxSigners = 0
|
||||
vm := bootOn(t, memdb.New(), cfg)
|
||||
require.Equal(t, 100, vm.config.MaxSigners)
|
||||
}
|
||||
|
||||
// TestTheGenesisTimestampIsTheChainsFirstTime.
|
||||
func TestGenesisComesFromItsBytes(t *testing.T) {
|
||||
vm := boot(t)
|
||||
require.Equal(t, int64(1000000), vm.genesisBlock.BlockTimestamp)
|
||||
require.Equal(t, ids.Empty, vm.genesisBlock.ParentID())
|
||||
require.Zero(t, vm.genesisBlock.Height())
|
||||
require.Equal(t, time.Unix(1000000, 0), vm.genesisBlock.Timestamp())
|
||||
}
|
||||
|
||||
// TestTheVMIDIsTheSharedOne. A chain is resolved to a plugin binary by this
|
||||
// id, so a private copy of the value beside the shared one is a chain nothing
|
||||
// can open.
|
||||
func TestTheVMIDIsTheSharedOne(t *testing.T) {
|
||||
require.Equal(t, constants.BridgeVMID, VMID)
|
||||
require.NotEqual(t, ids.Empty, VMID)
|
||||
require.NotEqual(t, constants.MPCVMID, VMID)
|
||||
// The encoding the node resolves a plugin binary by. Computed outside this
|
||||
// package — base58(bytes ‖ sha256(bytes)[28:]) over "bridgevm" padded to 32
|
||||
// — so it pins the value rather than restating whatever the code produces.
|
||||
require.Equal(t, "kMhHABHM8j4bH94MCc4rsTNdo5E9En37MMyiujk4WdNxgXFsY", VMID.String())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// What consensus asks of the VM
|
||||
// =============================================================================
|
||||
|
||||
func TestTheChainAnswersConsensus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm := boot(t)
|
||||
pend(vm, requestFor(1, 100))
|
||||
blk := buildAndAccept(t, vm)
|
||||
|
||||
last, err := vm.LastAccepted(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, blk.ID(), last)
|
||||
|
||||
require.NoError(t, vm.SetPreference(ctx, blk.ID()))
|
||||
|
||||
got, err := vm.GetBlock(ctx, blk.ID())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, blk.Bytes(), got.Bytes())
|
||||
|
||||
_, err = vm.GetBlock(ctx, ids.GenerateTestID())
|
||||
require.Error(t, err)
|
||||
|
||||
parsed, err := vm.ParseBlock(ctx, blk.Bytes())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, blk.ID(), parsed.ID())
|
||||
|
||||
health, err := vm.HealthCheck(ctx)
|
||||
require.NoError(t, err)
|
||||
require.True(t, health.Healthy)
|
||||
}
|
||||
|
||||
// A block reports where it is and what became of it.
|
||||
func TestABlockReportsItself(t *testing.T) {
|
||||
vm := boot(t)
|
||||
pend(vm, requestFor(1, 100))
|
||||
blk := build(t, vm)
|
||||
|
||||
require.Equal(t, vm.genesisBlock.ID(), blk.Parent())
|
||||
require.Equal(t, vm.genesisBlock.ID(), blk.ParentID())
|
||||
require.Equal(t, uint64(1), blk.Height())
|
||||
require.Equal(t, time.Unix(blk.BlockTimestamp, 0), blk.Timestamp())
|
||||
require.NotEmpty(t, blk.Bytes())
|
||||
require.Equal(t, blk.Bytes(), blk.Bytes(), "the encoding is cached, not recomputed")
|
||||
|
||||
require.NoError(t, blk.Verify(context.Background()))
|
||||
require.NoError(t, blk.Accept(context.Background()))
|
||||
require.Equal(t, uint8(choices.Accepted), blk.Status())
|
||||
require.Nil(t, blk.spend, "an accepted block's state is the database")
|
||||
}
|
||||
|
||||
// A rejected block leaves nothing behind, so its transfers are proposed again
|
||||
// rather than lost with it.
|
||||
func TestARejectedBlockLeavesItsTransfersWaiting(t *testing.T) {
|
||||
vm := boot(t)
|
||||
req := requestFor(1, 100)
|
||||
pend(vm, req)
|
||||
|
||||
blk := build(t, vm)
|
||||
require.NoError(t, blk.Verify(context.Background()))
|
||||
require.NoError(t, blk.Reject(context.Background()))
|
||||
require.Equal(t, uint8(choices.Rejected), blk.Status())
|
||||
require.Nil(t, blk.spend)
|
||||
|
||||
_, err := vm.GetBlock(context.Background(), blk.ID())
|
||||
require.Error(t, err, "a rejected block is not a parent anything can build on")
|
||||
|
||||
vm.mu.RLock()
|
||||
_, waiting := vm.pendingBridges[req.ID]
|
||||
vm.mu.RUnlock()
|
||||
require.True(t, waiting)
|
||||
|
||||
// And it can be carried by the block that replaces it.
|
||||
again := buildAndAccept(t, vm)
|
||||
require.Equal(t, req.ID, again.BridgeRequests[0].ID)
|
||||
}
|
||||
|
||||
// The block id is computed once and cached, and computing it twice gives the
|
||||
// same answer.
|
||||
func TestABlockIDIsAFunctionOfItsContents(t *testing.T) {
|
||||
vm := boot(t)
|
||||
blk := blockOn(t, vm, vm.genesisBlock, 1_000_000, requestFor(1, 100))
|
||||
want := blk.ID()
|
||||
|
||||
blk.ID_ = ids.Empty
|
||||
require.Equal(t, want, blk.ID(), "the id is not a function of its contents")
|
||||
require.Equal(t, want, blk.computeID())
|
||||
}
|
||||
|
||||
// The peer-to-peer surface is answered rather than left to time out. B-Chain
|
||||
// carries no application protocol of its own: what it learns, it learns from
|
||||
// the source chains it reads.
|
||||
func TestThePeerSurfaceIsAnswered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm := boot(t)
|
||||
node := ids.GenerateTestNodeID()
|
||||
chain := ids.GenerateTestID()
|
||||
|
||||
require.NoError(t, vm.Connected(ctx, node, nil))
|
||||
require.NoError(t, vm.Disconnected(ctx, node))
|
||||
require.NoError(t, vm.Request(ctx, node, 1, time.Now(), nil))
|
||||
require.NoError(t, vm.Response(ctx, node, 1, nil))
|
||||
require.NoError(t, vm.RequestFailed(ctx, node, 1, &warp.Error{}))
|
||||
require.NoError(t, vm.Gossip(ctx, node, nil))
|
||||
require.NoError(t, vm.CrossChainRequest(ctx, chain, 1, time.Now(), nil))
|
||||
require.NoError(t, vm.CrossChainResponse(ctx, chain, 1, nil))
|
||||
require.NoError(t, vm.CrossChainRequestFailed(ctx, chain, 1, &warp.Error{}))
|
||||
require.NoError(t, vm.SetState(ctx, 0))
|
||||
require.NoError(t, vm.Shutdown(ctx))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The reshare request
|
||||
// =============================================================================
|
||||
|
||||
// signingWarp is a node's BLS signer, answering with a signature of the width
|
||||
// the envelope requires.
|
||||
type signingWarp struct {
|
||||
sig []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (s signingWarp) Sign(*warp.Message) ([]byte, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.sig, nil
|
||||
}
|
||||
|
||||
type recordingSender struct {
|
||||
warp.FakeSender
|
||||
mu sync.Mutex
|
||||
sent [][]byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *recordingSender) SendGossip(_ context.Context, _ warp.SendConfig, msg []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.sent = append(s.sent, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestReplacingASignerAsksMChainToReshare. B-Chain holds no custody key: the
|
||||
// reshare happens on M, and this is the request that asks for it.
|
||||
func TestReplacingASignerAsksMChainToReshare(t *testing.T) {
|
||||
vm := boot(t)
|
||||
sender := &recordingSender{}
|
||||
vm.rt.WarpSigner = signingWarp{sig: make([]byte, warp.SignatureLen)}
|
||||
vm.rt.Sender = sender
|
||||
|
||||
nodes := make([]ids.NodeID, 0, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
node := ids.GenerateTestNodeID()
|
||||
nodes = append(nodes, node)
|
||||
require.NoError(t, registerSigner(vm, node))
|
||||
}
|
||||
replacement := ids.GenerateTestNodeID()
|
||||
|
||||
result, err := vm.RemoveSigner(nodes[2], &replacement)
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Success)
|
||||
|
||||
sender.mu.Lock()
|
||||
defer sender.mu.Unlock()
|
||||
require.Len(t, sender.sent, 1, "M-Chain was never asked to reshare")
|
||||
|
||||
env, err := warp.ParseEnvelope(sender.sent[0])
|
||||
require.NoError(t, err)
|
||||
var asked CrossChainMPCRequest
|
||||
require.NoError(t, json.Unmarshal(env.Message.Payload, &asked))
|
||||
|
||||
require.Equal(t, MPCRequestReshare, asked.Type)
|
||||
require.Equal(t, result.ReshareSession, asked.SessionID)
|
||||
require.Equal(t, uint64(1), asked.Epoch)
|
||||
require.Equal(t, vm.signerSet.ThresholdT, asked.Threshold)
|
||||
require.Equal(t, testChainID[:], asked.SourceChainID,
|
||||
"the request must name the chain it came from")
|
||||
require.Len(t, asked.NewPartyIDs, 4)
|
||||
require.Len(t, asked.OldPartyIDs, 3, "the removed and the arriving signer are not old parties")
|
||||
}
|
||||
|
||||
// A node with no warp plumbing is not a fault: it validates like any other and
|
||||
// has no reshare to broadcast.
|
||||
func TestANodeWithoutWarpAsksNobody(t *testing.T) {
|
||||
vm := boot(t)
|
||||
node := ids.GenerateTestNodeID()
|
||||
require.NoError(t, registerSigner(vm, node))
|
||||
|
||||
result, err := vm.RemoveSigner(node, nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Success, "removal stands whether or not the request goes out")
|
||||
}
|
||||
|
||||
// A reshare request that cannot be signed or sent does not undo the removal:
|
||||
// the signer is out either way, and the request is retryable.
|
||||
func TestAReshareRequestThatFailsDoesNotUndoTheRemoval(t *testing.T) {
|
||||
for name, wire := range map[string]func(*VM){
|
||||
"the signature is the wrong width": func(vm *VM) {
|
||||
vm.rt.WarpSigner = signingWarp{sig: make([]byte, 10)}
|
||||
vm.rt.Sender = &recordingSender{}
|
||||
},
|
||||
"the node cannot sign": func(vm *VM) {
|
||||
vm.rt.WarpSigner = signingWarp{err: errors.New("no key")}
|
||||
vm.rt.Sender = &recordingSender{}
|
||||
},
|
||||
"the gossip does not go out": func(vm *VM) {
|
||||
vm.rt.WarpSigner = signingWarp{sig: make([]byte, warp.SignatureLen)}
|
||||
vm.rt.Sender = &recordingSender{err: errors.New("no peers")}
|
||||
},
|
||||
} {
|
||||
vm := boot(t)
|
||||
wire(vm)
|
||||
node := ids.GenerateTestNodeID()
|
||||
require.NoError(t, registerSigner(vm, node))
|
||||
|
||||
result, err := vm.RemoveSigner(node, nil)
|
||||
require.NoError(t, err, name)
|
||||
require.True(t, result.Success, name)
|
||||
require.False(t, vm.HasSigner(node), name)
|
||||
}
|
||||
}
|
||||
|
||||
// A VM with no logger does not panic when something happens worth logging.
|
||||
func TestALoglessVMDoesNotPanic(t *testing.T) {
|
||||
vm := &VM{signerSet: &SignerSet{}, config: testConfig()}
|
||||
vm.logInfo("nothing here")
|
||||
vm.logWarn("nor here")
|
||||
|
||||
vm.log = log.NewNoOpLogger()
|
||||
vm.logInfo("nor here either")
|
||||
vm.logWarn("nor here either")
|
||||
}
|
||||
Reference in New Issue
Block a user