Gestion des comptes et des données#

Le modèle de comptes de Solana impose au développeur de gérer explicitement l’allocation, le dimensionnement et la libération de l’espace de stockage on-chain. Contrairement aux blockchains où l’état est implicitement attaché à un contrat, Solana exige que chaque donnée persistante soit hébergée dans un compte dont la taille, le propriétaire et le financement sont déclarés à l’avance. Anchor simplifie considérablement cette gestion grâce à un système de contraintes déclaratives, mais une compréhension fine des mécanismes sous-jacents reste indispensable pour écrire des programmes sûrs et économiques.

Ce chapitre approfondit les aspects pratiques de la gestion des données avec Anchor. Nous avons vu au chapitre 5 que chaque compte possède un propriétaire, un solde en lamports et un champ de données de taille fixe ; au chapitre 8, nous avons découvert la structure d’un programme Anchor avec ses macros #[account] et #[derive(Accounts)]. Nous allons maintenant explorer les contraintes init, close et realloc, les patterns avancés de PDA, et les architectures de stockage courantes dans l’écosystème Solana.

La maitrise de ces mécanismes est ce qui distingue un prototype fonctionnel d’un programme prêt pour la production : un compte mal dimensionné gaspille des lamports, un compte non fermé laisse des données orphelines, et un PDA mal dérivé ouvre la porte à des attaques de confusion d’identité.

Initialisation de comptes#

L’initialisation est l’acte fondateur de la vie d’un compte on-chain. Avant qu’un programme puisse y stocker des données, l’espace doit être alloué, le loyer prépayé, et le compte assigné au programme.

Définition 88 (La contrainte init)

La contrainte init d’Anchor effectue trois opérations atomiques lors de l’initialisation d’un compte :

  1. Allocation : elle réserve un espace de space octets sur la blockchain pour les données du compte.

  2. Exemption de loyer : elle transfère suffisamment de lamports depuis le payer pour rendre le compte rent-exempt (c’est-à-dire que le solde couvre le loyer à perpétuité).

  3. Attribution de propriétaire : elle assigne la propriété du compte au programme en cours d’exécution, et écrit le discriminateur de 8 octets en tête des données pour identifier le type du compte.

Ces trois opérations sont indissociables : omettre l’une d’entre elles rendrait le compte inutilisable ou vulnérable.

Remarque 56 (Le triplet init + payer + space)

La contrainte init ne fonctionne jamais seule. Elle requiert toujours deux compagnons :

  • payer : le compte signataire qui finance la création (il doit être mut car son solde diminue).

  • space : le nombre d’octets à allouer pour les données du compte.

L’oubli de payer ou de space provoque une erreur de compilation. Ce triplet obligatoire est un garde-fou délibéré d’Anchor : il rend explicite le coût de chaque allocation on-chain.

Voici un premier exemple montrant l’initialisation d’un compteur simple.

use anchor_lang::prelude::*;

declare_id!("Cptr1111111111111111111111111111111111111111");

#[program]
pub mod counter {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let counter = &mut ctx.accounts.counter;
        counter.authority = ctx.accounts.authority.key();
        counter.count = 0;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = authority,
        space = 8 + 32 + 8  // discriminateur + Pubkey + u64
    )]
    pub counter: Account<'info, Counter>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct Counter {
    pub authority: Pubkey,  // 32 octets
    pub count: u64,         // 8 octets
}

Pour un compte plus complexe contenant des chaines de caractères, le calcul de l’espace nécessite une attention particulière.

#[derive(Accounts)]
pub struct CreateProfile<'info> {
    #[account(
        init,
        payer = user,
        space = 8 + 32 + (4 + 50) + 8 + 1  // = 103 octets
    )]
    pub profile: Account<'info, UserProfile>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct UserProfile {
    pub authority: Pubkey,  // 32 octets
    pub name: String,       // 4 (préfixe de longueur) + max 50 caractères
    pub score: u64,         // 8 octets
    pub active: bool,       // 1 octet
}

