跳转到帖子

用本地规则做一层钱包转账风控:从 ERC-20 授权到可疑地址关联

精选回复

发布于

背景

很多钱包被盗并不是私钥立刻泄露,而是用户签过一次高风险授权,后续被恶意合约或被控地址慢慢转走资产。社区里经常看到类似情况:用户只记得“点过一次 Mint / Claim / 空投页面”,链上看则是 approve、setApprovalForAll 或 Permit 签名被利用。

如果做钱包、机器人或内部资金管理,建议不要只依赖前端提示。更稳妥的做法是在本地加一层轻量风控:交易发出前解析 calldata,识别高风险授权、异常接收方、黑名单关联地址,并在签名前拦截或二次确认。

技术分析

以 EVM 链为例,最常见的高风险操作主要有几类:

  • ERC-20 无限授权:approve(spender, uint256),当 amount 接近 2^256-1 时通常代表无限授权。
  • ERC-721 / ERC-1155 全量授权:setApprovalForAll(operator, true),一旦 operator 是恶意地址,NFT 可能被批量转走。
  • Permit 类签名:用户不发链上交易,但签名本身可以让攻击者代发授权交易,需要解析 EIP-712 typed data。
  • 直接转账到高风险地址:比如已知钓鱼合约、混币入口、被盗资金归集地址、近期新创建且频繁收款的地址。

本地风控的关键不是“百分百判黑”,而是把明显危险的操作在签名前暴露出来。实际落地时可以将风险拆成几个维度:

  • 函数选择器:通过 calldata 前 4 字节识别调用方法。
  • 参数解析:解析 spender/operator、amount、approved 等关键字段。
  • 地址画像:维护本地 denylist、近期高风险交互地址、内部白名单。
  • 合约状态:检查目标地址是否为合约、合约创建时间、是否开源、是否代理合约。
  • 行为规则:如“新地址 + 无限授权 + 非主流合约”直接高危。

操作步骤

  1. 收集交易对象:拿到待签交易的 to、data、value、chainId。
  2. 解析函数选择器:读取 calldata 前 4 字节,匹配常见 ABI。
  3. 提取关键参数:重点关注 spender、operator、amount、approved。
  4. 计算风险分:结合本地规则,比如无限授权 + spender 命中风险地址则高危。
  5. 输出可读提示:不要只显示“风险交易”,应明确说明“你正在授权某地址无限使用某代币”。
  6. 保留人工确认:高危交易建议默认拦截,必要时要求二次确认或硬件钱包确认。

代码示例

下面是一个简化版 Node.js 示例,用于解析 ERC-20 approve 和 ERC-721/1155 setApprovalForAll。实际生产环境建议接入完整 ABI、链上 RPC 校验和内部地址库。

import { Interface, getAddress, MaxUint256 } from "ethers";

const ERC20_ABI = [
  "function approve(address spender,uint256 amount) returns (bool)"
];

const NFT_ABI = [
  "function setApprovalForAll(address operator,bool approved)"
];

const erc20Iface = new Interface(ERC20_ABI);
const nftIface = new Interface(NFT_ABI);

// 示例:本地高风险地址库,实际应定期更新并记录来源
const denylist = new Set([
  "0x1111111111111111111111111111111111111111".toLowerCase(),
  "0x2222222222222222222222222222222222222222".toLowerCase()
]);

function isUnlimitedApproval(amount) {
  // 很多钱包会用 MaxUint256,也可以把大于余额若干倍的授权视为风险
  return BigInt(amount.toString()) === BigInt(MaxUint256.toString());
}

function normalizeAddress(addr) {
  try {
    return getAddress(addr).toLowerCase();
  } catch {
    return null;
  }
}

