🚮

Lit Protocol 使ってConditional Signingを実装する

2023/12/31に公開

はじめに

こんにちは! no plan inc. にてエンジニアやってます @somasekiです。
これはno plan inc.の Advent Calendar 2023の13日目の記事です。

今回は、Lit Protocol を利用して、借りたERC721トークン(NFT)を返却期限を過ぎたら自動で元の持ち主に返却するシステムを作ろうと思います。

結局今回は一部しか作れませんでした

前提

  • LitProtocol自体の基本的な説明は省きます
  • NFTを自動で返却できる機能のみに焦点を当てます。
    • その他の部分(レンタルしたNFTを持ち主以外に送信できないように、とか) は考えてません。
  • PKP NFTはすでにミント済み
    • Lit Protocolには独自のブロックチェーンネットワークが存在していて、そのテストネットであるChronicle TestnetでPKPというNFTをミントしてあることとします

この記事を書く理由

今年参加したETHGlobal Tokyoで、僕が実装を試みた Lit Protocol利用によるNFTの自動返却機能を完成させられず、今もずっと悔しいからです。(もちろんLitProtocolプライズはもらえませんでした。)

今年のうちにその無念を晴らそうということです。

要約

先に結論を知りたい方へ

  • 自動返却機能は難しかった(実装できなかった=ハッカソンでもできない)
    • クラウドサービスを利用して関数を自動実行することにより実装可能
  • LitProtocolはまだ開発途中で大きな変更が多い
  • V3になって機能の種類がかなり豊富になった

コントラクト

NFT

ただのERC721規格のコントラクトです。

NFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract TestNonFungibleToken is ERC721 {
    constructor() ERC721("TestNonFungibleToken", "TNFT"){}

    function safeMint(address to, uint256 tokenId) public {
        _safeMint(to, tokenId);
    }
}

Rental コントラクト

レンタル用のコントラクトです。今回は、back関数が返却するための関数で、ERC721トークンをtransferするだけです。それに合わせてlendやrentや、rentalsというマッピング変数を書きました。超適当です。

RentalInfo.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;


import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";

contract Rental {
    struct RentalInfo {
        address owner;
        address renter;
        uint256 expiration;
    }

    mapping (address => mapping (uint256 => RentalInfo)) rentals;

    constructor() {
    }

    function lend(address nft, uint256 tokenId)public{
        address owner = IERC721(nft).ownerOf(tokenId);
        require(owner == msg.sender, "You are not the owner of this NFT");
        RentalInfo memory rental = RentalInfo(msg.sender, address(0), 0);
        rentals[nft][tokenId] = rental;
    }

    function rent(address nft, uint256 tokenId, uint256 expiration)public{
        RentalInfo storage rentalInfo = rentals[nft][tokenId];
        require(rentalInfo.owner != address(0), "This NFT is not available for rent");
        require(rentalInfo.renter == address(0), "This NFT is already rented");
        rentalInfo.renter = msg.sender;
        rentalInfo.expiration = expiration;
        IERC721(nft).transferFrom(rentalInfo.owner, rentalInfo.renter, tokenId);
    }

    function back(address nft, uint256 tokenId)public{
        RentalInfo storage rentalInfo = rentals[nft][tokenId];
        require(rentalInfo.renter == msg.sender, "You are not the renter of this NFT");
        require(isExpired(nft, tokenId), "You can't return this NFT yet");
        delete rentals[nft][tokenId];
        IERC721(nft).transferFrom(msg.sender, rentalInfo.owner, tokenId);
    }

    function isExpired(address nft, uint256 tokenId)public view returns(bool){
        return rentals[nft][tokenId].expiration > 0 && rentals[nft][tokenId].expiration < block.timestamp;
    }
}

関数の説明

  • back: NFT返却する関数
  • isExpired: レンタル期限が過ぎているかどうか確認するview関数。これをLitActionsから確認する。
  • lend: NFTを貸し出す関数
  • rent: NFTを借りる関数

自動返却のコード

