Skip to content
Open
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
19 changes: 18 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ jobs:

- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: wasm32-unknown-unknown
components: rustfmt

Expand Down Expand Up @@ -149,7 +150,17 @@ jobs:

- name: Check for bindings drift (diff detection)
if: steps.bindings.outcome == 'success'
run: git diff --exit-code
run: |
if ! git diff --exit-code; then
echo "ERROR: Generated Stellar bindings differ from committed files."
echo "Regenerate and commit them locally with:"
echo " cd stellar && pnpm bindings:stellar && git add -A && git commit -m 'chore(stellar): regenerate TypeScript bindings' && git push"
echo ""
echo "Diff:"
git --no-pager diff --stat
git --no-pager diff
exit 1
fi
working-directory: .

stellar-nightly:
Expand All @@ -162,6 +173,7 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: wasm32-unknown-unknown
components: rustfmt
- run: cargo fmt --all --check
Expand All @@ -180,6 +192,7 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: wasm32-unknown-unknown

- name: Setup Stellar CLI
Expand Down Expand Up @@ -234,6 +247,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.91.0
with:
toolchain: 1.91.0
- uses: heyAyushh/setup-anchor@v4.2
with:
anchor-version: 0.30.1
Expand Down Expand Up @@ -265,6 +280,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --version "^0.12" --locked
# 30-minute total budget, split evenly across the two targets (900s each).
Expand Down
6 changes: 6 additions & 0 deletions evm/contracts/ERC5564Announcer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,11 @@ contract ERC5564Announcer is IERC5564Announcer {
bytes memory metadata
) external {
emit Announcement(schemeId, stealthAddress, msg.sender, ephemeralPubKey, metadata);

// Also emit legacy v1-shaped announcement for indexer compatibility.
// The legacy shape used topics ("announce", schemeId, stealthAddress)
// and put `caller` in the data payload (non-indexed), resulting in
// three topics. Emit here to avoid breaking existing indexers.
emit LegacyAnnouncement(schemeId, stealthAddress, msg.sender, ephemeralPubKey, metadata);
}
}
13 changes: 13 additions & 0 deletions evm/contracts/interfaces/IERC5564Announcer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ interface IERC5564Announcer {
bytes metadata
);

/// @notice Legacy v1-shaped announcement retained for indexer compatibility.
/// @dev Legacy shape used the topic tuple `("announce", schemeId, stealthAddress)`
/// and placed `caller` in data (non-indexed) so there were only 3 topics.
/// Keep this event so off-chain indexers that expect the older layout continue
/// to function during a migration window.
event LegacyAnnouncement(
uint256 indexed schemeId,
address indexed stealthAddress,
address caller,
bytes ephemeralPubKey,
bytes metadata
);

/// @notice Publishes a stealth address announcement.
/// @param schemeId The scheme ID (1 for secp256k1 with view tags).
/// @param stealthAddress The generated stealth address.
Expand Down
4 changes: 2 additions & 2 deletions evm/test/WraithSender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ describe('WraithSender', function () {
return false;
}
});
expect(announcementLogs.length).to.equal(3);
expect(announcementLogs.length).to.equal(stealthAddresses.length * 2);

