@paralock/adaptor
Reference for ECDSA adaptor signatures on secp256k1 with DLEQ proofs: pre-sign, pre-verify, adapt, extract, encodings and security notes.
@paralock/adaptor implements ECDSA adaptor signatures on secp256k1 with Chaum–Pedersen DLEQ proofs. A pre-signature on a digest is "encrypted" to a point Y = y·G:
- anyone can check it without learning
y; - whoever knows
ycompletes it into a normal low-S ECDSA signature; - whoever holds the pre-signature and later sees that signature learns
y.
Completing a signature on one chain therefore reveals the secret needed on another, with no hash or other shared value on-chain. Paralock's private (adaptor) mode is built on it. Pure TypeScript on @noble/curves and @noble/hashes; browser-safe.
Install
npm install @paralock/adaptorVersion 0.1.1 (no API change from 0.1.0: hedged nonces and stricter checks).
The math in brief
A pre-signature on digest m under private key x (public key X = x·G), encrypted to the adaptor point Y = y·G:
k random (hedged nonce)
R' = k·G
R = k·Y
r = R.x mod n
s' = k⁻¹(m + r·x) mod n
π = DLEQ proof that log_G(R') = log_Y(R)| Step | Who | Computation |
|---|---|---|
| Pre-verify | anyone | Check π, then check s'⁻¹(. This proves ( becomes a valid signature under X once y is applied, without revealing y. |
| Adapt | holder of y | s = s'·y⁻¹. The effective nonce is k·y, so ( verifies under X. Normalized to low-S. |
| Extract | holder of the pre-signature | y = ±s'·s⁻¹, choosing the sign whose y·G equals Y (the sign is lost by low-S normalization). |
Example
import {
adapt,
adaptorKeyPair,
decodePreSignature,
encodePreSignature,
extract,
preSign,
preVerify,
recoveryId,
verify,
} from "@paralock/adaptor";
// The signer's key pair. Any secp256k1 key works; adaptorKeyPair() also makes one.
const signer = adaptorKeyPair();
// The 32-byte digest to sign, for example an escrow's claimDigest.
const digest = crypto.getRandomValues(new Uint8Array(32));
// Taker: the adaptor secret y and its point Y.
const { secret, point } = adaptorKeyPair();
// Maker: pre-sign, encrypted to Y, and send it (162 bytes, as hex).
const wire = encodePreSignature(preSign(signer.secret, digest, point));
// Taker: parse and ALWAYS verify before relying on it.
const pre = decodePreSignature(wire);
if (!preVerify(signer.point, digest, point, pre)) throw new Error("invalid pre-signature");
// Taker: complete it with y and publish the signature (here, for EVM ecrecover).
const sig = adapt(pre, secret);
const v = 27 + recoveryId(signer.point, digest, sig);
verify(signer.point, digest, sig); // true
// Maker: seeing (r, s) on-chain, recover y.
const recovered = extract(pre, sig, point); // the same 32 bytes as `secret`API
Bytes is Uint8Array | string: bytes, or hex with or without 0x.
| Export | Signature | Meaning |
|---|---|---|
adaptorKeyPair | adaptor | A fresh secret y (32 bytes) and its point Y (33-byte compressed). |
preSign | pre | Pre-signs a 32-byte digest with a DLEQ proof. A fresh hedged nonce on every call. Throws if the key is not in [1,, the digest is not 32 bytes, or the point is invalid, and if 8 nonces in a row are unusable (only a broken random number generator does that) instead of looping. |
preVerify | pre | True if pre becomes a valid signature by public on digest once y is applied. Checks the DLEQ proof and every range. Returns false (never throws) on malformed input. |
adapt | adapt( | Completes the pre-signature with y. Always returns low-S. Throws if y or s' is out of range. |
extract | extract( | Recovers y (32 bytes) from the pre-signature and the published signature. Throws if sig doesn't belong to pre. |
verify | verify( | Standard ECDSA verification of a prehashed digest. Low-S required. |
compact | compact( | 64-byte r ‖ s. |
der | der( | DER encoding, as used in Zcash scriptSigs. Append the sighash type byte yourself (0x01 for SIGHASH_). |
recoveryId | recovery | The recovery id such that ecrecover( returns public. Throws if neither value recovers it. |
encodePreSignature | encode | The 162-byte wire format, as 324 lowercase hex characters without 0x. |
decodePreSignature | decode | Parses the wire format. Throws unless it is exactly 162 bytes. It does not check ranges or points: call pre. |
secretToHex | secret | Hex of an adaptor secret, without 0x. |
Types
| Type | Fields |
|---|---|
PreSignature | R: Uint8 (k·Y, compressed; its x coordinate is the final r), Rp: Uint8 (k·G, compressed), s: bigint (s'), proof: Dleq |
DleqProof | c: bigint (challenge), z: bigint (response) |
Signature | r: bigint, s: bigint |
Wire format
encodePreSignature writes 162 bytes. Scalars are 32-byte big-endian.
| Offset | Bytes | Field |
|---|---|---|
| 0 | 33 | R = k·Y, compressed |
| 33 | 33 | R' = k·G, compressed |
| 66 | 32 | s' |
| 98 | 32 | DLEQ challenge c |
| 130 | 32 | DLEQ response z |
The SDK sends pre-signatures in this format (AdaptorPreSignatureRequest.takerPreSignature, Swap.adaptor.evmPreSignature).
How Paralock uses it
Both pre-signatures in a private-mode swap are encrypted to the taker's point Y. Only the taker knows y. See How it works and Privacy for the full protocol.
- The taker pre-signs the maker's Zcash claim (the ZIP 244 sighash of the 2-of-2 spend) with its multisig key. The maker pre-verifies it.
- The maker locks stablecoins in
ParalockAdaptorEscrowand pre-signs the escrow'sclaimDigest(id)with a one-time signer key. The taker pre-verifies it, along with the lock. - The taker adapts the escrow pre-signature with
yand claims withv = 27 + recoveryId(...). The escrow'sClaimedevent publishes(r, s). - The maker extracts
yfrom its pre-signature and that(r, s), adapts the taker's Zcash pre-signature, and claims the ZEC withder(sig)plus theSIGHASH_ALLbyte.
No hash, preimage or other shared value appears on either chain.
Security notes
- Hedged nonces. Each pre-signature nonce is
sha512("paralock/adaptor/nonce/v1" ‖ x ‖ m ‖ Y ‖ 32 random bytes)reduced mod n; the DLEQ nonce uses its own tag and mixes inkand the points. Fresh randomness makes every nonce unique, and mixing in the secret and the message means a broken or repeated RNG output still can't produce the same nonce for two different messages, which would leak the key. Two calls never share a nonce. - DLEQ proof and domain separation. The challenge is
sha256("paralock/adaptor/dleq/v1" ‖ G ‖ Y ‖ R' ‖ R ‖ T1 ‖ T2) mod nover 33-byte compressed points: a fixed-length, protocol-specific encoding. It is part of the wire format; changing it breaks compatibility. - Low-S.
adaptalways returns low-S andverifyrequires it, matching the EVM escrow (which rejects high-S asMalleableSignature) and Zcash standardness rules.extracttries both signs, so normalization never loses the secret. - Range checks. Private keys and adaptor secrets must be in
[1, n).preVerifyrejects out-of-ranges'and proof values and invalid points. - Always call
preVerifyon a pre-signature you receive, before acting on it.decodePreSignatureonly parses.