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:
2026-08-28 11:52:11 -07:00
parent a8dbf849b4
commit 168838436e
6 changed files with 1828 additions and 131 deletions
+84 -116
View File
@@ -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)
+625
View File
@@ -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"))
}
+314
View File
@@ -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
View File
@@ -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()
+420
View File
@@ -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
}
+360
View File
@@ -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")
}