Skip to content

Commit 4d7fb3d

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 4d7fb3d

File tree

5 files changed

+526
-0
lines changed

5 files changed

+526
-0
lines changed

packages/wasm-utxo/js/ecpair.ts

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

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)