💡 本次解读的文章是 2020 年 arXiv 的一篇与智能合约 Gas 优化相关的论文,这篇论文主要通过将以太坊虚拟机 EVM 指令的操作语义编码为 SMT 公式,并利用约束求解器自动寻找更廉价的字节码,来实现 Gas 优化。基于上述思想,作者在超级优化器 ebso 中实现了该方法,并在真实数据集上进行了两次大规模评估。
1、本文贡献
(1)实现 EVM 字节码语义子集的 SMT 编码;
(2)提出两种超优化方法:basic 和 unbounded;
(3)实现两次大规模的评估。
2、基础概念
2.1 EVM 相关概念
(1)EVM 是基于栈的机器,其包含的元素(位向量),大小为
2
256
2^{256}
2256,最大栈大小设置为
2
10
2^{10}
210;
(2)EVM 具易失性内存(memory)和持久键值存储(storage),storage 内容存储在以太坊区块链上;
(3)EVM 字节码与易于人类理解的操作指令相对应,这些操作指令可分为不同的类别,比如:
- 算术运算:
ADD
、SUB
等 - 比较运算:
SLT
等 - 二进制运算:
AND
、NOT
等 - 栈操作:
PUSH
、POP
、DUPi
、SWAPi
等 - 控制流操作:
JUMP
、JUMPDEST
、STOP
等 - 与区块链域相关操作:
BLOCKHASH
、ADDRESS
等
2.2 超优化概念
给定一个源程序 p p p,超优化试图生成一个目标程序 p ′ p' p′,使得 p ′ p' p′ 等价于 p p p 且 p ′ p' p′ 的代价关于给定的代价函数C是极小的,这个问题出现在源语言和目标语言不同的语境中,而在本文中,源(source)和目标(target)都是 EVM 字节码。
超优化和合成的一个标准方法是搜索代价递增的候选指令序列空间,并使用约束求解器(constraint solver)检查候选指令序列是否正确执行源程序,所选择的求解器通常是可满足性模理论求解器(SMT),SMT 结合背景理论(如位向量或数组理论)对一阶公式进行运算。然而,随着候选序列代价的增加,搜索空间也急剧地增大,为了应对这种爆炸,一种思路是使用模板将部分搜索交给求解器(对应本文的 base superoptimization)。另外,Unbounded superoptimization 进一步推动了这一思想。它将搜索转移到求解器中,即编码表达了正确实现源程序的所有任意长度的候选指令序列,而不是通过搜索候选程序并调用其上的 SMT 求解器。
2.3 相关符号表示
- δ ( ι ) \delta(\iota) δ(ι):表示指令 ι \iota ι 从栈中取出的元素数
- α ( ι ) \alpha(\iota) α(ι):表示指令 ι \iota ι 向栈中添加的元素数
- p , p ′ p,p' p,p′:分别表示源程序和目标程序
- ∣ p ∣ |p| ∣p∣:表示源程序的指令数
3、编码与优化
这部分主要是对 EVM 执行状态(execution state)的三个部分进行编码,分别是栈、gas 消耗以及执行是否处于异常中止状态,将栈建模为无解释的函数和一个计数器 j j j(它指向栈上的下一个自由位置)。
(1)状态 σ = ⟨ st , c , h l t , g ⟩ \sigma=\langle\text {st}, \mathrm{c}, \mathrm{hlt}, \mathrm{g}\rangle σ=⟨st,c,hlt,g⟩ 的定义
函数 | 含义 |
---|---|
st ( V , j , n ) \operatorname{st}(\mathcal{V}, j, n) st(V,j,n) | 程序对来自 V \mathcal{V} V 的输入变量执行 j j j 条指令后,返回来自栈中位置 n n n 的元素 |
c ( j ) \mathrm{c}(j) c(j) | 返回执行 j j j 条指令后栈上的元素数,显然, st ( V , j , c ( j ) − 1 ) \operatorname{st}(\mathcal{V}, j, \mathrm{c}(j)-1) st(V,j,c(j)−1) 表示返回栈顶元素 |
hlt ( j ) \operatorname{hlt}(j) hlt(j) | 如果执行 j j j 条指令后发生异常停止,返回 true ( ⊤ ) (\top) (⊤),否则返回 false ( ⊥ ) (\perp) (⊥) |
g ( V , j ) \mathrm{g}(\mathcal{V}, j) g(V,j) | 返回执行 j j j 条指令后消耗的 gas |
这里论文以字节码操作序列 PUSH 41 PUSH 1 ADD
为例子,展示了执行指令过程中函数的取值变化:
上面的例子引入了两个新的元素,而没有使用到栈中原先存在的元素,但一般情况下并非如此,一些操作或函数可能需要使用到栈中初始存在的元素。因此,首先需要计算出程序 p p p 执行之前的深度 δ ^ ( p ) \hat{\delta}(p) δ^(p),表示程序需要栈中元素的个数,并利用 x 0 , … , x δ ^ ( p ) − 1 x_{0}, \ldots, x_{{\hat{\delta}}(p)-1} x0,…,xδ^(p)−1 表示程序的输入(即栈的初始元素)。
(2)状态 σ = ⟨ st , c , h l t , g ⟩ \sigma=\langle\text {st}, \mathrm{c}, \mathrm{hlt}, \mathrm{g}\rangle σ=⟨st,c,hlt,g⟩ 的初始化
g
σ
(
0
)
=
0
∧
h
l
t
σ
(
0
)
=
⊥
∧
c
σ
(
0
)
=
d
∧
⋀
0
⩽
ℓ
<
d
s
t
σ
(
V
,
0
,
ℓ
)
=
x
ℓ
\mathrm{g}_{\sigma}(0)=0 \wedge \mathrm{hlt}_{\sigma}(0)=\perp \wedge \mathrm{c}_{\sigma}(0)=d \wedge \bigwedge_{0 \leqslant \ell<d} \mathrm{st}_{\sigma}(\mathcal{V}, 0, \ell)=x_{\ell}
gσ(0)=0∧hltσ(0)=⊥∧cσ(0)=d∧0⩽ℓ<d⋀stσ(V,0,ℓ)=xℓ
其中,
δ
^
(
p
)
=
d
\hat{\delta}(p)=d
δ^(p)=d,上述公式表示状态
σ
\sigma
σ 的初始化。
(3)指令 ι \iota ι 操作语义的编码
为了编码 EVM 指令的效果,本文构建了 SMT 公式来捕获它们的操作语义,对于指令 ι \iota ι 和状态 σ \sigma σ 有公式: τ ( ι , σ , j ) \tau(\iota,\sigma,j) τ(ι,σ,j),表示第 j j j 个指令执行 τ \tau τ 对状态 σ \sigma σ 的影响,具体的函数定义如下:
τ g ( ι , σ , j ) ≡ g σ ( V , j + 1 ) = g σ ( V , j ) + C ( σ , j , ι ) τ c ( ι , σ , j ) ≡ c σ ( j + 1 ) = c σ ( j ) + α ( ι ) − δ ( ι ) τ pres ( ι , σ , j ) ≡ ∀ n ⋅ n < c σ ( j ) − δ ( ι ) → st σ ( V , j + 1 , n ) = s t σ ( V , j , n ) τ h l t ( ι , σ , j ) ≡ h l t σ ( j + 1 ) = h l t σ ( j ) ∨ c σ ( j ) − δ ( ι ) < 0 ∨ c σ ( j ) − δ ( ι ) + α ( ι ) > 2 10 \begin{aligned} \tau_{\mathrm{g}}(\iota, \sigma, j) & \equiv \mathrm{g}_{\sigma}(\mathcal{V}, j+1)=\mathrm{g}_{\sigma}(\mathcal{V}, j)+C(\sigma, j, \iota) \\ \tau_{\mathrm{c}}(\iota, \sigma, j) & \equiv \mathrm{c}_{\sigma}(j+1)=\mathrm{c}_{\sigma}(j)+\alpha(\iota)-\delta(\iota) \\ \tau_{\text {pres }}(\iota, \sigma, j) & \equiv \forall n \cdot n<\mathrm{c}_{\sigma}(j)-\delta(\iota) \rightarrow \operatorname{st}_{\sigma}(\mathcal{V}, j+1, n)=\mathrm{st}_{\sigma}(\mathcal{V}, j, n) \\ \tau_{\mathrm{hlt}}(\iota, \sigma, j) & \equiv \mathrm{hlt}_{\sigma}(j+1)=\mathrm{hlt}_{\sigma}(j) \vee \mathrm{c}_{\sigma}(j)-\delta(\iota)<0 \vee \mathrm{c}_{\sigma}(j)-\delta(\iota)+\alpha(\iota)>2^{10} \end{aligned} τg(ι,σ,j)τc(ι,σ,j)τpres (ι,σ,j)τhlt(ι,σ,j)≡gσ(V,j+1)=gσ(V,j)+C(σ,j,ι)≡cσ(j+1)=cσ(j)+α(ι)−δ(ι)≡∀n⋅n<cσ(j)−δ(ι)→stσ(V,j+1,n)=stσ(V,j,n)≡hltσ(j+1)=hltσ(j)∨cσ(j)−δ(ι)<0∨cσ(j)−δ(ι)+α(ι)>210
这里的
C
(
σ
,
j
,
ι
)
C(\sigma, j, \iota)
C(σ,j,ι) 表示经过
j
j
j 次操作后,在状态
σ
\sigma
σ 上执行指令
ι
\iota
ι 消耗的 gas 成本;公式
τ
pres
(
ι
,
σ
,
j
)
\tau_{\text {pres }}(\iota, \sigma, j)
τpres (ι,σ,j) 表示在栈中
c
σ
(
j
)
−
δ
(
ι
)
\mathrm{c}_{\sigma}(j)-\delta(\iota)
cσ(j)−δ(ι) 以下所有的元素都被保留。另外,指令
ι
\iota
ι 对栈的影响可以表示为
τ
s
t
(
τ
,
σ
,
j
)
\tau_{\mathrm{st}}(\tau, \sigma, j)
τst(τ,σ,j) ,这里仅以操作 ADD
为例:
τ s t ( A D D , σ , j ) ≡ s t σ ( V , j + 1 , c σ ( j + 1 ) − 1 ) = st σ ( V , j , c σ ( j ) − 1 ) + s t σ ( V , j , c σ ( j ) − 2 ) \begin{aligned} \tau_{\mathrm{st}}(\mathrm{ADD}, \sigma, j) & \equiv \mathrm{st}_{\sigma}\left(\mathcal{V}, j+1, \mathrm{c}_{\sigma}(j+1)-1\right) \\ & =\operatorname{st}_{\sigma}\left(\mathcal{V}, j, \mathrm{c}_{\sigma}(j)-1\right)+\mathrm{st}_{\sigma}\left(\mathcal{V}, j, \mathrm{c}_{\sigma}(j)-2\right) \end{aligned} τst(ADD,σ,j)≡stσ(V,j+1,cσ(j+1)−1)=stσ(V,j,cσ(j)−1)+stσ(V,j,cσ(j)−2)
综上,可将指令语义编码为:
τ ( ι , σ , j ) ≡ τ s t ( ι , σ , j ) ∧ τ c ( ι , σ , j ) ∧ τ g ( ι , σ , j ) ∧ τ h l t ( ι , σ , j ) ∧ τ pres ( ι , σ , j ) \tau(\iota, \sigma, j) \equiv \tau_{\mathrm{st}}(\iota, \sigma, j) \wedge \tau_{\mathrm{c}}(\iota, \sigma, j) \wedge \tau_{\mathrm{g}}(\iota, \sigma, j) \wedge \tau_{\mathrm{hlt}}(\iota, \sigma, j) \wedge \tau_{\text {pres }}(\iota, \sigma, j) τ(ι,σ,j)≡τst(ι,σ,j)∧τc(ι,σ,j)∧τg(ι,σ,j)∧τhlt(ι,σ,j)∧τpres (ι,σ,j)
(4)存储指令的编码
对于程序
p
=
ι
0
⋯
ι
n
p=\iota_{0} \cdots \iota_{n}
p=ι0⋯ιn,设:
τ
(
p
,
σ
)
≡
⋀
0
⩽
j
⩽
n
τ
(
ι
j
,
σ
,
j
)
\tau(p, \sigma) \equiv \bigwedge_{0 \leqslant j \leqslant n} \tau\left(\iota_{j}, \sigma, j\right)
τ(p,σ)≡⋀0⩽j⩽nτ(ιj,σ,j)。在之前的编码上,考虑的是与栈相关的编码,而现在需要对 存储(storage)
和 内存(memory)
进行编码。存储元素的 gas 成本取决于当前存储的元素,同样,使用内存的 gas 成本取决于当前使用的字节数。为了增加对存储和内存的支持,本文选择了阿克曼编码(an Ackermann encoding)。
在表示存储上,本文利用
str
(
V
,
j
,
k
)
\operatorname{str}(\mathcal{V}, j, k)
str(V,j,k) 对状态进行扩展,表示在程序执行了
j
j
j 条指令后返回
k
k
k 键处的单词,与之前设置初始栈类似,需要在程序执行之前表示存储所持有的值。因此,为了初始化
str
\operatorname{str}
str,本文引入新变量来表示存储的初始内容。对于源程序中出现在
j
1
,
…
,
j
ℓ
j_{1}, \ldots, j_{\ell}
j1,…,jℓ 位置的所有 SLOAD
和 SSTORE
指令,引入新变量
s
1
,
…
,
s
ℓ
s_{1}, \ldots, s_{\ell}
s1,…,sℓ 并将它们添加到
V
\mathcal{V}
V 中,之后,对于一个状态
σ
\sigma
σ ,通过在之前定义(2)的初始化约束中添加下面的约束来初始化
str
σ
\operatorname{str}_{\sigma}
strσ:
∀ w . str σ ( V , 0 , w ) = i te ( w = a j 1 , s 1 , i te ( w = a j 2 , s 2 , … , i te ( w = a j ℓ , s ℓ , w ⊥ ) ) ) \forall w . \operatorname{str}_{\sigma}(\mathcal{V}, 0, w)=i \operatorname{te}\left(w=a_{j_{1}}, s_{1}, i\operatorname{te}\left(w=a_{j_{2}}, s_{2}, \ldots, i \operatorname{te}\left(w=a_{j_{\ell}}, s_{\ell}, w_{\perp}\right)\right)\right) ∀w.strσ(V,0,w)=ite(w=aj1,s1,ite(w=aj2,s2,…,ite(w=ajℓ,sℓ,w⊥)))
其中,
a
j
=
st
σ
(
V
,
j
,
c
(
j
)
−
1
)
a_{j}=\operatorname{st}_{\sigma}(\mathcal{V}, j, \mathrm{c}(j)-1)
aj=stσ(V,j,c(j)−1),且
w
⊥
w_{\perp}
w⊥ 是存储单元的默认值。上面这个公式中对于任意
w
w
w(表示某一状态),首先判断是否等于
a
j
1
a_{j_{1}}
aj1,如果是,则取值为新变量
s
1
s_{1}
s1,否则继续判断是否等于
a
j
2
a_{j_{2}}
aj2,以此类推,建立一个存储操作与初始存储元素的映射关系。因此, SLOAD
和 SSTORE
两种存储指令的作用可以编码如下:
τ
s
t
(
SLOAD
,
σ
,
j
)
≡
st
σ
(
V
,
j
+
1
,
c
σ
(
j
+
1
)
−
1
)
=
str
(
V
,
j
,
st
σ
(
V
,
j
,
c
σ
(
j
)
−
1
)
)
τ
s
t
r
(
SSTORE
,
σ
,
j
)
≡
∀
w
⋅
str
σ
(
V
,
j
+
1
,
w
)
=
i
t
e
(
w
=
st
σ
(
V
,
j
,
c
σ
(
j
)
−
1
)
,
st
σ
(
V
,
j
,
c
σ
(
j
)
−
2
)
,
str
σ
(
V
,
j
,
w
)
)
\begin{aligned} \tau_{\mathrm{st}}(\operatorname{SLOAD}, \sigma, j) \equiv & \operatorname{st}_{\sigma}\left(\mathcal{V}, j+1, \mathrm{c}_{\sigma}(j+1)-1\right)=\operatorname{str}\left(\mathcal{V}, j, \operatorname{st}_{\sigma}\left(\mathcal{V}, j, \mathrm{c}_{\sigma}(j)-1\right)\right) \\ \tau_{\mathrm{str}}(\operatorname{SSTORE}, \sigma, j) \equiv & \forall w \cdot \operatorname{str}_{\sigma}(\mathcal{V}, j+1, w)= \\ & i t e\left(w=\operatorname{st}_{\sigma}\left(\mathcal{V}, j, \mathrm{c}_{\sigma}(j)-1\right), \operatorname{st}_{\sigma}\left(\mathcal{V}, j, \mathrm{c}_{\sigma}(j)-2\right), \operatorname{str}_{\sigma}(\mathcal{V}, j, w)\right) \end{aligned}
τst(SLOAD,σ,j)≡τstr(SSTORE,σ,j)≡stσ(V,j+1,cσ(j+1)−1)=str(V,j,stσ(V,j,cσ(j)−1))∀w⋅strσ(V,j+1,w)=ite(w=stσ(V,j,cσ(j)−1),stσ(V,j,cσ(j)−2),strσ(V,j,w))
在上面的公式中,SLOAD
操作相当于在存储中查找之前存在的映射
st
σ
(
V
,
j
,
c
σ
(
j
)
−
1
)
\operatorname{st}_{\sigma}\left(\mathcal{V}, j, \mathrm{c}_{\sigma}(j)-1\right)
stσ(V,j,cσ(j)−1),而 SSTORE
操作相当于在存储中添加一个新的映射。此外,除了 SSTORE
以外的所有指令都保留了存储,即对于
ι
\iota
ι 不等于 SSTORE
,向
τ
pres
(
ι
,
σ
,
j
)
\tau_{\text {pres }}(\iota, \sigma, j)
τpres (ι,σ,j) 中增加条件,表示操作前后存储中的映射关系不变:
∀
w
.
str
σ
(
V
,
j
+
1
,
w
)
=
str
σ
(
V
,
j
,
w
)
\forall w. \operatorname{str}_{\sigma}(\mathcal{V}, j+1, w)=\operatorname{str}_{\sigma}(\mathcal{V}, j, w)
∀w.strσ(V,j+1,w)=strσ(V,j,w)
然而,由于程序的规模和通用量化变量的数量,求解器在使用堆栈和存储编码上的性能已经存在不足,因此,本文的工作没有对内存进行相似的编码。
(5)状态等价判断的编码
最后,在进行超优化之前,还需要对判断执行了一定数量指令后的状态是否等价进行编码,即以编码的形式确定两个程序是等价的(它们以相等状态开始和结束),或者非等价的(它们以相同状态开始,但以不同状态结束),这一约束编码如下(对状态 σ 1 \sigma_{1} σ1 和 σ 2 \sigma_{2} σ2 以及 程序位置 j 1 j_{1} j1 和 j 2 j_{2} j2):
ϵ
(
σ
1
,
σ
2
,
j
1
,
j
2
)
≡
c
σ
1
(
j
1
)
=
c
σ
2
(
j
2
)
∧
h
l
t
σ
1
(
j
1
)
=
h
l
t
σ
2
(
j
2
)
∧
∀
n
⋅
n
<
c
σ
1
(
j
1
)
→
st
σ
1
(
V
,
j
1
,
n
)
=
st
σ
2
(
V
,
j
2
,
n
)
∧
∀
w
⋅
str
σ
1
(
V
,
j
1
,
w
)
=
str
σ
2
(
V
,
j
2
,
w
)
\begin{aligned} \epsilon\left(\sigma_{1}, \sigma_{2}, j_{1}, j_{2}\right) & \equiv \mathrm{c}_{\sigma_{1}}\left(j_{1}\right)=\mathrm{c}_{\sigma_{2}}\left(j_{2}\right) \wedge \mathrm{hlt}_{\sigma_{1}}\left(j_{1}\right)=\mathrm{hlt}_{\sigma_{2}}\left(j_{2}\right) \\ & \wedge \forall n \cdot n<\mathrm{c}_{\sigma_{1}}\left(j_{1}\right) \rightarrow \operatorname{st}_{\sigma_{1}}\left(\mathcal{V}, j_{1}, n\right)=\operatorname{st}_{\sigma_{2}}\left(\mathcal{V}, j_{2}, n\right) \\ & \wedge \forall w \cdot \operatorname{str}_{\sigma_{1}}\left(\mathcal{V}, j_{1}, w\right)=\operatorname{str}_{\sigma_{2}}\left(\mathcal{V}, j_{2}, w\right) \end{aligned}
ϵ(σ1,σ2,j1,j2)≡cσ1(j1)=cσ2(j2)∧hltσ1(j1)=hltσ2(j2)∧∀n⋅n<cσ1(j1)→stσ1(V,j1,n)=stσ2(V,j2,n)∧∀w⋅strσ1(V,j1,w)=strσ2(V,j2,w)
由于 gas 成本消耗是优化的目的,因此,在约束条件中不要求两个状态对应的 gas 消耗相同。
(6)基本超优化(basic superoptimization)
现在有了实现基本超优化所需的所有编码,然而,对于操作 PUSH
的对象有
2
256
2^{256}
2256 种可能,因此需要使用模板,这里引入一个无解释的函数
a
(
j
)
a(j)
a(j),它将程序位置
j
j
j 映射到一个元素,作为操作 PUSH
的参数,然后求解器填充这些模板并从模型中得到值。另外,由于有 80 条编码指令,枚举所有的排列仍然会产生太大的搜索空间,因此论文使用类似于 CEGIS 算法的编码
[
1
]
[1]
[1]。即给定一个指令集合,制定一个约束来表示这些指令的所有可能的排列。如果有一种方法可以将指令连接成与源程序等价的目标程序,则是可满足的。指令的顺序可以从求解器提供的模型中重新构建。更准确地说,给定源程序
p
p
p 和候选指令列表
ι
1
,
…
,
ι
n
\iota_{1}, \ldots, \iota_{n}
ι1,…,ιn,算法(a)中的 EncodeBso 取变量
j
1
,
…
,
j
n
j_{1}, \ldots, j_{n}
j1,…,jn 和两个状态
σ
\sigma
σ 和
σ
′
\sigma'
σ′ 并构建如下约束:
∀ V . ϵ ( σ , σ ′ , 0 , 0 ) ∧ ϵ ( σ , σ ′ , ∣ p ∣ , n ) ∧ τ ( p , σ ) ∧ ⋀ 1 ⩽ ℓ ⩽ n τ ( ι ℓ , σ ′ , j ℓ ) ∧ ⋀ 1 ⩽ ℓ < k ⩽ n j ℓ ≠ j k ∧ ⋀ 1 ⩽ ℓ ⩽ n j ℓ ⩾ 0 ∧ j ℓ < n \begin{array}{l} \forall \mathcal{V} . \epsilon\left(\sigma, \sigma^{\prime}, 0,0\right) \wedge \epsilon\left(\sigma, \sigma^{\prime},|p|, n\right) \wedge \tau(p, \sigma) \\ \wedge \bigwedge_{1 \leqslant \ell \leqslant n} \tau\left(\iota_{\ell}, \sigma^{\prime}, j_{\ell}\right) \wedge \bigwedge_{1 \leqslant \ell<k \leqslant n} j_{\ell} \neq j_{k} \wedge \bigwedge_{1 \leqslant \ell \leqslant n} j_{\ell} \geqslant 0 \wedge j_{\ell}<n \end{array} ∀V.ϵ(σ,σ′,0,0)∧ϵ(σ,σ′,∣p∣,n)∧τ(p,σ)∧⋀1⩽ℓ⩽nτ(ιℓ,σ′,jℓ)∧⋀1⩽ℓ<k⩽njℓ=jk∧⋀1⩽ℓ⩽njℓ⩾0∧jℓ<n
这里第一行对源程序进行编码,并表示两个程序的开始和结束状态是等价的。第二行对候选指令的效果进行编码,并强制它们按照某种顺序全部使用。如果这个公式是可满足的,则可以简单地从模型中得到 j i j_{i} ji,并据此对候选指令重新排序,从而得到目标程序。
(7)无界超优化(Unbounded superoptimization)
无界超优化将更多的搜索转移到求解器中,编码所有可能程序的搜索空间(即将 base 中遍历排列的过程换成搜索,缩小解的空间)。为此,这里取一个变量
n
n
n 表示目标程序中指令的数量,并取一个无解释的函数
i
n
s
t
r
(
j
)
instr( j )
instr(j) 作为模板,返回
j
j
j 处要使用的指令。然后,给定一组候选指令,可以建立编码搜索的公式,即给定指令集
C
l
\mathrm{Cl}
Cl,公式
ρ
(
σ
,
n
)
\rho(\sigma, n)
ρ(σ,n) 定义为:
∀
j
.
j
⩾
0
∧
j
<
n
→
⋀
ι
∈
C
l
instr
(
j
)
=
ι
→
τ
(
ι
,
σ
,
j
)
∧
⋁
ι
∈
C
l
instr
(
j
)
=
ι
\forall j . j \geqslant 0 \wedge j<n \rightarrow \bigwedge_{\iota \in \mathrm{Cl}} \operatorname{instr}(j)=\iota \rightarrow \tau(\iota, \sigma, j) \wedge \bigvee_{\iota \in \mathrm{Cl}} \operatorname{instr}(j)=\iota
∀j.j⩾0∧j<n→ι∈Cl⋀instr(j)=ι→τ(ι,σ,j)∧ι∈Cl⋁instr(j)=ι
则算法(b)中 EncodeUso 产生的约束为:
∀
V
⋅
τ
(
p
,
σ
)
∧
ρ
(
σ
′
,
n
)
∧
ϵ
(
σ
,
σ
′
,
0
,
0
)
∧
ϵ
(
σ
,
σ
′
,
∣
p
∣
,
n
)
∧
g
σ
(
V
,
∣
p
∣
)
>
g
σ
′
(
V
,
n
)
\forall \mathcal{V} \cdot \tau(p, \sigma) \wedge \rho\left(\sigma^{\prime}, n\right) \wedge \epsilon\left(\sigma, \sigma^{\prime}, 0,0\right) \wedge \epsilon\left(\sigma, \sigma^{\prime},|p|, n\right) \wedge \mathrm{g}_{\sigma}(\mathcal{V},|p|)>\mathrm{g}_{\sigma^{\prime}}(\mathcal{V}, n)
∀V⋅τ(p,σ)∧ρ(σ′,n)∧ϵ(σ,σ′,0,0)∧ϵ(σ,σ′,∣p∣,n)∧gσ(V,∣p∣)>gσ′(V,n)
而在实验中,本文观察到求解器证明当
p
p
p 已经是最优时,公式是不可满足的,因此论文在
n
n
n 上增加一个界:由于最便宜的EVM指令的气体成本为1,目标程序不能使用比
p
p
p 的气体成本更多的指令,所以增加:
n
⩽
g
σ
(
V
,
∣
p
∣
)
n \leqslant g_{\sigma}(\mathcal{V},|p|)
n⩽gσ(V,∣p∣)。
(8)获取外界信息的指令优化
在应用领域中有许多从外界获取信息的指令,比如 ADDRESS
等,这些类似 ADDRESS
的指令需要一种方法来进行编码,而这些指令有一个共同点,即它们将任意但固定的元素放到栈上。类似于无解释函数,论文将其称为无解释指令,并将这些指令归并到 UI 中。为了编码无解释指令对栈的影响,论文区分了恒定(constant)和非恒定(non-constant)无解释指令。
- 恒定无解释指令
以 ADDRESS
指令为例子,该指令是获取当前执行该智能合约字节码的帐户以太坊地址,由于在编译时不可能知道这些值,因此无法对它们的完整语义进行编码。然而,可以通过例如 DUP
和 SWAP
等操作进行优化。
这里论文给了一个例子:考虑
ADDRESS
DUP1
程序,可以通过简单的调用ADDRESS
ADDRESS
来实现同样的效果。由于执行ADDRESS
需要花费 2 gas,执行DUP1
需要花费 3 gas,因此第二个程序更便宜。
在对这类指令进行编码时,设置
u
i
c
(
p
)
\mathrm{ui}_{c}(p)
uic(p) 是程序
p
p
p 的恒定无解释指令的集合,则有:
u
i
c
(
p
)
=
{
ι
∈
p
∣
ι
∈
UI
∣
∧
δ
(
ι
)
=
0
}
\mathrm{ui}_{c}(p) = \{\iota \in p|\iota \in \text { UI }| \wedge \delta(\iota)=0\}
uic(p)={ι∈p∣ι∈ UI ∣∧δ(ι)=0}
对于 u i c ( p ) = { ι 1 , … , ι k } \mathrm{ui}_{c}(p)=\left\{\iota_{1}, \ldots, \iota_{k}\right\} uic(p)={ι1,…,ιk},取变量 u ι 1 , … , u ι k u_{\iota_{1}}, \ldots, u_{\iota_{k}} uι1,…,uιk 加入 V \mathcal{V} V , τ s t \tau_{st} τst 可以用这些变量来表示无解释的指令产生的未知元素,即对 ι ∈ u i c ( p ) \iota \in \mathrm{ui}_{c}(p) ι∈uic(p) 有: τ st ( ι , σ , j ) ≡ st σ ( V , j + 1 , c σ ( j ) ) = u ι \tau_{\text {st }}(\iota, \sigma, j) \equiv \operatorname{st}_{\sigma}\left(\mathcal{V}, j+1, \mathrm{c}_{\sigma}(j)\right)=u_{\iota} τst (ι,σ,j)≡stσ(V,j+1,cσ(j))=uι。
- 非恒定无解释指令
这类指令如 BLOCKHASH
或 BALANCE
,指令
ι
\iota
ι 推入栈中的元素取决于从栈顶读取的
δ
(
ι
)
\delta(\iota)
δ(ι) 个单词。这里再次使用无解释的函数对这种依赖性进行建模,即对于源程序
p
p
p 中的每一个非恒定无解释的指令
ι
\iota
ι 有:
u i n ( p ) = { ι ∈ p ∣ ι ∈ U I ∧ δ ( ι ) > 0 } \mathrm{ui}_{n}(p)=\{\iota \in p \mid \iota \in U I \wedge \delta(\iota)>0\} uin(p)={ι∈p∣ι∈UI∧δ(ι)>0}
使用 f ι f_{\iota} fι 表示无解释函数,则可以把 f ι f_{\iota} fι 看作一个只读存储器,用对 ι \iota ι 的调用产生的值来初始化。
这里论文给了一个例子:
BLOCKHASH
指令获取给定块b
的哈希,因此优化程序PUSH b1 BLOCKHASH PUSH b2 BLOCKHASH
取决于b1
和b2
的值。如果b1 = b2
,则较廉价的程序PUSH b1 BLOCKHASH DUP1
产生与源程序相同的状态。
为了捕捉这种行为,需要将 BLOCKHASH
的参数 b1
和 b2
与它们可能产生的两种不同结果联系起来。与恒定无解释指令一样,为了建模任意但固定的结果,在
V
V
V 中添加了新变量。然而,为了解释在
p
p
p 中调用
ι
\iota
ι 产生的不同结果
ℓ
\ell
ℓ ,需要添加
ℓ
\ell
ℓ 变量,对于变量
u
1
,
…
,
u
ℓ
u_{1}, \ldots, u_{\ell}
u1,…,uℓ,初始化
f
ι
f_{\iota}
fι :
∀
w
⋅
f
ι
(
V
,
w
)
=
ite
(
w
=
a
j
1
,
u
1
,
i
t
e
(
w
=
a
j
2
,
u
2
,
…
,
i
t
e
(
w
=
a
j
ℓ
,
u
ℓ
,
w
⊥
)
)
)
\forall w \cdot f_{\iota}(\mathcal{V}, w)=\operatorname{ite}\left(w=a_{j_{1}}, u_{1}, i t e\left(w=a_{j_{2}}, u_{2}, \ldots, i t e\left(w=a_{j_{\ell}}, u_{\ell}, w_{\perp}\right)\right)\right)
∀w⋅fι(V,w)=ite(w=aj1,u1,ite(w=aj2,u2,…,ite(w=ajℓ,uℓ,w⊥)))
其中,
a
j
a_{j}
aj 表示程序
p
p
p 中执行
j
j
j 个指令后栈上的元素,即
a
j
=
s
t
σ
(
V
,
j
,
c
(
j
)
−
1
)
a_{j} = \mathrm{st}_{\sigma}(\mathcal{V}, j, \mathrm{c}(j)-1)
aj=stσ(V,j,c(j)−1) 且
w
⊥
w_{\perp}
w⊥ 表示默认值。这种方法可以延伸到有多个参数的指令,这里假设无解释指令只在栈上放一个单词,即对所有的
ι
∈
UI
,
α
(
ι
)
=
1
\iota \in \text{UI}, \alpha(\iota)=1
ι∈UI,α(ι)=1,这一假设在 EVM 中很容易得到验证,因为
α
(
ι
)
>
1
\alpha(\iota) \gt 1
α(ι)>1 的指令只有 DUP
和 SWAP
。最后设定一个非恒定无解释指令
ι
\iota
ι 和相关函数
f
ι
f_{\iota}
fι 对栈的影响:
τ
st
(
ι
,
σ
,
j
)
≡
st
σ
(
V
,
j
+
1
,
c
σ
(
j
+
1
)
−
1
)
=
f
ι
(
V
,
s
t
σ
(
V
,
j
,
c
σ
(
j
)
−
1
)
)
\tau_{\text {st }}(\iota, \sigma, j) \equiv \operatorname{st}_{\sigma}\left(\mathcal{V}, j+1, \mathrm{c}_{\sigma}(j+1)-1\right)=f_{\iota}\left(\mathcal{V}, \mathrm{st}_{\sigma}\left(\mathcal{V}, j, \mathrm{c}_{\sigma}(j)-1\right)\right)
τst (ι,σ,j)≡stσ(V,j+1,cσ(j+1)−1)=fι(V,stσ(V,j,cσ(j)−1))
对于一些无解释的指令,可能有办法对其语义进行部分编码。如果指令 BLOCKHASH
调用的块数大于当前块数,则返回0。当前块编号在编译时未知时,指令 NUMBER
会返回它。编码 BLOCKHASH
和 NUMBER
之间的这种相互作用可能被用来寻找优化。
4、模型实现
本文提出的基本和无界超优化在工具 ebso 中已经实现,获取方式:
https://github.com/juliannagele/ebso
本文的工作提供了一个原型,而不依赖于繁重的工程和优化(如利用并行或调整Z3策略),但在没有任何优化的情况下,对于全字长的 EVM-256 bit ,ebso 并不能在合理的时间内处理简单的程序 PUSH 0 ADD POP
。因此,需要通过优化使 ebso 可行。通过考察 Z3 在默认配置下运行产生的模型,论文认为问题在于主要的通用量化变量,这方面的优化可通过将元素的大小降低到一个很小的
k
k
k,即将通用量化变量的搜索空间从
2
256
2^{256}
2256 减少到一些明显较小的
2
k
2^{k}
2k,这需要检查发现任何具有较小元素的目标程序。
例如,程序
PUSH 0 SUB PUSH 3 ADD
可将原先 256 bit 字长表示优化成字长 2 bit 的表示,此时元素 3 的二进制表示均为1。
为了确保目标程序在字长为 256 bit 时具有相同的语义,论文使用翻译验证:要求求解器寻找输入,以区分源程序和目标程序,也就是说,两个程序都以等价的状态开始,但它们的最终状态不同,即程序
p
p
p 和
p
′
p'
p′ 是等价的,当不满足:
有了上述判断后,还需要思考的问题是:如何用 k bit表示 PUSH 224981
程序?论文的解决方案是将 PUSH
中
a
i
≥
2
k
a_{i} \ge 2^{k}
ai≥2k 的参数
a
1
,
⋯
,
a
m
a_{1},\cdots,a_{m}
a1,⋯,am 替换为新的通用量化变量
c
1
,
⋯
,
c
m
c_{1},\cdots,c_{m}
c1,⋯,cm。如果发现目标程序,则用原始值
a
i
a_{i}
ai 替换回
c
i
c_{i}
ci,并通过翻译验证检查目标程序是否正确。这种方法的一个缺点是可能会失去潜在的优化。
例如,程序
PUSH 0b111...111 AND
优化为空程序。然而抽象PUSH
参数后将程序翻译成PUSH ci AND
,翻译后的语句却不允许同样的优化。
另外,与许多编译器优化一样,ebso 优化的对象是基本块。即将 EVM 字节码沿着改变控制流的指令进行拆分,例如 Jumpi
等。类似地,可以进一步将基本块拆分为 ebso 块,使它们只包含编码指令。不编码或不可编码的指令包括写入内存的指令如MSTORE,或日志指令LOG。
5、参考文献
[ 1 ] [1] [1] Gulwani, S., Jha, S., Tiwari, A., Venkatesan, R.: Synthesis of loop-free programs. In: Proc. 32nd PLDI. pp. 62–73. ACM (2011). https://doi.org/10.1145/1993498.1993506