SCamlによるTezosプログラミング#7

Tezos ブロックチェーン のためのスマートコントラクト記述言語 SCaml、チュートリアルの第7回目です。

今回はもう少し複雑は SCaml コントラクトを書いてみます。

#3 で終わるはずだったんだけど…

投票システム

一気にいろんな SCaml の機能を使った人気投票スマートコントラクトを作ってみる。

設定 config

title
投票の名前。文字列。 “sousenkyo”
beginning_time
投票開始時: Timestamp “2022-04-01T00:00:00Z”
finish_time
投票終了時: Timestamp “2022-07-01T00:00:00Z”

これらをレコードにまとめる。レコードの型を宣言しよう:

type config =
  { title : string;
    beginnig_time : timestamp;
    finish_time : timestamp
  }

ストレージ storage

config
投票設定
candidates
候補(string)をキーとした、集めた得票数(nat)のマップ(key value store)。 型は (string, nat) map と書く。
voters
二重投票を防ぐため、投票したアカウントのアドレスを集合として記録する。型は address set

これまたレコードにしておきましょう:

type storage = 
  { config     : config
  ; candidates : (string, nat) map
  ; voters     : address set
  }

エントリ vote

パラメタとして候補者の名前(name)を受け取ります。 ストレージは上で定義した型storageの値です。

let [@entry] vote name storage =
  ...

投票可能な時間か?

まず、設定で決めた時間帯か確認します。現在時刻を調べるには Global.get_now () を使います。

  let now = Global.get_now () in
  ...

現在時刻がストレージstorage.config内に保存された時間帯の外であれば無慈悲に失敗することにします:

  assert (storage.config.beginning_time <= now 
          && now <= storage.config.finish_time);

SCamlモジュールmodule Globalを見ると他の情報取得APIがリストされています。

投票者のアドレスを得る

Global.get_source () でコントラクトを起動したアカウントのアドレスがわかります。これで投票者を判別します。

  let addr = Global.get_source () in
  ...

二重投票を防ぐ

すでに投票した場合は、storage.voters にこのアドレスが入っています。その場合は無慈悲に実行を失敗します:

  assert (not (Set.mem addr storage.voters));

Set.mem «キー» «集合» でキーが集合に入っているかどうか調べることができます。入っていればtrue、なければfalse

そうです。アカウントを作りまくれば多重投票しまくれます。この問題を解決するには、事前に投票管理者が投票券を作って事前に投票者に配るなどしなければいけませんね。考えてみると面白いと思います。投票券を偽造されないためにはどうしたらいいでしょうか。

新しい投票者であれば、voters に加えます。Set.update «キー» true «集合» でキーを集合に加えます。falseだと取り除きます:

  let voters' = Set.update addr true voters in
  ...

候補者リストに投票を加える

投票候補である namestorage.candiates に登録しているかチェックし、投票数を一つ加えます。候補者以外の投票は失敗します:

  let n' = match Map.get name storage.candidates with
    | Some n -> n +^ Nat 1
    | None -> failwith "no such candidate"
  in
  ...
  

Map.get «キー» «マップ»で与えられたキーに対応した値を取り出します。存在していればSome «値»、しなければNoneが返ります。この返ってきた値をパターンマッチで場合分けしています。存在していなければエラー。存在して現在の値がnであれば、1足して、結果をn'に束縛します。

新しい投票数のマップを作ります:

  let candidates' = Map.update name (Some n') storage.candidates in
  ...

Map.update «キー» (Some «値») «マップ» で、(キー, 値)の対応を元のマップに追加した新しいマップを作ります。

新しいストレージを作る

  • 新しい投票済み投票者集合 voters'
  • 新しい投票データ candidates'

ができました。新しいストレージが作れます:

  let storage' = 
    { config = config ;
      candidates = candidates';
      voters = voters'
    }
  in
  ...

出力する

