Initial commit

This commit is contained in:
Pierre-Marie Padiou
2024-03-08 18:44:24 +01:00
committed by pm47
commit d791179125
55 changed files with 4985 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
package fr.acinq.lightning.bin
import fr.acinq.bitcoin.Bitcoin
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Script
import fr.acinq.bitcoin.utils.Either
import fr.acinq.bitcoin.utils.toEither
import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.bin.json.ApiType.*
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.states.ChannelStateWithCommitments
import fr.acinq.lightning.channel.states.ClosingFeerates
import fr.acinq.lightning.io.Peer
import fr.acinq.lightning.io.WrappedChannelCommand
import fr.acinq.lightning.payment.Bolt11Invoice
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.sum
import fr.acinq.lightning.utils.toByteVector
import fr.acinq.lightning.utils.toMilliSatoshi
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.engine.*
import io.ktor.server.plugins.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
class Api(private val nodeParams: NodeParams, private val peer: Peer, private val eventsFlow: SharedFlow<ApiEvent>, private val password: String, private val webhookUrl: Url?) {
fun Application.module() {
val json = Json {
prettyPrint = true
isLenient = true
serializersModule = fr.acinq.lightning.json.JsonSerializers.json.serializersModule
}
install(ContentNegotiation) {
json(json)
}
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(json)
timeoutMillis = 10_000
pingPeriodMillis = 10_000
}
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respondText(text = cause.message ?: "", status = defaultExceptionStatusCode(cause) ?: HttpStatusCode.InternalServerError)
}
}
install(Authentication) {
basic {
validate { credentials ->
if (credentials.password == password) {
UserIdPrincipal(credentials.name)
} else {
null
}
}
}
}
routing {
authenticate {
get("getinfo") {
val info = NodeInfo(
nodeId = nodeParams.nodeId,
channels = peer.channels.values.map { Channel.from(it) }
)
call.respond(info)
}
get("getbalance") {
val balance = peer.channels.values
.filterIsInstance<ChannelStateWithCommitments>()
.map { it.commitments.active.first().availableBalanceForSend(it.commitments.params, it.commitments.changes) }
.sum().truncateToSatoshi()
call.respond(Balance(balance, nodeParams.feeCredit.value))
}
get("listchannels") {
call.respond(peer.channels.values.toList())
}
post("createinvoice") {
val formParameters = call.receiveParameters()
val amount = formParameters.getLong("amountSat").sat
val description = formParameters.getString("description")
val invoice = peer.createInvoice(randomBytes32(), amount.toMilliSatoshi(), Either.Left(description))
call.respond(GeneratedInvoice(invoice.amount?.truncateToSatoshi(), invoice.paymentHash, serialized = invoice.write()))
}
post("payinvoice") {
val formParameters = call.receiveParameters()
val overrideAmount = formParameters["amountSat"]?.let { it.toLongOrNull() ?: invalidType("amountSat", "integer") }?.sat?.toMilliSatoshi()
val invoice = formParameters.getInvoice("invoice")
val amount = (overrideAmount ?: invoice.amount) ?: missing("amountSat")
when (val event = peer.sendLightning(amount, invoice)) {
is fr.acinq.lightning.io.PaymentSent -> call.respond(PaymentSent(event))
is fr.acinq.lightning.io.PaymentNotSent -> call.respond(PaymentFailed(event))
}
}
post("sendtoaddress") {
val res = kotlin.runCatching {
val formParameters = call.receiveParameters()
val amount = formParameters.getLong("amountSat").sat
val scriptPubKey = formParameters.getAddressAndConvertToScript("address")
val feerate = FeeratePerKw(FeeratePerByte(formParameters.getLong("feerateSatByte").sat))
peer.spliceOut(amount, scriptPubKey, feerate)
}.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())
else -> call.respondText("no channel available")
}
is Either.Left -> call.respondText(res.value.message.toString())
}
}
post("closechannel") {
val formParameters = call.receiveParameters()
val channelId = formParameters.getByteVector32("channelId")
val scriptPubKey = formParameters.getAddressAndConvertToScript("address")
val feerate = FeeratePerKw(FeeratePerByte(formParameters.getLong("feerateSatByte").sat))
peer.send(WrappedChannelCommand(channelId, ChannelCommand.Close.MutualClose(scriptPubKey, ClosingFeerates(feerate))))
call.respondText("ok")
}
webSocket("/websocket") {
try {
eventsFlow.collect { sendSerialized(it) }
} catch (e: Throwable) {
println("onError ${closeReason.await()}")
}
}
}
}
webhookUrl?.let { url ->
val client = HttpClient(io.ktor.client.engine.cio.CIO) {
install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) {
json(json = Json {
prettyPrint = true
isLenient = true
})
}
}
launch {
eventsFlow.collect { event ->
client.post(url) {
contentType(ContentType.Application.Json)
setBody(event)
}
}
}
}
}
private fun missing(argName: String): Nothing = throw MissingRequestParameterException(argName)
private fun invalidType(argName: String, typeName: String): Nothing = throw ParameterConversionException(argName, typeName)
private fun Parameters.getString(argName: String): String = (this[argName] ?: missing(argName))
private fun Parameters.getByteVector32(argName: String): ByteVector32 = getString(argName).let { hex -> kotlin.runCatching { ByteVector32.fromValidHex(hex) }.getOrNull() ?: invalidType(argName, "hex32") }
private fun Parameters.getAddressAndConvertToScript(argName: String): ByteVector = Script.write(Bitcoin.addressToPublicKeyScript(nodeParams.chainHash, getString(argName)).right ?: error("invalid address")).toByteVector()
private fun Parameters.getInvoice(argName: String): Bolt11Invoice = getString(argName).let { invoice -> Bolt11Invoice.read(invoice).getOrElse { invalidType(argName, "bolt11invoice") } }
private fun Parameters.getLong(argName: String): Long = ((this[argName] ?: missing(argName)).toLongOrNull()) ?: invalidType(argName, "integer")
}

View File

@@ -0,0 +1,9 @@
package fr.acinq.lightning.bin
import app.cash.sqldelight.db.SqlDriver
import okio.Path
expect val homeDirectory: Path
expect fun createAppDbDriver(dir: Path): SqlDriver
expect fun createPaymentsDbDriver(dir: Path): SqlDriver

View File

@@ -0,0 +1,135 @@
package fr.acinq.lightning.bin
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Crypto
import fr.acinq.bitcoin.TxId
import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.channel.ChannelException
import fr.acinq.lightning.db.*
import fr.acinq.lightning.payment.FinalFailure
import fr.acinq.lightning.payment.OutgoingPaymentFailure
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.toByteVector32
import fr.acinq.lightning.wire.FailureMessage
class InMemoryPaymentsDb : PaymentsDb {
private val incoming = mutableMapOf<ByteVector32, IncomingPayment>()
private val outgoing = mutableMapOf<UUID, LightningOutgoingPayment>()
private val outgoingParts = mutableMapOf<UUID, Pair<UUID, LightningOutgoingPayment.Part>>()
override suspend fun setLocked(txId: TxId) {}
override suspend fun addIncomingPayment(preimage: ByteVector32, origin: IncomingPayment.Origin, createdAt: Long): IncomingPayment {
val paymentHash = Crypto.sha256(preimage).toByteVector32()
require(!incoming.contains(paymentHash)) { "an incoming payment for $paymentHash already exists" }
val incomingPayment = IncomingPayment(preimage, origin, null, createdAt)
incoming[paymentHash] = incomingPayment
return incomingPayment
}
override suspend fun getIncomingPayment(paymentHash: ByteVector32): IncomingPayment? = incoming[paymentHash]
override suspend fun receivePayment(paymentHash: ByteVector32, receivedWith: List<IncomingPayment.ReceivedWith>, receivedAt: Long) {
when (val payment = incoming[paymentHash]) {
null -> Unit // no-op
else -> incoming[paymentHash] = run {
payment.copy(
received = IncomingPayment.Received(
receivedWith = (payment.received?.receivedWith ?: emptySet()) + receivedWith,
receivedAt = receivedAt
)
)
}
}
}
fun listIncomingPayments(count: Int, skip: Int): List<IncomingPayment> =
incoming.values
.asSequence()
.sortedByDescending { it.createdAt }
.drop(skip)
.take(count)
.toList()
override suspend fun listExpiredPayments(fromCreatedAt: Long, toCreatedAt: Long): List<IncomingPayment> =
incoming.values
.asSequence()
.filter { it.createdAt in fromCreatedAt until toCreatedAt }
.filter { it.isExpired() }
.filter { it.received == null }
.sortedByDescending { it.createdAt }
.toList()
override suspend fun removeIncomingPayment(paymentHash: ByteVector32): Boolean {
val payment = getIncomingPayment(paymentHash)
return when (payment?.received) {
null -> incoming.remove(paymentHash) != null
else -> false // do nothing if payment already partially paid
}
}
override suspend fun addOutgoingPayment(outgoingPayment: OutgoingPayment) {
require(!outgoing.contains(outgoingPayment.id)) { "an outgoing payment with id=${outgoingPayment.id} already exists" }
when (outgoingPayment) {
is LightningOutgoingPayment -> {
outgoingPayment.parts.forEach { require(!outgoingParts.contains(it.id)) { "an outgoing payment part with id=${it.id} already exists" } }
outgoing[outgoingPayment.id] = outgoingPayment.copy(parts = listOf())
outgoingPayment.parts.forEach { outgoingParts[it.id] = Pair(outgoingPayment.id, it) }
}
is OnChainOutgoingPayment -> {} // we don't persist on-chain payments
}
}
override suspend fun getLightningOutgoingPayment(id: UUID): LightningOutgoingPayment? {
return outgoing[id]?.let { payment ->
val parts = outgoingParts.values.filter { it.first == payment.id }.map { it.second }
return when (payment.status) {
is LightningOutgoingPayment.Status.Completed.Succeeded -> payment.copy(parts = parts.filter { it.status is LightningOutgoingPayment.Part.Status.Succeeded })
else -> payment.copy(parts = parts)
}
}
}
override suspend fun completeOutgoingPaymentOffchain(id: UUID, preimage: ByteVector32, completedAt: Long) {
require(outgoing.contains(id)) { "outgoing payment with id=$id doesn't exist" }
val payment = outgoing[id]!!
outgoing[id] = payment.copy(status = LightningOutgoingPayment.Status.Completed.Succeeded.OffChain(preimage = preimage, completedAt = completedAt))
}
override suspend fun completeOutgoingPaymentOffchain(id: UUID, finalFailure: FinalFailure, completedAt: Long) {
require(outgoing.contains(id)) { "outgoing payment with id=$id doesn't exist" }
val payment = outgoing[id]!!
outgoing[id] = payment.copy(status = LightningOutgoingPayment.Status.Completed.Failed(reason = finalFailure, completedAt = completedAt))
}
override suspend fun addOutgoingLightningParts(parentId: UUID, parts: List<LightningOutgoingPayment.Part>) {
require(outgoing.contains(parentId)) { "parent outgoing payment with id=$parentId doesn't exist" }
parts.forEach { require(!outgoingParts.contains(it.id)) { "an outgoing payment part with id=${it.id} already exists" } }
parts.forEach { outgoingParts[it.id] = Pair(parentId, it) }
}
override suspend fun completeOutgoingLightningPart(partId: UUID, failure: Either<ChannelException, FailureMessage>, completedAt: Long) {
require(outgoingParts.contains(partId)) { "outgoing payment part with id=$partId doesn't exist" }
val (parentId, part) = outgoingParts[partId]!!
outgoingParts[partId] = Pair(parentId, part.copy(status = OutgoingPaymentFailure.convertFailure(failure, completedAt)))
}
override suspend fun completeOutgoingLightningPart(partId: UUID, preimage: ByteVector32, completedAt: Long) {
require(outgoingParts.contains(partId)) { "outgoing payment part with id=$partId doesn't exist" }
val (parentId, part) = outgoingParts[partId]!!
outgoingParts[partId] = Pair(parentId, part.copy(status = LightningOutgoingPayment.Part.Status.Succeeded(preimage, completedAt)))
}
override suspend fun getLightningOutgoingPaymentFromPartId(partId: UUID): LightningOutgoingPayment? {
return outgoingParts[partId]?.let { (parentId, _) ->
require(outgoing.contains(parentId)) { "parent outgoing payment with id=$parentId doesn't exist" }
getLightningOutgoingPayment(parentId)
}
}
override suspend fun listLightningOutgoingPayments(paymentHash: ByteVector32): List<LightningOutgoingPayment> {
return outgoing.values.filter { it.paymentHash == paymentHash }.map { payment ->
val parts = outgoingParts.values.filter { it.first == payment.id }.map { it.second }
payment.copy(parts = parts)
}
}
}

View File

