💭

Slash Extensionを使ってみよう!!【応用編】

2023/04/03に公開2

slash4.001.jpeg

皆さん、こんにちは!

今回も、Slashを使ってみようシリーズになります!

前回の記事は下記からアクセスできます!

https://qiita.com/mashharuki/items/0a0076d45cd6f4e1d9e6

はじめに

今回は、前回に続いて 「Slash Extension」 の機能についての記事になります。
「Slash Extension」 を使えば決済と同時に任意の処理を実行させることができることを共有しました。

ただ、前回の状態だと引数を渡せないので例えばトークンIDを指定してNFTを他の人に渡したりすることができませんでした。

今回は、APIサーバーを実装して引数を渡したり金額を設定したりできるような使い方をまとめていきます!!
さらに開発寄りになりますがここをクリアできればかなり自由にSlashを使いこなせるようになるはずです!!

ちょっと長くなりそうなので目次です。

目次

今回作ったアプリのイメージ

init.png

ソースコード

https://github.com/mashharuki/SlashExtensionSampleAppV2

実際にデプロイしたスマートコントラクトについては以下から確認できます!!
今回は、3つのブロックチェーンにデプロイしてみました!

Mumbai Networkにデプロイしたスマートコントラクト

https://mumbai.polygonscan.com/address/0xDFb533125a3Fdde24c1cdCe563aa7bEBaAd5A1Cd#code

Goerliにデプロイしたスマートコントラクト

https://goerli.etherscan.io/address/0xd36dbfb7Cb30167A92650A00Ca460d0EDd834780#code

Avalanche Fuji chainにデプロイしたスマートコントラクト

https://testnet.snowtrace.io/address/0x336709CAbCB19362Bf9374aE4811FF934E9626B6#code

実際にNFTを複数移転させたトランザクション

https://testnet.snowtrace.io/tx/0x074d92e93e3e565b630623864a0513f60378a3be1698fd1acf70018680224f3c

https://goerli.etherscan.io/tx/0x65da2d7082c9692c67e717e23eccb42a77800e71b95013713b49b1ac5bd41bd7

OpenSea (testnet)上のデータ

https://testnets.opensea.io/ja/collection/slashextensionsample-1

APIサーバーの解説

前回と異なり、今回はAPIサーバーが関わってきます!
加盟店側やシステム提供者側であらかじめ値段や法定通貨の種類を設定しておきたい場合・任意の値を渡してその値を引数についてスマートコントラクトの関数を実行させたい場合には、このAPIサーバー側で決済用のURLを生成する処理を実装する必要があります。

Slashの公式ドキュメントを元にGo言語でAPIサーバーを書いてみました!

https://slash-fi.gitbook.io/docs/integration-guide/apis/payment-request-api

かなりシンプルな実装です!

APIサーバーのソースコード
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"math/rand"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"time"

	"github.com/joho/godotenv"

	"github.com/gin-gonic/gin"
)

/**
 * 乱数列を生成するメソッド
 */
func generateCode() string {
	// 生成する文字列の候補
	candidates := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"

	// 文字列の長さ
	length := 20

	// 現在時刻からシード値を生成する
	rand.Seed(time.Now().UnixNano())

	// ランダムな文字列を生成する
	result := make([]byte, length)
	for i := range result {
		result[i] = candidates[rand.Intn(len(candidates))]
	}

	return string(result)
}

/**
 * APIサーバーのmainファイル
 */
