Gravitational plate of three masses and a slashed discABC0Static engraved plate. Three-dimensional view is unavailable or reduced motion is requested.

← back to fieldarticle

articleAug 1, 2022

Solana programs and Web3 API

Walkthrough of Solana web3.js patterns: connect, keypair, airdrop, transfer, deploy a hello-world program, create greeting accounts, and read/write on-chain state.

sol.png
sol.png

Solana programs and Web3 API

import type {NextApiRequest, NextApiResponse} from 'next';
import {getNodeURL} from '@figment-solana/lib';
import {Connection} from '@solana/web3.js';
 
export default async function connect(
  req: NextApiRequest,
  res: NextApiResponse<string>,
) {
  try {
    const {network} = req.body;
    const url = getNodeURL(network);
    const connection = new Connection(url);
    const version = await connection.getVersion();
    res.status(200).json(version['solana-core']);
  } catch (error) {
    let errorMessage = error instanceof Error ? error.message : 'Unknown Error';
    res.status(500).json(errorMessage);
  }
}
  • Third-party JSON-RPC endpoints act as the Web3 provider.
  • getNodeURL(network) returns the RPC URL; @solana/web3.js Connection talks to it and can report solana-core version.

Generate a keypair

import type {NextApiRequest, NextApiResponse} from 'next';
import {Keypair} from '@solana/web3.js';
 
type ResponseT = {
  secret: string;
  address: string;
};
export default function keypair(
  _req: NextApiRequest,
  res: NextApiResponse<string | ResponseT>,
) {
  try {
    const keypair = new Keypair();
    const address = keypair.publicKey.toString();
    const secret = JSON.stringify(Array.from(keypair.secretKey));
    res.status(200).json({
      secret,
      address,
    });
  } catch (error) {
    let errorMessage = error instanceof Error ? error.message : 'Unknown Error';
    res.status(500).json(errorMessage);
  }
}
 

Solana keypairs use Ed25519. The public key is the address; the secret key is the signing material.

SOL balance and airdrop

Token names and faucet behavior differ by network (mainnet vs testnet/devnet).

1 SOL = 1,000,000,000 lamports
import {Connection, PublicKey, LAMPORTS_PER_SOL} from '@solana/web3.js';
import type {NextApiRequest, NextApiResponse} from 'next';
import {getNodeURL} from '@figment-solana/lib';
 
export default async function fund(
  req: NextApiRequest,
  res: NextApiResponse<string>,
) {
  try {
    const {network, address} = req.body;
    const url = getNodeURL(network);
    const connection = new Connection(url, 'confirmed');
    const publicKey = new PublicKey(address);
    const hash = await connection.requestAirdrop(publicKey, LAMPORTS_PER_SOL);
    await connection.confirmTransaction(hash);
    res.status(200).json(hash);
  } catch (error) {
    let errorMessage = error instanceof Error ? error.message : 'Unknown Error';
    res.status(500).json(errorMessage);
  }
}
  • Build a PublicKey from the address.
  • Connection.requestAirdrop(pubkey, LAMPORTS_PER_SOL) requests 1 SOL.
  • Await confirmTransaction before treating the airdrop as settled.

Check balance

import type {NextApiRequest, NextApiResponse} from 'next';
import {Connection, PublicKey} from '@solana/web3.js';
import {getNodeURL} from '@figment-solana/lib';
 
export default async function balance(
  req: NextApiRequest,
  res: NextApiResponse<string | number>,
) {
  try {
    const {network, address} = req.body;
    const url = getNodeURL(network);
    const connection = new Connection(url, 'confirmed');
    const publicKey = new PublicKey(address);
    const balance = await connection.getBalance(publicKey);
    if (balance === 0 || balance === undefined) {
      throw new Error('Account not funded');
    }
    res.status(200).json(balance);
  } catch (error) {
    let errorMessage = error instanceof Error ? error.message : 'Unknown Error';
    res.status(500).json(errorMessage);
  }
}
  • Convert the address string to a PublicKey.
  • getBalance(pubkey) returns lamports.

