👽

Meta が開発した Move Language を使ってスマートコントラクトを記述する

2022/07/12に公開

はじめに

今回は、Meta の仮想通貨プロジェクトである Diem が開発したスマートコントラクト向けプログラミング言語である Move Language を使ってみます。
当初 Diem ブロックチェーンのためのものであり、本プロジェクト自体は中止になりましたが、その後も Sui0L など他チェーンでも利用されています。

https://developers.diem.com/docs/technical-papers/move-paper/
https://github.com/move-language/move

今回はこの言語を利用して、簡易的な譲渡可能なコイン(ERC20的なもの)を題材として、設計/実装をしてみましょう。

使ってみる

Move での開発を進めていくために、プロジェクト管理/操作のために専用のCLIを使用します。
Move CLI を利用してプロジェクトの初期化をします。

https://github.com/move-language/move/tree/main/language/tools/move-cli

※ install 方法はこちらを参照ください。

move package init Sample
cd Sample
touch sources/SampleCoin.move

先述で作成した.moveファイルで1つの Module を作成してみましょう。
(Solidity でいう1コントラクトのファイルと同等のものと捉えてもらえればこの場は大丈夫です。)

最初にロジックの無い Module を生成し、ビルド、デプロイまで実行してみましょう。
Module を定義する際には、

  • その Module を保有するアカウントの address
  • Module 名
    が必要で、(アドレス)::(モジュール名)と宣言します
sources/SampleCoin.move
module Sample::SampleCoin {}
// or
// address Sample {
//   module SampleCoin {}
// }

アドレス自体は実際0x...なのですが、自動生成されたファイルに対し下記のように追記することで、モジュールをコーディングする際にはそのエイリアスを使用します。

Move.toml
[addresses]
std = "0x1"
+ Sample = "0xFFFFFF"

これで Module の定義ができたので、ビルド(move package build)、デプロイ(move sandbox publish)を実行してみましょう。
※ 今回のデプロイ自体は、Move CLI がもつ Sandbox にデプロイするだけで、実際の blockchain にデプロイするわけではありません。

% move package build               
INCLUDING DEPENDENCY MoveStdlib
BUILDING Sample
# -> create /build folder
% move sandbox publish 
# -> create /storage folder

一通りのコーディングを経験してみる

実装イメージを持つために、リソース、function 定義からテストまで、簡単な機能を通して体験してみましょう。
基本の基本となる"リソース生成と移動"の機能をテーマに本トピックを進めていきます。

Struct, Function を定義してみる

Struct (構造体) を定義して、Resource 生成/移動をやってみます。
少し説明を加えるとやろうとしていることは、管理/操作したい概念(今回で言うと Coin)を、その概念とマッピングされるデータを作成し、指定したアカウント配下に移動する、というイメージです。

sources/SampleCoin.move
module Sample::SampleCoin {
  struct SampleCoin has key {
    value: u64
  }

  public fun publish_coin(account: &signer) {
    let coin = SampleCoin { value: 0 };
    move_to(account, coin);
  }
}

これによ理、定義したpublish_coinを利用して、リソースを指定したアカウントに移動することができるようになりました。
実際に動作確認をしてみましょう。

Test を記述してみる

1つ funtion を記述することができたので、合わせて1つテストを書いてみます。
publish_coinによりリソース生成ができるので、function 実行後にそのユーザーがリソースを保持している状態か確認しましょう。

以下のように記述することで、テストコードを記述することができます。

#[test]
fun test_function() { ... }

このように記述したテストコードは、move package test で実行され、結果を確認することができます。

今回は以下のように記述してみましょう。

sources/SampleCoin.move
#[test(user = @0x2)]
fun test_publish_coin(user: &signer) {
  publish_coin(user); // テスト対象の function をしようする
  let user_address = signer::address_of(user); // signer から address を取得する
  assert!(exists<SampleCoin>(user_address), 0); // user が SampleCoin を保有しているかチェック
}

