Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ kotlinx-io = "0.9.0"
clikt = "5.1.0"
ktor = "3.4.1"
sqldelight = "2.2.1"
lightningkmp = "1.12.0"
lightningkmp = "1.12.1-SNAPSHOT"
kermit-io = "2.0.8"

# test dependencies
Expand Down
27 changes: 25 additions & 2 deletions src/commonMain/kotlin/fr/acinq/phoenixd/Api.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import fr.acinq.bitcoin.utils.toEither
import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.blockchain.electrum.SwapInManager
import fr.acinq.lightning.blockchain.electrum.balance
import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelCloseResponse
Expand Down Expand Up @@ -55,6 +57,7 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.io.files.Path
import kotlinx.serialization.json.Json
import kotlin.collections.filterIsInstance
import kotlin.time.Duration.Companion.seconds

class Api(
Expand Down Expand Up @@ -154,12 +157,27 @@ class Api(
call.respond(info)
}
get("getbalance") {
val balance = peer.channels.values
val channels = peer.channels.values
val balance = channels
.filterIsInstance<ChannelStateWithCommitments>()
.filterNot { it is Closing || it is Closed }
.map { it.commitments.active.first().availableBalanceForSend(it.commitments.channelParams, it.commitments.changes) }
.sum().truncateToSatoshi()
call.respond(Balance(balance, peer.feeCreditFlow.value.truncateToSatoshi()))
val swapInBalance = peer.swapInWallet?.wallet?.walletStateFlow?.value?.let { walletState ->
val reservedInputs = SwapInManager.reservedWalletInputs(channels.filterIsInstance<PersistedChannelState>())
val walletWithoutReserved = walletState.withoutReservedUtxos(reservedInputs)
val swapInWallet = walletWithoutReserved.withConfirmations(
currentBlockHeight = peer.currentTipFlow.value ?: 0,
swapInParams = peer.walletParams.swapInParams
)
SwapInBalance(
unconfirmedBalance = swapInWallet.unconfirmed.balance,
weaklyConfirmedBalance = swapInWallet.weaklyConfirmed.balance,
deeplyConfirmedBalance = swapInWallet.deeplyConfirmed.balance
)
}

call.respond(Balance(balance, peer.feeCreditFlow.value.truncateToSatoshi(), swapInBalance))
}
get("estimateliquidityfees") {
val amount = call.parameters.getLong("amountSat").sat
Expand Down Expand Up @@ -219,6 +237,11 @@ class Api(
call.respond("₿$address")
}
}
get("getswapinaddress") {
val swapInWallet = peer.swapInWallet ?: badRequest("swap-in wallet unavailable")
val (address, index) = swapInWallet.swapInAddressFlow.filterNotNull().first()
call.respond(SwapInAddress(address, index))
}
get("payments/incoming") {
val payments: List<ApiType> = paymentDb.listIncomingPayments(
from = call.parameters.getOptionalLong("from") ?: 0L,
Expand Down
81 changes: 71 additions & 10 deletions src/commonMain/kotlin/fr/acinq/phoenixd/Phoenixd.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.LiquidityEvents
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.PaymentEvents
import fr.acinq.lightning.blockchain.electrum.*
import fr.acinq.lightning.blockchain.mempool.MempoolSpaceClient
import fr.acinq.lightning.blockchain.mempool.MempoolSpaceWatcher
import fr.acinq.lightning.channel.states.PersistedChannelState
import fr.acinq.lightning.crypto.LocalKeyManager
import fr.acinq.lightning.db.*
import fr.acinq.lightning.io.Peer
Expand Down Expand Up @@ -80,20 +82,15 @@ class Phoenixd : CliktCommand() {
).default(Chain.Mainnet, defaultForHelp = "mainnet")
private val mempoolSpaceUrl by option("--mempool-space-url", help = "Custom mempool.space instance")
.convert { Url(it) }
.defaultLazy {
when (chain) {
Chain.Mainnet -> MempoolSpaceClient.OfficialMempoolMainnet
Chain.Testnet3 -> MempoolSpaceClient.OfficialMempoolTestnet3
else -> error("unsupported chain")
}
}
private val mempoolPollingInterval by option(
"--mempool-space-polling-interval-minutes",
help = "Polling interval for mempool.space API",
hidden = true
)
.int().convert { it.minutes }
.default(10.minutes)
private val customElectrumServer by option("--electrum-server", help = "Custom electrum server host:port (SSL)")
.convert { it.split(":").run { ServerAddress(host = first(), port = last().toInt(), tls = TcpSocket.TLS.TRUSTED_CERTIFICATES()) } }

class LiquidityOptions : OptionGroup(name = "Liquidity Options") {
val autoLiquidity by option("--auto-liquidity", help = "Amount automatically requested when inbound liquidity is needed").choice(
Expand Down Expand Up @@ -308,10 +305,23 @@ class Phoenixd : CliktCommand() {
val channelsDb = SqliteChannelsDb(driver, database)
val paymentsDb = SqlitePaymentsDb(database)

val mempoolSpace = MempoolSpaceClient(mempoolSpaceUrl, loggerFactory)
val watcher = MempoolSpaceWatcher(mempoolSpace, scope, loggerFactory, pollingInterval = mempoolPollingInterval)
val (blockchainClient, blockchainWatcher) = when (val mempoolUrl = mempoolSpaceUrl) {
null -> {
consoleLog(cyan("using electrum"))
val client = ElectrumClient(scope, loggerFactory)
val watcher = ElectrumWatcher(client, scope, loggerFactory)
client to watcher
}
else -> {
consoleLog(cyan("using mempool.space"))
val client = MempoolSpaceClient(mempoolUrl, loggerFactory)
val watcher = MempoolSpaceWatcher(client, scope, loggerFactory, pollingInterval = mempoolPollingInterval)
client to watcher
}
}

val peer = Peer(
nodeParams = nodeParams, walletParams = lsp.walletParams, client = mempoolSpace, watcher = watcher, db = object : Databases {
nodeParams = nodeParams, walletParams = lsp.walletParams, client = blockchainClient, watcher = blockchainWatcher, db = object : Databases {
override val channels: ChannelsDb get() = channelsDb
override val payments: PaymentsDb get() = paymentsDb
}, socketBuilder = TcpSocket.Builder(), scope
Expand Down Expand Up @@ -351,6 +361,37 @@ class Phoenixd : CliktCommand() {
}
}
}
if (blockchainClient is ElectrumClient) {
launch {
blockchainClient.connectionStatus
.drop(1) // we drop the initial value which a disconnection event
.collect {
when (it) {
is ElectrumConnectionStatus.Connecting -> consoleLog(yellow("connecting to electrum server ${it.serverAddress.host}:${it.serverAddress.port}..."))
is ElectrumConnectionStatus.Connected -> consoleLog(yellow("connected to electrum server"))
is ElectrumConnectionStatus.Closed -> consoleLog(yellow("disconnected from electrum server"))
}
}
}
}
val swapInWallet = peer.swapInWallet
if (swapInWallet != null) {
launch {
combine(swapInWallet.wallet.walletStateFlow, peer.currentTipFlow.filterNotNull(), peer.channelsFlow) { walletState, currentBlockHeight, channels ->
val reservedInputs = SwapInManager.reservedWalletInputs(channels.values.filterIsInstance<PersistedChannelState>())
val walletWithoutReserved = walletState.withoutReservedUtxos(reservedInputs)
walletWithoutReserved.withConfirmations(
currentBlockHeight = currentBlockHeight,
swapInParams = peer.walletParams.swapInParams
)
}.drop(1)
.map { wallet -> Triple(wallet.unconfirmed.balance, wallet.weaklyConfirmed.balance, wallet.deeplyConfirmed.balance) }
.distinctUntilChanged()
.collect { (unconfirmedBalance, weaklyConfirmedBalance, deeplyConfirmedBalance) -> consoleLog("swap-in wallet: unconfirmed=$unconfirmedBalance weaklyConfirmed=$weaklyConfirmedBalance deeplyConfirmed=$deeplyConfirmedBalance") }

}

}
launch {
nodeParams.nodeEvents
.filterIsInstance<PaymentEvents>()
Expand All @@ -362,6 +403,9 @@ class Phoenixd : CliktCommand() {
val type = payment.parts.joinToString { part -> part::class.simpleName.toString().lowercase() }
consoleLog("received lightning payment: ${payment.amount.truncateToSatoshi()} ($type${if (fee > 0.sat) " fee=$fee" else ""})")
}
is OnChainIncomingPayment -> {
consoleLog("received on-chain payment: ${payment.amount.truncateToSatoshi()} (fee=${payment.fees})")
}
else -> {}
}
is PaymentEvents.PaymentSent ->
Expand Down Expand Up @@ -413,6 +457,17 @@ class Phoenixd : CliktCommand() {
}
}

if (blockchainClient is ElectrumClient) {
val electrumConnectionLoop = scope.launch {
while (true) {
val electrumServer = customElectrumServer ?: ElectrumServers.pickElectrumServer(chain)
blockchainClient.connect(electrumServer, TcpSocket.Builder())
blockchainClient.connectionStatus.first { it is ElectrumConnectionStatus.Closed }
delay(3.seconds)
}
}
}

val peerConnectionLoop = scope.launch {
while (true) {
peer.connect(connectTimeout = 10.seconds, handshakeTimeout = 10.seconds)
Expand All @@ -422,9 +477,15 @@ class Phoenixd : CliktCommand() {
}

runBlocking {
if (blockchainClient is ElectrumClient) {
blockchainClient.connectionStatus.first { it is ElectrumConnectionStatus.Connected }
}
peer.connectionState.first { it == Connection.ESTABLISHED }
}

// Start monitoring swap-in wallet after both electrum and peer are connected
scope.launch { peer.startWatchSwapInWallet() }

val server = embeddedServer(
CIO,
environment = applicationEnvironment {
Expand Down
7 changes: 7 additions & 0 deletions src/commonMain/kotlin/fr/acinq/phoenixd/cli/PhoenixCli.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ fun main(args: Array<String>): kotlin.Unit =
CreateOffer(),
GetOffer(),
GetLnAddress(),
GetSwapInAddress(),
PayInvoice(),
PayOffer(),
PayLnAddress(),
Expand Down Expand Up @@ -405,6 +406,12 @@ class LnurlAuth : PhoenixCliCommand(name = "lnurlauth", help = "Authenticate on
}
}

class GetSwapInAddress : PhoenixCliCommand(name = "getswapinaddress", help = "Get swap-in wallet address") {
override suspend fun httpRequest() = commonOptions.httpClient.use {
it.get(url = commonOptions.baseUrl / "getswapinaddress")
}
}

class SendToAddress : PhoenixCliCommand(name = "sendtoaddress", help = "Send to a Bitcoin address", printHelpOnEmptyArgs = true) {
private val amountSat by option("--amountSat").long().required()
private val address by option("--address").required().check { runCatching { Base58Check.decode(it) }.isSuccess || runCatching { Bech32.decodeWitnessAddress(it) }.isSuccess }
Expand Down
63 changes: 55 additions & 8 deletions src/commonMain/kotlin/fr/acinq/phoenixd/json/JsonSerializers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import fr.acinq.lightning.channel.states.ChannelStateWithCommitments
import fr.acinq.lightning.db.*
import fr.acinq.lightning.json.JsonSerializers
import fr.acinq.lightning.payment.Bolt11Invoice
import fr.acinq.lightning.payment.OfferPaymentMetadata
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.lightning.wire.LiquidityAds
Expand Down Expand Up @@ -78,7 +77,15 @@ sealed class ApiType {
)

@Serializable
data class Balance(@SerialName("balanceSat") val amount: Satoshi, @SerialName("feeCreditSat") val feeCredit: Satoshi) : ApiType()
data class SwapInBalance(
@SerialName("unconfirmedBalanceSat") val unconfirmedBalance: Satoshi,
@SerialName("weaklyConfirmedBalanceSat") val weaklyConfirmedBalance: Satoshi,
@SerialName("deeplyConfirmedBalanceSat") val deeplyConfirmedBalance: Satoshi
) : ApiType()


@Serializable
data class Balance(@SerialName("balanceSat") val amount: Satoshi, @SerialName("feeCreditSat") val feeCredit: Satoshi, val swapIn: SwapInBalance?) : ApiType()

@Serializable
data class LiquidityFees(@SerialName("miningFeeSat") val miningFee: Satoshi, @SerialName("serviceFeeSat") val serviceFee: Satoshi) : ApiType() {
Expand Down Expand Up @@ -109,7 +116,13 @@ sealed class ApiType {

@Serializable
@SerialName("payment_sent")
data class PaymentSent(@SerialName("recipientAmountSat") val recipientAmount: Satoshi, @SerialName("routingFeeSat") val routingFee: Satoshi, @SerialName("paymentId") val uuid: UUID, val paymentHash: ByteVector32, val paymentPreimage: ByteVector32) : ApiType() {
data class PaymentSent(
@SerialName("recipientAmountSat") val recipientAmount: Satoshi,
@SerialName("routingFeeSat") val routingFee: Satoshi,
@SerialName("paymentId") val uuid: UUID,
val paymentHash: ByteVector32,
val paymentPreimage: ByteVector32
) : ApiType() {
constructor(event: fr.acinq.lightning.io.PaymentSent) : this(
event.payment.recipientAmount.truncateToSatoshi(),
event.payment.routingFee.truncateToSatoshi(),
Expand All @@ -128,8 +141,25 @@ sealed class ApiType {

@Serializable
@SerialName("incoming_payment")
data class IncomingPayment(val subType: String, val paymentHash: ByteVector32, val preimage: ByteVector32, val externalId: String?, val description: String?, val invoice: String?, val isPaid: Boolean, val isExpired: Boolean, val requestedSat: Satoshi?, val receivedSat: Satoshi, val fees: MilliSatoshi, val payerNote: String?, val payerKey: PublicKey?, val expiresAt: Long?, val completedAt: Long?, val createdAt: Long): ApiType() {
constructor(payment: LightningIncomingPayment, externalId: String?) : this (
data class IncomingPayment(
val subType: String,
val paymentHash: ByteVector32,
val preimage: ByteVector32,
val externalId: String?,
val description: String?,
val invoice: String?,
val isPaid: Boolean,
val isExpired: Boolean,
val requestedSat: Satoshi?,
val receivedSat: Satoshi,
val fees: MilliSatoshi,
val payerNote: String?,
val payerKey: PublicKey?,
val expiresAt: Long?,
val completedAt: Long?,
val createdAt: Long
) : ApiType() {
constructor(payment: LightningIncomingPayment, externalId: String?) : this(
subType = "lightning",
paymentHash = payment.paymentHash,
preimage = payment.paymentPreimage,
Expand All @@ -147,8 +177,9 @@ sealed class ApiType {
completedAt = payment.completedAt,
createdAt = payment.createdAt,
)

@Suppress("DEPRECATION")
constructor(payment: LegacyPayToOpenIncomingPayment, externalId: String?) : this (
constructor(payment: LegacyPayToOpenIncomingPayment, externalId: String?) : this(
subType = "lightning",
paymentHash = payment.paymentHash,
preimage = payment.paymentPreimage,
Expand All @@ -170,7 +201,19 @@ sealed class ApiType {

@Serializable
@SerialName("outgoing_payment")
data class OutgoingPayment(val subType: String, val paymentId: String, val paymentHash: ByteVector32?, val preimage: ByteVector32?, val txId: TxId?, val isPaid: Boolean, val sent: Satoshi, val fees: MilliSatoshi, val invoice: String?, val completedAt: Long?, val createdAt: Long): ApiType() {
data class OutgoingPayment(
val subType: String,
val paymentId: String,
val paymentHash: ByteVector32?,
val preimage: ByteVector32?,
val txId: TxId?,
val isPaid: Boolean,
val sent: Satoshi,
val fees: MilliSatoshi,
val invoice: String?,
val completedAt: Long?,
val createdAt: Long
) : ApiType() {
constructor(payment: LightningOutgoingPayment) : this(
subType = "lightning",
paymentId = payment.id.toString(),
Expand All @@ -184,8 +227,9 @@ sealed class ApiType {
completedAt = payment.completedAt,
createdAt = payment.createdAt,
)

constructor(payment: OnChainOutgoingPayment) : this(
subType = when(payment) {
subType = when (payment) {
is AutomaticLiquidityPurchasePayment -> "auto_liquidity"
is ManualLiquidityPurchasePayment -> "manual_liquidity"
is SpliceOutgoingPayment -> "splice_out"
Expand All @@ -205,6 +249,9 @@ sealed class ApiType {
)
}

@Serializable
data class SwapInAddress(val address: String, val index: Int) : ApiType()

@Serializable
@SerialName("lnurl_request")
data class LnurlRequest(val url: String, val tag: String?) {
Expand Down