下面是一份可用于生产环境的方案与实现(含完整的合约、后端签名服务和前端调用示例),说明各模块职责、数据流以及安全点与部署/测试要点。实现思路基于:
-
代币(ERC-20)预先存入财库(Treasury)合约,作为提币(本金 + 奖励)的资金池(reward 需由项目方提前注资)。
-
用户在 DApp 发起质押(stake),质押记录存储在 Staking 合约,质押时用户将代币转入 Treasury(而不是 staking 合约)(满足你“代币提前存储到财库合约中”的要求)。
-
当质押到期且未提取时,用户在 DApp 发起提现请求 -> 后端校验链上质押状态 并 用后端保管的签名私钥(安全存储)对一次性提现请求签名(EIP-712) -> 返回签名给前端 -> 前端把签名带到 Treasury.withdraw(...),合约验证签名并把资金打给用户,同时调用 Staking.finalizeWithdrawal() 将质押标记为已提取(由 Treasury 调用,保证原子性)。
关键安全点:
-
使用 EIP-712(Typed Data)签名 避免 personal_sign 的歧义与更强的反重放保障(domain 包含 chainId 与 verifyingContract)。
-
在合约端使用 nonce + deadline 与 nonces[user],防止重放并限制签名有效期。
-
后端私钥必须放在安全存储(KMS、Vault、HSM),并且后端应验证链上 Staking 状态再签名(不能直接签任意请求)。
-
Treasury.withdraw 做 非重入保护(ReentrancyGuard),并在成功转账后立即调用 Staking.finalizeWithdrawal 将状态写链上(同一 tx 内完成,避免 race)。
测试demo程序(包含前后端和链端)
-
目前支持主流的EVM链上的不同切换。通过测试前端demo程序切换不同的EVM链
2. 用户通过后端校验具备提币资格后,发起提币请求
3. 使用验签要求的私钥签名提币消息
4. 反解析验证签名的提币消息使用的公钥地址
5. 执行提币请求
提币结果回填dapp程序
一、合约实现(Solidity)
下面给两个合约:Staking.sol(记录质押并计算可提现数额),Treasury.sol(持有代币并执行带签名的提现)。合约使用 OpenZeppelin 工具库(EIP712、ECDSA、Ownable、ReentrancyGuard)。Solidity ^0.8.x。
注意:下列代码引用了 OpenZeppelin 合约(生产用时通过 npm 安装 @openzeppelin/contracts)。
1) Staking.sol
功能:用户质押(质押时把代币转到 Treasury),保存质押信息,计算奖励规则(示例用 APR basis points),提供 getWithdrawInfo() 给后端校验,finalizeWithdrawal() 只能由 Treasury 调用以标记已提取。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract Staking is Ownable {
IERC20 public immutable token;
address public treasury; // treasury address that holds tokens and will call finalizeWithdrawal
uint256 public aprBasisPoints; // e.g. 500 = 5% APR
struct Stake {
uint256 amount;
uint256 start;
uint256 duration; // seconds
bool claimed;
}
mapping(address => Stake) public stakes;
event Staked(address indexed user, uint256 amount, uint256 duration, uint256 start);
event WithdrawalFinalized(address indexed user, uint256 principal, uint256 reward);
constructor(address token_, uint256 aprBP_) {
require(token_ != address(0), "zero token");
token = IERC20(token_);
aprBasisPoints = aprBP_;
}
function setTreasury(address t) external onlyOwner {
treasury = t;
}
function setApr(uint256 aprBP) external onlyOwner {
aprBasisPoints = aprBP;
}
/// @notice Stake tokens. User must approve this contract to spend `amount` beforehand.
/// @dev tokens are transferred from user to treasury (pre-funded treasury pattern).
function stake(uint256 amount, uint256 duration) external {
require(amount > 0, "zero amount");
require(duration > 0, "zero duration");
require(treasury != address(0), "treasury not set");
// require no active stake (this is a choice — you can allow multiple stakes by an array)
Stake storage s = stakes[msg.sender];
require(s.amount == 0 || s.claimed || block.timestamp >= s.start + s.duration, "active stake exists");
// transfer tokens from user to treasury (user must approve this staking contract)
bool ok = IERC20(token).transferFrom(msg.sender, treasury, amount);
require(ok, "transferFrom failed");
stakes[msg.sender] = Stake({
amount: amount,
start: block.timestamp,
duration: duration,
claimed: false
});
emit Staked(msg.sender, amount, duration, block.timestamp);
}
/// @notice View functionfor backend: returns principal, reward (if matured), matured flag and claimed flag.
function getWithdrawInfo(address user) external view returns (uint256 principal, uint256 reward, bool matured, bool claimed) {
Stake memory s = stakes[user];
principal = s.amount;
claimed = s.claimed;
matured = (s.start > 0 && block.timestamp >= s.start + s.duration);
if (matured && !claimed) {
reward = computeReward(s.amount, s.duration);
} else {
reward = 0;
}
}
function computeReward(uint256 amount, uint256 duration) public view returns (uint256) {
// reward = amount * aprBP / 10000 * (duration / 365 days)
// compute: amount * aprBP * duration / 10000 / 365 days
return (amount * aprBasisPoints * duration) / (10000 * 365 days);
}
/// @notice Called by Treasury contract after treasury sends funds to user. Marks stake as claimed.
function finalizeWithdrawal(address user) external {
require(msg.sender == treasury, "only treasury");
Stake storage s = stakes[user];
require(s.start > 0, "no stake");
require(!s.claimed, "already claimed");
require(block.timestamp >= s.start + s.duration, "not matured");
s.claimed = true;
uint256 reward = computeReward(s.amount, s.duration);
emit WithdrawalFinalized(user, s.amount, reward);
}
}
2) Treasury.sol
-
功能:持有 ERC-20 代币,验证 EIP-712 签名并执行提现(transfer to user),并在同一 tx 内调用 Staking.finalizeWithdrawal(user) 将质押设置为已提取,防止重复提现。
-
使用 OpenZeppelin 的 EIP712, ECDSA, Ownable, ReentrancyGuard。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
interface IStaking {
function finalizeWithdrawal(address user) external;
function getWithdrawInfo(address user) external view returns (uint256,uint256,bool,bool);
}
contract Treasury is EIP712, Ownable, ReentrancyGuard {
IERC20 public immutable token;
address public authorizedSigner; // backend signer address (the one whose key is used to sign withdraw messages)
IStaking public staking;
mapping(address => uint256) public nonces; // per-user nonce
bytes32 public constant WITHDRAW_TYPEHASH = keccak256("Withdraw(address to,uint256 amount,uint256 nonce,uint256 deadline)");
event WithdrawProcessed(address indexed to, uint256 amount, uint256 nonce, uint256 timestamp);
event SignerUpdated(address indexed newSigner);
event StakingSet(address indexed stakingAddress);
constructor(address token_, address signer_) EIP712("Treasury", "1") {
require(token_ != address(0), "zero token");
token = IERC20(token_);
authorizedSigner = signer_;
}
function setSigner(address signer_) external onlyOwner {
authorizedSigner = signer_;
emit SignerUpdated(signer_);
}
function setStaking(address staking_) external onlyOwner {
staking = IStaking(staking_);
emit StakingSet(staking_);
}
/// @notice Withdraw tokens based on server-side signature (EIP-712)
/// @param to recipient address
/// @param amount token amount in token smallest unit
/// @param nonce expected nonce for `to`
/// @param deadline unix timestamp signature expiry
/// @param signature EIP-712 signature bytes
function withdraw(address to, uint256 amount, uint256 nonce, uint256 deadline, bytes calldata signature) external nonReentrant {
require(block.timestamp <= deadline, "signature expired");
require(nonce == nonces[to], "invalid nonce");
bytes32 structHash = keccak256(abi.encode(WITHDRAW_TYPEHASH, to, amount, nonce, deadline));
bytes32 digest = _hashTypedDataV4(structHash);
address recovered = ECDSA.recover(digest, signature);
require(recovered == authorizedSigner, "invalid signature");
// increment nonce to prevent replay
nonces[to] = nonces[to] + 1;
// transfer tokens (Treasury must have enough balance)
require(token.transfer(to, amount), "token transfer failed");
// mark stake as finalized on staking contract (must be set and staking.finalizeWithdrawal only callable by this treasury)
if (address(staking) != address(0)) {
staking.finalizeWithdrawal(to);
}
emit WithdrawProcessed(to, amount, nonce, block.timestamp);
}
// helper for owner to deposit reward tokens to treasury (owner can transfer tokens to this contract)
}
注意部署顺序与授权:
-
部署 Token(如果是已有代币跳过)。
-
部署 Treasury,Treasury 地址得到后。
-
部署 Staking,把 treasury 地址通过 staking.setTreasury(treasuryAddress) 设置到 staking 合约中。
-
在 Treasury 调用 setStaking(stakingAddress),并确保 Treasury 合约持有足够的代币(reward + 用户质押代币会被转给 Treasury,因此 Treasury 需要能持有和支付代币)。
二、后端(签名服务)
后端职责:
-
接收前端的提现请求(user address)。
-
使用只读 provider 读取 Staking.getWithdrawInfo(user),确保 matured && !claimed 并计算 amount = principal + reward(使用链上计算函数,避免本地误差)。
-
读取 Treasury.nonces(user) 获取当前 nonce。
-
生成 EIP-712 签名(typed data),domain 必须与合约 EIP712("Treasury","1") 的 name/version、chainId、verifyingContract 保持一致。
-
返回签名 + amount + nonce + deadline 给前端(或直接返回签名与必要数据)。
示例代码(简化):
// server.js (示例)
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const { ethers } = require('ethers');
const app = express();
app.use(bodyParser.json());
const RPC_URL = process.env.RPC_URL; // BSC 主网 RPC
const provider = new ethers.providers.JsonRpcProvider(RPC_URL);
const STAKING_ADDR = process.env.STAKING_ADDRESS;
const TREASURY_ADDR = process.env.TREASURY_ADDRESS;
const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY; // 安全存放,不要直接在代码中
// ABI needs the functions we call: getWithdrawInfo, nonces
const stakingAbi = [
"function getWithdrawInfo(address user) view returns (uint256,uint256,bool,bool)"
];
const treasuryAbi = [
"function nonces(address) view returns (uint256)"
];
const staking = new ethers.Contract(STAKING_ADDR, stakingAbi, provider);
const treasury = new ethers.Contract(TREASURY_ADDR, treasuryAbi, provider);
const signerWallet = new ethers.Wallet(SIGNER_PRIVATE_KEY, provider);
app.post('/request-withdraw', async (req, res) => {
try {
const user = req.body.user;
if (!user || !ethers.utils.isAddress(user)) return res.status(400).send({ error: 'invalid user' });
// read on-chain stake info
const [principal, reward, matured, claimed] = await staking.getWithdrawInfo(user);
if (!matured) return res.status(400).send({ error: 'stake not matured' });
if (claimed) return res.status(400).send({ error: 'already claimed' });
const amount = principal.add(reward); // BigNumber
// read nonce from treasury
const nonceBN = await treasury.nonces(user);
const nonce = nonceBN.toNumber();
const network = await provider.getNetwork();
const chainId = network.chainId;
const deadline = Math.floor(Date.now() / 1000) + 10 * 60; // 10 minutes expiry
// EIP-712 domain must match contract's EIP712("Treasury","1")
const domain = {
name: "Treasury",
version: "1",
chainId: chainId,
verifyingContract: TREASURY_ADDR
};
const types = {
Withdraw: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" }
]
};
const value = {
to: user,
amount: amount.toString(),
nonce: nonce,
deadline: deadline
};
// signerWallet._signTypedData(domain, types, value) uses ethers v5 EIP-712 signing
const signature = await signerWallet._signTypedData(domain, types, value);
return res.json({
signature,
amount: amount.toString(),
nonce,
deadline
});
} catch (err) {
console.error(err);
return res.status(500).send({ error: 'server error' });
}
});
app.listen(3000, () => console.log('Sign service listening on 3000'));
生产注意:
-
SIGNER_PRIVATE_KEY 必须放在 KMS/HSM 中(例如 AWS KMS / HashiCorp Vault / Azure Key Vault),并且把签名服务限制在内网、加鉴权、速率限制、登录/审计等。
-
后端必须严格使用链上 getWithdrawInfo() 来核验奖励、到期与是否已被领取,不要用信任的本地数据库作为唯一数据源。
三、前端(DApp)调用流程示例(ethers.js)
-
用户在 DApp 点击 请求提现。
-
DApp 调用后端 /request-withdraw 获取签名与参数。
-
DApp 使用用户的 Metamask 签名者将交易发送到 Treasury.withdraw(...)。
示例(简化):
// frontend.js (简化)
async functionrequestWithdraw() {
const provider = new ethers.providers.Web3Provider(window.ethereum);
await provider.send("eth_requestAccounts", []);
const signer = provider.getSigner();
const user = await signer.getAddress();
// call backend to get signature
const resp = await fetch('/request-withdraw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user })
});
const data = await resp.json();
if (data.error) { alert(data.error); return; }
const signature = data.signature;
const amount = data.amount; // string in smallest unit
const nonce = data.nonce;
const deadline = data.deadline;
// interact with on-chain Treasury contract
const treasuryAbi = [
"function withdraw(address to,uint256 amount,uint256 nonce,uint256 deadline,bytes signature) external"
];
const treasuryAddress = "0x..."; // your treasury contract
const treasuryContract = new ethers.Contract(treasuryAddress, treasuryAbi, signer);
try {
const tx = await treasuryContract.withdraw(user, amount, nonce, deadline, signature);
console.log('tx hash', tx.hash);
await tx.wait();
alert('Withdraw successful');
} catch (err) {
console.error(err);
alert('Withdraw failed: ' + (err && err.message));
}
}
注意前端单位:amount 在后端返回是 token 的最小单位(wei-like),前端无需再次 parseUnits。如果你希望前端自己请求后端给出 amountHuman,请小心数值精度与单位。
四、端到端流程总结(时间线)
-
项目方(owner)把足够的代币转入 Treasury(用作奖励池 + 存用户质押的本金)。
-
用户在 DApp 中 approve(stakingContract, amount),然后 staking.stake(amount,duration)。staking 合约 transferFrom(user -> treasury), staking 记录 (amount, start, duration)。
-
到期后,用户点击 DApp 的 Withdraw。
-
DApp 向后端发起 /request-withdraw(包含用户地址),后端:
调用 staking.getWithdrawInfo(user) 确认 matured && !claimed。
读取 treasury.nonces(user)。
计算 total = principal+reward(以链上计算为准)。
用 SIGNER_PRIVATE_KEY 对 EIP-712 数据签名(带 nonce 与 deadline)。
返回签名与 amount/nonce/deadline 给前端。
-
前端调用 treasury.withdraw(user, amount, nonce, deadline, signature) 由用户发起链上交易(支付 gas)。
-
Treasury.withdraw:
验证 deadline、nonce、EIP-712 签名是否来自 authorizedSigner。
nonce++ 防止重放。
将 amount 从 Treasury 转给 user(token.transfer)。
调用 staking.finalizeWithdrawal(user) 将质押标记为 claimed(只允许 Treasury 调用)。
事件 WithdrawProcessed 触发。
-
完成:用户收到本金 + 奖励,链上状态一致且签名无法被重放/伪造。
五、安全建议(生产)
私钥保护:签名私钥必须存放在 KMS/HSM 中,不应直接放在代码或普通服务器环境变量。即便在临时测试环境也要注意保存位置。
签名有效期:使用 deadline,并尽量短(例如 10 分钟),避免长期签名被截取后使用。
非对称验证:合约里只信任 authorizedSigner 的地址(可在合约里 onlyOwner 修改)。
链ID & verifyingContract:EIP-712 domain 使用 chainId 与 verifyingContract,防止跨链重放(不同链上签名无法复用)。
nonce:使用合约端 nonces[to],签名前后端都读取合约 nonce 保证一致(避免 race)。
Reentrancy:Treasury.withdraw 使用 nonReentrant。
后端校验:后端在签名前必须使用链上视图(staking.getWithdrawInfo)校验状态,而不是信任前端传来的数据。
审计:上线前进行第三方合约安全审计。
日志与监控:后端签名动作记录审计日志、触发告警(过大数额或频繁)。
资金冗余:Treasury 必须保持充足余额以保证提现成功;建议设置监控与入金提醒。
六、测试建议
-
在 Testnet(BSC 测试网或本地 Hardhat)上先做完整测试。
-
测试用例:
-
正常 stake -> 到期 -> 请求签名 -> withdraw -> 收到资金。
-
使用过期 deadline 的签名(应失败)。
-
重放同一签名(nonce 保护应阻止)。
-
非授权 signer 签名(应拒绝)。
-
treasury 余额不足(应失败并且不改变 staking.claimed)。
-
保险做法:在 withdraw 之前后端把签名写数据库并标记“签发中”,在链上 tx 成功后更新状态为已完成(方便补偿/排查)。
小结
上述方案把链上状态(staking)作为最终凭证,把签名生成放在后端受控环境(且仅在链上数据验证通过时)——这是一种常见的生产模式,既能保证链上安全性,又能将复杂的商业逻辑(如批量计算奖励、KYC 验证、风控)放到后端处理,再由可验证的签名委托链上资金动作,从而做到防伪造、可验证、并能在链上强制执行。
❝本公众号发布的内容除特别标明外版权归原作者所有。若涉及版权问题,请联系我们。所有信息及评论区内容仅供参考,请读者自行判断信息真伪,不构成任何投资建议。据此产生的任何损失,本公众号概不负责,亦不负任何法律责任。

