Starknet 上的签名验证

RareSkills 发布于 2026-04-26 阅读 104

本文深入讲解了 Starknet 上的签名验证机制。

签名验证

签名验证是利用公钥在数学上证明某条消息或交易是使用对应的私钥签名的过程。

以太坊与 Starknet 上的签名验证

在以太坊上,签名验证存在于协议或合约代码中,具体取决于账户是 EOA 还是智能合约钱包。

  • EOA 使用原生 ECDSA(secp256k1):协议作为交易处理的一部分处理签名验证。它从签名中恢复签名者的地址,如果恢复的地址与预期地址匹配,则签名有效。验证规则内置于协议中,无法更改。
  • 智能合约钱包使用合约定义的验证:钱包合约定义自己的验证逻辑(最常见的是通过 EIP-1271),并返回签名是否有效。验证权从协议转移到合约代码。

在 Starknet 上,没有 EOA。每个账户都是智能合约,因此 EOA 和智能钱包之间的区别不存在。签名验证始终由账户合约本身处理。这是账户抽象的一个示例,其中验证规则是可编程的,而不是由协议固定。

从协议的角度来看,签名验证过程从未改变:提供一个消息哈希和一个签名,调用账户合约,由它决定签名是否有效。然后根据该决定执行或拒绝交易。

账户合约如何做出该决定完全取决于其实现。在实践中,大多数实现的主要区别在于它们用于验证的签名方案。

在本文中,我们将介绍 Starknet 上常见的签名方案,并展示每种方案在实际中是如何验证的。

用于验证的常见签名方案

在今天的 Starknet 上,最常见的签名方案是:

  • Stark 曲线 ECDSA,以及
  • secp256k1 ECDSA。

Stark 曲线 ECDSA(Starknet 原生方案)

Starknet 的原生签名方案在 Stark 友好椭圆曲线(“Stark 曲线”)上使用 ECDSA。使用 Cairo 的内置 ECDSA 函数,根据消息哈希和签名者的公钥验证签名。

如何使用 Stark 曲线 ECDSA 验证签名

Cairo 的核心库提供了一个内置函数 check_ecdsa_signature,用于验证 Stark 曲线签名。它接受四个参数:消息哈希、签名者的公钥以及签名值 rs,然后返回签名是否有效:

fn check_ecdsa_signature(
     message_hash: felt252,
     public_key: felt252,
     signature_r: felt252,
     signature_s: felt252
) -> bool;

在任何验证发生之前,函数内部会对输入进行合理性检查。签名值必须非零且不等于 Stark 曲线的阶数。但是,该函数不检查 rs 是否严格小于曲线阶数,这很重要,因为两个值都在整数模 n(曲线阶数)下运算;也不检查签名可塑性。因此,调用者在调用该函数之前应断言两件事:

  • r 小于曲线阶数,
  • 并且 s <= ORDER / 2 以消除可塑性变体。

这两个检查将在后面的小节中介绍。

在所有检查就绪后,调用 check_ecdsa_signature 函数来验证签名 (r, s) 是否与给定的消息哈希和公钥绑定。它对这些输入执行一系列椭圆曲线运算,并检查结果是否一致。如果一致,则签名有效,函数返回 true;否则返回 false

钱包提供商(例如,Ready、Braavos)通常依赖此方案来确认交易在允许链上执行之前已由正确的账户授权。

将 Stark 曲线签名验证付诸实践:代币空投示例

为了更具体,考虑一个简单的代币空投示例,该示例展示了如何在实际中使用 Stark 曲线 ECDSA 验证来确认用户有资格接收代币。

在此流程中,我们将:

  • 由一个授权签名者使用其私钥对符合条件的接收者地址和索赔金额生成 Stark 曲线签名
  • 部署一个空投合约,该合约在转账任何代币之前链上验证签名
  • 通过验证生成的签名并索赔一些代币来测试空投合约
使用 starknet.js 生成 Stark 曲线签名