Transfer SOL

Move lamports from a signed account to another address on the same cluster.

import type {NextApiRequest, NextApiResponse} from 'next';
import {getNodeURL} from '@figment-solana/lib';
import {
  Connection,
  PublicKey,
  SystemProgram,
  Transaction,
  sendAndConfirmTransaction,
} from '@solana/web3.js';
 
export default async function transfer(
  req: NextApiRequest,
  res: NextApiResponse<string>,
) {
  try {
    const {address, secret, recipient, lamports, network} = req.body;
    const url = getNodeURL(network);
    const connection = new Connection(url, 'confirmed');
 
    const fromPubkey = new PublicKey(address);
    const toPubkey = new PublicKey(recipient);
    const secretKey = Uint8Array.from(JSON.parse(secret as string));
    const instructions = SystemProgram.transfer({
      fromPubkey,
      toPubkey,
      lamports,
    });
    const signers = [
      {
        publicKey: fromPubkey,
        secretKey,
      },
    ];
    const transaction = new Transaction().add(instructions);
    const hash = await sendAndConfirmTransaction(
      connection,
      transaction,
      signers,
    );
 
    res.status(200).json(hash);
  } catch (error) {
    let errorMessage = error instanceof Error ? error.message : 'Unknown Error';
    res.status(500).json(errorMessage);
  }
}

SystemProgram

SystemProgram in @solana/web3.js is the factory for instructions that talk to the native system program.

SystemProgram.transfer

Builds a transfer instruction:

const instructions = SystemProgram.transfer({
      fromPubkey,
      toPubkey,
      lamports,
    });

Build a signers array with the payer public and secret keys:

    const signers = [
      {
        publicKey: fromPubkey,
        secretKey,
      },
    ];
 

Wrap the instruction in a Transaction:

const transaction = new Transaction().add(instructions);

Send and confirm

const hash = await sendAndConfirmTransaction(
      connection,
      transaction,
      signers,
    );

Solana TX

Deploy a simple program (lib.rs)

use solana_program::{
    account_info::{next_account_info, AccountInfo},
    entrypoint,
    entrypoint::ProgramResult,
    msg,
    program_error::ProgramError,
    pubkey::Pubkey,
};
  • borsh — Binary Object Representation Serializer for Hashing
  • solana_program crates: account_info, entrypoint, ProgramResult, msg!, ProgramError, Pubkey

GreetingAccount

#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct GreetingAccount {
    pub counter: u32,
}

Derive macros wire Borsh (de)serialization and Debug at compile time. The only field is counter: u32.

Program entrypoint

// Declare and export the program's entrypoint
entrypoint!(process_instruction);

process_instruction

// Program entrypoint's implementation
pub fn process_instruction(
    program_id: &Pubkey, // Public key of the account the hello world program was loaded into
    accounts: &[AccountInfo], // The account to say hello to
    _instruction_data: &[u8], // Ignored, all helloworld instructions are hellos
) -> ProgramResult {
    msg!("Hello World Rust program entrypoint");
 
    // Iterating accounts is safer than indexing
    let accounts_iter = &mut accounts.iter();
 
    // Get the account to say hello to
    let account = next_account_info(accounts_iter)?;
 
    // The account must be owned by the program in order to modify its data
    if account.owner != program_id {
        msg!("Greeted account does not have the correct program id");
        return Err(ProgramError::IncorrectProgramId);
    }
 
    // Increment and store the number of times the account has been greeted
    let mut greeting_account = GreetingAccount::try_from_slice(&account.data.borrow())?;
    greeting_account.counter += 1;
    greeting_account.serialize(&mut &mut account.data.borrow_mut()[..])?;
 
    msg!("Greeted {} time(s)!", greeting_account.counter);
 
    Ok(())
}
  • entrypoint’s implementation
  • retrun value ⇒ process_instruction entrypoint (ProgramResult {})
  • parms

program_id: &Pubkey,

  • reference ← Account Pubkey