Définition 89 (Calcul de l’espace d’un compte)

L’espace total requis pour un compte Anchor se calcule comme suit :

\[ \text{space} = 8 + \sum_{i} \text{taille}(f_i) \]

où les 8 premiers octets correspondent au discriminateur (hash SHA-256 du nom du type, tronqué à 8 octets) et \(f_i\) sont les champs de la structure.

Les tailles des types courants sont :

Type

Taille (octets)

bool

1

u8 / i8

1

u16 / i16

2

u32 / i32

4

u64 / i64

8

u128 / i128

16

Pubkey

32

String

4 + longueur maximale

Vec<T>

4 + (longueur maximale \(\times\) taille de T)

Option<T>

1 + taille de T

Le préfixe de 4 octets des types String et Vec est un entier u32 little-endian qui encode la longueur effective de la donnée à la désérialisation.

La visualisation suivante illustre la décomposition de l’espace pour la structure UserProfile.

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)

fields = [
    ("Discriminateur", 8, 0),
    ("authority\n(Pubkey)", 32, 8),
    ("name prefix\n(u32)", 4, 40),
    ("name data\n(max 50)", 50, 44),
    ("score\n(u64)", 8, 94),
    ("active\n(bool)", 1, 102),
]

palette = sns.color_palette("muted", n_colors=6)

fig, ax = plt.subplots(figsize=(14, 4))

for i, (label, size, offset) in enumerate(fields):
    rect = mpatches.FancyBboxPatch(
        (offset, 0.2), size, 0.6,
        boxstyle="round,pad=0.02",
        facecolor=palette[i],
        edgecolor="white",
        linewidth=2,
        alpha=0.85,
    )
    ax.add_patch(rect)
    ax.text(
        offset + size / 2, 0.5,
        f"{label}\n{size} octets",
        ha="center", va="center",
        fontsize=8 if size < 10 else 9,
        fontweight="bold",
        color="white",
    )

# Accolades de début et fin
ax.annotate("", xy=(0, 0.1), xytext=(103, 0.1),
            arrowprops=dict(arrowstyle="<->", color="grey", lw=1.5))
ax.text(51.5, 0.02, "Total : 103 octets", ha="center", va="center",
        fontsize=10, fontstyle="italic", color="grey")

ax.set_xlim(-2, 108)
ax.set_ylim(-0.1, 1.0)
ax.set_xlabel("Offset (octets)")
ax.set_title("Décomposition de l'espace mémoire : UserProfile (103 octets)")
ax.get_yaxis().set_visible(False)
for spine in ["top", "right", "left"]:
    ax.spines[spine].set_visible(False)

plt.show()
_images/54f1eab558c0c58c81f315200d4f7f032402d1083464c3071c9acf0ba0eed3d7.png

Remarque 57 (La contrainte init_if_needed)

Anchor propose également la contrainte init_if_needed, qui crée le compte uniquement s’il n’existe pas encore. Si le compte existe déjà, l’instruction se poursuit sans erreur.

Pour l’activer, il faut ajouter le feature flag dans Cargo.toml :

[dependencies]
anchor-lang = { version = "0.30", features = ["init-if-needed"] }

Attention : cette contrainte doit être utilisée avec précaution. Si l’instruction n’est pas idempotente, un attaquant pourrait appeler l’instruction une seconde fois avec un compte déjà initialisé et provoquer un comportement inattendu. Il faut systématiquement vérifier l’état du compte dans le corps de l’instruction lorsque init_if_needed est employé, ou privilégier init avec une gestion explicite de l’erreur AccountAlreadyInitialized.

Fermeture de comptes#

Un compte qui n’a plus de raison d’exister doit être fermé pour récupérer les lamports immobilisés par le loyer. Sur un réseau où l’espace de stockage a un coût réel, la fermeture est une question d’hygiène économique autant que de sécurité.

Définition 90 (La contrainte close)

