Skip to content

Commit 498cd91

Browse files
committed
Add support for sending payment metadata
Whenever we find a payment metadata field in an invoice, we send it in the onion payload for the final recipient.
1 parent 79e6aef commit 498cd91

18 files changed

+173
-129
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ object IncomingPaymentPacket {
117117
} else {
118118
// We merge contents from the outer and inner payloads.
119119
// We must use the inner payload's total amount and payment secret because the payment may be split between multiple trampoline payments (#reckless).
120-
Right(FinalPacket(add, PaymentOnion.createMultiPartPayload(outerPayload.amount, innerPayload.totalAmount, outerPayload.expiry, innerPayload.paymentSecret)))
120+
Right(FinalPacket(add, PaymentOnion.createMultiPartPayload(outerPayload.amount, innerPayload.totalAmount, outerPayload.expiry, innerPayload.paymentSecret, innerPayload.paymentMetadata)))
121121
}
122122
}
123123

eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentRequest.scala

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import scodec.bits.{BitVector, ByteOrdering, ByteVector}
2424
import scodec.codecs.{list, ubyte}
2525
import scodec.{Codec, Err}
2626

27-
import scala.concurrent.duration._
2827
import scala.util.{Failure, Success, Try}
2928

3029
/**
@@ -67,6 +66,11 @@ case class PaymentRequest(prefix: String, amount: Option[MilliSatoshi], timestam
6766
case PaymentRequest.DescriptionHash(h) => Right(h)
6867
}.get
6968

69+
/**
70+
* @return the payment metadata
71+
*/
72+
lazy val paymentMetadata: Option[ByteVector] = tags.collectFirst { case m: PaymentRequest.PaymentMetadata => m.data }
73+
7074
/**
7175
* @return the fallback address if any. It could be a script address, pubkey address, ..
7276
*/
@@ -181,7 +185,6 @@ object PaymentRequest {
181185
case class UnknownTag10(data: BitVector) extends UnknownTaggedField
182186
case class UnknownTag11(data: BitVector) extends UnknownTaggedField
183187
case class UnknownTag12(data: BitVector) extends UnknownTaggedField
184-
case class UnknownTag14(data: BitVector) extends UnknownTaggedField
185188
case class UnknownTag15(data: BitVector) extends UnknownTaggedField
186189
case class InvalidTag16(data: BitVector) extends InvalidTaggedField
187190
case class UnknownTag17(data: BitVector) extends UnknownTaggedField
@@ -229,6 +232,11 @@ object PaymentRequest {
229232
*/
230233
case class DescriptionHash(hash: ByteVector32) extends TaggedField
231234

235+
/**
236+
* Additional metadata to attach to the payment.
237+
*/
238+
case class PaymentMetadata(data: ByteVector) extends TaggedField
239+
232240
/**
233241
* Fallback Payment that specifies a fallback payment address to be used if LN payment cannot be processed
234242
*/
@@ -410,7 +418,7 @@ object PaymentRequest {
410418
.typecase(11, dataCodec(bits).as[UnknownTag11])
411419
.typecase(12, dataCodec(bits).as[UnknownTag12])
412420
.typecase(13, dataCodec(alignedBytesCodec(utf8)).as[Description])
413-
.typecase(14, dataCodec(bits).as[UnknownTag14])
421+
.typecase(14, dataCodec(alignedBytesCodec(bytes)).as[PaymentMetadata])
414422
.typecase(15, dataCodec(bits).as[UnknownTag15])
415423
.\(16) {
416424
case a: PaymentSecret => a: TaggedField

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,13 +271,13 @@ class NodeRelay private(nodeParams: NodeParams,
271271
val paymentSecret = payloadOut.paymentSecret.get // NB: we've verified that there was a payment secret in validateRelay
272272
if (Features(features).hasFeature(Features.BasicMultiPartPayment)) {
273273
context.log.debug("sending the payment to non-trampoline recipient using MPP")
274-
val payment = SendMultiPartPayment(payFsmAdapters, paymentSecret, payloadOut.outgoingNodeId, payloadOut.amountToForward, payloadOut.outgoingCltv, nodeParams.maxPaymentAttempts, routingHints, routeParams)
274+
val payment = SendMultiPartPayment(payFsmAdapters, paymentSecret, payloadOut.outgoingNodeId, payloadOut.amountToForward, payloadOut.outgoingCltv, nodeParams.maxPaymentAttempts, None, routingHints, routeParams)
275275
val payFSM = outgoingPaymentFactory.spawnOutgoingPayFSM(context, paymentCfg, multiPart = true)
276276
payFSM ! payment
277277
payFSM
278278
} else {
279279
context.log.debug("sending the payment to non-trampoline recipient without MPP")
280-
val finalPayload = PaymentOnion.createSinglePartPayload(payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret)
280+
val finalPayload = PaymentOnion.createSinglePartPayload(payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, None)
281281
val payment = SendPaymentToNode(payFsmAdapters, payloadOut.outgoingNodeId, finalPayload, nodeParams.maxPaymentAttempts, routingHints, routeParams)
282282
val payFSM = outgoingPaymentFactory.spawnOutgoingPayFSM(context, paymentCfg, multiPart = false)
283283
payFSM ! payment
@@ -287,7 +287,7 @@ class NodeRelay private(nodeParams: NodeParams,
287287
context.log.debug("sending the payment to the next trampoline node")
288288
val payFSM = outgoingPaymentFactory.spawnOutgoingPayFSM(context, paymentCfg, multiPart = true)
289289
val paymentSecret = randomBytes32() // we generate a new secret to protect against probing attacks
290-
val payment = SendMultiPartPayment(payFsmAdapters, paymentSecret, payloadOut.outgoingNodeId, payloadOut.amountToForward, payloadOut.outgoingCltv, nodeParams.maxPaymentAttempts, routeParams = routeParams, additionalTlvs = Seq(OnionPaymentPayloadTlv.TrampolineOnion(packetOut)))
290+
val payment = SendMultiPartPayment(payFsmAdapters, paymentSecret, payloadOut.outgoingNodeId, payloadOut.amountToForward, payloadOut.outgoingCltv, nodeParams.maxPaymentAttempts, None, routeParams = routeParams, additionalTlvs = Seq(OnionPaymentPayloadTlv.TrampolineOnion(packetOut)))
291291
payFSM ! payment
292292
payFSM
293293
}

eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import fr.acinq.eclair.payment.send.PaymentLifecycle.SendPaymentToRoute
3232
import fr.acinq.eclair.router.Router._
3333
import fr.acinq.eclair.wire.protocol._
3434
import fr.acinq.eclair.{CltvExpiry, FSMDiagnosticActorLogging, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli}
35+
import scodec.bits.ByteVector
3536

3637
import java.util.UUID
3738
import java.util.concurrent.TimeUnit
@@ -301,23 +302,25 @@ object MultiPartPaymentLifecycle {
301302
* Send a payment to a given node. The payment may be split into multiple child payments, for which a path-finding
302303
* algorithm will run to find suitable payment routes.
303304
*
304-
* @param paymentSecret payment secret to protect against probing (usually from a Bolt 11 invoice).
305-
* @param targetNodeId target node (may be the final recipient when using source-routing, or the first trampoline
306-
* node when using trampoline).
307-
* @param totalAmount total amount to send to the target node.
308-
* @param targetExpiry expiry at the target node (CLTV for the target node's received HTLCs).
309-
* @param maxAttempts maximum number of retries.
310-
* @param assistedRoutes routing hints (usually from a Bolt 11 invoice).
311-
* @param routeParams parameters to fine-tune the routing algorithm.
312-
* @param additionalTlvs when provided, additional tlvs that will be added to the onion sent to the target node.
313-
* @param userCustomTlvs when provided, additional user-defined custom tlvs that will be added to the onion sent to the target node.
305+
* @param paymentSecret payment secret to protect against probing (usually from a Bolt 11 invoice).
306+
* @param targetNodeId target node (may be the final recipient when using source-routing, or the first trampoline
307+
* node when using trampoline).
308+
* @param totalAmount total amount to send to the target node.
309+
* @param targetExpiry expiry at the target node (CLTV for the target node's received HTLCs).
310+
* @param maxAttempts maximum number of retries.
311+
* @param paymentMetadata payment metadata (usually from the Bolt 11 invoice).
312+
* @param assistedRoutes routing hints (usually from a Bolt 11 invoice).
313+
* @param routeParams parameters to fine-tune the routing algorithm.
314+
* @param additionalTlvs when provided, additional tlvs that will be added to the onion sent to the target node.
315+
* @param userCustomTlvs when provided, additional user-defined custom tlvs that will be added to the onion sent to the target node.
314316
*/
315317
case class SendMultiPartPayment(replyTo: ActorRef,
316318
paymentSecret: ByteVector32,
317319
targetNodeId: PublicKey,
318320
totalAmount: MilliSatoshi,
319321
targetExpiry: CltvExpiry,
320322
maxAttempts: Int,
323+
paymentMetadata: Option[ByteVector],
321324
assistedRoutes: Seq[Seq[ExtraHop]] = Nil,
322325
routeParams: RouteParams,
323326
additionalTlvs: Seq[OnionPaymentPayloadTlv] = Nil,
@@ -400,7 +403,7 @@ object MultiPartPaymentLifecycle {
400403
Some(cfg.paymentContext))
401404

402405
private def createChildPayment(replyTo: ActorRef, route: Route, request: SendMultiPartPayment): SendPaymentToRoute = {
403-
val finalPayload = PaymentOnion.createMultiPartPayload(route.amount, request.totalAmount, request.targetExpiry, request.paymentSecret, request.additionalTlvs, request.userCustomTlvs)
406+
val finalPayload = PaymentOnion.createMultiPartPayload(route.amount, request.totalAmount, request.targetExpiry, request.paymentSecret, request.paymentMetadata, request.additionalTlvs, request.userCustomTlvs)
404407
SendPaymentToRoute(replyTo, Right(route), finalPayload)
405408
}
406409

eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentInitiator.scala

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ class PaymentInitiator(nodeParams: NodeParams, outgoingPaymentFactory: PaymentIn
5959
sender() ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(r.recipientAmount, Nil, PaymentSecretMissing) :: Nil)
6060
case Some(paymentSecret) if r.paymentRequest.features.allowMultiPart && nodeParams.features.hasFeature(BasicMultiPartPayment) =>
6161
val fsm = outgoingPaymentFactory.spawnOutgoingMultiPartPayment(context, paymentCfg)
62-
fsm ! SendMultiPartPayment(sender(), paymentSecret, r.recipientNodeId, r.recipientAmount, finalExpiry, r.maxAttempts, r.assistedRoutes, r.routeParams, userCustomTlvs = r.userCustomTlvs)
62+
fsm ! SendMultiPartPayment(sender(), paymentSecret, r.recipientNodeId, r.recipientAmount, finalExpiry, r.maxAttempts, r.paymentRequest.paymentMetadata, r.assistedRoutes, r.routeParams, userCustomTlvs = r.userCustomTlvs)
6363
case Some(paymentSecret) =>
64-
val finalPayload = PaymentOnion.createSinglePartPayload(r.recipientAmount, finalExpiry, paymentSecret, r.userCustomTlvs)
64+
val finalPayload = PaymentOnion.createSinglePartPayload(r.recipientAmount, finalExpiry, paymentSecret, r.paymentRequest.paymentMetadata, r.userCustomTlvs)
6565
val fsm = outgoingPaymentFactory.spawnOutgoingPayment(context, paymentCfg)
6666
fsm ! PaymentLifecycle.SendPaymentToNode(sender(), r.recipientNodeId, finalPayload, r.maxAttempts, r.assistedRoutes, r.routeParams)
6767
}
@@ -140,11 +140,11 @@ class PaymentInitiator(nodeParams: NodeParams, outgoingPaymentFactory: PaymentIn
140140
sender() ! SendPaymentToRouteResponse(paymentId, parentPaymentId, Some(trampolineSecret))
141141
val payFsm = outgoingPaymentFactory.spawnOutgoingPayment(context, paymentCfg)
142142
val (trampolineAmount, trampolineExpiry, trampolineOnion) = buildTrampolinePayment(r, trampoline, r.trampolineFees, r.trampolineExpiryDelta)
143-
payFsm ! PaymentLifecycle.SendPaymentToRoute(sender(), Left(r.route), PaymentOnion.createMultiPartPayload(r.amount, trampolineAmount, trampolineExpiry, trampolineSecret, Seq(OnionPaymentPayloadTlv.TrampolineOnion(trampolineOnion))), r.paymentRequest.routingInfo)
143+
payFsm ! PaymentLifecycle.SendPaymentToRoute(sender(), Left(r.route), PaymentOnion.createMultiPartPayload(r.amount, trampolineAmount, trampolineExpiry, trampolineSecret, r.paymentRequest.paymentMetadata, Seq(OnionPaymentPayloadTlv.TrampolineOnion(trampolineOnion))), r.paymentRequest.routingInfo)
144144
case Nil =>
145145
sender() ! SendPaymentToRouteResponse(paymentId, parentPaymentId, None)
146146
val payFsm = outgoingPaymentFactory.spawnOutgoingPayment(context, paymentCfg)
147-
payFsm ! PaymentLifecycle.SendPaymentToRoute(sender(), Left(r.route), PaymentOnion.createMultiPartPayload(r.amount, r.recipientAmount, finalExpiry, r.paymentRequest.paymentSecret.get), r.paymentRequest.routingInfo)
147+
payFsm ! PaymentLifecycle.SendPaymentToRoute(sender(), Left(r.route), PaymentOnion.createMultiPartPayload(r.amount, r.recipientAmount, finalExpiry, r.paymentRequest.paymentSecret.get, r.paymentRequest.paymentMetadata), r.paymentRequest.routingInfo)
148148
case _ =>
149149
sender() ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(r.recipientAmount, Nil, TrampolineMultiNodeNotSupported) :: Nil)
150150
}
@@ -156,9 +156,9 @@ class PaymentInitiator(nodeParams: NodeParams, outgoingPaymentFactory: PaymentIn
156156
NodeHop(trampolineNodeId, r.recipientNodeId, trampolineExpiryDelta, trampolineFees) // for now we only use a single trampoline hop
157157
)
158158
val finalPayload = if (r.paymentRequest.features.allowMultiPart) {
159-
PaymentOnion.createMultiPartPayload(r.recipientAmount, r.recipientAmount, r.finalExpiry(nodeParams.currentBlockHeight), r.paymentRequest.paymentSecret.get)
159+
PaymentOnion.createMultiPartPayload(r.recipientAmount, r.recipientAmount, r.finalExpiry(nodeParams.currentBlockHeight), r.paymentRequest.paymentSecret.get, r.paymentRequest.paymentMetadata)
160160
} else {
161-
PaymentOnion.createSinglePartPayload(r.recipientAmount, r.finalExpiry(nodeParams.currentBlockHeight), r.paymentRequest.paymentSecret.get)
161+
PaymentOnion.createSinglePartPayload(r.recipientAmount, r.finalExpiry(nodeParams.currentBlockHeight), r.paymentRequest.paymentSecret.get, r.paymentRequest.paymentMetadata)
162162
}
163163
// We assume that the trampoline node supports multi-part payments (it should).
164164
val (trampolineAmount, trampolineExpiry, trampolineOnion) = if (r.paymentRequest.features.allowTrampoline) {
@@ -175,7 +175,7 @@ class PaymentInitiator(nodeParams: NodeParams, outgoingPaymentFactory: PaymentIn
175175
val trampolineSecret = randomBytes32()
176176
val (trampolineAmount, trampolineExpiry, trampolineOnion) = buildTrampolinePayment(r, r.trampolineNodeId, trampolineFees, trampolineExpiryDelta)
177177
val fsm = outgoingPaymentFactory.spawnOutgoingMultiPartPayment(context, paymentCfg)
178-
fsm ! SendMultiPartPayment(self, trampolineSecret, r.trampolineNodeId, trampolineAmount, trampolineExpiry, nodeParams.maxPaymentAttempts, r.paymentRequest.routingInfo, r.routeParams, Seq(OnionPaymentPayloadTlv.TrampolineOnion(trampolineOnion)))
178+
fsm ! SendMultiPartPayment(self, trampolineSecret, r.trampolineNodeId, trampolineAmount, trampolineExpiry, nodeParams.maxPaymentAttempts, r.paymentRequest.paymentMetadata, r.paymentRequest.routingInfo, r.routeParams, Seq(OnionPaymentPayloadTlv.TrampolineOnion(trampolineOnion)))
179179
}
180180

181181
}

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PaymentOnion.scala

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ object OnionPaymentPayloadTlv {
155155
/** Id of the next node. */
156156
case class OutgoingNodeId(nodeId: PublicKey) extends OnionPaymentPayloadTlv
157157

158+
/**
159+
* When payment metadata is included in a Bolt 9 invoice, we should send it as-is to the recipient.
160+
* This lets recipients generate invoices without having to store anything on their side until the invoice is paid.
161+
*/
162+
case class PaymentMetadata(data: ByteVector) extends OnionPaymentPayloadTlv
163+
158164
/**
159165
* Invoice feature bits. Only included for intermediate trampoline nodes when they should convert to a legacy payment
160166
* because the final recipient doesn't support trampoline.
@@ -242,6 +248,7 @@ object PaymentOnion {
242248
val paymentSecret: ByteVector32
243249
val totalAmount: MilliSatoshi
244250
val paymentPreimage: Option[ByteVector32]
251+
val paymentMetadata: Option[ByteVector]
245252
}
246253

247254
case class RelayLegacyPayload(outgoingChannelId: ShortChannelId, amountToForward: MilliSatoshi, outgoingCltv: CltvExpiry) extends ChannelRelayPayload with LegacyFormat
@@ -280,6 +287,7 @@ object PaymentOnion {
280287
case totalAmount => totalAmount
281288
}).getOrElse(amount)
282289
override val paymentPreimage = records.get[KeySend].map(_.paymentPreimage)
290+
override val paymentMetadata = records.get[PaymentMetadata].map(_.data)
283291
}
284292

285293
def createNodeRelayPayload(amount: MilliSatoshi, expiry: CltvExpiry, nextNodeId: PublicKey): NodeRelayPayload =
@@ -292,11 +300,17 @@ object PaymentOnion {
292300
NodeRelayPayload(TlvStream(tlvs2))
293301
}
294302

295-
def createSinglePartPayload(amount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, userCustomTlvs: Seq[GenericTlv] = Nil): FinalPayload =
296-
FinalTlvPayload(TlvStream(Seq(AmountToForward(amount), OutgoingCltv(expiry), PaymentData(paymentSecret, amount)), userCustomTlvs))
303+
def createSinglePartPayload(amount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, paymentMetadata: Option[ByteVector], userCustomTlvs: Seq[GenericTlv] = Nil): FinalPayload = {
304+
paymentMetadata match {
305+
case Some(metadata) => FinalTlvPayload(TlvStream(Seq(AmountToForward(amount), OutgoingCltv(expiry), PaymentData(paymentSecret, amount), PaymentMetadata(metadata)), userCustomTlvs))
306+
case None => FinalTlvPayload(TlvStream(Seq(AmountToForward(amount), OutgoingCltv(expiry), PaymentData(paymentSecret, amount)), userCustomTlvs))
307+
}
308+
}
297309

298-
def createMultiPartPayload(amount: MilliSatoshi, totalAmount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, additionalTlvs: Seq[OnionPaymentPayloadTlv] = Nil, userCustomTlvs: Seq[GenericTlv] = Nil): FinalPayload =
299-
FinalTlvPayload(TlvStream(AmountToForward(amount) +: OutgoingCltv(expiry) +: PaymentData(paymentSecret, totalAmount) +: additionalTlvs, userCustomTlvs))
310+
def createMultiPartPayload(amount: MilliSatoshi, totalAmount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, paymentMetadata: Option[ByteVector], additionalTlvs: Seq[OnionPaymentPayloadTlv] = Nil, userCustomTlvs: Seq[GenericTlv] = Nil): FinalPayload = {
311+
val tlvs = Seq[OnionPaymentPayloadTlv](AmountToForward(amount), OutgoingCltv(expiry), PaymentData(paymentSecret, totalAmount)) ++ paymentMetadata.map(PaymentMetadata) ++ additionalTlvs
312+
FinalTlvPayload(TlvStream(tlvs, userCustomTlvs))
313+
}
300314

301315
/** Create a trampoline outer payload. */
302316
def createTrampolinePayload(amount: MilliSatoshi, totalAmount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, trampolinePacket: OnionRoutingPacket): FinalPayload = {
@@ -340,6 +354,8 @@ object PaymentOnionCodecs {
340354

341355
private val outgoingNodeId: Codec[OutgoingNodeId] = (("length" | constant(hex"21")) :: ("node_id" | publicKey)).as[OutgoingNodeId]
342356

357+
private val paymentMetadata: Codec[PaymentMetadata] = variableSizeBytesLong(varintoverflow, "payment_metadata" | bytes).as[PaymentMetadata]
358+
343359
private val invoiceFeatures: Codec[InvoiceFeatures] = variableSizeBytesLong(varintoverflow, bytes).as[InvoiceFeatures]
344360

345361
private val invoiceRoutingInfo: Codec[InvoiceRoutingInfo] = variableSizeBytesLong(varintoverflow, list(listOfN(uint8, PaymentRequest.Codecs.extraHopCodec))).as[InvoiceRoutingInfo]
@@ -355,6 +371,7 @@ object PaymentOnionCodecs {
355371
.typecase(UInt64(8), paymentData)
356372
.typecase(UInt64(10), encryptedRecipientData)
357373
.typecase(UInt64(12), blindingPoint)
374+
.typecase(UInt64(14), paymentMetadata)
358375
// Types below aren't specified - use cautiously when deploying (be careful with backwards-compatibility).
359376
.typecase(UInt64(66097), invoiceFeatures)
360377
.typecase(UInt64(66098), outgoingNodeId)

eclair-core/src/test/scala/fr/acinq/eclair/channel/FuzzySpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ class FuzzySpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Channe
122122
// allow overpaying (no more than 2 times the required amount)
123123
val amount = requiredAmount + Random.nextInt(requiredAmount.toLong.toInt).msat
124124
val expiry = (Channel.MIN_CLTV_EXPIRY_DELTA + 1).toCltvExpiry(blockHeight = 400000)
125-
OutgoingPaymentPacket.buildCommand(self, Upstream.Local(UUID.randomUUID()), paymentHash, ChannelHop(null, dest, null) :: Nil, PaymentOnion.createSinglePartPayload(amount, expiry, paymentSecret))._1
125+
OutgoingPaymentPacket.buildCommand(self, Upstream.Local(UUID.randomUUID()), paymentHash, ChannelHop(null, dest, null) :: Nil, PaymentOnion.createSinglePartPayload(amount, expiry, paymentSecret, None))._1
126126
}
127127

128128
def initiatePaymentOrStop(remaining: Int): Unit =

0 commit comments

Comments
 (0)