From a84e50f6ffcb29dc0c2f53aeedc3c153f8cdfc03 Mon Sep 17 00:00:00 2001 From: Akira Taguchi <31825085+lambdakilo@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:03:33 +0300 Subject: [PATCH] feat: display listings from nostr --- src/lib/bounty.test.ts | 337 +++++++++++++++++++++++++- src/lib/bounty.ts | 176 +++++++++++++- src/lib/components/BountyCard.svelte | 2 +- src/lib/mock/bounties.ts | 13 +- src/lib/types/bounty.ts | 6 +- src/routes/+page.svelte | 77 +++++- src/routes/bounties/[id]/+page.svelte | 4 +- 7 files changed, 596 insertions(+), 19 deletions(-) diff --git a/src/lib/bounty.test.ts b/src/lib/bounty.test.ts index 07493e6..d9925e9 100644 --- a/src/lib/bounty.test.ts +++ b/src/lib/bounty.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; -import type { BountyDraft } from './bounty'; +import { NDKEvent } from '@nostr-dev-kit/ndk'; +import type { BountyDraft, Deletion } from './bounty'; +import type { Bounty } from './types/bounty'; // --------------------------------------------------------------------------- // Mock the ndk singleton so no real WebSocket connections are made. @@ -10,10 +12,107 @@ vi.mock('./ndk', () => ({ })); // Import AFTER the mock is registered -const { buildBountyEvent } = await import('./bounty'); +const { + buildBountyEvent, + parseBountyEvent, + recordDeletion, + upsertBounty, + visibleBounties +} = await import('./bounty'); // --------------------------------------------------------------------------- +const MAKER = 'a'.repeat(64); +const IMPOSTOR = 'b'.repeat(64); + +interface BountyEventOverrides { + id?: string; + pubkey?: string; + createdAt?: number; + /** `null` omits the tag entirely. */ + d?: string | null; + title?: string | null; + amount?: string | null; + amountTag?: 'amount_sats' | 'amount'; + status?: string | null; + topics?: string[]; + resolutionMode?: string | null; + /** Omitted by default, as `buildBountyEvent` omits it. */ + checkInDays?: string | null; + content?: string; +} + +/** A kind-30050 event carrying only the tags `buildBountyEvent` emits. */ +function makeBountyEvent(overrides: BountyEventOverrides = {}): NDKEvent { + const { + id = 'event-1', + pubkey = MAKER, + createdAt = 1_700_000_000, + d = 'bounty-1', + title = 'Fix memory leak', + amount = '250000', + amountTag = 'amount_sats', + status = 'open', + topics = [], + resolutionMode = 'A', + checkInDays = null, + content = '' + } = overrides; + + const tags: string[][] = []; + if (d !== null) tags.push(['d', d]); + if (title !== null) tags.push(['title', title]); + if (amount !== null) tags.push([amountTag, amount]); + if (status !== null) tags.push(['s', status]); + if (resolutionMode !== null) tags.push(['resolution_mode', resolutionMode]); + if (checkInDays !== null) tags.push(['check_in_days', checkInDays]); + for (const topic of topics) tags.push(['t', topic]); + + return new NDKEvent(undefined, { + id, + pubkey, + created_at: createdAt, + kind: 30050, + content, + sig: '', + tags + }); +} + +/** A kind-5 deletion request, as `NDKEvent.delete()` would build it. */ +function makeDeletionEvent( + targets: { addresses?: string[]; ids?: string[] }, + overrides: { pubkey?: string; createdAt?: number } = {} +): NDKEvent { + const { pubkey = MAKER, createdAt = 1_700_000_100 } = overrides; + return new NDKEvent(undefined, { + id: 'deletion-1', + pubkey, + created_at: createdAt, + kind: 5, + content: 'test event', + sig: '', + tags: [ + ...(targets.addresses ?? []).map((address) => ['a', address]), + ...(targets.ids ?? []).map((id) => ['e', id]), + ['k', '30050'] + ] + }); +} + +function parseOrThrow(event: NDKEvent): Bounty { + const bounty = parseBountyEvent(event); + if (!bounty) throw new Error('fixture failed to parse'); + return bounty; +} + +/** Index bounties the way the listing does. */ +function index(...bounties: Bounty[]): Map { + const byAddress = new Map(); + for (const bounty of bounties) upsertBounty(bounty, byAddress); + return byAddress; +} + function makeDraft(overrides: Partial = {}): BountyDraft { return { title: 'Fix memory leak', @@ -87,3 +186,237 @@ describe('buildBountyEvent', () => { expect(tags.filter((tag) => tag[0] === 't')).toEqual([]); }); }); + +describe('parseBountyEvent', () => { + it('parses a well-formed event', () => { + const event = makeBountyEvent({ + topics: ['rust', 'bitcoin'], + content: ' Heap grows without bound. ', + checkInDays: '7' + }); + + expect(parseBountyEvent(event)).toEqual({ + id: 'event-1', + address: `30050:${MAKER}:bounty-1`, + makerPubkey: MAKER, + createdAt: 1_700_000_000, + title: 'Fix memory leak', + description: 'Heap grows without bound.', + amountSats: 250000, + status: 'open', + resolutionMode: 'A', + checkInIntervalDays: 7, + tags: ['rust', 'bitcoin'] + }); + }); + + it('reads the oracle resolution mode', () => { + const event = makeBountyEvent({ resolutionMode: 'B' }); + + expect(parseBountyEvent(event)?.resolutionMode).toBe('B'); + }); + + it.each([ + ['a missing tag', null], + ['a blank tag', ''], + ['an out-of-range value', '0'], + ['a non-numeric value', 'weekly'], + ['a fractional value', '1.5'] + ] satisfies [string, string | null][])( + 'leaves the check-in interval unset for %s', + (_label, checkInDays) => { + const event = makeBountyEvent({ checkInDays }); + + expect(parseBountyEvent(event)?.checkInIntervalDays).toBeUndefined(); + } + ); + + it('caps the description', () => { + const event = makeBountyEvent({ content: 'x'.repeat(20_001) }); + + expect(parseBountyEvent(event)?.description).toBe('x'.repeat(20_000)); + }); + + it('reads back an event built by buildBountyEvent', () => { + // buildBountyEvent omits gov_key_*, refund_address and check_in_days, which + // the spec marks required — the parser must not reject the app's own writes. + const built = buildBountyEvent(makeDraft({ topics: 'rust' })); + const event = new NDKEvent(undefined, { + ...built, + id: 'event-1', + pubkey: MAKER, + created_at: 1_700_000_000, + sig: '' + }); + + expect(parseBountyEvent(event)).toMatchObject({ + title: 'Fix memory leak', + description: 'Find and fix the leak.', + amountSats: 250000, + status: 'open', + resolutionMode: 'A', + checkInIntervalDays: undefined, + tags: ['rust'] + }); + }); + + it('falls back to the legacy amount tag', () => { + const event = makeBountyEvent({ amount: '500000', amountTag: 'amount' }); + + expect(parseBountyEvent(event)?.amountSats).toBe(500000); + }); + + it('trims and caps the title', () => { + const event = makeBountyEvent({ title: ` ${'x'.repeat(300)} ` }); + + expect(parseBountyEvent(event)?.title).toBe('x'.repeat(200)); + }); + + it('caps topic tags', () => { + const topics = Array.from({ length: 20 }, (_, i) => `topic-${i}`); + const event = makeBountyEvent({ topics }); + + expect(parseBountyEvent(event)?.tags).toHaveLength(8); + }); + + it.each([ + ['a missing d tag', { d: null }], + ['a blank title', { title: ' ' }], + ['a missing title', { title: null }], + ['a missing id', { id: '' }], + ['a non-numeric amount', { amount: 'lots' }], + ['an empty amount', { amount: '' }], + ['a negative amount', { amount: '-1' }], + ['a fractional amount', { amount: '1.5' }], + ['a missing amount', { amount: null }], + // An unrecognized status would reach BountyCard's statusBadge lookup and + // throw, blanking the whole listing. + ['an unrecognized status', { status: 'pwned' }], + ['a missing status', { status: null }], + // An unrecognized mode would fall through to the detail page's `{:else}` + // and mislabel the bounty as Mode A · Juror. + ['an unrecognized resolution mode', { resolutionMode: 'C' }], + ['a missing resolution mode', { resolutionMode: null }] + ] satisfies [string, BountyEventOverrides][])( + 'returns null for %s', + (_label, overrides) => { + expect(parseBountyEvent(makeBountyEvent(overrides))).toBeNull(); + } + ); +}); + +describe('upsertBounty', () => { + it('keeps a single entry when a newer revision arrives', () => { + const older = parseOrThrow(makeBountyEvent({ createdAt: 100 })); + const newer = parseOrThrow( + makeBountyEvent({ id: 'event-2', createdAt: 200, status: 'claimed' }) + ); + + const byAddress = index(older, newer); + + expect([...byAddress.values()]).toEqual([newer]); + }); + + it('ignores a stale revision arriving after a newer one', () => { + const newer = parseOrThrow(makeBountyEvent({ createdAt: 200 })); + const older = parseOrThrow( + makeBountyEvent({ id: 'event-2', createdAt: 100, status: 'claimed' }) + ); + + const byAddress = index(newer, older); + + expect([...byAddress.values()]).toEqual([newer]); + }); + + it('keeps bounties at different addresses side by side', () => { + const first = parseOrThrow(makeBountyEvent({ d: 'bounty-1' })); + const second = parseOrThrow( + makeBountyEvent({ id: 'event-2', d: 'bounty-2' }) + ); + + expect(index(first, second).size).toBe(2); + }); +}); + +describe('visibleBounties', () => { + function record(...events: NDKEvent[]): Map { + const deletions = new Map(); + for (const event of events) recordDeletion(event, deletions); + return deletions; + } + + it('returns bounties newest first', () => { + const older = parseOrThrow(makeBountyEvent({ d: 'a', createdAt: 100 })); + const newer = parseOrThrow( + makeBountyEvent({ id: 'event-2', d: 'b', createdAt: 200 }) + ); + + expect(visibleBounties(index(older, newer), new Map())).toEqual([ + newer, + older + ]); + }); + + it('hides a bounty deleted by address', () => { + const bounty = parseOrThrow(makeBountyEvent({ createdAt: 100 })); + const deletions = record( + makeDeletionEvent({ addresses: [bounty.address] }, { createdAt: 200 }) + ); + + expect(visibleBounties(index(bounty), deletions)).toEqual([]); + }); + + it('hides a bounty deleted by event id', () => { + const bounty = parseOrThrow(makeBountyEvent({ createdAt: 100 })); + const deletions = record( + makeDeletionEvent({ ids: [bounty.id] }, { createdAt: 200 }) + ); + + expect(visibleBounties(index(bounty), deletions)).toEqual([]); + }); + + it('hides a bounty whose deletion was recorded before it arrived', () => { + const bounty = parseOrThrow(makeBountyEvent({ createdAt: 100 })); + const deletions = record( + makeDeletionEvent( + { addresses: [`30050:${MAKER}:bounty-1`] }, + { createdAt: 200 } + ) + ); + + expect(visibleBounties(index(bounty), deletions)).toEqual([]); + }); + + it('ignores a deletion signed by anyone but the maker', () => { + const bounty = parseOrThrow(makeBountyEvent({ createdAt: 100 })); + const deletions = record( + makeDeletionEvent( + { addresses: [bounty.address], ids: [bounty.id] }, + { pubkey: IMPOSTOR, createdAt: 200 } + ) + ); + + expect(visibleBounties(index(bounty), deletions)).toEqual([bounty]); + }); + + it('ignores an address deletion older than the revision it targets', () => { + // NIP-09: an `a` request only deletes versions created before it, so a + // republished bounty outlives an earlier retraction. + const bounty = parseOrThrow(makeBountyEvent({ createdAt: 300 })); + const deletions = record( + makeDeletionEvent({ addresses: [bounty.address] }, { createdAt: 200 }) + ); + + expect(visibleBounties(index(bounty), deletions)).toEqual([bounty]); + }); + + it('keeps the newest deletion when requests arrive out of order', () => { + const bounty = parseOrThrow(makeBountyEvent({ createdAt: 300 })); + const deletions = record( + makeDeletionEvent({ addresses: [bounty.address] }, { createdAt: 400 }), + makeDeletionEvent({ addresses: [bounty.address] }, { createdAt: 200 }) + ); + + expect(visibleBounties(index(bounty), deletions)).toEqual([]); + }); +}); diff --git a/src/lib/bounty.ts b/src/lib/bounty.ts index d652a8c..e8c1e29 100644 --- a/src/lib/bounty.ts +++ b/src/lib/bounty.ts @@ -1,7 +1,11 @@ -import { NDKEvent } from '@nostr-dev-kit/ndk'; +import { NDKEvent, type NDKKind } from '@nostr-dev-kit/ndk'; import { ndk } from './ndk'; +import type { Bounty, BountyStatus, ResolutionMode } from './types/bounty'; -export const BOUNTY_KIND = 30050; +// NDK's NDKKind enum doesn't enumerate the SatCode protocol kinds, and since +// TS 5 only declared members are assignable to an enum type — so the assertion +// is what lets this be used in an NDKFilter. +export const BOUNTY_KIND = 30050 as NDKKind; export interface BountyDraft { title: string; @@ -52,3 +56,171 @@ export async function publishBounty(draft: BountyDraft): Promise { await event.publish(); // throws NDKPublishError if no relay accepts return event; } + +// --------------------------------------------------------------------------- +// Reading bounties off the relays +// --------------------------------------------------------------------------- + +/** Longest title we render. Relay titles are untrusted and unbounded. */ +const MAX_TITLE_LENGTH = 200; + +/** Longest description we render, for the same reason. */ +const MAX_DESCRIPTION_LENGTH = 20_000; + +/** Most topic chips a card shows. */ +const MAX_TOPICS = 8; + +/** + * Every value the spec allows in the `s` tag. + * + * Also serves as a filter: kind 30050 is contested namespace — unrelated apps + * publish key bundles, chat rooms and device handshakes on it, and on the + * default relays they outnumber bounties ~50:1. Asking for `#s` narrows the + * query to events carrying a bounty status, so those apps don't consume the + * subscription's limit. + */ +export const BOUNTY_STATUSES: readonly BountyStatus[] = [ + 'open', + 'in-progress', + 'in-dispute', + 'claimed', + 'cancelled' +]; + +function isBountyStatus(value: string | undefined): value is BountyStatus { + return BOUNTY_STATUSES.some((status) => status === value); +} + +function isResolutionMode(value: string | undefined): value is ResolutionMode { + return value === 'A' || value === 'B'; +} + +/** + * Parse a kind-30050 event into a `Bounty`, or `null` when it cannot be + * rendered as one. + * + * Relays are untrusted input: an event carrying an unrecognized `s` or + * `resolution_mode` tag would otherwise reach the status lookup and mode badge + * and render wrong. Only the tags a bounty is unrenderable without are + * required — `buildBountyEvent` omits `gov_key_*`, `refund_address` and + * `check_in_days`, so requiring those would stop the app reading its own + * writes. + */ +export function parseBountyEvent(event: NDKEvent): Bounty | null { + const { id, pubkey, created_at: createdAt } = event; + const title = event.tagValue('title')?.trim(); + const status = event.tagValue('s'); + const resolutionMode = event.tagValue('resolution_mode'); + + if (!id || !pubkey || !createdAt || !event.dTag || !title) return null; + if (!isBountyStatus(status)) return null; + if (!isResolutionMode(resolutionMode)) return null; + + // 'amount_sats' is canonical; 'amount' is accepted for compatibility. + const amount = event.tagValue('amount_sats') ?? event.tagValue('amount'); + const amountSats = Number(amount); + if (!amount || !Number.isSafeInteger(amountSats) || amountSats < 0) { + return null; + } + + // Optional, unlike the tags above: absent on every bounty this app publishes. + const checkInDays = Number(event.tagValue('check_in_days')); + const checkInIntervalDays = + Number.isSafeInteger(checkInDays) && checkInDays > 0 + ? checkInDays + : undefined; + + return { + id, + address: event.tagAddress(), + makerPubkey: pubkey, + createdAt, + title: title.slice(0, MAX_TITLE_LENGTH), + description: event.content.trim().slice(0, MAX_DESCRIPTION_LENGTH), + amountSats, + status, + resolutionMode, + checkInIntervalDays, + tags: event + .getMatchingTags('t') + .map((tag) => tag[1]) + .filter(Boolean) + .slice(0, MAX_TOPICS) + }; +} + +/** + * Insert a bounty into `byAddress`, keeping whichever revision is newest. + * + * Addressable events are identified by address, not event id, and NDK's + * subscriptions only dedupe by id — so two relays holding different revisions + * of one bounty arrive as two events that must collapse to a single entry. + */ +export function upsertBounty( + bounty: Bounty, + byAddress: Map +): void { + const current = byAddress.get(bounty.address); + if (!current || bounty.createdAt > current.createdAt) { + byAddress.set(bounty.address, bounty); + } +} + +/** A NIP-09 deletion request, keyed by the event id or address it targets. */ +export interface Deletion { + /** `created_at` of the newest deletion request seen for that target. */ + at: number; + /** Pubkey that signed it — only an author may delete their own events. */ + by: string; +} + +/** + * Record the targets of a kind-5 deletion request, newest wins. + * + * `a` and `e` targets share one map: a 64-character event id can never collide + * with a `::` address. + */ +export function recordDeletion( + event: NDKEvent, + deletions: Map +): void { + const { pubkey, created_at: at } = event; + if (!pubkey || !at) return; + + const targets = [ + ...event.getMatchingTags('a'), + ...event.getMatchingTags('e') + ]; + for (const [, target] of targets) { + if (!target) continue; + const current = deletions.get(target); + if (!current || at > current.at) { + deletions.set(target, { at, by: pubkey }); + } + } +} + +function isDeleted( + bounty: Bounty, + deletions: ReadonlyMap +): boolean { + const byAddress = deletions.get(bounty.address); + // NIP-09: an `a` request deletes every version created before it. + if ( + byAddress?.by === bounty.makerPubkey && + byAddress.at >= bounty.createdAt + ) { + return true; + } + return deletions.get(bounty.id)?.by === bounty.makerPubkey; +} + +/** Bounties their maker has not retracted, newest first. */ +export function visibleBounties( + byAddress: ReadonlyMap, + deletions: ReadonlyMap +): Bounty[] { + return [...byAddress.values()] + .filter((bounty) => !isDeleted(bounty, deletions)) + .sort((a, b) => b.createdAt - a.createdAt); +} diff --git a/src/lib/components/BountyCard.svelte b/src/lib/components/BountyCard.svelte index f6c824d..727d029 100644 --- a/src/lib/components/BountyCard.svelte +++ b/src/lib/components/BountyCard.svelte @@ -34,7 +34,7 @@
diff --git a/src/lib/mock/bounties.ts b/src/lib/mock/bounties.ts index e797a48..ed542f3 100644 --- a/src/lib/mock/bounties.ts +++ b/src/lib/mock/bounties.ts @@ -5,12 +5,14 @@ import type { Bounty, BountyComment, NostrProfile } from '$lib/types/bounty'; * * When NDK integration is ready, replace this module with real fetches: * export async function getBounties(): Promise { ... } - * export async function getBounty(id: string): Promise { ... } + * export async function getBounty(address: string): Promise { ... } */ export const mockBounties: Bounty[] = [ { id: '1', + address: + '30050:npub1alice00000000000000000000000000000000000000000000000000:1', title: 'Add NIP-57 zap receipts to the bounty feed', description: `## Overview Implement NIP-57 zap receipt parsing so that the bounty feed shows how much has been zapped to each bounty. @@ -34,6 +36,8 @@ Implement NIP-57 zap receipt parsing so that the bounty feed shows how much has }, { id: '2', + address: + '30050:npub1bob0000000000000000000000000000000000000000000000000000:2', title: 'Escrow state machine for Arkade multiparty flows', description: `## Overview Design and implement a state machine that manages escrow transitions for multi-party Bitcoin transactions using Arkade. @@ -57,6 +61,8 @@ Design and implement a state machine that manages escrow transitions for multi-p }, { id: '3', + address: + '30050:npub1alice00000000000000000000000000000000000000000000000000:3', title: 'Dark-mode landing page skeleton', description: `## Overview Create a responsive dark-mode landing page skeleton using Tailwind CSS. @@ -131,8 +137,9 @@ export function getBounties(): Bounty[] { return mockBounties; } -export function getBounty(id: string): Bounty | null { - return mockBounties.find((b) => b.id === id) ?? null; +/** Looked up by address: BountyCard links by it, since event ids change on edit. */ +export function getBounty(address: string): Bounty | null { + return mockBounties.find((b) => b.address === address) ?? null; } export function getComments(bountyId: string): BountyComment[] { diff --git a/src/lib/types/bounty.ts b/src/lib/types/bounty.ts index 628f41a..32bf711 100644 --- a/src/lib/types/bounty.ts +++ b/src/lib/types/bounty.ts @@ -4,7 +4,10 @@ export type BountyStatus = export type ResolutionMode = 'A' | 'B'; export interface Bounty { + /** Event id of the revision this was parsed from; NIP-09 `e` tags match it. */ id: string; + /** `::` — stable across revisions, unlike the event id. */ + address: string; title: string; description: string; amountSats: number; @@ -13,7 +16,8 @@ export interface Bounty { makerPubkey: string; createdAt: number; // unix timestamp resolutionMode: ResolutionMode; - checkInIntervalDays: number; + /** Omitted when the event carries no `check_in_days` tag. */ + checkInIntervalDays?: number; } export interface BountyComment { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 07f9c9e..8d640d6 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,11 +1,62 @@ @@ -17,9 +68,17 @@

Software bounties organized on Nostr, paid in Bitcoin.

-
- {#each bounties as bounty (bounty.id)} - - {/each} -
+ {#if loading && bounties.length === 0} +
+ {:else if bounties.length === 0} +

+ No bounties found on your relays yet. +

+ {:else} +
+ {#each bounties as bounty (bounty.address)} + + {/each} +
+ {/if} diff --git a/src/routes/bounties/[id]/+page.svelte b/src/routes/bounties/[id]/+page.svelte index 3ebfa03..6717967 100644 --- a/src/routes/bounties/[id]/+page.svelte +++ b/src/routes/bounties/[id]/+page.svelte @@ -11,6 +11,8 @@ // TODO: Replace mock imports with NDK-based fetching: // import { getBounty, getComments, getProfile, shortPubkey } from '$lib/services/bounties'; + // Until then only the mock bounties resolve here — the listing is already + // live, so a card for a real relay bounty lands on "Bounty not found". const isLoggedIn = false; @@ -134,7 +136,7 @@ · {timeAgo(bounty.createdAt)} - {#if bounty.checkInIntervalDays > 0} + {#if (bounty.checkInIntervalDays ?? 0) > 0} · Check-in every {bounty.checkInIntervalDays}d {/if}