🎮

Code理解 SuperPlay (ETHGlobalTokyo2023 )

2023/05/13に公開

4月に行われたETHGlobalTokyo2023 でFinalistに選ばれた作品のソースコードを理解していきます。

https://ethglobal.com/showcase/superplay-5zr28

Project説明の要約

SuperPlayは、Web3ゲームを簡単に試せるようにするプラットフォームです。メール登録で簡単にアカウントが作成でき、試用期間中はガス代も無料です。

ユーザーは、ゲームプロジェクトのウェブサイトでメールアドレスを登録し、Safeコントラクトウォレットが作成されます。試用期間(例:1週間)中は無料でゲームをプレイでき、その間のガス代はプロジェクトが負担します。試用期間終了後、クレジットカードでERC20トークンを購入し、自動的にウォレットから料金が引かれる定期購読を選べます。

SuperPlayは、Safe Contract Wallet SDK、Safe Relay Kits、Superfluidなどの技術を利用して、使いやすく安全な分散型ゲームの世界への入り口を提供しています。これにより、暗号通貨に詳しくないユーザーでも簡単にWeb3ゲームを試せるようになります。

個人的な期待

Gameが使いやすいということはWeb2のどんなAppも使いやすくなるということだと思います。かなり色々賞をもらっていますので、関連技術も多く使用していると想定します。

特にSafeについてはきちんとStudyしたいと思っていましたのでコードとともに理解していきたいと思います。そして、定期購読(Subscription)も NPOなどをDAO化するときに是非取り入れたいと思っていたので、利用している技術を理解したいと思います。

Github readme 要約

https://github.com/Rashmi-278/SuperPlay-ETH-Tokyo-2023

SuperPlayは、Web3ゲームを試す際のウォレット設定や資産購入の煩わしさを取り除くことで、新規プレイヤーにシームレスでユーザーフレンドリーな導入プロセスを提供します。ゲーマーはメールアドレスで登録し、自動的にウォレットが作成されます。試用期間中(例:1週間)はゲームを無料でプレイでき、その間のガス料金はBiconomyを使ってゲームプロジェクトが負担します。試用期間後、プロジェクトのERC20トークンで定期購読料を支払うことができます。

SuperPlayの主な機能:

  • メール登録でウォレット作成
  • ENSを使った自動サブドメイン作成
  • ウォレット内の資産を使わずに限定時間の試用が可能
  • ゲームプロジェクトのERC20トークンを使用した定期購読サービス
  • 試用期間中のガス料金をBiconomyでカバー

また、この例では、Next.jsアプリ内でchakra-uiコンポーネントライブラリを使用する方法が示されています。Next.jsとchakra-uiはTypeScript宣言を組み込んでいるため、モジュールの自動補完がすぐに利用できます。Chakra-uiのスタイルプロップを使用したコンポーネントも作成されています。

VercelまたはStackBlitzを使ってデプロイでき、create-next-appコマンドを実行してアプリケーションをブートストラップできます。また、Vercelのドキュメントに従ってクラウドにデプロイすることもできます。

メール登録でウォレット作成

まずメール登録でのウォレット作成の部分です。
web3Authを利用してログイン、ユーザー情報管理、
その後SafeAccountと連携させています。それぞれみていきましょう。

web3 CreateAccount (メアド、SNSでのログイン)

web3AuthはWalletや、メアドやSNSアカウントでログイン、 MPC(Multi Party Computation) Wallet を提供する Wallet as a Service です。

SuperPlayのなかでは下記のようにユーザーがはじめるときにまずWeb3でユーザー認証するという
ユーザー情報の管理をすべて任せるというかたちになっていてアプリ側はとてもシンプルな作りにできるのですね。

src/components/Landing.tsx
	web3AuthService.connect("0x5").then(() => {
	  router.push("/createAccount");
	});
