Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

High level payments overview method #1225

Merged
merged 3 commits into from
Dec 2, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/db/PaymentsDb.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import fr.acinq.eclair.{MilliSatoshi, ShortChannelId}

import scala.compat.Platform

trait PaymentsDb extends IncomingPaymentsDb with OutgoingPaymentsDb
trait PaymentsDb extends IncomingPaymentsDb with OutgoingPaymentsDb with PaymentsOverviewDb

trait IncomingPaymentsDb {
/** Add a new expected incoming payment (not yet received). */
Expand Down Expand Up @@ -193,4 +193,41 @@ object FailureSummary {
case RemoteFailure(route, e) => FailureSummary(FailureType.REMOTE, e.failureMessage.message, route.map(h => HopSummary(h)).toList)
case UnreadableRemoteFailure(route) => FailureSummary(FailureType.UNREADABLE_REMOTE, "could not decrypt failure onion", route.map(h => HopSummary(h)).toList)
}
}
}

trait PaymentsOverviewDb {
def listPaymentsOverview(limit: Int): Seq[PlainPayment]
}

/**
* Generic payment trait holding only the minimum information in the most plain type possible. Notably, payment request
* is kept as a String, because deserialization is costly.
* <p>
* This object should only be used for a high level snapshot of the payments stored in the payment database.
* <p>
* Payment status should be of the correct type, but may not contain all the required data (routes, failures...).
*/
sealed trait PlainPayment {
val paymentHash: ByteVector32
val paymentRequest: Option[String]
val finalAmount: Option[MilliSatoshi]
val createdAt: Long
val completedAt: Option[Long]
}

case class PlainIncomingPayment(paymentHash: ByteVector32,
finalAmount: Option[MilliSatoshi],
paymentRequest: Option[String],
status: IncomingPaymentStatus,
createdAt: Long,
completedAt: Option[Long],
expireAt: Option[Long]) extends PlainPayment

case class PlainOutgoingPayment(parentId: Option[UUID],
externalId: Option[String],
paymentHash: ByteVector32,
finalAmount: Option[MilliSatoshi],
paymentRequest: Option[String],
status: OutgoingPaymentStatus,
createdAt: Long,
completedAt: Option[Long]) extends PlainPayment
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import fr.acinq.eclair.payment.{PaymentFailed, PaymentRequest, PaymentSent}
import fr.acinq.eclair.wire.CommonCodecs
import grizzled.slf4j.Logging
import scodec.Attempt
import scodec.bits.BitVector
import scodec.codecs._

import scala.collection.immutable.Queue
Expand Down Expand Up @@ -141,7 +142,14 @@ class SqlitePaymentsDb(sqlite: Connection) extends PaymentsDb with Logging {
}

