From 54a1b92e313046399a5f8f0cd08843828afd6329 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Fri, 3 Apr 2026 14:22:08 -0400 Subject: [PATCH 1/6] feat: replace Blossom uploads with Arweave DVM via TOON Protocol Replace Blossom HTTP file upload servers with Arweave permanent storage via TOON Protocol's NIP-90 DVM system (kind 5094). File uploads now discover DVM providers via kind 10035 service discovery, select the cheapest provider, and publish blob storage requests with prepaid ILP micropayments. The Arweave transaction ID is returned in the ILP FULFILL data and used to construct gateway URLs (ArDrive primary, Arweave fallback). Key changes: - Add ArweaveDvmUploader with provider discovery, cost calculation, and upload via ToonClient.publishEvent() with destination routing - Update media URL detection to support ?ext= query params for extension-less Arweave gateway URLs - Remove Blossom upload config, settings UI, and sync (keep display fallback for existing Blossom-hosted content) - Fix viem parseAbi for wallet channel detail/deposit/close/settle pages Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 5 - src/components/AppProvider.tsx | 16 +- src/components/BlossomSettings.tsx | 278 -------------- src/components/ComposeBox.tsx | 12 +- src/components/NostrSync.tsx | 49 --- .../wallet/ChannelDepositDialog.tsx | 10 +- src/components/wallet/CloseChannelDialog.tsx | 6 +- src/contexts/AppContext.ts | 19 - src/hooks/useArweaveDvm.ts | 40 +++ src/hooks/useBlossomFallback.ts | 27 +- src/hooks/useChannelDetails.ts | 6 +- src/hooks/useInitialSync.ts | 31 +- src/hooks/useToon.ts | 4 +- src/hooks/useUploadFile.ts | 118 ++---- src/lib/ArweaveDvmUploader.ts | 339 ++++++++++++++++++ src/lib/appBlossom.ts | 81 ----- src/lib/helpContent.ts | 12 +- src/lib/mediaUrls.ts | 48 ++- src/lib/schemas.ts | 7 - src/pages/ChannelDetailPage.tsx | 6 +- src/pages/NetworkSettingsPage.tsx | 14 +- src/test/TestApp.tsx | 5 - 22 files changed, 474 insertions(+), 659 deletions(-) delete mode 100644 src/components/BlossomSettings.tsx create mode 100644 src/hooks/useArweaveDvm.ts create mode 100644 src/lib/ArweaveDvmUploader.ts delete mode 100644 src/lib/appBlossom.ts diff --git a/src/App.tsx b/src/App.tsx index 2781d3d8..28c49b8b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -132,11 +132,6 @@ const hardcodedConfig: AppConfig = { ], nip85StatsPubkey: "5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea", - blossomServerMetadata: { - servers: [], - updatedAt: 0, - }, - useAppBlossomServers: true, faviconUrl: "https://ditto.pub/api/favicon/{hostname}", linkPreviewUrl: "https://ditto.pub/api/link-preview/{url}", corsProxy: "https://proxy.shakespeare.diy/?url={href}", diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 8ee84469..de0324f9 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -5,7 +5,7 @@ import { builtinThemes, themePresets, buildThemeCssFromCore, resolveTheme, resol import { AppConfigSchema } from '@/lib/schemas'; import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader'; import { hslToRgb, parseHsl, rgbToHex } from '@/lib/colorUtils'; -import { z } from 'zod'; + interface AppProviderProps { children: ReactNode; @@ -54,20 +54,6 @@ export function AppProvider(props: AppProviderProps) { result.customTheme = { colors: themePresets[legacyTheme].colors }; } - // Migrate legacy blossomServers (string[]) to blossomServerMetadata - if (!result.blossomServerMetadata) { - const legacyServers = parsed.blossomServers; - if (Array.isArray(legacyServers)) { - const parsed2 = z.array(z.string().url()).safeParse(legacyServers); - if (parsed2.success && parsed2.data.length > 0) { - result.blossomServerMetadata = { - servers: parsed2.data, - updatedAt: 0, - }; - } - } - } - return result; } } diff --git a/src/components/BlossomSettings.tsx b/src/components/BlossomSettings.tsx deleted file mode 100644 index f9fe30b6..00000000 --- a/src/components/BlossomSettings.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Upload, X, Plus } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; -import { useAppContext } from '@/hooks/useAppContext'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; -import { useToast } from '@/hooks/useToast'; -import { APP_BLOSSOM_SERVERS } from '@/lib/appBlossom'; -import { cn } from '@/lib/utils'; - -export function BlossomSettings() { - const { config, updateConfig } = useAppContext(); - const { user } = useCurrentUser(); - const { mutate: publishEvent } = useNostrPublish(); - const { toast } = useToast(); - - const [servers, setServers] = useState(config.blossomServerMetadata.servers); - const [newServerUrl, setNewServerUrl] = useState(''); - - // Sync local state with config when it changes (e.g., from NostrSync) - useEffect(() => { - setServers(config.blossomServerMetadata.servers); - }, [config.blossomServerMetadata.servers]); - - const normalizeServerUrl = (url: string): string => { - url = url.trim(); - try { - return new URL(url).toString(); - } catch { - try { - return new URL(`https://${url}`).toString(); - } catch { - return url; - } - } - }; - - const isValidServerUrl = (url: string): boolean => { - const trimmed = url.trim(); - if (!trimmed) return false; - const normalized = normalizeServerUrl(trimmed); - try { - const parsed = new URL(normalized); - return parsed.protocol === 'https:' || parsed.protocol === 'http:'; - } catch { - return false; - } - }; - - const handleToggleAppServers = (enabled: boolean) => { - updateConfig((current) => ({ - ...current, - useAppBlossomServers: enabled, - })); - toast({ - title: enabled ? 'App Blossom servers enabled' : 'App Blossom servers disabled', - description: enabled - ? 'App Blossom servers will be used alongside your personal servers.' - : 'Only your personal Blossom servers will be used.', - }); - }; - - const handleAddServer = () => { - if (!isValidServerUrl(newServerUrl)) { - toast({ - title: 'Invalid server URL', - description: 'Please enter a valid HTTPS URL (e.g., https://blossom.example.com/)', - variant: 'destructive', - }); - return; - } - - const normalized = normalizeServerUrl(newServerUrl); - - if (servers.some((s) => s === normalized)) { - toast({ - title: 'Server already added', - variant: 'destructive', - }); - return; - } - - const newServers = [...servers, normalized]; - setServers(newServers); - setNewServerUrl(''); - saveServers(newServers); - }; - - const handleRemoveServer = (url: string) => { - const newServers = servers.filter((s) => s !== url); - setServers(newServers); - saveServers(newServers); - }; - - const saveServers = (newServers: string[]) => { - const now = Math.floor(Date.now() / 1000); - - updateConfig((current) => ({ - ...current, - blossomServerMetadata: { - servers: newServers, - updatedAt: now, - }, - })); - - // Publish kind 10063 to Nostr if user is logged in - if (user) { - publishKind10063(newServers); - } - }; - - const publishKind10063 = (serverList: string[]) => { - const tags = serverList.map((url) => ['server', url]); - - publishEvent( - { - kind: 10063, - content: '', - tags, - }, - { - onSuccess: () => { - toast({ - title: 'Blossom server list published', - description: 'Your Blossom server list has been published to Nostr.', - }); - }, - onError: (error) => { - console.error('Failed to publish Blossom server list:', error); - toast({ - title: 'Failed to publish Blossom server list', - description: 'There was an error publishing your server list to Nostr.', - variant: 'destructive', - }); - }, - }, - ); - }; - - const renderServerUrl = (url: string): string => { - try { - const parsed = new URL(url); - return parsed.host + (parsed.pathname === '/' ? '' : parsed.pathname); - } catch { - return url; - } - }; - - return ( -
- {/* App Blossom Servers Section */} -
-
-
-

App Blossom Servers

-
- - -
-
-

- Default file upload servers for reliable media hosting. Used alongside your personal servers when enabled. -

-
- -
- {APP_BLOSSOM_SERVERS.servers.map((server) => ( -
- - - {renderServerUrl(server)} - -
- ))} -
-
- - {/* User Blossom Servers Section */} -
-
-

Your Blossom Servers

-

- Your personal Blossom server list (BUD-03). Synced to Nostr as kind 10063 when logged in. -

-
- - {/* Server List */} -
- {servers.length === 0 ? ( -
- No personal Blossom servers configured. Add a server below or enable App Blossom Servers above. -
- ) : ( -
- {servers.map((server) => ( -
- - - {renderServerUrl(server)} - - -
- ))} -
- )} -
- - {/* Add Server Form */} -
-
-
- - setNewServerUrl(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleAddServer(); - }} - placeholder="https://blossom.example.com/" - className="h-9 text-base md:text-sm font-mono" - /> -
- -
- - {!user && ( -

- Log in to sync your Blossom server list with Nostr -

- )} -
-
-
- ); -} diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index ef43d48a..f10e67d0 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -33,7 +33,7 @@ import { useToast } from '@/hooks/useToast'; import { useAppContext } from '@/hooks/useAppContext'; import type { EventStats } from '@/hooks/useTrending'; import { cn } from '@/lib/utils'; -import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, mimeFromExt } from '@/lib/mediaUrls'; +import { extractVideoUrls, extractAudioUrls, IMAGE_URL_REGEX, IMETA_MEDIA_URL_REGEX, matchedExt, mimeFromExt } from '@/lib/mediaUrls'; /** Lazy-loaded EmojiPicker — keeps emoji-mart + its data out of the main bundle. */ const LazyEmojiPicker = lazy(() => import('@/components/EmojiPicker').then(m => ({ default: m.EmojiPicker }))); @@ -351,7 +351,7 @@ export function ComposeBox({ // Detect inline images for preview mode const hasPreviewImages = useMemo(() => { if (!content) return false; - return /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|avif)(\?[^\s]*)?/i.test(content); + return IMAGE_URL_REGEX.test(content); }, [content]); // Detect nostr:npub/nprofile mentions in content @@ -433,7 +433,7 @@ export function ComposeBox({ const url = m[0]; if (processedUrls.has(url)) continue; processedUrls.add(url); - const ext = m[1].toLowerCase(); + const ext = matchedExt(m); const isWebxdc = ext === 'xdc'; const fileTags = uploadedFileGroups.get(url); if (fileTags) { @@ -560,8 +560,8 @@ export function ComposeBox({ } expand(); - } catch { - toast({ title: 'Upload failed', description: 'Could not upload file.', variant: 'destructive' }); + } catch (err) { + toast({ title: 'Upload failed', description: err instanceof Error ? err.message : 'Could not upload file.', variant: 'destructive' }); } }, [uploadFile, expand, toast, imageQuality]); @@ -813,7 +813,7 @@ export function ComposeBox({ if (processedUrls.has(url)) continue; processedUrls.add(url); - const ext = match[1].toLowerCase(); + const ext = matchedExt(match); const isWebxdc = ext === 'xdc'; // Build imeta from grouped upload tags if available, otherwise infer diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index d5ae7d42..f55b9f75 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -6,7 +6,6 @@ import { useAppContext } from "@/hooks/useAppContext"; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useEncryptedSettings } from "@/hooks/useEncryptedSettings"; import { isSyncDone } from "@/hooks/useInitialSync"; -import { parseBlossomServerList } from "@/lib/appBlossom"; import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent"; import type { ThemeConfig } from "@/themes"; import { themePresets } from "@/themes"; @@ -17,7 +16,6 @@ import { themePresets } from "@/themes"; * This component runs globally to sync various Nostr data when the user logs in. * Currently syncs: * - NIP-65 relay list (kind 10002) - * - BUD-03 Blossom server list (kind 10063) * - Encrypted app settings (kind 30078) - theme, feed settings, relay toggle * - Active profile theme (kind 16767) - when autoShareTheme is enabled */ @@ -152,53 +150,6 @@ export function NostrSync() { } }, [relayListEvent, config.relayMetadata.updatedAt, updateConfig]); - // Fetch the user's BUD-03 Blossom server list (kind 10063). - // useInitialSync seeds ['blossomServerList', pubkey] into the cache on first login. - const { data: blossomServerListEvent } = useQuery({ - queryKey: ["blossomServerList", user?.pubkey ?? ""], - queryFn: async ({ signal }) => { - if (!user) return null; - const events = await nostr.query( - [{ kinds: [10063], authors: [user.pubkey], limit: 1 }], - { signal }, - ); - return events[0] ?? null; - }, - enabled: !!user, - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - retry: 1, - }); - - useEffect(() => { - if (!blossomServerListEvent) return; - - // Only update if the event is newer than our stored data - if ( - blossomServerListEvent.created_at > config.blossomServerMetadata.updatedAt - ) { - const fetchedServers = parseBlossomServerList(blossomServerListEvent); - - if (fetchedServers.length > 0) { - console.log( - "Syncing Blossom server list from Nostr (kind 10063):", - fetchedServers, - ); - updateConfig((current) => ({ - ...current, - blossomServerMetadata: { - servers: fetchedServers, - updatedAt: blossomServerListEvent.created_at, - }, - })); - } - } - }, [ - blossomServerListEvent, - config.blossomServerMetadata.updatedAt, - updateConfig, - ]); - // Sync encrypted settings from Nostr on login useEffect(() => { if (!user || settingsLoading) return; diff --git a/src/components/wallet/ChannelDepositDialog.tsx b/src/components/wallet/ChannelDepositDialog.tsx index a5caa60a..9b743bdb 100644 --- a/src/components/wallet/ChannelDepositDialog.tsx +++ b/src/components/wallet/ChannelDepositDialog.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { X, Loader2 } from 'lucide-react'; import { useNostrLogin } from '@nostrify/react/login'; import { nip19 } from 'nostr-tools'; -import { createWalletClient, createPublicClient, http, parseEther, defineChain } from 'viem'; +import { createWalletClient, createPublicClient, http, parseEther, defineChain, parseAbi } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { Dialog, @@ -15,14 +15,14 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useToast } from '@/hooks/useToast'; -const ERC20_ABI = [ +const ERC20_ABI = parseAbi([ 'function approve(address spender, uint256 amount) returns (bool)', -] as const; +]); -const TOKEN_NETWORK_ABI = [ +const TOKEN_NETWORK_ABI = parseAbi([ 'function participants(bytes32, address) view returns (uint256 deposit, uint256 nonce, uint256 transferredAmount)', 'function setTotalDeposit(bytes32 channelId, address participant, uint256 totalDeposit)', -] as const; +]); interface ChannelDepositDialogProps { open: boolean; diff --git a/src/components/wallet/CloseChannelDialog.tsx b/src/components/wallet/CloseChannelDialog.tsx index 4b591ad0..5152cb6e 100644 --- a/src/components/wallet/CloseChannelDialog.tsx +++ b/src/components/wallet/CloseChannelDialog.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Loader2 } from 'lucide-react'; import { useNostrLogin } from '@nostrify/react/login'; import { nip19 } from 'nostr-tools'; -import { createWalletClient, createPublicClient, http, defineChain } from 'viem'; +import { createWalletClient, createPublicClient, http, defineChain, parseAbi } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { AlertDialog, @@ -16,9 +16,9 @@ import { } from '@/components/ui/alert-dialog'; import { useToast } from '@/hooks/useToast'; -const TOKEN_NETWORK_ABI = [ +const TOKEN_NETWORK_ABI = parseAbi([ 'function closeChannel(bytes32 channelId)', -] as const; +]); interface CloseChannelDialogProps { open: boolean; diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index c5ac97d2..da876b06 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -26,14 +26,6 @@ export interface RelayMetadata { updatedAt: number; } -/** Blossom server list metadata, mirroring RelayMetadata for parity with relay management. */ -export interface BlossomServerMetadata { - /** Ordered list of Blossom server URLs (most trusted/reliable first per BUD-03). */ - servers: string[]; - /** Unix timestamp of when the server list was last updated (from kind 10063 created_at). */ - updatedAt: number; -} - /** Which "Other Stuff" content types to show in the sidebar nav and include in feeds. */ export interface FeedSettings { /** Include text posts (kind 1) in the feed */ @@ -206,17 +198,6 @@ export interface AppConfig { sidebarOrder: string[]; /** NIP-85 stats pubkey source (hex format) */ nip85StatsPubkey: string; - /** - * Blossom file upload server metadata (BUD-03). - * `servers` is the user's personal list, synced from/to kind 10063. - * App default servers are managed separately via APP_BLOSSOM_SERVERS. - */ - blossomServerMetadata: BlossomServerMetadata; - /** - * Whether to use app default Blossom servers in addition to the user's kind 10063 servers. - * Mirrors `useAppRelays` semantics for Blossom. - */ - useAppBlossomServers: boolean; /** Favicon URI template. Supports RFC 6570 variables: {href}, {origin}, {hostname}, etc. */ faviconUrl: string; /** Link preview URI template. Supports RFC 6570 variables: {url}, {href}, {origin}, {hostname}, etc. Returns OEmbed JSON. */ diff --git a/src/hooks/useArweaveDvm.ts b/src/hooks/useArweaveDvm.ts new file mode 100644 index 00000000..cac3ce5b --- /dev/null +++ b/src/hooks/useArweaveDvm.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; + +import { useToon } from '@/contexts/ToonContext'; +import { useAppContext } from './useAppContext'; +import { + discoverDvmProviders, + calculateUploadCost, + formatUsdcMicroUnits, + type DvmProvider, +} from '@/lib/ArweaveDvmUploader'; + +/** + * Hook for interacting with Arweave DVM providers on the TOON network. + * + * Discovers DVM providers via kind:10035 on the relay (fresh query per call), + * selects the cheapest provider supporting kind:5094, and exposes cost + * calculation utilities. + */ +export function useArweaveDvm() { + const { config } = useAppContext(); + const { isConnected } = useToon(); + + /** Discover DVM providers from the relay, returning the cheapest first. */ + const discoverProviders = useCallback(async (): Promise => { + if (!config.toonEnabled || !isConnected) return []; + return discoverDvmProviders(config.toonNostrRelayUrl); + }, [config.toonEnabled, config.toonNostrRelayUrl, isConnected]); + + /** Estimate upload cost for a file size using a specific provider's pricing. */ + const estimateCost = useCallback((fileSize: number, provider: DvmProvider): bigint => { + return calculateUploadCost(fileSize, provider.blobPricePerByte); + }, []); + + return { + discoverProviders, + estimateCost, + formatCost: formatUsdcMicroUnits, + isAvailable: config.toonEnabled && isConnected, + }; +} diff --git a/src/hooks/useBlossomFallback.ts b/src/hooks/useBlossomFallback.ts index 1caf3cdb..a06ca8d0 100644 --- a/src/hooks/useBlossomFallback.ts +++ b/src/hooks/useBlossomFallback.ts @@ -1,31 +1,29 @@ import { useCallback, useMemo, useRef, useState } from 'react'; -import { useAppContext } from './useAppContext'; -import { getEffectiveBlossomServers } from '@/lib/appBlossom'; - /** SHA-256 hash pattern (64 hex characters) used in Blossom content-addressed URLs. */ const BLOSSOM_PATH_REGEX = /^\/([a-f0-9]{64})\b/; +/** Legacy Blossom servers used as fallbacks for displaying existing Blossom-hosted content. */ +const LEGACY_BLOSSOM_SERVERS = [ + 'https://blossom.ditto.pub/', + 'https://blossom.dreamith.to/', + 'https://blossom.primal.net/', +]; + /** - * Given a media URL, provides fallback URLs from other configured Blossom servers. + * Given a media URL, provides fallback URLs from known Blossom servers. * * If the URL points to a Blossom server (path matches `/...`), and the - * primary URL fails to load, calling `onError()` swaps to the next configured + * primary URL fails to load, calling `onError()` swaps to the next known * Blossom server that serves the same content-addressed blob. * * Returns `{ src, onError }` — wire these onto `` or `