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
19 changes: 17 additions & 2 deletions stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
134 changes: 112 additions & 22 deletions stellar/stealth-announcer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,94 @@

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,
stealth_address: Address,
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),
);
}
}

#[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() {
Expand All @@ -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);

Expand All @@ -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<Val> = 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<Val> = 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<Val> = 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,22 @@
"u32": 2
},
{
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4"
"u32": 255
},
{
"u32": 1
}
],
"data": {
"vec": [
{
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4"
},
{
"bytes": "0101010101010101010101010101010101010101010101010101010101010101"
},
{
"bytes": "00"
"bytes": "ff63"
}
]
}
Expand Down
Loading