Transacciones versionadas

Resumen

Solana tiene tres formatos de transacción: legacy, v0 y v1. v0 añade Address Lookup Tables (ALTs) para referenciar cuentas mediante índices de 1 byte. v1 eleva el límite de tamaño a 4.096 bytes, mueve los límites de recursos al propio mensaje y elimina las ALTs.

Solana admite tres formatos de transacción: legacy, v0 y v1. Cada uno se describe a continuación con los mismos tres aspectos: cómo organiza sus bytes en la red, cómo referencia las cuentas y de dónde provienen sus límites de recursos.

Estado de activación de v1

El formato v1 está activo en mainnet, devnet y testnet. La lectura de transacciones es un cambio incompatible: los clientes que no lo activan fallan con las transacciones v1. Las aplicaciones existentes deberían revisar la preparación para v1.

Comparación de formatos

Límitelegacyv0v1
Tamaño máximo de transacción1.232 bytes1.232 bytes4.096 bytes
Direcciones de cuenta~32, limitado por tamaño64, mediante tablas de búsqueda64, en línea (96 con SIMD-0596)
Tablas de búsqueda de direccionesno compatiblecompatibleno compatible
Límites de recursosInstrucciones ComputeBudgetInstrucciones ComputeBudgetconfiguración del mensaje

Ir a: Legacy · v0 · v1

Formato legacy

El formato original, y aún el predeterminado en la mayoría de las herramientas. No tiene ningún prefijo de versión: el primer byte de la transacción es el recuento compact-u16 del array de firmas, y el primer byte del mensaje es num_required_signatures, cuyo bit alto siempre está sin establecer.

Estructura de bytes legacy

CampoTamañoDescripción
num_signaturescompact-u16Número de firmas
signaturesnum_signatures x 64 bytesFirmas Ed25519
header3 bytesMessageHeader — el primer byte tiene el bit de versión sin establecer
num_account_keyscompact-u16Número de claves de cuenta
account_keysnum_account_keys x 32 bytesClaves públicas, todas en línea
recent_blockhash32 bytesEspecificador de tiempo de vida
num_instructionscompact-u16Número de instrucciones
instructionsvariableCada instrucción serializada de forma contigua

Cada array de longitud variable va precedido de una longitud compact-u16: 1 byte para valores del 0 al 127, y 2–3 bytes para valores mayores. Para ver la estructura por instrucción y un cálculo de tamaño paso a paso, consulta el formato binario de transacciones.

Cuentas en legacy

Cada cuenta se escribe como una clave pública completa de 32 bytes en account_keys, y las instrucciones las referencian mediante un índice de 1 byte en ese array. No hay forma de referenciar una cuenta que no esté incluida explícitamente en la transacción, lo cual limita una transacción legacy a aproximadamente 32 cuentas antes de agotar sus 1.232 bytes.

Límites de recursos en legacy

El límite de unidades de cómputo, el límite de tamaño de datos de cuentas cargadas, el tamaño del heap y la tarifa de prioridad se solicitan incluyendo instrucciones del programa ComputeBudget en la transacción. Cada una consume un slot de instrucción y 150 unidades de cómputo. Omitirlas es seguro: el entorno de ejecución recurre a los valores predeterminados de 200.000 CU por instrucción (con un máximo de 1,4 M), un límite de tamaño de datos de 64 MiB, un heap de 32 KiB y una tarifa de prioridad de cero.

Formato v0

v0 es un mensaje legacy con dos añadidos: un byte de prefijo de versión 0x80 y un array address_table_lookups anexado tras las instrucciones. Todo lo anterior a estos elementos es idéntico byte a byte al formato legacy.

Estructura de bytes v0

CampoTamañoDescripción
num_signaturescompact-u16Número de firmas
signaturesnum_signatures x 64 bytesFirmas Ed25519
0x801 byteByte de prefijo de versión — primer byte del mensaje
header3 bytesMessageHeader (igual que legacy)
num_account_keyscompact-u16Número de claves de cuenta estáticas
static_account_keysnum_account_keys x 32 bytesClaves que aparecen literalmente en la transacción
recent_blockhash32 bytesEspecificador de tiempo de vida
num_instructionscompact-u16Número de instrucciones
instructionsvariableMismo formato que legacy
address_table_lookupscompact-u16 + variableReferencias ALT (ver abajo)