func main() {
	r := gin.Default()

	err := godotenv.Load(".env")
	if err != nil {
		fmt.Printf("failed to load .env file: %v", err)
	}

	/**
	 * テスト用API
	 */
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "pong",
		})
	})

	/**
	 * Slashの決済用のURLを生成するAPI
	 */
	r.POST("/getUrl", func(c *gin.Context) {
		c.Header("Access-Control-Allow-Origin", "*")
		// api-endpoint
		API_ENDPOINT := "https://testnet.slash.fi/api/v1/payment/receive"
		// Authentication Token
		AUTH_TOKEN := os.Getenv("SLASH_AUTH_TOKEN")
		// Hash Token
		HASH_TOKEN := os.Getenv("SLASH_HASH_TOKEN")

		// Code set by the merchant to uniquely identify the payment
		// 決済ごとにユニークな値を生成する必要あり
		ORDER_CODE := generateCode()
		// 確認のため出力
		fmt.Printf("AUTH_TOKEN: %s\n", AUTH_TOKEN)
		fmt.Printf("HASH_TOKEN: %s\n", HASH_TOKEN)
		fmt.Printf("ORDER_CODE: %s\n", ORDER_CODE)

		// Set amount and Generate verify token
		// get ext_reserved
		ext_reserved := c.PostForm("extReserved")
		// this number must be Zero or more
		amount_to_be_charged := c.PostForm("amount")
		// float型に変換
		fAmount, err := strconv.ParseFloat(amount_to_be_charged, 64)
		if err != nil {
			fmt.Printf("%s\n", err.Error())
			return
		}

		fmt.Printf("amount_to_be_charged: %f\n", fAmount)
		fmt.Printf("ext_reserved: %s\n", ext_reserved)
		// verify_tokenを生成する。
		raw := fmt.Sprintf("%s::%f::%s", ORDER_CODE, fAmount, HASH_TOKEN)
		verify_token := sha256.Sum256([]byte(raw))

		// data to be sent to api
		data := url.Values{}
		data.Add("identification_token", AUTH_TOKEN)
		data.Add("order_code", ORDER_CODE)
		data.Add("verify_token", hex.EncodeToString(verify_token[:]))
		data.Add("amount", fmt.Sprintf("%f", fAmount))
		data.Add("amount_type", "JPY")
		data.Add("ext_description", "This is payment for NFT")
		data.Add("ext_reserved", fmt.Sprintf("%s", ext_reserved))

		// sending post request and saving response as response object
		res, _ := http.PostForm(API_ENDPOINT, data)
		defer res.Body.Close()

		body, _ := ioutil.ReadAll(res.Body)

		fmt.Printf("url: %s\n", body)

		var responseObj interface{}
		_ = json.Unmarshal(body, &responseObj)

		url := responseObj.(map[string]interface{})["url"]

		if url != nil {
			fmt.Printf("url: %s\n", url)
		} else {
			err := responseObj.(map[string]interface{})["errors"]
			fmt.Println(err)
		}

		c.JSON(200, gin.H{
			"url": url,
		})
	})

	// 0.0.0.0:8080 でサーバーを立てます。
	r.Run()
}

開発用なので、途中経過も確認するため色々変数の値を出力するようにしています!!

  • 動かし方
cd api && go run main.go

試しに叩くには下記コマンドを打ってみると良いです!

curl -Ss -XPOST http://localhost:8080/getUrl  --data-urlencode 'amount=10' --data-urlencode 'ext_reserved=0x0000000000000000000000000000000000000000000000000000000000000003'

正常にAPIサーバーが動いていれば、下記のように決済URLが生成されます。

api.png

今回の実装例では、フロントエンド側から extReservedamountという二つの値を渡しています。
それぞれのパラメータの詳細は次の通りです。

  • extReserved: スマートコントラクトに渡す変数の型と値をエンコードしたもの
  • amount: 料金

バックエンドの解説

続いてバックエンド側になります!
ベース部分は前回のブログで紹介したソースとほぼ変わりませんが、決済時に渡された任意の値を取得するところの実装が異なってきています。

スマートコントラクトのソースコード
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

//// 省略 /////

