feat: zap-pool

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

View File

@ -154,5 +154,9 @@
<symbol id="arrow-right" viewBox="0 0 14 14" fill="none">
<path d="M1.16663 6.99935H12.8333M12.8333 6.99935L6.99996 1.16602M12.8333 6.99935L6.99996 12.8327" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="piggy-bank" viewBox="0 0 22 20" fill="none">
<path d="M3.99993 11C3.99993 12.6484 4.66466 14.1415 5.74067 15.226C5.84445 15.3305 5.89633 15.3828 5.92696 15.4331C5.95619 15.4811 5.9732 15.5224 5.98625 15.5771C5.99993 15.6343 5.99993 15.6995 5.99993 15.8298V18.2C5.99993 18.48 5.99993 18.62 6.05443 18.727C6.10236 18.8211 6.17885 18.8976 6.27293 18.9455C6.37989 19 6.5199 19 6.79993 19H8.69993C8.97996 19 9.11997 19 9.22693 18.9455C9.32101 18.8976 9.3975 18.8211 9.44543 18.727C9.49993 18.62 9.49993 18.48 9.49993 18.2V17.8C9.49993 17.52 9.49993 17.38 9.55443 17.273C9.60236 17.1789 9.67885 17.1024 9.77293 17.0545C9.87989 17 10.0199 17 10.2999 17H11.6999C11.98 17 12.12 17 12.2269 17.0545C12.321 17.1024 12.3975 17.1789 12.4454 17.273C12.4999 17.38 12.4999 17.52 12.4999 17.8V18.2C12.4999 18.48 12.4999 18.62 12.5544 18.727C12.6024 18.8211 12.6789 18.8976 12.7729 18.9455C12.8799 19 13.0199 19 13.2999 19H15.2C15.48 19 15.62 19 15.727 18.9455C15.8211 18.8976 15.8976 18.8211 15.9455 18.727C16 18.62 16 18.48 16 18.2V17.2243C16 17.0223 16 16.9212 16.0288 16.8401C16.0563 16.7624 16.0911 16.708 16.15 16.6502C16.2114 16.59 16.3155 16.5417 16.5237 16.445C17.5059 15.989 18.344 15.2751 18.9511 14.3902C19.0579 14.2346 19.1112 14.1568 19.1683 14.1108C19.2228 14.0668 19.2717 14.0411 19.3387 14.021C19.4089 14 19.4922 14 19.6587 14H20.2C20.48 14 20.62 14 20.727 13.9455C20.8211 13.8976 20.8976 13.8211 20.9455 13.727C21 13.62 21 13.48 21 13.2V9.78575C21 9.51916 21 9.38586 20.9505 9.28303C20.9013 9.181 20.819 9.09867 20.717 9.04953C20.6141 9 20.4808 9 20.2143 9C20.0213 9 19.9248 9 19.8471 8.9738C19.7633 8.94556 19.7045 8.90798 19.6437 8.84377C19.5874 8.78422 19.5413 8.68464 19.4493 8.48547C19.1538 7.84622 18.7492 7.26777 18.2593 6.77404C18.1555 6.66945 18.1036 6.61716 18.073 6.56687C18.0437 6.51889 18.0267 6.47759 18.0137 6.42294C18 6.36567 18 6.30051 18 6.17018V5.06058C18 4.70053 18 4.52051 17.925 4.39951C17.8593 4.29351 17.7564 4.21588 17.6365 4.18184C17.4995 4.14299 17.3264 4.19245 16.9802 4.29136L14.6077 4.96922C14.5673 4.98074 14.5472 4.9865 14.5267 4.99054C14.5085 4.99414 14.4901 4.99671 14.4716 4.99826C14.4508 5 14.4298 5 14.3879 5H13.959M3.99993 11C3.99993 8.69594 5.29864 6.6952 7.20397 5.6899M3.99993 11H3C1.89543 11 1 10.1046 1 9C1 8.25972 1.4022 7.61337 2 7.26756M14 4.5C14 6.433 12.433 8 10.5 8C8.567 8 7 6.433 7 4.5C7 2.567 8.567 1 10.5 1C12.433 1 14 2.567 14 4.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 41 KiB

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,