Estructura de transacción

Resumen

Una transacción tiene firmas + un mensaje. El mensaje contiene un encabezado, direcciones de cuenta, blockhash reciente e instrucciones compiladas. Tamaño serializado máximo: 1.232 bytes.

Una Transaction tiene dos campos de nivel superior:

  • signatures: Un array de firmas
  • message: Información de la transacción, incluyendo la lista de instrucciones a procesar
Transaction
pub struct Transaction {
pub signatures: Vec<Signature>,
pub message: Message,
}

Diagrama mostrando las dos partes de una transacciónDiagrama mostrando las dos partes de una transacción

El tamaño serializado total de una transacción no debe exceder PACKET_DATA_SIZE (1.232 bytes). Este límite equivale a 1.280 bytes (el MTU mínimo de IPv6) menos 48 bytes para encabezados de red (40 bytes IPv6 + 8 bytes de encabezado de fragmento). Los 1.232 bytes incluyen tanto el array signatures como la estructura message.

Diagrama mostrando el formato de transacción y límites de tamañoDiagrama mostrando el formato de transacción y límites de tamaño

Firmas

El campo signatures es un array codificado de forma compacta de valores Signature. Cada Signature es una firma Ed25519 de 64 bytes del Message serializado, firmado con la clave privada de la cuenta firmante. Se requiere una firma por cada cuenta firmante referenciada por las instrucciones de la transacción.

Cada firma es producida por una clave privada. El lugar donde reside esa clave — un keypair local, un HSM o KMS en la nube, o un servicio de billetera administrada — es una decisión de diseño para producción. Consulta Firma en Producción.

La primera firma en el array pertenece al pagador de comisiones, la cuenta que paga la comisión base y la comisión de priorización de la transacción. Esta primera firma también sirve como el ID de transacción, utilizado para buscar la transacción en la red. El ID de transacción se conoce comúnmente como la firma de transacción.

Requisitos del pagador de comisiones:

  • Debe ser la primera cuenta en el mensaje (índice 0) y un firmante.
  • Debe ser una cuenta propiedad del System Program o una cuenta nonce (validada por validate_fee_payer).
  • Debe contener suficientes lamports para cubrir rent_exempt_minimum + total_fee; de lo contrario, la transacción falla con InsufficientFundsForFee.

Mensaje

El campo message es una estructura Message que contiene el payload de la transacción:

Message
pub struct Message {
/// The message header, identifying signed and read-only `account_keys`.
pub header: MessageHeader,
/// All the account keys used by this transaction.
#[serde(with = "short_vec")]
pub account_keys: Vec<Pubkey>,
/// The id of a recent ledger entry.
pub recent_blockhash: Hash,
/// Programs that will be executed in sequence and committed in
/// one atomic transaction if all succeed.
#[serde(with = "short_vec")]
pub instructions: Vec<CompiledInstruction>,
}

Encabezado

El campo header es una estructura MessageHeader con tres campos u8 que dividen el array account_keys en grupos de permisos:

  • num_required_signatures: Número total de firmas requeridas por la transacción.
  • num_readonly_signed_accounts: Número de cuentas firmantes de solo lectura.
  • num_readonly_unsigned_accounts: Número de cuentas no firmantes de solo lectura.
MessageHeader
pub struct MessageHeader {
/// The number of signatures required for this message to be considered
/// valid. The signers of those signatures must match the first
/// `num_required_signatures` of [`Message::account_keys`].
pub num_required_signatures: u8,
/// The last `num_readonly_signed_accounts` of the signed keys are read-only
/// accounts.
pub num_readonly_signed_accounts: u8,
/// The last `num_readonly_unsigned_accounts` of the unsigned keys are
/// read-only accounts.
pub num_readonly_unsigned_accounts: u8,
}

Prefijos de mensajes heredados y versionados

