【Gas优化】Blockchain Superoptimizer

💡 本次解读的文章是 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 字节码与易于人类理解的操作指令相对应,这些操作指令可分为不同的类别,比如:

  • 算术运算:ADDSUB
  • 比较运算:SLT
  • 二进制运算:ANDNOT
  • 栈操作:PUSHPOPDUPiSWAPi
  • 控制流操作:JUMPJUMPDESTSTOP
  • 与区块链域相关操作:BLOCKHASHADDRESS

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)=0hltσ(0)=⊥cσ(0)=d0<dstσ(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)+α(ι)δ(ι)nn<cσ(j)δ(ι)stσ(V,j+1,n)=stσ(V,j,n)hltσ(j+1)=hltσ(j)cσ(j)δ(ι)<0cσ(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,σ)0jnτ(ι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 位置的所有 SLOADSSTORE 指令,引入新变量 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,以此类推,建立一个存储操作与初始存储元素的映射关系。因此, SLOADSSTORE 两种存储指令的作用可以编码如下:
τ 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))wstrσ(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)nn<cσ1(j1)stσ1(V,j1,n)=stσ2(V,j2,n)wstrσ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,σ)1nτ(ι,σ,j)1<knj=jk1nj0j<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.j0j<nιClinstr(j)=ιτ(ι,σ,j)ιClinstr(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|) ngσ(V,p)

(8)获取外界信息的指令优化

在应用领域中有许多从外界获取信息的指令,比如 ADDRESS 等,这些类似 ADDRESS 的指令需要一种方法来进行编码,而这些指令有一个共同点,即它们将任意但固定的元素放到栈上。类似于无解释函数,论文将其称为无解释指令,并将这些指令归并到 UI 中。为了编码无解释指令对栈的影响,论文区分了恒定(constant)和非恒定(non-constant)无解释指令。

  • 恒定无解释指令

ADDRESS 指令为例子,该指令是获取当前执行该智能合约字节码的帐户以太坊地址,由于在编译时不可能知道这些值,因此无法对它们的完整语义进行编码。然而,可以通过例如 DUPSWAP 等操作进行优化。

这里论文给了一个例子:考虑 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ι

  • 非恒定无解释指令

这类指令如 BLOCKHASHBALANCE,指令 ι \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 取决于 b1b2 的值。如果 b1 = b2,则较廉价的程序 PUSH b1 BLOCKHASH DUP1 产生与源程序相同的状态。

为了捕捉这种行为,需要将 BLOCKHASH 的参数 b1b2 与它们可能产生的两种不同结果联系起来。与恒定无解释指令一样,为了建模任意但固定的结果,在 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) wfι(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 的指令只有 DUPSWAP。最后设定一个非恒定无解释指令 ι \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 会返回它。编码 BLOCKHASHNUMBER之间的这种相互作用可能被用来寻找优化。

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} ai2k 的参数 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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值