Mapeamentos de Conta

Mapeamentos são estruturas de dados que usamos frequentemente na programação para associar uma chave com um valor de algum tipo. A chave e o valor podem ser de qualquer tipo arbitrário e a chave atua como um identificador para um determinado valor que está sendo salvo. Em seguida, dada sua chave, nos é permitido inserir, recuperar e atualizar esses valores de forma eficiente.

O modelo de conta da Solana, como sabemos, requer que os dados do programa e seus dados de estado relevantes sejam armazenados em contas diferentes. Essas contas têm um endereço associado a elas. Isso, por si só, atua como um mapeamento! Saiba mais sobre o modelo de conta da Solana aquiopen in new window.

Portanto, faria sentido armazenar seus valores em contas separadas, com seu endereço sendo a chave necessária para recuperar o valor. Mas isso traz algumas questões, como:

  • Os endereços mencionados acima provavelmente não serão chaves ideais, que você pode lembrar e recuperar o valor necessário.

  • Os endereços mencionados acima referem-se a chaves públicas de diferentes pares de chaves, ou Keypairs, em que cada chave pública (ou endereço) teria uma chave privada associada a ela também. Essa chave privada seria necessária para assinar diferentes instruções quando necessário, exigindo que armazenemos a chave privada em algum lugar, o que definitivamente não é recomendado!

Isso apresenta um problema que muitos desenvolvedores da Solana enfrentam, que é implementar uma lógica semelhante a um Map em seus programas. Vamos analisar algumas maneiras de como lidar com esse problema.

Derivando PDAs

PDA significa Endereços Derivados de Programaopen in new window, que são, em resumo, endereços derivados de um conjunto de sementes (seeds) e um ID do programa (ou endereço).

O aspecto único dos PDAs é que esses endereços não estão associados a nenhuma chave privada. Isso ocorre porque esses endereços não estão na curva ED25519. Portanto, apenas o programa do qual este endereço foi derivado pode assinar uma instrução com a chave, desde que as sementes também sejam fornecidas. Saiba mais sobre isso aquiopen in new window.

Agora que temos uma ideia do que são os PDAs, vamos usá-los para mapear algumas contas! Vamos usar um exemplo de um programa de blog para demonstrar como isso seria implementado.

Neste programa de blog, gostaríamos que cada usuário (User) tivesse um único blog. Este blog pode ter qualquer número de Posts. Isso significa que estamos mapeando cada usuário para um blog e cada postagem é mapeada para um determinado blog.

Em resumo, há uma correspondência 1:1 entre um usuário e seu blog, enquanto uma correspondência 1:N entre um blog e suas postagens.

Para a correspondência 1:1, gostaríamos que o endereço de um blog fosse derivado apenas do seu usuário, o que nos permitiria recuperar um blog, dada sua autoridade (ou usuário). Portanto, as sementes para um blog consistiriam na chave de sua autoridade e possivelmente um prefixo de "blog", para atuar como um identificador de tipo.

Para a correspondência 1:N, gostaríamos que o endereço de cada postagem fosse derivado não apenas do blog com o qual está associado, mas também de outro identificador, permitindo-nos diferenciar entre as N postagens no blog. No exemplo abaixo, o endereço de cada postagem é derivado da chave do blog, um slug para identificar cada postagem e um prefixo de "post", para atuar como um identificador de tipo.

O código é mostrado abaixo:

Press </> button to view full source
use anchor_lang::prelude::*;

declare_id!("2vD2HBhLnkcYcKxnxLjFYXokHdcsgJnyEXGnSpAX376e");

#[program]
pub mod mapping_pda {
    use super::*;
    pub fn initialize_blog(ctx: Context<InitializeBlog>, _blog_account_bump: u8, blog: Blog) -> ProgramResult {
        ctx.accounts.blog_account.set_inner(blog);
        Ok(())
    }

    pub fn create_post(ctx: Context<CreatePost>, _post_account_bump: u8, post: Post) -> ProgramResult {
        if (post.title.len() > 20) || (post.content.len() > 50) {
            return Err(ErrorCode::InvalidContentOrTitle.into());
        }

        ctx.accounts.post_account.set_inner(post);
        ctx.accounts.blog_account.post_count += 1;

        Ok(())
    }
}

