Wire oracle.js to crypto provider, add QuickJS compatibility

- Oracle: replace Node.js crypto (createHash/createVerify) with crypto
    provider's verifySignature, eliminating the 'crypto' module dependency.
    verifyPricingRecordSignature and validatePricingRecord are now async.

  - Rust crate: add SHA-256 (all targets) and verify_signature with ECDSA
    P-256 + DSA support (native-only) to salvium-crypto. FFI bindings and
    C header updated. All 31 Rust tests pass.

  - Crypto backends: add sha256/verifySignature to JS (noble + WebCrypto
    fallback), WASM (noble + WebCrypto fallback), and JSI (native FFI).

  - QuickJS compat (6 issues):
    · catch {} → catch (_e) {} across 10 files
    · Buffer → bytesToHex/hexToBytes in consensus.js and validation.js
    · AbortController → Promise.race timeout in rpc/client, connection-
      manager, and oracle
    · URLSearchParams → manual query string in rpc/client and oracle
    · Promise.allSettled → Promise.all wrapper in connection-manager
    · btoa → _toBase64 with manual fallback in rpc/client
    · pemToDer base64 decoder with 3-tier fallback (Buffer/atob/manual)

  - Fix GammaPicker exclusion zone (60 → 10) matching C++ gamma_picker
  - Fix testnet TARGET heights for coinbase maturity
