Web3免费领比特币的“水龙头”到底是什么?教你开发多个版本的水龙头DApp(涵盖前端、后端、智能合约)

6,190 阅读16分钟

刚接触区块链的朋友可能都会有个疑惑。当有一个新币发布,在刚开始的时候改怎么让它流通呢?

这就靠领币网站了。免费领币网站,也叫做水龙头。意思就是当一个人口渴的时候,就要去水龙头接水喝。

其实这就是一种薅羊毛的行为,和我们平时在各种 App 里面签到得奖励的逻辑是一样的。利用一些免费的奖励去扩大影响力。

区块链的鼻祖比特币在最开始就是用了 BTC 水龙头来扩大影响力。BTC 的水龙头一共免费送出 19700 枚比特币,如果放到比特币最贵的时候,价值大约有 14 亿美金。

那我们今天就来设计并实现一个水龙头网站。当然这个网站不可以领比特币,我也没有那么多比特币给你们。

我可以免费提供一些我自己的 Noah 币,这个币发布到 Goerli 测试网,没有什么实际价值。

下面我们开始设计逻辑。

为了更好的学习,这里推荐你先去看同一系列的上一篇文章。

玩法与实现方式

领币,实际上是一种修改链上数据的行为。而修改链上数据,就一定需要支付一定的 gas 费用。

这笔 gas 由谁出呢?要么是领币的用户出,要么是项目方来出。

这也就对应着两种玩法,两种实现方式。

玩法一:用户出 gas 费,纯智能合约实现

这种玩法需要用户连接自己的钱包账户,然后签名交易。这个玩法所有的校验逻辑,发币逻辑都可以在智能合约上做。但对用户来说有一定成本。

玩法二:纯后端 or 后端+智能合约

由项目方支付 gas 费,用户不需要支出任何费用,也无需签名交易。所以这种玩法很容易被接受,比较主流。

可以选择纯后端实现,也可以使用后端配合智能合约实现。

基本规则

免费的东西一定要有限制才可以长久使用,水龙头也不可能无限出水。

我们为水龙头设置一些规则限制:

  • 每个账户每 24 小时内只可以领一次币,每次领币个数由合约拥有者设置。
  • 如果水龙头合约币不够,就会导致领币失败。

接下来我们分别用两种方式来实现水龙头。

纯智能合约实现

纯智能合约实现比较简单。

合约实现

下面是具体代码。

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC20.sol";

contract Faucet {
    IERC20 public tokenContract; // 代币合约
    mapping(address => uint256) public recivedRecord; // 领取记录
    uint256 public amountEachTime; // 每次领取的数量
    address public owner; // 合约发布者

    constructor(address _tokenContractAddress, uint256 _amountEachTime) {
        tokenContract = IERC20(_tokenContractAddress);
        amountEachTime = _amountEachTime;
        owner = msg.sender;
    }

    // 领取代币,每个地址每24小时只能领取一次
    function withdraw() external {
        if (recivedRecord[msg.sender] > 0) {
            require(
                recivedRecord[msg.sender] - block.timestamp >= 1 days,
                "You can only request tokens once every 24 hours"
            );
        }
        require(
            tokenContract.balanceOf(address(this)) >= amountEachTime,
            "Not enough tokens in the contract"
        );
        recivedRecord[msg.sender] = block.timestamp; // 更新领取记录
        tokenContract.transfer(msg.sender, amountEachTime); // 转账
    }

    // 设置每次领取的数量,只有合约发布者可以调用
    function setAmountEachTime(uint256 _amountEachTime) public {
        require(msg.sender == owner, "Only the owner can set the amount");
        amountEachTime = _amountEachTime; // 更新每次领取的数量
    }
}

该合约主要有以下几个函数:

  • constructor:设置代币合约地址与每次领币数量。
  • withdraw:领币,在里面做了一些限制。
  • setAmountEachTime:设置每次领币数量,只能由合约拥有者调用。

其中会涉及到合约调用合约的逻辑,需要注意 msg.sender 的变化,以及一些边缘条件的检测。

具体细节就不多讲了,代码中有详细中文注释。

合约测试

我们编写一些代码对合约进行测试。

创建 /test/faucet.js 文件,并写入以下代码:

const NoahToken = artifacts.require("NoahToken");
const Faucet = artifacts.require("Faucet");

