Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .changeset/v1-transactions-experimental.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@solana/client": minor
---

Add **experimental** support for version 1 (v1) transactions. Kit v7 supports v1 at runtime
(`MAX_SUPPORTED_TRANSACTION_VERSION === 1`) but gates it out of its public types, so this ships an
opt-in foundation — `createV1TransactionMessage`, `setV1TransactionConfig`, and
`buildV1TransactionMessage` — that builds a signable v1 message with native compute-budget config and
rejects address-lookup-table instructions. Treat as experimental until Kit exposes v1 officially. See
`packages/client/V1-TRANSACTIONS.md`.
73 changes: 73 additions & 0 deletions packages/client/V1-TRANSACTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Version 1 (v1) transaction support — scope

Status: **experimental foundation**. Tracks what it takes to support Solana v1 transactions in
`@solana/client` on top of the Kit v7 upgrade.

## What a v1 transaction is

v1 is a transaction format where compute-budget settings live natively on the message instead of as
separate Compute Budget program instructions:

```ts
type V1TransactionMessage = BaseTransactionMessage<1, InstructionWithoutLookupTables> & {
config?: {
computeUnitLimit?: number;
priorityFeeLamports?: bigint; // total priority fee — NOT a per-CU price
heapSize?: number;
loadedAccountsDataSizeLimit?: number;
};
};
```

Two properties matter for us:

1. Compute budget is a message field, not instructions.
2. v1 messages use `InstructionWithoutLookupTables` — **address lookup tables are not supported**.

## The Kit v7 gating (why this is "experimental")

v1 is **fully functional at runtime** in Kit v7 (`MAX_SUPPORTED_TRANSACTION_VERSION === 1`; v1
messages compile and encode to the wire — leading byte `0x81`). But Kit v7 deliberately gates v1 out
of its **public type surface**:

| Capability | Public in Kit v7? |
| --- | --- |
| `createTransactionMessage({ version: 1 })` | ❌ typed `Exclude<TransactionVersion, 1>` (works at runtime) |
| `setTransactionMessageConfig` / `V1TransactionConfig` | ❌ not re-exported from `@solana/kit` |
| `setTransactionMessageComputeUnitLimit` / `…PriorityFeeLamports` / `…HeapSize` / `…LoadedAccountsDataSizeLimit` | ✅ public and v1-aware (write into `config`) |
| `TransactionMessage` union (includes the v1 member) | ✅ exported |
| `compileTransactionMessage`, wire encoders, signers | ✅ handle v1 |

So the **only** thing missing from the public API is a typed way to construct an empty v1 message.
That requires exactly one isolated assertion (see `createV1TransactionMessage` in
`src/features/transactionsV1.ts`); everything else uses public, typed, v1-aware APIs.

Because we depend on this gating not changing shape, treat v1 support as experimental until Kit
exposes v1 officially (the `8.0.0-canary` line is where the public API is expected to land).

## What this PR sets up

`src/features/transactionsV1.ts` — a working, tested foundation:

- `createV1TransactionMessage()` — the single, documented cast.
- `setV1TransactionConfig(config, message)` — compute budget via public v1-aware setters.
- `buildV1TransactionMessage({ feePayer, lifetime, instructions, config })` — assembles a signable
v1 message and rejects lookup-table instructions.
- Types: `V1TransactionMessage`, `V1TransactionConfig`, `V1BlockhashLifetime`, `BuildV1TransactionMessageInput`.

Tests in `transactionsV1.test.ts` cover construction, config-in-message, wire encoding, and the
no-ALT guard.

## Follow-up phases (not in this PR)

1. **Wire `version: 1` into `createTransactionHelper.prepare`** — accept `version: 1` explicitly
(never via `'auto'`, which resolves to `0`/`legacy`), and route compute budget through the v1
config instead of `getSetComputeUnitLimit`/`Price` prefix instructions. This is the main behavior
fork, since today's `prepare` builds one instruction sequence and packs it.
2. **Priority-fee model** — decide how `computeUnitPrice` (per-CU microLamports) maps to v1's total
`priorityFeeLamports`, or expose `priorityFeeLamports` directly for v1 requests.
3. **Feature helpers** — extend `sol` / `spl` / `stake` / `wsol` `transactionVersion` inputs to accept
`1` once (1) lands, including the no-ALT constraint in their types.
4. **Planner/packer** — account for the `config` byte cost in size estimation when packing multiple
v1 messages.
5. Revisit once Kit publishes the official v1 API and drop the internal assertion.
67 changes: 67 additions & 0 deletions packages/client/src/features/transactionsV1.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
compileTransactionMessage,
generateKeyPairSigner,
getCompiledTransactionMessageEncoder,
getTransactionMessageComputeUnitLimit,
getTransactionMessagePriorityFeeLamports,
type Instruction,
} from '@solana/kit';
import { describe, expect, it } from 'vitest';
import { buildV1TransactionMessage, createV1TransactionMessage, setV1TransactionConfig } from './transactionsV1';