在创建接受签名的空投合约之前,我们需要一种在链下生成 Stark 曲线签名的方法。在实际应用中,这通常通过钱包完成。在此示例中,我们将保持简单,使用 starknet.js 生成签名。

我们将从设置一个小的 Node.js 项目开始,目的是:

  • 构建要签名的消息
  • 对其哈希
  • 使用 Stark 曲线私钥签名

项目设置

首先,创建一个新的项目文件夹并初始化:

mkdir my_signature
cd my_signature
npm init -y

接下来,安装依赖项:

npm install starknet dotenv

以下是它们的作用:

  • starknet:提供哈希消息和生成 Stark 曲线 ECDSA 签名的函数
  • dotenv:让我们将私钥和配置排除在代码库之外,并安全地从环境变量加载它们

现在,创建 src 目录和入口文件:

mkdir src
touch src/index.js

此时,项目结构应如下所示:

my_signature/
├── src/
│   └── index.js
├── package.json
└── node_modules/

导航到 package.json 文件,它应该类似于下图:

新创建项目的 package.json 文件截图

将红色高亮部分替换为以下内容:

{
		...
		scripts: {
				"start": "node src/index.js"
		},
		...
		"type": "module",
		...
}

创建一个 .env 文件用于配置变量:

touch .env

将以下内容添加到 .env 文件:

AIRDROP_SIGNER_PK=0x...
RECIPIENT=0x...

替换占位符值:

  • AIRDROP_SIGNER_PK:将签名消息的账户的私钥。通过运行 sncast account list -p 使用你现有的一个 sncast 账户来查看可用账户及其对应的私钥
  • RECIPIENT:符合条件的用户 Starknet 账户地址

项目设置完成后,我们可以进入下一部分,即编写签名消息的脚本。

为此,我们将:

  1. 定义要签名的消息。我们将定义谁可以索赔代币以及可以索赔多少
  2. 以与合约期望完全一致的方式哈希该消息
  3. 使用 Stark 曲线 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("在 .env 文件中设置 AIRDROP_SIGNER_PK=0x...");

const recipient = process.env.RECIPIENT;
if (!recipient) throw new Error("在 .env 文件中设置 RECIPIENT=0x...");

// 金额(18 位小数缩放)
const amount = 200 * 10**18;

// 消息布局必须与 Cairo 哈希完全匹配
// [recipient, amount]
const message = [recipient, amount];

// 哈希 + 签名(Stark 曲线 ECDSA)
const msgHash = hash.computePoseidonHashOnElements(message);
const signature = ec.starkCurve.sign(msgHash, privateKey);

// 输出签名
console.log(signature);

// 以十六进制输出 `r` 和 `s` 值
console.log("\n\n\t\t==== 十六进制 `r` 和 `s` 值 ====");
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 曲线上签名哈希消息

我们还使用 dotenv 加载环境变量,这使私密信息远离源代码。

从环境变量读取输入并验证:

在代码的这一部分,我们读取私钥(AIRDROP_SIGNER_PK)和用户地址(RECIPIENT),然后验证它们是否存在,否则抛出错误。

const privateKey = process.env.AIRDROP_SIGNER_PK;
if (!privateKey) throw new Error("设置 AIRDROP_SIGNER_PK=0x...");

const recipient = process.env.RECIPIENT;
if (!recipient) throw new Error("设置 RECIPIENT=0x...");

定义要签名的消息:

const amount = 200 * 10**18;
const message = [recipient, amount];

这是我们签名的确切消息。简单来说,它的意思是“这个‘接收者’被允许索赔这个‘金额’的代币。”

注意,消息布局必须与合约链上哈希的完全一致。任何不匹配,无论是顺序不同、缺少值,甚至使用不同的哈希函数,都会导致签名验证失败。

哈希消息:

const msgHash = hash.computePoseidonHashOnElements(message);

