From bbe9af7ea709439db700a4e0d1f36bf85d9bf910 Mon Sep 17 00:00:00 2001 From: gloskull Date: Fri, 29 May 2026 20:03:03 +0100 Subject: [PATCH 1/3] Audit Stellar stealth announcer --- CHANGELOG.md | 5 + stellar/stealth-announcer/Cargo.toml | 2 +- .../audits/2026-05-gpt-5-3-codex.md | 88 ++++++++++++ stellar/stealth-announcer/tests/audit.rs | 125 ++++++++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md create mode 100644 stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md create mode 100644 stellar/stealth-announcer/tests/audit.rs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b043944 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +- Added a security audit report for the Stellar `stealth-announcer` contract, including reproducer tests for caller attribution, metadata sizing, ephemeral public key validation, and CPI behavior. diff --git a/stellar/stealth-announcer/Cargo.toml b/stellar/stealth-announcer/Cargo.toml index d2c851d..740d78a 100644 --- a/stellar/stealth-announcer/Cargo.toml +++ b/stellar/stealth-announcer/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } diff --git a/stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md b/stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md new file mode 100644 index 0000000..c843563 --- /dev/null +++ b/stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md @@ -0,0 +1,88 @@ +# stealth-announcer audit — GPT-5.3-Codex — 2026-05-29 + +## Summary + +The `stealth-announcer` crate was reviewed across `src/lib.rs`, `Cargo.toml`, its workspace dependency pin, and its semantic relationship to the EVM ERC-5564 announcer. The contract is intentionally small and stores no state, which keeps reentrancy, authorization-state, and upgrade/state-corruption risks out of scope. The main risks are event semantic drift: Soroban has no implicit `msg.sender`, so the current event payload publishes the announcer contract address as the `caller` field rather than the real invoker, and CPI calls are indistinguishable from direct calls. Additional low-severity hardening opportunities exist around unbounded metadata and invalid-but-well-typed ephemeral public keys. No Critical or High findings were identified, so no embargoed disclosure timeline is required. + +## Scope and methodology + +Reviewed files and references: + +- `contracts/stellar/stealth-announcer/src/lib.rs` +- `contracts/stellar/stealth-announcer/Cargo.toml` +- `contracts/stellar/Cargo.toml` +- `contracts/stellar/stealth-sender/src/lib.rs`, to understand CPI usage +- `contracts/evm/contracts/ERC5564Announcer.sol` +- `contracts/evm/contracts/interfaces/IERC5564Announcer.sol` +- Soroban auth model documentation requested in the issue brief + +Testing added in `contracts/stellar/stealth-announcer/tests/audit.rs` reproduces each finding. + +## Findings table + +| ID | Severity | Title | Status | +| --- | --- | --- | --- | +| WA-ANN-01 | Medium | Event `caller` payload is always the announcer contract, not the invoker | Open | +| WA-ANN-02 | Low | Unbounded metadata can inflate event payloads and indexer workload | Open | +| WA-ANN-03 | Low | Invalid all-zero ephemeral public keys are accepted | Open | +| WA-ANN-04 | Informational | Unauthenticated CPI calls can emit indistinguishable announcements | Open | + +## Findings + +### WA-ANN-01 — Event `caller` payload is always the announcer contract, not the invoker + +**Severity:** Medium + +**Description:** The EVM reference emits `Announcement(schemeId, stealthAddress, msg.sender, ephemeralPubKey, metadata)`, and the interface documents `caller` as the address that made the announcement. The Soroban implementation publishes `(env.current_contract_address(), ephemeral_pub_key, metadata)` as the event value. In Soroban, `env.current_contract_address()` is the announcer contract itself, not the account or contract that invoked `announce`. As a result, every announcement has the same `caller` value, off-chain indexers cannot distinguish direct calls from sender-contract calls, and semantic parity with ERC-5564 is broken. + +This is not a direct funds-loss issue because the contract holds no assets. It is Medium because all clients and indexers consume this singleton stream, so a wrong attribution field can become protocol-wide metadata corruption and may break clients that expect ERC-5564-compatible caller semantics. + +**Reproduction:** `cargo test -p stealth-announcer --test audit wa_ann_01_caller_payload_is_contract_not_invoker` demonstrates that the event value's first element equals the announcer contract address and not a generated invoker address. + +**Recommendation:** Choose and document the intended Soroban semantics explicitly: + +1. If ERC-5564 parity is required, add a `caller: Address` argument, call `caller.require_auth()` for direct user announcements, and publish that authenticated `caller`. For contract-mediated sends, decide whether the authenticated sender or the sender contract should be the caller and encode that consistently. +2. If Wraith clients do not need caller attribution on Stellar, remove or rename the field to avoid presenting the contract address as a caller equivalent. +3. Update `stealth-sender` and indexer schemas in the same change so consumers do not silently parse old and new event values as the same format. + +### WA-ANN-02 — Unbounded metadata can inflate event payloads and indexer workload + +**Severity:** Low + +**Description:** `metadata` is accepted as arbitrary `Bytes` and emitted without a protocol-level length cap. Soroban transaction fees and resource limits constrain worst-case on-chain execution, but successful oversized announcements still become durable event data that every Wraith client and indexer must parse, transfer, and potentially store. The ERC-5564 interface notes that the first metadata byte is the view tag; the current contract does not enforce a minimum, maximum, or scheme-specific metadata shape. + +This is Low because spam is priced by the network and no storage is written by the contract. The impact compounds operationally because the announcer stream is global infrastructure for the protocol. + +**Reproduction:** `cargo test -p stealth-announcer --test audit wa_ann_02_oversized_metadata_is_accepted` emits an announcement with 4,096 bytes of metadata and verifies that it is accepted into the event payload. + +**Recommendation:** Add scheme-specific metadata limits. For the default DKSAP/view-tag scheme, consider requiring at least one byte and setting a conservative maximum around the expected metadata envelope. If future schemes need larger metadata, gate the size by `scheme_id` and document the indexer limit. Add negative tests for metadata above the maximum. + +### WA-ANN-03 — Invalid all-zero ephemeral public keys are accepted + +**Severity:** Low + +**Description:** `ephemeral_pub_key` is typed as `BytesN<32>`, which enforces exactly 32 bytes but does not prove the bytes represent a valid public key for the selected stealth-address scheme. The contract accepts the all-zero value. Invalid keys can generate useless announcements, waste recipient scanning work, and may create inconsistent behavior across SDKs if some clients reject malformed keys while others attempt to process them. + +This is Low because malformed announcements do not let an attacker steal funds and may be intentionally allowed for scheme extensibility. It is still a protocol hardening issue because the default scheme has cryptographic assumptions that are not represented by `BytesN<32>` alone. + +**Reproduction:** `cargo test -p stealth-announcer --test audit wa_ann_03_zero_ephemeral_pub_key_is_accepted` publishes an announcement whose ephemeral public key is `[0u8; 32]` and verifies the event is emitted. + +**Recommendation:** If the default Stellar scheme always uses a fixed curve/key encoding, validate supported `scheme_id` values and reject known-invalid encodings, at minimum the all-zero key. If full curve validation is intentionally left off-chain, document that the contract only enforces byte length and add client/indexer requirements to discard invalid keys before cryptographic processing. + +### WA-ANN-04 — Unauthenticated CPI calls can emit indistinguishable announcements + +**Severity:** Informational + +**Description:** `announce` does not call `require_auth()`, so any account or contract can emit announcements, including through contract-to-contract invocation. This matches the no-access-control goal of the EVM announcer. However, combined with WA-ANN-01, CPI-originated announcements are indistinguishable from direct calls because the published event source is the announcer contract and the payload caller is also the announcer contract. A malicious contract can therefore generate announcements that look identical to ordinary direct announcer usage at the Soroban event layer. + +This is Informational because permissionless announcement is an intended design property and network pricing limits spam. The operational risk is primarily for consumers that infer trust or provenance from the event shape. + +**Reproduction:** `cargo test -p stealth-announcer --test audit wa_ann_04_cpi_can_emit_announcements_without_auth` registers a forwarder contract that invokes `announce` via CPI and verifies that the resulting event is sourced from the announcer and carries the announcer as the payload caller. + +**Recommendation:** Keep `announce` permissionless if ERC-5564-style public announcing is the desired model, but do not let clients infer user authorization from the event alone. If caller provenance matters, implement the WA-ANN-01 recommendation with an explicit authenticated caller or explicitly document that Stellar announcements are anonymous/unattributed and CPI-safe by design. + +## Additional observations + +- Dependency pinning is simple: the Stellar workspace pins `soroban-sdk` through a workspace dependency. The audit did not identify risky optional runtime features in the announcer crate; `testutils` is limited to dev-dependencies. +- Topic ordering is deterministic and covered by existing in-tree tests: `(symbol_short!("announce"), scheme_id, stealth_address)`. Keep this order stable because indexers likely key on it. +- The crate now declares `rlib` in addition to `cdylib` so integration audit tests can import the generated client. This does not change deployed WASM behavior. diff --git a/stellar/stealth-announcer/tests/audit.rs b/stellar/stealth-announcer/tests/audit.rs new file mode 100644 index 0000000..734a347 --- /dev/null +++ b/stellar/stealth-announcer/tests/audit.rs @@ -0,0 +1,125 @@ +use soroban_sdk::testutils::{Address as _, EnvTestConfig, Events}; +use soroban_sdk::{ + contract, contractimpl, symbol_short, vec, Address, Bytes, BytesN, Env, FromVal, IntoVal, Val, +}; +use stealth_announcer::{StealthAnnouncerContract, StealthAnnouncerContractClient}; + +fn audit_env() -> Env { + Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }) +} + +#[test] +fn wa_ann_01_caller_payload_is_contract_not_invoker() { + let env = audit_env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + let invoker = Address::generate(&env); + let stealth_address = Address::generate(&env); + let ephemeral_pub_key = BytesN::from_array(&env, &[1u8; 32]); + let metadata = Bytes::from_slice(&env, &[0u8; 1]); + + client.announce(&1u32, &stealth_address, &ephemeral_pub_key, &metadata); + + let events = env.events().all(); + let event = events.last().unwrap(); + let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2); + + assert_ne!(contract_id, invoker); + assert_eq!(actual_value, (contract_id, ephemeral_pub_key, metadata)); +} + +#[test] +fn wa_ann_02_oversized_metadata_is_accepted() { + let env = audit_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, &[2u8; 32]); + let metadata = Bytes::from_array(&env, &[7u8; 4096]); + + client.announce(&1u32, &stealth_address, &ephemeral_pub_key, &metadata); + + let events = env.events().all(); + let event = events.last().unwrap(); + let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2); + + assert_eq!(actual_value, (contract_id, ephemeral_pub_key, metadata)); +} + +#[test] +fn wa_ann_03_zero_ephemeral_pub_key_is_accepted() { + let env = audit_env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + let stealth_address = Address::generate(&env); + let zero_ephemeral_pub_key = BytesN::from_array(&env, &[0u8; 32]); + let metadata = Bytes::from_slice(&env, &[0u8; 1]); + + client.announce(&1u32, &stealth_address, &zero_ephemeral_pub_key, &metadata); + + let events = env.events().all(); + let event = events.last().unwrap(); + let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2); + + assert_eq!( + actual_value, + (contract_id, zero_ephemeral_pub_key, metadata) + ); +} + +#[contract] +pub struct ForwarderContract; + +#[contractimpl] +impl ForwarderContract { + pub fn forward( + env: Env, + announcer: Address, + scheme_id: u32, + stealth_address: Address, + ephemeral_pub_key: BytesN<32>, + metadata: Bytes, + ) { + let client = StealthAnnouncerContractClient::new(&env, &announcer); + client.announce(&scheme_id, &stealth_address, &ephemeral_pub_key, &metadata); + } +} + +#[test] +fn wa_ann_04_cpi_can_emit_announcements_without_auth() { + let env = audit_env(); + let announcer_id = env.register(StealthAnnouncerContract, ()); + let forwarder_id = env.register(ForwarderContract, ()); + let forwarder = ForwarderContractClient::new(&env, &forwarder_id); + + let stealth_address = Address::generate(&env); + let ephemeral_pub_key = BytesN::from_array(&env, &[4u8; 32]); + let metadata = Bytes::from_slice(&env, &[0u8; 1]); + + forwarder.forward( + &announcer_id, + &1u32, + &stealth_address, + &ephemeral_pub_key, + &metadata, + ); + + let events = env.events().all(); + let event = events.last().unwrap(); + let expected_topics: soroban_sdk::Vec = vec![ + &env, + symbol_short!("announce").into_val(&env), + 1u32.into_val(&env), + stealth_address.into_val(&env), + ]; + let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2); + + assert_eq!(event.0, announcer_id.clone()); + assert_eq!(event.1, expected_topics); + assert_eq!(actual_value, (announcer_id, ephemeral_pub_key, metadata)); +} From 33b24452e9448623c6f234a5005f45789e404496 Mon Sep 17 00:00:00 2001 From: gloskull Date: Sun, 31 May 2026 20:13:02 +0100 Subject: [PATCH 2/3] Implement Stellar announcer v2 event topics --- stellar/stealth-announcer/src/lib.rs | 134 +++++++++++++++--- .../test/test_announce_emits_event.1.json | 11 +- ...t_derives_from_first_metadata_byte.1.json} | 9 +- stellar/stealth-announcer/tests/audit.rs | 43 ++++-- 4 files changed, 156 insertions(+), 41 deletions(-) rename stellar/stealth-announcer/test_snapshots/test/{test_announce_different_schemes.1.json => test_view_tag_bucket_derives_from_first_metadata_byte.1.json} (95%) diff --git a/stellar/stealth-announcer/src/lib.rs b/stellar/stealth-announcer/src/lib.rs index 78f47c5..074e8dd 100644 --- a/stellar/stealth-announcer/src/lib.rs +++ b/stellar/stealth-announcer/src/lib.rs @@ -2,22 +2,65 @@ use soroban_sdk::{contract, contractimpl, symbol_short, Address, Bytes, BytesN, Env}; +/// Stellar v2 deployment scheme id. +/// +/// The v1 Stellar announcer emitted topics as +/// `(announce, scheme_id, stealth_address)` and data as +/// `(caller, ephemeral_pub_key, metadata)`. That historical shape is not +/// rewritten in place because existing indexers may already rely on it. The v2 +/// rollout is a new announcer deployment that only accepts `scheme_id = 2` and +/// emits the bucketed event shape documented below. +pub const STELLAR_V2_SCHEME_ID: u32 = 2; + +/// Initial metadata kind for v2 announcements. +/// +/// `1` means `metadata[0]` is the one-byte view tag used for pre-filtering and +/// the remaining bytes, if any, are scheme-specific metadata. Future metadata +/// encodings must reserve a new `metadata_kind` value instead of changing this +/// interpretation. +pub const METADATA_KIND_VIEW_TAG: u32 = 1; + +/// Derives the indexed view-tag bucket for v2 announcement topics. +/// +/// The bucket is exactly the first metadata byte interpreted as an unsigned +/// integer in `[0, 255]`. Because `METADATA_KIND_VIEW_TAG` commits to the first +/// byte being present, callers must provide non-empty metadata. +pub fn view_tag_bucket(metadata: &Bytes) -> u32 { + metadata.get(0).expect("metadata must include view tag") as u32 +} + #[contract] pub struct StealthAnnouncerContract; #[contractimpl] impl StealthAnnouncerContract { - /// Emits a stealth address announcement event. + /// Emits a Stellar v2 stealth address announcement event. /// /// This is a pure event-emission function with no access control and no /// storage. Indexers watch for these events to let recipients detect /// incoming payments. /// + /// v2 event shape: + /// * topics: `("announce", scheme_id, view_tag_bucket, metadata_kind)` + /// * data: `(stealth_address, ephemeral_pub_key, metadata)` + /// + /// The stable `view_tag_bucket` derivation is `metadata[0] as u32`, where + /// `metadata_kind = 1` (`METADATA_KIND_VIEW_TAG`) means the first metadata + /// byte is the view tag and the remaining bytes are scheme-specific. This + /// lets wallets and indexers filter Stellar RPC `getEvents` by scheme and + /// bucket before doing client-side cryptographic validation. + /// + /// Migration note: v1 announcements used the old Stellar layout + /// `("announce", scheme_id, stealth_address)` with + /// `(caller, ephemeral_pub_key, metadata)`. Do not reinterpret historical v1 + /// events as v2. The compatibility path is a new announcer deployment using + /// `scheme_id = 2`. + /// /// # Arguments - /// * `scheme_id` - Identifier for the stealth address scheme (e.g. 1 for the default DKSAP scheme). + /// * `scheme_id` - Must be `2` for the v2 Stellar announcer deployment. /// * `stealth_address` - The one-time stealth address that received funds. /// * `ephemeral_pub_key` - The ephemeral public key used to derive the stealth address. - /// * `metadata` - Arbitrary metadata (e.g. view tag) to speed up scanning. + /// * `metadata` - Non-empty metadata whose first byte is the view tag. pub fn announce( env: Env, scheme_id: u32, @@ -25,9 +68,19 @@ impl StealthAnnouncerContract { ephemeral_pub_key: BytesN<32>, metadata: Bytes, ) { + assert_eq!(scheme_id, STELLAR_V2_SCHEME_ID); + + let view_tag_bucket = view_tag_bucket(&metadata); + let metadata_kind = METADATA_KIND_VIEW_TAG; + env.events().publish( - (symbol_short!("announce"), scheme_id, stealth_address), - (env.current_contract_address(), ephemeral_pub_key, metadata), + ( + symbol_short!("announce"), + scheme_id, + view_tag_bucket, + metadata_kind, + ), + (stealth_address, ephemeral_pub_key, metadata), ); } } @@ -35,8 +88,8 @@ impl StealthAnnouncerContract { #[cfg(test)] mod test { use super::*; - use soroban_sdk::testutils::{Address as _, Events}; - use soroban_sdk::{vec, Address, Bytes, BytesN, Env, IntoVal, Val}; + use soroban_sdk::testutils::{Address as _, EnvTestConfig, Events}; + use soroban_sdk::{vec, Address, Bytes, BytesN, Env, FromVal, IntoVal, Val}; #[test] fn test_announce_emits_event() { @@ -46,8 +99,8 @@ mod test { let stealth_address = Address::generate(&env); let ephemeral_pub_key = BytesN::from_array(&env, &[1u8; 32]); - let metadata = Bytes::from_slice(&env, &[0u8; 1]); - let scheme_id: u32 = 1; + let metadata = Bytes::from_slice(&env, &[42u8, 7u8]); + let scheme_id: u32 = STELLAR_V2_SCHEME_ID; client.announce(&scheme_id, &stealth_address, &ephemeral_pub_key, &metadata); @@ -59,51 +112,88 @@ mod test { // Verify the event was published by the correct contract. assert_eq!(event.0, contract_id); - // Verify topics: ("announce", scheme_id, stealth_address). + // Verify topics: ("announce", scheme_id, view_tag_bucket, metadata_kind). let expected_topics: soroban_sdk::Vec = vec![ &env, symbol_short!("announce").into_val(&env), scheme_id.into_val(&env), - stealth_address.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)); } #[test] - fn test_announce_different_schemes() { + fn test_view_tag_bucket_derives_from_first_metadata_byte() { let env = Env::default(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); let addr = Address::generate(&env); let epk = BytesN::from_array(&env, &[1u8; 32]); - let meta = Bytes::from_slice(&env, &[0u8; 1]); + let first_meta = Bytes::from_slice(&env, &[0u8, 99u8]); + let second_meta = Bytes::from_slice(&env, &[255u8, 99u8]); - // Announce with scheme_id = 1. - client.announce(&1u32, &addr, &epk, &meta); + client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &first_meta); let events = env.events().all(); - assert!(!events.is_empty()); let event = events.last().unwrap(); assert_eq!(event.0, contract_id.clone()); let expected_topics: soroban_sdk::Vec = vec![ &env, symbol_short!("announce").into_val(&env), - 1u32.into_val(&env), - addr.clone().into_val(&env), + STELLAR_V2_SCHEME_ID.into_val(&env), + 0u32.into_val(&env), + METADATA_KIND_VIEW_TAG.into_val(&env), ]; assert_eq!(event.1, expected_topics); - // Announce again with scheme_id = 2 — still works. - client.announce(&2u32, &addr, &epk, &meta); + client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &second_meta); let events2 = env.events().all(); let event2 = events2.last().unwrap(); let expected_topics2: soroban_sdk::Vec = vec![ &env, symbol_short!("announce").into_val(&env), - 2u32.into_val(&env), - addr.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); } + + #[test] + #[should_panic] + fn test_announce_rejects_v1_scheme_id() { + let env = Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + let addr = Address::generate(&env); + let epk = BytesN::from_array(&env, &[1u8; 32]); + let meta = Bytes::from_slice(&env, &[0u8; 1]); + + client.announce(&1u32, &addr, &epk, &meta); + } + + #[test] + #[should_panic] + fn test_announce_rejects_missing_view_tag() { + let env = Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + let addr = Address::generate(&env); + let epk = BytesN::from_array(&env, &[1u8; 32]); + let meta = Bytes::new(&env); + + client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &meta); + } } diff --git a/stellar/stealth-announcer/test_snapshots/test/test_announce_emits_event.1.json b/stellar/stealth-announcer/test_snapshots/test/test_announce_emits_event.1.json index f80a4d2..a5d9aa7 100644 --- a/stellar/stealth-announcer/test_snapshots/test/test_announce_emits_event.1.json +++ b/stellar/stealth-announcer/test_snapshots/test/test_announce_emits_event.1.json @@ -85,22 +85,25 @@ "symbol": "announce" }, { - "u32": 1 + "u32": 2 + }, + { + "u32": 42 }, { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + "u32": 1 } ], "data": { "vec": [ { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" }, { "bytes": "0101010101010101010101010101010101010101010101010101010101010101" }, { - "bytes": "00" + "bytes": "2a07" } ] } diff --git a/stellar/stealth-announcer/test_snapshots/test/test_announce_different_schemes.1.json b/stellar/stealth-announcer/test_snapshots/test/test_view_tag_bucket_derives_from_first_metadata_byte.1.json similarity index 95% rename from stellar/stealth-announcer/test_snapshots/test/test_announce_different_schemes.1.json rename to stellar/stealth-announcer/test_snapshots/test/test_view_tag_bucket_derives_from_first_metadata_byte.1.json index b9acfab..bf857fb 100644 --- a/stellar/stealth-announcer/test_snapshots/test/test_announce_different_schemes.1.json +++ b/stellar/stealth-announcer/test_snapshots/test/test_view_tag_bucket_derives_from_first_metadata_byte.1.json @@ -89,19 +89,22 @@ "u32": 2 }, { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + "u32": 255 + }, + { + "u32": 1 } ], "data": { "vec": [ { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" }, { "bytes": "0101010101010101010101010101010101010101010101010101010101010101" }, { - "bytes": "00" + "bytes": "ff63" } ] } diff --git a/stellar/stealth-announcer/tests/audit.rs b/stellar/stealth-announcer/tests/audit.rs index 734a347..e7a269f 100644 --- a/stellar/stealth-announcer/tests/audit.rs +++ b/stellar/stealth-announcer/tests/audit.rs @@ -2,7 +2,10 @@ use soroban_sdk::testutils::{Address as _, EnvTestConfig, Events}; use soroban_sdk::{ contract, contractimpl, symbol_short, vec, Address, Bytes, BytesN, Env, FromVal, IntoVal, Val, }; -use stealth_announcer::{StealthAnnouncerContract, StealthAnnouncerContractClient}; +use stealth_announcer::{ + StealthAnnouncerContract, StealthAnnouncerContractClient, METADATA_KIND_VIEW_TAG, + STELLAR_V2_SCHEME_ID, +}; fn audit_env() -> Env { Env::new_with_config(EnvTestConfig { @@ -11,7 +14,7 @@ fn audit_env() -> Env { } #[test] -fn wa_ann_01_caller_payload_is_contract_not_invoker() { +fn wa_ann_01_v2_payload_uses_stealth_address_not_caller() { let env = audit_env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); @@ -21,14 +24,19 @@ fn wa_ann_01_caller_payload_is_contract_not_invoker() { let ephemeral_pub_key = BytesN::from_array(&env, &[1u8; 32]); let metadata = Bytes::from_slice(&env, &[0u8; 1]); - client.announce(&1u32, &stealth_address, &ephemeral_pub_key, &metadata); + client.announce( + &STELLAR_V2_SCHEME_ID, + &stealth_address, + &ephemeral_pub_key, + &metadata, + ); let events = env.events().all(); let event = events.last().unwrap(); let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2); assert_ne!(contract_id, invoker); - assert_eq!(actual_value, (contract_id, ephemeral_pub_key, metadata)); + assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata)); } #[test] @@ -41,13 +49,18 @@ fn wa_ann_02_oversized_metadata_is_accepted() { let ephemeral_pub_key = BytesN::from_array(&env, &[2u8; 32]); let metadata = Bytes::from_array(&env, &[7u8; 4096]); - client.announce(&1u32, &stealth_address, &ephemeral_pub_key, &metadata); + client.announce( + &STELLAR_V2_SCHEME_ID, + &stealth_address, + &ephemeral_pub_key, + &metadata, + ); let events = env.events().all(); let event = events.last().unwrap(); let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2); - assert_eq!(actual_value, (contract_id, ephemeral_pub_key, metadata)); + assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata)); } #[test] @@ -60,7 +73,12 @@ fn wa_ann_03_zero_ephemeral_pub_key_is_accepted() { let zero_ephemeral_pub_key = BytesN::from_array(&env, &[0u8; 32]); let metadata = Bytes::from_slice(&env, &[0u8; 1]); - client.announce(&1u32, &stealth_address, &zero_ephemeral_pub_key, &metadata); + client.announce( + &STELLAR_V2_SCHEME_ID, + &stealth_address, + &zero_ephemeral_pub_key, + &metadata, + ); let events = env.events().all(); let event = events.last().unwrap(); @@ -68,7 +86,7 @@ fn wa_ann_03_zero_ephemeral_pub_key_is_accepted() { assert_eq!( actual_value, - (contract_id, zero_ephemeral_pub_key, metadata) + (stealth_address, zero_ephemeral_pub_key, metadata) ); } @@ -103,7 +121,7 @@ fn wa_ann_04_cpi_can_emit_announcements_without_auth() { forwarder.forward( &announcer_id, - &1u32, + &STELLAR_V2_SCHEME_ID, &stealth_address, &ephemeral_pub_key, &metadata, @@ -114,12 +132,13 @@ fn wa_ann_04_cpi_can_emit_announcements_without_auth() { let expected_topics: soroban_sdk::Vec = vec![ &env, symbol_short!("announce").into_val(&env), - 1u32.into_val(&env), - stealth_address.into_val(&env), + STELLAR_V2_SCHEME_ID.into_val(&env), + 0u32.into_val(&env), + METADATA_KIND_VIEW_TAG.into_val(&env), ]; let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2); assert_eq!(event.0, announcer_id.clone()); assert_eq!(event.1, expected_topics); - assert_eq!(actual_value, (announcer_id, ephemeral_pub_key, metadata)); + assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata)); } From 2c82bdb087ae9445852b9b5990f2c46627b4bf46 Mon Sep 17 00:00:00 2001 From: gloskull Date: Mon, 1 Jun 2026 20:24:00 +0100 Subject: [PATCH 3/3] fix(stellar): reconcile announcer v2 audit coverage --- .../audits/2026-05-gpt-5-3-codex.md | 19 ++++- stellar/stealth-announcer/tests/audit.rs | 43 +++++++--- stellar/stealth-announcer/tests/properties.rs | 81 +++++++++++++------ 3 files changed, 103 insertions(+), 40 deletions(-) diff --git a/stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md b/stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md index c843563..5d02b2d 100644 --- a/stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md +++ b/stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md @@ -16,7 +16,7 @@ Reviewed files and references: - `contracts/evm/contracts/interfaces/IERC5564Announcer.sol` - Soroban auth model documentation requested in the issue brief -Testing added in `contracts/stellar/stealth-announcer/tests/audit.rs` reproduces each finding. +Testing added in `contracts/stellar/stealth-announcer/tests/audit.rs` originally reproduced each v1 finding. After the v2 redesign, the same audit test file keeps coverage for the historical finding categories while asserting the new v2 event shape and migration guard rails. ## Findings table @@ -81,8 +81,23 @@ This is Informational because permissionless announcement is an intended design **Recommendation:** Keep `announce` permissionless if ERC-5564-style public announcing is the desired model, but do not let clients infer user authorization from the event alone. If caller provenance matters, implement the WA-ANN-01 recommendation with an explicit authenticated caller or explicitly document that Stellar announcements are anonymous/unattributed and CPI-safe by design. +## V2 redesign notes + +The follow-up Stellar announcer implementation intentionally keeps the v1 audit findings above as historical contract documentation instead of rewriting them in place. Existing v1 events used topics `(announce, scheme_id, stealth_address)` and data `(caller, ephemeral_pub_key, metadata)`, so indexers must continue parsing that stream with the v1 schema. The v2 compatibility path is a new announcer deployment, not reinterpretation of already-emitted v1 events. + +The v2 deployment addresses the event-shape ambiguity by fixing `STELLAR_V2_SCHEME_ID = 2` and enforcing it with a strict `assert_eq!(scheme_id, STELLAR_V2_SCHEME_ID)` guard. That makes accidental v1-style calls fail fast and gives indexers an unambiguous scheme filter for the new deployment. + +The v2 event shape is: + +- Topics: `(announce, scheme_id, view_tag_bucket, metadata_kind)` +- Data: `(stealth_address, ephemeral_pub_key, metadata)` + +`view_tag_bucket` is derived stably as `metadata[0] as u32`, and `metadata_kind = 1` records that the first metadata byte is the view tag while the remaining bytes are scheme-specific metadata. This moves the indexed stealth-address-sized topic out of the topic set, replaces it with a compact recipient scan bucket, and removes the misleading v1 `caller` payload field from the v2 data tuple. + +The v2 redesign resolves the WA-ANN-01 caller-attribution ambiguity for new deployments by no longer publishing the announcer contract address as a caller-equivalent payload. WA-ANN-02 and WA-ANN-03 remain operational/client-hardening considerations: v2 still accepts large metadata subject to Soroban resource limits, and `BytesN<32>` still does not prove that an ephemeral public key is curve-valid. WA-ANN-04 remains an intended permissionless-announcement property, but CPI-originated v2 events are no longer coupled to a misleading `caller` value. + ## Additional observations - Dependency pinning is simple: the Stellar workspace pins `soroban-sdk` through a workspace dependency. The audit did not identify risky optional runtime features in the announcer crate; `testutils` is limited to dev-dependencies. -- Topic ordering is deterministic and covered by existing in-tree tests: `(symbol_short!("announce"), scheme_id, stealth_address)`. Keep this order stable because indexers likely key on it. +- Historical v1 topic ordering remains documented as `(symbol_short!("announce"), scheme_id, stealth_address)`. The v2 tests cover the new deterministic ordering `(symbol_short!("announce"), scheme_id, view_tag_bucket, metadata_kind)`, and indexers must not parse one shape as the other. - The crate now declares `rlib` in addition to `cdylib` so integration audit tests can import the generated client. This does not change deployed WASM behavior. diff --git a/stellar/stealth-announcer/tests/audit.rs b/stellar/stealth-announcer/tests/audit.rs index 734a347..e7a269f 100644 --- a/stellar/stealth-announcer/tests/audit.rs +++ b/stellar/stealth-announcer/tests/audit.rs @@ -2,7 +2,10 @@ use soroban_sdk::testutils::{Address as _, EnvTestConfig, Events}; use soroban_sdk::{ contract, contractimpl, symbol_short, vec, Address, Bytes, BytesN, Env, FromVal, IntoVal, Val, }; -use stealth_announcer::{StealthAnnouncerContract, StealthAnnouncerContractClient}; +use stealth_announcer::{ + StealthAnnouncerContract, StealthAnnouncerContractClient, METADATA_KIND_VIEW_TAG, + STELLAR_V2_SCHEME_ID, +}; fn audit_env() -> Env { Env::new_with_config(EnvTestConfig { @@ -11,7 +14,7 @@ fn audit_env() -> Env { } #[test] -fn wa_ann_01_caller_payload_is_contract_not_invoker() { +fn wa_ann_01_v2_payload_uses_stealth_address_not_caller() { let env = audit_env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); @@ -21,14 +24,19 @@ fn wa_ann_01_caller_payload_is_contract_not_invoker() { let ephemeral_pub_key = BytesN::from_array(&env, &[1u8; 32]); let metadata = Bytes::from_slice(&env, &[0u8; 1]); - client.announce(&1u32, &stealth_address, &ephemeral_pub_key, &metadata); + client.announce( + &STELLAR_V2_SCHEME_ID, + &stealth_address, + &ephemeral_pub_key, + &metadata, + ); let events = env.events().all(); let event = events.last().unwrap(); let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2); assert_ne!(contract_id, invoker); - assert_eq!(actual_value, (contract_id, ephemeral_pub_key, metadata)); + assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata)); } #[test] @@ -41,13 +49,18 @@ fn wa_ann_02_oversized_metadata_is_accepted() { let ephemeral_pub_key = BytesN::from_array(&env, &[2u8; 32]); let metadata = Bytes::from_array(&env, &[7u8; 4096]); - client.announce(&1u32, &stealth_address, &ephemeral_pub_key, &metadata); + client.announce( + &STELLAR_V2_SCHEME_ID, + &stealth_address, + &ephemeral_pub_key, + &metadata, + ); let events = env.events().all(); let event = events.last().unwrap(); let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2); - assert_eq!(actual_value, (contract_id, ephemeral_pub_key, metadata)); + assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata)); } #[test] @@ -60,7 +73,12 @@ fn wa_ann_03_zero_ephemeral_pub_key_is_accepted() { let zero_ephemeral_pub_key = BytesN::from_array(&env, &[0u8; 32]); let metadata = Bytes::from_slice(&env, &[0u8; 1]); - client.announce(&1u32, &stealth_address, &zero_ephemeral_pub_key, &metadata); + client.announce( + &STELLAR_V2_SCHEME_ID, + &stealth_address, + &zero_ephemeral_pub_key, + &metadata, + ); let events = env.events().all(); let event = events.last().unwrap(); @@ -68,7 +86,7 @@ fn wa_ann_03_zero_ephemeral_pub_key_is_accepted() { assert_eq!( actual_value, - (contract_id, zero_ephemeral_pub_key, metadata) + (stealth_address, zero_ephemeral_pub_key, metadata) ); } @@ -103,7 +121,7 @@ fn wa_ann_04_cpi_can_emit_announcements_without_auth() { forwarder.forward( &announcer_id, - &1u32, + &STELLAR_V2_SCHEME_ID, &stealth_address, &ephemeral_pub_key, &metadata, @@ -114,12 +132,13 @@ fn wa_ann_04_cpi_can_emit_announcements_without_auth() { let expected_topics: soroban_sdk::Vec = vec![ &env, symbol_short!("announce").into_val(&env), - 1u32.into_val(&env), - stealth_address.into_val(&env), + STELLAR_V2_SCHEME_ID.into_val(&env), + 0u32.into_val(&env), + METADATA_KIND_VIEW_TAG.into_val(&env), ]; let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2); assert_eq!(event.0, announcer_id.clone()); assert_eq!(event.1, expected_topics); - assert_eq!(actual_value, (announcer_id, ephemeral_pub_key, metadata)); + assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata)); } diff --git a/stellar/stealth-announcer/tests/properties.rs b/stellar/stealth-announcer/tests/properties.rs index 5fc9d6e..7821446 100644 --- a/stellar/stealth-announcer/tests/properties.rs +++ b/stellar/stealth-announcer/tests/properties.rs @@ -1,7 +1,10 @@ use proptest::prelude::*; use soroban_sdk::testutils::{Address as _, EnvTestConfig, Events}; use soroban_sdk::{symbol_short, vec, Address, Bytes, BytesN, Env, IntoVal, TryFromVal, Val}; -use stealth_announcer::{StealthAnnouncerContract, StealthAnnouncerContractClient}; +use stealth_announcer::{ + view_tag_bucket, StealthAnnouncerContract, StealthAnnouncerContractClient, + METADATA_KIND_VIEW_TAG, STELLAR_V2_SCHEME_ID, +}; fn cases() -> u32 { std::env::var("WRAITH_PROPTEST_CASES") @@ -29,38 +32,41 @@ fn bytes32(env: &Env, data: &[u8]) -> BytesN<32> { proptest! { #![proptest_config(ProptestConfig { cases: cases(), .. ProptestConfig::default() })] #[test] - fn announces_once_for_valid_payloads(scheme_id in any::(), epk in any::<[u8; 32]>(), metadata in prop::collection::vec(any::(), 0..128)) { + fn announces_once_for_valid_v2_payloads(epk in any::<[u8; 32]>(), metadata in prop::collection::vec(any::(), 1..128)) { let env = env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); let stealth_address = Address::generate(&env); - client.announce(&scheme_id, &stealth_address, &bytes32(&env, &epk), &bytes(&env, &metadata)); + client.announce(&STELLAR_V2_SCHEME_ID, &stealth_address, &bytes32(&env, &epk), &bytes(&env, &metadata)); prop_assert_eq!(env.events().all().len(), 1); } #[test] - fn topics_round_trip_verbatim(scheme_id in any::(), epk in any::<[u8; 32]>(), metadata in prop::collection::vec(any::(), 0..128)) { + fn v2_topics_include_scheme_view_tag_bucket_and_metadata_kind(epk in any::<[u8; 32]>(), metadata in prop::collection::vec(any::(), 1..128)) { let env = env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); let stealth_address = Address::generate(&env); + let metadata = bytes(&env, &metadata); + let bucket = view_tag_bucket(&metadata); - client.announce(&scheme_id, &stealth_address, &bytes32(&env, &epk), &bytes(&env, &metadata)); + client.announce(&STELLAR_V2_SCHEME_ID, &stealth_address, &bytes32(&env, &epk), &metadata); let event = env.events().all().last().unwrap(); let expected_topics: soroban_sdk::Vec = vec![ &env, symbol_short!("announce").into_val(&env), - scheme_id.into_val(&env), - stealth_address.into_val(&env), + STELLAR_V2_SCHEME_ID.into_val(&env), + bucket.into_val(&env), + METADATA_KIND_VIEW_TAG.into_val(&env), ]; prop_assert_eq!(event.1, expected_topics); } #[test] - fn payload_round_trips_verbatim(scheme_id in any::(), epk in any::<[u8; 32]>(), metadata in prop::collection::vec(any::(), 0..128)) { + fn v2_payload_round_trips_without_caller(epk in any::<[u8; 32]>(), metadata in prop::collection::vec(any::(), 1..128)) { let env = env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); @@ -68,47 +74,70 @@ proptest! { let epk = bytes32(&env, &epk); let metadata = bytes(&env, &metadata); - client.announce(&scheme_id, &stealth_address, &epk, &metadata); + client.announce(&STELLAR_V2_SCHEME_ID, &stealth_address, &epk, &metadata); let event = env.events().all().last().unwrap(); let actual_value: (Address, BytesN<32>, Bytes) = <(Address, BytesN<32>, Bytes)>::try_from_val(&env, &event.2).unwrap(); - prop_assert_eq!(actual_value, (contract_id, epk, metadata)); + prop_assert_eq!(actual_value, (stealth_address, epk, metadata)); } #[test] - fn repeated_announcements_publish_latest_call(scheme_id in any::(), next_scheme_id in any::(), epk in any::<[u8; 32]>()) { + fn repeated_v2_announcements_publish_latest_view_tag_bucket(epk in any::<[u8; 32]>(), first_view_tag in any::(), second_view_tag in any::()) { let env = env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); let stealth_address = Address::generate(&env); let epk = bytes32(&env, &epk); - let metadata = bytes(&env, &[7]); + let first_metadata = bytes(&env, &[first_view_tag]); + let second_metadata = bytes(&env, &[second_view_tag]); - client.announce(&scheme_id, &stealth_address, &epk, &metadata); - client.announce(&next_scheme_id, &stealth_address, &epk, &metadata); + client.announce(&STELLAR_V2_SCHEME_ID, &stealth_address, &epk, &first_metadata); + client.announce(&STELLAR_V2_SCHEME_ID, &stealth_address, &epk, &second_metadata); let event = env.events().all().last().unwrap(); let expected_topics: soroban_sdk::Vec = vec![ &env, symbol_short!("announce").into_val(&env), - next_scheme_id.into_val(&env), - stealth_address.into_val(&env), + STELLAR_V2_SCHEME_ID.into_val(&env), + (second_view_tag as u32).into_val(&env), + METADATA_KIND_VIEW_TAG.into_val(&env), ]; prop_assert_eq!(event.1, expected_topics); } +} - #[test] - fn zero_length_metadata_is_valid(scheme_id in any::(), epk in any::<[u8; 32]>()) { - let env = env(); - let contract_id = env.register(StealthAnnouncerContract, ()); - let client = StealthAnnouncerContractClient::new(&env, &contract_id); - let stealth_address = Address::generate(&env); - - client.announce(&scheme_id, &stealth_address, &bytes32(&env, &epk), &Bytes::new(&env)); +#[test] +#[should_panic] +fn zero_length_metadata_is_rejected() { + let env = env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + let stealth_address = Address::generate(&env); + + client.announce( + &STELLAR_V2_SCHEME_ID, + &stealth_address, + &bytes32(&env, &[1u8; 32]), + &Bytes::new(&env), + ); +} - prop_assert_eq!(env.events().all().len(), 1); - } +#[test] +#[should_panic] +fn non_v2_scheme_id_is_rejected() { + let env = env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + let stealth_address = Address::generate(&env); + let metadata = bytes(&env, &[7]); + + client.announce( + &1u32, + &stealth_address, + &bytes32(&env, &[1u8; 32]), + &metadata, + ); } #[test]