最近 AI 层出不穷,是开发者的红利期,我和大家分享一下如何在 PlatON 网络开发简单的借贷项目 MVP

先码住,我改一下钱包按钮,就发出来

1 个赞

首先找一个最近出来的 AI,比如 KIMI2,比如 Qwen3 Code,比如 Claude,比如 GPT 5。

然后就可以让它写代码了。我以借贷项目 PlatON Lending 为例,这个项目的逻辑很简单:

用户存入 aLAT,可以借出等价于存入 aLAT 价值的 66.7% 的 maoUSD-L(稳定币),然后可以归还。

OK,再分析一下页面的布局:

  1. 要有顶部栏,顶部栏包括项目名称和钱包连接按钮
  2. 数据看板
  3. DepositCard、BorrowCard 和 RepayCard
  4. 底部栏

然后将预期想要的功能详细的告诉 AI 让它阅读完之后,先向你提问,把思路对齐之后,就可以让它开干了。

在干活的过程中,会遇到一系列的问题和报错,没关系,统统交给 AI 进行善后和修改。

以下是我全程使用 AI,自己没有写过一行代码的源文件,分享给大家。

合约部分:

LendingCore.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../token/MaoUSDL.sol";
import "../oracle/PriceOracle.sol";
import "../lib/Errors.sol";

contract LendingCore is ReentrancyGuard, Pausable, AccessControl {
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");

    struct Position {
        uint256 collateralAmount;
        uint256 debtAmount;
    }

    mapping(address => mapping(address => Position)) public positions;

    mapping(address => bool)     public collateralAssets;
    mapping(address => uint256)  public collateralDecimals;

    uint256 public constant LIQUIDATION_THRESHOLD = 120e16; // 120%
    uint256 public constant LIQUIDATION_PENALTY    = 10e16;  // 10%
    uint256 public constant LTV                    = 666666666666666666; // 66.67%

    PriceOracle public immutable oracle;
    MaoUSDL     public immutable maoUSD;

    event Deposit(address indexed user, address indexed asset, uint256 amount);
    event Borrow(address indexed user, uint256 amount);
    event Repay(address indexed user, uint256 amount);
    event Liquidate(address indexed liquidator, address indexed user, address indexed asset, uint256 seized);

    constructor(address _oracle, address _maoUSD) {
        oracle  = PriceOracle(_oracle);
        maoUSD  = MaoUSDL(_maoUSD);
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(ADMIN_ROLE, msg.sender);
    }

    function addCollateralAsset(address asset, uint8 decimals) external onlyRole(ADMIN_ROLE) {
        collateralAssets[asset]   = true;
        collateralDecimals[asset] = 10 ** decimals;
    }

    function deposit(address asset, uint256 amount) external nonReentrant whenNotPaused {
        require(collateralAssets[asset], "Unsupported asset");
        IERC20(asset).transferFrom(msg.sender, address(this), amount);
        positions[msg.sender][asset].collateralAmount += amount;
        emit Deposit(msg.sender, asset, amount);
    }

    function borrow(address asset, uint256 maoAmount) external nonReentrant whenNotPaused {
        Position storage p = positions[msg.sender][asset];
        uint256 collateralValue = (p.collateralAmount * oracle.getPrice(asset)) / collateralDecimals[asset];
        uint256 maxDebt = (collateralValue * LTV) / 1e18;
        require(p.debtAmount + maoAmount <= maxDebt, "Insufficient collateral");
        p.debtAmount += maoAmount;
        maoUSD.mint(msg.sender, maoAmount);
        emit Borrow(msg.sender, maoAmount);
    }

    function repay(address asset, uint256 maoAmount) external nonReentrant {
        Position storage p = positions[msg.sender][asset];
        require(p.debtAmount >= maoAmount, "Over repay");
        maoUSD.burn(msg.sender, maoAmount);
        p.debtAmount -= maoAmount;
        emit Repay(msg.sender, maoAmount);
    }

    function liquidate(address user, address asset) external nonReentrant {
        Position storage p = positions[user][asset];
        require(p.debtAmount > 0, Errors.HEALTHY);
        uint256 collateralValue = (p.collateralAmount * oracle.getPrice(asset)) / collateralDecimals[asset];
        uint256 health = (collateralValue * 1e18) / p.debtAmount;
        require(health < LIQUIDATION_THRESHOLD, Errors.HEALTHY);

        uint256 seized = (p.debtAmount * (1e18 + LIQUIDATION_PENALTY)) * collateralDecimals[asset] / oracle.getPrice(asset) / 1e18;
        if (seized > p.collateralAmount) seized = p.collateralAmount;

        p.collateralAmount -= seized;
        p.debtAmount = 0;

        IERC20(asset).transfer(msg.sender, seized);
        emit Liquidate(msg.sender, user, asset, seized);
    }

    function healthFactor(address user, address asset) external view returns (uint256) {
        Position memory p = positions[user][asset];
        if (p.debtAmount == 0) return type(uint256).max;
        uint256 collateralValue = (p.collateralAmount * oracle.getPrice(asset)) / collateralDecimals[asset];
        return (collateralValue * 1e18) / p.debtAmount;
    }
}

