sd-webui+智能合约构建头像生成dapp

最近做了一个 dapp 小项目用来巩固学习一下智能合约，也尝试用 ai 来增强 nft 的定制与趣味性。这篇文章主要记录一下其中的一些关键步骤。

Brain Storm

我习惯先脑暴一下，想象出最终的效果，然后反推实现。

用 ai 来生成头像，单纯生成头像有一点无聊了，生成的头像可以与现实世界的状态有联系，我想要一种区块来锚定住 ai 的黑盒产出和现实世界状态的感觉。

加入抽卡元素，而对于 ai 绘画来说，这个 “卡 “设定为 “绘画关键词”。

生成的头像可以铸造为 nft，单纯铸造也有点无聊，它不应该只是一张上链的图片，可以有更多 “正版” 的感觉。我现在愿意给正版游戏、画作花钱一是为了支持作者，二很大程度是因为后续的配套服务。除了 nft 天然的交易特性，铸造后的用户应该拥有更多附加的延伸功能。

简单确定一下第一个版本用户动线：

进入后，点击生成按钮（可以有额外配置），生成头像图片。

生成后，可以选择其中一张铸造为 nft。

如果要铸造，点击连接钱包，点击铸造。

铸造完成后，会记录铸造信息，作为提供后续服务的基础（如可以基于铸造过的图片 seed 上再生成）。同时会奖励一定的点数，并抽取一个绘画关键词。

效果预览

gpu 服务器非常贵，，为了防止恶意 ddos，在线试玩暂时只开放给了几个朋友，这里截图看一下流程：

初始状态，用户没有链接钱包，也仅有 “flower” 关键词可用：

点击 “make a wish” 即可生成头像
每张头像的结果都与生成的时间，地点等有一定关联。比如你在日本，白天生成，那么头像中的人物会在白天，穿着和服：

获取图片后，如果想要进行铸造则需要连接钱包：

连接钱包并铸造成功，会赠送一个抽取的绘画关键词：

回到首页，就会发现你可以基于已铸造的图片进行头像图片生成，也增加了额外的可用关键词：

我们试试在之前头像的基础上，用上新的关键词进行生成：

新关键词 “Mononoke Hime” 出自《幽灵公主》，生成的头像也会有幽灵公主的风格：

预览看完了，接下来说说应用中各个部分是如何实现的

AI 头像生成服务

当前最流行的开源 ai 画作生成项目 stable-diffusion-webui（后面简称 sd-webui），在部署后除了可以直接启动一个本地 web ui 服务，也可以以 api 模式运行，暴露出接口供其他服务调用。只需要在启动时加上--api参数就可以。
克隆仓库并安装，在 sd-webui 的仓库下运行命令
./webui.sh --api
或者
./webui.sh --nowebui
区别是--api除了提供 api 接口服务外，依然会启动原来的本地 web ui 服务，而--nowebui则单纯提供接口服务。

启动成功后，打开此网页：
http://127.0.0.1:7860/docs，你应该就可以看到对应的接口文档：

然后就可以像调用普通接口一样调用 sd-webui 的接口了。比如最常用的 text2img 功能：

接口返回的图片资源是 base64 字符串，除了方便前端处理外，还可以利用 sd 模型黑盒噪声的随机性，将 base64 字符串或者直接将图片 seed 做 hash，作为应用其他功能如抽奖的伪随机种子。
如果部署后需要进行跨域请求，在运行 "webui.sh" 时加上--cors-allow-origins参数。

※ 如果安装过程问题太多，可以试试直接用这个 docker 镜像：https://hub.docker.com/r/kestr3l/stable-diffusion-webui

合约编写

合约需要的功能：

基础的 nft 功能（铸造、转移、可关联 tokenURI）。

记录某用户拥有的所有 nft tokenId。

积分功能（后续服务会用到）。

这次选择以ERC721Enumerable为基础合约来编写智能合约。合约代码如下

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

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "hardhat/console.sol";

error Insufficient_Mint_Value();

contract HimeNft is ERC721Enumerable, Ownable {
uint256 private constant MINT_PRICE = 0.1 * 1e18;
uint256 private _tokenId = 0;
mapping(uint256 => string) private _tokenURIs;
mapping(address => uint256) private _points;

constructor() ERC721("HimeNft", "HN") {}

function mintNft(
string calldata tokenURI
) public payable returns (uint256) {
if (msg.value < MINT_PRICE) revert Insufficient_Mint_Value();

_safeMint(msg.sender, _tokenId);
_setTokenURI(_tokenId, tokenURI);
_tokenId += 1;
_points[msg.sender] += 10;
return _tokenId;
}

function withDraw() public onlyOwner {
(bool callSuccess, ) = payable(msg.sender).call{
value: address(this).balance
}("");
require(callSuccess, "call failed");
}

function _setTokenURI(
uint256 tokenId,
string memory _tokenURI
) internal virtual {
require(
_exists(tokenId),
"ERC721URIStorage: URI set of nonexistent token"
);
_tokenURIs[tokenId] = _tokenURI;
}

function tokenURI(
uint256 tokenId
) public view virtual override returns (string memory) {
_requireMinted(tokenId);

return _tokenURIs[tokenId];
}

function getTokenCounter() public view returns (uint256) {
return _tokenId;
}

function getPoints(address owner) public view returns (uint256) {
return _points[owner];
}

function getOwnedTokens(
address owner
) public view returns (uint256[] memory) {
uint256 tokenCount = balanceOf(owner);
uint256[] memory result = new uint256[](tokenCount);
for (uint256 i = 0; i < tokenCount; i++) {
result[i] = tokenOfOwnerByIndex(owner, i);
}
return result;
}
}

