解读Starknet智能合约模型与原生AA:特立独行的技术巨匠
作者:Shew & Faust,极客web3
顾问:CryptoNerdCn,Starknet生态核心开发者,浏览器端Cairo开发平台WASM Cairo创始人
摘要:
- Starknet最主要的几大技术特性,包括利于ZK证明生成的Cairo语言、原生级别的AA、业务逻辑与状态存储相独立的智能合约模型。
- Cairo是一种通用的ZK语言,既可以在Starknet上实现智能合约,也可以用于开发偏传统的应用,其编译流程中引入Sierra作为中间语言,使得Cairo可以频繁迭代,但又不必变更最底层的字节码,只需要把变化传导至中间语言身上;在Cairo的标准库内,还纳入了账户抽象所需要的许多基本数据结构。
- Starknet智能合约将业务逻辑与状态数据分开来存储,不同于EVM链,Cairo合约部署包含“编译、声明、部署”三阶段,业务逻辑被声明在Contract class中,包含状态数据的Contract实例可以与class建立关联,并调用后者所包含的代码;
- Starknet的上述智能合约模型利于代码复用、合约状态复用、存储分层、检测垃圾合约,也利于存储租赁制和交易并行化的实现。虽然后两者目前暂未落地,但Cairo智能合约的架构,还是为其创造了“必要条件”。
- Starknet链上只有智能合约账户,没有EOA账户,从一开始就支持原生级别的AA账户抽象。其AA方案一定程度吸收了ERC-4337的思路,允许用户选择高度定制化的交易处理方案。为了防止潜在的攻击场景,Starknet做出了诸多反制措施,为AA生态做出了重要的探索。
正文:继Starknet发行代币之后,STRK逐渐成为以太坊观察者眼中不可或缺的要素之一。这个向来以“特立独行”“不重视用户体验”而闻名的以太坊Layer2明星,就像一个与世无争的隐士,在EVM兼容大行其道的Layer2生态里默默的开辟自己的一亩三分地。
由于太过忽视用户,甚至公开在Discord开设“电子乞丐”频道,Starknet一度遭到撸毛党的抨击,在遭喷“不近人情”的同时,技术上的深厚造诣瞬间变得“一文不值”,似乎只有UX和造富效应才是一切。《金阁寺》中那句“不被人理解成了我唯一的自豪”,简直就是Starknet的自我写照。
但抛开这些江湖琐事,单纯从代码极客们的“技术品味”出发,作为ZK Rollup先驱之一的Starknet和StarkEx,几乎就是Cairo爱好者眼中的瑰宝,在某些全链游戏开发者心中,Starknet和Cairo简直就是web3的一切,无论是Solidity还是Move都无法与之相提并论。现如今横亘在“技术极客”和“用户”之间的最大代沟,其实更多归因于人们对Starknet的认知欠缺。
抱着对区块链技术的兴趣与探索欲,以及对Starknet的价值发现,本文作者从Starknet的智能合约模型与原生AA出发,为大家简单梳理其技术方案与机制设计,在为更多人展示Starknet技术特性的同时,也希望让人们了解这个“不被人所理解的独行侠”。
Cairo语言极简科普
下文中我们将重点讨论Starknet的智能合约模型与原生账户抽象,说明Starknet是如何实现原生AA的。读完此文,大家也可以理解为什么Starknet中不同钱包的助记词不能混用。
但在介绍原生账户抽象前,让我们先了解下Starknet独创的Cairo语言。在Cairo的发展历程中,出现了名为Cairo0的早期版本,以及后来的的现代版。Cairo的现代版本整体语法类似于Rust,实际上是一门通用的ZK语言,除了可以在Starknet上编写智能合约,也可以用于通用应用的开发。
比如我们可以用Cairo语言开发ZK身份验证系统,这段程序可以在自己搭建的服务器上运行,不必依赖于StarkNet网络。可以说,任何需要可验证计算属性的程序都可以用Cairo语言来实现。而Cairo可能是目前最利于生成ZK证明的编程语言。
从编译流程来看,Cairo使用了基于中间语言的编译方法,如下图所示。图中的Sierra是Cairo语言编译过程中的一道中间形态(IR),而Sierra会再被编译为更底层的二进制代码形式,名为CASM,在Starknet节点设备上直接运行。
引入Sierra作为中间形态,便于Cairo语言增加新特性,许多时候只要在Sierra这道中间语言上做手脚,不必直接变更底层的CASM代码,这就省去了很多麻烦事,Starknet的节点客户端就不必频繁更新。这样就可以在不变更StarkNet底层逻辑的情况下,实现Cairo语言的频繁迭代。而在Cairo的标准库内,还纳入了账户抽象所需要的许多基本数据结构。
Cairo的其他创新,包括一种被称为Cairo Native的理论方案,该方案计划把Cairo编译为能适配不同硬件设备的底层机器代码,Starknet节点在运行智能合约时,将不必依赖于CairoVM虚拟机,这样可以大幅度提升代码执行速度【目前还处于理论阶段,未落地】。
Starknet智能合约模型:代码逻辑与状态存储的剥离
与EVM兼容链不同,Starknet在智能合约系统的设计上,有着突破性的创新,这些创新很大程度是为原生AA以及未来上线的并行交易功能准备的。在这里,我们要知道,以太坊等传统公链上,智能合约的部署往往遵循“编译后部署”的方式,以ETH智能合约举例:
1.开发者在本地编写好智能合约后,通过编辑器将Solidity程序编译为EVM的字节码,这样就可以被EVM直接理解并处理;
2.开发者发起一笔部署智能合约的交易请求,把编译好的EVM字节码部署到以太坊链上。
Starknet的智能合约虽然也遵循“先编译后部署”的思路,智能合约以CairoVM支持的CASM字节码形式部署在链上,但在智能合约的调用方式与状态存储模式上,Starknet与EVM兼容链有着巨大差异。
准确的说,以太坊智能合约=业务逻辑+状态信息,比如USDT的合约中不光实现了Transfer、Approval等常用的函数功能,还存放着所有USDT持有者的资产状态,代码和状态被耦合在了一起,这带来了诸多麻烦,首先不利于DAPP合约升级与状态迁移,也不利于交易的并行处理,是一种沉重的技术包袱。
对此,Starknet对状态的存储方式进行了改良,在其智能合约实现方案中,DAPP的业务逻辑与资产状态完全解耦,分别存放在不同地方,这样做的好处很明显,首先可以让系统更快速的分辨出,是否存在重复或多余的代码部署。这里的原理是这样:
以太坊的智能合约=业务逻辑+状态数据,假如有几个合约的业务逻辑部分完全一致,但状态数据不同,则这几个合约的hash也不同,此时系统难以分辨出这些合约是否冗余,是否有“垃圾合约”存在。
而在Starknet的方案中,代码部分和状态数据直接分开,系统根据代码部分的hash,更容易分辨出是否有相同的代码被多次部署,因为他们的hash是相同的。这样便于制止重复的代码部署行为,节约Starknet节点的存储空间。
在Starknet的智能合约系统中,合约的部署与使用,分为“编译、声明、部署”三个阶段。资产发行者如果要部署Cairo合约,第一步要在自己的设备本地,把写好的Cairo代码,编译为 Sierra 以及底层字节码CASM形式。
然后,合约部署者要发布声明“declare”交易,把合约的 CASM 字节码和 Sierra 中间代码部署到链上,名为Contract Class。
之后,如果你要要采用该资产合约里定义的函数功能,可以通过DAPP前端发起“deploy"交易,部署一个和Contract Class相关联的Contract实例,这个实例里面会存放资产状态。之后,用户可以调用Contract Class里的函数功能,变更Contract实例的状态。
其实,但凡了解面向对象编程的人,都应该能很容易的理解Starknet这里的Class和Instance各自代表啥。开发者声明的Contract Class,只包含智能合约的业务逻辑,是一段谁都可以调用的函数功能,但没有实际的资产状态,也就没有直接实现“资产实体”,只有“灵魂”没有“肉体”。
而当用户部署具体的Contract实例后,资产就完成了“实体化”。如果你要对资产“实体”的状态进行变更,比如把自己的token转移给别人,可以直接调用Contract Class里写好的函数功能。上述过程就和传统面向对象编程语言里的“实例化”有些类似(但不完全一致)。
智能合约被分离为Class和实例后,业务逻辑与状态数据解耦合,为Starknet带来了以下特性:
1.利于存储分层和“存储租赁制”的实现
所谓的存储分层,就是开发者可以按照自己的需求,将数据放在自定义的位置,比如Starknet链下。StarkNet准备兼容Celestia等DA层,DAPP开发者可以将数据存放在这些第三方DA层里。比如一个游戏可以将最重要的资产数据存放在Starknet主网上,而将其他数据存储在Celestia等链下DA层。这种按照安全需求定制化选择DA层的方案,被Starknet命名为"Volition"。
而所谓的存储租赁制,是指每个人应当持续的为自己占用的存储空间付费。你占用的链上空间有多少,理论上就该持续的支付租金。
在以太坊智能合约模型中,合约的所有权不明确,难以分辨出一个ERC-20合约应该由部署者还是资产持有者支付“租金”,迟迟没有上线存储租赁功能,只在合约部署时向部署者收取一笔费用,这种存储费用模型并不合理。
而在Starknet和Sui以及CKB、Solana的智能合约模型下,智能合约的所有权划分更明确,便于收取存储资金【目前Starknet没有直接上线存储租赁制,但未来会实现】
2.实现真正的代码复用,减少垃圾合约的部署
我们可以声明一个通用的代币合约作为class存储到链上,然后所有人都可以调用这个class里的函数,来部署属于自己的代币实例。而且合约也可以直接调用class内的代码,这就实现了类似于Solidity中的Library函数库的效果。
同时,Starknet的这种智能合约模型,有助于分辨“垃圾合约”。前面对此有所解释。在支持代码复用与垃圾合约检测后,Starknet可以大幅度减少上链的数据量,尽可能减轻节点的存储压力。
3.真正的合约“状态”复用
区块链上的合约升级主要涉及到业务逻辑的变更,在Starknet的场景下,智能合约的业务逻辑与资产状态天生就是分离的,合约实例变更了关联的合约类型class,就可以完成业务逻辑升级,不需要把资产状态迁移到新去处,这种合约升级形式比以太坊的更彻底、更原生。
而以太坊合约要变更业务逻辑,往往就要把业务逻辑“外包”给代理合约,通过变更依赖的代理合约,来实现主合约业务逻辑的变更,但这种方式不够简洁,也“不原生”。
在某些场景下,如果旧的以太坊合约被整个弃用,里面的资产状态就无法直接迁移到新去处,非常麻烦;而Cairo合约就不需要把状态迁移走,可以直接“复用”旧的状态。
4.利于交易并行化处理
要尽可能提升不同交易指令的可并行度,必要一环是把不同人的资产状态分散开存储,这在比特币、CKB和Sui身上可见一斑。而上述目标的先决条件,就是把智能合约的业务逻辑和资产状态数据剥离开。虽然Starknet还没有针对交易并行进行深度的技术实现,但未来将把并行交易作为一个重要目标。
Starknet的原生AA与账户合约部署
其实,所谓的账户抽象与AA,是以太坊社区发明出来的独特概念,在许多新公链中,并没有EOA账户和智能合约账户的分野,从一开始就避开了以太坊式账户体系的坑。比如在以太坊的设定下,EOA账户控制者必须在链上有ETH才可以发起交易,没有办法直接选用多样性的身份验证方式,要添加一些定制化的支付逻辑也极为麻烦。甚至有人认为,以太坊的这种账户设计简直就是反人类的。
如果我们去观察Starknet或zkSyncEra等主打“原生AA”的链,可以观察到明显的不同:首先,Starknet和zkSyncEra统一了账户类型,链上只有智能合约账户,从一开始就没有EOA账户这种东西(zkSync Era会在用户新创建的账户上,默认部署一套合约代码,模拟出以太坊EOA账户的特征,这样就便于兼容Metamask)。
而Starknet没有考虑直接兼容Metamask等以太坊周边设施,用户在初次使用Starknet钱包时,会自动部署专用的合约账户,说白了就是部署前面提到的合约实例,这个合约实例会和钱包项目方事先部署的合约class相关联,可以直接调用class里面写好的一些功能。
下面我们将谈及一个有意思的话题:在领取STRK空投时,很多人发现Argent与Braavos钱包彼此不能兼容,将Argent的助记词导入Braavos后,无法导出对应的账户,这其实是因为Argent和Braavos采用了不同的账户生成计算方式,导致相同助记词生成的账户地址不同。
具体而言,在Starknet中,新部署的合约地址可以通过确定性的算法得出,具体使用以下公式:
上述公式中的pedersen(),是一种易于在ZK系统中使用的哈希算法,生成账户的过程,其实就是给pedersen函数输入几个特殊参数,产生相应的hash,这个hash就是生成的账户地址。
上面的图片中显示了Starknet生成“新的合约地址”时用到的几个参数,deployer_address代表“合约部署者”的地址,这个参数可以为空,即便你事先没有Starknet合约账户,也可以部署新的合约。
salt为计算合约地址的盐值,简单来说,就是一个随机数,该变量实际上是为了避免合约地址重复引入的。class_hash就是前面介绍过的,合约实例对应的class的哈希值。而constructor_calldata_hash,代表合约初始化参数的哈希。
基于上述公式,用户可以在合约部署至链上之前,就预先算出生成的合约地址。Starknet允许用户在事先没有Starknet账户的情况下,直接部署合约,流程如下:
1. 用户先确定自己要部署的合约实例,要关联哪个合约class,把该class的hash作为初始化参数之一,并算出salt,得知自己生成的合约地址;
2. 用户知道自己将会把合约部署在哪后,先向该地址转入一定量的ETH,作为合约部署费用。一般来说,这部分ETH要通过跨链桥从L1跨到Starknet网络;
3. 用户发起合约部署的交易请求。
其实,所有的Starknet账户都是通过上述流程部署的,但大部分钱包屏蔽了这里面的细节,用户根本感知不到里面的过程,就好像自己转入ETH后合约账户就部署完了。
上述方案带来了一些兼容性问题,因为不同的钱包在生成账户地址时,生成的结果并不一致,只有满足以下条件的钱包才可以混用:
- 钱包使用的私钥派生公钥与签名算法相同;
- 钱包的salt计算流程相同;
- 钱包的智能合约class在实现细节上没有根本性不同;
在之前谈到的案例中,Argent与Braavos都使用了ECDSA签名算法,但双方的salt计算方法不同,相同的助记词在两款钱包中生成的账户地址会不一致。
我们再回到账户抽象的话题上。Starknet和zkSync Era把交易处理流程中涉及的一系列流程,如身份验证(验证数字签名)、Gas费支付等核心逻辑,全部挪到“链底层”之外去实现。用户可以在自己的账户中,自定义上述逻辑的实现细节.
比如你可以在自己的Starknet智能合约账户里,部署专用的数字签名验证函数,当Starknet节点收到了你发起的交易后,会调用你在链上账户中自定义的一系列交易处理逻辑。这样显然要更灵活。
而在以太坊的设计中,身份验证(数字签名)等逻辑是写死在节点客户端代码里的,不能原生支持账户功能的自定义。
按照zkSyncEra和Starknet官方人员的说法,这套账户功能模块化的思路,借鉴了EIP-4337。但不同的是,zkSync和Starknet从一开始就把账户类型合并了,统一了交易类型,并且用统一入口接收处理所有交易,而以太坊因为存在历史包袱,且基金会希望尽可能避免硬分叉等粗暴的迭代方案,所以支持了EIP-4337这种“曲线救国”的方案,但这样的效果是,EOA账户和4337方案各自采用独立的交易处理流程,显得别扭而且臃肿,不像原生AA那么灵便。
但目前Starknet的原生账户抽象还没有达到完全的成熟,从实践进度来看,Starknet的AA账户实现了签名验证算法的自定义,但对于手续费支付的自定义,目前Starknet实际上仅支持ETH和STRK缴纳gas费,并且还没有支持第三方代缴gas。所以Starknet在原生AA上的进度,可以说是“理论方案基本成熟,实践方案还在推进”。
由于Starknet内只有智能合约账户,所以其交易的全流程都考虑了账户智能合约的影响。首先,一笔交易被Starknet节点的内存池(Mempool)接收后,要进行校验,验证步骤包括:
- 交易的数字签名是否正确,此时会调用交易发起者账户中,自定义的验签函数;
- 交易发起人的账户余额能否支付得起gas费;
这里要注意,使用账户智能合约中自定义的签名验证函数,就意味着存在攻击场景。因为内存池在对新来的交易进行签名验证时,并不收取gas费(如果直接收取gas费,会带来更严重的攻击场景)。恶意用户可以先在自己的账户合约中自定义超级复杂的验签函数,再发起大量交易,让这些交易被验签时,都去调用自定义的复杂验签函数,这样可以直接耗尽节点的计算资源。
为了避免此情况的发生,StarkNet对交易进行了以下限制:
- 单一用户在单位时间内,可发起的交易笔数有上限;
- Starknet账户合约中自定义的签名验证函数,存在复杂度上的限制,过于复杂的验签函数不会被执行。Starknet限制了验签函数的gas消耗上限,如果验签函数消耗的gas量过高,则直接拒绝此交易。同时,也不允许账户合约内的验签函数调用其他合约。
Starknet交易的流程图如下:
值得注意的是,为了进一步加速交易校验流程,Starknet节点客户端中直接实现了Braavos和Argent钱包的签名验证算法,节点发现交易生成自这两大主流Starknet钱包时,会调用客户端里自带的Braavos/Argent签名算法,通过这种类似于缓存的思想,Starknet可以缩短交易验证时间。
交易数据再通过排序器的验证后(排序器的验证步骤比内存池验证会深入很多),排序器会将来自内存池的交易打包处理,并递交给ZK证明生成者。进入此环节的交易即使失败,也会被收取gas。
但如果读者了解Starknet的历史,会发现早期的Starknet对执行失败的交易不收取手续费,最常见的交易失败情况是,用户仅有1ETH 的资金,但是对外转出10ETH,这种交易显然有逻辑错误,最终必然失败,但在具体执行前谁也不知道结果是啥。
但StarkNet在过去不会对这种失败交易收取手续费。这种无成本的错误交易会浪费Starknet节点的计算资源,会衍生出ddos攻击场景。表面上看,对错误交易收取手续费似乎很好实现,实际上却相当复杂。Starknet推出新版的Cairo1语言,很大程度就是为了解决失败交易的gas收取问题。
我们都知道,ZK Proof是一种有效性证明,而执行失败的交易,其结果是无效的,无法在链上留下输出结果。尝试用有效性证明,来证明某笔指令执行无效,不能产生输出结果,听起来就相当奇怪,实际上也不可行。所以过去的Starknet在生成证明时,直接把不能产生输出结果的失败交易都刨除了出去。
Starknet团队后来采用了更聪明的解决方案,构建了一门新的合约语言Cairo1,使得“所有交易指令都能产生输出结果并onchain”。乍一看,所有交易都能产生输出,就意味着从不出现逻辑错误,而大多数时候交易失败,是因为遇到一些bug,导致指令执行中断了。
让交易永不中断并成功产生输出,很难实现,但实际上有一种很简单的替代方案,就是在交易遇到逻辑错误导致中断时,也让他产生输出结果,只不过这时候会返回一个False值,使大家知道这笔交易的执行不顺利。
但要注意,返回False值,也就返回了输出结果,也就是说,Cairo1里面,不管指令有没有遇到逻辑错误,有没有临时中断,都能够产生输出结果并onchain。这个输出结果可以是正确的,也可以是False报错信息。
For Example,假如存在以下代码段
此处的 _balances::read(from) – amount可能因为向下溢出而报错,这个时候就会导致相应的交易指令中断并停止执行,不会在链上留下交易结果;而如果将其改写为以下形式,在交易失败时仍然返回一个输出结果,留存在链上,单纯从观感上来看,这就好像所有的交易都能顺利的在链上留下交易输出,统一收取手续费就显得特别合理。
StarknetAA合约概述
考虑到本文有部分读者可能存在编程背景,所以此处简单展示了一下Starknet中的账户抽象合约的接口:
上述接口中的__validate_declare__,用于用户发起的declare交易的验证,而__validate__则用于一般交易的验证,主要验证用户的签名是否正确,而__execute__则用于交易的执行。我们可以看到Starknet合约账户默认支持multicall即多重调用。多重调用可以实现一些很有趣的功能,比如在进行某些DeFi交互时打包以下三笔交易:
- 第一笔交易将代币授权给DeFi合约
- 第二笔交易触发DeFi合约逻辑
- 第三笔交易清空对DeFi合约的授权
当然,由于多重调用是具有原子性的,所以存在一些更加复杂的用法,比如执行某些套利交易。
总结
- Starknet最主要的几大技术特性,包括利于ZK证明生成的Cairo语言、原生级别的AA、业务逻辑与状态存储相独立的智能合约模型。
- Cairo是一种通用的ZK语言,既可以在Starknet上实现智能合约,也可以用于开发偏传统的应用,其编译流程中引入Sierra作为中间语言,使得Cairo可以频繁迭代,但又不必变更最底层的字节码,只需要把变化传导至中间语言身上;在Cairo的标准库内,还纳入了账户抽象所需要的许多基本数据结构。
- Starknet智能合约将业务逻辑与状态数据分开来存储,不同于EVM链,Cairo合约部署包含“编译、声明、部署”三阶段,业务逻辑被声明在Contract class中,包含状态数据的Contract实例可以与class建立关联,并调用后者包含的代码;
- Starknet的上述智能合约模型利于代码复用、合约状态复用、存储分层、检测垃圾合约,也利于存储租赁制和交易并行化的实现。虽然后两者目前暂未落地,但Cairo智能合约的架构,还是为其创造了“必要条件”。
- Starknet链上只有智能合约账户,没有EOA账户,从一开始就支持原生级别的AA账户抽象。其AA方案一定程度吸收了ERC-4337的思路,允许用户选择高度定制化的交易处理方案。为了防止潜在的攻击场景,Starknet做出了诸多反制措施,为AA生态做出了重要的探索。