Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/commonMain/kotlin/fr/acinq/phoenixd/Api.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Normal>().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")
Expand Down
61 changes: 45 additions & 16 deletions src/commonMain/kotlin/fr/acinq/phoenixd/Phoenixd.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<PaymentEvents>()
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions src/commonMain/kotlin/fr/acinq/phoenixd/cli/PhoenixCli.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ fun main(args: Array<String>) =
LnurlPay(),
LnurlWithdraw(),
LnurlAuth(),
GetSwapInAddress(),
GetSwapInWalletBalance(),
SpliceIn(),
SendToAddress(),
BumpFee(),
CloseChannel(),
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand Down
75 changes: 75 additions & 0 deletions tests/test_swap_in_e2e.sh
Original file line number Diff line number Diff line change
@@ -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 .