La contrainte close d’Anchor effectue trois opérations lors de la fermeture d’un compte :

  1. Mise à zero du discriminateur : les 8 premiers octets sont ecrasés par des zeros, rendant le compte non désérializable par Anchor.

  2. Transfert des lamports : la totalité du solde en lamports est transférée vers un compte destinataire spécifié.

  3. Réduction de la taille : la taille du champ de données est mise à zero.

Après ces opérations, le compte est effectivement vide et ne consomme plus d’espace facturable sur le réseau.

Voici la syntaxe de la contrainte close dans Anchor.

#[derive(Accounts)]
pub struct DeleteProfile<'info> {
    #[account(
        mut,
        close = user,
        has_one = authority,
    )]
    pub profile: Account<'info, UserProfile>,
    pub authority: Signer<'info>,
    #[account(mut)]
    pub user: SystemAccount<'info>,
}

Remarque 58 (L’attaque de réanimation de compte)

Après la fermeture d’un compte, son adresse redevient disponible. Un attaquant pourrait, dans la même transaction, appeler une instruction qui recrée un compte à la même adresse avec des données malveillantes. C’est l”attaque de réanimation de compte (account revival attack).

Anchor se prémunit contre cette attaque en mettant le discriminateur à zero : toute tentative de désérialiser le compte fermé échouera. Cependant, dans un programme Solana natif (sans Anchor), cette protection n’existe pas automatiquement. Il faut alors vérifier manuellement que le compte n’a pas été recréé entre la fermeture et la fin de la transaction, par exemple en vérifiant que le propriétaire du compte est bien le programme système et que les données sont vides.

Exemple 25 (Instruction de suppression d’un profil utilisateur)

Voici un exemple complet d’instruction qui ferme le compte de profil d’un utilisateur et lui restitue les lamports :

#[program]
pub mod profiles {
    use super::*;

    pub fn delete_user(ctx: Context<DeleteUser>) -> Result<()> {
        msg!(
            "Profil de {} supprime. Lamports restitues a {}.",
            ctx.accounts.profile.name,
            ctx.accounts.user.key()
        );
        // La fermeture est gérée automatiquement par la contrainte close.
        // Aucune logique supplémentaire n'est nécessaire ici.
        Ok(())
    }
}

#[derive(Accounts)]
pub struct DeleteUser<'info> {
    #[account(
        mut,
        close = user,
        has_one = authority,
    )]
    pub profile: Account<'info, UserProfile>,
    pub authority: Signer<'info>,
    #[account(mut)]
    pub user: SystemAccount<'info>,
}

PDA avancés#

Les Program Derived Addresses (PDA) ont été introduites au chapitre 6. Rappelons qu’un PDA est une adresse dérivée de manière déterministe à partir de seeds (graines) et du program ID, avec la propriété essentielle de ne correspondre à aucune clé privée — ce qui signifie que seul le programme peut « signer » pour ce compte.

Définition 91 (Dérivation de PDA avec seeds multiples)

Un PDA est dérivé par la fonction find_program_address, qui prend en entrée un ensemble de graines et l’identifiant du programme :

\[ (\text{pda},\, \text{bump}) = \texttt{find\_program\_address}\bigl([\text{seed}_1, \text{seed}_2, \ldots, \text{seed}_n],\; \text{program\_id}\bigr) \]

L’algorithme essaie des valeurs décroissantes du bump (de 255 à 0) jusqu’à trouver une adresse qui ne se trouve pas sur la courbe elliptique Ed25519. Le premier bump valide est appelé bump canonique.

En utilisant plusieurs graines, on peut créer des espaces de noms hiérarchiques. Par exemple, [b"vault", user_pubkey] dérive un coffre unique par utilisateur, tandis que [b"post", user_pubkey, &post_id.to_le_bytes()] dérive un compte unique pour chaque publication de chaque utilisateur.

Voici un exemple de PDA avec graines multiples pour un coffre par utilisateur.

