本記事の概要
今回Moralisを用いて GASHO 2.0のようなサービスを開発したので開発の際につまづいたところや調べたところなどを備忘録として残す。
まだWeb3やMoralisなどの日本語の情報は少なそうなので後から触る人が同じとこに引っかかった時の解決に役立てば幸い。
作成したサービスの概要
まだ一般公開されていないので機能レベルでの記載
機能的にはGASHOと似ていて、Metamaskで接続して読み込んだNFTを加工して配送先を入力してETHで決済して注文履歴でそれらが管理できるというような機能群になります。
これらのざっくりとしたユースケースを満たすためにMoralisを活用しています。
- Metamaskログイン認証機能
- Moralis標準機能で対応可
- 認証WalletのNFT一覧の取得と表示
- Moralis SDKの標準関数で取得可能
- ログインユーザー関連情報の保持(Moralis DB)
- MoralisはDB機能まで付いている。そしてNoSQL並みに扱いやすい。
- ETHによる決済
- ここは正直Moralisはそんなに関係なかった
- Moralisに内包されているetherjsを用いていつも通りにContract叩く
アプリケーション構成
Moralisを使う人はそこまで多くないとは思いますが、Moralis以外はよくあるような構成かなと思います。(Chakra UIもそんなに主流ではないか)
FrontendはNext.jsとTypescriptで作成し、Vercelにデプロイしています。
Backendはデータベースへの書き込み処理のためにNext APIを利用しています。
MoralisはDatabaseも利用可能なのでデータはMoralisに格納しています。
格納データはユーザーの購入情報や配送先などです。
ContractはHardhatを使って作成しています。
今回のContractはやっている内容はかなりシンプルです。
後ほど簡単に記載します。
Metamaskログイン認証機能
詳細はMoralisのDocsを見てもらうと良いですが、Docsでも一番最初に出てくる基本機能の1つです。
https://docs.moralis.io/moralis-dapp/connect-the-sdk/connect-with-react
公式ドキュメントだと、ただボタン押して認証するだけですが、自分はよくあるModalでの表示にしています。
コード的には以下のように書くとログインが可能
import {
Button,
Image,
Modal,
ModalBody,
ModalContent,
ModalOverlay,
Text,
useDisclosure,
VStack,
} from "@chakra-ui/react"
import React from "react"
import { useTranslation } from "react-i18next"
import { useMoralis } from "react-moralis"
export const ConnectWallet: React.FC = () => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { authenticate, isAuthenticated, chainId, Moralis } = useMoralis()
const login = async () => {
if (!isAuthenticated) {
await authenticate({ signingMessage: "Login Message" })
.then(async function (user) {
})
.catch(function (error) {
console.log(error)
})
.finally(function () {
onClose()
})
}
}
return (
<>
<Button
onClick={onOpen}
borderRadius="3xl"
bg="black"
color="white"
px={8}
>
Connect Wallet
</Button>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalBody>
<VStack p={4} justifyItems="center" cursor="pointer">
<VStack onClick={login}>
<Image src="metamask.svg" height="45" width="45" />
<Text color="#a9a9bc" fontSize="lg">
Connect to your metamask wallet
</Text>
</VStack>
</VStack>
</ModalBody>
</ModalContent>
</Modal>
</>
)
}
ログイン認証は一度すればあとはuserを確認することで認証状態かが確認できます。
その他の注意点としては、ログインはしていてもweb3関連の処理(ネットワークの変更やトランザクション発行など)は isWeb3Enabled
がtrueになっている必要があります。
そのため、状態を監視して無効であればenableWeb3()
でweb3機能を有効化します。
この辺りのサンプルコードは以下(実際のコードからドメイン系の処理手動で適当に消して載せているので記載が少し変だったりこれをコピペだと動かない可能性があります)
import { Center, HStack, Text, VStack } from "@chakra-ui/react"
import React, { useEffect } from "react"
import { useMoralis } from "react-moralis"
import { ConnectWallet } from "../component/ConnectWallet"
export default function Index() {
const { isAuthenticated, isWeb3Enabled, user, enableWeb3 } = useMoralis()
useEffect(() => {
if (isAuthenticated) {
// 認証状態で行う初期データ取得など
}
}, [isAuthenticated])
const initialize = async () => {
if (!isWeb3Enabled) {
await enableWeb3()
}
}
useEffect(() => {
initialize()
}, [])
return (
<>
<Center>
<VStack width="100%">
<HStack>
{user ? (
<VStack>
<Text fontWeight="semibold" color="white">
You are logged in
</Text>
</VStack>
) : (
<ConnectWallet />
)}
</HStack>
</VStack>
</Center>
</>
)
}
認証WalletのNFT一覧の取得と表示
こちらも公式のドキュメントに記載があります。
https://docs.moralis.io/moralis-dapp/web3-api/account#getnfts
が、Moralisは標準だとTypescriptに対応していないためそのままだとエラーが発生します。
エラーを解消するにはchain対して型を合わせる必要があります。
import { useChain, useMoralis, useMoralisWeb3Api } from "react-moralis"
const Web3Api = useMoralisWeb3Api()
const { user } = useMoralis()
const { chainId } = useChain()
const result = await Web3Api.Web3API.account.getNFTs({
chain: chainId as
| "0x3"
| "eth"
| "0x1"
| "ropsten"
| "rinkeby"
| "0x4"
| "goerli"
| "0x5"
| "kovan",
address: user?.attributes.ethAddress,
})
ログインユーザー関連情報の保持(Moralis DB)
Moralisはデータベースが内包されており、標準でログインに使われるUserやSessionなどが用意されており、自動的に値が入ります。
また、それ以外の情報を格納したい場合は簡単にオブジェクトを作成してデータを扱うことが可能です。
今回はOrdersオブジェクトを作成してそこで注文関連情報を保持するようにしました。
データベースへのアクセスはセキュリティやビジネスロジックなどを考慮してサーバー側で諸々処理を行うことにしました。
その際にサーバー側で対象のリクエストが認証されたユーザーのものかどうかというのを確認する必要があります。確認しない場合はエンドポイントさえわかれば誰でも好きなようにデータの作成や更新が行えてしまいます。
通常のDAppsだとこの部分で結構苦労がありそうだなと作りながら思いましたが、Moralisでログイン機能を用いている場合、SessionTokenが発行されているのでリクエストにSessionTokenを含ませてサーバー側で突合して認証するという方式をとることが可能です。
リクエストのコード的にはこんな感じ
headerやbody(認証だとheaderに載せる場合が多い印象ですがこのサンプルはbody)にユーザーのsessionTokenを載せてリクエストを行います。
const result = await axios.post(
`${document.location.origin}/api/moralis`,
{
sessionToken: user?.getSessionToken(),
orderNos,
}
)
サーバー側ではMoralisのNode SDKを用いてSessionを取得し、リクエストと突合してユーザーの認証を行うことが可能です。
サーバー側の認証サンプルコードはこんな感じ
import type { NextApiRequest, NextApiResponse } from "next"
import { NotAuthenticatedError } from "react-moralis"
import Moralis from "moralis/node"
export const auth = async (req: NextApiRequest): Promise<string> => {
const { sessionToken } = req.body.sessionToken ? req.body : req.query
/* Moralis init code */
const serverUrl = process.env.NEXT_PUBLIC_MORALIS_SERVER_URL
const appId = process.env.NEXT_PUBLIC_MORALIS_APP_ID
const masterKey = process.env.MASTER_KEY
await Moralis.start({ serverUrl, appId, masterKey })
const query = new Moralis.Query("_Session")
await query.equalTo("sessionToken", sessionToken)
const auth = await query.find({ useMasterKey: true })
if (auth.length === 0) {
throw new NotAuthenticatedError("NotAuthenticated")
} else {
const query = new Moralis.Query("_User")
await query.equalTo("objectId", auth[0].attributes.user.id)
const user = await query.find({ useMasterKey: true })
return user[0].attributes.ethAddress
}
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<any[]>
) {
const { method } = req
try {
await auth(req)
switch (method) {
case "POST": {
const { orderNos } = req.body
// サーバー側処理
return res.status(200).json(result)
}
default:
res.setHeader("Allow", ["POST"])
res.status(405).end(`Method ${method} Not Allowed`)
break
}
} catch (error: any) {
res.status(401).end(error.message)
}
}
認証のタイミングでユーザーIDやアドレスなども取得できるので本人のみ更新が可能などの処理もこの辺りの処理を行うことで実現が行えます。
また、作成したObjectsへのデータの追加や更新のサンプルも載せておきます。
オブジェクトの追加
export const insertOrder = async () => {
/* Moralis init code */
const serverUrl = process.env.NEXT_PUBLIC_MORALIS_SERVER_URL
const appId = process.env.NEXT_PUBLIC_MORALIS_APP_ID
const masterKey = process.env.MASTER_KEY
await Moralis.start({ serverUrl, appId, masterKey })
const Orders = Moralis.Object.extend("Orders")
const orders = new Orders()
orders.set("orderNo", "xxxxxx")
orders.set("orderStatus", "xxxxxx")
orders.set("email", "xxxxxx")
orders.set("walletAddress", "xxxxxx")
orders.set("chain", "eth")
await orders.save()
}
オブジェクトの更新
export const updateOrder = async (orderNo: string, txhash: string) => {
const serverUrl = process.env.NEXT_PUBLIC_MORALIS_SERVER_URL
const appId = process.env.NEXT_PUBLIC_MORALIS_APP_ID
const masterKey = process.env.MASTER_KEY
await Moralis.start({ serverUrl, appId, masterKey })
const Orders = Moralis.Object.extend("Orders")
const query = new Moralis.Query(Orders).equalTo("orderNo", orderNo)
const order = await query.find()
if (order) {
order[0].set("orderStatus", "ordered")
order[0].set("txhash", txhash)
order[0].save()
}
}
データの操作も非常に簡単でした。
これら以外にもさまざまな条件での制御や検索なども機能提供されているので詳細はドキュメントを参照ください。
https://docs.moralis.io/moralis-dapp/database
ETHによる決済
Contractに注文情報を全て保存することも可能ですが、個人情報を含んでいたりデータ保存量が多くなるため多くのデータはMoralisのオブジェクトへ保存し、Contract上には注文番号のみを保持するようにしました。
Contractでやっていることは以下
- 金額チェック
- AddressとOrderNoのマッピングへの追加
- AddressとOrderCountのマッピングへの追加
- コントラクトオーナーへ転送
実際のSolidityコードはこんな感じです。
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract xxxxxx is Ownable {
mapping(address => string[]) public orders;
mapping(address => uint256) public orderCount;
uint64 public price;
event Ordered(address indexed _from, string orderNo);
constructor(uint64 _price) {
price = _price;
}
function setPrice(uint64 _price) external onlyOwner {
price = _price;
}
function Purchase(string calldata orderNo) external payable {
require(price == msg.value, "send correct value");
orders[msg.sender].push(orderNo);
orderCount[msg.sender]++;
(bool result, ) = payable(owner()).call{value: msg.value}("");
require(result);
emit Ordered(msg.sender, orderNo);
}
}
ordersとorderCountで2つ分けて保持しているのはmappingで配列を保持する場合、Read処理で配列の場所まで指定する必要があるため、配列の総数を別途把握する必要があるためです。
ここは正直もっと良い方法があるのではないかと思っていますが、一旦これで実装をしています。
DAppsから注文全権取得するときもorderCountをまず取得してそのカウント分だけループしてorder情報を取得しています。
Moralis使ってて気になった点
- Typescriptに対応していないところがちらほら
- 公式のドキュメント通りだと動かない
- トランザクションの発行は結局ethersjs使うのとそんな変わらない
- MoralisのSDK経由だとReadしか対応していないのでWriteするときはetherjs使う必要がある
NFT扱ってて気になった点
- IPFSのレスポンスが激おそ.....
- NFT一覧取得して表示するときに画像の表示だけめちゃくちゃ遅い
- なんなら大量に特定コレクションの画像があるウォレットだとIPFS側から429返されたりする
Moralisの良いなと思った点
- ログインの実装がかなり楽
- 現状だとWeb2組み合わせてのDappsが基本だと思うがその際にSessionでの認証が簡単
- カスタムオブジェクトへのデータ保存やサーバーレスファンクションの実装などDapps作成関連の機能が豊富
- サーバー側処理は今回は書き慣れているNext APIを利用したので触っていないがMoralisだけでもできることの幅は広そう。
ContractのトランザクションとWeb2のトランザクションの整合性に関して
ContractへはMetamask経由(Client)からのリクエスト、Moralis DBへはセキュリティなどを考慮してサーバーからのリクエストのため通常のサーバー側で完結する処理であればTransactionを貼って、Rollback等の処理が組めるがClientとServerで分かれているのでそれができない。
特にContractはロールバックが行えないのでその制約を考慮した上での実装が必要となった。
最終的には苦肉の策で以下の処理でDB側はステータスで制御することにした。
- DBにdraftステータスでレコード作成
- Contractの実行
- DBのステータスを更新
ゴミレコードが生まれる可能性が非常に高いのでレコード数によっては定期的にクリーンアップするようなジョブも組むことを検討する必要がある。