在 Ethereum 上,账户默认是外部拥有账户(EOAs)。每个账户由一个私钥控制,如果私钥被泄露,就没有办法限制损失或撤销访问权限。如果私钥丢失,账户将永远消失。
EOAs 的运行方式也非常僵化。它们原生不支持将多个操作打包成单个交易。无论用户实际使用的是什么代币,他们都必须持有原生的 gas 代币来支付手续费。并且没有内置的方法来添加多方审批、密钥恢复等规则。
Starknet 没有 EOAs。每个账户都是一个智能合约,被称为账户合约。
将每个账户都变成智能合约就是账户抽象(Account Abstraction,简称 AA),它通过让账户逻辑变得可编程,解决了上述局限性。
在本文中,我们将学习什么是账户抽象,Starknet 的方法与 Ethereum 相比有何不同,它启用了哪些功能,以及在 Starknet 上是如何构建账户合约的。
什么是账户抽象?
账户抽象(AA)是一种使账户可编程的区块链设计模式。不是由协议来规定如何验证和执行交易,而是由账户本身来定义该逻辑。在 Starknet 上,这意味着所有交易在执行之前都必须通过账户合约进行验证。
由于账户逻辑是可编程的,诸如恢复选项、交易批处理、使用任何代币支付 gas 以及细粒度权限等功能都成为了可能。
当可编程账户直接在协议层面实现时,账户抽象被认为是原生的。或者,它可以分层叠加在现有协议之上,但这种实现并不能提供原生账户抽象的所有好处。Starknet 采用了原生方法,从一开始就将其内置于协议中。
账户抽象与 EOAs 的区别
对于 EOAs,私钥(签名者)和账户是紧密耦合的。账户地址从私钥派生而来,而这把相同的私钥是签名交易的唯一方式。私钥即账户。
对于账户抽象,签名者和账户合约是解耦的。这种分离正是让账户变得可编程的原因:由于账户不再与私钥绑定,因此可以自定义谁能授权交易以及交易如何执行的逻辑。在 Starknet 上,其工作原理如下:
- 签名者是私钥,保存在用户设备上的钱包软件中,用于在用户批准交易时对其进行签名。
- 账户合约是部署在链上的智能合约,它持有用户的资产并定义了验证和执行交易的逻辑。
当用户发起一个操作时,钱包会使用私钥在链下对交易详情进行签名,并生成一个证明用户已授权该交易的签名。随后,包含此签名的交易会被发送到定序器(Starknet 上负责排序交易并构建区块的节点),定序器会将其转发给用户的账户合约。账户合约会根据交易详情验证签名,如果有效,则执行该交易。
因为验证逻辑存在于账户合约内部而非协议层,所以账户所有者可以自由定义什么是“有效”。账户合约可以要求单密钥签名、多密钥签名,或者开发者选择实现的任何自定义验证逻辑。
Ethereum 也有账户抽象
账户抽象并不是什么新概念。早在 2017 年,Vitalik Buterin 就提出了light account contracts的构想,即账户可以成为拥有自身验证逻辑的合约,而不是仅仅依赖协议层面的签名检查。
从那时起,它在 Ethereum 生态系统中获得了广泛关注。如今最成熟的实现是 EIP-4337,它引入了智能合约钱包,而无需对 Ethereum 协议本身进行更改。EIP-4337 已经部署在主网,并有多个生产就绪的实现。
最近,作为 Pectra 升级一部分引入的 EIP-7702 使账户抽象更加接近协议层。它允许 EOAs 指向一个已经部署的智能合约,赋予它们如交易批处理和 gas 抽象等智能账户能力,而无需迁移到新地址。这个被称为“委托”的过程是可选的,并且会一直持续,直到用户通过委托给零地址来移除它。
创建 Starknet 账户时会发生什么
每当您使用钱包软件创建一个新账户时,钱包就会部署一个新的账户合约。正如我们在“理解 Starknet 的合约部署模型”一章中所述,可以从同一个类中创建多个合约实例,它们共享相同的代码,但各自拥有独立的地址和存储。钱包提供商只需在 Starknet 上声明一次他们的账户类,之后创建的每一个新钱包都只是该类的一个新实例。
账户合约的构造函数使用用户的公钥等参数进行初始化,生成的账户地址是确定性的:它由多个输入派生而来,包括类哈希、盐值、部署者地址和构造函数的 calldata。
请注意,由于其地址是确定性的,该账户甚至在部署之前就可以接收资产,但在账户合约部署完成之前,它不能执行任何交易。
一旦部署完毕,账户合约就会与它的逻辑绑定,切换到不同的账户合约意味着创建一个全新的账户。
在账户合约之间切换
理论上,任何人都可以自由实现自己的账户合约。在协议层面,其要求是:
- 实现
__validate__和__execute__,即协议在交易生命周期中调用的入口点 - 实现
__validate_declare__或__validate_deploy__,具体取决于所需的功能 - 遵循 SNIP-6 standard,该标准添加了
is_valid_signature函数,用于实现与 dApps 的互操作性和链下签名验证
我们将在下一节解释这些函数中的每一个。
然而在实践中,使用自定义的账户合约实现是很困难的。即使您遵循了标准且在技术上一切正常,您仍然必须通过某个钱包软件来使用该账户合约。
既然任何人都可以自由地在满足最低账户抽象要求的基础之上添加功能,大多数流行的钱包(如 Ready)都有它们自己的账户合约实现。这些实现是不可互换的。这使得在它们之间进行切换或使用您自己的实现变得困难。
账户合约剖析
让我们看一个非常极简的账户合约实现。它是故意简化的,且非常不安全,它在没有任何检查的情况下批准所有交易和签名。我们的目标是说明 Starknet 账户合约所需的最基本结构,它仅供演示目的,请勿在生产环境中使用。
创建一个新的 Scarb 项目并进入该目录:
scarb new aa
cd aa
首先,我们将 Call 结构体导入到 src/lib.cairo 中。该结构体代表交易中的单个操作。每个 Call 包含一个目标合约地址、一个函数选择器和要传递的 calldata。我们的账户合约将使用它来了解要调用哪个合约以及传递什么数据。
use starknet::account::Call;
接下来,我们定义每个账户合约都期望实现的 SNIP-6 接口(ISRC6)。它包含三个函数:
__validate__用于交易验证,__execute__用于交易执行,以及is_valid_signature用于链下签名验证:
use starknet::account::Call;
//////NEWLY ADDED///////
#[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;
}
//////NEWLY ADDED///////
#[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 函数
此函数不会被协议调用。它的存在是为了链下签名验证,并允许 dApps 确认签名属于该账户。这里它不进行任何检查,直接返回 'VALID':
fn is_valid_signature(
self: @ContractState, hash: felt252, signature: Array<felt252>,
) -> felt252 {
'VALID'
}
此示例合约跳过了所有验证,绝不应在生产环境中使用。生产环境的账户合约必须实现适当的验证和签名检查,以保护用户资产和访问权限。
以下是完整的账户合约代码:
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 中使用这个账户合约,看看它如何与其他合约交互。稍后,在“账户合约是如何被调用的”一节中,我们将解释在每笔交易期间,协议在幕后做了什么。
部署并与我们的账户合约交互
一个极简的账户合约能够发起以其他合约为目标的交易。然而,它无法声明和部署其他合约。由于本教程的流程涉及声明和部署一个独立的合约,我们需要额外添加两个验证函数:
__validate_declare__:当用户想要声明一个新的合约类时,由协议调用。__validate_deploy__:在进行DEPLOY_ACCOUNT交易期间,当第一次部署新的账户合约时,由协议调用。
就像 __validate__ 在执行前验证 invoke(调用)交易一样,__validate_declare__ 同样对 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's customized validation function
__validate_deploy__(
class_hash: felt252,
contract_address_salt: felt252,
owner: Signer, // uses Signer type
guardian: Option<Signer> // adds guardian support
) -> felt252
注意,Ready 的版本接收 owner: Signer 和 guardian: Option<Signer> 参数,而不是标准的 public_key: felt252 参数。这种定制化允许 Ready:
- 使用他们自定义的
Signer类型来代替简单的公钥,这可以表示不同的签名方案 - 添加用于社交恢复的守护者(guardian)功能。守护者是一个受信任的参与方(比如另一个钱包、朋友或钱包提供商本身),在主要签名者丢失时,可以帮助恢复对账户的访问。
尽管有这些不同的参数,Ready 的 __validate_deploy__ 仍然调用了相同的底层验证逻辑,检查签名并确保部署获得了授权。自定义参数只是允许 Ready 通过其验证过程传递额外的信息(如守护者密钥)。
在遵循 Starknet 所期望的验证接口的同时,还可以灵活地定制验证参数,这是账户抽象带来的好处之一。它允许钱包提供商在不偏离协议的情况下添加多重签名支持、会话密钥或社交恢复等功能。
以下是添加了 __validate_declare__ 和 __validate_deploy__ 的完整账户合约代码:
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 的内容。
添加一个用于交互的合约
由于我们想演示账户合约是如何被使用的,我们需要有其他某个合约来进行交互。让我们在同一个 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 <PREDEPLOYED_ACCOUNT_ADDRESS> \
--private-key <PREDEPLOYED_PRIVATE_KEY> \
--type oz
--url标志指向本地 devnet 的 RPC 端点。- 既然我们的账户合约不验证签名,私钥的值在这里并不重要。然而,
sncast要求它作为必填字段,因为大多数账户合约都依赖签名进行授权。 —-type标志告诉sncast如何格式化该账户的交易。可用的选项有ready、braavos或oz(OpenZeppelin)。由于我们的自定义账户不匹配任何特定的钱包提供商,我们使用oz作为最接近的通用选项。
运行该命令后,sncast 会询问您是否要将此账户设置为默认账户。选择本地默认(local default)选项,这样该账户的作用域就仅限于当前项目。

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

