Skip to content

Commit a29e192

Browse files
author
hunter-baddie
committed
feat:implement New contract: stealth-address announcement registry
1 parent d94a543 commit a29e192

5 files changed

Lines changed: 804 additions & 0 deletions

File tree

contracts/Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

contracts/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ members = [
2323
"contracts/guardian",
2424
"contracts/fx-oracle",
2525
"contracts/payment-splitter",
26+
"contracts/stealth-announcement",
2627
"integration_tests",
2728
]
2829

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "stealth-announcement"
3+
version = "0.0.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[lib]
8+
crate-type = ["lib", "cdylib"]
9+
doctest = false
10+
11+
[dependencies]
12+
soroban-sdk = { workspace = true }
13+
14+
[dev-dependencies]
15+
soroban-sdk = { workspace = true, features = ["testutils"] }
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
#![no_std]
2+
//! Stealth Address Announcement Registry.
3+
//!
4+
//! # Purpose
5+
//! Senders of stealth payments publish an **ephemeral pubkey** (R = r·G) alongside
6+
//! an optional **view tag** on-chain. Recipients scan announcements with their
7+
//! view private key and, when the view tag matches, derive the one‑time stealth
8+
//! address P = spend_pubkey + H(view_privkey · R)·G to detect incoming payments.
9+
//!
10+
//! # Privacy (No Linkability)
11+
//! The contract deliberately stores **no sender identity, no recipient identity,
12+
//! and no amount or asset**. Every field is public by the very nature of the
13+
//! stealth protocol:
14+
//!
15+
//! * `ephemeral_pubkey` – already committed in the payment transaction memo.
16+
//! * `view_tag` – the first byte of a hash anyone can *try* to match
17+
//! against their own view key. A single byte leaks
18+
//! at most 1/256 of the search space and is purely a
19+
//! scanning optimisation.
20+
//! * `announcement_index` – a monotonic counter for pagination.
21+
//! * `timestamp` – ledger time, public regardless.
22+
//!
23+
//! An observer learns only that *someone* published a stealth announcement;
24+
//! they cannot tell which recipient it is for, nor link two announcements to
25+
//! the same recipient without knowledge of that recipient's view private key.
26+
27+
use soroban_sdk::{
28+
contract, contracterror, contractevent, contractimpl, contracttype, vec, Address, Bytes, Env,
29+
Vec,
30+
};
31+
32+
// ============================================================================
33+
// Errors
34+
// ============================================================================
35+
36+
#[contracterror]
37+
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
38+
pub enum AnnouncementError {
39+
AlreadyInit = 1, // contract already initialised
40+
EmptyPubkey = 2, // ephemeral_pubkey has zero length
41+
PubkeyTooLong = 3, // ephemeral_pubkey exceeds 128 bytes
42+
RangeTooLarge = 4, // pagination range exceeds MAX_PAGE_SIZE
43+
InvalidRange = 5, // start > end
44+
NotAdmin = 6, // caller is not the admin
45+
}
46+
47+
/// Maximum number of announcements returned in a single paginated query.
48+
pub const MAX_PAGE_SIZE: u64 = 100;
49+
50+
/// Maximum acceptable byte length for an ephemeral pubkey.
51+
/// secp256k1 compressed = 33 bytes; uncompressed = 65 bytes;
52+
/// we accept up to 128 bytes to leave room for future schemes.
53+
pub const MAX_PUBKEY_LEN: u32 = 128;
54+
55+
// ============================================================================
56+
// Types
57+
// ============================================================================
58+
59+
/// A single on‑chain stealth announcement.
60+
///
61+
/// Every field is public information; nothing links the announcement to a
62+
/// specific recipient or sender.
63+
#[contracttype]
64+
#[derive(Clone, Debug, Eq, PartialEq)]
65+
pub struct StealthAnnouncement {
66+
/// Ephemeral public key R = r·G. Variable‑length bytes so the registry
67+
/// is curve‑agnostic (secp256k1 compressed 33 B, ed25519 32 B, …).
68+
pub ephemeral_pubkey: Bytes,
69+
/// View tag — low 8 bits of H(ECDH(view_pubkey, R)), stored as u32
70+
/// because Soroban does not expose a raw u8 ABI. Recipients reject
71+
/// ~255/256 non‑matching announcements without a full scalar mul.
72+
pub view_tag: u32,
73+
/// Monotonically increasing index assigned at publish time. Used as the
74+
/// pagination cursor — guarantees order without leaking timing correlation
75+
/// beyond the public ledger timestamp.
76+
pub announcement_index: u64,
77+
/// Ledger timestamp when the announcement was recorded.
78+
pub timestamp: u64,
79+
}
80+
81+
#[contracttype]
82+
#[derive(Clone)]
83+
enum DataKey {
84+
Admin,
85+
AnnouncementCount,
86+
Announcement(u64),
87+
}
88+
89+
// ============================================================================
90+
// Events
91+
// ============================================================================
92+
93+
/// Fired when a new announcement is published. The index is the only new
94+
/// piece of data (the rest are also in calldata) — included for event log
95+
/// consumers that want a short lookup key.
96+
#[contractevent]
97+
pub struct AnnouncementPublished {
98+
pub announcement_index: u64,
99+
pub view_tag: u32,
100+
}
101+
102+
// ============================================================================
103+
// Contract
104+
// ============================================================================
105+
106+
#[contract]
107+
pub struct StealthAnnouncementContract;
108+
109+
#[contractimpl]
110+
impl StealthAnnouncementContract {
111+
// ------------------------------------------------------------------------
112+
// Initialisation
113+
// ------------------------------------------------------------------------
114+
115+
/// Initialise the registry with an optional `admin` address.
116+
///
117+
/// The admin has no special powers over announcements (anyone can publish,
118+
/// anyone can query) — the role exists purely to allow future upgrades of
119+
/// the contract if the workspace adopts the `contract-upgrade` pattern.
120+
/// Pass the zero/any address if you want an effectively immutable registry.
121+
pub fn init(env: Env, admin: Address) -> Result<(), AnnouncementError> {
122+
if env.storage().instance().has(&DataKey::Admin) {
123+
return Err(AnnouncementError::AlreadyInit);
124+
}
125+
env.storage().instance().set(&DataKey::Admin, &admin);
126+
env.storage()
127+
.instance()
128+
.set(&DataKey::AnnouncementCount, &0u64);
129+
Ok(())
130+
}
131+
132+
/// Return the admin address, if any. Exposed for upgrade tooling.
133+
pub fn get_admin(env: Env) -> Option<Address> {
134+
env.storage().instance().get(&DataKey::Admin)
135+
}
136+
137+
// ------------------------------------------------------------------------
138+
// Publish
139+
// ------------------------------------------------------------------------
140+
141+
/// Publish a stealth announcement.
142+
///
143+
/// # Arguments
144+
/// * `ephemeral_pubkey` – ephemeral public key R. Variable length; the
145+
/// contract does *not* validate the point (validation is the scanner's
146+
/// responsibility and skipping it keeps the registry lightweight and
147+
/// curve‑agnostic). However we do reject empty bytes and cap length at
148+
/// `MAX_PUBKEY_LEN` to prevent griefing.
149+
/// * `view_tag` – first byte of `H(shared_secret)`. The contract does
150+
/// **not** verify it (the sender could even lie); recipients do the
151+
/// check off‑chain and simply skip false positives.
152+
///
153+
/// # Returns
154+
/// The `announcement_index` assigned to this entry.
155+
///
156+
/// # Permissions
157+
/// **Any** address may call `publish`. Announcements are inherently
158+
/// self‑authenticating via the ECDH that recipients run; spamming costs
159+
/// the sender transaction fees and recipients' view‑tag filter discards
160+
/// junk at ~1/256 false‑positive rate.
161+
pub fn publish(
162+
env: Env,
163+
ephemeral_pubkey: Bytes,
164+
view_tag: u32,
165+
) -> Result<u64, AnnouncementError> {
166+
if ephemeral_pubkey.is_empty() {
167+
return Err(AnnouncementError::EmptyPubkey);
168+
}
169+
if ephemeral_pubkey.len() > MAX_PUBKEY_LEN {
170+
return Err(AnnouncementError::PubkeyTooLong);
171+
}
172+
// Mask to the low 8 bits so the stored value is always 0..=255
173+
// regardless of what the caller passes. Scanners only inspect one
174+
// byte; keeping the range small makes the on‑chain data uniform and
175+
// avoids accidentally leaking a 32‑bit correlate.
176+
let view_tag_clamped = view_tag & 0xFF;
177+
178+
let count: u64 = env
179+
.storage()
180+
.instance()
181+
.get(&DataKey::AnnouncementCount)
182+
.unwrap_or(0);
183+
184+
let index = count
185+
.checked_add(1)
186+
.expect("announcement index overflow");
187+
188+
env.storage()
189+
.instance()
190+
.set(&DataKey::AnnouncementCount, &index);
191+
192+
let announcement = StealthAnnouncement {
193+
ephemeral_pubkey: ephemeral_pubkey.clone(),
194+
view_tag: view_tag_clamped,
195+
announcement_index: count,
196+
timestamp: env.ledger().timestamp(),
197+
};
198+
199+
env.storage()
200+
.persistent()
201+
.set(&DataKey::Announcement(count), &announcement);
202+
203+
AnnouncementPublished {
204+
announcement_index: count,
205+
view_tag: view_tag_clamped,
206+
}
207+
.publish(&env);
208+
209+
Ok(count)
210+
}
211+
212+
// ------------------------------------------------------------------------
213+
// Query (single)
214+
// ------------------------------------------------------------------------
215+
216+
/// Fetch a single announcement by its monotonic `announcement_index`.
217+
pub fn get_announcement(env: Env, announcement_index: u64) -> Option<StealthAnnouncement> {
218+
env.storage()
219+
.persistent()
220+
.get(&DataKey::Announcement(announcement_index))
221+
}
222+
223+
/// Total number of announcements ever published.
224+
pub fn get_announcement_count(env: Env) -> u64 {
225+
env.storage()
226+
.instance()
227+
.get(&DataKey::AnnouncementCount)
228+
.unwrap_or(0)
229+
}
230+
231+
// ------------------------------------------------------------------------
232+
// Query (paginated range)
233+
// ------------------------------------------------------------------------
234+
235+
/// Fetch announcements by inclusive index range: `[start_index, end_index]`.
236+
///
237+
/// # Pagination cursors
238+
/// The caller uses `announcement_index` as a cursor. For example:
239+
/// * first page: `start = 0, end = min(99, count - 1)`
240+
/// * next page: `start = prev_end + 1, end = min(start + 99, count - 1)`
241+
///
242+
/// # Limits
243+
/// At most `MAX_PAGE_SIZE` entries are returned per call. Use multiple
244+
/// calls to walk the full set — the monotonic index guarantees you won't
245+
/// miss or double‑count entries as new announcements are appended.
246+
///
247+
/// Gaps (e.g. if an individual entry were somehow missing) are skipped
248+
/// silently — this matches the behaviour of the `subscription_logging`
249+
/// contract and keeps iteration simple.
250+
pub fn get_announcements_range(
251+
env: Env,
252+
start_index: u64,
253+
end_index: u64,
254+
) -> Result<Vec<StealthAnnouncement>, AnnouncementError> {
255+
if end_index < start_index {
256+
return Err(AnnouncementError::InvalidRange);
257+
}
258+
let range_size = end_index - start_index + 1;
259+
if range_size > MAX_PAGE_SIZE {
260+
return Err(AnnouncementError::RangeTooLarge);
261+
}
262+
263+
let mut results = vec![&env];
264+
for idx in start_index..=end_index {
265+
if let Some(a) = Self::get_announcement(env.clone(), idx) {
266+
results.push_back(a);
267+
}
268+
}
269+
Ok(results)
270+
}
271+
272+
/// Convenience: return the last `limit` announcements (most recent first).
273+
///
274+
/// Equivalent to calling `get_announcement_count()` then building a
275+
/// range `[count - limit, count - 1]`. Useful for UI "latest" widgets.
276+
///
277+
/// `limit` is capped at `MAX_PAGE_SIZE`.
278+
pub fn get_latest_announcements(
279+
env: Env,
280+
limit: u64,
281+
) -> Result<Vec<StealthAnnouncement>, AnnouncementError> {
282+
let count = Self::get_announcement_count(env.clone());
283+
if count == 0 {
284+
return Ok(vec![&env]);
285+
}
286+
let effective_limit = if limit > MAX_PAGE_SIZE {
287+
MAX_PAGE_SIZE
288+
} else {
289+
limit
290+
};
291+
let start = count.saturating_sub(effective_limit);
292+
let end = count - 1;
293+
294+
let page = Self::get_announcements_range(env.clone(), start, end)?;
295+
// Reverse so newest is first.
296+
let mut reversed = vec![&env];
297+
for i in (0..page.len()).rev() {
298+
reversed.push_back(page.get_unchecked(i).clone());
299+
}
300+
Ok(reversed)
301+
}
302+
303+
// ------------------------------------------------------------------------
304+
// Admin-only helpers (none of them can censor announcements)
305+
// ------------------------------------------------------------------------
306+
307+
fn require_admin(env: &Env) -> Result<(), AnnouncementError> {
308+
let admin: Address = env
309+
.storage()
310+
.instance()
311+
.get(&DataKey::Admin)
312+
.ok_or(AnnouncementError::NotAdmin)?;
313+
admin.require_auth();
314+
Ok(())
315+
}
316+
}
317+
318+
#[cfg(test)]
319+
mod test;

0 commit comments

Comments
 (0)