computePoseidonHashOnElements 接受一个值数组(在我们的例子中是空投消息)并使用 Poseidon 哈希函数将它们哈希为单个域元素。结果是将被签名的消息哈希(msgHash)。

我们在这里使用 Poseidon,因为与其他哈希函数相比,它在链上验证更快且更便宜。

使用 Stark 曲线 ECDSA 签名:

const signature = ec.starkCurve.sign(msgHash, privateKey);

最后,我们使用 Stark 曲线 ECDSA 签名消息哈希。结果是一个签名对象,包含两个大数 rs,它们一起构成签名,以及一个在验证期间内部使用的 recovery 值。

这个签名数据是空投合约将在链上验证的内容。

使用以下命令运行脚本:

npm start

如果一切正确执行,终端输出应类似于以下内容(注意,根据使用的消息和私钥,值会有所不同):

签名及其十六进制值的截图

在实践中,只需要 Signature 结构中的 rs 值(红框内)。这些是符合条件的接收者在索赔代币时将传递给合约的签名组件。

recovery 值用于从签名中重建签名者的公钥。但是,Stark 曲线签名验证不需要它,因为公钥作为参数提供给验证函数,所以在验证期间无需恢复它。

蓝框显示了十六进制格式的 rs 值。保存这些值,因为它们稍后在代币索赔时会用到。

空投合约

现在我们已经可以在链下生成一个有效的 Stark 曲线签名,下一步是查看空投合约,了解它在向接收者发放代币之前如何在链上验证这个签名。

创建一个 Scarb 项目

在根文件夹中,运行以下命令创建一个名为 my_contract 的 Scarb 项目,然后 cd 进入它:

scarb new my_contract
cd my_contract

新的项目结构应如下所示:

my_signature/
├── my_contract/
│   ├── src/
│   │   └── lib.cairo
│   └ ...
└── ...

接下来,将 lib.cairo 文件中自动生成的合约替换为下面的空投合约。从高层次来看,该合约做三件事:

  • 重建在链下签名的确切消息哈希
  • 验证 Stark 曲线 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};

    // 将生成的调度器类型/trait 引入作用域(来自 IERC20 接口)。
    use super::{IERC20Dispatcher, IERC20DispatcherTrait};

    #[storage]
    struct Storage {
        // 使用 ContractAddress 作为映射键(而不是 felt252)。
        claimed: Map<ContractAddress, bool>,
        // 将签名者“Stark 密钥”存储为 felt252(check_ecdsa_signature 期望的类型)。
        signer: felt252,
        // 要空投的 ERC20 代币
        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) 缓存调用者地址
            let recipient = get_caller_address();

            // 2) 一次性索赔
            let already_claimed = self.claimed.entry(recipient).read();
            assert!(!already_claimed, "Already claimed");

            // 3) 像 starknet.js 一样精确重建消息哈希:
            //    msgHash = computePoseidonHashOnElements([recipient, amount])
            let msg: Array<felt252> = array![recipient.into(), amount];
            let msg_hash: felt252 = poseidon_hash_span(msg.span());

            // 4) 对 r 和 s 值进行合理性检查。
            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) 验证签名
            let signer_pk = self.signer.read();
            let valid = check_ecdsa_signature(msg_hash, signer_pk, r, s);
            assert!(valid, "Invalid signature");

            // 6) 标记已索赔
            self.claimed.entry(recipient).write(true);

            // 7) 转移代币
            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. 一个用于转移代币的最小 ERC-20 接口
  2. 一个用于空投合约的 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 入口点,供用户索赔代币。

导入相关依赖项

此处的多数依赖项应该已经从前面的文章中熟悉了。我们尚未讨论的唯一导入是在注释 **新导入的依赖项** 下面的那些:

use core::poseidon::poseidon_hash_span;
use starknet::storage::{
    Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address};
use super::{IERC20Dispatcher, IERC20DispatcherTrait};

// *** 新导入的依赖项 *****
use core::ecdsa::check_ecdsa_signature;
use core::ec::stark_curve::ORDER;

