ETH ICAP 地址协议算法实现

以太坊钱包生成收款码时,有些是直接拿裸地址例如 "0x0728F0...75445F" 生成收款二维码,例如 TrustWallet;还有些是使用 "iban:" 开头的 ICAP 串来生成收款二维码,例如 imToken 1.0。

IBAN 本身是国际上一部分银行间转账使用的账号代码格式,以太坊社区在 IBAN 地址格式上做了一些扩展,用来编码以太坊地址和校验码,用作地址交换使用,叫做 ICAP (Inter exchange Client Address Protocol)。

IBAN 编码看起来很简单,但在实现上字母到数值的转换方法挺 trick 的,需要花一些时间进行理解。为简化理解,下面我拿一个例子来说明整个编码过程。

假设我们已经有了一个以太坊地址:0x730aEA2B39AA2Cf6B24829b3D39dC9a1F9297B88,下面是生成对应 ICAP 地址的过程:

第一步:将原始 16 进制以太坊地址转换成为 36 进制地址:

16 进制 ETH 地址:0x730aEA2B39AA2Cf6B24829b3D39dC9a1F9297B88
36 进制 ETH 地址:DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4

第二步:为 36 进制 ETH 地址拼接上国家码 "XE" 和空校验字符串 "00" 形成 36 进制待校验字串:

36 进制 ETH 地址: DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4
36 进制待校验字串: DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4XE00

第三步:将 36 进制待校验字串逐字符转成 10 进制数字字串:

36 进制待校验字串: DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4XE00
10 进制待校验字串: 1315273521273029152915344143118231017341572926622101234174331400

第四步:将 10 进制大整数对 97 取模,然后用 98 - 模数:

校验码:42 = 98 - 1315273521273029152915344143118231017341572926622101234174331400 % 97

第五步:将校验码替换空校验字符串,然后重新安排 XE** 到地址前,并加上前缀:

36 进制待校验字串: DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4XE00
36 进制已校验字串: DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4XE42
36 进制 IBAN 号: iban:XE42DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4

可以用这个 IBAN 号生成二维码,用支持 iban 地址的 app 扫描二维码验证下是否能解析到正确的 ETH 原始地址。

ICAP 地址生成和校验的实现,可以参考下面这段 Java 代码,可直接用于 Android 客户端:

package com.yangwenbo;

import java.math.BigInteger;

/**
 * Ethereum ICAP (Inter exchange Client Address Protocol) Address Converter
 * Convert Ethereum Address from/to ICAP iban address
 *
 * @ref https://github.com/ethereum/wiki/wiki/Inter-exchange-Client-Address
 * -Protocol-(ICAP)
 */
public class EthICAP {
  private static String ICAP_XE_PREFIX = "XE";
  private static String IBAN_SCHEME = "iban:";
  private static String IBAN_MOD = "97";

  /**
   * Build ICAP iban address from ethereum address.
   *
   * @param ethAddress ethereum address
   * @return ICAP iban address
   * @example input:  0x730aea2b39aa2cf6b24829b3d39dc9a1f9297b88
   * return: iban:XE42DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4
   */
  public static String buildICAP(String ethAddress) {
    if (!ethAddress.startsWith("0x") || ethAddress.length() != 42) {
      throw new IllegalArgumentException("Invalid ethereum address.");
    }
    BigInteger ethInt = new BigInteger(ethAddress.substring(2), 16);
    String base36Addr = ethInt.toString(36).toUpperCase();
    String checkAddr = base36Addr + ICAP_XE_PREFIX + "00";
    String base10Str = "";
    for (Character c : checkAddr.toCharArray()) {
      base10Str += new BigInteger(c.toString(), 36);
    }
    Integer checkSum = 98
        - (new BigInteger(base10Str)).mod(new BigInteger(IBAN_MOD)).intValue();
    String icapAddress = IBAN_SCHEME + ICAP_XE_PREFIX 
        + checkSum.toString() + base36Addr;
    return icapAddress;
  }

