From d948fad6cb6b9a52e94d6314a7c861c4a5f9e0cd Mon Sep 17 00:00:00 2001 From: JamesEjembi <263482779+JamesEjembi@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:10:50 +0100 Subject: [PATCH] feat: canonical decision payloads, settlement self-containment, version-negotiation endpoint, address auth labels - #429: sla_calc and set_int now share the 9-field canonical SLAResult field order with dup_input so a single decoder parses every decision event - #428: set_int carries the full decision (mttr minutes, threshold, rating) so settlement-only consumers reconstruct the SLA decision without a follow-up read - #427: add get_version_negotiation_info exposing protocol_version/min_compatible_protocol/storage/pause/migration state for the multi-contract handshake - #426: get_public_api labels accept_admin/accept_operator as address-scoped ("addr") since they call require_auth and match the pending-slot address, not "none" Closes #429, #428, #427, #426 --- apexchainx_calculator/src/calculation.rs | 11 +- apexchainx_calculator/src/event_schema.rs | 23 +- apexchainx_calculator/src/lib.rs | 105 +++++- apexchainx_calculator/src/tests.rs | 305 ++++++++++++++++-- .../src/version_negotiation.rs | 17 +- 5 files changed, 402 insertions(+), 59 deletions(-) diff --git a/apexchainx_calculator/src/calculation.rs b/apexchainx_calculator/src/calculation.rs index 22f45e2..1a2cec5 100644 --- a/apexchainx_calculator/src/calculation.rs +++ b/apexchainx_calculator/src/calculation.rs @@ -423,11 +423,13 @@ fn publish_sla_event(env: &Env, severity: Symbol, result: &SLAResult) { ( result.outage_id.clone(), result.status.clone(), - result.payment_type.clone(), - result.rating.clone(), result.mttr_minutes, result.threshold_minutes, result.amount, + result.payment_type.clone(), + result.rating.clone(), + result.config_version_hash, + result.recorded_at, ), ); } @@ -438,8 +440,11 @@ fn publish_settlement_intent_event(env: &Env, severity: Symbol, result: &SLAResu ( result.outage_id.clone(), result.status.clone(), - result.payment_type.clone(), + result.mttr_minutes, + result.threshold_minutes, result.amount, + result.payment_type.clone(), + result.rating.clone(), result.config_version_hash, result.recorded_at, ), diff --git a/apexchainx_calculator/src/event_schema.rs b/apexchainx_calculator/src/event_schema.rs index 6378d9b..4c185db 100644 --- a/apexchainx_calculator/src/event_schema.rs +++ b/apexchainx_calculator/src/event_schema.rs @@ -13,18 +13,31 @@ //! //! # Event Catalog //! +//! The three decision-carrying events (`sla_calc`, `set_int`, `dup_input`) +//! share a single canonical payload field order — the `SLAResult` struct +//! order — so indexers parse one layout regardless of which decision event +//! they consume. Any divergence between them is a bug (#429). The canonical +//! order is: +//! +//! (outage_id, status, mttr_minutes, threshold_minutes, amount, +//! payment_type, rating, config_version_hash, recorded_at) +//! //! ## sla_calc (`sla_calc`) //! Emitted on every successful `calculate_sla` call. //! - topic[2]: severity Symbol -//! - payload: (outage_id: Symbol, status: Symbol, payment_type: Symbol, -//! rating: Symbol, mttr_minutes: u32, threshold_minutes: u32, -//! amount: i128) +//! - payload: (outage_id: Symbol, status: Symbol, mttr_minutes: u32, +//! threshold_minutes: u32, amount: i128, payment_type: Symbol, +//! rating: Symbol, config_version_hash: u64, recorded_at: u64) //! //! ## set_int (`set_int`) //! Settlement intent emitted alongside sla_calc for backend reconciliation. +//! Carries the full decision (including `mttr_minutes`, `threshold_minutes`, +//! and `rating`) so a settlement-only consumer can reconstruct the SLA +//! decision without a follow-up read (#428). //! - topic[2]: severity Symbol -//! - payload: (outage_id: Symbol, status: Symbol, payment_type: Symbol, -//! amount: i128, config_version_hash: u64, recorded_at: u64) +//! - payload: (outage_id: Symbol, status: Symbol, mttr_minutes: u32, +//! threshold_minutes: u32, amount: i128, payment_type: Symbol, +//! rating: Symbol, config_version_hash: u64, recorded_at: u64) //! //! ## dup_input (`dup_input`) //! Emitted when `calculate_sla` rejects a conflicting duplicate `outage_id` diff --git a/apexchainx_calculator/src/lib.rs b/apexchainx_calculator/src/lib.rs index fdc6b1b..33c903d 100644 --- a/apexchainx_calculator/src/lib.rs +++ b/apexchainx_calculator/src/lib.rs @@ -239,9 +239,16 @@ pub use crate::config_metadata::LAST_CFG_UPDATE_KEY; // // ===== Event Payload Schemas ===== // -// sla_calc → (outage_id: Symbol, status: Symbol, payment_type: Symbol, -// rating: Symbol, mttr_minutes: u32, threshold_minutes: u32, -// amount: i128) +// The three decision-carrying events (sla_calc, set_int, dup_input) share a +// single canonical field order — the SLAResult struct order — so indexers +// parse one layout regardless of which decision event they consume (#429): +// +// decision → (outage_id, status, mttr_minutes, threshold_minutes, amount, +// payment_type, rating, config_version_hash, recorded_at) +// +// sla_calc → (outage_id: Symbol, status: Symbol, mttr_minutes: u32, +// threshold_minutes: u32, amount: i128, payment_type: Symbol, +// rating: Symbol, config_version_hash: u64, recorded_at: u64) // context: severity Symbol // // cfg_upd → (threshold_minutes: u32, penalty_per_minute: i128, @@ -284,8 +291,9 @@ pub use crate::config_metadata::LAST_CFG_UPDATE_KEY; // op_can → () // context: caller Address // -// set_int → (outage_id: Symbol, status: Symbol, payment_type: Symbol, -// amount: i128, config_version_hash: u64, recorded_at: u64) +// set_int → (outage_id: Symbol, status: Symbol, mttr_minutes: u32, +// threshold_minutes: u32, amount: i128, payment_type: Symbol, +// rating: Symbol, config_version_hash: u64, recorded_at: u64) // context: severity Symbol // // dup_input → (outage_id: Symbol, status: Symbol, mttr_minutes: u32, @@ -307,8 +315,13 @@ pub(crate) const EVENT_SLA_CALC: Symbol = symbol_short!("sla_calc"); /// Emitted alongside sla_calc for settlement intent reconciliation. /// -/// Compatibility decision: settlement intent fields are ordered by settlement -/// priority (id, status, payment, amount, hash, timestamp). Field additions +/// Carries the full SLA decision (including `mttr_minutes`, +/// `threshold_minutes`, and `rating`) so a consumer processing only the +/// settlement stream can reconstruct the decision without a follow-up read. +/// +/// Compatibility decision: shares the canonical decision field order +/// (`outage_id, status, mttr_minutes, threshold_minutes, amount, +/// payment_type, rating, config_version_hash, recorded_at`). Field additions /// go at the end; any reorder or removal requires a version bump. pub(crate) const EVENT_SETTLE_INTENT: Symbol = symbol_short!("set_int"); @@ -744,7 +757,13 @@ pub struct PublicApiMethod { pub name: Symbol, /// Whether the method mutates storage (`true`) or is read-only (`false`). pub mutates: bool, - /// Auth role required: "admin", "operator", or "none". + /// Auth classification. Values: + /// - `"admin"` – caller must hold the admin role. + /// - `"operator"` – caller must hold the operator role. + /// - `"addr"` – only a specific stored address may call (the pending + /// proposal slot holder must sign, e.g. `accept_admin`/`accept_operator`) + /// (#426). + /// - `"none"` – no authorization gate (read-only / public). pub auth: Symbol, /// The primary event name emitted by this method, or `Symbol::new(env, "")` if none. pub event: Symbol, @@ -1976,7 +1995,9 @@ impl SLACalculatorContract { /// Each `PublicApiMethod` contains: /// - `name`: the contract method name (e.g. "calculate_sla") /// - `mutates`: `true` if the method modifies storage - /// - `auth`: auth role required — "admin", "operator", or "none" + /// - `auth`: auth classification — `"admin"`, `"operator"`, `"addr"` + /// (a specific pending address, e.g. `accept_admin`/`accept_operator`), + /// or `"none"`. /// - `event`: the primary event name emitted, or empty if none /// /// # Errors @@ -2015,10 +2036,12 @@ impl SLACalculatorContract { // All public methods added in alphabetical order for deterministic output. // Lifecycle: // Note: accept_admin/accept_operator are called by the proposed address - // (not the current role holder), so auth is "none" — only an address - // equality check against the pending slot is performed. - methods.push_back(method("accept_admin", true, "none", "adm_acc")); - methods.push_back(method("accept_operator", true, "none", "op_acc")); + // (not the current role holder). They still call `caller.require_auth()` + // and enforce that the caller equals the pending address, so they are + // NOT "none" — they are address-scoped ("addr"— the pending slot holder + // must sign) (#426). + methods.push_back(method("accept_admin", true, "addr", "adm_acc")); + methods.push_back(method("accept_operator", true, "addr", "op_acc")); // Calculation: methods.push_back(method("calculate_sla", true, "operator", "sla_calc")); methods.push_back(method("calculate_sla_view", false, "none", "")); @@ -2060,6 +2083,7 @@ impl SLACalculatorContract { methods.push_back(method("get_storage_footprint_estimate", false, "none", "")); methods.push_back(method("get_storage_version", false, "none", "")); methods.push_back(method("get_version_info", false, "none", "")); + methods.push_back(method("get_version_negotiation_info", false, "none", "")); // Health: methods.push_back(method("healthcheck", false, "none", "")); // Init: @@ -2984,28 +3008,38 @@ impl SLACalculatorContract { } fn publish_sla_event(env: &Env, severity: Symbol, result: &SLAResult) { + // Canonical decision field order (#429): shares the SLAResult struct + // order with set_int and dup_input so indexers parse one layout. env.events().publish( (EVENT_SLA_CALC, EVENT_VERSION, severity), ( result.outage_id.clone(), result.status.clone(), - result.payment_type.clone(), - result.rating.clone(), result.mttr_minutes, result.threshold_minutes, result.amount, + result.payment_type.clone(), + result.rating.clone(), + result.config_version_hash, + result.recorded_at, ), ); } fn publish_settlement_intent_event(env: &Env, severity: Symbol, result: &SLAResult) { + // Canonical decision field order (#429) carrying the full decision + // (mttr_minutes, threshold_minutes, rating) so a settlement-only + // consumer can reconstruct the SLA decision (#428). env.events().publish( (EVENT_SETTLE_INTENT, EVENT_VERSION, severity), ( result.outage_id.clone(), result.status.clone(), - result.payment_type.clone(), + result.mttr_minutes, + result.threshold_minutes, result.amount, + result.payment_type.clone(), + result.rating.clone(), result.config_version_hash, result.recorded_at, ), @@ -3403,6 +3437,45 @@ impl SLACalculatorContract { }) } + // ------------------------------------------------------------------- + // SC-W5-078 – Version negotiation endpoint for multi-contract handshake + // ------------------------------------------------------------------- + + /// Returns the `VersionNegotiationInfo` for this contract, exposing the + /// version-negotiation protocol data (`protocol_version`, + /// `min_compatible_protocol`, storage version, pause & migration state) + /// over a live contract method. + /// + /// This makes the multi-contract handshake documented in + /// `version_negotiation.rs` and `docs/VERSION_NEGOTIATION_CONTRIBUTOR_GUIDE.md` + /// actually runnable: a coordinator/backend calls this on each peer to + /// obtain the `protocol_version`/`min_compatible_protocol` it needs to + /// feed `negotiate_contract_versions` off-chain (or via a cross-contract + /// coordinator), instead of the data living only in dead code (#427). + /// + /// Like `get_version_info`, this intentionally bypasses `check_version` + /// so it remains callable even in a pre-migration or pre-init state. + /// + /// # Returns + /// The `VersionNegotiationInfo` for this contract (empty peers: a + /// coordinator can then run the negotiation rules against a peer list it + /// assembles from these responses). + pub fn get_version_negotiation_info( + env: Env, + ) -> Result { + let stored_version: u32 = env + .storage() + .instance() + .get(&STORAGE_VERSION_KEY) + .ok_or(SLAError::NotInitialized)?; + let is_paused: bool = env.storage().instance().get(&PAUSED_KEY).unwrap_or(false); + Ok(crate::version_negotiation::build_negotiation_info( + stored_version, + STORAGE_VERSION, + is_paused, + )) + } + // ------------------------------------------------------------------- // #218 – Read-only healthcheck path for backend startup readiness // ------------------------------------------------------------------- diff --git a/apexchainx_calculator/src/tests.rs b/apexchainx_calculator/src/tests.rs index 32b76b9..e892651 100644 --- a/apexchainx_calculator/src/tests.rs +++ b/apexchainx_calculator/src/tests.rs @@ -133,7 +133,7 @@ fn test_result_schema_is_explicit_and_stable() { fn test_calculate_sla_emits_versioned_integration_event() { let (env, client, actors) = setup(); - client.calculate_sla( + let stored = client.calculate_sla( &actors.operator, &symbol_short!("EVT001"), &symbol_short!("critical"), @@ -146,23 +146,35 @@ fn test_calculate_sla_emits_versioned_integration_event() { let topic_0: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); let topic_1: Symbol = topics.get(1).unwrap().try_into_val(&env).unwrap(); let topic_2: Symbol = topics.get(2).unwrap().try_into_val(&env).unwrap(); - let event_data: (Symbol, Symbol, Symbol, Symbol, u32, u32, i128) = data.try_into_val(&env).unwrap(); + // Canonical decision field order (#429): the sla_calc payload mirrors the + // SLAResult struct order shared with set_int and dup_input, so it decodes + // as the full 9-field tuple. + let (outage_id, status, mttr, threshold, amount, payment_type, rating, hash, recorded_at): ( + Symbol, + Symbol, + u32, + u32, + i128, + Symbol, + Symbol, + u64, + u64, + ) = data.try_into_val(&env).unwrap(); assert_eq!(topic_0, EVENT_SLA_CALC); assert_eq!(topic_1, EVENT_VERSION); assert_eq!(topic_2, symbol_short!("critical")); - assert_eq!( - event_data, - ( - symbol_short!("EVT001"), - symbol_short!("met"), - symbol_short!("rew"), - symbol_short!("top"), - 5u32, - 15u32, - 1500i128, - ), - ); + + // Every payload field matches the stored decision in canonical order. + assert_eq!(outage_id, stored.outage_id); + assert_eq!(status, stored.status); + assert_eq!(mttr, stored.mttr_minutes); + assert_eq!(threshold, stored.threshold_minutes); + assert_eq!(amount, stored.amount); + assert_eq!(payment_type, stored.payment_type); + assert_eq!(rating, stored.rating); + assert_eq!(hash, stored.config_version_hash); + assert_eq!(recorded_at, stored.recorded_at); } #[test] @@ -3585,10 +3597,12 @@ fn test_sla_calc_event_topic_count_is_three() { } #[test] -fn test_sla_calc_event_payload_field_count_is_seven() { - // sla_calc payload: (outage_id, status, payment_type, rating, mttr, threshold, amount) +fn test_sla_calc_event_payload_field_count_is_nine() { + // Canonical decision payload (#429): (outage_id, status, mttr_minutes, + // threshold_minutes, amount, payment_type, rating, config_version_hash, + // recorded_at) — the shared SLAResult struct order. let (env, client, actors) = setup(); - client.calculate_sla( + let stored = client.calculate_sla( &actors.operator, &symbol_short!("EV_SZ2"), &symbol_short!("critical"), @@ -3598,16 +3612,141 @@ fn test_sla_calc_event_payload_field_count_is_seven() { let events = env.events().all(); // Find the sla_calc event (last is set_int, we need sla_calc) let (_, _, data) = events.get(events.len() - 2).unwrap(); - let payload: (Symbol, Symbol, Symbol, Symbol, u32, u32, i128) = data.try_into_val(&env).unwrap(); - // Destructure to confirm all 7 fields decode without error - let (outage_id, status, payment_type, rating, mttr, threshold, amount) = payload; - assert_eq!(outage_id, symbol_short!("EV_SZ2")); - assert_eq!(status, symbol_short!("met")); - assert_eq!(payment_type, symbol_short!("rew")); - assert_eq!(rating, symbol_short!("top")); - assert_eq!(mttr, 5u32); - assert_eq!(threshold, 15u32); - assert_eq!(amount, 1500i128); + let payload: (Symbol, Symbol, u32, u32, i128, Symbol, Symbol, u64, u64) = + data.try_into_val(&env).unwrap(); + // Destructure to confirm all 9 fields decode in canonical order. + let (outage_id, status, mttr, threshold, amount, payment_type, rating, hash, recorded_at) = + payload; + assert_eq!(outage_id, stored.outage_id); + assert_eq!(status, stored.status); + assert_eq!(mttr, stored.mttr_minutes); + assert_eq!(threshold, stored.threshold_minutes); + assert_eq!(amount, stored.amount); + assert_eq!(payment_type, stored.payment_type); + assert_eq!(rating, stored.rating); + assert_eq!(hash, stored.config_version_hash); + assert_eq!(recorded_at, stored.recorded_at); +} + +/// #429 – Guardrail: all three decision-carrying events (sla_calc, set_int, +/// dup_input) MUST share the same canonical payload field order, or an +/// indexer's single decoder would misread one of them. If a payload tuple is +/// reordered or a decision event diverges, this test fails so the drift cannot +/// reach release without a review + version bump. +#[test] +fn test_decision_events_share_canonical_payload_order() { + let (env, client, actors) = setup(); + + // First submission produces sla_calc + set_int and stores a result. + let _stored = client.calculate_sla( + &actors.operator, + &symbol_short!("CANON1"), + &symbol_short!("high"), + &10, + ); + // Conflicting resubmission emits dup_input. + let _ = client.try_calculate_sla(&actors.operator, &symbol_short!("CANON1"), &symbol_short!("high"), &30); + + // Canonical 9-field order shared by all three decision events (#429). + type DecisionPayload = (Symbol, Symbol, u32, u32, i128, Symbol, Symbol, u64, u64); + + let mut sla_calc_payload: Option = None; + let mut set_int_payload: Option = None; + let mut dup_input_payload: Option = None; + + let events = env.events().all(); + for i in 0..events.len() { + let (_, topics, data) = events.get(i).unwrap(); + if topics.len() < 1 { + continue; + } + let name: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + let payload: DecisionPayload = data.try_into_val(&env).expect( + "every decision event must decode as the canonical 9-field tuple", + ); + if name == EVENT_SLA_CALC { + sla_calc_payload = Some(payload); + } else if name == EVENT_SETTLE_INTENT { + set_int_payload = Some(payload); + } else if name == EVENT_DUP_INPUT { + dup_input_payload = Some(payload); + } + } + + let sla_calc = sla_calc_payload.expect("sla_calc event not found"); + let set_int = set_int_payload.expect("set_int event not found"); + let dup_input = dup_input_payload.expect("dup_input event not found"); + + // All three decode to the canonical order; the shared decision fields + // (outage_id, status, mttr, threshold, amount, payment_type, rating) must + // agree with each other so a single decoder is unambiguous. + assert_eq!(sla_calc.0, set_int.0, "outage_id order/index drift"); + assert_eq!(sla_calc.1, set_int.1, "status order/index drift"); + assert_eq!(sla_calc.2, set_int.2, "mttr order/index drift"); + assert_eq!(sla_calc.3, set_int.3, "threshold order/index drift"); + assert_eq!(sla_calc.4, set_int.4, "amount order/index drift"); + assert_eq!(sla_calc.5, set_int.5, "payment_type order/index drift"); + assert_eq!(sla_calc.6, set_int.6, "rating order/index drift"); + + assert_eq!(dup_input.0, sla_calc.0, "dup_input outage_id drift"); + assert_eq!(dup_input.1, sla_calc.1, "dup_input status drift"); + assert_eq!(dup_input.2, sla_calc.2, "dup_input mttr drift"); + assert_eq!(dup_input.3, sla_calc.3, "dup_input threshold drift"); + assert_eq!(dup_input.4, sla_calc.4, "dup_input amount drift"); + assert_eq!(dup_input.5, sla_calc.5, "dup_input payment_type drift"); + assert_eq!(dup_input.6, sla_calc.6, "dup_input rating drift"); +} + +/// #428 – A consumer processing only `set_int` can reconstruct the full SLA +/// decision (status, amount, mttr, thresholds, rating, hash) from the event +/// payload alone, without a follow-up read. +#[test] +fn test_set_int_payload_is_self_contained_for_reconciliation() { + let (env, client, actors) = setup(); + + let stored = client.calculate_sla( + &actors.operator, + &symbol_short!("SETL1"), + &symbol_short!("critical"), + &25, + ); + assert_eq!(stored.status, symbol_short!("viol")); + + let events = env.events().all(); + let mut found = false; + for i in 0..events.len() { + let (_, topics, data) = events.get(i).unwrap(); + if topics.len() < 1 { + continue; + } + let name: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + if name != EVENT_SETTLE_INTENT { + continue; + } + found = true; + // set_int now carries the full decision in canonical order (#428). + let (outage_id, status, mttr, threshold, amount, payment_type, rating, hash, recorded_at): ( + Symbol, + Symbol, + u32, + u32, + i128, + Symbol, + Symbol, + u64, + u64, + ) = data.try_into_val(&env).unwrap(); + assert_eq!(outage_id, stored.outage_id); + assert_eq!(status, stored.status); + assert_eq!(mttr, stored.mttr_minutes); + assert_eq!(threshold, stored.threshold_minutes); + assert_eq!(amount, stored.amount); + assert_eq!(payment_type, stored.payment_type); + assert_eq!(rating, stored.rating); + assert_eq!(hash, stored.config_version_hash); + assert_eq!(recorded_at, stored.recorded_at); + } + assert!(found, "set_int event not found"); } #[test] @@ -4292,8 +4431,17 @@ fn test_event_replay_history_matches_emitted_events() { // Each history entry outage_id matches the corresponding event payload outage_id for i in 0..3u32 { let (_, _, data) = sla_events.get(i).unwrap(); - let (event_outage_id, _, _, _, _, _, _): (Symbol, Symbol, Symbol, Symbol, u32, u32, i128) = - data.try_into_val(&env).unwrap(); + let (event_outage_id, _, _, _, _, _, _, _, _): ( + Symbol, + Symbol, + u32, + u32, + i128, + Symbol, + Symbol, + u64, + u64, + ) = data.try_into_val(&env).unwrap(); assert_eq!(history.get(i).unwrap().outage_id, event_outage_id); } } @@ -4487,6 +4635,40 @@ fn test_get_version_info_returns_correct_versions_after_init() { assert_eq!(info.contract_name, symbol_short!("sla_calc")); } +#[test] +fn test_get_version_negotiation_info_exposes_protocol_data() { + // #427 – the negotiation data must be reachable from a live contract + // method so backends can run the documented multi-contract handshake. + let (_env, client, _actors) = setup(); + let info = client.get_version_negotiation_info(); + assert_eq!(info.contract_name, symbol_short!("sla_calc")); + assert_eq!(info.protocol_version, crate::version_negotiation::PROTOCOL_VERSION); + assert_eq!( + info.min_compatible_protocol, + crate::version_negotiation::MIN_COMPATIBLE_PROTOCOL + ); + assert_eq!(info.storage_version, 1); + assert!(!info.is_paused); + assert!(!info.needs_migration); +} + +#[test] +fn test_get_version_negotiation_info_reflects_pause_and_migration_state() { + let (env, client, actors) = setup(); + client.pause(&actors.admin, &soroban_sdk::String::from_str(&env, "upgrade")); + let info = client.get_version_negotiation_info(); + assert!(info.is_paused); + assert!(!info.needs_migration); +} + +#[test] +fn test_get_version_negotiation_info_is_deterministic() { + let (_env, client, _actors) = setup(); + let a = client.get_version_negotiation_info(); + let b = client.get_version_negotiation_info(); + assert_eq!(a, b); +} + #[test] fn test_get_version_info_reflects_paused_state() { let (env, client, actors) = setup(); @@ -8756,14 +8938,73 @@ fn test_get_public_api_includes_all_major_methods() { assert!(found_migrate, "migrate not found in API descriptor"); } +#[test] +fn test_get_public_api_auth_labels_are_accurate() { + // #426/#427 – the descriptor's `auth` field must reflect the real auth + // logic instead of the old "none" (roles) labels. + let (_env, client, _actors) = setup(); + let api = client.get_public_api(); + + let mut found_accept_admin = false; + let mut found_accept_operator = false; + let mut found_get_version_negotiation_info = false; + + for method in api.methods.iter() { + if method.name == Symbol::new(&_env, "accept_admin") { + found_accept_admin = true; + assert_eq!(method.mutates, true); + // #426 – not "none": the pending address must authorise. + assert_eq!(method.auth, Symbol::new(&_env, "addr")); + } + if method.name == Symbol::new(&_env, "accept_operator") { + found_accept_operator = true; + assert_eq!(method.mutates, true); + assert_eq!(method.auth, Symbol::new(&_env, "addr")); + } + if method.name == Symbol::new(&_env, "get_version_negotiation_info") { + found_get_version_negotiation_info = true; + assert_eq!(method.mutates, false); + assert_eq!(method.auth, Symbol::new(&_env, "none")); + } + } + + assert!(found_accept_admin); + assert!(found_accept_operator); + assert!(found_get_version_negotiation_info); +} + +#[test] +fn test_get_public_api_accept_admin_operator_require_auth_not_none() { + // #426 – accept_admin/accept_operator call require_auth() and check the + // pending-address equality, so the descriptor must NOT label them "none". + let (_env, client, _actors) = setup(); + let api = client.get_public_api(); + + let mut found_accept_admin = false; + let mut found_accept_operator = false; + for method in api.methods.iter() { + if method.name == Symbol::new(&_env, "accept_admin") { + found_accept_admin = true; + assert_eq!(method.mutates, true); + assert_eq!(method.auth, Symbol::new(&_env, "addr")); + } + if method.name == Symbol::new(&_env, "accept_operator") { + found_accept_operator = true; + assert_eq!(method.mutates, true); + assert_eq!(method.auth, Symbol::new(&_env, "addr")); + } + } + assert!(found_accept_admin && found_accept_operator); +} + #[test] fn test_get_public_api_method_count_is_stable() { let (_env, client, _actors) = setup(); let api = client.get_public_api(); - // 61 methods as of get_contract_info/get_storage_footprint_estimate/ - // get_rent_estimate being added to the descriptor (#418). + // 62 methods as of get_version_negotiation_info being added to the + // descriptor (#427). get_public_api / get_contract_info / ... (#418). // This test catches accidental additions or removals - assert_eq!(api.methods.len(), 61, "Public API method count changed"); + assert_eq!(api.methods.len(), 62, "Public API method count changed"); } #[test] diff --git a/apexchainx_calculator/src/version_negotiation.rs b/apexchainx_calculator/src/version_negotiation.rs index 6764743..de666d9 100644 --- a/apexchainx_calculator/src/version_negotiation.rs +++ b/apexchainx_calculator/src/version_negotiation.rs @@ -23,9 +23,20 @@ //! //! # Integration //! -//! Existing backend-facing endpoints (`get_version_info`, `get_migration_state`) -//! on the SLA calculator are extended with this protocol so that multi-contract -//! backends can verify all contracts agree before deploying. +//! Backend-facing integration surface: +//! +//! - `get_version_negotiation_info()` — a live `#[contractimpl]` method on the +//! SLA calculator returns this contract's `VersionNegotiationInfo` +//! (including `protocol_version` and `min_compatible_protocol`), so a +//! multi-contract backend can obtain the data it needs to run +//! `negotiate_contract_versions` against a live deployment (#427). +//! - `get_version_info()` / `get_migration_state()` — existing endpoints that +//! continue to expose storage/result-schema and migration state. +//! - `negotiate_contract_versions()` / `build_negotiation_info()` are the +//! pure, library-level rules. A coordinator calls `get_version_negotiation_info` +//! on each peer (and itself), assembles the peer list, and invokes +//! `negotiate_contract_versions` to decide whether all contracts agree +//! before deploying. //! //! # Changing this module //!