accounts: &[AccountInfo],

  • Iterator Reference (accoutns_iter)
  • next_account_info(account_iter)? ⇒ AccountInfo or NotEnoughAccountKeys error
// Iterating accounts is safer than indexing
    let accounts_iter = &mut accounts.iter();
 
    // Get the account to say hello to
    let account = next_account_info(accounts_iter)?;

_instruction_data: &[u8]

Ignored here — every call is a hello.

// The account must be owned by the program in order to modify its data
if account.owner != program_id {
        msg!("Greeted account does not have the correct program id");
        return Err(ProgramError::IncorrectProgramId);
 }

Owner check: the greeting account must be owned by this program.

// Increment and store the number of times the account has been greeted
    let mut greeting_account = GreetingAccount::try_from_slice(&account.data.borrow())?;
    greeting_account.counter += 1;
    greeting_account.serialize(&mut &mut account.data.borrow_mut()[..])?;
 
    msg!("Greeted {} time(s)!", greeting_account.counter);
 
    Ok(())

Deserialize the account data into GreetingAccount, bump counter, then serialize it back into the account buffer.

Solana CLI: keypair and airdrop

  app [main] ⚡  solana-keygen new --outfile solana-wallet/keypair.json
Generating a new keypair
 
For added security, enter a BIP39 passphrase
 
NOTE! This passphrase improves security of the recovery seed phrase NOT the
keypair file itself, which is stored as insecure plain text
 
BIP39 Passphrase (empty for none):
 
Wrote new keypair to solana-wallet/keypair.json
=====================================================================
pubkey: <PUB-KEY>
=====================================================================
Save this seed phrase and your BIP39 passphrase to recover your new keypair:
jelly panel immense gadget task cat ship paper knock gap prevent just
=====================================================================
  app [main] ⚡  solana airdrop 1 $(solana-keygen pubkey solana-wallet/keypair.json)
Requesting airdrop of 1 SOL
 
Signature: <Signature>

Build and deploy

Building the program

yarn run solana:build:program
"solana:build:program": "cargo build-bpf --manifest-path=contracts/solana/program/Cargo.toml --bpf-out-dir=dist/solana/program",

The build emits a .so shared object.

$ solana program deploy /Users/zer0luck/project/app/dist/solana/program/helloworld.so

Deploying the program

  Contract [main] ⚡  solana deploy -v --keypair solana-wallet/keypair.json dist/solana/program/helloworld.so
RPC URL: <https://api.devnet.solana.com>
Default Signer Path: solana-wallet/keypair.json
Commitment: confirmed
Program Id: <ProgramID>
import type {NextApiRequest, NextApiResponse} from 'next';
import {Connection, PublicKey} from '@solana/web3.js';
import {getNodeURL} from '@figment-solana/lib';
import path from 'path';
import fs from 'mz/fs';
 
const PROGRAM_PATH = path.resolve('dist/solana/program');
const PROGRAM_SO_PATH = path.join(PROGRAM_PATH, 'helloworld.so');
 
export default async function deploy(
  req: NextApiRequest,
  res: NextApiResponse<string | boolean>,
) {
  try {
    const {network, programId} = req.body;
    const url = getNodeURL(network);
    const connection = new Connection(url, 'confirmed');
    // Re-create publicKeys from params
    const publicKey = new PublicKey(programId);
    const programInfo = await connection.getAccountInfo(publicKey);
 
    if (programInfo === null) {
      if (fs.existsSync(PROGRAM_SO_PATH)) {
        throw new Error(
          'Program needs to be deployed with `solana program deploy`',
        );
      } else {
        throw new Error('Program needs to be built and deployed');
      }
    } else if (!programInfo.executable) {
      throw new Error(`Program is not executable`);
    }
 
    res.status(200).json(true);
  } catch (error) {
    let errorMessage = error instanceof Error ? error.message : 'Unknown Error';
    res.status(500).json(errorMessage);
  }
}

getAccountInfo(programId) must return an executable account after a successful deploy.

