账户

与 Ethereum 中账户从私钥派生不同,所有 Starknet 账户都是合约。这意味着 Starknet 上没有外部拥有账户 (EOA) 的概念。

相反,网络具有原生的账户抽象,签名验证发生在合约级别。

有关账户抽象的总体概述,请参阅 Starknet 的文档。 有关该主题的更详细讨论,请参见 Starknet Shaman 的论坛

有关用法和实施的详细信息,请查看 API 参考部分。

什么是账户?

Starknet 中的账户是智能合约,因此可以像任何其他合约一样进行部署和交互,并且可以扩展以实现任何自定义逻辑。但是,账户是一种特殊类型的合约,用于验证和执行交易。因此,它必须实现一组协议用于此执行流程的入口点。https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-6.md[SNIP-6] 提案定义了账户的标准接口,支持此执行流程以及与生态系统中 DApp 的互操作性。

ISRC6 接口

/// 表示对目标合约函数的调用。
struct Call {
    to: ContractAddress,
    selector: felt252,
    calldata: Span<felt252>
}

/// 标准账户接口
#[starknet::interface]
pub trait ISRC6 {
    /// 通过账户执行交易。
    fn __execute__(calls: Array<Call>);

    /// 断言交易是否有效以执行。
    fn __validate__(calls: Array<Call>) -> felt252;

    /// 断言给定哈希的给定签名是否有效。
    fn is_valid_signature(hash: felt252, signature: Array<felt252>) -> felt252;
}
出于优化目的,账户中 Call 结构体的 calldata 成员已更新为 Span<felt252>,但为了向后兼容,接口 ID 保持不变。此不一致将在未来的版本中得到修复。

SNIP-6 添加了 is_valid_signature 方法。协议不使用此方法,但它对于 DApp 验证签名的有效性、支持诸如使用 Starknet 登录之类的功能很有用。

SNIP-6 还定义了兼容账户必须遵循 SNIP-5 实现 SRC5 接口,作为通过自省检测合约是否为账户的机制。

ISRC5 接口

/// 标准接口检测
#[starknet::interface]
pub trait ISRC5 {
    /// 查询合约是否实现了给定的接口。
    fn supports_interface(interface_id: felt252) -> bool;
}

当查询 ISRC6 接口 ID 时,https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-6.md[SNIP-6] 兼容账户必须返回 true

即使协议不强制执行这些接口,也建议实现它们以实现与生态系统的互操作性。

协议级别方法

Starknet 协议使用一些入口点来抽象账户。我们已经提到了前两个作为 ISRC6 接口的一部分,并且两者都是使账户能够用于执行交易所必需的。其余的是可选的:

  1. __validate__ 验证要执行的交易的有效性。这通常用于验证签名,但可以自定义入口点实现以具有任何验证机制 具有一些限制

  2. 如果验证成功,则 __execute__ 执行交易。

  3. __validate_declare__ 可选入口点,类似于 __validate__,但用于声明其他合约的交易。

  4. __validate_deploy__ 可选入口点,类似于 __validate__,但用于 反事实部署

尽管这些入口点可供协议用于其常规交易流程,但也可以像任何其他方法一样调用它们。

Starknet 账户

Starknet 原生账户抽象模式允许创建具有不同验证方案的自定义账户,但通常大多数账户实现都使用 Stark 曲线 验证交易,这是验证签名的最有效方式,因为它是 STARK 友好的曲线。

用于 Cairo 的 OpenZeppelin Contracts 提供了 AccountComponent 用于实现此验证方案。

用法

构造账户合约需要集成 AccountComponentSRC5Component。该合约还应设置构造函数以初始化将用作账户签名者的公钥。这是一个基本合约的示例:

#[starknet::contract(account)]
mod MyAccount {
    use openzeppelin_account::AccountComponent;
    use openzeppelin_introspection::src5::SRC5Component;

    component!(path: AccountComponent, storage: account, event: AccountEvent);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // Account Mixin
    #[abi(embed_v0)]
    impl AccountMixinImpl = AccountComponent::AccountMixinImpl<ContractState>;
    impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        account: AccountComponent::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        AccountEvent: AccountComponent::Event,
        #[flat]
        SRC5Event: SRC5Component::Event
    }

    #[constructor]
    fn constructor(ref self: ContractState, public_key: felt252) {
        self.account.initializer(public_key);
    }
}

接口

这是 AccountMixinImpl 实现的完整接口:

#[starknet::interface]
pub trait AccountABI {
    // ISRC6
    fn __execute__(calls: Array<Call>);
    fn __validate__(calls: Array<Call>) -> felt252;
    fn is_valid_signature(hash: felt252, signature: Array<felt252>) -> felt252;

    // ISRC5
    fn supports_interface(interface_id: felt252) -> bool;

    // IDeclarer
    fn __validate_declare__(class_hash: felt252) -> felt252;

    // IDeployable
    fn __validate_deploy__(
        class_hash: felt252, contract_address_salt: felt252, public_key: felt252
    ) -> felt252;

    // IPublicKey
    fn get_public_key() -> felt252;
    fn set_public_key(new_public_key: felt252, signature: Span<felt252>);