contract NFTCollectible is ERC721Enumerable, Ownable, ISlashCustomPlugin {

    using UniversalERC20 for IERC20;

    using SafeMath for uint256;
    using Counters for Counters.Counter;

    struct PurchaseInfo {
        uint mintCounts;
    }

    //// 省略 /////

    /**
     * receivePayment 支払い時に実行されるコントラクト
     */
    function receivePayment(
        address receiveToken,
        uint256 amount,
        bytes calldata,
        string calldata,
        bytes calldata  reserved
    ) external payable override {
        require(amount > 0, "invalid amount");
        
        IERC20(receiveToken).universalTransferFrom(msg.sender, owner(), amount);
        // decode
        PurchaseInfo memory info = abi.decode(reserved, (PurchaseInfo));
        // mint NFT
        mintNFTs(info.mintCounts);
    }

    /**
     * mint NFT func 
     * @param _count count of NFT
     */
    function mintNFTs(uint _count) public payable {
        // get token IDs
        uint totalMinted = _tokenIds.current();
        // check fro mint NFT
        require(totalMinted.add(_count) <= MAX_SUPPLY, "Not enough NFTs!");
        require(_count > 0 && _count <= MAX_PER_MINT, "Cannot mint specified number of NFTs.");
        //require(msg.value >= PRICE.mul(_count), "Not enough ether to purchase NFTs.");

        for (uint i = 0; i < _count; i++) {
            _mintSingleNFT();
        }
    }

    function _mintSingleNFT() private {
        uint newTokenID = _tokenIds.current();
        // call _safeMint func
        _safeMint(tx.origin, newTokenID);
        _tokenIds.increment();
    }

    //// 省略 /////
    
    /**
     * V2に対応したプラグインコントラクト用のインターフェースを継承しているかどうかチェック
     */
    function supportSlashExtensionInterface()
        external
        pure
        override
        returns (uint8)
    {
        return 2;
    }
}

receivePaymentの中身の処理がとても重要で、フロントエンド側でスマートコントラクトに渡したい変数の型と値をエンコードしたものがreservedとして渡されます。

この中身の値を取得するために、デコードしてあげる必要があります!

今回ではデコードした値からmintCountsという変数を取得して一回の決済で幾つのNFTを発行するのかという値を取得しています。

// decode
PurchaseInfo memory info = abi.decode(reserved, (PurchaseInfo));
// mint NFT
mintNFTs(info.mintCounts);

また、デコードした値を詰めるための独自のStructを用意してあげる必要があるので、ここは各々渡したい値にマッチするように定義してあげましょう!

今回の場合だと以下のようになります!

struct PurchaseInfo {
    uint mintCounts;
}

デプロイ方法

ブロックチェーンへのデプロイは以下のコマンドを実行することで可能です!
※ 自身の秘密鍵の情報を環境変数として.envファイルに記載していることが前提です。

Avalanceのテストネットにデプロイする場合
cd backend && npm run deploy:fuji

フロントエンドの解説

最後にフロントエンドです!
前回よりも少し複雑になります。

前回は、ボタンを押したらSlashの決済画面に遷移していましたが今回は以下のようなステップを踏みます!

  1. ボタンを押す
  2. 決済用URL発行APIが呼び出される。
  3. APIの実行結果として決済URLが返却される。
  4. URLをフロントに表示する。
  5. URLをクリックして決済画面に遷移する。
フロントエンドのソースコード
import './css/App.css';

/**
 * Appコンポーネント
 */
