NEU: Helius übernimmt Light Protocol
Einführung in Anchor
Blog/Entwicklung

Einführung in Anchor: Ein Leitfaden für Einsteiger zur Entwicklung von Solana-Programmen

Developer Experience Engineer0xIchigo auf X0xIchigo auf LinkedIn0xIchigo auf GitHub
46 Min. Lesezeit

Ein großes Dankeschön an Noah, Mike, Jonas, Ryan, Prames und bl0ckpain für die Durchsicht dieses Artikels.

Worum geht es in diesem Artikel?

Rust wird oft als Lingua franca der Entwicklung von Solana-Programmen bezeichnet. Treffender wäre diese Beschreibung jedoch für Anchor, da die meisten Rust-Entwicklungen dieses Framework verwenden. Anchor ist ein meinungsstarkes und leistungsfähiges Framework, mit dem du schnell sichere Solana-Programme entwickeln kannst. Es optimiert den Entwicklungsprozess, indem es Boilerplate für Bereiche wie die (De-)Serialisierung von Accounts und Anweisungsdaten reduziert, wichtige Sicherheitsprüfungen durchführt, automatisch Client-Bibliotheken generiert und eine umfangreiche Testumgebung bereitstellt.

Dieser Artikel zeigt, wie du Anchor-Programme entwickelst. Er behandelt die Installation von Anchor, die Verwendung von Solana Playground sowie das Erstellen, Bauen und Bereitstellen eines einfachen Hello, World!-Programms. Anschließend sehen wir uns genauer an, wie Anchor den Entwicklungsprozess optimiert. Dazu untersuchen wir IDLs, Makros, die Struktur von Anchor-Programmen, Account-Typen und Constraints sowie die Fehlerbehandlung. Außerdem behandeln wir kurz Cross-Program Invocations und Program Derived Addresses. Dieser Artikel vermittelt dir alles, was du heute für den Einstieg in Anchor wissen musst.

Erforderliche Vorkenntnisse

Dieser Artikel setzt Kenntnisse des Programmiermodells von Solana voraus. Wenn du noch nie auf Solana entwickelt hast, empfehle ich meinen vorherigen Blogbeitrag Das Solana-Programmiermodell: Eine Einführung in die Entwicklung auf Solana. 

Keine Sorge, wenn Rust neu für dich ist – du brauchst keine fortgeschrittenen Kenntnisse, um mit der Anchor-Entwicklung zu beginnen. Laut der Anchor-Dokumentation müssen Entwickler nur mit den Grundlagen von Rust vertraut sein, also mit den ersten neun Kapiteln des Rust Book. Ich empfehle The Rust Survival Guide, um einen guten Überblick über die wichtigsten Konzepte der Rust-Programmierung zu erhalten. Ebenso wichtig ist es, die Regeln von Rust für Speicher, Ownership und Borrowing zu verstehen.

Um den Einstieg zu erleichtern, empfehle ich Entwicklern ohne Erfahrung mit Low-Level-Programmiersprachen, sich mit verschiedenen Konzepten der Systemprogrammierung vertraut zu machen, die Rust-Ressourcen häufig überspringen. Sieh dir beispielsweise Themen wie Variablengrößen, Zeiger und Speicherlecks an. Für praktische Rust-Beispiele empfehle ich außerdem Rust By Example und mein Repository zu verschiedenen in Rust implementierten Datenstrukturen und Algorithmen.

Du möchtest stattdessen TypeScript verwenden? Erfahre, wie du Solana-Programme in TypeScript schreibst. Das Framework von Poseidon transpiliert TypeScript in Rust und generiert gültige Anchor-Programme.

Dieser Artikel konzentriert sich ausschließlich auf die Anchor-Entwicklung. Wir behandeln weder die Entwicklung von Programmen in nativem Rust noch setzen wir entsprechende Kenntnisse voraus. Auch die clientseitige Entwicklung mit Anchor ist nicht Teil dieses Artikels. Wie du Anchor-Programme über TypeScript testest und mit ihnen interagierst, behandeln wir in einem zukünftigen Artikel.

Legen wir also mit Anchor los!

Anchor installieren

Zum Einrichten von Anchor musst du nur einige einfache Schritte ausführen, um die erforderlichen Tools und Pakete zu installieren. Dieser Abschnitt behandelt die Installation dieser Tools und Pakete, also Rust, die Solana Tool Suite, Yarn und den Anchor Version Manager.

Rust installieren

Du kannst Rust über die offizielle Rust-Website oder über die Befehlszeile installieren:

Code
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Solana Tool Suite installieren

Anchor benötigt außerdem die Solana Tool Suite. Die neueste Version zum Zeitpunkt der Erstellung dieses Artikels, 1.17.16, kannst du unter macOS und Linux mit folgendem Befehl installieren:

Code
sh -c "$(curl -sSfL https://release.solana.com/v1.17.16/install)"

Unter Windows kannst du die Solana Tool Suite mit folgendem Befehl installieren:

Code
cmd /c "curl https://release.solana.com/v1.17.16/solana-install-init-x86_64-pc-windows-msvc.exe --output C:\solana-install-tmp\solana-install-init.exe --create-dirs"

Wir empfehlen jedoch dringend, stattdessen das Windows-Subsystem für Linux (WSL) zu verwenden. Damit kannst du eine Linux-Umgebung auf deinem Windows-Computer ausführen, ohne ein Dual-Boot-System oder eine separate virtuelle Maschine einrichten zu müssen. Wenn du diesen Weg wählst, folge der Installationsanleitung für Linux, also dem curl-Befehl.

Entwickler können v1.17.16 auch durch das Release-Tag der gewünschten Version ersetzen. Alternativ kannst du die Kanalnamen stable, beta oder edge verwenden. Führe nach der Installation solana –-version aus, um zu prüfen, ob die gewünschte Version von solana installiert ist.

Yarn installieren

Anchor benötigt außerdem Yarn. Du kannst es über Corepack verwenden. Corepack ist in allen offiziellen Node.js-Versionen ab Node.js 14.9/16.9 enthalten. In der aktuellen experimentellen Phase musst du es jedoch explizit aktivieren. Führe daher corepack enable aus, bevor du es verwendest. Einige Drittanbieter-Distributionen enthalten Corepack möglicherweise nicht standardmäßig. In diesem Fall musst du vor corepack enable zunächst npm install -g corepack ausführen.

Anchor mit AVM installieren

