diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 7c425328c4..1a53978115 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -77,7 +77,7 @@ trait Eclair { def receivedInfo(paymentHash: ByteVector32)(implicit timeout: Timeout): Future[Option[IncomingPayment]] - def send(recipientNodeId: PublicKey, amountMsat: Long, paymentHash: ByteVector32, assistedRoutes: Seq[Seq[PaymentRequest.ExtraHop]] = Seq.empty, minFinalCltvExpiry_opt: Option[Long] = None, maxAttempts_opt: Option[Int] = None, feeThresholdSat_opt: Option[Long] = None, maxFeePct_opt: Option[Double] = None)(implicit timeout: Timeout): Future[UUID] + def send(recipientNodeId: PublicKey, amountMsat: Long, paymentHash: ByteVector32, assistedRoutes: Seq[Seq[PaymentRequest.ExtraHop]] = Seq.empty, minFinalCltvExpiry_opt: Option[Long] = None, maxAttempts_opt: Option[Int] = None, feeThresholdSat_opt: Option[Long] = None, maxFeePct_opt: Option[Double] = None, paymentRequest_opt: Option[PaymentRequest] = None)(implicit timeout: Timeout): Future[UUID] def sentInfo(id: Either[UUID, ByteVector32])(implicit timeout: Timeout): Future[Seq[OutgoingPayment]] @@ -188,7 +188,8 @@ class EclairImpl(appKit: Kit) extends Eclair { (appKit.paymentInitiator ? SendPaymentToRoute(amountMsat, paymentHash, route, finalCltvExpiry)).mapTo[UUID] } - override def send(recipientNodeId: PublicKey, amountMsat: Long, paymentHash: ByteVector32, assistedRoutes: Seq[Seq[PaymentRequest.ExtraHop]] = Seq.empty, minFinalCltvExpiry_opt: Option[Long], maxAttempts_opt: Option[Int], feeThresholdSat_opt: Option[Long], maxFeePct_opt: Option[Double])(implicit timeout: Timeout): Future[UUID] = { + override def send(recipientNodeId: PublicKey, amountMsat: Long, paymentHash: ByteVector32, assistedRoutes: Seq[Seq[PaymentRequest.ExtraHop]], minFinalCltvExpiry_opt: Option[Long], maxAttempts_opt: Option[Int], feeThresholdSat_opt: Option[Long], maxFeePct_opt: Option[Double], paymentRequest_opt: Option[PaymentRequest])(implicit timeout: Timeout): Future[UUID] = { + require(paymentRequest_opt.map(PaymentRequest.write(_).getBytes).forall(_.length <= 65536)) val maxAttempts = maxAttempts_opt.getOrElse(appKit.nodeParams.maxPaymentAttempts) val defaultRouteParams = Router.getDefaultRouteParams(appKit.nodeParams.routerConf) @@ -198,8 +199,8 @@ class EclairImpl(appKit: Kit) extends Eclair { ) val sendPayment = minFinalCltvExpiry_opt match { - case Some(minCltv) => SendPayment(amountMsat, paymentHash, recipientNodeId, assistedRoutes, finalCltvExpiry = minCltv, maxAttempts = maxAttempts, routeParams = Some(routeParams)) - case None => SendPayment(amountMsat, paymentHash, recipientNodeId, assistedRoutes, maxAttempts = maxAttempts, routeParams = Some(routeParams)) + case Some(minCltv) => SendPayment(amountMsat, paymentHash, recipientNodeId, paymentRequest_opt, assistedRoutes, finalCltvExpiry = minCltv, maxAttempts = maxAttempts, routeParams = Some(routeParams)) + case None => SendPayment(amountMsat, paymentHash, recipientNodeId, paymentRequest_opt, assistedRoutes, maxAttempts = maxAttempts, routeParams = Some(routeParams)) } (appKit.paymentInitiator ? sendPayment).mapTo[UUID] } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala b/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala index f816aa8be3..d9c6a531d6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala @@ -219,15 +219,15 @@ trait Service extends ExtraDirectives with Logging { path("payinvoice") { formFields(invoiceFormParam, amountMsatFormParam.?, "maxAttempts".as[Int].?, "feeThresholdSat".as[Long].?, "maxFeePct".as[Double].?) { case (invoice@PaymentRequest(_, Some(amount), _, nodeId, _, _), None, maxAttempts, feeThresholdSat_opt, maxFeePct_opt) => - complete(eclairApi.send(nodeId, amount.toLong, invoice.paymentHash, invoice.routingInfo, invoice.minFinalCltvExpiry, maxAttempts, feeThresholdSat_opt, maxFeePct_opt)) + complete(eclairApi.send(nodeId, amount.toLong, invoice.paymentHash, invoice.routingInfo, invoice.minFinalCltvExpiry, maxAttempts, feeThresholdSat_opt, maxFeePct_opt, None)) case (invoice, Some(overrideAmount), maxAttempts, feeThresholdSat_opt, maxFeePct_opt) => - complete(eclairApi.send(invoice.nodeId, overrideAmount, invoice.paymentHash, invoice.routingInfo, invoice.minFinalCltvExpiry, maxAttempts, feeThresholdSat_opt, maxFeePct_opt)) + complete(eclairApi.send(invoice.nodeId, overrideAmount, invoice.paymentHash, invoice.routingInfo, invoice.minFinalCltvExpiry, maxAttempts, feeThresholdSat_opt, maxFeePct_opt, None)) case _ => reject(MalformedFormFieldRejection("invoice", "The invoice must have an amount or you need to specify one using the field 'amountMsat'")) } } ~ path("sendtonode") { formFields(amountMsatFormParam, paymentHashFormParam, nodeIdFormParam, "maxAttempts".as[Int].?, "feeThresholdSat".as[Long].?, "maxFeePct".as[Double].?) { (amountMsat, paymentHash, nodeId, maxAttempts_opt, feeThresholdSat_opt, maxFeePct_opt) => - complete(eclairApi.send(nodeId, amountMsat, paymentHash, maxAttempts_opt = maxAttempts_opt, feeThresholdSat_opt = feeThresholdSat_opt, maxFeePct_opt = maxFeePct_opt)) + complete(eclairApi.send(nodeId, amountMsat, paymentHash, maxAttempts_opt = maxAttempts_opt, feeThresholdSat_opt = feeThresholdSat_opt, maxFeePct_opt = maxFeePct_opt, paymentRequest_opt = None)) } } ~ path("sendtoroute") { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/PaymentsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/PaymentsDb.scala index 38b1f60e8d..aaca372741 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/PaymentsDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/PaymentsDb.scala @@ -17,7 +17,9 @@ package fr.acinq.eclair.db import java.util.UUID + import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.eclair.payment.PaymentRequest trait PaymentsDb { @@ -65,18 +67,31 @@ trait PaymentsDb { case class IncomingPayment(paymentHash: ByteVector32, amountMsat: Long, receivedAt: Long) /** - * Sent payment is every payment that is sent by this node, they may not be finalized and + * OutgoingPayment is every payment that is sent by this node, they may not be finalized and * when is final it can be failed or successful. * - * @param id internal payment identifier - * @param paymentHash payment_hash - * @param preimage the preimage of the payment_hash, known if the outgoing payment was successful - * @param amountMsat amount of the payment, in milli-satoshis - * @param createdAt absolute time in seconds since UNIX epoch when the payment was created. - * @param completedAt absolute time in seconds since UNIX epoch when the payment succeeded. - * @param status current status of the payment. + * @param id internal payment identifier + * @param paymentHash payment_hash + * @param preimage the preimage of the payment_hash, known if the outgoing payment was successful + * @param targetNodeId the recipient of this payment + * @param amountMsat amount of the payment, in milli-satoshis + * @param createdAt absolute time in seconds since UNIX epoch when the payment was created. + * @param completedAt absolute time in seconds since UNIX epoch when the payment succeeded. + * @param status current status of the payment. + * @param paymentRequest_opt the payment request (serialized) that was associated with this payment + * @param description_opt a custom description */ -case class OutgoingPayment(id: UUID, paymentHash: ByteVector32, preimage:Option[ByteVector32], amountMsat: Long, createdAt: Long, completedAt: Option[Long], status: OutgoingPaymentStatus.Value) +case class OutgoingPayment( + id: UUID, + paymentHash: ByteVector32, + preimage:Option[ByteVector32], + targetNodeId: Option[PublicKey], + amountMsat: Long, + createdAt: Long, + completedAt: Option[Long], + status: OutgoingPaymentStatus.Value, + paymentRequest_opt: Option[String] = None, + description_opt: Option[String] = None) object OutgoingPaymentStatus extends Enumeration { val PENDING = Value(1, "PENDING") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqlitePaymentsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqlitePaymentsDb.scala index 8fd6ded566..7d318ac509 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqlitePaymentsDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqlitePaymentsDb.scala @@ -16,7 +16,7 @@ package fr.acinq.eclair.db.sqlite -import java.sql.Connection +import java.sql.{Connection, Statement} import java.util.UUID import fr.acinq.bitcoin.ByteVector32 import fr.acinq.eclair.db.sqlite.SqliteUtils._ @@ -25,6 +25,7 @@ import fr.acinq.eclair.payment.PaymentRequest import grizzled.slf4j.Logging import scala.collection.immutable.Queue import OutgoingPaymentStatus._ +import fr.acinq.bitcoin.Crypto.PublicKey import concurrent.duration._ import scala.compat.Platform @@ -33,25 +34,50 @@ class SqlitePaymentsDb(sqlite: Connection) extends PaymentsDb with Logging { import SqliteUtils.ExtendedResultSet._ val DB_NAME = "payments" - val CURRENT_VERSION = 2 + val CURRENT_VERSION = 3 - using(sqlite.createStatement()) { statement => - require(getVersion(statement, DB_NAME, CURRENT_VERSION) <= CURRENT_VERSION, s"incompatible version of $DB_NAME DB found") // version 2 is "backward compatible" in the sense that it uses separate tables from version 1. There is no migration though + def migration12(statement: Statement) = { statement.executeUpdate("CREATE TABLE IF NOT EXISTS received_payments (payment_hash BLOB NOT NULL PRIMARY KEY, preimage BLOB NOT NULL, payment_request TEXT NOT NULL, received_msat INTEGER, created_at INTEGER NOT NULL, expire_at INTEGER, received_at INTEGER)") statement.executeUpdate("CREATE TABLE IF NOT EXISTS sent_payments (id TEXT NOT NULL PRIMARY KEY, payment_hash BLOB NOT NULL, preimage BLOB, amount_msat INTEGER NOT NULL, created_at INTEGER NOT NULL, completed_at INTEGER, status VARCHAR NOT NULL)") statement.executeUpdate("CREATE INDEX IF NOT EXISTS payment_hash_idx ON sent_payments(payment_hash)") - setVersion(statement, DB_NAME, CURRENT_VERSION) + } + + def migration23(statement: Statement) = { + statement.executeUpdate("ALTER TABLE sent_payments ADD payment_request TEXT") + statement.executeUpdate("ALTER TABLE sent_payments ADD description BLOB") + statement.executeUpdate(s"ALTER TABLE sent_payments ADD target_node_id BLOB") + } + + using(sqlite.createStatement()) { statement => + getVersion(statement, DB_NAME, CURRENT_VERSION) match { + case 1 => + logger.warn(s"migrating $DB_NAME from version 1 to $CURRENT_VERSION") + migration12(statement) + migration23(statement) + setVersion(statement, DB_NAME, CURRENT_VERSION) + case 2 => + logger.warn(s"migrating $DB_NAME from version 2 to $CURRENT_VERSION") + migration23(statement) + setVersion(statement, DB_NAME, CURRENT_VERSION) + case CURRENT_VERSION => + statement.executeUpdate("CREATE TABLE IF NOT EXISTS received_payments (payment_hash BLOB NOT NULL PRIMARY KEY, preimage BLOB NOT NULL, payment_request TEXT NOT NULL, received_msat INTEGER, created_at INTEGER NOT NULL, expire_at INTEGER, received_at INTEGER)") + statement.executeUpdate("CREATE TABLE IF NOT EXISTS sent_payments (id TEXT NOT NULL PRIMARY KEY, payment_hash BLOB NOT NULL, preimage BLOB, amount_msat INTEGER NOT NULL, created_at INTEGER NOT NULL, completed_at INTEGER, status VARCHAR NOT NULL, payment_request TEXT, description BLOB, target_node_id BLOB)") + statement.executeUpdate("CREATE INDEX IF NOT EXISTS payment_hash_idx ON sent_payments(payment_hash)") + setVersion(statement, DB_NAME, CURRENT_VERSION) + } } override def addOutgoingPayment(sent: OutgoingPayment): Unit = { - using(sqlite.prepareStatement("INSERT INTO sent_payments (id, payment_hash, amount_msat, created_at, status) VALUES (?, ?, ?, ?, ?)")) { statement => + using(sqlite.prepareStatement("INSERT INTO sent_payments (id, payment_hash, amount_msat, created_at, status, payment_request, description, target_node_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) { statement => statement.setString(1, sent.id.toString) statement.setBytes(2, sent.paymentHash.toArray) statement.setLong(3, sent.amountMsat) statement.setLong(4, sent.createdAt) statement.setString(5, sent.status.toString) - val res = statement.executeUpdate() - logger.debug(s"inserted $res payment=${sent.paymentHash} into payment DB") + statement.setString(6, sent.paymentRequest_opt.orNull) + statement.setBytes(7, sent.description_opt.map(_.getBytes).orNull) + statement.setBytes(8, sent.targetNodeId.map(_.value.toArray).orNull) + statement.executeUpdate() } } @@ -68,7 +94,7 @@ class SqlitePaymentsDb(sqlite: Connection) extends PaymentsDb with Logging { } override def getOutgoingPayment(id: UUID): Option[OutgoingPayment] = { - using(sqlite.prepareStatement("SELECT id, payment_hash, preimage, amount_msat, created_at, completed_at, status FROM sent_payments WHERE id = ?")) { statement => + using(sqlite.prepareStatement("SELECT id, payment_hash, preimage, amount_msat, created_at, completed_at, status, payment_request, description, target_node_id FROM sent_payments WHERE id = ?")) { statement => statement.setString(1, id.toString) val rs = statement.executeQuery() if (rs.next()) { @@ -76,10 +102,13 @@ class SqlitePaymentsDb(sqlite: Connection) extends PaymentsDb with Logging { UUID.fromString(rs.getString("id")), rs.getByteVector32("payment_hash"), rs.getByteVector32Nullable("preimage"), + rs.getByteVectorNullable("target_node_id").map(PublicKey(_)), rs.getLong("amount_msat"), rs.getLong("created_at"), - getNullableLong(rs, "completed_at"), - OutgoingPaymentStatus.withName(rs.getString("status")) + rs.getNullableLong("completed_at"), + OutgoingPaymentStatus.withName(rs.getString("status")), + rs.getStringNullable("payment_request"), + rs.getByteVectorNullable("description").map(bytes => new String(bytes.toArray)) )) } else { None @@ -88,7 +117,7 @@ class SqlitePaymentsDb(sqlite: Connection) extends PaymentsDb with Logging { } override def getOutgoingPayments(paymentHash: ByteVector32): Seq[OutgoingPayment] = { - using(sqlite.prepareStatement("SELECT id, payment_hash, preimage, amount_msat, created_at, completed_at, status FROM sent_payments WHERE payment_hash = ?")) { statement => + using(sqlite.prepareStatement("SELECT id, payment_hash, preimage, amount_msat, created_at, completed_at, status, payment_request, description, target_node_id FROM sent_payments WHERE payment_hash = ?")) { statement => statement.setBytes(1, paymentHash.toArray) val rs = statement.executeQuery() var q: Queue[OutgoingPayment] = Queue() @@ -97,10 +126,13 @@ class SqlitePaymentsDb(sqlite: Connection) extends PaymentsDb with Logging { UUID.fromString(rs.getString("id")), rs.getByteVector32("payment_hash"), rs.getByteVector32Nullable("preimage"), + rs.getByteVectorNullable("target_node_id").map(PublicKey(_)), rs.getLong("amount_msat"), rs.getLong("created_at"), - getNullableLong(rs, "completed_at"), - OutgoingPaymentStatus.withName(rs.getString("status")) + rs.getNullableLong("completed_at"), + OutgoingPaymentStatus.withName(rs.getString("status")), + rs.getStringNullable("payment_request"), + rs.getByteVectorNullable("description").map(bytes => new String(bytes.toArray)) ) } q @@ -109,17 +141,20 @@ class SqlitePaymentsDb(sqlite: Connection) extends PaymentsDb with Logging { override def listOutgoingPayments(): Seq[OutgoingPayment] = { using(sqlite.createStatement()) { statement => - val rs = statement.executeQuery("SELECT id, payment_hash, preimage, amount_msat, created_at, completed_at, status FROM sent_payments") + val rs = statement.executeQuery("SELECT id, payment_hash, preimage, amount_msat, created_at, completed_at, status, payment_request, description, target_node_id FROM sent_payments") var q: Queue[OutgoingPayment] = Queue() while (rs.next()) { q = q :+ OutgoingPayment( UUID.fromString(rs.getString("id")), rs.getByteVector32("payment_hash"), rs.getByteVector32Nullable("preimage"), + rs.getByteVectorNullable("target_node_id").map(PublicKey(_)), rs.getLong("amount_msat"), rs.getLong("created_at"), - getNullableLong(rs, "completed_at"), - OutgoingPaymentStatus.withName(rs.getString("status")) + rs.getNullableLong("completed_at"), + OutgoingPaymentStatus.withName(rs.getString("status")), + rs.getStringNullable("payment_request"), + rs.getByteVectorNullable("description").map(bytes => new String(bytes.toArray)) ) } q diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteUtils.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteUtils.scala index dda0510dee..2e8463d833 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteUtils.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteUtils.scala @@ -17,11 +17,9 @@ package fr.acinq.eclair.db.sqlite import java.sql.{Connection, ResultSet, Statement} - import fr.acinq.bitcoin.ByteVector32 import scodec.Codec import scodec.bits.{BitVector, ByteVector} - import scala.collection.immutable.Queue object SqliteUtils { @@ -92,19 +90,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 - * - * @param label - * @return - */ - 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). @@ -127,9 +112,21 @@ object SqliteUtils { def getByteVector32(columnLabel: String): ByteVector32 = ByteVector32(ByteVector(rs.getBytes(columnLabel))) - def getByteVector32Nullable(columnLabel: String): Option[ByteVector32] = { + def getByteVectorNullable(columnLabel: String): Option[ByteVector] = { val bytes = rs.getBytes(columnLabel) - if(rs.wasNull()) None else Some(ByteVector32(ByteVector(bytes))) + if(rs.wasNull()) None else Some(ByteVector(bytes)) + } + + def getByteVector32Nullable(columnLabel: String): Option[ByteVector32] = getByteVectorNullable(columnLabel).map(ByteVector32(_)) + + def getStringNullable(columnLabel: String): Option[String] = { + val str = rs.getString(columnLabel) + if(rs.wasNull()) None else Some(str) + } + + def getNullableLong(label: String) : Option[Long] = { + val result = rs.getLong(label) + if (rs.wasNull()) None else Some(result) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/Autoprobe.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/Autoprobe.scala index 918d02f065..7b32206649 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/Autoprobe.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/Autoprobe.scala @@ -23,7 +23,6 @@ import fr.acinq.eclair.payment.PaymentLifecycle.{PaymentFailed, PaymentResult, R import fr.acinq.eclair.router.{Announcements, Data} import fr.acinq.eclair.wire.{IncorrectOrUnknownPaymentDetails} import fr.acinq.eclair.{NodeParams, randomBytes32, secureRandom} - import scala.concurrent.duration._ /** @@ -54,7 +53,7 @@ class Autoprobe(nodeParams: NodeParams, router: ActorRef, paymentInitiator: Acto case Some(targetNodeId) => val paymentHash = randomBytes32 // we don't even know the preimage (this needs to be a secure random!) log.info(s"sending payment probe to node=$targetNodeId payment_hash=$paymentHash") - paymentInitiator ! SendPayment(PAYMENT_AMOUNT_MSAT, paymentHash, targetNodeId, maxAttempts = 1) + paymentInitiator ! SendPayment(PAYMENT_AMOUNT_MSAT, paymentHash, targetNodeId, maxAttempts = 1, paymentRequest_opt = None) case None => log.info(s"could not find a destination, re-scheduling") scheduleProbe() diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentLifecycle.scala index cf878ed627..b04d3b257c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentLifecycle.scala @@ -48,14 +48,14 @@ class PaymentLifecycle(nodeParams: NodeParams, id: UUID, router: ActorRef, regis when(WAITING_FOR_REQUEST) { case Event(c: SendPaymentToRoute, WaitingForRequest) => - val send = SendPayment(c.amountMsat, c.paymentHash, c.hops.last, finalCltvExpiry = c.finalCltvExpiry, maxAttempts = 1) - paymentsDb.addOutgoingPayment(OutgoingPayment(id, c.paymentHash, None, c.amountMsat, Platform.currentTime, None, OutgoingPaymentStatus.PENDING)) + val send = SendPayment(c.amountMsat, c.paymentHash, c.hops.last, finalCltvExpiry = c.finalCltvExpiry, maxAttempts = 1, assistedRoutes = Seq.empty, routeParams = None, paymentRequest_opt = None) + paymentsDb.addOutgoingPayment(OutgoingPayment(id, c.paymentHash, None, Some(c.hops.last), c.amountMsat, Platform.currentTime, None, OutgoingPaymentStatus.PENDING, None, None)) router ! FinalizeRoute(c.hops) goto(WAITING_FOR_ROUTE) using WaitingForRoute(sender, send, failures = Nil) case Event(c: SendPayment, WaitingForRequest) => router ! RouteRequest(nodeParams.nodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, routeParams = c.routeParams) - paymentsDb.addOutgoingPayment(OutgoingPayment(id, c.paymentHash, None, c.amountMsat, Platform.currentTime, None, OutgoingPaymentStatus.PENDING)) + paymentsDb.addOutgoingPayment(OutgoingPayment(id, c.paymentHash, None, Some(c.targetNodeId), c.amountMsat, Platform.currentTime, None, OutgoingPaymentStatus.PENDING, c.paymentRequest_opt.map(PaymentRequest.write), c.paymentRequest_opt.map(_.description.fold(s => s, _.toHex)))) goto(WAITING_FOR_ROUTE) using WaitingForRoute(sender, c, failures = Nil) } @@ -205,6 +205,7 @@ object PaymentLifecycle { case class SendPayment(amountMsat: Long, paymentHash: ByteVector32, targetNodeId: PublicKey, + paymentRequest_opt: Option[PaymentRequest] = None, assistedRoutes: Seq[Seq[ExtraHop]] = Nil, finalCltvExpiry: Long = Channel.MIN_CLTV_EXPIRY, maxAttempts: Int, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index 2ab1b0b329..21dbb727e0 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -132,6 +132,17 @@ class EclairImplSpec extends TestKit(ActorSystem("mySystem")) with fixture.FunSu assert(send3.paymentHash == ByteVector32.Zeroes) assert(send3.routeParams.get.maxFeeBaseMsat == 123 * 1000) // conversion sat -> msat assert(send3.routeParams.get.maxFeePct == 4.20) + + // with paymentRequest + val pr = PaymentRequest.read("lnbc10u1pw2t4phpp5ezwm2gdccydhnphfyepklc0wjkxhz0r4tctg9paunh2lxgeqhcmsdqlxycrqvpqwdshgueqvfjhggr0dcsry7qcqzpgfa4ecv7447p9t5hkujy9qgrxvkkf396p9zar9p87rv2htmeuunkhydl40r64n5s2k0u7uelzc8twxmp37nkcch6m0wg5tvvx69yjz8qpk94qf3") + eclair.send(recipientNodeId = nodeId, amountMsat = 123, paymentHash = ByteVector32.Zeroes, assistedRoutes = Seq.empty, minFinalCltvExpiry_opt = None, feeThresholdSat_opt = Some(123), maxFeePct_opt = Some(4.20), paymentRequest_opt = Some(pr)) + val send4 = paymentInitiator.expectMsgType[SendPayment] + assert(send4.targetNodeId == nodeId) + assert(send4.amountMsat == 123) + assert(send4.paymentHash == ByteVector32.Zeroes) + assert(send4.routeParams.get.maxFeeBaseMsat == 123 * 1000) // conversion sat -> msat + assert(send4.routeParams.get.maxFeePct == 4.20) + assert(send4.paymentRequest_opt == Some(pr)) } test("allupdates can filter by nodeId") { f => diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala index c558649466..5cc72674e5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala @@ -241,7 +241,7 @@ class ApiServiceSpec extends FunSuite with ScalatestRouteTest with IdiomaticMock val invoice = "lnbc12580n1pw2ywztpp554ganw404sh4yjkwnysgn3wjcxfcq7gtx53gxczkjr9nlpc3hzvqdq2wpskwctddyxqr4rqrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glc7z9rtvqqwngqqqqqqqlgqqqqqeqqjqrrt8smgjvfj7sg38dwtr9kc9gg3era9k3t2hvq3cup0jvsrtrxuplevqgfhd3rzvhulgcxj97yjuj8gdx8mllwj4wzjd8gdjhpz3lpqqvk2plh" val eclair = mock[Eclair] - eclair.send(any, any, any, any, any, any, any, any)(any[Timeout]) returns Future.successful(UUID.randomUUID()) + eclair.send(any, any, any, any, any, any, any, any, any)(any[Timeout]) returns Future.successful(UUID.randomUUID()) val mockService = new MockService(eclair) Post("/payinvoice", FormData("invoice" -> invoice).toEntity) ~> @@ -250,7 +250,7 @@ class ApiServiceSpec extends FunSuite with ScalatestRouteTest with IdiomaticMock check { assert(handled) assert(status == OK) - eclair.send(any, 1258000, any, any, any, any, any, any)(any[Timeout]).wasCalled(once) + eclair.send(any, 1258000, any, any, any, any, any, any, None)(any[Timeout]).wasCalled(once) } @@ -260,7 +260,7 @@ class ApiServiceSpec extends FunSuite with ScalatestRouteTest with IdiomaticMock check { assert(handled) assert(status == OK) - eclair.send(any, 123, any, any, any, any, Some(112233), Some(2.34))(any[Timeout]).wasCalled(once) + eclair.send(any, 123, any, any, any, any, Some(112233), Some(2.34), None)(any[Timeout]).wasCalled(once) } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/SqlitePaymentsDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/SqlitePaymentsDbSpec.scala index b8eec4b784..6ec537809c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/SqlitePaymentsDbSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/SqlitePaymentsDbSpec.scala @@ -17,6 +17,7 @@ package fr.acinq.eclair.db import java.util.UUID + import fr.acinq.eclair.db.sqlite.SqliteUtils._ import fr.acinq.bitcoin.{Block, ByteVector32, MilliSatoshi} import fr.acinq.eclair.TestConstants.Bob @@ -26,22 +27,29 @@ import fr.acinq.eclair.payment.PaymentRequest import org.scalatest.FunSuite import scodec.bits._ import fr.acinq.eclair.randomBytes32 + import scala.compat.Platform import OutgoingPaymentStatus._ +import fr.acinq.bitcoin.Crypto.PublicKey +import fr.acinq.eclair.wire.ChannelCodecs._ + import concurrent.duration._ class SqlitePaymentsDbSpec extends FunSuite { + lazy val dummyPaymentRequest = "lnbc5450n1pw2t4qdpp5vcrf6ylgpettyng4ac3vujsk0zpc25cj0q3zp7l7w44zvxmpzh8qdzz2pshjmt9de6zqen0wgsr2dp4ypcxj7r9d3ejqct5ypekzar0wd5xjuewwpkxzcm99cxqzjccqp2rzjqtspxelp67qc5l56p6999wkatsexzhs826xmupyhk6j8lxl038t27z9tsqqqgpgqqqqqqqlgqqqqqzsqpcz8z8hmy8g3ecunle4n3edn3zg2rly8g4klsk5md736vaqqy3ktxs30ht34rkfkqaffzxmjphvd0637dk2lp6skah2hq09z6lrjna3xqp3d4vyd" + test("init sqlite 2 times in a row") { val sqlite = TestConstants.sqliteInMemory() val db1 = new SqlitePaymentsDb(sqlite) val db2 = new SqlitePaymentsDb(sqlite) } - test("handle version migration 1->2") { + test("handle version migration 1->3") { val connection = TestConstants.sqliteInMemory() + // payment DB version 1 using(connection.createStatement()) { statement => getVersion(statement, "payments", 1) statement.executeUpdate("CREATE TABLE IF NOT EXISTS payments (payment_hash BLOB NOT NULL PRIMARY KEY, amount_msat INTEGER NOT NULL, timestamp INTEGER NOT NULL)") @@ -64,14 +72,14 @@ class SqlitePaymentsDbSpec extends FunSuite { val preMigrationDb = new SqlitePaymentsDb(connection) using(connection.createStatement()) { statement => - assert(getVersion(statement, "payments", 1) == 2) // version has changed from 1 to 2! + assert(getVersion(statement, "payments", 1) == 3) // version has changed from 1 to 3! } // the existing received payment can NOT be queried anymore assert(preMigrationDb.getIncomingPayment(oldReceivedPayment.paymentHash).isEmpty) // add a few rows - val ps1 = OutgoingPayment(id = UUID.randomUUID(), paymentHash = ByteVector32(hex"0f059ef9b55bb70cc09069ee4df854bf0fab650eee6f2b87ba26d1ad08ab114f"), None, amountMsat = 12345, createdAt = 12345, None, PENDING) + val ps1 = OutgoingPayment(id = UUID.randomUUID(), paymentHash = ByteVector32(hex"0f059ef9b55bb70cc09069ee4df854bf0fab650eee6f2b87ba26d1ad08ab114f"), None, targetNodeId = Some(PublicKey(hex"030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87")), amountMsat = 12345, createdAt = 12345, None, PENDING) val i1 = PaymentRequest.read("lnbc10u1pw2t4phpp5ezwm2gdccydhnphfyepklc0wjkxhz0r4tctg9paunh2lxgeqhcmsdqlxycrqvpqwdshgueqvfjhggr0dcsry7qcqzpgfa4ecv7447p9t5hkujy9qgrxvkkf396p9zar9p87rv2htmeuunkhydl40r64n5s2k0u7uelzc8twxmp37nkcch6m0wg5tvvx69yjz8qpk94qf3") val pr1 = IncomingPayment(i1.paymentHash, 12345678, 1513871928275L) @@ -86,7 +94,7 @@ class SqlitePaymentsDbSpec extends FunSuite { val postMigrationDb = new SqlitePaymentsDb(connection) using(connection.createStatement()) { statement => - assert(getVersion(statement, "payments", 2) == 2) // version still to 2 + assert(getVersion(statement, "payments", 3) == 3) // version still to 2 } assert(postMigrationDb.listIncomingPayments() == Seq(pr1)) @@ -94,6 +102,69 @@ class SqlitePaymentsDbSpec extends FunSuite { assert(preMigrationDb.listPaymentRequests(0, (Platform.currentTime.milliseconds + 1.minute).toSeconds) == Seq(i1)) } + + test("handle version migration 2->3") { + + val connection = TestConstants.sqliteInMemory() + + // payment DB version 2 + using(connection.createStatement()) { statement => + getVersion(statement, "payments", 2) + statement.executeUpdate("CREATE TABLE IF NOT EXISTS received_payments (payment_hash BLOB NOT NULL PRIMARY KEY, preimage BLOB NOT NULL, payment_request TEXT NOT NULL, received_msat INTEGER, created_at INTEGER NOT NULL, expire_at INTEGER, received_at INTEGER)") + statement.executeUpdate("CREATE TABLE IF NOT EXISTS sent_payments (id TEXT NOT NULL PRIMARY KEY, payment_hash BLOB NOT NULL, preimage BLOB, amount_msat INTEGER NOT NULL, created_at INTEGER NOT NULL, completed_at INTEGER, status VARCHAR NOT NULL)") + statement.executeUpdate("CREATE INDEX IF NOT EXISTS payment_hash_idx ON sent_payments(payment_hash)") + } + + using(connection.createStatement()) { statement => + assert(getVersion(statement, "payments", 2) == 2) // version 2 is deployed now + } + + // insert old type record + using(connection.prepareStatement("INSERT INTO sent_payments VALUES (?, ?, ?, ?, ?, ?, ?)")) { statement => + statement.setString(1, UNKNOWN_UUID.toString) + statement.setBytes(2, ByteVector32.One.toArray) + statement.setBytes(3, ByteVector32.Zeroes.toArray) + statement.setLong(4, 123) + statement.setLong(5, 456) + statement.setLong(6, 789) + statement.setString(7, PENDING.toString) + statement.executeUpdate() + } + + // migration + val preMigrationDb = new SqlitePaymentsDb(connection) + + using(connection.createStatement()) { statement => + assert(getVersion(statement, "payments", 2) == 3) // version has changed from 2 to 3! + } + + // check the old record has been migrated + assert(preMigrationDb.getOutgoingPayment(UNKNOWN_UUID).get.targetNodeId.isEmpty) + + // add a few rows + val ps1 = OutgoingPayment(id = UUID.randomUUID(), paymentHash = ByteVector32(hex"0f059ef9b55bb70cc09069ee4df854bf0fab650eee6f2b87ba26d1ad08ab114f"), None, targetNodeId = Some(PublicKey(hex"030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87")), amountMsat = 12345, createdAt = 12345, None, PENDING) + val i1 = PaymentRequest.read("lnbc10u1pw2t4phpp5ezwm2gdccydhnphfyepklc0wjkxhz0r4tctg9paunh2lxgeqhcmsdqlxycrqvpqwdshgueqvfjhggr0dcsry7qcqzpgfa4ecv7447p9t5hkujy9qgrxvkkf396p9zar9p87rv2htmeuunkhydl40r64n5s2k0u7uelzc8twxmp37nkcch6m0wg5tvvx69yjz8qpk94qf3") + val pr1 = IncomingPayment(i1.paymentHash, 12345678, 1513871928275L) + + preMigrationDb.addPaymentRequest(i1, ByteVector32.Zeroes) + preMigrationDb.addIncomingPayment(pr1) + preMigrationDb.addOutgoingPayment(ps1) + + assert(preMigrationDb.listIncomingPayments() == Seq(pr1)) + assert(preMigrationDb.listOutgoingPayments().contains(ps1)) + assert(preMigrationDb.listPaymentRequests(0, (Platform.currentTime.milliseconds + 1.minute).toSeconds) == Seq(i1)) + + val postMigrationDb = new SqlitePaymentsDb(connection) + + using(connection.createStatement()) { statement => + assert(getVersion(statement, "payments", 3) == 3) // version still to 3 + } + + assert(postMigrationDb.listIncomingPayments() == Seq(pr1)) + assert(postMigrationDb.listOutgoingPayments().contains(ps1)) + assert(preMigrationDb.listPaymentRequests(0, (Platform.currentTime.milliseconds + 1.minute).toSeconds) == Seq(i1)) + } + test("add/list received payments/find 1 payment that exists/find 1 payment that does not exist") { val sqlite = TestConstants.sqliteInMemory() val db = new SqlitePaymentsDb(sqlite) @@ -121,8 +192,8 @@ class SqlitePaymentsDbSpec extends FunSuite { val db = new SqlitePaymentsDb(TestConstants.sqliteInMemory()) - val s1 = OutgoingPayment(id = UUID.randomUUID(), paymentHash = ByteVector32(hex"0f059ef9b55bb70cc09069ee4df854bf0fab650eee6f2b87ba26d1ad08ab114f"), None, amountMsat = 12345, createdAt = 12345, None, PENDING) - val s2 = OutgoingPayment(id = UUID.randomUUID(), paymentHash = ByteVector32(hex"08d47d5f7164d4b696e8f6b62a03094d4f1c65f16e9d7b11c4a98854707e55cf"), None, amountMsat = 12345, createdAt = 12345, None, PENDING) + val s1 = OutgoingPayment(id = UUID.randomUUID(), paymentHash = ByteVector32(hex"0f059ef9b55bb70cc09069ee4df854bf0fab650eee6f2b87ba26d1ad08ab114f"), None, targetNodeId = Some(PublicKey(hex"030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87")), amountMsat = 12345, createdAt = 12345, None, PENDING, paymentRequest_opt = Some(dummyPaymentRequest), description_opt = Some("custom description")) + val s2 = OutgoingPayment(id = UUID.randomUUID(), paymentHash = ByteVector32(hex"08d47d5f7164d4b696e8f6b62a03094d4f1c65f16e9d7b11c4a98854707e55cf"), None, targetNodeId = Some(PublicKey(hex"030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87")), amountMsat = 12345, createdAt = 12345, None, PENDING) assert(db.listOutgoingPayments().isEmpty) db.addOutgoingPayment(s1) @@ -131,6 +202,9 @@ class SqlitePaymentsDbSpec extends FunSuite { assert(db.listOutgoingPayments().toList == Seq(s1, s2)) assert(db.getOutgoingPayment(s1.id) === Some(s1)) assert(db.getOutgoingPayment(s1.id).get.completedAt.isEmpty) + assert(db.getOutgoingPayment(s1.id).get.paymentRequest_opt === Some(dummyPaymentRequest)) + assert(db.getOutgoingPayment(s1.id).get.description_opt === Some("custom description")) + assert(db.getOutgoingPayment(s1.id).get.targetNodeId === Some(PublicKey(hex"030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87"))) assert(db.getOutgoingPayment(UUID.randomUUID()) === None) assert(db.getOutgoingPayments(s2.paymentHash) === Seq(s2)) assert(db.getOutgoingPayments(ByteVector32.Zeroes) === Seq.empty) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentLifecycleSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentLifecycleSpec.scala index deb2a51441..4435a43036 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentLifecycleSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentLifecycleSpec.scala @@ -67,6 +67,7 @@ class PaymentLifecycleSpec extends BaseRouterSpec { val Transition(_, WAITING_FOR_REQUEST, WAITING_FOR_ROUTE) = monitor.expectMsgClass(classOf[Transition[_]]) val Transition(_, WAITING_FOR_ROUTE, WAITING_FOR_PAYMENT_COMPLETE) = monitor.expectMsgClass(classOf[Transition[_]]) awaitCond(paymentDb.getOutgoingPayment(id).exists(_.status == OutgoingPaymentStatus.PENDING)) + awaitCond(paymentDb.getOutgoingPayment(id).exists(_.targetNodeId == Some(d))) sender.send(paymentFSM, UpdateFulfillHtlc(ByteVector32.Zeroes, 0, defaultPaymentHash)) sender.expectMsgType[PaymentSucceeded] @@ -369,6 +370,7 @@ class PaymentLifecycleSpec extends BaseRouterSpec { test("payment succeeded") { fixture => import fixture._ + val paymentRequest = PaymentRequest.read("lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp") val defaultPaymentHash = randomBytes32 val nodeParams = TestConstants.Alice.nodeParams.copy(keyManager = testKeyManager) val paymentDb = nodeParams.db.payments @@ -382,11 +384,16 @@ class PaymentLifecycleSpec extends BaseRouterSpec { paymentFSM ! SubscribeTransitionCallBack(monitor.ref) val CurrentState(_, WAITING_FOR_REQUEST) = monitor.expectMsgClass(classOf[CurrentState[_]]) - val request = SendPayment(defaultAmountMsat, defaultPaymentHash, d, maxAttempts = 5) + val request = SendPayment(defaultAmountMsat, defaultPaymentHash, d, paymentRequest_opt = Some(paymentRequest), maxAttempts = 5) sender.send(paymentFSM, request) val Transition(_, WAITING_FOR_REQUEST, WAITING_FOR_ROUTE) = monitor.expectMsgClass(classOf[Transition[_]]) val Transition(_, WAITING_FOR_ROUTE, WAITING_FOR_PAYMENT_COMPLETE) = monitor.expectMsgClass(classOf[Transition[_]]) - awaitCond(paymentDb.getOutgoingPayment(id).exists(_.status == OutgoingPaymentStatus.PENDING)) + awaitCond(paymentDb.getOutgoingPayment(id).exists { payment => + payment.status == OutgoingPaymentStatus.PENDING && + payment.targetNodeId == Some(d) && + payment.paymentRequest_opt == Some("lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp") && + payment.description_opt == Some("1 cup coffee") + }) sender.send(paymentFSM, UpdateFulfillHtlc(ByteVector32.Zeroes, 0, defaultPaymentHash)) val paymentOK = sender.expectMsgType[PaymentSucceeded] diff --git a/eclair-node-gui/src/main/scala/fr/acinq/eclair/gui/Handlers.scala b/eclair-node-gui/src/main/scala/fr/acinq/eclair/gui/Handlers.scala index 80e286051b..c6002fc7bf 100644 --- a/eclair-node-gui/src/main/scala/fr/acinq/eclair/gui/Handlers.scala +++ b/eclair-node-gui/src/main/scala/fr/acinq/eclair/gui/Handlers.scala @@ -88,8 +88,8 @@ class Handlers(fKit: Future[Kit])(implicit ec: ExecutionContext = ExecutionConte (for { kit <- fKit sendPayment = req.minFinalCltvExpiry match { - case None => SendPayment(amountMsat, req.paymentHash, req.nodeId, req.routingInfo, maxAttempts = kit.nodeParams.maxPaymentAttempts) - case Some(minFinalCltvExpiry) => SendPayment(amountMsat, req.paymentHash, req.nodeId, req.routingInfo, finalCltvExpiry = minFinalCltvExpiry, maxAttempts = kit.nodeParams.maxPaymentAttempts) + case None => SendPayment(amountMsat, req.paymentHash, req.nodeId, assistedRoutes = req.routingInfo, maxAttempts = kit.nodeParams.maxPaymentAttempts) + case Some(minFinalCltvExpiry) => SendPayment(amountMsat, req.paymentHash, req.nodeId, assistedRoutes = req.routingInfo, finalCltvExpiry = minFinalCltvExpiry, maxAttempts = kit.nodeParams.maxPaymentAttempts) } res <- (kit.paymentInitiator ? sendPayment).mapTo[UUID] } yield res).recover {