【Sui】Hot Potatoパターンを試してみる
Sui Moveには、いくつかの実装パターンがあります
このうち、 Hot Potato
というパターンはアビリティを持たず、捉え所のないパターンで正直よくわかっていませんでした
Move Bookによると Hot Potato
は以下のように定義されています
アビリティ・システムにおけるケース(アビリティを持たない構造体)は、ホットポテトと呼ばれる。 この構造体は(オブジェクトとしても他の構造体のフィールドとしても)格納できず、コピーも破棄もできません。 コールバックをサポートする言語に慣れているなら、ホットポテトはコールバック関数を呼び出す義務だと考えることができる。 この名前の由来は、子供向けのゲームで、ボールがプレイヤー間で素早く渡され、音楽が止まったり、ゲームから外れたりしたときに、どのプレイヤーも最後にボールを持っている人になりたくないというものです。 ホットポテト構造体のインスタンスはコール間で渡され、どのモジュールもそれを保持できない。
わかるような、わからないような。便利そうではあるが、具体的にどんなユースケースで使えばいいのかわかりません
Move Bookに書かれているユースケースとして最もわかりやすいのはフラッシュローンでの使用ではないでしょうか
フラッシュローンとHot Potato
フラッシュローンは主に
- 資産を借入れる
- 借入れた資産を使う
- 借入れた資産を返す
といったように、一時的に資産を借りて、活用して、すぐに返すときに使う機能になります
アービトラージとかでよく使いますね。例えば手元に0.1SUIといったガス代ぐらいのSUIしか持っていなくても、アビトラの機会さえあればフラッシュローンを有効活用できます
1,000SUIを一時的に借りて、その1,000SUIでアビトラをし、結果的に1,100SUIを得られたとしたら、ローンの1,000SUIを返して100SUIが利益になります(ガス代は除く)
このフラッシュローンを実現するために Hot Potato
パターンが使われているとのことです
たしかに、最初の定義に書いてあるように、 ホットポテト構造体のインスタンスはコール間で渡され、どのモジュールもそれを保持できない。 のであれば、確実にローンの返済を求めるために適している構造体であることがわかります💡
でもこれ以外に使い所ってあるの...?
あとホットポテトってなんやねんというモヤモヤはいまだに頭から離れません🥔
そんなとき、SPAMで有名なSuiの開発者 juzy さんのXポストが目にとまったのです。ぱっと読んだ感じ、ポテトのことをより理解できそうだと思ったので、以降はこの投稿の翻訳を解説をしていきたいと思います
Xポストの翻訳
SuiアプリにおけるMoveのホットポテトパターン 🥔
典型的なオンチェーンのユーザーシステムは次の課題を解決する必要があります。
-
Suiアドレスごとに1つのUserオブジェクト(いわゆる「ユーザープロフィール」)のみを持つこと
-
Userオブジェクトが検索可能であること(「このSuiアドレスに対応するUserを教えて」)
-
アプリ内でのアクションによってUserオブジェクトが更新されること(例:ユーザー履歴の追跡など)
-
理想的には、ユーザー作成をエンドユーザーに隠すこと(追加のトランザクションの署名を求めない)
-
理想的には、ガス代や混雑を減らすために、アプリ全体で共有されるオブジェクトの使用を最小限に抑えること。
-
(A) 簡単な解決策(共有レジストリがUserオブジェクトを含む)
単純に、全てのUserオブジェクトを共有オブジェクト(例:Table)内の動的フィールドとして保存します。この方法は、ユーザーシステムの最初の4つの要件を解決しますが、最後のポイントを解決できません。
アプリ内でUserを更新するすべてのアクションが同じ共有UserRegistryにアクセスするため、スケーラビリティのボトルネックになります。
- (B) ホットポテト解決策(Userオブジェクトはユーザーが所有する)
このアプローチでは、1つのアドレスにつき1つのUserオブジェクトが存在することを強制するために、共有UserRegistryを使用します。しかし、実際にUserオブジェクトを所有するのはエンドユーザー自身です。
これはよりスケーラブルな方法で、アプリ全体で共有されるUserRegistryは初回のUser作成時にのみアクセスされ、以降のアクションではUserRegistryへのアクセスは不要になります。
Suiにはローカル(オブジェクトごと)の手数料マーケットが存在します。そのため、アプリで同じ共有オブジェクトをすべてのやり取りに使用すると、混雑とガス代の増加につながる可能性があります。ホットポテト解決策では、ほとんどのやり取りでアプリ全体のUserRegistryオブジェクトに触れる必要がないため、これを回避できます。
Xポストの要約
簡単にまとめると、 juzyさんは
「ホットポテトを使うことで、共有オブジェクトへの依存を減らすことができるよー。パフォーマンスも上がるし、ガス代も節約できるよー」
と言っているんですね
Suiには大きく2つのオブジェクトがあります。共有オブジェクトと所有オブジェクトですね
共有オブジェクトは「どのトランザクションからも読み(書き)可能」で、所有オブジェクトは「所有者が署名したトランザクションから読み(書き)可能」です
多くのアプリケーションは、共有オブジェクトまたは所有オブジェクトのみを使用するソリューションを使って構築することができますが、それぞれのトレードオフを考慮する必要があります
共有オブジェクトって?
共有オブジェクトは、ネットワーク上の誰でもアクセス可能であり、コンセンサスを通じて取引の順序付けが必要です。これは、共有オブジェクトが複数のトランザクションから同時にアクセスされる可能性があるため、ネットワークの整合性を保つために必要です。共有オブジェクトを扱うトランザクションは、「コンセンサスパス」を通じて処理され、これによりガスコストが若干高くなり、レイテンシーも増加します
所有オブジェクトって?
所有オブジェクトは、特定のアドレスによって所有されており、その所有者のみがアクセス可能です。このため、所有オブジェクトを扱うトランザクションは、コンセンサスを必要とせずに迅速に処理されます。これを「ファストパス」と呼びます。ファストパスでは、トランザクションは他のトランザクションと順序付けされることなく、迅速に処理されます
共有オブジェクトへの依存が多いとどうなる?
関数が共有オブジェクトへの依存が多い場合、トランザクションの実行パフォーマンスに影響を与える可能性があります。具体的には、以下のような影響があります:
- レイテンシーの増加: 共有オブジェクトはコンセンサスを必要とするため、トランザクションの実行に時間がかかることがあります。
- ガスコストの増加: 共有オブジェクトを扱うトランザクションは、所有オブジェクトを扱うトランザクションよりも高いガスコストがかかることがあります。
- 競合の可能性: 複数のトランザクションが同じ共有オブジェクトにアクセスしようとすると、競合が発生し、処理が遅れる可能性があります。
共有オブジェクトを使用することで、複数のアドレスが同じオブジェクトにアクセスできる柔軟性が得られますが、パフォーマンス面でのトレードオフがあることを理解しておくことが重要です
So What?
共有オブジェクトへの依存が少ないと良いことがあるということはわかった
じゃあそれとホットポテト🥔はどう関係があるのよ?というのが疑問ですね
juzyさんは親切にポテトを使わないパターンと使うパターンのコードを提示してくれました
ただ上記のコードは実際に動くコードではないこと、また例えのコードが少しわかりにくかったので、
理解を深めるためにjuzyさんのコードをベースにしつつ、実際に動く&理解しやすいコードを書いてみました
Hot Potatoパターンを実装して試してみる
カスタマイズしたコードのリンクはこちらです。
具体的にはパターン別にUserとGameコントラクトがあります
UserコントラクトではUserオブジェクトの作成や管理、またUserオブジェクトを変更できる関数を公開しています
GameコントラクトではGame共有オブジェクトの作成や、Userオブジェクトを実際に変更する関数を実装しています
(A) 簡単な解決策のコード
(B) ホットポテト解決策のコード
(A)と(B)において決定的な違いは、 Userオブジェクト の管理方法です
// user_a.move
public struct UserRegistry has key {
id: UID,
users: Table<address, User>,
}
// game_a.move
entry public fun update_point(
_: &mut Game,
registry: &mut UserRegistry,
ctx: &TxContext,
) {
let user = get_or_create_user(registry, ctx);
let point = user.point() + 1;
user.update_user_point(point);
}
(A)では UserRegistry
という共有オブジェクトに User
が格納されています。この場合 User
に対して何か変更を加えたいときは常に UserRegistry
を使用することになります。
また、 update_point
というUserのpointを増やす関数では、 Game
と UserRegistry
という2つの共有オブジェクトに依存していますね
大量のユーザーがいるようなゲームでは、トランザクションの速さはとても大事です。ユーザーの変更をするたびに共有オブジェクトがいくつも必要となる設計では問題になりそうです
では、 (B)のホットポテトパターンはどうでしょうか? コードを見てみましょう
// user_b.move
public struct UserRegistry has key {
id: UID,
users: Table<address, address>,
}
// game_b.move
public fun update_point(_: &mut Game, mut user_req: UserRequest): UserRequest {
let point = user_req.point() + 1;
user_req.update_user_point(point);
return user_req
}
UserRegistry
では User
そのものは管理していません。 Table<address, address>
では User
オブジェクト自体ではなく、オブジェクトIDのみを管理する設計になっています
結果として、 update_point
関数では、 Game
共有オブジェクト1つのみに依存しています
ここでポイントになるのは UserRequest
というオブジェクトです
// user_b.move
public struct UserRequest {
user: User
}
そうです、この UserRequest
がアビリティを一切もたない Hot Potato
の正体なのです。 UserRequest
に関連するコードを見てみましょう
// user_b.move
public fun new_user_request(
registry: &mut UserRegistry,
ctx: &mut TxContext
): UserRequest {
let sender = ctx.sender();
assert!(!registry.users.contains(sender));
let user = User {
id: object::new(ctx),
point: 0
};
registry.users.add(sender, user.id.to_address());
return UserRequest { user }
}
public fun existing_user_request(user: User): UserRequest {
return UserRequest { user }
}
public fun destroy_user_request(
user_req: UserRequest,
ctx: &TxContext
) {
let UserRequest { user } = user_req;
transfer::transfer(user, ctx.sender());
}
public(package) fun update_user_point(user_req: &mut UserRequest, point: u64) {
user_req.user.point = point;
}
-
new_user_request
:User
を作成する関数です。1度だけしか作成されないことを保証するために、ウォレットアドレスとUser
オブジェクトID をマッピングするTable
をUserRegistry
で管理しています。作成したUser
オブジェクトはUserRequest
でラップされ、関数の戻り値になります -
existing_user_request
:User
オブジェクトを入力として渡すと、UserRequest
でラップされ出力される関数です -
destroy_user_request
:destroy_user_request
関数とは逆で、UserRequest
を入力として渡すと、 アンラップしてUser
オブジェクトを返してくれる関数です
この3つの関数をうまく利用することで、常に UserRequest
を経由してユーザーの操作を管理することができるようになるのです
また、その結果共有オブジェクトの依存を減らすことができるのでした
おわりに
ほっくほくのポテトの味はいかがだったでしょうか🥔
実は、今回紹介した(A), (B)以外にも、Move初学者であれば思いつくパターンがあるので、最後にコードのリンクを記載しておきますね
(C) おまけのパターン
このコードが一番直感的でわかりやすく、共有オブジェクトへの依存も1つで済みます。アプリケーション要件によっては(C)パターンもありかもしれません
ただし、より複雑なアプリケーションを作る場合、ホットポテトのようなアビリティを持たない構造体によって、それを作成・破棄しなければならないライフサイクルの利点を活かした柔軟な設計が求められるのかもしれません
筆者は実務でコントラクトを書いたことがないですし、ましてや複雑なコントラクトなんて書けません。ホットポテトを使う機会があるのかわかりませんが、この記事によって「もしかしてここで使えるのかも?」と思える日が来ることを楽しみしています🥔
Discussion