signer_primitives/lib.rs
1//! Unified signing traits and types for multi-chain transaction signers.
2//!
3//! This crate defines the capability traits that every chain-specific signer
4//! crate implements — [`Sign`] for the mandatory signing surface,
5//! [`SignMessage`] / [`EncodeSignedTransaction`] / [`ExtractSignableBytes`]
6//! for opt-in capabilities — plus the discriminated [`SignOutput`] enum that
7//! covers every wire format the workspace produces.
8//!
9//! # Design principles
10//!
11//! - **Single source of truth** — [`SignError`] is defined once here; chain
12//! crates re-export it directly and only introduce a wrapper enum when they
13//! carry additional failure modes.
14//! - **Capability traits** — [`Sign`] is the mandatory primitive-level
15//! minimum ([`sign_hash`](Sign::sign_hash) over 32 bytes); optional
16//! capabilities live in separate traits so types never carry "not
17//! implemented" lies. Chains without a canonical message-signing standard
18//! (e.g. XRPL, Cosmos) simply do not implement [`SignMessage`]. Chain-
19//! specific `sign_transaction` is an inherent method on each chain's
20//! `Signer`, not part of a trait — transaction bytes semantics differ
21//! irreconcilably across chains, so a trait method would be a false
22//! abstraction.
23//! - **Type-safe digests** — `sign_hash` accepts `&[u8; 32]`; ragged byte
24//! slices are rejected at compile time.
25//! - **Discriminated output** — [`SignOutput`] variants mirror real wire
26//! formats; callers pattern-match instead of juggling `Option` metadata.
27//! - **Fallible randomness** — every primitive exposes a `try_random`
28//! constructor that surfaces [`getrandom`] failures; the panicking
29//! `random()` helper is kept only as an ergonomic wrapper.
30//! - **Thread-safe** — every signer is `Send + Sync` and ready to share
31//! across async tasks.
32//! - **No address derivation** — that responsibility lives in `kobe`.
33//!
34//! # Verification
35//!
36//! Verification lives on each chain's inherent `Signer` (e.g.
37//! [`signer_btc::Signer::verify_hash`](https://docs.rs/signer-btc)).
38//! Because every chain derives its signable digest through a different
39//! transform (EIP-191, Bitcoin message prefix, Sui intent, …), a single
40//! generic `Verify` trait would have to replay chain logic, so the workspace
41//! keeps verification inherent to avoid false abstraction.
42
43#![cfg_attr(not(feature = "std"), no_std)]
44
45extern crate alloc;
46
47use alloc::string::String;
48use alloc::vec::Vec;
49
50#[cfg(feature = "ed25519")]
51mod ed25519;
52mod error;
53#[doc(hidden)]
54pub mod macros;
55#[cfg(feature = "schnorr")]
56mod schnorr;
57#[cfg(feature = "secp256k1")]
58mod secp256k1;
59#[cfg(feature = "testing")]
60pub mod testing;
61
62#[cfg(test)]
63mod tests;
64
65#[cfg(feature = "ed25519")]
66pub use ed25519::Ed25519Signer;
67pub use error::SignError;
68#[cfg(feature = "schnorr")]
69pub use schnorr::SchnorrSigner;
70#[cfg(feature = "secp256k1")]
71pub use secp256k1::Secp256k1Signer;
72
73/// Signature output across every scheme the workspace supports.
74///
75/// Each variant mirrors a concrete wire format; callers pattern-match on the
76/// variant rather than inspect optional metadata.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum SignOutput {
79 /// secp256k1 ECDSA with a single-byte tail (EVM, BTC, Cosmos, Filecoin, Tron, Spark).
80 ///
81 /// Flat bytes: `signature || v` (65 B total). The exact meaning of `v`
82 /// depends on the producing call site and chain; every producer in the
83 /// workspace documents its encoding explicitly:
84 ///
85 /// | Producer | `v` encoding |
86 /// |-----------------------------------------------------------------------|---------------------------|
87 /// | [`Sign::sign_hash`] / each chain's inherent `sign_transaction` | `0` or `1` (raw parity) |
88 /// | `signer_evm::Signer::{sign_message, sign_typed_data}` (EIP-191) | `27` or `28` |
89 /// | `signer_tron::Signer::sign_message` (TRON message prefix) | `27` or `28` |
90 /// | `signer_btc::Signer::sign_message` (BIP-137, compressed P2PKH) | `31` or `32` |
91 /// | `signer_btc::Signer::sign_message_with` (BIP-137, caller-selected) | `27..=42` per BIP-137 |
92 /// | `signer_spark::Signer::sign_message` (same BIP-137 encoding as BTC) | `31` or `32` |
93 ///
94 /// The encodings collide across chains: `27|28` means both EIP-191
95 /// (EVM) **and** TRON's message prefix, and `31|32` means BIP-137
96 /// compressed on both BTC and Spark. Consumers therefore cannot
97 /// identify the producing chain from the `v` byte alone — the
98 /// verifier must already know which chain / scheme the signature
99 /// was produced against.
100 Ecdsa {
101 /// 64-byte compact `r || s`.
102 signature: [u8; 64],
103 /// `v` byte. The raw parity is always in the low bit; producers that
104 /// need the chain's on-wire header (EIP-191, BIP-137, …) add the
105 /// appropriate constant before constructing this variant.
106 v: u8,
107 },
108 /// secp256k1 ECDSA encoded as ASN.1 DER (XRPL).
109 ///
110 /// Variable length (typically 70–72 B).
111 EcdsaDer(Vec<u8>),
112 /// Ed25519 signature (Solana, TON).
113 Ed25519([u8; 64]),
114 /// Ed25519 signature accompanied by the signer's public key (Sui, Aptos).
115 Ed25519WithPubkey {
116 /// 64-byte Ed25519 signature.
117 signature: [u8; 64],
118 /// 32-byte Ed25519 public key.
119 public_key: [u8; 32],
120 },
121 /// BIP-340 Schnorr signature accompanied by the x-only public key (Nostr / Taproot).
122 Schnorr {
123 /// 64-byte BIP-340 Schnorr signature.
124 signature: [u8; 64],
125 /// 32-byte x-only public key.
126 xonly_public_key: [u8; 32],
127 },
128}
129
130impl SignOutput {
131 /// Flat signature bytes in the chain's native wire layout.
132 ///
133 /// - `Ecdsa` → 65 bytes (`r || s || v`).
134 /// - `EcdsaDer` → DER-encoded (variable length).
135 /// - `Ed25519` → 64 bytes.
136 /// - `Ed25519WithPubkey` → 64 bytes (the public key is carried separately).
137 /// - `Schnorr` → 64 bytes (the x-only public key is carried separately).
138 #[must_use]
139 pub fn to_bytes(&self) -> Vec<u8> {
140 match *self {
141 Self::Ecdsa { signature, v } => {
142 let mut out = Vec::with_capacity(65);
143 out.extend_from_slice(&signature);
144 out.push(v);
145 out
146 }
147 Self::EcdsaDer(ref der) => der.clone(),
148 Self::Ed25519(sig) | Self::Ed25519WithPubkey { signature: sig, .. } => sig.to_vec(),
149 Self::Schnorr { signature, .. } => signature.to_vec(),
150 }
151 }
152
153 /// Hex-encode the flat signature bytes returned by [`to_bytes`](Self::to_bytes).
154 #[must_use]
155 pub fn to_hex(&self) -> String {
156 hex::encode(self.to_bytes())
157 }
158
159 /// The public key attached to the signature, if any.
160 ///
161 /// Only `Ed25519WithPubkey` and `Schnorr` carry a public key; other
162 /// variants return `None`.
163 #[must_use]
164 pub const fn public_key(&self) -> Option<&[u8]> {
165 match self {
166 Self::Ed25519WithPubkey { public_key, .. } => Some(public_key.as_slice()),
167 Self::Schnorr {
168 xonly_public_key, ..
169 } => Some(xonly_public_key.as_slice()),
170 _ => None,
171 }
172 }
173
174 /// `v` byte (secp256k1 ECDSA recoverable format only).
175 ///
176 /// See [`SignOutput::Ecdsa`] for the chain-specific meaning of this byte.
177 #[must_use]
178 pub const fn v(&self) -> Option<u8> {
179 match self {
180 Self::Ecdsa { v, .. } => Some(*v),
181 _ => None,
182 }
183 }
184
185 /// Add `offset` to the `v` byte of an [`Ecdsa`](Self::Ecdsa) variant.
186 ///
187 /// Used by chains whose on-wire `v` encoding is a fixed offset over the
188 /// raw parity bit (EIP-191 adds `27`; BIP-137 adds `27`, `31`, `35`, or
189 /// `39` depending on the address type; TRON adds `27`). Non-`Ecdsa`
190 /// variants are returned unchanged.
191 ///
192 /// The offset is applied with wrapping arithmetic; callers choose the
193 /// offset per their chain's encoding table.
194 ///
195 /// # Example
196 ///
197 /// ```
198 /// use signer_primitives::SignOutput;
199 ///
200 /// let raw = SignOutput::Ecdsa { signature: [0u8; 64], v: 1 };
201 /// let eip191 = raw.with_v_offset(27);
202 /// assert_eq!(eip191.v(), Some(28));
203 /// ```
204 #[must_use]
205 pub fn with_v_offset(self, offset: u8) -> Self {
206 match self {
207 Self::Ecdsa { signature, v } => Self::Ecdsa {
208 signature,
209 v: v.wrapping_add(offset),
210 },
211 other => other,
212 }
213 }
214}
215
216/// Primitive-level signing surface implemented by every chain-specific `Signer`.
217///
218/// # Contract (primitive-level, **not** protocol-level)
219///
220/// [`sign_hash`](Self::sign_hash) runs the underlying cryptographic primitive
221/// over 32 bytes. The semantics of those 32 bytes differ by curve:
222///
223/// - **secp256k1 ECDSA / BIP-340 Schnorr**: the 32 bytes are treated as a
224/// pre-computed digest (RFC 6979 / BIP-340 prehash semantics).
225/// - **Ed25519**: the 32 bytes are signed as the **entire message** (`EdDSA` /
226/// RFC 8032 does not accept pre-hashed input). Do not equate this with
227/// signing a pre-computed digest on on-chain verifiers.
228///
229/// # On-chain applicability
230///
231/// [`sign_hash`](Self::sign_hash) output is directly on-chain verifiable
232/// when the 32 bytes is the chain's native sighash — true for EVM, BTC,
233/// Cosmos, Tron, Filecoin, Spark, XRPL, and Nostr event ids.
234///
235/// For **Sui** and **Aptos**, [`sign_hash`](Self::sign_hash) output is
236/// **not on-chain verifiable** without intent / domain framing around the
237/// 32 bytes. Use each
238/// chain's inherent `Signer::sign_transaction` (not part of this trait) for
239/// on-chain-correct output.
240///
241/// # Chain-specific transaction signing
242///
243/// `sign_transaction` is deliberately **not** part of this trait: every chain
244/// interprets its `tx_bytes` argument under a different canonical format
245/// (RLP, sighash preimage, `SignDoc`, BCS, …) and hashes with a different
246/// algorithm, so the trait abstraction would provide no real constraint.
247/// Each chain crate exposes `sign_transaction` as a documented inherent
248/// method on its own `Signer` type.
249///
250/// Off-chain message signing lives on the opt-in [`SignMessage`] trait for
251/// the same reason it is not universal: XRPL and Cosmos have no canonical
252/// single-argument scheme.
253///
254/// # Thread safety
255///
256/// Implementors must be `Send + Sync` so signers can cross async task
257/// boundaries and multi-threaded executors.
258///
259/// # Error contract
260///
261/// `Error` must be a real [`core::error::Error`] and losslessly liftable
262/// from [`SignError`], so downstream code can attribute core failures
263/// without string-matching while still participating in the standard
264/// `?` / `Box<dyn Error>` ecosystem.
265///
266/// # Example
267///
268/// ```
269/// use signer_primitives::{Sign, SignOutput};
270///
271/// fn sign_hash_generic<S: Sign>(signer: &S, hash: &[u8; 32]) -> Result<SignOutput, S::Error> {
272/// signer.sign_hash(hash)
273/// }
274/// ```
275pub trait Sign: Send + Sync {
276 /// Error returned by signing.
277 ///
278 /// Implementations must honour the full [`core::error::Error`] contract
279 /// (which implies [`core::fmt::Debug`] + [`core::fmt::Display`]) and
280 /// losslessly accept [`SignError`] via [`From`].
281 type Error: core::error::Error + From<SignError> + Send + Sync + 'static;
282
283 /// Sign 32 bytes with the underlying cryptographic primitive.
284 ///
285 /// See the [trait-level docs](Sign#contract-primitive-level-not-protocol-level)
286 /// for the per-curve semantics of the 32 bytes and on-chain applicability.
287 ///
288 /// # Errors
289 ///
290 /// Returns an error if the underlying signing primitive fails.
291 fn sign_hash(&self, hash: &[u8; 32]) -> Result<SignOutput, Self::Error>;
292}
293
294/// Opt-in capability: sign an off-chain message with the chain's own
295/// message-signing convention.
296///
297/// Implemented only by chains with a well-defined standard for signing
298/// arbitrary messages. Chains without one (XRPL, Cosmos, Filecoin, TON,
299/// Aptos) deliberately do **not** implement this trait, making the
300/// capability visible in the type system rather than hidden behind a
301/// runtime `Err`. Those chains expose only the inherent signing entry
302/// points (`sign_transaction` and, for Ed25519 chains, `sign_raw`).
303///
304/// | Chain | Transform | `v` on `Ecdsa` |
305/// |------------------|--------------------------------------------------------------------|-------------------|
306/// | EVM | Keccak-256 of `\x19Ethereum Signed Message:\n{len}` + message | `27` or `28` |
307/// | Bitcoin / Spark | double-SHA256 of `\x18Bitcoin Signed Message:\n` + CompactSize+msg | `31` or `32` |
308/// | Tron | Keccak-256 of `\x19TRON Signed Message:\n{len}` + message | `27` or `28` |
309/// | Solana | Raw Ed25519 over the message (matches `nacl.sign.detached`) | — (Ed25519) |
310/// | Sui | BLAKE2b-256 of `PersonalMessage` intent + BCS-encoded message | — (Ed25519) |
311/// | Nostr | Raw BIP-340 Schnorr over the message (no prefix) | — (Schnorr) |
312///
313/// # Example
314///
315/// ```
316/// use signer_primitives::{SignMessage, SignOutput};
317///
318/// fn personal_sign<S: SignMessage>(signer: &S, msg: &[u8]) -> Result<SignOutput, S::Error> {
319/// signer.sign_message(msg)
320/// }
321/// ```
322pub trait SignMessage: Sign {
323 /// Sign an arbitrary message with the chain's message-signing convention.
324 ///
325 /// # Errors
326 ///
327 /// Returns an error if the underlying signing primitive fails.
328 fn sign_message(&self, message: &[u8]) -> Result<SignOutput, Self::Error>;
329}
330
331/// Optional capability: extract the signable portion from a fully serialized
332/// transaction.
333///
334/// Implemented by chains whose wire format interleaves signed payload and
335/// unsigned metadata (e.g. Solana's compact-u16 header plus signature-slot
336/// placeholders). The majority of chains sign the entire input verbatim and
337/// therefore do not implement this trait.
338///
339/// ```
340/// use signer_primitives::{ExtractSignableBytes, Sign};
341///
342/// fn strip<'a, S: Sign + ExtractSignableBytes>(
343/// signer: &S,
344/// tx: &'a [u8],
345/// ) -> Result<&'a [u8], S::Error> {
346/// signer.extract_signable_bytes(tx)
347/// }
348/// ```
349pub trait ExtractSignableBytes: Sign {
350 /// Return the portion of `tx_bytes` that the sighash is computed over.
351 ///
352 /// # Errors
353 ///
354 /// Returns an error if the transaction is malformed.
355 fn extract_signable_bytes<'a>(&self, tx_bytes: &'a [u8]) -> Result<&'a [u8], Self::Error>;
356}
357
358/// Optional capability: assemble the final signed-transaction wire bytes from
359/// the unsigned input plus a [`SignOutput`].
360///
361/// Implemented by chains whose wire format can be reconstructed from
362/// `(unsigned_tx, signature)` without recomputing hashes (currently EVM's
363/// typed transaction RLP and Solana's signature-slot splicing). Other chains
364/// expect callers to splice the signature into their own domain-specific
365/// envelope and therefore do not implement this trait.
366///
367/// ```
368/// use signer_primitives::{EncodeSignedTransaction, Sign, SignOutput};
369///
370/// fn wrap<S: Sign + EncodeSignedTransaction>(
371/// signer: &S,
372/// unsigned: &[u8],
373/// signature: &SignOutput,
374/// ) -> Result<Vec<u8>, S::Error> {
375/// signer.encode_signed_transaction(unsigned, signature)
376/// }
377/// ```
378pub trait EncodeSignedTransaction: Sign {
379 /// Encode `unsigned_tx + signature` into the chain's signed-wire form.
380 ///
381 /// # Errors
382 ///
383 /// Returns an error if the unsigned payload or signature variant is
384 /// malformed for this chain.
385 fn encode_signed_transaction(
386 &self,
387 unsigned_tx: &[u8],
388 signature: &SignOutput,
389 ) -> Result<Vec<u8>, Self::Error>;
390}