Errors.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

library Errors {
    string internal constant PAUSED      = "1";
    string internal constant UNAUTHORIZED = "2";
    string internal constant HEALTHY      = "3";
    string internal constant LOW_HEALTH   = "4";
}

PriceOracle.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "@openzeppelin/contracts/access/AccessControl.sol";

contract PriceOracle is AccessControl {
    bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE");

    mapping(address => uint256) private _prices; // USD, 8 decimals

    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
    }

    function setPrice(address asset, uint256 price) external onlyRole(ORACLE_ROLE) {
        _prices[asset] = price;
    }

    function getPrice(address asset) external view returns (uint256) {
        return _prices[asset];
    }
}

LiquidatorBot.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "../core/LendingCore.sol";

contract LiquidatorBot {
    LendingCore public immutable lendingCore;

    constructor(address _lendingCore) {
        lendingCore = LendingCore(_lendingCore);
    }

    function executeLiquidation(address user, address asset) external {
        lendingCore.liquidate(user, asset);
    }
}

aLAT.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract aLAT is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

    constructor() ERC20("aLAT", "aLAT") {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) external onlyRole(BURNER_ROLE) {
        _burn(from, amount);
    }
}

MaoUSDL

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract MaoUSDL is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

    constructor() ERC20("Mao USD-L", "maoUSD-L") {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) external onlyRole(BURNER_ROLE) {
        _burn(from, amount);
    }
}

然后通过 hardhat 部署即可,要是觉得麻烦,也可以用 remix 进行部署

然后前端部署用 React + Typescript + Wagmi

Nav.tsx

import { useAccount, useConnect, useDisconnect } from "wagmi";
import { useState } from "react";
import { PowerIcon } from "@heroicons/react/24/outline";

export default function Nav() {
  const { isConnected, address } = useAccount();
  const { connect, connectors } = useConnect();
  const { disconnect } = useDisconnect();
  const [isConnecting, setIsConnecting] = useState(false);

  const handleConnect = async () => {
    setIsConnecting(true);
    try {
      await connect({ connector: connectors[0] });
    } finally {
      setIsConnecting(false);
    }
  };

  return (
    <nav className="flex justify-between items-center px-6 py-4 bg-white shadow-sm">
      <h1 className="text-2xl font-bold text-indigo-600">PlatON Lending</h1>

      {isConnected ? (
        <button
          onClick={() => disconnect()}
          className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-md hover:bg-gray-200 transition"
        >
          <span className="text-sm font-mono">
            {address?.slice(0, 6)}…{address?.slice(-4)}
          </span>
          <PowerIcon className="h-4 w-4 text-gray-600" />
        </button>
      ) : (
        <button
          onClick={handleConnect}
          disabled={isConnecting}
          className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 transition disabled:opacity-50"
        >
          {isConnecting ? "Waiting for MetaMask…" : "Connect MetaMask"}
        </button>
      )}
    </nav>
  );
}

Dashboard.tsx