Cada entrada de la tabla de búsqueda de direcciones contiene:

CampoTamañoDescripción
account_key32 bytesLa clave pública de la cuenta ALT
writable_indexescompact-u16 + N x 1 byteÍndices en la ALT para cuentas con permisos de escritura
readonly_indexescompact-u16 + N x 1 byteÍndices en la ALT para cuentas de solo lectura

Tablas de búsqueda de direcciones

Un ALT es una cuenta onchain que almacena hasta 256 claves públicas. Al hacer referencia a un ALT, una transacción puede incluir cuentas adicionales usando índices de 1 byte en lugar de claves públicas de 32 bytes, reduciendo significativamente la sobrecarga por cuenta.

En tiempo de ejecución, antes de que comience la ejecución, el validador resuelve todas las referencias de ALT en claves públicas completas. Las direcciones resueltas se añaden a las claves de cuenta estáticas para formar la lista completa de claves de cuenta. Las cuentas resueltas por ALT siguen el mismo orden que las cuentas estáticas: las búsquedas con permisos de escritura vienen antes que las búsquedas de solo lectura.

Las tablas de búsqueda de direcciones solo afectan cómo se referencian las cuentas en la transacción transmitida. En tiempo de ejecución, el runtime resuelve todos los índices a direcciones de cuenta completas. Las cuentas resueltas por ALT solo pueden tener permisos de escritura o ser de solo lectura (no firmantes); no pueden ser firmantes.

Crear y usar una tabla de búsqueda de direcciones

Este ejemplo crea una tabla de búsqueda, agrega una dirección y usa la tabla para compilar y enviar una transacción v0.

Solo se pueden cargar desde una tabla de búsqueda las cuentas que no son firmantes. El pagador de comisiones y todos los firmantes requeridos permanecen en la lista de cuentas estática del mensaje. Además, una dirección no se puede buscar en el mismo slot en el que se agregó, por lo que el ejemplo espera al siguiente slot confirmado antes de compilar el mensaje v0.

Límites de recursos en v0

Sin cambios respecto a legacy: instrucciones ComputeBudget, con los mismos valores predeterminados cuando se omiten.

Formato v1

v1 eleva el límite de tamaño a 4.096 bytes y reestructura el mensaje en torno a una configuración de transacción: los límites de recursos salen de las instrucciones ComputeBudget y pasan a campos de posición fija en el propio mensaje. Esto permite que la red clasifique una transacción por tarifa de prioridad con una sola lectura en un desplazamiento fijo, en lugar de escanear y deserializar su lista de instrucciones.

Estructura de bytes v1

CampoTamañoDescripción
0x811 byteByte de prefijo de versión — el primer byte de la transacción
header3 bytesMessageHeader (igual que legacy)
config_mask4 bytesMáscara de bits u32 LE que indica qué valores de configuración están presentes
recent_blockhash32 bytesEspecificador de tiempo de vida
num_instructions1 byteRecuento de ancho fijo, máximo 64
num_addresses1 byteRecuento de ancho fijo, máximo 64 (96 con SIMD-0596)
addressesN x 32 bytesDirecciones de cuenta, todas en línea — sin referencias a tablas de búsqueda
config_values0–20 bytesUn valor por bit de máscara establecido, en orden de bit (ver más abajo)
instruction_headersN x 4 bytesPor instrucción: program_id_index (u8), num_accounts (u8), data_len (u16 LE)
instruction_payloadsvariablePor instrucción: índices de cuenta y, a continuación, instruction data
signaturesN x 64 bytesAl final, sin prefijo de longitud — el recuento proviene del encabezado

Al escribir un decodificador, vale la pena señalar dos diferencias estructurales respecto a legacy y v0. Los recuentos son campos u8 de ancho fijo en lugar de compact-u16, y las instrucciones se dividen en dos bloques: primero todos los encabezados de tamaño fijo y, después, todos los payloads de longitud variable, en lugar de que cada instrucción sea contigua.

Cuentas en v1: sin tablas de búsqueda de direcciones

v1 elimina deliberadamente la compatibilidad con ALT. 64 direcciones sin procesar son 2048 bytes, y las 96 direcciones permitidas tras SIMD-0596 son 3072 bytes, ambas cómodamente dentro del límite de 4096 bytes, por lo que cada dirección va en línea, como en legacy. Si tu aplicación depende de tablas de búsqueda, pasar a v1 implica incluir esas direcciones en línea.

