Overview
Mithril does not enforce the 20 MiB per-transaction account data growth limit
when expanding a writable account. As a result, a valid sBPF V2 program can
successfully commit account data even when the transaction's cumulative resize
delta exceeds the allowed limit.
serializeParametersAligned
-> sbpf.Interpreter
-> deserializeParametersAligned
The transaction starts with a writable, program-owned account containing one
byte of data and an AccountsResizeDelta one byte below the 20 MiB transaction
limit. The program updates the serialized account length to 11 and writes
0x42 to account-data byte 10:
mov64 r2, 11
stxdw [r1 + serialized_account_data_len_offset], r2
mov64 r2, 0x42
stxb [r1 + account_data_offset + 10], r2
exit
Configuration:
sBPF version: V2
VirtualAddressSpaceAdjustments: enabled
AccountDataDirectMapping: enabled
initial account data length: 1
initial AccountsResizeDelta: 20 MiB - 1 = 20,971,519
requested final account data length: 11
transaction growth limit: 20 MiB = 20,971,520
Observed consistently across three runs:
Mithril verifier: pass
Mithril execution: success, r0=0
final account length: 11
account data byte 10: 0x42
final resize delta: 20,971,529
transaction limit: 20,971,520
amount over the limit: 9 bytes
Agave limits direct-mapped account growth to the remaining transaction-wide
growth budget. With only one byte of budget remaining, the account can grow
from 1 to at most 2 bytes, so the same store cannot succeed and the transaction
is rejected.
This is a runtime semantic difference. With both direct-mapping features
enabled, Mithril accepts and commits a transaction that Agave rejects.
Tested Versions
Mithril: 6f31241ba6c48a37316ea23cc947bde395e0722f
Agave: f7db9dee441713162a70086cff23e84788f05386
Go: go1.25.7 linux/amd64
Rust: rustc 1.96.0, cargo 1.96.0
Suspected Cause
BorrowedAccount.CanDataBeResized validates account ownership and the 10 MiB
per-account size limit, but it does not enforce the transaction-wide resize
limit. The missing check is explicitly noted in the implementation:
UpdateAccountsResizeDelta simply increments AccountsResizeDelta; it neither
rejects nor clamps updates that exceed the transaction-wide limit.
When direct account mapping is enabled, the aligned serializer reserves
old_len + 10 KiB and installs directMappedAccountData as the region's
OnWrite callback:
Whenever a store extends beyond the mapped region, the interpreter invokes this
callback:
The callback verifies the reserved growth and the maximum individual account
size, then resizes the account and updates AccountsResizeDelta. It never
checks the remaining transaction-wide growth budget. As a result, the
oversized account is accepted, and the subsequent deserialization preserves
the program-requested length and data.
Agave defines the transaction-wide maximum as twice the 10 MiB per-account
limit:
Its access-violation handler subtracts the current resize delta, limits growth
to the remaining transaction-wide growth budget, and only then updates the
account and mapped region:
Suggested Fix
Enforce the transaction-wide growth limit consistently for both ordinary
account resizing and direct-mapped OnWrite expansion.
For the direct-mapping callback, compute the remaining transaction budget
before mutating the account:
remaining = max(
0,
MAX_ACCOUNT_DATA_GROWTH_PER_TRANSACTION
- AccountsResizeDelta,
)
new_len = min(
reserved,
MaxPermittedDataLength,
old_len + remaining,
)
If requestedLen > new_len, the write should fail without committing the
resized account or updating AccountsResizeDelta.
Likewise, the ordinary CanDataBeResized/deserialization path should reject
any final account length that would cause AccountsResizeDelta to exceed the
transaction limit, returning InstrErrMaxAccountsDataAllocationsExceeded where
appropriate.
How to Reproduce the Bug
Add the following test as:
pkg/sealevel/mithril_resize_limit_repro_test.go
package sealevel
import (
"encoding/binary"
"testing"
"github.com/Overclock-Validator/mithril/pkg/accounts"
"github.com/Overclock-Validator/mithril/pkg/cu"
feat "github.com/Overclock-Validator/mithril/pkg/features"
"github.com/Overclock-Validator/mithril/pkg/sbpf"
"github.com/Overclock-Validator/mithril/pkg/sbpf/sbpfver"
"github.com/gagliardetto/solana-go"
"github.com/stretchr/testify/require"
)
func reproProgram(raw []byte, version uint32) *sbpf.Program {
text := make([]sbpf.Slot, len(raw)/8)
for i := range text {
text[i] = sbpf.Slot(binary.LittleEndian.Uint64(raw[i*8:]))
}
return &sbpf.Program{
TextBytes: raw,
Text: text,
TextVA: sbpf.VaddrProgram,
Entrypoint: 0,
Funcs: map[uint32]int64{},
SbpfVersion: sbpfver.SbpfVersion{Version: version},
}
}
func reproSlot(op, dst, src uint8, off int16, imm uint32) sbpf.Slot {
return sbpf.Slot(op) |
sbpf.Slot(dst)<<8 |
sbpf.Slot(src)<<12 |
sbpf.Slot(uint16(off))<<16 |
sbpf.Slot(imm)<<32
}
func reproProgramFromSlots(slots []sbpf.Slot, version uint32) *sbpf.Program {
raw := make([]byte, len(slots)*8)
for i, slot := range slots {
binary.LittleEndian.PutUint64(raw[i*8:], uint64(slot))
}
return reproProgram(raw, version)
}
func TestMithrilMissingTransactionResizeLimit(t *testing.T) {
programKey := solana.PublicKey{1}
dataKey := solana.PublicKey{2}
txAccounts := NewTransactionAccounts([]accounts.Account{
{Key: programKey, Lamports: 1, Executable: true},
{Key: dataKey, Lamports: 1, Owner: programKey, Data: []byte{0xab}},
})
tx := NewTransactionCtx(*txAccounts, 4, 8)
tx.InstructionTrace[0].Configure(
[]uint64{0},
[]InstructionAccount{{
IndexInTransaction: 1,
IndexInCaller: 1,
IndexInCallee: 0,
IsWritable: true,
}},
nil,
)
tx.InstructionStack = append(tx.InstructionStack, 0)
const maxTransactionGrowth = int64(20 * 1024 * 1024)
tx.AccountsResizeDelta = maxTransactionGrowth - 1
features := feat.NewFeaturesDefault()
features.EnableFeature(feat.VirtualAddressSpaceAdjustments, 0)
features.EnableFeature(feat.AccountDataDirectMapping, 0)
execCtx := &ExecutionCtx{
TransactionContext: tx,
Features: *features,
IsSimulation: true,
}
input, preLens, _, _, regions, err := serializeParametersAligned(execCtx)
require.NoError(t, err)
accountRegion := -1
for i := range regions {
if regions[i].AccountIndex == 0 {
accountRegion = i
break
}
}
require.NotEqual(t, -1, accountRegion)
region := ®ions[accountRegion]
require.Equal(t, uint64(1), region.RegionSize)
require.Equal(t, uint64(1+MaxPermittedDataIncrease), region.AddressSpaceReserved)
require.NotNil(t, region.OnWrite)
// First update the serialized AccountInfo length to 11, then write byte 10.
// This preserves the grown account through production deserialization.
dataLenOffset := region.Offset - 8
storeOffset := region.Offset + 10
require.LessOrEqual(t, dataLenOffset, uint64(0x7fff))
require.LessOrEqual(t, storeOffset, uint64(0x7fff))
program := reproProgramFromSlots([]sbpf.Slot{
reproSlot(sbpf.OpMov64Imm, 2, 0, 0, 11),
reproSlot(sbpf.OpSt8BReg, 1, 2, int16(dataLenOffset), 0),
reproSlot(sbpf.OpMov64Imm, 2, 0, 0, 0x42),
reproSlot(sbpf.OpSt1BReg, 1, 2, int16(storeOffset), 0),
reproSlot(sbpf.OpExit, 0, 0, 0, 0),
}, sbpfver.SbpfVersionV2)
require.NoError(t, program.Verify())
meter := cu.NewComputeMeter(1_000_000)
vm := sbpf.NewInterpreter(program, &sbpf.VMOpts{
HeapMax: 32 * 1024,
Syscalls: func(uint32) (sbpf.Syscall, bool) { return nil, false },
ComputeMeter: &meter,
Input: input,
InputRegions: regions,
Context: execCtx,
DisableStackFrameGaps: true,
})
defer vm.Finish()
ret, used, runErr := vm.Run()
require.NoError(t, runErr)
require.NoError(t, deserializeParametersAligned(execCtx, input, preLens))
dataAccount := tx.Accounts.Accounts[1]
t.Logf(
"ret=%d used=%d err=%v old=1 final=%d byte10=%#x resize_delta=%d max=%d reserved=%d",
ret,
used,
runErr,
len(dataAccount.Data),
dataAccount.Data[10],
tx.AccountsResizeDelta,
maxTransactionGrowth,
region.AddressSpaceReserved,
)
require.Equal(t, byte(0x42), dataAccount.Data[10])
require.Equal(t, 11, len(dataAccount.Data))
require.Greater(t, tx.AccountsResizeDelta, maxTransactionGrowth)
}
Run:
go test ./pkg/sealevel \
-run '^TestMithrilMissingTransactionResizeLimit$' \
-count=3 -v
Expected Results
Current Mithril succeeds and prints:
ret=0 used=5 err=<nil> old=1 final=11 byte10=0x42 \
resize_delta=20971529 max=20971520 reserved=10241
After the transaction-wide limit is enforced, this reproducer should fail at
execution or deserialization before the 11-byte account and byte10=0x42 are
committed. A regression test should verify that the appropriate instruction
error is returned and that the transaction-level account state remains
unchanged.
Overview
Mithril does not enforce the 20 MiB per-transaction account data growth limit
when expanding a writable account. As a result, a valid sBPF V2 program can
successfully commit account data even when the transaction's cumulative resize
delta exceeds the allowed limit.
The transaction starts with a writable, program-owned account containing one
byte of data and an
AccountsResizeDeltaone byte below the 20 MiB transactionlimit. The program updates the serialized account length to 11 and writes
0x42to account-data byte 10:Configuration:
Observed consistently across three runs:
Agave limits direct-mapped account growth to the remaining transaction-wide
growth budget. With only one byte of budget remaining, the account can grow
from 1 to at most 2 bytes, so the same store cannot succeed and the transaction
is rejected.
This is a runtime semantic difference. With both direct-mapping features
enabled, Mithril accepts and commits a transaction that Agave rejects.
Tested Versions
Suspected Cause
BorrowedAccount.CanDataBeResizedvalidates account ownership and the 10 MiBper-account size limit, but it does not enforce the transaction-wide resize
limit. The missing check is explicitly noted in the implementation:
BorrowedAccount.CanDataBeResized, lines 243-255BorrowedAccount.UpdateAccountsResizeDelta, lines 283-284UpdateAccountsResizeDeltasimply incrementsAccountsResizeDelta; it neitherrejects nor clamps updates that exceed the transaction-wide limit.
When direct account mapping is enabled, the aligned serializer reserves
old_len + 10 KiBand installsdirectMappedAccountDataas the region'sOnWritecallback:serializeParametersAligned, lines 675-745directMappedAccountData, lines 517-552Whenever a store extends beyond the mapped region, the interpreter invokes this
callback:
Interpreter.translateInputRegion, lines 1245-1275The callback verifies the reserved growth and the maximum individual account
size, then resizes the account and updates
AccountsResizeDelta. It neverchecks the remaining transaction-wide growth budget. As a result, the
oversized account is accepted, and the subsequent deserialization preserves
the program-requested length and data.
Agave defines the transaction-wide maximum as twice the 10 MiB per-account
limit:
MAX_ACCOUNT_DATA_GROWTH_PER_TRANSACTION, lines 19-24Its access-violation handler subtracts the current resize delta, limits growth
to the remaining transaction-wide growth budget, and only then updates the
account and mapped region:
TransactionContext::access_violation_handler, lines 534-555Suggested Fix
Enforce the transaction-wide growth limit consistently for both ordinary
account resizing and direct-mapped
OnWriteexpansion.For the direct-mapping callback, compute the remaining transaction budget
before mutating the account:
If
requestedLen > new_len, the write should fail without committing theresized account or updating
AccountsResizeDelta.Likewise, the ordinary
CanDataBeResized/deserialization path should rejectany final account length that would cause
AccountsResizeDeltato exceed thetransaction limit, returning
InstrErrMaxAccountsDataAllocationsExceededwhereappropriate.
How to Reproduce the Bug
Add the following test as:
Run:
Expected Results
Current Mithril succeeds and prints:
After the transaction-wide limit is enforced, this reproducer should fail at
execution or deserialization before the 11-byte account and
byte10=0x42arecommitted. A regression test should verify that the appropriate instruction
error is returned and that the transaction-level account state remains
unchanged.