contract("Faucet", (accounts) => {
  const [alice, bob] = accounts;

  it("withdraw", async () => {
    // 发 Noah 币,发行 100 个
    const noahTokenInstance = await NoahToken.new('noah', 'NOAH', 0, '1024', { from: alice });
    // 发水龙头,每次发 1 个
    const faucetInstance = await Faucet.new(noahTokenInstance.address, 1, { from: alice });
    // 把 100 个 Noah 币转给水龙头
    await noahTokenInstance.transfer(faucetInstance.address, 100, { from: alice });
    // 查看水龙头的余额
    const res = await noahTokenInstance.balanceOf(faucetInstance.address, { from: alice });
    assert.equal(res.words[0], 100, "水龙头的余额不是 100");
    // bob 从水龙头取币
    await faucetInstance.withdraw({ from: bob });
    try {
      // bob 重复从水龙头取币
      await faucetInstance.withdraw({ from: bob });
    } catch (e) {
      // 重复取币会报错
      assert.include(e.message, 'You can only request tokens once every 24 hours', 'bob 不能重复取币')
    }
    // 查看水龙头的余额
    const res2 = await noahTokenInstance.balanceOf(faucetInstance.address, { from: alice });
    assert.equal(res2.words[0], 99, "水龙头的余额不是 99");
    // 查看 bob 的余额
    const res3 = await noahTokenInstance.balanceOf(bob, { from: alice });
    assert.equal(res3.words[0], 1, "bob 的余额不是 1");
  });
});

代码中有详尽的中文注释,就不多做解释了。

写完之后运行测试命令:

turffle test ./test/faucet.js

结果一切正常。

合约部署

我们需要编写一个新的部署文件。

创建 /migrations/2_Faucet_migration.js 文件,并写入如下代码:

const NoahToken = artifacts.require("NoahToken");
const Faucet = artifacts.require("Faucet");

module.exports = function (deployer) {
  deployer.deploy(NoahToken, 'noah', 'NOAH', 18, '1024000000000000000000').then(function () {
    return deployer.deploy(Faucet, NoahToken.address, 1);
  });
}

因为 faucet 合约会依赖 noahToken 合约的地址,所以可以串行部署。noahToken 合约部署成功后,可以将实例传递给 faucet,然后 faucet 合约再使用 noahToken 的合约地址进行部署。

写完之后运行部署命令:

turffle migrate --network development --f 2

部署成功后,会在 ganache 中看到这两个合约。

前端实现

前端主要两个组件:

  • Withdraw:用于领币,任何连接钱包的用户都可以看到。
  • SetAmountEachTime:用于设置每次领取数量,只有合约拥有者可以看到。其他人看到也无所谓,因为调用合约会失败。

Withdraw 组件

代码如下,逻辑并不复杂,就不多介绍了:

function Withdraw() {
  const toast = useToast();

  // 查询当前每天领取的数量
  const [amountEachTime, setAmountEachTime] = useState(1);
  const { data: signer } = useSigner();
  const { address } = useAccount();
  const contractInstance = useContract({
    ...contract,
    signerOrProvider: signer,
  });

  useEffect(() => {
    if (!contractInstance || !signer) {
      return;
    }
    (async () => {
      const res = await contractInstance.amountEachTime();
      setAmountEachTime(res.toNumber());
    })();
  }, [contractInstance, address, signer]);

  // 领币
  const { config: withdrawConfig, isError: isPrepareError } =
    usePrepareContractWrite({
      ...contract,
      functionName: "withdraw",
    });
  const {
    write,
    data,
    isError: isWriteError,
  } = useContractWrite(withdrawConfig);
  const { isLoading, isError: isWaitTransactionError } = useWaitForTransaction({
    hash: data?.hash,
    onSuccess: () => {
      toast({
        title: "领取成功",
        status: "success",
        duration: 3000,
        isClosable: true,
      });
    },
  });

  return (
    <div className="flex flex-col gap-4">
      <Heading>领取代币</Heading>

      <Alert status="info">
        <ul>
          <li>每个地址每 24 小时最多只能领取 1 次</li>
          <li>每次可以领取 {amountEachTime} 个 Noah 币</li>
        </ul>
      </Alert>

      <Button
        disabled={isLoading}
        isLoading={isLoading}
        onClick={() => write?.()}
      >
        领取
      </Button>

      {isPrepareError && <Alert status="error">{`该地址目前不可以领取`}</Alert>}
      {isWriteError || isWaitTransactionError ? (
        <Alert status="error">{`领取失败`}</Alert>
      ) : null}
    </div>
  );
}

