签名验证是一个使用公钥在数学上证明某条消息或交易是使用相应的私钥进行签名的过程。
Ethereum 与 Starknet 上的签名验证
在 Ethereum 上,签名验证存在于协议或合约代码中,具体取决于该账户是 EOA 还是智能合约钱包。
- EOA 使用原生的 ECDSA (secp256k1):协议在交易处理过程中处理签名验证。它从签名中恢复签名者的地址,如果恢复的地址与预期地址匹配,则签名有效。验证规则内置于协议中,无法更改。
- 智能合约钱包使用合约定义的验证:钱包合约定义其自身的验证逻辑(最常见的是通过 EIP-1271)并返回签名是否有效。权限从协议转移到了合约代码。
在 Starknet 上没有 EOA。每个账户都是一个智能合约,因此 EOA 和智能钱包之间不存在区别。签名验证始终由账户合约本身处理。这是 account abstraction(账户抽象)的一个例子,其中的验证规则是可编程的,而不是由协议固定。
从协议的角度来看,签名验证过程从未改变:提供消息哈希和签名,调用账户合约,然后由它决定签名是否有效。随后根据该决定执行或拒绝交易。
账户合约如何做出该决定完全取决于其实现。在实践中,大多数实现的主要区别在于它们用于验证的签名方案。
在本文中,我们将介绍 Starknet 上常见的签名方案,并展示每种方案在实践中是如何验证的。
用于验证的常见签名方案
如今在 Starknet 上,最常见的签名方案是:
- Stark curve ECDSA,以及
- secp256k1 ECDSA。
Stark curve ECDSA(Starknet 原生方案)
Starknet 的原生签名方案在对 Stark 友好的椭圆曲线(“Stark curve”)上使用 ECDSA。使用 Cairo 的内置 ECDSA 函数,根据消息的哈希值和签名者的公钥对签名进行验证。
如何使用 Stark curve ECDSA 验证签名
Cairo 的核心库提供了一个内置函数 check_ecdsa_signature,用于验证 Stark curve 签名。它接受四个参数:消息哈希、签名者的公钥以及签名值 r 和 s,然后返回签名是否有效:
fn check_ecdsa_signature(
message_hash: felt252,
public_key: felt252,
signature_r: felt252,
signature_s: felt252
) -> bool;
在进行任何验证之前,函数内部会对输入进行合理性检查(sanity-checked)。签名值必须非零且不等于 stark curve 的阶(order)。然而,该函数并未检查 r 和 s 是否严格小于曲线的阶,这一点很重要,因为这两个值都在模 n(曲线阶)的整数范围内运算,该函数也没有检查 signature malleability(签名延展性)。因此,调用者在调用该函数之前需要断言两件事:
r小于曲线的阶,- 且
s <= ORDER / 2以消除具有延展性的变体。
这两项检查将在后面的小节中详细说明。
在所有检查到位后,调用 check_ecdsa_signature 函数以验证签名 (r, s) 是否与给定的消息哈希和公钥绑定。它对这些输入执行一系列椭圆曲线运算,并检查结果是否一致。如果一致,则签名有效,函数返回 true;否则,返回 false。
钱包提供商(例如 Ready、Braavos)通常依赖此方案,在允许交易在链上执行之前,确认该交易是由正确的账户授权的。
将 Stark curve 签名验证付诸实践:代币空投示例
为了使其更加具体,考虑一个简单的代币空投示例,该示例展示了在实践中如何使用 Stark-curve ECDSA 验证来确认用户是否有资格接收代币。
在这个流程中,我们将:
- 让授权的签名者使用其私钥对符合条件的接收者地址和领取代币数量生成 Stark-curve 签名
- 部署一个空投合约,在转移任何代币之前在链上验证该签名
- 通过验证生成的签名并领取一些代币来测试空投合约。
使用 starknet.js 生成 Stark-curve 签名
在创建接受签名的空投合约之前,我们需要一种在链下生成 Stark-curve 签名的方法。在实际应用中,这通常是由钱包完成的。在这个例子中,我们将保持简单,使用 starknet.js 生成签名。
我们首先建立一个小型 Node.js 项目来:
- 构造要签名的消息
- 对其进行哈希处理
- 使用 Stark-curve 私钥对其进行签名
项目设置
首先,创建一个新的项目文件夹并初始化它:
mkdir my_signature
cd my_signature
npm init -y
接下来,安装依赖项:
npm install starknet dotenv
这是我们需要它们的原因:
starknet:提供用于哈希消息和生成 Stark-curve ECDSA 签名的函数dotenv:让我们能将私钥和配置排除在代码库之外,并从环境变量中安全地加载它们
现在,让我们创建 src 目录和入口文件:
mkdir src
touch src/index.js
此时,项目结构应如下所示:
my_signature/
├── src/
│ └── index.js
├── package.json
└── node_modules/
导航到 package.json 文件,它应如下图所示:

