Skip to content

Commit

Permalink
(android) Add swap-in input signing tool
Browse files Browse the repository at this point in the history
This tool is for debugging purposes. It lets users sign a
swap-in input locked in the potentiam scheme, and unlock
it in cooperation with the peer.
  • Loading branch information
dpad85 committed Jan 26, 2024
1 parent bc8035f commit 6066677
Show file tree
Hide file tree
Showing 13 changed files with 353 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import fr.acinq.phoenix.android.settings.fees.AdvancedIncomingFeePolicy
import fr.acinq.phoenix.android.settings.fees.LiquidityPolicyView
import fr.acinq.phoenix.android.payments.liquidity.RequestLiquidityView
import fr.acinq.phoenix.android.settings.walletinfo.FinalWalletInfo
import fr.acinq.phoenix.android.settings.walletinfo.SwapInSignerView
import fr.acinq.phoenix.android.settings.walletinfo.SwapInWalletInfo
import fr.acinq.phoenix.android.settings.walletinfo.WalletInfoView
import fr.acinq.phoenix.android.startup.LegacySwitcherView
Expand Down Expand Up @@ -400,8 +401,12 @@ fun AppView(
SwapInWalletInfo(
onBackClick = { navController.popBackStack() },
onViewChannelPolicyClick = { navController.navigate(Screen.LiquidityPolicy.route) },
onAdvancedClick = { navController.navigate(Screen.WalletInfo.SwapInSigner.route) },
)
}
composable(Screen.WalletInfo.SwapInSigner.route) {
SwapInSignerView(onBackClick = { navController.popBackStack() })
}
composable(Screen.WalletInfo.FinalWallet.route) {
FinalWalletInfo(onBackClick = { navController.popBackStack() })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ sealed class Screen(val route: String) {
object Logs : Screen("settings/logs")
object WalletInfo : Screen("settings/walletinfo") {
object SwapInWallet: Screen("settings/walletinfo/swapin")
object SwapInSigner: Screen("settings/walletinfo/swapinsigner")
object FinalWallet: Screen("settings/walletinfo/final")
}
object LiquidityPolicy: Screen("settings/liquiditypolicy")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,14 @@ fun AmountInput(
staticLabel: String?,
placeholder: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
forceUnit: CurrencyUnit? = null,
) {

val context = LocalContext.current
val prefBitcoinUnit = LocalBitcoinUnit.current
val prefFiat = LocalFiatCurrency.current
val rate = fiatRate
val units = listOf<CurrencyUnit>(BitcoinUnit.Sat, BitcoinUnit.Bit, BitcoinUnit.MBtc, BitcoinUnit.Btc, prefFiat)
val units = forceUnit?.let { listOf(it) } ?: listOf<CurrencyUnit>(BitcoinUnit.Sat, BitcoinUnit.Bit, BitcoinUnit.MBtc, BitcoinUnit.Btc, prefFiat)
val focusManager = LocalFocusManager.current
val customTextSelectionColors = TextSelectionColors(
handleColor = MaterialTheme.colors.primary.copy(alpha = 0.7f),
Expand Down Expand Up @@ -257,7 +258,7 @@ fun AmountInput(
colors = outlinedTextFieldColors(),
interactionSource = interactionSource,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.padding(bottom = 8.dp, top = if (staticLabel != null) 14.dp else 0.dp)
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp, top = if (staticLabel != null) 14.dp else 0.dp)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.utils.annotatedStringResource
import fr.acinq.phoenix.android.utils.negativeColor
import fr.acinq.phoenix.android.utils.spannableStringToAnnotatedString

@Composable
fun ErrorMessage(
Expand All @@ -39,8 +40,8 @@ fun ErrorMessage(
header = header,
details = when (details) {
null -> null
is AnnotatedString -> annotatedStringResource(id = R.string.component_error_message_details, details)
else -> stringResource(id = R.string.component_error_message_details, details.toString())
is AnnotatedString -> spannableStringToAnnotatedString(details)
else -> details.toString()
},
icon = R.drawable.ic_alert_triangle,
iconColor = negativeColor,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* 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.walletinfo

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.collectAsState
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.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.phoenix.android.LocalBitcoinUnit
import fr.acinq.phoenix.android.business
import fr.acinq.phoenix.android.components.DefaultScreenHeader
import fr.acinq.phoenix.android.components.DefaultScreenLayout
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.components.AmountInput
import fr.acinq.phoenix.android.components.BorderButton
import fr.acinq.phoenix.android.components.Button
import fr.acinq.phoenix.android.components.Card
import fr.acinq.phoenix.android.components.InlineSatoshiInput
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.settings.channels.ImportChannelsDataViewModel
import fr.acinq.phoenix.android.utils.copyToClipboard
import fr.acinq.phoenix.data.BitcoinUnit


@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SwapInSignerView(
onBackClick: () -> Unit,
) {
val context = LocalContext.current
val vm = viewModel<SwapInSignerViewModel>(factory = SwapInSignerViewModel.Factory(business.walletManager))

var amountInput by remember { mutableStateOf<MilliSatoshi?>(null) }
var txInput by remember { mutableStateOf("") }

DefaultScreenLayout(isScrollable = true) {
DefaultScreenHeader(
onBackClick = onBackClick,
title = stringResource(id = R.string.swapin_signer_title),
)

Card(internalPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)) {
val state = vm.state.value
Text(text = stringResource(id = R.string.swapin_signer_instructions))
Spacer(modifier = Modifier.height(16.dp))
AmountInput(
amount = amountInput,
onAmountChange = {
amountInput = it?.amount
if (state != SwapInSignerState.Init) vm.state.value = SwapInSignerState.Init
},
staticLabel = stringResource(id = R.string.swapin_signer_amount),
enabled = state !is SwapInSignerState.Signing,
modifier = Modifier.fillMaxWidth(),
forceUnit = BitcoinUnit.Sat,
)
Spacer(modifier = Modifier.height(8.dp))
TextInput(
text = txInput,
onTextChange = {
txInput = it
if (state != SwapInSignerState.Init) vm.state.value = SwapInSignerState.Init
},
staticLabel = stringResource(id = R.string.swapin_signer_tx),
maxLines = 4,
enabled = state !is SwapInSignerState.Signing,
errorMessage = if (txInput.isBlank()) stringResource(id = R.string.validation_empty) else null
)
}

val keyboardManager = LocalSoftwareKeyboardController.current
Card(horizontalAlignment = Alignment.CenterHorizontally) {
when (val state = vm.state.value) {
is SwapInSignerState.Init -> {
Button(
text = stringResource(id = R.string.swapin_signer_sign),
icon = R.drawable.ic_check,
onClick = {
if (amountInput != null && txInput.isNotBlank()) {
vm.sign(unsignedTx = txInput, amount = amountInput!!.truncateToSatoshi())
keyboardManager?.hide()
}
},
modifier = Modifier.fillMaxWidth()
)
}
is SwapInSignerState.Signing -> {
ProgressView(text = stringResource(id = R.string.swapin_signer_signing))
}
is SwapInSignerState.Signed -> {
Column(modifier = Modifier.padding(PaddingValues(horizontal = 16.dp, vertical = 12.dp))) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = stringResource(id = R.string.swapin_signer_signed_sig), style = MaterialTheme.typography.body2, modifier = Modifier.width(100.dp))
Text(text = state.userSig)
}
Spacer(modifier = Modifier.height(16.dp))
BorderButton(
text = stringResource(id = R.string.btn_copy),
icon = R.drawable.ic_copy,
onClick = {
copyToClipboard(
context = context,
data = """
user_sig=${state.userSig}
""".trimIndent(),
dataLabel = "swap input signature"
)
}
)
}
}
is SwapInSignerState.Failed.Error -> {
ErrorMessage(
header = stringResource(id = R.string.swapin_signer_error_header),
details = state.cause.message ?: state.cause::class.java.simpleName,
modifier = Modifier.fillMaxWidth(),
)
}
is SwapInSignerState.Failed.InvalidTxInput -> {
ErrorMessage(
header = stringResource(id = R.string.swapin_signer_invalid_tx_header),
details = stringResource(id = R.string.swapin_signer_invalid_tx_details),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* 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.walletinfo

import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.Satoshi
import fr.acinq.bitcoin.Transaction
import fr.acinq.bitcoin.TxOut
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.crypto.KeyManager
import fr.acinq.lightning.crypto.LocalKeyManager
import fr.acinq.lightning.transactions.Transactions
import fr.acinq.phoenix.managers.NodeParamsManager
import fr.acinq.phoenix.managers.PeerManager
import fr.acinq.phoenix.managers.WalletManager
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory

sealed class SwapInSignerState {
object Init : SwapInSignerState()
object Signing : SwapInSignerState()
data class Signed(
val amount: Satoshi,
val txId: String,
val userSig: String,
) : SwapInSignerState()
sealed class Failed : SwapInSignerState() {
data class InvalidTxInput(val cause: Throwable) : Failed()
data class Error(val cause: Throwable) : Failed()
}
}

class SwapInSignerViewModel(
val walletManager: WalletManager,
) : ViewModel() {

val log = LoggerFactory.getLogger(this::class.java)
val state = mutableStateOf<SwapInSignerState>(SwapInSignerState.Init)

fun sign(
unsignedTx: String,
amount: Satoshi,
) {
if (state.value == SwapInSignerState.Signing) return
state.value = SwapInSignerState.Signing

viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e ->
log.error("failed to sign tx=$unsignedTx for amount=$amount : ", e)
state.value = SwapInSignerState.Failed.Error(e)
}) {
log.debug("signing tx=$unsignedTx amount=$amount")
val tx = try {
Transaction.read(unsignedTx)
} catch (e: Exception) {
log.error("invalid transaction input: ", e)
state.value = SwapInSignerState.Failed.InvalidTxInput(e)
return@launch
}
val keyManager = walletManager.keyManager.filterNotNull().first()
val userSig = Transactions.signSwapInputUser(
fundingTx = tx,
index = 0,
parentTxOut = TxOut(amount, ByteVector.empty),
userKey = keyManager.swapInOnChainWallet.userPrivateKey,
serverKey = keyManager.swapInOnChainWallet.remoteServerPublicKey,
refundDelay = 144 * 30 * 6
)
state.value = SwapInSignerState.Signed(
amount = amount,
txId = tx.txid.toString(),
userSig = userSig.toString()
)
}
}

class Factory(
private val walletManager: WalletManager,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return SwapInSignerViewModel(walletManager) as T
}
}
}
Loading

0 comments on commit 6066677

Please sign in to comment.