Skip to content

Commit

Permalink
Fix BIP353 implementation details
Browse files Browse the repository at this point in the history
- accept BIP353 address starting with ₿
- skip LNURL resolution for address starting with ₿
- accept `lno` offer parameter in BIP21 uris
- do not reject BIP21 uris without an address

on Android:
- display the ₿ prefix in BIP353 address
- copying the address now includes the ₿ prefix
- add a button to copy the BIP21 uri
- allow the user to pay the offer in bip21 uris

Fixes #588
  • Loading branch information
dpad85 committed Jul 12, 2024
1 parent aef7896 commit 58d67c8
Show file tree
Hide file tree
Showing 16 changed files with 244 additions and 72 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ fun TextInput(
onTextChange: (String) -> Unit,
textFieldColors: TextFieldColors = outlinedTextFieldColors(),
showResetButton: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text
) {
val charsCount by remember(text) { mutableStateOf(text.length) }
val focusManager = LocalFocusManager.current
Expand All @@ -81,7 +82,7 @@ fun TextInput(
singleLine = singleLine,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Text
keyboardType = keyboardType
),
label = null,
placeholder = placeholder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidViewBinding
Expand Down Expand Up @@ -157,8 +158,8 @@ fun ScanDataView(
SendOfferView(offer = model.offer, trampolineFees = trampolineFees, onBackClick = onBackClick, onPaymentSent = onProcessingFinished)
}
is Scan.Model.OnchainFlow -> {
val paymentRequest = model.uri.paymentRequest
if (paymentRequest == null) {
val lightningRequest = model.uri.paymentRequest?.write() ?: model.uri.offer?.encode()
if (lightningRequest == null) {
SendSpliceOutView(
requestedAmount = model.uri.amount,
address = model.uri.address,
Expand All @@ -171,11 +172,11 @@ fun ScanDataView(
ChoosePaymentModeDialog(
onPayOffchainClick = {
showPaymentModeDialog = true
postIntent(Scan.Intent.Parse(request = paymentRequest.write()))
postIntent(Scan.Intent.Parse(request = lightningRequest))
},
onPayOnchainClick = {
showPaymentModeDialog = true
postIntent(Scan.Intent.Parse(request = model.uri.copy(paymentRequest = null).write()))
postIntent(Scan.Intent.Parse(request = model.uri.copy(paymentRequest = null, offer = null).write()))
},
onDismiss = {
showPaymentModeDialog = false
Expand Down Expand Up @@ -428,6 +429,8 @@ private fun ManualInputDialog(
minLines = 2,
maxLines = 3,
staticLabel = stringResource(id = R.string.scan_manual_input_label),
placeholder = { Text(text = stringResource(id = R.string.scan_manual_input_hint)) },
keyboardType = KeyboardType.Email,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ fun Bip353AddressDisplay(address: String?) {
address.isNullOrBlank() -> {}
else -> {
SelectionContainer {
Text(text = address, style = MaterialTheme.typography.body2)
Text(text = stringResource(id = R.string.utils_bip353_with_prefix, address), style = MaterialTheme.typography.body2)
}
Spacer(modifier = Modifier.height(16.dp))
}
Expand All @@ -161,19 +161,24 @@ private fun CopyAddressDialog(
containerColor = MaterialTheme.colors.surface,
contentColor = MaterialTheme.colors.onSurface,
scrimColor = MaterialTheme.colors.onBackground.copy(alpha = 0.2f),
//dragHandle = null
) {
Column(Modifier.padding(bottom = 50.dp)) {
Button(
text = "Copy Bolt12 code",
text = stringResource(id = R.string.receive_offer_copy_bip353),
icon = R.drawable.ic_copy,
onClick = { copyToClipboard(context, data = context.getString(R.string.utils_bip353_with_prefix, address)) },
modifier = Modifier.fillMaxWidth(),
)
Button(
text = stringResource(id = R.string.receive_offer_copy_bolt12),
icon = R.drawable.ic_copy,
onClick = { copyToClipboard(context, data = offer) },
modifier = Modifier.fillMaxWidth(),
)
Button(
text = "Copy address",
text = stringResource(id = R.string.receive_offer_copy_bip21),
icon = R.drawable.ic_copy,
onClick = { copyToClipboard(context, data = address) },
onClick = { copyToClipboard(context, data = "bitcoin:?lno=$offer") },
modifier = Modifier.fillMaxWidth(),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,14 @@ private fun ClaimAddressButton(
is ClaimAddressState.Done -> {
val context = LocalContext.current
Setting(
title = state.address,
title = stringResource(id = R.string.utils_bip353_with_prefix, state.address),
leadingIcon = { PhoenixIcon(R.drawable.ic_arobase) },
trailingIcon = { Button(icon = R.drawable.ic_copy, onClick = { copyToClipboard(context, state.address) }) },
trailingIcon = {
Button(
icon = R.drawable.ic_copy,
onClick = { copyToClipboard(context, context.getString(R.string.utils_bip353_with_prefix, state.address)) }
)
},
subtitle = {
Text(text = stringResource(id = R.string.bip353_subtitle))
Spacer(modifier = Modifier.height(4.dp))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import fr.acinq.bitcoin.BitcoinError
import fr.acinq.bitcoin.Satoshi
import fr.acinq.bitcoin.Transaction
import fr.acinq.bitcoin.utils.Either
Expand Down Expand Up @@ -83,18 +84,26 @@ class SwapInRefundViewModel(
is Either.Right -> {
val keyManager = walletManager.keyManager.filterNotNull().first()
val swapInWallet = peerManager.swapInWallet.filterNotNull().first()
val res = swapInWallet.spendExpiredSwapIn(
swapInKeys = keyManager.swapInOnChainWallet,
scriptPubKey = parseAddress.value.script,
feerate = FeeratePerKw(feerate)
)
state = if (res == null) {
log.error("could not generate a swap-in refund transaction")
SwapInRefundState.Done.Failed.CannotCreateTx
val script = parseAddress.value.script
if (script == null) {
state = SwapInRefundState.Done.Failed.InvalidAddress(
address = parseAddress.value.address,
error = BitcoinUriError.InvalidScript(BitcoinError.InvalidScript)
)
} else {
val (tx, fee) = res
log.info("estimated fee=$fee for swap-in refund")
SwapInRefundState.ReviewFee(fees = fee, transaction = tx)
val res = swapInWallet.spendExpiredSwapIn(
swapInKeys = keyManager.swapInOnChainWallet,
scriptPubKey = script,
feerate = FeeratePerKw(feerate)
)
state = if (res == null) {
log.error("could not generate a swap-in refund transaction")
SwapInRefundState.Done.Failed.CannotCreateTx
} else {
val (tx, fee) = res
log.info("estimated fee=$fee for swap-in refund")
SwapInRefundState.ReviewFee(fees = fee, transaction = tx)
}
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions phoenix-android/src/main/res/values/important_strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@
<string name="receive_offer_howto_details_5">Check that Android does not apply a battery saving mode to Phoenix. Otherwise, you won\'t be able to receive payments if the app is closed or in the background. Enabling TOR in Phoenix will also cause issues.</string>
<string name="receive_offer_howto_details_faq_link">Learn more</string>

<string name="receive_offer_copy_bip353">Copy human-readable address</string>
<string name="receive_offer_copy_bolt12">Copy payment code</string>
<string name="receive_offer_copy_bip21">Copy full URI</string>

<!-- receive: Tor warning -->

<string name="receive_tor_warning_title">Tor is enabled</string>
Expand Down
7 changes: 5 additions & 2 deletions phoenix-android/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,9 @@
<string name="scan_bip353_resolving">Resolving payment request over DNS…</string>

<string name="scan_manual_input_title">Manual input</string>
<string name="scan_manual_input_instructions">Enter a Lightning invoice, LNURL or Lightning address you want to send money to.</string>
<string name="scan_manual_input_label">Invoice or LN address</string>
<string name="scan_manual_input_instructions">Enter a Lightning invoice, an offer, a LNURL or a Lightning address you want to send money to.</string>
<string name="scan_manual_input_label">Invoice, LN address…</string>
<string name="scan_manual_input_hint" translatable="false">₿satoshi@domain.com</string>

<!-- utils: misc strings -->

Expand All @@ -214,6 +215,8 @@
<string name="utils_loading_prefs">Loading preferences…</string>
<string name="utils_copied">Copied to clipboard!</string>

<string name="utils_bip353_with_prefix" translatable="false">₿%1$s</string>

<string name="accessibility_link">Open link in a browser</string>
<string name="accessibility_explorer_link">Open transaction in an explorer</string>
<string name="accessibility_address_explorer_link">Open address in an explorer</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,35 +20,45 @@ import fr.acinq.bitcoin.BitcoinError
import fr.acinq.bitcoin.Chain
import fr.acinq.bitcoin.utils.Either
import fr.acinq.bitcoin.utils.Try
import fr.acinq.lightning.*
import fr.acinq.lightning.Lightning
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.TrampolineFees
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.io.PayInvoice
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.debug
import fr.acinq.lightning.utils.*
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.controllers.AppController
import fr.acinq.phoenix.data.*
import fr.acinq.phoenix.data.lnurl.*
import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow
import fr.acinq.phoenix.managers.*
import fr.acinq.phoenix.utils.Parser
import fr.acinq.lightning.logging.error
import fr.acinq.lightning.logging.info
import fr.acinq.lightning.logging.warning
import fr.acinq.lightning.payment.Bolt11Invoice
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampSeconds
import fr.acinq.lightning.wire.OfferTypes
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.controllers.AppController
import fr.acinq.phoenix.data.BitcoinUri
import fr.acinq.phoenix.data.BitcoinUriError
import fr.acinq.phoenix.data.LnurlPayMetadata
import fr.acinq.phoenix.data.WalletPaymentId
import fr.acinq.phoenix.data.WalletPaymentMetadata
import fr.acinq.phoenix.data.lnurl.Lnurl
import fr.acinq.phoenix.data.lnurl.LnurlAuth
import fr.acinq.phoenix.data.lnurl.LnurlError
import fr.acinq.phoenix.data.lnurl.LnurlPay
import fr.acinq.phoenix.data.lnurl.LnurlWithdraw
import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow
import fr.acinq.phoenix.managers.DatabaseManager
import fr.acinq.phoenix.managers.LnurlManager
import fr.acinq.phoenix.managers.PeerManager
import fr.acinq.phoenix.utils.DnsResolvers
import fr.acinq.phoenix.utils.Parser
import io.ktor.http.Url
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.charsets.Charsets
import kotlinx.coroutines.*
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull
Expand Down Expand Up @@ -528,17 +538,15 @@ class AppScanController(
val username = components[0].lowercase()
val domain = components[1]

return resolveBip353Offer(username, domain)?.let {
Either.Left(it)
} ?: Either.Right(Lnurl.Request(Url("https://$domain/.well-known/lnurlp/$username"), tag = Lnurl.Tag.Pay))
}
val signalBip353 = username.startsWith("")
val cleanUsername = username.dropWhile { it == '' }

val bip353HttpClient: HttpClient by lazy {
HttpClient {
install(ContentNegotiation) {
json(json = Json { ignoreUnknownKeys = true })
expectSuccess = true
}
val offer = resolveBip353Offer(cleanUsername, domain)
return if (signalBip353) {
offer?.let { Either.Left(it) } // skip lnurl resolution if it's a bip353 address
} else {
offer?.let { Either.Left(it) }
?: Either.Right(Lnurl.Request(Url("https://$domain/.well-known/lnurlp/$username"), tag = Lnurl.Tag.Pay))
}
}

Expand All @@ -550,26 +558,22 @@ class AppScanController(
model(Scan.Model.ResolvingBip353)
val dnsPath = "$username.user._bitcoin-payment.$domain."

// list of resolvers: https://dnsprivacy.org/public_resolvers/
val url = Url("https://dns.google/resolve?name=$dnsPath&type=TXT")

try {
val response = bip353HttpClient.get(url)
val json = Json.decodeFromString<JsonObject>(response.bodyAsText(Charsets.UTF_8))
logger.info { "dns resolved to ${json.toString().take(100)}" }
val json = DnsResolvers.getRandom().getTxtRecord(dnsPath)
logger.debug { "dns resolved to ${json.toString().take(100)}" }

val status = json["Status"]?.jsonPrimitive?.intOrNull
if (status == null || status > 0) throw RuntimeException("invalid status=$status")

val ad = json["AD"]?.jsonPrimitive?.booleanOrNull
if (ad != true) {
logger.info { "AD false, abort dns lookup to $url" }
logger.info { "AD false, abort dns lookup" }
return null
}

val records = json["Answer"]?.jsonArray
if (records.isNullOrEmpty()) {
logger.debug { "no records for $url" }
logger.debug { "no records for $dnsPath" }
return null
}

Expand All @@ -586,7 +590,7 @@ class AppScanController(
return when (val offer = OfferTypes.Offer.decode(offerString)) {
is Try.Success -> { offer.result }
is Try.Failure -> {
model(Scan.Model.BadRequest(request = url.toString(), reason = Scan.BadRequestReason.InvalidBip353(dnsPath)))
model(Scan.Model.BadRequest(request = dnsPath, reason = Scan.BadRequestReason.InvalidBip353(dnsPath)))
null
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,22 @@ import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.Chain
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.payment.Bolt11Invoice
import fr.acinq.lightning.wire.OfferTypes
import io.ktor.http.*

data class BitcoinUri(
val chain: Chain,
/** Actual Bitcoin address; may be different than the source, e.g. if the source is an URI like "bitcoin:xyz?param=123". */
val address: String,
val script: ByteVector,
val script: ByteVector?,
// Bip-21 parameters
val label: String? = null,
val message: String? = null,
/** Amount requested in the URI. */
val amount: Satoshi? = null,
/** A Bitcoin URI may contain a Lightning payment request as an alternative way to make the payment. */
/** A Bitcoin URI may contain a Bolt11 invoice or an offer as an alternative way to make the payment. */
val paymentRequest: Bolt11Invoice? = null,
val offer: OfferTypes.Offer? = null,
/** Other bip-21 parameters in the URI that we do not handle. */
val ignoredParams: Parameters = Parameters.Empty,
) {
Expand Down
Loading

0 comments on commit 58d67c8

Please sign in to comment.