今回のメインです。Lit ProtocolのJS SDKを利用して、LitActions上でRental コントラクトのisExpiredを確認して、trueだった場合、back関数を呼ぶトランザクションに署名し署名を返します。

全てのコード
import * as LitJsSdk from "@lit-protocol/lit-node-client-nodejs";
import { SiweMessage } from 'siwe';
import { Wallet, ethers } from 'ethers';
import json from '../artifacts/contracts/RentalInfo.sol/Rental.json' assert { type: "json" };
const rentalAbi = json.abi;
import { serialize } from "@ethersproject/transactions";
import * as dotenv from "dotenv";
dotenv.config();


// LitActionsで実行するJSコード
const litActionCode = `
const go = async () => {  
  // test an access control condition
  const testResult = await LitActions.checkConditions({conditions, authSig, chain})

  console.log('testResult', testResult)

  // only sign if the access condition is true
  if (!testResult){
    return;
  }

  const fromAddress = ethers.utils.computeAddress(publicKey);
  const nonce = await LitActions.getLatestNonce({address: fromAddress, chain});
  const tx = {...txParams, nonce};
  const serializedTx = ethers.utils.serializeTransaction(tx);
  const rlpEncodedTxn = ethers.utils.arrayify(serializedTx);
  const unsignedTxHash = ethers.utils.keccak256(rlpEncodedTxn);
  const toSign = ethers.utils.arrayify(unsignedTxHash);
  const sigShare = await LitActions.signEcdsa({ toSign, publicKey , sigName });

  LitActions.setResponse({response: JSON.stringify({tx})});
  console.log('sigShare', sigShare)
  
  
};

go();
`;

/**
 * 
 * @param {string} contractAddress 
 * @param {string} nftAddress 
 * @param {number} nftTokenId 
 * @param {string} chain 
 * @param {string} privateKey 
 * @param {string} rpcUrl 
 */