for (let i = 0; i < stealthAddresses.length; i++) {
const balanceAfter = await ethers.provider.getBalance(stealthAddresses[i]);
Expand Down Expand Up @@ -229,7 +229,7 @@ describe('WraithSender', function () {
const announcementLogs = receipt!.logs.filter(
(log) => log.address.toLowerCase() === announcerAddress,
);
expect(announcementLogs.length).to.equal(2);
expect(announcementLogs.length).to.equal(stealthAddresses.length * 2);

for (let i = 0; i < stealthAddresses.length; i++) {
expect(await token.balanceOf(stealthAddresses[i])).to.equal(amounts[i]);
Expand Down
20 changes: 20 additions & 0 deletions stellar/ANNOUNCE_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Announcer v2 Migration & Release Notes

Summary
- Introduces v2 `announce` event topics: `("announce", scheme_id, view_tag_bucket, metadata_kind)` with data `(stealth_address, ephemeral_pub_key, metadata)`.
- During migration v2 deployments MUST dual-emit the legacy v1-shaped event for 3 months.

Deprecation window
- Duration: 3 months from v2 announcer mainnet launch.
- Rationale: gives indexers + SDKs time to update filters and backfill any needed historical data.

Migration checklist for releases
1. Deploy new `stealth-announcer` contract that enforces `scheme_id = 2` and emits both v2 and legacy v1-shaped events.
2. Announce the migration and deprecation window to integrators and indexers (mailing list, Discord, repo issue).
3. SDKs: update `getEvents` subscriptions to prefer topics `("announce", 2, my_bucket, *)`. Continue reading legacy v1 during the window.
4. Indexers: add filters for Topic 2 buckets and migrate storage backends as needed. Validate that dual-emit events are both visible in streaming/backfill paths.
5. After 3 months, perform a coordinated removal of the legacy v1 emission from new deployments and update docs to mark v1 historical-only.

Notes
- The repository contains unit and integration test coverage to validate dual-emit behavior. Run `cargo test -p stealth-announcer` to verify locally.
- Do not remove historical v1 events from chain history; they remain readable indefinitely.
8 changes: 8 additions & 0 deletions stellar/EVENT_INDEXING_AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ All 4 topic slots are used. `view_tag_bucket` is `metadata[0] as u32`, partition

**No changes needed.**

## Migration: dual-emit and deprecation window

To preserve existing indexer integrations during rollout, the new v2 announcer deployment MUST emit both the v2 authoritative topic shape and a legacy v1-shaped event for a limited migration window. The repository's `stealth-announcer` implementation now emits both shapes (v2: `("announce", scheme_id, view_tag_bucket, metadata_kind)`; legacy: `("announce", scheme_id, stealth_address)` with data `(caller, ephemeral_pub_key, metadata)`).

- **Deprecation window:** Indexers should migrate to v2 and stop relying on the legacy three-topic shape within **3 months** of the v2 announcer launch. After that window the legacy shape may be removed from new deployments.

Indexers and SDKs should be updated to: (a) subscribe to v2 topics for primary scanning efficiency, and (b) continue reading the legacy shape during the migration window for backward compatibility.

---

### stealth-registry
Expand Down
13 changes: 13 additions & 0 deletions stellar/EVENT_TOPIC_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ Reserve the last slot for coarse discovery classes such as:

This is optional at launch, but it is worth reserving now because Soroban only gives us four indexed topic slots. If we spend the last slot on something less selective today, we will likely regret it later.

### Topic-3: view-tag hint and discovery classes

Topic 3 (`metadata_kind`) should be selected to act as a compact discovery hint. Use short, stable symbols such as `"default"`, `"invoice"`, or domain-specific hints like `"view_hint"`. Indexers can optionally include topic-3 in filters to further reduce client-side validation work while keeping the primary partitioning responsibility in `view_tag_bucket` (Topic 2).

## Why `stealth_address` Should Leave Topics

The current schema indexes `stealth_address`, but that field is not a useful scan key:
Expand Down Expand Up @@ -208,6 +212,15 @@ Specifically:
- Keep v1 readable during transition.
- Update SDK fetch logic to query by bucket at the RPC layer.

## Structured error events (optional)

To aid indexer and operator diagnostics without changing the primary announce schema, consider emitting structured, optional error events from parsing/agent layers:

- Topics: `("announce_error", scheme_id, error_code, metadata_kind)`
- Data: `(caller, stealth_address_opt, ephemeral_pub_key_opt, metadata, message)`

`error_code` should be a small integer enum (e.g., `1 = malformed_metadata`, `2 = invalid_ephemeral_key`, `3 = auth_failure`). Contracts should avoid embedding sensitive details in `message`; prefer short machine-readable codes and let off-chain tooling map codes to human-friendly explanations.

## Follow-Up Work

The implementation should be split into contract and SDK work:
Expand Down
97 changes: 71 additions & 26 deletions stellar/stealth-announcer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ impl StealthAnnouncerContract {
),
(stealth_address, ephemeral_pub_key, metadata),
);

// Also emit a legacy v1-shaped announcement for backward
// compatibility. Many existing indexers expect the older three-topic
// layout `("announce", scheme_id, stealth_address)` with data
// `(caller, ephemeral_pub_key, metadata)`. To avoid breaking those
// indexers during migration, emit the legacy shape in addition to
// the v2 authoritative shape. The `caller` value is the announcer
// contract address (Soroban semantics), matching historical
// behavior documented in audits.
env.events().publish(
(symbol_short!("announce"), scheme_id, stealth_address),
(env.current_contract_address(), ephemeral_pub_key, metadata),
);
}
}

Expand All @@ -105,26 +118,49 @@ mod test {
client.announce(&scheme_id, &stealth_address, &ephemeral_pub_key, &metadata);

let events = env.events().all();
assert_eq!(events.len(), 1);

let event = events.last().unwrap();

// Verify the event was published by the correct contract.
assert_eq!(event.0, contract_id);

// Verify topics: ("announce", scheme_id, view_tag_bucket, metadata_kind).
let expected_topics: soroban_sdk::Vec<Val> = vec![
&env,
symbol_short!("announce").into_val(&env),
scheme_id.into_val(&env),
42u32.into_val(&env),
METADATA_KIND_VIEW_TAG.into_val(&env),
];
assert_eq!(event.1, expected_topics);

// Verify data: (stealth_address, ephemeral_pub_key, metadata).
let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2);
assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata));
// We now emit both v2 (4-topic) and legacy v1-shaped (3-topic) events.
assert_eq!(events.len(), 2);

