阅读视图

发现新文章,点击刷新页面。

React Native DApp 开发全栈实战·从 0 到 1 系列(兑换-合约部分)

前言

本文借助 Solidity 0.8、OpenZeppelin 与 Chainlink 喂价,构建一套 链上即时汇率结算、链下可信价格驱动 的微型兑换系统。本文将带你完成:

  1. 部署可铸造 ERC-20(BoykayuriToken,BTK)
  2. 部署 Chainlink 风格喂价合约(MockV3Aggregator),本地即可模拟 ETH/USD = 2000 的实时价格
  3. 部署 SwapToken —— 接收 ETH、按市价折算 USD、并立即向用户发放等值 BTK
  4. 使用 Hardhat 本地网络 + hardhat-deploy 插件一键启动,5 条指令完成编译、测试、部署全流程 无需前端,无需真实 LINK,即可体验 "价格输入 → 汇率计算 → 代币闪兑" 的完整闭环,为后续接入主网喂价、多币种池子、流动性挖矿奠定可复用的脚手架。

智能合约

代币合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SwapToken is Ownable {
    AggregatorV3Interface internal priceFeed;
    IERC20 public token;
    uint public constant TOKENS_PER_USD = 1000; // 1 USD = 1000 MTK

    constructor(address _priceFeed, address _token) Ownable(msg.sender) {
        priceFeed = AggregatorV3Interface(_priceFeed);
        token = IERC20(_token);
    }

    function swap() public payable {
        uint usd = getEthInUsd(msg.value);
        uint amount = usd * TOKENS_PER_USD;
        require(token.balanceOf(address(this)) >= amount, "Not enough liquidity");
        token.transfer(msg.sender, amount);
    }

    function getEthInUsd(uint ethAmount) public view returns (uint) {
        (, int price, , , ) = priceFeed.latestRoundData(); // price in 8 decimals
        uint ethUsd = (ethAmount * uint(price)) / 1e18; // ETH amount in USD (8 decimals)
        return ethUsd / 1e8; // return USD amount
    }

    receive() external payable {}
}
喂价合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";

contract MockV3Aggregator is AggregatorV3Interface {
    uint256 public constant versionvar = 4;

    uint8 public decimalsvar;
    int256 public latestAnswer;
    uint256 public latestTimestamp;
    uint256 public latestRound;
    mapping(uint256 => int256) public getAnswer;
    mapping(uint256 => uint256) public getTimestamp;
    mapping(uint256 => uint256) private getStartedAt;
    string private descriptionvar;

    constructor(
        uint8 _decimals,
        string memory _description,
        int256 _initialAnswer
    ) {
        decimalsvar = _decimals;
        descriptionvar = _description;
        updateAnswer(_initialAnswer);
    }

    function updateAnswer(int256 _answer) public {
        latestAnswer = _answer;
        latestTimestamp = block.timestamp;
        latestRound++;
        getAnswer[latestRound] = _answer;
        getTimestamp[latestRound] = block.timestamp;
        getStartedAt[latestRound] = block.timestamp;
    }

    function getRoundData(uint80 _roundId)
        external
        view
        override
        returns (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        )
    {
        return (
            _roundId,
            getAnswer[_roundId],
            getStartedAt[_roundId],
            getTimestamp[_roundId],
            _roundId
        );
    }

    function latestRoundData()
        external
        view
        override
        returns (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        )
    {
        return (
            uint80(latestRound),
            latestAnswer,
            getStartedAt[latestRound],
            latestTimestamp,
            uint80(latestRound)
        );
    }

    function decimals() external view override returns (uint8) {
        return decimalsvar;
    }

    function description() external view override returns (string memory) {
        return descriptionvar;
    }

    function version() external  pure override returns (uint256) {
        return versionvar;
    }
}
兑换合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SwapToken is Ownable {
    AggregatorV3Interface internal priceFeed;
    IERC20 public token;
    uint public constant TOKENS_PER_USD = 1000; // 1 USD = 1000 MTK

    constructor(address _priceFeed, address _token) Ownable(msg.sender) {
        priceFeed = AggregatorV3Interface(_priceFeed);
        token = IERC20(_token);
    }

    function swap() public payable {
        uint usd = getEthInUsd(msg.value);
        uint amount = usd * TOKENS_PER_USD;
        require(token.balanceOf(address(this)) >= amount, "Not enough liquidity");
        token.transfer(msg.sender, amount);
    }

    function getEthInUsd(uint ethAmount) public view returns (uint) {
        (, int price, , , ) = priceFeed.latestRoundData(); // price in 8 decimals
        uint ethUsd = (ethAmount * uint(price)) / 1e18; // ETH amount in USD (8 decimals)
        return ethUsd / 1e8; // return USD amount
    }

    receive() external payable {}
}
编译指令:npx hardhat compile

