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。
此实现确定域分隔符的值。 仅 name
和 version
字段是必需的,
因为 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);
}
}