Tezos ブロックチェーン のためのスマートコントラクト記述言語 SCaml、チュートリアルの第7回目です。
今回はもう少し複雑は SCaml コントラクトを書いてみます。
- コンセプトの紹介(英語)
- リリースのお知らせ(英語)
- プロジェクトページ
- レポジトリ
- 第0回: コンセプトの説明
- 第1回: 準備
- 第2回: 概観
- 第3回: ウォレット操作
- 第4回: スマートコントラクトのデータ型
- 第5回: はじめての SCaml コントラクト
- 第6回: もう少し複雑なコントラクト。パラメータとストレージ
- 第7回: 投票コントラクトを書いてみる
#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
...
候補者リストに投票を加える
投票候補である name
が storage.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
に投票可能なアドレスを事前に設定するのが手軽そうです。実装してみてください。