#[derive(Accounts)]
#[instruction(blog_account_bump: u8)]
pub struct InitializeBlog<'info> {
    #[account(
        init,
        seeds = [
            b"blog".as_ref(),
            authority.key().as_ref()
        ],
        bump = blog_account_bump,
        payer = authority,
        space = Blog::LEN
    )]
    pub blog_account: Account<'info, Blog>,

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

    pub system_program: Program<'info, System>
}

#[derive(Accounts)]
#[instruction(post_account_bump: u8, post: Post)]
pub struct CreatePost<'info> {
    #[account(mut, has_one = authority)]
    pub blog_account: Account<'info, Blog>,

    #[account(
        init,
        seeds = [
            b"post".as_ref(),
            blog_account.key().as_ref(),
            post.slug.as_ref(),
        ],
        bump = post_account_bump,
        payer = authority,
        space = Post::LEN
    )]
    pub post_account: Account<'info, Post>,

    #[account(mut)]
    pub authority: Signer<'info>,
    
    pub system_program: Program<'info, System>
}

#[account]
pub struct Blog {
    pub authority: Pubkey,
    pub bump: u8,
    pub post_count: u8,
}

#[account]
pub struct Post {
    pub author: Pubkey,
    pub slug: String, // 10 characters max
    pub title: String, // 20 characters max
    pub content: String // 50 characters max
}

impl Blog {
    const LEN: usize = 8 + 32 + 1 + (4 + (10 * 32));
}

impl Post {
    const LEN: usize = 8 + 32 + 32 + (4 + 10) + (4 + 20) + (4 + 50); 
}

#[error]
pub enum ErrorCode {
    #[msg("Invalid Content or Title.")]
    InvalidContentOrTitle,
}
use std::convert::TryInto;
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{
    sysvar::{rent::Rent, Sysvar},
    borsh::try_from_slice_unchecked,
    account_info::{AccountInfo, next_account_info},
    entrypoint,
    entrypoint::ProgramResult, 
    pubkey::Pubkey, 
    msg,
    program_error::ProgramError, system_instruction, program::invoke_signed,
};
use thiserror::Error;


entrypoint!(process_instruction);
fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    Processor::process(program_id, accounts, instruction_data)
}

pub enum BlogInstruction {

    /// Accounts expected:
    /// 
    /// 0. `[signer]` User account who is creating the blog
    /// 1. `[writable]` Blog account derived from PDA
    /// 2. `[]` The System Program
    InitBlog {},

    /// Accounts expected:
    /// 
    /// 0. `[signer]` User account who is creating the post
    /// 1. `[writable]` Blog account for which post is being created
    /// 2. `[writable]` Post account derived from PDA
    /// 3. `[]` System Program
    CreatePost {
        slug: String,
        title: String,
        content: String,
    }
}

