Vest: one refused account token fails only its account, and the relay sends the current session's token
Every open Vest tab renews the login on its own, and each renewal moves the session on: a token from before still reads, but Vest refuses to mint an account's token with it (401, code 1002). The extension relayed the first tab's token, so mints failed most of the time with two tabs open, and each refusal dropped the whole session — every later account in a history pass failed "no Vest session", and new accounts waited out 5-minute retries for up to an hour (2026-10-28/29). The client now fails only that account's call on a refused mint, keeps the session and the cached account tokens, and logs the refusal now and then with the login token's life left. An active account with no history is read again every minute even while a whole pass's retry waits; a pass reads those first and inactive accounts last, whose failures no longer set the retry. Exits, lone take-profits and flattens wait up to 90s for the session and the account's token instead of failing at once; entries still go now or not at all. Closed accounts' history is read through the login first. Extension 0.18.0: the hook records each token's session id and takes sign-in tokens too; the relay reads every Vest tab and Vest's own current-session record, and sends the token that session holds, else the newest, saying in the popup when none is current. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bf7e147507
commit
0eb56b3949
+121
-17
@@ -76,6 +76,16 @@ const TIMEOUT_MS = { read: 20_000, trade: 15_000, history: 30_000 } as const;
|
||||
const ACCOUNT_TOKEN_SKEW_MS = 90_000;
|
||||
/** The relayed user token must have this long left to start a request. */
|
||||
const USER_TOKEN_MIN_MS = 20_000;
|
||||
/** How long an exit waits for the login's session and its account's token
|
||||
* — the extension relays the session again within a minute — before it
|
||||
* gives up as not sent. */
|
||||
const EXIT_SESSION_WAIT_MS = 90_000;
|
||||
const EXIT_SESSION_POLL_MS = 2_000;
|
||||
/** A refused account-token mint is logged again after this long. */
|
||||
const MINT_REFUSED_LOG_MS = 5 * 60_000;
|
||||
/** How soon an active account with no history yet is read again, apart
|
||||
* from a whole pass's retry. */
|
||||
const NEW_HISTORY_RETRY_MS = 60_000;
|
||||
const FILL_POLL_MS = 1_000;
|
||||
const FILL_TIMEOUT_MS = 30_000;
|
||||
const PICK_TTL_MS = 45_000;
|
||||
@@ -238,6 +248,8 @@ export class VestClient implements PlatformClient {
|
||||
private seenOpenAt = new Map<number, { positionId: string; at: number; naggedAt: number }>();
|
||||
private accountTokens = new Map<string, { token: string; expiresAt: number }>();
|
||||
private accountTokenFlights = new Map<string, Promise<string>>();
|
||||
/** The account-token refusal last logged, and when. */
|
||||
private mintRefusedNote: { msg: string; at: number } | null = null;
|
||||
|
||||
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private watchdogInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -258,6 +270,9 @@ export class VestClient implements PlatformClient {
|
||||
private lastDaysFetchStartedAt = 0;
|
||||
private lastDaysFetchSucceededAt = 0;
|
||||
private nextDaysFetchRetryAt = 0;
|
||||
/** When the last read of only the accounts with no history yet began. */
|
||||
private lastNewHistoryFetchAt = 0;
|
||||
private newHistoryWarnedAt = 0;
|
||||
private historyLoaded = false;
|
||||
private historyLoadedIds = new Set<number>();
|
||||
private booksHeld = new Set<number>();
|
||||
@@ -333,16 +348,25 @@ export class VestClient implements PlatformClient {
|
||||
if (this.lastSyncAt === 0 || Date.now() - this.lastSyncAt > SYNC_INTERVAL_MS) void this.requestSync();
|
||||
}
|
||||
|
||||
/** A token for one trading account, minted from the user token and cached. */
|
||||
/** A token for one trading account, minted from the user token and cached.
|
||||
* Vest may refuse the mint for one account (401, code 1002 — closed ones,
|
||||
* and active ones for a while) while the login's own reads go through:
|
||||
* that fails this account's call alone (request's `mint`). */
|
||||
private async accountToken(uuid: string): Promise<string> {
|
||||
const cached = this.accountTokens.get(uuid);
|
||||
if (cached && cached.expiresAt - Date.now() > ACCOUNT_TOKEN_SKEW_MS) return cached.token;
|
||||
const flight = this.accountTokenFlights.get(uuid);
|
||||
if (flight) return flight;
|
||||
const p = (async () => {
|
||||
const { data } = await this.request<{ accessToken?: unknown; accessExpiresAtMs?: unknown }>('read', 'POST', '/v3/auth/account-token', {
|
||||
body: { accountId: uuid },
|
||||
});
|
||||
let data: { accessToken?: unknown; accessExpiresAtMs?: unknown } | undefined;
|
||||
try {
|
||||
({ data } = await this.request<{ accessToken?: unknown; accessExpiresAtMs?: unknown }>('read', 'POST', '/v3/auth/account-token', {
|
||||
body: { accountId: uuid }, mint: true,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (err instanceof VestApiError && err.status === 401 && err.code !== 'NO_SESSION') this.noteMintRefused(uuid, err.message);
|
||||
throw err;
|
||||
}
|
||||
const token = typeof data?.accessToken === 'string' ? data.accessToken : '';
|
||||
const expiresAt = num(data?.accessExpiresAtMs) ?? Date.now() + 10 * 60_000;
|
||||
if (!token) throw new Error('/v3/auth/account-token answered without a token');
|
||||
@@ -353,6 +377,55 @@ export class VestClient implements PlatformClient {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A refused mint, said once in a while — with how long the login's
|
||||
* token had left, the clue to why Vest refuses them — not once per
|
||||
* account per pass. */
|
||||
private noteMintRefused(uuid: string, msg: string): void {
|
||||
const now = Date.now();
|
||||
const last = this.mintRefusedNote;
|
||||
if (last && now - last.at < MINT_REFUSED_LOG_MS && last.msg === msg) return;
|
||||
this.mintRefusedNote = { msg, at: now };
|
||||
const name = this.accountList.find((a) => this.uuidById.get(a.id) === uuid)?.name ?? uuid;
|
||||
const session = vestSessionFor(this.wallet, now, 0);
|
||||
const left = session ? `${Math.round((session.expiresAt - now) / 1000)}s` : 'none';
|
||||
console.warn(`${this.tag} ${name}: Vest refused its account token (${msg}; the login's token has ${left} left) — only that account's calls fail, the session stays`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Before an exit: wait for the login's session (the extension relays it
|
||||
* again within a minute) and the account's own token, up to
|
||||
* EXIT_SESSION_WAIT_MS. An exit given up on leaves a position riding
|
||||
* with no stop, so it waits rather than failing at once; one still
|
||||
* without them is OrderNotPlacedError — nothing went out.
|
||||
*/
|
||||
private async readyForExit(accountId: number, what: string): Promise<void> {
|
||||
const uuid = this.uuidById.get(accountId);
|
||||
const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId);
|
||||
const startedAt = Date.now();
|
||||
let held = false;
|
||||
let lastError = 'no Vest session';
|
||||
for (;;) {
|
||||
if (vestSessionFor(this.wallet, Date.now(), USER_TOKEN_MIN_MS)) {
|
||||
try {
|
||||
if (uuid) await this.accountToken(uuid);
|
||||
if (held) console.log(`${this.tag} ${name}: ${what} — ready after ${Math.round((Date.now() - startedAt) / 1000)}s, sending`);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = errorMessage(err);
|
||||
}
|
||||
} else {
|
||||
lastError = 'no Vest session';
|
||||
}
|
||||
const waited = Date.now() - startedAt;
|
||||
if (this.intentionalDisconnect || waited + EXIT_SESSION_POLL_MS > EXIT_SESSION_WAIT_MS) {
|
||||
throw new OrderNotPlacedError(`${what} not sent — ${lastError}${held ? ` (waited ${Math.round(waited / 1000)}s)` : ''}`);
|
||||
}
|
||||
if (!held) console.warn(`${this.tag} ${name}: ${what} held — ${lastError}; waiting up to ${EXIT_SESSION_WAIT_MS / 1000}s before sending`);
|
||||
held = true;
|
||||
await sleep(EXIT_SESSION_POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// ── transport ────────────────────────────────────────────────────────────
|
||||
|
||||
private async gate(bucket: Bucket): Promise<void> {
|
||||
@@ -370,13 +443,14 @@ export class VestClient implements PlatformClient {
|
||||
* An account token that can't be minted is VestNotSentError: the call
|
||||
* itself never went out. A refused account token is re-minted once; a
|
||||
* refused user token is dropped, so nothing keeps knocking with it until
|
||||
* a fresh one arrives.
|
||||
* a fresh one arrives — but not on a refused mint (`mint`), which is one
|
||||
* account's: the login's own reads go on through on the same token.
|
||||
*/
|
||||
private async request<T>(
|
||||
bucket: Bucket,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
opts: { params?: Record<string, string | number | boolean>; body?: unknown; account?: string; auth?: boolean; retried?: boolean } = {},
|
||||
opts: { params?: Record<string, string | number | boolean>; body?: unknown; account?: string; auth?: boolean; retried?: boolean; mint?: boolean } = {},
|
||||
): Promise<{ data: T }> {
|
||||
let token: string | null = null;
|
||||
if (opts.auth !== false) {
|
||||
@@ -421,9 +495,12 @@ export class VestClient implements PlatformClient {
|
||||
if (opts.account) {
|
||||
this.accountTokens.delete(opts.account);
|
||||
if (!opts.retried) return this.request<T>(bucket, method, path, { ...opts, retried: true });
|
||||
} else {
|
||||
} else if (!opts.mint) {
|
||||
// The user token itself was refused: forget it, and wait for the
|
||||
// tab's next renewal rather than retrying it.
|
||||
// tab's next renewal rather than retrying it. (A refused mint
|
||||
// dropped it too until 2026-10-29, and every account after it
|
||||
// in a history pass failed "no Vest session" until the
|
||||
// extension relayed it again.)
|
||||
dropVestSession(this.wallet, token);
|
||||
this.accountTokens.clear();
|
||||
this.consecutiveAuthFailures++;
|
||||
@@ -1349,8 +1426,12 @@ export class VestClient implements PlatformClient {
|
||||
const retryPending = this.nextDaysFetchRetryAt > 0;
|
||||
const retryDue = retryPending && now >= this.nextDaysFetchRetryAt;
|
||||
const hourlyRefreshDue = !retryPending && this.lastDaysFetchSucceededAt > 0 && now - this.lastDaysFetchSucceededAt >= 60 * 60_000;
|
||||
const waiting = !initialFetchDue && !retryPending
|
||||
? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id) && !this.fetchingIds.has(a.id)).map((a) => a.id)
|
||||
// An active account with no history yet can't trade: it is read on
|
||||
// its own every NEW_HISTORY_RETRY_MS, a whole pass's retry pending or
|
||||
// not — on 2026-10-29 five new accounts sat out retries of passes
|
||||
// that failed. An inactive one waits for a whole pass.
|
||||
const waiting = !initialFetchDue && now - this.lastNewHistoryFetchAt >= NEW_HISTORY_RETRY_MS
|
||||
? this.accountList.filter((a) => a.active && !this.historyLoadedIds.has(a.id) && !this.fetchingIds.has(a.id)).map((a) => a.id)
|
||||
: [];
|
||||
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
|
||||
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
|
||||
@@ -1358,6 +1439,7 @@ export class VestClient implements PlatformClient {
|
||||
this.daysFetchInFlight = true;
|
||||
this.daysFetchInFlightSince = now;
|
||||
this.lastDaysFetchStartedAt = now;
|
||||
if (targeted) this.lastNewHistoryFetchAt = now;
|
||||
if (retryDue) this.nextDaysFetchRetryAt = 0;
|
||||
|
||||
this.fetchDaysTraded(targeted ? waiting : undefined)
|
||||
@@ -1366,6 +1448,14 @@ export class VestClient implements PlatformClient {
|
||||
this.historyLoaded = true;
|
||||
void this.requestSync();
|
||||
}
|
||||
if (failedAccounts > 0 && targeted) {
|
||||
// Read again on their own within the minute: said now and then.
|
||||
if (Date.now() - this.newHistoryWarnedAt >= 5 * 60_000) {
|
||||
this.newHistoryWarnedAt = Date.now();
|
||||
console.warn(`${this.tag} ${failedAccounts} new account(s) failed history fetch — reading them again every minute`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (failedAccounts > 0) {
|
||||
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
||||
console.warn(`${this.tag} ${failedAccounts} account(s) failed history fetch — retrying in 5 minutes`);
|
||||
@@ -1374,8 +1464,8 @@ export class VestClient implements PlatformClient {
|
||||
if (!targeted) this.lastDaysFetchSucceededAt = Date.now();
|
||||
})
|
||||
.catch((err) => {
|
||||
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
||||
console.error(`${this.tag} history fetch failed — retrying in 5 minutes: ${errorMessage(err)}`);
|
||||
if (!targeted) this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
||||
console.error(`${this.tag} history fetch failed — ${targeted ? 'reading the new account(s) again within a minute' : 'retrying in 5 minutes'}: ${errorMessage(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.daysFetchInFlight = false;
|
||||
@@ -1383,8 +1473,16 @@ export class VestClient implements PlatformClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the accounts' history: those with none yet first (they can't
|
||||
* trade until it's read), then the other active ones, the inactive
|
||||
* last. Only an active account's failure counts — an inactive one's is
|
||||
* noted, and waits for the hourly pass. */
|
||||
public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> {
|
||||
const ids = this.accountList.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id)).map((a) => a.id);
|
||||
const rank = (a: PlatformAccount) => (!a.active ? 2 : this.historyLoadedIds.has(a.id) ? 1 : 0);
|
||||
const ids = this.accountList
|
||||
.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id))
|
||||
.sort((x, y) => rank(x) - rank(y))
|
||||
.map((a) => a.id);
|
||||
for (const id of ids) this.fetchingIds.add(id);
|
||||
this.fetchDaysComplete = false;
|
||||
let failed = 0;
|
||||
@@ -1396,7 +1494,7 @@ export class VestClient implements PlatformClient {
|
||||
try {
|
||||
this.bookHistory(account, await this.fetchTxns(this.uuidById.get(id)!));
|
||||
} catch (err) {
|
||||
failed++;
|
||||
if (account.active) failed++;
|
||||
this.fallBackToCache(account, errorMessage(err));
|
||||
}
|
||||
try {
|
||||
@@ -1447,9 +1545,10 @@ export class VestClient implements PlatformClient {
|
||||
}
|
||||
try {
|
||||
const acc = { id: a.accountId, name: a.accountName, active: false };
|
||||
// A closed account may get no token of its own: the
|
||||
// login's reads it as the app's history tab does.
|
||||
await this.fetchTrades(acc).catch(() => this.fetchTrades(acc, true));
|
||||
// A closed account gets no token of its own (Vest refuses
|
||||
// the mint): the login's reads it as the app's history
|
||||
// tab does, and its own token is the fallback.
|
||||
await this.fetchTrades(acc, true).catch(() => this.fetchTrades(acc));
|
||||
console.log(`${this.tag} ${a.accountName}: off Vest's active list — read its position history one last time`);
|
||||
} catch (err) {
|
||||
console.warn(`${this.tag} ${a.accountName}: off Vest's active list, and its position history read failed: ${errorMessage(err)}`);
|
||||
@@ -1931,6 +2030,9 @@ export class VestClient implements PlatformClient {
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// A close (a flatten, most of all) waits out a lapse in the session;
|
||||
// an open goes now or not at all.
|
||||
if (intent === 'close') await this.readyForExit(accountId, 'close');
|
||||
const { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol);
|
||||
const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
||||
let positions: VestPosition[];
|
||||
@@ -2174,6 +2276,7 @@ export class VestClient implements PlatformClient {
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number,
|
||||
): Promise<{ orderId?: number }> {
|
||||
await this.readyForExit(accountId, orderType === 'Limit' && price !== undefined ? 'take-profit' : 'close');
|
||||
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
||||
const exitSide: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
||||
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
||||
@@ -2203,6 +2306,7 @@ export class VestClient implements PlatformClient {
|
||||
limitPrice: number,
|
||||
stopPrice: number,
|
||||
): Promise<{ orderId?: number }> {
|
||||
await this.readyForExit(accountId, 'exits');
|
||||
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
||||
const exitSide: 'long' | 'short' = exitAction === 'Buy' ? 'long' : 'short';
|
||||
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
||||
|
||||
Reference in New Issue
Block a user