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
@@ -103,6 +103,18 @@ dashboard POST /api/vest/session holds it in memory for the firm's Vest cl
|
|||||||
- The token lives ~15 minutes and the app renews it by itself while the tab is
|
- The token lives ~15 minutes and the app renews it by itself while the tab is
|
||||||
open. The tab is marked not auto-discardable, since a discarded tab stops
|
open. The tab is marked not auto-discardable, since a discarded tab stops
|
||||||
renewing.
|
renewing.
|
||||||
|
- Several Vest tabs each renew on their own, and every renewal moves the login
|
||||||
|
on to a new session (localStorage `vest-auth-session`). A token from before
|
||||||
|
still reads, but Vest refuses to mint an account's token with it (401, code
|
||||||
|
1002), so the bot's orders and history reads fail. Since 0.18.0 the hook
|
||||||
|
records each token's session id, and the relay sends the token Vest's
|
||||||
|
current session holds, whichever tab has it, not the first tab's. A tab
|
||||||
|
hooked by an older build is matched by its token's expiry instead; one that
|
||||||
|
renewed before any hook was in it shows in the popup as "none holds the
|
||||||
|
current session" until it renews again or is reloaded.
|
||||||
|
- A sign-in's answer (`/register`) carries the login's own refresh token, and
|
||||||
|
is taken too since 0.18.0; before, the first token after a sign-in waited
|
||||||
|
for the tab's first renewal.
|
||||||
- When the dashboard has a Vest firm (its credential is the login's wallet)
|
- When the dashboard has a Vest firm (its credential is the login's wallet)
|
||||||
and no Vest tab is open, the extension opens one in the background. The app
|
and no Vest tab is open, the extension opens one in the background. The app
|
||||||
signs back in from its 30-day session. After that the person signs in again
|
signs back in from its 30-day session. After that the person signs in again
|
||||||
|
|||||||
+61
-7
@@ -1204,6 +1204,13 @@ async function tick() {
|
|||||||
// Vest app renewed for itself (~15 minutes each); this lifts it and posts it to
|
// Vest app renewed for itself (~15 minutes each); this lifts it and posts it to
|
||||||
// the dashboard whenever it changes. Nothing here renews or signs anything: the
|
// the dashboard whenever it changes. Nothing here renews or signs anything: the
|
||||||
// session stays the app's.
|
// session stays the app's.
|
||||||
|
//
|
||||||
|
// With several Vest tabs open, each renews on its own, and each renewal moves
|
||||||
|
// the login on to a new session: the other tabs' tokens still read, but Vest
|
||||||
|
// refuses to mint an account's token with them (401, code 1002) — the bot's
|
||||||
|
// history reads and orders fail until the tab it relays renews again. So the
|
||||||
|
// token relayed is the one Vest's current session holds (pickVestSession),
|
||||||
|
// whichever tab it is in, not the first tab's.
|
||||||
|
|
||||||
const VEST_PATTERN = 'https://next.vestmarkets.com/*';
|
const VEST_PATTERN = 'https://next.vestmarkets.com/*';
|
||||||
const VEST_URL = 'https://next.vestmarkets.com/portfolio';
|
const VEST_URL = 'https://next.vestmarkets.com/portfolio';
|
||||||
@@ -1220,10 +1227,47 @@ let vestLastPostAt = 0;
|
|||||||
let vestOpenedAt = 0;
|
let vestOpenedAt = 0;
|
||||||
|
|
||||||
/** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while
|
/** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while
|
||||||
* it has life left. Self-contained. */
|
* it has life left, and the current session the Vest app keeps for all its
|
||||||
|
* tabs. Self-contained. Version 2's token whenever it holds one: it sees
|
||||||
|
* every login renewal version 1 does once it's in, so a version-1 token it
|
||||||
|
* lacks is an account's (the 0.17.0/0.17.1 hooks kept those too). */
|
||||||
function pageVestSession() {
|
function pageVestSession() {
|
||||||
const s = window.__afVestSession;
|
const live = (s) => (s && typeof s.accessToken === 'string' && s.expiresAt > Date.now() + 30000 ? s : null);
|
||||||
return { hooked: window.__afVestHook === true, session: s && s.expiresAt > Date.now() + 30000 ? s : null };
|
const session = live(window.__afVestLogin) ?? live(window.__afVestSession);
|
||||||
|
let current = null;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem('vest-auth-session') || 'null');
|
||||||
|
if (raw && typeof raw === 'object') {
|
||||||
|
current = {
|
||||||
|
refreshTokenId: typeof raw.refreshTokenId === 'string' && raw.refreshTokenId ? raw.refreshTokenId : null,
|
||||||
|
accessTokenExpiresAt: Number.isFinite(raw.accessTokenExpiresAt) ? raw.accessTokenExpiresAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch { /* no session kept */ }
|
||||||
|
return { hooked: window.__afVestHook === 2, session, current };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a tab's token is the one the Vest app's current session holds:
|
||||||
|
* the same session id — the app's own check before it mints — else, for a
|
||||||
|
* token caught by a version-1 hook (no id), the same expiry, which the app
|
||||||
|
* writes into the session with the token. */
|
||||||
|
function isCurrentVestSession(session, current) {
|
||||||
|
if (!session || !current) return false;
|
||||||
|
if (session.refreshTokenId && current.refreshTokenId) return session.refreshTokenId === current.refreshTokenId;
|
||||||
|
return current.accessTokenExpiresAt !== null && session.expiresAt === current.accessTokenExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Out of every Vest tab's pageVestSession, the session to relay: the
|
||||||
|
* current one, else — no tab holds it (one renewed before its hook was in)
|
||||||
|
* — the latest to expire, which still reads. `current` is null when no tab
|
||||||
|
* shows the app's session record to judge by. */
|
||||||
|
function pickVestSession(pages) {
|
||||||
|
const live = pages.filter((p) => p && p.session);
|
||||||
|
if (live.length === 0) return null;
|
||||||
|
const current = live.find((p) => isCurrentVestSession(p.session, p.current));
|
||||||
|
if (current) return { session: current.session, current: true };
|
||||||
|
const newest = live.reduce((x, y) => (y.session.expiresAt > x.session.expiresAt ? y : x));
|
||||||
|
return { session: newest.session, current: pages.some((p) => p && p.current) ? false : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The wallets the dashboard's Vest firms trade; [] from an older dashboard. */
|
/** The wallets the dashboard's Vest firms trade; [] from an older dashboard. */
|
||||||
@@ -1259,7 +1303,8 @@ async function relayVestSession(cfg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let found = null;
|
// Every tab: the current session may be in any of them.
|
||||||
|
const pages = [];
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
// A discarded tab stops renewing the session; keep Chrome from it.
|
// A discarded tab stops renewing the session; keep Chrome from it.
|
||||||
if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {});
|
if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {});
|
||||||
@@ -1272,12 +1317,14 @@ async function relayVestSession(cfg) {
|
|||||||
// next renewal is caught.
|
// next renewal is caught.
|
||||||
await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'MAIN', files: ['vest-relay.js'] }).catch(() => {});
|
await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'MAIN', files: ['vest-relay.js'] }).catch(() => {});
|
||||||
}
|
}
|
||||||
if (out?.session) { found = out.session; break; }
|
if (out) pages.push(out);
|
||||||
}
|
}
|
||||||
if (!found) {
|
const picked = pickVestSession(pages);
|
||||||
|
if (!picked) {
|
||||||
await setState({ vest: 'Vest tab open — waiting for its session (sign in there if it shows Log In)' });
|
await setState({ vest: 'Vest tab open — waiting for its session (sign in there if it shows Log In)' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const found = picked.session;
|
||||||
|
|
||||||
const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`;
|
const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`;
|
||||||
if (fingerprint === vestLastRelayed && now - vestLastPostAt < VEST_REPOST_MS) return;
|
if (fingerprint === vestLastRelayed && now - vestLastPostAt < VEST_REPOST_MS) return;
|
||||||
@@ -1297,7 +1344,14 @@ async function relayVestSession(cfg) {
|
|||||||
const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000));
|
const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000));
|
||||||
const note = Array.isArray(body.bound) && body.bound.length > 0 ? ` · bound to ${body.bound.join(', ')}`
|
const note = Array.isArray(body.bound) && body.bound.length > 0 ? ` · bound to ${body.bound.join(', ')}`
|
||||||
: body.used ? '' : ' (no Vest firm here uses this wallet)';
|
: body.used ? '' : ' (no Vest firm here uses this wallet)';
|
||||||
await setState({ vest: `Relayed ${short}${note} · good ${mins}m` });
|
// A tab that renewed before its hook was in holds the current session
|
||||||
|
// unseen: until it renews again, orders and history reads can fail —
|
||||||
|
// reloading it hooks it.
|
||||||
|
const tabsNote = picked.current === false
|
||||||
|
? (tabs.length === 1 ? " · its token isn't Vest's current session — reload the Vest tab"
|
||||||
|
: ` · ${tabs.length} Vest tabs and none holds the current session — reload them, or keep one open`)
|
||||||
|
: tabs.length > 1 ? ` · ${tabs.length} Vest tabs${picked.current ? ', relaying the current one' : ''}` : '';
|
||||||
|
await setState({ vest: `Relayed ${short}${note} · good ${mins}m${tabsNote}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2FA: the person's authenticator code for the firm site you are on ───────
|
// ── 2FA: the person's authenticator code for the firm site you are on ───────
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "AutoFirmer Capture",
|
"name": "AutoFirmer Capture",
|
||||||
"version": "0.17.2",
|
"version": "0.18.0",
|
||||||
|
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
||||||
"update_url": "https://ext.juicerroom.com/updates.xml",
|
"update_url": "https://ext.juicerroom.com/updates.xml",
|
||||||
|
|||||||
+22
-9
@@ -6,10 +6,16 @@
|
|||||||
*
|
*
|
||||||
* Vest's app renews its own ~15-minute access token by itself: at load, then
|
* Vest's app renews its own ~15-minute access token by itself: at load, then
|
||||||
* whenever one nears expiry (POST /v3/auth/refresh, or /v3/auth/register at
|
* whenever one nears expiry (POST /v3/auth/refresh, or /v3/auth/register at
|
||||||
* sign-in). Those answers carry the token, its expiry and the login's wallet.
|
* sign-in). Those answers carry the token, its expiry, the login's wallet and
|
||||||
* This hook reads them off the app's own XHRs and keeps the latest on
|
* the session it belongs to (refreshTokenId). This hook reads them off the
|
||||||
* `window.__afVestSession`, where background.js collects it and relays it to
|
* app's own XHRs and keeps the latest on `window.__afVestLogin`, where
|
||||||
* the local dashboard, whose Vest client trades with it.
|
* background.js collects it and relays it to the local dashboard, whose Vest
|
||||||
|
* client trades with it.
|
||||||
|
*
|
||||||
|
* Every tab renews on its own, and each renewal moves the login on to a new
|
||||||
|
* session (localStorage `vest-auth-session`): a token from before still reads,
|
||||||
|
* but Vest refuses to mint an account's token with it (401, code 1002). The
|
||||||
|
* session id lets background.js relay the tab whose token is the current one.
|
||||||
*
|
*
|
||||||
* It sends nothing anywhere, changes no request and never renews anything
|
* It sends nothing anywhere, changes no request and never renews anything
|
||||||
* itself: the session stays the app's, refreshed by the app with its own
|
* itself: the session stays the app's, refreshed by the app with its own
|
||||||
@@ -19,14 +25,19 @@
|
|||||||
* /v3/auth/refresh, sending that account's refresh token in the body (the
|
* /v3/auth/refresh, sending that account's refresh token in the body (the
|
||||||
* login's rides in a cookie) and getting it back renewed. Those tokens see
|
* login's rides in a cookie) and getting it back renewed. Those tokens see
|
||||||
* one account only, so they are skipped: relaying one would leave the
|
* one account only, so they are skipped: relaying one would leave the
|
||||||
* dashboard with a single account.
|
* dashboard with a single account. A sign-in's answer carries the login's
|
||||||
|
* own refresh token, and is taken.
|
||||||
|
*
|
||||||
|
* Version 2. A page hooked by version 1 (`__afVestHook === true`, keeping
|
||||||
|
* `__afVestSession`, no session id, no sign-ins) takes this one beside it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
if (window.__afVestHook) return;
|
if (window.__afVestHook === 2) return;
|
||||||
window.__afVestHook = true;
|
window.__afVestHook = 2;
|
||||||
|
|
||||||
const AUTH = /^https:\/\/api-gateway\.hz\.vestmarkets\.com\/v3\/auth\/(refresh|register)(\?|$)/;
|
const AUTH = /^https:\/\/api-gateway\.hz\.vestmarkets\.com\/v3\/auth\/(refresh|register)(\?|$)/;
|
||||||
|
const SIGN_IN = /\/v3\/auth\/register(\?|$)/;
|
||||||
|
|
||||||
/** A request body that carries a refresh token: an account's renewal. */
|
/** A request body that carries a refresh token: an account's renewal. */
|
||||||
function accountRenewal(reqBody) {
|
function accountRenewal(reqBody) {
|
||||||
@@ -38,14 +49,16 @@
|
|||||||
let body;
|
let body;
|
||||||
try { body = JSON.parse(text); } catch { return; }
|
try { body = JSON.parse(text); } catch { return; }
|
||||||
if (!body || typeof body.accessToken !== 'string' || !body.accessToken) return;
|
if (!body || typeof body.accessToken !== 'string' || !body.accessToken) return;
|
||||||
if (typeof body.refreshToken === 'string' && body.refreshToken) return; // an account's token, renewed
|
// A renewal answering with a refresh token is an account's.
|
||||||
|
if (!SIGN_IN.test(url) && typeof body.refreshToken === 'string' && body.refreshToken) return;
|
||||||
const expiresAt = Number(body.accessExpiresAtMs);
|
const expiresAt = Number(body.accessExpiresAtMs);
|
||||||
if (!Number.isFinite(expiresAt)) return;
|
if (!Number.isFinite(expiresAt)) return;
|
||||||
window.__afVestSession = {
|
window.__afVestLogin = {
|
||||||
accessToken: body.accessToken,
|
accessToken: body.accessToken,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
primaryAddr: typeof body.primaryAddr === 'string' ? body.primaryAddr : '',
|
primaryAddr: typeof body.primaryAddr === 'string' ? body.primaryAddr : '',
|
||||||
userId: typeof body.userId === 'string' ? body.userId : '',
|
userId: typeof body.userId === 'string' ? body.userId : '',
|
||||||
|
refreshTokenId: typeof body.refreshTokenId === 'string' && body.refreshTokenId ? body.refreshTokenId : null,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+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;
|
const ACCOUNT_TOKEN_SKEW_MS = 90_000;
|
||||||
/** The relayed user token must have this long left to start a request. */
|
/** The relayed user token must have this long left to start a request. */
|
||||||
const USER_TOKEN_MIN_MS = 20_000;
|
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_POLL_MS = 1_000;
|
||||||
const FILL_TIMEOUT_MS = 30_000;
|
const FILL_TIMEOUT_MS = 30_000;
|
||||||
const PICK_TTL_MS = 45_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 seenOpenAt = new Map<number, { positionId: string; at: number; naggedAt: number }>();
|
||||||
private accountTokens = new Map<string, { token: string; expiresAt: number }>();
|
private accountTokens = new Map<string, { token: string; expiresAt: number }>();
|
||||||
private accountTokenFlights = new Map<string, Promise<string>>();
|
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 syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private watchdogInterval: 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 lastDaysFetchStartedAt = 0;
|
||||||
private lastDaysFetchSucceededAt = 0;
|
private lastDaysFetchSucceededAt = 0;
|
||||||
private nextDaysFetchRetryAt = 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 historyLoaded = false;
|
||||||
private historyLoadedIds = new Set<number>();
|
private historyLoadedIds = new Set<number>();
|
||||||
private booksHeld = 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();
|
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> {
|
private async accountToken(uuid: string): Promise<string> {
|
||||||
const cached = this.accountTokens.get(uuid);
|
const cached = this.accountTokens.get(uuid);
|
||||||
if (cached && cached.expiresAt - Date.now() > ACCOUNT_TOKEN_SKEW_MS) return cached.token;
|
if (cached && cached.expiresAt - Date.now() > ACCOUNT_TOKEN_SKEW_MS) return cached.token;
|
||||||
const flight = this.accountTokenFlights.get(uuid);
|
const flight = this.accountTokenFlights.get(uuid);
|
||||||
if (flight) return flight;
|
if (flight) return flight;
|
||||||
const p = (async () => {
|
const p = (async () => {
|
||||||
const { data } = await this.request<{ accessToken?: unknown; accessExpiresAtMs?: unknown }>('read', 'POST', '/v3/auth/account-token', {
|
let data: { accessToken?: unknown; accessExpiresAtMs?: unknown } | undefined;
|
||||||
body: { accountId: uuid },
|
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 token = typeof data?.accessToken === 'string' ? data.accessToken : '';
|
||||||
const expiresAt = num(data?.accessExpiresAtMs) ?? Date.now() + 10 * 60_000;
|
const expiresAt = num(data?.accessExpiresAtMs) ?? Date.now() + 10 * 60_000;
|
||||||
if (!token) throw new Error('/v3/auth/account-token answered without a token');
|
if (!token) throw new Error('/v3/auth/account-token answered without a token');
|
||||||
@@ -353,6 +377,55 @@ export class VestClient implements PlatformClient {
|
|||||||
return p;
|
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 ────────────────────────────────────────────────────────────
|
// ── transport ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async gate(bucket: Bucket): Promise<void> {
|
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
|
* 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
|
* 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
|
* 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>(
|
private async request<T>(
|
||||||
bucket: Bucket,
|
bucket: Bucket,
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
path: string,
|
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 }> {
|
): Promise<{ data: T }> {
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
if (opts.auth !== false) {
|
if (opts.auth !== false) {
|
||||||
@@ -421,9 +495,12 @@ export class VestClient implements PlatformClient {
|
|||||||
if (opts.account) {
|
if (opts.account) {
|
||||||
this.accountTokens.delete(opts.account);
|
this.accountTokens.delete(opts.account);
|
||||||
if (!opts.retried) return this.request<T>(bucket, method, path, { ...opts, retried: true });
|
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
|
// 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);
|
dropVestSession(this.wallet, token);
|
||||||
this.accountTokens.clear();
|
this.accountTokens.clear();
|
||||||
this.consecutiveAuthFailures++;
|
this.consecutiveAuthFailures++;
|
||||||
@@ -1349,8 +1426,12 @@ export class VestClient implements PlatformClient {
|
|||||||
const retryPending = this.nextDaysFetchRetryAt > 0;
|
const retryPending = this.nextDaysFetchRetryAt > 0;
|
||||||
const retryDue = retryPending && now >= this.nextDaysFetchRetryAt;
|
const retryDue = retryPending && now >= this.nextDaysFetchRetryAt;
|
||||||
const hourlyRefreshDue = !retryPending && this.lastDaysFetchSucceededAt > 0 && now - this.lastDaysFetchSucceededAt >= 60 * 60_000;
|
const hourlyRefreshDue = !retryPending && this.lastDaysFetchSucceededAt > 0 && now - this.lastDaysFetchSucceededAt >= 60 * 60_000;
|
||||||
const waiting = !initialFetchDue && !retryPending
|
// An active account with no history yet can't trade: it is read on
|
||||||
? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id) && !this.fetchingIds.has(a.id)).map((a) => a.id)
|
// 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;
|
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
|
||||||
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
|
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
|
||||||
@@ -1358,6 +1439,7 @@ export class VestClient implements PlatformClient {
|
|||||||
this.daysFetchInFlight = true;
|
this.daysFetchInFlight = true;
|
||||||
this.daysFetchInFlightSince = now;
|
this.daysFetchInFlightSince = now;
|
||||||
this.lastDaysFetchStartedAt = now;
|
this.lastDaysFetchStartedAt = now;
|
||||||
|
if (targeted) this.lastNewHistoryFetchAt = now;
|
||||||
if (retryDue) this.nextDaysFetchRetryAt = 0;
|
if (retryDue) this.nextDaysFetchRetryAt = 0;
|
||||||
|
|
||||||
this.fetchDaysTraded(targeted ? waiting : undefined)
|
this.fetchDaysTraded(targeted ? waiting : undefined)
|
||||||
@@ -1366,6 +1448,14 @@ export class VestClient implements PlatformClient {
|
|||||||
this.historyLoaded = true;
|
this.historyLoaded = true;
|
||||||
void this.requestSync();
|
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) {
|
if (failedAccounts > 0) {
|
||||||
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
||||||
console.warn(`${this.tag} ${failedAccounts} account(s) failed history fetch — retrying in 5 minutes`);
|
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();
|
if (!targeted) this.lastDaysFetchSucceededAt = Date.now();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
if (!targeted) this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
||||||
console.error(`${this.tag} history fetch failed — retrying in 5 minutes: ${errorMessage(err)}`);
|
console.error(`${this.tag} history fetch failed — ${targeted ? 'reading the new account(s) again within a minute' : 'retrying in 5 minutes'}: ${errorMessage(err)}`);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.daysFetchInFlight = false;
|
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 }> {
|
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);
|
for (const id of ids) this.fetchingIds.add(id);
|
||||||
this.fetchDaysComplete = false;
|
this.fetchDaysComplete = false;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -1396,7 +1494,7 @@ export class VestClient implements PlatformClient {
|
|||||||
try {
|
try {
|
||||||
this.bookHistory(account, await this.fetchTxns(this.uuidById.get(id)!));
|
this.bookHistory(account, await this.fetchTxns(this.uuidById.get(id)!));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failed++;
|
if (account.active) failed++;
|
||||||
this.fallBackToCache(account, errorMessage(err));
|
this.fallBackToCache(account, errorMessage(err));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -1447,9 +1545,10 @@ export class VestClient implements PlatformClient {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const acc = { id: a.accountId, name: a.accountName, active: false };
|
const acc = { id: a.accountId, name: a.accountName, active: false };
|
||||||
// A closed account may get no token of its own: the
|
// A closed account gets no token of its own (Vest refuses
|
||||||
// login's reads it as the app's history tab does.
|
// the mint): the login's reads it as the app's history
|
||||||
await this.fetchTrades(acc).catch(() => this.fetchTrades(acc, true));
|
// 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`);
|
console.log(`${this.tag} ${a.accountName}: off Vest's active list — read its position history one last time`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`${this.tag} ${a.accountName}: off Vest's active list, and its position history read failed: ${errorMessage(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,
|
price?: number,
|
||||||
intent?: OrderIntent,
|
intent?: OrderIntent,
|
||||||
): Promise<Record<string, unknown>> {
|
): 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 { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol);
|
||||||
const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
||||||
let positions: VestPosition[];
|
let positions: VestPosition[];
|
||||||
@@ -2174,6 +2276,7 @@ export class VestClient implements PlatformClient {
|
|||||||
orderType: 'Market' | 'Limit',
|
orderType: 'Market' | 'Limit',
|
||||||
price?: number,
|
price?: number,
|
||||||
): Promise<{ orderId?: number }> {
|
): Promise<{ orderId?: number }> {
|
||||||
|
await this.readyForExit(accountId, orderType === 'Limit' && price !== undefined ? 'take-profit' : 'close');
|
||||||
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
||||||
const exitSide: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
const exitSide: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
||||||
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
||||||
@@ -2203,6 +2306,7 @@ export class VestClient implements PlatformClient {
|
|||||||
limitPrice: number,
|
limitPrice: number,
|
||||||
stopPrice: number,
|
stopPrice: number,
|
||||||
): Promise<{ orderId?: number }> {
|
): Promise<{ orderId?: number }> {
|
||||||
|
await this.readyForExit(accountId, 'exits');
|
||||||
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
||||||
const exitSide: 'long' | 'short' = exitAction === 'Buy' ? 'long' : 'short';
|
const exitSide: 'long' | 'short' = exitAction === 'Buy' ? 'long' : 'short';
|
||||||
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs"
|
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs scripts/check-extension-vest.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/signalr": "^10.0.11",
|
"@microsoft/signalr": "^10.0.11",
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Checks for the extension's Vest relay: the hook in the Vest tab
|
||||||
|
// (extension/vest-relay.js) and the choice of which tab's token the background
|
||||||
|
// relays (extension/background.js pageVestSession / pickVestSession).
|
||||||
|
//
|
||||||
|
// npm run check
|
||||||
|
//
|
||||||
|
// The hook runs in a sandbox standing in for the Vest page — its own XHR and
|
||||||
|
// fetch, answered here; the background's functions are lifted out of its
|
||||||
|
// source, since it is a service worker and not a module. Nothing reaches Vest
|
||||||
|
// or Chrome. The answers are shaped like the Vest app's own (index bundle,
|
||||||
|
// 2026-10-29): a renewal's carries refreshTokenId, a sign-in's the login's
|
||||||
|
// refreshToken too, an account's renewal a refreshToken in its request.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||||
|
const HOOK = read('extension/vest-relay.js');
|
||||||
|
const BACKGROUND = read('extension/background.js');
|
||||||
|
|
||||||
|
const GATEWAY = 'https://api-gateway.hz.vestmarkets.com';
|
||||||
|
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
||||||
|
const answer = (extra) => JSON.stringify({ accessToken: 'eyJ.login.sig', accessExpiresAtMs: 1_800_000_000_000, primaryAddr: WALLET, userId: 'u', ...extra });
|
||||||
|
|
||||||
|
/** A Vest page with the hook run in it (after `before`, a hook already
|
||||||
|
* there): `xhr(url, body, status, text)` and `fetch(...)` play one of the
|
||||||
|
* app's requests through it. */
|
||||||
|
function page(before = {}) {
|
||||||
|
class FakeXHR {
|
||||||
|
constructor() { this.listeners = []; this.responseType = ''; }
|
||||||
|
open(method, url) { this.url = url; }
|
||||||
|
send() {}
|
||||||
|
addEventListener(type, fn) { if (type === 'loadend') this.listeners.push(fn); }
|
||||||
|
}
|
||||||
|
const answers = [];
|
||||||
|
const sandbox = {
|
||||||
|
XMLHttpRequest: FakeXHR, URL,
|
||||||
|
fetch: async () => answers.shift(),
|
||||||
|
...before,
|
||||||
|
};
|
||||||
|
sandbox.window = sandbox;
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(HOOK, sandbox);
|
||||||
|
return {
|
||||||
|
sandbox,
|
||||||
|
run: () => vm.runInContext(HOOK, sandbox),
|
||||||
|
xhr(url, body, status, text) {
|
||||||
|
const x = new sandbox.XMLHttpRequest();
|
||||||
|
x.open('POST', url);
|
||||||
|
x.send(body);
|
||||||
|
x.status = status;
|
||||||
|
x.responseText = text;
|
||||||
|
for (const fn of x.listeners) fn();
|
||||||
|
},
|
||||||
|
async fetch(url, body, status, text) {
|
||||||
|
answers.push({ status, clone: () => ({ text: async () => text }) });
|
||||||
|
await sandbox.window.fetch(url, { method: 'POST', body });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the hook keeps the login's renewed token with its session id, and an account's never", async () => {
|
||||||
|
const p = page();
|
||||||
|
assert.equal(p.sandbox.__afVestHook, 2);
|
||||||
|
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r1', rememberMe: true }), 200, answer({ refreshTokenId: 'rt-1' }));
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-1');
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.primaryAddr, WALLET);
|
||||||
|
|
||||||
|
// An account's renewal, by its request, then by its answer alone.
|
||||||
|
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ refreshToken: 'acct-rt', requestId: 'r2' }), 200, answer({ accessToken: 'eyJ.acct.sig', refreshTokenId: 'rt-a' }));
|
||||||
|
await p.fetch(`${GATEWAY}/v3/auth/refresh`, undefined, 200, answer({ accessToken: 'eyJ.acct2.sig', refreshToken: 'acct-rt', refreshTokenId: 'rt-b' }));
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||||
|
|
||||||
|
// The login's next renewal, through fetch; a refused one leaves it be.
|
||||||
|
await p.fetch(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r3' }), 200, answer({ accessToken: 'eyJ.login2.sig', refreshTokenId: 'rt-2' }));
|
||||||
|
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r4' }), 401, answer({ accessToken: 'eyJ.no.sig', refreshTokenId: 'rt-x' }));
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login2.sig');
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a sign-in's token is taken, though its answer carries the login's refresh token", () => {
|
||||||
|
const p = page();
|
||||||
|
p.xhr(`${GATEWAY}/v3/auth/register`, JSON.stringify({ primaryAddr: WALLET }), 200, answer({ accessToken: 'eyJ.signin.sig', refreshToken: 'login-rt', refreshTokenId: 'rt-s' }));
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.signin.sig');
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the hook installs once, and beside a version-1 hook', () => {
|
||||||
|
const p = page({ __afVestHook: true, __afVestSession: { accessToken: 'eyJ.old.sig', expiresAt: 1 } });
|
||||||
|
assert.equal(p.sandbox.__afVestHook, 2);
|
||||||
|
const send = p.sandbox.XMLHttpRequest.prototype.send;
|
||||||
|
p.run();
|
||||||
|
assert.equal(p.sandbox.XMLHttpRequest.prototype.send, send);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The background's own functions, out of its source.
|
||||||
|
function lift(name) {
|
||||||
|
const m = BACKGROUND.match(new RegExp(`^function ${name}\\([\\s\\S]*?^}`, 'm'));
|
||||||
|
assert.ok(m, `${name} not found in background.js`);
|
||||||
|
return m[0];
|
||||||
|
}
|
||||||
|
const { isCurrentVestSession, pickVestSession } = new Function(`${lift('isCurrentVestSession')}\n${lift('pickVestSession')}\nreturn { isCurrentVestSession, pickVestSession };`)();
|
||||||
|
const pageVestSession = (window, stored) => new Function('window', 'localStorage', `${lift('pageVestSession')}\nreturn pageVestSession();`)(
|
||||||
|
window, { getItem: (key) => (key === 'vest-auth-session' ? stored : null) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const later = (min) => Date.now() + min * 60_000;
|
||||||
|
const session = (token, expiresAt, refreshTokenId) => ({ accessToken: token, expiresAt, primaryAddr: WALLET, userId: 'u', ...(refreshTokenId !== undefined ? { refreshTokenId } : {}) });
|
||||||
|
const record = (refreshTokenId, accessTokenExpiresAt) => JSON.stringify({ refreshTokenId, accessTokenExpiresAt, refreshTokenExpiresAt: later(43200), rememberMe: true });
|
||||||
|
|
||||||
|
test("a tab reports its live token (version 2's, else version 1's) and the app's current session", () => {
|
||||||
|
const expiresAt = later(12);
|
||||||
|
let out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', expiresAt, 'rt-2'), __afVestSession: session('eyJ.v1.sig', expiresAt) }, record('rt-2', expiresAt));
|
||||||
|
assert.equal(out.hooked, true);
|
||||||
|
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||||
|
assert.deepEqual(out.current, { refreshTokenId: 'rt-2', accessTokenExpiresAt: expiresAt });
|
||||||
|
|
||||||
|
// Hooked by version 1 only: its token, and the tab asks for version 2.
|
||||||
|
out = pageVestSession({ __afVestHook: true, __afVestSession: session('eyJ.v1.sig', later(9)) }, record('rt-3', later(14)));
|
||||||
|
assert.equal(out.hooked, false);
|
||||||
|
assert.equal(out.session.accessToken, 'eyJ.v1.sig');
|
||||||
|
|
||||||
|
// A 0.17.0 hook kept an account's renewal too: version 2's login token
|
||||||
|
// wins over it, though it expires sooner.
|
||||||
|
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', later(5), 'rt-1'), __afVestSession: session('eyJ.acct.sig', later(13)) }, null);
|
||||||
|
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||||
|
assert.equal(out.current, null);
|
||||||
|
|
||||||
|
// Nearly expired, or a record that doesn't read.
|
||||||
|
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', Date.now() + 20_000, 'rt-1') }, '{nope');
|
||||||
|
assert.equal(out.session, null);
|
||||||
|
assert.equal(out.current, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the token relayed is the one the current session holds, whichever tab has it — not the first tab's", () => {
|
||||||
|
// Tab 1 renewed first; tab 2 renewed after it, so the session is tab 2's.
|
||||||
|
const tab1 = { hooked: true, session: session('eyJ.tab1.sig', later(6), 'rt-1'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||||
|
const tab2 = { hooked: true, session: session('eyJ.tab2.sig', later(13), 'rt-2'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||||
|
assert.deepEqual(pickVestSession([tab1, tab2]), { session: tab2.session, current: true });
|
||||||
|
assert.equal(isCurrentVestSession(tab1.session, tab1.current), false);
|
||||||
|
|
||||||
|
// Tab 1 renews again: now its token is the current one.
|
||||||
|
const rt3 = { refreshTokenId: 'rt-3', accessTokenExpiresAt: later(15) };
|
||||||
|
const tab1b = { ...tab1, session: session('eyJ.tab1b.sig', later(15), 'rt-3'), current: rt3 };
|
||||||
|
assert.equal(pickVestSession([{ ...tab2, current: rt3 }, tab1b]).session.accessToken, 'eyJ.tab1b.sig');
|
||||||
|
|
||||||
|
// Version-1 hooks keep no id: matched by the expiry the app keeps with it.
|
||||||
|
const v1a = { hooked: false, session: session('eyJ.v1a.sig', later(4)), current: { refreshTokenId: 'rt-9', accessTokenExpiresAt: later(11) } };
|
||||||
|
const v1b = { hooked: false, session: session('eyJ.v1b.sig', v1a.current.accessTokenExpiresAt), current: v1a.current };
|
||||||
|
assert.deepEqual(pickVestSession([v1a, v1b]), { session: v1b.session, current: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with no tab holding the current session the newest is relayed, and said; with no record, unjudged', () => {
|
||||||
|
const current = { refreshTokenId: 'rt-5', accessTokenExpiresAt: later(14) };
|
||||||
|
const a = { hooked: true, session: session('eyJ.a.sig', later(3), 'rt-3'), current };
|
||||||
|
const b = { hooked: true, session: session('eyJ.b.sig', later(8), 'rt-4'), current };
|
||||||
|
assert.deepEqual(pickVestSession([a, b]), { session: b.session, current: false });
|
||||||
|
assert.deepEqual(pickVestSession([{ ...a, current: null }, { ...b, current: null }]), { session: b.session, current: null });
|
||||||
|
// A tab with no token yet still shows the record.
|
||||||
|
assert.equal(pickVestSession([{ hooked: true, session: null, current }, { ...b, current: null }]).current, false);
|
||||||
|
assert.equal(pickVestSession([{ hooked: true, session: null, current }]), null);
|
||||||
|
assert.equal(pickVestSession([]), null);
|
||||||
|
});
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
||||||
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
||||||
// trade beside an open of unknown outcome, and the client's day at 8 PM ET;
|
// trade beside an open of unknown outcome, and the client's day at 8 PM ET;
|
||||||
// the completed cycles a Vest day's claims end, named at each claim.
|
// the completed cycles a Vest day's claims end, named at each claim; a
|
||||||
|
// refused account-token mint, a new account's history read again within the
|
||||||
|
// minute, and exits that wait out a lapse in the session.
|
||||||
//
|
//
|
||||||
// npm run check
|
// npm run check
|
||||||
//
|
//
|
||||||
@@ -46,12 +48,17 @@ after(() => rmSync(directory, { recursive: true, force: true }));
|
|||||||
const { default: axios } = await import('../lib/http-client.ts');
|
const { default: axios } = await import('../lib/http-client.ts');
|
||||||
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
|
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
|
||||||
const { VestClient } = await import('../lib/vest-class.ts');
|
const { VestClient } = await import('../lib/vest-class.ts');
|
||||||
|
// The client's own transport, for the checks of what it does with an answer.
|
||||||
|
const realRequest = VestClient.prototype.request;
|
||||||
let gateway = null;
|
let gateway = null;
|
||||||
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
||||||
|
// Calls on an account's token reach the gateway here without one, so its
|
||||||
|
// mint is answered here too.
|
||||||
|
if (opts.mint) return { data: { accessToken: `eyJ.${opts.body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 } };
|
||||||
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||||
return gateway(method, path, opts);
|
return gateway(method, path, opts);
|
||||||
};
|
};
|
||||||
const { putVestSession } = await import('../lib/vest-session.ts');
|
const { putVestSession, vestSessionFor, dropVestSession } = await import('../lib/vest-session.ts');
|
||||||
const {
|
const {
|
||||||
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
|
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
|
||||||
} = await import('../lib/db.ts');
|
} = await import('../lib/db.ts');
|
||||||
@@ -529,3 +536,139 @@ test('each claim of a Vest day names the completed cycle it ends, the second and
|
|||||||
client.disconnect();
|
client.disconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a refused account-token mint fails only that account: the login's session stays, and the next account's reads go out", async () => {
|
||||||
|
clockAt('2026-10-29T05:41:00Z');
|
||||||
|
const client = testClient(unanswered, [A, B]);
|
||||||
|
await client.syncRunning;
|
||||||
|
// The client's own transport, on a gateway that refuses acc-1's mint as
|
||||||
|
// Vest did all night on 2026-10-28 (401, code 1002).
|
||||||
|
client.request = realRequest;
|
||||||
|
const adapter = axios.defaults.adapter;
|
||||||
|
const sent = [];
|
||||||
|
axios.defaults.adapter = async (config) => {
|
||||||
|
const path = config.url.replace(/^https:\/\/[^/]+/, '');
|
||||||
|
const body = typeof config.data === 'string' ? JSON.parse(config.data) : undefined;
|
||||||
|
sent.push(`${config.method.toUpperCase()} ${path}${body?.accountId ? ` ${body.accountId}` : ''}`);
|
||||||
|
const reply = (status, data) => ({ status, statusText: '', headers: {}, config, data });
|
||||||
|
if (path === '/v3/auth/account-token') {
|
||||||
|
return body.accountId === 'acc-1'
|
||||||
|
? reply(401, { detail: { code: 1002, msg: 'Unauthorized' } })
|
||||||
|
: reply(200, { accessToken: `eyJ.${body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 });
|
||||||
|
}
|
||||||
|
if (path === '/v3/transactions') return reply(200, { transactions: [] });
|
||||||
|
if (path === '/v3/positions') return reply(200, { positions: [] });
|
||||||
|
return reply(404, {});
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { failedAccounts } = await client.fetchDaysTraded([A, B]);
|
||||||
|
assert.equal(failedAccounts, 1);
|
||||||
|
assert.notEqual(vestSessionFor(WALLET), null);
|
||||||
|
assert.equal(client.historyLoadedIds.has(A), false);
|
||||||
|
assert.equal(client.historyLoadedIds.has(B), true);
|
||||||
|
assert.match(client.lastFetchErrors['V50K-F-D3-1'], /code 1002/);
|
||||||
|
assert.ok(sent.includes('POST /v3/auth/account-token acc-2'));
|
||||||
|
assert.ok(sent.includes('GET /v3/transactions'));
|
||||||
|
} finally {
|
||||||
|
axios.defaults.adapter = adapter;
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an active account with no history is read again within a minute, while a whole pass waits out its retry', async () => {
|
||||||
|
clockAt('2026-10-29T05:41:00Z');
|
||||||
|
const C = vestAccountId('acc-3');
|
||||||
|
const client = testClient(unanswered, [A, B, C]);
|
||||||
|
await client.syncRunning;
|
||||||
|
// acc-3 isn't active: it waits for a whole pass.
|
||||||
|
client.capital.set(C, { status: 3 });
|
||||||
|
client.accountList[2].active = false;
|
||||||
|
client.historyLoaded = true;
|
||||||
|
client.historyLoadedIds = new Set([A]);
|
||||||
|
client.lastDaysFetchStartedAt = Date.now() - 120_000;
|
||||||
|
// A whole pass failed: its retry is four minutes off.
|
||||||
|
client.nextDaysFetchRetryAt = Date.now() + 4 * 60_000;
|
||||||
|
const reads = [];
|
||||||
|
client.fetchDaysTraded = async (ids) => {
|
||||||
|
reads.push(ids);
|
||||||
|
return { failedAccounts: ids ? ids.length : 0 };
|
||||||
|
};
|
||||||
|
const settled = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
try {
|
||||||
|
client.maybeFetchDaysTraded();
|
||||||
|
await settled();
|
||||||
|
assert.deepEqual(reads, [[B]]);
|
||||||
|
// It failed again: not before the minute is up, and the whole pass's
|
||||||
|
// retry stays where it was.
|
||||||
|
client.maybeFetchDaysTraded();
|
||||||
|
await settled();
|
||||||
|
assert.deepEqual(reads, [[B]]);
|
||||||
|
clockAt('2026-10-29T05:42:01Z');
|
||||||
|
client.maybeFetchDaysTraded();
|
||||||
|
await settled();
|
||||||
|
assert.deepEqual(reads, [[B], [B]]);
|
||||||
|
clockAt('2026-10-29T05:45:01Z');
|
||||||
|
client.maybeFetchDaysTraded();
|
||||||
|
await settled();
|
||||||
|
assert.deepEqual(reads, [[B], [B], undefined]);
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a pass reads the accounts with no history first and the inactive last, and an inactive one's failure isn't the pass's", async () => {
|
||||||
|
clockAt('2026-10-29T05:41:00Z');
|
||||||
|
const C = vestAccountId('acc-3');
|
||||||
|
const client = testClient(unanswered, [A, B, C]);
|
||||||
|
await client.syncRunning;
|
||||||
|
client.capital.set(C, { status: 3 });
|
||||||
|
client.accountList[2].active = false;
|
||||||
|
client.historyLoadedIds = new Set([A]);
|
||||||
|
const order = [];
|
||||||
|
client.fetchTxns = async (uuid) => {
|
||||||
|
order.push(uuid);
|
||||||
|
if (uuid === 'acc-3') throw new Error('/v3/transactions not sent — no account token: Unauthorized (HTTP 401, code 1002)');
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
client.fetchTrades = async () => {};
|
||||||
|
try {
|
||||||
|
const { failedAccounts } = await client.fetchDaysTraded([A, B, C]);
|
||||||
|
assert.deepEqual(order, ['acc-2', 'acc-1', 'acc-3']);
|
||||||
|
assert.equal(failedAccounts, 0);
|
||||||
|
assert.match(client.lastFetchErrors['V50K-F-D3-3'], /code 1002/);
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exits wait for the login session to be relayed again; an open goes now or not at all', async () => {
|
||||||
|
clockAt('2026-10-29T06:00:00Z');
|
||||||
|
const legs = [];
|
||||||
|
const client = testClient((method, path, opts) => {
|
||||||
|
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions: [rawPosition('9101', 'acc-1', iso('2026-10-29T05:59:00Z'))] } };
|
||||||
|
if (method === 'POST' && (path === '/v3/positions/stop-loss' || path === '/v3/positions/take-profit')) {
|
||||||
|
legs.push(`${path.slice('/v3/positions/'.length)} @ ${opts.body.triggerPrice}`);
|
||||||
|
return { data: {} };
|
||||||
|
}
|
||||||
|
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||||
|
}, [A, B]);
|
||||||
|
try {
|
||||||
|
// The session lapses (Vest refused it) just after an entry filled.
|
||||||
|
dropVestSession(WALLET, vestSessionFor(WALLET).accessToken);
|
||||||
|
assert.equal(vestSessionFor(WALLET), null);
|
||||||
|
const opening = await client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
|
||||||
|
assert.equal(isOrderNotPlaced(opening), true);
|
||||||
|
assert.match(opening.message, /no Vest session/);
|
||||||
|
|
||||||
|
const exits = client.placeOcoExit(A, NDX, 500, 'Sell', 20100, 19900);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||||
|
assert.deepEqual(legs, []);
|
||||||
|
// The extension relays it again: the exits go, the stop first.
|
||||||
|
tokens += 1;
|
||||||
|
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
||||||
|
await exits;
|
||||||
|
assert.deepEqual(legs, ['stop-loss @ 19900', 'take-profit @ 20100']);
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user