SolanaでSBT（Soulbound Token）を発行してみた


## はじめに
　この記事は朝日新聞社 Advent Calendar 2024の12日目の記事です。
　はじめまして。メディア研究開発センターに所属している松村 圭貴（まつむら よしき）と申します。この冬もかなり寒くなってきましたね。私はこの冬、ボーナスが入ったら私用のPCを買い替えたいな〜と考えてます。
　今回は、仕事とは関係なく、私がプライベートで東京大学のブロックチェーンに関する公開講座を受講しており、せっかくなので公開講座で学んだ知見を活かしてブロックチェーンを利用した技術について紹介・実装していきたいと思います。

　公開講座の方では主にEthereumやビットコインプロトコルなどをメインに取り扱っていましたが、この記事では、2020年3月にローンチされたSolanaと呼ばれるブロックチェーンを取り扱います。

## 目次
1. ブロックチェーンとは
2. Solanaとは
3. SBTとは
4. 開発環境のセットアップ
5. コントラクト（プログラム）の実装
6. テストの作成
7. デプロイと動作確認
8. まとめ
## 1. ブロックチェーンとは
　まずブロックチェーンについて簡単に説明します。ブロックチェーンとは、「**複数のコンピュータで取引記録を共有・検証し、改ざんが極めて困難な分散型システム**」です。複数のコンピュータで取引データを検証し、全てのコンピュータで検証結果が一致したものをだけを台帳に記録するイメージです。
　ブロックチェーンにデータが載るとそのデータは一生書き換えることができません。この技術の応用例として代表的なものとして挙げられるのがビットコインなどの仮想通貨です。

## 2. Solanaとは
　Solanaは、ビットコインと同じ仮想通貨の一種であり、2020年3月にローンチされた暗号資産です。Solanaとは、ビットコインプロトコルとは異なり、**スマートコントラクト**を採用している暗号資産になります。
　スマートコントラクトとは**ブロックチェーンに保存されたプログラムであり、所定の条件が満たされた場合に実行**されます。ビットコインプロトコルは基本的な金銭取引データのみを扱いますが、Solanaではプログラム自体がブロックチェーンに記録されます。これによって複雑なビジネスロジックを実現することが可能になります。

## 3. SBTとは
### 概要
- **NFT**
　SBTを説明する前に**NFT**について少し解説をします。NFTとはNon Fungible Tokenの略で、**非代替性トークン**と呼ばれます。**NFTは代替可能性がない唯一無二のデータ**であり、ビットコインなどの暗号通貨は反対にFungible Tokenと呼ばれます。このNFTを利用してアートなどの作品に対してNFTを紐づけることで、絵画や音楽などの**所有権の売買**に利用され、投資の場面で利用されるケースが多いです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3927716/e008ec1c-27d0-c5c3-9ad6-db5ffe967982.png)

- **SBT**
　SBTとは**Soulbound Token**の略になります。Soulboundとは「**魂に紐づいた**」などの意味がありますが、このSBTは**所有権を譲渡できません**。つまり、一度発行されたSBTの所有者は永遠に変わることはありません。このSBTは、例えば職歴、学歴、資格などの不変の記録に使用することができます。言い方を変えると、**譲渡することに意味はないがその人が何かを証明する際に役立つトークン**として利用されるケースが多いです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3927716/b058e276-114c-1ac2-ef7c-27e7938df88f.png)

### 技術的特徴
**マルチ言語対応**
 Solanaは、主に使われている言語はRustですが、他にもTypescriptやGoなどでも開発をすることができます。
**転送不可能な仕組み**
 SBTはNFTの一種であり、NFTを譲渡不可能にする処理を追加することで実現できます。そのため基本的な実装はNFTを発行する手順と同じになります。

それでは次の項目から実装していきましょう！

## 4. 開発環境のセットアップ
実装開始の前に、まずは環境構築を行っていきましょう。

