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

Implement the KeySend feature, spontaneous payments #1485

Merged
merged 24 commits into from
Jul 21, 2020
Merged
Show file tree
Hide file tree
Changes from 23 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
2 changes: 1 addition & 1 deletion eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,4 @@ akka {
max-received-message-size = 16384b
}
}
}
}
21 changes: 18 additions & 3 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import akka.actor.ActorRef
import akka.pattern._
import akka.util.Timeout
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin.{ByteVector32, Satoshi}
import fr.acinq.bitcoin.{ByteVector32, Crypto, Satoshi}
import fr.acinq.eclair.TimestampQueryFilters._
import fr.acinq.eclair.blockchain.OnChainBalance
import fr.acinq.eclair.blockchain.bitcoind.BitcoinCoreWallet
Expand All @@ -38,7 +38,7 @@ import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, OutgoingChann
import fr.acinq.eclair.payment.send.PaymentInitiator.{SendPaymentRequest, SendPaymentToRouteRequest, SendPaymentToRouteResponse}
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.router.{NetworkStats, RouteCalculation}
import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement}
import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement, GenericTlv}
import scodec.bits.ByteVector

import scala.concurrent.duration._
Expand Down Expand Up @@ -98,6 +98,8 @@ trait Eclair {

def send(externalId_opt: Option[String], recipientNodeId: PublicKey, amount: MilliSatoshi, paymentHash: ByteVector32, invoice_opt: Option[PaymentRequest] = None, maxAttempts_opt: Option[Int] = None, feeThresholdSat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None)(implicit timeout: Timeout): Future[UUID]

def sendWithPreimage(externalId_opt: Option[String], recipientNodeId: PublicKey, amount: MilliSatoshi, paymentPreimage: ByteVector32 = randomBytes32, maxAttempts_opt: Option[Int] = None, feeThresholdSat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None)(implicit timeout: Timeout): Future[UUID]

def sentInfo(id: Either[UUID, ByteVector32])(implicit timeout: Timeout): Future[Seq[OutgoingPayment]]

def sendOnChain(address: String, amount: Satoshi, confirmationTarget: Long): Future[ByteVector32]
Expand Down Expand Up @@ -378,4 +380,17 @@ class EclairImpl(appKit: Kit) extends Eclair {

override def usableBalances()(implicit timeout: Timeout): Future[Iterable[UsableBalance]] =
(appKit.relayer ? GetOutgoingChannels()).mapTo[OutgoingChannels].map(_.channels.map(_.toUsableBalance))
}

override def sendWithPreimage(externalId_opt: Option[String], recipientNodeId: PublicKey, amount: MilliSatoshi, paymentPreimage: ByteVector32, maxAttempts_opt: Option[Int], feeThreshold_opt: Option[Satoshi], maxFeePct_opt: Option[Double])(implicit timeout: Timeout): Future[UUID] = {
val maxAttempts = maxAttempts_opt.getOrElse(appKit.nodeParams.maxPaymentAttempts)
val defaultRouteParams = RouteCalculation.getDefaultRouteParams(appKit.nodeParams.routerConf)
val routeParams = defaultRouteParams.copy(
maxFeePct = maxFeePct_opt.getOrElse(defaultRouteParams.maxFeePct),
maxFeeBase = feeThreshold_opt.map(_.toMilliSatoshi).getOrElse(defaultRouteParams.maxFeeBase)
)
val paymentHash: ByteVector32 = Crypto.sha256(paymentPreimage)
val keySendTlvRecords = Seq(GenericTlv(UInt64(5482373484L), paymentPreimage))
val sendPayment = SendPaymentRequest(amount, paymentHash, recipientNodeId, maxAttempts, externalId = externalId_opt, routeParams = Some(routeParams), userCustomTlvs = keySendTlvRecords)
(appKit.paymentInitiator ? sendPayment).mapTo[UUID]
}
}
11 changes: 9 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ object Features {
val mandatory = 50
}

case object KeySend extends Feature {
dariuskramer marked this conversation as resolved.
Show resolved Hide resolved
val rfcName = "keysend"
val mandatory = 54
}

