Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c844257
refactor building message publications into dedicated functions
johnsaigle Jul 8, 2026
ec44187
refactor runwithscissors into their own functions
johnsaigle Jul 8, 2026
14375a6
fix unit tests from the initial build msgpub refactor
johnsaigle Jul 8, 2026
0e2f1c4
move functions to dedicated files
johnsaigle Jul 8, 2026
60dac49
RPC client interface + mocking
johnsaigle Jul 8, 2026
5f3a64d
Add initial Solana test data
mdulin2 Jul 8, 2026
71afe06
Adding real Solana-based Txs
mdulin2 Jul 10, 2026
cabf705
first attempt at knitting tests together
johnsaigle Jul 10, 2026
a15ca32
add comments for key assertions and testing structs
johnsaigle Jul 10, 2026
76e4004
Add ALT table support
mdulin2 Jul 10, 2026
51411c5
move processNewTransactions to routines; add to tests
johnsaigle Jul 10, 2026
98e6d58
Speed up tests
mdulin2 Jul 10, 2026
97cb876
add comments; remove cruft; reorganize files
johnsaigle Jul 10, 2026
df35678
More test cases, reduce file size, and split into generated + real bu…
mdulin2 Jul 10, 2026
2ad5669
Clean up and add live test case code
mdulin2 Jul 20, 2026
def3959
Add missing generation files
mdulin2 Jul 23, 2026
c1bde22
node: clarify Solan watcher regression test wording
johnsaigle Jul 23, 2026
39bdf6d
clarify wording on the static vs live tests
johnsaigle Jul 23, 2026
b49bf8d
refactor to pass the lint rules
johnsaigle Jul 24, 2026
76b1003
additional test refactoring
johnsaigle Jul 30, 2026
bc06572
node(svm): add incomplete log replay coverage
ayushmishra2005 Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions node/pkg/watchers/evm/watcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package evm

import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"math/big"
"testing"
"time"

"github.com/certusone/wormhole/node/pkg/common"
gossipv1 "github.com/certusone/wormhole/node/pkg/proto/gossip/v1"
Expand All @@ -14,7 +16,9 @@ import (
ethereum "github.com/ethereum/go-ethereum"
eth_common "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/mr-tron/base58"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wormhole-foundation/wormhole/sdk/vaa"
Expand Down Expand Up @@ -979,3 +983,60 @@ func TestConsistencyLevelMatches(t *testing.T) {
assert.False(t, consistencyLevelMatches(vaa.ConsistencyLevelPublishImmediately, 0))
assert.False(t, consistencyLevelMatches(vaa.ConsistencyLevelSafe, 0))
}

func TestFigureOutTestData(t *testing.T) {
// Observation data:
// guardianSet: 7 (not part of the signing digest)
// timestamp: 1783494394
// nonce: 1300271749
// emitterChain: Solana
// emitterAddress: Gv1KWf8DT1jKv5pKBmGaTmVszqa56Xn8YGx2Pg7i7qAk
// sequence: 1407438
// consistencyLevel: 32
payload, err := hex.DecodeString("010000000000000000000000000000000000000000000000000000041d44ea298c0000000000000000000000008dce83eca4af45dbe618da1779f9aaca4320108400020000000000000000000000003e0954d9b32f823aff2f66173ffed5f453dedd9300020000000000000000000000000000000000000000000000000000000000000000")
require.NoError(t, err)

// Solana emitter addresses are base58-encoded and decode directly to 32 bytes (no padding).
emitterBytes, err := base58.Decode("Gv1KWf8DT1jKv5pKBmGaTmVszqa56Xn8YGx2Pg7i7qAk")
require.NoError(t, err)
var emitterAddress vaa.Address
copy(emitterAddress[:], emitterBytes)

msg := common.MessagePublication{
Timestamp: time.Unix(1783494394, 0),
Nonce: 1300271749,
Sequence: 1407438,
ConsistencyLevel: 32,
EmitterChain: vaa.ChainIDSolana,
EmitterAddress: emitterAddress,
Payload: payload,
}

// There are two related hashes here, and it's easy to confuse them:
//
// body = serialized VAA body (timestamp, nonce, chain, emitter, sequence, consistency, payload)
// vaaHash = keccak256(body) -- the VAA "hash" shown by Wormholescan / the SDK's .hash
// digest = keccak256(keccak256(body)) -- the SIGNING DIGEST
//
// The signing digest is the double-keccak256 of the body (guardian set index is NOT included).
// This is the value guardians actually sign (see processor.SigningDigest) and the value EVM
// contracts use for signature verification and replay protection (Bridge.sol setTransferCompleted(vm.hash)).
// The single-keccak vaaHash is the explorer/SDK identifier for the VAA and the double-keccak preimage;
// it is NOT signed and NOT the EVM replay key. (Solana's sig-verify instruction is passed this first
// hash to save space, then hashes once more to recover the digest.)
// The VAA body is the double-keccak preimage. There's no exported body accessor, but Marshal()
// with no signatures lays out a fixed 6-byte header (version[1] + guardianSetIndex[4] + sigCount[1]=0)
// followed by the body, so we can recover the body as the suffix.
marshaled, err := msg.CreateVAA(0).Marshal()
require.NoError(t, err)
body := marshaled[6:]
vaaHash := hex.EncodeToString(crypto.Keccak256(body))
digest := msg.CreateDigest()

t.Logf("VAA hash (keccak256(body), Wormholescan): %s", vaaHash)
t.Logf("signing digest (double keccak): %s", digest)

// Wormholescan's "hash" is the single-keccak VAA hash, not the signing digest.
require.Equal(t, "d663f03067d95fc160dae2be9d89b632a3f9f4f972b508afbce622aa5928bdbd", vaaHash)
require.Equal(t, "22b600c23ee95f323e5577fa8012e00bffd611f0ae65cae7ec66e7b2734b86a2", digest)
}
314 changes: 314 additions & 0 deletions node/pkg/watchers/solana/bundle_replay_setup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
package solana

// This file loads the Solana watcher replay cases and provides their mock RPC client.

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"testing"
"time"

"github.com/certusone/wormhole/node/pkg/common"
"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/rpc"
"github.com/stretchr/testify/require"
"github.com/wormhole-foundation/wormhole/sdk/vaa"
)