function App() {
  /// 省略 ///
  const [baseApiUrl, setBaseApiUrl] = useState("http://localhost:8080");
  const [basePrice, setBasePrice] = useState(100);
  const [paymentUrl, setPaymentUrl] = useState("");

  /// 省略 ///

  /**
   * 決済用のURLを取得するためのメソッド
   */
  const getUrl = async() => {
    // create encode data
    const extReserved = {
      types: ['uint'],
      params: [count]
    }

    const encodedExtReserved = ethers.utils.defaultAbiCoder.encode(
      extReserved.types,
      extReserved.params
    );
    
    console.log(`extReserved: ${encodedExtReserved}`)
      
    // 決済用のURLを取得するためのAPIを呼び出す。  
    await superAgent
      .post(baseApiUrl + "/getUrl")
      .type('form')
      .send({
        amount: `${basePrice * count}`, 
        extReserved: encodedExtReserved
      })
      .set({ 
        Accept: 'application/json',
      })
      .end((err, res) => {
        if (err) {
          console.log("決済用URL取得中にエラー発生", err)
          return err;
        }
        console.log("決済用URL取得成功!:", res.body.url);
        setPaymentUrl(res.body.url);
        setMintingFlg(true)
      });
      
  };

  /**
   * NFTMintボタンコンポーネント
   */
  const mintNftButton = () => {
    return (
      <>
        <Box sx={{ flexGrow: 1, overflow: "hidden", mt: 1, my: 1}}>
          <Stack 
            direction="row" 
            justifyContent="center" 
            alignItems="center" 
            spacing={1}
          >
            <IconButton 
              aria-label="remove"
              size='large' 
              onClick={() => setCount(count - 1)}
            >
              <RemoveCircleIcon/>
            </IconButton>
            <TextField
              id="outlined-number"
              type="number"
              InputLabelProps={{
                shrink: true,
              }}
              value={count}
            />
            <IconButton 
              aria-label="add" 
              size='large' 
              onClick={() => setCount(count + 1)}
            >
              <AddCircleIcon/>
            </IconButton>
          </Stack>
        </Box>
        <Button
          onClick={getUrl}
        >
          <img 
            src={paymentButton} 
            alt="paymentButton" 
            height={50} 
          />
        </Button>
      </>
    );
  };

  /// 省略 ///

  return (
    <div className="main-app">
      { viewFlg ? (
        <>
          <h1>NFT View</h1>
          <Box sx={{ flexGrow: 1, mt: 2, my: 1}}>
            <Grid
                container
                justifyContent="center"
                alignItems="center"
              >
                <View 
                  address={contractAddr} 
                  networkId={networkId}
                  baseURI={baseURI}
                />
            </Grid>
          </Box>
          <button 
            className="opensea-button cta-button"
            onClick={() => { setViewFlg(false) }}
          >
            NFTを発行する
          </button>
          <Footer/>
        </>
      ) : (
        <>
          <h1>Let's Mint NFT! 11,00円です。</h1>
            <Box sx={{ flexGrow: 1, overflow: "hidden", mt: 4, my: 2}}>
              <strong>
                contract address : 
                {(networkId === "0x13881") && (
                  <a href={POLYGONSCAN_LINK}>
                    {contractAddr}
                  </a>
                )} 
                {(networkId === "0x51") && (
                  <a href={BLOCKSCOUT_LINK}>
                    {contractAddr}
                  </a>
                )} 
                {(networkId === "0x150") && (
                  <a href={BLOCKSCOUT_LINK2}>
                    {contractAddr}
                  </a>
                )} 
                {(networkId === "0x5") && (
                  <a href={ETHERSCAN_LINK}>
                    {contractAddr}
                  </a>
                )} 
                {(networkId === "0xa869") && (
                  <a href={SNOWTRACE_LINK}>
                    {contractAddr}
                  </a>
                )} 
              </strong>
            </Box>
            <Grid
              container
              direction="row"
              justifyContent="center"
              alignItems="center"
            >
              <Box sx={{ flexGrow: 1, overflow: "hidden", px: 3, mt: 10}}>
                <StyledPaper sx={{my: 1, mx: "auto", p: 0, paddingTop: 2, borderRadius: 4}}>  
                  <Box sx={{ flexGrow: 1, overflow: "hidden", mt: 1, my: 1}}>
                    <strong>発行状況:{supply} / {MAX_SUPPLY}</strong>
                  </Box>
                  <img src={SquirrelsSvg} alt="Polygon Squirrels" height="40%" /><br/>
                  { mintingFlg ?
                    (
                      <div>
                        <div className="spin-color">
                          Click this link !
                        </div>
                        <a href={paymentUrl}>magic link</a>
                      </div>
                    ) :( 
                    <>
                      { currentAccount ? mintNftButton() : connectWalletButton()}
                    </>
                    )
                  }
                </StyledPaper>
                <button 
                  className="opensea-button cta-button"
                  onClick={() => { setViewFlg(true) }}
                >
                  NFTを確認する
                </button>
                <Footer/>
              </Box>
            </Grid>
        </>
      )}
    </div>
  );
}

