SNIP12和类型化消息

类似于 EIP712,https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-12.md[SNIP12] 是 Starknet 上安全链下签名验证的标准。 它提供了一种哈希和签名通用类型结构的方法,而不仅仅是字符串。 在构建去中心化 应用程序时,通常您可能需要使用复杂数据签名消息。 签名验证的目的是 确保收到的消息确实由预期的签名者签名,并且没有被篡改。

OpenZeppelin Contracts for Cairo 提供了一组实用程序,可以尽可能轻松地实现此标准。 在本指南中,我们将引导您完成使用这些实用程序生成类型化消息哈希的过程, 以便进行链上签名验证。 为此,让我们构建一个带有自定义 ERC20 合同的示例, 添加一个额外的 transfer_with_signature 方法。

这是一个教学示例,不应在生产环境中使用。

CustomERC20

让我们从一个利用 ERC20Component 的基本 ERC20 合同开始,并添加新函数。 请注意,为了简洁起见,省略了一些声明。 完整的示例将在本指南的末尾提供。

#[starknet::contract]
mod CustomERC20 {
    use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
    use starknet::ContractAddress;

    component!(path: ERC20Component, storage: erc20, event: ERC20Event);

    #[abi(embed_v0)]
    impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
    impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

    (...)

    #[constructor]
    fn constructor(
        ref self: ContractState,
        initial_supply: u256,
        recipient: ContractAddress
    ) {
        self.erc20.initializer("MyToken", "MTK");
        self.erc20.mint(recipient, initial_supply);
    }

    #[external(v0)]
    fn transfer_with_signature(
        ref self: ContractState,
        recipient: ContractAddress,
        amount: u256,
        nonce: felt252,
        expiry: u64,
        signature: Array<felt252>
    ) {
        (...)
    }
}

transfer_with_signature 函数将允许用户通过提供签名将 token 转移到另一个帐户。 该签名将在链下生成,并将用于验证链上的消息。 请注意,我们需要验证的消息 是一个具有以下字段的结构体:

  • recipient: 接收者的地址。

  • amount: 要转移的 token 数量。

  • nonce: 用于防止重放攻击的唯一编号。

  • expiry: 签名过期的unix时间戳。

请注意,在链上生成此消息的哈希是验证签名的要求,因为如果我们接受 该消息作为参数,则很容易被篡改。

生成类型化消息哈希

要生成消息的哈希,我们需要按照以下步骤操作:

1. 定义消息结构体。

在这个特定的例子中,消息结构体如下所示:

struct Message {
    recipient: ContractAddress,
    amount: u256,
    nonce: felt252,
    expiry: u64
}

2. 获取消息类型哈希。

这是 SNIP 中定义的 starknet_keccak(encode_type(message))

在这种情况下,它可以计算如下:

// Since there's no u64 type in SNIP-12, we use u128 for `expiry` in the type hash generation.
// 由于 SNIP-12 中没有 u64 类型,我们在类型哈希生成中使用 u128 作为 `expiry`。
let message_type_hash = selector!(
    "\"Message\"(\"recipient\":\"ContractAddress\",\"amount\":\"u256\",\"nonce\":\"felt\",\"expiry\":\"u128\")\"u256\"(\"low\":\"u128\",\"high\":\"u128\")"
);

与以下代码相同:

let message_type_hash = 0x28bf13f11bba405c77ce010d2781c5903cbed100f01f72fcff1664f98343eb6;
实际上,最好在链下计算类型哈希并将其硬编码到合约中,因为它是一个常量值。

3. 为结构体实现 StructHash trait。

您可以从以下位置导入该 trait:openzeppelin_utils::snip12::StructHash。 并且此实现 仅仅是 SNIP 中定义的消息编码。

use core::hash::{HashStateExTrait, HashStateTrait};
use core::poseidon::PoseidonTrait;
use openzeppelin_utils::snip12::StructHash;
use starknet::ContractAddress;

const MESSAGE_TYPE_HASH: felt252 =
    0x28bf13f11bba405c77ce010d2781c5903cbed100f01f72fcff1664f98343eb6;

#[derive(Copy, Drop, Hash)]
struct Message {
    recipient: ContractAddress,
    amount: u256,
    nonce: felt252,
    expiry: u64
}

impl StructHashImpl of StructHash<Message> {
    fn hash_struct(self: @Message) -> felt252 {
        let hash_state = PoseidonTrait::new();
        hash_state.update_with(MESSAGE_TYPE_HASH).update_with(*self).finalize()
    }
}

4. 实现 SNIP12Metadata trait。

此实现确定域分隔符的值。 仅 nameversion 字段是必需的, 因为 chain_id 是在链上获得的,而 revision 硬编码为 1

use openzeppelin_utils::snip12::SNIP12Metadata;

impl SNIP12MetadataImpl of SNIP12Metadata {
    fn name() -> felt252 { 'DAPP_NAME' }
    fn version() -> felt252 { 'v1' }
}

在上面的例子中,不需要存储读取,这避免了不必要的额外 Gas 成本,但是在 某些情况下,我们可能需要从存储中读取以获取域名分隔符值。 即使在 trait 未绑定到 ContractState 时,也可以实现此目的,如下所示:

use openzeppelin_utils::snip12::SNIP12Metadata;

impl SNIP12MetadataImpl of SNIP12Metadata {
    fn name() -> felt252 {
        let state = unsafe_new_contract_state();

        // Some logic to get the name from storage
        // 从存储中获取名称的一些逻辑
        state.erc20.name().at(0).unwrap().into()
    }