const MEMO_PROGRAM_ADDRESS = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr';
const LIFETIME = { blockhash: '11111111111111111111111111111111', lastValidBlockHeight: 100n } as const;

function memoInstruction(): Instruction {
return { programAddress: MEMO_PROGRAM_ADDRESS, accounts: [], data: new Uint8Array([1, 2, 3]) } as Instruction;
}

describe('v1 transactions', () => {
it('creates an empty version 1 message', () => {
const message = createV1TransactionMessage();
expect(message.version).toBe(1);
expect(message.instructions).toEqual([]);
});

it('writes compute budget into the native config rather than instructions', () => {
const message = setV1TransactionConfig(
{ computeUnitLimit: 300_000, priorityFeeLamports: 50_000n },
createV1TransactionMessage(),
);
expect(getTransactionMessageComputeUnitLimit(message)).toBe(300_000);
expect(getTransactionMessagePriorityFeeLamports(message)).toBe(50_000n);
// No Compute Budget instructions are appended for v1.
expect(message.instructions).toHaveLength(0);
});

it('builds and encodes a signable v1 message to the wire (version prefix 0x81)', async () => {
const signer = await generateKeyPairSigner();
const message = buildV1TransactionMessage({
feePayer: signer,
lifetime: LIFETIME,
instructions: [memoInstruction()],
config: { computeUnitLimit: 200_000, priorityFeeLamports: 10_000n },
});
const compiled = compileTransactionMessage(message);
expect(compiled.version).toBe(1);
const wire = getCompiledTransactionMessageEncoder().encode(compiled);
// A versioned message sets the high bit of the leading byte: 0x80 | 1 === 0x81.
expect(wire[0]).toBe(0x81);
});

it('rejects instructions that reference address lookup tables', () => {
const withLookup = {
programAddress: MEMO_PROGRAM_ADDRESS,
accounts: [],
data: new Uint8Array(),
addressTableLookups: [{ lookupTableAddress: '11111111111111111111111111111111', readableIndices: [0] }],
} as unknown as Instruction;
expect(() =>
buildV1TransactionMessage({
feePayer: '11111111111111111111111111111111' as never,
lifetime: LIFETIME,
instructions: [withLookup],
}),
).toThrow(/do not support address lookup tables/);
});
});
149 changes: 149 additions & 0 deletions packages/client/src/features/transactionsV1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Experimental support for **version 1 (v1) transactions**.
*
* Kit v7 ships the v1 transaction format at runtime — `MAX_SUPPORTED_TRANSACTION_VERSION` is `1`,
* and v1 messages compile and encode to the wire — but it deliberately **gates v1 out of its
* public type surface**:
*
* - `createTransactionMessage` is typed `<TVersion extends Exclude<TransactionVersion, 1>>`, so
* `createTransactionMessage({ version: 1 })` is a type error even though it works at runtime.
* - The low-level v1 config module (`setTransactionMessageConfig`, `V1TransactionConfig`) is not
* re-exported from `@solana/kit`.
*
* The per-field setters that write into a v1 message's native `config` (`computeUnitLimit`,
* `priorityFeeLamports`, `heapSize`, `loadedAccountsDataSizeLimit`) **are** public and v1-aware, so
* the only thing this module needs that kit does not publicly type is the initial construction of an
* (empty) v1 message. That is done via a single, isolated, documented assertion in
* {@link createV1TransactionMessage}.
*
* Because this rides on kit internals that are not yet part of kit's public API, treat everything in
* this module as **experimental** until kit exposes v1 transactions officially (expected in a future
* major). See `V1-TRANSACTIONS.md` for the full scope.
*/
import type { Address, Blockhash, Instruction, TransactionMessage, TransactionSigner } from '@solana/kit';
import {
appendTransactionMessageInstruction,
createTransactionMessage,
pipe,
setTransactionMessageComputeUnitLimit,
setTransactionMessageFeePayer,
setTransactionMessageFeePayerSigner,
setTransactionMessageHeapSize,
setTransactionMessageLifetimeUsingBlockhash,
setTransactionMessageLoadedAccountsDataSizeLimit,
setTransactionMessagePriorityFeeLamports,
} from '@solana/kit';

/** A kit transaction message pinned to version `1`. */
export type V1TransactionMessage = Extract<TransactionMessage, { version: 1 }>;

/** Blockhash lifetime accepted by {@link buildV1TransactionMessage}. */
export type V1BlockhashLifetime = Readonly<{
blockhash: Blockhash;
lastValidBlockHeight: bigint;
}>;

