rename example
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
package com.bitcointxoko.gudariwallet.api
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
|
||||
/**
|
||||
* Single source of truth for the app's Gson instance.
|
||||
*
|
||||
* All serialization/deserialization — Retrofit, SharedPreferences caches,
|
||||
* and WebSocket message parsing — must use this instance so that registered
|
||||
* type adapters (e.g. [SuccessActionAdapter]) are applied consistently
|
||||
* everywhere.
|
||||
*
|
||||
* To add a new adapter: register it here once. It will automatically apply
|
||||
* to every deserialization path in the app.
|
||||
*/
|
||||
object GsonProvider {
|
||||
val gson: Gson = GsonBuilder()
|
||||
.registerTypeAdapter(SuccessAction::class.java, SuccessActionAdapter())
|
||||
.create()
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.bitcointxoko.gudariwallet.api
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Url
|
||||
|
||||
interface LNbitsApi {
|
||||
|
||||
/** GET /api/v1/wallet — returns balance and wallet info */
|
||||
@GET("api/v1/wallet")
|
||||
suspend fun getWallet(
|
||||
@Header("X-Api-Key") apiKey: String
|
||||
): WalletResponse
|
||||
|
||||
/** POST /api/v1/payments — creates a BOLT-11 invoice */
|
||||
@POST("api/v1/payments")
|
||||
suspend fun createInvoice(
|
||||
@Header("X-Api-Key") apiKey: String,
|
||||
@Body request: CreateInvoiceRequest
|
||||
): CreateInvoiceResponse
|
||||
|
||||
/**
|
||||
* GET /api/v1/payments/{checking_id}
|
||||
* Returns paid status and details for a specific payment hash.
|
||||
* Auth: invoice key is sufficient.
|
||||
*/
|
||||
@GET("api/v1/payments/{checking_id}")
|
||||
suspend fun checkPayment(
|
||||
@Header("X-Api-Key") apiKey: String,
|
||||
@Path("checking_id") checkingId: String
|
||||
): PaymentStatusResponse
|
||||
|
||||
/** POST /api/v1/payments/pay — pays a BOLT-11 invoice */
|
||||
@POST("api/v1/payments")
|
||||
suspend fun payInvoice(
|
||||
@Header("X-Api-Key") adminKey: String,
|
||||
@Body request: PayInvoiceRequest
|
||||
): PayInvoiceResponse
|
||||
|
||||
/** GET /api/v1/payments — returns payment history */
|
||||
@GET("api/v1/payments")
|
||||
suspend fun getPayments(
|
||||
@Header("X-Api-Key") apiKey: String
|
||||
): List<WsPayment>
|
||||
|
||||
/** POST /api/v1/payments/decode — returns decoded payment request */
|
||||
@POST("api/v1/payments/decode")
|
||||
suspend fun decodeInvoice(
|
||||
@Header("X-Api-Key") invoiceKey: String,
|
||||
@Body request: DecodeInvoiceRequest
|
||||
): DecodeInvoiceResponse
|
||||
|
||||
// Unified scanner — accepts LNURL, Lightning Address, OR bolt11
|
||||
@GET("api/v1/lnurlscan/{code}")
|
||||
suspend fun lnurlScan(
|
||||
@Header("X-Api-Key") invoiceKey: String,
|
||||
@Path("code") code: String
|
||||
): LnurlScanResponse
|
||||
|
||||
// Pay an LNURL-pay (after scanning)
|
||||
@POST("api/v1/payments/lnurl")
|
||||
suspend fun payLnurl(
|
||||
@Header("X-Api-Key") adminKey: String,
|
||||
@Body body: PayLnurlRequest
|
||||
): PayLnurlResponse
|
||||
|
||||
// LNURL-withdraw callback (dynamic URL, per LUD-03)
|
||||
@GET
|
||||
suspend fun withdrawCallback(
|
||||
@Url url: String
|
||||
): WithdrawCallbackResponse
|
||||
|
||||
// Direct fetch of a raw LNURL metadata URL (LUD-17: lnurlp://, lnurlw://)
|
||||
@GET
|
||||
suspend fun fetchLnurlMetadata(
|
||||
@Url url: String
|
||||
): LnurlScanResponse
|
||||
|
||||
/**
|
||||
* GET {callback}?amount={msat}&comment={comment}
|
||||
* Fetches a BOLT-11 invoice from an LNURL-pay callback URL directly.
|
||||
* This is a raw @Url call — bypasses LNbits server-side proxy entirely.
|
||||
*/
|
||||
@GET
|
||||
suspend fun fetchLnurlCallback(
|
||||
@Url url: String
|
||||
): LnurlCallbackResponse
|
||||
|
||||
|
||||
/**
|
||||
* GET /lnurlp/api/v1/links?all_wallets=false
|
||||
* Returns all LNURLp pay links for the current wallet.
|
||||
* Auth: invoice key is sufficient.
|
||||
*/
|
||||
@GET("lnurlp/api/v1/links")
|
||||
suspend fun getLnurlpLinks(
|
||||
@Header("X-Api-Key") invoiceKey: String,
|
||||
@Query("all_wallets") allWallets: Boolean = false
|
||||
): List<LnurlpLink>
|
||||
|
||||
// Get historical payments
|
||||
@GET("api/v1/payments")
|
||||
suspend fun getPayments(
|
||||
@Header("X-Api-Key") invoiceKey: String,
|
||||
@Query("limit") limit: Int,
|
||||
@Query("offset") offset: Int
|
||||
): List<PaymentRecord>
|
||||
|
||||
// Get payment detail
|
||||
@GET("api/v1/payments/{checking_id}")
|
||||
suspend fun getPaymentDetail(
|
||||
@Header("X-Api-Key") invoiceKey: String,
|
||||
@Path("checking_id") checkingId: String
|
||||
): PaymentDetailResponse
|
||||
|
||||
/**
|
||||
* Returns the current BTC price in [currency].
|
||||
* Example: GET /api/v1/rate/USD → {"rate": 43250.50}
|
||||
* No X-Api-Key header needed.
|
||||
*/
|
||||
@GET("api/v1/rate/{currency}")
|
||||
suspend fun getFiatRate(
|
||||
@Path("currency") currency: String
|
||||
): FiatRateResponse
|
||||
|
||||
/**
|
||||
* Returns the list of supported ISO 4217 currency codes.
|
||||
* Example: GET /api/v1/currencies → ["USD","EUR","GBP",...]
|
||||
* No X-Api-Key header needed.
|
||||
*/
|
||||
@GET("api/v1/currencies")
|
||||
suspend fun getSupportedCurrencies(): List<String>
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.bitcointxoko.gudariwallet.api
|
||||
|
||||
import com.bitcointxoko.gudariwallet.BuildConfig
|
||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object LNbitsClient {
|
||||
|
||||
// Single shared client — used by both Retrofit and WebSocket
|
||||
val httpClient: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS) // time to establish TCP connection
|
||||
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS) // time to wait for data (longer for WebSocket pings)
|
||||
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS) // time to send a request body
|
||||
.apply {
|
||||
// Only log in debug builds — prevents API keys leaking in production logs
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
})
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
fun create(baseUrl: String): LNbitsApi {
|
||||
val url = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(url)
|
||||
.client(httpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson))
|
||||
.build()
|
||||
.create(LNbitsApi::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.bitcointxoko.gudariwallet.api
|
||||
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.google.gson.stream.JsonToken
|
||||
import com.google.gson.stream.JsonWriter
|
||||
|
||||
// ── Wallet ────────────────────────────────────────────────────────────────────
|
||||
data class WalletResponse(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val balance: Long // millisatoshis
|
||||
)
|
||||
|
||||
// ── Create invoice ────────────────────────────────────────────────────────────
|
||||
data class CreateInvoiceRequest(
|
||||
val out: Boolean,
|
||||
val amount: Long, // satoshis
|
||||
val memo: String
|
||||
)
|
||||
|
||||
data class CreateInvoiceResponse(
|
||||
@SerializedName("payment_hash") val paymentHash: String,
|
||||
@SerializedName("payment_request") val paymentRequest: String,
|
||||
@SerializedName("expiry") val expiry : String?
|
||||
)
|
||||
|
||||
// ── Pay invoice ───────────────────────────────────────────────────────────────
|
||||
data class PayInvoiceRequest(
|
||||
val out: Boolean,
|
||||
val bolt11: String
|
||||
)
|
||||
|
||||
data class PayInvoiceResponse(
|
||||
@SerializedName("payment_hash") val paymentHash: String,
|
||||
@SerializedName("checking_id") val checkingId: String
|
||||
)
|
||||
|
||||
// ── Check payment ─────────────────────────────────────────────────────────────
|
||||
data class PaymentStatusResponse(
|
||||
val paid: Boolean,
|
||||
val details: PaymentDetails? = null
|
||||
)
|
||||
|
||||
data class PaymentDetails(
|
||||
val amount: Long?,
|
||||
val fee: Long?,
|
||||
val memo: String?
|
||||
)
|
||||
|
||||
// ── Decode BOLT-11 ────────────────────────────────────────────────────────────
|
||||
data class DecodeInvoiceRequest(
|
||||
val data: String
|
||||
)
|
||||
|
||||
data class DecodeInvoiceResponse(
|
||||
@SerializedName("payment_hash") val paymentHash : String? = null,
|
||||
@SerializedName("amount_msat") val amountMsat : Long? = null,
|
||||
@SerializedName("description") val description : String? = null,
|
||||
@SerializedName("payee") val payee : String? = null,
|
||||
@SerializedName("route_hints") val routeHints : List<List<RouteHintHop>>? = null
|
||||
)
|
||||
|
||||
|
||||
data class RouteHintHop(
|
||||
@SerializedName("public_key") val publicKey : String,
|
||||
@SerializedName("short_channel_id") val shortChannelId : String? = null,
|
||||
@SerializedName("base_fee") val baseFeeMsat : Long = 0,
|
||||
@SerializedName("ppm_fee") val ppmFee : Long = 0,
|
||||
@SerializedName("cltv_expiry_delta") val cltvExpiryDelta : Int = 0
|
||||
)
|
||||
|
||||
// ── LNURL scan ────────────────────────────────────────────────────────────────
|
||||
data class LnurlScanResponse(
|
||||
val tag: String?,
|
||||
// ── payRequest fields ────────────────────────────────────────────────────
|
||||
val callback: String? = null,
|
||||
@SerializedName("minSendable") val minSendable: Long? = null,
|
||||
@SerializedName("maxSendable") val maxSendable: Long? = null,
|
||||
val metadata: String? = null,
|
||||
@SerializedName("commentAllowed") val commentAllowed: Int? = null,
|
||||
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
|
||||
@SerializedName("nostrPubkey") val nostrPubkey: String? = null,
|
||||
val bolt11: String? = null,
|
||||
@SerializedName("amount_msat") val amountMsat: Long? = null,
|
||||
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
|
||||
// ── withdrawRequest fields ───────────────────────────────────────────────
|
||||
val k1 : String? = null,
|
||||
@SerializedName("defaultDescription")
|
||||
val defaultDescription: String? = null,
|
||||
@SerializedName("minWithdrawable")
|
||||
val minWithdrawable : Long? = null,
|
||||
@SerializedName("maxWithdrawable")
|
||||
val maxWithdrawable : Long? = null,
|
||||
@SerializedName("balanceCheck")
|
||||
val balanceCheck : String? = null,
|
||||
@SerializedName("currentBalance")
|
||||
val currentBalance : Long? = null,
|
||||
// ── Error fields ─────────────────────────────────────────────────────────
|
||||
val status : String? = null, // "OK" or "ERROR"
|
||||
val reason : String? = null // error message when status == "ERROR"
|
||||
)
|
||||
|
||||
// ── LNURL-pay callback response (LUD-06) ─────────────────────────────────────
|
||||
data class LnurlCallbackResponse(
|
||||
val pr : String, // BOLT-11 invoice to pay
|
||||
val routes : List<Any>? = null,
|
||||
@SerializedName("successAction")
|
||||
val successAction : SuccessAction? = null,
|
||||
// Error fields (server may return status=ERROR instead of pr)
|
||||
val status : String? = null,
|
||||
val reason : String? = null
|
||||
)
|
||||
|
||||
|
||||
// ── LNURL pay ─────────────────────────────────────────────────────────────────
|
||||
data class LnurlPayRes(
|
||||
val tag: String?,
|
||||
val callback: String?,
|
||||
@SerializedName("minSendable") val minSendable: Long?,
|
||||
@SerializedName("maxSendable") val maxSendable: Long?,
|
||||
val metadata: String?,
|
||||
@SerializedName("commentAllowed") val commentAllowed: Int?,
|
||||
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
|
||||
@SerializedName("nostrPubkey") val nostrPubkey: String? = null
|
||||
)
|
||||
|
||||
data class PayLnurlRequest(
|
||||
val res: LnurlPayRes,
|
||||
val lnurl: String,
|
||||
val amount: Long,
|
||||
val comment: String? = null
|
||||
)
|
||||
|
||||
data class PayLnurlResponse(
|
||||
@SerializedName("payment_hash") val paymentHash: String,
|
||||
@SerializedName("checking_id") val checkingId: String
|
||||
)
|
||||
|
||||
data class WithdrawCallbackResponse(
|
||||
val status : String,
|
||||
val reason : String? = null
|
||||
)
|
||||
|
||||
// ── LNURLp pay link (lightning address) ──────────────────────────────────────
|
||||
data class LnurlpLink(
|
||||
val id : String,
|
||||
val wallet : String,
|
||||
val username : String?, // the part before the @ in the lightning address
|
||||
val description : String?,
|
||||
val min : Long,
|
||||
val max : Long
|
||||
)
|
||||
|
||||
|
||||
// ── Payment history ───────────────────────────────────────────────────────────
|
||||
data class PaymentRecord(
|
||||
@SerializedName("payment_hash") val paymentHash: String,
|
||||
@SerializedName("checking_id") val checkingId: String,
|
||||
@SerializedName("amount") val amountMsat: Long,
|
||||
@SerializedName("fee") val feeMsat: Long,
|
||||
@SerializedName("memo") val memo: String?,
|
||||
@SerializedName("time") val time: String,
|
||||
@SerializedName("status") val status: String,
|
||||
@SerializedName("bolt11") val bolt11: String?,
|
||||
@SerializedName("pending") val pending: Boolean,
|
||||
@SerializedName("preimage") val preimage: String?,
|
||||
@SerializedName("extra") val extra: PaymentExtra?
|
||||
) {
|
||||
val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
|
||||
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
|
||||
val isOutgoing: Boolean get() = amountMsat < 0
|
||||
}
|
||||
|
||||
data class PaymentExtra(
|
||||
@SerializedName("tag") val tag: String?,
|
||||
@SerializedName("comment") val comment: String?,
|
||||
@SerializedName("success_action") val successAction: SuccessAction?,
|
||||
@SerializedName("wallet_id") val walletId: String?,
|
||||
@SerializedName("destination") val destination: String?,
|
||||
@SerializedName("expires_at") val expiresAt: String?
|
||||
)
|
||||
|
||||
data class SuccessAction(
|
||||
@SerializedName("tag") val tag: String?,
|
||||
@SerializedName("message") val message: String?,
|
||||
@SerializedName("url") val url: String?,
|
||||
@SerializedName("description") val description: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* LNbits returns `success_action` as either:
|
||||
* - a JSON object {"tag":"message","message":"Thanks!"}
|
||||
* - a plain string "Thanks!"
|
||||
* - null
|
||||
*
|
||||
* Gson's default deserialiser throws IllegalStateException when it sees a
|
||||
* string where it expects {. This adapter handles all three cases.
|
||||
*
|
||||
* Registered centrally in [GsonProvider] — applies to all deserialization
|
||||
* paths automatically.
|
||||
*/
|
||||
class SuccessActionAdapter : TypeAdapter<SuccessAction?>() {
|
||||
|
||||
override fun write(out: JsonWriter, value: SuccessAction?) {
|
||||
if (value == null) { out.nullValue(); return }
|
||||
out.beginObject()
|
||||
out.name("tag"); out.value(value.tag)
|
||||
out.name("message"); out.value(value.message)
|
||||
out.name("url"); out.value(value.url)
|
||||
out.name("description"); out.value(value.description)
|
||||
out.endObject()
|
||||
}
|
||||
|
||||
override fun read(reader: JsonReader): SuccessAction? {
|
||||
return when (reader.peek()) {
|
||||
JsonToken.NULL -> { reader.nextNull(); null }
|
||||
|
||||
// Plain string — treat as a message-type success action
|
||||
JsonToken.STRING -> {
|
||||
val text = reader.nextString()
|
||||
if (text.isBlank()) null
|
||||
else SuccessAction(tag = "message", message = text, url = null, description = null)
|
||||
}
|
||||
|
||||
// Normal object
|
||||
JsonToken.BEGIN_OBJECT -> {
|
||||
reader.beginObject()
|
||||
var tag: String? = null
|
||||
var message: String? = null
|
||||
var url: String? = null
|
||||
var description: String? = null
|
||||
while (reader.hasNext()) {
|
||||
when (reader.nextName()) {
|
||||
"tag" -> tag = reader.nextString()
|
||||
"message" -> message = reader.nextString()
|
||||
"url" -> url = reader.nextString()
|
||||
"description" -> description = reader.nextString()
|
||||
else -> reader.skipValue()
|
||||
}
|
||||
}
|
||||
reader.endObject()
|
||||
SuccessAction(tag = tag, message = message, url = url, description = description)
|
||||
}
|
||||
|
||||
else -> { reader.skipValue(); null }
|
||||
}
|
||||
}
|
||||
}
|
||||
data class PaymentDetailResponse(
|
||||
val paid: Boolean,
|
||||
val details: PaymentRecord?
|
||||
)
|
||||
|
||||
// ── WebSocket payment message ─────────────────────────────────────────────────
|
||||
data class WsPaymentMessage(
|
||||
val payment: WsPayment?
|
||||
)
|
||||
|
||||
data class WsPayment(
|
||||
@SerializedName("payment_hash") val paymentHash: String,
|
||||
val amount: Long,
|
||||
val status: String,
|
||||
val memo: String?
|
||||
)
|
||||
|
||||
// ── Fiat rate ────────────────────────────────────────────────────────────────
|
||||
data class FiatRateResponse(
|
||||
val rate: Double, // sat to fiat rate
|
||||
val price : Double // BTC price in the requested fiat currency
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.bitcointxoko.gudariwallet.api
|
||||
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
|
||||
// ── mempool.space ─────────────────────────────────────────────────────────────
|
||||
|
||||
data class MempoolNodeResponse(
|
||||
val alias: String?,
|
||||
val public_key: String?
|
||||
)
|
||||
|
||||
interface MempoolApi {
|
||||
@GET("api/v1/lightning/nodes/{pubkey}")
|
||||
suspend fun getNode(@Path("pubkey") pubkey: String): MempoolNodeResponse
|
||||
}
|
||||
|
||||
// ── 1ml.com ───────────────────────────────────────────────────────────────────
|
||||
|
||||
data class OneMlNodeResponse(
|
||||
val alias: String?,
|
||||
val pub_key: String?
|
||||
)
|
||||
|
||||
interface OneMlApi {
|
||||
@GET("node/{pubkey}/json")
|
||||
suspend fun getNode(@Path("pubkey") pubkey: String): OneMlNodeResponse
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.bitcointxoko.gudariwallet.api
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Resolves a Lightning node pubkey to a human-readable alias.
|
||||
* Tries 1ml.com first, falls back to mempool.space.
|
||||
* Returns null if neither source has the node or both fail.
|
||||
*/
|
||||
class NodeAliasService {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val mempoolApi: MempoolApi = Retrofit.Builder()
|
||||
.baseUrl("https://mempool.space/")
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(MempoolApi::class.java)
|
||||
|
||||
private val oneMlApi: OneMlApi = Retrofit.Builder()
|
||||
.baseUrl("https://1ml.com/")
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(OneMlApi::class.java)
|
||||
|
||||
suspend fun resolveAlias(pubkey: String): String? {
|
||||
if (pubkey.isBlank()) return null
|
||||
|
||||
// 1. Try 1ml.com
|
||||
runCatching { oneMlApi.getNode(pubkey) }
|
||||
.onSuccess { response ->
|
||||
val alias = response.alias?.takeIf { it.isNotBlank() }
|
||||
if (alias != null) return alias
|
||||
}
|
||||
|
||||
// 2. Fall back to mempool.space
|
||||
runCatching { mempoolApi.getNode(pubkey) }
|
||||
.onSuccess { response ->
|
||||
val alias = response.alias?.takeIf { it.isNotBlank() }
|
||||
if (alias != null) return alias
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user