async function checkRentalExpiredAndBackNft(contractAddress, nftAddress, nftTokenId, chain, privateKey, rpcUrl) {
  // this code will be run on the nodejs server
  const litNodeClient = new LitJsSdk.LitNodeClientNodeJs({
    litNetwork: "cayenne",
    debug: true,
  });
  await litNodeClient.connect();

  /**
   * Creates a Siwe message for the given wallet address, statement, NFT contract address, and NFT token ID.
   * @param {string} address - The wallet address.
   * @param {string} statement - The statement.
   * @param {string} nft - The NFT contract address.
   * @param {number} tokenId - The NFT token ID.
   * @returns {string} The Siwe message.
   */
  function createSiweMessage(address, statement, nft, tokenId) {
    const domain = "localhost";
    const origin = "https://localhost/login";
    const encodedTokenId = LitJsSdk.uint8arrayToString(
      LitJsSdk.uint8arrayFromString(`${tokenId}`, "utf8"),
      "base64url"
    );
    const encodedNft = LitJsSdk.uint8arrayToString(
      LitJsSdk.uint8arrayFromString(nft, "utf8"),
      "base64url"
    );
    const siweMessage = new SiweMessage({
      domain,
      address,
      statement,
      uri: origin,
      version: '1',
      chainId: 80001,
      // ISO 8601 datetime tommorrow
      expirationTime: new Date(Date.now() + 86400000).toISOString(),
      // LitActions上で、checkConditionsを実行する際に使用する
      resources: [`litParam:tokenId:${encodedTokenId}`, `litParam:nft:${encodedNft}`],
    });
    return siweMessage.prepareMessage();
  }

  const provider = new ethers.JsonRpcProvider(rpcUrl);
  const wallet = new Wallet(privateKey, provider);
  const walletAddress = await wallet.getAddress();
  const statement = "I want to back my NFT";

  // Create a Siwe message for the given wallet address, statement, NFT contract address, and NFT token ID.
  const siweMessage = createSiweMessage(
    walletAddress,
    statement,
    nftAddress,
    nftTokenId,
  )
  const sig = await wallet.signMessage(siweMessage);
  
  // Create an authSig object.
  const authSig = {
    sig,
    derivedVia: "web3.eth.personal.sign",
    signedMessage: siweMessage,
    address: walletAddress,
  };
  console.log("authSig: ", authSig);
  
  // LitActionsで実行するJSコードの条件 今回はRentalInfo.solのisExpired関数を実行してtrueが返ってくるかどうか
  const conditions = [
    {
      contractAddress,
      functionName: "isExpired",
      functionParams: [":litParam:nft", ":litParam:tokenId"],
      functionAbi: {
        "inputs": [
          {
            "internalType": "address",
            "name": "nft",
            "type": "address"
          },
          {
            "internalType": "uint256",
            "name": "tokenId",
            "type": "uint256"
          }
        ],
        "name": "isExpired",
        "outputs": [
          {
            "internalType": "bool",
            "name": "",
            "type": "bool"
          }
        ],
        "stateMutability": "view",
        "type": "function"
      },
      chain,
      returnValueTest: {
        key: "",
        comparator: "=",
        value: "true",
      },
    },
  ];

  const contract = new ethers.Contract(contractAddress, rentalAbi, provider);
  const encodedData = contract.interface.encodeFunctionData("back", [nftAddress, nftTokenId]);
  const feeData = await provider.getFeeData();

  // LitActionsで実行するJSコードのパラメーター
  const signatures = await litNodeClient.executeJs({
    code: litActionCode,
    authSig,
    // all jsParams can be used anywhere in your litActionCode
    jsParams: {
        authSig,
        conditions,
        chain,
        publicKey: process.env.PKP_PUBLIC_KEY,
        sigName: "sig1",
        txParams: {
          to: contractAddress,
          value: "0x0",
          data: encodedData,
          chainId: 80001,
          type: 2,
          maxFeePerGas: feeData.maxFeePerGas.toString(),
          maxPriorityFeePerGas: feeData.maxPriorityFeePerGas.toString(),
        }
    },
  });
  console.log("signatures: ", signatures);

  // LitActionsの実行結果を取得し、トランザクションを送信する
  const txSig = signatures.signatures["sig1"].signature;

  const serializedTx = serialize(signatures.response.tx, txSig)
  
  const result = await provider.broadcastTransaction(serializedTx)
  console.log('result',result);
}

checkRentalExpiredAndBackNft(
  "0xBE71754cF535AA6A93E8717edF852026e1379957",
  "0x714575EE8893d514131883e30976af531100912F",
  1,
  "mumbai",
  process.env.PRIVATE_KEY,
  "https://polygon-mumbai-bor.publicnode.com",
);

使うパッケージ

import * as LitJsSdk from "@lit-protocol/lit-node-client-nodejs";
import { SiweMessage } from 'siwe';
import { Wallet, ethers } from 'ethers';
import json from '../artifacts/contracts/RentalInfo.sol/Rental.json' assert { type: "json" };
const rentalAbi = json.abi;
import { serialize } from "@ethersproject/transactions";
import * as dotenv from "dotenv";
dotenv.config();

LitActions上で実行するコード

// LitActionsで実行するJSコード
const litActionCode = `
const go = async () => {  
  // test an access control condition
  const testResult = await LitActions.checkConditions({conditions, authSig, chain})

  console.log('testResult', testResult)

  // only sign if the access condition is true
  if (!testResult){
    return;
  }

  const fromAddress = ethers.utils.computeAddress(publicKey);
  const nonce = await LitActions.getLatestNonce({address: fromAddress, chain});
  const tx = {...txParams, nonce};
  const serializedTx = ethers.utils.serializeTransaction(tx);
  const rlpEncodedTxn = ethers.utils.arrayify(serializedTx);
  const unsignedTxHash = ethers.utils.keccak256(rlpEncodedTxn);
  const toSign = ethers.utils.arrayify(unsignedTxHash);
  const sigShare = await LitActions.signEcdsa({ toSign, publicKey , sigName });

  LitActions.setResponse({response: JSON.stringify({tx})});
  console.log('sigShare', sigShare)
};

go();
`;

