Files
mutiny-web/src/utils/fetchZaps.ts
2023-11-15 15:01:15 -06:00

280 lines
7.7 KiB
TypeScript

import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
import { ResourceFetcher } from "solid-js";
import { useMegaStore } from "~/state/megaStore";
import { hexpubFromNpub, NostrKind, NostrTag } from "~/utils/nostr";
export type NostrEvent = {
created_at: number;
content: string;
tags: NostrTag[];
kind?: NostrKind | number;
pubkey: string;
id?: string;
sig?: string;
};
export type SimpleZapItem = {
kind: "public" | "private" | "anonymous";
from_hexpub: string;
to_hexpub: string;
timestamp: bigint;
amount_sats: bigint;
note?: string;
event_id?: string;
event: NostrEvent;
content?: string;
};
export type NostrProfile = {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
};
function findByTag(tags: string[][], tag: string): string | undefined {
if (!tags || !Array.isArray(tags)) return;
const found = tags.find((t) => {
if (t[0] === tag) {
return true;
}
});
if (found) {
return found[1];
}
}
async function simpleZapFromEvent(
event: NostrEvent,
wallet: MutinyWallet
): Promise<SimpleZapItem | undefined> {
if (event.kind === 9735 && event.tags?.length > 0) {
const to = findByTag(event.tags, "p") || "";
const request = JSON.parse(
findByTag(event.tags, "description") || "{}"
);
const from = request.pubkey;
const content = request.content;
const anon = findByTag(request.tags, "anon");
const bolt11 = findByTag(event.tags, "bolt11") || "";
if (!bolt11) {
// not a zap!
return undefined;
}
let amount = 0n;
// who is the asshole putting "lnbc9m" in all these tags?
if (bolt11) {
try {
// We hardcode the "bitcoin" network because we don't have a good source of mutinynet zaps
const decoded = await wallet.decode_invoice(bolt11, "bitcoin");
if (decoded.amount_sats) {
amount = decoded.amount_sats;
} else {
console.log("no amount in decoded invoice");
return undefined;
}
} catch (e) {
console.error(e);
return undefined;
}
}
// If we can't get the amount from the invoice we'll fallback to the event tags
if (amount === 0n && request.tags?.length > 0) {
amount = BigInt(findByTag(request.tags, "amount") || "0") / 1000n;
}
return {
// If the anon field is empty it's anon, if it has length it's private, otherwise it's public
kind:
typeof anon === "string"
? anon.length
? "private"
: "anonymous"
: "public",
from_hexpub: from,
to_hexpub: to,
timestamp: BigInt(event.created_at),
amount_sats: amount,
note: request.id,
event_id: findByTag(request.tags, "e"),
event,
content: content
};
}
}
const PRIMAL_API = import.meta.env.VITE_PRIMAL;
async function fetchFollows(npub: string): Promise<string[]> {
let pubkey = undefined;
try {
pubkey = await hexpubFromNpub(npub);
} catch (err) {
console.error("Failed to get hexpub from npub");
throw err;
}
const response = await fetch(PRIMAL_API, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify([
"contact_list",
{ pubkey: pubkey, extended_response: false }
])
});
if (!response.ok) {
throw new Error(`Failed to load follows`);
}
const data = await response.json();
const follows: string[] = [];
for (const event of data) {
if (event.kind === 3) {
for (const tag of event.tags) {
if (tag[0] === "p") {
follows.push(tag[1]);
}
}
}
}
return follows;
}
type PrimalResponse = NostrEvent | NostrProfile;
async function fetchZapsFromPrimal(
follows: string[],
until?: number
): Promise<PrimalResponse[]> {
if (!PRIMAL_API) throw new Error("Missing PRIMAL_API environment variable");
const query = {
kinds: [9735, 0, 10000113],
limit: 100,
pubkeys: follows
};
const restPayload = JSON.stringify([
"zaps_feed",
// If we have a until value, use it, otherwise don't include it
until ? { ...query, since: until } : query
]);
const response = await fetch(PRIMAL_API, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: restPayload
});
if (!response.ok) {
throw new Error(`Failed to load zaps`);
}
const data = await response.json();
// A primal response could be an error ({error: "error"}, or an array of events
if (data.error || !Array.isArray(data)) {
throw new Error("Zap response was not an array");
}
return data;
}
export const fetchZaps: ResourceFetcher<
string,
{
follows: string[];
zaps: SimpleZapItem[];
profiles: Record<string, NostrProfile>;
until?: number;
}
> = async (npub, info) => {
const [state, _actions] = useMegaStore();
try {
console.log("fetching zaps for:", npub);
let follows: string[] = info?.value ? info.value.follows : [];
const zaps: SimpleZapItem[] = [];
const profiles: Record<string, NostrProfile> =
info.value?.profiles || {};
let newUntil = undefined;
if (!PRIMAL_API)
throw new Error("Missing PRIMAL_API environment variable");
// Only have to ask the relays for follows one time
if (follows.length === 0) {
follows = await fetchFollows(npub);
}
// Ask primal for all the zaps for these follow pubkeys
const data = await fetchZapsFromPrimal(follows, info?.value?.until);
// Parse the primal response
for (const object of data) {
if (object.kind === 10000113) {
console.log("got a 10000113 object", object);
try {
const content = JSON.parse(object.content);
if (content?.until) {
newUntil = content?.until + 1;
}
} catch (e) {
console.error("Failed to parse content: ", object.content);
}
}
if (object.kind === 0) {
console.log("got a 0 object", object);
profiles[object.pubkey] = object as NostrProfile;
}
if (object.kind === 9735) {
console.log("got a 9735 object", object);
try {
const event = await simpleZapFromEvent(
object,
state.mutiny_wallet!
);
// Only add it if it's a valid zap (not undefined)
if (event) {
zaps.push(event);
}
} catch (e) {
console.error("Failed to parse zap event: ", object);
}
}
}
return {
follows,
zaps: [...zaps, ...(info?.value?.zaps || [])],
profiles,
until: newUntil ? newUntil : info?.value?.until
};
} catch (e) {
console.error("Failed to load zaps: ", e);
throw new Error("Failed to load zaps");
}
};