Skip to content

Commit

Permalink
Add support for paying zap splits
Browse files Browse the repository at this point in the history
  • Loading branch information
hzrd149 committed Oct 4, 2023
1 parent aa3690a commit c0e3269
Show file tree
Hide file tree
Showing 13 changed files with 593 additions and 284 deletions.
5 changes: 5 additions & 0 deletions .changeset/fair-bears-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostrudel": minor
---

Add support for paying zap splits
205 changes: 205 additions & 0 deletions src/components/event-zap-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { useState } from "react";
import {
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
ModalProps,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { Kind } from "nostr-tools";

import { DraftNostrEvent, NostrEvent, isDTag } from "../../types/nostr-event";
import clientRelaysService from "../../services/client-relays";
import { getEventRelays } from "../../services/event-relays";
import { getZapSplits } from "../../helpers/nostr/zaps";
import { unique } from "../../helpers/array";
import { RelayMode } from "../../classes/relay";
import relayScoreboardService from "../../services/relay-scoreboard";
import { getEventCoordinate, isReplaceable } from "../../helpers/nostr/events";
import { EmbedProps } from "../embed-event";
import userRelaysService from "../../services/user-relays";
import InputStep from "./input-step";
import lnurlMetadataService from "../../services/lnurl-metadata";
import userMetadataService from "../../services/user-metadata";
import signingService from "../../services/signing";
import accountService from "../../services/account";
import PayStep from "./pay-step";
import { getInvoiceFromCallbackUrl } from "../../helpers/lnurl";

export type PayRequest = { invoice?: string; pubkey: string; error?: any };

async function getPayRequestForPubkey(
pubkey: string,
event: NostrEvent | undefined,
amount: number,
comment?: string,
additionalRelays?: string[],
): Promise<PayRequest> {
const metadata = userMetadataService.getSubject(pubkey).value;
const address = metadata?.lud16 || metadata?.lud06;
if (!address) throw new Error("User missing lightning address");
const lnurlMetadata = await lnurlMetadataService.requestMetadata(address);

if (!lnurlMetadata) throw new Error("LNURL endpoint unreachable");

if (amount > lnurlMetadata.maxSendable) throw new Error("Amount to large");
if (amount < lnurlMetadata.minSendable) throw new Error("Amount to small");

const canZap = !!lnurlMetadata.allowsNostr && !!lnurlMetadata.nostrPubkey;
if (!canZap) {
// build LNURL callback url
const callback = new URL(lnurlMetadata.callback);
callback.searchParams.append("amount", String(amount));
if (comment) callback.searchParams.append("comment", comment);

const invoice = await getInvoiceFromCallbackUrl(callback);

return { invoice, pubkey };
}

const userInbox = relayScoreboardService
.getRankedRelays(
userRelaysService
.getRelays(pubkey)
.value?.relays.filter((r) => r.mode & RelayMode.READ)
.map((r) => r.url) ?? [],
)
.slice(0, 4);
const eventRelays = event ? relayScoreboardService.getRankedRelays(getEventRelays(event.id).value).slice(0, 4) : [];
const outbox = relayScoreboardService.getRankedRelays(clientRelaysService.getWriteUrls()).slice(0, 4);
const additional = relayScoreboardService.getRankedRelays(additionalRelays);

// create zap request
const zapRequest: DraftNostrEvent = {
kind: Kind.ZapRequest,
created_at: dayjs().unix(),
content: comment ?? "",
tags: [
["p", pubkey],
["relays", ...unique([...userInbox, ...eventRelays, ...outbox, ...additional])],
["amount", String(amount)],
],
};

// attach "e" or "a" tag
if (event) {
if (isReplaceable(event.kind) && event.tags.some(isDTag)) {
zapRequest.tags.push(["a", getEventCoordinate(event)]);
} else zapRequest.tags.push(["e", event.id]);
}

// TODO: move this out to a separate step so the user can choose when to sign
const account = accountService.current.value;
if (!account) throw new Error("No Account");
const signed = await signingService.requestSignature(zapRequest, account);

// build LNURL callback url
const callback = new URL(lnurlMetadata.callback);
callback.searchParams.append("amount", String(amount));
callback.searchParams.append("nostr", JSON.stringify(signed));

const invoice = await getInvoiceFromCallbackUrl(callback);

return { invoice, pubkey };
}

async function getPayRequestsForEvent(
event: NostrEvent,
amount: number,
comment?: string,
additionalRelays?: string[],
) {
const splits = getZapSplits(event);

const draftZapRequests: PayRequest[] = [];
for (const { pubkey, percent } of splits) {
try {
// NOTE: round to the nearest sat since there isn't support for msats yet
const splitAmount = Math.round((amount / 1000) * percent) * 1000;
draftZapRequests.push(await getPayRequestForPubkey(pubkey, event, splitAmount, comment, additionalRelays));
} catch (e) {
draftZapRequests.push({ error: e, pubkey });
}
}

return draftZapRequests;
}

export type ZapModalProps = Omit<ModalProps, "children"> & {
pubkey: string;
event?: NostrEvent;
relays?: string[];
initialComment?: string;
initialAmount?: number;
onInvoice: (invoice: string) => void;
allowComment?: boolean;
showEmbed?: boolean;
embedProps?: EmbedProps;
additionalRelays?: string[];
};

export default function ZapModal({
event,
pubkey,
relays,
onClose,
initialComment,
initialAmount,
onInvoice,
allowComment = true,
showEmbed = true,
embedProps,
additionalRelays = [],
...props
}: ZapModalProps) {
const [callbacks, setCallbacks] = useState<PayRequest[]>();

const renderContent = () => {
if (callbacks && callbacks.length > 0) {
return <PayStep callbacks={callbacks} onComplete={onClose} />;
} else {
return (
<InputStep
pubkey={pubkey}
event={event}
initialComment={initialComment}
initialAmount={initialAmount}
showEmbed={showEmbed}
embedProps={embedProps}
allowComment={allowComment}
onSubmit={async (values) => {
const amountInMSats = values.amount * 1000;
if (event) {
setCallbacks(await getPayRequestsForEvent(event, amountInMSats, values.comment, additionalRelays));
} else {
const callback = await getPayRequestForPubkey(
pubkey,
event,
amountInMSats,
values.comment,
additionalRelays,
);
setCallbacks([callback]);
}
}}
/>
);
}
};

return (
<Modal onClose={onClose} size="xl" {...props}>
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalHeader px="4" pb="0" pt="4">
Zap Event
</ModalHeader>
<ModalBody padding="4">{renderContent()}</ModalBody>
</ModalContent>
</Modal>
);
}
119 changes: 119 additions & 0 deletions src/components/event-zap-modal/input-step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Box, Button, Flex, Input, Text } from "@chakra-ui/react";
import { useForm } from "react-hook-form";

