Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for new on-the-fly funding #113

Merged
merged 13 commits into from
Oct 3, 2024
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