Create program storage

Solana programs do not hold app state in the program account itself. Allocate a separate data account owned by the program:

import {
  Connection,
  PublicKey,
  Keypair,
  SystemProgram,
  Transaction,
  sendAndConfirmTransaction,
} from '@solana/web3.js';
import type {NextApiRequest, NextApiResponse} from 'next';
import {getNodeURL} from '@figment-solana/lib';
import * as borsh from 'borsh';
 
// The state of a greeting account managed by the hello world program
class GreetingAccount {
  counter = 0;
  constructor(fields: {counter: number} | undefined = undefined) {
    if (fields) {
      this.counter = fields.counter;
    }
  }
}
 
// Borsh schema definition for greeting accounts
const GreetingSchema = new Map([
  [GreetingAccount, {kind: 'struct', fields: [['counter', 'u32']]}],
]);
 
// The expected size of each greeting account.
const GREETING_SIZE = borsh.serialize(
  GreetingSchema,
  new GreetingAccount(),
).length;
 
type ResponseT = {
  hash: string;
  greeter: string;
};
export default async function greeter(
  req: NextApiRequest,
  res: NextApiResponse<string | ResponseT>,
) {
  try {
    const {network, secret, programId: programAddress} = req.body;
    const url = getNodeURL(network);
    const connection = new Connection(url, 'confirmed');
 
    const programId = new PublicKey(programAddress);
    const payer = Keypair.fromSecretKey(new Uint8Array(JSON.parse(secret)));
    const GREETING_SEED = 'hello';
 
    // Are there any methods from PublicKey to derive a public key from a seed?
    const greetedPubkey = await PublicKey.createWithSeed(
      payer.publicKey,
      GREETING_SEED,
      programId,
    );
 
    // This function calculates the fees we have to pay to keep the newly
    // created account alive on the blockchain. We're naming it lamports because
    // that is the denomination of the amount being returned by the function.
    const lamports = await connection.getMinimumBalanceForRentExemption(
      GREETING_SIZE,
    );
 
    // Find which instructions are expected and complete SystemProgram with
    // the required arguments.
    const transaction = new Transaction().add(
      SystemProgram.createAccountWithSeed({
        fromPubkey: payer.publicKey,
        basePubkey: payer.publicKey,
        seed: GREETING_SEED,
        newAccountPubkey: greetedPubkey,
        lamports: lamports,
        space: GREETING_SIZE,
        programId,
      }),
    );
 
    // Complete this function call with the expected arguments.
    const hash = await sendAndConfirmTransaction(connection, transaction, [
      payer,
    ]);
    res.status(200).json({
      hash: hash,
      greeter: greetedPubkey.toBase58(),
    });
  } catch (error) {
    let errorMessage = error instanceof Error ? error.message : 'Unknown Error';
    res.status(500).json(errorMessage);
  }
}

Derive the greeting pubkey

const programId = new PublicKey(programAddress);
const payer = Keypair.fromSecretKey(new Uint8Array(JSON.parse(secret)));
const GREETING_SEED = 'hello';
 
// Are there any methods from PublicKey to derive a public key from a seed?
const greetedPubkey = await PublicKey.createWithSeed(
  payer.publicKey,
  GREETING_SEED,
  programId,
);

PublicKey.createWithSeed(base, seed, programId) derives the new account address.

const lamports = await connection.getMinimumBalanceForRentExemption(
  GREETING_SIZE,
);
 
// Find which instructions are expected and complete SystemProgram with
// the required arguments.
const transaction = new Transaction().add(
  SystemProgram.createAccountWithSeed({
    fromPubkey: payer.publicKey,
    basePubkey: payer.publicKey,
    seed: GREETING_SEED,
    newAccountPubkey: greetedPubkey,
    lamports: lamports,
    space: GREETING_SIZE,
    programId,
  }),
);
  • fromPubkey — pays for the create
  • basePubkey / seed — inputs to the derived address
  • lamports — rent-exempt minimum for GREETING_SIZE
  • space — allocated byte length
  • programId — owner of the new account
  • newAccountPubkey — result of createWithSeed

