feat: zap-pool
This commit is contained in:
@ -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">
|
||||
|
@ -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 ?? {});
|
||||
}
|
||||
|
3
packages/app/src/Pages/ZapPool.css
Normal file
3
packages/app/src/Pages/ZapPool.css
Normal file
@ -0,0 +1,3 @@
|
||||
.zap-pool input[type="range"] {
|
||||
width: 200px;
|
||||
}
|
185
packages/app/src/Pages/ZapPool.tsx
Normal file
185
packages/app/src/Pages/ZapPool.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
|
@ -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>;
|
||||
}
|
||||
|
124
packages/app/src/ZapPoolController.ts
Normal file
124
packages/app/src/ZapPoolController.ts
Normal 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();
|
@ -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,
|
||||
|
Reference in New Issue
Block a user