    fn version() -> felt252 { 'v1' }
}

5. 生成哈希。

最后一步是使用 OffchainMessageHashImpl 实现来生成消息的哈希 使用 get_message_hash 函数。 该实现已可用作实用程序。

use core::hash::{HashStateExTrait, HashStateTrait};
use core::poseidon::PoseidonTrait;
use openzeppelin_utils::snip12::{SNIP12Metadata, StructHash, OffchainMessageHash};
use starknet::ContractAddress;

const MESSAGE_TYPE_HASH: felt252 =
    0x28bf13f11bba405c77ce010d2781c5903cbed100f01f72fcff1664f98343eb6;

#[derive(Copy, Drop, Hash)]
struct Message {
    recipient: ContractAddress,
    amount: u256,
    nonce: felt252,
    expiry: u64
}

impl StructHashImpl of StructHash<Message> {
    fn hash_struct(self: @Message) -> felt252 {
        let hash_state = PoseidonTrait::new();
        hash_state.update_with(MESSAGE_TYPE_HASH).update_with(*self).finalize()
    }
}

impl SNIP12MetadataImpl of SNIP12Metadata {
    fn name() -> felt252 {
        'DAPP_NAME'
    }
    fn version() -> felt252 {
        'v1'
    }
}

fn get_hash(
    account: ContractAddress, recipient: ContractAddress, amount: u256, nonce: felt252, expiry: u64
) -> felt252 {
    let message = Message { recipient, amount, nonce, expiry };
    message.get_message_hash(account)
}
get_message_hash 函数的预期参数是签署消息的帐户的地址。

完整实现

最后,CustomERC20 合同的完整实现如下所示:

我们使用 ISRC6Dispatcher 来验证签名, 并且使用 NoncesComponent 来处理 Nonce 以防止重放攻击。
use core::hash::{HashStateExTrait, HashStateTrait};
use core::poseidon::PoseidonTrait;
use openzeppelin_utils::snip12::{SNIP12Metadata, StructHash, OffchainMessageHash};
use starknet::ContractAddress;

const MESSAGE_TYPE_HASH: felt252 =
    0x28bf13f11bba405c77ce010d2781c5903cbed100f01f72fcff1664f98343eb6;

#[derive(Copy, Drop, Hash)]
struct Message {
    recipient: ContractAddress,
    amount: u256,
    nonce: felt252,
    expiry: u64
}

impl StructHashImpl of StructHash<Message> {
    fn hash_struct(self: @Message) -> felt252 {
        let hash_state = PoseidonTrait::new();
        hash_state.update_with(MESSAGE_TYPE_HASH).update_with(*self).finalize()
    }
}

#[starknet::contract]
mod CustomERC20 {
    use openzeppelin_account::interface::{ISRC6Dispatcher, ISRC6DispatcherTrait};
    use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
    use openzeppelin_utils::cryptography::nonces::NoncesComponent;
    use starknet::ContractAddress;

    use super::{Message, OffchainMessageHash, SNIP12Metadata};

    component!(path: ERC20Component, storage: erc20, event: ERC20Event);
    component!(path: NoncesComponent, storage: nonces, event: NoncesEvent);

    #[abi(embed_v0)]
    impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
    impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

    #[abi(embed_v0)]
    impl NoncesImpl = NoncesComponent::NoncesImpl<ContractState>;
    impl NoncesInternalImpl = NoncesComponent::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        erc20: ERC20Component::Storage,
        #[substorage(v0)]
        nonces: NoncesComponent::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        ERC20Event: ERC20Component::Event,
        #[flat]
        NoncesEvent: NoncesComponent::Event
    }

    #[constructor]
    fn constructor(ref self: ContractState, initial_supply: u256, recipient: ContractAddress) {
        self.erc20.initializer("MyToken", "MTK");
        self.erc20.mint(recipient, initial_supply);
    }

    /// Required for hash computation.
    /// 哈希计算所需。
    impl SNIP12MetadataImpl of SNIP12Metadata {
        fn name() -> felt252 {
            'CustomERC20'
        }
        fn version() -> felt252 {
            'v1'
        }
    }

    #[external(v0)]
    fn transfer_with_signature(
        ref self: ContractState,
        recipient: ContractAddress,
        amount: u256,
        nonce: felt252,
        expiry: u64,
        signature: Array<felt252>
    ) {
        assert(starknet::get_block_timestamp() <= expiry, 'Expired signature');
        let owner = starknet::get_caller_address();

        // Check and increase nonce
        // 检查并增加 nonce
        self.nonces.use_checked_nonce(owner, nonce);

        // Build hash for calling `is_valid_signature`
        // 构建哈希以调用 `is_valid_signature`
        let message = Message { recipient, amount, nonce, expiry };
        let hash = message.get_message_hash(owner);

        let is_valid_signature_felt = ISRC6Dispatcher { contract_address: owner }
            .is_valid_signature(hash, signature);

        // Check either 'VALID' or true for backwards compatibility
        // 检查 'VALID' 或 true 以实现向后兼容性
        let is_valid_signature = is_valid_signature_felt == starknet::VALIDATED
            || is_valid_signature_felt == 1;
        assert(is_valid_signature, 'Invalid signature');

        // Transfer tokens
        // 转移 token
        self.erc20._transfer(owner, recipient, amount);
    }
}