import { useAccount, useContractRead } from "wagmi";
import { useState, useEffect } from "react";

const POSITION_ABI = [
  {
    inputs: [
      { internalType: "address", name: "", type: "address" },
      { internalType: "address", name: "", type: "address" },
    ],
    name: "positions",
    outputs: [
      { internalType: "uint256", name: "collateralAmount", type: "uint256" },
      { internalType: "uint256", name: "debtAmount", type: "uint256" },
    ],
    stateMutability: "view",
    type: "function",
  },
] as const;

const LENDING_CORE = "<部署后的合约地址>";
const ASSET_ALAT = "<部署后的合约地址>";

export default function Dashboard() {
  const { isConnected, address } = useAccount();

  // 避免 hydration 不一致
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);

  const { data } = useContractRead({
    address: LENDING_CORE,
    abi: POSITION_ABI,
    functionName: "positions",
    args: [address ?? "0x0", ASSET_ALAT],
    enabled: isConnected && Boolean(address),
    watch: true, // 监听区块变化
  });

  // 服务端渲染时先给 0n,客户端挂载后再用真实数据
  const [collateral, debt] = mounted && data ? data : [0n, 0n];

  return (
    <div className="bg-white rounded-2xl shadow-lg p-6 space-y-4">
      <h2 className="text-2xl font-bold text-gray-800">My Position</h2>
      <div className="flex justify-between items-center">
        <span className="text-gray-600">Collateral (raw)</span>
        <span className="text-indigo-600 font-bold">{collateral.toString()}</span>
      </div>
      <div className="flex justify-between items-center">
        <span className="text-gray-600">Debt (maoUSD-L)</span>
        <span className="text-green-600 font-bold">{debt.toString()}</span>
      </div>
    </div>
  );
}

DepositCard.tsx

import { useAccount, useContractWrite, usePrepareContractWrite, useWaitForTransaction } from "wagmi";
import { parseUnits } from "viem";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { CheckCircleIcon, ClipboardDocumentCheckIcon } from "@heroicons/react/24/outline";