Die Anchor-Dokumentation empfiehlt, Anchor über den Anchor Version Manager (AVM) zu installieren. AVM vereinfacht die Verwaltung und Auswahl mehrerer Installationen der Binärdatei anchor-cli. Das kann nötig sein, um verifizierbare Builds zu erstellen oder für verschiedene Programme mit unterschiedlichen Versionen zu arbeiten. Du kannst AVM mit Cargo und dem Befehl cargo install --git [https://github.com/coral-xyz/anchor](https://github.com/coral-xyz/anchor) avm --locked --force installieren. Installiere und verwende anschließend die neueste Version:

Code
avm install latest
avm use latest

# Verify the installation
avm --version

Eine Liste der verfügbaren Versionen von anchor-cli erhältst du mit dem Befehl avm list. Mit avm use <version> kannst du eine bestimmte Version verwenden. Diese Version bleibt aktiv, bis du sie änderst. Mit dem Befehl avm uninstall <version> kannst du eine bestimmte Version deinstallieren.

Anchor über Binärdateien oder aus dem Quellcode installieren

Unter Linux sind Anchor-Binärdateien über das npm-Paket @coral-xyz/anchor-cli verfügbar. Derzeit wird nur x86_64 Linux unterstützt. Für andere Betriebssysteme müssen Entwickler Anchor daher aus dem Quellcode bauen. Du kannst die CLI mit Cargo direkt installieren. Beispiel:

Code
cargo install --git https://github.com/coral-xyz/anchor --tag v0.29.0 anchor-cli --locked

Ändere das Argument --tag, um eine andere Anchor-Version zu installieren. Falls die Installation mit Cargo fehlschlägt, musst du möglicherweise zusätzliche Abhängigkeiten installieren. Unter Ubuntu beispielsweise:

Code
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install -y pkg-config build-essential libudev-dev

Anschließend kannst du deine Anchor-Installation mit dem Befehl anchor --version überprüfen.

Solana Playground

Alternativ kannst du mit Solana Playground (Solpg) in Anchor einsteigen. Solana Playground ist eine browserbasierte IDE, mit der du Solana-Programme schnell entwickeln, testen und bereitstellen kannst. 

Wenn du Solana Playground zum ersten Mal verwendest, musst du eine Playground Wallet erstellen. Klicke unten links auf dem Bildschirm auf die rote Statusanzeige Not connected. Daraufhin erscheint das folgende Dialogfenster:

Speichere die Keypair-Datei der Wallet als Sicherung, bevor du auf Continue klickst. Die Playground Wallet wird im lokalen Speicher des Browsers gespeichert. Wenn du den Browser-Cache leerst, wird die Wallet entfernt. 

Klicke auf Continue, um eine Devnet-Wallet zu erstellen, die du in der IDE verwenden kannst.

Um die Wallet aufzuladen, kannst du im Playground-Terminal den Befehl solana airdrop <amount> ausführen. Ersetze dabei <amount> durch die gewünschte Menge an Devnet-SOL. Alternativ erhältst du über diesen Faucet Devnet-SOL. Ich empfehle außerdem diesen Leitfaden zum Erhalt von Devnet-SOL.

Beachte, dass möglicherweise der folgende Fehler auftritt:

Code
Error: unable to confirm transaction. This can happen in situations such as transaction expiration and insufficient fee-payer funds

Das liegt häufig daran, dass der Devnet-Faucet leer ist und/oder du zu viel SOL anforderst. Das aktuelle Limit beträgt 5 SOL. Das reicht für die Bereitstellung dieses Programms mehr als aus. Fordere daher 5 SOL vom Faucet an oder führe den Befehl solana airdrop 5 aus. Wenn du schrittweise kleinere Mengen anforderst, kann das möglicherweise zu einer Ratenbegrenzung führen.

Hello, World!

Hello, World!-Programme eignen sich hervorragend als Einführung in neue Frameworks oder Programmiersprachen. Sie sind so einfach, dass Entwickler aller Erfahrungsstufen sie verstehen können. Außerdem veranschaulichen sie die grundlegende Struktur und Syntax des neuen Programmiermodells, ohne komplexe Logik oder Funktionen einzuführen. Sie haben sich schnell als Standardprogramm für Einsteiger etabliert. Daher schreiben wir natürlich auch eines für Anchor. Dieser Abschnitt zeigt, wie du ein Hello, World!-Programm sowohl mit einer lokalen Anchor-Installation als auch mit Solana Playground baust und bereitstellst.

Neues Projekt mit einer lokalen Anchor-Installation erstellen

Wenn Anchor installiert ist, kannst du ganz einfach ein neues Projekt erstellen:

Code
anchor init hello-world
cd hello-world

Diese Befehle initialisieren ein neues Anchor-Projekt namens hello-world und wechseln in das zugehörige Verzeichnis. Navigiere dort zu hello-world/programs/hello-world/src/lib.rs. Diese Datei enthält den folgenden Startcode:

Code
use anchor_lang::prelude::*;

declare_id!("HZfVb1ohL1TejhZNkgFSKqGsyTznYtrwLV6GpA8BwV5Q");

#[program]
pub mod hello-world {
    use super::*;

    pub fn initialize(ctx: Context) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}

Anchor hat bereits mehrere Dateien und Verzeichnisse für uns vorbereitet:

  • Eine leere app für den Client des Programms
  • Einen Ordner programs, der alle unsere Solana-Programme enthält
  • Einen Ordner tests für JavaScript-Tests. Er enthält eine automatisch für den Startcode generierte Testdatei
  • Eine Anchor.toml-Konfigurationsdatei. Falls Rust neu für dich ist: Eine TOML-Datei ist ein minimales Konfigurationsdateiformat, das sich dank seiner Semantik leicht lesen lässt. Die Datei Anchor.toml legt fest, wie Anchor mit dem Programm interagiert. Zum Beispiel, in welchem Cluster das Programm bereitgestellt werden soll.

Neues Projekt mit Solana Playground erstellen

Ein neues Projekt in Solana Playground zu erstellen, ist sehr einfach. Navigiere in die obere linke Ecke und klicke auf Create a New Project:

Daraufhin erscheint das folgende Dialogfenster:

Gib deinem Programm einen Namen, wähle Anchor(Rust) aus und klicke auf Create. Dadurch wird direkt in deinem Browser ein neues Anchor-Projekt erstellt. Links im Abschnitt Program siehst du ein Verzeichnis namens src. Es enthält die Datei lib.rs mit dem folgenden Startcode:

Code
use anchor_lang::prelude::*;

// This is your program's public key and it will update
// automatically when you build the project.
declare_id!("11111111111111111111111111111111");

#[program]
mod hello_anchor {
    use super::*;
    pub fn initialize(ctx: Context, data: u64) -> Result<()> {
        ctx.accounts.new_account.data = data;
        msg!("Changed data to: {}!", data); // Message will show up in the tx logs
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    // We must specify the space in order to initialize an account.
    // First 8 bytes are default account discriminator,
    // next 8 bytes come from NewAccount.data being type u64.
    // (u64 = 64 bits unsigned integer = 8 bytes)
    #[account(init, payer = signer, space = 8 + 8)]
    pub new_account: Account<'info, NewAccount>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct NewAccount {
    data: u64
}

Beachte, dass Solana Playground nur die Dateien client.ts und anchor.test.ts generiert. Lies den Abschnitt über das lokale Erstellen eines Programms mit Anchor, um zu erfahren, welche Dateien normalerweise für ein neues Anchor-Projekt generiert werden.

Hello, World! schreiben

Unabhängig davon, ob du Anchor lokal oder über Solana Playground verwendest: Ersetze den Startcode für ein sehr einfaches Hello, World!-Programm durch Folgendes:

Code
use anchor_lang::prelude::*;

declare_id!("HZfVb1ohL1TejhZNkgFSKqGsyTznYtrwLV6GpA8BwV5Q");

#[program]
mod hello_world {
use super::*;

pub fn hello(_ctx: Context<Hello>) -> Result<()> {
	msg!("Hello, World!");
	Ok(())
}

#[derive(Accounts)]
pub struct Hello {}
}

In den folgenden Abschnitten gehen wir genauer auf die einzelnen Bestandteile ein. Zunächst ist wichtig, dass Makros und Traits den Entwicklungsprozess vereinfachen. Das Makro declare_id! legt den öffentlichen Schlüssel des Programms fest. Bei der lokalen Entwicklung generiert der Befehl anchor init zum Einrichten des Programms ein Keypair im Verzeichnis target/deploy und füllt dieses Makro aus. Solana Playground erledigt das ebenfalls automatisch.

In unserem Hauptmodul hello_world erstellen wir eine Funktion, die Hello, World! protokolliert. Außerdem gibt sie Ok(()) zurück, um die erfolgreiche Ausführung des Programms zu signalisieren. Beachte, dass wir ctx ein Unterstrichpräfix geben, um Warnungen über ungenutzte Variablen in der Konsole zu vermeiden. Hello ist ein Account-Struct, an das keine Accounts übergeben werden müssen, da das Programm lediglich eine neue Nachricht protokolliert.

Das war's! Wir müssen keine Accounts entgegennehmen oder komplexe Logik implementieren. Der obige Code erstellt ein Programm, das Hello, World! protokolliert.

Lokal bauen und bereitstellen

Dieser Abschnitt konzentriert sich auf die Bereitstellung auf Localhost. Solana Playground verwendet standardmäßig Devnet, doch eine lokale Entwicklungsumgebung bietet eine deutlich bessere Developer Experience. Sie ist nicht nur schneller, sondern umgeht auch mehrere Probleme, die beim Testen mit Devnet häufig auftreten. Dazu gehören unzureichendes SOL für Transaktionen, langsame Bereitstellungen und fehlende Testmöglichkeiten, wenn Devnet nicht verfügbar ist. Bei der lokalen Entwicklung kannst du dagegen für jeden Test einen frischen Zustand garantieren. Das ermöglicht eine kontrolliertere und effizientere Entwicklungsumgebung.

Tools konfigurieren

Zunächst stellen wir sicher, dass die Solana Tool Suite korrekt für die Entwicklung auf Localhost konfiguriert ist. Führe den Befehl solana config set --url localhost aus, damit alle Konfigurationen auf Localhost-URLs verweisen. 

Stelle außerdem sicher, dass du ein lokales Keypair hast, um lokal mit Solana zu interagieren. Um ein Programm mit der Solana CLI bereitzustellen, benötigst du eine Solana-Wallet mit einem SOL-Guthaben. Führe den Befehl solana address aus, um zu prüfen, ob bereits ein lokales Keypair vorhanden ist. Wenn ein Fehler auftritt, führe den Befehl solana-keygen new aus. Standardmäßig wird unter ~/.config/solana/id.json eine neue Dateisystem-Wallet erstellt. Du erhältst außerdem eine Wiederherstellungsphrase, mit der du die öffentlichen und privaten Schlüssel wiederherstellen kannst. Speichere dieses Keypair sicher, auch wenn du es nur lokal verwendest. Wenn du bereits eine Dateisystem-Wallet am Standardspeicherort gespeichert hast, überschreibt der Befehl solana-keygen new sie nicht, sofern du dies nicht mit dem Befehl --force ausdrücklich angibst.

Anchor.toml konfigurieren

Als Nächstes stellen wir sicher, dass unsere Datei Anchor.toml korrekt auf Localhost verweist. Sie muss den folgenden Code enthalten:

Code
...
[programs.localnet]
hello-world = "EJTW6qsbfya86xeLRQpKLM8qhn11cJXmU35QbJwE11R8"
...
[provider]
cluster = "Localnet"
wallet = '~config/solana/id.json'

Hier bezeichnet [programs.localnet] die ID des Programms im Localnet, also auf Localhost. Die Programm-ID wird immer in Bezug auf den Cluster angegeben. Dasselbe Programm kann nämlich in einem anderen Cluster unter einer anderen Adresse bereitgestellt werden. Aus Sicht der Developer Experience kann es lästig sein, neue Programm-IDs für Programme festzulegen, die in verschiedenen Clustern bereitgestellt werden. 

Die Programm-ID ist öffentlich. Das zugehörige Keypair wird jedoch im Ordner target/deploy gespeichert. Der Dateiname folgt einer bestimmten Konvention, die auf dem Programmnamen basiert. Wenn das Programm beispielsweise hello_world heißt, sucht Anchor unter target/deploy/hello-world-keypair.json nach einem Keypair. Findet Anchor diese Datei während der Bereitstellung nicht, generiert es ein neues Keypair. Dadurch entsteht eine neue Programm-ID. Daher musst du die Programm-ID nach der ersten Bereitstellung unbedingt aktualisieren. Die Datei hello-world-keypair.json dient als Eigentumsnachweis für das Programm. Wenn das Keypair öffentlich wird, können Angreifer unbefugte Änderungen am Programm vornehmen. 

Mit [provider] weisen wir Anchor an, Localhost und die angegebene Wallet zu verwenden, um Speicher und Transaktionen zu bezahlen.

Bauen, bereitstellen und ein lokales Ledger ausführen

Baue das Programm mit dem Befehl anchor build. Um ein bestimmtes Programm anhand seines Namens zu bauen, verwende den Befehl anchor build -p <program name> und ersetze <program name> durch den Programmnamen. Da wir im Localnet entwickeln, können wir die Localnet-Befehle der Anchor CLI verwenden, um den Entwicklungsprozess zu optimieren. anchor localnet --skip-build ist beispielsweise besonders nützlich, um das Bauen eines Programms im Workspace zu überspringen. So sparst du beim Ausführen von Tests Zeit, wenn der Programmcode nicht geändert wurde.

Wenn wir jetzt den Befehl anchor deploy ausführen, erhalten wir einen Fehler. Auf unserem Computer läuft noch kein Solana-Cluster, mit dem wir testen können. Wir können ein lokales Ledger starten, um einen Cluster auf unserem Computer zu simulieren. Die Solana CLI enthält bereits einen Test-Validator. Der Befehl solana-test-validator startet einen vollwertigen Single-Node-Cluster auf deinem Rechner. Das bietet mehrere Vorteile: keine RPC-Ratenbegrenzungen, keine Airdrop-Limits, direkte On-Chain-Bereitstellung von Programmen, Laden von Accounts aus Dateien und Klonen von Accounts aus einem öffentlichen Cluster. Der Test-Validator muss in einem separaten geöffneten Terminalfenster laufen. Er darf nicht beendet werden, damit der Localhost-Cluster online und für Interaktionen verfügbar bleibt. 

Jetzt können wir anchor deploy erfolgreich ausführen und das Programm in unserem lokalen Ledger bereitstellen. Alle an das lokale Ledger übertragenen Daten werden in einem Ordner namens test-ledger gespeichert, der im aktuellen Arbeitsverzeichnis erstellt wird. Füge diesen Ordner deiner Datei .gitignore hinzu, damit du ihn nicht versehentlich in dein Repository eincheckst. Wenn du das lokale Ledger beendest, also im Terminal Ctrl + C drückst, werden die an den Cluster gesendeten Daten nicht gelöscht. Lösche dazu den Ordner test-ledger oder führe solana-test-validator --reset aus.

Glückwunsch! Du hast gerade dein erstes Solana-Programm auf Localhost bereitgestellt!

Solana Explorer

Du kannst auch Solana Explorer für dein lokales Ledger konfigurieren. Öffne den Solana Explorer. Klicke in der Navigationsleiste auf die grüne Schaltfläche mit dem aktuellen Cluster:

Daraufhin öffnet sich eine Seitenleiste, in der du einen Cluster auswählen kannst. Klicke auf Custom RPC URL. Das Feld sollte automatisch mit http://localhost:8899 ausgefüllt werden. Falls nicht, gib die URL ein, damit Explorer auf Port 8899 deines Computers verweist:

Das ist aus mehreren Gründen äußerst nützlich:

  • Entwickler können Transaktionen im lokalen Ledger in Echtzeit untersuchen. Das entspricht den Funktionen eines Block-Explorers für Devnet oder Mainnet
  • Der Zustand von Accounts, Token und Programmen lässt sich leichter visualisieren, als würden sie in einem Live-Cluster ausgeführt
  • Du erhältst detaillierte Informationen zu Fehlern und fehlgeschlagenen Transaktionen
  • Die vertraute Oberfläche sorgt für eine einheitliche Developer Experience über verschiedene Cluster hinweg

Auf Devnet bereitstellen

Obwohl wir die Entwicklung auf Localhost empfehlen, kannst du Programme auch auf Devnet bereitstellen, wenn du gezielt mit diesem Cluster testen möchtest. Der Ablauf ist grundsätzlich gleich. Du musst jedoch kein lokales Ledger ausführen, da bereits ein vollwertiger Solana-Cluster für die Interaktion verfügbar ist.

Führe den Befehl solana config set --url devnet aus, um den ausgewählten Cluster auf Devnet umzustellen. Jeder im Terminal ausgeführte Befehl solana wird jetzt auf Devnet ausgeführt. Dupliziere anschließend in der Datei Anchor.toml den Abschnitt [programs.localnet] und benenne ihn in [programs.devnet] um. Ändere außerdem [provider] so, dass es auf Devnet verweist:

Code
...
[programs.localnet]
hello-world = "EJTW6qsbfya86xeLRQpKLM8qhn11cJXmU35QbJwE11R8"

[programs.devnet]
hello-world = "EJTW6qsbfya86xeLRQpKLM8qhn11cJXmU35QbJwE11R8"
...
[provider]
cluster = "Devnet"
wallet = '~config/solana/id.json'

Stelle sicher, dass du genug Devnet-SOL hast, um das Programm bereitzustellen. Verwende den Befehl solana airdrop <amount>, um einen Airdrop an das Standard-Keypair unter ~/.config/solana/id.json zu senden. Mit solana aidrop <amount> <wallet address> kannst du auch eine Wallet-Adresse angeben. Alternativ erhältst du über diesen Faucet Devnet-SOL. Ich empfehle außerdem diesen Leitfaden zum Erhalt von Devnet-SOL.

Das liegt häufig daran, dass der Devnet-Faucet leer ist und/oder du zu viel SOL auf einmal anforderst. Das aktuelle Limit beträgt 5 SOL. Das reicht für die Bereitstellung dieses Programms mehr als aus. Fordere daher 5 SOL vom Faucet an oder führe den Befehl solana airdrop 5 aus. Wenn du schrittweise kleinere Mengen anforderst, kann das möglicherweise zu einer Ratenbegrenzung führen.

Baue das Programm nun mit den folgenden Befehlen und stelle es bereit:

Code
anchor build
anchor deploy

Glückwunsch! Du hast gerade dein erstes Solana-Programm lokal auf Devnet bereitgestellt!

In Solana Playground bauen und bereitstellen

Navigiere in Solana Playground zum Symbol Tools in der linken Seitenleiste. Klicke auf Build. In der Konsole solltest du Folgendes sehen:

Code
Building...
Build successful. Completed in 2.20s..

Beachte, dass die ID im Makro declare_id! überschrieben wurde. Unter dieser neuen Adresse stellen wir das Programm bereit. Klicke jetzt auf Deploy. In deiner Konsole sollte etwas Ähnliches erscheinen:

Code

Deploying... This could take a while depending on the program size and network conditions.
Warning: 41 transactions not confirmed, retrying...
Deployment successful. Completed in 17s

Glückwunsch! Du hast gerade dein erstes Solana-Programm über Solana Playground auf Devnet bereitgestellt!

Effektive Abstraktion: IDLs und Makros

Anchor vereinfacht die Programmentwicklung durch effektive Abstraktion. Das heißt: Anchor macht komplexe Konzepte der Blockchain-Programmierung zugänglicher und einfacher nutzbar. Anchor verwendet beispielsweise eine Interface Definition Language (IDL), um die Schnittstelle des Programms zu definieren. Beim Erstellen eines Programms generiert Anchor eine JSON-Datei, die dessen IDL darstellt. Diese Struktur lässt sich clientseitig verwenden und definiert, wie du mit den Funktionen und Datenstrukturen des Programms interagierst. Anchor bietet außerdem höherstufige Abstraktionen für die Zustandsverwaltung. Entwickler können den Zustand ihres Programms mit Rust-Structs definieren. Das ist intuitiver als die Arbeit mit rohen Byte-Arrays oder manueller Serialisierung. So definieren Entwickler den Zustand wie bei jeder typischen Rust-Datenstruktur. Anchor übernimmt anschließend die zugrunde liegende Serialisierung und Speicherung in Accounts.

Eine IDL lässt sich auch sehr einfach on-chain veröffentlichen. Entwickler können eine IDL mit folgendem Befehl veröffentlichen:

Code
anchor idl init --filepath   --provider.cluster  --provider.wallet

Stelle sicher, dass das angegebene Wallet die Authority des Programms ist und genug SOL für die Transaktion enthält. Entwickler können ihre IDL nun in einem Block-Explorer wie Orb anzeigen.

Hier ist beispielsweise die Aggregator-v4-IDL von DFlow auf Orb.

Die Makros von Anchor gehören zu den wichtigsten Abstraktionen, wenn sie nicht sogar die wichtigsten sind. In Rust ist ein Makro ein Codeabschnitt, der einen anderen Codeabschnitt generiert. Dies ist eine Form der Metaprogrammierung. Deklarative Makros sind die am häufigsten verwendete Makroform in Rust. Mit dem Konstrukt macro_rules! können Entwickler etwas schreiben, das einem Ausdruck vom Typ match ähnelt. Prozedurale Makros funktionieren eher wie eine Funktion: Sie nehmen Code als Eingabe entgegen, verarbeiten ihn und erzeugen eine Ausgabe. In Anchor definiert und erzwingt beispielsweise das Makro #[account] Constraints für Solana-Accounts. Das reduziert die Komplexität und mögliche Fehler bei der Account-Verwaltung. Eine Erläuterung der Anchor-Makros erfordert zwangsläufig auch eine Betrachtung der Programmstruktur von Anchor.

Struktur eines Anchor-Programms

Die Programmstruktur von Anchor kombiniert Makros und Traits, um Boilerplate-Code zu generieren und die Programmlogik durchzusetzen. Diese Designphilosophie trägt wesentlich dazu bei, den Entwicklungsprozess zu optimieren und ein konsistentes, zuverlässiges Programmverhalten zu gewährleisten.

Deklarationen vom Typ use stehen am Anfang der Datei. Beachte, dass sie zur allgemeinen Semantik der Sprache Rust gehören und nicht Anchor-spezifisch sind. Diese Deklarationen erstellen eine oder mehrere lokale Namensbindungen, die synonym für einen anderen Pfad stehen. Deklarationen vom Typ use verkürzen den Pfad, der nötig ist, um auf ein Modulelement zu verweisen. Sie können in Modulen oder Blöcken vorkommen. Außerdem kann das Schlüsselwort self eine Liste von Pfaden mit einem gemeinsamen Präfix und dem gemeinsamen übergeordneten Modul binden. Dies sind beispielsweise alles gültige Deklarationen vom Typ use: 

Code
use anchor_lang::prelude::*;
use std::collections::hash_map::{self, HashMap};

use a::b::{c, d, e::f, g::h::i};
use a::b::{self, c, d::e};

Das erste Anchor-Makro, auf das Entwickler stoßen, ist declare_id!. Es deklariert die Adresse des Programms (die Programm-ID) und stellt sicher, dass alle Interaktionen korrekt an das Programm weitergeleitet werden. Anchor generiert ein neues Schlüsselpaar, wenn ein Entwickler erstmals ein Anchor-Programm erstellt. Sofern nicht anders angegeben, wird dieses Schlüsselpaar für das Deployment des Programms verwendet. Der öffentliche Schlüssel des Schlüsselpaars muss dem Makro declare_id! als Programm-ID übergeben werden:

Code
declare_id!("HZfVb1ohL1TejhZNkgFSKqGsyTznYtrwLV6GpA8BwV5Q");

Das Attributmakro #[program] kennzeichnet das Modul mit der Instruction-Logik des Programms. Es dient als Einstiegspunkt und definiert, wie das Programm eingehende Instructions interpretiert und ausführt. Dieses Makro vereinfacht die Weiterleitung der Instructions an die passende Funktion im Programm. Dadurch bleibt der Programmcode übersichtlicher und leichter zu verwalten. Jede Funktion in diesem Modul wird als separate Instruction behandelt. Jede Funktion erhält als erstes Argument einen Kontextparameter (ctx) vom Typ Context. Entwickler können auf die Accounts, die Programm-ID des ausgeführten Programms und die übrigen Accounts zugreifen.

Der Typ Context ist wie folgt definiert:

Code
pub struct Context<'a, 'b, 'c, 'info, T: Bumps> {
    pub program_id: &'a Pubkey,
    pub accounts: &'b mut T,
    pub remaining_accounts: &'c [AccountInfo<'info>],
    pub bumps: T::Bumps,
}

Damit lassen sich Eingaben, die keine Argumente sind, an ein bestimmtes Programm übergeben. Das Feld program_id hat den Typ Pubkey und stellt die ID des aktuell ausgeführten Programms dar. accounts verweist auf die serialisierten Accounts. remaining_accounts verweist dagegen auf die übergebenen übrigen Accounts, die nicht deserialisiert oder validiert wurden. Sei bei der direkten Verwendung sehr vorsichtig. Das Feld bumps hat den Typ Bumps , der von #[derive(Accounts)] generiert wird. Es stellt die bei der Constraint-Validierung gefundenen Bump-Seeds dar. Account-Constraints behandeln wir in einem späteren Abschnitt. Vorerst ist wichtig: Dieses Feld dient als Komfortfunktion, damit Handler Bump-Seeds weder neu berechnen noch als Argumente übergeben müssen.

Beachte, dass Context ein generischer Typ ist. In Rust ermöglichen Generics flexiblen, wiederverwendbaren Code, der mit beliebigen Datentypen funktioniert. Damit lassen sich Typdefinitionen für Structs, Enums, Funktionen und Methoden erstellen, ohne den exakten Typ anzugeben, mit dem sie arbeiten. Stattdessen wird ein Platzhalter für diese Typen verwendet, normalerweise T. Generics reduzieren wiederholten Code und verbessern die Übersichtlichkeit. Ein Enum lässt sich beispielsweise so definieren, dass es generische Datentypen enthält:

Code
enum Option<T> {
  Some(T),
  None,
}

Der obige Codeausschnitt zeigt das Enum Option<T>. Es ist ein standardmäßiges Rust-Enum, das einen Wert eines beliebigen Typs (also Some(T)) oder keinen Typ (None) kapseln kann.

Für unsere Zwecke ist Context ein generischer Typ. T gibt dabei die für eine Instruction erforderlichen Accounts an, also jeden Typ, den ein Entwickler zum Speichern von Daten erstellen möchte. Entwickler können T als Struct definieren, die bei der Verwendung von Context den Trait Accounts implementiert. Ein Beispiel ist Context<SetData>. Auf Felder innerhalb des Typs Context können Entwickler per Punktnotation zugreifen. So greift ctx.accounts beispielsweise auf das Feld accounts der Struct Context zu.

Wie bereits erwähnt, definiert das Makro #[account] benutzerdefinierte Account-Typen. In den nächsten Abschnitten untersuchen wir mit #[account(...)] Account-Typen und Constraints. Wichtig ist zunächst: In der Accounts-Struct definiert ein Entwickler, welche Accounts eine Instruction erwartet und welche Constraints diese Accounts erfüllen müssen.

Account-Typen

Der Typ Account wird verwendet, wenn eine Instruction auf die deserialisierten Daten eines Accounts zugreifen möchte. Die Struct Account ist generisch über T und wie folgt definiert: 

Code
pub struct Account<'info, T: AccountSerialize + AccountDeserialize + Clone> { /* private fields */ }

Sie ist ein Wrapper für AccountInfo , der den Programmbesitz prüft und die zugrunde liegenden Daten in einen Rust-Typ deserialisiert. Der Programmbesitz wird anhand von Account.info.owner == T::owner() geprüft. Das heißt: Es wird geprüft, ob der Eigentümer der Daten mit ID des Crates übereinstimmt, in dem #[account] verwendet wird. Dies ist die zuvor mit declare_id! erstellte ID. Daher muss der von Account umschlossene Datentyp (=T) den Trait Owner implementieren. Das Attribut #[account] implementiert den Trait für eine Struct mit der crate::ID, die im selben Programm von declare_id! deklariert wurde. Meist können Entwickler einfach das Attribut #[account] verwenden, um ihren Daten die erforderlichen Traits und Implementierungen hinzuzufügen. Das Attribut #[account] generiert Implementierungen für die folgenden Traits:

Bei der Implementierung von Traits für die Account-Serialisierung werden die ersten 8 Byte für einen eindeutigen Account-Diskriminator reserviert. Dieser Diskriminator wird aus den ersten 8 Byte des SHA-256-Hashes des Rust-Bezeichners des Accounts bestimmt. Jeder Aufruf von try_deserialize für AccountDeserialize prüft diesen Diskriminator. Wurde ein ungültiger Account übergeben, bricht die Account-Deserialisierung mit einem Fehler ab.

Manchmal müssen Entwickler mit Programmen interagieren, die nicht auf Anchor basieren. In diesem Fall erhalten sie alle Vorteile von Account, wenn sie anstelle von #[account] einen eigenen benutzerdefinierten Wrapper-Typ erstellen. Der folgende Codeausschnitt dient als Beispiel:

Code
use anchor_lang::prelude::*;
use anchor_spl::token::TokenAccount;

// Rest of the program

#[derive(Accounts)]
pub struct SetData<'info> {
    #[account(mut)]
    pub my_account: Account<'info, MyAccount>,
    #[account(
        constraint = my_account.mint == token_account.mint,
        has_one = owner
    )]
    pub token_account: Account<'info, TokenAccount>,
    pub owner: Signer<'info>
}