    // ISRC6CamelOnly
    fn isValidSignature(hash: felt252, signature: Array<felt252>) -> felt252;

    // IPublicKeyCamel
    fn getPublicKey() -> felt252;
    fn setPublicKey(newPublicKey: felt252, signature: Span<felt252>);
}

Ethereum 账户

除了 Stark 曲线账户之外,用于 Cairo 的 OpenZeppelin Contracts 还提供 Ethereum 风格的账户,这些账户使用 secp256k1 曲线进行签名验证。 为此,必须使用 EthAccountComponent

用法

构造 secp256k1 账户合约还需要集成 EthAccountComponentSRC5Component。 该合约还应设置构造函数以初始化将用作账户签名者的公钥。 这是一个基本合约的示例:

#[starknet::contract(account)]
mod MyEthAccount {
    use openzeppelin_account::EthAccountComponent;
    use openzeppelin_account::interface::EthPublicKey;
    use openzeppelin_introspection::src5::SRC5Component;
    use starknet::ClassHash;

    component!(path: EthAccountComponent, storage: eth_account, event: EthAccountEvent);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // EthAccount Mixin
    #[abi(embed_v0)]
    impl EthAccountMixinImpl =
        EthAccountComponent::EthAccountMixinImpl<ContractState>;
    impl EthAccountInternalImpl = EthAccountComponent::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        eth_account: EthAccountComponent::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        EthAccountEvent: EthAccountComponent::Event,
        #[flat]
        SRC5Event: SRC5Component::Event
    }

    #[constructor]
    fn constructor(ref self: ContractState, public_key: EthPublicKey) {
        self.eth_account.initializer(public_key);
    }
}

接口

这是 EthAccountMixinImpl 实现的完整接口:

#[starknet::interface]
pub trait EthAccountABI {
    // ISRC6
    fn __execute__(calls: Array<Call>);
    fn __validate__(calls: Array<Call>) -> felt252;
    fn is_valid_signature(hash: felt252, signature: Array<felt252>) -> felt252;

    // ISRC5
    fn supports_interface(interface_id: felt252) -> bool;

    // IDeclarer
    fn __validate_declare__(class_hash: felt252) -> felt252;

    // IEthDeployable
    fn __validate_deploy__(
        class_hash: felt252, contract_address_salt: felt252, public_key: EthPublicKey
    ) -> felt252;

    // IEthPublicKey
    fn get_public_key() -> EthPublicKey;
    fn set_public_key(new_public_key: EthPublicKey, signature: Span<felt252>);

    // ISRC6CamelOnly
    fn isValidSignature(hash: felt252, signature: Array<felt252>) -> felt252;

    // IEthPublicKeyCamel
    fn getPublicKey() -> EthPublicKey;
    fn setPublicKey(newPublicKey: EthPublicKey, signature: Span<felt252>);
}

部署账户

在 Starknet 中,有两种部署智能合约的方式:使用 deploy_syscall 和进行反事实部署。 前者可以使用 通用部署合约 (UDC) 轻松完成,该合约包装并公开 deploy_syscall 以通过常规合约调用提供任意部署。 但是,如果您没有要调用的帐户,您可能需要使用后者。

要进行反事实部署,您需要实现另一个协议级别的入口点,名为 __validate_deploy__。查看 反事实部署 指南以了解如何操作。

发送交易

现在让我们探讨如何通过这些账户发送交易。

Starknet 账户

首先,让我们采用之前创建的示例账户并部署它:

#[starknet::contract(account)]
mod MyAccount {
    use openzeppelin_account::AccountComponent;
    use openzeppelin_introspection::src5::SRC5Component;

    component!(path: AccountComponent, storage: account, event: AccountEvent);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // Account Mixin
    #[abi(embed_v0)]
    impl AccountMixinImpl = AccountComponent::AccountMixinImpl<ContractState>;
    impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        account: AccountComponent::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        AccountEvent: AccountComponent::Event,
        #[flat]
        SRC5Event: SRC5Component::Event
    }

    #[constructor]
    fn constructor(ref self: ContractState, public_key: felt252) {
        self.account.initializer(public_key);
    }
}

要部署账户变体,请编译合约并声明类哈希,因为自定义账户可能尚未声明。 这意味着您需要一个已经部署的账户。

接下来,使用 Starknet Foundry 的 自定义账户设置 创建账户 JSON,并包含带有已声明类哈希的 --class-hash 标志。 该标志启用自定义账户变体。

以下示例使用 sncast v0.23.0
$ sncast \
  --url http://127.0.0.1:5050 \
  account create \
  --name my-custom-account \
  --class-hash 0x123456...

此命令将输出预先计算的合约地址和建议的 max-fee。 要反事实地部署账户,请将资金发送到该地址,然后部署自定义账户。

$ sncast \
  --url http://127.0.0.1:5050 \
  account deploy \
  --name my-custom-account

部署账户后,使用自定义账户名称设置 --account 标志,以从该账户发送交易。

