Faucet機能を実装しよう
🚰 Faucet 機能を実装しよう
このプロジェクトでは、DEX上で扱うオリジナルのトークンをユーザー自身が簡単に取得できるように、便宜上Faucet機能をつけたいと思います。
まずは、Faucetの役割をするキャニスターを作成します。作成後、DIP20キャニスターとFaucetキャニスターをデプロイしてDIP20キャニスターのmint
メソッドをコールします。Faucetキャニスターに対しミントを行うことで、一定量のトークンを保持させます。
Faucetキャニスター自身の機能としては以下になります。
- ユーザーにトークンを転送する
- トークンを渡したユーザーのデータを保持する
データを保持する目的としては、トークンの配布を一人のユーザーに対し一度だけと制限するためです。
それでは、実装していきましょう。まずは、Faucetキャニスターのコードを置くfaucet
ディレクトリを作成して、中に2つのファイルmain.mo
とtypes.mo
作成します。src
ディレクトリのフォルダ構成を以下のように更新しましょう。
src/
├── DIP20/
├── declarations/
+├── faucet/
+│ ├── main.mo
+│ └── types.mo
├── icp_basic_dex_backend/
└── icp_basic_dex_frontend/
では、実際にコードを書いていきます。
Motokoでは、モジュール間で共通して使用したいユーザー定義の型や、別のキャニスターのメソッドを呼び出すためのインタフェースを特定のファイルにまとめて定義し、インポートして使用する方法が一般的にとられます。まずは、types.mo
ファイルにDIP20キャニスターから呼び出したいメソッドやfaucetキャニスターが使用するユーザー定義の型を記述します。
以下のコードを、faucet/types.mo
に記述します。
[types.mo]
module {
// ===== DIP20 TOKEN INTERFACE =====
public type TxReceipt = {
#Ok : Nat;
#Err : {
#InsufficientAllowance;
#InsufficientBalance;
#ErrorOperationStyle;
#Unauthorized;
#LedgerTrap;
#ErrorTo;
#Other : Text;
#BlockUsed;
#AmountTooSmall;
};
};
public type DIPInterface = actor {
balanceOf : (who : Principal) -> async Nat;
transfer : (to : Principal, value : Nat) -> async TxReceipt;
};
// ===== FAUCET =====
public type FaucetReceipt = {
#Ok : Nat;
#Err : {
#AlreadyGiven;
#FaucetFailure;
#InsufficientToken;
};
};
};
コードを確認します。
module{}
の中に、3つの型を定義しました。1つ目のtype TxReceipt
は、DIP20キャニスターが使用している型です。この型は、後述している2つ目のtype DIPInterface
の中で戻り値の型として使用されています。
type DIPInterface
には、DIP20キャニスターからコールしたい関数を定義しています。
[ 関数名 ] : [ (引数) ] -> [ 戻り値 ]
という形式で記述します。
public type DIPInterface = actor {
balanceOf : (who : Principal) -> async Nat;
transfer : (to : Principal, value : Nat) -> async TxReceipt;
};
3つ目のtype FaucetReceipt
は、faucetキャニスターが使用する型になります。
次に、main.mo
ファイルにFaucetキャニスターのコードを記述しましょう。まずは、必要なライブラリのインポートとデータを定義します。
[main.mo]
import Array "mo:base/Array";
import Buffer "mo:base/Buffer";
import HashMap "mo:base/HashMap";
import Principal "mo:base/Principal";
import T "types";
shared (msg) actor class Faucet() = this {
private type Token = Principal;
private let FAUCET_AMOUNT : Nat = 1_000;
// ユーザーとトークンをマッピング
// トークンは、複数を想定して配列にする
private var faucet_book = HashMap.HashMap<Principal, [Token]>(
10,
Principal.equal,
Principal.hash,
);
}
コードを見ていきましょう。
最初に、型のライブラリをインポートしています。Motokoでは、型が持つ関数を使用したいときにはインポートする必要があります。mo:base
は、Motokoのベースライブラリを指しています。
Principal
型とは、ICP上でユーザーとキャニスターに付与されるIDを示す型です。
最後のインポート文は、先ほど実装したtypes.mo
をfaucet.mo
内で使用するためのものです。
import Array "mo:base/Array";
import Buffer "mo:base/Buffer";
import HashMap "mo:base/HashMap";
import Principal "mo:base/Principal";
import T "types";
次に、Faucet
を定義しています。
最初に、内部で使用する型を定義しています。Principal
型は先ほど紹介したように、ユーザーだけではなくキャニスターにも割り振られます。そのため、コード内でユーザーとDIP20キャニスター(トークンキャニスター)を区別しやすいようにToken
という別名をつけています。
shared (msg) actor class Faucet() = this {
private type Token = Principal;
次に、ユーザー一人に渡すトークンの量を定義しています。Motokoでは、let
キーワードが不変(イミュータブル)、var
キーワードが可変(ミュータブル)の変数を定義します。
private let FAUCET_AMOUNT : Nat = 1_000;
次に、トークンを受け取ったユーザーとそのトークンを記録するfaucet_book
変数を定義しています。Principal
型として受け取るのがユーザー ID、[Token]
は配列になっており、複数のトークンをユーザに紐づけて保存できるようにしています。
// ユーザーとトークンをマッピング
// トークンは、複数を想定して配列にする
var faucet_book = HashMap.HashMap<Principal, [Token]>(
10,
Principal.equal,
Principal.hash,
);
このような構造になるイメージです。
{
user1 : [tokenA, tokenB],
user2 : [tokenA],
user3 : [tokenB, tokenC],
}
また、hashMap
は3つの引数を取ります。第一引数に初期のサイズ、第二引数にHashMapのkey
同士を比較するために使用する関数、第三引数にkey
をハッシュ化するために使用する関数を指定します。Principal
型にはequal
とhash
がそれぞれ定義されているので、ここではその関数を指定しています。
続いて、関数を定義しましょう。以下のように関数をfaucet_book
変数の下に追加してください。
[main.mo]
shared (msg) actor class Faucet() = this {
private type Token = Principal;
private let FAUCET_AMOUNT : Nat = 1_000;
// ユーザーとトークンをマッピング
// トークンは、複数を想定して配列にする
private var faucet_book = HashMap.HashMap<Principal, [Token]>(
10,
Principal.equal,
Principal.hash,
);
+ public shared (msg) func getToken(token : Token) : async T.FaucetReceipt {
+ let faucet_receipt = await checkDistribution(msg.caller, token);
+ switch (faucet_receipt) {
+ case (#Err e) return #Err(e);
+ case _ {};
+ };
+
+ // `Token` PrincipalでDIP20アクターのインスタンスを生成
+ let dip20 = actor (Principal.toText(token)) : T.DIPInterface;
+
+ // トークンを転送する
+ let txReceipt = await dip20.transfer(msg.caller, FAUCET_AMOUNT);
+ switch txReceipt {
+ case (#Err e) return #Err(#FaucetFailure);
+ case _ {};
+ };
+
+ // 転送に成功したら、`faucet_book`に保存する
+ addUser(msg.caller, token);
+ return #Ok(FAUCET_AMOUNT);
+ };
+
+ // トークンを配布したユーザーとそのトークンを保存する
+ private func addUser(user : Principal, token : Token) {
+ // 配布するトークンをユーザーに紐づけて保存する
+ switch (faucet_book.get(user)) {
+ case null {
+ let new_data = Array.make<Token>(token);
+ faucet_book.put(user, new_data);
+ };
+ case (?tokens) {
+ let buff = Buffer.Buffer<Token>(2);
+ for (token in tokens.vals()) {
+ buff.add(token);
+ };
+ // ユーザーの情報を上書きする
+ faucet_book.put(user, Buffer.toArray<Token>(buff));
+ };
+ };
+ };
+
+ // Faucetとしてトークンを配布しているかどうかを確認する
+ // 配布可能なら`#Ok`、不可能なら`#Err`を返す
+ private func checkDistribution(user : Principal, token : Token) : async T.FaucetReceipt {
+ // `Token` PrincipalでDIP20アクターのインスタンスを生成
+ let dip20 = actor (Principal.toText(token)) : T.DIPInterface;
+ let balance = await dip20.balanceOf(Principal.fromActor(this));
+
+ if (balance == 0) {
+ return (#Err(#InsufficientToken));
+ };
+
+ switch (faucet_book.get(user)) {
+ case null return #Ok(FAUCET_AMOUNT);
+ case (?tokens) {
+ switch (Array.find<Token>(tokens, func(x : Token) { x == token })) {
+ case null return #Ok(FAUCET_AMOUNT);
+ case (?token) return #Err(#AlreadyGiven);
+ };
+ };
+ };
+ };
};
ここでは、外部から呼び出し可能なpublic
関数を1つ、内部で使用するprivate
関数を2つ実装しています。
順番に見ていきましょう。
最初の関数は、ユーザーがトークンを受け取るためにコールする関数です。引数にトークンキャニスターのPrincipal(ID)を受け取り、戻り値はtypes.mo
で定義したFaucetReceipt
を返します。
public shared (msg) func getToken(token : Token) : async FaucetReceipt
内部では、最初に配布可能かどうかの確認を行います。後述する内部関数checkDistribution
からエラーが返ってきたらその時点でreturn
をして終了します。
let faucet_receipt = await checkDistribution(msg.caller, token);
switch (faucet_receipt) {
case (#Err e) return #Err(e);
case _ {};
};
エラーでなければ、トークンの転送を行います。ここで、DIP20トークンキャニスターのtransfer
メソッドをコールします。
まずは、キャニスター Principalを使用してキャニスターのインスタンス(実体)を生成します。このとき、actor
はText
型(文字列)を引数に受け取るので、toText()
で変換します。インスタンスの型には、types.mo
で定義した型DIPInterface
を明示的に指定しています。
インスタンスが生成されたら、実際にtransfer
をコールします。msg.caller
には、getToken
関数を呼び出したユーザーのPrincipalが格納されています。transfer
からエラーが返ってきたらreturn
をして終了します。
// DIP20のインスタンスを生成
let dip20 = actor (Principal.toText(token)) : T.DIPInterface;
// トークンを転送する
let txReceipt = await dip20.transfer(msg.caller, FAUCET_AMOUNT);
switch txReceipt {
case (#Err e) return #Err(#FaucetFailure);
case _ {};
};
transfer
に成功した場合、トークンを付与したことを記録するためにユーザー Principalとトークンをデータ(faucet_book
)に保存します。
// 転送に成功したら、`faucet_book`に保存する
addUser(msg.caller, token);
return #Ok(FAUCET_AMOUNT);
最後の2つは、内部でのみ使用するprivate
関数になります。
checkDistribution
関数は、トークンの残高が十分か・ユーザーが既に受け取っていないかを確認する関数になります。
まずは、Faucetキャニスター内にトークンが残っているかをbalanceOf
メソッドをコールして確認します。このとき渡すPrincipalはthis
を変換したもので、this
はFaucet自身を指すキーワードです。Principal.fromActor()
を使うことにより、値をbalanceOf
が受け取れるPrincipalに変換することができます。
残高が配布する量より少ない場合は、エラーを返して終了します。
// `Token` PrincipalでDIP20アクターのインスタンスを生成
let dip20 = actor (Principal.toText(token)) : T.DIPInterface;
let balance = await dip20.balanceOf(Principal.fromActor(this));
if (balance < FAUCET_AMOUNT) {
return (#Err(#InsufficientToken));
};
次に、ユーザーがトークンをまだ受け取っていないかを確認します。ポイントは2つ目のswitch
文です。トークンの有無をArray.find
関数を使用して検索しています。HashMapの初期化にkey
に対して行う処理の関数を渡す必要がありましたが、Array.find
にも比較を行う関数を渡す必要があります。HashMapとの違いは内部で扱う型にあります。Array.find
では、扱う型がユーザー定義型Token
の配列です。そのため、比較関数を自分で定義して渡す必要があります。
// ユーザーのデータがあるか
switch (faucet_book.get(user)) {
case null return #Ok(FAUCET_AMOUNT);
case (?tokens) {
// トークンが既に配布されているか
switch (Array.find<Token>(tokens, func(x : Token) { x == token })) {
case null return #Ok(FAUCET_AMOUNT);
case (?token) return #Err(#AlreadyGiven);
};
};
};
最後のaddUser
関数は、配布したユーザーのデータを保存するための関数です。
ユーザー自体のデータがない場合は、新しく配列を生成してfaucet_book
変数に追加します。
case null {
let new_data = Array.make<Token>(token);
faucet_book.put(user, new_data);
};
ユーザーのデータが存在する場合は、今回配布したトークンのデータを[Token]配列に追加します。ここで使用しているBuffer
とは、任意の要素数まで拡張可能な汎用・可変なものになります。そのため、初期化時にサイズが決定し変更不可能なArray
(配列)を補完してくれます。
case (?tokens) {
let buff = Buffer.Buffer<Token>(2);
for (token in tokens.vals()) {
buff.add(token);
};
// ユーザーの情報を上書きする
faucet_book.put(user, Buffer.toArray<Token>(buff));
};
📝 設定ファイル dfx.json を編集しよう
それでは、作成したFaucetキャニスターの情報をdfx.json
に追加します。dfx.json
ファイルの"icp_basic_dex_backend"
下に、以下を参考に"faucet"{...}
を追記しましょう。
[dfx.json]
{
"canisters": {
"icp_basic_dex_backend": {
"main": "src/icp_basic_dex_backend/main.mo",
"type": "motoko"
},
"faucet": {
"main": "src/faucet/main.mo",
"type": "motoko"
},