age:一个现代化的加密工具

age:一个现代化的加密工具

age 是 golang crypto 库的维护者之一的 FiloSottile 写的一个现代化加密工具,目前正处在 Beta 阶段。

age 的是 Actual Good Encryption 的缩写,有点 PGP(Pretty Good Privacy) 的意思,说起来作者本身的想法就是想替代GnuPG

age 的现代化体现在密码学算法的选择上,age 使用 x25519 作为非对称加密算法,x25519 是 Curve25519 被设计用于密钥交换的曲线,是目前公认的最快的椭圆密码曲线,它还有个用做签名的 ed25519 的兄弟,二者的公私钥可以互相转换

对称加密算法选择上,age 使用 chacha20poly1305 ,现在已经是 TLS1.3 推荐对称加密算法,这个密码套件由两个算法构成:ChaCha20,一种流式密码,提供并行处理能力;以及用作认证加密(AEAD)的Poly1305。

age 的密钥也就是 x25519 的密钥,密钥的格式使用比特币bech32方式存储,bech32 相比 base58 编码提供更小的空间占用和更快的校验方式。

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
import "filippo.io/age/internal/bech32"

// 在 age 中私钥的别称是 Identity
type X25519Identity struct {
secretKey, ourPublicKey []byte
}

func (i *X25519Identity) String() string {
s, _ := bech32.Encode("AGE-SECRET-KEY-", i.secretKey)
return strings.ToUpper(s)
}

func (i *X25519Identity) Recipient() *X25519Recipient {
r := &X25519Recipient{}
r.theirPublicKey = i.ourPublicKey
return r
}

// 在 age 中公钥的别称是 Recipient
type X25519Recipient struct {
theirPublicKey []byte
}

func (r *X25519Recipient) String() string {
s, _ := bech32.Encode("age", r.theirPublicKey)
return s
}

在命令行下只需要运行 age-keygen 即可生成一个新公私钥对:

1
2
3
4
$ age-keygen
# created: 2020-01-11T22:53:47+08:00
# public key: age1sr534qzh3q408qmzkeamu7qux3l544fwwyluneks9f2ljjvdgpqqcfrgny
AGE-SECRET-KEY-1LTU2MHXLUJZDVANL949U694MH5PJ909KSQERSDE2TP3GTSRZSGYSNV3Y5N

在 x25519 下为了加密通常需要使用 ECDH,使用己方私钥和对方公钥计算出共享密钥,然后使用共享密钥密码进行加密。不过如果己方长期使用的私钥泄露,那么所有的历史消息都是有可能被破解的。为了保证前向安全,加密密钥,在 age 中称之为 fileKey,需要是临时生成的,只用做一次性加密。为了共享这个 filekey,可以使用 ECDH 共享密钥加密 filekey,那么接收方也能计算出共享密钥来得到真正加密密钥。加密 filekey 的这个操作称之为 Wrap,所以加密 fileKey 的加密密钥也称之为 wrappingKey。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// internal/age/x25519.go

// 己方密钥不参与加密流程
// 那么这里用作 ECDH 的 x25519 key 也需要是临时生成的

// 生成一对 x25519 临时私钥
ephemeral := make([]byte, curve25519.ScalarSize)
if _, err := rand.Read(ephemeral); err != nil {
return nil, err
}

// 根据私钥计算临时公钥
ourPublicKey, err := curve25519.X25519(ephemeral, curve25519.Basepoint)
if err != nil {
return nil, err
}

// 计算 ECDH 共享密钥
var r *X25519Recipient
sharedSecret, err := curve25519.X25519(ephemeral, r.theirPublicKey)
if err != nil {
return nil, err
}

临时公钥会放入加密内容中,这样接收方也能使用自己的私钥计算出真正的 wrappingKey。为了保证 wrappingKey 的随机性,这里的共享密钥不是 wrappingKey,需要做一次 HKDF 后得到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// internal/age/x25519.go

const x25519Label = "age-encryption.org/v1/X25519"

salt := make([]byte, 0, len(ourPublicKey)+len(r.theirPublicKey))
// 使用我方临时公钥和对方公钥作为 HKDF 盐
salt = append(salt, ourPublicKey...)
salt = append(salt, r.theirPublicKey...)
// 使用 ECDH 公钥密钥 secret 材料生成最终的 filekey 加密密钥 wrappingKey
h := hkdf.New(sha256.New, sharedSecret, salt, []byte(x25519Label))
wrappingKey := make([]byte, chacha20poly1305.KeySize)
if _, err := io.ReadFull(h, wrappingKey); err != nil {
return nil, err
}

经过上述一系列的操作我们得到 wrappingKey,之后就可以对 fileKey 进行加密。aeadEncrypt 是加密 filekey 的方法,其中 nonce 选择固定的全零值,这里由于只是加密 fileKey,作者说为了不要过度设计,如果 nonce 是随机的,那么还需要另外途径放在加密内容内。加密后的 fileKey 我们称之为 wrappedKey。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// internal/age/primitives.go
func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
// The nonce is fixed because this function is only used in places where the
// spec guarantees each key is only used once (by deriving it from values
// that include fresh randomness), allowing us to save the overhead.
// For the code that encrypts the actual payload, look at the
// filippo.io/age/internal/stream package.
nonce := make([]byte, chacha20poly1305.NonceSize)
return aead.Seal(nil, nonce, plaintext, nil), nil
}

为了保证消息完整性,还需要填充消息验证码。age 设计了一个类型 HTTP 的协议格式,先 header 后 body ,MAC 就放在 header 中。

header 第一行为版本信息,现在固定为 age-encryption.org/v1

接下来是临时公钥信息,使用 -> 开头字符串来标志,然后紧接着一个空格加上 Type 和 Args,对于 x25519 方式加密而言,Type 是 X25519,Args 是不带填充的 base64 编码的公钥信息,除了 x25519 ,age 还支持 RSA,scrypt,ed25519(间接转换为x25519)等加密方式。

