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
* ─────────────────────────────────────────────────────────────────────────────
* 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
* 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
* This gives instant results with no dependency on the CVM server being up.
*/
import { useCallback } from 'react';
import {
generateSecretKey,
getPublicKey,
finalizeEvent,
Relay,
} from 'nostr-tools';
import { nip44 } from 'nostr-tools';
import { useNostr } from '@nostrify/react';
// ─── CVM Server Config ───────────────────────────────────────────────────────
// ─── Relay list ───────────────────────────────────────────────────────────────
export const CVM_SERVER_PUBKEY =
'e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530';
const CVM_RELAYS = [
const DATA_RELAYS = [
'wss://goskatespots.nostr1.com',
'wss://relay.ditto.pub',
'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 ───────────────────────────────────────────────────────
export interface SpotResult {
@ -49,6 +25,7 @@ export interface SpotResult {
description: string;
city?: string;
country?: string;
location?: string;
difficulty?: string;
features: string[];
geohash?: string;
@ -108,118 +85,41 @@ export interface CommunityStats {
queried_at: string;
}
// ─── Core: send a CVM tool call via Nostr ────────────────────────────────────
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function callCVMTool(
toolName: string,
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
}
});
});
function getTag(event: { tags: string[][] }, name: string): string | undefined {
return event.tags.find((t) => t[0] === name)?.[1];
}
// Parse JSON from MCP tool result content array
function parseToolResult<T>(result: unknown): T {
const r = result as { content?: Array<{ type: string; text?: string }> };
const text = r?.content?.[0]?.text;
if (!text) throw new Error('Empty tool result');
return JSON.parse(text) as T;
function getTags(event: { tags: string[][] }, name: string): string[] {
return event.tags
.filter((t) => t[0] === name)
.map((t) => t[1])
.filter(Boolean);
}
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 ────────────────────────────────────────────────────────────────
export function useCVMClient() {
const { nostr } = useNostr();
const findSpots = useCallback(
async (params: {
city?: string;
@ -229,10 +129,100 @@ export function useCVMClient() {
search?: string;
limit?: number;
}): Promise<{ count: number; spots: SpotResult[] }> => {
const result = await callCVMTool('find_spots', params as Record<string, unknown>);
return parseToolResult(result);
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
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(
@ -244,10 +234,73 @@ export function useCVMClient() {
to_date?: string;
limit?: number;
}): Promise<{ count: number; events: EventResult[] }> => {
const result = await callCVMTool('find_events', params as Record<string, unknown>);
return parseToolResult(result);
const now = Math.floor(Date.now() / 1000);
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(
@ -257,10 +310,64 @@ export function useCVMClient() {
location?: string;
limit?: number;
}): Promise<{ count: number; profiles: CoachResult[] }> => {
const result = await callCVMTool('find_coaches', params as Record<string, unknown>);
return parseToolResult(result);
const kindMap: Record<string, number[]> = {
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(
@ -271,16 +378,95 @@ export function useCVMClient() {
search?: string;
limit?: number;
}): Promise<{ count: number; listings: GearResult[] }> => {
const result = await callCVMTool('find_gear', params as Record<string, unknown>);
return parseToolResult(result);
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
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 result = await callCVMTool('get_community_stats', {});
return parseToolResult(result);
}, []);
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
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 {
findSpots,