2024-03-08 18:44:24 +01:00
package fr.acinq.lightning.bin
2024-03-18 17:21:03 +01:00
import app.cash.sqldelight.EnumColumnAdapter
2024-03-08 18:44:24 +01:00
import co.touchlab.kermit.CommonWriter
import co.touchlab.kermit.Severity
import co.touchlab.kermit.StaticConfig
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.context
import com.github.ajalt.clikt.core.terminal
import com.github.ajalt.clikt.output.MordantHelpFormatter
import com.github.ajalt.clikt.parameters.groups.OptionGroup
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.choice
import com.github.ajalt.clikt.parameters.types.int
import com.github.ajalt.clikt.parameters.types.restrictTo
import com.github.ajalt.clikt.sources.MapValueSource
import com.github.ajalt.mordant.rendering.TextColors.*
import com.github.ajalt.mordant.rendering.TextStyles.bold
import com.github.ajalt.mordant.rendering.TextStyles.underline
import fr.acinq.bitcoin.Chain
import fr.acinq.lightning.Lightning.randomBytes32
2024-03-11 14:12:27 +01:00
import fr.acinq.lightning.LiquidityEvents
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.PaymentEvents
2024-03-08 18:44:24 +01:00
import fr.acinq.lightning.bin.conf.LSP
import fr.acinq.lightning.bin.conf.getOrGenerateSeed
import fr.acinq.lightning.bin.conf.readConfFile
import fr.acinq.lightning.bin.db.SqliteChannelsDb
import fr.acinq.lightning.bin.db.SqlitePaymentsDb
2024-03-19 11:58:39 +01:00
import fr.acinq.lightning.bin.db.WalletPaymentId
2024-03-18 17:21:03 +01:00
import fr.acinq.lightning.bin.db.payments.LightningOutgoingQueries
2024-03-08 18:44:24 +01:00
import fr.acinq.lightning.bin.json.ApiType
import fr.acinq.lightning.bin.logs.FileLogWriter
2024-03-19 21:43:29 +01:00
import fr.acinq.lightning.blockchain.mempool.MempoolSpaceClient
import fr.acinq.lightning.blockchain.mempool.MempoolSpaceWatcher
2024-03-08 18:44:24 +01:00
import fr.acinq.lightning.crypto.LocalKeyManager
import fr.acinq.lightning.db.ChannelsDb
import fr.acinq.lightning.db.Databases
import fr.acinq.lightning.db.PaymentsDb
import fr.acinq.lightning.io.Peer
import fr.acinq.lightning.io.TcpSocket
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.payment.LiquidityPolicy
import fr.acinq.lightning.utils.Connection
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
2024-03-18 17:21:03 +01:00
import fr.acinq.phoenix.db.*
2024-03-08 18:44:24 +01:00
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import okio.FileSystem
import okio.buffer
import okio.use
import kotlin.system.exitProcess
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
fun main ( args : Array < String > ) = Phoenixd ( ) . main ( args )
class LiquidityOptions : OptionGroup ( name = " Liquidity Options " ) {
val autoLiquidity by option ( " --auto-liquidity " , help = " Amount automatically requested when inbound liquidity is needed " ) . choice (
" off " to 0. sat ,
" 2m " to 2 _000 _000 . sat ,
" 5m " to 5 _000 _000 . sat ,
" 10m " to 10 _000 _000 . sat ,
) . default ( 2 _000 _000 . sat )
val maxAbsoluteFee by option ( " --max-absolute-fee " , help = " Max absolute fee for on-chain operations. Includes mining fee and service fee for auto-liquidity. " )
. int ( ) . convert { it . sat }
. restrictTo ( 5 _000 . sat .. 100 _000 . sat )
. default ( 40 _000 . sat ) // with a default auto-liquidity of 2m sat, that's a max total fee of 2%
val maxRelativeFeeBasisPoint by option ( " --max-relative-fee-percent " , help = " Max relative fee for on-chain operations in percent. " , hidden = true )
. int ( )
. restrictTo ( 1. . 50 )
. default ( 30 )
val maxFeeCredit by option ( " --max-fee-credit " , help = " Max fee credit, if reached payments will be rejected. " , hidden = true )
. int ( ) . convert { it . sat }
. restrictTo ( 0. sat .. 100 _000 . sat )
. default ( 100 _000 . sat )
}
class Phoenixd : CliktCommand ( ) {
//private val datadir by option("--datadir", help = "Data directory").convert { it.toPath() }.default(homeDirectory / ".phoenix", defaultForHelp = "~/.phoenix")
private val datadir = homeDirectory / " .phoenix "
private val confFile = datadir / " phoenix.conf "
private val chain by option ( " --chain " , help = " Bitcoin chain to use " ) . choice (
" mainnet " to Chain . Mainnet , " testnet " to Chain . Testnet
) . default ( Chain . Testnet , defaultForHelp = " testnet " )
2024-03-19 21:43:29 +01:00
private val customMempoolSpaceHost by option ( " --mempool-space " , " -e " , help = " Custom mempool.space instance " )
2024-03-08 18:44:24 +01:00
private val httpBindIp by option ( " --http-bind-ip " , help = " Bind ip for the http api " ) . default ( " 127.0.0.1 " )
private val httpBindPort by option ( " --http-bind-port " , help = " Bind port for the http api " ) . int ( ) . default ( 9740 )
private val httpPassword by option ( " --http-password " , help = " Password for the http api " ) . defaultLazy {
// the additionalValues map already contains values in phoenix.conf, so if we are here then there are no existing password
terminal . print ( yellow ( " Generating default api password... " ) )
val value = randomBytes32 ( ) . toHex ( )
val confFile = datadir / " phoenix.conf "
FileSystem . SYSTEM . createDirectories ( datadir )
FileSystem . SYSTEM . appendingSink ( confFile , mustExist = false ) . buffer ( ) . use { it . writeUtf8 ( " \n http-password= $value \n " ) }
terminal . println ( white ( " done " ) )
value
}
private val webHookUrl by option ( " --webhook " , help = " Webhook http endpoint for push notifications (alternative to websocket) " )
. convert { Url ( it ) }
private val liquidityOptions by LiquidityOptions ( )
private val verbose : Boolean by option ( " --verbose " , help = " Verbose mode " ) . flag ( default = false )
private val silent : Boolean by option ( " --silent " , " -s " , help = " Silent mode " ) . flag ( default = false )
init {
context {
valueSource = MapValueSource ( readConfFile ( confFile ) )
helpFormatter = { MordantHelpFormatter ( it , showDefaultValues = true ) }
}
}
@OptIn ( DelicateCoroutinesApi :: class )
override fun run ( ) {
FileSystem . SYSTEM . createDirectories ( datadir )
val ( seed , new ) = getOrGenerateSeed ( datadir )
if ( new ) {
runBlocking {
terminal . print ( yellow ( " Generating new seed... " ) )
delay ( 500. milliseconds )
terminal . println ( white ( " done " ) )
terminal . println ( )
terminal . println ( green ( " Backup " ) )
terminal . println ( " This software is self-custodial, you have full control and responsibility over your funds. " )
terminal . println ( " Your 12-words seed is located in ${FileSystem.SYSTEM.canonicalize(datadir)} , ${bold(red("make sure to do a backup or you risk losing your funds"))} . " )
terminal . println ( )
terminal . println ( green ( " How does it work? " ) )
terminal . println (
"""
When receiving a Lightning payment that doesn ' t fit within your existing channel , then :
- If the payment amount is large enough to cover mining fees and service fees for automated liquidity , then your channel will be created or enlarged right away .
- If the payment is too small , then the full amount is added to your fee credit . This credit will be used later to pay for future fees . $ { bold ( red ( " The fee credit is non-refundable " ) ) } .
""" .trimIndent()
)
terminal . println ( )
terminal . println (
gray (
"""
Examples :
With the default settings , and assuming that current mining fees are 10 k sat . The total fee for a
liquidity operation will be 10 k sat ( mining fee ) + 20 k sat ( service fee for the 2 m sat liquidity ) = 30 k sat .
$ { ( underline + gray ) ( " scenario A " ) } : you receive a continuous stream of tiny 100 sat payments
a ) the first 299 incoming payments will be added to your fee credit
b ) when receiving the 300 th payment , a 2 m sat channel will be created , with balance 0 sat on your side
c ) the next 20 thousands payments will be received in your channel
d ) back to a )
$ { ( underline + gray ) ( " scenario B " ) } : you receive a continuous stream of 50 k sat payments
a ) when receiving the first payment , a 1 M sat channel will be created with balance 50 k - 30 k = 20 k sat on your side
b ) the next next 40 payments will be received in your channel , at that point balance is 2 m sat
c ) back to a )
In both scenarios , the total average fee is the same : 30 k / 2 m = 1.5 % .
You can reduce this average fee further , by choosing a higher liquidity amount ( option $ { bold ( white ( " --auto-liquidity " ) ) } ) ,
in exchange for higher upfront costs . The higher the liquidity amount , the less significant the cost of
mining fee in relative terms .
""" .trimIndent()
)
)
terminal . println ( )
terminal . prompt ( " Please confirm by typing " , choices = listOf ( " I understand that if I do not make a backup I risk losing my funds " ) , invalidChoiceMessage = " Please type those exact words: " )
terminal . prompt (
" Please confirm by typing " ,
choices = listOf ( " I must not share the same seed with other phoenix instances (mobile or server) or I risk force closing my channels " ) ,
invalidChoiceMessage = " Please type those exact words: "
)
terminal . prompt ( " Please confirm by typing " , choices = listOf ( " I accept that the fee credit is non-refundable " ) , invalidChoiceMessage = " Please type those exact words: " )
terminal . println ( )
}
}
echo ( cyan ( " datadir: ${FileSystem.SYSTEM.canonicalize(datadir)} " ) )
echo ( cyan ( " chain: $chain " ) )
echo ( cyan ( " autoLiquidity: ${liquidityOptions.autoLiquidity} " ) )
val scope = GlobalScope
val loggerFactory = LoggerFactory (
StaticConfig ( minSeverity = Severity . Info , logWriterList = buildList {
// always log to file
add ( FileLogWriter ( datadir / " phoenix.log " , scope ) )
// only log to console if verbose mode is enabled
if ( verbose ) add ( CommonWriter ( ) )
} )
)
2024-03-19 21:43:29 +01:00
val mempoolSpaceHost = customMempoolSpaceHost ?: when ( chain ) {
Chain . Mainnet -> " mempool.space "
Chain . Testnet -> " mempool.space/testnet "
2024-03-08 18:44:24 +01:00
else -> error ( " unsupported chain " )
}
val lsp = LSP . from ( chain )
val liquidityPolicy = LiquidityPolicy . Auto (
maxAbsoluteFee = liquidityOptions . maxAbsoluteFee ,
maxRelativeFeeBasisPoints = liquidityOptions . maxRelativeFeeBasisPoint ,
skipAbsoluteFeeCheck = false ,
maxAllowedCredit = liquidityOptions . maxFeeCredit
)
val keyManager = LocalKeyManager ( seed , chain , lsp . swapInXpub )
val nodeParams = NodeParams ( chain , loggerFactory , keyManager )
2024-03-11 14:10:02 +01:00
. copy (
zeroConfPeers = setOf ( lsp . walletParams . trampolineNode . id ) ,
liquidityPolicy = MutableStateFlow ( liquidityPolicy ) ,
)
2024-03-08 18:44:24 +01:00
echo ( cyan ( " nodeid: ${nodeParams.nodeId} " ) )
2024-03-18 17:21:03 +01:00
val driver = createAppDbDriver ( datadir )
val database = PhoenixDatabase (
driver = driver ,
lightning _outgoing _payment _partsAdapter = Lightning_outgoing_payment_parts . Adapter (
part _routeAdapter = LightningOutgoingQueries . hopDescAdapter ,
part _status _typeAdapter = EnumColumnAdapter ( )
) ,
lightning _outgoing _paymentsAdapter = Lightning_outgoing_payments . Adapter (
status _typeAdapter = EnumColumnAdapter ( ) ,
details _typeAdapter = EnumColumnAdapter ( )
) ,
incoming _paymentsAdapter = Incoming_payments . Adapter (
origin _typeAdapter = EnumColumnAdapter ( ) ,
received _with _typeAdapter = EnumColumnAdapter ( )
) ,
channel _close _outgoing _paymentsAdapter = Channel_close_outgoing_payments . Adapter (
closing _info _typeAdapter = EnumColumnAdapter ( )
) ,
inbound _liquidity _outgoing _paymentsAdapter = Inbound_liquidity_outgoing_payments . Adapter (
lease _typeAdapter = EnumColumnAdapter ( )
)
)
2024-03-19 11:58:39 +01:00
val channelsDb = SqliteChannelsDb ( driver , database )
val paymentsDb = SqlitePaymentsDb ( database )
2024-03-18 17:21:03 +01:00
2024-03-19 21:43:29 +01:00
val mempoolSpace = MempoolSpaceClient ( mempoolSpaceHost , loggerFactory )
val watcher = MempoolSpaceWatcher ( mempoolSpace , scope , loggerFactory )
2024-03-08 18:44:24 +01:00
val peer = Peer (
2024-03-19 21:43:29 +01:00
nodeParams = nodeParams , walletParams = lsp . walletParams , client = mempoolSpace , watcher = watcher , db = object : Databases {
2024-03-19 11:58:39 +01:00
override val channels : ChannelsDb get ( ) = channelsDb
override val payments : PaymentsDb get ( ) = paymentsDb
2024-03-08 18:44:24 +01:00
} , socketBuilder = TcpSocket . Builder ( ) , scope
)
val eventsFlow : SharedFlow < ApiType . ApiEvent > = MutableSharedFlow < ApiType . ApiEvent > ( ) . run {
scope . launch {
launch {
nodeParams . nodeEvents
. collect {
when {
2024-03-19 11:58:39 +01:00
it is PaymentEvents . PaymentReceived && it . amount > 0. msat -> {
val metadata = paymentsDb . metadataQueries . get ( WalletPaymentId . IncomingPaymentId ( it . paymentHash ) )
emit ( ApiType . PaymentReceived ( it , metadata ) )
}
2024-03-08 18:44:24 +01:00
else -> { }
}
}
}
}
asSharedFlow ( )
}
val listeners = scope . launch {
launch {
// drop initial CLOSED event
peer . connectionState . dropWhile { it is Connection . CLOSED } . collect {
when ( it ) {
Connection . ESTABLISHING -> echo ( yellow ( " connecting to lightning peer... " ) )
Connection . ESTABLISHED -> echo ( yellow ( " connected to lightning peer " ) )
is Connection . CLOSED -> echo ( yellow ( " disconnected from lightning peer " ) )
}
}
}
launch {
nodeParams . nodeEvents
. filterIsInstance < PaymentEvents . PaymentReceived > ( )
. filter { it . amount > 0. msat }
. collect {
echo ( " received lightning payment: ${it.amount.truncateToSatoshi()} ( ${it.receivedWith.joinToString { part -> part::class.simpleName.toString().lowercase() } }) " )
}
}
launch {
nodeParams . nodeEvents
. filterIsInstance < LiquidityEvents . Decision . Rejected > ( )
. collect {
echo ( yellow ( " lightning payment rejected: amount= ${it.amount.truncateToSatoshi()} fee= ${it.fee.truncateToSatoshi()} maxFee= ${liquidityPolicy.maxAbsoluteFee} " ) )
}
}
launch {
nodeParams . feeCredit
. drop ( 1 ) // we drop the initial value which is 0 sat
. collect { feeCredit -> echo ( " fee credit: $feeCredit " ) }
}
}
2024-03-19 21:43:29 +01:00
val peerConnectionLoop = scope . launch {
while ( true ) {
peer . connect ( connectTimeout = 10. seconds , handshakeTimeout = 10. seconds )
peer . connectionState . first { it is Connection . CLOSED }
delay ( 3. seconds )
}
}
2024-03-08 18:44:24 +01:00
runBlocking {
peer . connectionState . first { it == Connection . ESTABLISHED }
peer . registerFcmToken ( " super- ${randomBytes32().toHex()} " )
peer . setAutoLiquidityParams ( liquidityOptions . autoLiquidity )
}
val server = embeddedServer ( CIO , port = httpBindPort , host = httpBindIp ,
configure = {
reuseAddress = true
} ,
module = {
Api ( nodeParams , peer , eventsFlow , httpPassword , webHookUrl ) . run { module ( ) }
}
)
val serverJob = scope . launch {
try {
server . start ( wait = true )
} catch ( t : Throwable ) {
if ( t . cause ?. message ?. contains ( " Address already in use " ) == true ) {
echo ( t . cause ?. message , err = true )
} else throw t
}
}
server . environment . monitor . subscribe ( ServerReady ) {
echo ( " listening on http:// $httpBindIp : $httpBindPort " )
}
server . environment . monitor . subscribe ( ApplicationStopPreparing ) {
echo ( brightYellow ( " shutting down... " ) )
2024-03-19 21:43:29 +01:00
listeners . cancel ( )
peerConnectionLoop . cancel ( )
2024-03-08 18:44:24 +01:00
peer . disconnect ( )
server . stop ( )
exitProcess ( 0 )
}
server . environment . monitor . subscribe ( ApplicationStopped ) { echo ( brightYellow ( " http server stopped " ) ) }
runBlocking { serverJob . join ( ) }
}
}