Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Off-chain: A trusted issuer (SMC admin) signs an attestation with their private key
  2. On-chain: User submits the attestation to a verifier contract
  3. 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

  1. Deploy IssuerRegistry and AttestationVerifier on Madara Testnet
  2. Add trusted issuers (SMC admins) to the registry
  3. Issuer signs attestations off-chain
  4. Users submit attestations to AttestationVerifier.verify_attestation()
  5. Users are now verified on testnet

Step 2: Deploy to Mainnet

  1. Deploy the same contracts to Madara Mainnet
  2. Add the same trusted issuers to the mainnet IssuerRegistry
  3. Users do nothing - their attestations are still valid!

Step 3: Users Migrate

  1. Users submit the exact same attestation (wallet, claim, timestamp, expiry, signature) to the mainnet AttestationVerifier
  2. The verifier checks the signature (same issuer public key)
  3. The verifier checks the issuer is trusted (same registry)
  4. 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

  1. Signature Verification: Use Starknet’s built-in isValidSignature for proper ECDSA verification
  2. Revocation List: Add a mapping to revoke individual attestations
  3. Multi-Issuer Support: Allow multiple issuers for different claim types
  4. Metadata: Store additional metadata (e.g., school ID, parent name hash)
  5. 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.