Transaction v1 activates on mainnet at epoch 1035, Tuesday 15 September, 01:20 UTC. It raises the transaction size ceiling from 1,232 to 4,096 bytes, which is the part everyone has heard about.
The part worth paying attention to is different. Sending v1 is opt-in. Reading it is not. And most of the ways it breaks a consumer don't produce an error. They produce a number that is confidently wrong.
We spent two days auditing our stack against it before shipping the upgrade. Here's every way we found that v1 breaks a reader, and what to check in yours.
What actually changed
Three things, and only the second one causes trouble.
Signatures moved to the tail. In legacy and v0, a serialized transaction starts with its signature count. In v1 the version byte lands at offset zero, so a v1 transaction begins with 0x81. You can now identify the format without deserializing anything, which is the point. Note that 0x80 was never at offset zero. It's the v0 marker on the message, which sits after the signatures. Plenty of code is about to learn this the hard way.

The compute budget moved into the message. Compute unit limit, loaded-accounts data size, heap size and priority fee used to be ComputeBudgetProgram instructions. In v1 they're a config on the message: a u32 bitmask at a fixed offset plus a positional value list. The network can price a transaction from the header without walking the instruction list.
Address lookup tables are gone. v1 doesn't support them and doesn't need them. 64 inline addresses at 32 bytes is 2,048 bytes, comfortably inside 4,096. Duplicate addresses are now rejected outright.
| legacy | v0 | v1 | |
|---|---|---|---|
| Transaction size | 1,232 B | 1,232 B | 4,096 B |
| Account addresses | ~32 | 64, via ALT | 64, inline |
| Address lookup tables | — | yes | no |
| Duplicate addresses | allowed | allowed | rejected |
Silent failures
Fee indexing
Any pipeline that derives a priority fee or compute limit by scanning for ComputeBudget111111111111111111111111111111 reports zero for every v1 transaction, and does not error. The instructions aren't there any more. Dashboards keep rendering. Fee models keep returning numbers. The numbers are wrong.

Read transactionConfig off the message instead, and persist it. There's no v0 equivalent, so if you don't store it now you can't backfill it later.
Version checks
There is no version field in the Yellowstone protobuf and no maxSupportedTransactionVersion equivalent on gRPC. You discriminate structurally, and the order is the whole trick:

match (&message.config, message.versioned) {
(Some(_), _) => V1, // field 7 present
(None, true) => V0,
(None, false) => Legacy,
}versioned is true for both v0 and v1. Test it first and you label every single v1 transaction as a v0 with an empty compute budget. No error, no warning, just a silently mislabelled stream. Because config is a submessage it carries explicit presence in proto3, so this holds however your decoder treats defaults.
Stale schemas
Protobuf clients discard fields their generated schema doesn't know about. Regenerating your client isn't enough. You have to bump the schema.
| Dependency | Minimum |
|---|---|
yellowstone-grpc-proto (Rust) | 12.6.0 |
yellowstone-grpc-client (Rust) | 12.0.0, 13.3.0 recommended |
@triton-one/yellowstone-grpc (TS) | 6.0.0 |
| yellowstone-grpc geyser plugin | 15.1.1 |
Two traps in that table. yellowstone-grpc-client 13.3.0 only requires yellowstone-grpc-proto 12.5.0, which has no field 7 at all. Declare the proto directly and build --locked.
And Go is worse than a version bump can fix. The pre-generated stubs shipped alongside the .proto have no TransactionConfig in them. We checked the tagged release and upstream master: still none, while the .proto sitting in the same repo has carried config = 7 since 12.6.0. Run protoc against the .proto yourself:
git clone --depth 1 -b v15.2.1+solana.4.2.2 https://github.com/rpcpool/yellowstone-grpc
protoc --go_out=. --go-grpc_out=. \
-I yellowstone-grpc/yellowstone-grpc-proto/proto \
yellowstone-grpc/yellowstone-grpc-proto/proto/{geyser,solana-storage}.protoFee units
v0 states a price in micro-lamports per compute unit. v1 states a total in lamports. Put both on one dashboard without normalising and the chart is nonsense.
20,000 CU × 250,000 micro-lamports/CU = 5,000 lamports // v0
5,000 lamports // v1 equivalent
Multiply the v0 price by the compute unit limit the transaction actually requested, divide by 1,000,000, round up. If it set no limit, the implicit one is min(200,000 × instructions, 1,400,000).
Over RPC
Set maxSupportedTransactionVersion to the JSON integer 1 on getTransaction, getBlock and blockSubscribe. Do it in this order: upgrade the SDK, confirm your decode path handles v1, then set the parameter. Setting it before your decoder is ready just moves the breakage earlier.
The integer matters. A string "1" fails request validation with -32602 on every call, v1 or not. A 0 fails on v1 exactly like omitting it.
Without the opt-in:
| Method | Behaviour on a v1 transaction |
|---|---|
getTransaction | error -32015 |
getBlock | one v1 transaction fails the whole block, no partial result |
blockSubscribe | emits block: null and stops advancing |
getSignaturesForAddress | unaffected |
That blockSubscribe behaviour is the nastiest of the three. It doesn't disconnect. It just stops moving, and block: null looks like an empty block if you aren't checking for the error alongside it.
The hardcoded zero
The worst version of this is not an indexer reporting zero. It is an opt-in written before v1 existed that has sat at 0 ever since, in a code path nobody has opened in a year.
Transaction verification is the classic home for it. Something takes a signature, calls getTransaction, and checks a balance delta against what it expected. Pass maxSupportedTransactionVersion: 0 and a v1 transaction returns -32015 instead of a result.
What happens next depends on how the response is unwrapped, and the common shape is unkind:
let tx = match json.get("result") {
Some(r) if !r.is_null() => r,
_ => return Ok(false),
};A JSON-RPC error carries no result, so "the call failed" and "this doesn't match" collapse into the same false. Whatever is downstream logs a mismatch that never happened, retries forever, and points you at the wrong thing while it does.
Two changes fix it: raise the ceiling to 1, and separate a JSON-RPC error from a genuine negative. The parsing underneath usually needs nothing — indexing meta.preBalances and postBalances against accountKeys is version-independent, and v1 carries every account inline.
Grep your own code for maxSupportedTransactionVersion. Then look at what the caller does when that request returns an error instead of a result.
What we changed
Agave 4.2.1 → 4.2.2 on our validators. That specific patch matters more than the version bump suggests. 4.2.2 contains PR #14874, "Reconstruct V1 messages in storage-proto instead of downgrading them to V0." On 4.2.1 the storage-proto path that serves getTransaction and getBlock from ledger history silently rewrites a v1 transaction as a v0 and drops its config. A wrong answer, not an error. 4.2.2 is the minimum version the feature-gate tracker names for this gate, and this is why.
Our geyser plugin now carries Message.config end to end, aligned with yellowstone-grpc-geyser v15.2.1. The old build predated v1 entirely and couldn't emit field 7 under any circumstances.
Our published proto is field-for-field identical to yellowstone-grpc-proto 12.6.0. Our fan-out relays transaction bytes verbatim, so field 7 was never at risk in transit, but anyone generating stubs from our schema would have inherited the gap. That also picked up two other Agave 4.2 additions: RewardType.DeactivatedStake and Reward.commission_bps.
All of it is live. We're running 4.2.2 against v0 traffic for the days before the gate opens, which is the only soak that means anything.
Also in 4.2
Account updates for unmodified accounts are gone. Validators no longer write account state for accounts locked for writing but not actually modified. If you monitor liveness by account-update frequency, re-baseline your alerts. A missing update now means unchanged, not broken.
rewardType gained deactivated. Treat it as an open set, not a closed enum.
Rent is falling separately. SIMD-0437 cuts the rent-exempt minimum by 90% across five independently gated phases. Phase 1 took lamports_per_byte from 6,960 to 6,333 on 3 September, phase 2 lands mid-September, 696 is the target. Stop hardcoding rent-exempt minimums and call getMinimumBalanceForRentExemption at runtime. Existing accounts keep their balances and are now over-funded against the new minimum.
Blast radius

The short version
If you send transactions, nothing breaks. v1 is opt-in.
If you read them, you have until 15 September, 01:20 UTC. Set maxSupportedTransactionVersion: 1 as an integer. Pin yellowstone-grpc-proto at 12.6.0 and regenerate your Go stubs by hand. Check config before versioned. Stop scanning for ComputeBudget instructions and read transactionConfig. Normalise priority fees before comparing across versions.
And check every hardcoded maxSupportedTransactionVersion: 0 you own, along with what the caller does when that request errors instead of returning a result.
References
- SIMD-0385 — the v1 transaction format
- SIMD-0296 — 1,232 to 4,096 bytes
- Larger Transaction Sizes — the official upgrade guide, breaking-change tables and SDK versions
- transaction-v1-examples — runnable Rust, TypeScript, Python and Go
- Feature Gate Tracker — activation status per cluster and the minimum Agave version
- Agave v4.2.2 release notes — PR #14874, the storage-proto v1 reconstruction
- yellowstone-grpc releases — proto 12.6.0 and the geyser plugin line
- SIMD-0437 — the rent reduction phases
