Replace 24h absolute auto-lock with configurable idle timer#2802
Replace 24h absolute auto-lock with configurable idle timer#2802JakeUrban wants to merge 20 commits into
Conversation
Replaces the hardcoded 24-hour 'session length' alarm with a pure idle-based auto-lock timer. The browser session locks after a configurable number of minutes of user inactivity (1, 5, 15, 30, 60), defaulting to 15. Genuine user interaction in any extension page pings the background via a new USER_ACTIVITY message, which rearms the alarm. - @shared/constants/autoLock.ts: single source of truth for valid timeouts, default, and coercion helpers. - SessionTimer: rewritten as a class that reads the persisted timeout on every reset; same-name browser.alarms.create atomically replaces the in-flight deadline. - saveSettings: validates and persists the new field; when the new timeout is shorter than the elapsed idle time on an unlocked wallet, locks immediately rather than scheduling an alarm in the past. - userActivity handler: gated by isFromExtensionPage in popupMessageListener so dApp content scripts cannot extend a session. Rejects pings when the wallet is locked. - useActivityPing + ActivityTracker: mousedown/keydown/touchstart/wheel listeners on window with 5s leading-edge throttle; only active when the wallet is unlocked. - Preferences UI: dropdown driven by VALID_AUTO_LOCK_TIMEOUT_MINUTES. - Tests cover SessionTimer, userActivity, save/load validation, and the elapsed-idle short-circuit; mock fixtures updated to include the new field. Closes #2082 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-10f78efd0b0838157692 (SDF collaborators only — install instructions in the release description) |
There was a problem hiding this comment.
Pull request overview
This PR replaces Freighter’s current hardcoded 24-hour absolute session auto-lock with a configurable idle-based auto-lock timer. It introduces a shared source of truth for valid timeouts, sends throttled “user activity” pings from extension UI surfaces to the MV3 background worker, and reschedules/clears the underlying Chrome alarm as needed (including on settings changes and sign-out).
Changes:
- Add shared auto-lock timeout constants/types and persist
autoLockTimeoutMinutesvia settings load/save. - Implement idle timer reset/stop in the background (
SessionTimer+USER_ACTIVITYhandler) and propagate immediate-lock behavior on shortened timeout. - Add popup-side activity tracking + UI for selecting the timeout, plus tests/mocks updates across background and popup.
Reviewed changes
Copilot reviewed 43 out of 43 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| extension/src/popup/views/Preferences/index.tsx | Adds auto-lock timeout <Select> UI and wires the value into saveSettings. |
| extension/src/popup/views/IntegrationTest.tsx | Updates integration test settings payload to include the new timeout field. |
| extension/src/popup/views/tests/Swap.test.tsx | Updates mocked loadSettings response to include autoLockTimeoutMinutes. |
| extension/src/popup/views/tests/SignTransaction.test.tsx | Updates mocked loadSettings responses to include autoLockTimeoutMinutes. |
| extension/src/popup/views/tests/ManageAssets.test.tsx | Updates mocked loadSettings response to include autoLockTimeoutMinutes. |
| extension/src/popup/views/tests/GrantAccess.test.tsx | Updates mocked loadSettings response to include autoLockTimeoutMinutes. |
| extension/src/popup/views/tests/AddFunds.test.tsx | Updates mocked loadSettings response to include autoLockTimeoutMinutes. |
| extension/src/popup/views/tests/AccountHistory.test.tsx | Updates mocked loadSettings response to include autoLockTimeoutMinutes. |
| extension/src/popup/views/tests/AccountCreator.test.tsx | Updates mocked loadSettings response to include autoLockTimeoutMinutes. |
| extension/src/popup/views/tests/Account.test.tsx | Updates mocked loadSettings responses to include autoLockTimeoutMinutes. |
| extension/src/popup/helpers/hooks/useActivityPing.ts | New hook that throttles and sends USER_ACTIVITY pings to background while unlocked. |
| extension/src/popup/helpers/hooks/tests/useActivityPing.test.ts | Adds unit tests for throttling, event coverage, and cleanup behavior. |
| extension/src/popup/ducks/settings.ts | Plumbs autoLockTimeoutMinutes through state/thunk and propagates wasLocked to auth. |
| extension/src/popup/ducks/accountServices.ts | Adds lockAccount reducer used to flip hasPrivateKey immediately after background lock. |
| extension/src/popup/ducks/tests/settings.test.ts | Adds coverage ensuring saveSettings thunk dispatches lockAccount when wasLocked. |
| extension/src/popup/components/ActivityTracker/index.tsx | New component that mounts useActivityPing based on hasPrivateKeySelector. |
| extension/src/popup/App.tsx | Mounts <ActivityTracker /> once under the Provider to cover all extension surfaces. |
| extension/src/constants/localStorageTypes.ts | Adds AUTO_LOCK_TIMEOUT_MINUTES_ID storage key constant. |
| extension/src/background/messageListener/popupMessageListener.ts | Routes USER_ACTIVITY messages and threads sessionTimer into relevant handlers. |
| extension/src/background/messageListener/helpers/test-helpers.ts | Enhances the session timer test double to read timeout from mock storage and track calls. |
| extension/src/background/messageListener/helpers/login-all-accounts.ts | Awaits sessionTimer.startSession() in the unlock flow. |
| extension/src/background/messageListener/handlers/userActivity.ts | New handler that rearms the timer only when the background session is unlocked. |
| extension/src/background/messageListener/handlers/signOut.ts | Stops the auto-lock alarm on explicit sign-out to avoid stale alarm firing. |
| extension/src/background/messageListener/handlers/saveSettings.ts | Validates/persists timeout, reschedules or immediately locks on shortened-timeout edge cases. |
| extension/src/background/messageListener/handlers/recoverAccount.ts | Awaits sessionTimer.startSession() after account recovery unlock. |
| extension/src/background/messageListener/handlers/migrateAccounts.ts | Awaits sessionTimer.startSession() during migration flow. |
| extension/src/background/messageListener/handlers/loadSettings.ts | Loads timeout from storage via coercion and returns it in settings payload. |
| extension/src/background/messageListener/handlers/handleSignedHwPayload.ts | Treats HW signing completion as activity by resetting the idle timer. |
| extension/src/background/messageListener/handlers/createAccount.ts | Awaits sessionTimer.startSession() after creating an account. |
| extension/src/background/messageListener/handlers/confirmPassword.ts | Dispatches unlockHardwareWallet() on successful password unlock. |
| extension/src/background/messageListener/tests/userActivity.test.ts | Adds unit tests for USER_ACTIVITY behavior across locked/unlocked + HW scenarios. |
| extension/src/background/messageListener/tests/loadSaveSettings.test.ts | Extends tests to cover timeout validation, reschedule/lock behavior, and defaults. |
| extension/src/background/messageListener/tests/handleSignedHwPayload.test.ts | Adds coverage that HW signature handling resets the session timer first. |
| extension/src/background/messageListener/tests/createAccount.test.ts | Updates tests for timer signature changes (casts test timer). |
| extension/src/background/index.ts | Constructs SessionTimer with a storage access instance. |
| extension/src/background/helpers/session.ts | Replaces absolute 24h timer with idle-based SessionTimer and locks HW sessions on clear. |
| extension/src/background/helpers/tests/session.test.ts | Adds tests for SessionTimer reset/stop behavior and default coercion. |
| extension/src/background/ducks/session.ts | Adds HW-lock state, actions, and updates has-private-key selector semantics. |
| @shared/constants/services.ts | Adds SERVICE_TYPES.USER_ACTIVITY. |
| @shared/constants/autoLock.ts | New shared constants/types + coercion/validation helpers for timeout values. |
| @shared/api/types/types.ts | Adds autoLockTimeoutMinutes to Preferences (and thus Settings). |
| @shared/api/types/message-request.ts | Adds timeout field to SaveSettingsMessage and introduces UserActivityMessage. |
| @shared/api/internal.ts | Threads autoLockTimeoutMinutes through saveSettings and updates response typing. |
Comments suppressed due to low confidence (15)
extension/src/popup/views/tests/SignTransaction.test.tsx:466
- Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/SignTransaction.test.tsx:606 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/SignTransaction.test.tsx:721 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/SignTransaction.test.tsx:844 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/SignTransaction.test.tsx:967 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/SignTransaction.test.tsx:1084 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/SignTransaction.test.tsx:1207 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/SignTransaction.test.tsx:1347 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/SignTransaction.test.tsx:1522 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/SignTransaction.test.tsx:1689 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/Account.test.tsx:948 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/Account.test.tsx:1033 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/Account.test.tsx:1117 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/Account.test.tsx:1200 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
extension/src/popup/views/tests/Account.test.tsx:1266 - Indentation for this newly added field is inconsistent with the surrounding object literal, which makes the test fixture harder to read and may fail formatting checks.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Preferences: provide grammatically correct English defaultValue fallbacks in formatTimeoutLabel so missing i18n keys don't render "5 minute" / "5 hour" in dev or partial locales. Keys are now scoped (autoLockTimeout.minutes / autoLockTimeout.hours) so the fallback is unambiguous. - Preferences: type autoLockTimeoutMinutesValue as string (the runtime value from Formik/<select>) and make the conversion in handleSubmit explicit rather than relying on the field type being narrower than reality. - useActivityPing: send activePublicKey as an empty string instead of null to match BaseMessage typing. The background's mismatch check (`request.activePublicKey && …`) treats empty-string exactly like the previous null (skip the check). - useActivityPing test: update expectation to match the empty string. - Prettier: reflow newly added autoLockTimeoutMinutes fields in SignTransaction.test.tsx / Account.test.tsx test fixtures (and three other lightly drifted spots prettier picked up at the same time). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… simplify userActivity Two reviewer questions on PR #2802: 1. saveSettings.ts: when the user shortens the auto-lock timeout, the save itself is a user action — we should rearm the timer with the new timeout, not synthesize an immediate lock just because the elapsed-idle time of the in-flight alarm exceeds the new threshold. Remove the elapsed-idle / immediate-lock branch entirely along with the previousAutoLockTimeoutMinutes capture, the existingAlarm inspection, and the wasLocked round-trip. 2. userActivity.ts: a USER_ACTIVITY ping cannot arrive on a locked wallet by design — the popup-side useActivityPing hook only attaches event listeners while unlocked, and popupMessageListener gates the message behind isFromExtensionPage. Drop the redundant in-handler unlocked check (and its dependencies on sessionStore / buildHasPrivateKeySelector / localStore); the handler now just delegates to sessionTimer.resetSession. Knock-on cleanups: - Remove the wasLocked field from saveSettings's return type, from the @shared/api/internal saveSettings response, and from the popup settings thunk; drop the lockAccount dispatch in that thunk. - Remove the now-unused lockAccount reducer / action from popup/ducks/accountServices. - Update tests: loadSaveSettings.test.ts now asserts that shortening the timeout rearms (rather than locking); userActivity.test.ts collapses to a single "always rearms" case; settings.test.ts verifies the popup auth slice is left untouched after save. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Internal review-fix flagged the new "rearms when timeout is shortened" test as too weak — without an alarmsGet stub the parent implementation resetSession(), so the test could not distinguish parent from current behavior. Mock alarmsGet to return an in-flight alarm whose elapsed idle time (59 min) far exceeds the new 5 min timeout: that is the exact scenario where the parent code would have synthesized an immediate lock. The current implementation must just rearm. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
leofelix077
left a comment
There was a problem hiding this comment.
From my understanding of the PR description, the intention is to have a full lock after inactivity, and it keeps refreshing while the user is interacting,
but currently the UI is not flipping back to the lock screen after inactivity and still asks for password mid-interaction
The app UI stays visibly unlocked but no private key (Assets, account screen, history, etc. stays visible)
Screen.Recording.2026-05-25.at.16.08.39.mov
Was the intention to be 1. "fully locked" after idle, or 2. as shown on the video above?
It seems that flipping the UI back to locked state after inactivity is missing
Code style itself there's nothing major that catches my eyes and is following the standards from the code already present on the codebase
The previous PR locked the wallet on the background side (cleared the
hashKey via timeoutAccountAccess + lockHardwareWallet) but did not
notify any open extension UI surface. The popup, sidebar, and
standalone signing/grant-access windows kept rendering whichever view
they were on — Account, Assets, History — and the user only learned
the wallet had locked when they tried to take a private-key-requiring
action and were ambushed by a password prompt mid-flow.
Make the background broadcast a SESSION_LOCKED runtime message after
clearSession runs in the alarm handler. Add a SessionLockListener
mounted inside the Router (so it has access to useNavigate) that
dispatches a new lockAccount reducer (clearing hasPrivateKey,
publicKey, allAccounts, bipPath, tokenIdList) and navigates to
ROUTES.unlockAccount. applicationState is left untouched so
<UnlockAccountRoute> still renders <UnlockAccount> rather than
redirecting to <Welcome>.
The mechanism mirrors the original PR description's intent — a
push-based notification from background to popup so the UI flip is
not gated on the next useGetAppData refetch — but applies to the
regular idle-alarm path that was missed when the shortened-timeout
immediate-lock variant was simplified out earlier in review.
Tests added:
- background/__tests__/initAlarmListener.test.ts — broadcast happens
after clearSession; sendMessage rejection (no receivers) is swallowed;
unrelated alarms are ignored.
- popup/ducks/__tests__/accountServices.test.ts — lockAccount clears
private-key-derived state and preserves applicationState.
- popup/components/__tests__/SessionLockListener.test.tsx — listener
registers/unregisters, dispatches lockAccount + navigates on
SESSION_LOCKED, ignores unrelated messages.
Addresses #2802 (review)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Internal review-fix round identified that the previous commit dropped
location.state.from and location.search when navigating to the unlock
screen on SESSION_LOCKED. UnlockAccount reads state.from.pathname +
location.search to return the user to the interrupted flow after a
successful unlock — without this, a user auto-locked mid-grant-access
or mid-signing was returned to the default Account page on unlock
rather than back into the approval flow, exactly the way SignTransaction
and GrantAccess already preserve context for their own reroutes.
Capture useLocation() in SessionLockListener and navigate with
{state: {from: location}} + location.search appended, mirroring the
<Navigate to={...} state={{from: location}} replace /> pattern used by
the in-flow reroutes. Also short-circuit when the current pathname is
already /unlock-account so a redundant broadcast cannot clobber a
state.from already set by an earlier reroute (e.g. UnlockAccountRoute
firing before the SESSION_LOCKED message arrives).
Tests extended to cover:
- state.from + location.search preserved from /grant-access?...
- no-op when already on /unlock-account
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Human PR Review Response SummaryPR: #2802 Addressed in code
Filed as follow-up issuesInternal review-fix round 1 surfaced two broader architectural concerns that are out of scope of the reviewer's actionable feedback (per the skill's proportionality guide). Filed as separate issues so they get scheduled deliberately:
Internal review-fix status
Commits added to the PR
Validation
Project board noteThe Responded to using the |
Fixes three correctness bugs in the idle auto-lock flow reported by @leofelix077 on PR #2802: Bug A — Popup stays on Account after auto-lock fires. useGetAppData clearSession deliberately keeps publicKey and only wipes hashKey. Now after the alarm fires lands on the unlock screen. Bug B — clearSession's redux dispatch raced service-worker termination. buildStore's subscribe(saveStore) call was fire-and-forget; when the alarm fired with no popup open, chrome.storage.session.set often didn't complete before SW termination and the next popup read stale hashKey. Fix: - Export flushSessionStore(store) from background/store.ts that explicitly awaits chrome.storage.session.set, and route the subscribe-based save through it too so persistence is unified. - Await flushSessionStore in clearSession (between the dispatches and the TEMPORARY_STORE_ID removal) and in signOut (between dispatch(logOut()) and the TEMPORARY_STORE_ID removal). - Defense-in-depth in buildHasPrivateKeySelector: treat TEMPORARY_STORE_ID absence as authoritative, so a partial lock cannot leave the wallet looking unlocked even if a future flush ever races. Bug C — Unlocking one surface didn't unlock the others. Each surface owns its own redux store, so only the surface that submitted the password saw confirmPassword.fulfilled. Fix: - New SERVICE_TYPES.SESSION_UNLOCKED. - New broadcastSessionState(type) helper (background/messageListener/ helpers/broadcast-session-state.ts) that wraps the browser.runtime.sendMessage + no-receiver swallow pattern. - initAlarmListener replaces its inline try/catch with the helper. - signOut now broadcasts SESSION_LOCKED after the lock work, so explicit sign-out also flips other surfaces' UI. - loginToAllAccounts awaits flushSessionStore then broadcasts SESSION_UNLOCKED on the success path. - SessionLockListener handles SESSION_UNLOCKED: loadAccount + dispatch(saveAccount), and navigate to ROUTES.account only when on ROUTES.unlockAccount or ROUTES.verifyAccount. Does not restore state.from cross-surface (only the surface that entered the password restores its own interrupted flow). Tests: - loadSaveSettings.test.ts — makeLocalStore now seeds TEMPORARY_STORE_ID by default and accepts an opt-out for locked-wallet cases. - SessionLockListener.test.tsx — new SESSION_UNLOCKED cases. - session.test.ts — clearSession awaits flushSessionStore. - signOut.test.ts (new) — signOut awaits flushSessionStore then broadcasts SESSION_LOCKED. - login-all-accounts.test.ts (new) — loginToAllAccounts awaits flushSessionStore then broadcasts SESSION_UNLOCKED on success. yarn test:ci: 983 passed, 72 skipped, 0 failed. yarn build:extension: clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… fast path
Internal review-fix round 1 (CONCERNS) noted Bug A was only partially
addressed: the post-loadAccount reroute in useGetAppData correctly
useGetAppData.tsx:58 short-circuited on currentAccount.publicKey alone.
After a locked loadAccount response is saved into Redux (saveAccount),
a later cached fetchData could still return RESOLVED for
publicKey-present + hasPrivateKey:false and bypass the reroute.
Fix: also require currentAccount.hasPrivateKey in the cache fast path,
so the publicKey-vs-hasPrivateKey invariant is consistent across both
the cached and freshly-loaded paths.
Tests:
- useGetAppData.test.tsx (new) — direct regression test for Bug A:
cached publicKey + hasPrivateKey:false re-routes to unlockAccount;
cached publicKey + hasPrivateKey:true short-circuits to RESOLVED.
- Backfilled hasPrivateKey: true in five existing test files whose
Redux preloadedState seeded an unlocked account with only publicKey:
AddFunds.test.tsx, GrantAccess.test.tsx, SearchAsset.test.tsx,
useGetWalletsData.test.tsx, useGetAssetDomainsWithBalances.test.tsx.
yarn test:ci: 985 passed, 72 skipped, 0 failed.
yarn build:extension: clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e.onMessage response slot When multiple Freighter UI surfaces are open (popup, sidebar, fullscreen), they each mount a SessionLockListener that registers a runtime.onMessage listener. browser.runtime.sendMessage delivers to every extension context except the sender, so cross-surface requests (LOAD_ACCOUNT, GET_IS_ACCOUNT_MISMATCH, CONFIRM_PASSWORD, etc.) also reach the other surfaces' SessionLockListener handler. The previous handler was an async function, which always returned a Promise. Chrome interprets a Promise return as 'this listener will respond', and that immediately-resolved Promise(undefined) could win the race against the background's real handler. The sender then received undefined/null instead of the real payload, surfacing as: - 'Cannot read properties of null (reading \'publicKey\')' when opening a second surface (the chained appData fetch dereferenced the null account). - 'Cannot read properties of null (reading \'error\')' beneath the password input when entering the password on one surface while another was open (activePublicKeyMiddleware fires getIsAccountMismatch on every pending action; its internal client does 'if (response.error)' which throws on a null response, and the thrown message lands in authError). Fix: the handler is now synchronous. For unrelated message types it returns undefined immediately, leaving the response slot to the background. For SESSION_LOCKED / SESSION_UNLOCKED it still returns undefined synchronously (the background's broadcast doesn't await a reply); the SESSION_UNLOCKED loadAccount round-trip runs as a fire-and-forget side effect. Adds two regression tests asserting the handler returns undefined synchronously for unrelated messages and for the broadcast types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The SessionLockListener was navigating to /account on SESSION_UNLOCKED from the /unlock-account route. Because runtime.sendMessage broadcasts to every extension context — including the popup that initiated the unlock via confirmPassword — this navigation raced the UnlockAccount post-submit navigation. In the grant-access flow that meant the popup sometimes landed on /account instead of the /grant-access destination preserved via state.from, which broke the freighterApiIntegration.test.ts "should get public key when logged out" e2e test. Move navigation responsibility to UnlockAccount itself: it watches hasPrivateKey in redux and navigates to from || /account on the false → true transition. This unifies both surfaces — the one that submitted the password and the passive surface that received the cross-surface SESSION_UNLOCKED broadcast — through a single code path and removes the race. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously the idle auto-lock alarm only rearmed on user input events (mousedown, keydown, touchstart, wheel). When a dApp triggered a flow that opened a new popup near the end of the idle window, the user could be locked out mid-flow before they ever interacted with the new surface. Ping USER_ACTIVITY once when useActivityPing mounts in the unlocked state. Opening any Freighter surface (popup, sidebar, fullscreen) is itself an act of user intent and is enough signal to treat the user as active. The existing throttle and isUnlocked guard naturally suppress the mount ping when the wallet is locked or when an input event fires within the same throttle window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…in background
The previous mount ping in useActivityPing was gated on the popup-side
isUnlocked signal, which is read from redux and only flips true after
loadAccount roundtrips to the background and dispatches saveAccount.
On a freshly-spawned dApp popup that gate is racy at best and silently
fails to fire at worst — users still saw the wallet auto-lock partway
through a signing flow because the new surface never managed to rearm
the alarm before the previous deadline elapsed.
Fix it by separating the two responsibilities:
- The popup fires the mount ping unconditionally, exactly once per
surface mount, in its own dependency-less useEffect. Opening any
Freighter UI is itself a user-initiated action.
- The background's userActivity handler is now the authoritative
gate: it reads hashKey and isHardwareWalletActive directly from
its own state and only rearms the idle alarm when the wallet is
actually unlocked (hot or HW). A ping that arrives on a locked
wallet is dropped — there is no live session to extend.
This matches the user's stated requirement ('reset the timer when a
new instance opens, as long as Freighter is currently unlocked') while
removing the dependency on a redux signal that the popup cannot
guarantee has hydrated by the time the ping needs to fire.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds console.warn at four points so we can pinpoint which link in the
chain is failing:
1. popup useActivityPing.sendPing — fires when the popup attempts
to send USER_ACTIVITY (mount or event-driven).
2. popup useActivityPing.sendPing response/error — fires when the
background response (or rejection) lands back in the popup.
3. background userActivity handler — fires when BG receives the
ping, with hot/HW unlock state.
4. background SessionTimer.resetSession — fires when the alarm is
about to be rearmed, with the resolved delay.
5. background initAlarmListener onAlarm — fires when the alarm
actually fires (i.e. wallet about to lock).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause of the auto-lock-doesn't-reset-on-new-popup bug found via
runtime instrumentation: the popup's USER_ACTIVITY mount ping was being
rejected by popupMessageListener with { error: 'Unauthorized' }.
The isFromExtensionPage check, introduced with sidebar support, was:
const isFromExtensionPage =
The intent was to reject content scripts (dApp pages) which always
carry a sender.tab. The flaw: dApp-spawned signing popups created via
browser.windows.create({ type: 'popup', url: 'index.html#...' }) are
also full tabs and therefore also have sender.tab set. Their USER_ACTIVITY
pings (and REJECT_SIGNING_REQUEST, OPEN_SIDEBAR) were being dropped as
Unauthorized — so the new popup never reset the idle alarm, and the
user got auto-locked partway through signing, exactly as reported.
The reliable distinguishing signal is sender.url: extension pages —
whether tabless (browser-action popup, sidepanel) or tabbed (popup
window, options page, fullscreen) — have a chrome-extension:// URL,
while content scripts have the dApp's page URL.
The new check:
- sender.id matches our own extension (same as before), AND
- either sender.tab is absent (legacy fast path for popup/sidepanel)
OR sender.url starts with our extension origin (covers all tabbed
extension pages).
Adds a regression test in sidebar.test.ts for the windows.create
popup case to lock in the fix. The other isFromExtensionPage call
sites (REJECT_SIGNING_REQUEST, OPEN_SIDEBAR) had the same latent
issue and are fixed by the same change.
Reverts the earlier diagnostic console.warn instrumentation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e timer Previously, mounting any Freighter surface (popup, sidebar, fullscreen, dApp-spawned signing popup) fired a USER_ACTIVITY ping that reset the idle auto-lock alarm. The intent was to avoid mid-flow lockout when a dApp opens a fresh popup near the end of the idle window. The tradeoff: dApp-triggered signing popups are programmatic. A malicious or compromised dApp could call requestSign() on a timer to spawn fresh popups indefinitely, each one resetting the idle alarm without any actual user presence. This effectively neutralizes the user's auto-lock setting. Align with MetaMask and Phantom: only direct user input inside the Freighter UI (mousedown, keydown, touchstart, wheel) resets the timer. Surface mounts no longer ping. A surface that opens within the idle window still has whatever time was left on the clock; if the user engages with it, the throttled event handler rearms the alarm as expected. The popupMessageListener gate widening from c0f2e16 is retained: it benefits OPEN_SIDEBAR, REJECT_SIGNING_REQUEST, and event-driven USER_ACTIVITY pings sent from extension-origin tabbed surfaces (popup windows, fullscreen, options). The background-side authoritative unlock gate from b3868f9 is also retained as cheap defense-in-depth against stale popup-side redux state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
After debugging and applying some fixes, I asked Claude to generate a summary of the changes and bugs, so it could be piped into the plan-do-review agent. @JakeUrban can you input this into it and push again, so we can check if it's gonna apply it correctly now? Fix: idle auto-lock leaves wallet inconsistent and looks unlocked across surfacesThree correctness bugs in the idle auto-lock flow ship in the current branch. They all need fixing together for the feature to behave the way the PR description promises. Bug A — popup stays on the Account screen after auto-lock firesRepro: unlock, set auto-lock timeout, close the popup, wait past the timeout, reopen the popup. The alarm fires correctly and the background reports Root cause: Fix: also re-route when if (
!account.publicKey ||
!account.hasPrivateKey ||
account.applicationState === APPLICATION_STATE.APPLICATION_STARTED
) {
// existing re-route to unlockAccount / welcome
}Bug B —
|
Addresses the consolidated review asks from PR #2802 rounds 1-3, all of which are local cleanups; no externally-observable behavior change beyond the two flagged hardening fixes (saveSettings validation drop and handleSignedHwPayload rearm narrowing). userActivity.ts handler: Rewrite the doc comment to match the shipped behavior. The previous text still described an unconditional surface-mount ping that was removed in 7b3e4dd for the security reason captured in that commit message (dApp-spawned signing popups would otherwise extend the alarm without user presence). saveSettings.ts handler: Drop the {error: 'Invalid autoLockTimeoutMinutes'} early-return. The response type has no error variant the popup could surface, and the only producer is a <Select> populated from VALID_AUTO_LOCK_TIMEOUT_MINUTES and coerced before dispatch. Coerce malformed input to the default instead, matching what the storage-read path already does. Tests updated accordingly. handleSignedHwPayload.ts handler: Move sessionTimer.resetSession() into the success branch. A malformed request (missing uuid, or no matching queue entry) is never a legitimate signal of user presence and must not extend the idle deadline. Tests flipped + a new no-matching-uuid case added. signOut.ts handler: Stop the auto-lock alarm BEFORE mutating session state, flushing, and removing the temporary store. Eliminates the race window where a pending alarm could fire clearSession on already-cleared state and emit a duplicate SESSION_LOCKED broadcast. Test asserts the full new ordering: stop < dispatch < flush < remove < broadcast. broadcast-session-state.ts: Replace bare 'catch {}' with a catch that warns on errors which are NOT the well-known 'no receivers' patterns. The common case (no UI surfaces open) stays silent; genuine broadcast failures are no longer invisible. SessionLockListener: Move 'location' into a useRef so the runtime.onMessage listener doesn't re-register on every navigation. Functionally identical (cleanup unregisters first), but avoids re-binding the listener on a hot path. loadSaveSettings.test.ts: Drop the entire unreferenced browser.alarms shim (alarmsGet / alarmsCreate / alarmsClear and the beforeEach that installs them on the polyfill). The current saveSettings handler operates entirely through the injected sessionTimer mock; the shim was scaffolding from the removed elapsed-idle short-circuit path. Also remove both '(result as any).wasLocked).toBeUndefined()' assertions, since the response type no longer admits the field, and rewrite the shortened-timeout test comment to describe the current save-as-activity behavior rather than the removed immediate-lock branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
👤 Human PR Review Feedback (PR #2802)The following feedback was left on the PR and will now be addressed:
Test updates required (per the reviewer)
Acceptance criteria (from the reviewer)
Addressing feedback now… |
🔬 Internal Review-Fix Report (Round 1/3)Full review report (click to expand)Fix Review: 6b22e3aCommit Summary
Verdict: CONCERNSKey findingBug A is only partially addressed. The post- Recommended actions
Other observations
Test results from review
Verdict: CONCERNS Key issue (one):
Fixing now and re-running validation. |
🔬 Internal Review-Fix Report (Round 2/3)Full review report (click to expand)Fix Review: f3e4598Commit summary
Verdict: SOUNDFindings
Optional follow-up (non-blocking)Extract a shared Verdict: SOUND The cache-path fix is correct, the new regression test would have caught the original bug, and the backfilled One optional non-blocking follow-up noted (extract a shared |
✅ Human PR Review Response (PR #2802)Changes madeAddresses @leofelix077's three reported bugs (Bugs A, B, C from the detailed analysis) in two commits. Commit
Commit Round 1 caught that
Feedback not incorporatedAll actionable feedback was incorporated. The reviewer's optional follow-up of extracting a shared Internal reviewPassed internal review-fix in 2 round(s). Round 1 verdict: Commits added to PR
Validation
Acceptance criteria status
Project board noteSame as the prior round — the running PR #2802 updated and ready for re-review. |
Six concrete code issues raised on PR #2802: 1. signOut handler now dispatches lockHardwareWallet() alongside logOut() so HW sessions are locked on explicit sign-out. logOut resets state to initialState (isHardwareWalletLocked: false) but KEY_ID (hw: prefix) is intentionally not cleared, so without this the HW branch of buildHasPrivateKeySelector would report UNLOCKED after sign-out. 2. loginToAllAccounts's two catch blocks now rethrow after clearSession(). Previously execution fell through to startSession() + broadcastSessionState(SESSION_UNLOCKED), arming an idle alarm and telling every Freighter surface the wallet was unlocked even though it had just been cleared. 3. importHardwareWallet now arms the idle auto-lock alarm. popupMessageListener threads sessionTimer into the handler so HW-only sessions imported via this path are subject to the same auto-lock guarantees as hot-wallet sessions. 4. SessionLockListener wraps the SESSION_UNLOCKED fire-and-forget loadAccount in try/catch, and guards against an undefined response. Previously a rejection or undefined result could crash the surface with 'Cannot destructure property hasPrivateKey of undefined'. 5. SessionLockListener now dispatches lockAccount() unconditionally on SESSION_LOCKED; only the navigate is suppressed when the surface is already on /unlock-account. Without this, a surface parked on /unlock-account kept hasPrivateKey: true in redux while the background was locked, and ActivityTracker kept sending pings. 6. saveSettings now uses a precise SaveSettingsResponse type end-to-end (handler return, @shared/api client, popup duck thunk), removing the 'as unknown as typeof response' cast at the client boundary. sendMessageToBackground is generic over the response type so handler boundaries can be typed without per-call coercion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Folds in the adjacent cleanup flagged by internal review: loadSettings was the last remaining 'as unknown as Promise<...>' cast in the client API surface. Now that sendMessageToBackground is generic, it can carry the return type directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
007d48b to
a5ed79d
Compare
🔧 Auto-Lock Cross-Surface Fix — Additional RoundResolves the user-reported regression where opening Freighter in a second surface (sidebar → popup, popup → fullscreen, etc.) while unlocked still caused the new surface's session to expire at the original surface's idle deadline rather than resetting on mount. Root cause (confirmed via runtime instrumentation)
Fix (commit c0f2e16)Gate now accepts a sender if Also includes the upstream surface-mount fix (b3868f9): mount ping fires in its own dependency-less Validation
Files changed since last update
|
Review — round 1Overall the idle-timer mechanism is sound: That said, the PR has accreted across enough scope shifts that there's noticeable drift between what the description says, what the comments say, and what the code now does. The main asks are below. 1. Stale narrative — PR body and in-code comments don't match the shipped behaviorThe PR description and one of the handler doc-comments still describe a previous scope and will mislead reviewers:
2. Leftover scaffolding from removed
|
Meta-review of round-1 review (GPT-5.5)Verdict: revise the round-1 review. It has several high-signal findings, but one concrete claim is false and the test/scope analysis needs tightening. Accept these points
Drop or revise these points
Issues the round-1 review missed
Posted by GPT-5.5 meta-reviewer. |
Review — round 2 (revised after meta-review)Picking up GPT-5.5's meta-review (comment). Retracting one false claim, tightening the test-analysis, and adding one finding the round-1 review missed. Retractions
Revisions
Issue round-1 missed
Standing issues from round 1 (unchanged)§1 (stale PR body + stale Net effect: one bullet retracted, two reframed, one new dead-scaffolding finding added. The substance of the review (scope drift in narrative and tests, one orphaned bugfix) is unchanged. |
Meta-review of round-2 review (GPT-5.5)Verdict: revise round 2 lightly. The main conclusions are sound: the Two concrete fixes before accepting:
I did not find an additional confirmed issue in Posted by GPT-5.5 meta-reviewer. |
Review — round 3 (final)Adopting GPT-5.5's round-2 corrections (comment). This is the third and final round; net effect is two precision fixes on the cleanup asks already filed. Correction to round-2 "fixture fallout" sectionThe
All six should move with the Broader cleanup for
|
|
This PR should use the existing Auto Lock config design here: https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=4835-35341&m=dev |
| autoLockTimeoutMinutes, | ||
| type: SERVICE_TYPES.SAVE_SETTINGS, | ||
| }); | ||
| })) as unknown as typeof response; |
There was a problem hiding this comment.
this type of casting is a code smell. address this type properly
Code reviewFive severe issues from a deeper-effort sweep that the PR's idle-lock goal makes user-visible. Sharing as a courtesy — happy to follow up with patches or close as out of scope.
freighter/extension/src/popup/components/SessionLockListener/index.tsx Lines 96 to 103 in 7793c28
🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Move the auto-lock timer from a dropdown in Preferences to a dedicated full-page selector under Security, matching the Figma design at node 5578:17977. Changes: - @shared/constants/autoLock.ts: expand VALID_AUTO_LOCK_TIMEOUT_MINUTES to [1, 5, 15, 30, 60, 240, 1440] (adds 4h and 24h options); move formatTimeoutLabel helper here from Preferences - Security/index.tsx: add Auto-lock timer nav item with Icon.ClockSnooze - AutoLockTimer/index.tsx: new view with tap-to-select list; reads live selected value from Redux (autoLockTimeoutMinutesSelector), saves immediately on tap with isSaving guard - AutoLockTimer/styles.scss: card-style option rows with dividers and check icon - Preferences/index.tsx: remove auto-lock timer section; pass through current Redux value unchanged on form submit - routes.ts, metricsNames.ts, metrics/views.ts: register new route - Router.tsx: add route for AutoLockTimer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Replace 240 min (4h) with 360 min (6h) in VALID_AUTO_LOCK_TIMEOUT_MINUTES - Add 720 min (12h) to the list - Change DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES from 15 to 720 (12h) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>


