clawpowers 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/COMPATIBILITY.md +13 -0
  3. package/KNOWN_LIMITATIONS.md +19 -0
  4. package/LICENSING.md +10 -0
  5. package/README.md +201 -9
  6. package/SECURITY.md +33 -53
  7. package/dist/index.d.ts +638 -5
  8. package/dist/index.js +986 -58
  9. package/dist/index.js.map +1 -1
  10. package/native/Cargo.lock +4863 -0
  11. package/native/Cargo.toml +73 -0
  12. package/native/crates/canonical/Cargo.toml +24 -0
  13. package/native/crates/canonical/src/lib.rs +673 -0
  14. package/native/crates/compression/Cargo.toml +20 -0
  15. package/native/crates/compression/benches/compression_bench.rs +42 -0
  16. package/native/crates/compression/src/lib.rs +393 -0
  17. package/native/crates/evm-eth/Cargo.toml +13 -0
  18. package/native/crates/evm-eth/src/lib.rs +105 -0
  19. package/native/crates/fee/Cargo.toml +15 -0
  20. package/native/crates/fee/src/lib.rs +281 -0
  21. package/native/crates/index/Cargo.toml +16 -0
  22. package/native/crates/index/src/lib.rs +277 -0
  23. package/native/crates/policy/Cargo.toml +17 -0
  24. package/native/crates/policy/src/lib.rs +614 -0
  25. package/native/crates/security/Cargo.toml +22 -0
  26. package/native/crates/security/src/lib.rs +478 -0
  27. package/native/crates/tokens/Cargo.toml +13 -0
  28. package/native/crates/tokens/src/lib.rs +534 -0
  29. package/native/crates/verification/Cargo.toml +23 -0
  30. package/native/crates/verification/src/lib.rs +333 -0
  31. package/native/crates/wallet/Cargo.toml +20 -0
  32. package/native/crates/wallet/src/lib.rs +261 -0
  33. package/native/crates/x402/Cargo.toml +30 -0
  34. package/native/crates/x402/src/lib.rs +423 -0
  35. package/native/ffi/Cargo.toml +34 -0
  36. package/native/ffi/build.rs +4 -0
  37. package/native/ffi/index.node +0 -0
  38. package/native/ffi/src/lib.rs +352 -0
  39. package/native/ffi/tests/integration.rs +354 -0
  40. package/native/pyo3/Cargo.toml +26 -0
  41. package/native/pyo3/pyproject.toml +16 -0
  42. package/native/pyo3/src/lib.rs +407 -0
  43. package/native/pyo3/tests/test_smoke.py +180 -0
  44. package/native/wasm/Cargo.toml +44 -0
  45. package/native/wasm/pkg/.gitignore +6 -0
  46. package/native/wasm/pkg/clawpowers_wasm.d.ts +208 -0
  47. package/native/wasm/pkg/clawpowers_wasm.js +872 -0
  48. package/native/wasm/pkg/clawpowers_wasm_bg.wasm +0 -0
  49. package/native/wasm/pkg/clawpowers_wasm_bg.wasm.d.ts +40 -0
  50. package/native/wasm/pkg/package.json +17 -0
  51. package/native/wasm/pkg-node/.gitignore +6 -0
  52. package/native/wasm/pkg-node/clawpowers_wasm.d.ts +143 -0
  53. package/native/wasm/pkg-node/clawpowers_wasm.js +798 -0
  54. package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm +0 -0
  55. package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm.d.ts +40 -0
  56. package/native/wasm/pkg-node/package.json +13 -0
  57. package/native/wasm/src/lib.rs +433 -0
  58. package/package.json +24 -3
  59. package/src/skills/catalog.ts +435 -0
  60. package/src/skills/executor.ts +56 -0
  61. package/src/skills/index.ts +3 -0
  62. package/src/skills/itp/SKILL.md +112 -0
  63. package/src/skills/loader.ts +193 -0
