2024-03-21 13:20:43 +01:00

351 lines
17 KiB
Kotlin

package fr.acinq.lightning.bin
import app.cash.sqldelight.EnumColumnAdapter
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.default
import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions
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.BuildVersions
import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.LiquidityEvents
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.PaymentEvents
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
import fr.acinq.lightning.bin.db.WalletPaymentId
import fr.acinq.lightning.bin.db.payments.LightningOutgoingQueries
import fr.acinq.lightning.bin.json.ApiType
import fr.acinq.lightning.bin.logs.FileLogWriter
import fr.acinq.lightning.bin.logs.TimestampFormatter
import fr.acinq.lightning.bin.logs.stringTimestamp
import fr.acinq.lightning.blockchain.mempool.MempoolSpaceClient
import fr.acinq.lightning.blockchain.mempool.MempoolSpaceWatcher
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
import fr.acinq.phoenix.db.*
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()
.versionOption(BuildVersions.phoenixdVersion, names = setOf("--version", "-v"))
.main(args)
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.Mainnet, defaultForHelp = "mainnet")
private val customMempoolSpaceHost by option("--mempool-space", help = "Custom mempool.space instance")
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("\nhttp-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) }
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)
}
private val liquidityOptions by LiquidityOptions()
sealed class Verbosity {
data object Default : Verbosity()
data object Silent : Verbosity()
data object Verbose : Verbosity()
}
private val verbosity by option(help = "Verbosity level").switch(
"--silent" to Verbosity.Silent,
"--verbose" to Verbosity.Verbose
).default(Verbosity.Default, defaultForHelp = "prints high-level info to the console")
init {
context {
valueSource = MapValueSource(readConfFile(confFile))
helpFormatter = { MordantHelpFormatter(it, showDefaultValues = true) }
}
}
private fun consoleLog(msg: Any, err: Boolean = false) {
if (verbosity == Verbosity.Default) { // in verbose mode we print logs instead
terminal.print(gray(stringTimestamp()), stderr = err)
terminal.print(" ")
terminal.println(msg, stderr = err)
}
}
@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("Do not share the same seed with other phoenix instances (mobile or server), it will cause issues and channel force closes.")
terminal.println()
terminal.prompt(
"Please confirm by typing",
choices = listOf("I understand"),
invalidChoiceMessage = "Please type those exact words:"
)
terminal.println()
terminal.println(green("Continuous liquidity"))
terminal.println(
"""
Liquidity management is fully automated.
When receiving a Lightning payment that doesn't fit in your existing channel:
- 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, and will be used later to pay for future fees. ${bold(red("The fee credit is non-refundable"))}.
""".trimIndent()
)
terminal.println()
terminal.prompt(
"Please confirm by typing",
choices = listOf("I understand"),
invalidChoiceMessage = "Please type those exact words:"
)
terminal.println()
terminal.println("Phoenix server is about to start, use ${bold("phoenix-cli")} or the ${bold("http api")} to interact with the daemon. This message will not be displayed next time.")
terminal.println("Press any key to continue...")
terminal.readLineOrNull(true)
terminal.println()
}
}
consoleLog(cyan("datadir: ${FileSystem.SYSTEM.canonicalize(datadir)}"))
consoleLog(cyan("chain: $chain"))
consoleLog(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 (verbosity == Verbosity.Verbose) add(CommonWriter(TimestampFormatter))
})
)
val mempoolSpaceHost = customMempoolSpaceHost ?: when (chain) {
Chain.Mainnet -> "mempool.space"
Chain.Testnet -> "mempool.space/testnet"
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)
.copy(
zeroConfPeers = setOf(lsp.walletParams.trampolineNode.id),
liquidityPolicy = MutableStateFlow(liquidityPolicy),
)
consoleLog(cyan("nodeid: ${nodeParams.nodeId}"))
val driver = createAppDbDriver(datadir, chain, nodeParams.nodeId)
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()
)
)
val channelsDb = SqliteChannelsDb(driver, database)
val paymentsDb = SqlitePaymentsDb(database)
val mempoolSpace = MempoolSpaceClient(mempoolSpaceHost, loggerFactory)
val watcher = MempoolSpaceWatcher(mempoolSpace, scope, loggerFactory)
val peer = Peer(
nodeParams = nodeParams, walletParams = lsp.walletParams, client = mempoolSpace, watcher = watcher, db = object : Databases {
override val channels: ChannelsDb get() = channelsDb
override val payments: PaymentsDb get() = paymentsDb
}, socketBuilder = TcpSocket.Builder(), scope
)
val eventsFlow: SharedFlow<ApiType.ApiEvent> = MutableSharedFlow<ApiType.ApiEvent>().run {
scope.launch {
launch {
nodeParams.nodeEvents
.collect {
when {
it is PaymentEvents.PaymentReceived && it.amount > 0.msat -> {
val metadata = paymentsDb.metadataQueries.get(WalletPaymentId.IncomingPaymentId(it.paymentHash))
emit(ApiType.PaymentReceived(it, metadata))
}
else -> {}
}
}
}
}
asSharedFlow()
}
val listeners = scope.launch {
launch {
// drop initial CLOSED event
peer.connectionState.dropWhile { it is Connection.CLOSED }.collect {
when (it) {
Connection.ESTABLISHING -> consoleLog(yellow("connecting to lightning peer..."))
Connection.ESTABLISHED -> consoleLog(yellow("connected to lightning peer"))
is Connection.CLOSED -> consoleLog(yellow("disconnected from lightning peer"))
}
}
}
launch {
nodeParams.nodeEvents
.filterIsInstance<PaymentEvents.PaymentReceived>()
.filter { it.amount > 0.msat }
.collect {
consoleLog("received lightning payment: ${it.amount.truncateToSatoshi()} (${it.receivedWith.joinToString { part -> part::class.simpleName.toString().lowercase() }})")
}
}
launch {
nodeParams.nodeEvents
.filterIsInstance<LiquidityEvents.Decision.Rejected>()
.collect {
consoleLog(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 -> consoleLog("fee credit: $feeCredit") }
}
}
val peerConnectionLoop = scope.launch {
while (true) {
peer.connect(connectTimeout = 10.seconds, handshakeTimeout = 10.seconds)
peer.connectionState.first { it is Connection.CLOSED }
delay(3.seconds)
}
}
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) {
consoleLog(t.cause?.message.toString(), err = true)
} else throw t
}
}
server.environment.monitor.subscribe(ServerReady) {
consoleLog("listening on http://$httpBindIp:$httpBindPort")
}
server.environment.monitor.subscribe(ApplicationStopPreparing) {
consoleLog(brightYellow("shutting down..."))
listeners.cancel()
peerConnectionLoop.cancel()
peer.disconnect()
server.stop()
exitProcess(0)
}
server.environment.monitor.subscribe(ApplicationStopped) { consoleLog(brightYellow("http server stopped")) }
runBlocking { serverJob.join() }
}
}