记录开发一个工具类Dapp的过程

最近开发了工具类的 Dapp, 主要内容就是用户上传 RSS Feed 的 OPML 文件，上传到 IPFS，生成一个 CID，然后使用智能合约，对钱包地址和 CID 做一个映射，支持在网页端进行一个简单的文件操作。目标是希望每个用户都可以通过这个 Dapp 去中心化的维护一份自己的 opml 文件。但是这样一个简单的目标，在开发的时候也遇到了很多的问题，开一篇博客记录一下，以供参考。
（本人还只是在校学生，凭兴趣接触 Dapp 开发不久，技术力有限）

所用技术栈

React+Vite
构建前端静态页面。

RainbowKit
通用的钱包连接组件，轻松自定义钱包连接，区块链切换，查看钱包信息。

Wagmi
用于与以太坊钱包和区块链进行交互的 React Hooks 库。提供了简单的接口来连接钱包、获取链上数据、调用合约等功能。

Web3.js
用于调用智能合约，实现与区块链的交互。

Kubo-rpc-client
用于与 IPFS 节点进行通信的客户端库。通过 IPFS 的 RPC 接口，上传文件、获取文件、固定文件。

Remix IDE
用于编写和部署智能合约，适合简单的智能合约开发。

fast-xml-parser
用于解析和构建 OPML 文件，使得能够对 OPML 进行基本的添加、删除、更新操作。

Dapp 实现目标

在用户开始使用时，需要进行 3 个初始化步骤，第一步是连接钱包，第二步是连接 Kubo 网关，第三步是导入 OPML 文件，支持从本地或通过 CID 从 IPFS 导入，此 CID 可以通过区块链 Map 的信息自动获取。
在完成初始化步骤后，即可对 OPML 文件进行一些基础的操作。在完成操作后，用户将文件重新上传到 IPFS, 获取新的 CID, 最后通过调用智能合约将新的 CID 写入区块链。

智能合约部分

该项目的智能合约逻辑非常简单，就是维护一个钱包地址对 CID 的映射表，提供查询和更新操作即可。对于简单的智能合约直接使用 Remix IDE 编写和部署即可。可以免去大部分安装环境的麻烦，使用 Remix IDE 将合约部署到各个区块链上非常的方便。
以下是我编写的 sol 合约

Copy
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract RSSFeedStorage {
address private immutable owner;

mapping(address => string) private ipfsHashes;

constructor() {
owner = msg.sender;
}

modifier onlyOwner() {
require(msg.sender == owner, "Only the owner can set the IPFS hash");
_;
}

event IPFSHashUpdated(string newHash);

function updateIPFSHash(string memory _ipfsHash) public onlyOwner {
if (
keccak256(bytes(ipfsHashes[owner])) != keccak256(bytes(_ipfsHash))
) {
ipfsHashes[owner] = _ipfsHash;
emit IPFSHashUpdated(_ipfsHash);
}
}

function getIPFSHash(address user) public view returns (string memory) {
return ipfsHashes[user];
}
}

完成智能合约编写后进行编译，获取到一个通用的 ABI 文件，通过 web3.js 库，根据这个 ABI 文件配合不同链实际部署的地址，即可实现在各种链上完成相同的交互逻辑，将合约发布到各个区块链上，可以获得不同的合约地址，在 React 项目中维护一个合约地址映射表，使得 Dapp 可以选择不同的链调用不同的智能合约。

对于这个项目而言，区块链的确认速度不是很关键，操作次数可能会比较多，可以选择一些 Layer2 的区块链，大大降低合约交互的费用，或者使用免费的测试网。

前端合约交互部分

前端合约交互部分主要使用到 RainbowKit 和 Wagmi 库，RainbowKit 提供了一个可自定义的钱包连接组件，基本上提供了连接钱包所有的交互逻辑，包括连接钱包，查看钱包信息，切换钱包当前链。开发者只需要在默认组建上做样式修改即可。
在使用 RainbowKit 的组件的时候，需要到 WalletConnect Cloud 申请一个 projectId，配置到 RainbowkitConfig 中，使用 Provider 包括当前组件即可在项目中使用 RainbowKit hook。