#[derive(Accounts)]
pub struct CreateVault<'info> {
    #[account(
        init,
        payer = user,
        space = 8 + 32 + 8,
        seeds = [b"vault", user.key().as_ref()],
        bump,
    )]
    pub vault: Account<'info, Vault>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct Vault {
    pub owner: Pubkey,  // 32 octets
    pub balance: u64,   // 8 octets
}

Exemple 26 (PDA comme signataire dans un CPI)

Lorsqu’un programme doit effectuer un Cross-Program Invocation (CPI) au nom d’un PDA — par exemple pour transférer des lamports depuis un coffre PDA vers un utilisateur — il utilise invoke_signed en fournissant les graines qui dérivent le PDA. Le runtime Solana vérifie que les graines produisent bien l’adresse du signataire :

use anchor_lang::system_program;

pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    let bump = ctx.bumps.vault;
    let user_key = ctx.accounts.user.key();
    let signer_seeds: &[&[&[u8]]] = &[
        &[b"vault", user_key.as_ref(), &[bump]],
    ];

    system_program::transfer(
        CpiContext::new_with_signer(
            ctx.accounts.system_program.to_account_info(),
            system_program::Transfer {
                from: ctx.accounts.vault.to_account_info(),
                to: ctx.accounts.user.to_account_info(),
            },
            signer_seeds,
        ),
        amount,
    )?;

    Ok(())
}

La visualisation suivante illustre la structure de mapping PDA : plusieurs utilisateurs, chacun pointant vers son propre compte dérivé, tous possédés par le même programme.

Hide code cell source

import matplotlib.pyplot as plt
import networkx as nx
import seaborn as sns

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

G = nx.DiGraph()

# Noeud programme
G.add_node("Programme", type="program")

# Utilisateurs et leurs PDA
users = ["Alice", "Bob", "Carol", "Dave"]
for u in users:
    G.add_node(u, type="user")
    pda_name = f"PDA\n({u})"
    G.add_node(pda_name, type="pda")
    G.add_edge(u, pda_name, label="seeds")
    G.add_edge("Programme", pda_name, label="owns")

palette = sns.color_palette("muted", n_colors=3)
color_map = {"program": palette[2], "user": palette[0], "pda": palette[1]}

pos = {}
# Programme en haut au centre
pos["Programme"] = (2.0, 2.0)
# Utilisateurs sur la rangée du bas
for i, u in enumerate(users):
    pos[u] = (i * 1.2 + 0.2, 0.0)
    pda_name = f"PDA\n({u})"
    pos[pda_name] = (i * 1.2 + 0.2, 1.0)

node_colors = [color_map[G.nodes[n]["type"]] for n in G.nodes()]

fig, ax = plt.subplots(figsize=(12, 6))

nx.draw_networkx_nodes(
    G, pos, ax=ax,
    node_color=node_colors,
    node_size=1800,
    edgecolors="white",
    linewidths=2,
)
nx.draw_networkx_labels(G, pos, ax=ax, font_size=9, font_weight="bold")

# Arêtes avec styles différents
user_to_pda = [(u, v) for u, v, d in G.edges(data=True) if d["label"] == "seeds"]
prog_to_pda = [(u, v) for u, v, d in G.edges(data=True) if d["label"] == "owns"]

nx.draw_networkx_edges(
    G, pos, edgelist=user_to_pda, ax=ax,
    edge_color=palette[0], width=2, style="solid",
    arrows=True, arrowsize=18, connectionstyle="arc3,rad=0.05",
)
nx.draw_networkx_edges(
    G, pos, edgelist=prog_to_pda, ax=ax,
    edge_color=palette[2], width=2, style="dashed",
    arrows=True, arrowsize=18, connectionstyle="arc3,rad=0.1",
)

# Légende
import matplotlib.patches as mpatches
legend_items = [
    mpatches.Patch(color=palette[0], label="Utilisateur (seed)"),
    mpatches.Patch(color=palette[1], label="Compte PDA"),
    mpatches.Patch(color=palette[2], label="Programme (owner)"),
]
ax.legend(handles=legend_items, loc="upper right", fontsize=9, framealpha=0.9)

