Add Cashu payment chips and pay command

This commit is contained in:
a1denvalu3 2026-07-27 08:52:09 +02:00
parent 4f567ecd5f
commit b9ca8ed3eb
5 changed files with 557 additions and 1 deletions

View 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 }
}
}

View File

@ -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> {

View File

@ -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) {

View File

@ -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>

View File

@ -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)
}