val knownFeatures: Set[Feature] = Set(
OptionDataLossProtect,
InitialRoutingSync,
Expand All @@ -202,7 +207,8 @@ object Features {
BasicMultiPartPayment,
Wumbo,
TrampolinePayment,
StaticRemoteKey
StaticRemoteKey,
KeySend
)

private val supportedMandatoryFeatures: Set[Feature] = Set(
Expand All @@ -222,7 +228,8 @@ object Features {
// invoices in their payment history. We choose to treat such invoices as valid; this is a harmless spec violation.
// PaymentSecret -> (VariableLengthOnion :: Nil),
BasicMultiPartPayment -> (PaymentSecret :: Nil),
TrampolinePayment -> (PaymentSecret :: Nil)
TrampolinePayment -> (PaymentSecret :: Nil),
KeySend -> (VariableLengthOnion :: Nil)
)

case class FeatureException(message: String) extends IllegalArgumentException(message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ case object PaymentType {
val Standard = "Standard"
val SwapIn = "SwapIn"
val SwapOut = "SwapOut"
val KeySend = "KeySend"
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import fr.acinq.bitcoin.{ByteVector32, Crypto}
import fr.acinq.eclair.channel.{CMD_FAIL_HTLC, CMD_FULFILL_HTLC, ChannelCommandResponse}
import fr.acinq.eclair.db._
import fr.acinq.eclair.payment.Monitoring.{Metrics, Tags}
import fr.acinq.eclair.payment.PaymentRequest.ExtraHop
import fr.acinq.eclair.payment.PaymentRequest.{ExtraHop, PaymentRequestFeatures}
import fr.acinq.eclair.payment.{IncomingPacket, PaymentReceived, PaymentRequest}
import fr.acinq.eclair.wire._
import fr.acinq.eclair.{Features, Logs, MilliSatoshi, NodeParams, randomBytes32}
Expand Down Expand Up @@ -93,10 +93,27 @@ class MultiPartHandler(nodeParams: NodeParams, register: ActorRef, db: IncomingP
pendingPayments = pendingPayments + (p.add.paymentHash -> (record.paymentPreimage, handler))
}
}
case None =>
Metrics.PaymentFailed.withTag(Tags.Direction, Tags.Directions.Received).withTag(Tags.Failure, "InvoiceNotFound").increment()
val cmdFail = CMD_FAIL_HTLC(p.add.id, Right(IncorrectOrUnknownPaymentDetails(p.payload.totalAmount, nodeParams.currentBlockHeight)), commit = true)
PendingRelayDb.safeSend(register, nodeParams.db.pendingRelay, p.add.channelId, cmdFail)
case None => p.payload.paymentPreimage match {
case Some(paymentPreimage) if nodeParams.features.hasFeature(Features.KeySend) =>
val amount = Some(p.payload.totalAmount)
val paymentHash = Crypto.sha256(paymentPreimage)
val desc = "Donation"
val features = if (nodeParams.features.hasFeature(Features.BasicMultiPartPayment)) {
PaymentRequestFeatures(Features.BasicMultiPartPayment.optional, Features.PaymentSecret.optional, Features.VariableLengthOnion.optional)
} else {
PaymentRequestFeatures(Features.PaymentSecret.optional, Features.VariableLengthOnion.optional)
}

// Insert a fake invoice and then restart the incoming payment handler
val paymentRequest = PaymentRequest(nodeParams.chainHash, amount, paymentHash, nodeParams.privateKey, desc, nodeParams.minFinalExpiryDelta, features = Some(features))
log.debug("generated fake payment request={} from amount={} (KeySend)", PaymentRequest.write(paymentRequest), amount)
db.addIncomingPayment(paymentRequest, paymentPreimage, paymentType = PaymentType.KeySend)
ctx.self ! p
case _ =>
Metrics.PaymentFailed.withTag(Tags.Direction, Tags.Directions.Received).withTag(Tags.Failure, "InvoiceNotFound").increment()
val cmdFail = CMD_FAIL_HTLC(p.add.id, Right(IncorrectOrUnknownPaymentDetails(p.payload.totalAmount, nodeParams.currentBlockHeight)), commit = true)
PendingRelayDb.safeSend(register, nodeParams.db.pendingRelay, p.add.channelId, cmdFail)
}
}
}

Expand Down
9 changes: 8 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/wire/Onion.scala
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ object OnionTlv {
/** An encrypted trampoline onion packet. */
case class TrampolineOnion(packet: OnionRoutingPacket) extends OnionTlv

/** Pre-image included by the sender of a payment in case of a donation */
case class KeySend(paymentPreimage: ByteVector32) extends OnionTlv
}

object Onion {
Expand Down Expand Up @@ -228,13 +230,15 @@ object Onion {
val expiry: CltvExpiry
val paymentSecret: Option[ByteVector32]
val totalAmount: MilliSatoshi
val paymentPreimage: Option[ByteVector32]
}

case class RelayLegacyPayload(outgoingChannelId: ShortChannelId, amountToForward: MilliSatoshi, outgoingCltv: CltvExpiry) extends ChannelRelayPayload with LegacyFormat

case class FinalLegacyPayload(amount: MilliSatoshi, expiry: CltvExpiry) extends FinalPayload with LegacyFormat {
override val paymentSecret = None
override val totalAmount = amount
override val paymentPreimage = None
}

case class ChannelRelayTlvPayload(records: TlvStream[OnionTlv]) extends ChannelRelayPayload with TlvFormat {
Expand Down Expand Up @@ -264,6 +268,7 @@ object Onion {
case MilliSatoshi(0) => amount
case totalAmount => totalAmount
}).getOrElse(amount)
override val paymentPreimage = records.get[KeySend].map(_.paymentPreimage)
}

def createNodeRelayPayload(amount: MilliSatoshi, expiry: CltvExpiry, nextNodeId: PublicKey): NodeRelayPayload =
Expand All @@ -289,7 +294,6 @@ object Onion {
def createTrampolinePayload(amount: MilliSatoshi, totalAmount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, trampolinePacket: OnionRoutingPacket): FinalPayload = {
FinalTlvPayload(TlvStream(AmountToForward(amount), OutgoingCltv(expiry), PaymentData(paymentSecret, totalAmount), TrampolineOnion(trampolinePacket)))
}

}

object OnionCodecs {
Expand Down Expand Up @@ -334,6 +338,8 @@ object OnionCodecs {

private val trampolineOnion: Codec[TrampolineOnion] = variableSizeBytesLong(varintoverflow, trampolineOnionPacketCodec).as[TrampolineOnion]

private val keySend: Codec[KeySend] = variableSizeBytesLong(varintoverflow, bytes32).as[KeySend]

private val onionTlvCodec = discriminated[OnionTlv].by(varint)
.typecase(UInt64(2), amountToForward)
.typecase(UInt64(4), outgoingCltv)
Expand All @@ -344,6 +350,7 @@ object OnionCodecs {
.typecase(UInt64(66098), outgoingNodeId)
.typecase(UInt64(66099), invoiceRoutingInfo)
.typecase(UInt64(66100), trampolineOnion)
.typecase(UInt64(5482373484L), keySend)

val tlvPerHopPayloadCodec: Codec[TlvStream[OnionTlv]] = TlvCodecs.lengthPrefixedTlvStream[OnionTlv](onionTlvCodec).complete

Expand Down
42 changes: 42 additions & 0 deletions eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,46 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I
paymentInitiator.expectMsg(SendPaymentToRouteRequest(1000 msat, 1200 msat, Some("42"), Some(parentId), pr, CltvExpiryDelta(123), route, Some(secret), 100 msat, CltvExpiryDelta(144), trampolines))
}

test("call sendWithPreimage, which generate a random preimage, to perform a KeySend payment") { f =>
import f._

val eclair = new EclairImpl(kit)
val nodeId = randomKey.publicKey

eclair.sendWithPreimage(None, nodeId, 12345 msat)
val send = paymentInitiator.expectMsgType[SendPaymentRequest]
assert(send.externalId === None)
assert(send.recipientNodeId === nodeId)
assert(send.recipientAmount === 12345.msat)
assert(send.paymentRequest === None)
t-bast marked this conversation as resolved.
Show resolved Hide resolved

assert(send.userCustomTlvs.length === 1)
val keySendTlv = send.userCustomTlvs.head
assert(keySendTlv.tag === UInt64(5482373484L))
val preimage = ByteVector32(keySendTlv.value)
assert(Crypto.sha256(preimage) === send.paymentHash)
}

test("call sendWithPreimage, giving a specific preimage, to perform a KeySend payment") { f =>
import f._

val eclair = new EclairImpl(kit)
val nodeId = randomKey.publicKey
val expectedPaymentPreimage = ByteVector32(hex"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
val expectedPaymentHash = Crypto.sha256(expectedPaymentPreimage)

eclair.sendWithPreimage(None, nodeId, 12345 msat, paymentPreimage = expectedPaymentPreimage)
val send = paymentInitiator.expectMsgType[SendPaymentRequest]
assert(send.externalId === None)
assert(send.recipientNodeId === nodeId)
assert(send.recipientAmount === 12345.msat)
assert(send.paymentRequest === None)
assert(send.paymentHash === expectedPaymentHash)

assert(send.userCustomTlvs.length === 1)
val keySendTlv = send.userCustomTlvs.head
assert(keySendTlv.tag === UInt64(5482373484L))
assert(expectedPaymentPreimage === ByteVector32(keySendTlv.value))
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ import fr.acinq.eclair.payment.PaymentRequest.ExtraHop
import fr.acinq.eclair.payment.receive.MultiPartHandler.{GetPendingPayments, PendingPayments, ReceivePayment}
import fr.acinq.eclair.payment.receive.MultiPartPaymentFSM.HtlcPart
import fr.acinq.eclair.payment.receive.{MultiPartPaymentFSM, PaymentHandler}
import fr.acinq.eclair.wire.Onion.FinalTlvPayload
import fr.acinq.eclair.wire._
import fr.acinq.eclair.{ActivatedFeature, CltvExpiry, CltvExpiryDelta, Features, LongToBtcAmount, NodeParams, ShortChannelId, TestConstants, TestKitBaseClass, randomKey}
import fr.acinq.eclair.{ActivatedFeature, CltvExpiry, CltvExpiryDelta, Features, LongToBtcAmount, NodeParams, ShortChannelId, TestConstants, TestKitBaseClass, randomBytes32, randomKey}
import org.scalatest.Outcome
import org.scalatest.funsuite.FixtureAnyFunSuiteLike

Expand All @@ -52,9 +53,15 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
ActivatedFeature(BasicMultiPartPayment, Optional)
))

val featuresWithKeySend = Features(Set(
ActivatedFeature(VariableLengthOnion, Optional),
ActivatedFeature(KeySend, Optional)
))

case class FixtureParam(nodeParams: NodeParams, defaultExpiry: CltvExpiry, register: TestProbe, eventListener: TestProbe, sender: TestProbe) {
lazy val normalHandler = TestActorRef[PaymentHandler](PaymentHandler.props(nodeParams, register.ref))
lazy val mppHandler = TestActorRef[PaymentHandler](PaymentHandler.props(nodeParams.copy(features = featuresWithMpp), register.ref))
lazy val keySendHandler = TestActorRef[PaymentHandler](PaymentHandler.props(nodeParams.copy(features = featuresWithKeySend), register.ref))
}

override def withFixture(test: OneArgTest): Outcome = {
Expand Down Expand Up @@ -460,4 +467,41 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
})
}

test("KeySend payment in a single HTLC") { f =>
dariuskramer marked this conversation as resolved.
Show resolved Hide resolved
import f._

val amountMsat = 42000 msat
val paymentPreimage = randomBytes32
val paymentHash = Crypto.sha256(paymentPreimage)
val payload = FinalTlvPayload(TlvStream(Seq(OnionTlv.AmountToForward(amountMsat), OnionTlv.OutgoingCltv(defaultExpiry), OnionTlv.KeySend(paymentPreimage))))

assert(nodeParams.db.payments.getIncomingPayment(paymentHash) === None)

val add = UpdateAddHtlc(ByteVector32.One, 0, amountMsat, paymentHash, defaultExpiry, TestConstants.emptyOnionPacket)
sender.send(keySendHandler, IncomingPacket.FinalPacket(add, payload))
register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]]

