feat: zap-pool

This commit is contained in:
2023-05-16 22:30:52 +01:00
parent 9b3b3adef9
commit 7317bc4c35
9 changed files with 357 additions and 23 deletions

View File

@ -10,7 +10,7 @@ import Spinner from "Icons/Spinner";
import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher";
import { bech32ToHex, delay, normalizeReaction, unwrap } from "Util";
import { delay, normalizeReaction, unwrap } from "Util";
import { NoteCreator } from "Element/NoteCreator";
import { ReBroadcaster } from "Element/ReBroadcaster";
import Reactions from "Element/Reactions";
@ -25,13 +25,13 @@ import {
reset as resetReBroadcast,
} from "State/ReBroadcast";
import useModeration from "Hooks/useModeration";
import { SnortPubKey, TranslateHost } from "Const";
import { TranslateHost } from "Const";
import { LNURL } from "LNURL";
import { DonateLNURL } from "Pages/DonatePage";
import { useWallet } from "Wallet";
import useLogin from "Hooks/useLogin";
import { setBookmarked, setPinned } from "Login";
import { useInteractionCache } from "Hooks/useInteractionCache";
import { ZapPoolController } from "ZapPoolController";
import messages from "./messages";
@ -160,7 +160,6 @@ export default function NoteFooter(props: NoteFooterProps) {
setZapping(true);
try {
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id);
fastZapDonate();
} catch (e) {
console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") {
@ -184,26 +183,12 @@ export default function NoteFooter(props: NoteFooterProps) {
const zap = handler.canZap && publisher ? await publisher.zap(amount * 1000, key, zr, id) : undefined;
const invoice = await handler.getInvoice(amount, undefined, zap);
await wallet?.payInvoice(unwrap(invoice.pr));
ZapPoolController.allocate(amount);
await interactionCache.zap();
});
}
function fastZapDonate() {
queueMicrotask(async () => {
if (prefs.fastZapDonate > 0) {
// spin off donate
const donateAmount = Math.floor(prefs.defaultZapAmount * prefs.fastZapDonate);
if (donateAmount > 0) {
console.debug(`Donating ${donateAmount} sats to ${DonateLNURL}`);
fastZapInner(DonateLNURL, donateAmount, bech32ToHex(SnortPubKey))
.then(() => console.debug("Donation sent! Thank You!"))
.catch(() => console.debug("Failed to donate"));
}
}
});
}
useEffect(() => {
if (prefs.autoZap && !didZap && !isMine && !zapping) {
const lnurl = getLNURL();
@ -212,7 +197,6 @@ export default function NoteFooter(props: NoteFooterProps) {
queueMicrotask(async () => {
try {
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id);
fastZapDonate();
} catch {
// ignored
} finally {
@ -457,6 +441,7 @@ export default function NoteFooter(props: NoteFooterProps) {
author={author?.pubkey}
target={getTargetName()}
note={ev.id}
allocatePool={true}
/>
</div>
<div className="zaps-container">

View File

@ -16,6 +16,7 @@ import { useWallet } from "Wallet";
import useLogin from "Hooks/useLogin";
import { generateRandomKey } from "Login";
import { EventPublisher } from "System/EventPublisher";
import { ZapPoolController } from "ZapPoolController";
import messages from "./messages";
@ -36,6 +37,7 @@ export interface SendSatsProps {
target?: string;
note?: HexKey;
author?: HexKey;
allocatePool?: boolean;
}
export default function SendSats(props: SendSatsProps) {
@ -194,9 +196,12 @@ export default function SendSats(props: SendSatsProps) {
async function payWithWallet(invoice: LNURLInvoice) {
try {
if (wallet?.isReady) {
if (wallet?.isReady()) {
setPaying(true);
const res = await wallet.payInvoice(invoice?.pr ?? "");
if (props.allocatePool) {
ZapPoolController.allocate(amount);
}
console.log(res);
setSuccess(invoice?.successAction ?? {});
}

View File

@ -0,0 +1,3 @@
.zap-pool input[type="range"] {
width: 200px;
}

View File

@ -0,0 +1,185 @@
import "./ZapPool.css";
import { useMemo, useSyncExternalStore } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { SnortPubKey } from "Const";
import ProfilePreview from "Element/ProfilePreview";
import useLogin from "Hooks/useLogin";
import { System } from "System";
import { UploaderServices } from "Upload";
import { bech32ToHex, getRelayName, unwrap } from "Util";
import { ZapPoolController, ZapPoolRecipient, ZapPoolRecipientType } from "ZapPoolController";
import { useUserProfile } from "Hooks/useUserProfile";
import AsyncButton from "Element/AsyncButton";
import { useWallet } from "Wallet";
function ZapTarget({ target }: { target: ZapPoolRecipient }) {
const login = useLogin();
const profile = useUserProfile(target.pubkey);
const hasAddress = profile?.lud16 || profile?.lud06;
const defaultZapMount = Math.ceil(login.preferences.defaultZapAmount * (target.split / 100));
return (
<ProfilePreview
pubkey={target.pubkey}
actions={
hasAddress ? (
<div>
<div>
<FormattedNumber value={target.split} />% (
<FormattedMessage defaultMessage="{n} sats" values={{ n: defaultZapMount }} />)
</div>
<input
type="range"
min={0}
max={100}
value={target.split}
onChange={e =>
ZapPoolController.set({
...target,
split: e.target.valueAsNumber,
})
}
/>
</div>
) : (
<FormattedMessage defaultMessage="No lightning address" />
)
}
/>
);
}
export default function ZapPoolPage() {
const login = useLogin();
const zapPool = useSyncExternalStore(
c => ZapPoolController.hook(c),
() => ZapPoolController.snapshot()
);
const { wallet } = useWallet();
const relayConnections = useMemo(() => {
return [...System.Sockets.values()]
.map(a => {
if (a.Info?.pubkey) {
return {
address: a.Address,
pubkey: a.Info.pubkey,
};
}
})
.filter(a => a !== undefined)
.map(unwrap);
}, [login.relays]);
const sumPending = zapPool.reduce((acc, v) => acc + v.sum, 0);
return (
<div className="zap-pool">
<h1>
<FormattedMessage defaultMessage="Zap Pool" />
</h1>
<p>
<FormattedMessage defaultMessage="Fund the services that you use by splitting a portion of all your zaps into a pool of funds!" />
</p>
<p>
<FormattedMessage defaultMessage="Zap Pool only works if you use one of the supported wallet connections (WebLN, LNC, LNDHub or Nostr Wallet Connect)" />
</p>
<p>
<FormattedMessage
defaultMessage="Your default zap amount is {number} sats, example values are calculated from this."
values={{
number: (
<b>
<FormattedNumber value={login.preferences.defaultZapAmount} />
</b>
),
}}
/>
</p>
<p>
<FormattedMessage
defaultMessage="A single zap of {nIn} sats will allocate {nOut} sats to the zap pool."
values={{
nIn: (
<b>
<FormattedNumber value={login.preferences.defaultZapAmount} />
</b>
),
nOut: (
<b>
<FormattedNumber value={ZapPoolController.calcAllocation(login.preferences.defaultZapAmount)} />
</b>
),
}}
/>
</p>
<p>
<FormattedMessage
defaultMessage="You currently have {number} sats in your zap pool."
values={{
number: (
<b>
<FormattedNumber value={sumPending} />
</b>
),
}}
/>
</p>
<p>
{wallet && (
<AsyncButton onClick={() => ZapPoolController.payout(wallet)}>
<FormattedMessage defaultMessage="Payout Now" />
</AsyncButton>
)}
</p>
<div className="card">
<ZapTarget
target={
zapPool.find(b => b.pubkey === bech32ToHex(SnortPubKey) && b.type === ZapPoolRecipientType.Generic) ?? {
type: ZapPoolRecipientType.Generic,
pubkey: bech32ToHex(SnortPubKey),
split: 0,
sum: 0,
}
}
/>
</div>
<h3>
<FormattedMessage defaultMessage="Relays" />
</h3>
{relayConnections.map(a => (
<div className="card">
<h4>{getRelayName(a.address)}</h4>
<ZapTarget
target={
zapPool.find(b => b.pubkey === a.pubkey && b.type === ZapPoolRecipientType.Relay) ?? {
type: ZapPoolRecipientType.Relay,
pubkey: a.pubkey,
split: 0,
sum: 0,
}
}
/>
</div>
))}
<h3>
<FormattedMessage defaultMessage="File hosts" />
</h3>
{UploaderServices.map(a => (
<div className="card">
<h4>{a.name}</h4>
<ZapTarget
target={
zapPool.find(b => b.pubkey === a.owner && b.type === ZapPoolRecipientType.FileHost) ?? {
type: ZapPoolRecipientType.FileHost,
pubkey: a.owner,
split: 0,
sum: 0,
}
}
/>
</div>
))}
</div>
);
}

View File

@ -83,7 +83,11 @@ const SettingsIndex = () => {
<FormattedMessage {...messages.Donate} />
<Icon name="arrowFront" />
</div>
<div className="settings-row" onClick={() => navigate("/zap-pool")}>
<Icon name="piggy-bank" />
<FormattedMessage defaultMessage="Zap Pool" />
<Icon name="arrowFront" />
</div>
<div className="settings-row" onClick={handleLogout}>
<Icon name="logout" />
<FormattedMessage {...messages.LogOut} />

View File

@ -4,6 +4,8 @@ import { RawEvent } from "@snort/nostr";
import NostrBuild from "Upload/NostrBuild";
import VoidCat from "Upload/VoidCat";
import NostrImg from "Upload/NostrImg";
import { KieranPubKey } from "Const";
import { bech32ToHex } from "Util";
export interface UploadResult {
url?: string;
@ -15,6 +17,24 @@ export interface UploadResult {
header?: RawEvent;
}
/**
* List of supported upload services and their owners on nostr
*/
export const UploaderServices = [
{
name: "void.cat",
owner: bech32ToHex(KieranPubKey),
},
{
name: "nostr.build",
owner: bech32ToHex("npub1nxy4qpqnld6kmpphjykvx2lqwvxmuxluddwjamm4nc29ds3elyzsm5avr7"),
},
{
name: "nostrimg.com",
owner: bech32ToHex("npub1xv6axulxcx6mce5mfvfzpsy89r4gee3zuknulm45cqqpmyw7680q5pxea6"),
},
];
export interface Uploader {
upload: (f: File | Blob, filename: string) => Promise<UploadResult>;
}

View File

@ -0,0 +1,124 @@
import { UserCache } from "Cache";
import ExternalStore from "ExternalStore";
import { LNURL } from "LNURL";
import { LNWallet, WalletInvoiceState } from "Wallet";
export enum ZapPoolRecipientType {
Generic = 0,
Relay = 1,
FileHost = 2,
}
export interface ZapPoolRecipient {
type: ZapPoolRecipientType;
pubkey: string;
split: number;
sum: number;
}
class ZapPool extends ExternalStore<Array<ZapPoolRecipient>> {
#store: Map<string, ZapPoolRecipient>;
#isPayoutInProgress = false;
constructor() {
super();
this.#store = new Map();
this.#load();
}
async payout(wallet: LNWallet) {
if (this.#isPayoutInProgress) {
throw new Error("Payout already in progress");
}
this.#isPayoutInProgress = true;
for (const x of this.#store.values()) {
if (x.sum === 0) continue;
try {
const profile = await UserCache.get(x.pubkey);
if (!profile) {
throw new Error(`Failed to get profile for ${x.pubkey}`);
}
const svc = new LNURL(profile.lud16 || profile.lud06 || "");
await svc.load();
const amtSend = x.sum;
const invoice = await svc.getInvoice(amtSend, `SnortZapPool: ${x.split}%`);
if (invoice.pr) {
const result = await wallet.payInvoice(invoice.pr);
if (result.state === WalletInvoiceState.Paid) {
x.sum -= amtSend;
} else {
throw new Error("Payment failed");
}
} else {
throw new Error(invoice.reason ?? "Failed to get invoice");
}
} catch (e) {
console.error(e);
}
}
this.#save();
this.notifyChange();
this.#isPayoutInProgress = false;
}
calcAllocation(n: number) {
let res = 0;
for (const x of this.#store.values()) {
res += Math.ceil(n * (x.split / 100));
}
return res;
}
allocate(n: number) {
if (this.#isPayoutInProgress) {
throw new Error("Payout is in progress, cannot allocate to pool");
}
for (const x of this.#store.values()) {
x.sum += Math.ceil(n * (x.split / 100));
}
this.#save();
this.notifyChange();
}
getOrDefault(rcpt: ZapPoolRecipient) {
const k = this.#key(rcpt);
if (this.#store.has(k)) {
return { ...this.#store.get(k) };
}
return rcpt;
}
set(rcpt: ZapPoolRecipient) {
const k = this.#key(rcpt);
// delete entry if split is 0 and sum is 0
if (rcpt.split === 0 && rcpt.sum === 0 && this.#store.has(k)) {
this.#store.delete(k);
} else {
this.#store.set(k, rcpt);
}
this.#save();
this.notifyChange();
}
#key(rcpt: ZapPoolRecipient) {
return `${rcpt.pubkey}-${rcpt.type}`;
}
#save() {
self.localStorage.setItem("zap-pool", JSON.stringify(this.takeSnapshot()));
}
#load() {
const existing = self.localStorage.getItem("zap-pool");
if (existing) {
const arr = JSON.parse(existing) as Array<ZapPoolRecipient>;
this.#store = new Map(arr.map(a => [`${a.pubkey}-${a.type}`, a]));
}
}
takeSnapshot(): ZapPoolRecipient[] {
return [...this.#store.values()];
}
}
export const ZapPoolController = new ZapPool();

View File

@ -30,7 +30,7 @@ import { WalletRoutes } from "Pages/WalletPage";
import NostrLinkHandler from "Pages/NostrLinkHandler";
import Thread from "Element/Thread";
import { SubscribeRoutes } from "Pages/subscribe";
import Discover from "Pages/Discover";
import ZapPoolPage from "Pages/ZapPool";
/**
* HTTP query provider
@ -94,6 +94,10 @@ export const router = createBrowserRouter([
path: "/search/:keyword?",
element: <SearchPage />,
},
{
path: "/zap-pool",
element: <ZapPoolPage />,
},
...NewUserRoutes,
...WalletRoutes,
...SubscribeRoutes,