测试合约

const { ethers } = require("hardhat");
const { expect } = require("chai");

describe("SwapToken", function () {
  let SwapToken, MockToken, MockV3Aggregator;
  let owner, user;

  beforeEach(async () => {
    [owner, user] = await ethers.getSigners();
    await deployments.fixture(["MockV3Aggregator","token","SwapToken"]);
    const MockTokenAddress = await deployments.get("MyToken");          // 存入资产        // 奖励代币(USDC)
        const MockV3AggregatorAddress = await deployments.get("MockV3Aggregator");
        const SwapTokenAddress = await deployments.get("SwapToken");
        
        MockToken = await ethers.getContractAt("MyToken", MockTokenAddress.address);
        MockV3Aggregator = await ethers.getContractAt("MockV3Aggregator", MockV3AggregatorAddress.address);
        SwapToken = await ethers.getContractAt("SwapToken", SwapTokenAddress.address);
        // 给SwapToken合约铸造资产
        await MockToken.mint(await SwapToken.getAddress(), ethers.parseEther("1000000"));
        console.log('name',await MockToken.name())
        console.log("symbol",await MockToken.symbol())
        console.log(await MockV3Aggregator.latestAnswer())
   
  });

  it("Should swap ETH for MTK", async function () {
    const ethAmount = ethers.parseEther("1"); // 1 ETH = 2000 USD = 2,000,000 MTK
    await SwapToken.connect(user).swap({ value: ethAmount });

    const balance = await MockToken.balanceOf(user.address);
    console.log(balance)
    expect(balance).to.equal(2000 * 1000); // 2,000,000 MTK
  });
});
测试指令:npx hardhat test ./test/xxxx.js

部署合约

module.exports = async  ({getNamedAccounts,deployments})=>{
    const getNamedAccount = (await getNamedAccounts()).firstAccount;
    const secondAccount= (await getNamedAccounts()).secondAccount;
    console.log('secondAccount',secondAccount)
    const {deploy,log} = deployments;
    const MyAsset  = await deployments.get("MyToken");
    //执行MockV3Aggregator部署合约
  const MockV3Aggregator=await deploy("MockV3Aggregator",{
        from:getNamedAccount,
        args: [8,"ETH/USDC", 200000000000],//参数
        log: true,
    })
  console.log("MockV3Aggregator合约地址:", MockV3Aggregator.address);
    const SwapToken=await deploy("SwapToken",{
        from:getNamedAccount,
        args: [MockV3Aggregator.address,MyAsset.address],//参数 喂价,资产地址
        log: true,
    })
    // await hre.run("verify:verify", {
    //     address: TokenC.address,
    //     constructorArguments: [TokenName, TokenSymbol],
    //     });
    console.log('SwapToken 兑换合约地址',SwapToken.address)
}
module.exports.tags = ["all", "SwapToken"];
部署指令:npx hardhat deploy --tags token,MockV3Aggregator,SwapToken

总结

通过本文,我们完成了:

  • 价格层:MockV3Aggregator 遵循 Chainlink 接口,可无缝替换为主网喂价
  • 资产层:ERC-20 代币自带 mintOwnable,方便快速补充流动性
  • 兑换层:SwapToken 利用 latestRoundData() 实时计算 ETH→USD→BTK 数量,全程链上可查
  • 脚本层:Hardhat 脚本化部署 + 测试固件,保证"一键重置、秒级回滚",让迭代安全又高效

后续优化:

  • 把 Mock 喂价替换为 ETH/USD 主网聚合器
  • 引入 Uniswap V2 风格的流动性池,实现双向兑换

React Native DApp 开发全栈实战·从 0 到 1 系列(流动性挖矿-前端部分)

前言

继上一篇《React Native DApp 开发全栈实战·从 0 到 1 系列(流动性挖矿-合约部分)》,本文进入“前端交互”环节:把 Hardhat 测试脚本里那套「mint → approve → deposit → evm_increaseTime → harvest」的自动化流程,
原封不动地搬到浏览器/移动端,并通过 MetaMask 实现「真钱包、真签名、真 Gas」的交互体验。
同时,我们还会解决 Web3Provider 无法时间快进、授权额度不足、奖励误差等 3 个常见踩坑点,让你真正做到「开发时秒级验证,上线后零改动」。

