TUTORIAL
ZNAP Tutorial - Donate/Pay a user
Alice is a well-known influencer on Twitter. She usually shares research and valuable insights with her audience free of charge. She decides she wants to start a donation campaign for herself. She spins up a “Donate to me” Blink using the Actions Stack and shares the link to Sphere on Twitter.The link unfurls into an actionable Blink containing the following information:
- Her image, name, description
- 3 buttons: 1 SOL, 5 SOL, 10 SOL
- 4th button with an input field: “Enter a custom SOL amount”
Brief review
Solana Actions are APIs that adhere to the Solana Actions Specification. Our Solana Actions must be capable of returning:
- Action Metadata
- A transaction to be sent to the Solana blockchain.
Let’s Start!
First, we will create our workspace using znap-cli
. If you do not have znap-cli
installed, follow our guide here.
Next, let’s create a new project with znap init alice-campaign
.
You will then see a message like:
____
|\ \ ________ ________ ________ ________
\ \ \ |\_____ \|\ ___ \|\ __ \|\ __ \
_\_\ \ \|___/ /\ \ \\ \ \ \ \|\ \ \ \|\ \
|\ ___\ / / /\ \ \\ \ \ \ __ \ \ ____\
\ \ \ / /_/__\ \ \\ \ \ \ \ \ \ \ \___|
\ \ \ |\________\ \__\\ \__\ \__\ \__\ \__\
\ \ / \|_______|\|__| \|__|\|__|\|__|\|__|
\_\/
Someone is about to get some action...
No worries, we got you. ✨BLINK BLINK✨
You are about to create a Znap workspace named: alice-campaign
Added:
+ alice-campaign/Cargo.toml
+ alice-campaign/Znap.toml
+ alice-campaign/package.json
+ alice-campaign/tsconfig.json
+ alice-campaign/actions.json
+ alice-campaign/.gitignore
+ alice-campaign/.znap/.gitkeep
+ alice-campaign/collections/.gitkeep
+ alice-campaign/tests/utils.ts
+ alice-campaign/tests/e2e.ts
Znap workspace created at file:///home/<user>/<path>/alice-campaign
The command znap init
will create the following files and folder structure:
Cargo.toml
: Configuration file used by Cargo, the package management and build system for the Rust programming language.Znap.toml
: Indicates that you are in a “znap workspace”. In this file, there is an array of the collections that belong to that workspace.package.json
: This file is used by npm (Node Package Manager) to manage JavaScript dependencies in the project. It contains information about the project name, version, dependencies, and scripts. In Znap we use TypeScript to perform tests.tsconfig.json
: This file is used by TypeScript to configure the TypeScript compiler. It contains information about input files, compilation options, and module configuration.actions.json
: Enables you to configure the accessibility of your paths for external consumers through various rules, including Path Matching, Exact Match, and Wildcard Match..gitignore
: Specifies which files or directories should be ignored by Git..znap
: This folder is used to store files specific to the znap-cli. For example, when you run the serve command, certain files are generated in this folder..gitkeep
: Is used because Git does not push empty folders./collections
: All your created actions are stored here.utils.ts
: This file contains functions and utilities that can be used in tests.e2e.ts
: This file contains end-to-end tests to verify the project's functionality.
1. Let’s create our action collection for Alice’s campaign
In the root directory of your project, run the following command to create a collection: znap new donation-campaign
. This command will create a new action collection called ‘donation-campaign’ inside the /collections
folder, where we will place our actions for Alice.
You will then see a message like:
You are about to create a collection named: donation-campaign
Added:
+ collections/donation-campaign/Cargo.toml
+ collections/donation-campaign/src/lib.rs
Modified:
* ./Znap.toml
Collection created at file:///home/<>/<path>/alice-campaign/collections/donation-campaign
The command znap new
will create a folder with the previously specified name, containing two files:
/donation-campaign
Cargo.toml
: Configuration file used by Cargo, the package management and build system for the Rust programming language./src
lib.rs
: main entry point for libraries in Rust.
Now, let’s open our lib.rs
file where we will create our actions. The file will look like this:
use znap::prelude::*;
#[collection]
pub mod donation_campaign {
use super::*;
}
2. Let’s Import All Necessary Libraries
We will import three necessary libraries:
solana-sdk
: The base library for off-chain programs that interact with Solana and its data structures.std
: An essential collection of functionalities for working with Rust.znap
: A framework for building APIs compatible with the Solana Actions specification.
use solana_sdk::{
message::Message, native_token::LAMPORTS_PER_SOL, pubkey, pubkey::Pubkey,
system_instruction::transfer, transaction::Transaction,
};
use std::str::FromStr;
use znap::prelude::*;
#[collection]
pub mod donation_campaign {
use super::*;
}
3. Set Up Our Solana Action’s Metadata
Action Metadata: We must set up all the necessary information to comply with the Solana Actions Specification. This is crucial because clients need to correctly retrieve all the metadata (GET
Request) to create blinks using our Solana actions.
- Title: The action’s title
- Icon: The action’s icon
- Description: A brief description of the purpose of the action
- Label: The text for the main button shown to the user
- Link(s): Buttons or inputs where the user will be able to get a transaction to be sent to the blockchain (
POST
Request)
We will use the macros #[derive]
, #[action]
, #[query]
and the trait Action
to create our Solana Action’s metadata.
Query params: Similar to Anchor contexts, our query parameters allow our function to understand what information to work with and access to complete its purpose.
- Amount: The amount the user wishes to donate
use solana_sdk::{
message::Message, native_token::LAMPORTS_PER_SOL, pubkey, pubkey::Pubkey,
system_instruction::transfer, transaction::Transaction,
};
use std::str::FromStr;
use znap::prelude::*;
#[collection]
pub mod donation_campaign {
use super::*;
}
#[derive(Action)]
#[action(
icon = "https://media.discordapp.net/attachments/1205590693041541181/1212566609202520065/icon.png?ex=667eb568&is=667d63e8&hm=0f247078545828c0a5cf8300a5601c56bbc9b59d3d87a0c74b082df0f3a6d6bd&=&format=webp&quality=lossless&width=660&height=660",
title = "Alice's website",
description = "Website to make a donation to Alice",
label = "Send",
link = {
label = "Send 1 SOL",
href = "/api/send_donation?amount=1",
},
link = {
label = "Send 5 SOL",
href = "/api/send_donation?amount=5",
},
link = {
label = "Send 10 SOL",
href = "/api/send_donation?amount=10",
},
link = {
label = "Send SOL",
href = "/api/send_donation?amount={amount}",
parameter = { label = "Enter a custom SOL amount", name = "amount" }
},
)]
#[query(amount: u64)]
pub struct SendDonationAction;
4. Define Custom Errors
In this step, we will create a custom error message for our function in case the requester’s public key is incorrect.
#[derive(ErrorCode)]
enum ActionError {
#[error(msg = "Invalid account public key")]
InvalidAccountPublicKey,
}
At this point, you should have something like this:
use solana_sdk::{
message::Message, native_token::LAMPORTS_PER_SOL, pubkey, pubkey::Pubkey,
system_instruction::transfer, transaction::Transaction,
};
use std::str::FromStr;
use znap::prelude::*;
#[collection]
pub mod donation_campaign {
use super::*;
}
#[derive(Action)]
#[action(
icon = "https://media.discordapp.net/attachments/1205590693041541181/1212566609202520065/icon.png?ex=667eb568&is=667d63e8&hm=0f247078545828c0a5cf8300a5601c56bbc9b59d3d87a0c74b082df0f3a6d6bd&=&format=webp&quality=lossless&width=660&height=660",
title = "Alice's website",
description = "Website to make a donation to Alice",
label = "Send",
link = {
label = "Send 1 SOL",
href = "/api/send_donation?amount=1",
},
link = {
label = "Send 5 SOL",
href = "/api/send_donation?amount=5",
},
link = {
label = "Send 10 SOL",
href = "/api/send_donation?amount=10",
},
link = {
label = "Send SOL",
href = "/api/send_donation?amount={amount}",
parameter = { label = "Enter a custom SOL amount", name = "amount" }
},
)]
#[query(amount: u64)]
pub struct SendDonationAction;
#[derive(ErrorCode)]
enum ActionError {
#[error(msg = "Invalid account public key")]
InvalidAccountPublicKey,
}
5. Create a Transaction to be Sent
In this step, below use super::*;
we will create our function send_donation
, which will generate a transaction to be sent to the blockchain.
First, we declare our function with the parameter it will use, which we defined in the previous step.
pub fn send_donation(ctx: Context<SendDonationAction>) -> Result<ActionTransaction> {
}
Next, we obtain and validate the applicant’s public key. If it is not valid, we return an error.
pub fn send_donation(ctx: Context<SendDonationAction>) -> Result<ActionTransaction> {
let account_pubkey = Pubkey::from_str(&ctx.payload.account)
.or_else(|_| Err(Error::from(ActionError::InvalidAccountPublicKey)))?;
}
If the public key is valid, we set the public key of the receiver (Alice) and create the instructions for our transfer transaction.
To create the instructions, we use ctx.query.amount
to get the amount specified by the requester (client).
pub fn send_donation(ctx: Context<SendDonationAction>) -> Result<ActionTransaction> {
let account_pubkey = Pubkey::from_str(&ctx.payload.account)
.or_else(|_| Err(Error::from(ActionError::InvalidAccountPublicKey)))?;
let receiver_pubkey = pubkey!("6GBLiSwAPhDMttmdjo3wvEsssEnCiW3yZwVyVZnhFm3G");
let transfer_instruction = transfer(
&account_pubkey,
&receiver_pubkey,
ctx.query.amount * LAMPORTS_PER_SOL,
);
}
Finally, we create a message with our instructions to indicate that everything went well.
Then, we create and return an unsigned transaction with the instructions we generated.
pub fn send_donation(ctx: Context<SendDonationAction>) -> Result<ActionTransaction> {
let account_pubkey = Pubkey::from_str(&ctx.payload.account)
.or_else(|_| Err(Error::from(ActionError::InvalidAccountPublicKey)))?;
let receiver_pubkey = pubkey!("6GBLiSwAPhDMttmdjo3wvEsssEnCiW3yZwVyVZnhFm3G");
let transfer_instruction = transfer(
&account_pubkey,
&receiver_pubkey,
ctx.query.amount * LAMPORTS_PER_SOL,
);
let transaction_message = Message::new(&[transfer_instruction], None);
let transaction = Transaction::new_unsigned(transaction_message);
Ok(ActionTransaction {
transaction,
message: Some("Donation sent!".to_string()),
})
}
And that’s it! We did it.
Your final project should look like this:
use solana_sdk::{
message::Message, native_token::LAMPORTS_PER_SOL, pubkey, pubkey::Pubkey,
system_instruction::transfer, transaction::Transaction,
};
use std::str::FromStr;
use znap::prelude::*;
#[collection]
pub mod donation_campaign {
use super::*;
pub fn send_donation(ctx: Context<SendDonationAction>) -> Result<ActionTransaction> {
let account_pubkey = Pubkey::from_str(&ctx.payload.account)
.or_else(|_| Err(Error::from(ActionError::InvalidAccountPublicKey)))?;
let receiver_pubkey = pubkey!("6GBLiSwAPhDMttmdjo3wvEsssEnCiW3yZwVyVZnhFm3G");
let transfer_instruction = transfer(
&account_pubkey,
&receiver_pubkey,
ctx.query.amount * LAMPORTS_PER_SOL,
);
let transaction_message = Message::new(&[transfer_instruction], None);
let transaction = Transaction::new_unsigned(transaction_message);
Ok(ActionTransaction {
transaction,
message: Some("Donation sent!".to_string()),
})
}
}
#[derive(Action)]
#[action(
icon = "https://media.discordapp.net/attachments/1205590693041541181/1212566609202520065/icon.png?ex=667eb568&is=667d63e8&hm=0f247078545828c0a5cf8300a5601c56bbc9b59d3d87a0c74b082df0f3a6d6bd&=&format=webp&quality=lossless&width=660&height=660",
title = "Alice's website",
description = "Website to make a donation to Alice",
label = "Send",
link = {
label = "Send 1 SOL",
href = "/api/send_donation?amount=1",
},
link = {
label = "Send 5 SOL",
href = "/api/send_donation?amount=5",
},
link = {
label = "Send 10 SOL",
href = "/api/send_donation?amount=10",
},
link = {
label = "Send SOL",
href = "/api/send_donation?amount={amount}",
parameter = { label = "Enter a custom SOL amount", name = "amount" }
},
)]
#[query(amount: u64)]
pub struct SendDonationAction;
#[derive(ErrorCode)]
enum ActionError {
#[error(msg = "Invalid account public key")]
InvalidAccountPublicKey,
}
🎉 CONGRATULATIONS, YOU HAVE CREATED YOUR FIRST SOLANA ACTION WITH ZNAP 🎉
6. Let’s Test Our Action!
In the root of your terminal, run znap serve donation-campaign
This will start building your project, and once finished, it will start a server for your actions to be consumed.
If everything went well, you should see something like this:
✨ Znap Server ✨
Service is running at http://127.0.0.1:3000
[donation_campaign] endpoints:
GET /api/send_donation
POST /api/send_donation
💡 Press Ctrl+C to stop the server
GET Method
Now, if you consume your endpoint with a GET
method using the following commando
curl http://127.0.0.1:3000/api/send_donation
You will receive the metadata of your Solana Action.
{
"icon":"https://media.discordapp.net/attachments/1205590693041541181/1212566609202520065/icon.png?ex=667eb568&is=667d63e8&hm=0f247078545828c0a5cf8300a5601c56bbc9b59d3d87a0c74b082df0f3a6d6bd&=&format=webp&quality=lossless&width=660&height=660",
"title":"Alice's website",
"description":"Website to make a donation to Alice",
"label":"Send",
"links":{
"actions":[
{
"label":"Send 1 SOL",
"href":"/api/send_donation?amount=1",
"parameters":[
]
},
{
"label":"Send 5 SOL",
"href":"/api/send_donation?amount=5",
"parameters":[
]
},
{
"label":"Send 10 SOL",
"href":"/api/send_donation?amount=10",
"parameters":[
]
},
{
"label":"Send SOL",
"href":"/api/send_donation?amount={amount}",
"parameters":[
{
"label":"Enter a custom SOL amount",
"name":"amount",
"required":false
}
]
}
]
},
"disabled":false,
"error":null
}
POST Method
And if you consume your endpoint with a POST
method, setting the query parameter amount
to 1 or another number
curl -X POST -H "Content-Type: application/json" -d '{"account": "8byTaGAbftXnv7FWt6zhR8zTpy9t7fitFRgbtW7EWcAt"}' http://127.0.0.1:3000/api/send_donation?amount=1
Note: Replace the account
value with the public key of the wallet you want to donate SOL from.
You will receive an unsigned Solana transaction ready to be sent to the blockchain.
{
"transaction":"AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAQGcPdkuJpytnAJJPkZPmoN+AHwIoLTv85WOfeT7VJ+VQGEiwN3lFs03v5rMh14RkppSphcxxnqyxno9sGGLTe34QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABUpTWpkpIQZNJOhxYNo4fHw1td28kruB5B+oQEEFRI2dQeAIc0PFGKpcl+gDFc3oN7bjAe/AY7l/fQgPDoaHVKy/Eq9t3+pnqEttexDqyAmLyMTopTiLrMA94to6MUbimnbBgaj40d2YXO953qbEkUbxFkjCDinyfOZUn/AbC9UCAgQAAQQFDAIAAADoAwAAAAAAAAMAvwFzb2xhbmEtYWN0aW9uOkNkTDdOcGhnR3VKeDYxTmVVcDZQN29TNjd4VlZHQmlZN3JIVTVqWEhqdWJGOkJhc0diUUJKd0NWcWo4S2tmcExvZERxSmsxZmJ3MTJrZWJWNWZmUHZiUExGOmVuZ2hOcmkyaW9jeXltajlacVd0R0xqOFhaWEFXQ2NKb2Q0SFlGbWp2aHd3UEtNd3lCaWtWVEVxNXg5VE5OSmpNb2hESlRyY3FEdTZhRFN6S1VSWkNFaA==",
"message":"Donation sent!"
}