🧐

SolanaのAnchorで実装されたEscrowのコード解説

23 min read

Solanaで dApps を開発したい人にとって最初の壁になるのはおそらくProgramming on Solana - An Introduction | paulxで解説されてる Escrow を理解することではないだろうか。

Escrow というのは例えば A さんが持っているトークン A と B さんが持っているトークン B を交換したい場合に仲介してくれる役割のことである。流れとしては、

    1. A さんがトークン A を Escrow に送信
    1. B さんがトークン B を A さんに送信
    1. Escrow が B さんにトークン A を送信

といった具合だ。Escrow を挟むことで「トークンを送ったのに相手からは送られてこない」といったような取りっぱぐれを防ぐことが出来る。

この Escrow を Solana で実装する Tutorial が先に挙げたProgramming on Solana - An Introduction | paulxの記事であるが、これが如何せん難しい。Solana の Program(スマートコントラクトのこと)の実装がそもそも難しいというのもあるが、Escrow の実装ではいくつもの Account が登場するので話がややこしくなる。この Tutorial の解説は英語でもいくつかあるが、Program Derived Address 日本語でという記事は中でも一番しっくりくると思うので困ったら読むのをお勧めする。

ところでAnchorという Solana のスマートコントラクト開発用のフレームワークがある。上記の Tutorial では Pure Rust で全てのコントラクトが書かれていたが、Anchor を使うとシリアライズ/デシリアライズが隠蔽されていたりビジネスロジックに集中してコードが書けるようになる。

この Anchor を使って先の Tutorial の Escrow を実装するというのがAnchor Example: Escrow Programである。この記事では Anchor を使って Escrow の実装を解説してくれているが少々説明が不親切な部分が多い。なので自分の理解を確認する意味でも今回は Anchor で Escrow を実装するコードについて全体像が見えるように解説しようと思う。

前提知識

Program と Account

Solana のコントラクト実装でまず理解すべきなのは ProgramAccountだ。一旦ざっくりと

  • Program は Solana におけるスマートコントラクト。ステートレスなので状態を持てない。状態を持たせたい場合は Account を利用する。
  • Account は Solana における全てのデータを表現するオブジェクト。Wallet も Token も全て Account である。

とだけ理解しておくと良い。詳細は公式の Docsを読んでも良いがProgram Derived Address 日本語での Terminology を読む方が手っ取り早いと思う。

トランザクションと署名

トークンを送信(transfer)したり権限を付与(set_authority)したりする際(トランザクション)には必ず 署名 が必要になる。例えば A さんがトークン A を B さんに送信する場合は A さんが署名者になる。

Program Derived Address(PDA)

では Escrow の場合の署名を考えてみる。Escrow では

    1. A さんがトークン A を Escrow に送信
    1. B さんがトークン B を A さんに送信
    1. Escrow が B さんにトークン A を送信

という流れになると書いた。ここで問題になるのは 3.の時である。なぜかというと 3.の場合は署名者が Escrow になるはずなのだが、Escrow はどうやって署名するのか。A さんと B さんは其々自分の Wallet を持っており自分のキーペアで署名すれば良い。しかし Escrow は Solana 上の Program でそのままでは A さんや B さんのように署名ができない。

これを解決するためにProgram Derived Address(PDA)という仕組みがある。

正確さを欠くが PDA の気持ちを書くと Escrow のような Program がトランザクション時に署名できるようにするための Account みたいなものである。だいぶ雰囲気を伝える言葉で書いてるので正確な定義やその中身についてはProgram Derived Address(PDA)Program Derived Address 日本語での Program Derived Address(PDA)の特徴あたりを読んでほしい。

実装解説

では実際のコードを見ていく。コードはYuheiNakasaka/anchor-escrowにおいてある。

https://github.com/YuheiNakasaka/anchor-escrow

コードを読む時のコツとしては、Rust で実装されたコントラクトである Programそのコントラクトを呼び出して使うクライアントを順番に追っていくことだ。Anchor で作られたプロジェクトの場合、Program はprograms/にあり、クライアントの動きはtests/配下のコードを見ればわかる。

