Home 天价交易费的分析与思考-如何复现一笔交易费为7676ETH的交易
Post
Cancel

天价交易费的分析与思考-如何复现一笔交易费为7676ETH的交易

天价交易费的分析与思考-如何复现一笔交易费为7676ETH的交易

2021年9月27号一笔7676ETH的交易的出现引爆了整个加密货币社区.到底是什么原因出现的这一笔交易引起了诸多猜测。2天后也是9月29号交易的发出者Deversifi在其网站上纰漏了更多细节,以及事故分析。

作为一名老前端者,安全研究爱好者,作者第一时间阅读了这片文章。下边带大家一探究竟尝试解释一下这笔交易的由来。

背景

Deversifi在不久之前讲交易类型升级到EIP-1559的交易,并且它的前端支持连接Metamask和Ledger两种钱包。然而这两种钱包交易构建的方法是非常不同,对于Metamask来说,交易构建是Metamask来负责的,Dapp开发者基本不用操作什么。而对于ledger来说,Dapp需要自己构建交易,构建完成后传给Ledger进行交易的签名。目前Dapp开发者基本都会使用“@ethereumjs/tx” 来构建交易。下边就是官方文档给出的构建交易的事例代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import Common, { Chain, Hardfork } from '@ethereumjs/common'
import { FeeMarketEIP1559Transaction } from '@ethereumjs/tx'

const common = new Common({ chain: Chain.Mainnet, hardfork: Hardfork.London })

const txData = {
  "data": "0x1a8451e600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  "gasLimit": "0x02625a00",
  "maxPriorityFeePerGas": "0x01",
  "maxFeePerGas": "0xff",
  "nonce": "0x00",
  "to": "0xcccccccccccccccccccccccccccccccccccccccc",
  "value": "0x0186a0",
  "v": "0x01",
  "r": "0xafb6e247b1c490e284053c87ab5f6b59e219d51f743f7a4d83e400782bc7e4b9",
  "s": "0x479a268e0e0acd4de3f1e28e4fac2a6b32a4195e8dfa9d19147abe8807aa6f64",
  "chainId": "0x01",
  "accessList": [],
  "type": "0x02"
}

const tx = FeeMarketEIP1559Transaction.fromTxData(txData, { common })

一探究竟

从例子上看似乎没什么问题,只要将txData的参数传对,应该也不会出现什么问题。那到底这个天价交易是如何出现的?我们来通过代码一探究竟。

