Skip to content

Commit 8dd4e7e

Browse files
starknetdevclaude
andauthored
fix(token): preserve absolute lifecycle.end across mint round-trip (#110)
* fix(token): preserve absolute lifecycle.end across mint round-trip The packed token stores `[minted_at, start_delay, end_delay]` where both delays are non-negative u32s, so `lifecycle.start >= minted_at` is a structural invariant — there is no representation for a window that opened before mint time. Previously the mint logic clamped `start_delay` to 0 silently when `start <= current_time`, but still computed `end_delay = lifecycle.end - lifecycle.start` from the *unclamped* start. The reconstructed end therefore landed at `mint_time + (end - start)` rather than the caller's intended `lifecycle.end`, silently extending the token's playable window past the cutoff the caller specified. Two cases that hit this: 1. Late entry into a window that already opened (e.g. a no-registration tournament where a player enters during the live phase): caller passes `start = absolute_game_start, end = absolute_game_end`, expects token playable through `game_end`, gets a token playable through `mint_time + game_duration` instead. 2. Caller passes `start = None` (defaults to 0) with an absolute `end`: `end_delay = end - 0 = end`, which both overflows the 25-bit field for any realistic timestamp and encodes the wrong window. Fix: clamp `effective_start` to `max(start, current_time)` and derive `end_delay` from the clamped value. The token's playable window for late entries becomes `[mint_time, end]` — which matches reality, since the token didn't exist during the part of the window that already passed. Also panic when a non-zero `end` is at or before `current_time`. Without this, `end_delay` collapses to 0 — which the storage format treats as "no expiration" — silently producing a token that lives forever instead of one that's already expired (almost certainly a caller bug). Updated `test_core_token_playability_with_lifecycle` to use absolute `end` semantics; the previous version passed `end = PAST_TIME + 1` and relied on the buggy `end - start` duration encoding to make the token expire one second after mint. Added round-trip tests covering both \`start in past\` and \`start unset\` cases. * fix(token): also reject end == start; cover panic path with should_panic tests Strengthen the lifecycle precondition added in 7c1835a: a non-zero `end` must be strictly greater than `lifecycle.start` as well as `current_time`. `validate()` only enforces `start <= end`, so `start == end > current_time` slipped through the original check, then fell into the `end_delay = 0` branch downstream — silently producing exactly the immortal-token failure mode this PR was supposed to eliminate, just on a different path. A zero-length window can never be playable (`is_playable` requires `start <= now < end`), so callers passing `start == end` are buggy. Cover the assertion's panic branch with three `#[should_panic]` regression tests (closing the codecov gap and matching the Codex/CodeRabbit asks): - `end < current_time` - `end == current_time` - `start == end > current_time` (zero-length window) * test(token): cover remaining mint lifecycle edge cases Adds regression tests for the cases that the existing PR didn't exercise directly: explicit Some(0)/Some(0) (no-expiration), gated start with no end, equal start/end in the past, both start and end in the past with explicit start, and a window exceeding the 25-bit end_delay pack limit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 90121ff commit 8dd4e7e

3 files changed

Lines changed: 431 additions & 14 deletions

File tree

packages/embeddable_game_standard/src/token/tests/test_additional_coverage.cairo

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -600,17 +600,17 @@ fn test_core_token_playability_with_lifecycle() {
600600
0,
601601
);
602602

603-
// Token that ends soon after current time (to test expiry)
604-
// end_delay = end - start = (PAST_TIME + 1) - PAST_TIME = 1
605-
// lifecycle.end = minted_at + start_delay + end_delay = CURRENT_TIME + 0 + 1 = CURRENT_TIME + 1
603+
// Token that ends one second after the current time (to test expiry).
604+
// `end` is interpreted as an absolute timestamp, not a duration — so we
605+
// pass `CURRENT_TIME + 1` directly rather than `start + 1`.
606606
let expiring_token = test_contracts
607607
.test_token
608608
.mint(
609609
test_contracts.minigame.contract_address,
610610
Option::None,
611611
Option::None,
612612
Option::Some(PAST_TIME),
613-
Option::Some(PAST_TIME + 1), // end_delay = 1 second (end - start)
613+
Option::Some(CURRENT_TIME + 1),
614614
Option::None,
615615
Option::None,
616616
Option::None,
@@ -646,10 +646,8 @@ fn test_core_token_playability_with_lifecycle() {
646646

647647
// Verify playability at CURRENT_TIME
648648
assert!(!test_contracts.test_token.is_playable(future_token), "Future token not playable yet");
649-
// expiring_token: start_delay=0 (PAST_TIME < CURRENT_TIME), end_delay=1 (end - start)
650-
// lifecycle.start = minted_at + start_delay = CURRENT_TIME + 0 = CURRENT_TIME
651-
// lifecycle.end = minted_at + start_delay + end_delay = CURRENT_TIME + 0 + 1 = CURRENT_TIME + 1
652-
// At CURRENT_TIME: playable (start <= now < end)
649+
// expiring_token: start clamped to mint time (since PAST_TIME < CURRENT_TIME),
650+
// end = CURRENT_TIME + 1. At CURRENT_TIME: playable (start <= now < end).
653651
assert!(
654652
test_contracts.test_token.is_playable(expiring_token),
655653
"Expiring token should be playable now",

0 commit comments

Comments
 (0)