コントラクトがどう呼び出されているのか を読んでから 使われているコントラクト の実装を追っていく形で解説する。

コントラクトを呼び出す準備のコード

最初はコントラクトを呼び出す事前準備の部分。tests/anchor-escrow.tsの 1~96 行目あたり。

import * as anchor from "@project-serum/anchor";
import { PublicKey, SystemProgram, Transaction } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID, Token } from "@solana/spl-token";
import { assert } from "chai";

describe("anchor-escrow", () => {
  const provider = anchor.Provider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.AnchorEscrow;

  let mintA = null;
  let mintB = null;
  let initializerTokenAccountA = null;
  let initializerTokenAccountB = null;
  let takerTokenAccountA = null;
  let takerTokenAccountB = null;

  let vault_account_pda = null;
  let vault_account_bump = null;
  let vault_authority_pda = null;

  const takerAmount = 1000;
  const initializerAmount = 500;

  // メインアカウントにSOLをAirdropするためのアカウント
  const payer = anchor.web3.Keypair.generate();
  // 今回の主役のアカウント。送る側のアカウント。
  const initializerMainAccount = anchor.web3.Keypair.generate();
  // 今回の主役のアカウント。受け取る側のアカウント。
  const takerMainAccount = anchor.web3.Keypair.generate();
  // Tokenアカウントを作成するアカウント。
  const mintAuthority = anchor.web3.Keypair.generate();
  // escrowアカウント
  const escrowAccount = anchor.web3.Keypair.generate();

  // Escrowをテストするための初期状態のセットアップ
  it("Initialize escrow state", async () => {
    await provider.connection.confirmTransaction(
      await provider.connection.requestAirdrop(payer.publicKey, 10000000000),
      "confirmed"
    );

    // 送受信するためのlamportsをinitializerとtakerのそれぞれアカウントへ送金
    await provider.send(
      (() => {
        const tx = new Transaction();
        tx.add(
          SystemProgram.transfer({
            fromPubkey: payer.publicKey,
            toPubkey: initializerMainAccount.publicKey,
            lamports: 1000000000,
          }),
          SystemProgram.transfer({
            fromPubkey: payer.publicKey,
            toPubkey: takerMainAccount.publicKey,
            lamports: 1000000000,
          })
        );
        return tx;
      })(),
      [payer]
    );

    // payerがminterにmintの許可を与える
    mintA = await Token.createMint(
      provider.connection,
      payer, // このアカウントの作成者
      mintAuthority.publicKey, // 今後mintするアカウント
      null,
      0,
      TOKEN_PROGRAM_ID
    );
    mintB = await Token.createMint(provider.connection, payer, mintAuthority.publicKey, null, 0, TOKEN_PROGRAM_ID);

    // トークンAのトークンアカウントを作成。initializerにトークンAのトークンアカウントをアサインする
    initializerTokenAccountA = await mintA.createAccount(initializerMainAccount.publicKey);
    // トークンBのトークンアカウントを作成。initializerにトークンBのトークンアカウントをアサインする
    initializerTokenAccountB = await mintB.createAccount(initializerMainAccount.publicKey);

    // トークンAのトークンアカウントを作成。takerにトークンAのトークンアカウントをアサインする
    takerTokenAccountA = await mintA.createAccount(takerMainAccount.publicKey);
    // トークンBのトークンアカウントを作成。takerにトークンBのトークンアカウントをアサインする
    takerTokenAccountB = await mintB.createAccount(takerMainAccount.publicKey);

    // トークンAをinitializerにinitializerAmount枚発行
    await mintA.mintTo(initializerTokenAccountA, mintAuthority.publicKey, [mintAuthority], initializerAmount);
    // トークンBをtakerにtakerAmount枚発行
    await mintB.mintTo(takerTokenAccountB, mintAuthority.publicKey, [mintAuthority], takerAmount);

    // トークンがちゃんと発行されているかを確認するテスト
    let _initializerTokenAccountA = await mintA.getAccountInfo(initializerTokenAccountA);
    let _takerTokenAccountB = await mintB.getAccountInfo(takerTokenAccountB);
    assert.ok(_initializerTokenAccountA.amount.toNumber() === initializerAmount);
    assert.ok(_takerTokenAccountB.amount.toNumber() === takerAmount);
  });
  ...
});