将红色高亮部分替换为以下内容:
{
...
scripts: {
"start": "node src/index.js"
},
...
"type": "module",
...
}
创建一个 .env 文件用于存放配置变量:
touch .env
将以下内容添加到 .env 文件中:
AIRDROP_SIGNER_PK=0x...
RECIPIENT=0x...
替换占位符的值:
AIRDROP_SIGNER_PK:将要对消息进行签名的账户私钥。使用您现有的sncast账户之一,通过运行sncast account list -p来查看可用的账户及其对应的私钥RECIPIENT:符合条件的用户 Starknet 账户地址
项目设置完成后,我们可以继续下一部分,即编写对消息进行签名的脚本。
为此,我们将:
- 定义要签名的消息。我们将定义谁可以领取代币以及他们可以领取多少数量
- 按照合约预期的完全相同的方式对该消息进行哈希处理
- 使用 Stark-curve ECDSA 对哈希进行签名
对空投消息进行签名
下面是完整的脚本。将其粘贴到我们之前创建的 index.js 文件中。乍一看可能会觉得有点复杂,但别担心,我们随后会逐步对其进行分解。
import { ec, hash } from "starknet";
import * as dotenv from "dotenv";
dotenv.config();
const privateKey = process.env.AIRDROP_SIGNER_PK;
if (!privateKey) throw new Error("Set AIRDROP_SIGNER_PK=0x... in .env file");
const recipient = process.env.RECIPIENT;
if (!recipient) throw new Error("Set RECIPIENT=0x... in .env file");
// Amount (scaled 18 decimals)
const amount = 200 * 10**18;
// Message layout MUST match Cairo hashing exactly
// [recipient, amount]
const message = [recipient, amount];
// Hash + sign (Stark curve ECDSA)
const msgHash = hash.computePoseidonHashOnElements(message);
const signature = ec.starkCurve.sign(msgHash, privateKey);
// Log the signature
console.log(signature);
// Log `r` and `s` values in hex
console.log("\n\n\t\t==== `r` and `s` values in HEX ====");
console.log("r: 0x" + signature.r.toString(16));
console.log("s: 0x" + signature.s.toString(16) + "\n");
这是上面代码的完整分解。
导入与配置:
import { ec, hash } from "starknet";
import * as dotenv from "dotenv";
dotenv.config();
在这里,我们从 starknet.js 中导入了两项内容:
hash:一个模块,提供用于使用 Starknet 哈希方案对消息进行哈希处理的辅助函数ec:一个用于椭圆曲线运算的模块,用于在 Stark curve 上对哈希后的消息进行签名
我们还使用 dotenv 加载环境变量,这使得私有信息远离源代码。
从环境变量读取输入并进行验证:
在代码的这一部分中,我们将读取私钥(AIRDROP_SIGNER_PK)和用户地址(RECIPIENT),然后验证它们是否存在,否则抛出错误。
const privateKey = process.env.AIRDROP_SIGNER_PK;
if (!privateKey) throw new Error("Set AIRDROP_SIGNER_PK=0x...");
const recipient = process.env.RECIPIENT;
if (!recipient) throw new Error("Set RECIPIENT=0x...");
定义要签名的消息:
const amount = 200 * 10**18;
const message = [recipient, amount];
这正是我们要签名的消息。简单来说,它的意思是“允许这个 ‘recipient’ 领取这个 ‘amount’ 数量的代币。”
请注意,消息的布局必须与合约在链上哈希的内容完全匹配。任何不匹配的情况(无论是顺序不同、缺少值,甚至是使用了不同的哈希函数)都会导致签名验证失败。
哈希消息:
const msgHash = hash.computePoseidonHashOnElements(message);
computePoseidonHashOnElements 接受一个值数组(在我们的例子中是空投消息),并使用 Poseidon 哈希函数将它们哈希为单个字段元素。结果是将被签名的消息哈希(msgHash)。
我们在这里使用 Poseidon,因为与其他 hash functions(哈希函数)相比,它在链上验证更快、更便宜。
使用 Stark-curve ECDSA 进行签名:
const signature = ec.starkCurve.sign(msgHash, privateKey);
最后,我们使用 Stark-curve ECDSA 对消息哈希进行签名。结果是一个签名对象,其中包含两个大数字 r 和 s(它们共同构成签名),外加一个在验证期间内部使用的 recovery 值。
这个签名数据正是空投合约将在链上验证的内容。
使用以下命令运行脚本:
npm start
如果一切执行正确,终端输出应类似于以下内容(请注意,根据所使用的消息和私钥,具体的值会有所不同):