src/services/web3Auth.ts
  async connect(chainId: string): Promise<Provider> {
    this.web3Auth = new Web3Auth({
      chainConfig: {
        chainNamespace: "eip155",
        chainId,
      },
      clientId:
        "XXX",
    });

    await this.web3Auth.initModal({});
    const web3AuthProvider = await this.web3Auth.connect();
    if (!web3AuthProvider) {
      throw new Error("Could not connect to Web3Auth provider.");
    }

    this.web3AuthProvider = web3AuthProvider;
    this.chainId = chainId;
    this.web3Provider = new Web3Provider(web3AuthProvider);
    return this.web3Provider;
  }

上記のweb3Auth.initModal({}) で下記のようなログインダイアログがでてきます。

src/pages/createAccount.tsx
  const createSafe = useCallback(async () => {
    setLoading(true);

    try {
      const addr = await web3AuthService.getProvider().getSigner().getAddress();
      await axios.post("/api/create_safe", { ethAddress: addr });
    } catch (err) {
      setLoading(false);
      throw err;
    }
    setLoading(false);
  }, []);

メールや、SNSのアカウントでWeb3をLoginした場合、上記のaddr は 何がかえってくるのでしょうか?
Browserでbreakして地道に追っていくと下記のようなながれでした

@ethersproject/providers/lib.esm/json-rpc-provider.js?8679
    getAddress() {
        if (this._address) {
            return Promise.resolve(this._address);
        }
        return this.provider.send("eth_accounts", []).then((accounts) => {
            if (accounts.length <= this._index) {
                logger.throwError("unknown account #" + this._index, Logger.errors.UNSUPPORTED_OPERATION, {
                    operation: "getAddress"
                });
            }
            return this.provider.formatter.address(accounts[this._index]);
        });
    }
    send(method, params) {
        return this.jsonRpcFetchFunc(method, params);
    }
@ethersproject/providers/lib.esm/web3-provider.js?6870
function buildEip1193Fetcher(provider) {
    return function (method, params) {
        if (params == null) {
            params = [];
        }
        const request = { method, params };
        this.emit("debug", {
            action: "request",
            fetcher: "Eip1193Fetcher",
            request: deepCopy(request),
            provider: this
        });
        return provider.request(request).then((response) => {
            this.emit("debug", {
                action: "response",
                fetcher: "Eip1193Fetcher",
                request,
                response,
                provider: this
            });
            return response;
        }, (error) => {
            this.emit("debug", {
                action: "response",
                fetcher: "Eip1193Fetcher",
                request,
                error,
                provider: this
            });
            throw error;
        });
    };
}

一般的なEthersが提供しているJsonRPCProviderEIP1193互換のRPCProviderをweb3AuthのOpenLoginというSDKが提供しています。

https://github.com/torusresearch/OpenLoginSdk

@toruslabs/openlogin-jrpc/dist/openloginJrpc.esm.js?8aa9
  /**
   * Serially executes the given stack of middleware.
   *
   * @returns An array of any error encountered during middleware execution,
   * a boolean indicating whether the request was completed, and an array of
   * middleware-defined return handlers.
   */
  static async _runAllMiddleware(req, res, middlewareStack) {
    const returnHandlers = [];
    let error = null;
    let isComplete = false;
    // Go down stack of middleware, call and collect optional returnHandlers
    for (const middleware of middlewareStack) {
      [error, isComplete] = await JRPCEngine._runMiddleware(req, res, middleware, returnHandlers);
      if (isComplete) {
        break;
      }
    }
    return [error, isComplete, returnHandlers.reverse()];
  }
@toruslabs/openlogin-jrpc/dist/openloginJrpc.esm.js?8aa9
/**
   * Returns this engine as a middleware function that can be pushed to other
   * engines.
   *
   * @returns This engine as a middleware function.
   */
  asMiddleware() {
    return async (req, res, next, end) => {
      try {
        const [middlewareError, isComplete, returnHandlers] = await JRPCEngine._runAllMiddleware(req, res, this._middleware);
        if (isComplete) {
          await JRPCEngine._runReturnHandlers(returnHandlers);
          return end(middlewareError);
        }
        return next(async handlerCallback => {
          try {
            await JRPCEngine._runReturnHandlers(returnHandlers);
          } catch (error) {
            return handlerCallback(error);
          }
          return handlerCallback();
        });
      } catch (error) {
        return end(error);
      }
    };
  }