@@ -0,0 +1,339 @@
package fr.acinq.lightning.bin
import co.touchlab.kermit.CommonWriter
import co.touchlab.kermit.Severity
import co.touchlab.kermit.StaticConfig
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.context
import com.github.ajalt.clikt.core.terminal
import com.github.ajalt.clikt.output.MordantHelpFormatter
import com.github.ajalt.clikt.parameters.groups.OptionGroup
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.choice
import com.github.ajalt.clikt.parameters.types.int
import com.github.ajalt.clikt.parameters.types.restrictTo
import com.github.ajalt.clikt.sources.MapValueSource
import com.github.ajalt.mordant.rendering.TextColors.*
import com.github.ajalt.mordant.rendering.TextStyles.bold
import com.github.ajalt.mordant.rendering.TextStyles.underline
import fr.acinq.bitcoin.Chain
import fr.acinq.lightning.*
import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.bin.conf.LSP
import fr.acinq.lightning.bin.conf.getOrGenerateSeed
import fr.acinq.lightning.bin.conf.readConfFile
import fr.acinq.lightning.bin.db.SqliteChannelsDb
import fr.acinq.lightning.bin.db.SqlitePaymentsDb
import fr.acinq.lightning.bin.json.ApiType
import fr.acinq.lightning.bin.logs.FileLogWriter
import fr.acinq.lightning.blockchain.electrum.ElectrumClient
import fr.acinq.lightning.blockchain.electrum.ElectrumConnectionStatus
import fr.acinq.lightning.blockchain.electrum.ElectrumWatcher
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.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.ServerAddress
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import okio.FileSystem
import okio.buffer
import okio.use
import kotlin.system.exitProcess
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
fun main(args: Array<String>) = Phoenixd().main(args)
class LiquidityOptions : OptionGroup(name = "Liquidity Options") {
val autoLiquidity by option("--auto-liquidity", help = "Amount automatically requested when inbound liquidity is needed").choice(
"off" to 0.sat,
"2m" to 2_000_000.sat,
"5m" to 5_000_000.sat,
"10m" to 10_000_000.sat,
).default(2_000_000.sat)
val maxAbsoluteFee by option("--max-absolute-fee", help = "Max absolute fee for on-chain operations. Includes mining fee and service fee for auto-liquidity.")
.int().convert { it.sat }
.restrictTo(5_000.sat..100_000.sat)
.default(40_000.sat) // with a default auto-liquidity of 2m sat, that's a max total fee of 2%
val maxRelativeFeeBasisPoint by option("--max-relative-fee-percent", help = "Max relative fee for on-chain operations in percent.", hidden = true)
.int()
.restrictTo(1..50)
.default(30)
val maxFeeCredit by option("--max-fee-credit", help = "Max fee credit, if reached payments will be rejected.", hidden = true)
.int().convert { it.sat }
.restrictTo(0.sat..100_000.sat)
.default(100_000.sat)
}
class Phoenixd : CliktCommand() {
//private val datadir by option("--datadir", help = "Data directory").convert { it.toPath() }.default(homeDirectory / ".phoenix", defaultForHelp = "~/.phoenix")
private val datadir = homeDirectory / ".phoenix"
private val confFile = datadir / "phoenix.conf"
private val chain by option("--chain", help = "Bitcoin chain to use").choice(
"mainnet" to Chain.Mainnet, "testnet" to Chain.Testnet
).default(Chain.Testnet, defaultForHelp = "testnet")
private val customElectrumServer by option("--electrum-server", "-e", help = "Custom Electrum server")
.convert { it.split(":").run { ServerAddress(first(), last().toInt(), TcpSocket.TLS.DISABLED) } }
private val httpBindIp by option("--http-bind-ip", help = "Bind ip for the http api").default("127.0.0.1")
private val httpBindPort by option("--http-bind-port", help = "Bind port for the http api").int().default(9740)
private val httpPassword by option("--http-password", help = "Password for the http api").defaultLazy {
// the additionalValues map already contains values in phoenix.conf, so if we are here then there are no existing password
terminal.print(yellow("Generating default api password..."))
val value = randomBytes32().toHex()
val confFile = datadir / "phoenix.conf"
FileSystem.SYSTEM.createDirectories(datadir)
FileSystem.SYSTEM.appendingSink(confFile, mustExist = false).buffer().use { it.writeUtf8("\nhttp-password=$value\n") }
terminal.println(white("done"))
value
}
private val webHookUrl by option("--webhook", help = "Webhook http endpoint for push notifications (alternative to websocket)")
.convert { Url(it) }
private val liquidityOptions by LiquidityOptions()
private val verbose: Boolean by option("--verbose", help = "Verbose mode").flag(default = false)
private val silent: Boolean by option("--silent", "-s", help = "Silent mode").flag(default = false)
init {
context {
valueSource = MapValueSource(readConfFile(confFile))
helpFormatter = { MordantHelpFormatter(it, showDefaultValues = true) }
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun run() {
FileSystem.SYSTEM.createDirectories(datadir)
val (seed, new) = getOrGenerateSeed(datadir)
if (new) {
runBlocking {
terminal.print(yellow("Generating new seed..."))
delay(500.milliseconds)
terminal.println(white("done"))
terminal.println()
terminal.println(green("Backup"))
terminal.println("This software is self-custodial, you have full control and responsibility over your funds.")
terminal.println("Your 12-words seed is located in ${FileSystem.SYSTEM.canonicalize(datadir)}, ${bold(red("make sure to do a backup or you risk losing your funds"))}.")
terminal.println()
terminal.println(green("How does it work?"))
terminal.println(
"""
When receiving a Lightning payment that doesn't fit within your existing channel, then:
- If the payment amount is large enough to cover mining fees and service fees for automated liquidity, then your channel will be created or enlarged right away.
- If the payment is too small, then the full amount is added to your fee credit. This credit will be used later to pay for future fees. ${bold(red("The fee credit is non-refundable"))}.
""".trimIndent()
)
terminal.println()
terminal.println(
gray(
"""
Examples:
With the default settings, and assuming that current mining fees are 10k sat. The total fee for a
liquidity operation will be 10k sat (mining fee) + 20k sat (service fee for the 2m sat liquidity) = 30k sat.
${(underline + gray)("scenario A")}: you receive a continuous stream of tiny 100 sat payments
a) the first 299 incoming payments will be added to your fee credit
b) when receiving the 300th payment, a 2m sat channel will be created, with balance 0 sat on your side
c) the next 20 thousands payments will be received in your channel
d) back to a)
${(underline + gray)("scenario B")}: you receive a continuous stream of 50k sat payments
a) when receiving the first payment, a 1M sat channel will be created with balance 50k-30k=20k sat on your side
b) the next next 40 payments will be received in your channel, at that point balance is 2m sat
c) back to a)
In both scenarios, the total average fee is the same: 30k/2m = 1.5%.
You can reduce this average fee further, by choosing a higher liquidity amount (option ${bold(white("--auto-liquidity"))}),
in exchange for higher upfront costs. The higher the liquidity amount, the less significant the cost of
mining fee in relative terms.
""".trimIndent()
)
)
terminal.println()
terminal.prompt("Please confirm by typing", choices = listOf("I understand that if I do not make a backup I risk losing my funds"), invalidChoiceMessage = "Please type those exact words:")
terminal.prompt(
"Please confirm by typing",
choices = listOf("I must not share the same seed with other phoenix instances (mobile or server) or I risk force closing my channels"),
invalidChoiceMessage = "Please type those exact words:"
)
terminal.prompt("Please confirm by typing", choices = listOf("I accept that the fee credit is non-refundable"), invalidChoiceMessage = "Please type those exact words:")
terminal.println()
}
}
echo(cyan("datadir: ${FileSystem.SYSTEM.canonicalize(datadir)}"))
echo(cyan("chain: $chain"))
echo(cyan("autoLiquidity: ${liquidityOptions.autoLiquidity}"))
val scope = GlobalScope
val loggerFactory = LoggerFactory(
StaticConfig(minSeverity = Severity.Info, logWriterList = buildList {
// always log to file
add(FileLogWriter(datadir / "phoenix.log", scope))
// only log to console if verbose mode is enabled
if (verbose) add(CommonWriter())
})
)
val electrumServer = customElectrumServer ?: when (chain) {
Chain.Mainnet -> ServerAddress("electrum.acinq.co", 50001, TcpSocket.TLS.DISABLED)
Chain.Testnet -> ServerAddress("testnet1.electrum.acinq.co", 51001, TcpSocket.TLS.DISABLED)
else -> error("unsupported chain")
}
val lsp = LSP.from(chain)
val liquidityPolicy = LiquidityPolicy.Auto(
maxAbsoluteFee = liquidityOptions.maxAbsoluteFee,
maxRelativeFeeBasisPoints = liquidityOptions.maxRelativeFeeBasisPoint,
skipAbsoluteFeeCheck = false,
maxAllowedCredit = liquidityOptions.maxFeeCredit
)
val keyManager = LocalKeyManager(seed, chain, lsp.swapInXpub)
val nodeParams = NodeParams(chain, loggerFactory, keyManager)
.run {
copy(
zeroConfPeers = setOf(lsp.walletParams.trampolineNode.id),
liquidityPolicy = MutableStateFlow(liquidityPolicy),
features = features.copy(
activated = buildMap {
putAll(features.activated)
put(Feature.FeeCredit, FeatureSupport.Optional)
}
)
)
}
echo(cyan("nodeid: ${nodeParams.nodeId}"))
val electrum = ElectrumClient(scope, loggerFactory)
val paymentsDb = SqlitePaymentsDb(loggerFactory, createPaymentsDbDriver(datadir))
val peer = Peer(
nodeParams = nodeParams, walletParams = lsp.walletParams, watcher = ElectrumWatcher(electrum, scope, loggerFactory), db = object : Databases {
override val channels: ChannelsDb
get() = SqliteChannelsDb(createAppDbDriver(datadir))
override val payments: PaymentsDb
get() = paymentsDb
}, socketBuilder = TcpSocket.Builder(), scope
)
val eventsFlow: SharedFlow<ApiType.ApiEvent> = MutableSharedFlow<ApiType.ApiEvent>().run {
scope.launch {
launch {
nodeParams.nodeEvents
.collect {
when {
it is PaymentEvents.PaymentReceived && it.amount > 0.msat -> emit(ApiType.PaymentReceived(it))
else -> {}
}
}
}
launch {
peer.eventsFlow
.collect {
when {
it is fr.acinq.lightning.io.PaymentSent -> emit(ApiType.PaymentSent(it))
else -> {}
}
}
}
}
asSharedFlow()
}
val listeners = scope.launch {
launch {
// drop initial CLOSED event
electrum.connectionStatus.dropWhile { it is ElectrumConnectionStatus.Closed }.collect {
when (it) {
is ElectrumConnectionStatus.Connecting -> echo(yellow("connecting to electrum server..."))
is ElectrumConnectionStatus.Connected -> echo(yellow("connected to electrum server"))
is ElectrumConnectionStatus.Closed -> echo(yellow("disconnected from electrum server"))
}
}
}
launch {
// drop initial CLOSED event
peer.connectionState.dropWhile { it is Connection.CLOSED }.collect {
when (it) {
Connection.ESTABLISHING -> echo(yellow("connecting to lightning peer..."))
Connection.ESTABLISHED -> echo(yellow("connected to lightning peer"))
is Connection.CLOSED -> echo(yellow("disconnected from lightning peer"))
}
}
}
launch {
nodeParams.nodeEvents
.filterIsInstance<PaymentEvents.PaymentReceived>()
.filter { it.amount > 0.msat }
.collect {
echo("received lightning payment: ${it.amount.truncateToSatoshi()} (${it.receivedWith.joinToString { part -> part::class.simpleName.toString().lowercase() }})")
}
}
launch {
nodeParams.nodeEvents
.filterIsInstance<LiquidityEvents.Decision.Rejected>()
.collect {
echo(yellow("lightning payment rejected: amount=${it.amount.truncateToSatoshi()} fee=${it.fee.truncateToSatoshi()} maxFee=${liquidityPolicy.maxAbsoluteFee}"))
}
}
launch {
nodeParams.feeCredit
.drop(1) // we drop the initial value which is 0 sat
.collect { feeCredit -> echo("fee credit: $feeCredit") }
}
}
runBlocking {
electrum.connect(electrumServer, TcpSocket.Builder())
peer.connect(connectTimeout = 10.seconds, handshakeTimeout = 10.seconds)
peer.connectionState.first { it == Connection.ESTABLISHED }
peer.registerFcmToken("super-${randomBytes32().toHex()}")
peer.setAutoLiquidityParams(liquidityOptions.autoLiquidity)
}
val server = embeddedServer(CIO, port = httpBindPort, host = httpBindIp,
configure = {
reuseAddress = true
},
module = {
Api(nodeParams, peer, eventsFlow, httpPassword, webHookUrl).run { module() }
}
)
val serverJob = scope.launch {
try {
server.start(wait = true)
} catch (t: Throwable) {
if (t.cause?.message?.contains("Address already in use") == true) {
echo(t.cause?.message, err = true)
} else throw t
}
}
server.environment.monitor.subscribe(ServerReady) {
echo("listening on http://$httpBindIp:$httpBindPort")
}
server.environment.monitor.subscribe(ApplicationStopPreparing) {
echo(brightYellow("shutting down..."))
electrum.stop()
peer.disconnect()
server.stop()
listeners.cancel()
exitProcess(0)
}
server.environment.monitor.subscribe(ApplicationStopped) { echo(brightYellow("http server stopped")) }
runBlocking { serverJob.join() }
}
}

View File

@@ -0,0 +1,19 @@
package fr.acinq.lightning.bin.conf
import okio.FileSystem
import okio.Path
fun readConfFile(confFile: Path): Map<String, String> = try {
buildMap {
if (FileSystem.SYSTEM.exists(confFile)) {
FileSystem.SYSTEM.read(confFile) {
while (true) {
val line = readUtf8Line() ?: break
line.split("=").run { put(first(), last()) }
}
}
}
}
} catch (t: Throwable) {
emptyMap()
}

View File

@@ -0,0 +1,79 @@
package fr.acinq.lightning.bin.conf
import fr.acinq.bitcoin.Chain
import fr.acinq.bitcoin.PublicKey
import fr.acinq.lightning.*
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
data class LSP(val walletParams: WalletParams, val swapInXpub: String) {
companion object {
private val trampolineFees = listOf(
TrampolineFees(
feeBase = 4.sat,
feeProportional = 4_000,
cltvExpiryDelta = CltvExpiryDelta(576)
)
)
private val invoiceDefaultRoutingFees = InvoiceDefaultRoutingFees(
feeBase = 1_000.msat,
feeProportional = 100,
cltvExpiryDelta = CltvExpiryDelta(144)
)
private val swapInParams = SwapInParams(
minConfirmations = DefaultSwapInParams.MinConfirmations,
maxConfirmations = DefaultSwapInParams.MaxConfirmations,
refundDelay = DefaultSwapInParams.RefundDelay,
)
fun from(chain: Chain) = when (chain) {
is Chain.Mainnet -> LSP(
swapInXpub = "xpub69q3sDXXsLuHVbmTrhqmEqYqTTsXJKahdfawXaYuUt6muf1PbZBnvqzFcwiT8Abpc13hY8BFafakwpPbVkatg9egwiMjed1cRrPM19b2Ma7",
walletParams = WalletParams(
trampolineNode = NodeUri(PublicKey.fromHex("03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"), "3.33.236.230", 9735),
trampolineFees,
invoiceDefaultRoutingFees,
swapInParams
)
)
is Chain.Testnet -> LSP(
swapInXpub = "tpubDAmCFB21J9ExKBRPDcVxSvGs9jtcf8U1wWWbS1xTYmnUsuUHPCoFdCnEGxLE3THSWcQE48GHJnyz8XPbYUivBMbLSMBifFd3G9KmafkM9og",
walletParams = WalletParams(
trampolineNode = NodeUri(PublicKey.fromHex("03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134"), "13.248.222.197", 9735),
trampolineFees,
invoiceDefaultRoutingFees,
swapInParams
)
)
else -> error("unsupported chain $chain")
}
// 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
// )
// }
}
}

View File

@@ -0,0 +1,24 @@
package fr.acinq.lightning.bin.conf
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.MnemonicCode
import fr.acinq.lightning.Lightning.randomBytes
import fr.acinq.lightning.utils.toByteVector
import okio.FileSystem
import okio.Path
/**
* @return a pair with the seed and a boolean indicating whether the seed was newly generated
*/
fun getOrGenerateSeed(dir: Path): Pair<ByteVector, Boolean> {
val file = dir / "seed.dat"
val (mnemonics, new) = if (FileSystem.SYSTEM.exists(file)) {
FileSystem.SYSTEM.read(file) { readUtf8() } to false
} else {
val entropy = randomBytes(16)
val mnemonics = MnemonicCode.toMnemonics(entropy).joinToString(" ")
FileSystem.SYSTEM.write(file) { writeUtf8(mnemonics) }
mnemonics to true
}
return MnemonicCode.toSeed(mnemonics, "").toByteVector() to new
}

View File

@@ -0,0 +1,70 @@
package fr.acinq.lightning.bin.db
import app.cash.sqldelight.db.SqlDriver
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.lightning.CltvExpiry
import fr.acinq.lightning.channel.states.PersistedChannelState
import fr.acinq.lightning.db.ChannelsDb
import fr.acinq.lightning.serialization.Serialization
import fr.acinq.phoenix.db.ChannelsDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal class SqliteChannelsDb(private val driver: SqlDriver) : ChannelsDb {
private val database = ChannelsDatabase(driver)
private val queries = database.channelsDatabaseQueries
override suspend fun addOrUpdateChannel(state: PersistedChannelState) {
val channelId = state.channelId.toByteArray()
val data = Serialization.serialize(state)
withContext(Dispatchers.Default) {
queries.transaction {
queries.getChannel(channelId).executeAsOneOrNull()?.run {
queries.updateChannel(channel_id = this.channel_id, data_ = data)
} ?: run {
queries.insertChannel(channel_id = channelId, data_ = data)
}
}
}
}
override suspend fun removeChannel(channelId: ByteVector32) {
withContext(Dispatchers.Default) {
queries.deleteHtlcInfo(channel_id = channelId.toByteArray())
queries.closeLocalChannel(channel_id = channelId.toByteArray())
}
}
override suspend fun listLocalChannels(): List<PersistedChannelState> = withContext(Dispatchers.Default) {
val bytes = queries.listLocalChannels().executeAsList()
bytes.mapNotNull {
when (val res = Serialization.deserialize(it)) {
is Serialization.DeserializationResult.Success -> res.state
is Serialization.DeserializationResult.UnknownVersion -> null
}
}
}
override suspend fun addHtlcInfo(channelId: ByteVector32, commitmentNumber: Long, paymentHash: ByteVector32, cltvExpiry: CltvExpiry) {
withContext(Dispatchers.Default) {
queries.insertHtlcInfo(
channel_id = channelId.toByteArray(),
commitment_number = commitmentNumber,
payment_hash = paymentHash.toByteArray(),
cltv_expiry = cltvExpiry.toLong())
}
}
override suspend fun listHtlcInfos(channelId: ByteVector32, commitmentNumber: Long): List<Pair<ByteVector32, CltvExpiry>> {
return withContext(Dispatchers.Default) {
queries.listHtlcInfos(channel_id = channelId.toByteArray(), commitment_number = commitmentNumber, mapper = { payment_hash, cltv_expiry ->
ByteVector32(payment_hash) to CltvExpiry(cltv_expiry)
}).executeAsList()
}
}
override fun close() {
driver.close()
}
}

View File

@@ -0,0 +1,289 @@
/*
* Copyright 2020 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db
import app.cash.sqldelight.EnumColumnAdapter
import app.cash.sqldelight.db.SqlDriver
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Crypto
import fr.acinq.bitcoin.TxId
import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.bin.db.payments.*
import fr.acinq.lightning.bin.db.payments.LinkTxToPaymentQueries
import fr.acinq.lightning.channel.ChannelException
import fr.acinq.lightning.db.*
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.payment.FinalFailure
import fr.acinq.lightning.utils.*
import fr.acinq.lightning.wire.FailureMessage
import fr.acinq.phoenix.db.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
class SqlitePaymentsDb(
loggerFactory: LoggerFactory,
private val driver: SqlDriver,
) : PaymentsDb {
private val log = loggerFactory.newLogger(this::class)
private val database = PaymentsDatabase(
driver = driver,
outgoing_payment_partsAdapter = Outgoing_payment_parts.Adapter(
part_routeAdapter = OutgoingQueries.hopDescAdapter,
part_status_typeAdapter = EnumColumnAdapter()
),
outgoing_paymentsAdapter = Outgoing_payments.Adapter(
status_typeAdapter = EnumColumnAdapter(),
details_typeAdapter = EnumColumnAdapter()
),
incoming_paymentsAdapter = Incoming_payments.Adapter(
origin_typeAdapter = EnumColumnAdapter(),
received_with_typeAdapter = EnumColumnAdapter()
),
outgoing_payment_closing_tx_partsAdapter = Outgoing_payment_closing_tx_parts.Adapter(
part_closing_info_typeAdapter = EnumColumnAdapter()
),
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()
)
)
internal val inQueries = IncomingQueries(database)
internal val outQueries = OutgoingQueries(database)
private val spliceOutQueries = SpliceOutgoingQueries(database)
private val channelCloseQueries = ChannelCloseOutgoingQueries(database)
private val cpfpQueries = SpliceCpfpOutgoingQueries(database)
private val linkTxToPaymentQueries = LinkTxToPaymentQueries(database)
private val inboundLiquidityQueries = InboundLiquidityQueries(database)
override suspend fun addOutgoingLightningParts(
parentId: UUID,
parts: List<LightningOutgoingPayment.Part>
) {
withContext(Dispatchers.Default) {
outQueries.addLightningParts(parentId, parts)
}
}
override suspend fun addOutgoingPayment(
outgoingPayment: OutgoingPayment
) {
withContext(Dispatchers.Default) {
database.transaction {
when (outgoingPayment) {
is LightningOutgoingPayment -> {
outQueries.addLightningOutgoingPayment(outgoingPayment)
}
is SpliceOutgoingPayment -> {
spliceOutQueries.addSpliceOutgoingPayment(outgoingPayment)
linkTxToPaymentQueries.linkTxToPayment(
txId = outgoingPayment.txId,
walletPaymentId = outgoingPayment.walletPaymentId()
)
}
is ChannelCloseOutgoingPayment -> {
channelCloseQueries.addChannelCloseOutgoingPayment(outgoingPayment)
linkTxToPaymentQueries.linkTxToPayment(
txId = outgoingPayment.txId,
walletPaymentId = outgoingPayment.walletPaymentId()
)
}
is SpliceCpfpOutgoingPayment -> {
cpfpQueries.addCpfpPayment(outgoingPayment)
linkTxToPaymentQueries.linkTxToPayment(outgoingPayment.txId, outgoingPayment.walletPaymentId())
}
is InboundLiquidityOutgoingPayment -> {
inboundLiquidityQueries.add(outgoingPayment)
linkTxToPaymentQueries.linkTxToPayment(outgoingPayment.txId, outgoingPayment.walletPaymentId())
}
}
}
}
}
override suspend fun completeOutgoingPaymentOffchain(
id: UUID,
preimage: ByteVector32,
completedAt: Long
) {
withContext(Dispatchers.Default) {
outQueries.completePayment(id, LightningOutgoingPayment.Status.Completed.Succeeded.OffChain(preimage, completedAt))
}
}
override suspend fun completeOutgoingPaymentOffchain(
id: UUID,
finalFailure: FinalFailure,
completedAt: Long
) {
withContext(Dispatchers.Default) {
outQueries.completePayment(id, LightningOutgoingPayment.Status.Completed.Failed(finalFailure, completedAt))
}
}
override suspend fun completeOutgoingLightningPart(
partId: UUID,
preimage: ByteVector32,
completedAt: Long
) {
withContext(Dispatchers.Default) {
outQueries.updateLightningPart(partId, preimage, completedAt)
}
}
override suspend fun completeOutgoingLightningPart(
partId: UUID,
failure: Either<ChannelException, FailureMessage>,
completedAt: Long
) {
withContext(Dispatchers.Default) {
outQueries.updateLightningPart(partId, failure, completedAt)
}
}
override suspend fun getLightningOutgoingPayment(id: UUID): LightningOutgoingPayment? = withContext(Dispatchers.Default) {
outQueries.getPaymentStrict(id)
}
override suspend fun getLightningOutgoingPaymentFromPartId(partId: UUID): LightningOutgoingPayment? = withContext(Dispatchers.Default) {
outQueries.getPaymentFromPartId(partId)
}
// ---- list outgoing
override suspend fun listLightningOutgoingPayments(
paymentHash: ByteVector32
): List<LightningOutgoingPayment> = withContext(Dispatchers.Default) {
outQueries.listLightningOutgoingPayments(paymentHash)
}
// ---- incoming payments
override suspend fun addIncomingPayment(
preimage: ByteVector32,
origin: IncomingPayment.Origin,
createdAt: Long
): IncomingPayment {
val paymentHash = Crypto.sha256(preimage).toByteVector32()
return withContext(Dispatchers.Default) {
database.transactionWithResult {
inQueries.addIncomingPayment(preimage, paymentHash, origin, createdAt)
inQueries.getIncomingPayment(paymentHash)!!
}
}
}
override suspend fun receivePayment(
paymentHash: ByteVector32,
receivedWith: List<IncomingPayment.ReceivedWith>,
receivedAt: Long
) {
withContext(Dispatchers.Default) {
database.transaction {
inQueries.receivePayment(paymentHash, receivedWith, receivedAt)
// if one received-with is on-chain, save the tx id to the db
receivedWith.filterIsInstance<IncomingPayment.ReceivedWith.OnChainIncomingPayment>().firstOrNull()?.let {
linkTxToPaymentQueries.linkTxToPayment(it.txId, WalletPaymentId.IncomingPaymentId(paymentHash))
}
}
}
}
override suspend fun setLocked(txId: TxId) {
database.transaction {
val lockedAt = currentTimestampMillis()
linkTxToPaymentQueries.setLocked(txId, lockedAt)
linkTxToPaymentQueries.listWalletPaymentIdsForTx(txId).forEach { walletPaymentId ->
when (walletPaymentId) {
is WalletPaymentId.IncomingPaymentId -> {
inQueries.setLocked(walletPaymentId.paymentHash, lockedAt)
}
is WalletPaymentId.LightningOutgoingPaymentId -> {
// LN payments need not be locked
}
is WalletPaymentId.SpliceOutgoingPaymentId -> {
spliceOutQueries.setLocked(walletPaymentId.id, lockedAt)
}
is WalletPaymentId.ChannelCloseOutgoingPaymentId -> {
channelCloseQueries.setLocked(walletPaymentId.id, lockedAt)
}
is WalletPaymentId.SpliceCpfpOutgoingPaymentId -> {
cpfpQueries.setLocked(walletPaymentId.id, lockedAt)
}
is WalletPaymentId.InboundLiquidityOutgoingPaymentId -> {
inboundLiquidityQueries.setLocked(walletPaymentId.id, lockedAt)
}
}
}
}
}
suspend fun setConfirmed(txId: TxId) = withContext(Dispatchers.Default) {
database.transaction {
val confirmedAt = currentTimestampMillis()
linkTxToPaymentQueries.setConfirmed(txId, confirmedAt)
linkTxToPaymentQueries.listWalletPaymentIdsForTx(txId).forEach { walletPaymentId ->
when (walletPaymentId) {
is WalletPaymentId.IncomingPaymentId -> {
inQueries.setConfirmed(walletPaymentId.paymentHash, confirmedAt)
}
is WalletPaymentId.LightningOutgoingPaymentId -> {
// LN payments need not be confirmed
}
is WalletPaymentId.SpliceOutgoingPaymentId -> {
spliceOutQueries.setConfirmed(walletPaymentId.id, confirmedAt)
}
is WalletPaymentId.ChannelCloseOutgoingPaymentId -> {
channelCloseQueries.setConfirmed(walletPaymentId.id, confirmedAt)
}
is WalletPaymentId.SpliceCpfpOutgoingPaymentId -> {
cpfpQueries.setConfirmed(walletPaymentId.id, confirmedAt)
}
is WalletPaymentId.InboundLiquidityOutgoingPaymentId -> {
inboundLiquidityQueries.setConfirmed(walletPaymentId.id, confirmedAt)
}
}
}
}
}
override suspend fun getIncomingPayment(
paymentHash: ByteVector32
): IncomingPayment? = withContext(Dispatchers.Default) {
inQueries.getIncomingPayment(paymentHash)
}
override suspend fun listExpiredPayments(
fromCreatedAt: Long,
toCreatedAt: Long
): List<IncomingPayment> = withContext(Dispatchers.Default) {
inQueries.listExpiredPayments(fromCreatedAt, toCreatedAt)
}
override suspend fun removeIncomingPayment(
paymentHash: ByteVector32
): Boolean = withContext(Dispatchers.Default) {
inQueries.deleteIncomingPayment(paymentHash)
}
}

View File

@@ -0,0 +1,119 @@
package fr.acinq.lightning.bin.db
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.lightning.db.*
import fr.acinq.lightning.utils.UUID
/**
* Helper class that helps to link an actual payment to a unique string. This is useful to store a reference
* to a payment in a single TEXT sql column.
*
* e.g.: incoming|b50ccb7e52ecc6f25b21eb23c2efdd1cfdb973ca12c7db9eef3d818dcc9b437c
* This is a unique identifier for an [IncomingPayment] with paymentHash=b50ccb7...b437c.
*
* It is common to reference these rows in other database tables via [dbType] or [dbId].
*
* @param dbType Long representing either incoming or outgoing/splice-outgoing/...
* @param dbId String representing the appropriate id for either table (payment hash or UUID).
*/
sealed class WalletPaymentId {
abstract val dbType: DbType
abstract val dbId: String
/** Use this to get a single (hashable) identifier for the row, for example within a hashmap or Cache. */
abstract val identifier: String
data class IncomingPaymentId(val paymentHash: ByteVector32) : WalletPaymentId() {
override val dbType: DbType = DbType.INCOMING
override val dbId: String = paymentHash.toHex()
override val identifier: String = "incoming|$dbId"
companion object {
fun fromString(id: String) = IncomingPaymentId(paymentHash = ByteVector32(id))
fun fromByteArray(id: ByteArray) = IncomingPaymentId(paymentHash = ByteVector32(id))
}
}
data class LightningOutgoingPaymentId(val id: UUID) : WalletPaymentId() {
override val dbType: DbType = DbType.OUTGOING
override val dbId: String = id.toString()
override val identifier: String = "outgoing|$dbId"
companion object {
fun fromString(id: String) = LightningOutgoingPaymentId(id = UUID.fromString(id))
}
}
data class SpliceOutgoingPaymentId(val id: UUID) : WalletPaymentId() {
override val dbType: DbType = DbType.SPLICE_OUTGOING
override val dbId: String = id.toString()
override val identifier: String = "splice_outgoing|$dbId"
companion object {
fun fromString(id: String) = SpliceOutgoingPaymentId(id = UUID.fromString(id))
}
}
data class ChannelCloseOutgoingPaymentId(val id: UUID) : WalletPaymentId() {
override val dbType: DbType = DbType.CHANNEL_CLOSE_OUTGOING
override val dbId: String = id.toString()
override val identifier: String = "channel_close_outgoing|$dbId"
companion object {
fun fromString(id: String) = ChannelCloseOutgoingPaymentId(id = UUID.fromString(id))
}
}
data class SpliceCpfpOutgoingPaymentId(val id: UUID) : WalletPaymentId() {
override val dbType: DbType = DbType.SPLICE_CPFP_OUTGOING
override val dbId: String = id.toString()
override val identifier: String = "splice_cpfp_outgoing|$dbId"
companion object {
fun fromString(id: String) = SpliceCpfpOutgoingPaymentId(id = UUID.fromString(id))
}
}
data class InboundLiquidityOutgoingPaymentId(val id: UUID) : WalletPaymentId() {
override val dbType: DbType = DbType.INBOUND_LIQUIDITY_OUTGOING
override val dbId: String = id.toString()
override val identifier: String = "inbound_liquidity_outgoing|$dbId"
companion object {
fun fromString(id: String) = InboundLiquidityOutgoingPaymentId(id = UUID.fromString(id))
}
}
enum class DbType(val value: Long) {
INCOMING(1),
OUTGOING(2),
SPLICE_OUTGOING(3),
CHANNEL_CLOSE_OUTGOING(4),
SPLICE_CPFP_OUTGOING(5),
INBOUND_LIQUIDITY_OUTGOING(6),
}
companion object {
fun create(type: Long, id: String): WalletPaymentId? {
return when (type) {
DbType.INCOMING.value -> IncomingPaymentId.fromString(id)
DbType.OUTGOING.value -> LightningOutgoingPaymentId.fromString(id)
DbType.SPLICE_OUTGOING.value -> SpliceOutgoingPaymentId.fromString(id)
DbType.CHANNEL_CLOSE_OUTGOING.value -> ChannelCloseOutgoingPaymentId.fromString(id)
DbType.SPLICE_CPFP_OUTGOING.value -> SpliceCpfpOutgoingPaymentId.fromString(id)
DbType.INBOUND_LIQUIDITY_OUTGOING.value -> InboundLiquidityOutgoingPaymentId.fromString(id)
else -> null
}
}
}
}
fun WalletPayment.walletPaymentId(): WalletPaymentId = when (this) {
is IncomingPayment -> WalletPaymentId.IncomingPaymentId(paymentHash = this.paymentHash)
is LightningOutgoingPayment -> WalletPaymentId.LightningOutgoingPaymentId(id = this.id)
is SpliceOutgoingPayment -> WalletPaymentId.SpliceOutgoingPaymentId(id = this.id)
is ChannelCloseOutgoingPayment -> WalletPaymentId.ChannelCloseOutgoingPaymentId(id = this.id)
is SpliceCpfpOutgoingPayment -> WalletPaymentId.SpliceCpfpOutgoingPaymentId(id = this.id)
is InboundLiquidityOutgoingPayment -> WalletPaymentId.InboundLiquidityOutgoingPaymentId(id = this.id)
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2023 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.payments
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.db.ChannelCloseOutgoingPayment
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toByteVector32
import fr.acinq.phoenix.db.PaymentsDatabase
class ChannelCloseOutgoingQueries(val database: PaymentsDatabase) {
private val channelCloseQueries = database.channelCloseOutgoingPaymentsQueries
fun getChannelCloseOutgoingPayment(id: UUID): ChannelCloseOutgoingPayment? {
return channelCloseQueries.getChannelCloseOutgoing(id.toString(), Companion::mapChannelCloseOutgoingPayment).executeAsOneOrNull()
}
fun addChannelCloseOutgoingPayment(payment: ChannelCloseOutgoingPayment) {
val (closingInfoType, closingInfoBlob) = payment.mapClosingTypeToDb()
database.transaction {
channelCloseQueries.insertChannelCloseOutgoing(
id = payment.id.toString(),
recipient_amount_sat = payment.recipientAmount.sat,
address = payment.address,
is_default_address = if (payment.isSentToDefaultAddress) 1 else 0,
mining_fees_sat = payment.miningFees.sat,
tx_id = payment.txId.value.toByteArray(),
created_at = payment.createdAt,
confirmed_at = payment.confirmedAt,
locked_at = payment.lockedAt,
channel_id = payment.channelId.toByteArray(),
closing_info_type = closingInfoType,
closing_info_blob = closingInfoBlob,
)
}
}
fun setConfirmed(id: UUID, confirmedAt: Long) {
database.transaction {
channelCloseQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString())
}
}
fun setLocked(id: UUID, lockedAt: Long) {
database.transaction {
channelCloseQueries.setLocked(locked_at = lockedAt, id = id.toString())
}
}
companion object {
fun mapChannelCloseOutgoingPayment(
id: String,
amount_sat: Long,
address: String,
is_default_address: Long,
mining_fees_sat: Long,
tx_id: ByteArray,
created_at: Long,
confirmed_at: Long?,
locked_at: Long?,
channel_id: ByteArray,
closing_info_type: OutgoingPartClosingInfoTypeVersion,
closing_info_blob: ByteArray
): ChannelCloseOutgoingPayment {
return ChannelCloseOutgoingPayment(
id = UUID.fromString(id),
recipientAmount = amount_sat.sat,
address = address,
isSentToDefaultAddress = is_default_address == 1L,
miningFees = mining_fees_sat.sat,
txId = TxId(tx_id),
createdAt = created_at,
confirmedAt = confirmed_at,
lockedAt = locked_at,
channelId = channel_id.toByteVector32(),
closingType = OutgoingPartClosingInfoData.deserialize(closing_info_type, closing_info_blob),
)
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2021 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.payments
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
object DbTypesHelper {
/** Decode a byte array and apply a deserialization handler. */
fun <T> decodeBlob(blob: ByteArray, handler: (String, Json) -> T) = handler(String(bytes = blob, charset = Charsets.UTF_8), Json)
val module = SerializersModule {
polymorphic(IncomingReceivedWithData.Part::class) {
subclass(IncomingReceivedWithData.Part.Htlc.V0::class)
subclass(IncomingReceivedWithData.Part.NewChannel.V2::class)
subclass(IncomingReceivedWithData.Part.SpliceIn.V0::class)
subclass(IncomingReceivedWithData.Part.FeeCredit.V0::class)
}
}
val polymorphicFormat = Json { serializersModule = module }
}

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2023 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:UseSerializers(
ByteVectorSerializer::class,
ByteVector32Serializer::class,
ByteVector64Serializer::class,
SatoshiSerializer::class,
MilliSatoshiSerializer::class
)
package fr.acinq.lightning.bin.db.payments
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector64
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment
import fr.acinq.lightning.wire.LiquidityAds
import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer
import fr.acinq.lightning.bin.db.serializers.v1.ByteVector64Serializer
import fr.acinq.lightning.bin.db.serializers.v1.ByteVectorSerializer
import fr.acinq.lightning.bin.db.serializers.v1.MilliSatoshiSerializer
import fr.acinq.lightning.bin.db.serializers.v1.SatoshiSerializer
import io.ktor.utils.io.charsets.Charsets
import io.ktor.utils.io.core.toByteArray
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
enum class InboundLiquidityLeaseTypeVersion {
LEASE_V0,
}
sealed class InboundLiquidityLeaseData {
@Serializable
data class V0(
val amount: Satoshi,
val miningFees: Satoshi,
val serviceFee: Satoshi,
val sellerSig: ByteVector64,
val witnessFundingScript: ByteVector,
val witnessLeaseDuration: Int,
val witnessLeaseEnd: Int,
val witnessMaxRelayFeeProportional: Int,
val witnessMaxRelayFeeBase: MilliSatoshi
) : InboundLiquidityLeaseData()
companion object {
/** Deserializes a json-encoded blob containing data for an [LiquidityAds.Lease] object. */
fun deserialize(
typeVersion: InboundLiquidityLeaseTypeVersion,
blob: ByteArray,
): LiquidityAds.Lease = DbTypesHelper.decodeBlob(blob) { json, format ->
when (typeVersion) {
InboundLiquidityLeaseTypeVersion.LEASE_V0 -> format.decodeFromString<V0>(json).let {
LiquidityAds.Lease(
amount = it.amount,
fees = LiquidityAds.LeaseFees(miningFee = it.miningFees, serviceFee = it.serviceFee),
sellerSig = it.sellerSig,
witness = LiquidityAds.LeaseWitness(
fundingScript = it.witnessFundingScript,
leaseDuration = it.witnessLeaseDuration,
leaseEnd = it.witnessLeaseEnd,
maxRelayFeeProportional = it.witnessMaxRelayFeeProportional,
maxRelayFeeBase = it.witnessMaxRelayFeeBase,
)
)
}
}
}
}
}
fun InboundLiquidityOutgoingPayment.mapLeaseToDb() = InboundLiquidityLeaseTypeVersion.LEASE_V0 to
InboundLiquidityLeaseData.V0(
amount = lease.amount,
miningFees = lease.fees.miningFee,
serviceFee = lease.fees.serviceFee,
sellerSig = lease.sellerSig,
witnessFundingScript = lease.witness.fundingScript,
witnessLeaseDuration = lease.witness.leaseDuration,
witnessLeaseEnd = lease.witness.leaseEnd,
witnessMaxRelayFeeProportional = lease.witness.maxRelayFeeProportional,
witnessMaxRelayFeeBase = lease.witness.maxRelayFeeBase,
).let {
Json.encodeToString(it).toByteArray(Charsets.UTF_8)
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2023 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.payments
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toByteVector32
import fr.acinq.phoenix.db.PaymentsDatabase
class InboundLiquidityQueries(val database: PaymentsDatabase) {
private val queries = database.inboundLiquidityOutgoingQueries
fun add(payment: InboundLiquidityOutgoingPayment) {
database.transaction {
val (leaseType, leaseData) = payment.mapLeaseToDb()
queries.insert(
id = payment.id.toString(),
mining_fees_sat = payment.miningFees.sat,
channel_id = payment.channelId.toByteArray(),
tx_id = payment.txId.value.toByteArray(),
lease_type = leaseType,
lease_blob = leaseData,
created_at = payment.createdAt,
confirmed_at = payment.confirmedAt,
locked_at = payment.lockedAt,
)
}
}
fun get(id: UUID): InboundLiquidityOutgoingPayment? {
return queries.get(id = id.toString(), mapper = Companion::mapPayment)
.executeAsOneOrNull()
}
fun setConfirmed(id: UUID, confirmedAt: Long) {
database.transaction {
queries.setConfirmed(confirmed_at = confirmedAt, id = id.toString())
}
}
fun setLocked(id: UUID, lockedAt: Long) {
database.transaction {
queries.setLocked(locked_at = lockedAt, id = id.toString())
}
}
private companion object {
fun mapPayment(
id: String,
mining_fees_sat: Long,
channel_id: ByteArray,
tx_id: ByteArray,
lease_type: InboundLiquidityLeaseTypeVersion,
lease_blob: ByteArray,
created_at: Long,
confirmed_at: Long?,
locked_at: Long?
): InboundLiquidityOutgoingPayment {
return InboundLiquidityOutgoingPayment(
id = UUID.fromString(id),
miningFees = mining_fees_sat.sat,
channelId = channel_id.toByteVector32(),
txId = TxId(tx_id),
lease = InboundLiquidityLeaseData.deserialize(lease_type, lease_blob),
createdAt = created_at,
confirmedAt = confirmed_at,
lockedAt = locked_at
)
}
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright 2021 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:UseSerializers(
OutpointSerializer::class,
ByteVector32Serializer::class,
)
package fr.acinq.lightning.bin.db.payments
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.OutPoint
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.bin.db.payments.DbTypesHelper.decodeBlob
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.lightning.payment.Bolt11Invoice
import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer
import fr.acinq.lightning.bin.db.serializers.v1.OutpointSerializer
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
enum class IncomingOriginTypeVersion {
KEYSEND_V0,
INVOICE_V0,
SWAPIN_V0,
ONCHAIN_V0,
}
sealed class IncomingOriginData {
sealed class KeySend : IncomingOriginData() {
@Serializable
@SerialName("KEYSEND_V0")
object V0 : KeySend()
}
sealed class Invoice : IncomingOriginData() {
@Serializable
data class V0(val paymentRequest: String) : Invoice()
}
sealed class SwapIn : IncomingOriginData() {
@Serializable
data class V0(val address: String?) : SwapIn()
}
sealed class OnChain : IncomingOriginData() {
@Serializable
data class V0(@Serializable val txId: ByteVector32, val outpoints: List<@Serializable OutPoint>) : SwapIn()
}
companion object {
fun deserialize(typeVersion: IncomingOriginTypeVersion, blob: ByteArray): IncomingPayment.Origin = decodeBlob(blob) { json, format ->
when (typeVersion) {
IncomingOriginTypeVersion.KEYSEND_V0 -> IncomingPayment.Origin.KeySend
IncomingOriginTypeVersion.INVOICE_V0 -> format.decodeFromString<Invoice.V0>(json).let { IncomingPayment.Origin.Invoice(Bolt11Invoice.read(it.paymentRequest).get()) }
IncomingOriginTypeVersion.SWAPIN_V0 -> format.decodeFromString<SwapIn.V0>(json).let { IncomingPayment.Origin.SwapIn(it.address) }
IncomingOriginTypeVersion.ONCHAIN_V0 -> format.decodeFromString<OnChain.V0>(json).let {
IncomingPayment.Origin.OnChain(TxId(it.txId), it.outpoints.toSet())
}
}
}
}
}
fun IncomingPayment.Origin.mapToDb(): Pair<IncomingOriginTypeVersion, ByteArray> = when (this) {
is IncomingPayment.Origin.KeySend -> IncomingOriginTypeVersion.KEYSEND_V0 to
Json.encodeToString(IncomingOriginData.KeySend.V0).toByteArray(Charsets.UTF_8)
is IncomingPayment.Origin.Invoice -> IncomingOriginTypeVersion.INVOICE_V0 to
Json.encodeToString(IncomingOriginData.Invoice.V0(paymentRequest.write())).toByteArray(Charsets.UTF_8)
is IncomingPayment.Origin.SwapIn -> IncomingOriginTypeVersion.SWAPIN_V0 to
Json.encodeToString(IncomingOriginData.SwapIn.V0(address)).toByteArray(Charsets.UTF_8)
is IncomingPayment.Origin.OnChain -> IncomingOriginTypeVersion.ONCHAIN_V0 to
Json.encodeToString(IncomingOriginData.OnChain.V0(txId.value, localInputs.toList())).toByteArray(Charsets.UTF_8)
}

View File

@@ -0,0 +1,201 @@
/*
* Copyright 2021 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.payments
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.byteVector32
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.lightning.utils.msat
import fr.acinq.phoenix.db.PaymentsDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
class IncomingQueries(private val database: PaymentsDatabase) {
private val queries = database.incomingPaymentsQueries
fun addIncomingPayment(
preimage: ByteVector32,
paymentHash: ByteVector32,
origin: IncomingPayment.Origin,
createdAt: Long
) {
val (originType, originData) = origin.mapToDb()
queries.insert(
payment_hash = paymentHash.toByteArray(),
preimage = preimage.toByteArray(),
origin_type = originType,
origin_blob = originData,
created_at = createdAt
)
}
fun receivePayment(
paymentHash: ByteVector32,
receivedWith: List<IncomingPayment.ReceivedWith>,
receivedAt: Long
) {
database.transaction {
val paymentInDb = queries.get(
payment_hash = paymentHash.toByteArray(),
mapper = Companion::mapIncomingPayment
).executeAsOneOrNull() ?: throw IncomingPaymentNotFound(paymentHash)
val existingReceivedWith = paymentInDb.received?.receivedWith ?: emptySet()
val newReceivedWith = existingReceivedWith + receivedWith
val (receivedWithType, receivedWithBlob) = newReceivedWith.mapToDb() ?: (null to null)
queries.updateReceived(
received_at = receivedAt,
received_with_type = receivedWithType,
received_with_blob = receivedWithBlob,
payment_hash = paymentHash.toByteArray()
)
}
}
fun setLocked(paymentHash: ByteVector32, lockedAt: Long) {
database.transaction {
val paymentInDb = queries.get(
payment_hash = paymentHash.toByteArray(),
mapper = Companion::mapIncomingPayment
).executeAsOneOrNull()
val newReceivedWith = paymentInDb?.received?.receivedWith?.map {
when (it) {
is IncomingPayment.ReceivedWith.NewChannel -> it.copy(lockedAt = lockedAt)
is IncomingPayment.ReceivedWith.SpliceIn -> it.copy(lockedAt = lockedAt)
else -> it
}
}
val (newReceivedWithType, newReceivedWithBlob) = newReceivedWith?.mapToDb()
?: (null to null)
queries.updateReceived(
// we override the previous received_at timestamp to trigger a refresh of the payment's cache data
// because the list-all query feeding the cache uses `received_at` for incoming payments
received_at = lockedAt,
received_with_type = newReceivedWithType,
received_with_blob = newReceivedWithBlob,
payment_hash = paymentHash.toByteArray()
)
}
}
fun setConfirmed(paymentHash: ByteVector32, confirmedAt: Long) {
database.transaction {
val paymentInDb = queries.get(
payment_hash = paymentHash.toByteArray(),
mapper = Companion::mapIncomingPayment
).executeAsOneOrNull()
val newReceivedWith = paymentInDb?.received?.receivedWith?.map {
when (it) {
is IncomingPayment.ReceivedWith.NewChannel -> it.copy(confirmedAt = confirmedAt)
is IncomingPayment.ReceivedWith.SpliceIn -> it.copy(confirmedAt = confirmedAt)
else -> it
}
}
val (newReceivedWithType, newReceivedWithBlob) = newReceivedWith?.mapToDb()
?: (null to null)
queries.updateReceived(
received_at = paymentInDb?.received?.receivedAt,
received_with_type = newReceivedWithType,
received_with_blob = newReceivedWithBlob,
payment_hash = paymentHash.toByteArray()
)
}
}
fun getIncomingPayment(paymentHash: ByteVector32): IncomingPayment? {
return queries.get(payment_hash = paymentHash.toByteArray(), Companion::mapIncomingPayment).executeAsOneOrNull()
}
fun getOldestReceivedDate(): Long? {
return queries.getOldestReceivedDate().executeAsOneOrNull()
}
fun listAllNotConfirmed(): Flow<List<IncomingPayment>> {
return queries.listAllNotConfirmed(Companion::mapIncomingPayment).asFlow().mapToList(Dispatchers.IO)
}
fun listExpiredPayments(fromCreatedAt: Long, toCreatedAt: Long): List<IncomingPayment> {
return queries.listAllWithin(fromCreatedAt, toCreatedAt, Companion::mapIncomingPayment).executeAsList().filter {
it.received == null
}
}
/** Try to delete an incoming payment ; return true if an element was deleted, false otherwise. */
fun deleteIncomingPayment(paymentHash: ByteVector32): Boolean {
return database.transactionWithResult {
queries.delete(payment_hash = paymentHash.toByteArray())
queries.changes().executeAsOne() != 0L
}
}
companion object {
fun mapIncomingPayment(
@Suppress("UNUSED_PARAMETER") payment_hash: ByteArray,
preimage: ByteArray,
created_at: Long,
origin_type: IncomingOriginTypeVersion,
origin_blob: ByteArray,
@Suppress("UNUSED_PARAMETER") received_amount_msat: Long?,
received_at: Long?,
received_with_type: IncomingReceivedWithTypeVersion?,
received_with_blob: ByteArray?,
): IncomingPayment {
return IncomingPayment(
preimage = ByteVector32(preimage),
origin = IncomingOriginData.deserialize(origin_type, origin_blob),
received = mapIncomingReceived(received_at, received_with_type, received_with_blob),
createdAt = created_at
)
}
private fun mapIncomingReceived(
received_at: Long?,
received_with_type: IncomingReceivedWithTypeVersion?,
received_with_blob: ByteArray?,
): IncomingPayment.Received? {
return when {
received_at == null && received_with_type == null && received_with_blob == null -> null
received_at != null && received_with_type != null && received_with_blob != null -> {
IncomingPayment.Received(
receivedWith = IncomingReceivedWithData.deserialize(received_with_type, received_with_blob),
receivedAt = received_at
)
}
received_at != null -> {
IncomingPayment.Received(
receivedWith = emptyList(),
receivedAt = received_at
)
}
else -> throw UnreadableIncomingReceivedWith(received_at, received_with_type, received_with_blob)
}
}
private fun mapTxIdPaymentHash(
tx_id: ByteArray,
payment_hash: ByteArray
): Pair<ByteVector32, ByteVector32> {
return tx_id.byteVector32() to payment_hash.byteVector32()
}
}
}
class IncomingPaymentNotFound(paymentHash: ByteVector32) : RuntimeException("missing payment for payment_hash=$paymentHash")
class UnreadableIncomingReceivedWith(receivedAt: Long?, receivedWithTypeVersion: IncomingReceivedWithTypeVersion?, receivedWithBlob: ByteArray?) :
RuntimeException("unreadable received with data [ receivedAt=$receivedAt, receivedWithTypeVersion=$receivedWithTypeVersion, receivedWithBlob=$receivedWithBlob ]")

View File

@@ -0,0 +1,169 @@
/*
* Copyright 2021 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:UseSerializers(
SatoshiSerializer::class,
MilliSatoshiSerializer::class,
ByteVector32Serializer::class,
UUIDSerializer::class,
)
package fr.acinq.lightning.bin.db.payments
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Satoshi
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer
import fr.acinq.lightning.bin.db.serializers.v1.MilliSatoshiSerializer
import fr.acinq.lightning.bin.db.serializers.v1.UUIDSerializer
import fr.acinq.lightning.bin.db.serializers.v1.SatoshiSerializer
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
import kotlinx.serialization.*
import kotlinx.serialization.builtins.SetSerializer
enum class IncomingReceivedWithTypeVersion {
MULTIPARTS_V1,
}
sealed class IncomingReceivedWithData {
@Serializable
sealed class Part : IncomingReceivedWithData() {
sealed class Htlc : Part() {
@Serializable
data class V0(
@Serializable val amount: MilliSatoshi,
@Serializable val channelId: ByteVector32,
val htlcId: Long
) : Htlc()
}
sealed class NewChannel : Part() {
/** V2 supports dual funding. New fields: service/miningFees, channel id, funding tx id, and the confirmation/lock timestamps. Id is removed. */
@Serializable
data class V2(
@Serializable val amount: MilliSatoshi,
@Serializable val serviceFee: MilliSatoshi,
@Serializable val miningFee: Satoshi,
@Serializable val channelId: ByteVector32,
@Serializable val txId: ByteVector32,
@Serializable val confirmedAt: Long?,
@Serializable val lockedAt: Long?,
) : NewChannel()
}
sealed class SpliceIn : Part() {
@Serializable
data class V0(
@Serializable val amount: MilliSatoshi,
@Serializable val serviceFee: MilliSatoshi,
@Serializable val miningFee: Satoshi,
@Serializable val channelId: ByteVector32,
@Serializable val txId: ByteVector32,
@Serializable val confirmedAt: Long?,
@Serializable val lockedAt: Long?,
) : SpliceIn()
}
sealed class FeeCredit : Part() {
@Serializable
data class V0(
val amount: MilliSatoshi
) : FeeCredit()
}
}
companion object {
/** Deserializes a received-with blob from the database using the given [typeVersion]. */
fun deserialize(
typeVersion: IncomingReceivedWithTypeVersion,
blob: ByteArray,
): List<IncomingPayment.ReceivedWith> = DbTypesHelper.decodeBlob(blob) { json, _ ->
when (typeVersion) {
IncomingReceivedWithTypeVersion.MULTIPARTS_V1 -> DbTypesHelper.polymorphicFormat.decodeFromString(SetSerializer(PolymorphicSerializer(Part::class)), json).map {
when (it) {
is Part.Htlc.V0 -> IncomingPayment.ReceivedWith.LightningPayment(
amount = it.amount,
channelId = it.channelId,
htlcId = it.htlcId
)
is Part.NewChannel.V2 -> IncomingPayment.ReceivedWith.NewChannel(
amount = it.amount,
serviceFee = it.serviceFee,
miningFee = it.miningFee,
channelId = it.channelId,
txId = TxId(it.txId),
confirmedAt = it.confirmedAt,
lockedAt = it.lockedAt,
)
is Part.SpliceIn.V0 -> IncomingPayment.ReceivedWith.SpliceIn(
amount = it.amount,
serviceFee = it.serviceFee,
miningFee = it.miningFee,
channelId = it.channelId,
txId = TxId(it.txId),
confirmedAt = it.confirmedAt,
lockedAt = it.lockedAt,
)
is Part.FeeCredit.V0 -> IncomingPayment.ReceivedWith.FeeCreditPayment(
amount = it.amount
)
}
}
}
}
}
}
/** Only serialize received_with into the [IncomingReceivedWithTypeVersion.MULTIPARTS_V1] type. */
fun List<IncomingPayment.ReceivedWith>.mapToDb(): Pair<IncomingReceivedWithTypeVersion, ByteArray>? = map {
when (it) {
is IncomingPayment.ReceivedWith.LightningPayment -> IncomingReceivedWithData.Part.Htlc.V0(
amount = it.amount,
channelId = it.channelId,
htlcId = it.htlcId
)
is IncomingPayment.ReceivedWith.NewChannel -> IncomingReceivedWithData.Part.NewChannel.V2(
amount = it.amount,
serviceFee = it.serviceFee,
miningFee = it.miningFee,
channelId = it.channelId,
txId = it.txId.value,
confirmedAt = it.confirmedAt,
lockedAt = it.lockedAt,
)
is IncomingPayment.ReceivedWith.SpliceIn -> IncomingReceivedWithData.Part.SpliceIn.V0(
amount = it.amount,
serviceFee = it.serviceFee,
miningFee = it.miningFee,
channelId = it.channelId,
txId = it.txId.value,
confirmedAt = it.confirmedAt,
lockedAt = it.lockedAt,
)
is IncomingPayment.ReceivedWith.FeeCreditPayment -> IncomingReceivedWithData.Part.FeeCredit.V0(
amount = it.amount
)
}
}.takeIf { it.isNotEmpty() }?.toSet()?.let {
IncomingReceivedWithTypeVersion.MULTIPARTS_V1 to DbTypesHelper.polymorphicFormat.encodeToString(
SetSerializer(PolymorphicSerializer(IncomingReceivedWithData.Part::class)), it
).toByteArray(Charsets.UTF_8)
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2023 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.payments
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.bin.db.WalletPaymentId
import fr.acinq.phoenix.db.PaymentsDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.*
class LinkTxToPaymentQueries(val database: PaymentsDatabase) {
private val linkTxQueries = database.linkTxToPaymentQueries
fun listUnconfirmedTxs(): Flow<List<ByteArray>> {
return linkTxQueries.listUnconfirmed().asFlow().mapToList(Dispatchers.IO)
}
fun listWalletPaymentIdsForTx(txId: TxId): List<WalletPaymentId> {
return linkTxQueries.getPaymentIdForTx(tx_id = txId.value.toByteArray()).executeAsList()
.mapNotNull { WalletPaymentId.create(it.type, it.id) }
}
fun linkTxToPayment(txId: TxId, walletPaymentId: WalletPaymentId) {
linkTxQueries.linkTxToPayment(tx_id = txId.value.toByteArray(), type = walletPaymentId.dbType.value, id = walletPaymentId.dbId)
}
fun setConfirmed(txId: TxId, confirmedAt: Long) {
linkTxQueries.setConfirmed(tx_id = txId.value.toByteArray(), confirmed_at = confirmedAt)
}
fun setLocked(txId: TxId, lockedAt: Long) {
linkTxQueries.setLocked(tx_id = txId.value.toByteArray(), locked_at = lockedAt)
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright 2021 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:UseSerializers(
SatoshiSerializer::class,
ByteVector32Serializer::class,
)
package fr.acinq.lightning.bin.db.payments
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer
import fr.acinq.lightning.bin.db.serializers.v1.SatoshiSerializer
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.payment.Bolt11Invoice
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
enum class OutgoingDetailsTypeVersion {
NORMAL_V0,
KEYSEND_V0,
SWAPOUT_V0,
}
sealed class OutgoingDetailsData {
sealed class Normal : OutgoingDetailsData() {
@Serializable
data class V0(val paymentRequest: String) : Normal()
}
sealed class KeySend : OutgoingDetailsData() {
@Serializable
data class V0(@Serializable val preimage: ByteVector32) : KeySend()
}
sealed class SwapOut : OutgoingDetailsData() {
@Serializable
data class V0(val address: String, val paymentRequest: String, @Serializable val swapOutFee: Satoshi) : SwapOut()
}
companion object {
/** Deserialize the details of an outgoing payment. Return null if the details is for a legacy channel closing payment (see [deserializeLegacyClosingDetails]). */
fun deserialize(typeVersion: OutgoingDetailsTypeVersion, blob: ByteArray): LightningOutgoingPayment.Details? = DbTypesHelper.decodeBlob(blob) { json, format ->
when (typeVersion) {
OutgoingDetailsTypeVersion.NORMAL_V0 -> format.decodeFromString<Normal.V0>(json).let { LightningOutgoingPayment.Details.Normal(Bolt11Invoice.read(it.paymentRequest).get()) }
OutgoingDetailsTypeVersion.KEYSEND_V0 -> format.decodeFromString<KeySend.V0>(json).let { LightningOutgoingPayment.Details.KeySend(it.preimage) }
OutgoingDetailsTypeVersion.SWAPOUT_V0 -> format.decodeFromString<SwapOut.V0>(json).let { LightningOutgoingPayment.Details.SwapOut(it.address, Bolt11Invoice.read(it.paymentRequest).get(), it.swapOutFee) }
}
}
}
}
fun LightningOutgoingPayment.Details.mapToDb(): Pair<OutgoingDetailsTypeVersion, ByteArray> = when (this) {
is LightningOutgoingPayment.Details.Normal -> OutgoingDetailsTypeVersion.NORMAL_V0 to
Json.encodeToString(OutgoingDetailsData.Normal.V0(paymentRequest.write())).toByteArray(Charsets.UTF_8)
is LightningOutgoingPayment.Details.KeySend -> OutgoingDetailsTypeVersion.KEYSEND_V0 to
Json.encodeToString(OutgoingDetailsData.KeySend.V0(preimage)).toByteArray(Charsets.UTF_8)
is LightningOutgoingPayment.Details.SwapOut -> OutgoingDetailsTypeVersion.SWAPOUT_V0 to
Json.encodeToString(OutgoingDetailsData.SwapOut.V0(address, paymentRequest.write(), swapOutFee)).toByteArray(Charsets.UTF_8)
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2021 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.payments
import fr.acinq.lightning.db.ChannelCloseOutgoingPayment
import fr.acinq.lightning.db.ChannelClosingType
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
enum class OutgoingPartClosingInfoTypeVersion {
// basic type, containing only a [ChannelClosingType] field
CLOSING_INFO_V0,
}
sealed class OutgoingPartClosingInfoData {
@Serializable
data class V0(val closingType: ChannelClosingType)
companion object {
fun deserialize(typeVersion: OutgoingPartClosingInfoTypeVersion, blob: ByteArray): ChannelClosingType = DbTypesHelper.decodeBlob(blob) { json, format ->
when (typeVersion) {
OutgoingPartClosingInfoTypeVersion.CLOSING_INFO_V0 -> format.decodeFromString<V0>(json).closingType
}
}
}
}
fun ChannelCloseOutgoingPayment.mapClosingTypeToDb() = OutgoingPartClosingInfoTypeVersion.CLOSING_INFO_V0 to
Json.encodeToString(OutgoingPartClosingInfoData.V0(this.closingType)).toByteArray(Charsets.UTF_8)

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2021 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:UseSerializers(
ByteVector32Serializer::class,
)
package fr.acinq.lightning.bin.db.payments
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer
import fr.acinq.lightning.db.LightningOutgoingPayment
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
enum class OutgoingPartStatusTypeVersion {
SUCCEEDED_V0,
FAILED_V0,
}
sealed class OutgoingPartStatusData {
sealed class Succeeded : OutgoingPartStatusData() {
@Serializable
data class V0(@Serializable val preimage: ByteVector32) : Succeeded()
}
sealed class Failed : OutgoingPartStatusData() {
@Serializable
data class V0(val remoteFailureCode: Int?, val details: String) : Failed()
}
companion object {
fun deserialize(
typeVersion: OutgoingPartStatusTypeVersion,
blob: ByteArray, completedAt: Long
): LightningOutgoingPayment.Part.Status = DbTypesHelper.decodeBlob(blob) { json, format ->
when (typeVersion) {
OutgoingPartStatusTypeVersion.SUCCEEDED_V0 -> format.decodeFromString<Succeeded.V0>(json).let {
LightningOutgoingPayment.Part.Status.Succeeded(it.preimage, completedAt)
}
OutgoingPartStatusTypeVersion.FAILED_V0 -> format.decodeFromString<Failed.V0>(json).let {
LightningOutgoingPayment.Part.Status.Failed(it.remoteFailureCode, it.details, completedAt)
}
}
}
}
}
fun LightningOutgoingPayment.Part.Status.Succeeded.mapToDb() = OutgoingPartStatusTypeVersion.SUCCEEDED_V0 to
Json.encodeToString(OutgoingPartStatusData.Succeeded.V0(preimage)).toByteArray(Charsets.UTF_8)
fun LightningOutgoingPayment.Part.Status.Failed.mapToDb() = OutgoingPartStatusTypeVersion.FAILED_V0 to
Json.encodeToString(OutgoingPartStatusData.Failed.V0(remoteFailureCode, details)).toByteArray(Charsets.UTF_8)

View File

@@ -0,0 +1,373 @@
/*
* Copyright 2021 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.payments
import app.cash.sqldelight.ColumnAdapter
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.ShortChannelId
import fr.acinq.lightning.channel.ChannelException
import fr.acinq.lightning.db.ChannelCloseOutgoingPayment
import fr.acinq.lightning.db.HopDesc
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.db.OutgoingPayment
import fr.acinq.lightning.payment.OutgoingPaymentFailure
import fr.acinq.lightning.utils.*
import fr.acinq.lightning.wire.FailureMessage
import fr.acinq.phoenix.db.PaymentsDatabase
import fr.acinq.secp256k1.Hex
class OutgoingQueries(val database: PaymentsDatabase) {
private val queries = database.outgoingPaymentsQueries
fun addLightningParts(parentId: UUID, parts: List<LightningOutgoingPayment.Part>) {
if (parts.isEmpty()) return
database.transaction {
parts.map {
// This will throw an exception if the sqlite foreign-key-constraint is violated.
queries.insertLightningPart(
part_id = it.id.toString(),
part_parent_id = parentId.toString(),
part_amount_msat = it.amount.msat,
part_route = it.route,
part_created_at = it.createdAt
)
}
}
}
fun addLightningOutgoingPayment(payment: LightningOutgoingPayment) {
val (detailsTypeVersion, detailsData) = payment.details.mapToDb()
database.transaction(noEnclosing = false) {
queries.insertPayment(
id = payment.id.toString(),
recipient_amount_msat = payment.recipientAmount.msat,
recipient_node_id = payment.recipient.toString(),
payment_hash = payment.details.paymentHash.toByteArray(),
created_at = payment.createdAt,
details_type = detailsTypeVersion,
details_blob = detailsData
)
payment.parts.map {
queries.insertLightningPart(
part_id = it.id.toString(),
part_parent_id = payment.id.toString(),
part_amount_msat = it.amount.msat,
part_route = it.route,
part_created_at = it.createdAt
)
}
}
}
fun completePayment(id: UUID, completed: LightningOutgoingPayment.Status.Completed): Boolean {
var result = true
database.transaction {
val (statusType, statusBlob) = completed.mapToDb()
queries.updatePayment(
id = id.toString(),
completed_at = completed.completedAt,
status_type = statusType,
status_blob = statusBlob
)
if (queries.changes().executeAsOne() != 1L) {
result = false
}
}
return result
}
fun updateLightningPart(
partId: UUID,
preimage: ByteVector32,
completedAt: Long
): Boolean {
var result = true
val (statusTypeVersion, statusData) = LightningOutgoingPayment.Part.Status.Succeeded(preimage).mapToDb()
database.transaction {
queries.updateLightningPart(
part_id = partId.toString(),
part_status_type = statusTypeVersion,
part_status_blob = statusData,
part_completed_at = completedAt
)
if (queries.changes().executeAsOne() != 1L) {
result = false
}
}
return result
}
fun updateLightningPart(
partId: UUID,
failure: Either<ChannelException, FailureMessage>,
completedAt: Long
): Boolean {
var result = true
val (statusTypeVersion, statusData) = OutgoingPaymentFailure.convertFailure(failure).mapToDb()
database.transaction {
queries.updateLightningPart(
part_id = partId.toString(),
part_status_type = statusTypeVersion,
part_status_blob = statusData,
part_completed_at = completedAt
)
if (queries.changes().executeAsOne() != 1L) {
result = false
}
}
return result
}
/** This method will ignore any parts that are not proper [LightningOutgoingPayment]. */
fun getPaymentFromPartId(partId: UUID): LightningOutgoingPayment? {
return queries.getLightningPart(part_id = partId.toString()).executeAsOneOrNull()?.let { part ->
queries.getPayment(id = part.part_parent_id, Companion::mapLightningOutgoingPayment).executeAsList()
}?.filterIsInstance<LightningOutgoingPayment>()?.let {
// first ignore any legacy channel closing, then group by parent id
groupByRawLightningOutgoing(it).firstOrNull()
}?.let {
filterUselessParts(it)
// resulting payment must contain the request part id, or should be null
.takeIf { p -> p.parts.map { it.id }.contains(partId) }
}
}
fun getPaymentWithoutParts(id: UUID): LightningOutgoingPayment? {
return queries.getPaymentWithoutParts(
id = id.toString(),
mapper = Companion::mapLightningOutgoingPaymentWithoutParts
).executeAsOneOrNull()
}
/**
* Returns a [LightningOutgoingPayment] for this id - if instead we find legacy converted to a new type (such as
* [ChannelCloseOutgoingPayment], this payment is ignored and we return null instead.
*/
fun getPaymentStrict(id: UUID): LightningOutgoingPayment? = queries.getPayment(
id = id.toString(),
mapper = Companion::mapLightningOutgoingPayment
).executeAsList().let { parts ->
// only take regular LN payments parts, and group them
parts.filterIsInstance<LightningOutgoingPayment>().let {
groupByRawLightningOutgoing(it).firstOrNull()
}?.let {
filterUselessParts(it)
}
}
/**
* May return a [ChannelCloseOutgoingPayment] instead of the expected [LightningOutgoingPayment]. That's because
* channel closing used to be stored as [LightningOutgoingPayment] with special closing parts. We convert those to
* the propert object type.
*/
fun getPaymentRelaxed(id: UUID): OutgoingPayment? = queries.getPayment(
id = id.toString(),
mapper = Companion::mapLightningOutgoingPayment
).executeAsList().let { parts ->
// this payment may be a legacy channel closing - otherwise, only take regular LN payment parts, and group them
parts.firstOrNull { it is ChannelCloseOutgoingPayment } ?: parts.filterIsInstance<LightningOutgoingPayment>().let {
groupByRawLightningOutgoing(it).firstOrNull()
}?.let {
filterUselessParts(it)
}
}
fun getOldestCompletedDate(): Long? {
return queries.getOldestCompletedDate().executeAsOneOrNull()
}
fun listLightningOutgoingPayments(paymentHash: ByteVector32): List<LightningOutgoingPayment> {
return queries.listPaymentsForPaymentHash(paymentHash.toByteArray(), Companion::mapLightningOutgoingPayment).executeAsList()
.filterIsInstance<LightningOutgoingPayment>()
.let { groupByRawLightningOutgoing(it) }
}
/** Group a list of outgoing payments by parent id and parts. */
private fun groupByRawLightningOutgoing(payments: List<LightningOutgoingPayment>) = payments
.takeIf { it.isNotEmpty() }
?.groupBy { it.id }
?.values
?.map { group -> group.first().copy(parts = group.flatMap { it.parts }) }
?: emptyList()
/** Get a payment without its failed/pending parts. */
private fun filterUselessParts(payment: LightningOutgoingPayment): LightningOutgoingPayment = when (payment.status) {
is LightningOutgoingPayment.Status.Completed.Succeeded.OffChain -> {
payment.copy(parts = payment.parts.filter {
it.status is LightningOutgoingPayment.Part.Status.Succeeded
})
}
else -> payment
}
companion object {
@Suppress("UNUSED_PARAMETER")
fun mapLightningOutgoingPaymentWithoutParts(
id: String,
recipient_amount_msat: Long,
recipient_node_id: String,
payment_hash: ByteArray,
details_type: OutgoingDetailsTypeVersion,
details_blob: ByteArray,
created_at: Long,
completed_at: Long?,
status_type: OutgoingStatusTypeVersion?,
status_blob: ByteArray?
): LightningOutgoingPayment {
val details = OutgoingDetailsData.deserialize(details_type, details_blob)
return if (details != null) {
LightningOutgoingPayment(
id = UUID.fromString(id),
recipientAmount = MilliSatoshi(recipient_amount_msat),
recipient = PublicKey.parse(Hex.decode(recipient_node_id)),
details = details,
parts = listOf(),
status = mapPaymentStatus(status_type, status_blob, completed_at),
createdAt = created_at
)
} else throw IllegalArgumentException("cannot handle closing payment at this stage, use LegacyChannelCloseHelper")
}
@Suppress("UNUSED_PARAMETER")
fun mapLightningOutgoingPayment(
id: String,
recipient_amount_msat: Long,
recipient_node_id: String,
payment_hash: ByteArray,
details_type: OutgoingDetailsTypeVersion,
details_blob: ByteArray,
created_at: Long,
completed_at: Long?,
status_type: OutgoingStatusTypeVersion?,
status_blob: ByteArray?,
// lightning parts data, may be null
lightning_part_id: String?,
lightning_part_amount_msat: Long?,
lightning_part_route: List<HopDesc>?,
lightning_part_created_at: Long?,
lightning_part_completed_at: Long?,
lightning_part_status_type: OutgoingPartStatusTypeVersion?,
lightning_part_status_blob: ByteArray?,
// closing tx parts data, may be null
closingtx_part_id: String?,
closingtx_part_tx_id: ByteArray?,
closingtx_part_amount_sat: Long?,
closingtx_part_closing_info_type: OutgoingPartClosingInfoTypeVersion?,
closingtx_part_closing_info_blob: ByteArray?,
closingtx_part_created_at: Long?
): OutgoingPayment {
val parts = if (lightning_part_id != null && lightning_part_amount_msat != null && lightning_part_route != null && lightning_part_created_at != null) {
listOf(
mapLightningPart(
id = lightning_part_id,
amountMsat = lightning_part_amount_msat,
route = lightning_part_route,
createdAt = lightning_part_created_at,
completedAt = lightning_part_completed_at,
statusType = lightning_part_status_type,
statusBlob = lightning_part_status_blob
)
)
} else emptyList()
return mapLightningOutgoingPaymentWithoutParts(
id = id,
recipient_amount_msat = recipient_amount_msat,
recipient_node_id = recipient_node_id,
payment_hash = payment_hash,
details_type = details_type,
details_blob = details_blob,
created_at = created_at,
completed_at = completed_at,
status_type = status_type,
status_blob = status_blob
).copy(
parts = parts
)
}
private fun mapLightningPart(
id: String,
amountMsat: Long,
route: List<HopDesc>,
createdAt: Long,
completedAt: Long?,
statusType: OutgoingPartStatusTypeVersion?,
statusBlob: ByteArray?
): LightningOutgoingPayment.Part {
return LightningOutgoingPayment.Part(
id = UUID.fromString(id),
amount = MilliSatoshi(amountMsat),
route = route,
status = mapLightningPartStatus(
statusType = statusType,
statusBlob = statusBlob,
completedAt = completedAt
),
createdAt = createdAt
)
}
private fun mapPaymentStatus(
statusType: OutgoingStatusTypeVersion?,
statusBlob: ByteArray?,
completedAt: Long?,
): LightningOutgoingPayment.Status = when {
completedAt == null && statusType == null && statusBlob == null -> LightningOutgoingPayment.Status.Pending
completedAt != null && statusType != null && statusBlob != null -> OutgoingStatusData.deserialize(statusType, statusBlob, completedAt)
else -> throw UnhandledOutgoingStatus(completedAt, statusType, statusBlob)
}
private fun mapLightningPartStatus(
statusType: OutgoingPartStatusTypeVersion?,
statusBlob: ByteArray?,
completedAt: Long?,
): LightningOutgoingPayment.Part.Status = when {
completedAt == null && statusType == null && statusBlob == null -> LightningOutgoingPayment.Part.Status.Pending
completedAt != null && statusType != null && statusBlob != null -> OutgoingPartStatusData.deserialize(statusType, statusBlob, completedAt)
else -> throw UnhandledOutgoingPartStatus(statusType, statusBlob, completedAt)
}
val hopDescAdapter: ColumnAdapter<List<HopDesc>, String> = object : ColumnAdapter<List<HopDesc>, String> {
override fun decode(databaseValue: String): List<HopDesc> = when {
databaseValue.isEmpty() -> listOf()
else -> databaseValue.split(";").map { hop ->
val els = hop.split(":")
val n1 = PublicKey.parse(Hex.decode(els[0]))
val n2 = PublicKey.parse(Hex.decode(els[1]))
val cid = els[2].takeIf { it.isNotBlank() }?.run { ShortChannelId(this) }
HopDesc(n1, n2, cid)
}
}
override fun encode(value: List<HopDesc>): String = value.joinToString(";") {
"${it.nodeId}:${it.nextNodeId}:${it.shortChannelId ?: ""}"
}
}
}
}
data class UnhandledOutgoingStatus(val completedAt: Long?, val statusTypeVersion: OutgoingStatusTypeVersion?, val statusData: ByteArray?) :
RuntimeException("cannot map outgoing payment status data with completed_at=$completedAt status_type=$statusTypeVersion status=$statusData")
data class UnhandledOutgoingPartStatus(val status_type: OutgoingPartStatusTypeVersion?, val status_blob: ByteArray?, val completedAt: Long?) :
RuntimeException("cannot map outgoing part status data [ completed_at=$completedAt status_type=$status_type status_blob=$status_blob]")

View File

@@ -0,0 +1,110 @@
/*
* Copyright 2021 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:UseSerializers(
SatoshiSerializer::class,
ByteVector32Serializer::class,
)
package fr.acinq.lightning.bin.db.payments
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.bin.db.payments.DbTypesHelper.decodeBlob
import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer
import fr.acinq.lightning.bin.db.serializers.v1.SatoshiSerializer
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.payment.FinalFailure
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
enum class OutgoingStatusTypeVersion {
SUCCEEDED_OFFCHAIN_V0,
FAILED_V0,
}
sealed class OutgoingStatusData {
sealed class SucceededOffChain : OutgoingStatusData() {
@Serializable
data class V0(@Serializable val preimage: ByteVector32) : SucceededOffChain()
}
sealed class SucceededOnChain : OutgoingStatusData() {
@Serializable
data class V0(
val txIds: List<@Serializable ByteVector32>,
@Serializable val claimed: Satoshi,
val closingType: String
) : SucceededOnChain()
@Serializable
object V1 : SucceededOnChain()
}
sealed class Failed : OutgoingStatusData() {
@Serializable
data class V0(val reason: String) : Failed()
}
companion object {
/** Extract valuable data from old outgoing payments status that represent closing transactions. */
fun deserializeLegacyClosingStatus(blob: ByteArray): SucceededOnChain.V0 = decodeBlob(blob) { json, format ->
val data = format.decodeFromString<SucceededOnChain.V0>(json)
data
}
fun deserialize(typeVersion: OutgoingStatusTypeVersion, blob: ByteArray, completedAt: Long): LightningOutgoingPayment.Status = decodeBlob(blob) { json, format ->
@Suppress("DEPRECATION")
when (typeVersion) {
OutgoingStatusTypeVersion.SUCCEEDED_OFFCHAIN_V0 -> format.decodeFromString<SucceededOffChain.V0>(json).let {
LightningOutgoingPayment.Status.Completed.Succeeded.OffChain(it.preimage, completedAt)
}
OutgoingStatusTypeVersion.FAILED_V0 -> format.decodeFromString<Failed.V0>(json).let {
LightningOutgoingPayment.Status.Completed.Failed(deserializeFinalFailure(it.reason), completedAt)
}
}
}
internal fun serializeFinalFailure(failure: FinalFailure): String = failure::class.simpleName ?: "UnknownError"
private fun deserializeFinalFailure(failure: String): FinalFailure = when (failure) {
FinalFailure.InvalidPaymentAmount::class.simpleName -> FinalFailure.InvalidPaymentAmount
FinalFailure.InvalidPaymentId::class.simpleName -> FinalFailure.InvalidPaymentId
FinalFailure.NoAvailableChannels::class.simpleName -> FinalFailure.NoAvailableChannels
FinalFailure.InsufficientBalance::class.simpleName -> FinalFailure.InsufficientBalance
FinalFailure.NoRouteToRecipient::class.simpleName -> FinalFailure.NoRouteToRecipient
FinalFailure.RecipientUnreachable::class.simpleName -> FinalFailure.RecipientUnreachable
FinalFailure.RetryExhausted::class.simpleName -> FinalFailure.RetryExhausted
FinalFailure.WalletRestarted::class.simpleName -> FinalFailure.WalletRestarted
else -> FinalFailure.UnknownError
}
}
}
fun LightningOutgoingPayment.Status.Completed.mapToDb(): Pair<OutgoingStatusTypeVersion, ByteArray> = when (this) {
is LightningOutgoingPayment.Status.Completed.Succeeded.OffChain -> OutgoingStatusTypeVersion.SUCCEEDED_OFFCHAIN_V0 to
Json.encodeToString(OutgoingStatusData.SucceededOffChain.V0(preimage)).toByteArray(Charsets.UTF_8)
is LightningOutgoingPayment.Status.Completed.Failed -> OutgoingStatusTypeVersion.FAILED_V0 to
Json.encodeToString(OutgoingStatusData.Failed.V0(OutgoingStatusData.serializeFinalFailure(reason))).toByteArray(Charsets.UTF_8)
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2023 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.payments
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toByteVector32
import fr.acinq.phoenix.db.PaymentsDatabase
class SpliceCpfpOutgoingQueries(val database: PaymentsDatabase) {
private val cpfpQueries = database.spliceCpfpOutgoingPaymentsQueries
fun addCpfpPayment(payment: SpliceCpfpOutgoingPayment) {
database.transaction {
cpfpQueries.insertCpfp(
id = payment.id.toString(),
mining_fees_sat = payment.miningFees.sat,
channel_id = payment.channelId.toByteArray(),
tx_id = payment.txId.value.toByteArray(),
created_at = payment.createdAt,
confirmed_at = payment.confirmedAt,
locked_at = payment.lockedAt
)
}
}
fun getCpfp(id: UUID): SpliceCpfpOutgoingPayment? {
return cpfpQueries.getCpfp(
id = id.toString(),
mapper = Companion::mapCpfp
).executeAsOneOrNull()
}
fun setConfirmed(id: UUID, confirmedAt: Long) {
database.transaction {
cpfpQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString())
}
}
fun setLocked(id: UUID, lockedAt: Long) {
database.transaction {
cpfpQueries.setLocked(locked_at = lockedAt, id = id.toString())
}
}
private companion object {
fun mapCpfp(
id: String,
mining_fees_sat: Long,
channel_id: ByteArray,
tx_id: ByteArray,
created_at: Long,
confirmed_at: Long?,
locked_at: Long?
): SpliceCpfpOutgoingPayment {
return SpliceCpfpOutgoingPayment(
id = UUID.fromString(id),
miningFees = mining_fees_sat.sat,
channelId = channel_id.toByteVector32(),
txId = TxId(tx_id),
createdAt = created_at,
confirmedAt = confirmed_at,
lockedAt = locked_at
)
}
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2023 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.payments
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.db.SpliceOutgoingPayment
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toByteVector32
import fr.acinq.phoenix.db.PaymentsDatabase
class SpliceOutgoingQueries(val database: PaymentsDatabase) {
private val spliceOutQueries = database.spliceOutgoingPaymentsQueries
fun addSpliceOutgoingPayment(payment: SpliceOutgoingPayment) {
database.transaction {
spliceOutQueries.insertSpliceOutgoing(
id = payment.id.toString(),
recipient_amount_sat = payment.recipientAmount.sat,
address = payment.address,
mining_fees_sat = payment.miningFees.sat,
channel_id = payment.channelId.toByteArray(),
tx_id = payment.txId.value.toByteArray(),
created_at = payment.createdAt,
confirmed_at = payment.confirmedAt,
locked_at = payment.lockedAt
)
}
}
fun getSpliceOutPayment(id: UUID): SpliceOutgoingPayment? {
return spliceOutQueries.getSpliceOutgoing(
id = id.toString(),
mapper = Companion::mapSpliceOutgoingPayment
).executeAsOneOrNull()
}
fun setConfirmed(id: UUID, confirmedAt: Long) {
database.transaction {
spliceOutQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString())
}
}
fun setLocked(id: UUID, lockedAt: Long) {
database.transaction {
spliceOutQueries.setLocked(locked_at = lockedAt, id = id.toString())
}
}
companion object {
fun mapSpliceOutgoingPayment(
id: String,
recipient_amount_sat: Long,
address: String,
mining_fees_sat: Long,
tx_id: ByteArray,
channel_id: ByteArray,
created_at: Long,
confirmed_at: Long?,
locked_at: Long?
): SpliceOutgoingPayment {
return SpliceOutgoingPayment(
id = UUID.fromString(id),
recipientAmount = recipient_amount_sat.sat,
address = address,
miningFees = mining_fees_sat.sat,
txId = TxId(tx_id),
channelId = channel_id.toByteVector32(),
createdAt = created_at,
confirmedAt = confirmed_at,
lockedAt = locked_at
)
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.serializers.v1
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
abstract class AbstractStringSerializer<T>(
name: String,
private val toString: (T) -> String,
private val fromString: (String) -> T
) : KSerializer<T> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(name, PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: T) {
encoder.encodeString(toString(value))
}
override fun deserialize(decoder: Decoder): T {
return fromString(decoder.decodeString())
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.serializers.v1
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.ByteVector64
import fr.acinq.lightning.bin.db.serializers.v1.AbstractStringSerializer
object ByteVector32Serializer : AbstractStringSerializer<ByteVector32>(
name = "ByteVector32",
toString = ByteVector32::toHex,
fromString = ::ByteVector32
)
object ByteVector64Serializer : AbstractStringSerializer<ByteVector64>(
name = "ByteVector64",
toString = ByteVector64::toHex,
fromString = ::ByteVector64
)
object ByteVectorSerializer : AbstractStringSerializer<ByteVector>(
name = "ByteVector",
toString = ByteVector::toHex,
fromString = ::ByteVector
)

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.serializers.v1
import fr.acinq.lightning.MilliSatoshi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object MilliSatoshiSerializer : KSerializer<MilliSatoshi> {
// we are using a surrogate for legacy reasons.
@Serializable
private data class MilliSatoshiSurrogate(val msat: Long)
override val descriptor: SerialDescriptor = MilliSatoshiSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: MilliSatoshi) {
val surrogate = MilliSatoshiSurrogate(msat = value.msat)
return encoder.encodeSerializableValue(MilliSatoshiSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): MilliSatoshi {
val surrogate = decoder.decodeSerializableValue(MilliSatoshiSurrogate.serializer())
return MilliSatoshi(msat = surrogate.msat)
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.serializers.v1
import fr.acinq.bitcoin.OutPoint
import fr.acinq.bitcoin.TxHash
class OutpointSerializer : AbstractStringSerializer<OutPoint>(
name = "Outpoint",
fromString = { serialized ->
serialized.split(":").let {
OutPoint(hash = TxHash(it[0]), index = it[1].toLong())
}
},
toString = { outpoint -> "${outpoint.hash}:${outpoint.index}" }
)

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.serializers.v1
import fr.acinq.bitcoin.Satoshi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object SatoshiSerializer : KSerializer<Satoshi> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Satoshi", PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: Satoshi) {
encoder.encodeLong(value.toLong())
}
override fun deserialize(decoder: Decoder): Satoshi {
return Satoshi(decoder.decodeLong())
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.lightning.bin.db.serializers.v1
import fr.acinq.lightning.utils.UUID
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object UUIDSerializer : KSerializer<UUID> {
@Serializable
private data class UUIDSurrogate(val mostSignificantBits: Long, val leastSignificantBits: Long)
override val descriptor: SerialDescriptor = UUIDSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: UUID) {
val surrogate = UUIDSurrogate(value.mostSignificantBits, value.leastSignificantBits)
return encoder.encodeSerializableValue(UUIDSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): UUID {
val surrogate = decoder.decodeSerializableValue(UUIDSurrogate.serializer())
return UUID(surrogate.mostSignificantBits, surrogate.leastSignificantBits)
}
}

View File

@@ -0,0 +1,92 @@
@file:UseSerializers(
// This is used by Kotlin at compile time to resolve serializers (defined in this file)
// in order to build serializers for other classes (also defined in this file).
// If we used @Serializable annotations directly on the actual classes, Kotlin would be
// able to resolve serializers by itself. It is verbose, but it allows us to contain
// serialization code in this file.
JsonSerializers.SatoshiSerializer::class,
JsonSerializers.MilliSatoshiSerializer::class,
JsonSerializers.ByteVector32Serializer::class,
JsonSerializers.PublicKeySerializer::class,
JsonSerializers.TxIdSerializer::class,
)
package fr.acinq.lightning.bin.json
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.Satoshi
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.channel.states.ChannelState
import fr.acinq.lightning.channel.states.ChannelStateWithCommitments
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.json.JsonSerializers
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
sealed class ApiType {
@Serializable
data class Channel internal constructor(
val state: String,
val channelId: ByteVector32? = null,
val balanceSat: Satoshi? = null,
val inboundLiquiditySat: Satoshi? = null,
val capacitySat: Satoshi? = null,
val fundingTxId: TxId? = null
) {
companion object {
fun from(channel: ChannelState) = when {
channel is ChannelStateWithCommitments -> Channel(
state = channel.stateName,
channelId = channel.channelId,
balanceSat = channel.commitments.availableBalanceForSend().truncateToSatoshi(),
inboundLiquiditySat = channel.commitments.availableBalanceForReceive().truncateToSatoshi(),
capacitySat = channel.commitments.active.first().fundingAmount,
fundingTxId = channel.commitments.active.first().fundingTxId
)
else -> Channel(state = channel.stateName)
}
}
}
@Serializable
data class NodeInfo(
val nodeId: PublicKey,
val channels: List<Channel>
)
@Serializable
data class Balance(@SerialName("amountSat") val amount: Satoshi, @SerialName("feeCreditSat") val feeCredit: Satoshi) : ApiType()
@Serializable
data class GeneratedInvoice(@SerialName("amountSat") val amount: Satoshi?, val paymentHash: ByteVector32, val serialized: String) : ApiType()
@Serializable
sealed class ApiEvent : ApiType()
@Serializable
@SerialName("payment_received")
data class PaymentReceived(@SerialName("amountSat") val amount: Satoshi, val paymentHash: ByteVector32) : ApiEvent() {
constructor(event: fr.acinq.lightning.PaymentEvents.PaymentReceived) : this(event.amount.truncateToSatoshi(), event.paymentHash)
}
@Serializable
@SerialName("payment_sent")
data class PaymentSent(@SerialName("recipientAmountSat") val recipientAmount: Satoshi, @SerialName("routingFeeSat") val routingFee: Satoshi, val paymentHash: ByteVector32, val paymentPreimage: ByteVector32) : ApiEvent() {
constructor(event: fr.acinq.lightning.io.PaymentSent) : this(
event.payment.recipientAmount.truncateToSatoshi(),
event.payment.routingFee.truncateToSatoshi(),
event.payment.paymentHash,
(event.payment.status as LightningOutgoingPayment.Status.Completed.Succeeded.OffChain).preimage
)
}
@Serializable
@SerialName("payment_failed")
data class PaymentFailed(val paymentHash: ByteVector32, val reason: String) : ApiType() {
constructor(event: fr.acinq.lightning.io.PaymentNotSent) : this(event.request.paymentHash, event.reason.reason.toString())
}
}

View File

@@ -0,0 +1,32 @@
package fr.acinq.lightning.bin.logs
import co.touchlab.kermit.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
import okio.FileSystem
import okio.Path
import okio.buffer
class FileLogWriter(private val logFile: Path, scope: CoroutineScope, private val messageStringFormatter: MessageStringFormatter = DefaultFormatter) : LogWriter() {
private val mailbox: Channel<String> = Channel(Channel.BUFFERED)
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
mailbox.trySend(messageStringFormatter.formatMessage(severity, Tag(tag), Message(message)))
throwable?.run { mailbox.trySend(stackTraceToString()) }
}
init {
scope.launch {
val sink = FileSystem.SYSTEM.appendingSink(logFile).buffer()
mailbox.consumeAsFlow().collect { logLine ->
val sb = StringBuilder()
sb.append(logLine)
sb.appendLine()
sink.writeUtf8(sb.toString())
sink.flush()
}
}
}
}

View File

@@ -0,0 +1,194 @@
package fr.acinq.lightning.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.context
import com.github.ajalt.clikt.core.requireObject
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.output.MordantHelpFormatter
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.int
import com.github.ajalt.clikt.parameters.types.long
import com.github.ajalt.clikt.sources.MapValueSource
import fr.acinq.bitcoin.Base58Check
import fr.acinq.bitcoin.Bech32
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.lightning.bin.conf.readConfFile
import fr.acinq.lightning.bin.homeDirectory
import fr.acinq.lightning.payment.Bolt11Invoice
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.util.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
fun main(args: Array<String>) =
PhoenixCli()
.subcommands(GetInfo(), GetBalance(), ListChannels(), CreateInvoice(), PayInvoice(), SendToAddress(), CloseChannel())
.main(args)
data class HttpConf(val baseUrl: Url, val httpClient: HttpClient)
class PhoenixCli : CliktCommand() {
private val datadir = homeDirectory / ".phoenix"
private val confFile = datadir / "phoenix.conf"
private val httpBindIp by option("--http-bind-ip", help = "Bind ip for the http api").default("127.0.0.1")
private val httpBindPort by option("--http-bind-port", help = "Bind port for the http api").int().default(9740)
private val httpPassword by option("--http-password", help = "Password for the http api").required()
init {
context {
valueSource = MapValueSource(readConfFile(confFile))
helpFormatter = { MordantHelpFormatter(it, showDefaultValues = true) }
}
}
override fun run() {
currentContext.obj = HttpConf(
baseUrl = Url(
url {
protocol = URLProtocol.HTTP
host = httpBindIp
port = httpBindPort
}
),
httpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(json = Json {
prettyPrint = true
isLenient = true
})
}
install(Auth) {
basic {
credentials {
BasicAuthCredentials("phoenix-cli", httpPassword)
}
}
}
}
)
}
}
class GetInfo : CliktCommand(name = "getinfo", help = "Show basic info about your node") {
private val commonOptions by requireObject<HttpConf>()
override fun run() {
runBlocking {
val res = commonOptions.httpClient.get(
url = commonOptions.baseUrl / "getinfo"
)
echo(res.bodyAsText())
}
}
}
class GetBalance : CliktCommand(name = "getbalance", help = "Returns your current balance") {
private val commonOptions by requireObject<HttpConf>()
override fun run() {
runBlocking {
val res = commonOptions.httpClient.get(
url = commonOptions.baseUrl / "getbalance"
)
echo(res.bodyAsText())
}
}
}
class ListChannels : CliktCommand(name = "listchannels", help = "List all channels") {
private val commonOptions by requireObject<HttpConf>()
override fun run() {
runBlocking {
val res = commonOptions.httpClient.get(
url = commonOptions.baseUrl / "listchannels"
)
echo(res.bodyAsText())
}
}
}
class CreateInvoice : CliktCommand(name = "createinvoice", help = "Create a Lightning invoice", printHelpOnEmptyArgs = true) {
private val commonOptions by requireObject<HttpConf>()
private val amountSat by option("--amountSat").long()
private val description by option("--description", "--desc").required()
override fun run() {
runBlocking {
val res = commonOptions.httpClient.submitForm(
url = (commonOptions.baseUrl / "createinvoice").toString(),
formParameters = parameters {
amountSat?.let { append("amountSat", amountSat.toString()) }
append("description", description)
}
)
echo(res.bodyAsText())
}
}
}
class PayInvoice : CliktCommand(name = "payinvoice", help = "Pay a Lightning invoice", printHelpOnEmptyArgs = true) {
private val commonOptions by requireObject<HttpConf>()
private val amountSat by option("--amountSat").long()
private val invoice by option("--invoice").required().check { Bolt11Invoice.read(it).isSuccess }
override fun run() {
runBlocking {
val res = commonOptions.httpClient.submitForm(
url = (commonOptions.baseUrl / "payinvoice").toString(),
formParameters = parameters {
amountSat?.let { append("amountSat", amountSat.toString()) }
append("invoice", invoice)
}
)
echo(res.bodyAsText())
}
}
}
class SendToAddress : CliktCommand(name = "sendtoaddress", help = "Send to a Bitcoin address", printHelpOnEmptyArgs = true) {
private val commonOptions by requireObject<HttpConf>()
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 }
private val feerateSatByte by option("--feerateSatByte").int().required()
override fun run() {
runBlocking {
val res = commonOptions.httpClient.submitForm(
url = (commonOptions.baseUrl / "sendtoaddress").toString(),
formParameters = parameters {
append("amountSat", amountSat.toString())
append("address", address)
append("feerateSatByte", feerateSatByte.toString())
}
)
echo(res.bodyAsText())
}
}
}
class CloseChannel : CliktCommand(name = "closechannel", help = "Close all channels", printHelpOnEmptyArgs = true) {
private val commonOptions by requireObject<HttpConf>()
private val channelId by option("--channelId").convert { ByteVector32.fromValidHex(it) }.required()
private val address by option("--address").required().check { runCatching { Base58Check.decode(it) }.isSuccess || runCatching { Bech32.decodeWitnessAddress(it) }.isSuccess }
private val feerateSatByte by option("--feerateSatByte").int().required()
override fun run() {
runBlocking {
val res = commonOptions.httpClient.submitForm(
url = (commonOptions.baseUrl / "closechannel").toString(),
formParameters = parameters {
append("channelId", channelId.toHex())
append("address", address)
append("feerateSatByte", feerateSatByte.toString())
}
)
echo(res.bodyAsText())
}
}
}
operator fun Url.div(path: String) = Url(URLBuilder(this).appendPathSegments(path))

View File

@@ -0,0 +1,48 @@
import kotlin.Boolean;
PRAGMA foreign_keys = 1;
-- channels table
-- note: boolean are stored as INTEGER, with 0=false
CREATE TABLE IF NOT EXISTS local_channels (
channel_id BLOB NOT NULL PRIMARY KEY,
data BLOB NOT NULL,
is_closed INTEGER AS Boolean DEFAULT 0 NOT NULL
);
-- htlcs info table
CREATE TABLE IF NOT EXISTS htlc_infos (
channel_id BLOB NOT NULL,
commitment_number INTEGER NOT NULL,
payment_hash BLOB NOT NULL,
cltv_expiry INTEGER NOT NULL,
FOREIGN KEY(channel_id) REFERENCES local_channels(channel_id)
);
CREATE INDEX IF NOT EXISTS htlc_infos_idx ON htlc_infos(channel_id, commitment_number);
-- channels queries
getChannel:
SELECT * FROM local_channels WHERE channel_id=?;
updateChannel:
UPDATE local_channels SET data=? WHERE channel_id=?;
insertChannel:
INSERT INTO local_channels VALUES (?, ?, 0);
closeLocalChannel:
UPDATE local_channels SET is_closed=1 WHERE channel_id=?;
listLocalChannels:
SELECT data FROM local_channels WHERE is_closed=0;
-- htlcs info queries
insertHtlcInfo:
INSERT INTO htlc_infos VALUES (?, ?, ?, ?);
listHtlcInfos:
SELECT payment_hash, cltv_expiry FROM htlc_infos WHERE channel_id=? AND commitment_number=?;
deleteHtlcInfo:
DELETE FROM htlc_infos WHERE channel_id=?;

View File

@@ -0,0 +1,38 @@
import fr.acinq.lightning.bin.db.payments.OutgoingPartClosingInfoTypeVersion;
-- Store in a flat row outgoing payments standing for channel-closing.
-- There are no complex json columns like in the outgoing_payments table.
-- This table replaces the legacy outgoing_payment_closing_tx_parts table.
CREATE TABLE IF NOT EXISTS channel_close_outgoing_payments (
id TEXT NOT NULL PRIMARY KEY,
recipient_amount_sat INTEGER NOT NULL,
address TEXT NOT NULL,
is_default_address INTEGER NOT NULL,
mining_fees_sat INTEGER NOT NULL,
tx_id BLOB NOT NULL,
created_at INTEGER NOT NULL,
confirmed_at INTEGER DEFAULT NULL,
locked_at INTEGER DEFAULT NULL,
channel_id BLOB NOT NULL,
closing_info_type TEXT AS OutgoingPartClosingInfoTypeVersion NOT NULL,
closing_info_blob BLOB NOT NULL
);
insertChannelCloseOutgoing:
INSERT INTO channel_close_outgoing_payments (
id, recipient_amount_sat, address, is_default_address, mining_fees_sat, tx_id, created_at, confirmed_at, locked_at, channel_id, closing_info_type, closing_info_blob
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
setConfirmed:
UPDATE channel_close_outgoing_payments SET confirmed_at=? WHERE id=?;
setLocked:
UPDATE channel_close_outgoing_payments SET locked_at=? WHERE id=?;
getChannelCloseOutgoing:
SELECT id, recipient_amount_sat, address, is_default_address, mining_fees_sat, tx_id, created_at, confirmed_at, locked_at, channel_id, closing_info_type, closing_info_blob
FROM channel_close_outgoing_payments
WHERE id=?;
deleteChannelCloseOutgoing:
DELETE FROM channel_close_outgoing_payments WHERE id=?;

View File

@@ -0,0 +1,34 @@
import fr.acinq.lightning.bin.db.payments.InboundLiquidityLeaseTypeVersion;
-- Stores in a flat row payments standing for an inbound liquidity request (which are done through a splice).
-- The lease data are stored in a complex column, as a json-encoded blob. See InboundLiquidityLeaseType file.
CREATE TABLE IF NOT EXISTS inbound_liquidity_outgoing_payments (
id TEXT NOT NULL PRIMARY KEY,
mining_fees_sat INTEGER NOT NULL,
channel_id BLOB NOT NULL,
tx_id BLOB NOT NULL,
lease_type TEXT AS InboundLiquidityLeaseTypeVersion NOT NULL,
lease_blob BLOB NOT NULL,
created_at INTEGER NOT NULL,
confirmed_at INTEGER DEFAULT NULL,
locked_at INTEGER DEFAULT NULL
);
insert:
INSERT INTO inbound_liquidity_outgoing_payments (
id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, created_at, confirmed_at, locked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
setConfirmed:
UPDATE inbound_liquidity_outgoing_payments SET confirmed_at=? WHERE id=?;
setLocked:
UPDATE inbound_liquidity_outgoing_payments SET locked_at=? WHERE id=?;
get:
SELECT id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, created_at, confirmed_at, locked_at
FROM inbound_liquidity_outgoing_payments
WHERE id=?;
delete:
DELETE FROM inbound_liquidity_outgoing_payments WHERE id=?;

View File

@@ -0,0 +1,98 @@
import fr.acinq.lightning.bin.db.payments.IncomingOriginTypeVersion;
import fr.acinq.lightning.bin.db.payments.IncomingReceivedWithTypeVersion;
-- incoming payments
CREATE TABLE IF NOT EXISTS incoming_payments (
payment_hash BLOB NOT NULL PRIMARY KEY,
preimage BLOB NOT NULL,
created_at INTEGER NOT NULL,
-- origin
origin_type TEXT AS IncomingOriginTypeVersion NOT NULL,
origin_blob BLOB NOT NULL,
-- this field is legacy, the amount received is the sum of the received-with parts
received_amount_msat INTEGER DEFAULT NULL,
-- timestamp when the payment has been received
received_at INTEGER DEFAULT NULL,
-- received-with parts
received_with_type TEXT AS IncomingReceivedWithTypeVersion DEFAULT NULL,
received_with_blob BLOB DEFAULT NULL
);
-- Create indexes to optimize the queries in AggregatedQueries.
-- Tip: Use "explain query plan" to ensure they're actually being used.
CREATE INDEX IF NOT EXISTS incoming_payments_filter_idx
ON incoming_payments(received_at)
WHERE received_at IS NOT NULL;
-- queries
insert:
INSERT INTO incoming_payments (
payment_hash,
preimage,
created_at,
origin_type,
origin_blob)
VALUES (?, ?, ?, ?, ?);
updateReceived:
UPDATE incoming_payments
SET received_at=?,
received_with_type=?,
received_with_blob=?
WHERE payment_hash = ?;
insertAndReceive:
INSERT INTO incoming_payments (
payment_hash,
preimage,
created_at,
origin_type, origin_blob,
received_at,
received_with_type,
received_with_blob)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
get:
SELECT payment_hash, preimage, created_at, origin_type, origin_blob, received_amount_msat, received_at, received_with_type, received_with_blob
FROM incoming_payments
WHERE payment_hash=?;
getOldestReceivedDate:
SELECT received_at
FROM incoming_payments AS r
WHERE received_at IS NOT NULL
ORDER BY r.received_at ASC
LIMIT 1;
listAllWithin:
SELECT payment_hash, preimage, created_at, origin_type, origin_blob, received_amount_msat, received_at, received_with_type, received_with_blob
FROM incoming_payments
WHERE created_at BETWEEN :from AND :to
ORDER BY
coalesce(received_at, created_at) DESC,
payment_hash DESC;
listAllNotConfirmed:
SELECT incoming_payments.payment_hash, preimage, created_at, origin_type, origin_blob, received_amount_msat, received_at, received_with_type, received_with_blob
FROM incoming_payments
LEFT OUTER JOIN link_tx_to_payments
ON link_tx_to_payments.type = 1
AND link_tx_to_payments.confirmed_at IS NULL
AND link_tx_to_payments.id = incoming_payments.payment_hash
WHERE received_at IS NOT NULL
;
scanCompleted:
SELECT payment_hash,
received_at
FROM incoming_payments
WHERE received_at IS NOT NULL;
delete:
DELETE FROM incoming_payments
WHERE payment_hash = ?;
-- use this in a `transaction` block to know how many rows were changed after an UPDATE
changes:
SELECT changes();

View File

@@ -0,0 +1,29 @@
-- This table links an on-chain transaction to one/many incoming or outgoing payment
-- * tx_id => hex identifier of an on-chain transaction
-- * type => tracks the type of a payment. The value is an int as defined in the DbType enum
-- * id => the identifier of the payment, can be a payment hash (incoming) or a UUID (outgoing)
CREATE TABLE IF NOT EXISTS link_tx_to_payments (
tx_id BLOB NOT NULL,
type INTEGER NOT NULL,
id TEXT NOT NULL,
confirmed_at INTEGER DEFAULT NULL,
locked_at INTEGER DEFAULT NULL,
PRIMARY KEY (tx_id, type, id)
);
CREATE INDEX IF NOT EXISTS link_tx_to_payments_txid ON link_tx_to_payments(tx_id);
listUnconfirmed:
SELECT DISTINCT(tx_id) FROM link_tx_to_payments WHERE confirmed_at IS NULL;
getPaymentIdForTx:
SELECT tx_id, type, id FROM link_tx_to_payments WHERE tx_id=?;
linkTxToPayment:
INSERT INTO link_tx_to_payments(tx_id, type, id) VALUES (?, ?, ?);
setConfirmed:
UPDATE link_tx_to_payments SET confirmed_at=? WHERE tx_id=?;
setLocked:
UPDATE link_tx_to_payments SET locked_at=? WHERE tx_id=?;

View File

@@ -0,0 +1,228 @@
import fr.acinq.lightning.db.HopDesc;
import fr.acinq.lightning.bin.db.payments.OutgoingDetailsTypeVersion;
import fr.acinq.lightning.bin.db.payments.OutgoingPartClosingInfoTypeVersion;
import fr.acinq.lightning.bin.db.payments.OutgoingPartStatusTypeVersion;
import fr.acinq.lightning.bin.db.payments.OutgoingStatusTypeVersion;
import kotlin.collections.List;
PRAGMA foreign_keys = 1;
-- outgoing payments
-- Stores an outgoing payment in a flat row. Some columns can be null.
CREATE TABLE IF NOT EXISTS outgoing_payments (
id TEXT NOT NULL PRIMARY KEY,
recipient_amount_msat INTEGER NOT NULL,
recipient_node_id TEXT NOT NULL,
payment_hash BLOB NOT NULL,
created_at INTEGER NOT NULL,
-- details
details_type TEXT AS OutgoingDetailsTypeVersion NOT NULL,
details_blob BLOB NOT NULL,
-- status
completed_at INTEGER DEFAULT NULL,
status_type TEXT AS OutgoingStatusTypeVersion DEFAULT NULL,
status_blob BLOB DEFAULT NULL
);
-- Create indexes to optimize the queries in AggregatedQueries.
-- Tip: Use "explain query plan" to ensure they're actually being used.
CREATE INDEX IF NOT EXISTS outgoing_payments_filter_idx
ON outgoing_payments(completed_at);
-- Stores the lightning parts that make up a lightning payment
CREATE TABLE IF NOT EXISTS outgoing_payment_parts (
part_id TEXT NOT NULL PRIMARY KEY,
part_parent_id TEXT NOT NULL,
part_amount_msat INTEGER NOT NULL,
part_route TEXT AS List<HopDesc> NOT NULL,
part_created_at INTEGER NOT NULL,
-- status
part_completed_at INTEGER DEFAULT NULL,
part_status_type TEXT AS OutgoingPartStatusTypeVersion DEFAULT NULL,
part_status_blob BLOB DEFAULT NULL,
FOREIGN KEY(part_parent_id) REFERENCES outgoing_payments(id)
);
-- !! This table is legacy, and will only contain old payments. See ChannelCloseOutgoingPayment.sq for the new table.
-- Stores the transactions that close a channel
CREATE TABLE IF NOT EXISTS outgoing_payment_closing_tx_parts (
part_id TEXT NOT NULL PRIMARY KEY,
part_parent_id TEXT NOT NULL,
part_tx_id BLOB NOT NULL,
part_amount_sat INTEGER NOT NULL,
part_closing_info_type TEXT AS OutgoingPartClosingInfoTypeVersion NOT NULL,
part_closing_info_blob BLOB NOT NULL,
part_created_at INTEGER NOT NULL,
FOREIGN KEY(part_parent_id) REFERENCES outgoing_payments(id)
);
-- A FOREIGN KEY does NOT create an implicit index.
-- One would expect it to, but it doesn't.
-- As per the docs (https://sqlite.org/foreignkeys.html):
-- > Indices are not required for child key columns but they are almost always beneficial.
-- > [...] So, in most real systems, an index should be created on the child key columns
-- > of each foreign key constraint.
CREATE INDEX IF NOT EXISTS parent_id_idx ON outgoing_payment_parts(part_parent_id);
CREATE INDEX IF NOT EXISTS parent_id_idx ON outgoing_payment_closing_tx_parts(part_parent_id);
-- queries for outgoing payments
hasPayment:
SELECT COUNT(*) FROM outgoing_payments
WHERE id = ?;
insertPayment:
INSERT INTO outgoing_payments (
id,
recipient_amount_msat,
recipient_node_id,
payment_hash,
created_at,
details_type,
details_blob)
VALUES (?, ?, ?, ?, ?, ?, ?);
updatePayment:
UPDATE outgoing_payments SET completed_at=?, status_type=?, status_blob=? WHERE id=?;
scanCompleted:
SELECT id, completed_at
FROM outgoing_payments
WHERE completed_at IS NOT NULL;
deletePayment:
DELETE FROM outgoing_payments WHERE id = ?;
-- queries for lightning parts
countLightningPart:
SELECT COUNT(*) FROM outgoing_payment_parts WHERE part_id = ?;
insertLightningPart:
INSERT INTO outgoing_payment_parts (
part_id,
part_parent_id,
part_amount_msat,
part_route,
part_created_at)
VALUES (?, ?, ?, ?, ?);
updateLightningPart:
UPDATE outgoing_payment_parts
SET part_status_type=?,
part_status_blob=?,
part_completed_at=?
WHERE part_id=?;
getLightningPart:
SELECT * FROM outgoing_payment_parts WHERE part_id=?;
deleteLightningPartsForParentId:
DELETE FROM outgoing_payment_parts WHERE part_parent_id = ?;
-- queries for closing tx parts
countClosingTxPart:
SELECT COUNT(*) FROM outgoing_payment_closing_tx_parts WHERE part_id = ?;
insertClosingTxPart:
INSERT INTO outgoing_payment_closing_tx_parts (
part_id,
part_parent_id,
part_tx_id,
part_amount_sat,
part_closing_info_type,
part_closing_info_blob,
part_created_at
) VALUES (:id, :parent_id, :tx_id, :amount_msat, :closing_info_type, :closing_info_blob, :created_at);
-- queries mixing outgoing payments and parts
getPaymentWithoutParts:
SELECT id,
recipient_amount_msat,
recipient_node_id,
payment_hash,
details_type,
details_blob,
created_at,
completed_at,
status_type,
status_blob
FROM outgoing_payments
WHERE id=?;
getOldestCompletedDate:
SELECT completed_at
FROM outgoing_payments AS o
WHERE completed_at IS NOT NULL
ORDER BY o.completed_at ASC
LIMIT 1;
getPayment:
SELECT parent.id,
parent.recipient_amount_msat,
parent.recipient_node_id,
parent.payment_hash,
parent.details_type,
parent.details_blob,
parent.created_at,
parent.completed_at,
parent.status_type,
parent.status_blob,
-- lightning parts
lightning_parts.part_id AS lightning_part_id,
lightning_parts.part_amount_msat AS lightning_part_amount_msat,
lightning_parts.part_route AS lightning_part_route,
lightning_parts.part_created_at AS lightning_part_created_at,
lightning_parts.part_completed_at AS lightning_part_completed_at,
lightning_parts.part_status_type AS lightning_part_status_type,
lightning_parts.part_status_blob AS lightning_part_status_blob,
-- closing tx parts
closing_parts.part_id AS closingtx_part_id,
closing_parts.part_tx_id AS closingtx_tx_id,
closing_parts.part_amount_sat AS closingtx_amount_sat,
closing_parts.part_closing_info_type AS closingtx_info_type,
closing_parts.part_closing_info_blob AS closingtx_info_blob,
closing_parts.part_created_at AS closingtx_created_at
FROM outgoing_payments AS parent
LEFT OUTER JOIN outgoing_payment_parts AS lightning_parts ON lightning_parts.part_parent_id = parent.id
LEFT OUTER JOIN outgoing_payment_closing_tx_parts AS closing_parts ON closing_parts.part_parent_id = parent.id
WHERE parent.id=?;
listPaymentsForPaymentHash:
SELECT parent.id,
parent.recipient_amount_msat,
parent.recipient_node_id,
parent.payment_hash,
parent.details_type,
parent.details_blob,
parent.created_at,
parent.completed_at,
parent.status_type,
parent.status_blob,
-- lightning parts
lightning_parts.part_id AS lightning_part_id,
lightning_parts.part_amount_msat AS lightning_part_amount_msat,
lightning_parts.part_route AS lightning_part_route,
lightning_parts.part_created_at AS lightning_part_created_at,
lightning_parts.part_completed_at AS lightning_part_completed_at,
lightning_parts.part_status_type AS lightning_part_status_type,
lightning_parts.part_status_blob AS lightning_part_status_blob,
-- closing tx parts
closing_parts.part_id AS closingtx_part_id,
closing_parts.part_tx_id AS closingtx_tx_id,
closing_parts.part_amount_sat AS closingtx_amount_sat,
closing_parts.part_closing_info_type AS closingtx_info_type,
closing_parts.part_closing_info_blob AS closingtx_info_blob,
closing_parts.part_created_at AS closingtx_created_at
FROM outgoing_payments AS parent
LEFT OUTER JOIN outgoing_payment_parts AS lightning_parts ON lightning_parts.part_parent_id = parent.id
LEFT OUTER JOIN outgoing_payment_closing_tx_parts AS closing_parts ON closing_parts.part_parent_id = parent.id
WHERE payment_hash=?;
-- use this in a `transaction` block to know how many rows were changed after an UPDATE
changes:
SELECT changes();

View File

@@ -0,0 +1,32 @@
import fr.acinq.lightning.bin.db.payments.OutgoingPartClosingInfoTypeVersion;
-- Store in a flat row the outgoing payments standing for a CPFP (which are done throuh a splice).
-- There are no complex json columns like in the outgoing_payments table.
CREATE TABLE IF NOT EXISTS splice_cpfp_outgoing_payments (
id TEXT NOT NULL PRIMARY KEY,
mining_fees_sat INTEGER NOT NULL,
channel_id BLOB NOT NULL,
tx_id BLOB NOT NULL,
created_at INTEGER NOT NULL,
confirmed_at INTEGER DEFAULT NULL,
locked_at INTEGER DEFAULT NULL
);
insertCpfp:
INSERT INTO splice_cpfp_outgoing_payments (
id, mining_fees_sat, channel_id, tx_id, created_at, confirmed_at, locked_at
) VALUES (?, ?, ?, ?, ?, ?, ?);
setConfirmed:
UPDATE splice_cpfp_outgoing_payments SET confirmed_at=? WHERE id=?;
setLocked:
UPDATE splice_cpfp_outgoing_payments SET locked_at=? WHERE id=?;
getCpfp:
SELECT id, mining_fees_sat, channel_id, tx_id, created_at, confirmed_at, locked_at
FROM splice_cpfp_outgoing_payments
WHERE id=?;
deleteCpfp:
DELETE FROM splice_cpfp_outgoing_payments WHERE id=?;

View File

@@ -0,0 +1,32 @@
-- store a splice-out payment in a flat row
-- there are no complex json columns like in the outgoing_payments table
CREATE TABLE IF NOT EXISTS splice_outgoing_payments (
id TEXT NOT NULL PRIMARY KEY,
recipient_amount_sat INTEGER NOT NULL,
address TEXT NOT NULL,
mining_fees_sat INTEGER NOT NULL,
tx_id BLOB NOT NULL,
channel_id BLOB NOT NULL,
created_at INTEGER NOT NULL,
confirmed_at INTEGER DEFAULT NULL,
locked_at INTEGER DEFAULT NULL
);
insertSpliceOutgoing:
INSERT INTO splice_outgoing_payments (
id, recipient_amount_sat, address, mining_fees_sat, tx_id, channel_id, created_at, confirmed_at, locked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
setConfirmed:
UPDATE splice_outgoing_payments SET confirmed_at=? WHERE id=?;
setLocked:
UPDATE splice_outgoing_payments SET locked_at=? WHERE id=?;
getSpliceOutgoing:
SELECT id, recipient_amount_sat, address, mining_fees_sat, tx_id, channel_id, created_at, confirmed_at, locked_at
FROM splice_outgoing_payments
WHERE id=?;
deleteSpliceOutgoing:
DELETE FROM splice_outgoing_payments WHERE id=?;

View File

@@ -0,0 +1,24 @@
package fr.acinq.lightning.bin
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import fr.acinq.phoenix.db.ChannelsDatabase
import fr.acinq.phoenix.db.PaymentsDatabase
import okio.Path
import okio.Path.Companion.toPath
actual val homeDirectory: Path = System.getProperty("user.home").toPath()
actual fun createAppDbDriver(dir: Path): SqlDriver {
val path = dir / "phoenix.db"
val driver = JdbcSqliteDriver("jdbc:sqlite:$path")
ChannelsDatabase.Schema.create(driver)
return driver
}
actual fun createPaymentsDbDriver(dir: Path): SqlDriver {
val path = dir / "payments.db"
val driver = JdbcSqliteDriver("jdbc:sqlite:$path")
PaymentsDatabase.Schema.create(driver)
return driver
}

View File

@@ -0,0 +1,27 @@
package fr.acinq.lightning.bin
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import fr.acinq.phoenix.db.ChannelsDatabase
import fr.acinq.phoenix.db.PaymentsDatabase
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.toKString
import okio.Path
import okio.Path.Companion.toPath
import platform.posix.getenv
import platform.posix.setenv
@OptIn(ExperimentalForeignApi::class)
actual val homeDirectory: Path = setenv("KTOR_LOG_LEVEL", "WARN", 1).let { getenv("HOME")?.toKString()!!.toPath() }
actual fun createAppDbDriver(dir: Path): SqlDriver {
return NativeSqliteDriver(ChannelsDatabase.Schema, "phoenix.db",
onConfiguration = { it.copy(extendedConfig = it.extendedConfig.copy(basePath = dir.toString())) }
)
}
actual fun createPaymentsDbDriver(dir: Path): SqlDriver {
return NativeSqliteDriver(PaymentsDatabase.Schema, "payments.db",
onConfiguration = { it.copy(extendedConfig = it.extendedConfig.copy(basePath = dir.toString())) }
)
}