正直私もそんなに詳しくないので、仮想通貨周りの用語を正しく使用できているかどうか全く自信が無い。
よって、この記事では相当変な事を書いている可能性があるので、注意して読んでいただきたい。
仮想通貨で仮想通貨を作る?
単に「仮想通貨」とか「コイン」とか呼んだ場合、ブロックチェーンの上で実現される仮想通貨全般を意味することが多いと思う。
ただこの「ブロックチェーンの上で実現される」というものにも2種類あって、そのブロックチェーンのシステムの中に組み込まれているもの(所謂「ビットコイン」とか「イーサリアム」とか呼んだ場合、これを指すと思う。 Tezos も同じ)と、ユーザが独自に実装するものがある。
既存のブロックチェーン上に勝手に仮想通貨を乗っけることができるの? と思うかもしれないが、これは割と広く行われていることらしい。 Tezos ではなくイーサリアムを使う場合が多いのだろうが、既存のブロックチェーンのスマートコントラクト機能を利用して、トークンをやり取りする仕組みで独自の仮想通貨を構築することができる。ここで言うトークンというのは、それぞれのアカウントの保有数が公になっており、誰でも確認でき、また取引履歴等を調べることもできる、まさに暗号通貨そのものだ。
独自の暗号通貨をそんな簡単に作れるものかとも思うのだが、思ったほど難しくは無いらしい。というのも、上に書いた通り「既存のブロックチェーンの上に」構築するものだからだ。当然、その独自のトークンのやり取りは土台となるブロックチェーンの機能を利用して行われることになる。という事は、土台となるブロックチェーンが提供している取引の記録が、そのまま独自トークンのやり取りの記録として利用できるという事になる。
トークンの規格
このようにスマートコントラクトを利用して独自トークンを構築することは広く行われている。という事は、それらをまとめて扱ったり、複数種類のトークンを管理したり相互にやり取りしたりといった機能にもまた需要が生まれてくる。ウォレット、エクスプローラ、マーケットプレイス等と呼ばれるアプリケーションだ。
なのだが、皆が皆それぞれの方法で、殊に独自のインターフェイスでトークンを実装していては、対応するのが面倒になる。これはトークンを作る側からしても問題で、折角トークンを作ったのに誰にも使ってもらえないという事に繋がる。
というわけで、トークンを扱う際のインターフェイスを共通化し規格にしようという動きが出てくる。
イーサリアムでは、 ERC-20やERC-721と言った規格でそのインターフェイスを定めている。他にも幾つか規格があり、以下のページで確認できる。
トークンにも幾つか種類があって、最も有名なのは fungible token と non-fungible token の違いだと思うが、他にも細かく種類があって、それぞれ必要とされるインターフェイスが異なるらしい。
一方の Tezos も、 FA1.2という名前で規格が定めてある。この規格に則ってトークンを実装するだけで、 Tezos のトークン用に作られた各種サービスの恩恵を受けることができる、というわけである。
というわけで、この規格を参考に Tezos のスマートコントラクトで独自トークンを実装してみたいと思う。
なお、 Tezos のトークン規格はもう一つ FA2 というものがあるのだが、 FA1.2 は fungible token のみを扱い、 FA2 が non-fungible token も含めてカバーしているらしい。単純に数字を管理すれば良い fungble token の方が実装が幾分楽だろう、という雑な考えから、今回は FA1.2 の方を使ってみる。
独自トークンの実装
さて、ここに LIGO で書かれた FA1.2 準拠のトークンの参考実装が存在する。
最小機能のトークンの実装はだいたい似たり寄ったりの構造になるだろうから、このコードを理解しながら SCaml に移植することで、独自トークンを実装していきたいと思う。
要求されるインターフェイス
FA1.2 によると、これに準拠するトークンのスマートコントラクトは、以下の5つのエントリーポイントを持っていなければいけないらしい。
(address :from, (address :to, nat :value)) %transfer
(address :spender, nat :value) %approve
(view (address :owner, address :spender) nat) %getAllowance
(view (address :owner) nat) %getBalance
(view unit nat) %getTotalSupply
これらのエントリーポイントの挙動についても、規格で定めてある。
ではコードを見ながら、一つ一つの機能について見て言ってみよう。
型定義
まず、スマートコントラクト内で利用される型定義を最初にしておこう。
type allowance_key = {
owner : address;
spender : address
}
type tokens = (address, nat) big_map
type allowances = (allowance_key, nat) big_map
type storage = {
tokens : tokens;
allowances : allowances;
total_supply : nat
}
SCaml では storage
と言う名前のレコードを定義すると、それがそのスマートコントラクトのストレージの型として扱われる。
このスマートコントラクトでは、 tokens
と allowances
と total_supply
の値を情報として保持しているというわけだ。
tokens
と allowances
はどちらも big_map 型の値である。 big_map は連想配列の一種で、 Michelson に組み込み型として入っている物らしい。 Michelson の連想配列にはもう一つ map 型もあるようなのだが、 map 型は最大要素数が決まっているような場合に利用し、 big_map はどれだけ多くのデータが入るか分からないような場合に使うのが良いらしい。
tokens
は利用者のトークン情報を全て、 allowances
は同じく allowance 情報を全て保持しておくので、ユーザ数が増加するにつれて要素数も多くなっていく。なので、 big_map を利用している。
Allowance
ところで token はともかく、 allowance とは何だろうか。これはあるアカウントが別のアカウントに、自分のトークンを他に送付する権利を付与する仕組みらしい。
例えば、アカウント A が200トークンを持っているとする。ここで、 A が B に、50トークン分の allowance を付与する。そうすると、 B は A のトークンを50まで、任意のアカウントに送付することができる。 C や D といった全然別のアカウントに送っても良いし、 B 自身のアカウントに送付することもできる。ただし、 B に与えられた allowance では50トークン分しか権利がないので、 A が200トークン持っていたとしても50トークンまでしか操作する事はできない。総額で50トークンなので、例えば B が C に20トークン送付すると、その時点で B が持つ allowance の残りは 30 トークンになる。また、送付元の A のトークンの総量を超えて転送することも当然できない。 A が B に allowance を与えた後で自分のトークンを使い込み、残りが10トークンになってしまった場合、 B は50トークン分の allowance を持っていても10トークンまでしか動かせない。
何のための機能なんだろう? と思っていたのだが、どうもウォレットアプリなどを利用する際にそのアプリに送金操作を委譲する為に使うことが想定されているらしい。
ちなみに、アカウントの識別は Tezos のアドレスで行う。
ユーティリティ関数
便利関数を2つ、予め定義しておく。
let check_amount () : unit =
if Global.get_amount () <> Tz 0.0 then
failwith "DontSendTez"
else
()
let positive n : nat option =
if n = Nat 0 then
None
else
Some n
check_amount
関数はこのスマートコントラクトに対して送金されていないかを確認する関数だ。
トークン情報を管理するためだけのスマートコントラクトなので(こういうのを「台帳」と呼ぶらしい)、 Tezos を送金されても困る。なので、少しでも送金があればエラーにするというわけだ。
次の positive
関数は少し意図が分かりづらいかもしれない。この関数は、与えられた数字(自然数型を受け取っている。 Michelson は零も自然数に含める派らしい)が 0
なら None を、それ以外なら Some でその数字を包んで返すというものだ。
天下り的に説明をすると、この関数は BigMap.update
関数の第2引数に数値を渡すために利用する。 BigMap.update
は 'k -> 'v option -> ('k, 'v) big_map -> ('k, 'v) big_map
という型なのだが、この2つめの引数が option 型になっているのがポイントで、ここが Some x
の場合はその x
の値でキーの値を更新、または挿入し、 None
の場合はそのキーを削除するという挙動になっている。
つまり、更新した結果が 0
の場合はキー自体を big_map から削除したいが為に、この関数を噛ませているわけだ。
このプログラムの中では 0
の値で更新しても挙動は同じになるのだが、スマートコントラクトはストレージの容量に応じて手数料が発生するので、不要な情報は可能な限り削除した方が良い、という事なのだろう。
では、各エントリーポイントを見ていこう。
transfer
このエントリーポイントは、 from
アカウントから to
アカウントに value
分のトークンを送付する。
コードは全エントリーポイント中で一番大きい。
type transfer = {
from : address;
to_ : address;
value : nat
}
let [@entry] transfer : (transfer, storage) entry = fun {from; to_; value} ({tokens; allowances; _} as storage) ->
check_amount ();
let allowances =
if Global.get_sender () = from then
allowances
else
let allowance_key = { owner= from; spender= Global.get_sender () } in
let authorized_value = BigMap.get_with_default allowance_key allowances (Nat 0) in
let authorized_value =
match isnat (authorized_value -^ value) with
| None -> failwith "NotEnoughAllowance"
| Some authorized_value -> authorized_value
in
BigMap.update allowance_key (positive authorized_value) allowances
in
let tokens =
let from_balance = BigMap.get_with_default from tokens (Nat 0) in
let from_balance =
match isnat (from_balance -^ value) with
| None -> failwith "NotEnoughBalance"
| Some from_balance -> from_balance
in
BigMap.update from (positive from_balance) tokens
in
let tokens =
let to_balance = BigMap.get_with_default to_ tokens (Nat 0) in
let to_balance = to_balance +^ value in
BigMap.update to_ (positive to_balance) tokens
in
[], { storage with tokens; allowances }
引数型のレコードのフィールドが、本来は to
であるところが to_
になっている。
これは、 to
が OCaml の予約語でフィールド名としても使えない為だ。
LIGO だとこういう場合はアノテーションを使って Michelson にコンパイルされた際の名前を操作しているが、 SCaml で同様の事を行う方法が分からなかったので、とりあえずそのまま置いている。
ちょっと長いので、幾つかに分けて見ていこう。
let allowances =
if Global.get_sender () = from then
allowances
else
let allowance_key = { owner= from; spender= Global.get_sender () } in
let authorized_value = BigMap.get_with_default allowance_key allowances (Nat 0) in
let authorized_value =
match isnat (authorized_value -^ value) with
| None -> failwith "NotEnoughAllowance"
| Some authorized_value -> authorized_value
in
BigMap.update allowance_key (positive authorized_value) allowances
in
これは allowance の確認と更新の処理。
まず sender 、つまりこのスマートコントラクトを呼び出した相手がトークンの送信元かどうかを確認している。
もし呼び出し元が送信元と同じなら、それ以上は何もしない。 allowance 情報も変更しない。
そうでない場合は allowance 情報を調べて、送付量に足りる allowance を持っているかを確認し、問題無いなら送付した分だけ allowance を減らしている。持っていない場合はエラーを出す。
isnat (authorized_value -^ value)
の部分だが、 SCaml では数値型に応じて異なる演算子を使い、自然数型は ^
を付けた演算子を用いる。ただし、 -^
の型は nat -> nat -> int
であり、結果は int 型になる。引き算の結果は負の数になる可能性があるからだ。
isnat 関数は int -> nat option
という型で、ご想像通り int 型を nat 型に変換する。無論、負の数だった場合は None になるというわけだ。
let tokens =
let from_balance = BigMap.get_with_default from tokens (Nat 0) in
let from_balance =
match isnat (from_balance -^ value) with
| None -> failwith "NotEnoughBalance"
| Some from_balance -> from_balance
in
BigMap.update from (positive from_balance) tokens
in
送付元のトークンを減らす処理。上の allowance の処理と似ていて、送付元のトークンが送付量に足りなければエラーになるようになっている。
let tokens =
let to_balance = BigMap.get_with_default to_ tokens (Nat 0) in
let to_balance = to_balance +^ value in
BigMap.update to_ (positive to_balance) tokens
in
送付先のトークンを増やす処理。こちらは、単純に送付量分トークンを増やすだけなので、基本的にエラーは起きない。
[], { storage with tokens; allowances }
エントリーポイントの関数の返値型は operation list * storage
である。
今回は追加の操作は何もないので、更新した storage の値を返して終わりである。
approve
このエントリーポイントは、呼び出し元のアカウントが他のアカウントに allowance を与える(または 0
にする)。
type approve = {
spender : address;
value : nat
}
let [@entry] approve : (approve, storage) entry = fun {spender; value} ({allowances; _} as storage) ->
check_amount ();
let allowance_key = { owner= Global.get_sender (); spender } in
let previous_value = BigMap.get_with_default allowance_key allowances (Nat 0) in
if previous_value > (Nat 0) && value > (Nat 0) then
failwith "UnsafeAllowanceChange"
else
let allowances = BigMap.update allowance_key (positive value) allowances in
[], { storage with allowances }
approve は transfer とは違って、付与元は必ずエントリーポイントの呼び出しアカウントとなる為、付与先のアドレスと allowance の量だけを引数に取る。
ところで、元々 allowance の値が存在する場合に、 0
ではない別の値に設定しようとするとエラーになるよう実装されている。これは、 corresponding attack vector という攻撃を防ぐための工夫らしい。
getAllowance
type getAllowance = {
request : allowance_key;
callback : nat contract
}
let [@entry] getAllowance : (getAllowance, storage) entry = fun {request; callback} ({allowances; _} as storage) ->
check_amount ();
let value = BigMap.get_with_default request allowances (Nat 0) in
[Operation.transfer_tokens value (Tz 0.0) callback], storage
2つのアカウントの間に設定された allowance を取得する。
ここからの3つのエントリーポイントは、 (view (address :owner, address :spender) nat)
のように view
というインターフェイスが指定されている。これは view 'a 'b
= ('a, contract 'b)
という糖衣構文らしく、要するにコールバック用の他のスマートコントラクトの宛先を受け取るエントリーポイントということだ。
どうしてこのようなインターフェイスになっているのだろうか。
スマートコントラクトのエントリーポイントは、関数というか RPC のようなものと看做すことができるのだけど、それとは違って返り値を取得することができない。打ちっぱなしである。よって、返り値を受け取るような処理を書こうとすると、後続処理を別のエントリーポイントに分けて、コールバックとしてそのエントリーポイントを叩いてもらう必要があるというわけだ。返り値はその呼び出しの引数として渡してもらう。
何だか複雑そうだが、一種の継続渡しスタイルだと思えばまぁ素直に思えるのでは無いだろうか。
というわけで、今まで空リストを返していた operation list
の戻り値を活用していく。 Operation.transfer
関数は他のスマートコントラクトに Tezos を送金する operation を作る。引数、送金する Tezos の量、そして宛先を指定する。これを使って、コールバック先に取得した値を渡そう。今回は値を返すことが目的なので、送金する Tezos は 0.0
で良い。
これで、呼び出し元が欲しがっていた情報を返すことができた。
getBalance
type getBalance = {
owner : address;
callback : nat contract
}
let [@entry] getBalance : (getBalance, storage) entry = fun {owner; callback} ({tokens; _} as storage) ->
check_amount ();
let value = BigMap.get_with_default owner tokens (Nat 0) in
[Operation.transfer_tokens value (Tz 0.0) callback], storage
あるアカウントが持つトークン量を返す。
getAllowance とほぼ同じなので特筆すべき事項は無いかな。
getTotalSupply
type getTotalSupply = {
request : unit;
callback : nat contract
}
let [@entry] getTotalSupply : (getTotalSupply, storage) entry = fun {callback; _} ({total_supply; _} as storage) ->
check_amount ();
[Operation.transfer_tokens total_supply (Tz 0.0) callback], storage
トークンの総量を返す。
今までの処理でこの「トークンの総量」を変化させる処理が全く存在しなかったことに気付いたかもしれない。 allowance の付与やトークンの授受ではトークンの総量は変化しないから、そういう処理が入り込む余地はないのだ。
ではトークンの総量はどこで決めるかというと、最初にスマートコントラクトを登録する際のストレージの初期値設定の際となる。この時に、最初にトークンを誰に与えるかを設定し、その総量を total_supply
に入れておく。その値を取得するのがこのエントリーポイントというわけだ。
勿論これは、このスマートコントラクトの話であり、実際の独自トークンの場合は、その仕組みによって総量が変わってくることもあり得る。
まとめ
これで、独自のトークンを Tezos のスマートコントラクトの上に構築することができた。
では早速 ghostnet に登録して動作確認を……と言いたいところだけど、長くなったので今回はこの辺で。