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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion stellar/stealth-announcer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]
crate-type = ["cdylib", "rlib"]

[dependencies]
soroban-sdk = { workspace = true }
Expand Down
88 changes: 88 additions & 0 deletions stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md
Original file line number Diff line number Diff line change
@@ -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.
125 changes: 125 additions & 0 deletions stellar/stealth-announcer/tests/audit.rs
Original file line number Diff line number Diff line change
@@ -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<Val> = 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));
}