feat(marketplace): NIP-13 proof of work - free CPU anti-spam

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.
This commit is contained in:
SkateSpots 2026-10-01 16:42:10 +07:00
parent 864a384f60
commit a12db574d4
7 changed files with 169 additions and 16 deletions

View file

@ -143,9 +143,18 @@ session with **no other memory of this project**. So:
~0 even when cross-confirmed. **Ring analysis** — `useTradeRings` does a
**2-hop** query of the partners' own attestations and measures **closedness**
(share of possible partner↔partner trades); `≥3` partners with `≥0.5`
closedness flags a closed cluster. ⚠️ **PoW (NIP-13) is the remaining lever**
but requires **mining at publish** (and a read-side verifier) — a verifier
alone does nothing because no events carry a `nonce` yet. Deferred.
closedness flags a closed cluster.
- **NIP-13 proof of work (2026-10-01):** `src/lib/pow.ts` (`minePow` /
`verifyPow` / `countLeadingZeroBits`). ⚠️ **PoW pays no one** — it is a free
**CPU** tax, not a fee; don't describe it as a payment. `useNostrPublish`
accepts `pow?: number` and **mines the nonce on the UNSIGNED event before
signing** (the id is signature-independent, so the signer is still prompted
once). `CreateListingPage` sends **16 bits** on every listing (~80 ms) and
**20 bits** when a **newcomer picks work instead of the 1,000-sat listing
fee** (`SpamFeePayment.onPowInstead` → the free alternative). `parseMarketplaceItem`
exposes `powDifficulty` and `MarketplaceItemCard` shows a **⚡ PoW badge** at
≥16. It raises cost, it doesn't prove humanity; keep targets low (mining runs
on the main thread with periodic yields).
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a

View file