val paymentReceived = eventListener.expectMsgType[PaymentReceived]
assert(paymentReceived.copy(parts = paymentReceived.parts.map(_.copy(timestamp = 0))) === PaymentReceived(add.paymentHash, PartialPayment(amountMsat, add.channelId, timestamp = 0) :: Nil))
val received = nodeParams.db.payments.getIncomingPayment(paymentHash)
assert(received.isDefined && received.get.status.isInstanceOf[IncomingPaymentStatus.Received])
assert(received.get.status.asInstanceOf[IncomingPaymentStatus.Received].copy(receivedAt = 0) === IncomingPaymentStatus.Received(amountMsat, 0))
}

test("KeySend payment without the feature activated") { f =>
import f._

val amountMsat = 42000 msat
val paymentPreimage = randomBytes32
val paymentHash = Crypto.sha256(paymentPreimage)
val payload = FinalTlvPayload(TlvStream(Seq(OnionTlv.AmountToForward(amountMsat), OnionTlv.OutgoingCltv(defaultExpiry), OnionTlv.KeySend(paymentPreimage))))

assert(nodeParams.db.payments.getIncomingPayment(paymentHash) === None)

val add = UpdateAddHtlc(ByteVector32.One, 0, amountMsat, paymentHash, defaultExpiry, TestConstants.emptyOnionPacket)
sender.send(normalHandler, IncomingPacket.FinalPacket(add, payload))

f.register.expectMsg(Register.Forward(add.channelId, CMD_FAIL_HTLC(add.id, Right(IncorrectOrUnknownPaymentDetails(42000 msat, nodeParams.currentBlockHeight)), commit = true)))
assert(nodeParams.db.payments.getIncomingPayment(paymentHash) === None)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@ class OnionCodecsSpec extends AnyFunSuite {
}

test("encode/decode variable-length (tlv) final per-hop payload with custom user records") {
val tlvs = TlvStream[OnionTlv](Seq(AmountToForward(561 msat), OutgoingCltv(CltvExpiry(42))), Seq(GenericTlv(5482373484L, hex"16c7ec71663784ff100b6eface1e60a97b92ea9d18b8ece5e558586bc7453828")))
val bin = hex"31 02020231 04012a ff0000000146c6616c2016c7ec71663784ff100b6eface1e60a97b92ea9d18b8ece5e558586bc7453828"
val tlvs = TlvStream[OnionTlv](Seq(AmountToForward(561 msat), OutgoingCltv(CltvExpiry(42))), Seq(GenericTlv(5432123456L, hex"16c7ec71663784ff100b6eface1e60a97b92ea9d18b8ece5e558586bc7453828")))
val bin = hex"31 02020231 04012a ff0000000143c7a0402016c7ec71663784ff100b6eface1e60a97b92ea9d18b8ece5e558586bc7453828"

val encoded = finalPerHopPayloadCodec.encode(FinalTlvPayload(tlvs)).require.bytes
assert(encoded === bin)
Expand Down
16 changes: 12 additions & 4 deletions eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import fr.acinq.bitcoin.{ByteVector32, Satoshi}
import fr.acinq.eclair.api.FormParamExtractors._
import fr.acinq.eclair.io.NodeURI
import fr.acinq.eclair.payment.{PaymentEvent, PaymentRequest}
import fr.acinq.eclair.{CltvExpiryDelta, Eclair, MilliSatoshi}
import fr.acinq.eclair.{CltvExpiryDelta, Eclair, MilliSatoshi, randomBytes32}
import grizzled.slf4j.Logging
import scodec.bits.ByteVector

Expand Down Expand Up @@ -224,9 +224,17 @@ trait Service extends ExtraDirectives with Logging {
}
} ~
path("sendtonode") {
formFields(amountMsatFormParam, paymentHashFormParam, nodeIdFormParam, "maxAttempts".as[Int].?, "feeThresholdSat".as[Satoshi].?, "maxFeePct".as[Double].?, "externalId".?) {
(amountMsat, paymentHash, nodeId, maxAttempts_opt, feeThresholdSat_opt, maxFeePct_opt, externalId_opt) =>
complete(eclairApi.send(externalId_opt, nodeId, amountMsat, paymentHash, maxAttempts_opt = maxAttempts_opt, feeThresholdSat_opt = feeThresholdSat_opt, maxFeePct_opt = maxFeePct_opt))
formFields(amountMsatFormParam, nodeIdFormParam, paymentHashFormParam.?, "maxAttempts".as[Int].?, "feeThresholdSat".as[Satoshi].?, "maxFeePct".as[Double].?, "externalId".?, "keysend".as[Boolean].?) {
case (amountMsat, nodeId, Some(paymentHash), maxAttempts_opt, feeThresholdSat_opt, maxFeePct_opt, externalId_opt, keySend) =>
keySend match {
case Some(true) => reject(MalformedFormFieldRejection("paymentHash", "You cannot request a KeySend payment and specify a paymentHash"))
case _ => complete(eclairApi.send(externalId_opt, nodeId, amountMsat, paymentHash, maxAttempts_opt = maxAttempts_opt, feeThresholdSat_opt = feeThresholdSat_opt, maxFeePct_opt = maxFeePct_opt))
}
case (amountMsat, nodeId, None, maxAttempts_opt, feeThresholdSat_opt, maxFeePct_opt, externalId_opt, keySend) =>
keySend match {
case Some(true) => complete(eclairApi.sendWithPreimage(externalId_opt, nodeId, amountMsat, maxAttempts_opt = maxAttempts_opt, feeThresholdSat_opt = feeThresholdSat_opt, maxFeePct_opt = maxFeePct_opt))
case _ => reject(MalformedFormFieldRejection("paymentHash", "No payment type specified. Either provide a paymentHash or use --keysend=true"))
}
}
} ~
path("sendtoroute") {
Expand Down