check_ecdsa_signature 是执行验证的函数。

ORDER 常量是从 Cairo 的核心 Stark 曲线模块导入的,它将用于确保 rs 值位于曲线的有效范围内。

存储布局

每个字段都有一个非常特定的作用:

  • claimed 跟踪接收者是否已索赔(每个地址一次索赔)
  • signer 是对应于授权签署空投消息的私钥的 Stark 曲线公钥
  • 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 函数允许符合条件的用户索赔其空投分配。它接收索赔的 amount 和一个签名 (r, s) 作为参数:

fn claim(ref self: ContractState, amount: felt252, r: felt252, s: felt252) {
    // 1) 缓存调用者地址
    let recipient = get_caller_address();

    // 2) 一次性索赔
    let already_claimed = self.claimed.entry(recipient).read();
    assert!(!already_claimed, "Already claimed");

    // 3) 像 starknet.js 一样精确重建消息哈希:
    //    msgHash = computePoseidonHashOnElements([recipient, amount])
    let msg: Array<felt252> = array![recipient.into(), amount];
    let msg_hash: felt252 = poseidon_hash_span(msg.span());

    // 4) 对 r 和 s 值进行合理性检查。
    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) 验证签名
    let signer_pk = self.signer.read();
    let valid = check_ecdsa_signature(msg_hash, signer_pk, r, s);
    assert!(valid, "Invalid signature");

    // 6) 标记已索赔
    self.claimed.entry(recipient).write(true);

    // 7) 转移代币
    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. 缓存调用者的地址:
// 1) 缓存调用者地址
let recipient = get_caller_address();

我们需要它来重建消息并确保调用者有资格索赔一些代币。

  1. 强制一次性索赔:
let already_claimed = self.claimed.entry(recipient).read();
assert!(!already_claimed, "Already claimed");

在执行任何加密操作之前,我们确保接收者尚未索赔。这可以防止重放相同的签名以耗尽空投。

  1. 重建消息哈希:
let msg: Array<felt252> = array![recipient.into(), amount];
let msg_hash: felt252 = poseidon_hash_span(msg.span());

这里,合约使用相同的哈希函数重建了在链下签名的确切消息哈希。链上和链下哈希的任何不匹配都会导致签名验证失败。

  1. 签名合理性检查:
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 曲线阶数,以及 s 是否在曲线阶数的下半部分(s <= ORDER / 2)。这两个检查确保值是有效的 ECDSA 标量。s 检查还消除了签名可塑性。将 s 限制在下半部分确保只接受两种形式中的一种,从而保证每条消息的签名唯一。

  1. 验证 Stark 曲线签名:
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) 签名对

如果此检查通过,我们就知道签名者授权了“接收者”索赔此“金额”的代币。如果未通过,整个交易会回滚并显示“Invalid signature”错误。

  1. 标记为已索赔:
self.claimed.entry(recipient).write(true);

在验证签名后,接收者在转移代币之前被标记为已索赔。

  1. 转移代币:
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 合约并转移代币。如果转移失败,整个交易回滚。

测试合约

为了保持本节的重点在我们实际测试的内容(签名验证和索赔逻辑)上,我们将重用 Starknet sepolia 上现有的一个 ERC-20 代币,并将代币直接铸造到空投合约。

部署空投合约

首先,我们声明合约以获取其类哈希,使用以下命令:

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 上注册其类。一旦有了类哈希,我们就可以部署合约的实例。

要部署空投合约,我们需要以下内容:

  • 签名者公钥: 这是 Stark 曲线公钥,其对应的私钥用于链下签署空投消息。在索赔期间,合约验证提交的签名是否由该密钥生成,然后才发放代币。

运行此命令获取与对应私钥绑定的公钥列表:

sncast account list

   # 或

sncast account list -p # 也显示私钥

然后复制与用于生成签名的签名者私钥对应的公钥:

本地可用的 Starknet 账户列表截图

  • 代币合约地址: 已在 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> 为声明中的类哈希
  • <SIGNER_PUBKEY> 为签名者的公钥
  • <TOKEN_CONTRACT_ADDRESS> 为已部署的代币合约地址(0x03ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b

部署成功后,保存空投合约地址,我们将需要向它铸造一些代币并用于所有后续测试调用。

向空投合约铸造代币

在任何索赔成功之前,空投合约必须持有足够的代币进行分发。

运行命令向空投合约铸造 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 函数

要索赔,我们将提供在上一节中链下生成的 rs 签名组件,以及代币 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> 分别为 rs 值(从链下生成的签名中获取的值)

如果一切正确,交易应该成功。为了确认索赔有效,我们查询接收者的 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 曲线签名并完成了一次索赔,让我们看看如何在 Cairo 合约中验证以太坊签名。

Secp256k1 ECDSA(以太坊风格签名)

secp256k1 是以太坊的 ECDSA 签名方案使用的椭圆曲线。在实践中,以太坊钱包通过使用其私钥签名消息或交易来证明“我控制此地址”。验证者可以从签名和消息或交易哈希中恢复签名者的以太坊地址,然后检查它是否与预期地址匹配。

这就是与之前讨论的 Stark 曲线方案不同的地方。使用我们之前讨论的 check_ecdsa_signature 函数,公钥作为输入传入,函数返回一个显式的布尔结果。使用以太坊风格签名,ecrecover 仅恢复一个地址,只有在恢复的地址与预期的签名者显式比较时才进行验证。

在 Starknet 上,Cairo 的核心库提供了用于验证以太坊签名的辅助函数,即 verify_eth_signatureis_eth_signature_valid。这些函数在 Starknet 合约中用于根据消息哈希和预期的以太坊地址验证以太坊签名。它们之间的主要区别是 verify_eth_signature 在输入无效时断言并 panic,而 is_eth_signature_valid 返回一个 Result,允许优雅地处理错误。

此外,这两个函数都内置了签名可塑性修复,它们在底层通过 is_signature_s_valid 强制 s <= N/2(以及 s != 0 作为合理性检查),这意味着你无需自己添加该检查。

为了演示以太坊签名验证在 Starknet 合约内部如何工作,我们可以重用 Stark 曲线 ECDSA 部分中基于空投的相同示例思路。

代币空投示例

假设项目希望向基于其以太坊地址有资格的用户分发 Starknet 上的代币。为了确保只有符合条件的以太坊地址的合法所有者才能索赔,签名消息应包括定义索赔的所有值,例如:

  • 符合条件的以太坊地址,
  • 将接收代币的 Starknet 地址,以及
  • 代币数量。

这会将索赔数据绑定在一起。如果这些值中的任何一个被更改,消息哈希也会更改,恢复的签名者将不再与可信签名者匹配,验证将失败。

在代码中,流程将是:

  • 使用以太坊私钥(授权签名者)在链下通过 ethers.js 生成签名
  • 创建一个 Starknet 空投合约来验证签名并索赔代币

使用 Ethers.js 生成签名

为了在链下生成以太坊签名,我们可以使用 Ethers.js,这是一个用于与以太坊交互的 JavaScript 库。它提供了用于计算哈希、创建签名以及与智能合约交互的工具,但适用于以太坊生态系统。

从高层次来看,签名生成代码执行以下步骤:

  1. 从 ethers 库导入。
  2. 使用私钥加载一个以太坊钱包。
  3. 构建索赔消息(包括以太坊地址、Starknet 地址和代币数量)。
  4. 使用 keccak256 哈希消息。
  5. 使用 secp256k1 签名
  6. 提取验证签名所需的 rsv 值。
// 1. 导入
import { solidityPackedKeccak256, Signature, SigningKey } from "ethers";
import * as dotenv from "dotenv";

dotenv.config();

// 2. 授权签名者(分发者)
const privateKey = process.env.ETH_SIGNER_PK; // 在 .env 文件中设置 `privateKey`
const key = new SigningKey(privateKey);

// 3. 索赔数据(符合条件的用户信息)
const ethAddress = "0x1234567890123456789012345678901234567890";
const starknetAddress =
  "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd";
const amount = 1000;

// 4. 创建消息哈希
const messageHash = solidityPackedKeccak256(
  ["uint256", "uint256", "uint256"],
  [ethAddress, starknetAddress, amount],
);

// 5. 签名哈希
const signature = key.sign(messageHash);

// 6. 拆分签名
const { r, s, v } = Signature.from(signature);

console.log({ messageHash, r, s, v });

以下是每个部分的详细分解:

  1. 导入
import { solidityPackedKeccak256, Signature, SigningKey } from "ethers";
  • solidityPackedKeccak256:将值打包在一起并使用 Keccak256 进行哈希。
  • SigningKey:提供低级别 secp256k1 签名功能。用于直接签名一个 32 字节的消息哈希。
  • Signature:一种用于从签名对象中格式化和提取 rsv 的工具。
  1. 创建签名密钥
const key = new SigningKey(privateKey);

这使我们能够访问 secp256k1 签名功能,这正是我们所需要的:签名一个 32 字节的哈希并获取 (r, s, v)

  1. 索赔数据
const ethAddress = "0x1234567890123456789012345678901234567890";
const starknetAddress =
     "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd";
const amount = 1000;
  • ethAddress 是符合条件的以太坊地址。
  • starknetAddress 是在 Starknet 上接收代币的地址。
  • amount 是正在索赔的代币数量。
  1. 使用索赔数据构建消息哈希
const messageHash = solidityPackedKeccak256(
     ["uint256", "uint256", "uint256"],
     [ethAddress, starknetAddress, amount],
);

打包索赔数据(以太坊地址、Starknet 地址、数量)并使用 Keccak-256(与以太坊使用的相同哈希函数)哈希。

请记住,链下的哈希逻辑必须与 Starknet 合约内部的哈希逻辑完全一致。如果打包顺序或哈希函数不同,恢复的签名者将不匹配,索赔将失败。

  1. 签名哈希
const signature = key.sign(messageHash);

这将生成一个标准的以太坊 ECDSA 签名(基于 secp256k1)。结果包含三个组件 rsv(恢复位)。这些是空投合约稍后将用于恢复签名者公钥并派生以太坊地址的值。

  1. 提取 rsv
const { r, s, v } = Signature.from(signature);

这三个值是用户在 Starknet 上调用 claim 函数时将传递的参数。

接下来是空投合约的实现,然后我们本地测试它。

空投合约

在合约中,claim 函数执行以下操作:

  1. 重建消息哈希,与链下脚本相同
  2. 将 keccak 输出从小端序转换为大端序
  3. 使用 Cairo 的 secp256k1 辅助函数验证以太坊签名
  4. 将代币转账给 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 {
    // 导入
    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) 重建与 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) 从小端序转换为大端序
            let msg_hash_be = u256 {
                low: u128_byte_reverse(msg_hash_le.high),
                high: u128_byte_reverse(msg_hash_le.low),
            };

            // 3) 根据存储的授权签名者验证以太坊签名
            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) 转移代币
            let token = IERC20Dispatcher { contract_address: self.token.read() };
            let ok = token.transfer(recipient, amount);
            assert(ok, 'TRANSFER_FAILED');
        }
    }
}