Die Account-Validierung erfolgt größtenteils über Account-Constraints, die wir im nächsten Abschnitt behandeln. Sieh dir zunächst an, wie der Typ TokenAccount sicherstellt, dass der eingehende Account dem Token-Programm gehört. TokenAccount umschließt die Struct Account des Token-Programms und fügt die erforderlichen Funktionen hinzu. So kann Anchor den Account deserialisieren. Entwickler können dessen Felder innerhalb von Account-Constraints und mit der Instruction-Funktion verwenden.

Beachte außerdem, dass das Makro derive im obigen Codeausschnitt die gesamte Struct umschließt. Dadurch wird ein Accounts-Deserialisierer für SetData implementiert, der eingehende Accounts validiert.

In der Struct zur Account-Validierung können mehrere Typen von Account verwendet werden, darunter:

Account-Constraints

Account-Constraints sind entscheidend für die Entwicklung sicherer Anchor-Programme. In zukünftigen Artikeln behandeln wir die Sicherheit von Solana-Programmen und das Hacken von Anchor-Programmen ausführlicher. Hier müssen wir jedoch zunächst Constraints erläutern. Damit können Entwickler prüfen, ob bestimmte Accounts oder die darin enthaltenen Daten vordefinierte Anforderungen erfüllen. Mit dem Attribut #[account(...)] lassen sich mehrere Arten von Constraints anwenden. Sie können auch auf andere Datenstrukturen verweisen. Das Format lautet:

Code
#[account(constraint goes here)]
pub account: AccountType

Innerhalb des Makros Accounts können Entwickler außerdem über das Attribut #[instruction(...)] auf die Argumente der Instructions zugreifen. Sie müssen die Instruction-Argumente in derselben Reihenfolge wie in der Instruction aufführen, können aber alle Argumente nach dem letzten benötigten Argument auslassen. Hier ein Beispiel aus der Anchor-Dokumentation: 

Code
...
pub fn initialize(ctx: Context, bump: u8, authority: Pubkey, data: u64) -> anchor_lang::Result<()> {
    ...
    Ok(())
}
...
#[derive(Accounts)]
#[instruction(bump: u8)]
pub struct Initialize<'info> {
    ...
}

Account-Constraints lassen sich in normale Constraints und SPL-Constraints unterteilen. Im weiteren Verlauf dieses Artikels gehen wir auf einzelne Constraints ein. In diesen Beispielen steht <expr> für einen beliebigen Ausdruck, der übergeben werden kann, solange er einen Wert des erwarteten Typs ergibt. Ein Beispiel ist owner = token_program.key().

Constraints eines Programms analysieren

Eine umfassendere Liste möglicher Constraints findest du in der Anchor-Dokumentation zu Accounts. Jeden einzelnen Constraint durchzugehen und in einer Tabelle formal zu definieren, wäre zu aufwendig. Für unsere Zwecke ist es hilfreicher, das folgende Programm zu analysieren und Account-Constraints in Aktion kennenzulernen:

Code
use anchor_lang::prelude::*;
#[cfg(not(feature = "no-entrypoint"))]
use {default_env::default_env, solana_security_txt::security_txt};

declare_id!("fanqeMu3fw8R4LwKNbahPtYXJsyLL6NXyfe2BqzhfB6");

pub mod errors;
pub mod instructions;
pub mod state;

pub use instructions::*;
pub use state::*;

