简要了解开罗 1.0、Starknet 及其安全考虑因素。
在这篇文章中,我们将会探讨Cairo 1.0,Starknet的原生语言。我们将对 Cairo和Starknet进行简要介绍,探索 Cairo的一些安全特性,并审视在 Cairo中编写合约时可能遇到的一些潜在陷阱。对于任何考虑在 Cairo中编写合约的人来说,这篇文章将为您提供一个起点和编写安全代码时需要考虑的一些事项。
Cairo 1.0 是一种受Rust启发的语言,旨在让任何人都能创建STARK可证明的智能合约。它是Starknet的原生语言,Starknet是一个为高吞吐量和低gas成本而设计的zkRollup。在这篇文章中,我们将重点关注在Starknet上编写智能合约时使用Cairo的安全特性。 首先,让我们开始浏览一个用Cairo 1.0编写的简单合约:
#[contract]
mod hello {
use starknet::get_caller_address;
use starknet::ContractAddress;
struct Storage {
last_caller: ContractAddress,
}
#[event]
fn Hello(from: ContractAddress, value: felt252) {}
#[external]
fn say_hello(message: felt252) {
let caller = get_caller_address();
last_caller::write(caller);
Hello(caller, message);
}
#[view]
fn get_last_caller() -> ContractAddress {
last_caller::read()
}
}
如果您之前使用过Rust,那么上述代码可能看起来很熟悉,因为Cairo 1.0在很大程度上受到了它的启发。如果您对此不熟悉,那么starklings-cairo1是一个很好的起点。Starklings之于Cairo,就像Rustlings之于Rust,一系列小型互动练习帮助您学习这门语言。
Cairo 1.0中变量的默认类型是一个名为felt252
的field元素,它是范围在0≤x<P
的整数,其中P是一个非常大的质数=2^251+17*2^192+1。Cairo中的所有其他类型都是基于felt252构建的,例如u8到u256的整数类型。建议尽可能使用这些高级类型,因为它们提供了额外的安全功能,如溢出保护。
在编写Starknet合约时,有一些特殊属性被用来允许编译器生成正确的代码。#[contract]
属性用于定义一个Starknet合约,类似于Solidity中的contract关键字。
合约可能需要与另一个合约交互,或者需要了解当前执行状态的一些信息(例如,调用者地址)。这就是系统调用的作用,它允许合约与Starknet OS交互并使用其服务。大多数时候,系统调用被抽象出来或隐藏在辅助方法后面。
#[event]
属性用于定义合约可以发出的事件。类似于Solidity,事件用于通知外部世界合约状态的变化,并通过emit_event_syscall
系统调用或调用编译器生成的带有#[event]
属性的辅助函数来发出。
#[external]
属性用于定义外部世界可以调用的函数,类似于Solidity中的external关键字。#[view]
属性旨在表明一个函数不修改合约状态,尽管这不是由编译器强制执行的,所以如果函数在链上被调用,状态变化是可能的。
struct Storage
是一个特殊的结构体,编译器使用它来生成与合约存储交互的辅助方法,使用低级系统调用storage_read_syscall
和storage_write_syscall
。在Starknet合约中,存储是一个由2^251个槽组成的映射,每个槽都可以被读取或修改。每个槽都是一个初始设置为0的felt。Storage结构体中的字段被转换为带有读写方法的模块,这些方法会自动计算存储映射中的正确位置,并可用于读写存储。
在您可以在Starknet上部署合约之前,合约类必须首先在网络上声明。网络上声明的每个类都由一个clash hash表示,它唯一地标识了它,并可以用来部署新的合约实例。
与以太坊不同,Starknet没有外部拥有的账户(EOAs)。相反,账户是特殊的合约,可以定义它们自己的逻辑和规则。以下是一个通用账户合约的接口:
#[account_contract]
mod Account {
use starknet::ContractAddress;
#[constructor]
fn constructor(public_key_: felt252);
fn isValidSignature() -> felt252;
#[external]
fn __validate_deploy__(
class_hash: felt252, contract_address_salt: felt252, public_key_: felt252
) -> felt252;
#[external]
fn __validate_declare__(class_hash: felt252) -> felt252;
#[external]
fn __validate__(
contract_address: ContractAddress, entry_point_selector: felt252, calldata: Array<felt252>
) -> felt252;
#[external]
#[raw_output]
fn __execute__(mut calls: Array<Call>) -> Span<felt252>;
}
要使合约成为有效的账户,它至少必须实现__validate__
和__execute__
函数,并且可以选择性地实现其他函数。__validate__
函数应确保交易是由账户所有者发起的,而__execute__
函数将执行剩余的操作。
实现可以简单到检查一个ECDSA签名,或者可以是从多重签名到允许多次调用的任何事情。
Starknet作为一个zkRollup的主要好处之一是,用Cairo编写的合约允许在以太坊L1上证明和验证执行轨迹。它被设计为提供灵活性,但这也可能导致不安全的代码。在这一部分,我们将看一些潜在的陷阱。
在使用如u128和u256这样的整数类型时,现在有一些很好的内置溢出保护,会导致程序崩溃——例如,
let a: u128 = 0xffffffffffffffffffffffffffffffff;
let b: u128 = 1;
let c: u128 = a + b;
// Run panicked with [39878429859757942499084499860145094553463 ('u128_add Overflow'), ].
当直接使用felts时,情况并非如此,因为溢出仍然可能发生:
let a: felt252 = 0x800000000000011000000000000000000000000000000000000000000000000;
let b: felt252 = 1;
let c: felt252 = a + b;
c.print();
// [DEBUG] (raw: 0)
如果您用#[abi]
属性标记一个trait,那么编译器将自动基于特性名称生成两个dispatchers;例如,对于ICallback
trait,生成的名称将是ICallbackDispatcher
和ICallbackLibraryDispatcher
。dispatchers是一个简单的结构体,包装了call_contract
系统调用,允许您调用其他合约。Library dispatchers是一个简单的结构体不同之处在于,执行外部代码时将使用当前合约的上下文和存储,类似于Solidity中的delegatecall。
由于合约dispatchers将控制权传递给外部合约,外部合约可能会回调当前合约,这可能导致重入性错误。例如,考虑以下合约:
#[abi]
trait ICallback {
#[external]
fn callback();
}
#[contract]
mod reentrancy {
use option::OptionTrait;
use starknet::get_caller_address;
use starknet::ContractAddress;
use super::ICallbackDispatcher;
use super::ICallbackDispatcherTrait;
struct Storage {
balances: LegacyMap::<ContractAddress, u256>,
claimed: LegacyMap::<ContractAddress, bool>,
}
#[external]
fn claim(callback: ContractAddress) {
let caller = get_caller_address();
if !claimed::read(caller) {
ICallbackDispatcher { contract_address: callback }.callback();
balances::write(caller, balances::read(caller) + 100);
claimed::write(caller, true);
}
}
#[external]
fn transfer(to: ContractAddress, amount: u256) {
let caller = get_caller_address();
balances::write(caller, balances::read(caller) - amount);
balances::write(to, balances::read(to) + amount);
}
#[view]
fn get_balance(addr: ContractAddress) -> u256 {
balances::read(addr)
}
}
claim函数允许用户从合约中领取100个代币,如果他们还没有领取过,但由于回调发生在状态更新之前,合约可能会反复调用claim函数并领取他们想要的任意数量的代币:
use starknet::ContractAddress;
#[abi]
trait IClaim {
#[external]
fn claim(callback: ContractAddress);
}
#[contract]
mod hello {
use starknet::get_caller_address;
use starknet::get_contract_address;
use super::IClaimDispatcher;
use super::IClaimDispatcherTrait;
struct Storage {
count: u256,
}
#[external]
fn callback() {
if (count::read() < 10) {
count::write(count::read() + 1);
IClaimDispatcher { contract_address: get_caller_address() }.claim(get_contract_address());
} else {
count::write(0);
}
}
}
当使用library dispatcher时,您必须提供一个类哈希而不是合约地址,因此您不会意外使用错误的dispatcher时。执行的代码将使用与当前合约相同的上下文和存储,因此类哈希必须是可信的。
在使用存储结构体时,存储槽的底层地址是使用sn_keccak(variable_name)
计算的(sn_keccak是Keccak256哈希的前250位)。如果您使用其他模块或外部库,它们有类似的存储结构体,那么存储槽可能会相同并相互覆盖。例如,考虑以下合约:
// foo.cairo
#[contract]
mod foo {
struct Storage {
num: u256,
}
fn get_num() -> u256 {
num::read()
}
fn set_num(n: u256) {
num::write(n);
}
}
// bar.cairo
use super::foo::foo;
#[contract]
mod bar {
struct Storage {
num: u128,
}
#[external]
fn set_num(n: u128) {
num::write(n)
}
#[view]
fn get_num() -> u128 {
num::read()
}
#[view]
fn foo_get_num() -> u256 {
super::foo::get_num()
}
#[external]
fn foo_set_num(n: u256) {
super::foo::set_num(n);
}
}
这两个设置器都写入同一个存储槽,只是一个期望u256,另一个期望u128,所以当调用set_num时,num的底部128位将被设置,顶部128位不会改变。例如,如果我们用0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff调用foo_set_num,然后用0x1234调用set_num,然后foo_get_num,我们得到以下输出:
starknet call --address 0x005e942196b3e1adfac0e1d2664d69671188237db343067ab61048e63957487c --function foo_get_num
4660 0xffffffffffffffffffffffffffffffff
从Cairo 0到1.0的变化是使语言易于使用并增加一些不错的安全特性的重要一步。然而,仍然有可能编写不安全的代码,因此理解底层系统、潜在的陷阱以及如何避免它们是非常重要的。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!