账户抽象
本文详细介绍了 Starknet 上的原生账户抽象(Account Abstraction)实现。
账户抽象
在以太坊上,账户默认是外部拥有账户(EOA)。每个账户由一个私钥控制,如果私钥泄露,就无法限制损失或撤销访问权限。如果密钥丢失,账户将永远消失。
EOA 的操作方式也非常僵化。没有原生支持将多个操作捆绑到单个交易中。用户必须持有原生 gas 代币来支付费用,无论他们实际使用的是哪种代币。也没有内置的方法来添加诸如多重签名、密钥恢复等规则。
Starknet 没有 EOA。每个账户都是一个智能合约,称为账户合约。
将每个账户都变成智能合约就是账户抽象(AA),它通过使账户逻辑可编程来解决上述限制。
在本文中,我们将学习什么是账户抽象,Starknet 的方法与以太坊相比如何,它启用了哪些功能,以及如何在 Starknet 上构建账户合约。
什么是账户抽象?
账户抽象(AA)是一种区块链设计模式,它使账户变得可编程。交易如何被验证和执行不再由协议决定,而是由账户本身定义该逻辑。在 Starknet 上,这意味着所有交易在执行前都必须通过账户合约进行验证。
由于账户逻辑是可编程的,因此可以实现诸如恢复选项、交易批处理、使用任何代币支付 gas 以及细粒度权限等功能。
当可编程账户直接实现在协议层面时,账户抽象被认为是原生的。或者,它也可以作为一层叠加在现有协议之上,但这种实现无法提供原生账户抽象的所有好处。Starknet 采用原生方法,从一开始就内置在协议中。
账户抽象与 EOA 的区别
使用 EOA 时,私钥(签名者)和账户是紧密耦合的。账户地址是从私钥派生出来的,而同一个私钥是签署交易的唯一方式。私钥就是账户。
使用账户抽象时,签名者和账户合约是解耦的。这种分离使得账户可编程:因为账户不再与私钥绑定,所以谁可以授权交易以及如何执行的逻辑都可以进行定制。在 Starknet 上,其工作方式如下:
- 签名者是私钥,由用户设备上的钱包软件持有,在用户批准交易时用于签署交易。
- 账户合约是在链上部署的智能合约,它持有用户的资产,并定义了验证和执行交易的逻辑。
当用户发起一个操作时,钱包使用私钥在链下签署交易详情,并生成一个证明用户已授权交易的签名。然后,该交易连同此签名被发送到排序器——在 Starknet 上对交易进行排序和构建区块的节点——排序器将其转发给用户的账户合约。账户合约根据交易详情验证签名,如果有效,则执行交易。
由于验证逻辑存在于账户合约内部而不是协议层面,账户所有者可以自由定义“有效”的含义。账户合约可以要求单一密钥签名、多重密钥签名,或者开发者选择实现的任何自定义验证逻辑。
以太坊也有账户抽象
账户抽象并非新鲜事物。早在 2017 年,Vitalik Buterin 就提出了轻量账户合约,即账户可以是具有自身验证逻辑的合约,而不是仅仅依赖协议层面的签名检查。
此后,它在以太坊生态系统中获得了关注。目前最成熟的实现是 EIP-4337,它在不要求修改以太坊协议本身的情况下引入了智能合约钱包。EIP-4337 已部署在主网上,并有多个生产就绪的实现。
最近,作为 Pectra 升级的一部分引入的 EIP-7702 将账户抽象更接近协议层面。它允许 EOA 指向一个已部署的智能合约,从而使其获得智能账户功能,如交易批处理和 gas 抽象,而无需迁移到新地址。这个过程称为委托,是可选的,并且在用户通过委托到零地址将其移除之前一直有效。
创建 Starknet 账户时会发生什么
每当你使用钱包软件创建一个新账户时,钱包都会部署一个新的账户合约。正如我们在“理解 Starknet 的合约部署模型”一章中所讨论的,可以从同一个类创建多个合约实例,共享相同的代码,但每个实例都有自己的地址和存储。钱包提供商在 Starknet 上声明其账户类一次,之后创建的每个新钱包只是该类的一个新实例。
账户合约的构造函数使用诸如用户公钥等参数进行初始化,生成的账户地址是确定性的:它由多个输入派生而来,包括类哈希、盐值、部署者地址和构造函数调用数据。
回想一下,账户甚至可以在部署之前接收资产,因为其地址是确定性的,但在账户合约部署之前,它无法执行任何交易。
一旦部署,账户合约就绑定到其逻辑,切换到不同的账户合约意味着完全创建一个新账户。
在账户合约之间切换
理论上,任何人都可以自由实现自己的账户合约。在协议层面,要求是:
- 实现
__validate__和__execute__,即协议在交易生命周期中调用的入口点。 - 根据预期功能,实现
__validate_declare__或__validate_deploy__。 - 遵循 SNIP-6 标准,该标准增加了
is_valid_signature函数,用于与 dApp 和链下签名验证的互操作性。
我们将在下一节中详细解释这些函数。
然而,在实践中,使用自定义的账户合约实现是困难的。即使你遵循了标准并且一切在技术上工作正常,你仍然需要通过某些钱包软件来使用这个账户合约。
由于任何人都可以在最低账户抽象要求之上自由添加功能,大多数流行的钱包(如 Ready)都有自己特定的账户合约实现。这些实现是不可互换的。这使得在它们之间切换或使用你自己的实现变得困难。
账户合约的结构
让我们看一个非常简化的账户合约实现。它被故意简化且高度不安全,它会批准所有交易和签名,而不进行任何检查。目的是说明 Starknet 账户合约所需的最小结构,仅用于演示目的,不可用于生产环境。
创建一个新的 Scarb 项目并进入其中:
scarb new aa
cd aa
首先,我们将 Call 结构体导入 src/lib.cairo。这个结构体表示交易中的单个操作。每个 Call 包含目标合约地址、函数选择器和要传递的调用数据。我们的账户合约将使用它来知道要调用哪个合约以及使用什么数据。
use starknet::account::Call;
接下来,我们定义每个账户合约预期实现的 SNIP-6 接口(ISRC6)。它有三个函数:
__validate__用于交易验证,__execute__用于交易执行,以及is_valid_signature用于链下签名验证:
use starknet::account::Call;
//////新增开始///////
#[starknet::interface]
trait ISRC6<T> {
fn __validate__(self: @T, calls: Array<Call>) -> felt252;
fn __execute__(ref self: T, calls: Array<Call>) -> Array<Span<felt252>>;
fn is_valid_signature(self: @T, hash: felt252, signature: Array<felt252>) -> felt252;
}
现在让我们定义账户合约本身。#[starknet::contract(account)] 属性告诉编译器这是一个账户合约,这使得协议能够在交易处理期间调用其 __validate__ 和 __execute__ 入口点。如果没有这个属性,该合约将被视为普通智能合约,不能用作账户。我们还导入了 call_contract_syscall,我们将在 __execute__ 中使用它来调用其他合约,以及 SyscallResultTrait 来处理这些调用的结果:
use starknet::account::Call;
#[starknet::interface]
trait ISRC6<T> {
fn __validate__(self: @T, calls: Array<Call>) -> felt252;
fn __execute__(ref self: T, calls: Array<Call>) -> Array<Span<felt252>>;
fn is_valid_signature(self: @T, hash: felt252, signature: Array<felt252>) -> felt252;
}
//////新增开始///////
#[starknet::contract(account)]
mod Account {
use starknet::SyscallResultTrait;
use starknet::syscalls::call_contract_syscall;
use super::Call;
#[storage]
struct Storage {}
}
现在让我们实现每个函数。
__validate__ 函数
协议在交易执行前调用此函数。在生产环境的账户合约中,这是验证交易签名以确认调用者是否被授权的地方。在我们的实现中,它只是为每个交易返回 'VALID',而不进行任何检查:
#[abi(embed_v0)]
impl AccountImpl of super::ISRC6<ContractState> {
fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 {
'VALID'
}
}
__execute__ 函数
协议在 __validate__ 通过后调用此函数。它接收一个调用数组,并使用 call_contract_syscall 按顺序执行它们。这就是启用多调用(multicall)的原因,即在单个交易中执行多个操作的能力:
fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> {
let mut results = ArrayTrait::new();
for call in calls {
let result = call_contract_syscall(call.to, call.selector, call.calldata)
.unwrap_syscall();
results.append(result);
}
results
}
is_valid_signature 函数
此函数不由协议调用。它用于链下签名验证,允许 dApp 确认签名属于此账户。这里它返回 'VALID',不检查任何内容:
fn is_valid_signature(
self: @ContractState, hash: felt252, signature: Array<felt252>,
) -> felt252 {
'VALID'
}
这个示例合约跳过了所有验证,绝不应在生产环境中使用。生产环境的账户合约必须实现适当的验证和签名检查,以保护用户资产和访问权限。
以下是完整的 Account 合约代码:
use starknet::account::Call;
#[starknet::interface]
trait ISRC6<T> {
fn __validate__(self: @T, calls: Array<Call>) -> felt252;
fn __execute__(ref self: T, calls: Array<Call>) -> Array<Span<felt252>>;
fn is_valid_signature(self: @T, hash: felt252, signature: Array<felt252>) -> felt252;
}
#[starknet::contract(account)]
mod Account {
use starknet::SyscallResultTrait;
use starknet::syscalls::call_contract_syscall;
use super::Call;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl AccountImpl of super::ISRC6<ContractState> {
fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 {
'VALID'
}
fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> {
let mut results = ArrayTrait::new();
for call in calls {
let result = call_contract_syscall(call.to, call.selector, call.calldata)
.unwrap_syscall();
results.append(result);
}
results
}
fn is_valid_signature(
self: @ContractState, hash: felt252, signature: Array<felt252>,
) -> felt252 {
'VALID'
}
}
}
让我们在本地 devnet 中使用这个 Account 合约,看看它如何与其他合约交互。稍后,在“账户合约是如何被调用的”一节中,我们将解释协议在每个交易背后做了什么。
部署并与我们的账户合约交互
一个最小的账户合约能够发起针对其他合约的调用交易。但是,它不能声明和部署其他合约。由于本教程流程涉及声明和部署另一个合约,我们需要添加两个额外的验证函数:
__validate_declare__:当用户想要声明一个新的合约类时,由协议调用。__validate_deploy__:在DEPLOY_ACCOUNT交易期间,当新账户合约首次部署时,由协议调用。
正如 __validate__ 在执行之前验证调用交易一样,__validate_declare__ 对声明交易执行相同的操作,__validate_deploy__ 对账户部署交易执行相同的操作。
将这两个函数添加到接口中:
fn __validate_declare__(self: @T, class_hash: felt252) -> felt252;
fn __validate_deploy__(
self: @T, class_hash: felt252, contract_address_salt: felt252, public_key: felt252,
) -> felt252;
同时在合约中添加它们的实现:
fn __validate_declare__(self: @ContractState, class_hash: felt252) -> felt252 {
return 'VALID';
}
fn __validate_deploy__(
self: @ContractState,
class_hash: felt252,
contract_address_salt: felt252,
public_key: felt252,
) -> felt252 {
return 'VALID';
}
借助 Starknet 的原生账户抽象,不同的钱包提供商可以在保持兼容性的同时定制其验证函数。例如,Ready 的账户合约使用自定义参数定义了 __validate_deploy__:
// Ready 的自定义验证函数
__validate_deploy__(
class_hash: felt252,
contract_address_salt: felt252,
owner: Signer, // 使用 Signer 类型
guardian: Option<Signer> // 添加守护者支持
) -> felt252
注意,Ready 的版本使用 owner: Signer 和 guardian: Option<Signer> 而不是标准的 public_key: felt252 参数。这种定制允许 Ready:
- 使用其自定义的
Signer类型而不是简单的公钥,它可以表示不同的签名方案。 - 为社交恢复添加守护者功能。守护者是一个受信任的方(例如另一个钱包、朋友或钱包提供商本身),如果主签名者丢失,可以帮助恢复对账户的访问。
尽管参数不同,Ready 的 __validate_deploy__ 仍然调用相同的底层验证逻辑,检查签名并确保部署已授权。自定义参数只允许 Ready 通过其验证过程传递附加信息(如守护者密钥)。
这种在遵循 Starknet 期望的验证接口的同时定制验证参数的灵活性,是账户抽象带来的好处之一。它允许钱包提供商在不偏离协议的情况下添加诸如多重签名支持、会话密钥或社交恢复等功能。
以下是添加了 __validate_declare__ 和 __validate_deploy__ 的完整 Account 合约代码:
use starknet::account::Call;
#[starknet::interface]
trait ISRC6<T> {
fn __validate__(self: @T, calls: Array<Call>) -> felt252;
fn __execute__(ref self: T, calls: Array<Call>) -> Array<Span<felt252>>;
fn is_valid_signature(self: @T, hash: felt252, signature: Array<felt252>) -> felt252;
fn __validate_declare__(self: @T, class_hash: felt252) -> felt252;
fn __validate_deploy__(
self: @T, class_hash: felt252, contract_address_salt: felt252, public_key: felt252,
) -> felt252;
}
#[starknet::contract(account)]
mod Account {
use starknet::SyscallResultTrait;
use starknet::syscalls::call_contract_syscall;
use super::Call;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl AccountImpl of super::ISRC6<ContractState> {
fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 {
'VALID'
}
fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> {
let mut results = ArrayTrait::new();
for call in calls {
let result = call_contract_syscall(call.to, call.selector, call.calldata)
.unwrap_syscall();
results.append(result);
}
results
}
fn is_valid_signature(
self: @ContractState, hash: felt252, signature: Array<felt252>,
) -> felt252 {
'VALID'
}
fn __validate_declare__(self: @ContractState, class_hash: felt252) -> felt252 {
return 'VALID';
}
fn __validate_deploy__(
self: @ContractState,
class_hash: felt252,
contract_address_salt: felt252,
public_key: felt252,
) -> felt252 {
return 'VALID';
}
}
}
将 src/lib.cairo 的内容替换为上面的 Account 合约。
添加一个要交互的合约
由于我们想演示如何使用账户合约,我们需要有其他合约来进行交互。让我们在同一个 src/lib.cairo 文件中添加一个简单的 Counter 合约:
#[starknet::interface]
pub trait ICounter<TContractState> {
fn increase_counter(ref self: TContractState, amount: felt252);
fn get_counter(self: @TContractState) -> felt252;
}
#[starknet::contract]
mod Counter {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
counter: felt252,
}
#[abi(embed_v0)]
impl CounterImpl of super::ICounter<ContractState> {
fn increase_counter(ref self: ContractState, amount: felt252) {
self.counter.write(self.counter.read() + amount);
}
fn get_counter(self: @ContractState) -> felt252 {
self.counter.read()
}
}
}
运行 scarb build 编译合约。
使用我们的账户合约启动 devnet
如果你使用 starkup 安装了 Starknet 开发工具,starknet-devnet 应该已经安装。你可以通过运行以下命令验证:
starknet-devnet --version
在撰写本文时,预期版本是 0.7.2。如果尚未安装,首先添加插件,然后安装:
asdf plugin add starknet-devnet
asdf install starknet-devnet 0.7.2
asdf set starknet-devnet 0.7.2
现在让我们启动 devnet。默认情况下,starknet-devnet 会使用标准账户合约预部署一组有资金的账户。我们希望它们使用我们的账户合约,因此将其作为标志传递:
starknet-devnet --seed 1 --account-class-custom target/dev/aa_Account.contract_class.json
--seed参数确保每次 devnet 重启时生成相同的预部署账户(地址和密钥),这样你就不需要在每次重启后重新导入它们。--account-class-custom标志告诉 devnet 为预部署的账户使用我们编译的账户合约。文件名结合了你的 Scarb 项目名称(aa)和合约名称(Account)。

