From 818d3f048f7fc0aa41d558ecaf9db70eae34d7da Mon Sep 17 00:00:00 2001 From: Benoit Lavenier <benoit.lavenier@e-is.pro> Date: Fri, 2 Jun 2023 17:31:41 +0200 Subject: [PATCH] fix(1442): Force insert for TXs, if no cautious mode (do not check is tx exists). - If cautious mode, insert TX using batch (delete then insert) - Migrate existing txs.db (add and fill missing columns) --- app/lib/dal/drivers/SQLiteDriver.ts | 8 +- app/lib/dal/fileDAL.ts | 180 ++++++++++-------- app/lib/dal/indexDAL/abstract/TxsDAO.ts | 10 +- .../dal/indexDAL/sqlite/SqliteTransactions.ts | 33 ++-- app/lib/dal/sqliteDAL/MetaDAL.ts | 8 +- app/lib/db/DBTx.ts | 17 +- app/lib/dto/TransactionDTO.ts | 88 ++++----- 7 files changed, 188 insertions(+), 156 deletions(-) diff --git a/app/lib/dal/drivers/SQLiteDriver.ts b/app/lib/dal/drivers/SQLiteDriver.ts index 2fe0faf78..d034f0a03 100644 --- a/app/lib/dal/drivers/SQLiteDriver.ts +++ b/app/lib/dal/drivers/SQLiteDriver.ts @@ -95,12 +95,12 @@ export class SQLiteDriver { } async destroyDatabase(): Promise<void> { - this.logger.debug("Removing SQLite database..."); + this.logger.debug("Removing SQLite database \"%s\"...", this.path); await this.closeConnection(); if (this.path !== MEMORY_PATH) { await RealFS().fsUnlink(this.path); } - this.logger.debug("Database removed"); + this.logger.debug("Database \"%s\" removed", this.path); } get closed() { @@ -116,9 +116,9 @@ export class SQLiteDriver { db.open; // For an unknown reason, we need this line. } await new Promise((resolve, reject) => { - this.logger.debug("Trying to close SQLite..."); + this.logger.debug("Closing SQLite database \"%s\"...", this.path); db.on("close", () => { - this.logger.info("Database closed."); + this.logger.info("Database \"%s\" closed.", this.path); this.dbPromise = null; resolve(); }); diff --git a/app/lib/dal/fileDAL.ts b/app/lib/dal/fileDAL.ts index 53ca48384..100d1cb51 100644 --- a/app/lib/dal/fileDAL.ts +++ b/app/lib/dal/fileDAL.ts @@ -13,12 +13,12 @@ import * as fs from "fs"; import * as path from "path"; -import { SQLiteDriver } from "./drivers/SQLiteDriver"; -import { ConfDAL } from "./fileDALs/ConfDAL"; -import { ConfDTO } from "../dto/ConfDTO"; -import { BlockDTO } from "../dto/BlockDTO"; -import { DBHead } from "../db/DBHead"; -import { DBIdentity, IdentityDAL } from "./sqliteDAL/IdentityDAL"; +import {SQLiteDriver} from "./drivers/SQLiteDriver"; +import {ConfDAL} from "./fileDALs/ConfDAL"; +import {ConfDTO} from "../dto/ConfDTO"; +import {BlockDTO} from "../dto/BlockDTO"; +import {DBHead} from "../db/DBHead"; +import {DBIdentity, IdentityDAL} from "./sqliteDAL/IdentityDAL"; import { CindexEntry, FullCindexEntry, @@ -31,56 +31,55 @@ import { SimpleUdEntryForWallet, SindexEntry, } from "../indexer"; -import { TransactionDTO } from "../dto/TransactionDTO"; -import { CertDAL, DBCert } from "./sqliteDAL/CertDAL"; -import { DBBlock } from "../db/DBBlock"; -import { DBMembership, MembershipDAL } from "./sqliteDAL/MembershipDAL"; -import { MerkleDTO } from "../dto/MerkleDTO"; -import { CommonConstants } from "../common-libs/constants"; -import { PowDAL } from "./fileDALs/PowDAL"; -import { Initiable } from "./sqliteDAL/Initiable"; -import { MetaDAL } from "./sqliteDAL/MetaDAL"; -import { DataErrors } from "../common-libs/errors"; -import { BasicRevocableIdentity, IdentityDTO } from "../dto/IdentityDTO"; -import { FileSystem } from "../system/directory"; -import { Wot } from "../../../neon/lib"; -import { IIndexDAO } from "./indexDAL/abstract/IIndexDAO"; -import { BIndexDAO } from "./indexDAL/abstract/BIndexDAO"; -import { MIndexDAO } from "./indexDAL/abstract/MIndexDAO"; -import { SIndexDAO } from "./indexDAL/abstract/SIndexDAO"; -import { CIndexDAO } from "./indexDAL/abstract/CIndexDAO"; -import { IdentityForRequirements } from "../../service/BlockchainService"; -import { NewLogger } from "../logger"; -import { BlockchainDAO } from "./indexDAL/abstract/BlockchainDAO"; -import { TxsDAO } from "./indexDAL/abstract/TxsDAO"; -import { WalletDAO } from "./indexDAL/abstract/WalletDAO"; -import { PeerDAO } from "./indexDAL/abstract/PeerDAO"; -import { DBTx } from "../db/DBTx"; -import { DBWallet } from "../db/DBWallet"; -import { Tristamp } from "../common/Tristamp"; -import { CFSCore } from "./fileDALs/CFSCore"; -import { Underscore } from "../common-libs/underscore"; -import { DBPeer } from "../db/DBPeer"; -import { MonitorFlushedIndex } from "../debug/MonitorFlushedIndex"; -import { cliprogram } from "../common-libs/programOptions"; -import { DividendDAO, UDSource } from "./indexDAL/abstract/DividendDAO"; -import { HttpSource, HttpUD } from "../../modules/bma/lib/dtos"; -import { GenericDAO } from "./indexDAL/abstract/GenericDAO"; -import { MonitorExecutionTime } from "../debug/MonitorExecutionTime"; -import { LevelDBDividend } from "./indexDAL/leveldb/LevelDBDividend"; -import { LevelDBBindex } from "./indexDAL/leveldb/LevelDBBindex"; - -import { LevelUp } from "levelup"; -import { LevelDBBlockchain } from "./indexDAL/leveldb/LevelDBBlockchain"; -import { LevelDBSindex } from "./indexDAL/leveldb/LevelDBSindex"; -import { SqliteTransactions } from "./indexDAL/sqlite/SqliteTransactions"; -import { SqlitePeers } from "./indexDAL/sqlite/SqlitePeers"; -import { LevelDBWallet } from "./indexDAL/leveldb/LevelDBWallet"; -import { LevelDBCindex } from "./indexDAL/leveldb/LevelDBCindex"; -import { LevelDBIindex } from "./indexDAL/leveldb/LevelDBIindex"; -import { LevelDBMindex } from "./indexDAL/leveldb/LevelDBMindex"; -import { ConfDAO } from "./indexDAL/abstract/ConfDAO"; -import { ServerDAO } from "./server-dao"; +import {TransactionDTO} from "../dto/TransactionDTO"; +import {CertDAL, DBCert} from "./sqliteDAL/CertDAL"; +import {DBBlock} from "../db/DBBlock"; +import {DBMembership, MembershipDAL} from "./sqliteDAL/MembershipDAL"; +import {MerkleDTO} from "../dto/MerkleDTO"; +import {CommonConstants} from "../common-libs/constants"; +import {PowDAL} from "./fileDALs/PowDAL"; +import {Initiable} from "./sqliteDAL/Initiable"; +import {MetaDAL} from "./sqliteDAL/MetaDAL"; +import {DataErrors} from "../common-libs/errors"; +import {BasicRevocableIdentity, IdentityDTO} from "../dto/IdentityDTO"; +import {FileSystem} from "../system/directory"; +import {Wot} from "../../../neon/lib"; +import {IIndexDAO} from "./indexDAL/abstract/IIndexDAO"; +import {BIndexDAO} from "./indexDAL/abstract/BIndexDAO"; +import {MIndexDAO} from "./indexDAL/abstract/MIndexDAO"; +import {SIndexDAO} from "./indexDAL/abstract/SIndexDAO"; +import {CIndexDAO} from "./indexDAL/abstract/CIndexDAO"; +import {IdentityForRequirements} from "../../service/BlockchainService"; +import {BlockchainDAO} from "./indexDAL/abstract/BlockchainDAO"; +import {TxsDAO} from "./indexDAL/abstract/TxsDAO"; +import {WalletDAO} from "./indexDAL/abstract/WalletDAO"; +import {PeerDAO} from "./indexDAL/abstract/PeerDAO"; +import {DBTx} from "../db/DBTx"; +import {DBWallet} from "../db/DBWallet"; +import {Tristamp} from "../common/Tristamp"; +import {CFSCore} from "./fileDALs/CFSCore"; +import {Underscore} from "../common-libs/underscore"; +import {DBPeer} from "../db/DBPeer"; +import {MonitorFlushedIndex} from "../debug/MonitorFlushedIndex"; +import {cliprogram} from "../common-libs/programOptions"; +import {DividendDAO, UDSource} from "./indexDAL/abstract/DividendDAO"; +import {HttpSource, HttpUD} from "../../modules/bma/lib/dtos"; +import {GenericDAO} from "./indexDAL/abstract/GenericDAO"; +import {MonitorExecutionTime} from "../debug/MonitorExecutionTime"; +import {LevelDBDividend} from "./indexDAL/leveldb/LevelDBDividend"; +import {LevelDBBindex} from "./indexDAL/leveldb/LevelDBBindex"; + +import {LevelUp} from "levelup"; +import {LevelDBBlockchain} from "./indexDAL/leveldb/LevelDBBlockchain"; +import {LevelDBSindex} from "./indexDAL/leveldb/LevelDBSindex"; +import {SqliteTransactions} from "./indexDAL/sqlite/SqliteTransactions"; +import {SqlitePeers} from "./indexDAL/sqlite/SqlitePeers"; +import {LevelDBWallet} from "./indexDAL/leveldb/LevelDBWallet"; +import {LevelDBCindex} from "./indexDAL/leveldb/LevelDBCindex"; +import {LevelDBIindex} from "./indexDAL/leveldb/LevelDBIindex"; +import {LevelDBMindex} from "./indexDAL/leveldb/LevelDBMindex"; +import {ConfDAO} from "./indexDAL/abstract/ConfDAO"; +import {ServerDAO} from "./server-dao"; const readline = require("readline"); const indexer = require("../indexer").Indexer; @@ -830,7 +829,7 @@ export class FileDAL implements ServerDAO { } removeTxByHash(hash: string) { - return this.txsDAL.removeTX(hash); + return this.txsDAL.removeByHash(hash); } getTransactionsPending(versionMin = 0) { @@ -1304,11 +1303,11 @@ export class FileDAL implements ServerDAO { const from = await this.getWrittenIdtyByPubkeyForWotbID(entry.issuer); const to = await this.getWrittenIdtyByPubkeyForWotbID(entry.receiver); if (entry.op == CommonConstants.IDX_CREATE) { - // NewLogger().trace('addLink %s -> %s', from.wotb_id, to.wotb_id) + // logger.trace('addLink %s -> %s', from.wotb_id, to.wotb_id) wotb.addLink(from.wotb_id, to.wotb_id); } else { // Update = removal - NewLogger().trace("removeLink %s -> %s", from.wotb_id, to.wotb_id); + //logger.trace("removeLink %s -> %s", from.wotb_id, to.wotb_id); wotb.removeLink(from.wotb_id, to.wotb_id); } } @@ -1350,28 +1349,57 @@ export class FileDAL implements ServerDAO { return this.writeSideFileOfBlock(block); } + /** + * Map tx DTO into DBtxs + * @param txs + * @param block_number + * @param medianTime + * @private + */ + private async mapToDBTxs( + txs: TransactionDTO[], + block_number: number, + medianTime: number + ): Promise<DBTx[]> { + return Promise.all( + txs.map(async (tx) => { + const sp = tx.blockstamp.split("-", 2); + const basedBlock = (await this.getAbsoluteBlockByNumberAndHash( + parseInt(sp[0]), + sp[1] + )) as DBBlock; + tx.blockstampTime = basedBlock.medianTime; + const txEntity = TransactionDTO.fromJSONObject(tx); + if (!txEntity.hash) txEntity.computeAllHashes(); + const dbTx = DBTx.fromTransactionDTO(txEntity); + dbTx.written = true; + dbTx.block_number = block_number; + dbTx.time = medianTime; + return dbTx; + }) + ); + } + async saveTxsInFiles( txs: TransactionDTO[], block_number: number, medianTime: number ) { - return Promise.all( - txs.map(async (tx) => { - const sp = tx.blockstamp.split("-"); - const basedBlock = (await this.getAbsoluteBlockByNumberAndHash( - parseInt(sp[0]), - sp[1] - )) as DBBlock; - tx.blockstampTime = basedBlock.medianTime; - const txEntity = TransactionDTO.fromJSONObject(tx); - txEntity.computeAllHashes(); - return this.txsDAL.addLinked( - TransactionDTO.fromJSONObject(txEntity), - block_number, - medianTime - ); - }) - ); + if (!txs.length) return []; + const records = await this.mapToDBTxs(txs, block_number, medianTime); + await this.txsDAL.saveBatch(records); + return records; + } + + async insertTxsInFiles( + txs: TransactionDTO[], + block_number: number, + medianTime: number + ): Promise<DBTx[]> { + if (!txs.length) return []; + const dbTxs = await this.mapToDBTxs(txs, block_number, medianTime); + await this.txsDAL.insertBatch(dbTxs); + return dbTxs; } async merkleForPeers() { diff --git a/app/lib/dal/indexDAL/abstract/TxsDAO.ts b/app/lib/dal/indexDAL/abstract/TxsDAO.ts index 7ef9e06de..93854de3e 100644 --- a/app/lib/dal/indexDAL/abstract/TxsDAO.ts +++ b/app/lib/dal/indexDAL/abstract/TxsDAO.ts @@ -10,6 +10,12 @@ export interface TxsDAO extends GenericDAO<DBTx> { getTX(hash: string): Promise<DBTx>; + /** + * Make a batch insert or update. + * @param records The records to insert or update as a batch. + */ + saveBatch(records: DBTx[]): Promise<void>; + addLinked( tx: TransactionDTO, block_number: number, @@ -51,7 +57,9 @@ export interface TxsDAO extends GenericDAO<DBTx> { getPendingWithRecipient(pubkey: string): Promise<DBTx[]>; - removeTX(hash: string): Promise<void>; + removeByHash(hash: string): Promise<void>; + + removeByHashBatch(hashArray: string[]): Promise<void>; removeAll(): Promise<void>; diff --git a/app/lib/dal/indexDAL/sqlite/SqliteTransactions.ts b/app/lib/dal/indexDAL/sqlite/SqliteTransactions.ts index 28f36b9ae..b8846d704 100644 --- a/app/lib/dal/indexDAL/sqlite/SqliteTransactions.ts +++ b/app/lib/dal/indexDAL/sqlite/SqliteTransactions.ts @@ -79,24 +79,23 @@ export class SqliteTransactions extends SqliteTable<DBTx> implements TxsDAO { */ @MonitorExecutionTime() - async insert(record: DBTx): Promise<void> { - this.onBeforeInsert(record); - await this.insertInTable(this.driver, record); + insert(record: DBTx): Promise<void> { + return this.insertInTable(this.driver, record); } @MonitorExecutionTime() async insertBatch(records: DBTx[]): Promise<void> { if (records.length) { - records.forEach(r => this.onBeforeInsert(r)); return this.insertBatchInTable(this.driver, records); } } - onBeforeInsert(dbTx: DBTx) { - // Compute unique issuer/recipient (need to improve tx history) - dbTx.issuer = (dbTx.issuers.length === 1) ? dbTx.issuers[0] : null; - const recipients = !dbTx.issuer ? dbTx.recipients : dbTx.recipients.filter(r => r !== dbTx.issuer); - dbTx.recipient = (recipients.length === 1) ? recipients[0] : null; + @MonitorExecutionTime() + async saveBatch(records: DBTx[]): Promise<void> { + if (records.length) { + await this.removeByHashBatch(records.map(t => t.hash)); + await this.insertBatch(records); + } } sandbox: SandBox<{ @@ -184,8 +183,8 @@ export class SqliteTransactions extends SqliteTable<DBTx> implements TxsDAO { to: number ): Promise<{ sent: DBTx[]; received: DBTx[] }> { return { - sent: await this.getLinkedWithIssuerByRange('blockstampTime', pubkey, from, to), - received: await this.getLinkedWithRecipientByRange('blockstampTime', pubkey, from, to) + sent: await this.getLinkedWithIssuerByRange('time', pubkey, from, to), + received: await this.getLinkedWithRecipientByRange('time', pubkey, from, to) }; } @@ -294,7 +293,17 @@ export class SqliteTransactions extends SqliteTable<DBTx> implements TxsDAO { ); } - removeTX(hash: string): Promise<void> { + async removeByHashBatch(hashArray: string[]): Promise<void> { + let i = 0; + // Delete by slice of 500 items (because SQLite IN operator is limited) + while (i < hashArray.length - 1) { + const slice = hashArray.slice(i, i + 500); + await this.driver.sqlWrite(`DELETE FROM txs WHERE hash IN (${slice.map(_ => '?')})`, slice); + i += 500; + } + } + + removeByHash(hash: string): Promise<void> { return this.driver.sqlWrite("DELETE FROM txs WHERE hash = ?", [hash]); } diff --git a/app/lib/dal/sqliteDAL/MetaDAL.ts b/app/lib/dal/sqliteDAL/MetaDAL.ts index b74e6dd82..4f4672c8d 100644 --- a/app/lib/dal/sqliteDAL/MetaDAL.ts +++ b/app/lib/dal/sqliteDAL/MetaDAL.ts @@ -192,14 +192,15 @@ export class MetaDAL extends AbstractSQLite<DBMeta> { // Wrong transaction storage 25: async () => {}, - // Add columns 'issuer' and 'recipient' in transaction table - see issue #1442 + // Drop old table 'txs' (replaced by a file 'txs.db') 26: async() => { - // Drop old table 'txs' (replaced by a file 'txs.db') await this.exec("BEGIN;" + "DROP TABLE IF EXISTS txs;" + "COMMIT;") + }, - // Migrate txs.db + // Add columns 'issuer' and 'recipient' in transaction table - see issue #1442 + 27: async() => { const txsDriver = await this.getSqliteDB("txs.db"); const txsDAL = new MetaDAL(txsDriver, this.getSqliteDB); @@ -214,6 +215,7 @@ export class MetaDAL extends AbstractSQLite<DBMeta> { "DROP INDEX IF EXISTS idx_txs_received;" + "DROP INDEX IF EXISTS idx_txs_output_base;" + "DROP INDEX IF EXISTS idx_txs_output_amount;" + + "CREATE INDEX IF NOT EXISTS idx_txs_issuers ON txs (issuers);" + "CREATE INDEX IF NOT EXISTS idx_txs_recipients ON txs (recipients);" + "COMMIT;" ); diff --git a/app/lib/db/DBTx.ts b/app/lib/db/DBTx.ts index 4b74b8457..bcb1954de 100644 --- a/app/lib/db/DBTx.ts +++ b/app/lib/db/DBTx.ts @@ -46,18 +46,13 @@ export class DBTx { dbTx.removed = false; dbTx.output_base = tx.output_base; dbTx.output_amount = tx.output_amount; - return dbTx; - } - static setRecipients(txs: DBTx[]) { - // Each transaction must have a good "recipients" field for future searchs - txs.forEach((tx) => (tx.recipients = DBTx.outputs2recipients(tx))); - } + // Computed columns (unique issuer and/or recipient) + dbTx.issuer = (dbTx.issuers.length === 1) ? dbTx.issuers[0] : null; + const recipients = !dbTx.issuer ? dbTx.recipients : dbTx.recipients.filter(r => r !== dbTx.issuer); + dbTx.recipient = (recipients.length === 1) ? recipients[0] : null; - static outputs2recipients(tx: DBTx) { - return tx.outputs.map(function (out) { - const recipent = out.match("SIG\\((.*)\\)"); - return (recipent && recipent[1]) || "UNKNOWN"; - }); + return dbTx; } + } diff --git a/app/lib/dto/TransactionDTO.ts b/app/lib/dto/TransactionDTO.ts index 71dfa660f..618488046 100644 --- a/app/lib/dto/TransactionDTO.ts +++ b/app/lib/dto/TransactionDTO.ts @@ -14,6 +14,7 @@ import { hashf } from "../common"; import { Cloneable } from "./Cloneable"; import { verify } from "../../../neon/lib"; +import {CommonConstants} from "../common-libs/constants"; export interface BaseDTO { base: number; @@ -74,8 +75,8 @@ export class TransactionDTO implements Cloneable { public currency: string, public locktime: number, public hash: string, - public blockstamp: string, - public blockstampTime: number, + public blockstamp: string, // Reference block of the TX + public blockstampTime: number, // Median time of the reference block public issuers: string[], public inputs: string[], public outputs: string[], @@ -95,14 +96,14 @@ export class TransactionDTO implements Cloneable { get output_amount() { return this.outputs.reduce( - (maxBase, output) => Math.max(maxBase, parseInt(output.split(":")[0])), + (sum, output) => sum + parseInt(output.split(":")[0]), 0 ); } get output_base() { return this.outputs.reduce( - (sum, output) => sum + parseInt(output.split(":")[1]), + (maxBase, output) => Math.max(maxBase, parseInt(output.split(":")[1])), 0 ); } @@ -126,8 +127,11 @@ export class TransactionDTO implements Cloneable { } getHash() { - const raw = TransactionDTO.toRAW(this); - return hashf(raw); + if (!this.hash) { + const raw = TransactionDTO.toRAW(this); + this.hash = hashf(raw).toUpperCase(); + } + return this.hash; } getRawTxNoSig() { @@ -161,40 +165,26 @@ export class TransactionDTO implements Cloneable { } outputsAsRecipients(): string[] { - return this.outputs.map((out) => { - const recipent = out.match("SIG\\((.*)\\)"); - return (recipent && recipent[1]) || "UNKNOWN"; - }); + return this.outputs.reduce((res, output) => { + let match: any; + const recipients: string[] = []; + while (output && (match = CommonConstants.TRANSACTION.OUTPUT_CONDITION_SIG_PUBKEY.exec(output)) !== null) { + const pub = match[1] as string; + if (!res.includes(pub) && !recipients.includes(pub)) { + recipients.push(pub) + } + output = output.substring(match.index + match[0].length); + } + if (recipients.length) { + return res.concat(recipients); + } + if (res.includes("UNKNOWN")) return res; + return res.concat("UNKNOWN"); + }, <string[]>[]); } getRaw() { - let raw = ""; - raw += "Version: " + this.version + "\n"; - raw += "Type: Transaction\n"; - raw += "Currency: " + this.currency + "\n"; - raw += "Blockstamp: " + this.blockstamp + "\n"; - raw += "Locktime: " + this.locktime + "\n"; - raw += "Issuers:\n"; - (this.issuers || []).forEach((issuer) => { - raw += issuer + "\n"; - }); - raw += "Inputs:\n"; - this.inputs.forEach((input) => { - raw += input + "\n"; - }); - raw += "Unlocks:\n"; - this.unlocks.forEach((unlock) => { - raw += unlock + "\n"; - }); - raw += "Outputs:\n"; - this.outputs.forEach((output) => { - raw += output + "\n"; - }); - raw += "Comment: " + (this.comment || "") + "\n"; - this.signatures.forEach((signature) => { - raw += signature + "\n"; - }); - return raw; + return TransactionDTO.toRAW(this); } getCompactVersion() { @@ -231,7 +221,7 @@ export class TransactionDTO implements Cloneable { } computeAllHashes() { - this.hash = hashf(this.getRaw()).toUpperCase(); + this.hash = this.getHash(); } json() { @@ -293,32 +283,32 @@ export class TransactionDTO implements Cloneable { ); } - static toRAW(json: TransactionDTO, noSig = false) { + static toRAW(tx: TransactionDTO, noSig = false) { let raw = ""; - raw += "Version: " + json.version + "\n"; + raw += "Version: " + tx.version + "\n"; raw += "Type: Transaction\n"; - raw += "Currency: " + json.currency + "\n"; - raw += "Blockstamp: " + json.blockstamp + "\n"; - raw += "Locktime: " + json.locktime + "\n"; + raw += "Currency: " + tx.currency + "\n"; + raw += "Blockstamp: " + tx.blockstamp + "\n"; + raw += "Locktime: " + tx.locktime + "\n"; raw += "Issuers:\n"; - (json.issuers || []).forEach((issuer) => { + (tx.issuers || []).forEach((issuer) => { raw += issuer + "\n"; }); raw += "Inputs:\n"; - (json.inputs || []).forEach((input) => { + (tx.inputs || []).forEach((input) => { raw += input + "\n"; }); raw += "Unlocks:\n"; - (json.unlocks || []).forEach((unlock) => { + (tx.unlocks || []).forEach((unlock) => { raw += unlock + "\n"; }); raw += "Outputs:\n"; - (json.outputs || []).forEach((output) => { + (tx.outputs || []).forEach((output) => { raw += output + "\n"; }); - raw += "Comment: " + (json.comment || "") + "\n"; + raw += "Comment: " + (tx.comment || "") + "\n"; if (!noSig) { - (json.signatures || []).forEach((signature) => { + (tx.signatures || []).forEach((signature) => { raw += signature + "\n"; }); } -- GitLab