Skip to content

Commit a751ce7

Browse files
fix(vultisig): re-prompt on expired password cache + center confirm modal (#1137)
Two fixes to the Vultisig confirmation modal: 1. Password-cache TTL (crash fix): #1134 skipped the password prompt whenever the vault phase was Active, but phase never tracks the SDK's 5-minute password-cache TTL. After idle > 5 min, signing hit an expired cache → "VaultError: Password required". The modal now asks the SDK for its live cache state (new isVaultUnlocked IPC → vault.getUnlockTimeRemaining()): it skips the prompt while the cache is warm and re-prompts only once expired. 2. Centering: the confirm modal now uses the same lg:pl-[240px] sidebar offset as UnifiedTxModal, so it centers within the content area, not the full viewport. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 52b11c0 commit a751ce7

6 files changed

Lines changed: 103 additions & 65 deletions

File tree

src/main/api/mpc/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,25 @@ export function registerMpcIpcHandlers(ipcMain: IpcMain): void {
481481
}
482482
})
483483

484+
// Whether the vault can currently sign without a fresh password prompt, i.e.
485+
// its password is still cached and not expired. `getUnlockTimeRemaining()`
486+
// tracks exactly the password-cache TTL, so `> 0` means a signing request
487+
// won't hit an `onPasswordRequired` cache miss. Fails safe to `false` (prompt).
488+
ipcMain.handle(MpcIPCMessages.MPC_IS_VAULT_UNLOCKED, async (_event, vaultId: string) => {
489+
try {
490+
assertString(vaultId, 'vaultId')
491+
if (!isSDKInitialized()) return false
492+
const sdk = getSDK()
493+
const vault = await sdk.getVaultById(vaultId)
494+
if (!vault) return false
495+
const remaining = vault.getUnlockTimeRemaining()
496+
return typeof remaining === 'number' && remaining > 0
497+
} catch (error) {
498+
log.warn(`[MPC IPC] isVaultUnlocked check failed for ${vaultId}:`, errorMsg(error))
499+
return false
500+
}
501+
})
502+
484503
// ============================================
485504
// Transaction Signing
486505
// ============================================