接下来是 wrappedKey ,也是进行 base64 进行编码,如果过长会进行换行。

header 的最后是 footer,由 --- 开头,至此是所有计算 HMAC 的内容,计算 MAC 后放入后面。如下所示:

1
2
3
4
5
6
age-encryption.org/v1
-> X25519 7hjWVZhiYlh0vvIOt+gvV4WDI2yLWsr+JOIoPBSSfVA
bxtayTNuMQ+gdYgO7MaebFFTVj/SAwxWVNSCabITY64
-> X25519 BQ/dREFj+hbGVyxzSReDqtn15yVvAu5zqDyGa9cQxko
MLWTEcNlj7LThMSZK4P4bkoWakUYjiOK7rYQ3Z6gUTw
--- GGPEM7/pB9b3FpzJiym0t3wCnC7cQw/LgVjeilNkKl8

计算 MAC 是通过 HMAC-With-SHA256 进行,不对 body 进行 MAC 是因为我们使用 AEAD 加密内容,不需要额外的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func headerMAC(fileKey []byte, hdr *format.Header) ([]byte, error) {
// 通过 filekey 计算 mac key
h := hkdf.New(sha256.New, fileKey, nil, []byte("header"))
hmacKey := make([]byte, 32)
if _, err := io.ReadFull(h, hmacKey); err != nil {
return nil, err
}
hh := hmac.New(sha256.New, hmacKey)
// 下面这个过程是计算 header 的过程,内容参考上述 header 结构
if err := hdr.MarshalWithoutMAC(hh); err != nil {
return nil, err
}
return hh.Sum(nil), nil
}

body 是存放加密内容的地方。这里计算方式是 chacha20poly1305 的过程,为了得到最终的加密密钥,这里生成了同样 16 字节的 nonce,与 fileKey 进行 HKDF 混合后得到。

1
2
3
4
5
6
7
8
func streamKey(fileKey, nonce []byte) []byte {
h := hkdf.New(sha256.New, fileKey, nonce, []byte("payload"))
streamKey := make([]byte, chacha20poly1305.KeySize)
if _, err := io.ReadFull(h, streamKey); err != nil {
panic("age: internal error: failed to read from HKDF: " + err.Error())
}
return streamKey
}

body 先写入 nonce 后,后续使用流式方式加密并写入。

这个就是 age x25519 的加密方式的所有内容,解密最重要的计算 filekey 的过程,这个上述有说过,这里不再赘述。(TODO:或许以后会写)

命令行工具进行加解密也十分简单:

1
2
3
$ head -c 32 /dev/urandom | base64 > plain.txt # 生成一个文本并保存到 plain.txt
$ age -r [RECIPIENT PUBKEY] -o cipher.txt plain.txt # 加密 plain.txt 文件并保存到 cipher.txt
$ age -i [IDENTITY PRVKEY] -o decrypt.txt -d cipher.txt # 解密 cipher.txt 并保存到 decrypt.txt

age 加密也支持读取 stdin 数据:

1
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age

也可以制定 -a 参数加密为 base64 格式数据:

1
$ age -r [RECIPIENT PUBKEY] -a -o cipher.txt plain.txt

如果要输出到 stdout 需要指定 -

1
$ age -r [RECIPIENT PUBKEY] -a -o - plain.txt

总之 age 是个非常简单而且现代化的加密工具,但很遗憾的是,age只提供加解密,并不提供签名功能,加密流程也没有提供签名,只能可以在确认发送方身份时使用。

备注

Why HKDF

HKDF 遵循“先提取后扩展”的模式,其中KDF逻辑上由两个模块组成。第一阶段采用输入密钥材料并从中“提取”固定长度的伪随机密钥K。第二阶段将密钥K“扩展”为多个附加的伪随机密钥(KDF的输出)。在许多应用中,输入密钥材料不一定均匀分布,攻击者可能对其有部分了解(例如,由密钥交换协议计算的Diffie-Hellman值),甚至对其有部分控制(如在一些熵收集应用中)。因此,“提取”阶段的目标是将输入密钥材料的可能分散的熵“集中”成一个短的、但加密性强的伪随机密钥。在某些应用中,输入可能已经是一个很好的伪随机密钥;在这些情况下,不需要“提取”阶段,“扩展”部分可以单独使用。第二阶段将伪随机密钥“扩展”到所需的长度;输出密钥的数量和长度取决于需要密钥的特定加密算法。RFC

Why not RSA

RSA 仍被广泛使用,但是有个已知安全问题,PKCS#1.5签名密钥也是OAEP的加密密钥。另外RSA的安全强度也不如椭圆曲线,3072位RSA密钥的加密强度才等同于256位的ECC密钥的水平。

谷歌和苹果发布的COVID-19密切接触者追踪应用是如何保护用户隐私的?

最近谷歌和苹果公司作为竞争对手不常见的联合起来,发布了一个COVID-19 Contract Tracing应用,也就是密切接触者追踪应用。其实现原理并不复杂,简单而言就是用户使用蓝牙将个人相关信息传递附近的手机上,如果有人确诊了,可以将这个人的信息上传到服务端,然后再推送给其它相关手机上,这样就能判断一个人是否有感染风险。

截屏2020-04-19 上午10 16 00

为了隐私保护,苹果和谷歌都明确表示不会上传手机用户身份信息,而且还有位置信息,在相关文档上有说明具体的隐私保护实现细节,本文就简单说明其实现原理。

截屏2020-04-19 上午10 16 29

在Contract Tracing中会生成三个密钥,只有第一个密钥是固定的,作为主密钥(master key),其它两个都是通过前一个密钥用时间为变量进行密钥衍生而不断变化,这样就保证了信息混淆,外部无法简单的根据共享信息推论到个人。

任意一个确诊的密钥所有者只要给出部分时间段的第二个密钥,其它用户就能通过接触时间推断第三个密钥,通过第三个密钥就能得到确诊者与自己的相关性,进而得出自己是否为密切接触者。