$ sncast \
  --account my-custom-account \
  --url http://127.0.0.1:5050 \
  invoke \
  --contract-address 0x123... \
  --function "some_function" \
  --calldata 1 2 3

Ethereum 账户

首先,让我们采用之前创建的示例账户并部署它:

#[starknet::contract(account)]
mod MyEthAccount {
    use openzeppelin_account::EthAccountComponent;
    use openzeppelin_account::interface::EthPublicKey;
    use openzeppelin_introspection::src5::SRC5Component;

    component!(path: EthAccountComponent, storage: eth_account, event: EthAccountEvent);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // EthAccount Mixin
    #[abi(embed_v0)]
    impl EthAccountMixinImpl =
        EthAccountComponent::EthAccountMixinImpl<ContractState>;
    impl EthAccountInternalImpl = EthAccountComponent::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        eth_account: EthAccountComponent::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        EthAccountEvent: EthAccountComponent::Event,
        #[flat]
        SRC5Event: SRC5Component::Event
    }

    #[constructor]
    fn constructor(ref self: ContractState, public_key: EthPublicKey) {
        self.eth_account.initializer(public_key);
    }
}

需要特殊的工具才能部署和发送带有 Ethereum 风格的账户合约的交易。 以下示例利用 StarknetJS 库。

编译并在目标网络上声明合约。 接下来,使用已声明的类哈希预先计算 EthAccount 合约地址。

以下示例使用来自提交 d002baea0abc1de3ac6e87a671f3dec3757437b3 的 StarknetJS (starknetjs@next) 的未发布功能。
import * as dotenv from 'dotenv';
import { CallData, EthSigner, hash } from 'starknet';
import { ABI as ETH_ABI } from '../abis/eth_account.js';
dotenv.config();

// Calculate EthAccount address
const ethSigner = new EthSigner(process.env.ETH_PRIVATE_KEY);
const ethPubKey = await ethSigner.getPubKey();
const ethAccountClassHash = '<ETH_ACCOUNT_CLASS_HASH>';
const ethCallData = new CallData(ETH_ABI);
const ethAccountConstructorCalldata = ethCallData.compile('constructor', {
    public_key: ethPubKey
})
const salt = '0x12345';
const deployerAddress = '0x0';
const ethContractAddress = hash.calculateContractAddressFromHash(
    salt,
    ethAccountClassHash,
    ethAccountConstructorCalldata,
    deployerAddress
);
console.log('Pre-calculated EthAccount address: ', ethContractAddress);

将资金发送到预先计算的 EthAccount 地址并部署合约。

import * as dotenv from 'dotenv';
import { Account, CallData, EthSigner, RpcProvider, stark } from 'starknet';
import { ABI as ETH_ABI } from '../abis/eth_account.js';
dotenv.config();

// Prepare EthAccount
const provider = new RpcProvider({ nodeUrl: process.env.API_URL });
const ethSigner = new EthSigner(process.env.ETH_PRIVATE_KEY);
const ethPubKey = await ethSigner.getPubKey();
const ethAccountAddress = '<ETH_ACCOUNT_ADDRESS>'
const ethAccount = new Account(provider, ethAccountAddress, ethSigner);

// Prepare payload
const ethAccountClassHash = '<ETH_ACCOUNT_CLASS_HASH>'
const ethCallData = new CallData(ETH_ABI);
const ethAccountConstructorCalldata = ethCallData.compile('constructor', {
    public_key: ethPubKey
})
const salt = '0x12345';
const deployPayload = {
    classHash: ethAccountClassHash,
    constructorCalldata: ethAccountConstructorCalldata,
    addressSalt: salt,
};

// Deploy
const { suggestedMaxFee: feeDeploy } = await ethAccount.estimateAccountDeployFee(deployPayload);
const { transaction_hash, contract_address } = await ethAccount.deployAccount(
    deployPayload,
    { maxFee: stark.estimatedFeeToMaxFee(feeDeploy, 100) }
);
await provider.waitForTransaction(transaction_hash);
console.log('EthAccount deployed at: ', contract_address);

部署完成后,将 EthAccount 实例连接到目标合约,从而允许从 EthAccount 发起调用。 这是一个来自 EthAccount 的 ERC20 转账的示例。

import * as dotenv from 'dotenv';
import { Account, RpcProvider, Contract, EthSigner } from 'starknet';
dotenv.config();

// Prepare EthAccount
const provider = new RpcProvider({ nodeUrl: process.env.API_URL });
const ethSigner = new EthSigner(process.env.ETH_PRIVATE_KEY);
const ethAccountAddress = '<ETH_ACCOUNT_CONTRACT_ADDRESS>'
const ethAccount = new Account(provider, ethAccountAddress, ethSigner);

// Prepare target contract
const erc20 = new Contract(compiledErc20.abi, erc20Address, provider);

// Connect EthAccount with the target contract
erc20.connect(ethAccount);

// Execute ERC20 transfer
const transferCall = erc20.populate('transfer', {
    recipient: recipient.address,
    amount: 50n
});
const tx = await erc20.transfer(
    transferCall.calldata, { maxFee: 900_000_000_000_000 }
);
await provider.waitForTransaction(tx.transaction_hash);