Transactions et instructions#
Le chapitre précédent a présenté le modèle de comptes de Solana : chaque état sur la blockchain est stocké dans un compte, et les programmes sont eux-mêmes des comptes exécutables. Mais comment un utilisateur modifie-t-il cet état ? Comment invoque-t-il un programme ? La réponse tient en un mot : les transactions. Une transaction est le véhicule par lequel toute mutation d’état transite sur Solana. Sans transaction, aucun lamport ne circule, aucun compte n’est créé, aucun programme n’est appelé.
Ce chapitre détaille l’anatomie d’une transaction Solana, depuis sa structure binaire jusqu’aux mécanismes de frais qui régissent sa priorité d’exécution. Nous étudierons les instructions — l’unité atomique d’exécution —, le mécanisme de Cross-Program Invocation (CPI) qui permet la composabilité entre programmes, et les Program Derived Addresses (PDA) qui offrent un schéma d’adressage déterministe sans clé privée. Ces concepts sont les fondations sur lesquelles repose tout développement de programmes Solana.
Apres cette lecture, le lecteur comprendra comment les programmes sont invoqués, comment les comptes sont adresses dans une instruction, et comment les frais de transaction sont calculés. Ce sont les prérequis indispensables avant d’aborder le développement avec Anchor dans les chapitres suivants.
Anatomie d’une transaction Solana#
Définition 55 (Transaction)
Une transaction Solana est un message signe contenant une ou plusieurs instructions. Elle se compose de deux parties :
Signatures : un tableau de signatures Ed25519, une par signataire requis.
Message : le corps de la transaction, contenant le header, la liste des adresses de comptes, un recent blockhash, et la liste des instructions a exécuter.
Une transaction est la seule manière de modifier l’état de la blockchain Solana.
Définition 56 (Message header)
Le message header d’une transaction encode trois entiers sur un octet chacun :
num_required_signatures: le nombre total de signatures requises pour que la transaction soit valide.num_readonly_signed_accounts: parmi les comptes signataires, combien sont en lecture seule.num_readonly_unsigned_accounts: parmi les comptes non signataires, combien sont en lecture seule.
Ces trois valeurs permettent au runtime de déterminer, pour chaque compte dans la liste d’adresses, s’il est signataire et/ou en écriture, sans stocker cette information individuellement pour chaque adresse.
La structure complète d’une transaction peut etre visualisée comme un ensemble de couches imbriquées. Le diagramme suivant represente cette hiérarchie.
Remarque 36
Le récent blockhash joue le rôle d’un nonce : il garantit l’unicité de la transaction et empêche les attaques par rejeu (replay attacks). Une transaction n’est valide que si son blockhash correspond a l’un des 150 derniers slots (environ 60 secondes avec un temps de slot de ~400 ms). Passé ce delai, la transaction est considérée comme expirée et sera rejetée par les validateurs. Ce mécanisme force les clients à construire des transactions « fraiches » et évite qu’une transaction signée puisse être resoumise indéfiniment.
Instructions#
Définition 57 (Instruction)
Une instruction est l’unité atomique d’exécution sur Solana. Chaque instruction specifie :
program_id: la clé publique du programme à invoquer.accounts: une liste deAccountMeta, chacun indiquant un compte requis par l’instruction ainsi que ses permissions (signataire et/ou ecriture).data: un tableau d’octets (Vec<u8>) contenant les paramètres de l’instruction, dont le format est défini par le programme cible.
Une transaction contient une ou plusieurs instructions, executées séquentiellement dans l’ordre ou elles apparaissent.
Définition 58 (AccountMeta)
Un AccountMeta est un triplet (pubkey, is_signer, is_writable) qui informe le runtime Solana des comptes dont une instruction a besoin et des permissions requises :
pubkey: l’adresse du compte.is_signer: sitrue, la transaction doit contenir une signature valide pour cette clé publique.is_writable: sitrue, le programme est autorisé à modifier les données ou le solde de ce compte.
Cette déclaration explicite des permissions permet au runtime de paralléliser les transactions : deux transactions qui ne partagent aucun compte en écriture peuvent être executées simultanément.
La structure Rust de ces types dans le SDK Solana est la suivante :
/// Une instruction à envoyer à un programme.
pub struct Instruction {
/// Clé publique du programme qui va traiter cette instruction.
pub program_id: Pubkey,
/// Metadonnées des comptes requis par l'instruction.
pub accounts: Vec<AccountMeta>,
/// Données opaques passées au programme.
pub data: Vec<u8>,
}
/// Metadonnées d'un compte dans une instruction.
pub struct AccountMeta {
/// Clé publique du compte.
pub pubkey: Pubkey,
/// Le compte doit-il signer la transaction ?
pub is_signer: bool,
/// Le programme peut-il écrire dans ce compte ?
pub is_writable: bool,
}
Remarque 37
Atomicité des transactions. Toutes les instructions d’une transaction s’exécutent dans un contexte atomique : soit elles réussissent toutes, soit elles echouent toutes. Si la troisième instruction d’une transaction échoue, les modifications apportées par les deux premières sont annulées (rollback). Ce comportement transactionnel est fondamental pour la cohérence de l’état et simplifie le raisonnement sur la correction des programmes. Il est analogue aux transactions ACID des bases de données relationnelles.
Exemple 18 (Transaction a deux instructions)
Considérons une transaction qui effectue deux opérations : (1) transférer 1 SOL du compte Alice vers le compte Bob, puis (2) créer un nouveau compte Data possédé par un programme MyProgram.
Instruction 1 : transfert SOL (programme System Program)
accounts:
- AccountMeta(pubkey=Alice, is_signer=true, is_writable=true) // source
- AccountMeta(pubkey=Bob, is_signer=false, is_writable=true) // destination
data: Transfer { lamports: 1_000_000_000 }
Instruction 2 : création de compte (programme System Program)
accounts:
- AccountMeta(pubkey=Alice, is_signer=true, is_writable=true) // payeur
- AccountMeta(pubkey=Data, is_signer=true, is_writable=true) // nouveau compte
data: CreateAccount { lamports: ..., space: ..., owner: MyProgram }
On observe que Alice apparait dans les deux instructions avec is_signer=true et is_writable=true. Dans le message de la transaction, son adresse n’est listée qu’une seule fois dans le tableau d’adresses, et les instructions y font référence par index. Le header indiquera num_required_signatures = 2 (Alice et Data doivent toutes deux signer).
Cross-Program Invocation (CPI)#
Définition 59 (Cross-Program Invocation (CPI))
Un Cross-Program Invocation (CPI) est le mécanisme par lequel un programme Solana appelle une instruction d’un autre programme pendant son exécution. Le SDK Solana expose deux fonctions pour effectuer un CPI :
invoke(instruction, account_infos): appel standard, utilisé lorsque les privilèges du programme appelant suffisent.invoke_signed(instruction, account_infos, signer_seeds): appel avec signature PDA, utilisé lorsque le programme appelant doit signer au nom d’un Program Derived Address qu’il possède.
Le programme appele (callee) hérite des privilèges de signature et d’écriture que le programme appelant (caller) detient sur les comptes transmis. Il ne peut cependant pas obtenir de privilèges que l’appelant ne possède pas lui-même.
Le CPI est le fondement de la composabilité sur Solana. Un programme de finance decentralisée peut appeler le Token Program pour transférer des tokens, qui lui-même pourrait appeler le System Program. Cette architecture en couches permet de construire des protocoles complexes à partir de briques élémentaires.
Les règles d’escalade de privilèges sont essentielles à comprendre :
Si le programme A detient le privilège de signature sur un compte, le programme B appelé via CPI hérite automatiquement de ce privilège.
Si le programme A détient le privilège d’écriture sur un compte, le programme B hérite de ce privilège.
Le programme B ne peut pas obtenir un privilège que le programme A ne possède pas.
Le diagramme suivant illustre une chaine de CPI typique :
Remarque 38
Limite de profondeur CPI. Solana impose une profondeur maximale de 4 niveaux pour les CPI. Au-delà, l’instruction échoue avec une erreur CallDepth. Cette limite existe pour prévenir les boucles infinies et garantir des temps d’exécution bornés. En pratique, la plupart des programmes n’utilisent qu’un ou deux niveaux de CPI.
Remarque 39
Sécurité des CPI. Avant d’effectuer un CPI, un programme doit impérativement vérifier la propriété (ownership) des comptes transmis. Sans cette vérification, un attaquant pourrait substituer un compte contrefait détenu par un programme malveillant. En particulier, il faut toujours vérifier que le program_id du CPI correspond bien au programme attendu (par exemple, vérifier que le Token Program est bien TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA).
Program Derived Addresses (PDA)#
Définition 60 (Program Derived Address (PDA))
Un Program Derived Address (PDA) est une adresse de 32 octets derivée de manière déterministe à partir de :
un ensemble de seeds (tableaux d’octets arbitraires choisis par le développeur),
le program_id du programme propriétaire,
un bump (un octet).
Le PDA est calculé par : sha256(seeds || program_id || [bump] || "ProgramDerivedAddress"). Le bump est décrémenté a partir de 255 jusqu’à ce que le hash produit ne soit pas un point valide sur la courbe Ed25519. Cette garantie est fondamentale : aucune clé privée ne peut exister pour un PDA, ce qui signifie que seul le programme propriétaire peut « signer » en son nom (via invoke_signed).
La simulation suivante illustre le processus de dérivation d’un PDA en Python. On reproduit le mécanisme de recherche du bump canonique :
=== Dérivation d'un PDA ===
Seeds : [b'counter', user_pubkey]
Program ID : a1b2c3d4e5f60718...
Bump trouvé : 255
PDA (hex) : 7c43ad2aab31cfdb...
PDA (base58): (encodage base58 omis pour simplicité)
Le bump 255 est le premier (en partant de 255) pour lequel
sha256(seeds || program_id || [bump] || 'ProgramDerivedAddress')
produit un hash qui n'est PAS sur la courbe Ed25519.
Exemple 19 (Dérivation d’un PDA pour un compteur utilisateur)
Un programme de compteur souhaite créer un compte unique par utilisateur, sans que l’utilisateur ait besoin de génerer une nouvelle paire de clés. On utilise les seeds [b"counter", user_pubkey] et le program_id du programme :
let (pda, bump) = Pubkey::find_program_address(
&[b"counter", user.key.as_ref()],
program_id,
);
L’adresse pda est déterministe : pour un même user et un même program_id, on obtient toujours la même adresse. Le bump retourne est le bump canonique (le plus grand bump valide). On peut ensuite créer le compte à cette adresse et y stocker les données du compteur.
Le schéma suivant visualise le processus de dérivation :
Remarque 40
Bump canonique et Anchor. La fonction Pubkey::find_program_address retourne le bump canonique, c’est-à-dire le plus grand bump (partant de 255) qui produit une adresse hors courbe. En moyenne, il faut tester un ou deux bumps avant de trouver une adresse valide, car environ 50% des hashs de 32 octets ne sont pas des points valides sur Ed25519. Le framework Anchor stocke automatiquement ce bump canonique dans le compte lui-même (via la contrainte #[account(seeds = [...], bump)]), ce qui évite de le recalculer à chaque utilisation.
Frais de transaction#
Solana utilise un système de frais à deux composantes : un frais de base fixe et un frais de priorité optionnel. Comprendre ce mécanisme est essentiel pour dimensionner correctement ses transactions.
Définition 61 (Frais de base (base fee))
Le frais de base est un montant fixe de 5 000 lamports (soit 0,000005 SOL) par signature dans la transaction. Si une transaction contient \(k\) signatures, le frais de base total est \(k \times 5\,000\) lamports. Ce frais est prelevé sur le compte du premier signataire (fee payer) et est partiellement brule (50%) et partiellement reversé au validateur (50%).
Définition 62 (Frais de priorité (priority fee))
Le frais de priorité est un montant additionnel, exprimé en micro-lamports par compute unit (CU). Il permet aux utilisateurs de prioriser leurs transactions lorsque le réseau est congestioné. Le frais de priorité total se calcule comme :
où prix_par_CU est exprimé en micro-lamports et CU_demandees est le budget de compute units alloué à la transaction.
Définition 63 (Compute units (CU))
Les compute units (CU) sont l’unité de mesure du coût computationnel sur Solana. Chaque opération élémentaire (acces mémoire, opération arithmétique, appel système, CPI) consomme un certain nombre de CU. Les limites sont :
200 000 CU par instruction (par défaut).
1 400 000 CU par transaction (maximum absolu).
Si un programme dépasse son budget de CU, l’instruction échoue avec une erreur ComputationalBudgetExceeded.
Le tableau suivant résume la décomposition des frais pour une transaction typique :
Composante |
Formule |
Exemple |
|---|---|---|
Frais de base |
\(k \times 5\,000\) lamports |
\(1 \times 5\,000 = 5\,000\) lamports |
Compute units demandées |
defini par |
200 000 CU |
Prix de priorité |
défini par |
1 000 micro-lamports/CU |
Frais de priorité |
\(\lceil 1\,000 \times 200\,000 / 10^6 \rceil\) |
200 lamports |
Total |
base + priorité |
5 200 lamports (~0,0000052 SOL) |
Remarque 41
Instructions de budget de calcul. Le programme natif ComputeBudget expose des instructions spéciales que l’on place en tête de transaction pour ajuster les paramètres de calcul :
SetComputeUnitLimit(units): définit le nombre maximal de CU pour la transaction entière. Réduire cette valeur en dessous du défaut permet d’économiser sur le frais de priorité (car il est proportionnel aux CU demandées).SetComputeUnitPrice(micro_lamports): définit le prix de priorité en micro-lamports par CU. Plus ce prix est élevé, plus la transaction a de chances d’être incluse rapidement dans un bloc.
Ces instructions ne consomment elles-mêmes qu’une quantité négligeable de CU et n’ont aucun effet sur l’état de la blockchain : elles ne servent qu’a paramétrer l’exécution.
Résumé#
Le tableau suivant synthétise les concepts clés de ce chapitre :
Concept |
Description |
|---|---|
Transaction |
Message signé contenant une ou plusieurs instructions ; seul moyen de modifier l’état de la blockchain. |
Message header |
Trois octets encodant le nombre de signatures requises et les comptes en lecture seule. |
Récent blockhash |
Nonce qui expire après ~150 slots (~60 s), empêchant le rejeu des transactions. |
Instruction |
Unité atomique d’exécution : |
AccountMeta |
Triplet |
Atomicité |
Toutes les instructions d’une transaction réussissent ou échouent ensemble. |
CPI |
Mécanisme permettant à un programme d’appeler un autre programme, avec héritage des privilèges. |
PDA |
Adresse dérivée de seeds + program_id + bump, garantie hors courbe Ed25519 (pas de clé privée). |
Frais de base |
5 000 lamports par signature. |
Frais de priorité |
Micro-lamports par compute unit, permettant de prioriser l’exécution. |
Compute units |
Mesure du coût computationnel ; 200 000 CU/instruction par défaut, 1 400 000 CU/transaction max. |