LitProtocol JS SDKのクライアント

  const litNodeClient = new LitJsSdk.LitNodeClientNodeJs({
    litNetwork: "cayenne",
    debug: true,
  });
  await litNodeClient.connect();

今回はSDKのバージョン3を利用するので、litNetworkは cayenneにする必要があります。

SIWEを利用してLitActionsのメソッドを利用するためのAuthSigを作成

PKPを使ってLitActions上のメソッドを実行する際に必要となる認証情報(AuthSig)を作成します。
ブラウザから実行する場合だと SDKが提供してくれているメソッドを利用すれば簡単に実装できるんですが、今回はNodejsでの実装を想定しているため、SIWEを利用して自前で作る必要があります。

/**
   * Creates a Siwe message for the given wallet address, statement, NFT contract address, and NFT token ID.
   * @param {string} address - The wallet address.
   * @param {string} statement - The statement.
   * @param {string} nft - The NFT contract address.
   * @param {number} tokenId - The NFT token ID.
   * @returns {string} The Siwe message.
   */
  function createSiweMessage(address, statement, nft, tokenId) {
    const domain = "localhost";
    const origin = "https://localhost/login";
    const encodedTokenId = LitJsSdk.uint8arrayToString(
      LitJsSdk.uint8arrayFromString(`${tokenId}`, "utf8"),
      "base64url"
    );
    const encodedNft = LitJsSdk.uint8arrayToString(
      LitJsSdk.uint8arrayFromString(nft, "utf8"),
      "base64url"
    );
    const siweMessage = new SiweMessage({
      domain,
      address,
      statement,
      uri: origin,
      version: '1',
      chainId: 80001,
      // ISO 8601 datetime tommorrow
      expirationTime: new Date(Date.now() + 86400000).toISOString(),
      // LitActions上で、checkConditionsを実行する際に使用する
      resources: [`litParam:tokenId:${encodedTokenId}`, `litParam:nft:${encodedNft}`],
    });
    return siweMessage.prepareMessage();
  }

  const provider = new ethers.JsonRpcProvider(rpcUrl);
  const wallet = new Wallet(privateKey, provider);
  const walletAddress = await wallet.getAddress();
  const statement = "I want to back my NFT";

  // Create a Siwe message for the given wallet address, statement, NFT contract address, and NFT token ID.
  const siweMessage = createSiweMessage(
    walletAddress,
    statement,
    nftAddress,
    nftTokenId,
  )
  const sig = await wallet.signMessage(siweMessage);
  
  // Create an authSig object.
  const authSig = {
    sig,
    derivedVia: "web3.eth.personal.sign",
    signedMessage: siweMessage,
    address: walletAddress,
  };

レンタル期限切れてるかどうかコントラクトに問い合わせる条件を作る

LitActions上で実行するコードのcheckCondtionsの引数を作ります。

LitActions上で実行するコード
const testResult = await LitActions.checkConditions({conditions, authSig, chain})
// LitActionsで実行するJSコードの条件 今回はRentalInfo.solのisExpired関数を実行してtrueが返ってくるかどうか
  const conditions = [
    {
      contractAddress, // レンタルコントラクトのアドレス
      functionName: "isExpired", // function名
      functionParams: [":litParam:nft", ":litParam:tokenId"], // isExpired関数の引数。AuthSigを作成するときにresourcesに追加したもの
      functionAbi: {
        "inputs": [
          {
            "internalType": "address",
            "name": "nft",
            "type": "address"
          },
          {
            "internalType": "uint256",
            "name": "tokenId",
            "type": "uint256"
          }
        ],
        "name": "isExpired",
        "outputs": [
          {
            "internalType": "bool",
            "name": "",
            "type": "bool"
          }
        ],
        "stateMutability": "view",
        "type": "function"
      }, // RentalコントラクトのisExpired関数のABI
      chain, // "mumbai"
      returnValueTest: {
        key: "", // 返り値に変数名がついていない場合""にする
        comparator: "=",
        value: "true",
      }, // 返り値の比較条件
    },
  ];

