From f12ed18ea9ca7792e14fbeda01e0d3a5a9ad8a06 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 18 Aug 2026 17:21:03 +0530 Subject: [PATCH 1/7] fix: only accept solana gateway events emitted by the gateway program (F-2026-18198) --- universalClient/chains/svm/event_listener.go | 84 +++++++++++- .../chains/svm/event_listener_test.go | 129 ++++++++++++++++++ 2 files changed, 212 insertions(+), 1 deletion(-) diff --git a/universalClient/chains/svm/event_listener.go b/universalClient/chains/svm/event_listener.go index aff9f027..1dfd10e7 100644 --- a/universalClient/chains/svm/event_listener.go +++ b/universalClient/chains/svm/event_listener.go @@ -295,9 +295,19 @@ func (el *EventListener) processSignatureBatch( continue } - // Process each log in the transaction + // Process each log in the transaction. + // getSignaturesForAddress returns any tx that merely references the + // gateway in accountKeys, and a discriminator is a schema tag rather than + // an authenticator. So track the invocation stack and accept a + // "Program data:" line only while the gateway is the executing program; + // otherwise any program could emit a forged gateway event. if tx != nil && tx.Meta != nil && len(tx.Meta.LogMessages) > 0 { + fromGateway := gatewayEmittedLogs(tx.Meta.LogMessages, el.gatewayAddress) for logIndex, log := range tx.Meta.LogMessages { + if !fromGateway[logIndex] { + continue + } + // Determine event type based on discriminator eventType := el.determineEventType(log) if eventType == "" { @@ -395,6 +405,78 @@ func (el *EventListener) getPollingInterval() time.Duration { return 5 * time.Second // default } +// invokedProgram returns the program ID from a "Program invoke []" +// runtime log. Programs cannot emit these: sol_log and sol_log_data are always +// prefixed with "Program log: " / "Program data: ", so the invoke and exit lines +// are runtime-generated and safe to build an attribution stack from. +func invokedProgram(log string) (string, bool) { + const prefix = "Program " + if !strings.HasPrefix(log, prefix) { + return "", false + } + rest := log[len(prefix):] + idx := strings.Index(rest, " invoke [") + if idx <= 0 { + return "", false + } + programID := rest[:idx] + if _, err := solana.PublicKeyFromBase58(programID); err != nil { + return "", false + } + return programID, true +} + +// gatewayEmittedLogs returns the indexes of "Program data:" lines emitted while +// gatewayAddress was the executing program, walking the invoke/exit stack. +// Lines emitted by any other program are excluded: a discriminator identifies an +// encoding schema, not the emitter, so without this any program could log a +// well-formed gateway event and have it observed as a real deposit. +func gatewayEmittedLogs(logs []string, gatewayAddress string) map[int]bool { + emitted := make(map[int]bool) + var stack []string + for i, log := range logs { + if programID, ok := invokedProgram(log); ok { + stack = append(stack, programID) + continue + } + if isProgramExit(log) { + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + continue + } + if !strings.HasPrefix(log, "Program data: ") { + continue + } + if len(stack) > 0 && stack[len(stack)-1] == gatewayAddress { + emitted[i] = true + } + } + return emitted +} + +// isProgramExit reports whether log ends an invocation frame, i.e. +// "Program success" or "Program failed: ...". +// The program ID must parse as a pubkey: otherwise a program logging "success" +// emits "Program log: success", which would pop a frame it does not own and let +// a later log be attributed to its caller. +func isProgramExit(log string) bool { + const prefix = "Program " + if !strings.HasPrefix(log, prefix) { + return false + } + rest := log[len(prefix):] + sp := strings.IndexByte(rest, ' ') + if sp <= 0 { + return false + } + if _, err := solana.PublicKeyFromBase58(rest[:sp]); err != nil { + return false + } + tail := rest[sp+1:] + return tail == "success" || strings.HasPrefix(tail, "failed") +} + // determineEventType determines the event type based on the log discriminator func (el *EventListener) determineEventType(log string) string { if !strings.HasPrefix(log, "Program data: ") { diff --git a/universalClient/chains/svm/event_listener_test.go b/universalClient/chains/svm/event_listener_test.go index 62065a9b..ba223b5f 100644 --- a/universalClient/chains/svm/event_listener_test.go +++ b/universalClient/chains/svm/event_listener_test.go @@ -676,3 +676,132 @@ func TestEventListener_StartWhileRunning(t *testing.T) { cancel() el.wg.Wait() } + +const ( + testGatewayProgram = "CFVSincHYbETh2k7w6u1ENEkjbSLtveRCEBupKidw2VS" + testAttackerProgram = "AttackerProgram1111111111111111111111111111" +) + +// getSignaturesForAddress returns any tx that merely references the gateway in +// accountKeys, and a discriminator is a schema tag, not an authenticator. So a +// gateway-shaped log must only be trusted when the gateway is the executing +// program. Otherwise any program can forge a deposit that every honest UV +// deterministically votes for. +func TestGatewayEmittedLogs(t *testing.T) { + const data = "Program data: q83vEjRWeJA=" + + t.Run("accepts log emitted by the gateway", func(t *testing.T) { + logs := []string{ + "Program " + testGatewayProgram + " invoke [1]", + data, + "Program " + testGatewayProgram + " success", + } + assert.Equal(t, map[int]bool{1: true}, gatewayEmittedLogs(logs, testGatewayProgram)) + }) + + // The reported attack: attacker program lists the gateway as an unused + // read-only account and emits a correctly encoded gateway event. + t.Run("rejects forged log from an attacker program", func(t *testing.T) { + logs := []string{ + "Program " + testAttackerProgram + " invoke [1]", + data, + "Program " + testAttackerProgram + " success", + } + assert.Empty(t, gatewayEmittedLogs(logs, testGatewayProgram)) + }) + + t.Run("accepts gateway frame reached via CPI", func(t *testing.T) { + logs := []string{ + "Program " + testAttackerProgram + " invoke [1]", + "Program " + testGatewayProgram + " invoke [2]", + data, + "Program " + testGatewayProgram + " success", + "Program " + testAttackerProgram + " success", + } + assert.Equal(t, map[int]bool{2: true}, gatewayEmittedLogs(logs, testGatewayProgram)) + }) + + // After the gateway frame exits, control is back with the caller, so a log + // there is not the gateway's. + t.Run("rejects log emitted after the gateway frame exits", func(t *testing.T) { + logs := []string{ + "Program " + testAttackerProgram + " invoke [1]", + "Program " + testGatewayProgram + " invoke [2]", + "Program " + testGatewayProgram + " success", + data, + "Program " + testAttackerProgram + " success", + } + assert.Empty(t, gatewayEmittedLogs(logs, testGatewayProgram)) + }) + + t.Run("rejects log from a failed gateway invocation's caller", func(t *testing.T) { + logs := []string{ + "Program " + testGatewayProgram + " invoke [1]", + "Program " + testGatewayProgram + " failed: custom program error: 0x1", + data, + } + assert.Empty(t, gatewayEmittedLogs(logs, testGatewayProgram)) + }) + + // A program can only emit "Program log: ..." or "Program data: ...", so it + // cannot fake an invoke line to push a gateway frame onto the stack. + t.Run("cannot spoof an invoke line via program log", func(t *testing.T) { + logs := []string{ + "Program " + testAttackerProgram + " invoke [1]", + "Program log: Program " + testGatewayProgram + " invoke [1]", + data, + "Program " + testAttackerProgram + " success", + } + assert.Empty(t, gatewayEmittedLogs(logs, testGatewayProgram)) + }) + + // A callee that logs "success" emits "Program log: success". If that were + // treated as a frame exit it would pop its own frame and the next data log + // would be attributed to its caller, the gateway. + t.Run("callee cannot pop its frame by logging success", func(t *testing.T) { + logs := []string{ + "Program " + testGatewayProgram + " invoke [1]", + "Program " + testAttackerProgram + " invoke [2]", + "Program log: success", + data, + "Program " + testAttackerProgram + " success", + "Program " + testGatewayProgram + " success", + } + assert.Empty(t, gatewayEmittedLogs(logs, testGatewayProgram), + "data logged inside the callee must not be attributed to the gateway") + }) + + t.Run("no logs or no invocation yields nothing", func(t *testing.T) { + assert.Empty(t, gatewayEmittedLogs(nil, testGatewayProgram)) + assert.Empty(t, gatewayEmittedLogs([]string{data}, testGatewayProgram)) + }) + + t.Run("unrelated runtime lines do not disturb the stack", func(t *testing.T) { + logs := []string{ + "Program " + testGatewayProgram + " invoke [1]", + "Program log: Instruction: SendFunds", + "Program return: " + testGatewayProgram + " AQID", + "Program " + testGatewayProgram + " consumed 12345 of 200000 compute units", + data, + "Program " + testGatewayProgram + " success", + } + assert.Equal(t, map[int]bool{4: true}, gatewayEmittedLogs(logs, testGatewayProgram)) + }) +} + +func TestInvokedProgramAndExit(t *testing.T) { + id, ok := invokedProgram("Program " + testGatewayProgram + " invoke [1]") + assert.True(t, ok) + assert.Equal(t, testGatewayProgram, id) + + _, ok = invokedProgram("Program log: hello") + assert.False(t, ok) + _, ok = invokedProgram("Program data: AQID") + assert.False(t, ok) + + assert.True(t, isProgramExit("Program "+testGatewayProgram+" success")) + assert.True(t, isProgramExit("Program "+testGatewayProgram+" failed: custom program error: 0x1")) + assert.False(t, isProgramExit("Program log: success")) + assert.False(t, isProgramExit("Program data: AQID")) + assert.False(t, isProgramExit("Program "+testGatewayProgram+" consumed 1 of 2 compute units")) +} From d5747e2cfb4ba2686d6a4f199cb9db6cc786fc75 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 18 Aug 2026 18:18:50 +0530 Subject: [PATCH 2/7] test: end-to-end proof that forged solana gateway events are not stored (F-2026-18198) --- .../chains/svm/event_listener_test.go | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/universalClient/chains/svm/event_listener_test.go b/universalClient/chains/svm/event_listener_test.go index ba223b5f..63b61c27 100644 --- a/universalClient/chains/svm/event_listener_test.go +++ b/universalClient/chains/svm/event_listener_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/pushchain/push-chain-node/universalClient/chains/common" "github.com/pushchain/push-chain-node/universalClient/db" uregistrytypes "github.com/pushchain/push-chain-node/x/uregistry/types" ) @@ -805,3 +806,77 @@ func TestInvokedProgramAndExit(t *testing.T) { assert.False(t, isProgramExit("Program data: AQID")) assert.False(t, isProgramExit("Program "+testGatewayProgram+" consumed 1 of 2 compute units")) } + +// forgeryRPC serves one transaction whose logs the test controls. +type forgeryRPC struct { + slot uint64 + sig solana.Signature + logs []string +} + +func (m *forgeryRPC) GetLatestSlot(context.Context) (uint64, error) { return m.slot, nil } + +func (m *forgeryRPC) GetSignaturesForAddress(context.Context, solana.PublicKey, solana.Signature) ([]*solanarpc.TransactionSignature, error) { + return []*solanarpc.TransactionSignature{{Signature: m.sig, Slot: m.slot}}, nil +} + +func (m *forgeryRPC) GetTransaction(context.Context, solana.Signature) (*solanarpc.GetTransactionResult, error) { + return &solanarpc.GetTransactionResult{ + Slot: m.slot, + Meta: &solanarpc.TransactionMeta{LogMessages: m.logs}, + }, nil +} + +// End-to-end proof that the listener drops a forged event. The same valid +// send_funds payload is served twice: emitted by an attacker program it must be +// ignored, emitted by the gateway it must be stored. Running both with one +// payload shows attribution is what rejects it, not a decode failure. +func TestProcessSignatureBatch_RejectsForgedGatewayEvent(t *testing.T) { + discriminator := "0000000000000000" // buildSendFundsPayload zeroes the discriminator + payload := buildSendFundsPayload( + [32]byte{1}, [20]byte{2}, [32]byte{3}, 1_000_000, + nil, [32]byte{4}, 0, nil, false, + ) + dataLog := "Program data: " + base64.StdEncoding.EncodeToString(payload) + + run := func(t *testing.T, logs []string) int { + t.Helper() + database, err := db.OpenInMemoryDB(true) + require.NoError(t, err) + t.Cleanup(func() { database.Close() }) + + methods := []*uregistrytypes.GatewayMethods{ + {Name: EventTypeSendFunds, EventIdentifier: discriminator}, + } + rpc := &forgeryRPC{slot: 100, sig: mkSig(7), logs: logs} + el, err := NewEventListener(rpc, testGatewayProgram, "solana:test", methods, database, 10, nil, zerolog.Nop()) + require.NoError(t, err) + + _, err = el.processSignatureBatch(context.Background(), []*solanarpc.TransactionSignature{ + {Signature: mkSig(7), Slot: 100}, + }, 0, 200) + require.NoError(t, err) + + events, err := common.NewChainStore(database).GetPendingEvents(100) + require.NoError(t, err) + return len(events) + } + + t.Run("forged by attacker program is not stored", func(t *testing.T) { + stored := run(t, []string{ + "Program " + testAttackerProgram + " invoke [1]", + dataLog, + "Program " + testAttackerProgram + " success", + }) + assert.Zero(t, stored, "forged gateway event must not become an inbound") + }) + + t.Run("same payload from the gateway is stored", func(t *testing.T) { + stored := run(t, []string{ + "Program " + testGatewayProgram + " invoke [1]", + dataLog, + "Program " + testGatewayProgram + " success", + }) + assert.Equal(t, 1, stored, "genuine gateway event must be observed") + }) +} From b695d24bee60fa79deca0289c319b4a9c1f701a6 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 18 Aug 2026 18:27:56 +0530 Subject: [PATCH 3/7] fix: detect truncated solana log buffer so dropped gateway events are not silent (F-2026-18198) --- universalClient/chains/svm/event_listener.go | 30 ++++++++ .../chains/svm/event_listener_test.go | 74 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/universalClient/chains/svm/event_listener.go b/universalClient/chains/svm/event_listener.go index 1dfd10e7..6c229258 100644 --- a/universalClient/chains/svm/event_listener.go +++ b/universalClient/chains/svm/event_listener.go @@ -302,6 +302,16 @@ func (el *EventListener) processSignatureBatch( // "Program data:" line only while the gateway is the executing program; // otherwise any program could emit a forged gateway event. if tx != nil && tx.Meta != nil && len(tx.Meta.LogMessages) > 0 { + // Surface truncation loudly: a gateway event may have been dropped and + // is unrecoverable from RPC, so the deposit needs manual reconciliation. + // Visible logs are still processed, since events before the cut are real. + if logsTruncated(tx.Meta.LogMessages) { + el.logger.Error(). + Str("signature", sig.Signature.String()). + Uint64("slot", sig.Slot). + Msg("solana log buffer truncated; a gateway event may have been dropped and needs manual review") + } + fromGateway := gatewayEmittedLogs(tx.Meta.LogMessages, el.gatewayAddress) for logIndex, log := range tx.Meta.LogMessages { if !fromGateway[logIndex] { @@ -455,6 +465,26 @@ func gatewayEmittedLogs(logs []string, gatewayAddress string) map[int]bool { return emitted } +// logsTruncated reports whether the runtime dropped part of this transaction's +// log buffer. Programs can only emit "Program log:" and "Program data:" lines, +// so a bare line is runtime-generated and cannot be spoofed. Matching on the +// word rather than one exact literal keeps this working if the wording changes. +// +// It matters because gateway events are emitted with sol_log_data: once the +// buffer overflows the event line is gone, and no RPC call can recover it. The +// deposit would otherwise be missed in silence. +func logsTruncated(logs []string) bool { + for _, log := range logs { + if strings.HasPrefix(log, "Program ") { + continue + } + if strings.Contains(strings.ToLower(log), "truncated") { + return true + } + } + return false +} + // isProgramExit reports whether log ends an invocation frame, i.e. // "Program success" or "Program failed: ...". // The program ID must parse as a pubkey: otherwise a program logging "success" diff --git a/universalClient/chains/svm/event_listener_test.go b/universalClient/chains/svm/event_listener_test.go index 63b61c27..9961f6d4 100644 --- a/universalClient/chains/svm/event_listener_test.go +++ b/universalClient/chains/svm/event_listener_test.go @@ -880,3 +880,77 @@ func TestProcessSignatureBatch_RejectsForgedGatewayEvent(t *testing.T) { assert.Equal(t, 1, stored, "genuine gateway event must be observed") }) } + +// Gateway events are emitted with sol_log_data, so once the runtime truncates +// the log buffer the event line is gone and no RPC call recovers it. Detect it +// so a missed deposit is alertable instead of silent. +func TestLogsTruncated(t *testing.T) { + t.Run("detects the runtime marker", func(t *testing.T) { + assert.True(t, logsTruncated([]string{ + "Program " + testGatewayProgram + " invoke [1]", + "Program log: Instruction: SendFunds", + "Log truncated", + })) + }) + + t.Run("matches wording variants and case", func(t *testing.T) { + assert.True(t, logsTruncated([]string{"log truncated"})) + assert.True(t, logsTruncated([]string{"Log Truncated"})) + }) + + t.Run("normal logs are not flagged", func(t *testing.T) { + assert.False(t, logsTruncated([]string{ + "Program " + testGatewayProgram + " invoke [1]", + "Program log: Instruction: SendFunds", + "Program data: q83vEjRWeJA=", + "Program " + testGatewayProgram + " success", + })) + assert.False(t, logsTruncated(nil)) + }) + + // A program cannot emit a bare line, so it cannot fake the marker. Its own + // output is always prefixed and must not trip detection. + t.Run("program cannot spoof the marker", func(t *testing.T) { + assert.False(t, logsTruncated([]string{ + "Program " + testAttackerProgram + " invoke [1]", + "Program log: Log truncated", + "Program data: dHJ1bmNhdGVk", + "Program " + testAttackerProgram + " success", + })) + }) +} + +// Truncation must not discard the events that did survive: anything logged +// before the cut is genuine and attributable. +func TestProcessSignatureBatch_TruncatedLogsStillStoreVisibleEvents(t *testing.T) { + discriminator := "0000000000000000" + payload := buildSendFundsPayload( + [32]byte{1}, [20]byte{2}, [32]byte{3}, 1_000_000, + nil, [32]byte{4}, 0, nil, false, + ) + + database, err := db.OpenInMemoryDB(true) + require.NoError(t, err) + defer database.Close() + + methods := []*uregistrytypes.GatewayMethods{ + {Name: EventTypeSendFunds, EventIdentifier: discriminator}, + } + rpc := &forgeryRPC{slot: 100, sig: mkSig(9), logs: []string{ + "Program " + testGatewayProgram + " invoke [1]", + "Program data: " + base64.StdEncoding.EncodeToString(payload), + "Program " + testGatewayProgram + " success", + "Log truncated", + }} + el, err := NewEventListener(rpc, testGatewayProgram, "solana:test", methods, database, 10, nil, zerolog.Nop()) + require.NoError(t, err) + + _, err = el.processSignatureBatch(context.Background(), []*solanarpc.TransactionSignature{ + {Signature: mkSig(9), Slot: 100}, + }, 0, 200) + require.NoError(t, err) + + events, err := common.NewChainStore(database).GetPendingEvents(100) + require.NoError(t, err) + assert.Len(t, events, 1, "events logged before the cut must still be stored") +} From 4f8c505374eaefa6332d2b125ecac0044cf56607 Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 19 Aug 2026 16:36:14 +0530 Subject: [PATCH 4/7] fix: identify which solana gateway event was dropped, not just that logs were cut (F-2026-18198) --- docs/svm-event-observation.md | 232 ++++++++++++++++ universalClient/chains/svm/event_listener.go | 162 ++++++++++- .../chains/svm/event_listener_test.go | 251 +++++++++++++++++- 3 files changed, 631 insertions(+), 14 deletions(-) create mode 100644 docs/svm-event-observation.md diff --git a/docs/svm-event-observation.md b/docs/svm-event-observation.md new file mode 100644 index 00000000..860f5a8c --- /dev/null +++ b/docs/svm-event-observation.md @@ -0,0 +1,232 @@ +# SVM event observation: current state and options + +Context for F-2026-18198 (forged gateway events) and the log truncation gap found alongside it. +Covers how Solana event observation differs from EVM, what we shipped, and what the remaining +options are. + +## Why Solana is different from EVM + +**EVM.** One filtered call does everything: + +```go +query := ethereum.FilterQuery{Addresses: []common.Address{gateway, vault}, Topics: ...} +logs, _ := rpcClient.FilterLogs(ctx, query) +``` + +A log's `address` field is set by the EVM itself when the contract executes `LOG0`-`LOG4`. +A contract cannot emit a log attributed to another address, so attribution is a protocol +guarantee and we get it for free. `topic[0]` identifies the event. + +**Solana.** There is no event system. What Anchor calls an event is `sol_log_data(bytes)`, +which appends a base64 string to a flat `meta.logMessages` array: + +``` +Program invoke [1] +Program log: Instruction: SendFunds +Program data: <- the "event" +Program success +``` + +Two consequences: + +- No per-log program field. Nothing in that line says who emitted it. The only signal is + which `invoke` frame was open at the time. +- No "get logs by program" RPC. `getSignaturesForAddress` returns transactions that merely + reference an address in `accountKeys`. Referenced, not invoked, not even writable. + +So on Solana attribution must be reconstructed. That reconstruction was missing, which is +what F-2026-18198 exploited. + +## What was wrong + +We applied the EVM mental model: + +1. `getSignaturesForAddress(gateway)` to select candidate transactions. +2. Treat every `Program data:` line whose 8-byte discriminator matched as a gateway event. + +Both steps were unsound. Referencing the gateway proves nothing, and a discriminator is +`sha256("event:SendFunds")[:8]`, a public schema tag rather than an authenticator. Any +program could emit a well-formed `send_funds` event with any amount and recipient. Every +honest UV would read the same successful transaction and vote identically, minting PRC20 +with no deposit behind it. No validator compromise required. + +## What we shipped (PR #308) + +**1. Invocation-stack attribution.** `gatewayEmittedLogs` walks the runtime `invoke` and +`success` / `failed:` lines as a stack and accepts a `Program data:` line only while the +gateway is the executing frame. + +Sound because programs cannot forge those lines: `sol_log` always produces `Program log: ` +and `sol_log_data` always produces `Program data: `, so bare `Program invoke [n]` lines +are runtime generated. Tested, not assumed. + +A first attempt had its own hole: `isProgramExit("Program log: success")` returned true, so a +program logging `msg!("success")` could pop a frame it did not own. If the gateway CPIs into +another program, that callee could pop its own frame and have its next data log attributed to +the gateway. Fixed by requiring the token to parse as a base58 pubkey. + +**2. Truncation detection.** `logsTruncated` flags a dropped log buffer and the listener logs +at error level with signature and slot. Visible logs are still processed, since events before +the cut are genuine. + +**3. Dropped-event identification.** `gatewayInstructionCounts` counts the gateway instructions +that executed and compares them against the events observed, so a shortfall names the event type +and count rather than just reporting that the buffer was cut. Option B1 below. + +This is identification, not recovery. See below. + +## The remaining gap: log truncation + +Solana caps the log buffer per transaction. On overflow the runtime drops the remainder and +appends a truncation marker. Because gateway events are emitted with `sol_log_data`, an +overflowing transaction can lose the event line entirely. + +Impact is liveness, not forgery: a real deposit is never observed, and the user's funds sit on +Solana until someone reconciles manually. No RPC call recovers a truncated log. + +Rarity, measured: 7116 mainnet transactions scanned across 5 blocks, zero truncated. Rare, +but the consequence per occurrence is a stuck deposit. + +## Options for closing it + +### Option A: detection only (shipped) + +Flag truncation at error level so a missed deposit is alertable instead of silent. + +- Cost: done. +- Closes forgery: not applicable, separate fix. +- Closes truncation: no. Converts silent loss into detected loss. +- Requires Solana program change: no. + +### Option B: use the instruction, not the event log (recommended) + +Instructions live in `transaction.message.instructions` and `meta.innerInstructions`. Neither +is part of the log buffer, so neither is ever truncated. This splits into two steps of very +different cost. + +**B1: detect a dropped event (shipped in this PR).** Count the gateway instructions carrying a +known instruction discriminator, compare against the events actually observed, and report a +shortfall. This needs only the discriminator, not the argument layout. It converts "the buffer +was cut somewhere" into "this event type was lost, N times, in this signature". + +Only the shortfall direction is checked. `finalize_universal_tx` emits more than one event per +instruction (see below), so requiring equality would false-positive. + +**B2: reconstruct the event (needs the IDL).** The event is a deterministic function of the +instruction arguments and the account list, so it can be rebuilt when the log is gone. This is +verified, not assumed. Field alignment from a live devnet transaction +(`3FuFyisoxYwvwsbXhEX9Z6kg…`), 108 argument bytes against a 138 byte event body: + +| Event offset | Content | Source | +| --- | --- | --- | +| `[0:32]` | pubkey | `accounts[5]`, also `ix[64:96]` | +| `[32:52]` | EVM recipient | `ix[0:20]` | +| `[52:84]` | 32 bytes | `ix[20:52]` | +| `[84:92]` | amount, u64 LE | `ix[52:60]` | +| `[92:96]` | borsh string `"PRC2"` | program supplied | +| `[100:132]` | pubkey | `accounts[5]`, also `ix[64:96]` | + +Every field except the token-standard string comes straight from the instruction or the +accounts. So reconstruction is feasible, and it would make truncation a non-issue for inbound +deposits rather than merely detectable. + +It should be built as a shadow check: on every transaction where the event *is* present, +reconstruct it as well and compare. Any disagreement is logged and the logged event wins. That +proves the decoder continuously against production traffic, and the reconstructed value is only +consumed when the log is genuinely missing. + +**Blocker.** The three samples available were near identical (same accounts, same amount, one +all-zero field), which is far too narrow to pin a Borsh layout. Inferring the rest is the same +class of unverified assumption that produced the original bug. This needs the gateway IDL or +program source, which is not in this repository. That is now a small, concrete ask rather than +an open question. + +- Cost: B1 done. B2 medium, client only. +- Closes truncation: B1 detects, B2 recovers. +- Requires Solana program change: no. + +### Blocking prerequisite: the registry identifiers are wrong + +B1 is inert until the registry is corrected. Verified against devnet +(`pchaind q uregistry all-chain-configs --node https://donut.rpc.push.org`) and against 50 live +gateway transactions: + +| Method | Registry `identifier` | Actually executing on devnet | +| --- | --- | --- | +| `send_funds` | `54f7d3283f6a0f3b` = `global:send_funds` | `9113a437a5dc1761` = `global:send_universal_tx` | +| `finalize_universal_tx` | `0x` | `de5bee964bd80250` = `global:finalize_universal_tx` | +| `revert_universal_tx` | `0x` | not observed | +| `funds_rescued` | `0x` | not observed | + +The deployed instruction was renamed to `send_universal_tx`; the registry still carries the +discriminator for the old name. Nothing consumed `identifier` before, so the staleness was +invisible. Anything keyed on it silently matches nothing. + +The `event_identifier` values are all correct, confirmed by recomputing the Anchor hashes: +`event:UniversalTx`, `event:UniversalTxFinalized`, `event:RevertUniversalTx`, +`event:FundsRescued`. + +Until the registry is fixed the listener logs a warning at startup naming the methods with no +usable instruction discriminator, so the gap is visible instead of looking like coverage. + +### Separate anomaly found while verifying this + +`finalize_universal_tx` emits the `UniversalTx` event (`6c9ad829b5ea1d7c`) in addition to its +own, in 6 of the ~19 finalize transactions sampled. Example: +`2TnARo9vCAEJswQNBwLQh3yp…`, where a single `FinalizeUniversalTx` instruction emits both +discriminators from the gateway frame at depth 1. + +The client maps `6c9ad829b5ea1d7c` to `send_funds`, and the log is genuinely gateway-attributed, +so it passes every check the current code makes and is stored as an inbound. Whether that +produces a spurious inbound depends on downstream deduplication and voting, which has not been +traced. Flagged for the contract team: it is not part of F-2026-18198 and is not addressed here. + +### Why truncation cannot be prevented client side + +The log budget is per transaction and shared by every program in it, so our own log volume is +not the deciding factor. Measured across 50 devnet gateway transactions, the largest total log +payload was 2192 bytes against a roughly 10 KB budget, and none were truncated. Headroom is +about 4x today. + +It is exceeded by composition: a caller placing log-heavy instructions in the same transaction +as `send_universal_tx` pushes our event past the cut. That is outside our control, which is why +recovery (B2) is the durable answer and trimming program logs is not. + +### Option C: migrate events to `emit_cpi!` + +Anchor's `emit_cpi!` emits an event as a self-CPI, so it arrives in `meta.innerInstructions` +as a real instruction with the program ID attached as structured data. No string parsing, no +log buffer. + +- Cost: high. Solana program change plus deploy, then a client change. +- Closes forgery: yes, structurally. +- Closes truncation: yes. +- Requires Solana program change: yes. + +Worth doing if the gateway program is being revised anyway. Strictly more expensive than +option B for the same benefit, since option B needs no deploy. + +### Option D: cross-check against balance deltas + +`meta.preBalances` / `postBalances` and `preTokenBalances` / `postTokenBalances` are always +returned and never truncated. They can corroborate token and amount. + +Not sufficient alone. They do not carry recipient, payload, tx type or revert recipient, all +of which exist only in the event. Useful as a defence-in-depth check on an already-observed +event, not as a source of truth. This is part of the auditor's recommendation 6. + +## Recommendation + +1. Ship the current PR. Forgery is closed and a dropped event is now identifiable rather than + merely suspected. +2. Fix the registry `identifier` for `send_funds` to `9113a437a5dc1761` and populate the three + `0x` placeholders. Until then the detection in B1 matches nothing. This is a registry + change, not a code change, and it is the cheapest item on this list. +3. Obtain the gateway IDL or program source, then implement B2 behind a shadow check. That is + what actually recovers a truncated deposit instead of reporting it. +4. Have the contract team confirm whether `finalize_universal_tx` emitting `UniversalTx` is + intended, and whether the client should be ignoring it. +5. Treat option C as the long-term direction only if the Solana program is being revised for + other reasons. +6. Independently of all of the above, review historical Solana inbounds for forged events. The + fix prevents new ones but says nothing about whether the vector was used before it shipped. diff --git a/universalClient/chains/svm/event_listener.go b/universalClient/chains/svm/event_listener.go index 6c229258..a99f4a64 100644 --- a/universalClient/chains/svm/event_listener.go +++ b/universalClient/chains/svm/event_listener.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/hex" "fmt" + "slices" "strings" "sync" "time" @@ -22,6 +23,9 @@ import ( // re-emitted per subsequent page so ops sees a sustained signal, not a blip. const largePollWarnThreshold uint64 = 100_000 +// Anchor prefixes both instruction data and emitted events with an 8 byte discriminator. +const discriminatorSize = 8 + // rpcClientInterface is the subset of *RPCClient methods the listener depends on. // Defined as an interface so tests can supply a mock without spinning up a real // JSON-RPC server. *RPCClient satisfies it implicitly. @@ -42,6 +46,7 @@ type EventListener struct { gatewayAddress string chainID string discriminatorToEventType map[string]string + instructionToEventType map[string]string eventPollingSeconds int eventStartFrom *int64 @@ -71,21 +76,47 @@ func NewEventListener( return nil, fmt.Errorf("chain ID not configured") } - // Build discriminator to event type mapping + // Build discriminator to event type mapping. EventIdentifier tags the emitted + // log, Identifier tags the instruction that emits it; the second is what lets + // us tell a dropped event from a transaction that never emitted one. discriminatorToEventType := make(map[string]string) + instructionToEventType := make(map[string]string) for _, method := range gatewayMethods { - if method.EventIdentifier == "" { - continue - } switch method.Name { case EventTypeSendFunds, EventTypeFinalizeUniversalTx, EventTypeRevertUniversalTx, EventTypeFundsRescued: - discriminator := strings.ToLower(method.EventIdentifier) - discriminatorToEventType[discriminator] = method.Name + default: + continue + } + if d := normalizeDiscriminator(method.EventIdentifier); d != "" { + discriminatorToEventType[d] = method.Name + } + if d := normalizeDiscriminator(method.Identifier); d != "" { + instructionToEventType[d] = method.Name + } + } + + // Without an instruction discriminator a dropped event for that method is + // undetectable, so say so at startup rather than looking like it is covered. + covered := make(map[string]bool, len(instructionToEventType)) + for _, eventType := range instructionToEventType { + covered[eventType] = true + } + var uncovered []string + for _, eventType := range discriminatorToEventType { + if !covered[eventType] { + uncovered = append(uncovered, eventType) } } + if len(uncovered) > 0 { + slices.Sort(uncovered) + logger.Warn(). + Strs("methods", uncovered). + Msg("gateway methods have no instruction discriminator in the registry; " + + "a dropped event for these cannot be detected") + } return &EventListener{ rpcClient: rpcClient, @@ -94,6 +125,7 @@ func NewEventListener( gatewayAddress: gatewayAddress, chainID: chainID, discriminatorToEventType: discriminatorToEventType, + instructionToEventType: instructionToEventType, eventPollingSeconds: eventPollingSeconds, eventStartFrom: eventStartFrom, logger: logger.With().Str("component", "svm_event_listener").Str("chain", chainID).Logger(), @@ -312,6 +344,7 @@ func (el *EventListener) processSignatureBatch( Msg("solana log buffer truncated; a gateway event may have been dropped and needs manual review") } + observed := make(map[string]int) fromGateway := gatewayEmittedLogs(tx.Meta.LogMessages, el.gatewayAddress) for logIndex, log := range tx.Meta.LogMessages { if !fromGateway[logIndex] { @@ -323,6 +356,7 @@ func (el *EventListener) processSignatureBatch( if eventType == "" { continue } + observed[eventType]++ // Parse gateway event from individual log event := ParseEvent(log, sig.Signature.String(), sig.Slot, uint(logIndex), eventType, el.chainID, el.logger) @@ -345,12 +379,124 @@ func (el *EventListener) processSignatureBatch( } } } + + el.reportMissedEvents(sig, tx, observed) } } return processed, nil } +// reportMissedEvents flags gateway instructions that executed without their +// event reaching us. Instructions are part of the transaction payload rather +// than the log buffer, so counting them survives truncation and turns "some +// logs were cut" into "this event type was lost, N times, in this signature". +func (el *EventListener) reportMissedEvents( + sig *solanarpc.TransactionSignature, + tx *solanarpc.GetTransactionResult, + observed map[string]int, +) { + if len(el.instructionToEventType) == 0 { + return + } + + expected, err := el.gatewayInstructionCounts(tx) + if err != nil { + el.logger.Warn(). + Err(err). + Str("signature", sig.Signature.String()). + Msg("failed to decode transaction instructions; cannot check for dropped gateway events") + return + } + + for eventType, want := range expected { + got := observed[eventType] + if got >= want { + continue + } + el.logger.Error(). + Str("signature", sig.Signature.String()). + Uint64("slot", sig.Slot). + Str("event_type", eventType). + Int("instructions", want). + Int("events_observed", got). + Msg("gateway instruction executed but its event was not observed; " + + "the event is unrecoverable from RPC and needs manual reconciliation") + } +} + +// gatewayInstructionCounts returns, per event type, how many gateway +// instructions in tx carry that method's instruction discriminator, counting +// both top level and CPI instructions. +func (el *EventListener) gatewayInstructionCounts(tx *solanarpc.GetTransactionResult) (map[string]int, error) { + if tx == nil || tx.Transaction == nil || tx.Meta == nil { + return nil, nil + } + + decoded, err := tx.Transaction.GetTransaction() + if err != nil { + return nil, err + } + if decoded == nil { + return nil, nil + } + + // A v0 transaction resolves part of accountKeys through address lookup + // tables. The runtime appends loaded writable then loaded readonly after the + // static keys, and ProgramIDIndex indexes into that combined list. + keys := decoded.Message.AccountKeys + loaded := len(tx.Meta.LoadedAddresses.Writable) + len(tx.Meta.LoadedAddresses.ReadOnly) + if loaded > 0 { + keys = make([]solana.PublicKey, 0, len(decoded.Message.AccountKeys)+loaded) + keys = append(keys, decoded.Message.AccountKeys...) + keys = append(keys, tx.Meta.LoadedAddresses.Writable...) + keys = append(keys, tx.Meta.LoadedAddresses.ReadOnly...) + } + + counts := make(map[string]int) + // Top level and inner instructions carry the same fields under different + // named types, so tally takes the two values it needs. + tally := func(programIDIndex uint16, data []byte) { + if int(programIDIndex) >= len(keys) { + return + } + if keys[programIDIndex].String() != el.gatewayAddress { + return + } + if len(data) < discriminatorSize { + return + } + if eventType, ok := el.instructionToEventType[hex.EncodeToString(data[:discriminatorSize])]; ok { + counts[eventType]++ + } + } + + for _, ins := range decoded.Message.Instructions { + tally(ins.ProgramIDIndex, ins.Data) + } + for _, inner := range tx.Meta.InnerInstructions { + for _, ins := range inner.Instructions { + tally(ins.ProgramIDIndex, ins.Data) + } + } + + return counts, nil +} + +// normalizeDiscriminator returns the lowercase hex of an 8 byte registry +// discriminator, or "" when the entry is a placeholder or the wrong width. +func normalizeDiscriminator(identifier string) string { + s := strings.ToLower(identifier) + s = strings.TrimPrefix(s, "0x") + if len(s) != discriminatorSize*2 { + return "" + } + if _, err := hex.DecodeString(s); err != nil { + return "" + } + return s +} + // getStartSlot returns the slot to start watching from func (el *EventListener) getStartSlot(ctx context.Context) (uint64, error) { // Get chain height from store @@ -519,11 +665,11 @@ func (el *EventListener) determineEventType(log string) string { return "" } - if len(decoded) < 8 { + if len(decoded) < discriminatorSize { return "" } - discriminator := strings.ToLower(hex.EncodeToString(decoded[:8])) + discriminator := hex.EncodeToString(decoded[:discriminatorSize]) // Look up event type from discriminator map eventType, ok := el.discriminatorToEventType[discriminator] diff --git a/universalClient/chains/svm/event_listener_test.go b/universalClient/chains/svm/event_listener_test.go index 9961f6d4..6e28112c 100644 --- a/universalClient/chains/svm/event_listener_test.go +++ b/universalClient/chains/svm/event_listener_test.go @@ -5,6 +5,8 @@ import ( "context" "encoding/base64" "encoding/hex" + "encoding/json" + "fmt" "strings" "testing" "time" @@ -807,11 +809,14 @@ func TestInvokedProgramAndExit(t *testing.T) { assert.False(t, isProgramExit("Program "+testGatewayProgram+" consumed 1 of 2 compute units")) } -// forgeryRPC serves one transaction whose logs the test controls. +// forgeryRPC serves one transaction whose logs the test controls. `envelope` +// and `inner` are optional and carry the instruction side of that transaction. type forgeryRPC struct { - slot uint64 - sig solana.Signature - logs []string + slot uint64 + sig solana.Signature + logs []string + envelope *solanarpc.TransactionResultEnvelope + inner []solanarpc.InnerInstruction } func (m *forgeryRPC) GetLatestSlot(context.Context) (uint64, error) { return m.slot, nil } @@ -822,8 +827,12 @@ func (m *forgeryRPC) GetSignaturesForAddress(context.Context, solana.PublicKey, func (m *forgeryRPC) GetTransaction(context.Context, solana.Signature) (*solanarpc.GetTransactionResult, error) { return &solanarpc.GetTransactionResult{ - Slot: m.slot, - Meta: &solanarpc.TransactionMeta{LogMessages: m.logs}, + Slot: m.slot, + Transaction: m.envelope, + Meta: &solanarpc.TransactionMeta{ + LogMessages: m.logs, + InnerInstructions: m.inner, + }, }, nil } @@ -954,3 +963,233 @@ func TestProcessSignatureBatch_TruncatedLogsStillStoreVisibleEvents(t *testing.T require.NoError(t, err) assert.Len(t, events, 1, "events logged before the cut must still be stored") } + +const testSendFundsInstruction = "54f7d3283f6a0f3b" + +// buildGatewayTx encodes a transaction invoking programID once per data entry, +// in the wire format GetTransaction returns, so the listener decodes it exactly +// as it would a live one. +func buildGatewayTx(t *testing.T, programID string, data ...[]byte) *solanarpc.TransactionResultEnvelope { + t.Helper() + + program := solana.MustPublicKeyFromBase58(programID) + payer := solana.MustPublicKeyFromBase58("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM") + + ixs := make([]solana.Instruction, 0, len(data)) + for _, d := range data { + ixs = append(ixs, solana.NewInstruction(program, solana.AccountMetaSlice{ + {PublicKey: payer, IsSigner: true, IsWritable: true}, + }, d)) + } + + tx, err := solana.NewTransaction(ixs, solana.Hash{}, solana.TransactionPayer(payer)) + require.NoError(t, err) + raw, err := tx.MarshalBinary() + require.NoError(t, err) + + envelope := new(solanarpc.TransactionResultEnvelope) + require.NoError(t, json.Unmarshal( + []byte(fmt.Sprintf(`["%s","base64"]`, base64.StdEncoding.EncodeToString(raw))), + envelope, + )) + return envelope +} + +func instructionData(discriminator string, extra ...byte) []byte { + d, err := hex.DecodeString(discriminator) + if err != nil { + panic(err) + } + return append(d, extra...) +} + +// Instructions are part of the transaction payload, so unlike logs they are +// never truncated. Counting them is what tells a dropped event apart from a +// transaction that simply never emitted one. +func TestGatewayInstructionCounts(t *testing.T) { + methods := []*uregistrytypes.GatewayMethods{ + {Name: EventTypeSendFunds, EventIdentifier: "0000000000000000", Identifier: testSendFundsInstruction}, + // The other gateway methods ship a "0x" placeholder rather than a real + // discriminator, so they must not land in the map and cannot be counted. + {Name: EventTypeRevertUniversalTx, EventIdentifier: "1111111111111111", Identifier: "0x"}, + } + + newListener := func(t *testing.T) *EventListener { + t.Helper() + database, err := db.OpenInMemoryDB(true) + require.NoError(t, err) + t.Cleanup(func() { database.Close() }) + el, err := NewEventListener(&forgeryRPC{}, testGatewayProgram, "solana:test", methods, database, 10, nil, zerolog.Nop()) + require.NoError(t, err) + return el + } + + t.Run("placeholder identifiers are not registered", func(t *testing.T) { + el := newListener(t) + assert.Equal(t, map[string]string{testSendFundsInstruction: EventTypeSendFunds}, el.instructionToEventType) + }) + + t.Run("counts every gateway send_funds instruction", func(t *testing.T) { + el := newListener(t) + counts, err := el.gatewayInstructionCounts(&solanarpc.GetTransactionResult{ + Transaction: buildGatewayTx(t, testGatewayProgram, + instructionData(testSendFundsInstruction, 1, 2, 3), + instructionData(testSendFundsInstruction, 4, 5, 6), + ), + Meta: &solanarpc.TransactionMeta{}, + }) + require.NoError(t, err) + assert.Equal(t, map[string]int{EventTypeSendFunds: 2}, counts) + }) + + t.Run("counts CPI instructions", func(t *testing.T) { + el := newListener(t) + gateway := solana.MustPublicKeyFromBase58(testGatewayProgram) + envelope := buildGatewayTx(t, testGatewayProgram, instructionData(testSendFundsInstruction)) + + decoded, err := envelope.GetTransaction() + require.NoError(t, err) + gatewayIndex := -1 + for i, k := range decoded.Message.AccountKeys { + if k.Equals(gateway) { + gatewayIndex = i + } + } + require.NotEqual(t, -1, gatewayIndex) + + counts, err := el.gatewayInstructionCounts(&solanarpc.GetTransactionResult{ + Transaction: envelope, + Meta: &solanarpc.TransactionMeta{InnerInstructions: []solanarpc.InnerInstruction{{ + Index: 0, + Instructions: []solanarpc.CompiledInstruction{{ + ProgramIDIndex: uint16(gatewayIndex), + Data: instructionData(testSendFundsInstruction), + }}, + }}}, + }) + require.NoError(t, err) + assert.Equal(t, map[string]int{EventTypeSendFunds: 2}, counts, "top level plus CPI") + }) + + t.Run("ignores the same discriminator from another program", func(t *testing.T) { + el := newListener(t) + counts, err := el.gatewayInstructionCounts(&solanarpc.GetTransactionResult{ + Transaction: buildGatewayTx(t, testAttackerProgram, instructionData(testSendFundsInstruction)), + Meta: &solanarpc.TransactionMeta{}, + }) + require.NoError(t, err) + assert.Empty(t, counts) + }) + + t.Run("ignores unknown and undersized instruction data", func(t *testing.T) { + el := newListener(t) + counts, err := el.gatewayInstructionCounts(&solanarpc.GetTransactionResult{ + Transaction: buildGatewayTx(t, testGatewayProgram, + instructionData("aaaaaaaaaaaaaaaa"), + []byte{1, 2, 3}, + ), + Meta: &solanarpc.TransactionMeta{}, + }) + require.NoError(t, err) + assert.Empty(t, counts) + }) +} + +// Detection only works for methods whose instruction discriminator is in the +// registry. Devnet currently ships "0x" for three of the four, and the +// send_funds identifier does not match the deployed instruction, so the gap has +// to be visible at startup instead of looking like coverage. +func TestNewEventListener_WarnsOnMissingInstructionDiscriminators(t *testing.T) { + database, err := db.OpenInMemoryDB(true) + require.NoError(t, err) + defer database.Close() + + // Verbatim from `pchaind q uregistry all-chain-configs` for solana devnet. + methods := []*uregistrytypes.GatewayMethods{ + {Name: EventTypeSendFunds, Identifier: "54f7d3283f6a0f3b", EventIdentifier: "6c9ad829b5ea1d7c"}, + {Name: EventTypeFinalizeUniversalTx, Identifier: "0x", EventIdentifier: "b3409670758c9c25"}, + {Name: EventTypeRevertUniversalTx, Identifier: "0x", EventIdentifier: "f94a27cb953630ba"}, + {Name: EventTypeFundsRescued, Identifier: "0x", EventIdentifier: "9f25065d627ab0d2"}, + } + + var logBuf bytes.Buffer + _, err = NewEventListener(&forgeryRPC{}, testGatewayProgram, "solana:test", methods, database, 10, nil, zerolog.New(&logBuf)) + require.NoError(t, err) + + output := logBuf.String() + assert.Contains(t, output, "have no instruction discriminator in the registry") + for _, name := range []string{EventTypeFinalizeUniversalTx, EventTypeRevertUniversalTx, EventTypeFundsRescued} { + assert.Contains(t, output, name) + } +} + +// The truncation marker only says something was cut. Comparing instructions to +// observed events says what was lost, so a stranded deposit is actionable. +func TestProcessSignatureBatch_ReportsEventDroppedByTruncation(t *testing.T) { + payload := buildSendFundsPayload( + [32]byte{1}, [20]byte{2}, [32]byte{3}, 1_000_000, + nil, [32]byte{4}, 0, nil, false, + ) + methods := []*uregistrytypes.GatewayMethods{{ + Name: EventTypeSendFunds, + EventIdentifier: "0000000000000000", // buildSendFundsPayload zeroes the discriminator + Identifier: testSendFundsInstruction, + }} + + run := func(t *testing.T, logs []string) string { + t.Helper() + database, err := db.OpenInMemoryDB(true) + require.NoError(t, err) + t.Cleanup(func() { database.Close() }) + + var logBuf bytes.Buffer + rpc := &forgeryRPC{ + slot: 100, + sig: mkSig(11), + logs: logs, + envelope: buildGatewayTx(t, testGatewayProgram, instructionData(testSendFundsInstruction)), + } + el, err := NewEventListener(rpc, testGatewayProgram, "solana:test", methods, database, 10, nil, zerolog.New(&logBuf)) + require.NoError(t, err) + + _, err = el.processSignatureBatch(context.Background(), []*solanarpc.TransactionSignature{ + {Signature: mkSig(11), Slot: 100}, + }, 0, 200) + require.NoError(t, err) + return logBuf.String() + } + + t.Run("event cut by truncation is reported", func(t *testing.T) { + output := run(t, []string{ + "Program " + testGatewayProgram + " invoke [1]", + "Program log: Instruction: SendFunds", + "Log truncated", + }) + assert.Contains(t, output, "gateway instruction executed but its event was not observed") + assert.Contains(t, output, EventTypeSendFunds) + assert.Contains(t, output, mkSig(11).String(), "the signature must be actionable") + }) + + t.Run("event that arrived is not reported", func(t *testing.T) { + output := run(t, []string{ + "Program " + testGatewayProgram + " invoke [1]", + "Program data: " + base64.StdEncoding.EncodeToString(payload), + "Program " + testGatewayProgram + " success", + }) + assert.NotContains(t, output, "gateway instruction executed but its event was not observed") + }) + + // A forged event does not satisfy the instruction that really ran, so + // suppressing the alert is not something an attacker can do. + t.Run("forged event does not mask the loss", func(t *testing.T) { + output := run(t, []string{ + "Program " + testGatewayProgram + " invoke [1]", + "Program log: Instruction: SendFunds", + "Program " + testAttackerProgram + " invoke [2]", + "Program data: " + base64.StdEncoding.EncodeToString(payload), + "Program " + testAttackerProgram + " success", + "Log truncated", + }) + assert.Contains(t, output, "gateway instruction executed but its event was not observed") + }) +} From 4ae60823c17b1a9b8c960864a614be63a74838c6 Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 19 Aug 2026 16:36:55 +0530 Subject: [PATCH 5/7] chore: drop svm event observation doc from branch --- docs/svm-event-observation.md | 232 ---------------------------------- 1 file changed, 232 deletions(-) delete mode 100644 docs/svm-event-observation.md diff --git a/docs/svm-event-observation.md b/docs/svm-event-observation.md deleted file mode 100644 index 860f5a8c..00000000 --- a/docs/svm-event-observation.md +++ /dev/null @@ -1,232 +0,0 @@ -# SVM event observation: current state and options - -Context for F-2026-18198 (forged gateway events) and the log truncation gap found alongside it. -Covers how Solana event observation differs from EVM, what we shipped, and what the remaining -options are. - -## Why Solana is different from EVM - -**EVM.** One filtered call does everything: - -```go -query := ethereum.FilterQuery{Addresses: []common.Address{gateway, vault}, Topics: ...} -logs, _ := rpcClient.FilterLogs(ctx, query) -``` - -A log's `address` field is set by the EVM itself when the contract executes `LOG0`-`LOG4`. -A contract cannot emit a log attributed to another address, so attribution is a protocol -guarantee and we get it for free. `topic[0]` identifies the event. - -**Solana.** There is no event system. What Anchor calls an event is `sol_log_data(bytes)`, -which appends a base64 string to a flat `meta.logMessages` array: - -``` -Program invoke [1] -Program log: Instruction: SendFunds -Program data: <- the "event" -Program success -``` - -Two consequences: - -- No per-log program field. Nothing in that line says who emitted it. The only signal is - which `invoke` frame was open at the time. -- No "get logs by program" RPC. `getSignaturesForAddress` returns transactions that merely - reference an address in `accountKeys`. Referenced, not invoked, not even writable. - -So on Solana attribution must be reconstructed. That reconstruction was missing, which is -what F-2026-18198 exploited. - -## What was wrong - -We applied the EVM mental model: - -1. `getSignaturesForAddress(gateway)` to select candidate transactions. -2. Treat every `Program data:` line whose 8-byte discriminator matched as a gateway event. - -Both steps were unsound. Referencing the gateway proves nothing, and a discriminator is -`sha256("event:SendFunds")[:8]`, a public schema tag rather than an authenticator. Any -program could emit a well-formed `send_funds` event with any amount and recipient. Every -honest UV would read the same successful transaction and vote identically, minting PRC20 -with no deposit behind it. No validator compromise required. - -## What we shipped (PR #308) - -**1. Invocation-stack attribution.** `gatewayEmittedLogs` walks the runtime `invoke` and -`success` / `failed:` lines as a stack and accepts a `Program data:` line only while the -gateway is the executing frame. - -Sound because programs cannot forge those lines: `sol_log` always produces `Program log: ` -and `sol_log_data` always produces `Program data: `, so bare `Program invoke [n]` lines -are runtime generated. Tested, not assumed. - -A first attempt had its own hole: `isProgramExit("Program log: success")` returned true, so a -program logging `msg!("success")` could pop a frame it did not own. If the gateway CPIs into -another program, that callee could pop its own frame and have its next data log attributed to -the gateway. Fixed by requiring the token to parse as a base58 pubkey. - -**2. Truncation detection.** `logsTruncated` flags a dropped log buffer and the listener logs -at error level with signature and slot. Visible logs are still processed, since events before -the cut are genuine. - -**3. Dropped-event identification.** `gatewayInstructionCounts` counts the gateway instructions -that executed and compares them against the events observed, so a shortfall names the event type -and count rather than just reporting that the buffer was cut. Option B1 below. - -This is identification, not recovery. See below. - -## The remaining gap: log truncation - -Solana caps the log buffer per transaction. On overflow the runtime drops the remainder and -appends a truncation marker. Because gateway events are emitted with `sol_log_data`, an -overflowing transaction can lose the event line entirely. - -Impact is liveness, not forgery: a real deposit is never observed, and the user's funds sit on -Solana until someone reconciles manually. No RPC call recovers a truncated log. - -Rarity, measured: 7116 mainnet transactions scanned across 5 blocks, zero truncated. Rare, -but the consequence per occurrence is a stuck deposit. - -## Options for closing it - -### Option A: detection only (shipped) - -Flag truncation at error level so a missed deposit is alertable instead of silent. - -- Cost: done. -- Closes forgery: not applicable, separate fix. -- Closes truncation: no. Converts silent loss into detected loss. -- Requires Solana program change: no. - -### Option B: use the instruction, not the event log (recommended) - -Instructions live in `transaction.message.instructions` and `meta.innerInstructions`. Neither -is part of the log buffer, so neither is ever truncated. This splits into two steps of very -different cost. - -**B1: detect a dropped event (shipped in this PR).** Count the gateway instructions carrying a -known instruction discriminator, compare against the events actually observed, and report a -shortfall. This needs only the discriminator, not the argument layout. It converts "the buffer -was cut somewhere" into "this event type was lost, N times, in this signature". - -Only the shortfall direction is checked. `finalize_universal_tx` emits more than one event per -instruction (see below), so requiring equality would false-positive. - -**B2: reconstruct the event (needs the IDL).** The event is a deterministic function of the -instruction arguments and the account list, so it can be rebuilt when the log is gone. This is -verified, not assumed. Field alignment from a live devnet transaction -(`3FuFyisoxYwvwsbXhEX9Z6kg…`), 108 argument bytes against a 138 byte event body: - -| Event offset | Content | Source | -| --- | --- | --- | -| `[0:32]` | pubkey | `accounts[5]`, also `ix[64:96]` | -| `[32:52]` | EVM recipient | `ix[0:20]` | -| `[52:84]` | 32 bytes | `ix[20:52]` | -| `[84:92]` | amount, u64 LE | `ix[52:60]` | -| `[92:96]` | borsh string `"PRC2"` | program supplied | -| `[100:132]` | pubkey | `accounts[5]`, also `ix[64:96]` | - -Every field except the token-standard string comes straight from the instruction or the -accounts. So reconstruction is feasible, and it would make truncation a non-issue for inbound -deposits rather than merely detectable. - -It should be built as a shadow check: on every transaction where the event *is* present, -reconstruct it as well and compare. Any disagreement is logged and the logged event wins. That -proves the decoder continuously against production traffic, and the reconstructed value is only -consumed when the log is genuinely missing. - -**Blocker.** The three samples available were near identical (same accounts, same amount, one -all-zero field), which is far too narrow to pin a Borsh layout. Inferring the rest is the same -class of unverified assumption that produced the original bug. This needs the gateway IDL or -program source, which is not in this repository. That is now a small, concrete ask rather than -an open question. - -- Cost: B1 done. B2 medium, client only. -- Closes truncation: B1 detects, B2 recovers. -- Requires Solana program change: no. - -### Blocking prerequisite: the registry identifiers are wrong - -B1 is inert until the registry is corrected. Verified against devnet -(`pchaind q uregistry all-chain-configs --node https://donut.rpc.push.org`) and against 50 live -gateway transactions: - -| Method | Registry `identifier` | Actually executing on devnet | -| --- | --- | --- | -| `send_funds` | `54f7d3283f6a0f3b` = `global:send_funds` | `9113a437a5dc1761` = `global:send_universal_tx` | -| `finalize_universal_tx` | `0x` | `de5bee964bd80250` = `global:finalize_universal_tx` | -| `revert_universal_tx` | `0x` | not observed | -| `funds_rescued` | `0x` | not observed | - -The deployed instruction was renamed to `send_universal_tx`; the registry still carries the -discriminator for the old name. Nothing consumed `identifier` before, so the staleness was -invisible. Anything keyed on it silently matches nothing. - -The `event_identifier` values are all correct, confirmed by recomputing the Anchor hashes: -`event:UniversalTx`, `event:UniversalTxFinalized`, `event:RevertUniversalTx`, -`event:FundsRescued`. - -Until the registry is fixed the listener logs a warning at startup naming the methods with no -usable instruction discriminator, so the gap is visible instead of looking like coverage. - -### Separate anomaly found while verifying this - -`finalize_universal_tx` emits the `UniversalTx` event (`6c9ad829b5ea1d7c`) in addition to its -own, in 6 of the ~19 finalize transactions sampled. Example: -`2TnARo9vCAEJswQNBwLQh3yp…`, where a single `FinalizeUniversalTx` instruction emits both -discriminators from the gateway frame at depth 1. - -The client maps `6c9ad829b5ea1d7c` to `send_funds`, and the log is genuinely gateway-attributed, -so it passes every check the current code makes and is stored as an inbound. Whether that -produces a spurious inbound depends on downstream deduplication and voting, which has not been -traced. Flagged for the contract team: it is not part of F-2026-18198 and is not addressed here. - -### Why truncation cannot be prevented client side - -The log budget is per transaction and shared by every program in it, so our own log volume is -not the deciding factor. Measured across 50 devnet gateway transactions, the largest total log -payload was 2192 bytes against a roughly 10 KB budget, and none were truncated. Headroom is -about 4x today. - -It is exceeded by composition: a caller placing log-heavy instructions in the same transaction -as `send_universal_tx` pushes our event past the cut. That is outside our control, which is why -recovery (B2) is the durable answer and trimming program logs is not. - -### Option C: migrate events to `emit_cpi!` - -Anchor's `emit_cpi!` emits an event as a self-CPI, so it arrives in `meta.innerInstructions` -as a real instruction with the program ID attached as structured data. No string parsing, no -log buffer. - -- Cost: high. Solana program change plus deploy, then a client change. -- Closes forgery: yes, structurally. -- Closes truncation: yes. -- Requires Solana program change: yes. - -Worth doing if the gateway program is being revised anyway. Strictly more expensive than -option B for the same benefit, since option B needs no deploy. - -### Option D: cross-check against balance deltas - -`meta.preBalances` / `postBalances` and `preTokenBalances` / `postTokenBalances` are always -returned and never truncated. They can corroborate token and amount. - -Not sufficient alone. They do not carry recipient, payload, tx type or revert recipient, all -of which exist only in the event. Useful as a defence-in-depth check on an already-observed -event, not as a source of truth. This is part of the auditor's recommendation 6. - -## Recommendation - -1. Ship the current PR. Forgery is closed and a dropped event is now identifiable rather than - merely suspected. -2. Fix the registry `identifier` for `send_funds` to `9113a437a5dc1761` and populate the three - `0x` placeholders. Until then the detection in B1 matches nothing. This is a registry - change, not a code change, and it is the cheapest item on this list. -3. Obtain the gateway IDL or program source, then implement B2 behind a shadow check. That is - what actually recovers a truncated deposit instead of reporting it. -4. Have the contract team confirm whether `finalize_universal_tx` emitting `UniversalTx` is - intended, and whether the client should be ignoring it. -5. Treat option C as the long-term direction only if the Solana program is being revised for - other reasons. -6. Independently of all of the above, review historical Solana inbounds for forged events. The - fix prevents new ones but says nothing about whether the vector was used before it shipped. From 754cdb1bcbd572c33865f32813a0af117be73c64 Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 19 Aug 2026 16:52:16 +0530 Subject: [PATCH 6/7] revert: drop instruction discriminator reads from svm event listener --- universalClient/chains/svm/event_listener.go | 162 +---------- .../chains/svm/event_listener_test.go | 251 +----------------- 2 files changed, 14 insertions(+), 399 deletions(-) diff --git a/universalClient/chains/svm/event_listener.go b/universalClient/chains/svm/event_listener.go index a99f4a64..6c229258 100644 --- a/universalClient/chains/svm/event_listener.go +++ b/universalClient/chains/svm/event_listener.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "encoding/hex" "fmt" - "slices" "strings" "sync" "time" @@ -23,9 +22,6 @@ import ( // re-emitted per subsequent page so ops sees a sustained signal, not a blip. const largePollWarnThreshold uint64 = 100_000 -// Anchor prefixes both instruction data and emitted events with an 8 byte discriminator. -const discriminatorSize = 8 - // rpcClientInterface is the subset of *RPCClient methods the listener depends on. // Defined as an interface so tests can supply a mock without spinning up a real // JSON-RPC server. *RPCClient satisfies it implicitly. @@ -46,7 +42,6 @@ type EventListener struct { gatewayAddress string chainID string discriminatorToEventType map[string]string - instructionToEventType map[string]string eventPollingSeconds int eventStartFrom *int64 @@ -76,47 +71,21 @@ func NewEventListener( return nil, fmt.Errorf("chain ID not configured") } - // Build discriminator to event type mapping. EventIdentifier tags the emitted - // log, Identifier tags the instruction that emits it; the second is what lets - // us tell a dropped event from a transaction that never emitted one. + // Build discriminator to event type mapping discriminatorToEventType := make(map[string]string) - instructionToEventType := make(map[string]string) for _, method := range gatewayMethods { + if method.EventIdentifier == "" { + continue + } switch method.Name { case EventTypeSendFunds, EventTypeFinalizeUniversalTx, EventTypeRevertUniversalTx, EventTypeFundsRescued: - default: - continue - } - if d := normalizeDiscriminator(method.EventIdentifier); d != "" { - discriminatorToEventType[d] = method.Name - } - if d := normalizeDiscriminator(method.Identifier); d != "" { - instructionToEventType[d] = method.Name - } - } - - // Without an instruction discriminator a dropped event for that method is - // undetectable, so say so at startup rather than looking like it is covered. - covered := make(map[string]bool, len(instructionToEventType)) - for _, eventType := range instructionToEventType { - covered[eventType] = true - } - var uncovered []string - for _, eventType := range discriminatorToEventType { - if !covered[eventType] { - uncovered = append(uncovered, eventType) + discriminator := strings.ToLower(method.EventIdentifier) + discriminatorToEventType[discriminator] = method.Name } } - if len(uncovered) > 0 { - slices.Sort(uncovered) - logger.Warn(). - Strs("methods", uncovered). - Msg("gateway methods have no instruction discriminator in the registry; " + - "a dropped event for these cannot be detected") - } return &EventListener{ rpcClient: rpcClient, @@ -125,7 +94,6 @@ func NewEventListener( gatewayAddress: gatewayAddress, chainID: chainID, discriminatorToEventType: discriminatorToEventType, - instructionToEventType: instructionToEventType, eventPollingSeconds: eventPollingSeconds, eventStartFrom: eventStartFrom, logger: logger.With().Str("component", "svm_event_listener").Str("chain", chainID).Logger(), @@ -344,7 +312,6 @@ func (el *EventListener) processSignatureBatch( Msg("solana log buffer truncated; a gateway event may have been dropped and needs manual review") } - observed := make(map[string]int) fromGateway := gatewayEmittedLogs(tx.Meta.LogMessages, el.gatewayAddress) for logIndex, log := range tx.Meta.LogMessages { if !fromGateway[logIndex] { @@ -356,7 +323,6 @@ func (el *EventListener) processSignatureBatch( if eventType == "" { continue } - observed[eventType]++ // Parse gateway event from individual log event := ParseEvent(log, sig.Signature.String(), sig.Slot, uint(logIndex), eventType, el.chainID, el.logger) @@ -379,124 +345,12 @@ func (el *EventListener) processSignatureBatch( } } } - - el.reportMissedEvents(sig, tx, observed) } } return processed, nil } -// reportMissedEvents flags gateway instructions that executed without their -// event reaching us. Instructions are part of the transaction payload rather -// than the log buffer, so counting them survives truncation and turns "some -// logs were cut" into "this event type was lost, N times, in this signature". -func (el *EventListener) reportMissedEvents( - sig *solanarpc.TransactionSignature, - tx *solanarpc.GetTransactionResult, - observed map[string]int, -) { - if len(el.instructionToEventType) == 0 { - return - } - - expected, err := el.gatewayInstructionCounts(tx) - if err != nil { - el.logger.Warn(). - Err(err). - Str("signature", sig.Signature.String()). - Msg("failed to decode transaction instructions; cannot check for dropped gateway events") - return - } - - for eventType, want := range expected { - got := observed[eventType] - if got >= want { - continue - } - el.logger.Error(). - Str("signature", sig.Signature.String()). - Uint64("slot", sig.Slot). - Str("event_type", eventType). - Int("instructions", want). - Int("events_observed", got). - Msg("gateway instruction executed but its event was not observed; " + - "the event is unrecoverable from RPC and needs manual reconciliation") - } -} - -// gatewayInstructionCounts returns, per event type, how many gateway -// instructions in tx carry that method's instruction discriminator, counting -// both top level and CPI instructions. -func (el *EventListener) gatewayInstructionCounts(tx *solanarpc.GetTransactionResult) (map[string]int, error) { - if tx == nil || tx.Transaction == nil || tx.Meta == nil { - return nil, nil - } - - decoded, err := tx.Transaction.GetTransaction() - if err != nil { - return nil, err - } - if decoded == nil { - return nil, nil - } - - // A v0 transaction resolves part of accountKeys through address lookup - // tables. The runtime appends loaded writable then loaded readonly after the - // static keys, and ProgramIDIndex indexes into that combined list. - keys := decoded.Message.AccountKeys - loaded := len(tx.Meta.LoadedAddresses.Writable) + len(tx.Meta.LoadedAddresses.ReadOnly) - if loaded > 0 { - keys = make([]solana.PublicKey, 0, len(decoded.Message.AccountKeys)+loaded) - keys = append(keys, decoded.Message.AccountKeys...) - keys = append(keys, tx.Meta.LoadedAddresses.Writable...) - keys = append(keys, tx.Meta.LoadedAddresses.ReadOnly...) - } - - counts := make(map[string]int) - // Top level and inner instructions carry the same fields under different - // named types, so tally takes the two values it needs. - tally := func(programIDIndex uint16, data []byte) { - if int(programIDIndex) >= len(keys) { - return - } - if keys[programIDIndex].String() != el.gatewayAddress { - return - } - if len(data) < discriminatorSize { - return - } - if eventType, ok := el.instructionToEventType[hex.EncodeToString(data[:discriminatorSize])]; ok { - counts[eventType]++ - } - } - - for _, ins := range decoded.Message.Instructions { - tally(ins.ProgramIDIndex, ins.Data) - } - for _, inner := range tx.Meta.InnerInstructions { - for _, ins := range inner.Instructions { - tally(ins.ProgramIDIndex, ins.Data) - } - } - - return counts, nil -} - -// normalizeDiscriminator returns the lowercase hex of an 8 byte registry -// discriminator, or "" when the entry is a placeholder or the wrong width. -func normalizeDiscriminator(identifier string) string { - s := strings.ToLower(identifier) - s = strings.TrimPrefix(s, "0x") - if len(s) != discriminatorSize*2 { - return "" - } - if _, err := hex.DecodeString(s); err != nil { - return "" - } - return s -} - // getStartSlot returns the slot to start watching from func (el *EventListener) getStartSlot(ctx context.Context) (uint64, error) { // Get chain height from store @@ -665,11 +519,11 @@ func (el *EventListener) determineEventType(log string) string { return "" } - if len(decoded) < discriminatorSize { + if len(decoded) < 8 { return "" } - discriminator := hex.EncodeToString(decoded[:discriminatorSize]) + discriminator := strings.ToLower(hex.EncodeToString(decoded[:8])) // Look up event type from discriminator map eventType, ok := el.discriminatorToEventType[discriminator] diff --git a/universalClient/chains/svm/event_listener_test.go b/universalClient/chains/svm/event_listener_test.go index 6e28112c..9961f6d4 100644 --- a/universalClient/chains/svm/event_listener_test.go +++ b/universalClient/chains/svm/event_listener_test.go @@ -5,8 +5,6 @@ import ( "context" "encoding/base64" "encoding/hex" - "encoding/json" - "fmt" "strings" "testing" "time" @@ -809,14 +807,11 @@ func TestInvokedProgramAndExit(t *testing.T) { assert.False(t, isProgramExit("Program "+testGatewayProgram+" consumed 1 of 2 compute units")) } -// forgeryRPC serves one transaction whose logs the test controls. `envelope` -// and `inner` are optional and carry the instruction side of that transaction. +// forgeryRPC serves one transaction whose logs the test controls. type forgeryRPC struct { - slot uint64 - sig solana.Signature - logs []string - envelope *solanarpc.TransactionResultEnvelope - inner []solanarpc.InnerInstruction + slot uint64 + sig solana.Signature + logs []string } func (m *forgeryRPC) GetLatestSlot(context.Context) (uint64, error) { return m.slot, nil } @@ -827,12 +822,8 @@ func (m *forgeryRPC) GetSignaturesForAddress(context.Context, solana.PublicKey, func (m *forgeryRPC) GetTransaction(context.Context, solana.Signature) (*solanarpc.GetTransactionResult, error) { return &solanarpc.GetTransactionResult{ - Slot: m.slot, - Transaction: m.envelope, - Meta: &solanarpc.TransactionMeta{ - LogMessages: m.logs, - InnerInstructions: m.inner, - }, + Slot: m.slot, + Meta: &solanarpc.TransactionMeta{LogMessages: m.logs}, }, nil } @@ -963,233 +954,3 @@ func TestProcessSignatureBatch_TruncatedLogsStillStoreVisibleEvents(t *testing.T require.NoError(t, err) assert.Len(t, events, 1, "events logged before the cut must still be stored") } - -const testSendFundsInstruction = "54f7d3283f6a0f3b" - -// buildGatewayTx encodes a transaction invoking programID once per data entry, -// in the wire format GetTransaction returns, so the listener decodes it exactly -// as it would a live one. -func buildGatewayTx(t *testing.T, programID string, data ...[]byte) *solanarpc.TransactionResultEnvelope { - t.Helper() - - program := solana.MustPublicKeyFromBase58(programID) - payer := solana.MustPublicKeyFromBase58("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM") - - ixs := make([]solana.Instruction, 0, len(data)) - for _, d := range data { - ixs = append(ixs, solana.NewInstruction(program, solana.AccountMetaSlice{ - {PublicKey: payer, IsSigner: true, IsWritable: true}, - }, d)) - } - - tx, err := solana.NewTransaction(ixs, solana.Hash{}, solana.TransactionPayer(payer)) - require.NoError(t, err) - raw, err := tx.MarshalBinary() - require.NoError(t, err) - - envelope := new(solanarpc.TransactionResultEnvelope) - require.NoError(t, json.Unmarshal( - []byte(fmt.Sprintf(`["%s","base64"]`, base64.StdEncoding.EncodeToString(raw))), - envelope, - )) - return envelope -} - -func instructionData(discriminator string, extra ...byte) []byte { - d, err := hex.DecodeString(discriminator) - if err != nil { - panic(err) - } - return append(d, extra...) -} - -// Instructions are part of the transaction payload, so unlike logs they are -// never truncated. Counting them is what tells a dropped event apart from a -// transaction that simply never emitted one. -func TestGatewayInstructionCounts(t *testing.T) { - methods := []*uregistrytypes.GatewayMethods{ - {Name: EventTypeSendFunds, EventIdentifier: "0000000000000000", Identifier: testSendFundsInstruction}, - // The other gateway methods ship a "0x" placeholder rather than a real - // discriminator, so they must not land in the map and cannot be counted. - {Name: EventTypeRevertUniversalTx, EventIdentifier: "1111111111111111", Identifier: "0x"}, - } - - newListener := func(t *testing.T) *EventListener { - t.Helper() - database, err := db.OpenInMemoryDB(true) - require.NoError(t, err) - t.Cleanup(func() { database.Close() }) - el, err := NewEventListener(&forgeryRPC{}, testGatewayProgram, "solana:test", methods, database, 10, nil, zerolog.Nop()) - require.NoError(t, err) - return el - } - - t.Run("placeholder identifiers are not registered", func(t *testing.T) { - el := newListener(t) - assert.Equal(t, map[string]string{testSendFundsInstruction: EventTypeSendFunds}, el.instructionToEventType) - }) - - t.Run("counts every gateway send_funds instruction", func(t *testing.T) { - el := newListener(t) - counts, err := el.gatewayInstructionCounts(&solanarpc.GetTransactionResult{ - Transaction: buildGatewayTx(t, testGatewayProgram, - instructionData(testSendFundsInstruction, 1, 2, 3), - instructionData(testSendFundsInstruction, 4, 5, 6), - ), - Meta: &solanarpc.TransactionMeta{}, - }) - require.NoError(t, err) - assert.Equal(t, map[string]int{EventTypeSendFunds: 2}, counts) - }) - - t.Run("counts CPI instructions", func(t *testing.T) { - el := newListener(t) - gateway := solana.MustPublicKeyFromBase58(testGatewayProgram) - envelope := buildGatewayTx(t, testGatewayProgram, instructionData(testSendFundsInstruction)) - - decoded, err := envelope.GetTransaction() - require.NoError(t, err) - gatewayIndex := -1 - for i, k := range decoded.Message.AccountKeys { - if k.Equals(gateway) { - gatewayIndex = i - } - } - require.NotEqual(t, -1, gatewayIndex) - - counts, err := el.gatewayInstructionCounts(&solanarpc.GetTransactionResult{ - Transaction: envelope, - Meta: &solanarpc.TransactionMeta{InnerInstructions: []solanarpc.InnerInstruction{{ - Index: 0, - Instructions: []solanarpc.CompiledInstruction{{ - ProgramIDIndex: uint16(gatewayIndex), - Data: instructionData(testSendFundsInstruction), - }}, - }}}, - }) - require.NoError(t, err) - assert.Equal(t, map[string]int{EventTypeSendFunds: 2}, counts, "top level plus CPI") - }) - - t.Run("ignores the same discriminator from another program", func(t *testing.T) { - el := newListener(t) - counts, err := el.gatewayInstructionCounts(&solanarpc.GetTransactionResult{ - Transaction: buildGatewayTx(t, testAttackerProgram, instructionData(testSendFundsInstruction)), - Meta: &solanarpc.TransactionMeta{}, - }) - require.NoError(t, err) - assert.Empty(t, counts) - }) - - t.Run("ignores unknown and undersized instruction data", func(t *testing.T) { - el := newListener(t) - counts, err := el.gatewayInstructionCounts(&solanarpc.GetTransactionResult{ - Transaction: buildGatewayTx(t, testGatewayProgram, - instructionData("aaaaaaaaaaaaaaaa"), - []byte{1, 2, 3}, - ), - Meta: &solanarpc.TransactionMeta{}, - }) - require.NoError(t, err) - assert.Empty(t, counts) - }) -} - -// Detection only works for methods whose instruction discriminator is in the -// registry. Devnet currently ships "0x" for three of the four, and the -// send_funds identifier does not match the deployed instruction, so the gap has -// to be visible at startup instead of looking like coverage. -func TestNewEventListener_WarnsOnMissingInstructionDiscriminators(t *testing.T) { - database, err := db.OpenInMemoryDB(true) - require.NoError(t, err) - defer database.Close() - - // Verbatim from `pchaind q uregistry all-chain-configs` for solana devnet. - methods := []*uregistrytypes.GatewayMethods{ - {Name: EventTypeSendFunds, Identifier: "54f7d3283f6a0f3b", EventIdentifier: "6c9ad829b5ea1d7c"}, - {Name: EventTypeFinalizeUniversalTx, Identifier: "0x", EventIdentifier: "b3409670758c9c25"}, - {Name: EventTypeRevertUniversalTx, Identifier: "0x", EventIdentifier: "f94a27cb953630ba"}, - {Name: EventTypeFundsRescued, Identifier: "0x", EventIdentifier: "9f25065d627ab0d2"}, - } - - var logBuf bytes.Buffer - _, err = NewEventListener(&forgeryRPC{}, testGatewayProgram, "solana:test", methods, database, 10, nil, zerolog.New(&logBuf)) - require.NoError(t, err) - - output := logBuf.String() - assert.Contains(t, output, "have no instruction discriminator in the registry") - for _, name := range []string{EventTypeFinalizeUniversalTx, EventTypeRevertUniversalTx, EventTypeFundsRescued} { - assert.Contains(t, output, name) - } -} - -// The truncation marker only says something was cut. Comparing instructions to -// observed events says what was lost, so a stranded deposit is actionable. -func TestProcessSignatureBatch_ReportsEventDroppedByTruncation(t *testing.T) { - payload := buildSendFundsPayload( - [32]byte{1}, [20]byte{2}, [32]byte{3}, 1_000_000, - nil, [32]byte{4}, 0, nil, false, - ) - methods := []*uregistrytypes.GatewayMethods{{ - Name: EventTypeSendFunds, - EventIdentifier: "0000000000000000", // buildSendFundsPayload zeroes the discriminator - Identifier: testSendFundsInstruction, - }} - - run := func(t *testing.T, logs []string) string { - t.Helper() - database, err := db.OpenInMemoryDB(true) - require.NoError(t, err) - t.Cleanup(func() { database.Close() }) - - var logBuf bytes.Buffer - rpc := &forgeryRPC{ - slot: 100, - sig: mkSig(11), - logs: logs, - envelope: buildGatewayTx(t, testGatewayProgram, instructionData(testSendFundsInstruction)), - } - el, err := NewEventListener(rpc, testGatewayProgram, "solana:test", methods, database, 10, nil, zerolog.New(&logBuf)) - require.NoError(t, err) - - _, err = el.processSignatureBatch(context.Background(), []*solanarpc.TransactionSignature{ - {Signature: mkSig(11), Slot: 100}, - }, 0, 200) - require.NoError(t, err) - return logBuf.String() - } - - t.Run("event cut by truncation is reported", func(t *testing.T) { - output := run(t, []string{ - "Program " + testGatewayProgram + " invoke [1]", - "Program log: Instruction: SendFunds", - "Log truncated", - }) - assert.Contains(t, output, "gateway instruction executed but its event was not observed") - assert.Contains(t, output, EventTypeSendFunds) - assert.Contains(t, output, mkSig(11).String(), "the signature must be actionable") - }) - - t.Run("event that arrived is not reported", func(t *testing.T) { - output := run(t, []string{ - "Program " + testGatewayProgram + " invoke [1]", - "Program data: " + base64.StdEncoding.EncodeToString(payload), - "Program " + testGatewayProgram + " success", - }) - assert.NotContains(t, output, "gateway instruction executed but its event was not observed") - }) - - // A forged event does not satisfy the instruction that really ran, so - // suppressing the alert is not something an attacker can do. - t.Run("forged event does not mask the loss", func(t *testing.T) { - output := run(t, []string{ - "Program " + testGatewayProgram + " invoke [1]", - "Program log: Instruction: SendFunds", - "Program " + testAttackerProgram + " invoke [2]", - "Program data: " + base64.StdEncoding.EncodeToString(payload), - "Program " + testAttackerProgram + " success", - "Log truncated", - }) - assert.Contains(t, output, "gateway instruction executed but its event was not observed") - }) -} From 3e425c436d2bd113de45dcb09f3e26cd0af3ebac Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 19 Aug 2026 16:58:56 +0530 Subject: [PATCH 7/7] test: unbalanced solana invoke logs must not underflow the attribution stack (F-2026-18198) --- .../chains/svm/event_listener_test.go | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/universalClient/chains/svm/event_listener_test.go b/universalClient/chains/svm/event_listener_test.go index 9961f6d4..84c20e36 100644 --- a/universalClient/chains/svm/event_listener_test.go +++ b/universalClient/chains/svm/event_listener_test.go @@ -777,6 +777,43 @@ func TestGatewayEmittedLogs(t *testing.T) { assert.Empty(t, gatewayEmittedLogs([]string{data}, testGatewayProgram)) }) + // Unbalanced logs are reachable: truncation can cut a frame's exit line, and + // the parser must not underflow or start attributing to a frame nobody owns. + t.Run("more exits than invokes does not underflow", func(t *testing.T) { + assert.Empty(t, gatewayEmittedLogs([]string{ + "Program " + testGatewayProgram + " success", + "Program " + testGatewayProgram + " success", + data, + }, testGatewayProgram)) + + assert.Empty(t, gatewayEmittedLogs([]string{ + "Program " + testGatewayProgram + " invoke [1]", + "Program " + testGatewayProgram + " success", + "Program " + testGatewayProgram + " success", + data, + }, testGatewayProgram)) + }) + + t.Run("attacker exits cannot expose an outer gateway frame", func(t *testing.T) { + // Gateway CPIs into the attacker, who emits surplus exits hoping to pop + // back to the gateway frame and have its own data log attributed to it. + assert.Empty(t, gatewayEmittedLogs([]string{ + "Program " + testGatewayProgram + " invoke [1]", + "Program " + testAttackerProgram + " invoke [2]", + "Program " + testAttackerProgram + " success", + "Program " + testGatewayProgram + " success", + data, + }, testGatewayProgram)) + }) + + t.Run("gateway frame left open still attributes", func(t *testing.T) { + // What a mid-frame truncation looks like: the exit line never arrives. + assert.Equal(t, map[int]bool{1: true}, gatewayEmittedLogs([]string{ + "Program " + testGatewayProgram + " invoke [1]", + data, + }, testGatewayProgram)) + }) + t.Run("unrelated runtime lines do not disturb the stack", func(t *testing.T) { logs := []string{ "Program " + testGatewayProgram + " invoke [1]",