Skip to content

Commit

Permalink
Add liquidity ads proof
Browse files Browse the repository at this point in the history
The seller signs a commitment to the lease parameters.
It provides the buyer with a way to prove if the seller later cheats.
  • Loading branch information
t-bast committed Nov 29, 2023
1 parent 33e0d64 commit c1f0b1b
Show file tree
Hide file tree
Showing 3 changed files with 99 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ data class MissingChannelType (override val channelId: Byte
data class DustLimitTooSmall (override val channelId: ByteVector32, val dustLimit: Satoshi, val min: Satoshi) : ChannelException(channelId, "dustLimit=$dustLimit is too small (min=$min)")
data class DustLimitTooLarge (override val channelId: ByteVector32, val dustLimit: Satoshi, val max: Satoshi) : ChannelException(channelId, "dustLimit=$dustLimit is too large (max=$max)")
data class ToSelfDelayTooHigh (override val channelId: ByteVector32, val toSelfDelay: CltvExpiryDelta, val max: CltvExpiryDelta) : ChannelException(channelId, "unreasonable to_self_delay=$toSelfDelay (max=$max)")
data class MissingLiquidityAds (override val channelId: ByteVector32) : ChannelException(channelId, "liquidity ads field is missing")
data class InvalidLiquidityAdsSig (override val channelId: ByteVector32) : ChannelException(channelId, "liquidity ads signature is invalid")
data class LiquidityRatesRejected (override val channelId: ByteVector32) : ChannelException(channelId, "rejecting liquidity ads proposed rates")
data class ChannelFundingError (override val channelId: ByteVector32) : ChannelException(channelId, "channel funding error")
data class RbfAttemptAborted (override val channelId: ByteVector32) : ChannelException(channelId, "rbf attempt aborted")
data class SpliceAborted (override val channelId: ByteVector32) : ChannelException(channelId, "splice aborted")
Expand Down
64 changes: 63 additions & 1 deletion src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package fr.acinq.lightning.wire

import fr.acinq.bitcoin.Satoshi
import fr.acinq.bitcoin.*
import fr.acinq.bitcoin.io.ByteArrayOutput
import fr.acinq.bitcoin.io.Input
import fr.acinq.bitcoin.io.Output
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelException
import fr.acinq.lightning.channel.InvalidLiquidityAdsSig
import fr.acinq.lightning.channel.LiquidityRatesRejected
import fr.acinq.lightning.channel.MissingLiquidityAds
import fr.acinq.lightning.transactions.Transactions
import fr.acinq.lightning.utils.Either
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat

Expand Down Expand Up @@ -60,4 +66,60 @@ object LiquidityAds {
}
}

/** Request inbound liquidity from a remote peer that supports liquidity ads. */
data class RequestRemoteFunding(val fundingAmount: Satoshi, val maxFee: Satoshi, val leaseStart: Int, val leaseDuration: Int) {
private val leaseExpiry: Int = leaseStart + leaseDuration
val requestFunds: ChannelTlv.RequestFunds = ChannelTlv.RequestFunds(fundingAmount, leaseExpiry, leaseDuration)

fun validateLeaseRates(remoteNodeId: PublicKey, channelId: ByteVector32, remoteFundingPubKey: PublicKey, remoteFundingAmount: Satoshi, fundingFeerate: FeeratePerKw, willFund: ChannelTlv.WillFund?): Either<ChannelException, Lease> {
return when (willFund) {
// If the remote peer doesn't want to provide inbound liquidity, we immediately fail the attempt.
// The user should retry this funding attempt without requesting inbound liquidity.
null -> Either.Left(MissingLiquidityAds(channelId))
else -> {
val witness = LeaseWitness(remoteFundingPubKey, leaseExpiry, leaseDuration, willFund.leaseRates.maxRelayFeeProportional, willFund.leaseRates.maxRelayFeeBase)
val fees = willFund.leaseRates.fees(fundingFeerate, fundingAmount, remoteFundingAmount)
return if (!LeaseWitness.verify(remoteNodeId, willFund.sig, witness)) {
Either.Left(InvalidLiquidityAdsSig(channelId))
} else if (remoteFundingAmount <= 0.sat) {
Either.Left(LiquidityRatesRejected(channelId))
} else if (maxFee < fees) {
Either.Left(LiquidityRatesRejected(channelId))
} else {
val leaseAmount = fundingAmount.min(remoteFundingAmount)
Either.Right(Lease(leaseAmount, fees, willFund.sig, witness))
}
}
}
}
}

