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:
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+123
-5
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user