记下导入输出中的账户名称,并在后续命令中用它替换
<ACCOUNT_NAME>。
如果您需要用不同的详细信息重新导入该账户,或在测试后进行清理,您可以通过以下命令将其删除:
sncast \
account delete \
--url [http://127.0.0.1:5050](http://127.0.0.1:5050/) \
--name <ACCOUNT_NAME>
声明并部署 Counter 合约
既然我们已经导入了账户合约,让我们使用它来声明和部署我们的 Counter 合约。
声明合约:
sncast \
--account <ACCOUNT_NAME> \
declare \
--url http://127.0.0.1:5050 \
--contract-name Counter

记下输出的 class hash(类哈希),并在随后的部署合约命令中替换它:
sncast \
--account <ACCOUNT_NAME> \
deploy \
--class-hash <CLASS_HASH> \
--url http://127.0.0.1:5050

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

我们现在可以进行查询以查看新的计数器值。它应该返回 23(以十六进制编码):
sncast \
call \
--url http://127.0.0.1:5050 \
--contract-address <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是一个常规合约,而不是账户。它是通过 Universal Deployer Contract(通用部署合约)作为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 使用了我们之前讨论过的守护者系统,以便在主签名者丢失时帮助用户重新获得对其账户的访问权限。
账户抽象启用的功能
账户抽象引入了 EOAs 难以或无法实现的功能。这些包括:
- 使用任何代币支付 gas。不用原生的 gas 代币支付 gas,代付(paymaster)服务可以接收用户的代币,对其进行兑换,并在后台用所需的 gas 代币支付 gas 费。用户仍然在付费,只不过用的是不同的代币。
- 赞助交易。第三方可以完全赞助整个 gas 费用,允许用户免费提交交易。这通常用于新手引导或补贴应用使用。
- 自定义签名方案。虽然大多数 Starknet 账户使用 ECDSA 签名,但
__validate__函数可以实现任何验证逻辑,包括不同的密码学方案,或者在特殊情况下甚至完全跳过签名检查。 - 多重签名和自定义访问控制。账户合约可以要求多方批准一笔交易,强制执行基于时间的规则,或实现任何自定义访问逻辑。
- 账户恢复。如果主要的访问方法失效,例如由于密钥丢失,可以在账户合约中内置替代恢复选项。
- 限速账户。账户合约可以限制特定时段内的交易次数,这对于有使用上限的赞助账户非常有用。
安全权衡
对于账户抽象,每一个附加功能(自定义逻辑、恢复、多签、速率限制)都会增加账户合约的复杂性。此逻辑中的 bug 或错误配置可能会导致不可逆转的访问权限或资金丢失。
此外,Starknet 协议仍在不断演进。与账户抽象相关的新功能正在被引入,最佳实践也在积极转变。跟上最新进展非常重要。您应该只使用经过审计的账户合约。
结论
账户抽象使账户变得可编程。它实现了像使用任何代币支付 gas、赞助交易、多签方案以及自定义恢复方法等功能,而这些是 EOAs 根本无法做到的。Starknet 的原生实现意味着这些功能默认提供给每一个账户。
然而,这些好处也伴随着权衡取舍。正如我们前面所讨论的,安全的账户合约实现起来很复杂,并且在不同钱包提供商的实现之间进行切换仍然很困难。
归根结底,账户抽象是为了改善用户体验。它消除了如需特定 gas 代币或直接管理私钥等障碍,让新用户的入门变得更加顺畅。