登場人物は下記。

  • initializer(主役)
    • tokenAccountA: こちらは taker へ送るための token を入れておく用。
    • tokenAccountB: こちらは空。あとで taker から token が送られてきたときに受け取る用。
  • taker(主役)
    • tokenAccountA: こちらは空。あとで initializer から token が送られてきたときに受け取る用。
    • tokenAccountB: こちらは initializer へ送るための token を入れておく用。
  • payer
  • mintA
  • mintB
  • mintAuthority

このコードでは下記がやりたい。

  • initializer さんと taker さんの wallet にお金(SOL)を用意すること
  • initializer さんと taker さんの受け取り用の tokenAccount を用意すること
  • initializer さんと taker さんの送信用の tokenAccount を用意すること

payer,mintA,mintB,mintAuthority はなぜ必要か?というと、

  • payer は SOL を発行してinitializertakerに受け渡す銀行みたいな役割
  • mintA はinitializerの tokenAccountA に token を発行する役割とtakerの受け取り用の tokenAccountA を作成する役割
  • mintB はtakerの tokenAccountB に token を発行する役割とinitializerの受け取り用の tokenAccountB を作成する役割
  • mintAuthority は mintA,mintB が token を発行する際の署名者の役割

になっている。

ここではまだ Program を呼び出していないのでこれで終わり。

initializer から escrow へ token を送信する

    1. A さんがトークン A を Escrow に送信
    1. B さんがトークン B を A さんに送信
    1. Escrow が B さんにトークン A を送信

でいうところの 1.である。

it("Initialize escrow", async () => {
  // PDA keyの作成
  // pdaの方はPublicKey, bumpの方はnum
  const [_vault_account_pda, _vault_account_bump] =
    await PublicKey.findProgramAddress(
      // このseedを使ってProgram側ではkeyを復元する
      [Buffer.from(anchor.utils.bytes.utf8.encode("token-seed"))],
      program.programId
    );
  vault_account_pda = _vault_account_pda;
  vault_account_bump = _vault_account_bump;

  // PDA authorityの作成
  const [_vault_authority_pda, _vault_authority_bump] =
    await PublicKey.findProgramAddress(
      // このseedを使ってProgram側ではkeyを復元する
      [Buffer.from(anchor.utils.bytes.utf8.encode("escrow"))],
      program.programId
    );
  vault_authority_pda = _vault_authority_pda;

  await program.rpc.initializeEscrow(
    // 一応渡してるがProgramの方では使われてないっぽい
    vault_account_bump,
    // anchor.BN(num)はBigNumberのラッパー
    new anchor.BN(initializerAmount),
    new anchor.BN(takerAmount),
    // context
    // #derive[Accounts]で定義したものと同じcontextを用意する
    {
      // initializeEscrowで必要なアカウントとか
      accounts: {
        initializer: initializerMainAccount.publicKey,
        vaultAccount: vault_account_pda,
        mint: mintA.publicKey,
        initializerDepositTokenAccount: initializerTokenAccountA,
        initializerReceiveTokenAccount: initializerTokenAccountB,
        escrowAccount: escrowAccount.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
        rent: anchor.web3.SYSVAR_RENT_PUBKEY,
        tokenProgram: TOKEN_PROGRAM_ID,
      },
      instructions: [
        await program.account.escrowAccount.createInstruction(escrowAccount),
      ],
      signers: [escrowAccount, initializerMainAccount],
    }
  );

  let _vault = await mintA.getAccountInfo(vault_account_pda);
  let _escrowAccount = await program.account.escrowAccount.fetch(
    escrowAccount.publicKey
  );

  // これは何? => initializerMainAccountからAuthorityをvault(PDA)にセットされてるかの確認
  assert.ok(_vault.owner.equals(vault_authority_pda));

  assert.ok(
    _escrowAccount.initializerKey.equals(initializerMainAccount.publicKey)
  );
  assert.ok(_escrowAccount.initializerAmount.toNumber() == initializerAmount);
  assert.ok(_escrowAccount.takerAmount.toNumber() == takerAmount);
  assert.ok(
    _escrowAccount.initializerDepositTokenAccount.equals(
      initializerTokenAccountA
    )
  );
  assert.ok(
    _escrowAccount.initializerReceiveTokenAccount.equals(
      initializerTokenAccountB
    )
  );
});