pub struct Processor;
impl Processor {
    pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8]) -> ProgramResult {
        
        let instruction = BlogInstruction::unpack(instruction_data)?;

        match instruction {
            BlogInstruction::InitBlog {} => {
                msg!("Instruction: InitBlog");
                Self::process_init_blog(accounts, program_id)
            },
            BlogInstruction::CreatePost { slug, title, content} => {
                msg!("Instruction: CreatePost");
                Self::process_create_post(accounts, slug, title, content, program_id)
            }
        }
    }

    fn process_create_post(
        accounts: &[AccountInfo],
        slug: String,
        title: String,
        content: String,
        program_id: &Pubkey
    ) -> ProgramResult {
        if slug.len() > 10 || content.len() > 20 || title.len() > 50 {
            return Err(BlogError::InvalidPostData.into())
        }

        let account_info_iter = &mut accounts.iter();

        let authority_account = next_account_info(account_info_iter)?;
        let blog_account = next_account_info(account_info_iter)?;
        let post_account = next_account_info(account_info_iter)?;
        let system_program = next_account_info(account_info_iter)?;

        if !authority_account.is_signer {
            return Err(ProgramError::MissingRequiredSignature);
        }

        let (blog_pda, _blog_bump) = Pubkey::find_program_address(
            &[b"blog".as_ref(), authority_account.key.as_ref()],
            program_id
        );
        if blog_pda != *blog_account.key || !blog_account.is_writable || blog_account.data_is_empty() {
            return Err(BlogError::InvalidBlogAccount.into())
        }

        let (post_pda, post_bump) = Pubkey::find_program_address(
            &[b"post".as_ref(), slug.as_ref(), authority_account.key.as_ref()],
            program_id
        );
        if post_pda != *post_account.key {
            return Err(BlogError::InvalidPostAccount.into())
        }

        let post_len: usize = 32 + 32 + 1 + (4 + slug.len()) + (4 + title.len()) + (4 + content.len());

        let rent = Rent::get()?;
        let rent_lamports = rent.minimum_balance(post_len);

        let create_post_pda_ix = &system_instruction::create_account(
            authority_account.key,
            post_account.key,
            rent_lamports,
            post_len.try_into().unwrap(),
            program_id
        );
        msg!("Creating post account!");
        invoke_signed(
            create_post_pda_ix, 
            &[
                authority_account.clone(),
                post_account.clone(),
                system_program.clone()
            ],
            &[&[
                b"post".as_ref(),
                slug.as_ref(),
                authority_account.key.as_ref(),
                &[post_bump]
            ]]
        )?;

        let mut post_account_state = try_from_slice_unchecked::<Post>(&post_account.data.borrow()).unwrap();
        post_account_state.author = *authority_account.key;
        post_account_state.blog = *blog_account.key;
        post_account_state.bump = post_bump;
        post_account_state.slug = slug;
        post_account_state.title = title;
        post_account_state.content = content;

        msg!("Serializing Post data");
        post_account_state.serialize(&mut &mut post_account.data.borrow_mut()[..])?;


        let mut blog_account_state = Blog::try_from_slice(&blog_account.data.borrow())?;
        blog_account_state.post_count += 1;

        msg!("Serializing Blog data");
        blog_account_state.serialize(&mut &mut blog_account.data.borrow_mut()[..])?;

        Ok(())
    }

    fn process_init_blog(
        accounts: &[AccountInfo],
        program_id: &Pubkey
    ) -> ProgramResult {
        let account_info_iter = &mut accounts.iter();
        
        let authority_account = next_account_info(account_info_iter)?;
        let blog_account = next_account_info(account_info_iter)?;
        let system_program = next_account_info(account_info_iter)?;

        if !authority_account.is_signer {
            return Err(ProgramError::MissingRequiredSignature);
        }

        let (blog_pda, blog_bump) = Pubkey::find_program_address(
            &[b"blog".as_ref(), authority_account.key.as_ref()],
            program_id 
        );
        if blog_pda != *blog_account.key {
            return Err(BlogError::InvalidBlogAccount.into())
        }

        let rent = Rent::get()?;
        let rent_lamports = rent.minimum_balance(Blog::LEN);
        
        let create_blog_pda_ix = &system_instruction::create_account(
            authority_account.key,
            blog_account.key,
            rent_lamports,
            Blog::LEN.try_into().unwrap(),
            program_id
        );
        msg!("Creating blog account!");
        invoke_signed(
            create_blog_pda_ix, 
            &[
                authority_account.clone(),
                blog_account.clone(),
                system_program.clone()
            ],
            &[&[
                b"blog".as_ref(),
                authority_account.key.as_ref(),
                &[blog_bump]
            ]]
        )?;

        let mut blog_account_state = Blog::try_from_slice(&blog_account.data.borrow())?;
        blog_account_state.authority = *authority_account.key;
        blog_account_state.bump = blog_bump;
        blog_account_state.post_count = 0;
        blog_account_state.serialize(&mut &mut blog_account.data.borrow_mut()[..])?;
        

        Ok(())
    }
}



#[derive(BorshDeserialize, Debug)]
struct PostIxPayload {
    slug: String,
    title: String,
    content: String
}


impl BlogInstruction {
    pub fn unpack(input: &[u8]) -> Result<Self, ProgramError> {
        let (variant, rest) = input.split_first().ok_or(BlogError::InvalidInstruction)?;
        let payload = PostIxPayload::try_from_slice(rest).unwrap();

        Ok(match variant {
            0 => Self::InitBlog {},
            1 => Self::CreatePost {
                slug: payload.slug,
                title: payload.title,
                content: payload.content
            },
            _ => return Err(BlogError::InvalidInstruction.into()),
        })
    }
}

#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
pub struct Blog {
    pub authority: Pubkey,
    pub bump: u8,
    pub post_count: u8 // 10 posts max
}