截屏2020-04-19 下午5 12 33

追踪密钥(TracingKey)是一个32字节的密码学安全随机数,并作为唯一身份凭证,而不是每部手机自带的 UDID 强关联用户,32字节密钥空间在 2**256 内,设备间密钥重复的概率极低。当然这个追踪密钥仅保存在本机,不会上传到服务端,也不会传播给其它用户设备。

1
TracingKey = CRNG(size=32)

然后通过密钥衍生函数生成一个24小时内固定的每日追踪密钥(DailyTracing Key ),这个密钥也不会广播给其它设备,只有用户确诊时才需要被上传到服务端。

如下所示,保证每天都是一个同一个密钥。Di 是第 i 天的值,并被序列化成小端存储的 32 位数据,生成 16 字节的原因是为了减少用户设备和服务器的存储压力。

1
2
Di = UnixEpochTime / (60 * 60 * 24)
DTKi = HKDF(key=TracingKey , salt=NULL, info=(UTF8("CT-DTK")||Di),size=16)

最终广播给其它设备的是“轮换接触标志符”(Rolling Proximity Identifier,简称 RPI),RPI 每10分钟一个周期(TimeIntervalNumber简称 TIN)通过 HMAC 生成一次,这样一天大概有 6*24 = 144 个值。

那么第 i 天的第 j 次的 RPI 计算公式可以通过如下方式得到,经过 HMAC 后,再取前 16 自己进行广播,是不是有点像 OTP 的生成方式?

1
2
3
Seconds =  UnixEpochTime % (60 × 60 × 24)
TIN = Seconds / (60*10)
RPI(i,j) = Truncate(HMAC( key=DKTi, Data=(UTF8("CT-RPI")||TINj)),16)

用户会在不间断的广播 RPI,其它用户接收到后存储在本地,按照需要 10k 数量存储来估算,最多用户会存储到 156.25 KB 的数据量,再加上时间更长(比如15天以上)的数据可以清理,其实用户手机存储的负担并不大。

这样三个密钥,TracingKey 唯一标志用户身份,只有所有者知道;DailyTraceKey 每天不断变化,只有用户感染周期内可以被上传到服务器和下发到其它设备;RPI 每 10 分钟变化一次,可以安全在用户设备间共享。

如果用户被感染时,那么可以将近期他人可能被感染的日期的 DailyTraceKey 子集计算出来,文档中称之为 DiagnosisKeys,与相关日期时间 DayNumbers 一并上传到服务端(DiagnosisServer),而健康的用户无需上传这些数据。

DiagnosisKeys 会推送到到其它手机设备,其它手机设备本地通过 DayNumbers 计算得到 RPI,如果本地有缓存的匹配到 RPI,那么证明曾经与确诊者接触过,有被感染的风险。

整个过程服务端仅记录了相关的 DiagnosisKeys 和 DayNumbers,也没有参与用户 RPI 匹配过程,所以这个过程是隐私友好的。

这样子,用户不需要提供位置信息,也不需要进行身份认证,当然这个也是自愿的,如果每个人都安装的话,也能防止病毒进一步的传播,保护自己也保护了他人。

Go linter: io.ReadFull 不需要判断返回值长度

io.ReadFull 返回从Reader读取的字节数量和错误信息

1
ReadFull(r Reader, buf []byte) (n int, err error)

ReadFull 经常出现在其它辅助函数中,比如随机数生成函数 rand.Read

1
2
3
4
5
6
7
8
9
package rand

import "io"

var Reader io.Reader

func Read(b []byte) (n int, err error) {
return io.ReadFull(Reader, b)
}

除了判断 err 是否不为空之外,经常有代码还会判断这个 n 的长度是否和 b 的长度一样,其实这是没必要的,io.ReadFull 内部保证了如果 err == nil 那么 n == len(b)。

如实现代码所示,如果 Reader 内字节长度小于所需长度那么一定会返回 EOF 错误或者 ErrUnexpectedEOF 错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func ReadFull(r Reader, buf []byte) (n int, err error) {
return ReadAtLeast(r, buf, len(buf))
}

func ReadAtLeast(r Reader, buf []byte, min int) (n int, err error) {
if len(buf) < min {
return 0, ErrShortBuffer
}
for n < min && err == nil {
var nn int
nn, err = r.Read(buf[n:])
n += nn
}
if n >= min {
err = nil
} else if n > 0 && err == EOF {
err = ErrUnexpectedEOF
}
return
}

使用下面实际代码运行来看,确实不需要判断返回长度

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
package main

import (
"bytes"
"fmt"
"io"
)

func main() {
// 无数据 0 EOF
{
src := bytes.NewReader(make([]byte, 0))
dsc := make([]byte, 40)
fmt.Println(io.ReadFull(src, dsc))
}

// 数据不够 32 unexpected EOF
{
src := bytes.NewReader(make([]byte, 32))
dsc := make([]byte, 40)
fmt.Println(io.ReadFull(src, dsc))
}

// 数据正好 32 <nil>
{
src := bytes.NewReader(make([]byte, 32))
dsc := make([]byte, 32)
fmt.Println(io.ReadFull(src, dsc))
}

// 数据更多 32 <nil>
{
src := bytes.NewReader(make([]byte, 40))
dsc := make([]byte, 32)
fmt.Println(io.ReadFull(src, dsc))
}
}

Go slice 技巧

翻译: https://github.com/golang/go/wiki/SliceTricks

追加

1
a = append(a, b...)

复制

1
2
3
4
5
6
b = make([]T, len(a))
copy(b, a)
// or
b = append([]T(nil), a...)
// or
b = append(a[:0:0], a...) // See https://github.com/go101/go101/wiki

切割(cut)

1
a = append(a[:i], a[j:]...)

删除

1
2
3
a = append(a[:i], a[i+1:]...)
// or
a = a[:i+copy(a[i:], a[i+1:])]

