diff --git a/.changeset/v1-transactions-experimental.md b/.changeset/v1-transactions-experimental.md new file mode 100644 index 0000000..f06a0e6 --- /dev/null +++ b/.changeset/v1-transactions-experimental.md @@ -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`. diff --git a/packages/client/V1-TRANSACTIONS.md b/packages/client/V1-TRANSACTIONS.md new file mode 100644 index 0000000..c57ebf6 --- /dev/null +++ b/packages/client/V1-TRANSACTIONS.md @@ -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` (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. diff --git a/packages/client/src/features/transactionsV1.test.ts b/packages/client/src/features/transactionsV1.test.ts new file mode 100644 index 0000000..5475534 --- /dev/null +++ b/packages/client/src/features/transactionsV1.test.ts @@ -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/); + }); +}); diff --git a/packages/client/src/features/transactionsV1.ts b/packages/client/src/features/transactionsV1.ts new file mode 100644 index 0000000..daf1fdb --- /dev/null +++ b/packages/client/src/features/transactionsV1.ts @@ -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 `>`, 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; + +/** 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( + 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; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ae8d360..38c0f90 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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,