ax.set_title("Structure de mapping PDA : un compte dérivé par utilisateur")
ax.axis("off")

plt.show()
_images/79c4cc68ccaa165c327d6fd21ac274ad882c250fa6106a4db5cd66a1652eb028.png

Remarque 59 (Le bump canonique et le cache d’Anchor)

Lorsqu’on utilise la contrainte seeds avec bump dans Anchor, le framework calcule automatiquement le bump canonique et le rend accessible via ctx.bumps.<nom_du_compte>. Ce bump est le plus grand u8 (en partant de 255) tel que les seeds produisent une adresse hors courbe.

Deux points importants :

  1. Toujours utiliser le bump canonique. Si un programme stockait un bump non canonique, un attaquant pourrait appeler l’instruction avec un bump différent, dérivant une adresse différente et contournant les vérifications de propriété.

  2. Ne pas recalculer inutilement. L’appel à find_program_address coûte des unités de calcul (compute units). Anchor optimise ce coût en utilisant create_program_address lorsque le bump est déjà connu (par exemple, après l’initialisation).

Réallocation#

Les comptes Solana ont une taille fixe à la création. Mais certains cas d’usage nécessitent de modifier cette taille après coup — par exemple, lorsqu’un utilisateur met à jour son nom et que la nouvelle chaine est plus longue que l’ancienne.

Définition 92 (La contrainte realloc)

La contrainte realloc d’Anchor permet de modifier la taille du champ de données d’un compte existant. Elle ajuste automatiquement le solde en lamports du compte :

  • Si la taille augmente, des lamports supplémentaires sont transférés depuis le realloc::payer pour maintenir l’exemption de loyer.

  • Si la taille diminue, les lamports excédentaires sont restitués au realloc::payer.

La contrainte realloc::zero contrôle si les octets nouvellement alloués sont initialisés à zero (true) ou laissés non initialisés (false). Il est recommandé de toujours utiliser realloc::zero = true pour éviter de lire des données résiduelles.

Voici un exemple de réallocation pour un profil utilisateur dont le nom peut changer de longueur.

#[derive(Accounts)]
pub struct UpdateProfile<'info> {
    #[account(
        mut,
        realloc = 8 + 32 + (4 + new_name.len()) + 8 + 1,
        realloc::payer = user,
        realloc::zero = true,
        has_one = authority,
    )]
    pub profile: Account<'info, UserProfile>,
    pub authority: Signer<'info>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

Exemple 27 (Instruction de mise à jour du nom)

L’instruction ci-dessous met à jour le nom du profil utilisateur. La contrainte realloc ajuste dynamiquement la taille du compte en fonction de la longueur du nouveau nom :

pub fn update_name(ctx: Context<UpdateProfile>, new_name: String) -> Result<()> {
    require!(new_name.len() <= 50, ProfileError::NameTooLong);
    let profile = &mut ctx.accounts.profile;
    profile.name = new_name;
    Ok(())
}

#[error_code]
pub enum ProfileError {
    #[msg("Le nom ne doit pas dépasser 50 caractères.")]
    NameTooLong,
}

Remarque 60 (Réallocation ou taille fixe ?)

La réallocation n’est pas toujours la bonne approche. Voici les critères de décision :

  • Taille fixe : préférée lorsque la taille maximale des données est connue à l’avance et raisonnable. Un compteur (48 octets) ou un profil avec un nom de 50 caractères maximum (103 octets) ne justifient pas la complexité de realloc. On alloue simplement la taille maximale dès le départ.

  • Réallocation : justifiée lorsque la taille des données est hautement variable et que l’allocation de la taille maximale serait gaspilleuse. Par exemple, un compte qui stocke un vecteur d’éléments dont la cardinalité varie de 0 à 10 000 bénéficiera de la réallocation.

