Surcharge de Mutation d'Événement Remplaçable Nostr
Problème
Les événements remplaçables Nostr (Kind 0, 3, 10002, etc.) utilisent un modèle de remplacement complet : publier un nouvel événement remplace complètement le précédent. Si un client publie une mutation basée sur un état en cache obsolète, incomplet ou nul, il écrase silencieusement la version canonique sur les relais, causant une perte de données. Le cas le plus courant est l'effacement de la liste de suivi (Kind 3) quand un utilisateur suit quelqu'un avant que le client ait chargé sa liste de contacts existante.
Contexte / Conditions de Déclenchement
- L'utilisateur signale « J'ai suivi quelqu'un et j'ai perdu tous mes autres suivis »
- Action de suivi/arrêt de suivi sur une session navigateur fraîche ou une connexion mobile
- La mise à jour de profil perd les champs de métadonnées existants
- La mise à jour de la liste de relais supprime les relais existants
- Toute mutation sur un événement remplaçable où l'UI passe un état en cache à la fonction de mutation
- React Query / TanStack Query
dataestundefinedquand la mutation se déclenche (la requête est toujours en cours de chargement) - La fonction de mutation accepte l'événement actuel comme paramètre depuis la couche UI
Solution
1. Toujours Récupérer l'État Frais à l'Intérieur de la Mutation
Ne fiez-vous jamais uniquement à l'état en cache/requête de l'UI. Récupérez la dernière version de l'événement remplaçable directement depuis le relais à l'intérieur de la fonction de mutation, avant de publier :
// MAUVAIS : Dépend du cache UI qui peut être null/obsolète
mutationFn: async ({ targetPubkey, currentContactList }) => {
const currentTags = currentContactList?.tags || []; // null -> [] -> perte de données !
// ... publier avec seulement le nouveau suivi
}
// BON : Récupère frais du relais avant de muter
mutationFn: async ({ targetPubkey, currentContactList }) => {
let bestContactList = currentContactList;
try {
const relayEvents = await nostr.query([
{ kinds: [3], authors: [userPubkey], limit: 1 },
], { signal: AbortSignal.timeout(5000) });
const relayContactList = relayEvents
.sort((a, b) => b.created_at - a.created_at)[0] || null;
if (relayContactList) {
// Utilise celle qui a plus de données pour éviter la perte
const relayCount = relayContactList.tags.filter(t => t[0] === 'p').length;
const passedCount = currentContactList?.tags.filter(t => t[0] === 'p').length ?? 0;
if (relayCount >= passedCount) {
bestContactList = relayContactList;
}
}
} catch {
// Revenir à la liste de contacts transmise
}
if (!bestContactList) {
throw new Error('Could not load existing data. Please try again.');
}
// Maintenant muter bestContactList...
}
2. Stratégie « Le Meilleur des Deux »
Comparez la version du relais contre la version en cache de l'UI et utilisez celle qui a PLUS de données (plus de tags, plus de champs, etc.). Cela protège contre :
- Relais obsolète (cache UI plus récent à partir d'une action locale récente)
- Cache UI obsolète (relais a des mises à jour d'un autre client)
- Cache UI nul (la requête n'a pas chargé sur une session fraîche)
3. Refuser de Publier en Cas d'Échec Total
Si ni la récupération du relais ni le cache UI ne fournissent de données, levez une erreur au lieu de publier un événement remplaçable vide/minimal. Un message d'erreur convivial est toujours préférable à une perte silencieuse de données.
4. Appliquer aux Deux Directions
Appliquez ce motif à TOUTES les directions de mutation (suivi ET arrêt de suivi, ajout ET suppression de relais, mise à jour ET effacement de champs de profil). Le chemin d'arrêt de suivi est tout aussi dangereux que le suivi.
Vérification
- Ouvrez l'application dans une fenêtre de navigateur privée/incognito
- Connectez-vous avec un compte qui a plusieurs suivis
- Naviguez vers un profil et appuyez sur Suivre IMMÉDIATEMENT (avant que la page se charge complètement)
- Vérifiez que le nombre de suivis a augmenté de 1 (et non réinitialisé à 1)
Exemple
// Correction réelle du hook useFollowUser de divine-web
export function useFollowUser() {
const { nostr } = useNostr();
return useMutation({
mutationFn: async ({ targetPubkey, currentContactList }) => {
// Étape 1 : Récupérer frais depuis le relais
let bestContactList = currentContactList;
try {
const events = await nostr.query([
{ kinds: [3], authors: [user.pubkey], limit: 1 }
], { signal: AbortSignal.timeout(5000) });
const relayList = events.sort((a, b) => b.created_at - a.created_at)[0];
if (relayList) {
const relayFollows = relayList.tags.filter(t => t[0] === 'p').length;
const cachedFollows = currentContactList?.tags.filter(t => t[0] === 'p').length ?? 0;
if (relayFollows >= cachedFollows) bestContactList = relayList;
}
} catch { /* revenir au cache */ }
// Étape 2 : Refuser s'il n'y a pas de données
if (!bestContactList) throw new Error('Could not load follow list');
// Étape 3 : Muter en sécurité
const tags = [...bestContactList.tags, ['p', targetPubkey]];
return publishEvent({ kind: 3, tags, content: bestContactList.content });
}
});
}
Notes
- Ce motif s'applique à TOUS les kinds d'événements remplaçables Nostr : Kind 0 (profil), Kind 3 (contacts), Kind 10002 (liste de relais), Kind 10000 (liste de sourdine), Kind 30000+ (adressable)
- La condition de course est la plus courante sur les navigateurs mobiles où le réseau est plus lent et les utilisateurs appuient rapidement
- Les dialogues de vérification de sécurité (comme « Êtes-vous sûr ? ») ne sont pas utiles car ils vérifient le même cache obsolète – la correction doit être à l'intérieur de la mutation elle-même
- Le délai d'expiration de 5 secondes sur la récupération du relais est un équilibre raisonnable entre sécurité et UX
- Envisagez aussi de désactiver le bouton de mutation pendant le chargement de la requête initiale, comme approche de double sécurité