From 5fe71c1801b7fbfadcb4e374c760b05ec9afe39b Mon Sep 17 00:00:00 2001 From: syoul Date: Sun, 22 Mar 2026 17:41:26 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20g=C3=A9olocalisation=20par=20cl=C3=A9?= =?UTF-8?q?=20cryptographique=20(SS58=20=E2=86=92=20Duniter=20base58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Au lieu de chercher par nom (title), on résout maintenant par clé : 1. buildIdentityKeyMap() : charge toutes les identités Ğ1v2 depuis Subsquid avec leur ownerKeyChange → currentSS58 → genesisKey → duniterKey 2. ss58ToDuniterKey() : conversion SS58 v2 (préfixe 2 octets) → base58 Ed25519 = _id Cesium+ (même matériau cryptographique, encodage différent) 3. resolveGeoByKeys() : query Cesium+ par ids{} → résultat exact, pas d'ambiguïté 4. Cache keyMap 10 min : 1 requête Subsquid pour ~8000 identités, pas par refresh Résultat : les membres migrés v1→v2 avec un profil Cesium+ sont correctement géolocalisés même si leur nom v2 diffère de leur nom v1. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 25 ++++---- src/services/DataService.ts | 54 +++++++++++++----- src/services/adapters/CesiumAdapter.ts | 42 ++++++++++++++ src/services/adapters/SubsquidAdapter.ts | 72 ++++++++++++++++++++++++ src/test/DataService.test.ts | 22 ++++---- 5 files changed, 180 insertions(+), 35 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 52c1d77..ac3bbe0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,18 +15,23 @@ export default function App() { useEffect(() => { let cancelled = false; - setLoading(true); - fetchData(periodDays).then(({ transactions, stats, source }) => { - if (!cancelled) { - setTransactions(transactions); - setStats(stats); - setSource(source); - setLoading(false); - } - }); + const load = (showLoading: boolean) => { + if (showLoading) setLoading(true); + fetchData(periodDays).then(({ transactions, stats, source }) => { + if (!cancelled) { + setTransactions(transactions); + setStats(stats); + setSource(source); + setLoading(false); + } + }); + }; - return () => { cancelled = true; }; + load(true); + const interval = setInterval(() => load(false), 30_000); + + return () => { cancelled = true; clearInterval(interval); }; }, [periodDays]); return ( diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 6f0b190..d10e047 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -3,17 +3,17 @@ * * Mode mock (USE_LIVE_API = false) : données simulées, aucun appel réseau. * Mode live (USE_LIVE_API = true) : données réelles Ğ1v2. - * - Transactions : Subsquid indexer https://squidv2s.syoul.fr/v1/graphql - * - Géolocalisation : Cesium+ https://g1.data.e-is.pro - * → recherche batch par nom d'identité (champ "title" analysé ES) - * → couverture ~50-60% : les tx sans profil géo sont EXCLUES du heatmap - * mais comptées dans totalCount / totalVolume + * - Transactions : Subsquid indexer https://squidv2s.syoul.fr/v1/graphql + * - Géolocalisation : Cesium+ https://g1.data.e-is.pro + * → lookup par clé Duniter (_id) via conversion SS58 → base58 Ed25519 + * → membres migrés v1→v2 : clé genesis (previousId) = _id Cesium+ + * → carte d'identité (keyMap) mise en cache 10 min, 1 seule requête * * Pour activer : définir VITE_USE_LIVE_API=true dans .env.local */ -import { fetchTransfers } from './adapters/SubsquidAdapter'; -import { resolveGeoByNames } from './adapters/CesiumAdapter'; +import { fetchTransfers, buildIdentityKeyMap } from './adapters/SubsquidAdapter'; +import { resolveGeoByKeys } from './adapters/CesiumAdapter'; import { getTransactionsForPeriod, computeStats, @@ -22,6 +22,16 @@ import { const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true'; +// Cache de la carte identité SS58→DuniterKey, valide 10 minutes +let keyMapCache: { map: Map; expiresAt: number } | null = null; + +async function getIdentityKeyMap(): Promise> { + if (keyMapCache && Date.now() < keyMapCache.expiresAt) return keyMapCache.map; + const map = await buildIdentityKeyMap(); + keyMapCache = { map, expiresAt: Date.now() + 10 * 60 * 1000 }; + return map; +} + async function fetchLiveTransactions(periodDays: number): Promise<{ geolocated: Transaction[]; totalCount: number; @@ -33,13 +43,25 @@ async function fetchLiveTransactions(periodDays: number): Promise<{ const totalCount = rawTransfers.length; const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0); - // Résolution géo batch via Cesium+ - const names = rawTransfers.map((t) => t.fromName).filter(Boolean); + // Carte SS58 courant → clé Duniter (= _id Cesium+) + let keyMap = new Map(); + try { + keyMap = await getIdentityKeyMap(); + } catch (err) { + console.warn('Identity key map indisponible :', err); + } + + // Clés Duniter uniques des émetteurs + const duniterKeys = [...new Set( + rawTransfers.map((t) => keyMap.get(t.fromId)).filter(Boolean) as string[] + )]; + + // Résolution géo par clé Duniter (_id Cesium+) let geoMap = new Map(); try { - const profiles = await resolveGeoByNames(names); - for (const [name, p] of profiles) { - geoMap.set(name, { lat: p.lat, lng: p.lng, city: p.city }); + const profiles = await resolveGeoByKeys(duniterKeys); + for (const [key, p] of profiles) { + geoMap.set(key, { lat: p.lat, lng: p.lng, city: p.city }); } } catch (err) { console.warn('Cesium+ indisponible :', err); @@ -48,8 +70,10 @@ async function fetchLiveTransactions(periodDays: number): Promise<{ // Seules les transactions avec un profil géo entrent dans le heatmap const geolocated: Transaction[] = []; for (const t of rawTransfers) { - const geo = geoMap.get(t.fromName.toLowerCase()); - if (!geo) continue; // pas de profil → exclu du heatmap + const duniterKey = keyMap.get(t.fromId); + if (!duniterKey) continue; + const geo = geoMap.get(duniterKey); + if (!geo) continue; geolocated.push({ id: t.id, @@ -100,7 +124,7 @@ export async function fetchData(periodDays: number): Promise { return { transactions: geolocated, stats: { - totalVolume, // vrai total blockchain + totalVolume, transactionCount: totalCount, geoCount: geolocated.length, topCities: base.topCities, diff --git a/src/services/adapters/CesiumAdapter.ts b/src/services/adapters/CesiumAdapter.ts index 03c362c..8bf59cd 100644 --- a/src/services/adapters/CesiumAdapter.ts +++ b/src/services/adapters/CesiumAdapter.ts @@ -23,6 +23,7 @@ export interface GeoProfile { } const HitSchema = z.object({ + _id: z.string(), _source: z.object({ title: z.string().optional(), city: z.string().optional(), @@ -39,6 +40,47 @@ const SearchResponseSchema = z.object({ }), }); +/** + * Résout les coordonnées par clé Duniter (Cesium+ _id). + * Plus fiable que par nom car la clé est unique et indépendante de la casse. + * Fonctionne pour les membres migrés v1→v2 (clé genesis = _id Cesium+). + */ +export async function resolveGeoByKeys( + duniterKeys: string[] +): Promise> { + const unique = [...new Set(duniterKeys.filter(Boolean))]; + if (unique.length === 0) return new Map(); + + const query = { + size: unique.length, + query: { ids: { values: unique } }, + _source: ['title', 'city', 'geoPoint'], + }; + + const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(query), + }); + if (!response.ok) throw new Error(`Cesium+ HTTP ${response.status}`); + + const raw = await response.json(); + const parsed = SearchResponseSchema.parse(raw); + + const result = new Map(); + for (const hit of parsed.hits.hits) { + const src = hit._source; + if (!src.geoPoint) continue; + result.set(hit._id, { + name: src.title ?? '', + city: src.city ?? 'Inconnue', + lat: src.geoPoint.lat, + lng: src.geoPoint.lon, + }); + } + return result; +} + /** * Résout les coordonnées de plusieurs membres Ğ1 par leur nom d'identité. * Envoie une requête Elasticsearch multi-terms en un seul appel. diff --git a/src/services/adapters/SubsquidAdapter.ts b/src/services/adapters/SubsquidAdapter.ts index aae3ec6..da4e0b3 100644 --- a/src/services/adapters/SubsquidAdapter.ts +++ b/src/services/adapters/SubsquidAdapter.ts @@ -79,6 +79,78 @@ const TRANSFERS_QUERY = ` } `; +// --------------------------------------------------------------------------- +// Conversion de clé SS58 Ğ1v2 → base58 Duniter (pour lookup Cesium+ par _id) +// SS58 Ğ1v2 utilise un préfixe réseau sur 2 octets : 2 + 32 bytes clé + 2 checksum = 36 bytes +// --------------------------------------------------------------------------- +const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +function b58decode(s: string): Uint8Array { + let n = 0n; + for (const c of s) n = n * 58n + BigInt(B58.indexOf(c)); + const hex = n.toString(16); + const padded = hex.length % 2 ? '0' + hex : hex; + const bytes = new Uint8Array(padded.length / 2); + for (let i = 0; i < bytes.length; i++) + bytes[i] = parseInt(padded.slice(i * 2, i * 2 + 2), 16); + return bytes; +} + +function b58encode(bytes: Uint8Array): string { + let n = 0n; + for (const b of bytes) n = n * 256n + BigInt(b); + let result = ''; + while (n > 0n) { const r = Number(n % 58n); result = B58[r] + result; n = n / 58n; } + return result; +} + +/** Ğ1v2 SS58 (préfixe 2 octets) → base58 Duniter (clé Ed25519 brute 32 octets) */ +export function ss58ToDuniterKey(ss58: string): string { + const decoded = b58decode(ss58); // 36 bytes + return b58encode(decoded.slice(2, 34)); // skip 2-byte prefix + 2-byte checksum +} + +// --------------------------------------------------------------------------- +// Carte d'identité : currentSS58 → duniterKey (= Cesium+ _id) +// Récupère toutes les identités Ğ1v2 avec leur clé d'origine (avant migration éventuelle) +// --------------------------------------------------------------------------- +const IDENTITY_KEY_MAP_QUERY = ` + query { + identities(first: 10000) { + nodes { + accountId + ownerKeyChange(orderBy: BLOCK_NUMBER_ASC, first: 1) { + nodes { previousId } + } + } + } + } +`; + +/** + * Construit une Map pour tous les membres Ğ1v2. + * - Membres sans changement de clé : accountId → ss58ToDuniterKey(accountId) + * - Membres migrés (changeOwnerKey) : accountId → ss58ToDuniterKey(previousId) + * car previousId = clé génesis = clé Ed25519 v1 = _id dans Cesium+ + */ +export async function buildIdentityKeyMap(): Promise> { + const response = await fetch(SUBSQUID_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: IDENTITY_KEY_MAP_QUERY }), + }); + if (!response.ok) throw new Error(`Subsquid HTTP ${response.status}`); + const raw = await response.json(); + if (raw.errors?.length) throw new Error(raw.errors[0].message); + + const result = new Map(); + for (const node of raw.data.identities.nodes) { + const genesisKey: string = node.ownerKeyChange.nodes[0]?.previousId ?? node.accountId; + result.set(node.accountId, ss58ToDuniterKey(genesisKey)); + } + return result; +} + export async function fetchTransfers( periodDays: number, limit = 2000 diff --git a/src/test/DataService.test.ts b/src/test/DataService.test.ts index 1e7e66a..60be077 100644 --- a/src/test/DataService.test.ts +++ b/src/test/DataService.test.ts @@ -1,18 +1,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -// Mocker les adaptateurs live AVANT l'import de DataService, -// pour que les tests soient déterministes quel que soit VITE_USE_LIVE_API +// Mocker les adaptateurs AVANT l'import de DataService (vi.mock est hoistés) vi.mock('../services/adapters/SubsquidAdapter', () => ({ fetchTransfers: vi.fn(async (days: number) => [ - { id: 't1', timestamp: Date.now(), amount: 20, fromId: 'g1' + 'a'.repeat(47), toId: 'g1' + 'b'.repeat(47), fromName: 'Alice' }, - { id: 't2', timestamp: Date.now(), amount: 10, fromId: 'g1' + 'c'.repeat(47), toId: 'g1' + 'd'.repeat(47), fromName: 'Bob' }, + { id: 't1', timestamp: Date.now(), amount: 20, fromId: 'SS58_ALICE', toId: 'SS58_BOB' }, + { id: 't2', timestamp: Date.now(), amount: 10, fromId: 'SS58_BOB', toId: 'SS58_CHARLIE' }, ].slice(0, days >= 7 ? 2 : 1)), + buildIdentityKeyMap: vi.fn(async () => new Map([ + ['SS58_ALICE', 'DUN_ALICE'], + ['SS58_BOB', 'DUN_BOB'], + ['SS58_CHARLIE', 'DUN_CHARLIE'], + ])), })); vi.mock('../services/adapters/CesiumAdapter', () => ({ - resolveGeoByNames: vi.fn(async () => new Map([ - ['Alice', { name: 'Alice', city: 'Paris', lat: 48.8566, lng: 2.3522 }], - ['Bob', { name: 'Bob', city: 'Lyon', lat: 45.764, lng: 4.8357 }], + resolveGeoByKeys: vi.fn(async () => new Map([ + ['DUN_ALICE', { name: 'Alice', city: 'Paris', lat: 48.8566, lng: 2.3522 }], + ['DUN_BOB', { name: 'Bob', city: 'Lyon', lat: 45.764, lng: 4.8357 }], ])), })); @@ -22,7 +26,7 @@ vi.mock('../data/mockData', () => ({ { id: 't2', timestamp: Date.now(), lat: 45.7, lng: 4.8, amount: 10, city: 'Lyon', fromKey: 'g1' + 'c'.repeat(47), toKey: 'g1' + 'd'.repeat(47) }, ].slice(0, days >= 7 ? 2 : 1)), computeStats: vi.fn((txs) => ({ - totalVolume: txs.reduce((s: number, t: { amount: number }) => s + t.amount, 0), + totalVolume: txs.reduce((s, t) => s + t.amount, 0), transactionCount: txs.length, topCities: [{ name: 'Paris', volume: 20, count: 1 }], })), @@ -53,8 +57,6 @@ describe('fetchData', () => { it('totalVolume >= somme des transactions géolocalisées', async () => { const { transactions, stats } = await fetchData(7); const geoSum = transactions.reduce((s, t) => s + t.amount, 0); - // En mode live : totalVolume = total blockchain >= geolocalisées - // En mode mock : totalVolume = geoSum (même ensemble) expect(stats.totalVolume).toBeGreaterThanOrEqual(geoSum - 0.01); });