Skip to main content

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}