mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:25:20 +00:00
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:
co-authored by
jack
Claude Fable 5
parent
60be88a4f5
commit
3c610a83cd
@@ -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,
|
||||
@@ -477,6 +644,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