Account Maps

Map들은 우리가 프로그래밍에서 key를 어떤 종류의 value와 연관시키기 위해 자주 사용하는 데이터 구조들입니다. key와 value는 임의의 타입이 될 수 있고 key는 저장할 value를 위한 식별자로써 기능할 수 있습니다. 그러면, 주어진 key들은 우리가 이 value들을 효율적으로 insert, retrieve 그리고 update 할 수 있게 해줍니다.

우리가 알고 있듯이, Solana의 Account 모델은 Program Data와 이것과 관련있는 다른 Account들에 저장될 상태 데이터를 요구합니다. 이러한 Account들은 Address를 갖고 있습니다. 이것은 자체적으로 Map으로 동작합니다. Solana의 Account 모델에 대한 더 자세한 내용은 여기open in new window에서 확인 할 수 있습니다.

그래서, 이제 key가 되는 Account의 address를 가지고 나누어진 Account들에 values를 저장하고, 가져오는 것을 이해할 것입니다. 그러나 이것은 다음과 같은 몇 가지 이슈들을 야기합니다.

  • 위에서 언급된 address들은 아마도 이상적인 keys가 될 수는 없습니다. 이것들은 당신이 기억할 수 없고 원하는 값을 가져올 수 없습니다.

다른 Keypairs의 public key들로 언급된 Address들에서, 각 public key (or address)는 연관된 private key 또한 갖고 있습니다. 이 private key는 필요할때 instruction들에 서명하기 위해 요구될 것입니다. 이것은 우리가 private key를 어딘가에 저장해야 하는 것을 의미하며 이것은 완전히 추천되지 않는 방식입니다.

이것은 Program들에 Map과 같은 로직을 구현하는 데 문제가 있고, 많은 Solana 개발자들이 당면하고 있습니다. 우리가 이 문제를 어떻게 할지 몇 가지 방법을 살펴 봄시다.

Deriving PDAs

PDA는 Program Derived Addressopen in new window를 의미하는 줄임말이고, seeds들의 집합으로부터 비롯된 Address들이며, Program id(or address)입니다.

PDA들에 관해 유니크한 것은 이들 Address들은 어떤 private key와도 연관되어 있지 않다는 것입니다. 이것은 Address들이 ED25519 곡선에 놓여 있지 않기 때문입니다. 이런 이유로, 오직 이 address 를 구한 program만이 key를 가지고 instruction에 서명할 수 있습니다. 이 key 또한 제공된 seeds입니다. 더 자세한 내용은 여기open in new window에 있습니다.

우리는 이제 PDA들이 무엇인지 알았습니다. PDA들을 사용해 몇 가지 Account들을 맵핑 해봅시다. 우리는 어떻게 구현되는지 설명하기 위해 Blog Program에 대한 한 예제를 살펴볼 것입니다.

이 Blog Program에서 우리는 각 User가 하나의 Blog를 갖도록 하고 싶습니다. 이 blog는 여러개의 Posts를 가질 수 있습니다. 이것은 우리가 각 user를 하나의 blog에 맵핑하고 각 post는 특정 blog에 맵핑된다는 것을 의미합니다.

요약하면, user와 그/그녀의 blog 사이에는 1:1 맵핑 관계가 있고, 하나의 blog와 이것의 posts 에는 1:N 맵핑 관계가 있습니다.

1:1 맵핑 관게를 위해서, 우리는 blog의 address가 오직 blog의 사용자로부터 만들어지길 원합니다. 이것은 blog의 authority (or user)가 주어졌을 때, 하나의 blog를 가져오는 것을 가능하게 합니다. 이런 이유로, blog의 seeds는 blog의 authority's key 로 구성될 것이고, 타입 식별자로써 기능하기 위해 가능하면 **"blog"**라는 prefix를 가지도록 구성될 것입니다.

1:N 맵핑 관계를 위해서, 우리는 각 post의 address가 오직 연관된 blog로부터만 만들어지길 원하지 않고, 또 다른 식별자로부터 함께 만들어지길 원합니다. 이 식별자는 우리가 blog에서 N 개의 post들을 구별할 수 있게 해줍니다. 아래의 예제에서 각 post의 address는 각 post를 식별하기 위해 blog's key와 하나의 slug, 그리고 타입 식별자로써 기능하기 위한 **"post"**라는 prefix로부터 만들어집니다.

코드는 아래와 같습니다.

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);
    }
}

Client 영역에서, 당신은 BlogPost Account address를 얻기 위해 PublicKey.findProgramAddress()를 사용할 수 있습니다. 당신은 Account Data를 가져오기 위해 이 Address를 connection.getAccountInfo()로 넘길 수 있습니다. 아래는 그 예제입니다.

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);
};

Single Map Account

맵핑을 구현하는 또 다른 방법은 하나의 Account안에 명확히 저장되는 BTreeMap 데이터 구조입니다. 이 Account의 Address는 자체적으로 PDA일 수 있고, 또는 생성된 Keypair의 public key일 수도 있습니다.

Account들을 맵핑하는 이 방법은 아래 이유들로 이상적이진 않습니다.

  • 당신은 필요한 key-value 쌍들을 넣기 전에, 먼저 BTreeMap을 저장하는 Account를 초기화 해야 할 것입니다. 그러면, 당신은 이 Account의 주소를 또 어딘가에 저장해야 할 것입니다.

  • 하나의 Account는 10 megabytes 최대 사이즈의 메모리 제한이 있습니다. 이것은 BTreeMap을 많은 수의 key-value 쌍들을 저장할 수 없게 만듭니다.

이런 이유로, 당신의 케이스를 고려한 후 아래의 방법을 사용하세요.

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);
    }
}

위 프로그램을 테스트하기 위한 Client 영역의 코드는 아래와 같습니다.

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: