diff --git a/src/lib/bounty.test.ts b/src/lib/bounty.test.ts new file mode 100644 index 0000000..07493e6 --- /dev/null +++ b/src/lib/bounty.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { BountyDraft } from './bounty'; + +// --------------------------------------------------------------------------- +// Mock the ndk singleton so no real WebSocket connections are made. +// --------------------------------------------------------------------------- + +vi.mock('./ndk', () => ({ + ndk: () => ({}) +})); + +// Import AFTER the mock is registered +const { buildBountyEvent } = await import('./bounty'); + +// --------------------------------------------------------------------------- + +function makeDraft(overrides: Partial = {}): BountyDraft { + return { + title: 'Fix memory leak', + description: 'Find and fix the leak.', + amountSats: 250000, + deadline: '2026-08-20', + topics: '', + ...overrides + }; +} + +function tagValue(tags: string[][], name: string): string | undefined { + return tags.find((tag) => tag[0] === name)?.[1]; +} + +describe('buildBountyEvent', () => { + it('builds a kind-30050 event with the trimmed description as content', () => { + const event = buildBountyEvent( + makeDraft({ description: ' Find and fix the leak. ' }) + ); + + expect(event.kind).toBe(30050); + expect(event.content).toBe('Find and fix the leak.'); + }); + + it('uses a fresh UUID as the d tag on every call', () => { + const first = tagValue(buildBountyEvent(makeDraft()).tags, 'd'); + const second = tagValue(buildBountyEvent(makeDraft()).tags, 'd'); + + const uuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + expect(first).toMatch(uuid); + expect(second).toMatch(uuid); + expect(first).not.toBe(second); + }); + + it('sets title (trimmed), amount_sats, s and resolution_mode tags', () => { + const { tags } = buildBountyEvent( + makeDraft({ title: ' Fix memory leak ', amountSats: 21000 }) + ); + + expect(tagValue(tags, 'title')).toBe('Fix memory leak'); + expect(tagValue(tags, 'amount_sats')).toBe('21000'); + expect(tagValue(tags, 's')).toBe('open'); + expect(tagValue(tags, 'resolution_mode')).toBe('A'); + }); + + it('sets bounty_deadline to 23:59:59 local time of the picked date', () => { + const { tags } = buildBountyEvent(makeDraft({ deadline: '2026-08-20' })); + + const endOfDay = new Date(2026, 7, 20, 23, 59, 59); + expect(tagValue(tags, 'bounty_deadline')).toBe( + String(Math.floor(endOfDay.getTime() / 1000)) + ); + }); + + it('lowercases, trims and dedupes topics into t tags', () => { + const { tags } = buildBountyEvent( + makeDraft({ topics: 'TypeScript, nostr,, NOSTR , ' }) + ); + + expect(tags.filter((tag) => tag[0] === 't')).toEqual([ + ['t', 'typescript'], + ['t', 'nostr'] + ]); + }); + + it('emits no t tags when topics is empty', () => { + const { tags } = buildBountyEvent(makeDraft({ topics: '' })); + + expect(tags.filter((tag) => tag[0] === 't')).toEqual([]); + }); +}); diff --git a/src/lib/bounty.ts b/src/lib/bounty.ts new file mode 100644 index 0000000..d652a8c --- /dev/null +++ b/src/lib/bounty.ts @@ -0,0 +1,54 @@ +import { NDKEvent } from '@nostr-dev-kit/ndk'; +import { ndk } from './ndk'; + +export const BOUNTY_KIND = 30050; + +export interface BountyDraft { + title: string; + description: string; // markdown + amountSats: number; + deadline: string; // YYYY-MM-DD, user's local time zone + topics: string; // comma-separated, may be empty +} + +export function buildBountyEvent(draft: BountyDraft): { + kind: number; + content: string; + tags: string[][]; +} { + // End of the picked day (23:59:59) in the poster's local time zone — + // new Date('YYYY-MM-DD') would parse as UTC midnight and shift the day. + const [year, month, day] = draft.deadline.split('-').map(Number); + const deadlineUnix = Math.floor( + new Date(year, month - 1, day, 23, 59, 59).getTime() / 1000 + ); + const topics = [ + ...new Set( + draft.topics + .split(',') + .map((topic) => topic.trim().toLowerCase()) + .filter(Boolean) + ) + ]; + return { + kind: BOUNTY_KIND, + content: draft.description.trim(), + tags: [ + ['d', crypto.randomUUID()], + ['title', draft.title.trim()], + ['amount_sats', String(draft.amountSats)], + ['s', 'open'], + // Mode B (oracle) requires infrastructure that doesn't exist yet. + ['resolution_mode', 'A'], + ['bounty_deadline', String(deadlineUnix)], + ...topics.map((topic) => ['t', topic]) + ] + }; +} + +export async function publishBounty(draft: BountyDraft): Promise { + const event = new NDKEvent(ndk(), buildBountyEvent(draft)); + await event.sign(); // NIP-07 extension prompt (signer set at login) + await event.publish(); // throws NDKPublishError if no relay accepts + return event; +} diff --git a/src/lib/index.ts b/src/lib/index.ts index ca30e6b..9208b19 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,3 +1,4 @@ export * from './ndk'; export * from './nostr'; export * from './auth.svelte'; +export * from './bounty'; diff --git a/src/routes/bounties/new/+page.svelte b/src/routes/bounties/new/+page.svelte index 7320b49..ae2a024 100644 --- a/src/routes/bounties/new/+page.svelte +++ b/src/routes/bounties/new/+page.svelte @@ -1,10 +1,19 @@ @@ -48,80 +83,111 @@ Post a software bounty to Nostr, paid in Bitcoin.

-
- - - - -
+ {#if published} +
+

Bounty published

+

+ “{publishedTitle}” is now live on Nostr. +

+
+ + + Back to bounties + +
+
+ {:else} + -
- +
+ -
- -

- Publishing to Nostr isn't wired up yet. -

-
- + +
+ + + +
+ + {#if !loggedIn} +

Log in to post a bounty.

+ {/if} + {#if publishError} +

{publishError}

+ {/if} +
+ + {/if}