export default App;

実際に決済用のURLを発行する処理は、getUrlというメソッドが担当していて、画面からだとCryptoPayボタンを押した時に実行されます。

// 決済用のURLを取得するためのAPIを呼び出す。  
await superAgent
  .post(baseApiUrl + "/getUrl")
  .type('form')
  .send({
    amount: `${basePrice * count}`, 
    extReserved: encodedExtReserved
  })
  .set({ 
    Accept: 'application/json',
  })
  .end((err, res) => {
    if (err) {
      console.log("決済用URL取得中にエラー発生", err)
      return err;
    }
    console.log("決済用URL取得成功!:", res.body.url);
    setPaymentUrl(res.body.url);
    setMintingFlg(true)
  });

重要になってくるのが、encodedExtReservedという値でこれが任意の変数の型と値をエンコードしたものとなっています。
この処理の直前で生成しています。

これは、ethers.jsというライブラリを使った場合の生成方法です。

// create encode data
const extReserved = {
  types: ['uint'],
  params: [count]
}

const encodedExtReserved = ethers.utils.defaultAbiCoder.encode(
  extReserved.types,
  extReserved.params
);

ちょっと複雑ですが一度仕組みを理解してしまえば簡単に導入できそうですね!

ここからは、実際の画面イメージと合わせながら解説していきます。

  • トップ画面

最初のトップ画面です。

init.png

CryptoPay ボタンをクリックすると裏でAPIを呼び出して決済URLが生成されます!

magicLink.png

magic link をクリックするとSlashの決済ページに遷移します!
前回と異なるのは金額や法定通貨がすでに指定されている点です!!

magic linkという文言で決済用URLが表示されるようにしたのでそれをクリックしましょう!

うまくいけば決済画面に遷移します。

payment.png

すでに値が指定されていますね!!
支払うトークンを選択して通常通り決済処理を進めます。

payment2.png

payment3.png

payment4.png

処理されるとブロックエクスプローラーまでのURLが発行されるのでトランザクションの内容を見に行きましょう!
今回は、一度決済でNFTを2つミントさせるように引数を渡しました。

正常に動作していれば決済と同時にNFTが2つミントされているはずです!

tx.png

引数に渡した回数だけNFTがミントされていました!!

トップ画面に戻ります!

after.png

増えてました!!

view.png

一応、OpenSeaも確認してみます!!

opensea.png

大丈夫そうですね!!

まとめ

今日のメイントピックは以上です!

応用編ということで少し難易度が上がっていますが、やり方さえ把握してしまえば色々組み込めそうだなって思いました!

ここまでドキュメントや仕組みを整備しているSlashのチームは本当にすごいですね・・・。

余談ですが、先日CEOの佐藤さんから新しいプロジェクトの発足も発表されていていました!!

https://twitter.com/SATOSHINSUKE11/status/1641778486514962432

その他ピッチ資料などが公開されていましたのでご興味があればぜひこちらも!
決済ソリューション以外にも開発が進められているみたいなので楽しみですね!!

https://drive.google.com/file/d/1ib1-RokKEaXtJnmyzxMKgLI6Ndz2kqXH/view

参考文献

https://slash-fi.gitbook.io/docs/advanced-features/develop-your-own-extension

https://slash-fi.gitbook.io/docs/integration-guide/apis/payment-request-api

https://app.unchain.tech/learn/Polygon-Generative-NFT/

Discussion

A.J.A.J.

NFTの初心者ですが、これから色々な記事を読んで勉強させてもらいます。よろしくお願いします😆