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:
+137
-2
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user