Skip to content
Closed
5 changes: 3 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ androidComponents {
"USDT_RPC_URL",
localProp("USDT_RPC_URL").orElse("https://bitkit.to/v1/usdt/chain-rpc").stringField(),
)
buildConfigFields.put("PRIMARY_NODE_URI", localProp("PRIMARY_NODE_URI").orElse("").stringField())
buildConfigFields.put("E2E", e2eEnv.booleanField())
buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField())
buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField())
Expand Down Expand Up @@ -556,7 +557,8 @@ dependencies {
// Crypto
implementation(libs.bouncycastle.provider.jdk)
testImplementation(libs.bouncycastle.pkix.jdk) // test certificates
implementation(libs.ldk.node.android) { exclude(group = "net.java.dev.jna", module = "jna") }
implementation(libs.ldk.node.android) { artifact { type = "aar" } }
implementation(libs.atomicfu) // ldk-node-android runtime dependency; the release asset carries no POM
val localBitkitCoreAar = providers.gradleProperty("bitkitCoreAar").orNull
if (localBitkitCoreAar != null) {
debugImplementation(files(localBitkitCoreAar))
Expand All @@ -568,7 +570,6 @@ dependencies {
implementation(libs.paykit)
implementation(libs.vss.client)
if (localBitkitCoreAar == null) nativeDebugSymbols(libs.bitkit.core.nativeDebugSymbolsArtifact())
nativeDebugSymbols(libs.ldk.node.android.nativeDebugSymbolsArtifact())
nativeDebugSymbols(libs.paykit.nativeDebugSymbolsArtifact())
nativeDebugSymbols(libs.vss.client.nativeDebugSymbolsArtifact())
// Firebase
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ data class SettingsData(
val coinSelectPreference: CoinSelectionPreference = CoinSelectionPreference.BranchAndBound,
val electrumServer: String = Env.electrumServerUrl,
val rgsServerUrl: String? = Env.ldkRgsServerUrl,
/** The paired primary node's URI; blank means the network's default primary, see [Env.defaultPrimaryNodeUri]. */
val primaryNodeUri: String = "",
/** Set by a transfer to savings, so confirmed savings are not moved back into the primary channel. */
val primaryFundingPaused: Boolean = false,
val selectedAddressType: String = DEFAULT_ADDRESS_TYPE_STRING,
val addressTypesToMonitor: List<String> = listOf(DEFAULT_ADDRESS_TYPE_STRING),
val pendingRestoreAddressTypePrune: Boolean = false,
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ internal object Env {
const val e2eHomegateUrl = BuildConfig.E2E_HOMEGATE_URL
val e2eHomeserverPubky = BuildConfig.E2E_HOMESERVER_PUBKY.takeIf { isLocalE2eBackend && it.isNotBlank() }
val network = Network.valueOf(BuildConfig.NETWORK)

/**
* The primary node a wallet uses until the user pairs another one in Settings.
* A `PRIMARY_NODE_URI` build property overrides it, for regtest runs against a local primary.
*/
val defaultPrimaryNodeUri: String get() = when {
BuildConfig.PRIMARY_NODE_URI.isNotBlank() -> BuildConfig.PRIMARY_NODE_URI
network == Network.BITCOIN -> PRIMARY_NODE_URI_MAINNET
else -> ""
}
val locales = BuildConfig.LOCALES.split(",")
const val walletSyncIntervalSecs = 10_uL
const val walletSyncTimeoutSecs = 10_uL
Expand Down Expand Up @@ -164,6 +174,11 @@ internal object Env {
const val BITKIT_TELEGRAM = "https://t.me/bitkitchat"
const val BITKIT_GITHUB = "https://github.com/synonymdev"
const val BITKIT_HELP_CENTER = "https://help.bitkit.to"

/** Beignet primary run for the Lightning-first mainnet test, reached through a public TCP relay. */
const val PRIMARY_NODE_URI_MAINNET =
"024a6cc632544189896cca8e06e375bdb1d1f34a71a4c25d09a39cf0ed920a299c@bore.pub:39101"

const val BLOCKTANK_SUNSET_URL = "https://blog.bitkit.to/"
const val TERMS_OF_USE_URL = "https://bitkit.to/terms-of-use"
const val PRIVACY_POLICY_URL = "https://bitkit.to/privacy-policy"
Expand Down
73 changes: 73 additions & 0 deletions app/src/main/java/to/bitkit/models/PrimaryNode.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package to.bitkit.models

import androidx.compose.runtime.Immutable
import org.bouncycastle.asn1.sec.SECNamedCurves
import org.lightningdevkit.ldknode.PeerDetails
import to.bitkit.env.Env
import to.bitkit.ext.fromHex
import to.bitkit.utils.AppError
import java.net.URI
import java.util.Locale

/**
* The Lightning node that serves this wallet: it opens a channel just in time for the first receive,
* grows it by splicing, and is the wallet's only Lightning peer.
*/
@Immutable
data class PrimaryNode(
val nodeId: String,
val address: String? = null,
) {
val uri: String get() = address?.let { "$nodeId@$it" } ?: nodeId

companion object {
private val publicKeyPattern = Regex("^(02|03)[0-9a-fA-F]{64}$")
private val hostnamePattern = Regex("^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$")
private val uriPrefix = Regex("^(?:lightning:/{0,2}|node://)", RegexOption.IGNORE_CASE)
private val curve = SECNamedCurves.getByName("secp256k1").curve

/** Parses a `pubkey` or `pubkey@host:port` node URI, as pasted or scanned. */
fun parse(input: String): PrimaryNode {
val value = input.trim().replaceFirst(uriPrefix, "")
val parts = value.split('@')
if (parts.size !in 1..2 || value.any(Char::isWhitespace)) throw PrimaryNodeError.InvalidInput()
val nodeId = parts.first().lowercase(Locale.ROOT)
if (!publicKeyPattern.matches(nodeId) || !isValidPublicKey(nodeId)) throw PrimaryNodeError.InvalidInput()
return PrimaryNode(nodeId, parts.getOrNull(1)?.let(::parseAddress))
}

fun parseOrNull(input: String): PrimaryNode? = runCatching { parse(input) }.getOrNull()

/** The primary the wallet uses: the paired one, else the network's default. */
fun effective(savedUri: String): PrimaryNode? = parseOrNull(savedUri.ifBlank { Env.defaultPrimaryNodeUri })

private fun isValidPublicKey(value: String): Boolean = runCatching {
val point = curve.decodePoint(value.fromHex())
point.isValid && !point.isInfinity
}.getOrDefault(false)

private fun parseAddress(value: String): String {
val endpoint = runCatching { URI("tcp://$value") }.getOrNull() ?: throw PrimaryNodeError.InvalidInput()
val host = endpoint.host ?: throw PrimaryNodeError.InvalidInput()
val isClean = endpoint.userInfo == null && endpoint.path.isNullOrEmpty() &&
endpoint.query == null && endpoint.fragment == null
if (endpoint.port !in 1..MAX_VALID_PORT || !isClean ||
(!host.startsWith('[') && !hostnamePattern.matches(host))
) {
throw PrimaryNodeError.InvalidInput()
}
return "${host.lowercase(Locale.ROOT)}:${endpoint.port}"
}
}
}

fun PrimaryNode.toPeerDetails(): PeerDetails? = address?.let {
PeerDetails(nodeId = nodeId, address = it, isConnected = false, isPersisted = true)
}

sealed class PrimaryNodeError(message: String) : AppError(message) {
class InvalidInput : PrimaryNodeError("Enter a valid Lightning node public key or publickey@host:port URI.")
class AddressUnknown : PrimaryNodeError("No address is known for this node. A full node URI is required.")
class ConnectionInUse : PrimaryNodeError("Close the channel with the current primary node before changing it.")
class NotConfigured : PrimaryNodeError("No primary node is set.")
}
10 changes: 9 additions & 1 deletion app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,26 @@ sealed interface ReceiveAdditionalLiquidityAction {

/** More inbound capacity needs a new channel, and Blocktank no longer sells them. */
data object TransferUnavailable : ReceiveAdditionalLiquidityAction

/** The primary node opens or grows the channel for this receive, once the user approves its fee. */
data object PrimaryReceive : ReceiveAdditionalLiquidityAction
}

data class ReceiveAdditionalLiquidityParams(
val source: ReceiveLiquiditySource,
val invoiceAmountSats: ULong,
val inboundCapacitySats: ULong?,
val hasPrimaryNode: Boolean = false,
)

object ReceiveLiquidityDecision {
fun canCreateLightningInvoice(
hasReadyChannels: Boolean,
inboundCapacitySats: ULong?,
invoiceAmountSats: ULong?,
hasPrimaryInvoice: Boolean = false,
): Boolean {
if (hasPrimaryInvoice) return true
if (!hasReadyChannels || inboundCapacitySats == null) return false

if (invoiceAmountSats == null || invoiceAmountSats == 0uL) {
Expand All @@ -36,9 +42,11 @@ object ReceiveLiquidityDecision {

fun additionalLiquidityAction(params: ReceiveAdditionalLiquidityParams): ReceiveAdditionalLiquidityAction {
return when {
params.source != ReceiveLiquiditySource.SPENDING -> ReceiveAdditionalLiquidityAction.None
params.source == ReceiveLiquiditySource.SAVINGS -> ReceiveAdditionalLiquidityAction.None
!needsInboundLiquidity(params.invoiceAmountSats, params.inboundCapacitySats) ->
ReceiveAdditionalLiquidityAction.None
params.hasPrimaryNode && params.invoiceAmountSats > 0uL -> ReceiveAdditionalLiquidityAction.PrimaryReceive
params.source != ReceiveLiquiditySource.SPENDING -> ReceiveAdditionalLiquidityAction.None
(params.inboundCapacitySats ?: 0uL) == 0uL -> ReceiveAdditionalLiquidityAction.None
else -> ReceiveAdditionalLiquidityAction.TransferUnavailable
}
Expand Down
62 changes: 62 additions & 0 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import org.lightningdevkit.ldknode.PaymentDetails
import org.lightningdevkit.ldknode.PaymentHash
import org.lightningdevkit.ldknode.PaymentId
import org.lightningdevkit.ldknode.PeerDetails
import org.lightningdevkit.ldknode.PrimaryReceiveQuote
import org.lightningdevkit.ldknode.SpendableUtxo
import org.lightningdevkit.ldknode.Txid
import to.bitkit.async.appScope
Expand All @@ -79,6 +80,7 @@ import to.bitkit.models.ElectrumServer
import to.bitkit.models.NATIVE_WITNESS_TYPES
import to.bitkit.models.NodeLifecycleState
import to.bitkit.models.OpenChannelResult
import to.bitkit.models.PrimaryNode
import to.bitkit.models.TransactionSpeed
import to.bitkit.models.WalletScope
import to.bitkit.models.safe
Expand Down Expand Up @@ -821,6 +823,14 @@ class LightningRepo @Inject constructor(
}

is Event.ChannelClosed -> scope.launch { registerClosedChannel(event.channelId, event.reason) }
is Event.PaymentReceived,
is Event.BalanceChanged,
is Event.PaymentSuccessful,
is Event.PaymentFailed,
is Event.SplicePending,
is Event.SpliceFailed,
-> scope.launch { refreshChannelsAndPeers() }

else -> Unit
}
}
Expand Down Expand Up @@ -1300,6 +1310,37 @@ class LightningRepo @Inject constructor(
runCatching { lightningService.receive(amountSats, description, expirySeconds) }
}

/** The primary node the running node was built with. */
val primaryNode: PrimaryNode? get() = lightningService.primaryNode

suspend fun quotePrimaryReceive(amountSats: ULong): Result<PrimaryReceiveQuote> =
executeWhenNodeRunning("quotePrimaryReceive") {
runSuspendCatching { lightningService.quotePrimaryReceive(amountSats) }.also { refreshChannelsAndPeers() }
}

suspend fun createPrimaryInvoice(
amountSats: ULong,
description: String,
maxFeeMsat: ULong,
): Result<String> = executeWhenNodeRunning("createPrimaryInvoice") {
requireWalletRestoreComplete()
runSuspendCatching { lightningService.receiveViaPrimary(amountSats, description, maxFeeMsat) }
}

suspend fun checkPrimaryConnection(nodeId: String): Result<Unit> = executeWhenNodeRunning(
"checkPrimaryConnection"
) {
runSuspendCatching { lightningService.checkPrimaryConnection(nodeId) }.also { refreshChannelsAndPeers() }
}

suspend fun hasPendingFundingOperation(): Result<Boolean> = executeWhenNodeRunning("hasPendingFundingOperation") {
runSuspendCatching { lightningService.hasPendingFundingOperation() }
}

suspend fun channelFundingFeeRate(): Result<ULong> = executeWhenNodeRunning("channelFundingFeeRate") {
runSuspendCatching { lightningService.channelFundingFeeRate() }
}

suspend fun createInvoiceMsats(
amountMsats: ULong,
description: String,
Expand Down Expand Up @@ -2090,6 +2131,27 @@ class LightningRepo @Inject constructor(
}
}

suspend fun spliceIn(
channel: ChannelDetails,
amountSats: ULong,
): Result<Unit> = executeWhenNodeRunning("spliceIn") {
lightningService.spliceIn(channel, amountSats).also {
syncState()
}
}

suspend fun spliceOut(
channel: ChannelDetails,
address: String,
amountSats: ULong,
feeRateSatsPerVByte: ULong,
): Result<Unit> = executeWhenNodeRunning("spliceOut") {
requireWalletRestoreComplete()
lightningService.spliceOut(channel, address, amountSats, feeRateSatsPerVByte).also {
syncState()
}
}

suspend fun closeChannel(
channel: ChannelDetails,
force: Boolean = false,
Expand Down
Loading
Loading