Creating “mappings” and “nested mapping” in Solana

"Mappings" and "Nested Mappings" in Solana

In the previous tutorials, the seeds=[] parameter was always empty. If we put data into it, it behaves like a key or keys in a Solidity mapping.

Consider the following example:

contract ExampleMapping {

    struct SomeNum {
        uint64 num;
    }

    mapping(uint64 => SomeNum) public exampleMap;

    function setExampleMap(uint64 key, uint64 val) public {
        exampleMap[key] = SomeNum(val);
    }
}

We now create a Solana Anchor program example_map.

Initializing a mapping: Rust

At first, we will only show the initialization step because it will introduce some new syntax we need to explain.

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("DntexDPByFxpVeBSjd6nLqQQSqZmSaDkP8TUbcJ9jAgt");

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

    pub fn initialize(ctx: Context<Initialize>, key: u64) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
#[instruction(key: u64)]
pub struct Initialize<'info> {

    #[account(init,
              payer = signer,
              space = size_of::<Val>() + 8,
              seeds=[&key.to_le_bytes().as_ref()],
              bump)]
    val: Account<'info, Val>,
    
    #[account(mut)]
    signer: Signer<'info>,
    
    system_program: Program<'info, System>,
}

#[account]
pub struct Val {
    value: u64,
}

Here’s how you can think of the map:

The seeds parameter key in &key.to_le_bytes().as_ref() can be thought of as a “key” to the map similar to the Solidity construction:

mapping(uint256 => uint256) myMap;
myMap[key] = val

The unfamiliar parts of the code are #[instruction(key: u64)] and seeds=[&key.to_le_bytes().as_ref()].

seeds = [&key.to_le_bytes().as_ref()]

The items in seeds are expected to be bytes. However, we are passing in a u64 which is not of type bytes. To convert it to bytes, we use to_le_bytes(). The “le” means “little endian“. Seeds do not have to be encoded as little endian bytes, we just chose that for this example. Big endian works too as long as you are consistent. To convert to big endian, we would have used to_be_bytes().

[instruction(key: u64)]

In order to “pass” the function argument key in initialize(ctx: Context<Initialize>, key: u64) we need to use the instruction macro, otherwise our init macro has no way to “see” the key argument from initialize.

Initializing a mapping: Typescript

The code below shows how to initialize the account:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ExampleMap } from "../target/types/example_map";

describe("example_map", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.ExampleMap as Program<ExampleMap>;

  it("Initialize mapping storage", async () => {
    const key = new anchor.BN(42);
    const seeds = [key.toArrayLike(Buffer, "le", 8)];

    let valueAccount = anchor.web3.PublicKey.findProgramAddressSync(
      seeds,
      program.programId
    )[0];

    await program.methods.initialize(key).accounts({val: valueAccount}).rpc();
  });
});

The code key.toArrayLike(Buffer, "le", 8) specifies that we are trying to create a bytes buffer of size 8 bytes using the value from key. We chose 8 bytes because our key is 64 bits, and 64 bits is 8 bytes. The “le” is little endian so that we match the Rust code.

Each “value” in the mapping is a separate account and must be initialized separately.

Set a mapping: Rust

The additional Rust code we need to set the value. All the syntax here should be familiar.

// inside the #[program] module
pub fn set(ctx: Context<Set>, key: u64, val: u64) -> Result<()> {
    ctx.accounts.val.value = val;
    Ok(())
}

//...

#[derive(Accounts)]
#[instruction(key: u64)]
pub struct Set<'info> {
    #[account(mut)]
    val: Account<'info, Val>,
}

Set and read a mapping: Typescript

Because we derive the account address where the value is stored in the client (Typescript), we read and write from it just like we do with accounts that have the seeds array empty. The syntax for reading the Solana account data and writing is identical to previous tutorials:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ExampleMap } from "../target/types/example_map";

describe("example_map", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.ExampleMap as Program<ExampleMap>;

  it("Initialize and set value", async () => {
    const key = new anchor.BN(42);
    const value = new anchor.BN(1337);

    const seeds = [key.toArrayLike(Buffer, "le", 8)];
    let valueAccount = anchor.web3.PublicKey.findProgramAddressSync(
      seeds,
      program.programId
    )[0];

    await program.methods.initialize(key).accounts({val: valueAccount}).rpc();

    // set the account
    await program.methods.set(key, value).accounts({val: valueAccount}).rpc();

    // read the account back
    let result = await program.account.val.fetch(valueAccount);

    console.log(`the value ${result.value} was stored in ${valueAccount.toBase58()}`);
  });
});

Clarifying “nested mappings”

In languages like Python or JavaScript, a true nested mapping is a hashmap that points to another hash map.

In Solidity however, “nested mappings” are only a single map with multiple keys behaving as if they are one key.

In a “true” nested mapping, you can provide only the first key and get another hashmap returned to you.

Solidity “nested mappings” are not “true” nested mappings: you cannot supply one key and get a map back: you must provide all the keys and get the final result.