function analyzeTx(tx) {
  const result = {
    level: "low",
    reasons: [],
    decoded: null
  };

  if (!tx || !tx.data || tx.data === "0x") {
    return result;
  }

  // 1. 尝试解析 ERC-20 approve
  try {
    const parsed = erc20Iface.parseTransaction({ data: tx.data, value: tx.value || 0 });
    if (parsed && parsed.name === "approve") {
      const spender = normalizeAddress(parsed.args.spender);
      const amount = parsed.args.amount;

      result.decoded = {
        type: "ERC20_APPROVE",
        token: tx.to,
        spender,
        amount: amount.toString()
      };

      if (isUnlimitedApproval(amount)) {
        result.level = "medium";
        result.reasons.push("检测到 ERC-20 无限授权");
      }

      if (spender && denylist.has(spender)) {
        result.level = "high";
        result.reasons.push("spender 命中本地高风险地址库");
      }

      return result;
    }
  } catch (_) {
    // 不是 approve,继续尝试其他 ABI
  }

  // 2. 尝试解析 NFT setApprovalForAll
  try {
    const parsed = nftIface.parseTransaction({ data: tx.data, value: tx.value || 0 });
    if (parsed && parsed.name === "setApprovalForAll") {
      const operator = normalizeAddress(parsed.args.operator);
      const approved = parsed.args.approved;

      result.decoded = {
        type: "NFT_SET_APPROVAL_FOR_ALL",
        collection: tx.to,
        operator,
        approved
      };

      if (approved === true) {
        result.level = "medium";
        result.reasons.push("检测到 NFT 全量授权");
      }

      if (operator && denylist.has(operator)) {
        result.level = "high";
        result.reasons.push("operator 命中本地高风险地址库");
      }

      return result;
    }
  } catch (_) {
    // 未识别
  }

  return result;
}

// 示例交易结构,实际可来自钱包插件、后端服务或交易构造模块
const tx = {
  chainId: 1,
  to: "0xTokenContractAddress000000000000000000000000",
  value: 0,
  data: "0x095ea7b30000000000000000000000001111111111111111111111111111111111111111ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
};

console.log(analyzeTx(tx));

规则设计建议

单条规则容易误报,建议用组合规则:

  • 低风险:普通转账、授权金额较小、交互对象在内部白名单。
  • 中风险:无限授权、NFT 全量授权、目标合约未验证、首次交互地址。
  • 高风险:命中高风险地址库、钓鱼域名来源、授权对象与被盗资金归集地址有关联。

地址关联可以先做简单版本:记录资金流入流出的一跳关系。例如某 spender 曾经把资产转入已知盗币归集地址,即使 spender 本身没在黑名单里,也应至少标记为中高风险。后续再扩展多跳图谱、时间窗口、金额阈值。

注意事项

  • 不要只看域名:很多钓鱼站会频繁换域名,链上 spender/operator 更稳定。
  • 不要只靠黑名单:新地址没有历史记录,仍可能通过“首次交互 + 无限授权”识别风险。
  • Permit 必须单独处理:它不一定有链上 tx data,需要解析用户签名的 typed data。
  • 代理合约要追实现:如果目标是 proxy,应读取 implementation slot,否则只看 proxy 源码意义有限。
  • 提示要具体:用户真正需要知道的是“谁可以动我的什么资产、额度是多少”。

总结

钱包风控不一定一开始就做成复杂的链上情报系统。先从本地 calldata 解析、授权识别、风险地址库、组合规则做起,就能挡住相当一部分常见盗币路径。重点是把风险前置到签名前,而不是等资产被转走后再追踪。

实际落地时建议先覆盖 approve、setApprovalForAll、Permit、常见 Router 交互,再逐步接入链上地址画像和资金流关联。规则简单但稳定,比只依赖前端展示更可靠。

  • 楼主
本地规则做钱包风控是可行的,关键是把规则拆成“交易前静态检查”和“广播前模拟检查”,不要只看收款地址。 可以重点加几类规则: 1. ERC-20 授权风险 很多被盗不是直接转账,而是 `approve`/`increaseAllowance` 给了异常 spender。建议本地解析 calldata,遇到以下情况直接拦截或二次确认: - `approve(spender, uint256)` 且 amount 接近 `uint256.max` - spender 是新创建合约、无源码、短期内被多个受害地址授权 - spender 不是常见路由/协议合约,但请求无限授权 - token 本身是假币或带钓鱼符号,比如名称伪装成 USDT/USDC 函数选择器可以先覆盖这些:
approve(address,uint256)          0x095ea7b3
increaseAllowance(address,uint256) 0x39509351
permit(...) 需按 EIP-2612 / Permit2 分开解析
如果是 ethers v6,可以本地这样解析:
import { Interface, MaxUint256 } from "ethers";

const erc20 = new Interface([
  "function approve(address spender,uint256 amount)",
  "function increaseAllowance(address spender,uint256 addedValue)"
]);

