Status (2026-03-03): Archived for dapp launch backlog tracking. The migration claim contracts, scripts, and merkle artifacts now live in
tnt-core/packages/migration-claim. This file is retained for historical context only and is not a current action tracker for this repository. Backlog triage source of truth:LAUNCH_BACKLOG_CLOSEOUT.md.
A ZK-based claim system allowing users to migrate their Substrate chain balances to EVM by proving SR25519 key ownership. Users submit a ZK proof to claim ERC20 TNT tokens at their EVM address.
| Feature | SP1 | RiscZero |
|---|---|---|
| Base Sepolia Verifier | ✅ 0x397A5f7f3dBd538f23DE225B51f532c34448dA9B (Groth16) |
✅ 0x0b144e07a0826182b6b59788c34b32bfa86fb711 |
| Language Support | Rust | Rust |
| Native SR25519 | ❌ (custom circuit needed) | ❌ (custom circuit needed) |
| Proving Speed | Faster (optimized for blockchain) | Good |
| Developer Tools | Excellent (prover network) | Good |
| Open Source | MIT/Apache 2.0 | Apache 2.0 |
Both frameworks require custom SR25519 verification code. SP1 is recommended because:
- Prover Network: Succinct provides a hosted prover network, reducing infrastructure burden
- Performance: Optimized specifically for blockchain verification tasks
- Active Development: More frequent updates and better documentation
- Same Verifier Address: Groth16 verifier uses the same address across chains (easier deployment)
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ - Connect EVM wallet │
│ - Input Substrate address/public key │
│ - Sign challenge message with SR25519 key │
│ - Generate ZK proof (via prover network or locally) │
│ - Submit claim transaction │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ MigrationClaim.sol │
│ - Stores Merkle root of eligible balances │
│ - Verifies ZK proof of SR25519 key ownership │
│ - Verifies Merkle proof of balance eligibility │
│ - Mints/transfers TNT tokens to claimant │
│ - Tracks claimed addresses (prevent double-claim) │
│ - 1-year expiry → Treasury recovery │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ SP1 Verifier Gateway │
│ Address: 0x397A5f7f3dBd538f23DE225B51f532c34448dA9B │
│ - Verifies Groth16 proofs from SP1 guest programs │
└─────────────────────────────────────────────────────────────────┘
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract TNT is ERC20, Ownable {
constructor(address initialOwner)
ERC20("Tangle Network Token", "TNT")
Ownable(initialOwner)
{}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ISP1Verifier} from "@sp1-contracts/ISP1Verifier.sol";
contract MigrationClaim {
// SP1 Verifier Gateway on Base Sepolia
ISP1Verifier public constant VERIFIER = ISP1Verifier(0x397A5f7f3dBd538f23DE225B51f532c34448dA9B);
// Verification key for SR25519 proof program (set after deployment)
bytes32 public immutable SR25519_VKEY;
// Merkle root of eligible (substratePublicKey => balance) pairs
bytes32 public immutable merkleRoot;
// TNT token contract
IERC20 public immutable tntToken;
// Treasury address for unclaimed funds
address public immutable treasury;
// Claim deadline (1 year from deployment)
uint256 public immutable claimDeadline;
// Substrate public key (32 bytes) => claimed status
mapping(bytes32 => bool) public claimed;
// Total allocated for claims
uint256 public totalAllocated;
uint256 public totalClaimed;
event Claimed(
bytes32 indexed substratePublicKey,
address indexed evmAddress,
uint256 amount
);
event UnclaimedRecovered(uint256 amount);
constructor(
bytes32 _sr25519Vkey,
bytes32 _merkleRoot,
address _tntToken,
address _treasury,
uint256 _totalAllocated
) {
SR25519_VKEY = _sr25519Vkey;
merkleRoot = _merkleRoot;
tntToken = IERC20(_tntToken);
treasury = _treasury;
claimDeadline = block.timestamp + 365 days;
totalAllocated = _totalAllocated;
}
/**
* @notice Claim TNT tokens by proving SR25519 key ownership
* @param substratePublicKey The 32-byte SR25519 public key
* @param amount The claimable amount from the snapshot
* @param merkleProof Proof that (publicKey, amount) is in the Merkle tree
* @param sp1Proof The SP1 proof of SR25519 signature verification
* @param publicValues The public values from the SP1 proof
*/
function claim(
bytes32 substratePublicKey,
uint256 amount,
bytes32[] calldata merkleProof,
bytes calldata sp1Proof,
bytes calldata publicValues
) external {
require(block.timestamp < claimDeadline, "Claim period ended");
require(!claimed[substratePublicKey], "Already claimed");
// Verify Merkle proof for balance eligibility
bytes32 leaf = keccak256(abi.encodePacked(substratePublicKey, amount));
require(_verifyMerkleProof(merkleProof, merkleRoot, leaf), "Invalid Merkle proof");
// Decode and validate public values from ZK proof
(
bytes32 provenPublicKey,
address provenEvmAddress,
bytes32 provenChallenge
) = abi.decode(publicValues, (bytes32, address, bytes32));
// Ensure proof is for the correct public key and recipient
require(provenPublicKey == substratePublicKey, "Public key mismatch");
require(provenEvmAddress == msg.sender, "EVM address mismatch");
// Verify challenge includes commitment to this contract and chain
bytes32 expectedChallenge = keccak256(abi.encodePacked(
address(this),
block.chainid,
msg.sender
));
require(provenChallenge == expectedChallenge, "Invalid challenge");
// Verify ZK proof of SR25519 signature
VERIFIER.verifyProof(SR25519_VKEY, publicValues, sp1Proof);
// Mark as claimed and transfer tokens
claimed[substratePublicKey] = true;
totalClaimed += amount;
require(tntToken.transfer(msg.sender, amount), "Transfer failed");
emit Claimed(substratePublicKey, msg.sender, amount);
}
/**
* @notice Recover unclaimed tokens to treasury after 1 year
*/
function recoverUnclaimed() external {
require(block.timestamp >= claimDeadline, "Claim period not ended");
uint256 unclaimed = totalAllocated - totalClaimed;
require(unclaimed > 0, "Nothing to recover");
totalAllocated = totalClaimed; // Prevent re-recovery
require(tntToken.transfer(treasury, unclaimed), "Transfer failed");
emit UnclaimedRecovered(unclaimed);
}
function _verifyMerkleProof(
bytes32[] calldata proof,
bytes32 root,
bytes32 leaf
) internal pure returns (bool) {
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
bytes32 proofElement = proof[i];
if (computedHash <= proofElement) {
computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
} else {
computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
}
}
return computedHash == root;
}
}The SP1 guest program verifies SR25519 signatures using the schnorrkel library.
tnt-core/packages/migration-claim/sp1/
├── Cargo.toml
├── program/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # SP1 guest program
├── script/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # Host program for proof generation
└── lib/
└── src/
└── lib.rs # Shared types
#![no_main]
sp1_zkvm::entrypoint!(main);
use schnorrkel::{PublicKey, Signature, signing_context};
/// Public values that will be exposed on-chain
#[derive(Debug)]
pub struct PublicValues {
/// The SR25519 public key being proven
pub substrate_public_key: [u8; 32],
/// The EVM address claiming the tokens
pub evm_address: [u8; 20],
/// The challenge that was signed
pub challenge: [u8; 32],
}
pub fn main() {
// Read inputs from the host
let public_key_bytes: [u8; 32] = sp1_zkvm::io::read();
let signature_bytes: [u8; 64] = sp1_zkvm::io::read();
let evm_address: [u8; 20] = sp1_zkvm::io::read();
let challenge: [u8; 32] = sp1_zkvm::io::read();
// Parse the SR25519 public key
let public_key = PublicKey::from_bytes(&public_key_bytes)
.expect("Invalid public key");
// Parse the signature
let signature = Signature::from_bytes(&signature_bytes)
.expect("Invalid signature");
// Create signing context (Substrate uses "substrate" context)
let ctx = signing_context(b"substrate");
// Verify the signature over the challenge
public_key
.verify(ctx.bytes(&challenge), &signature)
.expect("Signature verification failed");
// Commit public values (these are exposed on-chain)
let public_values = PublicValues {
substrate_public_key: public_key_bytes,
evm_address,
challenge,
};
sp1_zkvm::io::commit(&public_values.substrate_public_key);
sp1_zkvm::io::commit(&public_values.evm_address);
sp1_zkvm::io::commit(&public_values.challenge);
}use sp1_sdk::{ProverClient, SP1Stdin};
const ELF: &[u8] = include_bytes!("../../program/elf/riscv32im-succinct-zkvm-elf");
fn main() {
// Initialize the prover client
let client = ProverClient::new();
// Prepare inputs
let mut stdin = SP1Stdin::new();
// These would come from the frontend
let public_key: [u8; 32] = /* substrate public key */;
let signature: [u8; 64] = /* SR25519 signature */;
let evm_address: [u8; 20] = /* claimer's EVM address */;
let challenge: [u8; 32] = /* challenge hash */;
stdin.write(&public_key);
stdin.write(&signature);
stdin.write(&evm_address);
stdin.write(&challenge);
// Generate the proof
let (pk, vk) = client.setup(ELF);
let proof = client.prove(&pk, stdin).groth16().run().unwrap();
// The proof and public values can be submitted on-chain
println!("Proof generated successfully!");
println!("Verification Key: {:?}", vk.bytes32());
println!("Public Values: {:?}", proof.public_values);
println!("Proof: {:?}", proof.bytes());
}interface SnapshotEntry {
substrateAddress: string; // SS58 address
publicKey: string; // 32 bytes hex
balance: bigint; // Balance in smallest unit
}
// Leaf format: keccak256(abi.encodePacked(publicKey, balance))import { StandardMerkleTree } from "@openzeppelin/merkle-tree";
import { keccak256, encodePacked } from "viem";
interface ClaimEntry {
publicKey: `0x${string}`;
balance: bigint;
}
function generateMerkleTree(entries: ClaimEntry[]) {
// Format: [publicKey, balance]
const values = entries.map(e => [e.publicKey, e.balance.toString()]);
const tree = StandardMerkleTree.of(values, ["bytes32", "uint256"]);
return {
root: tree.root,
tree,
getProof: (publicKey: `0x${string}`, balance: bigint) => {
for (const [i, v] of tree.entries()) {
if (v[0] === publicKey && v[1] === balance.toString()) {
return tree.getProof(i);
}
}
throw new Error("Entry not found");
}
};
}apps/tangle-dapp/src/pages/claim/migration/
├── index.tsx # Main claim page
├── components/
│ ├── SubstrateKeyInput.tsx
│ ├── ClaimStatus.tsx
│ ├── ProofGenerator.tsx
│ └── ClaimButton.tsx
└── hooks/
├── useClaimEligibility.ts
├── useGenerateProof.ts
└── useSubmitClaim.ts
- Connect EVM Wallet - User connects via RainbowKit
- Enter Substrate Address - User inputs their Substrate address or public key
- Check Eligibility - Frontend queries Merkle tree data for balance
- Sign Challenge - User signs a challenge message with their SR25519 key using polkadot.js extension
- Generate Proof - Call SP1 prover (network or local) with signature
- Submit Claim - Send transaction to MigrationClaim contract
const challenge = keccak256(encodePacked(
["address", "uint256", "address"],
[migrationClaimAddress, chainId, userEvmAddress]
));
// User signs this challenge with their SR25519 key
const signature = await polkadotExtension.sign(challenge);- Create TNT ERC20 token contract
- Create MigrationClaim contract with Merkle verification
- Integrate SP1 verifier interface
- Write deployment scripts
- Deploy to Base Sepolia testnet
- Set up SP1 project structure
- Implement SR25519 verification in guest program
- Test with sample signatures
- Generate verification key
- Deploy verifier configuration
- Create snapshot parsing script
- Generate Merkle tree from snapshot
- Create proof generation service/API
- Set up prover infrastructure (or use Succinct Network)
- Create migration claim page
- Integrate polkadot.js extension for SR25519 signing
- Implement eligibility checking
- Implement proof generation flow
- Implement claim submission
- Add claim status tracking
- End-to-end testing on testnet
- Security audit considerations
- Deploy to Base mainnet
- Monitor and support
apps/tangle-dapp/src/pages/claim/migration/
├── index.tsx
├── components/SubstrateKeyInput.tsx
├── components/ClaimStatus.tsx
├── components/ProofGenerator.tsx
├── components/ClaimButton.tsx
├── hooks/useClaimEligibility.ts
├── hooks/useGenerateProof.ts
└── hooks/useSubmitClaim.ts
libs/tangle-shared-ui/src/data/migration/
├── useMigrationClaim.ts
└── merkleTree.ts
tnt-core/packages/migration-claim/
├── src/
│ ├── TNT.sol
│ └── MigrationClaim.sol
├── script/
│ └── Deploy.s.sol
└── test/
└── MigrationClaim.t.sol
tnt-core/packages/migration-claim/sp1/
├── program/src/main.rs
├── script/src/main.rs
└── lib/src/lib.rs
apps/tangle-dapp/src/types/index.ts # Add PagePath.CLAIM_MIGRATION
apps/tangle-dapp/src/app/app.tsx # Add route
libs/dapp-config/src/contracts.ts # Add migration contract addresses
- Double-claim Prevention: Track claimed Substrate public keys, not EVM addresses
- Replay Protection: Challenge includes contract address and chain ID
- Front-running Protection: Only msg.sender can claim their own proof
- Merkle Tree Integrity: Root is immutable after deployment
- Time-lock: 1-year claim period with treasury recovery
- ZK Security: SP1's Groth16 proofs provide 128-bit security
- OpenZeppelin Contracts v5
- SP1 Contracts (
@sp1-contracts)
- sp1-zkvm
- schnorrkel (Rust)
- @polkadot/extension-dapp (for SR25519 signing)
- @openzeppelin/merkle-tree
- viem/wagmi
| Operation | Estimated Gas |
|---|---|
| Deploy TNT | ~800,000 |
| Deploy MigrationClaim | ~1,200,000 |
| Claim (with proof verification) | ~350,000 - 500,000 |
| Recover Unclaimed | ~50,000 |
- Prover Infrastructure: Use Succinct Network (hosted) or self-hosted prover?
- Snapshot Source: How will the Substrate chain snapshot be generated and verified?
- Token Supply: Pre-mint all claimable tokens or mint on claim?
- Vesting: Should claimed tokens have any vesting schedule?