// The replay cases are split across two minified JSON files generated as follows:
//
// - staticBundlesFile: the builder-generated (synthetic) matrix. Regenerate with:
// go run ./pkg/watchers/solana/testgen/cmd static
// - liveBundlesFile: live-collected Solana transactions. Regenerate with:
// go run ./pkg/watchers/solana/testgen/cmd live --rpc "$SOLANA_RPC_URL"

const staticBundlesFile = "testdata/static_bundles.json"
const liveBundlesFile = "testdata/live_bundles.json"

// bundleFiles is every fixture file the replay test loads, in read order.
var bundleFiles = []string{staticBundlesFile, liveBundlesFile}

const updateReplayFixturesEnv = "UPDATE_SOLANA_REPLAY_FIXTURES"

// replayBundle mirrors the JSON emitted by the testgen builder. transaction and meta
// decode straight into the watcher's own input types.
type replayBundle struct {
Name string `json:"name"`
Slot uint64 `json:"slot"`
Contract solana.PublicKey `json:"contract"`
ShimContract solana.PublicKey `json:"shimContract"`
Transaction solana.Transaction `json:"transaction"`
Meta rpc.TransactionMeta `json:"meta"`
Accounts []replayAccount `json:"accounts"`
Expected *expectedOutput `json:"expected,omitempty"`
}

type replayAccount struct {
Pubkey solana.PublicKey `json:"pubkey"`
Owner solana.PublicKey `json:"owner"`
Data []string `json:"data"` // [base64Data, "base64"]
}

// expectedOutput records the VAA signing digests emitted by each replay flow:
// - Reobservation: handleReobservationRequest(txID).
// - Observation: fetchBlock, the normal guardian block-observation path.
// - Polling: processNewTransactions, the getSignaturesForAddress -> getTransaction path.
// - Account: handleReobservationRequest(accountID) over every served account.
type expectedOutput struct {
Reobservation []string `json:"reobservation"`
Observation []string `json:"observation"`
Polling []string `json:"polling"`
Account []string `json:"account"`
}

type expectedCount struct {
count int
collectUntilQuiet bool
}

type replayExpectedCounts struct {
reobservation expectedCount
observation expectedCount
polling expectedCount
account expectedCount
}

// newReplayWatcher builds the minimum watcher state needed to exercise the real Solana
// observation and reobservation entrypoints.
func newReplayWatcher(t *testing.T, b *replayBundle, msgC chan<- *common.MessagePublication) *SolanaWatcher {
t.Helper()

s := newTestWatcher(t, vaa.ChainIDSolana, rpc.CommitmentFinalized, msgC)
s.errC = make(chan error, 64)
s.ctx = context.Background()
s.contract = b.Contract
s.whLogPrefix = fmt.Sprintf("Program %s", b.Contract) // Necessary for the observation flow
s.shimContractAddr = b.ShimContract
s.shimContractStr = b.ShimContract.String()
s.shimSetup()
return s
}