不保护删除

1
2
a[i] = a[len(a)-1] 
a = a[:len(a)-1]

注意:如果元素类型是指针或者一个结构体内含有指针,上述的切割和删除会有内存泄露的风险,被删除的元素仍然被 a 引用,而不被垃圾回收处理,下面的代码可以处理这些问题:

切割(内存安全)

1
2
3
4
5
copy(a[i:], a[j:])
for k, n := len(a)-j+i, len(a); k < n; k++ {
a[k] = nil // 或 T 的零值
}
a = a[:len(a)-j+i]

删除(内存安全)

1
2
3
4
5
if i < len(a)-1 {
copy(a[i:], a[i+1:])
}
a[len(a)-1] = nil // 或 T 的零值
a = a[:len(a)-1]

不保护顺序删除(内存安全)

1
2
3
a[i] = a[len(a)-1]
a[len(a)-1] = nil
a = a[:len(a)-1]

扩张(expand)

1
a = append(a[:i], append(make([]T, j), a[i:]...)...)

扩大(extend)

1
a = append(a, make([]T, j)...)

过滤

1
2
3
4
5
6
7
8
n := 0
for _, x := range a {
if keep(x) {
a[n] = x
n++
}
}
a = a[:n]

插入

a = append(a[:i], append([]T{x}, a[i:]…)…)

注意:第二个 append 会创建一个新底层存储的 slice,并复制之前 a[i:] 的元素到新 slice 内,然后再复制到 a 中。可以使用下面方式避免二次复制。

s = append(s, 0 /* use the zero value of the element type */)
copy(s[i+1:], s[i:])
s[i] = x

插入slice

a = append(a[:i], append(b, a[i:]…)…)

栈模式(push)

a = append(a, x)

栈模式(pop)

x, a = a[len(a)-1], a[:len(a)-1]

栈模式(Push Front/Unshift)

a = append([]T{x}, a…)

栈模式(Pop Front/Shift)

x, a = a[0], a[1:]

无内存分配过滤

下面方式重用了原有内存。

1
2
3
4
5
6
b := a[:0]
for _, x := range a {
if f(x) {
b = append(b, x)
}
}

如果元素必须进行垃圾回收,那么可以再使用下面方式:

1
2
3
for i := len(b); i < len(a); i++ {
a[i] = nil // or the zero value of T
}

翻转

1
2
3
4
for i := len(a)/2-1; i >= 0; i-- {
opp := len(a)-1-i
a[i], a[opp] = a[opp], a[i]
}

或者

1
2
3
for left, right := 0, len(a)-1; left < right; left, right = left+1, right-1 {
a[left], a[right] = a[right], a[left]
}

洗牌算法

go1.10 之后可以使用 math/rand.Shuffle

1
2
3
4
for i := len(a) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
a[i], a[j] = a[j], a[i]
}

批处理

1
2
3
4
5
6
7
8
actions := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
batchSize := 3
batches := make([][]int, 0, (len(actions) + batchSize - 1) / batchSize)

for batchSize < len(actions) {
actions, batches = actions[batchSize:], append(batches, actions[0:batchSize:batchSize])
}
batches = append(batches, actions)

结果

1
[[0 1 2] [3 4 5] [6 7 8] [9]]

去重

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import "sort"

in := []int{3,2,1,4,3,2,1,4,1} // any item can be sorted
sort.Ints(in)
j := 0
for i := 1; i < len(in); i++ {
if in[j] == in[i] {
continue
}
j++
// preserve the original data
// in[i], in[j] = in[j], in[i]
// only set what is required
in[j] = in[i]
}
result := in[:j+1]
fmt.Println(result) // [1 2 3 4]

Go: 清空 Slice 的两种方法:[:0]和nil

如果要清空一个slice,那么可以简单的赋值为nil,垃圾回收器会自动回收原有的数据。

1
2
3
a := [1,2,3]
a = nil
fmt.Println(a, len(a), cap(a) // [] 0 0

nil slice 和普通 slice一样可以使用 cap len 内置函数,以及被 for range 遍历。本质和 empty slice 性质一样,零长度和零容量,当然也可以使用 append 操作。

但是如果还需要使用 slice 底层内存,那么最佳的方式是 re-slice:

1
2
3
4
a := [1,2,3]
a = a[:0]
fmt.Println(a, len(a), cap(a) // [] 0 3
fmt.Println(a[:1]) // [1]

不过如果序列化成 json 时候,上述二者就不太相同了,nil slice 会编码成 null,而 empty slice 会编码成 []

Go: big.Int 常用辅助函数

深拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
"math/big"
)

func main() {
var a = big.NewInt(100)
var b = new(big.Int).Set(a)
a.Add(a, big.NewInt(100))
fmt.Println(a, b) // 200 100
}

判断值是否大于、小于或等于 0

如果 x < 0 返回 -1
如果 x == 0 返回 0
如果 x > 0 返回 +1

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"fmt"
"math/big"
)

func main() {
var a = big.NewInt(1)
var b = big.NewInt(-1)
fmt.Println(a.Sign(), b.Sign())
}

如果判断是否为 0 那么也可以用这个,如果 BitLen() 返回 0 那么就是值为 0

1
2
var a = new(big.Int)
fmt.Println(a.BitLen()) // Output: 0

转化为固定长度的 bytes

转化为 bytes 很简单,直接使用 Bytes() 即可,但转化的 bytes 只有值的最小字节长度。

1
2
var a = big.NewInt(1)
fmt.Println(hex.EncodeToString(a.Bytes())) // Output: 01

如果要转化为固定长度,在 go 1.15 可以使用 FillBytes() 方法,这个比较配合适合 crypto 库使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"encoding/hex"
"fmt"
"math/big"
)

func main() {
a := big.NewInt(1)
v := a.FillBytes(make([]byte, 32))
fmt.Println(hex.EncodeToString(v))
// Output:
// 0000000000000000000000000000000000000000000000000000000000000001
}