  /**
   * Decode ethereum address from ICAP iban address
   *
   * @param icapAddress ICAP iban address
   * @return ethereum address
   * @example input:  iban:XE42DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4
   * return: 0x730aea2b39aa2cf6b24829b3d39dc9a1f9297b88
   */
  public static String decodeICAP(String icapAddress) {
    if (!isValid(icapAddress)) {
      throw new IllegalArgumentException("Invalid icap address.");
    }
    BigInteger ethInt = new BigInteger(icapAddress.substring(9), 36);
    String base16Addr = ethInt.toString(16).toLowerCase();
    return "0x" + base16Addr;
  }

  /**
   * Check ICAP iban address validation
   *
   * @param icapAddress ICAP iban address
   * @return true if valid; false if invalid
   */
  public static boolean isValid(String icapAddress) {
    if (!icapAddress.startsWith("iban:XE") || icapAddress.length() != 40) {
      return false;
    }
    String base10Str = "";
    for (Character c : icapAddress.substring(9).toCharArray()) {
      base10Str += new BigInteger(c.toString(), 36);
    }
    for (Character c : icapAddress.substring(5, 9).toCharArray()) {
      base10Str += new BigInteger(c.toString(), 36);
    }
    Integer checkSum
        = (new BigInteger(base10Str)).mod(new BigInteger(IBAN_MOD)).intValue();
    return checkSum == 1;
  }
}

专门用于批量空投的 ETH 智能合约

2018 年 4 月份,美链 BeautyChain (BEC) 爆出了智能合约漏洞,导致市值 60 亿的代币价格几乎归零,这在币圈里也算是一场大事件了。

那么漏洞主要出在什么地方呢?主要是 BEC 在标准的 ERC20 接口之外,自己添加了一个 batchTransfer 接口。一般而言,添加这种接口主要是为了便于空投时批量转账,可是 BEC 在这个接口设计上犯了两个错误:

  1. 没有使用安全的数值计算,存在数值溢出漏洞。这也是在网上被广泛传播的漏洞分析。
  2. 没有限定 batchTransfer 的使用范围。如果它限制了 batchTransfer 只能合约拥有者调用,即使存在漏洞也不能被人利用了。

BEC 为了便于空投增加的这个接口可谓代价惨重,但如果他们知道还有不需要修改代币主合约就能批量转账的办法,不知道会不会吐一口老血?

批量转账,指的是在一笔 ETH 交易中转多笔代币到不同的账户,一般用于 ERC20 代币项目启动时对用户进行空投(有人叫糖果发放)。

批量空投的好处主要有两个,一是省 GAS 费,但事实上省得不多;二是省时间,这是最主要目的。以太坊是以交易为粒度打包,如果转账只能单对单,即使一次发起多笔单对单的交易,等待这些交易被打包的时间也非常漫长,而且还有笔数上限限制。将多笔转账放到同一个交易中,被打包确认的速度就会非常快。一般 ERC20 代币项目启动时都会大撒币,空投地址动辄都是几万几十万,批量空投接口对效率会有上百倍的提升。

空投合约基本原理:ERC20 可以通过 approve 和 transferFrom 两个接口授权其它地址一定的额度。那既然是这样,我们也可以授权一个合约地址来花自己的代币,如果这个合约支持批量转账,那么就可以通过这个合约来实现批量空投了。

下面是具体实操流程:

假设已经存在一个 ERC20 代币的合约,合约地址为“TOKEN_ADDR”,而你的钱包里已经有了 100 万 TOKEN,你的钱包地址是“WALLET_ADDR”。

STEP1: 用自己的钱包部署支持批量转账的空投合约,假设创建成功后地址为“AD_ADDR”。下面给出最关键的部分,完整合约参考 github 链接 。这里 transferFrom 取的是 msg.sender,理论上来讲不加 onlyOwner 限定这个合约也可以给其它人使用,但为了安全还是加上较为稳妥。