前置准备

  • hardhat启动网络节点npx hardhat node
  • hardhat启动网络节点npx hardhat node
  • 合约编译npx hardhat compile 生成对应的xxx.json用获取abi等相关信息
  • 合约部署npx hardhat deploy --tags token,token1,LiquidityMiningVault 获取合约地址(奖励代币、质押代币和流动性挖矿的合约地址)
  • 节点的私钥导入钱包用来与合约交互时支付对应的gas费

核心代码

注意事项

  • 解决测试时模拟时间快进使用new ethers.providers.Web3Provider(window.ethereum)没有快进的属性
const url = 'http://localhost:8545';   // 以 Hardhat 启动日志为准
const raw = new ethers.providers.JsonRpcProvider(url);
const id = await raw.send('eth_chainId', []);
console.log('chainId', parseInt(id, 16));
await raw.send('evm_increaseTime', [10]);
await raw.send('evm_mine');
console.log('✅ 直连成功');
  • 获取不到预取结果区分质押代币和奖励代币合约的使用场景
  • 领取奖励后会出现预期误差主要是模拟时间快进原因

代码

说明把测试操作集中在一个函数中

  • 用 MetaMask 做签名器,实现了「预充 → mint → 授权 → 质押 → 调速度 → 时间快进 → 领取」
  • 详细步骤
      1. 双签名器:owner 管 mint / 设速,alice 管授权、质押、领取,符合真实权限模型。
      1. 授权必校验:approve 完立刻读 allowance,杜绝「额度不足」导致的 deposit 失败。
      1. 时间快进:必须另起 JsonRpcProvider 直连接口,调用 evm_increaseTime + evm_mineWeb3Provider 无此 API。
      1. 触发更新:链上时间只会在新块里生效,快进后必须再发一笔交易(这里用 deposit(0))让合约重新计算 earned
      1. 代币别混淆:StakeToken 用于质押,RewardToken 用于收益;地址一旦反了,就会出现「查不到余额」或「领不到钱」。
      1. 日志全打满:每一步都 console.log.wait() 回执,开发阶段一眼定位失败点;上线前把 try/catch 细化到业务层即可直接复用。