Límites de recursos en v1: la configuración de transacción

La configuración es una máscara de bits u32 seguida de valores de ancho fijo para cada campo cuyo bit está establecido:

Bit(s)CampoAnchoNotas
0–1Tarifa de prioridadu64Total en lamports — ambos bits establecidos juntos
2Límite de unidades de cómputou32
3Límite de tamaño de datos de cuentas cargadasu32
4Tamaño de heap solicitadou32

Los bits desconocidos son rechazados. Dado que el mensaje está firmado, los campos de configuración no reconocidos no pueden descartarse silenciosamente.

La tarifa de prioridad es el total en lamports, no un precio

En legacy y v0, la tarifa de prioridad se establece mediante SetComputeUnitPrice como micro-lamports por unidad de cómputo, multiplicados por el límite de unidades de cómputo. En v1 es un total absoluto en lamports — sin multiplicación ni redondeo. No traslades la aritmética por CU. La fórmula total de la tarifa no cambia en lo demás: (signatures × lamports_per_signature) + priority_fee.

Las instrucciones ComputeBudget son no-ops en v1

Una transacción v1 no rechaza las instrucciones ComputeBudget — las ignora para la configuración. Siguen ejecutándose como no-ops exitosas, consumiendo 150 unidades de cómputo y uno de los 64 slots de instrucción sin tener ningún efecto sobre el presupuesto. Elimínalas al construir transacciones v1, y deja de escanearlas al leer transacciones v1: los valores residen en la configuración del mensaje.

Los campos de configuración deben establecerse explícitamente

El cambio de comportamiento más importante para los emisores: a diferencia de legacy y v0, las transacciones v1 deben establecer explícitamente el límite de unidades de cómputo y el límite de tamaño de datos de cuentas cargadas; de lo contrario, la transacción fallará.

Campo no establecidolegacy / v0v1Consecuencia si se omite
Límite de unidades de cómputo200k por instrucción, máx. 1,4 M0 CUFalla inmediatamente, presupuesto agotado
Tamaño de datos de cuentas cargadas64 MiB0 bytesMaxLoadedAccountsDataSizeExceeded en la primera cuenta cargada
Tarifa de prioridad00—
Tamaño de heap32 KiB32 KiB—

El enfoque recomendado es simular una vez con ambos límites al máximo, y luego escribir los valores devueltos unitsConsumed y loadedAccountsDataSize de vuelta en la configuración, redondeando el tamaño de datos hacia arriba hasta la siguiente página de 32 KiB para tener margen (el modelo de coste de bloque cobra en páginas de 32 KiB, por lo que el margen por debajo del límite de la siguiente página es gratuito).

Construcción de una transacción v1

Un mensaje v1 se ensambla mediante el mismo flujo que uno legacy o v0. La diferencia es setTransactionMessageConfig: los límites de recursos son una propiedad del mensaje, por lo que la lista de instrucciones contiene solo las instrucciones del programa.