Copy
import { getDefaultConfig } from "@rainbow-me/rainbowkit";
import { optimism, sepolia } from "wagmi/chains";

const config = getDefaultConfig({
appName: "AppName",
projectId: 'projectId',
chains: [sepolia, optimism],
ssr: false,
});

const queryClient = new QueryClient();
const App = () => {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider>
{/* Your App */}
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
};

智能合约的调用可以使用 web3.js 库，通过提供 ABI 文件和合约地址，即可调用智能合约，想要调用以上的智能合约的 getIPFSHash 方法，需要传入一个钱包地址，此时就可以使用 Wagmi Hooks 获取钱包地址，Wagmi 提供了很多区块链相关的 Hook, 可以查看用户地址，查看当前链 ID 等操作。
以下是一个简单的 web3.js 调用智能合约的实例。

Copy
//Example React Provider componet.ts
import contractABI from "./RSSFeedStorage.json";
import Web3 from "web3";

//定义不同链的合约地址
//具体区块链ID查看https://wagmi.sh/react/api/chains#supported-chains
const contractAddresses: { [key: number]: { address: string } } = {
11155111: { // Sepolia
address: "0x63Bbcd45b669367034680093CeF5B8BFEee62C4d"
},
10: { // Optimism
address: "0x2F4508a5b56FF1d0CbB2701296C6c3EF8eE7D6B5"
},
};

//调用合约updateIPFSHash方法
const updateIPFSAddress = async (
ipfsPath: string,
address: string,
chainId: number
) => {
const contractAddress = contractAddresses[chainId]?.address;
if (!contractAddress) {
throw new Error("contract address is not found");
}

const contract = new web3.eth.Contract(contractABI, contractAddress);
const ipfsAddress = await contract.methods
.updateIPFSHash(ipfsPath)
.send({ from: address });
return ipfsAddress;
};

//调用合约getIPFSHash方法
const getIPFSAddress = async (address: string, chainId: number) => {
const contractAddress = contractAddresses[chainId]?.address;
if (!contractAddress) {
throw new Error("contract address is not found");
}

const contract = new web3.eth.Contract(contractABI, contractAddress);
const ipfsAddress = await contract.methods.getIPFSHash(address).call();
return ipfsAddress;
};

在 react 组件中，使用 Wagmi Hook 调用以上方法

Copy
import { useAccount, useChainId } from "wagmi";
import { useDapp } from "../providers/DappProvider";

const Example = () => {
const chainId = useChainId();
const { address, isConnected } = useAccount();
const { getIPFSAddress, updateIPFSAddress } = useDapp();
const ipfsPath = "bafkreie5duvimf3er4ta5brmvhm4axzj3tnclin3kbujmhzv3haih52adm"
return (
<>
{
updateIPFSAddress(ipfsPath, address, chainId);
}}>Update IPFS Address
{
getIPFSAddress(address, chainId);
}}>Get IPFS Address
</>
)
}
export default Example

IPFS 存储部分

IPFS 存储部分主要使用 Kubo-rpc-client 库，Kubo 是 IPFS Golang 语言的实现方案，像是常见的 IPFS Desktop, 以及 Brave 浏览器自带的 IPFS 服务均为 Kubo, 使用 Kubo-rpc-client 库连接 Kubo 网关后，即可轻松上传，下载，固定文件。
注意：在 Kubo 的配置文件中，要进行跨域设置，否则无法连接到 Kubo 网关。

Copy
{
"API": {
"HTTPHeaders": {
"Access-Control-Allow-Credentials": [
"true"
],
"Access-Control-Allow-Headers": [
"Authorization"
],
"Access-Control-Allow-Methods": [
"PUT",
"GET",
"POST",
"OPTIONS"
],
"Access-Control-Allow-Origin": [
"*"
],
"Access-Control-Expose-Headers": [
"Location"
]
}
},
...
}

以下是一个简单的创建 Kubo-rpc-client 实例，并进行上传，下载的示例。