What and why
Freighter currently locks the wallet on a fixed 24-hour timer that starts the moment you unlock and never moves. This causes two bad outcomes:
This PR replaces the fixed timer with a configurable idle timer, matching MetaMask, Rabby, Phantom, Trust Wallet, and Coinbase Wallet.
User-visible changes
mousedown,keydown,touchstart,wheel) inside any Freighter UI surface — popup, sidebar, standalone signing window, grant-access window — resets the timer./sign-transaction?...).Explicitly out of scope for v1: lock-on-blur and lock-on-window-close. Those can layer on later as additional toggles.
Implementation notes worth flagging
USER_ACTIVITYis gated onisFromExtensionPageinpopupMessageListener, and the backgrounduserActivityhandler re-checks unlocked state authoritatively before rearming. Belt-and-braces against both dApp-origin spoofing and stale popup-side Redux state.isHardwareWalletLockedflag in the session duck because HW "unlocked" state lives inlocalStore.isHardwareWalletActive, which the existingtimeoutAccountAccessaction doesn't clear. Without this the idle alarm would silently no-op on HW-only sessions.clearSession(idle alarm) andsignOutemitSESSION_LOCKED; successful unlock emitsSESSION_UNLOCKED. A newSessionLockListenermounted inside the router converts those into navigation + auth-state refreshes. The handler is intentionally synchronous so it doesn't claim theruntime.onMessageresponse slot for unrelated cross-surface requests (this was a real footgun discovered mid-PR — see the related comment thread).signOutstops the alarm before mutating state, eliminating the race where a pending alarm could fireclearSessionon already-cleared state and emit a duplicate broadcast.A new
@shared/constants/autoLock.tsis the single source of truth for valid preset values, the type, the default, the type guard, and the coercer. Every consumer (background handlers,SessionTimer, Preferences<Select>, message-request types) imports from there.Hitchhiker:
useGetAppDatacache fast-path bugfixThis PR also includes a small unrelated bugfix to
extension/src/helpers/hooks/useGetAppData.tsx: the cache fast-path now requires bothcurrentAccount.publicKeyANDcurrentAccount.hasPrivateKeybefore short-circuiting. Previously a stale cachedpublicKeyfrom a locked session could let callers skip the unlock reroute. Surfaced during internal review of this PR; covered byuseGetAppData.test.tsxand thehasPrivateKey: truefixture additions in three cached-path tests (useGetAssetDomainsWithBalances.test.tsx,SearchAsset.test.tsx,useGetWalletsData.test.tsx). Strictly belongs in its own PR; called out here so reviewers don't have to bisect.Closes #2082