### Rust のインストール
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```

### Solana CLI のインストール
```bash
sh -c "$(curl -sSfL https://release.solana.com/v1.17.0/install)"
```

### Anchor のインストール
Anchor：Solanaプログラムを安全かつ迅速に構築するためのフレームワークになります。
```bash
cargo install --git https://github.com/coral-xyz/anchor avm --locked --force
```
最新でない場合は以下のコマンドを実行
```
avm install latest
avm use latest
```

## 5. プログラムの実装
いよいよここから実装開始です。
### プロジェクトの作成
```bash
anchor init mint_sbt
cd mint_sbt
```
### Anchor.tomlファイルの設定
tomlファイルは以下のものを貼り付けてください。solanaのバージョンは個人の環境に合わせて変えてください。`solana -V`でバージョン確認ができます。
```
[toolchain]
anchor_version = "0.30.1"
solana_version = "1.18.26"

[features]
resolution = true
skip-lint = false

[programs.localnet]
mint_sbt = "11111111111111111111111111111111"

[programs.devnet]
mint_sbt = "11111111111111111111111111111111"

[registry]
url = "https://api.apr.dev"

[provider]
cluster = "devnet"
wallet = "~/.config/solana/id.json"

[scripts]
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"

[workspace]
types = "docker"
members = ["programs/*"]

```

### solana用のwalletが存在しない場合はwalletを作成
*公開鍵と秘密鍵は控えておく
```
solana-keygen new
```
- アドレス（公開鍵）確認コマンド
```bash
solana address -k ~/.config/solana/id.json
```
- devnet残高の確認
```
solana balance --url devnet
```
- 残高がなければdevnet用のsolanaをもらう
```
solana airdrop 10 --url devnet
```

- devnet（検証用のブロックチェーン環境）にデプロイするため、Solanaの設定をdevnetに変更・確認を行う
```
solana config set --url devnet
solana config get
```


### SBTプログラムの実装
- 本来はMetaplexが提供するプロトコルを通じてNFT・SBTを発行するが、今回は学習も兼ねているため独自にSBT発行プログラムを実装し、発行していきます。
- `programs/mint-sbt/src/lib.rs`に以下のコードを記述。
```rust
use anchor_lang::prelude::*;

declare_id!("11111111111111111111111111111111");

#[program]
pub mod my_sbt_project {
    use super::*;
    // 初期化
    pub fn initialize(ctx: Context<Initialize>, data: String) -> Result<()> {
        let sbt_account = &mut ctx.accounts.sbt_account;
        sbt_account.data = data;
        sbt_account.owner = ctx.accounts.user.key();
        sbt_account.is_transferable = false;
        Ok(())
    }
    // 転送
    pub fn transfer(ctx: Context<Transfer>) -> Result<()> {
        let sbt_account = &ctx.accounts.sbt_account;
        
        // 転送可能かどうかをチェック
        require!(sbt_account.is_transferable, ErrorCode::NonTransferable);

        // 所有者チェック
        require!(
            ctx.accounts.current_owner.key() == sbt_account.owner,
            ErrorCode::InvalidOwner
        );

        // 転送処理
        let sbt_account = &mut ctx.accounts.sbt_account;
        sbt_account.owner = ctx.accounts.new_owner.key();

        Ok(())
    }
}
#[derive(Accounts)]
pub struct Initialize<'info> {
    // 初期化するSBTアカウント
    #[account(init, payer = user, space = 8 + 64)]
    pub sbt_account: Account<'info, SBTAccount>,
    // 初期化するユーザー
    #[account(mut)]
    pub user: Signer<'info>,
    // システムプログラム
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Transfer<'info> {
    #[account(mut)]
    pub sbt_account: Account<'info, SBTAccount>,
    pub current_owner: Signer<'info>,
    /// CHECK: new_ownerは単なるアドレスの受信者であり、プログラムの安全性に影響を与えません
    pub new_owner: AccountInfo<'info>,
}