/**
* Once a liquidity ads has been paid, we should keep track of the lease, and check that our peer doesn't raise their
* routing fees above the values they signed up for.
*/
data class Lease(val amount: Satoshi, val fees: Satoshi, val sellerSig: ByteVector64, val witness: LeaseWitness) {
val expiry: Int = witness.leaseEnd
}

/** The seller signs the lease parameters: if they cheat, the buyer can use that signature to prove they cheated. */
data class LeaseWitness(val fundingPubKey: PublicKey, val leaseEnd: Int, val leaseDuration: Int, val maxRelayFeeProportional: Int, val maxRelayFeeBase: MilliSatoshi) {
fun sign(nodeKey: PrivateKey): ByteVector64 = Crypto.sign(Crypto.sha256(encode()), nodeKey)

fun encode(): ByteArray {
val out = ByteArrayOutput()
LightningCodecs.writeBytes("option_will_fund".encodeToByteArray(), out)
LightningCodecs.writeBytes(fundingPubKey.value, out)
LightningCodecs.writeU32(leaseEnd, out)
LightningCodecs.writeU32(leaseDuration, out)
LightningCodecs.writeU16(maxRelayFeeProportional, out)
LightningCodecs.writeU32(maxRelayFeeBase.msat.toInt(), out)
return out.toByteArray()
}

companion object {
fun verify(nodeId: PublicKey, sig: ByteVector64, witness: LeaseWitness): Boolean = Crypto.verifySignature(Crypto.sha256(witness.encode()), sig, nodeId)
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import fr.acinq.lightning.Lightning.randomBytes
import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.Lightning.randomBytes64
import fr.acinq.lightning.Lightning.randomKey
import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelType
import fr.acinq.lightning.channel.Origin
import fr.acinq.lightning.channel.*
import fr.acinq.lightning.crypto.assertArrayEquals
import fr.acinq.lightning.tests.utils.LightningTestSuite
import fr.acinq.lightning.utils.msat
Expand Down Expand Up @@ -770,4 +770,35 @@ class LightningCodecsTestsCommon : LightningTestSuite() {
assertArrayEquals(it.second, encoded)
}
}

@Test
fun `validate liquidity ads lease`() {
// The following lease has been signed by eclair.
val channelId = randomBytes32()
val remoteNodeId = PublicKey.fromHex("023d1d3fc041ca0417e60abffb1d44acf3db3bc1bfcab89031df4920f4ac68b91e")
val remoteFundingPubKey = PublicKey.fromHex("03fda99086f3426ccc6f7bcb5a163e1f93fdfd23e2770d462138ddf9f8db779933")
val remoteWillFund = ChannelTlv.WillFund(
sig = ByteVector64("293f412e6a2b2b3eeab7e67134dc0458e9f068f7e1bc9c0460ed0cdb285096eb592d7881f0dd0777b8176a9f8e232af9d3f21c8925617a3e33bca4d41f30a2fe"),
leaseRates = LiquidityAds.LeaseRates(500, 100, 250, 10.sat, 2000.msat),
)
assertEquals(remoteWillFund.leaseRates.fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 500_000.sat), 5635.sat)
assertEquals(remoteWillFund.leaseRates.fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 600_000.sat), 5635.sat)
assertEquals(remoteWillFund.leaseRates.fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 400_000.sat), 4635.sat)

data class TestCase(val remoteFundingAmount: Satoshi, val feerate: FeeratePerKw, val willFund: ChannelTlv.WillFund?, val failure: ChannelException?)

val testCases = listOf(
TestCase(500_000.sat, FeeratePerKw(FeeratePerByte(5.sat)), remoteWillFund, failure = null),
TestCase(500_000.sat, FeeratePerKw(FeeratePerByte(5.sat)), willFund = null, failure = MissingLiquidityAds(channelId)),
TestCase(500_000.sat, FeeratePerKw(FeeratePerByte(5.sat)), remoteWillFund.copy(sig = randomBytes64()), failure = InvalidLiquidityAdsSig(channelId)),
TestCase(0.sat, FeeratePerKw(FeeratePerByte(5.sat)), remoteWillFund, failure = LiquidityRatesRejected(channelId)),
TestCase(800_000.sat, FeeratePerKw(FeeratePerByte(20.sat)), remoteWillFund, failure = LiquidityRatesRejected(channelId)), // exceeds maximum fee
)
testCases.forEach {
val request = LiquidityAds.RequestRemoteFunding(it.remoteFundingAmount, 10_000.sat, leaseStart = 819_000, leaseDuration = 1000)
val result = request.validateLeaseRates(remoteNodeId, channelId, remoteFundingPubKey, it.remoteFundingAmount, it.feerate, it.willFund)
assertEquals(result.left, it.failure)
}

}
}

0 comments on commit c1f0b1b

Please sign in to comment.