From b9ca8ed3eb446477c63f52104bd7f53d8b50de25 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Mon, 27 Jul 2026 08:52:09 +0200 Subject: [PATCH] Add Cashu payment chips and pay command --- .../bitchat/android/ui/CashuTokenDecoder.kt | 287 ++++++++++++++++++ .../bitchat/android/ui/CommandProcessor.kt | 91 +++++- .../bitchat/android/ui/MessageComponents.kt | 18 ++ app/src/main/res/values/strings.xml | 5 + .../android/ui/CashuTokenDecoderTest.kt | 157 ++++++++++ 5 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/CashuTokenDecoderTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt b/app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt new file mode 100644 index 00000000..ee17ffa1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt @@ -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 { + if (text.isEmpty() || max <= 0) return emptyList() + val matches = TOKEN_REGEX.findAll(text) + val result = LinkedHashSet() + 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() + 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 + data class MapValue(val pairs: List>) : 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>(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? { + if (count > 10_000 || count > itemBudget) return null + val values = ArrayList(count.coerceAtMost(64).toInt()) + repeat(count.toInt()) { values += parseValue(depth + 1) ?: return null } + return wrap(values) + } + + private fun readHead(): Pair? { + 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 } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index f306ca04..ab5235d9 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -26,6 +26,7 @@ class CommandProcessor( CommandSuggestion("/hug", emptyList(), "", "send someone a warm hug"), CommandSuggestion("/j", listOf("/join"), "", "join or create a channel"), CommandSuggestion("/m", listOf("/msg"), " [message]", "send private message"), + CommandSuggestion("/pay", emptyList(), " [public]", "send a Cashu ecash token"), CommandSuggestion("/slap", emptyList(), "", "slap someone with a trout"), CommandSuggestion("/unblock", emptyList(), "", "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?) -> Unit, + viewModel: ChatViewModel? + ) { + val args = command.trim().split(Regex("\\s+")).drop(1) + if (args.isEmpty()) { + addSystemMessage("usage: /pay [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 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, input: String): List { diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 16c7f63d..91713a6d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 497e342d..7b54b62a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,10 @@ + pay via Cashu + Cashu payment + Copy token + Redeem in wallet + Redeem on web bitchat Bluetooth permission is required for peer-to-peer messaging without internet. Location permission is required to discover nearby devices via Bluetooth. diff --git a/app/src/test/java/com/bitchat/android/ui/CashuTokenDecoderTest.kt b/app/src/test/java/com/bitchat/android/ui/CashuTokenDecoderTest.kt new file mode 100644 index 00000000..eb20f62c --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/CashuTokenDecoderTest.kt @@ -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) = + 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) +}