在实践中,只需要 Signature 结构体中的 r 和 s 值(在红框中)。这些是符合条件的接收者在领取代币时将传递给合约的签名组件。
recovery 值用于从签名中重建签名者的公钥。然而,Stark-curve 签名验证不需要它,因为公钥是作为验证函数的参数提供的,因此在验证过程中不需要去恢复它。
蓝框显示了十六进制格式的 r 和 s 值。请保存这些值,因为稍后在领取代币时会用到它们。
空投合约
既然我们可以在链下生成有效的 Stark-curve 签名,下一步就是查看空投合约,了解它在向接收者发放代币之前如何在链上验证该签名。
创建 Scarb 项目
在根文件夹中,运行以下命令创建一个名为 my_contract 的 Scarb 项目,然后 cd 进入该目录:
scarb new my_contract
cd my_contract
新的项目结构应如下所示:
my_signature/
├── my_contract/
│ ├── src/
│ │ └── lib.cairo
│ └ ...
└── ...
接下来,将 lib.cairo 文件中自动生成的合约替换为下面的空投合约。从高层次来看,该合约执行三件事:
- 重建在链下签名的确切消息哈希
- 验证 Stark-curve ECDSA 签名
- 验证成功后转移代币
我们将在代码块之后逐步剖析该合约。
// WARNING: This code is for demonstration purposes only. Do not use in production.
use starknet::ContractAddress;
#[starknet::interface]
trait IERC20<TContractState> {
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}
#[starknet::interface]
pub trait ISignatureAirdrop<TContractState> {
fn claim(ref self: TContractState, amount: felt252, r: felt252, s: felt252);
}
#[starknet::contract]
mod SignatureAirdrop {
use core::ecdsa::check_ecdsa_signature;
use core::ec::stark_curve::ORDER;
use core::poseidon::poseidon_hash_span;
use starknet::storage::{
Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address};
// Bring the generated dispatcher types/traits into scope (from IERC20 interface).
use super::{IERC20Dispatcher, IERC20DispatcherTrait};
#[storage]
struct Storage {
// Use ContractAddress as the mapping key (instead of felt252).
claimed: Map<ContractAddress, bool>,
// Store the signer "Stark key" as felt252 (what check_ecdsa_signature expects).
signer: felt252,
// ERC20 token to airdrop
token: ContractAddress,
}
#[constructor]
fn constructor(ref self: ContractState, signer_stark_key: felt252, token: ContractAddress) {
self.signer.write(signer_stark_key);
self.token.write(token);
}
#[abi(embed_v0)]
impl SignatureAirdropImpl of super::ISignatureAirdrop<ContractState> {
fn claim(ref self: ContractState, amount: felt252, r: felt252, s: felt252) {
// 1) Cache caller's address
let recipient = get_caller_address();
// 2) One-time claim
let already_claimed = self.claimed.entry(recipient).read();
assert!(!already_claimed, "Already claimed");
// 3) Reconstruct message hash exactly like starknet.js:
// msgHash = computePoseidonHashOnElements([recipient, amount])
let msg: Array<felt252> = array![recipient.into(), amount];
let msg_hash: felt252 = poseidon_hash_span(msg.span());
// 4) Sanity-check on r and s value.
let order_u256: u256 = ORDER.into();
let r_u256: u256 = r.into();
let s_u256: u256 = s.into();
assert!(r_u256 < order_u256, "r >= curve order");
assert!(s_u256 <= order_u256 / 2, "s > curve order / 2");
// 5) Verify signature
let signer_pk = self.signer.read();
let valid = check_ecdsa_signature(msg_hash, signer_pk, r, s);
assert!(valid, "Invalid signature");
// 6) Mark claimed
self.claimed.entry(recipient).write(true);
// 7) Transfer tokens
let token_addr = self.token.read();
let token = IERC20Dispatcher { contract_address: token_addr };
let ok = token.transfer(recipient, amount.into());
assert!(ok, "Transfer failed");
}
}
}
合约接口
在上面的代码中,我们使用了两个接口:
- 一个用于转移代币的最简 ERC-20 接口
- 一个用于空投 claim 函数的接口
use starknet::ContractAddress;
#[starknet::interface]
trait IERC20<TContractState> {
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}
#[starknet::interface]
pub trait ISignatureAirdrop<TContractState> {
fn claim(
ref self: TContractState,
amount: felt252,
r: felt252,
s: felt252,
);
}
空投合约需要两个接口:一个用于通过其 transfer 函数与 ERC-20 合约进行交互,另一个用于暴露一个 claim 入口点供用户领取他们的代币。
导入相关依赖项
这里的大部分依赖项在之前的文章中应该已经很熟悉了。我们尚未讨论过的唯一些导入项是在注释 NEWLY ADDED IMPORTS 下方的那些:
use core::poseidon::poseidon_hash_span;
use starknet::storage::{
Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address};
use super::{IERC20Dispatcher, IERC20DispatcherTrait};
// *** NEWLY ADDED IMPORTS *** //
use core::ecdsa::check_ecdsa_signature;
use core::ec::stark_curve::ORDER;
check_ecdsa_signature 是执行验证的函数。
ORDER 常量是从 Cairo 的核心 Stark-curve 模块导入的,它将用于确保 r 和 s 值位于曲线的有效范围内。
存储布局 (Storage Layout)
每个字段都有非常具体的作用:
claimed跟踪接收者是否已经领取过(每个地址限领一次)signer是授权签署空投消息的私钥所对应的 Stark-curve 公钥token是正在分发的 ERC-20 代币地址
#[storage]
struct Storage {
claimed: Map<ContractAddress, bool>,
signer: felt252,
token: ContractAddress,
}
空投合约构造函数
构造函数只是分配了:
- 将授权签名者的公钥赋值给
signer存储变量 - 将空投代币地址赋值给
token存储变量
#[constructor]
fn constructor(ref self: ContractState, signer_stark_key: felt252, token: ContractAddress) {
self.signer.write(signer_stark_key);
self.token.write(token);
}
Claim 函数
claim 函数允许符合条件的用户领取其空投份额。它将领取的 amount 和签名 (r, s) 作为参数:
fn claim(ref self: ContractState, amount: felt252, r: felt252, s: felt252) {
// 1) Cache caller's address
let recipient = get_caller_address();
// 2) One-time claim
let already_claimed = self.claimed.entry(recipient).read();
assert!(!already_claimed, "Already claimed");
// 3) Reconstruct message hash exactly like starknet.js:
// msgHash = computePoseidonHashOnElements([recipient, amount])
let msg: Array<felt252> = array![recipient.into(), amount];
let msg_hash: felt252 = poseidon_hash_span(msg.span());
// 4) Sanity-check on r and s value.
let order_u256: u256 = ORDER.into();
let r_u256: u256 = r.into();
let s_u256: u256 = s.into();
assert!(r_u256 < order_u256, "r >= curve order");
assert!(s_u256 <= order_u256 / 2, "s > curve order / 2");
// 5) Verify signature
let signer_pk = self.signer.read();
let valid = check_ecdsa_signature(msg_hash, signer_pk, r, s);
assert!(valid, "Invalid signature");
// 6) Mark claimed
self.claimed.entry(recipient).write(true);
// 7) Transfer tokens
let token_addr = self.token.read();
let token = IERC20Dispatcher { contract_address: token_addr };
let ok = token.transfer(recipient, amount.into());
assert!(ok, "Transfer failed");
}
让我们逐步进行分析。
-
缓存调用者的地址:
// 1) Cache caller's address let recipient = get_caller_address();我们需要这个地址来重建消息,并确保调用者有资格领取部分代币。
-
强制一次性领取:
let already_claimed = self.claimed.entry(recipient).read(); assert!(!already_claimed, "Already claimed");在执行任何密码学操作之前,我们需确保接收者尚未领取过。这可以防止通过重放相同的签名来耗尽空投。
-
重建消息哈希:
let msg: Array<felt252> = array![recipient.into(), amount]; let msg_hash: felt252 = poseidon_hash_span(msg.span());在这里,合约使用相同的哈希函数重建了在链下签名的确切消息哈希。链上和链下哈希之间的任何不匹配都将导致签名验证失败。
-
签名合理性检查:
let order_u256: u256 = ORDER.into(); let r_u256: u256 = r.into(); let s_u256: u256 = s.into(); assert!(r_u256 < order_u256, "r >= curve order"); assert!(s_u256 <= order_u256 / 2, "s > curve order / 2");检查
r是否严格小于 Stark curve 的阶,并且s是否在曲线阶的下半部分(s <= ORDER / 2)。这两项检查可确保这些值是有效的 ECDSA 标量。对s的检查还额外消除了签名延展性。将s限制在下半部分确保只接受两种形式中的一种,从而保证每条消息的签名具有唯一性。 -
验证 stark-curve 签名:
let signer_pk = self.signer.read(); let valid = check_ecdsa_signature(msg_hash, signer_pk, r, s); assert!(valid, "Invalid signature");在这里,Cairo 内置的
check_ecdsa_signature函数验证了:- 消息哈希
- 签名者的公钥
(r, s)签名对
如果通过了这项检查,我们就知道签名者已授权该 “recipient” 领取该 “amount” 数量的代币。如果没有通过,整个交易将以 “Invalid signature”(无效签名)错误而回滚。
-
标记为已领取:
self.claimed.entry(recipient).write(true);验证签名后,在转移代币之前将接收者标记为已领取。
-
转移代币:
let token_addr = self.token.read(); let token = IERC20Dispatcher { contract_address: token_addr }; let ok = token.transfer(recipient, amount.into()); assert!(ok, "Transfer failed");最后,合约调用 ERC-20 合约并转移代币。如果转移失败,整个交易将被回滚。
测试合约
为了使本节专注于我们实际测试的内容(签名验证和 claim 逻辑),我们将重用一个已经部署在 Starknet sepolia 上的现有 ERC-20 代币,并直接将代币铸造给空投合约。
部署空投合约
首先,我们使用以下命令声明(declare)该合约以获取其 class hash:
sncast --account <ACCOUNT_NAME> \
declare \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--contract-name SignatureAirdrop
替换:
<ACCOUNT_NAME>替换为你在 sncast 中的账户名<YOUR_API_KEY>替换为你的 Alchemy API 密钥。
声明合约将其类注册在 StarkNet 上。一旦我们有了 class hash,我们就可以部署该合约的实例。
要部署空投合约,我们需要以下内容:
-
**签名者公钥 (Signer public key):**这是 Stark-curve 的公钥,其对应的私钥用于在链下签署空投消息。在 claim 期间,合约在释放任何代币之前会验证提交的签名是否由该密钥生成。
运行此命令可获取与相应私钥绑定的公钥列表:
sncast account list # OR sncast account list -p # To show private keys too然后复制在生成签名时所使用的签名者私钥对应的公钥:

-
**代币合约地址 (Token contract address):**已在 StarkNet Sepolia 上部署的 ERC-20 代币的地址。
0x03ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b
现在我们可以继续部署空投合约:
sncast --account <ACCOUNT_NAME> \
deploy \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--class-hash <CLASS_HASH> \
--arguments '<SIGNER_PUBKEY>, <TOKEN_CONTRACT_ADDRESS>'
替换:
<ACCOUNT_NAME>替换为你的账户名<YOUR_API_KEY>替换为你的 Alchemy API 密钥<CLASS_HASH>替换为声明阶段获取的 class hash<SIGNER_PUBKEY>替换为签名者的公钥<TOKEN_CONTRACT_ADDRESS>替换为已部署的代币合约地址 (0x03ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b)
一旦部署成功,请保存空投合约地址,我们需要向其铸造一些代币,并将其用于所有后续的测试调用。
向空投合约铸造代币
在任何 claim 成功之前,空投合约必须持有足够的代币以供分发。
运行以下命令向空投合约铸造 1,000 个代币(带有 18 位小数的精度):
sncast --account <ACCOUNT_NAME> \
invoke \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--contract-address <TOKEN_CONTRACT_ADDRESS> \
--function <FUNCTION_NAME> \
--arguments '<RECIPIENT>, 1_000_000_000_000_000_000_000'
替换:
<ACCOUNT_NAME>替换为你的账户名<YOUR_API_KEY>替换为你的 Alchemy API 密钥<TOKEN_CONTRACT_ADDRESS>替换为已部署的代币合约地址 (0x03ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b)<FUNCTION_NAME>替换为要调用的函数名 (mint)<RECIPIENT>替换为空投合约地址
至此,设置已完成。空投合约已部署并被充入了代币。我们现在可以继续测试 claim 函数。
调用 claim 函数
要进行领取操作,我们将提供在前面部分链下生成的 r 和 s 签名组件,以及代币的 amount。
调用
claim函数的账户地址 必须与消息中签名的接收者地址相同。如果不同的账户尝试使用该签名进行领取,交易将被回滚。
接收者使用 sncast 在已部署的空投合约上调用 claim 函数,并传递必要的参数:
sncast --account <RECIPIENT_ACCOUNT> \
invoke \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--contract-address <AIRDROP_CONTRACT_ADDRESS> \
--function claim \
--arguments '<AMOUNT>, <R>, <S>'
替换:
<RECIPIENT_ACCOUNT>替换为与已签名的接收者账户名相对应的账户<YOUR_API_KEY>替换为你的 Alchemy API 密钥<AIRDROP_CONTRACT_ADDRESS>替换为已部署的空投合约地址<AMOUNT>替换为包含在已签名消息中的确切金额,在我们的例子中是0xad78ebc5ac6200000(200 * 1e18)<R>和<S>分别替换为r和s值(来自链下生成签名的值)
如果一切正确,交易应该会成功。要确认领取成功,我们查询该接收者的 ERC-20 代币 balance_of 函数:
sncast call \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--contract-address <TOKEN_CONTRACT_ADDRESS> \
--function balance_of \
--arguments '<RECIPIENT_ADDRESS>'
替换:
<YOUR_API_KEY>替换为你的 Alchemy API 密钥<TOKEN_CONTRACT_ADDRESS>替换为代币合约地址<RECIPIENT_ADDRESS>替换为接收者的地址
如果领取成功,返回的余额应该会反映所领取的 <AMOUNT>。
既然我们已经成功验证了 Stark-curve 签名并完成了代币领取,接下来让我们看看如何在 Cairo 合约中验证 Ethereum 签名。
Secp256k1 ECDSA(Ethereum 风格签名)
secp256k1 是 Ethereum 的 ECDSA 签名方案所使用的椭圆曲线。在实践中,Ethereum 钱包通过使用其私钥签署消息或交易来证明“我控制这个地址”。验证者可以从签名和消息或交易哈希中恢复签名者的 Ethereum 地址,然后检查其是否与预期地址相匹配。
这就是流程与前面讨论的 Stark-curve 方案不同之处。对于前面讨论过的 check_ecdsa_signature 函数,公钥作为输入传入,函数返回一个明确的布尔结果。而在 Ethereum 风格的签名中,ecrecover 仅恢复一个地址,直到将恢复的地址显式地与预期签名者进行比较时,才完成验证。
在 Starknet 上,Cairo 的核心库提供了用于验证 Ethereum 签名的辅助函数,即 verify_eth_signature 和 is_eth_signature_valid。这些函数在 Starknet 合约中用于根据消息哈希和预期的 Ethereum 地址验证 Ethereum 签名。它们之间的主要区别在于,verify_eth_signature 在遇到无效输入时会断言并引发 panic,而 is_eth_signature_valid 则返回一个 Result,允许优雅地处理错误。
此外,这两个函数都内置了对签名延展性的修复,它们在底层通过 is_signature_s_valid 强制执行 s <= N/2(并结合 s != 0 作为合理性检查),这意味着你不需要自己添加该检查。
为了演示 Ethereum 签名验证在 Starknet 合约中是如何运作的,我们可以重用 Stark-curve ECDSA 部分中基于空投的相同示例思路。
代币空投示例
假设项目方希望在 Starknet 上向基于其 Ethereum 地址符合条件的用户分发代币。为确保只有符合条件的 Ethereum 地址的合法所有者才能领取,签名消息应包括定义该次领取的所有值,例如:
- 符合条件的 Ethereum 地址,
- 将接收代币的 Starknet 地址,以及
- 代币数量。
这将 claim 数据绑定在一起。如果这些值中的任何一个被更改,消息哈希也将随之更改,恢复出的签名者将不再与受信任的签名者匹配,验证也将失败。
在代码中,流程将是:
- 使用
ethers.js结合 Ethereum 私钥(授权签名者)在链下生成签名 - 创建一个 Starknet 空投合约以验证签名并领取代币
使用 Ethers.js 生成签名
为了在链下生成 Ethereum 签名,我们可以使用 Ethers.js,这是一个用于与 Ethereum 交互的 JavaScript 库。它提供了计算哈希、创建签名和与智能合约交互的工具,主要针对 Ethereum 生态系统。
从高层次来看,签名生成代码执行以下步骤:
- 从 ethers 库中导入所需项。
- 使用私钥加载 Ethereum 钱包。
- 构造 claim 消息(包括 Ethereum 地址、Starknet 地址和代币数量)。
- 使用 keccak256 对消息进行哈希处理。
- 使用 secp256k1 对哈希进行签名。
- 提取验证签名所需的
r、s和v值。
// 1. Imports
import { solidityPackedKeccak256, Signature, SigningKey } from "ethers";
import * as dotenv from "dotenv";
dotenv.config();
// 2. Authorized signer (distributor)
const privateKey = process.env.ETH_SIGNER_PK; // SET `privateKey` IN .env FILE
const key = new SigningKey(privateKey);
// 3. Claim data (eligible user info)
const ethAddress = "0x1234567890123456789012345678901234567890";
const starknetAddress =
"0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd";
const amount = 1000;
// 4. Create message hash
const messageHash = solidityPackedKeccak256(
["uint256", "uint256", "uint256"],
[ethAddress, starknetAddress, amount],
);
// 5. Sign the hash
const signature = key.sign(messageHash);
// 6. Split signature
const { r, s, v } = Signature.from(signature);
console.log({ messageHash, r, s, v });
以下是各部分的详细分解:
-
Imports(导入)
import { solidityPackedKeccak256, Signature, SigningKey } from "ethers";solidityPackedKeccak256:将值打包在一起并使用 Keccak256 对其进行哈希。SigningKey:提供底层的 secp256k1 签名功能。用于直接签署 32 字节的消息哈希。Signature:一个实用工具,用于格式化并从签名对象中提取r、s和v。
-
创建 Signing Key
const key = new SigningKey(privateKey);这使我们能够访问 secp256k1 签名功能,这正是我们所需要的:签署 32 字节的哈希并获取
(r, s, v)。 -
Claim 数据
const ethAddress = "0x1234567890123456789012345678901234567890"; const starknetAddress = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd"; const amount = 1000;ethAddress是符合条件的 Ethereum 地址。starknetAddress是将在 Starknet 上接收代币的地址。amount是被领取的代币数量。
-
使用 claim 数据构建消息哈希
const messageHash = solidityPackedKeccak256( ["uint256", "uint256", "uint256"], [ethAddress, starknetAddress, amount], );将 claim 数据(Ethereum 地址、Starknet 地址、代币数量)打包,并使用 Keccak-256(Ethereum 使用的同一哈希函数)对其进行哈希处理。
请记住,链下的哈希逻辑必须与 Starknet 合约内的哈希逻辑完全匹配。如果打包顺序或哈希函数不同,则恢复出的签名者将不匹配,进而导致 claim 失败。
-
签名哈希
const signature = key.sign(messageHash);这将在 secp256k1 上生成一个标准的 Ethereum ECDSA 签名。结果包含三个组件
r、s和v(恢复位,recovery bit)。这些值稍后将由空投合约用于恢复签名者的公钥并推导出 Ethereum 地址。 -
提取
r、s和vconst { r, s, v } = Signature.from(signature);这三个值正是用户将要传递给 Starknet 上的
claim函数的内容。
接下来是空投合约的实现,然后我们在本地进行测试。
空投合约
在合约中,claim 函数执行以下操作:
- 重建消息哈希,与链下脚本相同
- 将 keccak 输出从小端字节序(little-endian)转换为大端字节序(big-endian)
- 使用 Cairo 的 secp256k1 辅助函数验证 Ethereum 签名
- 将代币转移给 Starknet 接收者
将 lib.cairo 的内容替换为以下代码:
// WARNING: This code is for demonstration purposes only. Do not use in production.
use starknet::ContractAddress;
use starknet::eth_address::EthAddress;
#[starknet::interface]
pub trait IERC20<TContractState> {
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}
#[starknet::interface]
pub trait ISignatureAirdrop<TContractState> {
fn claim(
ref self: TContractState,
eth_address: EthAddress,
recipient: ContractAddress,
amount: u256,
r: u256,
s: u256,
v: u256,
);
}
#[starknet::contract]
mod SignatureAirdrop {
// Imports
use core::integer::u128_byte_reverse;
use core::keccak::keccak_u256s_be_inputs;
use starknet::eth_address::EthAddress;
use starknet::eth_signature::verify_eth_signature;
use starknet::secp256_trait::Signature;
use starknet::storage::{
Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
};
use starknet::ContractAddress;
use super::{IERC20Dispatcher, IERC20DispatcherTrait, ISignatureAirdrop};
#[storage]
struct Storage {
authorized_signer: EthAddress,
token: ContractAddress,
claimed: Map<ContractAddress, bool>,
}
#[constructor]
fn constructor(ref self: ContractState, authorized_signer: EthAddress, token: ContractAddress) {
self.authorized_signer.write(authorized_signer);
self.token.write(token);
}
#[abi(embed_v0)]
impl SignatureAirdropImpl of ISignatureAirdrop<ContractState> {
fn claim(
ref self: ContractState,
eth_address: EthAddress,
recipient: ContractAddress,
amount: u256,
r: u256,
s: u256,
v: u256,
) {
let already_claimed = self.claimed.entry(recipient).read();
assert(!already_claimed, 'ALREADY_CLAIMED');
// 1) Rebuild the exact same hash as ethers.js:
// solidityPackedKeccak256(
// ["uint256", "uint256", "uint256"],
// [ethAddress, starknetAddress, amount]
// )
let eth_felt: felt252 = eth_address.into();
let eth_u256: u256 = eth_felt.into();
let recipient_felt: felt252 = recipient.into();
let recipient_u256: u256 = recipient_felt.into();
let msg_hash_le = keccak_u256s_be_inputs(
array![eth_u256, recipient_u256, amount].span(),
);
// 2) Convert from little-endian to big-endian
let msg_hash_be = u256 {
low: u128_byte_reverse(msg_hash_le.high),
high: u128_byte_reverse(msg_hash_le.low),
};
// 3) Verify Ethereum signature from the stored authorized signer
let signer = self.authorized_signer.read();
let sig = Signature {
r,
s,
y_parity: v == 28
};
verify_eth_signature(msg_hash_be, sig, signer);
self.claimed.entry(recipient).write(true);
// 4) Transfer tokens
let token = IERC20Dispatcher { contract_address: self.token.read() };
let ok = token.transfer(recipient, amount);
assert(ok, 'TRANSFER_FAILED');
}
}
}
以下是注释部分的详细分解:
Imports(导入)
在链上验证 Ethereum 风格的签名之前,我们需要一些辅助类型和函数。下面的前五个导入项都是在 Starknet 合约中验证 secp256k1 (Ethereum) 签名所必需的。
use core::integer::u128_byte_reverse;
use core::keccak::keccak_u256s_be_inputs;
use starknet::eth_address::EthAddress;
use starknet::eth_signature::verify_eth_signature;
use starknet::secp256_trait::Signature;
u128_byte_reverse翻转 128 位整数的字节顺序。keccak_u256s_be_inputs以大端字节序格式计算u256输入的 Keccak 哈希,与 Ethereum 的哈希标准相匹配。EthAddress在 Cairo 中表示标准的 20 字节 Ethereum 地址。verify_eth_signature在链上执行 secp256k1 验证。它接收消息哈希和签名,从签名中恢复 Ethereum 地址,并将其与预期地址进行比较。如果匹配,则签名有效。Signature定义了 Ethereum 签名的结构,它由三个组件r、s和v组成。
claim 函数
该函数接收:
eth_address:符合空投条件的 Ethereum 地址recipient:将接收空投代币的 Starknet 地址amount:领取的代币数量r,s,v:Ethereum ECDSA 签名的组件
fn claim(
ref self: ContractState,
eth_address: EthAddress,
recipient: ContractAddress,
amount: u256,
r: u256,
s: u256,
v: u256,
) {
// Claim logic
}
Claim 逻辑
-
重建消息哈希
let eth_felt: felt252 = eth_address.into(); let eth_u256: u256 = eth_felt.into(); let recipient_felt: felt252 = recipient.into(); let recipient_u256: u256 = recipient_felt.into(); let msg_hash_le = keccak_u256s_be_inputs( array![eth_u256, recipient_u256, amount].span(), );这重新创建了在链下使用如下方式构建的完全相同的消息哈希:
solidityPackedKeccak256( ["uint256","uint256","uint256"], [ethAddress, starknetAddress, amount] );我们:
- 将
EthAddress和ContractAddress转换为u256 - 保持相同的顺序:
[ethAddress, starknetAddress, amount] - 使用 Keccak-256 对它们进行哈希
顺序、类型和哈希函数必须与链下逻辑完全匹配。任何差异都会产生不同的哈希,并且签名验证将失败。
- 将
-
将消息哈希转换为大端字节序格式
let msg_hash_be = u256 { low: u128_byte_reverse(msg_hash_le.high), high: u128_byte_reverse(msg_hash_le.low), };这一步的原因是 Cairo 的
keccak_u256s_be_inputs以小端格式返回哈希,而 Ethereum 签名是基于大端 32 字节哈希生成的。由于这种差异,在 Starknet 合约内生成的消息哈希将无法与链下使用
ethersjs生成的哈希相匹配。如果我们尝试直接使用小端值来验证签名,恢复的签名者将不正确,从而导致验证失败。为解决这个问题,我们通过翻转字节顺序将哈希转换为大端格式。由于
u256在内部表示为两个u128halves(low和high),因此我们:- 翻转每个
u128的字节 - 交换它们的位置
这生成了与链下签署的哈希相匹配的消息哈希,从而使签名验证步骤能够成功。
- 翻转每个
-
验证 Ethereum 签名
let signer = self.authorized_signer.read(); let sig = Signature { r, s, y_parity: v == 28, }; verify_eth_signature(msg_hash_be, sig, signer);这是密码学验证步骤:
-
加载存储的
authorized_signer -
使用
(r, s, y_parity)构造一个Signature结构体。尽管claim函数接收了签名参数v(在 Ethereum 中通常是 27 或 28),但 Starknet 的Signature类型需要bool类型的y_parity。如果v等于 28,则 parity 为true,否则为false。y_parity: v == 28 -
调用
verify_eth_signature函数
在内部,该函数:
- 从
(r, s, y_parity)中恢复公钥 - 从该密钥推导出 Ethereum 地址
- 将其与
authorized_signer进行比较
如果它们不匹配,执行将回滚。
-
-
转移代币
self.claimed.entry(recipient).write(true); let token = IERC20Dispatcher { contract_address:self.token.read() }; let ok = token.transfer(recipient,amount); assert(ok,'TRANSFER_FAILED');只有在签名验证成功之后,我们才:
- 将用户标记为已领取(以防止重复领取)。
- 调用 ERC20 合约的
transfer函数。 - 断言转移成功。
如果转移失败,整个交易将被回滚。
在本地测试空投合约
既然这是一个没有集成 ERC-20 的本地测试,请注释掉合约的 claim 函数中的步骤 4(代币转移),因为现在的重点纯粹是验证签名逻辑。
将以下代码粘贴到 test_contract.cairo 文件中:
use /** <PROJECT_NAME> **/::{ISignatureAirdropDispatcher, ISignatureAirdropDispatcherTrait};
use snforge_std::{ContractClassTrait, DeclareResultTrait, declare};
use starknet::{ContractAddress, EthAddress};
fn deploy_contract(name: ByteArray) -> ContractAddress {
let contract = declare(name).unwrap().contract_class();
let signerAddress: felt252 = /** <SIGNER_ADDRESS> **/;
let tokenAddress: felt252 = 0x123;
let mut args = ArrayTrait::new();
args.append(signerAddress);
args.append(tokenAddress);
let (contract_address, _) = contract.deploy(@args).unwrap();
contract_address
}
#[test]
fn test_increase_balance() {
let contract_address = deploy_contract("SignatureAirdrop");
let dispatcher = ISignatureAirdropDispatcher { contract_address };
// The same message we signed off-chain
let eth_address: EthAddress = 0x1234567890123456789012345678901234567890.try_into().unwrap();
let starknet_address: ContractAddress =
0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd
.try_into()
.unwrap();
let amount: u256 = 1000;
dispatcher
.claim(
eth_address,
starknet_address,
amount,
/** <R> **/,
/** <S> **/,
/** <V> **/,
);
}
替换:
<PROJECT_NAME>替换为项目(文件夹)名称<SIGNER_ADDRESS>替换为与签名者私钥绑定的 Ethereum 地址<R>、<S>和<V>分别替换为r、s和v值(来自链下生成签名的值)
然后运行:
scarb test
测试通过即可确认合约正确地重建了消息哈希,根据签名者的公钥验证了签名,并将该次 claim 标记为已使用。如果测试因 "Invalid signature"(无效签名)而失败,最可能的原因是:
- 链上和链下构建的消息哈希不完全相同(字段排序、编码不匹配)
- 传递给构造函数的签名者地址有误
- 从链下脚本输出复制
r、s或v值时出错
练习:通过仅将测试文件中的 amount 更改为其他值来测试失败情况,然后再次运行测试。