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.

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

fig, ax = plt.subplots(figsize=(12, 8))
ax.set_xlim(0, 12)
ax.set_ylim(0, 10)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Anatomie d'une transaction Solana", fontsize=14, fontweight="bold", pad=15)

# Couleurs
c_tx = "#4C72B0"
c_sig = "#C44E52"
c_msg = "#55A868"
c_header = "#8172B2"
c_accounts = "#CCB974"
c_blockhash = "#64B5CD"
c_instr = "#DD8452"

# Transaction (boite externe)
tx_box = mpatches.FancyBboxPatch(
    (0.5, 0.5), 11, 9, boxstyle="round,pad=0.15",
    fc="white", ec=c_tx, lw=3, zorder=1
)
ax.add_patch(tx_box)
ax.text(6, 9.7, "Transaction", ha="center", va="top",
        fontsize=13, fontweight="bold", color=c_tx, zorder=5)

# Signatures
sig_box = mpatches.FancyBboxPatch(
    (1, 7.2), 4.5, 2, boxstyle="round,pad=0.1",
    fc=c_sig, ec="white", lw=2, alpha=0.15, zorder=2
)
ax.add_patch(sig_box)
sig_border = mpatches.FancyBboxPatch(
    (1, 7.2), 4.5, 2, boxstyle="round,pad=0.1",
    fc="none", ec=c_sig, lw=2, zorder=3
)
ax.add_patch(sig_border)
ax.text(3.25, 8.9, "Signatures", ha="center", va="center",
        fontsize=11, fontweight="bold", color=c_sig, zorder=5)
ax.text(3.25, 8.2, "Ed25519 sig #1\nEd25519 sig #2\n...", ha="center", va="center",
        fontsize=9, color="#333", family="monospace", zorder=5)

# Message (boite droite, plus grande)
msg_box = mpatches.FancyBboxPatch(
    (6, 0.8), 5.2, 8.4, boxstyle="round,pad=0.1",
    fc=c_msg, ec="white", lw=2, alpha=0.08, zorder=2
)
ax.add_patch(msg_box)
msg_border = mpatches.FancyBboxPatch(
    (6, 0.8), 5.2, 8.4, boxstyle="round,pad=0.1",
    fc="none", ec=c_msg, lw=2, zorder=3
)
ax.add_patch(msg_border)
ax.text(8.6, 9.0, "Message", ha="center", va="center",
        fontsize=11, fontweight="bold", color=c_msg, zorder=5)

# Header (dans Message)
h_box = mpatches.FancyBboxPatch(
    (6.3, 7.5), 4.6, 1.1, boxstyle="round,pad=0.08",
    fc=c_header, ec="white", lw=1.5, alpha=0.2, zorder=3
)
ax.add_patch(h_box)
h_border = mpatches.FancyBboxPatch(
    (6.3, 7.5), 4.6, 1.1, boxstyle="round,pad=0.08",
    fc="none", ec=c_header, lw=1.5, zorder=4
)
ax.add_patch(h_border)
ax.text(8.6, 8.3, "Header", ha="center", va="center",
        fontsize=10, fontweight="bold", color=c_header, zorder=5)
ax.text(8.6, 7.85, "num_required_signatures | num_readonly_signed | num_readonly_unsigned",
        ha="center", va="center", fontsize=6.5, color="#333", family="monospace", zorder=5)

# Account addresses
a_box = mpatches.FancyBboxPatch(
    (6.3, 5.8), 4.6, 1.4, boxstyle="round,pad=0.08",
    fc=c_accounts, ec="white", lw=1.5, alpha=0.2, zorder=3
)
ax.add_patch(a_box)
a_border = mpatches.FancyBboxPatch(
    (6.3, 5.8), 4.6, 1.4, boxstyle="round,pad=0.08",
    fc="none", ec=c_accounts, lw=1.5, zorder=4
)
ax.add_patch(a_border)
ax.text(8.6, 6.9, "Adresses de comptes", ha="center", va="center",
        fontsize=10, fontweight="bold", color="#8B7D3C", zorder=5)