function checkERC20Approval(tx) {
  if (!tx.data || tx.data.length < 10) return null;

  try {
    const parsed = erc20.parseTransaction({ data: tx.data, value: tx.value || 0 });
    if (!parsed) return null;

    if (parsed.name === "approve") {
      const spender = parsed.args.spender;
      const amount = parsed.args.amount;

      if (amount > MaxUint256 / 2n) {
        return {
          level: "high",
          reason: `无限授权风险,spender=${spender}`
        };
      }
    }

    if (parsed.name === "increaseAllowance") {
      return {
        level: "medium",
        reason: `增加授权额度,spender=${parsed.args.spender}`
      };
    }
  } catch (_) {}

  return null;
}
2. Permit / Permit2 要单独处理 现在很多钓鱼不走链上 `approve`,而是诱导用户签名 `permit` 或 Permit2 的 `PermitSingle/PermitBatch`。本地风控如果只看交易,会漏掉一大块。 建议对签名请求做 EIP-712 域检查: - `domain.verifyingContract` 是否为官方 Permit2:`0x000000000022D473030F116dDEE9F6B43aC78BA3` - `spender` 是否在本地白名单 - `deadline` 是否异常长 - `amount` 是否接近最大值 - `chainId` 是否和当前网络一致 特别是 Permit2,一旦给了恶意 spender,后面不需要用户再签 ERC-20 approve。 3. 地址关联不要只靠“黑名单命中” 本地规则可以做轻量级图关系,不一定要跑完整链上图数据库。比较实用的是维护几类边: - 资金来源边:A 给 B 转过 ETH/原生币作为 gas - 交互边:A 调用过 B 合约 - 授权边:A approve 给 B - 共同归集边:多个地址把资产转到同一个地址 - 创建者边:EOA 创建了某个合约 比如可疑地址不直接命中黑名单,但它的 gas 来源来自已知钓鱼地址,或者它创建的合约被大量受害者授权,就可以提升风险分。 规则示例:
{
  "rules": [
    {
      "name": "spender_related_to_bad_funder",
      "type": "approval",
      "condition": {
        "spender.gas_funder_in": "local_bad_addresses",
        "max_depth": 1
      },
      "score": 40
    },
    {
      "name": "new_contract_unlimited_approval",
      "type": "approval",
      "condition": {
        "spender.is_contract": true,
        "spender.contract_age_blocks_lt": 7200,
        "amount_is_unlimited": true
      },
      "score": 50
    },
    {
      "name": "victim_cluster_spender",
      "type": "approval",
      "condition": {
        "spender.unique_approvers_24h_gt": 20,
        "spender.source_verified": false
      },
      "score": 60
    }
  ],
  "thresholds": {
    "warn": 40,
    "block": 80
  }
}
4. 广播前最好加一次模拟 本地规则只能看意图,模拟可以看结果。建议接入本地节点或 RPC 的 `eth_call`,在 pending block 上模拟交易,观察: - 用户地址 ERC-20 balance 是否减少 - NFT owner 是否变化 - allowance 是否新增或扩大 - 是否触发不常见合约调用链 - 是否通过 multicall / aggregator 包了一层真实调用 如果能用 debug 接口,效果更好:
debug_traceCall
trace_call
eth_call
尤其要注意 `multicall`、`execute`、`swap`、`claim` 这类表面函数,真实风险经常藏在内部 call 里。 5. 本地缓存几个字段就够用 不一定一开始就做很重的链上索引。可以先为地址缓存这些信息:
{
  "address": "0x...",
  "is_contract": true,
  "first_seen_block": 19300000,
  "creator": "0x...",
  "source_verified": false,
  "labels": ["unverified_contract"],
  "funders": ["0x..."],
  "recent_approver_count": 12,
  "bad_distance": 1
}
风险分可以按交易实时计算,缓存过期时间按字段区分:合约创建者基本永久,recent approver 这类用 1h/24h 窗口即可。 个人建议拦截优先级可以这样排: - 高危:无限授权给未知 spender、Permit2 授权给未知 spender、收款地址命中本地黑名单或一跳关联 - 中危:新合约、未验证合约、短期大量授权、gas 来源异常 - 低危:首次交互、ENS/符号伪装、金额超过用户历史分位数 最后一点,本地规则一定要保留“解释原因”。不要只弹一个风险分,最好明确告诉用户:这笔交易是在给哪个 spender 授权、额度是多少、这个 spender 和哪个可疑地址有关。这样误报时也方便用户判断。
补一块比较容易漏的:`permit`/Permit2 的风险不一定体现在当前交易 `to` 地址上,很多钓鱼页面是让用户先签名,后面由攻击者代付 gas 调 `permitTransferFrom` 或 `transferFrom`。所以本地风控最好把“签名内容解析”也纳入,而不是只拦截链上 tx。 可以加三类检查: 1. EIP-712 签名域检查 重点看 `domain.verifyingContract`、`chainId`、`name`,不要只展示“你正在签名”。例如 Permit2 正常一般是固定合约地址,不同链不一样;如果 verifyingContract 是陌生合约,但 message 里又出现 token/spender/amount,就要提高风险级别。 ethers v6 里可以这样先把 typed data 打出来做规则判断:
import { TypedDataEncoder, getAddress, MaxUint256 } from "ethers";

