Add AUDIT transaction support for compliance audits

- Add buildAuditTransaction() in transaction.js
  - Add createAuditTransaction() in wallet.js
  - Change-is-zero requirement: all coins locked
  - Coins return via protocol_tx after maturity
  - 14 unit tests
  - Only valid during AUDIT hard fork periods (HF v6, v8)
This commit is contained in:
Matt Hess
2026-01-25 20:50:41 +00:00
parent 6e6743269e
commit 4ba87a02d8
3 changed files with 714 additions and 8 deletions
+137 -2
View File
@@ -2962,11 +2962,12 @@ export function buildTransaction(params, options = {}) {
if (!inputs || inputs.length === 0) {
throw new Error('At least one input is required');
}
// STAKE, BURN, and CONVERT transactions can have no payment destinations (only change)
// STAKE, BURN, CONVERT, and AUDIT transactions can have no payment destinations
// - STAKE/BURN: The "burned" amount goes to amount_burnt field, not to outputs
// - CONVERT: The converted output is created by the protocol_tx at block mining time
// - AUDIT: All coins locked (change-is-zero), returned via protocol_tx after maturity
if ((!destinations || destinations.length === 0) &&
txType !== TX_TYPE.STAKE && txType !== TX_TYPE.BURN && txType !== TX_TYPE.CONVERT) {
txType !== TX_TYPE.STAKE && txType !== TX_TYPE.BURN && txType !== TX_TYPE.CONVERT && txType !== TX_TYPE.AUDIT) {
throw new Error('At least one destination is required');
}
@@ -3518,6 +3519,140 @@ export function buildConvertTransaction(params, options = {}) {
);
}
/**
* Build an AUDIT transaction
*
* AUDIT transactions enable users to participate in periodic compliance/transparency
* audits during designated AUDIT hard fork periods. Users voluntarily lock their
* holdings for a defined period, providing cryptographic proofs of ownership.
*
* NOTE: AUDIT transactions are only valid during specific AUDIT hard fork periods
* (HF v6, v8). Transactions submitted outside these windows will be rejected.
*
* @param {Object} params - Transaction parameters:
* - inputs: Array of inputs to spend (all coins from the wallet/subaddress)
* - auditAmount: Total amount being audited (locked)
* - sourceAsset: Asset type being audited ('SAL' or 'SAL1' depending on HF)
* - destAsset: Asset type received after maturity ('SAL1')
* - unlockHeight: Block height when coins unlock (current_height + lock_period)
* - returnAddress: Public key for receiving coins after maturity
* - returnPubkey: TX public key for ECDH
* - fee: Transaction fee
* @param {Object} options - Optional settings:
* - txSecretKey: Pre-set transaction secret key
* - useCarrot: Use CARROT output format
* - viewSecretKey: View secret key for audit disclosure (encrypted in tx)
* - spendPublicKey: Spend public key for audit verification
* @returns {Object} Built transaction ready for broadcast
*/
export function buildAuditTransaction(params, options = {}) {
const {
inputs,
auditAmount,
sourceAsset,
destAsset,
unlockHeight,
returnAddress,
returnPubkey,
fee
} = params;
const {
txSecretKey,
useCarrot = false,
viewSecretKey = null,
spendPublicKey = null
} = options;
// Validate inputs
if (!inputs || inputs.length === 0) {
throw new Error('At least one input is required');
}
if (!auditAmount || auditAmount <= 0n) {
throw new Error('Audit amount must be positive');
}
if (!sourceAsset) {
throw new Error('Source asset type is required');
}
if (!destAsset) {
throw new Error('Destination asset type is required');
}
// AUDIT transactions convert SAL -> SAL1 or audit SAL1 -> SAL1
const validPairs = [
['SAL', 'SAL1'],
['SAL1', 'SAL1']
];
const isValidPair = validPairs.some(
([from, to]) => from === sourceAsset && to === destAsset
);
if (!isValidPair) {
throw new Error(`Invalid audit asset pair: ${sourceAsset} -> ${destAsset}. AUDIT uses SAL->SAL1 or SAL1->SAL1`);
}
if (!returnAddress) {
throw new Error('Return address is required for audit transaction');
}
if (!returnPubkey) {
throw new Error('Return pubkey is required for audit transaction');
}
if (!unlockHeight || unlockHeight <= 0) {
throw new Error('Unlock height must be positive');
}
const auditAmountBig = typeof auditAmount === 'bigint' ? auditAmount : BigInt(auditAmount);
const feeBig = typeof fee === 'bigint' ? fee : BigInt(fee);
// Calculate total input amount
let totalInputAmount = 0n;
for (const input of inputs) {
const amount = typeof input.amount === 'bigint' ? input.amount : BigInt(input.amount);
totalInputAmount += amount;
}
// For AUDIT: all coins are locked (minus fee), no change output
// The change-is-zero proof requires that change = 0
const expectedAudit = totalInputAmount - feeBig;
if (auditAmountBig !== expectedAudit) {
throw new Error(
`AUDIT requires all inputs minus fee. Expected audit amount: ${expectedAudit}, got: ${auditAmountBig}. ` +
`AUDIT transactions must lock all coins (no change allowed).`
);
}
// AUDIT has no change output - change-is-zero is a requirement
// The locked coins return via protocol_tx after maturity
const destinations = [];
// Build using base buildTransaction with AUDIT options
return buildTransaction(
{
inputs,
destinations, // Empty - AUDIT has no payment destinations, no change
changeAddress: null, // No change for AUDIT
fee
},
{
unlockTime: unlockHeight, // Unlock after the audit period
txSecretKey,
useCarrot,
txType: TX_TYPE.AUDIT,
amountBurnt: auditAmountBig, // Amount being locked for audit
sourceAssetType: sourceAsset,
destinationAssetType: destAsset,
returnAddress,
returnPubkey,
protocolTxData: null,
amountSlippageLimit: 0n, // Not used for AUDIT
// AUDIT-specific options for the special proofs
auditData: {
viewSecretKey, // For encrypted view key disclosure
spendPublicKey // For spend authority verification
}
}
);
}
/**
* Sign an unsigned transaction
*
+143 -6
View File
@@ -26,6 +26,7 @@ import {
buildStakeTransaction,
buildBurnTransaction,
buildConvertTransaction,
buildAuditTransaction,
signTransaction,
prepareInputs,
selectUTXOs,
@@ -1625,14 +1626,150 @@ export class Wallet {
}
/**
* Create an audit transaction (Salvium-specific)
* @param {Object} options - Options
* @returns {Promise<Object>} Audit transaction
* Create an AUDIT transaction (Salvium-specific)
*
* AUDIT transactions enable users to participate in periodic compliance/transparency
* audits during designated AUDIT hard fork periods. Users voluntarily lock ALL their
* holdings (or from a specific account/subaddress) for a defined period.
*
* NOTE: AUDIT transactions are only valid during specific AUDIT hard fork periods
* (HF v6, v8). Transactions submitted outside these windows will be rejected.
*
* The change-is-zero requirement means ALL coins must be locked - no partial audits.
* Coins are returned via protocol_tx after the lock period expires.
*
* @param {Object} options - Options:
* - sourceAsset: Asset to audit ('SAL' or 'SAL1' depending on HF), default 'SAL'
* - destAsset: Asset received after maturity ('SAL1'), default 'SAL1'
* - accountIndex: Source account index (default: 0)
* - subaddressIndices: Specific subaddresses to audit (default: all)
* - lockPeriod: Lock period in blocks (default: network-specific from AUDIT_HARD_FORKS)
* - ringSize: Ring size for anonymity (default: 16)
* - priority: Fee priority ('low'|'default'|'elevated'|'priority')
* - rpcClient: RPC client for fetching ring members
* @returns {Promise<Object>} Audit transaction ready for broadcast
*/
async createAuditTransaction(options = {}) {
// TODO: Implement audit transaction following Salvium spec
// This creates a TX_TYPE.AUDIT transaction
throw new Error('Audit transactions not yet implemented');
if (!this.canSign()) {
throw new Error('Full wallet required to create audit transactions');
}
const {
sourceAsset = 'SAL',
destAsset = 'SAL1',
accountIndex = 0,
subaddressIndices = null, // null = all subaddresses in account
lockPeriod = null, // null = use network default from AUDIT_HARD_FORKS
ringSize = 16,
priority = 'default',
rpcClient = null
} = options;
// Validate asset types
const validSourceAssets = ['SAL', 'SAL1'];
if (!validSourceAssets.includes(sourceAsset)) {
throw new Error(`Invalid source asset: ${sourceAsset}. Must be SAL or SAL1`);
}
if (destAsset !== 'SAL1') {
throw new Error(`Invalid destination asset: ${destAsset}. AUDIT destination must be SAL1`);
}
// Get all UTXOs from the specified account/subaddresses
const utxoOptions = {
unlockedOnly: true,
accountIndex,
assetType: sourceAsset
};
if (subaddressIndices) {
utxoOptions.subaddressIndices = subaddressIndices;
}
const availableUTXOs = this.getUTXOs(utxoOptions);
if (availableUTXOs.length === 0) {
throw new Error(`No unlocked ${sourceAsset} outputs available for audit`);
}
// Calculate total amount to audit (ALL coins - change-is-zero requirement)
let totalAmount = 0n;
for (const utxo of availableUTXOs) {
totalAmount += typeof utxo.amount === 'bigint' ? utxo.amount : BigInt(utxo.amount);
}
// Estimate fee for all inputs, 0 outputs (AUDIT has no outputs)
const estimatedFee = estimateTransactionFee(
availableUTXOs.length,
0, // AUDIT has 0 outputs (change-is-zero)
{ priority, ringSize }
);
// Audit amount is total minus fee
const auditAmount = totalAmount - estimatedFee;
if (auditAmount <= 0n) {
throw new Error(`Insufficient funds: total ${totalAmount} minus fee ${estimatedFee} <= 0`);
}
// Prepare inputs with ring members (decoys)
const preparedInputs = await prepareInputs(availableUTXOs, rpcClient, { ringSize });
// Recalculate fee with actual input count
const actualFee = estimateTransactionFee(
preparedInputs.length,
0, // AUDIT has 0 outputs
{ priority, ringSize }
);
// Recalculate audit amount with actual fee
const actualAuditAmount = totalAmount - actualFee;
if (actualAuditAmount <= 0n) {
throw new Error(`Insufficient funds after fee calculation`);
}
// Calculate unlock height
// Default lock periods from C++: mainnet 30*24*10 or 30*24*14, testnet 30 or 40
const defaultLockPeriod = 30 * 24 * 10; // ~10 days on mainnet (1 block/min)
const lockBlocks = lockPeriod || defaultLockPeriod;
const unlockHeight = this._syncHeight + lockBlocks;
// Return address and pubkey for receiving coins after maturity
const returnAddress = this._spendPublicKey;
const returnPubkey = this._viewPublicKey;
// Build the audit transaction
const tx = buildAuditTransaction(
{
inputs: preparedInputs,
auditAmount: actualAuditAmount,
sourceAsset,
destAsset,
unlockHeight,
returnAddress,
returnPubkey,
fee: actualFee
},
{
useCarrot: false,
viewSecretKey: this._viewSecretKey, // For audit disclosure
spendPublicKey: this._spendPublicKey // For spend authority verification
}
);
// Validate
const validation = validateTransaction(tx);
if (!validation.valid) {
throw new Error(`Audit transaction validation failed: ${validation.errors.join(', ')}`);
}
// Add metadata for tracking
tx._meta = tx._meta || {};
tx._meta.txType = 'AUDIT';
tx._meta.auditAmount = actualAuditAmount.toString();
tx._meta.sourceAsset = sourceAsset;
tx._meta.destAsset = destAsset;
tx._meta.unlockHeight = unlockHeight;
tx._meta.lockPeriod = lockBlocks;
return tx;
}
/**