参考

https://developer.litprotocol.com/v3/sdk/access-control/evm/custom-contract-calls

LitActionsでコードを実行する

  const contract = new ethers.Contract(contractAddress, rentalAbi, provider);
  const encodedData = contract.interface.encodeFunctionData("back", [nftAddress, nftTokenId]);
  const feeData = await provider.getFeeData();

  // LitActionsで実行するJSコードのパラメーター
  const signatures = await litNodeClient.executeJs({
    code: litActionCode,
    authSig,
    // all jsParams can be used anywhere in your litActionCode
    jsParams: {
        authSig,
        conditions,
        chain,
        publicKey: process.env.PKP_PUBLIC_KEY,
        sigName: "sig1",
        txParams: {
          to: contractAddress,
          value: "0x0",
          data: encodedData,
          chainId: 80001,
          type: 2,
          maxFeePerGas: feeData.maxFeePerGas.toString(),
          maxPriorityFeePerGas: feeData.maxPriorityFeePerGas.toString(),
        }
    },
  });
  • authSig
    • ここで作成したAuthSig
  • conditions
  • chain
    • Polygon Mumbai Testnetを利用するので、"mumbai"という文字列
    • ここに対応チェーンは書いてあります。
  • publicKey
    • PKPのパブリックキー
    • ここでPKPをミントできます
  • sigName
    • 署名をレスポンスから取得するときのキー。この後使います。
  • txParams
    • 条件がtrueだったときの場合に実行するトランザクション

LitActionsのレスポンスから署名とtxを取得しbroadcast

executeJsで渡したsigNameという引数をここで利用します。

  // LitActionsの実行結果を取得し、トランザクションを送信する
  const txSig = signatures.signatures["sig1"].signature;

  const serializedTx = serialize(signatures.response.tx, txSig)
  
  const result = await provider.broadcastTransaction(serializedTx)

これで終わりです。

おわりに

実際にハッカソンでやろうとしていた機能は十分に実装できませんでした。Litprotocolには、今回利用したパッケージの他にも便利パッケージがいくつかあります。event-listenterというサードパーティツールを利用すれば、rent関数にイベントを追加してそのイベントをトリガーにして、今回のコードを定期実行することが可能そうです。これ使うのであれば、クラウドサービス(AWS LambdaやGCP Cloud Functions)あたりを使う方が楽に実装できそうです。

今回は記事を書くにあたって改めてドキュメントをかなり読み込んで、実装に時間をかけられたので、ハッカソンの無念は一旦晴らすことができました。

ありがとうございました。

LitProtocolはまだ開発途中

ドキュメントにも何度も記載されてありましたが、まだ開発途中であるLitProtocolは頻繁にSDKの破壊的な仕様変更が起こります。

僕が初めて利用したとき(2023年2月)はV1のみだったはずで、ハッカソンでの使用時(2023年4月)はV2が出たばかり、そして今回この記事を書く数ヶ月前くらい(2023年10月ごろ)にV3が出たみたいです。

毎回、ドキュメントに書いてある内容がかなり大きく変更されてる印象です。ですが、どんどん機能も増えています。PKPを中心にアクセスコントロールの設定をより柔軟にできます。また、最初からあった機能はより使いやすくなってる印象でした。今後を期待し、引き続きウォッチしていこうと思います。

no plan株式会社について

  • no plan株式会社は 「テクノロジーの力でZEROから未来を創造する、精鋭クリエイター集団」 です。
  • ブロックチェーン/AI技術をはじめとした、Webサイト開発、ネイティブアプリ開発、チーム育成、などWebサービス全般の開発から運用や教育、支援なども行っています。よくわからない、ふわふわしたノープラン状態でも大丈夫!ご一緒にプランを立てていきましょう!
  • no plan株式会社について
  • no plan株式会社 | web3実績
  • no plan株式会社 | ブログ一覧
    エンジニアの採用も積極的に行なっていますので、興味がある方は是非ご連絡ください!
  • CTOのDMはこちら

参考記事

Discussion