contract Airdropper is Ownable {
    function multisend(address _tokenAddr, address[] dests,
                       uint256[] values) public onlyOwner returns (uint256) {
        uint256 i = 0;
        while (i < dests.length) {
           ERC20(_tokenAddr).transferFrom(msg.sender, dests[i], values[i]);
           i += 1;
        }
        return(i);
    }
}

STEP2: 用自己的钱包授权空投合约地址 AD_ADDR 100 万 TOKEN 额度。即通过代码或者 remix 执行 approve(AD_ADDR, 1000000*精度),注意这是 ERC20 合约里的接口,需要将交易发往 TOKEN_ADDR。

STEP3: 检查 AD_ADDR 是否得到了 100 万 TOKEN 授权。通过代码或者 remix 执行 allowance(WALLET_ADDR, AD_ADDR),如果结果是 100 万 TOKEN,说明空投合约已经得到你的 100万额度授权。

STEP4: 用自己的钱包调用空投合约的 multisend 接口发起批量空投。通过代码或者 remix 执行 multisend(TOKEN_ADDR, [addr1, addr2, ...], [value1, value2, ...]),执行成功即能实现批量转账。这是空投合约里的接口,需要将交易发往 AD_ADDR。

这里最容易混淆的是几个地址,TOKEN_ADDR/WALLET_ADDR/AD_ADDR,在每一步操作中要想明白调用的是哪个合约的接口,参数应该填哪个地址。 第一步只需要操作一次,第四步可以操作很多次,第二步可根据需求随时调整授权额度。

有了这个空投合约,你就可以将自己钱包里任意类型代币都通过批量转账方式空投出去。额度可控,任意账户均可用,还避免了在代币主合约里额外增加非 ERC20 标准接口带来的风险。

为什么两笔 Token 转账消耗 GAS 不同

大家都知道,以太坊 Token 转账的过程就是智能合约执行的过程,所以每笔交易都会根据智能合约的执行情况消耗一定数量的 GAS 作为交易手续费,同时也限制了交易对资源的滥用。

大家都能接受交易有手续费的概念,因为银行转账,或者支付宝微信转账,也都有可能产生手续费。大部分情况下,手续费是按照金额一定比例收取的。但会出乎很多人意料的是,在以太坊上即使往同一个地址里转账同一种 Token,消耗的 GAS 也有可能不同。

随便在 etherscan.io 上找一个近期有多笔 Token 交易的地址(其实找了好一会儿),比如这个 : https://etherscan.io/address/0x5debb351b536eb1a61be12810abe614485167f46#tokentxns

可以看到近期正好有两笔 Rating Token 转入到这个账户,对比下 GAS 的消耗:

可以看到两笔转账消耗的 GAS 分别是 37434 和 22434,差了 15000。这就奇怪了,第一笔转账的 Token 金额比第二笔少,但是消耗的 GAS 反而更多,这完全不合理啊!

其实这种现象的解释也很简单,通过对比两笔交易的 VMTRACE 可以发现,最大的差异出现在一条指令上:“SSTORE”。第一笔交易的 SSTORE 消耗了 20000 GAS,第二笔交易只消耗了 5000 GAS。

这时候只好去查文档了,以太坊黄皮书 https://ethereum.github.io/yellowpaper/paper.pdf 附录G:FEE SCHEDULE。发现下面这段说明:

当 SSTORE 将存储的值从 0 改成非 0 时,消耗 20000 GAS,其它情况下消耗 5000 GAS。真相大白,不是以太坊乱收费,文档就是这么写的。

虽然明白了原理,但这的确颠覆了我们的认知,转账的 GAS 费用居然跟对方的账户余额有关!

不过这也解释了一件事:为什么我们在 etherscan 上经常能看到那么多 *.9999999 的转账?可能很多交易所或者黑客早就弄明白了这件事,故意留一点点金额在账户里,减少未来转账的 GAS 手续费。