导入一个账户以发送交易
devnet 现在使用我们的账户合约预部署了账户,但我们本地机器上的 sncast 还不知道它们。我们需要导入一个,以便 sncast 可以使用它来发送交易。打开一个新终端(因为当前终端正在运行 devnet),然后从 devnet 列出的第一个账户复制地址和私钥,并在导入命令中使用它们:
sncast \
account import \
--url http://127.0.0.1:5050 \
--address <预部署账户地址> \
--private-key <预部署私钥> \
--type oz
--url标志指向本地 devnet RPC 端点。- 由于我们的账户合约不验证签名,这里的私钥值无关紧要。但是,
sncast要求它作为必填字段,因为大多数账户合约依赖签名进行授权。 --type标志告诉sncast如何为账户格式化交易。可用选项是ready、braavos或oz(OpenZeppelin)。由于我们的自定义账户不匹配任何特定的钱包提供商,我们使用oz作为最接近的通用选项。
运行命令后,sncast 会询问你是否要使此账户成为默认账户。选择本地默认选项,使该账户仅限定于此项目。

下图显示了 devnet 输出(左侧)如何映射到导入命令(右侧)。黄线连接账户地址,红线将 devnet 预部署账户的私钥连接到 sncast account import 命令中的相应字段。

记下导入输出中的账户名称,并在后续命令中使用它替换
<账户名称>。
如果你需要以不同的详细信息重新导入账户,或者在测试后清理,可以使用以下命令删除它:
sncast \
account delete \
--url http://127.0.0.1:5050 \
--name <账户名称>
声明并部署 Counter 合约
现在我们有了一个导入的账户合约,让我们用它来声明和部署我们的 Counter 合约。
声明合约:
sncast \
--account <账户名称> \
declare \
--url http://127.0.0.1:5050 \
--contract-name Counter

