feat: auto-convert $event spells to use #a tags for replaceable events

Handles the edge case where $event parameter spells reference replaceable
events. Instead of using event IDs in #e tags, the system now automatically
detects replaceable/parameterized replaceable events and uses address
coordinates in #a tags instead.

This ensures replaceable events are properly referenced by their coordinate
(kind:pubkey:d-tag) rather than their event ID, which aligns with Nostr best
practices since replaceable events can be superseded.

Implementation:
- Add getEventCoordinate() utility to construct coordinates from events
- Detect addressable kinds using isAddressableKind() helper
- Pass both targetEventId and targetAddress to applySpellParameters()
- When substituting $event in #e tags and targetAddress exists:
  * Remove $event from #e tags
  * Add targetAddress to #a tags instead
  * Preserve non-$event values in original tags
- Keep using event ID for filter.ids (direct ID lookups)

Changes:
- spell-conversion.ts: Add getEventCoordinate(), update applySpellParameters
- EventDetailViewer.tsx: Calculate and pass targetAddress for replaceable events
- spell-conversion.test.ts: Add comprehensive tests (98 tests passing)

Tested:
- Regular events continue using #e tags (no change)
- Replaceable events (0, 3, 10000-19999) use #a tags with kind:pubkey: format
- Parameterized replaceable (30000-39999) use #a tags with kind:pubkey:d-tag
- Mixed #e filters preserve non-$event values correctly
- Existing #a values are preserved when converting from #e
This commit is contained in:
Claude
2026-01-23 23:07:10 +00:00
parent 2f8f214f50
commit 07e7dfb1df
3 changed files with 271 additions and 7 deletions

View File

