Skip to content

Commit

Permalink
Bolt12Invoice
Browse files Browse the repository at this point in the history
  • Loading branch information
thomash-acinq committed Feb 19, 2024
1 parent 8f38e7d commit d488743
Show file tree
Hide file tree
Showing 17 changed files with 989 additions and 190 deletions.
13 changes: 11 additions & 2 deletions src/commonMain/kotlin/fr/acinq/lightning/Features.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import fr.acinq.lightning.utils.or
import kotlinx.serialization.Serializable

/** Feature scope as defined in Bolt 9. */
enum class FeatureScope { Init, Node, Invoice }
enum class FeatureScope { Init, Node, Invoice, Bolt12 }

enum class FeatureSupport {
Mandatory {
Expand Down Expand Up @@ -88,7 +88,7 @@ sealed class Feature {
object BasicMultiPartPayment : Feature() {
override val rfcName get() = "basic_mpp"
override val mandatory get() = 16
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node, FeatureScope.Invoice)
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node, FeatureScope.Invoice, FeatureScope.Bolt12)
}

@Serializable
Expand All @@ -105,6 +105,13 @@ sealed class Feature {
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
}

@Serializable
object RouteBlinding : Feature() {
override val rfcName get() = "option_route_blinding"
override val mandatory get() = 24
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node, FeatureScope.Invoice)
}

