Skip to content
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
1 change: 1 addition & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ It can be enabled by setting `eclair.features.option_attributable_failure = opti
### API changes

- `listoffers` now returns more details about each offer.
- `parseoffer` is added to display offer fields in a human-readable format.


### Configuration changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ import fr.acinq.eclair.payment._
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.transactions.DirectedHtlc
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, EncodedNodeId, Feature, FeatureSupport, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampMilli, TimestampSecond, UInt64, UnknownFeature}
import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, EncodedNodeId, Feature, FeatureSupport, Features, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampMilli, TimestampSecond, UInt64, UnknownFeature}
import org.json4s
import org.json4s.JsonAST._
import org.json4s.jackson.Serialization
Expand Down Expand Up @@ -476,6 +477,52 @@ object InvoiceSerializer extends MinimalSerializer({
JObject(fieldList)
})

private case class BlindedRouteJson(firstNodeId: EncodedNodeId, length: Int)
private case class OfferJson(chains: Option[Seq[String]],
amount: Option[String],
currency: Option[String],
description: Option[String],
expiry: Option[TimestampSecond],
issuer: Option[String],
nodeId: Option[PublicKey],
paths: Option[Seq[BlindedRouteJson]],
quantityMax: Option[Long],
features: Option[Features[Feature]],
metadata: Option[String],
unknownTlvs: Option[Map[String, String]])
object OfferSerializer extends ConvertClassSerializer[Offer](o => {
val fractionDigits = o.records.get[OfferTypes.OfferCurrency].map(_.currency.getDefaultFractionDigits()).getOrElse(3)
OfferJson(
chains = o.records.get[OfferTypes.OfferChains].map(_.chains.map(_.toString())),
amount = o.records.get[OfferTypes.OfferAmount].map(a =>
if (fractionDigits == 0) {
a.amount.toString
} else {
val one = scala.math.pow(10, fractionDigits).toInt
s"${a.amount / one}.%0${fractionDigits}d".format(a.amount % one)
}
),
currency = if (o.records.get[OfferTypes.OfferAmount].isEmpty) {
None
} else {
Some(o.records.get[OfferTypes.OfferCurrency].map(_.currency.getCurrencyCode()).getOrElse("satoshi"))
},
description = o.records.get[OfferTypes.OfferDescription].map(_.description),
expiry = o.records.get[OfferTypes.OfferAbsoluteExpiry].map(_.absoluteExpiry),
issuer = o.records.get[OfferTypes.OfferIssuer].map(_.issuer),
nodeId = o.records.get[OfferTypes.OfferNodeId].map(_.publicKey),
paths = o.records.get[OfferTypes.OfferPaths].map(_.paths.map(p => BlindedRouteJson(p.firstNodeId, p.blindedHops.length))),
quantityMax = o.records.get[OfferTypes.OfferQuantityMax].map(_.max),
features = o.records.get[OfferTypes.OfferFeatures].map(_.features),
metadata = o.records.get[OfferTypes.OfferMetadata].map(_.data.toHex),
unknownTlvs = if (o.records.unknown.isEmpty) {
None
} else {
Some(o.records.unknown.map(tlv => tlv.tag.toString -> tlv.value.toHex).toMap)
}
)
})

private case class OfferDataJson(amountMsat: Option[MilliSatoshi],
description: Option[String],
issuer: Option[String],
Expand Down Expand Up @@ -733,6 +780,7 @@ object JsonSerializers {
NodeAddressSerializer +
DirectedHtlcSerializer +
InvoiceSerializer +
OfferSerializer +
OfferDataSerializer +
JavaUUIDSerializer +
OriginSerializer +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ private class OfferCreator(context: ActorContext[OfferCreator.Command],
} else {
val tlvs: Set[OfferTlv] = Set(
if (nodeParams.chainHash != Block.LivenetGenesisBlock.hash) Some(OfferChains(Seq(nodeParams.chainHash))) else None,
amount_opt.map(OfferAmount),
amount_opt.map(_.toLong).map(OfferAmount),
description_opt.map(OfferDescription),
expiry_opt.map(OfferAbsoluteExpiry),
issuer_opt.map(OfferIssuer),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,25 @@ import fr.acinq.eclair.wire.protocol.CommonCodecs._
import fr.acinq.eclair.wire.protocol.OfferTypes._
import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tmillisatoshi, tu32, tu64overflow}
import fr.acinq.eclair.{EncodedNodeId, TimestampSecond, UInt64}
import scodec.Codec
import scodec.{Attempt, Codec}
import scodec.codecs._

import java.util.Currency
import scala.util.Try

object OfferCodecs {
private val offerChains: Codec[OfferChains] = tlvField(list(blockHash).xmap[Seq[BlockHash]](_.toSeq, _.toList))

private val offerMetadata: Codec[OfferMetadata] = tlvField(bytes)

private val offerCurrency: Codec[OfferCurrency] = tlvField(utf8)
val offerCurrency: Codec[OfferCurrency] =
tlvField(utf8.narrow[Currency](s => Attempt.fromTry(Try{
val c = Currency.getInstance(s)
require(c.getDefaultFractionDigits() >= 0) // getDefaultFractionDigits may return -1 for things that are not currencies
c
}), _.getCurrencyCode()))

private val offerAmount: Codec[OfferAmount] = tlvField(tmillisatoshi)
private val offerAmount: Codec[OfferAmount] = tlvField(tu64overflow)

private val offerDescription: Codec[OfferDescription] = tlvField(utf8)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ import fr.acinq.eclair.crypto.Sphinx.RouteBlinding.BlindedRoute
import fr.acinq.eclair.wire.protocol.CommonCodecs.varint
import fr.acinq.eclair.wire.protocol.OnionRoutingCodecs.{ForbiddenTlv, InvalidTlvPayload, MissingRequiredTlv}
import fr.acinq.eclair.wire.protocol.TlvCodecs.genericTlv
import fr.acinq.eclair.{Bolt12Feature, CltvExpiryDelta, Feature, Features, MilliSatoshi, TimestampSecond, UInt64, nodeFee, randomBytes32}
import fr.acinq.eclair.{Bolt12Feature, CltvExpiryDelta, Feature, Features, MilliSatoshi, MilliSatoshiLong, TimestampSecond, UInt64, nodeFee, randomBytes32}
import scodec.Codec
import scodec.bits.ByteVector
import scodec.codecs.vector

import java.util.Currency
import scala.util.{Failure, Try}

/**
Expand Down Expand Up @@ -71,12 +72,12 @@ object OfferTypes {
/**
* Three-letter code of the currency the offer is denominated in. If empty, bitcoin is implied.
*/
case class OfferCurrency(iso4217: String) extends OfferTlv
case class OfferCurrency(currency: Currency) extends OfferTlv

/**
* Amount to pay per item. As we only support bitcoin, the amount is in msat.
* Amount to pay per item.
*/
case class OfferAmount(amount: MilliSatoshi) extends OfferTlv
case class OfferAmount(amount: Long) extends OfferTlv

/**
* Description of the purpose of the payment.
Expand Down Expand Up @@ -238,7 +239,7 @@ object OfferTypes {
case class Offer(records: TlvStream[OfferTlv]) {
val chains: Seq[BlockHash] = records.get[OfferChains].map(_.chains).getOrElse(Seq(Block.LivenetGenesisBlock.hash))
val metadata: Option[ByteVector] = records.get[OfferMetadata].map(_.data)
val amount: Option[MilliSatoshi] = records.get[OfferAmount].map(_.amount)
val amount: Option[MilliSatoshi] = if (records.get[OfferCurrency].isEmpty) records.get[OfferAmount].map(_.amount.msat) else None
val description: Option[String] = records.get[OfferDescription].map(_.description)
val features: Features[Bolt12Feature] = records.get[OfferFeatures].map(_.features.bolt12Features()).getOrElse(Features.empty)
val expiry: Option[TimestampSecond] = records.get[OfferAbsoluteExpiry].map(_.absoluteExpiry)
Expand Down Expand Up @@ -279,7 +280,7 @@ object OfferTypes {
require(amount_opt.isEmpty || description_opt.nonEmpty)
val tlvs: Set[OfferTlv] = Set(
if (chain != Block.LivenetGenesisBlock.hash) Some(OfferChains(Seq(chain))) else None,
amount_opt.map(OfferAmount),
amount_opt.map(_.toLong).map(OfferAmount),
description_opt.map(OfferDescription),
if (!features.isEmpty) Some(OfferFeatures(features.unscoped())) else None,
Some(OfferNodeId(nodeId)),
Expand All @@ -297,7 +298,7 @@ object OfferTypes {
require(amount_opt.isEmpty || description_opt.nonEmpty)
val tlvs: Set[OfferTlv] = Set(
if (chain != Block.LivenetGenesisBlock.hash) Some(OfferChains(Seq(chain))) else None,
amount_opt.map(OfferAmount),
amount_opt.map(_.toLong).map(OfferAmount),
description_opt.map(OfferDescription),
if (!features.isEmpty) Some(OfferFeatures(features.unscoped())) else None,
Some(OfferPaths(paths))
Expand All @@ -308,8 +309,6 @@ object OfferTypes {
def validate(records: TlvStream[OfferTlv]): Either[InvalidTlvPayload, Offer] = {
if (records.get[OfferDescription].isEmpty && records.get[OfferAmount].nonEmpty) return Left(MissingRequiredTlv(UInt64(10)))
if (records.get[OfferNodeId].isEmpty && records.get[OfferPaths].forall(_.paths.isEmpty)) return Left(MissingRequiredTlv(UInt64(22)))
// Currency conversion isn't supported yet.
if (records.get[OfferCurrency].nonEmpty) return Left(ForbiddenTlv(UInt64(6)))
if (records.unknown.exists(!isOfferTlv(_))) return Left(ForbiddenTlv(records.unknown.find(!isOfferTlv(_)).get.tag))
Right(Offer(records))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,22 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature
import fr.acinq.eclair.channel.Helpers.Funding
import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.ShaChain
import fr.acinq.eclair.crypto.{ShaChain, Sphinx}
import fr.acinq.eclair.db.OfferData
import fr.acinq.eclair.io.Peer
import fr.acinq.eclair.io.Peer.PeerInfo
import fr.acinq.eclair.payment.{Invoice, PaymentSettlingOnChain}
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.transactions.{CommitmentSpec, IncomingHtlc, OutgoingHtlc}
import fr.acinq.eclair.wire.internal.channel.ChannelCodecs
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer
import fr.acinq.eclair.wire.protocol.OfferTypes.{Offer, OfferTlv}
import fr.acinq.eclair.wire.protocol._
import org.scalatest.funsuite.AnyFunSuiteLike
import org.scalatest.matchers.should.Matchers
import scodec.bits._

import java.net.InetAddress
import java.util.UUID
import java.util.{Currency, UUID}

class JsonSerializersSpec extends TestKitBaseClass with AnyFunSuiteLike with Matchers {

Expand Down Expand Up @@ -334,6 +334,30 @@ class JsonSerializersSpec extends TestKitBaseClass with AnyFunSuiteLike with Mat
JsonSerializers.serialization.write(pr)(JsonSerializers.formats) shouldBe """{"amount":456001234,"nodeId":"03c48ac97e09f3cbbaeb35b02aaa6d072b57726841a34d25952157caca60a1caf5","paymentHash":"2cb0e7b052366787450c33daf6d2f2c3cb6132221326e1c1b49ac97fdd7eb720","description":"minimal offer","features":{"activated":{},"unknown":[]},"blindedPaths":[{"introductionNodeId":"03c48ac97e09f3cbbaeb35b02aaa6d072b57726841a34d25952157caca60a1caf5","blindedNodeIds":["031fca650042031dcb777156ef66806c73b01a7f52c4e73c89a0d15823a1ac6237"]}],"createdAt":1665412681,"expiresAt":1665412981,"serialized":"lni1qqsf4h8fsnpjkj057gjg9c3eqhv889440xh0z6f5kng9vsaad8pgq7sgqsdjuqsqpgxk66twd9kkzmpqdanxvetjzcss83y2e9lqnu7tht4ntvp24fksw26hwf5yrg6dyk2jz472efs2rjh42qsxlc5vp2m0rvmjcxn2y34wv0m5lyc7sdj7zksgn35dvxgqqqqqqqzjqsdjupkjtqssx05572ha26x39rczan5yft22pgwa72jw8gytavkm5ydn7yf5kpgh5zsq83y2e9lqnu7tht4ntvp24fksw26hwf5yrg6dyk2jz472efs2rjh4q2rd3ny0elv9m7mh38xxwe6ypfheeqeqlwgft05r6dhc50gtw0nv2qgrrl9x2qzzqvwukam32mhkdqrvwwcp5l6jcnnnezdq69vz8gdvvgmsqwk3efqf3f6gmf0ul63940awz429rdhhsts86s0r30e5nffwhrqw90xgxf7f60sm7tcclvyqwz7cer5q9223madstdy2p5q6y8qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqf2qheqqqqq2gprrgshynfszqyk2sgpvkrnmq53kv7r52rpnmtmd9ukredsnygsnymsurdy6e9la6l4hyz4qgxewqmftqggrcj9vjlsf709m46e4kq425mg89dthy6zp5dxjt9fp2l9v5c9pet6lqsx4k5r7rsld3hhe87psyy5cnhhzt4dz838f75734mted7pdsrflpvys23tkafmhctf3musnsaa42h6qjdggyqlhtevutzzpzlnwd8alq"}"""
}

test("Bolt 12 offer") {
val minimalOffer = Offer(TlvStream[OfferTlv](OfferTypes.OfferNodeId(PublicKey(hex"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"))))
JsonSerializers.serialization.write(minimalOffer)(JsonSerializers.formats) shouldBe """{"nodeId":"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"}"""

val ref = "lno1pqzqzltcgq9q6urvv4shxefqv3hkuct5v58qxrp4qqfquctvd93k2srpvd5kuufwvdh3vggzg2hd49ueds8phzcahvh4p2m3pnen649dza2h3k6gxpaequr8fhtq"
val offer = Offer.decode(ref).get
JsonSerializers.serialization.write(offer)(JsonSerializers.formats) shouldBe """{"amount":"25000.000","currency":"satoshi","description":"please donate","expiry":{"iso":"1970-01-10T06:13:20Z","unix":800000},"issuer":"alice@acinq.co","nodeId":"0242aeda97996c0e1b8b1dbb2f50ab710cf33d54ad175578db48307b9070674dd6"}"""

val bigOffer = Offer(TlvStream(Set[OfferTlv](
OfferTypes.OfferChains(Seq(Block.Testnet4GenesisBlock.hash)),
OfferTypes.OfferMetadata(hex"d5f4a6"),
OfferTypes.OfferCurrency(Currency.getInstance("EUR")),
OfferTypes.OfferAmount(86205),
OfferTypes.OfferDescription("offer with a lot of fields in it"),
OfferTypes.OfferFeatures(Features(Features.ProvideStorage -> FeatureSupport.Mandatory)),
OfferTypes.OfferAbsoluteExpiry(TimestampSecond(3600)),
OfferTypes.OfferPaths(Seq(Sphinx.RouteBlinding.BlindedRoute(EncodedNodeId.WithPublicKey.Plain(PublicKey(hex"022812e3a3760ac989b8749ee9fc70fd12e4d7f3cad5e3e2bf572e9e4eaaa7b7d9")), PublicKey(hex"028a2b20b2debdfd97de08f6e2374f2946116492f358b78acf9eac05f6fdac632d"), Seq(Sphinx.RouteBlinding.BlindedHop(PublicKey(hex"031b27d9e97dbb0ef87c48bb0231c96c6bca1ee54b0e0cfe869ad2388ce247719f"), hex"def5"))))),
OfferTypes.OfferIssuer("bob@bobcorp.com"),
OfferTypes.OfferQuantityMax(5),
OfferTypes.OfferNodeId(PublicKey(hex"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f")),
), Set(GenericTlv(UInt64(71), hex"bd4e85ce"))))
JsonSerializers.serialization.write(bigOffer)(JsonSerializers.formats) shouldBe """{"chains":["43f08bdab050e35b567c864b91f47f50ae725ae2de53bcfbbaf284da00000000"],"amount":"862.05","currency":"EUR","description":"offer with a lot of fields in it","expiry":{"iso":"1970-01-01T01:00:00Z","unix":3600},"issuer":"bob@bobcorp.com","nodeId":"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f","paths":[{"firstNodeId":{"publicKey":"022812e3a3760ac989b8749ee9fc70fd12e4d7f3cad5e3e2bf572e9e4eaaa7b7d9"},"length":1}],"quantityMax":5,"features":{"activated":{"option_provide_storage":"mandatory"},"unknown":[]},"metadata":"d5f4a6","unknownTlvs":{"71":"bd4e85ce"}}"""
}

test("Bolt 12 offer data") {
val ref = "lno1pqzqzltcgq9q6urvv4shxefqv3hkuct5v58qxrp4qqfquctvd93k2srpvd5kuufwvdh3vggzg2hd49ueds8phzcahvh4p2m3pnen649dza2h3k6gxpaequr8fhtq"
val offer = OfferData(Offer.decode(ref).get, None, createdAt = TimestampMilli(100), disabledAt_opt = None)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite {
// changing fields makes the signature invalid
val withModifiedUnknownTlv = Bolt12Invoice(invoice.records.copy(unknown = Set(GenericTlv(UInt64(7), hex"ade4"))))
assert(!withModifiedUnknownTlv.checkSignature())
val withModifiedAmount = Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(amount) => OfferAmount(amount + 100.msat) case x => x }, invoice.records.unknown))
val withModifiedAmount = Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(amount) => OfferAmount(amount + 100) case x => x }, invoice.records.unknown))
assert(!withModifiedAmount.checkSignature())
}

Expand All @@ -92,7 +92,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite {
val invoice = Bolt12Invoice(request, randomBytes32(), nodeKey, 300 seconds, Features.empty, Seq(createPaymentBlindedRoute(nodeKey.publicKey)))
assert(invoice.validateFor(request, nodeKey.publicKey).isRight)
// amount must match the request
val withOtherAmount = signInvoice(Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(_) => OfferAmount(9000 msat) case x => x })), nodeKey)
val withOtherAmount = signInvoice(Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(_) => OfferAmount(9000) case x => x })), nodeKey)
assert(withOtherAmount.validateFor(request, nodeKey.publicKey).isLeft)
// description must match the offer
val withOtherDescription = signInvoice(Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferDescription(_) => OfferDescription("other description") case x => x })), nodeKey)
Expand Down Expand Up @@ -229,7 +229,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite {
val tlvs = TlvStream[InvoiceTlv](Set[InvoiceTlv](
InvoiceRequestMetadata(payerInfo),
OfferChains(Seq(chain)),
OfferAmount(amount),
OfferAmount(amount.toLong),
OfferDescription(description),
OfferFeatures(Features.empty),
OfferIssuer(issuer),
Expand Down Expand Up @@ -339,7 +339,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite {
val preimage = ByteVector32(hex"99221825b86576e94391b179902be8b22c7cfa7c3d14aec6ae86657dfd9bd2a8")
val offer = Offer(TlvStream[OfferTlv](
OfferChains(Seq(Block.Testnet3GenesisBlock.hash)),
OfferAmount(100000 msat),
OfferAmount(100000),
OfferDescription("offer with quantity"),
OfferIssuer("alice@bigshop.com"),
OfferQuantityMax(1000),
Expand Down
Loading