diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9e38edb..fbfa484 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,6 +49,8 @@ | `is_owner(address)` | Checks ownership for a connected wallet | Wallet-connected gating | | `has_approved(proposal_id, owner)` | Per-owner approval flag | Approval bar UI | +See [EVENTS.md](./EVENTS.md) for a full catalog of events emitted by the contract and parsed by the off-chain indexer. + ## 3. Storage Layout (Soroban) ### Instance Storage (low-cost, short TTL) diff --git a/docs/EVENTS.md b/docs/EVENTS.md new file mode 100644 index 0000000..5321e84 --- /dev/null +++ b/docs/EVENTS.md @@ -0,0 +1,33 @@ +# Accord Protocol Event Catalog + +This document outlines all the custom events emitted by the Accord Protocol contract (`contracts/accord/src/lib.rs`). + +The events are mapped by their primary topic (a `symbol_short!` or `Symbol`) to their payload struct. + +| Topic Name | Struct Name | Payload Description | +|----------------|----------------------------------|---------------------| +| `migrated` | `GovernanceMigratedEvent` | `{ owner_count: u32, total_weight: u32 }` | +| `rbac_migrated`| `RbacMigratedEvent` | `{ owner_count: u32, role_version: u32 }` | +| `created` | `ProposalCreatedEvent` | `{ id: u64, proposer: Address, threshold: u32, category: ProposalCategory, transfers: Vec, quorum_weight: u32, total_weight_at_creation: u32 }` | +| `rpay` | `RecurringPaymentDisbursedEvent` | `{ schedule_id: u64, recipient: Address, token: Address, amount: i128, total_disbursed: i128, periods_disbursed: u32 }` | +| `approved` | `ProposalApprovedEvent` | `{ id: u64, approver: Address, approvals: u32, threshold: u32, weight: u32, cumulative_weight: u32 }` | +| `revoked` | `ProposalRevokedEvent` | `{ id: u64, approver: Address, approvals: u32, weight: u32, cumulative_weight: u32 }` | +| `executed` | `ProposalExecutedEvent` | `{ id: u64, executor: Address, transfers: Vec }` | +| `guard_set` | `GuardianSetEvent` | `{ guardian: Address }` | +| `frozen` | `FrozenEvent` | `{ guardian: Address }` | +| `unfrozen` | `UnfrozenEvent` | `{ approvers: Vec
}` | +| `r_pause` | `RecurringPaymentPausedEvent` | `{ id: u64, caller: Address }` | +| `r_resum` | `RecurringPaymentResumedEvent` | `{ id: u64, caller: Address }` | +| `r_mod` | `RecurringPaymentModifiedEvent` | `{ schedule_id: u64, previous_amount: i128, new_amount: i128, previous_interval: u64, new_interval: u64, previous_end_time: u64, new_end_time: u64 }` | +| `r_crt` | `RecurringPaymentCreatedEvent` | `{ id: u64, proposer: Address, recipient: Address, token: Address, amount: i128, interval_secs: u64, start_time: u64, end_time: u64, cliff_time: u64, total_cap: i128, kind: RecurringKind }` | +| `r_cncl` | `RecurringPaymentCancelledEvent` | `{ id: u64, caller: Address }` | +| `upgraded` | `UpgradeExecutedEvent` | `{ caller: Address, new_wasm_hash: BytesN<32> }` | +| `a_own` | `AddOwnerExecutedEvent` | `{ new_owner: Address, owner_count: u32 }` | +| `r_own` | `RemoveOwnerExecutedEvent` | `{ removed_owner: Address, owner_count: u32 }` | +| `c_thr` | `ChangeThresholdExecutedEvent` | `{ previous_threshold: u32, new_threshold: u32 }` | +| `s_lim` | `SetSpendingLimitExecutedEvent` | `{ owner: Address, token: Address, previous_limit: Option, new_limit: i128 }` | +| `c_wgt` | `OwnerWeightChangedEvent` | `{ owner: Address, old_weight: u32, new_weight: u32, new_total_weight: u32 }` | +| `role_granted` | `RoleGrantedEvent` | `{ target: Address, role: Role, before: Vec, after: Vec }` | +| `role_revoked` | `RoleRevokedEvent` | `{ target: Address, role: Role, before: Vec, after: Vec }` | + +This catalog is loaded by the off-chain indexer to decode raw contract events deterministically. diff --git a/indexer/README.md b/indexer/README.md new file mode 100644 index 0000000..8d7ccde --- /dev/null +++ b/indexer/README.md @@ -0,0 +1,20 @@ +# Accord Protocol Indexer + +The indexer is a standalone Node.js service that polls the Soroban RPC for events emitted by the Accord Protocol contract and its configured tokens. + +## Features + +- **Token Transfers**: Indexes `transfer` events from configured tokens to/from the treasury address, tracking balances natively. +- **Accord Protocol Events**: Indexes core protocol events (proposals, roles, thresholds) using the typed catalog and exposes them decoded. +- **Reconciliation**: Periodically reads on-chain token balances to detect drift between the indexed state and the live ledger state. + +## Architecture + +The indexer persists events in a local SQLite database (`data/treasury.sqlite` by default). It maintains state (like `next_ledger` and `event_cursor`) across restarts to ensure no events are missed. + +## Endpoints + +- `GET /health` - Service health and current ledger state. +- `GET /ledger?token=XLM&limit=100` - Query historical treasury token transfer events. +- `GET /events?topic=created&limit=100` - Query decoded Accord Protocol events. +- `GET /reconciliation` - View latest token balance reconciliation checks. diff --git a/indexer/src/catalog.js b/indexer/src/catalog.js new file mode 100644 index 0000000..d430d65 --- /dev/null +++ b/indexer/src/catalog.js @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const catalogPath = join(__dirname, 'catalog.json'); +export const eventCatalog = JSON.parse(readFileSync(catalogPath, 'utf8')); + +export function getEventSchema(topic) { + return eventCatalog[topic] || null; +} diff --git a/indexer/src/catalog.json b/indexer/src/catalog.json new file mode 100644 index 0000000..83f3a4c --- /dev/null +++ b/indexer/src/catalog.json @@ -0,0 +1,199 @@ +{ + "migrated": { + "struct": "GovernanceMigratedEvent", + "fields": { + "owner_count": "u32", + "total_weight": "u32" + } + }, + "rbac_migrated": { + "struct": "RbacMigratedEvent", + "fields": { + "owner_count": "u32", + "role_version": "u32" + } + }, + "created": { + "struct": "ProposalCreatedEvent", + "fields": { + "id": "u64", + "proposer": "Address", + "threshold": "u32", + "category": "ProposalCategory", + "transfers": "Vec", + "quorum_weight": "u32", + "total_weight_at_creation": "u32" + } + }, + "rpay": { + "struct": "RecurringPaymentDisbursedEvent", + "fields": { + "schedule_id": "u64", + "recipient": "Address", + "token": "Address", + "amount": "i128", + "total_disbursed": "i128", + "periods_disbursed": "u32" + } + }, + "approved": { + "struct": "ProposalApprovedEvent", + "fields": { + "id": "u64", + "approver": "Address", + "approvals": "u32", + "threshold": "u32", + "weight": "u32", + "cumulative_weight": "u32" + } + }, + "revoked": { + "struct": "ProposalRevokedEvent", + "fields": { + "id": "u64", + "approver": "Address", + "approvals": "u32", + "weight": "u32", + "cumulative_weight": "u32" + } + }, + "executed": { + "struct": "ProposalExecutedEvent", + "fields": { + "id": "u64", + "executor": "Address", + "transfers": "Vec" + } + }, + "guard_set": { + "struct": "GuardianSetEvent", + "fields": { + "guardian": "Address" + } + }, + "frozen": { + "struct": "FrozenEvent", + "fields": { + "guardian": "Address" + } + }, + "unfrozen": { + "struct": "UnfrozenEvent", + "fields": { + "approvers": "Vec
" + } + }, + "r_pause": { + "struct": "RecurringPaymentPausedEvent", + "fields": { + "id": "u64", + "caller": "Address" + } + }, + "r_resum": { + "struct": "RecurringPaymentResumedEvent", + "fields": { + "id": "u64", + "caller": "Address" + } + }, + "r_mod": { + "struct": "RecurringPaymentModifiedEvent", + "fields": { + "schedule_id": "u64", + "previous_amount": "i128", + "new_amount": "i128", + "previous_interval": "u64", + "new_interval": "u64", + "previous_end_time": "u64", + "new_end_time": "u64" + } + }, + "r_crt": { + "struct": "RecurringPaymentCreatedEvent", + "fields": { + "id": "u64", + "proposer": "Address", + "recipient": "Address", + "token": "Address", + "amount": "i128", + "interval_secs": "u64", + "start_time": "u64", + "end_time": "u64", + "cliff_time": "u64", + "total_cap": "i128", + "kind": "RecurringKind" + } + }, + "r_cncl": { + "struct": "RecurringPaymentCancelledEvent", + "fields": { + "id": "u64", + "caller": "Address" + } + }, + "upgraded": { + "struct": "UpgradeExecutedEvent", + "fields": { + "caller": "Address", + "new_wasm_hash": "BytesN<32>" + } + }, + "a_own": { + "struct": "AddOwnerExecutedEvent", + "fields": { + "new_owner": "Address", + "owner_count": "u32" + } + }, + "r_own": { + "struct": "RemoveOwnerExecutedEvent", + "fields": { + "removed_owner": "Address", + "owner_count": "u32" + } + }, + "c_thr": { + "struct": "ChangeThresholdExecutedEvent", + "fields": { + "previous_threshold": "u32", + "new_threshold": "u32" + } + }, + "s_lim": { + "struct": "SetSpendingLimitExecutedEvent", + "fields": { + "owner": "Address", + "token": "Address", + "previous_limit": "Option", + "new_limit": "i128" + } + }, + "c_wgt": { + "struct": "OwnerWeightChangedEvent", + "fields": { + "owner": "Address", + "old_weight": "u32", + "new_weight": "u32", + "new_total_weight": "u32" + } + }, + "role_granted": { + "struct": "RoleGrantedEvent", + "fields": { + "target": "Address", + "role": "Role", + "before": "Vec", + "after": "Vec" + } + }, + "role_revoked": { + "struct": "RoleRevokedEvent", + "fields": { + "target": "Address", + "role": "Role", + "before": "Vec", + "after": "Vec" + } + } +} diff --git a/indexer/src/events.js b/indexer/src/events.js index 9fe8987..b341cd7 100644 --- a/indexer/src/events.js +++ b/indexer/src/events.js @@ -1,4 +1,5 @@ import { scValToNative, xdr } from "@stellar/stellar-sdk"; +import { getEventSchema } from "./catalog.js"; function decodeScVal(encoded) { const value = typeof encoded === "string" ? xdr.ScVal.fromXDR(encoded, "base64") : encoded; @@ -41,4 +42,44 @@ export function parseTokenTransferEvent(event, tokenNames, treasuryAddress) { from, to, }; +} + +export function parseAccordEvent(event, treasuryAddress) { + const contractId = String(event.contractId ?? "").toLowerCase(); + if (contractId !== treasuryAddress.toLowerCase()) return null; + if (!Array.isArray(event.topic) || event.topic.length === 0) return null; + + const topicName = decodeScVal(event.topic[0]); + const schema = getEventSchema(topicName); + if (!schema) return null; // Not an Accord protocol event from the catalog + + const payload = decodeScVal(event.value); + const ledger = Number(event.ledger); + const transactionHash = event.txHash ?? null; + + // scValToNative output can contain BigInts or complex objects. + // We can convert BigInts to strings for JSON serialization if necessary, + // but JSON.stringify in ledger.js will throw on BigInts natively. + // Let's recursively replace BigInt with string in the payload. + const sanitize = (val) => { + if (typeof val === "bigint") return val.toString(); + if (Array.isArray(val)) return val.map(sanitize); + if (val !== null && typeof val === "object") { + const obj = {}; + for (const [k, v] of Object.entries(val)) { + obj[k] = sanitize(v); + } + return obj; + } + return val; + }; + + return { + eventId: String(event.id ?? event.pagingToken ?? `${ledger}:${transactionHash ?? "unknown"}`), + topic: topicName, + payload: sanitize(payload), + ledger, + transactionHash, + occurredAt: new Date(event.ledgerClosedAt).toISOString(), + }; } \ No newline at end of file diff --git a/indexer/src/index.js b/indexer/src/index.js index 30f60de..c02487f 100644 --- a/indexer/src/index.js +++ b/indexer/src/index.js @@ -7,7 +7,7 @@ import { scValToNative, TransactionBuilder, } from "@stellar/stellar-sdk"; -import { parseTokenTransferEvent } from "./events.js"; +import { parseTokenTransferEvent, parseAccordEvent } from "./events.js"; import { openLedgerStore } from "./ledger.js"; function required(name) { @@ -48,13 +48,16 @@ async function indexAvailableEvents() { while (pages < 100) { const response = await stellar.getEvents({ ...(cursor ? { cursor } : { startLedger: nextLedger }), - filters: [{ type: "contract", contractIds: tokens.map(({ address }) => address) }], + filters: [{ type: "contract", contractIds: [treasuryAddress, ...tokens.map(({ address }) => address)] }], limit: pageLimit, }); for (const event of response.events) { - const entry = parseTokenTransferEvent(event, tokenNames, treasuryAddress); - if (entry) store.recordTransfer(entry); + const transferEntry = parseTokenTransferEvent(event, tokenNames, treasuryAddress); + if (transferEntry) store.recordTransfer(transferEntry); + + const accordEntry = parseAccordEvent(event, treasuryAddress); + if (accordEntry) store.recordAccordEvent(accordEntry); } if (response.events.length === pageLimit) { @@ -159,6 +162,22 @@ const api = createServer((request, response) => { }); return sendJson(response, 200, { entries }); } + if (request.method === "GET" && url.pathname === "/events") { + const topic = url.searchParams.get("topic"); + const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 100), 1), 1000); + const offset = Math.max(Number(url.searchParams.get("offset") ?? 0), 0); + if (!Number.isSafeInteger(limit) || !Number.isSafeInteger(offset)) { + throw new Error("limit and offset must be non-negative integers"); + } + const events = store.getAccordEvents({ + topic, + from: parseDateParam(url.searchParams.get("from"), "from"), + to: parseDateParam(url.searchParams.get("to"), "to"), + limit, + offset, + }); + return sendJson(response, 200, { events }); + } if (request.method === "GET" && url.pathname === "/reconciliation") { return sendJson(response, 200, { results: store.getLatestReconciliations() }); } diff --git a/indexer/src/ledger.js b/indexer/src/ledger.js index 5d376bc..1a30b49 100644 --- a/indexer/src/ledger.js +++ b/indexer/src/ledger.js @@ -31,6 +31,19 @@ export function openLedgerStore(databasePath) { token TEXT PRIMARY KEY, balance TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS accord_events ( + id INTEGER PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + topic TEXT NOT NULL, + payload TEXT NOT NULL, + ledger INTEGER NOT NULL, + transaction_hash TEXT, + occurred_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS accord_events_time_idx + ON accord_events (occurred_at, id); + CREATE INDEX IF NOT EXISTS accord_events_topic_time_idx + ON accord_events (topic, occurred_at, id); CREATE TABLE IF NOT EXISTS indexer_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -54,6 +67,11 @@ export function openLedgerStore(databasePath) { transaction_hash, occurred_at, from_address, to_address ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); + const insertAccordEvent = database.prepare(` + INSERT INTO accord_events ( + event_id, topic, payload, ledger, transaction_hash, occurred_at + ) VALUES (?, ?, ?, ?, ?, ?) + `); const selectBalance = database.prepare( "SELECT balance FROM token_balances WHERE token = ?" ); @@ -125,6 +143,52 @@ export function openLedgerStore(databasePath) { `).all(...values, limit, offset); }, + recordAccordEvent(entry) { + try { + insertAccordEvent.run( + entry.eventId, + entry.topic, + JSON.stringify(entry.payload), + entry.ledger, + entry.transactionHash ?? null, + entry.occurredAt + ); + return true; + } catch (error) { + if (String(error.message).includes("UNIQUE constraint failed: accord_events.event_id")) { + return false; + } + throw error; + } + }, + + getAccordEvents({ topic, from, to, limit, offset }) { + const clauses = []; + const values = []; + if (topic) { + clauses.push("topic = ?"); + values.push(topic); + } + if (from) { + clauses.push("occurred_at >= ?"); + values.push(from); + } + if (to) { + clauses.push("occurred_at <= ?"); + values.push(to); + } + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + return database.prepare(` + SELECT event_id AS eventId, topic, payload, ledger, + transaction_hash AS transactionHash, occurred_at AS occurredAt + FROM accord_events ${where} + ORDER BY ledger ASC, id ASC LIMIT ? OFFSET ? + `).all(...values, limit, offset).map((row) => ({ + ...row, + payload: JSON.parse(row.payload), + })); + }, + setState(key, value) { database.prepare(` INSERT INTO indexer_state (key, value) VALUES (?, ?)