@@ -0,0 +1,42 @@
1
+ use clawpowers_compression::{CompressionConfig, TurboCompressor};
2
+ use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
3
+
4
+ fn make_vector(dim: usize, seed: u64) -> Vec<f32> {
5
+ use std::collections::hash_map::DefaultHasher;
6
+ use std::hash::{Hash, Hasher};
7
+ (0..dim)
8
+ .map(|i| {
9
+ let mut h = DefaultHasher::new();
10
+ (seed ^ i as u64).hash(&mut h);
11
+ let v = h.finish() as f32 / u64::MAX as f32;
12
+ v * 2.0 - 1.0
13
+ })
14
+ .collect()
15
+ }
16
+
17
+ fn bench_compress_decompress(c: &mut Criterion) {
18
+ let dim = 768;
19
+ let compressor = TurboCompressor::new(CompressionConfig {
20
+ dimensions: dim,
21
+ quantization_bits: 8,
22
+ rotation_seed: 0xDEAD_BEEF,
23
+ });
24
+ let vector = make_vector(dim, 1);
25
+
26
+ let mut group = c.benchmark_group("turbo_compression");
27
+
28
+ group.bench_function(BenchmarkId::new("compress", dim), |b| {
29
+ b.iter(|| compressor.compress(&vector).expect("compress"));
30
+ });
31
+
32
+ let compressed = compressor.compress(&vector).expect("pre-compress");
33
+
34
+ group.bench_function(BenchmarkId::new("decompress", dim), |b| {
35
+ b.iter(|| compressor.decompress(&compressed).expect("decompress"));
36
+ });
37
+
38
+ group.finish();
39
+ }
40
+
41
+ criterion_group!(benches, bench_compress_decompress);
42
+ criterion_main!(benches);
@@ -0,0 +1,393 @@
1
+ //! TurboQuant vector compression for TurboMemory.
2
+ //!
3
+ //! Provides a four-times compression of f32 dense vectors via:
4
+ //! 1. Deterministic random orthogonal rotation (decorrelation).
5
+ //! 2. Min/max scalar quantization to `u8`.
6
+ //! 3. A QJL-inspired residual norm sketch.
7
+
8
+ use rand::{Rng, SeedableRng, rngs::StdRng};
9
+ use serde::{Deserialize, Serialize};
10
+ use thiserror::Error;
11
+
12
+ /// Errors produced by the compression pipeline.
13
+ #[derive(Debug, Error)]
14
+ pub enum CompressionError {
15
+ /// Input vector has a different length than the configured dimensions.
16
+ #[error("dimension mismatch: expected {expected}, got {got}")]
17
+ DimensionMismatch {
18
+ /// Expected number of dimensions.
19
+ expected: usize,
20
+ /// Actual number of dimensions.
21
+ got: usize,
22
+ },
23
+ /// The compressed vector contains no data.
24
+ #[error("empty compressed vector")]
25
+ EmptyVector,
26
+ }
27
+
28
+ /// A shorthand result type for [`CompressionError`].
29
+ pub type Result<T> = std::result::Result<T, CompressionError>;
30
+
31
+ /// Configuration for the TurboQuant compressor.
32
+ #[derive(Debug, Clone)]
33
+ pub struct CompressionConfig {
34
+ /// Number of dimensions in the input vectors.
35
+ pub dimensions: usize,
36
+ /// Bits used for scalar quantization (default 8, meaning `u8`).
37
+ pub quantization_bits: u8,
38
+ /// Seed for the deterministic rotation matrix.
39
+ pub rotation_seed: u64,
40
+ }
41
+
42
+ impl Default for CompressionConfig {
43
+ fn default() -> Self {
44
+ Self {
45
+ dimensions: 768,
46
+ quantization_bits: 8,
47
+ rotation_seed: 0xDEAD_BEEF_CAFE_1234,
48
+ }
49
+ }
50
+ }
51
+
52
+ /// The result of compressing a dense f32 vector.
53
+ #[derive(Debug, Clone, Serialize, Deserialize)]
54
+ pub struct CompressedVector {
55
+ /// Scalar-quantized values in `u8`.
56
+ pub quantized: Vec<u8>,
57
+ /// Minimum f32 value seen in the rotated vector.
58
+ pub min_val: f32,
59
+ /// Maximum f32 value seen in the rotated vector.
60
+ pub max_val: f32,
61
+ /// L2 norm of the quantization error (QJL residual sketch).
62
+ pub residual_norm: f32,
63
+ /// Original dimensionality.
64
+ pub original_dim: usize,
65
+ }
66
+
67
+ impl CompressedVector {
68
+ /// Return the byte size of the quantized payload.
69
+ pub fn byte_size(&self) -> usize {
70
+ self.quantized.len()
71
+ }
72
+ }
73
+
74
+ /// TurboQuant compressor: rotation → quantization → residual sketch.
75
+ pub struct TurboCompressor {
76
+ /// Configuration.
77
+ pub config: CompressionConfig,
78
+ rotation_matrix: Vec<f32>,
79
+ }
80
+
81
+ impl TurboCompressor {
82
+ /// Create a new compressor, pre-computing the rotation matrix from the seed.
83
+ pub fn new(config: CompressionConfig) -> Self {
84
+ let rotation_matrix = build_rotation_matrix(config.dimensions, config.rotation_seed);
85
+ Self {
86
+ config,
87
+ rotation_matrix,
88
+ }
89
+ }
90
+
91
+ /// Compress `vector` into a [`CompressedVector`].
92
+ pub fn compress(&self, vector: &[f32]) -> Result<CompressedVector> {
93
+ let dim = self.config.dimensions;
94
+ if vector.len() != dim {
95
+ return Err(CompressionError::DimensionMismatch {
96
+ expected: dim,
97
+ got: vector.len(),
98
+ });
99
+ }
100
+ let rotated = mat_vec_mul(&self.rotation_matrix, vector, dim);
101
+ let min_val = rotated.iter().copied().fold(f32::INFINITY, f32::min);
102
+ let max_val = rotated.iter().copied().fold(f32::NEG_INFINITY, f32::max);
103
+ let range = if (max_val - min_val).abs() < f32::EPSILON {
104
+ 1.0_f32
105
+ } else {
106
+ max_val - min_val
107
+ };
108
+ let quantized: Vec<u8> = rotated
109
+ .iter()
110
+ .map(|&v| (((v - min_val) / range) * 255.0).round().clamp(0.0, 255.0) as u8)
111
+ .collect();
112
+ let residual_norm = {
113
+ let sum_sq: f32 = rotated
114
+ .iter()
115
+ .zip(quantized.iter())
116
+ .map(|(&orig, &q)| {
117
+ let reconstructed = (q as f32 / 255.0) * range + min_val;
118
+ let err = orig - reconstructed;
119
+ err * err
120
+ })
121
+ .sum();
122
+ sum_sq.sqrt()
123
+ };
124
+ Ok(CompressedVector {
125
+ quantized,
126
+ min_val,
127
+ max_val,
128
+ residual_norm,
129
+ original_dim: dim,
130
+ })
131
+ }
132
+
133
+ /// Reconstruct an approximate f32 vector from a [`CompressedVector`].
134
+ pub fn decompress(&self, compressed: &CompressedVector) -> Result<Vec<f32>> {
135
+ if compressed.quantized.is_empty() {
136
+ return Err(CompressionError::EmptyVector);
137
+ }
138
+ let range = compressed.max_val - compressed.min_val;
139
+ let dequantized: Vec<f32> = compressed
140
+ .quantized
141
+ .iter()
142
+ .map(|&q| (q as f32 / 255.0) * range + compressed.min_val)
143
+ .collect();
144
+ Ok(mat_t_vec_mul(
145
+ &self.rotation_matrix,
146
+ &dequantized,
147
+ compressed.original_dim,
148
+ ))
149
+ }
150
+
151
+ /// Fast Euclidean distance estimate in the compressed domain.
152
+ pub fn approximate_distance(&self, a: &CompressedVector, b: &CompressedVector) -> Result<f32> {
153
+ if a.quantized.is_empty() || b.quantized.is_empty() {
154
+ return Err(CompressionError::EmptyVector);
155
+ }
156
+ if a.quantized.len() != b.quantized.len() {
157
+ return Err(CompressionError::DimensionMismatch {
158
+ expected: a.quantized.len(),
159
+ got: b.quantized.len(),
160
+ });
161
+ }
162
+ let range_a = a.max_val - a.min_val;
163
+ let range_b = b.max_val - b.min_val;
164
+ let sum_sq: f32 = a
165
+ .quantized
166
+ .iter()
167
+ .zip(b.quantized.iter())
168
+ .map(|(&qa, &qb)| {
169
+ let va = (qa as f32 / 255.0) * range_a + a.min_val;
170
+ let vb = (qb as f32 / 255.0) * range_b + b.min_val;
171
+ let diff = va - vb;
172
+ diff * diff
173
+ })
174
+ .sum();
175
+ Ok(sum_sq.sqrt())
176
+ }
177
+ }
178
+
179
+ fn build_rotation_matrix(dim: usize, seed: u64) -> Vec<f32> {
180
+ let mut rng = StdRng::seed_from_u64(seed);
181
+ let mut basis: Vec<Vec<f32>> = (0..dim)
182
+ .map(|_| (0..dim).map(|_| rng.random::<f32>() * 2.0 - 1.0).collect())
183
+ .collect();
184
+ for i in 0..dim {
185
+ let norm = l2_norm(&basis[i]);
186
+ if norm > f32::EPSILON {
187
+ for x in basis[i].iter_mut() {
188
+ *x /= norm;
189
+ }
190
+ }
191
+ for j in (i + 1)..dim {
192
+ let dot: f32 = basis[i]
193
+ .iter()
194
+ .zip(basis[j].iter())
195
+ .map(|(a, b)| a * b)
196
+ .sum();
197
+ let proj: Vec<f32> = basis[i].iter().map(|&v| v * dot).collect();
198
+ for (x, p) in basis[j].iter_mut().zip(proj.iter()) {
199
+ *x -= p;
200
+ }
201
+ }
202
+ }
203
+ basis.into_iter().flatten().collect()
204
+ }
205
+
206
+ fn mat_vec_mul(matrix: &[f32], vector: &[f32], dim: usize) -> Vec<f32> {
207
+ (0..dim)
208
+ .map(|row| {
209
+ matrix[row * dim..(row + 1) * dim]
210
+ .iter()
211
+ .zip(vector.iter())
212
+ .map(|(m, v)| m * v)
213
+ .sum()
214
+ })
215
+ .collect()
216
+ }
217
+
218
+ fn mat_t_vec_mul(matrix: &[f32], vector: &[f32], dim: usize) -> Vec<f32> {
219
+ let mut result = vec![0.0_f32; dim];
220
+ for row in 0..dim {
221
+ for col in 0..dim {
222
+ result[col] += matrix[row * dim + col] * vector[row];
223
+ }
224
+ }
225
+ result
226
+ }
227
+
228
+ fn l2_norm(v: &[f32]) -> f32 {
229
+ v.iter().map(|x| x * x).sum::<f32>().sqrt()
230
+ }
231
+
232
+ /// Compute the L2 distance between two f32 slices.
233
+ pub fn l2_distance(a: &[f32], b: &[f32]) -> f32 {
234
+ a.iter()
235
+ .zip(b.iter())
236
+ .map(|(x, y)| (x - y) * (x - y))
237
+ .sum::<f32>()
238
+ .sqrt()
239
+ }
240
+
241
+ /// Compute cosine similarity between two f32 slices.
242
+ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
243
+ let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
244
+ let na = l2_norm(a);
245
+ let nb = l2_norm(b);
246
+ if na < f32::EPSILON || nb < f32::EPSILON {
247
+ 0.0
248
+ } else {
249
+ dot / (na * nb)
250
+ }
251
+ }
252
+
253
+ #[cfg(test)]
254
+ mod tests {
255
+ use super::*;
256
+
257
+ const DIM: usize = 64;
258
+
259
+ fn random_vector(dim: usize, seed: u64) -> Vec<f32> {
260
+ let mut rng = StdRng::seed_from_u64(seed);
261
+ (0..dim).map(|_| rng.random::<f32>() * 2.0 - 1.0).collect()
262
+ }
263
+
264
+ fn make_compressor(dim: usize) -> TurboCompressor {
265
+ TurboCompressor::new(CompressionConfig {
266
+ dimensions: dim,
267
+ quantization_bits: 8,
268
+ rotation_seed: 42,
269
+ })
270
+ }
271
+
272
+ #[test]
273
+ fn test_roundtrip_l2_error_below_threshold() {
274
+ let c = make_compressor(DIM);
275
+ let v = random_vector(DIM, 1);
276
+ let cv = c.compress(&v).expect("compress");
277
+ let r = c.decompress(&cv).expect("decompress");
278
+ let err = l2_distance(&v, &r);
279
+ let norm = l2_norm(&v);
280
+ assert!((if norm > f32::EPSILON { err / norm } else { err }) < 0.05);
281
+ }
282
+
283
+ #[test]
284
+ fn test_roundtrip_768_dim() {
285
+ let c = make_compressor(768);
286
+ let v = random_vector(768, 99);
287
+ let cv = c.compress(&v).expect("compress");
288
+ let r = c.decompress(&cv).expect("decompress");
289
+ let err = l2_distance(&v, &r);
290
+ let norm = l2_norm(&v);
291
+ assert!((if norm > f32::EPSILON { err / norm } else { err }) < 0.05);
292
+ }
293
+
294
+ #[test]
295
+ fn test_compression_ratio_4x() {
296
+ let c = make_compressor(DIM);
297
+ let cv = c.compress(&random_vector(DIM, 2)).expect("compress");
298
+ assert_eq!(DIM * 4, cv.byte_size() * 4);
299
+ }
300
+
301
+ #[test]
302
+ fn test_approximate_distance_preserves_order() {
303
+ let c = make_compressor(DIM);
304
+ let q = random_vector(DIM, 10);
305
+ let mut near = q.clone();
306
+ near[0] += 0.01;
307
+ let far = random_vector(DIM, 20);
308
+ let cq = c.compress(&q).expect("cq");
309
+ let cn = c.compress(&near).expect("cn");
310
+ let cf = c.compress(&far).expect("cf");
311
+ let dn = c.approximate_distance(&cq, &cn).expect("dn");
312
+ let df = c.approximate_distance(&cq, &cf).expect("df");
313
+ assert!(dn < df, "near {dn:.4} should < far {df:.4}");
314
+ }
315
+
316
+ #[test]
317
+ fn test_distance_identical_vectors_is_zero() {
318
+ let c = make_compressor(DIM);
319
+ let v = random_vector(DIM, 3);
320
+ let cv = c.compress(&v).expect("cv");
321
+ assert!(c.approximate_distance(&cv, &cv).expect("d") < 0.01);
322
+ }
323
+
324
+ #[test]
325
+ fn test_dimension_mismatch_errors() {
326
+ let c = make_compressor(DIM);
327
+ assert!(matches!(
328
+ c.compress(&random_vector(DIM + 1, 4)).expect_err("e"),
329
+ CompressionError::DimensionMismatch { .. }
330
+ ));
331
+ }
332
+
333
+ #[test]
334
+ fn test_different_seeds_produce_different_outputs() {
335
+ let c1 = TurboCompressor::new(CompressionConfig {
336
+ dimensions: DIM,
337
+ quantization_bits: 8,
338
+ rotation_seed: 1,
339
+ });
340
+ let c2 = TurboCompressor::new(CompressionConfig {
341
+ dimensions: DIM,
342
+ quantization_bits: 8,
343
+ rotation_seed: 2,
344
+ });
345
+ let v = random_vector(DIM, 5);
346
+ assert_ne!(
347
+ c1.compress(&v).expect("c1").quantized,
348
+ c2.compress(&v).expect("c2").quantized
349
+ );
350
+ }
351
+
352
+ #[test]
353
+ fn test_same_seed_is_deterministic() {
354
+ let v = random_vector(DIM, 7);
355
+ assert_eq!(
356
+ make_compressor(DIM).compress(&v).expect("c1").quantized,
357
+ make_compressor(DIM).compress(&v).expect("c2").quantized
358
+ );
359
+ }
360
+
361
+ #[test]
362
+ fn test_residual_norm_is_nonnegative() {
363
+ let cv = make_compressor(DIM)
364
+ .compress(&random_vector(DIM, 8))
365
+ .expect("cv");
366
+ assert!(cv.residual_norm >= 0.0);
367
+ }
368
+
369
+ #[test]
370
+ fn test_decompress_empty_errors() {
371
+ let c = make_compressor(DIM);
372
+ let empty = CompressedVector {
373
+ quantized: vec![],
374
+ min_val: 0.0,
375
+ max_val: 1.0,
376
+ residual_norm: 0.0,
377
+ original_dim: DIM,
378
+ };
379
+ assert!(matches!(
380
+ c.decompress(&empty).expect_err("e"),
381
+ CompressionError::EmptyVector
382
+ ));
383
+ }
384
+
385
+ #[test]
386
+ fn test_all_zero_vector() {
387
+ let c = make_compressor(DIM);
388
+ let zero = vec![0.0_f32; DIM];
389
+ let cv = c.compress(&zero).expect("cv");
390
+ let r = c.decompress(&cv).expect("decompress");
391
+ assert!(l2_distance(&zero, &r) < 1e-5);
392
+ }
393
+ }
@@ -0,0 +1,13 @@
1
+ [package]
2
+ name = "clawpowers-evm-eth"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+ description = "secp256k1 + Keccak Ethereum address and ECDSA helpers (pure Rust, WASM-safe)"
7
+
8
+ [dependencies]
9
+ k256 = { version = "0.13", features = ["ecdsa", "sha2"] }
10
+ alloy-primitives = { workspace = true }
11
+
12
+ [dev-dependencies]
13
+ hex = "0.4"
@@ -0,0 +1,105 @@
1
+ //! Ethereum address derivation (pubkey → Keccak last 20) and ECDSA over secp256k1 (k256).
2
+
3
+ use alloy_primitives::{keccak256, Address};
4
+ use k256::ecdsa::signature::hazmat::PrehashVerifier;
5
+ use k256::ecdsa::{RecoveryId, Signature, SigningKey, VerifyingKey};
6
+
7
+ /// Uncompressed public key as 64 bytes (x || y), without the `0x04` prefix.
8
+ pub fn derive_public_key(private_key_bytes: &[u8]) -> Result<Vec<u8>, String> {
9
+ if private_key_bytes.len() != 32 {
10
+ return Err("private key must be 32 bytes".into());
11
+ }
12
+ let sk = SigningKey::from_slice(private_key_bytes).map_err(|e| e.to_string())?;
13
+ let vk = VerifyingKey::from(&sk);
14
+ let encoded = vk.to_encoded_point(false);
15
+ let bytes = encoded.as_bytes();
16
+ if bytes.len() != 65 || bytes[0] != 0x04 {
17
+ return Err("invalid uncompressed public key encoding".into());
18
+ }
19
+ Ok(bytes[1..].to_vec())
20
+ }
21
+
22
+ /// `0x` + 20-byte Ethereum address (Keccak-256 of 64-byte pubkey, last 20 bytes), EIP-55 checksummed.
23
+ pub fn derive_ethereum_address(private_key_bytes: &[u8]) -> Result<String, String> {
24
+ let pk64 = derive_public_key(private_key_bytes)?;
25
+ let digest = keccak256(pk64.as_slice());
26
+ let addr = Address::from_slice(&digest[12..]);
27
+ Ok(addr.to_checksum(None))
28
+ }
29
+
30
+ /// ECDSA sign a 32-byte message hash; returns 65 bytes: r (32) || s (32) || recovery_id (0–3).
31
+ pub fn sign_ecdsa(private_key_bytes: &[u8], message_hash: &[u8]) -> Result<Vec<u8>, String> {
32
+ if message_hash.len() != 32 {
33
+ return Err("message hash must be 32 bytes".into());
34
+ }
35
+ let sk = SigningKey::from_slice(private_key_bytes).map_err(|e| e.to_string())?;
36
+ let (sig, recid) = sk
37
+ .sign_prehash_recoverable(message_hash)
38
+ .map_err(|e| e.to_string())?;
39
+ let mut out = Vec::with_capacity(65);
40
+ out.extend_from_slice(&sig.to_bytes());
41
+ out.push(recid.to_byte());
42
+ Ok(out)
43
+ }
44
+
45
+ /// Verify ECDSA over a 32-byte prehash. `public_key_bytes` is 64-byte uncompressed x||y (no prefix).
46
+ /// `signature` is 65 bytes (r||s||v) or 64 bytes (r||s) using the given public key.
47
+ pub fn verify_ecdsa(
48
+ public_key_bytes: &[u8],
49
+ message_hash: &[u8],
50
+ signature: &[u8],
51
+ ) -> Result<bool, String> {
52
+ if message_hash.len() != 32 {
53
+ return Err("message hash must be 32 bytes".into());
54
+ }
55
+ if public_key_bytes.len() != 64 {
56
+ return Err("public key must be 64 bytes (uncompressed x||y, no 0x04 prefix)".into());
57
+ }
58
+ let mut sec1 = Vec::with_capacity(65);
59
+ sec1.push(0x04);
60
+ sec1.extend_from_slice(public_key_bytes);
61
+ let vk = VerifyingKey::from_sec1_bytes(&sec1).map_err(|e| e.to_string())?;
62
+
63
+ match signature.len() {
64
+ 65 => {
65
+ let sig = Signature::try_from(&signature[..64]).map_err(|e| e.to_string())?;
66
+ let recid = RecoveryId::try_from(signature[64])
67
+ .map_err(|_| "invalid recovery id (expected 0–3)".to_string())?;
68
+ let recovered = VerifyingKey::recover_from_prehash(message_hash, &sig, recid)
69
+ .map_err(|e| e.to_string())?;
70
+ Ok(recovered == vk)
71
+ }
72
+ 64 => {
73
+ let sig = Signature::try_from(signature).map_err(|e| e.to_string())?;
74
+ vk.verify_prehash(message_hash, &sig)
75
+ .map(|_| true)
76
+ .map_err(|e| e.to_string())
77
+ }
78
+ _ => Err("signature must be 64 or 65 bytes".into()),
79
+ }
80
+ }
81
+
82
+ #[cfg(test)]
83
+ mod tests {
84
+ use super::*;
85
+
86
+ /// Hardhat / Foundry default account #0
87
+ const HH0_SK: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
88
+ const HH0_ADDR: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
89
+
90
+ #[test]
91
+ fn hardhat_account_0_address() {
92
+ let sk = hex::decode(HH0_SK).unwrap();
93
+ let addr = derive_ethereum_address(&sk).unwrap();
94
+ assert_eq!(addr.to_lowercase(), HH0_ADDR.to_lowercase());
95
+ }
96
+
97
+ #[test]
98
+ fn sign_and_verify_roundtrip() {
99
+ let sk = hex::decode(HH0_SK).unwrap();
100
+ let pk = derive_public_key(&sk).unwrap();
101
+ let msg_hash = keccak256(b"hello clawpowers");
102
+ let sig = sign_ecdsa(&sk, msg_hash.as_slice()).unwrap();
103
+ assert!(verify_ecdsa(&pk, msg_hash.as_slice(), &sig).unwrap());
104
+ }
105
+ }
@@ -0,0 +1,15 @@
1
+ [package]
2
+ name = "clawpowers-fee"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+
7
+ [dependencies]
8
+ serde = { workspace = true }
9
+ serde_json = { workspace = true }
10
+ thiserror = { workspace = true }
11
+ tracing = { workspace = true }
12
+ alloy-primitives = { workspace = true }
13
+ clawpowers-tokens = { path = "../tokens" }
14
+
15
+ [dev-dependencies]