OpenLogin SDKのなかで下記web3Auth自体のLibraryのethereumProviderを利用しています。
ここでgetAccounts よばれたときに 内部で取得したprivKeyを返していることが分かります。

@web3auth/ethereum-provider/dist/ethereumProvider.esm.js?e7b7
  async function lookupAccounts(req, res) {
    res.result = await getAccounts(req);
  }
...
function getProviderHandlers(_ref) {
  let {
    txFormatter,
    privKey,
    getProviderEngineProxy
  } = _ref;
  return {
    getAccounts: async _ => [`0x${privateToAddress(Buffer.from(privKey, "hex")).toString("hex")}`],
    getPrivateKey: async _ => privKey,
    processTransaction: async (txParams, _) => {

privateKey自体は下記のように openloginInstance.PrivKeyからコピーしています。

webpack
  _getFinalPrivKey() {
    var _this$openloginOption;
    if (!this.openloginInstance) return "";
    let finalPrivKey = this.openloginInstance.privKey;
    // coreKitKey is available only for custom verifiers by default

openloginInstance自体は下記のようにweb3AuthにLoginするModalダイアログをInitした戻りでうけとっている、つまりWeb3Auth Server側で Loginしたアカウントに応じて発行しているのだということが分かりました!

webpack
 async init(options) {
    super.checkInitializationRequirements();
    if (!this.clientId) throw WalletInitializationError.invalidParams("clientId is required before openlogin's initialization");
    if (!this.openloginOptions) throw WalletInitializationError.invalidParams("openloginOptions is required before openlogin's initialization");
    let isRedirectResult = false;
    if (this.openloginOptions.uxMode === UX_MODE.REDIRECT || this.openloginOptions.uxMode === UX_MODE.SESSIONLESS_REDIRECT) {
      const redirectResult = getHashQueryParams();
      if (Object.keys(redirectResult).length > 0 && redirectResult._pid) {
        isRedirectResult = true;
      }
    }
    this.openloginOptions = _objectSpread(_objectSpread({}, this.openloginOptions), {}, {
      replaceUrlOnRedirect: isRedirectResult
    });
    this.openloginInstance = new OpenLogin(_objectSpread(_objectSpread({}, this.openloginOptions), {}, {
      clientId: this.clientId,
      network: this.openloginOptions.network || this.web3AuthNetwork || OPENLOGIN_NETWORK.MAINNET
    }));

safeでのwallet作成

上記でweb3AuthでログインしてprivateKeyからaddressをつくったのちに、
下記でbakend 側の api create_safeを呼び出します。

src/pages/createAccount.tsx
      const addr = await web3AuthService.getProvider().getSigner().getAddress();
      await axios.post("/api/create_safe", { ethAddress: addr });

next の仕組みで同じフォルダのapi以下にbackendコードをまとめて管理されています。next 便利ですね。
backend側ではBrowserに公開できない privatekey、APIKeyが必要な処理を呼び出しています。
大まかな処理の流れはいかのとおりです。

  1. safe にリクエストする処理の署名は サービス開発者の秘密鍵で行います(owner1Signer)
  2. safe に新規の Instanceを作成します: safeFactory.deploySafe()
  3. Instanceに SafeModule/superfluid module を有効にします
  4. InstanceのOwnerを上記web3Authで取得したアドレスに設定します updateOwners(safeInstance, ethAddress)
src/pages/api/create_safe.ts
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
    const { ethAddress } = req.body; // body only contains ETH address
    // get signers
    const owner1Signer = new ethers.Wallet(acc0, provider);
    const ethAdapterOwner1 = new EthersAdapter({
      ethers,
      signerOrProvider: owner1Signer,
    });

    // create safe config
    const safeFactory = await SafeFactory.create({
      ethAdapter: ethAdapterOwner1,
    });
    const safeAccountConfig: SafeAccountConfig = {
      owners: [
        await owner1Signer.getAddress(), // set original owner to be server
      ],
      threshold: 1,
    };

    // create safe for user
    const safeInstance = await safeFactory.deploySafe({ safeAccountConfig });
    const safeAddress = safeInstance.getAddress();

    // enable superfluid module
    let safeTX = await safeInstance.createEnableModuleTx(moduleAddress);
    let txResponse = await safeInstance.executeTransaction(safeTX);
    await txResponse.transactionResponse?.wait();

    await updateOwners(safeInstance, ethAddress);

    const modules = await safeInstance.getModules();
    const guardAddress = await safeInstance.getGuard();
    console.log(safeAddress, modules, guardAddress);
    res.send({ success: true });
  }
}

上記のなかででてくる SafeFactoryは、 safe protocol-kit になります。

import { SafeFactory } from "@safe-global/protocol-kit";
import { SafeAccountConfig } from "@safe-global/protocol-kit";

下記のsafe 公式ドキュメントのながれの通り実装されています。
https://docs.safe.global/learn/safe-core/safe-core-account-abstraction-sdk/protocol-kit

deploy safe

API詳細は下記ですが ゲーム利用ユーザー 一人ひとりの専用Contract (ContractWallet)がDeployされていると思えば良いです。

https://github.com/safe-global/safe-core-sdk/tree/main/packages/protocol-kit#deploysafe

enable safe module

上記のdeploy safe でつくられるのは GnosisSafe.sol という共通のcontractからのコピーです。
そこにサービスごとの独自処理を追加する仕組みが safe moduleです。

let moduleAddress = "0xc074Dca6083ccD17872394c8A58Cb79Ac660Ac3d";
safeInstance.createEnableModuleTx(moduleAddress)

上記のmodule addressが何かを調べてみます。
https://goerli.etherscan.io/address/0xc074Dca6083ccD17872394c8A58Cb79Ac660Ac3d#code

SuperPlay.sol, SuperPlayModule.sol でした!
ここで web2, web3 が safeを介してつながっていたんですね。
web3 では アドレスとネットワークが分かるとコードが分かることが多いので本当に透明でよいです。

今回はDemoでもともとコードも公開してくれているので、下記でも同じ内容は参照できます。
https://github.com/Rashmi-278/SuperPlay-ETH-Tokyo-2023/tree/main/hardhat/contracts

updateOwner

こちらは下記のように web3AuthでログインしたユーザーのアドレスをOwnerに設定して、
初期作成時のサービス開発者アドレスを削除しています。
コードがきれいでとてもわかり易いですね。

src/pages/api/create_safe.ts
sync function updateOwners(safeInstance: any, newOwner: string) {
  let params: AddOwnerTxParams = {
    ownerAddress: newOwner,
    threshold: 1,
  };

  let ret = [];

  let safeTX = await safeInstance.createAddOwnerTx(params);
  let txResponse = await safeInstance.executeTransaction(safeTX);
  await txResponse.transactionResponse?.wait();
 params = {
    ownerAddress: await dev.getAddress(),
    threshold: 1,
  };

  safeTX = await safeInstance.createRemoveOwnerTx(params);
  txResponse = await safeInstance.executeTransaction(safeTX);
  await txResponse.transactionResponse?.wait();
}

apiの説明は safeのweb pageでは見つからず、下記のGithub のreadmeを直接見るのが良いようです。

https://github.com/safe-global/safe-core-sdk/blob/main/packages/protocol-kit/README.md#createaddownertx

中間まとめ

ながくなったので一度記事を区切ります。
web3Auth, Safe の機能をうまく利用することで、 SuperPlayの本体はとても短くきれいで読みやすかったです。
理解するのは時間がかかりましたが、とてもよいお手本でした。
とくに web3auth (Browserで取得するUserごとの秘密情報) => safe (App共通で秘密にしたいbackend処理) の流れをどう実装しているのかが参考になりました。

後半は 課金、お金の流れを追っていきたいと思います。

Discussion