/**
* Native v1 transaction configuration.
*
* Unlike legacy/v0 transactions — where compute-budget settings are separate Compute Budget program
* instructions — v1 transactions carry these values in the message itself. Note that v1 uses a total
* `priorityFeeLamports` rather than a per-compute-unit price.
*/
export type V1TransactionConfig = Readonly<{
/** Maximum compute units the transaction may consume (max 1,400,000). */
computeUnitLimit?: number;
/** Total priority fee in lamports paid for prioritization. */
priorityFeeLamports?: bigint;
/** Requested heap frame size, in bytes. */
heapSize?: number;
/** Maximum size, in bytes, for loaded account data. */
loadedAccountsDataSizeLimit?: number;
}>;

/**
* Creates an empty v1 transaction message.
*
* This is the one place that reaches past kit v7's public typing: `createTransactionMessage` supports
* `{ version: 1 }` at runtime (it simply returns `{ instructions: [], version }`) but its type
* signature excludes `1`. The assertion is isolated here so the rest of the module stays fully typed.
*/
export function createV1TransactionMessage(): V1TransactionMessage {
const create = createTransactionMessage as unknown as (config: { version: 1 }) => V1TransactionMessage;
return create({ version: 1 });
}

/** Applies {@link V1TransactionConfig} values to a v1 message using kit's public, v1-aware setters. */
export function setV1TransactionConfig<TMessage extends V1TransactionMessage>(
config: V1TransactionConfig,
message: TMessage,
): TMessage {
let next = message;
if (config.computeUnitLimit !== undefined) {
next = setTransactionMessageComputeUnitLimit(config.computeUnitLimit, next) as TMessage;
}
if (config.priorityFeeLamports !== undefined) {
next = setTransactionMessagePriorityFeeLamports(config.priorityFeeLamports, next) as TMessage;
}
if (config.heapSize !== undefined) {
next = setTransactionMessageHeapSize(config.heapSize, next) as TMessage;
}
if (config.loadedAccountsDataSizeLimit !== undefined) {
next = setTransactionMessageLoadedAccountsDataSizeLimit(config.loadedAccountsDataSizeLimit, next) as TMessage;
}
return next;
}

/**
* v1 transaction messages cannot reference address lookup tables. Throws if any instruction carries
* lookup-table metadata so callers get a clear error instead of an invalid transaction.
*/
function assertNoAddressLookups(instructions: readonly Instruction[]): void {
for (const instruction of instructions) {
if (
('addressTableLookup' in instruction && instruction.addressTableLookup != null) ||
('addressTableLookups' in instruction &&
Array.isArray((instruction as { addressTableLookups?: unknown[] }).addressTableLookups) &&
(instruction as { addressTableLookups: unknown[] }).addressTableLookups.length > 0)
) {
throw new Error('Version 1 transactions do not support address lookup tables.');
}
}
}

/** Inputs for {@link buildV1TransactionMessage}. */
export type BuildV1TransactionMessageInput = Readonly<{
/** Fee payer. Provide a {@link TransactionSigner} to attach the signer, or an {@link Address}. */
feePayer: Address | TransactionSigner;
/** Blockhash lifetime for the transaction. */
lifetime: V1BlockhashLifetime;
/** Instructions to include. Must not reference address lookup tables. */
instructions: readonly Instruction[];
/** Optional native compute-budget configuration. */
config?: V1TransactionConfig;
}>;

/**
* Builds a signable v1 transaction message from the supplied fee payer, lifetime, instructions, and
* optional native config. Compute-budget settings are written into the message's `config` rather than
* appended as Compute Budget instructions.
*/
export function buildV1TransactionMessage(input: BuildV1TransactionMessageInput): V1TransactionMessage {
assertNoAddressLookups(input.instructions);
const isSigner = typeof input.feePayer === 'object' && 'address' in input.feePayer;
let message = pipe(
createV1TransactionMessage(),
(m) =>
isSigner
? setTransactionMessageFeePayerSigner(input.feePayer as TransactionSigner, m)
: setTransactionMessageFeePayer(input.feePayer as Address, m),
(m) => setTransactionMessageLifetimeUsingBlockhash(input.lifetime, m),
) as V1TransactionMessage;
for (const instruction of input.instructions) {
message = appendTransactionMessageInstruction(instruction, message) as V1TransactionMessage;
}
if (input.config) {
message = setV1TransactionConfig(input.config, message);
}
return message;
}
9 changes: 9 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ export {
type TransactionSendOptions,
type TransactionSignOptions,
} from './features/transactions';
export {
type BuildV1TransactionMessageInput,
buildV1TransactionMessage,
createV1TransactionMessage,
setV1TransactionConfig,
type V1BlockhashLifetime,
type V1TransactionConfig,
type V1TransactionMessage,
} from './features/transactionsV1';
export {
createWsolHelper,
WRAPPED_SOL_MINT,
Expand Down