Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions docs/EVENTS.md
Original file line number Diff line number Diff line change
@@ -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<Transfer>, 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<Transfer> }` |
| `guard_set` | `GuardianSetEvent` | `{ guardian: Address }` |
| `frozen` | `FrozenEvent` | `{ guardian: Address }` |
| `unfrozen` | `UnfrozenEvent` | `{ approvers: Vec<Address> }` |
| `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<i128>, 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<Role>, after: Vec<Role> }` |
| `role_revoked` | `RoleRevokedEvent` | `{ target: Address, role: Role, before: Vec<Role>, after: Vec<Role> }` |

This catalog is loaded by the off-chain indexer to decode raw contract events deterministically.
20 changes: 20 additions & 0 deletions indexer/README.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions indexer/src/catalog.js
Original file line number Diff line number Diff line change
@@ -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;
}
199 changes: 199 additions & 0 deletions indexer/src/catalog.json
Original file line number Diff line number Diff line change
@@ -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<Transfer>",
"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<Transfer>"
}
},
"guard_set": {
"struct": "GuardianSetEvent",
"fields": {
"guardian": "Address"
}
},
"frozen": {
"struct": "FrozenEvent",
"fields": {
"guardian": "Address"
}
},
"unfrozen": {
"struct": "UnfrozenEvent",
"fields": {
"approvers": "Vec<Address>"
}
},
"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<i128>",
"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<Role>",
"after": "Vec<Role>"
}
},
"role_revoked": {
"struct": "RoleRevokedEvent",
"fields": {
"target": "Address",
"role": "Role",
"before": "Vec<Role>",
"after": "Vec<Role>"
}
}
}
41 changes: 41 additions & 0 deletions indexer/src/events.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(),
};
}
27 changes: 23 additions & 4 deletions indexer/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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() });
}
Expand Down
Loading