このコードでは、

  • PDA を作成
  • Program のinitializeEscrowを呼び出す
    • A さんがトークン A を Escrow に送信に相当する処理

新しい登場人物としては下記だろう。

  • vault_account_pda
  • vault_authority_pda

vault_account_pda は先に書いた initializer から送られた tokenA を受け取る Account。vault_authority_pda は後に Escrow が B さんにトークン A を送信する時に必要になる署名者。

次にinitializeEscrowの実装された Program の方を見てみる。主にコードは下記。

    pub fn initialize_escrow(
        ctx: Context<Initialize>,
        _vault_account_bump: u8,
        initializer_amount: u64,
        taker_amount: u64,
    ) -> ProgramResult {
        // escrow_account(Programのデータの永続化)の初期化
        ctx.accounts.escrow_account.initializer_key = *ctx.accounts.initializer.key;
        ctx.accounts
            .escrow_account
            .initializer_deposit_token_account = *ctx
            .accounts
            .initializer_deposit_token_account
            .to_account_info()
            .key;
        ctx.accounts
            .escrow_account
            .initializer_receive_token_account = *ctx
            .accounts
            .initializer_receive_token_account
            .to_account_info()
            .key;
        ctx.accounts.escrow_account.initializer_amount = initializer_amount;
        ctx.accounts.escrow_account.taker_amount = taker_amount;

        // Authorityをinitializerからpdaへ設定
        let (vault_authority, _vault_authority_bump) =
            Pubkey::find_program_address(&[ESCROW_PDA_SEED], ctx.program_id);
        token::set_authority(
            ctx.accounts.into_set_authority_context(),
            AuthorityType::AccountOwner,
            Some(vault_authority),
        )?;

        // initializerのTokenAccountからTokenAをinitializer_amount分だけpdaへ送信
        token::transfer(
            ctx.accounts.into_transfer_to_pda_context(),
            ctx.accounts.escrow_account.initializer_amount,
        )?;

        Ok(())
    }
#[derive(Accounts)]
#[instruction(vault_account_bump: u8, initializer_amount: u64)]
pub struct Initialize<'info> {
    #[account(mut, signer)]
    pub initializer: AccountInfo<'info>,

    pub mint: Account<'info, Mint>,
    #[account(init, seeds = [b"token-seed".as_ref()], bump = vault_account_bump, payer = initializer, token::mint = mint, token::authority = initializer)]
    pub vault_account: Account<'info, TokenAccount>,
    #[account(mut, constraint = initializer_deposit_token_account.amount >= initializer_amount)]
    pub initializer_deposit_token_account: Account<'info, TokenAccount>,
    pub initializer_receive_token_account: Account<'info, TokenAccount>,
    #[account(zero)]
    pub escrow_account: Account<'info, EscrowAccount>,

    pub system_program: AccountInfo<'info>,
    pub rent: Sysvar<'info, Rent>,
    pub token_program: AccountInfo<'info>,
}
#[account]
pub struct EscrowAccount {
    pub initializer_key: Pubkey,
    pub initializer_deposit_token_account: Pubkey,
    pub initializer_receive_token_account: Pubkey,
    pub initializer_amount: u64,
    pub taker_amount: u64,
}

initialize_escrow(ctx: Context<Initialize>, _vault_account_bump: u8, initializer_amount: u64, taker_amount: u64) -> ProgramResultの中でやってることとしては下記。

  • EscrowAccount(Program のデータの永続化)の初期化
  • 署名権限 を initializer から pda へ設定
  • initializer の TokenAccount から TokenA を initializer_amount 分だけ pda へ送信

