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

Generate default deterministic offer #626

Merged
merged 5 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
24 changes: 21 additions & 3 deletions src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package fr.acinq.lightning

import co.touchlab.kermit.Logger
import fr.acinq.bitcoin.Chain
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.Satoshi
import fr.acinq.bitcoin.*
import fr.acinq.lightning.Lightning.nodeFee
import fr.acinq.lightning.blockchain.fee.FeerateTolerance
import fr.acinq.lightning.blockchain.fee.OnChainFeeConf
Expand All @@ -14,6 +12,9 @@ import fr.acinq.lightning.payment.LiquidityPolicy
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toMilliSatoshi
import fr.acinq.lightning.wire.OfferTypes
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
Expand Down Expand Up @@ -228,4 +229,21 @@ data class NodeParams(
minFinalCltvExpiryDelta = Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA,
bolt12invoiceExpiry = 60.seconds
)

/**
* We generate a default, deterministic Bolt 12 offer based on the node's seed and its trampoline node.
* This offer will stay valid after restoring the seed on a different device.
* We also return the path_id included in this offer, which should be used to route onion messages.
*/
fun defaultOffer(trampolineNode: NodeUri): Pair<ByteVector32, OfferTypes.Offer> {
// We generate a deterministic path_id based on:
// - a custom tag indicating that this is used in the Bolt 12 context
// - our trampoline node, which is used as an introduction node for the offer's blinded path
// - our private key, which ensures that nobody else can generate the same path_id
val pathId = Crypto.sha256("bolt 12 default offer".toByteArray(Charsets.UTF_8) + trampolineNode.id.value.toByteArray() + nodePrivateKey.value.toByteArray()).byteVector32()
// We don't use our currently activated features, otherwise the offer would change when we add support for new features.
// If we add a new feature that we would like to use by default, we will need to explicitly create a new offer.
val offer = OfferTypes.Offer.createBlindedOffer(amount = null, description = null, this, trampolineNode, Features.empty, pathId)
return Pair(pathId, offer)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ data class Bolt12Invoice(val records: TlvStream<InvoiceTlv>) : PaymentRequest()
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 description: String? = invoiceRequest.offer.description
val createdAtSeconds: Long = records.get<InvoiceCreatedAt>()!!.timestampSeconds
val relativeExpirySeconds: Long = records.get<InvoiceRelativeExpiry>()?.seconds ?: DEFAULT_EXPIRY_SECONDS

Expand Down
61 changes: 33 additions & 28 deletions src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -714,15 +714,15 @@ object OfferTypes {
} else {
null // TODO: add exchange rates
}
val description: String = records.get<OfferDescription>()!!.description
val description: String? = records.get<OfferDescription>()?.description
val features: Features = records.get<OfferFeatures>()?.features ?: Features.empty
val expirySeconds: Long? = records.get<OfferAbsoluteExpiry>()?.absoluteExpirySeconds
private val paths: List<ContactInfo.BlindedPath>? = records.get<OfferPaths>()?.paths
val issuer: String? = records.get<OfferIssuer>()?.issuer
val quantityMax: Long? = records.get<OfferQuantityMax>()?.max?.let { if (it == 0L) Long.MAX_VALUE else it }
val nodeId: PublicKey = records.get<OfferNodeId>()!!.publicKey

val contactInfos: List<ContactInfo> = paths ?: listOf(ContactInfo.RecipientNodeId(nodeId))
val nodeId: PublicKey? = records.get<OfferNodeId>()?.publicKey
// A valid offer must contain a blinded path or a nodeId.
val contactInfos: List<ContactInfo> = paths ?: listOf(ContactInfo.RecipientNodeId(nodeId!!))

fun encode(): String {
val data = tlvSerializer.write(records)
Expand All @@ -737,65 +737,70 @@ object OfferTypes {
val hrp = "lno"

/**
* @param amount amount if it can be determined at offer creation time.
* @param description description of the offer.
* @param nodeId the nodeId to use for this offer, which should be different from our public nodeId if we're hiding behind a blinded route.
* @param features invoice features.
* @param chain chain on which the offer is valid.
* Create an offer without using a blinded path to hide our nodeId.
*
* @param amount amount if it can be determined at offer creation time.
* @param description description of the offer (may be null if [amount] is also null).
* @param nodeId the nodeId to use for this offer.
* @param features invoice features.
* @param chain chain on which the offer is valid.
*/
internal fun createInternal(
internal fun createNonBlindedOffer(
amount: MilliSatoshi?,
description: String,
description: String?,
nodeId: PublicKey,
features: Features,
chain: BlockHash,
additionalTlvs: Set<OfferTlv> = setOf(),
customTlvs: Set<GenericTlv> = setOf()
): Offer {
if (description == null) require(amount == null) { "an offer description must be provided if the amount isn't null" }
val tlvs: Set<OfferTlv> = setOfNotNull(
if (chain != Block.LivenetGenesisBlock.hash) OfferChains(listOf(chain)) else null,
amount?.let { OfferAmount(it) },
OfferDescription(description),
description?.let { OfferDescription(it) },
if (features != Features.empty) OfferFeatures(features) else null,
OfferNodeId(nodeId) // TODO: If the spec allows it, removes `OfferNodeId` when we already set `OfferPaths`.
) + additionalTlvs
return Offer(TlvStream(tlvs, customTlvs))
OfferNodeId(nodeId)
)
return Offer(TlvStream(tlvs + additionalTlvs, customTlvs))
}

/**
* Create an offer using a single-hop blinded path going through our trampoline node.
*
* @param amount amount if it can be determined at offer creation time.
* @param description description of the offer.
* @param description description of the offer (may be null if [amount] is also null).
* @param nodeParams our node parameters.
* @param trampolineNode our trampoline node.
* @param features features that should be advertised in the offer.
* @param pathId pathId on which we will listen for invoice requests.
*/
fun createBlindedOffer(
amount: MilliSatoshi?,
description: String,
description: String?,
nodeParams: NodeParams,
trampolineNode: NodeUri,
features: Features,
pathId: ByteVector32,
additionalTlvs: Set<OfferTlv> = setOf(),
customTlvs: Set<GenericTlv> = setOf()
): Offer {
if (description == null) require(amount == null) { "an offer description must be provided if the amount isn't null" }
val path = OnionMessages.buildRoute(PrivateKey(pathId), listOf(OnionMessages.IntermediateNode(trampolineNode.id, ShortChannelId.peerId(nodeParams.nodeId))), OnionMessages.Destination.Recipient(nodeParams.nodeId, pathId))
val offerNodeId = path.blindedNodeIds.last()
return createInternal(
amount,
description,
offerNodeId,
nodeParams.features.bolt12Features(),
nodeParams.chainHash,
additionalTlvs + OfferPaths(listOf(ContactInfo.BlindedPath(path))),
customTlvs
val tlvs: Set<OfferTlv> = setOfNotNull(
if (nodeParams.chainHash != Block.LivenetGenesisBlock.hash) OfferChains(listOf(nodeParams.chainHash)) else null,
amount?.let { OfferAmount(it) },
description?.let { OfferDescription(it) },
if (features.bolt12Features() != Features.empty) OfferFeatures(features.bolt12Features()) else null,
// Note that we don't include an offer_node_id since we're using a blinded path.
OfferPaths(listOf(ContactInfo.BlindedPath(path))),
)
return Offer(TlvStream(tlvs + additionalTlvs, customTlvs))
}

fun validate(records: TlvStream<OfferTlv>): Either<InvalidTlvPayload, Offer> {
if (records.get<OfferDescription>() == null) return Left(MissingRequiredTlv(10L))
if (records.get<OfferNodeId>() == null) return Left(MissingRequiredTlv(22L))
if (records.get<OfferDescription>() == null && records.get<OfferAmount>() != null) return Left(MissingRequiredTlv(10))
if (records.get<OfferNodeId>() == null && records.get<OfferPaths>() == null) return Left(MissingRequiredTlv(22))
if (records.unknown.any { it.tag >= 80 }) return Left(ForbiddenTlv(records.unknown.find { it.tag >= 80 }!!.tag))
return Right(Offer(records))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() {
val nodeKey = randomKey()
val payerKey = randomKey()
val chain = BlockHash(randomBytes32())
val offer = Offer.createInternal(10000.msat, "test offer", nodeKey.publicKey(), Features.empty, chain)
val offer = Offer.createNonBlindedOffer(10000.msat, "test offer", nodeKey.publicKey(), Features.empty, chain)
val request = InvoiceRequest(offer, 11000.msat, 1, Features.empty, payerKey, chain)
val invoice = Bolt12Invoice(
request,
Expand Down Expand Up @@ -121,7 +121,7 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() {
val nodeKey = randomKey()
val payerKey = randomKey()
val chain = BlockHash(randomBytes32())
val offer = Offer.createInternal(10000.msat, "test offer", nodeKey.publicKey(), Features.empty, chain)
val offer = Offer.createNonBlindedOffer(10000.msat, "test offer", nodeKey.publicKey(), Features.empty, chain)
val basicRequest = InvoiceRequest(offer, 11000.msat, 1, Features.empty, payerKey, chain)
val requestWithUnknownTlv = basicRequest.copy(records = TlvStream(basicRequest.records.records, setOf(GenericTlv(87, ByteVector.fromHex("0404")))))
val invoice = Bolt12Invoice(
Expand All @@ -143,7 +143,7 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() {
val nodeKey = randomKey()
val payerKey = randomKey()
val chain = BlockHash(randomBytes32())
val offer = Offer.createInternal(10000.msat, "test offer", nodeKey.publicKey(), Features.empty, chain)
val offer = Offer.createNonBlindedOffer(10000.msat, "test offer", nodeKey.publicKey(), Features.empty, chain)
val request = InvoiceRequest(offer, 11000.msat, 1, Features.empty, payerKey, chain)
val invoice = Bolt12Invoice(
request,
Expand Down Expand Up @@ -189,7 +189,7 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() {
val nodeKey = randomKey()
val payerKey = randomKey()
val chain = BlockHash(randomBytes32())
val offer = Offer.createInternal(15000.msat, "test offer", nodeKey.publicKey(), Features.empty, chain)
val offer = Offer.createNonBlindedOffer(15000.msat, "test offer", nodeKey.publicKey(), Features.empty, chain)
val request = InvoiceRequest(offer, 15000.msat, 1, Features.empty, payerKey, chain)
assertTrue(request.quantity_opt == null) // when paying for a single item, the quantity field must not be present
val invoice = Bolt12Invoice(
Expand Down Expand Up @@ -271,7 +271,7 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() {
val nodeKey = randomKey()
val payerKey = randomKey()
val chain = BlockHash(randomBytes32())
val offer = Offer.createInternal(5000.msat, "test offer", nodeKey.publicKey(), Features.empty, chain)
val offer = Offer.createNonBlindedOffer(5000.msat, "test offer", nodeKey.publicKey(), Features.empty, chain)
val request = InvoiceRequest(offer, 5000.msat, 1, Features.empty, payerKey, chain)
val invoice = Bolt12Invoice(
request,
Expand Down Expand Up @@ -307,7 +307,6 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() {
val nodeKey = randomKey()
val tlvs = setOf(
InvoiceRequestMetadata(ByteVector.fromHex("012345")),
OfferDescription("minimal invoice"),
OfferNodeId(nodeKey.publicKey()),
InvoiceRequestPayerId(randomKey().publicKey()),
InvoicePaths(listOf(createPaymentBlindedRoute(randomKey().publicKey()).route)),
Expand Down Expand Up @@ -408,7 +407,7 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() {
val payerKey = PrivateKey.fromHex("d817e8896c67d0bcabfdb93da7eb7fc698c829a181f994dd0ad866a8eda745e8")
assertEquals(payerKey.publicKey(), PublicKey.fromHex("031ef4439f638914de79220483dda32dfb7a431e799a5ce5a7643fbd70b2118e4e"))
val preimage = ByteVector32.fromValidHex("317d1fd8fec5f3ea23044983c2ba2a8043395b2a0790a815c9b12719aa5f1516")
val offer = Offer.createInternal(null, "minimal tip", nodeKey.publicKey(), Features.empty, Block.LivenetGenesisBlock.hash)
val offer = Offer.createNonBlindedOffer(null, "minimal tip", nodeKey.publicKey(), Features.empty, Block.LivenetGenesisBlock.hash)
val encodedOffer = "lno1pg9k66twd9kkzmpqw35hq93pqf8l2vtlq5w87m4vqfnvtn82adk9wadfgratnp2wg7l7ha4u0gzqw"
assertEquals(offer.toString(), encodedOffer)
assertEquals(Offer.decode(encodedOffer).get(), offer)
Expand Down Expand Up @@ -446,7 +445,7 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() {
val payerKey = PrivateKey.fromHex("0e00a9ef505292f90a0e8a7aa99d31750e885c42a3ef8866dd2bf97919aa3891")
assertEquals(payerKey.publicKey(), PublicKey.fromHex("033e94f2afd568d128f02ece844ad4a0a1ddf2a4e3a08beb2dba11b3f1134b0517"))
val preimage = ByteVector32.fromValidHex("09ad5e952ec39d45461ebdeceac206fb45574ae9054b5a454dd02c65f5ba1b7c")
val offer = Offer.createInternal(456000000.msat, "minimal offer", nodeKey.publicKey(), Features.empty, Block.LivenetGenesisBlock.hash)
val offer = Offer.createNonBlindedOffer(456000000.msat, "minimal offer", nodeKey.publicKey(), Features.empty, Block.LivenetGenesisBlock.hash)
val encodedOffer = "lno1pqzpktszqq9q6mtfde5k6ctvyphkven9wgtzzq7y3tyhuz0newawkdds924x6pet2aexssdrf5je2g2het9xpgw275"
assertEquals(offer.toString(), encodedOffer)
assertEquals(Offer.decode(encodedOffer).get(), offer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() {
@Test
fun `build a trampoline payment to blinded paths`() {
val features = Features(Feature.BasicMultiPartPayment to FeatureSupport.Optional)
val offer = OfferTypes.Offer.createInternal(finalAmount, "test offer", e, features, Block.LivenetGenesisBlock.hash)
val offer = OfferTypes.Offer.createNonBlindedOffer(finalAmount, "test offer", e, features, Block.LivenetGenesisBlock.hash)
// E uses a 1-hop blinded path from its LSP.
val (invoice, blindedRoute) = run {
val payerKey = randomKey()
Expand Down
Loading
Loading