If you use seeds to simulate nested mappings similar to Solidity, you will face the same restriction. You must supply all of the seeds — Solana will not accept only one seed.

Initializing a nested mapping: Rust

The seeds array can hold as many items as we like, similar to a nested mapping in Solidity. It is of course subject to compute limits imposed on each of transaction. The code to do the initialization and setting are shown below.

We do not need any special syntax to do this, it’s just a matter of taking more function arguments and putting more items in seeds, so we will show the complete code without further explanation.

Rust nested mapping

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("DntexDPByFxpVeBSjd6nLqQQSqZmSaDkP8TUbcJ9jAgt");

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

    pub fn initialize(ctx: Context<Initialize>, key1: u64, key2: u64) -> Result<()> {
        Ok(())
    }

    pub fn set(ctx: Context<Set>, key1: u64, key2: u64, val: u64) -> Result<()> {
        ctx.accounts.val.value = val;
        Ok(())
    }
}

#[derive(Accounts)]
#[instruction(key1: u64, key2: u64)] // new key args added
pub struct Initialize<'info> {

    #[account(init,
              payer = signer,
              space = size_of::<Val>() + 8,
              seeds=[&key1.to_le_bytes().as_ref(), &key2.to_le_bytes().as_ref()], // 2 seeds
              bump)]
    val: Account<'info, Val>,
    
    #[account(mut)]
    signer: Signer<'info>,
    
    system_program: Program<'info, System>,
}

#[derive(Accounts)]
#[instruction(key1: u64, key2: u64)] // new key args added
pub struct Set<'info> {
    #[account(mut)]
    val: Account<'info, Val>,
}

#[account]
pub struct Val {
    value: u64,
}

Typescript nested mapping

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ExampleMap } from "../target/types/example_map";

describe("example_map", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.ExampleMap as Program<ExampleMap>;

  it("Initialize and set value", async () => {
    // we now have two keys
    const key1 = new anchor.BN(42);
    const key2 = new anchor.BN(43);
    const value = new anchor.BN(1337);

    // seeds has two values
    const seeds = [key1.toArrayLike(Buffer, "le", 8), key2.toArrayLike(Buffer, "le", 8)];
    let valueAccount = anchor.web3.PublicKey.findProgramAddressSync(
      seeds,
      program.programId
    )[0];

    // functions now take two keys
    await program.methods.initialize(key1, key2).accounts({val: valueAccount}).rpc();
    await program.methods.set(key1, key2, value).accounts({val: valueAccount}).rpc();

    // read the account back
    let result = await program.account.val.fetch(valueAccount);
    console.log(`the value ${result.value} was stored in ${valueAccount.toBase58()}`);
  });
});

Exercise: Modify the above code to form a nested mapping that takes three keys.

Initializing more than one map

A straightforward way to accomplish having more than one map is to add another variable to the seeds array and treat it as a way to “index” the first map, second map, and so forth.

The following code shows an example of initializing which_map which only holds one key.

#[derive(Accounts)]
#[instruction(which_map: u64, key: u64)]
pub struct InitializeMap<'info> {

    #[account(init,
              payer = signer,
              space = size_of::<Val1>() + 8,
              seeds=[&which_map.to_le_bytes().as_ref(), &key.to_le_bytes().as_ref()],
              bump)]
    val: Account<'info, Val1>,

    #[account(mut)]
    signer: Signer<'info>,

    system_program: Program<'info, System>,
}

Exercise: Complete the Rust and Typescript code to create a program that has two mappings: the first one with a single key and second one with two keys. Think about how to turn a two level map into a single level map when the first map is specified.

Learn Solana with RareSkills

See our Solana course to see the rest of our Solana tutorials.

Originally Published February, 27, 2024

Cross Program Invocation In Anchor

Cross Program Invocation In Anchor Cross Program Invocation (CPI) is Solana’s terminology for a program calling the public function of another program. We’ve already done CPI before when we sent a transfer SOL transaction to the system program. Here is the relevant snippet by way of reminder: pub fn send_sol(ctx: Context<SendSol>, amount: u64) -> Result<()> […]

Reading Another Anchor Program’s Account Data On Chain

Reading Another Anchor Program’s Account Data On Chain In Solidity, reading another contract’s storage requires calling a view function or the storage variable being public. In Solana, an off-chain client can read a storage account directly. This tutorial shows how an on-chain Solana program can read the data in an account it does not own. […]

#[derive(Accounts)] in Anchor: different kinds of accounts

[derive(Accounts)] in Anchor: different kinds of accounts #[derive(Accounts)] in Solana Anchor is an attribute-like macro for structs that holds references to all the accounts the function will access during its execution. In Solana, every account the transaction will access must be specified in advance One reason Solana is so fast is that it executes transactions […]

Modifying accounts using different signers

Modifying accounts using different signers In our Solana tutorials thus far, we’ve only had one account initialize and write to the account. In practice, this is very restrictive. For example, if user Alice is transferring points to Bob, Alice must be able to write to an account initialized by user Bob. In this tutorial we […]