import { abi as LiquidityMiningVaultABI } from "@/abi/LiquidityMiningVault.json";
import { abi as StakeTokenABI } from "@/abi/MyToken.json";
import { abi as REWARDTokenABI } from "@/abi/MyToken1.json"; //代币
import * as ethers from 'ethers';
const withdrawToken = async () => {
  try {
    const provider = new ethers.providers.Web3Provider(window.ethereum);

    /* 0. 连接 MetaMask 并确保 Alice 在账户列表里 */
    await provider.send('eth_requestAccounts', []);
    const ALICE_ADDR = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';
    const VAULT_ADDR = '0x1613beB3B2C4f22Ee086B2b38C1476A3cE7f78E8';
    const STAKE_ADDR = '0x5FbDB2315678afecb367f032d93F642f64180aa3';
    const REWARD_ADDR = '0xa513E6E4b8f2a923D98304ec87F64353C4D5C853';//奖励币

    let accounts = await provider.listAccounts();
    if (!accounts.map(a => a.toLowerCase()).includes(ALICE_ADDR.toLowerCase())) {
      await window.ethereum.request({
        method: 'wallet_requestPermissions',
        params: [{ eth_accounts: {} }]
      });
      accounts = await provider.send('eth_requestAccounts', []);
    }

    const aliceIndex = accounts.findIndex(
      a => a.toLowerCase() === ALICE_ADDR.toLowerCase()
    );
    if (aliceIndex === -1) throw new Error('MetaMask 中未找到 Alice 地址');
    const aliceSigner = provider.getSigner(aliceIndex);
    const ownerSigner = provider.getSigner(0);

    const DEPOSIT = ethers.utils.parseEther('1000');
    const SPEED   = ethers.utils.parseEther('10');

    const stakeToken = new ethers.Contract(STAKE_ADDR, StakeTokenABI, ownerSigner);
    const rewardToken = new ethers.Contract(REWARD_ADDR, REWARDTokenABI, ownerSigner);
    const vault      = new ethers.Contract(VAULT_ADDR, LiquidityMiningVaultABI, ownerSigner);

    /* 1. vault 预充奖励 */
    console.log('1. vault 预充奖励');
    try{
    await (await rewardToken.mint(VAULT_ADDR, ethers.utils.parseEther('2000'))).wait();
    }catch(err){
        console.error('❌ 预充奖励失败', err);
    }

    /* 2. 给 alice 发币 */
    console.log('2. alice mint');
    await (await stakeToken.mint(ALICE_ADDR, DEPOSIT)).wait();

    /* 3. alice 授权 —— 用 alice 自己的签名器 */
    console.log('3. alice 授权');
    const stakeForAlice = stakeToken.connect(aliceSigner);
    const approveTx = await stakeForAlice.approve(VAULT_ADDR, DEPOSIT);
    const approveRcpt = await approveTx.wait();
    console.log('approve receipt status:', approveRcpt.status);

    /* 4. 再读一次授权,确认额度足够 */
    const allowance = await stakeToken.allowance(ALICE_ADDR, VAULT_ADDR);
    console.log('allowance (枚)', ethers.utils.formatEther(allowance));
    if (!allowance.gte(DEPOSIT)) throw new Error('授权额仍不足');

    /* 5. alice 质押 */
    console.log('4. alice deposit');
    try{
    await (await vault.connect(aliceSigner).deposit(DEPOSIT, ALICE_ADDR)).wait();
    }catch(err){
        console.error('❌ 质押失败', err);
    }
    /* 6. owner 设奖励速度 */
    console.log('5. 设奖励速度');
    await (await vault.connect(ownerSigner).setRewardPerSecond(SPEED)).wait();

    /* 7. 时间快进 */
    console.log('6. evm+100s');
    try{
        const url = 'http://localhost:8545';   // 以 Hardhat 启动日志为准
        const raw = new ethers.providers.JsonRpcProvider(url);
        const id = await raw.send('eth_chainId', []);
        console.log('chainId', parseInt(id, 16));

        await raw.send('evm_increaseTime', [10]);
        await raw.send('evm_mine');
        console.log('✅ 直连成功');
    }catch(err){
        console.error('❌ 时间快进失败', err);
    }
    /* 8. 触发更新 */
    console.log('7. 再存 0 触发更新');
    await (await vault.connect(aliceSigner).deposit(0, ALICE_ADDR)).wait();

    /* 9. 查询收益 */
    const earned = await vault.earned(ALICE_ADDR);
    console.log('earned(枚)', ethers.utils.formatEther(earned));
    // 10. 提取奖励
    console.log('8. 提取奖励');
    try{
    await (await vault.connect(aliceSigner).harvest()).wait();
    }catch(err){
        console.error('❌ 提取奖励失败', err);
    }
    console.log('9. 提取奖励后查询余额');
    const balance = await rewardToken.balanceOf(ALICE_ADDR);
    console.log('balance(枚)', ethers.utils.formatEther(balance));
  } catch (err) {
    console.error('❌ 流程中断', err.message ?? err);
  }
};

效果图

图1转存失败,建议直接上传图片文件图1转存失败,建议直接上传图片文件图1转存失败,建议直接上传图片文件图1转存失败,建议直接上传图片文件图1转存失败,建议直接上传图片文件

总结

  1. 环境打通:一条 npx hardhat node 本地链 + 合约地址 + 节点私钥导入钱包,即可完成「开发 ⇄ 钱包」的双向通信。
  2. 时间快进:浏览器端无法 time.increase(),但可以通过直连 JsonRpcProvider 调用 evm_increaseTimeevm_mine,在 UI 上实现「一键跳 100 秒」的测试快感。
  3. 权限拆分:mint/预充用 owner 签名器,approve/deposit/harvest 用 Alice 签名器,既符合真实业务,也避免「一个私钥走天下」的安全误区。
  4. 代币辨析:StakeToken 只负责「质押额度」,RewardToken 只负责「收益发放」,两者地址一旦混淆,就会出现「查询不到余额」或「领取失败」的假象。
  5. 误差可控:时间快进后立即调用 deposit(0) 触发 updateReward,能把区块时间误差压到 1 s 以内,肉眼可见「earned ≈ 1000 枚」。
  6. 无缝迁移:整套代码基于 ethers.js v5,与 React/React Native 100% 兼容;只要把 window.ethereum 换成 @walletconnect/web3walletWalletConnectModal,即可原地切换主网、测试网或移动端钱包,真正做到「开发即生产」。

至此,「合约开发 → 单元测试 → 前端交互」完整链路已跑通。

❌