Skip to content

Commit

Permalink
Announce liquidity ads
Browse files Browse the repository at this point in the history
Configure liquidity ads and include it in our `node_announcement`.
  • Loading branch information
t-bast committed Nov 13, 2023
1 parent dc5ffe4 commit f8c6095
Show file tree
Hide file tree
Showing 19 changed files with 140 additions and 112 deletions.
1 change: 1 addition & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ This feature leaks a bit of information about the balance when the channel is al
### API changes

- `bumpforceclose` can be used to make a force-close confirm faster, by spending the anchor output (#2743)
- `nodes` allows filtering nodes that offer liquidity ads (#2550)

### Miscellaneous improvements and bug fixes

Expand Down
8 changes: 8 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,14 @@ eclair {
enabled = true // enable automatic purges of expired invoices from the database
interval = 24 hours // interval between expired invoice purges
}

// Liquidity Ads allow remote nodes to pay us to provide them with inbound liquidity.
liquidity-ads {
enabled = false // set this field to true if you want to sell your unused on-chain liquidity
fee-base-satoshis = 1000 // flat fee that we will receive every time we accept a lease request
fee-basis-points = 500 // 5% of the liquidity we will provide
max-duration-blocks = 4032 // ~1 month
}
}

akka {
Expand Down
16 changes: 14 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
blockchainWatchdogThreshold: Int,
blockchainWatchdogSources: Seq[String],
onionMessageConfig: OnionMessageConfig,
purgeInvoicesInterval: Option[FiniteDuration]) {
purgeInvoicesInterval: Option[FiniteDuration],
liquidityAdsConfig_opt: Option[LiquidityAds.Config]) {
val privateKey: Crypto.PrivateKey = nodeKeyManager.nodeKey.privateKey

val nodeId: PublicKey = nodeKeyManager.nodeId
Expand All @@ -97,6 +98,8 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,

val pluginOpenChannelInterceptor: Option[InterceptOpenChannelPlugin] = pluginParams.collectFirst { case p: InterceptOpenChannelPlugin => p }

val liquidityRates_opt: Option[LiquidityAds.LeaseRates] = liquidityAdsConfig_opt.map(_.leaseRates(relayParams.defaultFees(announceChannel = true)))

def currentBlockHeight: BlockHeight = BlockHeight(blockHeight.get)

def currentFeerates: FeeratesPerKw = feerates.get()
Expand Down Expand Up @@ -604,7 +607,16 @@ object NodeParams extends Logging {
timeout = FiniteDuration(config.getDuration("onion-messages.reply-timeout").getSeconds, TimeUnit.SECONDS),
maxAttempts = config.getInt("onion-messages.max-attempts"),
),
purgeInvoicesInterval = purgeInvoicesInterval
purgeInvoicesInterval = purgeInvoicesInterval,
liquidityAdsConfig_opt = if (config.getBoolean("liquidity-ads.enabled")) {
Some(LiquidityAds.Config(
feeBase = Satoshi(config.getInt("liquidity-ads.fee-base-satoshis")),
feeProportional = config.getInt("liquidity-ads.fee-basis-points"),
maxLeaseDuration = config.getInt("liquidity-ads.max-duration-blocks"),
))
} else {
None
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ object Announcements {
)
}

def makeNodeAnnouncement(nodeSecret: PrivateKey, alias: String, color: Color, nodeAddresses: List[NodeAddress], features: Features[NodeFeature], timestamp: TimestampSecond = TimestampSecond.now()): NodeAnnouncement = {
def makeNodeAnnouncement(nodeSecret: PrivateKey, alias: String, color: Color, nodeAddresses: List[NodeAddress], features: Features[NodeFeature], liquidityRates_opt: Option[LiquidityAds.LeaseRates], timestamp: TimestampSecond = TimestampSecond.now()): NodeAnnouncement = {
require(alias.length <= 32)
// sort addresses by ascending address descriptor type; do not reorder addresses within the same descriptor type
val sortedAddresses = nodeAddresses.map {
Expand All @@ -78,7 +78,10 @@ object Announcements {
case address@(_: Tor3) => (4, address)
case address@(_: DnsHostname) => (5, address)
}.sortBy(_._1).map(_._2)
val witness = nodeAnnouncementWitnessEncode(timestamp, nodeSecret.publicKey, color, alias, features.unscoped(), sortedAddresses, TlvStream.empty)
val tlvs: Set[NodeAnnouncementTlv] = Set(
liquidityRates_opt.map(NodeAnnouncementTlv.LiquidityAdsTlv),
).flatten
val witness = nodeAnnouncementWitnessEncode(timestamp, nodeSecret.publicKey, color, alias, features.unscoped(), sortedAddresses, TlvStream(tlvs))
val sig = Crypto.sign(witness, nodeSecret)
NodeAnnouncement(
signature = sig,
Expand All @@ -87,7 +90,8 @@ object Announcements {
rgbColor = color,
alias = alias,
features = features.unscoped(),
addresses = sortedAddresses
addresses = sortedAddresses,
tlvStream = TlvStream(tlvs)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class Router(val nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Comm

// on restart we update our node announcement
// note that if we don't currently have public channels, this will be ignored
val nodeAnn = Announcements.makeNodeAnnouncement(nodeParams.privateKey, nodeParams.alias, nodeParams.color, nodeParams.publicAddresses, nodeParams.features.nodeAnnouncementFeatures())
val nodeAnn = Announcements.makeNodeAnnouncement(nodeParams.privateKey, nodeParams.alias, nodeParams.color, nodeParams.publicAddresses, nodeParams.features.nodeAnnouncementFeatures(), nodeParams.liquidityRates_opt)
self ! nodeAnn

log.info("initialization completed, ready to process messages")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ object Validation {
// in case this was our first local channel, we make a node announcement
if (!d.nodes.contains(nodeParams.nodeId) && isRelatedTo(ann, nodeParams.nodeId)) {
log.info("first local channel validated, announcing local node")
val nodeAnn = Announcements.makeNodeAnnouncement(nodeParams.privateKey, nodeParams.alias, nodeParams.color, nodeParams.publicAddresses, nodeParams.features.nodeAnnouncementFeatures())
val nodeAnn = Announcements.makeNodeAnnouncement(nodeParams.privateKey, nodeParams.alias, nodeParams.color, nodeParams.publicAddresses, nodeParams.features.nodeAnnouncementFeatures(), nodeParams.liquidityRates_opt)
handleNodeAnnouncement(d1, nodeParams.db.network, Set(LocalGossip), nodeAnn)
} else d1
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import fr.acinq.eclair.payment.relay.Relayer.RelayFees
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.wire.protocol.CommonCodecs.{blockHeight, millisatoshi32, publicKey, satoshi32}
import fr.acinq.eclair.wire.protocol.TlvCodecs.tmillisatoshi32
import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, MilliSatoshi, ToMilliSatoshiConversion}
import fr.acinq.eclair.{BlockHeight, MilliSatoshi, ToMilliSatoshiConversion}
import scodec.Codec
import scodec.bits.ByteVector
import scodec.codecs._
Expand All @@ -41,8 +41,13 @@ import java.nio.charset.StandardCharsets
*/
object LiquidityAds {

/** Liquidity leases are valid for a fixed duration, after which they must be renewed. */
val LeaseDuration = CltvExpiryDelta(1008) // 1 week
case class Config(feeBase: Satoshi, feeProportional: Int, maxLeaseDuration: Int) {
def leaseRates(relayFees: RelayFees): LeaseRates = {
// We make the remote node pay for one p2wpkh input and one p2wpkh output.
// If we need more inputs, we will pay the fees for those additional inputs ourselves.
LeaseRates(Transactions.claimP2WPKHOutputWeight, feeProportional, (relayFees.feeProportionalMillionths / 100).toInt, feeBase, relayFees.feeBase)
}
}

/**
* Liquidity is leased using the following rates:
Expand Down Expand Up @@ -70,12 +75,6 @@ object LiquidityAds {
}
}

object LeaseRates {
def apply(fundingWeight: Int, leaseFeeBase: Satoshi, leaseFeeProportional: Int, relayFees: RelayFees): LeaseRates = {
LeaseRates(fundingWeight, leaseFeeProportional, (relayFees.feeProportionalMillionths / 100).toInt, leaseFeeBase, relayFees.feeBase)
}
}

val leaseRatesCodec: Codec[LeaseRates] = (
("funding_weight" | uint16) ::
("lease_fee_basis" | uint16) ::
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import fr.acinq.eclair.payment.relay.Relayer.{AsyncPaymentsParams, RelayFees, Re
import fr.acinq.eclair.router.Graph.{MessagePath, WeightRatios}
import fr.acinq.eclair.router.PathFindingExperimentConf
import fr.acinq.eclair.router.Router.{MessageRouteParams, MultiPartParams, PathFindingConf, RouterConf, SearchBoundaries}
import fr.acinq.eclair.wire.protocol.{Color, EncodingType, NodeAddress, OnionRoutingPacket}
import fr.acinq.eclair.wire.protocol.{Color, EncodingType, LiquidityAds, NodeAddress, OnionRoutingPacket}
import org.scalatest.Tag
import scodec.bits.{ByteVector, HexStringSyntax}

Expand All @@ -51,6 +51,7 @@ object TestConstants {
val feeratePerKw: FeeratePerKw = FeeratePerKw(10_000 sat)
val anchorOutputsFeeratePerKw: FeeratePerKw = FeeratePerKw(2_500 sat)
val emptyOnionPacket: OnionRoutingPacket = OnionRoutingPacket(0, ByteVector.fill(33)(0), ByteVector.fill(1300)(0), ByteVector32.Zeroes)
val defaultLiquidityRates: LiquidityAds.LeaseRates = LiquidityAds.LeaseRates(500, 100, 10, 100 sat, 200 msat)

case object TestFeature extends Feature with InitFeature with NodeFeature {
val rfcName = "test_feature"
Expand Down Expand Up @@ -224,7 +225,8 @@ object TestConstants {
timeout = 200 millis,
maxAttempts = 2,
),
purgeInvoicesInterval = None
purgeInvoicesInterval = None,
liquidityAdsConfig_opt = None,
)

def channelParams: LocalParams = OpenChannelInterceptor.makeChannelParams(
Expand Down Expand Up @@ -389,7 +391,8 @@ object TestConstants {
timeout = 100 millis,
maxAttempts = 2,
),
purgeInvoicesInterval = None
purgeInvoicesInterval = None,
liquidityAdsConfig_opt = None,
)

def channelParams: LocalParams = OpenChannelInterceptor.makeChannelParams(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import fr.acinq.eclair.router.Announcements
import fr.acinq.eclair.router.Router.PublicChannel
import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.{channelAnnouncementCodec, channelUpdateCodec, nodeAnnouncementCodec}
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{CltvExpiryDelta, Features, MilliSatoshiLong, RealShortChannelId, ShortChannelId, TestDatabases, randomBytes32, randomKey}
import fr.acinq.eclair.{CltvExpiryDelta, Features, MilliSatoshiLong, RealShortChannelId, ShortChannelId, TestConstants, TestDatabases, randomBytes32, randomKey}
import org.scalatest.funsuite.AnyFunSuite
import scodec.bits.HexStringSyntax

Expand All @@ -56,11 +56,11 @@ class NetworkDbSpec extends AnyFunSuite {
forAllDbs { dbs =>
val db = dbs.network

val node_1 = Announcements.makeNodeAnnouncement(randomKey(), "node-alice", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty)
val node_2 = Announcements.makeNodeAnnouncement(randomKey(), "node-bob", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features(VariableLengthOnion -> Optional))
val node_3 = Announcements.makeNodeAnnouncement(randomKey(), "node-charlie", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features(VariableLengthOnion -> Optional))
val node_4 = Announcements.makeNodeAnnouncement(randomKey(), "node-eve", Color(100.toByte, 200.toByte, 300.toByte), Tor3("of7husrflx7sforh3fw6yqlpwstee3wg5imvvmkp4bz6rbjxtg5nljad", 42000) :: Nil, Features.empty)
val node_5 = Announcements.makeNodeAnnouncement(randomKey(), "node-frank", Color(100.toByte, 200.toByte, 300.toByte), DnsHostname("eclair.invalid", 42000) :: Nil, Features.empty)
val node_1 = Announcements.makeNodeAnnouncement(randomKey(), "node-alice", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty, None)
val node_2 = Announcements.makeNodeAnnouncement(randomKey(), "node-bob", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features(VariableLengthOnion -> Optional), Some(TestConstants.defaultLiquidityRates))
val node_3 = Announcements.makeNodeAnnouncement(randomKey(), "node-charlie", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features(VariableLengthOnion -> Optional), None)
val node_4 = Announcements.makeNodeAnnouncement(randomKey(), "node-eve", Color(100.toByte, 200.toByte, 300.toByte), Tor3("of7husrflx7sforh3fw6yqlpwstee3wg5imvvmkp4bz6rbjxtg5nljad", 42000) :: Nil, Features.empty, None)
val node_5 = Announcements.makeNodeAnnouncement(randomKey(), "node-frank", Color(100.toByte, 200.toByte, 300.toByte), DnsHostname("eclair.invalid", 42000) :: Nil, Features.empty, None)

assert(db.listNodes().toSet == Set.empty)
db.addNode(node_1)
Expand Down Expand Up @@ -401,7 +401,7 @@ object NetworkDbSpec {
update_2_data_opt: Option[Array[Byte]])

val nodeTestCases: Seq[NodeTestCase] = for (_ <- 0 until 10) yield {
val node = Announcements.makeNodeAnnouncement(randomKey(), "node-alice", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty)
val node = Announcements.makeNodeAnnouncement(randomKey(), "node-alice", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty, None)
val data = nodeAnnouncementCodec.encode(node).require.toByteArray
NodeTestCase(
nodeId = node.nodeId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,9 @@ class PgUtilsSpec extends TestKitBaseClass with AnyFunSuiteLike with Eventually
val db = Databases.postgres(baseConfig, UUID.randomUUID(), datadir, None, LockFailureHandler.logAndThrow)
db.channels.addOrUpdateChannel(ChannelCodecsSpec.normal)
db.channels.updateChannelMeta(ChannelCodecsSpec.normal.channelId, ChannelEvent.EventType.Created)
db.network.addNode(Announcements.makeNodeAnnouncement(randomKey(), "node-A", Color(50, 99, -80), Nil, Features.empty, TimestampSecond.now() - 45.days))
db.network.addNode(Announcements.makeNodeAnnouncement(randomKey(), "node-B", Color(50, 99, -80), Nil, Features.empty, TimestampSecond.now() - 3.days))
db.network.addNode(Announcements.makeNodeAnnouncement(randomKey(), "node-C", Color(50, 99, -80), Nil, Features.empty, TimestampSecond.now() - 7.minutes))
db.network.addNode(Announcements.makeNodeAnnouncement(randomKey(), "node-A", Color(50, 99, -80), Nil, Features.empty, None, TimestampSecond.now() - 45.days))
db.network.addNode(Announcements.makeNodeAnnouncement(randomKey(), "node-B", Color(50, 99, -80), Nil, Features.empty, None, TimestampSecond.now() - 3.days))
db.network.addNode(Announcements.makeNodeAnnouncement(randomKey(), "node-C", Color(50, 99, -80), Nil, Features.empty, None, TimestampSecond.now() - 7.minutes))
db.audit.add(ChannelPaymentRelayed(421 msat, 400 msat, randomBytes32(), randomBytes32(), randomBytes32(), TimestampMilli.now() - 5.seconds, TimestampMilli.now() - 3.seconds))
db.dataSource.close()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,14 @@

package fr.acinq.eclair.router

import fr.acinq.bitcoin.scalacompat.Block
import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey}
import fr.acinq.bitcoin.scalacompat.{Block, SatoshiLong}
import fr.acinq.eclair.TestConstants.Alice
import fr.acinq.eclair.RealShortChannelId
import fr.acinq.eclair._
import fr.acinq.eclair.{RealShortChannelId, _}
import fr.acinq.eclair.router.Announcements._
import fr.acinq.eclair.wire.protocol.ChannelUpdate.ChannelFlags
import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.nodeAnnouncementCodec
import fr.acinq.eclair.wire.protocol.NodeAddress
import fr.acinq.eclair.wire.protocol.{LiquidityAds, NodeAddress, TlvStream}
import org.scalatest.funsuite.AnyFunSuite
import scodec.bits._

Expand All @@ -51,7 +50,7 @@ class AnnouncementsSpec extends AnyFunSuite {
val bitcoin_b_sig = Announcements.signChannelAnnouncement(witness, bitcoin_b)
val ann = makeChannelAnnouncement(Block.RegtestGenesisBlock.hash, RealShortChannelId(42), node_a.publicKey, node_b.publicKey, bitcoin_a.publicKey, bitcoin_b.publicKey, node_a_sig, node_b_sig, bitcoin_a_sig, bitcoin_b_sig)
assert(checkSigs(ann))
assert(checkSigs(ann.copy(nodeId1 = randomKey().publicKey)) == false)
assert(!checkSigs(ann.copy(nodeId1 = randomKey().publicKey)))
}

test("create valid signed node announcement") {
Expand All @@ -65,7 +64,7 @@ class AnnouncementsSpec extends AnyFunSuite {
Features.BasicMultiPartPayment -> FeatureSupport.Optional,
Features.PaymentMetadata -> FeatureSupport.Optional,
)
val ann = makeNodeAnnouncement(Alice.nodeParams.privateKey, Alice.nodeParams.alias, Alice.nodeParams.color, Alice.nodeParams.publicAddresses, features.nodeAnnouncementFeatures())
val ann = makeNodeAnnouncement(Alice.nodeParams.privateKey, Alice.nodeParams.alias, Alice.nodeParams.color, Alice.nodeParams.publicAddresses, features.nodeAnnouncementFeatures(), Some(TestConstants.defaultLiquidityRates))
// Features should be filtered to only include node_announcement related features.
assert(ann.features == Features(
Features.DataLossProtect -> FeatureSupport.Optional,
Expand All @@ -76,7 +75,8 @@ class AnnouncementsSpec extends AnyFunSuite {
Features.BasicMultiPartPayment -> FeatureSupport.Optional,
))
assert(checkSig(ann))
assert(checkSig(ann.copy(timestamp = 153 unixsec)) == false)
assert(!checkSig(ann.copy(timestamp = 153 unixsec)))
assert(!checkSig(ann.copy(tlvStream = TlvStream.empty)))
}

test("sort node announcement addresses") {
Expand All @@ -86,7 +86,7 @@ class AnnouncementsSpec extends AnyFunSuite {
NodeAddress.fromParts("2620:1ec:c11:0:0:0:0:200", 9735).get,
NodeAddress.fromParts("140.82.121.4", 9735).get,
)
val ann = makeNodeAnnouncement(Alice.nodeParams.privateKey, Alice.nodeParams.alias, Alice.nodeParams.color, addresses, Alice.nodeParams.features.nodeAnnouncementFeatures())
val ann = makeNodeAnnouncement(Alice.nodeParams.privateKey, Alice.nodeParams.alias, Alice.nodeParams.color, addresses, Alice.nodeParams.features.nodeAnnouncementFeatures(), None)
assert(checkSig(ann))
assert(ann.addresses == List(
NodeAddress.fromParts("140.82.121.4", 9735).get,
Expand All @@ -111,7 +111,7 @@ class AnnouncementsSpec extends AnyFunSuite {
NodeAddress.fromParts("acinq.co", 9735).get,
NodeAddress.fromParts("acinq.fr", 9735).get, // ignore more than one DNS hostnames
)
val ann = makeNodeAnnouncement(Alice.nodeParams.privateKey, Alice.nodeParams.alias, Alice.nodeParams.color, addresses, Alice.nodeParams.features.nodeAnnouncementFeatures())
val ann = makeNodeAnnouncement(Alice.nodeParams.privateKey, Alice.nodeParams.alias, Alice.nodeParams.color, addresses, Alice.nodeParams.features.nodeAnnouncementFeatures(), None)
assert(checkSig(ann))
assert(ann.validAddresses === List(
NodeAddress.fromParts("140.82.121.5", 9735).get,
Expand All @@ -131,7 +131,7 @@ class AnnouncementsSpec extends AnyFunSuite {
test("create valid signed channel update announcement") {
val ann = makeChannelUpdate(Block.RegtestGenesisBlock.hash, Alice.nodeParams.privateKey, randomKey().publicKey, ShortChannelId(45561L), Alice.nodeParams.channelConf.expiryDelta, Alice.nodeParams.channelConf.htlcMinimum, Alice.nodeParams.relayParams.publicChannelFees.feeBase, Alice.nodeParams.relayParams.publicChannelFees.feeProportionalMillionths, 500000000 msat)
assert(checkSig(ann, Alice.nodeParams.nodeId))
assert(checkSig(ann, randomKey().publicKey) == false)
assert(!checkSig(ann, randomKey().publicKey))
}

test("check flags") {
Expand Down
Loading

0 comments on commit f8c6095

Please sign in to comment.