From 726cd8c5b0bc01e6127a27be62250d743ad84c7e Mon Sep 17 00:00:00 2001 From: centboy123 Date: Mon, 1 Jun 2026 00:34:48 +0000 Subject: [PATCH] Issue #26: Add monitored_accounts and activity_events migrations, Stellar scanner, types and admin UI skeleton --- .env.example | 1 + src/@types/activity.ts | 34 ++++++++++++ src/app/admin/(protected)/activity/page.tsx | 14 +++++ .../modules/admin/activity/ActivityFeed.tsx | 17 ++++++ src/lib/stellar/index.ts | 43 +++++++++++++++ supabase/functions/activity_scanner/index.ts | 55 +++++++++++++++++++ .../migrations/0003_monitored_accounts.sql | 26 +++++++++ supabase/migrations/0004_activity_events.sql | 32 +++++++++++ 8 files changed, 222 insertions(+) create mode 100644 src/@types/activity.ts create mode 100644 src/app/admin/(protected)/activity/page.tsx create mode 100644 src/components/modules/admin/activity/ActivityFeed.tsx create mode 100644 src/lib/stellar/index.ts create mode 100644 supabase/functions/activity_scanner/index.ts create mode 100644 supabase/migrations/0003_monitored_accounts.sql create mode 100644 supabase/migrations/0004_activity_events.sql diff --git a/.env.example b/.env.example index 60f9701..db4942c 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,4 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY= # Server-only — used by scripts, seeders and admin mutations. # NEVER expose this to the browser. SUPABASE_SERVICE_ROLE_KEY= +STELLAR_HORIZON_URL=https://horizon.stellar.org diff --git a/src/@types/activity.ts b/src/@types/activity.ts new file mode 100644 index 0000000..ba16ea2 --- /dev/null +++ b/src/@types/activity.ts @@ -0,0 +1,34 @@ +export type EventType = + | 'contract_deploy' + | 'payment' + | 'trust_change' + | 'contract_invoke' + | 'data_entry' + | 'account_create'; + +export type Significance = 'low' | 'medium' | 'high' | 'critical'; + +export interface MonitoredAccount { + id: string; + stellar_address: string; + label: string; + account_type: string; + monitor_events: string[]; + active: boolean; + last_cursor?: string | null; + created_at: string; +} + +export interface ActivityEvent { + id: string; + account_id: string; + source_account: string; + event_type: EventType; + significance: Significance; + raw_data: any; + summary?: string; + processed: boolean; + draft_article_id?: string | null; + detected_at: string; + tx_hash?: string | null; +} diff --git a/src/app/admin/(protected)/activity/page.tsx b/src/app/admin/(protected)/activity/page.tsx new file mode 100644 index 0000000..cbd6504 --- /dev/null +++ b/src/app/admin/(protected)/activity/page.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { ActivityFeed } from '@/components/modules/admin/activity/ActivityFeed'; + +export default async function ActivityPage() { + const supabase = createAdminClient(); + const { data: events } = await supabase.from('activity_events').select('*').order('detected_at', { ascending: false }).limit(50); + return ( +
+

Admin — Activity