En pratique, la plupart des programmes Anchor utilisent des comptes de taille fixe. La réallocation ajoute de la complexité (il faut passer le system_program et un payer mutable) et consomme des unités de calcul supplémentaires.

Patterns courants de stockage#

L’absence de base de données relationnelle on-chain oblige le développeur à concevoir des schémas de stockage adaptés au modèle de comptes de Solana. Quatre patterns reviennent fréquemment dans les programmes de production.

Définition 93 (Pattern 1 : Singleton global)

Un singleton global est un compte unique pour l’ensemble du programme, dérivé d’un PDA à graine fixe. Il sert typiquement à stocker la configuration du programme (autorité d’administration, paramètres globaux, drapeaux d’activation).

#[account]
pub struct ProgramConfig {
    pub admin: Pubkey,          // 32
    pub fee_basis_points: u16,  // 2
    pub paused: bool,           // 1
}

// Seeds : [b"config"]
// Un seul compte possible pour ce programme.
#[derive(Accounts)]
pub struct InitConfig<'info> {
    #[account(
        init,
        payer = admin,
        space = 8 + 32 + 2 + 1,
        seeds = [b"config"],
        bump,
    )]
    pub config: Account<'info, ProgramConfig>,
    #[account(mut)]
    pub admin: Signer<'info>,
    pub system_program: Program<'info, System>,
}

Ce pattern garantit l’unicité : les graines étant fixes, find_program_address retourne toujours la même adresse.

Définition 94 (Pattern 2 : Compte par utilisateur)

Un compte par utilisateur est un PDA dérivé avec la clé publique de l’utilisateur comme graine. Chaque utilisateur possède exactement un compte de ce type, et l’adresse est calculable à l’avance côté client.

#[account]
pub struct UserProfile {
    pub authority: Pubkey,  // 32
    pub name: String,       // 4 + max 50
    pub score: u64,         // 8
    pub active: bool,       // 1
}

// Seeds : [b"profile", user.key().as_ref()]
#[derive(Accounts)]
pub struct CreateProfile<'info> {
    #[account(
        init,
        payer = user,
        space = 8 + 32 + (4 + 50) + 8 + 1,
        seeds = [b"profile", user.key().as_ref()],
        bump,
    )]
    pub profile: Account<'info, UserProfile>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

Ce pattern est le plus répandu dans les programmes Solana. Il permet de retrouver le compte d’un utilisateur sans index ni requête on-chain : il suffit de connaitre sa clé publique.

Définition 95 (Pattern 3 : Mapping avec index)

Un mapping avec index permet de stocker plusieurs comptes du même type pour un même utilisateur, en ajoutant un compteur aux graines. C’est l’équivalent d’une table (user, index) -> data.

#[account]
pub struct Post {
    pub author: Pubkey,   // 32
    pub content: String,  // 4 + max 280
    pub timestamp: i64,   // 8
}

// Seeds : [b"post", user.key().as_ref(), &post_index.to_le_bytes()]
#[derive(Accounts)]
#[instruction(post_index: u64)]
pub struct CreatePost<'info> {
    #[account(
        init,
        payer = author,
        space = 8 + 32 + (4 + 280) + 8,
        seeds = [b"post", author.key().as_ref(), &post_index.to_le_bytes()],
        bump,
    )]
    pub post: Account<'info, Post>,
    #[account(mut)]
    pub author: Signer<'info>,
    pub system_program: Program<'info, System>,
}

Le client maintient un compteur (souvent stocké dans le compte per-user) et l’incrémente à chaque nouvelle publication. Pour lire le post n\(^{\circ}3\) de l’utilisateur X, il suffit de dériver le PDA avec les graines [b"post", X, &3u64.to_le_bytes()].

Définition 96 (Pattern 4 : Liste chainée entre comptes)

Une liste chainée entre comptes utilise un champ next_account de type Option<Pubkey> dans chaque compte pour pointer vers le suivant. Ce pattern est utile lorsque le nombre d’éléments n’est pas borné à l’avance et que les graines ne peuvent pas être indexées par un entier séquentiel.

