Cashu ecash chips: detect, render, and redeem tokens + /pay command (#1376)

* Cashu ecash chips: detect, render, and redeem tokens + /pay command

Content-level Cashu support, no wire-protocol changes:

- CashuTokenDecoder: summarizes V3 (cashuA base64url-JSON) tokens —
  amount summed across proofs, unit, mint host, memo — and V4 (cashuB)
  via a minimal bounded CBOR reader. All input is treated as
  adversarial: size caps, depth/item budgets, overflow guards, display
  sanitization; malformed payloads fail closed to a generic chip.
- PaymentChipView: cashu chips now show "500 sat · mint.example.com"
  (+ memo) instead of a generic label; tap opens a cashu: wallet URL
  and falls back to https://redeem.cashu.me when no wallet handles it;
  context menu adds copy token / redeem in wallet / redeem on web.
- extractCashuLinks now returns bare deduplicated bearer strings so the
  chip can decode them (cashu: URIs still detected via the embedded
  token).
- /pay <token>: validates the token decodes, sends it as the message
  body; DMs send directly, public channels require an explicit
  "/pay <token> public" confirm since tokens are bearer instruments.
  Suggested everywhere except public geohash channels.
- Tests: decoder (V3/V4 decode, summation, URI forms, truncation/
  garbage/huge fuzzing, CBOR depth bounds) and /pay command flows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cashu: strict decode on the /pay SEND path

The permissive decoder turned any non-empty cashuB… base64 that failed
CBOR parsing into a generic TokenInfo, so the /pay guard accepted base64
junk and truncated V4 tokens and relayed them with a success message.

Add a `strict` flag to CashuTokenDecoder.decode: in strict mode there is
no permissive V4 fallback and the token must resolve to a known version
with a positive amount, else it returns nil. Rendering keeps the
permissive path (an unknown chip is fine for display). /pay now decodes
with strict:true and surfaces "invalid cashu token" instead of sending.

Tests: /pay with truncated cashuB / base64 junk is rejected; valid V3
and valid definite-length V4 still send; decoder strict-mode unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 14:14:08 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 60be88a4f5
commit 3c610a83cd
9 changed files with 1149 additions and 23 deletions
+60
View File
@@ -15425,6 +15425,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.slap" : {
"extractionState" : "manual",
"localizations" : {
@@ -18469,6 +18481,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",
@@ -19734,6 +19758,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" : {
@@ -19913,6 +19949,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"
}
}
}
},
"encryption.accessibility.establishing" : {
"extractionState" : "manual",
"localizations" : {
+14 -3
View File
@@ -20,6 +20,7 @@ enum CommandInfo: String, Identifiable {
case hug
case message = "msg"
case slap
case pay
case unblock
case who
case favorite = "fav"
@@ -33,6 +34,8 @@ enum CommandInfo: String, Identifiable {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .pay:
return "<" + String(localized: "content.input.token_placeholder") + ">"
case .clear, .help, .who:
return nil
}
@@ -45,6 +48,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")
@@ -54,12 +58,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 in geohash contexts, so only
// suggest them where they actually work: mesh.
if isGeoPublic || isGeoDM {
return baseCommands
return commands
}
return baseCommands + [.favorite, .unfavorite]
return commands + [.favorite, .unfavorite]
}
}
+339
View File
@@ -0,0 +1,339 @@
//
// CashuTokenDecoder.swift
// bitchat
//
// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` =
// base64url CBOR) just far enough to summarize them for the UI: total
// amount, unit, mint host, and memo. The app never contacts a mint tokens
// are bearer strings and redemption is delegated to an external wallet.
//
// This parses attacker-controlled message content, so every path is
// bounds-checked, size-capped, and returns nil instead of trapping.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
enum CashuTokenDecoder {
struct TokenInfo: Equatable {
/// Token serialization version: "A" (JSON) or "B" (CBOR).
let version: String
/// Sum of all proof amounts; nil when no valid amounts were found.
let amount: Int?
/// Currency unit as declared by the token (commonly "sat"), if any.
let unit: String?
/// Host of the (first) mint URL, for display.
let mintHost: String?
/// Optional sender memo, sanitized for display.
let memo: String?
/// "500 sat" style summary, defaulting the unit to sats per NUT-00.
var displayAmount: String? {
amount.map { "\($0) \(unit ?? "sat")" }
}
}
/// Upper bound on accepted token length in characters. Real tokens are a
/// few KB; anything much bigger is abuse we shouldn't spend CPU on.
static let maxTokenLength = 60_000
/// Per-proof and total amount sanity caps (order of total sats in existence).
private static let maxAmount: Int64 = 2_100_000_000_000_000
// MARK: - Public API
/// Extracts the bare `cashuA`/`cashuB` token from raw text that may be
/// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the
/// input doesn't look like a Cashu token at all.
static func bareToken(from raw: String) -> String? {
var token = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = token.lowercased()
if lower.hasPrefix("cashu://") {
token = String(token.dropFirst(8))
} else if lower.hasPrefix("cashu:") {
token = String(token.dropFirst(6))
}
if token.contains("%"), let decoded = token.removingPercentEncoding {
token = decoded
}
guard token.count >= 12, token.count <= maxTokenLength else { return nil }
guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil }
// Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens)
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=."))
guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil }
return token
}
/// Decodes a token (raw or `cashu:` URI form) into a display summary.
///
/// In the default (permissive) mode this is for *rendering*: V3 tokens
/// must parse as JSON, but a V4 token whose CBOR we cannot walk still
/// returns a generic `TokenInfo` (version "B", no amount) because the
/// payload may use encodings this minimal reader doesn't support an
/// unknown chip is fine for display.
///
/// In `strict` mode (used by the `/pay` SEND path) there is no permissive
/// fallback: the token must cleanly decode to a known version *and* carry
/// a positive amount, otherwise this returns nil. This stops base64 junk
/// and truncated V4 tokens from being relayed as if they were valid money.
static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? {
guard let token = bareToken(from: raw) else { return nil }
let version = String(token[token.index(token.startIndex, offsetBy: 5)])
guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else {
return nil
}
let info: TokenInfo?
switch version {
case "A":
info = decodeV3(payload)
case "B":
if let walked = decodeV4(payload) {
info = walked
} else if strict {
// Couldn't cleanly walk the CBOR refuse to send it.
return nil
} else {
info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil)
}
default:
return nil
}
guard let info else { return nil }
if strict {
// A sendable token must resolve to a positive, sane amount.
guard let amount = info.amount, amount > 0 else { return nil }
}
return info
}
// MARK: - Base64url
private static func base64URLDecode(_ input: String) -> Data? {
var s = input
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Normalize padding (wallets emit both padded and unpadded forms)
s = s.replacingOccurrences(of: "=", with: "")
let remainder = s.count % 4
if remainder == 1 { return nil }
if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) }
return Data(base64Encoded: s)
}
// MARK: - V3 (JSON)
private static func decodeV3(_ payload: Data) -> TokenInfo? {
guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any],
let entries = obj["token"] as? [[String: Any]],
!entries.isEmpty else {
return nil
}
var total: Int64 = 0
var sawAmount = false
var mintHost: String?
for entry in entries {
if mintHost == nil, let mint = entry["mint"] as? String {
mintHost = sanitizedHost(from: mint)
}
for proof in (entry["proofs"] as? [[String: Any]]) ?? [] {
guard let number = proof["amount"] as? NSNumber else { continue }
let value = number.int64Value
guard value > 0, value <= maxAmount else { continue }
total += value
guard total <= maxAmount else { return nil }
sawAmount = true
}
}
return TokenInfo(
version: "A",
amount: sawAmount ? Int(total) : nil,
unit: sanitizedUnit(obj["unit"] as? String),
mintHost: mintHost,
memo: sanitizedMemo(obj["memo"] as? String)
)
}
// MARK: - V4 (CBOR)
/// Minimal walk of the NUT-00 TokenV4 CBOR map:
/// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, } ] } ] }
private static func decodeV4(_ payload: Data) -> TokenInfo? {
var reader = CBORReader(data: payload)
guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil }
var mintHost: String?
var unit: String?
var memo: String?
var total: Int64 = 0
var sawAmount = false
for (key, value) in pairs {
guard case .text(let name) = key else { continue }
switch (name, value) {
case ("m", .text(let mint)):
mintHost = sanitizedHost(from: mint)
case ("u", .text(let u)):
unit = sanitizedUnit(u)
case ("d", .text(let d)):
memo = sanitizedMemo(d)
case ("t", .array(let groups)):
for case .map(let group) in groups {
for case (.text("p"), .array(let proofs)) in group {
for case .map(let proof) in proofs {
for case (.text("a"), .unsigned(let amount)) in proof {
guard amount > 0, amount <= UInt64(maxAmount) else { continue }
total += Int64(amount)
guard total <= maxAmount else { return nil }
sawAmount = true
}
}
}
}
default:
break
}
}
return TokenInfo(
version: "B",
amount: sawAmount ? Int(total) : nil,
unit: unit,
mintHost: mintHost,
memo: memo
)
}
// MARK: - Display Sanitization (values are attacker-controlled)
private static func sanitizedHost(from mint: String) -> String? {
guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil }
return String(host.lowercased().prefix(48))
}
private static func sanitizedUnit(_ unit: String?) -> String? {
guard let unit, !unit.isEmpty, unit.count <= 12,
unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else {
return nil
}
return unit
}
private static func sanitizedMemo(_ memo: String?) -> String? {
guard let memo, memo.count <= 512 else { return nil }
let stripped = CharacterSet.controlCharacters.union(.newlines)
var cleaned = ""
cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) })
cleaned = cleaned.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
return String(cleaned.prefix(80))
}
}
// MARK: - Minimal CBOR Reader
/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in
/// depth, item count, and byte length; indefinite-length items and anything
/// else exotic make the parse fail (the caller degrades to a generic chip).
private struct CBORReader {
indirect enum Value {
case unsigned(UInt64)
case text(String)
case array([Value])
case map([(Value, Value)])
/// Parsed-and-skipped content we don't need (byte strings, negatives, floats)
case opaque
}
private let bytes: [UInt8]
private var index = 0
/// Total item budget so hostile nesting can't run away.
private var itemBudget = 50_000
private static let maxDepth = 16
private static let maxContainerCount: UInt64 = 10_000
init(data: Data) {
bytes = [UInt8](data)
}
mutating func parseValue(depth: Int) -> Value? {
guard depth < Self.maxDepth, itemBudget > 0 else { return nil }
itemBudget -= 1
guard let (major, argument) = readHead() else { return nil }
switch major {
case 0: // unsigned int
return .unsigned(argument)
case 1: // negative int (argument already consumed)
return .opaque
case 2: // byte string
return readBytes(count: argument) != nil ? .opaque : nil
case 3: // text string
guard let raw = readBytes(count: argument) else { return nil }
return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque
case 4: // array
guard argument <= Self.maxContainerCount else { return nil }
var items: [Value] = []
items.reserveCapacity(Int(min(argument, 64)))
for _ in 0..<argument {
guard let item = parseValue(depth: depth + 1) else { return nil }
items.append(item)
}
return .array(items)
case 5: // map
guard argument <= Self.maxContainerCount else { return nil }
var pairs: [(Value, Value)] = []
pairs.reserveCapacity(Int(min(argument, 64)))
for _ in 0..<argument {
guard let key = parseValue(depth: depth + 1),
let value = parseValue(depth: depth + 1) else { return nil }
pairs.append((key, value))
}
return .map(pairs)
case 6: // tag: skip the tag number, parse the tagged value
return parseValue(depth: depth + 1)
case 7: // simple values / floats (payload consumed by readHead)
return .opaque
default:
return nil
}
}
/// Reads a CBOR head byte plus its argument. Rejects indefinite lengths.
private mutating func readHead() -> (major: UInt8, argument: UInt64)? {
guard index < bytes.count else { return nil }
let head = bytes[index]
index += 1
let major = head >> 5
let info = head & 0x1F
switch info {
case 0...23:
return (major, UInt64(info))
case 24:
return readUInt(width: 1).map { (major, $0) }
case 25:
return readUInt(width: 2).map { (major, $0) }
case 26:
return readUInt(width: 4).map { (major, $0) }
case 27:
return readUInt(width: 8).map { (major, $0) }
default: // 28-30 reserved, 31 indefinite
return nil
}
}
private mutating func readUInt(width: Int) -> UInt64? {
guard bytes.count - index >= width else { return nil }
var value: UInt64 = 0
for _ in 0..<width {
value = (value << 8) | UInt64(bytes[index])
index += 1
}
return value
}
private mutating func readBytes(count: UInt64) -> [UInt8]? {
guard count <= UInt64(bytes.count - index) else { return nil }
let length = Int(count)
let slice = Array(bytes[index..<(index + length)])
index += length
return slice
}
}
+41
View File
@@ -45,6 +45,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)
@@ -106,6 +108,8 @@ final class CommandProcessor {
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
case "/pay":
return handlePay(args)
case "/help":
return .success(message: Self.helpText)
default:
@@ -125,6 +129,7 @@ final class CommandProcessor {
/slap @name — slap with a large trout
/block @name · /unblock @name
/fav @name · /unfav @name — favorites (mesh only)
/pay <token> — send a cashu ecash token in this chat
/help — this list
"""
@@ -331,6 +336,42 @@ final class CommandProcessor {
return .error(message: "cannot unblock \(nickname): not found")
}
/// `/pay <cashu-token>` validates the token decodes, then sends it as
/// the message body in the current chat. Cashu tokens are bearer
/// instruments (whoever redeems first gets the funds), so posting one to
/// a public channel requires an explicit `/pay <token> public` confirm.
/// The app never contacts a mint; it only relays the string.
private func handlePay(_ args: String) -> CommandResult {
var parts = args.trimmed.split(separator: " ").map(String.init)
guard !parts.isEmpty else {
return .success(message: "usage: /pay <token> — paste a cashu token: /pay cashuA…")
}
let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public"
if confirmedPublic { parts.removeLast() }
guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else {
return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
}
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it")
}
let summary = info.displayAmount ?? "a cashu token"
if let peerID = contextProvider?.selectedPrivateChatPeer {
contextProvider?.sendPrivateMessage(token, to: peerID)
return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds")
}
guard confirmedPublic else {
return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public")
}
contextProvider?.sendPublicMessage(token)
return .success(message: "sent \(summary) to the public channel — anyone here can redeem it")
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmed
guard !targetName.isEmpty else {
+8
View File
@@ -1684,6 +1684,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.sendPublicRaw(content)
}
// Send a normal public message (with local echo) to the active channel.
// CommandContextProvider hook for commands that post real messages
// (`/pay`); only called when no private chat is selected.
@MainActor
func sendPublicMessage(_ content: String) {
sendMessage(content)
}
/// Handle incoming public message
@MainActor
func handlePublicMessage(_ message: BitchatMessage) {
+156 -14
View File
@@ -7,12 +7,17 @@
//
import SwiftUI
#if os(iOS)
import UIKit
#else
import AppKit
#endif
struct PaymentChipView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.openURL) private var openURL
@ThemedPalette private var palette
enum PaymentType {
case cashu(String)
case lightning(String)
@@ -35,14 +40,33 @@ struct PaymentChipView: View {
return URL(string: link)
}
}
/// The bare `cashuA`/`cashuB` bearer string, when this is a Cashu chip.
var cashuToken: String? {
if case .cashu(let link) = self {
return CashuTokenDecoder.bareToken(from: link)
}
return nil
}
/// Web fallback for redemption when no wallet handles `cashu:` URLs.
/// The token only reaches the site the user's browser loads; the app
/// itself never contacts a mint.
var cashuWebRedeemURL: URL? {
guard let token = cashuToken,
let enc = token.addingPercentEncoding(withAllowedCharacters: Self.cashuAllowedCharacters) else {
return nil
}
return URL(string: "https://redeem.cashu.me/?token=\(enc)")
}
var emoji: String {
switch self {
case .cashu: "🥜"
case .lightning: ""
}
}
var label: String {
switch self {
case .cashu:
@@ -52,27 +76,56 @@ struct PaymentChipView: View {
}
}
}
let paymentType: PaymentType
/// Decoded once at construction; tokens are capped in size so this is
/// cheap, and rows re-render often enough that lazy decode in `body`
/// would just repeat the work.
private let cashuInfo: CashuTokenDecoder.TokenInfo?
init(paymentType: PaymentType) {
self.paymentType = paymentType
if case .cashu(let link) = paymentType {
self.cashuInfo = CashuTokenDecoder.decode(link)
} else {
self.cashuInfo = nil
}
}
private var fgColor: Color { palette.primary }
private var bgColor: Color {
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
}
private var border: Color { fgColor.opacity(0.25) }
/// "500 sat · mint.example.com", degrading to the generic label when the
/// token didn't decode (V4 payloads we can't walk, malformed input).
private var primaryLabel: String {
guard let info = cashuInfo else { return paymentType.label }
var parts: [String] = []
if let amount = info.displayAmount { parts.append(amount) }
if let host = info.mintHost { parts.append(host) }
return parts.isEmpty ? paymentType.label : parts.joined(separator: " · ")
}
private var memoLabel: String? { cashuInfo?.memo }
var body: some View {
Button {
#if os(iOS)
if let url = paymentType.url { openURL(url) }
#else
if let url = paymentType.url { NSWorkspace.shared.open(url) }
#endif
primaryAction()
} label: {
HStack(spacing: 6) {
Text(paymentType.emoji)
Text(paymentType.label)
.bitchatFont(size: 12, weight: .semibold)
VStack(alignment: .leading, spacing: 1) {
Text(primaryLabel)
.bitchatFont(size: 12, weight: .semibold)
if let memoLabel {
Text(memoLabel)
.bitchatFont(size: 10)
.opacity(0.7)
.lineLimit(1)
}
}
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
@@ -87,13 +140,102 @@ struct PaymentChipView: View {
.foregroundColor(fgColor)
}
.buttonStyle(.plain)
.contextMenu {
if let token = paymentType.cashuToken {
Button {
copyToPasteboard(token)
} label: {
Label(String(localized: "content.payment.copy_token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc")
}
Button {
redeemCashu()
} label: {
Label(String(localized: "content.payment.redeem_wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass")
}
if let webURL = paymentType.cashuWebRedeemURL {
Button {
openExternalURL(webURL)
} label: {
Label(String(localized: "content.payment.redeem_web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari")
}
}
}
}
.accessibilityLabel(Text(verbatim: accessibilityText))
}
private var accessibilityText: String {
var text = "\(paymentType.label): \(primaryLabel)"
if let memoLabel { text += ", \(memoLabel)" }
return text
}
// MARK: - Actions
private func primaryAction() {
switch paymentType {
case .cashu:
redeemCashu()
case .lightning:
#if os(iOS)
if let url = paymentType.url { openURL(url) }
#else
if let url = paymentType.url { NSWorkspace.shared.open(url) }
#endif
}
}
/// Redemption is delegated: try a wallet registered for `cashu:` URLs
/// first, then fall back to the web redemption page. Uses the platform
/// opener directly (not the `openURL` environment) because the message
/// list overrides that action for cashu/lightning schemes without a
/// fallback path.
private func redeemCashu() {
let walletURL = paymentType.url
let webURL = paymentType.cashuWebRedeemURL
#if os(iOS)
if let walletURL {
UIApplication.shared.open(walletURL, options: [:]) { accepted in
if !accepted, let webURL {
UIApplication.shared.open(webURL)
}
}
} else if let webURL {
UIApplication.shared.open(webURL)
}
#else
if let walletURL, NSWorkspace.shared.urlForApplication(toOpen: walletURL) != nil {
NSWorkspace.shared.open(walletURL)
} else if let webURL {
NSWorkspace.shared.open(webURL)
} else if let walletURL {
NSWorkspace.shared.open(walletURL)
}
#endif
}
private func openExternalURL(_ url: URL) {
#if os(iOS)
UIApplication.shared.open(url)
#else
NSWorkspace.shared.open(url)
#endif
}
private func copyToPasteboard(_ string: String) {
#if os(iOS)
UIPasteboard.general.string = string
#else
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(string, forType: .string)
#endif
}
}
#Preview {
let cashuLink = "https://example.com/cashu"
let lightningLink = "https://example.com/lightning"
List {
HStack {
PaymentChipView(paymentType: .cashu(cashuLink))
+9 -6
View File
@@ -21,17 +21,20 @@ extension String {
return current >= threshold
}
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
// form matches too the token embedded after the scheme is the match.
func extractCashuLinks(max: Int = 3) -> [String] {
let regex = MessageFormattingEngine.Patterns.cashu
let ns = self as NSString
let range = NSRange(location: 0, length: ns.length)
var found: [String] = []
for m in regex.matches(in: self, range: range) {
if m.numberOfRanges > 0 {
let token = ns.substring(with: m.range(at: 0))
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
found.append("cashu:\(enc)")
for m in regex.matches(in: self, range: range) where m.numberOfRanges > 0 {
let token = ns.substring(with: m.range(at: 0))
// Dedup: repeated tokens are one bearer instrument (and duplicate
// ForEach IDs) one chip is enough.
if !found.contains(token) {
found.append(token)
if found.count >= max { break }
}
}