rename example

This commit is contained in:
2026-06-01 03:09:19 +02:00
parent 7f3d14a764
commit b5473bfafc
59 changed files with 261 additions and 182 deletions
@@ -0,0 +1,43 @@
package com.bitcointxoko.gudariwallet.data
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.api.RouteHintHop
/**
* Domain model classes for the wallet layer.
*
* These are the types returned by [WalletRepository] methods — deliberately
* decoupled from the raw API response models in [com.bitcointxoko.gudariwallet.api].
*/
data class WalletBalance(
val sats: Long
)
data class CreatedInvoice(
val bolt11 : String,
val paymentHash : String,
val expiry : String?
)
data class DecodedBolt11(
val amountSats : Long,
val memo : String,
val payee : String?,
val routeHints : List<RouteHintHop> = emptyList()
)
data class ScannedLnurl(
val tag : String?,
val callback : String?,
val minSats : Long,
val maxSats : Long,
val description : String,
val domain : String,
val commentAllowed : Int,
val rawRes : LnurlScanResponse
)
data class PaymentConfirmation(
val paymentHash: String
)
@@ -0,0 +1,19 @@
package com.bitcointxoko.gudariwallet.data
import com.bitcointxoko.gudariwallet.service.NodeAliasService
import kotlinx.coroutines.flow.StateFlow
/**
* [NodeAliasRepository] backed by [NodeAliasService], which queries
* mempool.space then 1ml.com and caches results in SharedPreferences.
*/
class LNbitsNodeAliasRepository(
private val service: NodeAliasService
) : NodeAliasRepository {
override val aliases: StateFlow<Map<String, String>>
get() = service.aliases
override suspend fun resolve(pubkey: String): String? =
service.resolve(pubkey)
}
@@ -0,0 +1,260 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import android.net.Uri
import android.util.Log
import com.bitcointxoko.gudariwallet.api.*
import com.bitcointxoko.gudariwallet.api.LNbitsClient.httpClient
import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.LnurlBech32
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
import com.bitcointxoko.gudariwallet.util.WalletConstants
private const val TAG = "LNbitsWalletRepo"
class LNbitsWalletRepository(
private val api: LNbitsApi,
private val secrets: SecretStore,
private val context : Context,
private val nodeAliasService: com.bitcointxoko.gudariwallet.service.NodeAliasService =
com.bitcointxoko.gudariwallet.service.NodeAliasService(httpClient, context),
) : WalletRepository {
// ── Balance ───────────────────────────────────────────────────────────────
override suspend fun getBalance(): WalletBalance {
val response = api.getWallet(secrets.invoiceKey)
return WalletBalance(sats = response.balance / WalletConstants.MSAT_PER_SAT)
}
// ── Receive ───────────────────────────────────────────────────────────────
override suspend fun createInvoice(amountSats: Long, memo: String): CreatedInvoice {
val response = api.createInvoice(
secrets.invoiceKey,
CreateInvoiceRequest(
out = false,
amount = amountSats,
memo = memo.ifBlank { "Gudari Wallet" }
)
)
return CreatedInvoice(
bolt11 = response.paymentRequest,
paymentHash = response.paymentHash,
expiry = response.expiry
)
}
override suspend fun isPaymentReceived(paymentHash: String): Boolean {
return api.checkPayment(secrets.invoiceKey, paymentHash).paid
}
// ── Send ──────────────────────────────────────────────────────────────────
override suspend fun decodeBolt11(bolt11: String): DecodedBolt11 {
val response = api.decodeInvoice(secrets.invoiceKey, DecodeInvoiceRequest(data = bolt11))
val amountSats = response.amountMsat
?.takeIf { it > 0L }
?.div(WalletConstants.MSAT_PER_SAT)
?: throw UnsupportedOperationException(
"Zero-amount invoices are not supported. Please ask the sender to specify an amount."
)
return DecodedBolt11(
amountSats = amountSats,
memo = response.description ?: "",
payee = response.payee?.takeIf { it.isNotBlank() },
routeHints = response.routeHints?.flatten() ?: emptyList()
)
}
override suspend fun scanLnurl(code: String): ScannedLnurl {
val r = api.lnurlScan(secrets.invoiceKey, code)
return ScannedLnurl(
tag = r.tag,
callback = r.callback,
minSats = (r.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
maxSats = (r.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
description = LnurlMetadataParser.parseDescription(r.metadata),
domain = LnurlMetadataParser.extractDomain(r.callback),
commentAllowed = r.commentAllowed ?: 0,
rawRes = r
)
}
override suspend fun fetchLnurlDirect(httpsUrl: String): LnurlScanResponse {
return api.fetchLnurlMetadata(httpsUrl)
}
/**
* Client-side LNURL/Lightning Address resolution.
* - Lightning Address (user@domain) → GET https://domain/.well-known/lnurlp/user
* - Bech32 LNURL → decode → GET decoded https URL
* Throws on any failure so the caller can fall back to the server proxy.
*/
override suspend fun scanLnurlDirect(code: String): ScannedLnurl {
val httpsUrl = when {
// Lightning Address: user@domain.com
'@' in code -> {
val parts = code.trim().split("@")
require(parts.size == 2) { "Invalid Lightning Address: $code" }
val (user, domain) = parts
"https://$domain/.well-known/lnurlp/$user"
}
// Bech32 LNURL: decode to https URL
else -> {
LnurlBech32.decode(code)
?: throw IllegalArgumentException("Could not decode LNURL: $code")
}
}
Log.d(TAG, "LNURL [DIRECT SCAN] $code$httpsUrl")
val r = api.fetchLnurlMetadata(httpsUrl)
return ScannedLnurl(
tag = r.tag,
callback = r.callback,
minSats = (r.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
maxSats = (r.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
description = LnurlMetadataParser.parseDescription(r.metadata),
domain = LnurlMetadataParser.extractDomain(r.callback),
commentAllowed = r.commentAllowed ?: 0,
rawRes = r
)
}
/**
* Client-side LNURL-pay: fetches the invoice from the callback URL directly,
* then pays it via the standard bolt11 path.
* Throws on any failure so the caller can fall back to the server proxy.
*/
override suspend fun payLnurlDirect(
rawRes : LnurlScanResponse,
amountMsat: Long,
comment : String?
): PaymentConfirmation {
val bolt11 = fetchLnurlCallbackInvoice(rawRes, amountMsat, comment)
Log.d(TAG, "LNURL [DIRECT PAY ] got invoice, paying bolt11")
return payBolt11(bolt11)
}
/**
* Fetches the bolt11 invoice from the LNURL callback URL without paying it.
* Reuses the same URL-building and error-handling logic as [payLnurlDirect],
* but returns the raw `pr` string instead of proceeding to payment.
*/
override suspend fun fetchLnurlCallbackInvoice(
rawRes : LnurlScanResponse,
amountMsat: Long,
comment : String?
): String {
val callback = requireNotNull(rawRes.callback) { "LNURL callback is null" }
val callbackUrl = buildString {
append(callback)
append(if ('?' in callback) '&' else '?')
append("amount=")
append(amountMsat)
if (!comment.isNullOrBlank()) {
append("&comment=")
append(android.net.Uri.encode(comment))
}
}
Log.d(TAG, "LNURL [FETCH INVOICE] callback=$callbackUrl")
val callbackResponse = api.fetchLnurlCallback(callbackUrl)
if (callbackResponse.status == "ERROR") {
throw RuntimeException(
callbackResponse.reason ?: "LNURL callback returned an error"
)
}
return callbackResponse.pr.ifBlank {
throw RuntimeException("LNURL callback returned an empty invoice")
}
}
override suspend fun payBolt11(bolt11: String): PaymentConfirmation {
val response = api.payInvoice(
secrets.adminKey,
PayInvoiceRequest(out = true, bolt11 = bolt11)
)
return PaymentConfirmation(paymentHash = response.paymentHash)
}
override suspend fun payLnurl(
rawRes: LnurlScanResponse,
lnurl: String,
amountMsat: Long,
comment: String?
): PaymentConfirmation {
val response = api.payLnurl(
secrets.adminKey,
PayLnurlRequest(
res = LnurlPayRes(
tag = rawRes.tag,
callback = rawRes.callback,
minSendable = rawRes.minSendable,
maxSendable = rawRes.maxSendable,
metadata = rawRes.metadata,
commentAllowed = rawRes.commentAllowed,
allowsNostr = rawRes.allowsNostr,
nostrPubkey = rawRes.nostrPubkey
),
lnurl = lnurl,
amount = amountMsat,
comment = comment
)
)
return PaymentConfirmation(paymentHash = response.paymentHash)
}
override suspend fun executeWithdraw(
callback: String,
k1 : String,
bolt11 : String
): WithdrawCallbackResponse {
val url = buildString {
append(callback)
append(if ('?' in callback) '&' else '?')
append("k1=")
append(Uri.encode(k1))
append("&pr=")
append(Uri.encode(bolt11))
}
Log.d(TAG, "LNURL-WITHDRAW [CALLBACK] $url")
return api.withdrawCallback(url)
}
override suspend fun getLightningAddress(): String? {
val links = api.getLnurlpLinks(secrets.invoiceKey, allWallets = false)
val username = links.firstOrNull { !it.username.isNullOrBlank() }?.username
?: return null
val domain = secrets.baseUrl
.removePrefix("https://")
.removePrefix("http://")
.trimEnd('/')
.substringBefore("/")
return "$username@$domain"
}
// ── Fiat ──────────────────────────────────────────────────────────────────
override suspend fun getFiatRate(currency: String): Double {
Log.d(TAG, "FIAT [RATE FETCH ] currency=$currency")
return api.getFiatRate(currency).rate
}
override suspend fun getSupportedCurrencies(): List<String> {
Log.d(TAG, "FIAT [CURRENCIES] fetching supported currency list")
return api.getSupportedCurrencies()
}
// ── History ───────────────────────────────────────────────────────────────
override suspend fun getPayments(offset: Int, limit: Int): List<PaymentRecord> {
return api.getPayments(secrets.invoiceKey, limit, offset)
}
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
return api.getPaymentDetail(secrets.invoiceKey, checkingId)
}
}
@@ -0,0 +1,123 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
private const val TAG = "LnurlCacheRepository"
data class LnurlCacheEntry(
val key : String,
val response : LnurlScanResponse,
val savedAt : Long
)
class LnurlCacheRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
WalletConstants.LNURL_CACHE_PREFS_NAME, Context.MODE_PRIVATE
)
private val gson = Gson()
// In-memory map for fast access within a session
private val memCache = mutableMapOf<String, LnurlCacheEntry>()
init { loadFromDisk() }
/**
* Returns a cached [LnurlScanResponse] for [key], or null if:
* - no entry exists
* - the entry is older than [WalletConstants.LNURL_CACHE_TTL_MS]
* - the server marked the endpoint as disposable (null is treated as true per LUD-06)
*/
fun get(key: String): LnurlScanResponse? {
val entry = memCache[key] ?: return null
val ageMs = System.currentTimeMillis() - entry.savedAt
if (ageMs > WalletConstants.LNURL_CACHE_TTL_MS) {
Log.d(TAG, "LNURL [CACHE STALE] $key (age ${ageMs / 1000}s)")
memCache.remove(key)
persistToDisk()
return null
}
// LUD-06: disposable == null means treat as true — do not serve from cache
val disposable = entry.response.disposable
if (disposable == null || disposable == true) {
Log.d(TAG, "LNURL [DISPOSABLE ] $key — not serving from cache (disposable=$disposable)")
return null
}
Log.d(TAG, "LNURL [CACHE HIT ] $key (age ${ageMs / 1000}s)")
return entry.response
}
/**
* Stores [response] under [key].
* Silently skips if the response is marked disposable (or disposable is null).
*/
fun put(key: String, response: LnurlScanResponse) {
val disposable = response.disposable
if (disposable == null || disposable == true) {
Log.d(TAG, "LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
return
}
val entry = LnurlCacheEntry(key, response, System.currentTimeMillis())
memCache[key] = entry
persistToDisk()
Log.d(TAG, "LNURL [CACHE SET ] $key")
}
/**
* Removes the entry for [key] from both memory and disk.
* Called when a payment attempt with cached data fails, so the next
* scan will always fetch fresh metadata from the server.
*/
fun invalidate(key: String) {
if (memCache.remove(key) != null) {
persistToDisk()
Log.d(TAG, "LNURL [INVALIDATED] $key")
}
}
fun clearAll() {
memCache.clear()
prefs.edit().clear().apply()
Log.d(TAG, "LNURL [CLEARED ] all entries")
}
// ── Persistence ───────────────────────────────────────────────────────────
private fun loadFromDisk() {
val json = prefs.getString(WalletConstants.LNURL_CACHE_PREFS_KEY, null) ?: return
runCatching {
val type = object : TypeToken<List<LnurlCacheEntry>>() {}.type
val list: List<LnurlCacheEntry> = gson.fromJson(json, type)
val now = System.currentTimeMillis()
var loaded = 0
list.forEach { entry ->
if (now - entry.savedAt < WalletConstants.LNURL_CACHE_TTL_MS) {
memCache[entry.key] = entry
loaded++
}
}
Log.d(TAG, "LNURL [LOADED ] $loaded valid entries from disk (${list.size - loaded} expired)")
}.onFailure {
Log.w(TAG, "LNURL [LOAD ERR ] ${it.message}")
}
}
private fun persistToDisk() {
runCatching {
prefs.edit()
.putString(WalletConstants.LNURL_CACHE_PREFS_KEY, gson.toJson(memCache.values.toList()))
.apply()
}.onFailure {
Log.w(TAG, "LNURL [PERSIST ERR] ${it.message}")
}
}
}
@@ -0,0 +1,21 @@
package com.bitcointxoko.gudariwallet.data
import kotlinx.coroutines.flow.StateFlow
/**
* Alias resolution concern, separated from the payment/wallet domain.
*
* Implementations are expected to cache results (in-memory and/or on disk)
* and expose the full resolved alias map as a [StateFlow] for observers.
*/
interface NodeAliasRepository {
/** Live map of pubkey → resolved alias for all pubkeys resolved so far. */
val aliases: StateFlow<Map<String, String>>
/**
* Resolves the human-readable alias for [pubkey].
* Returns the alias string, or null if resolution fails.
* Results are cached — repeated calls for the same pubkey are cheap.
*/
suspend fun resolve(pubkey: String): String?
}
@@ -0,0 +1,87 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.bitcointxoko.gudariwallet.api.GsonProvider
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.api.SuccessAction
import com.bitcointxoko.gudariwallet.api.SuccessActionAdapter
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
private const val TAG = "PaymentCacheRepository"
class PaymentCacheRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
WalletConstants.PAYMENTS_PREFS_NAME, Context.MODE_PRIVATE
)
private val gson = GsonProvider.gson
// ── In-memory detail cache — immutable once confirmed, no TTL needed ──────
private val detailCache = mutableMapOf<String, PaymentDetailResponse>()
// ── Payment list ──────────────────────────────────────────────────────────
/** Load the persisted first-page list. Returns empty list if nothing cached or cache is stale. */
fun loadCachedPayments(): List<PaymentRecord> {
val json = prefs.getString(WalletConstants.PAYMENTS_PREFS_KEY, null) ?: return emptyList()
val savedAt = prefs.getLong(WalletConstants.PAYMENTS_PREFS_SAVED_AT, 0L)
val ageMs = System.currentTimeMillis() - savedAt
if (ageMs > WalletConstants.PAYMENTS_CACHE_TTL_MS) {
Log.d(TAG, "PAYMENTS [CACHE STALE] age ${ageMs / 1000}s — ignoring persisted list")
return emptyList()
}
return runCatching {
val type: java.lang.reflect.Type = object : TypeToken<List<PaymentRecord>>() {}.type
val list: List<PaymentRecord> = gson.fromJson(json, type)
Log.d(TAG, "PAYMENTS [CACHE HIT ] loaded ${list.size} payments (age ${ageMs / 1000}s)")
list
}.getOrElse {
Log.w(TAG, "PAYMENTS [CACHE ERR ] failed to deserialise: ${it.message}")
emptyList()
}
}
/** Persist the first page of payments to SharedPreferences. */
fun savePayments(payments: List<PaymentRecord>) {
runCatching {
prefs.edit()
.putString(WalletConstants.PAYMENTS_PREFS_KEY, gson.toJson(payments))
.putLong(WalletConstants.PAYMENTS_PREFS_SAVED_AT, System.currentTimeMillis())
.apply()
Log.d(TAG, "PAYMENTS [PERSIST ] saved ${payments.size} payments")
}.onFailure {
Log.w(TAG, "PAYMENTS [PERSIST ] write failed: ${it.message}")
}
}
// ── Payment detail ────────────────────────────────────────────────────────
/** Returns a cached detail response, or null if not yet fetched this session. */
fun getCachedDetail(checkingId: String): PaymentDetailResponse? {
return detailCache[checkingId]?.also {
Log.d(TAG, "PAYMENTS [DETAIL HIT] $checkingId")
}
}
/** Store a detail response in the in-memory cache. */
fun saveDetail(checkingId: String, detail: PaymentDetailResponse) {
detailCache[checkingId] = detail
Log.d(TAG, "PAYMENTS [DETAIL SET] $checkingId")
}
/** Clear everything — useful for testing and logout. */
fun clearAll() {
detailCache.clear()
prefs.edit()
.remove(WalletConstants.PAYMENTS_PREFS_KEY)
.remove(WalletConstants.PAYMENTS_PREFS_SAVED_AT)
.apply()
Log.d(TAG, "PAYMENTS [CLEARED ] cache wiped")
}
}
@@ -0,0 +1,42 @@
package com.bitcointxoko.gudariwallet.data
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.WithdrawCallbackResponse
/**
* Contract for all wallet operations.
* Domain model types are defined in [DomainModels.kt].
*/
interface WalletRepository {
suspend fun getBalance(): WalletBalance
suspend fun createInvoice(amountSats: Long, memo: String): CreatedInvoice
suspend fun isPaymentReceived(paymentHash: String): Boolean
suspend fun decodeBolt11(bolt11: String): DecodedBolt11
suspend fun scanLnurl(code: String): ScannedLnurl
suspend fun fetchLnurlDirect(httpsUrl: String): LnurlScanResponse
suspend fun scanLnurlDirect(code: String): ScannedLnurl
suspend fun payLnurlDirect(
rawRes : LnurlScanResponse,
amountMsat: Long,
comment : String?
): PaymentConfirmation
suspend fun fetchLnurlCallbackInvoice(
rawRes: LnurlScanResponse,
amountMsat: Long,
comment: String?
): String
suspend fun payBolt11(bolt11: String): PaymentConfirmation
suspend fun payLnurl(rawRes: LnurlScanResponse, lnurl: String, amountMsat: Long, comment: String?): PaymentConfirmation
suspend fun executeWithdraw(
callback: String,
k1 : String,
bolt11 : String
): WithdrawCallbackResponse
suspend fun getLightningAddress(): String?
suspend fun getPayments(offset: Int, limit: Int): List<PaymentRecord>
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
suspend fun getFiatRate(currency: String): Double
suspend fun getSupportedCurrencies(): List<String>
}