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:
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")
+ }
+ }
}