The code paths for generating zap notifications were very different from the paths used by most other notifications. In this commit, I include the logic and data structures necessary for formatting zap notifications in the same fashion as local notifications. A good amount of refactoring and moving functions/structures around was necessary to reuse zap local notification logic. I also attempted to make the notification generation process more consistent between zaps and other notifications, without changing too much of existing logic to avoid even more regression risk. General push notifications + local notifications test ----------------------------------------------------- PASS Device: iPhone 15 Pro simulator iOS: 17.0.1 Damus: This commit Setup: - Two phones running Damus on different accounts - Local relay with strfry-push-notify test setup - Apple push notification test tool Coverage: 1. Mention notifications 2. DM notifications 3. Reaction notifications 4. Repost notifications Steps for each notification type: 1. Trigger a notification (local and then push) 2. Ensure that the notification is received on the other device 3. Ensure that the notification is formatted correctly 4. Ensure that DMs are decrypted correctly 5. Ensure that profile names are unfurled correctly 6. Click on the notification and ensure that the app opens to the correct screen Result: PASS (all notifications received and formatted correctly) Notes: - For some reason my relay is not receiving zap events, so I could not test zap notifications yet. - Reply notifications do not seem to be implemented yet - These apply to the tests below as well Changelog-Added: Zap notification support for push notifications Signed-off-by: Daniel D’Aquino <daniel@daquino.me> Signed-off-by: William Casarin <jb55@jb55.com>
555 lines
14 KiB
Swift
555 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 desc = get_zap_description(zap_ev, inv_desc: zap_invoice.description) else {
|
|
return nil
|
|
}
|
|
|
|
guard let zap_req = decode_nostr_event_json(desc) 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
|
|
}
|
|
|
|
/// Fetches the description from either the invoice, or tags, depending on the type of invoice
|
|
func get_zap_description(_ ev: NostrEvent, inv_desc: InvoiceDescription) -> String? {
|
|
switch inv_desc {
|
|
case .description(let string):
|
|
return string
|
|
case .description_hash(let deschash):
|
|
guard let desc = event_tag(ev, name: "description") else {
|
|
return nil
|
|
}
|
|
guard let data = desc.data(using: .utf8) else {
|
|
return nil
|
|
}
|
|
guard sha256(data) == deschash else {
|
|
return nil
|
|
}
|
|
|
|
return 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
|
|
}
|