ax.text(8.6, 6.3, "Pubkey #1, Pubkey #2, ..., Pubkey #n",
        ha="center", va="center", fontsize=8, color="#333", family="monospace", zorder=5)

# Recent blockhash
b_box = mpatches.FancyBboxPatch(
    (6.3, 4.6), 4.6, 0.9, boxstyle="round,pad=0.08",
    fc=c_blockhash, ec="white", lw=1.5, alpha=0.2, zorder=3
)
ax.add_patch(b_box)
b_border = mpatches.FancyBboxPatch(
    (6.3, 4.6), 4.6, 0.9, boxstyle="round,pad=0.08",
    fc="none", ec=c_blockhash, lw=1.5, zorder=4
)
ax.add_patch(b_border)
ax.text(8.6, 5.1, "Récent Blockhash", ha="center", va="center",
        fontsize=10, fontweight="bold", color="#3A8B9C", zorder=5)

# Instructions
i_box = mpatches.FancyBboxPatch(
    (6.3, 1.1), 4.6, 3.2, boxstyle="round,pad=0.08",
    fc=c_instr, ec="white", lw=1.5, alpha=0.15, zorder=3
)
ax.add_patch(i_box)
i_border = mpatches.FancyBboxPatch(
    (6.3, 1.1), 4.6, 3.2, boxstyle="round,pad=0.08",
    fc="none", ec=c_instr, lw=1.5, zorder=4
)
ax.add_patch(i_border)
ax.text(8.6, 4.0, "Instructions", ha="center", va="center",
        fontsize=10, fontweight="bold", color=c_instr, zorder=5)
ax.text(8.6, 3.15, "Instruction #1\n  program_id_index\n  account_indices[]\n  data[]",
        ha="center", va="center", fontsize=8, color="#333", family="monospace", zorder=5)
ax.text(8.6, 1.8, "Instruction #2\n  ...",
        ha="center", va="center", fontsize=8, color="#333", family="monospace", zorder=5)

# Lien entre Signatures et Message (accolade visuelle)
ax.annotate("", xy=(5.8, 5), xytext=(5.8, 8.2),
            arrowprops=dict(arrowstyle="-", color="#999", lw=1, ls="--"))

# Note sur la structure compacte
ax.text(3.25, 6.8, "Les indices dans les\ninstructions réfèrent\na la liste d'adresses\ndu message.",
        ha="center", va="center", fontsize=8, color="#666",
        style="italic", zorder=5,
        bbox=dict(boxstyle="round,pad=0.3", fc="#f9f9f9", ec="#ddd", lw=1))

plt.show()
_images/1fc49144630439f6c68de2945cc8a02a7f8d19631ac4b2b19cd8c9c517814040.png

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 de AccountMeta, 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 : si true, la transaction doit contenir une signature valide pour cette clé publique.

  • is_writable : si true, 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 :

  1. 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.

  2. Si le programme A détient le privilège d’écriture sur un compte, le programme B hérite de ce privilège.

  3. 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 :

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

