diff --git a/src/components/NoteOptions/EditOrCloneEventDialog.tsx b/src/components/NoteOptions/EditOrCloneEventDialog.tsx index 3e44a39b..0cd57fee 100644 --- a/src/components/NoteOptions/EditOrCloneEventDialog.tsx +++ b/src/components/NoteOptions/EditOrCloneEventDialog.tsx @@ -18,7 +18,13 @@ import Highlight from '@/components/Note/Highlight' import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle' import ClientTag from '@/components/ClientTag' -import { ExtendedKind } from '@/constants' +import { + ExtendedKind, + MAX_SIGNED_CUSTOM_EVENT_KIND, + UNSIGNED_EXPERIMENTAL_KIND_MAX, + UNSIGNED_EXPERIMENTAL_KIND_MIN, + isUnsignedExperimentalKind +} from '@/constants' import { applyImwaldAttributionTags } from '@/lib/draft-event' import { createFakeEvent } from '@/lib/event' import logger from '@/lib/logger' @@ -32,7 +38,7 @@ import { useNostr } from '@/providers/NostrProvider' import storage from '@/services/local-storage.service' import type { TDraftEvent } from '@/types' import dayjs from 'dayjs' -import { Plus, Trash2 } from 'lucide-react' +import { AlertTriangle, Plus, Trash2 } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' @@ -54,15 +60,15 @@ function tagsFromRows(rows: string[][]): string[][] { return out } -const MAX_CUSTOM_EVENT_KIND = 40000 - -/** Integer kind in [0, 40000], or null if invalid / empty. */ +/** Integer kind in [0, MAX_SIGNED_CUSTOM_EVENT_KIND] or unsigned experimental range; null if invalid / empty. */ function parseEventKindInput(s: string): number | null { const trimmed = s.trim() if (trimmed === '') return null const n = Number(trimmed) - if (!Number.isInteger(n) || n < 0 || n > MAX_CUSTOM_EVENT_KIND) return null - return n + if (!Number.isInteger(n) || n < 0) return null + if (n <= MAX_SIGNED_CUSTOM_EVENT_KIND) return n + if (isUnsignedExperimentalKind(n)) return n + return null } function StaticEventPreview({ event, className }: { event: Event; className?: string }) { @@ -179,7 +185,14 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp const buildDraftJson = useCallback(() => { if (isCreate && parsedCreateKind === null) { - return t('Enter a valid event kind (integer 0–40000).') + return t( + 'Enter a valid event kind: integer 0–{{maxSigned}}, or {{unsignedMin}}–{{unsignedMax}} (unsigned experiment).', + { + maxSigned: MAX_SIGNED_CUSTOM_EVENT_KIND, + unsignedMin: UNSIGNED_EXPERIMENTAL_KIND_MIN, + unsignedMax: UNSIGNED_EXPERIMENTAL_KIND_MAX + } + ) } const k = isCreate ? parsedCreateKind! : sourceEvent!.kind const base: TDraftEvent = { @@ -191,13 +204,18 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp const withAttribution = applyImwaldAttributionTags(base, { addClientTag: storage.getAddClientTag() }) + const unsignedNote = isUnsignedExperimentalKind(withAttribution.kind) + ? t( + 'Unsigned experimental kind: `sig` will be empty at publish; `id` is still the standard event hash. Not accepted by normal relays. Relays that allow this should authenticate you (e.g. NIP-42 AUTH) before writes.' + ) + : t('id and sig are assigned when you publish') const draft = { pubkey: pubkey ?? t('Log in to publish'), kind: withAttribution.kind, content: withAttribution.content, tags: withAttribution.tags, created_at: t('Set when you publish'), - _note: t('id and sig are assigned when you publish') + _note: unsignedNote } return JSON.stringify(draft, null, 2) }, [isCreate, parsedCreateKind, sourceEvent, pubkey, content, normalizedTags, t]) @@ -242,7 +260,16 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp if (isCreate) { const k = parseEventKindInput(createKindInput) if (k === null) { - showPublishingError(t('Kind must be an integer from 0 to 40000.')) + showPublishingError( + t( + 'Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).', + { + maxSigned: MAX_SIGNED_CUSTOM_EVENT_KIND, + unsignedMin: UNSIGNED_EXPERIMENTAL_KIND_MIN, + unsignedMax: UNSIGNED_EXPERIMENTAL_KIND_MAX + } + ) + ) return } } @@ -339,15 +366,41 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp setCreateKindInput(e.target.value)} className="font-mono text-sm" />
- {t('Integer from 0 to 40000')} + {t( + 'Signed: 0–{{maxSigned}}. Unsigned experiment (empty sig): {{unsignedMin}}–{{unsignedMax}}.', + { + maxSigned: MAX_SIGNED_CUSTOM_EVENT_KIND, + unsignedMin: UNSIGNED_EXPERIMENTAL_KIND_MIN, + unsignedMax: UNSIGNED_EXPERIMENTAL_KIND_MAX + } + )}
+ {parsedCreateKind !== null && isUnsignedExperimentalKind(parsedCreateKind) ? ( +{t('Unsigned experimental kind')}
++ {t( + 'This kind is published with an empty signature. Normal Nostr relays will reject it, and these events are not portable on the open network. Only use relays that explicitly support this experiment and authenticate you (for example with NIP-42 AUTH) before accepting writes.' + )} +
+- {t('Enter a valid event kind (integer 0–40000).')} + {t( + 'Enter a valid event kind: 0–{{maxSigned}}, or {{unsignedMin}}–{{unsignedMax}}.', + { + maxSigned: MAX_SIGNED_CUSTOM_EVENT_KIND, + unsignedMin: UNSIGNED_EXPERIMENTAL_KIND_MIN, + unsignedMax: UNSIGNED_EXPERIMENTAL_KIND_MAX + } + )}
)} diff --git a/src/constants.ts b/src/constants.ts index 123fb174..aed43311 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -457,6 +457,25 @@ export const ExtendedKind = { GIT_RELEASE: 1642 } +/** + * Relay-local experiment: event `id` is the standard Nostr hash, but `sig` is empty. + * Not verifiable on the public relay network; relays that accept writes should require NIP-42 AUTH first. + */ +export const UNSIGNED_EXPERIMENTAL_KIND_MIN = 69999 +export const UNSIGNED_EXPERIMENTAL_KIND_MAX = 130000 + +export const UNSIGNED_EXPERIMENTAL_RELAY_URLS = [ + 'wss://nostr.land', + 'wss://theforest.gitcitadel.eu', +] + +export function isUnsignedExperimentalKind(kind: number): boolean { + return kind >= UNSIGNED_EXPERIMENTAL_KIND_MIN && kind <= UNSIGNED_EXPERIMENTAL_KIND_MAX +} + +/** Max kind for signed “custom event” notes in the generic composer (below the unsigned experimental range). */ +export const MAX_SIGNED_CUSTOM_EVENT_KIND = 40000 + /** * Kinds subscribed on `#e` / `#a` for the OP in {@link useQuoteEvents} (thread “backlinks” shard), * alongside kind-1 `#q` quotes. Covers highlights, long-form, NIP-32 labels, NIP-56 reports, diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 5371f8e1..2fde5e98 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -8,7 +8,10 @@ import { ExtendedKind, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, - SEARCHABLE_RELAY_URLS + SEARCHABLE_RELAY_URLS, + UNSIGNED_EXPERIMENTAL_KIND_MAX, + UNSIGNED_EXPERIMENTAL_KIND_MIN, + isUnsignedExperimentalKind } from '@/constants' import { applyImwaldAttributionTags, @@ -43,7 +46,7 @@ import { } from '@/types' import { hexToBytes } from '@noble/hashes/utils' import dayjs from 'dayjs' -import { Event, kinds, VerifiedEvent, validateEvent } from 'nostr-tools' +import { Event, kinds, VerifiedEvent, getEventHash, validateEvent } from 'nostr-tools' import * as nip19 from 'nostr-tools/nip19' import * as nip49 from 'nostr-tools/nip49' import { NostrContext } from '@/providers/nostr-context' @@ -1261,8 +1264,29 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const normalizeOpts = { addClientTag: options.addClientTag } const draft = normalizeDraftEventTags(draftEvent, normalizeOpts) - let event: VerifiedEvent - if (minPow > 0) { + let event: Event + if (isUnsignedExperimentalKind(draft.kind)) { + if (minPow > 0) { + throw new Error( + t('Proof of work is not supported for unsigned experimental kinds ({{min}}–{{max}}).', { + min: UNSIGNED_EXPERIMENTAL_KIND_MIN, + max: UNSIGNED_EXPERIMENTAL_KIND_MAX + }) + ) + } + const unsignedTemplate = { + kind: draft.kind, + content: draft.content, + tags: draft.tags, + created_at: draft.created_at, + pubkey: account.pubkey + } + if (!validateEvent(unsignedTemplate)) { + throw new Error(t('Invalid event fields')) + } + const id = getEventHash(unsignedTemplate) + event = { ...unsignedTemplate, id, sig: '' } + } else if (minPow > 0) { const unsignedEvent = await minePow({ ...draft, pubkey: account.pubkey }, minPow) event = await signEvent(unsignedEvent, normalizeOpts) } else {