nwc: Wallet Redesign
This PR redesigns the NWC wallet view. A new view is added to introduce zaps to users. The set up wallet view is simplified, with new and existing wallet setup separated. This also adds new NWC features such as getBalance and listTransactions allowing users to see their balance and previous transactions made. Changelog-Added: Added view introducing users to Zaps Changelog-Added: Added new wallet view with balance and transactions list Changelog-Changed: Improved integration with Nostr Wallet Connect wallets Closes: https://github.com/damus-io/damus/issues/2900 Signed-off-by: ericholguin <ericholguin@apache.org> Co-Authored-By: Daniel D’Aquino <daniel@daquino.me> Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
committed by
Daniel D’Aquino
parent
98f2777fda
commit
22f2aba969
15
damus/Util/ExtraFonts.swift
Normal file
15
damus/Util/ExtraFonts.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// ExtraFonts.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-03-13.
|
||||
//
|
||||
import SwiftUI
|
||||
|
||||
extension Font {
|
||||
// Note: When changing the font size accessibility setting, these styles only update after an app restart. It's a current limitation of this.
|
||||
|
||||
static let veryLargeTitle: Font = .system(size: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize * 1.5, weight: .bold) // Makes a bigger title while allowing for iOS dynamic font sizing to take effect
|
||||
static let veryVeryLargeTitle: Font = .system(size: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize * 2.1, weight: .bold) // Makes a bigger title while allowing for iOS dynamic font sizing to take effect
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ enum LogCategory: String {
|
||||
case storage
|
||||
case networking
|
||||
case timeline
|
||||
/// Logs related to Nostr Wallet Connect components
|
||||
case nwc
|
||||
case push_notifications
|
||||
case damus_purple
|
||||
case image_uploading
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
//
|
||||
// WalletConnect+.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-11-27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
func make_wallet_pay_invoice_request(invoice: String) -> WalletRequest<PayInvoiceRequest> {
|
||||
let data = PayInvoiceRequest(invoice: invoice)
|
||||
return WalletRequest(method: "pay_invoice", params: data)
|
||||
}
|
||||
|
||||
func make_wallet_balance_request() -> WalletRequest<EmptyRequest> {
|
||||
return WalletRequest(method: "get_balance", params: nil)
|
||||
}
|
||||
|
||||
struct EmptyRequest: Codable {
|
||||
}
|
||||
|
||||
struct PayInvoiceRequest: Codable {
|
||||
let invoice: String
|
||||
}
|
||||
|
||||
func make_wallet_connect_request<T>(req: WalletRequest<T>, to_pk: Pubkey, keypair: FullKeypair) -> NostrEvent? {
|
||||
let tags = [to_pk.tag]
|
||||
let created_at = UInt32(Date().timeIntervalSince1970)
|
||||
guard let content = encode_json(req) else {
|
||||
return nil
|
||||
}
|
||||
return create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: 23194)
|
||||
}
|
||||
|
||||
func subscribe_to_nwc(url: WalletConnectURL, pool: RelayPool) {
|
||||
var filter = NostrFilter(kinds: [.nwc_response])
|
||||
filter.authors = [url.pubkey]
|
||||
filter.limit = 0
|
||||
let sub = NostrSubscribe(filters: [filter], sub_id: "nwc")
|
||||
|
||||
pool.send(.subscribe(sub), to: [url.relay], skip_ephemeral: false)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? {
|
||||
let req = make_wallet_pay_invoice_request(invoice: invoice)
|
||||
guard let ev = make_wallet_connect_request(req: req, to_pk: url.pubkey, keypair: url.keypair) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
try? pool.add_relay(.nwc(url: url.relay))
|
||||
subscribe_to_nwc(url: url, pool: pool)
|
||||
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
|
||||
return ev
|
||||
}
|
||||
|
||||
|
||||
func nwc_success(state: DamusState, resp: FullWalletResponse) {
|
||||
// find the pending zap and mark it as pending-confirmed
|
||||
for kv in state.zaps.our_zaps {
|
||||
let zaps = kv.value
|
||||
|
||||
for zap in zaps {
|
||||
guard case .pending(let pzap) = zap,
|
||||
case .nwc(let nwc_state) = pzap.state,
|
||||
case .postbox_pending(let nwc_req) = nwc_state.state,
|
||||
nwc_req.id == resp.req_id
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
if nwc_state.update_state(state: .confirmed) {
|
||||
// notify the zaps model of an update so it can mark them as paid
|
||||
state.events.get_cache_data(NoteId(pzap.target.id)).zaps_model.objectWillChange.send()
|
||||
print("NWC success confirmed")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func send_donation_zap(pool: RelayPool, postbox: PostBox, nwc: WalletConnectURL, percent: Int, base_msats: Int64) async {
|
||||
let percent_f = Double(percent) / 100.0
|
||||
let donations_msats = Int64(percent_f * Double(base_msats))
|
||||
|
||||
let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus")
|
||||
guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else {
|
||||
// we failed... oh well. no donation for us.
|
||||
print("damus-donation failed to fetch invoice")
|
||||
return
|
||||
}
|
||||
|
||||
print("damus-donation donating...")
|
||||
nwc_pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil)
|
||||
}
|
||||
|
||||
func nwc_error(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) {
|
||||
// find a pending zap with the nwc request id associated with this response and remove it
|
||||
for kv in zapcache.our_zaps {
|
||||
let zaps = kv.value
|
||||
|
||||
for zap in zaps {
|
||||
guard case .pending(let pzap) = zap,
|
||||
case .nwc(let nwc_state) = pzap.state,
|
||||
case .postbox_pending(let req) = nwc_state.state,
|
||||
req.id == resp.req_id
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
// remove the pending zap if there was an error
|
||||
let reqid = ZapRequestId(from_pending: pzap)
|
||||
remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
//
|
||||
// WalletConnect.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-03-22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct WalletConnectURL: Equatable {
|
||||
static func == (lhs: WalletConnectURL, rhs: WalletConnectURL) -> Bool {
|
||||
return lhs.keypair == rhs.keypair &&
|
||||
lhs.pubkey == rhs.pubkey &&
|
||||
lhs.relay == rhs.relay
|
||||
}
|
||||
|
||||
let relay: RelayURL
|
||||
let keypair: FullKeypair
|
||||
let pubkey: Pubkey
|
||||
let lud16: String?
|
||||
|
||||
func to_url() -> URL {
|
||||
var urlComponents = URLComponents()
|
||||
urlComponents.scheme = "nostrwalletconnect"
|
||||
urlComponents.host = pubkey.hex()
|
||||
urlComponents.queryItems = [
|
||||
URLQueryItem(name: "relay", value: relay.absoluteString),
|
||||
URLQueryItem(name: "secret", value: keypair.privkey.hex())
|
||||
]
|
||||
|
||||
if let lud16 {
|
||||
urlComponents.queryItems?.append(URLQueryItem(name: "lud16", value: lud16))
|
||||
}
|
||||
|
||||
return urlComponents.url!
|
||||
}
|
||||
|
||||
init?(str: String) {
|
||||
guard let components = URLComponents(string: str),
|
||||
components.scheme == "nostrwalletconnect" || components.scheme == "nostr+walletconnect",
|
||||
// The line below provides flexibility for both `nostrwalletconnect://` (non-compliant, but commonly used) and `nostrwalletconnect:` (NIP-47 compliant) formats
|
||||
let encoded_pubkey = components.path == "" ? components.host : components.path,
|
||||
let pubkey = hex_decode_pubkey(encoded_pubkey),
|
||||
let items = components.queryItems,
|
||||
let relay = items.first(where: { qi in qi.name == "relay" })?.value,
|
||||
let relay_url = RelayURL(relay),
|
||||
let secret = items.first(where: { qi in qi.name == "secret" })?.value,
|
||||
secret.utf8.count == 64,
|
||||
let decoded = hex_decode(secret)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let privkey = Privkey(Data(decoded))
|
||||
guard let our_pk = privkey_to_pubkey(privkey: privkey) else { return nil }
|
||||
|
||||
let lud16 = items.first(where: { qi in qi.name == "lud16" })?.value
|
||||
let keypair = FullKeypair(pubkey: our_pk, privkey: privkey)
|
||||
self = WalletConnectURL(pubkey: pubkey, relay: relay_url, keypair: keypair, lud16: lud16)
|
||||
}
|
||||
|
||||
init(pubkey: Pubkey, relay: RelayURL, keypair: FullKeypair, lud16: String?) {
|
||||
self.pubkey = pubkey
|
||||
self.relay = relay
|
||||
self.keypair = keypair
|
||||
self.lud16 = lud16
|
||||
}
|
||||
}
|
||||
|
||||
struct WalletRequest<T: Codable>: Codable {
|
||||
let method: String
|
||||
let params: T?
|
||||
}
|
||||
|
||||
struct WalletResponseErr: Codable {
|
||||
let code: String?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
struct PayInvoiceResponse: Decodable {
|
||||
let preimage: String
|
||||
}
|
||||
|
||||
enum WalletResponseResultType: String {
|
||||
case pay_invoice
|
||||
}
|
||||
|
||||
enum WalletResponseResult {
|
||||
case pay_invoice(PayInvoiceResponse)
|
||||
}
|
||||
|
||||
struct FullWalletResponse {
|
||||
let req_id: NoteId
|
||||
let response: WalletResponse
|
||||
|
||||
init?(from: NostrEvent, nwc: WalletConnectURL) async {
|
||||
guard let note_id = from.referenced_ids.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.req_id = note_id
|
||||
|
||||
let ares = Task {
|
||||
guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64),
|
||||
let resp: WalletResponse = decode_json(json)
|
||||
else {
|
||||
let resp: WalletResponse? = nil
|
||||
return resp
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
guard let res = await ares.value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.response = res
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct WalletResponse: Decodable {
|
||||
let result_type: WalletResponseResultType
|
||||
let error: WalletResponseErr?
|
||||
let result: WalletResponseResult?
|
||||
|
||||
private enum CodingKeys: CodingKey {
|
||||
case result_type, error, result
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let result_type_str = try container.decode(String.self, forKey: .result_type)
|
||||
|
||||
guard let result_type = WalletResponseResultType(rawValue: result_type_str) else {
|
||||
throw DecodingError.typeMismatch(WalletResponseResultType.self, .init(codingPath: decoder.codingPath, debugDescription: "result_type \(result_type_str) is unknown"))
|
||||
}
|
||||
|
||||
self.result_type = result_type
|
||||
self.error = try container.decodeIfPresent(WalletResponseErr.self, forKey: .error)
|
||||
|
||||
guard self.error == nil else {
|
||||
self.result = nil
|
||||
return
|
||||
}
|
||||
|
||||
switch result_type {
|
||||
case .pay_invoice:
|
||||
let res = try container.decode(PayInvoiceResponse.self, forKey: .result)
|
||||
self.result = .pay_invoice(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
137
damus/Util/WalletConnect/Request.swift
Normal file
137
damus/Util/WalletConnect/Request.swift
Normal file
@@ -0,0 +1,137 @@
|
||||
//
|
||||
// Request.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-03-10.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension WalletConnect {
|
||||
/// Models a request to an NWC wallet provider
|
||||
enum Request: Codable {
|
||||
/// Pay an invoice
|
||||
case payInvoice(
|
||||
/// bolt-11 invoice string
|
||||
invoice: String
|
||||
)
|
||||
/// Get the current wallet balance
|
||||
case getBalance
|
||||
/// Get the current wallet transaction history
|
||||
case getTransactionList(
|
||||
/// Starting timestamp in seconds since epoch (inclusive), optional.
|
||||
from: UInt64?,
|
||||
/// Ending timestamp in seconds since epoch (inclusive), optional.
|
||||
until: UInt64?,
|
||||
/// Maximum number of invoices to return, optional.
|
||||
limit: Int?,
|
||||
/// Offset of the first invoice to return, optional.
|
||||
offset: Int?,
|
||||
/// Include unpaid invoices, optional, default false.
|
||||
unpaid: Bool?,
|
||||
/// "incoming" for invoices, "outgoing" for payments, undefined for both.
|
||||
type: String?
|
||||
)
|
||||
|
||||
|
||||
// MARK: - Interface
|
||||
|
||||
/// Converts the NWC request into a raw Nostr event to be sent in the network
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - to_pk: The destination pubkey (used for encryption)
|
||||
/// - keypair: The requester's pubkey (used for encryption and signing)
|
||||
/// - Returns: The NWC request in a raw Nostr Event format, or nil if it cannot be encoded
|
||||
func to_nostr_event(to_pk: Pubkey, keypair: FullKeypair) -> NostrEvent? {
|
||||
let tags = [to_pk.tag]
|
||||
let created_at = UInt32(Date().timeIntervalSince1970)
|
||||
guard let content = encode_json(self) else {
|
||||
return nil
|
||||
}
|
||||
return NIP04.create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: NostrKind.nwc_request.rawValue)
|
||||
}
|
||||
|
||||
// MARK: - Encoding and decoding
|
||||
|
||||
/// Keys for top-level JSON
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case method
|
||||
case params
|
||||
}
|
||||
|
||||
/// Keys for the JSON inside the "params" object
|
||||
private enum ParamKeys: String, CodingKey {
|
||||
case invoice
|
||||
case from, until, limit, offset, unpaid, type
|
||||
}
|
||||
|
||||
/// Constants for possible request "method" verbs
|
||||
private enum Method: String {
|
||||
case payInvoice = "pay_invoice"
|
||||
case getBalance = "get_balance"
|
||||
case listTransactions = "list_transactions"
|
||||
}
|
||||
|
||||
/// Decodes a payload into this request structure
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let method = try container.decode(String.self, forKey: .method)
|
||||
|
||||
|
||||
switch method {
|
||||
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)
|
||||
|
||||
case Method.getBalance.rawValue:
|
||||
// No params to decode
|
||||
self = .getBalance
|
||||
|
||||
case Method.listTransactions.rawValue:
|
||||
let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
||||
let from = try paramsContainer.decodeIfPresent(UInt64.self, forKey: .from)
|
||||
let until = try paramsContainer.decodeIfPresent(UInt64.self, forKey: .until)
|
||||
let limit = try paramsContainer.decodeIfPresent(Int.self, forKey: .limit)
|
||||
let offset = try paramsContainer.decodeIfPresent(Int.self, forKey: .offset)
|
||||
let unpaid = try paramsContainer.decodeIfPresent(Bool.self, forKey: .unpaid)
|
||||
let type = try paramsContainer.decodeIfPresent(String.self, forKey: .type)
|
||||
self = .getTransactionList(from: from, until: until, limit: limit, offset: offset, unpaid: unpaid, type: type)
|
||||
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .method,
|
||||
in: container,
|
||||
debugDescription: "Unknown wallet method \"\(method)\""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes this request structure into a payload
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch self {
|
||||
case .payInvoice(let invoice):
|
||||
try container.encode(Method.payInvoice.rawValue, forKey: .method)
|
||||
var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
||||
try paramsContainer.encode(invoice, forKey: .invoice)
|
||||
|
||||
case .getBalance:
|
||||
try container.encode(Method.getBalance.rawValue, forKey: .method)
|
||||
// "params": null
|
||||
try container.encodeNil(forKey: .params)
|
||||
|
||||
case .getTransactionList(let from, let until, let limit, let offset, let unpaid, let type):
|
||||
try container.encode(Method.listTransactions.rawValue, forKey: .method)
|
||||
var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
||||
try paramsContainer.encodeIfPresent(from, forKey: .from)
|
||||
try paramsContainer.encodeIfPresent(until, forKey: .until)
|
||||
try paramsContainer.encodeIfPresent(limit, forKey: .limit)
|
||||
try paramsContainer.encodeIfPresent(offset, forKey: .offset)
|
||||
try paramsContainer.encodeIfPresent(unpaid, forKey: .unpaid)
|
||||
try paramsContainer.encodeIfPresent(type, forKey: .type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
damus/Util/WalletConnect/Response.swift
Normal file
110
damus/Util/WalletConnect/Response.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// Response.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-03-10.
|
||||
//
|
||||
|
||||
extension WalletConnect {
|
||||
/// Models a response from the NWC provider
|
||||
struct Response: Decodable {
|
||||
let result_type: Response.Result.ResultType
|
||||
let error: WalletResponseErr?
|
||||
let result: Response.Result?
|
||||
|
||||
private enum CodingKeys: CodingKey {
|
||||
case result_type, error, result
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let result_type_str = try container.decode(String.self, forKey: .result_type)
|
||||
|
||||
guard let result_type = Response.Result.ResultType(rawValue: result_type_str) else {
|
||||
throw DecodingError.typeMismatch(Response.Result.ResultType.self, .init(codingPath: decoder.codingPath, debugDescription: "result_type \(result_type_str) is unknown"))
|
||||
}
|
||||
|
||||
self.result_type = result_type
|
||||
self.error = try container.decodeIfPresent(WalletResponseErr.self, forKey: .error)
|
||||
|
||||
guard self.error == nil else {
|
||||
self.result = nil
|
||||
return
|
||||
}
|
||||
|
||||
switch result_type {
|
||||
case .pay_invoice:
|
||||
let res = try container.decode(Result.PayInvoiceResponse.self, forKey: .result)
|
||||
self.result = .pay_invoice(res)
|
||||
case .get_balance:
|
||||
let res = try container.decode(Result.GetBalanceResponse.self, forKey: .result)
|
||||
self.result = .get_balance(res)
|
||||
case .list_transactions:
|
||||
let res = try container.decode(Result.ListTransactionsResponse.self, forKey: .result)
|
||||
self.result = .list_transactions(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FullWalletResponse {
|
||||
let req_id: NoteId
|
||||
let response: Response
|
||||
|
||||
init?(from: NostrEvent, nwc: WalletConnect.ConnectURL) async {
|
||||
guard let note_id = from.referenced_ids.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.req_id = note_id
|
||||
|
||||
let ares = Task {
|
||||
guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64),
|
||||
let resp: WalletConnect.Response = decode_json(json)
|
||||
else {
|
||||
let resp: WalletConnect.Response? = nil
|
||||
return resp
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
guard let res = await ares.value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.response = res
|
||||
}
|
||||
}
|
||||
|
||||
struct WalletResponseErr: Codable {
|
||||
let code: String?
|
||||
let message: String?
|
||||
}
|
||||
}
|
||||
|
||||
extension WalletConnect.Response {
|
||||
/// The response data resulting from an NWC request
|
||||
enum Result {
|
||||
case pay_invoice(PayInvoiceResponse)
|
||||
case get_balance(GetBalanceResponse)
|
||||
case list_transactions(ListTransactionsResponse)
|
||||
|
||||
enum ResultType: String {
|
||||
case pay_invoice
|
||||
case get_balance
|
||||
case list_transactions
|
||||
}
|
||||
|
||||
struct PayInvoiceResponse: Decodable {
|
||||
let preimage: String
|
||||
}
|
||||
|
||||
struct GetBalanceResponse: Decodable {
|
||||
let balance: Int64
|
||||
}
|
||||
|
||||
struct ListTransactionsResponse: Decodable {
|
||||
let transactions: [WalletConnect.Transaction]
|
||||
}
|
||||
}
|
||||
}
|
||||
170
damus/Util/WalletConnect/WalletConnect+.swift
Normal file
170
damus/Util/WalletConnect/WalletConnect+.swift
Normal file
@@ -0,0 +1,170 @@
|
||||
//
|
||||
// WalletConnect+.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-11-27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// TODO: Eventually we should move these convenience functions into structured classes responsible for managing this type of functionality, such as `WalletModel`
|
||||
|
||||
extension WalletConnect {
|
||||
/// Creates and sends a subscription to an NWC relay requesting NWC responses to be sent back.
|
||||
///
|
||||
/// Notes: This assumes there is already a listener somewhere else
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: The Nostr Wallet Connect URL containing connection info to the NWC wallet
|
||||
/// - pool: The RelayPool to send the subscription request through
|
||||
static func subscribe(url: WalletConnectURL, pool: RelayPool) {
|
||||
var filter = NostrFilter(kinds: [.nwc_response])
|
||||
filter.authors = [url.pubkey]
|
||||
filter.limit = 0
|
||||
let sub = NostrSubscribe(filters: [filter], sub_id: "nwc")
|
||||
|
||||
pool.send(.subscribe(sub), to: [url.relay], skip_ephemeral: false)
|
||||
}
|
||||
|
||||
/// Sends out a request to pay an invoice to the NWC relay, and ensures that:
|
||||
/// 1. the NWC relay is connected and we are listening to NWC events
|
||||
/// 2. the NWC relay is connected and we are listening to NWC
|
||||
///
|
||||
/// Note: This does not return information about whether the payment is succesful or not. The actual confirmation is handled elsewhere around `HomeModel` and `WalletModel`
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: The NWC wallet connection URL
|
||||
/// - pool: The relay pool to connect to
|
||||
/// - post: The postbox to send events in
|
||||
/// - delay: The delay before actually sending the request to the network _(this makes it possible to cancel a zap)_
|
||||
/// - 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)
|
||||
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected
|
||||
WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay
|
||||
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
|
||||
return ev
|
||||
}
|
||||
|
||||
/// Sends out a wallet balance request to the NWC relay, and ensures that:
|
||||
/// 1. the NWC relay is connected and we are listening to NWC events
|
||||
/// 2. the NWC relay is connected and we are listening to NWC
|
||||
///
|
||||
/// Note: This does not return the actual balance information. The actual balance is handled elsewhere around `HomeModel` and `WalletModel`
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: The NWC wallet connection URL
|
||||
/// - pool: The relay pool to connect to
|
||||
/// - post: The postbox to send events in
|
||||
/// - delay: The delay before actually sending the request to the network
|
||||
/// - 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 request_balance_information(url: WalletConnectURL, pool: RelayPool, post: PostBox, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) -> NostrEvent? {
|
||||
let req = WalletConnect.Request.getBalance
|
||||
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected
|
||||
WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay
|
||||
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
|
||||
return ev
|
||||
}
|
||||
|
||||
/// Sends out a wallet transaction list request to the NWC relay, and ensures that:
|
||||
/// 1. the NWC relay is connected and we are listening to NWC events
|
||||
/// 2. the NWC relay is connected and we are listening to NWC
|
||||
///
|
||||
/// Note: This does not return the actual transaction list. The actual transaction list is handled elsewhere around `HomeModel` and `WalletModel`
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: The NWC wallet connection URL
|
||||
/// - pool: The relay pool to connect to
|
||||
/// - post: The postbox to send events in
|
||||
/// - delay: The delay before actually sending the request to the network
|
||||
/// - 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 request_transaction_list(url: WalletConnectURL, pool: RelayPool, post: PostBox, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) -> NostrEvent? {
|
||||
let req = WalletConnect.Request.getTransactionList(from: nil, until: nil, limit: 10, offset: 0, unpaid: false, type: "")
|
||||
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected
|
||||
WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay
|
||||
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
|
||||
return ev
|
||||
}
|
||||
|
||||
static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) {
|
||||
// find the pending zap and mark it as pending-confirmed
|
||||
for kv in state.zaps.our_zaps {
|
||||
let zaps = kv.value
|
||||
|
||||
for zap in zaps {
|
||||
guard case .pending(let pzap) = zap,
|
||||
case .nwc(let nwc_state) = pzap.state,
|
||||
case .postbox_pending(let nwc_req) = nwc_state.state,
|
||||
nwc_req.id == resp.req_id
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
if nwc_state.update_state(state: .confirmed) {
|
||||
// notify the zaps model of an update so it can mark them as paid
|
||||
state.events.get_cache_data(NoteId(pzap.target.id)).zaps_model.objectWillChange.send()
|
||||
print("NWC success confirmed")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a donation zap to the Damus team
|
||||
static func send_donation_zap(pool: RelayPool, postbox: PostBox, nwc: WalletConnectURL, percent: Int, base_msats: Int64) async {
|
||||
let percent_f = Double(percent) / 100.0
|
||||
let donations_msats = Int64(percent_f * Double(base_msats))
|
||||
|
||||
let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus")
|
||||
guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else {
|
||||
// we failed... oh well. no donation for us.
|
||||
print("damus-donation failed to fetch invoice")
|
||||
return
|
||||
}
|
||||
|
||||
print("damus-donation donating...")
|
||||
WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil)
|
||||
}
|
||||
|
||||
/// Handles a received Nostr Wallet Connect error
|
||||
static func handle_error(zapcache: Zaps, evcache: EventCache, resp: WalletConnect.FullWalletResponse) {
|
||||
// find a pending zap with the nwc request id associated with this response and remove it
|
||||
for kv in zapcache.our_zaps {
|
||||
let zaps = kv.value
|
||||
|
||||
for zap in zaps {
|
||||
guard case .pending(let pzap) = zap,
|
||||
case .nwc(let nwc_state) = pzap.state,
|
||||
case .postbox_pending(let req) = nwc_state.state,
|
||||
req.id == resp.req_id
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
// remove the pending zap if there was an error
|
||||
let reqid = ZapRequestId(from_pending: pzap)
|
||||
remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
damus/Util/WalletConnect/WalletConnect.swift
Normal file
92
damus/Util/WalletConnect/WalletConnect.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// WalletConnect.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-03-22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct WalletConnect {}
|
||||
|
||||
typealias WalletConnectURL = WalletConnect.ConnectURL // Declared to facilitate refactor
|
||||
|
||||
extension WalletConnect {
|
||||
/// Models a decoded NWC URL, containing information to connect to an NWC wallet.
|
||||
struct ConnectURL: Equatable {
|
||||
let relay: RelayURL
|
||||
let keypair: FullKeypair
|
||||
let pubkey: Pubkey
|
||||
let lud16: String?
|
||||
|
||||
static func == (lhs: ConnectURL, rhs: ConnectURL) -> Bool {
|
||||
return lhs.keypair == rhs.keypair &&
|
||||
lhs.pubkey == rhs.pubkey &&
|
||||
lhs.relay == rhs.relay
|
||||
}
|
||||
|
||||
func to_url() -> URL {
|
||||
var urlComponents = URLComponents()
|
||||
urlComponents.scheme = "nostrwalletconnect"
|
||||
urlComponents.host = pubkey.hex()
|
||||
urlComponents.queryItems = [
|
||||
URLQueryItem(name: "relay", value: relay.absoluteString),
|
||||
URLQueryItem(name: "secret", value: keypair.privkey.hex())
|
||||
]
|
||||
|
||||
if let lud16 {
|
||||
urlComponents.queryItems?.append(URLQueryItem(name: "lud16", value: lud16))
|
||||
}
|
||||
|
||||
return urlComponents.url!
|
||||
}
|
||||
|
||||
init?(str: String) {
|
||||
guard let components = URLComponents(string: str),
|
||||
components.scheme == "nostrwalletconnect" || components.scheme == "nostr+walletconnect",
|
||||
// The line below provides flexibility for both `nostrwalletconnect://` (non-compliant, but commonly used) and `nostrwalletconnect:` (NIP-47 compliant) formats
|
||||
let encoded_pubkey = components.path == "" ? components.host : components.path,
|
||||
let pubkey = hex_decode_pubkey(encoded_pubkey),
|
||||
let items = components.queryItems,
|
||||
let relay = items.first(where: { qi in qi.name == "relay" })?.value,
|
||||
let relay_url = RelayURL(relay),
|
||||
let secret = items.first(where: { qi in qi.name == "secret" })?.value,
|
||||
secret.utf8.count == 64,
|
||||
let decoded = hex_decode(secret)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let privkey = Privkey(Data(decoded))
|
||||
guard let our_pk = privkey_to_pubkey(privkey: privkey) else { return nil }
|
||||
|
||||
let lud16 = items.first(where: { qi in qi.name == "lud16" })?.value
|
||||
let keypair = FullKeypair(pubkey: our_pk, privkey: privkey)
|
||||
self = ConnectURL(pubkey: pubkey, relay: relay_url, keypair: keypair, lud16: lud16)
|
||||
}
|
||||
|
||||
init(pubkey: Pubkey, relay: RelayURL, keypair: FullKeypair, lud16: String?) {
|
||||
self.pubkey = pubkey
|
||||
self.relay = relay
|
||||
self.keypair = keypair
|
||||
self.lud16 = lud16
|
||||
}
|
||||
}
|
||||
|
||||
/// Models an NWC wallet transaction
|
||||
struct Transaction: Decodable, Equatable, Hashable {
|
||||
let type: String
|
||||
let invoice: String?
|
||||
let description: String?
|
||||
let description_hash: String?
|
||||
let preimage: String?
|
||||
let payment_hash: String?
|
||||
let amount: Int64
|
||||
let fees_paid: Int64?
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user