Kit
import {
appendTransactionMessageInstruction,
assertIsTransactionWithBlockhashLifetime,
createClient,
createTransactionMessage,
generateKeyPairSigner,
getSignatureFromTransaction,
lamports,
pipe,
sendAndConfirmTransactionFactory,
setTransactionMessageConfig,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signTransactionMessageWithSigners,
} from "@solana/kit";
import { rpcAirdrop, solanaRpc } from "@solana/kit-plugin-rpc";
import { airdropPayer, payer } from "@solana/kit-plugin-signer";
import { getTransferSolInstruction } from "@solana-program/system";
const sender = await generateKeyPairSigner();
const recipient = await generateKeyPairSigner();
const client = await createClient()
.use(payer(sender))
.use(
solanaRpc({
rpcUrl: "http://localhost:8899",
rpcSubscriptionsUrl: "ws://localhost:8900",
}),
)
.use(rpcAirdrop())
.use(airdropPayer(lamports(1_000_000_000n)));
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send();
// A v1 message is assembled through the same pipeline as a legacy or v0 one.
// The difference is the config: a v1 transaction carries its resource limits in
// the message itself, so no ComputeBudget instructions are compiled in.
const transactionMessage = pipe(
createTransactionMessage({ version: 1 }),
(m) => setTransactionMessageFeePayerSigner(sender, m),
(m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
(m) =>
appendTransactionMessageInstruction(
getTransferSolInstruction({
source: sender,
destination: recipient.address,
amount: lamports(10_000_000n),
}),
m,
),
// The compute unit limit and loaded accounts data size limit both default to
// zero on v1, so a transaction that leaves them unset always fails. The
// priority fee is an absolute total in lamports, not a price per compute
// unit.
(m) =>
setTransactionMessageConfig(
{
computeUnitLimit: 20_000,
loadedAccountsDataSizeLimit: 64 * 1024,
priorityFeeLamports: 5_000n,
},
m,
),
);
const signedTransaction =
await signTransactionMessageWithSigners(transactionMessage);
assertIsTransactionWithBlockhashLifetime(signedTransaction);
await sendAndConfirmTransactionFactory({
rpc: client.rpc,
rpcSubscriptions: client.rpcSubscriptions,
})(signedTransaction, { commitment: "confirmed" });
const transactionSignature = getSignatureFromTransaction(signedTransaction);
console.log("Transaction Signature:", transactionSignature);
Console
Click to execute the code.

Kit también expone un setter por cada campo de configuración — setTransactionMessagePriorityFeeLamports, setTransactionMessageComputeUnitLimit, setTransactionMessageLoadedAccountsDataSizeLimit y setTransactionMessageHeapSize — para establecerlos de uno en uno.

Dimensionar los límites mediante simulación

En lugar de adivinar los dos límites obligatorios, mídelos. La simulación se ejecuta con ambos límites elevados al máximo del runtime, por lo que no puede fallar por falta de los recursos que está midiendo. La cifra devuelta es el costo exacto de una ejecución simulada, así que añade un margen antes de basarte en ella.

Kit
import {
appendTransactionMessageInstruction,
assertIsTransactionWithBlockhashLifetime,
createClient,
createTransactionMessage,
estimateAndSetResourceLimitsFactory,
estimateResourceLimitsFactory,
fillTransactionMessageProvisoryResourceLimits,
generateKeyPairSigner,
getSignatureFromTransaction,
lamports,
pipe,
sendAndConfirmTransactionFactory,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
setTransactionMessagePriorityFeeLamports,
signTransactionMessageWithSigners,
} from "@solana/kit";
import { rpcAirdrop, solanaRpc } from "@solana/kit-plugin-rpc";
import { airdropPayer, payer } from "@solana/kit-plugin-signer";
import { getTransferSolInstruction } from "@solana-program/system";
const sender = await generateKeyPairSigner();
const recipient = await generateKeyPairSigner();
const client = await createClient()
.use(payer(sender))
.use(
solanaRpc({
rpcUrl: "http://localhost:8899",
rpcSubscriptionsUrl: "ws://localhost:8900",
}),
)
.use(rpcAirdrop())
.use(airdropPayer(lamports(1_000_000_000n)));
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send();
// The priority fee is a pricing decision that simulation cannot measure, so it
// is set directly. The two resource limits are measured below instead of
// guessed. The provisory fill writes placeholder limits so the message
// simulates at the same size it will be sent at.
const draftMessage = pipe(
createTransactionMessage({ version: 1 }),
(m) => setTransactionMessageFeePayerSigner(sender, m),
(m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
(m) =>
appendTransactionMessageInstruction(
getTransferSolInstruction({
source: sender,
destination: recipient.address,
amount: lamports(10_000_000n),
}),
m,
),
(m) => setTransactionMessagePriorityFeeLamports(5_000n, m),
fillTransactionMessageProvisoryResourceLimits,
);
// Simulation runs with both limits raised to the runtime maximum, so it cannot
// fail for want of the resources it is measuring.
const estimateAndSetResourceLimits = estimateAndSetResourceLimitsFactory(
estimateResourceLimitsFactory({ rpc: client.rpc }),
);
const transactionMessage = await estimateAndSetResourceLimits(draftMessage, {
commitment: "confirmed",
});
console.log("Compute unit limit:", transactionMessage.config?.computeUnitLimit);
console.log(
"Loaded accounts data size limit:",
transactionMessage.config?.loadedAccountsDataSizeLimit,
);
const signedTransaction =
await signTransactionMessageWithSigners(transactionMessage);
assertIsTransactionWithBlockhashLifetime(signedTransaction);
await sendAndConfirmTransactionFactory({
rpc: client.rpc,
rpcSubscriptions: client.rpcSubscriptions,
})(signedTransaction, { commitment: "confirmed" });
// The estimate is the exact cost of one simulated run, with nothing to spare.
// Add a margin before relying on it in production.
console.log(
"Transaction Signature:",
getSignatureFromTransaction(signedTransaction),
);
Console
Click to execute the code.

Preparación para v1

v1 cambia la lectura de transacciones, no solo su envío. Cualquier cliente que llame a getTransaction o getBlock sin activar la opción falla con las transacciones v1:

  • Pasa maxSupportedTransactionVersion: 1 — el entero JSON 1, no la cadena "1" — a getTransaction y getBlock. Pasar 0 falla con transacciones v1 exactamente igual que omitir el parámetro, por lo que una base de código actualizada durante el despliegue de v0 aún necesita que se cambie el valor.
  • getTransaction falla con una transacción v1 con el error -32015, y una sola transacción v1 hace fallar toda la respuesta de getBlock con el mismo error — no hay resultado parcial.
  • blockSubscribe emite block: null y deja de avanzar, por lo que un consumidor que interprete eso como un bloque vacío se quedará silenciosamente atrás a partir del primer slot v1 en adelante.
  • getSignaturesForAddress nunca inspecciona los cuerpos de las transacciones, por lo que las firmas v1 se listan con normalidad.
  • Las respuestas con la opción activada incluyen un objeto transactionConfig en el mensaje para las transacciones v1 (ausente por completo en legacy y v0). Las canalizaciones que obtienen comisiones de prioridad o límites de cómputo escaneando instrucciones ComputeBudget informan silenciosamente cero para cada transacción v1.
  • Usa encoding: 'base64' cuando decodifiques transacciones en el cliente, y para sendTransaction/simulateTransaction con transacciones de más de 1.232 bytes — la codificación base58 sigue estando limitada al tamaño antiguo.
  • El soporte en las bibliotecas cliente requiere versiones recientes: @solana/kit 8.0+, los crates de Rust de la generación Agave 4.2.x, o web3.js v3. web3.js v1 lee v1 a partir de 1.99.0 en adelante, pero no puede construirlo ni enviarlo.

Lectura de una transacción v1

Para activarlo se usa el entero JSON 1. Al decodificar los bytes devueltos se obtienen la versión y la configuración, que es donde residen los límites de recursos: escanear la lista de instrucciones en busca de instrucciones ComputeBudget no encuentra nada.

Kit
import {
decompileTransactionMessage,
getBase64Encoder,
getCompiledTransactionMessageDecoder,
getTransactionDecoder,
} from "@solana/kit";
// Reading a v1 transaction requires opting in. The integer 1 is mandatory:
// omitting the parameter, or passing 0, fails on a v1 transaction rather than
// returning it in a degraded form.
const fetched = await client.rpc
.getTransaction(transactionSignature, {
commitment: "confirmed",
encoding: "base64",
maxSupportedTransactionVersion: 1,
})
.send();
if (fetched === null) {
throw new Error("the transaction just sent was not found");
}
// The compiled message stores the config as a bitmask plus a positional list,
// so it is decompiled to address the fields by name. Decompiling fetches no
// accounts, because v1 does not support address lookup tables.
const wireTransaction = getBase64Encoder().encode(fetched.transaction[0]);
const transaction = getTransactionDecoder().decode(wireTransaction);
const compiledMessage = getCompiledTransactionMessageDecoder().decode(
transaction.messageBytes,
);
const message = decompileTransactionMessage(compiledMessage);
console.log("Version:", message.version);
if ("config" in message) {
// Scanning the instruction list for ComputeBudget instructions finds nothing
// here — on v1 the limits live in the config.
console.log("Compute unit limit:", message.config?.computeUnitLimit);
console.log("Priority fee (lamports):", message.config?.priorityFeeLamports);
}
Console
Click to execute the code.

Para consultar la guía de migración completa —incluidos el soporte de bibliotecas cliente, la detección de versiones en streaming (Geyser/gRPC) y el comportamiento de la simulación—, consulta la página de actualización al formato de transacción v1. Los ejemplos ejecutables en TypeScript, Rust, Python y Go se encuentran en solana-foundation/transaction-v1-examples.

Is this page helpful?