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 signaturesmessage: informations de transaction, incluant la liste des instructions à traiter
pub struct Transaction {pub signatures: Vec<Signature>,pub message: Message,}
Diagramme 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 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.
Chaque signature est produite par une clé privée. L'emplacement de cette clé — un keypair local, un HSM ou KMS cloud, ou un service de portefeuille géré — est une décision de conception en production. Voir Signature en production.
La première signature du tableau appartient au payeur des frais, le compte qui paye 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.
Conditions requises pour le payeur des frais :
- Doit être le premier compte dans le message (index 0) et un signataire.
- Doit être un compte appartenant au System Program 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 avecInsufficientFundsForFee.
Message
Le champ message est une
Message
struct contenant la charge utile de la transaction :
header: L'en-tête du messageaccount_keys: Un tableau d' adresses de compte requises par les instructions de la transactionrecent_blockhash: Un blockhash qui sert d'horodatage pour la transactioninstructions: Un tableau d' instructions
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
MessageHeader
struct comportant trois champs u8 qui répartissent 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.
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,}
Préfixes de messages legacy et versionnés
Dans un message de transaction legacy, le premier octet du message est
num_required_signatures, suivi des deux autres octets MessageHeader. Dans
un message de transaction versionnée, le premier octet est un préfixe de
version à la place ; les trois octets MessageHeader commencent immédiatement
après ce préfixe. Voir les transactions
versionnées pour la mise en
page complète du message v0.
Diagramme montrant les trois parties de l'en-tête de 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 chaque compte et doit respecter cet ordre strict :
- Signataire + Modifiable
- Signataire + Lecture seule
- Non-signataire + Modifiable
- Non-signataire + Lecture seule
Cet ordre strict permet de combiner le tableau account_keys avec les trois
compteurs de l'header du message pour déterminer les permissions
de chaque compte sans stocker 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 des adresses de comptes
Blockhash récent
Le champ recent_blockhash est un hash de 32 octets qui remplit deux fonctions
:
- Horodatage : prouve que la transaction a été créée récemment.
- Déduplication : empêche la même transaction d'être traitée deux fois.
Un blockhash expire après 150 slot. 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 avec 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 :
program_id_index: Index dansaccount_keysidentifiant le programme à invoquer.accounts: Tableau d'indices dansaccount_keysspécifiant les comptes à transmettre au programme.data: Tableau d'octets contenant le discriminateur d'instruction et les arguments sérialisés.
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'instructions
Format binaire des transactions
Les transactions sont sérialisées à l'aide d'un schéma d'encodage compact. Tous les tableaux de longueur variable (signatures, clés de compte, instructions) sont préfixés d'un encodage de longueur compact-u16. Ce format utilise 1 octet pour les valeurs de 0 à 127 et 2 à 3 octets pour les valeurs plus grandes.
Format de transaction héritée (sur le réseau) :
| Champ | Taille | Description |
|---|---|---|
num_signatures | 1-3 octets (compact-u16) | Nombre de signatures |
signatures | num_signatures x 64 octets | Signatures Ed25519 |
num_required_signatures | 1 octet | Champ 1 de MessageHeader |
num_readonly_signed | 1 octet | Champ 2 de MessageHeader |
num_readonly_unsigned | 1 octet | Champ 3 de MessageHeader |
num_account_keys | 1-3 octets (compact-u16) | Nombre de clés de compte statiques |
account_keys | num_account_keys x 32 octets | Clés publiques |
recent_blockhash | 32 octets | Blockhash |
num_instructions | 1-3 octets (compact-u16) | Nombre d'instructions |
instructions | variable | Tableau d'instructions compilées |
Chaque instruction compilée est sérialisée comme suit :
| Champ | Taille | Description |
|---|---|---|
program_id_index | 1 octet | Index dans les clés de compte |
num_accounts | 1-3 octets (compact-u16) | Nombre d'indices de compte |
account_indices | num_accounts x 1 octet | Indices des clés de compte |
data_len | 1-3 octets (compact-u16) | Longueur de l'instruction data |
data | data_len octets | instruction data opaque |
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 lamport. Les comptes de l'expéditeur et du destinataire doivent tous deux être modifiables afin que leur solde en lamport 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 SOL
Une fois la transaction envoyée, le System Program traite l'instruction de transfert et met à jour le solde en lamport des deux comptes.
Diagramme du processus de transfert de SOL
Vérifiez le destinataire avant d'envoyer des SOL
Un transfert via le System Program ajoute des lamport à n'importe quel compte. Aucune vérification au niveau du protocole ne garantit que le destinataire pourra retransférer les SOL. Les lamport ne peuvent être retirés que par le programme propriétaire du compte ; ainsi, envoyer des SOL à un token mint, à un programme ou à un PDA que vous ne contrôlez pas risque de provoquer une perte permanente 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 token SPL sont partiellement auto-protégés : le Token Program rejette un transfert dont les comptes ne correspondent pas au mint attendu. Les transferts natifs de SOL n'ont pas une telle protection, l'expéditeur doit donc vérifier le destinataire avant de signer. Consultez Vérifier l'adresse pour la logique de classification complète.
L'exemple ci-dessous montre 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 transferconst { 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 recipientconst 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 transferconst { 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);
L'exemple suivant montre la structure d'une transaction contenant une seule instruction de transfert 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 transferconst 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 recipientconst transferInstruction = client.system.instructions.transferSol({source: sender,destination: recipient.address,amount: transferAmount});// Create transaction messageconst transactionMessage = pipe(createTransactionMessage({ version: 0 }),(tx) => setTransactionMessageFeePayerSigner(sender, tx),(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),(tx) => appendTransactionMessageInstructions([transferInstruction], tx));const signedTransaction =await signTransactionMessageWithSigners(transactionMessage);// Decode the messageBytesconst compiledTransactionMessage =getCompiledTransactionMessageDecoder().decode(signedTransaction.messageBytes);console.log(JSON.stringify(compiledTransactionMessage, null, 2));
Le code ci-dessous affiche le résultat 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érifier le destinataire avant le transfert
Étant donné qu'un transfert SOL réussit vers n'importe quel compte, vérifiez le destinataire avant de signer. Récupérez le compte et envoyez uniquement vers un portefeuille System Program (ou une adresse sur la courbe non approvisionnée) ; rejetez les mints, token accounts, programmes et PDAs que vous ne contrôlez pas.
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);
Cet extrait vérifie les destinataires SOL natifs. Pour la classification complète qui gère également les envois de tokens SPL (token accounts, ATAs, Token-2022), consultez Vérifier l'adresse.
Récupération des détails de la transaction
Après la soumission, récupérez les détails de la transaction en utilisant la signature de la transaction et la méthode RPC getTransaction.
Vous pouvez également retrouver la transaction via Solana Explorer.
{"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 réponse brute identifie les comptes par index et stocke les instructions internes (CPI) sous forme de blobs encodés. Pour les résoudre en adresses et parcourir l'arbre complet des instructions, consultez Transaction Introspection.
Is this page helpful?