※ 这里只是简单实现，为了之后的功能升级，你最好使用代理合约来代理应用与合约间的交互

顺便附上测试代码，方便本地测试：

Copy
const { assert, expect } = require("chai");
const { deployments, ethers, getNamedAccounts } = require("hardhat");

describe("HimeNft", async () => {
const MINT_VALUE = ethers.utils.parseEther("0.1");
const INSUFFICIENT_MINT_VALUE = MINT_VALUE.sub(1);
const TEST_TOKENURI = "TEST_TOKENUR";
let deployer, nft;

beforeEach(async () => {
await deployments.fixture();
deployer = (await getNamedAccounts()).deployer;
nft = await ethers.getContract("HimeNft", deployer);
});

it("initial token count is 0", async () => {
const counter = await nft.getTokenCounter();

assert.equal(counter, 0);
});

it("revert on mint value is insufficient", async () => {
expect(
nft.mintNft(TEST_TOKENURI, {
value: INSUFFICIENT_MINT_VALUE,
})
).to.be.revertedWith("Insufficient_Mint_Value");
});

it("add correct points after mint", async () => {
await nft.mintNft(TEST_TOKENURI, {
value: MINT_VALUE,
});
const points = (await nft.getPoints(deployer)).toString();

assert.equal(points, "10");
});

it("get correct tokenURI and token list", async () => {
const TEST_TOKENURI0 = "TEST_TOKENURI0";
const TEST_TOKENURI1 = "TEST_TOKENURI1";

await Promise.all([
nft.mintNft(TEST_TOKENURI0, {
value: MINT_VALUE,
}),
nft.mintNft(TEST_TOKENURI1, {
value: MINT_VALUE,
}),
]);

const list = (await nft.getOwnedTokens(deployer)).toString();
const tokenURI0 = await nft.tokenURI(0);

assert.equal(tokenURI0, TEST_TOKENURI0);
assert.equal(list, "0,1");
});
});

但你可能会发现，这里好像并没有 “抽取一个绘画关键词” 这个逻辑。
记得上文提到的 “直接将图片 seed 做 hash，作为应用其他功能如抽奖的伪随机种子” 吗，为了节省存储空间，我将图片的 seed 存入 nft 的 metaData 中，并将 seed 映射为一个绘画关键词。这样 mint 的同时也可以完成 “抽取绘画关键词”，同时也节约 gas。
比如最简单的，我有一个这样的 map:

Copy
{
"royal": 0,
"cute": 1,
}

我将 seed 做 mod 取余处理，如 seed 为 334451，除数我们设置为 10，取余 mod (334451,10)，结果为 1，那对应的抽卡结果就是 “cute” 关键词。当然这个映射算法还可以更加复杂，甚至配置各项的概率。
为了足够的透明性，这个 map 方法应该上传至区块链并 verify。

合约中也定义了一个 “_points” 的 map，用来存储点数，这个点数会在之后用作其他服务的代币（这里为了简单没有用额外的 ERC20 合约去实现代币，如果你想要它是更加 “标准” 的代币，可以用 ERC20 单独实现，或者将本合约换成 ERC1155 以实现多种代币管理）。

构建前端界面

我这次使用 next.js+tailwind+react-moralis+ethers.js 来构建、部署前端的服务。
这里就不多讲了，代码放在 https://github.com/moayuisuda/HimeAvatar

值得注意的是 react-moralis 的useWeb3Contracthook 是支持分布调用的：
你可以直接调用合约方法：

Copy
const { runContractFunction } = useWeb3Contract({
abi,
contractAddress: address[chainId as keyof typeof address],
functionName: "mintNft",
params: { tokenURI: "TOKENURL" },
});

runContractFunction()

也可以先定义合约的 abi、地址等基础信息，再调用具体的方法：

Copy
const { runContractFunction } = useWeb3Contract({
abi,
contractAddress: address[chainId as keyof typeof address],
});

runContractFunction({
params: {
functionName: "mintNft",
params: { tokenURI: "TOKENURL" },
},
})

至此各个部分的搭建就都完成了，你可以将这几个服务打包为 docker，方便在服务器一键部署。

Todo

用 openai 的 sdk 构建可连贯世界观、对话（个人建议用 python 版本的 sdk，nodejs 版个人觉得用起来很别扭），让单纯的构建头像变为构建 “人物”。

支持创作者自行上传种子图片并生成 lora 模型，生产图片并进行创作者分成。

构建 points 代币的额外服务商店。

Gift

应用中的模型是我个人炼的，大模型底模或多或少会存在版权问题，所以单纯生成头像图片是完全免费的。