#[cfg(not(feature = "no-entrypoint"))]
security_txt! {
  name: "Fanout",
  project_url: "http://helium.com",
  contacts: "email:hello@helium.foundation",
  policy: "https://github.com/helium/helium-program-library/tree/master/SECURITY.md",

  // Optional Fields
  preferred_languages: "en",
  source_code: "https://github.com/helium/helium-program-library/tree/master/programs/fanout",
  source_revision: default_env!("GITHUB_SHA", ""),
  source_release: default_env!("GITHUB_REF_NAME", ""),
  auditors: "Sec3"
}

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

  pub fn initialize_fanout_v0(
    ctx: Context<InitializeFanoutV0>,
    args: InitializeFanoutArgsV0,
  ) -> Result<()> {
    instructions::initialize_fanout_v0::handler(ctx, args)
  }

  pub fn stake_v0(ctx: Context<StakeV0>, args: StakeArgsV0) -> Result<()> {
    instructions::stake_v0::handler(ctx, args)
  }

  pub fn unstake_v0(ctx: Context<UnstakeV0>) -> Result<()> {
    instructions::unstake_v0::handler(ctx)
  }

  pub fn distribute_v0(ctx: Context<DistributeV0>) -> Result<()> {
    instructions::distribute_v0::handler(ctx)
  }
}

Dies ist das Fanout-Programm von Helium. Es ist ein recht komplexes Programm, das Token proportional zu den Beständen der Token-Inhaber verteilt. Aktuell erscheint uns das Projekt noch nicht besonders hilfreich, da keine Constraints zu sehen sind. Wenn wir jedoch die Struct StakeV0 der Instruction stake_v0 analysieren, finden wir zahlreiche Constraints, die wir untersuchen können.

mut

Der erste Constraint in dieser Instruction ist der Account-Constraint mut. mut ist als #[account(mut)] oder #[account(mut @ <custom_error>)] definiert und unterstützt benutzerdefinierte Fehler über die Notation @. Dieser Constraint prüft, ob ein bestimmter Account veränderlich ist, und sorgt dafür, dass Anchor alle Zustandsänderungen dauerhaft speichert. Im Programm von Helium stellt der Constraint sicher, dass der Account payer veränderlich ist:

Code
...
pub struct StakeV0<'info> {
  #[account(mut)]
  pub payer: Signer<'info>,
  pub staker: Signer<'info>,
  /// CHECK: Just needed to receive nft
  pub recipient: AccountInfo<'info>,
...

has_one

Der Constraint has_one ist als #[account(has_one = <target_account)] oder #[account(has_one = <target_account> @ <custom_error>)] definiert. Er prüft im Feld target_account, ob der Account mit dem Schlüssel des Felds target_account in der Accounts-Struct übereinstimmt. Benutzerdefinierte Fehler werden über die Annotation @ unterstützt.

Im Kontext der Struct StakeV0 prüft der Constraint has_one, ob der Account über membership_mint, token_account und membership_collection verfügt:

Code
...
#[account(
  mut,
  has_one = membership_mint,
  has_one = token_account,
  has_one = membership_collection
)]
pub fanout: Box<Account<'info, FanoutV0>>,
pub membership_mint: Box<Account<'info, Mint>>,
pub token_account: Box<Account<'info, TokenAccount>>,
pub membership_collection: Box<Account<'info, Mint>>,
...

Beachte, dass mehrere Constraints vom Typ has_one sowie der Constraint mut verwendet werden. Auf einen Account lassen sich mehrere Account-Constraints gleichzeitig anwenden.

seeds, bump

Die Constraints seeds und bump prüfen, ob ein bestimmter Account eine PDA ist, die aus dem aktuell ausgeführten Programm, den Seeds und, sofern angegeben, dem Bump abgeleitet wurde:

  • #[account(seeds = <seeds>, bump)]
  • #[account(seeds = <seeds>, bump, seeds::program = <expr>)]
  • #[account(seeds = <seeds>, bump = <expr>)]
  • #[account(seeds = <seeds>, bump = <expr>, seeds::program = <expr>)]

Wird kein Bump angegeben, verwendet Anchor den kanonischen Bump. Mit Seeds::program = <expr> lässt sich die PDA aus einem anderen Programm als dem aktuell ausgeführten ableiten.

Im Fanout-Programm von Helium prüft der Constraint seeds, ob der Text „metadata“, der Schlüssel token_metadata_program, der Schlüssel membership_collection und der Text „edition“ als Seeds zur Ableitung dieser PDA verwendet werden. Der Constraint seeds::program stellt sicher, dass token_metadata_program statt des aktuellen Programms zur Ableitung der PDA verwendet wird:

Code
...
#[account(
  mut,
  seeds = ["metadata".as_bytes(), token_metadata_program.key().as_ref(), membership_collection.key().as_ref()],
  seeds::program = token_metadata_program.key(),
  bump,
)]
pub collection_metadata: UncheckedAccount<'info>,
...

token::mint, token::authority

Die Constraints token::mint und token::authority sind wie folgt definiert:

  • #[account(token::mint = <target account>, token::authority = <target account>)]
  • #[account(token::mint = <target account>, token::authority = <target account>, token::token_program = <target account>)]

Die Token-Constraints mint und authority prüfen die Mint-Adresse und Authority eines TokenAccount. Diese Constraints lassen sich als Prüfung oder zusammen mit dem Constraint init verwenden, um einen Token-Account mit der angegebenen Mint-Adresse und Authority zu erstellen. Bei der Verwendung als Prüfung kann auch nur eine Teilmenge der Constraints angegeben werden. 

Im Kontext des Programms von Helium prüfen diese Constraints, ob der Mint von associated_token mit membership_mint übereinstimmt und ob die Authority des Tokens auf staker gesetzt ist:

Code
...
#[account(
  mut,
  associated_token::mint = membership_mint,
  associated_token::authority = staker,
)]
pub from_account: Box<Account<'info, TokenAccount>>,
...

init, payer, space

An dieser Stelle ist es sinnvoll, im Code etwas vorzuspringen und die Constraints init, payer und space zu analysieren. Der Constraint init ist als [#account(init, payer = <target_account>, space = <num_bytes>)] definiert. Dieser Constraint erstellt den Account über einen CPI an das System Program und initialisiert ihn, indem er den Account-Diskriminator setzt. Dadurch wird der Account als veränderlich markiert. Der Constraint schließt mut gegenseitig aus. Verwende für Accounts mit mehr als 10 Kibibyte #[account(zero)].

Der Constraint init muss zusammen mit einigen zusätzlichen Constraints verwendet werden. Er erfordert den Constraint payer, der den Account angibt, der für die Erstellung des Accounts zahlt. Außerdem muss das System Program in der Struct vorhanden sein und system_program heißen. Auch der Constraint space muss definiert sein. Im Abschnitt zum Account-Speicherplatz gehen wir genauer auf diesen Constraint und die Speicherplatzanforderungen ein.

Im Fanout-Programm von Helium erstellt der Befehl init einen neuen Account. payer wird als payer festgelegt, das zuvor in der Struct als pub payer: Signer<'info> definiert wurde. Der Speicherplatz des Accounts wird auf die Größe von FanoutVoucherV0 zuzüglich 8 Byte für den Diskriminator und weiterer 61 Byte Speicherplatz gesetzt:

Code
...
#[account(
  init,
  payer = payer,
  space = 60 + 8 + std::mem::size_of::<FanoutVoucherV0>() + 1,
  seeds = ["fanout_voucher".as_bytes(), mint.key().as_ref()],
  bump,
)]
pub voucher: Box<Account<'info, FanoutVoucherV0>>,
...

init_if_needed

Der Constraint init_if_needed ist als #[account(init_if_nedded, payer = <target_Account>)] oder #[account(init)if_needed, payer = <target_account>, space = <num_bytes>)] definiert. Er hat exakt dieselbe Funktionalität wie init, wird jedoch nur ausgeführt, wenn der Account noch nicht existiert. Existiert der Account bereits, prüft init_if_needed weiterhin, ob alle Initialisierungs-Constraints erfüllt sind. Dazu gehört etwa, ob dem Account der richtige Speicherplatz zugewiesen wurde oder ob bei einer PDA die richtigen Seeds vorliegen.

Verwende init_if_needed vorsichtig, da es wegen potenzieller Risiken durch ein Feature-Flag geschützt ist. Importiere zur Aktivierung anchor-lang mit dem Cargo-Feature init-if-needed. Bei der Verwendung von init_if_needed ist der Schutz vor Reinitialisierungsangriffen entscheidend. Entwickler müssen mit Prüfungen im Code verhindern, dass der Account nach seiner Initialisierung auf den Ausgangszustand zurückgesetzt wird, sofern dieses Verhalten nicht beabsichtigt ist. Als Best Practice sollten die Ausführungspfade von Instructions einfach bleiben, um diese Angriffe zu erschweren. Teile die Instructions am besten in eine Instruction für die Initialisierung und weitere Instructions für nachfolgende Operationen auf.

Das Fanout-Programm von Helium verwendet den Constraint init_if_needed, um recipient_account zu initialisieren, falls der Account noch nicht existiert:

Code
...
#[account(
  init_if_needed,
  payer = payer,
  associated_token::mint = mint,
  associated_token::authority = recipient,
)]
pub receipt_account: Box<Account<'info, TokenAccount>>,
...

constraint

Der Constraint constraint ist als #[account(constraint = <expr>)] oder #[account(constraint = <expr> @ <custom_error>)] definiert. Er prüft, ob der angegebene Ausdruck „true“ ergibt. Das ist hilfreich, wenn kein anderer Constraint zum vorgesehenen Anwendungsfall passt. Über die Annotation @ unterstützt er außerdem benutzerdefinierte Fehler.

Das Fanout-Programm verwendet constraint, um zu prüfen, ob das Angebot des Mints auf null gesetzt ist:

Code
...
#[account(
  mut,
  constraint = mint.supply == 0,
  mint::decimals = 0,
  mint::authority = voucher,
  mint::freeze_authority = voucher,
)]
pub mint: Box<Account<'info, Mint>>,
...

