Skip to content

Commit

Permalink
Add a CSV export (#115)
Browse files Browse the repository at this point in the history
Usage:
```
$ ./phoenix-cli exportcsv
payment history has been exported to /home/<user>/.phoenix/exports/export-1728035509.csv
```

The resulting CSV allows precise tracking of the balance and fee credit, and shows the split between mining and service fees:
- balance: sum of all `amount_msat`
- fee credit: sum of all `fee_credit_msat`

Columns:
date|type|amount_msat|fee_credit_msat|mining_fee_sat|service_fee_msat|payment_hash|tx_id
---|---|---|---|---|---|---|---

Fixes #36.
  • Loading branch information
pm47 authored Oct 16, 2024
1 parent b70e4cb commit a3fe8f6
Show file tree
Hide file tree
Showing 10 changed files with 408 additions and 34 deletions.
19 changes: 15 additions & 4 deletions src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ import fr.acinq.bitcoin.utils.Either
import fr.acinq.bitcoin.utils.Try
import fr.acinq.bitcoin.utils.toEither
import fr.acinq.lightning.BuildVersions
import fr.acinq.lightning.ChannelEvents
import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.PaymentEvents
import fr.acinq.lightning.bin.api.WebsocketProtocolAuthenticationProvider
import fr.acinq.lightning.bin.conf.LSP
import fr.acinq.lightning.bin.csv.WalletPaymentCsvWriter
import fr.acinq.lightning.bin.db.SqlitePaymentsDb
import fr.acinq.lightning.bin.db.WalletPaymentId
import fr.acinq.lightning.bin.json.ApiType.*
Expand All @@ -31,14 +30,13 @@ import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelCommand
import fr.acinq.lightning.channel.ChannelFundingResponse
import fr.acinq.lightning.channel.LocalFundingStatus
import fr.acinq.lightning.channel.states.*
import fr.acinq.lightning.crypto.LocalKeyManager
import fr.acinq.lightning.db.ChannelCloseOutgoingPayment
import fr.acinq.lightning.io.ChannelClosing
import fr.acinq.lightning.io.Peer
import fr.acinq.lightning.io.WrappedChannelCommand
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.info
import fr.acinq.lightning.logging.warning
import fr.acinq.lightning.payment.Bolt11Invoice
import fr.acinq.lightning.utils.*
Expand Down Expand Up @@ -439,6 +437,19 @@ class Api(
call.respondText(channelClose.txId.toString())
}
}
post("export") {
val from = call.parameters.getOptionalLong("from") ?: 0L
val to = call.parameters.getOptionalLong("to") ?: currentTimestampMillis()
val csvPath = datadir / "exports" / "export-${currentTimestampSeconds()}.csv"
log.info { "exporting payments to $csvPath..." }
val csvWriter = WalletPaymentCsvWriter(csvPath)
paymentDb.processSuccessfulPayments(from, to) { payment ->
csvWriter.add(payment)
}
csvWriter.close()
log.info { "csv export completed" }
call.respond("payment history has been exported to $csvPath")
}
}
route("/websocket") {
authenticate(configurations = arrayOf(null, "websocket-protocol"), strategy = AuthenticationStrategy.FirstSuccessful) {
Expand Down
22 changes: 2 additions & 20 deletions src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package fr.acinq.lightning.bin

import app.cash.sqldelight.EnumColumnAdapter
import co.touchlab.kermit.CommonWriter
import co.touchlab.kermit.Severity
import co.touchlab.kermit.StaticConfig
Expand Down Expand Up @@ -32,7 +31,7 @@ import fr.acinq.lightning.bin.conf.getOrGenerateSeed
import fr.acinq.lightning.bin.db.SqliteChannelsDb
import fr.acinq.lightning.bin.db.SqlitePaymentsDb
import fr.acinq.lightning.bin.db.WalletPaymentId
import fr.acinq.lightning.bin.db.payments.LightningOutgoingQueries
import fr.acinq.lightning.bin.db.createPhoenixDb
import fr.acinq.lightning.bin.json.ApiType
import fr.acinq.lightning.bin.logs.FileLogWriter
import fr.acinq.lightning.bin.logs.TimestampFormatter
Expand Down Expand Up @@ -257,24 +256,7 @@ class Phoenixd : CliktCommand() {
consoleLog(cyan("offer: ${nodeParams.defaultOffer(lsp.walletParams.trampolineNode.id).first}"))

val driver = createAppDbDriver(datadir, chain, nodeParams.nodeId)
val database = PhoenixDatabase(
driver = driver,
lightning_outgoing_payment_partsAdapter = Lightning_outgoing_payment_parts.Adapter(
part_routeAdapter = LightningOutgoingQueries.hopDescAdapter,
part_status_typeAdapter = EnumColumnAdapter()
),
lightning_outgoing_paymentsAdapter = Lightning_outgoing_payments.Adapter(
status_typeAdapter = EnumColumnAdapter(),
details_typeAdapter = EnumColumnAdapter()
),
incoming_paymentsAdapter = Incoming_payments.Adapter(
origin_typeAdapter = EnumColumnAdapter(),
received_with_typeAdapter = EnumColumnAdapter()
),
channel_close_outgoing_paymentsAdapter = Channel_close_outgoing_payments.Adapter(
closing_info_typeAdapter = EnumColumnAdapter()
),
)
val database = createPhoenixDb(driver)
val channelsDb = SqliteChannelsDb(driver, database)
val paymentsDb = SqlitePaymentsDb(database)

Expand Down
43 changes: 43 additions & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/bin/csv/CsvWriter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package fr.acinq.lightning.bin.csv

import okio.BufferedSink
import okio.FileSystem
import okio.Path
import okio.buffer

/**
* A generic class for writing CSV files.
*/
open class CsvWriter(path: Path) {

private val sink: BufferedSink

init {
path.parent?.let { dir -> FileSystem.SYSTEM.createDirectories(dir) }
sink = FileSystem.SYSTEM.sink(path, mustCreate = false).buffer()
}

fun addRow(vararg fields: String) {
val cleanFields = fields.map { processField(it) }
sink.writeUtf8(cleanFields.joinToString(separator = ",", postfix = "\n"))
}

fun addRow(fields: List<String>) {
addRow(*fields.toTypedArray())
}

private fun processField(str: String): String {
return str.findAnyOf(listOf(",", "\"", "\n"))?.let {
// - field must be enclosed in double-quotes
// - a double-quote appearing inside the field must be
// escaped by preceding it with another double quote
"\"${str.replace("\"", "\"\"")}\""
} ?: str
}

fun close() {
sink.flush()
sink.close()
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package fr.acinq.lightning.bin.csv

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.*
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.sum
import fr.acinq.lightning.utils.toMilliSatoshi
import kotlinx.datetime.Instant
import okio.Path

/**
* Exports a payments db items to a csv file.
*
* The three main columns are:
* - `type`: can be any of [Type].
* - `amount_msat`: positive or negative, will be non-zero for all types except [Type.fee_credit]. Summing this value over all rows results in the current balance.
* - `fee_credit_msat`: positive or negative, will be zero for all types except [Type.fee_credit]. Summing this value over all rows results in the current fee credit.
*
* Other columns are metadata (timestamp, payment hash, txid, fee details).
*/
class WalletPaymentCsvWriter(path: Path) : CsvWriter(path) {

private val FIELD_DATE = "date"
private val FIELD_TYPE = "type"
private val FIELD_AMOUNT_MSAT = "amount_msat"
private val FIELD_FEE_CREDIT_MSAT = "fee_credit_msat"
private val FIELD_MINING_FEE_SAT = "mining_fee_sat"
private val FIELD_SERVICE_FEE_MSAT = "service_fee_msat"
private val FIELD_PAYMENT_HASH = "payment_hash"
private val FIELD_TX_ID = "tx_id"

init {
addRow(FIELD_DATE, FIELD_TYPE, FIELD_AMOUNT_MSAT, FIELD_FEE_CREDIT_MSAT, FIELD_MINING_FEE_SAT, FIELD_SERVICE_FEE_MSAT, FIELD_PAYMENT_HASH, FIELD_TX_ID)
}

@Suppress("EnumEntryName")
enum class Type {
legacy_swap_in,
legacy_swap_out,
legacy_pay_to_open,
legacy_pay_to_splice,
swap_in,
swap_out,
fee_bumping,
fee_credit,
lightning_received,
lightning_sent,
liquidity_purchase,
channel_close,
}

data class Details(
val type: Type,
val amount: MilliSatoshi,
val feeCredit: MilliSatoshi,
val miningFee: Satoshi,
val serviceFee: MilliSatoshi,
val paymentHash: ByteVector32?,
val txId: TxId?
)

private fun addRow(
timestamp: Long,
details: Details
) {
val dateStr = Instant.fromEpochMilliseconds(timestamp).toString() // ISO-8601 format
addRow(
dateStr,
details.type.toString(),
details.amount.msat.toString(),
details.feeCredit.msat.toString(),
details.miningFee.sat.toString(),
details.serviceFee.msat.toString(),
details.paymentHash?.toHex() ?: "",
details.txId?.toString() ?: ""
)
}

fun add(payment: WalletPayment) {
val timestamp = payment.completedAt ?: payment.createdAt

val details: List<Details> = when (payment) {
is IncomingPayment -> when (val origin = payment.origin) {
is IncomingPayment.Origin.Invoice -> extractLightningPaymentParts(payment)
is IncomingPayment.Origin.SwapIn -> listOf(
Details(
type = Type.legacy_swap_in,
amount = payment.amount,
feeCredit = 0.msat,
miningFee = payment.fees.truncateToSatoshi(),
serviceFee = 0.msat,
paymentHash = payment.paymentHash,
txId = null
)
)
is IncomingPayment.Origin.OnChain -> listOf(Details(Type.swap_in, amount = payment.amount, feeCredit = 0.msat, miningFee = payment.fees.truncateToSatoshi(), serviceFee = 0.msat, paymentHash = null, txId = origin.txId))
is IncomingPayment.Origin.Offer -> extractLightningPaymentParts(payment)
}

is LightningOutgoingPayment -> when (val details = payment.details) {
is LightningOutgoingPayment.Details.Normal -> listOf(Details(Type.lightning_sent, amount = -payment.amount, feeCredit = 0.msat, miningFee = 0.sat, serviceFee = payment.fees, paymentHash = payment.paymentHash, txId = null))
is LightningOutgoingPayment.Details.SwapOut -> listOf(Details(Type.legacy_swap_out, amount = -payment.amount, feeCredit = 0.msat, miningFee = details.swapOutFee, serviceFee = 0.msat, paymentHash = null, txId = null))
is LightningOutgoingPayment.Details.Blinded -> listOf(Details(Type.lightning_sent, amount = -payment.amount, feeCredit = 0.msat, miningFee = 0.sat, serviceFee = payment.fees, paymentHash = payment.paymentHash, txId = null))
}

is SpliceOutgoingPayment -> listOf(Details(Type.swap_out, amount = -payment.amount, feeCredit = 0.msat, miningFee = payment.miningFees, serviceFee = 0.msat, paymentHash = null, txId = payment.txId))
is ChannelCloseOutgoingPayment -> listOf(Details(Type.channel_close, amount = -payment.amount, feeCredit = 0.msat, miningFee = payment.miningFees, serviceFee = 0.msat, paymentHash = null, txId = payment.txId))
is SpliceCpfpOutgoingPayment -> listOf(Details(Type.fee_bumping, amount = -payment.amount, feeCredit = 0.msat, miningFee = payment.miningFees, serviceFee = 0.msat, paymentHash = null, txId = payment.txId))
is InboundLiquidityOutgoingPayment -> listOf(
Details(
Type.liquidity_purchase,
amount = 0.msat,
feeCredit = -payment.feeCreditUsed,
miningFee = payment.miningFees,
serviceFee = payment.serviceFees.toMilliSatoshi(),
paymentHash = null,
txId = payment.txId
)
)
}

details.forEach { addRow(timestamp, it) }

}

private fun extractLightningPaymentParts(payment: IncomingPayment): List<Details> = payment.received?.receivedWith.orEmpty()
.map {
when (it) {
is IncomingPayment.ReceivedWith.LightningPayment -> Details(Type.lightning_received, amount = it.amountReceived, feeCredit = 0.msat, miningFee = 0.sat, serviceFee = 0.msat, paymentHash = payment.paymentHash, txId = null)
is IncomingPayment.ReceivedWith.AddedToFeeCredit -> Details(Type.fee_credit, amount = 0.msat, feeCredit = it.amountReceived, miningFee = 0.sat, serviceFee = 0.msat, paymentHash = payment.paymentHash, txId = null)
is IncomingPayment.ReceivedWith.NewChannel -> Details(Type.legacy_pay_to_open, amount = it.amountReceived, feeCredit = 0.msat, miningFee = it.miningFee, serviceFee = it.serviceFee, paymentHash = payment.paymentHash, txId = it.txId)
is IncomingPayment.ReceivedWith.SpliceIn -> Details(Type.legacy_pay_to_splice, amount = it.amountReceived, feeCredit = 0.msat, miningFee = it.miningFee, serviceFee = it.serviceFee, paymentHash = payment.paymentHash, txId = it.txId)
else -> error("unexpected receivedWith part $it")
}
}
.groupBy { it.type }
.values.map { parts ->
Details(
type = parts.first().type,
amount = parts.map { it.amount }.sum(),
feeCredit = parts.map { it.feeCredit }.sum(),
miningFee = parts.map { it.miningFee }.sum(),
serviceFee = parts.map { it.serviceFee }.sum(),
paymentHash = parts.first().paymentHash,
txId = parts.first().txId
)
}.toList()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package fr.acinq.lightning.bin.db

import app.cash.sqldelight.EnumColumnAdapter
import app.cash.sqldelight.db.SqlDriver
import fr.acinq.lightning.bin.db.payments.LightningOutgoingQueries
import fr.acinq.phoenix.db.*

fun createPhoenixDb(driver: SqlDriver) = PhoenixDatabase(
driver = driver,
lightning_outgoing_payment_partsAdapter = Lightning_outgoing_payment_parts.Adapter(
part_routeAdapter = LightningOutgoingQueries.hopDescAdapter,
part_status_typeAdapter = EnumColumnAdapter()
),
lightning_outgoing_paymentsAdapter = Lightning_outgoing_payments.Adapter(
status_typeAdapter = EnumColumnAdapter(),
details_typeAdapter = EnumColumnAdapter()
),
incoming_paymentsAdapter = Incoming_payments.Adapter(
origin_typeAdapter = EnumColumnAdapter(),
received_with_typeAdapter = EnumColumnAdapter()
),
channel_close_outgoing_paymentsAdapter = Channel_close_outgoing_payments.Adapter(
closing_info_typeAdapter = EnumColumnAdapter()
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,14 @@ package fr.acinq.lightning.bin.db
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.csvexport.CsvExportQueries
import fr.acinq.lightning.bin.db.payments.*
import fr.acinq.lightning.bin.db.payments.LinkTxToPaymentQueries
import fr.acinq.lightning.bin.db.payments.PaymentsMetadataQueries
import fr.acinq.lightning.channel.ChannelException
import fr.acinq.lightning.db.*
import fr.acinq.lightning.payment.FinalFailure
import fr.acinq.lightning.utils.*
import fr.acinq.lightning.wire.FailureMessage
import fr.acinq.phoenix.db.*
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.lightning.utils.toByteVector32
import fr.acinq.phoenix.db.PhoenixDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

Expand All @@ -42,6 +40,7 @@ class SqlitePaymentsDb(val database: PhoenixDatabase) : PaymentsDb {
private val linkTxToPaymentQueries = LinkTxToPaymentQueries(database)
private val inboundLiquidityQueries = InboundLiquidityQueries(database)
val metadataQueries = PaymentsMetadataQueries(database)
val csvExportQueries = CsvExportQueries(database)

override suspend fun addOutgoingLightningParts(
parentId: UUID,
Expand Down Expand Up @@ -289,4 +288,42 @@ class SqlitePaymentsDb(val database: PhoenixDatabase) : PaymentsDb {
}
}
}

private suspend fun listSuccessfulPayments(from: Long, to: Long, limit: Long, offset: Long): List<WalletPayment> {
return withContext(Dispatchers.Default) {
csvExportQueries.listSuccessfulPaymentIds(from, to, limit, offset).mapNotNull { paymentId ->
when (paymentId) {
is WalletPaymentId.IncomingPaymentId -> {
inQueries.getIncomingPayment(paymentHash = paymentId.paymentHash)
}
is WalletPaymentId.InboundLiquidityOutgoingPaymentId -> {
inboundLiquidityQueries.get(paymentId.id)
}
is WalletPaymentId.LightningOutgoingPaymentId -> {
lightningOutgoingQueries.getPayment(paymentId.id)
}
is WalletPaymentId.SpliceOutgoingPaymentId -> {
spliceOutQueries.getSpliceOutPayment(paymentId.id)
}
is WalletPaymentId.SpliceCpfpOutgoingPaymentId -> {
cpfpQueries.getCpfp(paymentId.id)
}
is WalletPaymentId.ChannelCloseOutgoingPaymentId -> {
channelCloseQueries.getChannelCloseOutgoingPayment(paymentId.id)
}
}
}
}
}

suspend fun processSuccessfulPayments(from: Long, to: Long, batchSize: Long = 32, process: (WalletPayment) -> Unit) {
var batchOffset = 0L
var fetching = true
while (fetching) {
val results = listSuccessfulPayments(from, to, limit = batchSize, offset = batchOffset)
results.forEach { process(it) }
fetching = results.isNotEmpty()
batchOffset += results.size
}
}
}
Loading

0 comments on commit a3fe8f6

Please sign in to comment.