From 31f5f5568731724338754e678da7db3892576a79 Mon Sep 17 00:00:00 2001 From: Rupansh Sekar Date: Wed, 11 Feb 2026 20:37:11 +0530 Subject: [PATCH 1/7] refactor(replay): add a pure function for transaction processing --- pkg/replay/transaction.go | 221 ++++------- pkg/replay/transaction_processing_pure.go | 432 +++++++++++++++++++++ pkg/replay/transaction_processing_types.go | 307 +++++++++++++++ 3 files changed, 807 insertions(+), 153 deletions(-) create mode 100644 pkg/replay/transaction_processing_pure.go create mode 100644 pkg/replay/transaction_processing_types.go diff --git a/pkg/replay/transaction.go b/pkg/replay/transaction.go index 66632299..a3035699 100644 --- a/pkg/replay/transaction.go +++ b/pkg/replay/transaction.go @@ -18,9 +18,7 @@ import ( "github.com/Overclock-Validator/mithril/pkg/fees" "github.com/Overclock-Validator/mithril/pkg/global" "github.com/Overclock-Validator/mithril/pkg/metrics" - "github.com/Overclock-Validator/mithril/pkg/migration" "github.com/Overclock-Validator/mithril/pkg/mlog" - "github.com/Overclock-Validator/mithril/pkg/rent" "github.com/Overclock-Validator/mithril/pkg/sealevel" "github.com/Overclock-Validator/mithril/pkg/util" bin "github.com/gagliardetto/binary" @@ -316,10 +314,6 @@ func ProcessTransaction(slotCtx *sealevel.SlotCtx, sigverifyWg *sync.WaitGroup, } } - if arena != nil { - arena.Reset() - } - start := time.Now() sigverifyWg.Add(1) go verifySignatures(tx, slotCtx.Slot, sigverifyWg) @@ -329,171 +323,108 @@ func ProcessTransaction(slotCtx *sealevel.SlotCtx, sigverifyWg *sync.WaitGroup, defer mlog.Log.DisableInfLogging() } - instrs, acctMetasPerInstr, programIDSet, err := instrsAndAcctMetasFromTx(tx, slotCtx.Features) - if err != nil { - return nil, err - } - metrics.GlobalBlockReplay.InstructionsAndAccountMetasFromTx.AddTimingSince(start) - - start = time.Now() - computeBudgetLimits, err := sealevel.ComputeBudgetExecuteInstructions(instrs, slotCtx.Features) - if err != nil { - return nil, err - } - metrics.GlobalBlockReplay.ComputeBudgetExecutionInstructions.AddTimingSince(start) - - if !sealevel.IsTransactionAgeValid(tx, instrs, slotCtx) { - return nil, TxErrInvalidBlockhash - } - - instrsAcct := sealevel.MakeInstructionsSysvarAccount(instrs) - - start = time.Now() - var transactionAccts *sealevel.TransactionAccounts - var txAcctMetas []*solana.AccountMeta - - if slotCtx.Features.IsActive(features.FormalizeLoadedTransactionDataSize) { - transactionAccts, txAcctMetas, err = loadAndValidateTxAcctsSimd186(slotCtx, acctMetasPerInstr, tx, instrs, instrsAcct, computeBudgetLimits.LoadedAccountBytes) - } else { - transactionAccts, txAcctMetas, err = loadAndValidateTxAccts(slotCtx, acctMetasPerInstr, tx, instrs, instrsAcct, computeBudgetLimits.LoadedAccountBytes) - } - if err == TxErrMaxLoadedAccountsDataSizeExceeded || err == TxErrInvalidProgramForExecution || err == TxErrProgramAccountNotFound { - return handleFailedTx(slotCtx, tx, instrs, computeBudgetLimits, err, nil) - } else if err != nil { - return nil, err + // Execute via pure function + input := LoadAndExecuteTransactionInput{ + SlotCtx: slotCtx, + Transaction: tx, + Arena: arena, + TxMeta: txMeta, } - metrics.GlobalBlockReplay.AccountsFromTx.AddTimingSince(start) + output := LoadAndExecuteTransaction(input) - var log sealevel.LogRecorder - execCtx := newExecCtx(slotCtx, transactionAccts, computeBudgetLimits, &log) - execCtx.TransactionContext.AllInstructions = instrs - execCtx.TransactionContext.Signature = tx.Signatures[0] - execCtx.TransactionContext.BorrowedAccountArena = arena + execCtx := output.ExecCtx + instrs := output.Instrs + computeBudgetLimits := output.ComputeBudgetLimits - start = time.Now() - // check for pre-balance divergences - if txMeta != nil { + // Pre-balance divergence check (uses pre-fee-deduction lamports from pure function output) + start := time.Now() + if txMeta != nil && output.PreBalances != nil && execCtx != nil { for count := uint64(0); count < uint64(len(tx.Message.AccountKeys)); count++ { - txAcct, err := execCtx.TransactionContext.Accounts.GetAccount(count) - if err != nil { - panic(fmt.Sprintf("unable to get tx acct %d whilst checking for pre-balances divergences", count)) - } + txAcct := execCtx.TransactionContext.Accounts.Accounts[count] if dbgOpts.IsDebugTx(tx.Signatures[0]) { // Avoid calling util.PrettyPrintAcct when not debug logging. ////mlog.Log.Debugf("pre-balance account used in tx=%s: %s", tx.Signatures[0], util.PrettyPrintAcct(txAcct)) } if !isNativeProgram(txAcct.Key) && !txAcct.IsDummy { - if txAcct.Lamports != txMeta.PreBalances[count] { + if output.PreBalances[count] != txMeta.PreBalances[count] { mlog.Log.Errorf("[run:%s] DIVERGENCE in slot %d: tx %s pre-balance mismatch for %s: mithril=%d, onchain=%d", - CurrentRunID, slotCtx.Slot, tx.Signatures[0], txAcct.Key, txAcct.Lamports, txMeta.PreBalances[count]) - panic(fmt.Sprintf("tx %s pre-balance divergence: lamport balance for %s was %d but onchain lamport balance was %d\n%s", tx.Signatures[0], txAcct.Key, txAcct.Lamports, txMeta.PreBalances[count], util.PrettyPrintAcct(txAcct))) + CurrentRunID, slotCtx.Slot, tx.Signatures[0], txAcct.Key, output.PreBalances[count], txMeta.PreBalances[count]) + panic(fmt.Sprintf("tx %s pre-balance divergence: lamport balance for %s was %d but onchain lamport balance was %d\n%s", tx.Signatures[0], txAcct.Key, output.PreBalances[count], txMeta.PreBalances[count], util.PrettyPrintAcct(txAcct))) } } - - execCtx.TransactionContext.Accounts.Unlock(count) } } metrics.GlobalBlockReplay.PreBalanceDivergenceCheck.AddTimingSince(start) - start = time.Now() - txFeeInfo, _, err := fees.CalculateAndDeductTxFees(tx, txMeta, instrs, &execCtx.TransactionContext.Accounts, computeBudgetLimits, slotCtx.Features) - if err != nil { - return txFeeInfo, nil - } + // Handle transaction errors from the pure function + if output.ProcessingResult.TransactionError != nil { + txErr := output.ProcessingResult.TransactionError - metrics.GlobalBlockReplay.CalcAndDeductFees.AddTimingSince(start) + switch txErr.ErrorType { + case TransactionErrorSanitizeFailure: + return nil, txErr.InstructionError - // check for fee divergences - if txMeta != nil && txFeeInfo.TotalFee != txMeta.Fee { - mlog.Log.Errorf("[run:%s] DIVERGENCE in slot %d: tx %s fee mismatch: mithril=%d, onchain=%d", - CurrentRunID, slotCtx.Slot, tx.Signatures[0], txFeeInfo.TotalFee, txMeta.Fee) - panic(fmt.Sprintf("tx %s fee divergence: totalFee was %d, but onchain fee was %d", tx.Signatures[0], txFeeInfo.TotalFee, txMeta.Fee)) - } + case TransactionErrorBlockhashNotFound: + return nil, TxErrInvalidBlockhash - start = time.Now() - rentSysvar, err := sealevel.ReadRentSysvar(execCtx) - if err != nil { - panic("failed to get and deserialize rent sysvar") - } - metrics.GlobalBlockReplay.ReadRentSysvar.AddTimingSince(start) + case TransactionErrorMaxLoadedAccountsDataSizeExceeded, + TransactionErrorInvalidProgramForExecution, + TransactionErrorProgramAccountNotFound: + return handleFailedTx(slotCtx, tx, instrs, computeBudgetLimits, txErr.InstructionError, nil) - start = time.Now() - rent.MaybeSetRentExemptRentEpochMax(slotCtx, &rentSysvar, &execCtx.Features, &execCtx.TransactionContext.Accounts) - preTxRentStates := rent.NewRentStateInfo(&rentSysvar, execCtx.TransactionContext, &execCtx.Features, programIDSet) - metrics.GlobalBlockReplay.PreTxRentStates.AddTimingSince(start) + case TransactionErrorInsufficientFundsForFee: + // CalculateAndDeductTxFees failed - return fee info with nil error (matches original behavior) + return output.FeeInfo, nil - var instrErr error - writablePubkeys := make([]solana.PublicKey, 0, 64) + case TransactionErrorInstructionError: + return handleFailedTx(slotCtx, tx, instrs, computeBudgetLimits, txErr.InstructionError, nil) - start = time.Now() - for instrIdx, instr := range tx.Message.Instructions { - ixStart := time.Now() - err = fixupInstructionsSysvarAcct(execCtx, uint16(instrIdx)) - if err != nil { - return txFeeInfo, err - } - metrics.GlobalBlockReplay.FixupInstructionsSysvarAccount.AddTimingSince(ixStart) + case TransactionErrorInsufficientFundsForRent: + return handleFailedTx(slotCtx, tx, instrs, computeBudgetLimits, nil, txErr.InstructionError) - ixStart = time.Now() - acctMetas := acctMetasPerInstr[instrIdx] - instructionAccts := sealevel.InstructionAcctsFromAccountMetas(acctMetas, *transactionAccts) - metrics.GlobalBlockReplay.InstructionAccountsFromAccountMetas.AddTimingSince(ixStart) - - programId := tx.Message.AccountKeys[instr.ProgramIDIndex] - migratingCus, isMigrating := migration.IsMigratingProgramAndGetCUs(programId) - if isMigrating { - err = execCtx.ComputeMeter.Consume(migratingCus) - if err != nil { - instrErr = err - break - } - execCtx.ComputeMeter.Disable() + default: + return nil, txErr.InstructionError } + } - err = execCtx.ProcessInstruction(instr.Data, instructionAccts, programIndices(tx, instrIdx)) - if err == nil { - for _, am := range acctMetas { - if am.IsWritable { - writablePubkeys = append(writablePubkeys, am.Pubkey) - } - } - if isMigrating { - execCtx.ComputeMeter.Enable() - } - } else { - instrErr = err - break - } + // Successful execution path + processedTx := output.ProcessingResult.ProcessedTransaction + executedTx := processedTx.Executed + txFeeInfo := output.FeeInfo + + // Fee divergence check + if txMeta != nil && txFeeInfo.TotalFee != txMeta.Fee { + mlog.Log.Errorf("[run:%s] DIVERGENCE in slot %d: tx %s fee mismatch: mithril=%d, onchain=%d", + CurrentRunID, slotCtx.Slot, tx.Signatures[0], txFeeInfo.TotalFee, txMeta.Fee) + panic(fmt.Sprintf("tx %s fee divergence: totalFee was %d, but onchain fee was %d", tx.Signatures[0], txFeeInfo.TotalFee, txMeta.Fee)) } - metrics.GlobalBlockReplay.IxLoop.AddTimingSince(start) + // Debug logging if dbgOpts.IsDebugTx(tx.Signatures[0]) { - for _, l := range log.Logs { + for _, l := range executedTx.ExecutionDetails.LogMessages { mlog.Log.Debugf("%s", l) } } - // check for CU consumed divergences - if instrErr == nil && *txMeta.ComputeUnitsConsumed != execCtx.ComputeMeter.Used() { - discrepancy := max(execCtx.ComputeMeter.Used(), *txMeta.ComputeUnitsConsumed) - min(execCtx.ComputeMeter.Used(), *txMeta.ComputeUnitsConsumed) - var sign byte - if execCtx.ComputeMeter.Used() > *txMeta.ComputeUnitsConsumed { - sign = '+' - } else { - sign = '-' + // CU divergence check (non-failing) + if txMeta != nil && txMeta.ComputeUnitsConsumed != nil { + cuUsed := execCtx.ComputeMeter.Used() + if *txMeta.ComputeUnitsConsumed != cuUsed { + discrepancy := max(cuUsed, *txMeta.ComputeUnitsConsumed) - min(cuUsed, *txMeta.ComputeUnitsConsumed) + var sign byte + if cuUsed > *txMeta.ComputeUnitsConsumed { + sign = '+' + } else { + sign = '-' + } + mlog.Log.Infof("tx %s CU divergence: used was %d but onchain CU consumed was %d (%c%d discrepancy) [non-failing]", tx.Signatures[0], cuUsed, *txMeta.ComputeUnitsConsumed, sign, discrepancy) } - mlog.Log.Infof("tx %s CU divergence: used was %d but onchain CU consumed was %d (%c%d discrepancy) [non-failing]", tx.Signatures[0], execCtx.ComputeMeter.Used(), *txMeta.ComputeUnitsConsumed, sign, discrepancy) } + // Post-balance divergence check (only if tx succeeded) start = time.Now() - postTxRentStates := rent.NewRentStateInfo(&rentSysvar, execCtx.TransactionContext, &execCtx.Features, programIDSet) - rentStateErr := rent.VerifyRentStateChanges(preTxRentStates, postTxRentStates, execCtx.TransactionContext) - metrics.GlobalBlockReplay.PostTxRentStates.AddTimingSince(start) - - start = time.Now() - // check for post-balances divergences (but only if the tx succeeded) - if txMeta != nil && instrErr == nil && rentStateErr == nil { + if txMeta != nil { var errBuf strings.Builder for count := uint64(0); count < uint64(len(tx.Message.AccountKeys)); count++ { txAcct, err := execCtx.TransactionContext.Accounts.GetAccount(count) @@ -519,32 +450,16 @@ func ProcessTransaction(slotCtx *sealevel.SlotCtx, sigverifyWg *sync.WaitGroup, } metrics.GlobalBlockReplay.PostBalanceDivergenceCheck.AddTimingSince(start) + // Apply state changes to slotCtx start = time.Now() - payerAcct := execCtx.TransactionContext.Accounts.Accounts[0] - - // if there was an error in the tx, do not update account states, except for deducting the tx fee - // from the payer account and advancing the nonce account if applicable - if instrErr != nil || rentStateErr != nil { - return handleFailedTx(slotCtx, tx, instrs, computeBudgetLimits, instrErr, rentStateErr) - } - - // Reuse txAcctMetas from loadAndValidateTxAccts* (already built once per tx) - // Build writablePubkeySet inline to avoid second loop - writablePubkeySet := make(map[solana.PublicKey]struct{}, len(txAcctMetas)) - for _, txAcctMeta := range txAcctMetas { - if isWritable(txAcctMeta, &execCtx.Features, programIDSet) { - writablePubkeys = append(writablePubkeys, txAcctMeta.PublicKey) - writablePubkeySet[txAcctMeta.PublicKey] = struct{}{} - } - } + writablePubkeys := output.ExecutionResult.WritableAccounts + writablePubkeySet := output.ExecutionResult.WritableAccountSet for _, pk := range writablePubkeys { slotCtx.RecordWritableAcct(pk) } handleModifiedAccounts(slotCtx, execCtx) - writablePubkeys = append(writablePubkeys, payerAcct.Key) - writablePubkeySet[payerAcct.Key] = struct{}{} recordStakeAndVoteAccounts(slotCtx, execCtx, writablePubkeySet) metrics.GlobalBlockReplay.TxUpdateAccounts.AddTimingSince(start) diff --git a/pkg/replay/transaction_processing_pure.go b/pkg/replay/transaction_processing_pure.go new file mode 100644 index 00000000..2ea1f7ec --- /dev/null +++ b/pkg/replay/transaction_processing_pure.go @@ -0,0 +1,432 @@ +package replay + +import ( + "math" + "time" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/arena" + "github.com/Overclock-Validator/mithril/pkg/cu" + "github.com/Overclock-Validator/mithril/pkg/features" + "github.com/Overclock-Validator/mithril/pkg/fees" + "github.com/Overclock-Validator/mithril/pkg/metrics" + "github.com/Overclock-Validator/mithril/pkg/migration" + "github.com/Overclock-Validator/mithril/pkg/rent" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" +) + +// LoadAndExecuteTransactionInput contains all the input state needed for pure transaction processing +type LoadAndExecuteTransactionInput struct { + // SlotCtx provides access to accounts and slot state (used read-only) + SlotCtx *sealevel.SlotCtx + // Transaction is the transaction to process + Transaction *solana.Transaction + // Arena for borrowed accounts (optional) + Arena *arena.Arena[sealevel.BorrowedAccount] + // TxMeta is the on-chain transaction metadata (optional, used for fee calculation) + TxMeta *rpc.TransactionMeta +} + +// LoadAndExecuteTransaction is a pure function that loads and executes a transaction. +// It takes all required state as input and returns all state changes as output without +// modifying the input SlotCtx. This function can be used for both actual execution +// and simulation. +func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExecuteTransactionOutput { + tx := input.Transaction + slotCtx := input.SlotCtx + + if input.Arena != nil { + input.Arena.Reset() + } + + // Parse instructions and account metas + start := time.Now() + instrs, acctMetasPerInstr, programIDSet, err := instrsAndAcctMetasFromTx(tx, slotCtx.Features) + if err != nil { + return LoadAndExecuteTransactionOutput{ + ProcessingResult: TransactionProcessingResult{ + TransactionError: &TransactionError{ + ErrorType: TransactionErrorSanitizeFailure, + InstructionError: err, + }, + }, + } + } + metrics.GlobalBlockReplay.InstructionsAndAccountMetasFromTx.AddTimingSince(start) + + // Compute budget limits + start = time.Now() + computeBudgetLimits, err := sealevel.ComputeBudgetExecuteInstructions(instrs, slotCtx.Features) + if err != nil { + return LoadAndExecuteTransactionOutput{ + ProcessingResult: TransactionProcessingResult{ + TransactionError: &TransactionError{ + ErrorType: TransactionErrorInstructionError, + InstructionError: err, + }, + }, + Instrs: instrs, + ProgramIDSet: programIDSet, + } + } + metrics.GlobalBlockReplay.ComputeBudgetExecutionInstructions.AddTimingSince(start) + + // Validate transaction age + if !sealevel.IsTransactionAgeValid(tx, instrs, slotCtx) { + return LoadAndExecuteTransactionOutput{ + ProcessingResult: TransactionProcessingResult{ + TransactionError: &TransactionError{ + ErrorType: TransactionErrorBlockhashNotFound, + }, + }, + Instrs: instrs, + ComputeBudgetLimits: computeBudgetLimits, + ProgramIDSet: programIDSet, + } + } + + // Load and validate accounts + start = time.Now() + instrsAcct := sealevel.MakeInstructionsSysvarAccount(instrs) + var transactionAccts *sealevel.TransactionAccounts + var txAcctMetas []*solana.AccountMeta + + if slotCtx.Features.IsActive(features.FormalizeLoadedTransactionDataSize) { + transactionAccts, txAcctMetas, err = loadAndValidateTxAcctsSimd186(slotCtx, acctMetasPerInstr, tx, instrs, instrsAcct, computeBudgetLimits.LoadedAccountBytes) + } else { + transactionAccts, txAcctMetas, err = loadAndValidateTxAccts(slotCtx, acctMetasPerInstr, tx, instrs, instrsAcct, computeBudgetLimits.LoadedAccountBytes) + } + + // Base output fields for all paths after parsing + baseFields := func(out *LoadAndExecuteTransactionOutput) { + out.Instrs = instrs + out.ComputeBudgetLimits = computeBudgetLimits + out.ProgramIDSet = programIDSet + } + + if err == TxErrMaxLoadedAccountsDataSizeExceeded || err == TxErrInvalidProgramForExecution || err == TxErrProgramAccountNotFound { + errType := mapLoadErrorType(err) + out := LoadAndExecuteTransactionOutput{ + ProcessingResult: TransactionProcessingResult{ + TransactionError: &TransactionError{ + ErrorType: errType, + InstructionError: err, + }, + }, + } + baseFields(&out) + return out + } else if err != nil { + out := LoadAndExecuteTransactionOutput{ + ProcessingResult: TransactionProcessingResult{ + TransactionError: &TransactionError{ + ErrorType: TransactionErrorAccountNotFound, + InstructionError: err, + }, + }, + } + baseFields(&out) + return out + } + metrics.GlobalBlockReplay.AccountsFromTx.AddTimingSince(start) + + // Create execution context + var log sealevel.LogRecorder + execCtx := newExecCtx(slotCtx, transactionAccts, computeBudgetLimits, &log) + execCtx.TransactionContext.AllInstructions = instrs + execCtx.TransactionContext.Signature = tx.Signatures[0] + execCtx.TransactionContext.BorrowedAccountArena = input.Arena + + // Capture pre-balance lamports (before fee deduction) + preBalances := make([]uint64, len(tx.Message.AccountKeys)) + for i := range tx.Message.AccountKeys { + acct := execCtx.TransactionContext.Accounts.Accounts[i] + preBalances[i] = acct.Lamports + } + + // Calculate and deduct fees + start = time.Now() + txFeeInfo, _, err := fees.CalculateAndDeductTxFees(tx, input.TxMeta, instrs, &execCtx.TransactionContext.Accounts, computeBudgetLimits, slotCtx.Features) + if err != nil { + out := LoadAndExecuteTransactionOutput{ + ProcessingResult: TransactionProcessingResult{ + TransactionError: &TransactionError{ + ErrorType: TransactionErrorInsufficientFundsForFee, + InstructionError: err, + }, + }, + ExecCtx: execCtx, + PreBalances: preBalances, + FeeInfo: txFeeInfo, + } + baseFields(&out) + return out + } + metrics.GlobalBlockReplay.CalcAndDeductFees.AddTimingSince(start) + + // Read rent sysvar + start = time.Now() + rentSysvar, err := sealevel.ReadRentSysvar(execCtx) + if err != nil { + panic("failed to get and deserialize rent sysvar") + } + metrics.GlobalBlockReplay.ReadRentSysvar.AddTimingSince(start) + + // Set rent-exempt rent epoch max and compute pre-tx rent states + start = time.Now() + rent.MaybeSetRentExemptRentEpochMax(slotCtx, &rentSysvar, &execCtx.Features, &execCtx.TransactionContext.Accounts) + preTxRentStates := rent.NewRentStateInfo(&rentSysvar, execCtx.TransactionContext, &execCtx.Features, programIDSet) + metrics.GlobalBlockReplay.PreTxRentStates.AddTimingSince(start) + + // Execute all instructions + var instrErr error + writablePubkeys := make([]solana.PublicKey, 0, 64) + + start = time.Now() + for instrIdx, instr := range tx.Message.Instructions { + ixStart := time.Now() + err = fixupInstructionsSysvarAcct(execCtx, uint16(instrIdx)) + if err != nil { + instrErr = err + break + } + metrics.GlobalBlockReplay.FixupInstructionsSysvarAccount.AddTimingSince(ixStart) + + ixStart = time.Now() + acctMetas := acctMetasPerInstr[instrIdx] + instructionAccts := sealevel.InstructionAcctsFromAccountMetas(acctMetas, *transactionAccts) + metrics.GlobalBlockReplay.InstructionAccountsFromAccountMetas.AddTimingSince(ixStart) + + programId := tx.Message.AccountKeys[instr.ProgramIDIndex] + migratingCus, isMigrating := migration.IsMigratingProgramAndGetCUs(programId) + if isMigrating { + err = execCtx.ComputeMeter.Consume(migratingCus) + if err != nil { + instrErr = err + break + } + execCtx.ComputeMeter.Disable() + } + + err = execCtx.ProcessInstruction(instr.Data, instructionAccts, programIndices(tx, instrIdx)) + if err == nil { + for _, am := range acctMetas { + if am.IsWritable { + writablePubkeys = append(writablePubkeys, am.Pubkey) + } + } + if isMigrating { + execCtx.ComputeMeter.Enable() + } + } else { + instrErr = err + break + } + } + metrics.GlobalBlockReplay.IxLoop.AddTimingSince(start) + + // Check rent state transitions + start = time.Now() + postTxRentStates := rent.NewRentStateInfo(&rentSysvar, execCtx.TransactionContext, &execCtx.Features, programIDSet) + rentStateErr := rent.VerifyRentStateChanges(preTxRentStates, postTxRentStates, execCtx.TransactionContext) + metrics.GlobalBlockReplay.PostTxRentStates.AddTimingSince(start) + + // If there was an error, return failed transaction result + if instrErr != nil || rentStateErr != nil { + var relevantErr error + var errType TransactionErrorType + if instrErr != nil { + relevantErr = instrErr + errType = TransactionErrorInstructionError + } else { + relevantErr = rentStateErr + errType = TransactionErrorInsufficientFundsForRent + } + + out := LoadAndExecuteTransactionOutput{ + ProcessingResult: TransactionProcessingResult{ + TransactionError: &TransactionError{ + ErrorType: errType, + InstructionError: relevantErr, + }, + }, + ExecCtx: execCtx, + PreBalances: preBalances, + FeeInfo: txFeeInfo, + } + baseFields(&out) + return out + } + + // Success path: collect all writable accounts + writablePubkeySet := make(map[solana.PublicKey]struct{}, len(txAcctMetas)) + for _, txAcctMeta := range txAcctMetas { + if isWritable(txAcctMeta, &execCtx.Features, programIDSet) { + writablePubkeys = append(writablePubkeys, txAcctMeta.PublicKey) + writablePubkeySet[txAcctMeta.PublicKey] = struct{}{} + } + } + + // Add payer to writable sets + payerAcct := execCtx.TransactionContext.Accounts.Accounts[0] + writablePubkeys = append(writablePubkeys, payerAcct.Key) + writablePubkeySet[payerAcct.Key] = struct{}{} + + // Collect modified accounts for simulation output + accountUpdates := collectAccountUpdates(execCtx) + + // Collect modified vote accounts + modifiedVoteAccounts := make(map[solana.PublicKey]*sealevel.VoteStateVersions) + for pk, voteState := range execCtx.ModifiedVoteStates { + modifiedVoteAccounts[pk] = voteState + } + + // Calculate loaded accounts data size + var loadedAccountsDataSize uint32 + for _, acct := range transactionAccts.Accounts { + if !acct.IsDummy { + loadedAccountsDataSize += uint32(len(acct.Data)) + } + } + + // Collect return data + var returnData *TransactionReturnData + programId, data := execCtx.TransactionContext.ReturnData() + if len(data) > 0 { + returnData = &TransactionReturnData{ + ProgramId: programId, + Data: data, + } + } + + // Calculate accounts data len delta + var accountsDataLenDelta int64 + for idx, acct := range execCtx.TransactionContext.Accounts.Accounts { + if execCtx.TransactionContext.Accounts.Touched[idx] { + originalAcct := transactionAccts.Accounts[idx] + accountsDataLenDelta += int64(len(acct.Data)) - int64(len(originalAcct.Data)) + } + } + + // Build compute budget + computeBudget := SVMTransactionExecutionBudget{ + ComputeUnitLimit: uint64(computeBudgetLimits.ComputeUnitLimit), + MaxInstructionStackDepth: maxStackCapacity, + MaxInstructionTraceLength: maxInstrTraceCapacity, + Sha256MaxSlices: cu.CUSha256MaxSlices, + MaxCallDepth: 64, + StackFrameSize: 4096, + HeapSize: computeBudgetLimits.UpdatedHeapBytes, + } + + // Build loaded transaction + loadedTransaction := LoadedTransaction{ + Accounts: convertToKeyedAccountSharedData(transactionAccts.Accounts), + ProgramIndices: []uint16{}, + FeeDetails: *txFeeInfo, + ComputeBudget: computeBudget, + LoadedAccountsDataSize: loadedAccountsDataSize, + } + + // Build execution details + executionDetails := TransactionExecutionDetails{ + Status: nil, + LogMessages: log.Logs, + ReturnData: returnData, + ExecutedUnits: execCtx.ComputeMeter.Used(), + AccountsDataLenDelta: accountsDataLenDelta, + } + + // Build processed transaction + processedTx := ProcessedTransaction{ + TransactionType: ProcessedTransactionTypeExecuted, + Executed: &ExecutedTransaction{ + LoadedTransaction: loadedTransaction, + ExecutionDetails: executionDetails, + ProgramsModified: make(map[solana.PublicKey]bool), + }, + } + + // Build execution result + executionResult := &TransactionExecutionResult{ + AccountUpdates: accountUpdates, + WritableAccounts: writablePubkeys, + WritableAccountSet: writablePubkeySet, + ModifiedVoteAccounts: modifiedVoteAccounts, + ModifiedStakeAccounts: []solana.PublicKey{}, + } + + out := LoadAndExecuteTransactionOutput{ + ProcessingResult: TransactionProcessingResult{ + ProcessedTransaction: &processedTx, + }, + ExecutionResult: executionResult, + ExecCtx: execCtx, + PreBalances: preBalances, + FeeInfo: txFeeInfo, + } + baseFields(&out) + return out +} + +// mapLoadErrorType maps account loading errors to TransactionErrorType +func mapLoadErrorType(err error) TransactionErrorType { + switch err { + case TxErrMaxLoadedAccountsDataSizeExceeded: + return TransactionErrorMaxLoadedAccountsDataSizeExceeded + case TxErrInvalidProgramForExecution: + return TransactionErrorInvalidProgramForExecution + case TxErrProgramAccountNotFound: + return TransactionErrorProgramAccountNotFound + default: + return TransactionErrorAccountNotFound + } +} + +// collectAccountUpdates collects all modified accounts from the execution context +func collectAccountUpdates(execCtx *sealevel.ExecutionCtx) []AccountUpdate { + updates := make([]AccountUpdate, 0) + + for idx, newAcctState := range execCtx.TransactionContext.Accounts.Accounts { + if execCtx.TransactionContext.Accounts.Touched[idx] { + acct := *newAcctState + if acct.Lamports == 0 { + acct = accounts.Account{Key: acct.Key, RentEpoch: math.MaxUint64} + } + + updates = append(updates, AccountUpdate{ + Pubkey: acct.Key, + Account: acct, + }) + } + } + + return updates +} + +// convertToKeyedAccountSharedData converts accounts to KeyedAccountSharedData +func convertToKeyedAccountSharedData(accts []*accounts.Account) []KeyedAccountSharedData { + result := make([]KeyedAccountSharedData, len(accts)) + for i, acct := range accts { + result[i] = KeyedAccountSharedData{ + Pubkey: acct.Key, + AccountData: accountToSharedData(acct), + } + } + return result +} + +// accountToSharedData converts an Account to AccountSharedData +func accountToSharedData(acct *accounts.Account) AccountSharedData { + data := make([]byte, len(acct.Data)) + copy(data, acct.Data) + return AccountSharedData{ + Lamports: acct.Lamports, + Data: data, + Owner: acct.Owner, + Executable: acct.Executable, + RentEpoch: acct.RentEpoch, + } +} diff --git a/pkg/replay/transaction_processing_types.go b/pkg/replay/transaction_processing_types.go new file mode 100644 index 00000000..b9e18f30 --- /dev/null +++ b/pkg/replay/transaction_processing_types.go @@ -0,0 +1,307 @@ +package replay + +import ( + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/fees" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/gagliardetto/solana-go" +) + +// LoadAndExecuteTransactionOutput is the output of loading and executing a sanitized transaction. +// This structure contains the complete result of transaction processing, including whether +// the transaction was successfully processed or encountered an error. +type LoadAndExecuteTransactionOutput struct { + // Result of processing the transaction. Contains either ProcessedTransaction or TransactionError + ProcessingResult TransactionProcessingResult + // ExecutionResult contains all the state changes from executing the transaction + // This is only populated if ProcessingResult contains a ProcessedTransaction + ExecutionResult *TransactionExecutionResult + + // ExecCtx is the execution context after processing. Available when accounts were + // loaded successfully. Used by ProcessTransaction for divergence checks and state application. + ExecCtx *sealevel.ExecutionCtx + // PreBalances contains lamport balances for each account BEFORE fee deduction. + // Used by ProcessTransaction for pre-balance divergence checks. + PreBalances []uint64 + // FeeInfo contains the calculated fee info. Set whenever fees were calculated. + FeeInfo *fees.TxFeeInfo + // Instrs contains the parsed instructions from the transaction. + Instrs []sealevel.Instruction + // ComputeBudgetLimits contains the compute budget limits computed from instructions. + ComputeBudgetLimits *sealevel.ComputeBudgetLimits + // ProgramIDSet contains the set of program IDs from the transaction. + ProgramIDSet map[solana.PublicKey]struct{} +} + +// TransactionProcessingResult represents the result of processing a transaction +type TransactionProcessingResult struct { + // ProcessedTransaction contains the execution result if successful + ProcessedTransaction *ProcessedTransaction + // TransactionError contains the error if processing failed + TransactionError *TransactionError +} + +// TransactionError represents all possible transaction-level errors that can occur +// before or during transaction processing. These match the Solana error codes. +type TransactionError struct { + ErrorType TransactionErrorType + // InstructionIndex is set for InstructionError type + InstructionIndex *uint8 + // InstructionError is set for InstructionError type + InstructionError error + // AccountIndex is set for errors that reference a specific account + AccountIndex *uint8 +} + +// TransactionErrorType represents the type of transaction error +type TransactionErrorType int + +const ( + // TransactionErrorAccountInUse - An account is already being processed in another transaction + TransactionErrorAccountInUse TransactionErrorType = iota + // TransactionErrorAccountLoadedTwice - A Pubkey appears twice in the transaction's account_keys + TransactionErrorAccountLoadedTwice + // TransactionErrorAccountNotFound - Attempt to debit an account but found no record of a prior credit + TransactionErrorAccountNotFound + // TransactionErrorProgramAccountNotFound - Attempt to load a program that does not exist + TransactionErrorProgramAccountNotFound + // TransactionErrorInsufficientFundsForFee - The from Pubkey does not have sufficient balance to pay the fee + TransactionErrorInsufficientFundsForFee + // TransactionErrorInvalidAccountForFee - This account may not be used to pay transaction fees + TransactionErrorInvalidAccountForFee + // TransactionErrorAlreadyProcessed - The bank has seen this transaction before + TransactionErrorAlreadyProcessed + // TransactionErrorBlockhashNotFound - The bank has not seen the given recent_blockhash + TransactionErrorBlockhashNotFound + // TransactionErrorInstructionError - An error occurred while processing an instruction + TransactionErrorInstructionError + // TransactionErrorCallChainTooDeep - Loader call chain is too deep + TransactionErrorCallChainTooDeep + // TransactionErrorMissingSignatureForFee - Transaction requires a fee but has no signature present + TransactionErrorMissingSignatureForFee + // TransactionErrorInvalidAccountIndex - Transaction contains an invalid account reference + TransactionErrorInvalidAccountIndex + // TransactionErrorSignatureFailure - Transaction did not pass signature verification + TransactionErrorSignatureFailure + // TransactionErrorInvalidProgramForExecution - This program may not be used for executing instructions + TransactionErrorInvalidProgramForExecution + // TransactionErrorSanitizeFailure - Transaction failed to sanitize accounts offsets correctly + TransactionErrorSanitizeFailure + // TransactionErrorClusterMaintenance - Cluster is in maintenance mode + TransactionErrorClusterMaintenance + // TransactionErrorAccountBorrowOutstanding - Transaction processing left an account with an outstanding borrowed reference + TransactionErrorAccountBorrowOutstanding + // TransactionErrorWouldExceedMaxBlockCostLimit - Transaction would exceed max Block Cost Limit + TransactionErrorWouldExceedMaxBlockCostLimit + // TransactionErrorUnsupportedVersion - Transaction version is unsupported + TransactionErrorUnsupportedVersion + // TransactionErrorInvalidWritableAccount - Transaction loads a writable account that cannot be written + TransactionErrorInvalidWritableAccount + // TransactionErrorWouldExceedMaxAccountCostLimit - Transaction would exceed max account limit within the block + TransactionErrorWouldExceedMaxAccountCostLimit + // TransactionErrorWouldExceedAccountDataBlockLimit - Transaction would exceed account data limit within the block + TransactionErrorWouldExceedAccountDataBlockLimit + // TransactionErrorTooManyAccountLocks - Transaction locked too many accounts + TransactionErrorTooManyAccountLocks + // TransactionErrorAddressLookupTableNotFound - Address lookup table not found + TransactionErrorAddressLookupTableNotFound + // TransactionErrorInvalidAddressLookupTableOwner - Attempted to lookup addresses from an account owned by the wrong program + TransactionErrorInvalidAddressLookupTableOwner + // TransactionErrorInvalidAddressLookupTableData - Attempted to lookup addresses from an invalid account + TransactionErrorInvalidAddressLookupTableData + // TransactionErrorInvalidAddressLookupTableIndex - Address table lookup uses an invalid index + TransactionErrorInvalidAddressLookupTableIndex + // TransactionErrorInvalidRentPayingAccount - Transaction leaves an account with a lower balance than rent-exempt minimum + TransactionErrorInvalidRentPayingAccount + // TransactionErrorWouldExceedMaxVoteCostLimit - Transaction would exceed max Vote Cost Limit + TransactionErrorWouldExceedMaxVoteCostLimit + // TransactionErrorWouldExceedAccountDataTotalLimit - Transaction would exceed total account data limit + TransactionErrorWouldExceedAccountDataTotalLimit + // TransactionErrorDuplicateInstruction - Transaction contains a duplicate instruction that is not allowed + TransactionErrorDuplicateInstruction + // TransactionErrorInsufficientFundsForRent - Transaction results in an account with insufficient funds for rent + TransactionErrorInsufficientFundsForRent + // TransactionErrorMaxLoadedAccountsDataSizeExceeded - Transaction exceeded max loaded accounts data size cap + TransactionErrorMaxLoadedAccountsDataSizeExceeded + // TransactionErrorInvalidLoadedAccountsDataSizeLimit - LoadedAccountsDataSizeLimit set for transaction must be greater than 0 + TransactionErrorInvalidLoadedAccountsDataSizeLimit + // TransactionErrorResanitizationNeeded - Sanitized transaction differed before/after feature activation + TransactionErrorResanitizationNeeded + // TransactionErrorProgramExecutionTemporarilyRestricted - Program execution is temporarily restricted on an account + TransactionErrorProgramExecutionTemporarilyRestricted + // TransactionErrorUnbalancedTransaction - The total balance before the transaction does not equal the total balance after the transaction + TransactionErrorUnbalancedTransaction + // TransactionErrorProgramCacheHitMaxLimit - Program cache hit max limit + TransactionErrorProgramCacheHitMaxLimit +) + +// ProcessedTransaction represents a transaction that was processed, either executed or fees-only +type ProcessedTransaction struct { + // TransactionType indicates whether this is an Executed or FeesOnly transaction + TransactionType ProcessedTransactionType + // Executed contains the execution result if TransactionType is Executed + Executed *ExecutedTransaction + // FeesOnly contains the fees-only result if TransactionType is FeesOnly + FeesOnly *FeesOnlyTransaction +} + +// ProcessedTransactionType indicates the type of processed transaction +type ProcessedTransactionType int + +const ( + // ProcessedTransactionTypeExecuted - Transaction was executed (may have failed but all instructions ran) + ProcessedTransactionTypeExecuted ProcessedTransactionType = iota + // ProcessedTransactionTypeFeesOnly - Transaction was not executed but fees were collected + ProcessedTransactionTypeFeesOnly +) + +// ExecutedTransaction represents a transaction that was executed. +// If execution failed, all account state changes will be rolled back except +// deducted fees and any advanced nonces. +type ExecutedTransaction struct { + LoadedTransaction LoadedTransaction + ExecutionDetails TransactionExecutionDetails + ProgramsModified map[solana.PublicKey]bool // Programs that were modified by this transaction +} + +// FeesOnlyTransaction represents a transaction that was not able to be executed +// but fees are able to be collected and any nonces are advanceable +type FeesOnlyTransaction struct { + LoadedTransaction LoadedTransaction + ExecutionError error // The error that prevented execution +} + +// LoadedTransaction contains all the accounts and metadata loaded for a transaction +type LoadedTransaction struct { + // Accounts contains all the accounts loaded for this transaction with their public keys + Accounts []KeyedAccountSharedData + // ProgramIndices contains the indices of program accounts in the Accounts array + ProgramIndices []uint16 + // FeeDetails contains the fee calculation for this transaction + FeeDetails fees.TxFeeInfo + // RollbackAccounts contains the accounts that need to be rolled back if the transaction fails + RollbackAccounts RollbackAccounts + // ComputeBudget contains the compute unit limits and heap size for this transaction + ComputeBudget SVMTransactionExecutionBudget + // LoadedAccountsDataSize is the total size of all loaded account data in bytes + LoadedAccountsDataSize uint32 +} + +// KeyedAccountSharedData is a tuple of (Pubkey, AccountSharedData) +type KeyedAccountSharedData struct { + Pubkey solana.PublicKey + AccountData AccountSharedData +} + +// AccountSharedData represents the data stored in an account +type AccountSharedData struct { + // Lamports in the account + Lamports uint64 + // Data held in this account + Data []byte + // The program that owns this account + Owner solana.PublicKey + // This account's data contains a loaded program (and is now read-only) + Executable bool + // The epoch at which this account will next owe rent + RentEpoch uint64 +} + +// RollbackAccounts represents the accounts that need to be saved for potential rollback +type RollbackAccounts struct { + RollbackType RollbackAccountsType + // FeePayer is set for FeePayerOnly and SeparateNonceAndFeePayer types + FeePayer *KeyedAccountSharedData + // Nonce is set for SameNonceAndFeePayer and SeparateNonceAndFeePayer types + Nonce *KeyedAccountSharedData +} + +// RollbackAccountsType indicates the type of rollback accounts +type RollbackAccountsType int + +const ( + // RollbackAccountsTypeFeePayerOnly - Only the fee payer account needs rollback + RollbackAccountsTypeFeePayerOnly RollbackAccountsType = iota + // RollbackAccountsTypeSameNonceAndFeePayer - Nonce and fee payer are the same account + RollbackAccountsTypeSameNonceAndFeePayer + // RollbackAccountsTypeSeparateNonceAndFeePayer - Nonce and fee payer are separate accounts + RollbackAccountsTypeSeparateNonceAndFeePayer +) + +// SVMTransactionExecutionBudget contains the execution limits for a transaction +type SVMTransactionExecutionBudget struct { + // ComputeUnitLimit is the number of compute units that a transaction is allowed to consume + ComputeUnitLimit uint64 + // MaxInstructionStackDepth is the maximum program instruction invocation stack depth + MaxInstructionStackDepth uint64 + // MaxInstructionTraceLength is the maximum cross-program invocation and instructions per transaction + MaxInstructionTraceLength uint64 + // Sha256MaxSlices is the maximum number of slices hashed per syscall + Sha256MaxSlices uint64 + // MaxCallDepth is the maximum SBF to BPF call depth + MaxCallDepth uint64 + // StackFrameSize is the size of a stack frame in bytes + StackFrameSize uint64 + // HeapSize is the program heap region size + HeapSize uint32 +} + +// TransactionExecutionDetails contains the execution result and metadata +type TransactionExecutionDetails struct { + // Status is the execution result (nil for success, error for failure) + Status error + // LogMessages contains the log messages produced during execution (if logging is enabled) + LogMessages []string + // InnerInstructions contains the inner instructions executed via CPI + InnerInstructions []InnerInstructionsList + // ReturnData contains the return data from the last instruction + ReturnData *TransactionReturnData + // ExecutedUnits is the number of compute units consumed + ExecutedUnits uint64 + // AccountsDataLenDelta is the change in accounts data len for this transaction (only valid if Status is nil) + AccountsDataLenDelta int64 +} + +// InnerInstructionsList represents the inner instructions for a single top-level instruction +type InnerInstructionsList struct { + // Index is the index of the top-level instruction that produced these inner instructions + Index uint8 + // Instructions contains the inner instructions + Instructions []CompiledInstruction +} + +// CompiledInstruction represents a compiled instruction +type CompiledInstruction struct { + // ProgramIdIndex is the index into the transaction's account keys of the program + ProgramIdIndex uint8 + // Accounts contains the indices into the transaction's account keys + Accounts []uint8 + // Data is the instruction data + Data []byte +} + +// TransactionReturnData represents data returned from a program +type TransactionReturnData struct { + ProgramId solana.PublicKey + Data []byte +} + +// AccountUpdate represents a single account update from transaction execution +type AccountUpdate struct { + Pubkey solana.PublicKey + Account accounts.Account +} + +// TransactionExecutionResult contains all the updates from executing a transaction +type TransactionExecutionResult struct { + // AccountUpdates contains all account updates (modified accounts) + AccountUpdates []AccountUpdate + // WritableAccounts contains the pubkeys of all writable accounts (slice for ordered iteration) + WritableAccounts []solana.PublicKey + // WritableAccountSet is the same as WritableAccounts but as a set for O(1) lookups + WritableAccountSet map[solana.PublicKey]struct{} + // ModifiedStakeAccounts contains stake accounts that were modified + ModifiedStakeAccounts []solana.PublicKey + // ModifiedVoteAccounts contains vote accounts that were modified + ModifiedVoteAccounts map[solana.PublicKey]*sealevel.VoteStateVersions +} From c50d4869d0d782fe907774d2404fd453878ef08b Mon Sep 17 00:00:00 2001 From: Rupansh Sekar Date: Fri, 13 Feb 2026 00:34:24 +0530 Subject: [PATCH 2/7] feat: simulate txn rpc --- cmd/mithril/node/node.go | 8 +- pkg/replay/block.go | 12 +- pkg/rpcserver/rpcserver.go | 15 ++ pkg/rpcserver/simulate_transaction.go | 268 ++++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 pkg/rpcserver/simulate_transaction.go diff --git a/cmd/mithril/node/node.go b/cmd/mithril/node/node.go index 89a713bd..6aeb4fc0 100644 --- a/cmd/mithril/node/node.go +++ b/cmd/mithril/node/node.go @@ -1235,7 +1235,11 @@ postBootstrap: NearTipPollMs: blockNearTipPollMs, NearTipLookahead: blockNearTipLookahead, } - result := runReplayWithRecovery(ctx, accountsDb, accountsPath, manifest, resumeState, uint64(startSlot), liveEndSlot, rpcEndpoints, blockstorePath, int(txParallelism), true, useLightbringer, dbgOpts, metricsWriter, rpcServer, mithrilState, blockFetchOpts, replayStartTime) + var slotCtxSetter replay.SlotCtxSetter + if rpcServer != nil { + slotCtxSetter = rpcServer + } + result := runReplayWithRecovery(ctx, accountsDb, accountsPath, manifest, resumeState, uint64(startSlot), liveEndSlot, rpcEndpoints, blockstorePath, int(txParallelism), true, useLightbringer, dbgOpts, metricsWriter, slotCtxSetter, mithrilState, blockFetchOpts, replayStartTime) // Update state file with last persisted slot and shutdown context // Skip if already written during cancellation (eliminates timing window) @@ -2096,7 +2100,7 @@ func runReplayWithRecovery( useLightbringer bool, dbgOpts *replay.DebugOptions, metricsWriter io.Writer, - rpcServer *rpcserver.RpcServer, + rpcServer replay.SlotCtxSetter, mithrilState *state.MithrilState, blockFetchOpts *replay.BlockFetchOpts, replayStartTime time.Time, // Start time for resume context diff --git a/pkg/replay/block.go b/pkg/replay/block.go index cc84af16..2a87d025 100644 --- a/pkg/replay/block.go +++ b/pkg/replay/block.go @@ -36,7 +36,6 @@ import ( "github.com/Overclock-Validator/mithril/pkg/rent" "github.com/Overclock-Validator/mithril/pkg/rewards" "github.com/Overclock-Validator/mithril/pkg/rpcclient" - "github.com/Overclock-Validator/mithril/pkg/rpcserver" "github.com/Overclock-Validator/mithril/pkg/sealevel" "github.com/Overclock-Validator/mithril/pkg/state" "github.com/Overclock-Validator/mithril/pkg/statsd" @@ -46,6 +45,11 @@ import ( "github.com/panjf2000/ants/v2" ) +// SlotCtxSetter is implemented by types that accept a SlotCtx update (e.g. RpcServer). +type SlotCtxSetter interface { + SetSlotCtx(slotCtx *sealevel.SlotCtx) +} + // BlockFetchOpts contains options for parallel block fetching type BlockFetchOpts struct { MaxRPS int // Rate limit (requests per second), 0 = use default @@ -1171,7 +1175,7 @@ func ReplayBlocks( useLightbringer bool, dbgOpts *DebugOptions, metricsWriter io.Writer, - rpcServer *rpcserver.RpcServer, + rpcServer SlotCtxSetter, blockFetchOpts *BlockFetchOpts, onCancelWriteState OnCancelWriteState, // callback to write state immediately on cancellation (can be nil) ) *ReplayResult { @@ -1533,6 +1537,10 @@ func ReplayBlocks( break } + if rpcServer != nil { + rpcServer.SetSlotCtx(lastSlotCtx) + } + replayCtx.Capitalization -= lastSlotCtx.LamportsBurnt // Clear ManifestEpochStakes after first replayed slot past snapshot diff --git a/pkg/rpcserver/rpcserver.go b/pkg/rpcserver/rpcserver.go index eee7b048..78db91b0 100644 --- a/pkg/rpcserver/rpcserver.go +++ b/pkg/rpcserver/rpcserver.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "github.com/Overclock-Validator/mithril/pkg/accountsdb" "github.com/Overclock-Validator/mithril/pkg/sealevel" @@ -20,6 +21,8 @@ type RpcServer struct { listener net.Listener acctsDb *accountsdb.AccountsDb epochSchedule *sealevel.SysvarEpochSchedule + slotCtx *sealevel.SlotCtx + slotCtxMu sync.RWMutex } func NewRpcServer(acctsDb *accountsdb.AccountsDb, port uint16) *RpcServer { @@ -57,6 +60,18 @@ func fetchAndUnmarshalEpochScheduleSysvar(acctsDb *accountsdb.AccountsDb) *seale return &epochSchedule } +func (rpcServer *RpcServer) SetSlotCtx(slotCtx *sealevel.SlotCtx) { + rpcServer.slotCtxMu.Lock() + rpcServer.slotCtx = slotCtx + rpcServer.slotCtxMu.Unlock() +} + +func (rpcServer *RpcServer) getSlotCtx() *sealevel.SlotCtx { + rpcServer.slotCtxMu.RLock() + defer rpcServer.slotCtxMu.RUnlock() + return rpcServer.slotCtx +} + func (rpcServer *RpcServer) Start() { go http.Serve(rpcServer.listener, rpcServer.rpcService) } diff --git a/pkg/rpcserver/simulate_transaction.go b/pkg/rpcserver/simulate_transaction.go new file mode 100644 index 00000000..3cee0951 --- /dev/null +++ b/pkg/rpcserver/simulate_transaction.go @@ -0,0 +1,268 @@ +package rpcserver + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/replay" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/filecoin-project/go-jsonrpc" + "github.com/gagliardetto/solana-go" + "github.com/mr-tron/base58" +) + +type SimulateTransactionResp struct { + Context SimulateTransactionRespContext `json:"context"` + Value SimulateTransactionRespValue `json:"value"` +} + +type SimulateTransactionRespContext struct { + ApiVersion string `json:"apiVersion"` + Slot uint64 `json:"slot"` +} + +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"` +} + +type simulateTransactionConfig struct { + sigVerify bool + replaceRecentBlockhash bool + encoding string + accounts *simulateAccountsConfig +} + +type simulateAccountsConfig struct { + addresses []string + encoding string +} + +func (rpcServer *RpcServer) SimulateTransaction(ctx context.Context, p jsonrpc.RawParams) (SimulateTransactionResp, error) { + params, err := jsonrpc.DecodeParams[[]interface{}](p) + if err != nil { + return SimulateTransactionResp{}, fmt.Errorf("decoding params: %w", err) + } + + if len(params) < 1 { + return SimulateTransactionResp{}, fmt.Errorf("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") + } + + // Parse config + conf := parseSimulateConfig(params) + + // Decode transaction + var tx *solana.Transaction + if conf.encoding == "base58" { + tx, err = solana.TransactionFromBase58(txStr) + } else { + tx, err = solana.TransactionFromBase64(txStr) + } + if err != nil { + return SimulateTransactionResp{}, fmt.Errorf("failed to decode transaction: %w", err) + } + + // Validate sigVerify + replaceRecentBlockhash conflict + if conf.sigVerify && conf.replaceRecentBlockhash { + return SimulateTransactionResp{}, fmt.Errorf("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, + } + } + + // 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 + } + } + + // Get SlotCtx + slotCtx := rpcServer.getSlotCtx() + if slotCtx == nil { + return SimulateTransactionResp{}, fmt.Errorf("node is not ready for simulation") + } + + // Execute transaction using the pure function + output := replay.LoadAndExecuteTransaction(replay.LoadAndExecuteTransactionInput{ + SlotCtx: slotCtx, + Transaction: tx, + TxMeta: nil, + }) + + // Extract logs from ExecCtx if available + var logs []string + if output.ExecCtx != nil { + if logRecorder, ok := output.ExecCtx.Log.(*sealevel.LogRecorder); ok && logRecorder != nil { + logs = logRecorder.Logs + } + } + + resp := SimulateTransactionResp{ + Context: SimulateTransactionRespContext{ + ApiVersion: "mithril 0.1", + Slot: global.Slot(), + }, + Value: SimulateTransactionRespValue{ + Logs: logs, + ReplacementBlockhash: replacementBlockhash, + InnerInstructions: nil, + }, + } + + // Check processing result + if output.ProcessingResult.TransactionError != nil { + txErr := output.ProcessingResult.TransactionError + resp.Value.Err = txErr.InstructionError.Error() + return resp, nil + } + + if output.ProcessingResult.ProcessedTransaction == nil { + return resp, nil + } + + processedTx := output.ProcessingResult.ProcessedTransaction + + if processedTx.TransactionType == replay.ProcessedTransactionTypeExecuted && processedTx.Executed != nil { + executed := processedTx.Executed + units := executed.ExecutionDetails.ExecutedUnits + resp.Value.UnitsConsumed = &units + 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"}, + } + } + + // Execution status error + if executed.ExecutionDetails.Status != nil { + resp.Value.Err = executed.ExecutionDetails.Status.Error() + } + } + + // 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 + } + } + resp.Value.Accounts = accts + } + + return resp, nil +} + +func parseSimulateConfig(params []interface{}) simulateTransactionConfig { + conf := simulateTransactionConfig{ + encoding: "base64", + } + + if len(params) < 2 { + return conf + } + + confMap, ok := params[1].(map[string]interface{}) + if !ok { + return conf + } + + if sigVerify, ok := confMap["sigVerify"].(bool); ok { + conf.sigVerify = sigVerify + } + + if replaceRecentBlockhash, ok := confMap["replaceRecentBlockhash"].(bool); ok { + conf.replaceRecentBlockhash = replaceRecentBlockhash + } + + if encoding, ok := confMap["encoding"].(string); ok { + conf.encoding = encoding + } + + if accountsObj, ok := confMap["accounts"].(map[string]interface{}); ok { + acctConf := &simulateAccountsConfig{} + if addresses, ok := accountsObj["addresses"].([]interface{}); ok { + for _, addr := range addresses { + if addrStr, ok := addr.(string); ok { + acctConf.addresses = append(acctConf.addresses, addrStr) + } + } + } + if encoding, ok := accountsObj["encoding"].(string); ok { + acctConf.encoding = encoding + } + conf.accounts = acctConf + } + + return conf +} From f0d106aaa1655adf9a788f78230cce9f28ef28fb Mon Sep 17 00:00:00 2001 From: Rupansh Sekar Date: Sat, 25 Apr 2026 20:10:24 +0530 Subject: [PATCH 3/7] fix: fix simulation races --- pkg/replay/transaction_processing_pure.go | 3 +++ pkg/rpcserver/simulate_transaction.go | 7 ++++--- pkg/sealevel/bpf_loader.go | 8 +++++--- pkg/sealevel/execution_ctx.go | 1 + pkg/sealevel/loader_v4.go | 8 ++++++-- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/pkg/replay/transaction_processing_pure.go b/pkg/replay/transaction_processing_pure.go index 0c24642f..75e00afa 100644 --- a/pkg/replay/transaction_processing_pure.go +++ b/pkg/replay/transaction_processing_pure.go @@ -27,6 +27,8 @@ type LoadAndExecuteTransactionInput struct { Arena *arena.Arena[sealevel.BorrowedAccount] // TxMeta is the on-chain transaction metadata (optional, used for fee calculation) TxMeta *rpc.TransactionMeta + // IsSimulation indicates this is a simulation (no side effects on shared state) + IsSimulation bool } // LoadAndExecuteTransaction is a pure function that loads and executes a transaction. @@ -135,6 +137,7 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec execCtx.TransactionContext.AllInstructions = instrs execCtx.TransactionContext.Signature = tx.Signatures[0] execCtx.TransactionContext.BorrowedAccountArena = input.Arena + execCtx.IsSimulation = input.IsSimulation // Capture pre-balance lamports (before fee deduction) preBalances := make([]uint64, len(tx.Message.AccountKeys)) diff --git a/pkg/rpcserver/simulate_transaction.go b/pkg/rpcserver/simulate_transaction.go index 3cee0951..d7573c6b 100644 --- a/pkg/rpcserver/simulate_transaction.go +++ b/pkg/rpcserver/simulate_transaction.go @@ -117,9 +117,10 @@ func (rpcServer *RpcServer) SimulateTransaction(ctx context.Context, p jsonrpc.R // Execute transaction using the pure function output := replay.LoadAndExecuteTransaction(replay.LoadAndExecuteTransactionInput{ - SlotCtx: slotCtx, - Transaction: tx, - TxMeta: nil, + SlotCtx: slotCtx, + Transaction: tx, + TxMeta: nil, + IsSimulation: true, }) // Extract logs from ExecCtx if available diff --git a/pkg/sealevel/bpf_loader.go b/pkg/sealevel/bpf_loader.go index 3a643c36..2bccf493 100644 --- a/pkg/sealevel/bpf_loader.go +++ b/pkg/sealevel/bpf_loader.go @@ -547,7 +547,7 @@ func serializeParametersAligned(execCtx *ExecutionCtx) ([]byte, []uint64, uint64 size += solana.PublicKeyLength // program id var serializedData []byte - if execCtx.SlotCtx.SerializedParameterArena != nil { + if !execCtx.IsSimulation && execCtx.SlotCtx.SerializedParameterArena != nil { arenaData, _ := execCtx.SlotCtx.SerializedParameterArena.AllocN(size) serializedData = arenaData[:0] } else { @@ -824,7 +824,7 @@ func serializeParametersUnaligned(execCtx *ExecutionCtx) ([]byte, []uint64, uint size += solana.PublicKeyLength // program id var serializedData []byte - if execCtx.SlotCtx.SerializedParameterArena != nil { + if !execCtx.IsSimulation && execCtx.SlotCtx.SerializedParameterArena != nil { arenaData, _ := execCtx.SlotCtx.SerializedParameterArena.AllocN(size) serializedData = arenaData[:0] // Use arena slice with zero length but full capacity } else { @@ -1100,7 +1100,9 @@ func executeProgramFromBytes(execCtx *ExecutionCtx, programAddr solana.PublicKey } entry := &accountsdb.ProgramCacheEntry{Program: program} - execCtx.SlotCtx.AccountsDb.AddProgramToCache(programAddr, entry) + if !execCtx.IsSimulation { + execCtx.SlotCtx.AccountsDb.AddProgramToCache(programAddr, entry) + } metrics.GlobalBlockReplay.AddProgramToCache.AddTimingSince(start) diff --git a/pkg/sealevel/execution_ctx.go b/pkg/sealevel/execution_ctx.go index 72358d11..c74aab75 100644 --- a/pkg/sealevel/execution_ctx.go +++ b/pkg/sealevel/execution_ctx.go @@ -26,6 +26,7 @@ type ExecutionCtx struct { PrevLamportsPerSignature uint64 SlotCtx *SlotCtx ModifiedVoteStates map[solana.PublicKey]*VoteStateVersions + IsSimulation bool } type SlotBank struct { diff --git a/pkg/sealevel/loader_v4.go b/pkg/sealevel/loader_v4.go index 5e029e46..92a6b5c3 100644 --- a/pkg/sealevel/loader_v4.go +++ b/pkg/sealevel/loader_v4.go @@ -619,7 +619,9 @@ func LoaderV4ProcessDeploy(execCtx *ExecutionCtx) error { } entry := &accountsdb.ProgramCacheEntry{Program: programObj, DeploymentSlot: currentSlot} - execCtx.SlotCtx.AccountsDb.AddProgramToCache(program.Key(), entry) + if !execCtx.IsSimulation { + execCtx.SlotCtx.AccountsDb.AddProgramToCache(program.Key(), entry) + } return nil } @@ -669,7 +671,9 @@ func LoaderV4ProcessRetract(execCtx *ExecutionCtx) error { return err } - execCtx.SlotCtx.AccountsDb.RemoveProgramFromCache(program.Key()) + if !execCtx.IsSimulation { + execCtx.SlotCtx.AccountsDb.RemoveProgramFromCache(program.Key()) + } return nil } From ae736348e768d8cb0d62e851bfcd5f9624f9d409 Mon Sep 17 00:00:00 2001 From: Neeraj Godiyal Date: Mon, 4 May 2026 16:10:42 +0530 Subject: [PATCH 4/7] fix(replay,rpcserver): simulateTransaction safety and ALT resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make simulateTransaction safe for any user-submitted transaction. * Missing accounts: the SIMD-186 loader panicked on absent pubkeys. Now returns an empty System-owned default (matches Agave), so transactions that create new accounts can simulate. * Malformed transactions: added Agave-style sanitize that rejects zero/insufficient signatures, signatures > keys, out-of-range program/account indices, too many instructions (feature-gated), and the no-writable-signer case. All return SanitizeFailure. * Address lookup tables resolved on the simulate path so versioned transactions work end-to-end. * JSON wire format: TransactionError marshals to Agave's exact shape — bare string for unit variants, single-key object for tuple variants like InstructionError. * Rent sysvar load failure and fees.go no-fee-payer panics are now clean error returns. * Stake program: fix variable shadowing that silently dropped a rent sysvar read error. --- pkg/fees/fees.go | 4 +- pkg/replay/accounts.go | 11 +- pkg/replay/accounts_test.go | 174 +++++++++ pkg/replay/block.go | 35 ++ pkg/replay/transaction_processing_pure.go | 54 ++- .../transaction_processing_pure_test.go | 344 ++++++++++++++++++ pkg/replay/transaction_processing_types.go | 156 ++++++++ pkg/rpcserver/simulate_transaction.go | 20 +- pkg/sealevel/stake_program.go | 3 + pkg/sealevel/sysvar_rent.go | 2 +- 10 files changed, 796 insertions(+), 7 deletions(-) create mode 100644 pkg/replay/accounts_test.go create mode 100644 pkg/replay/transaction_processing_pure_test.go diff --git a/pkg/fees/fees.go b/pkg/fees/fees.go index a9380539..d6d5c76d 100644 --- a/pkg/fees/fees.go +++ b/pkg/fees/fees.go @@ -103,7 +103,9 @@ func CalculateTxFees(tx *solana.Transaction, instrs []sealevel.Instruction, comp func CalculateAndDeductTxFees(tx *solana.Transaction, txMeta *rpc.TransactionMeta, instrs []sealevel.Instruction, transactionAccts *sealevel.TransactionAccounts, computeBudgetLimits *sealevel.ComputeBudgetLimits, f *features.Features) (*TxFeeInfo, uint64, error) { feePayerAcct, err := transactionAccts.GetAccount(feePayerIdx) if err != nil { - panic("no fee payer") + // Defensive: sanitize guarantees AccountKeys[0]; return cleanly + // rather than crash if a future caller bypasses the guard. + return nil, 0, err } ////mlog.Log.Debugf("feePayerAcct=%+v", feePayerAcct) diff --git a/pkg/replay/accounts.go b/pkg/replay/accounts.go index 19e9747e..e218e56c 100644 --- a/pkg/replay/accounts.go +++ b/pkg/replay/accounts.go @@ -1,6 +1,7 @@ package replay import ( + "math" "slices" "sync/atomic" @@ -291,7 +292,15 @@ func loadAndValidateTxAcctsSimd186(slotCtx *sealevel.SlotCtx, acctMetasPerInstr } else { acct, err = slotCtx.GetAccountShared(pubkey) if err != nil { - panic("should be impossible - programming error") + // Mirror Agave's load_transaction_account: fabricate an + // empty System-owned default with rent-exempt epoch + // (SIMD-0267) so simulate can model accounts the tx + // itself creates. + acct = &accounts.Account{ + Key: pubkey, + Owner: addresses.SystemProgramAddr, + RentEpoch: math.MaxUint64, + } } } acctCache[i] = acct // Cache by index for reuse in Pass 2 diff --git a/pkg/replay/accounts_test.go b/pkg/replay/accounts_test.go new file mode 100644 index 00000000..67b9537a --- /dev/null +++ b/pkg/replay/accounts_test.go @@ -0,0 +1,174 @@ +package replay + +import ( + "math" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/addresses" + "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" +) + +// newSimd186SlotCtx returns a minimal SlotCtx wired for the SIMD-186 +// account loader path: empty MemAccounts and the +// FormalizeLoadedTransactionDataSize gate enabled. +func newSimd186SlotCtx() *sealevel.SlotCtx { + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + mem := accounts.NewMemAccounts() + return &sealevel.SlotCtx{ + Accounts: mem, + Features: feats, + } +} + +// TestLoadAndValidateTxAcctsSimd186_FabricatesDefaultForMissingAccount +// verifies the simulate-path fix: a tx that references a pubkey absent +// from local state must not panic. The loader fabricates an empty +// System-owned default (matching Agave's load_transaction_account). +func TestLoadAndValidateTxAcctsSimd186_FabricatesDefaultForMissingAccount(t *testing.T) { + slotCtx := newSimd186SlotCtx() + + missingKey := testPubkey(42) + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1}, + AccountKeys: []solana.PublicKey{missingKey}, + }, + } + instrsAcct := &accounts.Account{Key: sealevel.SysvarInstructionsAddr} + + require.NotPanics(t, func() { + txAccts, _, err := loadAndValidateTxAcctsSimd186( + slotCtx, + nil, // acctMetasPerInstr — no instructions + tx, + nil, // instrs — no instructions + instrsAcct, + math.MaxUint32, + ) + require.NoError(t, err) + require.NotNil(t, txAccts) + require.Len(t, txAccts.Accounts, 1) + + fabricated := txAccts.Accounts[0] + assert.Equal(t, missingKey, fabricated.Key) + assert.Equal(t, addresses.SystemProgramAddr, fabricated.Owner) + assert.Equal(t, uint64(0), fabricated.Lamports) + assert.Equal(t, uint64(math.MaxUint64), fabricated.RentEpoch, "fabricated default must use rent-exempt epoch") + assert.False(t, fabricated.Executable) + assert.Empty(t, fabricated.Data) + }) +} + +// TestLoadAndValidateTxAcctsSimd186_LoadedAccountTakesPrecedence +// confirms that an account already present in slotCtx is returned +// unchanged — fabrication only kicks in for the missing case. +func TestLoadAndValidateTxAcctsSimd186_LoadedAccountTakesPrecedence(t *testing.T) { + slotCtx := newSimd186SlotCtx() + mem, ok := slotCtx.Accounts.(accounts.MemAccounts) + require.True(t, ok) + + loadedKey := testPubkey(7) + loaded := &accounts.Account{ + Key: loadedKey, + Owner: addresses.NativeLoaderAddr, + Lamports: 100_000, + RentEpoch: 50, + } + require.NoError(t, mem.SetAccountWithoutLock(loadedKey, loaded)) + + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1}, + AccountKeys: []solana.PublicKey{loadedKey}, + }, + } + instrsAcct := &accounts.Account{Key: sealevel.SysvarInstructionsAddr} + + txAccts, _, err := loadAndValidateTxAcctsSimd186( + slotCtx, nil, tx, nil, instrsAcct, math.MaxUint32, + ) + require.NoError(t, err) + require.Len(t, txAccts.Accounts, 1) + got := txAccts.Accounts[0] + assert.Equal(t, loadedKey, got.Key) + assert.Equal(t, uint64(100_000), got.Lamports, "loaded account must not be replaced by fabricated default") + assert.Equal(t, addresses.NativeLoaderAddr, got.Owner) + assert.Equal(t, uint64(50), got.RentEpoch) +} + +// TestLoadAndValidateTxAcctsSimd186_ProgramRejectsFabricatedDefault is +// the security guardrail: when a tx names a non-existent pubkey as the +// program ID, Pass-1 fabricates a default System-owned account, but +// Pass-2 program validation must reject it (lamports==0 short-circuit) +// rather than letting an empty fabricated account act as a callable +// program. +func TestLoadAndValidateTxAcctsSimd186_ProgramRejectsFabricatedDefault(t *testing.T) { + slotCtx := newSimd186SlotCtx() + + missingProgram := testPubkey(99) + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1}, + AccountKeys: []solana.PublicKey{missingProgram}, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 0, Accounts: nil, Data: nil}, + }, + }, + } + instrs := []sealevel.Instruction{ + {ProgramId: missingProgram, Data: nil, Accounts: nil}, + } + acctMetasPerInstr := [][]sealevel.AccountMeta{nil} + instrsAcct := &accounts.Account{Key: sealevel.SysvarInstructionsAddr} + + _, _, err := loadAndValidateTxAcctsSimd186( + slotCtx, acctMetasPerInstr, tx, instrs, instrsAcct, math.MaxUint32, + ) + require.Error(t, err, "fabricated default with lamports=0 must be rejected as a program") + assert.Equal(t, TxErrProgramAccountNotFound, err) +} + +// TestLoadAndValidateTxAcctsSimd186_MixedLoadedAndMissing covers a tx +// with both a loaded account (the fee payer) and a missing account +// (the new account being created) — the canonical create_account +// simulate pattern. +func TestLoadAndValidateTxAcctsSimd186_MixedLoadedAndMissing(t *testing.T) { + slotCtx := newSimd186SlotCtx() + mem, ok := slotCtx.Accounts.(accounts.MemAccounts) + require.True(t, ok) + + feePayerKey := testPubkey(1) + feePayer := &accounts.Account{ + Key: feePayerKey, + Owner: addresses.SystemProgramAddr, + Lamports: 10 * 1_000_000_000, // 10 SOL + RentEpoch: math.MaxUint64, + } + require.NoError(t, mem.SetAccountWithoutLock(feePayerKey, feePayer)) + + missingKey := testPubkey(2) // account being created — does not exist locally + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1}, + AccountKeys: []solana.PublicKey{feePayerKey, missingKey}, + }, + } + instrsAcct := &accounts.Account{Key: sealevel.SysvarInstructionsAddr} + + txAccts, _, err := loadAndValidateTxAcctsSimd186( + slotCtx, nil, tx, nil, instrsAcct, math.MaxUint32, + ) + require.NoError(t, err) + require.Len(t, txAccts.Accounts, 2) + + assert.Equal(t, uint64(10_000_000_000), txAccts.Accounts[0].Lamports, "fee payer should be untouched") + assert.Equal(t, uint64(0), txAccts.Accounts[1].Lamports, "missing account should be fabricated default") + assert.Equal(t, addresses.SystemProgramAddr, txAccts.Accounts[1].Owner) + assert.Equal(t, uint64(math.MaxUint64), txAccts.Accounts[1].RentEpoch) +} diff --git a/pkg/replay/block.go b/pkg/replay/block.go index de96000c..8b549b47 100644 --- a/pkg/replay/block.go +++ b/pkg/replay/block.go @@ -282,6 +282,41 @@ txResolveLoop: return nil } +// ResolveAddrTableLookupsForTx resolves a single tx's address-table lookups +// against accountsdb at the given slot. +// +// No-op for legacy or empty-lookup versioned txs. Returns wrapped errors so +// callers can map missing/invalid tables to AddressLookupTableNotFound or +// InvalidAddressLookupTableData. +func ResolveAddrTableLookupsForTx(ctx context.Context, accountsDb *accountsdb.AccountsDb, slot uint64, tx *solana.Transaction) error { + if !tx.Message.IsVersioned() || tx.Message.AddressTableLookups.NumLookups() == 0 { + return nil + } + + tableIDs := tx.Message.GetAddressTableLookups().GetTableIDs() + accts, err := accountsDb.GetAccountsBatch(ctx, slot, tableIDs) + if err != nil { + return err + } + + tables := make(map[solana.PublicKey]solana.PublicKeySlice, len(tableIDs)) + for i, key := range tableIDs { + if accts[i] == nil || len(accts[i].Data) == 0 { + return fmt.Errorf("address lookup table %s not found", key) + } + addrLookupTable, err := sealevel.UnmarshalAddressLookupTable(accts[i].Data) + if err != nil { + return fmt.Errorf("address lookup table %s: invalid data: %w", key, err) + } + tables[key] = addrLookupTable.Addresses + } + + if err := tx.Message.SetAddressTables(tables); err != nil { + return err + } + return tx.Message.ResolveLookups() +} + func extractAndDedupeBlockAccts(block *b.Block) []solana.PublicKey { var numPubkeys int for _, tx := range block.Transactions { diff --git a/pkg/replay/transaction_processing_pure.go b/pkg/replay/transaction_processing_pure.go index 75e00afa..5d1e4d7e 100644 --- a/pkg/replay/transaction_processing_pure.go +++ b/pkg/replay/transaction_processing_pure.go @@ -35,6 +35,19 @@ type LoadAndExecuteTransactionInput struct { // It takes all required state as input and returns all state changes as output without // modifying the input SlotCtx. This function can be used for both actual execution // and simulation. +// sanitizeFailureOutput returns the canonical SanitizeFailure response. +// InstructionError is left nil so the RPC renderer falls back to +// ErrorType.String() and emits Agave-format "SanitizeFailure". +func sanitizeFailureOutput() LoadAndExecuteTransactionOutput { + return LoadAndExecuteTransactionOutput{ + ProcessingResult: TransactionProcessingResult{ + TransactionError: &TransactionError{ + ErrorType: TransactionErrorSanitizeFailure, + }, + }, + } +} + func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExecuteTransactionOutput { tx := input.Transaction slotCtx := input.SlotCtx @@ -43,6 +56,34 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec input.Arena.Reset() } + // Sanitize per Agave's Message::sanitize. Prevents index panics on + // tx.Signatures[0], AccountKeys[0], and the unchecked metas[acct] + // inside solana-go's ResolveInstructionAccounts for malformed + // user-submitted txs. + hdr := tx.Message.Header + numKeys := len(tx.Message.AccountKeys) + if hdr.NumReadonlySignedAccounts >= hdr.NumRequiredSignatures || + int(hdr.NumRequiredSignatures) > len(tx.Signatures) || + len(tx.Signatures) > numKeys { + return sanitizeFailureOutput() + } + // Mirror block-replay's StaticInstructionLimit cap so pre-activation + // clusters fail mid-execution like Agave instead of SanitizeFailure. + if slotCtx.Features.IsActive(features.StaticInstructionLimit) && + len(tx.Message.Instructions) > maxInstrTraceCapacity { + return sanitizeFailureOutput() + } + for _, ci := range tx.Message.Instructions { + if int(ci.ProgramIDIndex) >= numKeys { + return sanitizeFailureOutput() + } + for _, idx := range ci.Accounts { + if int(idx) >= numKeys { + return sanitizeFailureOutput() + } + } + } + // Parse instructions and account metas start := time.Now() instrs, acctMetasPerInstr, err := instrsAndAcctMetasFromTx(tx, slotCtx.Features) @@ -170,7 +211,18 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec start = time.Now() rentSysvar, err := sealevel.ReadRentSysvar(execCtx) if err != nil { - panic("failed to get and deserialize rent sysvar") + // Rent sysvar unreadable; return cleanly so the RPC worker + // doesn't crash on local-state corruption. + return LoadAndExecuteTransactionOutput{ + ProcessingResult: TransactionProcessingResult{ + TransactionError: &TransactionError{ + ErrorType: TransactionErrorAccountNotFound, + InstructionError: err, + }, + }, + Instrs: instrs, + ComputeBudgetLimits: computeBudgetLimits, + } } metrics.GlobalBlockReplay.ReadRentSysvar.AddTimingSince(start) diff --git a/pkg/replay/transaction_processing_pure_test.go b/pkg/replay/transaction_processing_pure_test.go new file mode 100644 index 00000000..0f8433f0 --- /dev/null +++ b/pkg/replay/transaction_processing_pure_test.go @@ -0,0 +1,344 @@ +package replay + +import ( + "encoding/json" + "testing" + + "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" +) + +// TestLoadAndExecuteTransaction_RejectsUnsignedTx confirms the sanitize +// guard at the entry of LoadAndExecuteTransaction returns a clean +// SanitizeFailure when a malformed (zero-signature) tx is submitted — +// avoiding the index-out-of-range panic at tx.Signatures[0]. +func TestLoadAndExecuteTransaction_RejectsUnsignedTx(t *testing.T) { + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + slotCtx := &sealevel.SlotCtx{Features: feats} + + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 0}, + AccountKeys: []solana.PublicKey{testPubkey(1)}, + }, + // Signatures intentionally empty — this would have panicked at + // transaction_processing_pure.go before the guard. + } + + require.NotPanics(t, func() { + out := LoadAndExecuteTransaction(LoadAndExecuteTransactionInput{ + SlotCtx: slotCtx, + Transaction: tx, + IsSimulation: true, + }) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) + }) +} + +// TestLoadAndExecuteTransaction_RejectsInsufficientSignatures covers the +// other Agave-sanitize case: header declares more required signatures +// than the tx actually carries. +func TestLoadAndExecuteTransaction_RejectsInsufficientSignatures(t *testing.T) { + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + slotCtx := &sealevel.SlotCtx{Features: feats} + + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 2}, + AccountKeys: []solana.PublicKey{testPubkey(1), testPubkey(2)}, + }, + Signatures: []solana.Signature{{}}, // 1 sig but header demands 2 + } + + require.NotPanics(t, func() { + out := LoadAndExecuteTransaction(LoadAndExecuteTransactionInput{ + SlotCtx: slotCtx, + Transaction: tx, + IsSimulation: true, + }) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) + }) +} + +// TestLoadAndExecuteTransaction_RejectsZeroNumRequiredSignatures matches +// Agave's Message::sanitize rule: every tx must declare at least one +// required signer (the fee payer). A header with NumRequiredSignatures=0 +// is malformed even if Signatures is non-empty. +func TestLoadAndExecuteTransaction_RejectsZeroNumRequiredSignatures(t *testing.T) { + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + slotCtx := &sealevel.SlotCtx{Features: feats} + + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 0}, + AccountKeys: []solana.PublicKey{testPubkey(1)}, + }, + Signatures: []solana.Signature{{}}, // signatures present but header says 0 + } + + out := LoadAndExecuteTransaction(LoadAndExecuteTransactionInput{ + SlotCtx: slotCtx, + Transaction: tx, + IsSimulation: true, + }) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) + // InstructionError is intentionally nil so the RPC renderer falls + // back to ErrorType.String() and emits Agave-format "SanitizeFailure". + assert.Nil(t, out.ProcessingResult.TransactionError.InstructionError) + assert.Equal(t, "SanitizeFailure", out.ProcessingResult.TransactionError.ErrorType.String()) +} + +// TestLoadAndExecuteTransaction_RejectsMoreSigsThanKeys covers Agave's +// Transaction::sanitize Tx-2 rule: len(signatures) <= len(account_keys). +// Without this guard, a tx with empty AccountKeys but non-empty +// Signatures would later panic at fee-payer access (AccountKeys[0]) +// or at fees.go:106 ("no fee payer"). +func TestLoadAndExecuteTransaction_RejectsMoreSigsThanKeys(t *testing.T) { + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + slotCtx := &sealevel.SlotCtx{Features: feats} + + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1}, + AccountKeys: nil, // no keys at all + }, + Signatures: []solana.Signature{{}}, // 1 sig but 0 keys + } + + require.NotPanics(t, func() { + out := LoadAndExecuteTransaction(LoadAndExecuteTransactionInput{ + SlotCtx: slotCtx, + Transaction: tx, + IsSimulation: true, + }) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) + assert.Nil(t, out.ProcessingResult.TransactionError.InstructionError) + }) +} + +// TestLoadAndExecuteTransaction_RejectsZeroWritableSigners covers +// Agave's Message::sanitize Msg-2 rule (legacy.rs:149): +// NumReadonlySignedAccounts >= NumRequiredSignatures is rejected because +// it leaves no writable signer (no fee-payer). Without this guard, +// fee deduction operates on a read-only account, producing wrong +// simulate output. +func TestLoadAndExecuteTransaction_RejectsZeroWritableSigners(t *testing.T) { + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + slotCtx := &sealevel.SlotCtx{Features: feats} + + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{ + NumRequiredSignatures: 1, + NumReadonlySignedAccounts: 1, // ← only signer is read-only + NumReadonlyUnsignedAccounts: 0, + }, + AccountKeys: []solana.PublicKey{testPubkey(1)}, + }, + Signatures: []solana.Signature{{}}, + } + + out := LoadAndExecuteTransaction(LoadAndExecuteTransactionInput{ + SlotCtx: slotCtx, + Transaction: tx, + IsSimulation: true, + }) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) +} + +// TestLoadAndExecuteTransaction_RejectsOOBInstructionAccountIndex covers +// the CRITICAL panic vector: solana-go's +// CompiledInstruction.ResolveInstructionAccounts dereferences metas[acct] +// without a bounds check (transaction.go:148-150). A user-submitted tx +// with an out-of-range index (e.g. Accounts:[42] when only 1 key exists) +// would crash the RPC worker. The sanitize guard rejects such txs. +func TestLoadAndExecuteTransaction_RejectsOOBInstructionAccountIndex(t *testing.T) { + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + slotCtx := &sealevel.SlotCtx{Features: feats} + + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1}, + AccountKeys: []solana.PublicKey{testPubkey(1)}, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 0, Accounts: []uint16{42}, Data: nil}, // 42 >> len(keys)=1 + }, + }, + Signatures: []solana.Signature{{}}, + } + + require.NotPanics(t, func() { + out := LoadAndExecuteTransaction(LoadAndExecuteTransactionInput{ + SlotCtx: slotCtx, + Transaction: tx, + IsSimulation: true, + }) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) + assert.Nil(t, out.ProcessingResult.TransactionError.InstructionError) + }) +} + +// TestLoadAndExecuteTransaction_RejectsOOBProgramIDIndex covers the +// related but bounds-checked path: ProgramIDIndex out of range is +// rejected by solana-go's ResolveProgramIDIndex with a clean error, +// but only AFTER instructions are parsed. Our sanitize catches it +// up-front so no parsing is wasted. +func TestLoadAndExecuteTransaction_RejectsOOBProgramIDIndex(t *testing.T) { + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + slotCtx := &sealevel.SlotCtx{Features: feats} + + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1}, + AccountKeys: []solana.PublicKey{testPubkey(1)}, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 99, Accounts: nil, Data: nil}, // 99 >> 1 + }, + }, + Signatures: []solana.Signature{{}}, + } + + require.NotPanics(t, func() { + out := LoadAndExecuteTransaction(LoadAndExecuteTransactionInput{ + SlotCtx: slotCtx, + Transaction: tx, + IsSimulation: true, + }) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) + }) +} + +// TestLoadAndExecuteTransaction_RejectsTooManyInstructions covers the +// instruction-count cap. The cap is feature-gated on +// StaticInstructionLimit (matching block-replay's check at +// transaction.go:453-457) so pre-activation networks behave identically +// to Agave (mid-execution failure rather than SanitizeFailure). +func TestLoadAndExecuteTransaction_RejectsTooManyInstructions(t *testing.T) { + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + feats.EnableFeature(features.StaticInstructionLimit, 0) + slotCtx := &sealevel.SlotCtx{Features: feats} + + instrs := make([]solana.CompiledInstruction, 65) // > 64 cap + for i := range instrs { + instrs[i] = solana.CompiledInstruction{ProgramIDIndex: 0, Accounts: nil, Data: nil} + } + tx := &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1}, + AccountKeys: []solana.PublicKey{testPubkey(1)}, + Instructions: instrs, + }, + Signatures: []solana.Signature{{}}, + } + + out := LoadAndExecuteTransaction(LoadAndExecuteTransactionInput{ + SlotCtx: slotCtx, + Transaction: tx, + IsSimulation: true, + }) + require.NotNil(t, out.ProcessingResult.TransactionError) + assert.Equal(t, TransactionErrorSanitizeFailure, out.ProcessingResult.TransactionError.ErrorType) +} + +// TestTransactionError_MarshalJSON_UnitVariant verifies that unit-variant +// TransactionErrors marshal to a bare JSON string matching Agave's serde +// rendering (e.g. "BlockhashNotFound", not "TxErrBlockhashNotFound"). +func TestTransactionError_MarshalJSON_UnitVariant(t *testing.T) { + cases := map[TransactionErrorType]string{ + TransactionErrorBlockhashNotFound: `"BlockhashNotFound"`, + TransactionErrorSanitizeFailure: `"SanitizeFailure"`, + TransactionErrorAccountNotFound: `"AccountNotFound"`, + TransactionErrorProgramAccountNotFound: `"ProgramAccountNotFound"`, + } + for in, want := range cases { + got, err := json.Marshal(&TransactionError{ErrorType: in}) + require.NoError(t, err) + assert.Equal(t, want, string(got), "variant %d", in) + } +} + +// TestTransactionError_MarshalJSON_InstructionError verifies the +// {"InstructionError":[idx, "VariantName"]} tuple shape that Agave emits. +// Critical for Anchor IDL error decoding to work. +func TestTransactionError_MarshalJSON_InstructionError(t *testing.T) { + idx := uint8(2) + got, err := json.Marshal(&TransactionError{ + ErrorType: TransactionErrorInstructionError, + InstructionIndex: &idx, + InstructionError: sealevel.InstrErrInvalidArgument, + }) + require.NoError(t, err) + assert.JSONEq(t, `{"InstructionError":[2,"InvalidArgument"]}`, string(got)) +} + +// TestTransactionError_MarshalJSON_CustomCarriesShape verifies the +// {"InstructionError":[idx, {"Custom":N}]} object shape Agave uses for +// program-defined error codes. Mithril does not yet preserve the program- +// specific u32, so the inner code is 0 — but the SHAPE is correct so +// Anchor / web3.js parsers don't break on the response. +func TestTransactionError_MarshalJSON_CustomCarriesShape(t *testing.T) { + idx := uint8(0) + got, err := json.Marshal(&TransactionError{ + ErrorType: TransactionErrorInstructionError, + InstructionIndex: &idx, + InstructionError: sealevel.InstrErrCustom, + }) + require.NoError(t, err) + assert.JSONEq(t, `{"InstructionError":[0,{"Custom":0}]}`, string(got)) +} + +// TestTransactionError_MarshalJSON_TupleStructVariants covers the +// account_index struct variants (InsufficientFundsForRent, +// ProgramExecutionTemporarilyRestricted) and the u8 tuple variant +// (DuplicateInstruction). +func TestTransactionError_MarshalJSON_TupleStructVariants(t *testing.T) { + ai := uint8(3) + got, err := json.Marshal(&TransactionError{ + ErrorType: TransactionErrorInsufficientFundsForRent, + AccountIndex: &ai, + }) + require.NoError(t, err) + assert.JSONEq(t, `{"InsufficientFundsForRent":{"account_index":3}}`, string(got)) + + idx := uint8(5) + got, err = json.Marshal(&TransactionError{ + ErrorType: TransactionErrorDuplicateInstruction, + InstructionIndex: &idx, + }) + require.NoError(t, err) + assert.JSONEq(t, `{"DuplicateInstruction":5}`, string(got)) +} + +// TestTransactionErrorType_String spot-checks the Agave-format wire +// names for variants the simulate handler can fall back to when the +// inner Go error is nil. +func TestTransactionErrorType_String(t *testing.T) { + cases := map[TransactionErrorType]string{ + TransactionErrorSanitizeFailure: "SanitizeFailure", + TransactionErrorBlockhashNotFound: "BlockhashNotFound", + TransactionErrorAccountNotFound: "AccountNotFound", + TransactionErrorProgramAccountNotFound: "ProgramAccountNotFound", + TransactionErrorInsufficientFundsForFee: "InsufficientFundsForFee", + } + for in, want := range cases { + assert.Equal(t, want, in.String(), "variant %d should stringify to Agave name", in) + } +} diff --git a/pkg/replay/transaction_processing_types.go b/pkg/replay/transaction_processing_types.go index 44835611..dd3c74f2 100644 --- a/pkg/replay/transaction_processing_types.go +++ b/pkg/replay/transaction_processing_types.go @@ -1,6 +1,9 @@ package replay import ( + "encoding/json" + "strings" + "github.com/Overclock-Validator/mithril/pkg/accounts" "github.com/Overclock-Validator/mithril/pkg/fees" "github.com/Overclock-Validator/mithril/pkg/sealevel" @@ -133,6 +136,91 @@ const ( TransactionErrorProgramCacheHitMaxLimit ) +// String returns the Agave-compatible TransactionError variant name (the +// const name with the TransactionError prefix stripped). Used by simulate +// callers that need a wire-format string when no InstructionError is set. +func (t TransactionErrorType) String() string { + switch t { + case TransactionErrorAccountInUse: + return "AccountInUse" + case TransactionErrorAccountLoadedTwice: + return "AccountLoadedTwice" + case TransactionErrorAccountNotFound: + return "AccountNotFound" + case TransactionErrorProgramAccountNotFound: + return "ProgramAccountNotFound" + case TransactionErrorInsufficientFundsForFee: + return "InsufficientFundsForFee" + case TransactionErrorInvalidAccountForFee: + return "InvalidAccountForFee" + case TransactionErrorAlreadyProcessed: + return "AlreadyProcessed" + case TransactionErrorBlockhashNotFound: + return "BlockhashNotFound" + case TransactionErrorInstructionError: + return "InstructionError" + case TransactionErrorCallChainTooDeep: + return "CallChainTooDeep" + case TransactionErrorMissingSignatureForFee: + return "MissingSignatureForFee" + case TransactionErrorInvalidAccountIndex: + return "InvalidAccountIndex" + case TransactionErrorSignatureFailure: + return "SignatureFailure" + case TransactionErrorInvalidProgramForExecution: + return "InvalidProgramForExecution" + case TransactionErrorSanitizeFailure: + return "SanitizeFailure" + case TransactionErrorClusterMaintenance: + return "ClusterMaintenance" + case TransactionErrorAccountBorrowOutstanding: + return "AccountBorrowOutstanding" + case TransactionErrorWouldExceedMaxBlockCostLimit: + return "WouldExceedMaxBlockCostLimit" + case TransactionErrorUnsupportedVersion: + return "UnsupportedVersion" + case TransactionErrorInvalidWritableAccount: + return "InvalidWritableAccount" + case TransactionErrorWouldExceedMaxAccountCostLimit: + return "WouldExceedMaxAccountCostLimit" + case TransactionErrorWouldExceedAccountDataBlockLimit: + return "WouldExceedAccountDataBlockLimit" + case TransactionErrorTooManyAccountLocks: + return "TooManyAccountLocks" + case TransactionErrorAddressLookupTableNotFound: + return "AddressLookupTableNotFound" + case TransactionErrorInvalidAddressLookupTableOwner: + return "InvalidAddressLookupTableOwner" + case TransactionErrorInvalidAddressLookupTableData: + return "InvalidAddressLookupTableData" + case TransactionErrorInvalidAddressLookupTableIndex: + return "InvalidAddressLookupTableIndex" + case TransactionErrorInvalidRentPayingAccount: + return "InvalidRentPayingAccount" + case TransactionErrorWouldExceedMaxVoteCostLimit: + return "WouldExceedMaxVoteCostLimit" + case TransactionErrorWouldExceedAccountDataTotalLimit: + return "WouldExceedAccountDataTotalLimit" + case TransactionErrorDuplicateInstruction: + return "DuplicateInstruction" + case TransactionErrorInsufficientFundsForRent: + return "InsufficientFundsForRent" + case TransactionErrorMaxLoadedAccountsDataSizeExceeded: + return "MaxLoadedAccountsDataSizeExceeded" + case TransactionErrorInvalidLoadedAccountsDataSizeLimit: + return "InvalidLoadedAccountsDataSizeLimit" + case TransactionErrorResanitizationNeeded: + return "ResanitizationNeeded" + case TransactionErrorProgramExecutionTemporarilyRestricted: + return "ProgramExecutionTemporarilyRestricted" + case TransactionErrorUnbalancedTransaction: + return "UnbalancedTransaction" + case TransactionErrorProgramCacheHitMaxLimit: + return "ProgramCacheHitMaxLimit" + } + return "Unknown" +} + // ProcessedTransaction represents a transaction that was processed, either executed or fees-only type ProcessedTransaction struct { // TransactionType indicates whether this is an Executed or FeesOnly transaction @@ -303,3 +391,71 @@ type TransactionExecutionResult struct { // ModifiedVoteAccounts contains vote accounts that were modified ModifiedVoteAccounts map[solana.PublicKey]*sealevel.VoteStateVersions } + +// agaveInstrErrName converts a Mithril InstrErr*/TxErr* sentinel into the +// Agave-format JSON value (bare string for unit variants, object for +// Custom and BorshIoError). +func agaveInstrErrName(err error) interface{} { + if err == nil { + return nil + } + // TODO: propagate the program-defined u32 through InstructionError + // rather than emitting 0 as a placeholder. + if err == sealevel.InstrErrCustom { + return map[string]uint32{"Custom": 0} + } + if err == sealevel.InstrErrBorshIoError { + return map[string]string{"BorshIoError": err.Error()} + } + name := err.Error() + switch { + case strings.HasPrefix(name, "InstrErr"): + return strings.TrimPrefix(name, "InstrErr") + case strings.HasPrefix(name, "TxErr"): + return strings.TrimPrefix(name, "TxErr") + } + return name +} + +// MarshalJSON renders TransactionError in Agave's wire format: bare string +// for unit variants, single-key object for tuple variants such as +// {"InstructionError":[idx, inner]}. +func (e *TransactionError) MarshalJSON() ([]byte, error) { + if e == nil { + return []byte("null"), nil + } + switch e.ErrorType { + case TransactionErrorInstructionError: + var idx uint8 + if e.InstructionIndex != nil { + idx = *e.InstructionIndex + } + return json.Marshal(map[string]interface{}{ + "InstructionError": []interface{}{idx, agaveInstrErrName(e.InstructionError)}, + }) + case TransactionErrorInsufficientFundsForRent: + var ai uint8 + if e.AccountIndex != nil { + ai = *e.AccountIndex + } + return json.Marshal(map[string]interface{}{ + "InsufficientFundsForRent": map[string]uint8{"account_index": ai}, + }) + case TransactionErrorProgramExecutionTemporarilyRestricted: + var ai uint8 + if e.AccountIndex != nil { + ai = *e.AccountIndex + } + return json.Marshal(map[string]interface{}{ + "ProgramExecutionTemporarilyRestricted": map[string]uint8{"account_index": ai}, + }) + case TransactionErrorDuplicateInstruction: + var idx uint8 + if e.InstructionIndex != nil { + idx = *e.InstructionIndex + } + return json.Marshal(map[string]uint8{"DuplicateInstruction": idx}) + default: + return json.Marshal(e.ErrorType.String()) + } +} diff --git a/pkg/rpcserver/simulate_transaction.go b/pkg/rpcserver/simulate_transaction.go index d7573c6b..11a8b9e0 100644 --- a/pkg/rpcserver/simulate_transaction.go +++ b/pkg/rpcserver/simulate_transaction.go @@ -115,6 +115,21 @@ func (rpcServer *RpcServer) SimulateTransaction(ctx context.Context, p jsonrpc.R 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. + 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 output := replay.LoadAndExecuteTransaction(replay.LoadAndExecuteTransactionInput{ SlotCtx: slotCtx, @@ -143,10 +158,9 @@ func (rpcServer *RpcServer) SimulateTransaction(ctx context.Context, p jsonrpc.R }, } - // Check processing result + // TransactionError.MarshalJSON renders the Agave wire format. if output.ProcessingResult.TransactionError != nil { - txErr := output.ProcessingResult.TransactionError - resp.Value.Err = txErr.InstructionError.Error() + resp.Value.Err = output.ProcessingResult.TransactionError return resp, nil } diff --git a/pkg/sealevel/stake_program.go b/pkg/sealevel/stake_program.go index 0661fc0c..1acf0e89 100644 --- a/pkg/sealevel/stake_program.go +++ b/pkg/sealevel/stake_program.go @@ -803,6 +803,9 @@ func StakeProgramExecute(execCtx *ExecutionCtx) error { var rent SysvarRent rent, err = ReadRentSysvar(execCtx) + if err != nil { + return err + } err = checkAcctForRentSysvar(txCtx, instrCtx, 1) if err != nil { return err diff --git a/pkg/sealevel/sysvar_rent.go b/pkg/sealevel/sysvar_rent.go index 79f11ab1..e2c3c275 100644 --- a/pkg/sealevel/sysvar_rent.go +++ b/pkg/sealevel/sysvar_rent.go @@ -104,7 +104,7 @@ func ReadRentSysvar(execCtx *ExecutionCtx) (SysvarRent, error) { accts := addrObjectForLookup(execCtx) rentAcct, err := (*accts).GetAccount(&SysvarRentAddr) if err != nil { - panic("failed to read rent sysvar account") + return SysvarRent{}, err } dec := bin.NewBinDecoder(rentAcct.Data) From fa20f6d8e6ebddd273f20ddf24f19373c66ed3eb Mon Sep 17 00:00:00 2001 From: Neeraj Godiyal Date: Mon, 4 May 2026 18:09:20 +0530 Subject: [PATCH 5/7] fix(replay,sealevel,fees): simulate-path follow-ups * Pass-1 accountsdb fallback in loadAndValidateTxAcctsSimd186: per-block MemAccounts only holds accounts the block's txs reference, so System Program and other native programs are often missing on the simulate path. Fall back to slotCtx.AccountsDb before fabricating a default; matches the existing Pass-2 program fallback pattern. * IsTransactionAgeValid accountsdb fallback for durable-nonce txs: when a user simulates a nonce-based tx without replaceRecentBlockhash, the nonce account isn't in MemAccounts. Same fallback pattern as Pass-1 so the on-chain nonce state is consulted instead of returning BlockhashNotFound. * CalculateAndDeductTxFees: thread isSimulation through. Simulate returns the error cleanly to the RPC client; block-replay keeps the panic on a sanitize-bypass invariant break, preserving the loud operator-visible signal. * Sanitize-block comment trim in transaction_processing_pure.go. --- pkg/fees/fees.go | 14 ++++++++++---- pkg/replay/accounts.go | 13 +++++++++---- pkg/replay/transaction_processing_pure.go | 8 +++----- pkg/sealevel/blockhash_nonce.go | 7 +++++++ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/pkg/fees/fees.go b/pkg/fees/fees.go index d6d5c76d..8ae3532e 100644 --- a/pkg/fees/fees.go +++ b/pkg/fees/fees.go @@ -100,12 +100,18 @@ func CalculateTxFees(tx *solana.Transaction, instrs []sealevel.Instruction, comp } // TODO: implement new fee model -func CalculateAndDeductTxFees(tx *solana.Transaction, txMeta *rpc.TransactionMeta, instrs []sealevel.Instruction, transactionAccts *sealevel.TransactionAccounts, computeBudgetLimits *sealevel.ComputeBudgetLimits, f *features.Features) (*TxFeeInfo, uint64, error) { +func CalculateAndDeductTxFees(tx *solana.Transaction, txMeta *rpc.TransactionMeta, instrs []sealevel.Instruction, transactionAccts *sealevel.TransactionAccounts, computeBudgetLimits *sealevel.ComputeBudgetLimits, f *features.Features, isSimulation bool) (*TxFeeInfo, uint64, error) { feePayerAcct, err := transactionAccts.GetAccount(feePayerIdx) if err != nil { - // Defensive: sanitize guarantees AccountKeys[0]; return cleanly - // rather than crash if a future caller bypasses the guard. - return nil, 0, err + if isSimulation { + // RPC simulate must not crash the validator on user-submitted + // input; surface the error to the client instead. + return nil, 0, err + } + // Block-replay invariant: sanitize + loader guarantee feePayer at + // AccountKeys[0] is loadable. Reaching here means our local state + // or sanitize is broken — keep the loud signal. + panic(fmt.Sprintf("CalculateAndDeductTxFees: feePayer GetAccount(0) failed: %v", err)) } ////mlog.Log.Debugf("feePayerAcct=%+v", feePayerAcct) diff --git a/pkg/replay/accounts.go b/pkg/replay/accounts.go index e218e56c..6be203cf 100644 --- a/pkg/replay/accounts.go +++ b/pkg/replay/accounts.go @@ -291,11 +291,16 @@ func loadAndValidateTxAcctsSimd186(slotCtx *sealevel.SlotCtx, acctMetasPerInstr acct = instrsAcct } else { acct, err = slotCtx.GetAccountShared(pubkey) + if err != nil && slotCtx.AccountsDb != nil { + // Fall back to full accountsdb so native programs + // (System, BPF Loader, etc.) and other always-on + // accounts are loaded even when the per-slot + // MemAccounts didn't reference them. + acct, err = slotCtx.GetAccountFromAccountsDb(pubkey) + } if err != nil { - // Mirror Agave's load_transaction_account: fabricate an - // empty System-owned default with rent-exempt epoch - // (SIMD-0267) so simulate can model accounts the tx - // itself creates. + // Empty default for genuinely absent pubkeys; matches + // Agave load_transaction_account (rent_epoch = SIMD-0267). acct = &accounts.Account{ Key: pubkey, Owner: addresses.SystemProgramAddr, diff --git a/pkg/replay/transaction_processing_pure.go b/pkg/replay/transaction_processing_pure.go index 5d1e4d7e..0cf2f31a 100644 --- a/pkg/replay/transaction_processing_pure.go +++ b/pkg/replay/transaction_processing_pure.go @@ -56,10 +56,8 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec input.Arena.Reset() } - // Sanitize per Agave's Message::sanitize. Prevents index panics on - // tx.Signatures[0], AccountKeys[0], and the unchecked metas[acct] - // inside solana-go's ResolveInstructionAccounts for malformed - // user-submitted txs. + // Agave-style sanitize: reject malformed user txs before they + // reach downstream index panics. hdr := tx.Message.Header numKeys := len(tx.Message.AccountKeys) if hdr.NumReadonlySignedAccounts >= hdr.NumRequiredSignatures || @@ -189,7 +187,7 @@ func LoadAndExecuteTransaction(input LoadAndExecuteTransactionInput) LoadAndExec // Calculate and deduct fees start = time.Now() - txFeeInfo, _, err := fees.CalculateAndDeductTxFees(tx, input.TxMeta, instrs, &execCtx.TransactionContext.Accounts, computeBudgetLimits, slotCtx.Features) + txFeeInfo, _, err := fees.CalculateAndDeductTxFees(tx, input.TxMeta, instrs, &execCtx.TransactionContext.Accounts, computeBudgetLimits, slotCtx.Features, input.IsSimulation) if err != nil { out := LoadAndExecuteTransactionOutput{ ProcessingResult: TransactionProcessingResult{ diff --git a/pkg/sealevel/blockhash_nonce.go b/pkg/sealevel/blockhash_nonce.go index a1f83dc7..ef7a0abd 100644 --- a/pkg/sealevel/blockhash_nonce.go +++ b/pkg/sealevel/blockhash_nonce.go @@ -153,6 +153,13 @@ func IsTransactionAgeValid(tx *solana.Transaction, instrs []Instruction, slotCtx noncePk := instr.Accounts[0].Pubkey nonceAcct, err := slotCtx.GetAccount(noncePk) + if err != nil && slotCtx.AccountsDb != nil { + // Per-slot MemAccounts only holds accounts referenced by the + // current block's txs. On the simulate path the nonce account + // is usually absent there — fall back to accountsdb so durable + // nonce txs validate against on-chain state. + nonceAcct, err = slotCtx.GetAccountFromAccountsDb(noncePk) + } if err != nil { return false } From 7b2b12b3ede4e03ef50400fccacdfa963b4bf3b5 Mon Sep 17 00:00:00 2001 From: Neeraj Godiyal Date: Mon, 4 May 2026 19:11:37 +0530 Subject: [PATCH 6/7] test(simulate): unit + fuzz coverage for fees and sanitize paths * fees: assert simulate returns err while block-replay panics with diagnostic context on a missing fee payer. * sealevel: assert IsTransactionAgeValid returns true when the nonce account is in MemAccounts and false safely when both MemAccounts and AccountsDb are unavailable. * replay: Go fuzz test that drives random tx shapes through LoadAndExecuteTransaction and asserts no panics. Covers sanitize, loader, and fees branches. --- pkg/fees/fees_test.go | 69 +++++++++++ pkg/replay/accounts_test.go | 6 +- .../transaction_processing_pure_fuzz_test.go | 113 ++++++++++++++++++ pkg/sealevel/blockhash_nonce_test.go | 96 +++++++++++++++ 4 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 pkg/fees/fees_test.go create mode 100644 pkg/replay/transaction_processing_pure_fuzz_test.go create mode 100644 pkg/sealevel/blockhash_nonce_test.go diff --git a/pkg/fees/fees_test.go b/pkg/fees/fees_test.go new file mode 100644 index 00000000..e100dc98 --- /dev/null +++ b/pkg/fees/fees_test.go @@ -0,0 +1,69 @@ +package fees + +import ( + "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" +) + +func emptyTransactionAccounts() *sealevel.TransactionAccounts { + return sealevel.NewTransactionAccountsFromRefs([]*accounts.Account{}, []bool{}) +} + +func newEmptyTransaction() *solana.Transaction { + return &solana.Transaction{ + Message: solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1}, + }, + } +} + +func TestCalculateAndDeductTxFees_Simulation_ReturnsErrorWithoutPanic(t *testing.T) { + require.NotPanics(t, func() { + fee, _, err := CalculateAndDeductTxFees( + newEmptyTransaction(), nil, nil, + emptyTransactionAccounts(), + &sealevel.ComputeBudgetLimits{}, + features.NewFeaturesDefault(), + true, + ) + assert.Nil(t, fee) + assert.Error(t, err) + }) +} + +func TestCalculateAndDeductTxFees_BlockReplay_PanicsOnMissingFeePayer(t *testing.T) { + require.Panics(t, func() { + _, _, _ = CalculateAndDeductTxFees( + newEmptyTransaction(), nil, nil, + emptyTransactionAccounts(), + &sealevel.ComputeBudgetLimits{}, + features.NewFeaturesDefault(), + false, + ) + }) +} + +func TestCalculateAndDeductTxFees_BlockReplay_PanicMessageContainsContext(t *testing.T) { + defer func() { + r := recover() + require.NotNil(t, r) + msg, ok := r.(string) + require.True(t, ok) + assert.Contains(t, msg, "CalculateAndDeductTxFees") + assert.Contains(t, msg, "feePayer") + }() + + _, _, _ = CalculateAndDeductTxFees( + newEmptyTransaction(), nil, nil, + emptyTransactionAccounts(), + &sealevel.ComputeBudgetLimits{}, + features.NewFeaturesDefault(), + false, + ) +} diff --git a/pkg/replay/accounts_test.go b/pkg/replay/accounts_test.go index 67b9537a..6ec65429 100644 --- a/pkg/replay/accounts_test.go +++ b/pkg/replay/accounts_test.go @@ -27,9 +27,9 @@ func newSimd186SlotCtx() *sealevel.SlotCtx { } // TestLoadAndValidateTxAcctsSimd186_FabricatesDefaultForMissingAccount -// verifies the simulate-path fix: a tx that references a pubkey absent -// from local state must not panic. The loader fabricates an empty -// System-owned default (matching Agave's load_transaction_account). +// asserts that the SIMD-186 loader returns an empty System-owned default +// for a pubkey absent from local state instead of panicking, matching +// Agave's load_transaction_account behavior. func TestLoadAndValidateTxAcctsSimd186_FabricatesDefaultForMissingAccount(t *testing.T) { slotCtx := newSimd186SlotCtx() diff --git a/pkg/replay/transaction_processing_pure_fuzz_test.go b/pkg/replay/transaction_processing_pure_fuzz_test.go new file mode 100644 index 00000000..01c6e30d --- /dev/null +++ b/pkg/replay/transaction_processing_pure_fuzz_test.go @@ -0,0 +1,113 @@ +package replay + +import ( + "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/require" +) + +const ( + fuzzMaxAccountKeys = 16 + fuzzMaxSignatures = 16 + fuzzMaxInstructions = 8 +) + +// FuzzLoadAndExecuteTransaction_NeverPanics asserts that LoadAndExecuteTransaction +// surfaces every reachable failure as a TransactionError, never as a panic. +// +// Run extended fuzz with: +// +// go test ./pkg/replay/ -fuzz FuzzLoadAndExecuteTransaction_NeverPanics -fuzztime 60s +func FuzzLoadAndExecuteTransaction_NeverPanics(f *testing.F) { + for _, s := range [][]byte{ + {}, + {0}, + {0, 0, 0, 0}, + {1, 1, 1, 0}, + {2, 1, 1, 1, 99, 0, 0}, + {255, 255, 255, 255, 0}, + } { + f.Add(s) + } + + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + + // Use an empty queue so the fuzzer exercises sanitize/load logic + // without depending on production sysvar initialization. + emptyRBH := sealevel.SysvarRecentBlockhashes{} + prev := sealevel.SysvarCache.RecentBlockHashes.Sysvar + sealevel.SysvarCache.RecentBlockHashes.Sysvar = &emptyRBH + defer func() { sealevel.SysvarCache.RecentBlockHashes.Sysvar = prev }() + + f.Fuzz(func(t *testing.T, payload []byte) { + slotCtx := &sealevel.SlotCtx{ + Features: feats, + Accounts: accounts.NewMemAccounts(), + FeeRateGovernor: &sealevel.FeeRateGovernor{}, + } + + require.NotPanics(t, func() { + _ = LoadAndExecuteTransaction(LoadAndExecuteTransactionInput{ + SlotCtx: slotCtx, + Transaction: buildFuzzedTx(payload), + IsSimulation: true, + }) + }) + }) +} + +func buildFuzzedTx(payload []byte) *solana.Transaction { + tx := &solana.Transaction{} + if len(payload) == 0 { + return tx + } + + at := func(i int) byte { + if i < len(payload) { + return payload[i] + } + return 0 + } + + tx.Message.Header.NumRequiredSignatures = at(0) + tx.Message.Header.NumReadonlySignedAccounts = at(1) + tx.Message.Header.NumReadonlyUnsignedAccounts = at(2) + + numKeys := int(at(3) % fuzzMaxAccountKeys) + numSigs := int(at(4) % fuzzMaxSignatures) + numInstrs := int(at(5) % fuzzMaxInstructions) + + tx.Message.AccountKeys = make([]solana.PublicKey, numKeys) + for i := range tx.Message.AccountKeys { + var pk solana.PublicKey + pk[0] = byte(i) + pk[1] = at(6 + i) + tx.Message.AccountKeys[i] = pk + } + + tx.Signatures = make([]solana.Signature, numSigs) + + tx.Message.Instructions = make([]solana.CompiledInstruction, numInstrs) + for i := range tx.Message.Instructions { + var programIdx, accountIdx uint8 + // Allow indices to land out of range so the loader's bounds check fires. + if numKeys > 0 { + programIdx = at(22+i*3) % byte(numKeys+2) + accountIdx = at(23+i*3) % byte(numKeys+2) + } else { + programIdx = at(22+i*3) % 4 + } + tx.Message.Instructions[i] = solana.CompiledInstruction{ + ProgramIDIndex: uint16(programIdx), + Accounts: []uint16{uint16(accountIdx)}, + Data: nil, + } + } + + return tx +} diff --git a/pkg/sealevel/blockhash_nonce_test.go b/pkg/sealevel/blockhash_nonce_test.go new file mode 100644 index 00000000..6cb6c612 --- /dev/null +++ b/pkg/sealevel/blockhash_nonce_test.go @@ -0,0 +1,96 @@ +package sealevel + +import ( + "encoding/binary" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + a "github.com/Overclock-Validator/mithril/pkg/addresses" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func marshalNonceCurrentState(t *testing.T, durableNonce [32]byte, authority solana.PublicKey) []byte { + t.Helper() + nsv := &NonceStateVersions{ + Type: NonceVersionCurrent, + Current: NonceData{ + IsInitialized: true, + Authority: authority, + DurableNonce: durableNonce, + FeeCalculator: FeeCalculator{LamportsPerSignature: 5000}, + }, + } + data, err := nsv.Marshal() + require.NoError(t, err) + return data +} + +func makeAdvanceNonceAccountInstr(noncePk, authorityPk solana.PublicKey) Instruction { + data := make([]byte, 4) + binary.LittleEndian.PutUint32(data, SystemProgramInstrTypeAdvanceNonceAccount) + return Instruction{ + ProgramId: a.SystemProgramAddr, + Accounts: []AccountMeta{ + {Pubkey: noncePk, IsSigner: false, IsWritable: true}, + {Pubkey: authorityPk, IsSigner: true, IsWritable: false}, + }, + Data: data, + } +} + +// withEmptyRecentBlockhashesSysvar swaps the global cache for an empty +// queue and returns a deferrable restore func. IsBlockhashAgeValid then +// returns false for any input, forcing the durable-nonce path. +func withEmptyRecentBlockhashesSysvar(t *testing.T) func() { + t.Helper() + prev := SysvarCache.RecentBlockHashes.Sysvar + empty := SysvarRecentBlockhashes{} + SysvarCache.RecentBlockHashes.Sysvar = &empty + return func() { SysvarCache.RecentBlockHashes.Sysvar = prev } +} + +func publicKeyForTest(kind byte, seed byte) solana.PublicKey { + var pk solana.PublicKey + pk[0] = kind + pk[31] = seed + return pk +} + +func TestIsTransactionAgeValid_NonceInMemAccounts_ReturnsTrue(t *testing.T) { + defer withEmptyRecentBlockhashesSysvar(t)() + + authority := publicKeyForTest('A', 1) + noncePk := publicKeyForTest('N', 2) + durable := [32]byte{0xAA} + + tx := &solana.Transaction{Message: solana.Message{RecentBlockhash: durable}} + instrs := []Instruction{makeAdvanceNonceAccountInstr(noncePk, authority)} + + mem := accounts.NewMemAccounts() + require.NoError(t, mem.SetAccountWithoutLock(noncePk, &accounts.Account{ + Key: noncePk, + Owner: a.SystemProgramAddr, + Data: marshalNonceCurrentState(t, durable, authority), + })) + + slotCtx := &SlotCtx{Accounts: mem, LastBlockhash: [32]byte{0x77}} + assert.True(t, IsTransactionAgeValid(tx, instrs, slotCtx)) +} + +func TestIsTransactionAgeValid_NonceMissing_NilAccountsDb_ReturnsFalse(t *testing.T) { + defer withEmptyRecentBlockhashesSysvar(t)() + + tx := &solana.Transaction{Message: solana.Message{RecentBlockhash: [32]byte{0xAA}}} + instrs := []Instruction{makeAdvanceNonceAccountInstr(publicKeyForTest('N', 2), publicKeyForTest('A', 1))} + + slotCtx := &SlotCtx{ + Accounts: accounts.NewMemAccounts(), + LastBlockhash: [32]byte{0x77}, + } + + require.NotPanics(t, func() { + assert.False(t, IsTransactionAgeValid(tx, instrs, slotCtx)) + }) +} From 82f4566e7a87a9c312dcece61583ad76a2cbe575 Mon Sep 17 00:00:00 2001 From: Neeraj Godiyal Date: Tue, 5 May 2026 16:41:36 +0530 Subject: [PATCH 7/7] feat(rpcserver): Agave-parity simulateTransaction wire format - Strict response shape: all 14 RpcSimulateTransactionResult fields with correct nil/empty/null distinctions - Custom JSON-RPC errors: -32602 InvalidParams, -32016 MinContextSlotNotReached with structured contextSlot data - Inner instruction recording with stackHeight (Option), preserved across InstructionError paths via AssembleInnerInstructions - SPL Token + Token-2022 balance decoding with precise uiAmountString for high-decimals tokens (no float saturation) - Pre/post account snapshots for token balance pre-state - accounts.encoding base58/binary rejection - too-many-accounts cap uses post-ALT-resolved key count - ALT resolve before sigVerify so sigVerify-fail responses carry resolved loadedAddresses - PACKET_DATA_SIZE encoded-length cap matching Agave constants - Log + return-data clamping to spec limits (10KB / 1KB) Live-verified A-to-Z (48/48) against real mainnet CPI swaps, Token-2022 txs, ALT-using txs, vote txs, sigVerify-fail paths, and 50-parallel concurrent simulates. Block-replay running cleanly across 400+ slots with these changes. --- pkg/metrics/metrics.go | 30 + pkg/replay/inner_instructions_test.go | 75 ++ pkg/replay/simulate_fixtures_test.go | 179 +++++ pkg/replay/transaction_processing_pure.go | 82 ++- pkg/replay/transaction_processing_types.go | 12 +- pkg/rpcserver/errors.go | 91 +++ pkg/rpcserver/errors_test.go | 83 +++ pkg/rpcserver/rpcserver.go | 12 +- pkg/rpcserver/simulate_transaction.go | 658 +++++++++++++++--- .../simulate_transaction_helpers_test.go | 180 +++++ .../simulate_transaction_response_test.go | 215 ++++++ pkg/rpcserver/token_balances.go | 198 ++++++ pkg/rpcserver/token_balances_test.go | 129 ++++ pkg/sealevel/execution_ctx.go | 43 ++ 14 files changed, 1861 insertions(+), 126 deletions(-) create mode 100644 pkg/replay/inner_instructions_test.go create mode 100644 pkg/replay/simulate_fixtures_test.go create mode 100644 pkg/rpcserver/errors.go create mode 100644 pkg/rpcserver/errors_test.go create mode 100644 pkg/rpcserver/simulate_transaction_helpers_test.go create mode 100644 pkg/rpcserver/simulate_transaction_response_test.go create mode 100644 pkg/rpcserver/token_balances.go create mode 100644 pkg/rpcserver/token_balances_test.go 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 {