En un mensaje de transacción heredado, el primer byte del mensaje es num_required_signatures, seguido de los otros dos bytes de MessageHeader. En un mensaje de transacción versionado, el primer byte es un prefijo de versión; los tres bytes de MessageHeader comienzan inmediatamente después de ese prefijo. Consulta transacciones versionadas para ver el diseño completo del mensaje v0.

Diagrama que muestra las tres partes del encabezado del mensajeDiagrama que muestra las tres partes del encabezado del mensaje

Direcciones de cuentas

El campo account_keys es un arreglo codificado de forma compacta de claves públicas. Cada entrada identifica una cuenta utilizada por al menos una de las instrucciones de la transacción. El arreglo debe incluir todas las cuentas y debe seguir este orden estricto:

  1. Firmante + Escritura
  2. Firmante + Solo lectura
  3. No firmante + Escritura
  4. No firmante + Solo lectura

Este orden estricto permite combinar el arreglo account_keys con los tres conteos del header del mensaje para determinar los permisos de cada cuenta sin almacenar indicadores de metadatos por cuenta. Los conteos del encabezado dividen el arreglo en los cuatro grupos de permisos mencionados anteriormente.

Diagrama que muestra el orden del arreglo de direcciones de cuentasDiagrama que muestra el orden del arreglo de direcciones de cuentas

Blockhash reciente

El campo recent_blockhash es un hash de 32 bytes que cumple dos propósitos:

  1. Marca de tiempo: demuestra que la transacción fue creada recientemente.
  2. Deduplicación: evita que la misma transacción sea procesada dos veces.

Un blockhash expira después de 150 slot. Si el blockhash ya no es válido cuando llega la transacción, esta es rechazada con BlockhashNotFound, a menos que sea una transacción con nonce duradero válida.

El método RPC getLatestBlockhash te permite obtener el blockhash actual y la última altura de bloque en la que el blockhash será válido.

Instrucciones

El campo instructions es un array de codificación compacta de structs CompiledInstruction. Cada CompiledInstruction referencia cuentas por índice en el array account_keys en lugar de por clave pública completa. Contiene:

  1. program_id_index: Índice en account_keys que identifica el programa a invocar.
  2. accounts: Array de índices en account_keys que especifica las cuentas a pasar al programa.
  3. data: Array de bytes que contiene el discriminador de instrucción y los argumentos serializados.
CompiledInstruction
pub struct CompiledInstruction {
/// Index into the transaction keys array indicating the program account that executes this instruction.
pub program_id_index: u8,
/// Ordered indices into the transaction keys array indicating which accounts to pass to the program.
#[serde(with = "short_vec")]
pub accounts: Vec<u8>,
/// The program input data.
#[serde(with = "short_vec")]
pub data: Vec<u8>,
}

Array compacto de instruccionesArray compacto de instrucciones

Formato binario de transacciones

Las transacciones se serializan utilizando un esquema de codificación compacta. Todos los arrays de longitud variable (firmas, claves de cuenta, instrucciones) tienen como prefijo una codificación de longitud compact-u16. Este formato utiliza 1 byte para valores del 0 al 127 y de 2 a 3 bytes para valores mayores.

Estructura de transacción legacy (en la red):

CampoTamañoDescripción
num_signatures1-3 bytes (compact-u16)Número de firmas
signaturesnum_signatures x 64 bytesFirmas Ed25519
num_required_signatures1 byteCampo 1 de MessageHeader
num_readonly_signed1 byteCampo 2 de MessageHeader
num_readonly_unsigned1 byteCampo 3 de MessageHeader
num_account_keys1-3 bytes (compact-u16)Número de claves de cuenta estáticas
account_keysnum_account_keys x 32 bytesClaves públicas
recent_blockhash32 bytesBlockhash
num_instructions1-3 bytes (compact-u16)Número de instrucciones
instructionsvariableArray de instrucciones compiladas

Cada instrucción compilada se serializa de la siguiente manera:

CampoTamañoDescripción
program_id_index1 byteÍndice en las claves de cuenta
num_accounts1-3 bytes (compact-u16)Número de índices de cuenta
account_indicesnum_accounts x 1 byteÍndices de claves de cuenta
data_len1-3 bytes (compact-u16)Longitud de instruction data
datadata_len bytesinstruction data opaca

Cálculo del tamaño

Dado PACKET_DATA_SIZE = 1,232 bytes, el espacio disponible puede calcularse:

Total = 1232 bytes
- compact-u16(num_sigs) # 1 byte
- num_sigs * 64 # signature bytes
- 3 # message header
- compact-u16(num_keys) # 1 byte
- num_keys * 32 # account key bytes
- 32 # recent blockhash
- compact-u16(num_ixs) # 1 byte
- sum(instruction_sizes) # per-instruction overhead + data

Ejemplo: transacción de transferencia de SOL

El diagrama a continuación muestra cómo las transacciones e instrucciones trabajan juntas para permitir a los usuarios interactuar con la red. En este ejemplo, se transfiere SOL de una cuenta a otra.

Los metadatos de la cuenta del remitente indican que debe firmar la transacción. Esto permite al System Program deducir lamports. Tanto la cuenta del remitente como la del destinatario deben ser modificables para que su saldo en lamports pueda cambiar. Para ejecutar esta instrucción, la billetera del remitente envía la transacción con su firma y el mensaje que contiene la instrucción de transferencia de SOL.

Diagrama de transferencia de SOLDiagrama de transferencia de SOL

Una vez enviada la transacción, el System Program procesa la instrucción de transferencia y actualiza el saldo en lamports de ambas cuentas.

Diagrama del proceso de transferencia de SOLDiagrama del proceso de transferencia de SOL

Verifica el destinatario antes de enviar SOL

Una transferencia del System Program añade lamports a cualquier cuenta. No existe una verificación a nivel de protocolo de que el destinatario pueda retirar el SOL. Los lamports solo pueden ser retirados por el programa propietario de la cuenta, por lo que enviar SOL a un token mint, a un programa o a una PDA que no controlas supone un riesgo de pérdida permanente de fondos — solo una autoridad especificada por el programa propietario puede devolverlos. El SOL enviado a un token account solo puede ser recuperado por el propietario de esa cuenta, nunca por el remitente.

Las transferencias de tokens SPL tienen cierta protección propia: el Token Program rechaza una transferencia cuyas cuentas no coincidan con el mint esperado. Las transferencias nativas de SOL no cuentan con esta protección, por lo que el remitente debe verificar el destinatario antes de firmar. Consulta Verificar dirección para conocer la lógica completa de clasificación.

El ejemplo a continuación muestra el código relevante para los diagramas anteriores. Consulta la función transfer del System Program.

import { createClient, generateKeyPairSigner, lamports } from "@solana/kit";
import { solanaRpc, rpcAirdrop } from "@solana/kit-plugin-rpc";
import { generatedPayer, airdropPayer } from "@solana/kit-plugin-signer";
import { systemProgram } from "@solana-program/system";
const client = await createClient()
.use(generatedPayer())
.use(
solanaRpc({
rpcUrl: "http://localhost:8899",
rpcSubscriptionsUrl: "ws://localhost:8900"
})
)
.use(rpcAirdrop())
.use(airdropPayer(lamports(1_000_000_000n)))
.use(systemProgram());
const sender = client.payer;
const recipient = await generateKeyPairSigner();
const LAMPORTS_PER_SOL = 1_000_000_000n;
const transferAmount = lamports(LAMPORTS_PER_SOL / 100n); // 0.01 SOL
// Check balance before transfer
const { value: preBalance1 } = await client.rpc
.getBalance(sender.address)
.send();
const { value: preBalance2 } = await client.rpc
.getBalance(recipient.address)
.send();
// Create a transfer instruction for transferring SOL from sender to recipient
const transferInstruction = client.system.instructions.transferSol({
source: sender,
destination: recipient.address,
amount: transferAmount // 0.01 SOL in lamports
});
const transactionSignature = await client.sendTransaction([
transferInstruction
]);
// Check balance after transfer
const { value: postBalance1 } = await client.rpc
.getBalance(sender.address)
.send();
const { value: postBalance2 } = await client.rpc
.getBalance(recipient.address)
.send();
console.log(
"Sender prebalance:",
Number(preBalance1) / Number(LAMPORTS_PER_SOL)
);
console.log(
"Recipient prebalance:",
Number(preBalance2) / Number(LAMPORTS_PER_SOL)
);
console.log(
"Sender postbalance:",
Number(postBalance1) / Number(LAMPORTS_PER_SOL)
);
console.log(
"Recipient postbalance:",
Number(postBalance2) / Number(LAMPORTS_PER_SOL)
);
console.log("Transaction Signature:", transactionSignature.context.signature);
Console
Click to execute the code.

