大数跨境
0
0

BSC链上通过签名验签方式实现质押系统代币及收益的提币接口

BSC链上通过签名验签方式实现质押系统代币及收益的提币接口 跨境大白
2025-09-09
22
导读:下面是一份可用于生产环境的方案与实现(含完整的合约、后端签名服务和前端调用示例),说明各模块职责、数据流以及安全点与部署/测试要点。主要涵盖ERC20代币的质押和签名验签方式的提币校验和安全性验证等功

下面是一份可用于生产环境的方案与实现(含完整的合约、后端签名服务和前端调用示例),说明各模块职责、数据流以及安全点与部署/测试要点。实现思路基于:

  • 代币(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程序(包含前后端和链端)

  1. 目前支持主流的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)
}

注意部署顺序与授权:

  1. 部署 Token(如果是已有代币跳过)。

  2. 部署 Treasury,Treasury 地址得到后。

  3. 部署 Staking,把 treasury 地址通过 staking.setTreasury(treasuryAddress) 设置到 staking 合约中。

  4. 在 Treasury 调用 setStaking(stakingAddress),并确保 Treasury 合约持有足够的代币(reward + 用户质押代币会被转给 Treasury,因此 Treasury 需要能持有和支付代币)。

二、后端(签名服务)

后端职责:

  1. 接收前端的提现请求(user address)。

  2. 使用只读 provider 读取 Staking.getWithdrawInfo(user),确保 matured && !claimed 并计算 amount = principal + reward(使用链上计算函数,避免本地误差)。

  3. 读取 Treasury.nonces(user) 获取当前 nonce。

  4. 生成 EIP-712 签名(typed data),domain 必须与合约 EIP712("Treasury","1") 的 name/version、chainId、verifyingContract 保持一致。

  5. 返回签名 + 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)

  1. 用户在 DApp 点击 请求提现。

  2. DApp 调用后端 /request-withdraw 获取签名与参数。

  3. 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,请小心数值精度与单位。

四、端到端流程总结(时间线)

  1. 项目方(owner)把足够的代币转入 Treasury(用作奖励池 + 存用户质押的本金)。

  2. 用户在 DApp 中 approve(stakingContract, amount),然后 staking.stake(amount,duration)。staking 合约 transferFrom(user -> treasury), staking 记录 (amount, start, duration)。

  3. 到期后,用户点击 DApp 的 Withdraw。

  4. DApp 向后端发起 /request-withdraw(包含用户地址),后端:

调用 staking.getWithdrawInfo(user) 确认 matured && !claimed。

读取 treasury.nonces(user)。

计算 total = principal+reward(以链上计算为准)。

用 SIGNER_PRIVATE_KEY 对 EIP-712 数据签名(带 nonce 与 deadline)。

返回签名与 amount/nonce/deadline 给前端。

  1. 前端调用 treasury.withdraw(user, amount, nonce, deadline, signature) 由用户发起链上交易(支付 gas)。

  2. Treasury.withdraw:

验证 deadline、nonce、EIP-712 签名是否来自 authorizedSigner。

nonce++ 防止重放。

将 amount 从 Treasury 转给 user(token.transfer)。

调用 staking.finalizeWithdrawal(user) 将质押标记为 claimed(只允许 Treasury 调用)。

事件 WithdrawProcessed 触发。

  1. 完成:用户收到本金 + 奖励,链上状态一致且签名无法被重放/伪造。

五、安全建议(生产)

私钥保护:签名私钥必须存放在 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 必须保持充足余额以保证提现成功;建议设置监控与入金提醒。

六、测试建议

  1. 在 Testnet(BSC 测试网或本地 Hardhat)上先做完整测试。

  2. 测试用例:

  • 正常 stake -> 到期 -> 请求签名 -> withdraw -> 收到资金。

  • 使用过期 deadline 的签名(应失败)。

  • 重放同一签名(nonce 保护应阻止)。

  • 非授权 signer 签名(应拒绝)。

  • treasury 余额不足(应失败并且不改变 staking.claimed)。

  1. 保险做法:在 withdraw 之前后端把签名写数据库并标记“签发中”,在链上 tx 成功后更新状态为已完成(方便补偿/排查)。

小结

上述方案把链上状态(staking)作为最终凭证,把签名生成放在后端受控环境(且仅在链上数据验证通过时)——这是一种常见的生产模式,既能保证链上安全性,又能将复杂的商业逻辑(如批量计算奖励、KYC 验证、风控)放到后端处理,再由可验证的签名委托链上资金动作,从而做到防伪造、可验证、并能在链上强制执行。


图片
扫描二维码关注我们

本公众号发布的内容除特别标明外版权归原作者所有。若涉及版权问题,请联系我们。所有信息及评论区内容仅供参考,请读者自行判断信息真伪,不构成任何投资建议。据此产生的任何损失,本公众号概不负责,亦不负任何法律责任。


【声明】内容源于网络
0
0
跨境大白
跨境分享社 | 持续输出跨境知识
内容 45144
粉丝 0
跨境大白 跨境分享社 | 持续输出跨境知识
总阅读273.3k
粉丝0
内容45.1k