const LENDING_CORE = process.env.NEXT_PUBLIC_LENDING_CORE as `0x${string}`;
const LENDING_ABI = [
  {
    inputs: [
      { internalType: "address", name: "asset", type: "address" },
      { internalType: "uint256", name: "amount", type: "uint256" },
    ],
    name: "deposit",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
] as const;

export default function DepositCard() {
  const { isConnected } = useAccount();
  const [asset, setAsset] = useState("");
  const [amount, setAmount] = useState("");

  // 全局遮罩状态
  const [status, setStatus] = useState<"idle" | "waiting" | "confirming" | "success">("idle");
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>();
  const [copied, setCopied] = useState(false);

  const queryClient = useQueryClient();
  const amountBI = parseUnits(amount || "0", 18);

  /* 1. 准备交易 */
  const { config } = usePrepareContractWrite({
    address: LENDING_CORE,
    abi: LENDING_ABI,
    functionName: "deposit",
    args: [asset as `0x${string}`, amountBI],
    enabled: Boolean(asset && amountBI > 0n && isConnected),
  });

  /* 2. 发起交易 */
  const { write, data: txData } = useContractWrite({
    ...config,
    onMutate() {
      setStatus("waiting");
    },
    onSuccess(data) {
      setTxHash(data.hash);
      setStatus("confirming");
    },
    onError() {
      setStatus("idle");
    },
  });

  /* 3. 监听链上确认 */
  useWaitForTransaction({
    hash: txData?.hash,
    onSuccess() {
      setStatus("success");
      queryClient.invalidateQueries({
        queryKey: ["contractRead", { address: LENDING_CORE, functionName: "positions" }],
      });
    },
    onError() {
      setStatus("idle");
    },
  });

  /* 4. 复制 txHash */
  const copyToClipboard = () => {
    if (!txHash) return;
    const ok = navigator.clipboard?.writeText?.(txHash);
    if (!ok) {
      // 兜底
      const ta = document.createElement("textarea");
      ta.value = txHash;
      document.body.appendChild(ta);
      ta.select();
      document.execCommand("copy");
      document.body.removeChild(ta);
    }
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };


  /* 5. 格式化哈希 */
  const shortHash = (hash?: string) => (hash ? `${hash.slice(0, 6)}…${hash.slice(-4)}` : "");

  const handleDeposit = () => {
    if (!write) return;
    setStatus("waiting");
    write();
  };

  /* 6. 渲染遮罩 */
  const renderOverlay = () => {
    if (status === "waiting") {
      return (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
          <div className="bg-white rounded-2xl shadow-2xl px-8 py-6 text-center">
            <svg
              className="animate-spin h-8 w-8 text-indigo-600 mx-auto mb-3"
              fill="none"
              viewBox="0 0 24 24"
            >
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
            </svg>
            <p className="text-gray-700 font-semibold">Waiting for MetaMask…</p>
          </div>
        </div>
      );
    }

    if (status === "confirming") {
      return (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
          <div className="bg-white rounded-2xl shadow-2xl px-8 py-6 text-center">
            <svg
              className="animate-spin h-8 w-8 text-indigo-600 mx-auto mb-3"
              fill="none"
              viewBox="0 0 24 24"
            >
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
            </svg>
            <p className="text-gray-700 font-semibold">Confirming on-chain…</p>
          </div>
        </div>
      );
    }

    if (status === "success") {
      return (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
          <div className="bg-white rounded-2xl shadow-2xl px-8 py-6 text-center max-w-sm">
            <CheckCircleIcon className="h-12 w-12 text-green-500 mx-auto mb-3" />
            <h3 className="text-xl font-bold text-gray-800 mb-2">Deposit Successful!</h3>
            <p className="text-sm text-gray-600 mb-4">
              Tx:{" "}
              <a
                href={`https://scan.platon.network/trade-detail?txHash=${txHash}`}
                target="_blank"
                rel="noopener noreferrer"
                className="text-indigo-600 underline"
              >
                {shortHash(txHash)}
              </a>
            </p>
            <button
              onClick={copyToClipboard}
              className="flex items-center justify-center w-full gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition"
            >
              <ClipboardDocumentCheckIcon className="h-5 w-5" />
              {copied ? "Copied!" : "Copy Tx Hash"}
            </button>
            <button
              onClick={() => setStatus("idle")}
              className="mt-2 w-full text-sm text-gray-500 hover:text-gray-700"
            >
              Close
            </button>
          </div>
        </div>
      );
    }

    return null;
  };

  return (
    <>
      {renderOverlay()}
      <div className="bg-white rounded-2xl shadow-lg p-6 space-y-4">
        <h2 className="text-2xl font-bold text-gray-800">Deposit Collateral</h2>
        <input
          className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
          placeholder="Token Address (0x...)"
          value={asset}
          onChange={(e) => setAsset(e.target.value)}
        />
        <input
          className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
          placeholder="Amount"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
        <button
          onClick={handleDeposit}
          disabled={!write || status !== "idle"}
          className="w-full py-2.5 bg-indigo-600 text-white font-semibold rounded-lg shadow-md hover:bg-indigo-700 disabled:bg-gray-300 transition"
        >
          Deposit
        </button>
      </div>
    </>
  );
}

BorrowCard.tsx

import { useAccount, useContractWrite, usePrepareContractWrite, useWaitForTransaction } from "wagmi";
import { parseUnits } from "viem";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { CheckCircleIcon, ClipboardDocumentCheckIcon } from "@heroicons/react/24/outline";

const LENDING_CORE = process.env.NEXT_PUBLIC_LENDING_CORE as `0x${string}`;
const LENDING_ABI = [
  {
    inputs: [
      { internalType: "address", name: "asset", type: "address" },
      { internalType: "uint256", name: "amount", type: "uint256" },
    ],
    name: "borrow",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
] as const;

// 固定抵押品和出借资产
const COLLATERAL_ASSET = "<部署后的合约地址>"; // aLAT
const MAO_USDL = "<部署后的合约地址>";   // maoUSDL

export default function BorrowCard() {
  const { isConnected } = useAccount();
  const [amount, setAmount] = useState("");

  // 全局遮罩状态
  const [status, setStatus] = useState<"idle" | "waiting" | "confirming" | "success">("idle");
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>();
  const [copied, setCopied] = useState(false);

  const queryClient = useQueryClient();
  const amountBI = amount ? parseUnits(amount, 18) : 0n;

  /* 1. 准备交易 */
  const { config } = usePrepareContractWrite({
    address: LENDING_CORE,
    abi: LENDING_ABI,
    functionName: "borrow",
    args: [COLLATERAL_ASSET, amountBI], // 始终用 aLAT 作抵押品
    enabled: isConnected && amountBI > 0n,
  });

  /* 2. 发起交易 */
  const { write, data: txData } = useContractWrite({
    ...config,
    onMutate() {
      setStatus("waiting");
    },
    onSuccess(data) {
      setTxHash(data.hash);
      setStatus("confirming");
    },
    onError() {
      setStatus("idle");
    },
  });

  /* 3. 监听链上确认 */
  useWaitForTransaction({
    hash: txData?.hash,
    onSuccess() {
      setStatus("success");
      queryClient.invalidateQueries({
        queryKey: ["contractRead", { address: LENDING_CORE, functionName: "positions" }],
      });
    },
    onError() {
      setStatus("idle");
    },
  });

  /* 4. 复制 txHash */
  const copyToClipboard = () => {
    if (!txHash) return;
    const ok = navigator.clipboard?.writeText?.(txHash);
    if (!ok) {
      // 兜底
      const ta = document.createElement("textarea");
      ta.value = txHash;
      document.body.appendChild(ta);
      ta.select();
      document.execCommand("copy");
      document.body.removeChild(ta);
    }
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  /* 5. 格式化哈希 */
  const shortHash = (hash?: string) => (hash ? `${hash.slice(0, 6)}…${hash.slice(-4)}` : "");

  /* 6. 渲染遮罩 */
  const renderOverlay = () => {
    if (status === "waiting") {
      return (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
          <div className="bg-white rounded-2xl shadow-2xl px-8 py-6 text-center">
            <svg className="animate-spin h-8 w-8 text-green-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24">
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
            </svg>
            <p className="text-gray-700 font-semibold">Waiting for MetaMask…</p>
          </div>
        </div>
      );
    }

    if (status === "confirming") {
      return (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
          <div className="bg-white rounded-2xl shadow-2xl px-8 py-6 text-center">
            <svg className="animate-spin h-8 w-8 text-green-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24">
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
            </svg>
            <p className="text-gray-700 font-semibold">Confirming on-chain…</p>
          </div>
        </div>
      );
    }

    if (status === "success") {
      return (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
          <div className="bg-white rounded-2xl shadow-2xl px-8 py-6 text-center max-w-sm">
            <CheckCircleIcon className="h-12 w-12 text-green-500 mx-auto mb-3" />
            <h3 className="text-xl font-bold text-gray-800 mb-2">Borrow Successful!</h3>
            <p className="text-sm text-gray-600 mb-4">
              Tx:{" "}
              <a
                href={`https://scan.platon.network/trade-detail?txHash=${txHash}`}
                target="_blank"
                rel="noopener noreferrer"
                className="text-indigo-600 underline"
              >
                {shortHash(txHash)}
              </a>
            </p>
            <button
              onClick={copyToClipboard}
              className="flex items-center justify-center w-full gap-2 bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition"
            >
              <ClipboardDocumentCheckIcon className="h-5 w-5" />
              {copied ? "Copied!" : "Copy Tx Hash"}
            </button>
            <button
              onClick={() => setStatus("idle")}
              className="mt-2 w-full text-sm text-gray-500 hover:text-gray-700"
            >
              Close
            </button>
          </div>
        </div>
      );
    }
    return null;
  };

  return (
    <>
      {renderOverlay()}
      <div className="bg-white rounded-2xl shadow-lg p-6 space-y-4">
        <h2 className="text-2xl font-bold text-gray-800">Borrow maoUSD-L</h2>
        {/* 不再让用户输入抵押品地址,只输入借多少 maoUSDL */}
        <input
          className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
          placeholder="Amount of maoUSD-L"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
        <button
          onClick={() => write?.()}
          disabled={!write || status !== "idle"}
          className="w-full py-2.5 bg-green-600 text-white font-semibold rounded-lg shadow-md hover:bg-green-700 disabled:bg-gray-300 transition"
        >
          {status === "idle" ? "Borrow" : status}
        </button>
      </div>
    </>
  );
}

RepayCard.tsx

import { useAccount, useContractWrite, usePrepareContractWrite, useWaitForTransaction, useContractRead } from "wagmi";
import { parseUnits, formatUnits } from "viem";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { CheckCircleIcon, ClipboardDocumentCheckIcon } from "@heroicons/react/24/outline";

const LENDING_CORE = process.env.NEXT_PUBLIC_LENDING_CORE as `0x${string}`;
const COLLATERAL = "<部署后的合约地址>";
const MAO_USDL = "<部署后的合约地址>";

const LENDING_ABI = [
  { inputs: [{ internalType: "address", name: "asset", type: "address" }, { internalType: "uint256", name: "amount", type: "uint256" }], name: "repay", outputs: [], stateMutability: "nonpayable", type: "function" },
] as const;

const ERC20_ABI = [
  { name: "approve", type: "function", stateMutability: "nonpayable", inputs: [{ name: "spender", type: "address" }, { name: "amount", type: "uint256" }], outputs: [] },
] as const;

export default function RepayCard() {
  const { isConnected, address } = useAccount();
  const [amount, setAmount] = useState("");

  /* 三态:idle | waiting | confirming | success */
  const [status, setStatus] = useState<"idle" | "waiting" | "confirming" | "success">("idle");
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>();
  const [copied, setCopied] = useState(false);

  const queryClient = useQueryClient();
  const amountBI = amount ? parseUnits(amount, 18) : 0n;

  /* 当前债务 */
  const { data: debt } = useContractRead({
    address: LENDING_CORE,
    abi: [
      {
        inputs: [{ internalType: "address", name: "", type: "address" }, { internalType: "address", name: "", type: "address" }],
        name: "positions",
        outputs: [
          { internalType: "uint256", name: "collateralAmount", type: "uint256" },
          { internalType: "uint256", name: "debtAmount", type: "uint256" },
        ],
        stateMutability: "view",
        type: "function",
      },
    ] as const,
    functionName: "positions",
    args: [address ?? "0x0", COLLATERAL],
    enabled: isConnected && Boolean(address),
    watch: true,
  });
  const debtAmount = debt?.[1] ?? 0n;
  const maxRepay = debtAmount;
  const isOver = amountBI > maxRepay;

  /* 无限授权 + 一键还款 */
  const { write, data: txData } = useContractWrite({
    ...usePrepareContractWrite({
      address: LENDING_CORE,
      abi: LENDING_ABI,
      functionName: "repay",
      args: [COLLATERAL, amountBI],
      enabled: isConnected && amountBI > 0n && !isOver,
    }).config,
    onMutate() {
      setStatus("waiting");
    },
    onSuccess(data) {
      setTxHash(data.hash);
      setStatus("confirming");
    },
    onError() {
      setStatus("idle");
    },
  });

  /* 监听授权或还款回执 */
  useWaitForTransaction({
    hash: txData?.hash,
    onSuccess() {
      setStatus("success");
      queryClient.invalidateQueries({ queryKey: ["contractRead", { address: LENDING_CORE, functionName: "positions" }] });
    },
    onError() {
      setStatus("idle");
    },
  });

  /* 复制 */
  const copyToClipboard = () => {
    if (!txHash) return;
    const ok = navigator.clipboard?.writeText?.(txHash);
    if (!ok) {
      // 兜底
      const ta = document.createElement("textarea");
      ta.value = txHash;
      document.body.appendChild(ta);
      ta.select();
      document.execCommand("copy");
      document.body.removeChild(ta);
    }
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  /* 5. 格式化哈希 */
  const shortHash = (hash?: string) => (hash ? `${hash.slice(0, 6)}…${hash.slice(-4)}` : "");

  /* 统一弹窗 */
  const renderOverlay = () => {
    if (status === "waiting") {
      return (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
          <div className="bg-white rounded-2xl shadow-2xl px-8 py-6 text-center">
            <svg className="animate-spin h-8 w-8 text-blue-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24">
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
            </svg>
            <p className="text-gray-700 font-semibold">Waiting for MetaMask…</p>
          </div>
        </div>
      );
    }
    if (status === "confirming") {
      return (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
          <div className="bg-white rounded-2xl shadow-2xl px-8 py-6 text-center">
            <svg className="animate-spin h-8 w-8 text-blue-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24">
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
            </svg>
            <p className="text-gray-700 font-semibold">Confirming on-chain…</p>
          </div>
        </div>
      );
    }
    if (status === "success") {
      return (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
          <div className="bg-white rounded-2xl shadow-2xl px-8 py-6 text-center max-w-sm">
            <CheckCircleIcon className="h-12 w-12 text-green-500 mx-auto mb-3" />
            <h3 className="text-xl font-bold text-gray-800 mb-2">Repay Successful!</h3>
            <p className="text-sm text-gray-600 mb-4">
              Tx:{" "}
              <a
                href={`https://scan.platon.network/trade-detail?txHash=${txHash}`}
                target="_blank"
                rel="noopener noreferrer"
                className="text-indigo-600 underline"
              >
                {shortHash(txHash)}
              </a>
            </p>
            <button
              onClick={copyToClipboard}
              className="flex items-center justify-center w-full gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
            >
              <ClipboardDocumentCheckIcon className="h-5 w-5" />
              {copied ? "Copied!" : "Copy Tx Hash"}
            </button>
            <button
              onClick={() => setStatus("idle")}
              className="mt-2 w-full text-sm text-gray-500 hover:text-gray-700"
            >
              Close
            </button>
          </div>
        </div>
      );
    }
    return null;
  };

  return (
    <>
      {renderOverlay()}
      <div className="bg-white rounded-2xl shadow-lg p-6 space-y-4">
        <h2 className="text-2xl font-bold text-gray-800">Repay maoUSD-L</h2>
        <div className="text-sm text-gray-600">
          Current Debt:{" "}
          <span className="font-bold text-gray-800" suppressHydrationWarning>
            {formatUnits(debtAmount, 18)} maoUSD-L
          </span>
        </div>
        <input
          className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
          placeholder="Amount to repay"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
        {isOver && <p className="text-xs text-red-600">Cannot exceed current debt</p>}
        <button
          onClick={() => write?.()}
          disabled={!write || isOver || status !== "idle"}
          className="w-full py-2.5 bg-blue-600 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 disabled:bg-gray-300 transition"
        >
          {status === "idle" ? "Repay" : status}
        </button>
      </div>
    </>
  );
}

Footer.tsx

import Link from "next/link";

export default function Footer() {
  return (
    <footer className="bg-white p-6 text-center">
      <p className="text-sm text-gray-600">
        &copy; 2025 PlatON Lending. All rights reserved.
      </p>
    </footer>
  );
}

index.tsx

// pages/index.tsx
import Head from "next/head";

import Nav from "@/components/Nav";
import Dashboard from "@/components/Dashboard";
import DepositCard from "../components/DepositCard";
import BorrowCard from "../components/BorrowCard";
import RepayCard from "../components/RepayCard";
import Footer from "@/components/Footer";

export default function Home() {
  return (
    <>
      <Head>
        <title>PlatON Lending</title>
      </Head>
      <Nav />
      <main className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-8 p-8">
        <Dashboard />
        <div className="space-y-8">
          <DepositCard />
          <BorrowCard />
          <RepayCard />
        </div>
      </main>
      <Footer />
    </>
  );
}

效果:

连接钱包前

连接钱包后

Deposit 完成

Borrow 完成

Repay 完成

1 个赞

浅浅,还在呢?白月光都跌成:poop:了!还没放弃吗?