先码住,我改一下钱包按钮,就发出来
1 个赞
首先找一个最近出来的 AI,比如 KIMI2,比如 Qwen3 Code,比如 Claude,比如 GPT 5。
然后就可以让它写代码了。我以借贷项目 PlatON Lending 为例,这个项目的逻辑很简单:
用户存入 aLAT,可以借出等价于存入 aLAT 价值的 66.7% 的 maoUSD-L(稳定币),然后可以归还。
OK,再分析一下页面的布局:
- 要有顶部栏,顶部栏包括项目名称和钱包连接按钮
- 数据看板
- DepositCard、BorrowCard 和 RepayCard
- 底部栏
然后将预期想要的功能详细的告诉 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">
© 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 />
</>
);
}
浅浅,还在呢?白月光都跌成了!还没放弃吗?