Read program data

Deserialize the on-chain buffer with the same Borsh schema:

import type {NextApiRequest, NextApiResponse} from 'next';
import {Connection, PublicKey} from '@solana/web3.js';
import {getNodeURL} from '@figment-solana/lib';
import * as borsh from 'borsh';
 
// The state of a greeting account managed by the hello world program
class GreetingAccount {
  counter = 0;
  constructor(fields: {counter: number} | undefined = undefined) {
    if (fields) {
      this.counter = fields.counter;
    }
  }
}
 
// Borsh schema definition for greeting accounts
const GreetingSchema = new Map([
  [GreetingAccount, {kind: 'struct', fields: [['counter', 'u32']]}],
]);
 
export default async function getter(
  req: NextApiRequest,
  res: NextApiResponse<string | number>,
) {
  try {
    const {network, greeter} = req.body;
    const url = getNodeURL(network);
    const connection = new Connection(url, 'confirmed');
    const greeterPublicKey = new PublicKey(greeter);
 
    const accountInfo = await connection.getAccountInfo(greeterPublicKey);
 
    if (accountInfo === null) {
      throw new Error('Error: cannot find the greeted account');
    }
 
    // Find the expected parameters.
    // const value = new Test({ x: 255, y: 20, z: '123', q: [1, 2, 3] });
    const greeting = borsh.deserialize(
      GreetingSchema,
      GreetingAccount,
      accountInfo.data,
    );
 
    // A little helper
    console.log(greeting);
 
    // Pass the counter to the client-side as JSON
    res.status(200).json(greeting.counter);
  } catch (error) {
    let errorMessage = error instanceof Error ? error.message : 'Unknown Error';
    console.log(errorMessage);
    res.status(500).json(errorMessage);
  }
}

borsh.deserialize(schema, class, buffer) turns account bytes into a GreetingAccount.

Call the program (increment)

import {
  Connection,
  PublicKey,
  Keypair,
  TransactionInstruction,
  Transaction,
  sendAndConfirmTransaction,
} from '@solana/web3.js';
import type {NextApiRequest, NextApiResponse} from 'next';
import {getNodeURL} from '@figment-solana/lib';
 
export default async function setter(
  req: NextApiRequest,
  res: NextApiResponse<string>,
) {
  try {
    const {greeter, secret, programId, network} = req.body;
    const url = getNodeURL(network);
    const connection = new Connection(url, 'confirmed');
 
    const greeterPublicKey = new PublicKey(greeter);
    const programKey = new PublicKey(programId);
 
    const payerSecretKey = new Uint8Array(JSON.parse(secret));
    const payerKeypair = Keypair.fromSecretKey(payerSecretKey);
 
    // this your turn to figure out
    // how to create this instruction
    const instruction = new TransactionInstruction({
      keys: [{pubkey: greeterPublicKey, isSigner: false, isWritable: true}],
      programId: programKey,
      data: Buffer.alloc(0), // All instructions are hellos
    });
    const hash = await sendAndConfirmTransaction(
      connection,
      new Transaction().add(instruction),
      [payerKeypair],
    );
 
    res.status(200).json(hash);
  } catch (error) {
    console.error(error);
    res.status(500).json('Get balance failed');
  }
}
const instruction = new TransactionInstruction({
  keys: [{pubkey: greeterPublicKey, isSigner: false, isWritable: true}],
  programId: programKey,
  data: Buffer.alloc(0), // All instructions are hellos
});
const hash = await sendAndConfirmTransaction(
  connection,
  new Transaction().add(instruction),
  [payerKeypair],
);

The greeting account is writable but not a signer. Instruction data is empty because every call is a hello.

Other Resources

related

  1. Jul 31, 2022/articleSolana Program Derived Addresses
  2. Jul 31, 2022/articleSolana on-chain programs
  3. Jul 31, 2022/articleSolana accounts model

graphfeed