La verificación de firmas es el proceso de usar una clave pública para probar matemáticamente que un mensaje o transacción fue firmado usando la clave privada correspondiente.
Verificación de firmas en Ethereum vs Starknet
En Ethereum, la verificación de firmas vive en el protocolo o en el código del contrato, dependiendo de si la cuenta es una EOA o una billetera de contrato inteligente.
- Las EOA usan ECDSA nativo (secp256k1): el protocolo maneja la verificación de firmas como parte del procesamiento de transacciones. Recupera la dirección del firmante a partir de la firma, y si la dirección recuperada coincide con la dirección esperada, la firma es válida. Las reglas de verificación están integradas en el protocolo y no pueden cambiarse.
- Las billeteras de contratos inteligentes usan validación definida por el contrato: el contrato de la billetera define su propia lógica de verificación (más comúnmente a través de EIP-1271) y devuelve si la firma es válida o no. La autoridad se traslada del protocolo al código del contrato.
En Starknet, no hay EOA. Cada cuenta es un contrato inteligente, por lo que no existe la distinción entre EOA y billeteras inteligentes. La verificación de firmas siempre es manejada por el propio contrato de la cuenta. Este es un ejemplo de account abstraction, donde las reglas para la validación son programables en lugar de estar fijadas por el protocolo.
Desde el punto de vista del protocolo, el proceso de verificación de firmas nunca cambia: se proporciona un hash del mensaje y una firma, se llama al contrato de la cuenta y este decide si la firma es válida o no. La transacción luego se ejecuta o rechaza basándose en esa decisión.
Cómo el contrato de la cuenta llega a esa decisión depende enteramente de su implementación. En la práctica, la mayoría de las implementaciones difieren principalmente en el esquema de firma que usan para la verificación.
En este artículo, cubriremos los esquemas de firma comunes en Starknet y mostraremos cómo se verifica cada uno en la práctica.
Esquemas de firma comunes usados para verificación
En Starknet hoy en día, los esquemas de firma más comunes son:
- Stark curve ECDSA, y
- secp256k1 ECDSA.
Stark curve ECDSA (esquema nativo de Starknet)
El esquema de firma nativo de Starknet usa ECDSA sobre la curva elíptica compatible con Stark (la “Stark curve”). Las firmas se verifican contra el hash del mensaje y la clave pública del firmante usando las funciones ECDSA integradas de Cairo.
Cómo se verifica una firma usando Stark curve ECDSA
La biblioteca principal de Cairo proporciona una función integrada, check_ecdsa_signature, para verificar firmas de Stark curve. Toma cuatro argumentos: el hash del mensaje, la clave pública del firmante, y los valores de la firma r y s, y luego devuelve si la firma es válida:
fn check_ecdsa_signature(
message_hash: felt252,
public_key: felt252,
signature_r: felt252,
signature_s: felt252
) -> bool;
Antes de que ocurra cualquier verificación, las entradas se someten a una comprobación básica (sanity-check) dentro de la función. Los valores de la firma deben ser distintos de cero y no iguales al orden de la Stark curve. Sin embargo, la función no comprueba que r y s sean estrictamente menores que el orden de la curva, lo cual es importante porque ambos valores operan en enteros módulo n (orden de la curva), ni tampoco comprueba la signature malleability. Por lo tanto, se espera que quienes llaman afirmen dos cosas antes de invocar la función:
- que
res menor que el orden de la curva, - y que
s <= ORDER / 2para eliminar las variantes maleables.
Ambas comprobaciones se cubrirán en una subsección posterior.
Después de que todas las comprobaciones estén en su lugar, se invoca la función check_ecdsa_signature para verificar que la firma (r, s) está vinculada al hash del mensaje y a la clave pública proporcionados. Realiza una serie de operaciones de curva elíptica sobre estas entradas y comprueba si los resultados son consistentes. Si lo son, la firma es válida y la función devuelve true; de lo contrario, devuelve false.
Los proveedores de billeteras (por ejemplo, Ready, Braavos) dependen comúnmente de este esquema para confirmar que una transacción fue autorizada por la cuenta correcta antes de permitir que se ejecute on-chain.
Poniendo en práctica la verificación de firmas de Stark curve: un ejemplo de airdrop de tokens
Para hacer esto más concreto, considera un ejemplo simple de airdrop de tokens que muestra cómo se usa la verificación ECDSA de Stark-curve en la práctica para confirmar que un usuario es elegible para recibir tokens.
En este flujo, vamos a:
- Hacer que un firmante autorizado genere una firma de Stark-curve sobre la dirección del destinatario elegible y la cantidad a reclamar usando su clave privada
- Desplegar un contrato de airdrop que verifique la firma on-chain antes de transferir cualquier token
- Probar el contrato de airdrop verificando la firma generada y reclamando algunos tokens.
Generar una firma de Stark-curve usando starknet.js
Antes de crear el contrato de airdrop que acepta firmas, necesitamos una forma de producir una firma de Stark-curve off-chain. En una aplicación real, esto se hace usualmente con una billetera. Para este ejemplo, mantendremos las cosas simples y generaremos la firma usando starknet.js.
Comenzaremos configurando un pequeño proyecto de Node.js para:
- construir el mensaje a ser firmado
- hashearlo
- firmarlo usando una clave privada de Stark-curve
Configuración del proyecto
Primero, crea una nueva carpeta de proyecto e inicialízala:
mkdir my_signature
cd my_signature
npm init -y
A continuación, instala las dependencias:
npm install starknet dotenv
He aquí por qué las necesitamos:
starknet: proporciona funciones para hacer hash de mensajes y generar firmas ECDSA de Stark-curvedotenv: nos permite mantener las claves privadas y la configuración fuera del código base y cargarlas de forma segura desde variables de entorno
Ahora, creemos el directorio src y el archivo de entrada:
mkdir src
touch src/index.js
En este punto, la estructura del proyecto debería verse así:
my_signature/
├── src/
│ └── index.js
├── package.json
└── node_modules/
Navega al archivo package.json, debería verse como la imagen a continuación:

Reemplaza las partes resaltadas en rojo con lo siguiente:
{
...
scripts: {
"start": "node src/index.js"
},
...
"type": "module",
...
}
Crea un archivo .env para las variables de configuración:
touch .env
Agrega lo siguiente al archivo .env:
AIRDROP_SIGNER_PK=0x...
RECIPIENT=0x...
Reemplaza los valores de marcador de posición:
AIRDROP_SIGNER_PK: la clave privada de la cuenta que firmará el mensaje. Usa una de tus cuentas desncastexistentes ejecutandosncast account list -ppara ver las cuentas disponibles y sus claves privadas correspondientesRECIPIENT: Dirección de la cuenta de Starknet del usuario elegible
Con el proyecto configurado, podemos pasar a la siguiente parte, que es escribir el script que firma un mensaje.
Para hacer eso, vamos a:
- Definir el mensaje a ser firmado. Definiremos quién puede reclamar un token y cuánto puede reclamar
- Hacer el hash de ese mensaje exactamente de la misma manera que el contrato espera
- Firmar el hash usando Stark-curve ECDSA
Firmando el mensaje del airdrop
A continuación se muestra el script completo. Pégalo en el archivo index.js que creamos anteriormente. Puede parecer un poco pesado a primera vista, pero no te preocupes, lo desglosaremos parte por parte justo después.
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");
Aquí está el desglose completo del código anterior.
Importaciones y configuración:
import { ec, hash } from "starknet";
import * as dotenv from "dotenv";
dotenv.config();
Aquí, importamos dos cosas de starknet.js:
hash: un módulo que proporciona funciones auxiliares para hacer hash de mensajes usando el esquema de hash de Starknetec: un módulo para operaciones de curva elíptica, usado para firmar el mensaje hasheado sobre la Stark curve
También cargamos las variables de entorno usando dotenv, lo que mantiene la información privada fuera del código fuente.
Leyendo entradas de las variables de entorno y validándolas:
En esta parte del código, estamos leyendo la clave privada (AIRDROP_SIGNER_PK) y la dirección del usuario (RECIPIENT), y luego validamos que existan, de lo contrario, se lanzará un error.
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...");
Definiendo el mensaje a ser firmado:
const amount = 200 * 10**18;
const message = [recipient, amount];
Este es el mensaje exacto que estamos firmando. En términos simples, significa “A este ‘recipient’ se le permite reclamar este ‘amount’ de tokens.”
Ten en cuenta que la disposición del mensaje debe coincidir exactamente con lo que el contrato hashea on-chain. Cualquier discrepancia, ya sea un orden diferente, un valor faltante, o incluso el uso de una función hash diferente, causará que la verificación de la firma falle.
Hasheando el mensaje:
const msgHash = hash.computePoseidonHashOnElements(message);
computePoseidonHashOnElements toma un arreglo de valores (en nuestro caso, el mensaje del airdrop) y los hashea en un único elemento de campo usando la función hash Poseidon. El resultado es el hash del mensaje (msgHash) que será firmado.
Estamos usando Poseidon aquí porque es más rápido y económico de verificar on-chain en comparación con otras hash functions.
Firmando con Stark-curve ECDSA:
const signature = ec.starkCurve.sign(msgHash, privateKey);
Finalmente, firmamos el hash del mensaje usando Stark-curve ECDSA. El resultado es un objeto de firma que contiene dos números grandes, r y s, que juntos componen la firma, más un valor recovery usado internamente durante la verificación.
Estos datos de la firma son los que el contrato de airdrop verificará on-chain.
Usa el siguiente comando para ejecutar el script:
npm start
Si todo se ejecuta correctamente, la salida de la terminal debería parecerse a la siguiente (ten en cuenta que los valores diferirán dependiendo del mensaje y la clave privada utilizados):