1
2
3
4
5
6
7
public constructor(txData: FeeMarketEIP1559TxData, opts: TxOptions = {}) {
    ...

    this.maxFeePerGas = new BN(toBuffer(maxFeePerGas === '' ? '0x' : maxFeePerGas))
    this.maxPriorityFeePerGas = new BN(
      toBuffer(maxPriorityFeePerGas === '' ? '0x' : maxPriorityFeePerGas)
    )

上边是FeeMarketEIP1559Transaction的constructor函数,我们注意maxPriorityFeePerGas和maxFeePerGas都是将传入的字符串转成Buffer对象,然后创建了一个BN的对象用于后续计算。从文档中看@ethereumjs/tx的开发者应该是期望上述两个参数都应是string类型,并且源码中的类型声明也是这么定义的 https://github.com/ethereumjs/ethereumjs-monorepo/blob/b0477d64c259b354ff57bab7e77be43081216fea/packages/tx/src/types.ts#L263:3

那么如果传入的类型如果不是string那么会发生什么?如果传入的是一个浮点数会发生什么?从Deversifi的事故分析中我们应该也能看到,他们应该传入的并不是string,而是浮点数。要回答这个问题我们就得进入toBuffer方法中看看了。

toBuffer方法是定义在ethereumjs-util中的下边是其代码片段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// ethereumjs-util
import { intToBuffer } from 'ethjs-util'
export const toBuffer = function (v: ToBufferInputTypes): Buffer {
  ...
  if (typeof v === 'number') {
    return intToBuffer(v)
  }
}

// ethjs-util
// https://github.com/ethjs/ethjs-util/blob/e9aede668177b6d1ea62d741ba1c19402bc337b3/src/index.js#L39
/**
 * Converts a `Number` into a hex `String`
 * @param {Number} i
 * @return {String}
 */

function padToEven(value) {
  var a = value; // eslint-disable-line

  if (typeof a !== 'string') {
    throw new Error(`[ethjs-util] while padding to even, value must be string, is currently ${typeof a}, while padToEven.`);
  }

  if (a.length % 2) {
    a = `0${a}`;
  }

  return a;
}

function intToHex(i) {
  var hex = i.toString(16); // eslint-disable-line

  return `0x${hex}`;
}

/**
 * Converts an `Number` to a `Buffer`
 * @param {Number} i
 * @return {Buffer}
 */
function intToBuffer(i) {
  const hex = intToHex(i);

  return new Buffer(padToEven(hex.slice(2)), 'hex');
}

从代码中可以发现,如果传入的是一个浮点数,那么它会被先按十六进制转成字符串,补0使得字符串长度为偶数后用来生成对应的Buffer。例如1.53125在toString(16)就会变为’1.88’,到这一步为止一个浮点数被变为字符串。

1
2
3
var a = 1.53125
a.toString(16) 
'1.88'

下一步才是真正的问题所在。

问题根本

下一步就是这个Buffer如何生成的了。因为Buffer是Node.js中数据类型,所以在浏览器中一般会引入响应的polyfill,用的最多的是feross/buffer, ethjs-util正是使用的是它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// feross/buffer
// https://github.com/feross/buffer/blob/master/index.js#L828:10
.. 
function hexWrite (buf, string, offset, length) {
  offset = Number(offset) || 0
  const remaining = buf.length - offset
  if (!length) {
    length = remaining
  } else {
    length = Number(length)
    if (length > remaining) {
      length = remaining
    }
  }

  const strLen = string.length

  if (length > strLen / 2) {
    length = strLen / 2
  }
  let i
  for (i = 0; i < length; ++i) {
    const parsed = parseInt(string.substr(i * 2, 2), 16)
    if (numberIsNaN(parsed)) return i
    buf[offset + i] = parsed
  }
  return i
}

对于encoding为’hex’的字符串,feross/buffer对调用上述的hexWrite来生成Buffer。关键点来了,这个函数是按两个字符为间隔来调用parseInt方法来进行转换。例如’1.88’生成的Buffer是[1,136], ‘01.8’生成的Buffer则是[1], 为什么是这样呢?

因为经过按两位分割后,”1.”会被转换为1 “.8”则会返回NaN导致函数退出。MDN文档中其实已经描述的非常清楚。

If parseInt encounters a character that is not a numeral in the specified radix, it ignores it and all succeeding characters and returns the integer value parsed up to that point. parseInt truncates numbers to integer values. Leading and trailing spaces are allowed.

好了,至此1.53125这个浮点数就变成了Buffer[1,136] 转化为整数为392。但是如果是1.5的话则为Buffer[1]也就1。

1
2
1.53125 => intToHex => '1.88' => new Buffer(padToEven(hex.slice(2)), 'hex') => Buffer[1,136] // 392
1.5 => intToHex => '1.8' => new Buffer(padToEven(hex.slice(2)), 'hex') => Buffer[1] // 1

这样1.53125就摇身一变成为了392,这就天价交易费的由来,一个浮点数的巨变!

到底想用多少交易费?

回到那笔天价的交易来说来说,到底最初设定了多少手续费呢?最后我们尝试推测一下。从链上取得交易的数据,我们发现maxPriorityFeePerGas的值为bd28c8360cb333, 我们已经知道了这是一个错误的浮点数巨变后的值,根据上边的分析原理,小数点后的值可能为”0cb333”。 整数部分大概为bd28c836,转换为整数为3173566518,3Gwei左右感觉相对合理。

This post is licensed under CC BY 4.0 by the author.

好奇心, 优秀软件工程师的内核

惊鸿一瞥:捕猎以太坊黑暗森林中的'那只怪兽'

Comments powered by Disqus.