Skip to content

Commit 0791667

Browse files
committed
Add support for option_payment_metadata
Add support for lightning/bolts#912 Whenever we find a payment metadata field in an invoice, we send it in the onion payload for the final recipient. We include a payment metadata in every invoice we generate. This lets us see whether our payers support it or not, which is important data to have before we make it mandatory and use it for storage-less invoices.
1 parent 89c60ae commit 0791667

File tree

13 files changed

+165
-44
lines changed

13 files changed

+165
-44
lines changed

src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fr.acinq.bitcoin.ByteVector32
44
import fr.acinq.bitcoin.Crypto
55
import fr.acinq.bitcoin.PrivateKey
66
import fr.acinq.lightning.CltvExpiry
7+
import fr.acinq.lightning.Lightning.randomBytes
78
import fr.acinq.lightning.Lightning.randomBytes32
89
import fr.acinq.lightning.MilliSatoshi
910
import fr.acinq.lightning.NodeParams
@@ -88,6 +89,8 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
8889
PaymentRequest.DEFAULT_MIN_FINAL_EXPIRY_DELTA,
8990
nodeParams.features,
9091
randomBytes32(),
92+
// We always include a payment metadata in our invoices, which lets us test whether senders support it
93+
randomBytes(64).toByteVector(),
9194
expirySeconds,
9295
extraHops,
9396
timestampSeconds
@@ -248,7 +251,10 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
248251
}
249252
else -> {
250253
// We have received all the payment parts.
251-
logger.info { "h:${paymentPart.paymentHash} payment received (${payment.amountReceived})" }
254+
when (val paymentMetadata = paymentPart.finalPayload.paymentMetadata) {
255+
null -> logger.info { "h:${paymentPart.paymentHash} payment received (${payment.amountReceived}) without payment metadata" }
256+
else -> logger.info { "h:${paymentPart.paymentHash} payment received (${payment.amountReceived}) with payment metadata ($paymentMetadata)" }
257+
}
252258
val (actions, receivedWith) = payment.parts.map { part ->
253259
when (part) {
254260
is HtlcPart -> {

src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ object IncomingPaymentPacket {
7474
else -> {
7575
// We merge contents from the outer and inner payloads.
7676
// We must use the inner payload's total amount and payment secret because the payment may be split between multiple trampoline payments (#reckless).
77-
Either.Right(PaymentOnion.FinalPayload.createMultiPartPayload(outerPayload.amount, innerPayload.totalAmount, outerPayload.expiry, innerPayload.paymentSecret))
77+
Either.Right(PaymentOnion.FinalPayload.createMultiPartPayload(outerPayload.amount, innerPayload.totalAmount, outerPayload.expiry, innerPayload.paymentSecret, innerPayload.paymentMetadata))
7878
}
7979
}
8080
}

src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ class OutgoingPaymentHandler(val nodeId: PublicKey, val walletParams: WalletPara
312312

313313
val finalExpiryDelta = request.details.paymentRequest.minFinalExpiryDelta ?: Channel.MIN_CLTV_EXPIRY_DELTA
314314
val finalExpiry = finalExpiryDelta.toCltvExpiry(currentBlockHeight.toLong())
315-
val finalPayload = PaymentOnion.FinalPayload.createSinglePartPayload(request.amount, finalExpiry, request.details.paymentRequest.paymentSecret)
315+
val finalPayload = PaymentOnion.FinalPayload.createSinglePartPayload(request.amount, finalExpiry, request.details.paymentRequest.paymentSecret, request.details.paymentRequest.paymentMetadata)
316316

317317
val invoiceFeatures = Features(request.details.paymentRequest.features)
318318
val (trampolineAmount, trampolineExpiry, trampolineOnion) = if (invoiceFeatures.hasFeature(Feature.TrampolinePayment)) {

src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@ object OutgoingPaymentPacket {
7676
* - the trampoline onion to include in final payload of a normal onion
7777
*/
7878
fun buildTrampolineToLegacyPacket(invoice: PaymentRequest, hops: List<NodeHop>, finalPayload: PaymentOnion.FinalPayload): Triple<MilliSatoshi, CltvExpiry, PacketAndSecrets> {
79-
val (firstAmount, firstExpiry, payloads) = hops.drop(1).reversed().fold(Triple(finalPayload.amount, finalPayload.expiry, listOf<PaymentOnion.PerHopPayload>(finalPayload))) { triple, hop ->
79+
// NB: the final payload will never reach the recipient, since the next-to-last trampoline hop will convert that to a legacy payment
80+
// We use the smallest final payload possible, otherwise we may overflow the trampoline onion size.
81+
val dummyFinalPayload = PaymentOnion.FinalPayload.createSinglePartPayload(finalPayload.amount, finalPayload.expiry, finalPayload.paymentSecret, null)
82+
val (firstAmount, firstExpiry, payloads) = hops.drop(1).reversed().fold(Triple(finalPayload.amount, finalPayload.expiry, listOf<PaymentOnion.PerHopPayload>(dummyFinalPayload))) { triple, hop ->
8083
val (amount, expiry, payloads) = triple
8184
val payload = when (payloads.size) {
8285
// The next-to-last trampoline hop must include invoice data to indicate the conversion to a legacy payment.

src/commonMain/kotlin/fr/acinq/lightning/payment/PaymentRequest.kt

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ data class PaymentRequest(
2828
@Transient
2929
val paymentSecret: ByteVector32 = tags.find { it is TaggedField.PaymentSecret }!!.run { (this as TaggedField.PaymentSecret).secret }
3030

31+
@Transient
32+
val paymentMetadata: ByteVector? = tags.find { it is TaggedField.PaymentMetadata }?.run { (this as TaggedField.PaymentMetadata).data }
33+
3134
@Transient
3235
val description: String? = tags.find { it is TaggedField.Description }?.run { (this as TaggedField.Description).description }
3336

@@ -147,6 +150,7 @@ data class PaymentRequest(
147150
minFinalCltvExpiryDelta: CltvExpiryDelta,
148151
features: Features,
149152
paymentSecret: ByteVector32 = randomBytes32(),
153+
paymentMetadata: ByteVector? = null,
150154
expirySeconds: Long? = null,
151155
extraHops: List<List<TaggedField.ExtraHop>> = listOf(),
152156
timestampSeconds: Long = currentTimestampSeconds()
@@ -159,9 +163,8 @@ data class PaymentRequest(
159163
TaggedField.PaymentSecret(paymentSecret),
160164
TaggedField.Features(features.invoiceFeatures().toByteArray().toByteVector())
161165
)
162-
if (expirySeconds != null) {
163-
tags.add(TaggedField.Expiry(expirySeconds))
164-
}
166+
paymentMetadata?.let { tags.add(TaggedField.PaymentMetadata(it)) }
167+
expirySeconds?.let { tags.add(TaggedField.Expiry(it)) }
165168
if (extraHops.isNotEmpty()) {
166169
extraHops.forEach { tags.add(TaggedField.RoutingInfo(it)) }
167170
}
@@ -210,6 +213,7 @@ data class PaymentRequest(
210213
when (tag) {
211214
TaggedField.PaymentHash.tag -> tags.add(kotlin.runCatching { TaggedField.PaymentHash.decode(value) }.getOrDefault(TaggedField.InvalidTag(tag, value)))
212215
TaggedField.PaymentSecret.tag -> tags.add(kotlin.runCatching { TaggedField.PaymentSecret.decode(value) }.getOrDefault(TaggedField.InvalidTag(tag, value)))
216+
TaggedField.PaymentMetadata.tag -> tags.add(kotlin.runCatching { TaggedField.PaymentMetadata.decode(value) }.getOrDefault(TaggedField.InvalidTag(tag, value)))
213217
TaggedField.Description.tag -> tags.add(kotlin.runCatching { TaggedField.Description.decode(value) }.getOrDefault(TaggedField.InvalidTag(tag, value)))
214218
TaggedField.DescriptionHash.tag -> tags.add(kotlin.runCatching { TaggedField.DescriptionHash.decode(value) }.getOrDefault(TaggedField.InvalidTag(tag, value)))
215219
TaggedField.Expiry.tag -> tags.add(kotlin.runCatching { TaggedField.Expiry.decode(value) }.getOrDefault(TaggedField.InvalidTag(tag, value)))
@@ -339,6 +343,17 @@ data class PaymentRequest(
339343
}
340344
}
341345

346+
@Serializable
347+
data class PaymentMetadata(@Contextual val data: ByteVector) : TaggedField() {
348+
override val tag: Int5 = PaymentMetadata.tag
349+
override fun encode(): List<Int5> = Bech32.eight2five(data.toByteArray()).toList()
350+
351+
companion object {
352+
const val tag: Int5 = 27
353+
fun decode(input: List<Int5>): PaymentMetadata = PaymentMetadata(Bech32.five2eight(input.toTypedArray(), 0).toByteVector())
354+
}
355+
}
356+
342357
/** @param expirySeconds payment expiry (in seconds) */
343358
@Serializable
344359
data class Expiry(val expirySeconds: Long) : TaggedField() {

src/commonMain/kotlin/fr/acinq/lightning/serialization/v1/Serialization.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ object Serialization {
4949
subclass(OnionPaymentPayloadTlv.OutgoingCltv.serializer())
5050
subclass(OnionPaymentPayloadTlv.OutgoingChannelId.serializer())
5151
subclass(OnionPaymentPayloadTlv.PaymentData.serializer())
52+
subclass(OnionPaymentPayloadTlv.PaymentMetadata.serializer())
5253
subclass(OnionPaymentPayloadTlv.InvoiceFeatures.serializer())
5354
subclass(OnionPaymentPayloadTlv.OutgoingNodeId.serializer())
5455
subclass(OnionPaymentPayloadTlv.InvoiceRoutingInfo.serializer())

src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/Serialization.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ object Serialization {
5959
subclass(OnionPaymentPayloadTlv.OutgoingCltv.serializer())
6060
subclass(OnionPaymentPayloadTlv.OutgoingChannelId.serializer())
6161
subclass(OnionPaymentPayloadTlv.PaymentData.serializer())
62+
subclass(OnionPaymentPayloadTlv.PaymentMetadata.serializer())
6263
subclass(OnionPaymentPayloadTlv.InvoiceFeatures.serializer())
6364
subclass(OnionPaymentPayloadTlv.OutgoingNodeId.serializer())
6465
subclass(OnionPaymentPayloadTlv.InvoiceRoutingInfo.serializer())

src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/Serialization.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ object Serialization {
5959
subclass(OnionPaymentPayloadTlv.OutgoingCltv.serializer())
6060
subclass(OnionPaymentPayloadTlv.OutgoingChannelId.serializer())
6161
subclass(OnionPaymentPayloadTlv.PaymentData.serializer())
62+
subclass(OnionPaymentPayloadTlv.PaymentMetadata.serializer())
6263
subclass(OnionPaymentPayloadTlv.InvoiceFeatures.serializer())
6364
subclass(OnionPaymentPayloadTlv.OutgoingNodeId.serializer())
6465
subclass(OnionPaymentPayloadTlv.InvoiceRoutingInfo.serializer())

src/commonMain/kotlin/fr/acinq/lightning/wire/PaymentOnion.kt

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ sealed class OnionPaymentPayloadTlv : Tlv {
7575
}
7676
}
7777

78+
/**
79+
* When payment metadata is included in a Bolt 9 invoice, we should send it as-is to the recipient.
80+
* This lets recipients generate invoices without having to store anything on their side until the invoice is paid.
81+
*/
82+
@Serializable
83+
data class PaymentMetadata(@Contextual val data: ByteVector) : OnionPaymentPayloadTlv() {
84+
override val tag: Long get() = PaymentMetadata.tag
85+
override fun write(out: Output) = LightningCodecs.writeBytes(data, out)
86+
87+
companion object : TlvValueReader<PaymentMetadata> {
88+
const val tag: Long = 16
89+
override fun read(input: Input): PaymentMetadata = PaymentMetadata(ByteVector(LightningCodecs.bytes(input, input.availableBytes)))
90+
}
91+
}
92+
7893
/**
7994
* Invoice feature bits. Only included for intermediate trampoline nodes when they should convert to a legacy payment
8095
* because the final recipient doesn't support trampoline.
@@ -176,6 +191,7 @@ object PaymentOnion {
176191
OnionPaymentPayloadTlv.OutgoingCltv.tag to OnionPaymentPayloadTlv.OutgoingCltv.Companion as TlvValueReader<OnionPaymentPayloadTlv>,
177192
OnionPaymentPayloadTlv.OutgoingChannelId.tag to OnionPaymentPayloadTlv.OutgoingChannelId.Companion as TlvValueReader<OnionPaymentPayloadTlv>,
178193
OnionPaymentPayloadTlv.PaymentData.tag to OnionPaymentPayloadTlv.PaymentData.Companion as TlvValueReader<OnionPaymentPayloadTlv>,
194+
OnionPaymentPayloadTlv.PaymentMetadata.tag to OnionPaymentPayloadTlv.PaymentMetadata.Companion as TlvValueReader<OnionPaymentPayloadTlv>,
179195
OnionPaymentPayloadTlv.InvoiceFeatures.tag to OnionPaymentPayloadTlv.InvoiceFeatures.Companion as TlvValueReader<OnionPaymentPayloadTlv>,
180196
OnionPaymentPayloadTlv.OutgoingNodeId.tag to OnionPaymentPayloadTlv.OutgoingNodeId.Companion as TlvValueReader<OnionPaymentPayloadTlv>,
181197
OnionPaymentPayloadTlv.InvoiceRoutingInfo.tag to OnionPaymentPayloadTlv.InvoiceRoutingInfo.Companion as TlvValueReader<OnionPaymentPayloadTlv>,
@@ -198,30 +214,54 @@ object PaymentOnion {
198214
val total = records.get<OnionPaymentPayloadTlv.PaymentData>()!!.totalAmount
199215
if (total > 0.msat) total else amount
200216
}
217+
val paymentMetadata = records.get<OnionPaymentPayloadTlv.PaymentMetadata>()?.data
201218

202219
override fun write(out: Output) = tlvSerializer.write(records, out)
203220

204221
companion object : PerHopPayloadReader<FinalPayload> {
205222
override fun read(input: Input): FinalPayload = FinalPayload(tlvSerializer.read(input))
206223

207224
/** Create a single-part payment (total amount sent at once). */
208-
fun createSinglePartPayload(amount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, userCustomTlvs: List<GenericTlv> = listOf()): FinalPayload =
209-
FinalPayload(TlvStream(listOf(OnionPaymentPayloadTlv.AmountToForward(amount), OnionPaymentPayloadTlv.OutgoingCltv(expiry), OnionPaymentPayloadTlv.PaymentData(paymentSecret, amount)), userCustomTlvs))
225+
fun createSinglePartPayload(amount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, paymentMetadata: ByteVector?, userCustomTlvs: List<GenericTlv> = listOf()): FinalPayload {
226+
val tlvs = buildList {
227+
add(OnionPaymentPayloadTlv.AmountToForward(amount))
228+
add(OnionPaymentPayloadTlv.OutgoingCltv(expiry))
229+
add(OnionPaymentPayloadTlv.PaymentData(paymentSecret, amount))
230+
paymentMetadata?.let { add(OnionPaymentPayloadTlv.PaymentMetadata(it)) }
231+
}
232+
return FinalPayload(TlvStream(tlvs, userCustomTlvs))
233+
}
210234

211235
/** Create a partial payment (total amount split between multiple payments). */
212236
fun createMultiPartPayload(
213-
amount: MilliSatoshi, totalAmount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, additionalTlvs: List<OnionPaymentPayloadTlv> = listOf(), userCustomTlvs: List<GenericTlv> = listOf()
214-
): FinalPayload =
215-
FinalPayload(TlvStream(listOf(OnionPaymentPayloadTlv.AmountToForward(amount), OnionPaymentPayloadTlv.OutgoingCltv(expiry), OnionPaymentPayloadTlv.PaymentData(paymentSecret, totalAmount)) + additionalTlvs, userCustomTlvs))
237+
amount: MilliSatoshi,
238+
totalAmount: MilliSatoshi,
239+
expiry: CltvExpiry,
240+
paymentSecret: ByteVector32,
241+
paymentMetadata: ByteVector?,
242+
additionalTlvs: List<OnionPaymentPayloadTlv> = listOf(),
243+
userCustomTlvs: List<GenericTlv> = listOf()
244+
): FinalPayload {
245+
val tlvs = buildList {
246+
add(OnionPaymentPayloadTlv.AmountToForward(amount))
247+
add(OnionPaymentPayloadTlv.OutgoingCltv(expiry))
248+
add(OnionPaymentPayloadTlv.PaymentData(paymentSecret, totalAmount))
249+
paymentMetadata?.let { add(OnionPaymentPayloadTlv.PaymentMetadata(it)) }
250+
addAll(additionalTlvs)
251+
}
252+
return FinalPayload(TlvStream(tlvs, userCustomTlvs))
253+
}
216254

217255
/** Create a trampoline outer payload. */
218-
fun createTrampolinePayload(amount: MilliSatoshi, totalAmount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, trampolinePacket: OnionRoutingPacket): FinalPayload = FinalPayload(
219-
TlvStream(
220-
listOf(
221-
OnionPaymentPayloadTlv.AmountToForward(amount), OnionPaymentPayloadTlv.OutgoingCltv(expiry), OnionPaymentPayloadTlv.PaymentData(paymentSecret, totalAmount), OnionPaymentPayloadTlv.TrampolineOnion(trampolinePacket)
222-
)
223-
)
224-
)
256+
fun createTrampolinePayload(amount: MilliSatoshi, totalAmount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, trampolinePacket: OnionRoutingPacket): FinalPayload {
257+
val tlvs = buildList {
258+
add(OnionPaymentPayloadTlv.AmountToForward(amount))
259+
add(OnionPaymentPayloadTlv.OutgoingCltv(expiry))
260+
add(OnionPaymentPayloadTlv.PaymentData(paymentSecret, totalAmount))
261+
add(OnionPaymentPayloadTlv.TrampolineOnion(trampolinePacket))
262+
}
263+
return FinalPayload(TlvStream(tlvs))
264+
}
225265
}
226266
}
227267

@@ -255,6 +295,7 @@ object PaymentOnion {
255295

256296
// NB: the following fields are only included in the trampoline-to-legacy case.
257297
val paymentSecret = records.get<OnionPaymentPayloadTlv.PaymentData>()?.secret
298+
val paymentMetadata = records.get<OnionPaymentPayloadTlv.PaymentMetadata>()?.data
258299
val invoiceFeatures = records.get<OnionPaymentPayloadTlv.InvoiceFeatures>()?.features
259300
val invoiceRoutingInfo = records.get<OnionPaymentPayloadTlv.InvoiceRoutingInfo>()?.extraHops
260301

@@ -279,14 +320,15 @@ object PaymentOnion {
279320
}.map { it.hints }
280321
return NodeRelayPayload(
281322
TlvStream(
282-
listOf(
283-
OnionPaymentPayloadTlv.AmountToForward(amount),
284-
OnionPaymentPayloadTlv.OutgoingCltv(expiry),
285-
OnionPaymentPayloadTlv.OutgoingNodeId(targetNodeId),
286-
OnionPaymentPayloadTlv.PaymentData(invoice.paymentSecret, totalAmount),
287-
OnionPaymentPayloadTlv.InvoiceFeatures(invoice.features),
288-
OnionPaymentPayloadTlv.InvoiceRoutingInfo(prunedRoutingHints)
289-
)
323+
buildList {
324+
add(OnionPaymentPayloadTlv.AmountToForward(amount))
325+
add(OnionPaymentPayloadTlv.OutgoingCltv(expiry))
326+
add(OnionPaymentPayloadTlv.OutgoingNodeId(targetNodeId))
327+
add(OnionPaymentPayloadTlv.PaymentData(invoice.paymentSecret, totalAmount))
328+
invoice.paymentMetadata?.let { add(OnionPaymentPayloadTlv.PaymentMetadata(it)) }
329+
add(OnionPaymentPayloadTlv.InvoiceFeatures(invoice.features))
330+
add(OnionPaymentPayloadTlv.InvoiceRoutingInfo(prunedRoutingHints))
331+
}
290332
)
291333
)
292334
}

src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ object TestsHelper {
320320
val expiry = CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight)
321321
val dummyKey = PrivateKey(ByteVector32("0101010101010101010101010101010101010101010101010101010101010101")).publicKey()
322322
val dummyUpdate = ChannelUpdate(ByteVector64.Zeroes, ByteVector32.Zeroes, ShortChannelId(144, 0, 0), 0, 0, 0, CltvExpiryDelta(1), 0.msat, 0.msat, 0, null)
323-
val cmd = OutgoingPaymentPacket.buildCommand(paymentId, paymentHash, listOf(ChannelHop(dummyKey, destination, dummyUpdate)), PaymentOnion.FinalPayload.createSinglePartPayload(amount, expiry, randomBytes32())).first.copy(commit = false)
323+
val cmd = OutgoingPaymentPacket.buildCommand(paymentId, paymentHash, listOf(ChannelHop(dummyKey, destination, dummyUpdate)), PaymentOnion.FinalPayload.createSinglePartPayload(amount, expiry, randomBytes32(), null)).first.copy(commit = false)
324324
return Pair(paymentPreimage, cmd)
325325
}
326326

0 commit comments

Comments
 (0)