Skip to content

Commit

Permalink
(android) Add screen to recover funds from channel's outpoint (#644)
Browse files Browse the repository at this point in the history
This is a debug tool to fix funds that have been accidentally sent
to a channel's address.
  • Loading branch information
dpad85 authored Nov 8, 2024
1 parent 9894ae9 commit aeca2d8
Show file tree
Hide file tree
Showing 11 changed files with 553 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import fr.acinq.phoenix.android.settings.TorConfigView
import fr.acinq.phoenix.android.settings.channels.ChannelDetailsView
import fr.acinq.phoenix.android.settings.channels.ChannelsView
import fr.acinq.phoenix.android.settings.channels.ImportChannelsData
import fr.acinq.phoenix.android.settings.channels.SpendFromChannelAddress
import fr.acinq.phoenix.android.settings.displayseed.DisplaySeedView
import fr.acinq.phoenix.android.settings.fees.AdvancedIncomingFeePolicy
import fr.acinq.phoenix.android.settings.fees.LiquidityPolicyView
Expand Down Expand Up @@ -376,6 +377,7 @@ fun AppView(
},
onChannelClick = { navController.navigate("${Screen.ChannelDetails.route}?id=$it") },
onImportChannelsDataClick = { navController.navigate(Screen.ImportChannelsData.route)},
onSpendFromChannelBalance = { navController.navigate(Screen.SpendChannelAddress.route)},
)
}
composable(
Expand All @@ -388,6 +390,9 @@ fun AppView(
composable(Screen.ImportChannelsData.route) {
ImportChannelsData(onBackClick = { navController.popBackStack() })
}
composable(Screen.SpendChannelAddress.route) {
SpendFromChannelAddress(onBackClick = { navController.popBackStack() })
}
composable(Screen.MutualClose.route) {
MutualCloseView(onBackClick = { navController.popBackStack() })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ sealed class Screen(val route: String) {
data object Channels : Screen("settings/channels")
data object ChannelDetails : Screen("settings/channeldetails")
data object ImportChannelsData : Screen("settings/importchannels")
data object SpendChannelAddress : Screen("settings/spendchanneladdress")
data object MutualClose : Screen("settings/mutualclose")
data object ForceClose : Screen("settings/forceclose")
data object Preferences : Screen("settings/preferences")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ fun ChannelsView(
onBackClick: () -> Unit,
onChannelClick: (String) -> Unit,
onImportChannelsDataClick: () -> Unit,
onSpendFromChannelBalance: () -> Unit,
) {
val channelsState by business.peerManager.channelsFlow.collectAsState()
val balance by business.balanceManager.balance.collectAsState()
Expand All @@ -78,10 +79,10 @@ fun ChannelsView(
Box(contentAlignment = Alignment.TopEnd) {
DropdownMenu(expanded = showAdvancedMenuPopIn, onDismissRequest = { showAdvancedMenuPopIn = false }) {
DropdownMenuItem(onClick = onImportChannelsDataClick, contentPadding = PaddingValues(horizontal = 12.dp)) {
Text(
text = stringResource(R.string.channelsview_menu_import_channels),
style = MaterialTheme.typography.body1,
)
Text(text = stringResource(R.string.channelsview_menu_import_channels), style = MaterialTheme.typography.body1)
}
DropdownMenuItem(onClick = onSpendFromChannelBalance, contentPadding = PaddingValues(horizontal = 12.dp)) {
Text(text = stringResource(R.string.channelsview_menu_spend_channel_balance), style = MaterialTheme.typography.body1)
}
}
Button(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import fr.acinq.phoenix.android.business
import fr.acinq.phoenix.android.components.*
import fr.acinq.phoenix.android.components.feedback.ErrorMessage
import fr.acinq.phoenix.android.utils.positiveColor
import fr.acinq.phoenix.utils.import.ChannelsImportResult
import fr.acinq.phoenix.utils.channels.ChannelsImportResult

@Composable
fun ImportChannelsData(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import androidx.lifecycle.viewModelScope
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.managers.NodeParamsManager
import fr.acinq.phoenix.managers.PeerManager
import fr.acinq.phoenix.utils.import.ChannelsImportHelper
import fr.acinq.phoenix.utils.import.ChannelsImportResult
import fr.acinq.phoenix.utils.channels.ChannelsImportHelper
import fr.acinq.phoenix.utils.channels.ChannelsImportResult
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.phoenix.android.settings.channels

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.business
import fr.acinq.phoenix.android.components.AmountInput
import fr.acinq.phoenix.android.components.Button
import fr.acinq.phoenix.android.components.Card
import fr.acinq.phoenix.android.components.DefaultScreenHeader
import fr.acinq.phoenix.android.components.DefaultScreenLayout
import fr.acinq.phoenix.android.components.FilledButton
import fr.acinq.phoenix.android.components.ProgressView
import fr.acinq.phoenix.android.components.TextInput
import fr.acinq.phoenix.android.components.feedback.ErrorMessage
import fr.acinq.phoenix.android.utils.copyToClipboard
import fr.acinq.phoenix.data.BitcoinUnit

@Composable
fun SpendFromChannelAddress(
onBackClick: () -> Unit,
) {
val vm = viewModel<SpendFromChannelAddressViewModel>(factory = SpendFromChannelAddressViewModel.Factory(business))
val state = vm.state.value

var amount by remember { mutableStateOf<MilliSatoshi?>(null) }
var txIndex by remember { mutableStateOf("") }
var channelData by remember { mutableStateOf("") }
var remoteFundingPubkey by remember { mutableStateOf("") }
var unsignedTx by remember { mutableStateOf("") }

DefaultScreenLayout {
DefaultScreenHeader(onBackClick = onBackClick, title = stringResource(id = R.string.spendchanneladdress_title))
Card(internalPadding = PaddingValues(16.dp)) {
Text(text = stringResource(id = R.string.spendchanneladdress_instructions))
Spacer(modifier = Modifier.height(24.dp))

// amount
AmountInput(
amount = amount,
onAmountChange = {
if (it?.amount != amount) vm.resetState()
amount = it?.amount
},
staticLabel = stringResource(id = R.string.spendchanneladdress_amount),
forceUnit = BitcoinUnit.Sat,
enabled = state.canProcess,
)
Spacer(modifier = Modifier.height(16.dp))

// tx index
TextInput(
text = txIndex,
onTextChange = {newValue ->
if (newValue != txIndex) { vm.resetState() }
newValue.toLongOrNull()?.let { txIndex = newValue }
},
staticLabel = stringResource(id = R.string.spendchanneladdress_tx_index),
minLines = 1,
maxLines = 1,
enabled = state.canProcess,
keyboardType = KeyboardType.Number
)
Spacer(modifier = Modifier.height(16.dp))

// encrypted channel data
TextInput(
text = channelData,
onTextChange = {
if (it != channelData) { vm.resetState() }
channelData = it
},
staticLabel = stringResource(id = R.string.spendchanneladdress_channel_data),
minLines = 2,
maxLines = 4,
enabled = state.canProcess
)
Spacer(modifier = Modifier.height(16.dp))

// remote funding pubkey
TextInput(
text = remoteFundingPubkey,
onTextChange = {
if (it != remoteFundingPubkey) { vm.resetState() }
remoteFundingPubkey = it
},
staticLabel = stringResource(id = R.string.spendchanneladdress_remote_funding_pubkey),
minLines = 1,
maxLines = 2,
enabled = state.canProcess
)
Spacer(modifier = Modifier.height(16.dp))

// unsigned tx
TextInput(
text = unsignedTx,
onTextChange = {
if (it != unsignedTx) { vm.resetState() }
unsignedTx = it
},
staticLabel = stringResource(id = R.string.spendchanneladdress_unsigned_tx),
minLines = 1,
maxLines = 3,
enabled = state.canProcess
)
}

Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state) {
is SpendFromChannelAddressViewState.Init, is SpendFromChannelAddressViewState.Error -> {
if (state is SpendFromChannelAddressViewState.Error) {
ErrorMessage(
header = stringResource(id = R.string.spendchanneladdress_error_generic),
details = when (state) {
is SpendFromChannelAddressViewState.Error.Generic -> state.cause.localizedMessage
is SpendFromChannelAddressViewState.Error.AmountMissing -> {
stringResource(id = R.string.spendchanneladdress_error_amount)
}
is SpendFromChannelAddressViewState.Error.TxIndexMalformed -> {
stringResource(id = R.string.spendchanneladdress_error_tx_index)
}
is SpendFromChannelAddressViewState.Error.ChannelDataMalformed -> {
stringResource(id = R.string.spendchanneladdress_error_channel_data)
}
is SpendFromChannelAddressViewState.Error.ChannelDataDecryption -> {
stringResource(id = R.string.spendchanneladdress_error_channel_data)
}
is SpendFromChannelAddressViewState.Error.ChannelDataUnhandledState -> {
stringResource(id = R.string.spendchanneladdress_error_channel_data_state, state.stateClassName ?: "??")
}
is SpendFromChannelAddressViewState.Error.ChannelDataUnhandledVersion -> {
stringResource(id = R.string.spendchanneladdress_error_channel_data_version, state.version)
}
is SpendFromChannelAddressViewState.Error.PublicKeyMalformed -> {
stringResource(id = R.string.spendchanneladdress_error_remote_funding_pubkey, state.details)
}
is SpendFromChannelAddressViewState.Error.TransactionMalformed -> {
stringResource(id = R.string.spendchanneladdress_error_tx, state.details)
}
},
alignment = Alignment.CenterHorizontally
)
}

Card {
FilledButton(
text = stringResource(id = R.string.spendchanneladdress_sign_button),
icon = R.drawable.ic_build,
onClick = { vm.spendFromChannelAddress(amount?.truncateToSatoshi(), txIndex.toLongOrNull(), channelData, remoteFundingPubkey, unsignedTx) },
modifier = Modifier.fillMaxWidth(),
shape = RectangleShape
)
}
}
is SpendFromChannelAddressViewState.Processing -> {
ProgressView(text = stringResource(id = R.string.spendchanneladdress_signing))
}
is SpendFromChannelAddressViewState.SignedTransaction -> {
val context = LocalContext.current
Card {
Spacer(modifier = Modifier.height(8.dp))
SigLabelValue(label = stringResource(id = R.string.spendchanneladdress_success_pubkey), value = state.pubkey.toHex())
SigLabelValue(label = stringResource(id = R.string.spendchanneladdress_success_signature), value = state.signature.toHex())
Spacer(modifier = Modifier.height(16.dp))
Button(
text = stringResource(id = R.string.btn_copy),
icon = R.drawable.ic_copy,
modifier = Modifier.fillMaxWidth(),
onClick = {
copyToClipboard(
context = context,
data = """
pubkey=${state.pubkey.toHex()}
signature=${state.signature.toHex()}
""".trimIndent(),
dataLabel = "channel outpoint spending data"
)
}
)
}
}
}
}
Spacer(modifier = Modifier.height(80.dp))
}
}

@Composable
private fun SigLabelValue(label: String, value: String) {
Row(
modifier = Modifier.padding(PaddingValues(horizontal = 16.dp, vertical = 6.dp)),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(text = label, style = MaterialTheme.typography.body2, modifier = Modifier.width(100.dp))
Text(text = value)
}
}
Loading

0 comments on commit aeca2d8

Please sign in to comment.