#[account]
pub struct SBTAccount {
    // SBTアカウントに格納されたデータ
    pub data: String,
    // SBTアカウントの所有者
    pub owner: Pubkey,
    // SBTアカウントが転送可能かどうか
    pub is_transferable: bool,
}

#[error_code]
pub enum ErrorCode {
    #[msg("This SBT is non-transferable")]
    NonTransferable,
    #[msg("Only the owner can transfer this SBT")]
    InvalidOwner,
}
/// CHECK: new_ownerは安全であることを確認済みです。なぜなら...
pub struct NewOwner {
    pub new_owner: Pubkey,
}
```
この実装でのポイントは、まず初期化処理で`sbt_account.is_transferable = false;`になっていることです。[技術的特徴](#技術的特徴)でも述べたようにSBTはNFTを転送不可能にしたものになります。そのため最初から初期化の段階で転送できないようにすることでSBTにすることが可能になります。それからtransfer関数内で転送可能かとして以下のチェックを入れています。
```rust
require!(sbt_account.is_transferable, ErrorCode::NonTransferable);
```
これにより転送命令が出たとしてもエラーとして転送不可能になります。
### ビルド

```bash
anchor build
```
- ビルド後にプログラムIDが生成されるため確認
```bash
solana address -k target/deploy/mint_sbt-keypair.json

8eUF79XJRsRAKaCgTiPpvREgPwkshCbPj5x9b7cjZFsG
```
- プログラムIDが生成されたので、仮のプログラムIDを入れていた箇所を上記のプログラムIDに差し替える。

### devnetへデプロイ
```
mint-sbt % anchor deploy

Deploying cluster: https://api.devnet.solana.com
Upgrade authority: /Users/User/.config/solana/id.json
Deploying program "mint_sbt"...
Program path: /Users/User/.../sbt/mint-sbt/target/deploy/mint_sbt.so...
Program Id: 8eUF79XJRsRAKaCgTiPpvREgPwkshCbPj5x9b7cjZFsG

Deploy success
```
[solana explorer](https://explorer.solana.com/address/8eUF79XJRsRAKaCgTiPpvREgPwkshCbPj5x9b7cjZFsG?cluster=devnet)で確認することができる。

### SBT発行スクリプト（Typescript）
AnchorのフレームワークではTypescriptとRust言語の両方を使ってsolanaのブロックチェーン開発を行う


| 言語 | 使い分け |
| ---- | ---- |
| Rust | ブロックチェーン上で実行される全ての処理(例：アカウントの作成、データの更新、トークンの転送など)|
| Typescript | ブロックチェーンと対話するクライアント側の処理やテスト（例：トランザクションの送信、アカウントデータの取得、ユーザー操作の処理） |

- モジュールインストール
```
yarn add dotenv
```
- .envファイルの作成
プロジェクトルート配下に`.env`ファイルを作成
```
ANCHOR_PROVIDER_URL="https://api.devnet.solana.com"
ANCHOR_WALLET="/Users/<User>/.config/solana/id.json"
```
- `scripts/mint-sbt.ts`を作成し、以下のコードを追加する
```typescript
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { MySbtProject } from "../target/types/my_sbt_project";
import dotenv from "dotenv";
// .envファイルを読み込む
dotenv.config();

async function main() {
  // プロバイダーの設定
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  // プログラムの取得
  const program = anchor.workspace.MySbtProject as Program<MySbtProject>;

  // 新しいSBTアカウントの生成
  const sbtAccount = anchor.web3.Keypair.generate();
  const testData = "My First SBT";

  try {
    // SBTの発行
    const tx = await program.methods.initialize(testData)
      .accounts({
        sbtAccount: sbtAccount.publicKey,
        user: provider.wallet.publicKey,
      })
      .signers([sbtAccount])
      .rpc();

    console.log("SBT発行成功！");
    console.log("Transaction:", tx);
    console.log("SBT Account:", sbtAccount.publicKey.toString());

    // 発行したSBTの内容を確認
    const account = await program.account.sbtAccount.fetch(sbtAccount.publicKey);
    console.log("SBT Data:", account);

  } catch (error) {
    console.error("エラー:", error);
  }
}