これでコントラクト終了です! Operation は無いので空です []。新しいストレージは storage' に束縛されています:

  ([], storage')

全コード

open SCaml

(* 選挙設定 *)
type config = 
  { title          : string
  ; beginning_time : timestamp
  ; finish_time    : timestamp
  }
        
(* コントラクトストレージ *)
type storage = 
  { config     : config
  ; candidates : (string, nat) map
  ; voters     : address set
  }

let [@entry] vote name storage =
  let now = Global.get_now () in

  (* 投票期間かどうかテスト *)
  assert (storage.config.beginning_time <= now 
          && now <= storage.config.finish_time);

  (* 投票者のアドレス *)
  let addr = Global.get_source () in

  (* 未投票かどうかテスト *)
  assert (not (Set.mem addr storage.voters));

  (* 投票済みと記録 *)
  let voters' = Set.update addr true storage.voters in

  (* 投票が候補者名と合致していれば、該当者への得票数を増やす *)
  let n' = match Map.get name storage.candidates with
    | Some n -> n +^ Nat 1
    | None -> failwith "no such candidate"
  in

  (* 新しい得票数で map を更新 *)
  let candidates' = Map.update name (Some n') storage.candidates in
  
  (* 新しいストレージを作成 *)
  let storage' = 
    { config     = storage.config;
      candidates = candidates';
      voters     = voters'
    }
  in

  ([], storage')

デプロイ

まず初期ストレージ値を vote_init.ml というファイルに作ります。open Vote することで vote.ml で定義されている型を使えるようにしています:

(* vote_init.ml *)
open SCaml
open Vote

let init =
  { config = { title = "senkyo";
               beginning_time = Timestamp "2022-04-01T00:00:00Z";
               finish_time = Timestamp "2022-07-01T00:00:00Z";
             };
    candidates = Map [ ( "taro", Nat 0 );
                       ( "shinzo", Nat 0 ) ];
    voters = Set [ ]
  }

./scamlc --scaml-convert vote_init.ml で変換しよう:

$ ./scamlc --scaml-convert vote_init.ml
init: Pair (Pair "senkyo"
                 (Pair "2022-04-01T00:00:00-00:00" "2022-07-01T00:00:00-00:00"))
           (Pair { Elt "shinzo" 0
                 ; Elt "taro" 0
                 }
                 { })

これで得られた Michelson の値を使ってデプロイします:

$ ./tezos-client originate contract vote transferring 0 from myself running ./vote.tz --burn-cap 100 --init 'Pair (Pair "senkyo" (Pair "2022-04-01T00:00:00-00:00" "2022-07-01T00:00:00-00:00")) (Pair { Elt "shinzo" 0 ; Elt "taro" 0 } {})'
...
Contract memorized as vote.

投票してみる

投票するにはパラメータに候補者名を指定します。文字列は SCaml も Michelson も同じ表記です。 文字列はダブルクォートで囲みますが、このダブルクォートを shell が解釈しないようにシングルクォートで囲む必要があります:

$ ./tezos-client transfer 0 from myself to vote --arg '"taro"' --burn-cap 100
...
    Transaction:
      Amount: ꜩ0
      From: tz1Uuf4NLeeEWcX7p7cDCJhPztyoqrdTTuez
      To: KT1WEhWsyFdKioijs6K3gFe83Sa92KhqFcF5
      Parameter: "taro"
      This transaction was successfully applied
      Updated storage:
        (Pair (Pair "senkyo" (Pair 1648771200 1656633600))
              (Pair { Elt "shinzo" 0 ; Elt "taro" 1 }
                    { 0x000065adba85737221057b0d408547ec9f6f9c1a6067 }))
      Storage size: 446 bytes
      Paid storage size diff: 27 bytes
      Consumed gas: 2142.014
      Balance updates:
        tz1Uuf4NLeeEWcX7p7cDCJhPztyoqrdTTuez ... -ꜩ0.00675
        storage fees ........................... +ꜩ0.00675

"taro" に一票入っているのを確認してください。投票すると自分のアドレスが新たに votes 集合に登録されるため、ストレージ総量が 27bytes 増えます。そのために storage fee ꜩ0.00675 が burn されています。1 byte につき ꜩ0.00025 ですね。

演習

  • shinzo もかわいそうなので一票入れてあげてください
  • yukiwo に投票したらどうなりますか?ブロックチェーンにトランザクションが送られるでしょうか。
  • 多重投票を防ぐにはどうしたら良いでしょうか。いろんな方法がありますが、config に投票可能なアドレスを事前に設定するのが手軽そうです。実装してみてください。