mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Add Cashu payment chips and pay command
This commit is contained in:
parent
4f567ecd5f
commit
b9ca8ed3eb
287
app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt
Normal file
287
app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt
Normal file
@ -0,0 +1,287 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.math.BigDecimal
|
||||
import java.util.Base64
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Bounded, display-only Cashu token decoder. Tokens are bearer instruments, so
|
||||
* this class never contacts a mint or attempts to hold/redeem funds.
|
||||
*/
|
||||
object CashuTokenDecoder {
|
||||
const val MAX_TOKEN_LENGTH = 60_000
|
||||
private const val MAX_AMOUNT = 2_100_000_000_000_000L
|
||||
|
||||
data class TokenInfo(
|
||||
val version: Char,
|
||||
val amount: Long?,
|
||||
val unit: String?,
|
||||
val mintHost: String?,
|
||||
val memo: String?
|
||||
) {
|
||||
val displayAmount: String?
|
||||
get() = amount?.let { value ->
|
||||
val displayUnit = unit ?: "sat"
|
||||
val minorDigits = minorUnitDigits(displayUnit)
|
||||
val formatted = if (minorDigits == null || minorDigits == 0) {
|
||||
value.toString()
|
||||
} else {
|
||||
BigDecimal.valueOf(value, minorDigits).setScale(minorDigits).toPlainString()
|
||||
}
|
||||
"$formatted $displayUnit"
|
||||
}
|
||||
}
|
||||
|
||||
fun bareToken(raw: String): String? {
|
||||
var token = raw.trim()
|
||||
if ('%' in token) token = percentDecode(token) ?: return null
|
||||
token = when {
|
||||
token.startsWith("cashu://", ignoreCase = true) -> token.substring(8)
|
||||
token.startsWith("cashu:", ignoreCase = true) -> token.substring(6)
|
||||
else -> token
|
||||
}
|
||||
if (token.length !in 12..MAX_TOKEN_LENGTH) return null
|
||||
if (!token.startsWith("cashuA") && !token.startsWith("cashuB")) return null
|
||||
if (token.any { !it.isLetterOrDigit() && it !in "-_+/=." }) return null
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissive decoding is suitable for display: unsupported but plausible
|
||||
* v4 CBOR still gets a generic chip. Strict decoding is required before
|
||||
* sending and accepts only a fully parsed token with a positive amount.
|
||||
*/
|
||||
fun decode(raw: String, strict: Boolean = false): TokenInfo? {
|
||||
val token = bareToken(raw) ?: return null
|
||||
val payload = decodeBase64Url(token.substring(6)) ?: return null
|
||||
if (payload.isEmpty()) return null
|
||||
val info = when (token[5]) {
|
||||
'A' -> decodeV3(payload)
|
||||
'B' -> decodeV4(payload) ?: if (strict) null else TokenInfo('B', null, null, null, null)
|
||||
else -> null
|
||||
} ?: return null
|
||||
return if (!strict || (info.amount != null && info.amount > 0)) info else null
|
||||
}
|
||||
|
||||
fun extractTokens(text: String, max: Int = 3): List<String> {
|
||||
if (text.isEmpty() || max <= 0) return emptyList()
|
||||
val matches = TOKEN_REGEX.findAll(text)
|
||||
val result = LinkedHashSet<String>()
|
||||
for (match in matches) {
|
||||
bareToken(match.value)?.let(result::add)
|
||||
if (result.size == max) break
|
||||
}
|
||||
return result.toList()
|
||||
}
|
||||
|
||||
fun walletUri(token: String): String? = bareToken(token)?.let { "cashu:${encodeUriComponent(it)}" }
|
||||
|
||||
fun webRedeemUri(token: String): String? =
|
||||
bareToken(token)?.let { "https://redeem.cashu.me/?token=${encodeUriComponent(it)}" }
|
||||
|
||||
private fun encodeUriComponent(value: String): String =
|
||||
URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20")
|
||||
|
||||
/** Percent-decodes URI input without URLDecoder's form-specific '+' → space conversion. */
|
||||
private fun percentDecode(value: String): String? {
|
||||
val output = StringBuilder(value.length)
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
if (value[index] != '%') {
|
||||
output.append(value[index++])
|
||||
continue
|
||||
}
|
||||
val bytes = ArrayList<Byte>()
|
||||
while (index < value.length && value[index] == '%') {
|
||||
if (index + 2 >= value.length) return null
|
||||
val byte = value.substring(index + 1, index + 3).toIntOrNull(16) ?: return null
|
||||
bytes += byte.toByte()
|
||||
index += 3
|
||||
}
|
||||
output.append(String(bytes.toByteArray(), StandardCharsets.UTF_8))
|
||||
}
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private fun decodeBase64Url(input: String): ByteArray? {
|
||||
val normalized = input.replace('-', '+').replace('_', '/').trimEnd('=')
|
||||
if (normalized.length % 4 == 1) return null
|
||||
val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4)
|
||||
return runCatching { Base64.getDecoder().decode(padded) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun decodeV3(payload: ByteArray): TokenInfo? = runCatching {
|
||||
val root = JsonParser.parseString(String(payload, StandardCharsets.UTF_8)).asJsonObject
|
||||
val entries = root.getAsJsonArray("token")?.takeIf { it.size() > 0 } ?: return null
|
||||
var total = 0L
|
||||
var sawAmount = false
|
||||
var mintHost: String? = null
|
||||
for (entryElement in entries) {
|
||||
val entry = entryElement.takeIf { it.isJsonObject }?.asJsonObject ?: continue
|
||||
if (mintHost == null) mintHost = sanitizeHost(entry.get("mint")?.takeIf { it.isJsonPrimitive }?.asString)
|
||||
val proofs = entry.getAsJsonArray("proofs") ?: continue
|
||||
for (proofElement in proofs) {
|
||||
val amountElement = proofElement.takeIf { it.isJsonObject }?.asJsonObject?.get("amount") ?: continue
|
||||
if (!amountElement.isJsonPrimitive || !amountElement.asJsonPrimitive.isNumber) continue
|
||||
val value = runCatching { amountElement.asBigDecimal.longValueExact() }.getOrNull() ?: continue
|
||||
if (value <= 0 || value > MAX_AMOUNT) continue
|
||||
if (total > MAX_AMOUNT - value) return null
|
||||
total += value
|
||||
sawAmount = true
|
||||
}
|
||||
}
|
||||
TokenInfo(
|
||||
version = 'A',
|
||||
amount = total.takeIf { sawAmount },
|
||||
unit = sanitizeUnit(root.get("unit")?.takeIf { it.isJsonPrimitive }?.asString),
|
||||
mintHost = mintHost,
|
||||
memo = sanitizeMemo(root.get("memo")?.takeIf { it.isJsonPrimitive }?.asString)
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private fun decodeV4(payload: ByteArray): TokenInfo? {
|
||||
val root = CborReader(payload).parseComplete() as? CborValue.MapValue ?: return null
|
||||
var total = 0L
|
||||
var sawAmount = false
|
||||
var mintHost: String? = null
|
||||
var unit: String? = null
|
||||
var memo: String? = null
|
||||
for ((key, value) in root.pairs) {
|
||||
when ((key as? CborValue.Text)?.value) {
|
||||
"m" -> mintHost = sanitizeHost((value as? CborValue.Text)?.value)
|
||||
"u" -> unit = sanitizeUnit((value as? CborValue.Text)?.value)
|
||||
"d" -> memo = sanitizeMemo((value as? CborValue.Text)?.value)
|
||||
"t" -> for (group in (value as? CborValue.ArrayValue)?.values.orEmpty()) {
|
||||
for ((groupKey, groupValue) in (group as? CborValue.MapValue)?.pairs.orEmpty()) {
|
||||
if ((groupKey as? CborValue.Text)?.value != "p") continue
|
||||
for (proof in (groupValue as? CborValue.ArrayValue)?.values.orEmpty()) {
|
||||
for ((proofKey, proofValue) in (proof as? CborValue.MapValue)?.pairs.orEmpty()) {
|
||||
if ((proofKey as? CborValue.Text)?.value != "a") continue
|
||||
val amount = (proofValue as? CborValue.Unsigned)?.value ?: continue
|
||||
if (amount == 0L || amount > MAX_AMOUNT) continue
|
||||
if (total > MAX_AMOUNT - amount) return null
|
||||
total += amount
|
||||
sawAmount = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return TokenInfo('B', total.takeIf { sawAmount }, unit, mintHost, memo)
|
||||
}
|
||||
|
||||
private fun sanitizeHost(value: String?): String? = value
|
||||
?.takeIf { it.length <= 512 }
|
||||
?.let { runCatching { URI(it).host }.getOrNull() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.lowercase()
|
||||
?.take(48)
|
||||
|
||||
private fun sanitizeUnit(value: String?): String? =
|
||||
value?.takeIf { it.isNotEmpty() && it.length <= 12 && it.all(Char::isLetterOrDigit) }
|
||||
|
||||
private fun sanitizeMemo(value: String?): String? {
|
||||
if (value == null || value.length > 512) return null
|
||||
return value.filterNot(Char::isISOControl).trim().take(80).takeIf(String::isNotEmpty)
|
||||
}
|
||||
|
||||
/** ISO-4217 values use their currency's minor unit; custom units stay integer-denominated. */
|
||||
private fun minorUnitDigits(unit: String): Int? {
|
||||
return runCatching {
|
||||
Currency.getInstance(unit.uppercase(Locale.ROOT)).defaultFractionDigits
|
||||
}.getOrNull()?.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
private val TOKEN_REGEX = Regex("""(?i:cashu:(?://)?)?cashu[AB][A-Za-z0-9_+/.=%-]{6,}""")
|
||||
}
|
||||
|
||||
private sealed interface CborValue {
|
||||
data class Unsigned(val value: Long) : CborValue
|
||||
data class Text(val value: String) : CborValue
|
||||
data class ArrayValue(val values: List<CborValue>) : CborValue
|
||||
data class MapValue(val pairs: List<Pair<CborValue, CborValue>>) : CborValue
|
||||
data object Opaque : CborValue
|
||||
}
|
||||
|
||||
private class CborReader(private val bytes: ByteArray) {
|
||||
private var index = 0
|
||||
private var itemBudget = 50_000
|
||||
|
||||
fun parseComplete(): CborValue? {
|
||||
val value = parseValue(0) ?: return null
|
||||
return value.takeIf { index == bytes.size }
|
||||
}
|
||||
|
||||
private fun parseValue(depth: Int): CborValue? {
|
||||
if (depth >= 16 || itemBudget-- <= 0) return null
|
||||
val (major, argument) = readHead() ?: return null
|
||||
return when (major) {
|
||||
0 -> CborValue.Unsigned(argument.takeIf { it <= Long.MAX_VALUE }?.toLong() ?: return null)
|
||||
1 -> CborValue.Opaque
|
||||
2 -> if (readBytes(argument) != null) CborValue.Opaque else null
|
||||
3 -> readBytes(argument)?.toString(StandardCharsets.UTF_8)?.let(CborValue::Text)
|
||||
4 -> parseContainer(argument, depth) { CborValue.ArrayValue(it) }
|
||||
5 -> {
|
||||
if (argument > 10_000 || argument > itemBudget / 2) return null
|
||||
val pairs = ArrayList<Pair<CborValue, CborValue>>(argument.coerceAtMost(64).toInt())
|
||||
repeat(argument.toInt()) {
|
||||
pairs += (parseValue(depth + 1) ?: return null) to (parseValue(depth + 1) ?: return null)
|
||||
}
|
||||
CborValue.MapValue(pairs)
|
||||
}
|
||||
6 -> parseValue(depth + 1)
|
||||
7 -> CborValue.Opaque
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseContainer(
|
||||
count: Long,
|
||||
depth: Int,
|
||||
wrap: (List<CborValue>) -> CborValue
|
||||
): CborValue? {
|
||||
if (count > 10_000 || count > itemBudget) return null
|
||||
val values = ArrayList<CborValue>(count.coerceAtMost(64).toInt())
|
||||
repeat(count.toInt()) { values += parseValue(depth + 1) ?: return null }
|
||||
return wrap(values)
|
||||
}
|
||||
|
||||
private fun readHead(): Pair<Int, Long>? {
|
||||
if (index >= bytes.size) return null
|
||||
val head = bytes[index++].toInt() and 0xff
|
||||
val major = head ushr 5
|
||||
val info = head and 0x1f
|
||||
val argument = when (info) {
|
||||
in 0..23 -> info.toLong()
|
||||
24 -> readUInt(1)
|
||||
25 -> readUInt(2)
|
||||
26 -> readUInt(4)
|
||||
27 -> readUInt(8)
|
||||
else -> null
|
||||
} ?: return null
|
||||
return major to argument
|
||||
}
|
||||
|
||||
private fun readUInt(width: Int): Long? {
|
||||
if (bytes.size - index < width) return null
|
||||
var value = 0L
|
||||
repeat(width) {
|
||||
val next = bytes[index++].toLong() and 0xff
|
||||
if (value > (Long.MAX_VALUE - next) ushr 8) return null
|
||||
value = (value shl 8) or next
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun readBytes(count: Long): ByteArray? {
|
||||
if (count < 0 || count > bytes.size - index) return null
|
||||
val end = index + count.toInt()
|
||||
return bytes.copyOfRange(index, end).also { index = end }
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,7 @@ class CommandProcessor(
|
||||
CommandSuggestion("/hug", emptyList(), "<nickname>", "send someone a warm hug"),
|
||||
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
|
||||
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
|
||||
CommandSuggestion("/pay", emptyList(), "<token> [public]", "send a Cashu ecash token"),
|
||||
CommandSuggestion("/slap", emptyList(), "<nickname>", "slap someone with a trout"),
|
||||
CommandSuggestion("/unblock", emptyList(), "<nickname>", "unblock a peer"),
|
||||
CommandSuggestion("/w", emptyList(), null, "see who's online")
|
||||
@ -41,6 +42,7 @@ class CommandProcessor(
|
||||
when (cmd) {
|
||||
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
|
||||
"/m", "/msg" -> handleMessageCommand(parts, meshService, viewModel)
|
||||
"/pay" -> handlePayCommand(command, meshService, myPeerID, onSendMessage, viewModel)
|
||||
"/w" -> handleWhoCommand(meshService, viewModel)
|
||||
"/clear" -> handleClearCommand()
|
||||
"/pass" -> handlePassCommand(parts, myPeerID)
|
||||
@ -363,6 +365,88 @@ class CommandProcessor(
|
||||
)
|
||||
messageManager.addMessage(systemMessage)
|
||||
}
|
||||
|
||||
private fun handlePayCommand(
|
||||
command: String,
|
||||
meshService: MeshService,
|
||||
myPeerID: String,
|
||||
onSendMessage: (String, List<String>, String?) -> Unit,
|
||||
viewModel: ChatViewModel?
|
||||
) {
|
||||
val args = command.trim().split(Regex("\\s+")).drop(1)
|
||||
if (args.isEmpty()) {
|
||||
addSystemMessage("usage: /pay <cashu token> [public] — Cashu tokens are bearer instruments")
|
||||
return
|
||||
}
|
||||
|
||||
val publicConfirmed = args.lastOrNull()?.equals("public", ignoreCase = true) == true
|
||||
val rawToken = if (publicConfirmed) args.dropLast(1).joinToString(" ") else args.joinToString(" ")
|
||||
val token = CashuTokenDecoder.bareToken(rawToken)
|
||||
val info = token?.let { CashuTokenDecoder.decode(it, strict = true) }
|
||||
if (token == null || info == null) {
|
||||
addSystemMessage("invalid cashu token — not sending it")
|
||||
return
|
||||
}
|
||||
|
||||
val selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||
if (selectedPeer != null) {
|
||||
privateChatManager.sendPrivateMessage(
|
||||
token,
|
||||
selectedPeer,
|
||||
getPeerNickname(selectedPeer, meshService),
|
||||
state.getNicknameValue(),
|
||||
myPeerID
|
||||
) { content, peerID, recipientNickname, messageId ->
|
||||
sendPrivateMessageVia(meshService, content, peerID, recipientNickname, messageId, viewModel)
|
||||
}
|
||||
} else {
|
||||
if (!publicConfirmed) {
|
||||
addSystemMessage(
|
||||
"Cashu tokens are bearer instruments. Anyone here can redeem this token. " +
|
||||
"Confirm with: /pay <token> public"
|
||||
)
|
||||
return
|
||||
}
|
||||
val isLocationChannel =
|
||||
state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
|
||||
if (!isLocationChannel) {
|
||||
val message = BitchatMessage(
|
||||
sender = state.getNicknameValue() ?: myPeerID,
|
||||
content = token,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
senderPeerID = myPeerID,
|
||||
channel = state.getCurrentChannelValue()
|
||||
)
|
||||
val channel = state.getCurrentChannelValue()
|
||||
if (channel != null) channelManager.addChannelMessage(channel, message, myPeerID)
|
||||
else messageManager.addMessage(message)
|
||||
}
|
||||
onSendMessage(token, emptyList(), state.getCurrentChannelValue())
|
||||
}
|
||||
|
||||
addSystemMessage(
|
||||
"sent ${info.displayAmount ?: "Cashu token"} — bearer token; first redeemer wins"
|
||||
)
|
||||
}
|
||||
|
||||
private fun addSystemMessage(content: String) {
|
||||
val message = BitchatMessage(
|
||||
sender = "system",
|
||||
content = content,
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
val selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||
val channel = state.getCurrentChannelValue()
|
||||
when {
|
||||
selectedPeer != null -> {
|
||||
messageManager.addPrivateMessageNoUnread(selectedPeer, message.copy(isPrivate = true))
|
||||
}
|
||||
channel != null -> channelManager.addChannelMessage(channel, message, null)
|
||||
else -> messageManager.addMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUnknownCommand(cmd: String) {
|
||||
val systemMessage = BitchatMessage(
|
||||
@ -410,7 +494,12 @@ class CommandProcessor(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return baseCommands + channelCommands
|
||||
val isPublicGeohash =
|
||||
state.getSelectedPrivateChatPeerValue() == null &&
|
||||
state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
|
||||
return (baseCommands + channelCommands).filterNot {
|
||||
isPublicGeohash && it.command == "/pay"
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterCommands(commands: List<CommandSuggestion>, input: String): List<CommandSuggestion> {
|
||||
|
||||
@ -599,6 +599,24 @@ fun MessageItem(
|
||||
return
|
||||
}
|
||||
|
||||
val cashuTokens = remember(message.content) {
|
||||
CashuTokenDecoder.extractTokens(message.content)
|
||||
}
|
||||
if (cashuTokens.isNotEmpty() && message.sender != "system") {
|
||||
CashuMessageContent(
|
||||
message = message,
|
||||
tokens = cashuTokens,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.sender == "system") {
|
||||
// Background narration: `// Tor started. Routing all chats…`
|
||||
val annotatedText = remember(message, colorScheme.onSurface) {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="cashu_pay_via">pay via Cashu</string>
|
||||
<string name="cashu_payment_description">Cashu payment</string>
|
||||
<string name="cashu_copy_token">Copy token</string>
|
||||
<string name="cashu_redeem_wallet">Redeem in wallet</string>
|
||||
<string name="cashu_redeem_web">Redeem on web</string>
|
||||
<string name="app_name">bitchat</string>
|
||||
<string name="permission_bluetooth_rationale">Bluetooth permission is required for peer-to-peer messaging without internet.</string>
|
||||
<string name="permission_location_rationale">Location permission is required to discover nearby devices via Bluetooth.</string>
|
||||
|
||||
@ -0,0 +1,157 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Base64
|
||||
|
||||
class CashuTokenDecoderTest {
|
||||
@Test
|
||||
fun `v3 sums proofs and sanitizes display metadata`() {
|
||||
val token = v3Token(
|
||||
"""{"token":[{"mint":"https://MINT.example.com/path","proofs":[{"amount":1},{"amount":4},{"amount":16}]}],"unit":"sat","memo":" lunch\n"}"""
|
||||
)
|
||||
|
||||
val info = CashuTokenDecoder.decode(token, strict = true)
|
||||
|
||||
assertEquals(21L, info?.amount)
|
||||
assertEquals("21 sat", info?.displayAmount)
|
||||
assertEquals("mint.example.com", info?.mintHost)
|
||||
assertEquals("lunch", info?.memo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `display amount applies currency minor units without changing proof sum`() {
|
||||
val usd = CashuTokenDecoder.decode(
|
||||
v3Token("""{"token":[{"proofs":[{"amount":900},{"amount":1}]}],"unit":"usd"}"""),
|
||||
strict = true
|
||||
)
|
||||
val jpy = CashuTokenDecoder.decode(
|
||||
v3Token("""{"token":[{"proofs":[{"amount":901}]}],"unit":"jpy"}"""),
|
||||
strict = true
|
||||
)
|
||||
|
||||
assertEquals(901L, usd?.amount)
|
||||
assertEquals("9.01 usd", usd?.displayAmount)
|
||||
assertEquals("901 jpy", jpy?.displayAmount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `display amount does not assume decimals for custom units`() {
|
||||
val token = CashuTokenDecoder.decode(
|
||||
v3Token("""{"token":[{"proofs":[{"amount":901}]}],"unit":"usdc"}"""),
|
||||
strict = true
|
||||
)
|
||||
|
||||
assertEquals("901 usdc", token?.displayAmount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v4 definite length token decodes strictly`() {
|
||||
val token = validV4Token()
|
||||
|
||||
val info = CashuTokenDecoder.decode(token, strict = true)
|
||||
|
||||
assertEquals(21L, info?.amount)
|
||||
assertEquals("mint.example.com", info?.mintHost)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strict v4 rejects truncation junk and trailing data`() {
|
||||
val valid = validV4Token()
|
||||
val truncated = valid.dropLast(12)
|
||||
val junk = "cashuB" + "Q".repeat(40)
|
||||
val payloadWithTrailingGarbage = decodePayload(valid) + byteArrayOf(0)
|
||||
val trailing = "cashuB" + base64Url(payloadWithTrailingGarbage)
|
||||
|
||||
assertNull(CashuTokenDecoder.decode(truncated, strict = true))
|
||||
assertNull(CashuTokenDecoder.decode(junk, strict = true))
|
||||
assertNull(CashuTokenDecoder.decode(trailing, strict = true))
|
||||
assertNotNull(CashuTokenDecoder.decode(junk))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strict decoding rejects missing nonpositive fractional and overflowing amounts`() {
|
||||
val values = listOf(
|
||||
"""{"token":[{"proofs":[]}]}""",
|
||||
"""{"token":[{"proofs":[{"amount":0}]}]}""",
|
||||
"""{"token":[{"proofs":[{"amount":1.5}]}]}""",
|
||||
"""{"token":[{"proofs":[{"amount":2100000000000000},{"amount":1}]}]}"""
|
||||
)
|
||||
|
||||
values.forEach { assertNull(CashuTokenDecoder.decode(v3Token(it), strict = true)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extracts URI forms as bare deduplicated tokens with a cap`() {
|
||||
val first = v3Token("""{"token":[{"proofs":[{"amount":1}]}]}""")
|
||||
val second = v3Token("""{"token":[{"proofs":[{"amount":2}]}]}""")
|
||||
val text = "cashu:$first and cashu://$first then $second"
|
||||
|
||||
assertEquals(listOf(first, second), CashuTokenDecoder.extractTokens(text, max = 2))
|
||||
assertEquals(first, CashuTokenDecoder.bareToken("cashu%3A$first"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversized and deeply nested input fails closed`() {
|
||||
assertNull(CashuTokenDecoder.decode("cashuA" + "A".repeat(CashuTokenDecoder.MAX_TOKEN_LENGTH)))
|
||||
var nested = byteArrayOf(0)
|
||||
repeat(20) { nested = byteArrayOf(0x81.toByte()) + nested }
|
||||
assertNull(CashuTokenDecoder.decode("cashuB" + base64Url(nested), strict = true))
|
||||
}
|
||||
|
||||
private fun v3Token(json: String): String =
|
||||
"cashuA" + base64Url(json.toByteArray(StandardCharsets.UTF_8))
|
||||
|
||||
private fun validV4Token(): String {
|
||||
val proofs = listOf(1L, 4L, 16L).map { amount ->
|
||||
cborMap(
|
||||
"a" to cborUnsigned(amount),
|
||||
"s" to cborText("secret"),
|
||||
"c" to cborBytes(byteArrayOf(2, 0xab.toByte(), 0xcd.toByte()))
|
||||
)
|
||||
}
|
||||
val payload = cborMap(
|
||||
"m" to cborText("https://mint.example.com"),
|
||||
"u" to cborText("sat"),
|
||||
"t" to cborArray(
|
||||
cborMap(
|
||||
"i" to cborBytes(byteArrayOf(0, 0xad.toByte(), 0x26, 0x8c.toByte())),
|
||||
"p" to cborArray(*proofs.toTypedArray())
|
||||
)
|
||||
)
|
||||
)
|
||||
return "cashuB" + base64Url(payload)
|
||||
}
|
||||
|
||||
private fun cborUnsigned(value: Long) = cborHead(0, value)
|
||||
private fun cborText(value: String) =
|
||||
cborHead(3, value.toByteArray().size.toLong()) + value.toByteArray()
|
||||
private fun cborBytes(value: ByteArray) = cborHead(2, value.size.toLong()) + value
|
||||
private fun cborArray(vararg values: ByteArray) =
|
||||
cborHead(4, values.size.toLong()) + values.fold(byteArrayOf(), ByteArray::plus)
|
||||
private fun cborMap(vararg pairs: Pair<String, ByteArray>) =
|
||||
cborHead(5, pairs.size.toLong()) + pairs.fold(byteArrayOf()) { bytes, pair ->
|
||||
bytes + cborText(pair.first) + pair.second
|
||||
}
|
||||
|
||||
private fun cborHead(major: Int, value: Long): ByteArray = when (value) {
|
||||
in 0..23 -> byteArrayOf(((major shl 5) or value.toInt()).toByte())
|
||||
in 24..255 -> byteArrayOf(((major shl 5) or 24).toByte(), value.toByte())
|
||||
else -> byteArrayOf(
|
||||
((major shl 5) or 25).toByte(),
|
||||
(value ushr 8).toByte(),
|
||||
value.toByte()
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodePayload(token: String): ByteArray {
|
||||
val encoded = token.substring(6)
|
||||
return Base64.getUrlDecoder().decode(encoded + "=".repeat((4 - encoded.length % 4) % 4))
|
||||
}
|
||||
|
||||
private fun base64Url(bytes: ByteArray): String =
|
||||
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user