409 lines
17 KiB
Kotlin
409 lines
17 KiB
Kotlin
package com.bitcointxoko.gudariwallet.api
|
|
|
|
import androidx.compose.runtime.Immutable
|
|
import kotlinx.serialization.KSerializer
|
|
import kotlinx.serialization.SerialName
|
|
import kotlinx.serialization.Serializable
|
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
|
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
|
import kotlinx.serialization.encoding.Decoder
|
|
import kotlinx.serialization.encoding.Encoder
|
|
import kotlinx.serialization.json.JsonDecoder
|
|
import kotlinx.serialization.json.JsonElement
|
|
import kotlinx.serialization.json.JsonEncoder
|
|
import kotlinx.serialization.json.JsonNull
|
|
import kotlinx.serialization.json.JsonObject
|
|
import kotlinx.serialization.json.JsonPrimitive
|
|
import kotlinx.serialization.json.jsonObject
|
|
import kotlinx.serialization.json.jsonPrimitive
|
|
|
|
// ── Wallet ────────────────────────────────────────────────────────────────────
|
|
@Serializable
|
|
data class WalletResponse(
|
|
val id: String = "",
|
|
val name: String = "",
|
|
val balance: Long = 0L // millisatoshis
|
|
)
|
|
|
|
// ── Create invoice ────────────────────────────────────────────────────────────
|
|
@Serializable
|
|
data class CreateInvoiceRequest(
|
|
val out: Boolean,
|
|
val amount: Long, // satoshis
|
|
val memo: String
|
|
)
|
|
|
|
@Serializable
|
|
data class CreateInvoiceResponse(
|
|
@SerialName("payment_hash") val paymentHash: String = "",
|
|
@SerialName("payment_request") val paymentRequest: String = "",
|
|
@SerialName("expiry") val expiry: String? = null
|
|
)
|
|
|
|
// ── Pay invoice ───────────────────────────────────────────────────────────────
|
|
@Serializable
|
|
data class PayInvoiceRequest(
|
|
val out: Boolean,
|
|
val bolt11: String
|
|
)
|
|
|
|
@Serializable
|
|
data class PayInvoiceResponse(
|
|
@SerialName("payment_hash") val paymentHash: String = "",
|
|
@SerialName("checking_id") val checkingId: String = ""
|
|
)
|
|
|
|
// ── Check payment ─────────────────────────────────────────────────────────────
|
|
@Serializable
|
|
data class PaymentStatusResponse(
|
|
val paid: Boolean = false,
|
|
val details: PaymentDetails? = null
|
|
)
|
|
|
|
@Serializable
|
|
data class PaymentDetails(
|
|
val amount: Long? = null,
|
|
val fee: Long? = null,
|
|
val memo: String? = null
|
|
)
|
|
|
|
// ── Decode BOLT-11 ────────────────────────────────────────────────────────────
|
|
@Serializable
|
|
data class DecodeInvoiceRequest(
|
|
val data: String
|
|
)
|
|
|
|
@Serializable
|
|
data class DecodeInvoiceResponse(
|
|
@SerialName("payment_hash") val paymentHash: String? = null,
|
|
@SerialName("amount_msat") val amountMsat: Long? = null,
|
|
@SerialName("description") val description: String? = null,
|
|
@SerialName("payee") val payee: String? = null,
|
|
@SerialName("route_hints") val routeHints: List<List<RouteHintHop>>? = null
|
|
)
|
|
|
|
@Serializable
|
|
data class RouteHintHop(
|
|
@SerialName("public_key") val publicKey: String = "",
|
|
@SerialName("short_channel_id") val shortChannelId: String? = null,
|
|
@SerialName("base_fee") val baseFeeMsat: Long = 0,
|
|
@SerialName("ppm_fee") val ppmFee: Long = 0,
|
|
@SerialName("cltv_expiry_delta") val cltvExpiryDelta: Int = 0
|
|
)
|
|
|
|
// ── LNURL scan ────────────────────────────────────────────────────────────────
|
|
@Serializable
|
|
data class LnurlScanResponse(
|
|
val tag: String? = null,
|
|
// ── payRequest fields ─────────────────────────────────────────────────────
|
|
val callback: String? = null,
|
|
@SerialName("minSendable") val minSendable: Long? = null,
|
|
@SerialName("maxSendable") val maxSendable: Long? = null,
|
|
val metadata: String? = null,
|
|
@SerialName("commentAllowed") val commentAllowed: Int? = null,
|
|
@SerialName("allowsNostr") val allowsNostr: Boolean? = null,
|
|
@SerialName("nostrPubkey") val nostrPubkey: String? = null,
|
|
val bolt11: String? = null,
|
|
@SerialName("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,
|
|
@SerialName("defaultDescription") val defaultDescription: String? = null,
|
|
@SerialName("minWithdrawable") val minWithdrawable: Long? = null,
|
|
@SerialName("maxWithdrawable") val maxWithdrawable: Long? = null,
|
|
@SerialName("balanceCheck") val balanceCheck: String? = null,
|
|
@SerialName("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) ─────────────────────────────────────
|
|
@Serializable
|
|
data class LnurlCallbackResponse(
|
|
val pr: String = "",
|
|
val routes: List<JsonElement>? = null,
|
|
@SerialName("successAction") val successAction: SuccessAction? = null,
|
|
val status: String? = null,
|
|
val reason: String? = null
|
|
)
|
|
|
|
// ── LNURL pay ─────────────────────────────────────────────────────────────────
|
|
@Serializable
|
|
data class LnurlPayRes(
|
|
val tag: String? = null,
|
|
val callback: String? = null,
|
|
@SerialName("minSendable") val minSendable: Long? = null,
|
|
@SerialName("maxSendable") val maxSendable: Long? = null,
|
|
val metadata: String? = null,
|
|
@SerialName("commentAllowed") val commentAllowed: Int? = null,
|
|
@SerialName("allowsNostr") val allowsNostr: Boolean? = null,
|
|
@SerialName("nostrPubkey") val nostrPubkey: String? = null
|
|
)
|
|
|
|
@Serializable
|
|
data class PayLnurlRequest(
|
|
val res: LnurlPayRes,
|
|
val lnurl: String,
|
|
val amount: Long,
|
|
val comment: String? = null
|
|
)
|
|
|
|
@Serializable
|
|
data class PayLnurlResponse(
|
|
@SerialName("payment_hash") val paymentHash: String = "",
|
|
@SerialName("checking_id") val checkingId: String = ""
|
|
)
|
|
|
|
@Serializable
|
|
data class WithdrawCallbackResponse(
|
|
val status: String = "",
|
|
val reason: String? = null
|
|
)
|
|
|
|
// ── LNURLp pay link (lightning address) ──────────────────────────────────────
|
|
@Serializable
|
|
data class LnurlpLink(
|
|
val id: String = "",
|
|
val wallet: String = "",
|
|
val username: String? = null,
|
|
val description: String? = null,
|
|
@Serializable(with = LongAsDoubleSerializer::class) val min: Long = 0L,
|
|
@Serializable(with = LongAsDoubleSerializer::class) val max: Long = 0L
|
|
)
|
|
|
|
// ── Payment history ───────────────────────────────────────────────────────────
|
|
@Immutable
|
|
@Serializable
|
|
data class PaymentRecord(
|
|
@SerialName("payment_hash") val paymentHash: String = "",
|
|
@SerialName("checking_id") val checkingId: String = "",
|
|
@SerialName("amount") val amountMsat: Long = 0L,
|
|
@SerialName("fee") val feeMsat: Long = 0L,
|
|
@SerialName("memo") val memo: String? = null,
|
|
@SerialName("time") val time: String = "",
|
|
@SerialName("status") val status: String = "",
|
|
@SerialName("bolt11") val bolt11: String? = null,
|
|
@SerialName("pending") val pending: Boolean = false,
|
|
@SerialName("preimage") val preimage: String? = null,
|
|
@SerialName("extra") val extra: PaymentExtra? = null,
|
|
// Not from the API — populated only when loaded from Room via toDomain()
|
|
val createdAt: Long? = null,
|
|
val pubkey: String? = null,
|
|
val alias: String? = null
|
|
) {
|
|
val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
|
|
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
|
|
val isOutgoing: Boolean get() = amountMsat < 0
|
|
|
|
companion object {
|
|
fun fromPayment(
|
|
paymentHash: String,
|
|
amountSats: Long,
|
|
feeSats: Long?,
|
|
bolt11: String?,
|
|
memo: String?,
|
|
pubkey: String?,
|
|
alias: String?,
|
|
): PaymentRecord = PaymentRecord(
|
|
paymentHash = paymentHash,
|
|
checkingId = paymentHash,
|
|
amountMsat = -(amountSats * 1_000L),
|
|
feeMsat = (feeSats ?: 0L) * 1_000L,
|
|
memo = memo,
|
|
time = (System.currentTimeMillis() / 1_000L).toString(),
|
|
status = "complete",
|
|
bolt11 = bolt11,
|
|
pending = false,
|
|
preimage = null,
|
|
extra = null,
|
|
pubkey = pubkey,
|
|
alias = alias,
|
|
)
|
|
}
|
|
}
|
|
|
|
@Immutable
|
|
@Serializable
|
|
data class PaymentExtra(
|
|
@SerialName("tag") val tag: String? = null,
|
|
@Serializable(with = FlexibleStringSerializer::class)
|
|
val comment: String? = null,
|
|
@SerialName("success_action") @Serializable(with = SuccessActionSerializer::class)
|
|
val successAction: SuccessAction? = null,
|
|
@SerialName("wallet_id") val walletId: String? = null,
|
|
@SerialName("destination") val destination: String? = null,
|
|
@SerialName("expires_at") val expiresAt: String? = null
|
|
)
|
|
|
|
@Immutable
|
|
@Serializable(with = SuccessActionSerializer::class)
|
|
data class SuccessAction(
|
|
@SerialName("tag") val tag: String?,
|
|
@SerialName("message") val message: String?,
|
|
@SerialName("url") val url: String?,
|
|
@SerialName("description") val description: String?
|
|
)
|
|
|
|
/**
|
|
* LNbits returns `success_action` as either:
|
|
* - a JSON object {"tag":"message","message":"Thanks!"}
|
|
* - a plain string "Thanks!"
|
|
* - null
|
|
*
|
|
* kotlinx.serialization's default deserializer throws when it sees a
|
|
* string where it expects {. This serializer handles all three cases.
|
|
*
|
|
* Applied via @Serializable(with = SuccessActionSerializer::class) on
|
|
* the SuccessAction class and on PaymentExtra.successAction.
|
|
*/
|
|
object SuccessActionSerializer : KSerializer<SuccessAction?> {
|
|
|
|
override val descriptor: SerialDescriptor =
|
|
buildClassSerialDescriptor("SuccessAction")
|
|
|
|
override fun serialize(encoder: Encoder, value: SuccessAction?) {
|
|
val jsonEncoder = encoder as JsonEncoder
|
|
if (value == null) {
|
|
jsonEncoder.encodeJsonElement(JsonNull)
|
|
} else {
|
|
jsonEncoder.encodeJsonElement(
|
|
JsonObject(
|
|
mapOf(
|
|
"tag" to JsonPrimitive(value.tag),
|
|
"message" to JsonPrimitive(value.message),
|
|
"url" to JsonPrimitive(value.url),
|
|
"description" to JsonPrimitive(value.description)
|
|
)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
override fun deserialize(decoder: Decoder): SuccessAction? {
|
|
val jsonDecoder = decoder as JsonDecoder
|
|
return when (val element = jsonDecoder.decodeJsonElement()) {
|
|
is JsonNull -> null
|
|
is JsonPrimitive -> {
|
|
// Plain string — treat as a message-type success action
|
|
val text = element.jsonPrimitive.content
|
|
if (text.isBlank()) null
|
|
else SuccessAction(tag = "message", message = text, url = null, description = null)
|
|
}
|
|
is JsonObject -> {
|
|
SuccessAction(
|
|
tag = element.jsonObject["tag"]?.jsonPrimitive?.content,
|
|
message = element.jsonObject["message"]?.jsonPrimitive?.content,
|
|
url = element.jsonObject["url"]?.jsonPrimitive?.content,
|
|
description = element.jsonObject["description"]?.jsonPrimitive?.content
|
|
)
|
|
}
|
|
else -> null
|
|
}
|
|
}
|
|
}
|
|
|
|
@Serializable
|
|
data class PaymentDetailResponse(
|
|
val paid: Boolean,
|
|
val details: PaymentRecord?
|
|
)
|
|
|
|
// ── WebSocket payment message ─────────────────────────────────────────────────
|
|
@Serializable
|
|
data class WsPaymentMessage(
|
|
@SerialName("wallet_balance") val walletBalance: Long? = null,
|
|
val payment: WsPayment? = null
|
|
)
|
|
|
|
@Serializable
|
|
data class WsPayment(
|
|
@SerialName("payment_hash") val paymentHash: String = "",
|
|
@SerialName("checking_id") val checkingId: String = "",
|
|
@SerialName("amount") val amount: Long = 0L,
|
|
@SerialName("fee") val fee: Long = 0L,
|
|
@SerialName("memo") val memo: String? = null,
|
|
@SerialName("time") val time: String = "",
|
|
@SerialName("status") val status: String = "",
|
|
@SerialName("bolt11") val bolt11: String? = null,
|
|
@SerialName("pending") val pending: Boolean = false,
|
|
@SerialName("preimage") val preimage: String? = null,
|
|
@SerialName("extra") val extra: PaymentExtra? = null
|
|
)
|
|
|
|
// ── Fiat rate ─────────────────────────────────────────────────────────────────
|
|
@Serializable
|
|
data class FiatRateResponse(
|
|
val rate: Double = 0.0, // sat to fiat rate
|
|
val price: Double = 0.0 // BTC price in the requested fiat currency
|
|
)
|
|
|
|
// ── History sync ──────────────────────────────────────────────────────────────
|
|
@Serializable
|
|
data class PaginatedPaymentsResponse(
|
|
@SerialName("data") val data: List<PaymentRecord> = emptyList(),
|
|
@SerialName("total") val total: Int = 0
|
|
)
|
|
|
|
// ── NWC Provider models ───────────────────────────────────────────────────────
|
|
@Serializable
|
|
data class NwcKey(
|
|
val pubkey: String = "",
|
|
val wallet: String = "",
|
|
val description: String = "",
|
|
val expires_at: Long = 0L,
|
|
val permissions: String = "",
|
|
val created_at: Long = 0L,
|
|
val last_used: Long = 0L
|
|
)
|
|
|
|
@Serializable
|
|
data class NwcBudget(
|
|
val id: Int = 0,
|
|
val pubkey: String = "",
|
|
val budget_msats: Long = 0L,
|
|
val refresh_window: Int = 0,
|
|
val created_at: Long = 0L,
|
|
val used_budget_msats: Long = 0L
|
|
)
|
|
|
|
@Serializable
|
|
data class NwcGetResponse(
|
|
val data: NwcKey = NwcKey(),
|
|
val budgets: List<NwcBudget> = emptyList()
|
|
)
|
|
|
|
// Request models — no defaults needed (you control the fields, never deserializing)
|
|
@Serializable
|
|
data class NwcNewBudget(
|
|
val budget_msats: Long,
|
|
val refresh_window: Int,
|
|
val created_at: Long = System.currentTimeMillis() / 1000L
|
|
)
|
|
|
|
@Serializable
|
|
data class NwcRegistrationRequest(
|
|
val permissions: List<String>,
|
|
val description: String,
|
|
val expires_at: Long,
|
|
val budgets: List<NwcNewBudget> = emptyList()
|
|
)
|
|
|
|
/**
|
|
* Deserializes a JSON number that may arrive as either an integer (1)
|
|
* or a float (1.0) into a Long, truncating any fractional part.
|
|
*/
|
|
object LongAsDoubleSerializer : KSerializer<Long> {
|
|
override val descriptor = buildClassSerialDescriptor("LongAsDouble")
|
|
|
|
override fun serialize(encoder: Encoder, value: Long) = encoder.encodeLong(value)
|
|
|
|
override fun deserialize(decoder: Decoder): Long {
|
|
val jsonDecoder = decoder as JsonDecoder
|
|
return when (val element = jsonDecoder.decodeJsonElement()) {
|
|
is JsonPrimitive -> element.content.toDouble().toLong()
|
|
else -> 0L
|
|
}
|
|
}
|
|
}
|