pow.ts: minePow/verifyPow/countLeadingZeroBits. PoW pays NO ONE - it is a free CPU tax, not a fee. useNostrPublish accepts pow and mines the nonce on the UNSIGNED event before signing (id is signature-independent -> one signer prompt). CreateListingPage sends 16 bits per listing (~80ms) and 20 bits when a newcomer chooses work instead of the 1,000-sat fee (SpamFeePayment.onPowInstead = the free alternative). parseMarketplaceItem exposes powDifficulty; the card shows a PoW badge at >=16.
154 lines
No EOL
4.8 KiB
TypeScript
154 lines
No EOL
4.8 KiB
TypeScript
import { useNostr } from "@nostrify/react";
|
|
import { useMutation, type UseMutationResult } from "@tanstack/react-query";
|
|
|
|
import { useCurrentUser } from "./useCurrentUser";
|
|
import { isOffline, queueSignedEvent } from "@/lib/offlineCache";
|
|
import { minePow } from "@/lib/pow";
|
|
import { toast } from "@/hooks/useToast";
|
|
|
|
import type { NostrEvent, NPool } from "@nostrify/nostrify";
|
|
|
|
/**
|
|
* Keep a signed event for delivery when we're back online. Signing already
|
|
* happened (above), so an interactive signer is NOT prompted again later — we
|
|
* just hand the signature over.
|
|
*/
|
|
async function queueForLater(event: NostrEvent): Promise<boolean> {
|
|
try {
|
|
await queueSignedEvent(event);
|
|
toast({
|
|
title: 'Saved offline',
|
|
description: "We'll publish it automatically when you're back online.",
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Publishes a Nostr event with retry logic and improved error handling.
|
|
*
|
|
* If the relays can't be reached, the SIGNED event is queued (see
|
|
* `lib/offlineCache`) and delivered by `useSyncPendingPosts()` on reconnect —
|
|
* so an offline action completes instead of being lost.
|
|
*/
|
|
export function useNostrPublish(): UseMutationResult<NostrEvent> {
|
|
const { nostr } = useNostr();
|
|
const { user } = useCurrentUser();
|
|
|
|
return useMutation({
|
|
mutationFn: async (t: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { pow?: number }) => {
|
|
if (!user) {
|
|
throw new Error("You must be logged in to publish events");
|
|
}
|
|
|
|
const tags = [...(t.tags ?? [])];
|
|
|
|
// Add the client tag if it doesn't exist
|
|
if (location.protocol === "https:" && !tags.some(([name]) => name === "client")) {
|
|
tags.push(["client", location.hostname]);
|
|
}
|
|
|
|
const createdAt = t.created_at ?? Math.floor(Date.now() / 1000);
|
|
|
|
// Optional NIP-13 proof of work. Mined on the UNSIGNED event — the id does
|
|
// not depend on the signature — so the signer is still prompted only once.
|
|
let finalTags = tags;
|
|
if (t.pow && t.pow > 0) {
|
|
const { nonce } = await minePow(
|
|
{ pubkey: user.pubkey, kind: t.kind, created_at: createdAt, tags, content: t.content ?? "" },
|
|
t.pow,
|
|
);
|
|
finalTags = [...tags.filter(([n]) => n !== "nonce"), ["nonce", nonce, String(t.pow)]];
|
|
}
|
|
|
|
// Sign the event
|
|
const event = await user.signer.signEvent({
|
|
kind: t.kind,
|
|
content: t.content ?? "",
|
|
tags: finalTags,
|
|
created_at: createdAt,
|
|
});
|
|
|
|
// Offline: don't sit through a 15s publish timeout before giving up.
|
|
if (isOffline()) {
|
|
await queueForLater(event);
|
|
return event;
|
|
}
|
|
|
|
try {
|
|
await publishEventWithRetry(nostr, event);
|
|
} catch (err) {
|
|
// Relays unreachable — keep it and deliver later, unless queuing failed
|
|
// (then the real error is the honest thing to surface).
|
|
const queued = await queueForLater(event);
|
|
if (!queued) throw err;
|
|
}
|
|
|
|
return event;
|
|
},
|
|
onError: (error) => {
|
|
console.error("Failed to publish event:", error);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Publishes event with retry logic and partial success handling
|
|
*/
|
|
async function publishEventWithRetry(
|
|
nostr: NPool,
|
|
event: NostrEvent,
|
|
maxRetries = 1,
|
|
timeoutMs = 15000
|
|
): Promise<void> {
|
|
let lastError: Error | null = null;
|
|
|
|
// Try publishing with retries
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
try {
|
|
// Attempt to publish
|
|
await nostr.event(event, { signal: controller.signal });
|
|
return; // Success!
|
|
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
|
|
// If this isn't the last attempt, wait before retrying
|
|
if (attempt < maxRetries) {
|
|
const backoffMs = 1000 * Math.pow(2, attempt);
|
|
await new Promise(resolve => setTimeout(resolve, backoffMs));
|
|
}
|
|
}
|
|
}
|
|
|
|
// All retries failed
|
|
if (lastError) {
|
|
// Provide user-friendly error messages
|
|
if (lastError.name === 'AbortError' || lastError.message.includes('timeout')) {
|
|
throw new Error(
|
|
'Publishing timed out. Your relays may be slow or unreachable. ' +
|
|
'Try checking your relay connections in Settings > Relays.'
|
|
);
|
|
} else if (lastError.message.includes('network') || lastError.message.includes('fetch')) {
|
|
throw new Error(
|
|
'Network error while publishing. Please check your internet connection and try again.'
|
|
);
|
|
} else {
|
|
throw new Error(
|
|
`Failed to publish after ${maxRetries + 1} attempts: ${lastError.message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
throw new Error('Failed to publish event to any relay');
|
|
} |