function checkTypedData(domain, types, value, allowList) {
  const verifying = domain.verifyingContract
    ? getAddress(domain.verifyingContract)
    : null;

  const chainId = Number(domain.chainId || 0);

  const warnings = [];

  if (!verifying) {
    warnings.push("EIP-712 缺少 verifyingContract");
  } else if (!allowList.permitContracts?.[chainId]?.has(verifying)) {
    warnings.push(`非常见 EIP-712 验证合约: ${verifying}`);
  }

  const primaryType = TypedDataEncoder.getPrimaryType(types);

  // 粗略递归找 amount / value / nonce / deadline / spender
  const found = {};
  function walk(obj) {
    if (!obj || typeof obj !== "object") return;
    for (const [k, v] of Object.entries(obj)) {
      const key = k.toLowerCase();
      if (["spender", "operator", "to"].includes(key)) found[key] = v;
      if (["amount", "value", "allowed"].includes(key)) found[key] = v;
      if (["deadline", "expiration", "sigdeadline"].includes(key)) found[key] = v;
      if (typeof v === "object") walk(v);
    }
  }
  walk(value);

  if (found.spender && !allowList.spenders?.[chainId]?.has(getAddress(found.spender))) {
    warnings.push(`签名授权给非常见 spender: ${found.spender}`);
  }

  if (found.amount) {
    const amount = BigInt(found.amount.toString());
    if (amount > (MaxUint256 / 2n)) {
      warnings.push("签名中包含接近无限额度授权");
    }
  }

  if (found.deadline) {
    const deadline = BigInt(found.deadline.toString());
    const now = BigInt(Math.floor(Date.now() / 1000));
    if (deadline === 0n || deadline > now + 86400n * 30n) {
      warnings.push("授权 deadline 过长或无过期限制");
    }
  }

  return {
    primaryType,
    verifyingContract: verifying,
    warnings
  };
}
2. 对 Permit2 单独建规则 Permit2 常见入口不只是 `permit`,还要关注这些 selector:
permit(address,((address,uint160,uint48,uint48),address,uint256),bytes)
permitBatch(address,((address,uint160,uint48,uint48)[],address,uint256),bytes)
permitTransferFrom(((address,uint256),uint256,uint256),((address,uint256)),address,bytes)
permitWitnessTransferFrom(...)
lockdown(...)
其中 `uint160 amount`、`uint48 expiration`、`sigDeadline` 很关键。很多 UI 只展示 token,不展示 spender 或 expiration,本地规则里建议强制显示: - token - spender - amount - expiration - sigDeadline - nonce - Permit2 合约地址 - 当前 chainId 如果 `spender` 不是当前交互 dApp 的已知合约,却要求大额度或长时间有效,建议直接红色提示。 3. 做一次 allowance 状态对比 广播前模拟检查时,不只是看本笔交易是否成功,也要看交易后用户地址对外暴露了哪些 allowance。可以用 `eth_call` 在 pending block 或 fork 环境模拟后,再查询关键 token 的 `allowance(owner, spender)`。 最小化可以先做交易前后对比:
const erc20Abi = [
  "function allowance(address owner, address spender) view returns (uint256)",
  "function symbol() view returns (string)",
  "function decimals() view returns (uint8)"
];

async function getAllowance(provider, token, owner, spender) {
  const c = new ethers.Contract(token, erc20Abi, provider);
  return await c.allowance(owner, spender);
}

// 交易前记录
const before = await getAllowance(provider, token, owner, spender);

// 如果本地能解析出 approve/permit 的目标 spender,广播前提示变化
const afterExpected = parsedAmount;

if (afterExpected > before && afterExpected > riskThreshold) {
  console.warn("本次操作将扩大授权额度", {
    token,
    spender,
    before: before.toString(),
    after: afterExpected.toString()
  });
}
另外建议本地缓存一份“常见协议合约白名单”时不要只按地址,还要绑定: - chainId - contract address - expected bytecode hash - 首次加入时间 - 来源说明 单纯地址白名单在多链环境容易误判,尤其是同地址不同链、代理合约升级、钓鱼合约仿冒名称这几类场景。实际落地里,规则优先级可以设成:签名解析 > calldata 解析 > 合约画像 > 地址关联,这样误报会低一些。

创建帐户或登录后发表意见

最近浏览 0

  • 没有会员查看此页面。