Structure des transactions

Résumé

Une transaction contient des signatures + un message. Le message contient un en-tête, des adresses de compte, un blockhash récent et des instructions compilées. Taille sérialisée maximale : 1 232 octets.

Une Transaction comporte deux champs de niveau supérieur :

  • signatures : un tableau de signatures
  • message : informations de transaction, incluant la liste des instructions à traiter
Transaction
pub struct Transaction {
pub signatures: Vec<Signature>,
pub message: Message,
}

Diagramme montrant les deux parties d'une transactionDiagramme montrant les deux parties d'une transaction

La taille sérialisée totale d'une transaction ne doit pas dépasser PACKET_DATA_SIZE (1 232 octets). Cette limite équivaut à 1 280 octets (la MTU minimale IPv6) moins 48 octets pour les en-têtes réseau (40 octets IPv6 + 8 octets d'en-tête de fragment). Les 1 232 octets incluent à la fois le tableau signatures et la structure message.

Diagramme montrant le format de transaction et les limites de tailleDiagramme montrant le format de transaction et les limites de taille

Signatures

Le champ signatures est un tableau encodé de manière compacte de valeurs Signature. Chaque Signature est une signature Ed25519 de 64 octets du Message sérialisé, signée avec la clé privée du compte signataire. Une signature est requise pour chaque compte signataire référencé par les instructions de la transaction.

La première signature du tableau appartient au payeur de frais, le compte qui paie les frais de base et les frais de priorisation de la transaction. Cette première signature sert également d'identifiant de transaction, utilisé pour rechercher la transaction sur le réseau. L'identifiant de transaction est communément appelé la signature de transaction.

Exigences du payeur de frais :

  • Doit être le premier compte dans le message (index 0) et un signataire.
  • Doit être un compte détenu par le programme système ou un compte nonce (validé par validate_fee_payer).
  • Doit détenir suffisamment de lamports pour couvrir rent_exempt_minimum + total_fee ; sinon la transaction échoue avec InsufficientFundsForFee.

Message

Le champ message est une structure Message contenant la charge utile de la transaction :

  • header : l'en-tête du message
  • account_keys : un tableau d'adresses de comptes requises par les instructions de la transaction
  • recent_blockhash : un blockhash qui agit comme horodatage pour la transaction
  • instructions : un tableau d'instructions
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>,
}

En-tête

Le champ header est une structure MessageHeader avec trois champs u8 qui partitionnent le tableau account_keys en groupes de permissions :

  • num_required_signatures : nombre total de signatures requises par la transaction.
  • num_readonly_signed_accounts : nombre de comptes signés en lecture seule.
  • num_readonly_unsigned_accounts : nombre de comptes non signés en lecture seule.
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,
}

Diagramme montrant les trois parties de l'en-tête du messageDiagramme montrant les trois parties de l'en-tête du message

Adresses de comptes

Le champ account_keys est un tableau encodé de manière compacte de clés publiques. Chaque entrée identifie un compte utilisé par au moins une des instructions de la transaction. Le tableau doit inclure tous les comptes et doit suivre cet ordre strict :

  1. Signataire + Modifiable
  2. Signataire + Lecture seule
  3. Non-signataire + Modifiable
  4. Non-signataire + Lecture seule

Cet ordre strict permet de combiner le tableau account_keys avec les trois compteurs dans l'header du message pour déterminer les permissions de chaque compte sans stocker de drapeaux de métadonnées par compte. Les compteurs de l'en-tête partitionnent le tableau en quatre groupes de permissions listés ci-dessus.

Diagramme montrant l'ordre du tableau d'adresses de comptesDiagramme montrant l'ordre du tableau d'adresses de comptes

Blockhash récent

Le champ recent_blockhash est un hash de 32 octets qui remplit deux fonctions :

  1. Horodatage : prouve que la transaction a été créée récemment.
  2. Déduplication : empêche la même transaction d'être traitée deux fois.

Un blockhash expire après 150 slots. Si le blockhash n'est plus valide lorsque la transaction arrive, elle est rejetée avec BlockhashNotFound, sauf s'il s'agit d'une transaction nonce durable valide.

La méthode RPC getLatestBlockhash vous permet d'obtenir le blockhash actuel et la dernière hauteur de bloc à laquelle le blockhash sera valide.

Instructions

Le champ instructions est un tableau encodé de manière compacte de structures CompiledInstruction. Chaque CompiledInstruction référence les comptes par index dans le tableau account_keys plutôt que par clé publique complète. Il contient :

  1. program_id_index : index dans account_keys identifiant le programme à invoquer.
  2. accounts : tableau d'indices dans account_keys spécifiant les comptes à transmettre au programme.
  3. data : tableau d'octets contenant le discriminateur d'instruction et les arguments sérialisés.
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>,
}

Tableau compact d'instructionsTableau compact d'instructions

