1- // boundless-events: admin operations.
2- //
3- // Spec: boundless-platform-contract-prd.md Section 6.1.
4-
51use soroban_sdk:: { panic_with_error, Address , BytesN , Env , String } ;
62
73use crate :: errors:: Error ;
84use crate :: events as evt;
95use crate :: storage;
106use crate :: types:: { PendingAdmin , PendingUpgrade } ;
117
12- // Two-step admin rotation TTL: 7 days at the mainnet 5-second ledger cadence.
13- // 7 * 24 * 60 * 60 / 5 = 120_960 ledgers.
148const PENDING_ADMIN_TTL_LEDGERS : u32 = 120_960 ;
159
16- // Fee bps cap. 100% = 10_000 bps. L4 (2026-06 audit): tightened from 5_000
17- // (50%) to 1_000 (10%). 10% covers the full envelope of real Boundless
18- // pricing tiers; a config typo can no longer push the fee above operating
19- // range. Per-event overrides still respect this cap.
2010pub ( crate ) const MAX_FEE_BPS : u32 = 1_000 ;
2111
22- // H6: timelocked upgrade windows.
23- //
24- // UPGRADE_TIMELOCK_LEDGERS earliest gap between propose and apply.
25- // ~1 day so off-chain monitors have a window
26- // to react before the new wasm lands.
27- // PENDING_UPGRADE_TTL_LEDGERS hard expiry on the proposal; ~30 days.
28- // Past this the admin must re-propose.
29- // Testnet builds (`--features testnet`) zero the upgrade timelock for fast
30- // iteration; the default build (mainnet + everything else) keeps the full
31- // ~1-day timelock. Fail-safe: omitting the flag yields the secure value, never 0.
3212#[ cfg( not( feature = "testnet" ) ) ]
3313const UPGRADE_TIMELOCK_LEDGERS : u32 = 17_280 ;
3414#[ cfg( feature = "testnet" ) ]
3515const UPGRADE_TIMELOCK_LEDGERS : u32 = 0 ;
3616const PENDING_UPGRADE_TTL_LEDGERS : u32 = 518_400 ;
3717
38- // Initial contract version. Written by __constructor and bumped on
39- // apply_upgrade. Bump alongside any storage-layout or public-surface change
40- // that warrants a migration entrypoint.
4118pub const INITIAL_VERSION : & str = "1.1.0" ;
4219
4320// ============================================================
@@ -50,8 +27,6 @@ pub fn initialize(
5027 fee_bps : u32 ,
5128 profile_contract : Address ,
5229) {
53- // Refuse double-init by checking the admin key in instance storage (the
54- // new home for admin/config per the 2026-06 audit).
5530 if env. storage ( ) . instance ( ) . has ( & crate :: types:: DataKey :: Admin ) {
5631 panic_with_error ! ( env, Error :: AlreadyInitialized ) ;
5732 }
@@ -140,12 +115,6 @@ pub fn set_fee_bps(env: &Env, new_bps: u32) -> Result<(), Error> {
140115
141116pub fn set_fee_account ( env : & Env , new_account : Address ) -> Result < ( ) , Error > {
142117 require_admin ( env) ?;
143- // M2 (2026-06 audit): we do not verify trustline existence at the
144- // contract layer because Soroban's SAC interface cannot reliably
145- // distinguish "no trustline" from "zero balance". Admin must verify
146- // off-chain BEFORE calling this; the FeeAccountUpdated event below is
147- // the signal off-chain monitors rely on to re-verify. See
148- // docs/audit-2026-06-stellar-skill.md M2.
149118 storage:: set_fee_account ( env, & new_account) ;
150119 storage:: touch_instance ( env) ;
151120 evt:: FeeAccountUpdated {
@@ -190,33 +159,13 @@ pub fn unpause(env: &Env) -> Result<(), Error> {
190159
191160// ============================================================
192161// UPGRADE (timelocked; H6)
193- //
194- // Three steps:
195- // 1. propose_upgrade(wasm_hash, new_version) — admin-only; writes
196- // PendingUpgrade with proposed_at = now, available_at = now + TIMELOCK,
197- // expires_at = now + TTL. Off-chain monitors can see exactly which
198- // version + wasm is queued before it lands.
199- // 2. apply_upgrade() — admin-only; requires
200- // now in [available_at, expires_at]; swaps the wasm hash and bumps
201- // the on-chain version label.
202- // 3. cancel_pending_upgrade() — admin-only; prunes a stale
203- // or unwanted proposal so a fresh one can be queued.
204- //
205- // migrate(to_version) is a SEPARATE call that runs the one-shot data
206- // migration matched to the just-applied version. Guard via MigratedToVersion.
207- //
208- // Spec: docs/audit-2026-06-stellar-skill.md H6.
209162// ============================================================
210163pub fn propose_upgrade (
211164 env : & Env ,
212165 new_wasm_hash : BytesN < 32 > ,
213166 new_version : String ,
214167) -> Result < ( ) , Error > {
215168 require_admin ( env) ?;
216- // Empty version is rejected; reuse InvalidPillar to stay inside the
217- // soroban contracterror 50-variant cap (a dedicated InvalidVersion
218- // would push us over). Off-chain monitors should treat InvalidPillar
219- // on propose_upgrade as "bad version label."
220169 if new_version. is_empty ( ) {
221170 return Err ( Error :: InvalidPillar ) ;
222171 }
@@ -262,7 +211,6 @@ pub fn apply_upgrade(env: &Env) -> Result<(), Error> {
262211 new_version : pending. new_version . clone ( ) ,
263212 }
264213 . publish ( env) ;
265- // Keep the legacy Upgraded event for indexers built against the old shape.
266214 evt:: Upgraded {
267215 new_wasm_hash : pending. wasm_hash ,
268216 }
@@ -286,29 +234,6 @@ pub fn cancel_pending_upgrade(env: &Env) -> Result<(), Error> {
286234
287235// ============================================================
288236// MIGRATE (post-upgrade one-shot; H6)
289- //
290- // Called once per version after apply_upgrade swaps the wasm. The shape
291- // is:
292- //
293- // 1. Read the current Version label (set by apply_upgrade) and the
294- // previously-applied migration marker (MigratedToVersion). If the
295- // marker already equals the current Version, reject as
296- // MigrationAlreadyApplied — a second invocation is always a
297- // misconfiguration.
298- // 2. Dispatch on (prev, current) and run the migration body. Bodies
299- // run cleanly inside the same tx as the marker write, so a failure
300- // reverts both — there is no half-migrated state to recover from.
301- // 3. Stamp MigratedToVersion = current and emit Migrated{}.
302- //
303- // Mainnet bootstrap: the first deploy lands the constructor with the
304- // current storage layout, so no migration body is needed. The first real
305- // migration body will land with the first storage-layout upgrade after
306- // mainnet goes live. We keep an empty match arm for the no-op case so the
307- // shape is stable and future contributors do not have to debate where
308- // the dispatch goes.
309- //
310- // NB: Soroban String only supports equality + length, no `as_str()` /
311- // pattern matching. The dispatch below uses `String::from_str` + equality.
312237// ============================================================
313238pub fn migrate ( env : & Env ) -> Result < ( ) , Error > {
314239 require_admin ( env) ?;
@@ -324,31 +249,8 @@ pub fn migrate(env: &Env) -> Result<(), Error> {
324249
325250 // ============================================================
326251 // PER-(from -> to) MIGRATION DISPATCH
327- //
328- // Each future upgrade adds an `if` clause here with its migration body.
329- // Touch only persistent / instance entries that the new layout changes;
330- // anything the new code reads with backwards-compatible defaults can
331- // be left alone.
332- //
333- // Pattern:
334- //
335- // if from_version == String::from_str(env, "0.2.0")
336- // && current == String::from_str(env, "0.3.0")
337- // {
338- // migrate_0_2_0_to_0_3_0(env)?;
339- // }
340- //
341- // The corresponding private fn lives below the match block. Keep it
342- // small enough to read; if the migration is large, split it into named
343- // helpers and call from inside the body.
344252 // ============================================================
345253
346- // No-op for the 1.0.0 -> 1.1.0 credit-removal upgrade: the contracts hold
347- // no events yet, so there are no EventRecord rows to rewrite. __constructor
348- // populates storage in the current shape, so admin can call migrate() once
349- // just to stamp the marker and unlock the audit trail (the Migrated event
350- // signals off-chain runbooks that the post-upgrade cleanup ran).
351-
352254 storage:: set_migrated_to_version ( env, & current) ;
353255 storage:: touch_instance ( env) ;
354256 evt:: Migrated {
@@ -404,8 +306,6 @@ pub fn require_admin(env: &Env) -> Result<(), Error> {
404306}
405307
406308pub fn require_not_paused ( env : & Env ) -> Result < ( ) , Error > {
407- // Every operation path runs this first, so this is the single spot to
408- // bump instance TTL on the hot path. Admin paths bump explicitly.
409309 storage:: touch_instance ( env) ;
410310 if storage:: is_paused ( env) {
411311 return Err ( Error :: Paused ) ;
0 commit comments