diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 86189bc6..b7b1c9cf 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -81,3 +81,33 @@ type BlockReplay struct { } var GlobalBlockReplay = BlockReplay{} + +// Counter is a monotonic atomic counter for events. Operations are +// lock-free; use it for high-frequency increments where Timing's +// nanosecond accumulator is unnecessary. +type Counter struct { + value uint64 +} + +func (c *Counter) Inc() { + atomic.AddUint64(&c.value, 1) +} + +func (c *Counter) Get() uint64 { + return atomic.LoadUint64(&c.value) +} + +// Simulate tracks RPC simulateTransaction handler events. Counters are +// surfaced through the standard metrics endpoint; latency uses the same +// Timing type as block replay so dashboards can reuse existing rendering. +type Simulate struct { + TotalCalls Counter + SanitizeFailures Counter + AddressLookupFails Counter + NonceFallbackHits Counter + Errors Counter + Successes Counter + HandlerLatency Timing +} + +var GlobalSimulate = Simulate{} diff --git a/pkg/replay/inner_instructions_test.go b/pkg/replay/inner_instructions_test.go new file mode 100644 index 00000000..08731139 --- /dev/null +++ b/pkg/replay/inner_instructions_test.go @@ -0,0 +1,75 @@ +package replay + +import ( + "testing" + + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/stretchr/testify/assert" +) + +func TestAssembleInnerInstructions_EmptyReturnsNil(t *testing.T) { + execCtx := &sealevel.ExecutionCtx{} + got := AssembleInnerInstructions(execCtx) + assert.Nil(t, got) +} + +func TestAssembleInnerInstructions_GroupsByTopLevelIndex(t *testing.T) { + execCtx := &sealevel.ExecutionCtx{ + InnerInstrs: []sealevel.RecordedInnerInstr{ + {TopLevelIdx: 0, StackHeight: 2, ProgramIdIndex: 5, Accounts: []uint8{1, 2}, Data: []byte{0xAA}}, + {TopLevelIdx: 0, StackHeight: 3, ProgramIdIndex: 6, Accounts: []uint8{3}, Data: []byte{0xBB}}, + {TopLevelIdx: 2, StackHeight: 2, ProgramIdIndex: 7, Accounts: []uint8{4, 5}, Data: []byte{0xCC}}, + }, + } + + got := AssembleInnerInstructions(execCtx) + assert.Len(t, got, 2) + + assert.Equal(t, uint8(0), got[0].Index) + assert.Len(t, got[0].Instructions, 2) + assert.Equal(t, uint8(5), got[0].Instructions[0].ProgramIdIndex) + assert.Equal(t, []uint8{1, 2}, got[0].Instructions[0].Accounts) + assert.Equal(t, []byte{0xAA}, got[0].Instructions[0].Data) + assert.Equal(t, uint8(2), got[0].Instructions[0].StackHeight) + assert.Equal(t, uint8(6), got[0].Instructions[1].ProgramIdIndex) + assert.Equal(t, uint8(3), got[0].Instructions[1].StackHeight) + + assert.Equal(t, uint8(2), got[1].Index) + assert.Len(t, got[1].Instructions, 1) + assert.Equal(t, uint8(7), got[1].Instructions[0].ProgramIdIndex) + assert.Equal(t, uint8(2), got[1].Instructions[0].StackHeight) +} + +func TestAssembleInnerInstructions_PreservesInsertionOrder(t *testing.T) { + execCtx := &sealevel.ExecutionCtx{ + InnerInstrs: []sealevel.RecordedInnerInstr{ + {TopLevelIdx: 3}, + {TopLevelIdx: 1}, + {TopLevelIdx: 3}, + {TopLevelIdx: 0}, + }, + } + got := AssembleInnerInstructions(execCtx) + indices := []uint8{got[0].Index, got[1].Index, got[2].Index} + assert.Equal(t, []uint8{3, 1, 0}, indices, + "order must reflect first-seen top-level index, not numeric sort") +} + +func TestAssembleInnerInstructions_DefensiveCopySlices(t *testing.T) { + original := sealevel.RecordedInnerInstr{ + TopLevelIdx: 0, + ProgramIdIndex: 1, + Accounts: []uint8{1, 2}, + Data: []byte{0x01, 0x02}, + } + execCtx := &sealevel.ExecutionCtx{ + InnerInstrs: []sealevel.RecordedInnerInstr{original}, + } + got := AssembleInnerInstructions(execCtx) + + // Mutating the assembled output must not corrupt the source. + got[0].Instructions[0].Accounts[0] = 99 + got[0].Instructions[0].Data[0] = 0xFF + assert.Equal(t, uint8(1), execCtx.InnerInstrs[0].Accounts[0]) + assert.Equal(t, byte(0x01), execCtx.InnerInstrs[0].Data[0]) +} diff --git a/pkg/replay/simulate_fixtures_test.go b/pkg/replay/simulate_fixtures_test.go new file mode 100644 index 00000000..0ae75ef7 --- /dev/null +++ b/pkg/replay/simulate_fixtures_test.go @@ -0,0 +1,179 @@ +package replay + +import ( + "encoding/base64" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/features" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Fixtures captured from live mainnet RPC validation. Each base64 string is +// a serialized transaction that exercises a specific simulate-path branch. +// Constants live in the test file so the suite is self-contained and runs +// without any external state. +const ( + // Single transfer with a missing recipient/system pubkey. Without the + // SIMD-186 fabricate-default fix, the loader panicked. + fixtureMissingAccount = "ARH9cDfHfnReizlJ7wUiQUDjBHEmFqxOwkrd58jKTtaSiWDD12ojfj+Q6EFIZdtZPEhjaFBjGEKaso9l8vStIgoBAAEDm5dS3l4ndvoMPGwAgbDa2etV4ba8GhFmzEEoWLnQUzLlsqZyE2J9rbpagOWcYxTmla0sPZSdLWedHDC5wCQ+QwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAgIAAQwCAAAAAQAAAAAAAAA=" + + // Tx with NumRequiredSignatures=0 — sanitize must reject before + // anything tries to index Signatures[0] / AccountKeys[0]. + fixtureZeroSig = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + // Out-of-range instruction account index. + fixtureOOBAccount = "AcvGOK/HCvm+jp0iIyg/CBjxoYXidL7Eua6PZr/gYq6SnXSwFBBClGqoT3Dq8SkekT05ydTutpEnhaCXZTkyoQQBAAABm5dS3l4ndvoMPGwAgbDa2etV4ba8GhFmzEEoWLnQUzIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAASoA" + + // NumReadonlySignedAccounts >= NumRequiredSignatures (no writable + // signer); sanitize must reject. + fixtureZeroWritableSigners = "AXP1E+UKbcHEW0T4v81ty2POfQ3VR7omBlei30O7AjHX/Me4P2Zidwhl8acF3jrYONVVV2kv8bbCjyLhq/SZVAYBAQABm5dS3l4ndvoMPGwAgbDa2etV4ba8GhFmzEEoWLnQUzIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + // Header declares 2 required sigs, transaction carries 1. + fixtureInsufficientSigs = "AVW49xPgdaW13hcaexkKxn9aruubS1QPM2i4Gfyvcj+/ZCml3HapzjPxsKXvpsph6bhHIziIQS+wW7il9prJ4AgCAAACm5dS3l4ndvoMPGwAgbDa2etV4ba8GhFmzEEoWLnQUzLlsqZyE2J9rbpagOWcYxTmla0sPZSdLWedHDC5wCQ+QwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + + // 2 signatures but only 1 account key. + fixtureSigsGreaterThanKeys = "AkoD9kRSzxiTCsWNc0tek1JI5Capj2LNsTI0MTUTYwuF15j7urkfyWvnyPQ87UHzaMHkChQ3TA7CBhuNwmojVgVKA/ZEUs8YkwrFjXNLXpNSSOQmqY9izbEyNDE1E2MLhdeY+7q5H8lr58j0PO1B82jB5AoUN0wOwgYbjcJqI1YFAQAAAZuXUt5eJ3b6DDxsAIGw2tnrVeG2vBoRZsxBKFi50FMyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + // ProgramIDIndex points outside AccountKeys range. + fixtureOOBProgram = "AT2SwP5wteXeZK5dcicARe9tPzofzhoUDRBgMS+By+o8CwEidmOMyFLPlqDk1AY8PP0dk5u9zkBCCAhiRXRfVQIBAAABm5dS3l4ndvoMPGwAgbDa2etV4ba8GhFmzEEoWLnQUzIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFjAAA=" + + // Tx with no instructions. Should pass sanitize and proceed to + // fee-payer balance check; payer has 0 SOL → InsufficientFundsForFee. + fixtureNoInstructions = "AUoD9kRSzxiTCsWNc0tek1JI5Capj2LNsTI0MTUTYwuF15j7urkfyWvnyPQ87UHzaMHkChQ3TA7CBhuNwmojVgUBAAABm5dS3l4ndvoMPGwAgbDa2etV4ba8GhFmzEEoWLnQUzIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + // Self-transfer: fee payer also receives. + fixtureSelfTransfer = "AQaoIo/np/BZLN6khLJM2yEybInNs68QaywxRtscCSguw5/qEYsSrYorN0y0zWSGaGrgFrlD5Oaz96CMsAO3iw8BAAECm5dS3l4ndvoMPGwAgbDa2etV4ba8GhFmzEEoWLnQUzIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQECAAAMAgAAAAEAAAAAAAAA" + + // Duplicate AccountKeys entries. + fixtureDuplicateKeys = "ARL1Vif1R7PRgEj1eZZzM1uhhoiPseT/kr/MjAVxurE8GtpLADDL6pfDVEege4anhvnZC/dMZPlRkTTgSUr2awIBAAEDm5dS3l4ndvoMPGwAgbDa2etV4ba8GhFmzEEoWLnQUzKbl1LeXid2+gw8bACBsNrZ61XhtrwaEWbMQShYudBTMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAgIAAQwCAAAAAQAAAAAAAAA=" +) + +func decodeFixtureTx(t *testing.T, b64 string) *solana.Transaction { + t.Helper() + raw, err := base64.StdEncoding.DecodeString(b64) + require.NoError(t, err) + tx, err := solana.TransactionFromBytes(raw) + require.NoError(t, err) + return tx +} + +// simulateFixtureSlotCtx returns a SlotCtx with the SIMD-186 feature +// enabled and an empty MemAccounts. Suitable for fixtures that exercise +// the sanitize / loader path without needing a populated accountsdb. +// +// FeeRateGovernor is non-nil because LoadAndExecuteTransaction reaches +// for it during execution context construction; production callers +// always set it. +func simulateFixtureSlotCtx() *sealevel.SlotCtx { + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + return &sealevel.SlotCtx{ + Features: feats, + Accounts: accounts.NewMemAccounts(), + FeeRateGovernor: &sealevel.FeeRateGovernor{}, + } +} + +func runSimulateFixture(t *testing.T, b64 string) LoadAndExecuteTransactionOutput { + t.Helper() + defer withEmptyRecentBlockhashesSysvarFixture()() + return LoadAndExecuteTransaction(LoadAndExecuteTransactionInput{ + SlotCtx: simulateFixtureSlotCtx(), + Transaction: decodeFixtureTx(t, b64), + IsSimulation: true, + }) +} + +// withEmptyRecentBlockhashesSysvarFixture seeds the global cache so +// IsTransactionAgeValid does not nil-deref. Production seeds this via +// cacheConstantSysvars before any RPC call. +func withEmptyRecentBlockhashesSysvarFixture() func() { + prev := sealevel.SysvarCache.RecentBlockHashes.Sysvar + empty := sealevel.SysvarRecentBlockhashes{} + sealevel.SysvarCache.RecentBlockHashes.Sysvar = &empty + return func() { sealevel.SysvarCache.RecentBlockHashes.Sysvar = prev } +} + +// Without a populated AccountsDb, the System Program is unreachable and +// the loader rejects with ProgramAccountNotFound. With AccountsDb (the +// production case), the Pass-1 fallback resolves it and the tx fails +// later at the fee-payer balance check (InsufficientFundsForFee). Either +// outcome is a clean TransactionError, never a panic. +func TestSimulateFixture_MissingAccount(t *testing.T) { + out := runSimulateFixture(t, fixtureMissingAccount) + require.NotNil(t, out.ProcessingResult.TransactionError) + got := out.ProcessingResult.TransactionError.ErrorType + assert.Contains(t, + []TransactionErrorType{TransactionErrorProgramAccountNotFound, TransactionErrorInsufficientFundsForFee}, + got, + "expected ProgramAccountNotFound (no AccountsDb) or InsufficientFundsForFee (with AccountsDb), got %v", got, + ) +} + +func TestSimulateFixture_ZeroSig(t *testing.T) { + out := runSimulateFixture(t, fixtureZeroSig) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) +} + +func TestSimulateFixture_OOBAccount(t *testing.T) { + out := runSimulateFixture(t, fixtureOOBAccount) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) +} + +func TestSimulateFixture_ZeroWritableSigners(t *testing.T) { + out := runSimulateFixture(t, fixtureZeroWritableSigners) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) +} + +func TestSimulateFixture_InsufficientSigs(t *testing.T) { + out := runSimulateFixture(t, fixtureInsufficientSigs) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) +} + +func TestSimulateFixture_SigsGreaterThanKeys(t *testing.T) { + out := runSimulateFixture(t, fixtureSigsGreaterThanKeys) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) +} + +func TestSimulateFixture_OOBProgram(t *testing.T) { + out := runSimulateFixture(t, fixtureOOBProgram) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) +} + +func TestSimulateFixture_NoInstructions(t *testing.T) { + out := runSimulateFixture(t, fixtureNoInstructions) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorInsufficientFundsForFee, out.ProcessingResult.TransactionError.ErrorType) +} + +// Same caveat as MissingAccount: with no AccountsDb the System Program +// is unreachable, so the loader fails earlier than the fee-payer check. +func TestSimulateFixture_SelfTransfer(t *testing.T) { + out := runSimulateFixture(t, fixtureSelfTransfer) + require.NotNil(t, out.ProcessingResult.TransactionError) + got := out.ProcessingResult.TransactionError.ErrorType + assert.Contains(t, + []TransactionErrorType{TransactionErrorProgramAccountNotFound, TransactionErrorInsufficientFundsForFee}, + got, + ) +} + +func TestSimulateFixture_DuplicateKeys(t *testing.T) { + out := runSimulateFixture(t, fixtureDuplicateKeys) + require.NotNil(t, out.ProcessingResult.TransactionError) + got := out.ProcessingResult.TransactionError.ErrorType + assert.Contains(t, + []TransactionErrorType{TransactionErrorProgramAccountNotFound, TransactionErrorInsufficientFundsForFee}, + got, + ) +} diff --git a/pkg/replay/transaction_processing_pure.go b/pkg/replay/transaction_processing_pure.go index 0cf2f31a..5242cbcf 100644 --- a/pkg/replay/transaction_processing_pure.go +++ b/pkg/replay/transaction_processing_pure.go @@ -29,6 +29,9 @@ type LoadAndExecuteTransactionInput struct { TxMeta *rpc.TransactionMeta // IsSimulation indicates this is a simulation (no side effects on shared state) IsSimulation bool + // RecordInnerInstructions enables CPI recording. The simulate handler + // sets this when the RPC request asks for innerInstructions. + RecordInnerInstructions bool } // LoadAndExecuteTransaction is a pure function that loads and executes a transaction. @@ -177,6 +180,7 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec execCtx.TransactionContext.Signature = tx.Signatures[0] execCtx.TransactionContext.BorrowedAccountArena = input.Arena execCtx.IsSimulation = input.IsSimulation + execCtx.RecordInnerInstructions = input.RecordInnerInstructions // Capture pre-balance lamports (before fee deduction) preBalances := make([]uint64, len(tx.Message.AccountKeys)) @@ -185,6 +189,21 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec preBalances[i] = acct.Lamports } + // Snapshot accounts so the simulate response can decode pre-state + // token balances. Cloning is required because execution mutates + // transactionAccts in place. Only the simulate handler reads this; + // block-replay callers leave it nil and ignore. + var preAccountSnapshots []*accounts.Account + if input.IsSimulation { + preAccountSnapshots = make([]*accounts.Account, len(execCtx.TransactionContext.Accounts.Accounts)) + for i, acct := range execCtx.TransactionContext.Accounts.Accounts { + if acct == nil { + continue + } + preAccountSnapshots[i] = acct.Clone() + } + } + // Calculate and deduct fees start = time.Now() txFeeInfo, _, err := fees.CalculateAndDeductTxFees(tx, input.TxMeta, instrs, &execCtx.TransactionContext.Accounts, computeBudgetLimits, slotCtx.Features, input.IsSimulation) @@ -196,9 +215,10 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec InstructionError: err, }, }, - ExecCtx: execCtx, - PreBalances: preBalances, - FeeInfo: txFeeInfo, + ExecCtx: execCtx, + PreBalances: preBalances, + PreAccountSnapshots: preAccountSnapshots, + FeeInfo: txFeeInfo, } baseFields(&out) return out @@ -236,6 +256,7 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec start = time.Now() for instrIdx, instr := range tx.Message.Instructions { + execCtx.SetCurrentTopLevelInstr(uint8(instrIdx)) ixStart := time.Now() err = fixupInstructionsSysvarAcct(execCtx, uint16(instrIdx)) if err != nil { @@ -302,9 +323,10 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec InstructionError: relevantErr, }, }, - ExecCtx: execCtx, - PreBalances: preBalances, - FeeInfo: txFeeInfo, + ExecCtx: execCtx, + PreBalances: preBalances, + PreAccountSnapshots: preAccountSnapshots, + FeeInfo: txFeeInfo, } baseFields(&out) return out @@ -384,6 +406,7 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec executionDetails := TransactionExecutionDetails{ Status: nil, LogMessages: log.Logs, + InnerInstructions: AssembleInnerInstructions(execCtx), ReturnData: returnData, ExecutedUnits: execCtx.ComputeMeter.Used(), AccountsDataLenDelta: accountsDataLenDelta, @@ -412,10 +435,11 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec ProcessingResult: TransactionProcessingResult{ ProcessedTransaction: &processedTx, }, - ExecutionResult: executionResult, - ExecCtx: execCtx, - PreBalances: preBalances, - FeeInfo: txFeeInfo, + ExecutionResult: executionResult, + ExecCtx: execCtx, + PreBalances: preBalances, + PreAccountSnapshots: preAccountSnapshots, + FeeInfo: txFeeInfo, } baseFields(&out) return out @@ -435,6 +459,44 @@ func mapLoadErrorType(err error) TransactionErrorType { } } +// AssembleInnerInstructions groups CPI captures from execCtx by their +// top-level instruction index and converts them into the response shape. +// Returns nil when no captures were recorded (e.g. simulate without +// innerInstructions=true, or block-replay). +// +// Exported so the simulate RPC handler can reach into a partially-executed +// execCtx on the InstructionError path — Agave wire format keeps captured +// CPIs on tx failure even though Mithril's bifurcated processing-result +// type discards them via the TransactionError early-return. +func AssembleInnerInstructions(execCtx *sealevel.ExecutionCtx) []InnerInstructionsList { + if len(execCtx.InnerInstrs) == 0 { + return nil + } + + byTopLevel := make(map[uint8][]CompiledInstruction) + order := make([]uint8, 0) + for _, r := range execCtx.InnerInstrs { + if _, seen := byTopLevel[r.TopLevelIdx]; !seen { + order = append(order, r.TopLevelIdx) + } + byTopLevel[r.TopLevelIdx] = append(byTopLevel[r.TopLevelIdx], CompiledInstruction{ + ProgramIdIndex: r.ProgramIdIndex, + Accounts: append([]uint8{}, r.Accounts...), + Data: append([]byte{}, r.Data...), + StackHeight: r.StackHeight, + }) + } + + result := make([]InnerInstructionsList, 0, len(order)) + for _, idx := range order { + result = append(result, InnerInstructionsList{ + Index: idx, + Instructions: byTopLevel[idx], + }) + } + return result +} + // collectAccountUpdates collects all modified accounts from the execution context func collectAccountUpdates(execCtx *sealevel.ExecutionCtx) []AccountUpdate { updates := make([]AccountUpdate, 0) diff --git a/pkg/replay/transaction_processing_types.go b/pkg/replay/transaction_processing_types.go index dd3c74f2..fc9f397e 100644 --- a/pkg/replay/transaction_processing_types.go +++ b/pkg/replay/transaction_processing_types.go @@ -26,6 +26,11 @@ type LoadAndExecuteTransactionOutput struct { // PreBalances contains lamport balances for each account BEFORE fee deduction. // Used by ProcessTransaction for pre-balance divergence checks. PreBalances []uint64 + // PreAccountSnapshots holds clones of every transaction account + // captured before fee deduction and execution. The simulate handler + // uses these to decode pre-execution token balances; block-replay + // callers leave it nil. + PreAccountSnapshots []*accounts.Account // FeeInfo contains the calculated fee info. Set whenever fees were calculated. FeeInfo *fees.TxFeeInfo // Instrs contains the parsed instructions from the transaction. @@ -356,7 +361,9 @@ type InnerInstructionsList struct { Instructions []CompiledInstruction } -// CompiledInstruction represents a compiled instruction +// CompiledInstruction represents a compiled instruction. +// StackHeight is set for inner instructions to mirror Agave's +// UiCompiledInstruction; top-level top-level captures leave it zero. type CompiledInstruction struct { // ProgramIdIndex is the index into the transaction's account keys of the program ProgramIdIndex uint8 @@ -364,6 +371,9 @@ type CompiledInstruction struct { Accounts []uint8 // Data is the instruction data Data []byte + // StackHeight is the depth at which this instruction executed. + // 1 = top-level; >=2 = CPI invoked from within another instruction. + StackHeight uint8 } // TransactionReturnData represents data returned from a program diff --git a/pkg/rpcserver/errors.go b/pkg/rpcserver/errors.go new file mode 100644 index 00000000..b2562d30 --- /dev/null +++ b/pkg/rpcserver/errors.go @@ -0,0 +1,91 @@ +package rpcserver + +import ( + "encoding/json" + "fmt" + + "github.com/filecoin-project/go-jsonrpc" +) + +// JSON-RPC error codes that match Agave's solana_rpc_client_types. +const ( + // -32602 is the JSON-RPC standard "Invalid params" code. + rpcCodeInvalidParams jsonrpc.ErrorCode = -32602 + // -32016 is Agave's reserved code for MinContextSlotNotReached. + rpcCodeMinContextSlotNotReached jsonrpc.ErrorCode = -32016 +) + +// MinContextSlotNotReachedError is returned when the caller demands a +// minimum context slot we have not yet reached. The structured payload +// is emitted as the JSON-RPC error's `data` field (Agave-compatible), +// matching the shape `{"contextSlot": N}` that Solana clients consume. +type MinContextSlotNotReachedError struct { + ContextSlot uint64 +} + +func (e *MinContextSlotNotReachedError) Error() string { + return "Minimum context slot has not been reached" +} + +func (e *MinContextSlotNotReachedError) ToJSONRPCError() (jsonrpc.JSONRPCError, error) { + return jsonrpc.JSONRPCError{ + Code: rpcCodeMinContextSlotNotReached, + Message: e.Error(), + Data: map[string]uint64{"contextSlot": e.ContextSlot}, + }, nil +} + +func (e *MinContextSlotNotReachedError) FromJSONRPCError(rpcErr jsonrpc.JSONRPCError) error { + if rpcErr.Code != rpcCodeMinContextSlotNotReached { + return fmt.Errorf("unexpected code %d for MinContextSlotNotReachedError", rpcErr.Code) + } + if rpcErr.Data == nil { + return nil + } + // `Data` is interface{}; round-trip through JSON to read camelCase. + raw, err := json.Marshal(rpcErr.Data) + if err != nil { + return fmt.Errorf("re-encoding MinContextSlotNotReachedError data: %w", err) + } + var payload struct { + ContextSlot uint64 `json:"contextSlot"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return fmt.Errorf("decoding MinContextSlotNotReachedError data: %w", err) + } + e.ContextSlot = payload.ContextSlot + return nil +} + +// InvalidParamsError maps a Mithril-side argument-validation failure to +// the standard JSON-RPC -32602. Use for shape and conflict checks like +// "sigVerify may not be used with replaceRecentBlockhash". +type InvalidParamsError struct { + Message string +} + +func (e *InvalidParamsError) Error() string { return e.Message } + +func (e *InvalidParamsError) ToJSONRPCError() (jsonrpc.JSONRPCError, error) { + return jsonrpc.JSONRPCError{ + Code: rpcCodeInvalidParams, + Message: e.Message, + }, nil +} + +func (e *InvalidParamsError) FromJSONRPCError(rpcErr jsonrpc.JSONRPCError) error { + if rpcErr.Code != rpcCodeInvalidParams { + return fmt.Errorf("unexpected code %d for InvalidParamsError", rpcErr.Code) + } + e.Message = rpcErr.Message + return nil +} + +// rpcErrorRegistry returns an Errors set with Mithril's custom codes +// registered. Pass to jsonrpc.NewServer via WithServerErrors. +func rpcErrorRegistry() jsonrpc.Errors { + errs := jsonrpc.NewErrors() + errs.Register(rpcCodeInvalidParams, new(*InvalidParamsError)) + errs.Register(rpcCodeMinContextSlotNotReached, new(*MinContextSlotNotReachedError)) + return errs +} diff --git a/pkg/rpcserver/errors_test.go b/pkg/rpcserver/errors_test.go new file mode 100644 index 00000000..6967eb7d --- /dev/null +++ b/pkg/rpcserver/errors_test.go @@ -0,0 +1,83 @@ +package rpcserver + +import ( + "encoding/json" + "testing" + + "github.com/filecoin-project/go-jsonrpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMinContextSlotNotReachedError_ToJSONRPCError(t *testing.T) { + e := &MinContextSlotNotReachedError{ContextSlot: 417_500_000} + got, err := e.ToJSONRPCError() + require.NoError(t, err) + assert.Equal(t, jsonrpc.ErrorCode(-32016), got.Code) + assert.Equal(t, "Minimum context slot has not been reached", got.Message) + require.NotNil(t, got.Data, "must populate Data (not Meta) to match Agave wire format") + dataMap, ok := got.Data.(map[string]uint64) + require.True(t, ok, "Data should be a map[string]uint64") + assert.Equal(t, uint64(417_500_000), dataMap["contextSlot"]) +} + +// JSON-marshal round-trip: catches wire-format regressions where the +// structured payload lands in the wrong envelope field. Solana SDKs read +// `error.data.contextSlot`, so the wire MUST emit `data`, not `meta`. +func TestMinContextSlotNotReachedError_WireShape(t *testing.T) { + e := &MinContextSlotNotReachedError{ContextSlot: 417_500_000} + got, err := e.ToJSONRPCError() + require.NoError(t, err) + + raw, err := json.Marshal(&got) + require.NoError(t, err) + rawStr := string(raw) + + assert.Contains(t, rawStr, `"code":-32016`) + assert.Contains(t, rawStr, `"message":"Minimum context slot has not been reached"`) + assert.Contains(t, rawStr, `"data":{"contextSlot":417500000}`) + assert.NotContains(t, rawStr, `"meta"`, "must not emit legacy meta envelope; Agave uses data") +} + +func TestMinContextSlotNotReachedError_FromJSONRPCError(t *testing.T) { + rpcErr := jsonrpc.JSONRPCError{ + Code: -32016, + Message: "Minimum context slot has not been reached", + Data: map[string]interface{}{"contextSlot": float64(42)}, + } + e := &MinContextSlotNotReachedError{} + require.NoError(t, e.FromJSONRPCError(rpcErr)) + assert.Equal(t, uint64(42), e.ContextSlot) +} + +func TestMinContextSlotNotReachedError_FromJSONRPCError_RejectsWrongCode(t *testing.T) { + rpcErr := jsonrpc.JSONRPCError{Code: 1} + e := &MinContextSlotNotReachedError{} + assert.Error(t, e.FromJSONRPCError(rpcErr)) +} + +func TestInvalidParamsError_ToJSONRPCError(t *testing.T) { + e := &InvalidParamsError{Message: "bad input"} + got, err := e.ToJSONRPCError() + require.NoError(t, err) + assert.Equal(t, jsonrpc.ErrorCode(-32602), got.Code) + assert.Equal(t, "bad input", got.Message) +} + +func TestInvalidParamsError_WireShape(t *testing.T) { + e := &InvalidParamsError{Message: "sigVerify may not be used with replaceRecentBlockhash"} + got, err := e.ToJSONRPCError() + require.NoError(t, err) + raw, err := json.Marshal(&got) + require.NoError(t, err) + rawStr := string(raw) + assert.Contains(t, rawStr, `"code":-32602`) + assert.Contains(t, rawStr, `"message":"sigVerify may not be used with replaceRecentBlockhash"`) + assert.NotContains(t, rawStr, `"data"`, "InvalidParams emits no data; only code+message") +} + +func TestInvalidParamsError_FromJSONRPCError(t *testing.T) { + e := &InvalidParamsError{} + require.NoError(t, e.FromJSONRPCError(jsonrpc.JSONRPCError{Code: -32602, Message: "x"})) + assert.Equal(t, "x", e.Message) +} diff --git a/pkg/rpcserver/rpcserver.go b/pkg/rpcserver/rpcserver.go index 78db91b0..47b7fc6b 100644 --- a/pkg/rpcserver/rpcserver.go +++ b/pkg/rpcserver/rpcserver.go @@ -35,10 +35,14 @@ func NewRpcServer(acctsDb *accountsdb.AccountsDb, port uint16) *RpcServer { panic(err) } - rpcServer.rpcService = jsonrpc.NewServer(jsonrpc.WithServerMethodNameFormatter( - func(namespace, method string) string { - return strings.ToLower(string(method[0])) + method[1:] - })) + rpcErrors := rpcErrorRegistry() + rpcServer.rpcService = jsonrpc.NewServer( + jsonrpc.WithServerMethodNameFormatter( + func(namespace, method string) string { + return strings.ToLower(string(method[0])) + method[1:] + }), + jsonrpc.WithServerErrors(rpcErrors), + ) rpcServer.rpcService.Register("MithrilRpc", rpcServer) rpcServer.acctsDb = acctsDb diff --git a/pkg/rpcserver/simulate_transaction.go b/pkg/rpcserver/simulate_transaction.go index 11a8b9e0..98e88dc2 100644 --- a/pkg/rpcserver/simulate_transaction.go +++ b/pkg/rpcserver/simulate_transaction.go @@ -4,8 +4,12 @@ import ( "context" "encoding/base64" "fmt" + "time" + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/accountsdb" "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/metrics" "github.com/Overclock-Validator/mithril/pkg/replay" "github.com/Overclock-Validator/mithril/pkg/sealevel" "github.com/filecoin-project/go-jsonrpc" @@ -23,21 +27,95 @@ type SimulateTransactionRespContext struct { Slot uint64 `json:"slot"` } +// SimulateTransactionRespValue mirrors Agave's RpcSimulateTransactionResult +// field-for-field. Every field is emitted unconditionally as either a real +// value or null; no omitempty. Pointer types model Agave's Option so +// nil → JSON null and a populated value → the value. +// +// `Err` is intentionally left as interface{}: it is a true sum type +// (nil | string | *replay.TransactionError), and *replay.TransactionError +// carries a custom MarshalJSON. Wrapping it in a concrete struct would +// only force a custom MarshalJSON without adding type safety. type SimulateTransactionRespValue struct { - Err interface{} `json:"err"` - Logs []string `json:"logs"` - Accounts interface{} `json:"accounts"` - UnitsConsumed *uint64 `json:"unitsConsumed,omitempty"` - ReturnData interface{} `json:"returnData"` - InnerInstructions interface{} `json:"innerInstructions"` - ReplacementBlockhash interface{} `json:"replacementBlockhash"` - LoadedAccountsDataSize *uint32 `json:"loadedAccountsDataSize,omitempty"` + Err interface{} `json:"err"` + Logs *[]string `json:"logs"` + Accounts *[]*AccountInfoPayload `json:"accounts"` + UnitsConsumed *uint64 `json:"unitsConsumed"` + ReturnData *ReturnDataPayload `json:"returnData"` + InnerInstructions *[]InnerInstructionGroup `json:"innerInstructions"` + ReplacementBlockhash *ReplacementBlockhashPayload `json:"replacementBlockhash"` + LoadedAccountsDataSize *uint32 `json:"loadedAccountsDataSize"` + Fee *uint64 `json:"fee"` + PreBalances *[]uint64 `json:"preBalances"` + PostBalances *[]uint64 `json:"postBalances"` + PreTokenBalances *[]TokenBalancePayload `json:"preTokenBalances"` + PostTokenBalances *[]TokenBalancePayload `json:"postTokenBalances"` + LoadedAddresses *LoadedAddressesPayload `json:"loadedAddresses"` +} + +type AccountInfoPayload struct { + Data interface{} `json:"data"` + Executable bool `json:"executable"` + Lamports uint64 `json:"lamports"` + Owner string `json:"owner"` + RentEpoch uint64 `json:"rentEpoch"` + Space int `json:"space"` +} + +type ReturnDataPayload struct { + ProgramId string `json:"programId"` + Data []string `json:"data"` +} + +type InnerInstructionGroup struct { + Index uint8 `json:"index"` + Instructions []InnerInstruction `json:"instructions"` +} + +// InnerInstruction matches Agave's UiCompiledInstruction. `Data` is +// base58-encoded; `StackHeight` is nil when not tracked. `StackHeight` +// is `Option` in Agave's wire format; using *uint32 here so the +// field's JSON-number range matches the spec exactly even though +// real-world CPI depth never exceeds the 4-deep cap. +type InnerInstruction struct { + ProgramIdIndex uint8 `json:"programIdIndex"` + Accounts []uint8 `json:"accounts"` + Data string `json:"data"` + StackHeight *uint32 `json:"stackHeight"` +} + +type ReplacementBlockhashPayload struct { + Blockhash string `json:"blockhash"` + LastValidBlockHeight uint64 `json:"lastValidBlockHeight"` +} + +type LoadedAddressesPayload struct { + Readonly []string `json:"readonly"` + Writable []string `json:"writable"` +} + +type TokenBalancePayload struct { + AccountIndex uint8 `json:"accountIndex"` + Mint string `json:"mint"` + Owner string `json:"owner,omitempty"` + ProgramId string `json:"programId,omitempty"` + UiTokenAmount UiTokenAmountPayload `json:"uiTokenAmount"` +} + +type UiTokenAmountPayload struct { + Amount string `json:"amount"` + Decimals uint8 `json:"decimals"` + UiAmount *float64 `json:"uiAmount"` + UiAmountString string `json:"uiAmountString"` } type simulateTransactionConfig struct { sigVerify bool replaceRecentBlockhash bool + innerInstructions bool encoding string + commitment string + minContextSlot *uint64 accounts *simulateAccountsConfig } @@ -47,24 +125,55 @@ type simulateAccountsConfig struct { } func (rpcServer *RpcServer) SimulateTransaction(ctx context.Context, p jsonrpc.RawParams) (SimulateTransactionResp, error) { + metrics.GlobalSimulate.TotalCalls.Inc() + defer metrics.GlobalSimulate.HandlerLatency.AddTimingSince(time.Now()) + params, err := jsonrpc.DecodeParams[[]interface{}](p) if err != nil { - return SimulateTransactionResp{}, fmt.Errorf("decoding params: %w", err) + return SimulateTransactionResp{}, &InvalidParamsError{Message: fmt.Sprintf("decoding params: %v", err)} } if len(params) < 1 { - return SimulateTransactionResp{}, fmt.Errorf("simulateTransaction requires a transaction string as first parameter") + return SimulateTransactionResp{}, &InvalidParamsError{Message: "simulateTransaction requires a transaction string as first parameter"} } txStr, ok := params[0].(string) if !ok { - return SimulateTransactionResp{}, fmt.Errorf("simulateTransaction requires a transaction string as first parameter") + return SimulateTransactionResp{}, &InvalidParamsError{Message: "simulateTransaction requires a transaction string as first parameter"} } - // Parse config conf := parseSimulateConfig(params) - // Decode transaction + // Validate the encoding name BEFORE attempting tx decode so a bad + // encoding surfaces as "unsupported encoding: …" not "failed to decode". + // Agave order: into_binary_encoding() runs before decode_and_deserialize. + if conf.encoding != "" && conf.encoding != "base58" && conf.encoding != "base64" { + return SimulateTransactionResp{}, &InvalidParamsError{ + Message: fmt.Sprintf("unsupported encoding: %s", conf.encoding), + } + } + + // Reject oversized encoded inputs BEFORE decode, matching Agave's + // decode_and_deserialize. Constants are from Agave's rpc.rs. + const ( + maxBase58TxSize = 1683 + maxBase64TxSize = 1644 + packetDataSize = 1232 + ) + if conf.encoding == "base58" { + if len(txStr) > maxBase58TxSize { + return SimulateTransactionResp{}, &InvalidParamsError{ + Message: fmt.Sprintf("base58 encoded solana_transaction too large: %d bytes (max: encoded/raw %d/%d)", len(txStr), maxBase58TxSize, packetDataSize), + } + } + } else { + if len(txStr) > maxBase64TxSize { + return SimulateTransactionResp{}, &InvalidParamsError{ + Message: fmt.Sprintf("base64 encoded solana_transaction too large: %d bytes (max: encoded/raw %d/%d)", len(txStr), maxBase64TxSize, packetDataSize), + } + } + } + var tx *solana.Transaction if conf.encoding == "base58" { tx, err = solana.TransactionFromBase58(txStr) @@ -72,79 +181,89 @@ func (rpcServer *RpcServer) SimulateTransaction(ctx context.Context, p jsonrpc.R tx, err = solana.TransactionFromBase64(txStr) } if err != nil { - return SimulateTransactionResp{}, fmt.Errorf("failed to decode transaction: %w", err) + return SimulateTransactionResp{}, &InvalidParamsError{Message: fmt.Sprintf("failed to decode transaction: %v", err)} } - // Validate sigVerify + replaceRecentBlockhash conflict + // Conflict check runs after decode so a bad tx surfaces as "failed to + // decode" rather than masking the underlying issue. if conf.sigVerify && conf.replaceRecentBlockhash { - return SimulateTransactionResp{}, fmt.Errorf("sigVerify may not be used with replaceRecentBlockhash") + return SimulateTransactionResp{}, &InvalidParamsError{ + Message: "sigVerify may not be used with replaceRecentBlockhash", + } } - // Replace recent blockhash if requested - var replacementBlockhash interface{} - if conf.replaceRecentBlockhash { - latestBlockhash := global.LatestBlockHash() - tx.Message.RecentBlockhash = solana.Hash(latestBlockhash) - blockHeight := global.BlockHeight() - replacementBlockhash = map[string]interface{}{ - "blockhash": base58.Encode(latestBlockhash[:]), - "lastValidBlockHeight": blockHeight, + if conf.accounts != nil && conf.accounts.encoding != "" { + switch conf.accounts.encoding { + case "base58", "binary": + return SimulateTransactionResp{}, &InvalidParamsError{ + Message: "base58 encoding not supported", + } } } - // Verify signatures if requested - if conf.sigVerify { - err = tx.VerifySignatures() - if err != nil { - return SimulateTransactionResp{ - Context: SimulateTransactionRespContext{ - ApiVersion: "mithril 0.1", - Slot: global.Slot(), - }, - Value: SimulateTransactionRespValue{ - Err: "SignatureFailure", - Logs: nil, - }, - }, nil + if conf.minContextSlot != nil && global.Slot() < *conf.minContextSlot { + return SimulateTransactionResp{}, &MinContextSlotNotReachedError{ + ContextSlot: *conf.minContextSlot, + } + } + + var replacementBlockhash *ReplacementBlockhashPayload + if conf.replaceRecentBlockhash { + latestBlockhash := global.LatestBlockHash() + tx.Message.RecentBlockhash = solana.Hash(latestBlockhash) + replacementBlockhash = &ReplacementBlockhashPayload{ + Blockhash: base58.Encode(latestBlockhash[:]), + LastValidBlockHeight: global.BlockHeight(), } } - // Get SlotCtx slotCtx := rpcServer.getSlotCtx() if slotCtx == nil { return SimulateTransactionResp{}, fmt.Errorf("node is not ready for simulation") } - // Resolve address-table lookups for versioned txs before the loader - // runs. ALT misses surface as AddressLookupTableNotFound in-band. + // ALT resolve runs before sigVerify so the failure response carries the + // resolved loadedAddresses, not empty arrays. if err := replay.ResolveAddrTableLookupsForTx(ctx, rpcServer.acctsDb, slotCtx.Slot, tx); err != nil { - return SimulateTransactionResp{ - Context: SimulateTransactionRespContext{ - ApiVersion: "mithril 0.1", - Slot: global.Slot(), - }, - Value: SimulateTransactionRespValue{ - Err: "AddressLookupTableNotFound", - ReplacementBlockhash: replacementBlockhash, - }, - }, nil - } - - // Execute transaction using the pure function + metrics.GlobalSimulate.AddressLookupFails.Inc() + return earlyFailResponse("AddressLookupTableNotFound", replacementBlockhash, conf, tx), nil + } + + // Cap uses post-ALT-resolve key count; pre-resolve would reject valid + // requests for versioned txs. + if conf.accounts != nil && len(conf.accounts.addresses) > len(tx.Message.AccountKeys) { + return SimulateTransactionResp{}, &InvalidParamsError{ + Message: fmt.Sprintf("Too many accounts provided; max %d", len(tx.Message.AccountKeys)), + } + } + + // Verify signatures if requested. Runs AFTER ALT resolve (matches + // Agave's order: sanitize → verify) so the failure response reports + // the fully-resolved loadedAddresses, not empty arrays. + if conf.sigVerify { + err = tx.VerifySignatures() + if err != nil { + return earlyFailResponse("SignatureFailure", replacementBlockhash, conf, tx), nil + } + } + output := replay.LoadAndExecuteTransaction(replay.LoadAndExecuteTransactionInput{ - SlotCtx: slotCtx, - Transaction: tx, - TxMeta: nil, - IsSimulation: true, + SlotCtx: slotCtx, + Transaction: tx, + TxMeta: nil, + IsSimulation: true, + RecordInnerInstructions: conf.innerInstructions, }) - // Extract logs from ExecCtx if available - var logs []string + // Default to empty slice so JSON marshals "logs":[] not null when the + // simulator ran. + logs := []string{} if output.ExecCtx != nil { - if logRecorder, ok := output.ExecCtx.Log.(*sealevel.LogRecorder); ok && logRecorder != nil { + if logRecorder, ok := output.ExecCtx.Log.(*sealevel.LogRecorder); ok && logRecorder != nil && logRecorder.Logs != nil { logs = logRecorder.Logs } } + logs = clampLogs(logs) resp := SimulateTransactionResp{ Context: SimulateTransactionRespContext{ @@ -152,19 +271,80 @@ func (rpcServer *RpcServer) SimulateTransaction(ctx context.Context, p jsonrpc.R Slot: global.Slot(), }, Value: SimulateTransactionRespValue{ - Logs: logs, + Logs: ptrSlice(logs), ReplacementBlockhash: replacementBlockhash, InnerInstructions: nil, + PreBalances: ptrSlice(output.PreBalances), + PostBalances: ptrSlice(postBalancesFromExecCtx(output.ExecCtx)), + PreTokenBalances: ptrSliceTokenBalance(tokenBalancesFromAccounts(output.PreAccountSnapshots, rpcServer.acctsDb, slotCtx.Slot)), + PostTokenBalances: ptrSliceTokenBalance(tokenBalancesFromAccounts(postExecAccounts(output.ExecCtx), rpcServer.acctsDb, slotCtx.Slot)), + LoadedAddresses: loadedAddressesFromTx(tx), }, } + if output.FeeInfo != nil { + fee := output.FeeInfo.TotalFee + resp.Value.Fee = &fee + } - // TransactionError.MarshalJSON renders the Agave wire format. if output.ProcessingResult.TransactionError != nil { - resp.Value.Err = output.ProcessingResult.TransactionError + txErr := output.ProcessingResult.TransactionError + resp.Value.Err = txErr + + // InstructionError / InsufficientFundsForRent reach this path AFTER + // execution started, so execCtx carries real CU/data-size/logs/CPIs. + // Pre-execution failures (sanitize, load, fee) leave it nil. + executionRan := output.ExecCtx != nil && + (txErr.ErrorType == replay.TransactionErrorInstructionError || + txErr.ErrorType == replay.TransactionErrorInsufficientFundsForRent) + + if executionRan { + units := output.ExecCtx.ComputeMeter.Used() + resp.Value.UnitsConsumed = &units + dataSize := loadedAccountsDataSizeFromExecCtx(output.ExecCtx) + resp.Value.LoadedAccountsDataSize = &dataSize + if logRecorder, ok := output.ExecCtx.Log.(*sealevel.LogRecorder); ok && logRecorder != nil && logRecorder.Logs != nil { + clamped := clampLogs(logRecorder.Logs) + resp.Value.Logs = &clamped + } + } else { + zeroUnits := uint64(0) + zeroSize := uint32(0) + resp.Value.UnitsConsumed = &zeroUnits + resp.Value.LoadedAccountsDataSize = &zeroSize + } + + if conf.innerInstructions { + var rendered []InnerInstructionGroup + if executionRan { + rendered = renderInnerInstructions(replay.AssembleInnerInstructions(output.ExecCtx)) + } else { + rendered = []InnerInstructionGroup{} + } + resp.Value.InnerInstructions = &rendered + } + + // On result.is_err() the post-state isn't meaningful — return + // a null-filled array of the requested length. + if conf.accounts != nil { + nullAccounts := make([]*AccountInfoPayload, len(conf.accounts.addresses)) + resp.Value.Accounts = &nullAccounts + } + switch txErr.ErrorType { + case replay.TransactionErrorSanitizeFailure: + metrics.GlobalSimulate.SanitizeFailures.Inc() + default: + metrics.GlobalSimulate.Errors.Inc() + } return resp, nil } if output.ProcessingResult.ProcessedTransaction == nil { + // Defensive: pure-execution returned neither error nor processed tx. + // Agave parity: if accounts.addresses was provided, emit nulls. + if conf.accounts != nil { + nullAccounts := make([]*AccountInfoPayload, len(conf.accounts.addresses)) + resp.Value.Accounts = &nullAccounts + } return resp, nil } @@ -177,62 +357,43 @@ func (rpcServer *RpcServer) SimulateTransaction(ctx context.Context, p jsonrpc.R dataSize := executed.LoadedTransaction.LoadedAccountsDataSize resp.Value.LoadedAccountsDataSize = &dataSize - // Return data if executed.ExecutionDetails.ReturnData != nil { rd := executed.ExecutionDetails.ReturnData - resp.Value.ReturnData = map[string]interface{}{ - "programId": base58.Encode(rd.ProgramId[:]), - "data": []string{base64.StdEncoding.EncodeToString(rd.Data), "base64"}, + clamped := clampReturnData(rd.Data) + resp.Value.ReturnData = &ReturnDataPayload{ + ProgramId: base58.Encode(rd.ProgramId[:]), + Data: []string{base64.StdEncoding.EncodeToString(clamped), "base64"}, } } - // Execution status error + if conf.innerInstructions { + rendered := renderInnerInstructions(executed.ExecutionDetails.InnerInstructions) + resp.Value.InnerInstructions = &rendered + } + + // Pass the structured Status through; its MarshalJSON renders the + // tuple shape (e.g. {"InstructionError":[idx,{"Custom":N}]}). A + // Go-stringified form would break clients. if executed.ExecutionDetails.Status != nil { - resp.Value.Err = executed.ExecutionDetails.Status.Error() + resp.Value.Err = executed.ExecutionDetails.Status + metrics.GlobalSimulate.Errors.Inc() + } else { + metrics.GlobalSimulate.Successes.Inc() } } - // Handle requested accounts - if conf.accounts != nil && output.ExecCtx != nil { - accts := make([]interface{}, len(conf.accounts.addresses)) - for i, addrStr := range conf.accounts.addresses { - pk, err := solana.PublicKeyFromBase58(addrStr) - if err != nil { - accts[i] = nil - continue - } - - // Look up account in the execution context's post-execution state - found := false - for idx, acct := range output.ExecCtx.TransactionContext.Accounts.Accounts { - if acct.Key == pk { - encodingType := GetAccountEncodingBase64 - if conf.accounts.encoding != "" { - encodingType, _ = parseGetAcctDataEncodingType(conf.accounts.encoding) - } - acctConf := &GetAccountInfoConfig{EncodingType: &encodingType} - encodedData, err := encodeAcctDataWithConfig(output.ExecCtx.TransactionContext.Accounts.Accounts[idx].Data, acctConf) - if err != nil { - accts[i] = nil - continue - } - accts[i] = map[string]interface{}{ - "data": encodedData, - "executable": acct.Executable, - "lamports": acct.Lamports, - "owner": base58.Encode(acct.Owner[:]), - "rentEpoch": acct.RentEpoch, - "space": len(acct.Data), - } - found = true - break - } - } - if !found { - accts[i] = nil - } + // In-band InstructionError (Status != nil on an executed tx) is still + // result.is_err(), so accounts must be null-filled. + if conf.accounts != nil { + processedTx := output.ProcessingResult.ProcessedTransaction + execErr := processedTx != nil && processedTx.Executed != nil && + processedTx.Executed.ExecutionDetails.Status != nil + if execErr { + nullAccounts := make([]*AccountInfoPayload, len(conf.accounts.addresses)) + resp.Value.Accounts = &nullAccounts + } else { + resp.Value.Accounts = renderRequestedAccounts(conf, output.ExecCtx, rpcServer.acctsDb, slotCtx.Slot) } - resp.Value.Accounts = accts } return resp, nil @@ -264,6 +425,19 @@ func parseSimulateConfig(params []interface{}) simulateTransactionConfig { conf.encoding = encoding } + if commitment, ok := confMap["commitment"].(string); ok { + conf.commitment = commitment + } + + if minSlot, ok := confMap["minContextSlot"].(float64); ok && minSlot >= 0 { + v := uint64(minSlot) + conf.minContextSlot = &v + } + + if innerInstr, ok := confMap["innerInstructions"].(bool); ok { + conf.innerInstructions = innerInstr + } + if accountsObj, ok := confMap["accounts"].(map[string]interface{}); ok { acctConf := &simulateAccountsConfig{} if addresses, ok := accountsObj["addresses"].([]interface{}); ok { @@ -281,3 +455,265 @@ func parseSimulateConfig(params []interface{}) simulateTransactionConfig { return conf } + +// postBalancesFromExecCtx returns the lamport balance of every account key +// in the transaction's order after execution. Returns nil when the +// execution context is unavailable (e.g. early sanitize/load failure). +func postBalancesFromExecCtx(execCtx *sealevel.ExecutionCtx) []uint64 { + if execCtx == nil { + return nil + } + accts := execCtx.TransactionContext.Accounts.Accounts + out := make([]uint64, len(accts)) + for i, acct := range accts { + out[i] = acct.Lamports + } + return out +} + +// loadedAccountsDataSizeFromExecCtx sums the data size of all loaded +// accounts on the failure path, mirroring the success-path calculation +// in transaction_processing_pure.go. Used so InstructionError responses +// emit a non-zero loadedAccountsDataSize, matching Agave's wire format. +func loadedAccountsDataSizeFromExecCtx(execCtx *sealevel.ExecutionCtx) uint32 { + if execCtx == nil { + return 0 + } + var total uint32 + for _, acct := range execCtx.TransactionContext.Accounts.Accounts { + if acct == nil || acct.IsDummy { + continue + } + total += uint32(len(acct.Data)) + } + return total +} + +// postExecAccounts returns the live post-execution transaction accounts +// (or nil when the execution context never came up). Used to feed the +// token-balance extractor without requiring callers to pierce the +// execCtx struct themselves. +func postExecAccounts(execCtx *sealevel.ExecutionCtx) []*accounts.Account { + if execCtx == nil { + return nil + } + return execCtx.TransactionContext.Accounts.Accounts +} + +// Agave-parity limits applied to simulate response payloads. Values +// match `solana_runtime::bank::TransactionSimulationResult` clamps so +// large outputs don't bloat RPC responses. +const ( + maxSimulateLogBytes = 10 * 1024 + maxSimulateReturnDataBytes = 1024 +) + +// clampLogs truncates a log slice so the total byte size of all +// concatenated log lines stays under maxSimulateLogBytes. When the cap +// is hit, a synthetic "Log truncated" line is appended (matching Agave). +func clampLogs(logs []string) []string { + if logs == nil { + return logs + } + total := 0 + for i, l := range logs { + total += len(l) + if total > maxSimulateLogBytes { + out := make([]string, 0, i+1) + out = append(out, logs[:i]...) + out = append(out, "Log truncated") + return out + } + } + return logs +} + +// clampReturnData truncates raw return-data bytes to the spec limit. +// Programs may emit larger blobs at run time, but the wire format never +// exceeds 1024 bytes per Agave's TransactionReturnData. +func clampReturnData(data []byte) []byte { + if len(data) <= maxSimulateReturnDataBytes { + return data + } + out := make([]byte, maxSimulateReturnDataBytes) + copy(out, data[:maxSimulateReturnDataBytes]) + return out +} + +// earlyFailResponse builds a response for paths that fail before the +// simulator runs (sigVerify failure, ALT not found). Matches Agave's +// SignatureFailure fixture: every required field present, balances null, +// logs and loadedAddresses populated as empty containers, innerInstructions +// as [] when caller asked for it and null otherwise. +// +// `tx` is consulted only for loadedAddresses: when ALT resolution has +// completed (sigVerify-fail path), the resolved writable/readonly arrays +// are emitted. For pre-ALT failures (ALT-not-found), tx may be nil or +// not-yet-resolved, in which case loadedAddresses is the empty payload. +func earlyFailResponse(errStr string, replacementBlockhash *ReplacementBlockhashPayload, conf simulateTransactionConfig, tx *solana.Transaction) SimulateTransactionResp { + zeroUnits := uint64(0) + zeroSize := uint32(0) + emptyLogs := []string{} + loaded := &LoadedAddressesPayload{Readonly: []string{}, Writable: []string{}} + if tx != nil { + loaded = loadedAddressesFromTx(tx) + } + resp := SimulateTransactionResp{ + Context: SimulateTransactionRespContext{ + ApiVersion: "mithril 0.1", + Slot: global.Slot(), + }, + Value: SimulateTransactionRespValue{ + Err: errStr, + Logs: &emptyLogs, + UnitsConsumed: &zeroUnits, + LoadedAccountsDataSize: &zeroSize, + ReplacementBlockhash: replacementBlockhash, + LoadedAddresses: loaded, + }, + } + if conf.innerInstructions { + empty := []InnerInstructionGroup{} + resp.Value.InnerInstructions = &empty + } + if conf.accounts != nil { + nullAccounts := make([]*AccountInfoPayload, len(conf.accounts.addresses)) + resp.Value.Accounts = &nullAccounts + } + return resp +} + +// ptrSlice returns a pointer to its argument. Lifts a slice into the +// Some/None shape Agave uses on the wire (nil → JSON null, non-nil → JSON array). +func ptrSlice[T any](s []T) *[]T { + if s == nil { + return nil + } + return &s +} + +func ptrSliceTokenBalance(s []TokenBalancePayload) *[]TokenBalancePayload { + if s == nil { + return nil + } + return &s +} + +// renderRequestedAccounts builds the response slice for `accounts.addresses` +// in caller-specified order. Lookup precedence: +// 1. post-execution transaction context (most up-to-date for tx accounts) +// 2. accountsdb fallback (for addresses NOT touched by the tx) +// Each entry is nil (JSON null) when the address can't be resolved. +// Always returns a slice of length len(conf.accounts.addresses), matching +// Agave so clients can index by request order. +func renderRequestedAccounts(conf simulateTransactionConfig, execCtx *sealevel.ExecutionCtx, db *accountsdb.AccountsDb, slot uint64) *[]*AccountInfoPayload { + accts := make([]*AccountInfoPayload, len(conf.accounts.addresses)) + encodingType := GetAccountEncodingBase64 + if conf.accounts.encoding != "" { + encodingType, _ = parseGetAcctDataEncodingType(conf.accounts.encoding) + } + acctConf := &GetAccountInfoConfig{EncodingType: &encodingType} + + for i, addrStr := range conf.accounts.addresses { + pk, err := solana.PublicKeyFromBase58(addrStr) + if err != nil { + continue + } + + var resolved *accounts.Account + if execCtx != nil { + for idx, acct := range execCtx.TransactionContext.Accounts.Accounts { + if acct.Key == pk { + resolved = execCtx.TransactionContext.Accounts.Accounts[idx] + break + } + } + } + if resolved == nil && db != nil { + if dbAcct, dbErr := db.GetAccount(slot, pk); dbErr == nil { + resolved = dbAcct + } + } + if resolved == nil { + continue + } + + encodedData, encErr := encodeAcctDataWithConfig(resolved.Data, acctConf) + if encErr != nil { + continue + } + accts[i] = &AccountInfoPayload{ + Data: encodedData, + Executable: resolved.Executable, + Lamports: resolved.Lamports, + Owner: base58.Encode(resolved.Owner[:]), + RentEpoch: resolved.RentEpoch, + Space: len(resolved.Data), + } + } + return &accts +} + +// renderInnerInstructions converts replay's inner-instruction shape into +// the typed payload clients expect. Field encoding matches Agave: +// - data: base58 (not base64) — what UiCompiledInstruction emits +// - stackHeight: nil when not tracked, *uint8 when known +func renderInnerInstructions(lists []replay.InnerInstructionsList) []InnerInstructionGroup { + if len(lists) == 0 { + return []InnerInstructionGroup{} + } + out := make([]InnerInstructionGroup, 0, len(lists)) + for _, l := range lists { + instrs := make([]InnerInstruction, 0, len(l.Instructions)) + for _, ix := range l.Instructions { + accts := make([]uint8, len(ix.Accounts)) + copy(accts, ix.Accounts) + entry := InnerInstruction{ + ProgramIdIndex: ix.ProgramIdIndex, + Accounts: accts, + Data: base58.Encode(ix.Data), + } + if ix.StackHeight > 0 { + h := uint32(ix.StackHeight) + entry.StackHeight = &h + } + instrs = append(instrs, entry) + } + out = append(out, InnerInstructionGroup{ + Index: l.Index, + Instructions: instrs, + }) + } + return out +} + +// loadedAddressesFromTx splits the ALT-resolved keys into writable and +// readonly groups using solana-go's post-resolve ordering: +// +// [static] + [writable from each lookup, in order] + [readonly from each lookup, in order] +// +// Returns an empty payload (rather than nil) for legacy or no-lookup txs +// to keep the response shape stable for clients. +func loadedAddressesFromTx(tx *solana.Transaction) *LoadedAddressesPayload { + out := &LoadedAddressesPayload{Readonly: []string{}, Writable: []string{}} + if !tx.Message.IsVersioned() || tx.Message.AddressTableLookups.NumLookups() == 0 { + return out + } + + totalLookups := tx.Message.AddressTableLookups.NumLookups() + writableCount := tx.Message.AddressTableLookups.NumWritableLookups() + if len(tx.Message.AccountKeys) < totalLookups { + return out // not yet resolved; return empty rather than panic + } + + staticEnd := len(tx.Message.AccountKeys) - totalLookups + writableEnd := staticEnd + writableCount + + for i := staticEnd; i < writableEnd; i++ { + out.Writable = append(out.Writable, tx.Message.AccountKeys[i].String()) + } + for i := writableEnd; i < len(tx.Message.AccountKeys); i++ { + out.Readonly = append(out.Readonly, tx.Message.AccountKeys[i].String()) + } + return out +} diff --git a/pkg/rpcserver/simulate_transaction_helpers_test.go b/pkg/rpcserver/simulate_transaction_helpers_test.go new file mode 100644 index 00000000..349032c8 --- /dev/null +++ b/pkg/rpcserver/simulate_transaction_helpers_test.go @@ -0,0 +1,180 @@ +package rpcserver + +import ( + "encoding/json" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/replay" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClampLogs_UnderLimitReturnsUnchanged(t *testing.T) { + in := []string{"hello", "world"} + got := clampLogs(in) + assert.Equal(t, in, got) +} + +func TestClampLogs_AtBoundaryAppendsTruncatedMarker(t *testing.T) { + big := make([]byte, maxSimulateLogBytes/2+1) + for i := range big { + big[i] = 'a' + } + in := []string{string(big), string(big), "this should not appear"} + got := clampLogs(in) + assert.Equal(t, "Log truncated", got[len(got)-1]) + assert.NotContains(t, got, "this should not appear") +} + +func TestClampLogs_NilStaysNil(t *testing.T) { + assert.Nil(t, clampLogs(nil)) +} + +func TestClampReturnData_UnderLimitReturnsUnchanged(t *testing.T) { + in := []byte{1, 2, 3} + got := clampReturnData(in) + assert.Equal(t, in, got) +} + +func TestClampReturnData_OverLimitTruncated(t *testing.T) { + in := make([]byte, maxSimulateReturnDataBytes+500) + for i := range in { + in[i] = byte(i % 256) + } + got := clampReturnData(in) + assert.Len(t, got, maxSimulateReturnDataBytes) + assert.Equal(t, in[:maxSimulateReturnDataBytes], got) +} + +// renderInnerInstructions must encode `data` as base58 (matching Agave's +// UiCompiledInstruction.from), include `stackHeight`, and preserve the +// programIdIndex / accounts fields as numbers. +func TestRenderInnerInstructions_AgaveWireShape(t *testing.T) { + lists := []replay.InnerInstructionsList{ + {Index: 0, Instructions: []replay.CompiledInstruction{ + {ProgramIdIndex: 4, Accounts: []uint8{1, 2}, Data: []byte{0x01, 0x02, 0x03}, StackHeight: 2}, + }}, + } + out := renderInnerInstructions(lists) + require.Len(t, out, 1) + assert.Equal(t, uint8(0), out[0].Index) + + require.Len(t, out[0].Instructions, 1) + got := out[0].Instructions[0] + assert.Equal(t, uint8(4), got.ProgramIdIndex) + assert.Equal(t, []uint8{1, 2}, got.Accounts) + assert.Equal(t, "Ldp", got.Data, "data must be base58-encoded, not base64") + require.NotNil(t, got.StackHeight) + assert.Equal(t, uint32(2), *got.StackHeight, "stackHeight must serialize as Option per Agave wire format") + + // JSON shape sanity: round-trip and confirm keys. + raw, err := json.Marshal(out) + require.NoError(t, err) + for _, want := range []string{`"index":0`, `"programIdIndex":4`, `"data":"Ldp"`, `"stackHeight":2`} { + assert.Contains(t, string(raw), want) + } +} + +func TestRenderInnerInstructions_EmptyReturnsEmptyArray(t *testing.T) { + out := renderInnerInstructions(nil) + assert.NotNil(t, out, "must be [], not nil, so JSON marshals as [] not null") + assert.Len(t, out, 0) +} + +func TestParseSimulateConfig_MinContextSlot(t *testing.T) { + params := []interface{}{ + "unused-tx-string", + map[string]interface{}{"minContextSlot": float64(417500000)}, + } + conf := parseSimulateConfig(params) + if assert.NotNil(t, conf.minContextSlot) { + assert.Equal(t, uint64(417500000), *conf.minContextSlot) + } +} + +func TestParseSimulateConfig_Commitment(t *testing.T) { + params := []interface{}{ + "unused-tx-string", + map[string]interface{}{"commitment": "confirmed"}, + } + conf := parseSimulateConfig(params) + assert.Equal(t, "confirmed", conf.commitment) +} + +func TestParseSimulateConfig_DefaultsOmitOptional(t *testing.T) { + conf := parseSimulateConfig([]interface{}{"unused", map[string]interface{}{}}) + assert.Nil(t, conf.minContextSlot) + assert.Empty(t, conf.commitment) +} + +func TestPostBalancesFromExecCtx_Nil(t *testing.T) { + assert.Nil(t, postBalancesFromExecCtx(nil)) +} + +func TestPostBalancesFromExecCtx_ReadsLamportsInOrder(t *testing.T) { + ax := &accounts.Account{Lamports: 100} + bx := &accounts.Account{Lamports: 200} + cx := &accounts.Account{Lamports: 300} + + txAccts := sealevel.NewTransactionAccountsFromRefs( + []*accounts.Account{ax, bx, cx}, + []bool{false, false, false}, + ) + execCtx := &sealevel.ExecutionCtx{ + TransactionContext: sealevel.NewTransactionCtx(*txAccts, 1, 1), + } + + got := postBalancesFromExecCtx(execCtx) + assert.Equal(t, []uint64{100, 200, 300}, got) +} + +func TestLoadedAddressesFromTx_LegacyReturnsEmpty(t *testing.T) { + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: []solana.PublicKey{{1}, {2}, {3}}, + }, + } + got := loadedAddressesFromTx(tx) + assert.NotNil(t, got) + assert.Equal(t, []string{}, got.Writable) + assert.Equal(t, []string{}, got.Readonly) +} + +func TestLoadedAddressesFromTx_VersionedSplitsWritableThenReadonly(t *testing.T) { + staticA := solana.PublicKey{0xA1} + staticB := solana.PublicKey{0xA2} + wA := solana.PublicKey{0xB1} + wB := solana.PublicKey{0xB2} + rA := solana.PublicKey{0xC1} + rB := solana.PublicKey{0xC2} + + msg := solana.Message{ + AccountKeys: []solana.PublicKey{staticA, staticB, wA, wB, rA, rB}, + AddressTableLookups: solana.MessageAddressTableLookupSlice{ + { + AccountKey: solana.PublicKey{0xFF, 1}, + WritableIndexes: []uint8{0, 1}, + ReadonlyIndexes: []uint8{0, 1}, + }, + }, + } + msg.SetVersion(solana.MessageVersionV0) + + got := loadedAddressesFromTx(&solana.Transaction{Message: msg}) + assert.Equal(t, []string{wA.String(), wB.String()}, got.Writable) + assert.Equal(t, []string{rA.String(), rB.String()}, got.Readonly) +} + +func TestLoadedAddressesFromTx_VersionedNoLookupsReturnsEmpty(t *testing.T) { + msg := solana.Message{ + AccountKeys: []solana.PublicKey{{1}, {2}}, + } + msg.SetVersion(solana.MessageVersionV0) + + got := loadedAddressesFromTx(&solana.Transaction{Message: msg}) + assert.Empty(t, got.Writable) + assert.Empty(t, got.Readonly) +} diff --git a/pkg/rpcserver/simulate_transaction_response_test.go b/pkg/rpcserver/simulate_transaction_response_test.go new file mode 100644 index 00000000..53638677 --- /dev/null +++ b/pkg/rpcserver/simulate_transaction_response_test.go @@ -0,0 +1,215 @@ +package rpcserver + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// All 14 spec-mandated fields must always be present (non-error happy path) +// — Agave emits them unconditionally as either real values or null. +func TestSimulateTransactionResp_JSONShape_AllSpecFieldsPresent(t *testing.T) { + fee := uint64(5000) + units := uint64(100) + dataSize := uint32(1024) + logs := []string{"a", "b"} + bals := []uint64{1, 2} + tokenBals := []TokenBalancePayload{} + + resp := SimulateTransactionResp{ + Context: SimulateTransactionRespContext{ApiVersion: "test", Slot: 42}, + Value: SimulateTransactionRespValue{ + Err: nil, + Logs: &logs, + Accounts: nil, + UnitsConsumed: &units, + ReturnData: nil, + InnerInstructions: nil, + ReplacementBlockhash: nil, + LoadedAccountsDataSize: &dataSize, + Fee: &fee, + PreBalances: &bals, + PostBalances: &bals, + PreTokenBalances: &tokenBals, + PostTokenBalances: &tokenBals, + LoadedAddresses: &LoadedAddressesPayload{Readonly: []string{}, Writable: []string{}}, + }, + } + + raw, err := json.Marshal(resp) + require.NoError(t, err) + + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &decoded)) + + value, ok := decoded["value"].(map[string]interface{}) + require.True(t, ok, "response must contain a value object") + + specRequiredKeys := []string{ + "err", + "logs", + "accounts", + "unitsConsumed", + "returnData", + "innerInstructions", + "replacementBlockhash", + "loadedAccountsDataSize", + "fee", + "preBalances", + "postBalances", + "preTokenBalances", + "postTokenBalances", + "loadedAddresses", + } + + for _, key := range specRequiredKeys { + _, present := value[key] + assert.True(t, present, "spec-required field %q missing from value", key) + } +} + +// Every value field is emitted unconditionally; nil maps to JSON null. +// Agave does not omit any field — strict-shape clients depend on this. +func TestSimulateTransactionResp_JSONShape_NilFieldsRenderAsNull(t *testing.T) { + resp := SimulateTransactionResp{ + Context: SimulateTransactionRespContext{ApiVersion: "x", Slot: 1}, + Value: SimulateTransactionRespValue{}, + } + + raw, err := json.Marshal(resp) + require.NoError(t, err) + + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &decoded)) + value := decoded["value"].(map[string]interface{}) + + for _, key := range []string{ + "err", "logs", "accounts", "unitsConsumed", "returnData", + "innerInstructions", "replacementBlockhash", "loadedAccountsDataSize", + "fee", "preBalances", "postBalances", + "preTokenBalances", "postTokenBalances", "loadedAddresses", + } { + v, present := value[key] + assert.True(t, present, "field %q must always be emitted", key) + assert.Nil(t, v, "field %q must serialize as null when unset", key) + } +} + +func TestSimulateTransactionResp_JSONShape_LoadedAddressesNested(t *testing.T) { + resp := SimulateTransactionResp{ + Value: SimulateTransactionRespValue{ + LoadedAddresses: &LoadedAddressesPayload{ + Readonly: []string{"r1", "r2"}, + Writable: []string{"w1"}, + }, + }, + } + + raw, err := json.Marshal(resp) + require.NoError(t, err) + + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &decoded)) + la := decoded["value"].(map[string]interface{})["loadedAddresses"].(map[string]interface{}) + + assert.ElementsMatch(t, []interface{}{"r1", "r2"}, la["readonly"]) + assert.ElementsMatch(t, []interface{}{"w1"}, la["writable"]) +} + +func TestSimulateTransactionResp_JSONShape_FeeAsTopLevelUint64(t *testing.T) { + fee := uint64(12345) + resp := SimulateTransactionResp{ + Value: SimulateTransactionRespValue{Fee: &fee}, + } + raw, err := json.Marshal(resp) + require.NoError(t, err) + assert.Contains(t, string(raw), `"fee":12345`) +} + +// When the simulator runs an InstructionError, the response must carry +// the structured Agave shape ({"InstructionError":[idx,…]}), not a Go- +// stringified version. Pass-through of *replay.TransactionError into the +// Err field must hit MarshalJSON, not Error(). +func TestSimulateTransactionResp_JSONShape_InstructionErrorAgaveShape(t *testing.T) { + idx := uint8(2) + resp := SimulateTransactionResp{ + Value: SimulateTransactionRespValue{ + Err: &replayTransactionErrorForTest{ + ErrorTypeName: "InstructionError", + InstructionIndex: idx, + InnerJSON: `{"Custom":42}`, + }, + }, + } + raw, err := json.Marshal(resp) + require.NoError(t, err) + got := string(raw) + assert.Contains(t, got, `"err":{"InstructionError":[2,{"Custom":42}]}`) +} + +// replayTransactionErrorForTest is a minimal stand-in that mimics the +// replay package's MarshalJSON contract without importing it (avoids a +// circular path here in the rpcserver test). The real handler passes +// the actual *replay.TransactionError through, which has the same +// MarshalJSON behavior — verified separately in pkg/replay tests. +type replayTransactionErrorForTest struct { + ErrorTypeName string + InstructionIndex uint8 + InnerJSON string +} + +func (e *replayTransactionErrorForTest) MarshalJSON() ([]byte, error) { + return []byte(`{"` + e.ErrorTypeName + `":[` + + strconvU8(e.InstructionIndex) + `,` + e.InnerJSON + `]}`), nil +} + +func strconvU8(n uint8) string { + if n == 0 { + return "0" + } + out := []byte{} + for n > 0 { + out = append([]byte{byte('0' + n%10)}, out...) + n /= 10 + } + return string(out) +} + +// Agave SignatureFailure fixture (rpc.rs:6147-6170) emits this exact shape. +// Mithril's response on the equivalent path must match — strict-typed +// clients depend on every field being present. +func TestSimulateTransactionResp_JSONShape_SignatureFailureMatchesAgave(t *testing.T) { + zeroUnits := uint64(0) + zeroSize := uint32(0) + logs := []string{} + resp := SimulateTransactionResp{ + Value: SimulateTransactionRespValue{ + Err: "SignatureFailure", + Logs: &logs, + UnitsConsumed: &zeroUnits, + LoadedAccountsDataSize: &zeroSize, + LoadedAddresses: &LoadedAddressesPayload{Readonly: []string{}, Writable: []string{}}, + }, + } + + raw, err := json.Marshal(resp) + require.NoError(t, err) + + got := string(raw) + for _, want := range []string{ + `"err":"SignatureFailure"`, + `"logs":[]`, + `"unitsConsumed":0`, + `"loadedAccountsDataSize":0`, + `"loadedAddresses":{"readonly":[],"writable":[]}`, + `"fee":null`, + `"preBalances":null`, + `"postBalances":null`, + `"preTokenBalances":null`, + `"postTokenBalances":null`, + } { + assert.Contains(t, got, want, "Agave-shape SignatureFailure response missing %q", want) + } +} diff --git a/pkg/rpcserver/token_balances.go b/pkg/rpcserver/token_balances.go new file mode 100644 index 00000000..b44e87f6 --- /dev/null +++ b/pkg/rpcserver/token_balances.go @@ -0,0 +1,198 @@ +package rpcserver + +import ( + "encoding/binary" + "strconv" + "strings" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + "github.com/gagliardetto/solana-go" +) + +// SPL Token program IDs. +var ( + splTokenProgramID = solana.MustPublicKeyFromBase58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") + splToken2022ProgramID = solana.MustPublicKeyFromBase58("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb") +) + +// SPL Token Account is exactly 165 bytes; offsets are stable across both +// the legacy program and Token-2022 (Token-2022 stores extension data +// past byte 165, but the leading account state has the same layout). +const ( + tokenAccountSize = 165 + tokenAccountMintOffset = 0 + tokenAccountOwnerOffset = 32 + tokenAccountAmountOffset = 64 + // Mint account layout: 82 bytes for legacy SPL Token. decimals lives at + // offset 44 (mintAuthorityOption 4 + mintAuthority 32 + supply 8 = 44). + mintAccountSize = 82 + mintDecimalsOffset = 44 +) + +// isTokenProgramOwner reports whether an account is owned by either the +// legacy SPL Token program or Token-2022. Both programs use the same +// account layout for the leading 165 bytes. +func isTokenProgramOwner(owner [32]byte) bool { + return owner == [32]byte(splTokenProgramID) || owner == [32]byte(splToken2022ProgramID) +} + +// extractTokenBalances walks the supplied transaction accounts and emits +// a TokenBalancePayload for each one that is recognized as an SPL Token +// account. mintDecimals is consulted for the per-mint decimal count; +// callers populate it from accountsdb. Missing decimals fall back to 0 +// rather than skipping the account so pre/post arrays stay aligned with +// the transaction's account index. +func extractTokenBalances( + txAccts []*accounts.Account, + mintDecimals map[solana.PublicKey]uint8, +) []TokenBalancePayload { + if len(txAccts) == 0 { + return []TokenBalancePayload{} + } + out := make([]TokenBalancePayload, 0) + for idx, acct := range txAccts { + if acct == nil || !isTokenProgramOwner(acct.Owner) { + continue + } + if len(acct.Data) < tokenAccountSize { + continue + } + mint := publicKeyFromOffset(acct.Data, tokenAccountMintOffset) + owner := publicKeyFromOffset(acct.Data, tokenAccountOwnerOffset) + amount := binary.LittleEndian.Uint64(acct.Data[tokenAccountAmountOffset : tokenAccountAmountOffset+8]) + + decimals := mintDecimals[mint] + programID := splTokenProgramID + if acct.Owner == [32]byte(splToken2022ProgramID) { + programID = splToken2022ProgramID + } + + out = append(out, TokenBalancePayload{ + AccountIndex: uint8(idx), + Mint: mint.String(), + Owner: owner.String(), + ProgramId: programID.String(), + UiTokenAmount: UiTokenAmountPayload{ + Amount: strconv.FormatUint(amount, 10), + Decimals: decimals, + UiAmount: uiAmountForRaw(amount, decimals), + UiAmountString: uiAmountStringForRaw(amount, decimals), + }, + }) + } + return out +} + +// fetchMintDecimals collects the unique mints referenced by token +// accounts in txAccts and looks each one up in accountsdb to extract its +// decimals byte. Mints that fail the size check or aren't found are +// omitted; callers default missing entries to 0. +func fetchMintDecimals( + txAccts []*accounts.Account, + db *accountsdb.AccountsDb, + slot uint64, +) map[solana.PublicKey]uint8 { + out := make(map[solana.PublicKey]uint8) + if db == nil { + return out + } + seen := make(map[solana.PublicKey]struct{}) + for _, acct := range txAccts { + if acct == nil || !isTokenProgramOwner(acct.Owner) { + continue + } + if len(acct.Data) < tokenAccountSize { + continue + } + mint := publicKeyFromOffset(acct.Data, tokenAccountMintOffset) + if _, ok := seen[mint]; ok { + continue + } + seen[mint] = struct{}{} + + mintAcct, err := db.GetAccount(slot, mint) + if err != nil || mintAcct == nil || len(mintAcct.Data) < mintAccountSize { + continue + } + out[mint] = mintAcct.Data[mintDecimalsOffset] + } + return out +} + +func publicKeyFromOffset(data []byte, off int) solana.PublicKey { + var pk solana.PublicKey + copy(pk[:], data[off:off+32]) + return pk +} + +// uiAmountForRaw returns the raw token amount divided by 10^decimals as +// a float64. Matches Agave's lossy float conversion; precision suffers +// for amounts > 2^53 but this is the wire contract. +func uiAmountForRaw(amount uint64, decimals uint8) *float64 { + if decimals == 0 { + v := float64(amount) + return &v + } + v := float64(amount) / pow10Float(decimals) + return &v +} + +// uiAmountStringForRaw renders amount/10^decimals as a decimal string +// with trailing zeros trimmed, matching Agave's UiAmountString. Pure +// string manipulation so precision is preserved for any decimals up to +// uint8 max (255) — Token-2022 doesn't bound decimals at the protocol +// level, and float64-based division saturates uint64 cast at decimals≥20. +func uiAmountStringForRaw(amount uint64, decimals uint8) string { + if amount == 0 { + return "0" + } + digits := strconv.FormatUint(amount, 10) + if decimals == 0 { + return digits + } + d := int(decimals) + if d >= len(digits) { + // Whole part is zero; pad fractional with leading zeros. + frac := strings.Repeat("0", d-len(digits)) + digits + frac = strings.TrimRight(frac, "0") + if frac == "" { + return "0" + } + return "0." + frac + } + whole := digits[:len(digits)-d] + frac := strings.TrimRight(digits[len(digits)-d:], "0") + if frac == "" { + return whole + } + return whole + "." + frac +} + +// pow10Float returns 10^n as a float64. Used only by uiAmountForRaw +// (the *float64 path); the string path uses index math instead. +func pow10Float(n uint8) float64 { + out := 1.0 + for i := uint8(0); i < n; i++ { + out *= 10 + } + return out +} + +// tokenBalancesFromAccounts wraps mint-decimal lookup + decoding so the +// simulate handler can build pre/post arrays from a slice of accounts. +// Returns nil when txAccts is nil (no accounts ever loaded — e.g. sanitize +// failure path) so the response emits JSON null, matching Agave's +// behavior on early-fail paths. Returns an empty slice for explicitly +// empty input or non-token transactions. +func tokenBalancesFromAccounts(txAccts []*accounts.Account, db *accountsdb.AccountsDb, slot uint64) []TokenBalancePayload { + if txAccts == nil { + return nil + } + if len(txAccts) == 0 { + return []TokenBalancePayload{} + } + mintDecimals := fetchMintDecimals(txAccts, db, slot) + return extractTokenBalances(txAccts, mintDecimals) +} + diff --git a/pkg/rpcserver/token_balances_test.go b/pkg/rpcserver/token_balances_test.go new file mode 100644 index 00000000..b5ac0b85 --- /dev/null +++ b/pkg/rpcserver/token_balances_test.go @@ -0,0 +1,129 @@ +package rpcserver + +import ( + "encoding/binary" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeTokenAccount builds a 165-byte SPL Token account payload. +func makeTokenAccount(t *testing.T, mint, owner solana.PublicKey, amount uint64) *accounts.Account { + t.Helper() + data := make([]byte, tokenAccountSize) + copy(data[tokenAccountMintOffset:], mint[:]) + copy(data[tokenAccountOwnerOffset:], owner[:]) + binary.LittleEndian.PutUint64(data[tokenAccountAmountOffset:], amount) + var ownerArr [32]byte + copy(ownerArr[:], splTokenProgramID[:]) + return &accounts.Account{Owner: ownerArr, Data: data} +} + +func TestExtractTokenBalances_DecodesMintOwnerAmount(t *testing.T) { + mint := solana.MustPublicKeyFromBase58("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + owner := solana.MustPublicKeyFromBase58("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB") + tokenAcct := makeTokenAccount(t, mint, owner, 1234567) + + got := extractTokenBalances( + []*accounts.Account{tokenAcct}, + map[solana.PublicKey]uint8{mint: 6}, + ) + require.Len(t, got, 1) + assert.Equal(t, mint.String(), got[0].Mint) + assert.Equal(t, owner.String(), got[0].Owner) + assert.Equal(t, splTokenProgramID.String(), got[0].ProgramId) + assert.Equal(t, "1234567", got[0].UiTokenAmount.Amount) + assert.Equal(t, uint8(6), got[0].UiTokenAmount.Decimals) + require.NotNil(t, got[0].UiTokenAmount.UiAmount) + assert.InDelta(t, 1.234567, *got[0].UiTokenAmount.UiAmount, 1e-9) + assert.Equal(t, "1.234567", got[0].UiTokenAmount.UiAmountString) +} + +func TestExtractTokenBalances_SkipsNonTokenAccounts(t *testing.T) { + wallet := &accounts.Account{Owner: [32]byte{1, 2, 3}, Data: make([]byte, 165)} + got := extractTokenBalances([]*accounts.Account{wallet}, map[solana.PublicKey]uint8{}) + assert.Empty(t, got) +} + +func TestExtractTokenBalances_EmptyInputReturnsEmpty(t *testing.T) { + got := extractTokenBalances(nil, nil) + assert.NotNil(t, got) + assert.Empty(t, got) +} + +func TestExtractTokenBalances_TruncatedDataIsSkipped(t *testing.T) { + var ownerArr [32]byte + copy(ownerArr[:], splTokenProgramID[:]) + short := &accounts.Account{Owner: ownerArr, Data: make([]byte, 100)} + got := extractTokenBalances([]*accounts.Account{short}, nil) + assert.Empty(t, got) +} + +func TestExtractTokenBalances_MissingDecimalsDefaultsToZero(t *testing.T) { + mint := solana.MustPublicKeyFromBase58("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + owner := solana.MustPublicKeyFromBase58("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB") + tokenAcct := makeTokenAccount(t, mint, owner, 42) + + got := extractTokenBalances([]*accounts.Account{tokenAcct}, nil) + require.Len(t, got, 1) + assert.Equal(t, "42", got[0].UiTokenAmount.Amount) + assert.Equal(t, uint8(0), got[0].UiTokenAmount.Decimals) + assert.Equal(t, "42", got[0].UiTokenAmount.UiAmountString) +} + +func TestExtractTokenBalances_Token2022ProgramIdDistinguished(t *testing.T) { + mint := solana.MustPublicKeyFromBase58("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + owner := solana.MustPublicKeyFromBase58("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB") + tokenAcct := makeTokenAccount(t, mint, owner, 1) + var ownerArr [32]byte + copy(ownerArr[:], splToken2022ProgramID[:]) + tokenAcct.Owner = ownerArr + + got := extractTokenBalances([]*accounts.Account{tokenAcct}, map[solana.PublicKey]uint8{mint: 9}) + require.Len(t, got, 1) + assert.Equal(t, splToken2022ProgramID.String(), got[0].ProgramId) +} + +func TestUiAmountStringForRaw_TrimsTrailingZeros(t *testing.T) { + assert.Equal(t, "1.5", uiAmountStringForRaw(15_000_000, 7)) + assert.Equal(t, "1.234567", uiAmountStringForRaw(1_234_567, 6)) + assert.Equal(t, "1", uiAmountStringForRaw(1_000_000, 6)) + assert.Equal(t, "0.0001", uiAmountStringForRaw(100, 6)) + assert.Equal(t, "0", uiAmountStringForRaw(0, 6)) +} + +func TestUiAmountStringForRaw_ZeroDecimals(t *testing.T) { + assert.Equal(t, "12345", uiAmountStringForRaw(12345, 0)) +} + +// High-decimals (>= 20) cases — Token-2022 doesn't bound decimals at the +// protocol level so the renderer must stay precise even past float64 +// representable range. Float-based division saturates at uint64(1e20). +func TestUiAmountStringForRaw_HighDecimalsPrecise(t *testing.T) { + const maxU64 = uint64(18446744073709551615) + cases := []struct { + amount uint64 + decimals uint8 + want string + }{ + {maxU64, 20, "0.18446744073709551615"}, + {maxU64, 25, "0.0000018446744073709551615"}, + {1, 20, "0.00000000000000000001"}, + {0, 20, "0"}, + {0, 0, "0"}, + {1, 0, "1"}, + } + for _, c := range cases { + got := uiAmountStringForRaw(c.amount, c.decimals) + assert.Equal(t, c.want, got, "amount=%d decimals=%d", c.amount, c.decimals) + } +} + +func TestUiAmountStringForRaw_LargeAmountModerateDecimals(t *testing.T) { + // 9007199254740993 is 2^53 + 1, the first integer not exactly + // representable in float64. The string path must still be exact. + assert.Equal(t, "9007199254.740993", uiAmountStringForRaw(9007199254740993, 6)) +} diff --git a/pkg/sealevel/execution_ctx.go b/pkg/sealevel/execution_ctx.go index c74aab75..0739e8d5 100644 --- a/pkg/sealevel/execution_ctx.go +++ b/pkg/sealevel/execution_ctx.go @@ -27,6 +27,30 @@ type ExecutionCtx struct { SlotCtx *SlotCtx ModifiedVoteStates map[solana.PublicKey]*VoteStateVersions IsSimulation bool + + // RecordInnerInstructions enables capture of CPI instructions into + // InnerInstrs. Off by default; the simulate handler enables it when + // the caller requests innerInstructions in the RPC response. + RecordInnerInstructions bool + currentTopLevelInstrIdx uint8 + InnerInstrs []RecordedInnerInstr +} + +// RecordedInnerInstr is a CPI invocation captured during execution. +// Replay-layer code groups records by TopLevelIdx into the response shape. +type RecordedInnerInstr struct { + TopLevelIdx uint8 + StackHeight uint8 + ProgramIdIndex uint8 + Accounts []uint8 + Data []byte +} + +// SetCurrentTopLevelInstr is called by the replay loop before dispatching +// each top-level instruction so subsequent CPI captures know which entry +// in the response's innerInstructions array to belong to. +func (execCtx *ExecutionCtx) SetCurrentTopLevelInstr(idx uint8) { + execCtx.currentTopLevelInstrIdx = idx } type SlotBank struct { @@ -185,6 +209,25 @@ func (execCtx *ExecutionCtx) ProcessInstruction(instrData []byte, instructionAcc nextInstrCtx.Configure(programIndices, instructionAccts, instrData) metrics.GlobalBlockReplay.NextIxCtxConfigure.AddTimingSince(start) + // Capture this invocation as an inner instruction when nested under + // an active top-level frame and recording is enabled. Stack height + // before Push reflects parent depth: 0 = top-level, >=1 = CPI. + if execCtx.RecordInnerInstructions && len(programIndices) > 0 && execCtx.StackHeight() >= 1 { + acctIdxs := make([]uint8, 0, len(instructionAccts)) + for _, ia := range instructionAccts { + acctIdxs = append(acctIdxs, uint8(ia.IndexInTransaction)) + } + dataCopy := make([]byte, len(instrData)) + copy(dataCopy, instrData) + execCtx.InnerInstrs = append(execCtx.InnerInstrs, RecordedInnerInstr{ + TopLevelIdx: execCtx.currentTopLevelInstrIdx, + StackHeight: uint8(execCtx.StackHeight() + 1), + ProgramIdIndex: uint8(programIndices[0]), + Accounts: acctIdxs, + Data: dataCopy, + }) + } + start = time.Now() err = execCtx.Push() if err != nil {