以下是注释部分的详细分解:

导入

在链上验证以太坊风格签名之前,我们需要一些辅助类型和函数。下面的前五个导入都是验证 Starknet 合约内部的 secp256k1(以太坊)签名所必需的。

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 哈希,匹配以太坊的哈希标准。
  • EthAddress 在 Cairo 中表示标准的 20 字节以太坊地址。
  • verify_eth_signature 在链上执行 secp256k1 验证。它接收消息哈希和签名,从签名中恢复以太坊地址,并将其与预期地址进行比较。如果匹配,则签名有效。
  • Signature 定义以太坊签名的结构,包含三个组件 rsv

claim 函数

该函数接收:

  • eth_address:符合条件的空投以太坊地址
  • recipient:将接收空投代币的 Starknet 地址
  • amount:正在索赔的代币数量
  • rsv:以太坊 ECDSA 签名的组件
fn claim(
    ref self: ContractState,
    eth_address: EthAddress,
    recipient: ContractAddress,
    amount: u256,
    r: u256,
    s: u256,
    v: u256,
) {

    // 索赔逻辑

}

索赔逻辑

  1. 重建消息哈希
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]
);

我们:

  • EthAddressContractAddress 转换为 u256
  • 保持相同的顺序:[ethAddress, starknetAddress, amount]
  • 使用 Keccak-256 哈希它们