json.Number

bigint 进行 json 序列化的时候默认转化为 json.Number(float64),但是值如果过大,那么会精度损失,最佳的方式转化为 decimal string 即可。这个比较复杂,我这里封装了一个库,可以直接使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"encoding/json"
"fmt"

"github.com/islishude/bigint"
)

func main() {
a := bigint.New(100)
data, err := json.Marshal(a)
if err != nil {
panic(err)
}
fmt.Println(string(data)) // "100"
}

当然也直接 hex string json 反序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"encoding/json"
"fmt"

"github.com/islishude/bigint"
)

func main() {
type Object struct {
Field bigint.Int
}

var d Object
_ = json.Unmarshal([]byte(`{"Field": "0x3"}`), &d)
fmt.Println(d.Field) // 3

var e Object
_ = json.Unmarshal([]byte(`{"Field": "0x"}`), &e)
fmt.Println(e.Field) // <nil>
}

另外这个库也实现了 driver.Valuersql.Scanner,适用在数据库序列化使用。

1
2
3
4
5
6
7
8
9
// sql.Scanner
var i Int
_ = db.QueryRow("SELECT i FROM example WHERE id=1;").Scan(&i)

// driver.Valuer
var i bigint.Int
_ = db.Exec("INSERT INTO example (i) VALUES (?);", i) // nullable
i = bigint.New(1024)
_ = db.Exec("INSERT INTO example (i) VALUES (?);", i)

使用中转合约减少以太坊多笔转账Gas使用

以太坊中普通交易至少需要 21000 Gas 而合约 transfer 方法仅需要 2300 Gas ,这样可以利用这点节省费用。

部署一个代理合约,使用这个代理合约进行转账,另外也不需要先转入再转出,直接使用合约进行中转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pragma solidity ^0.6.0;

pragma experimental ABIEncoderV2;

contract Sender {
struct Payment {
address payable to;
uint256 value;
}

function multi(Payment[] calldata payments) external payable {
for (uint256 i = 0; i < payments.length; ++i) {
Payment calldata item = payments[i];
item.to.transfer(item.value);
}
}
}

在 Goerli 网络上进行测试,发送了 20 笔转账仅使用了
211,093 Gas 而要是传统转账则需要 20 * 21000 = 420,000,差不多节省了50%的 Gas 消耗。

所以中转合约特别适合小额转账,将多比小额转账合并一起,不仅节省手续费,而且还提高了以太坊网络的效率。实际使用在转出开始2笔包含及以上就可以节省矿工费使用。

不过需要注意的是,如果目标地址如果没有被初始化,那么会消耗更多的gas,因为地址在之前没有转入过 ETH,那么需要初始化这个地址的状态。根据黄皮书的描述,需要先消耗 25000 gas 创建这些地址,然后才花费 2300 gas 完成转账。实际上会比普通转账所花费 21000 gas 要多 6300 gas。

例如这个交易,发送给10个之前没有任何状态的地址,gas 消耗了 331981,比上面节省 gas 情况下多了10万多。不过第二次就会小很多,恢复到上文说明的节省手续费的状态。

另外需要注意的是,在使用这个合约的时候,这里接收地址参数必须非合约地址,因为接收地址是合约地址时,那么所需 gas 就不一定是 2300 了,如果要兼容这种情况需要改一下合约,使用 call 并指定金额 value,而不限定 gas。

1
2
3
4
5
6
7
function multi(Payment[] calldata payments) external payable {
for (uint256 i = 0; i < payments.length; ++i) {
Payment calldata item = payments[i];
(bool success,) = item.to.call{value: item.value}(new bytes(0));
require(success, "transfer failed");
}
}

如果在商业项目中,如果我们能明确接收地址一定不是合约地址,那么就不需要这种形式。如果转出地址是其它方地址,那么需要注意防范重入攻击。如果我们发起方始终都是外部账户,那么这样最简单的方式直接判断发送方是不是主动调用者。

1
2
3
4
5
6
7
8
9
10
11
function multi(Payment[] calldata payments) external payable {
// 禁止非交易调用方调用
if (msg.sender != tx.orgin) {
return;
}
for (uint256 i = 0; i < payments.length; ++i) {
Payment calldata item = payments[i];
(bool success,) = item.to.call{value: item.value}(new bytes(0));
require(success, "transfer failed");
}
}

如果发起方是合约账户,我们可以加上锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint256 private locked = 0;

function multi(Payment[] calldata payments) external payable {
if (locked == 1) {
return;
}
locked = 1;
for (uint256 i = 0; i < payments.length; ++i) {
Payment calldata item = payments[i];
(bool success,) = item.to.call{value: item.value}(new bytes(0));
require(success, "transfer failed");
}
locked = 0;
}

在实际应用中,也可以加上事件,外部应用能更好的捕捉到成功转出,不过这也增加gas消耗。

1
2
3
4
5
6
7
8
9
10
event Transfer(address to, uint256 amount);

function multi(Payment[] calldata payments) external payable {
for (uint256 i = 0; i < payments.length; ++i) {
Payment calldata item = payments[i];
(bool success,) = item.to.call{value: item.value}(new bytes(0));
require(success, "transfer failed");
emit Transfer(item.to, item.value);
}
}

最后一提,这里使用 calldata 而不是 memory 来定义引用类型位置可以节省 gas 消耗。

Calldata is a non-modifiable, non-persistent area where function arguments are stored, and behaves mostly like memory. It is required for parameters of external functions but can also be used for other variables.If you can, try to use calldata as data location because it will avoid copies and also makes sure that the data cannot be modified. https://solidity.readthedocs.io/en/v0.7.1/types.html#data-location

如上述文档所述,不过在实际测试中,使用了Solidity编译优化之后,Gas 节省效果并不那么明显,不过还是节省了一些。

k8s 国内源安装备忘清单

注意以下命令,需要切换到 root 后运行

系统:ubuntu 16.04

