From ea09516981ad799238b5bae672955e55148fb0d2 Mon Sep 17 00:00:00 2001 From: Matt Hess Date: Sat, 24 Jan 2026 20:56:24 +0000 Subject: [PATCH] =?UTF-8?q?=E2=97=8F=20Add=20proper=20error=20handling=20-?= =?UTF-8?q?=20eliminate=20silent=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ParseError class with context (offset, field, expected/actual) - transaction.js: parseRctSigPrunable throws instead of returning null - address.js: createAddress, toIntegratedAddress, toStandardAddress throw on invalid input - base58.js: decode functions throw descriptive errors - scanning.js: validate inputs and throw on failures - carrot-scanning.js: validate required fields - signature.js: remove debug console.log and testEd25519 - Update tests to expect throws for error cases --- src/address.js | 62 ++++++++++++------- src/base58.js | 52 ++++++++-------- src/carrot-scanning.js | 10 ++- src/index.js | 3 +- src/scanning.js | 92 +++++++++++++++++++--------- src/signature.js | 127 +------------------------------------- src/transaction.js | 135 +++++++++++++++++++++++++++-------------- src/wallet-sync.js | 22 ++++--- test/address.test.js | 43 ++++++++----- 9 files changed, 272 insertions(+), 274 deletions(-) diff --git a/src/address.js b/src/address.js index 934d5d4..5fc9822 100644 --- a/src/address.js +++ b/src/address.js @@ -74,9 +74,11 @@ export function parseAddress(address) { } // Decode the address - const decoded = decodeAddress(address); - if (decoded === null) { - result.error = 'Invalid Base58 encoding or checksum'; + let decoded; + try { + decoded = decodeAddress(address); + } catch (e) { + result.error = e.message; return result; } @@ -256,24 +258,33 @@ export function createAddress(options) { const { network, format, type, spendPublicKey, viewPublicKey, paymentId } = options; // Validate keys - if (!spendPublicKey || spendPublicKey.length !== KEY_SIZE) { - return null; + if (!spendPublicKey) { + throw new Error('createAddress: spendPublicKey is required'); } - if (!viewPublicKey || viewPublicKey.length !== KEY_SIZE) { - return null; + if (spendPublicKey.length !== KEY_SIZE) { + throw new Error(`createAddress: spendPublicKey must be ${KEY_SIZE} bytes, got ${spendPublicKey.length}`); + } + if (!viewPublicKey) { + throw new Error('createAddress: viewPublicKey is required'); + } + if (viewPublicKey.length !== KEY_SIZE) { + throw new Error(`createAddress: viewPublicKey must be ${KEY_SIZE} bytes, got ${viewPublicKey.length}`); } // Get prefix const prefix = getPrefix(network, format, type); if (prefix === null) { - return null; + throw new Error(`createAddress: invalid network/format/type combination: ${network}/${format}/${type}`); } // Build data let data; if (type === ADDRESS_TYPE.INTEGRATED) { - if (!paymentId || paymentId.length !== PAYMENT_ID_SIZE) { - return null; + if (!paymentId) { + throw new Error('createAddress: paymentId is required for integrated addresses'); + } + if (paymentId.length !== PAYMENT_ID_SIZE) { + throw new Error(`createAddress: paymentId must be ${PAYMENT_ID_SIZE} bytes, got ${paymentId.length}`); } data = new Uint8Array(KEY_SIZE * 2 + PAYMENT_ID_SIZE); data.set(spendPublicKey, 0); @@ -292,19 +303,23 @@ export function createAddress(options) { * Convert a standard address to an integrated address by adding a payment ID * @param {string} address - Standard address * @param {Uint8Array|string} paymentId - 8-byte payment ID (or 16-char hex string) - * @returns {string|null} - Integrated address or null on error + * @returns {string} - Integrated address + * @throws {Error} If address is invalid or not a standard address */ export function toIntegratedAddress(address, paymentId) { const parsed = parseAddress(address); - if (!parsed.valid || parsed.type !== ADDRESS_TYPE.STANDARD) { - return null; + if (!parsed.valid) { + throw new Error(`toIntegratedAddress: invalid address - ${parsed.error}`); + } + if (parsed.type !== ADDRESS_TYPE.STANDARD) { + throw new Error(`toIntegratedAddress: address must be a standard address, got ${parsed.type}`); } // Convert hex string to bytes if needed if (typeof paymentId === 'string') { if (paymentId.length !== 16) { - return null; + throw new Error(`toIntegratedAddress: payment ID hex string must be 16 characters, got ${paymentId.length}`); } const bytes = new Uint8Array(8); for (let i = 0; i < 8; i++) { @@ -314,7 +329,7 @@ export function toIntegratedAddress(address, paymentId) { } if (paymentId.length !== PAYMENT_ID_SIZE) { - return null; + throw new Error(`toIntegratedAddress: payment ID must be ${PAYMENT_ID_SIZE} bytes, got ${paymentId.length}`); } return createAddress({ @@ -330,13 +345,17 @@ export function toIntegratedAddress(address, paymentId) { /** * Extract the standard address from an integrated address * @param {string} address - Integrated address - * @returns {string|null} - Standard address or null on error + * @returns {string} - Standard address + * @throws {Error} If address is invalid or not an integrated address */ export function toStandardAddress(address) { const parsed = parseAddress(address); - if (!parsed.valid || parsed.type !== ADDRESS_TYPE.INTEGRATED) { - return null; + if (!parsed.valid) { + throw new Error(`toStandardAddress: invalid address - ${parsed.error}`); + } + if (parsed.type !== ADDRESS_TYPE.INTEGRATED) { + throw new Error(`toStandardAddress: address must be an integrated address, got ${parsed.type}`); } return createAddress({ @@ -486,16 +505,13 @@ export function generateRandomPaymentId() { /** * Create an integrated address with a random payment ID * @param {string} address - Standard address - * @returns {Object} { address, paymentId } - Integrated address and its payment ID + * @returns {Object} { address, paymentId, paymentIdHex } - Integrated address and its payment ID + * @throws {Error} If address is invalid or not a standard address */ export function createIntegratedAddressWithRandomId(address) { const paymentId = genPaymentId(); const integratedAddress = toIntegratedAddress(address, paymentId); - if (!integratedAddress) { - return null; - } - return { address: integratedAddress, paymentId, diff --git a/src/base58.js b/src/base58.js index 600d0f3..8210dda 100644 --- a/src/base58.js +++ b/src/base58.js @@ -76,12 +76,14 @@ function encodeBlock(block) { /** * Decode a single Base58 block to bytes * @param {string} block - Base58 encoded block - * @returns {Uint8Array|null} - Decoded bytes or null on error + * @param {number} blockIndex - Block index for error messages + * @returns {Uint8Array} - Decoded bytes + * @throws {Error} If block is invalid */ -function decodeBlock(block) { +function decodeBlock(block, blockIndex = 0) { const decodedSize = DECODED_BLOCK_SIZES.get(block.length); if (decodedSize === undefined || decodedSize < 0) { - return null; // Invalid block size + throw new Error(`Base58 decode: invalid block size ${block.length} at block ${blockIndex}`); } if (decodedSize === 0) { @@ -94,14 +96,14 @@ function decodeBlock(block) { for (let i = 0; i < block.length; i++) { const digit = ALPHABET_MAP.get(block[i]); if (digit === undefined) { - return null; // Invalid character + throw new Error(`Base58 decode: invalid character '${block[i]}' at position ${i} in block ${blockIndex}`); } num = num * base + BigInt(digit); } // Check for overflow if (decodedSize < BASE58_FULL_BLOCK_SIZE && num >= (1n << BigInt(8 * decodedSize))) { - return null; // Overflow + throw new Error(`Base58 decode: numeric overflow in block ${blockIndex}`); } return uint64ToUint8BE(num, decodedSize); @@ -144,7 +146,8 @@ export function encode(data) { /** * Decode Base58 string to binary data (CryptoNote variant) * @param {string} encoded - Base58 encoded string - * @returns {Uint8Array|null} - Decoded binary data or null on error + * @returns {Uint8Array} - Decoded binary data + * @throws {Error} If string is not valid Base58 */ export function decode(encoded) { if (encoded.length === 0) { @@ -156,7 +159,7 @@ export function decode(encoded) { const lastBlockDecodedSize = DECODED_BLOCK_SIZES.get(lastBlockSize); if (lastBlockDecodedSize === undefined || lastBlockDecodedSize < 0) { - return null; // Invalid encoded length + throw new Error(`Base58 decode: invalid encoded length ${encoded.length} (last block size ${lastBlockSize} is invalid)`); } const dataSize = fullBlockCount * BASE58_FULL_BLOCK_SIZE + lastBlockDecodedSize; @@ -167,10 +170,7 @@ export function decode(encoded) { // Decode full blocks for (let i = 0; i < fullBlockCount; i++) { const block = encoded.slice(i * BASE58_FULL_ENCODED_BLOCK_SIZE, (i + 1) * BASE58_FULL_ENCODED_BLOCK_SIZE); - const decoded = decodeBlock(block); - if (decoded === null) { - return null; - } + const decoded = decodeBlock(block, i); result.set(decoded, offset); offset += BASE58_FULL_BLOCK_SIZE; } @@ -178,10 +178,7 @@ export function decode(encoded) { // Decode last partial block if (lastBlockSize > 0) { const block = encoded.slice(fullBlockCount * BASE58_FULL_ENCODED_BLOCK_SIZE); - const decoded = decodeBlock(block); - if (decoded === null) { - return null; - } + const decoded = decodeBlock(block, fullBlockCount); result.set(decoded, offset); } @@ -209,9 +206,14 @@ export function encodeVarint(value) { /** * Decode a varint from the start of data * @param {Uint8Array} data - Data containing varint - * @returns {{value: BigInt, bytesRead: number}|null} - Decoded value and bytes consumed + * @returns {{value: BigInt, bytesRead: number}} - Decoded value and bytes consumed + * @throws {Error} If varint is invalid or incomplete */ export function decodeVarint(data) { + if (!data || data.length === 0) { + throw new Error('decodeVarint: empty data'); + } + let value = 0n; let shift = 0n; let bytesRead = 0; @@ -228,7 +230,7 @@ export function decodeVarint(data) { shift += 7n; } - return null; // Varint too long or incomplete + throw new Error(`decodeVarint: varint incomplete or too long (read ${bytesRead} bytes without termination)`); } /** @@ -259,12 +261,13 @@ export function encodeAddress(tag, data) { /** * Decode an address, verifying checksum and extracting tag and data * @param {string} address - Base58 encoded address - * @returns {{tag: BigInt, data: Uint8Array}|null} - Decoded tag and data, or null on error + * @returns {{tag: BigInt, data: Uint8Array}} - Decoded tag and data + * @throws {Error} If address is invalid or checksum fails */ export function decodeAddress(address) { const decoded = decode(address); - if (decoded === null || decoded.length <= 4) { - return null; + if (decoded.length <= 4) { + throw new Error(`decodeAddress: address too short (${decoded.length} bytes, need >4)`); } // Extract checksum @@ -277,17 +280,12 @@ export function decodeAddress(address) { for (let i = 0; i < 4; i++) { if (checksum[i] !== expectedChecksum[i]) { - return null; // Checksum mismatch + throw new Error('decodeAddress: checksum mismatch - address may be corrupted or invalid'); } } // Decode varint tag - const varintResult = decodeVarint(payload); - if (varintResult === null) { - return null; - } - - const { value: tag, bytesRead } = varintResult; + const { value: tag, bytesRead } = decodeVarint(payload); const data = payload.slice(bytesRead); return { tag, data }; diff --git a/src/carrot-scanning.js b/src/carrot-scanning.js index f5ebfe3..e696ce6 100644 --- a/src/carrot-scanning.js +++ b/src/carrot-scanning.js @@ -627,8 +627,14 @@ export function scanCarrotOutput(output, viewIncomingKey, accountSpendPubkey, in console.log(` accountSpendPubkey: ${accountSpendPubkey ? 'SET' : 'NULL'}`); } - if (!onetimeAddress || !viewTag || !enoteEphemeralPubkey) { - return null; // Missing required CARROT fields + if (!onetimeAddress) { + throw new Error('scanCarrotOutput: onetimeAddress is required'); + } + if (!viewTag) { + throw new Error('scanCarrotOutput: viewTag is required'); + } + if (!enoteEphemeralPubkey) { + throw new Error('scanCarrotOutput: enoteEphemeralPubkey is required'); } // 1. Compute uncontextualized shared secret using X25519 diff --git a/src/index.js b/src/index.js index 2afe31c..6fddda5 100644 --- a/src/index.js +++ b/src/index.js @@ -185,8 +185,7 @@ import { import { verifySignature, - parseSignature, - testEd25519 + parseSignature } from './signature.js'; import { diff --git a/src/scanning.js b/src/scanning.js index 7bbacdd..48fe0cd 100644 --- a/src/scanning.js +++ b/src/scanning.js @@ -129,6 +129,14 @@ export function generateKeyDerivation(txPubKey, viewSecretKey) { viewSecretKey = hexToBytes(viewSecretKey); } + // Validate inputs + if (!txPubKey || txPubKey.length !== 32) { + throw new Error(`generateKeyDerivation: txPubKey must be 32 bytes, got ${txPubKey?.length ?? 'null'}`); + } + if (!viewSecretKey || viewSecretKey.length !== 32) { + throw new Error(`generateKeyDerivation: viewSecretKey must be 32 bytes, got ${viewSecretKey?.length ?? 'null'}`); + } + try { // Convert scalar to BigInt (little-endian) let scalar = 0n; @@ -147,10 +155,16 @@ export function generateKeyDerivation(txPubKey, viewSecretKey) { } catch (e) { // Fallback to original implementation const result = scalarMultPoint(viewSecretKey, txPubKey); - if (!result) return null; + if (!result) { + throw new Error(`generateKeyDerivation: point multiplication failed - ${e.message}`); + } const eight = new Uint8Array(32); eight[0] = 8; - return scalarMultPoint(eight, result); + const cofactorResult = scalarMultPoint(eight, result); + if (!cofactorResult) { + throw new Error('generateKeyDerivation: cofactor multiplication failed'); + } + return cofactorResult; } } @@ -261,23 +275,29 @@ export function deriveSubaddressPublicKey(outputKey, derivation, outputIndex) { } // Validate inputs - if (!outputKey || !(outputKey instanceof Uint8Array) || outputKey.length !== 32) { - return null; + if (!outputKey || !(outputKey instanceof Uint8Array)) { + throw new Error('deriveSubaddressPublicKey: outputKey must be a Uint8Array'); } - if (!derivation || !(derivation instanceof Uint8Array) || derivation.length !== 32) { - return null; + if (outputKey.length !== 32) { + throw new Error(`deriveSubaddressPublicKey: outputKey must be 32 bytes, got ${outputKey.length}`); + } + if (!derivation || !(derivation instanceof Uint8Array)) { + throw new Error('deriveSubaddressPublicKey: derivation must be a Uint8Array'); + } + if (derivation.length !== 32) { + throw new Error(`deriveSubaddressPublicKey: derivation must be 32 bytes, got ${derivation.length}`); } // scalar = H_s(derivation || output_index) const scalar = derivationToScalar(derivation, outputIndex); if (!scalar) { - return null; + throw new Error('deriveSubaddressPublicKey: derivationToScalar failed'); } // scalar * G const scalarG = scalarMultBase(scalar); if (!scalarG || scalarG.length !== 32) { - return null; + throw new Error('deriveSubaddressPublicKey: scalarMultBase failed'); } // Negate the point (subtract instead of add) @@ -521,27 +541,30 @@ export function checkSubaddressOwnership(outputPubKey, txPubKey, viewSecretKey, outputPubKey = hexToBytes(outputPubKey); } - // Compute key derivation - const derivation = generateKeyDerivation(txPubKey, viewSecretKey); - if (!derivation) return null; + try { + // Compute key derivation + const derivation = generateKeyDerivation(txPubKey, viewSecretKey); - // For subaddress, we compute: derived = outputKey - scalar*G - // Then check if derived matches any known subaddress spend key - const derivedSpendKey = deriveSubaddressPublicKey(outputPubKey, derivation, outputIndex); - if (!derivedSpendKey) return null; + // For subaddress, we compute: derived = outputKey - scalar*G + // Then check if derived matches any known subaddress spend key + const derivedSpendKey = deriveSubaddressPublicKey(outputPubKey, derivation, outputIndex); - const derivedHex = bytesToHex(derivedSpendKey); - const subaddressInfo = subaddressSpendKeys.get(derivedHex); + const derivedHex = bytesToHex(derivedSpendKey); + const subaddressInfo = subaddressSpendKeys.get(derivedHex); - if (subaddressInfo) { - return { - major: subaddressInfo.major, - minor: subaddressInfo.minor, - derivation - }; + if (subaddressInfo) { + return { + major: subaddressInfo.major, + minor: subaddressInfo.minor, + derivation + }; + } + + return null; // Not our subaddress + } catch (e) { + // Crypto operation failed - this output isn't valid for our keys + return null; } - - return null; } // ============================================================================ @@ -559,9 +582,14 @@ export function checkSubaddressOwnership(outputPubKey, txPubKey, viewSecretKey, * @returns {Object|null} Scan result with derivation, amount, etc. */ export function scanOutput(output, txPubKey, viewSecretKey, spendPubKey, outputIndex) { - // Compute key derivation - const derivation = generateKeyDerivation(txPubKey, viewSecretKey); - if (!derivation) return null; + let derivation; + try { + // Compute key derivation + derivation = generateKeyDerivation(txPubKey, viewSecretKey); + } catch (e) { + // Key derivation failed - cannot be our output + return null; + } // Check view tag first (if available) for optimization if (output.view_tag !== undefined) { @@ -572,7 +600,13 @@ export function scanOutput(output, txPubKey, viewSecretKey, spendPubKey, outputI } // Derive expected output public key - const expectedPubKey = derivePublicKey(derivation, outputIndex, spendPubKey); + let expectedPubKey; + try { + expectedPubKey = derivePublicKey(derivation, outputIndex, spendPubKey); + } catch (e) { + // Key derivation failed - cannot be our output + return null; + } if (!expectedPubKey) return null; // Compare with actual output key diff --git a/src/signature.js b/src/signature.js index 7847ca1..2451119 100644 --- a/src/signature.js +++ b/src/signature.js @@ -12,15 +12,14 @@ import { keccak256 } from './keccak.js'; import { decode } from './base58.js'; -import { parseAddress, hexToBytes } from './address.js'; +import { parseAddress } from './address.js'; import { scalarCheck, scalarIsNonzero, scalarSub, pointFromBytes, doubleScalarMultBase, - isIdentity, - scalarMultBase + isIdentity } from './ed25519.js'; // Domain separator for V2 signatures (includes null terminator) @@ -60,22 +59,9 @@ function getMessageHashV1(message) { * @returns {Uint8Array} 32-byte hash */ function getMessageHashV2(message, spendKey, viewKey, mode) { - // Debug helper - const bytesToHex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); - const messageBytes = new TextEncoder().encode(message); const lenVarint = encodeVarint(messageBytes.length); - console.log('=== V2 Hash Construction ==='); - console.log('Domain sep length:', HASH_KEY_MESSAGE_SIGNING.length); - console.log('Domain sep:', bytesToHex(HASH_KEY_MESSAGE_SIGNING)); - console.log('Spend key:', bytesToHex(spendKey)); - console.log('View key:', bytesToHex(viewKey)); - console.log('Mode:', mode); - console.log('Message length:', messageBytes.length); - console.log('Len varint:', bytesToHex(lenVarint)); - console.log('Message bytes:', bytesToHex(messageBytes)); - // Concatenate: domain_separator + spend_key + view_key + mode + len + message const totalLen = HASH_KEY_MESSAGE_SIGNING.length + 32 + 32 + 1 + lenVarint.length + messageBytes.length; const data = new Uint8Array(totalLen); @@ -97,11 +83,7 @@ function getMessageHashV2(message, spendKey, viewKey, mode) { data.set(messageBytes, offset); - const hash = keccak256(data); - console.log('Total data length:', data.length); - console.log('V2 hash result:', bytesToHex(hash)); - - return hash; + return keccak256(data); } /** @@ -116,37 +98,22 @@ function getMessageHashV2(message, spendKey, viewKey, mode) { * @returns {boolean} true if signature is valid */ function checkSignature(hash, publicKey, sigC, sigR) { - // Import bytesToHex for debugging - const bytesToHex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); - - console.log('=== checkSignature ==='); - console.log('hash (message hash):', bytesToHex(hash)); - console.log('publicKey:', bytesToHex(publicKey)); - console.log('sigC:', bytesToHex(sigC)); - console.log('sigR:', bytesToHex(sigR)); - // Validate scalars if (!scalarCheck(sigC) || !scalarCheck(sigR) || !scalarIsNonzero(sigC)) { - console.log('Scalar validation failed'); return false; } - console.log('Scalar validation passed'); // Decompress public key const P = pointFromBytes(publicKey); if (!P) { - console.log('Failed to decompress public key'); return false; } - console.log('Public key decompressed successfully'); // Compute R' = c*P + r*G using double scalar multiplication const RBytes = doubleScalarMultBase(sigC, P, sigR); - console.log('R\' computed:', bytesToHex(RBytes)); // Check R' is not identity if (isIdentity(RBytes)) { - console.log('R\' is identity point'); return false; } @@ -158,19 +125,14 @@ function checkSignature(hash, publicKey, sigC, sigR) { buf.set(RBytes, 64); const cPrime = keccak256(buf); - console.log('cPrime (raw hash) H(m||P||R):', bytesToHex(cPrime)); // Reduce c' mod L const cPrimeReduced = new Uint8Array(32); reduceScalar32(cPrimeReduced, cPrime); - console.log('cPrime (reduced):', bytesToHex(cPrimeReduced)); - console.log('sigC (expected):', bytesToHex(sigC)); // Check c' == c const diff = new Uint8Array(32); scalarSub(diff, cPrimeReduced, sigC); - console.log('diff:', bytesToHex(diff)); - console.log('diff nonzero:', scalarIsNonzero(diff)); return !scalarIsNonzero(diff); } @@ -212,89 +174,6 @@ function reduceScalar32(r, x) { * - keyType: string - 'spend' or 'view' (which key was used to sign) * - error: string|null - error message if invalid */ -// Test function to verify Ed25519 and Keccak are working -export function testEd25519() { - const bytesToHex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); - - // Test 1: Verify 2*G (this is the key test - if this works, Ed25519 is correct) - const two = new Uint8Array(32); - two[0] = 2; - const twoG = scalarMultBase(two); - const twoGHex = bytesToHex(twoG); - const twoGExpected = 'c9a3f86aae465f0e56513864510f3997561fa2c9e85ea21dc2292309f3cd6022'; - console.log('2*G =', twoGHex); - console.log('2*G expected:', twoGExpected); - console.log('2*G match:', twoGHex === twoGExpected); - - // Test 2: Verify Keccak-256 - // Known test vector: Keccak256("") = c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 - const emptyHash = keccak256(new Uint8Array(0)); - const emptyHashHex = bytesToHex(emptyHash); - const emptyHashExpected = 'c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470'; - console.log('Keccak256("") =', emptyHashHex); - console.log('Expected:', emptyHashExpected); - console.log('Keccak match:', emptyHashHex === emptyHashExpected); - - // Test 3: Keccak of "test" - const testHash = keccak256(new TextEncoder().encode('test')); - const testHashHex = bytesToHex(testHash); - // Keccak256("test") = 9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658 - const testHashExpected = '9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658'; - console.log('Keccak256("test") =', testHashHex); - console.log('Expected:', testHashExpected); - console.log('Keccak test match:', testHashHex === testHashExpected); - - // Test 4: Point decompression roundtrip - // Compress 2*G, decompress it, recompress, should match - const twoGDecompressed = pointFromBytes(twoG); - if (twoGDecompressed) { - // Need to access pointToBytes - let me export it - console.log('2*G decompression: success'); - } else { - console.log('2*G decompression: FAILED'); - } - - // Test 5: Test with the actual public key from the signature test - const testPubKey = new Uint8Array([ - 0x28, 0x97, 0x3e, 0x82, 0x1c, 0xc2, 0xf2, 0xde, - 0x5e, 0x68, 0x9a, 0xd6, 0x1c, 0x4d, 0xda, 0xd4, - 0x8a, 0x1b, 0xac, 0x77, 0xf1, 0x94, 0x43, 0x97, - 0xe0, 0x6e, 0x90, 0xf7, 0xd6, 0x5f, 0xda, 0xd1 - ]); - const pubKeyPoint = pointFromBytes(testPubKey); - console.log('Public key decompression:', pubKeyPoint ? 'success' : 'FAILED'); - - // Test 6: Double scalar mult: verify 3*G + 5*G = 8*G - const three = new Uint8Array(32); three[0] = 3; - const five = new Uint8Array(32); five[0] = 5; - const eight = new Uint8Array(32); eight[0] = 8; - - const threeG = scalarMultBase(three); - const threeGPoint = pointFromBytes(threeG); - const result = doubleScalarMultBase(three, threeGPoint, five); // 3*(3G) + 5*G = 9G + 5G = 14G? No wait... - - // Actually test: a*G + b*G = (a+b)*G using G as the point - // doubleScalarMultBase(a, P, b) = a*P + b*G - // If P = G, then a*G + b*G = (a+b)*G - const oneScalar = new Uint8Array(32); oneScalar[0] = 1; - const Gcompressed = scalarMultBase(oneScalar); - const Gpoint = pointFromBytes(Gcompressed); - - // 3*G + 5*G should equal 8*G - const sumResult = doubleScalarMultBase(three, Gpoint, five); - const eightG = scalarMultBase(eight); - - console.log('3*G + 5*G =', bytesToHex(sumResult)); - console.log('8*G = ', bytesToHex(eightG)); - console.log('Double scalar mult match:', bytesToHex(sumResult) === bytesToHex(eightG)); - - return { - ed25519OK: twoGHex === twoGExpected, - keccakOK: emptyHashHex === emptyHashExpected && testHashHex === testHashExpected, - doubleScalarOK: bytesToHex(sumResult) === bytesToHex(eightG) - }; -} - export function verifySignature(message, address, signature) { // Parse signature header const isV1 = signature.startsWith('SigV1'); diff --git a/src/transaction.js b/src/transaction.js index 36ff672..672414b 100644 --- a/src/transaction.js +++ b/src/transaction.js @@ -11,6 +11,36 @@ */ import { keccak256, keccak256Hex } from './keccak.js'; + +// ============================================================================= +// ERROR CLASSES +// ============================================================================= + +/** + * Error thrown when parsing fails + * Provides detailed context about what went wrong and where + */ +export class ParseError extends Error { + constructor(message, context = {}) { + super(message); + this.name = 'ParseError'; + this.offset = context.offset; + this.field = context.field; + this.expected = context.expected; + this.actual = context.actual; + this.dataLength = context.dataLength; + } + + toString() { + let msg = `ParseError: ${this.message}`; + if (this.field) msg += ` [field: ${this.field}]`; + if (this.offset !== undefined) msg += ` [offset: ${this.offset}]`; + if (this.dataLength !== undefined) msg += ` [dataLength: ${this.dataLength}]`; + if (this.expected !== undefined) msg += ` [expected: ${this.expected}]`; + if (this.actual !== undefined) msg += ` [actual: ${this.actual}]`; + return msg; + } +} import { scalarMultBase, scalarMultPoint, pointAddCompressed, getGeneratorG } from './ed25519.js'; import { generateKeyDerivation, derivationToScalar, derivePublicKey, deriveSecretKey } from './scanning.js'; import { hashToPoint, generateKeyImage } from './keyimage.js'; @@ -4111,8 +4141,8 @@ function parseRingCtSignature(data, startOffset, inputCount, outputCount, mixin // Parse prunable data (bulletproofs + CLSAGs) with bounds checking // The prunable section follows the base section if (offset < data.length && type !== RCT_TYPE.Null) { - const prunable = parseRctSigPrunable(data, offset, type, inputCount, outputCount, mixin); - if (prunable) { + try { + const prunable = parseRctSigPrunable(data, offset, type, inputCount, outputCount, mixin); rct.bulletproofPlus = prunable.bulletproofPlus; rct.CLSAGs = prunable.CLSAGs; rct.TCLSAGs = prunable.TCLSAGs; @@ -4120,6 +4150,12 @@ function parseRingCtSignature(data, startOffset, inputCount, outputCount, mixin if (prunable._endOffset) { offset = prunable._endOffset; } + } catch (e) { + if (e instanceof ParseError) { + rct.prunable_parse_error = e.toString(); + } else { + rct.prunable_parse_error = `Unexpected error parsing prunable: ${e.message}`; + } } } @@ -4302,18 +4338,29 @@ function parseSalviumData(data, startOffset, full = true) { function parseRctSigPrunable(data, startOffset, type, inputCount, outputCount, mixin) { let offset = startOffset; - // Bounds check helper - const canRead = (count) => offset + count <= data.length; - - const readBytes = (count) => { - if (!canRead(count)) return null; + const readBytes = (count, fieldName) => { + if (offset + count > data.length) { + throw new ParseError(`Unexpected end of data reading ${fieldName}`, { + field: fieldName, + offset, + expected: count, + actual: data.length - offset, + dataLength: data.length + }); + } const result = data.slice(offset, offset + count); offset += count; return result; }; - const readVarint = () => { - if (offset >= data.length) return null; + const readVarint = (fieldName) => { + if (offset >= data.length) { + throw new ParseError(`Unexpected end of data reading varint for ${fieldName}`, { + field: fieldName, + offset, + dataLength: data.length + }); + } const { value, bytesRead } = decodeVarint(data, offset); offset += bytesRead; return value; @@ -4323,36 +4370,44 @@ function parseRctSigPrunable(data, startOffset, type, inputCount, outputCount, m // For BulletproofPlus types (6, 7, 8, 9), parse BulletproofPlus if (type >= RCT_TYPE.BulletproofPlus) { - const nbp = readVarint(); - if (nbp === null || nbp > 1000) return null; // Sanity check + const nbp = readVarint('bulletproofPlus count'); + if (nbp > 1000) { + throw new ParseError('Invalid bulletproofPlus count', { + field: 'bulletproofPlus count', + offset, + expected: '<=1000', + actual: nbp + }); + } result.bulletproofPlus = []; for (let i = 0; i < nbp; i++) { - const A = readBytes(32); - const A1 = readBytes(32); - const B = readBytes(32); - const r1 = readBytes(32); - const s1 = readBytes(32); - const d1 = readBytes(32); - - if (!d1) return null; // Ran out of data + const A = readBytes(32, `bulletproofPlus[${i}].A`); + const A1 = readBytes(32, `bulletproofPlus[${i}].A1`); + const B = readBytes(32, `bulletproofPlus[${i}].B`); + const r1 = readBytes(32, `bulletproofPlus[${i}].r1`); + const s1 = readBytes(32, `bulletproofPlus[${i}].s1`); + const d1 = readBytes(32, `bulletproofPlus[${i}].d1`); // L array - const Lcount = readVarint(); - if (Lcount === null || Lcount > 64) return null; // Sanity check (log2(2^64) = 64 max) + const Lcount = readVarint(`bulletproofPlus[${i}].L count`); + if (Lcount > 64) { + throw new ParseError('Invalid L array count in bulletproofPlus', { + field: `bulletproofPlus[${i}].L count`, + offset, + expected: '<=64', + actual: Lcount + }); + } const L = []; for (let j = 0; j < Lcount; j++) { - const key = readBytes(32); - if (!key) return null; - L.push(key); + L.push(readBytes(32, `bulletproofPlus[${i}].L[${j}]`)); } // R array (same size as L) const R = []; for (let j = 0; j < Lcount; j++) { - const key = readBytes(32); - if (!key) return null; - R.push(key); + R.push(readBytes(32, `bulletproofPlus[${i}].R[${j}]`)); } result.bulletproofPlus.push({ A, A1, B, r1, s1, d1, L, R }); @@ -4370,22 +4425,17 @@ function parseRctSigPrunable(data, startOffset, type, inputCount, outputCount, m // sx array: mixin + 1 elements, NO size prefix const sx = []; for (let j = 0; j < ringSize; j++) { - const key = readBytes(32); - if (!key) return null; - sx.push(key); + sx.push(readBytes(32, `TCLSAG[${i}].sx[${j}]`)); } // sy array: mixin + 1 elements, NO size prefix const sy = []; for (let j = 0; j < ringSize; j++) { - const key = readBytes(32); - if (!key) return null; - sy.push(key); + sy.push(readBytes(32, `TCLSAG[${i}].sy[${j}]`)); } - const c1 = readBytes(32); - const D = readBytes(32); - if (!D) return null; + const c1 = readBytes(32, `TCLSAG[${i}].c1`); + const D = readBytes(32, `TCLSAG[${i}].D`); result.TCLSAGs.push({ sx, sy, c1, D }); } @@ -4396,14 +4446,11 @@ function parseRctSigPrunable(data, startOffset, type, inputCount, outputCount, m // s array: mixin + 1 elements, NO size prefix const s = []; for (let j = 0; j < ringSize; j++) { - const key = readBytes(32); - if (!key) return null; - s.push(key); + s.push(readBytes(32, `CLSAG[${i}].s[${j}]`)); } - const c1 = readBytes(32); - const D = readBytes(32); - if (!D) return null; + const c1 = readBytes(32, `CLSAG[${i}].c1`); + const D = readBytes(32, `CLSAG[${i}].D`); result.CLSAGs.push({ s, c1, D }); } @@ -4413,9 +4460,7 @@ function parseRctSigPrunable(data, startOffset, type, inputCount, outputCount, m if (type >= RCT_TYPE.BulletproofPlus) { result.pseudoOuts = []; for (let i = 0; i < inputCount; i++) { - const key = readBytes(32); - if (!key) return null; - result.pseudoOuts.push(key); + result.pseudoOuts.push(readBytes(32, `pseudoOuts[${i}]`)); } } diff --git a/src/wallet-sync.js b/src/wallet-sync.js index f9ee768..e283e4e 100644 --- a/src/wallet-sync.js +++ b/src/wallet-sync.js @@ -1181,14 +1181,20 @@ export class WalletSync { } // Scan with CARROT algorithm - const result = scanCarrotOutput( - outputForScan, - this.carrotKeys.viewIncomingKey, - this.carrotKeys.accountSpendPubkey, - inputContext, - this.carrotSubaddresses, - amountCommitment - ); + let result; + try { + result = scanCarrotOutput( + outputForScan, + this.carrotKeys.viewIncomingKey, + this.carrotKeys.accountSpendPubkey, + inputContext, + this.carrotSubaddresses, + amountCommitment + ); + } catch (e) { + // Missing required fields in CARROT output - skip this output + return null; + } if (!result) { return null; diff --git a/test/address.test.js b/test/address.test.js index 7c6801f..60853dd 100644 --- a/test/address.test.js +++ b/test/address.test.js @@ -79,6 +79,18 @@ function assertLength(value, length, message = '') { } } +function assertThrows(fn, message = '') { + try { + fn(); + throw new Error(`${message} Expected function to throw, but it did not`); + } catch (e) { + if (e.message.includes('Expected function to throw')) { + throw e; + } + // Function threw as expected + } +} + // ============================================================ // Seed Generation Tests // ============================================================ @@ -178,15 +190,16 @@ test('createAddress creates valid stagenet address', () => { assertTrue(isStagenet(addr)); }); -test('createAddress returns null for invalid keys', () => { - const addr = createAddress({ - network: NETWORK.MAINNET, - format: ADDRESS_FORMAT.LEGACY, - type: ADDRESS_TYPE.STANDARD, - spendPublicKey: new Uint8Array(31), // Wrong length - viewPublicKey: testKeys.viewPublicKey +test('createAddress throws for invalid keys', () => { + assertThrows(() => { + createAddress({ + network: NETWORK.MAINNET, + format: ADDRESS_FORMAT.LEGACY, + type: ADDRESS_TYPE.STANDARD, + spendPublicKey: new Uint8Array(31), // Wrong length + viewPublicKey: testKeys.viewPublicKey + }); }); - assertEqual(addr, null); }); test('createAddress round-trips correctly', () => { @@ -255,7 +268,7 @@ test('toIntegratedAddress preserves payment ID', () => { assertEqual(bytesToHex(parsed.paymentId), paymentIdHex); }); -test('toIntegratedAddress returns null for integrated address input', () => { +test('toIntegratedAddress throws for integrated address input', () => { const standardAddr = createAddress({ network: NETWORK.MAINNET, format: ADDRESS_FORMAT.LEGACY, @@ -264,9 +277,10 @@ test('toIntegratedAddress returns null for integrated address input', () => { viewPublicKey: testKeys.viewPublicKey }); const integrated = toIntegratedAddress(standardAddr, 'deadbeef12345678'); - const doubleIntegrated = toIntegratedAddress(integrated, 'abcdef0123456789'); - assertEqual(doubleIntegrated, null); + assertThrows(() => { + toIntegratedAddress(integrated, 'abcdef0123456789'); + }); }); test('toStandardAddress extracts standard from integrated', () => { @@ -283,7 +297,7 @@ test('toStandardAddress extracts standard from integrated', () => { assertEqual(extracted, standardAddr); }); -test('toStandardAddress returns null for non-integrated', () => { +test('toStandardAddress throws for non-integrated', () => { const standardAddr = createAddress({ network: NETWORK.MAINNET, format: ADDRESS_FORMAT.LEGACY, @@ -291,9 +305,10 @@ test('toStandardAddress returns null for non-integrated', () => { spendPublicKey: testKeys.spendPublicKey, viewPublicKey: testKeys.viewPublicKey }); - const result = toStandardAddress(standardAddr); - assertEqual(result, null); + assertThrows(() => { + toStandardAddress(standardAddr); + }); }); test('createIntegratedAddressWithRandomId works', () => {