#[account]
pub struct ListNode {
    pub owner: Pubkey,              // 32
    pub data: u64,                  // 8
    pub next: Option<Pubkey>,       // 1 + 32
    pub prev: Option<Pubkey>,       // 1 + 32 (doublement chainée)
}

// Seeds : [b"node", owner.key().as_ref(), &node_id.to_le_bytes()]

Ce pattern est rarement utilisé en pratique car il impose un parcours séquentiel (une transaction par noeud pour la lecture), ce qui est coûteux en unités de calcul. On lui préfère généralement le pattern de mapping avec index, ou le stockage off-chain (bases de données indexées alimentées par des listeners on-chain).

Remarque 61 (Limites de taille des comptes)

Un compte Solana peut contenir au maximum 10 Mo de données (10 485 760 octets). En théorie, cela permet de stocker des structures massives. En pratique, plusieurs facteurs incitent à maintenir les comptes aussi petits que possible :

  1. Coût du loyer : l’exemption de loyer coûte environ 6,96 SOL pour 1 Mo. Un compte de 10 Mo couterait près de 70 SOL (plusieurs milliers d’euros).

  2. Coût de sérialisation : chaque lecture ou écriture du compte désérialise/sérialise l’integralité des données, ce qui consomme des compute units.

  3. Limites de transaction : une seule transaction a un budget de 200 000 compute units (extensible à 1 400 000). Un compte trop volumineux peut épuiser ce budget rien qu’à la désérialisation.

La règle empirique est de viser des comptes de quelques centaines d’octets à quelques kilo-octets, et de distribuer les données volumineuses entre plusieurs comptes.

Exemple 28 (Choisir le bon pattern de stockage)

Voici un guide de decision rapide :

Besoin

Pattern

Graines

Configuration globale du programme

Singleton

[b"config"]

Un profil par utilisateur

Per-user

[b"profile", user]

Plusieurs posts par utilisateur

Mapping

[b"post", user, index]

Structure dynamique non bornée

Liste chainée

[b"node", owner, id]

En cas de doute, commencer par le pattern le plus simple (singleton ou per-user) et migrer vers un pattern plus complexe si les besoins évoluent. Les programmes les plus robustes de l’ecosystème Solana utilisent presque exclusivement les patterns 1 à 3.

Résumé#

Ce chapitre a couvert les mécanismes essentiels de gestion des données on-chain avec Anchor.

Concept

Description

init

Alloue l’espace, prépaye le loyer, assigne le propriétaire et écrit le discriminateur

Triplet init + payer + space

Les trois contraintes obligatoires pour toute création de compte

Calcul de l’espace

8 octets (discriminateur) + somme des tailles des champs

init_if_needed

Création conditionnelle — à utiliser avec précaution

close

Ferme un compte : zero le discriminateur, transfère les lamports, vide les données

Attaque de réanimation

Risque de recréation d’un compte fermé dans la même transaction

PDA multi-seeds

Dérivation avec plusieurs graines pour créer des espaces de noms hiérarchiques

PDA signataire (CPI)

invoke_signed avec les seeds pour signer au nom d’un PDA

Bump canonique

Plus grand bump valide, caché par Anchor dans ctx.bumps

realloc

Modification dynamique de la taille d’un compte existant

Singleton global

PDA à graine fixe pour la configuration du programme

Compte per-user

PDA avec la clé utilisateur comme graine

Mapping avec index

PDA avec clé utilisateur et compteur comme graines

Liste chainée

Option<Pubkey> pour relier des comptes (rarement utilisé)

Taille maximale

10 Mo par compte, mais viser quelques centaines d’octets en pratique

Le chapitre suivant abordera les tests et le débogage des programmes Solana : comment écrire des tests d’intégration avec Anchor, simuler des transactions localement, et diagnostiquer les erreurs courantes.