damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 309b00380de5dbfdabc88d9e1c44a18ab35fae51
parent 7fa2118480ea23696cf149dd2d356e1c6e9ce984
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Wed, 23 Apr 2025 19:47:20 -0700

Add description and metadata to pay_invoice command

Changelog-Added: Zap receiver information now included for outgoing zaps
Closes: https://github.com/damus-io/damus/issues/2927
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>

Diffstat:
Mdamus/Components/NoteZapButton.swift | 2+-
Mdamus/Models/HomeModel.swift | 1+
Mdamus/Util/WalletConnect/Request.swift | 48++++++++++++++++++++++++++++++++++++++++++++----
Mdamus/Util/WalletConnect/WalletConnect+.swift | 7++++---
Mdamus/Util/WalletConnect/WalletConnect.swift | 2+-
Mdamus/Views/Wallet/TransactionsView.swift | 22++++++++++++++++------
MdamusTests/WalletConnectTests.swift | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
7 files changed, 173 insertions(+), 16 deletions(-)

diff --git a/damus/Components/NoteZapButton.swift b/damus/Components/NoteZapButton.swift @@ -240,7 +240,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust // we don't have a delay on one-tap nozaps (since this will be from customize zap view) let delay = damus_state.settings.nozaps ? nil : 5.0 - let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: inv, delay: delay, on_flush: flusher) + let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: inv, zap_request: zapreq, delay: delay, on_flush: flusher) guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else { print("nwc: failed to send nwc request for zapreq \(reqid.reqid)") diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -263,6 +263,7 @@ class HomeModel: ContactsDelegate { guard let nwc_str = damus_state.settings.nostr_wallet_connect, let nwc = WalletConnectURL(str: nwc_str), let resp = await WalletConnect.FullWalletResponse(from: ev, nwc: nwc) else { + Log.error("HomeModel: Received NWC response I do not understand", for: .nwc) return } diff --git a/damus/Util/WalletConnect/Request.swift b/damus/Util/WalletConnect/Request.swift @@ -13,7 +13,11 @@ extension WalletConnect { /// Pay an invoice case payInvoice( /// bolt-11 invoice string - invoice: String + invoice: String, + /// The full description of the invoice (If description does not fit in the BOLT-11 invoice, this is the pre-image of the description hash) + description: String?, + /// Optional metadata object containing more information + metadata: Metadata? ) /// Get the current wallet balance case getBalance @@ -33,6 +37,38 @@ extension WalletConnect { type: String? ) + static func payZapRequest(invoice: String, zapRequest: NostrEvent?) -> Self { + guard let zapRequest, let zapRequestEncoded = encode_json(zapRequest) else { + return WalletConnect.Request.payInvoice( + invoice: invoice, + description: nil, + metadata: nil + ) + } + return WalletConnect.Request.payInvoice( + invoice: invoice, + description: zapRequestEncoded, + metadata: .init(nostr: zapRequest) + ) + } + + struct Metadata: Codable, Equatable, Hashable { + /// NIP-57-compliant `kind:9734` zap request event + let nostr: NostrEvent? + + init(nostr: NostrEvent?) { + self.nostr = nostr + } + + init(from decoder: any Decoder) throws { + let container: KeyedDecodingContainer<WalletConnect.Request.Metadata.CodingKeys> = try decoder.container(keyedBy: WalletConnect.Request.Metadata.CodingKeys.self) + guard let decodedZapRequest = try? container.decodeIfPresent(NostrEvent.self, forKey: WalletConnect.Request.Metadata.CodingKeys.nostr) else { + self.nostr = nil // Be lenient and fallback to nil if the NWC provider provided something invalid, since metadata is not strictly spec'd yet. + return + } + self.nostr = decodedZapRequest + } + } // MARK: - Interface @@ -61,7 +97,7 @@ extension WalletConnect { /// Keys for the JSON inside the "params" object private enum ParamKeys: String, CodingKey { - case invoice + case invoice, description, metadata case from, until, limit, offset, unpaid, type } @@ -82,7 +118,9 @@ extension WalletConnect { case Method.payInvoice.rawValue: let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params) let invoice = try paramsContainer.decode(String.self, forKey: .invoice) - self = .payInvoice(invoice: invoice) + let description: String? = try paramsContainer.decodeIfPresent(String.self, forKey: .description) + let metadata: Metadata? = try paramsContainer.decodeIfPresent(Metadata.self, forKey: .metadata) + self = .payInvoice(invoice: invoice, description: description, metadata: metadata) case Method.getBalance.rawValue: // No params to decode @@ -112,10 +150,12 @@ extension WalletConnect { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case .payInvoice(let invoice): + case .payInvoice(let invoice, let description, let metadata): try container.encode(Method.payInvoice.rawValue, forKey: .method) var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params) try paramsContainer.encode(invoice, forKey: .invoice) + try paramsContainer.encodeIfPresent(description, forKey: .description) + try paramsContainer.encodeIfPresent(metadata, forKey: .metadata) case .getBalance: try container.encode(Method.getBalance.rawValue, forKey: .method) diff --git a/damus/Util/WalletConnect/WalletConnect+.swift b/damus/Util/WalletConnect/WalletConnect+.swift @@ -40,8 +40,9 @@ extension WalletConnect { /// - on_flush: A callback to call after the event has been flushed to the network /// - Returns: The Nostr Event that was sent to the network, representing the request that was made @discardableResult - static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? { - let req = WalletConnect.Request.payInvoice(invoice: invoice) + static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, zap_request: NostrEvent?, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? { + + let req = WalletConnect.Request.payZapRequest(invoice: invoice, zapRequest: zap_request) guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else { return nil } @@ -142,7 +143,7 @@ extension WalletConnect { } print("damus-donation donating...") - WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil) + WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, zap_request: nil, delay: nil) } /// Handles a received Nostr Wallet Connect error diff --git a/damus/Util/WalletConnect/WalletConnect.swift b/damus/Util/WalletConnect/WalletConnect.swift @@ -86,7 +86,7 @@ extension WalletConnect { let created_at: UInt64 // unixtimestamp, // invoice/payment creation time let expires_at: UInt64? // unixtimestamp, // invoice expiration time, optional if not applicable let settled_at: UInt64? // unixtimestamp, // invoice/payment settlement time, optional if unpaid - //"metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc. + let metadata: WalletConnect.Request.Metadata? // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc. } } diff --git a/damus/Views/Wallet/TransactionsView.swift b/damus/Views/Wallet/TransactionsView.swift @@ -20,8 +20,8 @@ struct TransactionView: View { let created_at = Date.init(timeIntervalSince1970: TimeInterval(transaction.created_at)) let formatter = RelativeDateTimeFormatter() let relativeDate = formatter.localizedString(for: created_at, relativeTo: Date.now) - let event = decode_nostr_event_json(transaction.description ?? "") - let pubkey = (event?.pubkey ?? ANON_PUBKEY) + let event = decode_nostr_event_json(transaction.description ?? "") ?? transaction.metadata?.nostr + let pubkey = self.pubkeyToDisplay(for: event, isIncomingTransaction: isIncomingTransaction) ?? ANON_PUBKEY VStack(alignment: .leading) { HStack(alignment: .center) { @@ -74,6 +74,16 @@ struct TransactionView: View { } } + func pubkeyToDisplay(for zapRequest: NostrEvent?, isIncomingTransaction: Bool) -> Pubkey? { + guard let zapRequest else { return nil } + if isIncomingTransaction { + return zapRequest.pubkey // We want to know who sent it to us + } + else { + return zapRequest.referenced_pubkeys.first // We want to know who we sent it to + } + } + func userDisplayName(pubkey: Pubkey) -> String { let profile_txn = damus_state.profiles.lookup(id: pubkey, txn_name: "txview-profile") let profile = profile_txn?.unsafeUnownedValue @@ -139,10 +149,10 @@ struct TransactionsView: View { struct TransactionsView_Previews: PreviewProvider { static let tds = test_damus_state - static let transaction1: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "{\"id\":\"7c0999a5870ca3ba0186a29a8650152b555cee29b53b5b8747d8a3798042d01c\",\"pubkey\":\"b8851a06dfd79d48fc325234a15e9a46a32a0982a823b54cdf82514b9b120ba1\",\"created_at\":1736383715,\"kind\":9734,\"tags\":[[\"p\",\"520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626\"],[\"amount\",\"21000\"],[\"e\",\"a25e152a4cd1b3bbc3d22e8e9315d8ea1f35c227b2f212c7cff18abff36fa208\"],[\"relays\",\"wss://nos.lol\",\"wss://nostr.wine\",\"wss://premium.primal.net\",\"wss://relay.damus.io\",\"wss://relay.nostr.band\",\"wss://relay.nostrarabia.com\"]],\"content\":\"🫡 Onward!\",\"sig\":\"e77d16822fa21b9c2e6b580b51c470588052c14aeb222f08f0e735027e366157c8742a6d5cb850780c2bf44ac63d89b048e5cc56dd47a1bfc740a3173e578f4e\"}", description_hash: "", preimage: "", payment_hash: "1234567890", amount: 21000, fees_paid: 0, created_at: 1737736866, expires_at: 0, settled_at: 0) - static let transaction2: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "123456789033", amount: 100000000, fees_paid: 0, created_at: 1737690090, expires_at: 0, settled_at: 0) - static let transaction3: WalletConnect.Transaction = WalletConnect.Transaction(type: "outgoing", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "123456789042", amount: 303000, fees_paid: 0, created_at: 1737590101, expires_at: 0, settled_at: 0) - static let transaction4: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "1234567890662", amount: 720000, fees_paid: 0, created_at: 1737090300, expires_at: 0, settled_at: 0) + static let transaction1: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "{\"id\":\"7c0999a5870ca3ba0186a29a8650152b555cee29b53b5b8747d8a3798042d01c\",\"pubkey\":\"b8851a06dfd79d48fc325234a15e9a46a32a0982a823b54cdf82514b9b120ba1\",\"created_at\":1736383715,\"kind\":9734,\"tags\":[[\"p\",\"520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626\"],[\"amount\",\"21000\"],[\"e\",\"a25e152a4cd1b3bbc3d22e8e9315d8ea1f35c227b2f212c7cff18abff36fa208\"],[\"relays\",\"wss://nos.lol\",\"wss://nostr.wine\",\"wss://premium.primal.net\",\"wss://relay.damus.io\",\"wss://relay.nostr.band\",\"wss://relay.nostrarabia.com\"]],\"content\":\"🫡 Onward!\",\"sig\":\"e77d16822fa21b9c2e6b580b51c470588052c14aeb222f08f0e735027e366157c8742a6d5cb850780c2bf44ac63d89b048e5cc56dd47a1bfc740a3173e578f4e\"}", description_hash: "", preimage: "", payment_hash: "1234567890", amount: 21000, fees_paid: 0, created_at: 1737736866, expires_at: 0, settled_at: 0, metadata: nil) + static let transaction2: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "123456789033", amount: 100000000, fees_paid: 0, created_at: 1737690090, expires_at: 0, settled_at: 0, metadata: nil) + static let transaction3: WalletConnect.Transaction = WalletConnect.Transaction(type: "outgoing", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "123456789042", amount: 303000, fees_paid: 0, created_at: 1737590101, expires_at: 0, settled_at: 0, metadata: nil) + static let transaction4: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "1234567890662", amount: 720000, fees_paid: 0, created_at: 1737090300, expires_at: 0, settled_at: 0, metadata: nil) static var test_transactions: [WalletConnect.Transaction] = [transaction1, transaction2, transaction3, transaction4] static var previews: some View { diff --git a/damusTests/WalletConnectTests.swift b/damusTests/WalletConnectTests.swift @@ -87,7 +87,7 @@ final class WalletConnectTests: XCTestCase { let pool = RelayPool(ndb: .empty) let box = PostBox(pool: pool) - WalletConnect.pay(url: nwc, pool: pool, post: box, invoice: "invoice") + WalletConnect.pay(url: nwc, pool: pool, post: box, invoice: "invoice", zap_request: nil) XCTAssertEqual(pool.our_descriptors.count, 0) XCTAssertEqual(pool.all_descriptors.count, 1) @@ -99,4 +99,109 @@ final class WalletConnectTests: XCTestCase { XCTAssertEqual(ev.remaining.count, 1) XCTAssertEqual(ev.remaining[0].relay.url.absoluteString, "ws://127.0.0.1") } + + let testBolt11 = "lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs" + let testStringEncodedZapRequest = """ + {"content":"","created_at":1746235486,"id":"faf5192c6805dea002e50cd52c7e553e3ee66ac42f30f41f1fe62b924f68fb22","kind":9734,"pubkey":"056b5b5966f500defb3b790a14633e5ec4a0e8883ca29bc23d0030553edb084a","sig":"21076018677656a220977c77e34bfa7427e1056a49b633afd3653d1d7466846cf6b35cf3fbf5908c712ebd647119cfadb1fa47e83121a238d77b1996f0fa26ee","tags":[["p","e8361082333142fc7f483b7dbd9bb36d671f2fbcf0a28015b2304fed79365fe8"],["relays","wss://nos.lol","wss://notify.damus.io","wss://relay.damus.io"]]} + """ + let testDoubleStringEncodedZapRequest = """ + "{\\\"content\\\":\\\"\\\",\\\"created_at\\\":1746235486,\\\"id\\\":\\\"faf5192c6805dea002e50cd52c7e553e3ee66ac42f30f41f1fe62b924f68fb22\\\",\\\"kind\\\":9734,\\\"pubkey\\\":\\\"056b5b5966f500defb3b790a14633e5ec4a0e8883ca29bc23d0030553edb084a\\\",\\\"sig\\\":\\\"21076018677656a220977c77e34bfa7427e1056a49b633afd3653d1d7466846cf6b35cf3fbf5908c712ebd647119cfadb1fa47e83121a238d77b1996f0fa26ee\\\",\\\"tags\\\":[[\\\"p\\\",\\\"e8361082333142fc7f483b7dbd9bb36d671f2fbcf0a28015b2304fed79365fe8\\\"],[\\\"relays\\\",\\\"wss://nos.lol\\\",\\\"wss://notify.damus.io\\\",\\\"wss://relay.damus.io\\\"]]}" + """ + + func testEncodingPayInvoiceRequest() throws { + let testZapRequest = decode_nostr_event_json(json: testStringEncodedZapRequest)! + let metadata = WalletConnect.Request.Metadata(nostr: testZapRequest) + let request = WalletConnect.Request.payInvoice(invoice: "lntest", description: testStringEncodedZapRequest, metadata: metadata) + + let encodedData = try JSONEncoder().encode(request) + let encodedString = String(data: encodedData, encoding: .utf8)! + + XCTAssertTrue(encodedString.contains("\"method\":\"pay_invoice\"")) + XCTAssertTrue(encodedString.contains("\"invoice\":\"lntest\"")) + XCTAssertTrue(encodedString.contains("\"description\":\"{")) + XCTAssertTrue(encodedString.contains("\"nostr\":{")) + } + + func testDecodingPayInvoiceRequest() throws { + let testZapRequest = decode_nostr_event_json(json: testStringEncodedZapRequest)! + + let jsonText = """ + { + "method": "pay_invoice", + "params": { + "invoice": "\(testBolt11)", + "description": \(testDoubleStringEncodedZapRequest), + "metadata": { + "nostr": \(testStringEncodedZapRequest) + } + } + } + """ + + let jsonData = jsonText.data(using: .utf8)! + + let decodedRequest = try JSONDecoder().decode(WalletConnect.Request.self, from: jsonData) + + switch decodedRequest { + case .payInvoice(let invoice, let description, let metadata): + XCTAssertEqual(invoice, testBolt11) + XCTAssertEqual(description, testStringEncodedZapRequest) + XCTAssertNotNil(metadata) + XCTAssertEqual(metadata!.nostr, testZapRequest) + default: + XCTFail("Decoded to the wrong case") + } + } + + func testDecodingPayInvoiceRequestWithoutMetadata() throws { + let jsonData = """ + { + "method": "pay_invoice", + "params": { + "invoice": "\(testBolt11)", + "description": \(testDoubleStringEncodedZapRequest) + } + } + """.data(using: .utf8)! + + let decodedRequest = try JSONDecoder().decode(WalletConnect.Request.self, from: jsonData) + + switch decodedRequest { + case .payInvoice(let invoice, let description, let metadata): + XCTAssertEqual(invoice, testBolt11) + XCTAssertEqual(description, testStringEncodedZapRequest) + XCTAssertNil(metadata) + default: + XCTFail("Decoded to the wrong case") + } + } + + func testDecodingPayInvoiceRequestWithCrazyMetadata() throws { + let jsonText = """ + { + "method": "pay_invoice", + "params": { + "invoice": "\(testBolt11)", + "description": \(testDoubleStringEncodedZapRequest), + "metadata": { + "nostr": "totally not a zap request because this metadata is crazy", + "lorem": "ipsum" + } + } + } + """ + + let jsonData = jsonText.data(using: .utf8)! + + let decodedRequest = try JSONDecoder().decode(WalletConnect.Request.self, from: jsonData) + + switch decodedRequest { + case .payInvoice(let invoice, let description, let metadata): + XCTAssertEqual(invoice, testBolt11) + XCTAssertEqual(description, testStringEncodedZapRequest) + XCTAssertEqual(metadata?.nostr, nil) + default: + XCTFail("Decoded to the wrong case") + } + } }