🥔

【Sui】Hot Potatoパターンを試してみる

2024/10/24に公開

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ポストが目にとまったのです。ぱっと読んだ感じ、ポテトのことをより理解できそうだと思ったので、以降はこの投稿の翻訳を解説をしていきたいと思います

https://x.com/juzybits/status/1836771640010518927

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) 簡単な解決策のコード
https://gist.github.com/0x-onigiri/ca9efea7b93e6f22ca7cbf3a42d0a4c4
https://gist.github.com/0x-onigiri/e60c26d6b39e0a4c2a3cac3e394ed536

(B) ホットポテト解決策のコード
https://gist.github.com/0x-onigiri/21142bed9aa6743409c850e2452fce0c
https://gist.github.com/0x-onigiri/00d700e6640c395b5a47b525bd3377bf

(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を増やす関数では、 GameUserRegistry という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 をマッピングする TableUserRegistry で管理しています。作成した User オブジェクトは UserRequest でラップされ、関数の戻り値になります

  • existing_user_request: User オブジェクトを入力として渡すと、 UserRequest でラップされ出力される関数です

  • destroy_user_request: destroy_user_request 関数とは逆で、 UserRequest を入力として渡すと、 アンラップして User オブジェクトを返してくれる関数です

この3つの関数をうまく利用することで、常に UserRequest を経由してユーザーの操作を管理することができるようになるのです

また、その結果共有オブジェクトの依存を減らすことができるのでした

おわりに

ほっくほくのポテトの味はいかがだったでしょうか🥔

実は、今回紹介した(A), (B)以外にも、Move初学者であれば思いつくパターンがあるので、最後にコードのリンクを記載しておきますね

(C) おまけのパターン
https://gist.github.com/0x-onigiri/7396b5978a33651487c8b11b20563260
https://gist.github.com/0x-onigiri/2f81dd6b9f1cce45faca467064b639fd

このコードが一番直感的でわかりやすく、共有オブジェクトへの依存も1つで済みます。アプリケーション要件によっては(C)パターンもありかもしれません

ただし、より複雑なアプリケーションを作る場合、ホットポテトのようなアビリティを持たない構造体によって、それを作成・破棄しなければならないライフサイクルの利点を活かした柔軟な設計が求められるのかもしれません

筆者は実務でコントラクトを書いたことがないですし、ましてや複雑なコントラクトなんて書けません。ホットポテトを使う機会があるのかわかりませんが、この記事によって「もしかしてここで使えるのかも?」と思える日が来ることを楽しみしています🥔

Discussion