feat: géolocalisation par clé cryptographique (SS58 → Duniter base58)
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 <noreply@anthropic.com>
This commit is contained in:
25
src/App.tsx
25
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 (
|
||||
|
||||
@@ -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<string, string>; expiresAt: number } | null = null;
|
||||
|
||||
async function getIdentityKeyMap(): Promise<Map<string, string>> {
|
||||
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<string, string>();
|
||||
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<string, { lat: number; lng: number; city: string }>();
|
||||
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<DataResult> {
|
||||
return {
|
||||
transactions: geolocated,
|
||||
stats: {
|
||||
totalVolume, // vrai total blockchain
|
||||
totalVolume,
|
||||
transactionCount: totalCount,
|
||||
geoCount: geolocated.length,
|
||||
topCities: base.topCities,
|
||||
|
||||
@@ -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<Map<string, GeoProfile>> {
|
||||
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<string, GeoProfile>();
|
||||
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.
|
||||
|
||||
@@ -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<currentSS58 → duniterKey> 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<Map<string, string>> {
|
||||
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<string, string>();
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user