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:
parent
864a384f60
commit
a12db574d4
7 changed files with 169 additions and 16 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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
83
src/lib/pow.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue