Merge remote-tracking branch 'origin/feat/cashu-chips' into feat/integration-all

# Conflicts:
#	bitchat/Localizable.xcstrings
#	bitchat/Models/CommandInfo.swift
#	bitchat/Services/CommandProcessor.swift
This commit is contained in:
jack
2026-07-06 22:31:20 +02:00
9 changed files with 1149 additions and 23 deletions
+60
View File
@@ -15507,6 +15507,18 @@
}
}
},
"content.commands.pay" : {
"comment" : "Autocomplete description for the /pay command that sends a Cashu ecash token",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "send a cashu ecash token in this chat"
}
}
}
},
"content.commands.ping" : {
"comment" : "Description of the /ping command in the suggestions panel",
"extractionState" : "manual",
@@ -18598,6 +18610,18 @@
}
}
},
"content.input.token_placeholder" : {
"comment" : "Placeholder shown after /pay in the command suggestion panel",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "token"
}
}
}
},
"content.jump.new_count" : {
"comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill",
"extractionState" : "manual",
@@ -19863,6 +19887,18 @@
}
}
},
"content.payment.copy_token" : {
"comment" : "Context menu action copying a Cashu token to the pasteboard",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "copy token"
}
}
}
},
"content.payment.lightning" : {
"extractionState" : "manual",
"localizations" : {
@@ -20042,6 +20078,30 @@
}
}
},
"content.payment.redeem_wallet" : {
"comment" : "Context menu action opening a Cashu token in an ecash wallet app",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "redeem in wallet"
}
}
}
},
"content.payment.redeem_web" : {
"comment" : "Context menu action opening a Cashu token in the web redemption page",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "redeem on web"
}
}
}
},
"content.private.caption_group" : {
"extractionState" : "manual",
"localizations" : {
+14 -3
View File
@@ -21,6 +21,7 @@ enum CommandInfo: String, Identifiable {
case hug
case message = "msg"
case slap
case pay
case unblock
case who
case favorite = "fav"
@@ -38,6 +39,8 @@ enum CommandInfo: String, Identifiable {
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .group:
return "<" + String(localized: "content.input.group_placeholder") + ">"
case .pay:
return "<" + String(localized: "content.input.token_placeholder") + ">"
case .clear, .help, .who:
return nil
}
@@ -51,6 +54,7 @@ enum CommandInfo: String, Identifiable {
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .pay: String(localized: "content.commands.pay")
case .slap: String(localized: "content.commands.slap")
case .unblock: String(localized: "content.commands.unblock")
case .who: String(localized: "content.commands.who")
@@ -62,12 +66,19 @@ enum CommandInfo: String, Identifiable {
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// Cashu tokens are bearer instruments: in a public geohash any nearby
// stranger can redeem one, so don't *suggest* /pay there (the
// processor still allows it behind an explicit "public" confirm).
// Payments make sense in every DM and in mesh public.
if !isGeoPublic {
commands.append(.pay)
}
// The processor rejects favorites, groups and mesh diagnostics in
// geohash contexts, so only suggest them where they actually work: mesh.
if isGeoPublic || isGeoDM {
return baseCommands
return commands
}
return baseCommands + [.favorite, .unfavorite, .group, .ping, .trace]
return commands + [.favorite, .unfavorite, .group, .ping, .trace]
}
}
+339
View File
@@ -0,0 +1,339 @@
//
// CashuTokenDecoder.swift
// bitchat
//
// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` =
// base64url CBOR) just far enough to summarize them for the UI: total
// amount, unit, mint host, and memo. The app never contacts a mint tokens
// are bearer strings and redemption is delegated to an external wallet.
//
// This parses attacker-controlled message content, so every path is
// bounds-checked, size-capped, and returns nil instead of trapping.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
enum CashuTokenDecoder {
struct TokenInfo: Equatable {
/// Token serialization version: "A" (JSON) or "B" (CBOR).
let version: String
/// Sum of all proof amounts; nil when no valid amounts were found.
let amount: Int?
/// Currency unit as declared by the token (commonly "sat"), if any.
let unit: String?
/// Host of the (first) mint URL, for display.
let mintHost: String?
/// Optional sender memo, sanitized for display.
let memo: String?
/// "500 sat" style summary, defaulting the unit to sats per NUT-00.
var displayAmount: String? {
amount.map { "\($0) \(unit ?? "sat")" }
}
}
/// Upper bound on accepted token length in characters. Real tokens are a
/// few KB; anything much bigger is abuse we shouldn't spend CPU on.
static let maxTokenLength = 60_000
/// Per-proof and total amount sanity caps (order of total sats in existence).
private static let maxAmount: Int64 = 2_100_000_000_000_000
// MARK: - Public API
/// Extracts the bare `cashuA`/`cashuB` token from raw text that may be
/// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the
/// input doesn't look like a Cashu token at all.
static func bareToken(from raw: String) -> String? {
var token = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = token.lowercased()
if lower.hasPrefix("cashu://") {
token = String(token.dropFirst(8))
} else if lower.hasPrefix("cashu:") {
token = String(token.dropFirst(6))
}
if token.contains("%"), let decoded = token.removingPercentEncoding {
token = decoded
}
guard token.count >= 12, token.count <= maxTokenLength else { return nil }
guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil }
// Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens)
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=."))
guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil }
return token
}
/// Decodes a token (raw or `cashu:` URI form) into a display summary.
///
/// In the default (permissive) mode this is for *rendering*: V3 tokens
/// must parse as JSON, but a V4 token whose CBOR we cannot walk still
/// returns a generic `TokenInfo` (version "B", no amount) because the
/// payload may use encodings this minimal reader doesn't support an
/// unknown chip is fine for display.
///
/// In `strict` mode (used by the `/pay` SEND path) there is no permissive
/// fallback: the token must cleanly decode to a known version *and* carry
/// a positive amount, otherwise this returns nil. This stops base64 junk
/// and truncated V4 tokens from being relayed as if they were valid money.
static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? {
guard let token = bareToken(from: raw) else { return nil }
let version = String(token[token.index(token.startIndex, offsetBy: 5)])
guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else {
return nil
}
let info: TokenInfo?
switch version {
case "A":
info = decodeV3(payload)
case "B":
if let walked = decodeV4(payload) {
info = walked
} else if strict {
// Couldn't cleanly walk the CBOR refuse to send it.
return nil
} else {
info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil)
}
default:
return nil
}
guard let info else { return nil }
if strict {
// A sendable token must resolve to a positive, sane amount.
guard let amount = info.amount, amount > 0 else { return nil }
}
return info
}
// MARK: - Base64url
private static func base64URLDecode(_ input: String) -> Data? {
var s = input
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Normalize padding (wallets emit both padded and unpadded forms)
s = s.replacingOccurrences(of: "=", with: "")
let remainder = s.count % 4
if remainder == 1 { return nil }
if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) }
return Data(base64Encoded: s)
}
// MARK: - V3 (JSON)
private static func decodeV3(_ payload: Data) -> TokenInfo? {
guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any],
let entries = obj["token"] as? [[String: Any]],
!entries.isEmpty else {
return nil
}
var total: Int64 = 0
var sawAmount = false
var mintHost: String?
for entry in entries {
if mintHost == nil, let mint = entry["mint"] as? String {
mintHost = sanitizedHost(from: mint)
}
for proof in (entry["proofs"] as? [[String: Any]]) ?? [] {
guard let number = proof["amount"] as? NSNumber else { continue }
let value = number.int64Value
guard value > 0, value <= maxAmount else { continue }
total += value
guard total <= maxAmount else { return nil }
sawAmount = true
}
}
return TokenInfo(
version: "A",
amount: sawAmount ? Int(total) : nil,
unit: sanitizedUnit(obj["unit"] as? String),
mintHost: mintHost,
memo: sanitizedMemo(obj["memo"] as? String)
)
}
// MARK: - V4 (CBOR)
/// Minimal walk of the NUT-00 TokenV4 CBOR map:
/// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, } ] } ] }
private static func decodeV4(_ payload: Data) -> TokenInfo? {
var reader = CBORReader(data: payload)
guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil }
var mintHost: String?
var unit: String?
var memo: String?
var total: Int64 = 0
var sawAmount = false
for (key, value) in pairs {
guard case .text(let name) = key else { continue }
switch (name, value) {
case ("m", .text(let mint)):
mintHost = sanitizedHost(from: mint)
case ("u", .text(let u)):
unit = sanitizedUnit(u)
case ("d", .text(let d)):
memo = sanitizedMemo(d)
case ("t", .array(let groups)):
for case .map(let group) in groups {
for case (.text("p"), .array(let proofs)) in group {
for case .map(let proof) in proofs {
for case (.text("a"), .unsigned(let amount)) in proof {
guard amount > 0, amount <= UInt64(maxAmount) else { continue }
total += Int64(amount)
guard total <= maxAmount else { return nil }
sawAmount = true
}
}
}
}
default:
break
}
}
return TokenInfo(
version: "B",
amount: sawAmount ? Int(total) : nil,
unit: unit,
mintHost: mintHost,
memo: memo
)
}
// MARK: - Display Sanitization (values are attacker-controlled)
private static func sanitizedHost(from mint: String) -> String? {
guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil }
return String(host.lowercased().prefix(48))
}
private static func sanitizedUnit(_ unit: String?) -> String? {
guard let unit, !unit.isEmpty, unit.count <= 12,
unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else {
return nil
}
return unit
}
private static func sanitizedMemo(_ memo: String?) -> String? {
guard let memo, memo.count <= 512 else { return nil }
let stripped = CharacterSet.controlCharacters.union(.newlines)
var cleaned = ""
cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) })
cleaned = cleaned.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
return String(cleaned.prefix(80))
}
}
// MARK: - Minimal CBOR Reader
/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in
/// depth, item count, and byte length; indefinite-length items and anything
/// else exotic make the parse fail (the caller degrades to a generic chip).
private struct CBORReader {
indirect enum Value {
case unsigned(UInt64)
case text(String)
case array([Value])
case map([(Value, Value)])
/// Parsed-and-skipped content we don't need (byte strings, negatives, floats)
case opaque
}
private let bytes: [UInt8]
private var index = 0
/// Total item budget so hostile nesting can't run away.
private var itemBudget = 50_000
private static let maxDepth = 16
private static let maxContainerCount: UInt64 = 10_000
init(data: Data) {
bytes = [UInt8](data)
}
mutating func parseValue(depth: Int) -> Value? {
guard depth < Self.maxDepth, itemBudget > 0 else { return nil }
itemBudget -= 1
guard let (major, argument) = readHead() else { return nil }
switch major {
case 0: // unsigned int
return .unsigned(argument)
case 1: // negative int (argument already consumed)
return .opaque
case 2: // byte string
return readBytes(count: argument) != nil ? .opaque : nil
case 3: // text string
guard let raw = readBytes(count: argument) else { return nil }
return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque
case 4: // array
guard argument <= Self.maxContainerCount else { return nil }
var items: [Value] = []
items.reserveCapacity(Int(min(argument, 64)))
for _ in 0..<argument {
guard let item = parseValue(depth: depth + 1) else { return nil }
items.append(item)
}
return .array(items)
case 5: // map
guard argument <= Self.maxContainerCount else { return nil }
var pairs: [(Value, Value)] = []
pairs.reserveCapacity(Int(min(argument, 64)))
for _ in 0..<argument {
guard let key = parseValue(depth: depth + 1),
let value = parseValue(depth: depth + 1) else { return nil }
pairs.append((key, value))
}
return .map(pairs)
case 6: // tag: skip the tag number, parse the tagged value
return parseValue(depth: depth + 1)
case 7: // simple values / floats (payload consumed by readHead)
return .opaque
default:
return nil
}
}
/// Reads a CBOR head byte plus its argument. Rejects indefinite lengths.
private mutating func readHead() -> (major: UInt8, argument: UInt64)? {
guard index < bytes.count else { return nil }
let head = bytes[index]
index += 1
let major = head >> 5
let info = head & 0x1F
switch info {
case 0...23:
return (major, UInt64(info))
case 24:
return readUInt(width: 1).map { (major, $0) }
case 25:
return readUInt(width: 2).map { (major, $0) }
case 26:
return readUInt(width: 4).map { (major, $0) }
case 27:
return readUInt(width: 8).map { (major, $0) }
default: // 28-30 reserved, 31 indefinite
return nil
}
}
private mutating func readUInt(width: Int) -> UInt64? {
guard bytes.count - index >= width else { return nil }
var value: UInt64 = 0
for _ in 0..<width {
value = (value << 8) | UInt64(bytes[index])
index += 1
}
return value
}
private mutating func readBytes(count: UInt64) -> [UInt8]? {
guard count <= UInt64(bytes.count - index) else { return nil }
let length = Int(count)
let slice = Array(bytes[index..<(index + length)])
index += length
return slice
}
}
+41
View File
@@ -56,6 +56,8 @@ protocol CommandContextProvider: AnyObject {
/// Empties the peer's chat (single-writer store intent for `/clear`).
func clearPrivateChat(_ peerID: PeerID)
func sendPublicRaw(_ content: String)
/// Sends a normal public message (with local echo) to the active channel.
func sendPublicMessage(_ content: String)
// MARK: - System Messages
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
@@ -142,6 +144,8 @@ final class CommandProcessor {
case "/trace":
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
return handleTrace(args)
case "/pay":
return handlePay(args)
case "/help":
return .success(message: Self.helpText)
default:
@@ -166,6 +170,7 @@ final class CommandProcessor {
/group leave · /group list — leave or list your groups
/ping @name — measure round-trip time (mesh only)
/trace @name — estimated mesh path (mesh only)
/pay <token> — send a cashu ecash token in this chat
/help — this list
"""
@@ -468,6 +473,42 @@ final class CommandProcessor {
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
}
/// `/pay <cashu-token>` validates the token decodes, then sends it as
/// the message body in the current chat. Cashu tokens are bearer
/// instruments (whoever redeems first gets the funds), so posting one to
/// a public channel requires an explicit `/pay <token> public` confirm.
/// The app never contacts a mint; it only relays the string.
private func handlePay(_ args: String) -> CommandResult {
var parts = args.trimmed.split(separator: " ").map(String.init)
guard !parts.isEmpty else {
return .success(message: "usage: /pay <token> — paste a cashu token: /pay cashuA…")
}
let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public"
if confirmedPublic { parts.removeLast() }
guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else {
return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
}
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it")
}
let summary = info.displayAmount ?? "a cashu token"
if let peerID = contextProvider?.selectedPrivateChatPeer {
contextProvider?.sendPrivateMessage(token, to: peerID)
return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds")
}
guard confirmedPublic else {
return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public")
}
contextProvider?.sendPublicMessage(token)
return .success(message: "sent \(summary) to the public channel — anyone here can redeem it")
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmed
guard !targetName.isEmpty else {
+8
View File
@@ -1745,6 +1745,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.sendPublicRaw(content)
}
// Send a normal public message (with local echo) to the active channel.
// CommandContextProvider hook for commands that post real messages
// (`/pay`); only called when no private chat is selected.
@MainActor
func sendPublicMessage(_ content: String) {
sendMessage(content)
}
/// Handle incoming public message
@MainActor
func handlePublicMessage(_ message: BitchatMessage) {
+156 -14
View File
@@ -7,12 +7,17 @@
//
import SwiftUI
#if os(iOS)
import UIKit
#else
import AppKit
#endif
struct PaymentChipView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.openURL) private var openURL
@ThemedPalette private var palette
enum PaymentType {
case cashu(String)
case lightning(String)
@@ -35,14 +40,33 @@ struct PaymentChipView: View {
return URL(string: link)
}
}
/// The bare `cashuA`/`cashuB` bearer string, when this is a Cashu chip.
var cashuToken: String? {
if case .cashu(let link) = self {
return CashuTokenDecoder.bareToken(from: link)
}
return nil
}
/// Web fallback for redemption when no wallet handles `cashu:` URLs.
/// The token only reaches the site the user's browser loads; the app
/// itself never contacts a mint.
var cashuWebRedeemURL: URL? {
guard let token = cashuToken,
let enc = token.addingPercentEncoding(withAllowedCharacters: Self.cashuAllowedCharacters) else {
return nil
}
return URL(string: "https://redeem.cashu.me/?token=\(enc)")
}
var emoji: String {
switch self {
case .cashu: "🥜"
case .lightning: ""
}
}
var label: String {
switch self {
case .cashu:
@@ -52,27 +76,56 @@ struct PaymentChipView: View {
}
}
}
let paymentType: PaymentType
/// Decoded once at construction; tokens are capped in size so this is
/// cheap, and rows re-render often enough that lazy decode in `body`
/// would just repeat the work.
private let cashuInfo: CashuTokenDecoder.TokenInfo?
init(paymentType: PaymentType) {
self.paymentType = paymentType
if case .cashu(let link) = paymentType {
self.cashuInfo = CashuTokenDecoder.decode(link)
} else {
self.cashuInfo = nil
}
}
private var fgColor: Color { palette.primary }
private var bgColor: Color {
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
}
private var border: Color { fgColor.opacity(0.25) }
/// "500 sat · mint.example.com", degrading to the generic label when the
/// token didn't decode (V4 payloads we can't walk, malformed input).
private var primaryLabel: String {
guard let info = cashuInfo else { return paymentType.label }
var parts: [String] = []
if let amount = info.displayAmount { parts.append(amount) }
if let host = info.mintHost { parts.append(host) }
return parts.isEmpty ? paymentType.label : parts.joined(separator: " · ")
}
private var memoLabel: String? { cashuInfo?.memo }
var body: some View {
Button {
#if os(iOS)
if let url = paymentType.url { openURL(url) }
#else
if let url = paymentType.url { NSWorkspace.shared.open(url) }
#endif
primaryAction()
} label: {
HStack(spacing: 6) {
Text(paymentType.emoji)
Text(paymentType.label)
.bitchatFont(size: 12, weight: .semibold)
VStack(alignment: .leading, spacing: 1) {
Text(primaryLabel)
.bitchatFont(size: 12, weight: .semibold)
if let memoLabel {
Text(memoLabel)
.bitchatFont(size: 10)
.opacity(0.7)
.lineLimit(1)
}
}
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
@@ -87,13 +140,102 @@ struct PaymentChipView: View {
.foregroundColor(fgColor)
}
.buttonStyle(.plain)
.contextMenu {
if let token = paymentType.cashuToken {
Button {
copyToPasteboard(token)
} label: {
Label(String(localized: "content.payment.copy_token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc")
}
Button {
redeemCashu()
} label: {
Label(String(localized: "content.payment.redeem_wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass")
}
if let webURL = paymentType.cashuWebRedeemURL {
Button {
openExternalURL(webURL)
} label: {
Label(String(localized: "content.payment.redeem_web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari")
}
}
}
}
.accessibilityLabel(Text(verbatim: accessibilityText))
}
private var accessibilityText: String {
var text = "\(paymentType.label): \(primaryLabel)"
if let memoLabel { text += ", \(memoLabel)" }
return text
}
// MARK: - Actions
private func primaryAction() {
switch paymentType {
case .cashu:
redeemCashu()
case .lightning:
#if os(iOS)
if let url = paymentType.url { openURL(url) }
#else
if let url = paymentType.url { NSWorkspace.shared.open(url) }
#endif
}
}
/// Redemption is delegated: try a wallet registered for `cashu:` URLs
/// first, then fall back to the web redemption page. Uses the platform
/// opener directly (not the `openURL` environment) because the message
/// list overrides that action for cashu/lightning schemes without a
/// fallback path.
private func redeemCashu() {
let walletURL = paymentType.url
let webURL = paymentType.cashuWebRedeemURL
#if os(iOS)
if let walletURL {
UIApplication.shared.open(walletURL, options: [:]) { accepted in
if !accepted, let webURL {
UIApplication.shared.open(webURL)
}
}
} else if let webURL {
UIApplication.shared.open(webURL)
}
#else
if let walletURL, NSWorkspace.shared.urlForApplication(toOpen: walletURL) != nil {
NSWorkspace.shared.open(walletURL)
} else if let webURL {
NSWorkspace.shared.open(webURL)
} else if let walletURL {
NSWorkspace.shared.open(walletURL)
}
#endif
}
private func openExternalURL(_ url: URL) {
#if os(iOS)
UIApplication.shared.open(url)
#else
NSWorkspace.shared.open(url)
#endif
}
private func copyToPasteboard(_ string: String) {
#if os(iOS)
UIPasteboard.general.string = string
#else
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(string, forType: .string)
#endif
}
}
#Preview {
let cashuLink = "https://example.com/cashu"
let lightningLink = "https://example.com/lightning"
List {
HStack {
PaymentChipView(paymentType: .cashu(cashuLink))
+9 -6
View File
@@ -21,17 +21,20 @@ extension String {
return current >= threshold
}
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
// form matches too the token embedded after the scheme is the match.
func extractCashuLinks(max: Int = 3) -> [String] {
let regex = MessageFormattingEngine.Patterns.cashu
let ns = self as NSString
let range = NSRange(location: 0, length: ns.length)
var found: [String] = []
for m in regex.matches(in: self, range: range) {
if m.numberOfRanges > 0 {
let token = ns.substring(with: m.range(at: 0))
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
found.append("cashu:\(enc)")
for m in regex.matches(in: self, range: range) where m.numberOfRanges > 0 {
let token = ns.substring(with: m.range(at: 0))
// Dedup: repeated tokens are one bearer instrument (and duplicate
// ForEach IDs) one chip is enough.
if !found.contains(token) {
found.append(token)
if found.count >= max { break }
}
}
+350
View File
@@ -0,0 +1,350 @@
//
// CashuTokenDecoderTests.swift
// bitchatTests
//
// Tests for the Cashu token summary decoder: V3 JSON decode, minimal V4
// CBOR traversal, URI normalization, detection ranges, and adversarial
// (truncated / garbage / huge) input. The decoder renders attacker-controlled
// message content, so "never crash" matters as much as "decode correctly".
// This is free and unencumbered software released into the public domain.
//
import Foundation
import Testing
@testable import bitchat
struct CashuTokenDecoderTests {
// MARK: - Token Builders
private func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func makeV3Token(
entries: [(mint: String, amounts: [Int])],
unit: String? = "sat",
memo: String? = nil
) -> String {
var json: [String: Any] = [
"token": entries.map { entry in
[
"mint": entry.mint,
"proofs": entry.amounts.map {
["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] as [String: Any]
}
] as [String: Any]
}
]
if let unit { json["unit"] = unit }
if let memo { json["memo"] = memo }
let data = try! JSONSerialization.data(withJSONObject: json)
return "cashuA" + base64URL(data)
}
/// Tiny deterministic CBOR encoder (definite lengths only) for building
/// V4 test tokens without depending on the decoder under test.
private enum CBOREncode {
static func head(_ major: UInt8, _ value: UInt64) -> [UInt8] {
switch value {
case 0...23:
return [(major << 5) | UInt8(value)]
case 24...0xFF:
return [(major << 5) | 24, UInt8(value)]
case 0x100...0xFFFF:
return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)]
default:
return [(major << 5) | 26,
UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF),
UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)]
}
}
static func uint(_ v: UInt64) -> [UInt8] { head(0, v) }
static func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b }
static func text(_ s: String) -> [UInt8] {
let utf8 = Array(s.utf8)
return head(3, UInt64(utf8.count)) + utf8
}
static func array(_ items: [[UInt8]]) -> [UInt8] {
head(4, UInt64(items.count)) + items.flatMap { $0 }
}
static func map(_ pairs: [(String, [UInt8])]) -> [UInt8] {
head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 }
}
}
private func makeV4Token(
mint: String = "https://mint.example.com",
unit: String = "sat",
memo: String? = nil,
amounts: [UInt64] = [1, 4]
) -> String {
var pairs: [(String, [UInt8])] = [
("m", CBOREncode.text(mint)),
("u", CBOREncode.text(unit))
]
if let memo { pairs.append(("d", CBOREncode.text(memo))) }
let proofs = amounts.map { amount in
CBOREncode.map([
("a", CBOREncode.uint(amount)),
("s", CBOREncode.text("secret")),
("c", CBOREncode.bytes([0x02, 0xAB, 0xCD]))
])
}
pairs.append(("t", CBOREncode.array([
CBOREncode.map([
("i", CBOREncode.bytes([0x00, 0xAD, 0x26, 0x8C])),
("p", CBOREncode.array(proofs))
])
])))
return "cashuB" + base64URL(Data(CBOREncode.map(pairs)))
}
// MARK: - V3 Decode
@Test func v3DecodeValidToken() {
let token = makeV3Token(
entries: [("https://mint.example.com", [2, 8])],
unit: "sat",
memo: "thanks!"
)
let info = CashuTokenDecoder.decode(token)
#expect(info != nil)
#expect(info?.version == "A")
#expect(info?.amount == 10)
#expect(info?.unit == "sat")
#expect(info?.mintHost == "mint.example.com")
#expect(info?.memo == "thanks!")
#expect(info?.displayAmount == "10 sat")
}
@Test func v3AmountSumsAcrossEntriesAndProofs() {
let token = makeV3Token(entries: [
("https://a.mint.example", [1, 2, 4]),
("https://b.mint.example", [8, 16])
])
let info = CashuTokenDecoder.decode(token)
#expect(info?.amount == 31)
// First mint wins for the display host
#expect(info?.mintHost == "a.mint.example")
}
@Test func v3MissingUnitDefaultsToSatForDisplay() {
let token = makeV3Token(entries: [("https://mint.example.com", [5])], unit: nil)
let info = CashuTokenDecoder.decode(token)
#expect(info?.unit == nil)
#expect(info?.displayAmount == "5 sat")
}
@Test func v3RejectsNonsenseAmounts() {
// Negative and absurd amounts must not poison the sum
let json: [String: Any] = [
"token": [[
"mint": "https://mint.example.com",
"proofs": [
["amount": -5, "id": "x", "secret": "s", "C": "c"],
["amount": 3, "id": "x", "secret": "s", "C": "c"]
]
] as [String: Any]]
]
let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json))
#expect(CashuTokenDecoder.decode(token)?.amount == 3)
}
@Test func v3MemoIsSanitizedForDisplay() {
let token = makeV3Token(
entries: [("https://mint.example.com", [1])],
memo: "line1\nline2\u{0007}" + String(repeating: "x", count: 300)
)
let memo = CashuTokenDecoder.decode(token)?.memo
#expect(memo != nil)
#expect(memo?.contains("\n") == false)
#expect(memo?.contains("\u{0007}") == false)
#expect((memo?.count ?? 0) <= 80)
}
// MARK: - V4 (CBOR) Decode
@Test func v4DecodeValidToken() {
let token = makeV4Token(memo: "Thank you", amounts: [1, 4, 16])
let info = CashuTokenDecoder.decode(token)
#expect(info?.version == "B")
#expect(info?.amount == 21)
#expect(info?.unit == "sat")
#expect(info?.mintHost == "mint.example.com")
#expect(info?.memo == "Thank you")
}
@Test func v4UnparseableCBORDegradesToGenericToken() {
// Valid base64 payload, but not CBOR we can walk: still a token,
// rendered as a generic chip with no amount.
let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02]))
let info = CashuTokenDecoder.decode(token)
#expect(info?.version == "B")
#expect(info?.amount == nil)
#expect(info?.mintHost == nil)
}
// MARK: - Strict Mode (used by the /pay SEND path)
@Test func strictAcceptsValidV3WithPositiveAmount() {
let token = makeV3Token(entries: [("https://mint.example.com", [2, 8])])
let info = CashuTokenDecoder.decode(token, strict: true)
#expect(info?.version == "A")
#expect(info?.amount == 10)
}
@Test func strictAcceptsValidDefiniteLengthV4() {
let token = makeV4Token(amounts: [1, 4, 16])
let info = CashuTokenDecoder.decode(token, strict: true)
#expect(info?.version == "B")
#expect(info?.amount == 21)
}
@Test func strictRejectsUnwalkableV4() {
// Valid base64, but not CBOR we can walk: permissive mode returns a
// generic chip, strict mode refuses it.
let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02]))
#expect(CashuTokenDecoder.decode(token)?.version == "B")
#expect(CashuTokenDecoder.decode(token, strict: true) == nil)
}
@Test func strictRejectsTruncatedV4() {
let token = makeV4Token(amounts: [1, 4, 16])
// Lop off the tail of the base64 payload CBOR can no longer be walked.
let truncated = String(token.prefix(token.count - 12))
#expect(CashuTokenDecoder.decode(truncated, strict: true) == nil)
}
@Test func strictRejectsAmountlessToken() {
// A well-formed V3 token that carries no positive proof amount.
let json: [String: Any] = [
"token": [[
"mint": "https://mint.example.com",
"proofs": [["amount": 0, "id": "x", "secret": "s", "C": "c"] as [String: Any]]
] as [String: Any]]
]
let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json))
#expect(CashuTokenDecoder.decode(token)?.amount == nil)
#expect(CashuTokenDecoder.decode(token, strict: true) == nil)
}
// MARK: - URI Form and Normalization
@Test func uriFormsDecode() {
let token = makeV3Token(entries: [("https://mint.example.com", [7])])
for wrapped in ["cashu:\(token)", "cashu://\(token)", "CASHU:\(token)"] {
#expect(CashuTokenDecoder.bareToken(from: wrapped) == token, "failed for \(wrapped)")
#expect(CashuTokenDecoder.decode(wrapped)?.amount == 7)
}
}
@Test func percentEncodedURIDecodes() {
let token = makeV3Token(entries: [("https://mint.example.com", [7])])
let encoded = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
#expect(CashuTokenDecoder.decode("cashu:\(encoded)")?.amount == 7)
}
@Test func bareTokenRejectsNonTokens() {
#expect(CashuTokenDecoder.bareToken(from: "hello world") == nil)
#expect(CashuTokenDecoder.bareToken(from: "cashuC" + String(repeating: "a", count: 50)) == nil)
#expect(CashuTokenDecoder.bareToken(from: "cashuA{not-base64!}") == nil)
#expect(CashuTokenDecoder.bareToken(from: "cashuA") == nil) // too short
}
// MARK: - Adversarial Input (never crash, fail closed)
@Test func truncatedTokensNeverCrash() {
let v3 = makeV3Token(entries: [("https://mint.example.com", [1, 2, 4, 8])], memo: "memo")
let v4 = makeV4Token(memo: "memo", amounts: [1, 2, 4, 8])
for token in [v3, v4] {
for length in stride(from: 0, to: token.count, by: 3) {
_ = CashuTokenDecoder.decode(String(token.prefix(length)))
}
}
// Truncating the payload must not produce a phantom V3 summary
#expect(CashuTokenDecoder.decode(String(v3.prefix(v3.count - 10))) == nil)
}
@Test func garbagePayloadsNeverCrash() {
var rng = SystemRandomNumberGenerator()
for _ in 0..<200 {
let length = Int.random(in: 0..<600, using: &rng)
let junk = Data((0..<length).map { _ in UInt8.random(in: 0...255, using: &rng) })
_ = CashuTokenDecoder.decode("cashuA" + base64URL(junk))
_ = CashuTokenDecoder.decode("cashuB" + base64URL(junk))
}
}
@Test func hugeInputIsRejectedQuickly() {
let huge = "cashuA" + String(repeating: "Q", count: 500_000)
#expect(CashuTokenDecoder.bareToken(from: huge) == nil)
#expect(CashuTokenDecoder.decode(huge) == nil)
}
@Test func absurdAmountsFailClosed() {
// Each proof exceeds the per-proof sanity cap: skipped, no amount.
let perProofJSON: [String: Any] = [
"token": [[
"mint": "https://mint.example.com",
"proofs": [["amount": Int64.max / 2, "id": "x", "secret": "s", "C": "c"] as [String: Any]]
] as [String: Any]]
]
let perProofToken = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: perProofJSON))
#expect(CashuTokenDecoder.decode(perProofToken)?.amount == nil)
// Individually plausible proofs whose *sum* overflows the cap: the
// token is nonsense, reject it entirely (never trap on the add).
let sumJSON: [String: Any] = [
"token": [[
"mint": "https://mint.example.com",
"proofs": (0..<3).map { _ in
["amount": 1_500_000_000_000_000, "id": "x", "secret": "s", "C": "c"] as [String: Any]
}
] as [String: Any]]
]
let sumToken = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: sumJSON))
#expect(CashuTokenDecoder.decode(sumToken) == nil)
}
@Test func deeplyNestedCBORIsBounded() {
// 64 nested single-element arrays around an int: deeper than the
// reader's depth cap, must fail cleanly.
var payload = CBOREncode.uint(1)
for _ in 0..<64 { payload = CBOREncode.array([payload]) }
let token = "cashuB" + base64URL(Data(payload))
let info = CashuTokenDecoder.decode(token)
#expect(info?.amount == nil)
}
// MARK: - Detection Ranges (message scanning)
@Test func detectionFindsWholeMessageToken() {
let token = makeV3Token(entries: [("https://mint.example.com", [1])])
#expect(token.extractCashuLinks() == [token])
}
@Test func detectionFindsEmbeddedAndURITokens() {
let token = makeV3Token(entries: [("https://mint.example.com", [1])])
let message = "here you go: cashu:\(token) enjoy!"
// The regex matches the token embedded after the scheme
#expect(message.extractCashuLinks() == [token])
let embedded = "prefix \(token) suffix"
#expect(embedded.extractCashuLinks() == [token])
}
@Test func detectionDeduplicatesRepeatedTokens() {
let token = makeV3Token(entries: [("https://mint.example.com", [1])])
let message = "\(token) and again \(token)"
#expect(message.extractCashuLinks() == [token])
}
@Test func detectionIgnoresNonTokens() {
#expect("just talking about cashu here".extractCashuLinks().isEmpty)
#expect("cashuAshort".extractCashuLinks().isEmpty)
}
}
+172
View File
@@ -370,6 +370,173 @@ struct CommandProcessorTests {
}
}
// MARK: - /pay
@MainActor
@Test func payWithoutArgumentsPrintsUsage() {
let processor = makePayProcessor(context: MockCommandContextProvider())
switch processor.process("/pay") {
case .success(let message):
#expect(message?.contains("usage: /pay") == true)
default:
Issue.record("Expected success (usage) result")
}
}
@MainActor
@Test func payRejectsInvalidToken() {
let context = MockCommandContextProvider()
let processor = makePayProcessor(context: context)
for bad in ["/pay nonsense", "/pay cashuAshort", "/pay cashuA!!!!!!!!!!!!!!!!"] {
switch processor.process(bad) {
case .error:
break
default:
Issue.record("Expected error for \(bad)")
}
}
#expect(context.sentPrivateMessages.isEmpty)
#expect(context.sentPublicMessages.isEmpty)
}
@MainActor
@Test func paySendsBareTokenInPrivateChat() {
let context = MockCommandContextProvider()
let peerID = PeerID(str: "abcd1234abcd1234")
context.selectedPrivateChatPeer = peerID
let processor = makePayProcessor(context: context)
// cashu: URI form must be normalized to the bare token before sending
switch processor.process("/pay cashu:\(Self.validV3Token)") {
case .success(let message):
#expect(message?.contains("21 sat") == true)
default:
Issue.record("Expected success result")
}
#expect(context.sentPrivateMessages.count == 1)
#expect(context.sentPrivateMessages.first?.content == Self.validV3Token)
#expect(context.sentPrivateMessages.first?.peerID == peerID)
#expect(context.sentPublicMessages.isEmpty)
}
@MainActor
@Test func payInPublicChannelRequiresExplicitConfirm() {
let context = MockCommandContextProvider()
let processor = makePayProcessor(context: context)
switch processor.process("/pay \(Self.validV3Token)") {
case .error(let message):
#expect(message.contains("public") == true)
default:
Issue.record("Expected error without confirm")
}
#expect(context.sentPublicMessages.isEmpty)
switch processor.process("/pay \(Self.validV3Token) public") {
case .success:
break
default:
Issue.record("Expected success with confirm")
}
#expect(context.sentPublicMessages == [Self.validV3Token])
#expect(context.sentPrivateMessages.isEmpty)
}
@MainActor
@Test func payRejectsTruncatedOrJunkV4Token() {
let context = MockCommandContextProvider()
context.selectedPrivateChatPeer = PeerID(str: "abcd1234abcd1234")
let processor = makePayProcessor(context: context)
// Truncated V4 (definite-length CBOR can no longer be walked) and
// pure base64 junk under the cashuB prefix must both be refused.
let truncatedV4 = String(Self.validV4Token.prefix(Self.validV4Token.count - 12))
let junkV4 = "cashuB" + String(repeating: "Q", count: 40)
for bad in ["/pay \(truncatedV4)", "/pay \(junkV4)"] {
switch processor.process(bad) {
case .error(let message):
#expect(message.contains("invalid cashu token") == true)
default:
Issue.record("Expected error for \(bad)")
}
}
#expect(context.sentPrivateMessages.isEmpty)
#expect(context.sentPublicMessages.isEmpty)
}
@MainActor
@Test func paySendsValidDefiniteLengthV4Token() {
let context = MockCommandContextProvider()
let peerID = PeerID(str: "abcd1234abcd1234")
context.selectedPrivateChatPeer = peerID
let processor = makePayProcessor(context: context)
switch processor.process("/pay \(Self.validV4Token)") {
case .success(let message):
#expect(message?.contains("21 sat") == true)
default:
Issue.record("Expected success result for valid V4 token")
}
#expect(context.sentPrivateMessages.count == 1)
#expect(context.sentPrivateMessages.first?.content == Self.validV4Token)
}
/// 21-sat single-mint V3 token (proofs of 1+4+16).
private static let validV3Token: String = {
let json: [String: Any] = [
"token": [[
"mint": "https://mint.example.com",
"proofs": [1, 4, 16].map { ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] }
]],
"unit": "sat"
]
let data = try! JSONSerialization.data(withJSONObject: json)
let b64 = data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return "cashuA" + b64
}()
/// 21-sat single-mint definite-length V4 (CBOR) token (proofs of 1+4+16).
private static let validV4Token: String = {
func head(_ major: UInt8, _ value: UInt64) -> [UInt8] {
switch value {
case 0...23: return [(major << 5) | UInt8(value)]
case 24...0xFF: return [(major << 5) | 24, UInt8(value)]
default: return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)]
}
}
func text(_ s: String) -> [UInt8] { head(3, UInt64(s.utf8.count)) + Array(s.utf8) }
func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b }
func uint(_ v: UInt64) -> [UInt8] { head(0, v) }
func array(_ items: [[UInt8]]) -> [UInt8] { head(4, UInt64(items.count)) + items.flatMap { $0 } }
func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } }
let proofs = [UInt64(1), 4, 16].map { amount in
map([("a", uint(amount)), ("s", text("secret")), ("c", bytes([0x02, 0xAB, 0xCD]))])
}
let cbor = map([
("m", text("https://mint.example.com")),
("u", text("sat")),
("t", array([map([("i", bytes([0x00, 0xAD, 0x26, 0x8C])), ("p", array(proofs))])]))
])
let b64 = Data(cbor).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return "cashuB" + b64
}()
@MainActor
private func makePayProcessor(context: MockCommandContextProvider) -> CommandProcessor {
CommandProcessor(
contextProvider: context,
meshService: MockTransport(),
identityManager: MockIdentityManager(MockKeychain())
)
}
@MainActor
private func withSelectedChannel<T>(
_ channel: ChannelID,
@@ -479,6 +646,11 @@ private final class MockCommandContextProvider: CommandContextProvider {
sentPublicRawMessages.append(content)
}
private(set) var sentPublicMessages: [String] = []
func sendPublicMessage(_ content: String) {
sentPublicMessages.append(content)
}
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
localPrivateSystemMessages.append((content, peerID))
}