mint::authority, mint::decimals, mint::freeze_authority 

Im obigen Codeausschnitt prüfen die Constraints mint::decimals, mint::authority und mint::freeze_authority, ob die Dezimalstellen des Mints auf null gesetzt sind und voucher über die Authority und Freeze-Authority verfügt.

Zur Einordnung: Die Constraints mint::authority, mint::decimals und mint::freeze_authority sind wie folgt definiert:

  • #[account(mint::authority = <target account>, mint::decimals = <expr>)]
  • #[account(mint::authority = <target account>, mint::decimals = <expr>, mint::freeze_authority = <target account>)]

Diese Constraints sind selbsterklärend: Sie prüfen jeweils die Authority, Dezimalstellen und Freeze-Authority des Tokens. Du kannst sie als Prüfung oder zusammen mit init verwenden, um einen Mint-Account mit den angegebenen Mint-Dezimalstellen und der angegebenen Mint-Authority zu erstellen. Bei der Verwendung mit init ist die Freeze-Authority vollständig optional. Bei der Verwendung als Prüfung kann auch nur eine Teilmenge dieser Constraints angegeben werden.

Account-Speicherplatz 

Für jeden Account auf Solana, den ein Programm verwendet, muss der Speicherplatz explizit zugewiesen werden. Diese Zuweisung ist für eine effiziente Ressourcenverwaltung entscheidend und stellt sicher, dass nur erforderliche Daten on-chain gespeichert werden. Sie sorgt außerdem für vorhersehbare Transaktionskosten und eine effizientere Transaktionsausführung. Transaktionen lassen sich verarbeiten, ohne den Account-Speicher dynamisch zuzuweisen oder seine Größe anzupassen. Darüber hinaus garantiert die Vorabzuweisung, dass der Account genügend Platz für alle erforderlichen Daten hat. Das reduziert das Risiko fehlgeschlagener Transaktionen oder potenzieller Sicherheitslücken.

Größen von Variablen

Verschiedene Datentypen benötigen unterschiedlich viel Speicherplatz. Dieser vereinfachte Leitfaden hilft dir, den Speicherbedarf abzuschätzen:

  • Grundtypen: Einfache Datentypen wie bool, u8, i8, u16, i16, u32, i32, u64, i64, u128 und i128 haben feste Größen. Sie reichen von 1 Byte für ein bool (obwohl es nur 1 Bit verwendet) bis zu 16 Byte für u128 / i128
  • Arrays: Für ein Array [T;amount] berechnet sich der Speicherplatz aus der Größe von T multipliziert mit der Anzahl der Elemente (also amount). Ein Array mit 16 u16 würde beispielsweise 32 Byte benötigen
  • Pubkey: Ein öffentlicher Schlüssel belegt auf Solana immer 32 Byte
  • Dynamische Typen: String und Vec<T> erfordern besondere Aufmerksamkeit. Beide benötigen 4 Byte zum Speichern ihrer Länge sowie den Platz für den eigentlichen Inhalt. Du musst genügend Speicherplatz für die maximal erwartete Größe zuweisen. Bei einem String sind das 4 Byte plus die Länge des String in Byte. Bei einem Vec<T> sind es 4 Byte plus der Speicherplatz des jeweiligen Typs, multipliziert mit der erwarteten Anzahl der Elemente (also 4 + space(T) * amount)
  • Options und Enums: Ein Option<T>-Typ benötigt 1 Byte plus den Speicherplatz für den Typ T. Enums benötigen 1 Byte für den Enum-Diskriminator sowie den erforderlichen Speicherplatz für die größte Variante
  • Gleitkommazahlen: Typen wie f32 und f64 belegen 4 beziehungsweise 8 Byte. Sei bei NaN-Werten vorsichtig, da sie dazu führen können, dass die Serialisierung fehlschlägt

Der folgende Leitfaden gilt nur für Accounts, die keine zero-copy-Serialisierung verwenden. Zero-Copy-Serialisierung wird durch das Attribut #[zero_copy] gekennzeichnet. Sie nutzt das Attribut repr(c) für das Speicherlayout und ermöglicht direkten Pointer-Casting-Zugriff auf Daten. So kannst du effizient mit On-Chain-Daten arbeiten, ohne den Overhead herkömmlicher Deserialisierung. #[zero_copy] ist eine Kurzform für die Anwendung von #[derive(Copy, Clone)], #[derive(bytemuck::Zeroable)], #[derive(bytemuck::Pod)] und #[repr(C)]. Diese Attribute stellen sicher, dass der Account sicher als Bytefolge behandelt werden kann und mit Zero-Copy-Deserialisierung kompatibel ist. Zero-Copy-Deserialisierung ist für Accounts mit sehr hohem Speicherbedarf entscheidend – also für Accounts, die sich mit Borsh oder den standardmäßigen Serialisierungsmechanismen von Anchor nicht effizient serialisieren lassen, ohne Heap- oder Stack-Limits zu erreichen.

Anchors interner Diskriminator

Entwickler müssen für Anchors internen Diskriminator 8 zur space-Constraint addieren. Benötigt ein Account beispielsweise 32 Byte, müssen 40 Byte zugewiesen werden. Es gilt als Best Practice, die Space-Constraint auf space = 8 + <account size> zu setzen. So ist klar, dass der interne Diskriminator bei der Speicherplatzberechnung berücksichtigt wird.  

Ein Diskriminator ist ein eindeutiger Bezeichner, mit dem verschiedene Datentypen unterschieden werden. Er ist nützlich, um zur Laufzeit zwischen unterschiedlichen Strukturen von Account-Daten zu unterscheiden. Er wird außerdem Instruktionen vorangestellt und hilft dabei, sie an die entsprechenden Methoden in einem Anchor-Programm weiterzuleiten. Der Diskriminator ist ein 8-Byte-Array, das den eindeutigen Bezeichner des Datentyps darstellt.

Anfänglichen Speicherplatz berechnen

Den anfänglichen Speicherbedarf eines Accounts zu berechnen, kann schwierig sein. Das Makro InitSpace fügt eine Konstante INIT_SPACE hinzu, die für die Struktur des Accounts verwendet werden kann. Die Struktur muss das Makro #[account] nicht enthalten, um die Konstante zu erzeugen. Die Anchor-Dokumentation zeigt folgendes Beispiel:

Code
#[account]
#[derive(InitSpace)]
pub struct ExampleAccount {
  pub data: u64,
  // max_len represents the length of the structure
  #[max_len(50)]
  pub string_one: String,
  #[max_len(10, 5)]
  pub nested: Vec<Vec<u8>>,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
  #[account(mut)]
  pub payer: Signer<'info>,
  pub system_program: Program<'info, System>,
  #[account(init, payer = payer, space = 8 + ExampleAccount::INIT_SPACE)]
  pub data: Account<'info, ExampleAccount>,
}

In diesem Beispiel berechnet ExampleAccount::INIT_SPACE automatisch den erforderlichen Speicherplatz für ExampleAccount. Dabei berücksichtigt es auch Anchors internen Diskriminator.

Programmspeicher vergrößern oder verkleinern

Die Constraint realloc passt den Speicherplatz eines Programm-Accounts zu Beginn einer Instruktion an. Der Account muss veränderbar sein (also mut). Die Constraint kann auf die Typen Account oder AccountLoader angewendet werden. Sie ist als #[account(realloc = <space>, realloc::payer = <target>, realloc::zero = <bool>)] definiert. Wenn die Länge der Account-Daten zunimmt, werden Lamports vom realloc::payer an den Programm-Account übertragen, damit dieser von der Rent befreit bleibt. Verringert sich die Datenlänge, werden die Lamports vom Programm-Account zurück an den realloc::payer übertragen. Die Constraint realloc::zero legt fest, ob der neu zugewiesene Speicher mit Nullen initialisiert werden soll. Die Nullinitialisierung stellt sicher, dass der neue Speicher sauber und frei von übrig gebliebenen oder unerwünschten Daten ist.

Es wird nicht empfohlen, AccountInfo::realloc anstelle der Constraint realloc manuell zu verwenden. Es fehlen Laufzeitprüfungen, die sicherstellen, dass die Neuzuweisung das Limit MAX_PERMITTED_DATA_INCREASE nicht überschreitet. Dadurch könnten Daten anderer Accounts überschrieben werden. Die Constraint prüft und verhindert außerdem wiederholte Neuzuweisungen innerhalb einer einzelnen Instruktion.

Beispiel:

Code
#[derive(Accounts)]
pub struct Data {
#[account(mut)]
pub payer: Signer<'info>,
  #[account(
    mut,
    seeds = [b"data"],
    bump,
    realloc = 8 + std::mem::size_of::<()>() + 48,
    realloc::payer = payer,
    realloc::zero = false
  )]
  pub update_account: Account<'info, NewData>,
  system_program: Program<'info, System>,
}

Fehler

Die Fehlerbehandlung ist ein zentraler Bestandteil der Programmentwicklung. Mit ihr lassen sich Fehler erkennen und verwalten, die die Ausführung eines Programms stoppen können. Du musst die Fehlerbehandlung bewusst planen, um Codequalität, Wartbarkeit und Funktionalität sicherzustellen. Anchor vereinfacht dies mit robusten Mechanismen zur Fehlerbehandlung. Fehler in Anchor-Programmen lassen sich in AnchorErrors und Nicht-Anchor-Fehler unterteilen. Dieser Abschnitt konzentriert sich auf AnchorErrors, da Nicht-Anchor-Fehler eine große Bandbreite an Rust-Fehlern umfassen. Für Nicht-Anchor-Fehler empfehle ich das Kapitel zur Fehlerbehandlung im Rust Book und den Abschnitt zur Fehlerbehandlung in Rust By Example.

Das folgende struct definiert AnchorError:

Code
pub struct AnchorError {
  pub error_name: String,
  pub error_code_number: u32,
  pub error_msg: String,
  pub error_origin: Option<ErrorOrigin>,
  pub compared_values: Option<ComparedValues>,
}