Copy
import { create, KuboRPCClient } from "kubo-rpc-client";

// 连接Kubo网关，创建kubo-rpc-client实例
const connectKubo = async (KuboUrl: string) => {
try {
const KuboClient = create({ KuboUrl });
} catch (error) {
console.error("Can't connect to Kubo gateway:", error);
throw error;
}
};

// 上传文件到IPFS
const uploadOpmlToIpfs = async (opml) => {
try {
const buildedOpml = buildOpml(opml);
//add方法pin参数默认为true,即上传文件后默认固定到IPFS,更多参数查看
//https://github.com/ipfs/js-ipfs/blob/master/docs/core-api/FILES.md#ipfsadddata-options
const res = await kuboClient?.add(buildedOpml, { cidVersion: 1 });
} catch (error) {
console.error("Can't upload OPML to IPFS:", error);
}
};

// 从IPFS获取一个文件
// 由于IPFS中的Cat是一个异步迭代器，编写一个工具方法获取完整数据。
export async function catFileFromPath(path: string, kubo: KuboRPCClient) {
const chunks = [];
for await (const chunk of kubo.cat(path)) {
chunks.push(chunk);
}

const fullData = new Uint8Array(chunks.reduce((acc, curr) => acc + curr.length, 0));
let offset = 0;
for (const chunk of chunks) {
fullData.set(chunk, offset);
offset += chunk.length;
}

return fullData;
}

// 调用方法，把二进制数据解码为文本。
const handleImportFromIpfs = async (ipfsPath) => {
try {
const res = await catFileFromPath(importIpfsPath, kuboClient);
const opmlText = new TextDecoder().decode(res);
} catch (error) {
console.error("Can't import OPML from IPFS:", error);
}
};

前端静态文件部署

在使用 React+Vite 完成前端页面编写后，使用 Vite 编译静态文件，使用 IPFS Desktop 上传到编译的 dist 文件夹并固定到本地即可。至此为此，该 Dapp 已经部署到去中心化网络中，但是此时的可用性只能说是一点没有，编译的静态文件除了在本地可用，在其他环境中可用性完全看运气，最后还是在中心化和去中心化找了一个折中点，使用了 Fleek 服务。
Fleek 是一个类似 Vercel 的自动化部署、静态网站托管平台，但是 Fleek 会将静态网站发布到 IPFS 中，并且自带了 CDN, 对于 IPFS 环境的浏览器，提供 IPFS 服务，对于没有 IPFS 环境的浏览器，同样提供服务，使得 Dapp 的可用性大大增加，最重要的是，提供了 web2 域名解析的功能，配合 CloudFlare 服务，不用在本地为该前端静态文件维护 IPNS, 以及使用又臭又长的 CID 地址访问了。
使用方法和 Vercel 类似，连接到 Github 仓库，配置 Deploy 设置，一键部署即可。然后在 Custom Domains 绑定上自己的域名即可。Fleek 同样支持 ENS 与 HNS。

IPFS 文件可靠性保证

在使用 Kubo-rpc-client 上传的 Opml 文件，会固定在 Kubo 上，通过同一个 Kubo 服务获取文件速度和可靠性还行。但是在不同的网络环境中，这可靠性同样一言难尽，在很多时候已经通过区块链的映射获取到了 CID, 但是 CAT 文件要非常非常久，甚至失败。这时候还是会需要用到 IPFS 服务商，提供 IPFS 存储的服务商有很多，例如

Panata

Filebase

Infura

Fleek

这些服务商都有一定的免费存储额度，少的几个 g, 多的十来 g, 对于存储 OPML 这种纯文本文件，在用户量较小的时候足以轻松应对。
但是 Panata 和 Filebase 的 SDK 只支持后端环境上传文件，导致纯静态页面无法使用其服务，还有一个巨坑的点，这两家服务商对于免费账户不提供存储 html 文件服务，但是我存的是 OPML，他也会识别成 html 不让我存储，Infura 还不清楚免费账户如何开通 IPFS 存储权限（研究的不深入，如果有实现方案欢迎指出）
最后还是选择了 Fleek 的 IPFS 存储服务，但是 Fleek 貌似有两个版本的网站，一个是 fleek.co, 一个是 fleek.xyz, 我的前端页面是部署在 fleek.co 上，但是 ipfs 存储服务只有 fleek.xyz 上才有。