@ -343,6 +343,15 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
: '📍📦 Pickup or ship'}
</Badge>
)}
{typeof item.powDifficulty === 'number' && item.powDifficulty >= 16 && (
<Badge
variant="outline"
className="text-xs border-gray-600 bg-gray-800/50"
title="NIP-13 proof of work — free anti-spam"
>
⚡ PoW {item.powDifficulty}
</Badge>
)}
{sellerRep && sellerRep.verified > 0 && (
<Badge variant="outline" className="text-xs border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00]">
<CheckCircle className="h-3 w-3 mr-1" />

View file

@ -14,6 +14,8 @@ interface SpamFeePaymentProps {
feeRecipient: string;
feeRecipientName: string;
onPaymentSuccess: () => void;
/** Offered as a FREE alternative to the sats fee: mine NIP-13 proof of work. */
onPowInstead?: () => void;
onSkip?: () => void;
}
@ -23,6 +25,7 @@ export function SpamFeePayment({
feeRecipient,
feeRecipientName,
onPaymentSuccess,
onPowInstead,
}: SpamFeePaymentProps) {
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
@ -210,15 +213,33 @@ export function SpamFeePayment({
<p className="text-sm text-gray-400 mt-1">~${(LISTING_FEE_SATS * 0.0006).toFixed(2)} USD • One-time fee</p>
</div>
{/* Initial state - generate invoice button */}
{/* Initial state — pay, or prove work instead (free) */}
{paymentState === 'idle' && (
<Button
onClick={generateInvoice}
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-bold"
>
<Zap className="h-4 w-4 mr-2" />
Pay {LISTING_FEE_SATS} sats to Continue
</Button>
<div className="space-y-3">
<Button
onClick={generateInvoice}
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-bold"
>
<Zap className="h-4 w-4 mr-2" />
Pay {LISTING_FEE_SATS} sats to Continue
</Button>
{onPowInstead && (
<>
<p className="text-center text-xs uppercase tracking-widest text-gray-500">or</p>
<Button
onClick={onPowInstead}
variant="outline"
className="w-full font-bold border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
>
⚡ Prove work instead — free
</Button>
<p className="text-center text-xs text-gray-500">
Spend a few seconds of CPU instead of {LISTING_FEE_SATS.toLocaleString()} sats. No
money, no wallet — a spam tax, not a payment.
</p>
</>
)}
</div>
)}
{/* Generating invoice */}

View file

@ -3,6 +3,7 @@ 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";
@ -37,24 +38,37 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
const { user } = useCurrentUser();
return useMutation({
mutationFn: async (t: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>) => {
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 ?? [];
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,
created_at: t.created_at ?? Math.floor(Date.now() / 1000),
tags: finalTags,
created_at: createdAt,
});
// Offline: don't sit through a 15s publish timeout before giving up.

83
src/lib/pow.ts Normal file
View file

@ -0,0 +1,83 @@
/**
* NIP-13 proof of work — a **free** (CPU-only) anti-spam tax.
*
* A `["nonce", <nonce>, <target>]` tag plus an event id with at least `target`
* leading zero bits. Mining varies the nonce and recomputes the id (which does
* NOT depend on the signature), so we **mine before signing** and sign once —
* no extra signer prompts. Verification is a hash-count, free for everyone.
*
* Nobody receives anything: it costs the sender a little electricity and makes
* mass-minting expensive. See `CreateListingPage` for the fee-or-work gate.
*/
import { getEventHash } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
export const NONCE_TAG = 'nonce';
/** Number of leading zero BITS in a hex string (the NIP-13 difficulty). */
export function countLeadingZeroBits(hex: string): number {
let bits = 0;
for (const ch of hex) {
const nibble = parseInt(ch, 16);
if (Number.isNaN(nibble)) return bits;
if (nibble === 0) {
bits += 4;
continue;
}
bits += Math.clz32(nibble) - 28; // 1→3, 2→2, 4→1, 8→0
break;
}
return bits;
}
export interface PowProof {
valid: boolean;
/** Actual leading-zero bits of the event id. */
difficulty: number;
/** The target the author claimed. */
target: number;
}
/** Check a signed event's PoW. Cheap — a single hash count. */
export function verifyPow(event: NostrEvent): PowProof {
const tag = event.tags.find(([n]) => n === NONCE_TAG);
if (!tag) return { valid: false, difficulty: 0, target: 0 };
const target = Number(tag[2] ?? 0);
const difficulty = countLeadingZeroBits(event.id);
return { valid: target > 0 && difficulty >= target, difficulty, target };
}
export interface MineOptions {
signal?: AbortSignal;
onProgress?: (attempts: number) => void;
}
/**
* Mine a nonce so the resulting event id has >= `target` leading zero bits.
* Yields to the event loop periodically so the UI stays responsive; cancellable.
*/
export async function minePow(
template: { pubkey: string; kind: number; created_at: number; tags: string[][]; content: string },
target: number,
options: MineOptions = {},
): Promise<{ nonce: string; id: string; difficulty: number; target: number }> {
const baseTags = template.tags.filter(([n]) => n !== NONCE_TAG);
let attempts = 0;
// Random start so two clients never mine the same nonce sequence.
let counter = Math.floor(Math.random() * 0xffff);
for (;;) {
if (options.signal?.aborted) throw new Error('Proof-of-work cancelled');
counter += 1;
const nonce = `${counter.toString(36)}-${Math.floor(Math.random() * 0xffff).toString(36)}`;
const tags = [...baseTags, [NONCE_TAG, nonce, String(target)]];
const id = getEventHash({ ...template, tags } as NostrEvent);
attempts += 1;
const difficulty = countLeadingZeroBits(id);
if (difficulty >= target) return { nonce, id, difficulty, target };
if (attempts % 4000 === 0) {
options.onProgress?.(attempts);
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
}

View file

@ -1,5 +1,6 @@
import type { NostrEvent } from '@nostrify/nostrify';
import ngeohash from 'ngeohash';
import { countLeadingZeroBits } from '@/lib/pow';
import { parseCrewVideo } from '@/lib/videoEmbed';
import { isAdminPubkey } from '@/lib/admins';
@ -3555,6 +3556,8 @@ export interface MarketplaceItem {
size?: string;
/** How the deal is fulfilled: local pickup, shipping, or either. */
fulfilment?: 'pickup' | 'ships' | 'both';
/** NIP-13 proof-of-work difficulty (leading zero bits) on the event id. */
powDifficulty?: number;
expiration?: number; // Unix timestamp
images: string[];
location?: string;
@ -3635,6 +3638,9 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null
const condition = event.tags.find(([name]) => name === 'condition')?.[1] as MarketplaceItem['condition'];
const listingType = event.tags.find(([name]) => name === 'listing_type')?.[1] as MarketplaceItem['listingType'];
const fulfilment = event.tags.find(([name]) => name === 'fulfilment')?.[1] as MarketplaceItem['fulfilment'];
const powDifficulty = event.tags.some(([name]) => name === 'nonce')
? countLeadingZeroBits(event.id)
: undefined;
const size = event.tags.find(([name]) => name === 'size')?.[1];
const expirationTag = event.tags.find(([name]) => name === 'expiration')?.[1];
const expiration = expirationTag ? parseInt(expirationTag) : undefined;
@ -3669,6 +3675,7 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null
condition,
listingType,
fulfilment,
powDifficulty,
size,
expiration,
images,

View file

@ -47,6 +47,7 @@ export default function CreateListingPage() {
// Anti-spam restrictions
const restrictions = useListingRestrictions(linkedSpot?.pubkey);
const [feePaid, setFeePaid] = useState(false);
const [powBypass, setPowBypass] = useState(false);
const [, setLastListingRecord] = useLocalStorage<{ timestamp: number; spotOwnerPubkey?: string } | null>(
'last-listing-record',
null
@ -309,6 +310,9 @@ export default function CreateListingPage() {
kind: MARKETPLACE_ITEM_KIND,
content: description.trim(),
tags,
// NIP-13 proof of work — a free anti-spam tax (CPU, we never pay anyone).
// Invisible on a normal listing; a higher target stands in for the sats fee.
pow: powBypass ? 20 : 16,
});
// Record listing creation for cooldown tracking (only for unknown trust level)
@ -516,6 +520,10 @@ export default function CreateListingPage() {
feeRecipient={restrictions.feeRecipient}
feeRecipientName={restrictions.feeRecipientName || 'SkateSpots'}
onPaymentSuccess={() => setFeePaid(true)}
onPowInstead={() => {
setPowBypass(true);
setFeePaid(true);
}}
/>
</div>
)}
@ -527,7 +535,9 @@ export default function CreateListingPage() {
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-green-400 flex-shrink-0" />
<div>
<span className="font-bold text-green-400">Fee Paid!</span>
<span className="font-bold text-green-400">
{powBypass ? 'Proof of work accepted!' : 'Fee Paid!'}
</span>
<span className="text-gray-300 ml-2">You can now create your listing.</span>
</div>
</div>