How Chain Signatures work
Inside NEAR Chain Signatures: the v1.signer MPC contract, key derivation per account and path, secp256k1 and ed25519 domains, yield/resume, deposit and gas.
Advanced7 min read3-question check
Chain Signatures let any NEAR account, including a smart contract, get signatures from a key that no single machine holds. A network of MPC nodes holds shares of one root key per signature scheme. Every NEAR account gets its own child keys, derived from the root key, its account ID and a free-form path string. Only that account can ask for signatures with them.
You never talk to the nodes. You call one contract and wait for the answer. Official docs: What are Chain Signatures?; contract and node source: near/mpc.
The moving parts#
| Part | Details (checked October 2026) |
|---|---|
| Signer contract | Mainnet v1.signer, testnet v1.signer-prod.testnet. Receives sign requests, verifies the nodes’ answers, returns the signature to the caller. |
| MPC nodes | Watch the contract, run the threshold signing protocol and call respond. The live state view on both networks lists 17 participants with a threshold of 11 (some docs pages still say 8 nodes; the contract is the source of truth). Nodes also submit TEE attestations that the contract checks. |
| Root keys | One per *domain*. Read them with the public_key view (domain_id argument, default 0). |
| Derivation | A child key per (calling account, path), computed from the root public key, so anyone can derive the address offline. |
Domains: picking a signature scheme#
A request names a domain_id, not just a curve. The registry on mainnet and testnet (from the contract’s state view, October 2026):
domain_id | Protocol | Purpose | Use it for |
|---|---|---|---|
0 | CaitSith (ECDSA secp256k1) | Sign | Bitcoin, Ethereum and all EVM chains, Cosmos, XRP, Dogecoin… |
1 | FROST (EdDSA ed25519) | Sign | Solana, Aptos, Sui, TON, Stellar, NEAR |
2 | Confidential Key Derivation (BLS12-381) | CKD | Deterministic app secrets via request_app_private_key, not signing |
3 | CaitSith (secp256k1) | ForeignTx | verify_foreign_transaction: nodes check a foreign-chain transaction before signing. Newer feature; read the design doc before relying on it |
Derivation: one root key, a child per account and path#
The contract computes a 32-byte tweak from the caller and the path, then adds tweak · G to the root public key. The caller is env::predecessor_account_id() of the sign call, so a path is only meaningful together with the account that calls: alice.near + ethereum-1 and bob.near + ethereum-1 are unrelated keys, and nobody but alice.near can sign for the first one.
MoreThe exact derivation formula
tweak = sha3_256("near-mpc-recovery v0.1.0 epsilon derivation:" + predecessor_id + "," + path)- secp256k1:
child_pk = root_pk + tweak · G(tweak read as a big-endian scalar) - ed25519:
child_pk = root_pk + (tweak mod L) · B(tweak read little-endian)
This is additive key derivation: the nodes sign with root_sk + tweak, which needs no new key generation per account. Source: derive_tweak in crates/near-mpc-crypto-types/src/kdf.rs of near/mpc, mirrored by deriveChildPublicKey in chainsig.js.
The sign request#
{
"request": {
"path": "ethereum-1",
"payload_v2": { "Ecdsa": "<64 hex chars: the 32-byte hash to sign>" },
"domain_id": 0
}
}
// ed25519: the payload is the message itself (up to 1232 bytes), not a hash
{
"request": {
"path": "solana-1",
"payload_v2": { "Eddsa": "<hex of the serialized Solana message>" },
"domain_id": 1
}
}| Rule | Detail |
|---|---|
| Deposit | At least 1 yoctoNEAR (SIGN_DEPOSIT_YOCTONEAR = 1); any excess is refunded to the caller. |
| Gas | Prepaid gas on the sign receipt must be at least sign_call_gas_attachment_requirement_tera_gas from the config view: 15 Tgas in October 2026. Attaching more is fine; unused gas is refunded. |
| ECDSA payload | Exactly 32 bytes and a valid secp256k1 scalar. The contract does not hash it: you pass the chain-specific hash (keccak256 of the unsigned EVM tx, the BIP-143 sighash for Bitcoin…). |
| Legacy format | Old clients send payload (raw 32-byte array) and key_version. Both are still accepted as aliases of payload_v2 and domain_id, but never in the same request. |
| Timeout | The request waits at most the protocol yield timeout, 200 blocks. Then the call fails with Request has timed out. |
Yield and resume#
sign does not return immediately. It validates the request, stores it as pending and yields: it creates a promise that will be resumed with data later (NEAR’s yield/resume host functions). The nodes see the pending request, sign, and call respond. respond checks the signature against the derived public key and resumes the yielded promise, whose callback returns the signature as the result of your original sign call.
From the caller’s point of view it is just a slow cross-contract call: a few seconds to a few blocks. A user transaction gets its result in the final outcome; a contract gets it in its callback (next lessons).
{
"scheme": "Secp256k1",
"big_r": { "affine_point": "02a1…(33-byte compressed point, hex)" },
"s": { "scalar": "4f1c…(32 bytes, hex)" },
"recovery_id": 0
}
{
"scheme": "Ed25519",
"signature": [12, 201, 7, "… 64 bytes as a JSON array of numbers"]
}For ECDSA, r is the x coordinate of big_r (drop the first byte of the compressed point), s is s.scalar, and the recovery bit is recovery_id (EVM yParity; legacy v = recovery_id + 27). chainsig.js does this conversion for you.
Check yourself
3 questions · progress saved in this browser