private def parseOutgoingPayment(rs: ResultSet): OutgoingPayment = {
val result = OutgoingPayment(
val status = buildOutgoingPaymentStatus(
rs.getByteVector32Nullable("payment_preimage"),
rs.getMilliSatoshiNullable("fees_msat"),
rs.getBitVectorOpt("payment_route"),
rs.getLongNullable("completed_at"),
rs.getBitVectorOpt("failures"))

OutgoingPayment(
UUID.fromString(rs.getString("id")),
UUID.fromString(rs.getString("parent_id")),
rs.getStringNullable("external_id"),
Expand All @@ -150,30 +158,31 @@ class SqlitePaymentsDb(sqlite: Connection) extends PaymentsDb with Logging {
PublicKey(rs.getByteVector("target_node_id")),
rs.getLong("created_at"),
rs.getStringNullable("payment_request").map(PaymentRequest.read),
OutgoingPaymentStatus.Pending
status
)
// If we have a pre-image, the payment succeeded.
rs.getByteVector32Nullable("payment_preimage") match {
case Some(paymentPreimage) => result.copy(status = OutgoingPaymentStatus.Succeeded(
paymentPreimage,
MilliSatoshi(rs.getLong("fees_msat")),
rs.getBitVectorOpt("payment_route").map(b => paymentRouteCodec.decode(b) match {
}

private def buildOutgoingPaymentStatus(preimage_opt: Option[ByteVector32], fees_opt: Option[MilliSatoshi], paymentRoute_opt: Option[BitVector], completedAt_opt: Option[Long], failures: Option[BitVector]): OutgoingPaymentStatus = {
preimage_opt match {
// If we have a pre-image, the payment succeeded.
case Some(preimage) => OutgoingPaymentStatus.Succeeded(
preimage, fees_opt.getOrElse(MilliSatoshi(0)), paymentRoute_opt.map(b => paymentRouteCodec.decode(b) match {
case Attempt.Successful(route) => route.value
case Attempt.Failure(_) => Nil
}).getOrElse(Nil),
rs.getLong("completed_at")
))
case None => getNullableLong(rs, "completed_at") match {
completedAt_opt.getOrElse(0)
)
case None => completedAt_opt match {
// Otherwise if the payment was marked completed, it's a failure.
case Some(completedAt) => result.copy(status = OutgoingPaymentStatus.Failed(
rs.getBitVectorOpt("failures").map(b => paymentFailuresCodec.decode(b) match {
case Attempt.Successful(failures) => failures.value
case Some(completedAt) => OutgoingPaymentStatus.Failed(
failures.map(b => paymentFailuresCodec.decode(b) match {
case Attempt.Successful(f) => f.value
case Attempt.Failure(_) => Nil
}).getOrElse(Nil),
completedAt
))
)
// Else it's still pending.
case _ => result
case _ => OutgoingPaymentStatus.Pending
}
}
}
Expand Down Expand Up @@ -245,14 +254,18 @@ class SqlitePaymentsDb(sqlite: Connection) extends PaymentsDb with Logging {
}

private def parseIncomingPayment(rs: ResultSet): IncomingPayment = {
val paymentRequest = PaymentRequest.read(rs.getString("payment_request"))
val paymentPreimage = rs.getByteVector32("payment_preimage")
val createdAt = rs.getLong("created_at")
val received = getNullableLong(rs, "received_msat").map(MilliSatoshi(_))
received match {
case Some(amount) => IncomingPayment(paymentRequest, paymentPreimage, createdAt, IncomingPaymentStatus.Received(amount, rs.getLong("received_at")))
case None if paymentRequest.isExpired => IncomingPayment(paymentRequest, paymentPreimage, createdAt, IncomingPaymentStatus.Expired)
case None => IncomingPayment(paymentRequest, paymentPreimage, createdAt, IncomingPaymentStatus.Pending)
val paymentRequest = rs.getString("payment_request")
IncomingPayment(PaymentRequest.read(paymentRequest),
rs.getByteVector32("payment_preimage"),
rs.getLong("created_at"),
buildIncomingPaymentStatus(rs.getMilliSatoshiNullable("received_msat"), Some(paymentRequest), rs.getLongNullable("received_at")))
}

private def buildIncomingPaymentStatus(amount_opt: Option[MilliSatoshi], serializedPaymentRequest_opt: Option[String], receivedAt_opt: Option[Long]): IncomingPaymentStatus = {
amount_opt match {
case Some(amount) => IncomingPaymentStatus.Received(amount, receivedAt_opt.getOrElse(0))
case None if serializedPaymentRequest_opt.exists(PaymentRequest.fastHasExpired) => IncomingPaymentStatus.Expired
case None => IncomingPaymentStatus.Pending
}
}

Expand Down Expand Up @@ -317,4 +330,73 @@ class SqlitePaymentsDb(sqlite: Connection) extends PaymentsDb with Logging {
q
}

override def listPaymentsOverview(limit: Int): Seq[PlainPayment] = {
// This query is an UNION of the ``sent_payments`` and ``received_payments`` table
// - missing fields set to NULL when needed.
// - only retrieve incoming payments that did receive funds.
// - outgoing payments are grouped by parent_id.
// - order by completion date (or creation date if nothing else).
using(sqlite.prepareStatement(
"""
|SELECT * FROM (
| SELECT 'received' as type,
| NULL as parent_id,
| NULL as external_id,
| payment_hash,
| payment_preimage,
| received_msat as final_amount,
| payment_request,
| NULL as target_node_id,
| created_at,
| received_at as completed_at,
| expire_at
| FROM received_payments
| WHERE final_amount > 0
|UNION ALL
| SELECT 'sent' as type,
| parent_id,
| external_id,
| payment_hash,
| payment_preimage,
| sum(amount_msat) as final_amount,
| payment_request,
| target_node_id,
| created_at,
| completed_at,
| NULL as expire_at
| FROM sent_payments
| GROUP BY parent_id
|)
|ORDER BY coalesce(completed_at, created_at) DESC
|LIMIT ?
""".stripMargin
)) { statement =>
statement.setInt(1, limit)
val rs = statement.executeQuery()
var q: Queue[PlainPayment] = Queue()
while (rs.next()) {
val parentId = rs.getUUIDNullable("parent_id")
val externalId_opt = rs.getStringNullable("external_id")
val paymentHash = rs.getByteVector32("payment_hash")
val paymentRequest_opt = rs.getStringNullable("payment_request")
val amount_opt = rs.getMilliSatoshiNullable("final_amount")
val createdAt = rs.getLong("created_at")
val completedAt_opt = rs.getLongNullable("completed_at")
val expireAt_opt = rs.getLongNullable("expire_at")

val p = if (rs.getString("type") == "received") {
val status: IncomingPaymentStatus = buildIncomingPaymentStatus(amount_opt, paymentRequest_opt, completedAt_opt)
PlainIncomingPayment(paymentHash, amount_opt, paymentRequest_opt, status, createdAt, completedAt_opt, expireAt_opt)
} else {
val preimage_opt = rs.getByteVector32Nullable("payment_preimage")
// note that the resulting status will not contain any details (routes, failures...)
val status: OutgoingPaymentStatus = buildOutgoingPaymentStatus(preimage_opt, None, None, completedAt_opt, None)
PlainOutgoingPayment(parentId, externalId_opt, paymentHash, amount_opt, paymentRequest_opt, status, createdAt, completedAt_opt)
}
q = q :+ p
}
q
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
package fr.acinq.eclair.db.sqlite

import java.sql.{Connection, ResultSet, Statement}
import java.util.UUID

import fr.acinq.bitcoin.ByteVector32
import fr.acinq.eclair.MilliSatoshi
import scodec.Codec
import scodec.bits.{BitVector, ByteVector}

Expand Down Expand Up @@ -83,16 +85,6 @@ object SqliteUtils {
q
}

/**
* This helper retrieves the value from a nullable integer column and interprets it as an option. This is needed
* because `rs.getLong` would return `0` for a null value.
* It is used on Android only
*/
def getNullableLong(rs: ResultSet, label: String): Option[Long] = {
val result = rs.getLong(label)
if (rs.wasNull()) None else Some(result)
}

/**
* Obtain an exclusive lock on a sqlite database. This is useful when we want to make sure that only one process
* accesses the database file (see https://www.sqlite.org/pragma.html).
Expand Down Expand Up @@ -130,6 +122,21 @@ object SqliteUtils {
if (rs.wasNull()) None else Some(result)
}

def getLongNullable(columnLabel: String): Option[Long] = {
val result = rs.getLong(columnLabel)
if (rs.wasNull()) None else Some(result)
}

def getUUIDNullable(label: String): Option[UUID] = {
val result = rs.getString(label)
if (rs.wasNull()) None else Some(UUID.fromString(result))
}

def getMilliSatoshiNullable(label: String): Option[MilliSatoshi] = {
val result = rs.getLong(label)
if (rs.wasNull()) None else Some(MilliSatoshi(result))
}

}

object ExtendedResultSet {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,47 @@ object PaymentRequest {
)
}

private def readBoltData(input: String): Bolt11Data = {
val lowercaseInput = input.toLowerCase
val separatorIndex = lowercaseInput.lastIndexOf('1')
val hrp = lowercaseInput.take(separatorIndex)
val prefix: String = prefixes.values.find(prefix => hrp.startsWith(prefix)).getOrElse(throw new RuntimeException("unknown prefix"))
val data = string2Bits(lowercaseInput.slice(separatorIndex + 1, lowercaseInput.length - 6)) // 6 == checksum size
Codecs.bolt11DataCodec.decode(data).require.value
}

/**
* Extracts the description from a serialized payment request that is **expected to be valid**.
* Throws an error if the payment request is not valid.
*
* @param input valid serialized payment request
* @return description as a String. If the description is a hash, returns the hash value as a String.
*/
def fastReadDescription(input: String): String = {
readBoltData(input).taggedFields.collectFirst {
case PaymentRequest.Description(d) => d
case PaymentRequest.DescriptionHash(h) => h.toString()
}.get
}

/**
* Checks if a serialized payment request is expired. Timestamp is compared to the System's current time.
*
* @param input valid serialized payment request
* @return true if the payment request has expired, false otherwise.
*/
def fastHasExpired(input: String): Boolean = {
val bolt11Data = readBoltData(input)
val expiry_opt = bolt11Data.taggedFields.collectFirst {
case p: PaymentRequest.Expiry => p
}
val timestamp = bolt11Data.timestamp
expiry_opt match {
case Some(expiry) => timestamp + expiry.toLong <= Platform.currentTime.milliseconds.toSeconds
case None => timestamp + DEFAULT_EXPIRY_SECONDS <= Platform.currentTime.milliseconds.toSeconds
}
}

/**
* @param pr payment request
* @return a bech32-encoded payment request
Expand Down
Loading