Compare commits

..
2 Commits
Author SHA1 Message Date
Brandon LiandClaude Opus 5.5 8a5b2a64e7 Vest: the bot activates a passed evaluation's funded account, and a passed one no longer holds the sync
A passed Vest evaluation sits at GOAL_PASSED (status 3) until someone presses
"Go to funded account", which sends POST /v3/capital/activate-funded
{eval_account_id} on the evaluation's own token (read from the Vest app's
bundle, 2026-10-29); Vest then opens the funded account and marks the
evaluation CLAIMED (6). The 5 evaluations that passed on 2026-10-29 waited
for a hand activation.

runActivations now asks for it, the way runClaims claims: from the
scheduler's tick (not awaited, so a slow ask doesn't hold the lane's entry)
and from the claim watcher while the scheduler is off, for evaluations with
an account type only, 15 minutes apart after a failure. Off with the
auto_activate setting at 'off' (dashboard: "Auto-activate funded"). The
client asks for an evaluation once at a time, never again after Vest
answered, and after a timeout not until a list read a minute on says either
way; a refusal frees it at once. The rule is the Vest app's own: an
evaluation at GOAL_PASSED, not blocked, on its plan's last step.

Vest's active balance list leaves a passed evaluation out, and a missing
balance made every sync short of good (15:31-15:34Z), which holds and then
quarantines the lane. Only a live account's missing balance does now. Log
lines name Vest's statuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:09:49 -05:00
Brandon LiandClaude Opus 5.5 0eb56b3949 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>
2026-10-29 10:55:01 -05:00
14 changed files with 909 additions and 55 deletions
+8 -5
View File
@@ -3,7 +3,7 @@ import { getSetting, setSetting } from '@/lib/db';
import { scheduleBackupPush } from '@/lib/backup-sync'; import { scheduleBackupPush } from '@/lib/backup-sync';
import { getRequestUserAgent } from '@/lib/request-user-agent'; import { getRequestUserAgent } from '@/lib/request-user-agent';
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim'] as const; const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim', 'auto_activate'] as const;
type SettingKey = typeof VALID_KEYS[number]; type SettingKey = typeof VALID_KEYS[number];
export async function GET() { export async function GET() {
@@ -25,10 +25,13 @@ export async function PATCH(req: NextRequest) {
try { try {
const body = await req.json() as Partial<Record<SettingKey, string | number>>; const body = await req.json() as Partial<Record<SettingKey, string | number>>;
// Claims run unless it reads exactly 'off' — say so exactly, and // Claims and activations run unless it reads exactly 'off' — say so
// before anything is written. // exactly, and before anything is written.
if (body.auto_claim !== undefined && body.auto_claim !== null && body.auto_claim !== 'on' && body.auto_claim !== 'off') { for (const key of ['auto_claim', 'auto_activate'] as const) {
return NextResponse.json({ error: "auto_claim is 'on' or 'off'" }, { status: 400 }); const v = body[key];
if (v !== undefined && v !== null && v !== 'on' && v !== 'off') {
return NextResponse.json({ error: `${key} is 'on' or 'off'` }, { status: 400 });
}
} }
for (const key of VALID_KEYS) { for (const key of VALID_KEYS) {
+25 -1
View File
@@ -744,6 +744,7 @@ export default function Home() {
const [tradeLoading, setTradeLoading] = useState(false); const [tradeLoading, setTradeLoading] = useState(false);
const [stopAfterAll, setStopAfterAll] = useState(false); const [stopAfterAll, setStopAfterAll] = useState(false);
const [autoClaim, setAutoClaim] = useState(true); const [autoClaim, setAutoClaim] = useState(true);
const [autoActivate, setAutoActivate] = useState(true);
const handleSort = (col: SortKey) => { const handleSort = (col: SortKey) => {
if (sortKey === col) { if (sortKey === col) {
@@ -813,9 +814,10 @@ export default function Home() {
fetch('/api/settings') fetch('/api/settings')
.then((r) => r.json()) .then((r) => r.json())
.then((s: { tick_interval_seconds?: string | null; auto_claim?: string | null }) => { .then((s: { tick_interval_seconds?: string | null; auto_claim?: string | null; auto_activate?: string | null }) => {
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds); if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
setAutoClaim(s.auto_claim !== 'off'); setAutoClaim(s.auto_claim !== 'off');
setAutoActivate(s.auto_activate !== 'off');
}) })
.catch(() => {}); .catch(() => {});
@@ -1024,6 +1026,28 @@ export default function Home() {
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-claim payouts</span> <span className="text-xs text-slate-400 whitespace-nowrap">Auto-claim payouts</span>
</label> </label>
)} )}
{config.some((f) => f.platform === 'vest') && (
<label
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
title="Activate a passed evaluation's funded account as soon as it passes (Vest) — whether or not the scheduler is running"
>
<input
type="checkbox"
checked={autoActivate}
onChange={(e) => {
const on = e.target.checked;
setAutoActivate(on);
fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ auto_activate: on ? 'on' : 'off' }),
}).then((r) => { if (!r.ok) setAutoActivate(!on); }).catch(() => setAutoActivate(!on));
}}
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
/>
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-activate funded</span>
</label>
)}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{hasAnyPosition && ( {hasAnyPosition && (
<button <button
+12
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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(),
}; };
} }
+60 -5
View File
@@ -72,6 +72,11 @@ interface TradeRuntime {
sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number }; sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number };
/** Account id → when its last payout claim failed (runClaims backoff). */ /** Account id → when its last payout claim failed (runClaims backoff). */
claimFailedAt?: Map<number, number>; claimFailedAt?: Map<number, number>;
/** Account id → when its last funded-account activation failed
* (runActivations backoff). */
activateFailedAt?: Map<number, number>;
/** Lanes whose runActivations is out (one at a time per lane). */
activationsRunning?: Set<string>;
/** Accounts whose exit the broker refused — a bracket while a hard daily /** Accounts whose exit the broker refused — a bracket while a hard daily
* loss limit was on, or a lone take-profit: the position was flattened * loss limit was on, or a lone take-profit: the position was flattened
* and the account sits out the trade date rather than bleed a round trip * and the account sits out the trade date rather than bleed a round trip
@@ -2038,6 +2043,49 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
} }
} }
/** How long a failed activation waits before it's tried again — a refusal
* (Vest's cap on funded accounts, say) holds for a while. */
const ACTIVATE_RETRY_MS = 15 * 60_000;
/**
* Activate the funded account of every passed evaluation in the lane that
* the bot trades (one with an account type, as runClaims goes by — Vest caps
* funded accounts, and one kept by hand isn't the bot's to spend a slot on),
* where the platform does that by API (Vest: a passed evaluation waits until
* it's asked). It opens and closes nothing — the evaluation no longer
* trades, and the funded account shows on the next sync and trades from
* there — so it takes no lane mutex, and the scheduler doesn't wait on it.
* One run per lane at a time. Run by the scheduler's tick, or by the claim
* watcher while the scheduler is off. Off with the auto_activate setting at
* 'off'.
*/
async function runActivations(lane: string, ready: FirmClient[]): Promise<void> {
if (getSetting('auto_activate') === 'off') return;
const running = (runtime().activationsRunning ??= new Set());
if (running.has(lane)) return;
running.add(lane);
try {
for (const { firm, client } of inLane(ready, lane)) {
if (typeof client.activatable !== 'function' || typeof client.activateFunded !== 'function') continue;
for (const id of client.activatable()) {
const acc = client.accountList.find((a) => a.id === id);
if (!acc || !getAccountConfig(id).accountType) continue;
if (Date.now() - (runtime().activateFailedAt?.get(id) ?? 0) < ACTIVATE_RETRY_MS) continue;
try {
const { funded } = await client.activateFunded(id);
runtime().activateFailedAt?.delete(id);
console.log(`[auto-activate] ${firm.name} ${acc.name}: passed — funded account ${funded ?? '(listed on the next sync)'} activated`);
} catch (err) {
(runtime().activateFailedAt ??= new Map()).set(id, Date.now());
console.error(`[auto-activate] ${firm.name} ${acc.name}: activation failed — ${errorMessage(err)}; retrying in ${ACTIVATE_RETRY_MS / 60_000}m`);
}
}
}
} finally {
running.delete(lane);
}
}
/** How often auto-claims run while the scheduler is off — its tick's pace. */ /** How often auto-claims run while the scheduler is off — its tick's pace. */
const CLAIM_WATCH_MS = 60_000; const CLAIM_WATCH_MS = 60_000;
@@ -2049,8 +2097,9 @@ const _claimWatch = globalThis as typeof globalThis & { __claimWatcher?: ReturnT
* at its target while the bot is off is claimed all the same, before * at its target while the bot is off is claimed all the same, before
* whatever trades it next can take the profit back. While the scheduler * whatever trades it next can take the profit back. While the scheduler
* runs, its own tick claims (first thing in each lane), and this stays out * runs, its own tick claims (first thing in each lane), and this stays out
* of the way. Off with the auto_claim setting at 'off'. Started once, with * of the way. Off with the auto_claim setting at 'off'. Activations of passed
* the clients. * evaluations' funded accounts ride along the same way (auto_activate).
* Started once, with the clients.
*/ */
export function startClaimWatcher(): void { export function startClaimWatcher(): void {
if (_claimWatch.__claimWatcher) return; if (_claimWatch.__claimWatcher) return;
@@ -2058,15 +2107,18 @@ export function startClaimWatcher(): void {
} }
async function claimTick(): Promise<void> { async function claimTick(): Promise<void> {
if (getState().running || getSetting('auto_claim') === 'off' || _claimWatch.__claimTickBusy) return; if (getState().running || _claimWatch.__claimTickBusy) return;
if (getSetting('auto_claim') === 'off' && getSetting('auto_activate') === 'off') return;
_claimWatch.__claimTickBusy = true; _claimWatch.__claimTickBusy = true;
try { try {
// Ready clients only, as the tick: a stale snapshot may hide a // Ready clients only, as the tick: a stale snapshot may hide a
// position. runClaims takes each lane's mutex itself. // position. runClaims takes each lane's mutex itself; each step
// checks its own setting.
const { ready } = firmClients(); const { ready } = firmClients();
for (const lane of tradableLanes()) { for (const lane of tradableLanes()) {
if (getState().running) return; // the scheduler took over if (getState().running) return; // the scheduler took over
await runClaims(lane, ready); await runClaims(lane, ready);
await runActivations(lane, ready);
} }
} catch (err) { } catch (err) {
console.error(`[auto-claim] ${errorMessage(err)}`); console.error(`[auto-claim] ${errorMessage(err)}`);
@@ -2343,8 +2395,11 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
continue; continue;
} }
// Payouts first, account by account — a claim doesn't wait // Payouts first, account by account — a claim doesn't wait
// for the rest of the lane to go flat. // for the rest of the lane to go flat. Nor does a passed
// evaluation's funded account, and the batch doesn't wait
// on it: a slow one would hold back the lane's entry.
await runClaims(lane, ready); await runClaims(lane, ready);
void runActivations(lane, ready).catch((err) => console.error(`[auto-activate] ${laneName(lane)}: ${errorMessage(err)}`));
// Skip this lane if any of its accounts still has an open // Skip this lane if any of its accounts still has an open
// position from its previous batch — a batch fires only // position from its previous batch — a batch fires only
// when every (ready) account in the lane is flat. // when every (ready) account in the lane is flat.
+8
View File
@@ -175,6 +175,14 @@ export interface PlatformClient {
/** A claim this client made that no good sync has read past yet: the /** A claim this client made that no good sync has read past yet: the
* balance on hand may not show it, so no claim may be judged on it. */ * balance on hand may not show it, so no claim may be judged on it. */
claimPending?(accountId: number): boolean; claimPending?(accountId: number): boolean;
/** Passed evaluations whose funded account the platform activates by
* API (Vest's "Go to funded account"), by account id — less any this
* client already asked for that no sync has read back yet. */
activatable?(): number[];
/** Activate the funded account of a passed evaluation. Resolves once the
* platform opened it, with its name when the answer gives one (the next
* sync lists it); rejects with the platform's reason otherwise. */
activateFunded?(accountId: number): Promise<{ funded: string | null }>;
/** Drop the account's history from memory (the wipe routes), so it reads /** Drop the account's history from memory (the wipe routes), so it reads
* unloaded until the next fetch rebuilds it. */ * unloaded until the next fetch rebuilds it. */
forgetHistory(accountId: number, accountName: string): void; forgetHistory(accountId: number, accountName: string): void;
+215 -19
View File
@@ -54,6 +54,7 @@ import {
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop, parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop,
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION, parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION,
vestStatusName, awaitingActivation,
VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince,
} from './vest-helpers'; } from './vest-helpers';
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade, OwnClaim } from './vest-helpers'; import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade, OwnClaim } from './vest-helpers';
@@ -74,8 +75,22 @@ type Bucket = keyof typeof SPACING_MS;
const TIMEOUT_MS = { read: 20_000, trade: 15_000, history: 30_000 } as const; const TIMEOUT_MS = { read: 20_000, trade: 15_000, history: 30_000 } as const;
/** A cached account token is renewed this long before it expires. */ /** A cached account token is renewed this long before it expires. */
const ACCOUNT_TOKEN_SKEW_MS = 90_000; const ACCOUNT_TOKEN_SKEW_MS = 90_000;
/** An activation whose answer never came may still land at Vest: only a
* capital list read from this long after it gave up is taken as Vest's
* word on it. */
const ACTIVATION_UNKNOWN_HOLD_MS = 60_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 +253,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 +275,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>();
@@ -294,6 +314,16 @@ export class VestClient implements PlatformClient {
private lastSurveyAt = 0; private lastSurveyAt = 0;
private unlistedLoggedAt = 0; private unlistedLoggedAt = 0;
private unknownStatusLogged = new Set<string>(); private unknownStatusLogged = new Set<string>();
/** Passed evaluations whose activation is out right now. */
private activationsOut = new Set<number>();
/** Passed evaluations Vest answered an activation for: never asked
* again, even while Vest's list is slow to stop showing them. */
private activated = new Set<number>();
/** Account id → when an activation of unknown outcome gave up; held
* until a list read ACTIVATION_UNKNOWN_HOLD_MS on says either way. */
private activationUnknownAt = new Map<number, number>();
/** When the sync whose capital list this client holds started. */
private capitalReadStartedAt = 0;
constructor(cfg: VestConfig, onSync: () => Promise<void>) { constructor(cfg: VestConfig, onSync: () => Promise<void>) {
this.cfg = cfg; this.cfg = cfg;
@@ -333,16 +363,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 +392,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 +458,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 +510,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++;
@@ -607,7 +699,8 @@ export class VestClient implements PlatformClient {
this.restorePendingOpen(id, vestAccountName(a)); this.restorePendingOpen(id, vestAccountName(a));
if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) { if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) {
this.unknownStatusLogged.add(`${a.id}:${a.status}`); this.unknownStatusLogged.add(`${a.id}:${a.status}`);
console.warn(`${this.tag} ${vestAccountName(a)} has status ${a.status} — not trading it until that status is known`); if (awaitingActivation(a)) console.log(`${this.tag} ${vestAccountName(a)} passed — its funded account waits on activation`);
else console.warn(`${this.tag} ${vestAccountName(a)} is ${vestStatusName(a.status)} — not trading it`);
} }
const acc: PlatformAccount = { id, name: vestAccountName(a), active: a.status === VEST_STATUS_ACTIVE }; const acc: PlatformAccount = { id, name: vestAccountName(a), active: a.status === VEST_STATUS_ACTIVE };
list.push(acc); list.push(acc);
@@ -626,7 +719,14 @@ export class VestClient implements PlatformClient {
this.adoptUnknownOpen(id, mine, startedAt); this.adoptUnknownOpen(id, mine, startedAt);
const amount = free.get(a.id); const amount = free.get(a.id);
if (amount === undefined) { complete = false; continue; } // Only a live account's balance is owed: Vest's active list
// leaves out a passed evaluation's (seen 2026-10-29), and
// waiting on it kept every sync short of good — the lane held,
// and nothing ran that would activate it.
if (amount === undefined) {
if (a.status === VEST_STATUS_ACTIVE) complete = false;
continue;
}
const balance = amount + mine.reduce((s, p) => s + p.collateral, 0); const balance = amount + mine.reduce((s, p) => s + p.collateral, 0);
this.recordOpenTrades(acc, mine); this.recordOpenTrades(acc, mine);
if (mine.length === 0) { if (mine.length === 0) {
@@ -655,6 +755,7 @@ export class VestClient implements PlatformClient {
this.numbersAt = now; this.numbersAt = now;
const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id)); const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id));
this.accountList = list; this.accountList = list;
this.capitalReadStartedAt = startedAt;
if (shrank) void this.closeOutDropped(); if (shrank) void this.closeOutDropped();
// An account gone from Vest's list (breached, closed) takes its // An account gone from Vest's list (breached, closed) takes its
// position with it — a stale one here would hold the lane. // position with it — a stale one here would hold the lane.
@@ -971,6 +1072,74 @@ export class VestClient implements PlatformClient {
return { claimed, received }; return { claimed, received };
} }
/** See PlatformClient: the passed evaluations on Vest's list whose funded
* account waits on activation, less any this client's asks hold. */
public activatable(): number[] {
return this.accountList.flatMap((acc) => {
const meta = this.capital.get(acc.id);
return meta && awaitingActivation(meta) && !this.activationHeld(acc.id) ? [acc.id] : [];
});
}
/** An ask of this client's settles the evaluation until Vest's list can
* speak for it: one out now, one Vest answered (for good), or one whose
* answer never came, until a list read a while after it gave up. The
* list a sync reads while an ask is out predates Vest's answer. */
private activationHeld(accountId: number): boolean {
if (this.activationsOut.has(accountId) || this.activated.has(accountId)) return true;
const unknownAt = this.activationUnknownAt.get(accountId);
return unknownAt !== undefined && this.capitalReadStartedAt < unknownAt + ACTIVATION_UNKNOWN_HOLD_MS;
}
/**
* See PlatformClient. Vest's "Go to funded account": POST
* /v3/capital/activate-funded with the evaluation's own token, as the Vest
* app sends it. Vest answers with the funded account it opened, and marks
* the evaluation CLAIMED. Nothing to pay and no terms to accept; Vest
* refuses one past its cap on funded accounts.
*
* One ask at a time per evaluation, and none after Vest answered one. One
* that timed out may have gone in: none until a list read a minute on
* says either way. A refusal frees it at once.
*/
public async activateFunded(accountId: number): Promise<{ funded: string | null }> {
const uuid = this.uuidById.get(accountId);
const account = this.accountList.find((a) => a.id === accountId);
const meta = this.capital.get(accountId);
if (!uuid || !account || !meta) throw new Error('account not read yet');
if (!awaitingActivation(meta)) {
throw new Error(`Vest shows ${account.name} as ${meta.kind || 'not an evaluation'}, ${vestStatusName(meta.status)} — only a passed evaluation's funded account is activated`);
}
if (this.activationHeld(accountId)) throw new Error("the last activation hasn't been read back from Vest yet");
// Taken before the first await: a second caller meanwhile is refused.
this.activationsOut.add(accountId);
let data: { account?: unknown } | undefined;
try {
({ data } = await this.request<{ account?: unknown }>('trade', 'POST', '/v3/capital/activate-funded', {
account: uuid,
body: { eval_account_id: uuid },
}));
} catch (err) {
if (!nothingWentIn(err)) {
this.activationUnknownAt.set(accountId, Date.now());
console.error(`${this.tag} ${account.name}: activation — outcome unknown (${errorMessage(err)}); not asked again until Vest's list says either way`);
void this.syncRunning.then(() => this.requestSync());
}
throw err;
} finally {
this.activationsOut.delete(accountId);
}
this.activationUnknownAt.delete(accountId);
this.activated.add(accountId);
const opened = parseCapitalAccount(data?.account);
const funded = opened ? vestAccountName(opened) : null;
console.log(`${this.tag} ${account.name}: activated its funded account${funded ? ` ${funded}` : ''}`);
// A fresh sync lists it — after any one already out, whose reads predate this.
void this.syncRunning.then(() => this.requestSync());
return { funded };
}
/** /**
* See PlatformClient: a claim of this client's that Vest's transactions * See PlatformClient: a claim of this client's that Vest's transactions
* don't show yet — until they do, the balance on hand can't be trusted * don't show yet — until they do, the balance on hand can't be trusted
@@ -1349,8 +1518,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 +1531,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 +1540,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 +1556,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 +1565,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 +1586,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 +1637,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 +2122,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 +2368,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 +2398,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);
+51 -4
View File
@@ -211,7 +211,15 @@ export interface VestCapitalAccount {
productType: string; productType: string;
/** 1-based stage; stages[stage-1] is the current one. */ /** 1-based stage; stages[stage-1] is the current one. */
stage: number; stage: number;
/** Evaluation steps the plan has (a 1-Step's is 1, a 2-Step's 2). */
stepsTotal: number; stepsTotal: number;
/** The evaluation step this account is (1-based; a funded one's is
* stepsTotal + 1, as the Vest app counts it when Vest leaves it out). */
step: number;
/** Vest's account_type: 2 an evaluation, 3 a funded account. */
accountType: number;
/** Vest blocked the account (blocked_at set): nothing it offers applies. */
blocked: boolean;
/** The current stage: 'eval' or 'funded'. */ /** The current stage: 'eval' or 'funded'. */
kind: string; kind: string;
status: number; status: number;
@@ -236,13 +244,18 @@ export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : []; const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : [];
const stage = num(a.stage) ?? 1; const stage = num(a.stage) ?? 1;
const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {}; const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {};
// The Vest app's own fallbacks, for an answer that leaves these out.
const stepsTotal = num(a.steps_total) ?? (a.plan_product_type === 'eval' ? 1 : 0);
return { return {
id: a.id, id: a.id,
planId: typeof a.plan_id === 'string' ? a.plan_id : '', planId: typeof a.plan_id === 'string' ? a.plan_id : '',
planName: typeof a.plan_name === 'string' ? a.plan_name : '', planName: typeof a.plan_name === 'string' ? a.plan_name : '',
productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '', productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '',
stage, stage,
stepsTotal: num(a.steps_total) ?? 0, stepsTotal,
step: num(a.step) ?? (stage === VEST_STAGE_FUNDED ? stepsTotal + 1 : 1),
accountType: num(a.account_type) ?? 0,
blocked: Boolean(a.blocked_at),
kind: typeof current.kind === 'string' ? current.kind : '', kind: typeof current.kind === 'string' ? current.kind : '',
status: num(a.status) ?? -1, status: num(a.status) ?? -1,
attemptIndex: num(a.attempt_index) ?? 0, attemptIndex: num(a.attempt_index) ?? 0,
@@ -259,9 +272,43 @@ export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
}; };
} }
/** The capital account status every listed live account showed (2026-10-26). /** Vest's capital account statuses, as the Vest app names them (its bundle,
* Anything else is treated as unable to trade until seen and named. */ * 2026-10-29). Only an ACTIVE account trades. A passed evaluation sits at
export const VEST_STATUS_ACTIVE = 2; * GOAL_PASSED until its funded account is activated, and is CLAIMED after. */
export const VEST_STATUS = {
CREATED: 1,
ACTIVE: 2,
GOAL_PASSED: 3,
FAILED_MAX_DRAWDOWN: 4,
FAILED_MAX_DAILY_LOSS: 5,
CLAIMED: 6,
EXPIRED: 7,
} as const;
export const VEST_STATUS_ACTIVE = VEST_STATUS.ACTIVE;
/** Vest's stage field: an evaluation's, then the funded account's. */
const VEST_STAGE_EVAL = 1;
const VEST_STAGE_FUNDED = 2;
/** Vest's account_type of an evaluation. */
const VEST_ACCOUNT_TYPE_EVAL = 2;
/** A status as the log says it: Vest's own name, with its number. */
export function vestStatusName(status: number): string {
const name = Object.entries(VEST_STATUS).find(([, v]) => v === status)?.[0];
return `${name ? name.toLowerCase().replace(/_/g, ' ') : 'unknown'} (status ${status})`;
}
/**
* A passed evaluation whose funded account Vest activates on request (POST
* /v3/capital/activate-funded) — the Vest app's own test for its "Go to
* funded account" button: an evaluation at GOAL_PASSED, not blocked, on its
* plan's last step. An earlier step of a 2-Step passes on to the next step
* by itself.
*/
export function awaitingActivation(a: VestCapitalAccount): boolean {
const evaluation = a.accountType === VEST_ACCOUNT_TYPE_EVAL || a.stage === VEST_STAGE_EVAL;
return evaluation && a.status === VEST_STATUS.GOAL_PASSED && !a.blocked && a.stepsTotal > 0 && a.step === a.stepsTotal;
}
const pct = (fraction: number) => Math.round(fraction * 1000) / 10; const pct = (fraction: number) => Math.round(fraction * 1000) / 10;
+1 -1
View File
@@ -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",
+166
View File
@@ -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);
});
+242 -2
View File
@@ -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');
@@ -404,6 +411,103 @@ test("the client's Vest day turns with its numbers, and a sync that stalls stops
} }
}); });
// A timeout: an ask let through twice leaves the first one waiting forever.
test("a passed evaluation doesn't hold the sync, and its funded account is asked for once", { timeout: 10_000 }, async () => {
clockAt('2026-10-29T15:31:00Z');
// Vest's status for each account (acc-2 passed), and the balance Vest's
// active list leaves out — a passed one's.
const status = { 'acc-1': 2, 'acc-2': 3 };
let missingBalance = 'acc-2';
let activation = null;
const sent = [];
const client = await syncingClient((method, path, opts) => {
if (method === 'GET' && path === '/v3/capital/accounts/active') {
return { data: { accounts: Object.keys(status).map((id, i) => ({
id, status: status[id], account_type: 2, stage: 1, step: 1, steps_total: 1, plan_product_type: 'eval',
attempt_index: i + 80, initial_capital: '50000', max_daily_loss_pct: '0.03',
stages: [{ ordinal: 1, kind: 'eval', profit_target: '5000' }, { ordinal: 2, kind: 'funded', profit_target: '' }],
})) } };
}
if (method === 'GET' && path === '/v3/accounts') {
return { data: { accounts: [
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '55100', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
].filter((b) => b.account_id !== missingBalance) } };
}
if (method === 'POST' && path === '/v3/capital/activate-funded') {
sent.push({ account: opts.account, body: opts.body });
return activation();
}
return syncReads(method, path, []) ?? unanswered(method, path);
});
const funded = { id: 'acc-2-funded', status: 2, account_type: 3, stage: 2, attempt_index: 81, initial_capital: '50000', max_daily_loss_pct: '0.03', stages: [{ ordinal: 1, kind: 'eval' }, { ordinal: 2, kind: 'funded' }] };
// The sync an ask starts behind it, run to its end.
const settled = async () => {
await new Promise((resolve) => setImmediate(resolve));
await client.syncRunning;
};
try {
await client.requestSync();
assert.ok(client.lastSyncAt > 0, "the passed account's missing balance didn't make the sync short of good");
assert.equal(client.accountList.find((a) => a.id === B).active, false);
assert.deepEqual(client.activatable(), [B]);
await assert.rejects(client.activateFunded(A), /only a passed evaluation/);
// While the ask is out, a second caller is refused, and a sync reads
// a list from before Vest's answer: it isn't asked again on it.
let answer;
activation = () => new Promise((resolve) => { answer = resolve; });
const asking = client.activateFunded(B);
await assert.rejects(client.activateFunded(B), /hasn't been read back/);
clockAt('2026-10-29T15:31:30Z');
await client.requestSync();
assert.deepEqual(client.activatable(), []);
answer({ data: { account: funded } });
assert.deepEqual(await asking, { funded: 'V50K-F-D3-81' });
assert.deepEqual(sent, [{ account: 'acc-2', body: { eval_account_id: 'acc-2' } }]);
// Answered: never asked again, while Vest's list is slow to move it
// or once it's claimed.
await settled();
clockAt('2026-10-29T15:40:00Z');
await client.requestSync();
assert.deepEqual(client.activatable(), []);
status['acc-2'] = 6;
await client.requestSync();
assert.deepEqual(client.activatable(), []);
assert.equal(sent.length, 1);
// acc-1 passes too. A timeout may have gone in: held until a list
// read a minute after it. A refusal went nowhere: free at once.
status['acc-1'] = 3;
missingBalance = 'acc-1';
await client.requestSync();
assert.deepEqual(client.activatable(), [A]);
activation = () => { throw new Error('/v3/capital/activate-funded timed out after 15s'); };
await assert.rejects(client.activateFunded(A), /timed out/);
await settled();
assert.deepEqual(client.activatable(), []);
clockAt('2026-10-29T15:40:30Z');
await client.requestSync();
assert.deepEqual(client.activatable(), []);
clockAt('2026-10-29T15:41:10Z');
await client.requestSync();
assert.deepEqual(client.activatable(), [A]);
activation = () => { throw new VestApiError('/v3/capital/activate-funded: funded limit reached (HTTP 400)', 400); };
await assert.rejects(client.activateFunded(A), /funded limit/);
assert.deepEqual(client.activatable(), [A]);
assert.equal(sent.length, 3);
// A live account's missing balance still holds the sync.
status['acc-1'] = 2;
const good = client.lastSyncAt;
clockAt('2026-10-29T15:45:00Z');
await client.requestSync();
assert.equal(client.lastSyncAt, good);
} finally {
client.disconnect();
}
});
test('two payouts on one Vest day share its fund row, and still count two toward the stage', async () => { test('two payouts on one Vest day share its fund row, and still count two toward the stage', async () => {
clockAt('2026-10-27T23:00:00Z'); clockAt('2026-10-27T23:00:00Z');
const client = await syncingClient(unanswered); const client = await syncingClient(unanswered);
@@ -529,3 +633,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();
}
});
+37 -1
View File
@@ -14,7 +14,8 @@ import {
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN, vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings, hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims, accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill, claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill, awaitingActivation, vestStatusName,
VEST_STATUS,
} from '../lib/vest-helpers.ts'; } from '../lib/vest-helpers.ts';
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts'; import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts'; import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts';
@@ -120,6 +121,41 @@ test('capital accounts parse, and are named for their rules', () => {
assert.equal(parseCapitalAccount({ nope: true }), null); assert.equal(parseCapitalAccount({ nope: true }), null);
}); });
test('a passed evaluation waits on its funded account, as the Vest app decides', () => {
const passed = { ...evalAccount, status: VEST_STATUS.GOAL_PASSED };
const a = parseCapitalAccount(passed);
assert.equal(a.step, 1);
assert.equal(a.stepsTotal, 1);
assert.equal(a.accountType, 2);
assert.equal(a.blocked, false);
assert.equal(awaitingActivation(a), true);
// Still trading, already claimed, failed or expired: nothing to activate.
for (const status of [1, 2, 4, 5, 6, 7]) assert.equal(awaitingActivation(parseCapitalAccount({ ...evalAccount, status })), false, `status ${status}`);
// Blocked by Vest.
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, blocked_at: 1793287000000 })), false);
// A funded account that reached its goal isn't an evaluation — on the
// last step's number too, so only the evaluation test can say so.
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3, stage: 2, step: 1 })), false);
// Either of Vest's marks makes it one.
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3 })), true);
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, stage: 2, step: 1 })), true);
// A 2-Step's first step passes on to its second by itself; the last one waits.
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 1, steps_total: 2 })), false);
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 2, steps_total: 2 })), true);
// Without step or steps_total, the Vest app's own fallbacks.
const { step, steps_total, ...bare } = passed;
assert.equal(awaitingActivation(parseCapitalAccount(bare)), true);
assert.equal(parseCapitalAccount({ ...bare, stage: 2 }).step, 2);
assert.equal(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' }).stepsTotal, 0);
assert.equal(awaitingActivation(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' })), false);
assert.equal(vestStatusName(3), 'goal passed (status 3)');
assert.equal(vestStatusName(6), 'claimed (status 6)');
assert.equal(vestStatusName(9), 'unknown (status 9)');
});
const openPosition = { const openPosition = {
positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2, positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2,
symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000', symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000',