diff --git a/extension/README.md b/extension/README.md index cbd9538..ba55963 100644 --- a/extension/README.md +++ b/extension/README.md @@ -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. diff --git a/extension/manifest.json b/extension/manifest.json index 8f0bb76..562c8d3 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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", diff --git a/extension/vest-relay.js b/extension/vest-relay.js index ee2ee41..5d1d7f1 100644 --- a/extension/vest-relay.js +++ b/extension/vest-relay.js @@ -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; }; diff --git a/lib/vest-class.ts b/lib/vest-class.ts index 449a067..a05c9c4 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -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(); private flatAmounts = new Map(); + /** When a relayed token last turned out to be one account's own. */ + private scopedRefusedAt = 0; private gateNext: Record = { read: 0, trade: 0, history: 0 }; private suspectAccounts = new Map(); @@ -261,7 +263,9 @@ 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` - : `no Vest session for ${this.wallet} — keep a Vest tab signed in to that wallet open in Chrome with the AutoFirmer extension`; + : 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; return null; @@ -472,7 +476,8 @@ export class VestClient implements PlatformClient { */ private async requestSync(): Promise { 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(); 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(); diff --git a/lib/vest-helpers.ts b/lib/vest-helpers.ts index 02de3dc..106a43b 100644 --- a/lib/vest-helpers.ts +++ b/lib/vest-helpers.ts @@ -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'); diff --git a/lib/vest-session.ts b/lib/vest-session.ts index 3e5fdac..105389b 100644 --- a/lib/vest-session.ts +++ b/lib/vest-session.ts @@ -32,12 +32,30 @@ export interface VestSessionInfo { receivedAt: number; } -const g = globalThis as typeof globalThis & { __vestSessions?: Map }; +const g = globalThis as typeof globalThis & { + __vestSessions?: Map; + __vestPrevious?: Map; + __vestRefused?: Map; +}; function store(): Map { return (g.__vestSessions ??= new Map()); } +/** The session each wallet's current one replaced — what a refused token + * falls back to. */ +function previous(): Map { + 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 { + 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(); diff --git a/scripts/check-vest.mjs b/scripts/check-vest.mjs index fdeab58..dddd738 100644 --- a/scripts/check-vest.mjs +++ b/scripts/check-vest.mjs @@ -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); +});