#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
pub struct Post {
    pub author: Pubkey,
    pub blog: Pubkey,
    pub bump: u8,
    pub slug: String, // 10 chars max
    pub title: String, // 20 chars max
    pub content: String, // 50 chars max
}

impl Blog {
    pub const LEN: usize = 32 + 1 + 1;
}

#[derive(Error, Debug, Copy, Clone)]
pub enum BlogError {
    #[error("Invalid Instruction")]
    InvalidInstruction,

    #[error("Invalid Blog Account")]
    InvalidBlogAccount,

    #[error("Invalid Post Account")]
    InvalidPostAccount,

    #[error("Invalid Post Data")]
    InvalidPostData,

    #[error("Account not Writable")]
    AccountNotWritable,
}

impl From<BlogError> for ProgramError {
    fn from(e: BlogError) -> Self {
        return ProgramError::Custom(e as u32);
    }
}

No lado do cliente, você pode usar PublicKey.findProgramAddress() para obter os endereços da conta do Blog e do Post necessários, que você pode passar para connection.getAccountInfo() para buscar os dados da conta. Um exemplo é mostrado abaixo:

Press </> button to view full source
import * as borsh from "@project-serum/borsh";
import { PublicKey } from "@solana/web3.js";

export const BLOG_ACCOUNT_DATA_LAYOUT = borsh.struct([
  borsh.publicKey("authorityPubkey"),
  borsh.u8("bump"),
  borsh.u8("postCount"),
]);

export const POST_ACCOUNT_DATA_LAYOUT = borsh.struct([
  borsh.publicKey("author"),
  borsh.publicKey("blog"),
  borsh.u8("bump"),
  borsh.str("slug"),
  borsh.str("title"),
  borsh.str("content"),
]);

async () => {
  const connection = new Connection("http://localhost:8899", "confirmed");

  const [blogAccount] = await PublicKey.findProgramAddress(
    [Buffer.from("blog"), user.publicKey.toBuffer()],
    MY_PROGRAM_ID
  );

  const [postAccount] = await PublicKey.findProgramAddress(
    [Buffer.from("post"), Buffer.from("slug-1"), user.publicKey.toBuffer()],
    MY_PROGRAM_ID
  );

  const blogAccountInfo = await connection.getAccountInfo(blogAccount);
  const blogAccountState = BLOG_ACCOUNT_DATA_LAYOUT.decode(
    blogAccountInfo.data
  );
  console.log("Blog account state: ", blogAccountState);

  const postAccountInfo = await connection.getAccountInfo(postAccount);
  const postAccountState = POST_ACCOUNT_DATA_LAYOUT.decode(
    postAccountInfo.data
  );
  console.log("Post account state: ", postAccountState);
};

Conta Única de Mapeamento

Outra forma de implementar mapeamento seria ter uma estrutura de dados BTreeMap armazenada explicitamente em uma única conta. O endereço dessa conta em si poderia ser um PDA, ou a chave pública de um par de chaves gerado.

Este método de mapeamento de contas não é ideal pelas seguintes razões:

  • Você terá que inicializar a conta que armazena o BTreeMap antes de poder inserir os pares de chave-valor necessários nele. Em seguida, você também terá que armazenar o endereço desta conta em algum lugar, para atualizá-lo sempre que necessário.

  • Existem limitações de memória para uma conta, onde uma conta pode ter um tamanho máximo de 10 megabytes, o que limita o BTreeMap de armazenar um grande número de pares chave-valor.

Portanto, após considerar seu caso de uso, você pode implementar este método como mostrado abaixo:

Press </> button to view full source
use std::{collections::BTreeMap};
use thiserror::Error;
use borsh::{BorshSerialize, BorshDeserialize};
use num_traits::FromPrimitive;
use solana_program::{sysvar::{rent::Rent, Sysvar}, entrypoint, entrypoint::ProgramResult, pubkey::Pubkey, account_info::{AccountInfo, next_account_info}, program_error::ProgramError, system_instruction, msg, program::{invoke_signed}, borsh::try_from_slice_unchecked};

entrypoint!(process_instruction);

fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    msg!("instruction_data: {:?}", instruction_data);
    Processor::process(program_id, accounts, instruction_data)
}

pub struct Processor;

