Stage 4 prep · testnet milestone

Threshold MPC —
live numbers & how to verify

Sunima's signing layer uses FROST 2-of-3 (RFC 9591) — a threshold signature scheme where no single node holds enough material to sign alone. Every number on this page was measured on real hardware against real on-chain transactions. Every claim has a falsification test you can run yourself.

This is a testnet milestone, not a production deployment. The MPC layer is implemented and the cryptography is real. The testnet validator cohort is still being grown. Meaningful signing-share decentralisation (independent share custodians, separate jurisdictions, no shared administrative access) is the goal of the Stage 4 testnet validator programme. We publish the approach now so it can be scrutinised early, not to claim production readiness.
Vault withdraw · Arb Sepolia · status 1 · gas 134,421  ↗

_verifySchnorr accepted a FROST-aggregated 64-byte (e ‖ s) wire on-chain. The tx is publicly indexed. No special access required to inspect it.

Stage 1 of 2 — cryptographic layer complete. This testnet run proves the cryptography works end-to-end: threshold signing is real, the on-chain verifier accepted it, auto-failover recovers in under 200 ms. Stage 2 is operational decentralisation — distributing signing shards to independent operators in separate jurisdictions with no shared administrative access. The crypto is done. Operator decentralisation is the next milestone.

Measured numbers

156 ms
Sign latency — p50
n=100 sequential bench, server round-trip
194 ms
Sign latency — p99
same bench, worst of 100 calls
5.13
Signatures / sec (sequential)
single-threaded coordinator, no batching
100/100
Unique wires, 0 collisions
uniqueness checked via set over all 100 runs
< 200 ms
Failover time (kill one node)
next /sign call after SIGTERM on node, threshold still met
< 4 s
Coordinator restart
SIGKILL coordinator → supervised auto-restart → first sign success
128–143 ms
Cross-domain round-trip
coordinator total_ms across 3 trial signs to a remote participant over the network
134,421
Gas · vault withdraw (on-chain)
includes _verifySchnorr + TFHE range-proof consume
PropertyValueHow to verify
SchemeFROST 2-of-3 · RFC 9591open-source Rust crate — frost-core (ZcashFoundation)
CiphersuiteSunima-Schnorr-EVM-keccak256-v1see ciphersuite snippet below — const ID
Curvesecp256k1 (EVM-native)frost-core upstream + vault SECP256K1_Q constant
Challenge hashKeccak256 (same as EVM)see challenge override snippet — matches vault _verifySchnorr
Group pubkey0x2bd974e1…f8c54vault.organizerPx() on-chain: cast call 0x3a112C36… 'organizerPx()(uint256)'
Trust separationseparate OS processes (independent-domain custody is the Stage 4 goal)authenticated TCP transport · no inbound exposure on participating endpoints
Deposit tx0x4aba0c79…755ea7account opened, 0.001 ETH locked
Withdraw tx0x144fc744…22306a2dstatus = 1, vault accepted FROST signature

Relevant source

Custom ciphersuite — EVM-compatible challenge

Standard FROST uses SHA-512 for the challenge hash. Sunima overrides exactly one function — challenge() — to produce a Keccak256 hash that the on-chain Solidity verifier can reproduce without precompiles. All other FROST hashing delegates to the audited upstream.

Rust frost-keygen/src/sunima_schnorr_evm.rs
impl Ciphersuite for SunimaSchnorrEvm {
    const ID: &'static str = "Sunima-Schnorr-EVM-keccak256-v1";
    type Group = Secp256K1Group;

    // All non-challenge hashes delegate to the audited upstream ciphersuite
    fn H1(m: &[u8]) -> Scalar { Secp256K1Sha256::H1(m) }
    // … H2 H3 H4 H5 HDKG HID delegate identically …

    /// Only this function is overridden: produce the EVM-compatible challenge.
    /// e = keccak256(R_ethereum_addr ‖ parity ‖ px ‖ message)
    fn challenge(r: &Element<Self>, vk: &VerifyingKey<Self>, msg: &[u8])
        -> Result<Challenge<Self>, Error<Self>>
    {
        let scalar = compute_evm_challenge_scalar(r, &vk.to_element(), msg);
        Ok(Challenge::from_scalar(scalar))
    }
}

Auto-failover coordinator — first-t-wins logic

The coordinator dispatches round-1 to all nodes simultaneously and commits to the first threshold that respond. Slow or offline nodes are cancelled before round-2 begins — the surviving cohort is the one that actually produces the signature share.

Rust frost-keygen/src/coord.rs
// Dispatch round-1 to every node concurrently
let mut r1_set: JoinSet<(String, Result<Round1Response>)> = JoinSet::new();
for url in &node_urls {
    r1_set.spawn(async move { (base, call_round1(endpoint, req).await) });
}

// Collect the first `want` (= threshold) successful responses
let mut active_urls: Vec<String> = Vec::new();
while commitments.len() < want {
    let (url, body) = r1_set.join_next().await??;
    match body {
        Ok(resp) => { commitments.insert(id, commitment); active_urls.push(url); }
        Err(e)   => { warn!("round1 node failed (failover): {e}"); }
    }
}
r1_set.shutdown().await; // cancel surviving slow nodes

// Round-2 goes ONLY to the nodes that completed round-1
for url in &active_urls { call_round2(url, &signing_package).await?; }

On-chain Schnorr verifier — the complete function

Every withdraw and transfer calls _verifySchnorr with the FROST-aggregated wire. No privileged bypass, no owner shortcut, no upgradability. The logic is roughly 25 lines. Source is verified on Sourcify.

Solidity contracts/sunima-v3/SunimaVaultElGamal.sol · _verifySchnorr
function _verifySchnorr(
    bytes32 message,
    bytes calldata sig,       // 64 bytes: e (32) ‖ s (32)
    uint256 pubKeyPx,
    uint8   pubKeyParity
) private pure returns (bool) {
    if (sig.length != 64) return false;
    uint256 e_; uint256 s_;
    assembly {
        e_ := calldataload(sig.offset)
        s_ := calldataload(add(sig.offset, 32))
    }
    if (e_ == 0 || e_ >= SECP256K1_Q) return false;
    if (s_ == 0 || s_ >= SECP256K1_Q) return false;

    // ecrecover trick: recover the nonce-commitment address R
    uint256 sp = SECP256K1_Q - mulmod(s_, pubKeyPx, SECP256K1_Q);
    uint256 ep = SECP256K1_Q - mulmod(e_, pubKeyPx, SECP256K1_Q);
    address recovered = ecrecover(bytes32(sp), pubKeyParity, bytes32(pubKeyPx), bytes32(ep));
    if (recovered == address(0)) return false;

    // Recompute challenge and compare — same formula as the off-chain signer
    bytes32 challenge = keccak256(abi.encodePacked(recovered, pubKeyParity, pubKeyPx, message));
    return bytes32(e_) == challenge;
}

Signing session — live coordinator log

Below is the actual coordinator log from the E2E withdraw run that produced tx 0x144fc744…. The session UUID, digest, round timings, and the list of participating nodes are all present. No staging, no mock — the log was captured from a running testnet coordinator service.

log sunima-frost-coord · round that produced the on-chain withdraw
INFO frost_keygen::coord: coord starting FROST session
     session=bc5fec3c-9ad3-4a41-982e-bd3076153acc
     digest_hex=dd406edddec0a1326cab270438c2fa398be0a9f0552836d841cea3e51939aa64
     nodes=["node-1","node-2","node-3"]
     want_threshold=2

INFO frost_keygen::coord: round 1 done
     round1_ms=1  n_commitments=2  n_failed=0
     active_urls=["node-2","node-1"]

INFO frost_keygen::coord: round 2 done
     round2_ms=0  n_shares=2

# Aggregate + native FROST verify passed.
# Wire (e ‖ s) handed to signer service, which submitted withdraw tx.
# On-chain vault._verifySchnorr: status 1 · gas 134,421
What the log shows: two participants responded to round-1 within 1 ms. The remaining participant did not reach the coordinator before the threshold was met — JoinSet::shutdown() cancelled its pending request. This is the intended behaviour: fastest t participants win, the signature proceeds without waiting. Participation of every cohort member was confirmed in separate failover tests where one participant was killed and a different subset produced the signature.

10-point falsification checklist

Each item below is a concrete test any technically inclined reader can perform. If any item fails, our MPC claim is false. We encourage you to run them.

Stage 4 preparation — honest scope. This is a testnet milestone. The signing layer uses a FROST 2-of-3 group; the three FROST signing shares themselves are still held within a single administrative domain. In this configuration the holder can, in principle, sign without a quorum ceremony. Production decentralisation — independent share custodians in separate jurisdictions with no shared administrative access — is scheduled for the Stage 4 testnet validator programme. The MPC layer and its on-chain verifier are working in this testnet proof-of-concept; the trust model becomes meaningful once share custody is distributed.

Reproducibility

The coordinator and node binaries are a standard Rust workspace (≥ 1.80). Anyone can build them from source, generate a fresh 2-of-3 key set with frost-keygen generate, stand up three node processes, and reproduce all latency numbers against the same on-chain verifier. The Solidity vault is source-verified and can be redeployed on any Arbitrum Sepolia address with any FROST group key — the threshold logic is generic over the group pubkey baked in at construction.

This page is updated as milestones ship. Previous values are replaced when re-measured, not silently rewritten; changes are noted in the protocol changelog.