feat: migration Ğ1v2 — Subsquid + Cesium+ g1.data.e-is.pro

Sources de données réelles Ğ1v2 (vérifiées par introspection) :
- SubsquidAdapter : https://squidv2s.syoul.fr/v1/graphql
  transfers(filter: timestamp >= since, orderBy: TIMESTAMP_DESC)
  with from.linkedIdentity.name pour jointure géo
- CesiumAdapter : https://g1.data.e-is.pro (59 841 profils)
  recherche batch par nom d'identité (title.keyword)
  g1.data.duniter.fr hors ligne depuis arrêt Ğ1v1

Schémas Zod mis à jour pour Ğ1v2 :
- G1v2KeySchema : SS58 "g1" + 47 chars = 49 chars (mesuré sur données réelles)
- SubsquidTransferSchema : id, blockNumber, timestamp ISO, amount BigInt string
- parseSubsquidAmount : BigInt string centimes → Ğ1 flottant

Activation : VITE_USE_LIVE_API=true dans .env.local
Badge "● live Ğ1v2 / ○ mock" ajouté dans l'UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-03-22 16:01:36 +01:00
parent d20d042bca
commit 2f813c5fdf
8 changed files with 392 additions and 259 deletions
+51 -70
View File
@@ -1,12 +1,18 @@
/**
* DataService — abstraction layer between the UI and data sources.
* DataService — couche d'abstraction entre l'UI et les sources de données.
*
* Currently backed by mock data. To switch to the Subsquid GraphQL API:
* 1. Set `USE_LIVE_API = true` (or read from env: import.meta.env.VITE_USE_LIVE_API)
* 2. Fill in SUBSQUID_ENDPOINT with your indexer URL
* 3. Implement `fetchLiveTransactions` with a proper GraphQL query
* 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 par nom d'identité (identity.name depuis le graphe Subsquid)
* → les transactions sans profil Cesium+ reçoivent des coordonnées approx.
*
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
*/
import { fetchTransfers } from './adapters/SubsquidAdapter';
import { resolveGeoByNames } from './adapters/CesiumAdapter';
import {
getTransactionsForPeriod,
computeStats,
@@ -16,76 +22,48 @@ import {
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const USE_LIVE_API = false;
const SUBSQUID_ENDPOINT = 'https://squid.subsquid.io/g1-indexer/graphql'; // placeholder
const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true';
// Centroïde France — fallback géo quand Cesium+ n'a pas le profil
const FRANCE_CENTER = { lat: 46.2276, lng: 2.2137 };
// ---------------------------------------------------------------------------
// GraphQL helpers (used when USE_LIVE_API = true)
// Pipeline données live Ğ1v2
// ---------------------------------------------------------------------------
interface GqlTransactionEdge {
node: {
id: string;
blockTimestamp: string;
amount: string;
issuer: string;
recipient: string;
issuerlat?: number;
issuerlng?: number;
city?: string;
};
}
async function fetchLiveTransactions(periodDays: number): Promise<Transaction[]> {
const since = new Date(Date.now() - periodDays * 24 * 60 * 60 * 1000).toISOString();
// 1. Récupère les transferts depuis Subsquid
const rawTransfers = await fetchTransfers(periodDays);
const query = `
query GetTransactions($since: DateTime!) {
transfers(
where: { blockTimestamp_gte: $since }
orderBy: blockTimestamp_DESC
limit: 5000
) {
edges {
node {
id
blockTimestamp
amount
issuer
recipient
issuerlat
issuerlng
city
}
}
}
if (rawTransfers.length === 0) return [];
// 2. Collecte les noms d'identité uniques pour la recherche Cesium+
const names = rawTransfers.map((t) => t.fromName).filter(Boolean);
// 3. Résout les coordonnées via Cesium+ (une seule requête batch)
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 });
}
`;
} catch (err) {
console.warn('Cesium+ indisponible, fallback coordonnées France :', err);
}
const response = await fetch(SUBSQUID_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables: { since } }),
// 4. Assemble les transactions avec coordonnées
return rawTransfers.map((t): Transaction => {
const geo = geoMap.get(t.fromName) ?? FRANCE_CENTER;
return {
id: t.id,
timestamp: t.timestamp,
lat: geo.lat,
lng: geo.lng,
amount: t.amount,
city: ('city' in geo ? geo.city : undefined) ?? 'Inconnue',
fromKey: t.fromId,
toKey: t.toId,
};
});
if (!response.ok) {
throw new Error(`GraphQL request failed: ${response.statusText}`);
}
const { data, errors } = await response.json();
if (errors?.length) {
throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`);
}
return (data.transfers.edges as GqlTransactionEdge[]).map((edge) => ({
id: edge.node.id,
timestamp: new Date(edge.node.blockTimestamp).getTime(),
lat: edge.node.issuerlat ?? 46.2276, // fallback: France centroid
lng: edge.node.issuerlng ?? 2.2137,
amount: parseFloat(edge.node.amount) / 100, // Ğ1 uses centimes
city: edge.node.city ?? 'Inconnue',
fromKey: edge.node.issuer,
toKey: edge.node.recipient,
}));
}
// ---------------------------------------------------------------------------
@@ -100,19 +78,22 @@ export interface PeriodStats {
export interface DataResult {
transactions: Transaction[];
stats: PeriodStats;
source: 'live' | 'mock';
}
export async function fetchData(periodDays: number): Promise<DataResult> {
let transactions: Transaction[];
let source: 'live' | 'mock';
if (USE_LIVE_API) {
transactions = await fetchLiveTransactions(periodDays);
source = 'live';
} else {
// Simulate async for drop-in replacement compatibility
await new Promise((r) => setTimeout(r, 120));
await new Promise((r) => setTimeout(r, 80));
transactions = getTransactionsForPeriod(periodDays);
source = 'mock';
}
const stats = computeStats(transactions);
return { transactions, stats };
return { transactions, stats, source };
}
+56 -76
View File
@@ -4,86 +4,63 @@
* Cesium+ est la couche sociale de Ğ1 : les membres y publient
* un profil optionnel avec pseudo, avatar, ville, et coordonnées GPS.
*
* API docs : https://github.com/duniter/cesium-plus-pod
* Endpoint public : https://g1.data.duniter.fr
* Endpoint actif : https://g1.data.e-is.pro (59 841 profils, vérifié le 2026-03-22)
* g1.data.duniter.fr est hors ligne depuis l'arrêt de Ğ1v1.
*
* En Ğ1v2 les clés SS58 ont changé : la recherche se fait par nom d'identité
* (identity.name depuis Subsquid) et non plus par clé publique.
*/
import { CesiumSearchResponseSchema, type CesiumProfile } from '../../schemas/g1.schema';
import { z } from 'zod';
export const CESIUM_ENDPOINT = 'https://g1.data.duniter.fr';
export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro';
export interface GeoProfile {
pubkey: string;
city: string;
lat: number;
lng: number;
name: string; // nom d'identité Ğ1 (ex: "Anikka")
city: string;
lat: number;
lng: number;
}
const HitSchema = z.object({
_source: z.object({
title: z.string().optional(),
city: z.string().optional(),
geoPoint: z.object({
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
}).optional(),
}),
});
const SearchResponseSchema = z.object({
hits: z.object({
hits: z.array(HitSchema),
}),
});
/**
* Résout les coordonnées géographiques d'une liste de clés publiques.
* Les membres sans profil ou sans geoPoint sont filtrés.
* 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.
*
* @param pubkeys - tableau de clés publiques Ğ1 (base58)
* @returns Map<pubkey, GeoProfile>
* @param names - noms d'identité uniques (depuis SubsquidAdapter RawTransfer.fromName)
* @returns Map<name, GeoProfile>
*/
export async function resolveGeoProfiles(
pubkeys: string[]
export async function resolveGeoByNames(
names: string[]
): Promise<Map<string, GeoProfile>> {
if (pubkeys.length === 0) return new Map();
const unique = [...new Set(names.filter(Boolean))];
if (unique.length === 0) return new Map();
// Elasticsearch multi-get (mget) — efficace en batch
const body = { ids: pubkeys };
const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_mget`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Cesium+ → HTTP ${response.status}`);
}
const raw = await response.json();
const parsed = CesiumSearchResponseSchema.parse({ hits: { hits: raw.docs ?? [] } });
const result = new Map<string, GeoProfile>();
for (const hit of parsed.hits.hits) {
const src = hit._source;
if (src.geoPoint) {
result.set(hit._id, {
pubkey: hit._id,
city: src.city ?? 'Inconnue',
lat: src.geoPoint.lat,
lng: src.geoPoint.lon,
});
}
}
return result;
}
/**
* Recherche des membres Ğ1 avec profil géolocalisé dans un rayon donné.
* Utile pour initialiser la carte avec les membres actifs d'une région.
*/
export async function searchMembersInBoundingBox(opts: {
topLeft: { lat: number; lng: number };
bottomRight: { lat: number; lng: number };
size?: number;
}): Promise<GeoProfile[]> {
const query = {
size: opts.size ?? 200,
size: unique.length,
query: {
bool: {
must: [
{ terms: { 'title.keyword': unique } },
],
filter: [
{ term: { '_source.socials.type': 'member' } },
{
geo_bounding_box: {
'_source.geoPoint': {
top_left: { lat: opts.topLeft.lat, lon: opts.topLeft.lng },
bottom_right: { lat: opts.bottomRight.lat, lon: opts.bottomRight.lng },
},
},
},
{ exists: { field: 'geoPoint' } },
],
},
},
@@ -97,20 +74,23 @@ export async function searchMembersInBoundingBox(opts: {
});
if (!response.ok) {
throw new Error(`Cesium+ search → HTTP ${response.status}`);
throw new Error(`Cesium+ HTTP ${response.status}`);
}
const raw = await response.json();
const parsed = CesiumSearchResponseSchema.parse(raw);
const parsed = SearchResponseSchema.parse(raw);
return parsed.hits.hits
.filter((h): h is CesiumProfile & { _source: { geoPoint: NonNullable<CesiumProfile['_source']['geoPoint']> } } =>
h._source.geoPoint !== undefined
)
.map((h) => ({
pubkey: h._id,
city: h._source.city ?? 'Inconnue',
lat: h._source.geoPoint.lat,
lng: h._source.geoPoint.lon,
}));
const result = new Map<string, GeoProfile>();
for (const hit of parsed.hits.hits) {
const src = hit._source;
if (src.geoPoint && src.title) {
result.set(src.title, {
name: src.title,
city: src.city ?? 'Inconnue',
lat: src.geoPoint.lat,
lng: src.geoPoint.lon,
});
}
}
return result;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* SubsquidAdapter — interroge l'indexeur Ğ1v2 via GraphQL (PostgREST/Hasura style).
* Endpoint : https://squidv2s.syoul.fr/v1/graphql
*
* Schéma réel vérifié par introspection :
* - transfers { id, blockNumber, timestamp (Datetime ISO), amount (BigInt string),
* fromId, toId, from { linkedIdentity { name } } }
* - Filtre : TransferFilter.timestamp.greaterThanOrEqualTo (Datetime ISO)
* - Tri : TransfersOrderBy (TIMESTAMP_DESC)
* - Montant: BigInt en string, diviser par 100 pour obtenir des Ğ1
* - Clés : format SS58 Ğ1v2, ~50 chars, préfixe "g1"
*/
import { z } from 'zod';
export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql';
// ---------------------------------------------------------------------------
// Schéma de validation Zod pour la réponse brute Subsquid
// ---------------------------------------------------------------------------
const SubsquidTransferNodeSchema = z.object({
id: z.string(),
blockNumber: z.number().int().positive(),
timestamp: z.string(), // ISO 8601 ex: "2026-03-22T14:53:36+00:00"
amount: z.string(), // BigInt en string, en centimes Ğ1
fromId: z.string().nullable(),
toId: z.string().nullable(),
from: z.object({
linkedIdentity: z.object({ name: z.string() }).nullable(),
}).nullable(),
});
const SubsquidResponseSchema = z.object({
data: z.object({
transfers: z.object({
nodes: z.array(SubsquidTransferNodeSchema),
}),
}),
});
export type SubsquidTransferRaw = z.infer<typeof SubsquidTransferNodeSchema>;
// ---------------------------------------------------------------------------
// Type intermédiaire : transfer enrichi avec nom d'identité
// ---------------------------------------------------------------------------
export interface RawTransfer {
id: string;
timestamp: number; // Unix ms
amount: number; // en Ğ1 (divisé par 100)
fromId: string;
toId: string;
fromName: string; // nom d'identité Ğ1 de l'émetteur (peut être vide)
}
// ---------------------------------------------------------------------------
// Query
// ---------------------------------------------------------------------------
const TRANSFERS_QUERY = `
query GetTransfers($since: Datetime!, $limit: Int!) {
transfers(
orderBy: TIMESTAMP_DESC
first: $limit
filter: { timestamp: { greaterThanOrEqualTo: $since } }
) {
nodes {
id
blockNumber
timestamp
amount
fromId
toId
from {
linkedIdentity {
name
}
}
}
}
}
`;
export async function fetchTransfers(
periodDays: number,
limit = 2000
): Promise<RawTransfer[]> {
const since = new Date(
Date.now() - periodDays * 24 * 60 * 60 * 1000
).toISOString();
const response = await fetch(SUBSQUID_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: TRANSFERS_QUERY,
variables: { since, limit },
}),
});
if (!response.ok) {
throw new Error(`Subsquid HTTP ${response.status}: ${response.statusText}`);
}
const raw = await response.json();
if (raw.errors?.length) {
throw new Error(`Subsquid GraphQL: ${raw.errors[0].message}`);
}
const parsed = SubsquidResponseSchema.parse(raw);
return parsed.data.transfers.nodes.map((node) => ({
id: node.id,
timestamp: new Date(node.timestamp).getTime(),
amount: parseInt(node.amount, 10) / 100,
fromId: node.fromId ?? '',
toId: node.toId ?? '',
fromName: node.from?.linkedIdentity?.name ?? '',
}));
}