main().then(
  () => process.exit(),
  err => {
    console.error(err);
    process.exit(-1);
  }
); 
```

### テストコードの実装
SBTを発行する前にテストコードでSBT発行できるかテストする。
`tests/mint-sbt.ts`を作成。以下のコードを追加する。

```typescript
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { MySbtProject } from "../target/types/my_sbt_project";
import { expect } from "chai";
import assert from "assert";

describe("mint-sbt", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.MySbtProject as Program<MySbtProject>;
  const sbtAccount = anchor.web3.Keypair.generate();

  it("Mint new SBT", async () => {
    const sbtData = "My First SBT Token";
    
    try {
      const tx = await program.methods
        .initialize(sbtData)
        .accounts({
          sbtAccount: sbtAccount.publicKey,
          user: provider.wallet.publicKey,
        })
        .signers([sbtAccount])
        .rpc();

      // トランザクションの確認を待つ
      await provider.connection.confirmTransaction(tx);
      console.log("SBT発行トランザクション:", tx);

      // 少し待機してアカウントの状態が更新されるのを待つ
      await new Promise(resolve => setTimeout(resolve, 2000));

      // SBTアカウントの状態を確認
      const account = await program.account.sbtAccount.fetch(sbtAccount.publicKey);
      console.log("発行されたSBT:", {
        data: account.data,
        owner: account.owner.toString(),
        isTransferable: account.isTransferable,
      });

      expect(account.data).to.equal(sbtData);
      expect(account.owner.toString()).to.equal(provider.wallet.publicKey.toString());
      expect(account.isTransferable).to.equal(false);

    } catch (error) {
      console.error("エラー:", error);
      throw error;
    }
  });
  // 転送できないSBTを転送しようとした場合のテスト
  it("Cannot transfer non-transferable SBT", async () => {
    const newOwner = anchor.web3.Keypair.generate();
    
    try {
        // 転送できないSBTを転送
        await program.methods
            // 転送
            .transfer()
            // アカウント
            .accounts({
                sbtAccount: sbtAccount.publicKey,
                currentOwner: provider.wallet.publicKey,
                newOwner: newOwner.publicKey,
            })
            // 署名
            .rpc();
        assert.fail("転送失敗");
    } catch (error) {
        expect(error.message).to.include("This SBT is non-transferable");
    }
  });
});
```

### テストの実行
テストがパスすればOK。
```bash
 mint-sbt % anchor test

...
  mint-sbt
SBT発行トランザクション: 3rUVeKEmdGXyrJpQ6SeAYfRGYWxHHJ3yhgk49inwph9DPbeyfJHBbjcsApYCWz6HzM6LYfaaVjjUUAqvJoST57rs
発行されたSBT: {
  data: 'My First SBT Token',
  owner: '573MBhYX7aQXKgMfVb656Wt7PDs6vAw4mRCndmkPL5pg',
  isTransferable: false
}
    ✔ Mint new SBT (3288ms)
    ✔ Cannot transfer non-transferable SBT (222ms)


  2 passing (4s)

✨  Done in 5.18s.
Error: No such file or directory (os error 2)
```

### SBTの発行
ではテストも通ったので、実際にSBTを発行（mint）してみましょう！
- `package.json`に以下の記述を追加する
```bash
  "scripts": {
    "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w",
    "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check",
    "mint": "ts-node scripts/mint-sbt.ts", // 追加
    "fetch": "ts-node scripts/fetch-sbt.ts" // 後述
  },
```

- SBTの発行（npmの場合は`npm run mint`）
```bash
mint-sbt % yarn mint
...
$ ts-node scripts/mint-sbt.ts
SBT発行成功！
Transaction: 5ak16ZSYWcVwNuxUDrGmfVZ2GGKK2HpGGQWc7Qw1ZnXynuogadnQujFE3DksPMYqt9u5fwKAFvT7u7yYx9CbHvaR
SBT Account: 3jJjsm1yT1ZSYBwimrNFTtuQh6tuHDRNx9SCsp7uMuX5
SBT Data: {
  data: 'My First SBT',
  owner: PublicKey [PublicKey(573MBhYX7aQXKgMfVb656Wt7PDs6vAw4mRCndmkPL5pg)] {
    _bn: <BN: 3cfae330a5026a7e1491eff7a12817f221101b74ecbbb038b9736a4ed910b995>
  },
  isTransferable: false
}
✨  Done in 1.93s.
```

署名しているプログラムID（Assigned Program Id）を確認すると、devnetへデプロイしたプログラムアカウントのIDになっていることが確認できます。[solana explorer](https://explorer.solana.com/address/3jJjsm1yT1ZSYBwimrNFTtuQh6tuHDRNx9SCsp7uMuX5?cluster=devnet)
![スクリーンショット 2024-12-05 18.00.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3927716/6580e3a0-64a6-28ec-5521-47d91898e311.png)
### 発行したSBTの確認
デプロイしたプログラムや発行したSBTは本来Verifyという検証を行って確認しますが、初心者ということもありVerifyがうまくいかずでしたので、発行したSBTをフェッチしてデータの中身を確認してみます。

- `scripts/fetch-sbt.ts`を作成し、以下のコードを追加する

```typescript
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { MySbtProject } from "../target/types/my_sbt_project";
import dotenv from "dotenv";
dotenv.config();

async function main() {
  // 環境変数からプロバイダーを取得
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  // プログラムをワークスペースに設定
  const program = anchor.workspace.MySbtProject as Program<MySbtProject>;
  
  // ここに確認したいSBTアカウントのアドレスを入れる
  const sbtAddress = new anchor.web3.PublicKey("3jJjsm1yT1ZSYBwimrNFTtuQh6tuHDRNx9SCsp7uMuX5");

  try {
    const account = await program.account.sbtAccount.fetch(sbtAddress);
    console.log("SBTの詳細:");
    console.log("データ:", account.data);
    console.log("所有者:", account.owner.toString());
    console.log("転送可能:", account.isTransferable);
  } catch (error) {
    console.error("エラー:", error);
  }
}

main().then(
  () => process.exit(),
  err => {
    console.error(err);
    process.exit(-1);
  }
); 
```

- `package.json`にすでに記述しているので、コマンドで`yarn fetch`または`npm run fetch`を実行する。

以下のように出ればOK
```bash
$ ts-node scripts/fetch-sbt.ts
SBTの詳細:
データ: My First SBT
所有者: 573MBhYX7aQXKgMfVb656Wt7PDs6vAw4mRCndmkPL5pg
転送可能: false
✨  Done in 1.44s.
```

以上でSBT発行の実装は終わりになります！

## まとめ
この記事では、Solanaブロックチェーン上でSBT（Soulbound Token）を発行するプログラムの実装を行いました。主なポイントは以下の通りです：
### 1. SBTの特徴理解
- NFTの一種であり、転送不可能なトークン
- 学歴や資格など、個人に紐づく証明に適している
### 2. 開発環境の構築
- Rust、Solana CLI、Anchorフレームワークのセットアップ
- devnet環境での開発・テスト
### 3. 実装のポイント
- RustでSBTの基本機能（発行・転送制限）を実装
- TypeScriptでクライアント側の処理を実装
- テストコードによる動作確認
### 4. 実際の動作確認
- devnetへのデプロイ
- SBTの発行と確認
- 転送不可能な仕様の確認


　今回の実装を通じて、ブロックチェーン上でのSBT発行の基本的な流れと、Anchorフレームワークを利用したSolanaブロックチェーン開発を学びました。今回はシンプルなものですが、今回学んだプログラムを応用すれば、複雑なロジックも記述できるようになると思います！

 最後まで読んでいただきありがとうございました！良いお年を！！