This commit is contained in:
Matt Hess
2026-02-10 05:42:11 +00:00
parent 01bcb742ae
commit 4eb3062d9c
25 changed files with 1076 additions and 142 deletions
+429
View File
@@ -14,6 +14,24 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "blake2b_simd"
version = "1.0.4"
@@ -25,6 +43,15 @@ dependencies = [
"constant_time_eq",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
@@ -37,6 +64,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
@@ -58,6 +91,28 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@@ -84,24 +139,258 @@ dependencies = [
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"subtle",
]
[[package]]
name = "dsa"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689"
dependencies = [
"digest",
"num-bigint-dig",
"num-traits",
"pkcs8",
"rfc6979",
"sha2",
"signature",
"zeroize",
]
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"pem-rfc7468",
"pkcs8",
"rand_core",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "generic-array"
version = "0.14.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core",
"subtle",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]]
name = "libc"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand",
"smallvec",
"zeroize",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -120,6 +409,45 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -141,16 +469,79 @@ version = "0.1.0"
dependencies = [
"blake2b_simd",
"curve25519-dalek",
"der",
"dsa",
"p256",
"sha2",
"signature",
"spki",
"tiny-keccak",
"wasm-bindgen",
]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -177,12 +568,30 @@ dependencies = [
"crunchy",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
@@ -228,6 +637,26 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "zerocopy"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zeroize"
version = "1.8.2"
+11
View File
@@ -13,6 +13,17 @@ blake2b_simd = "1.0"
wasm-bindgen = "0.2"
curve25519-dalek = { version = "4", features = ["alloc"] }
# Oracle signature verification (SHA-256, ECDSA P-256, DSA)
# Only for native targets — WASM uses the JS crypto shim
sha2 = "0.10"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
p256 = { version = "0.13", features = ["ecdsa"] }
dsa = "0.6"
spki = "0.7"
signature = "2"
der = "0.7"
[features]
default = []
ffi = []
@@ -135,6 +135,24 @@ int32_t salvium_zero_commit(
int32_t salvium_gen_commitment_mask(
const uint8_t *secret /* 32 */, uint8_t *out /* 32 */);
/* ─── Oracle Signature Verification ───────────────────────────────────────── */
/** SHA-256 hash */
int32_t salvium_sha256(
const uint8_t *data, size_t data_len,
uint8_t *out /* 32 */);
/**
* Verify signature against DER-encoded SPKI public key.
* Supports ECDSA P-256 (testnet) and DSA (mainnet).
* Message is hashed with SHA-256 internally.
* Returns 1 for valid, 0 for invalid/error.
*/
int32_t salvium_verify_signature(
const uint8_t *message, size_t msg_len,
const uint8_t *signature, size_t sig_len,
const uint8_t *pubkey_der, size_t key_len);
#ifdef __cplusplus
}
#endif
+98
View File
@@ -354,6 +354,37 @@ pub unsafe extern "C" fn salvium_gen_commitment_mask(
0
}
// ─── Oracle Signature Verification ──────────────────────────────────────────
#[no_mangle]
pub unsafe extern "C" fn salvium_sha256(
data: *const u8,
data_len: usize,
out: *mut u8,
) -> i32 {
let data = slice::from_raw_parts(data, data_len);
let result = crate::sha256(data);
ptr::copy_nonoverlapping(result.as_ptr(), out, 32);
0
}
/// Verify a signature against a DER-encoded SPKI public key.
/// Returns 1 for valid, 0 for invalid/error.
#[no_mangle]
pub unsafe extern "C" fn salvium_verify_signature(
message: *const u8,
msg_len: usize,
signature: *const u8,
sig_len: usize,
pubkey_der: *const u8,
key_len: usize,
) -> i32 {
let message = slice::from_raw_parts(message, msg_len);
let signature = slice::from_raw_parts(signature, sig_len);
let pubkey_der = slice::from_raw_parts(pubkey_der, key_len);
crate::verify_signature(message, signature, pubkey_der)
}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -726,4 +757,71 @@ mod tests {
assert_eq!(rc, 0);
assert_ffi_matches(&out, &crate::gen_commitment_mask(&secret));
}
#[test]
fn test_sha256() {
let data = b"test data";
let mut out = [0u8; 32];
let rc = unsafe { salvium_sha256(data.as_ptr(), data.len(), out.as_mut_ptr()) };
assert_eq!(rc, 0);
// Known SHA-256 of "test data"
let expected = crate::sha256(data);
assert_eq!(&out[..], &expected[..]);
// Verify against known hash
assert_ne!(out, [0u8; 32]); // not all zeros
}
#[test]
fn test_sha256_empty() {
let mut out = [0u8; 32];
let rc = unsafe { salvium_sha256([].as_ptr(), 0, out.as_mut_ptr()) };
assert_eq!(rc, 0);
// SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
assert_eq!(out[0], 0xe3);
assert_eq!(out[1], 0xb0);
assert_eq!(out[31], 0x55);
}
#[test]
fn test_verify_signature_invalid() {
// Invalid inputs should return 0, not crash
let message = b"test";
let bad_sig = [0u8; 64];
let bad_key = [0u8; 32];
let rc = unsafe {
salvium_verify_signature(
message.as_ptr(), message.len(),
bad_sig.as_ptr(), bad_sig.len(),
bad_key.as_ptr(), bad_key.len(),
)
};
assert_eq!(rc, 0); // invalid → 0
}
#[test]
fn test_verify_signature_ecdsa_p256() {
// Testnet oracle public key (ECDSA P-256, DER-encoded SPKI)
let key_der: [u8; 91] = [
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02,
0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03,
0x42, 0x00, 0x04, 0xe5, 0x80, 0x71, 0x5b, 0x1d, 0x40, 0x64, 0x20, 0x3d,
0x8d, 0x35, 0x24, 0xf0, 0xfa, 0xf6, 0xb9, 0x9f, 0x63, 0xa5, 0xf4, 0x6d,
0x29, 0x6b, 0xf7, 0x56, 0x8d, 0x7f, 0x1a, 0x7c, 0xbe, 0xd6, 0xf7, 0xda,
0xc6, 0xc5, 0xe1, 0x05, 0x08, 0x86, 0xd4, 0xa9, 0x47, 0x91, 0xa7, 0xcd,
0x19, 0xaa, 0xf3, 0xa0, 0xbd, 0x16, 0x1d, 0x6e, 0x28, 0x72, 0xa6, 0x9a,
0xa8, 0x5e, 0x62, 0xbf, 0xc8, 0xb7, 0xe4,
];
// Verify that parsing the key doesn't crash with an invalid signature
let message = b"test message";
let bad_sig = [0u8; 70];
let rc = unsafe {
salvium_verify_signature(
message.as_ptr(), message.len(),
bad_sig.as_ptr(), bad_sig.len(),
key_der.as_ptr(), key_der.len(),
)
};
// Should return 0 (invalid sig), not crash
assert_eq!(rc, 0);
}
}
+109
View File
@@ -4,6 +4,7 @@ use curve25519_dalek::scalar::Scalar;
use curve25519_dalek::edwards::{CompressedEdwardsY, EdwardsPoint};
use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE;
use curve25519_dalek::traits::VartimeMultiscalarMul;
use sha2::{Sha256, Digest};
mod elligator2;
@@ -314,3 +315,111 @@ pub fn gen_commitment_mask(shared_secret: &[u8]) -> Vec<u8> {
let hash = keccak256_internal(&data);
Scalar::from_bytes_mod_order(hash).to_bytes().to_vec()
}
// ============================================================================
// Oracle Signature Verification (SHA-256, ECDSA P-256, DSA)
// ============================================================================
/// SHA-256 hash
#[wasm_bindgen]
pub fn sha256(data: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().to_vec()
}
/// Verify a signature against a DER-encoded SubjectPublicKeyInfo (SPKI) key.
///
/// Supports:
/// - ECDSA P-256 (testnet oracle) — OID 1.2.840.10045.2.1
/// - DSA (mainnet oracle) — OID 1.2.840.10040.4.1
///
/// The message is hashed with SHA-256 internally (matching Node.js createVerify('SHA256')).
/// Signature must be DER-encoded (ASN.1 SEQUENCE of two INTEGERs r, s).
/// Public key is the raw DER bytes of the SPKI structure (base64-decoded PEM body).
///
/// Returns 1 for valid, 0 for invalid/error.
///
/// Only available on native targets (not WASM). On WASM, the JS crypto shim is used.
#[cfg(not(target_arch = "wasm32"))]
#[wasm_bindgen]
pub fn verify_signature(message: &[u8], signature: &[u8], pubkey_der: &[u8]) -> i32 {
verify_signature_internal(message, signature, pubkey_der)
.unwrap_or(0)
}
#[cfg(not(target_arch = "wasm32"))]
fn verify_signature_internal(message: &[u8], signature: &[u8], pubkey_der: &[u8]) -> Option<i32> {
use spki::SubjectPublicKeyInfoRef;
use der::Decode;
// Parse the SPKI structure to determine algorithm
let spki = SubjectPublicKeyInfoRef::from_der(pubkey_der).ok()?;
let algorithm_oid = spki.algorithm.oid;
// OID 1.2.840.10045.2.1 = id-ecPublicKey (ECDSA)
const EC_PUBLIC_KEY_OID: der::oid::ObjectIdentifier =
der::oid::ObjectIdentifier::new_unwrap("1.2.840.10045.2.1");
// OID 1.2.840.10040.4.1 = id-dsa (DSA)
const DSA_OID: der::oid::ObjectIdentifier =
der::oid::ObjectIdentifier::new_unwrap("1.2.840.10040.4.1");
if algorithm_oid == EC_PUBLIC_KEY_OID {
verify_ecdsa_p256(message, signature, pubkey_der)
} else if algorithm_oid == DSA_OID {
verify_dsa(message, signature, pubkey_der)
} else {
None // Unknown algorithm
}
}
/// Verify ECDSA P-256 signature (SHA-256 digest)
#[cfg(not(target_arch = "wasm32"))]
fn verify_ecdsa_p256(message: &[u8], signature: &[u8], pubkey_der: &[u8]) -> Option<i32> {
use p256::ecdsa::{VerifyingKey, Signature};
use spki::DecodePublicKey;
let verifying_key = VerifyingKey::from_public_key_der(pubkey_der).ok()?;
let sig = Signature::from_der(signature).ok()?;
// SHA-256 hash the message first (matching createVerify('SHA256'))
let digest = {
let mut hasher = Sha256::new();
hasher.update(message);
hasher.finalize()
};
// p256 ecdsa Verifier::verify expects pre-hashed when using verify_prehash
// But the standard Verifier trait hashes internally. Since createVerify('SHA256')
// hashes with SHA-256 then verifies, we use verify_prehash.
use p256::ecdsa::signature::hazmat::PrehashVerifier;
match verifying_key.verify_prehash(&digest, &sig) {
Ok(()) => Some(1),
Err(_) => Some(0),
}
}
/// Verify DSA signature (SHA-256 digest)
#[cfg(not(target_arch = "wasm32"))]
fn verify_dsa(message: &[u8], signature: &[u8], pubkey_der: &[u8]) -> Option<i32> {
use dsa::{VerifyingKey, Signature, signature::hazmat::PrehashVerifier};
use spki::DecodePublicKey;
use der::Decode as _;
let verifying_key = VerifyingKey::from_public_key_der(pubkey_der).ok()?;
// Parse DER signature
let sig = Signature::from_der(signature).ok()?;
// SHA-256 hash the message
let digest = {
let mut hasher = Sha256::new();
hasher.update(message);
hasher.finalize()
};
match verifying_key.verify_prehash(&digest, &sig) {
Ok(()) => Some(1),
Err(_) => Some(0),
}
}
+5 -17
View File
@@ -265,16 +265,8 @@ export function parseProof(proofBytes) {
let offset = 0;
// Salvium binary format: varint(V.len), V[], A, A1, B, r1, s1, d1, varint(L.len), L[], varint(R.len), R[]
// V (commitments)
const { value: vCount, bytesRead: vBytes } = _decodeVarint(proofBytes, offset);
offset += vBytes;
const V = [];
for (let i = 0; i < vCount; i++) {
V.push(bytesToPoint(proofBytes.slice(offset, offset + 32)));
offset += 32;
}
// Salvium binary format: A, A1, B, r1, s1, d1, varint(L.len), L[], varint(R.len), R[]
// Note: V (commitments) is NOT in the wire format — restored from outPk.
// A, A1, B (points)
const A = bytesToPoint(proofBytes.slice(offset, offset + 32));
@@ -310,7 +302,7 @@ export function parseProof(proofBytes) {
offset += 32;
}
return { V, A, A1, B, r1, s1, d1, L, R };
return { A, A1, B, r1, s1, d1, L, R };
}
function _decodeVarint(bytes, offset) {
@@ -1079,22 +1071,18 @@ export function bulletproofPlusProve(amounts, masks) {
* Serialize a Bulletproof+ proof to bytes
*/
export function serializeProof(proof) {
const { V, A, A1, B, r1, s1, d1, L, R } = proof;
const { A, A1, B, r1, s1, d1, L, R } = proof;
// Monero/Salvium binary format for BulletproofPlus:
// varint(V.length), V[0..n] (32 bytes each)
// A (32), A1 (32), B (32)
// r1 (32), s1 (32), d1 (32)
// varint(L.length), L[0..n] (32 bytes each)
// varint(R.length), R[0..n] (32 bytes each)
// Note: V (commitments) is NOT serialized — restored from outPk.
// Must match parseProof format for roundtrip compatibility.
const chunks = [];
// V (commitments)
chunks.push(_encodeVarint(V.length));
for (const v of V) chunks.push(v.toBytes());
// A, A1, B (points)
chunks.push(A.toBytes());
chunks.push(A1.toBytes());
+4 -2
View File
@@ -7,6 +7,8 @@
* Reference: salvium/src/cryptonote_config.h, cryptonote_basic_impl.cpp, difficulty.cpp
*/
import { bytesToHex } from './address.js';
// =============================================================================
// CORE CONSTANTS
// =============================================================================
@@ -891,11 +893,11 @@ export function validateBlockLinkage(currentHeader, previousHeader) {
// Check previous hash matches
const prevHashHex = typeof currentHeader.prevId === 'string'
? currentHeader.prevId
: Buffer.from(currentHeader.prevId).toString('hex');
: bytesToHex(new Uint8Array(currentHeader.prevId));
const expectedPrevHash = typeof previousHeader.hash === 'string'
? previousHeader.hash
: Buffer.from(previousHeader.hash).toString('hex');
: bytesToHex(new Uint8Array(previousHeader.hash));
if (prevHashHex !== expectedPrevHash) {
return {
+114
View File
@@ -9,6 +9,7 @@
import { keccak256 as jsKeccak } from '../keccak.js';
import { blake2b as jsBlake2b } from '../blake2b.js';
import { sha256 as nobleSha256 } from '@noble/hashes/sha2.js';
import {
scAdd, scSub, scMul, scMulAdd, scMulSub,
scReduce32, scReduce64, scInvert, scCheck, scIsZero
@@ -75,4 +76,117 @@ export class JsCryptoBackend {
commit(amount, mask) { return commit(amount, mask); }
zeroCommit(amount) { return zeroCommit(amount); }
genCommitmentMask(sharedSecret) { return genCommitmentMask(sharedSecret); }
// Oracle signature verification
sha256(data) { return nobleSha256(data); }
/**
* Verify signature using WebCrypto (browser/Node.js 15+) or Node.js crypto.
* @param {Uint8Array} message - Message bytes (will be SHA-256 hashed)
* @param {Uint8Array} signature - DER-encoded signature
* @param {Uint8Array} pubkeyDer - DER-encoded SPKI public key
* @returns {Promise<boolean>} True if valid
*/
async verifySignature(message, signature, pubkeyDer) {
// Try WebCrypto first (works in browsers and modern Node.js)
if (typeof globalThis.crypto?.subtle?.verify === 'function') {
return verifyWithWebCrypto(message, signature, pubkeyDer);
}
// Fall back to Node.js crypto module
return verifyWithNodeCrypto(message, signature, pubkeyDer);
}
}
/**
* Verify using WebCrypto API.
* Detects ECDSA P-256 vs DSA from the SPKI OID.
*/
async function verifyWithWebCrypto(message, signature, pubkeyDer) {
try {
// Detect algorithm from SPKI OID bytes
// ECDSA P-256 OID: 06 07 2a 86 48 ce 3d 02 01 (1.2.840.10045.2.1)
// DSA OID: 06 07 2a 86 48 ce 38 04 01 (1.2.840.10040.4.1)
const isEcdsa = containsBytes(pubkeyDer, [0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01]);
if (isEcdsa) {
// Convert DER signature to raw r||s format for WebCrypto
const rawSig = derSignatureToRaw(signature, 32);
const key = await globalThis.crypto.subtle.importKey(
'spki', pubkeyDer,
{ name: 'ECDSA', namedCurve: 'P-256' },
false, ['verify']
);
return await globalThis.crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
key, rawSig, message
);
}
// WebCrypto doesn't support DSA — fall back to Node.js crypto
return verifyWithNodeCrypto(message, signature, pubkeyDer);
} catch (_e) {
return false;
}
}
/**
* Verify using Node.js crypto module (createVerify).
* Works for both ECDSA and DSA.
*/
async function verifyWithNodeCrypto(message, signature, pubkeyDer) {
try {
const { createVerify } = await import('crypto');
// Wrap DER key in PEM
const b64 = typeof Buffer !== 'undefined'
? Buffer.from(pubkeyDer).toString('base64')
: btoa(String.fromCharCode(...pubkeyDer));
const pem = `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`;
const verifier = createVerify('SHA256');
verifier.update(message);
return verifier.verify(pem, Buffer.from(signature));
} catch (_e) {
return false;
}
}
/**
* Convert DER-encoded ECDSA signature to raw r||s format.
* WebCrypto expects raw format, not DER.
*/
function derSignatureToRaw(der, componentLen) {
// DER: 30 <len> 02 <rlen> <r> 02 <slen> <s>
let offset = 2; // skip SEQUENCE tag + length
if (der[0] !== 0x30) return der; // not DER, return as-is
// Parse r
if (der[offset] !== 0x02) return der;
const rLen = der[offset + 1];
const rStart = offset + 2;
offset = rStart + rLen;
// Parse s
if (der[offset] !== 0x02) return der;
const sLen = der[offset + 1];
const sStart = offset + 2;
const raw = new Uint8Array(componentLen * 2);
// Copy r (right-aligned, strip leading zeros)
const rBytes = der.slice(rStart, rStart + rLen);
const rTrim = rBytes[0] === 0 ? rBytes.slice(1) : rBytes;
raw.set(rTrim, componentLen - rTrim.length);
// Copy s (right-aligned, strip leading zeros)
const sBytes = der.slice(sStart, sStart + sLen);
const sTrim = sBytes[0] === 0 ? sBytes.slice(1) : sBytes;
raw.set(sTrim, componentLen * 2 - sTrim.length);
return raw;
}
/** Check if haystack contains needle bytes at any offset */
function containsBytes(haystack, needle) {
outer: for (let i = 0; i <= haystack.length - needle.length; i++) {
for (let j = 0; j < needle.length; j++) {
if (haystack[i + j] !== needle[j]) continue outer;
}
return true;
}
return false;
}
+11
View File
@@ -124,4 +124,15 @@ export class JsiCryptoBackend {
sharedSecret = ensureBytes(sharedSecret);
return this.native.genCommitmentMask(sharedSecret);
}
// ─── Oracle Signature Verification ──────────────────────────────────────
sha256(data) {
return this.native.sha256(data);
}
async verifySignature(message, signature, pubkeyDer) {
// Native module returns 1 for valid, 0 for invalid
return this.native.verifySignature(message, signature, pubkeyDer) === 1;
}
}
+82
View File
@@ -7,6 +7,8 @@
* @module crypto/backend-wasm
*/
import { sha256 as nobleSha256 } from '@noble/hashes/sha2.js';
let wasmExports = null;
/**
@@ -162,4 +164,84 @@ export class WasmCryptoBackend {
}
return this.wasm.gen_commitment_mask(sharedSecret);
}
// Oracle signature verification
sha256(data) { return nobleSha256(data); }
async verifySignature(message, signature, pubkeyDer) {
// WASM can't do ECDSA/DSA verification (native-only crates).
// Use WebCrypto (browser/Node 15+) or Node.js crypto as fallback.
if (typeof globalThis.crypto?.subtle?.verify === 'function') {
return wasmVerifyWebCrypto(message, signature, pubkeyDer);
}
return wasmVerifyNodeCrypto(message, signature, pubkeyDer);
}
}
// ─── WASM backend verify helpers (same logic as JS backend) ───────────────
async function wasmVerifyWebCrypto(message, signature, pubkeyDer) {
try {
const isEcdsa = containsBytesWasm(pubkeyDer, [0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01]);
if (isEcdsa) {
const rawSig = derToRawWasm(signature, 32);
const key = await globalThis.crypto.subtle.importKey(
'spki', pubkeyDer,
{ name: 'ECDSA', namedCurve: 'P-256' },
false, ['verify']
);
return await globalThis.crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
key, rawSig, message
);
}
return wasmVerifyNodeCrypto(message, signature, pubkeyDer);
} catch (_e) {
return false;
}
}
async function wasmVerifyNodeCrypto(message, signature, pubkeyDer) {
try {
const { createVerify } = await import('crypto');
const b64 = typeof Buffer !== 'undefined'
? Buffer.from(pubkeyDer).toString('base64')
: btoa(String.fromCharCode(...pubkeyDer));
const pem = `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`;
const verifier = createVerify('SHA256');
verifier.update(message);
return verifier.verify(pem, Buffer.from(signature));
} catch (_e) {
return false;
}
}
function derToRawWasm(der, componentLen) {
let offset = 2;
if (der[0] !== 0x30) return der;
if (der[offset] !== 0x02) return der;
const rLen = der[offset + 1];
const rStart = offset + 2;
offset = rStart + rLen;
if (der[offset] !== 0x02) return der;
const sLen = der[offset + 1];
const sStart = offset + 2;
const raw = new Uint8Array(componentLen * 2);
const rBytes = der.slice(rStart, rStart + rLen);
const rTrim = rBytes[0] === 0 ? rBytes.slice(1) : rBytes;
raw.set(rTrim, componentLen - rTrim.length);
const sBytes = der.slice(sStart, sStart + sLen);
const sTrim = sBytes[0] === 0 ? sBytes.slice(1) : sBytes;
raw.set(sTrim, componentLen * 2 - sTrim.length);
return raw;
}
function containsBytesWasm(haystack, needle) {
outer: for (let i = 0; i <= haystack.length - needle.length; i++) {
for (let j = 0; j < needle.length; j++) {
if (haystack[i + j] !== needle[j]) continue outer;
}
return true;
}
return false;
}
+2
View File
@@ -36,6 +36,8 @@ export {
ecdhDecode, ecdhDecodeFull, ecdhEncode,
// Pedersen commitments
commit, zeroCommit, genCommitmentMask,
// Oracle signature verification
sha256, verifySignature,
// CARROT key derivation
computeCarrotSpendPubkey, computeCarrotAccountViewPubkey,
computeCarrotMainAddressViewPubkey,
+18 -9
View File
@@ -32,15 +32,18 @@ export async function setCryptoBackend(type) {
currentBackend = new JsiCryptoBackend();
await currentBackend.init();
} else if (type === 'wasm') {
// Dynamic import() is not supported in React Native/Hermes.
// In Node/Bun, import backend-wasm.js directly:
// import { WasmCryptoBackend } from './backend-wasm.js';
// const backend = new WasmCryptoBackend(); await backend.init();
throw new Error(
"WASM backend cannot be loaded via setCryptoBackend() — dynamic import() " +
"is not available in all runtimes (e.g. Hermes). In Node/Bun, import " +
"WasmCryptoBackend from './crypto/backend-wasm.js' directly."
);
// Dynamic import() works in Node/Bun but not React Native/Hermes.
// On Hermes, use 'jsi' backend instead.
try {
const { WasmCryptoBackend } = await import('./backend-wasm.js');
currentBackend = new WasmCryptoBackend();
await currentBackend.init();
} catch (e) {
throw new Error(
"WASM backend failed to load: " + e.message + ". " +
"On React Native/Hermes, use setCryptoBackend('jsi') instead."
);
}
} else {
throw new Error(`Unknown crypto backend: ${type}. Use 'js', 'wasm', or 'jsi'.`);
}
@@ -111,6 +114,12 @@ export function commit(amount, mask) { return getCryptoBackend().commit(amount,
export function zeroCommit(amount) { return getCryptoBackend().zeroCommit(amount); }
export function genCommitmentMask(sharedSecret) { return getCryptoBackend().genCommitmentMask(sharedSecret); }
// Oracle signature verification
export function sha256(data) { return getCryptoBackend().sha256(data); }
export async function verifySignature(message, signature, pubkeyDer) {
return getCryptoBackend().verifySignature(message, signature, pubkeyDer);
}
// =============================================================================
// Composite functions — built on top of backend primitives
// =============================================================================
+77 -30
View File
@@ -11,7 +11,7 @@
* @module oracle
*/
import { createHash, createVerify } from 'crypto';
import { verifySignature } from './crypto/provider.js';
// ============================================================================
// CONSTANTS
@@ -204,6 +204,55 @@ export function getAssetMaPrice(pr, assetType) {
// SIGNATURE VERIFICATION
// ============================================================================
/**
* Convert PEM-encoded public key to DER bytes.
* Strips the -----BEGIN/END PUBLIC KEY----- headers and base64-decodes.
*
* @param {string} pem - PEM-encoded public key
* @returns {Uint8Array} DER-encoded SPKI public key
*/
function pemToDer(pem) {
const b64 = pem
.replace(/-----BEGIN PUBLIC KEY-----/, '')
.replace(/-----END PUBLIC KEY-----/, '')
.replace(/\s+/g, '');
return _base64Decode(b64);
}
/** Decode base64 string to Uint8Array (works on QuickJS, browsers, Node.js) */
function _base64Decode(b64) {
if (typeof Buffer !== 'undefined') {
return new Uint8Array(Buffer.from(b64, 'base64'));
}
if (typeof atob === 'function') {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// Manual base64 decoder for QuickJS and other minimal runtimes
const lookup = new Uint8Array(128);
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
for (let i = 0; i < chars.length; i++) lookup[chars.charCodeAt(i)] = i;
const len = b64.length;
let pad = 0;
if (b64[len - 1] === '=') pad++;
if (b64[len - 2] === '=') pad++;
const outLen = (len * 3 / 4) - pad;
const bytes = new Uint8Array(outLen);
let j = 0;
for (let i = 0; i < len; i += 4) {
const a = lookup[b64.charCodeAt(i)];
const b = lookup[b64.charCodeAt(i + 1)];
const c = lookup[b64.charCodeAt(i + 2)];
const d = lookup[b64.charCodeAt(i + 3)];
bytes[j++] = (a << 2) | (b >> 4);
if (j < outLen) bytes[j++] = ((b & 15) << 4) | (c >> 2);
if (j < outLen) bytes[j++] = ((c & 3) << 6) | d;
}
return bytes;
}
/**
* Build the JSON message that was signed
*
@@ -241,30 +290,32 @@ export function buildSignatureMessage(pr) {
/**
* Verify pricing record signature
*
* Uses the crypto provider's verifySignature which dispatches to the active
* backend (Rust FFI, WASM, WebCrypto, or Node.js crypto). No direct
* dependency on Node.js crypto module.
*
* Reference: ~/github/salvium/src/oracle/pricing_record.cpp (verifySignature)
*
* @param {PricingRecord} pr - Pricing record to verify
* @param {string} publicKeyPem - Oracle public key in PEM format
* @returns {boolean} True if signature is valid
* @returns {Promise<boolean>} True if signature is valid
*/
export function verifyPricingRecordSignature(pr, publicKeyPem) {
export async function verifyPricingRecordSignature(pr, publicKeyPem) {
if (!pr.signature || pr.signature.length === 0) {
return false;
}
try {
// Build the message that was signed
// Build the message that was signed and encode to UTF-8 bytes
const message = buildSignatureMessage(pr);
const messageBytes = new TextEncoder().encode(message);
// Create verifier
// Note: The mainnet key is DSA, testnet is ECDSA
// Node.js crypto handles both with the 'dsa' or 'ecdsa' algorithm
const verifier = createVerify('SHA256');
verifier.update(message);
// Convert PEM public key to DER bytes for the crypto provider
const pubkeyDer = pemToDer(publicKeyPem);
// Verify the signature
return verifier.verify(publicKeyPem, Buffer.from(pr.signature));
} catch (error) {
// Verify via crypto provider (backend handles SHA-256 hashing internally)
return await verifySignature(messageBytes, pr.signature, pubkeyDer);
} catch (_e) {
// Signature verification failed
return false;
}
@@ -300,7 +351,7 @@ export function getOraclePublicKey(network = 'mainnet') {
* @param {number} options.lastBlockTimestamp - Previous block timestamp
* @returns {{valid: boolean, error?: string}} Validation result
*/
export function validatePricingRecord(pr, options = {}) {
export async function validatePricingRecord(pr, options = {}) {
const {
network = 'mainnet',
hfVersion = 0,
@@ -322,7 +373,7 @@ export function validatePricingRecord(pr, options = {}) {
// Rule 3: Signature must be valid
const publicKey = getOraclePublicKey(network);
if (!verifyPricingRecordSignature(pr, publicKey)) {
if (!await verifyPricingRecordSignature(pr, publicKey)) {
return { valid: false, error: 'Invalid pricing record signature' };
}
@@ -600,25 +651,21 @@ export async function fetchPricingRecord(options = {}) {
} = options;
// Build request URL
const queryParams = new URLSearchParams({
height: height.toString(),
sal: salSupply.toString(),
vsd: vsdSupply.toString()
});
const queryString = [
`height=${encodeURIComponent(height.toString())}`,
`sal=${encodeURIComponent(salSupply.toString())}`,
`vsd=${encodeURIComponent(vsdSupply.toString())}`,
].join('&');
const fullUrl = `${url}/price?${queryParams}`;
const fullUrl = `${url}/price?${queryString}`;
try {
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(fullUrl, {
method: 'GET',
signal: controller.signal
});
clearTimeout(timeoutId);
const response = await Promise.race([
fetch(fullUrl, { method: 'GET' }),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Oracle request timed out')), timeout)
),
]);
if (!response.ok) {
return {
+1 -1
View File
@@ -24,7 +24,7 @@ export const DATASET_SIZE = RANDOMX_DATASET_ITEM_COUNT * DATASET_ITEM_SIZE;
export function getCpuCount() {
try {
return cpus().length;
} catch {
} catch (_e) {
if (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) {
return navigator.hardwareConcurrency;
}
+1 -1
View File
@@ -64,7 +64,7 @@ export function getAvailableCores() {
try {
const os = require('os');
return os.cpus().length;
} catch {
} catch (_e) {
return 4; // Default fallback
}
}
+27 -21
View File
@@ -12,6 +12,21 @@
import { ConnectionManager, SEED_NODES } from './connection-manager.js';
/** Base64 encode a string (works in QuickJS, browsers, Node.js) */
function _toBase64(str) {
if (typeof btoa === 'function') return btoa(str);
const bytes = new TextEncoder().encode(str);
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let result = '';
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i], b = bytes[i + 1] || 0, c = bytes[i + 2] || 0;
result += chars[a >> 2] + chars[((a & 3) << 4) | (b >> 4)]
+ (i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '=')
+ (i + 2 < bytes.length ? chars[c & 63] : '=');
}
return result;
}
/**
* @typedef {Object} RPCClientOptions
* @property {string} url - RPC server URL (e.g., 'http://localhost:19081')
@@ -151,7 +166,7 @@ export class RPCClient {
// Add basic auth if credentials provided
if (this.username && this.password) {
const credentials = btoa(`${this.username}:${this.password}`);
const credentials = _toBase64(`${this.username}:${this.password}`);
headers['Authorization'] = `Basic ${credentials}`;
}
@@ -176,18 +191,12 @@ export class RPCClient {
* @private
*/
async _fetchWithTimeout(url, options) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
return response;
} finally {
clearTimeout(timeoutId);
}
return Promise.race([
fetch(url, options),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), this.timeout)
),
]);
}
/**
@@ -519,13 +528,10 @@ export class RPCClient {
: `${this.url}/${endpoint}`;
// Add query parameters
const queryParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
queryParams.append(key, String(value));
}
}
const queryString = queryParams.toString();
const queryString = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&');
if (queryString) {
url += `?${queryString}`;
}
@@ -597,7 +603,7 @@ export class RPCClient {
headers: this._buildHeaders()
});
return response.ok || response.status === 404; // 404 is OK, server is reachable
} catch {
} catch (_e) {
return false;
}
}
+13 -13
View File
@@ -83,31 +83,31 @@ export class ConnectionManager {
this._racing = true;
try {
const results = await Promise.allSettled(
const results = await Promise.all(
this.urls.map(async (url) => {
const start = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.raceTimeout);
try {
const resp = await fetch(`${url.replace(/\/+$/, '')}/get_info`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
signal: controller.signal,
});
clearTimeout(timer);
const resp = await Promise.race([
fetch(`${url.replace(/\/+$/, '')}/get_info`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Race timeout')), this.raceTimeout)
),
]);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
await resp.json(); // consume body
const latency = Date.now() - start;
this._latencies.set(url, latency);
return { url, latency };
return { status: 'fulfilled', value: { url, latency } };
} catch (e) {
clearTimeout(timer);
this._latencies.set(url, Infinity);
throw e;
return { status: 'rejected', reason: e };
}
})
);
+21 -14
View File
@@ -1198,9 +1198,11 @@ export class GammaPicker {
this.shape = options.shape || GAMMA_SHAPE;
this.scale = options.scale || GAMMA_SCALE;
// Use coinbase maturity window (60) as the exclusion zone to ensure
// all selected decoys are unlocked (coinbase outputs need 60 blocks)
const unlockExclusion = CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW;
// Match C++ gamma_picker: use DEFAULT_TX_SPENDABLE_AGE (10) as the
// exclusion zone, not MINED_MONEY_UNLOCK_WINDOW (60). Locked coinbase
// outputs still have valid public keys and are fine as ring decoys.
// The C++ wallet over-requests outputs to compensate for locked picks.
const unlockExclusion = CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE;
if (rctOffsets.length <= unlockExclusion) {
throw new Error('Not enough blocks for decoy selection');
@@ -1276,7 +1278,7 @@ export class GammaPicker {
findBlockIndex(outputIndex) {
// Binary search
let low = 0;
let high = this.rctOffsets.length - CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW;
let high = this.rctOffsets.length - CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE;
while (low < high) {
const mid = Math.floor((low + high) / 2);
@@ -2151,16 +2153,21 @@ export function buildTransaction(params, options = {}) {
// Generate Bulletproofs+ range proofs BEFORE pre-MLSAG hash
// (C++ genRctSimple generates BP+ first, then hashes components for CLSAG message)
const bpAmounts = outputs.map(o => o.amount);
const bpMasks = outputMasks.map(m => {
const bytes = typeof m === 'string' ? hexToBytes(m) : m;
return _bytesToBigInt(bytes);
});
const bpProof = bulletproofPlusProve(bpAmounts, bpMasks);
const bulletproofPlus = {
...bpProof,
serialized: serializeBpPlus(bpProof)
};
// Skip for transactions with no outputs (e.g. AUDIT — all coins locked, no change)
let bpProof = null;
let bulletproofPlus = null;
if (outputs.length > 0) {
const bpAmounts = outputs.map(o => o.amount);
const bpMasks = outputMasks.map(m => {
const bytes = typeof m === 'string' ? hexToBytes(m) : m;
return _bytesToBigInt(bytes);
});
bpProof = bulletproofPlusProve(bpAmounts, bpMasks);
bulletproofPlus = {
...bpProof,
serialized: serializeBpPlus(bpProof)
};
}
// Build RingCT base (needed for pre-MLSAG hash)
// Must include ecdhInfo, outPk, p_r, and salvium_data — matches C++ serialize_rctsig_base
+4 -3
View File
@@ -34,6 +34,7 @@ import {
} from './consensus.js';
import { TX_TYPE, RCT_TYPE } from './transaction.js';
import { hexToBytes } from './address.js';
// =============================================================================
// CONSTANTS
@@ -372,7 +373,7 @@ export function validateOutputPubkeySorting(tx, hfVersion) {
if (!key) continue;
const keyBytes = typeof key === 'string'
? Buffer.from(key, 'hex')
? hexToBytes(key)
: key;
if (prevKey !== null) {
@@ -533,10 +534,10 @@ export function validateInputs(tx, hfVersion) {
const keyImage = input.k_image || input.keyImage;
if (keyImage && prevKeyImage) {
const kiBytes = typeof keyImage === 'string'
? Buffer.from(keyImage, 'hex')
? hexToBytes(keyImage)
: keyImage;
const prevKiBytes = typeof prevKeyImage === 'string'
? Buffer.from(prevKeyImage, 'hex')
? hexToBytes(prevKeyImage)
: prevKeyImage;
// Compare lexicographically (must be strictly increasing)
+2 -2
View File
@@ -166,7 +166,7 @@ export function decryptWalletJSON(envelope, password) {
let plaintext;
try {
plaintext = gcm(encryptionKey, hexToBytes(enc.iv)).decrypt(hexToBytes(enc.ciphertext));
} catch {
} catch (_e) {
throw new Error('Decryption failed: incorrect password or corrupted data');
}
@@ -254,7 +254,7 @@ export function encryptData(key, plaintext) {
export function decryptData(key, envelope) {
try {
return gcm(key, hexToBytes(envelope.iv)).decrypt(hexToBytes(envelope.ciphertext));
} catch {
} catch (_e) {
throw new Error('Data decryption failed: wrong key or corrupted data');
}
}
+6 -3
View File
@@ -462,9 +462,12 @@ export class WalletSync {
: new Uint8Array(binBlock.block);
const parsed = parseBlock(blockBlob);
// Compute miner_tx and protocol_tx hashes from the raw blob
const minerTxHash = this._computeTxHashFromBlob(blockBlob, parsed.minerTx);
const protocolTxHash = this._computeTxHashFromBlob(blockBlob, parsed.protocolTx);
// Use tx hashes from block header (reliable) instead of computing from blob
// (Salvium v3 tx hashing differs from Monero v2 — blob-based hash is wrong)
const minerTxHash = header.miner_tx_hash
|| this._computeTxHashFromBlob(blockBlob, parsed.minerTx);
const protocolTxHash = header.protocol_tx_hash
|| this._computeTxHashFromBlob(blockBlob, parsed.protocolTx);
// Process miner_tx (coinbase)
if (parsed.minerTx && minerTxHash) {
+1 -1
View File
@@ -170,7 +170,7 @@ function extractRejectionReason(respData) {
try {
const detail = JSON.stringify(respData, null, 0);
return detail.length > 200 ? `${status} (response too large)` : `${status}: ${detail}`;
} catch {
} catch (_e) {
return status;
}
}
+5 -10
View File
@@ -245,15 +245,11 @@ test('multiScalarMul with empty arrays', () => {
console.log('\n--- Proof Parsing Tests ---');
test('parseProof extracts correct structure', () => {
// Build a proof in Salvium binary format:
// varint(V.len), V[], A, A1, B, r1, s1, d1, varint(L.len), L[], varint(R.len), R[]
// Build a proof in Salvium binary format (V is NOT serialized — restored from outPk):
// A, A1, B, r1, s1, d1, varint(L.len), L[], varint(R.len), R[]
const baseBytes = Point.BASE.toBytes();
const chunks = [];
// V: 1 commitment
chunks.push(new Uint8Array([1])); // varint(1)
chunks.push(baseBytes);
// A, A1, B
chunks.push(baseBytes);
chunks.push(baseBytes);
@@ -286,7 +282,6 @@ test('parseProof extracts correct structure', () => {
assertExists(proof.A);
assertExists(proof.A1);
assertExists(proof.B);
assertEqual(proof.V.length, 1);
assertEqual(proof.r1, 1n);
assertEqual(proof.s1, 2n);
assertEqual(proof.d1, 3n);
@@ -467,9 +462,9 @@ test('serializeProof produces correct size', () => {
const proof = proveRange(amount, mask);
const bytes = serializeProof(proof);
// Salvium binary format: varint(V.len) + V + A + A1 + B + r1 + s1 + d1 + varint(L.len) + L + varint(R.len) + R
// For single amount: 1 + 32 + 3*32 + 3*32 + 1 + 6*32 + 1 + 6*32 = 611 bytes
assertEqual(bytes.length, 611, 'Serialized proof should be 611 bytes');
// Salvium binary format (no V): A + A1 + B + r1 + s1 + d1 + varint(L.len) + L + varint(R.len) + R
// For single amount: 3*32 + 3*32 + 1 + 6*32 + 1 + 6*32 = 578 bytes
assertEqual(bytes.length, 578, 'Serialized proof should be 578 bytes');
});
test('serialized proof can be parsed and verified', () => {
+9 -7
View File
@@ -37,12 +37,14 @@ const WALLET_DIR = join(homedir(), 'testnet-wallet');
const LOG_PATH = join(WALLET_DIR, 'full-testnet-log.json');
const DEFAULT_DAEMON = 'http://web.whiskymine.io:29081';
// Target heights (HF activation + coinbase maturity window)
// Target heights (HF activation + coinbase maturity + ring buffer)
// Coinbase outputs lock for 60 blocks. After a fork, new-era outputs need
// to unlock before they can be spent. Need: 60 (lock) + 16 (ring size) + 4 buffer = 80.
const TARGET = {
HF2_MATURE: 270, // HF2 at 250 + 20 extra
HF6_MATURE: 835, // HF6 at 815 + 20 extra
CARROT_MATURE: 1120, // HF10 at 1100 + 20 extra
FINAL: 1200,
HF2_MATURE: 270, // HF2 at 250 + 20 (SAL outputs already plentiful)
HF6_MATURE: 895, // HF6 at 815 + 80 (SAL1 coinbase needs 60-block unlock)
CARROT_MATURE: 1180, // HF10 at 1100 + 80 (same coinbase maturity requirement)
FINAL: 1260,
};
const MATURITY_BLOCKS = 10; // blocks to mine after TX for spendable age
@@ -344,7 +346,7 @@ async function phase2_cnTxTests(daemon, daemonUrl, walletA, walletB) {
}
async function phase3_mineToHF6(daemon, daemonUrl, walletA) {
console.log('\n═══ Phase 3: Mine to HF6 / SAL1 (target height 835) ═══');
console.log(`\n═══ Phase 3: Mine to HF6 / SAL1 (target height ${TARGET.HF6_MATURE}) ═══`);
const t0 = performance.now();
const address = walletA.getLegacyAddress();
await mineTo(daemon, TARGET.HF6_MATURE, address, daemonUrl, 'rust');
@@ -391,7 +393,7 @@ async function phase4_sal1TxTests(daemon, daemonUrl, walletA, walletB) {
}
async function phase5_mineToCarrot(daemon, daemonUrl, walletA) {
console.log('\n═══ Phase 5: Mine to CARROT (target height 1120) ═══');
console.log(`\n═══ Phase 5: Mine to CARROT (target height ${TARGET.CARROT_MATURE}) ═══`);
const t0 = performance.now();
const address = walletA.getLegacyAddress();
+8 -8
View File
@@ -306,17 +306,17 @@ describe('Pricing Record Validation', () => {
describe('validatePricingRecord', () => {
test('empty record is always valid', () => {
test('empty record is always valid', async () => {
const pr = createEmptyPricingRecord();
const result = validatePricingRecord(pr);
const result = await validatePricingRecord(pr);
expect(result.valid).toBe(true);
});
test('non-empty record requires HF >= HF_VERSION_SLIPPAGE_YIELD', () => {
test('non-empty record requires HF >= HF_VERSION_SLIPPAGE_YIELD', async () => {
const pr = createMockPricingRecord();
// Before HF
const result1 = validatePricingRecord(pr, { hfVersion: 5 });
const result1 = await validatePricingRecord(pr, { hfVersion: 5 });
expect(result1.valid).toBe(false);
expect(result1.error).toContain('not allowed before oracle HF');
@@ -324,14 +324,14 @@ describe('Pricing Record Validation', () => {
// Just testing the HF gate here
});
test('rejects timestamp too far in future', () => {
test('rejects timestamp too far in future', async () => {
const pr = createMockPricingRecord();
const currentTime = Math.floor(Date.now() / 1000);
// Set timestamp 200 seconds in future (> 120 seconds allowed)
pr.timestamp = currentTime + 200;
const result = validatePricingRecord(pr, {
const result = await validatePricingRecord(pr, {
hfVersion: 255,
blockTimestamp: currentTime
});
@@ -340,14 +340,14 @@ describe('Pricing Record Validation', () => {
// but if we had a valid signature, timestamp would be checked
});
test('rejects timestamp older than previous block', () => {
test('rejects timestamp older than previous block', async () => {
const pr = createMockPricingRecord();
const currentTime = Math.floor(Date.now() / 1000);
// Set timestamp older than last block
pr.timestamp = currentTime - 100;
const result = validatePricingRecord(pr, {
const result = await validatePricingRecord(pr, {
hfVersion: 255,
blockTimestamp: currentTime,
lastBlockTimestamp: currentTime - 50 // Previous block was 50 seconds ago