Files
salvium-rs/src/offline.js
T
Matt Hess 0bbb7bd6f0 Wire crypto provider into transaction, wallet, and scanning code
Switch 16 consumer files from direct crypto imports to the switchable
  crypto provider. setCryptoBackend('wasm') now accelerates all real
  transaction building, output scanning, and key derivation end-to-end.
  Source implementation files (keccak.js, ed25519.js, scanning.js, etc.)
  keep direct imports to avoid circular dependencies.
2026-02-01 03:29:45 +00:00

464 lines
14 KiB
JavaScript

/**
* Offline Transaction Signing
*
* Provides a workflow for cold wallet / air-gapped transaction signing:
* 1. Online (view-only) wallet creates unsigned transaction
* 2. Export unsigned transaction to portable format
* 3. Offline (full) wallet imports and signs
* 4. Export signed transaction
* 5. Online wallet imports and broadcasts
*
* @module offline
*/
import { bytesToHex, hexToBytes } from './address.js';
import { keccak256 } from './crypto/index.js';
import {
signTransaction,
serializeTransaction,
validateTransaction
} from './transaction.js';
// ============================================================================
// UNSIGNED TRANSACTION FORMAT
// ============================================================================
/**
* Version for unsigned transaction format
*/
export const UNSIGNED_TX_VERSION = 1;
/**
* Create an unsigned transaction structure
* @param {Object} txData - Transaction data
* @returns {Object} Unsigned transaction
*/
export function createUnsignedTx(txData) {
return {
version: UNSIGNED_TX_VERSION,
created: Date.now(),
tx: {
version: txData.version || 2,
unlockTime: txData.unlockTime || 0n,
inputs: txData.inputs.map(input => ({
// Include all data needed for signing
amount: input.amount.toString(),
keyImage: null, // Will be computed during signing
outputIndex: input.outputIndex,
txHash: input.txHash,
publicKey: bytesToHex(input.publicKey),
// Ring member data
ring: input.ring.map(member => ({
publicKey: bytesToHex(member.publicKey),
commitment: member.commitment ? bytesToHex(member.commitment) : null,
globalIndex: member.globalIndex.toString()
})),
realOutputIndex: input.realOutputIndex,
// Commitment data
commitment: input.commitment ? bytesToHex(input.commitment) : null,
mask: input.mask ? bytesToHex(input.mask) : null
})),
outputs: txData.outputs.map(output => ({
amount: output.amount.toString(),
publicKey: bytesToHex(output.publicKey),
viewTag: output.viewTag,
// CARROT encrypted data
encryptedAmount: output.encryptedAmount ? bytesToHex(output.encryptedAmount) : null,
commitment: output.commitment ? bytesToHex(output.commitment) : null,
// Destination info (for verification)
destinationAddress: output.destinationAddress || null
})),
extra: txData.extra ? bytesToHex(txData.extra) : '',
fee: txData.fee.toString(),
// Additional metadata for signing
txSecretKey: txData.txSecretKey ? bytesToHex(txData.txSecretKey) : null,
changeIndex: txData.changeIndex ?? null
},
// Metadata
metadata: {
networkType: txData.networkType || 'mainnet',
description: txData.description || '',
priority: txData.priority || 'default'
}
};
}
/**
* Parse an unsigned transaction from JSON
* @param {Object|string} data - Unsigned transaction data
* @returns {Object} Parsed unsigned transaction
*/
export function parseUnsignedTx(data) {
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
if (parsed.version !== UNSIGNED_TX_VERSION) {
throw new Error(`Unsupported unsigned tx version: ${parsed.version}`);
}
return {
version: parsed.version,
created: parsed.created,
tx: {
version: parsed.tx.version,
unlockTime: BigInt(parsed.tx.unlockTime),
inputs: parsed.tx.inputs.map(input => ({
amount: BigInt(input.amount),
keyImage: null,
outputIndex: input.outputIndex,
txHash: input.txHash,
publicKey: hexToBytes(input.publicKey),
ring: input.ring.map(member => ({
publicKey: hexToBytes(member.publicKey),
commitment: member.commitment ? hexToBytes(member.commitment) : null,
globalIndex: BigInt(member.globalIndex)
})),
realOutputIndex: input.realOutputIndex,
commitment: input.commitment ? hexToBytes(input.commitment) : null,
mask: input.mask ? hexToBytes(input.mask) : null
})),
outputs: parsed.tx.outputs.map(output => ({
amount: BigInt(output.amount),
publicKey: hexToBytes(output.publicKey),
viewTag: output.viewTag,
encryptedAmount: output.encryptedAmount ? hexToBytes(output.encryptedAmount) : null,
commitment: output.commitment ? hexToBytes(output.commitment) : null,
destinationAddress: output.destinationAddress
})),
extra: parsed.tx.extra ? hexToBytes(parsed.tx.extra) : new Uint8Array(0),
fee: BigInt(parsed.tx.fee),
txSecretKey: parsed.tx.txSecretKey ? hexToBytes(parsed.tx.txSecretKey) : null,
changeIndex: parsed.tx.changeIndex
},
metadata: parsed.metadata
};
}
// ============================================================================
// SIGNED TRANSACTION FORMAT
// ============================================================================
/**
* Version for signed transaction format
*/
export const SIGNED_TX_VERSION = 1;
/**
* Create a signed transaction structure
* @param {Object} signedTx - Signed transaction
* @param {Object} metadata - Transaction metadata
* @returns {Object} Signed transaction export
*/
export function createSignedTx(signedTx, metadata = {}) {
const serialized = serializeTransaction(signedTx);
const txHash = bytesToHex(keccak256(serialized));
return {
version: SIGNED_TX_VERSION,
created: Date.now(),
txHash,
txBlob: bytesToHex(serialized),
// Include original metadata
metadata: {
...metadata,
fee: signedTx.fee?.toString() || '0',
inputCount: signedTx.inputs?.length || 0,
outputCount: signedTx.outputs?.length || 0
}
};
}
/**
* Parse a signed transaction from JSON
* @param {Object|string} data - Signed transaction data
* @returns {Object} Parsed signed transaction
*/
export function parseSignedTx(data) {
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
if (parsed.version !== SIGNED_TX_VERSION) {
throw new Error(`Unsupported signed tx version: ${parsed.version}`);
}
return {
version: parsed.version,
created: parsed.created,
txHash: parsed.txHash,
txBlob: hexToBytes(parsed.txBlob),
metadata: parsed.metadata
};
}
// ============================================================================
// OFFLINE SIGNING WORKFLOW
// ============================================================================
/**
* Export an unsigned transaction for offline signing
* @param {Object} txData - Transaction data from wallet
* @param {Object} options - Export options
* @returns {string} JSON string for transfer to offline device
*/
export function exportUnsignedTx(txData, options = {}) {
const unsigned = createUnsignedTx(txData);
if (options.compact) {
return JSON.stringify(unsigned);
}
return JSON.stringify(unsigned, null, 2);
}
/**
* Import an unsigned transaction on the offline device
* @param {string} exportedTx - JSON string from online wallet
* @returns {Object} Unsigned transaction ready for signing
*/
export function importUnsignedTx(exportedTx) {
return parseUnsignedTx(exportedTx);
}
/**
* Sign an imported unsigned transaction
* @param {Object} unsignedTx - Imported unsigned transaction
* @param {Uint8Array} spendSecretKey - Spend secret key
* @param {Object} options - Signing options
* @returns {Object} Signed transaction export
*/
export function signOffline(unsignedTx, spendSecretKey, options = {}) {
// Validate the unsigned transaction
if (!unsignedTx.tx) {
throw new Error('Invalid unsigned transaction: missing tx data');
}
// Sign the transaction
const signedTx = signTransaction(unsignedTx.tx, spendSecretKey);
// Validate the signed transaction
const validation = validateTransaction(signedTx);
if (!validation.valid && !options.skipValidation) {
throw new Error(`Transaction validation failed: ${validation.errors.join(', ')}`);
}
// Create export format
return createSignedTx(signedTx, unsignedTx.metadata);
}
/**
* Export a signed transaction for broadcast
* @param {Object} signedTx - Signed transaction from signOffline
* @param {Object} options - Export options
* @returns {string} JSON string for transfer to online device
*/
export function exportSignedTx(signedTx, options = {}) {
if (options.compact) {
return JSON.stringify(signedTx);
}
return JSON.stringify(signedTx, null, 2);
}
/**
* Import a signed transaction on the online device
* @param {string} exportedTx - JSON string from offline device
* @returns {Object} Signed transaction ready for broadcast
*/
export function importSignedTx(exportedTx) {
return parseSignedTx(exportedTx);
}
/**
* Get transaction blob for broadcast
* @param {Object} signedTx - Imported signed transaction
* @returns {string} Hex-encoded transaction blob
*/
export function getTxBlobHex(signedTx) {
if (typeof signedTx.txBlob === 'string') {
return signedTx.txBlob;
}
return bytesToHex(signedTx.txBlob);
}
// ============================================================================
// KEY IMAGE EXPORT/IMPORT
// ============================================================================
/**
* Export key images for a view-only wallet to detect spent outputs
* @param {Array<Object>} outputs - Outputs with key images
* @returns {Object} Key image export data
*/
export function exportKeyImages(outputs) {
return {
version: 1,
created: Date.now(),
keyImages: outputs.map(output => ({
keyImage: bytesToHex(output.keyImage),
txHash: output.txHash,
outputIndex: output.outputIndex,
amount: output.amount.toString()
}))
};
}
/**
* Import key images into a view-only wallet
* @param {Object|string} data - Key image export data
* @returns {Array<Object>} Key image records
*/
export function importKeyImages(data) {
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
return parsed.keyImages.map(ki => ({
keyImage: hexToBytes(ki.keyImage),
txHash: ki.txHash,
outputIndex: ki.outputIndex,
amount: BigInt(ki.amount)
}));
}
// ============================================================================
// OUTPUTS EXPORT/IMPORT
// ============================================================================
/**
* Export outputs from full wallet for view-only wallet
* @param {Array<Object>} outputs - Wallet outputs
* @returns {Object} Outputs export data
*/
export function exportOutputs(outputs) {
return {
version: 1,
created: Date.now(),
outputs: outputs.map(output => ({
txHash: output.txHash,
outputIndex: output.outputIndex,
globalIndex: output.globalIndex?.toString(),
amount: output.amount.toString(),
publicKey: bytesToHex(output.publicKey),
keyImage: output.keyImage ? bytesToHex(output.keyImage) : null,
commitment: output.commitment ? bytesToHex(output.commitment) : null,
mask: output.mask ? bytesToHex(output.mask) : null,
blockHeight: output.blockHeight,
assetType: output.assetType || 'SAL',
subaddressIndex: output.subaddressIndex
}))
};
}
/**
* Import outputs into a wallet
* @param {Object|string} data - Outputs export data
* @returns {Array<Object>} Output records
*/
export function importOutputs(data) {
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
return parsed.outputs.map(output => ({
txHash: output.txHash,
outputIndex: output.outputIndex,
globalIndex: output.globalIndex ? BigInt(output.globalIndex) : null,
amount: BigInt(output.amount),
publicKey: hexToBytes(output.publicKey),
keyImage: output.keyImage ? hexToBytes(output.keyImage) : null,
commitment: output.commitment ? hexToBytes(output.commitment) : null,
mask: output.mask ? hexToBytes(output.mask) : null,
blockHeight: output.blockHeight,
assetType: output.assetType,
subaddressIndex: output.subaddressIndex
}));
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Verify unsigned transaction integrity
* @param {Object} unsignedTx - Unsigned transaction
* @returns {Object} { valid: boolean, errors: string[] }
*/
export function verifyUnsignedTx(unsignedTx) {
const errors = [];
if (!unsignedTx.tx) {
errors.push('Missing transaction data');
return { valid: false, errors };
}
const tx = unsignedTx.tx;
// Check inputs
if (!tx.inputs || tx.inputs.length === 0) {
errors.push('Transaction has no inputs');
} else {
for (let i = 0; i < tx.inputs.length; i++) {
const input = tx.inputs[i];
if (!input.ring || input.ring.length === 0) {
errors.push(`Input ${i} has no ring members`);
}
if (input.realOutputIndex === undefined) {
errors.push(`Input ${i} missing real output index`);
}
}
}
// Check outputs
if (!tx.outputs || tx.outputs.length === 0) {
errors.push('Transaction has no outputs');
}
// Check fee
if (tx.fee === undefined || tx.fee <= 0n) {
errors.push('Invalid fee');
}
return { valid: errors.length === 0, errors };
}
/**
* Get summary of unsigned transaction
* @param {Object} unsignedTx - Unsigned transaction
* @returns {Object} Transaction summary
*/
export function summarizeUnsignedTx(unsignedTx) {
const tx = unsignedTx.tx;
const totalIn = tx.inputs.reduce((sum, i) => sum + i.amount, 0n);
const totalOut = tx.outputs.reduce((sum, o) => sum + o.amount, 0n);
return {
inputCount: tx.inputs.length,
outputCount: tx.outputs.length,
totalIn,
totalOut,
fee: tx.fee,
ringSize: tx.inputs[0]?.ring?.length || 0,
created: new Date(unsignedTx.created).toISOString(),
network: unsignedTx.metadata?.networkType || 'unknown',
description: unsignedTx.metadata?.description || ''
};
}
export default {
// Unsigned tx
UNSIGNED_TX_VERSION,
createUnsignedTx,
parseUnsignedTx,
exportUnsignedTx,
importUnsignedTx,
verifyUnsignedTx,
summarizeUnsignedTx,
// Signed tx
SIGNED_TX_VERSION,
createSignedTx,
parseSignedTx,
exportSignedTx,
importSignedTx,
getTxBlobHex,
// Signing
signOffline,
// Key images
exportKeyImages,
importKeyImages,
// Outputs
exportOutputs,
importOutputs
};