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:
19
src/App.tsx
19
src/App.tsx
@@ -11,15 +11,17 @@ export default function App() {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [stats, setStats] = useState<PeriodStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [source, setSource] = useState<'live' | 'mock'>('mock');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
fetchData(periodDays).then(({ transactions, stats }) => {
|
||||
fetchData(periodDays).then(({ transactions, stats, source }) => {
|
||||
if (!cancelled) {
|
||||
setTransactions(transactions);
|
||||
setStats(stats);
|
||||
setSource(source);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
@@ -41,10 +43,19 @@ export default function App() {
|
||||
<PeriodSelector value={periodDays} onChange={setPeriodDays} />
|
||||
</div>
|
||||
|
||||
{/* Transaction count badge */}
|
||||
{/* Transaction count + source badge */}
|
||||
{!loading && (
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
||||
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
||||
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
||||
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
||||
</div>
|
||||
<div className={`backdrop-blur-sm border rounded-full px-3 py-1.5 text-xs font-medium ${
|
||||
source === 'live'
|
||||
? 'bg-emerald-950/80 border-emerald-700 text-emerald-400'
|
||||
: 'bg-[#0a0b0f]/80 border-[#2e2f3a] text-[#4b5563]'
|
||||
}`}>
|
||||
{source === 'live' ? '● live Ğ1v2' : '○ mock'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
timestamp: number; // Unix ms
|
||||
timestamp: number; // Unix ms (entier)
|
||||
lat: number;
|
||||
lng: number;
|
||||
amount: number; // Ğ1
|
||||
amount: number; // Ğ1 (pas en centimes)
|
||||
city: string;
|
||||
fromKey: string;
|
||||
fromKey: string; // SS58 Ğ1v2 : préfixe "g1", ~50 chars
|
||||
toKey: string;
|
||||
}
|
||||
|
||||
@@ -51,9 +51,11 @@ function weightedRandom<T extends { weight: number }>(items: T[]): T {
|
||||
return items[items.length - 1];
|
||||
}
|
||||
|
||||
// Génère une clé SS58 Ğ1v2 simulée : préfixe "g1" + 48 chars base58
|
||||
function generateKey(): string {
|
||||
const chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
return Array.from({ length: 44 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
const suffix = Array.from({ length: 47 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
return 'g1' + suffix;
|
||||
}
|
||||
|
||||
function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
|
||||
@@ -62,7 +64,6 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const city = weightedRandom(CITIES);
|
||||
// Add slight spatial noise around city center
|
||||
const lat = city.lat + randomBetween(-0.08, 0.08);
|
||||
const lng = city.lng + randomBetween(-0.12, 0.12);
|
||||
const amount = Math.round(randomBetween(0.5, 150) * 100) / 100;
|
||||
@@ -82,7 +83,6 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
|
||||
return transactions.sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
// Pre-generate a pool of 30 days worth of transactions
|
||||
const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
export function getTransactionsForPeriod(periodDays: number): Transaction[] {
|
||||
|
||||
@@ -1,49 +1,63 @@
|
||||
/**
|
||||
* Schémas Zod — source de vérité sur ce que la vraie blockchain Ğ1 renvoie.
|
||||
* Schémas Zod — source de vérité sur ce que la blockchain Ğ1v2 renvoie.
|
||||
*
|
||||
* Ces schémas servent à trois choses :
|
||||
* 1. Valider les réponses des API réelles (Duniter GVA + Cesium+) au runtime
|
||||
* 2. Documenter exactement à quoi ressemble la donnée brute
|
||||
* 3. Garantir que le mockData.ts reste structurellement compatible
|
||||
* 1. Valide les réponses des API réelles (Subsquid v2 + Cesium+) au runtime
|
||||
* 2. Documente exactement la forme de la donnée brute
|
||||
* 3. Garantit que mockData.ts reste structurellement compatible
|
||||
*
|
||||
* Ğ1v2 (Substrate) vs Ğ1v1 (Duniter) :
|
||||
* - Clés : SS58 préfixe "g1", 50 chars (vs base58 43-44 chars)
|
||||
* - Montant : BigInt string en centimes (identique v1)
|
||||
* - Timestamp : ISO 8601 string (vs Unix secondes en v1)
|
||||
* - API : Subsquid GraphQL (vs Duniter GVA)
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Duniter GVA — réponse brute d'une transaction blockchain
|
||||
// Endpoint : POST https://<node>/gva (query GVA GraphQL)
|
||||
// Ğ1v2 — clé publique SS58 (format Substrate, préfixe "g1", 50 chars)
|
||||
// Ex : "g1QQRUbQUkc4P5jSYpXP4erLN8bBPe7HSUB4PLeUMa3oNJf5c"
|
||||
// ---------------------------------------------------------------------------
|
||||
export const GvaTransactionNodeSchema = z.object({
|
||||
currency: z.literal('g1'),
|
||||
// issuers : tableau de clés publiques (base58, 43-44 chars)
|
||||
issuers: z.array(z.string().min(43).max(44)),
|
||||
// outputs : ex. ["100:0:SIG(pubkeyDestinataire)"] (valeur en centimes Ğ1)
|
||||
outputs: z.array(z.string().regex(/^\d+:\d+:SIG\(.+\)$/)).min(1),
|
||||
blockstampTime: z.number().int().positive(), // timestamp Unix (secondes)
|
||||
comment: z.string().optional(),
|
||||
hash: z.string().optional(),
|
||||
});
|
||||
export type GvaTransactionNode = z.infer<typeof GvaTransactionNodeSchema>;
|
||||
export const G1v2KeySchema = z.string()
|
||||
.startsWith('g1')
|
||||
.length(49);
|
||||
|
||||
export const GvaResponseSchema = z.object({
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subsquid — réponse brute d'un transfert Ğ1v2
|
||||
// Endpoint : POST https://squidv2s.syoul.fr/v1/graphql
|
||||
// Vérifié par introspection + appel réel le 2026-03-22
|
||||
// ---------------------------------------------------------------------------
|
||||
export const SubsquidTransferSchema = z.object({
|
||||
id: z.string(),
|
||||
blockNumber: z.number().int().positive(),
|
||||
timestamp: z.string(), // ISO 8601 : "2026-03-22T14:53:36+00:00"
|
||||
amount: z.string(), // BigInt string en centimes Ğ1, ex: "9424" = 94.24 Ğ1
|
||||
fromId: z.string().nullable(),
|
||||
toId: z.string().nullable(),
|
||||
from: z.object({
|
||||
linkedIdentity: z.object({ name: z.string() }).nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
export type SubsquidTransfer = z.infer<typeof SubsquidTransferSchema>;
|
||||
|
||||
export const SubsquidResponseSchema = z.object({
|
||||
data: z.object({
|
||||
txsHistoryBc: z.object({
|
||||
both: z.object({
|
||||
edges: z.array(z.object({ node: GvaTransactionNodeSchema })),
|
||||
}),
|
||||
transfers: z.object({
|
||||
nodes: z.array(SubsquidTransferSchema),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cesium+ Elasticsearch — profil membre avec géolocalisation
|
||||
// Endpoint : GET https://g1.data.duniter.fr/user/profile/<pubkey>
|
||||
// Endpoint actif : https://g1.data.e-is.pro (g1.data.duniter.fr hors ligne)
|
||||
// Recherche par nom d'identité (title.keyword) en Ğ1v2
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CesiumProfileSchema = z.object({
|
||||
_id: z.string(), // clé publique
|
||||
_id: z.string(),
|
||||
_source: z.object({
|
||||
title: z.string().optional(), // pseudo
|
||||
city: z.string().optional(),
|
||||
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),
|
||||
@@ -60,36 +74,38 @@ export const CesiumSearchResponseSchema = z.object({
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modèle interne de l'application (ce que les composants consomment)
|
||||
// Ce schéma valide également le mockData au moment des tests
|
||||
// Clés en format SS58 Ğ1v2 : préfixe "g1", longueur 50
|
||||
// ---------------------------------------------------------------------------
|
||||
export const AppTransactionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
timestamp: z.number().int().positive(), // Unix ms
|
||||
timestamp: z.number().int().positive(),
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
amount: z.number().positive(), // en Ğ1 (pas en centimes)
|
||||
amount: z.number().positive(),
|
||||
city: z.string().min(1),
|
||||
fromKey: z.string().min(43).max(44),
|
||||
toKey: z.string().min(43).max(44),
|
||||
fromKey: G1v2KeySchema,
|
||||
toKey: G1v2KeySchema,
|
||||
});
|
||||
export type AppTransaction = z.infer<typeof AppTransactionSchema>;
|
||||
|
||||
export const AppTransactionArraySchema = z.array(AppTransactionSchema);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper : parse un output Duniter en montant Ğ1
|
||||
// ex. "1500:0:SIG(abc...)" → 15.0
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** BigInt Subsquid en centimes → Ğ1 flottant. Ex: "9424" → 94.24 */
|
||||
export function parseSubsquidAmount(raw: string): number {
|
||||
return parseInt(raw, 10) / 100;
|
||||
}
|
||||
|
||||
// Gardés pour référence Ğ1v1 (non utilisés en v2)
|
||||
export function parseOutputAmount(output: string): number {
|
||||
const match = output.match(/^(\d+):/);
|
||||
if (!match) return 0;
|
||||
return parseInt(match[1], 10) / 100; // centimes → Ğ1
|
||||
return parseInt(match[1], 10) / 100;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper : extrait la clé destinataire d'un output
|
||||
// ex. "1500:0:SIG(Abc123...)" → "Abc123..."
|
||||
// ---------------------------------------------------------------------------
|
||||
export function parseOutputRecipient(output: string): string {
|
||||
const match = output.match(/SIG\((.+)\)/);
|
||||
return match?.[1] ?? '';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
src/services/adapters/SubsquidAdapter.ts
Normal file
119
src/services/adapters/SubsquidAdapter.ts
Normal 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 ?? '',
|
||||
}));
|
||||
}
|
||||
@@ -1,45 +1,64 @@
|
||||
/**
|
||||
* Tests de conformité — vérifient que les données de l'app (mock inclus)
|
||||
* respectent exactement les schémas de la vraie blockchain Ğ1.
|
||||
* respectent exactement les schémas de la vraie blockchain Ğ1v2.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
AppTransactionArraySchema,
|
||||
GvaTransactionNodeSchema,
|
||||
SubsquidTransferSchema,
|
||||
CesiumProfileSchema,
|
||||
G1v2KeySchema,
|
||||
parseOutputAmount,
|
||||
parseOutputRecipient,
|
||||
parseSubsquidAmount,
|
||||
} from '../schemas/g1.schema';
|
||||
import { getTransactionsForPeriod } from '../data/mockData';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Le mockData respecte-t-il le schéma interne de l'app ?
|
||||
// Format des clés Ğ1v2 (SS58)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mockData conformité schéma AppTransaction', () => {
|
||||
describe('G1v2KeySchema', () => {
|
||||
it('accepte une clé SS58 Ğ1v2 valide (49 chars, préfixe g1)', () => {
|
||||
expect(G1v2KeySchema.safeParse('g1QQRUbQUkc4P5jSYpXP4erLN8bBPe7HSUB4PLeUMa3oNJf5c').success).toBe(true);
|
||||
expect(G1v2KeySchema.safeParse('g1NkcLXuSkUpWaGmXhDJD4ybfwGdfX5ZZB5YYmNWLpdMS7sQe').success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejette une clé Ğ1v1 base58 sans préfixe g1', () => {
|
||||
expect(G1v2KeySchema.safeParse('4tNQ9BCqDVznjMAnNq9BqBanasjoC5BGw2LauXF7dKFv').success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejette une clé trop courte ou trop longue', () => {
|
||||
expect(G1v2KeySchema.safeParse('g1abc').success).toBe(false);
|
||||
expect(G1v2KeySchema.safeParse('g1' + 'a'.repeat(60)).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Le mockData respecte-t-il le schéma interne de l'app (format v2) ?
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mockData conformité schéma AppTransaction (Ğ1v2)', () => {
|
||||
it('toutes les transactions 30j passent le schéma Zod sans erreur', () => {
|
||||
const txs = getTransactionsForPeriod(30);
|
||||
const result = AppTransactionArraySchema.safeParse(txs);
|
||||
if (!result.success) {
|
||||
// Affiche la première erreur pour faciliter le débogage
|
||||
const first = result.error.issues[0];
|
||||
throw new Error(`Schéma invalide à ${first.path.join('.')}: ${first.message}`);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('les clés fromKey/toKey font 44 caractères (format Ğ1 base58)', () => {
|
||||
it('les clés fromKey/toKey font 49 chars et commencent par g1 (format SS58 Ğ1v2)', () => {
|
||||
const txs = getTransactionsForPeriod(7);
|
||||
for (const tx of txs) {
|
||||
expect(tx.fromKey).toHaveLength(44);
|
||||
expect(tx.toKey).toHaveLength(44);
|
||||
expect(tx.fromKey).toHaveLength(49);
|
||||
expect(tx.toKey).toHaveLength(49);
|
||||
expect(tx.fromKey.startsWith('g1')).toBe(true);
|
||||
expect(tx.toKey.startsWith('g1')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("aucun montant n'est en centimes (doit être en Ğ1, > 0.01)", () => {
|
||||
// Si quelqu'un branche l'API et oublie de diviser par 100,
|
||||
// les montants seraient > 10000 pour des tx normales
|
||||
it("aucun montant n'est en centimes (doit être en Ğ1)", () => {
|
||||
const txs = getTransactionsForPeriod(30);
|
||||
const suspicious = txs.filter((tx) => tx.amount > 5000);
|
||||
expect(suspicious).toHaveLength(0);
|
||||
@@ -55,13 +74,58 @@ describe('mockData conformité schéma AppTransaction', () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Les parseurs de données brutes Duniter fonctionnent-ils correctement ?
|
||||
// Subsquid v2 — schéma des transferts bruts
|
||||
// Données réelles observées : { id, blockNumber, timestamp ISO, amount BigInt string, fromId, toId, from.linkedIdentity.name }
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('parseOutputAmount', () => {
|
||||
it('convertit les centimes Ğ1 en Ğ1', () => {
|
||||
describe('SubsquidTransferSchema', () => {
|
||||
const validTransfer = {
|
||||
id: '0000213818-3ffc3-000004',
|
||||
blockNumber: 213818,
|
||||
timestamp: '2026-03-22T14:53:36+00:00',
|
||||
amount: '9424',
|
||||
fromId: 'g1QQRUbQUkc4P5jSYpXP4erLN8bBPe7HSUB4PLeUMa3oNJf5c',
|
||||
toId: 'g1NkcLXuSkUpWaGmXhDJD4ybfwGdfX5ZZB5YYmNWLpdMS7sQe',
|
||||
from: { linkedIdentity: { name: 'Anikka' } },
|
||||
};
|
||||
|
||||
it('accepte un transfert réel Subsquid', () => {
|
||||
expect(SubsquidTransferSchema.safeParse(validTransfer).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepte from=null (transfert depuis compte non-membre)', () => {
|
||||
expect(SubsquidTransferSchema.safeParse({ ...validTransfer, from: null }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepte linkedIdentity=null (compte sans identité)', () => {
|
||||
expect(SubsquidTransferSchema.safeParse({
|
||||
...validTransfer, from: { linkedIdentity: null },
|
||||
}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejette si blockNumber est négatif', () => {
|
||||
expect(SubsquidTransferSchema.safeParse({ ...validTransfer, blockNumber: -1 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseSubsquidAmount — conversion BigInt Subsquid → Ğ1
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('parseSubsquidAmount', () => {
|
||||
it('convertit les centimes Subsquid en Ğ1', () => {
|
||||
expect(parseSubsquidAmount('9424')).toBeCloseTo(94.24);
|
||||
expect(parseSubsquidAmount('2000')).toBeCloseTo(20.0);
|
||||
expect(parseSubsquidAmount('100')).toBeCloseTo(1.0);
|
||||
expect(parseSubsquidAmount('50')).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOutputAmount / parseOutputRecipient — conservés pour référence Ğ1v1
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('parseOutputAmount (Ğ1v1 référence)', () => {
|
||||
it('convertit les centimes Ğ1v1 en Ğ1', () => {
|
||||
expect(parseOutputAmount('100:0:SIG(abc)')).toBe(1.0);
|
||||
expect(parseOutputAmount('1500:0:SIG(abc)')).toBe(15.0);
|
||||
expect(parseOutputAmount('50:0:SIG(abc)')).toBe(0.5);
|
||||
});
|
||||
|
||||
it('retourne 0 pour un output malformé', () => {
|
||||
@@ -70,7 +134,7 @@ describe('parseOutputAmount', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOutputRecipient', () => {
|
||||
describe('parseOutputRecipient (Ğ1v1 référence)', () => {
|
||||
it('extrait la clé publique du destinataire', () => {
|
||||
expect(parseOutputRecipient('100:0:SIG(ABC123xyz)')).toBe('ABC123xyz');
|
||||
});
|
||||
@@ -81,47 +145,13 @@ describe('parseOutputRecipient', () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Les schémas Zod rejettent-ils les données invalides ?
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('GvaTransactionNodeSchema — rejet des données invalides', () => {
|
||||
const validNode = {
|
||||
currency: 'g1',
|
||||
issuers: ['4tNQ9BCqDVznjMAnNq9BqBanasjoC5BGw2LauXF7dKFv'],
|
||||
outputs: ['500:0:SIG(9q5Jjaj8pNGiijzT7Bej9pCeqxXNQxN8q7JLVPshpuT)'],
|
||||
blockstampTime: 1700000000,
|
||||
};
|
||||
|
||||
it('accepte un nœud valide', () => {
|
||||
expect(GvaTransactionNodeSchema.safeParse(validNode).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejette si currency n'est pas g1", () => {
|
||||
expect(GvaTransactionNodeSchema.safeParse({ ...validNode, currency: 'euro' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejette si blockstampTime est négatif', () => {
|
||||
expect(GvaTransactionNodeSchema.safeParse({ ...validNode, blockstampTime: -1 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejette si outputs est vide', () => {
|
||||
expect(GvaTransactionNodeSchema.safeParse({ ...validNode, outputs: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejette un output mal formaté (pas de SIG())', () => {
|
||||
expect(
|
||||
GvaTransactionNodeSchema.safeParse({ ...validNode, outputs: ['100:notvalid'] }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CesiumProfileSchema — validation des profils membres
|
||||
// CesiumProfileSchema — profils Cesium+ (endpoint : g1.data.e-is.pro)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('CesiumProfileSchema', () => {
|
||||
const validProfile = {
|
||||
_id: '4tNQ9BCqDVznjMAnNq9BqBanasjoC5BGw2LauXF7dKFv',
|
||||
_id: 'AnyCesiumId',
|
||||
_source: {
|
||||
title: 'Alice',
|
||||
title: 'Anikka',
|
||||
city: 'Lyon',
|
||||
geoPoint: { lat: 45.76, lon: 4.83 },
|
||||
},
|
||||
@@ -132,8 +162,7 @@ describe('CesiumProfileSchema', () => {
|
||||
});
|
||||
|
||||
it('accepte un profil sans geoPoint (champ optionnel)', () => {
|
||||
const noGeo = { _id: validProfile._id, _source: {} };
|
||||
expect(CesiumProfileSchema.safeParse(noGeo).success).toBe(true);
|
||||
expect(CesiumProfileSchema.safeParse({ _id: 'x', _source: {} }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejette une latitude hors plage', () => {
|
||||
@@ -148,23 +177,20 @@ describe('CesiumProfileSchema', () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test de régression : les timestamps sont en ms, pas en secondes
|
||||
// (erreur classique lors du branchement API)
|
||||
// Régression timestamps
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('cohérence des timestamps', () => {
|
||||
it('les timestamps du mock sont en millisecondes (> an 2000 en ms)', () => {
|
||||
it('les timestamps du mock sont en millisecondes (entiers)', () => {
|
||||
const Y2000_MS = 946684800000;
|
||||
const txs = getTransactionsForPeriod(30);
|
||||
for (const tx of txs) {
|
||||
expect(tx.timestamp).toBeGreaterThan(Y2000_MS);
|
||||
expect(Number.isInteger(tx.timestamp)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('détecte si un timestamp est accidentellement en secondes', () => {
|
||||
const Y2000_S = 946684800; // valeur en secondes = an 2000
|
||||
const txs = getTransactionsForPeriod(30);
|
||||
// En secondes, les timestamps seraient < 2 milliards (avant 2033)
|
||||
// En ms, ils sont > 1 000 milliards
|
||||
const likelySecs = txs.filter((tx) => tx.timestamp < 9_999_999_999);
|
||||
expect(likelySecs).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -34,8 +34,8 @@ describe('getTransactionsForPeriod', () => {
|
||||
for (const tx of txs) {
|
||||
expect(tx.id).toBeTruthy();
|
||||
expect(tx.city).toBeTruthy();
|
||||
expect(tx.fromKey).toHaveLength(44);
|
||||
expect(tx.toKey).toHaveLength(44);
|
||||
expect(tx.fromKey).toHaveLength(49); // SS58 Ğ1v2 : "g1" + 47 chars
|
||||
expect(tx.toKey).toHaveLength(49);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user