Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/lib/bounty.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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([]);
});
});
54 changes: 54 additions & 0 deletions src/lib/bounty.ts
Original file line number Diff line number Diff line change
@@ -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<NDKEvent> {
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;
}
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './ndk';
export * from './nostr';
export * from './auth.svelte';
export * from './bounty';
198 changes: 132 additions & 66 deletions src/routes/bounties/new/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { authState } from '$lib/auth.svelte';
import { publishBounty } from '$lib/bounty';

let title = $state('');
let description = $state('');
let amountSats = $state<number | null>(null);
let deadline = $state(''); // YYYY-MM-DD from the date input
let topics = $state(''); // comma-separated, optional

let submitting = $state(false);
let published = $state(false);
let publishedTitle = $state('');
let publishError = $state<string | null>(null);

// Local "tomorrow" as YYYY-MM-DD, built from local date parts —
// toISOString() would shift the day for users west of UTC.
const now = new Date();
Expand All @@ -29,12 +38,38 @@
amountSats >= 1 &&
deadline >= minDeadline
);
const loggedIn = $derived(
authState.status === 'ready' && authState.user !== null
);

const inputClasses =
'mt-1 block w-full rounded-md border-surface-500 bg-surface-700 text-sm text-gray-100 placeholder-gray-600 focus:border-bitcoin-500 focus:ring-bitcoin-500';

function handleSubmit(e: SubmitEvent) {
e.preventDefault(); // publishing is wired up in the follow-up Nostr PR
function resetForm() {
title = '';
description = '';
amountSats = null;
deadline = '';
topics = '';
}

async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (!valid || !loggedIn || submitting || amountSats === null) return;
submitting = true;
publishError = null;
try {
await publishBounty({ title, description, amountSats, deadline, topics });
publishedTitle = title.trim();
resetForm();
published = true;
} catch (err) {
publishError =
err instanceof Error ? err.message : 'Failed to publish bounty';
// Keep the draft so a rejected signature can simply be retried.
} finally {
submitting = false;
}
}
</script>

Expand All @@ -48,80 +83,111 @@
Post a software bounty to Nostr, paid in Bitcoin.
</p>

<form class="mt-8 space-y-5" onsubmit={handleSubmit}>
<label class="block">
<span class="text-sm font-medium text-gray-300">Title</span>
<input
type="text"
bind:value={title}
maxlength="120"
required
placeholder="Fix memory leak in Rust Bitcoin parser"
class={inputClasses}
/>
</label>

<label class="block">
<span class="text-sm font-medium text-gray-300">Description</span>
<textarea
bind:value={description}
rows="6"
required
placeholder="What needs to be done, and what does success look like?"
class={inputClasses}></textarea>
<span class="mt-1 block text-xs text-gray-500">Markdown supported</span>
</label>

<div class="grid gap-5 sm:grid-cols-2">
{#if published}
<div
class="mt-8 rounded-xl border border-surface-600 bg-surface-800 p-6 text-center"
>
<h2 class="text-lg font-semibold text-green-400">Bounty published</h2>
<p class="mt-2 text-sm text-gray-300">
“{publishedTitle}” is now live on Nostr.
</p>
<div class="mt-5 flex justify-center gap-3">
<button
type="button"
onclick={() => (published = false)}
class="rounded-md bg-bitcoin-500 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-bitcoin-600"
>
Post another
</button>
<a
href={resolve('/')}
class="rounded-md border border-surface-500 bg-surface-700 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:border-surface-400 hover:text-gray-100"
>
Back to bounties
</a>
</div>
</div>
{:else}
<form class="mt-8 space-y-5" onsubmit={handleSubmit}>
<label class="block">
<span class="text-sm font-medium text-gray-300">Reward (sats)</span>
<span class="text-sm font-medium text-gray-300">Title</span>
<input
type="number"
bind:value={amountSats}
min="1"
step="1"
type="text"
bind:value={title}
maxlength="120"
required
placeholder="250000"
placeholder="Short title for the bounty"
class={inputClasses}
/>
</label>

<label class="block">
<span class="text-sm font-medium text-gray-300">Deadline</span>
<input
type="date"
bind:value={deadline}
min={minDeadline}
<span class="text-sm font-medium text-gray-300">Description</span>
<textarea
bind:value={description}
rows="6"
required
class="{inputClasses} scheme-dark"
/>
placeholder="What needs to be done, and what are the exact bounty acceptance criteria?"
class={inputClasses}></textarea>
<span class="mt-1 block text-xs text-gray-500">Markdown supported</span>
</label>
</div>

<label class="block">
<span class="text-sm font-medium text-gray-300">Topics</span>
<input
type="text"
bind:value={topics}
placeholder="typescript, nostr"
class={inputClasses}
/>
<span class="mt-1 block text-xs text-gray-500">
Comma-separated, optional
</span>
</label>
<div class="grid gap-5 sm:grid-cols-2">
<label class="block">
<span class="text-sm font-medium text-gray-300">Reward (sats)</span>
<input
type="number"
bind:value={amountSats}
min="1"
step="1"
required
placeholder="250000"
class={inputClasses}
/>
</label>

<div>
<button
type="submit"
disabled={!valid}
class="rounded-md bg-bitcoin-500 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-bitcoin-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-bitcoin-500 disabled:cursor-not-allowed disabled:opacity-50"
>
Post bounty
</button>
<p class="mt-2 text-xs text-gray-500">
Publishing to Nostr isn't wired up yet.
</p>
</div>
</form>
<label class="block">
<span class="text-sm font-medium text-gray-300"
>Deadline (maker can claim the reward after this)</span
>
<input
type="date"
bind:value={deadline}
min={minDeadline}
required
class="{inputClasses} scheme-dark"
/>
</label>
</div>

<label class="block">
<span class="text-sm font-medium text-gray-300">Topics</span>
<input
type="text"
bind:value={topics}
placeholder="typescript, nostr"
class={inputClasses}
/>
<span class="mt-1 block text-xs text-gray-500">
Comma-separated, optional
</span>
</label>

<div>
<button
type="submit"
disabled={!valid || !loggedIn || submitting}
class="rounded-md bg-bitcoin-500 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-bitcoin-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-bitcoin-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? 'Publishing…' : 'Post bounty'}
</button>
{#if !loggedIn}
<p class="mt-2 text-xs text-gray-500">Log in to post a bounty.</p>
{/if}
{#if publishError}
<p class="mt-2 text-xs text-red-400">{publishError}</p>
{/if}
</div>
</form>
{/if}
</main>