import { NostrEvent } from "../../types/nostr-event";
import { readablizeSats } from "../../helpers/bolt11";
import { LightningIcon } from "../icons";
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
import { getZapSplits } from "../../helpers/nostr/zaps";
import { EmbedEvent, EmbedProps } from "../embed-event";
import useAppSettings from "../../hooks/use-app-settings";
import CustomZapAmountOptions from "./zap-options";
import { UserAvatar } from "../user-avatar";
import { UserLink } from "../user-link";

function UserCard({ pubkey, percent }: { pubkey: string; percent?: number }) {
const { address } = useUserLNURLMetadata(pubkey);

return (
<Flex gap="2" alignItems="center" overflow="hidden">
<UserAvatar pubkey={pubkey} size="md" />
<Box overflow="hidden">
<UserLink pubkey={pubkey} fontWeight="bold" />
<Text isTruncated>{address}</Text>
</Box>
{percent && (
<Text fontWeight="bold" fontSize="lg" ml="auto">
{Math.round(percent * 10000) / 100}%
</Text>
)}
</Flex>
);
}

export type InputStepProps = {
pubkey: string;
event?: NostrEvent;
initialComment?: string;
initialAmount?: number;
allowComment?: boolean;
showEmbed?: boolean;
embedProps?: EmbedProps;
onSubmit: (values: { amount: number; comment: string }) => void;
};

export default function InputStep({
event,
pubkey,
initialComment,
initialAmount,
allowComment = true,
showEmbed = true,
embedProps,
onSubmit,
}: InputStepProps) {
const { customZapAmounts } = useAppSettings();

const {
register,
handleSubmit,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<{
amount: number;
comment: string;
}>({
mode: "onBlur",
defaultValues: {
amount: initialAmount ?? (parseInt(customZapAmounts.split(",")[0]) || 100),
comment: initialComment ?? "",
},
});

const splits = event ? getZapSplits(event) : [];

const { metadata: lnurlMetadata } = useUserLNURLMetadata(pubkey);
const canZap = lnurlMetadata?.allowsNostr && lnurlMetadata?.nostrPubkey;

const showComment = allowComment && (splits.length > 0 || canZap || lnurlMetadata?.commentAllowed);
const actionName = canZap ? "Zap" : "Tip";

const onSubmitZap = handleSubmit(onSubmit);

return (
<form onSubmit={onSubmitZap}>
<Flex gap="4" direction="column">
{splits.map((p) => (
<UserCard key={p.pubkey} pubkey={p.pubkey} percent={p.percent} />
))}

{showEmbed && event && <EmbedEvent event={event} {...embedProps} />}

{showComment && (
<Input
placeholder="Comment"
{...register("comment", { maxLength: lnurlMetadata?.commentAllowed ?? 150 })}
autoComplete="off"
/>
)}

<CustomZapAmountOptions onSelect={(amount) => setValue("amount", amount)} />

<Flex gap="2">
<Input
type="number"
placeholder="Custom amount"
isInvalid={!!errors.amount}
step={1}
flex={1}
{...register("amount", { valueAsNumber: true, min: 1 })}
/>
<Button leftIcon={<LightningIcon />} type="submit" isLoading={isSubmitting} variant="solid" size="md">
{actionName} {readablizeSats(watch("amount"))} sats
</Button>
</Flex>
</Flex>
</form>
);
}
Loading

0 comments on commit c0e3269

Please sign in to comment.