diff --git a/extension/README.md b/extension/README.md index ba55963..2540b7d 100644 --- a/extension/README.md +++ b/extension/README.md @@ -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 open. The tab is marked not auto-discardable, since a discarded tab stops 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) 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 diff --git a/extension/background.js b/extension/background.js index d446601..64c41a6 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1204,6 +1204,13 @@ async function tick() { // 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 // 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_URL = 'https://next.vestmarkets.com/portfolio'; @@ -1220,10 +1227,47 @@ let vestLastPostAt = 0; let vestOpenedAt = 0; /** 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() { - const s = window.__afVestSession; - return { hooked: window.__afVestHook === true, session: s && s.expiresAt > Date.now() + 30000 ? s : null }; + const live = (s) => (s && typeof s.accessToken === 'string' && 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. */ @@ -1259,7 +1303,8 @@ async function relayVestSession(cfg) { return; } - let found = null; + // Every tab: the current session may be in any of them. + const pages = []; for (const tab of tabs) { // A discarded tab stops renewing the session; keep Chrome from it. if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {}); @@ -1272,12 +1317,14 @@ async function relayVestSession(cfg) { // next renewal is caught. 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)' }); return; } + const found = picked.session; const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`; 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 note = Array.isArray(body.bound) && body.bound.length > 0 ? ` · bound to ${body.bound.join(', ')}` : 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 ─────── diff --git a/extension/manifest.json b/extension/manifest.json index 562c8d3..982d7df 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "AutoFirmer Capture", - "version": "0.17.2", + "version": "0.18.0", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB", "update_url": "https://ext.juicerroom.com/updates.xml", diff --git a/extension/vest-relay.js b/extension/vest-relay.js index 5d1d7f1..23930a5 100644 --- a/extension/vest-relay.js +++ b/extension/vest-relay.js @@ -6,10 +6,16 @@ * * 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 - * sign-in). Those answers carry the token, its expiry and the login's wallet. - * This hook reads them off the app's own XHRs and keeps the latest on - * `window.__afVestSession`, where background.js collects it and relays it to - * the local dashboard, whose Vest client trades with it. + * sign-in). Those answers carry the token, its expiry, the login's wallet and + * the session it belongs to (refreshTokenId). This hook reads them off the + * app's own XHRs and keeps the latest on `window.__afVestLogin`, where + * 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 * 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 * 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 - * 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; - window.__afVestHook = true; + if (window.__afVestHook === 2) return; + window.__afVestHook = 2; 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. */ function accountRenewal(reqBody) { @@ -38,14 +49,16 @@ let body; try { body = JSON.parse(text); } catch { 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); if (!Number.isFinite(expiresAt)) return; - window.__afVestSession = { + window.__afVestLogin = { accessToken: body.accessToken, expiresAt, primaryAddr: typeof body.primaryAddr === 'string' ? body.primaryAddr : '', userId: typeof body.userId === 'string' ? body.userId : '', + refreshTokenId: typeof body.refreshTokenId === 'string' && body.refreshTokenId ? body.refreshTokenId : null, at: Date.now(), }; } diff --git a/lib/vest-class.ts b/lib/vest-class.ts index 83d4d8e..c301737 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -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(); private accountTokens = new Map(); private accountTokenFlights = new Map>(); + /** The account-token refusal last logged, and when. */ + private mintRefusedNote: { msg: string; at: number } | null = null; private syncInterval: ReturnType | null = null; private watchdogInterval: ReturnType | 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(); private booksHeld = new Set(); @@ -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 { 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 { + 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 { @@ -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( bucket: Bucket, method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, - opts: { params?: Record; body?: unknown; account?: string; auth?: boolean; retried?: boolean } = {}, + opts: { params?: Record; 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(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> { + // 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); diff --git a/package.json b/package.json index 6b3f8bd..61ec044 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "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": { "@microsoft/signalr": "^10.0.11", diff --git a/scripts/check-extension-vest.mjs b/scripts/check-extension-vest.mjs new file mode 100644 index 0000000..0d3df98 --- /dev/null +++ b/scripts/check-extension-vest.mjs @@ -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); +}); diff --git a/scripts/check-vest-client.mjs b/scripts/check-vest-client.mjs index c50848e..27629ed 100644 --- a/scripts/check-vest-client.mjs +++ b/scripts/check-vest-client.mjs @@ -3,7 +3,9 @@ // 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 // 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 // @@ -46,12 +48,17 @@ after(() => rmSync(directory, { recursive: true, force: true })); 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}`)); 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; 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}`); return gateway(method, path, opts); }; -const { putVestSession } = await import('../lib/vest-session.ts'); +const { putVestSession, vestSessionFor, dropVestSession } = await import('../lib/vest-session.ts'); const { botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles, } = 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(); } }); + +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(); + } +});