Skip to content

Commit

Permalink
Implement the KeySend feature, spontaneous payments (#1485)
Browse files Browse the repository at this point in the history
Support for receiving and sending KeySend payment is added.

For an explanation of the KeySend feature see: ElementsProject/lightning#3611
  • Loading branch information
dariuskramer authored Jul 21, 2020
1 parent ab4831f commit e6909cf
Show file tree
Hide file tree
Showing 10 changed files with 160 additions and 19 deletions.
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 {
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)

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("PaymentHandler should handle single-part KeySend payment") { 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(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("PaymentHandler should reject KeySend payment when feature is disabled") { 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

0 comments on commit e6909cf

Please sign in to comment.