Skip to content

Commit

Permalink
Add a basic mempool.space blockchain backend (#657)
Browse files Browse the repository at this point in the history
First we introduce `IWatcher`/`IClient` interfaces that abstract access to the blockchain.
  • Loading branch information
pm47 authored Jun 4, 2024
1 parent 7e8482b commit 319dcce
Show file tree
Hide file tree
Showing 14 changed files with 565 additions and 101 deletions.
27 changes: 20 additions & 7 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest
import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest

plugins {
Expand Down Expand Up @@ -89,16 +90,16 @@ kotlin {
api("co.touchlab:kermit:$kermitLoggerVersion")
api(ktor("network"))
api(ktor("network-tls"))
}
}

commonTest {
dependencies {
implementation(ktor("client-core"))
implementation(ktor("client-auth"))
implementation(ktor("client-json"))
implementation(ktor("client-content-negotiation"))
implementation(ktor("serialization-kotlinx-json"))
}
}

commonTest {
dependencies {
implementation(kotlin("test-common"))
implementation(kotlin("test-annotations-common"))
implementation("org.kodein.memory:klio-files:0.12.0")
Expand All @@ -123,14 +124,19 @@ kotlin {
}

if (currentOs.isMacOsX) {
iosTest {
iosMain {
dependencies {
implementation(ktor("client-ios"))
}
}
macosMain {
dependencies {
implementation(ktor("client-darwin"))
}
}
}

linuxTest {
linuxMain {
dependencies {
implementation(ktor("client-curl"))
}
Expand Down Expand Up @@ -306,6 +312,13 @@ tasks
it.filter.excludeTestsMatching("*SwapInWalletTestsCommon")
}

// Those tests do not work with the ios simulator
tasks
.filterIsInstance<KotlinNativeSimulatorTest>()
.map {
it.filter.excludeTestsMatching("*MempoolSpace*Test")
}

// Make NS_FORMAT_ARGUMENT(1) a no-op
// This fixes an issue when building PhoenixCrypto using XCode 13
// More on this: https://youtrack.jetbrains.com/issue/KT-48807#focus=Comments-27-5210791.0-0
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,33 @@
package fr.acinq.lightning.blockchain.electrum
package fr.acinq.lightning.blockchain

import fr.acinq.bitcoin.Satoshi
import fr.acinq.bitcoin.Transaction
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.Commitments
import fr.acinq.lightning.channel.LocalFundingStatus
import fr.acinq.lightning.logging.*
import fr.acinq.lightning.logging.MDCLogger
import fr.acinq.lightning.transactions.Transactions
import fr.acinq.lightning.utils.sat

suspend fun IElectrumClient.getConfirmations(txId: TxId): Int? = getTx(txId)?.let { tx -> getConfirmations(tx) }
interface IClient {
suspend fun getConfirmations(txId: TxId): Int?

/**
* @return the number of confirmations, zero if the transaction is in the mempool, null if the transaction is not found
*/
suspend fun IElectrumClient.getConfirmations(tx: Transaction): Int? {
return when (val status = connectionStatus.value) {
is ElectrumConnectionStatus.Connected -> {
val currentBlockHeight = status.height
val scriptHash = ElectrumClient.computeScriptHash(tx.txOut.first().publicKeyScript)
val scriptHashHistory = getScriptHashHistory(scriptHash)
val item = scriptHashHistory.find { it.txid == tx.txid }
item?.let { if (item.blockHeight > 0) currentBlockHeight - item.blockHeight + 1 else 0 }
}
else -> null
}
suspend fun getFeerates(): Feerates?
}

data class Feerates(
val minimum: FeeratePerByte,
val slow: FeeratePerByte,
val medium: FeeratePerByte,
val fast: FeeratePerByte,
val fastest: FeeratePerByte
)

/**
* @weight must be the total estimated weight of the splice tx, otherwise the feerate estimation will be wrong
*/
suspend fun IElectrumClient.computeSpliceCpfpFeerate(commitments: Commitments, targetFeerate: FeeratePerKw, spliceWeight: Int, logger: MDCLogger): Pair<FeeratePerKw, Satoshi> {
suspend fun IClient.computeSpliceCpfpFeerate(commitments: Commitments, targetFeerate: FeeratePerKw, spliceWeight: Int, logger: MDCLogger): Pair<FeeratePerKw, Satoshi> {
val (parentsWeight, parentsFees) = commitments.all
.takeWhile { getConfirmations(it.fundingTxId).let { confirmations -> confirmations == null || confirmations == 0 } } // we check for null in case the tx has been evicted
.fold(Pair(0, 0.sat)) { (parentsWeight, parentsFees), commitment ->
Expand All @@ -55,4 +51,4 @@ suspend fun IElectrumClient.computeSpliceCpfpFeerate(commitments: Commitments, t
logger.info { "projectedFeerate=$projectedFeerate projectedFee=$projectedFee" }
logger.info { "actualFeerate=$actualFeerate actualFee=$actualFee" }
return Pair(actualFeerate, actualFee)
}
}
13 changes: 13 additions & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/blockchain/IWatcher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package fr.acinq.lightning.blockchain

import fr.acinq.bitcoin.Transaction
import fr.acinq.lightning.blockchain.electrum.IElectrumClient
import kotlinx.coroutines.flow.Flow

interface IWatcher {
fun openWatchNotificationsFlow(): Flow<WatchEvent>

suspend fun watch(watch: Watch)

suspend fun publish(tx: Transaction)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.io.TcpSocket
import fr.acinq.lightning.io.send
import fr.acinq.lightning.logging.*
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.debug
import fr.acinq.lightning.logging.info
import fr.acinq.lightning.logging.warning
import fr.acinq.lightning.utils.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package fr.acinq.lightning.blockchain.electrum
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Transaction
import fr.acinq.lightning.blockchain.*
import fr.acinq.lightning.logging.*
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.debug
import fr.acinq.lightning.logging.info
import fr.acinq.lightning.transactions.Scripts
import fr.acinq.lightning.utils.currentTimestampMillis
import kotlinx.coroutines.*
Expand All @@ -15,24 +17,24 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.consumeAsFlow
import kotlin.math.max

class ElectrumWatcher(val client: IElectrumClient, val scope: CoroutineScope, loggerFactory: LoggerFactory) : CoroutineScope by scope {
class ElectrumWatcher(val client: IElectrumClient, val scope: CoroutineScope, loggerFactory: LoggerFactory) : IWatcher, CoroutineScope by scope {

private val logger = loggerFactory.newLogger(this::class)
private val mailbox = Channel<WatcherCommand>(Channel.BUFFERED)

private val _notificationsFlow = MutableSharedFlow<WatchEvent>(replay = 0, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)
fun openWatchNotificationsFlow(): Flow<WatchEvent> = _notificationsFlow.asSharedFlow()
override fun openWatchNotificationsFlow(): Flow<WatchEvent> = _notificationsFlow.asSharedFlow()

// this is used by a Swift watch-tower module in the Phoenix iOS app to tell when the watcher is up-to-date
// the value that is emitted in the time elapsed (in milliseconds) since the watcher is ready and idle
private val _uptodateFlow = MutableSharedFlow<Long>(replay = 0, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)
fun openUpToDateFlow(): Flow<Long> = _uptodateFlow.asSharedFlow()

suspend fun watch(watch: Watch) {
override suspend fun watch(watch: Watch) {
mailbox.send(WatcherCommand.AddWatch(watch))
}

suspend fun publish(tx: Transaction) {
override suspend fun publish(tx: Transaction) {
mailbox.send(WatcherCommand.Publish(tx))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import fr.acinq.bitcoin.BlockHeader
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Transaction
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.blockchain.Feerates
import fr.acinq.lightning.blockchain.IClient
import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow

/** Note to implementers: methods exposed through this interface must *not* throw exceptions. */
interface IElectrumClient {
interface IElectrumClient : IClient {
val notifications: Flow<ElectrumSubscriptionResponse>
val connectionStatus: StateFlow<ElectrumConnectionStatus>

Expand Down Expand Up @@ -47,4 +50,32 @@ interface IElectrumClient {

/** Subscribe to headers for new blocks found. */
suspend fun startHeaderSubscription(): HeaderSubscriptionResponse

/**
* @return the number of confirmations, zero if the transaction is in the mempool, null if the transaction is not found
*/
suspend fun getConfirmations(tx: Transaction): Int? {
return when (val status = connectionStatus.value) {
is ElectrumConnectionStatus.Connected -> {
val currentBlockHeight = status.height
val scriptHash = ElectrumClient.computeScriptHash(tx.txOut.first().publicKeyScript)
val scriptHashHistory = getScriptHashHistory(scriptHash)
val item = scriptHashHistory.find { it.txid == tx.txid }
item?.let { if (item.blockHeight > 0) currentBlockHeight - item.blockHeight + 1 else 0 }
}
else -> null
}
}

override suspend fun getConfirmations(txId: TxId): Int? = getTx(txId)?.let { tx -> getConfirmations(tx) }

override suspend fun getFeerates(): Feerates? {
return Feerates(
minimum = estimateFees(144)?.let { FeeratePerByte(it) } ?: return null,
slow = estimateFees(18)?.let { FeeratePerByte(it) } ?: return null,
medium = estimateFees(6)?.let { FeeratePerByte(it) } ?: return null,
fast = estimateFees(2)?.let { FeeratePerByte(it) } ?: return null,
fastest = estimateFees(1)?.let { FeeratePerByte(it) } ?: return null,
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package fr.acinq.lightning.blockchain.fee

import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.blockchain.Feerates
import fr.acinq.lightning.utils.sat

interface FeeEstimator {
Expand All @@ -15,7 +16,14 @@ interface FeeEstimator {
* @param claimMainFeerate feerate used to claim our main output when a channel is force-closed (typically configured by the user, based on their preference).
* @param fastFeerate feerate used to claim outputs quickly to avoid loss of funds: this one should not be set by the user (we should look at current on-chain fees).
*/
data class OnChainFeerates(val fundingFeerate: FeeratePerKw, val mutualCloseFeerate: FeeratePerKw, val claimMainFeerate: FeeratePerKw, val fastFeerate: FeeratePerKw)
data class OnChainFeerates(val fundingFeerate: FeeratePerKw, val mutualCloseFeerate: FeeratePerKw, val claimMainFeerate: FeeratePerKw, val fastFeerate: FeeratePerKw) {
constructor(feerates: Feerates) : this(
fundingFeerate = FeeratePerKw(feerates.medium),
mutualCloseFeerate = FeeratePerKw(feerates.medium),
claimMainFeerate = FeeratePerKw(feerates.medium),
fastFeerate = FeeratePerKw(feerates.fast),
)
}

data class FeerateTolerance(val ratioLow: Double, val ratioHigh: Double)

Expand Down
Loading

0 comments on commit 319dcce

Please sign in to comment.