Format binaire de transaction

Les transactions sont sérialisées en utilisant un schéma d'encodage compact. Tous les tableaux de longueur variable (signatures, clés de compte, instructions) sont préfixés par un encodage de longueur compact-u16. Ce format utilise 1 octet pour les valeurs 0-127 et 2-3 octets pour les valeurs plus grandes.

Structure de transaction legacy (sur le réseau) :

ChampTailleDescription
num_signatures1-3 octets (compact-u16)Nombre de signatures
signaturesnum_signatures x 64 octetsSignatures Ed25519
num_required_signatures1 octetChamp 1 de MessageHeader
num_readonly_signed1 octetChamp 2 de MessageHeader
num_readonly_unsigned1 octetChamp 3 de MessageHeader
num_account_keys1-3 octets (compact-u16)Nombre de clés de compte statiques
account_keysnum_account_keys x 32 octetsClés publiques
recent_blockhash32 octetsBlockhash
num_instructions1-3 octets (compact-u16)Nombre d'instructions
instructionsvariableTableau d'instructions compilées

Chaque instruction compilée est sérialisée comme suit :

ChampTailleDescription
program_id_index1 octetIndex dans les clés de compte
num_accounts1-3 octets (compact-u16)Nombre d'indices de compte
account_indicesnum_accounts x 1 octetIndices de clés de compte
data_len1-3 octets (compact-u16)Longueur des données d'instruction
datadata_len octetsDonnées d'instruction opaques

Calcul de la taille

Étant donné PACKET_DATA_SIZE = 1 232 octets, l'espace disponible peut être calculé :

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

Exemple : transaction de transfert de SOL

Le diagramme ci-dessous montre comment les transactions et les instructions fonctionnent ensemble pour permettre aux utilisateurs d'interagir avec le réseau. Dans cet exemple, des SOL sont transférés d'un compte à un autre.

Les métadonnées du compte expéditeur indiquent qu'il doit signer la transaction. Cela permet au System Program de déduire des lamports. Les comptes expéditeur et destinataire doivent être modifiables, afin que leur solde en lamports puisse changer. Pour exécuter cette instruction, le portefeuille de l'expéditeur envoie la transaction contenant sa signature et le message contenant l'instruction de transfert de SOL.

Diagramme de transfert de SOLDiagramme de transfert de SOL

Après l'envoi de la transaction, le System Program traite l'instruction de transfert et met à jour le solde en lamports des deux comptes.

Diagramme du processus de transfert de SOLDiagramme du processus de transfert de SOL

Vérifiez le destinataire avant d'envoyer des SOL

Un transfert par System Program ajoute des lamports à n'importe quel compte. Il n'existe aucune vérification au niveau du protocole garantissant que le destinataire peut renvoyer les SOL. Les lamports ne peuvent être retirés que par le programme propriétaire du compte. Ainsi, envoyer des SOL à un token mint, à un programme ou à une PDA que vous ne contrôlez pas risque une perte définitive de fonds — seule une autorité désignée par le programme propriétaire peut les restituer. Les SOL envoyés à un token account ne sont récupérables que par le propriétaire de ce compte, jamais par l'expéditeur.

Les transferts de tokens SPL sont partiellement auto-protégés : le Token Program rejette tout transfert dont les comptes ne correspondent pas au mint attendu. Les transferts natifs de SOL ne bénéficient d'aucune telle protection, c'est pourquoi l'expéditeur doit vérifier le destinataire avant de signer. Consultez Vérifier l'adresse pour la logique de classification complète.

L'exemple ci-dessous présente le code correspondant aux diagrammes précédents. Consultez la fonction transfer du 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.

L'exemple suivant illustre la structure d'une transaction contenant une seule instruction de transfert 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.

Le code ci-dessous affiche la sortie des extraits de code précédents. Le format varie selon les SDK, mais notez que chaque instruction contient les mêmes informations requises.

{
"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
}
}
]
}

Vérifiez le destinataire avant d'effectuer un transfert

Étant donné qu'un transfert de SOL aboutit vers n'importe quel compte, vérifiez le destinataire avant de signer. Récupérez le compte et n'envoyez des SOL qu'à un portefeuille System Program (ou à une adresse sur la courbe non financée) ; rejetez les mints, les token accounts, les programmes et les PDA que vous ne contrôlez pas.

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.

Cet extrait vérifie les destinataires SOL natifs. Pour la classification complète qui gère également les envois de token SPL (token accounts, ATA, Token-2022), consultez Vérifier une adresse.

Récupération des détails d'une transaction

Après soumission, récupérez les détails de la transaction à l'aide de la signature de transaction et de la méthode RPC getTransaction.

Vous pouvez également retrouver la transaction via Solana Explorer.

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"
}

Is this page helpful?

Table des matières

Modifier la page
© 2026 Fondation Solana. Tous droits réservés.