Account Maps

マップは、key とを何らかの value に関連付けるためにプログラミングでよく使用するデータ構造です。キーと値は任意の型にすることができ、キーは保存される特定の値の識別子として機能します。 次に、そのキーを指定すると、これらの値を効率的に挿入、取得、更新できます。

ご存知のように、Solana の Account モデルでは、プログラム データとそれに関連する状態データを異なるアカウントに保存する必要があります。 これらのアカウントにはアドレスが関連付けられています。これ自体がマップとして機能します。 Solana のAccount モードの詳細については、こちらopen in new window

そのため、値を取得するために必要なキーをアドレスとして、別のアカウントに値を保存することは理にかなっています。しかし、これは次のようないくつかの問題を引き起こします。

  • 上記のアドレスは、記憶して必要な値を取得できる理想的なkeyではない可能性が高いです。

  • 上記のアドレスは、異なる鍵ペアの公開鍵を参照しており、各公開鍵 (またはアドレス) には秘密鍵も関連付けられています。この秘密鍵は、必要に応じて別の指示に署名する必要があり、秘密鍵をどこかに保管する必要がありますが、これは絶対にお勧めできません!

これは、多くの Solana 開発者が直面する問題であり、Map のようなロジックをプログラムに実装しています。この問題を解決する方法をいくつか見てみましょう。

Deriving PDAs

PDA は Program Derived Addressopen in new windowの略で、要するに一連のシードから派生した(derived)アドレスとプログラム ID (またはアドレス) です。

PDA のユニークな点は、これらのアドレスが秘密鍵に関連付けられていないことです。 これは、これらのアドレスが ED25519 曲線上にないためです。 詳細はこちらopen in new window.

PDA とは何かがわかったので、それらを使用していくつかのアカウントをマッピングしてみましょう。 これがどのように実装されるかを示すために、ブログ プログラムの例を取り上げます。

このブログプログラムでは、各ユーザーに 1 つのブログを作成してもらいます。このブログには、任意の数の投稿を含めることができます。 これは、各ユーザーを一つのブログマッピングし、複数の投稿を特定のブログマッピングすることを意味します。

つまり、ユーザーとブログの間には 1:1 のマッピングがあり、ブログとその投稿の間には 1:N のマッピングがあります。

1:1 マッピングの場合、ブログのアドレスをそのユーザーからのみ 取得する必要があります。これにより、権限 (またはユーザー) を指定してブログを取得できます。 したがって、ブログのシードは、その権限のキーと、場合によっては「ブログ」のプレフィックスで構成され、型別子として機能します。 1:N マッピングの場合、各投稿のアドレスは、関連付けられているブログだけでなく、ブログ内の N 個の投稿を区別できる別の識別子から取得する必要があります。 以下の例では、各投稿のアドレスは、ブログのキー、各投稿を識別するためのスラッグ、およびタイプ識別子として機能する「投稿」のプレフィックスから派生します。

コードは以下のとおりです。

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

クライアント側では、 PublicKey.findProgramAddress() を使用して必要なblogpostのアカウント アドレスを取得できます。これを connection.getAccountInfo() に渡してアカウント データを取得できます。以下に例を示します。

Press </> button to view full source
import * as borsh from "@project-OpenBook/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);
};

単一のマップ アカウント

マッピングを実装する別の方法は、BTreeMap データ構造を単一のアカウントに明示的に格納することです。 このアカウントのアドレス自体は、PDA または生成されたキーペアの公開鍵である可能性があります。

アカウントをマッピングするこの方法は、次の理由により理想的ではありません。

  • 必要なキーと値のペアを挿入する前に、まず BTreeMap を格納するアカウントを初期化する必要があります。 次に、毎回更新できるように、このアカウントのアドレスをどこかに保存する必要があります。

  • アカウントにはメモリ制限があり、アカウントの最大サイズは 10 メガバイトであり、BTreeMap が多数のキーと値のペアを格納するには限りがあります。

したがって、ユースケースを検討した後、以下に示すようにこのメソッドを実装できます。

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

上記のプログラムをテストするクライアント側のコードは、次のようになります。

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-OpenBook/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: PokoPoko2ry