AlbyHub does not use description hash invoices. We had some code that looked for zap request invoices inside the description which albyhub does not do. Change our code to always get the zap_request from the description. Changelog-Fixed: Fix albyhub zaps not appearing Signed-off-by: William Casarin <jb55@jb55.com>
539 lines
14 KiB
Swift
539 lines
14 KiB
Swift
//
|
|
// Zap.swift
|
|
// damus
|
|
//
|
|
// Created by William Casarin on 2023-01-15.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
struct NoteZapTarget: Equatable, Hashable {
|
|
public let note_id: NoteId
|
|
public let author: Pubkey
|
|
}
|
|
|
|
enum ZapTarget: Equatable, Hashable {
|
|
case profile(Pubkey)
|
|
case note(NoteZapTarget)
|
|
|
|
static func note(id: NoteId, author: Pubkey) -> ZapTarget {
|
|
return .note(NoteZapTarget(note_id: id, author: author))
|
|
}
|
|
|
|
var pubkey: Pubkey {
|
|
switch self {
|
|
case .profile(let pk):
|
|
return pk
|
|
case .note(let note_target):
|
|
return note_target.author
|
|
}
|
|
}
|
|
|
|
var note_id: NoteId? {
|
|
switch self {
|
|
case .profile:
|
|
return nil
|
|
case .note(let noteZapTarget):
|
|
return noteZapTarget.note_id
|
|
}
|
|
}
|
|
|
|
var id: Data {
|
|
switch self {
|
|
case .profile(let pubkey):
|
|
return pubkey.id
|
|
case .note(let noteZapTarget):
|
|
return noteZapTarget.note_id.id
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ZapRequest {
|
|
let ev: NostrEvent
|
|
let marked_hidden: Bool
|
|
|
|
var id: ZapRequestId {
|
|
ZapRequestId(from_zap_request: self)
|
|
}
|
|
|
|
var is_in_thread: Bool {
|
|
return !self.ev.content.isEmpty && !marked_hidden
|
|
}
|
|
|
|
init(ev: NostrEvent) {
|
|
self.ev = ev
|
|
self.marked_hidden = ev.tags.first(where: { t in t.count > 0 && t[0].matches_str("hidden") }) != nil
|
|
}
|
|
}
|
|
|
|
enum ExtPendingZapStateType {
|
|
case fetching_invoice
|
|
case done
|
|
}
|
|
|
|
class ExtPendingZapState: Equatable {
|
|
static func == (lhs: ExtPendingZapState, rhs: ExtPendingZapState) -> Bool {
|
|
return lhs.state == rhs.state
|
|
}
|
|
|
|
var state: ExtPendingZapStateType
|
|
|
|
init(state: ExtPendingZapStateType) {
|
|
self.state = state
|
|
}
|
|
}
|
|
|
|
enum PendingZapState: Equatable {
|
|
case nwc(NWCPendingZapState)
|
|
case external(ExtPendingZapState)
|
|
}
|
|
|
|
|
|
enum NWCStateType: Equatable {
|
|
case fetching_invoice
|
|
case cancel_fetching_invoice
|
|
case postbox_pending(NostrEvent)
|
|
case confirmed
|
|
case failed
|
|
}
|
|
|
|
class NWCPendingZapState: Equatable {
|
|
private(set) var state: NWCStateType
|
|
let url: WalletConnectURL
|
|
|
|
init(state: NWCStateType, url: WalletConnectURL) {
|
|
self.state = state
|
|
self.url = url
|
|
}
|
|
|
|
//@discardableResult -- not discardable, the ZapsDataModel may need to send objectWillChange but we don't force it
|
|
func update_state(state: NWCStateType) -> Bool {
|
|
guard state != self.state else {
|
|
return false
|
|
}
|
|
self.state = state
|
|
return true
|
|
}
|
|
|
|
static func == (lhs: NWCPendingZapState, rhs: NWCPendingZapState) -> Bool {
|
|
return lhs.state == rhs.state && lhs.url == rhs.url
|
|
}
|
|
}
|
|
|
|
class PendingZap {
|
|
let amount_msat: Int64
|
|
let target: ZapTarget
|
|
let request: ZapRequest
|
|
let type: ZapType
|
|
private(set) var state: PendingZapState
|
|
|
|
init(amount_msat: Int64, target: ZapTarget, request: MakeZapRequest, type: ZapType, state: PendingZapState) {
|
|
self.amount_msat = amount_msat
|
|
self.target = target
|
|
self.request = request.private_inner_request
|
|
self.type = type
|
|
self.state = state
|
|
}
|
|
|
|
@discardableResult
|
|
func update_state(model: ZapsDataModel, state: PendingZapState) -> Bool {
|
|
guard self.state != state else {
|
|
return false
|
|
}
|
|
|
|
self.state = state
|
|
model.objectWillChange.send()
|
|
return true
|
|
}
|
|
}
|
|
|
|
struct ZapRequestId: Equatable, Hashable {
|
|
let reqid: NoteId
|
|
|
|
init(from_zap_request: ZapRequest) {
|
|
self.reqid = from_zap_request.ev.id
|
|
}
|
|
|
|
init(from_zap: Zapping) {
|
|
self.reqid = from_zap.request.ev.id
|
|
}
|
|
|
|
init(from_makezap: MakeZapRequest) {
|
|
self.reqid = from_makezap.private_inner_request.ev.id
|
|
}
|
|
|
|
init(from_pending: PendingZap) {
|
|
self.reqid = from_pending.request.ev.id
|
|
}
|
|
}
|
|
|
|
enum Zapping {
|
|
case zap(Zap)
|
|
case pending(PendingZap)
|
|
|
|
var is_pending: Bool {
|
|
switch self {
|
|
case .zap:
|
|
return false
|
|
case .pending:
|
|
return true
|
|
}
|
|
}
|
|
|
|
var is_paid: Bool {
|
|
switch self {
|
|
case .zap:
|
|
// we have a zap so this is proof of payment
|
|
return true
|
|
case .pending(let pzap):
|
|
switch pzap.state {
|
|
case .external:
|
|
// It could be but we don't know. We have to wait for a zap to know.
|
|
return false
|
|
case .nwc(let nwc_state):
|
|
// nwc confirmed that we have a payment, but we might not have zap yet
|
|
return nwc_state.state == .confirmed
|
|
}
|
|
}
|
|
}
|
|
|
|
var is_private: Bool {
|
|
switch self {
|
|
case .zap(let zap):
|
|
return zap.private_request != nil
|
|
case .pending(let pzap):
|
|
return pzap.type == .priv
|
|
}
|
|
}
|
|
|
|
var amount: Int64 {
|
|
switch self {
|
|
case .zap(let zap):
|
|
return zap.invoice.amount
|
|
case .pending(let pzap):
|
|
return pzap.amount_msat
|
|
}
|
|
}
|
|
|
|
var target: ZapTarget {
|
|
switch self {
|
|
case .zap(let zap):
|
|
return zap.target
|
|
case .pending(let pzap):
|
|
return pzap.target
|
|
}
|
|
}
|
|
|
|
var request: ZapRequest {
|
|
switch self {
|
|
case .zap(let zap):
|
|
return zap.request
|
|
case .pending(let pzap):
|
|
return pzap.request
|
|
}
|
|
}
|
|
|
|
var created_at: UInt32 {
|
|
switch self {
|
|
case .zap(let zap):
|
|
return zap.event.created_at
|
|
case .pending(let pzap):
|
|
// pending zaps are created right away
|
|
return pzap.request.ev.created_at
|
|
}
|
|
}
|
|
|
|
var event: NostrEvent? {
|
|
switch self {
|
|
case .zap(let zap):
|
|
return zap.event
|
|
case .pending:
|
|
// pending zaps don't have a zap event
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var is_in_thread: Bool {
|
|
switch self {
|
|
case .zap(let zap):
|
|
return zap.request.is_in_thread
|
|
case .pending(let pzap):
|
|
return pzap.request.is_in_thread
|
|
}
|
|
}
|
|
|
|
var is_anon: Bool {
|
|
switch self {
|
|
case .zap(let zap):
|
|
return zap.is_anon
|
|
case .pending(let pzap):
|
|
return pzap.type == .anon
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Zap {
|
|
public let event: NostrEvent
|
|
public let invoice: ZapInvoice
|
|
public let zapper: Pubkey /// zap authorizer
|
|
public let target: ZapTarget
|
|
public let raw_request: ZapRequest
|
|
public let is_anon: Bool
|
|
public let private_request: ZapRequest?
|
|
|
|
var request: ZapRequest {
|
|
return private_request ?? self.raw_request
|
|
}
|
|
|
|
public static func from_zap_event(zap_ev: NostrEvent, zapper: Pubkey, our_privkey: Privkey?) -> Zap? {
|
|
/// Make sure that we only create a zap event if it is authorized by the profile or event
|
|
guard zapper == zap_ev.pubkey else {
|
|
return nil
|
|
}
|
|
guard let bolt11_str = event_tag(zap_ev, name: "bolt11") else {
|
|
return nil
|
|
}
|
|
guard let bolt11 = decode_bolt11(bolt11_str) else {
|
|
return nil
|
|
}
|
|
/// Any amount invoices are not allowed
|
|
guard let zap_invoice = invoice_to_zap_invoice(bolt11) else {
|
|
return nil
|
|
}
|
|
// Some endpoints don't have this, let's skip the check for now. We're mostly trusting the zapper anyways
|
|
/*
|
|
guard let preimage = event_tag(zap_ev, name: "preimage") else {
|
|
return nil
|
|
}
|
|
guard preimage_matches_invoice(preimage, inv: zap_invoice) else {
|
|
return nil
|
|
}
|
|
*/
|
|
guard let zap_req = get_zap_request(zap_ev) else {
|
|
return nil
|
|
}
|
|
|
|
guard validate_event(ev: zap_req) == .ok else {
|
|
return nil
|
|
}
|
|
|
|
guard let target = determine_zap_target(zap_req) else {
|
|
return nil
|
|
}
|
|
|
|
let private_request = our_privkey.flatMap {
|
|
decrypt_private_zap(our_privkey: $0, zapreq: zap_req, target: target)
|
|
}
|
|
|
|
let is_anon = private_request == nil && event_is_anonymous(ev: zap_req)
|
|
let preq = private_request.map { pr in ZapRequest(ev: pr) }
|
|
|
|
return Zap(event: zap_ev, invoice: zap_invoice, zapper: zapper, target: target, raw_request: ZapRequest(ev: zap_req), is_anon: is_anon, private_request: preq)
|
|
}
|
|
}
|
|
|
|
func decrypt_private_zap(our_privkey: Privkey, zapreq: NostrEvent, target: ZapTarget) -> NostrEvent? {
|
|
guard let anon_tag = zapreq.tags.first(where: { t in
|
|
t.count >= 2 && t[0].matches_str("anon")
|
|
}) else {
|
|
return nil
|
|
}
|
|
|
|
let enc_note = anon_tag[1].string()
|
|
|
|
var note = decrypt_note(our_privkey: our_privkey, their_pubkey: zapreq.pubkey, enc_note: enc_note, encoding: .bech32)
|
|
|
|
// check to see if the private note was from us
|
|
if note == nil {
|
|
guard let our_private_keypair = generate_private_keypair(our_privkey: our_privkey, id: NoteId(target.id), created_at: zapreq.created_at) else {
|
|
return nil
|
|
}
|
|
// use our private keypair and their pubkey to get the shared secret
|
|
note = decrypt_note(our_privkey: our_private_keypair.privkey, their_pubkey: target.pubkey, enc_note: enc_note, encoding: .bech32)
|
|
}
|
|
|
|
guard let note else {
|
|
return nil
|
|
}
|
|
|
|
guard note.kind == 9733 else {
|
|
return nil
|
|
}
|
|
|
|
let zr_etag = zapreq.referenced_ids.first
|
|
let note_etag = note.referenced_ids.first
|
|
|
|
guard zr_etag == note_etag else {
|
|
return nil
|
|
}
|
|
|
|
let zr_ptag = zapreq.referenced_pubkeys.first
|
|
let note_ptag = note.referenced_pubkeys.first
|
|
|
|
guard let zr_ptag, let note_ptag, zr_ptag == note_ptag else {
|
|
return nil
|
|
}
|
|
|
|
guard validate_event(ev: note) == .ok else {
|
|
return nil
|
|
}
|
|
|
|
return note
|
|
}
|
|
|
|
func event_is_anonymous(ev: NostrEvent) -> Bool {
|
|
return ev.known_kind == .zap_request && event_has_tag(ev: ev, tag: "anon")
|
|
}
|
|
|
|
func event_has_tag(ev: NostrEvent, tag: String) -> Bool {
|
|
for t in ev.tags {
|
|
if t.count >= 1 && t[0].matches_str(tag) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func get_zap_request(_ ev: NostrEvent) -> NostrEvent? {
|
|
guard let desc = event_tag(ev, name: "description") else {
|
|
return nil
|
|
}
|
|
|
|
return decode_nostr_event_json(desc)
|
|
}
|
|
|
|
func invoice_to_zap_invoice(_ invoice: Invoice) -> ZapInvoice? {
|
|
guard case .specific(let amt) = invoice.amount else {
|
|
return nil
|
|
}
|
|
|
|
return ZapInvoice(description: invoice.description, amount: amt, string: invoice.string, expiry: invoice.expiry, payment_hash: invoice.payment_hash, created_at: invoice.created_at)
|
|
}
|
|
|
|
func determine_zap_target(_ ev: NostrEvent) -> ZapTarget? {
|
|
guard let ptag = ev.referenced_pubkeys.first else {
|
|
return nil
|
|
}
|
|
|
|
if let etag = ev.referenced_ids.first {
|
|
return ZapTarget.note(id: etag, author: ptag)
|
|
}
|
|
|
|
return .profile(ptag)
|
|
}
|
|
|
|
func decode_bolt11(_ s: String) -> Invoice? {
|
|
var bs = note_blocks()
|
|
bs.num_blocks = 0
|
|
blocks_init(&bs)
|
|
|
|
let bytes = s.utf8CString
|
|
let _ = bytes.withUnsafeBufferPointer { p in
|
|
damus_parse_content(&bs, p.baseAddress)
|
|
}
|
|
|
|
guard bs.num_blocks == 1 else {
|
|
blocks_free(&bs)
|
|
return nil
|
|
}
|
|
|
|
let block = bs.blocks[0]
|
|
|
|
guard let converted = Block(block) else {
|
|
blocks_free(&bs)
|
|
return nil
|
|
}
|
|
|
|
guard case .invoice(let invoice) = converted else {
|
|
blocks_free(&bs)
|
|
return nil
|
|
}
|
|
|
|
blocks_free(&bs)
|
|
return invoice
|
|
}
|
|
|
|
func event_tag(_ ev: NostrEvent, name: String) -> String? {
|
|
for tag in ev.tags {
|
|
if tag.count >= 2 && tag[0].matches_str(name) {
|
|
return tag[1].string()
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func decode_nostr_event_json(_ desc: String) -> NostrEvent? {
|
|
return NostrEvent.owned_from_json(json: desc)
|
|
}
|
|
|
|
|
|
func fetch_zapper_from_lnurl(lnurls: LNUrls, pubkey: Pubkey, lnurl: String) async -> Pubkey? {
|
|
guard let endpoint = await lnurls.lookup_or_fetch(pubkey: pubkey, lnurl: lnurl),
|
|
let allows = endpoint.allowsNostr, allows,
|
|
let key = endpoint.nostrPubkey,
|
|
let pk = hex_decode_pubkey(key)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return pk
|
|
}
|
|
|
|
func fetch_zap_invoice(_ payreq: LNUrlPayRequest, zapreq: NostrEvent?, msats: Int64, zap_type: ZapType, comment: String?) async -> String? {
|
|
guard var base_url = payreq.callback.flatMap({ URLComponents(string: $0) }) else {
|
|
return nil
|
|
}
|
|
|
|
let zappable = payreq.allowsNostr ?? false
|
|
|
|
var query = [URLQueryItem(name: "amount", value: "\(msats)")]
|
|
|
|
if zappable && zap_type != .non_zap, let json = encode_json(zapreq) {
|
|
print("zapreq json: \(json)")
|
|
query.append(URLQueryItem(name: "nostr", value: json))
|
|
}
|
|
|
|
// add a lud12 comment as well if we have it
|
|
if zap_type != .priv, let comment, let limit = payreq.commentAllowed, limit != 0 {
|
|
let limited_comment = String(comment.prefix(limit))
|
|
query.append(URLQueryItem(name: "comment", value: limited_comment))
|
|
}
|
|
|
|
base_url.queryItems = query
|
|
|
|
guard let url = base_url.url else {
|
|
return nil
|
|
}
|
|
|
|
print("url \(url)")
|
|
|
|
var ret: (Data, URLResponse)? = nil
|
|
do {
|
|
ret = try await URLSession.shared.data(from: url)
|
|
} catch {
|
|
print(error.localizedDescription)
|
|
return nil
|
|
}
|
|
|
|
guard let ret else {
|
|
return nil
|
|
}
|
|
|
|
let json_str = String(decoding: ret.0, as: UTF8.self)
|
|
guard let result: LNUrlPayResponse = decode_json(json_str) else {
|
|
print("fetch_zap_invoice error: \(json_str)")
|
|
return nil
|
|
}
|
|
|
|
// make sure it's the correct amount
|
|
guard let bolt11 = decode_bolt11(result.pr),
|
|
.specific(msats) == bolt11.amount
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return result.pr
|
|
}
|