Skip to content

Commit

Permalink
Support for new on-the-fly funding (#113)
Browse files Browse the repository at this point in the history
Adds support for liquidity-ads based protocol for on-the-fly liquidity as specified in lightning/blips#36 and lightning/blips#41, implemented respectively in ACINQ/lightning-kmp#649  and ACINQ/lightning-kmp#660.

### Lightning-kmp update

Phoenixd now uses the main branch of `lightning-kmp` (v1.8.0).

### Database update

- `LiquidityAds.Lease` is replaced by `LiquidityAds.Purchase`, so we need to update the liquidity table.
- the `receivedWith` data have been updated in lightning-kmp, and we need a new `Part.Htlc.V1` object that may contain a `LiquidityAds.FundingFee`.

With the `Lease->Purchase` change, we've updated our pattern for versioning database objects. We now have `asDb()` & `asCanonical()` mapping methods and store the type of the db object inside the json (which means we don't need the `type` column anymore, except for convenience).

---------

Co-authored-by: pm47 <[email protected]>
  • Loading branch information
dpad85 and pm47 authored Oct 3, 2024
1 parent 9414518 commit bbc18be
Show file tree
Hide file tree
Showing 19 changed files with 449 additions and 214 deletions.
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/Versions.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
object Versions {
val kotlin = "1.9.23"
val lightningKmp = "1.7.3-FEECREDIT-11"
val lightningKmp = "1.8.0"
val sqlDelight = "2.0.1"
val okio = "3.8.0"
val clikt = "4.2.2"
Expand Down
17 changes: 12 additions & 5 deletions src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import fr.acinq.lightning.bin.payments.lnurl.models.LnurlWithdraw
import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelCommand
import fr.acinq.lightning.channel.ChannelFundingResponse
import fr.acinq.lightning.channel.states.ChannelStateWithCommitments
import fr.acinq.lightning.channel.states.Closed
import fr.acinq.lightning.channel.states.Closing
Expand Down Expand Up @@ -162,13 +163,19 @@ class Api(
.filterNot { it is Closing || it is Closed }
.map { it.commitments.active.first().availableBalanceForSend(it.commitments.params, it.commitments.changes) }
.sum().truncateToSatoshi()
call.respond(Balance(balance, nodeParams.feeCredit.value))
call.respond(Balance(balance, peer.feeCreditFlow.value.truncateToSatoshi()))
}
get("estimateliquidityfees") {
val amount = call.parameters.getLong("amountSat").sat
val feerate = peer.onChainFeeratesFlow.filterNotNull().first().fundingFeerate
val liquidityFees = LSP.liquidityFees(amount, feerate, isNew = peer.channels.isEmpty())
call.respond(LiquidityFees(liquidityFees))
val fundingRates = peer.remoteFundingRates.filterNotNull().first()
when (val fundingRate = fundingRates.findRate(amount)) {
null -> badRequest("no available funding rates for amount=$amount")
else -> {
val liquidityFees = fundingRate.fees(feerate, amount, amount, isChannelCreation = peer.channels.isEmpty())
call.respond(LiquidityFees(liquidityFees))
}
}
}
get("listchannels") {
call.respond(peer.channels.values.toList())
Expand Down Expand Up @@ -388,8 +395,8 @@ class Api(
}.toEither()
when (res) {
is Either.Right -> when (val r = res.value) {
is ChannelCommand.Commitment.Splice.Response.Created -> call.respondText(r.fundingTxId.toString())
is ChannelCommand.Commitment.Splice.Response.Failure -> call.respondText(r.toString())
is ChannelFundingResponse.Success -> call.respondText(r.fundingTxId.toString())
is ChannelFundingResponse.Failure -> call.respondText(r.toString())
else -> call.respondText("no channel available")
}
is Either.Left -> call.respondText(res.value.message.toString())
Expand Down
90 changes: 51 additions & 39 deletions src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,13 @@ import fr.acinq.lightning.bin.logs.stringTimestamp
import fr.acinq.lightning.blockchain.mempool.MempoolSpaceClient
import fr.acinq.lightning.blockchain.mempool.MempoolSpaceWatcher
import fr.acinq.lightning.crypto.LocalKeyManager
import fr.acinq.lightning.db.ChannelsDb
import fr.acinq.lightning.db.Databases
import fr.acinq.lightning.db.PaymentsDb
import fr.acinq.lightning.db.*
import fr.acinq.lightning.io.Peer
import fr.acinq.lightning.io.TcpSocket
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.payment.LiquidityPolicy
import fr.acinq.lightning.utils.Connection
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toByteVector
import fr.acinq.lightning.utils.*
import fr.acinq.lightning.wire.LiquidityAds
import fr.acinq.phoenix.db.*
import io.ktor.http.*
import io.ktor.server.application.*
Expand Down Expand Up @@ -90,14 +86,14 @@ class Phoenixd : CliktCommand() {
}
private val agreeToTermsOfService by option("--agree-to-terms-of-service", hidden = true, help = "Agree to terms of service").flag()
private val chain by option("--chain", help = "Bitcoin chain to use").choice(
"mainnet" to Chain.Mainnet, "testnet" to Chain.Testnet
"mainnet" to Chain.Mainnet, "testnet" to Chain.Testnet3
).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.Testnet -> MempoolSpaceClient.OfficialMempoolTestnet
Chain.Testnet3 -> MempoolSpaceClient.OfficialMempoolTestnet
else -> error("unsupported chain")
}
}
Expand Down Expand Up @@ -155,7 +151,7 @@ class Phoenixd : CliktCommand() {
"off" to 0.sat,
"50k" to 50_000.sat,
"100k" to 100_000.sat,
).default(100_000.sat, "100k")
).convert { it.toMilliSatoshi() }.default(100_000.sat.toMilliSatoshi(), "100k")
private val maxRelativeFeePct by option("--max-relative-fee-percent", help = "Max relative fee for on-chain operations in percent.", hidden = true)
.int()
.restrictTo(1..50)
Expand Down Expand Up @@ -244,10 +240,11 @@ class Phoenixd : CliktCommand() {
)
val lsp = LSP.from(chain)
val liquidityPolicy = LiquidityPolicy.Auto(
maxMiningFee = liquidityOptions.maxMiningFee,
inboundLiquidityTarget = liquidityOptions.autoLiquidity,
maxAbsoluteFee = liquidityOptions.maxMiningFee,
maxRelativeFeeBasisPoints = liquidityOptions.maxRelativeFeeBasisPoints,
skipMiningFeeCheck = false,
maxAllowedCredit = liquidityOptions.maxFeeCredit
skipAbsoluteFeeCheck = false,
maxAllowedFeeCredit = liquidityOptions.maxFeeCredit
)
val keyManager = LocalKeyManager(seed.seed, chain, lsp.swapInXpub)
val nodeParams = NodeParams(chain, loggerFactory, keyManager)
Expand Down Expand Up @@ -276,9 +273,6 @@ class Phoenixd : CliktCommand() {
channel_close_outgoing_paymentsAdapter = Channel_close_outgoing_payments.Adapter(
closing_info_typeAdapter = EnumColumnAdapter()
),
inbound_liquidity_outgoing_paymentsAdapter = Inbound_liquidity_outgoing_payments.Adapter(
lease_typeAdapter = EnumColumnAdapter()
)
)
val channelsDb = SqliteChannelsDb(driver, database)
val paymentsDb = SqlitePaymentsDb(database)
Expand Down Expand Up @@ -324,39 +318,59 @@ class Phoenixd : CliktCommand() {
}
launch {
nodeParams.nodeEvents
.filterIsInstance<PaymentEvents.PaymentReceived>()
.filter { it.amount > 0.msat }
.filterIsInstance<PaymentEvents>()
.collect {
consoleLog("received lightning payment: ${it.amount.truncateToSatoshi()} (${it.receivedWith.joinToString { part -> part::class.simpleName.toString().lowercase() }})")
when (it) {
is PaymentEvents.PaymentReceived -> {
val fee = it.receivedWith.filterIsInstance<IncomingPayment.ReceivedWith.LightningPayment>().map { it.fundingFee?.amount ?: 0.msat }.sum().truncateToSatoshi()
val type = it.receivedWith.joinToString { part -> part::class.simpleName.toString().lowercase() }
consoleLog("received lightning payment: ${it.amount.truncateToSatoshi()} ($type${if (fee > 0.sat) " fee=$fee" else ""})")
}
is PaymentEvents.PaymentSent ->
when (val payment = it.payment) {
is InboundLiquidityOutgoingPayment -> {
val totalFee = payment.fees.truncateToSatoshi()
val feePaidFromBalance = payment.feePaidFromChannelBalance.total
val feePaidFromFeeCredit = payment.feeCreditUsed.truncateToSatoshi()
val feeRemaining = totalFee - feePaidFromBalance - feePaidFromFeeCredit
val purchaseType = payment.purchase.paymentDetails.paymentType::class.simpleName.toString().lowercase()
consoleLog("purchased inbound liquidity: ${payment.purchase.amount} (totalFee=$totalFee feePaidFromBalance=$feePaidFromBalance feePaidFromFeeCredit=$feePaidFromFeeCredit feeRemaining=$feeRemaining purchaseType=$purchaseType)")
}
else -> {}
}
}
}
}
launch {
nodeParams.nodeEvents
.filterIsInstance<LiquidityEvents.Decision.Rejected>()
.filterIsInstance<LiquidityEvents.Rejected>()
.collect {
when (val reason = it.reason) {
is LiquidityEvents.Decision.Rejected.Reason.OverMaxCredit -> {
consoleLog(yellow("lightning payment rejected (amount=${it.amount.truncateToSatoshi()}): over max fee credit (max=${reason.maxAllowedCredit})"))
}
is LiquidityEvents.Decision.Rejected.Reason.TooExpensive.OverMaxMiningFee -> {
consoleLog(yellow("lightning payment rejected (amount=${it.amount.truncateToSatoshi()}): over max mining fee (max=${reason.maxMiningFee})"))
}
is LiquidityEvents.Decision.Rejected.Reason.TooExpensive.OverRelativeFee -> {
consoleLog(yellow("lightning payment rejected (amount=${it.amount.truncateToSatoshi()}): fee=${it.fee.truncateToSatoshi()} more than ${reason.maxRelativeFeeBasisPoints.toDouble() / 100}% of amount"))
}
LiquidityEvents.Decision.Rejected.Reason.ChannelInitializing -> {
consoleLog(yellow("channels are initializing"))
}
LiquidityEvents.Decision.Rejected.Reason.PolicySetToDisabled -> {
// TODO: put this back after rework of LiquidityPolicy to handle fee credit
// is LiquidityEvents.Rejected.Reason.OverMaxCredit -> {
// consoleLog(yellow("lightning payment rejected (amount=${it.amount.truncateToSatoshi()}): over max fee credit (max=${reason.maxAllowedCredit})"))
// }
is LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee ->
consoleLog(yellow("lightning payment rejected (amount=${it.amount.truncateToSatoshi()}): over absolute fee (fee=${it.fee.truncateToSatoshi()} max=${reason.maxAbsoluteFee})"))
is LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee ->
consoleLog(yellow("lightning payment rejected (amount=${it.amount.truncateToSatoshi()}): over relative fee (fee=${it.fee.truncateToSatoshi()} max=${reason.maxRelativeFeeBasisPoints.toDouble() / 100}%)"))
LiquidityEvents.Rejected.Reason.PolicySetToDisabled ->
consoleLog(yellow("automated liquidity is disabled"))
}
LiquidityEvents.Rejected.Reason.ChannelFundingInProgress ->
consoleLog(yellow("channel operation is in progress"))
is LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow ->
consoleLog(yellow("missing offchain amount is too low (missingOffChainAmount=${reason.missingOffChainAmount} currentFeeCredit=${reason.currentFeeCredit}"))
LiquidityEvents.Rejected.Reason.NoMatchingFundingRate ->
consoleLog(yellow("no matching funding rates"))
is LiquidityEvents.Rejected.Reason.TooManyParts ->
consoleLog(yellow("too many payment parts"))
}
}
}
launch {
nodeParams.feeCredit
.drop(1) // we drop the initial value which is 0 sat
.collect { feeCredit -> consoleLog("fee credit: $feeCredit") }
peer.feeCreditFlow
.drop(1) // we drop the initial value which is 0 msat
.collect { feeCredit -> consoleLog("fee credit: ${feeCredit.truncateToSatoshi()}") }
}
}

