Skip to content
Closed
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
4 changes: 4 additions & 0 deletions stellar/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

172 changes: 67 additions & 105 deletions stellar/EVENT_INDEXING_AUDIT.md
Original file line number Diff line number Diff line change
@@ -1,139 +1,101 @@
# Stellar Event Indexing Audit (Issue #61)
Stellar Event Indexing Audit (Issue #61)
Date: 2026-06-27
Last updated: 2026-08-25
Scope: All Soroban contracts in stellar/
Background: Soroban getEvents filters support 1–4 topic segments (hard limit). Unused topic slots waste filtering selectivity and force indexers to download more events than necessary.

**Date:** 2026-06-27
**Scope:** All Soroban contracts in `stellar/`
**Background:** Soroban `getEvents` filters support 1–4 topic segments (hard limit). Unused topic slots waste filtering selectivity and force indexers to download more events than necessary.
Status
Overall verdict: OPTIMAL. The two core contracts (stealth-announcer, stealth-registry) are correctly shaped. wraith-names is acceptable as-is. stealth-splitter already routes per-transfer announcements through the announcer (Issue #62 was a stale audit-doc finding). stealth-batch-sender now does the same (Issue #63 resolved), so all three announcement sources share the v2 4-topic layout.

## Status
Findings Summary
Contract Event Current Topics Assessment Recommendation
stealth-announcer announce ("announce", scheme_id, view_tag_bucket, metadata_kind) ✅ FINAL None — v2 schema is authoritative
stealth-registry register ("register", registrant, scheme_id) ✅ OPTIMAL None
stealth-registry remove ("remove", registrant, scheme_id) ✅ OPTIMAL None
stealth-sender — No user-facing events (delegates to announcer) ✅ N/A None
wraith-names register, update, release, extend (event_type, name_hash) ✅ ACCEPTABLE Optional: add owner as topic 3 (low priority)
stealth-splitter per-transfer announce routed through announcer → v2 4-topic layout ✅ RESOLVED Issue #62 closed — audit-doc correction; fund_split already calls announcer_client::announce
stealth-splitter create, fund (event_type, split_id) ✅ OK None
stealth-batch-sender per-transfer announce routed through announcer → v2 4-topic layout ✅ RESOLVED Issue #63 closed — see merging PR
stealth-batch-sender batch summary ("BATCH",) ✅ OK Summary event; not an announcement. Filter by announcer for scan traffic.
Detailed Findings
stealth-announcer
Event: announce
Topics: ("announce", scheme_id, view_tag_bucket, metadata_kind)
Data: (stealth_address, ephemeral_pub_key, metadata)

**Overall verdict: MOSTLY OPTIMAL.** The two core contracts (`stealth-announcer`, `stealth-registry`) are correctly shaped. `wraith-names` is acceptable as-is. `stealth-splitter` has a confirmed indexing deficiency in its `ANNOUNCE` events. `stealth-batch-sender` requires a follow-up review.
All 4 topic slots are used. view_tag_bucket is metadata[0] as u32, partitioning events into 256 buckets — a recipient only needs to scan ~1/256 of all announcements (~99.6% traffic reduction). metadata_kind=1 signals that metadata[0] is the view tag byte. STELLAR_V2_SCHEME_ID=2 is asserted at runtime, preventing accidental v1 data from appearing under this contract.

---
No changes needed.

## Findings Summary
stealth-registry
Events: register, remove
Topics: ("register"|"remove", registrant, scheme_id)
Data: register → stealth_meta_address; remove → ()

| Contract | Event | Current Topics | Assessment | Recommendation |
|---|---|---|---|---|
| stealth-announcer | `announce` | `("announce", scheme_id, view_tag_bucket, metadata_kind)` | ✅ FINAL | None — v2 schema is authoritative |
| stealth-registry | `register` | `("register", registrant, scheme_id)` | ✅ OPTIMAL | None |
| stealth-registry | `remove` | `("remove", registrant, scheme_id)` | ✅ OPTIMAL | None |
| stealth-sender | — | No user-facing events | ✅ N/A | None |
| wraith-names | `register`, `update`, `release`, `extend` | `(event_type, name_hash)` | ✅ ACCEPTABLE | Optional: add owner as topic 3 (low priority) |
| stealth-splitter | `ANNOUNCE` | `("ANNOUNCE",)` | ⚠️ SUBOPTIMAL | Issue #62: adopt v2 topic layout or route through announcer |
| stealth-splitter | `BATCH` | `("BATCH",)` | ⚠️ SUBOPTIMAL | Covered by Issue #62 |
| stealth-splitter | `create`, `fund` | `(event_type, split_id)` | ✅ OK | None |
| stealth-batch-sender | per-transfer event | Unknown (line 83) | ⚠️ NEEDS REVIEW | Issue #63: audit event topic layout |
| stealth-batch-sender | batch summary event | Unknown (line 95) | ⚠️ NEEDS REVIEW | Issue #63: audit event topic layout |
The dominant read pattern is "fetch meta-address for address X under scheme Y." The existing (registrant, scheme_id) topic pair maps directly to that query. A 4th topic (e.g. a hash of the meta-address) would add no useful selectivity for any realistic indexer query.

---
No changes needed.

## Detailed Findings
stealth-sender
Delegates all announcement work to the stealth-announcer contract via invoke_contract. No user-facing events are emitted directly. Internal observability events from wraith-metrics are out of scope for this audit.

### stealth-announcer
Not applicable.

**Event:** `announce`
**Topics:** `("announce", scheme_id, view_tag_bucket, metadata_kind)`
**Data:** `(stealth_address, ephemeral_pub_key, metadata)`
wraith-names
Events: register, update, release, extend
Topics: (event_type, name_hash)
Data: varies per event

All 4 topic slots are used. `view_tag_bucket` is `metadata[0] as u32`, partitioning events into 256 buckets — a recipient only needs to scan ~1/256 of all announcements (~99.6% traffic reduction). `metadata_kind=1` signals that `metadata[0]` is the view tag byte. `STELLAR_V2_SCHEME_ID=2` is asserted at runtime, preventing accidental v1 data from appearing under this contract.

**No changes needed.**

---

### stealth-registry

**Events:** `register`, `remove`
**Topics:** `("register"|"remove", registrant, scheme_id)`
**Data:** `register` → `stealth_meta_address`; `remove` → `()`

The dominant read pattern is "fetch meta-address for address X under scheme Y." The existing `(registrant, scheme_id)` topic pair maps directly to that query. A 4th topic (e.g. a hash of the meta-address) would add no useful selectivity for any realistic indexer query.

**No changes needed.**

---

### stealth-sender

Delegates all announcement work to the `stealth-announcer` contract via `invoke_contract`. No user-facing events are emitted directly. Internal observability events from `wraith-metrics` are out of scope for this audit.

**Not applicable.**

---

### wraith-names

**Events:** `register`, `update`, `release`, `extend`
**Topics:** `(event_type, name_hash)`
**Data:** varies per event

`name_hash` is `SHA-256(name)`, so `(event_type, name_hash)` is a deterministic, exact-match lookup for "history of name X" — the overwhelmingly common query. This is already optimal for the primary use case.
name_hash is SHA-256(name), so (event_type, name_hash) is a deterministic, exact-match lookup for "history of name X" — the overwhelmingly common query. This is already optimal for the primary use case.

A potential improvement is adding the owner address as topic 3, enabling queries like "all registrations by owner O." However, the owner address is not currently included in event data, so adding it would require a data-shape change. Given the low event volume of a name registry, the cost/complexity is not justified at this time.

**No changes required.** Owner-indexed queries are a low-priority optional follow-up.

---

### stealth-splitter

**Events of concern:** `ANNOUNCE`, `BATCH`

`ANNOUNCE` topics: `("ANNOUNCE",)` — only 1 topic, no filtering possible beyond event name.
`BATCH` topics: `("BATCH",)` — same problem.
No changes required. Owner-indexed queries are a low-priority optional follow-up.

A recipient scanning for stealth payments routed through the splitter must download every `ANNOUNCE` event ever emitted by the contract. This is the exact problem the v2 announcer schema was designed to solve. The correct fix is one of:
stealth-splitter
Per-transfer announcements: routed through stealth-announcer.

- **(a) Route through announcer:** have `stealth-splitter` call `stealth-announcer` for each transfer instead of emitting its own `ANNOUNCE`. This fixes indexing and eliminates the architectural inconsistency (splitter announcements are not currently discoverable via the same `getEvents` filter as direct announcer events).
- **(b) Adopt v2 topic layout inline:** emit `("ANNOUNCE", scheme_id, view_tag_bucket, metadata_kind)` directly, mirroring the announcer schema without the cross-contract call overhead.
The original audit flagged ANNOUNCE / BATCH as single-topic events. That verdict is stale. fund_split (see stealth-splitter/src/lib.rs) already imports announcer_client::announce and invokes the announcer contract for every beneficiary transfer. Splitter output therefore appears on the announcer with the full 4-topic v2 layout ("announce", scheme_id, view_tag_bucket, metadata_kind). Recipients can apply the same topic-3 view-tag filter they use for direct announcer events.

Option (a) is preferred for consistency. Tracked as **Issue #62**.
The splitter does not emit its own ANNOUNCE or BATCH events. Management events create and fund ((event_type, split_id)) are unchanged and remain the correct shape for split-management queries.

**`create` and `fund` events** (`(event_type, split_id)`) are fine — split management queries by `split_id` are the natural access pattern.
Issue #62 closed as an audit-doc correction. No splitter code change was required.

---
stealth-batch-sender
Per-transfer announcements: routed through stealth-announcer (same announcer_client::announce pattern as splitter).

### stealth-batch-sender
The original audit could not inspect the topic layout and opened Issue #63. The contract previously emitted a single-topic ("ANNOUNCE",) event inline, which broke server-side topic-3 view-tag filtering and forced indexers to full-scan batch-sender output.

The contract emits at least two events (per-transfer at line 83, batch summary at line 95 of `src/lib.rs`). The full topic layout was not inspected during this audit. If these events follow a pattern similar to `stealth-splitter`'s `ANNOUNCE`emitting their own announcement-style events with fewer than 4 topics and no `view_tag_bucket` — the same indexing deficiency applies.
That emission is gone. Each transfer now calls the announcer, so batch-sender output uses the identical v2 4-topic layout (including view_tag_bucket = metadata[0] as u32). The remaining ("BATCH",) event is a batch-level summary (from, count, asset)not an announcementand does not need view-tag selectivity.

**Tracked as Issue #63** for a dedicated review.
Issue #63 resolved. Indexers watch the announcer; there is no batch-sender-specific announce path. Covered by the integration test in stellar/integration-tests/tests/topic_filter.rs.

---
v2 Schema Sign-off
The stealth-announcer v2 event schema is final and authoritative:

## v2 Schema Sign-off
text

The `stealth-announcer` v2 event schema is **final and authoritative**:

```
topics: ("announce", scheme_id: u32, view_tag_bucket: u32, metadata_kind: u32)
data: (stealth_address: Bytes, ephemeral_pub_key: Bytes, metadata: Bytes)
```

- `scheme_id = 2` (`STELLAR_V2_SCHEME_ID`) is the canonical Stellar stealth scheme identifier.
- `view_tag_bucket = metadata[0] as u32` when `metadata_kind = 1`.
- v1 events (scheme_id=1, 3-topic layout) remain readable from the old contract during any transition period (Path A migration).

SDK and off-chain indexers should treat this schema as stable. No further changes to `stealth-announcer` event structure are planned.

---

## Follow-up Issues

### Issue #62 — stealth-splitter: fix ANNOUNCE event topic layout
scheme_id = 2 (STELLAR_V2_SCHEME_ID) is the canonical Stellar stealth scheme identifier.
view_tag_bucket = metadata[0] as u32 when metadata_kind = 1.
v1 events (scheme_id=1, 3-topic layout) remain readable from the old contract during any transition period (Path A migration).
SDK and off-chain indexers should treat this schema as stable. No further changes to stealth-announcer event structure are planned.

**Problem:** `stealth-splitter` emits `ANNOUNCE` with only 1 topic, making per-recipient scan filtering impossible. Recipients must download all splitter announcements.
All three announcement sources (direct announcer, splitter, batch-sender) produce this layout. The reference indexer processor uses a single code path for all three — see stellar/examples/indexer/src/processor.ts.

**Options:**
1. Route splitter per-transfer announcements through `stealth-announcer` (preferred — restores unified indexing).
2. Adopt the v2 topic layout `("ANNOUNCE", scheme_id, view_tag_bucket, metadata_kind)` inline.
Follow-up Issues
Issue #62 — stealth-splitter: fix ANNOUNCE event topic layout
Status: CLOSED (audit-doc correction).

**Affects:** `ANNOUNCE` and `BATCH` events. `create`/`fund` events are unaffected.
Original problem: stealth-splitter was recorded as emitting ANNOUNCE with only 1 topic.

---
Resolution: No code change. fund_split already routes per-transfer announcements through announcer_client::announce, so splitter output has the v2 4-topic layout. The ANNOUNCE / BATCH rows in the original findings table were stale.

### Issue #63 — stealth-batch-sender: audit event topic layout
Issue #63 — stealth-batch-sender: audit event topic layout
Status: RESOLVED.

**Problem:** The event topic layout in `stealth-batch-sender` was not fully reviewed in this audit. If per-transfer events lack `view_tag_bucket` and `scheme_id` topics, the same deficiency as Issue #62 applies.
Original problem: The event topic layout in stealth-batch-sender was not fully reviewed. Per-transfer events used a single-topic ("ANNOUNCE",) publish, so topic-3 view-tag filtering could not be applied.

**Action:** Read `stealth-batch-sender/src/lib.rs` lines 83 and 95 in full context, assess topic layout against the v2 schema, and apply the same fix as Issue #62 if needed.
Resolution: Batch-sender now routes each transfer through the announcer contract (announcer_client::announce), matching splitter. Unit + snapshot tests assert the 4-topic layout. The integration test topic_filter.rs verifies a topic-3 filter returns only matching view-tag entries across announcer, splitter, and batch-sender. Merging PR: this change.
7 changes: 5 additions & 2 deletions stellar/bench-crossover/src/crossover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,16 @@ fn measure_batch(n: u32) -> Measured {
for i in 0..n {
transfers.push_back(Transfer {
stealth_address: Address::generate(&env),
ephemeral_pub_key: bytes(&env, 33, 0x02u8.wrapping_add(i as u8)),
ephemeral_pub_key: BytesN::from_array(&env, &[0x02u8.wrapping_add(i as u8); 32]),
amount: 100,
metadata: bytes(&env, 1, (i as u8).wrapping_add(1)),
});
}

let announcer_id = env.register(StealthAnnouncerContract, ());
env.cost_estimate().budget().reset_unlimited();
let start = Instant::now();
client.batch_send(&sender, &transfers, &token);
client.batch_send(&sender, &transfers, &token, &announcer_id, &2u32);
Measured {
instructions: env.cost_estimate().resources().instructions,
wall_ns: start.elapsed().as_nanos(),
Expand Down Expand Up @@ -245,3 +247,4 @@ fn write_chart_data(rows: &[CrossoverRow], crossover: Option<u32>) {
chart_path.display()
);
}

74 changes: 56 additions & 18 deletions stellar/examples/indexer/src/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,34 @@ dotenv.config();
const RPC_URL = process.env.RPC_URL || 'https://futurenet.sorobanrpc.com';
const server = new Server(RPC_URL);

/**
* Contract kinds the reference indexer understands.
*
* Announcement sources (announcer, sender, splitter, batch-sender) all share
* one processing path. Splitter and batch-sender route per-transfer
* announcements through the announcer contract, so the events appear on the
* announcer address with the v2 4-topic layout:
*
* topics: ("announce", scheme_id, view_tag_bucket, metadata_kind)
* data: (stealth_address, ephemeral_pub_key, metadata)
*
* There is no batch-sender-specific single-topic ("ANNOUNCE",) handler —
* that emission was removed so a topic-3 view-tag filter works uniformly.
*/
type ContractType = 'announcer' | 'registry' | 'sender' | 'names' | 'splitter' | 'batch-sender';

interface ContractConfig {
address: string;
type: 'announcer' | 'registry' | 'sender' | 'names';
type: ContractType;
}

const ANNOUNCEMENT_SOURCES: ReadonlySet<ContractType> = new Set([
'announcer',
'sender',
'splitter',
'batch-sender',
]);

const CONTRACTS: Record<string, ContractConfig> = {
stealthAnnouncer: {
address: process.env.STEALTH_ANNOUNCER_ADDRESS || '',
Expand All @@ -34,6 +57,14 @@ const CONTRACTS: Record<string, ContractConfig> = {
address: process.env.STEALTH_SENDER_ADDRESS || '',
type: 'sender',
},
stealthSplitter: {
address: process.env.STEALTH_SPLITTER_ADDRESS || '',
type: 'splitter',
},
stealthBatchSender: {
address: process.env.STEALTH_BATCH_SENDER_ADDRESS || '',
type: 'batch-sender',
},
wraithNames: {
address: process.env.WRAITH_NAMES_ADDRESS || '',
type: 'names',
Expand Down Expand Up @@ -108,24 +139,30 @@ async function processEvent(config: ContractConfig, event: any, client: any) {
return;
}

// Single code path for every announcement source (announcer, sender,
// splitter, batch-sender). All emit / forward the v2 4-topic "announce"
// layout; no per-source decoding remains.
if (ANNOUNCEMENT_SOURCES.has(config.type)) {
if (Array.isArray(event.topic) && event.topic[0] === 'announce') {
// topics: ("announce", scheme_id, view_tag_bucket, metadata_kind)
// data: (stealth_address, ephemeral_pub_key, metadata)
const schemeId = event.topic[1];
const [stealthAddress, ephemeralPubKey, metadata] = event.value;
await insertAnnouncement(
ledger,
txHash,
config.address,
schemeId,
stealthAddress,
Buffer.from(ephemeralPubKey, 'base64'),
metadata ? Buffer.from(metadata, 'base64') : null,
client
);
}
return;
}

switch (config.type) {
case 'announcer':
case 'sender':
// Both emit the same announcement event
if (Array.isArray(event.topic) && event.topic[0] === 'announce') {
const [schemeId, stealthAddress, ephemeralPubKey, metadata] = event.value;
await insertAnnouncement(
ledger,
txHash,
config.address,
schemeId,
stealthAddress,
Buffer.from(ephemeralPubKey, 'base64'),
metadata ? Buffer.from(metadata, 'base64') : null,
client
);
}
break;
case 'registry':
if (Array.isArray(event.topic) && event.topic[0] === 'register_keys') {
const [registrant, schemeId, stealthMetaAddress] = event.value;
Expand Down Expand Up @@ -191,3 +228,4 @@ export async function startProcessing() {
}, POLL_INTERVAL_MS);
}
}

Loading
Loading