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ímite | legacy | v0 | v1 |
|---|---|---|---|
| Tamaño máximo de transacción | 1.232 bytes | 1.232 bytes | 4.096 bytes |
| Direcciones de cuenta | ~32, limitado por tamaño | 64, mediante tablas de búsqueda | 64, en línea (96 con SIMD-0596) |
| Tablas de búsqueda de direcciones | no compatible | compatible | no compatible |
| Límites de recursos | Instrucciones ComputeBudget | Instrucciones ComputeBudget | configuración del mensaje |
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
| Campo | Tamaño | Descripción |
|---|---|---|
num_signatures | compact-u16 | Número de firmas |
signatures | num_signatures x 64 bytes | Firmas Ed25519 |
header | 3 bytes | MessageHeader — el primer byte tiene el bit de versión sin establecer |
num_account_keys | compact-u16 | Número de claves de cuenta |
account_keys | num_account_keys x 32 bytes | Claves públicas, todas en línea |
recent_blockhash | 32 bytes | Especificador de tiempo de vida |
num_instructions | compact-u16 | Número de instrucciones |
instructions | variable | Cada 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
| Campo | Tamaño | Descripción |
|---|---|---|
num_signatures | compact-u16 | Número de firmas |
signatures | num_signatures x 64 bytes | Firmas Ed25519 |
0x80 | 1 byte | Byte de prefijo de versión — primer byte del mensaje |
header | 3 bytes | MessageHeader (igual que legacy) |
num_account_keys | compact-u16 | Número de claves de cuenta estáticas |
static_account_keys | num_account_keys x 32 bytes | Claves que aparecen literalmente en la transacción |
recent_blockhash | 32 bytes | Especificador de tiempo de vida |
num_instructions | compact-u16 | Número de instrucciones |
instructions | variable | Mismo formato que legacy |
address_table_lookups | compact-u16 + variable | Referencias ALT (ver abajo) |
Cada entrada de la tabla de búsqueda de direcciones contiene:
| Campo | Tamaño | Descripción |
|---|---|---|
account_key | 32 bytes | La clave pública de la cuenta ALT |
writable_indexes | compact-u16 + N x 1 byte | Índices en la ALT para cuentas con permisos de escritura |
readonly_indexes | compact-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
| Campo | Tamaño | Descripción |
|---|---|---|
0x81 | 1 byte | Byte de prefijo de versión — el primer byte de la transacción |
header | 3 bytes | MessageHeader (igual que legacy) |
config_mask | 4 bytes | Máscara de bits u32 LE que indica qué valores de configuración están presentes |
recent_blockhash | 32 bytes | Especificador de tiempo de vida |
num_instructions | 1 byte | Recuento de ancho fijo, máximo 64 |
num_addresses | 1 byte | Recuento de ancho fijo, máximo 64 (96 con SIMD-0596) |
addresses | N x 32 bytes | Direcciones de cuenta, todas en línea — sin referencias a tablas de búsqueda |
config_values | 0–20 bytes | Un valor por bit de máscara establecido, en orden de bit (ver más abajo) |
instruction_headers | N x 4 bytes | Por instrucción: program_id_index (u8), num_accounts (u8), data_len (u16 LE) |
instruction_payloads | variable | Por instrucción: índices de cuenta y, a continuación, instruction data |
signatures | N x 64 bytes | Al 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) | Campo | Ancho | Notas |
|---|---|---|---|
| 0–1 | Tarifa de prioridad | u64 | Total en lamports — ambos bits establecidos juntos |
| 2 | Límite de unidades de cómputo | u32 | |
| 3 | Límite de tamaño de datos de cuentas cargadas | u32 | |
| 4 | Tamaño de heap solicitado | u32 |
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 establecido | legacy / v0 | v1 | Consecuencia si se omite |
|---|---|---|---|
| Límite de unidades de cómputo | 200k por instrucción, máx. 1,4 M | 0 CU | Falla inmediatamente, presupuesto agotado |
| Tamaño de datos de cuentas cargadas | 64 MiB | 0 bytes | MaxLoadedAccountsDataSizeExceeded en la primera cuenta cargada |
| Tarifa de prioridad | 0 | 0 | — |
| Tamaño de heap | 32 KiB | 32 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.
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);
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.
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),);
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 JSON1, no la cadena"1"— agetTransactionygetBlock. Pasar0falla 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. getTransactionfalla con una transacción v1 con el error-32015, y una sola transacción v1 hace fallar toda la respuesta degetBlockcon el mismo error — no hay resultado parcial.blockSubscribeemiteblock: nully 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.getSignaturesForAddressnunca 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
transactionConfigen 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 parasendTransaction/simulateTransactioncon 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/kit8.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.
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);}
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?