@@ -29,6 +29,7 @@ import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced";
import {
applySpellParameters,
detectCommandType,
getEventCoordinate,
} from "@/lib/spell-conversion";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { KindBadge } from "./KindBadge";
@@ -70,14 +71,20 @@ function SpellTabContent({
// Apply parameters to get final filter
const appliedFilter = useMemo(() => {
if (!parsed || !targetEventId) return null;
if (!parsed || !targetEventId || !targetEvent) return null;
try {
// For replaceable events, get the address coordinate (kind:pubkey:d-tag)
// This ensures we reference them by address instead of event ID in filters
const targetAddress = getEventCoordinate(targetEvent);
const applied = applySpellParameters(parsed, {
targetEventId,
targetAddress: targetAddress || undefined,
});
console.log(`[EventSpell:${spell.name || spellId}] Applied parameters:`, {
targetEventId,
targetAddress,
result: applied,
});
return applied;
@@ -88,7 +95,7 @@ function SpellTabContent({
);
return null;
}
}, [parsed, targetEventId, spell.name, spellId]);
}, [parsed, targetEventId, targetEvent, spell.name, spellId]);
// Resolve relays - use explicit relays from spell, or use relay hints from target event
const finalRelays = useMemo(() => {

View File

@@ -3,8 +3,10 @@ import {
encodeSpell,
decodeSpell,
applySpellParameters,
getEventCoordinate,
} from "./spell-conversion";
import type { SpellEvent } from "@/types/spell";
import type { NostrEvent } from "@/types/nostr";
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
describe("Spell Conversion", () => {
@@ -1005,6 +1007,98 @@ describe("Spell Conversion", () => {
});
});
describe("getEventCoordinate", () => {
it("should return null for regular events", () => {
const event: NostrEvent = {
id: "test123",
pubkey: "a".repeat(64),
created_at: 1234567890,
kind: 1, // Regular kind
tags: [],
content: "Test",
sig: "test-sig",
};
expect(getEventCoordinate(event)).toBeNull();
});
it("should return coordinate for simple replaceable events (kind 0)", () => {
const pubkey = "a".repeat(64);
const event: NostrEvent = {
id: "test123",
pubkey,
created_at: 1234567890,
kind: 0, // Metadata - simple replaceable
tags: [],
content: "{}",
sig: "test-sig",
};
expect(getEventCoordinate(event)).toBe(`0:${pubkey}:`);
});
it("should return coordinate for simple replaceable events (kind 10002)", () => {
const pubkey = "b".repeat(64);
const event: NostrEvent = {
id: "test123",
pubkey,
created_at: 1234567890,
kind: 10002, // Relay list - simple replaceable
tags: [],
content: "",
sig: "test-sig",
};
expect(getEventCoordinate(event)).toBe(`10002:${pubkey}:`);
});
it("should return coordinate for parameterized replaceable events with d-tag", () => {
const pubkey = "c".repeat(64);
const event: NostrEvent = {
id: "test123",
pubkey,
created_at: 1234567890,
kind: 30023, // Long-form content - parameterized replaceable
tags: [["d", "my-article-slug"]],
content: "Article content",
sig: "test-sig",
};
expect(getEventCoordinate(event)).toBe(
`30023:${pubkey}:my-article-slug`,
);
});
it("should return coordinate with empty d-tag for parameterized replaceable without d-tag", () => {
const pubkey = "d".repeat(64);
const event: NostrEvent = {
id: "test123",
pubkey,
created_at: 1234567890,
kind: 30078, // App data - parameterized replaceable
tags: [],
content: "{}",
sig: "test-sig",
};
expect(getEventCoordinate(event)).toBe(`30078:${pubkey}:`);
});
it("should return null for ephemeral events", () => {
const event: NostrEvent = {
id: "test123",
pubkey: "e".repeat(64),
created_at: 1234567890,
kind: 20000, // Ephemeral
tags: [],
content: "",
sig: "test-sig",
};
expect(getEventCoordinate(event)).toBeNull();
});
});
describe("applySpellParameters", () => {
describe("$pubkey parameters", () => {
it("should substitute $pubkey in authors array", () => {
@@ -1109,6 +1203,96 @@ describe("Spell Conversion", () => {
expect(result.ids).toEqual([eventId]);
});
it("should convert #e to #a tags when targetAddress is provided", () => {
const parsed = {
filter: { kinds: [1], "#e": ["$event"] },
parameter: { type: "$event" as const },
} as any;
const eventId = "abc123def456";
const address = "30023:pubkeyhex:article-slug";
const result = applySpellParameters(parsed, {
targetEventId: eventId,
targetAddress: address,
});
// Should remove #e filter and add #a filter instead
expect(result["#e"]).toBeUndefined();
expect(result["#a"]).toEqual([address]);
});
it("should preserve existing #a values when converting from #e", () => {
const existingAddress = "30024:otherpubkey:other-article";
const parsed = {
filter: { kinds: [1], "#a": [existingAddress], "#e": ["$event"] },
parameter: { type: "$event" as const },
} as any;
const eventId = "abc123def456";
const address = "30023:pubkeyhex:article-slug";
const result = applySpellParameters(parsed, {
targetEventId: eventId,
targetAddress: address,
});
// Should keep existing #a values and add new one
expect(result["#e"]).toBeUndefined();
expect(result["#a"]).toContain(existingAddress);
expect(result["#a"]).toContain(address);
});
it("should preserve non-$event values in #e when converting to #a", () => {
const otherEventId = "otherevent123";
const parsed = {
filter: { kinds: [1], "#e": [otherEventId, "$event"] },
parameter: { type: "$event" as const },
} as any;
const eventId = "abc123def456";
const address = "30023:pubkeyhex:article-slug";
const result = applySpellParameters(parsed, {
targetEventId: eventId,
targetAddress: address,
});
// Should keep non-$event values in #e and add $event to #a
expect(result["#e"]).toEqual([otherEventId]);
expect(result["#a"]).toEqual([address]);
});
it("should use event ID in #e when no targetAddress provided", () => {
const parsed = {
filter: { kinds: [1], "#e": ["$event"] },
parameter: { type: "$event" as const },
} as any;
const eventId = "abc123def456";
const result = applySpellParameters(parsed, {
targetEventId: eventId,
});
// Should use normal event ID substitution
expect(result["#e"]).toEqual([eventId]);
expect(result["#a"]).toBeUndefined();
});
it("should use targetAddress in #a tags directly", () => {
const parsed = {
filter: { kinds: [1], "#a": ["$event"] },
parameter: { type: "$event" as const },
} as any;
const eventId = "abc123def456";
const address = "30023:pubkeyhex:article-slug";
const result = applySpellParameters(parsed, {
targetEventId: eventId,
targetAddress: address,
});
// Should use address for #a tags
expect(result["#a"]).toEqual([address]);
});
});
describe("$relay parameters", () => {

View File

@@ -6,7 +6,31 @@ import type {
SpellEvent,
} from "@/types/spell";
import type { NostrFilter } from "@/types/nostr";
import type { NostrEvent } from "@/types/nostr";
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
import { isAddressableKind } from "./nostr-kinds";
import { getTagValue } from "applesauce-core/helpers";
/**
* Construct an address coordinate from an event if it's addressable
* Returns coordinate in format: kind:pubkey:d-tag
*
* For replaceable events (10000-19999): kind:pubkey:
* For parameterized replaceable events (30000-39999): kind:pubkey:d-tag
*
* @param event - Nostr event
* @returns Coordinate string or null if not addressable
*/
export function getEventCoordinate(event: NostrEvent): string | null {
if (!isAddressableKind(event.kind)) {
return null;
}
// Get d-tag value (empty string for simple replaceable events)
const dTag = getTagValue(event, "d") || "";
return `${event.kind}:${event.pubkey}:${dTag}`;
}
/**
* Simple tokenization that doesn't expand shell variables
@@ -511,8 +535,12 @@ export function reconstructCommand(
* - $contacts is replaced with targetContacts
* - $pubkey is replaced with targetPubkey (for explicitly parameterized spells)
*
* For $event spells:
* - If targetAddress is provided (replaceable events), uses #a tags instead of #e tags
* - This ensures replaceable events are referenced by coordinate rather than ID
*
* @param parsed - Parsed spell
* @param context - Context for substitution (pubkey, contacts, or event/relay IDs)
* @param context - Context for substitution (pubkey, contacts, event ID/address, or relay)
* @returns Filter with parameters applied
*/
export function applySpellParameters(
@@ -521,6 +549,7 @@ export function applySpellParameters(
targetPubkey?: string;
targetContacts?: string[];
targetEventId?: string;
targetAddress?: string;
targetRelay?: string;
} = {},
): NostrFilter {
@@ -528,6 +557,7 @@ export function applySpellParameters(
targetPubkey,
targetContacts = [],
targetEventId,
targetAddress,
targetRelay,
} = context;
@@ -576,21 +606,64 @@ export function applySpellParameters(
);
}
// Substitute $event in ids
// For replaceable events, we use address coordinates instead of event IDs in tags
const useAddress = !!targetAddress;
const addressValues = targetAddress ? [targetAddress] : [];
// Substitute $event in ids (always use event ID for direct lookups)
if (filter.ids) {
filter.ids = filter.ids.flatMap((id) =>
id === "$event" ? values : [id],
);
}
// Substitute $event in all single-letter tag filters (#e, #a, etc.)
// Substitute $event in all single-letter tag filters
// For #e tags on replaceable events, convert to #a tags with address coordinate
for (const key in filter) {
if (key.startsWith("#") && key.length === 2) {
const tagArray = filter[key as keyof NostrFilter] as string[];
if (Array.isArray(tagArray)) {
(filter as any)[key] = tagArray.flatMap((val) =>
val === "$event" ? values : [val],
// Check if this filter has $event placeholder
const hasEventPlaceholder = tagArray.some(
(val) => val === "$event",
);
if (hasEventPlaceholder) {
// Special handling for #e tags with replaceable events
if (key === "#e" && useAddress) {
// Move substitutions to #a tags for replaceable events
const substituted = tagArray.flatMap(
(val) => (val === "$event" ? [] : [val]), // Remove $event from #e
);
if (substituted.length > 0 || tagArray.length === 1) {
// Only keep #e if it has non-placeholder values
(filter as any)[key] =
substituted.length > 0 ? substituted : undefined;
}
// Add to #a tags
const existingA = (filter as any)["#a"] || [];
(filter as any)["#a"] = [...existingA, ...addressValues];
} else {
// Normal substitution for other tags
(filter as any)[key] = tagArray.flatMap((val) =>
val === "$event"
? useAddress && key === "#a"
? addressValues
: values
: [val],
);
}
}
}
}
}
// Clean up empty tag filters
for (const key in filter) {
if (key.startsWith("#") && key.length === 2) {
const tagArray = filter[key as keyof NostrFilter];
if (Array.isArray(tagArray) && tagArray.length === 0) {
delete (filter as any)[key];
}
}
}