mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:45:20 +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" : {
|
"content.commands.ping" : {
|
||||||
"comment" : "Description of the /ping command in the suggestions panel",
|
"comment" : "Description of the /ping command in the suggestions panel",
|
||||||
"extractionState" : "manual",
|
"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" : {
|
"content.jump.new_count" : {
|
||||||
"comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill",
|
"comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill",
|
||||||
"extractionState" : "manual",
|
"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" : {
|
"content.payment.lightning" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"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" : {
|
"content.private.caption_group" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ enum CommandInfo: String, Identifiable {
|
|||||||
case hug
|
case hug
|
||||||
case message = "msg"
|
case message = "msg"
|
||||||
case slap
|
case slap
|
||||||
|
case pay
|
||||||
case unblock
|
case unblock
|
||||||
case who
|
case who
|
||||||
case favorite = "fav"
|
case favorite = "fav"
|
||||||
@@ -38,6 +39,8 @@ enum CommandInfo: String, Identifiable {
|
|||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||||
case .group:
|
case .group:
|
||||||
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
||||||
|
case .pay:
|
||||||
|
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
||||||
case .clear, .help, .who:
|
case .clear, .help, .who:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -51,6 +54,7 @@ enum CommandInfo: String, Identifiable {
|
|||||||
case .help: String(localized: "content.commands.help")
|
case .help: String(localized: "content.commands.help")
|
||||||
case .hug: String(localized: "content.commands.hug")
|
case .hug: String(localized: "content.commands.hug")
|
||||||
case .message: String(localized: "content.commands.message")
|
case .message: String(localized: "content.commands.message")
|
||||||
|
case .pay: String(localized: "content.commands.pay")
|
||||||
case .slap: String(localized: "content.commands.slap")
|
case .slap: String(localized: "content.commands.slap")
|
||||||
case .unblock: String(localized: "content.commands.unblock")
|
case .unblock: String(localized: "content.commands.unblock")
|
||||||
case .who: String(localized: "content.commands.who")
|
case .who: String(localized: "content.commands.who")
|
||||||
@@ -62,12 +66,19 @@ enum CommandInfo: String, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
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
|
// The processor rejects favorites, groups and mesh diagnostics in
|
||||||
// geohash contexts, so only suggest them where they actually work: mesh.
|
// geohash contexts, so only suggest them where they actually work: mesh.
|
||||||
if isGeoPublic || isGeoDM {
|
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`).
|
/// Empties the peer's chat (single-writer store intent for `/clear`).
|
||||||
func clearPrivateChat(_ peerID: PeerID)
|
func clearPrivateChat(_ peerID: PeerID)
|
||||||
func sendPublicRaw(_ content: String)
|
func sendPublicRaw(_ content: String)
|
||||||
|
/// Sends a normal public message (with local echo) to the active channel.
|
||||||
|
func sendPublicMessage(_ content: String)
|
||||||
|
|
||||||
// MARK: - System Messages
|
// MARK: - System Messages
|
||||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||||
@@ -142,6 +144,8 @@ final class CommandProcessor {
|
|||||||
case "/trace":
|
case "/trace":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
|
||||||
return handleTrace(args)
|
return handleTrace(args)
|
||||||
|
case "/pay":
|
||||||
|
return handlePay(args)
|
||||||
case "/help":
|
case "/help":
|
||||||
return .success(message: Self.helpText)
|
return .success(message: Self.helpText)
|
||||||
default:
|
default:
|
||||||
@@ -166,6 +170,7 @@ final class CommandProcessor {
|
|||||||
/group leave · /group list — leave or list your groups
|
/group leave · /group list — leave or list your groups
|
||||||
/ping @name — measure round-trip time (mesh only)
|
/ping @name — measure round-trip time (mesh only)
|
||||||
/trace @name — estimated mesh path (mesh only)
|
/trace @name — estimated mesh path (mesh only)
|
||||||
|
/pay <token> — send a cashu ecash token in this chat
|
||||||
/help — this list
|
/help — this list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -468,6 +473,42 @@ final class CommandProcessor {
|
|||||||
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
|
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 {
|
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmed
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
|
|||||||
@@ -1745,6 +1745,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
publicConversationCoordinator.sendPublicRaw(content)
|
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
|
/// Handle incoming public message
|
||||||
@MainActor
|
@MainActor
|
||||||
func handlePublicMessage(_ message: BitchatMessage) {
|
func handlePublicMessage(_ message: BitchatMessage) {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct PaymentChipView: View {
|
struct PaymentChipView: View {
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@@ -36,6 +41,25 @@ struct PaymentChipView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
var emoji: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .cashu: "🥜"
|
case .cashu: "🥜"
|
||||||
@@ -54,6 +78,19 @@ struct PaymentChipView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let paymentType: PaymentType
|
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 fgColor: Color { palette.primary }
|
||||||
private var bgColor: Color {
|
private var bgColor: Color {
|
||||||
@@ -61,18 +98,34 @@ struct PaymentChipView: View {
|
|||||||
}
|
}
|
||||||
private var border: Color { fgColor.opacity(0.25) }
|
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 {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
#if os(iOS)
|
primaryAction()
|
||||||
if let url = paymentType.url { openURL(url) }
|
|
||||||
#else
|
|
||||||
if let url = paymentType.url { NSWorkspace.shared.open(url) }
|
|
||||||
#endif
|
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(paymentType.emoji)
|
Text(paymentType.emoji)
|
||||||
Text(paymentType.label)
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
.bitchatFont(size: 12, weight: .semibold)
|
Text(primaryLabel)
|
||||||
|
.bitchatFont(size: 12, weight: .semibold)
|
||||||
|
if let memoLabel {
|
||||||
|
Text(memoLabel)
|
||||||
|
.bitchatFont(size: 10)
|
||||||
|
.opacity(0.7)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -87,6 +140,95 @@ struct PaymentChipView: View {
|
|||||||
.foregroundColor(fgColor)
|
.foregroundColor(fgColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,17 +21,20 @@ extension String {
|
|||||||
return current >= threshold
|
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] {
|
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||||
let regex = MessageFormattingEngine.Patterns.cashu
|
let regex = MessageFormattingEngine.Patterns.cashu
|
||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
let range = NSRange(location: 0, length: ns.length)
|
||||||
var found: [String] = []
|
var found: [String] = []
|
||||||
for m in regex.matches(in: self, range: range) {
|
for m in regex.matches(in: self, range: range) where m.numberOfRanges > 0 {
|
||||||
if m.numberOfRanges > 0 {
|
let token = ns.substring(with: m.range(at: 0))
|
||||||
let token = ns.substring(with: m.range(at: 0))
|
// Dedup: repeated tokens are one bearer instrument (and duplicate
|
||||||
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
// ForEach IDs) — one chip is enough.
|
||||||
found.append("cashu:\(enc)")
|
if !found.contains(token) {
|
||||||
|
found.append(token)
|
||||||
if found.count >= max { break }
|
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
|
@MainActor
|
||||||
private func withSelectedChannel<T>(
|
private func withSelectedChannel<T>(
|
||||||
_ channel: ChannelID,
|
_ channel: ChannelID,
|
||||||
@@ -479,6 +646,11 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
|||||||
sentPublicRawMessages.append(content)
|
sentPublicRawMessages.append(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private(set) var sentPublicMessages: [String] = []
|
||||||
|
func sendPublicMessage(_ content: String) {
|
||||||
|
sentPublicMessages.append(content)
|
||||||
|
}
|
||||||
|
|
||||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
|
||||||
localPrivateSystemMessages.append((content, peerID))
|
localPrivateSystemMessages.append((content, peerID))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user