安装 docker

首先确定已经安装完成 docker,如果没有安装可以使用以下脚本快速安装并配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
curl -fsSL https://get.docker.com | sudo sh -s -- --mirror Aliyun
sudo usermod -aG docker $USER
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
"exec-opts": ["native.cgroupdriver=systemd"],
"log-driver": "json-file",
"log-opts": {
"max-size": "100m"
},
"storage-driver": "overlay2",
"registry-mirrors": ["https://t9ab0rkd.mirror.aliyuncs.com"]
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker

安装 k8s 套件

1
2
3
4
5
6
7
8
9
10
11
12
# 添加并信任APT证书
curl https://mirrors.aliyun.com/kubernetes/apt/doc/apt-key.gpg | sudo apt-key add -

# 添加源地址
add-apt-repository "deb https://mirrors.aliyun.com/kubernetes/apt/ kubernetes-xenial main"

# 更新源并安装最新版 kubenetes
sudo apt update && apt install -y kubelet kubeadm kubectl

# 添加 completion,最好放入 .bashrc 中
source <(kubectl completion bash)
source <(kubeadm completion bash)

关闭 swap

为了性能考虑,k8s 需要关闭 swap 功能,然后重启主机。

/etc/fstab 中找到带有 swap 的那一行,注释掉。

1
2
$ vim /etc/fstab
# UUID=9224d95f-cd87-4b56-b249-3dc7de4491d3 none swap sw 0 0

启动 master 节点:

1
kubeadm init --image-repository='registry.cn-hangzhou.aliyuncs.com/google_containers'

--image-repository 指定控制平面容器镜像地址,这里使用aliyun镜像,而不是默认的 k8s.gcr.io,这样就能避免下载失败。

如果 init 失败,检查是否关闭 swap、 用户是否为 root 以及是否下载好核心组件镜像(可能得网络的问题)、是否为至少 2G 内存,然后运行 kubeadm reset 接着再 kubeadm init

配置读取路径

1
2
# append to .bashrc
export KUBECONFIG=/etc/kubernetes/admin.conf

安装网络插件

1
kubectl apply -f "https://cloud.weave.works/k8s/net?k8s-version=$(kubectl version | base64 | tr -d '\n')"

加入 worker

worker 节点加入 master,使用 kubeadm init 最后提示的命令在 worker 上运行

1
2
3
4
kubeadm join 192.168.199.117:6443 \
--token y8l6qv.oj2hxua9szguei23 \
--discovery-token-ca-cert-hash \
sha256:bae71d8fb4a26c5f29a6df2db037e08e581fcb344ff85089a603e3eeb9d6d26f

其中 --token 是临时的生成,可以通过下面命令获取

1
2
3
$ kubeadm token list
TOKEN TTL EXPIRES USAGES DESCRIPTION EXTRA GROUPS
y8l6qv.oj2hxua9szguei23 23h 2019-09-09T12:04:27+08:00 authentication,signing The default bootstrap token generated by 'kubeadm init'. system:bootstrappers:kubeadm:default-node-token

这个 token 24小时后会自动过期,不过可以再次获取

1
2
$ kubeadm token create
czlboe.16mkdt47tkhd0714

--discovery-token-ca-cert-hash 指的是 CA 证书的哈希值,那么可以使用这种方式获取:

1
2
$ openssl x509 -pubkey -in /etc/kubernetes/pki/ca.crt | openssl rsa -pubin -outform der 2>/dev/null | sha256sum | awk '{print $1}'
3e77f845edf944d76234a6d78dde3e5bae3e50261362b1d8cc8d025ac97136b0

查看 nodes

在 master 节点上运行

1
kubectl get nodes

minikube国内源

minikube文档页面,选择操作系统,然后下载 minikube,注意版本号。

k8s-kubectl页面下载 kubectl 并放在$PATH下,注意版本号。

下载安装 virtualbox。

启动命令:

1
2
3
4
5
6
minikube start \
--vm-driver=virtualbox \
--image-mirror-country=cn \
--registry-mirror='https://t9ab0rkd.mirror.aliyuncs.com' \
--image-repository='registry.cn-hangzhou.aliyuncs.com/google_containers' \
--iso-url='https://kubernetes.oss-cn-hangzhou.aliyuncs.com/minikube/iso/minikube-v1.5.1.iso'

–image-mirror-country cn 将缺省利用 registry.cn-hangzhou.aliyuncs.com/google_containers 作为安装Kubernetes的容器镜像仓库,
–iso-url=*** 利用阿里云的镜像地址下载相应的 .iso 文件
–cpus=2: 为minikube虚拟机分配CPU核数
–memory=2000mb: 为minikube虚拟机分配内存数
–kubernetes-version=***: minikube 虚拟机将使用的 kubernetes 版本

官方源

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
# k8s
curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add
apt-add-repository "deb http://apt.kubernetes.io/ kubernetes-xenial main"

# docker
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -
add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

# apt安装
apt-get update && apt-get install docker-ce kubeadm

cat > /etc/docker/daemon.json <<EOF
{
"exec-opts": ["native.cgroupdriver=systemd"],
"log-driver": "json-file",
"log-opts": {
"max-size": "100m"
},
"storage-driver": "overlay2"
}
EOF

mkdir -p /etc/systemd/system/docker.service.d

systemctl daemon-reload
systemctl restart docker

使用以太坊 CREATE2 操作码实现在线支付系统

假设我们有个一个支持 ETH 支付的线上商城,支付系统怎么设计?

因为 ETH 支持合约,我们可以设计一个单地址合约,让顾客扫描预定义的二维码,二维码含有订单id,当支付成功后发送一个 Pay 事件,订单系统只要监听这个事件即可。不过我们需要设计一整套的系统,包括二维码标准、合约系统等等,而且不能兼容交易所/钱包的转账提现操作。

1
2
3
4
5
6
7
8
9
pragma solidity ^0.6.0;

