diff --git a/README.md b/README.md
index df2b941..7c444ea 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,88 @@ bun run pr
CI runs the same checks (all except `format`) on every pull request.
+## Retracting a bounty
+
+There is no retract button yet. Until there is, publish a [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) deletion request by hand, from the browser console on any SatCode page, signed in with your NIP-07 extension.
+
+Start by grabbing your pubkey and the relay list. Both later steps use them.
+
+```js
+const pubkey = await window.nostr.getPublicKey();
+
+// Keep in sync with DEFAULT_RELAYS in src/lib/ndk.ts
+const relays = [
+ 'wss://relay.damus.io',
+ 'wss://relay.snort.social',
+ 'wss://relay.nos.social',
+ 'wss://nos.lol',
+ 'wss://nostr.bitcoiner.social',
+ 'wss://relay.primal.net',
+ 'wss://nostr.land'
+];
+```
+
+Now list the bounties you have published, and copy the address of the one you want gone. A bounty usually reaches only some of the relays, so ask all of them and collapse the answers by address. Cards link to `/bounties/
`, so you can also read one out of the URL bar instead.
+
+```js
+const mine = new Map();
+
+for (const url of relays) {
+ const ws = new WebSocket(url);
+ ws.onopen = () =>
+ ws.send(
+ JSON.stringify([
+ 'REQ',
+ 'mine',
+ { kinds: [30050], authors: [pubkey], limit: 50 }
+ ])
+ );
+ ws.onmessage = ({ data }) => {
+ const [type, , event] = JSON.parse(data);
+ if (type !== 'EVENT') return ws.close(); // EOSE, CLOSED, NOTICE
+ const tag = (name) => event.tags.find((t) => t[0] === name)?.[1];
+ mine.set(`30050:${event.pubkey}:${tag('d')}`, tag('title'));
+ };
+ setTimeout(() => ws.close(), 5000);
+}
+
+// give it a few seconds, then
+console.table([...mine]);
+```
+
+Then sign a kind 5 request naming that address, and send it to every relay:
+
+```js
+const address = '30050::'; // from the table above
+
+const request = await window.nostr.signEvent({
+ kind: 5,
+ pubkey,
+ created_at: Math.floor(Date.now() / 1000),
+ content: 'retracted',
+ tags: [
+ ['a', address],
+ ['k', '30050']
+ ]
+});
+
+for (const url of relays) {
+ const ws = new WebSocket(url);
+ ws.onopen = () => ws.send(JSON.stringify(['EVENT', request]));
+ ws.onmessage = ({ data }) => {
+ console.log(url, data);
+ ws.close();
+ };
+ setTimeout(() => ws.close(), 5000);
+}
+```
+
+Both tags matter. `a` names the bounty by address rather than by event id, so the request covers every revision of it. `k` is what the listing subscribes to, so a request without `["k", "30050"]` reaches the relays but stays invisible to SatCode.
+
+Each relay answers `["OK", , true, ""]` on success, or `false` with a reason. Relays that require [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md) authentication will reject this, because the snippet does not authenticate and the app does, so a bounty published through SatCode may sit on relays this cannot reach.
+
+Deletion is a request, not a command, and a relay may keep serving the event regardless. SatCode filters retracted bounties out client side, and only honours a request signed by the bounty's own author, so the card leaves the listing either way.
+
## License
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version.
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}