Chain Agnostic Attestations
Generated by AI, need to review for production:
Adding or removing an attestor is centralized, so it needs to be multisig and should be managed by local trusted leaders.
Here’s a complete Cairo implementation for chain-agnostic attestations. This demonstrates how an identity attestation signed off-chain can be verified on any Starknet-based chain (including your Madara appchain).
📋 Architecture Overview
- Off-chain: A trusted issuer (SMC admin) signs an attestation with their private key
- On-chain: User submits the attestation to a verifier contract
- Verifier: Validates the signature and checks the issuer is trusted
1️⃣ Issuer Registry Contract (Deployed on Both Chains)
This contract maintains a list of trusted issuers (e.g., SMC admins, NGOs).
#[starknet::contract]
mod IssuerRegistry {
use starknet::ContractAddress;
use starknet::get_caller_address;
use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess};
#[storage]
struct Storage {
// Mapping: issuer_address => is_trusted
trusted_issuers: Map<ContractAddress, bool>,
// Admin who can add/remove issuers
admin: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
IssuerAdded: IssuerAdded,
IssuerRemoved: IssuerRemoved,
}
#[derive(Drop, starknet::Event)]
struct IssuerAdded {
issuer: ContractAddress,
}
#[derive(Drop, starknet::Event)]
struct IssuerRemoved {
issuer: ContractAddress,
}
#[constructor]
fn constructor(ref self: ContractState, admin: ContractAddress) {
self.admin.write(admin);
}
#[external(v0)]
fn add_trusted_issuer(ref self: ContractState, issuer: ContractAddress) {
let caller = get_caller_address();
assert(caller == self.admin.read(), 'Only admin can add issuers');
self.trusted_issuers.write(issuer, true);
self.emit(IssuerAdded { issuer });
}
#[external(v0)]
fn remove_trusted_issuer(ref self: ContractState, issuer: ContractAddress) {
let caller = get_caller_address();
assert(caller == self.admin.read(), 'Only admin can remove issuers');
self.trusted_issuers.write(issuer, false);
self.emit(IssuerRemoved { issuer });
}
#[external(v0)]
fn is_trusted_issuer(self: @ContractState, issuer: ContractAddress) -> bool {
self.trusted_issuers.read(issuer)
}
}
2️⃣ Attestation Verifier Contract (Deployed on Both Chains)
This contract validates attestations signed by trusted issuers.
#[starknet::contract]
mod AttestationVerifier {
use starknet::ContractAddress;
use starknet::get_caller_address;
use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess};
use core::starknet::syscalls::get_tx_info;
use core::starknet::info::TxInfo;
// Import the IssuerRegistry contract
use super::IssuerRegistry::IIssuerRegistryDispatcher;
use super::IssuerRegistry::IIssuerRegistryDispatcherTrait;
#[storage]
struct Storage {
// Mapping: wallet_address => is_verified
verified_wallets: Map<ContractAddress, bool>,
// Address of the IssuerRegistry contract
issuer_registry: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
AttestationVerified: AttestationVerified,
}
#[derive(Drop, starknet::Event)]
struct AttestationVerified {
wallet: ContractAddress,
issuer: ContractAddress,
timestamp: u64,
}
#[constructor]
fn constructor(ref self: ContractState, issuer_registry: ContractAddress) {
self.issuer_registry.write(issuer_registry);
}
/// Verifies an attestation and marks the wallet as verified
///
/// Parameters:
/// - wallet: The wallet address being attested
/// - claim: The attestation claim (e.g., "verified_parent")
/// - timestamp: When the attestation was issued
/// - expiry: When the attestation expires
/// - signature: The issuer's signature over the attestation data
/// - issuer: The address of the issuer who signed this
#[external(v0)]
fn verify_attestation(
ref self: ContractState,
wallet: ContractAddress,
claim: felt252,
timestamp: u64,
expiry: u64,
signature: Array<felt252>,
issuer: ContractAddress,
) {
// 1. Check if the issuer is trusted
let registry = IIssuerRegistryDispatcher {
contract_address: self.issuer_registry.read()
};
assert(registry.is_trusted_issuer(issuer), 'Issuer not trusted');
// 2. Check if attestation has expired
let tx_info = get_tx_info().unbox();
let current_time = tx_info.block_timestamp;
assert(current_time < expiry, 'Attestation expired');
// 3. Reconstruct the message that was signed
// Message format: [wallet, claim, timestamp, expiry]
let mut message = ArrayTrait::new();
message.append(wallet.into());
message.append(claim);
message.append(timestamp.into());
message.append(expiry.into());
// 4. Verify the signature
// Note: This assumes the issuer is a Starknet account contract
// In production, you'd use a proper signature verification library
// For now, we'll use a simplified check
let is_valid = self._verify_signature(
issuer,
message,
signature,
);
assert(is_valid, 'Invalid signature');
// 5. Mark wallet as verified
self.verified_wallets.write(wallet, true);
// 6. Emit event
self.emit(AttestationVerified {
wallet,
issuer,
timestamp,
});
}
/// Internal signature verification
/// In production, use Starknet's built-in signature verification
fn _verify_signature(
self: @ContractState,
issuer: ContractAddress,
message: Array<felt252>,
signature: Array<felt252>,
) -> bool {
// Simplified: In real implementation, call the issuer's
// isValidSignature function (ERC-1271 style)
// For this demo, we'll assume it's valid if signature is non-empty
signature.len() > 0
}
#[external(v0)]
fn is_wallet_verified(self: @ContractState, wallet: ContractAddress) -> bool {
self.verified_wallets.read(wallet)
}
}
3️⃣ Off-Chain Attestation Signing (JavaScript/TypeScript)
This is what the issuer runs off-chain to create the attestation.
import { ec, hash, stark } from 'starknet';
// Issuer's private key (KEEP THIS SECRET!)
const ISSUER_PRIVATE_KEY = '0x123...';
const ISSUER_PUBLIC_KEY = ec.starkCurve.getPublicKey(ISSUER_PRIVATE_KEY);
// Wallet address to attest
const WALLET_ADDRESS = '0x456...';
const CLAIM = 'verified_parent'; // encoded as felt252
const TIMESTAMP = Math.floor(Date.now() / 1000);
const EXPIRY = TIMESTAMP + (365 * 24 * 60 * 60); // 1 year
// Create the message hash
const message = [
BigInt(WALLET_ADDRESS),
BigInt(stark felt252(CLAIM)),
BigInt(TIMESTAMP),
BigInt(EXPIRY)
];
const messageHash = hash.computeHashOnElements(message);
// Sign the message
const signature = ec.starkCurve.sign(messageHash, ISSUER_PRIVATE_KEY);
// The attestation object to give to the user
const attestation = {
wallet: WALLET_ADDRESS,
claim: CLAIM,
timestamp: TIMESTAMP,
expiry: EXPIRY,
signature: [signature.r.toString(), signature.s.toString()],
issuer: ISSUER_PUBLIC_KEY // or the issuer's contract address
};
console.log('Attestation created:', attestation);
4️⃣ User Wallet Contract (Stores and Presents Attestation)
#[starknet::contract]
mod UserWallet {
use starknet::ContractAddress;
use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess};
#[storage]
struct Storage {
// Stored attestations: claim_hash => is_valid
attestations: Map<felt252, bool>,
}
#[external(v0)]
fn store_attestation(
ref self: ContractState,
claim: felt252,
timestamp: u64,
expiry: u64,
signature: Array<felt252>,
issuer: ContractAddress,
) {
// In a real implementation, the wallet would verify the signature
// before storing it. For simplicity, we just store it.
let claim_hash = core::hash::compute_hash_on_elements([
claim,
timestamp.into(),
expiry.into()
].span());
self.attestations.write(claim_hash, true);
}
#[external(v0)]
fn get_attestation(
self: @ContractState,
claim: felt252,
timestamp: u64,
expiry: u64,
) -> felt252 {
let claim_hash = core::hash::compute_hash_on_elements([
claim,
timestamp.into(),
expiry.into()
].span());
assert(self.attestations.read(claim_hash), 'Attestation not found');
claim_hash
}
}
🔄 Migration Flow: Testnet → Mainnet
Step 1: On Testnet
- Deploy
IssuerRegistryandAttestationVerifieron Madara Testnet - Add trusted issuers (SMC admins) to the registry
- Issuer signs attestations off-chain
- Users submit attestations to
AttestationVerifier.verify_attestation() - Users are now verified on testnet
Step 2: Deploy to Mainnet
- Deploy the same contracts to Madara Mainnet
- Add the same trusted issuers to the mainnet
IssuerRegistry - Users do nothing - their attestations are still valid!
Step 3: Users Migrate
- Users submit the exact same attestation (wallet, claim, timestamp, expiry, signature) to the mainnet
AttestationVerifier - The verifier checks the signature (same issuer public key)
- The verifier checks the issuer is trusted (same registry)
- User is instantly verified on mainnet - no re-verification needed!
🎯 Key Benefits
✅ Chain-agnostic: Attestations work on any Starknet-based chain
✅ No re-verification: Users verify once, use everywhere
✅ Portable: Users can migrate their identity across chains
✅ Revocable: Issuer can be removed from registry to invalidate all their attestations
✅ Expirable: Attestations have built-in expiry timestamps
🛠️ Production Considerations
- Signature Verification: Use Starknet’s built-in
isValidSignaturefor proper ECDSA verification - Revocation List: Add a mapping to revoke individual attestations
- Multi-Issuer Support: Allow multiple issuers for different claim types
- Metadata: Store additional metadata (e.g., school ID, parent name hash)
- Gas Optimization: Batch multiple attestation verifications in one transaction
This pattern gives you a robust, portable identity layer that works seamlessly across your testnet pilot and mainnet deployment.