impl Processor {
    pub fn process(
        program_id: &Pubkey,
        accounts: &[AccountInfo],
        instruction_data: &[u8]
    ) -> ProgramResult {
        let instruction = FromPrimitive::from_u8(instruction_data[0]).ok_or(ProgramError::InvalidInstructionData)?;

        match instruction {
            0 => {
                msg!("Initializing map!");
                Self::process_init_map(accounts, program_id)?;
            },
            1 => {
                msg!("Inserting entry!");
                Self::process_insert_entry(accounts, program_id)?;
            },
            _ => {
                return Err(ProgramError::InvalidInstructionData)
            }
        }
        Ok(())
    }

    fn process_init_map(accounts: &[AccountInfo], program_id: &Pubkey) -> ProgramResult {
        let account_info_iter = &mut accounts.iter();

        let authority_account = next_account_info(account_info_iter)?;
        let map_account = next_account_info(account_info_iter)?;
        let system_program = next_account_info(account_info_iter)?;

        if !authority_account.is_signer {
            return Err(ProgramError::MissingRequiredSignature)
        }

        let (map_pda, map_bump) = Pubkey::find_program_address(
            &[b"map".as_ref()],
            program_id
        );

        if map_pda != *map_account.key || !map_account.is_writable || !map_account.data_is_empty() {
            return Err(BlogError::InvalidMapAccount.into())
        }

        let rent = Rent::get()?;
        let rent_lamports = rent.minimum_balance(MapAccount::LEN);

        let create_map_ix = &system_instruction::create_account(
            authority_account.key, 
            map_account.key, 
            rent_lamports, 
            MapAccount::LEN.try_into().unwrap(), 
            program_id
        );

        msg!("Creating MapAccount account");
        invoke_signed(
            create_map_ix, 
            &[
                authority_account.clone(),
                map_account.clone(),
                system_program.clone()
            ],
            &[&[
                b"map".as_ref(),
                &[map_bump]
            ]]
        )?;

        msg!("Deserializing MapAccount account");
        let mut map_state = try_from_slice_unchecked::<MapAccount>(&map_account.data.borrow()).unwrap();
        let empty_map: BTreeMap<Pubkey, Pubkey> = BTreeMap::new();

        map_state.is_initialized = 1;
        map_state.map = empty_map;

        msg!("Serializing MapAccount account");
        map_state.serialize(&mut &mut map_account.data.borrow_mut()[..])?;

        Ok(())
    }

    fn process_insert_entry(accounts: &[AccountInfo], program_id: &Pubkey) -> ProgramResult {
        
        let account_info_iter = &mut accounts.iter();

        let a_account = next_account_info(account_info_iter)?;
        let b_account = next_account_info(account_info_iter)?;
        let map_account = next_account_info(account_info_iter)?;

        if !a_account.is_signer {
            return Err(ProgramError::MissingRequiredSignature)
        }

        if map_account.data.borrow()[0] == 0 || *map_account.owner != *program_id {
            return Err(BlogError::InvalidMapAccount.into())
        }

        msg!("Deserializing MapAccount account");
        let mut map_state = try_from_slice_unchecked::<MapAccount>(&map_account.data.borrow())?;

        if map_state.map.contains_key(a_account.key) {
            return Err(BlogError::AccountAlreadyHasEntry.into())
        }

        map_state.map.insert(*a_account.key, *b_account.key);
        
        msg!("Serializing MapAccount account");
        map_state.serialize(&mut &mut map_account.data.borrow_mut()[..])?;

        Ok(())
    }
}

#[derive(BorshSerialize, BorshDeserialize, Clone, Debug)]
pub struct MapAccount {
    pub is_initialized: u8,
    pub map: BTreeMap<Pubkey, Pubkey> // 100
}

impl MapAccount {
    const LEN: usize = 1 + (4 + (10 * 64)); // 10 user -> blog
}

#[derive(Error, Debug, Copy, Clone)]
pub enum BlogError {
    #[error("Invalid MapAccount account")]
    InvalidMapAccount,

    #[error("Invalid Blog account")]
    InvalidBlogAccount,

    #[error("Account already has entry in Map")]
    AccountAlreadyHasEntry,
}

impl From<BlogError> for ProgramError {
    fn from(e: BlogError) -> Self {
        return ProgramError::Custom(e as u32);
    }
}