// seedReplayRPCClient converts a generated bundle into the RPC responses consumed by
// the high-level watcher paths: getAccountInfo, getBlock, and getTransaction.
func seedReplayRPCClient(t *testing.T, b *replayBundle) *mockSolanaRPCClient {
t.Helper()

m := newMockSolanaRPCClient()
for _, acc := range b.Accounts {
require.NotEmpty(t, acc.Data, "account %s has no data", acc.Pubkey)
decoded, err := base64.StdEncoding.DecodeString(acc.Data[0])
require.NoError(t, err, "decode account data")
m.SetAccount(acc.Pubkey, acc.Owner.String(), decoded)
}

tx := b.Transaction // addressable copy
if len(tx.Message.AddressTableLookups) > 0 {
tx.Message.SetAddressTableLookups(tx.Message.AddressTableLookups)
}
txBytes, err := tx.MarshalBinary()
require.NoError(t, err, "marshal bundle transaction")
meta := b.Meta
if meta.Err == nil && len(meta.LogMessages) == 0 {
// fetchBlock filters transactions by Wormhole-looking logs before parsing
// instructions. Generated bundles model the transaction and account data, so
// synthesize the minimal logs needed to reach the watcher parsing logic.
meta.LogMessages = []string{
fmt.Sprintf("Program %s invoke [1]", b.Contract),
"Program log: Sequence: 1",
}
}

txWithMeta := rpc.TransactionWithMeta{
Slot: b.Slot,
Transaction: rpc.DataBytesOrJSONFromBytes(txBytes),
Meta: &meta,
Version: rpc.LegacyTransactionVersion,
}
m.blocks[b.Slot] = &rpc.GetBlockResult{Transactions: []rpc.TransactionWithMeta{txWithMeta}}

if len(tx.Signatures) > 0 {
m.transactions[tx.Signatures[0]] = makeGetTransactionResult(t, b.Slot, txBytes, &meta)
}
return m
}

// makeGetTransactionResult round-trips through JSON so the private solana-go envelope
// fields are populated exactly as they are for a real base64 getTransaction response.
func makeGetTransactionResult(t *testing.T, slot uint64, txBytes []byte, meta *rpc.TransactionMeta) *rpc.GetTransactionResult {
t.Helper()

raw := map[string]interface{}{
"slot": slot,
"transaction": []string{base64.StdEncoding.EncodeToString(txBytes), "base64"},
"meta": meta,
"version": "legacy",
}
encoded, err := json.Marshal(raw)
require.NoError(t, err, "marshal getTransaction fixture")

var result rpc.GetTransactionResult
require.NoError(t, json.Unmarshal(encoded, &result), "decode getTransaction fixture")
return &result
}

// drain collects the VAA signing digests published by one replay flow. The caller
// compares the sorted digest list with the recorded regression baseline.
func drain(t *testing.T, msgC <-chan *common.MessagePublication, errC <-chan error, expect expectedCount) (digests []string, replayErrs []error) {
t.Helper()
if expect.collectUntilQuiet {
return collectUntilQuiet(t, msgC, errC)
}
return collectExpected(t, msgC, errC, expect.count)
}

// drainPolling collects output from processNewTransactions, which returns before
// processTransactionWithRetry finishes. Zero-output fixtures must wait for the
// channel to stay quiet, otherwise a delayed unexpected publication can be missed.
// Future refactor: split the watcher async boundaries so RunWithScissors wrappers
// spawn deterministic serial workers that replay tests can call directly.
func drainPolling(t *testing.T, msgC <-chan *common.MessagePublication, errC <-chan error, expect expectedCount) (digests []string, replayErrs []error) {
t.Helper()
if expect.collectUntilQuiet || expect.count == 0 {
return collectUntilQuiet(t, msgC, errC)
}
return collectExpected(t, msgC, errC, expect.count)
}

func collectExpected(t *testing.T, msgC <-chan *common.MessagePublication, errC <-chan error, expect int) (digests []string, replayErrs []error) {
t.Helper()
const perMsg = 3 * time.Second
for i := 0; i < expect; i++ {
select {
case msg := <-msgC:
require.NotNil(t, msg, "nil publication")
digests = append(digests, msg.CreateDigest())
case <-time.After(perMsg):
return finishDrain(digests, msgC, errC)
}
}
return finishDrain(digests, msgC, errC)
}

