Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/blockprod/bank.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ func (b *WorkingBank) forgeTransaction(tx *solana.Transaction, wireSize int, pre
Arena: b.borrowedAccounts,
}, prepared)
if output.ProcessingResult.TransactionError != nil {
feeInfo, err := replay.ApplyFeesOnlyTransaction(b.slotCtx, tx, output)
feeInfo, err := replay.ApplyFailedTransaction(b.slotCtx, tx, output)
if err != nil {
return ForgeDroppedExecution, costmodel.ExceedNone
}
Expand Down
143 changes: 143 additions & 0 deletions pkg/blockprod/bank_failed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package blockprod

import (
"sync"
"testing"

"github.com/Overclock-Validator/mithril/pkg/costmodel"
"github.com/Overclock-Validator/mithril/pkg/replay"
"github.com/Overclock-Validator/mithril/pkg/sealevel"
"github.com/Overclock-Validator/mithril/pkg/tpu/txfixture"
"github.com/gagliardetto/solana-go"
computebudget "github.com/gagliardetto/solana-go/programs/compute-budget"
"github.com/gagliardetto/solana-go/programs/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func bankLamports(t *testing.T, slotCtx *sealevel.SlotCtx, pubkey solana.PublicKey) uint64 {
t.Helper()
acct, err := slotCtx.GetAccount(pubkey)
if err != nil {
return 0
}
return acct.Lamports
}

func TestWorkingBankIncludesExecutionFailureAsFailed(t *testing.T) {
env := NewTestEnv(TestEnvConfig{})
defer env.Close()
// The fee is payable, but the payer holds 10 SOL and cannot transfer 20.
tx := mustSignBankTestTransaction(t,
system.NewTransferInstruction(20_000_000_000, txfixture.PayerPubkey(), txfixture.DestPubkey()).Build())
wire, err := tx.MarshalBinary()
require.NoError(t, err)
payerBefore := bankLamports(t, env.SlotCtx, txfixture.PayerPubkey())
destBefore := bankLamports(t, env.SlotCtx, txfixture.DestPubkey())

result, reason := env.Bank.ForgeTransaction(tx, len(wire))
require.Equal(t, ForgeAccepted, result)
require.Equal(t, costmodel.ExceedNone, reason)
assert.Equal(t, payerBefore-5_000, bankLamports(t, env.SlotCtx, txfixture.PayerPubkey()), "only the fee is charged")
assert.Equal(t, destBefore, bankLamports(t, env.SlotCtx, txfixture.DestPubkey()))
assert.Equal(t, uint64(5_000), env.Bank.TxFeeAccumulator().TotalFees)
assert.Equal(t, []*solana.Transaction{tx}, env.Bank.ForgedTransactions())
assert.Equal(t, uint64(1), env.Bank.NumSignatures())
assert.Equal(t, 1, env.Bank.EntryBuilder().PendingCount())
assert.NotZero(t, env.Bank.CostTracker().BlockCost())

result, _ = env.Bank.ForgeTransaction(tx, len(wire))
assert.Equal(t, ForgeDroppedAlreadyProcessed, result, "the failed status is recorded")
assert.Equal(t, payerBefore-5_000, bankLamports(t, env.SlotCtx, txfixture.PayerPubkey()))
}

func TestWorkingBankDropsFailuresBeforeExecution(t *testing.T) {
payerKey := txfixture.PayerPrivateKey()
sign := func(t *testing.T, blockhash solana.Hash, instructions ...solana.Instruction) *solana.Transaction {
tx, err := solana.NewTransaction(instructions, blockhash, solana.TransactionPayer(txfixture.PayerPubkey()))
require.NoError(t, err)
_, err = tx.Sign(func(solana.PublicKey) *solana.PrivateKey { return &payerKey })
require.NoError(t, err)
return tx
}
transfer := system.NewTransferInstruction(1, txfixture.PayerPubkey(), txfixture.DestPubkey()).Build()
cases := map[string]*solana.Transaction{
"compute-budget instruction error": sign(t, txfixture.TestBlockhash(),
solana.NewInstruction(computebudget.ProgramID, nil, []byte{0xFF}), transfer),
"unknown blockhash": sign(t, solana.Hash{0x42}, transfer),
}
for name, tx := range cases {
t.Run(name, func(t *testing.T) {
env := NewTestEnv(TestEnvConfig{})
defer env.Close()
payerBefore := bankLamports(t, env.SlotCtx, txfixture.PayerPubkey())

result, _ := env.Bank.ForgeTransaction(tx, 0)
require.Equal(t, ForgeDroppedExecution, result)
assert.Equal(t, payerBefore, bankLamports(t, env.SlotCtx, txfixture.PayerPubkey()))
assert.Empty(t, env.Bank.ForgedTransactions())
assert.Zero(t, env.Bank.TxFeeAccumulator().TotalFees)
assert.Zero(t, env.Bank.EntryBuilder().PendingCount())
})
}
}

// TestLeaderFailedTransactionsMatchReplay forges each transaction on a leader
// bank and processes it with replay from the same state: replay must accept
// exactly what the leader includes, with the same balances and fee.
func TestLeaderFailedTransactionsMatchReplay(t *testing.T) {
rentSysvar := sealevel.NewDefaultRentSysvar()
rentMin := rentSysvar.MinimumBalance(0)
newAccount := solana.PublicKey{0xEE}
transfer := func(lamports uint64, to solana.PublicKey) solana.Instruction {
return system.NewTransferInstruction(lamports, txfixture.PayerPubkey(), to).Build()
}
cases := []struct {
name string
payer uint64
tx func(t *testing.T) *solana.Transaction
included bool
}{
{name: "success", tx: func(t *testing.T) *solana.Transaction { return mustSignBankTestTransaction(t, transfer(1, txfixture.DestPubkey())) }, included: true},
{name: "instruction error", tx: func(t *testing.T) *solana.Transaction {
return mustSignBankTestTransaction(t, transfer(20_000_000_000, txfixture.DestPubkey()))
}, included: true},
{name: "new account below rent", tx: func(t *testing.T) *solana.Transaction { return mustSignBankTestTransaction(t, transfer(1, newAccount)) }, included: true},
{name: "payer left below rent", payer: 5_000 + 1_000_000 + 100, tx: func(t *testing.T) *solana.Transaction {
return mustSignBankTestTransaction(t, transfer(1_000_000, txfixture.DestPubkey()))
}, included: true},
{name: "fees-only load failure", tx: func(t *testing.T) *solana.Transaction {
return mustSignBankTestTransaction(t, computebudget.NewSetLoadedAccountsDataSizeLimitInstruction(1).Build(), transfer(1, txfixture.DestPubkey()))
}, included: true},
{name: "fee-payer rent", payer: rentMin + 5_000 - 1, tx: func(t *testing.T) *solana.Transaction {
return mustSignBankTestTransaction(t, transfer(1, txfixture.DestPubkey()))
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tx := tc.tx(t)
leader := NewTestEnv(TestEnvConfig{})
defer leader.Close()
if tc.payer != 0 {
setPayerLamports(t, leader, tc.payer)
}
result, _ := leader.Bank.ForgeTransaction(tx, 0)
require.Equal(t, tc.included, result == ForgeAccepted, "leader result %s", result)

validator := NewTestEnv(TestEnvConfig{})
defer validator.Close()
if tc.payer != 0 {
setPayerLamports(t, validator, tc.payer)
}
feeInfo, _, _ := replay.ProcessTransaction(validator.SlotCtx, &sync.WaitGroup{}, tx, nil, nil, nil, false)
require.Equal(t, tc.included, feeInfo != nil, "replay must accept exactly what the leader includes")
if !tc.included {
return
}
assert.Equal(t, feeInfo.TotalFee, leader.Bank.TxFeeAccumulator().TotalFees)
for _, key := range []solana.PublicKey{txfixture.PayerPubkey(), txfixture.DestPubkey(), newAccount} {
assert.Equal(t, bankLamports(t, validator.SlotCtx, key), bankLamports(t, leader.SlotCtx, key), "%s", key)
}
})
}
}
19 changes: 16 additions & 3 deletions pkg/blockprod/bank_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ func TestWorkingBankAcceptsTransferThatDrainsPayerToZero(t *testing.T) {
assert.Equal(t, destBefore.Lamports+transfer, destAfter.Lamports)
}

func TestWorkingBankDropsTransferThatLeavesPayerBelowRent(t *testing.T) {
func TestWorkingBankIncludesTransferThatLeavesPayerBelowRentAsFailed(t *testing.T) {
env := NewTestEnv(TestEnvConfig{})
defer env.Close()

Expand All @@ -276,14 +276,27 @@ func TestWorkingBankDropsTransferThatLeavesPayerBelowRent(t *testing.T) {
destBefore, err := env.SlotCtx.GetAccount(txfixture.DestPubkey())
require.NoError(t, err)
tx := mustSignedTransfer(t, transfer)
// The payer can pay the fee and stay rent exempt, so it passes fee-payer
// validation; only the transfer leaves it below rent, which the
// post-execution rent-state check reports. That failure is includable.
preview := replay.LoadAndExecuteTransaction(replay.LoadAndExecuteTransactionInput{
SlotCtx: env.SlotCtx, Transaction: tx, LeanResult: true,
})
require.NotNil(t, preview.ProcessingResult.TransactionError)
require.Equal(t, replay.TransactionErrorInsufficientFundsForRent, preview.ProcessingResult.TransactionError.ErrorType)
require.NotNil(t, preview.ExecCtx)

result, reason := env.Bank.ForgeTransaction(tx, 200)
require.Equal(t, ForgeDroppedExecution, result)
require.Equal(t, ForgeAccepted, result)
require.Equal(t, costmodel.ExceedNone, reason)

destAfter, err := env.SlotCtx.GetAccount(txfixture.DestPubkey())
require.NoError(t, err)
assert.Equal(t, destBefore.Lamports, destAfter.Lamports)
assert.Empty(t, env.Bank.ForgedTransactions())
payerAfter, err := env.SlotCtx.GetAccount(txfixture.PayerPubkey())
require.NoError(t, err)
assert.Equal(t, transfer+leftover, payerAfter.Lamports, "only the fee is charged")
assert.Equal(t, []*solana.Transaction{tx}, env.Bank.ForgedTransactions())
}

func TestWorkingBankForgesTransfer(t *testing.T) {
Expand Down
47 changes: 37 additions & 10 deletions pkg/replay/commit.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package replay

import (
"errors"
"fmt"
"sync/atomic"
"time"

"github.com/Overclock-Validator/mithril/pkg/fees"
"github.com/Overclock-Validator/mithril/pkg/metrics"
"github.com/Overclock-Validator/mithril/pkg/rent"
"github.com/Overclock-Validator/mithril/pkg/sealevel"
"github.com/gagliardetto/solana-go"
)
Expand Down Expand Up @@ -67,26 +69,51 @@ func ApplySuccessfulTransaction(slotCtx *sealevel.SlotCtx, output LoadAndExecute
return applySuccessfulTransactionState(slotCtx, output.ExecCtx, output.ExecutionResult)
}

// ApplyFeesOnlyTransaction commits the fee-payer and durable-nonce rollback
// state for an account-load failure that Agave treats as processable. The
// transaction itself remains failed, but must be recorded in the block.
func ApplyFeesOnlyTransaction(slotCtx *sealevel.SlotCtx, tx *solana.Transaction, output LoadAndExecuteTransactionOutput) (*fees.TxFeeInfo, error) {
// includableFailure reports whether a failed transaction was processed, so a
// leader may record it in the block as failed with only its fee charged: it
// failed loading accounts after its fee payer was validated (fees-only), or
// during execution, including the post-execution rent-state check. The same
// error types raised before execution are not includable, nor are no-ops.
func includableFailure(output LoadAndExecuteTransactionOutput) bool {
txErr := output.ProcessingResult.TransactionError
if txErr == nil {
return nil, fmt.Errorf("fees-only transaction has no transaction error")
if txErr == nil || output.ProcessedAsNoOp {
return false
}
switch txErr.ErrorType {
case TransactionErrorMaxLoadedAccountsDataSizeExceeded,
TransactionErrorInvalidProgramForExecution,
TransactionErrorProgramAccountNotFound:
default:
return nil, fmt.Errorf("transaction error %s is not a processable fees-only load failure", txErr.ErrorType.String())
return true
case TransactionErrorInstructionError:
// Compute-budget instructions are parsed before execution, which is
// the only stage that creates an execution context.
return output.ExecCtx != nil
case TransactionErrorInsufficientFundsForRent:
// The fee-payer rent check before execution reports a plain error; only
// the post-execution check reports a rent-state transition.
var transition *rent.RentStateTransitionError
return errors.As(txErr.InstructionError, &transition)
}
return false
}

// ApplyFailedTransaction commits the fee-payer and durable-nonce rollback
// state of a failed transaction that includableFailure accepts. The
// transaction itself remains failed, but must be recorded in the block. Any
// other failure commits nothing.
func ApplyFailedTransaction(slotCtx *sealevel.SlotCtx, tx *solana.Transaction, output LoadAndExecuteTransactionOutput) (*fees.TxFeeInfo, error) {
txErr := output.ProcessingResult.TransactionError
if txErr == nil {
return nil, fmt.Errorf("failed transaction has no transaction error")
}
if !includableFailure(output) {
return nil, fmt.Errorf("transaction error %s is not an includable failure", txErr.ErrorType.String())
}
if slotCtx == nil || tx == nil || output.ComputeBudgetLimits == nil || output.FeeInfo == nil {
return nil, fmt.Errorf("fees-only transaction is missing required processing state")
return nil, fmt.Errorf("failed transaction is missing required processing state")
}

// The load error is the transaction's recorded status, not a failure to
// The failure is the transaction's recorded status, not a failure to
// publish its fee/nonce effects, so do not return it as an apply error.
return handleFailedTx(slotCtx, tx, output.Instrs, output.ComputeBudgetLimits, nil, nil)
}
14 changes: 7 additions & 7 deletions pkg/replay/commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func TestApplySuccessfulTransactionCommitsV1TransferWithInlineBudget(t *testing.
assert.Equal(t, destBefore.Lamports+8, destAfter.Lamports)
}

func TestApplyFeesOnlyTransactionCommitsProcessableLoadFailures(t *testing.T) {
func TestApplyFailedTransactionCommitsProcessableLoadFailures(t *testing.T) {
for _, errType := range []TransactionErrorType{
TransactionErrorMaxLoadedAccountsDataSizeExceeded,
TransactionErrorInvalidProgramForExecution,
Expand All @@ -127,7 +127,7 @@ func TestApplyFeesOnlyTransactionCommitsProcessableLoadFailures(t *testing.T) {
},
}

feeInfo, err := ApplyFeesOnlyTransaction(slotCtx, tx, output)
feeInfo, err := ApplyFailedTransaction(slotCtx, tx, output)
require.NoError(t, err)
require.NotNil(t, feeInfo)
assert.Equal(t, uint64(5_000), feeInfo.TotalFee)
Expand All @@ -138,7 +138,7 @@ func TestApplyFeesOnlyTransactionCommitsProcessableLoadFailures(t *testing.T) {
}
}

func TestApplyFeesOnlyTransactionRejectsUnprocessableError(t *testing.T) {
func TestApplyFailedTransactionRejectsUnprocessableError(t *testing.T) {
slotCtx, cleanup := newCommitTestSlotCtx()
defer cleanup()
tx, err := solana.TransactionFromBytes(txfixture.MustSignedTransferWire(0))
Expand All @@ -153,15 +153,15 @@ func TestApplyFeesOnlyTransactionRejectsUnprocessableError(t *testing.T) {
ComputeBudgetLimits: &sealevel.ComputeBudgetLimits{},
FeeInfo: &fees.TxFeeInfo{ExecutionFee: 5_000, TotalFee: 5_000},
}
feeInfo, err := ApplyFeesOnlyTransaction(slotCtx, tx, output)
feeInfo, err := ApplyFailedTransaction(slotCtx, tx, output)
assert.Nil(t, feeInfo)
assert.ErrorContains(t, err, "not a processable fees-only load failure")
assert.ErrorContains(t, err, "not an includable failure")
payerAfter, err := slotCtx.GetAccount(txfixture.PayerPubkey())
require.NoError(t, err)
assert.Equal(t, payerBefore.Lamports, payerAfter.Lamports)
}

func TestApplyFeesOnlyTransactionRejectsIncompleteProcessingState(t *testing.T) {
func TestApplyFailedTransactionRejectsIncompleteProcessingState(t *testing.T) {
slotCtx, cleanup := newCommitTestSlotCtx()
defer cleanup()
tx, err := solana.TransactionFromBytes(txfixture.MustSignedTransferWire(0))
Expand All @@ -177,7 +177,7 @@ func TestApplyFeesOnlyTransactionRejectsIncompleteProcessingState(t *testing.T)
// FeeInfo intentionally omitted: this output did not complete the
// validated fee-payer phase and is unsafe to publish.
}
feeInfo, err := ApplyFeesOnlyTransaction(slotCtx, tx, output)
feeInfo, err := ApplyFailedTransaction(slotCtx, tx, output)
assert.Nil(t, feeInfo)
assert.ErrorContains(t, err, "missing required processing state")
payerAfter, err := slotCtx.GetAccount(txfixture.PayerPubkey())
Expand Down
Loading
Loading