O código do lado do cliente para testar o programa acima seria algo como mostrado abaixo:

Press </> button to view full source
import {
  Connection,
  Keypair,
  LAMPORTS_PER_SOL,
  PublicKey,
  SystemProgram,
  Transaction,
  TransactionInstruction,
} from "@solana/web3.js";

import * as borsh from "@project-serum/borsh";

const MY_PROGRAM_ID = new PublicKey(
  "FwcG3yKuAkCfX68q9GPykNWDaaPjdZFaR1Tgr8qSxaEa"
);

const MAP_DATA_LAYOUT = borsh.struct([
  borsh.u8("is_initialized"),
  borsh.map(borsh.publicKey("user_a"), borsh.publicKey("user_b"), "blogs"),
]);

async () => {
  const connection = new Connection("http://localhost:8899", "confirmed");

  const userA = Keypair.generate();
  const userB = Keypair.generate();
  const userC = Keypair.generate();

  const [mapKey] = await PublicKey.findProgramAddress(
    [Buffer.from("map")],
    MY_PROGRAM_ID
  );

  const airdropASig = await connection.requestAirdrop(
    userA.publicKey,
    5 * LAMPORTS_PER_SOL
  );
  const airdropBSig = await connection.requestAirdrop(
    userB.publicKey,
    5 * LAMPORTS_PER_SOL
  );
  const airdropCSig = await connection.requestAirdrop(
    userC.publicKey,
    5 * LAMPORTS_PER_SOL
  );
  const promiseA = connection.confirmTransaction(airdropASig);
  const promiseB = connection.confirmTransaction(airdropBSig);
  const promiseC = connection.confirmTransaction(airdropCSig);

  await Promise.all([promiseA, promiseB, promiseC]);

  const initMapIx = new TransactionInstruction({
    programId: MY_PROGRAM_ID,
    keys: [
      {
        pubkey: userA.publicKey,
        isSigner: true,
        isWritable: true,
      },
      {
        pubkey: mapKey,
        isSigner: false,
        isWritable: true,
      },
      {
        pubkey: SystemProgram.programId,
        isSigner: false,
        isWritable: false,
      },
    ],
    data: Buffer.from(Uint8Array.of(0)),
  });

  const insertABIx = new TransactionInstruction({
    programId: MY_PROGRAM_ID,
    keys: [
      {
        pubkey: userA.publicKey,
        isSigner: true,
        isWritable: true,
      },
      {
        pubkey: userB.publicKey,
        isSigner: false,
        isWritable: false,
      },
      {
        pubkey: mapKey,
        isSigner: false,
        isWritable: true,
      },
    ],
    data: Buffer.from(Uint8Array.of(1)),
  });

  const insertBCIx = new TransactionInstruction({
    programId: MY_PROGRAM_ID,
    keys: [
      {
        pubkey: userB.publicKey,
        isSigner: true,
        isWritable: true,
      },
      {
        pubkey: userC.publicKey,
        isSigner: false,
        isWritable: false,
      },
      {
        pubkey: mapKey,
        isSigner: false,
        isWritable: true,
      },
    ],
    data: Buffer.from(Uint8Array.of(1)),
  });

  const insertCAIx = new TransactionInstruction({
    programId: MY_PROGRAM_ID,
    keys: [
      {
        pubkey: userC.publicKey,
        isSigner: true,
        isWritable: true,
      },
      {
        pubkey: userA.publicKey,
        isSigner: false,
        isWritable: false,
      },
      {
        pubkey: mapKey,
        isSigner: false,
        isWritable: true,
      },
    ],
    data: Buffer.from(Uint8Array.of(1)),
  });

  const tx = new Transaction();
  tx.add(initMapIx);
  tx.add(insertABIx);
  tx.add(insertBCIx);
  tx.add(insertCAIx);

  const sig = await connection.sendTransaction(tx, [userA, userB, userC], {
    skipPreflight: false,
    preflightCommitment: "confirmed",
  });
  await connection.confirmTransaction(sig);

  const mapAccount = await connection.getAccountInfo(mapKey);
  const mapData = MAP_DATA_LAYOUT.decode(mapAccount.data);
  console.log("MapData: ", mapData);
};
Last Updated:
Contributors: Daniel Cukier