Skip to content

feat(contract): auto-expire unused launcher image hashes - #3564

Open
barakeinav1 wants to merge 30 commits into
mainfrom
feat/auto-remove-launcher-hashes
Open

feat(contract): auto-expire unused launcher image hashes#3564
barakeinav1 wants to merge 30 commits into
mainfrom
feat/auto-remove-launcher-hashes

Conversation

@barakeinav1

@barakeinav1 barakeinav1 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Implements the approved design (docs/design/auto-remove-launcher-hashes-design.md, PR #3488).

Usage-based expiry for allowed_launcher_image_hashes, replacing unanimous-vote-only removal for routine cleanup.

Model. Each AllowedLauncherImage stores an expires_at: Timestamp, stamped now + TTL at write time (vote-in, re-vote, and on a successful attestation). An entry is expired when expires_at < now. Because expiry is fixed at write time — mirroring how attestations store their own expiry — reads are a plain comparison and the TTL only touches the write sites; it isn't threaded through the ~20 read/verify paths.

  • Refresh on use — a successful attestation restamps expires_at = now + TTL, but only for a current participant (enforced by requiring an AuthenticatedParticipantId capability token), so a prospective/non-participant node cannot keep a launcher alive. Applies to both attestation paths: the mock path (MockAttestation::WithConstraints may reference a launcher) and the Dstack path (refresh runs in the async resolve_verification callback; the signer is preserved across the verifier promise). No node-side changes.
  • Read-time filtering — all reads of the allowed set skip expired entries; newest-entry fallback so the set is never empty.
  • Deferred sweepverify_tee spawns a detached #[private] clean_expired_launcher_hashes self-call (guarded by has_expired(), so it's skipped when nothing is expired; gas via clean_expired_launcher_hashes_tera_gas); never removes the last entry; safe to fail since read-time filtering already enforces expiry.
  • Re-vote recoveryvote_add_launcher_hash on an already-present hash restamps its expires_at (threshold vote), recovering a never-adopted hash.
  • Config — new launcher_hash_unused_ttl_seconds (default 14d), validated >= DEFAULT_EXPIRATION_DURATION_SECONDS (the attestation validity window). Validation is folded into the DTO→Config TryFrom, so init / init_running / update_config can't skip it.

Migration

Shadows the live 3.13.0 baseline in v3_13_0_state.rs: OldConfig deserializes the old config and defaults the new fields; OldTeeState deserializes launcher entries without a timestamp and stamps expires_at = migration_time + TTL. Only Config and allowed_launcher_images changed borsh layout; every other field reuses the real (byte-identical) type. Combined with main's #3785, the same migration also stamps an expiry on legacy MockAttestation::Valid entries so they become cleanable — both steps run together and are covered by tests (including a combined round-trip).

Tests

Expiry filtering, newest fallback, cleanup_expired, re-vote refresh; expired-launcher rejection end-to-end; refresh gating (participant vs. non-participant, both mock and dstack arms); migration round-trips (launcher, legacy-mock, and combined).

Follow-ups

Closes #3381

Comment thread crates/contract/src/tee/proposal.rs Outdated
pub(crate) launcher_hash: LauncherImageHash,
pub(crate) compose_hashes: Vec<LauncherDockerComposeHash>,
pub(crate) added: Timestamp,
pub(crate) last_attested: Timestamp,

@barakeinav1 barakeinav1 Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated (superseded by the last_used refactor in c435dc5):

last_used is a single keep-alive signal — stamped when the hash is voted in / re-voted, and refreshed on each attestation by a current participant. An entry is expired when last_used + TTL < now; the all-expired fallback keeps the most-recently-used entry.

(Originally this was two fields, added + last_attested, with expiry on max(...). Collapsed to one per Patrick's suggestion — the distinction was not load-bearing.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a feeling it's an overkill to have both in. I feel you only need the last_attested and potentially a bool to state whether it should benefit from full grace or partial one. Then this last_attested is as done currently updated along with the boolean. The expiry would be if bool=true then check last_attested<full grace period , otherwise check last_attested < partial_grace.

Of course this is assuming you have two different grace periods. If you have only one then the logic is even simpler

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed already — collapsed to a single last_used in c435dc5 (we have one TTL / one grace period, so no bool needed). Sorry for the churn on this thread.

@barakeinav1
barakeinav1 force-pushed the feat/auto-remove-launcher-hashes branch from ba05472 to b2d72fe Compare July 2, 2026 10:31
Launcher image hashes accumulated forever; removal required a unanimous
vote. This adds usage-based expiry:

- AllowedLauncherImage gains added/last_attested timestamps; an entry is
  expired when max(added, last_attested) + TTL < now
- last_attested is refreshed on a successful attestation, but ONLY for a
  current participant (enforced by requiring an AuthenticatedParticipantId);
  a prospective/non-participant node cannot keep a stale launcher alive
- reads filter out expired entries, with a newest-entry fallback so the
  allowed set never goes empty
- verify_tee spawns a detached self-call to a new #[private]
  clean_expired_launcher_hashes that sweeps expired entries from storage
- re-voting an existing launcher hash refreshes its added timestamp
- new config launcher_hash_unused_ttl_seconds (default 14d), validated
  >= DEFAULT_EXPIRATION_DURATION_SECONDS
- state migration (v3_12_0_state) initializes timestamps for existing entries

Updates the design doc status to Implemented and regenerates the borsh-schema
and ABI snapshots for the new fields/method.

Closes #3381
@barakeinav1
barakeinav1 force-pushed the feat/auto-remove-launcher-hashes branch from b2d72fe to 99f6904 Compare July 2, 2026 11:32
@barakeinav1
barakeinav1 marked this pull request as ready for review July 2, 2026 11:46
Copilot AI review requested due to automatic review settings July 2, 2026 11:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements the approved “auto-expire unused launcher image hashes” design by adding usage-based expiry for allowed launcher images in the contract, including config/ABI updates, state migration for the new borsh layout, and test coverage for refresh/expiry/cleanup behavior.

Changes:

  • Add added / last_attested timestamps to launcher allowlist entries and filter expired entries at read/verify time (with newest-entry fallback).
  • Refresh last_attested on successful participant submissions and add a detached private cleanup self-call to physically evict expired entries.
  • Introduce new config knobs (launcher_hash_unused_ttl_seconds, clean_expired_launcher_hashes_tera_gas) with interface + DTO mapping + snapshots + migration/tests updated.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
docs/design/auto-remove-launcher-hashes-design.md Marks the design as implemented and updates wording/invariants/decisions to match the shipped behavior.
crates/test-utils/src/contract_types.rs Extends dummy config builder with the new TTL + cleanup gas fields.
crates/near-mpc-contract-interface/src/types/config.rs Adds the new config fields to InitConfig/Config and updates serialization tests.
crates/near-mpc-contract-interface/src/method_names.rs Adds the clean_expired_launcher_hashes method name constant.
crates/mpc-attestation/src/attestation.rs Exposes the launcher compose hash from a verified attestation to support refresh-on-use.
crates/contract/tests/snapshots/abi__abi_has_not_changed.snap Updates ABI snapshot for the new private cleanup method and config fields.
crates/contract/tests/sandbox/upgrade_from_current_contract.rs Updates sandbox upgrade test config to include the new TTL + cleanup gas fields.
crates/contract/tests/sandbox/contract_configuration.rs Updates sandbox init config test to include the new TTL + cleanup gas fields.
crates/contract/src/v3_12_0_state.rs Adds 3.12.0 shadow types for launcher allowlist migration and a migration round-trip test.
crates/contract/src/tee/tee_state.rs Threads launcher TTL through verification paths and adds refresh-on-use + cleanup hooks.
crates/contract/src/tee/proposal.rs Implements TTL filtering, fallback selection, refresh-on-use, re-vote refresh, and cleanup for launcher allowlist entries (with tests).
crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap Updates borsh schema snapshot for new fields.
crates/contract/src/lib.rs Wires TTL into submit/verify/read paths, spawns detached cleanup self-call, adds private cleanup endpoint, and validates config updates.
crates/contract/src/dto_mapping.rs Maps new config fields between DTOs and contract config.
crates/contract/src/config.rs Adds new config fields, defaults, and a validation invariant tying TTL to attestation expiry window.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 2407 to +2412
pub fn update_config(&mut self, config: dtos::Config) {
self.config = config.into();
let new_config: Config = config.into();
if let Err(e) = new_config.validate() {
env::panic_str(e);
}
self.config = new_config;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in d7c0c65. init and init_running now build the config, call config.validate(), and propagate the error (both are #[handle_result]), so the contract can no longer be initialized with a TTL below the attestation validity window. Added init_rejects_launcher_ttl_below_attestation_validity to lock it in.

Comment thread crates/test-utils/src/contract_types.rs Outdated
@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown

Pull request overview

Implements usage-based expiry for allowed_launcher_image_hashes: each AllowedLauncherImage now carries added / last_attested timestamps, all reads filter by max(added, last_attested) + TTL >= now, submit_participant_info refreshes last_attested when the caller is authenticated as a current participant, and verify_tee spawns a detached self-call to a new #[private] clean_expired_launcher_hashes for physical eviction. Includes a 3.12.0 shadow borsh layout in v3_12_0_state.rs to migrate existing entries (stamping both timestamps to now).

Changes:

  • New AllowedLauncherImage.{added, last_attested} + is_expired / last_active helpers; AllowedLauncherImages::{live_indices, refresh_last_attested, cleanup_expired, from_entries} and add() semantics changed (re-adding an existing hash now refreshes added and returns true).
  • TeeState::refresh_launcher_usage gated by AuthenticatedParticipantId; add_participant, reverify_participants, reverify_and_cleanup_participants, clean_invalid_attestations, get_allowed_launcher_* all take a new launcher_unused_ttl: Duration.
  • New Config.launcher_hash_unused_ttl_seconds (default 14d) + clean_expired_launcher_hashes_tera_gas (default 5 Tgas); Config::validate() enforces TTL ≥ DEFAULT_EXPIRATION_DURATION_SECONDS, called from update_config.
  • New #[private] clean_expired_launcher_hashes; detached self-call from verify_tee.
  • VerifiedAttestation::launcher_compose_hash() accessor.
  • v3_12_0_state.rs gains OldTeeState / OldAllowedLauncherImage(s) shadow; migration stamps added = last_attested = now.
  • Snapshot updates (borsh schema, ABI) and design-doc status flip to Implemented; docstrings say "participant attestation".

Reviewed changes

Per-file summary
File Description
crates/contract/src/config.rs Adds two config fields (TTL, gas) with defaults and a Config::validate() invariant check
crates/contract/src/dto_mapping.rs Wires the two new config fields through init/get/update DTOs
crates/contract/src/lib.rs Threads launcher_unused_ttl into every TEE call site; refreshes on use gated by AuthenticatedParticipantId; detached sweep from verify_tee; new #[private] clean_expired_launcher_hashes; validation now called by update_config
crates/contract/src/tee/proposal.rs AllowedLauncherImage gains timestamps + is_expired; AllowedLauncherImages gains live_indices / refresh_last_attested / cleanup_expired / from_entries; add() now refreshes an existing entry and returns true
crates/contract/src/tee/tee_state.rs Adds refresh_launcher_usage, clean_expired_launcher_images; propagates TTL through all reads and reverifications; adds a refresh test
crates/contract/src/v3_12_0_state.rs Adds OldTeeState / OldAllowedLauncherImage(s) borsh shadow + From impl stamping timestamps to now; adds a round-trip migration test
crates/contract/src/snapshots/...borsh_schema.snap, crates/contract/tests/snapshots/abi.snap Regenerated snapshots for the schema/ABI additions
crates/contract/tests/sandbox/{contract_configuration,upgrade_from_current_contract}.rs Updates sandbox configs with the two new fields
crates/mpc-attestation/src/attestation.rs New VerifiedAttestation::launcher_compose_hash()
crates/near-mpc-contract-interface/src/method_names.rs Adds CLEAN_EXPIRED_LAUNCHER_HASHES
crates/near-mpc-contract-interface/src/types/config.rs Adds the two new config fields to Config/InitConfig DTOs
crates/test-utils/src/contract_types.rs Adds new fields to dummy_config
docs/design/auto-remove-launcher-hashes-design.md Status → Implemented; notes participant-gating; open questions → decisions

Findings

Blocking (must fix before merge):

  • crates/contract/src/lib.rs:2014,2081init and init_running build the config with init_config.map(Into::into).unwrap_or_default() but never call Config::validate(). Only update_config (line 2409) validates. An operator can therefore initialize the contract with launcher_hash_unused_ttl_seconds < DEFAULT_EXPIRATION_DURATION_SECONDS, breaking the very safety invariant the validator was added to enforce ("a hash backing a valid participant attestation is never expired"). Please call config.validate() on both init paths and propagate the error (both are already #[handle_result]).

Non-blocking (nits, follow-ups, suggestions):

  • crates/mpc-attestation/src/attestation.rs:28 vs crates/contract/src/config.rs:113, crates/test-utils/src/contract_types.rs:18, crates/contract/tests/sandbox/contract_configuration.rs:106, crates/contract/tests/sandbox/upgrade_from_current_contract.rs:122, docs/design/auto-remove-launcher-hashes-design.md:34,76 — the actual constant DEFAULT_EXPIRATION_DURATION_SECONDS = 60*60*24 is 1 day, but new comments and the design doc call it "7 days". The test values (14 days) still satisfy the real bound so nothing breaks, but the comments are misleading — either drop the parenthetical or match the current constant. The safety-invariant argument in the design doc also relies on "at most 7d old" for participant attestations, which needs a matching update if the 1-day constant is authoritative.

  • crates/contract/src/tee/proposal.rs:269-274is_expired returns false on checked_add overflow (deadline unrepresentable ⇒ "never expires"). Intentional and safe here, but worth a one-line WHY comment given the arithmetic policy in engineering-standards — otherwise a future reader may "fix" it to a panic.

  • crates/contract/src/tee/proposal.rs:302 — the log! on re-vote ("launcher hash already in allowed list, refreshing") does not mention that the caller's add now returns true for this path. vote_add_launcher_hash logs \"launcher hash add result: {}\" (lib.rs:1486) which will read true even though no new entry was created. Consider tightening the log message (or returning an enum) so operator logs distinguish "newly added" from "refreshed".

  • crates/contract/src/tee/proposal.rs:296-305 — refreshing an existing entry updates added but leaves compose_hashes untouched. This is consistent with add_mpc_image_compose_hashes being the sole path for adding new compose hashes, but a one-line comment on the refresh branch would help — otherwise it looks like a re-vote should also "refresh" the compose set against the current MPC image list.

  • crates/contract/src/tee/tee_state.rs:337-351refresh_launcher_usage takes _authenticated_participant: &AuthenticatedParticipantId purely as a type-level capability token. That's fine and matches other patterns in this codebase, but the current docstring says "Requires an [AuthenticatedParticipantId] so a non-participant submission cannot keep a launcher hash alive." — worth explicitly calling out that the argument is intentionally unused so a future reader doesn't "clean up" the parameter.

  • crates/contract/src/lib.rs:795-839 — the authenticated_participant is computed before add_participant runs but consumed after. add_participant doesn't touch the participant set so this is safe today, but a comment noting the intentional ordering (or hoisting the refresh to a helper) would make the invariant less fragile.

  • crates/contract/src/tee/proposal.rs:362-381cleanup_expired recomputes is_expired per entry twice on the fast path (once in any, once in retain). Micro; entries list is tiny. Leaving as-is is fine.

⚠️ Issues found

Address review feedback:
- init/init_running now validate the config (not just update_config), so the
  contract cannot be initialized with launcher_hash_unused_ttl_seconds below
  the attestation validity window; add a regression test.
- dummy_config: give clean_expired_launcher_hashes_tera_gas a unique offset
  (was duplicating remove_non_participant_tee_verifier_votes_tera_gas).
Address non-blocking review comments:
- is_expired: note why overflow returns not-expired (never panic on a bogus timestamp)
- add(): note re-vote refreshes only the clock; compose hashes managed separately
- refresh_launcher_usage: note the AuthenticatedParticipantId is a capability token
@barakeinav1

barakeinav1 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. Responses inline:

init and init_running build the config with init_config.map(Into::into).unwrap_or_default() but never call Config::validate(). Only update_config validates.

Fixed in d7c0c65 — both init paths now call config.validate() and propagate the error (both are #[handle_result]), with a regression test init_rejects_launcher_ttl_below_attestation_validity.

is_expired returns false on checked_add overflow ... worth a one-line WHY comment

Done (a8694aa): added a comment explaining the deadline is unrepresentably far off and we must never panic on a bogus timestamp.

refreshing an existing entry updates added but leaves compose_hashes untouched ... a one-line comment ... would help

Done (a8694aa): the re-vote branch now notes compose hashes are maintained separately via add_mpc_image_compose_hashes.

refresh_launcher_usage takes _authenticated_participant: &AuthenticatedParticipantId purely as a type-level capability token ... worth explicitly calling out that the argument is intentionally unused

Done (a8694aa): docstring now states it is a capability token with the value intentionally unused.

the log! on re-vote ... does not mention that the callers add now returns true ... Consider tightening the log message (or returning an enum)

Left as-is: the proposal-level log! already distinguishes "refreshing" from a new add; an enum return felt like over-engineering for a log line.

authenticated_participant is computed before add_participant runs but consumed after ... a comment noting the intentional ordering

cleanup_expired recomputes is_expired per entry twice on the fast path ... Micro; entries list is tiny. Leaving as-is is fine.

Left as-is — micro / the list is tiny, as you noted.

…and design doc

DEFAULT_EXPIRATION_DURATION_SECONDS was changed 7d -> 1d in #3626. Update the
stale '7 days' wording; reference the constant instead of hardcoding a
day-count in test comments to avoid future drift. The MPC docker-image grace
period (DEFAULT_TEE_UPGRADE_DEADLINE_DURATION_SECONDS) remains 7 days.
@barakeinav1

barakeinav1 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

the actual constant DEFAULT_EXPIRATION_DURATION_SECONDS = 60*60*24 is 1 day, but new comments and the design doc call it "7 days" ... either drop the parenthetical or match the current constant. The safety-invariant argument in the design doc also relies on "at most 7d old" ... which needs a matching update

Good catch — fixed in cb955fb. It was deliberately changed 7d → 1d in #3626, so the wording was stale. Updated the comments/design doc to "1 day", and to avoid this drift recurring I dropped the hardcoded day-count in the test comments (they now just reference the constant). The design-doc safety argument is now phrased around the constant (TTL >= DEFAULT_EXPIRATION_DURATION_SECONDS holds regardless of its exact value). The unrelated DEFAULT_TEE_UPGRADE_DEADLINE_DURATION_SECONDS (MPC docker-image grace) stays 7 days, which is still correct.

Comment thread crates/contract/src/tee/proposal.rs Outdated
Comment on lines +260 to +261
pub(crate) added: Timestamp,
pub(crate) last_attested: Timestamp,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn’t it be simpler to just have last_used instead of both added and last_attested? I don’t see why we need that distinction. last_used could then be refreshed both when re-voting for the launcher hash and when the attestation is used.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be simpler to just have last_used instead of both added and last_attested?

Agreed — collapsed to a single last_used (refreshed on both re-vote and participant attestation) in c435dc5.

For context on why I originally split them: I wanted to distinguish governance liveness (when the hash was voted in) from usage liveness (last participant attestation), so the all-expired fallback would deliberately retain the most-recently-voted-in hash. But the only readers are the expiry check (max, unchanged) and that fallback — and "keep the most-recently-used" is just as sensible there (arguably better). So the distinction isn't load-bearing; one field is simpler and equivalent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oups sorry commented the same thing. Just noticed you had the same comment

Comment thread crates/contract/src/tee/proposal.rs Outdated
Comment on lines +260 to +261
pub(crate) added: Timestamp,
pub(crate) last_attested: Timestamp,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s a bit confusing to use the Timestamp type here while we use u64 in other places. We should probably align on one type unless there’s a specific reason not to:

// TODO(#1639): This timestamp can not come from the contract,
// but should be extracted from the certificate itself.
pub expiry_timestamp_seconds: u64,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit confusing to use the Timestamp type here while we use u64 in other places.

Kept Timestamp here: in this file it matches the sibling AllowedMpcDockerImage.added: Timestamp, and gives checked_add/now/Ord for free. The u64 you linked is in the mpc-attestation crate (raw unix seconds in the attestation DTO) — switching the launcher fields to u64 would make them inconsistent with their neighbor here. The broader u64-vs-Timestamp alignment across crates is a real but pre-existing, separate cleanup.

Comment thread crates/contract/src/tee/proposal.rs Outdated
/// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call.
pub(crate) remove_non_participant_tee_verifier_votes_tera_gas: u64,
/// TTL after which a launcher image hash unused by any participant is evicted.
pub(crate) launcher_hash_unused_ttl_seconds: u64,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see no test covering enlarging the launcher_hash_unused_ttl_seconds config while entries are stored: a bigger TTL should bring back an entry a smaller TTL had hidden, since filtering only hides (doesn't delete — only cleanup_expired does). Existing tests only advance time at a fixed TTL.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see no test covering enlarging the launcher_hash_unused_ttl_seconds ... a bigger TTL should bring back an entry a smaller TTL had hidden

Good point — added enlarging_ttl_unhides_previously_expired_entry in c435dc5, asserting a larger TTL re-surfaces an entry a smaller TTL hid (filtering hides, only cleanup_expired deletes).

@SimonRastikian

Copy link
Copy Markdown
Contributor

The design doc [docs/design/auto-remove-launcher-hashes-design.md](https://github.com/near/mpc/blob/main/docs/design/auto-remove-launcher-hashes-design.md still says it's a Draft

@barakeinav1

Copy link
Copy Markdown
Contributor Author

The design doc [docs/design/auto-remove-launcher-hashes-design.md](https://github.com/near/mpc/blob/main/docs/design/auto-remove-launcher-hashes-design.md still says it's a Draft

I should have written "design had been reviewed (by @pbeza and @netrome) )

… simplify live_indices

Address review feedback:
- AllowedLauncherImage: replace added + last_attested with one last_used,
  refreshed on both re-vote and participant attestation. Expiry and the
  all-expired fallback (now newest-by-last_used) are unchanged in behavior.
- live_indices: drop the .expect()/empty-guard in favor of
  max_by_key(...).unwrap_or_default().
- Add a test that enlarging the TTL un-hides an entry a smaller TTL filtered
  (read-time filtering hides, never deletes).
- Regenerate borsh-schema snapshot for the field change.

@SimonRastikian SimonRastikian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial review only. Will do the second part most likely later today

Comment thread docs/design/auto-remove-launcher-hashes-design.md Outdated
Comment thread docs/design/auto-remove-launcher-hashes-design.md Outdated
Comment thread crates/contract/src/tee/proposal.rs Outdated
Comment on lines +269 to +271
// Overflow means the deadline is unrepresentably far in the future, so the
// entry is not expired. Never panic here: a bogus timestamp must not evict a hash.
None => false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's interesting, not sure if I strongly like it or strongly dislike it. I guess this should be fine and cannot be called adversarially. or can it? If it can then better evict the hash (even on bogus timestamp)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not adversarial: last_used is stamped by the contract via Timestamp::now() (block time, seconds) — never user-supplied — so last_used + ttl cannot approach u64::MAX for ~centuries. Overflow is unreachable in practice, so returning "not expired" is safe (and we prefer never to evict a hash on an arithmetic edge). Kept as-is with the explanatory comment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All good them

@pbeza pbeza Jul 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@barakeinav1 @SimonRastikian I think we should add a log! here. If this ever gets printed, it would be a red flag that something is very wrong. Without logging it, we might never catch it.

Comment thread crates/contract/src/tee/proposal.rs Outdated
Comment thread crates/contract/src/tee/proposal.rs Outdated
Comment thread crates/contract/src/tee/proposal.rs
Comment thread crates/contract/src/tee/proposal.rs Outdated
Comment thread crates/contract/src/tee/proposal.rs Outdated
Comment thread crates/contract/src/tee/proposal.rs Outdated
Comment thread crates/contract/src/tee/proposal.rs Outdated

@SimonRastikian SimonRastikian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial review only. Will do the second part most likely later today

…c to last_used

Address review feedback (SimonRastikian):
- add -> add_or_refresh returning enum AddOutcome { Added, Refreshed }; the
  bool return was always true. Thread the outcome through add_launcher_image
  and make vote_add_launcher_hash log added vs refreshed. (internal only; not
  in state/ABI)
- fix all_compose_hashes/launcher_hashes docstrings: they return live entries
  (non-expired, or the most-recently-used fallback when all expired)
- fix stale refresh_launcher_usage docstring (last_attested -> last_used)
- rewrite the design doc to the single last_used model (struct, expiry,
  mermaid, migration) and correct the stale 1-day attestation-validity wording
pbeza
pbeza previously approved these changes Jul 24, 2026

@pbeza pbeza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I briefly re-scanned the PR after you rebased it on the latest main, with #3714 and #3715 merged in, and it looks good to me. But I’d prefer to re-review the whole PR from scratch, since it’s been a while since I last approved it and this is a big PR that has now been rebased on top of several other large PRs.

So ideally, someone else (@gilcu3?) could also take another look next week before we merge it, since I’ll be OoO the whole week.

@gilcu3

gilcu3 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

So ideally, someone else (@gilcu3?) could also take another look next week before we merge it.

I can take a look next Tuesday when I am back. This is a tricky PR indeed, so the more eyes the better.

@gilcu3

gilcu3 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@claude review

1 similar comment
@gilcu3

gilcu3 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@claude review

@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

Pull request overview

Implements usage-based expiry for the launcher-image allowlist. AllowedLauncherImage gains a single last_used: Timestamp, stamped at vote-in/re-vote and refreshed on each attestation submitted by a current participant (gated by AuthenticatedParticipantId). Every read of the allowed set filters entries whose last_used + TTL < now, falling back to the most-recently-used entry so the set is never empty, and verify_tee spawns a detached #[private] clean_expired_launcher_hashes self-call for physical eviction. A new Config.launcher_hash_unused_ttl_seconds (default 14d) is validated >= DEFAULT_EXPIRATION_DURATION_SECONDS on both init paths and update_config, and the v3_13_0_state shadow migrates existing entries by stamping last_used = now.

The core mechanics look right to me: the signer is genuinely preserved across the verifier promise so the capability gate holds in the async callback; AuthenticatedParticipantId::new is signer-based and submit_participant_info already pins node_id.account_id == signer, so the refresh can only ever touch the caller's own attestation; the migration shadow's field order matches TeeState; and 5 Tgas for the detached sweep is comfortable given nodes attach MAX_GAS to verify_tee. My findings are mostly about documentation that no longer matches the code, and about test coverage for the security gate.

Changes:

  • AllowedLauncherImage::{new, is_expired} + last_used; AllowedLauncherImages::{add_or_refresh, newest_index, non_expired_or_newest_indices, refresh_last_used, cleanup_expired, from_entries}; reads take a ttl.
  • TeeState::refresh_launcher_usage gated by AuthenticatedParticipantId; launcher_unused_ttl threaded through every verify/reverify/read entry point.
  • New Config fields + Config::validate(), called from init, init_running, update_config.
  • New #[private] clean_expired_launcher_hashes + detached self-call from verify_tee.
  • v3_13_0_state: OldTeeState / OldAllowedLauncherImage(s) shadow with a borsh round-trip migration test.
  • VerifiedAttestation::launcher_compose_hash(); regenerated borsh + ABI snapshots; design doc flipped to Implemented.

Reviewed changes

Per-file summary
File Description
crates/contract/src/config.rs New TTL + cleanup-gas fields with defaults; Config::validate ties TTL to the attestation validity window
crates/contract/src/dto_mapping.rs Wires the two new fields through the init/get/update DTO conversions
crates/contract/src/lib.rs Threads TTL into every TEE call site; refresh-on-use gated by AuthenticatedParticipantId on both the mock and dstack paths; detached sweep from verify_tee; new #[private] clean_expired_launcher_hashes; config validation on both init paths
crates/contract/src/tee/proposal.rs last_used, is_expired, AllowedLauncherImageInsertion, add_or_refresh, newest_index, non_expired_or_newest_indices, refresh_last_used, cleanup_expired, from_entries; reads filter expired with a newest-entry fallback
crates/contract/src/tee/tee_state.rs Propagates launcher_unused_ttl; adds refresh_launcher_usage and clean_expired_launcher_images; adds a refresh-keeps-alive test
crates/contract/src/v3_13_0_state.rs OldTeeState / OldAllowedLauncherImage(s) borsh shadow + From impl stamping last_used = now; round-trip migration test
crates/contract/src/snapshots/...borsh_schema...snap, crates/contract/tests/snapshots/abi...snap Regenerated for the new field/method
crates/contract/tests/sandbox/{contract_configuration,upgrade_from_current_contract}.rs New config fields in the sandbox init/update payloads
crates/mpc-attestation/src/attestation.rs VerifiedAttestation::launcher_compose_hash() accessor
crates/near-mpc-contract-interface/src/{method_names.rs,types/config.rs} New method-name constant and the two new Config/InitConfig fields
crates/test-utils/src/contract_types.rs New fields in dummy_config
docs/design/auto-remove-launcher-hashes-design.md Status to Implemented; rewritten around last_used; participant gating documented

Findings

Blocking (must fix before merge):

  • docs/design/auto-remove-launcher-hashes-design.md:35,76,120 — the doc now states the attestation validity window is 1 day, but crates/mpc-attestation/src/attestation.rs:28 on current main is 60 * 60 * 24 * 7 (7 days), and this PR does not change it. main's copy of this doc says "7 days", so the PR regresses it. Not cosmetic: line 76 carries the safety-invariant argument (TTL >= DEFAULT_EXPIRATION_DURATION_SECONDS, "the 14-day default leaves ample margin" — with 7d the margin is 2x, not 14x), and line 120 tells operators "its attestation already lapsed at the 1-day validity window", which is wrong guidance. Best fix is the one you proposed in the earlier thread: drop the hardcoded day count and phrase everything in terms of the constant, so it cannot drift again.

  • crates/contract/README.md:327-328 — two documented view methods change observable behavior and the README was not updated. allowed_launcher_image_hashes() / allowed_launcher_compose_hashes() are documented as "Returns all currently allowed ..."; they now return only non-expired entries, or a single most-recently-used entry when everything is expired. Same for the operator guide: docs/running-an-mpc-node-in-tdx-external-guide.md:1670 and :1829 still present unanimous vote_remove_launcher_hash as the only way an old launcher digest ever leaves the allowlist ("After all operators have migrated, participants vote to remove the old launcher manifest digest"), and the "Query allowed launcher manifest digests" section (:1737-1748) does not mention that the query now hides expired digests. CLAUDE.md treats this doc drift as review-blocking and asks for the fix in the same PR.

  • crates/contract/src/lib.rs:848 and :2448 — the participant-only refresh gate has no negative test. This gate is the entire reason AuthenticatedParticipantId is plumbed through, and the property it protects (a prospective/non-participant node cannot keep a stale launcher alive indefinitely) is asserted nowhere: refresh_launcher_usage__keeps_attested_launcher_alive only exercises the authenticated path at the TeeState level, i.e. below the gate. Worth a submit_participant_info-level test where a non-participant signer submits an attestation referencing a live launcher and last_used is asserted unchanged. The mock-path refresh in particular (added in 4485904, after the last human review) has no coverage above TeeState at all. I see test(contract): cover launcher-hash expiry rejection and async-path refresh gating #3928 tracks this — but for the single security control this PR introduces I would rather it land here.

  • Test naming/structure — CLAUDE.md states new tests must use <system_under_test>__should_<assertion> with // Given / // When / // Then. Of the new tests only crates/contract/src/tee/tee_state.rs:1707 follows it; crates/contract/src/tee/proposal.rs:804,832,858,878,895,930, crates/contract/src/lib.rs:4100 and crates/contract/src/v3_13_0_state.rs:209 do not. The convention is well established in these files (59 __should_ tests in lib.rs, 12 in tee_state.rs).

Non-blocking (nits, follow-ups, suggestions):

  • crates/contract/src/tee/proposal.rs:311 — using Duration::MAX as the "no expiry" TTL in the new test call sites means those tests take the checked_add overflow branch, i.e. they verify "never expires" through the path the comment calls unreachable-in-practice (and emit the warning log for every entry). A large-but-representable constant (e.g. Duration::from_secs(u64::MAX / 2), or a named NO_LAUNCHER_EXPIRY) would exercise the real comparison instead.

  • crates/contract/src/config.rs:130validate() bounds the TTL from below but not above. A TTL near u64::MAX makes every is_expired overflow, emitting one log! per entry per read — and reads happen once per participant inside reverify_and_cleanup_participants. Either cap the value, or hoist the log out of the per-entry path.

  • crates/contract/src/lib.rs:1740 — the sweep is spawned on every verify_tee, which nodes call on a timer, so it is a permanent extra receipt even when nothing is expired. A cheap entries.iter().any(is_expired) guard before creating the promise would skip the common case. (The gas budget itself is fine — verify_tee gets 300 Tgas from the node.)

  • crates/contract/src/lib.rs:1876clean_expired_launcher_hashes is #[handle_result] -> Result<(), Error> but can only ever return Ok. Dropping both would simplify the ABI entry.

  • crates/contract/src/tee/proposal.rs:915set_block_secs(1_000_000); here is dead: nothing reads allowed after the preceding assertion, and the second scenario immediately resets the clock to 1 at :918. That second scenario is also independent enough to be its own test.

  • crates/contract/src/tee/proposal.rs:375newest_index uses max_by_key, which returns the last maximum on ties. Ties are the normal case right after migration, since the shadow stamps every entry with the same last_used. The result is deterministic, but the name implies a meaningful choice, so a word in the doc comment would help.

  • Still open from earlier rounds and not addressed in the current head: crates/contract/src/tee/proposal.rs:930 is still named re_add_refresh_resets_added_and_keeps_alive after the added -> last_used rename (raised by Copilot, cc'd by @pbeza on 2026-07-20).

⚠️ Issues found

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

crates/test-utils/src/contract_types.rs:23

  • dummy_config is documented as producing different values for each Config field, but clean_expired_launcher_hashes_tera_gas currently duplicates verifier_tera_gas (value + 14). This contradicts the docstring and reduces the usefulness of the dummy config in tests.
        clean_expired_launcher_hashes_tera_gas: value + 14,

- docs: express the attestation-validity window via DEFAULT_EXPIRATION_DURATION_SECONDS
  instead of a hardcoded day count (design doc); update README launcher views to note
  expiry filtering; update operator guide (auto-expiry vs. immediate vote-remove, and
  the query now hides expired digests)
- test: add participant-gate coverage above TeeState — verify_and_store_mock rejects an
  expired launcher hash; resolve_verification refreshes for a participant and not for a
  non-participant
- verify_tee: guard the detached sweep with has_expired() to skip a wasted receipt when
  nothing is expired
- is_expired: drop the per-entry overflow log
- clean_expired_launcher_hashes: drop the always-Ok Result
- proposal.rs: document newest_index tie behavior; remove dead set_block_secs; rename
  re_add_refresh test after the added->last_used rename
@barakeinav1
barakeinav1 dismissed stale reviews from pbeza and SimonRastikian via 883dfad July 28, 2026 11:10
@barakeinav1

barakeinav1 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — thorough review. Addressed across 883dfadc / 6ffd1257 (also re-merged main). Replying per finding:

Blocking

the doc now states the attestation validity window is 1 day, but attestation.rs:28 on current main is 60*60*24*7 (7 days) … the PR regresses it.

Not a regression by this PR — the branch had merged main while the constant was 1 day (#3626); main then reverted it to 7 days (#3948) after that merge, so the branch was stale. Re-merged main (7-day revert now in) and rewrote the doc to express the window via DEFAULT_EXPIRATION_DURATION_SECONDS rather than a hardcoded day count, so it can't drift again. ✅

crates/contract/README.md:327-328 — two documented view methods change observable behavior … Same for the operator guide (vote_remove_launcher_hash as the only removal path; the query section).

Done. README launcher views now describe expiry filtering; operator guide explains auto-expiry vs. immediate vote_remove_launcher_hash and that the query hides expired digests (kept the OS-measurement distinction — those don't auto-expire). ✅

lib.rs:848 and :2448 — the participant-only refresh gate has no negative test … the mock-path refresh in particular has no coverage above TeeState.

Both gate sites now have negative tests in this PR: resolve_verification__should_not_refresh_for_non_participant (async/dstack arm) and submit_participant_info__should_not_refresh_launcher_for_non_participant (mock arm), plus the participant positive and verify_and_store_mock__should_reject_expired_launcher_hash. ✅

Test naming/structure — CLAUDE.md states new tests must use <sut>__should_<assertion>

New tests use the __should_ form. but I did not do a sweeping rename of the pre-existing launcher tests]

Non-blocking

proposal.rs:311 — using Duration::MAX as the "no expiry" TTL … takes the checked_add overflow branch … and emits the warning log for every entry.

Dropped the per-entry log! in is_expired entirely — that removes the only real occurrence of the noise (tests) and makes the overflow branch silent. ✅

config.rs:130validate() bounds the TTL from below but not above … one log! per entry per read.

Same fix (log removed). Skipped adding an upper bound: the field is set only by the deployer at init or a participant threshold vote (update_config is #[private]), and overflow needs a ~520-year TTL, so a cap isn't worth it. ✅ (via log removal)

lib.rs:1740 — the sweep is spawned on every verify_tee … a cheap entries.iter().any(is_expired) guard would skip the common case.

Done — verify_tee now guards the detached sweep with has_expired(). ✅

lib.rs:1876clean_expired_launcher_hashes … can only ever return Ok. Dropping both would simplify the ABI entry.

Done — dropped #[handle_result] + Result; ABI regenerated. ✅

proposal.rs:915set_block_secs(1_000_000); here is dead …

Removed the dead line. (Left the two scenarios in one test.) ✅

proposal.rs:375newest_index uses max_by_key, which returns the last maximum on ties …

Documented the tie behavior on newest_index. ✅

proposal.rs:930 is still named re_add_refresh_resets_added… after the added -> last_used rename.

Renamed to …resets_last_used…. ✅

check-assert-matches.sh flags assert!(matches!(..)) unless the matched type
lacks Debug and is annotated; PromiseOrValue doesn't implement Debug.
…t refresh

Covers submit_participant_info's mock arm (the second refresh gate site): a
non-participant's WithConstraints mock stores but does not extend the
referenced launcher's last_used.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

docs/running-an-mpc-node-in-tdx-external-guide.md:1829

  • This line states an unused digest “is evicted automatically once no node has attested with it” after TTL. In the implementation, expiry is enforced by read-time filtering with a newest-entry fallback (so one digest can still be returned even if all are expired), and physical eviction is done later by an internal cleanup. The wording should match those semantics.
An unused launcher manifest digest now auto-expires after the configured TTL (`launcher_hash_unused_ttl_seconds`, default 14 days) and is evicted automatically once no node has attested with it for that window, so no vote is needed for routine rotation. The unanimous `vote_remove_launcher_hash` is only needed to remove a still-valid digest *immediately* (before its TTL lapses), for example a compromised launcher.

docs/running-an-mpc-node-in-tdx-external-guide.md:1739

  • This sentence suggests expired digests are always removed from results (“auto-evicted”), but the contract keeps a newest-entry fallback when all entries are expired, and physical eviction happens later via a detached cleanup call. The guide should reflect the read-time filtering + newest fallback semantics rather than implying immediate removal.

This issue also appears on line 1829 of the same file.

The contract method is named `allowed_launcher_image_hashes` for historical reasons, but the values returned are manifest digests. The query returns only non-expired digests; digests that have aged out past their TTL are hidden and auto-evicted.

crates/contract/src/lib.rs:4156

  • Test name doesn’t follow the repository’s required pattern fn <system_under_test>__should_<assertion>() from AGENTS.md, which the surrounding tests in this module adhere to.
    fn init_rejects_launcher_ttl_below_attestation_validity() {

crates/contract/src/v3_13_0_state.rs:209

  • Test name doesn’t follow the repository’s required pattern fn <system_under_test>__should_<assertion>() from AGENTS.md. Using the standard naming makes it easier to scan and keep consistency across the suite.
    fn migrating_launcher_images_preserves_hashes_and_stamps_timestamps() {

@gilcu3
gilcu3 self-requested a review July 29, 2026 12:24

@gilcu3 gilcu3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks quite good, although it takes a lot of time to go through this. Added some comments, still need to review changes in crates/contract/src/tee/tee_state.rs. Will continue tomorrow

Comment thread crates/contract/src/v3_13_0_state.rs Outdated
Comment on lines +9 to +14
//!
//! Relative to `3.13.0`, this release adds two `Config` fields
//! (`launcher_hash_unused_ttl_seconds`, `clean_expired_launcher_hashes_tera_gas`) and a
//! `last_used` timestamp to each `AllowedLauncherImage`. Those are the only borsh-layout
//! changes, so only `Config` and `TeeState` are shadowed here; every other field reuses the
//! real (byte-identical) type.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment can be removed, as it is explaining something that is very visible in the code already

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — removed.

/// The launcher compose hash this attestation was validated against, if it carries one.
pub fn launcher_compose_hash(&self) -> Option<LauncherDockerComposeHash> {
match self {
Self::Dstack(a) => Some(a.launcher_compose_hash),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
Self::Dstack(a) => Some(a.launcher_compose_hash),
Self::Dstack(attestation) => Some(attestation.launcher_compose_hash),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — renamed the binding to attestation.

Comment thread crates/contract/src/v3_13_0_state.rs Outdated
Comment on lines +203 to +207
/// The `3.13.0` launcher layout (no timestamp) must deserialize under the shadow and
/// migrate: launcher hash + compose hashes preserved, and `last_used` set to the
/// migration time (NOT the borsh/epoch default, which would immediately expire every
/// migrated hash). Two entries + a short TTL defeat the newest-only read fallback, so
/// both surviving proves the timestamp was stamped to "now".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this is also not needed. Sorry for being annoying on this one, we are trying to keep comments to the necessary as they tend to add bloat.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — removed.

Comment thread crates/contract/src/lib.rs Outdated
Comment on lines +1879 to +1936
Ok(self
.tee_state
.clean_invalid_attestations(tee_upgrade_deadline_duration, max_scan as usize))
Ok(self.tee_state.clean_invalid_attestations(
tee_upgrade_deadline_duration,
Duration::from_secs(self.config.launcher_hash_unused_ttl_seconds),
max_scan as usize,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't expect this change, will need to look deeper on why it is needed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It needed the launcher TTL because clean_invalid_attestations re-verifies stored attestations against the (then read-time-filtered) launcher allowlist. This is now moot: the expires_at refactor removed the read-time TTL entirely, so clean_invalid_attestations no longer takes a launcher-TTL param (02a05c3).

Comment thread crates/contract/src/lib.rs Outdated
Comment on lines +2133 to +2138
let config: Config = init_config.map(Into::into).unwrap_or_default();
config
.validate()
.map_err(|reason| InvalidParameters::MalformedPayload {
reason: reason.to_string(),
})?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should make the validation part of the into conversion to make this more ergonomic, and avoid someone forgetting to call validate

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — InitConfig/ConfigConfig are now TryFrom impls that validate, so init/init_running/update_config can't skip validation (02a05c3).

Comment thread crates/contract/src/lib.rs Outdated
VMContextBuilder::new()
.signer_account_id(non_participant.clone())
.predecessor_account_id(non_participant)
.attached_deposit(MINIMUM_ATTESTATION_STORAGE_DEPOSIT)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this just out of date? Or we have not removed the deposit yet on main?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is before we removed the deposit, I need to rebase main.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the deposit was dropped on main (#3940). Re-merged main, so MINIMUM_ATTESTATION_STORAGE_DEPOSIT is gone from these tests too.

Comment on lines +300 to +318
pub(crate) fn new(
launcher_hash: LauncherImageHash,
compose_hashes: Vec<LauncherDockerComposeHash>,
) -> Self {
Self {
launcher_hash,
compose_hashes,
last_used: Timestamp::now(),
}
}

fn is_expired(&self, ttl: Duration, now: Timestamp) -> bool {
match self.last_used.checked_add(ttl) {
Some(deadline) => deadline < now,
// Overflow ⇒ deadline unrepresentably far off, so treat as not expired rather than
// panic: a bogus timestamp (or an enormous TTL) must never evict a hash.
None => false,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't is_expired do the same as new above and take the current time from Timestamp::now? Else the function semantics is a bit off

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by the expires_at refactor — is_expired(now) is now just expires_at < now, and Timestamp::now() is only used at write time when stamping expires_at. The new vs is_expired asymmetry you flagged is gone.

Comment on lines -321 to +355
log!("launcher hash already in allowed list");
return false;
existing.last_used = Timestamp::now();
return AllowedLauncherImageInsertion::Refreshed;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why removing the log line?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok I see in another thread that it was moved, should be fine

Comment thread crates/contract/src/tee/proposal.rs Outdated
Comment on lines +311 to +318
fn is_expired(&self, ttl: Duration, now: Timestamp) -> bool {
match self.last_used.checked_add(ttl) {
Some(deadline) => deadline < now,
// Overflow ⇒ deadline unrepresentably far off, so treat as not expired rather than
// panic: a bogus timestamp (or an enormous TTL) must never evict a hash.
None => false,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having to pass the ttl to many methods seems like a code smell. As I explained in slack this does not seem necessary, as we could simply store the expire at time instead, like we do for attestations.

@barakeinav1 barakeinav1 Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — implemented the stored-expires_at model you suggested (02a05c3): each entry stores expires_at = now + TTL, stamped at write time. Expiry is a plain expires_at < now, so the TTL no longer threads through the ~20 read/verify sites — only the 3 write sites (vote-in, refresh, migration) take it (still have it for tests though) . Thanks for the suggestion.

Comment thread crates/contract/src/tee/tee_state.rs Outdated
Comment on lines +375 to +382
/// Refreshes the `last_used` timestamp of the launcher image referenced by the stored
/// attestation for `tls_public_key`. The [`AuthenticatedParticipantId`] is an unused
/// capability token — requiring it means only a current participant can refresh.
pub(crate) fn refresh_launcher_usage(
&mut self,
tls_public_key: &Ed25519PublicKey,
_authenticated_participant: &AuthenticatedParticipantId,
) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's a very strange (or advanced) way of using the type system :)

@barakeinav1 barakeinav1 Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a capability-token / proof-by-construction pattern (suggested by claude) : an AuthenticatedParticipantId can only be built via ::new(participants), which succeeds only when env::signer_account_id() is a current participant. So requiring one as a parameter means refresh_launcher_usage cannot be called unless the caller has proven participant status — a compile-time gate.

The type is already used across the contract (vote_launcher, vote_measurement, CodeHashesVotes::vote, …), but in those its value is used (the participant identity). Here it's a pure gate, so the value is unused (_authenticated_participant) — which is what reads oddly.

The runtime gate already lives at the lib layer (if let Some(participant) = AuthenticatedParticipantId::new(...).ok()), so this param is a belt-and-suspenders compile-time backstop against a future caller forgetting the check. I'm happy either way: keep it (defensive) or drop the unused param and rely on the lib-layer gate + a doc comment. Do you have a preference?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just thinking if there would be a more natural way of achieving the same. But we can leave that for future coders, like it is now is also fine IMO

…cher-hashes

# Conflicts:
#	crates/contract/src/lib.rs
#	crates/contract/src/v3_13_0_state.rs
Reynaldo's main suggestion: store a single expires_at timestamp per launcher
image (stamped now+TTL at write time) instead of last_used + a read-time TTL.
Expiry becomes a plain expires_at < now, and the TTL no longer threads through
the ~20 read/verify call sites — only the 3 write sites (vote-in, refresh,
migration) take it. Overflow saturates at Timestamp::MAX.

Also:
- fold Config validation into the DTO->Config conversion (TryFrom) so it can't
  be forgotten
- Given/When/Then + clearer constants on the new launcher tests
- drop two redundant comments in v3_13_0_state; tidy an attestation binding
- regenerate the borsh schema snapshot (last_used -> expires_at)
Our launcher shadow-conversion and main's stamp_expiry_on_legacy_mocks were each
tested in isolation. Add a test exercising the full From<MpcContract> sequence on
one TeeState: a launcher image gets its expires_at stamped AND a legacy Valid mock
becomes cleanable, confirming the two steps compose correctly.
Reflect the refactor from last_used + read-time TTL to a stored expires_at
(stamped now+TTL at write time), and note the migration now also stamps expiry
on legacy mocks (main #3785).

@gilcu3 gilcu3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping some more comments.

Main one is the use of a promise for the cleanup, that seems not needed taking into account what I explain in the comment.

Hopefully we can get this merged on Monday when the other reviewers are back!

Comment on lines +1724 to +1741
testing_env!(
VMContextBuilder::new()
.signer_account_id(signer)
.block_timestamp(10 * 1_000_000_000)
.build()
);
let authenticated = AuthenticatedParticipantId::new(&participants).unwrap();

let mut tee_state = TeeState::default();
tee_state
.allowed_launcher_images
.add_or_refresh(launcher_1, &[mpc_hash]);

// A second (newer) launcher so the list is never empty — this defeats the
// newest-only read fallback and lets us observe real expiry.
testing_env!(
VMContextBuilder::new()
.block_timestamp(20 * 1_000_000_000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to name this constants to clarify what's the aim

Comment thread crates/contract/src/tee/tee_state.rs Outdated
Comment on lines +375 to +382
/// Refreshes the `last_used` timestamp of the launcher image referenced by the stored
/// attestation for `tls_public_key`. The [`AuthenticatedParticipantId`] is an unused
/// capability token — requiring it means only a current participant can refresh.
pub(crate) fn refresh_launcher_usage(
&mut self,
tls_public_key: &Ed25519PublicKey,
_authenticated_participant: &AuthenticatedParticipantId,
) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just thinking if there would be a more natural way of achieving the same. But we can leave that for future coders, like it is now is also fine IMO

fn from(value: near_mpc_contract_interface::types::Config) -> Self {
Config {
impl TryFrom<near_mpc_contract_interface::types::Config> for Config {
type Error = &'static str;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be Error or ConversionError, as used in other imp in this file

Comment on lines +822 to +831
let launcher_unused_ttl =
Duration::from_secs(self.config.launcher_hash_unused_ttl_seconds);

// Capability token: only a current participant may keep its launcher hash alive.
let authenticated_participant = self
.protocol_state
.threshold_parameters()
.ok()
.and_then(|params| AuthenticatedParticipantId::new(params.participants()).ok());
let tls_public_key_for_refresh = node_id.tls_public_key.clone();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: better do define vars right before they are used, unless the compiler restricts it ofc

Comment on lines +841 to +846
if let Some(participant) = &authenticated_participant {
self.tee_state.refresh_launcher_usage(
&tls_public_key_for_refresh,
participant,
launcher_unused_ttl,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I believe this is an accepted pattern in rust

Suggested change
if let Some(participant) = &authenticated_participant {
self.tee_state.refresh_launcher_usage(
&tls_public_key_for_refresh,
participant,
launcher_unused_ttl,
);
if let Some(authenticated_participant) = &authenticated_participant {
self.tee_state.refresh_launcher_usage(
&tls_public_key_for_refresh,
authenticated_participant,
launcher_unused_ttl,
);

Comment on lines 1745 to +1759
let current_params = running_state.parameters.clone();

// Physical eviction runs in a detached self-call so it can never fail this
// transaction. Skip it when nothing is expired to avoid a wasted receipt on the
// common (timer-driven) path.
if self.tee_state.has_expired_launcher_images() {
Promise::new(env::current_account_id())
.function_call(
method_names::CLEAN_EXPIRED_LAUNCHER_HASHES.to_string(),
vec![],
NearToken::from_yoctonear(0),
Gas::from_tgas(self.config.clean_expired_launcher_hashes_tera_gas),
)
.detach();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It felt strange to have this here, and I found out why. For the docker mpc-node hashes, we do the cleanup inline in

pub fn reverify_and_cleanup_participants(
&mut self,
participants: &Participants,
tee_upgrade_deadline_duration: Duration,
) -> TeeValidationResult {
self.allowed_docker_image_hashes
.cleanup_expired_hashes(tee_upgrade_deadline_duration);

Notice that when this function runs, these values are already in memory, not lazy loaded, and we expect the amount to be pretty small. Therefore, I think we should move this code to reverify_and_cleanup_participants, right after the line:

self.allowed_docker_image_hashes
            .cleanup_expired_hashes(tee_upgrade_deadline_duration);

and then remove the promise and the new config value, that seem no needed in this case.

Comment on lines +4155 to +4159
#[test]
fn init_rejects_launcher_ttl_below_attestation_validity() {
let participants = gen_participants(3);
let signer = participants.participants()[0].0.clone();
testing_env!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, although apparently in this test where the comment lives nothing changed 🤔

Comment on lines -394 to 419
/// Returns all allowed launcher image hashes.
pub fn get_allowed_launcher_hashes(&self) -> Vec<LauncherImageHash> {
self.allowed_launcher_images.launcher_hashes()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this was probably not removed on purpose

Comment on lines +426 to +429
/// Whether a `clean_expired_launcher_images` sweep would evict anything.
pub fn has_expired_launcher_images(&self) -> bool {
self.allowed_launcher_images.has_expired()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if dropping the cleanup promise for launcher hashes as I suggested in another comment, this will also disappear, so it will be a good simplification

/// Index of the entry with the latest expiry, if any. On ties (e.g. right after a
/// migration stamps every entry with the same `expires_at`) this returns the last such
/// entry — deterministic, but the specific choice among equal entries is arbitrary.
fn newest_index(&self) -> Option<usize> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the name is now a bit misleading, because the ttl is a dynamic parameter. so latest_expiry_index would probably be a more accurate name

@jackson-harris-iii
jackson-harris-iii force-pushed the feat/auto-remove-launcher-hashes branch from 9cf1bd1 to 93c9ab5 Compare August 1, 2026 11:22
@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

PR title type suggestion: This PR changes only configuration files, assets, and dependencies—no source code changes. The type prefix should probably be chore: instead of feat:.

Suggested title: chore: add fonts and configuration updates

@andrei-near
andrei-near force-pushed the feat/auto-remove-launcher-hashes branch from 93c9ab5 to 9cf1bd1 Compare August 1, 2026 16:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auto-remove unused launcher image hashes from the contract

5 participants