记下结果中的类哈希,并在以下命令中替换它以部署合约:
sncast \
--account <账户名称> \
deploy \
--class-hash <类哈希> \
--url http://127.0.0.1:5050

记下结果中的合约地址。
与 Counter 合约交互
我们现在准备与 Counter 合约交互。请记住,我们导入的预部署账户在本地存储,名称是我们在导入步骤中指定的 <账户名称>。
运行以下命令来增加计数器。将 <合约地址> 替换为部署输出中的合约地址:
sncast \
--account <账户名称> \
invoke \
--url http://127.0.0.1:5050 \
--contract-address <合约地址> \
--function 'increase_counter' \
--arguments 23

我们现在可以查询以查看新的计数器值。它应该返回 23(以十六进制编码):
sncast \
call \
--url http://127.0.0.1:5050 \
--contract-address <合约地址> \
--function 'get_counter'

账户(AA)合约是如何被调用的
在上面的示例流程中,我们:
- 使用我们的账户合约作为预部署的账户实现启动了一个 devnet。
- 导入了一个预部署的账户,以便
sncast可以在本地使用它。 - 在 devnet 中声明了一个新合约:
Counter。 - 部署了
Counter合约。 - 在 Counter 合约上调用了
increase_counter。 - 调用了
get_counter以读取计数器值。
在幕后,在步骤 3-6 期间,协议在每个步骤都调用了我们账户合约的验证和执行函数:
- 步骤 3 - 声明 Counter 合约: 这是一个
DECLARE交易。排序器在我们的账户合约上调用了__validate_declare__。由于它返回了'VALID',排序器将Counter类注册到了网络上。 - 步骤 4 - 部署 Counter 合约: 这不是一个
DEPLOY_ACCOUNT交易,因为Counter是一个常规合约,而不是账户。它通过通用部署器合约作为INVOKE交易进行。排序器在我们的账户合约上调用了__validate__,验证通过后,调用了__execute__来处理部署。 - 步骤 5 - 调用
increase_counter: 这是一个INVOKE交易。排序器在我们的账户合约上调用了__validate__,验证通过后,调用了__execute__,它将调用转发给了Counter合约。 - 步骤 6 - 调用
get_counter: 这是一个只读调用。没有提交交易,不支付 gas,我们的账户合约根本不参与。
注意,在我们的流程中从未触发
__validate_deploy__。此函数仅在DEPLOY_ACCOUNT交易期间调用,该交易用于从头部署账户合约(反事实部署)。由于 devnet 为我们预部署了账户,因此没有DEPLOY_ACCOUNT交易。只有当我们创建并部署一个新账户时,才会调用它。
要查看验证的实际效果,请尝试将 Account 合约中 __validate_declare__ 的返回值从 'VALID' 更改为 'INVALID'。重新构建合约,使用更新的合约重启 devnet:
starknet-devnet --seed 1 --account-class-custom target/dev/aa_Account.contract_class.json
然后像我们之前那样导入第一个预部署账户,并再次尝试声明 Counter 合约。你会看到交易失败,并显示如下错误:
Error: Transaction execution error: The `validate` entry point should return
`VALID`. Got Retdata([0x494e56414c4944]).
这证实了协议会检查验证函数的返回值,并拒绝任何未返回 'VALID' 的交易。
恢复账户
由于 EOA 地址直接派生自私钥,正如我们之前讨论的,仅凭私钥恢复账户是直接的。但是,如果密钥丢失,则没有恢复选项。
在 Starknet 上,由于签名者和账户是解耦的,恢复访问需要同时拥有私钥和账户地址,因为两者不能互相派生。这就是为什么在像 Ready 这样的钱包上导入账户需要同时提供这两者。然而,由于账户是一个智能合约,开发者或钱包提供商可以实现替代的恢复机制。例如,Ready 使用我们之前讨论的守护者系统来帮助用户在主签名者丢失时重新获得对账户的访问。
账户抽象启用的功能
账户抽象引入了使用 EOA 很难或不可能实现的功能。这些包括:
- 使用任何代币支付 gas。无需使用原生 gas 代币支付费用,支付服务(paymaster)可以接受用户的代币,进行兑换,并在幕后以所需的 gas 代币支付费用。用户仍然需要支付,只是使用的代币不同。
- 赞助交易。第三方可以完全赞助 gas 费用,允许用户免费提交交易。这通常用于用户引导或补贴应用使用。
- 自定义签名方案。虽然大多数 Starknet 账户使用 ECDSA 签名,但
__validate__函数可以实现任何验证逻辑,包括不同的加密方案,甚至在特殊情况下完全跳过签名检查。 - 多重签名和自定义访问控制。账户合约可以要求多方批准交易,强制执行基于时间的规则,或实现任何自定义的访问逻辑。
- 账户恢复。如果主要访问方式失败(例如由于密钥丢失),可以在账户合约中内置替代的恢复选项。
- 速率限制账户。账户合约可以限制给定时间段内的交易数量,这对于具有使用上限的赞助账户很有用。
安全权衡
通过账户抽象,每个额外的功能(自定义逻辑、恢复、多重签名、速率限制)都会增加账户合约的复杂性。此逻辑中的错误或配置错误可能导致访问权限或资金的无常损失。
此外,Starknet 协议仍在发展中。正在引入新的与账户抽象相关的功能,最佳实践也在积极变化中。保持对最新发展的关注很重要。你应该只使用经过审计的账户合约。
结论
账户抽象使账户可编程。它实现了诸如使用任何代币支付 gas、赞助交易、多重签名方案和自定义恢复方法等功能,这些功能使用 EOA 根本不可能实现。Starknet 的原生实现意味着这些功能默认可用于每个账户。
然而,这些好处也有代价。安全地实现账户合约是复杂的,并且如我们之前所讨论的,在不同钱包提供商的实现之间切换仍然困难。
最终,账户抽象是为了改善用户体验。它消除了诸如需要特定 gas 代币或直接管理私钥等障碍,使新用户的引导过程更加顺畅。
- 原文链接: rareskills.io/post/cairo...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~