contract Payment {
constructor() public {}

event Pay(bytes32 orderId, uint256 value );
function pay(bytes32 orderId) public payable {
}
}

为了兼容已有的转账提现操作,我们需要直接使用地址收款操作,为每一个订单提供一个地址,这个可以使用 bip32 实现,使用一个助记词生成多个 xpub 扩展公钥,然后就可以安全的生成新地址进行收款,这种方式需要集中托管商户的钱。

如果我们要支持 C2C 这种模式,而且卖家不想将钱托管在我们这里,也就是更去中心化些,基于 bip32 的方式实现并不好。那么让商家保存自己 bip32 可以吗?这个可以的,这里商家需要做的就是安全的保存好助记词。

使用 bip32 构建支付系统的很多,比如 bitpay 的比特币在线支付协议。

除此之外,在以太坊上卖家可以类似 bip32 自己构建一个工厂合约,用于创建新的支持收款合约,买家付款后,卖家监听这个这个合约的地址余额变化。

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
pragma solidity ^0.6.0;

contract Payment {
address payable public dst;
constructor(address payable _dst) public {
dst = _dst;
}

function flush() public {
dst.transfer(address(this).balance);
}
fallback() external payable {}
}

contract Factory {
address payable public owner;

constructor() public {
owner = msg.sender;
}

function create() public returns (address){
require(msg.sender == owner, "403");
Payment p = new Payment(owner);
return address(p);
}
}

不过这样有个很大问题,每次付款都要生成一个新合约,卖家太浪费钱了,遇到恶意下单而不付款更损失钱。

另外还有个问题,如果买家都是善良的,不会恶意,但是创建合约账户的计算公式是 keccak256(rlp([sender, nonce])) ,这个和发送者及其 nonce 相关,而这里的 nonce 准确说是序列(sequence)并不是实际随机数,所以开发者并不能随意控制合约账户的生成,如果要生成第 100 个合约账户,还得需要先创建前 99 个。

CREATE2 解决了这两个问题,一方面允许开发者不依赖 nonce 的情况下控制账户的生成方式,另一方面开发者可以先使用,需要时再进行创建。

怎么做到的呢?其实很简单。账户标志符号,本质上就是 20 字节的随机数,无论从什么方式生成这 20 字节都可以作为账户使用。那么只要定义安全的账户生成方式即可。

CREATE2 定义了新的账户生成方式:keccak256( 0xff ++ address ++ salt ++ keccak256(init_code))[12:]。这里的 address 还是指发送者账户,可以是合约账户,也可以是外部账户;salt 是额外的数据,固定为 32 字节,开发者可以随意控制;init_code 是合约初始化代码及其参数。

CREATE2 已经在以太坊 Constantinople 分叉后上线,现在已经可以在主网和测试网上使用。

下面使用一个钱包的例子来说明,我们需要在链下进行不需要任何私钥的方式进行创建账户,然后使用这个账户进行收款,最后这些账户受一个合约来管理。

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
pragma solidity ^0.6.2;

contract Account {
address payable public reciever;
event Flush(address to, uint256 value);

constructor(address payable _reciever) public {
reciever = _reciever;
}

function flush() public {
uint256 balance = address(this).balance;
if (balance == 0){
return;
}
reciever.transfer(balance);
emit Flush(reciever, balance);
}
}

contract Wallet {
address payable public admin;
mapping(address => bool) public accounts;

event Create(address);

constructor() public {
admin = msg.sender;
}

modifier OnlyAdmin {
require(msg.sender == admin, "403");
_;
}

// 在这里创建新的 CREATE2 账户,保证 CREATE2 的地址参数始终是当前合约
function create(address payable _to, bytes32 _salt) public OnlyAdmin {
Account a = new Account{salt: _salt}(_to);
emit Create(address(a));
}
}

其中 Account a = new Account{salt: _salt}(_to); 是 Solidity 0.6.2 加入的支持 CREATE2 的语法糖,在之前的版本需要使用内联汇编实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
function create(address payable _to, uint256 salt) public {
bytes memory deploymentData = abi.encodePacked(
type(Forwarder).creationCode,
uint256(_to)
);

assembly {
let a := create2(
0x0, add(0x20, deploymentData), mload(deploymentData), salt
)
}
emit Create(a);
}

为了链外创建 CREATE2 地址,我们首先需要创建 Wallet 合约。这里部署在以太坊上的合约地址为 0x908e2d13714091fa97c7deb010080516817beaec, 稍后我们将使用这个地址。

接下来我们来创建 CREATE2 地址,单独编译 Account 合约得到 bytecode :

1
608060405234801561001057600080fd5b506040516101993803806101998339818101604052602081101561003357600080fd5b5051600080546001600160a01b039092166001600160a01b0319909216919091179055610134806100656000396000f3fe6080604052348015600f57600080fd5b506004361060325760003560e01c80636b9f96ea146037578063f4b0b75614603f575b600080fd5b603d6061565b005b604560ef565b604080516001600160a01b039092168252519081900360200190f35b4780606b575060ed565b600080546040516001600160a01b039091169183156108fc02918491818181858888f1935050505015801560a3573d6000803e3d6000fd5b50600054604080516001600160a01b0390921682526020820183905280517f12b2a0ee977e74c33898f8be30fde7ae3a32ac7409a3666da55ce77e9bc32e879281900390910190a1505b565b6000546001600160a01b03168156fea26469706673582212205e6860d5d09847eb11d1dfbfc695e3cd56b77e17f59031058e0c81b5ef8043af64736f6c63430006020033

