Compare commits

...
2 Commits
Author SHA1 Message Date
Brandon LiandClaude Opus 5.5 c109aff3e5 Trades: honor an explicit 'outside' mark on trades from before marking began
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:33:29 -05:00
Brandon LiandClaude Opus 5.5 a0e7c5b7c2 Vest: relay the login's token, never one account's
The Vest app renews each trading account's own token through the same
POST /v3/auth/refresh as the login's (with that account's refresh token
in the body). The extension relayed those too, and the dashboard kept
whichever token lived longest — so while someone traded in an account on
Vest, Me synced with a token that sees that account alone and dropped
the other 24 from the list.

Extension 0.17.2 skips account renewals. The dashboard also refuses a
token that sees one account and no primary (or names that account in
its claims), keeps the last account list, and falls back to the login's
token it replaced. An account gone from Vest's list no longer leaves a
stale position holding the lane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:32:40 -05:00
9 changed files with 182 additions and 18 deletions
+2 -2
View File
@@ -29,7 +29,7 @@ export async function GET(req: Request) {
const open = t.closedAt === null && t.closeReason === null && t.closedBy === null;
const closedBy = open ? null
: t.closedBy === 'bot' ? 'bot'
: t.closeReason === 'user_requested' ? (tracked(t.closedAt ?? t.openedAt) ? 'outside' : 'user_requested')
: t.closeReason === 'user_requested' ? (t.closedBy === 'outside' || tracked(t.closedAt ?? t.openedAt) ? 'outside' : 'user_requested')
: t.closeReason ?? 'unknown';
const net = open || t.pnl === null ? null
: Math.round((t.pnl - Math.abs(t.fee ?? 0) + (t.funding ?? 0)) * 100) / 100;
@@ -48,7 +48,7 @@ export async function GET(req: Request) {
net,
openedAt: t.openedAt,
closedAt: t.closedAt,
openedBy: t.openedBy === 'bot' ? 'bot' : tracked(t.openedAt) ? 'outside' : null,
openedBy: t.openedBy === 'bot' ? 'bot' : t.openedBy === 'outside' || tracked(t.openedAt) ? 'outside' : null,
closedBy,
};
});
+5
View File
@@ -95,6 +95,11 @@ dashboard POST /api/vest/session holds it in memory for the firm's Vest cl
- The hook only reads the Vest app's own `/v3/auth/refresh` and `/register`
answers. It renews nothing and sends nothing; the session stays the app's,
so the bot never logs the tab out.
- It takes the login's token only. The app renews each trading account's own
token through the same `/v3/auth/refresh` (with that account's refresh token
in the body), and one of those sees a single account. Before 0.17.2 the hook
relayed those too, and the dashboard dropped every other account; the
dashboard now refuses such a token and falls back to the login's.
- 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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "AutoFirmer Capture",
"version": "0.17.1",
"version": "0.17.2",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
"update_url": "https://ext.juicerroom.com/updates.xml",
+22 -7
View File
@@ -14,6 +14,12 @@
* 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
* device key, so the tab is never logged out by the bot.
*
* The app also renews each trading account's own token through the same
* /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.
*/
(() => {
@@ -22,11 +28,17 @@
const AUTH = /^https:\/\/api-gateway\.hz\.vestmarkets\.com\/v3\/auth\/(refresh|register)(\?|$)/;
function take(url, status, text) {
if (status < 200 || status >= 300 || !AUTH.test(url)) return;
/** A request body that carries a refresh token: an account's renewal. */
function accountRenewal(reqBody) {
return typeof reqBody === 'string' && /"refreshToken"\s*:\s*"[^"]/.test(reqBody);
}
function take(url, status, text, accountRequest) {
if (accountRequest || status < 200 || status >= 300 || !AUTH.test(url)) return;
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
const expiresAt = Number(body.accessExpiresAtMs);
if (!Number.isFinite(expiresAt)) return;
window.__afVestSession = {
@@ -44,12 +56,14 @@
try { this.__afVestUrl = String(url); } catch { /* ignore */ }
return open.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
XMLHttpRequest.prototype.send = function (reqBody) {
if (this.__afVestUrl && AUTH.test(this.__afVestUrl)) {
let acct = false;
try { acct = accountRenewal(reqBody); } catch { /* ignore */ }
this.addEventListener('loadend', () => {
try {
if (this.responseType === '' || this.responseType === 'text') take(this.__afVestUrl, this.status, this.responseText);
else if (this.responseType === 'json') take(this.__afVestUrl, this.status, JSON.stringify(this.response));
if (this.responseType === '' || this.responseType === 'text') take(this.__afVestUrl, this.status, this.responseText, acct);
else if (this.responseType === 'json') take(this.__afVestUrl, this.status, JSON.stringify(this.response), acct);
} catch { /* never break the app */ }
});
}
@@ -57,11 +71,12 @@
};
const fetch0 = window.fetch;
window.fetch = async function (input) {
window.fetch = async function (input, init) {
const res = await fetch0.apply(this, arguments);
try {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input && input.url;
if (url && AUTH.test(url)) res.clone().text().then((t) => take(url, res.status, t)).catch(() => {});
const acct = accountRenewal(init && init.body);
if (url && AUTH.test(url)) res.clone().text().then((t) => take(url, res.status, t, acct)).catch(() => {});
} catch { /* never break the app */ }
return res;
};
+3 -1
View File
@@ -582,7 +582,9 @@ export function wipeAccountData(accountId: number): void {
// Every position a platform's history shows — the bot's and anyone else's
// (Vest: GET /v3/positions). The platform can't tell an API order from a
// click, so opened_by / closed_by record what this instance did itself:
// 'bot' when its own order opened or closed the position, NULL otherwise.
// 'bot' when its own order opened or closed the position, NULL otherwise —
// or 'outside', set by hand for a trade from before marking began whose
// origin the bot's own log settles.
// A wipe leaves these alone: they're refetched, but the marks aren't.
db.exec(`
+28 -3
View File
@@ -49,11 +49,11 @@ import { firmInstruments } from './instruments';
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers';
import { vestSessionFor, dropVestSession, listVestSessions } from './vest-session';
import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session';
import {
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
parseHistoryPosition, VEST_STATUS_ACTIVE, VEST_TXN,
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_TXN,
} from './vest-helpers';
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } from './vest-helpers';
@@ -218,6 +218,8 @@ export class VestClient implements PlatformClient {
/** Open positions already in the trades table, by id → size stored. */
private recordedOpen = new Map<string, number>();
private flatAmounts = new Map<number, number>();
/** When a relayed token last turned out to be one account's own. */
private scopedRefusedAt = 0;
private gateNext: Record<Bucket, number> = { read: 0, trade: 0, history: 0 };
private suspectAccounts = new Map<number, number>();
@@ -261,6 +263,8 @@ export class VestClient implements PlatformClient {
const other = listVestSessions().find((s) => s.primaryAddr !== this.wallet && s.expiresAt > Date.now());
const msg = other
? `no Vest session for ${this.wallet} — the Vest tab here is signed in as ${other.primaryAddr}; sign in as ${this.wallet} there, or remove this firm's credential on the dashboard to switch to the tab's login`
: Date.now() - this.scopedRefusedAt < 30 * 60_000
? `no Vest session for ${this.wallet} — the extension relayed one account's own token, not the login's; reload the Vest tab (or update the extension) to relay the login's`
: `no Vest session for ${this.wallet} — keep a Vest tab signed in to that wallet open in Chrome with the AutoFirmer extension`;
if (this.lastFetchErrors['login'] !== msg) console.warn(`${this.tag} ${msg}`);
this.lastAuthError = this.lastFetchErrors['login'] = msg;
@@ -472,7 +476,8 @@ export class VestClient implements PlatformClient {
*/
private async requestSync(): Promise<void> {
if (this.intentionalDisconnect) return;
if (!this.userToken()) return;
const token = this.userToken();
if (!token) return;
const startedAt = Date.now();
if (this.syncInFlight) {
const runningMs = startedAt - this.syncInFlightSince;
@@ -499,6 +504,16 @@ export class VestClient implements PlatformClient {
delete this.lastFetchErrors['login'];
const accounts = (cap.data?.accounts ?? []).map(parseCapitalAccount).filter((a): a is VestCapitalAccount => a !== null);
// One account's own token (the Vest app renews those through the
// same endpoint the extension watches) sees that account alone:
// syncing on it would drop every other account from the list.
const hasPrimary = (bal.data?.accounts ?? []).some((b) => num(b.account_type) === 1);
if (accountScopedToken({ capitalIds: accounts.map((a) => a.id), hasPrimary, claims: jwtClaimStrings(token) })) {
refuseVestSession(this.wallet, token);
this.scopedRefusedAt = Date.now();
console.warn(`${this.tag} the relayed token is one Vest account's own (it sees ${accounts.length} account, no primary), not the login's — refused; keeping the last account list until the login's token comes`);
return;
}
const free = new Map<string, number>();
for (const b of bal.data?.accounts ?? []) {
const amount = num(b.amount);
@@ -565,6 +580,16 @@ export class VestClient implements PlatformClient {
+ `${a.targetEquity ? `, target ${a.targetEquity}` : ''}`;
}
this.accountList = list;
// An account gone from Vest's list (breached, closed) takes its
// position with it — a stale one here would hold the lane.
for (const key of Object.keys(this.positions)) {
const id = Number(key);
if (!list.some((a) => a.id === id)) {
delete this.positions[id];
this.positionsDetail.delete(id);
this.seenOpenAt.delete(id);
}
}
this.settleSuspects();
this.checkNakedPositions();
+34
View File
@@ -334,6 +334,40 @@ export function parseHistoryPosition(raw: unknown): VestTrade | null {
};
}
/** Every string value in a JWT's payload, lower-case ([] when it isn't
* one). Used only to compare against account ids — never logged. */
export function jwtClaimStrings(token: string): string[] {
const part = token.split('.')[1];
if (!part) return [];
let payload: unknown;
try {
payload = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
} catch {
return [];
}
const out: string[] = [];
const walk = (v: unknown, depth: number): void => {
if (typeof v === 'string') out.push(v.toLowerCase());
else if (v && typeof v === 'object' && depth < 4) for (const x of Object.values(v)) walk(x, depth + 1);
};
walk(payload, 0);
return out;
}
/**
* Whether a token the extension relayed is one Vest account's own token, not
* the login's. The Vest app renews both through POST /v3/auth/refresh — the
* login's every ~15 minutes, an account's while someone trades in it — and
* an account's token sees only that account: /v3/capital/accounts/active
* lists it alone, and /v3/accounts has no primary (wallet) account. The
* login sees every account and its primary one.
*/
export function accountScopedToken(o: { capitalIds: string[]; hasPrimary: boolean; claims: string[] }): boolean {
if (o.capitalIds.length > 1) return false;
if (!o.hasPrimary) return true;
return o.capitalIds.some((id) => o.claims.includes(id.toLowerCase()));
}
/** Exit legs still waiting to trigger. */
export function awaitingLegs(p: VestPosition): VestLeg[] {
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
+36 -1
View File
@@ -32,12 +32,30 @@ export interface VestSessionInfo {
receivedAt: number;
}
const g = globalThis as typeof globalThis & { __vestSessions?: Map<string, VestSession> };
const g = globalThis as typeof globalThis & {
__vestSessions?: Map<string, VestSession>;
__vestPrevious?: Map<string, VestSession>;
__vestRefused?: Map<string, number>;
};
function store(): Map<string, VestSession> {
return (g.__vestSessions ??= new Map());
}
/** The session each wallet's current one replaced — what a refused token
* falls back to. */
function previous(): Map<string, VestSession> {
return (g.__vestPrevious ??= new Map());
}
/** Tokens the client found to be one account's, not the login's (by their
* tail — enough to tell them apart, never the whole token), with when. */
function refused(): Map<string, number> {
return (g.__vestRefused ??= new Map());
}
const REFUSED_KEEP = 50;
/** Shortest life a relayed token must have left to be taken. */
const MIN_REMAINING_MS = 30_000;
/** Longest a relayed token may claim to live — a Vest access token is
@@ -54,8 +72,10 @@ export function putVestSession(input: { accessToken: unknown; expiresAt: unknown
if (!/^0x[0-9a-f]{40}$/.test(addr)) return 'primaryAddr is not a wallet address';
if (!Number.isFinite(expiresAt) || expiresAt - now < MIN_REMAINING_MS) return 'token already expired';
if (expiresAt - now > MAX_REMAINING_MS) return 'expiresAt is implausibly far away';
if (refused().has(token.slice(-32))) return "that is one Vest account's token, not the login's — reload the Vest tab, or update the extension";
const prev = store().get(addr);
if (prev && prev.expiresAt > expiresAt && prev.accessToken !== token) return null; // keep the fresher one
if (prev && prev.accessToken !== token) previous().set(addr, prev);
store().set(addr, {
accessToken: token,
expiresAt,
@@ -73,6 +93,21 @@ export function vestSessionFor(primaryAddr: string, now = Date.now(), minRemaini
return s && s.expiresAt - now >= minRemainingMs ? s : null;
}
/** The client found the wallet's token to be one account's own (see
* accountScopedToken): drop it, and take it no more if it's relayed again —
* the login's own token, when the Vest app next renews it, takes its place. */
export function refuseVestSession(primaryAddr: string, token: string, now = Date.now()): void {
const key = primaryAddr.trim().toLowerCase();
refused().set(token.slice(-32), now);
while (refused().size > REFUSED_KEEP) refused().delete(refused().keys().next().value as string);
if (store().get(key)?.accessToken !== token) return;
store().delete(key);
// Back to the token it replaced, if that one is still good.
const before = previous().get(key);
previous().delete(key);
if (before && !refused().has(before.accessToken.slice(-32)) && before.expiresAt - now >= MIN_REMAINING_MS) store().set(key, before);
}
/** Drop a wallet's session — the gateway refused it. */
export function dropVestSession(primaryAddr: string, token: string): void {
const key = primaryAddr.trim().toLowerCase();
+50 -2
View File
@@ -11,9 +11,10 @@ import assert from 'node:assert/strict';
import {
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition,
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
accountScopedToken,
} from '../lib/vest-helpers.ts';
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions } from '../lib/vest-session.ts';
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
const iso = (s) => Date.parse(s);
@@ -296,3 +297,50 @@ test('parseHistoryPosition: open, snake_case, seconds, and junk', () => {
assert.equal(parseHistoryPosition({ positionId: 'p2', symbol: 'GC-PERP' }), null, 'no open time');
assert.equal(parseHistoryPosition(null), null);
});
test('an account\'s own token is told from the login\'s', () => {
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const acct = '275e765a-2d3d-5de3-8386-71aaa847c0e8';
const accountToken = `${b64({ alg: 'ES256' })}.${b64({ sub: '3cab1794-47bc-523b-91e6-64a9ad26dcbf', acc: { id: acct.toUpperCase() } })}.sig`;
assert.ok(jwtClaimStrings(accountToken).includes(acct));
assert.deepEqual(jwtClaimStrings('not.a-jwt.at-all'), []);
assert.deepEqual(jwtClaimStrings('nodots'), []);
// The login sees every account and its primary one.
assert.equal(accountScopedToken({ capitalIds: [acct, 'b', 'c'], hasPrimary: true, claims: [] }), false);
// One account and no primary: an account's token, whatever its claims.
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: false, claims: [] }), true);
// One account with a primary: only when the token names that account.
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: true, claims: jwtClaimStrings(accountToken) }), true);
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: true, claims: ['3cab1794-47bc-523b-91e6-64a9ad26dcbf'] }), false);
// A login with no capital accounts yet.
assert.equal(accountScopedToken({ capitalIds: [], hasPrimary: true, claims: [] }), false);
});
test('a refused account token falls back to the login\'s and stays refused', () => {
const now = iso('2026-10-27T01:00:00Z');
const wallet = '0x00000000000000000000000000000000000000aa';
const login = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJsb2dpbiJ9.loginsignature-loginsignature-loginsig';
const account = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJhY2N0In0.accountsignature-accountsignature-acct';
assert.equal(putVestSession({ accessToken: login, expiresAt: now + 600_000, primaryAddr: wallet, userId: 'u' }, now), null);
// The account's token lives longer, so it replaces the login's...
assert.equal(putVestSession({ accessToken: account, expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), null);
assert.equal(vestSessionFor(wallet, now).accessToken, account);
// ...until the client finds it sees one account: back to the login's.
refuseVestSession(wallet, account, now);
assert.equal(vestSessionFor(wallet, now).accessToken, login);
// Relayed again (the old extension posts it every minute): refused.
assert.match(putVestSession({ accessToken: account, expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), /one Vest account's token/);
assert.equal(vestSessionFor(wallet, now).accessToken, login);
// The login's next renewal is taken as usual.
const renewed = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJsb2dpbiJ9.renewedsignature-renewedsignature-rene';
assert.equal(putVestSession({ accessToken: renewed, expiresAt: now + 1_200_000, primaryAddr: wallet, userId: 'u' }, now), null);
assert.equal(vestSessionFor(wallet, now).accessToken, renewed);
// Refusing a token that isn't held changes nothing held.
refuseVestSession(wallet, 'x.y.zzz', now);
assert.equal(vestSessionFor(wallet, now).accessToken, renewed);
// No good token to fall back to: none at all.
const wallet2 = '0x00000000000000000000000000000000000000bb';
assert.equal(putVestSession({ accessToken: account.replace('acct', 'accu'), expiresAt: now + 900_000, primaryAddr: wallet2, userId: 'u' }, now), null);
refuseVestSession(wallet2, account.replace('acct', 'accu'), now);
assert.equal(vestSessionFor(wallet2, now), null);
});