Expand All @@ -370,8 +384,6 @@ class Phoenixd : CliktCommand() {

runBlocking {
peer.connectionState.first { it == Connection.ESTABLISHED }
peer.registerFcmToken("super-${randomBytes32().toHex()}")
peer.setAutoLiquidityParams(liquidityOptions.autoLiquidity)
}

val server = embeddedServer(CIO, port = httpBindPort, host = httpBindIp,
Expand Down
35 changes: 1 addition & 34 deletions src/commonMain/kotlin/fr/acinq/lightning/bin/conf/Lsp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@ package fr.acinq.lightning.bin.conf

import fr.acinq.bitcoin.Chain
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.*
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.wire.LiquidityAds


data class LSP(val walletParams: WalletParams, val swapInXpub: String) {
Expand Down Expand Up @@ -44,7 +41,7 @@ data class LSP(val walletParams: WalletParams, val swapInXpub: String) {
swapInParams
)
)
is Chain.Testnet -> LSP(
is Chain.Testnet3 -> LSP(
swapInXpub = "tpubDAmCFB21J9ExKBRPDcVxSvGs9jtcf8U1wWWbS1xTYmnUsuUHPCoFdCnEGxLE3THSWcQE48GHJnyz8XPbYUivBMbLSMBifFd3G9KmafkM9og",
walletParams = WalletParams(
trampolineNode = NodeUri(PublicKey.fromHex("03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134"), "13.248.222.197", 9735),
Expand All @@ -55,35 +52,5 @@ data class LSP(val walletParams: WalletParams, val swapInXpub: String) {
)
else -> error("unsupported chain $chain")
}

fun liquidityFees(amount: Satoshi, feerate: FeeratePerKw, isNew: Boolean): LiquidityAds.LeaseFees {
val creationFee = if (isNew) 1_000.sat else 0.sat
val leaseRate = liquidityLeaseRate(amount)
val leaseFees = leaseRate.fees(feerate, requestedAmount = amount, contributedAmount = amount)
return leaseFees.copy(serviceFee = creationFee + leaseFees.serviceFee)
}

private fun liquidityLeaseRate(amount: Satoshi): LiquidityAds.LeaseRate {
// WARNING : THIS MUST BE KEPT IN SYNC WITH LSP OTHERWISE FUNDING REQUEST WILL BE REJECTED BY PHOENIX
val fundingWeight = if (amount <= 100_000.sat) {
271 * 2 // 2-inputs (wpkh) / 0-change
} else if (amount <= 250_000.sat) {
271 * 2 // 2-inputs (wpkh) / 0-change
} else if (amount <= 500_000.sat) {
271 * 4 // 4-inputs (wpkh) / 0-change
} else if (amount <= 1_000_000.sat) {
271 * 4 // 4-inputs (wpkh) / 0-change
} else {
271 * 6 // 6-inputs (wpkh) / 0-change
}
return LiquidityAds.LeaseRate(
leaseDuration = 0,
fundingWeight = fundingWeight,
leaseFeeProportional = 100, // 1%
leaseFeeBase = 0.sat,
maxRelayFeeProportional = 100,
maxRelayFeeBase = 1_000.msat
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ class SqlitePaymentsDb(val database: PhoenixDatabase) : PaymentsDb {
}
}

override suspend fun getInboundLiquidityPurchase(fundingTxId: TxId): InboundLiquidityOutgoingPayment? {
return withContext(Dispatchers.Default) {
inboundLiquidityQueries.getByTxId(fundingTxId)
}
}

override suspend fun completeOutgoingPaymentOffchain(
id: UUID,
finalFailure: FinalFailure,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ object DbTypesHelper {

val module = SerializersModule {
polymorphic(IncomingReceivedWithData.Part::class) {
@Suppress("DEPRECATION")
subclass(IncomingReceivedWithData.Part.Htlc.V0::class)
subclass(IncomingReceivedWithData.Part.Htlc.V1::class)
subclass(IncomingReceivedWithData.Part.NewChannel.V2::class)
subclass(IncomingReceivedWithData.Part.SpliceIn.V0::class)
subclass(IncomingReceivedWithData.Part.FeeCredit.V0::class)
Expand Down
Loading

0 comments on commit bbc18be

Please sign in to comment.