生成 ABI 数据:

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
48
49
50
51
52
[
{
"inputs": [
{
"internalType": "address payable",
"name": "_reciever",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "value",
"type": "uint256"
}
],
"name": "Flush",
"type": "event"
},
{
"inputs": [],
"name": "flush",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "reciever",
"outputs": [
{
"internalType": "address payable",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
}
]

然后计算 init_code 的哈希:

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
package main

import (
"strings"

"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
)

func main() {
// abidata 为 Account 合约 ABI json
parsed, err := abi.JSON(strings.NewReader(abidata))
if err != nil {
panic(err)
}

// Account 合约构造函数设置了 reciever 参数
// 为了以后能生成这个地址,这个需要持久化保存
const reciever = "0x9639C636F1ECDA62c6c3d6eb8c1C4A630E184ff7"
param, err := parsed.Pack("", common.HexToAddress(reciever))
if err != nil {
panic(err)
}

// 计算 Account 合约初始化哈希
// 360c3c0304ab4f09eee311be7433387a83c3d62c7150e7654dfa339f5294eb45
inithash := Keccak256(MustHexDecode(bytecode), param)
}

这里我们自定义一个 32 字节的 salt 值,这个需要持久化保存到数据库内,然后根据上面所有参数计算新的地址

1
2
3
4
5
6
7
8
// Wallet 合约地址
address := MustHexDecode("0x908e2d13714091fa97c7deb010080516817beaec")
salt := MustHexDecode("0x844e2b5a3210a359906614364618e2991ecd95223bdaf2733ade658613540a9d")
inithash := MustHexDecode("360c3c0304ab4f09eee311be7433387a83c3d62c7150e7654dfa339f5294eb45")
// keccak256( 0xff ++ address ++ salt ++ keccak256(init_code))[12:]
addr := "0x" + hex.EncodeToString(Keccak256([]byte{0xff}, address, salt, inithash)[12:])
// 0x73026082ffa5b73dcbaa95626441bd9f7d4b64fd
fmt.Println(addr)

这个地址可以对外进行收款,如果我们想要提取地址内的钱可以取出之前持久化保存的 salt 和 reciever ,然后调用 Wallet.create ,最后调用 Account.Flush 即可将所有的钱发送到 reciever 地址内。

这种方式安全吗?除了使用 salt 这个随机参数以外,地址生成算法中的确保安全的是 init_code 的使用,我们可以确保 CREATE2 地址部署在正确的合约上。加上我们在构造函数加上了 receiver 参数,这样确保了接受者始终是我们自己,这样无论如何攻击者都无法使用任何手段获取 Account 的控制权。

上面的示例代码使用了下面辅助函数:

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
package main

import (
"encoding/hex"

"golang.org/x/crypto/sha3"
)

func MustHexDecode(raw string) []byte {
if raw == "0x" {
return []byte{}
}
if len(raw) > 2 && raw[:2] == "0x" {
raw = raw[2:]
}
data, err := hex.DecodeString(raw)
if err != nil {
panic(err)
}
return data
}

func Keccak256(data ...[]byte) []byte {
d := sha3.NewLegacyKeccak256()
for _, b := range data {
_, _ = d.Write(b)
}
return d.Sum(nil)
}

获取以太坊交易被Revert的原因

Solidity 0.4.22 支持 require 和 revert 函数加上错误原因的字符串,如果交易被Revert将会返回这个字符串,并编码成 Error(string)

1
2
3
4
5
6
7
pragma solidity ^0.6.6;

contract Test {
function test() public {
revert("always failed");
}
}

在测试环境中进行部署后,使用 eth_call 调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"id": "1",
"jsonrpc": "2.0",
"method": "eth_call",
"params": [{
"from": "0xF59fB9ff455c68627bd852d6FEb87Ba73398B9Aa",
"to": "0x6cC7f0784587DE4c3Da4c74b01707a4d0A3B32a4",
"value": "0x0",
"gas": "0x2dc6c0",
"gasPrice": "0x1",
"data": "0xf8a8fd6d"
},"latest"]
}

即可获取到返回结果:

1
2
3
4
5
{
"jsonrpc": "2.0",
"id": "1",
"result": "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000d616c77617973206661696c656400000000000000000000000000000000000000"
}

可以看到返回的结果,是使用 Error 进行了 ABI 编码了,所以可以进行解码。

使用 Go 进行数据解析,如果所示:

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
package main

import (
"bytes"
"context"
"fmt"
"math/big"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/ethclient"
)

func main() {
rpcclient, _ := ethclient.Dial("http://127.0.0.1:8545")
contract := common.HexToAddress("0x6cC7f0784587DE4c3Da4c74b01707a4d0A3B32a4")
calldata := ethereum.CallMsg{
From: common.HexToAddress("0xF59fB9ff455c68627bd852d6FEb87Ba73398B9Aa"), To: &contract,
Gas: 300_000, GasPrice: big.NewInt(1), Data: hexutil.MustDecode("0xf8a8fd6d"),
}

result, _ := rpcclient.CallContract(context.Background(), calldata, nil)
// Keccak256("Error(string)")[:4] == "0x08c379a0"
if bytes.HasPrefix(result, []byte{0x08, 0xc3, 0x79, 0xa0}) {
strtyp, _ := abi.NewType("string", "", nil)
var errstr string
_ = abi.Arguments{{Type: strtyp}}.Unpack(&errstr, result[4:])
fmt.Println(errstr)
}
}

最终返回 always failed,和预期的情况一致。

如果要查找历史交易的错误原因,可以在 eth_call 调用时,第二个参数传入交易被确认的高度即可。默认情况下,Geth 客户端仅保存最近的 128 块的信息,如果要获取更久远的交易,那么需要开启 --prune=archive

在 go-ethereum 1.9.14 abi 中,原生提供了 revert 解码,升级之后,可以直接使用:

1
2
data, _ := hex.DecodeString("...")
reason, _ := abi.UnpackRevert(data)

在 go-ethereum 1.9.15 后,eth_call 调用如果含有 Revert ,那么会直接返回含有错误的信息。在 OpenEthereum(parity) 中很久之前就返回错误了,但是没有给出详细的错误的字符串。

1
2
3
4
5
6
7
8
9
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": 3,
"message": "execution reverted: some error",
"data": "0x08c379a000000000000000000..."
}
}