SetAmountEachTime 组件

代码如下,逻辑并不复杂,就不多介绍了:

function SetAmountEachTime() {
  const toast = useToast();

  const { data: signer } = useSigner();
  const { address } = useAccount();
  const contractInstance = useContract({
    ...contract,
    signerOrProvider: signer,
  });

  useEffect(() => {
    if (!contractInstance || !signer) {
      return;
    }
    (async () => {
      const res = await contractInstance.owner();
      if (res === address) {
        setIsOwner(true);
      }
    })();
  }, [contractInstance, address, signer]);

  const [isOwner, setIsOwner] = useState(false);

  const [amountEachTime, setAmountEachTime] = useState(0);

  // 设置每次领取的数量
  const { config: setAmountEachTimeConfig } = usePrepareContractWrite({
    ...contract,
    functionName: "setAmountEachTime",
    args: [amountEachTime],
    enabled: isOwner,
  });
  const {
    write,
    data,
    isLoading,
    isError: isWriteError,
  } = useContractWrite(setAmountEachTimeConfig);
  const {
    isLoading: isSetAmountEachTimeLoading,
    isError: isWaitForTransactionError,
  } = useWaitForTransaction({
    hash: data?.hash,
    onSuccess: () => {
      toast({
        title: "设置成功",
        status: "success",
        duration: 3000,
        isClosable: true,
      });
    },
  });
  if (!isOwner) return null;
  return (
    <div className="flex flex-col gap-4">
      <Heading>设置每次领取的代币数量</Heading>
      <Input
        value={amountEachTime}
        onInput={(e) => {
          setAmountEachTime(Number(e.currentTarget.value));
        }}
        placeholder="输入新的每次领取 Noah 币数量"
      ></Input>
      <Button
        disabled={isLoading || isSetAmountEachTimeLoading}
        isLoading={isLoading || isSetAmountEachTimeLoading}
        onClick={() => write?.()}
      >
        设置
      </Button>

      {(isWriteError || isWaitForTransactionError) && (
        <Alert status="error">{`设置失败`}</Alert>
      )}
    </div>
  );
}

最终效果

未领币状态。

领币后的状态。

如果使用合约拥有者连接,那么就可以设置领币数量。

纯后端实现

接口定义

接口比较简单,只有三个,分别是:

  • withdraw:领币。
  • config(GET):获取配置,主要是每次领取的代币数量。
  • config(POST):设置配置,主要是设置每次领取的代币数量。

withdraw

基本信息
HTTP URL/api/faucet/withdraw
HTTP MethodPOST

请求

请求头
名称类型必填描述
Content-Typestring固定值:"application/json; charset=utf-8"
请求体
名称类型必填描述
addressstring钱包账户地址。示例值: "0x0Ad88a4Bd674c4632bD65B218E4B17c9F2f49AAc"数据校验规则:- 0x 开头,长度为 42。
请求体示例
{
  "address": "0x0Ad88a4Bd674c4632bD65B218E4B17c9F2f49AAc"
}

响应

响应体
名称类型描述
codenumber错误码,非 0 表示失败
msgstring错误描述
data--
响应体示例
{
  "code": 0,
  "msg": "success",
  "data": null
}

错误码

HTTP 状态码错误码描述排查建议
400100100地址不合法检查地址合法性
400100101当前地址不能领取24 小时内每个账户只可以领取一次
500200000服务暂不可用-
500200001未知错误-
500200100水龙头余额不足-

config-GET

基本信息
HTTP URL/api/faucet/config
HTTP MethodGET

请求

请求头
名称类型必填描述
Content-Typestring固定值:"application/json; charset=utf-8"
响应
响应体
名称类型描述
codenumber错误码,非 0 表示失败
msgstring错误描述
data--
响应体示例
{
  amount: 100
}

错误码

HTTP 状态码错误码描述排查建议
500200000服务暂不可用-
500200001未知错误-

config-POST

基本信息
HTTP URL/api/faucet/config
HTTP MethodPOST

请求

请求头
名称类型必填描述
Content-Typestring固定值:"application/json; charset=utf-8"
Authorizationstring合法身份用户的 jwt
请求体
名称类型必填描述
amountnumber每次领币数量。示例值:5****数据校验规则:- 有效正整数
请求体示例
{
  "amount": 5
}

