import type { PrivateKey, PublicKey, Script, UTXO } from "libnexa-ts"; import { Address, AddressType, Transaction, TransactionBuilder, UnitUtils } from "libnexa-ts"; import type { KeyManager } from "./key"; import type { RostrumService } from "./rostrum"; import type { AssetMovement, TransactionDTO, TransactionEntity, TransactionType } from "../types/db.types"; import { currentTimestamp, isNullOrEmpty, isValidNexaAddress, MAX_INT64, tokenAmountToAssetAmount, tokenIdToHex, VAULT_FIRST_BLOCK } from "../utils/common"; import type { WalletDB } from "../persistence/wallet-db"; import type { ITXHistory } from "../types/rostrum.types"; import type { AddressKey } from "../types/wallet.types"; export interface TxTemplateData { templateScript: Script; constraintScript: Script; visibleArgs: any[]; publicKey: PublicKey; } export interface TxOptions { isConsolidate?: boolean; templateData?: TxTemplateData; feeFromAmount?: boolean; } export class TransactionService { private readonly rostrumService: RostrumService; private readonly keyManager: KeyManager; private readonly walletDb: WalletDB; private readonly MAX_INPUTS_OUTPUTS = 250; public constructor(rostrumService: RostrumService, keysManager: KeyManager, walletDb: WalletDB) { this.rostrumService = rostrumService; this.keyManager = keysManager; this.walletDb = walletDb; } public async broadcastTransaction(txHex: string): Promise { return this.rostrumService.broadcast(txHex); } public async fetchTransactionsHistory(address: string, fromHeight: number): Promise<{txs: ITXHistory[], lastHeight: number}> { let maxHeight = fromHeight; const fromHeightFilter = fromHeight > 0 ? fromHeight + 1 : 0; const txHistory = await this.rostrumService.getTransactionsHistory(address, fromHeightFilter); for (const tx of txHistory) { maxHeight = Math.max(maxHeight, tx.height); } return {txs: txHistory, lastHeight: maxHeight}; } public async fetchVaultTransactions(address: string): Promise { const txHistory = await this.rostrumService.getTransactionsHistory(address, VAULT_FIRST_BLOCK); const transactions: Promise[] = []; for (const tx of txHistory) { const txEntry = this.classifyTransaction(tx.tx_hash, [address]); transactions.push(txEntry); } return Promise.all(transactions); } public async classifyAndSaveTransaction(accountId: number, txHash: string, myAddresses: string[]): Promise { const txDto = await this.classifyTransaction(txHash, myAddresses); txDto.accountId = accountId; const txEntity: TransactionEntity = { ...txDto, othersOutputs: JSON.stringify(txDto.othersOutputs), myOutputs: JSON.stringify(txDto.myOutputs), myInputs: JSON.stringify(txDto.myInputs) }; const involvedAssets = new Set(); txDto.myInputs.forEach(m => m.assetId && involvedAssets.add(m.assetId)); txDto.myOutputs.forEach(m => m.assetId && involvedAssets.add(m.assetId)); for (const assetId of involvedAssets) { await this.walletDb.addAssetTransaction({ accountId: accountId, assetId: assetId, txIdem: txDto.txIdem, time: txDto.time }); } await this.walletDb.addLocalTransaction(txEntity); } private async classifyTransaction(txHash: string, myAddresses: string[]): Promise { const t = await this.rostrumService.getTransaction(txHash); const myInputs: AssetMovement[] = []; const othersInputs: AssetMovement[] = []; const myOutputs: AssetMovement[] = []; const othersOutputs: AssetMovement[] = []; for (const vin of t.vin) { const movement: AssetMovement = { address: vin.addresses[0], nexaAmount: vin.value_satoshi.toString(), assetId: vin.token_id_hex ?? "", assetAmount: tokenAmountToAssetAmount(vin.groupQuantity) }; if (myAddresses.includes(movement.address)) { myInputs.push(movement); } else { othersInputs.push(movement); } } for (const vout of t.vout) { if (isNullOrEmpty(vout.scriptPubKey.addresses)) continue; const movement: AssetMovement = { address: vout.scriptPubKey.addresses[0], nexaAmount: vout.value_satoshi.toString(), assetId: vout.scriptPubKey.token_id_hex ?? "", assetAmount: tokenAmountToAssetAmount(vout.scriptPubKey.groupQuantity) }; if (myAddresses.includes(movement.address)) { myOutputs.push(movement); } else { othersOutputs.push(movement); } } let type: TransactionType; if (myInputs.length === 0) { type = 'receive'; } else if (myOutputs.length === 0) { type = 'send'; } else if (othersInputs.length === 0 && othersOutputs.length === 0) { type = 'self'; } else if (othersInputs.length === 0 && othersOutputs.length > 0) { type = 'send'; } else if (othersInputs.length > 0 && othersOutputs.length === 0) { type = 'receive'; } else { type = 'swap'; } const isConfirmed = t.height > 0; const txDto: TransactionDTO = { accountId: 0, // Will be set in classifyAndSaveTransaction txId: t.txid, txIdem: t.txidem, time: isConfirmed ? t.time : currentTimestamp(), height: isConfirmed ? t.height : 0, type: type, fee: t.fee_satoshi.toString(), othersOutputs: type == 'send' || type == 'swap' ? othersOutputs : [], myOutputs: myOutputs, myInputs: myInputs }; return txDto; } public async buildAndSignTransferTransaction( from: AddressKey[], toAddr: string, toChange: string, amount: string, feeFromAmount?: boolean, token?: string, feePerByte?: number, data?: string ): Promise { const txOptions: TxOptions = { feeFromAmount: feeFromAmount } const txBuilder = this.prepareTransaction(toAddr, amount, token, data); if (feePerByte) { txBuilder.feePerByte(feePerByte); } let tokenPrivKeys = new Map(); if (token) { tokenPrivKeys = await this.populateTokenInputsAndChange(txBuilder, from, toChange, token, BigInt(amount)); } const privKeys = await this.populateNexaInputsAndChange(txBuilder, from, toChange, txOptions); tokenPrivKeys.forEach((v, k) => privKeys.set(k, v)); return await this.finalizeTransaction(txBuilder, Array.from(privKeys.values())); } public async buildAndSignConsolidateTransaction(from: AddressKey[], toChange: string, templateData?: TxTemplateData): Promise { const txOptions: TxOptions = { isConsolidate: true, templateData: templateData } const txBuilder = new TransactionBuilder(); const privKeys = await this.populateNexaInputsAndChange(txBuilder, from, toChange, txOptions); return this.finalizeTransaction(txBuilder, Array.from(privKeys.values())); } private prepareTransaction(toAddr: string, amount: string, token?: string, data?: string): TransactionBuilder { if (!isValidNexaAddress(toAddr) && !isValidNexaAddress(toAddr, AddressType.PayToPublicKeyHash)) { throw new Error('Invalid Address.'); } if ((token && BigInt(amount) < 1n) || (!token && parseInt(amount) < Transaction.DUST_AMOUNT)) { throw new Error("The amount is too low."); } if ((token && BigInt(amount) > MAX_INT64) || (!token && parseInt(amount) > Transaction.MAX_MONEY)) { throw new Error("The amount is too high."); } const builder = new TransactionBuilder(); if (data) { builder.addData(data); } if (token) { if (!isValidNexaAddress(token, AddressType.GroupIdAddress)) { throw new Error('Invalid Token ID'); } if (Address.getOutputType(toAddr) === 0) { throw new Error('Token must be sent to script template address'); } builder.to(toAddr, Transaction.DUST_AMOUNT, token, BigInt(amount)) } else { builder.to(toAddr, amount); } return builder; } private async populateNexaInputsAndChange(txBuilder: TransactionBuilder, from: AddressKey[], toChange: string, options: TxOptions): Promise> { const allKeys = from.filter(k => BigInt(k.balance.confirmed) + BigInt(k.balance.unconfirmed) > 0n); if (isNullOrEmpty(allKeys)) { throw new Error("Not enough Nexa balance."); } const usedKeys = new Map(); const origAmount = options.isConsolidate ? 0 : Number(txBuilder.transaction.outputs.find(out => out.value > 0n)!.value); for (const key of allKeys) { const utxos = await this.rostrumService.getNexaUtxos(key.address); for (const utxo of utxos) { const input: UTXO = { outpoint: utxo.outpoint_hash, address: key.address, satoshis: utxo.value, templateData: options.templateData } txBuilder.from(input); if (!usedKeys.has(key.address)) { const hdkey = this.keyManager.getKey(key.keyPath); usedKeys.set(key.address, hdkey.privateKey); } if (options.isConsolidate) { txBuilder.change(toChange); if (txBuilder.transaction.inputs.length > this.MAX_INPUTS_OUTPUTS) { return usedKeys; } } else { const tx = txBuilder.transaction; if (tx.inputs.length > this.MAX_INPUTS_OUTPUTS) { throw new Error("Too many inputs. Consider consolidate transactions or reduce the send amount."); } const unspent = tx.getUnspentValue(); if (unspent < 0n) { continue; } if (unspent == 0n && options.feeFromAmount) { const txFee = tx.estimateRequiredFee(); tx.updateOutputAmount(0, origAmount - txFee); return usedKeys; } txBuilder.change(toChange); if (options.feeFromAmount) { const hasChange = tx.getChangeOutput(); let txFee = tx.estimateRequiredFee(); tx.updateOutputAmount(0, origAmount - txFee); // edge case where change added after update if (!hasChange && tx.getChangeOutput()) { txFee = tx.estimateRequiredFee(); tx.updateOutputAmount(0, origAmount - txFee); } } // check again after change output manipulation if (tx.getUnspentValue() < tx.estimateRequiredFee()) { // try to add more utxos to satisfy the minimum fee continue; } return usedKeys; } } } if (options.isConsolidate) { if (usedKeys.size > 0) { return usedKeys; } throw new Error("Not enough Nexa balance."); } const err = { errorMsg: "Not enough Nexa balance.", amount: UnitUtils.formatNEXA(txBuilder.transaction.outputs[0].value), fee: UnitUtils.formatNEXA(txBuilder.transaction.estimateRequiredFee()) } throw new Error(JSON.stringify(err)); } private async populateTokenInputsAndChange(txBuilder: TransactionBuilder, from: AddressKey[], toChange: string, token: string, outTokenAmount: bigint): Promise> { const tokenHex = tokenIdToHex(token); const allKeys = from.filter(k => Object.keys(k.tokensBalance).includes(tokenHex)); if (isNullOrEmpty(allKeys)) { throw new Error("Not enough token balance."); } const usedKeys = new Map(); let inTokenAmount = 0n; for (const key of allKeys) { const utxos = await this.rostrumService.getTokenUtxos(key.address, token); for (const utxo of utxos) { if (BigInt(utxo.token_amount) < 0n) { continue; } txBuilder.from({ outpoint: utxo.outpoint_hash, address: key.address, satoshis: utxo.value, groupId: utxo.group, groupAmount: BigInt(utxo.token_amount), }); inTokenAmount = inTokenAmount + BigInt(utxo.token_amount); if (!usedKeys.has(key.address)) { const hdkey = this.keyManager.getKey(key.keyPath); usedKeys.set(key.address, hdkey.privateKey); } if (inTokenAmount > MAX_INT64) { throw new Error("Token inputs exceeded max amount. Consider sending in small chunks"); } if (txBuilder.transaction.inputs.length > this.MAX_INPUTS_OUTPUTS) { throw new Error("Too many inputs. Consider consolidating transactions or reduce the send amount."); } if (inTokenAmount == outTokenAmount) { return usedKeys; } if (inTokenAmount > outTokenAmount) { // change txBuilder.to(toChange, Transaction.DUST_AMOUNT, token, inTokenAmount - outTokenAmount); return usedKeys; } } } throw new Error("Not enough token balance"); } private async finalizeTransaction(tx: TransactionBuilder, privKeys: PrivateKey[]): Promise { const tip = await this.rostrumService.getBlockTip(); return tx.lockUntilBlockHeight(tip.height).sign(privKeys).build(); } public printTransactionJson(tx?: Transaction | null): string { if (!tx) { return ""; } const obj = { ...tx.toObject(), hex: tx.toString(), } return JSON.stringify(obj, null, 2) } }