Skip to content

Commit b725ee2

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add ECPair implementation
Add elliptic curve key pair functionality with ECPair class that wraps the WasmECPair Rust implementation. This provides key generation, import/export functions including WIF format, and supports both private and public keys. Issue: BTC-2786 Co-authored-by: llm-git <[email protected]>
1 parent 9e5ab15 commit b725ee2

File tree

5 files changed

+542
-0
lines changed

5 files changed

+542
-0
lines changed

packages/wasm-utxo/js/ecpair.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { WasmECPair } from "./wasm/wasm_utxo.js";
2+
3+
/**
4+
* ECPairArg represents the various forms that ECPair keys can take
5+
* before being converted to a WasmECPair instance
6+
*/
7+
export type ECPairArg =
8+
/** Private key (32 bytes) or compressed public key (33 bytes) as Buffer/Uint8Array */
9+
| Uint8Array
10+
/** ECPair instance */
11+
| ECPair
12+
/** WasmECPair instance */
13+
| WasmECPair;
14+
15+
/**
16+
* Signer interface for signing operations
17+
*/
18+
export interface Signer {
19+
publicKey: Uint8Array;
20+
sign(hash: Uint8Array): Uint8Array;
21+
}
22+
23+
/**
24+
* Async signer interface
25+
*/
26+
export interface SignerAsync {
27+
publicKey: Uint8Array;
28+
sign(hash: Uint8Array): Promise<Uint8Array>;
29+
}
30+
31+
/**
32+
* ECPair interface for elliptic curve key pair operations
33+
*/
34+
export interface ECPairInterface {
35+
publicKey: Uint8Array;
36+
privateKey?: Uint8Array;
37+
toWIF(): string;
38+
}
39+
40+
/**
41+
* ECPair wrapper class for elliptic curve key pair operations
42+
*/
43+
export class ECPair implements ECPairInterface {
44+
private constructor(private _wasm: WasmECPair) {}
45+
46+
/**
47+
* Create an ECPair instance from a WasmECPair instance (internal use)
48+
* @internal
49+
*/
50+
static fromWasm(wasm: WasmECPair): ECPair {
51+
return new ECPair(wasm);
52+
}
53+
54+
/**
55+
* Convert ECPairArg to ECPair instance
56+
* @param key - The ECPair key in various formats
57+
* @returns ECPair instance
58+
*/
59+
static from(key: ECPairArg): ECPair {
60+
// Short-circuit if already an ECPair instance
61+
if (key instanceof ECPair) {
62+
return key;
63+
}
64+
// If it's a WasmECPair instance, wrap it
65+
if (key instanceof WasmECPair) {
66+
return new ECPair(key);
67+
}
68+
// Parse from Buffer/Uint8Array
69+
// Check length to determine if it's a private key (32 bytes) or public key (33 bytes)
70+
if (key.length === 32) {
71+
const wasm = WasmECPair.from_private_key(key);
72+
return new ECPair(wasm);
73+
} else if (key.length === 33) {
74+
const wasm = WasmECPair.from_public_key(key);
75+
return new ECPair(wasm);
76+
} else {
77+
throw new Error(
78+
`Invalid key length: ${key.length}. Expected 32 bytes (private key) or 33 bytes (compressed public key)`,
79+
);
80+
}
81+
}
82+
83+
/**
84+
* Create an ECPair from a private key (always uses compressed keys)
85+
* @param buffer - The 32-byte private key
86+
* @returns An ECPair instance
87+
*/
88+
static fromPrivateKey(buffer: Uint8Array): ECPair {
89+
const wasm = WasmECPair.from_private_key(buffer);
90+
return new ECPair(wasm);
91+
}
92+
93+
/**
94+
* Create an ECPair from a compressed public key
95+
* @param buffer - The compressed public key bytes (33 bytes)
96+
* @returns An ECPair instance
97+
*/
98+
static fromPublicKey(buffer: Uint8Array): ECPair {
99+
const wasm = WasmECPair.from_public_key(buffer);
100+
return new ECPair(wasm);
101+
}
102+
103+
/**
104+
* Create an ECPair from a WIF string (auto-detects network from WIF)
105+
* @param wifString - The WIF-encoded private key string
106+
* @returns An ECPair instance
107+
*/
108+
static fromWIF(wifString: string): ECPair {
109+
const wasm = WasmECPair.from_wif(wifString);
110+
return new ECPair(wasm);
111+
}
112+
113+
/**
114+
* Create an ECPair from a mainnet WIF string
115+
* @param wifString - The WIF-encoded private key string
116+
* @returns An ECPair instance
117+
*/
118+
static fromWIFMainnet(wifString: string): ECPair {
119+
const wasm = WasmECPair.from_wif_mainnet(wifString);
120+
return new ECPair(wasm);
121+
}
122+
123+
/**
124+
* Create an ECPair from a testnet WIF string
125+
* @param wifString - The WIF-encoded private key string
126+
* @returns An ECPair instance
127+
*/
128+
static fromWIFTestnet(wifString: string): ECPair {
129+
const wasm = WasmECPair.from_wif_testnet(wifString);
130+
return new ECPair(wasm);
131+
}
132+
133+
/**
134+
* Get the private key as a Uint8Array (if available)
135+
*/
136+
get privateKey(): Uint8Array | undefined {
137+
return this._wasm.private_key;
138+
}
139+
140+
/**
141+
* Get the public key as a Uint8Array
142+
*/
143+
get publicKey(): Uint8Array {
144+
return this._wasm.public_key;
145+
}
146+
147+
/**
148+
* Convert to WIF string (mainnet)
149+
* @returns The WIF-encoded private key
150+
*/
151+
toWIF(): string {
152+
return this._wasm.to_wif();
153+
}
154+
155+
/**
156+
* Convert to mainnet WIF string
157+
* @returns The WIF-encoded private key
158+
*/
159+
toWIFMainnet(): string {
160+
return this._wasm.to_wif_mainnet();
161+
}
162+
163+
/**
164+
* Convert to testnet WIF string
165+
* @returns The WIF-encoded private key
166+
*/
167+
toWIFTestnet(): string {
168+
return this._wasm.to_wif_testnet();
169+
}
170+
171+
/**
172+
* Get the underlying WASM instance (internal use only)
173+
* @internal
174+
*/
175+
get wasm(): WasmECPair {
176+
return this._wasm;
177+
}
178+
}