+ +
+ ); +} diff --git a/src/components/modules/admin/activity/ActivityFeed.tsx b/src/components/modules/admin/activity/ActivityFeed.tsx new file mode 100644 index 0000000..d25b8c3 --- /dev/null +++ b/src/components/modules/admin/activity/ActivityFeed.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import type { ActivityEvent } from '@/@types/activity'; + +export function ActivityFeed({ events }: { events: ActivityEvent[] }) { + return ( +
+

Activity Feed

+
    + {events.map((e) => ( +
  • + {e.event_type} — {e.summary} — {e.significance} +
  • + ))} +
+
+ ); +} diff --git a/src/lib/stellar/index.ts b/src/lib/stellar/index.ts new file mode 100644 index 0000000..f56aa78 --- /dev/null +++ b/src/lib/stellar/index.ts @@ -0,0 +1,43 @@ +import fetch from 'node-fetch'; + +export interface HorizonOperation { + id: string; + paging_token: string; + type: string; + transaction_hash?: string; + source_account?: string; + [key: string]: any; +} + +export async function fetchOperationsSince( + horizonUrl: string, + account: string, + cursor?: string +): Promise<{ operations: HorizonOperation[]; nextCursor?: string }> { + const url = new URL(`${horizonUrl.replace(/\/$/, '')}/accounts/${account}/operations`); + url.searchParams.set('limit', '200'); + url.searchParams.set('order', 'asc'); + if (cursor) url.searchParams.set('cursor', cursor); + + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`Horizon fetch failed: ${res.status}`); + const data = await res.json(); + const ops: HorizonOperation[] = (data._embedded?.records || []).map((r: any) => r as HorizonOperation); + const nextCursor = ops.length ? ops[ops.length - 1].paging_token : undefined; + return { operations: ops, nextCursor }; +} + +export function classifyOperation(op: HorizonOperation): { eventType: string; significance: string; summary?: string } { + const t = op.type; + if (t === 'create_account') return { eventType: 'account_create', significance: 'low', summary: `Account created: ${op.account}` }; + if (t === 'payment' || t === 'payment_strict_receive') { + const amount = op.amount || op.value || '0'; + const significance = Number(amount) > 100 ? 'high' : 'medium'; + return { eventType: 'payment', significance, summary: `Payment ${amount} ${op.asset_type || 'XLM'}` }; + } + if (t === 'change_trust') return { eventType: 'trust_change', significance: 'medium', summary: `Trustline change` }; + if (t === 'manage_data') return { eventType: 'data_entry', significance: 'low', summary: `Data entry updated` }; + if (t === 'invoke_host_function' || t === 'invoke_contract') return { eventType: 'contract_invoke', significance: 'high', summary: `Contract invoked` }; + if (t === 'deploy_contract') return { eventType: 'contract_deploy', significance: 'high', summary: `Contract deployed` }; + return { eventType: t, significance: 'low', summary: `Operation ${t}` }; +} diff --git a/supabase/functions/activity_scanner/index.ts b/supabase/functions/activity_scanner/index.ts new file mode 100644 index 0000000..d49050c --- /dev/null +++ b/supabase/functions/activity_scanner/index.ts @@ -0,0 +1,55 @@ +import { createAdminClient } from '../../../src/lib/supabase/admin'; +import { fetchOperationsSince, classifyOperation } from '../../../src/lib/stellar'; + +const HORIZON_URL = process.env.STELLAR_HORIZON_URL || 'https://horizon.stellar.org'; + +export default async function handler() { + const supabase = createAdminClient(); + + // fetch active monitored accounts + const { data: accounts } = await supabase.from('monitored_accounts').select('*').eq('active', true); + if (!accounts) return { processed: 0 }; + + let processed = 0; + + for (const acct of accounts) { + try { + const { operations, nextCursor } = await fetchOperationsSince(HORIZON_URL, acct.stellar_address, acct.last_cursor || undefined); + for (const op of operations) { + const classification = classifyOperation(op as any); + // basic dedupe: by tx_hash + const txHash = (op as any).transaction_hash || null; + const { data: existing } = await supabase + .from('activity_events') + .select('id') + .eq('tx_hash', txHash) + .limit(1); + if (existing && existing.length) continue; + + await supabase.from('activity_events').insert([ + { + account_id: acct.id, + source_account: op.source_account || acct.stellar_address, + event_type: classification.eventType, + significance: classification.significance, + raw_data: op, + summary: classification.summary, + tx_hash: txHash, + detected_at: new Date().toISOString(), + }, + ]); + processed++; + } + + // update cursor if we got one + if (nextCursor) { + await supabase.from('monitored_accounts').update({ last_cursor: nextCursor }).eq('id', acct.id); + } + } catch (err) { + // log and continue + console.error('scan error', acct.stellar_address, err); + } + } + + return { processed }; +} diff --git a/supabase/migrations/0003_monitored_accounts.sql b/supabase/migrations/0003_monitored_accounts.sql new file mode 100644 index 0000000..716c8fb --- /dev/null +++ b/supabase/migrations/0003_monitored_accounts.sql @@ -0,0 +1,26 @@ +-- ============================================================================= +-- Add monitored_accounts table for automated activity monitoring +-- ============================================================================= + +create table if not exists public.monitored_accounts ( + id uuid primary key default gen_random_uuid(), + stellar_address text unique not null, + label text not null, + account_type text not null, + monitor_events text[] not null default '{}', + active boolean not null default true, + last_cursor text, + created_at timestamptz not null default now() +); + +create index if not exists monitored_accounts_stellar_address_idx + on public.monitored_accounts (stellar_address); + +alter table public.monitored_accounts enable row level security; + +create policy "admins can manage monitored accounts" + on public.monitored_accounts + for all + to authenticated + using (public.is_admin()) + with check (public.is_admin()); diff --git a/supabase/migrations/0004_activity_events.sql b/supabase/migrations/0004_activity_events.sql new file mode 100644 index 0000000..0c49761 --- /dev/null +++ b/supabase/migrations/0004_activity_events.sql @@ -0,0 +1,32 @@ +-- ============================================================================= +-- Add activity_events table to store detected Stellar activity +-- ============================================================================= + +create table if not exists public.activity_events ( + id uuid primary key default gen_random_uuid(), + account_id uuid not null references public.monitored_accounts(id) on delete cascade, + source_account text not null, + event_type text not null, + significance text not null, + raw_data jsonb not null default '{}'::jsonb, + summary text, + processed boolean not null default false, + draft_article_id uuid, + detected_at timestamptz not null default now(), + tx_hash text +); + +create index if not exists activity_events_account_id_detected_at_idx + on public.activity_events (account_id, detected_at desc); + +create index if not exists activity_events_processed_idx + on public.activity_events (processed); + +alter table public.activity_events enable row level security; + +create policy "admins can manage activity events" + on public.activity_events + for all + to authenticated + using (public.is_admin()) + with check (public.is_admin());