diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index 8d8fb0786..97127b263 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -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 @@ -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 @@ -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 { + // 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) + } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/Bolt12Invoice.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/Bolt12Invoice.kt index a3edbebf6..eb13d67b8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/Bolt12Invoice.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/Bolt12Invoice.kt @@ -37,7 +37,7 @@ data class Bolt12Invoice(val records: TlvStream) : PaymentRequest() override val amount: MilliSatoshi? = records.get()?.amount val nodeId: PublicKey = records.get()!!.nodeId override val paymentHash: ByteVector32 = records.get()!!.hash - val description: String = invoiceRequest.offer.description + val description: String? = invoiceRequest.offer.description val createdAtSeconds: Long = records.get()!!.timestampSeconds val relativeExpirySeconds: Long = records.get()?.seconds ?: DEFAULT_EXPIRY_SECONDS diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt index 3471be20a..9ba5cab3b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt @@ -714,15 +714,15 @@ object OfferTypes { } else { null // TODO: add exchange rates } - val description: String = records.get()!!.description + val description: String? = records.get()?.description val features: Features = records.get()?.features ?: Features.empty val expirySeconds: Long? = records.get()?.absoluteExpirySeconds private val paths: List? = records.get()?.paths val issuer: String? = records.get()?.issuer val quantityMax: Long? = records.get()?.max?.let { if (it == 0L) Long.MAX_VALUE else it } - val nodeId: PublicKey = records.get()!!.publicKey - - val contactInfos: List = paths ?: listOf(ContactInfo.RecipientNodeId(nodeId)) + val nodeId: PublicKey? = records.get()?.publicKey + // A valid offer must contain a blinded path or a nodeId. + val contactInfos: List = paths ?: listOf(ContactInfo.RecipientNodeId(nodeId!!)) fun encode(): String { val data = tlvSerializer.write(records) @@ -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 = setOf(), customTlvs: Set = setOf() ): Offer { + if (description == null) require(amount == null) { "an offer description must be provided if the amount isn't null" } val tlvs: Set = 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 = setOf(), customTlvs: Set = 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 = 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): Either { - if (records.get() == null) return Left(MissingRequiredTlv(10L)) - if (records.get() == null) return Left(MissingRequiredTlv(22L)) + if (records.get() == null && records.get() != null) return Left(MissingRequiredTlv(10)) + if (records.get() == null && records.get() == 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)) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt index f100679d0..7d97d4e1a 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt @@ -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, @@ -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( @@ -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, @@ -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( @@ -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, @@ -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)), @@ -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) @@ -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) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt index 3bbc56a49..3734560da 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt @@ -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() diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt index f268f7b69..e2a4516a1 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt @@ -46,7 +46,7 @@ class OfferTypesTestsCommon : LightningTestSuite() { @Test fun `invoice request is signed`() { val sellerKey = randomKey() - val offer = Offer.createInternal(100_000.msat, "test offer", sellerKey.publicKey(), Features.empty, Block.LivenetGenesisBlock.hash) + val offer = Offer.createNonBlindedOffer(100_000.msat, "test offer", sellerKey.publicKey(), Features.empty, Block.LivenetGenesisBlock.hash) val payerKey = randomKey() val request = InvoiceRequest(offer, 100_000.msat, 1, Features.empty, payerKey, Block.LivenetGenesisBlock.hash) assertTrue(request.checkSignature()) @@ -54,23 +54,15 @@ class OfferTypesTestsCommon : LightningTestSuite() { @Test fun `minimal offer`() { - val tlvs = setOf( - OfferDescription("basic offer"), - OfferNodeId(nodeId) - ) + val tlvs = setOf(OfferNodeId(nodeId)) val offer = Offer(TlvStream(tlvs)) - val encoded = "lno1pg9kyctnd93jqmmxvejhy93pqvxl9c6mjgkeaxa6a0vtxqteql688v0ywa8qqwx4j05cyskn8ncrj" + val encoded = "lno1zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe" assertEquals(offer, Offer.decode(encoded).get()) assertNull(offer.amount) - assertEquals("basic offer", offer.description) + assertNull(offer.description) assertEquals(nodeId, offer.nodeId) - // Removing any TLV from the minimal offer makes it invalid. - for (tlv in tlvs) { - val incomplete = TlvStream(tlvs.filterNot { it == tlv }.toSet()) - assertTrue(Offer.validate(incomplete).isLeft) - val incompleteEncoded = Bech32.encodeBytes(Offer.hrp, Offer.tlvSerializer.write(incomplete), Bech32.Encoding.Beck32WithoutChecksum) - assertTrue(Offer.decode(incompleteEncoded).isFailure) - } + // We can't create an empty offer. + assertTrue(Offer.validate(TlvStream.empty()).isLeft) } @Test @@ -104,7 +96,7 @@ class OfferTypesTestsCommon : LightningTestSuite() { @Test fun `check that invoice request matches offer`() { - val offer = Offer.createInternal(2500.msat, "basic offer", randomKey().publicKey(), Features.empty, Block.LivenetGenesisBlock.hash) + val offer = Offer.createNonBlindedOffer(2500.msat, "basic offer", randomKey().publicKey(), Features.empty, Block.LivenetGenesisBlock.hash) val payerKey = randomKey() val request = InvoiceRequest(offer, 2500.msat, 1, Features.empty, payerKey, Block.LivenetGenesisBlock.hash) assertTrue(request.isValid()) @@ -130,7 +122,7 @@ class OfferTypesTestsCommon : LightningTestSuite() { @Test fun `check that invoice request matches offer - with features`() { - val offer = Offer.createInternal(2500.msat, "offer with features", randomKey().publicKey(), Features.empty, Block.LivenetGenesisBlock.hash) + val offer = Offer.createNonBlindedOffer(2500.msat, "offer with features", randomKey().publicKey(), Features.empty, Block.LivenetGenesisBlock.hash) val payerKey = randomKey() val request = InvoiceRequest(offer, 2500.msat, 1, Features(Feature.BasicMultiPartPayment to FeatureSupport.Optional), payerKey, Block.LivenetGenesisBlock.hash) assertTrue(request.isValid()) @@ -145,7 +137,7 @@ class OfferTypesTestsCommon : LightningTestSuite() { @Test fun `check that invoice request matches offer - without amount`() { - val offer = Offer.createInternal(null, "offer without amount", randomKey().publicKey(), Features.empty, Block.LivenetGenesisBlock.hash) + val offer = Offer.createNonBlindedOffer(null, "offer without amount", randomKey().publicKey(), Features.empty, Block.LivenetGenesisBlock.hash) val payerKey = randomKey() val request = InvoiceRequest(offer, 500.msat, 1, Features.empty, payerKey, Block.LivenetGenesisBlock.hash) assertTrue(request.isValid()) @@ -221,19 +213,18 @@ class OfferTypesTestsCommon : LightningTestSuite() { @Test fun `minimal invoice request`() { val payerKey = PrivateKey.fromHex("527d410ec920b626ece685e8af9abc976a48dbf2fe698c1b35d90a1c5fa2fbca") - val tlvsWithoutSignature = setOf( + val tlvsWithoutSignature = setOf( InvoiceRequestMetadata(ByteVector.fromHex("abcdef")), - OfferDescription("basic offer"), OfferNodeId(nodeId), InvoiceRequestPayerId(payerKey.publicKey()), ) - val signature = signSchnorr(InvoiceRequest.signatureTag, rootHash(TlvStream(tlvsWithoutSignature)), payerKey) + val signature = signSchnorr(InvoiceRequest.signatureTag, rootHash(TlvStream(tlvsWithoutSignature)), payerKey) val tlvs = tlvsWithoutSignature + Signature(signature) val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) - val encoded = "lnr1qqp6hn00pg9kyctnd93jqmmxvejhy93pqvxl9c6mjgkeaxa6a0vtxqteql688v0ywa8qqwx4j05cyskn8ncrjkppqfxajawru7sa7rt300hfzs2lyk2jrxduxrkx9lmzy6lxcvfhk0j7ruzqc4mtjj5fwukrqp7faqrxn664nmwykad76pu997terewcklsx47apag59wf8exly4tky7y63prr7450n28stqssmzuf48w7e6rjad2eq" + val encoded = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupetqssynwewhp70gwlp4chhm53g90jt9fpnx7rpmrzla3zd0nvxymm8e0p7pq06rwacy8756zgl3hdnsyfepq573astyz94rgn9uhxlyqj4gdyk6q8q0yrv6al909v3435amuvjqvkuq6k8fyld78r8srdyx7wnmwsdu" assertEquals(invoiceRequest, InvoiceRequest.decode(encoded).get()) assertNull(invoiceRequest.offer.amount) - assertEquals("basic offer", invoiceRequest.offer.description) + assertNull(invoiceRequest.offer.description) assertEquals(nodeId, invoiceRequest.offer.nodeId) assertEquals(ByteVector.fromHex("abcdef"), invoiceRequest.metadata) assertEquals(payerKey.publicKey(), invoiceRequest.payerId) @@ -515,9 +506,21 @@ class OfferTypesTestsCommon : LightningTestSuite() { @Test fun `generate deterministic blinded offer through trampoline node`() { - val pathId = ByteVector32.fromValidHex("8fe8758518872aa45287e18e613326bccc6d72e5bc4049b0353137bc6d83320a") - val offer = Offer.createBlindedOffer(amount = null, "default offer", TestConstants.Alice.nodeParams, TestConstants.Alice.walletParams.trampolineNode, pathId) - val expectedOffer = Offer.decode("lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrc2p4jx2enpw4k8ggr0venx2usvqvpqqqqs65pk9vv6swfs8zd5g697gqcga7elx54jx9p2uf0x4wsyvk5zyru4kpszvhkjfgd788sjgf5y6dqyvdq9s7lu68v97ad96cvsmzg99sgmcu0qyq6q20hxu4sp9gddmd0x7waap9wux94cm0246dxrjjw60qcparljtsqp5elqhdxerpqcfcup9ntxvrnpl50n226m7sm2n9jpvmqrfcnce7mdygk7wnhyl6y84nfypplcm3v25smd40lcjyemhvnvp2eqqv3ceeyp46we7d6vlfxfqggczrg55qj89nhaqzt8ymhddf2gmpcjz99dkszxp0kkupcf0dpnwpwsm52klvckyyp5ufuvldkjyt08fmj0azr6e5jqsludck92gdk6hlufzvamkfkq4vs").get() + val trampolineNode = NodeUri(PublicKey.fromHex("03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"), "3.33.236.230", 9735) + val nodeParams = TestConstants.Alice.nodeParams.copy(chain = Chain.Mainnet) + val (pathId, offer) = nodeParams.defaultOffer(trampolineNode) + assertNull(offer.amount) + assertNull(offer.description) + assertEquals(Features.empty, offer.features) // the offer shouldn't have any feature to guarantee stability + assertNull(offer.expirySeconds) + assertNull(offer.nodeId) // the offer should not leak our node_id + assertEquals(1, offer.contactInfos.size) + val path = offer.contactInfos.first() + assertIs(path) + assertEquals(EncodedNodeId(trampolineNode.id), path.route.introductionNodeId) + val expectedPathId = ByteVector32.fromValidHex("69e2c45e00f6e76c50f612b87294191cc634abfbf25eb2eb51f241bec3209897") + assertEquals(expectedPathId, pathId) + val expectedOffer = Offer.decode("lno1zr2s8pjw7qjlm68mtp7e3yvxee4y5xrgjhhyf2fxhlphpckrvevh50u0qf70a6j2x2akrhazctejaaqr8y4qtzjtjzmfesay6mzr3s789uryuqsr8dpgfgxuk56vh7cl89769zdpdrkqwtypzhu2t8ehp73dqeeq65lsqxhq3x0946e6y8hgjpqh5ej0dxpnftcmerz4320cx3luqwlpkg8vdcqsfvz89axkmv5sgdysmwn95tpsct6mdercmz8jh2r82qpjkzq2t69g44vdaj2hxed33qeatadtw8vzj0ze2ezd98u5q4dp9993dkzy8jpr883ayzf95kzv0dvm2fe4").get() assertEquals(expectedOffer, offer) } } \ No newline at end of file