使用 fleek 的 IPFS 存储服务需要申请一个 ClientID,ClientID 需要在 Fleek 的 Cli 中申请，所以需要安装 Fleek 的 SDK。

Copy
# You need to have Nodejs >= 18.18.2
npm install -g @fleek-platform/cli

#登陆fleek账户
fleek login

#创建ClientID
fleek applications create

#✔ Enter the name of the new application: …
#输入应用名称，随意即可。

#✔ Enter one or more domain names to whitelist, separated by commas (e.g. example123.com, site321.com) …
#输入网站域名，即fleek前端静态部署的域名，建议配置上localhost和127.0.0.1，会涉及到跨域问题。

#获取ClientID
fleek applications list

在获取到 ClientID 后，可以在环境变量中添加 ClientID, 在前端页面上传到本地 IPFS 时，同时上传到 Fleek。以下是一个简单示例。

Copy
// npm install @fleek-platform/sdk
import { FleekSdk, ApplicationAccessTokenService } from "@fleek-platform/sdk";

//在环境变量中配置VITE_FLEEK_CLIENT
const applicationService = new ApplicationAccessTokenService({
clientId: import.meta.env.VITE_FLEEK_CLIENT,
});

const fleekSdk = new FleekSdk({
accessTokenService: applicationService,
});

//Fleek.xyz中的IPFS存储默认是使用CID v1版本，生成的cid会以ba开头
//而Kubo默认生成的CID为v0版本，会以Qm开头，为保持一致，将kubo默认CID版本设置为v1
const uploadOpmlToIpfs = async () => {
try {
const xml = buildOpml(opml);
const res = await kuboClient?.add(xml, { cidVersion: 1 });

await fleekSdk.ipfs().add({
path: res.path,
content: xml,
});

addAlert(`OPML uploaded to IPFS`, "success");
} catch (error) {
console.error("Can't upload OPML to IPFS:", error);
}
};

为了增加 IPFS 文件的可靠性，还是建议将 IPFS 文件同时上传到不同的 IPFS 服务商，因为在我的使用感受中，只使用 Fleek 的 ipfs 存储服务，获取文件速度仍然感人。

一点开发感受

在一开始的时候，设想的是开发一个工具类 Dapp 后，交互逻辑都交给智能合约，数据存储交给 IPFS, 这两项服务都是去中心化的，自己也不需要架设服务器、提供运维，想着开发出来后基本上就不需要再维护了，属于一劳永逸的项目。但随着 Fleek 和一些中心化服务的 api key 的引入，发现就算是逻辑简单的工具类项目，同样需要提供一定限度的维护，有些功能的可靠性仍然需要中心化平台提供。一个纯粹的 Dapp 在目前仍然难以实现。
在开发的时候，还想着区块链高额的交易费是不是工具类 Dapp 的一个门槛，毕竟谁想简单的存个 OPML 文件要付费几十刀。但是现实是各种 Layer2 链已经能够将交易费优化到几美分。玩跨链桥剩下的 1 美元足够我发布合约并且正常使用该 Dapp 了，毫不肉痛（或许我应该把 OPML 文件直接存在区块链上？）。这点还是让我惊叹这两年区块链发展带来的优秀体验。
还有一个最大的感受就是 IPFS 真的是又慢又玄学，极大影响用户体验，为保证用户体验还是上云服务器吧，别折腾 IPFS 了，这玩意完全和用户体验冲突。

相关链接

RainbowKit 文档
Wagmi 文档
Web3.js 文档
申请 WalletConnect projectId
js-kubo-rpc-client Github
Remix IDE 在线智能合约开发
INFURA Sepolia 测试币水龙头
Fleek.co 静态前端页面部署
Fleek.xyz IPFS 存储文档