响应

响应体
名称类型描述
codenumber错误码,非 0 表示失败
msgstring错误描述
data--
响应体示例
{
  amount: 100
}

错误码

HTTP 状态码错误码描述排查建议
400100100数量格式不正确输入正确格式
500200000服务暂不可用-
500200001未知错误-

数据存储

因为后端需要存储数据,所以在编写代码之前,我们需要准备好数据库。

这里我选择使用免费的 supabase。

申请 supabase 的教程不多讲了,可以去 supabase 网站(supabase.com/)自行查看。

使用 prisma 操作数据库

prisma 是一个 Nodejs 中非常好用且流行的 ORM 框架,我们会使用它来操作数据库。

安装 prisma:

npm install prisma --save-dev 

初始化 prisma 模版:

npx prisma init

在 supabase dashboard 中找到 connection string,将它设置到 .env 中的 DATABASE_URL。

需要注意:prisma 和 supabase 集成需要配置影子数据库。

具体操作可以参考以下文档:

创建数据模型并迁移

在 prisma/schema.prisma 文件中编写以下数据模型:

model FaucetConfig {
  id        Int      @id @default(autoincrement())
  amount    Int      @default(1)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model FaucetDrawRecord {
  id        Int      @id @default(autoincrement())
  address   String   @unique
  drawTime  DateTime
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

这是 prisma 的 DSL,它会创建两张表。

两张表的作用分别是:

  • FaucetConfig 负责存储每次领币数量。
  • FaucetDrawRecord 负责记录每个地址的领币时间。

运行以下迁移命令:

npx prisma migrate dev --name init

稍等片刻,迁移成功。

在 supabase dashboard 中可以看到已经创建好了两张表。

prisma 和 next.js 集成

prisma 与 next.js 集成比较简单,创建 prisma/db.ts 文件,并写入以下代码:

import { PrismaClient } from "@prisma/client";

declare global {
  var prisma: PrismaClient | undefined;
}

export const prisma =
  global.prisma ||
  new PrismaClient({
    log: ["query"],
  });

if (process.env.NODE_ENV !== "production") global.prisma = prisma;

具体可以参考:

www.prisma.io/docs/guides…

集成 NextAuth

因为 setAmountEachTime 接口需要权限,所以我们需要添加认证功能。

在 Nodejs 中比较流行的认证框架是 nextauth,它可以很好的与 nextjs、prisma 进行集成。

它具有一套完整的数据模型,可以使用像 Google、GitHub 这种第三方账号进行登陆。

安装及配置

npm install next-auth @next-auth/prisma-adapter

创建 /pages/api/auth/[...nextauth].ts 文件,并写入以下代码:

import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import GitHubProvider from "next-auth/providers/github";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { prisma } from "../../../prisma/db";

export default NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    }),
    GitHubProvider({
      clientId: process.env.GITHUB_ID as string,
      clientSecret: process.env.GITHUB_SECRET as string,
    }),
  ],
});

nextauth 支持几十种第三方登录。这里我们选择使用 google 和 github 两种账号作为登录方式。

申请 ID 和 SECRET

我们需要申请 Google 和 GitHub 这两个平台的 ID 和 SECRET,申请地址如下:

具体申请流程比较简单,就不多做介绍了。

数据模型迁移

在 /prisma/scheme.prisma 文件中添加 nextauth 的数据模型:

model Account {
  id                       String  @id @default(cuid())
  userId                   String
  type                     String
  provider                 String
  providerAccountId        String
  refresh_token            String? @db.Text
  refresh_token_expires_in Int?
  access_token             String? @db.Text
  expires_at               Int?
  token_type               String?
  scope                    String?
  id_token                 String? @db.Text
  session_state            String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  accounts      Account[]
  sessions      Session[]
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

nextauth 会通过这四张表对我们的用户进行认证和授权。

首先生成客户端代码:

npx prisma generate

再进行数据迁移:

npx prisma migrate dev

如果一切正常,在 supabase dashboard 中就可以看到新创建的四张表。

接口实现

nextjs 框架支持编写 api,所以可以直接在项目中编写后端代码。

withdraw

先来实现 withdraw 功能。

创建 pages/api/faucet/withdraw.ts 文件,并写入以下代码:

import { ethers } from "ethers";
import { NextApiRequest, NextApiResponse } from "next";
import { abi } from "../../../abi/NoahToken.json";
import { prisma } from "../../../prisma/db";

type Response<T> = {
  code: number;
  message: string;
  data: T;
};

const errors: { [key in string]: { code: number; message: string } } = {
  invalidAddress: {
    code: 100100,
    message: "地址不合法",
  },
  insufficientBalance: {
    code: 100101,
    message: "当前地址不能领取",
  },
  serviceNotAvailable: {
    code: 200000,
    message: "服务暂不可用",
  },
  other: {
    code: 200001,
    message: "未知错误",
  },
  nonWithdrawable: {
    code: 200100,
    message: "水龙头余额不足",
  },
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Response<null>>
) {
  if (req.method !== "POST") {
    return res.status(405).end();
  }
  const { address } = req.body;

  // 检查地址是否合法
  const isAddress = ethers.utils.isAddress(address);
  if (!isAddress) {
    return res.status(400).json({
      code: errors.invalidAddress.code,
      message: errors.invalidAddress.message,
      data: null,
    });
  }

  // 获取水龙头配置
  const config = await prisma.faucetConfig.findFirst();
  if (!config) {
    // 如果没有水龙头配置,意味着不能领币
    return res.status(500).json({
      code: errors.serviceNotAvailable.code,
      message: errors.serviceNotAvailable.message,
      data: null,
    });
  }

  // 初始化合约
  const { contract, wallet } = await initWalletAndContract();

  // 检查水龙头余额
  const balance = await contract.balanceOf(wallet.address);
  const amountEachTime = ethers.BigNumber.from(config.amount);

  // 如果水龙头余额小于每次领取的数量,意味着不能领币
  if (balance.lte(amountEachTime)) {
    return res.status(400).json({
      code: errors.nonWithdrawable.code,
      message: errors.nonWithdrawable.message,
      data: null,
    });
  }

  // 查看领币记录
  const record = await prisma.faucetDrawRecord.findFirst({
    where: {
      address,
    },
  });

  // 如果没有领币记录或者领币记录超过一天,意味着可以领币
  if (
    !record ||
    new Date().getTime() - record.drawTime.getTime() > 24 * 60 * 60 * 1000
  ) {
    // 调用合约领币
    try {
      await contract.transfer(address, amountEachTime); // 如果没有领币记录,创建领币记录
      if (!record) {
        await prisma.faucetDrawRecord.create({
          data: {
            address,
            drawTime: new Date(),
          },
        });
      } else {
        // 如果有领币记录,更新领币时间
        await prisma.faucetDrawRecord.update({
          where: {
            id: record.id,
          },
          data: {
            drawTime: new Date(),
          },
        });
      }
      return res.status(200).end();
    } catch (e) {
      // 领币失败
      return res.status(500).json({
        code: errors.other.code,
        message: errors.other.message,
        data: null,
      });
    }
  } else {
    // 如果领币记录不超过一天,意味着不能领币
    return res.status(400).json({
      code: errors.insufficientBalance.code,
      message: errors.insufficientBalance.message,
      data: null,
    });
  }
}

async function initWalletAndContract() {
  const provider = new ethers.providers.JsonRpcProvider(
    process.env.JSON_RPC_URL as string,
    1337
  );
  const wallet = new ethers.Wallet(
    process.env.WALLET_PRIVATE_KEY as string
  ).connect(provider);
  const contract = new ethers.Contract(
    process.env.NEXT_PUBLIC_TOKEN_CONTRACT_ADDRESS as string,
    abi,
    wallet
  );
  return { wallet, contract };
}

config

现在我们来实现 config 功能。

在实现 config 之前,我们需要对 User 模型增加一个 role 字段来实现权限管理。

实现起来也比较简单,在 User 模型中增加 role 字段即可。

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  role          String?
  accounts      Account[]
  sessions      Session[]
}

然后运行 prisma 脚本来进行数据迁移与客户端代码生成。

再之后需要调整 pages/api/auth/[...nextauth].ts 文件。

修改 User 类型,并添加 callbacks 函数,以及导出 AuthOptions。

import NextAuth, { AuthOptions, DefaultSession } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import GitHubProvider from "next-auth/providers/github";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { prisma } from "../../../prisma/db";

declare module "next-auth" {
  interface Session {
    user: {
      role: string;
    } & DefaultSession["user"];
  }