packages/wasm-utxo/js/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export * as ast from "./ast/index.js";
99
export * as utxolibCompat from "./utxolibCompat.js";
1010
export * as fixedScriptWallet from "./fixedScriptWallet.js";
1111

12+
export { ECPair } from "./ecpair.js";
13+
1214
export type { CoinName } from "./coinName.js";
1315
export type { Triple } from "./triple.js";
1416
export type { AddressFormat } from "./address.js";
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
use crate::bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
2+
use crate::bitcoin::PrivateKey;
3+
use crate::error::WasmUtxoError;
4+
use wasm_bindgen::prelude::*;
5+
6+
// Internal enum to hold either public-only or private+public keys
7+
#[derive(Debug, Clone)]
8+
enum ECPairKey {
9+
PublicOnly(PublicKey),
10+
Private {
11+
secret_key: SecretKey,
12+
public_key: PublicKey,
13+
},
14+
}
15+
16+
impl ECPairKey {
17+
fn public_key(&self) -> PublicKey {
18+
match self {
19+
ECPairKey::PublicOnly(pk) => *pk,
20+
ECPairKey::Private { public_key, .. } => *public_key,
21+
}
22+
}
23+
24+
fn secret_key(&self) -> Option<SecretKey> {
25+
match self {
26+
ECPairKey::PublicOnly(_) => None,
27+
ECPairKey::Private { secret_key, .. } => Some(*secret_key),
28+
}
29+
}
30+
}
31+
32+
/// WASM wrapper for elliptic curve key pairs (always uses compressed keys)
33+
#[wasm_bindgen]
34+
#[derive(Debug, Clone)]
35+
pub struct WasmECPair {
36+
key: ECPairKey,
37+
}
38+
39+
impl WasmECPair {
40+
/// Get the public key as a secp256k1::PublicKey (for internal Rust use)
41+
pub(crate) fn get_public_key(&self) -> PublicKey {
42+
self.key.public_key()
43+
}
44+
}
45+
46+
#[wasm_bindgen]
47+
impl WasmECPair {
48+
/// Create an ECPair from a private key (always uses compressed keys)
49+
#[wasm_bindgen]
50+
pub fn from_private_key(private_key: &[u8]) -> Result<WasmECPair, WasmUtxoError> {
51+
if private_key.len() != 32 {
52+
return Err(WasmUtxoError::new("Private key must be 32 bytes"));
53+
}
54+
55+
let secret_key = SecretKey::from_slice(private_key)
56+
.map_err(|e| WasmUtxoError::new(&format!("Invalid private key: {}", e)))?;
57+
58+
let secp = Secp256k1::new();
59+
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
60+
61+
Ok(WasmECPair {
62+
key: ECPairKey::Private {
63+
secret_key,
64+
public_key,
65+
},
66+
})
67+
}
68+
69+
/// Create an ECPair from a public key (always uses compressed keys)
70+
#[wasm_bindgen]
71+
pub fn from_public_key(public_key: &[u8]) -> Result<WasmECPair, WasmUtxoError> {
72+
let public_key = PublicKey::from_slice(public_key)
73+
.map_err(|e| WasmUtxoError::new(&format!("Invalid public key: {}", e)))?;
74+
75+
Ok(WasmECPair {
76+
key: ECPairKey::PublicOnly(public_key),
77+
})
78+
}
79+
80+
fn from_wif_with_network_check(
81+
wif_string: &str,
82+
expected_network: Option<crate::bitcoin::NetworkKind>,
83+
) -> Result<WasmECPair, WasmUtxoError> {
84+
let private_key = PrivateKey::from_wif(wif_string)
85+
.map_err(|e| WasmUtxoError::new(&format!("Invalid WIF: {}", e)))?;
86+
87+
if let Some(expected) = expected_network {
88+
if private_key.network != expected {
89+
let network_name = match expected {
90+
crate::bitcoin::NetworkKind::Main => "mainnet",
91+
crate::bitcoin::NetworkKind::Test => "testnet",
92+
};
93+
return Err(WasmUtxoError::new(&format!(
94+
"Expected {} WIF",
95+
network_name
96+
)));
97+
}
98+
}
99+
100+
let secp = Secp256k1::new();
101+
let secret_key = private_key.inner;
102+
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
103+
104+
Ok(WasmECPair {
105+
key: ECPairKey::Private {
106+
secret_key,
107+
public_key,
108+
},
109+
})
110+
}
111+
112+
/// Create an ECPair from a WIF string (auto-detects network)
113+
#[wasm_bindgen]
114+
pub fn from_wif(wif_string: &str) -> Result<WasmECPair, WasmUtxoError> {
115+
Self::from_wif_with_network_check(wif_string, None)
116+
}
117+
118+
/// Create an ECPair from a mainnet WIF string
119+
#[wasm_bindgen]
120+
pub fn from_wif_mainnet(wif_string: &str) -> Result<WasmECPair, WasmUtxoError> {
121+
use crate::bitcoin::NetworkKind;
122+
Self::from_wif_with_network_check(wif_string, Some(NetworkKind::Main))
123+
}
124+
125+
/// Create an ECPair from a testnet WIF string
126+
#[wasm_bindgen]
127+
pub fn from_wif_testnet(wif_string: &str) -> Result<WasmECPair, WasmUtxoError> {
128+
use crate::bitcoin::NetworkKind;
129+
Self::from_wif_with_network_check(wif_string, Some(NetworkKind::Test))
130+
}
131+
132+
/// Get the private key as a Uint8Array (if available)
133+
#[wasm_bindgen(getter)]
134+
pub fn private_key(&self) -> Option<js_sys::Uint8Array> {
135+
self.key
136+
.secret_key()
137+
.map(|sk| js_sys::Uint8Array::from(&sk.secret_bytes()[..]))
138+
}
139+
140+
/// Get the compressed public key as a Uint8Array (always 33 bytes)
141+
#[wasm_bindgen(getter)]
142+
pub fn public_key(&self) -> js_sys::Uint8Array {
143+
let pk = self.key.public_key();
144+
let bytes = pk.serialize();
145+
js_sys::Uint8Array::from(&bytes[..])
146+
}
147+
148+
/// Convert to WIF string (mainnet)
149+
#[wasm_bindgen]
150+
pub fn to_wif(&self) -> Result<String, WasmUtxoError> {
151+
self.to_wif_mainnet()
152+
}
153+
154+
/// Convert to mainnet WIF string
155+
#[wasm_bindgen]
156+
pub fn to_wif_mainnet(&self) -> Result<String, WasmUtxoError> {
157+
use crate::bitcoin::NetworkKind;
158+
self.to_wif_with_network(NetworkKind::Main)
159+
}
160+
161+
/// Convert to testnet WIF string
162+
#[wasm_bindgen]
163+
pub fn to_wif_testnet(&self) -> Result<String, WasmUtxoError> {
164+
use crate::bitcoin::NetworkKind;
165+
self.to_wif_with_network(NetworkKind::Test)
166+
}
167+
168+
fn to_wif_with_network(
169+
&self,
170+
network: crate::bitcoin::NetworkKind,
171+
) -> Result<String, WasmUtxoError> {
172+
let secret_key = self
173+
.key
174+
.secret_key()
175+
.ok_or_else(|| WasmUtxoError::new("Cannot get WIF from public key"))?;
176+
177+
let private_key = PrivateKey::new(secret_key, network);
178+
Ok(private_key.to_wif())
179+
}
180+
}

packages/wasm-utxo/src/wasm/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod address;
22
mod bip32interface;
33
mod descriptor;
4+
mod ecpair;
45
mod fixed_script_wallet;
56
mod miniscript;
67
mod psbt;
@@ -11,6 +12,7 @@ pub(crate) mod wallet_keys_helpers;
1112

1213
pub use address::AddressNamespace;
1314
pub use descriptor::WrapDescriptor;
15+
pub use ecpair::WasmECPair;
1416
pub use fixed_script_wallet::FixedScriptWalletNamespace;
1517
pub use miniscript::WrapMiniscript;
1618
pub use psbt::WrapPsbt;

0 commit comments

Comments
 (0)