Skip to content

Commit 856da41

Browse files
committed
feat: enhance delegated wallet status reporting
Expose delegated wallet session details with active/expired/none status semantics and optional backend verification so users can reliably inspect and reconcile local session state. Made-with: Cursor
1 parent d4e335f commit 856da41

File tree

4 files changed

+507
-51
lines changed

4 files changed

+507
-51
lines changed

src/commands/wallet.ts

Lines changed: 153 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { printHuman, isJSONMode, printJSON } from "../lib/output.js";
1818
import { CLIError, ErrorCode, errAuthRequired, errInvalidArgs, errWalletKeyRequired, exitWithError } from "../lib/errors.js";
1919
import { green, dim, printKeyValueBox } from "../lib/ui.js";
2020
import { createPendingSession, loadSession, clearSession, isSessionValid, updateSession } from "../lib/wallet-session.js";
21+
import * as walletSession from "../lib/wallet-session.js";
2122

2223
type WalletType = "evm" | "solana";
2324
const CONNECT_POLL_INTERVAL_MS = 2_000;
@@ -97,6 +98,85 @@ async function getSolanaWalletAddress(secretKey: string): Promise<string> {
9798
const WALLET_KEYS_DIR = "wallet-keys";
9899
const UUID_SLICE_LEN = 8;
99100
const ADDRESS_SLICE_LEN = 12;
101+
type WalletStatus = "active" | "expired" | "none";
102+
type RemoteWalletStatus = Awaited<ReturnType<typeof getRemoteWalletSession>>["status"];
103+
104+
function normalizeRemoteStatusForStorage(status: RemoteWalletStatus): "pending" | "approved" | "revoked" | "expired" {
105+
if (status === "denied") {
106+
return "revoked";
107+
}
108+
return status;
109+
}
110+
111+
function isFutureIsoDate(value: string): boolean {
112+
const date = new Date(value);
113+
if (Number.isNaN(date.getTime())) {
114+
return false;
115+
}
116+
return date > new Date();
117+
}
118+
119+
function isActiveWalletSession(
120+
session: walletSession.WalletSession | null,
121+
remoteStatus?: RemoteWalletStatus,
122+
): boolean {
123+
if (!session) return false;
124+
if (session.status !== "approved") return false;
125+
if (!isFutureIsoDate(session.expiresAt)) return false;
126+
if (remoteStatus && remoteStatus !== "approved") return false;
127+
return true;
128+
}
129+
130+
function deriveWalletStatus(
131+
session: walletSession.WalletSession | null,
132+
remoteStatus?: RemoteWalletStatus,
133+
): WalletStatus {
134+
if (!session) return "none";
135+
if (isActiveWalletSession(session, remoteStatus)) return "active";
136+
if (session.status === "pending") return "none";
137+
return "expired";
138+
}
139+
140+
function resolveSessionAddress(session: walletSession.WalletSession | null): string | null {
141+
if (!session) return null;
142+
return session.evmAddress ?? session.solanaAddress ?? null;
143+
}
144+
145+
function resolveSessionEnvironment(session: walletSession.WalletSession | null): string | null {
146+
if (!session) return null;
147+
if (session.backendBaseUrl) {
148+
try {
149+
const { hostname } = new URL(session.backendBaseUrl);
150+
return hostname;
151+
} catch {
152+
return session.backendBaseUrl;
153+
}
154+
}
155+
156+
if (session.environment) {
157+
return session.environment.interactive ? "interactive" : "non-interactive";
158+
}
159+
160+
return null;
161+
}
162+
163+
function resolveEnabledCapabilities(
164+
session: walletSession.WalletSession | null,
165+
): string[] {
166+
if (!session?.capabilities) {
167+
return [];
168+
}
169+
170+
return Object.entries(session.capabilities)
171+
.filter(([, value]) => value)
172+
.map(([key]) => key)
173+
.sort((left, right) => left.localeCompare(right));
174+
}
175+
176+
function formatWalletStatus(value: WalletStatus): string {
177+
if (value === "active") return green("active");
178+
return dim(value);
179+
}
100180

101181
function walletKeysDirPath(): string {
102182
return join(config.configDir(), WALLET_KEYS_DIR);
@@ -402,23 +482,25 @@ export function registerWallet(program: Command) {
402482
}
403483

404484
const session = createPendingSession();
485+
const sessionEnvironment = {
486+
platform: process.platform,
487+
arch: process.arch,
488+
nodeVersion: process.version,
489+
interactive: isInteractiveAllowed(program),
490+
};
405491
updateSession({
406492
chainType: "evm",
407493
capabilities: { ...DEFAULT_WALLET_CAPABILITIES },
408494
backendBaseUrl: getWalletApiBaseUrl(),
495+
environment: sessionEnvironment,
409496
});
410497

411498
const remoteSession = await createRemoteWalletSession(authToken, {
412499
publicKeyJwk: session.publicKeyJwk,
413500
requestSignerVersion: session.envelopeVersion,
414501
chainType: "evm",
415502
capabilities: { ...DEFAULT_WALLET_CAPABILITIES },
416-
environment: {
417-
platform: process.platform,
418-
arch: process.arch,
419-
nodeVersion: process.version,
420-
interactive: isInteractiveAllowed(program),
421-
},
503+
environment: sessionEnvironment,
422504
}).catch((err) => {
423505
clearSession();
424506
throw err;
@@ -497,6 +579,7 @@ export function registerWallet(program: Command) {
497579
chainType: approvedSession.chainType ?? "evm",
498580
capabilities: approvedSession.capabilities ?? { ...DEFAULT_WALLET_CAPABILITIES },
499581
backendBaseUrl: getWalletApiBaseUrl(),
582+
environment: sessionEnvironment,
500583
});
501584

502585
if (isJSONMode()) {
@@ -528,59 +611,87 @@ export function registerWallet(program: Command) {
528611

529612
cmd
530613
.command("status")
531-
.description("Show current delegated wallet session status")
532-
.action(() => {
614+
.option("--verify", "Verify delegated session status with backend")
615+
.description("Show delegated wallet session status and metadata")
616+
.action(async (opts: { verify?: boolean }) => {
533617
try {
534-
const session = loadSession();
535-
const walletId = session?.walletId ?? session?.evmWalletId;
536-
537-
if (!session) {
538-
printHuman(
539-
` ${dim("No wallet session. Run")} alchemy wallet connect ${dim("to get started.")}\n`,
540-
{ connected: false },
541-
);
542-
return;
618+
let session = walletSession.loadStoredSession?.() ?? loadSession();
619+
let remoteStatus: RemoteWalletStatus | null = null;
620+
621+
if (opts.verify && session) {
622+
const authToken = resolveAuthToken();
623+
if (!authToken) throw errAuthRequired();
624+
625+
const remote = await getRemoteWalletSession(authToken, session.sessionId);
626+
remoteStatus = remote.status;
627+
const walletId = remote.walletId ?? remote.evmWalletId;
628+
629+
session = {
630+
...session,
631+
status: normalizeRemoteStatusForStorage(remote.status),
632+
expiresAt: remote.expiresAt ?? session.expiresAt,
633+
privyAppId: remote.privyAppId ?? session.privyAppId,
634+
walletId: walletId ?? session.walletId,
635+
evmWalletId: remote.evmWalletId ?? walletId ?? session.evmWalletId,
636+
evmAddress: remote.evmAddress ?? remote.address ?? session.evmAddress,
637+
solanaWalletId: remote.solanaWalletId ?? session.solanaWalletId,
638+
solanaAddress: remote.solanaAddress ?? session.solanaAddress,
639+
chainType: remote.chainType ?? session.chainType,
640+
capabilities: remote.capabilities ?? session.capabilities,
641+
};
642+
walletSession.saveSession(session);
543643
}
544644

645+
const status = deriveWalletStatus(session, remoteStatus ?? undefined);
646+
const walletAddress = resolveSessionAddress(session);
647+
const sessionEnvironment = resolveSessionEnvironment(session);
648+
const signerCapabilities = resolveEnabledCapabilities(session);
649+
const walletId = session?.walletId ?? session?.evmWalletId ?? null;
650+
const expiresAt = session?.expiresAt ?? null;
651+
545652
if (isJSONMode()) {
546653
printJSON({
547-
sessionId: session.sessionId,
548-
status: session.status,
549-
evmAddress: session.evmAddress ?? null,
550-
walletId: walletId ?? null,
551-
createdAt: session.createdAt,
552-
expiresAt: session.expiresAt,
553-
valid: isSessionValid(session),
554-
chainType: session.chainType ?? null,
555-
capabilities: session.capabilities ?? null,
654+
walletAddress,
655+
status,
656+
expiresAt,
657+
environment: sessionEnvironment,
658+
signerCapabilities,
659+
sessionId: session?.sessionId ?? null,
660+
sessionState: session?.status ?? null,
661+
walletId,
662+
chainType: session?.chainType ?? null,
663+
verified: Boolean(opts.verify),
664+
remoteStatus,
665+
valid: session ? isSessionValid(session) : false,
556666
});
557667
return;
558668
}
559669

560670
const pairs: [string, string][] = [
561-
["Session ID", session.sessionId],
562-
["Status", session.status],
671+
["Wallet Address", walletAddress ?? dim("none")],
672+
["Session Status", formatWalletStatus(status)],
673+
["Session Expiry", expiresAt ?? dim("none")],
674+
["Environment", sessionEnvironment ?? dim("none")],
675+
["Signer Capabilities", signerCapabilities.length > 0 ? signerCapabilities.join(", ") : dim("none")],
563676
];
564-
if (session.evmAddress) pairs.push(["EVM Address", session.evmAddress]);
565-
if (walletId) pairs.push(["Wallet ID", walletId]);
566-
if (session.chainType) pairs.push(["Chain Type", session.chainType]);
567-
pairs.push(["Created", session.createdAt]);
568-
pairs.push(["Expires", session.expiresAt]);
569-
570-
if (session.capabilities) {
571-
const caps = Object.entries(session.capabilities)
572-
.filter(([, v]) => v)
573-
.map(([k]) => k)
574-
.join(", ");
575-
if (caps) pairs.push(["Capabilities", caps]);
677+
if (session?.sessionId) {
678+
pairs.push(["Session ID", session.sessionId]);
679+
}
680+
if (walletId) {
681+
pairs.push(["Wallet ID", walletId]);
682+
}
683+
if (opts.verify) {
684+
pairs.push(["Backend Status", remoteStatus ?? dim("not checked")]);
576685
}
577686

578687
printKeyValueBox(pairs);
579688

580-
if (session.status === "approved") {
689+
if (status === "active") {
581690
console.log(` ${green("✓")} Wallet session active`);
691+
} else if (status === "none") {
692+
console.log(` ${dim("No delegated wallet session found. Run")} alchemy wallet connect ${dim("to get started.")}`);
582693
} else {
583-
console.log(` ${dim("Session is")} ${session.status}`);
694+
console.log(` ${dim("Wallet session is not active. Run")} alchemy wallet connect --force ${dim("to reconnect.")}`);
584695
}
585696
} catch (err) {
586697
exitWithError(err);

src/lib/wallet-session.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ const walletSessionCapabilitiesSchema = z
1717
})
1818
.strict();
1919

20+
const walletSessionEnvironmentSchema = z
21+
.object({
22+
platform: z.string().min(1),
23+
arch: z.string().min(1),
24+
nodeVersion: z.string().min(1),
25+
interactive: z.boolean(),
26+
})
27+
.strict();
28+
2029
const walletSessionSchema = z
2130
.object({
2231
sessionId: z.string().uuid(),
@@ -36,6 +45,7 @@ const walletSessionSchema = z
3645
privySignerId: z.string().min(1).optional(),
3746
chainType: z.string().min(1).optional(),
3847
backendBaseUrl: z.string().min(1).optional(),
48+
environment: walletSessionEnvironmentSchema.optional(),
3949
capabilities: walletSessionCapabilitiesSchema.optional(),
4050
})
4151
.strict();
@@ -62,6 +72,12 @@ export interface WalletSession {
6272
privySignerId?: string;
6373
chainType?: string;
6474
backendBaseUrl?: string;
75+
environment?: {
76+
platform: string;
77+
arch: string;
78+
nodeVersion: string;
79+
interactive: boolean;
80+
};
6581

6682
// Capability flags
6783
capabilities?: {
@@ -79,6 +95,7 @@ interface P256Keypair {
7995

8096
export interface WalletSessionStore {
8197
load(): WalletSession | null;
98+
loadRaw?(): WalletSession | null;
8299
save(session: WalletSession): void;
83100
clear(): boolean;
84101
}
@@ -93,6 +110,15 @@ function parseStoredSession(value: unknown): WalletSession | null {
93110
return parsed.data as WalletSession;
94111
}
95112

113+
function loadStoredSessionFromPath(path: string): WalletSession | null {
114+
if (!existsSync(path)) return null;
115+
try {
116+
return parseStoredSession(JSON.parse(readFileSync(path, "utf-8")));
117+
} catch {
118+
return null;
119+
}
120+
}
121+
96122
function isExpired(session: Pick<WalletSession, "expiresAt">): boolean {
97123
const expiresAt = new Date(session.expiresAt);
98124
return !Number.isNaN(expiresAt.getTime()) && expiresAt <= new Date();
@@ -101,20 +127,15 @@ function isExpired(session: Pick<WalletSession, "expiresAt">): boolean {
101127
export function createFileWalletSessionStore(path = sessionPath()): WalletSessionStore {
102128
return {
103129
load() {
104-
if (!existsSync(path)) return null;
105-
106-
let session: WalletSession | null;
107-
try {
108-
session = parseStoredSession(JSON.parse(readFileSync(path, "utf-8")));
109-
} catch {
110-
return null;
111-
}
112-
130+
const session = loadStoredSessionFromPath(path);
113131
if (!session) return null;
114132
if (session.status === "revoked") return null;
115133
if (isExpired(session)) return null;
116134
return session;
117135
},
136+
loadRaw() {
137+
return loadStoredSessionFromPath(path);
138+
},
118139
save(session) {
119140
mkdirSync(dirname(path), { recursive: true, mode: 0o755 });
120141
writeFileSync(path, JSON.stringify(session, null, 2) + "\n", { mode: 0o600 });
@@ -143,6 +164,9 @@ export function createMemoryWalletSessionStore(initialSession: WalletSession | n
143164
save(nextSession) {
144165
session = nextSession;
145166
},
167+
loadRaw() {
168+
return session;
169+
},
146170
clear() {
147171
const hadSession = session !== null;
148172
session = null;
@@ -205,6 +229,14 @@ export function loadSession(): WalletSession | null {
205229
return getWalletSessionStore().load();
206230
}
207231

232+
export function loadStoredSession(): WalletSession | null {
233+
const store = getWalletSessionStore();
234+
if (store.loadRaw) {
235+
return store.loadRaw();
236+
}
237+
return store.load();
238+
}
239+
208240
export function saveSession(session: WalletSession): void {
209241
getWalletSessionStore().save(session);
210242
}

0 commit comments

Comments
 (0)