  interface User {
    role: string;
  }
}

export const authOptions: AuthOptions = {
  adapter: PrismaAdapter(prisma),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    }),
    GitHubProvider({
      clientId: process.env.GITHUB_ID as string,
      clientSecret: process.env.GITHUB_SECRET as string,
    }),
  ],
  callbacks: {
    async session({ session, token, user }) {
      if (session.user && user) {
        session.user.role = user.role;
      }
      return session;
    },
  },
};

export default NextAuth(authOptions);

最后来创建 pages/api/faucet/config.ts 文件,并写入以下代码:

import { NextApiRequest, NextApiResponse } from "next";
import { FaucetConfig } from "@prisma/client";
import { prisma } from "../../../prisma/db";
import { unstable_getServerSession } from "next-auth";
import { authOptions } from "../auth/[...nextauth]";

type Response<T> = {
  code: number;
  message: string;
  data: T;
};

const errors: { [key in string]: { code: number; message: string } } = {
  invalidAmount: {
    code: 100100,
    message: "数量格式不正确",
  },
  serviceNotAvailable: {
    code: 200000,
    message: "服务暂不可用",
  },
  other: {
    code: 200001,
    message: "未知错误",
  },
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Response<any> | FaucetConfig>
) {
  if (req.method !== "POST" && req.method !== "GET") {
    return res.status(405).end();
  }

  if (req.method === "GET") {
    const config = await prisma.faucetConfig.findFirst();
    if (!config) {
      return res.status(500).json({
        code: errors.serviceNotAvailable.code,
        message: errors.serviceNotAvailable.message,
        data: null,
      });
    }
    return res.status(200).json(config);
  }

  if (req.method === "POST") {
    const session = await unstable_getServerSession(req, res, authOptions);
    if (!session) {
      return res.status(401).end();
    }
    const amount = req.body.amount;
    if (!amount) {
      return res.status(400).json({
        code: errors.other.code,
        message: errors.other.message,
        data: null,
      });
    }
    try {
      // 先查询是否有配置
      const findResult = await prisma.faucetConfig.findFirst();
      // 如果没有配置,就创建
      if (!findResult) {
        const result = await prisma.faucetConfig.create({
          data: {
            amount,
          },
        });
        return res.status(200).json(result);
      }
      // 如果有配置,就更新
      const result = await prisma.faucetConfig.update({
        where: {
          id: findResult.id,
        },
        data: {
          amount,
        },
      });
      return res.status(200).json(result);
    } catch (err) {
      return res.status(500).json({
        code: errors.other.code,
        message: errors.other.message,
        data: null,
      });
    }
  }
}

因为要使用 nodejs 与合约进行交互,所以这里要再配置三个环境变量。

  • JSON_RPC_URL:RPC 的 URL,可以是 infura 的 RPC 服务。
  • WALLET_PRIVATE_KEY:钱包私钥,用于转账。
  • NEXT_PUBLIC_TOKEN_CONTRACT_ADDRESS:Noah 币合约地址,之前已经配置过了。
JSON_RPC_URL=""
WALLET_PRIVATE_KEY=""
NEXT_PUBLIC_TOKEN_CONTRACT_ADDRESS=""

使用 nextjs 作为后端的一个好处是我们不需要再额外找一个库来与智能合约交互。ethersjs 这个库支持同构,所以也可以在 nodejs 中使用。

代码中有详尽的注释,逻辑上就不多讲了。

前端实现

首先要在 pages/_app.tsx 中添加 nextauth 的 provider,这样才可以使用 nextauth 的 hooks。

import { SessionProvider } from "next-auth/react"
export default function App({
  Component,
  pageProps: { session, ...pageProps },
}) {
  return (
    <SessionProvider session={session}>
      <Component {...pageProps} />
    </SessionProvider>
  )
}

然后创建 pages/faucet-with-backend.tsx 文件,写入以下代码:

import { useEffect, useState } from "react";
import {
  Alert,
  Button,
  Heading,
  Input,
  Spinner,
  useToast,
} from "@chakra-ui/react";
import axios, { AxiosError } from "axios";
import { signIn, signOut, useSession } from "next-auth/react";

export default function Faucet() {
  return (
    <div className="flex flex-col gap-8 p-8">
      <Withdraw />
      <Manager />
    </div>
  );
}