Initialize<'info>initialize_escrow(ctx: Context<Initialize>, _vault_account_bump: u8, initializer_amount: u64, taker_amount: u64) -> ProgramResultの処理で使われる Account の情報をContextという形でまとめて受け渡される。

EscrowAccountは ステートレスな Escrow の Program の状態を保持する役割。

B さんがトークン B を A さんに送信 & Escrow が B さんにトークン A を送信

ここではexchangeという Program の呼び出しで B さんがトークン B を A さんに送信と Escrow が B さんにトークン A を送信がまとめて行われる。B さんから A さんへの送信と Escrow から B さんへの送信があるので関連する Account が多く、exchangeの引数に渡される Account の数も大分多くなっている。

it("Exchange escrow", async () => {
  await program.rpc.exchange({
    accounts: {
      taker: takerMainAccount.publicKey,
      takerDepositTokenAccount: takerTokenAccountB,
      takerReceiveTokenAccount: takerTokenAccountA,
      initializerDepositTokenAccount: initializerTokenAccountA,
      initializerReceiveTokenAccount: initializerTokenAccountB,
      initializer: initializerMainAccount.publicKey,
      escrowAccount: escrowAccount.publicKey,
      vaultAccount: vault_account_pda,
      vaultAuthority: vault_authority_pda,
      tokenProgram: TOKEN_PROGRAM_ID,
    },
    signers: [takerMainAccount],
  });

  let _takerTokenAccountA = await mintA.getAccountInfo(takerTokenAccountA);
  let _takerTokenAccountB = await mintB.getAccountInfo(takerTokenAccountB);
  let _initializerTokenAccountA = await mintA.getAccountInfo(
    initializerTokenAccountA
  );
  let _initializerTokenAccountB = await mintB.getAccountInfo(
    initializerTokenAccountB
  );

  assert.ok(_takerTokenAccountA.amount.toNumber() == initializerAmount);
  assert.ok(_initializerTokenAccountA.amount.toNumber() == 0);
  assert.ok(_initializerTokenAccountB.amount.toNumber() == takerAmount);
  assert.ok(_takerTokenAccountB.amount.toNumber() == 0);
});

exchangeに関連する Program のコードは下記。

    pub fn exchange(ctx: Context<Exchange>) -> ProgramResult {
        let (_vault_authority, vault_authority_bump) =
            Pubkey::find_program_address(&[ESCROW_PDA_SEED], ctx.program_id);
        let authority_seeds = &[&ESCROW_PDA_SEED[..], &[vault_authority_bump]];

        // TakerTokenAccountB -> InitializerTokenAccountB
        token::transfer(
            ctx.accounts.into_transfer_to_initializer_context(),
            ctx.accounts.escrow_account.taker_amount,
        )?;

        // VaultTokenAccount -> TakerTokenAccountA
        token::transfer(
            ctx.accounts
                .into_transfer_to_taker_context()
                .with_signer(&[&authority_seeds[..]]), // PDAからのtransferなのでPDAの署名が必要
            ctx.accounts.escrow_account.initializer_amount,
        )?;

        token::close_account(
            ctx.accounts
                .into_close_context()
                .with_signer(&[&authority_seeds[..]]),
        )?;

        Ok(())
    }