Diese Felder sind relativ selbsterklärend. error_name ist ein String, der den Namen des Fehlers darstellt. error_code_number ist ein eindeutiger Bezeichner für den Fehler, also eine eindeutige vorzeichenlose Ganzzahl mit 32 Bit Speicherbedarf. error_msg ist eine beschreibende Meldung, die den Fehler erklärt. error_origin ist ein optionales Feld mit Informationen zum Ursprung des Fehlers, etwa zur betroffenen Quelldatei oder zum Account. compared_values ist ein optionales Feld, das die beim Auftreten des Fehlers verglichenen Werte aufführt. Das ist für das Debugging äußerst hilfreich.

AnchorError implementiert eine Log-Methode. Sie enthält Informationen zum Ursprung des Fehlers und zu den beteiligten Werten. Das hilft beim Debugging und bei der Fehlerbehebung. Die Methode verwendet error_origin und compared_values, um diese Informationen bereitzustellen.

AnchorErrors lassen sich weiter in interne Anchor-Fehler und benutzerdefinierte Fehler unterteilen. Anchor kann eine lange Liste interner Fehlercodes zurückgeben. Diese internen Fehler sind nicht für die Verwendung durch Nutzer gedacht. Dennoch ist es hilfreich, die Zuordnung zwischen Codes und Ursachen zu kennen. Sie werden üblicherweise ausgelöst, wenn eine Constraint verletzt wurde. Interne Fehlercodes folgen diesem Schema:

  • >= 100 sind Instruktionsfehlercodes
  • >= 1000 sind IDL-Fehlercodes
  • >= 2000 sind Constraint-Fehlercodes
  • >= 3000 sind Account-Fehlercodes
  • >= 4100 sind sonstige Fehlercodes
  • = 5000 sind veraltete Fehlercodes.

Benutzerdefinierte Fehler beginnen beim ERROR_CODE_OFFSET, also bei 6000.

Entwickler können mit dem Attribut error_code eigene Fehler implementieren. Dieses Attribut wird auf ein Enum angewendet. Dessen Varianten können im gesamten Programm als Fehler verwendet werden. Für jede Variante lässt sich eine Meldung hinzufügen. Der Client kann diese Meldung anzeigen, wenn der Fehler auftritt. Beispiel:

Code
#[error_code]
pub enum HeliusError {
  #[msg(“This RPC provider is too good”)]
  RPCTooGood
}

Mit den Makros err! und error! kannst du diese Fehler auslösen. Beispiel:

Code
require!(rpc.speed > 9000, HeliusError::RPCTooGood);

Beachte, dass mehrere require-Makros zur Auswahl stehen. Die meisten dieser Makros betreffen Werte, die keine öffentlichen Schlüssel sind. Das Makro require_gte prüft beispielsweise, ob der erste Wert, der kein öffentlicher Schlüssel ist, größer oder gleich dem zweiten solchen Wert ist:

Code
pub fn set_data(ctx: Context<SetData>, data: u64) -> Result<()> {
    require_gte!(ctx.accounts.data.data, 1);
    ctx.accounts.data.data = data;
    Ok(());
}

Beim Vergleich öffentlicher Schlüssel gibt es ebenfalls einige Besonderheiten. Entwickler sollten beispielsweise require_keys_eq statt require_eq verwenden, da Letzteres teurer ist.

Alle Programme geben einen ProgramError zurück. Dieser Fehlertyp enthält ein spezielles Feld für eine benutzerdefinierte Fehlernummer. Anchor speichert darin seine internen und benutzerdefinierten Fehlercodes. Da es sich jedoch nur um eine einzelne Zahl handelt, ist sie nicht besonders hilfreich. Anchors zuvor beschriebenes Logging mit AnchorErrors bietet wesentlich mehr Informationen. Anchor-Clients sind darauf ausgelegt, diese Logs zu parsen. In manchen Szenarien kann das jedoch schwierig sein. Beispielsweise ist das Abrufen von Logs für verarbeitete Transaktionen mit deaktivierten Preflight-Prüfungen weniger direkt. Anchor verwendet außerdem einen Fallback-Mechanismus für Nicht-Anchor- oder Legacy-Programme, die AnchorErrors nicht auf die standardmäßige Weise protokollieren. Dabei prüft Anchor, ob die von der Transaktion zurückgegebene Fehlernummer einem internen Anchor-Fehlercode oder einer in der IDL des Programms definierten Fehlernummer entspricht. Wird eine Übereinstimmung gefunden, ergänzt Anchor die Fehlerinformationen um weiteren Kontext. Anchor versucht nach Möglichkeit auch, den Programmfehler-Stack zu parsen, um die ursprüngliche Ursache des Programmfehlers zu ermitteln. ProgramError dient als grundlegender Fehlertyp. Anchors Logging- und Parsing-Mechanismen erweitern seinen Nutzen und liefern detaillierte Fehlerinformationen.

Programmübergreifende Aufrufe (CPIs)

Programmübergreifende Aufrufe (CPIs) wurden in diesem Artikel bereits mehrfach erwähnt. Daher verdienen sie einen eigenen Abschnitt. CPIs sind grundlegend für die Komponierbarkeit von Solana, da Programme damit andere Programme direkt aufrufen können. So wird das Solana-Ökosystem für Entwickler gewissermaßen zu einer riesigen, vernetzten API. Der Kürze halber empfehle ich die Anchor-Dokumentation zu CPIs. Sie zeigt anhand eines Marionetten- und Marionettenspieler-Programms ein nützliches Beispiel für CPIs in Aktion.

Ein CPI lässt sich als Aufruf von einem Programm an ein anderes definieren, der auf eine bestimmte Instruktion im aufgerufenen Programm zielt. Das aufrufende Programm wird angehalten, bis das aufgerufene Programm die Instruktion vollständig verarbeitet hat. 

Rechteausweitung

Mit CPIs kann ein aufrufendes Programm seine Signaturberechtigungen auf das aufgerufene Programm ausweiten. Diese Rechteausweitung ist praktisch, kann aber sehr gefährlich sein. Zielt ein CPI versehentlich auf ein schädliches Programm, erhält dieses dieselben Berechtigungen wie der Aufrufer. Anchor reduziert dieses Risiko mit zwei Schutzmechanismen:

  • Der Typ Program<’info, T> stellt sicher, dass der angegebene Account dem erwarteten Programm entspricht (T)
  • Selbst wenn der Typ Program nicht verwendet wird, prüft die automatisch generierte CPI-Funktion, ob das Argument cpi_program dem erwarteten Programm entspricht

Einen CPI ausführen

Ein Programm kann einen CPI mit invoke oder invoke_signed aus der Crate solana_program ausführen. Anchor stellt außerdem die CpiContext-Struktur bereit, um Eingaben für CPIs anzugeben, die keine Argumente sind.

invoke

Die Funktion invoke wird verwendet, wenn kein PDA als Signatur erforderlich ist. In diesem Fall weitet die Laufzeit die ursprüngliche Signatur des aufrufenden Programms auf das aufgerufene Programm aus. Die Funktion ist wie folgt definiert:

Code
pub fn invoke(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>]
) -> ProgramResult

Zum Aufrufen eines anderen Programms wird ein Instruction erstellt. Es enthält die Programm-ID, die Instruktionsdaten für das aufgerufene Programm und eine Liste der Accounts, auf die dieses zugreifen wird. Ein Programm erhält von der Laufzeit nur an seinem Programmeinstiegspunkt Werte vom Typ AccountInfo. Jeder Account, den das aufgerufene Programm für seinen Aufruf benötigt, muss vom aufrufenden Programm eingeschlossen und bereitgestellt werden. Muss das aufgerufene Programm beispielsweise einen bestimmten Account ändern, muss das aufrufende Programm diesen Account in die Liste der AccountInfo-Werte aufnehmen. Das gilt auch für die Programm-ID des aufgerufenen Programms. Der Aufrufer muss also explizit angeben, welches Programm er aufruft, indem er dessen Programm-ID einschließt.

Das Instruction wird normalerweise innerhalb des aufrufenden Programms erstellt, kann aber auch aus einer externen Ausgabe deserialisiert werden.

Die gesamte Transaktion schlägt sofort fehl, wenn im aufgerufenen Programm ein Fehler auftritt oder es abbricht. Das liegt daran, dass die Funktion invoke nur bei Erfolg zurückkehrt. Verwende die Funktionen set_return_data oder get_return_data, um Daten als Ergebnis eines CPI zurückzugeben. Der zurückgegebene Typ muss die Traits AnchorSerialize und AnchorDeserialize implementieren. Alternativ kann das aufgerufene Programm die Daten in einen dafür vorgesehenen Account schreiben

Ein Programm kann sich zwar rekursiv selbst aufrufen, indirekte rekursive Aufrufe durch ein anderes Programm, also Reentrancy, führen jedoch sofort zum Fehlschlagen der Transaktion.

Wenn wir beispielsweise ein Programm hätten, das Token per CPI überträgt, würden wir invoke wie folgt verwenden:

Code
pub fn set_data(ctx: Context<SetData>, data: u64) -> Result<()> {
    require_gte!(ctx.accounts.data.data, 1);
    ctx.accounts.data.data = data;
    Ok(());
}

invoke_signed

invoke_signed wird für CPIs verwendet, die einen PDA als Signer benötigen. Damit kann ein aufrufendes Programm im Namen eines PDA handeln, indem es die für dessen Ableitung erforderlichen Seeds bereitstellt:

Code
pub fn invoke_signed(
	instruction: &Instruction,
	account_infos: &[AccountInfo<'_>],
  signers_seeds: &[&[&[u8]]]
) -> ProgramResult

PDAs können in einem CPI ebenfalls als Signer fungieren. Die Laufzeit verwendet die bereitgestellten Seeds und program_id des aufrufenden Programms, um den PDA intern über create_program_address zu erzeugen. Anschließend wird der PDA mit den Adressen abgeglichen, die mit der Instruktion übergeben wurden (also account_infos). So wird bestätigt, dass er ein gültiger Signer ist.

Mit dieser Funktion kann ein Aufruf im Namen eines oder mehrerer PDAs signieren, die vom aufrufenden Programm kontrolliert werden. Dadurch kann das aufgerufene Programm mit den angegebenen Accounts interagieren, als wären sie kryptografisch signiert. signer_seeds besteht aus Seed-Slices, mit denen der PDA abgeleitet wird. Während des Aufrufs betrachtet die Laufzeit jeden übereinstimmenden Account in account_info als „signiert“. Wenn wir beispielsweise ein Programm hätten, das einen Account für einen PDA erstellt, würden wir invoke_signed wie folgt aufrufen:

Code
invoke_signed(
	&system_instruction::create_account(
  	&payer.key,
  	&vault_pda.key,
  	lamports,
  	vault_size,
  	&program_id,
  ),
  &[
  	payer.clone(),
  	vault_pda.clone(),
  ],
  &[
  	&[
  		b"vault",
  		payer.key.as_ref(),
  		&[vault_bump_seed],
  	],
  ]
)?;

CpiContext

Anchor stellt CpiContext als einfachere Möglichkeit bereit, CPIs auszuführen, statt invoke oder invoke_signed zu verwenden. Diese Struktur gibt die für CPIs erforderlichen Eingaben an, die keine Argumente sind, und bildet die Funktionalität von Context weitgehend nach. Sie enthält Informationen zu den für die Instruktion benötigten Accounts, weiteren beteiligten Accounts, der aufgerufenen Programm-ID und gegebenenfalls den Seeds zur Ableitung von PDAs. Verwende CpiContext::new für CPIs ohne PDAs und CpiContext::new_with_signer für CPIs, die PDA-Signer benötigen.

CpiContext ist wie folgt definiert. Dabei ist T ein generischer Typ, der jedes Objekt umfasst, das die Traits ToAccountMetas und ToAccountInfos<’info> implementiert:

Code
pub struct CpiContext<'a, 'b, 'c, 'info, T>where
    T: ToAccountMetas + ToAccountInfos<'info>,{
    pub accounts: T,
    pub remaining_accounts: Vec>,
    pub program: AccountInfo<'info>,
    pub signer_seeds: &'a [&'b [&'c [u8]]],
}

Accounts ist ein generischer Typ und erlaubt jedes Objekt, das die Traits ToAccountMetas und ToAccountInfos<’info> implementiert. Das Attributmakro #[derive(Accounts)] ermöglicht dies, um die Codeorganisation und Typsicherheit zu verbessern. 

CpiContext vereinfacht das Aufrufen von Anchor- und Nicht-Anchor-Programmen. Deklariere für Anchor-Programme einfach eine Abhängigkeit in der Datei Cargo.toml des Projekts und verwende das von Anchor generierte Modul cpi:

Code
[dependencies]
callee = { path = "../callee", features = ["cpi"]}

Wenn du features = [“cpi”] setzt, erhält das Programm Zugriff auf das Modul callee::cpi. Anchor generiert dieses Modul automatisch und stellt die Instruktionen des Programms als Rust-Funktion bereit. Diese Funktion nimmt ein CpiContext und zusätzliche Instruktionsdaten entgegen. Das entspricht dem Format regulärer Instruktionsfunktionen in Anchor-Programmen, wobei CpiContext an die Stelle von Context tritt. Das Modul cpi stellt außerdem die für den Aufruf der Instruktionen erforderlichen Account-Strukturen bereit.

Wenn das aufgerufene Programm beispielsweise eine Instruktion namens hello_there enthält, die bestimmte in der Struktur GeneralKenobi definierte Accounts benötigt, rufst du sie wie folgt auf:

Code
// We assume "jedi" is an Anchor program with a published crate
use jedi::cpi::accounts::GeneralKenobi;
use jedi::cpi::hello_there;
use anchor_lang::prelude::*;

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

pub fn call_hello_there(ctx: Context<CallGeneralKenobi>, data: GreetingParams) -> Result<()> {
	let cpi_accounts = GeneralKenobi {
		jedi: ctx.accounts.jedi.to_account_info(),
		// Other account infos needed for the GeneralKenobi struct go here
	};

	let cpi_program = ctx.accounts.jedi_program.to_account_info();
	let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);

	hello_there(cpi_ctx, data);
}

#[derive(Accounts)]
pub struct CallGeneralKenobi<'info> {
	pub jedi: UncheckedAccount<'info>,
	pub jedi_program: Program<'info, Jedi>,
	// Other required accounts
}

pub struct GreetingParams {
	// Params required for the hello_there function
}

Im Modul fight_on_utapau wird ein CPI mit CpiContext ausgeführt. Die Funktion call_hello_there interagiert mit dem Programm jedi. Sie erstellt ein CpiContext mit den erforderlichen Account-Informationen für die Account-Struktur GeneralKenobi aus dem Programm jedi sowie den Account-Informationen des Programms jedi. Dieser Kontext ruft hello_there auf und übergibt alle zusätzlichen erforderlichen Parameter, die in der Struktur GreetingParams festgelegt sind. Die Struktur CallGeneralKenobi definiert die für diese Funktion benötigten Accounts und vereinfacht so den Prozess.

Prüfe beim Aufrufen von Instruktionen aus Nicht-Anchor-Programmen abschließend, ob die Programmbetreiber eine eigene Crate mit Hilfsfunktionen für Aufrufe ihres Programms veröffentlicht haben. Gibt es keine Hilfsfunktionen für das Programm, dessen Instruktion oder Instruktionen aufgerufen werden müssen, verwende stattdessen invoke und invoke_signer, um CPIs zu organisieren und vorzubereiten.

Program Derived Addresses (PDAs)

Zur Erinnerung: PDAs liegen außerhalb der Kurve und haben keinen zugehörigen privaten Schlüssel. Mit ihnen können Programme Instruktionen signieren und Entwickler Hashmap-ähnliche Strukturen on-chain erstellen. Ein PDA wird aus einer Liste optionaler Seeds, einem Bump-Seed und einer Programm-ID abgeleitet. 

Noch einmal zusammengefasst: Mit den folgenden Constraints wird geprüft, ob ein bestimmter Account ein PDA ist, der aus dem aktuell ausgeführten Programm, den Seeds und, falls angegeben, dem Bump abgeleitet wurde:

  • #[account(seeds = <seeds>, bump)]
  • #[account(seeds = <seeds>, bump, seeds::program = <expr>)]
  • #[account(seeds = <seeds>, bump = <expr>)]
  • #[account(seeds = <seeds>, bump = <expr>, seeds::program = <expr>)]

Wenn der Bump nicht angegeben wird, verwendet Anchor den kanonischen Bump. Mit Seeds::program = <expr> kannst du den PDA von einem anderen als dem aktuell ausgeführten Programm ableiten.

Die Constraints seeds und bump vereinfachen die Ableitung:

Code
#[derive(Accounts)]
struct ExamplePDA<'info> {
	#[account(seeds = [b"example"], bump)]
	pub example_pda: Account<'info, AccountType>,
}

Hier wird die Constraint seeds verwendet, um den PDA abzuleiten. Anchor prüft automatisch, ob der an die Instruktion übergebene Account dem aus den Seeds abgeleiteten PDA entspricht. Wenn die Bump-Constraint ohne bestimmten Wert verwendet wird, nutzt Anchor standardmäßig den kanonischen Bump.

Anchor erlaubt außerdem dynamische Seeds auf Grundlage anderer Account-Felder oder Instruktionsdaten. Dazu verweist du auf andere Felder innerhalb der Struktur oder verwendest das Attributmakro #[instruction(...)], um deserialisierte Instruktionsdaten einzuschließen. In der folgenden Struktur ist example_pda beispielsweise so eingeschränkt, dass eine Kombination aus einem statischen Seed, Instruktionsdaten und dem öffentlichen Schlüssel des Signers verwendet wird:

Code
#[derive(Accounts)]
#[instruction(instruction_data: String)]
pub struct ExamplePDA<'info> {
	#[account(seeds = [b"example", signor.key().as_ref(), instruction_data.as_bytes()], bump)]
 	pub example_pda: Account<'info, AccountType>,
	#[account(mut)]
	pub signoooorrr: Signer<'info>
}

Fazit

Anchor nur als leistungsstarkes Framework zu bezeichnen, wäre untertrieben. Die verschiedenen Makros und Traits, mit denen Anchor den Codeumfang reduziert, zeigen deutlich, wie stark es den Entwicklungsprozess vereinfacht. Dazu kommen eine sorgfältig gepflegte Dokumentation und ein robustes Ökosystem aus Tutorials und Crates. Die große Mehrheit der Solana-Entwickler nutzt und schätzt Anchor.

Dieser Artikel ist ein sehr, sehr umfassender Leitfaden für die Entwicklung von Programmen mit Anchor. Er behandelte die Installation von Anchor, die Verwendung von Solana Playground sowie das Erstellen, Bauen und Bereitstellen eines „Hello, World!“-Programms. Anschließend haben wir uns angesehen, wie Anchor effektiv abstrahiert, wie ein typisches Anchor-Programm aufgebaut ist und welche zahlreichen Account-Typen und Constraints verfügbar sind. Außerdem ging es darum, warum die Zuweisung von Account-Speicherplatz und die Fehlerbehandlung wichtig sind. Zum Abschluss haben wir CPIs und PDAs untersucht. Das ist der Artikel über Anchor – hier findest du alles, was du brauchst, um noch heute Programme auf Solana zu entwickeln.

Wenn du bis hierher gelesen hast: Danke, Anon! Gib unten deine E-Mail-Adresse ein, damit du keine Neuigkeiten zu Solana verpasst. Bereit, tiefer einzusteigen? Tritt unserem Discord bei und beginne mit der Entwicklung von Anchor-Programmen.

Weitere Ressourcen

Helius abonnieren

Bleib bei der Solana-Entwicklung auf dem Laufenden und erhalte Updates, wenn wir neue Beiträge veröffentlichen

Vergrößertes Bild