function Withdraw() {
  const toast = useToast();
  const [address, setAddress] = useState("");
  const [amountEachTime, setAmountEachTime] = useState(0);
  const [getConfigIsLoading, setGetConfigIsLoading] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    (async () => {
      setGetConfigIsLoading(true);
      try {
        const res = await axios({
          url: "/api/faucet/config",
          method: "GET",
        });
        setAmountEachTime(res.data.amount);
      } catch (err: any) {
        setIsError(true);
      } finally {
        setGetConfigIsLoading(false);
      }
    })();
  }, []);

  const withdraw = async () => {
    setIsLoading(true);
    try {
      await axios({
        url: "/api/faucet/withdraw",
        method: "POST",
        data: { address },
        headers: {
          "Content-Type": "application/json",
        },
      });
      toast({
        title: "领取成功",
        status: "success",
        duration: 3000,
      });
    } catch (err: unknown) {
      const title = (err as AxiosError<any>).response?.data.message;
      toast({
        title,
        status: "error",
        duration: 3000,
      });
    } finally {
      setIsLoading(false);
    }
  };

  if (getConfigIsLoading) {
    return <Spinner />;
  }

  return (
    <div className="flex flex-col gap-4">
      <Heading>领取代币</Heading>

      <Alert status="info">
        <ul>
          <li>每个地址每 24 小时最多只能领取 1 次</li>
          <li>每次可以领取 {amountEachTime} 个 Noah 币</li>
        </ul>
      </Alert>

      <Input
        placeholder="输入你的地址"
        value={address}
        onChange={(e) => setAddress(e.target.value)}
      ></Input>

      <Button disabled={isLoading} isLoading={isLoading} onClick={withdraw}>
        领取
      </Button>

      {isError && <Alert status="error">{`当前服务不可用`}</Alert>}
    </div>
  );
}

function Manager() {
  const { data: session } = useSession();
  const toast = useToast();
  const [amount, setAmount] = useState(0);

  useEffect(() => {
    (async () => {
      try {
        const res = await axios({
          url: "/api/faucet/config",
          method: "GET",
        });
        setAmount(res.data.amount);
      } catch (err: any) {
        if (session) {
          toast({
            title: "获取配置失败",
            status: "error",
            duration: 3000,
          });
        }
      }
    })();
  }, [session, toast]);

  const setAmountApi = async () => {
    try {
      await axios({
        url: "/api/faucet/config",
        method: "POST",
        data: { amount },
        headers: {
          "Content-Type": "application/json",
        },
      });

      toast({
        title: "设置成功",
        status: "success",
        duration: 3000,
      });
    } catch (err: unknown) {
      const title = (err as AxiosError<any>).response?.data.message;
      toast({
        title,
        status: "error",
        duration: 3000,
      });
    }
  };

  return (
    <div className="flex flex-col gap-4">
      <Heading>设置每次领取的代币数量</Heading>
      <Alert status="warning">该功能仅系统管理员可操作</Alert>

      {session ? (
        <div className="flex flex-col gap-2">
          <div className="flex items-center gap-4">
            <div>你好 {session.user?.name}</div>
            <Button
              onClick={() => {
                signOut();
              }}
              bgColor={"red.400"}
              size={"sm"}
            >
              退出登录
            </Button>
          </div>

          <div>
            {session.user.role === "ADMIN" ? (
              <div>
                <Input
                  type="number"
                  value={amount}
                  onChange={(e) => setAmount(Number(e.target.value))}
                  placeholder="输入每次领取的代币数量"
                ></Input>
                <Button onClick={setAmountApi}>设置</Button>
              </div>
            ) : (
              <Alert status="warning">你不是管理员,无权操作</Alert>
            )}
          </div>
        </div>
      ) : (
        <div>
          如果你是管理员,
          <Button
            variant="link"
            onClick={() => {
              signIn();
            }}
          >
            请登录。
          </Button>
        </div>
      )}
    </div>
  );
}

最终效果如下:

游客进入。

管理员进入。

线上地址:www.webnext.cloud/

Github 源码地址:github.com/luzhenqian/…

后续我会更新一些其他 Web3 案例,也会放在这个仓库中。欢迎 star。

我们是一群立志改变世界的人。而 Web3 是未来世界一大变数,我们想帮助更多人了解并加入 Web3,如果你对 Web3 感兴趣,可以添加我的微信:LZQ20130415,邀你入群,一起沉淀、一起成长、一起拥抱未来。