mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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:
@@ -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" : {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user