src/main/preload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ const apiMpc: ApiMpc = {
124124
// Vault Lock/Unlock
125125
lockVault: (vaultId) => ipcRenderer.invoke(MpcIPCMessages.MPC_LOCK_VAULT, vaultId),
126126
unlockVault: (vaultId, password) => ipcRenderer.invoke(MpcIPCMessages.MPC_UNLOCK_VAULT, vaultId, password),
127+
isVaultUnlocked: (vaultId) => ipcRenderer.invoke(MpcIPCMessages.MPC_IS_VAULT_UNLOCKED, vaultId),
127128

128129
// Transaction Signing
129130
signBytes: (params) => ipcRenderer.invoke(MpcIPCMessages.MPC_SIGN_BYTES, params),

src/renderer/components/modal/confirmation/VultisigConfirmationModal.tsx

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { Label } from '../../uielements/label'
2121
import { QRCode } from '../../uielements/qrCode/QRCode'
2222

2323
type VaultType = 'fast' | 'secure'
24-
type Phase = 'password' | 'waiting-qr' | 'qr-ready' | 'device-joined' | 'signing' | 'error'
24+
type Phase = 'checking' | 'password' | 'waiting-qr' | 'qr-ready' | 'device-joined' | 'signing' | 'error'
2525

2626
type Props = {
2727
visible: boolean
@@ -89,26 +89,51 @@ export const VultisigConfirmationModal = ({
8989

9090
// Reset state when modal opens
9191
useEffect(() => {
92-
if (visible) {
93-
setPassword('')
94-
setPasswordError(null)
95-
setQrPayload(null)
96-
setDevicesJoined(0)
97-
setIsValidating(false)
98-
setIsCancelling(false)
99-
setTxErrorMsg(null)
100-
signingStartedRef.current = false
101-
closedRef.current = false
102-
103-
if (!isEncrypted) {
104-
// No password needed — skip straight to signing
105-
// Secure: modal stays open for QR/device flow
106-
// Fast: parent closes modal immediately via onSuccess callback
107-
setPhase(vaultType === 'secure' ? 'waiting-qr' : 'signing')
108-
onSuccess()
92+
if (!visible) return
93+
94+
setPassword('')
95+
setPasswordError(null)
96+
setQrPayload(null)
97+
setDevicesJoined(0)
98+
setIsValidating(false)
99+
setIsCancelling(false)
100+
setTxErrorMsg(null)
101+
signingStartedRef.current = false
102+
closedRef.current = false
103+
104+
// Skip straight to signing without asking for the password.
105+
// Secure: modal stays open for the QR/device flow. Fast: parent closes the
106+
// modal immediately via onSuccess.
107+
const skipToSigning = () => {
108+
setPhase(vaultType === 'secure' ? 'waiting-qr' : 'signing')
109+
onSuccess()
110+
}
111+
112+
if (!isEncrypted) {
113+
// Un-encrypted vault — no password ever needed.
114+
skipToSigning()
115+
return
116+
}
117+
118+
// Encrypted vault: only prompt when the SDK's password cache has actually
119+
// expired (idle > TTL). While it's still cached, re-entering the password is
120+
// redundant; skipping it also avoids the "Password required" cache-miss that
121+
// occurs when signing after the app has sat idle. `phase` alone can't tell us
122+
// this — only the SDK knows the live cache state.
123+
setPhase('checking')
124+
let cancelled = false
125+
const vaultId = getActiveVaultId()
126+
;(async () => {
127+
const stillUnlocked = vaultId ? await window.apiMpc.isVaultUnlocked(vaultId).catch(() => false) : false
128+
if (cancelled) return
129+
if (stillUnlocked) {
130+
skipToSigning()
109131
} else {
110132
setPhase('password')
111133
}
134+
})()
135+
return () => {
136+
cancelled = true
112137
}
113138
}, [visible]) // eslint-disable-line react-hooks/exhaustive-deps
114139

@@ -220,8 +245,8 @@ export const VultisigConfirmationModal = ({
220245
const [isCancelling, setIsCancelling] = useState(false)
221246

222247
const handleCancel = useCallback(async () => {
223-
if (phase === 'password' || phase === 'error') {
224-
// Password phase or failed tx - nothing to abort, just close
248+
if (phase === 'checking' || phase === 'password' || phase === 'error') {
249+
// Cache check, password phase, or failed tx - nothing to abort, just close
225250
onCancel?.()
226251
onClose()
227252
return
@@ -264,6 +289,15 @@ export const VultisigConfirmationModal = ({
264289

265290
// Render content based on phase
266291
const renderContent = () => {
292+
if (phase === 'checking') {
293+
// Brief state while we ask the SDK whether the password is still cached.
294+
return (
295+
<div className="flex flex-col items-center gap-4">
296+
<div className="h-12 w-12 animate-spin rounded-full border-4 border-turquoise border-t-transparent" />
297+
</div>
298+
)
299+
}
300+
267301
if (phase === 'password') {
268302
return (
269303
<div className="flex flex-col items-center gap-4">
@@ -357,7 +391,9 @@ export const VultisigConfirmationModal = ({
357391
// The handleCancel callback controls when closing is allowed
358392
<Dialog static as="div" className="relative z-10" open={visible} onClose={handleCancel}>
359393
<DialogBackdrop className="fixed inset-0 bg-bg0/40 dark:bg-bg0d/40" />
360-
<div className="fixed inset-0 flex items-center justify-center p-4">
394+
{/* `lg:pl-[240px]` offsets the sidebar so the panel centers within the
395+
content area, matching `UnifiedTxModal` (the tx-tracking modal). */}
396+
<div className="fixed inset-0 flex items-center justify-center p-4 lg:pl-[240px]">
361397
<DialogPanel
362398
className={clsx(
363399
'mx-auto flex flex-col items-center p-6',

src/renderer/services/wallet/types.test.ts

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -112,43 +112,28 @@ describe('services/wallet/types', () => {
112112
})
113113

114114
describe('isVultisigVaultPasswordRequired', () => {
115-
const fastActiveEncrypted: VultisigState = {
116-
...vultisigActive,
117-
activeVault: { id: 'v', name: 'V', type: 'fast', isEncrypted: true, chains: [] }
118-
}
119-
const fastLockedEncrypted: VultisigState = {
120-
mode: 'standalone-vultisig',
121-
phase: VultisigPhase.VaultLocked,
122-
availableVaults: [],
123-
activeVault: { id: 'v', name: 'V', type: 'fast', isEncrypted: true, chains: [] },
124-
addresses: {}
125-
}
126-
const secureActiveEncrypted: VultisigState = {
115+
// Reports whether the vault is password-protected (encrypted), independent of
116+
// phase. The live "is the cache still warm?" decision lives in the modal.
117+
const encryptedActive: VultisigState = {
127118
...vultisigActive,
128119
activeVault: { id: 'v', name: 'V', type: 'secure', isEncrypted: true, chains: [] }
129120
}
130121

131-
it('skips the prompt for an already-active encrypted fast vault', () => {
132-
expect(isVultisigVaultPasswordRequired(fastActiveEncrypted)).toBe(false)
133-
})
134-
it('skips the prompt for an already-active encrypted secure vault (co-signer authorizes)', () => {
135-
expect(isVultisigVaultPasswordRequired(secureActiveEncrypted)).toBe(false)
136-
})
137-
it('prompts for a still-locked encrypted fast vault', () => {
138-
expect(isVultisigVaultPasswordRequired(fastLockedEncrypted)).toBe(true)
122+
it('is true for an encrypted vault (Active)', () => {
123+
expect(isVultisigVaultPasswordRequired(encryptedActive)).toBe(true)
139124
})
140-
it('prompts for a still-locked encrypted secure vault', () => {
125+
it('is true for an encrypted vault (VaultLocked)', () => {
141126
// vultisigLocked is a secure, encrypted vault in the VaultLocked phase
142127
expect(isVultisigVaultPasswordRequired(vultisigLocked)).toBe(true)
143128
})
144-
it('never prompts for an un-encrypted vault', () => {
129+
it('is false for an un-encrypted vault', () => {
145130
// vultisigActive is a fast, un-encrypted vault
146131
expect(isVultisigVaultPasswordRequired(vultisigActive)).toBe(false)
147132
})
148-
it('falls back to prompting when there is no active vault', () => {
133+
it('falls back to true when there is no active vault', () => {
149134
expect(isVultisigVaultPasswordRequired(vultisigSelection)).toBe(true)
150135
})
151-
it('falls back to prompting for non-Vultisig states', () => {
136+
it('falls back to true for non-Vultisig states', () => {
152137
expect(isVultisigVaultPasswordRequired(keystoreUnlocked)).toBe(true)
153138
expect(isVultisigVaultPasswordRequired(ledgerState)).toBe(true)
154139
})

src/renderer/services/wallet/types.ts

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -80,33 +80,26 @@ export const isVultisigMode = (state: AppWalletState): state is VultisigState =>
8080
export const isVultisigVaultLocked = (state: VultisigState): boolean => state.phase === VultisigPhase.VaultLocked
8181

8282
/**
83-
* Whether the Vultisig confirmation modal should ask for the vault password
84-
* before signing.
83+
* Whether the active Vultisig vault is password-protected (encrypted) — i.e.
84+
* a password is required *in principle* before it can sign.
8585
*
86-
* The vault's static `isEncrypted` flag over-prompts on its own: it stays `true`
87-
* for the whole life of a password-protected vault, so every swap/send re-shows
88-
* the password field even when the vault is already unlocked for the session.
89-
*
90-
* Once a vault is unlocked (`phase === Active`) we should never ask for the
91-
* password again on a transaction — regardless of vault type:
92-
* - Fast vaults (1-of-1) already have the key share loaded, so re-entry is pure
93-
* theatre (`validatePassword` short-circuits to a non-empty check without even
94-
* calling the SDK).
95-
* - Secure vaults (multi-device) cannot submit anything without the co-signer
96-
* device (e.g. phone) completing the MPC ceremony, which is the real
97-
* authorization — so the desktop password re-entry is redundant there too.
86+
* This is intentionally NOT the "should we show the prompt right now?" decision.
87+
* A password-protected vault stays encrypted for its whole life, but the SDK
88+
* caches the password with a TTL after an unlock, so re-prompting on every tx is
89+
* redundant while the cache is warm. The *live* decision — skip while the SDK's
90+
* password cache is valid, prompt once it has expired — is made in
91+
* `VultisigConfirmationModal` via `window.apiMpc.isVaultUnlocked`, because only
92+
* the SDK knows the true cache state (the renderer's `phase` does not track the
93+
* TTL).
9894
*
9995
* - Not Vultisig / no active vault → `true` (safe default; callers only render
10096
* the Vultisig modal in Vultisig mode anyway).
101-
* - Un-encrypted vault → `false` (nothing to unlock).
102-
* - Vault already `Active` (unlocked this session) → `false`.
103-
* - Encrypted vault not yet unlocked → `true` (password needed to unlock).
97+
* - Un-encrypted vault → `false` (no password ever needed).
98+
* - Encrypted vault → `true`.
10499
*/
105100
export const isVultisigVaultPasswordRequired = (state: AppWalletState): boolean => {
106101
if (!isVultisigMode(state) || !state.activeVault) return true
107-
if (!state.activeVault.isEncrypted) return false
108-
if (state.phase === VultisigPhase.Active) return false
109-
return true
102+
return state.activeVault.isEncrypted
110103
}
111104

112105
export const isKeystoreMode = (state: AppWalletState): state is KeystoreState =>

src/shared/api/mpcTypes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export enum MpcIPCMessages {
196196
// Vault Lock/Unlock
197197
MPC_LOCK_VAULT = 'mpc:lockVault',
198198
MPC_UNLOCK_VAULT = 'mpc:unlockVault',
199+
MPC_IS_VAULT_UNLOCKED = 'mpc:isVaultUnlocked',
199200

200201
// Events (main -> renderer)
201202
MPC_CREATION_PROGRESS = 'mpc:creationProgress',
@@ -250,6 +251,9 @@ export type ApiMpc = {
250251
// Vault Lock/Unlock
251252
lockVault: (vaultId: string) => Promise<void>
252253
unlockVault: (vaultId: string, password: string) => Promise<void>
254+
// Whether the vault's password is currently cached & not expired (i.e. it can
255+
// sign without a fresh password prompt). Reflects the SDK's password-cache TTL.
256+
isVaultUnlocked: (vaultId: string) => Promise<boolean>
253257

254258
// Transaction Signing
255259
signBytes: (params: SignBytesParams) => Promise<SignBytesResult>

0 commit comments

Comments
 (0)