#[derive(Accounts)]
pub struct Exchange<'info> {
    #[account(signer)]
    pub taker: AccountInfo<'info>,
    #[account(mut)]
    pub taker_deposit_token_account: Account<'info, TokenAccount>,
    #[account(mut)]
    pub taker_receive_token_account: Account<'info, TokenAccount>,
    #[account(mut)]
    pub initializer_deposit_token_account: Account<'info, TokenAccount>,
    #[account(mut)]
    pub initializer_receive_token_account: Account<'info, TokenAccount>,
    #[account(mut)]
    pub initializer: AccountInfo<'info>,
    #[account(
        mut,
        // takerの残高保証とinitializerに間違いがないかの確認の制約
        constraint = escrow_account.taker_amount <= taker_deposit_token_account.amount,
        constraint = escrow_account.initializer_deposit_token_account == *initializer_deposit_token_account.to_account_info().key,
        constraint = escrow_account.initializer_receive_token_account == *initializer_receive_token_account.to_account_info().key,
        constraint = escrow_account.initializer_key == *initializer.key,
        // 取引が終わるとinitializerにauthorityが戻る
        close = initializer
    )]
    pub escrow_account: ProgramAccount<'info, EscrowAccount>,
    #[account(mut)]
    pub vault_account: Account<'info, TokenAccount>,
    pub vault_authority: AccountInfo<'info>,
    pub token_program: AccountInfo<'info>,
}
impl<'info> Exchange<'info> {
    fn into_transfer_to_initializer_context(
        &self,
    ) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> {
        let cpi_accounts = Transfer {
            from: self.taker_deposit_token_account.to_account_info().clone(),
            to: self
                .initializer_receive_token_account
                .to_account_info()
                .clone(),
            authority: self.taker.clone(),
        };
        let cpi_program = self.token_program.to_account_info();
        CpiContext::new(cpi_program, cpi_accounts)
    }

    fn into_transfer_to_taker_context(&self) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> {
        let cpi_accounts = Transfer {
            from: self.vault_account.to_account_info().clone(),
            to: self.taker_receive_token_account.to_account_info().clone(),
            authority: self.vault_authority.clone(),
        };
        CpiContext::new(self.token_program.clone(), cpi_accounts)
    }

    fn into_close_context(&self) -> CpiContext<'_, '_, '_, 'info, CloseAccount<'info>> {
        let cpi_accounts = CloseAccount {
            account: self.vault_account.to_account_info().clone(),
            destination: self.initializer.clone(),
            authority: self.vault_authority.clone(),
        };
        CpiContext::new(self.token_program.clone(), cpi_accounts)
    }
}

exchange(ctx: Context<Exchange>) -> ProgramResultでやっていることとしては、

    1. taker から initializer へ TokenB の送信
    1. Escrow から taker へ TokenA を送信
    1. PDA の署名権限を initializer へと戻す

    1),2)で initializer の TokenA と taker の TokenB の交換が成立。3)は不要になった PDA の後処理。ちょっと処理は多いけど基本的なコードはinitializeEscrowの時と同じ。

またExchange<'info>escrow_accountの attribute にconstraintというのがあるが、これはescrow_accountへのアクセス制限を指定しているもの。Attribute は他にも沢山あるのでここを見てみると良い。

まとめ

Programming on Solana - An Introduction | paulxを Anchor で実装する記事のコードを少し噛み砕いて解説してみた。

自分もまだ初心者レベルなので不正確な理解の部分や曖昧になっている知識もあるので間違っている場合は指摘してほしい。

Solana はまだまだスタートアップのレイヤー 1 ブロックチェーンということもあり Document の整備にそこまで労力が避けないという感じらしい(Solana Labs唯一の日本人エンジニアが語る、ソラナの魅力参照)。

なのでコードを読まないとわからない点も多く(読んでもわからん...ということもある)、自分含めて初心者殺しのように感じるところも多々ある(実際 Ethereum に比べると構造も複雑でとっつきにくい)。しかし暗号通貨取引の勢いもあり トランザクションの速さや PoH という仕組み、 Rust で堅牢なコントラクトを書けたりと技術的に面白い点も多いので興味のある人は触ってみると良いと思う。

また、開発してて困ったら Solana のDiscordの開発者サポートに質問を投げると結構優しく答えてくれる雰囲気なのでおすすめ。

その他何かあれば@razokuloverに聞いてください。

おまけ

  • Blochchain学習メモ
    • 雑な開発メモ。誰かの役には立つかもしれんリンクとかメモ書きがあるかも。
  • 役に立つかわからん雑なEscrowの全体像の図。汚すぎる...

Discussion

ログインするとコメントできます