// collectUntilQuiet is used during fixture generation, when a flow's count is not
// yet known, and for zero-output polling fixtures where processNewTransactions
// returns before its worker goroutine has necessarily finished.
func collectUntilQuiet(t *testing.T, msgC <-chan *common.MessagePublication, errC <-chan error) (digests []string, replayErrs []error) {
t.Helper()
const settle = 1 * time.Second
for {
select {
case msg := <-msgC:
require.NotNil(t, msg, "nil publication")
digests = append(digests, msg.CreateDigest())
case <-time.After(settle):
return finishDrain(digests, msgC, errC)
}
}
}

func finishDrain(digests []string, msgC <-chan *common.MessagePublication, errC <-chan error) ([]string, []error) {
for {
select {
case msg := <-msgC:
if msg != nil {
digests = append(digests, msg.CreateDigest())
}
default:
var errs []error
for {
select {
case err := <-errC:
errs = append(errs, err)
default:
sort.Strings(digests)
return digests, errs
}
}
}
}
}

func reobserveTransactionOutput(t *testing.T, b *replayBundle, expect expectedCount) (digests []string, replayErrs []error) {
t.Helper()

msgC := make(chan *common.MessagePublication, 64)
s := newReplayWatcher(t, b, msgC)
m := seedReplayRPCClient(t, b)
require.NotEmpty(t, b.Transaction.Signatures, "bundle has no transaction signature")
_, err := s.handleReobservationRequest(vaa.ChainIDSolana, b.Transaction.Signatures[0][:], m)
if b.Meta.Err == nil {
require.NoError(t, err, "reobserve transaction")
} else {
require.Error(t, err, "failed transaction reobservation should return an error")
}
return drain(t, msgC, s.errC, expect)
}

func fetchBlockOutput(t *testing.T, b *replayBundle, expect expectedCount) (digests []string, replayErrs []error) {
t.Helper()

msgC := make(chan *common.MessagePublication, 64)
s := newReplayWatcher(t, b, msgC)
m := seedReplayRPCClient(t, b)
s.rpcClient = m
require.True(t, s.fetchBlock(context.Background(), s.logger, b.Slot, 0, false), "fetch block")
return drain(t, msgC, s.errC, expect)
}

func processNewTransactionsOutput(t *testing.T, b *replayBundle, expect expectedCount) (digests []string, replayErrs []error) {
t.Helper()

msgC := make(chan *common.MessagePublication, 64)
s := newReplayWatcher(t, b, msgC)
m := seedReplayRPCClient(t, b)
require.NotEmpty(t, b.Transaction.Signatures, "bundle has no transaction signature")
m.signatures[b.Contract] = []*rpc.TransactionSignature{{Signature: b.Transaction.Signatures[0]}}
s.rpcClient = m

require.NoError(t, s.processNewTransactions(), "process new transactions")
return drainPolling(t, msgC, s.errC, expect)
}

// reobserveAccountOutput feeds every served account through the by-account reobservation path.
func reobserveAccountOutput(t *testing.T, b *replayBundle, expect expectedCount) (digests []string, replayErrs []error) {
t.Helper()

msgC := make(chan *common.MessagePublication, 64)
s := newReplayWatcher(t, b, msgC)
m := seedReplayRPCClient(t, b)

seen := map[solana.PublicKey]bool{}
for _, acc := range b.Accounts {
if seen[acc.Pubkey] {
continue
}
seen[acc.Pubkey] = true
_, err := s.handleReobservationRequest(vaa.ChainIDSolana, acc.Pubkey[:], m)
require.NoError(t, err, "reobserve account %s", acc.Pubkey)
}
return drain(t, msgC, s.errC, expect)
}

// insertExpected sets the "expected" field on a bundle's JSON object. It decodes the
// bundle into a map of raw fields so the existing transaction/meta/account values are
// preserved verbatim, adds (or replaces) the expected entry, and re-encodes.
func insertExpected(raw json.RawMessage, exp *expectedOutput) (json.RawMessage, error) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return nil, fmt.Errorf("decode bundle object: %w", err)
}
expJSON, err := json.Marshal(exp)
if err != nil {
return nil, fmt.Errorf("marshal expected: %w", err)
}
fields["expected"] = expJSON
out, err := json.Marshal(fields)
if err != nil {
return nil, fmt.Errorf("encode bundle object: %w", err)
}
return out, nil
}
Loading