顺序、类型和哈希函数必须与链下逻辑完全一致。任何差异都会产生不同的哈希,签名验证将失败。

  1. 将消息哈希转换为大端格式
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 以小端格式返回哈希,而以太坊签名是针对大端格式的 32 字节哈希生成的。

由于这种差异,Starknet 合约内部产生的消息哈希将不匹配使用 ethersjs 在链下产生的哈希。如果我们直接使用小端值验证签名,恢复的签名者将不正确,验证将失败。

为了解决这个问题,我们通过反转字节顺序将哈希转换为大端格式。由于 u256 在内部表示为两个 u128 半部分(lowhigh),我们:

  • 反转每个 u128 的字节
  • 交换它们的位置

这将产生与链下签名的消息哈希匹配的消息哈希,从而使签名验证步骤成功。

  1. 验证以太坊签名
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(在以太坊中通常是 27 或 28),但 Starknet 的 Signature 类型期望 y_parity 为布尔类型。如果 v 等于 28,则奇偶为 true,否则为 false
y_parity: v == 28
  • 调用 verify_eth_signature 函数

在内部,此函数:

  • (r, s, y_parity) 恢复公钥
  • 从该密钥派生以太坊地址
  • 将其与 authorized_signer 比较

如果不匹配,执行回滚。

  1. 转移代币
self.claimed.entry(recipient).write(true);

let token = IERC20Dispatcher { contract_address: self.token.read() };
let ok = token.transfer(recipient, amount);
assert(ok, 'TRANSFER_FAILED');

仅在成功验证签名后,我们才:

  1. 将用户标记为已索赔(防止双重索赔)。
  2. 调用 ERC20 合约的 transfer 函数。
  3. 断言转移成功。

如果转移失败,整个交易回滚。

本地测试空投合约

由于这是一个没有 ERC-20 集成的本地测试,请注释掉合约 claim 函数中的步骤 4(代币转账),因为重点仅仅在于验证签名逻辑。

将以下代码粘贴到 test_contract.cairo 文件中:

use /** <项目名称> **/::{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 = /** <签名者地址> **/;
    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 };

    // 与链下签名的相同消息
    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> **/,
        );
}

替换:

  • <项目名称> 为项目(文件夹)名称
  • <签名者地址> 为与签名者私钥绑定的以太坊地址
  • <R><S><V> 分别为 rsv 值(从链下生成的签名中获取的值)

然后运行:

scarb test

测试通过,确认合约正确重建了消息哈希,验证了签名与签名者公钥,并将索赔标记为已使用。如果测试失败并显示 "Invalid signature",最可能的原因是:

  • 消息哈希在链上和链下不是完全一致构建的(字段顺序、编码不匹配)
  • 错误地将错误的签名者地址传递给了构造函数
  • 从链下脚本输出复制了错误的 rsv

练习:通过仅将测试文件中的 amount 更改为不同的值来测试失败情况,然后再次运行测试。

  • 原文链接: rareskills.io/post/cairo...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论