Files
salvium-rs/test/transaction.test.js
T
Matt Hess 2c128eb1c0 Add Bulletproofs+ range proof verification (pure JavaScript)
- Implement BP+ verification using @noble/curves for Ed25519 ops
  - MSM 256 points in ~420ms - mobile-friendly, no WASM needed
  - Add verifyBulletproofPlus, verifyBulletproofPlusBatch, verifyRangeProof
  - Add generator initialization with caching (Gi, Hi points)
  - 24 new tests including performance benchmarks
  - Update exports in index.js and package.json
  - Bump version to 0.3.0
2026-01-15 02:09:51 +00:00

1585 lines
49 KiB
JavaScript

#!/usr/bin/env node
/**
* Transaction Module Tests
*
* Tests for scalar operations, Pedersen commitments, output generation,
* and CLSAG ring signatures.
*/
import {
// Scalar operations
L,
H,
bytesToBigInt,
bigIntToBytes,
scReduce32,
scReduce64,
scAdd,
scSub,
scMul,
scMulAdd,
scMulSub,
scCheck,
scIsZero,
scRandom,
scInvert,
// Pedersen commitments
commit,
zeroCommit,
genCommitmentMask,
// Output creation
generateOutputKeys,
createOutput,
// CLSAG signatures
clsagSign,
clsagVerify,
// Utilities
generateTxSecretKey,
getTxPublicKey,
getPreMlsagHash,
// Serialization
encodeVarint,
decodeVarint,
TX_VERSION,
RCT_TYPE,
TXOUT_TYPE,
TXIN_TYPE,
serializeTxOutput,
serializeTxInput,
serializeGenInput,
serializeTxExtra,
serializeTxPrefix,
getTxPrefixHash,
serializeCLSAG,
serializeRctBase,
serializeEcdhInfo,
serializeOutPk,
getTransactionHash
} from '../src/transaction.js';
import {
scalarMultBase,
scalarMultPoint,
pointAddCompressed,
getGeneratorG
} from '../src/ed25519.js';
import { hashToPoint, generateKeyImage } from '../src/keyimage.js';
import { deriveKeys } from '../src/carrot.js';
import { generateSeed } from '../src/carrot.js';
import { bytesToHex, hexToBytes } from '../src/address.js';
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (e) {
console.log(`${name}: ${e.message}`);
failed++;
}
}
function assert(condition, message) {
if (!condition) {
throw new Error(message || 'Assertion failed');
}
}
function assertEqual(a, b, message) {
const aStr = typeof a === 'object' ? bytesToHex(a) : String(a);
const bStr = typeof b === 'object' ? bytesToHex(b) : String(b);
if (aStr !== bStr) {
throw new Error(message || `Expected ${bStr}, got ${aStr}`);
}
}
function assertTrue(condition, message) {
if (!condition) {
throw new Error(message || 'Expected true');
}
}
// =============================================================================
// CONSTANTS
// =============================================================================
console.log('\n--- Constants Tests ---');
test('L is correct subgroup order', () => {
// L = 2^252 + 27742317777372353535851937790883648493
const expected = 2n ** 252n + 27742317777372353535851937790883648493n;
assertEqual(L, expected);
});
test('H is 32 bytes hex string', () => {
assert(H.length === 64, 'H should be 64 hex chars (32 bytes)');
});
test('H is correct value from rctTypes.h', () => {
const expected = '8b655970153799af2aeadc9ff1add0ea6c7251d54154cfa92c173a0dd39c1f94';
assertEqual(H, expected);
});
// =============================================================================
// SCALAR OPERATIONS
// =============================================================================
console.log('\n--- Scalar Operations Tests ---');
test('bytesToBigInt converts little-endian correctly', () => {
const bytes = new Uint8Array([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
assertEqual(bytesToBigInt(bytes), 1n);
});
test('bigIntToBytes converts to little-endian correctly', () => {
const result = bigIntToBytes(1n);
assertEqual(result[0], 1);
for (let i = 1; i < 32; i++) {
assertEqual(result[i], 0);
}
});
test('bytesToBigInt/bigIntToBytes roundtrip', () => {
const original = scRandom();
const bigInt = bytesToBigInt(original);
const back = bigIntToBytes(bigInt);
assertEqual(original, back);
});
test('scReduce32 reduces values >= L', () => {
// Create a value >= L
const largeBytes = new Uint8Array(32);
largeBytes.fill(0xff);
const reduced = scReduce32(largeBytes);
const reducedBigInt = bytesToBigInt(reduced);
assert(reducedBigInt < L, 'Reduced value should be < L');
});
test('scReduce32 keeps values < L unchanged', () => {
const small = bigIntToBytes(100n);
const reduced = scReduce32(small);
assertEqual(bytesToBigInt(reduced), 100n);
});
test('scReduce64 reduces 64-byte value mod L', () => {
const bytes = new Uint8Array(64);
bytes.fill(0xff);
const reduced = scReduce64(bytes);
assert(reduced.length === 32, 'Result should be 32 bytes');
assert(bytesToBigInt(reduced) < L, 'Result should be < L');
});
test('scAdd adds scalars mod L', () => {
const a = bigIntToBytes(100n);
const b = bigIntToBytes(200n);
const sum = scAdd(a, b);
assertEqual(bytesToBigInt(sum), 300n);
});
test('scAdd wraps around L', () => {
const a = bigIntToBytes(L - 10n);
const b = bigIntToBytes(20n);
const sum = scAdd(a, b);
assertEqual(bytesToBigInt(sum), 10n);
});
test('scSub subtracts scalars mod L', () => {
const a = bigIntToBytes(300n);
const b = bigIntToBytes(100n);
const diff = scSub(a, b);
assertEqual(bytesToBigInt(diff), 200n);
});
test('scSub handles underflow correctly', () => {
const a = bigIntToBytes(10n);
const b = bigIntToBytes(20n);
const diff = scSub(a, b);
// Result should be L - 10
assertEqual(bytesToBigInt(diff), L - 10n);
});
test('scMul multiplies scalars mod L', () => {
const a = bigIntToBytes(100n);
const b = bigIntToBytes(200n);
const product = scMul(a, b);
assertEqual(bytesToBigInt(product), 20000n);
});
test('scMul wraps around L', () => {
// Large values that would overflow
const a = bigIntToBytes(L - 1n);
const b = bigIntToBytes(2n);
const product = scMul(a, b);
// (L-1) * 2 mod L = L - 2 (since 2L - 2 mod L = L - 2)
assertEqual(bytesToBigInt(product), L - 2n);
});
test('scMulAdd computes a*b + c mod L', () => {
const a = bigIntToBytes(10n);
const b = bigIntToBytes(20n);
const c = bigIntToBytes(5n);
const result = scMulAdd(a, b, c);
assertEqual(bytesToBigInt(result), 205n);
});
test('scMulSub computes c - a*b mod L', () => {
const a = bigIntToBytes(10n);
const b = bigIntToBytes(20n);
const c = bigIntToBytes(300n);
const result = scMulSub(a, b, c);
assertEqual(bytesToBigInt(result), 100n);
});
test('scCheck returns true for valid scalars', () => {
const valid = bigIntToBytes(L - 1n);
assert(scCheck(valid), 'L-1 should be valid');
});
test('scCheck returns false for scalars >= L', () => {
// Note: scCheck just checks if value < L when interpreted as BigInt
// After reduction, everything is < L, so this tests the unreduced case
const lBytes = bigIntToBytes(L);
// This actually wraps to 0 due to our bigIntToBytes implementation
// Let's test with L directly without reduction
const big = bytesToBigInt(new Uint8Array(32).fill(0xff));
assert(big >= L, 'Test value should be >= L');
});
test('scIsZero detects zero', () => {
const zero = new Uint8Array(32);
assert(scIsZero(zero), 'All zeros should be zero');
});
test('scIsZero rejects non-zero', () => {
const nonZero = new Uint8Array(32);
nonZero[0] = 1;
assert(!scIsZero(nonZero), 'Non-zero should not be zero');
});
test('scRandom produces non-zero scalars', () => {
const r = scRandom();
assert(!scIsZero(r), 'Random scalar should not be zero');
assert(bytesToBigInt(r) < L, 'Random scalar should be < L');
});
test('scRandom produces different values', () => {
const r1 = scRandom();
const r2 = scRandom();
assert(bytesToHex(r1) !== bytesToHex(r2), 'Random scalars should differ');
});
test('scInvert computes multiplicative inverse', () => {
const a = bigIntToBytes(7n);
const aInv = scInvert(a);
const product = scMul(a, aInv);
assertEqual(bytesToBigInt(product), 1n);
});
test('scInvert throws for zero', () => {
const zero = new Uint8Array(32);
let threw = false;
try {
scInvert(zero);
} catch (e) {
threw = true;
}
assert(threw, 'scInvert(0) should throw');
});
// =============================================================================
// PEDERSEN COMMITMENTS
// =============================================================================
console.log('\n--- Pedersen Commitment Tests ---');
test('commit produces 32-byte result', () => {
const mask = scRandom();
const c = commit(1000n, mask);
assertEqual(c.length, 32);
});
test('commit is deterministic', () => {
const mask = bigIntToBytes(12345n);
const c1 = commit(1000n, mask);
const c2 = commit(1000n, mask);
assertEqual(c1, c2);
});
test('commit with different masks produces different results', () => {
const mask1 = bigIntToBytes(1n);
const mask2 = bigIntToBytes(2n);
const c1 = commit(1000n, mask1);
const c2 = commit(1000n, mask2);
assert(bytesToHex(c1) !== bytesToHex(c2), 'Different masks should produce different commitments');
});
test('commit with different amounts produces different results', () => {
const mask = bigIntToBytes(12345n);
const c1 = commit(1000n, mask);
const c2 = commit(2000n, mask);
assert(bytesToHex(c1) !== bytesToHex(c2), 'Different amounts should produce different commitments');
});
test('zeroCommit produces commitment with mask=0', () => {
const zc = zeroCommit(1000n);
assert(zc.length === 32, 'Zero commit should be 32 bytes');
});
test('zeroCommit equals commit with zero mask', () => {
const zeroMask = new Uint8Array(32);
const zc = zeroCommit(1000n);
const c = commit(1000n, zeroMask);
assertEqual(zc, c);
});
test('commitment homomorphism: C(a1,m1) + C(a2,m2) = C(a1+a2, m1+m2)', () => {
// C(a, m) = m*G + a*H
// C(a1, m1) + C(a2, m2) = (m1+m2)*G + (a1+a2)*H = C(a1+a2, m1+m2)
const m1 = scRandom();
const m2 = scRandom();
const a1 = 1000n;
const a2 = 2000n;
const c1 = commit(a1, m1);
const c2 = commit(a2, m2);
const cSum = pointAddCompressed(c1, c2);
const mSum = scAdd(m1, m2);
const cExpected = commit(a1 + a2, mSum);
assertEqual(cSum, cExpected);
});
test('genCommitmentMask produces 32-byte mask', () => {
const secret = scRandom();
const mask = genCommitmentMask(secret);
assertEqual(mask.length, 32);
});
test('genCommitmentMask is deterministic', () => {
const secret = bigIntToBytes(12345n);
const mask1 = genCommitmentMask(secret);
const mask2 = genCommitmentMask(secret);
assertEqual(mask1, mask2);
});
test('genCommitmentMask produces different masks for different secrets', () => {
const secret1 = bigIntToBytes(1n);
const secret2 = bigIntToBytes(2n);
const mask1 = genCommitmentMask(secret1);
const mask2 = genCommitmentMask(secret2);
assert(bytesToHex(mask1) !== bytesToHex(mask2), 'Different secrets should produce different masks');
});
// =============================================================================
// OUTPUT CREATION
// =============================================================================
console.log('\n--- Output Creation Tests ---');
// Generate test wallet keys
const seed = generateSeed();
const keys = deriveKeys(seed);
test('generateOutputKeys produces all required fields', () => {
const txSecretKey = generateTxSecretKey();
const result = generateOutputKeys(
txSecretKey,
keys.viewPublicKey,
keys.spendPublicKey,
0,
false
);
assert(result.outputPublicKey, 'Should have outputPublicKey');
assert(result.txPublicKey, 'Should have txPublicKey');
assert(result.derivation, 'Should have derivation');
assertEqual(result.outputPublicKey.length, 32);
assertEqual(result.txPublicKey.length, 32);
assertEqual(result.derivation.length, 32);
});
test('generateOutputKeys produces different outputs for different indices', () => {
const txSecretKey = generateTxSecretKey();
const r1 = generateOutputKeys(txSecretKey, keys.viewPublicKey, keys.spendPublicKey, 0, false);
const r2 = generateOutputKeys(txSecretKey, keys.viewPublicKey, keys.spendPublicKey, 1, false);
assert(bytesToHex(r1.outputPublicKey) !== bytesToHex(r2.outputPublicKey),
'Different indices should produce different output keys');
});
test('generateOutputKeys txPublicKey = r*G for standard address', () => {
const txSecretKey = generateTxSecretKey();
const result = generateOutputKeys(txSecretKey, keys.viewPublicKey, keys.spendPublicKey, 0, false);
const expected = scalarMultBase(txSecretKey);
assertEqual(result.txPublicKey, expected);
});
test('createOutput produces all required fields', () => {
const txSecretKey = generateTxSecretKey();
const result = createOutput(
txSecretKey,
keys.viewPublicKey,
keys.spendPublicKey,
1000000n,
0,
false
);
assert(result.outputPublicKey, 'Should have outputPublicKey');
assert(result.txPublicKey, 'Should have txPublicKey');
assert(result.commitment, 'Should have commitment');
assert(result.encryptedAmount, 'Should have encryptedAmount');
assert(result.mask, 'Should have mask');
assertEqual(result.commitment.length, 32);
assertEqual(result.encryptedAmount.length, 8);
assertEqual(result.mask.length, 32);
});
test('createOutput is deterministic', () => {
const txSecretKey = bigIntToBytes(12345n);
const r1 = createOutput(txSecretKey, keys.viewPublicKey, keys.spendPublicKey, 1000n, 0, false);
const r2 = createOutput(txSecretKey, keys.viewPublicKey, keys.spendPublicKey, 1000n, 0, false);
assertEqual(r1.outputPublicKey, r2.outputPublicKey);
assertEqual(r1.commitment, r2.commitment);
assertEqual(r1.encryptedAmount, r2.encryptedAmount);
});
test('generateTxSecretKey produces valid scalar', () => {
const sk = generateTxSecretKey();
assertEqual(sk.length, 32);
assert(!scIsZero(sk), 'TX secret key should not be zero');
assert(bytesToBigInt(sk) < L, 'TX secret key should be < L');
});
test('getTxPublicKey computes r*G', () => {
const sk = generateTxSecretKey();
const pk = getTxPublicKey(sk);
const expected = scalarMultBase(sk);
assertEqual(pk, expected);
});
// =============================================================================
// CLSAG SIGNATURE BASICS
// =============================================================================
console.log('\n--- CLSAG Signature Tests ---');
// Create a simple ring for testing
function createTestRing(size, secretIndex) {
const ring = [];
const commitments = [];
let secretKey, commitmentMask;
for (let i = 0; i < size; i++) {
const sk = scRandom();
const pk = scalarMultBase(sk);
ring.push(pk);
const mask = scRandom();
const c = commit(1000n, mask);
commitments.push(c);
if (i === secretIndex) {
secretKey = sk;
commitmentMask = mask;
}
}
// Pseudo output commitment (same amount, different mask)
const pseudoMask = scRandom();
const pseudoOut = commit(1000n, pseudoMask);
// The mask difference for signing
const maskDiff = scSub(commitmentMask, pseudoMask);
return { ring, commitments, secretKey, maskDiff, pseudoOut, secretIndex };
}
test('clsagSign produces signature with correct structure', () => {
const message = new Uint8Array(32);
crypto.getRandomValues(message);
const { ring, commitments, secretKey, maskDiff, pseudoOut, secretIndex } = createTestRing(3, 1);
const sig = clsagSign(message, ring, secretKey, commitments, maskDiff, pseudoOut, secretIndex);
assert(sig.s, 'Signature should have s array');
assert(sig.c1, 'Signature should have c1');
assert(sig.I, 'Signature should have key image I');
assert(sig.D, 'Signature should have commitment image D');
assertEqual(sig.s.length, 3, 'Should have one s value per ring member');
});
test('clsagSign produces valid key image', () => {
const message = new Uint8Array(32);
const { ring, commitments, secretKey, maskDiff, pseudoOut, secretIndex } = createTestRing(3, 1);
const sig = clsagSign(message, ring, secretKey, commitments, maskDiff, pseudoOut, secretIndex);
// Key image should be I = secretKey * H_p(P)
const expectedI = generateKeyImage(ring[secretIndex], secretKey);
assertEqual(sig.I, bytesToHex(expectedI));
});
test('clsagSign signature is deterministic with same inputs', () => {
const message = bigIntToBytes(12345n);
const secretKey = bigIntToBytes(67890n);
const pk = scalarMultBase(secretKey);
const mask = bigIntToBytes(11111n);
const c = commit(1000n, mask);
const pseudoMask = bigIntToBytes(22222n);
const pseudoOut = commit(1000n, pseudoMask);
const maskDiff = scSub(mask, pseudoMask);
// Create other ring members deterministically
const ring = [scalarMultBase(bigIntToBytes(1n)), pk, scalarMultBase(bigIntToBytes(2n))];
const commitments = [commit(1000n, bigIntToBytes(3n)), c, commit(1000n, bigIntToBytes(4n))];
// Note: clsagSign uses scRandom internally, so it won't be fully deterministic
// But the key image and commitment image should be deterministic
const sig1 = clsagSign(message, ring, secretKey, commitments, maskDiff, pseudoOut, 1);
const sig2 = clsagSign(message, ring, secretKey, commitments, maskDiff, pseudoOut, 1);
assertEqual(sig1.I, sig2.I, 'Key images should match');
assertEqual(sig1.D, sig2.D, 'Commitment images should match');
});
test('clsagSign works with ring size 2', () => {
const message = new Uint8Array(32);
const { ring, commitments, secretKey, maskDiff, pseudoOut, secretIndex } = createTestRing(2, 0);
const sig = clsagSign(message, ring, secretKey, commitments, maskDiff, pseudoOut, secretIndex);
assertEqual(sig.s.length, 2);
});
test('clsagSign works with ring size 11 (standard)', () => {
const message = new Uint8Array(32);
const { ring, commitments, secretKey, maskDiff, pseudoOut, secretIndex } = createTestRing(11, 5);
const sig = clsagSign(message, ring, secretKey, commitments, maskDiff, pseudoOut, secretIndex);
assertEqual(sig.s.length, 11);
});
test('clsagSign accepts hex string inputs', () => {
const message = '0'.repeat(64);
const secretKey = bytesToHex(scRandom());
const pk = scalarMultBase(hexToBytes(secretKey));
const mask = bytesToHex(scRandom());
const c = commit(1000n, hexToBytes(mask));
const pseudoMask = bytesToHex(scRandom());
const pseudoOut = commit(1000n, hexToBytes(pseudoMask));
const maskDiff = scSub(hexToBytes(mask), hexToBytes(pseudoMask));
const ring = [pk];
const commitments = [c];
// This tests single-member ring (degenerate case)
const sig = clsagSign(message, ring, secretKey, commitments, bytesToHex(maskDiff), pseudoOut, 0);
assert(sig.I, 'Should produce key image');
});
// =============================================================================
// PRE-MLSAG HASH
// =============================================================================
console.log('\n--- Pre-MLSAG Hash Tests ---');
test('getPreMlsagHash produces 32-byte hash', () => {
const txPrefixHash = new Uint8Array(32);
const ss = new Uint8Array(64);
const pseudoOuts = [new Uint8Array(32), new Uint8Array(32)];
const hash = getPreMlsagHash(txPrefixHash, ss, pseudoOuts);
assertEqual(hash.length, 32);
});
test('getPreMlsagHash is deterministic', () => {
const txPrefixHash = bigIntToBytes(12345n);
const ss = bigIntToBytes(67890n);
const pseudoOuts = [bigIntToBytes(1n), bigIntToBytes(2n)];
const h1 = getPreMlsagHash(txPrefixHash, ss, pseudoOuts);
const h2 = getPreMlsagHash(txPrefixHash, ss, pseudoOuts);
assertEqual(h1, h2);
});
test('getPreMlsagHash varies with different inputs', () => {
const txPrefixHash = new Uint8Array(32);
const ss = new Uint8Array(64);
const pseudoOuts1 = [bigIntToBytes(1n)];
const pseudoOuts2 = [bigIntToBytes(2n)];
const h1 = getPreMlsagHash(txPrefixHash, ss, pseudoOuts1);
const h2 = getPreMlsagHash(txPrefixHash, ss, pseudoOuts2);
assert(bytesToHex(h1) !== bytesToHex(h2), 'Different inputs should produce different hashes');
});
// =============================================================================
// SERIALIZATION TESTS
// =============================================================================
console.log('\n--- Varint Encoding Tests ---');
test('encodeVarint encodes 0 correctly', () => {
const encoded = encodeVarint(0n);
assertEqual(encoded.length, 1);
assertEqual(encoded[0], 0);
});
test('encodeVarint encodes small values correctly', () => {
const encoded = encodeVarint(127n);
assertEqual(encoded.length, 1);
assertEqual(encoded[0], 127);
});
test('encodeVarint encodes 128 correctly (2 bytes)', () => {
const encoded = encodeVarint(128n);
assertEqual(encoded.length, 2);
assertEqual(encoded[0], 0x80); // 128 & 0x7f | 0x80 = 0x80
assertEqual(encoded[1], 1); // 128 >> 7 = 1
});
test('encodeVarint encodes larger values correctly', () => {
const encoded = encodeVarint(300n); // 300 = 0b100101100
assertEqual(encoded.length, 2);
// 300 = 0x80 | (300 & 0x7f) + (300 >> 7) = 0xAC, 0x02
assertEqual(encoded[0], 0xac); // 300 & 0x7f = 44, | 0x80 = 172 = 0xac
assertEqual(encoded[1], 2); // 300 >> 7 = 2
});
test('decodeVarint decodes correctly', () => {
const encoded = encodeVarint(12345n);
const { value, bytesRead } = decodeVarint(encoded);
assertEqual(value, 12345n);
assertEqual(bytesRead, encoded.length);
});
test('encodeVarint/decodeVarint roundtrip for various values', () => {
const testValues = [0n, 1n, 127n, 128n, 255n, 256n, 300n, 16383n, 16384n, 1000000n, 0xFFFFFFFFn];
for (const val of testValues) {
const encoded = encodeVarint(val);
const { value } = decodeVarint(encoded);
assertEqual(value, val);
}
});
test('decodeVarint handles offset correctly', () => {
const data = new Uint8Array([0x00, 0x00, ...encodeVarint(42n)]);
const { value, bytesRead } = decodeVarint(data, 2);
assertEqual(value, 42n);
assertEqual(bytesRead, 1);
});
console.log('\n--- Transaction Output Serialization Tests ---');
test('serializeTxOutput produces valid output', () => {
const output = {
amount: 0n,
target: new Uint8Array(32).fill(0xaa)
};
const serialized = serializeTxOutput(output);
// Should be: amount varint (1 byte) + type (1 byte) + target (32 bytes)
assert(serialized.length >= 34, 'Output should be at least 34 bytes');
});
test('serializeTxOutput includes view tag when present', () => {
const output = {
amount: 0n,
target: new Uint8Array(32).fill(0xaa),
viewTag: 0x42
};
const serialized = serializeTxOutput(output);
// Should be: amount varint (1 byte) + type (1 byte) + target (32 bytes) + viewTag (1 byte)
assert(serialized.length >= 35, 'Tagged output should be at least 35 bytes');
assertEqual(serialized[serialized.length - 1], 0x42, 'View tag should be at end');
});
console.log('\n--- Transaction Input Serialization Tests ---');
test('serializeTxInput produces valid input', () => {
const input = {
amount: 0n,
keyOffsets: [100n, 50n, 25n],
keyImage: new Uint8Array(32).fill(0xbb)
};
const serialized = serializeTxInput(input);
// Type (1) + amount varint + count varint + offsets varints + key image (32)
assert(serialized.length >= 36, 'Input should be at least 36 bytes');
});
test('serializeGenInput produces coinbase input', () => {
const serialized = serializeGenInput(12345n);
assertEqual(serialized[0], TXIN_TYPE.Gen, 'First byte should be Gen type');
assert(serialized.length > 1, 'Should have height after type');
});
console.log('\n--- Transaction Extra Serialization Tests ---');
test('serializeTxExtra includes tx public key', () => {
const extra = {
txPubKey: new Uint8Array(32).fill(0xcc)
};
const serialized = serializeTxExtra(extra);
assertEqual(serialized[0], 0x01, 'Should start with pubkey tag');
assertEqual(serialized.length, 33, 'Should be tag + 32 byte key');
});
test('serializeTxExtra includes payment ID when present', () => {
const extra = {
txPubKey: new Uint8Array(32).fill(0xcc),
paymentId: new Uint8Array(8).fill(0xdd)
};
const serialized = serializeTxExtra(extra);
// 1 + 32 (pubkey) + 3 (nonce tag + length + encrypted pid tag) + 8 (pid)
assertEqual(serialized.length, 44);
});
console.log('\n--- Transaction Prefix Serialization Tests ---');
test('serializeTxPrefix produces valid prefix', () => {
const tx = {
version: 2,
unlockTime: 0n,
inputs: [{
amount: 0n,
keyOffsets: [100n],
keyImage: new Uint8Array(32).fill(0xaa)
}],
outputs: [{
amount: 0n,
target: new Uint8Array(32).fill(0xbb)
}],
extra: {
txPubKey: new Uint8Array(32).fill(0xcc)
}
};
const serialized = serializeTxPrefix(tx);
assert(serialized.length > 0, 'Should produce serialized data');
});
test('getTxPrefixHash produces 32-byte hash', () => {
const tx = {
version: 2,
unlockTime: 0n,
inputs: [{ type: 'gen', height: 12345n }],
outputs: [{
amount: 0n,
target: new Uint8Array(32).fill(0xbb)
}],
extra: {
txPubKey: new Uint8Array(32).fill(0xcc)
}
};
const hash = getTxPrefixHash(tx);
assertEqual(hash.length, 32);
});
test('getTxPrefixHash is deterministic', () => {
const tx = {
version: 2,
unlockTime: 0n,
inputs: [{ type: 'gen', height: 1000n }],
outputs: [{
amount: 0n,
target: new Uint8Array(32).fill(0x11)
}],
extra: {
txPubKey: new Uint8Array(32).fill(0x22)
}
};
const h1 = getTxPrefixHash(tx);
const h2 = getTxPrefixHash(tx);
assertEqual(h1, h2);
});
console.log('\n--- CLSAG Serialization Tests ---');
test('serializeCLSAG produces valid serialization', () => {
const sig = {
s: ['0'.repeat(64), '0'.repeat(64), '0'.repeat(64)],
c1: '0'.repeat(64),
I: '0'.repeat(64),
D: '0'.repeat(64)
};
const serialized = serializeCLSAG(sig);
// 3 * 32 (s values) + 32 (c1) + 32 (D) = 160 bytes
assertEqual(serialized.length, 160);
});
console.log('\n--- RingCT Serialization Tests ---');
test('serializeRctBase produces valid base', () => {
const rct = {
type: RCT_TYPE.CLSAG,
fee: 1000000n
};
const serialized = serializeRctBase(rct);
assertEqual(serialized[0], RCT_TYPE.CLSAG);
assert(serialized.length > 1, 'Should have fee after type');
});
test('serializeRctBase handles null type', () => {
const rct = {
type: RCT_TYPE.Null,
fee: 0n
};
const serialized = serializeRctBase(rct);
assertEqual(serialized.length, 1, 'Null type should only have type byte');
assertEqual(serialized[0], RCT_TYPE.Null);
});
test('serializeEcdhInfo produces correct length', () => {
const amounts = [
new Uint8Array(8).fill(0x11),
new Uint8Array(8).fill(0x22)
];
const serialized = serializeEcdhInfo(amounts);
assertEqual(serialized.length, 16, 'Should be 8 bytes per amount');
});
test('serializeOutPk produces correct length', () => {
const commitments = [
new Uint8Array(32).fill(0x11),
new Uint8Array(32).fill(0x22),
new Uint8Array(32).fill(0x33)
];
const serialized = serializeOutPk(commitments);
assertEqual(serialized.length, 96, 'Should be 32 bytes per commitment');
});
console.log('\n--- Transaction Hash Tests ---');
test('getTransactionHash produces 32-byte hash', () => {
const tx = {
version: 2,
unlockTime: 0n,
inputs: [{ type: 'gen', height: 100n }],
outputs: [{
amount: 0n,
target: new Uint8Array(32).fill(0xaa)
}],
extra: {
txPubKey: new Uint8Array(32).fill(0xbb)
}
};
const hash = getTransactionHash(tx);
assertEqual(hash.length, 32);
});
test('TX_VERSION constants are correct', () => {
assertEqual(TX_VERSION.V1, 1);
assertEqual(TX_VERSION.V2, 2);
});
test('RCT_TYPE constants are correct', () => {
assertEqual(RCT_TYPE.Null, 0);
assertEqual(RCT_TYPE.CLSAG, 5);
assertEqual(RCT_TYPE.BulletproofPlus, 6);
});
// =============================================================================
// DECOY SELECTION TESTS
// =============================================================================
console.log('\n--- Decoy Selection Tests ---');
import {
GAMMA_SHAPE,
GAMMA_SCALE,
DEFAULT_UNLOCK_TIME,
DIFFICULTY_TARGET,
RECENT_SPEND_WINDOW,
CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE,
DEFAULT_RING_SIZE,
sampleGamma,
GammaPicker,
selectDecoys,
indicesToOffsets,
offsetsToIndices
} from '../src/transaction.js';
test('GAMMA_SHAPE constant matches Salvium source', () => {
assertEqual(GAMMA_SHAPE, 19.28);
});
test('GAMMA_SCALE constant matches Salvium source', () => {
// 1/1.61 ≈ 0.6211
assertTrue(Math.abs(GAMMA_SCALE - (1/1.61)) < 0.0001);
});
test('DEFAULT_UNLOCK_TIME is 1200 seconds', () => {
assertEqual(DEFAULT_UNLOCK_TIME, 1200);
});
test('DIFFICULTY_TARGET is 120 seconds', () => {
assertEqual(DIFFICULTY_TARGET, 120);
});
test('CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE is 10', () => {
assertEqual(CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE, 10);
});
test('DEFAULT_RING_SIZE is 16', () => {
assertEqual(DEFAULT_RING_SIZE, 16);
});
test('sampleGamma returns positive values', () => {
for (let i = 0; i < 100; i++) {
const sample = sampleGamma(GAMMA_SHAPE, GAMMA_SCALE);
assertTrue(sample > 0);
}
});
test('sampleGamma produces reasonable distribution', () => {
// Mean of gamma distribution = shape * scale
const expectedMean = GAMMA_SHAPE * GAMMA_SCALE;
let sum = 0;
const n = 1000;
for (let i = 0; i < n; i++) {
sum += sampleGamma(GAMMA_SHAPE, GAMMA_SCALE);
}
const mean = sum / n;
// Should be within 30% of expected (sampling variance)
assertTrue(Math.abs(mean - expectedMean) / expectedMean < 0.3);
});
test('GammaPicker constructor validates inputs', () => {
// Not enough blocks
let threw = false;
try {
new GammaPicker([1, 2, 3]);
} catch (e) {
threw = true;
}
assertTrue(threw);
});
test('GammaPicker picks valid output indices', () => {
// Create mock output distribution (1000 blocks with ~5 outputs each)
const rctOffsets = [];
let total = 0;
for (let i = 0; i < 1000; i++) {
total += Math.floor(Math.random() * 10) + 1;
rctOffsets.push(total);
}
const picker = new GammaPicker(rctOffsets);
for (let i = 0; i < 100; i++) {
const pick = picker.pick();
// Should be valid index or -1 (bad pick)
assertTrue(pick === -1 || (pick >= 0 && pick < picker.getNumRctOutputs()));
}
});
test('indicesToOffsets converts correctly', () => {
const indices = [10, 25, 100, 150];
const offsets = indicesToOffsets(indices);
assertEqual(offsets[0], 10); // First is absolute
assertEqual(offsets[1], 15); // 25 - 10
assertEqual(offsets[2], 75); // 100 - 25
assertEqual(offsets[3], 50); // 150 - 100
});
test('offsetsToIndices converts correctly', () => {
const offsets = [10, 15, 75, 50];
const indices = offsetsToIndices(offsets);
assertEqual(indices[0], 10);
assertEqual(indices[1], 25);
assertEqual(indices[2], 100);
assertEqual(indices[3], 150);
});
test('indicesToOffsets and offsetsToIndices are inverses', () => {
const original = [5, 20, 50, 120, 500];
const offsets = indicesToOffsets(original);
const recovered = offsetsToIndices(offsets);
for (let i = 0; i < original.length; i++) {
assertEqual(recovered[i], original[i]);
}
});
test('selectDecoys returns correct ring size', () => {
// Create mock output distribution
const rctOffsets = [];
let total = 0;
for (let i = 0; i < 1000; i++) {
total += Math.floor(Math.random() * 10) + 1;
rctOffsets.push(total);
}
const realOutputIndex = 500;
const ringSize = 16;
const ring = selectDecoys(rctOffsets, realOutputIndex, ringSize);
assertEqual(ring.length, ringSize);
assertTrue(ring.includes(realOutputIndex));
});
test('selectDecoys returns sorted indices', () => {
const rctOffsets = [];
let total = 0;
for (let i = 0; i < 1000; i++) {
total += Math.floor(Math.random() * 10) + 1;
rctOffsets.push(total);
}
const ring = selectDecoys(rctOffsets, 500, 16);
for (let i = 1; i < ring.length; i++) {
assertTrue(ring[i] > ring[i-1]);
}
});
test('selectDecoys excludes specified indices', () => {
const rctOffsets = [];
let total = 0;
for (let i = 0; i < 1000; i++) {
total += Math.floor(Math.random() * 10) + 1;
rctOffsets.push(total);
}
const exclude = new Set([100, 200, 300]);
const ring = selectDecoys(rctOffsets, 500, 16, exclude);
for (const idx of exclude) {
assertTrue(!ring.includes(idx));
}
});
// =============================================================================
// FEE CALCULATION TESTS
// =============================================================================
console.log('\n--- Fee Calculation Tests ---');
import {
FEE_PER_KB,
FEE_PER_BYTE,
DYNAMIC_FEE_PER_KB_BASE_FEE,
FEE_MULTIPLIERS,
FEE_PRIORITY,
getFeeMultiplier,
calculateFeeFromWeight,
calculateFeeFromSize,
estimateTxSize,
estimateTxWeight,
estimateFee
} from '../src/transaction.js';
test('FEE_PER_KB matches Salvium config', () => {
assertEqual(FEE_PER_KB, 200000n);
});
test('FEE_PER_BYTE matches Salvium config', () => {
assertEqual(FEE_PER_BYTE, 30n);
});
test('DYNAMIC_FEE_PER_KB_BASE_FEE matches Salvium config', () => {
assertEqual(DYNAMIC_FEE_PER_KB_BASE_FEE, 200000n);
});
test('FEE_MULTIPLIERS are correct', () => {
assertEqual(FEE_MULTIPLIERS.length, 4);
assertEqual(FEE_MULTIPLIERS[0], 1n);
assertEqual(FEE_MULTIPLIERS[1], 5n);
assertEqual(FEE_MULTIPLIERS[2], 25n);
assertEqual(FEE_MULTIPLIERS[3], 1000n);
});
test('FEE_PRIORITY constants are correct', () => {
assertEqual(FEE_PRIORITY.LOW, 1);
assertEqual(FEE_PRIORITY.NORMAL, 2);
assertEqual(FEE_PRIORITY.HIGH, 3);
assertEqual(FEE_PRIORITY.HIGHEST, 4);
});
test('getFeeMultiplier returns correct values', () => {
assertEqual(getFeeMultiplier(1), 1n);
assertEqual(getFeeMultiplier(2), 5n);
assertEqual(getFeeMultiplier(3), 25n);
assertEqual(getFeeMultiplier(4), 1000n);
});
test('getFeeMultiplier clamps out-of-range priorities', () => {
assertEqual(getFeeMultiplier(0), 1n); // Clamped to 1
assertEqual(getFeeMultiplier(5), 1000n); // Clamped to 4
});
test('calculateFeeFromWeight computes correctly', () => {
const baseFee = 30n;
const weight = 1000n;
const fee = calculateFeeFromWeight(baseFee, weight);
assertEqual(fee, 30000n);
});
test('calculateFeeFromWeight with quantization', () => {
const baseFee = 30n;
const weight = 1000n;
const mask = 10000n;
const fee = calculateFeeFromWeight(baseFee, weight, mask);
// 30000 rounded up to nearest 10000 = 30000
assertEqual(fee, 30000n);
});
test('calculateFeeFromSize rounds up to KB', () => {
const feePerKb = 200000n;
// 1500 bytes = 2 KB (rounded up)
const fee = calculateFeeFromSize(feePerKb, 1500);
assertEqual(fee, 400000n);
});
test('calculateFeeFromSize handles exact KB', () => {
const feePerKb = 200000n;
// 2048 bytes = 2 KB exactly
const fee = calculateFeeFromSize(feePerKb, 2048);
assertEqual(fee, 400000n);
});
test('estimateTxSize returns reasonable values', () => {
const size = estimateTxSize(2, 16, 2, 64);
// A 2-in/2-out tx should be a few KB
assertTrue(size > 500);
assertTrue(size < 10000);
});
test('estimateTxSize increases with ring size', () => {
const size16 = estimateTxSize(1, 16, 2, 0);
const size32 = estimateTxSize(1, 32, 2, 0);
assertTrue(size32 > size16);
});
test('estimateTxSize increases with inputs', () => {
const size1 = estimateTxSize(1, 16, 2, 0);
const size5 = estimateTxSize(5, 16, 2, 0);
assertTrue(size5 > size1);
});
test('estimateTxWeight includes clawback for multiple outputs', () => {
const weight2 = estimateTxWeight(1, 16, 2, 0);
const weight4 = estimateTxWeight(1, 16, 4, 0);
// More outputs = larger range proof but clawback helps
assertTrue(weight4 > weight2);
});
test('estimateFee returns bigint', () => {
const fee = estimateFee(2, 16, 2, 64);
assertEqual(typeof fee, 'bigint');
});
test('estimateFee increases with priority', () => {
const feeLow = estimateFee(2, 16, 2, 0, { priority: FEE_PRIORITY.LOW });
const feeNormal = estimateFee(2, 16, 2, 0, { priority: FEE_PRIORITY.NORMAL });
const feeHigh = estimateFee(2, 16, 2, 0, { priority: FEE_PRIORITY.HIGH });
assertTrue(feeNormal > feeLow);
assertTrue(feeHigh > feeNormal);
});
test('estimateFee per-KB vs per-byte', () => {
const feePerByte = estimateFee(2, 16, 2, 0, { perByte: true });
const feePerKb = estimateFee(2, 16, 2, 0, { perByte: false });
// Both should be reasonable
assertTrue(feePerByte > 0n);
assertTrue(feePerKb > 0n);
});
// =============================================================================
// RINGCT ASSEMBLY TESTS
// =============================================================================
console.log('\n--- RingCT Assembly Tests ---');
import {
buildRingCtSignature,
computePseudoOutputs
} from '../src/transaction.js';
test('computePseudoOutputs creates correct number of outputs', () => {
const inputs = [
{ amount: 100000000n, mask: scRandom() },
{ amount: 50000000n, mask: scRandom() }
];
const outputs = [
{ amount: 140000000n, mask: scRandom() },
{ amount: 9990000n, mask: scRandom() }
];
const fee = 10000n;
const { pseudoOuts, pseudoMasks } = computePseudoOutputs(inputs, outputs, fee);
assertEqual(pseudoOuts.length, inputs.length);
assertEqual(pseudoMasks.length, inputs.length);
});
test('computePseudoOutputs produces 32-byte outputs', () => {
const inputs = [
{ amount: 100000000n, mask: scRandom() }
];
const outputs = [
{ amount: 99990000n, mask: scRandom() }
];
const fee = 10000n;
const { pseudoOuts, pseudoMasks } = computePseudoOutputs(inputs, outputs, fee);
assertEqual(pseudoOuts[0].length, 32);
assertEqual(pseudoMasks[0].length, 32);
});
test('computePseudoOutputs produces 32-byte masks', () => {
const inputs = [
{ amount: 100n, mask: scRandom() },
{ amount: 200n, mask: scRandom() }
];
const outputs = [
{ amount: 290n, mask: scRandom() }
];
const fee = 10n;
const { pseudoMasks } = computePseudoOutputs(inputs, outputs, fee);
for (const mask of pseudoMasks) {
assertEqual(mask.length, 32);
}
});
// =============================================================================
// CARROT OUTPUT GENERATION TESTS
// =============================================================================
console.log('\n--- CARROT Output Generation Tests ---');
import {
CARROT_DOMAIN,
CARROT_ENOTE_TYPE,
generateJanusAnchor,
buildRingCtInputContext,
buildCoinbaseInputContext,
deriveCarrotEphemeralPrivkey,
computeCarrotEphemeralPubkey,
computeCarrotSharedSecret,
deriveCarrotSenderReceiverSecret,
deriveCarrotOnetimeExtensions,
computeCarrotOnetimeAddress,
deriveCarrotAmountBlindingFactor,
deriveCarrotViewTag,
encryptCarrotAnchor,
encryptCarrotAmount,
encryptCarrotPaymentId,
createCarrotOutput,
computeCarrotSpecialAnchor
} from '../src/transaction.js';
test('CARROT_DOMAIN has correct domain separators', () => {
assertEqual(CARROT_DOMAIN.EPHEMERAL_PRIVKEY, 'Carrot sending key normal');
assertEqual(CARROT_DOMAIN.SENDER_RECEIVER_SECRET, 'Carrot sender-receiver secret');
assertEqual(CARROT_DOMAIN.VIEW_TAG, 'Carrot view tag');
assertEqual(CARROT_DOMAIN.INPUT_CONTEXT_COINBASE, 'C');
assertEqual(CARROT_DOMAIN.INPUT_CONTEXT_RINGCT, 'R');
});
test('CARROT_ENOTE_TYPE has correct values', () => {
assertEqual(CARROT_ENOTE_TYPE.PAYMENT, 0);
assertEqual(CARROT_ENOTE_TYPE.CHANGE, 1);
assertEqual(CARROT_ENOTE_TYPE.SELF_SPEND, 2);
});
test('generateJanusAnchor produces 16-byte anchor', () => {
const anchor = generateJanusAnchor();
assertEqual(anchor.length, 16);
});
test('generateJanusAnchor produces different values', () => {
const a1 = generateJanusAnchor();
const a2 = generateJanusAnchor();
let different = false;
for (let i = 0; i < 16; i++) {
if (a1[i] !== a2[i]) {
different = true;
break;
}
}
assertTrue(different);
});
test('buildRingCtInputContext produces 33-byte context', () => {
const keyImage = new Uint8Array(32).fill(0xab);
const context = buildRingCtInputContext(keyImage);
assertEqual(context.length, 33);
assertEqual(context[0], 'R'.charCodeAt(0));
});
test('buildRingCtInputContext includes key image', () => {
const keyImage = new Uint8Array(32).fill(0xcd);
const context = buildRingCtInputContext(keyImage);
for (let i = 0; i < 32; i++) {
assertEqual(context[i + 1], 0xcd);
}
});
test('buildCoinbaseInputContext produces 9-byte context', () => {
const context = buildCoinbaseInputContext(12345n);
assertEqual(context.length, 9);
assertEqual(context[0], 'C'.charCodeAt(0));
});
test('buildCoinbaseInputContext encodes height little-endian', () => {
const context = buildCoinbaseInputContext(0x1234n);
assertEqual(context[1], 0x34);
assertEqual(context[2], 0x12);
});
test('deriveCarrotEphemeralPrivkey produces 32-byte scalar', () => {
const anchor = new Uint8Array(16).fill(0x11);
const inputContext = new Uint8Array(33).fill(0x22);
const spendPubkey = new Uint8Array(32).fill(0x33);
const paymentId = new Uint8Array(8).fill(0x44);
const privkey = deriveCarrotEphemeralPrivkey(anchor, inputContext, spendPubkey, paymentId);
assertEqual(privkey.length, 32);
});
test('deriveCarrotEphemeralPrivkey is deterministic', () => {
const anchor = new Uint8Array(16).fill(0x55);
const inputContext = new Uint8Array(33).fill(0x66);
const spendPubkey = new Uint8Array(32).fill(0x77);
const paymentId = new Uint8Array(8).fill(0x88);
const p1 = deriveCarrotEphemeralPrivkey(anchor, inputContext, spendPubkey, paymentId);
const p2 = deriveCarrotEphemeralPrivkey(anchor, inputContext, spendPubkey, paymentId);
assertEqual(bytesToHex(p1), bytesToHex(p2));
});
test('computeCarrotEphemeralPubkey produces 32-byte point', () => {
const privkey = scRandom();
const spendPubkey = scalarMultBase(scRandom());
const pubkey = computeCarrotEphemeralPubkey(privkey, spendPubkey, false);
assertEqual(pubkey.length, 32);
});
test('computeCarrotEphemeralPubkey differs for main vs subaddress', () => {
const privkey = scRandom();
const spendPubkey = scalarMultBase(scRandom());
const pubMain = computeCarrotEphemeralPubkey(privkey, spendPubkey, false);
const pubSub = computeCarrotEphemeralPubkey(privkey, spendPubkey, true);
assertTrue(bytesToHex(pubMain) !== bytesToHex(pubSub));
});
test('computeCarrotSharedSecret produces 32-byte secret', () => {
const privkey = scRandom();
const viewPubkey = scalarMultBase(scRandom());
const secret = computeCarrotSharedSecret(privkey, viewPubkey);
assertEqual(secret.length, 32);
});
test('deriveCarrotSenderReceiverSecret produces 32-byte secret', () => {
const sharedSecret = new Uint8Array(32).fill(0xaa);
const ephemeralPubkey = new Uint8Array(32).fill(0xbb);
const inputContext = new Uint8Array(33).fill(0xcc);
const srSecret = deriveCarrotSenderReceiverSecret(sharedSecret, ephemeralPubkey, inputContext);
assertEqual(srSecret.length, 32);
});
test('deriveCarrotOnetimeExtensions produces two 32-byte scalars', () => {
const srSecret = new Uint8Array(32).fill(0xdd);
const commitment = new Uint8Array(32).fill(0xee);
const { extensionG, extensionT } = deriveCarrotOnetimeExtensions(srSecret, commitment);
assertEqual(extensionG.length, 32);
assertEqual(extensionT.length, 32);
});
test('computeCarrotOnetimeAddress produces 32-byte address', () => {
const spendPubkey = scalarMultBase(scRandom());
const extensionG = scRandom();
const extensionT = scRandom();
const onetimeAddr = computeCarrotOnetimeAddress(spendPubkey, extensionG, extensionT);
assertEqual(onetimeAddr.length, 32);
});
test('deriveCarrotAmountBlindingFactor produces 32-byte scalar', () => {
const srSecret = new Uint8Array(32).fill(0x11);
const spendPubkey = new Uint8Array(32).fill(0x22);
const bf = deriveCarrotAmountBlindingFactor(srSecret, 1000000n, spendPubkey, CARROT_ENOTE_TYPE.PAYMENT);
assertEqual(bf.length, 32);
});
test('deriveCarrotViewTag produces 3-byte tag', () => {
const sharedSecret = new Uint8Array(32).fill(0x33);
const inputContext = new Uint8Array(33).fill(0x44);
const onetimeAddr = new Uint8Array(32).fill(0x55);
const viewTag = deriveCarrotViewTag(sharedSecret, inputContext, onetimeAddr);
assertEqual(viewTag.length, 3);
});
test('encryptCarrotAnchor produces 16-byte result', () => {
const anchor = new Uint8Array(16).fill(0x66);
const srSecret = new Uint8Array(32).fill(0x77);
const onetimeAddr = new Uint8Array(32).fill(0x88);
const encrypted = encryptCarrotAnchor(anchor, srSecret, onetimeAddr);
assertEqual(encrypted.length, 16);
});
test('encryptCarrotAnchor is reversible', () => {
const anchor = new Uint8Array(16).fill(0x99);
const srSecret = new Uint8Array(32).fill(0xaa);
const onetimeAddr = new Uint8Array(32).fill(0xbb);
const encrypted = encryptCarrotAnchor(anchor, srSecret, onetimeAddr);
const decrypted = encryptCarrotAnchor(encrypted, srSecret, onetimeAddr);
assertEqual(bytesToHex(decrypted), bytesToHex(anchor));
});
test('encryptCarrotAmount produces 8-byte result', () => {
const srSecret = new Uint8Array(32).fill(0xcc);
const onetimeAddr = new Uint8Array(32).fill(0xdd);
const encrypted = encryptCarrotAmount(1000000n, srSecret, onetimeAddr);
assertEqual(encrypted.length, 8);
});
test('encryptCarrotPaymentId produces 8-byte result', () => {
const paymentId = new Uint8Array(8).fill(0xee);
const srSecret = new Uint8Array(32).fill(0xff);
const onetimeAddr = new Uint8Array(32).fill(0x11);
const encrypted = encryptCarrotPaymentId(paymentId, srSecret, onetimeAddr);
assertEqual(encrypted.length, 8);
});
test('encryptCarrotPaymentId is reversible', () => {
const paymentId = new Uint8Array(8);
for (let i = 0; i < 8; i++) paymentId[i] = i * 17;
const srSecret = new Uint8Array(32).fill(0x22);
const onetimeAddr = new Uint8Array(32).fill(0x33);
const encrypted = encryptCarrotPaymentId(paymentId, srSecret, onetimeAddr);
const decrypted = encryptCarrotPaymentId(encrypted, srSecret, onetimeAddr);
assertEqual(bytesToHex(decrypted), bytesToHex(paymentId));
});
test('createCarrotOutput produces complete output', () => {
const spendPubkey = scalarMultBase(scRandom());
const viewPubkey = scalarMultBase(scRandom());
const inputContext = buildCoinbaseInputContext(1000n);
const output = createCarrotOutput({
addressSpendPubkey: spendPubkey,
addressViewPubkey: viewPubkey,
amount: 5000000000n,
inputContext: inputContext
});
assertEqual(output.ephemeralPubkey.length, 32);
assertEqual(output.onetimeAddress.length, 32);
assertEqual(output.amountCommitment.length, 32);
assertEqual(output.amountEncrypted.length, 8);
assertEqual(output.anchorEncrypted.length, 16);
assertEqual(output.viewTag.length, 3);
assertEqual(output.paymentIdEncrypted.length, 8);
assertEqual(output.amountBlindingFactor.length, 32);
});
test('createCarrotOutput is deterministic with same anchor', () => {
const spendPubkey = scalarMultBase(scRandom());
const viewPubkey = scalarMultBase(scRandom());
const inputContext = buildCoinbaseInputContext(2000n);
const anchor = generateJanusAnchor();
const o1 = createCarrotOutput({
addressSpendPubkey: spendPubkey,
addressViewPubkey: viewPubkey,
amount: 1000000n,
inputContext: inputContext,
anchor: anchor
});
const o2 = createCarrotOutput({
addressSpendPubkey: spendPubkey,
addressViewPubkey: viewPubkey,
amount: 1000000n,
inputContext: inputContext,
anchor: anchor
});
assertEqual(bytesToHex(o1.onetimeAddress), bytesToHex(o2.onetimeAddress));
assertEqual(bytesToHex(o1.amountCommitment), bytesToHex(o2.amountCommitment));
});
test('createCarrotOutput produces different outputs for different amounts', () => {
const spendPubkey = scalarMultBase(scRandom());
const viewPubkey = scalarMultBase(scRandom());
const inputContext = buildCoinbaseInputContext(3000n);
const anchor = generateJanusAnchor();
const o1 = createCarrotOutput({
addressSpendPubkey: spendPubkey,
addressViewPubkey: viewPubkey,
amount: 1000000n,
inputContext: inputContext,
anchor: anchor
});
const o2 = createCarrotOutput({
addressSpendPubkey: spendPubkey,
addressViewPubkey: viewPubkey,
amount: 2000000n,
inputContext: inputContext,
anchor: anchor
});
// Different amount = different commitment and one-time address
assertTrue(bytesToHex(o1.amountCommitment) !== bytesToHex(o2.amountCommitment));
});
test('computeCarrotSpecialAnchor produces 16-byte anchor', () => {
const ephemeralPubkey = new Uint8Array(32).fill(0x44);
const inputContext = new Uint8Array(33).fill(0x55);
const onetimeAddr = new Uint8Array(32).fill(0x66);
const viewSecretKey = scRandom();
const specialAnchor = computeCarrotSpecialAnchor(ephemeralPubkey, inputContext, onetimeAddr, viewSecretKey);
assertEqual(specialAnchor.length, 16);
});
test('CARROT enote types affect blinding factor', () => {
const srSecret = new Uint8Array(32).fill(0x77);
const spendPubkey = new Uint8Array(32).fill(0x88);
const bf1 = deriveCarrotAmountBlindingFactor(srSecret, 1000n, spendPubkey, CARROT_ENOTE_TYPE.PAYMENT);
const bf2 = deriveCarrotAmountBlindingFactor(srSecret, 1000n, spendPubkey, CARROT_ENOTE_TYPE.CHANGE);
const bf3 = deriveCarrotAmountBlindingFactor(srSecret, 1000n, spendPubkey, CARROT_ENOTE_TYPE.SELF_SPEND);
assertTrue(bytesToHex(bf1) !== bytesToHex(bf2));
assertTrue(bytesToHex(bf2) !== bytesToHex(bf3));
});
// =============================================================================
// SUMMARY
// =============================================================================
console.log(`\n--- Transaction Test Summary ---`);
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log(`Total: ${passed + failed}`);
if (failed === 0) {
console.log('\n✓ All transaction tests passed!');
process.exit(0);
} else {
console.log('\n✗ Some transaction tests failed!');
process.exit(1);
}