From 069f45a3fd56191a0e7e01d21fdd1e191fdd5f75 Mon Sep 17 00:00:00 2001 From: matthiasdebernardini Date: Mon, 9 Mar 2026 16:37:56 -0500 Subject: [PATCH] Add swap-in wallet, balance, and splice-in endpoints via Electrum backend Port swap-in functionality from PR #69 to current master by switching the blockchain backend from MempoolSpaceClient to ElectrumClient. This enables peer.swapInWallet (which requires IElectrumClient) and adds three new API endpoints: getswapinaddress, swapinwalletbalance, and splicein. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kotlin/fr/acinq/phoenixd/Api.kt | 43 +++++++++++ .../kotlin/fr/acinq/phoenixd/Phoenixd.kt | 61 +++++++++++---- .../fr/acinq/phoenixd/cli/PhoenixCli.kt | 29 +++++++ .../fr/acinq/phoenixd/json/JsonSerializers.kt | 9 +++ tests/test_swap_in_e2e.sh | 75 +++++++++++++++++++ 5 files changed, 201 insertions(+), 16 deletions(-) create mode 100755 tests/test_swap_in_e2e.sh diff --git a/src/commonMain/kotlin/fr/acinq/phoenixd/Api.kt b/src/commonMain/kotlin/fr/acinq/phoenixd/Api.kt index 5e881d0..16ba9fd 100644 --- a/src/commonMain/kotlin/fr/acinq/phoenixd/Api.kt +++ b/src/commonMain/kotlin/fr/acinq/phoenixd/Api.kt @@ -55,9 +55,11 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.websocket.* +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import kotlinx.io.bytestring.encodeToByteString import kotlinx.io.files.Path import kotlinx.serialization.ExperimentalSerializationApi @@ -168,6 +170,19 @@ class Api( .sum().truncateToSatoshi() call.respond(Balance(balance, peer.feeCreditFlow.value.truncateToSatoshi())) } + get("getswapinaddress") { + val swapInWallet = peer.swapInWallet ?: badRequest("swap-in wallet unavailable") + val (address, index) = withTimeout(5.seconds) { + swapInWallet.swapInAddressFlow.filterNotNull().first() + } + call.respond(SwapInAddress(address, index)) + } + get("swapinwalletbalance") { + val swapInWallet = peer.swapInWallet ?: badRequest("swap-in wallet unavailable") + val walletState = swapInWallet.wallet.walletStateFlow.value + val unconfirmedBalance = walletState.utxos.filter { it.blockHeight == 0L }.map { it.amount }.sum() + call.respond(SwapInWalletBalance(walletState.totalBalance, unconfirmedBalance)) + } get("estimateliquidityfees") { val amount = call.parameters.getLong("amountSat").sat val feerate = peer.onChainFeeratesFlow.filterNotNull().first().fundingFeerate @@ -460,6 +475,34 @@ class Api( else -> call.respondText("no channel available") } } + post("splicein") { + val formParameters = call.receiveParameters() + val amountSat = formParameters.getLong("amountSat").sat + val feerate = FeeratePerKw(FeeratePerByte(formParameters.getLong("feerateSatByte").sat)) + + val swapInWallet = peer.swapInWallet ?: badRequest("swap-in wallet unavailable") + val walletState = swapInWallet.wallet.walletStateFlow.value + val utxos = walletState.utxos + if (utxos.isEmpty()) badRequest("no UTXOs available in swap-in wallet") + + val channel = peer.channels.values.filterIsInstance().firstOrNull() + ?: badRequest("no channel available") + + val spliceCommand = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(walletInputs = utxos), + spliceOut = null, + requestRemoteFunding = null, + currentFeeCredit = peer.feeCreditFlow.value, + feerate = feerate, + origins = listOf() + ) + peer.send(WrappedChannelCommand(channel.channelId, spliceCommand)) + when (val response = spliceCommand.replyTo.await()) { + is ChannelFundingResponse.Success -> call.respondText(response.fundingTxId.toString()) + is ChannelFundingResponse.Failure -> call.respondText(response.toString()) + } + } post("closechannel") { val formParameters = call.receiveParameters() val channelId = formParameters.getByteVector32("channelId") diff --git a/src/commonMain/kotlin/fr/acinq/phoenixd/Phoenixd.kt b/src/commonMain/kotlin/fr/acinq/phoenixd/Phoenixd.kt index 781a63f..70c89c2 100644 --- a/src/commonMain/kotlin/fr/acinq/phoenixd/Phoenixd.kt +++ b/src/commonMain/kotlin/fr/acinq/phoenixd/Phoenixd.kt @@ -24,8 +24,10 @@ 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.blockchain.mempool.MempoolSpaceClient -import fr.acinq.lightning.blockchain.mempool.MempoolSpaceWatcher +import fr.acinq.lightning.blockchain.electrum.ElectrumClient +import fr.acinq.lightning.blockchain.electrum.ElectrumConnectionStatus +import fr.acinq.lightning.blockchain.electrum.ElectrumWatcher +import fr.acinq.lightning.utils.ServerAddress import fr.acinq.lightning.crypto.LocalKeyManager import fr.acinq.lightning.db.* import fr.acinq.lightning.io.Peer @@ -75,22 +77,20 @@ class Phoenixd : CliktCommand() { private val chain by option("--chain", help = "Bitcoin chain to use").choice( "mainnet" to Chain.Mainnet, "testnet" to Chain.Testnet3 ).default(Chain.Mainnet, defaultForHelp = "mainnet") - private val mempoolSpaceUrl by option("--mempool-space-url", help = "Custom mempool.space instance") - .convert { Url(it) } + private val electrumServer by option("--electrum-server", help = "Electrum server host:port (SSL)") .defaultLazy { when (chain) { - Chain.Mainnet -> MempoolSpaceClient.OfficialMempoolMainnet - Chain.Testnet3 -> MempoolSpaceClient.OfficialMempoolTestnet3 + Chain.Mainnet -> "electrum.acinq.co:50002" + Chain.Testnet3 -> "testnet.acinq.co:51002" else -> error("unsupported chain") } } - private val mempoolPollingInterval by option( - "--mempool-space-polling-interval-minutes", - help = "Polling interval for mempool.space API", - hidden = true - ) - .int().convert { it.minutes } - .default(10.minutes) + @Suppress("unused") + private val mempoolSpaceUrl by option("--mempool-space-url", hidden = true) + .deprecated("--mempool-space-url is deprecated, phoenixd now uses Electrum. Use --electrum-server instead.", error = true) + @Suppress("unused") + private val mempoolPollingInterval by option("--mempool-space-polling-interval-minutes", hidden = true) + .deprecated("--mempool-space-polling-interval-minutes is deprecated, phoenixd now uses Electrum.", error = true) class LiquidityOptions : OptionGroup(name = "Liquidity Options") { val autoLiquidity by option("--auto-liquidity", help = "Amount automatically requested when inbound liquidity is needed").choice( @@ -302,10 +302,10 @@ class Phoenixd : CliktCommand() { val channelsDb = SqliteChannelsDb(driver, database) val paymentsDb = SqlitePaymentsDb(database) - val mempoolSpace = MempoolSpaceClient(mempoolSpaceUrl, loggerFactory) - val watcher = MempoolSpaceWatcher(mempoolSpace, scope, loggerFactory, pollingInterval = mempoolPollingInterval) + val electrumClient = ElectrumClient(scope, loggerFactory) + val electrumWatcher = ElectrumWatcher(electrumClient, scope, loggerFactory) val peer = Peer( - nodeParams = nodeParams, walletParams = lsp.walletParams, client = mempoolSpace, watcher = watcher, db = object : Databases { + nodeParams = nodeParams, walletParams = lsp.walletParams, client = electrumClient, watcher = electrumWatcher, db = object : Databases { override val channels: ChannelsDb get() = channelsDb override val payments: PaymentsDb get() = paymentsDb }, socketBuilder = TcpSocket.Builder(), scope @@ -345,6 +345,15 @@ class Phoenixd : CliktCommand() { } } } + launch { + electrumClient.connectionStatus.collect { + when (it) { + is ElectrumConnectionStatus.Connecting -> consoleLog(yellow("connecting to electrum server...")) + is ElectrumConnectionStatus.Connected -> consoleLog(yellow("connected to electrum server")) + is ElectrumConnectionStatus.Closed -> consoleLog(yellow("disconnected from electrum server")) + } + } + } launch { nodeParams.nodeEvents .filterIsInstance() @@ -407,6 +416,22 @@ class Phoenixd : CliktCommand() { } } + val (electrumHost, electrumPort) = electrumServer.let { + val parts = it.split(":") + require(parts.size == 2) { "Invalid electrum server format, expected host:port but got: $it" } + parts[0] to parts[1].toInt() + } + consoleLog(cyan("electrum server: $electrumHost:$electrumPort")) + + val electrumConnectionLoop = scope.launch { + val serverAddress = ServerAddress(electrumHost, electrumPort, TcpSocket.TLS.TRUSTED_CERTIFICATES(expectedHostName = electrumHost)) + while (true) { + electrumClient.connect(serverAddress, TcpSocket.Builder()) + electrumClient.connectionStatus.first { it is ElectrumConnectionStatus.Closed } + delay(3.seconds) + } + } + val peerConnectionLoop = scope.launch { while (true) { peer.connect(connectTimeout = 10.seconds, handshakeTimeout = 10.seconds) @@ -416,9 +441,13 @@ class Phoenixd : CliktCommand() { } runBlocking { + electrumClient.connectionStatus.first { it is ElectrumConnectionStatus.Connected } peer.connectionState.first { it == Connection.ESTABLISHED } } + // Start monitoring swap-in wallet after both electrum and peer are connected + scope.launch { peer.startWatchSwapInWallet() } + val server = embeddedServer( CIO, environment = applicationEnvironment { diff --git a/src/commonMain/kotlin/fr/acinq/phoenixd/cli/PhoenixCli.kt b/src/commonMain/kotlin/fr/acinq/phoenixd/cli/PhoenixCli.kt index 8fb3ecb..b1d76ae 100644 --- a/src/commonMain/kotlin/fr/acinq/phoenixd/cli/PhoenixCli.kt +++ b/src/commonMain/kotlin/fr/acinq/phoenixd/cli/PhoenixCli.kt @@ -65,6 +65,9 @@ fun main(args: Array) = LnurlPay(), LnurlWithdraw(), LnurlAuth(), + GetSwapInAddress(), + GetSwapInWalletBalance(), + SpliceIn(), SendToAddress(), BumpFee(), CloseChannel(), @@ -398,6 +401,32 @@ class LnurlAuth : PhoenixCliCommand(name = "lnurlauth", help = "Authenticate on } } +class GetSwapInAddress : PhoenixCliCommand(name = "getswapinaddress", help = "Get swap-in wallet address") { + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.get(url = commonOptions.baseUrl / "getswapinaddress") + } +} + +class GetSwapInWalletBalance : PhoenixCliCommand(name = "swapinwalletbalance", help = "Get swap-in wallet balance") { + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.get(url = commonOptions.baseUrl / "swapinwalletbalance") + } +} + +class SpliceIn : PhoenixCliCommand(name = "splicein", help = "Splice-in funds from swap-in wallet to channel", printHelpOnEmptyArgs = true) { + private val amountSat by option("--amountSat").long().required() + private val feerateSatByte by option("--feerateSatByte").int().required() + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.submitForm( + url = (commonOptions.baseUrl / "splicein").toString(), + formParameters = parameters { + append("amountSat", amountSat.toString()) + append("feerateSatByte", feerateSatByte.toString()) + } + ) + } +} + class SendToAddress : PhoenixCliCommand(name = "sendtoaddress", help = "Send to a Bitcoin address", printHelpOnEmptyArgs = true) { private val amountSat by option("--amountSat").long().required() private val address by option("--address").required().check { runCatching { Base58Check.decode(it) }.isSuccess || runCatching { Bech32.decodeWitnessAddress(it) }.isSuccess } diff --git a/src/commonMain/kotlin/fr/acinq/phoenixd/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/phoenixd/json/JsonSerializers.kt index ec28cde..18b8402 100644 --- a/src/commonMain/kotlin/fr/acinq/phoenixd/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/phoenixd/json/JsonSerializers.kt @@ -205,6 +205,15 @@ sealed class ApiType { ) } + @Serializable + data class SwapInAddress(val address: String, val index: Int) : ApiType() + + @Serializable + data class SwapInWalletBalance( + @SerialName("totalBalanceSat") val totalBalance: Satoshi, + @SerialName("unconfirmedBalanceSat") val unconfirmedBalance: Satoshi + ) : ApiType() + @Serializable @SerialName("lnurl_request") data class LnurlRequest(val url: String, val tag: String?) { diff --git a/tests/test_swap_in_e2e.sh b/tests/test_swap_in_e2e.sh new file mode 100755 index 0000000..5bc9d4a --- /dev/null +++ b/tests/test_swap_in_e2e.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Interactive swap-in E2E test for phoenixd on testnet +set -e + +BASE_URL="http://localhost:9740" +PASSWORD=$(grep http-password ~/.phoenix/phoenix.conf | head -1 | cut -d= -f2) + +echo "=== Step 1: Get swap-in address ===" +RESULT=$(curl -s -u ":$PASSWORD" "$BASE_URL/getswapinaddress") +ADDRESS=$(echo "$RESULT" | jq -r '.address') +echo "Swap-in address: $ADDRESS" +echo "" +if command -v qrencode &> /dev/null; then + echo "Scan this QR code with your phone and send testnet coins:" + qrencode -t ANSIUTF8 "bitcoin:$ADDRESS" +else + echo "Install qrencode for QR display, or send testnet coins to: $ADDRESS" +fi +echo "" + +echo "=== Step 2: Waiting for funds... ===" +echo "Press Enter after you've sent the transaction..." +read + +echo "Polling swap-in wallet balance..." +while true; do + BAL=$(curl -s -u ":$PASSWORD" "$BASE_URL/swapinwalletbalance") + CONFIRMED=$(echo "$BAL" | jq '.totalBalanceSat') + UNCONFIRMED=$(echo "$BAL" | jq '.unconfirmedBalanceSat') + echo " total: $CONFIRMED sat | unconfirmed: $UNCONFIRMED sat" + if [ "$UNCONFIRMED" -gt 0 ] 2>/dev/null || [ "$CONFIRMED" -gt 0 ] 2>/dev/null; then + echo "Funds detected!" + break + fi + sleep 10 +done + +echo "" +echo "=== Step 3: Wait for confirmation ===" +echo "Waiting for at least 1 confirmation..." +while true; do + BAL=$(curl -s -u ":$PASSWORD" "$BASE_URL/swapinwalletbalance") + CONFIRMED=$(echo "$BAL" | jq '.totalBalanceSat') + UNCONFIRMED=$(echo "$BAL" | jq '.unconfirmedBalanceSat') + CONFIRMED_ONLY=$((CONFIRMED - UNCONFIRMED)) + echo " confirmed: $CONFIRMED_ONLY sat (total: $CONFIRMED, unconfirmed: $UNCONFIRMED)" + if [ "$CONFIRMED_ONLY" -gt 0 ] 2>/dev/null; then + echo "Confirmed balance: $CONFIRMED_ONLY sat" + break + fi + sleep 30 +done + +echo "" +echo "=== Step 4: Splice-in to channel ===" +echo "Splicing $CONFIRMED sat into channel..." +SPLICE_RESULT=$(curl -s -u ":$PASSWORD" -X POST "$BASE_URL/splicein" \ + -d "amountSat=$CONFIRMED&feerateSatByte=2") +echo "Splice result: $SPLICE_RESULT" + +echo "" +echo "=== Step 5: Verify channel balance ===" +curl -s -u ":$PASSWORD" "$BASE_URL/getbalance" | jq . + +echo "" +echo "=== Step 6: Splice-out (send back on-chain) ===" +read -p "Enter your return Bitcoin address: " RETURN_ADDR +SEND_RESULT=$(curl -s -u ":$PASSWORD" -X POST "$BASE_URL/sendtoaddress" \ + -d "amountSat=$((CONFIRMED - 1000))&address=$RETURN_ADDR&feerateSatByte=2") +echo "Send result: $SEND_RESULT" + +echo "" +echo "=== Done! ===" +echo "Final balance:" +curl -s -u ":$PASSWORD" "$BASE_URL/getbalance" | jq .