En la práctica, solo se necesitan los valores r y s del struct Signature (en el recuadro rojo). Estos son los componentes de la firma que el destinatario elegible pasará al contrato al reclamar sus tokens.
El valor recovery se usa para reconstruir la clave pública del firmante a partir de la firma. Sin embargo, no es necesario para la verificación de firmas de Stark-curve, ya que la clave pública se proporciona como un argumento a la función de verificación, por lo que no hay necesidad de recuperarla durante la verificación.
El recuadro azul muestra los valores r y s en formato hexadecimal. Guarda estos valores, ya que se necesitarán más adelante durante el reclamo de tokens.
Contrato de airdrop
Ahora que podemos generar una firma válida de Stark-curve off-chain, el siguiente paso es observar el contrato de airdrop y ver cómo verifica esta firma on-chain antes de liberar los tokens al destinatario.
Crear un proyecto de Scarb
En la carpeta raíz, ejecuta el siguiente comando para crear un proyecto de Scarb llamado my_contract y luego haz cd en él:
scarb new my_contract
cd my_contract
La nueva estructura del proyecto debería verse así:
my_signature/
├── my_contract/
│ ├── src/
│ │ └── lib.cairo
│ └ ...
└── ...
A continuación, reemplaza el contrato generado automáticamente en el archivo lib.cairo con el contrato de airdrop que se muestra a continuación. A un alto nivel, el contrato hace tres cosas:
- reconstruye el hash exacto del mensaje que fue firmado off-chain
- verifica la firma ECDSA de Stark-curve
- transfiere tokens en caso de una verificación exitosa
Revisaremos el contrato parte por parte después del bloque de código.
// 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");
}
}
}
Interfaces del contrato
En el código anterior, usamos dos interfaces:
- una interfaz ERC-20 mínima para transferir tokens
- una interfaz para la función
claimdel airdrop
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,
);
}
El contrato de airdrop requiere dos interfaces: una para interactuar con el contrato ERC-20 a través de su función transfer, y la otra para exponer un punto de entrada claim para que los usuarios reclamen sus tokens.
Importando las dependencias relevantes
La mayoría de las dependencias aquí ya deberían parecer familiares por artículos anteriores. Las únicas importaciones de las que no hemos hablado aún son las que se encuentran debajo del comentario 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 es la función que realiza la verificación.
La constante ORDER se importa desde el módulo principal de Stark-curve de Cairo, el cual se usará para asegurar que los valores r y s se encuentren dentro del rango válido de la curva.
Diseño del almacenamiento (Storage Layout)
Cada campo tiene un rol muy específico:
claimedrastrea si un destinatario ya ha reclamado (un reclamo por dirección)signeres la clave pública de Stark-curve correspondiente a la clave privada autorizada para firmar los mensajes del airdroptokenes la dirección del token ERC-20 que se está distribuyendo
#[storage]
struct Storage {
claimed: Map<ContractAddress, bool>,
signer: felt252,
token: ContractAddress,
}
Constructor del contrato de airdrop
El constructor simplemente asigna:
- la clave pública del firmante autorizado a la variable de almacenamiento
signer - la dirección del token del airdrop a la variable de almacenamiento
token
#[constructor]
fn constructor(ref self: ContractState, signer_stark_key: felt252, token: ContractAddress) {
self.signer.write(signer_stark_key);
self.token.write(token);
}
Función Claim
La función claim permite a un usuario elegible reclamar su asignación del airdrop. Toma la cantidad (amount) reclamada y una firma (r, s) como argumentos:
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");
}
Veamos esto paso a paso.
-
Almacenar en caché la dirección de quien llama:
// 1) Cache caller's address let recipient = get_caller_address();Necesitamos esto para reconstruir el mensaje y estar seguros de que el caller es elegible para reclamar algunos tokens.
-
Imponer reclamos únicos (de una sola vez):
let already_claimed = self.claimed.entry(recipient).read(); assert!(!already_claimed, "Already claimed");Antes de realizar cualquier criptografía, nos aseguramos de que el destinatario no haya reclamado ya. Esto previene la reproducción (replay) de la misma firma para drenar el airdrop.
-
Reconstruir el hash del mensaje:
let msg: Array<felt252> = array![recipient.into(), amount]; let msg_hash: felt252 = poseidon_hash_span(msg.span());Aquí, el contrato reconstruye el hash exacto del mensaje que fue firmado off-chain usando la misma función hash. Cualquier discrepancia entre el hasheo on-chain y off-chain causará que la verificación de la firma falle.
-
Comprobación de cordura de la firma:
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");Comprueba que
rsea estrictamente menor que el orden de la Stark curve, y quesesté en la mitad inferior del orden de la curva (s <= ORDER / 2). Ambas comprobaciones aseguran que los valores sean escalares ECDSA válidos. La comprobación deselimina adicionalmente la maleabilidad de la firma. Restringirsa la mitad inferior asegura que solo se acepte una de las dos formas, garantizando una única firma por mensaje. -
Verificar la firma de stark-curve:
let signer_pk = self.signer.read(); let valid = check_ecdsa_signature(msg_hash, signer_pk, r, s); assert!(valid, "Invalid signature");Aquí, la función integrada
check_ecdsa_signaturede Cairo verifica:- el hash del mensaje
- la clave pública del firmante
- el par de firma
(r, s)
Si esta comprobación pasa, sabemos que el firmante autorizó al “recipient” a reclamar esta “amount” de tokens. Si no lo hace, toda la transacción se revierte con el error “Invalid signature”.
-
Marcar como reclamado:
self.claimed.entry(recipient).write(true);Después de verificar la firma, el destinatario es marcado como reclamado antes de transferir los tokens.
-
Transferir 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");Finalmente, el contrato llama al contrato ERC-20 y transfiere los tokens. Si la transferencia falla, toda la transacción se revierte.
Probar el contrato
Para mantener esta sección enfocada en lo que realmente estamos probando (verificación de firmas y lógica de reclamo), reutilizaremos un ERC-20 existente desplegado en Starknet sepolia y mintearemos tokens directamente al contrato de airdrop.
Desplegar el contrato de airdrop
Primero, declaramos el contrato para obtener su class hash usando el siguiente comando:
sncast --account <ACCOUNT_NAME> \
declare \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--contract-name SignatureAirdrop
Reemplaza:
<ACCOUNT_NAME>con el nombre de tu cuenta de sncast<YOUR_API_KEY>con tu clave API de Alchemy.
Declarar el contrato registra su clase en Starknet. Una vez que tenemos el class hash, podemos desplegar una instancia del contrato.
Para desplegar el contrato de airdrop, necesitamos lo siguiente:
-
Clave pública del firmante: Esta es la clave pública de Stark-curve cuya clave privada correspondiente se usa para firmar los mensajes del airdrop off-chain. Durante un reclamo, el contrato verifica que la firma enviada fue producida por esta clave antes de liberar cualquier token.
Ejecuta este comando para obtener la lista de claves públicas vinculadas a sus correspondientes claves privadas:
sncast account list # OR sncast account list -p # To show private keys tooLuego copia la clave pública que corresponde a la clave privada del firmante usada al generar la firma:

-
Dirección del contrato del token: La dirección del token ERC-20 ya desplegado en Starknet Sepolia.
0x03ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b
Ahora podemos proceder a desplegar el contrato de airdrop:
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>'
Reemplaza:
<ACCOUNT_NAME>con el nombre de tu cuenta<YOUR_API_KEY>con tu clave API de Alchemy<CLASS_HASH>con el class hash obtenido de la declaración<SIGNER_PUBKEY>con la clave pública del firmante<TOKEN_CONTRACT_ADDRESS>con la dirección del contrato del token desplegado (0x03ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b)
Una vez que el despliegue sea exitoso, guarda la dirección del contrato de airdrop, necesitaremos mintearle algunos tokens y usarlo para todas las llamadas de prueba subsiguientes.
Mintear tokens al contrato de airdrop
Antes de que cualquier reclamo pueda tener éxito, el contrato de airdrop debe tener suficientes tokens para distribuir.
Ejecuta el comando para mintear 1,000 tokens (escalados a 18 decimales) al contrato de airdrop:
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'
Reemplaza:
<ACCOUNT_NAME>con el nombre de tu cuenta<YOUR_API_KEY>con tu clave API de Alchemy<TOKEN_CONTRACT_ADDRESS>con la dirección del contrato del token desplegado (0x03ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b)<FUNCTION_NAME>con el nombre de la función a invocar (mint)<RECIPIENT>con la dirección del contrato de airdrop
En este punto, la configuración está completa. El contrato de airdrop está desplegado y fondeado con tokens. Ahora podemos pasar a probar la función claim.
Invocando la función claim
Para reclamar, suministraremos los componentes de la firma r y s que fueron generados off-chain en una sección anterior, junto con la cantidad (amount) del token.
La dirección de la cuenta que llama a la función
claimdebe ser la misma dirección del destinatario que se firmó en el mensaje. Si una cuenta diferente intenta reclamar usando esa firma, la transacción se revertirá.
Usando sncast, el destinatario llama a la función claim en el contrato de airdrop desplegado pasando los argumentos necesarios:
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>'
Reemplaza:
<RECIPIENT_ACCOUNT>con la cuenta correspondiente al nombre de cuenta del destinatario firmado<YOUR_API_KEY>con tu clave API de Alchemy<AIRDROP_CONTRACT_ADDRESS>con la dirección del contrato de airdrop desplegado<AMOUNT>con la cantidad exacta incluida en el mensaje firmado, en nuestro caso,0xad78ebc5ac6200000(200 * 1e18)<R>y<S>con el valorrysrespectivamente (los valores de la firma generada off-chain)
Si todo es correcto, la transacción debería tener éxito. Para confirmar que el reclamo funcionó, consultamos la función balance_of del token ERC-20 para el destinatario:
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>'
Reemplaza:
<YOUR_API_KEY>con tu clave API de Alchemy<TOKEN_CONTRACT_ADDRESS>con la dirección del contrato del token<RECIPIENT_ADDRESS>con la dirección del destinatario
Si el reclamo fue exitoso, el saldo devuelto debería reflejar el <AMOUNT> reclamado.
Ahora que hemos verificado exitosamente una firma de Stark-curve y completado un reclamo, veamos cómo se verifican las firmas de Ethereum dentro de un contrato de Cairo.
Secp256k1 ECDSA (Firmas al estilo Ethereum)
secp256k1 es la curva elíptica usada por el esquema de firmas ECDSA de Ethereum. En la práctica, una billetera de Ethereum demuestra “yo controlo esta dirección” firmando un mensaje o transacción con su clave privada. El verificador puede recuperar la dirección de Ethereum del firmante a partir de la firma y del hash del mensaje o de la transacción, para luego comprobar que coincide con la dirección esperada.
Aquí es donde el flujo difiere del esquema de Stark-curve discutido anteriormente. Con la función check_ecdsa_signature que discutimos antes, la clave pública se pasa como una entrada y la función devuelve un resultado booleano explícito. Con las firmas al estilo Ethereum, ecrecover solo recupera una dirección, no hay verificación hasta que la dirección recuperada se compara explícitamente con el firmante esperado.
En Starknet, la biblioteca principal de Cairo proporciona funciones auxiliares para verificar firmas de Ethereum, concretamente, verify_eth_signature y is_eth_signature_valid. Estas funciones se usan en los contratos de Starknet para verificar una firma de Ethereum frente a un hash de mensaje y una dirección de Ethereum esperada. La diferencia principal entre ellas es que verify_eth_signature hace una aserción (assert) y entra en pánico (panic) ante una entrada inválida, mientras que is_eth_signature_valid devuelve un Result, lo que permite un manejo elegante de los errores.
Además, ambas funciones tienen la corrección para la maleabilidad de la firma integrada, imponen s <= N/2 (junto a s != 0 como una comprobación de cordura) a través de is_signature_s_valid de forma interna (under the hood), lo que significa que no necesitas agregar esa comprobación tú mismo.
Para demostrar cómo funciona la verificación de firmas de Ethereum dentro de un contrato de Starknet, podemos reutilizar la misma idea del ejemplo basado en airdrop de la sección de Stark-curve ECDSA.
Ejemplo de airdrop de tokens
Supongamos que el proyecto quiere distribuir tokens en Starknet a los usuarios que son elegibles basándose en sus direcciones de Ethereum. Para asegurar que solo el propietario legítimo de una dirección de Ethereum elegible pueda reclamar, el mensaje firmado debe incluir todos los valores que definen el reclamo, tales como:
- la dirección de Ethereum elegible,
- la dirección de Starknet que recibirá los tokens, y
- la cantidad de tokens.
Esto vincula los datos del reclamo. Si alguno de estos valores se cambia, el hash del mensaje también cambiará, el firmante recuperado ya no coincidirá con el firmante de confianza, y la verificación fallará.
En código, el flujo sería:
- generar una firma off-chain usando una clave privada de Ethereum (el firmante autorizado) con
ethers.js - crear un contrato de airdrop en Starknet para verificar firmas y reclamar tokens
Generar firma usando Ethers.js
Para generar la firma de Ethereum off-chain, podemos usar Ethers.js, una biblioteca de JavaScript para interactuar con Ethereum. Proporciona herramientas para calcular hashes, crear firmas e interactuar con contratos inteligentes, pero para el ecosistema de Ethereum.
A un alto nivel, el código de generación de firmas realiza los siguientes pasos:
- Importaciones de la biblioteca ethers.
- Carga una billetera de Ethereum usando una clave privada.
- Construye el mensaje de reclamo (incluyendo la dirección de Ethereum, la dirección de Starknet y la cantidad de tokens).
- Hashea el mensaje usando keccak256.
- Firma el hash usando secp256k1
- Extrae los valores
r,s, yvnecesarios para verificar la firma.
// 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 });
Aquí hay un desglose detallado de cada parte:
-
Importaciones
import { solidityPackedKeccak256, Signature, SigningKey } from "ethers";solidityPackedKeccak256: Empaqueta los valores juntos y los hashea usando Keccak256.SigningKey: Proporciona funcionalidad de firma de bajo nivel en secp256k1. Se usa para firmar directamente un hash de mensaje de 32 bytes.Signature: Una utilidad para formatear y extraerr,s, yva partir del objeto de la firma.
-
Creando la Signing Key (Clave de firma)
const key = new SigningKey(privateKey);Esto nos da acceso a la funcionalidad de firma secp256k1, que es exactamente lo que necesitamos: firmar un hash de 32 bytes y obtener
(r, s, v). -
Datos del reclamo
const ethAddress = "0x1234567890123456789012345678901234567890"; const starknetAddress = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd"; const amount = 1000;ethAddresses la dirección de Ethereum elegible.starknetAddresses la dirección que recibirá los tokens en Starknet.amountes el número de tokens que se están reclamando.
-
Construyendo el hash del mensaje con los datos del reclamo
const messageHash = solidityPackedKeccak256( ["uint256", "uint256", "uint256"], [ethAddress, starknetAddress, amount], );Empaquetó los datos del reclamo (dirección de Ethereum, dirección de Starknet, amount) y los hasheó usando Keccak-256, la misma función hash usada por Ethereum.
Ten en cuenta que la lógica de hasheo off-chain debe coincidir exactamente con la lógica de hasheo dentro del contrato de Starknet. Si el orden de empaquetado, o la función de hash difieren, el firmante recuperado no coincidirá y el reclamo fallará.
-
Firmando el Hash
const signature = key.sign(messageHash);Esto produce una firma ECDSA estándar de Ethereum sobre secp256k1. El resultado contiene tres componentes
r,s, yv(bit de recuperación). Estos son los valores que el contrato de airdrop usará más tarde para recuperar la clave pública del firmante y derivar la dirección de Ethereum. -
Extrayendo
r,s, yvconst { r, s, v } = Signature.from(signature);Estos tres valores son los que el usuario pasará a la función
claimen Starknet.
Lo siguiente es la implementación del contrato de airdrop, luego lo probaremos localmente.
Contrato de airdrop
En el contrato, la función claim hace lo siguiente:
- Reconstruir el hash del mensaje, igual que el script off-chain
- Convertir la salida del keccak de little-endian a big-endian
- Verifica la firma de Ethereum usando los ayudantes (helpers) secp256k1 de Cairo
- Transfiere tokens al destinatario de Starknet
Reemplaza el contenido de lib.cairo con el siguiente código:
// 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');
}
}
}
Aquí hay un desglose detallado de las partes comentadas:
Importaciones
Antes de verificar una firma al estilo Ethereum on-chain, necesitamos unos cuantos tipos y funciones auxiliares. Las primeras cinco importaciones a continuación son todas necesarias para verificar una firma secp256k1 (Ethereum) dentro de un contrato de Starknet.
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_reverseinvierte el orden de los bytes de un entero de 128 bits.keccak_u256s_be_inputscalcula un hash Keccak sobre entradasu256en formato big-endian, coincidiendo con el estándar de hasheo de Ethereum.EthAddressrepresenta una dirección estándar de Ethereum de 20 bytes dentro de Cairo.verify_eth_signaturerealiza la verificación secp256k1 on-chain. Toma un hash de mensaje y una firma, recupera la dirección de Ethereum de la firma, y la compara con la dirección esperada. Si coinciden, la firma es válida.Signaturedefine la estructura de una firma de Ethereum, que consta de tres componentesr,syv.
La función claim
La función recibe:
eth_address: la dirección de Ethereum elegible para el airdroprecipient: la dirección de Starknet que recibirá los tokens del airdropamount: el número de tokens que se están reclamandor,s,v: los componentes de la firma ECDSA de Ethereum
fn claim(
ref self: ContractState,
eth_address: EthAddress,
recipient: ContractAddress,
amount: u256,
r: u256,
s: u256,
v: u256,
) {
// Claim logic
}
La lógica de claim (reclamo)
-
Reconstruyendo el Hash del Mensaje
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(), );Esto recrea exactamente el mismo hash del mensaje que se construyó off-chain usando:
solidityPackedKeccak256( ["uint256","uint256","uint256"], [ethAddress, starknetAddress, amount] );Nosotros:
- Convertimos
EthAddressyContractAddressenu256 - Mantenemos el mismo orden:
[ethAddress, starknetAddress, amount] - Los hasheamos usando Keccak-256
El orden, los tipos y la función hash deben coincidir exactamente con la lógica off-chain. Cualquier diferencia producirá un hash distinto, y la verificación de la firma fallará.
- Convertimos
-
Convirtiendo el Hash del Mensaje a Formato Big-Endian
let msg_hash_be = u256 { low: u128_byte_reverse(msg_hash_le.high), high: u128_byte_reverse(msg_hash_le.low), };La razón de este paso es que la función
keccak_u256s_be_inputsde Cairo devuelve el hash en formato little-endian, mientras que las firmas de Ethereum se producen sobre un hash de 32 bytes en big-endian.Debido a esta diferencia, el hash del mensaje producido dentro del contrato de Starknet no coincidiría con el hash producido off-chain usando
ethers.js. Si intentáramos verificar la firma usando el valor little-endian directamente, el firmante recuperado sería incorrecto y la verificación fallaría.Para arreglar esto, convertimos el hash al formato big-endian invirtiendo el orden de los bytes. Dado que un
u256se representa internamente como dos mitadesu128(lowyhigh), nosotros:- invertimos los bytes de cada
u128 - intercambiamos sus posiciones
Esto produce el hash del mensaje que coincide con el que fue firmado off-chain, permitiendo que el paso de verificación de la firma tenga éxito.
- invertimos los bytes de cada
-
Verificando la Firma de Ethereum
let signer = self.authorized_signer.read(); let sig = Signature { r, s, y_parity: v == 28, }; verify_eth_signature(msg_hash_be, sig, signer);Este es el paso de verificación criptográfica:
-
Carga el
authorized_signeralmacenado -
Construye un struct
Signatureusando(r, s, y_parity). Aunque la funciónclaimrecibe el parámetro de firmav(normalmente 27 o 28 en Ethereum), el tipoSignaturede Starknet espera en su lugar uny_parityde tipobool. Sives igual a28, la paridad estrue, de lo contrario esfalse.y_parity: v == 28 -
Llama a la función
verify_eth_signature
Internamente, esta función:
- Recupera la clave pública a partir de
(r, s, y_parity) - Deriva la dirección de Ethereum de esa clave
- La compara con
authorized_signer
Si no coinciden, la ejecución se revierte.
-
-
Transfiriendo los Tokens
self.claimed.entry(recipient).write(true); let token = IERC20Dispatcher { contract_address:self.token.read() }; let ok = token.transfer(recipient,amount); assert(ok,'TRANSFER_FAILED');Solo después de una verificación de firma exitosa nosotros:
- Marcamos al usuario como reclamado (para prevenir reclamos dobles).
- Llamamos a la función
transferdel contrato ERC20. - Aseguramos (assert) que la transferencia haya tenido éxito.
Si la transferencia falla, toda la transacción se revierte.
Probar el contrato de airdrop localmente
Dado que esta es una prueba local sin integración de ERC-20, comenta el paso 4 (la transferencia del token) en la función claim del contrato, ya que el enfoque está únicamente en verificar la lógica de la firma.
Pega el siguiente código en el archivo 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> **/,
);
}
Reemplaza:
<PROJECT_NAME>con el nombre del proyecto (carpeta)<SIGNER_ADDRESS>con la dirección de Ethereum vinculada a la clave privada del firmante<R>,<S>, y<V>con los valoresr,s, yvrespectivamente (los valores de la firma generada off-chain)
Luego ejecuta:
scarb test
Una prueba exitosa confirma que el contrato reconstruye correctamente el hash del mensaje, verifica la firma contra la clave pública del firmante, y marca el reclamo como usado. Si la prueba falla con "Invalid signature", las causas más probables son:
- El hash del mensaje no se construyó de manera idéntica on-chain y off-chain (orden de campos, desajuste de codificación)
- Se pasó la dirección de firmante incorrecta al constructor
- Los valores
r,s, ovse copiaron incorrectamente de la salida del script off-chain
Ejercicio: Prueba el caso de fallo cambiando solo el amount en el archivo de prueba a un valor diferente, luego ejecuta la prueba de nuevo.