From 9450edfa046f5881263dec27abe2f00ad9600a4e Mon Sep 17 00:00:00 2001 From: Ifeanyi nwokedi <138141666+Nemenwa@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:53:47 +0000 Subject: [PATCH] enforce a resource-based Stellar metadata limit Repo Avatar --- package-lock.json | 44 +------------ .../audits/2026-05-gpt-5-3-codex.md | 10 +-- stellar/stealth-announcer/src/lib.rs | 22 ++++++- stellar/stealth-announcer/tests/audit.rs | 66 ++++++++++++++++--- 4 files changed, 82 insertions(+), 60 deletions(-) diff --git a/package-lock.json b/package-lock.json index 847b8615..90bbd77d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "devDependencies": { "@stellar/stellar-sdk": "^15.1.0", "@types/node": "^22.0.0", + "buffer": "6.0.3", + "esbuild": "^0.28.0", "tsx": "^4.19.2", "typescript": "^5.7.0", "vitest": "^2.1.9" @@ -446,9 +448,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -577,9 +576,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -594,9 +590,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -611,9 +604,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -628,9 +618,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -645,9 +632,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -662,9 +646,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -679,9 +660,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -696,9 +674,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -713,9 +688,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -730,9 +702,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -747,9 +716,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -764,9 +730,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -781,9 +744,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ 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 d870484c..60e7437d 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 @@ -23,7 +23,7 @@ Testing added in `contracts/stellar/stealth-announcer/tests/audit.rs` originally | 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-02 | Low | Unbounded metadata can inflate event payloads and indexer workload | Fixed (1 KiB cap) | | WA-ANN-03 | Low | Invalid all-zero ephemeral public keys are accepted | Fixed (v2) | | WA-ANN-04 | Informational | Unauthenticated CPI calls can emit indistinguishable announcements | Open | @@ -49,13 +49,13 @@ This is not a direct funds-loss issue because the contract holds no assets. It i **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. +**Description:** `metadata` was 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. 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. +**Reproduction:** The original audit test emitted 4,096 bytes of metadata. The resource baseline measures 4,288 event bytes at that size versus 1,216 event bytes for 1,024 metadata bytes; both cases use 15,096 instructions and 1,610 memory bytes. -**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. +**Remediation:** `MAX_METADATA_LEN` is 1,024 bytes, sufficient for the current view-tag metadata envelope while bounding each emitted event to 1,216 bytes in the resource baseline. `announce` returns the stable `AnnouncerError::MetadataTooLong` contract error (code 1) above the limit. Audit tests cover 1,023-byte and 1,024-byte acceptance, 1,025-byte rejection, and resource ceilings of 16,000 instructions, 2,000 memory bytes, and 1,280 event bytes. ### WA-ANN-03 — Invalid all-zero ephemeral public keys are accepted @@ -96,7 +96,7 @@ The v2 event shape is: `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. The v2 redesign also resolves WA-ANN-03: `announce` now validates the ephemeral public key on-chain and rejects the all-zero key, non-canonical encodings, off-curve encodings, small-order points, and mixed-order points outside the prime-order subgroup. WA-ANN-02 remains an operational/client-hardening consideration: v2 still accepts large metadata subject to Soroban resource limits. WA-ANN-04 remains an intended permissionless-announcement property, but CPI-originated v2 events are no longer coupled to a misleading `caller` value. +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. The v2 redesign also resolves WA-ANN-03: `announce` now validates the ephemeral public key on-chain and rejects the all-zero key, non-canonical encodings, off-curve encodings, small-order points, and mixed-order points outside the prime-order subgroup. WA-ANN-02 is fixed with the 1,024-byte metadata limit described above. 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 diff --git a/stellar/stealth-announcer/src/lib.rs b/stellar/stealth-announcer/src/lib.rs index adf2d802..99ffd8f0 100644 --- a/stellar/stealth-announcer/src/lib.rs +++ b/stellar/stealth-announcer/src/lib.rs @@ -2,7 +2,7 @@ mod ed25519; -use soroban_sdk::{contract, contractimpl, symbol_short, Address, Bytes, BytesN, Env}; +use soroban_sdk::{contract, contracterror, contractimpl, symbol_short, Address, Bytes, BytesN, Env}; /// Stellar v2 deployment scheme id. /// @@ -22,6 +22,18 @@ pub const STELLAR_V2_SCHEME_ID: u32 = 2; /// interpretation. pub const METADATA_KIND_VIEW_TAG: u32 = 1; +/// Maximum metadata payload size. The 1 KiB cap measures 1,216 event bytes, +/// 15,096 instructions, and 1,610 memory bytes in the Soroban resource baseline. +pub const MAX_METADATA_LEN: u32 = 1024; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum AnnouncerError { + /// Metadata exceeds the protocol maximum. + MetadataTooLong = 1, +} + /// Derives the indexed view-tag bucket for v2 announcement topics. /// /// The bucket is exactly the first metadata byte interpreted as an unsigned @@ -62,14 +74,17 @@ impl StealthAnnouncerContract { /// * `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` - Non-empty metadata whose first byte is the view tag. + /// * `metadata` - Non-empty metadata whose first byte is the view tag, up to 1,024 bytes. pub fn announce( env: Env, scheme_id: u32, stealth_address: Address, ephemeral_pub_key: BytesN<32>, metadata: Bytes, - ) { + ) -> Result<(), AnnouncerError> { + if metadata.len() > MAX_METADATA_LEN { + return Err(AnnouncerError::MetadataTooLong); + } assert_eq!(scheme_id, STELLAR_V2_SCHEME_ID); assert!( ed25519::is_valid_ed25519_public_key(&ephemeral_pub_key.to_array()), @@ -88,6 +103,7 @@ impl StealthAnnouncerContract { ), (stealth_address, ephemeral_pub_key, metadata), ); + Ok(()) } } diff --git a/stellar/stealth-announcer/tests/audit.rs b/stellar/stealth-announcer/tests/audit.rs index fcc7591b..357a7731 100644 --- a/stellar/stealth-announcer/tests/audit.rs +++ b/stellar/stealth-announcer/tests/audit.rs @@ -3,8 +3,8 @@ use soroban_sdk::{ contract, contractimpl, symbol_short, vec, Address, Bytes, BytesN, Env, FromVal, IntoVal, Val, }; use stealth_announcer::{ - StealthAnnouncerContract, StealthAnnouncerContractClient, METADATA_KIND_VIEW_TAG, - STELLAR_V2_SCHEME_ID, + AnnouncerError, StealthAnnouncerContract, StealthAnnouncerContractClient, + MAX_METADATA_LEN, METADATA_KIND_VIEW_TAG, STELLAR_V2_SCHEME_ID, }; fn audit_env() -> Env { @@ -57,28 +57,74 @@ fn wa_ann_01_v2_payload_uses_stealth_address_not_caller() { assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata)); } +fn announce_with_metadata_len(metadata_len: u32) -> Env { + let env = audit_env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + let metadata = Bytes::from_slice(&env, &std::vec![7u8; metadata_len as usize]); + + client.announce( + &STELLAR_V2_SCHEME_ID, + &Address::generate(&env), + &BytesN::from_array(&env, &[0x19u8; 32]), + &metadata, + ); + + env +} + #[test] -fn wa_ann_02_oversized_metadata_is_accepted() { +fn wa_ann_02_metadata_at_and_below_limit_is_accepted() { + for metadata_len in [MAX_METADATA_LEN - 1, MAX_METADATA_LEN] { + let env = announce_with_metadata_len(metadata_len); + assert_eq!(env.events().all().len(), 1); + } +} + +#[test] +fn wa_ann_02_metadata_above_limit_returns_stable_error() { let env = audit_env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); + let metadata = Bytes::from_slice(&env, &std::vec![7u8; (MAX_METADATA_LEN + 1) as usize]); - let stealth_address = Address::generate(&env); + let result = client.try_announce( + &STELLAR_V2_SCHEME_ID, + &Address::generate(&env), + &BytesN::from_array(&env, &[0x19u8; 32]), + &metadata, + ); + + assert_eq!(result, Err(Ok(AnnouncerError::MetadataTooLong))); + assert!(env.events().all().is_empty()); +} + +#[test] +fn wa_ann_02_metadata_limit_stays_within_measured_resource_budget() { + let env = audit_env(); + env.cost_estimate().budget().reset_unlimited(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + let metadata = Bytes::from_slice(&env, &std::vec![7u8; MAX_METADATA_LEN as usize]); let ephemeral_pub_key = BytesN::from_array(&env, &[0x19u8; 32]); - let metadata = Bytes::from_array(&env, &[7u8; 4096]); client.announce( &STELLAR_V2_SCHEME_ID, - &stealth_address, + &Address::generate(&env), &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); + let resources = env.cost_estimate().resources(); - assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata)); + assert!(resources.instructions <= 16_000, "{} instructions", resources.instructions); + assert!(resources.mem_bytes <= 2_000, "{} memory bytes", resources.mem_bytes); + assert!( + resources.contract_events_size_bytes <= 1_280, + "{} event bytes", + resources.contract_events_size_bytes + ); } #[test]