@Serializable
object ShutdownAnySegwit : Feature() {
override val rfcName get() = "option_shutdown_anysegwit"
Expand Down Expand Up @@ -268,6 +275,8 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se

fun invoiceFeatures(): Features = Features(activated.filter { it.key.scopes.contains(FeatureScope.Invoice) }, unknown)

fun bolt12Features(): Features = Features(activated.filter { it.key.scopes.contains(FeatureScope.Bolt12) }, unknown)

/** NB: this method is not reflexive, see [[Features.areCompatible]] if you want symmetric validation. */
fun areSupported(remoteFeatures: Features): Boolean {
// we allow unknown odd features (it's ok to be odd)
Expand Down
7 changes: 4 additions & 3 deletions src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.ShortChannelId
import fr.acinq.lightning.channel.ChannelException
import fr.acinq.lightning.payment.Bolt11Invoice
import fr.acinq.lightning.payment.FinalFailure
import fr.acinq.lightning.payment.PaymentRequest
import fr.acinq.lightning.utils.*
Expand Down Expand Up @@ -147,7 +148,7 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r

sealed class Origin {
/** A normal, invoice-based lightning payment. */
data class Invoice(val paymentRequest: PaymentRequest) : Origin()
data class Invoice(val paymentRequest: Bolt11Invoice) : Origin()

/** KeySend payments are spontaneous donations for which we didn't create an invoice. */
data object KeySend : Origin()
Expand Down Expand Up @@ -249,7 +250,7 @@ data class LightningOutgoingPayment(
) : OutgoingPayment() {

/** Create an outgoing payment in a pending status, without any parts yet. */
constructor(id: UUID, amount: MilliSatoshi, recipient: PublicKey, invoice: PaymentRequest) : this(id, amount, recipient, Details.Normal(invoice), listOf(), Status.Pending)
constructor(id: UUID, amount: MilliSatoshi, recipient: PublicKey, invoice: Bolt11Invoice) : this(id, amount, recipient, Details.Normal(invoice), listOf(), Status.Pending)

val paymentHash: ByteVector32 = details.paymentHash

Expand Down Expand Up @@ -284,7 +285,7 @@ data class LightningOutgoingPayment(
abstract val paymentHash: ByteVector32

/** A normal lightning payment. */
data class Normal(val paymentRequest: PaymentRequest) : Details() {
data class Normal(val paymentRequest: Bolt11Invoice) : Details() {
override val paymentHash: ByteVector32 = paymentRequest.paymentHash
}

Expand Down
6 changes: 3 additions & 3 deletions src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ data object Disconnected : PeerCommand()
sealed class PaymentCommand : PeerCommand()
private data object CheckPaymentsTimeout : PaymentCommand()
data class PayToOpenResponseCommand(val payToOpenResponse: PayToOpenResponse) : PeerCommand()
data class SendPayment(val paymentId: UUID, val amount: MilliSatoshi, val recipient: PublicKey, val paymentRequest: PaymentRequest, val trampolineFeesOverride: List<TrampolineFees>? = null) : PaymentCommand() {
data class SendPayment(val paymentId: UUID, val amount: MilliSatoshi, val recipient: PublicKey, val paymentRequest: Bolt11Invoice, val trampolineFeesOverride: List<TrampolineFees>? = null) : PaymentCommand() {
val paymentHash: ByteVector32 = paymentRequest.paymentHash
}

Expand Down Expand Up @@ -614,7 +614,7 @@ class Peer(
}
}

suspend fun createInvoice(paymentPreimage: ByteVector32, amount: MilliSatoshi?, description: Either<String, ByteVector32>, expirySeconds: Long? = null): PaymentRequest {
suspend fun createInvoice(paymentPreimage: ByteVector32, amount: MilliSatoshi?, description: Either<String, ByteVector32>, expirySeconds: Long? = null): Bolt11Invoice {
// we add one extra hop which uses a virtual channel with a "peer id", using the highest remote fees and expiry across all
// channels to maximize the likelihood of success on the first payment attempt
val remoteChannelUpdates = _channels.values.mapNotNull { channelState ->
Expand All @@ -627,7 +627,7 @@ class Peer(
}
val extraHops = listOf(
listOf(
PaymentRequest.TaggedField.ExtraHop(
Bolt11Invoice.TaggedField.ExtraHop(
nodeId = walletParams.trampolineNode.id,
shortChannelId = ShortChannelId.peerId(nodeParams.nodeId),
feeBase = remoteChannelUpdates.maxOfOrNull { it.feeBaseMsat } ?: walletParams.invoiceDefaultRoutingFees.feeBase,
Expand Down
24 changes: 12 additions & 12 deletions src/commonMain/kotlin/fr/acinq/lightning/payment/Bolt11Invoice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ import fr.acinq.lightning.utils.*
import fr.acinq.lightning.wire.LightningCodecs
import kotlin.experimental.and

data class PaymentRequest(
data class Bolt11Invoice(
val prefix: String,
val amount: MilliSatoshi?,
override val amount: MilliSatoshi?,
val timestampSeconds: Long,
val nodeId: PublicKey,
val tags: List<TaggedField>,
val signature: ByteVector
) {
val paymentHash: ByteVector32 = tags.find { it is TaggedField.PaymentHash }!!.run { (this as TaggedField.PaymentHash).hash }
) : PaymentRequest {
override val paymentHash: ByteVector32 = tags.find { it is TaggedField.PaymentHash }!!.run { (this as TaggedField.PaymentHash).hash }

val paymentSecret: ByteVector32 = tags.find { it is TaggedField.PaymentSecret }!!.run { (this as TaggedField.PaymentSecret).secret }

Expand All @@ -37,12 +37,12 @@ data class PaymentRequest(

val fallbackAddress: String? = tags.find { it is TaggedField.FallbackAddress }?.run { (this as TaggedField.FallbackAddress).toAddress(prefix) }

val features: ByteVector = tags.find { it is TaggedField.Features }.run { (this as TaggedField.Features).bits }
override val features: Features = tags.find { it is TaggedField.Features }.run { Features((this as TaggedField.Features).bits) }

val routingInfo: List<TaggedField.RoutingInfo> = tags.filterIsInstance<TaggedField.RoutingInfo>()

init {
val f = Features(features).invoiceFeatures()
val f = features.invoiceFeatures()
require(f.hasFeature(Feature.VariableLengthOnion)) { "${Feature.VariableLengthOnion.rfcName} must be supported" }
require(f.hasFeature(Feature.PaymentSecret)) { "${Feature.PaymentSecret.rfcName} must be supported" }
require(Features.validateFeatureGraph(f) == null)
Expand All @@ -53,7 +53,7 @@ data class PaymentRequest(
require(description != null || descriptionHash != null) { "there must be exactly one description tag or one description hash tag" }
}

fun isExpired(currentTimestampSeconds: Long = currentTimestampSeconds()): Boolean = when (expirySeconds) {
override fun isExpired(currentTimestampSeconds: Long): Boolean = when (expirySeconds) {
null -> timestampSeconds + DEFAULT_EXPIRY_SECONDS <= currentTimestampSeconds
else -> timestampSeconds + expirySeconds <= currentTimestampSeconds
}
Expand Down Expand Up @@ -91,7 +91,7 @@ data class PaymentRequest(
* @param privateKey private key, which must match the payment request's node id
* @return a signature (64 bytes) plus a recovery id (1 byte)
*/
fun sign(privateKey: PrivateKey): PaymentRequest {
fun sign(privateKey: PrivateKey): Bolt11Invoice {
require(privateKey.publicKey() == nodeId) { "private key does not match node id" }
val msg = signedHash()
val sig = Crypto.sign(msg, privateKey)
Expand Down Expand Up @@ -143,7 +143,7 @@ data class PaymentRequest(
expirySeconds: Long? = null,
extraHops: List<List<TaggedField.ExtraHop>> = listOf(),
timestampSeconds: Long = currentTimestampSeconds()
): PaymentRequest {
): Bolt11Invoice {
val prefix = prefixes[chainHash] ?: error("unknown chain hash")
val tags = mutableListOf(
TaggedField.PaymentHash(paymentHash),
Expand All @@ -160,7 +160,7 @@ data class PaymentRequest(
extraHops.forEach { tags.add(TaggedField.RoutingInfo(it)) }
}

return PaymentRequest(
return Bolt11Invoice(
prefix = prefix,
amount = amount,
timestampSeconds = timestampSeconds,
Expand All @@ -177,7 +177,7 @@ data class PaymentRequest(
return loop(input, listOf())
}

fun read(input: String): Try<PaymentRequest> = runTrying {
fun read(input: String): Try<Bolt11Invoice> = runTrying {
val (hrp, data) = Bech32.decode(input)
val prefix = prefixes.values.find { hrp.startsWith(it) } ?: throw IllegalArgumentException("unknown prefix $hrp")
val amount = decodeAmount(hrp.drop(prefix.length))
Expand Down Expand Up @@ -219,7 +219,7 @@ data class PaymentRequest(
}

loop(data.drop(7).dropLast(104))
val pr = PaymentRequest(prefix, amount, timestamp, nodeId, tags, sigandrecid.toByteVector())
val pr = Bolt11Invoice(prefix, amount, timestamp, nodeId, tags, sigandrecid.toByteVector())
require(pr.signedPreimage().contentEquals(tohash)) { "invoice isn't canonically encoded" }
pr
}
Expand Down
160 changes: 160 additions & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/payment/Bolt12Invoice.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package fr.acinq.lightning.payment

import fr.acinq.bitcoin.*
import fr.acinq.bitcoin.utils.Either
import fr.acinq.bitcoin.utils.Try
import fr.acinq.bitcoin.utils.runTrying
import fr.acinq.lightning.Feature
import fr.acinq.lightning.FeatureSupport
import fr.acinq.lightning.Features
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.utils.currentTimestampSeconds
import fr.acinq.lightning.wire.GenericTlv
import fr.acinq.lightning.wire.OfferTypes
import fr.acinq.lightning.wire.OfferTypes.ContactInfo.BlindedPath
import fr.acinq.lightning.wire.OfferTypes.FallbackAddress
import fr.acinq.lightning.wire.OfferTypes.InvalidTlvPayload
import fr.acinq.lightning.wire.OfferTypes.InvoiceAmount
import fr.acinq.lightning.wire.OfferTypes.InvoiceBlindedPay
import fr.acinq.lightning.wire.OfferTypes.InvoiceCreatedAt
import fr.acinq.lightning.wire.OfferTypes.InvoiceFallbacks
import fr.acinq.lightning.wire.OfferTypes.InvoiceFeatures
import fr.acinq.lightning.wire.OfferTypes.InvoiceNodeId
import fr.acinq.lightning.wire.OfferTypes.InvoicePaths
import fr.acinq.lightning.wire.OfferTypes.InvoicePaymentHash
import fr.acinq.lightning.wire.OfferTypes.InvoiceRelativeExpiry
import fr.acinq.lightning.wire.OfferTypes.InvoiceRequest
import fr.acinq.lightning.wire.OfferTypes.InvoiceTlv
import fr.acinq.lightning.wire.OfferTypes.MissingRequiredTlv
import fr.acinq.lightning.wire.OfferTypes.PaymentInfo
import fr.acinq.lightning.wire.OfferTypes.Signature
import fr.acinq.lightning.wire.OfferTypes.filterInvoiceRequestFields
import fr.acinq.lightning.wire.OfferTypes.removeSignature
import fr.acinq.lightning.wire.OfferTypes.rootHash
import fr.acinq.lightning.wire.OfferTypes.signSchnorr
import fr.acinq.lightning.wire.OfferTypes.verifySchnorr
import fr.acinq.lightning.wire.TlvStream

data class Bolt12Invoice(val records: TlvStream<InvoiceTlv>) : PaymentRequest {
val invoiceRequest: InvoiceRequest = InvoiceRequest.validate(filterInvoiceRequestFields(records)).right!!

override val amount: MilliSatoshi? = records.get<InvoiceAmount>()?.amount
val nodeId: PublicKey = records.get<InvoiceNodeId>()!!.nodeId
override val paymentHash: ByteVector32 = records.get<InvoicePaymentHash>()!!.hash
val description: String = invoiceRequest.offer.description
val createdAtSeconds: Long = records.get<InvoiceCreatedAt>()!!.timestampSeconds
val relativeExpirySeconds: Long = records.get<InvoiceRelativeExpiry>()?.seconds ?: DEFAULT_EXPIRY_SECONDS


// We add invoice features that are implicitly required for Bolt 12 (the spec doesn't allow explicitly setting them).
override val features: Features =
(records.get<InvoiceFeatures>()?.features?.invoiceFeatures() ?: Features.empty).let {
it.copy(activated = it.activated + (Feature.VariableLengthOnion to FeatureSupport.Mandatory) + (Feature.RouteBlinding to FeatureSupport.Mandatory))
}

val blindedPaths: List<PaymentBlindedContactInfo> = records.get<InvoicePaths>()!!.paths.zip(records.get<InvoiceBlindedPay>()!!.paymentInfos).map { PaymentBlindedContactInfo(it.first, it.second) }
val fallbacks: List<FallbackAddress>? = records.get<InvoiceFallbacks>()?.addresses
val signature: ByteVector64 = records.get<Signature>()!!.signature


override fun isExpired(currentTimestampSeconds: Long): Boolean = createdAtSeconds + relativeExpirySeconds <= currentTimestampSeconds

// It is assumed that the request is valid for this offer.
fun validateFor(request: InvoiceRequest): Either<String, Unit> =
if (invoiceRequest.unsigned() != request.unsigned()) {
Either.Left("Invoice does not match request")
} else if (nodeId != invoiceRequest.offer.nodeId) {
Either.Left("Wrong node id")
} else if (isExpired()) {
Either.Left("Invoice expired")
} else if (request.amount != null && amount != null && request.amount != amount) {
Either.Left("Incompatible amount")
} else if (!Features.areCompatible(request.features, features.bolt12Features())) {
Either.Left("Incompatible features")
} else if (!checkSignature()) {
Either.Left("Invalid signature")
} else {
Either.Right(Unit)
}

fun checkSignature(): Boolean =
verifySchnorr(signatureTag, rootHash(removeSignature(records)), signature, nodeId)

override fun toString(): String {
val data = OfferTypes.Invoice.tlvSerializer.write(records)
return Bech32.encodeBytes(hrp, data, Bech32.Encoding.Beck32WithoutChecksum)
}

companion object {
val hrp = "lni"
val signatureTag: ByteVector = ByteVector(("lightning" + "invoice" + "signature").encodeToByteArray())
val DEFAULT_EXPIRY_SECONDS: Long = 7200

data class PaymentBlindedContactInfo(val route: BlindedPath, val paymentInfo: PaymentInfo)

/**
* Creates an invoice for a given offer and invoice request.
*
* @param request the request this invoice responds to
* @param preimage the preimage to use for the payment
* @param nodeKey the key that was used to generate the offer, may be different from our public nodeId if we're hiding behind a blinded route
* @param features invoice features
* @param paths the blinded paths to use to pay the invoice
*/
operator fun invoke(
request: InvoiceRequest,
preimage: ByteVector32,
nodeKey: PrivateKey,
invoiceExpirySeconds: Long,
features: Features,
paths: List<PaymentBlindedContactInfo>,
additionalTlvs: Set<InvoiceTlv> = setOf(),
customTlvs: Set<GenericTlv> = setOf()
): Bolt12Invoice {
require(request.amount != null || request.offer.amount != null)
val amount = request.amount ?: (request.offer.amount!! * request.quantity)
val tlvs: Set<InvoiceTlv> = removeSignature(request.records).records + setOfNotNull(
InvoicePaths(paths.map { it.route }),
InvoiceBlindedPay(paths.map { it.paymentInfo }),
InvoiceCreatedAt(currentTimestampSeconds()),
InvoiceRelativeExpiry(invoiceExpirySeconds),
InvoicePaymentHash(ByteVector32(Crypto.sha256(preimage))),
InvoiceAmount(amount),
if (features != Features.empty) InvoiceFeatures(features) else null,
InvoiceNodeId(nodeKey.publicKey()),
) + additionalTlvs
val signature = signSchnorr(
signatureTag,
rootHash(TlvStream(tlvs, request.records.unknown + customTlvs)),
nodeKey
)
return Bolt12Invoice(TlvStream(tlvs + Signature(signature), request.records.unknown + customTlvs))
}

fun validate(records: TlvStream<InvoiceTlv>): Either<InvalidTlvPayload, Bolt12Invoice> {
when (val invoiceRequest = InvoiceRequest.validate(filterInvoiceRequestFields(records))) {
is Either.Left -> return Either.Left(invoiceRequest.value)
is Either.Right -> {}
}
if (records.get<InvoiceAmount>() == null) return Either.Left(MissingRequiredTlv(170))
if (records.get<InvoicePaths>()?.paths?.isEmpty() != false) return Either.Left(MissingRequiredTlv(160))
if (records.get<InvoiceBlindedPay>()?.paymentInfos?.size != records.get<InvoicePaths>()?.paths?.size) return Either.Left(MissingRequiredTlv(162))
if (records.get<InvoiceNodeId>() == null) return Either.Left(MissingRequiredTlv(176))
if (records.get<InvoiceCreatedAt>() == null) return Either.Left(MissingRequiredTlv(164))
if (records.get<InvoicePaymentHash>() == null) return Either.Left(MissingRequiredTlv(168))
if (records.get<Signature>() == null) return Either.Left(MissingRequiredTlv(240))
return Either.Right(Bolt12Invoice(records))
}

fun fromString(input: String): Try<Bolt12Invoice> = runTrying {
val (prefix, encoded, encoding) = Bech32.decodeBytes(input.lowercase(), true)
require(prefix == hrp)
require(encoding == Bech32.Encoding.Beck32WithoutChecksum)
val tlvs = OfferTypes.Invoice.tlvSerializer.read(encoded)
when (val invoice = validate(tlvs)) {
is Either.Left -> throw IllegalArgumentException(invoice.value.toString())
is Either.Right -> invoice.value
}
}
}
}
Loading

0 comments on commit d488743

Please sign in to comment.