El siguiente ejemplo muestra la estructura de una transacción que contiene una sola instrucción de transferencia de SOL.

import {
createClient,
generateKeyPairSigner,
lamports,
createTransactionMessage,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstructions,
pipe,
signTransactionMessageWithSigners,
getCompiledTransactionMessageDecoder
} from "@solana/kit";
import { solanaRpc, rpcAirdrop } from "@solana/kit-plugin-rpc";
import { generatedPayer, airdropPayer } from "@solana/kit-plugin-signer";
import { systemProgram } from "@solana-program/system";
const client = await createClient()
.use(generatedPayer())
.use(
solanaRpc({
rpcUrl: "http://localhost:8899",
rpcSubscriptionsUrl: "ws://localhost:8900"
})
)
.use(rpcAirdrop())
.use(airdropPayer(lamports(1_000_000_000n)))
.use(systemProgram());
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send();
const sender = client.payer;
const recipient = await generateKeyPairSigner();
// Define the amount to transfer
const LAMPORTS_PER_SOL = 1_000_000_000n;
const transferAmount = lamports(LAMPORTS_PER_SOL / 100n); // 0.01 SOL
// Create a transfer instruction for transferring SOL from sender to recipient
const transferInstruction = client.system.instructions.transferSol({
source: sender,
destination: recipient.address,
amount: transferAmount
});
// Create transaction message
const transactionMessage = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(sender, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions([transferInstruction], tx)
);
const signedTransaction =
await signTransactionMessageWithSigners(transactionMessage);
// Decode the messageBytes
const compiledTransactionMessage =
getCompiledTransactionMessageDecoder().decode(signedTransaction.messageBytes);
console.log(JSON.stringify(compiledTransactionMessage, null, 2));
Console
Click to execute the code.

El código a continuación muestra la salida de los fragmentos de código anteriores. El formato varía según el SDK, pero ten en cuenta que cada instrucción contiene la misma información requerida.

{
"version": 0,
"header": {
"numSignerAccounts": 1,
"numReadonlySignerAccounts": 0,
"numReadonlyNonSignerAccounts": 1
},
"staticAccounts": [
"HoCy8p5xxDDYTYWEbQZasEjVNM5rxvidx8AfyqA4ywBa",
"5T388jBjovy7d8mQ3emHxMDTbUF8b7nWvAnSiP3EAdFL",
"11111111111111111111111111111111"
],
"lifetimeToken": "EGCWPUEXhqHJWYBfDirq3mHZb4qDpATmYqBZMBy9TBC1",
"instructions": [
{
"programAddressIndex": 2,
"accountIndices": [0, 1],
"data": {
"0": 2,
"1": 0,
"2": 0,
"3": 0,
"4": 128,
"5": 150,
"6": 152,
"7": 0,
"8": 0,
"9": 0,
"10": 0,
"11": 0
}
}
]
}

Verifica el destinatario antes de transferir

