diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala index 45068ec1e1..d6e87993dc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala @@ -78,6 +78,25 @@ trait OnChainChannelFunder { /** Rollback a transaction that we failed to commit: this probably translates to "release locks on utxos". */ def rollback(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] + /** + * Mark a transaction as abandoned, which will allow for its wallet inputs to be re-spent. + * + * If the transaction has been permanently double-spent by a direct conflict, there is no need to call this function, + * it will automatically be detected and the wallet inputs will be re-spent. + * + * This should only be used when the transaction has become invalid because one of its ancestors has been permanently + * double-spent. Since the wallet doesn't keep track of unconfirmed ancestors status, it cannot know that the + * transaction has become permanently invalid and will never be publishable again. + * + * This function must never be called on a transaction that isn't permanently invalidated, otherwise it would create + * a risk of accidentally double-spending ourselves: + * - the transaction is abandoned + * - its inputs are re-spent in another transaction + * - but the initial transaction confirms because it had already reached the mempool of other nodes, unexpectedly + * double-spending the second transaction + */ + def abandon(txId: TxId)(implicit ec: ExecutionContext): Future[Boolean] + /** * Tests whether the inputs of the provided transaction have been spent by another transaction. * Implementations may always return false if they don't want to implement it. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala index 71134d9abd..752a3c69e3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala @@ -483,12 +483,7 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onChainKeyManag getRawTransaction(tx.txid).map(_ => tx.txid).recoverWith { case _ => Future.failed(e) } } - /** - * Mark a transaction as abandoned, which will allow for its wallet inputs to be re-spent. - * This method can be used to replace "stuck" or evicted transactions. - * It only works on transactions which are not included in a block and are not currently in the mempool. - */ - def abandonTransaction(txId: TxId)(implicit ec: ExecutionContext): Future[Boolean] = { + override def abandon(txId: TxId)(implicit ec: ExecutionContext): Future[Boolean] = { rpcClient.invoke("abandontransaction", txId).map(_ => true).recover(_ => false) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index ffab4a6a2b..e1f2142e40 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -1622,6 +1622,23 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with d.commitments.resolveCommitment(tx) match { case Some(commitment) => log.warning("a commit tx for fundingTxIndex={} fundingTxId={} has been confirmed", commitment.fundingTxIndex, commitment.fundingTxId) + // Funding transactions with a greater index will never confirm: we abandon them to unlock their wallet inputs, + // which would otherwise stay locked forever in our bitcoind wallet. + d.commitments.all + .collect { case c: Commitment if commitment.fundingTxIndex <= c.fundingTxIndex => c.fundingTxId } + .foreach { txId => + log.warning("abandoning splice txId={} (alternative commitment was confirmed)", txId) + wallet.abandon(txId) + } + // Any anchor transaction that we created based on the latest local or remote commit will never confirm either + // so we need to abandon them to unlock their wallet inputs. + nodeParams.db.audit.listPublished(d.channelId).collect { + case tx if tx.desc == "local-anchor" => tx + case tx if tx.desc == "remote-anchor" => tx + }.foreach { tx => + log.warning("abandoning {} txId={} (alternative commitment was confirmed)", tx.desc, tx.txId) + wallet.abandon(tx.txId) + } val commitments1 = d.commitments.copy( active = commitment +: Nil, inactive = Nil @@ -1709,6 +1726,31 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with if (d1.localCommitPublished.exists(_.commitTx.txid == tx.txid)) { context.system.eventStream.publish(LocalCommitConfirmed(self, remoteNodeId, d.channelId, blockHeight + d.commitments.params.remoteParams.toSelfDelay.toInt)) } + // if the local or remote commitment tx just got confirmed, we abandon anchor transactions that were created based + // on the other commitment: they will never confirm so we must free their wallet inputs. + if (d1.localCommitPublished.exists(_.commitTx.txid == tx.txid)) { + nodeParams.db.audit.listPublished(d.channelId).collect { + case tx if tx.desc == "remote-anchor" => + log.warning("abandoning remote-anchor txId={} (local commit was confirmed)", tx.txId) + wallet.abandon(tx.txId) + } + } + if (d1.remoteCommitPublished.exists(_.commitTx.txid == tx.txid) || d1.nextRemoteCommitPublished.exists(_.commitTx.txid == tx.txid)) { + nodeParams.db.audit.listPublished(d.channelId).collect { + case tx if tx.desc == "local-anchor" => + log.warning("abandoning local-anchor txId={} (remote commit was confirmed)", tx.txId) + wallet.abandon(tx.txId) + } + } + if (d1.futureRemoteCommitPublished.exists(_.commitTx.txid == tx.txid) || d1.revokedCommitPublished.exists(_.commitTx.txid == tx.txid)) { + nodeParams.db.audit.listPublished(d.channelId).collect { + case tx if tx.desc == "local-anchor" => tx + case tx if tx.desc == "remote-anchor" => tx + }.foreach { tx => + log.warning("abandoning {} txId={} (future or revoked commitment was confirmed)", tx.desc, tx.txId) + wallet.abandon(tx.txId) + } + } // we may need to fail some htlcs in case a commitment tx was published and they have reached the timeout threshold val timedOutHtlcs = Closing.isClosingTypeAlreadyKnown(d1) match { case Some(c: Closing.LocalClose) => Closing.trimmedOrTimedOutHtlcs(d.commitments.params.commitmentFormat, c.localCommit, c.localCommitPublished, d.commitments.params.localParams.dustLimit, tx) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 341c43e622..7d91859720 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -327,17 +327,14 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, // Clean up the failed transaction attempt. Once that's done, go back to the waiting state with the new transaction. def cleanUpFailedTxAndWait(failedTx: Transaction, mempoolTx: FundedTx): Behavior[Command] = { - context.pipeToSelf(bitcoinClient.abandonTransaction(failedTx.txid))(_ => UnlockUtxos) + val toUnlock = failedTx.txIn.map(_.outPoint).toSet -- mempoolTx.signedTx.txIn.map(_.outPoint).toSet + if (toUnlock.isEmpty) { + context.self ! UtxosUnlocked + } else { + log.debug("unlocking utxos={}", toUnlock.mkString(", ")) + context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock.toSeq))(_ => UtxosUnlocked) + } Behaviors.receiveMessagePartial { - case UnlockUtxos => - val toUnlock = failedTx.txIn.map(_.outPoint).toSet -- mempoolTx.signedTx.txIn.map(_.outPoint).toSet - if (toUnlock.isEmpty) { - context.self ! UtxosUnlocked - } else { - log.debug("unlocking utxos={}", toUnlock.mkString(", ")) - context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock.toSeq))(_ => UtxosUnlocked) - } - Behaviors.same case UtxosUnlocked => // Now that we've cleaned up the failed transaction, we can go back to waiting for the current mempool transaction // or bump it if it doesn't confirm fast enough either. @@ -367,22 +364,21 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } def unlockAndStop(input: OutPoint, txs: Seq[Transaction]): Behavior[Command] = { - // The bitcoind wallet will keep transactions around even when they can't be published (e.g. one of their inputs has - // disappeared but bitcoind thinks it may reappear later), hoping that it will be able to automatically republish - // them later. In our case this is unnecessary, we will publish ourselves, and we don't want to pollute the wallet - // state with transactions that will never be valid, so we eagerly abandon every time. - // If the transaction is in the mempool or confirmed, it will be a no-op. - context.pipeToSelf(Future.traverse(txs)(tx => bitcoinClient.abandonTransaction(tx.txid)))(_ => UnlockUtxos) + // Note that we unlock utxos but we don't abandon failed transactions: + // - if they were successfully published: + // - the utxos have automatically been unlocked, so the call to unlock is a (safe) no-op + // - there is still a risk that transactions may confirm later, so it's unsafe to abandon them + // - if they failed to be published: + // - we must unlock the utxos, otherwise they would stay locked forever + // - abandoning the transaction would be a no-op, as it was never added to our wallet + val toUnlock = txs.flatMap(_.txIn).filterNot(_.outPoint == input).map(_.outPoint).toSet + if (toUnlock.isEmpty) { + context.self ! UtxosUnlocked + } else { + log.debug("unlocking utxos={}", toUnlock.mkString(", ")) + context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock.toSeq))(_ => UtxosUnlocked) + } Behaviors.receiveMessagePartial { - case UnlockUtxos => - val toUnlock = txs.flatMap(_.txIn).filterNot(_.outPoint == input).map(_.outPoint).toSet - if (toUnlock.isEmpty) { - context.self ! UtxosUnlocked - } else { - log.debug("unlocking utxos={}", toUnlock.mkString(", ")) - context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock.toSeq))(_ => UtxosUnlocked) - } - Behaviors.same case UtxosUnlocked => log.debug("utxos unlocked") Behaviors.stopped diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/AuditDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/AuditDb.scala index 261ef1f9e7..f9cf8cf5ff 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/AuditDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/AuditDb.scala @@ -17,12 +17,12 @@ package fr.acinq.eclair.db import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} -import fr.acinq.eclair.{Paginated, TimestampMilli} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, TxId} import fr.acinq.eclair.channel._ -import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats} +import fr.acinq.eclair.db.AuditDb.{NetworkFee, PublishedTransaction, Stats} import fr.acinq.eclair.db.DbEventHandler.ChannelEvent import fr.acinq.eclair.payment.{PathFindingExperimentMetrics, PaymentReceived, PaymentRelayed, PaymentSent} +import fr.acinq.eclair.{Paginated, TimestampMilli} trait AuditDb { @@ -44,6 +44,8 @@ trait AuditDb { def addPathFindingExperimentMetrics(metrics: PathFindingExperimentMetrics): Unit + def listPublished(channelId: ByteVector32): Seq[PublishedTransaction] + def listSent(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated] = None): Seq[PaymentSent] def listReceived(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated] = None): Seq[PaymentReceived] @@ -58,6 +60,8 @@ trait AuditDb { object AuditDb { + case class PublishedTransaction(txId: TxId, desc: String, miningFee: Satoshi) + case class NetworkFee(remoteNodeId: PublicKey, channelId: ByteVector32, txId: ByteVector32, fee: Satoshi, txType: String, timestamp: TimestampMilli) case class Stats(channelId: ByteVector32, direction: String, avgPaymentAmount: Satoshi, paymentCount: Int, relayFee: Satoshi, networkFee: Satoshi) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala index 66370177c4..17078e0d91 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala @@ -4,6 +4,7 @@ import com.google.common.util.concurrent.ThreadFactoryBuilder import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, TxId} import fr.acinq.eclair.channel._ +import fr.acinq.eclair.db.AuditDb.PublishedTransaction import fr.acinq.eclair.db.Databases.{FileBackup, PostgresDatabases, SqliteDatabases} import fr.acinq.eclair.db.DbEventHandler.ChannelEvent import fr.acinq.eclair.db.DualDatabases.runAsync @@ -175,6 +176,11 @@ case class DualAuditDb(primary: AuditDb, secondary: AuditDb) extends AuditDb { primary.addPathFindingExperimentMetrics(metrics) } + override def listPublished(channelId: ByteVector32): Seq[PublishedTransaction] = { + runAsync(secondary.listPublished(channelId)) + primary.listPublished(channelId) + } + override def listSent(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated]): Seq[PaymentSent] = { runAsync(secondary.listSent(from, to, paginated_opt)) primary.listSent(from, to, paginated_opt) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgAuditDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgAuditDb.scala index 6547012952..0b62cb7af3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgAuditDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgAuditDb.scala @@ -17,9 +17,9 @@ package fr.acinq.eclair.db.pg import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, TxId} import fr.acinq.eclair.channel._ -import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats} +import fr.acinq.eclair.db.AuditDb.{NetworkFee, PublishedTransaction, Stats} import fr.acinq.eclair.db.DbEventHandler.ChannelEvent import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics import fr.acinq.eclair.db.Monitoring.Tags.DbBackends @@ -36,7 +36,7 @@ import javax.sql.DataSource object PgAuditDb { val DB_NAME = "audit" - val CURRENT_VERSION = 11 + val CURRENT_VERSION = 12 } class PgAuditDb(implicit ds: DataSource) extends AuditDb with Logging { @@ -110,6 +110,10 @@ class PgAuditDb(implicit ds: DataSource) extends AuditDb with Logging { statement.executeUpdate("CREATE INDEX metrics_recipient_idx ON audit.path_finding_metrics(recipient_node_id)") } + def migration1112(statement: Statement): Unit = { + statement.executeUpdate("CREATE INDEX transactions_published_channel_id_idx ON audit.transactions_published(channel_id)") + } + getVersion(statement, DB_NAME) match { case None => statement.executeUpdate("CREATE SCHEMA audit") @@ -142,9 +146,10 @@ class PgAuditDb(implicit ds: DataSource) extends AuditDb with Logging { statement.executeUpdate("CREATE INDEX metrics_name_idx ON audit.path_finding_metrics(experiment_name)") statement.executeUpdate("CREATE INDEX metrics_recipient_idx ON audit.path_finding_metrics(recipient_node_id)") statement.executeUpdate("CREATE INDEX metrics_hash_idx ON audit.path_finding_metrics(payment_hash)") + statement.executeUpdate("CREATE INDEX transactions_published_channel_id_idx ON audit.transactions_published(channel_id)") statement.executeUpdate("CREATE INDEX transactions_published_timestamp_idx ON audit.transactions_published(timestamp)") statement.executeUpdate("CREATE INDEX transactions_confirmed_timestamp_idx ON audit.transactions_confirmed(timestamp)") - case Some(v@(4 | 5 | 6 | 7 | 8 | 9 | 10)) => + case Some(v@(4 | 5 | 6 | 7 | 8 | 9 | 10 | 11)) => logger.warn(s"migrating db $DB_NAME, found version=$v current=$CURRENT_VERSION") if (v < 5) { migration45(statement) @@ -167,6 +172,9 @@ class PgAuditDb(implicit ds: DataSource) extends AuditDb with Logging { if (v < 11) { migration1011(statement) } + if (v < 12) { + migration1112(statement) + } case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") } @@ -334,6 +342,17 @@ class PgAuditDb(implicit ds: DataSource) extends AuditDb with Logging { } } + override def listPublished(channelId: ByteVector32): Seq[PublishedTransaction] = withMetrics("audit/list-published", DbBackends.Postgres) { + inTransaction { pg => + using(pg.prepareStatement("SELECT * FROM audit.transactions_published WHERE channel_id = ?")) { statement => + statement.setString(1, channelId.toHex) + statement.executeQuery().map { rs => + PublishedTransaction(TxId.fromValidHex(rs.getString("tx_id")), rs.getString("tx_type"), rs.getLong("mining_fee_sat").sat) + }.toSeq + } + } + } + override def listSent(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated] = None): Seq[PaymentSent] = inTransaction { pg => using(pg.prepareStatement("SELECT * FROM audit.sent WHERE timestamp BETWEEN ? AND ?")) { statement => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteAuditDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteAuditDb.scala index f0decc63de..81c0da3e77 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteAuditDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteAuditDb.scala @@ -17,9 +17,9 @@ package fr.acinq.eclair.db.sqlite import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, TxId} import fr.acinq.eclair.channel._ -import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats} +import fr.acinq.eclair.db.AuditDb.{NetworkFee, PublishedTransaction, Stats} import fr.acinq.eclair.db.DbEventHandler.ChannelEvent import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics import fr.acinq.eclair.db.Monitoring.Tags.DbBackends @@ -34,7 +34,7 @@ import java.util.UUID object SqliteAuditDb { val DB_NAME = "audit" - val CURRENT_VERSION = 8 + val CURRENT_VERSION = 9 } class SqliteAuditDb(val sqlite: Connection) extends AuditDb with Logging { @@ -110,6 +110,10 @@ class SqliteAuditDb(val sqlite: Connection) extends AuditDb with Logging { statement.executeUpdate("DROP TABLE network_fees") } + def migration89(statement: Statement): Unit = { + statement.executeUpdate("CREATE INDEX transactions_published_channel_id_idx ON transactions_published(channel_id)") + } + getVersion(statement, DB_NAME) match { case None => statement.executeUpdate("CREATE TABLE sent (amount_msat INTEGER NOT NULL, fees_msat INTEGER NOT NULL, recipient_amount_msat INTEGER NOT NULL, payment_id TEXT NOT NULL, parent_payment_id TEXT NOT NULL, payment_hash BLOB NOT NULL, payment_preimage BLOB NOT NULL, recipient_node_id BLOB NOT NULL, to_channel_id BLOB NOT NULL, timestamp INTEGER NOT NULL)") @@ -138,9 +142,10 @@ class SqliteAuditDb(val sqlite: Connection) extends AuditDb with Logging { statement.executeUpdate("CREATE INDEX metrics_timestamp_idx ON path_finding_metrics(timestamp)") statement.executeUpdate("CREATE INDEX metrics_mpp_idx ON path_finding_metrics(is_mpp)") statement.executeUpdate("CREATE INDEX metrics_name_idx ON path_finding_metrics(experiment_name)") + statement.executeUpdate("CREATE INDEX transactions_published_channel_id_idx ON transactions_published(channel_id)") statement.executeUpdate("CREATE INDEX transactions_published_timestamp_idx ON transactions_published(timestamp)") statement.executeUpdate("CREATE INDEX transactions_confirmed_timestamp_idx ON transactions_confirmed(timestamp)") - case Some(v@(1 | 2 | 3 | 4 | 5 | 6 | 7)) => + case Some(v@(1 | 2 | 3 | 4 | 5 | 6 | 7 | 8)) => logger.warn(s"migrating db $DB_NAME, found version=$v current=$CURRENT_VERSION") if (v < 2) { migration12(statement) @@ -163,6 +168,9 @@ class SqliteAuditDb(val sqlite: Connection) extends AuditDb with Logging { if (v < 8) { migration78(statement) } + if (v < 9) { + migration89(statement) + } case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") } @@ -310,6 +318,15 @@ class SqliteAuditDb(val sqlite: Connection) extends AuditDb with Logging { } } + override def listPublished(channelId: ByteVector32): Seq[PublishedTransaction] = withMetrics("audit/list-published", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT * FROM transactions_published WHERE channel_id = ?")) { statement => + statement.setBytes(1, channelId.toArray) + statement.executeQuery().map { rs => + PublishedTransaction(TxId(rs.getByteVector32("tx_id")), rs.getString("tx_type"), rs.getLong("mining_fee_sat").sat) + }.toSeq + } + } + override def listSent(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated] = None): Seq[PaymentSent] = using(sqlite.prepareStatement("SELECT * FROM sent WHERE timestamp >= ? AND timestamp < ?")) { statement => statement.setLong(1, from.toLong) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala index d96844535e..88ed41d36f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala @@ -42,6 +42,7 @@ class DummyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { val funded = collection.concurrent.TrieMap.empty[TxId, Transaction] val published = collection.concurrent.TrieMap.empty[TxId, Transaction] var rolledback = Set.empty[Transaction] + var abandoned = Set.empty[TxId] override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(1105 sat, 561 sat)) @@ -78,6 +79,11 @@ class DummyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { Future.successful(true) } + override def abandon(txId: TxId)(implicit ec: ExecutionContext): Future[Boolean] = { + abandoned = abandoned + txId + Future.successful(true) + } + override def doubleSpent(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(false) override def getP2wpkhPubkey(renew: Boolean): PublicKey = dummyReceivePubkey @@ -89,6 +95,7 @@ class NoOpOnChainWallet extends OnChainWallet with OnchainPubkeyCache { var rolledback = Seq.empty[Transaction] var doubleSpent = Set.empty[TxId] + var abandoned = Set.empty[TxId] override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(1105 sat, 561 sat)) @@ -115,6 +122,11 @@ class NoOpOnChainWallet extends OnChainWallet with OnchainPubkeyCache { Future.successful(true) } + override def abandon(txId: TxId)(implicit ec: ExecutionContext): Future[Boolean] = { + abandoned = abandoned + txId + Future.successful(true) + } + override def doubleSpent(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(doubleSpent.contains(tx.txid)) override def getP2wpkhPubkey(renew: Boolean): PublicKey = dummyReceivePubkey @@ -127,6 +139,7 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { var inputs = Seq.empty[Transaction] var rolledback = Seq.empty[Transaction] var doubleSpent = Set.empty[TxId] + var abandoned = Set.empty[TxId] override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(1105 sat, 561 sat)) @@ -218,6 +231,11 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { Future.successful(true) } + override def abandon(txId: TxId)(implicit ec: ExecutionContext): Future[Boolean] = { + abandoned = abandoned + txId + Future.successful(true) + } + override def doubleSpent(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(doubleSpent.contains(tx.txid)) override def getP2wpkhPubkey(renew: Boolean): PublicKey = pubkey diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala index 2fd27b9703..0ee44b9bd2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala @@ -900,19 +900,19 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A sender.expectMsg(signedTx2.txid) // Abandon the first wallet transaction. - bitcoinClient.abandonTransaction(signedTx1.txid).pipeTo(sender.ref) + bitcoinClient.abandon(signedTx1.txid).pipeTo(sender.ref) sender.expectMsg(true) // Abandoning an already-abandoned transaction is a no-op. - bitcoinClient.abandonTransaction(signedTx1.txid).pipeTo(sender.ref) + bitcoinClient.abandon(signedTx1.txid).pipeTo(sender.ref) sender.expectMsg(true) // We can't abandon the second transaction (it's in the mempool). - bitcoinClient.abandonTransaction(signedTx2.txid).pipeTo(sender.ref) + bitcoinClient.abandon(signedTx2.txid).pipeTo(sender.ref) sender.expectMsg(false) // We can't abandon a confirmed transaction. - bitcoinClient.abandonTransaction(signedTx2.txIn.head.outPoint.txid).pipeTo(sender.ref) + bitcoinClient.abandon(signedTx2.txIn.head.outPoint.txid).pipeTo(sender.ref) sender.expectMsg(false) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala index 7a8ad38263..11aae21ead 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala @@ -23,6 +23,7 @@ import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.NumericSatoshi.abs import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction, TxIn} import fr.acinq.eclair._ +import fr.acinq.eclair.blockchain.SingleKeyOnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.Helpers.Closing.{LocalClose, RemoteClose, RevokedClose} @@ -1784,6 +1785,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val claimMain = alice2blockchain.expectMsgType[PublishFinalTx].tx val claimHtlcsTxsOut = htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "claim-htlc-timeout")) claimHtlcsTxsOut.foreach(tx => Transaction.correctlySpends(tx, Seq(bobCommitTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + awaitCond(wallet.asInstanceOf[SingleKeyOnChainWallet].abandoned.contains(fundingTx2.txid)) val watchConfirmedRemoteCommit = alice2blockchain.expectWatchTxConfirmed(bobCommitTx1.txid) // this one fires immediately, tx is already confirmed @@ -1905,6 +1907,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2blockchain.expectWatchTxConfirmed(aliceClaimMain.txid) assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == bobRevokedCommitTx.txid) // main-penalty aliceHtlcsPenalty.map(_ => assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == bobRevokedCommitTx.txid)) + awaitCond(wallet.asInstanceOf[SingleKeyOnChainWallet].abandoned.contains(fundingTx2.txid)) alice2blockchain.expectNoMessage(100 millis) // all penalty txs confirm diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/AuditDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/AuditDbSpec.scala index 1948d4f2e0..a66d344b1e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/AuditDbSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/AuditDbSpec.scala @@ -169,6 +169,10 @@ class AuditDbSpec extends AnyFunSuite { db.add(TransactionPublished(c4, n4, Transaction(0, Seq.empty, Seq(TxOut(4500 sat, hex"1111222233")), 0), 500 sat, "funding")) // unconfirmed db.add(TransactionConfirmed(c4, n4, Transaction(0, Seq.empty, Seq(TxOut(2500 sat, hex"ffffff")), 0))) // doesn't match a published tx + assert(db.listPublished(randomBytes32()).isEmpty) + assert(db.listPublished(c4).map(_.txId).toSet.size == 2) + assert(db.listPublished(c4).map(_.desc) == Seq("funding", "funding")) + // NB: we only count a relay fee for the outgoing channel, no the incoming one. assert(db.stats(0 unixms, TimestampMilli.now() + 1.milli).toSet == Set( Stats(channelId = c1, direction = "IN", avgPaymentAmount = 0 sat, paymentCount = 0, relayFee = 0 sat, networkFee = 0 sat),