// Locate v2 and v1 events by topic length.
let mut found_v2 = false;
let mut found_v1 = false;
for e in events.iter() {
// Verify the event was published by the correct contract.
assert_eq!(e.0, contract_id);
let topics_len = e.1.len();
if topics_len == 4 {
// v2 event
let expected_topics: soroban_sdk::Vec<Val> = vec![
&env,
symbol_short!("announce").into_val(&env),
scheme_id.into_val(&env),
42u32.into_val(&env),
METADATA_KIND_VIEW_TAG.into_val(&env),
];
assert_eq!(e.1, expected_topics);

let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &e.2);
assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata));
found_v2 = true;
} else if topics_len == 3 {
// legacy v1-shaped event: topics ("announce", scheme_id, stealth_address)
let expected_topics_v1: soroban_sdk::Vec<Val> = vec![
&env,
symbol_short!("announce").into_val(&env),
scheme_id.into_val(&env),
stealth_address.clone().into_val(&env),
];
assert_eq!(e.1, expected_topics_v1);

let actual_value_v1: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &e.2);
// Caller in Soroban historical behavior is the contract address.
assert_eq!(actual_value_v1.1, ephemeral_pub_key);
assert_eq!(actual_value_v1.2, metadata);
found_v1 = true;
}
}

assert!(found_v2 && found_v1, "Both v2 and legacy v1 events should be emitted");
}

#[test]
Expand All @@ -140,8 +176,13 @@ mod test {

client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &first_meta);
let events = env.events().all();
let event = events.last().unwrap();
assert_eq!(event.0, contract_id.clone());
// find the most recent v2 event and validate its topics
let v2_event = events
.iter()
.rev()
.find(|e| e.1.len() == 4)
.expect("v2 event must be present");
assert_eq!(v2_event.0, contract_id.clone());

let expected_topics: soroban_sdk::Vec<Val> = vec![
&env,
Expand All @@ -150,19 +191,23 @@ mod test {
0u32.into_val(&env),
METADATA_KIND_VIEW_TAG.into_val(&env),
];
assert_eq!(event.1, expected_topics);
assert_eq!(v2_event.1, expected_topics);

client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &second_meta);
let events2 = env.events().all();
let event2 = events2.last().unwrap();
let v2_event2 = events2
.iter()
.rev()
.find(|e| e.1.len() == 4)
.expect("second v2 event must be present");
let expected_topics2: soroban_sdk::Vec<Val> = vec![
&env,
symbol_short!("announce").into_val(&env),
STELLAR_V2_SCHEME_ID.into_val(&env),
255u32.into_val(&env),
METADATA_KIND_VIEW_TAG.into_val(&env),
];
assert_eq!(event2.1, expected_topics2);
assert_eq!(v2_event2.1, expected_topics2);
}

#[test]
Expand Down Expand Up @@ -196,4 +241,4 @@ mod test {

client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &meta);
}
}
}
9 changes: 7 additions & 2 deletions stellar/stealth-announcer/tests/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ fn wa_ann_01_v2_payload_uses_stealth_address_not_caller() {
);

let events = env.events().all();
let event = events.last().unwrap();
let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2);
// We emit both v2 and legacy v1-shaped events; ensure v2 payload uses stealth_address
let v2 = events
.iter()
.rev()
.find(|e| e.1.len() == 4)
.expect("v2 event should be present");
let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &v2.2);

assert_ne!(contract_id, invoker);
assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata));
Expand Down
46 changes: 46 additions & 0 deletions stellar/stealth-announcer/tests/dual_emit_integration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
use soroban_sdk::testutils::EnvTestConfig;
use soroban_sdk::{vec, Address, Bytes, BytesN, Env};
use stealth_announcer::{
StealthAnnouncerContract, StealthAnnouncerContractClient, METADATA_KIND_VIEW_TAG,
STELLAR_V2_SCHEME_ID,
};

fn test_env() -> Env {
Env::new_with_config(EnvTestConfig {
capture_snapshot_at_drop: false,
})
}

#[test]
fn integration_dual_emit_present() {
let env = test_env();
let contract_id = env.register(StealthAnnouncerContract, ());
let client = StealthAnnouncerContractClient::new(&env, &contract_id);

let stealth_address = Address::generate(&env);
let ephemeral_pub_key = BytesN::from_array(&env, &[9u8; 32]);
let metadata = Bytes::from_slice(&env, &[7u8; 2]);

client.announce(
&STELLAR_V2_SCHEME_ID,
&stealth_address,
&ephemeral_pub_key,
&metadata,
);

let events = env.events().all();
// must have both v2 (4-topic) and legacy v1 (3-topic)
let mut found_v2 = false;
let mut found_v1 = false;
for e in events.iter() {
if e.1.len() == 4 {
found_v2 = true;
assert_eq!(e.1[0], soroban_sdk::Symbol::short("announce").into_val(&env));
} else if e.1.len() == 3 {
found_v1 = true;
assert_eq!(e.1[0], soroban_sdk::Symbol::short("announce").into_val(&env));
}
}

assert!(found_v2 && found_v1, "both v2 and legacy v1 events should be emitted");
}
Loading