Dado que una transferencia de SOL se puede realizar a cualquier cuenta, verifica el destinatario antes de firmar. Obtén la cuenta y envía solo a una billetera de System Program (o a una dirección en curva sin fondos); rechaza mints, token accounts, programas y PDAs que no controles.

Kit
import {
type Address,
createSolanaRpc,
fetchJsonParsedAccount,
isOffCurveAddress
} from "@solana/kit";
const rpc = createSolanaRpc("https://api.mainnet-beta.solana.com");
const SYSTEM_PROGRAM = "11111111111111111111111111111111" as Address;
/**
* Throws if `recipient` cannot safely receive native SOL.
*
* Only System Program wallets (or unfunded on-curve addresses) are safe. Any
* other account locks the lamports because no authority can debit them.
*/
async function assertSafeSolRecipient(recipient: Address): Promise<void> {
const account = await fetchJsonParsedAccount(rpc, recipient);
if (!account.exists) {
// Off-curve = a PDA with no account; reject conservatively.
if (isOffCurveAddress(recipient)) {
throw new Error(
"Recipient is a PDA with no account; SOL would be locked"
);
}
// On-curve = an unfunded wallet, safe to fund.
return;
}
if (account.programAddress !== SYSTEM_PROGRAM) {
throw new Error(
`Recipient is owned by ${account.programAddress}, not a wallet; SOL would be locked`
);
}
}
// A wallet: safe.
await assertSafeSolRecipient(
"H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS" as Address
);
// The USDC mint: rejected before any SOL leaves the sender.
await assertSafeSolRecipient(
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" as Address
);
Console
Click to execute the code.

Este fragmento verifica destinatarios nativos de SOL. Para la clasificación completa que también gestiona envíos de tokens SPL (token accounts, ATAs, Token-2022), consulta Verificar Dirección.

Obtención de los detalles de la transacción

Tras el envío, recupera los detalles de la transacción usando la firma de la transacción y el método RPC getTransaction.

También puedes encontrar la transacción usando el Explorador de Solana.

Transaction Data
{
"blockTime": 1745196488,
"meta": {
"computeUnitsConsumed": 150,
"err": null,
"fee": 5000,
"innerInstructions": [],
"loadedAddresses": {
"readonly": [],
"writable": []
},
"logMessages": [
"Program 11111111111111111111111111111111 invoke [1]",
"Program 11111111111111111111111111111111 success"
],
"postBalances": [989995000, 10000000, 1],
"postTokenBalances": [],
"preBalances": [1000000000, 0, 1],
"preTokenBalances": [],
"rewards": [],
"status": {
"Ok": null
}
},
"slot": 13049,
"transaction": {
"message": {
"header": {
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 1,
"numRequiredSignatures": 1
},
"accountKeys": [
"8PLdpLxkuv9Nt8w3XcGXvNa663LXDjSrSNon4EK7QSjQ",
"7GLg7bqgLBv1HVWXKgWAm6YoPf1LoWnyWGABbgk487Ma",
"11111111111111111111111111111111"
],
"recentBlockhash": "7ZCxc2SDhzV2bYgEQqdxTpweYJkpwshVSDtXuY7uPtjf",
"instructions": [
{
"accounts": [0, 1],
"data": "3Bxs4NN8M2Yn4TLb",
"programIdIndex": 2,
"stackHeight": null
}
],
"indexToProgramIds": {}
},
"signatures": [
"3jUKrQp1UGq5ih6FTDUUt2kkqUfoG2o4kY5T1DoVHK2tXXDLdxJSXzuJGY4JPoRivgbi45U2bc7LZfMa6C4R3szX"
]
},
"version": "legacy"
}

La respuesta sin procesar identifica las cuentas por índice y almacena las instrucciones internas (CPI) como blobs codificados. Para resolverlos en direcciones y recorrer el árbol completo de instrucciones, consulta Introspección de transacciones.

Is this page helpful?

Tabla de Contenidos

Editar Página