Módulo 6: Tipos de Eventos Avanzados y NIPs
Visión General del Módulo
Duración: 6-7 horas
Nivel: Avanzado
Prerrequisitos: Módulos 1-5 completados
Objetivo: Dominar tipos de eventos avanzados de Nostr, NIPs y extensiones del protocolo
📋 Objetivos de Aprendizaje
Al final de este módulo, podrás:
- ✅ Comprender kinds de evento avanzados y sus casos de uso
- ✅ Implementar eventos reemplazables y reemplazables parametrizados
- ✅ Dominar el contenido de formato largo (NIP-23)
- ✅ Trabajar con mensajes directos cifrados (NIP-04, NIP-44)
- ✅ Implementar reacciones, reposts y citas
- ✅ Construir listas y conjuntos (NIP-51)
- ✅ Manejar insignias y logros (NIP-58)
- ✅ Integrar Zaps y Lightning (NIP-57)
📚 Guía Rápida de Referencia de NIPs
NIPs Principales Cubiertos en Este Módulo
| NIP | Título | Event Kinds | Estado | Caso de uso |
|---|---|---|---|---|
| NIP-01 | Protocolo básico | 0, 1, 2, 3, 4, 5, 6, 7 | Obligatorio | Estructura de eventos, kinds, filters |
| NIP-02 | Lista de seguimiento | 3 | Recomendado | Gestión de lista de contactos |
| NIP-04 | MD cifrado | 4 | Obsoleto | Cifrado antiguo de MD (usar NIP-17) |
| NIP-05 | Identificadores DNS | - | Opcional | Verificación username@domain |
| NIP-09 | Eliminación de eventos | 5 | Opcional | Solicitar eliminación de eventos |
| NIP-10 | Notas de texto | 1 | Recomendado | Manejo de respuestas/hilos |
| NIP-11 | Info del relé | - | Recomendado | Documento de metadatos del relé |
| NIP-13 | Prueba de trabajo | - | Opcional | PoW anti-spam |
| NIP-14 | Etiqueta subject | - | Opcional | Asuntos estilo correo |
| NIP-17 | MD privados | 14 | Recomendado | Mensajería cifrada moderna |
| NIP-18 | Reposts | 6, 16 | Opcional | Eventos de boost/repost |
| NIP-19 | Entidades bech32 | - | Recomendado | Codificación npub, note, nevent |
| NIP-21 | URI nostr: | - | Opcional | Esquema de URL nostr: |
| NIP-23 | Formato largo | 30023 | Recomendado | Artículos, entradas de blog |
| NIP-25 | Reacciones | 7 | Recomendado | Likes, reacciones con emoji |
| NIP-26 | Delegación de eventos | - | Opcional | Delegar la firma de eventos |
| NIP-27 | Referencias de texto | - | Recomendado | Formato de menciones |
| NIP-28 | Chat público | 40-44 | Opcional | Canales estilo IRC |
| NIP-29 | Grupos de relé | 9000-9030 | Opcional | Grupos privados de relé |
| NIP-31 | Eventos desconocidos | - | Recomendado | Degradación elegante |
| NIP-32 | Etiquetado | 1985 | Opcional | Clasificación de contenido |
| NIP-36 | Contenido sensible | - | Opcional | Advertencias de contenido |
| NIP-38 | Estados de usuario | 30315 | Opcional | Actualizaciones de estado |
| NIP-39 | IDs externos | - | Opcional | Vincular identidades externas |
| NIP-42 | Auth | 22242 | Opcional | Autenticación del cliente |
| NIP-44 | Payload cifrado | - | Recomendado | Cifrado moderno (versionado) |
| NIP-45 | Conteos de eventos | - | Opcional | Consultas COUNT |
| NIP-50 | Búsqueda | - | Opcional | Búsqueda de texto completo |
| NIP-51 | Listas | 10000-10030, 30000-30030 | Recomendado | Silenciados, pines, favoritos |
| NIP-56 | Reportes | 1984 | Opcional | Reportar spam/contenido ilegal |
| NIP-57 | Zaps | 9734, 9735 | Recomendado | Propinas Lightning |
| NIP-58 | Insignias | 30009, 8 | Opcional | Logros, premios |
| NIP-59 | Gift Wrap | 1059 | Recomendado | Envoltorio que oculta metadatos |
| NIP-65 | Lista de relés | 10002 | Recomendado | Preferencias de relé del usuario |
Referencia de Categorías de Kinds de Evento
Cómo se organizan los kinds de evento en el protocolo Nostr:
// Categorías de kinds de evento (NIP-01)
const KIND_RANGES = {
// Eventos regulares (almacenados de forma permanente)
REGULAR: {
range: [1, 10000],
behavior: 'Stored by all relays',
examples: [1, 6, 7, 40, 1984]
},
// Eventos reemplazables (solo se conserva el más reciente)
REPLACEABLE: {
range: [10000, 20000],
behavior: 'Only newest per pubkey kept',
examples: [0, 3, 10000, 10002]
},
// Eventos efímeros (no se almacenan)
EPHEMERAL: {
range: [20000, 30000],
behavior: 'Never stored, realtime only',
examples: [20000, 22242]
},
// Reemplazables parametrizados (el más reciente por etiqueta d)
PARAMETERIZED: {
range: [30000, 40000],
behavior: 'Only newest per pubkey+d-tag',
examples: [30000, 30001, 30023, 30315]
}
};
// Mapa completo de kinds de evento (de la especificación del protocolo Nostr)
const EVENT_KINDS = {
// Metadatos y perfiles
0: 'User Metadata (NIP-01)',
3: 'Contacts/Follow List (NIP-02)',
// Notas y contenido
1: 'Short Text Note (NIP-01)',
6: 'Repost (NIP-18)',
16: 'Generic Repost (NIP-18)',
30023: 'Long-form Article (NIP-23)',
30024: 'Draft Article (NIP-23)',
// Reacciones y participación
7: 'Reaction (NIP-25)',
9734: 'Zap Request (NIP-57)',
9735: 'Zap Receipt (NIP-57)',
// Mensajes directos
4: 'Encrypted DM (NIP-04, deprecated)',
14: 'Private DM (NIP-17)',
1059: 'Gift Wrap (NIP-59)',
// Moderación
5: 'Event Deletion Request (NIP-09)',
1984: 'Reporting (NIP-56)',
// Listas y colecciones (NIP-51)
10000: 'Mute List',
10001: 'Pin List',
10002: 'Relay List (NIP-65)',
10003: 'Bookmark List',
10004: 'Communities List',
10005: 'Public Chats List',
10006: 'Blocked Relays',
10007: 'Search Relays',
10015: 'Interests List',
10030: 'User Emoji List',
30000: 'Follow Sets',
30001: 'Generic Lists (deprecated)',
30002: 'Relay Sets',
30003: 'Bookmark Sets',
30004: 'Curation Sets',
30008: 'Profile Badges (NIP-58)',
30009: 'Badge Definition (NIP-58)',
30015: 'Interest Sets',
30030: 'Emoji Sets',
// Comunidad y canales (NIP-28)
40: 'Channel Creation',
41: 'Channel Metadata',
42: 'Channel Message',
43: 'Channel Hide Message',
44: 'Channel Mute User',
// Autenticación y seguridad
22242: 'Client Authentication (NIP-42)',
// Estado y presencia
30315: 'User Status (NIP-38)',
// Marketplace (NIP-15)
30017: 'Create/Update Stall',
30018: 'Create/Update Product',
// Otros
8: 'Badge Award (NIP-58)',
1111: 'Comments (NIP-22)',
1985: 'Label (NIP-32)'
};
Referencia de Tipos de Mensaje
Mensajes de cliente a relé y de relé a cliente:
// Mensajes de cliente a relé
const CLIENT_MESSAGES = {
EVENT: ['EVENT', event], // Publicar un evento
REQ: ['REQ', subId, ...filters], // Solicitar eventos y suscribirse
CLOSE: ['CLOSE', subId], // Terminar la suscripción
AUTH: ['AUTH', event], // Autenticación (NIP-42)
COUNT: ['COUNT', subId, filter] // Solicitar conteos (NIP-45)
};
// Mensajes de relé a cliente
const RELAY_MESSAGES = {
EVENT: ['EVENT', subId, event], // Enviar el evento solicitado
OK: ['OK', eventId, accepted, msg], // Aceptar/rechazar EVENT
EOSE: ['EOSE', subId], // Fin de eventos almacenados
CLOSED: ['CLOSED', subId, msg], // Suscripción terminada
NOTICE: ['NOTICE', humanMsg], // Mensaje legible por humanos
AUTH: ['AUTH', challenge], // Solicitar auth (NIP-42)
COUNT: ['COUNT', subId, {count}] // Enviar conteo (NIP-45)
};
Referencia de Etiquetas Comunes
Etiquetas estándar usadas en distintos kinds de evento:
| Etiqueta | Descripción | Valores | NIPs |
|---|---|---|---|
e |
Referencia a evento | [eventId, relay, marker, pubkey] |
01, 10 |
p |
Referencia a pubkey | [pubkey, relay, petname] |
01, 02 |
a |
Ref. a evento parametrizado | [kind:pubkey:d-tag, relay] |
01 |
d |
Identificador/d-tag | [string] |
01 (addressable) |
t |
Hashtag | [topic] |
24 |
r |
Referencia a URL | [url] |
24, 25 |
q |
Referencia de cita | [eventId, relay, pubkey] |
18 |
amount |
Millisats | [msats] |
57 (zaps) |
bolt11 |
Factura Lightning | [invoice] |
57 |
lnurl |
LNURL | [lnurl] |
57 |
relays |
Lista de relés | [url1, url2, ...] |
57 |
client |
Nombre del cliente | [name, url] |
89 |
title |
Título | [text] |
23 |
image |
URL de imagen | [url, dimensions] |
23, 52 |
summary |
Resumen | [text] |
23 |
published_at |
Hora de publicación | [timestamp] |
23 |
subject |
Línea de asunto | [text] |
14, 17 |
alt |
Descripción alternativa | [text] |
31 |
expiration |
Hora de expiración | [timestamp] |
40 |
content-warning |
Advertencia | [reason] |
36 |
delegation |
Token de delegación | [delegator, conditions, token] |
26 |
proxy |
ID externo | [id, protocol] |
48 |
i |
Identidad externa | [platform:identity, proof] |
39, 73 |
k |
Número de kind | [number] |
18, 25 |
l |
Etiqueta (label) | [label, namespace] |
32 |
L |
Espacio de nombres de label | [namespace] |
32 |
6.1 Categorías de Kinds de Evento
Estructura del Evento (NIP-01)
Todo evento Nostr sigue una estructura estándar definida en NIP-01:
{
"id": "<32-bytes lowercase hex-encoded sha256 of serialized event>",
"pubkey": "<32-bytes lowercase hex-encoded public key>",
"created_at": "<unix timestamp in seconds>",
"kind": "<integer>",
"tags": [
["<single-letter>", "<value>", "<optional-value>"],
// ... más etiquetas
],
"content": "<arbitrary string>",
"sig": "<64-bytes lowercase hex of signature of id>"
}
Cálculo del ID del Evento
El ID del evento es el hash SHA256 de los datos del evento serializados en UTF-8:
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
function getEventHash(event) {
const serialized = JSON.stringify([
0, // reservado para uso futuro
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = sha256(new TextEncoder().encode(serialized));
return bytesToHex(hash);
}
// Ejemplo
const event = {
pubkey: "abc123...",
created_at: 1640000000,
kind: 1,
tags: [],
content: "Hello Nostr!"
};
event.id = getEventHash(event);
Verificación de la Firma del Evento
import { schnorr } from '@noble/curves/secp256k1';
import { hexToBytes } from '@noble/hashes/utils';
function verifySignature(event) {
try {
return schnorr.verify(
event.sig,
event.id,
event.pubkey
);
} catch {
return false;
}
}
// Validar el evento completo
function validateEvent(event) {
// Comprobar campos requeridos
if (!event.id || !event.pubkey || !event.sig) {
return { valid: false, reason: 'Missing required fields' };
}
// Verificar que el ID coincida con el contenido
const calculatedId = getEventHash(event);
if (calculatedId !== event.id) {
return { valid: false, reason: 'Invalid event ID' };
}
// Verificar la firma
if (!verifySignature(event)) {
return { valid: false, reason: 'Invalid signature' };
}
// Comprobar que el timestamp sea razonable (no demasiado en el futuro)
const now = Math.floor(Date.now() / 1000);
if (event.created_at > now + 900) { // tolerancia de 15 minutos
return { valid: false, reason: 'Timestamp too far in future' };
}
return { valid: true };
}
Kinds de Evento Estándar
Los eventos Nostr se categorizan por su número kind, que determina su comportamiento y propósito.
| Rango | Categoría | Comportamiento | Ejemplos |
|---|---|---|---|
| 0-999 | Eventos regulares | Almacenados de forma permanente | Perfiles, notas, reacciones |
| 1000-9999 | Eventos regulares | Almacenados de forma permanente | Formato largo, listas |
| 10000-19999 | Eventos reemplazables | Solo se conserva el más reciente | Metadatos, listas de contactos |
| 20000-29999 | Eventos efímeros | No se almacenan | Indicadores de escritura, presencia |
| 30000-39999 | Reemplazables parametrizados | El más reciente por parámetro | Artículos, productos |
Kinds de Evento Comunes
const EVENT_KINDS = {
// Eventos regulares
METADATA: 0, // Perfil de usuario
TEXT_NOTE: 1, // Nota de texto corta
RECOMMEND_RELAY: 2, // Recomendación de relé
CONTACTS: 3, // Lista de contactos
ENCRYPTED_DM: 4, // Mensaje directo cifrado
EVENT_DELETION: 5, // Solicitud de eliminación
REPOST: 6, // Repost/boost
REACTION: 7, // Like/reacción con emoji
BADGE_AWARD: 8, // Premio de insignia
// Contenido de formato largo
LONG_FORM: 30023, // Artículos, entradas de blog
// Eventos reemplazables
RELAY_LIST: 10002, // Lista de relés del usuario (NIP-65)
// Listas (NIP-51)
MUTE_LIST: 10000,
PIN_LIST: 10001,
BOOKMARK_LIST: 10003,
// Zaps
ZAP_REQUEST: 9734,
ZAP_RECEIPT: 9735,
// Efímeros
AUTH: 22242, // Autenticación del cliente
// Comunidad
CHANNEL_CREATE: 40,
CHANNEL_METADATA: 41,
CHANNEL_MESSAGE: 42,
CHANNEL_HIDE_MESSAGE: 43,
CHANNEL_MUTE_USER: 44,
};
6.2 Eventos Reemplazables
Entender la Reemplazabilidad
Los eventos reemplazables se sustituyen automáticamente cuando se recibe un evento más reciente del mismo kind y del mismo autor.
class ReplaceableEvent {
constructor(kind, content, tags = []) {
if (kind < 10000 || kind >= 20000) {
throw new Error('Not a replaceable event kind');
}
this.kind = kind;
this.content = content;
this.tags = tags;
}
async publish(pool, privateKey) {
const event = {
kind: this.kind,
created_at: Math.floor(Date.now() / 1000),
tags: this.tags,
content: this.content,
};
// Firmar y publicar
const signedEvent = await signEvent(event, privateKey);
await pool.publish(signedEvent);
// Los relés reemplazarán automáticamente cualquier evento más antiguo
// del mismo kind proveniente de esta pubkey
return signedEvent;
}
}
// Ejemplo: actualizar metadatos de usuario (kind 0)
const metadata = {
name: "Alice",
about: "Nostr developer",
picture: "https://example.com/avatar.jpg",
nip05: "alice@example.com"
};
const metadataEvent = new ReplaceableEvent(
0,
JSON.stringify(metadata)
);
await metadataEvent.publish(pool, privateKey);
NIP-02: Listas de Contactos (Kind 3)
class ContactList {
constructor() {
this.contacts = [];
}
addContact(pubkey, relay = '', petname = '') {
this.contacts.push({
pubkey,
relay,
petname
});
}
removeContact(pubkey) {
this.contacts = this.contacts.filter(c => c.pubkey !== pubkey);
}
toEvent() {
return {
kind: 3,
content: '',
tags: this.contacts.map(c => [
'p',
c.pubkey,
c.relay,
c.petname
]),
created_at: Math.floor(Date.now() / 1000)
};
}
static fromEvent(event) {
const list = new ContactList();
event.tags
.filter(tag => tag[0] === 'p')
.forEach(tag => {
list.contacts.push({
pubkey: tag[1],
relay: tag[2] || '',
petname: tag[3] || ''
});
});
return list;
}
}
// Uso
const contacts = new ContactList();
contacts.addContact(
'pubkey123',
'wss://relay.damus.io',
'Alice'
);
contacts.addContact(
'pubkey456',
'wss://nos.lol',
'Bob'
);
const event = contacts.toEvent();
// Firmar y publicar
6.3 Eventos Reemplazables Parametrizados
NIP-33: Eventos Reemplazables Parametrizados
Estos eventos usan una etiqueta d para crear varios eventos reemplazables del mismo kind.
class ParameterizedReplaceableEvent {
constructor(kind, identifier, content, tags = []) {
if (kind < 30000 || kind >= 40000) {
throw new Error('Not a parameterized replaceable event kind');
}
this.kind = kind;
this.identifier = identifier;
this.content = content;
this.tags = [['d', identifier], ...tags];
}
toEvent() {
return {
kind: this.kind,
content: this.content,
tags: this.tags,
created_at: Math.floor(Date.now() / 1000)
};
}
}
// Ejemplo: crear un listado de producto
const product = new ParameterizedReplaceableEvent(
30018, // kind de listado de producto
'vintage-keyboard-001', // identificador único
JSON.stringify({
title: 'Vintage Mechanical Keyboard',
description: 'IBM Model M from 1987',
price: '150 USD',
images: ['https://...']
}),
[
['t', 'keyboards'],
['t', 'vintage'],
['price', '150', 'USD']
]
);
// Más tarde, actualizar el mismo producto
const updatedProduct = new ParameterizedReplaceableEvent(
30018,
'vintage-keyboard-001', // el mismo identificador
JSON.stringify({
title: 'Vintage Mechanical Keyboard',
description: 'IBM Model M from 1987',
price: '120 USD', // precio actualizado
images: ['https://...']
}),
[
['t', 'keyboards'],
['t', 'vintage'],
['price', '120', 'USD']
]
);
6.4 Contenido de Formato Largo (NIP-23)
Crear Artículos
class Article {
constructor(title, summary, content, image = '') {
this.title = title;
this.summary = summary;
this.content = content;
this.image = image;
this.tags = [];
this.publishedAt = null;
}
setPublishedAt(timestamp) {
this.publishedAt = timestamp;
return this;
}
addTag(tag) {
this.tags.push(tag);
return this;
}
addHashtag(hashtag) {
this.tags.push(['t', hashtag]);
return this;
}
setIdentifier(identifier) {
this.identifier = identifier;
return this;
}
toEvent() {
const tags = [
['d', this.identifier || this.generateSlug()],
['title', this.title],
['summary', this.summary],
...this.tags
];
if (this.image) {
tags.push(['image', this.image]);
}
if (this.publishedAt) {
tags.push(['published_at', this.publishedAt.toString()]);
}
return {
kind: 30023,
content: this.content,
tags: tags,
created_at: Math.floor(Date.now() / 1000)
};
}
generateSlug() {
return this.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
static fromEvent(event) {
const getTag = (name) => {
const tag = event.tags.find(t => t[0] === name);
return tag ? tag[1] : '';
};
const article = new Article(
getTag('title'),
getTag('summary'),
event.content,
getTag('image')
);
article.identifier = getTag('d');
const publishedAt = getTag('published_at');
if (publishedAt) {
article.publishedAt = parseInt(publishedAt);
}
article.tags = event.tags.filter(t => t[0] === 't');
return article;
}
}
// Crear y publicar un artículo
const article = new Article(
'Understanding Nostr Relays',
'A deep dive into relay architecture and best practices',
`# Understanding Nostr Relays
Nostr relays are the backbone of the protocol...
## Architecture
Relays use WebSocket connections...
## Best Practices
When running a relay...`
)
.setIdentifier('understanding-nostr-relays')
.addHashtag('nostr')
.addHashtag('relays')
.addHashtag('tutorial')
.setPublishedAt(Math.floor(Date.now() / 1000));
const event = article.toEvent();
// Firmar y publicar
Consultar Artículos
async function getArticlesByAuthor(pool, authorPubkey) {
return await pool.query({
kinds: [30023],
authors: [authorPubkey]
});
}
async function getArticlesByTag(pool, tag) {
return await pool.query({
kinds: [30023],
'#t': [tag]
});
}
async function getArticle(pool, authorPubkey, identifier) {
const results = await pool.query({
kinds: [30023],
authors: [authorPubkey],
'#d': [identifier]
});
return results[0] ? Article.fromEvent(results[0]) : null;
}
6.5 Reacciones y Participación (NIP-25)
Implementar Reacciones
Las reacciones en Nostr usan eventos de kind 7 con el campo content que contiene la reacción. Aunque el like estándar es +, los clientes pueden usar emojis o cualquier texto para crear reacciones expresivas.
class Reaction {
static LIKE = '+';
static DISLIKE = '-';
static create(targetEvent, content = '+') {
return {
kind: 7,
content: content, // Puede ser '+', un emoji o cualquier texto
tags: [
['e', targetEvent.id],
['p', targetEvent.pubkey]
],
created_at: Math.floor(Date.now() / 1000)
};
}
static createCustom(targetEvent, content) {
return this.create(targetEvent, content);
}
static async getReactions(pool, eventId) {
const reactions = await pool.query({
kinds: [7],
'#e': [eventId]
});
// Agrupar por tipo de reacción
const grouped = {};
reactions.forEach(r => {
const content = r.content || '+';
if (!grouped[content]) {
grouped[content] = [];
}
grouped[content].push(r);
});
return grouped;
}
static async getReactionCount(pool, eventId) {
const reactions = await this.getReactions(pool, eventId);
const counts = {};
Object.keys(reactions).forEach(emoji => {
counts[emoji] = reactions[emoji].length;
});
return counts;
}
}
// Ejemplos de uso: reacciones en la práctica
// Like estándar (soportado de forma universal)
const likeEvent = Reaction.create(someEvent, '+');
// Reacciones con emoji (populares en clientes modernos)
const heartEvent = Reaction.create(someEvent, '❤️');
const fireEvent = Reaction.create(someEvent, '🔥');
const laughEvent = Reaction.create(someEvent, '😂');
// Reacciones basadas en texto (también válidas)
const customReaction = Reaction.create(someEvent, 'awesome');
// El campo content acepta cualquier string; los clientes deciden cómo mostrarlo
// Obtener conteos de reacción agrupados por tipo
const counts = await Reaction.getReactionCount(pool, eventId);
// Resultado de ejemplo: { '+': 42, '❤️': 15, '🔥': 8, '😂': 3 }
Reposts (NIP-18)
class Repost {
static create(originalEvent) {
return {
kind: 6,
content: JSON.stringify(originalEvent),
tags: [
['e', originalEvent.id],
['p', originalEvent.pubkey]
],
created_at: Math.floor(Date.now() / 1000)
};
}
static createQuote(originalEvent, comment) {
return {
kind: 1, // Nota regular
content: comment,
tags: [
['e', originalEvent.id, '', 'mention'],
['p', originalEvent.pubkey]
],
created_at: Math.floor(Date.now() / 1000)
};
}
}
// Repost simple
const repost = Repost.create(originalEvent);
// Repost con cita (con comentario)
const quote = Repost.createQuote(
originalEvent,
'This is insightful! Everyone should read this.'
);
6.6 Listas y Conjuntos (NIP-51)
Listas de Usuario
class UserList {
constructor(kind, title = '') {
this.kind = kind;
this.title = title;
this.items = [];
}
addPubkey(pubkey, relay = '', petname = '') {
this.items.push({
type: 'p',
value: pubkey,
relay,
petname
});
}
addEvent(eventId, relay = '', reason = '') {
this.items.push({
type: 'e',
value: eventId,
relay,
reason
});
}
addHashtag(hashtag) {
this.items.push({
type: 't',
value: hashtag
});
}
toEvent() {
return {
kind: this.kind,
content: '',
tags: this.items.map(item => {
if (item.type === 'p') {
return ['p', item.value, item.relay || '', item.petname || ''];
} else if (item.type === 'e') {
return ['e', item.value, item.relay || ''];
} else if (item.type === 't') {
return ['t', item.value];
}
}),
created_at: Math.floor(Date.now() / 1000)
};
}
}
// Lista de silenciados (kind 10000)
const muteList = new UserList(10000, 'Muted Users');
muteList.addPubkey('spammer123');
muteList.addPubkey('troll456');
muteList.addHashtag('spam');
// Lista de pines (kind 10001)
const pinList = new UserList(10001, 'Pinned Notes');
pinList.addEvent('note1abc', '', 'Important announcement');
pinList.addEvent('note2def', '', 'Tutorial');
// Lista de favoritos (kind 10003)
const bookmarks = new UserList(10003, 'Reading List');
bookmarks.addEvent('article1');
bookmarks.addEvent('article2');
// Listas categorizadas (kind 30000-30001)
class CategorizedList extends UserList {
constructor(identifier, title) {
super(30001, title);
this.identifier = identifier;
}
toEvent() {
const event = super.toEvent();
event.tags.unshift(['d', this.identifier]);
event.tags.push(['title', this.title]);
return event;
}
}
const favoriteDevs = new CategorizedList('favorite-devs', 'Favorite Developers');
favoriteDevs.addPubkey('dev1');
favoriteDevs.addPubkey('dev2');
6.7 Mensajes Directos Cifrados
NIP-04: Cifrado Básico (Obsoleto)
import { nip04 } from 'nostr-tools';
class EncryptedDM {
static async send(pool, senderPrivkey, recipientPubkey, message) {
const encrypted = await nip04.encrypt(
senderPrivkey,
recipientPubkey,
message
);
const event = {
kind: 4,
content: encrypted,
tags: [['p', recipientPubkey]],
created_at: Math.floor(Date.now() / 1000)
};
const signed = await signEvent(event, senderPrivkey);
await pool.publish(signed);
return signed;
}
static async decrypt(privkey, senderPubkey, encryptedContent) {
return await nip04.decrypt(
privkey,
senderPubkey,
encryptedContent
);
}
static async getConversation(pool, userPubkey, otherPubkey) {
const sent = await pool.query({
kinds: [4],
authors: [userPubkey],
'#p': [otherPubkey]
});
const received = await pool.query({
kinds: [4],
authors: [otherPubkey],
'#p': [userPubkey]
});
return [...sent, ...received].sort(
(a, b) => a.created_at - b.created_at
);
}
}
NIP-44: Cifrado Mejorado (Recomendado)
import { nip44 } from 'nostr-tools';
class SecureEncryptedDM {
static async send(pool, senderPrivkey, recipientPubkey, message) {
const encrypted = nip44.encrypt(
senderPrivkey,
recipientPubkey,
message
);
const event = {
kind: 4,
content: encrypted,
tags: [['p', recipientPubkey]],
created_at: Math.floor(Date.now() / 1000)
};
const signed = await signEvent(event, senderPrivkey);
await pool.publish(signed);
return signed;
}
static decrypt(privkey, senderPubkey, encryptedContent) {
return nip44.decrypt(
privkey,
senderPubkey,
encryptedContent
);
}
}
6.8 Zaps e Integración con Lightning (NIP-57)
Entender los Zaps
Los Zaps son pagos de Lightning Network asociados a eventos Nostr.
class ZapService {
constructor(lnurlEndpoint) {
this.lnurlEndpoint = lnurlEndpoint;
}
async createZapRequest(recipientPubkey, amount, comment = '', eventToZap = null) {
const zapRequest = {
kind: 9734,
content: comment,
tags: [
['p', recipientPubkey],
['amount', amount.toString()],
['relays', 'wss://relay.damus.io', 'wss://nos.lol']
],
created_at: Math.floor(Date.now() / 1000)
};
if (eventToZap) {
zapRequest.tags.push(['e', eventToZap.id]);
}
return zapRequest;
}
async requestInvoice(zapRequest, amount) {
const params = new URLSearchParams({
amount: amount.toString(),
nostr: JSON.stringify(zapRequest)
});
const response = await fetch(
`${this.lnurlEndpoint}?${params}`
);
const data = await response.json();
return data.pr; // Solicitud de pago (factura)
}
static async getZapsForEvent(pool, eventId) {
return await pool.query({
kinds: [9735], // Recibos de Zap
'#e': [eventId]
});
}
static async getZapsForPubkey(pool, pubkey) {
return await pool.query({
kinds: [9735],
'#p': [pubkey]
});
}
static calculateTotalSats(zapReceipts) {
return zapReceipts.reduce((total, zap) => {
const boltTag = zap.tags.find(t => t[0] === 'bolt11');
if (boltTag) {
const invoice = boltTag[1];
const amount = this.parseInvoiceAmount(invoice);
return total + amount;
}
return total;
}, 0);
}
static parseInvoiceAmount(invoice) {
// Analizar la factura Lightning para extraer el monto
// Esto está simplificado: usa una biblioteca adecuada
const match = invoice.match(/lnbc(\d+)([munp]?)/);
if (match) {
const amount = parseInt(match[1]);
const unit = match[2];
const multipliers = {
'm': 100000, // milli-satoshi
'u': 100, // micro-satoshi
'n': 0.1, // nano-satoshi
'p': 0.0001, // pico-satoshi
'': 100000000 // bitcoin
};
return amount * (multipliers[unit] || 1);
}
return 0;
}
}
// Uso
const zapService = new ZapService('https://lnurl.example.com');
// Crear solicitud de Zap
const zapRequest = await zapService.createZapRequest(
recipientPubkey,
1000, // sats
'Great post!',
eventToZap
);
// Obtener factura
const invoice = await zapService.requestInvoice(zapRequest, 1000);
// Pagar la factura con una billetera Lightning
// ...
// Consultar Zaps de un evento
const zaps = await ZapService.getZapsForEvent(pool, eventId);
const totalSats = ZapService.calculateTotalSats(zaps);
console.log(`Total zapped: ${totalSats} sats`);
6.9 Insignias y Logros (NIP-58)
Crear Definiciones de Insignia
class Badge {
constructor(identifier, name, description, image) {
this.identifier = identifier;
this.name = name;
this.description = description;
this.image = image;
}
toDefinitionEvent() {
return {
kind: 30009,
content: '',
tags: [
['d', this.identifier],
['name', this.name],
['description', this.description],
['image', this.image]
],
created_at: Math.floor(Date.now() / 1000)
};
}
createAward(recipientPubkey) {
return {
kind: 8,
content: '',
tags: [
['a', `30009:${this.creatorPubkey}:${this.identifier}`],
['p', recipientPubkey]
],
created_at: Math.floor(Date.now() / 1000)
};
}
}
// Crear definición de insignia
const contributorBadge = new Badge(
'nostr-contributor-2024',
'Nostr Contributor 2024',
'Awarded to significant Nostr protocol contributors',
'https://example.com/badges/contributor.png'
);
const badgeDefEvent = contributorBadge.toDefinitionEvent();
// Firmar y publicar
// Otorgar la insignia a alguien
const awardEvent = contributorBadge.createAward(developerPubkey);
// Firmar y publicar
6.10 Identificadores Codificados en Bech32 (NIP-19)
NIP-19 define identificadores especiales codificados en bech32 para una representación amigable de claves y eventos.
Tipos de Entidad
| Prefijo | Entidad | Caso de uso |
|---|---|---|
npub |
Clave pública | Perfiles de usuario, menciones |
nsec |
Clave privada | Respaldo/importación (¡manéjala con seguridad!) |
note |
ID de evento | Compartir notas individuales |
nprofile |
Perfil con relés | Compartir perfil con pistas de relé |
nevent |
Evento con relés | Compartir evento con pistas de relé |
naddr |
Dirección de evento reemplazable | Referenciar artículos, productos |
Codificar y Decodificar
import { nip19 } from 'nostr-tools';
// Codificar clave pública
const hexPubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const npub = nip19.npubEncode(hexPubkey);
// "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"
// Decodificar npub
const decoded = nip19.decode(npub);
console.log(decoded);
// { type: 'npub', data: '3bf0c63fcb...' }
// Codificar clave privada (ADVERTENCIA: ¡manéjala con extremo cuidado!)
const hexPrivkey = "...";
const nsec = nip19.nsecEncode(hexPrivkey);
// "nsec1..."
// Codificar ID de nota
const noteId = "note1abc...";
const note1 = nip19.noteEncode(noteId);
// Codificar perfil con pistas de relé
const profilePointer = nip19.nprofileEncode({
pubkey: hexPubkey,
relays: ['wss://relay.damus.io', 'wss://nos.lol']
});
// Codificar evento con contexto
const eventPointer = nip19.neventEncode({
id: eventId,
relays: ['wss://relay.damus.io'],
author: authorPubkey,
kind: 1
});
// Codificar dirección de evento reemplazable parametrizado
const addressPointer = nip19.naddrEncode({
identifier: 'my-article',
pubkey: authorPubkey,
kind: 30023,
relays: ['wss://relay.damus.io']
});
Usos Prácticos
class Nip19Helper {
// Clave pública amigable para mostrar
static displayPubkey(hexPubkey) {
const npub = nip19.npubEncode(hexPubkey);
return npub.substring(0, 12) + '...' + npub.substring(npub.length - 6);
// "npub180cvv07...jh6w6"
}
// Crear enlace compartible a una nota
static createNoteLink(eventId, relays = []) {
if (relays.length > 0) {
return nip19.neventEncode({ id: eventId, relays });
}
return nip19.noteEncode(eventId);
}
// Analizar entrada del usuario (puede ser hex o bech32)
static parseUserInput(input) {
try {
// Intentar decodificar bech32
const decoded = nip19.decode(input);
return {
type: decoded.type,
data: decoded.data
};
} catch {
// Asumir hex si la decodificación falla
if (/^[0-9a-f]{64}$/i.test(input)) {
return {
type: 'hex',
data: input.toLowerCase()
};
}
throw new Error('Invalid input format');
}
}
// Extraer menciones del texto
static extractMentions(text) {
const mentionRegex = /(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g;
const matches = text.match(mentionRegex) || [];
return matches.map(mention => {
const decoded = nip19.decode(mention);
return {
mention,
pubkey: decoded.type === 'npub' ? decoded.data : decoded.data.pubkey
};
});
}
// Reemplazar menciones con nombres amigables
static async formatMentions(text, pool) {
const mentions = this.extractMentions(text);
let formatted = text;
for (const { mention, pubkey } of mentions) {
// Obtener el perfil del usuario
const profile = await pool.queryOne({
kinds: [0],
authors: [pubkey]
});
const name = profile
? JSON.parse(profile.content).name
: this.displayPubkey(pubkey);
formatted = formatted.replace(mention, `@${name}`);
}
return formatted;
}
}
// Ejemplos de uso
const shareableNote = Nip19Helper.createNoteLink(
eventId,
['wss://relay.damus.io']
);
console.log(`Share this: nostr:${shareableNote}`);
const parsed = Nip19Helper.parseUserInput('npub180cvv07...');
console.log(parsed); // { type: 'npub', data: '3bf0c63...' }
const mentions = Nip19Helper.extractMentions(
'Hey npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 check this out!'
);
console.log(mentions); // [{ mention: 'npub1...', pubkey: '3bf0c...' }]
Consideraciones de Seguridad
class SecureNip19Handler {
// NUNCA expongas nsec en la interfaz o en los logs
static handleNsec(nsec) {
try {
const { data: privateKey } = nip19.decode(nsec);
// Cifrar o almacenar de forma segura de inmediato
this.secureStore(privateKey);
// Borrar de la memoria
nsec = null;
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
// Validar antes de decodificar
static validateNip19(input) {
const validPrefixes = ['npub', 'nsec', 'note', 'nprofile', 'nevent', 'naddr'];
const prefix = input.substring(0, input.indexOf('1'));
if (!validPrefixes.includes(prefix)) {
throw new Error(`Invalid NIP-19 prefix: ${prefix}`);
}
try {
nip19.decode(input);
return true;
} catch {
return false;
}
}
}
6.11 Ejercicios Prácticos
Ejercicio 1: Plataforma de Artículos
Construye una plataforma de contenido de formato largo: 1. Crear una interfaz de publicación de artículos 2. Implementar edición de artículos (actualizar los existentes) 3. Agregar filtrado por hashtags 4. Construir un feed de artículos con paginación
Ejercicio 2: Interacciones Sociales
Implementa funciones de participación: 1. Sistema de likes/reacciones con emojis personalizados 2. Funcionalidad de repost 3. Reposts con cita y comentarios 4. Panel de estadísticas de reacciones
Ejercicio 3: Gestión de Listas
Crea un gestor de favoritos: 1. Varias listas categorizadas 2. Agregar/quitar elementos 3. Compartir listas de forma pública 4. Importar/exportar listas
Ejercicio 4: Chat Cifrado
Construye una aplicación de MD: 1. Implementar cifrado NIP-44 2. Actualizaciones de mensajes en tiempo real 3. Hilos de conversación 4. Confirmaciones de lectura
Ejercicio 5: Integración de Zaps
Agrega zapping a tu cliente: 1. Mostrar un botón de Zap en los eventos 2. Mostrar el total de Zaps recibidos 3. Listar a los principales zappers 4. Crear una tabla de clasificación de Zaps
6.12 Delegación de Eventos (NIP-26)
La delegación de eventos permite que un usuario autorice a otro a publicar eventos en su nombre. Esto es útil para: - Community managers que publican para marcas - Cuentas bot que publican contenido automatizado - Gestión de claves en varios dispositivos - Integraciones de servicios
Crear un Token de Delegación
import { getSignature, getEventHash } from 'nostr-tools';
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
class EventDelegation {
static createDelegation(delegatorPrivateKey, delegatePubkey, conditions = {}) {
const {
kinds = [], // kinds de evento permitidos
since = 0, // timestamp Unix: válido desde
until = null, // timestamp Unix: válido hasta
} = conditions;
// Construir la cadena de condiciones
const conditionStrings = [];
if (kinds.length > 0) {
conditionStrings.push(`kind=${kinds.join(',')}`);
}
if (since > 0) {
conditionStrings.push(`created_at>${since}`);
}
if (until) {
conditionStrings.push(`created_at<${until}`);
}
const conditionsStr = conditionStrings.join('&');
// Crear el token de delegación
const token = `nostr:delegation:${delegatePubkey}:${conditionsStr}`;
const hash = sha256(new TextEncoder().encode(token));
const sig = getSignature(hash, delegatorPrivateKey);
return {
delegatePubkey,
conditions: conditionsStr,
token: bytesToHex(sig)
};
}
static applyDelegationToEvent(event, delegatorPubkey, delegationToken) {
// Agregar etiqueta de delegación
event.tags.push([
'delegation',
delegatorPubkey,
delegationToken.conditions,
delegationToken.token
]);
return event;
}
static verifyDelegation(event) {
const delegationTag = event.tags.find(t => t[0] === 'delegation');
if (!delegationTag) {
return { valid: false, reason: 'No delegation tag' };
}
const [_, delegatorPubkey, conditions, token] = delegationTag;
// Verificar que las condiciones coincidan con el evento
const conditionChecks = conditions.split('&');
for (const condition of conditionChecks) {
if (condition.startsWith('kind=')) {
const allowedKinds = condition.substring(5).split(',').map(Number);
if (!allowedKinds.includes(event.kind)) {
return { valid: false, reason: 'Kind not allowed by delegation' };
}
}
if (condition.startsWith('created_at>')) {
const since = parseInt(condition.substring(11));
if (event.created_at <= since) {
return { valid: false, reason: 'Event too old for delegation' };
}
}
if (condition.startsWith('created_at<')) {
const until = parseInt(condition.substring(11));
if (event.created_at >= until) {
return { valid: false, reason: 'Event too new for delegation' };
}
}
}
// Verificar la firma de delegación
const delegationString = `nostr:delegation:${event.pubkey}:${conditions}`;
const hash = sha256(new TextEncoder().encode(delegationString));
// En la práctica, usa schnorr.verify aquí
// Por ahora, asumimos que el token es válido si el formato es correcto
return { valid: true, delegator: delegatorPubkey };
}
}
// Ejemplo de uso
const delegatorPrivateKey = "..."; // Clave de la marca
const delegatePubkey = "..."; // Clave pública del community manager
// Crear delegación para notas de kind 1, válida 30 días
const delegation = EventDelegation.createDelegation(
delegatorPrivateKey,
delegatePubkey,
{
kinds: [1],
since: Math.floor(Date.now() / 1000),
until: Math.floor(Date.now() / 1000) + (30 * 86400)
}
);
// El community manager crea un evento
const event = {
kind: 1,
pubkey: delegatePubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: "Posted on behalf of the brand"
};
// Aplicar la delegación
EventDelegation.applyDelegationToEvent(
event,
getDelegatorPubkey(delegatorPrivateKey), // Derivar de la clave privada
delegation
);
// Firmar con la clave del delegado y publicar
// Los relés mostrarán esto como proveniente del delegante
Casos de Uso Prácticos de Delegación
// 1. Delegación de cuenta bot
class BotDelegation {
constructor(ownerPrivateKey, botPubkey) {
this.delegation = EventDelegation.createDelegation(
ownerPrivateKey,
botPubkey,
{
kinds: [1], // Solo permitir notas de texto
since: Math.floor(Date.now() / 1000),
until: Math.floor(Date.now() / 1000) + (365 * 86400) // 1 año
}
);
}
createBotPost(content, botPrivateKey) {
const event = {
kind: 1,
pubkey: getPublicKey(botPrivateKey),
created_at: Math.floor(Date.now() / 1000),
tags: [],
content
};
EventDelegation.applyDelegationToEvent(event, this.ownerPubkey, this.delegation);
return finishEvent(event, botPrivateKey);
}
}
// 2. Delegación de acceso temporal
class TemporaryDelegation {
static createHourlyAccess(ownerKey, tempPubkey) {
const now = Math.floor(Date.now() / 1000);
return EventDelegation.createDelegation(
ownerKey,
tempPubkey,
{
since: now,
until: now + 3600 // 1 hora
}
);
}
}
6.13 Patrones Avanzados
Hilos de Eventos
class Thread {
static createReply(parentEvent, content, mentions = []) {
const tags = [
['e', parentEvent.id, '', 'reply']
];
// Agregar el evento raíz si esta es una respuesta anidada
const rootTag = parentEvent.tags.find(t => t[0] === 'e' && t[3] === 'root');
if (rootTag) {
tags.unshift(['e', rootTag[1], '', 'root']);
} else {
tags.unshift(['e', parentEvent.id, '', 'root']);
}
// Agregar al autor del padre
tags.push(['p', parentEvent.pubkey]);
// Agregar usuarios mencionados
mentions.forEach(pubkey => {
tags.push(['p', pubkey]);
});
return {
kind: 1,
content,
tags,
created_at: Math.floor(Date.now() / 1000)
};
}
static async getThread(pool, rootEventId) {
const replies = await pool.query({
kinds: [1],
'#e': [rootEventId]
});
// Construir estructura de árbol
const threadMap = new Map();
threadMap.set(rootEventId, { replies: [] });
replies.forEach(reply => {
const replyTag = reply.tags.find(t => t[0] === 'e' && t[3] === 'reply');
const parentId = replyTag ? replyTag[1] : rootEventId;
if (!threadMap.has(reply.id)) {
threadMap.set(reply.id, { event: reply, replies: [] });
} else {
threadMap.get(reply.id).event = reply;
}
if (!threadMap.has(parentId)) {
threadMap.set(parentId, { replies: [] });
}
threadMap.get(parentId).replies.push(reply.id);
});
return threadMap;
}
}
Descubrimiento de Contenido
class ContentDiscovery {
static async getTrending(pool, timeWindow = 86400) {
const since = Math.floor(Date.now() / 1000) - timeWindow;
// Obtener notas recientes
const notes = await pool.query({
kinds: [1],
since,
limit: 1000
});
// Obtener reacciones de estas notas
const noteIds = notes.map(n => n.id);
const reactions = await pool.query({
kinds: [7],
'#e': noteIds,
since
});
// Contar reacciones por nota
const reactionCounts = {};
reactions.forEach(r => {
const noteId = r.tags.find(t => t[0] === 'e')[1];
reactionCounts[noteId] = (reactionCounts[noteId] || 0) + 1;
});
// Ordenar por conteo de reacciones
return notes
.map(note => ({
...note,
reactions: reactionCounts[note.id] || 0
}))
.sort((a, b) => b.reactions - a.reactions);
}
static async getRecommended(pool, userPubkey) {
// Obtener los contactos del usuario
const contacts = await pool.queryOne({
kinds: [3],
authors: [userPubkey]
});
if (!contacts) return [];
const following = contacts.tags
.filter(t => t[0] === 'p')
.map(t => t[1]);
// Obtener notas recientes de los contactos
return await pool.query({
kinds: [1],
authors: following,
limit: 100
});
}
}
📝 Cuestionario del Módulo 6
-
¿Cuál es la diferencia entre eventos reemplazables y eventos reemplazables parametrizados?
Respuesta
Los eventos reemplazables (10000-19999) conservan solo el evento más reciente de ese kind por pubkey. Los eventos reemplazables parametrizados (30000-39999) usan una etiqueta `d` para permitir varias instancias, conservando la más reciente por combinación pubkey+identificador. -
¿Por qué se prefiere NIP-44 sobre NIP-04 para mensajes cifrados?
Respuesta
NIP-44 ofrece mejor seguridad con cifrado mejorado, relleno para impedir el análisis de la longitud del mensaje y protección contra varios ataques criptográficos a los que NIP-04 es vulnerable. -
¿Cuáles son los tres componentes principales de un Zap?
Respuesta
1) Solicitud de Zap (kind 9734): el cliente la crea y la firma 2) Factura Lightning: la genera el servidor LNURL 3) Recibo de Zap (kind 9735): se publica después del pago -
¿Cómo se crea una respuesta de hilo que mantiene la estructura de la conversación?
Respuesta
Usa etiquetas `e` con marcadores: una etiqueta `e` marcada `root` que apunta a la raíz del hilo, y una etiqueta `e` marcada `reply` que apunta al comentario padre inmediato. -
¿Qué hace distinto al contenido de formato largo (kind 30023) de las notas regulares?
Respuesta
El contenido de formato largo es reemplazable parametrizado, admite etiquetas de metadatos (`title`, `summary`, `image`, `published_at`), usa una etiqueta `d` para identificación y está pensado para artículos y entradas de blog en lugar de mensajes cortos.
🎯 Punto de Control del Módulo 6
Antes de completar este módulo, asegúrate de haber:
- Implementado eventos reemplazables (actualizaciones de perfil, listas de contactos)
- Creado y actualizado eventos reemplazables parametrizados
- Construido la publicación de contenido de formato largo
- Agregado reacciones y reposts a tu cliente
- Implementado al menos un tipo de lista (silenciados, favoritos, etc.)
- Integrado mensajería directa cifrada
- Comprendido el flujo de Zap (aunque no esté implementado por completo)
- Experimentado con insignias u otro NIP avanzado
📚 Recursos Adicionales
- NIP-01: Basic Protocol Flow
- NIP-23: Long-form Content
- NIP-25: Reactions
- NIP-33: Parameterized Replaceable Events
- NIP-44: Encrypted Direct Message
- NIP-51: Lists
- NIP-57: Lightning Zaps
- NIP-58: Badges
- Documentación de nostr-tools
💬 Discusión de la Comunidad
Únete a nuestro Discord para hablar del Módulo 6: - Comparte tus implementaciones avanzadas - Obtén ayuda con la integración de NIPs - Discute propuestas de protocolo - Colabora en el desarrollo de NIPs
¡Felicitaciones!
¡Has dominado los tipos de eventos avanzados y los NIPs de Nostr! Ahora puedes construir aplicaciones sofisticadas con contenido de formato largo, mensajería cifrada, interacciones sociales e integración con Lightning. ¡Estás listo para contribuir al ecosistema Nostr!
🎓 Curso Completado - Próximos Pasos →
Próximos Pasos
Ahora que has completado los 6 módulos, considera:
- Construir una app de producción — Toma lo que aprendiste y crea un cliente Nostr real
- Contribuir a los NIPs — Propón mejoras o nuevas funciones del protocolo
- Operar infraestructura — Configura relés, servidores LNURL u otros servicios
- Unirte al desarrollo — Contribuye a proyectos Nostr existentes
- Crear contenido — Comparte tutoriales y ayuda a otras personas a aprender
¡Bienvenido al ecosistema Nostr! 🚀💜