少し説明を加えておきます。

  • #[test(user = @0x2)] -> 実際の引数のアドレスを定義する (@0x... = アドレス)
  • fun test_publish_coin(user: &signer) -> 引数に注入する変数を定義する
  • その他補足
    • signer を利用するとき、function の外から signer のアドレス情報を注入する必要があります
    • assert! の第二引数は error code なのですが、テストコード内での処理なので暫定的に 0 を割り当てています

このように記述した後、実際にmove package testを実行してみましょう。
下記のように結果表示されたらOKです!

% move package test
INCLUDING DEPENDENCY MoveStdlib
BUILDING Sample
Running Move unit tests
[ PASS    ] 0xffffff::SampleCoin::test_publish_coin
Test result: OK. Total tests: 1; passed: 1; failed: 0

ここで以下のようなコードを実行してみましょう。

sources/SampleCoin.move
#[test(user = @0x2)]
fun test_sample(user: &signer) {
  publish_coin(user);
  publish_coin(user);
}

すると下記のように表示されると思います。

% move package test
...
Running Move unit tests
[ FAIL    ] 0xffffff::SampleCoin::test_publish_coin

Test failures:

Failures in 0xffffff::SampleCoin:
...
│ VMError (if there is one): VMError {
│     major_status: RESOURCE_ALREADY_EXISTS,
...

何かしらのエラーが発生してテスト失敗してしまいました...
次の章ではこちらの事象に対する対応をしてみましょう。

バリデーションを入れてみる

先ほどのテストコードでRESOURCE_ALREADY_EXISTSというメッセージが出力されました。
何が起こったかというと、(エラーメッセージの通りなのですが) 既に指定したリソースが存在していたためエラーとなりました。
こちらのエラーが出ないように function 側で対応してみましょう。

sources/SampleCoin.move
...
use std::Signer as signer;

+  const EALREADY_HAS_COIN: u64 = 1;
...
  public fun publish_coin(account: &signer) {
    let coin = SampleCoin { value: 0 };
+    let account_address = signer::address_of(account);
+    assert!(!exists<SampleCoin>(account_address), EALREADY_HAS_COIN);
    move_to(account, coin);
  }

先ほどのテストで使用したコードの流用なので簡単ですね。
一つ工夫しているのは error code の定義で、const として定義した上でassert!でそれを利用するようにしています。
Move ではこのような使い方が定石なようです。

こちらに対するテストも記述してみます。

sources/SampleCoin.move
#[test(user = @0x2)]
#[expected_failure(abort_code = 1)]
fun test_not_double_publish_coin(user: &signer) {
  publish_coin(user);
  publish_coin(user);
}

assert はありませんが、代わりに function の上にexpected_failureを記述することで、エラーが発生することを想定したテストケースの記述が可能です。

機能拡張をしていく

ほぼロジックがない機能でしたが、これで一通りのリソースの定義方法からテストまで体験することができました。
ここからコインの持つ基本機能を作りながら、move ならではの点を学んでいきましょう。

mint

Function

Coin のリソースを作れるようにはなりましたが、あくまで Coin の balance を管理する箱ができただけで、それぞれのユーザーごとの保有量をコントロールできるようになっていません。
mintを通して、保有量の更新をしてみましょう。

sources/SampleCoin.move
public fun mint(account: &signer, amount: u64) acquires SampleCoin {
  let coin_ref = borrow_global_mut<SampleCoin>(account_address); // 指定したアカウントの SampleCoin のリソースの更新可能な参照を取得する
  coin_ref.value = coin_ref.value + amount; // 取得した更新可能な参照データに対して、追加量分を加算する
}

Move の大きな特徴の一つです。
"既に生成されたリソースに対するアクセス制御を明示的に行うため"に下記のような記述が必要になります。

let coin_ref = borrow_global_mut<SampleCoin>(account_address);

これにより指定したアカウントの保有する SampleCoin データの"更新可能な"参照を取得することができました。
その参照に対して、更新したい値を代入することで、実際のデータに対する変更を行うことができます。

coin_ref.value = coin_ref.value + amount;

acquires

function 定義のところにも初めてみるacquiresというのがあります。
acquires (リソース名)と宣言することで、この function ではこのリソースに触れるよ、という印をつけます。

public fun mint(account: &signer, amount: u64) acquires SampleCoin { ...

今回の場合は、mintによってユーザーの保有量を増加させるという更新を行うので、function に SampleCoin に触れるよ、という印がつけています。
詳細は下記でご確認ください。

https://diem.github.io/move/functions.html#acquires

ではこちらに対するテストを記述してみましょう。

Test

mintすることで、指定したユーザーの保有量を増加させることができるので、その点を確認してみましょう。
各テストは独立しているので、実際に試したいコードを実行するための状況を作ることも必要な点に注意です。
今回は以下のような形になりました。

sources/SampleCoin.move
#[test(user = @0x2, )]
fun test_mint(user: &signer) acquires SampleCoin {
  publish_coin(user);
  mint(user, 100);
  let user_address = signer::address_of(user);
  let coin_ref = borrow_global<SampleCoin>(user_address);
  assert!(coin_ref.value == 100, 0);
}

重要な点だけ抜き出して解説をすると、

mint(user, 100);
...
assert!(coin_ref.value == 100, 0);

mint function を実行し、その際に 100 を指定しているので、実行後のユーザーの保有量が 100 となっていることを確認しています。

また Move の特徴である部分が出てきたのでここで解説を入れます。

let coin_ref = borrow_global<SampleCoin>(user_address);

先ほど borrow_global_mut というものを利用しましたが、今回は borrow_global です。
(想像つくかもしれませんが、)こちらは"更新不可能な"参照を取得します。

Refactor

[1] Validation に関する拡張

sources/SampleCoin.move
...
use std::Signer as signer;

const EALREADY_HAS_COIN: u64 = 1;
+  const EINVALID_VALUE: u64 = 2;
+  const ENOT_HAS_COIN: u64 = 3
...
public fun mint(account: &signer, amount: u64) acquires SampleCoin {
+  assert!(amount > 0, EINVALID_VALUE); // 指定した数量が0より大きいこと
+  let account_address = signer::address_of(account); // signer 情報よりアカウント情報を取得する
+  assert!(exists<SampleCoin>(account_address), ENOT_HAS_COIN); // 指定したアカウントが SampleCoin のリソースを保有しているか確認する
  let coin_ref = borrow_global_mut<SampleCoin>(account_address);
  coin_ref.value = coin_ref.value + amount;
}
sources/SampleCoin.move
#[test(user = @0x2)]
#[expected_failure(abort_code = 2)]
fun test_mint_when_use_insufficient_arg(user: &signer) acquires SampleCoin {
  mint(user, 0);
}
#[test(user = @0x2)]
#[expected_failure(abort_code = 3)]
fun test_mint_when_no_resource(user: &signer) acquires SampleCoin {
  mint(user, 100);
}

[2] 既存のテストに関する拡張

もしかしたら実装当時にテスト不足してない?と気づいた方もいるかもしれません。
今回 mint を実装する時に、実際のリソース内のスカラーにアクセスし、実際に更新を行いました。
最初の publish_coin でもリソース内のスカラーの初期値を決定しているので、こちらでも数値の検証を行うことができます。

#[test(user = @0x2)]
- fun test_publish_coin(user: &signer) {
+ fun test_publish_coin(user: &signer) acquires SampleCoin {
  publish_coin(user);
  let user_address = signer::address_of(user);
  assert!(exists<SampleCoin>(user_address), 0);
+  let coin_ref = borrow_global<SampleCoin>(user_address);
+  assert!(coin_ref.value == 0, 0);
}

transfer

最後に transfer を記述してみたいと思います。
とはいえこれまで出てきた内容の総集編のようなものなので、難しい点は少ないと思いますが、復習も兼ねてやってみましょう。
目的は from さんから to さんへ指定した数量の Coin を送ることです。

ということで、interface は以下のようになります。

fun transfer(from: &signer, to: address, amount: u64)

ここにやることを簡単に書いていくと、

fun transfer(from: &signer, to: address, amount: u64) {
  // [1] from から更新可能な SampleCoin の参照を取得する
  // [2] from の SampleCoin から指定数量を減らす
  // [3] to から更新可能な SampleCoin の参照を取得する
  // [4] to の SampleCoin から指定数量を増やす
}

となるので、これに沿ってコードを書いてみましょう。

fun transfer(from: &signer, to: address, amount: u64) {
  let from_coin = borrow_global_mut<SampleCoin>(signer::address_of(from)); // [1]
  from_coin.value = from_coin.value - amount; // [2]
  let to_coin = borrow_global_mut<SampleCoin>(to); // [3]
  to_coin.value = to_coin.value + amount; // [4]
}

既に出てきた borrow_global_mut を利用して取得した SampleCoin の量を更新する、ということを送信者と受信者に対して行う形になります。
これで from -> to への transfer は完了です。

Test

テストもこれまで学んだことで実装可能なので詳細な説明は割愛します。

#[test(from = @0x2, to = @0x3)]
fun test_transfer(from: &signer, to: &signer) acquires SampleCoin {
  // [1] 前提条件を整える
  publish_coin(from);
  publish_coin(to);
  mint(from, 100);
  // [2] テスト対象 function を実行
  let from_address = signer::address_of(from);
  let to_address = signer::address_of(to);
  transfer(from, signer::address_of(to), 70);
  // [3] assert
  assert!(borrow_global<SampleCoin>(from_address).value == 30, 0);
  assert!(borrow_global<SampleCoin>(to_address).value == 70, 0);
}

以下のように追記して、複数回の実行を確認しても良いですね。

#[test(from = @0x2, to = @0x3)]
fun test_transfer(from: &signer, to: &signer) acquires SampleCoin {
  ...
  let from_address = signer::address_of(from);
  let to_address = signer::address_of(to);
  transfer(from, signer::address_of(to), 70);
  assert!(borrow_global<SampleCoin>(from_address).value == 30, 0);
  assert!(borrow_global<SampleCoin>(to_address).value == 70, 0);
  transfer(from, signer::address_of(to), 20);
  assert!(borrow_global<SampleCoin>(from_address).value == 10, 0);
  assert!(borrow_global<SampleCoin>(to_address).value == 90, 0);
  transfer(from, signer::address_of(to), 10);
  assert!(borrow_global<SampleCoin>(from_address).value == 0, 0);
  assert!(borrow_global<SampleCoin>(to_address).value == 100, 0);
}

Refactor

今回もバリデーションに関する拡張をしてみようと思います。
このtransferにどのようなバリデーションを入れられるかというと以下のように列挙できます。

  • 指定量が0より大きい
  • from が既に SampleCoin を保有している
  • to が既に SampleCoin を保有している
  • from の SampleCoin の残高が指定量以上である

これらのバリデーションを実装すると以下のようになります。

public fun transfer(from: &signer, to: address, amount: u64) acquires SampleCoin {
+  assert!(amount > 0, EINVALID_VALUE);
  let from_address = signer::address_of(from);
+  assert!(exists<SampleCoin>(from_address), ENOT_HAS_COIN);
+  assert!(exists<SampleCoin>(to), ENOT_HAS_COIN);
  let from_coin = borrow_global_mut<SampleCoin>(from_address);
+  assert!(from_coin.value >= amount, EINVALID_VALUE);
  from_coin.value = from_coin.value - amount;
  let to_coin = borrow_global_mut<SampleCoin>(to);
  to_coin.value = to_coin.value + amount;
}

これらのバリデーションに対するテストも必要ですね。合わせて実装してみましょう。
コードと合わせて、実装したバリデーションとマッピングされているテストをコメントで示しています。

// Case: 指定量が0より大きい
#[test(from = @0x2, to = @0x3)]
#[expected_failure(abort_code = 2)]
fun test_transfer_when_use_insufficient_arg(from: &signer, to: &signer) acquires SampleCoin {
  transfer(from, signer::address_of(to), 0);
}
// Case: from が既に SampleCoin を保有している
#[test(from = @0x2, to = @0x3)]
#[expected_failure(abort_code = 3)]
fun test_transfer_when_no_coin_in_from(from: &signer, to: &signer) acquires SampleCoin {
  publish_coin(to);
  transfer(from, signer::address_of(to), 1);
}
// Case: to が既に SampleCoin を保有している
#[test(from = @0x2, to = @0x3)]
#[expected_failure(abort_code = 3)]
fun test_transfer_when_no_coin_in_to(from: &signer, to: &signer) acquires SampleCoin {
  publish_coin(from);
  transfer(from, signer::address_of(to), 1);
}
// from の SampleCoin の残高が指定量以上である
#[test(from = @0x2, to = @0x3)]
#[expected_failure(abort_code = 2)]
fun test_transfer_when_amount_over_balance(from: &signer, to: &signer) acquires SampleCoin {
  publish_coin(from);
  publish_coin(to);
  mint(from, 10);
  transfer(from, signer::address_of(to), 20);
}

以上でテストに対する拡充もできました。


これで簡単なコインの作成は完了しました。
最終的なコードは以下のようになります。

resources/SanpleCoin.move
module Sample::SampleCoin {
  use std::signer;

  const EALREADY_HAS_COIN: u64 = 1;
  const EINVALID_VALUE: u64 = 2;
  const ENOT_HAS_COIN: u64 = 3;

  struct SampleCoin has key {
    value: u64
  }

  public fun publish_coin(account: &signer) {
    let coin = SampleCoin { value: 0 };
    let account_address = signer::address_of(account);
    assert!(!exists<SampleCoin>(account_address), EALREADY_HAS_COIN);
    move_to(account, coin);
  }

  public fun mint(account: &signer, amount: u64) acquires SampleCoin {
    assert!(amount > 0, EINVALID_VALUE);
    let account_address = signer::address_of(account);
    assert!(exists<SampleCoin>(account_address), ENOT_HAS_COIN);
    let coin_ref = borrow_global_mut<SampleCoin>(account_address);
    coin_ref.value = coin_ref.value + amount;
  }

  public fun transfer(from: &signer, to: address, amount: u64) acquires SampleCoin {
    assert!(amount > 0, EINVALID_VALUE);
    let from_address = signer::address_of(from);
    assert!(exists<SampleCoin>(from_address), ENOT_HAS_COIN);
    assert!(exists<SampleCoin>(to), ENOT_HAS_COIN);
    let from_coin = borrow_global_mut<SampleCoin>(from_address);
    assert!(from_coin.value >= amount, EINVALID_VALUE);
    from_coin.value = from_coin.value - amount;
    let to_coin = borrow_global_mut<SampleCoin>(to);
    to_coin.value = to_coin.value + amount;
  }

  #[test(user = @0x2)]
  fun test_publish_coin(user: &signer) acquires SampleCoin {
    publish_coin(user);
    let user_address = signer::address_of(user);
    assert!(exists<SampleCoin>(user_address), 0);
    let coin_ref = borrow_global<SampleCoin>(user_address);
    assert!(coin_ref.value == 0, 0);
  }
  #[test(user = @0x2)]
  #[expected_failure(abort_code = 1)]
  fun test_not_double_publish_coin(user: &signer) {
    publish_coin(user);
    publish_coin(user);
  }

  #[test(user = @0x2)]
  fun test_mint(user: &signer) acquires SampleCoin {
    publish_coin(user);
    mint(user, 100);
    let user_address = signer::address_of(user);
    let coin_ref = borrow_global<SampleCoin>(user_address);
    assert!(coin_ref.value == 100, 0);
  }
  #[test(user = @0x2)]
  #[expected_failure(abort_code = 2)]
  fun test_mint_when_use_insufficient_arg(user: &signer) acquires SampleCoin {
    mint(user, 0);
  }
  #[test(user = @0x2)]
  #[expected_failure(abort_code = 3)]
  fun test_mint_when_no_resource(user: &signer) acquires SampleCoin {
    mint(user, 100);
  }

  #[test(from = @0x2, to = @0x3)]
  fun test_transfer(from: &signer, to: &signer) acquires SampleCoin {
    publish_coin(from);
    publish_coin(to);
    mint(from, 100);
    let from_address = signer::address_of(from);
    let to_address = signer::address_of(to);
    transfer(from, signer::address_of(to), 70);
    assert!(borrow_global<SampleCoin>(from_address).value == 30, 0);
    assert!(borrow_global<SampleCoin>(to_address).value == 70, 0);
    transfer(from, signer::address_of(to), 20);
    assert!(borrow_global<SampleCoin>(from_address).value == 10, 0);
    assert!(borrow_global<SampleCoin>(to_address).value == 90, 0);
    transfer(from, signer::address_of(to), 10);
    assert!(borrow_global<SampleCoin>(from_address).value == 0, 0);
    assert!(borrow_global<SampleCoin>(to_address).value == 100, 0);
  }
  #[test(from = @0x2, to = @0x3)]
  #[expected_failure(abort_code = 2)]
  fun test_transfer_when_use_insufficient_arg(from: &signer, to: &signer) acquires SampleCoin {
    transfer(from, signer::address_of(to), 0);
  }
  #[test(from = @0x2, to = @0x3)]
  #[expected_failure(abort_code = 3)]
  fun test_transfer_when_no_coin_in_from(from: &signer, to: &signer) acquires SampleCoin {
    publish_coin(to);
    transfer(from, signer::address_of(to), 1);
  }
  #[test(from = @0x2, to = @0x3)]
  #[expected_failure(abort_code = 3)]
  fun test_transfer_when_no_coin_in_to(from: &signer, to: &signer) acquires SampleCoin {
    publish_coin(from);
    transfer(from, signer::address_of(to), 1);
  }
  #[test(from = @0x2, to = @0x3)]
  #[expected_failure(abort_code = 2)]
  fun test_transfer_when_amount_over_balance(from: &signer, to: &signer) acquires SampleCoin {
    publish_coin(from);
    publish_coin(to);
    mint(from, 10);
    transfer(from, signer::address_of(to), 20);
  }
}

テストも問題なく通ると思います。

% move package test                                             
INCLUDING DEPENDENCY MoveStdlib
BUILDING Sample
Running Move unit tests
[ PASS    ] 0xffffff::SampleCoin::test_mint
[ PASS    ] 0xffffff::SampleCoin::test_mint_when_no_resource
[ PASS    ] 0xffffff::SampleCoin::test_mint_when_use_insufficient_arg
[ PASS    ] 0xffffff::SampleCoin::test_not_double_publish_coin
[ PASS    ] 0xffffff::SampleCoin::test_publish_coin
[ PASS    ] 0xffffff::SampleCoin::test_transfer
[ PASS    ] 0xffffff::SampleCoin::test_transfer_when_amount_over_balance
[ PASS    ] 0xffffff::SampleCoin::test_transfer_when_no_coin_in_from
[ PASS    ] 0xffffff::SampleCoin::test_transfer_when_no_coin_in_to
[ PASS    ] 0xffffff::SampleCoin::test_transfer_when_use_insufficient_arg
Test result: OK. Total tests: 10; passed: 10; failed: 0

まとめ

これまで blockchain の文脈で、Solidity を中心に投稿をしてきましたが、初めて別の言語を採用してみました。
Solidity 以外聞いたことないという方も多かったのではないかな、と思います。

(どちらが良い悪いの話ではないのですが、)
Solidity ではコントラクト自体が保持している状態を、コントラクト自分自身が持つ場合は自分の function で更新をし、他コントラクトが持っていればその状態を更新してもらう function を公開してもらってそれを呼び出す、というような、
色んな言語仕様にあるオブジェクトと近い感覚でデータそのものを触ることができました。
今回取り扱った move ではそのデータが誰に所有されているかを意識させ、そのデータに対する操作権限を選択/利用することを明示的にさせているところが大きな特徴の一つであり、Solidity と違うところで面白いなと思います。

是非遊びでも良いので使ってみてください。

実用例やエコシステムの発展度合いも含めると圧倒的にまだまだ Solidity の方が利用しやすい状態だと思いますが、
少し時間経って Move もその辺りが充実し、競争が当たり前になって、スマートコントラクトの開発全体において選択肢が増えると良いなと思います。

参考

https://github.com/move-language/move
https://diem.github.io/move/
https://move-book.com/

Discussion