fix: rewrite useCVMClient to query relays directly instead of NIP-44 round-trip

The encrypted CVM request/response cycle (kind 21000/21001) was too unreliable
in the browser — timeouts on every call. Replace with direct Nostr relay queries
using nostr.group() which is already proven to work in the app. Same filtering
logic as the server. No dependency on CVM server being up for data queries.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-17 12:32:59 +07:00
parent c672066c2c
commit 3a4e4ead05

View file

@ -1,47 +1,23 @@
/** /**
* useCVMClient * useCVMClient
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
* Browser-side client for the SkateSpots CVM server running on LNVPS. * Queries Nostr relays directly from the browser — same logic as the CVM
* server but without the NIP-44 encrypted round-trip (which was unreliable).
* *
* Calls the MCP tools exposed by the server via a direct Nostr-encrypted * This gives instant results with no dependency on the CVM server being up.
* request/response cycle (NIP-44 gift wrap style), without bundling the
* full @contextvm/sdk (which has Node.js dependencies incompatible with
* browser bundling).
*
* Protocol (simplified MCP over Nostr):
* 1. Generate ephemeral Nostr keypair for this session
* 2. Encrypt the MCP JSON-RPC request with NIP-44 to the server pubkey
* 3. Publish as kind 21000 (ContextVM request event) to relays
* 4. Subscribe for a response event tagged with our ephemeral pubkey
* 5. Decrypt the response and parse the MCP result
*
* Server pubkey: e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530
*/ */
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import { useNostr } from '@nostrify/react';
generateSecretKey,
getPublicKey,
finalizeEvent,
Relay,
} from 'nostr-tools';
import { nip44 } from 'nostr-tools';
// ─── CVM Server Config ─────────────────────────────────────────────────────── // ─── Relay list ───────────────────────────────────────────────────────────────
export const CVM_SERVER_PUBKEY = const DATA_RELAYS = [
'e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530';
const CVM_RELAYS = [
'wss://goskatespots.nostr1.com', 'wss://goskatespots.nostr1.com',
'wss://relay.ditto.pub', 'wss://relay.ditto.pub',
'wss://relay.damus.io', 'wss://relay.damus.io',
]; ];
// ContextVM uses kind 21000 for client→server requests (ephemeral)
const CVM_REQUEST_KIND = 21000;
const CVM_RESPONSE_KIND = 21001;
// ─── Tool result types ─────────────────────────────────────────────────────── // ─── Tool result types ───────────────────────────────────────────────────────
export interface SpotResult { export interface SpotResult {
@ -49,6 +25,7 @@ export interface SpotResult {
description: string; description: string;
city?: string; city?: string;
country?: string; country?: string;
location?: string;
difficulty?: string; difficulty?: string;
features: string[]; features: string[];
geohash?: string; geohash?: string;
@ -108,118 +85,41 @@ export interface CommunityStats {
queried_at: string; queried_at: string;
} }
// ─── Core: send a CVM tool call via Nostr ──────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
async function callCVMTool( function getTag(event: { tags: string[][] }, name: string): string | undefined {
toolName: string, return event.tags.find((t) => t[0] === name)?.[1];
toolArgs: Record<string, unknown>,
timeoutMs = 15000
): Promise<unknown> {
// Generate ephemeral keypair for this request
const sk = generateSecretKey();
const pk = getPublicKey(sk);
// Build the MCP JSON-RPC request
const requestId = Math.floor(Math.random() * 1000000);
const mcpRequest = {
jsonrpc: '2.0',
id: requestId,
method: 'tools/call',
params: {
name: toolName,
arguments: toolArgs,
},
};
// Encrypt request to server using NIP-44
const conversationKey = nip44.v2.utils.getConversationKey(sk, CVM_SERVER_PUBKEY);
const encryptedContent = nip44.v2.encrypt(
JSON.stringify(mcpRequest),
conversationKey
);
// Build and sign the request event
const signedEvent = finalizeEvent({
kind: CVM_REQUEST_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', CVM_SERVER_PUBKEY]],
content: encryptedContent,
}, sk);
// Use raw Relay connections (nostr-tools v2 compatible)
// Try each relay in order, race for the first response
return new Promise((resolve, reject) => {
const relays: InstanceType<typeof Relay>[] = [];
let settled = false;
const cleanup = () => {
relays.forEach(r => { try { r.close(); } catch { /* ignore */ } });
};
const timer = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
reject(new Error(`CVM tool call timed out after ${timeoutMs}ms`));
}
}, timeoutMs);
const since = Math.floor(Date.now() / 1000) - 5;
CVM_RELAYS.forEach(async (url) => {
try {
const relay = await Relay.connect(url);
relays.push(relay);
// Subscribe for response BEFORE publishing
const sub = relay.subscribe(
[{ kinds: [CVM_RESPONSE_KIND], '#p': [pk], since }],
{
onevent(responseEvent) {
if (settled) return;
try {
const convKey = nip44.v2.utils.getConversationKey(sk, responseEvent.pubkey);
const decrypted = nip44.v2.decrypt(responseEvent.content, convKey);
const mcpResponse = JSON.parse(decrypted);
if (mcpResponse.id !== requestId) return;
settled = true;
clearTimeout(timer);
sub.close();
cleanup();
if (mcpResponse.error) {
reject(new Error(mcpResponse.error.message ?? 'CVM error'));
} else {
resolve(mcpResponse.result);
}
} catch {
// Not for us — ignore
}
},
}
);
// Now publish the request
await relay.publish(signedEvent);
} catch {
// This relay failed — others may still succeed
}
});
});
} }
// Parse JSON from MCP tool result content array function getTags(event: { tags: string[][] }, name: string): string[] {
function parseToolResult<T>(result: unknown): T { return event.tags
const r = result as { content?: Array<{ type: string; text?: string }> }; .filter((t) => t[0] === name)
const text = r?.content?.[0]?.text; .map((t) => t[1])
if (!text) throw new Error('Empty tool result'); .filter(Boolean);
return JSON.parse(text) as T; }
function deduplicateAddressable(events: { pubkey: string; kind: number; tags: string[][]; created_at: number }[]) {
const seen = new Map<string, typeof events[0]>();
for (const e of events) {
const d = getTag(e, 'd') ?? '';
const key = `${e.pubkey}:${e.kind}:${d}`;
const existing = seen.get(key);
if (!existing || e.created_at > existing.created_at) {
seen.set(key, e);
}
}
return Array.from(seen.values());
}
function buildNaddr(event: { kind: number; pubkey: string; tags: string[][] }): string {
return `${event.kind}:${event.pubkey}:${getTag(event, 'd') ?? ''}`;
} }
// ─── The hook ──────────────────────────────────────────────────────────────── // ─── The hook ────────────────────────────────────────────────────────────────
export function useCVMClient() { export function useCVMClient() {
const { nostr } = useNostr();
const findSpots = useCallback( const findSpots = useCallback(
async (params: { async (params: {
city?: string; city?: string;
@ -229,10 +129,100 @@ export function useCVMClient() {
search?: string; search?: string;
limit?: number; limit?: number;
}): Promise<{ count: number; spots: SpotResult[] }> => { }): Promise<{ count: number; spots: SpotResult[] }> => {
const result = await callCVMTool('find_spots', params as Record<string, unknown>); const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
return parseToolResult(result); const relayGroup = nostr.group(DATA_RELAYS);
const raw = await relayGroup.query(
[{ kinds: [30818], limit: 500 }],
{ signal }
);
let spots = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
// Only valid skate spots
spots = spots.filter((e) =>
e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot')
);
if (params.difficulty && params.difficulty !== 'all') {
spots = spots.filter(
(e) => getTag(e, 'difficulty')?.toLowerCase() === params.difficulty
);
}
if (params.city) {
const q = params.city.toLowerCase();
spots = spots.filter(
(e) =>
(getTag(e, 'city') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'location') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q)
);
}
if (params.country) {
const q = params.country.toLowerCase();
spots = spots.filter(
(e) =>
(getTag(e, 'country') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'location') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q)
);
}
if (params.features && params.features.length > 0) {
spots = spots.filter((e) => {
const spotFeatures = [
...getTags(e, 'feature'),
...getTags(e, 't'),
].map((f) => f.toLowerCase());
const content = (e as unknown as { content: string }).content.toLowerCase();
return params.features!.some(
(f) =>
spotFeatures.some((sf) => sf.includes(f.toLowerCase())) ||
content.includes(f.toLowerCase())
);
});
}
if (params.search) {
const q = params.search.toLowerCase();
spots = spots.filter(
(e) =>
(getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'name') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q)
);
}
spots.sort((a, b) => b.created_at - a.created_at);
spots = spots.slice(0, params.limit ?? 10);
const result: SpotResult[] = spots.map((e) => {
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
const imeta = ev.tags.find((t) => t[0] === 'imeta');
const imetaUrl = imeta?.find((p) => p.startsWith('url '))?.replace('url ', '');
return {
name: getTag(ev, 'title') ?? getTag(ev, 'name') ?? 'Unnamed Spot',
description: ev.content.slice(0, 300),
city: getTag(ev, 'city'),
country: getTag(ev, 'country'),
location: getTag(ev, 'location'),
difficulty: getTag(ev, 'difficulty'),
features: getTags(ev, 't').filter((t) => t !== 'skatespot'),
geohash: getTag(ev, 'g'),
image: getTag(ev, 'image') ?? imetaUrl,
author_pubkey: ev.pubkey,
naddr: buildNaddr(ev),
url: `https://skate-spots.org/spot/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`,
created_at: new Date(ev.created_at * 1000).toISOString(),
};
});
return { count: result.length, spots: result };
}, },
[] [nostr]
); );
const findEvents = useCallback( const findEvents = useCallback(
@ -244,10 +234,73 @@ export function useCVMClient() {
to_date?: string; to_date?: string;
limit?: number; limit?: number;
}): Promise<{ count: number; events: EventResult[] }> => { }): Promise<{ count: number; events: EventResult[] }> => {
const result = await callCVMTool('find_events', params as Record<string, unknown>); const now = Math.floor(Date.now() / 1000);
return parseToolResult(result); const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
const relayGroup = nostr.group(DATA_RELAYS);
const raw = await relayGroup.query(
[{ kinds: [31923], limit: 200, since: now }],
{ signal }
);
let events = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
if (params.city) {
const q = params.city.toLowerCase();
events = events.filter(
(e) =>
(getTag(e, 'location') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'city') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q)
);
}
if (params.event_type) {
const q = params.event_type.toLowerCase();
events = events.filter(
(e) =>
getTags(e, 't').some((t) => t.toLowerCase().includes(q)) ||
(getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q)
);
}
if (params.to_date) {
const toTs = Math.floor(new Date(params.to_date).getTime() / 1000);
events = events.filter((e) => {
const start = parseInt(getTag(e, 'start') ?? '0');
return !start || start <= toTs;
});
}
events.sort((a, b) => {
const startA = parseInt(getTag(a, 'start') ?? '0');
const startB = parseInt(getTag(b, 'start') ?? '0');
return startA - startB;
});
events = events.slice(0, params.limit ?? 10);
const result: EventResult[] = events.map((e) => {
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
const startTag = getTag(ev, 'start');
const endTag = getTag(ev, 'end');
return {
title: getTag(ev, 'title') ?? 'Untitled Event',
description: ev.content.slice(0, 300),
location: getTag(ev, 'location'),
start: startTag ? new Date(parseInt(startTag) * 1000).toISOString() : undefined,
end: endTag ? new Date(parseInt(endTag) * 1000).toISOString() : undefined,
type: getTags(ev, 't'),
image: getTag(ev, 'image'),
author_pubkey: ev.pubkey,
naddr: buildNaddr(ev),
url: `https://skate-spots.org/event/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`,
};
});
return { count: result.length, events: result };
}, },
[] [nostr]
); );
const findCoaches = useCallback( const findCoaches = useCallback(
@ -257,10 +310,64 @@ export function useCVMClient() {
location?: string; location?: string;
limit?: number; limit?: number;
}): Promise<{ count: number; profiles: CoachResult[] }> => { }): Promise<{ count: number; profiles: CoachResult[] }> => {
const result = await callCVMTool('find_coaches', params as Record<string, unknown>); const kindMap: Record<string, number[]> = {
return parseToolResult(result); coach: [35001],
filmer: [35002],
editor: [35003],
any: [35001, 35002, 35003],
};
const kinds = kindMap[params.role ?? 'any'];
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
const relayGroup = nostr.group(DATA_RELAYS);
const raw = await relayGroup.query([{ kinds, limit: 200 }], { signal });
let profiles = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
if (params.skills && params.skills.length > 0) {
profiles = profiles.filter((e) => {
const profileSkills = getTags(e, 'skill').map((s) => s.toLowerCase());
const bio = (e as unknown as { content: string }).content.toLowerCase();
return params.skills!.some(
(s) =>
profileSkills.some((ps) => ps.includes(s.toLowerCase())) ||
bio.includes(s.toLowerCase())
);
});
}
if (params.location) {
const q = params.location.toLowerCase();
profiles = profiles.filter(
(e) =>
(getTag(e, 'location') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'city') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q)
);
}
profiles = profiles.slice(0, params.limit ?? 10);
const roleLabel = (kind: number): 'coach' | 'filmer' | 'editor' =>
kind === 35001 ? 'coach' : kind === 35002 ? 'filmer' : 'editor';
const result: CoachResult[] = profiles.map((e) => {
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
return {
role: roleLabel(ev.kind),
pubkey: ev.pubkey,
bio: ev.content.slice(0, 300),
location: getTag(ev, 'location'),
skills: getTags(ev, 'skill'),
experience: getTag(ev, 'experience'),
lightning_address: getTag(ev, 'lud16') ?? getTag(ev, 'contact'),
naddr: buildNaddr(ev),
url: `https://skate-spots.org/coach/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`,
};
});
return { count: result.length, profiles: result };
}, },
[] [nostr]
); );
const findGear = useCallback( const findGear = useCallback(
@ -271,16 +378,95 @@ export function useCVMClient() {
search?: string; search?: string;
limit?: number; limit?: number;
}): Promise<{ count: number; listings: GearResult[] }> => { }): Promise<{ count: number; listings: GearResult[] }> => {
const result = await callCVMTool('find_gear', params as Record<string, unknown>); const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
return parseToolResult(result); const relayGroup = nostr.group(DATA_RELAYS);
const raw = await relayGroup.query(
[{ kinds: [30402], limit: 200, '#t': ['skateboarding'] }],
{ signal }
);
let listings = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
if (params.category) {
const q = params.category.toLowerCase();
listings = listings.filter(
(e) =>
getTags(e, 't').some((t) => t.toLowerCase().includes(q)) ||
(getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q)
);
}
if (params.condition && params.condition !== 'any') {
listings = listings.filter((e) =>
(getTag(e, 'condition') ?? '')
.toLowerCase()
.replace(/\s+/g, '_')
.includes(params.condition!)
);
}
if (params.search) {
const q = params.search.toLowerCase();
listings = listings.filter(
(e) =>
(getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q)
);
}
listings.sort((a, b) => b.created_at - a.created_at);
listings = listings.slice(0, params.limit ?? 10);
const result: GearResult[] = listings.map((e) => {
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
return {
title: getTag(ev, 'title') ?? 'Untitled Listing',
description: ev.content.slice(0, 300),
price: getTag(ev, 'price'),
currency: getTag(ev, 'currency'),
condition: getTag(ev, 'condition'),
categories: getTags(ev, 't'),
image: getTag(ev, 'image') ?? getTag(ev, 'thumb'),
seller_pubkey: ev.pubkey,
naddr: buildNaddr(ev),
created_at: new Date(ev.created_at * 1000).toISOString(),
};
});
return { count: result.length, listings: result };
}, },
[] [nostr]
); );
const getCommunityStats = useCallback(async (): Promise<CommunityStats> => { const getCommunityStats = useCallback(async (): Promise<CommunityStats> => {
const result = await callCVMTool('get_community_stats', {}); const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
return parseToolResult(result); const relayGroup = nostr.group(DATA_RELAYS);
}, []); const now = Math.floor(Date.now() / 1000);
const [spots, events, coaches, filmers, editors, gear] = await Promise.all([
relayGroup.query([{ kinds: [30818], limit: 2000 }], { signal }),
relayGroup.query([{ kinds: [31923], limit: 500, since: now }], { signal }),
relayGroup.query([{ kinds: [35001], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35002], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35003], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [30402], limit: 500, '#t': ['skateboarding'] }], { signal }),
]);
return {
spots: deduplicateAddressable(spots as typeof spots & { tags: string[][] }[]).filter(
(e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot')
).length,
upcoming_events: deduplicateAddressable(events as typeof events & { tags: string[][] }[]).length,
coaches: deduplicateAddressable(coaches as typeof coaches & { tags: string[][] }[]).length,
filmers: deduplicateAddressable(filmers as typeof filmers & { tags: string[][] }[]).length,
editors: deduplicateAddressable(editors as typeof editors & { tags: string[][] }[]).length,
gear_listings: deduplicateAddressable(gear as typeof gear & { tags: string[][] }[]).length,
relays_queried: DATA_RELAYS,
queried_at: new Date().toISOString(),
};
}, [nostr]);
return { return {
findSpots, findSpots,