fig, ax = plt.subplots(figsize=(11, 5))
ax.set_xlim(-0.5, 10.5)
ax.set_ylim(-0.5, 4.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Chaine de Cross-Program Invocation (CPI)", fontsize=14, fontweight="bold", pad=15)

c_prog = ["#4C72B0", "#55A868", "#C44E52"]
labels = ["DeFi Program\n(votre programme)", "Token Program\n(SPL)", "System Program"]
positions = [(1.5, 2), (5, 2), (8.5, 2)]

for i, (pos, label, color) in enumerate(zip(positions, labels, c_prog)):
    box = mpatches.FancyBboxPatch(
        (pos[0] - 1.3, pos[1] - 0.8), 2.6, 1.6,
        boxstyle="round,pad=0.12", fc=color, ec="white", lw=2, alpha=0.85, zorder=3
    )
    ax.add_patch(box)
    ax.text(pos[0], pos[1], label, ha="center", va="center",
            fontsize=9, fontweight="bold", color="white", zorder=5)

# Fleches CPI
arrow_style = dict(arrowstyle="-|>", color="#333", lw=2.5)

ax.annotate("", xy=(3.7, 2.3), xytext=(2.8, 2.3),
            arrowprops=arrow_style, zorder=4)
ax.text(3.25, 2.85, "invoke()", ha="center", va="center",
        fontsize=9, fontweight="bold", color="#DD8452",
        bbox=dict(boxstyle="round,pad=0.2", fc="#FFF3E0", ec="#DD8452", lw=1), zorder=5)

ax.annotate("", xy=(7.2, 2.3), xytext=(6.3, 2.3),
            arrowprops=arrow_style, zorder=4)
ax.text(6.75, 2.85, "invoke()", ha="center", va="center",
        fontsize=9, fontweight="bold", color="#DD8452",
        bbox=dict(boxstyle="round,pad=0.2", fc="#FFF3E0", ec="#DD8452", lw=1), zorder=5)

# Labels de profondeur
ax.text(1.5, 0.8, "Profondeur 0", ha="center", va="center",
        fontsize=8, color="#666", style="italic", zorder=5)
ax.text(5, 0.8, "Profondeur 1", ha="center", va="center",
        fontsize=8, color="#666", style="italic", zorder=5)
ax.text(8.5, 0.8, "Profondeur 2", ha="center", va="center",
        fontsize=8, color="#666", style="italic", zorder=5)

# Annotation privileges
ax.text(5, 3.8, "Les privileges (signer, writable) sont herites du caller vers le callee",
        ha="center", va="center", fontsize=9, color="#555", style="italic", zorder=5)

plt.show()
_images/2ac101ca2b4475932779ee219e90327c430fd16c70e2bae8dc8706489df9fc32.png

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 :

Hide code cell source

import hashlib
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

# Constantes de la courbe Ed25519
# L'ordre du sous-groupe principal de Ed25519
ED25519_ORDER = 2**252 + 27742317777372353535851937790883648493

# Paramètres de la courbe Ed25519
ED25519_P = 2**255 - 19  # Le module premier du corps fini

def is_on_curve_simplified(point_bytes: bytes) -> bool:
    """
    Vérification simplifiée : on tente de decoder le point Ed25519.
    Un point valide sur Ed25519 doit satisfaire l'equation de la courbe.
    En pratique, environ 50% des hashs de 32 octets tombent hors courbe.
    """
    if len(point_bytes) != 32:
        return False
    # Decoder la coordonnee y (little-endian, bit de signe dans le MSB)
    y = int.from_bytes(point_bytes, "little") & ((1 << 255) - 1)
    if y >= ED25519_P:
        return False
    # Equation de Ed25519 : -x^2 + y^2 = 1 + d*x^2*y^2
    # On verifie si x^2 a une racine dans Fp
    d = -121665 * pow(121666, ED25519_P - 2, ED25519_P) % ED25519_P
    y2 = y * y % ED25519_P
    x2_num = (y2 - 1) % ED25519_P
    x2_den = (d * y2 + 1) % ED25519_P
    x2 = x2_num * pow(x2_den, ED25519_P - 2, ED25519_P) % ED25519_P
    # Critere d'Euler : x2 est un carre dans Fp ssi x2^((p-1)/2) == 1 mod p
    if x2 == 0:
        return True
    euler = pow(x2, (ED25519_P - 1) // 2, ED25519_P)
    return euler == 1

def find_pda(seeds: list[bytes], program_id: bytes) -> tuple[bytes, int]:
    """
    Derive un PDA en cherchant le bump canonique (de 255 vers 0).
    Retourne (pda_bytes, bump).
    """
    for bump in range(255, -1, -1):
        hasher = hashlib.sha256()
        for seed in seeds:
            hasher.update(seed)
        hasher.update(program_id)
        hasher.update(bytes([bump]))
        hasher.update(b"ProgramDerivedAddress")
        candidate = hasher.digest()
        if not is_on_curve_simplified(candidate):
            return candidate, bump
    raise Exception("Aucun PDA valide trouvé (extrêmement improbable)")

# Simulation avec des valeurs d'exemple
user_pubkey = bytes.fromhex(
    "d5b982576e2a5c4e87e51e7b6e4a1f8c3a2b9d0e1f5c6a7b8d9e0f1a2b3c4d5e"
)
program_id = bytes.fromhex(
    "a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"
)

seeds = [b"counter", user_pubkey]
pda, bump = find_pda(seeds, program_id)

print("=== Dérivation d'un PDA ===")
print(f"Seeds       : [b'counter', user_pubkey]")
print(f"Program ID  : {program_id.hex()[:16]}...")
print(f"Bump trouvé : {bump}")
print(f"PDA (hex)   : {pda.hex()[:16]}...")
print(f"PDA (base58): (encodage base58 omis pour simplicité)")
print(f"\nLe bump {bump} est le premier (en partant de 255) pour lequel")
print(f"sha256(seeds || program_id || [bump] || 'ProgramDerivedAddress')")
print(f"produit un hash qui n'est PAS sur la courbe Ed25519.")
=== 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 :

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

fig, ax = plt.subplots(figsize=(12, 7))
ax.set_xlim(-0.5, 12)
ax.set_ylim(-0.5, 7.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Dérivation d'un Program Derived Address (PDA)", fontsize=14, fontweight="bold", pad=15)

c_seed = "#4C72B0"
c_hash = "#55A868"
c_check = "#DD8452"
c_pda = "#C44E52"
c_bump = "#8172B2"

def draw_box(ax, x, y, w, h, label, sublabel, color, fontsize=9):
    box = mpatches.FancyBboxPatch(
        (x - w/2, y - h/2), w, h, boxstyle="round,pad=0.08",
        fc=color, ec="white", lw=2, alpha=0.85, zorder=3
    )
    ax.add_patch(box)
    ax.text(x, y + 0.12, label, ha="center", va="center",
            fontsize=fontsize, fontweight="bold", color="white", zorder=5)
    if sublabel:
        ax.text(x, y - 0.25, sublabel, ha="center", va="center",
                fontsize=7, color="white", alpha=0.9, zorder=5)

arrow_kw = dict(arrowstyle="-|>", color="#555", lw=2)

# Seeds
draw_box(ax, 1.5, 6.2, 2.5, 1, "Seeds", "b'counter' + pubkey", c_seed)
draw_box(ax, 1.5, 4.5, 2.5, 0.8, "Program ID", "a1b2c3d4...", c_seed)
draw_box(ax, 1.5, 3.0, 2.5, 0.8, "Bump", "255, 254, 253, ...", c_bump)

# Fleches vers SHA-256
ax.annotate("", xy=(3.8, 5.5), xytext=(2.75, 6.0),
            arrowprops=arrow_kw, zorder=4)
ax.annotate("", xy=(3.8, 5.0), xytext=(2.75, 4.5),
            arrowprops=arrow_kw, zorder=4)
ax.annotate("", xy=(3.8, 4.5), xytext=(2.75, 3.0),
            arrowprops=arrow_kw, zorder=4)

# SHA-256
draw_box(ax, 5, 4.8, 2, 1.6, "SHA-256", None, c_hash, fontsize=11)

# Flèche vers verification
ax.annotate("", xy=(7.0, 4.8), xytext=(6.0, 4.8),
            arrowprops=arrow_kw, zorder=4)

# Vérification on-curve
draw_box(ax, 8.5, 4.8, 2.4, 1.4, "Sur la courbe\nEd25519 ?", None, c_check)

# Branche OUI (boucle vers bump)
ax.annotate("", xy=(5, 2.0), xytext=(8.5, 3.8),
            arrowprops=dict(arrowstyle="-|>", color="#C44E52", lw=2,
                            connectionstyle="arc3,rad=-0.3"), zorder=4)
ax.text(8.0, 2.6, "OUI", ha="center", va="center",
        fontsize=9, fontweight="bold", color="#C44E52", zorder=5)
ax.text(5.8, 2.0, "bump -= 1\nréessayer", ha="center", va="center",
        fontsize=8, color="#C44E52", style="italic", zorder=5)

# Flèche retour vers SHA-256
ax.annotate("", xy=(5, 4.0), xytext=(5, 2.5),
            arrowprops=dict(arrowstyle="-|>", color="#C44E52", lw=1.5, ls="--"),
            zorder=4)

# Branche NON -> PDA
ax.annotate("", xy=(10.5, 4.8), xytext=(9.7, 4.8),
            arrowprops=arrow_kw, zorder=4)
ax.text(10.1, 5.5, "NON", ha="center", va="center",
        fontsize=9, fontweight="bold", color=c_hash, zorder=5)

draw_box(ax, 11.2, 4.8, 1.2, 1.2, "PDA", "adresse\nvalide", c_pda, fontsize=11)

# Note explicative
ax.text(6, 7, "Le bump canonique est le plus grand entier (partant de 255)\n"
               "pour lequel le hash résultant n'est pas sur la courbe Ed25519.",
        ha="center", va="center", fontsize=9, color="#555", style="italic",
        bbox=dict(boxstyle="round,pad=0.4", fc="#f5f5f5", ec="#ddd", lw=1), zorder=5)

plt.show()
_images/0d88084a6e3e6f727006fbf7a01522553f235c096132395768ce34f0dcff62c8.png

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 :

\[\text{priority\_fee} = \left\lceil \frac{\text{prix\_par\_CU} \times \text{CU\_demandees}}{10^6} \right\rceil \text{ lamports}\]

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 SetComputeUnitLimit

200 000 CU

Prix de priorité

défini par SetComputeUnitPrice

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.

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

# Visualisation : décomposition des frais pour différents scenarios
scenarios = [
    "Simple transfert\n(1 sig, 0 priorite)",
    "Transfert prioritaire\n(1 sig, 1000 uL/CU)",
    "Swap DeFi\n(1 sig, 5000 uL/CU)",
    "Multisig 3/5\n(3 sig, 2000 uL/CU)",
]
base_fees = [5000, 5000, 5000, 15000]  # en lamports
priority_fees = [0, 200, 1000, 400]  # en lamports (pour 200k CU)
total_fees = [b + p for b, p in zip(base_fees, priority_fees)]

fig, ax = plt.subplots(figsize=(10, 5))

x = np.arange(len(scenarios))
width = 0.35

bars_base = ax.bar(x - width/2, base_fees, width, label="Frais de base",
                   color="#4C72B0", alpha=0.85, zorder=3)
bars_prio = ax.bar(x + width/2, priority_fees, width, label="Frais de priorite",
                   color="#DD8452", alpha=0.85, zorder=3)

# Annotations du total
for i, total in enumerate(total_fees):
    ax.text(i, max(base_fees[i], priority_fees[i]) + 800,
            f"Total: {total:,} lamports",
            ha="center", va="bottom", fontsize=8, fontweight="bold", color="#333")

ax.set_xlabel("Scénario de transaction")
ax.set_ylabel("Frais (lamports)")
ax.set_title("Décomposition des frais de transaction Solana", fontsize=13, fontweight="bold")
ax.set_xticks(x)
ax.set_xticklabels(scenarios, fontsize=8)
ax.legend()

plt.show()
_images/49dc7717a722e117f3f17a9fc9b9b0be2b15630ad54cb77639bddc2b1bcacad5.png

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 : program_id + accounts (AccountMeta) + data (octets).

AccountMeta

Triplet (pubkey, is_signer, is_writable) déclarant les permissions requises sur un compte.

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.