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
| Property | Value | How to verify |
| Scheme | FROST 2-of-3 · RFC 9591 | open-source Rust crate — frost-core (ZcashFoundation) |
| Ciphersuite | Sunima-Schnorr-EVM-keccak256-v1 | see ciphersuite snippet below — const ID |
| Curve | secp256k1 (EVM-native) | frost-core upstream + vault SECP256K1_Q constant |
| Challenge hash | Keccak256 (same as EVM) | see challenge override snippet — matches vault _verifySchnorr |
| Group pubkey | 0x2bd974e1…f8c54 | vault.organizerPx() on-chain: cast call 0x3a112C36… 'organizerPx()(uint256)' |
| Trust separation | separate OS processes (independent-domain custody is the Stage 4 goal) | authenticated TCP transport · no inbound exposure on participating endpoints |
| Deposit tx | 0x4aba0c79…755ea7 | account opened, 0.001 ETH locked |
| Withdraw tx | 0x144fc744…22306a2d | status = 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.
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.
// 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.
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.
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.
-
1
No master secret key exists anywhere in the repository.
Run grep -ri 'master_sk\|consolidated_key\|full_secret' frost-keygen/ — expect zero hits.
Each share file holds one point on a degree-(t−1) polynomial.
With fewer than t shares, the linear system is underdetermined: infinitely many valid secrets exist.
-
2
The library is audited, not home-grown.
Cargo.toml depends on frost-core by ZcashFoundation — the reference Rust implementation of RFC 9591,
independently audited. The ciphersuite module overrides exactly one function (challenge());
everything else is unmodified upstream code.
-
3
Independent participants — separate OS processes (independent-domain custody is the Stage 4 goal).
During a live signing session: ss -tlnp on each participating machine shows the local
FROST endpoint as its own process. Not one process simulating multiple participants —
the signing share output of any participant is reproducible only against that participant's share file.
-
4
Real network traffic carries real cryptographic payloads.
tcpdump -i any -nn 'port 8901 or port 8902' during signing captures
HTTP POST /round1 → JSON commitment → POST /round2 → JSON signature share.
Inspection confirms non-trivial elliptic-curve point serialisations — not "OK" responses.
-
5
Group pubkey is polynomial(0)·G, not a naïve sum of share-pubkeys.
Additive multi-sig computes sum(g · share_i). FROST's DKG produces
verifying_key = polynomial(0) · G.
Anyone with frost-core can verify: the pubkey_package.json
output's verifying_key field matches the VSS_commitments[0] value.
-
6
Kill a node live — the signature still succeeds.
kill <node-pid> during a signing session.
The coordinator log shows round1 node failed (failover) and lists the surviving
active_urls. The resulting wire passes on-chain verification.
Not pre-recorded, not scripted.
-
7
Any t-subset produces a valid signature.
Sign with cohorts {1,2}, then {1,3}, then {2,3} — all three produce distinct 64-byte wires.
Submit each to vault._verifySchnorr: all three return true.
Threshold is real, not tied to a hardcoded node pair.
-
8
The on-chain verifier is open-source with no privileged bypass.
_verifySchnorr is ~25 lines of Solidity (see snippet above).
No if msg.sender == owner skip. No upgradability proxy.
Source is verified on Sourcify and indexed on Arbiscan.
-
9
On-chain pubkey bit-matches the keygen ceremony output.
vault.organizerPx() returns 0x2bd974e1…f8c54.
The same value appears as verifying_key in pubkey_package.json.
Derived deterministically from the DKG; no manual substitution possible.
-
10
No "reconstruct secret from shares" code path in the signer.
grep -rE 'reconstruct_secret|combine_shares|aggregate_to_sk' frost-keygen/src/ — zero hits on the signing path.
FROST aggregates signature shares, not key material.
The group secret is never assembled in one place during signing.
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.