Add STAKE transaction creation and fix CLSAG verification

STAKE transactions:
  - Add buildStakeTransaction() for creating stake transactions
  - Add createStakeTransaction() wallet API for staking SAL/SAL1
  - Extend serializeTxPrefix() with Salvium-specific fields (txType,
    amount_burnt, asset types, return address, protocol_tx_data)
  - Support STAKE_LOCK_PERIOD from network config (21600 mainnet, 20 testnet)

  CLSAG fixes:
  - Fix challenge hash to include full ring data matching C++ implementation
  - Update both signing and verification to use consistent hash format
This commit is contained in:
Matt Hess
2026-01-22 03:10:40 +00:00
parent e751b6569a
commit aa716113f5
15 changed files with 4380 additions and 325 deletions
+859
View File
@@ -0,0 +1,859 @@
/**
* CARROT Output Scanning Module
*
* Implements CARROT-specific output detection for Salvium.
* CARROT uses X25519 ECDH (Montgomery curve) instead of ed25519.
*
* Key differences from CryptoNote scanning:
* - Uses X25519 for key exchange (view_key_scalar_mult_x25519)
* - 3-byte view tag (vs 1-byte in CryptoNote)
* - Encrypted janus anchor for additional verification
* - Different address spend pubkey recovery
*
* References:
* - Salvium carrot_core/scan.cpp
* - Salvium carrot_core/enote_utils.cpp
*/
import { blake2b } from './blake2b.js';
import { keccak256 } from './keccak.js';
import { hexToBytes, bytesToHex } from './address.js';
import { scalarMultPoint, pointFromBytes, pointToBytes } from './ed25519.js';
// Group order L for scalar reduction
const L = (1n << 252n) + 27742317777372353535851937790883648493n;
// ============================================================================
// X25519 Implementation (Montgomery Curve)
// ============================================================================
/**
* Convert ed25519 point (compressed) to X25519 (Montgomery u-coordinate)
* The Montgomery u-coordinate is: u = (1 + y) / (1 - y) mod p
* where y is the ed25519 y-coordinate
*
* @param {Uint8Array} edPoint - 32-byte ed25519 compressed point
* @returns {Uint8Array} 32-byte X25519 u-coordinate
*/
export function edwardsToMontgomeryU(edPoint) {
// Ed25519 compressed format: sign bit in MSB of last byte, y-coordinate in rest
// Extract y-coordinate
const p = 2n ** 255n - 19n;
let y = 0n;
for (let i = 0; i < 32; i++) {
y |= BigInt(edPoint[i]) << (8n * BigInt(i));
}
// Clear the sign bit
y &= (1n << 255n) - 1n;
// u = (1 + y) / (1 - y) mod p
const one = 1n;
const numerator = (one + y) % p;
const denominator = (p + one - y) % p;
// Compute modular inverse of denominator using Fermat's little theorem
// p is prime, so denominator^(p-2) = denominator^(-1) mod p
const invDenom = modPow(denominator, p - 2n, p);
const u = (numerator * invDenom) % p;
// Convert to bytes (little-endian)
const result = new Uint8Array(32);
let val = u;
for (let i = 0; i < 32; i++) {
result[i] = Number(val & 0xffn);
val >>= 8n;
}
return result;
}
/**
* Modular exponentiation: base^exp mod mod
*/
function modPow(base, exp, mod) {
let result = 1n;
base = base % mod;
while (exp > 0n) {
if (exp % 2n === 1n) {
result = (result * base) % mod;
}
exp = exp >> 1n;
base = (base * base) % mod;
}
return result;
}
/**
* X25519 scalar multiplication on Montgomery curve
* Computes scalar * u where u is a Montgomery u-coordinate
*
* This implements Salvium's mx25519, which differs from RFC 7748:
* - Does NOT clear bits 0-2 of scalar (caller must do this if needed)
* - Only clears bit 255
* - Does NOT set bit 254
* - Uses formula z2 = E * (BB + a24 * E) with a24 = 121666
*
* @param {Uint8Array} scalar - 32-byte scalar
* @param {Uint8Array} u - 32-byte Montgomery u-coordinate
* @returns {Uint8Array} 32-byte result u-coordinate
*/
export function x25519ScalarMult(scalar, u) {
const p = 2n ** 255n - 19n;
const a24 = 121666n; // Salvium uses 121666, not 121665
// Clamp the scalar as per Salvium's mx25519:
// - Do NOT clear bits 0-2 (unlike standard X25519)
// - Clear bit 255
// - Do NOT set bit 254 (unlike standard X25519)
const k = new Uint8Array(scalar);
// k[0] &= 248; // Salvium does NOT clear bits 0-2
k[31] &= 127; // Only clear bit 255
// Convert inputs to BigInt
let kVal = 0n;
let uVal = 0n;
for (let i = 0; i < 32; i++) {
kVal |= BigInt(k[i]) << (8n * BigInt(i));
uVal |= BigInt(u[i]) << (8n * BigInt(i));
}
uVal &= (1n << 255n) - 1n; // Clear top bit
// Montgomery ladder
let x1 = uVal;
let x2 = 1n;
let z2 = 0n;
let x3 = uVal;
let z3 = 1n;
let swap = 0n;
// Process bits from 254 down to 0
for (let t = 254; t >= 0; t--) {
const kt = (kVal >> BigInt(t)) & 1n;
swap ^= kt;
// Conditional swap
if (swap) {
[x2, x3] = [x3, x2];
[z2, z3] = [z3, z2];
}
swap = kt;
// Montgomery ladder step (matching Salvium's mx25519 portable implementation)
const D = (p + x3 - z3) % p; // tmp0 = x3 - z3
const B = (p + x2 - z2) % p; // tmp1 = x2 - z2
const A = (x2 + z2) % p; // x2 = x2 + z2 (reusing as A)
const C = (x3 + z3) % p; // z2 = x3 + z3 (reusing as C)
const DA = (D * A) % p; // z3 = D * A
const CB = (C * B) % p; // z2 = C * B
const BB = (B * B) % p; // tmp0 = B^2
const AA = (A * A) % p; // tmp1 = A^2
const x3_new = ((DA + CB) % p) ** 2n % p; // x3 = (DA + CB)^2
const diff = (p + DA - CB) % p;
const z2_diff = (diff * diff) % p; // z2 = (DA - CB)^2
const x2_new = (AA * BB) % p; // x2 = AA * BB
const E = (p + AA - BB) % p; // tmp1 = AA - BB = E
const z3_new = (x1 * z2_diff) % p; // z3 = x1 * (DA - CB)^2
const a24E = (a24 * E) % p; // z3 = a24 * E
const z2_new = (E * ((BB + a24E) % p)) % p; // z2 = E * (BB + a24 * E)
x2 = x2_new;
z2 = z2_new;
x3 = x3_new;
z3 = z3_new;
}
// Final conditional swap
if (swap) {
[x2, x3] = [x3, x2];
[z2, z3] = [z3, z2];
}
// Compute result = x2 / z2
const z2Inv = modPow(z2, p - 2n, p);
const result = (x2 * z2Inv) % p;
// Convert to bytes
const out = new Uint8Array(32);
let val = result;
for (let i = 0; i < 32; i++) {
out[i] = Number(val & 0xffn);
val >>= 8n;
}
return out;
}
/**
* Perform X25519 ECDH key exchange for CARROT scanning
* s_sr = k_vi * D_e (where D_e is the enote ephemeral pubkey)
*
* @param {Uint8Array} viewIncomingKey - 32-byte view-incoming key (k_vi)
* @param {Uint8Array} enoteEphemeralPubkey - 32-byte enote ephemeral pubkey (D_e)
* @returns {Uint8Array} 32-byte shared secret (s_sender_receiver_unctx)
*/
export function carrotEcdhKeyExchange(viewIncomingKey, enoteEphemeralPubkey) {
// enoteEphemeralPubkey (D_e / p_r) is ALREADY in X25519 format (Montgomery u-coordinate)
// No conversion needed - just perform X25519 scalar multiplication directly
// s_sr = k_vi * D_e
const sharedSecret = x25519ScalarMult(viewIncomingKey, enoteEphemeralPubkey);
return sharedSecret;
}
// ============================================================================
// CARROT View Tag
// ============================================================================
/**
* Compute CARROT view tag (3 bytes)
* vt = H_3[s_sr_unctx]("Carrot view tag", input_context, Ko)
*
* Uses blake2b keyed hash: transcript is domain || input_context || Ko
* Key is s_sr_unctx (32 bytes)
*
* @param {Uint8Array} senderReceiverUnctx - 32-byte uncontextualized shared secret
* @param {Uint8Array} inputContext - Input context bytes
* @param {Uint8Array} onetimeAddress - 32-byte onetime address (Ko)
* @returns {Uint8Array} 3-byte view tag
*/
export function computeCarrotViewTag(senderReceiverUnctx, inputContext, onetimeAddress) {
// Build transcript: [length_byte] || domain_sep || input_context || Ko
// SpFixedTranscript format: domain separator is length-prefixed with a single byte
const domainSep = new TextEncoder().encode('Carrot view tag');
const data = new Uint8Array(1 + domainSep.length + inputContext.length + 32);
let offset = 0;
data[offset++] = domainSep.length; // Length prefix byte
data.set(domainSep, offset); offset += domainSep.length;
data.set(inputContext, offset); offset += inputContext.length;
data.set(onetimeAddress, offset);
// Blake2b with s_sr_unctx as key, output 3 bytes
return blake2b(data, 3, senderReceiverUnctx);
}
/**
* Test CARROT view tag
*
* @param {Uint8Array} senderReceiverUnctx - 32-byte uncontextualized shared secret
* @param {Uint8Array} inputContext - Input context bytes
* @param {Uint8Array} onetimeAddress - 32-byte onetime address
* @param {Uint8Array} viewTag - 3-byte view tag from the enote
* @returns {boolean} True if view tag matches
*/
export function testCarrotViewTag(senderReceiverUnctx, inputContext, onetimeAddress, viewTag) {
const expected = computeCarrotViewTag(senderReceiverUnctx, inputContext, onetimeAddress);
return expected[0] === viewTag[0] &&
expected[1] === viewTag[1] &&
expected[2] === viewTag[2];
}
// ============================================================================
// CARROT Input Context
// ============================================================================
/**
* Make input context for a regular (RingCT) transaction
* input_context = "R" || first_key_image (33 bytes total)
*
* @param {Uint8Array|string} firstKeyImage - First key image from transaction inputs (32 bytes)
* @returns {Uint8Array} Input context (33 bytes)
*/
export function makeInputContext(firstKeyImage) {
if (typeof firstKeyImage === 'string') {
firstKeyImage = hexToBytes(firstKeyImage);
}
// input_context_t is always 33 bytes: 1 byte type + 32 bytes data
const result = new Uint8Array(33);
result[0] = 0x52; // 'R' for RingCT
result.set(firstKeyImage, 1);
return result;
}
/**
* Make input context for coinbase transaction
* input_context = "C" || block_height_LE_8bytes || zeros_24bytes (33 bytes total)
*
* @param {number} blockHeight - Block height
* @returns {Uint8Array} Input context (33 bytes)
*/
export function makeInputContextCoinbase(blockHeight) {
// input_context_t is always 33 bytes: 1 byte type + 32 bytes data
const result = new Uint8Array(33);
result[0] = 0x43; // 'C' for Coinbase
// Block height as 8-byte little-endian at bytes 1-8
let h = BigInt(blockHeight);
for (let i = 0; i < 8; i++) {
result[1 + i] = Number(h & 0xffn);
h >>= 8n;
}
// Bytes 9-32 are already zero (padding)
return result;
}
// ============================================================================
// CARROT Domain Separators (matching config.h)
// ============================================================================
const CARROT_DOMAIN = {
SENDER_RECEIVER_SECRET: 'Carrot sender-receiver secret',
VIEW_TAG: 'Carrot view tag',
COMMITMENT_MASK: 'Carrot commitment mask',
ONETIME_EXTENSION_G: 'Carrot key extension G',
ONETIME_EXTENSION_T: 'Carrot key extension T',
ENCRYPTION_MASK_AMOUNT: 'Carrot encryption mask a',
ENCRYPTION_MASK_PAYMENT_ID: 'Carrot encryption mask pid'
};
// ============================================================================
// CARROT Sender-Receiver Secret
// ============================================================================
/**
* Compute contextualized sender-receiver secret
* s^ctx_sr = H_32[s_sr_unctx]("Carrot sender-receiver secret", D_e, input_context)
* Key = s_sender_receiver_unctx, transcript = D_e + input_context
*
* @param {Uint8Array} senderReceiverUnctx - 32-byte uncontextualized shared secret (KEY)
* @param {Uint8Array} enoteEphemeralPubkey - 32-byte enote ephemeral pubkey (D_e)
* @param {Uint8Array} inputContext - Input context (33 bytes)
* @returns {Uint8Array} 32-byte contextualized shared secret
*/
export function makeCarrotSenderReceiverSecret(senderReceiverUnctx, enoteEphemeralPubkey, inputContext) {
// Key is s_sender_receiver_unctx, transcript is D_e + input_context
// We need to build: [len] + "Carrot sender-receiver secret" + D_e + input_context
// Then use blake2b with s_sender_receiver_unctx as key
const domainBytes = new TextEncoder().encode(CARROT_DOMAIN.SENDER_RECEIVER_SECRET);
const domainLen = domainBytes.length;
// Transcript: [len] + domain + D_e + input_context
const transcript = new Uint8Array(1 + domainLen + 32 + inputContext.length);
let offset = 0;
transcript[offset++] = domainLen;
transcript.set(domainBytes, offset); offset += domainLen;
transcript.set(enoteEphemeralPubkey, offset); offset += 32;
transcript.set(inputContext, offset);
// blake2b with s_sender_receiver_unctx as key, 32-byte output
return blake2b(transcript, 32, senderReceiverUnctx);
}
// ============================================================================
// CARROT Hash Functions (matching Salvium's derive_* functions)
// ============================================================================
/**
* Build SpFixedTranscript: [len_byte] + domain_sep + args...
* @param {string} domain - Domain separator string
* @param {Array<Uint8Array>} args - Data arguments
* @returns {Uint8Array} Transcript bytes
*/
function makeTranscript(domain, ...args) {
const domainBytes = new TextEncoder().encode(domain);
const domainLen = domainBytes.length;
// Calculate total size: 1 (len) + domain + all args
let totalLen = 1 + domainLen;
const processed = args.map(item => {
if (typeof item === 'string') item = hexToBytes(item);
totalLen += item.length;
return item;
});
const transcript = new Uint8Array(totalLen);
let offset = 0;
// Length prefix byte
transcript[offset++] = domainLen;
// Domain separator
transcript.set(domainBytes, offset);
offset += domainLen;
// Arguments
for (const item of processed) {
transcript.set(item, offset);
offset += item.length;
}
return transcript;
}
/**
* sc_reduce: reduce a 64-byte value modulo L to 32 bytes
* This matches crypto-ops.c sc_reduce
* @param {Uint8Array} input - 64-byte input
* @returns {Uint8Array} 32-byte reduced scalar
*/
function scReduce(input) {
// Read 64-byte input as little-endian BigInt
let n = 0n;
for (let i = 63; i >= 0; i--) {
n = (n << 8n) | BigInt(input[i]);
}
// Reduce mod L
n = n % L;
// Convert back to 32-byte little-endian
const result = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
result[i] = Number(n & 0xffn);
n = n >> 8n;
}
return result;
}
/**
* derive_scalar: H_n with 64-byte hash then sc_reduce
* Matches Salvium's derive_scalar: blake2b(data, 64, key) then sc_reduce
*
* @param {Uint8Array} key - 32-byte key (e.g., s_sender_receiver)
* @param {string} domain - Domain separator
* @param {...Uint8Array} args - Data to include in transcript
* @returns {Uint8Array} 32-byte reduced scalar
*/
function deriveScalar(key, domain, ...args) {
// Build transcript: [len] + domain + args
const transcript = makeTranscript(domain, ...args);
// blake2b with key, 64-byte output
const hash64 = blake2b(transcript, 64, key);
// Reduce mod L
return scReduce(hash64);
}
/**
* derive_bytes_32: H_32 (32-byte output, keyed)
* @param {Uint8Array} key - 32-byte key
* @param {string} domain - Domain separator
* @param {...Uint8Array} args - Data arguments
* @returns {Uint8Array} 32-byte output
*/
function deriveBytes32(key, domain, ...args) {
const transcript = makeTranscript(domain, ...args);
return blake2b(transcript, 32, key);
}
/**
* derive_bytes_8: H_8 (8-byte output, keyed)
* @param {Uint8Array} key - 32-byte key
* @param {string} domain - Domain separator
* @param {...Uint8Array} args - Data arguments
* @returns {Uint8Array} 8-byte output
*/
function deriveBytes8(key, domain, ...args) {
const transcript = makeTranscript(domain, ...args);
return blake2b(transcript, 8, key);
}
// Legacy functions (for non-keyed hashing)
function carrotHash32(domain, ...data) {
const transcript = makeTranscript(domain, ...data);
return blake2b(transcript, 32);
}
function carrotHashToScalar(domain, ...data) {
const transcript = makeTranscript(domain, ...data);
const hash64 = blake2b(transcript, 64);
return scReduce(hash64);
}
function carrotHash8(domain, ...data) {
const transcript = makeTranscript(domain, ...data);
return blake2b(transcript, 8);
}
// ============================================================================
// CARROT One-time Address Recovery
// ============================================================================
// Import generator T from ed25519 or define it here
const T_BYTES = new Uint8Array([
0x96, 0x6f, 0xc6, 0x6b, 0x82, 0xcd, 0x56, 0xcf,
0x85, 0xea, 0xec, 0x80, 0x1c, 0x42, 0x84, 0x5f,
0x5f, 0x40, 0x88, 0x78, 0xd1, 0x56, 0x1e, 0x00,
0xd3, 0xd7, 0xde, 0xd2, 0x79, 0x4d, 0x09, 0x4f
]);
/**
* Derive one-time address extension G scalar
* k^o_g = H_n[s^ctx_sr]("Carrot key extension G", C_a)
* Key = s_sender_receiver, data = C_a
*/
function deriveOnetimeExtensionG(senderReceiverCtx, amountCommitment) {
// Key is s_sender_receiver, transcript is just C_a
return deriveScalar(senderReceiverCtx, CARROT_DOMAIN.ONETIME_EXTENSION_G, amountCommitment);
}
/**
* Derive one-time address extension T scalar
* k^o_t = H_n[s^ctx_sr]("Carrot key extension T", C_a)
* Key = s_sender_receiver, data = C_a
*/
function deriveOnetimeExtensionT(senderReceiverCtx, amountCommitment) {
// Key is s_sender_receiver, transcript is just C_a
return deriveScalar(senderReceiverCtx, CARROT_DOMAIN.ONETIME_EXTENSION_T, amountCommitment);
}
/**
* Compute one-time address extension pubkey
* K^o_ext = k^o_g * G + k^o_t * T
*/
function computeOnetimeExtensionPubkey(senderReceiverCtx, amountCommitment) {
const k_g = deriveOnetimeExtensionG(senderReceiverCtx, amountCommitment);
const k_t = deriveOnetimeExtensionT(senderReceiverCtx, amountCommitment);
// k^o_g * G
const kgG = scalarMultBase(k_g);
// k^o_t * T
const ktT = scalarMultPoint(k_t, T_BYTES);
// K^o_ext = k^o_g * G + k^o_t * T
return pointAddCompressed(kgG, ktT);
}
/**
* Recover address spend pubkey from one-time address
* K^j_s = Ko - K^o_ext
*/
export function recoverAddressSpendPubkey(onetimeAddress, senderReceiverCtx, amountCommitment) {
// Compute extension pubkey
const extensionPubkey = computeOnetimeExtensionPubkey(senderReceiverCtx, amountCommitment);
// Negate the extension pubkey (flip sign bit)
const negExtension = new Uint8Array(extensionPubkey);
negExtension[31] ^= 0x80;
// K^j_s = Ko + (-K^o_ext) = Ko - K^o_ext
return pointAddCompressed(onetimeAddress, negExtension);
}
// ============================================================================
// CARROT Amount Decryption
// ============================================================================
/**
* Decrypt CARROT amount
* amount = amount_enc XOR H_8[s^ctx_sr]("Carrot encryption mask a", Ko)
* Key = s_sender_receiver, transcript = Ko
*/
export function decryptCarrotAmount(encryptedAmount, senderReceiverCtx, onetimeAddress) {
// Key is s_sender_receiver, transcript is just Ko
const mask = deriveBytes8(senderReceiverCtx, CARROT_DOMAIN.ENCRYPTION_MASK_AMOUNT, onetimeAddress);
const decrypted = new Uint8Array(8);
for (let i = 0; i < 8; i++) {
decrypted[i] = encryptedAmount[i] ^ mask[i];
}
// Convert little-endian bytes to amount
let amount = 0n;
for (let i = 7; i >= 0; i--) {
amount = (amount << 8n) | BigInt(decrypted[i]);
}
return amount;
}
/**
* Derive CARROT commitment mask (amount blinding factor)
* k_a = H_n[s^ctx_sr]("Carrot commitment mask", amount, K_s, enote_type)
* Key = s_sender_receiver, transcript = amount + K_s + enote_type
*/
export function deriveCarrotCommitmentMask(senderReceiverCtx, amount, addressSpendPubkey, enoteType = 0) {
const amountBytes = new Uint8Array(8);
let a = typeof amount === 'bigint' ? amount : BigInt(amount);
for (let i = 0; i < 8; i++) {
amountBytes[i] = Number(a & 0xffn);
a = a >> 8n;
}
const typeBytes = new Uint8Array([enoteType]);
// Key is s_sender_receiver, transcript is amount + K_s + enote_type
return deriveScalar(senderReceiverCtx, CARROT_DOMAIN.COMMITMENT_MASK, amountBytes, addressSpendPubkey, typeBytes);
}
// ============================================================================
// CARROT Output Scanning
// ============================================================================
/**
* Scan a CARROT output for ownership
*
* Algorithm:
* 1. Compute uncontextualized shared secret: s_sr = k_vi * D_e (X25519)
* 2. Test view tag: vt' = H_3(s_sr, input_context, Ko) - fast filter
* 3. Compute contextualized shared secret: s^ctx_sr = H_32(s_sr, D_e, input_context)
* 4. Recover address spend pubkey: K^j_s = Ko - (k^o_g G + k^o_t T)
* 5. Check if K^j_s matches any known address
* 6. Decrypt amount
*
* @param {Object} output - Output from parsed transaction
* @param {Uint8Array} viewIncomingKey - View-incoming key (k_vi)
* @param {Uint8Array} accountSpendPubkey - Account spend public key (K_s)
* @param {Uint8Array} inputContext - Input context
* @param {Map} subaddressMap - Map of address spend pubkeys (hex) to {major, minor}
* @param {Uint8Array} amountCommitment - Amount commitment (C_a) from RingCT
* @returns {Object|null} Scan result or null if not owned
*/
export function scanCarrotOutput(output, viewIncomingKey, accountSpendPubkey, inputContext, subaddressMap, amountCommitment) {
// Convert keys if passed as hex strings
if (typeof viewIncomingKey === 'string') viewIncomingKey = hexToBytes(viewIncomingKey);
if (typeof accountSpendPubkey === 'string') accountSpendPubkey = hexToBytes(accountSpendPubkey);
if (typeof inputContext === 'string') inputContext = hexToBytes(inputContext);
if (typeof amountCommitment === 'string') amountCommitment = hexToBytes(amountCommitment);
// Extract CARROT-specific fields
const onetimeAddress = typeof output.key === 'string' ? hexToBytes(output.key) : output.key;
const viewTag = output.viewTag;
const enoteEphemeralPubkey = typeof output.enoteEphemeralPubkey === 'string'
? hexToBytes(output.enoteEphemeralPubkey)
: output.enoteEphemeralPubkey;
const encryptedAmount = output.encryptedAmount;
// Debug: trace entry (temporary)
if (globalThis._scanCarrotDebugCount === undefined) globalThis._scanCarrotDebugCount = 0;
globalThis._scanCarrotDebugCount++;
if (globalThis._scanCarrotDebugCount <= 3) {
console.log(`[scanCarrotOutput ENTRY #${globalThis._scanCarrotDebugCount}]`);
console.log(` onetimeAddress: ${onetimeAddress ? 'SET' : 'NULL'}`);
console.log(` viewTag: ${viewTag ? (Array.isArray(viewTag) || viewTag instanceof Uint8Array ? Array.from(viewTag).join(',') : viewTag) : 'NULL'}`);
console.log(` enoteEphemeralPubkey: ${enoteEphemeralPubkey ? 'SET' : 'NULL'}`);
console.log(` viewIncomingKey: ${viewIncomingKey ? 'SET' : 'NULL'}`);
console.log(` accountSpendPubkey: ${accountSpendPubkey ? 'SET' : 'NULL'}`);
}
if (!onetimeAddress || !viewTag || !enoteEphemeralPubkey) {
return null; // Missing required CARROT fields
}
// 1. Compute uncontextualized shared secret using X25519
const senderReceiverUnctx = carrotEcdhKeyExchange(viewIncomingKey, enoteEphemeralPubkey);
// 2. Test view tag (fast filter - 3 bytes)
const expectedViewTag = computeCarrotViewTag(senderReceiverUnctx, inputContext, onetimeAddress);
const viewTagMatch = expectedViewTag[0] === viewTag[0] &&
expectedViewTag[1] === viewTag[1] &&
expectedViewTag[2] === viewTag[2];
// Debug view tag comparison (temporary)
if (globalThis._carrotDebugCount === undefined) globalThis._carrotDebugCount = 0;
globalThis._carrotDebugCount++;
if (globalThis._carrotDebugCount <= 3) {
console.log(`[CARROT VIEW TAG DEBUG #${globalThis._carrotDebugCount}]`);
console.log(` expected: ${expectedViewTag[0]},${expectedViewTag[1]},${expectedViewTag[2]}`);
console.log(` received: ${viewTag[0]},${viewTag[1]},${viewTag[2]}`);
console.log(` match: ${viewTagMatch}`);
console.log(` viewIncomingKey: ${bytesToHex(viewIncomingKey)}`);
console.log(` enoteEphemeralPubkey: ${bytesToHex(enoteEphemeralPubkey)}`);
console.log(` senderReceiverUnctx: ${bytesToHex(senderReceiverUnctx)}`);
console.log(` inputContext (${inputContext.length} bytes): ${bytesToHex(inputContext)}`);
console.log(` onetimeAddress: ${bytesToHex(onetimeAddress)}`);
}
if (!viewTagMatch) {
return null; // View tag mismatch - not our output
}
// 3. Compute contextualized shared secret
const senderReceiverCtx = makeCarrotSenderReceiverSecret(
senderReceiverUnctx,
enoteEphemeralPubkey,
inputContext
);
// 4. Recover address spend pubkey
// Need amount commitment for this - if not provided, use zero commitment
const commitment = amountCommitment || new Uint8Array(32);
const recoveredSpendPubkey = recoverAddressSpendPubkey(onetimeAddress, senderReceiverCtx, commitment);
const recoveredSpendPubkeyHex = bytesToHex(recoveredSpendPubkey);
// 5. Check if this matches our account or any subaddress
let subaddressIndex = null;
let isMainAddress = false;
// Check main address (account spend pubkey)
if (bytesToHex(accountSpendPubkey) === recoveredSpendPubkeyHex) {
isMainAddress = true;
subaddressIndex = { major: 0, minor: 0 };
} else if (subaddressMap && subaddressMap.has(recoveredSpendPubkeyHex)) {
// Check subaddresses
subaddressIndex = subaddressMap.get(recoveredSpendPubkeyHex);
}
if (!subaddressIndex) {
return null; // Not our output
}
// 6. Decrypt amount (if encrypted)
let amount = 0n;
if (encryptedAmount) {
const encAmountBytes = typeof encryptedAmount === 'string'
? hexToBytes(encryptedAmount)
: encryptedAmount;
amount = decryptCarrotAmount(encAmountBytes, senderReceiverCtx, onetimeAddress);
}
// 7. Derive commitment mask for verification
const mask = deriveCarrotCommitmentMask(senderReceiverCtx, amount, recoveredSpendPubkey, 0);
return {
owned: true,
onetimeAddress: bytesToHex(onetimeAddress),
addressSpendPubkey: recoveredSpendPubkeyHex,
sharedSecret: bytesToHex(senderReceiverCtx),
viewTag: bytesToHex(viewTag),
amount,
mask,
subaddressIndex,
isMainAddress,
isCarrot: true
};
}
// ============================================================================
// Import additional ed25519 functions
// ============================================================================
import { scalarMultBase, pointAddCompressed } from './ed25519.js';
import { hashToPoint } from './keyimage.js';
// Group order L for scalar reduction (also defined at top for clarity)
const L_ORDER = (1n << 252n) + 27742317777372353535851937790883648493n;
/**
* Scalar addition: a + b mod L
* @param {Uint8Array} a - 32-byte scalar
* @param {Uint8Array} b - 32-byte scalar
* @returns {Uint8Array} 32-byte result
*/
function scalarAdd(a, b) {
let aVal = 0n;
let bVal = 0n;
for (let i = 0; i < 32; i++) {
aVal |= BigInt(a[i]) << (8n * BigInt(i));
bVal |= BigInt(b[i]) << (8n * BigInt(i));
}
let result = (aVal + bVal) % L_ORDER;
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
out[i] = Number(result & 0xffn);
result = result >> 8n;
}
return out;
}
/**
* Scalar multiplication: a * b mod L
* @param {Uint8Array} a - 32-byte scalar
* @param {Uint8Array} b - 32-byte scalar
* @returns {Uint8Array} 32-byte result
*/
function scalarMul(a, b) {
let aVal = 0n;
let bVal = 0n;
for (let i = 0; i < 32; i++) {
aVal |= BigInt(a[i]) << (8n * BigInt(i));
bVal |= BigInt(b[i]) << (8n * BigInt(i));
}
let result = (aVal * bVal) % L_ORDER;
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
out[i] = Number(result & 0xffn);
result = result >> 8n;
}
return out;
}
/**
* Generate CARROT key image
*
* CARROT key image formula:
* sender_extension_g = H_n("Carrot key extension G", s_sender_receiver_ctx, C_a)
* x = k_gi * k_subscal + sender_extension_g
* KI = x * H_p(Ko)
*
* For main address (j=0,0), k_subscal = 1, so x = k_gi + sender_extension_g
*
* @param {Uint8Array|string} onetimeAddress - One-time address Ko (32 bytes)
* @param {Uint8Array|string} senderReceiverCtx - Contextualized shared secret (32 bytes)
* @param {Uint8Array|string} amountCommitment - Amount commitment C_a (32 bytes)
* @param {Uint8Array|string} generateImageKey - k_gi from CARROT keys (32 bytes)
* @param {Uint8Array|string} [subaddressScalar] - k_subscal (32 bytes), defaults to 1 for main address
* @returns {Uint8Array} Key image (32 bytes)
*/
export function generateCarrotKeyImage(
onetimeAddress,
senderReceiverCtx,
amountCommitment,
generateImageKey,
subaddressScalar = null
) {
// Convert string inputs to bytes
const Ko = typeof onetimeAddress === 'string' ? hexToBytes(onetimeAddress) : onetimeAddress;
const sSR = typeof senderReceiverCtx === 'string' ? hexToBytes(senderReceiverCtx) : senderReceiverCtx;
const Ca = typeof amountCommitment === 'string' ? hexToBytes(amountCommitment) : amountCommitment;
const kGi = typeof generateImageKey === 'string' ? hexToBytes(generateImageKey) : generateImageKey;
// 1. Derive sender_extension_g = H_n("Carrot key extension G", s_sr_ctx, C_a)
const senderExtG = deriveOnetimeExtensionG(sSR, Ca);
// 2. Compute x = k_gi * k_subscal + sender_extension_g
let x;
if (subaddressScalar) {
const kSubscal = typeof subaddressScalar === 'string' ? hexToBytes(subaddressScalar) : subaddressScalar;
// x = k_gi * k_subscal + sender_extension_g
const kgiScaled = scalarMul(kGi, kSubscal);
x = scalarAdd(kgiScaled, senderExtG);
} else {
// Main address: k_subscal = 1, so x = k_gi + sender_extension_g
x = scalarAdd(kGi, senderExtG);
}
// 3. Compute H_p(Ko) - hash to point
const hpKo = hashToPoint(Ko);
// 4. Compute key image: KI = x * H_p(Ko)
const keyImage = scalarMultPoint(x, hpKo);
return keyImage;
}
// ============================================================================
// Exports
// ============================================================================
export default {
// X25519
edwardsToMontgomeryU,
x25519ScalarMult,
carrotEcdhKeyExchange,
// View tag
computeCarrotViewTag,
testCarrotViewTag,
// Input context
makeInputContext,
makeInputContextCoinbase,
// Shared secret
makeCarrotSenderReceiverSecret,
// Address recovery
recoverAddressSpendPubkey,
// Amount decryption
decryptCarrotAmount,
deriveCarrotCommitmentMask,
// Key image
generateCarrotKeyImage,
// Scanning
scanCarrotOutput
};
+59 -3
View File
@@ -6,7 +6,7 @@
import { blake2b } from './blake2b.js';
import { keccak256 } from './keccak.js';
import { hexToBytes, bytesToHex } from './address.js';
import { scalarMultBase } from './ed25519.js';
import { scalarMultBase, computeCarrotSpendPubkey, computeCarrotMainAddressViewPubkey, computeCarrotAccountViewPubkey } from './ed25519.js';
// Group order L for scalar reduction
const L = (1n << 252n) + 27742317777372353535851937790883648493n;
@@ -238,19 +238,74 @@ export function deriveCarrotKeys(masterSecret) {
masterSecret = hexToBytes(masterSecret);
}
// Derive account secrets
const viewBalanceSecret = makeViewBalanceSecret(masterSecret);
const proveSpendKey = makeProveSpendKey(masterSecret);
const viewIncomingKey = makeViewIncomingKey(viewBalanceSecret);
const generateImageKey = makeGenerateImageKey(viewBalanceSecret);
const generateAddressSecret = makeGenerateAddressSecret(viewBalanceSecret);
// Compute account pubkeys
// K_s = k_gi * G + k_ps * T
const accountSpendPubkey = computeCarrotSpendPubkey(generateImageKey, proveSpendKey);
// K^0_v = k_vi * G (primary address view pubkey - for main address)
const primaryAddressViewPubkey = computeCarrotMainAddressViewPubkey(viewIncomingKey);
// K_v = k_vi * K_s (account view pubkey - for subaddress derivation)
const accountViewPubkey = computeCarrotAccountViewPubkey(viewIncomingKey, accountSpendPubkey);
return {
// Account secrets
masterSecret: bytesToHex(masterSecret),
proveSpendKey: bytesToHex(proveSpendKey),
viewBalanceSecret: bytesToHex(viewBalanceSecret),
generateImageKey: bytesToHex(generateImageKey),
viewIncomingKey: bytesToHex(viewIncomingKey),
generateAddressSecret: bytesToHex(generateAddressSecret)
generateAddressSecret: bytesToHex(generateAddressSecret),
// Account pubkeys (for address generation)
accountSpendPubkey: bytesToHex(accountSpendPubkey),
primaryAddressViewPubkey: bytesToHex(primaryAddressViewPubkey),
accountViewPubkey: bytesToHex(accountViewPubkey)
};
}
/**
* Derive CARROT keys for view-only wallet from view-balance secret
* Requires account spend pubkey to be provided (can't be derived from s_vb)
* @param {Uint8Array|string} viewBalanceSecret - 32-byte view-balance secret or hex string
* @param {Uint8Array|string} accountSpendPubkey - 32-byte account spend pubkey or hex string
* @returns {Object} Derived keys for view-only scanning
*/
export function deriveCarrotViewOnlyKeys(viewBalanceSecret, accountSpendPubkey) {
// Convert hex string to bytes if needed
if (typeof viewBalanceSecret === 'string') {
viewBalanceSecret = hexToBytes(viewBalanceSecret);
}
if (typeof accountSpendPubkey === 'string') {
accountSpendPubkey = hexToBytes(accountSpendPubkey);
}
// Derive keys from view-balance secret
const viewIncomingKey = makeViewIncomingKey(viewBalanceSecret);
const generateImageKey = makeGenerateImageKey(viewBalanceSecret);
const generateAddressSecret = makeGenerateAddressSecret(viewBalanceSecret);
// Compute account view pubkey: K_v = k_vi * K_s
const accountViewPubkey = computeCarrotAccountViewPubkey(viewIncomingKey, accountSpendPubkey);
// Primary address view pubkey: K^0_v = k_vi * G
const primaryAddressViewPubkey = computeCarrotMainAddressViewPubkey(viewIncomingKey);
return {
// Secrets (view-only subset)
viewBalanceSecret: bytesToHex(viewBalanceSecret),
viewIncomingKey: bytesToHex(viewIncomingKey),
generateImageKey: bytesToHex(generateImageKey),
generateAddressSecret: bytesToHex(generateAddressSecret),
// Pubkeys
accountSpendPubkey: bytesToHex(accountSpendPubkey),
primaryAddressViewPubkey: bytesToHex(primaryAddressViewPubkey),
accountViewPubkey: bytesToHex(accountViewPubkey),
// Flag
isViewOnly: true
};
}
@@ -262,5 +317,6 @@ export default {
makeProveSpendKey,
makeGenerateImageKey,
makeGenerateAddressSecret,
deriveCarrotKeys
deriveCarrotKeys,
deriveCarrotViewOnlyKeys
};
+811
View File
@@ -0,0 +1,811 @@
/**
* Salvium Consensus Rules and Constants
*
* This module contains all consensus-critical constants and validation functions
* needed to implement a full validating node.
*
* Reference: salvium/src/cryptonote_config.h, cryptonote_basic_impl.cpp, difficulty.cpp
*/
// =============================================================================
// CORE CONSTANTS
// =============================================================================
// Money supply and emission
export const MONEY_SUPPLY = 18440000000000000n; // 184.4M coins * 10^8
export const EMISSION_SPEED_FACTOR_PER_MINUTE = 21;
export const FINAL_SUBSIDY_PER_MINUTE = 30000000n; // 3 * 10^7 (tail emission)
export const COIN = 100000000n; // 10^8 atomic units per coin
export const CRYPTONOTE_DISPLAY_DECIMAL_POINT = 8;
// Premine
export const PREMINE_AMOUNT = 2210000000000000n; // 12% of MONEY_SUPPLY
export const PREMINE_AMOUNT_UPFRONT = 650000000000000n; // 3.4% of MONEY_SUPPLY
export const PREMINE_AMOUNT_MONTHLY = 65000000000000n; // 8.6%/24 of MONEY_SUPPLY
// Treasury SAL1 minting
export const TREASURY_SAL1_MINT_AMOUNT = 130000000000000n; // 1.3M
export const TREASURY_SAL1_MINT_COUNT = 8;
// Block timing
export const DIFFICULTY_TARGET_V1 = 60; // seconds (before first fork)
export const DIFFICULTY_TARGET_V2 = 120; // seconds (current)
export const CRYPTONOTE_BLOCK_FUTURE_TIME_LIMIT = 60 * 60 * 2; // 2 hours
export const BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW = 60;
// Difficulty adjustment
export const DIFFICULTY_WINDOW = 720;
export const DIFFICULTY_WINDOW_V2 = 70;
export const DIFFICULTY_LAG = 15;
export const DIFFICULTY_CUT = 60;
export const DIFFICULTY_BLOCKS_COUNT = DIFFICULTY_WINDOW + DIFFICULTY_LAG;
export const DIFFICULTY_BLOCKS_COUNT_V2 = DIFFICULTY_WINDOW_V2;
// Block size/weight
export const CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V1 = 20000;
export const CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V2 = 60000;
export const CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V5 = 300000;
export const CRYPTONOTE_LONG_TERM_BLOCK_WEIGHT_WINDOW_SIZE = 100000;
export const CRYPTONOTE_SHORT_TERM_BLOCK_WEIGHT_SURGE_FACTOR = 50;
// Transaction limits
export const CRYPTONOTE_MAX_TX_SIZE = 1000000;
export const CRYPTONOTE_MAX_TX_PER_BLOCK = 0x10000000;
export const MAX_TX_EXTRA_SIZE = 1060;
export const BULLETPROOF_MAX_OUTPUTS = 16;
export const BULLETPROOF_PLUS_MAX_OUTPUTS = 16;
// Maturity and unlock
export const CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW = 60;
export const CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE = 10;
export const CRYPTONOTE_LOCKED_TX_ALLOWED_DELTA_BLOCKS = 1;
// Transaction versions
export const CURRENT_TRANSACTION_VERSION = 4;
export const TRANSACTION_VERSION_2_OUTS = 2;
export const TRANSACTION_VERSION_N_OUTS = 3;
export const TRANSACTION_VERSION_CARROT = 4;
// Fees
export const FEE_PER_KB = 200000n; // 2 * 10^5
export const FEE_PER_BYTE = 30n;
export const DYNAMIC_FEE_PER_KB_BASE_FEE = 200000n;
export const DYNAMIC_FEE_PER_KB_BASE_BLOCK_REWARD = 1000000000n; // 10 * 10^8
export const DYNAMIC_FEE_REFERENCE_TRANSACTION_WEIGHT = 3000n;
export const PER_KB_FEE_QUANTIZATION_DECIMALS = 8;
export const DEFAULT_DUST_THRESHOLD = 2000000000n; // 2 * 10^9
export const BASE_REWARD_CLAMP_THRESHOLD = 100000000n; // 10^8
// Mempool
export const CRYPTONOTE_MEMPOOL_TX_LIVETIME = 86400 * 3; // 3 days
export const CRYPTONOTE_MEMPOOL_TX_FROM_ALT_BLOCK_LIVETIME = 604800; // 1 week
export const DEFAULT_TXPOOL_MAX_WEIGHT = 648000000n; // 3 days at 300000
// Ring size (Salvium uses 16)
export const DEFAULT_RING_SIZE = 16;
// Pricing record
export const PRICING_RECORD_VALID_BLOCKS = 10;
export const PRICING_RECORD_VALID_TIME_DIFF_FROM_BLOCK = 120;
// Lock periods
export const BURN_LOCK_PERIOD = 0;
export const CONVERT_LOCK_PERIOD = 0;
// =============================================================================
// HARD FORK VERSIONS
// =============================================================================
export const HF_VERSION = {
// Version 1 features
DYNAMIC_FEE: 1,
PER_BYTE_FEE: 1,
ENFORCE_MIN_AGE: 1,
EXACT_COINBASE: 1,
CLSAG: 1,
DETERMINISTIC_UNLOCK_TIME: 1,
SMALLER_BP: 1,
MIN_V2_COINBASE_TX: 1,
REJECT_SIGS_IN_COINBASE: 1,
BULLETPROOF_PLUS: 1,
ENABLE_RETURN: 1,
VIEW_TAGS: 1,
// Version 2 features
LONG_TERM_BLOCK_WEIGHT: 2,
SCALING_2021: 2,
ENABLE_N_OUTS: 2,
// Version 3+
FULL_PROOFS: 3,
ENFORCE_FULL_PROOFS: 4,
SHUTDOWN_USER_TXS: 5,
AUDIT1: 6,
SALVIUM_ONE_PROOFS: 6,
AUDIT1_PAUSE: 7,
AUDIT2: 8,
AUDIT2_PAUSE: 9,
CARROT: 10,
// Future (v255 placeholder)
REQUIRE_VIEW_TAGS: 255,
ENABLE_CONVERT: 255,
ENABLE_ORACLE: 255,
SLIPPAGE_YIELD: 255,
};
// =============================================================================
// NETWORK CONFIGURATIONS
// =============================================================================
export const NETWORK_ID = {
MAINNET: 0,
TESTNET: 1,
STAGENET: 2,
FAKECHAIN: 3,
};
export const MAINNET_CONFIG = {
ADDRESS_PREFIX: 0x3ef318n, // SaLv
INTEGRATED_ADDRESS_PREFIX: 0x55ef318n, // SaLvi
SUBADDRESS_PREFIX: 0xf5ef318n, // SaLvs
CARROT_ADDRESS_PREFIX: 0x180c96n, // SC1
CARROT_INTEGRATED_PREFIX: 0x2ccc96n, // SC1i
CARROT_SUBADDRESS_PREFIX: 0x314c96n, // SC1s
P2P_PORT: 19080,
RPC_PORT: 19081,
ZMQ_PORT: 19082,
GENESIS_NONCE: 10000,
GENESIS_TX: '020001ff000180c0d0c7bbbff603031c7d3e2240c8ddbc2966c9dcbf703c3aa99624d34b82fbfebd71dcfa001c59800353414c3cb42101d7be8f8312cdd54e1ae390e86d6733c3d8f1ef7be27f75f5acbf0dc57aa8e60d010000',
STAKE_LOCK_PERIOD: 30 * 24 * 30, // blocks
TREASURY_SAL1_MINT_PERIOD: 30 * 24 * 30,
TREASURY_ADDRESS: 'SaLvdZR6w1A21sf2Wh6jYEh1wzY4GSbT7RX6FjyPsnLsffWLrzFQeXUXJcmBLRWDzZC2YXeYe5t7qKsnrg9FpmxmEcxPHsEYfqA',
};
export const TESTNET_CONFIG = {
ADDRESS_PREFIX: 0x15beb318n, // SaLvT
INTEGRATED_ADDRESS_PREFIX: 0xd055eb318n, // SaLvTi
SUBADDRESS_PREFIX: 0xa59eb318n, // SaLvTs
CARROT_ADDRESS_PREFIX: 0x254c96n, // SC1T
CARROT_INTEGRATED_PREFIX: 0x1ac50c96n, // SC1Ti
CARROT_SUBADDRESS_PREFIX: 0x3c54c96n, // SC1Ts
P2P_PORT: 29080,
RPC_PORT: 29081,
ZMQ_PORT: 29082,
GENESIS_NONCE: 10001,
GENESIS_TX: '020001ff000180c0d0c7bbbff60302838f76f69b70bb0d0f1961a12f6082a033d22285c07d4f12ec93c28197ae2a600353414c3c2101009e8b0abce686c417a1b1344eb7337176bdca90cc928b0facec8a9516190645010000',
STAKE_LOCK_PERIOD: 20,
TREASURY_SAL1_MINT_PERIOD: 20,
TREASURY_ADDRESS: 'SaLvTyLFta9BiAXeUfFkKvViBkFt4ay5nEUBpWyDKewYggtsoxBbtCUVqaBjtcCDyY1euun8Giv7LLEgvztuurLo5a6Km1zskZn36',
};
export const STAGENET_CONFIG = {
ADDRESS_PREFIX: 0x149eb318n, // SaLvS
INTEGRATED_ADDRESS_PREFIX: 0xf343eb318n, // SaLvSi
SUBADDRESS_PREFIX: 0x2d47eb318n, // SaLvSs
CARROT_ADDRESS_PREFIX: 0x24cc96n, // SC1S
CARROT_INTEGRATED_PREFIX: 0x1a848c96n, // SC1Si
CARROT_SUBADDRESS_PREFIX: 0x384cc96n, // SC1Ss
P2P_PORT: 39080,
RPC_PORT: 39081,
ZMQ_PORT: 39082,
GENESIS_NONCE: 10002,
GENESIS_TX: '013c01ff0001ffffffffffff0302df5d56da0c7d643ddd1ce61901c7bdc5fb1738bfe39fbe69c28a3a7032729c0f2101168d0c4ca86fb55a4cf6a36d31431be1c53a3bd7411bb24e8832410289fa6f3b',
STAKE_LOCK_PERIOD: 20,
TREASURY_SAL1_MINT_PERIOD: 20,
TREASURY_ADDRESS: 'fuLMowH85abK8nz9BBMEem7MAfUbQu4aSHHUV9j5Z86o6Go9Lv2U5ZQiJCWPY9R9HA8p5idburazjAhCqDngLo7fYPCD9ciM9ee1A',
};
/**
* Get network configuration
* @param {number} network - Network type (MAINNET, TESTNET, STAGENET)
* @returns {Object} Network configuration
*/
export function getNetworkConfig(network) {
switch (network) {
case NETWORK_ID.MAINNET:
case NETWORK_ID.FAKECHAIN:
return MAINNET_CONFIG;
case NETWORK_ID.TESTNET:
return TESTNET_CONFIG;
case NETWORK_ID.STAGENET:
return STAGENET_CONFIG;
default:
throw new Error(`Invalid network type: ${network}`);
}
}
// =============================================================================
// BLOCK REWARD CALCULATION
// =============================================================================
/**
* Get minimum block weight for full reward
* @param {number} version - Hard fork version
* @returns {number} Minimum block weight in bytes
*/
export function getMinBlockWeight(version) {
if (version < 2) {
return CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V1;
}
return CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V5;
}
/**
* Calculate block reward
*
* Formula: base_reward = (MONEY_SUPPLY - already_generated) >> emission_speed_factor
* With penalty for blocks larger than median weight
*
* @param {number} medianWeight - Median block weight
* @param {number} currentBlockWeight - Current block weight
* @param {bigint} alreadyGeneratedCoins - Total coins generated so far
* @param {number} version - Hard fork version
* @returns {{ success: boolean, reward: bigint }} Block reward result
*/
export function getBlockReward(medianWeight, currentBlockWeight, alreadyGeneratedCoins, version = 1) {
const target = DIFFICULTY_TARGET_V2;
const targetMinutes = target / 60;
const emissionSpeedFactor = EMISSION_SPEED_FACTOR_PER_MINUTE - (targetMinutes - 1);
// Calculate base reward
let baseReward = (MONEY_SUPPLY - alreadyGeneratedCoins) >> BigInt(emissionSpeedFactor);
// Apply tail emission (minimum subsidy)
const minSubsidy = FINAL_SUBSIDY_PER_MINUTE * BigInt(targetMinutes);
if (baseReward < minSubsidy) {
baseReward = minSubsidy;
}
// Genesis block (premine)
if (alreadyGeneratedCoins === 0n) {
return { success: true, reward: PREMINE_AMOUNT };
}
// Get full reward zone
let fullRewardZone = BigInt(getMinBlockWeight(version));
if (BigInt(medianWeight) < fullRewardZone) {
medianWeight = Number(fullRewardZone);
}
// No penalty if block is small
if (currentBlockWeight <= medianWeight) {
return { success: true, reward: baseReward };
}
// Block too large
if (currentBlockWeight > 2 * medianWeight) {
return { success: false, reward: 0n };
}
// Calculate penalty: reward * (2*M - W) * W / M^2
// Where M = median weight, W = current weight
const multiplicand = BigInt(2 * medianWeight - currentBlockWeight) * BigInt(currentBlockWeight);
const reward = (baseReward * multiplicand) / BigInt(medianWeight) / BigInt(medianWeight);
return { success: true, reward };
}
/**
* Calculate total emission at a given height (approximate)
* @param {number} height - Block height
* @returns {bigint} Approximate total emission
*/
export function getApproximateEmission(height) {
if (height === 0) return PREMINE_AMOUNT;
// Simplified calculation - actual emission is cumulative
// This is an approximation; real emission requires summing all block rewards
const target = DIFFICULTY_TARGET_V2;
const targetMinutes = target / 60;
const emissionSpeedFactor = EMISSION_SPEED_FACTOR_PER_MINUTE - (targetMinutes - 1);
// Geometric series approximation
// Sum ≈ MONEY_SUPPLY * (1 - 0.5^(height/halvingPeriod))
// Where halvingPeriod = 2^emissionSpeedFactor blocks
let emission = PREMINE_AMOUNT;
let remaining = MONEY_SUPPLY - PREMINE_AMOUNT;
for (let h = 1; h <= height && remaining > 0n; h++) {
const reward = remaining >> BigInt(emissionSpeedFactor);
const minReward = FINAL_SUBSIDY_PER_MINUTE * BigInt(targetMinutes);
const actualReward = reward < minReward ? minReward : reward;
emission += actualReward;
remaining -= actualReward;
}
return emission;
}
// =============================================================================
// DIFFICULTY CALCULATION
// =============================================================================
/**
* Calculate next difficulty using original algorithm
*
* @param {number[]} timestamps - Block timestamps (newest first)
* @param {bigint[]} cumulativeDifficulties - Cumulative difficulties
* @param {number} targetSeconds - Target block time
* @returns {bigint} Next difficulty
*/
export function nextDifficulty(timestamps, cumulativeDifficulties, targetSeconds = DIFFICULTY_TARGET_V2) {
// Trim to window size
if (timestamps.length > DIFFICULTY_WINDOW) {
timestamps = timestamps.slice(0, DIFFICULTY_WINDOW);
cumulativeDifficulties = cumulativeDifficulties.slice(0, DIFFICULTY_WINDOW);
}
const length = timestamps.length;
if (length !== cumulativeDifficulties.length) {
throw new Error('Timestamps and difficulties must have same length');
}
if (length <= 1) {
return 1n;
}
// Sort timestamps
timestamps = [...timestamps].sort((a, b) => a - b);
// Calculate cut points
let cutBegin, cutEnd;
if (length <= DIFFICULTY_WINDOW - 2 * DIFFICULTY_CUT) {
cutBegin = 0;
cutEnd = length;
} else {
cutBegin = Math.floor((length - (DIFFICULTY_WINDOW - 2 * DIFFICULTY_CUT) + 1) / 2);
cutEnd = cutBegin + (DIFFICULTY_WINDOW - 2 * DIFFICULTY_CUT);
}
// Calculate time span and work
let timeSpan = BigInt(timestamps[cutEnd - 1] - timestamps[cutBegin]);
if (timeSpan === 0n) timeSpan = 1n;
const totalWork = cumulativeDifficulties[cutEnd - 1] - cumulativeDifficulties[cutBegin];
// difficulty = work * target / timeSpan
return (totalWork * BigInt(targetSeconds) + timeSpan - 1n) / timeSpan;
}
/**
* Calculate next difficulty using LWMA (Linearly Weighted Moving Average) v2
*
* LWMA algorithm by Zawy
* https://github.com/zawy12/difficulty-algorithms/issues/3
*
* @param {number[]} timestamps - Block timestamps (oldest first, length N+1)
* @param {bigint[]} cumulativeDifficulties - Cumulative difficulties (length N+1)
* @param {number} targetSeconds - Target block time
* @returns {bigint} Next difficulty
*/
export function nextDifficultyV2(timestamps, cumulativeDifficulties, targetSeconds = DIFFICULTY_TARGET_V2) {
const T = targetSeconds;
let N = DIFFICULTY_WINDOW_V2;
// Trim to window
if (timestamps.length > N + 1) {
timestamps = timestamps.slice(0, N + 1);
cumulativeDifficulties = cumulativeDifficulties.slice(0, N + 1);
}
const n = timestamps.length;
if (n !== cumulativeDifficulties.length) {
throw new Error('Timestamps and difficulties must have same length');
}
// First 5 blocks: return difficulty 1
if (n < 6) return 1n;
// If height < N+1, adjust N
if (n < N + 1) N = n - 1;
// Adjustment factor for average solvetime accuracy
const adjust = 0.998;
// Normalization divisor
const k = N * (N + 1) / 2;
let LWMA = 0;
let sumInverseD = 0;
// Loop through N most recent blocks
for (let i = 1; i <= N; i++) {
let solveTime = Number(timestamps[i]) - Number(timestamps[i - 1]);
// Clamp solve time to [-7T, 7T]
solveTime = Math.min(T * 7, Math.max(solveTime, -7 * T));
const difficulty = Number(cumulativeDifficulties[i] - cumulativeDifficulties[i - 1]);
LWMA += (solveTime * i) / k;
sumInverseD += 1 / difficulty;
}
// Sanity check
if (LWMA < T / 20) LWMA = T / 20;
// Calculate harmonic mean of difficulties
const harmonicMeanD = N / sumInverseD * adjust;
// Next difficulty
const nextDiff = harmonicMeanD * T / LWMA;
return BigInt(Math.floor(nextDiff));
}
/**
* Check if a hash meets difficulty target
*
* hash * difficulty <= 2^256
*
* @param {Uint8Array} hash - 32-byte hash
* @param {bigint} difficulty - Difficulty target
* @returns {boolean} True if hash meets difficulty
*/
export function checkHash(hash, difficulty) {
if (hash.length !== 32) {
throw new Error('Hash must be 32 bytes');
}
// Convert hash to big-endian bigint
let hashVal = 0n;
for (let i = 0; i < 32; i++) {
hashVal = (hashVal << 8n) | BigInt(hash[i]);
}
// Check: hash * difficulty <= 2^256
const max256 = (1n << 256n) - 1n;
return hashVal * difficulty <= max256;
}
// =============================================================================
// TIMESTAMP VALIDATION
// =============================================================================
/**
* Get median timestamp from recent blocks
* @param {number[]} timestamps - Recent block timestamps (most recent first)
* @returns {number} Median timestamp
*/
export function getMedianTimestamp(timestamps) {
if (timestamps.length === 0) return 0;
const sorted = [...timestamps].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return Math.floor((sorted[mid - 1] + sorted[mid]) / 2);
}
return sorted[mid];
}
/**
* Validate block timestamp
*
* Timestamp must be:
* 1. Greater than median of last BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW blocks
* 2. Not more than CRYPTONOTE_BLOCK_FUTURE_TIME_LIMIT in the future
*
* @param {number} timestamp - Block timestamp
* @param {number[]} recentTimestamps - Recent block timestamps
* @param {number} currentTime - Current Unix timestamp
* @returns {{ valid: boolean, error?: string }}
*/
export function validateBlockTimestamp(timestamp, recentTimestamps, currentTime) {
// Check future limit
if (timestamp > currentTime + CRYPTONOTE_BLOCK_FUTURE_TIME_LIMIT) {
return {
valid: false,
error: `Timestamp too far in future: ${timestamp} > ${currentTime + CRYPTONOTE_BLOCK_FUTURE_TIME_LIMIT}`
};
}
// Check median time rule
if (recentTimestamps.length >= BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW) {
const medianTime = getMedianTimestamp(recentTimestamps.slice(0, BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW));
if (timestamp <= medianTime) {
return {
valid: false,
error: `Timestamp not greater than median: ${timestamp} <= ${medianTime}`
};
}
}
return { valid: true };
}
// =============================================================================
// UNLOCK TIME VALIDATION
// =============================================================================
/**
* Check if an output is unlocked (spendable)
*
* @param {bigint} unlockTime - Unlock time (0 = no lock, <500M = block height, >=500M = unix timestamp)
* @param {number} currentHeight - Current blockchain height
* @param {number} currentTime - Current Unix timestamp
* @param {number} version - Hard fork version
* @returns {boolean} True if unlocked
*/
export function isOutputUnlocked(unlockTime, currentHeight, currentTime, version = 1) {
// No lock
if (unlockTime === 0n) return true;
const unlockTimeNum = Number(unlockTime);
// Threshold: 500,000,000 - below = height, above = timestamp
const UNLOCK_TIME_THRESHOLD = 500000000;
if (unlockTimeNum < UNLOCK_TIME_THRESHOLD) {
// Block height based unlock
const allowedDelta = version >= 2
? CRYPTONOTE_LOCKED_TX_ALLOWED_DELTA_BLOCKS
: CRYPTONOTE_LOCKED_TX_ALLOWED_DELTA_BLOCKS;
return currentHeight + allowedDelta >= unlockTimeNum;
} else {
// Timestamp based unlock
const allowedDelta = version >= 2
? DIFFICULTY_TARGET_V2 * CRYPTONOTE_LOCKED_TX_ALLOWED_DELTA_BLOCKS
: DIFFICULTY_TARGET_V1 * CRYPTONOTE_LOCKED_TX_ALLOWED_DELTA_BLOCKS;
return currentTime + allowedDelta >= unlockTimeNum;
}
}
/**
* Check if coinbase output is mature (spendable)
*
* @param {number} outputHeight - Height where output was created
* @param {number} currentHeight - Current blockchain height
* @returns {boolean} True if mature
*/
export function isCoinbaseMature(outputHeight, currentHeight) {
return currentHeight >= outputHeight + CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW;
}
/**
* Check if output meets minimum age requirement
*
* @param {number} outputHeight - Height where output was created
* @param {number} currentHeight - Current blockchain height
* @returns {boolean} True if old enough
*/
export function meetsMinimumAge(outputHeight, currentHeight) {
return currentHeight >= outputHeight + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE;
}
// =============================================================================
// FEE VALIDATION
// =============================================================================
/**
* Calculate minimum required fee
*
* @param {number} txWeight - Transaction weight in bytes
* @param {bigint} baseReward - Current block reward
* @param {number} version - Hard fork version
* @returns {bigint} Minimum fee
*/
export function getMinimumFee(txWeight, baseReward, version = 1) {
if (version >= HF_VERSION.PER_BYTE_FEE) {
// Per-byte fee
return BigInt(txWeight) * FEE_PER_BYTE;
}
// Legacy per-KB fee
const kbSize = BigInt(Math.ceil(txWeight / 1024));
return kbSize * FEE_PER_KB;
}
/**
* Calculate dynamic fee based on block reward
*
* @param {bigint} baseReward - Current block reward
* @param {number} txWeight - Transaction weight
* @param {number} version - Hard fork version
* @returns {bigint} Dynamic fee
*/
export function getDynamicFee(baseReward, txWeight, version = 1) {
const fee = DYNAMIC_FEE_PER_KB_BASE_FEE * BigInt(txWeight) / 1024n;
// Scale by reward ratio
if (baseReward > 0n) {
const scaledFee = fee * DYNAMIC_FEE_PER_KB_BASE_BLOCK_REWARD / baseReward;
return scaledFee > fee ? scaledFee : fee;
}
return fee;
}
/**
* Quantize fee (round to reduce fingerprinting)
*
* @param {bigint} fee - Raw fee
* @returns {bigint} Quantized fee
*/
export function quantizeFee(fee) {
const mask = (10n ** BigInt(PER_KB_FEE_QUANTIZATION_DECIMALS)) - 1n;
return ((fee + mask) / (mask + 1n)) * (mask + 1n);
}
// =============================================================================
// BLOCK VALIDATION
// =============================================================================
/**
* Validate block header chain linkage
*
* @param {Object} currentHeader - Current block header
* @param {Object} previousHeader - Previous block header
* @returns {{ valid: boolean, error?: string }}
*/
export function validateBlockLinkage(currentHeader, previousHeader) {
// Check previous hash matches
const prevHashHex = typeof currentHeader.prevId === 'string'
? currentHeader.prevId
: Buffer.from(currentHeader.prevId).toString('hex');
const expectedPrevHash = typeof previousHeader.hash === 'string'
? previousHeader.hash
: Buffer.from(previousHeader.hash).toString('hex');
if (prevHashHex !== expectedPrevHash) {
return {
valid: false,
error: `Previous hash mismatch: ${prevHashHex} != ${expectedPrevHash}`
};
}
// Check height is sequential
if (currentHeader.height !== previousHeader.height + 1) {
return {
valid: false,
error: `Height not sequential: ${currentHeader.height} != ${previousHeader.height + 1}`
};
}
return { valid: true };
}
/**
* Validate block size/weight
*
* @param {number} blockWeight - Block weight in bytes
* @param {number} medianWeight - Median block weight
* @param {number} version - Hard fork version
* @returns {{ valid: boolean, error?: string }}
*/
export function validateBlockWeight(blockWeight, medianWeight, version = 1) {
const maxWeight = 2 * Math.max(medianWeight, getMinBlockWeight(version));
if (blockWeight > maxWeight) {
return {
valid: false,
error: `Block weight ${blockWeight} exceeds max ${maxWeight}`
};
}
return { valid: true };
}
// =============================================================================
// TRANSACTION VALIDATION
// =============================================================================
/**
* Validate transaction size
*
* @param {number} txSize - Transaction size in bytes
* @returns {{ valid: boolean, error?: string }}
*/
export function validateTxSize(txSize) {
if (txSize > CRYPTONOTE_MAX_TX_SIZE) {
return {
valid: false,
error: `Transaction size ${txSize} exceeds max ${CRYPTONOTE_MAX_TX_SIZE}`
};
}
return { valid: true };
}
/**
* Validate transaction extra field size
*
* @param {number} extraSize - Extra field size in bytes
* @returns {{ valid: boolean, error?: string }}
*/
export function validateTxExtraSize(extraSize) {
if (extraSize > MAX_TX_EXTRA_SIZE) {
return {
valid: false,
error: `Transaction extra size ${extraSize} exceeds max ${MAX_TX_EXTRA_SIZE}`
};
}
return { valid: true };
}
/**
* Validate transaction output count
*
* @param {number} outputCount - Number of outputs
* @param {number} version - Hard fork version
* @returns {{ valid: boolean, error?: string }}
*/
export function validateOutputCount(outputCount, version = 1) {
if (outputCount === 0) {
return { valid: false, error: 'Transaction must have at least one output' };
}
if (outputCount > BULLETPROOF_PLUS_MAX_OUTPUTS) {
return {
valid: false,
error: `Output count ${outputCount} exceeds max ${BULLETPROOF_PLUS_MAX_OUTPUTS}`
};
}
return { valid: true };
}
/**
* Validate ring size
*
* @param {number} ringSize - Ring size (number of decoys + 1)
* @param {number} version - Hard fork version
* @returns {{ valid: boolean, error?: string }}
*/
export function validateRingSize(ringSize, version = 1) {
if (ringSize < 1) {
return { valid: false, error: 'Ring size must be at least 1' };
}
// Salvium requires ring size of 16
if (ringSize !== DEFAULT_RING_SIZE) {
return {
valid: false,
error: `Ring size must be ${DEFAULT_RING_SIZE}, got ${ringSize}`
};
}
return { valid: true };
}
// =============================================================================
// EXPORTS
// =============================================================================
export default {
// Constants
MONEY_SUPPLY,
EMISSION_SPEED_FACTOR_PER_MINUTE,
FINAL_SUBSIDY_PER_MINUTE,
COIN,
PREMINE_AMOUNT,
DIFFICULTY_TARGET_V1,
DIFFICULTY_TARGET_V2,
DIFFICULTY_WINDOW,
DIFFICULTY_WINDOW_V2,
CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW,
CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE,
CRYPTONOTE_MAX_TX_SIZE,
DEFAULT_RING_SIZE,
FEE_PER_KB,
FEE_PER_BYTE,
HF_VERSION,
NETWORK_ID,
// Functions
getNetworkConfig,
getBlockReward,
nextDifficulty,
nextDifficultyV2,
checkHash,
getMedianTimestamp,
validateBlockTimestamp,
isOutputUnlocked,
isCoinbaseMature,
meetsMinimumAge,
getMinimumFee,
getDynamicFee,
validateBlockLinkage,
validateBlockWeight,
validateTxSize,
validateRingSize,
};
+35 -11
View File
@@ -1,10 +1,12 @@
/**
* Ed25519 Elliptic Curve Operations
*
* Direct port from Salvium ref10 logic using BigInt for correctness.
* Field elements are represented as BigInt in range [0, p) where p = 2^255 - 19
* Uses @noble/ed25519 for optimized scalar multiplication (hot path).
* Falls back to BigInt implementation for specialized operations.
*/
import { Point as NoblePoint } from '@noble/ed25519';
// Prime field: p = 2^255 - 19
const P = (1n << 255n) - 19n;
@@ -316,28 +318,50 @@ export function scalarSub(r, a, b) {
/**
* Scalar multiplication with base point: s * G
* Uses @noble/ed25519 for optimized performance.
* @param {Uint8Array} s - 32-byte scalar
* @returns {Uint8Array} 32-byte compressed public key
*/
export function scalarMultBase(s) {
const scalar = scalarFromBytes(s);
const G = pointFromXY(GX, GY);
const result = scalarMult(G, scalar);
return pointToBytes(result);
try {
// Convert scalar to BigInt (little-endian)
let scalar = 0n;
for (let i = 0; i < 32; i++) {
scalar |= BigInt(s[i]) << BigInt(i * 8);
}
// Use @noble for fast base point multiplication
const result = NoblePoint.BASE.multiply(scalar);
return result.toBytes();
} catch (e) {
// Fallback to original implementation
const scalarBig = scalarFromBytes(s);
const G = pointFromXY(GX, GY);
const result = scalarMult(G, scalarBig);
return pointToBytes(result);
}
}
/**
* Scalar multiplication with arbitrary point: s * P
* Uses @noble/ed25519 for optimized performance.
* @param {Uint8Array} s - 32-byte scalar
* @param {Uint8Array} P - 32-byte compressed point
* @returns {Uint8Array|null} 32-byte compressed result, or null if P is invalid
*/
export function scalarMultPoint(s, P) {
const scalar = scalarFromBytes(s);
const point = pointFromBytes(P);
if (!point) return null;
const result = scalarMult(point, scalar);
return pointToBytes(result);
try {
// Convert scalar to BigInt (little-endian)
let scalar = 0n;
for (let i = 0; i < 32; i++) {
scalar |= BigInt(s[i]) << BigInt(i * 8);
}
// Use @noble for fast scalar multiplication
const point = NoblePoint.fromBytes(P);
const result = point.multiply(scalar);
return result.toBytes();
} catch (e) {
return null;
}
}
/**
+79
View File
@@ -37,6 +37,7 @@ export * from './multisig.js';
export * from './wallet-store.js';
export * from './wallet-sync.js';
export * from './persistent-wallet.js';
export * from './consensus.js';
// RandomX proof-of-work (WASM-JIT implementation)
export * as randomx from './randomx/index.js';
@@ -571,6 +572,84 @@ import {
openPersistentWallet
} from './persistent-wallet.js';
import {
// Constants
MONEY_SUPPLY,
EMISSION_SPEED_FACTOR_PER_MINUTE,
FINAL_SUBSIDY_PER_MINUTE,
COIN,
CRYPTONOTE_DISPLAY_DECIMAL_POINT,
PREMINE_AMOUNT,
PREMINE_AMOUNT_UPFRONT,
PREMINE_AMOUNT_MONTHLY,
TREASURY_SAL1_MINT_AMOUNT,
TREASURY_SAL1_MINT_COUNT,
DIFFICULTY_TARGET_V1,
DIFFICULTY_TARGET_V2 as CONSENSUS_DIFFICULTY_TARGET,
CRYPTONOTE_BLOCK_FUTURE_TIME_LIMIT,
BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW,
DIFFICULTY_WINDOW,
DIFFICULTY_WINDOW_V2,
DIFFICULTY_LAG,
DIFFICULTY_CUT,
DIFFICULTY_BLOCKS_COUNT,
DIFFICULTY_BLOCKS_COUNT_V2,
CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V1,
CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V2,
CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V5,
CRYPTONOTE_LONG_TERM_BLOCK_WEIGHT_WINDOW_SIZE,
CRYPTONOTE_SHORT_TERM_BLOCK_WEIGHT_SURGE_FACTOR,
CRYPTONOTE_MAX_TX_SIZE,
CRYPTONOTE_MAX_TX_PER_BLOCK,
MAX_TX_EXTRA_SIZE,
BULLETPROOF_MAX_OUTPUTS,
BULLETPROOF_PLUS_MAX_OUTPUTS,
CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW,
CRYPTONOTE_LOCKED_TX_ALLOWED_DELTA_BLOCKS,
CURRENT_TRANSACTION_VERSION,
TRANSACTION_VERSION_2_OUTS,
TRANSACTION_VERSION_N_OUTS,
TRANSACTION_VERSION_CARROT,
DYNAMIC_FEE_REFERENCE_TRANSACTION_WEIGHT,
DEFAULT_DUST_THRESHOLD,
BASE_REWARD_CLAMP_THRESHOLD,
CRYPTONOTE_MEMPOOL_TX_LIVETIME,
CRYPTONOTE_MEMPOOL_TX_FROM_ALT_BLOCK_LIVETIME,
DEFAULT_TXPOOL_MAX_WEIGHT,
DEFAULT_RING_SIZE as CONSENSUS_DEFAULT_RING_SIZE,
PRICING_RECORD_VALID_BLOCKS,
PRICING_RECORD_VALID_TIME_DIFF_FROM_BLOCK,
BURN_LOCK_PERIOD,
CONVERT_LOCK_PERIOD,
HF_VERSION,
NETWORK_ID,
MAINNET_CONFIG,
TESTNET_CONFIG,
STAGENET_CONFIG,
// Functions
getNetworkConfig,
getMinBlockWeight,
getBlockReward,
getApproximateEmission,
nextDifficulty,
nextDifficultyV2,
checkHash as consensusCheckHash,
getMedianTimestamp,
validateBlockTimestamp,
isOutputUnlocked,
isCoinbaseMature,
meetsMinimumAge,
getMinimumFee,
getDynamicFee,
quantizeFee,
validateBlockLinkage,
validateBlockWeight,
validateTxSize,
validateTxExtraSize,
validateOutputCount,
validateRingSize
} from './consensus.js';
// Main API object
const salvium = {
// Constants
+29 -14
View File
@@ -110,30 +110,43 @@ export function mnemonicToSeed(mnemonic, options = {}) {
}
// Verify checksum (word 25)
// Checksum = first N letters of each of first 24 words (N = prefixLength), concatenated, then CRC32
// Salvium checksum: CRC32 of prefixes → mod 24 → the checksum word should match seed[index]
const checksumData = words.slice(0, 24).map(w => w.slice(0, prefixLength)).join('');
const expectedChecksum = crc32(checksumData) % WORD_LIST_SIZE;
const checksumIndex = crc32(checksumData) % 24;
if (indices[24] !== expectedChecksum) {
// The checksum word should match the word at checksumIndex (by prefix)
const expectedPrefix = words[checksumIndex].slice(0, prefixLength);
const actualPrefix = words[24].slice(0, prefixLength);
if (expectedPrefix !== actualPrefix) {
return {
valid: false,
seed: null,
language,
error: `Checksum mismatch: expected "${wordList[expectedChecksum]}", got "${words[24]}"`
error: `Checksum mismatch: expected "${words[checksumIndex]}", got "${words[24]}"`
};
}
// Decode 24 words to 256-bit seed
// Each group of 3 words encodes 32 bits: val = w1 + w2*1626 + w3*1626^2
// Salvium uses modified base-1626 encoding with wrapping for error detection
// Formula: val = w1 + N * (((N - w1) + w2) % N) + N^2 * (((N - w2) + w3) % N)
const seed = new Uint8Array(32);
const N = WORD_LIST_SIZE;
for (let i = 0; i < 8; i++) {
const w1 = indices[i * 3];
const w2 = indices[i * 3 + 1];
const w3 = indices[i * 3 + 2];
// Decode: val = w1 + w2*n + w3*n^2 where n=1626
let val = w1 + w2 * WORD_LIST_SIZE + w3 * WORD_LIST_SIZE * WORD_LIST_SIZE;
// Salvium/Monero electrum-style decoding with wrapping
const val = w1 +
N * (((N - w1) + w2) % N) +
N * N * (((N - w2) + w3) % N);
// Verify the encoding is valid (val % N should equal w1)
if (val % N !== w1) {
return { valid: false, seed: null, language, error: `Invalid word encoding at position ${i * 3 + 1}` };
}
// Store as 4 little-endian bytes
seed[i * 4] = val & 0xFF;
@@ -164,8 +177,11 @@ export function seedToMnemonic(seed, options = {}) {
const { wordList, prefixLength } = resolved;
const words = [];
const N = WORD_LIST_SIZE;
// Encode each 4 bytes (32 bits) as 3 words
// Salvium uses modified base-1626 encoding with wrapping for error detection
// Formula: w1 = val % N, w2 = ((val/N) + w1) % N, w3 = ((val/N/N) + w2) % N
for (let i = 0; i < 8; i++) {
let val = seed[i * 4] |
(seed[i * 4 + 1] << 8) |
@@ -175,19 +191,18 @@ export function seedToMnemonic(seed, options = {}) {
// Convert to unsigned
val = val >>> 0;
const w1 = val % WORD_LIST_SIZE;
val = Math.floor(val / WORD_LIST_SIZE);
const w2 = val % WORD_LIST_SIZE;
val = Math.floor(val / WORD_LIST_SIZE);
const w3 = val % WORD_LIST_SIZE;
const w1 = val % N;
const w2 = (Math.floor(val / N) + w1) % N;
const w3 = (Math.floor(val / N / N) + w2) % N;
words.push(wordList[w1], wordList[w2], wordList[w3]);
}
// Calculate checksum word
// Salvium checksum: CRC32 of prefixes → mod 24 → repeat seed[index] as checksum
const checksumData = words.map(w => w.slice(0, prefixLength)).join('');
const checksumIndex = crc32(checksumData) % WORD_LIST_SIZE;
words.push(wordList[checksumIndex]);
const checksumIndex = crc32(checksumData) % 24;
words.push(words[checksumIndex]);
return words.join(' ');
}
+23 -2
View File
@@ -384,19 +384,38 @@ export class DaemonRPC extends RPCClient {
// ============================================================
/**
* Get outputs by index
* Get outputs by index (JSON format)
* @param {Object[]} outputs - Array of {amount, index} objects
* @param {Object} [options={}] - Options
* @param {boolean} [options.get_txid=false] - Include transaction IDs
* @param {string} [options.asset_type='SAL'] - Asset type (Salvium-specific)
* @returns {Promise<RPCResponse>} Output data
*/
async getOuts(outputs, options = {}) {
return this.post('/get_outs', {
outputs,
get_txid: options.get_txid || false
get_txid: options.get_txid || false,
asset_type: options.asset_type || 'SAL'
});
}
/**
* Get outputs by index (binary format - more efficient)
* Returns: key, mask, unlocked, height, txid, output_id for each output
* @param {Object[]} outputs - Array of {amount, index} objects
* @param {Object} [options={}] - Options
* @param {boolean} [options.get_txid=true] - Include transaction IDs
* @param {string} [options.asset_type='SAL'] - Asset type (Salvium-specific)
* @returns {Promise<RPCResponse>} Output data with outs[]
*/
async getOutputs(outputs, options = {}) {
return this.post('/get_outs.bin', {
outputs,
get_txid: options.get_txid !== false,
asset_type: options.asset_type || 'SAL'
}, 'binary');
}
/**
* Get output histogram (distribution by age)
* @param {Object} [options={}] - Options
@@ -423,6 +442,7 @@ export class DaemonRPC extends RPCClient {
* @param {Object} [options={}] - Options
* @param {number} [options.from_height=0] - Start height
* @param {number} [options.to_height] - End height
* @param {string} [options.rct_asset_type='SAL'] - RCT asset type (Salvium-specific)
* @param {boolean} [options.cumulative=false] - Return cumulative distribution
* @param {boolean} [options.binary=true] - Use binary format
* @param {boolean} [options.compress=false] - Compress response
@@ -433,6 +453,7 @@ export class DaemonRPC extends RPCClient {
amounts,
from_height: options.from_height || 0,
to_height: options.to_height,
rct_asset_type: options.rct_asset_type || 'SAL',
cumulative: options.cumulative || false,
binary: options.binary !== false,
compress: options.compress || false
+61 -35
View File
@@ -16,6 +16,7 @@
import { keccak256 } from './keccak.js';
import { hexToBytes, bytesToHex } from './address.js';
import { Point as NoblePoint } from '@noble/ed25519';
import {
scalarMultBase,
scalarMultPoint,
@@ -128,23 +129,29 @@ export function generateKeyDerivation(txPubKey, viewSecretKey) {
viewSecretKey = hexToBytes(viewSecretKey);
}
// D = viewSecretKey * txPubKey (scalar mult)
const result = scalarMultPoint(viewSecretKey, txPubKey);
if (!result) return null;
try {
// Convert scalar to BigInt (little-endian)
let scalar = 0n;
for (let i = 0; i < 32; i++) {
scalar |= BigInt(viewSecretKey[i]) << BigInt(i * 8);
}
// Multiply by cofactor 8 to clear small subgroup components
// This is done by doubling 3 times (8 = 2^3)
let point = pointFromBytes(result);
if (!point) return null;
// D = viewSecretKey * txPubKey (scalar mult)
const point = NoblePoint.fromBytes(txPubKey);
const result = point.multiply(scalar);
// 8 * point via repeated doubling
// Since we're using extended coordinates, we do: result + result (3 times)
// Or equivalently, multiply scalar by 8
const eight = new Uint8Array(32);
eight[0] = 8;
const derivation = scalarMultPoint(eight, result);
// Multiply by cofactor 8 using fast doubling (8 = 2^3)
const derivation = result.double().double().double();
return derivation;
return derivation.toBytes();
} catch (e) {
// Fallback to original implementation
const result = scalarMultPoint(viewSecretKey, txPubKey);
if (!result) return null;
const eight = new Uint8Array(32);
eight[0] = 8;
return scalarMultPoint(eight, result);
}
}
/**
@@ -253,11 +260,25 @@ export function deriveSubaddressPublicKey(outputKey, derivation, outputIndex) {
derivation = hexToBytes(derivation);
}
// Validate inputs
if (!outputKey || !(outputKey instanceof Uint8Array) || outputKey.length !== 32) {
return null;
}
if (!derivation || !(derivation instanceof Uint8Array) || derivation.length !== 32) {
return null;
}
// scalar = H_s(derivation || output_index)
const scalar = derivationToScalar(derivation, outputIndex);
if (!scalar) {
return null;
}
// scalar * G
const scalarG = scalarMultBase(scalar);
if (!scalarG || scalarG.length !== 32) {
return null;
}
// Negate the point (subtract instead of add)
// In Ed25519, negating a point means negating the x-coordinate
@@ -276,11 +297,13 @@ export function deriveSubaddressPublicKey(outputKey, derivation, outputIndex) {
/**
* Derive view tag for fast output filtering
* view_tag = first byte of H(derivation || varint(output_index))
* view_tag = first byte of H("view_tag" || derivation || varint(output_index))
*
* View tags allow wallets to quickly filter out non-owned outputs
* without performing the full stealth address derivation.
*
* Matches Salvium C++ crypto_ops::derive_view_tag in crypto.cpp
*
* @param {Uint8Array|string} derivation - 32-byte key derivation
* @param {number} outputIndex - Output index
* @returns {number} Single byte view tag (0-255)
@@ -290,11 +313,15 @@ export function deriveViewTag(derivation, outputIndex) {
derivation = hexToBytes(derivation);
}
// Same hash computation as derivationToScalar
// Salvium uses "view_tag" (8 bytes) as salt prefix
const salt = new TextEncoder().encode('view_tag'); // 8 bytes
const indexBytes = encodeVarint(outputIndex);
const input = new Uint8Array(derivation.length + indexBytes.length);
input.set(derivation);
input.set(indexBytes, derivation.length);
// Build: salt || derivation || varint(output_index)
const input = new Uint8Array(salt.length + derivation.length + indexBytes.length);
input.set(salt);
input.set(derivation, salt.length);
input.set(indexBytes, salt.length + derivation.length);
const hash = keccak256(input);
@@ -345,26 +372,21 @@ function genCommitmentMask(sharedSecret) {
}
/**
* Compute shared secret for output
* This is the key used for ecdhEncode/ecdhDecode
* Compute shared secret for output (used for ECDH amount encoding/decoding)
*
* This is equivalent to Salvium's derivation_to_scalar, which computes:
* hash_to_scalar(derivation || varint(output_index))
*
* Matches Salvium C++ crypto_ops::derivation_to_scalar in crypto.cpp
*
* @param {Uint8Array|string} derivation - 32-byte key derivation
* @param {number} outputIndex - Output index
* @returns {Uint8Array} 32-byte shared secret
* @returns {Uint8Array} 32-byte scalar (reduced mod L)
*/
export function computeSharedSecret(derivation, outputIndex) {
// The shared secret is derived from derivation + output index
// It's essentially the same as derivationToScalar but used differently
if (typeof derivation === 'string') {
derivation = hexToBytes(derivation);
}
const indexBytes = encodeVarint(outputIndex);
const input = new Uint8Array(derivation.length + indexBytes.length);
input.set(derivation);
input.set(indexBytes, derivation.length);
return keccak256(input);
// Salvium uses derivation_to_scalar which calls hash_to_scalar
// This hashes and then reduces mod L (curve order)
return derivationToScalar(derivation, outputIndex);
}
/**
@@ -616,6 +638,9 @@ export function scanTransaction(tx, viewSecretKey, spendPubKey) {
// Exports
// ============================================================================
// Named export for scalarAdd
export { scalarAdd };
export default {
// Key derivation
generateKeyDerivation,
@@ -640,5 +665,6 @@ export default {
scanTransaction,
// Utilities
encodeVarint: encodeVarint
encodeVarint: encodeVarint,
scalarAdd
};
+116 -7
View File
@@ -80,15 +80,16 @@ function uint32ToLE(value) {
* @returns {Uint8Array} 32-byte subaddress secret key
*/
export function cnSubaddressSecretKey(viewSecretKey, major, minor) {
// Domain separator: "SubAddr" (7 bytes, no null terminator in hash)
const domainSep = new TextEncoder().encode('SubAddr');
// Domain separator: "SubAddr\0" (8 bytes, INCLUDING null terminator)
// Salvium uses sizeof(HASH_KEY_SUBADDRESS) which includes the null byte
const domainSep = new Uint8Array([0x53, 0x75, 0x62, 0x41, 0x64, 0x64, 0x72, 0x00]); // "SubAddr\0"
// Build data: "SubAddr" || k_view || major_LE || minor_LE
const data = new Uint8Array(7 + 32 + 4 + 4);
// Build data: "SubAddr\0" || k_view || major_LE || minor_LE
const data = new Uint8Array(8 + 32 + 4 + 4);
data.set(domainSep, 0);
data.set(viewSecretKey, 7);
data.set(uint32ToLE(major), 7 + 32);
data.set(uint32ToLE(minor), 7 + 32 + 4);
data.set(viewSecretKey, 8);
data.set(uint32ToLE(major), 8 + 32);
data.set(uint32ToLE(minor), 8 + 32 + 4);
return hashToScalar(data);
}
@@ -328,6 +329,107 @@ export function isValidPaymentId(paymentId) {
return !allZeros;
}
// ============================================================================
// Subaddress Map Generation (matches C++ wallet lookahead behavior)
// ============================================================================
/**
* Default lookahead values from Salvium C++ wallet
*/
export const SUBADDRESS_LOOKAHEAD_MAJOR = 50;
export const SUBADDRESS_LOOKAHEAD_MINOR = 200;
/**
* Convert bytes to hex string
* @param {Uint8Array} bytes
* @returns {string}
*/
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Generate CryptoNote subaddress lookup map
* Maps: spendPublicKey (hex) → {major, minor}
*
* @param {Uint8Array} spendPublicKey - Main spend public key
* @param {Uint8Array} viewSecretKey - View secret key
* @param {number} [majorLookahead=50] - Number of major indices
* @param {number} [minorLookahead=200] - Number of minor indices per major
* @returns {Map<string, {major: number, minor: number}>}
*/
export function generateCNSubaddressMap(spendPublicKey, viewSecretKey, majorLookahead = SUBADDRESS_LOOKAHEAD_MAJOR, minorLookahead = SUBADDRESS_LOOKAHEAD_MINOR) {
const map = new Map();
for (let major = 0; major <= majorLookahead; major++) {
for (let minor = 0; minor <= minorLookahead; minor++) {
const subaddr = cnSubaddress(spendPublicKey, viewSecretKey, major, minor);
const spendPubkeyHex = bytesToHex(subaddr.spendPublicKey);
map.set(spendPubkeyHex, { major, minor });
}
}
return map;
}
/**
* Generate CARROT subaddress lookup map
* Maps: spendPublicKey (hex) → {major, minor}
*
* @param {Uint8Array} accountSpendPubkey - K_s (account spend pubkey)
* @param {Uint8Array} accountViewPubkey - K_v = k_vi * K_s
* @param {Uint8Array} generateAddressSecret - s_ga
* @param {number} [majorLookahead=50] - Number of major indices
* @param {number} [minorLookahead=200] - Number of minor indices per major
* @returns {Map<string, {major: number, minor: number}>}
*/
export function generateCarrotSubaddressMap(accountSpendPubkey, accountViewPubkey, generateAddressSecret, majorLookahead = SUBADDRESS_LOOKAHEAD_MAJOR, minorLookahead = SUBADDRESS_LOOKAHEAD_MINOR) {
const map = new Map();
for (let major = 0; major <= majorLookahead; major++) {
for (let minor = 0; minor <= minorLookahead; minor++) {
const subaddr = carrotSubaddress(accountSpendPubkey, accountViewPubkey, generateAddressSecret, major, minor);
const spendPubkeyHex = bytesToHex(subaddr.spendPublicKey);
map.set(spendPubkeyHex, { major, minor });
}
}
return map;
}
/**
* Generate both CN and CARROT subaddress maps
* This matches the C++ wallet behavior of generating both derivation types
*
* @param {Object} keys - Wallet keys
* @param {Uint8Array} keys.spendPublicKey - CN main spend public key
* @param {Uint8Array} keys.viewSecretKey - CN view secret key
* @param {Uint8Array} keys.accountSpendPubkey - CARROT K_s
* @param {Uint8Array} keys.accountViewPubkey - CARROT K_v
* @param {Uint8Array} keys.generateAddressSecret - CARROT s_ga
* @param {number} [majorLookahead=50] - Number of major indices
* @param {number} [minorLookahead=200] - Number of minor indices per major
* @returns {Object} { cnSubaddresses: Map, carrotSubaddresses: Map }
*/
export function generateSubaddressMaps(keys, majorLookahead = SUBADDRESS_LOOKAHEAD_MAJOR, minorLookahead = SUBADDRESS_LOOKAHEAD_MINOR) {
const cnSubaddresses = generateCNSubaddressMap(
keys.spendPublicKey,
keys.viewSecretKey,
majorLookahead,
minorLookahead
);
const carrotSubaddresses = generateCarrotSubaddressMap(
keys.accountSpendPubkey,
keys.accountViewPubkey,
keys.generateAddressSecret,
majorLookahead,
minorLookahead
);
return { cnSubaddresses, carrotSubaddresses };
}
export default {
// CryptoNote
cnSubaddressSecretKey,
@@ -339,6 +441,13 @@ export default {
carrotSubaddressScalar,
carrotSubaddress,
// Subaddress map generation
generateCNSubaddressMap,
generateCarrotSubaddressMap,
generateSubaddressMaps,
SUBADDRESS_LOOKAHEAD_MAJOR,
SUBADDRESS_LOOKAHEAD_MINOR,
// Integrated address utilities
generatePaymentId,
isValidPaymentId,
+1146 -149
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -249,6 +249,8 @@ export class WalletTransaction {
// Salvium-specific
this.txType = data.txType || 3; // TX_TYPE
this.assetType = data.assetType || 'SAL';
this.isMinerTx = data.isMinerTx || false; // Coinbase (block reward)
this.isProtocolTx = data.isProtocolTx || false; // Protocol tx (yields, conversions, refunds)
// Note (user-defined)
this.note = data.note || '';
+1025 -95
View File
File diff suppressed because it is too large Load Diff
+123 -5
View File
@@ -23,6 +23,7 @@ import { generateKeyDerivation, derivationToScalar, deriveSecretKey, scanTransac
import { generateKeyImage } from './keyimage.js';
import {
buildTransaction,
buildStakeTransaction,
signTransaction,
prepareInputs,
selectUTXOs,
@@ -32,6 +33,7 @@ import {
UTXO_STRATEGY
} from './transaction.js';
import { NETWORK, ADDRESS_FORMAT } from './constants.js';
import { getNetworkConfig } from './consensus.js';
import { seedToMnemonic, mnemonicToSeed, validateMnemonic } from './mnemonic.js';
// ============================================================================
@@ -1210,14 +1212,130 @@ export class Wallet {
/**
* Create a stake transaction (Salvium-specific)
* @param {bigint} amount - Amount to stake
*
* Stakes the specified amount for STAKE_LOCK_PERIOD blocks to earn yield.
* The staked amount is locked and returned via PROTOCOL transaction after maturity.
*
* @param {bigint|number|string} amount - Amount to stake (in atomic units)
* @param {Object} options - Options
* @returns {Promise<Object>} Stake transaction
* @param {string} options.assetType - Asset type to stake ('SAL' or 'SAL1', default: 'SAL')
* @param {number} options.accountIndex - Account index (default: 0)
* @param {number} options.ringSize - Ring size for privacy (default: 16)
* @param {string} options.priority - Fee priority ('low', 'default', 'high')
* @param {Object} options.rpcClient - RPC client for fetching decoys
* @returns {Promise<Object>} Stake transaction ready for broadcast
*/
async createStakeTransaction(amount, options = {}) {
// TODO: Implement stake transaction following Salvium spec
// This creates a TX_TYPE.STAKE transaction
throw new Error('Stake transactions not yet implemented');
if (!this.canSign()) {
throw new Error('Full wallet required to create stake transactions');
}
const {
assetType = 'SAL',
accountIndex = 0,
ringSize = 16,
priority = 'default',
rpcClient = null
} = options;
// Validate asset type
if (assetType !== 'SAL' && assetType !== 'SAL1') {
throw new Error('STAKE transactions must use SAL or SAL1 asset type');
}
// Convert amount to bigint
const stakeAmount = typeof amount === 'bigint' ? amount :
typeof amount === 'string' ? BigInt(amount) : BigInt(Math.floor(amount));
if (stakeAmount <= 0n) {
throw new Error('Stake amount must be positive');
}
// Get network config for STAKE_LOCK_PERIOD
const networkConfig = getNetworkConfig(this.network);
const stakeLockPeriod = networkConfig.STAKE_LOCK_PERIOD;
// Estimate fee (STAKE tx has 1 input minimum, 1 output - change only)
const estimatedFee = estimateTransactionFee(
1, // inputs
1, // outputs (change only)
{ priority, ringSize }
);
// Select UTXOs from specified account
const availableUTXOs = this.getUTXOs({
unlockedOnly: true,
accountIndex,
assetType
});
if (availableUTXOs.length === 0) {
throw new Error(`No unlocked ${assetType} outputs available for staking`);
}
// Select UTXOs to cover stake amount + fee
const { selected, changeAmount } = selectUTXOs(
availableUTXOs,
stakeAmount,
estimatedFee,
{
strategy: UTXO_STRATEGY.LARGEST_FIRST,
currentHeight: this._syncHeight,
dustThreshold: 1000000n
}
);
if (selected.length === 0) {
throw new Error(`Insufficient ${assetType} balance for stake of ${stakeAmount} + fee ${estimatedFee}`);
}
// Prepare inputs with ring members (decoys)
const preparedInputs = await prepareInputs(selected, rpcClient, { ringSize });
// Recalculate fee with actual input count
const actualFee = estimateTransactionFee(
preparedInputs.length,
1, // Only change output
{ priority, ringSize }
);
// Return address is own address (stake returns to self)
const returnAddress = {
viewPublicKey: this._viewPublicKey,
spendPublicKey: this._spendPublicKey,
isSubaddress: false
};
// Build the stake transaction
const tx = buildStakeTransaction(
{
inputs: preparedInputs,
stakeAmount,
returnAddress,
fee: actualFee
},
{
stakeLockPeriod,
assetType,
useCarrot: false // TODO: Support CARROT staking when needed
}
);
// Validate
const validation = validateTransaction(tx);
if (!validation.valid) {
throw new Error(`Stake transaction validation failed: ${validation.errors.join(', ')}`);
}
// Add metadata for tracking
tx._meta = tx._meta || {};
tx._meta.txType = TX_TYPE.STAKE;
tx._meta.stakeAmount = stakeAmount.toString();
tx._meta.stakeLockPeriod = stakeLockPeriod;
tx._meta.unlockHeight = this._syncHeight + stakeLockPeriod;
tx._meta.assetType = assetType;
return tx;
}
/**