Vest: one refused account token fails only its account, and the relay sends the current session's token
Every open Vest tab renews the login on its own, and each renewal moves the session on: a token from before still reads, but Vest refuses to mint an account's token with it (401, code 1002). The extension relayed the first tab's token, so mints failed most of the time with two tabs open, and each refusal dropped the whole session — every later account in a history pass failed "no Vest session", and new accounts waited out 5-minute retries for up to an hour (2026-10-28/29). The client now fails only that account's call on a refused mint, keeps the session and the cached account tokens, and logs the refusal now and then with the login token's life left. An active account with no history is read again every minute even while a whole pass's retry waits; a pass reads those first and inactive accounts last, whose failures no longer set the retry. Exits, lone take-profits and flattens wait up to 90s for the session and the account's token instead of failing at once; entries still go now or not at all. Closed accounts' history is read through the login first. Extension 0.18.0: the hook records each token's session id and takes sign-in tokens too; the relay reads every Vest tab and Vest's own current-session record, and sends the token that session holds, else the newest, saying in the popup when none is current. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bf7e147507
commit
0eb56b3949
@@ -0,0 +1,166 @@
|
||||
// Checks for the extension's Vest relay: the hook in the Vest tab
|
||||
// (extension/vest-relay.js) and the choice of which tab's token the background
|
||||
// relays (extension/background.js pageVestSession / pickVestSession).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// The hook runs in a sandbox standing in for the Vest page — its own XHR and
|
||||
// fetch, answered here; the background's functions are lifted out of its
|
||||
// source, since it is a service worker and not a module. Nothing reaches Vest
|
||||
// or Chrome. The answers are shaped like the Vest app's own (index bundle,
|
||||
// 2026-10-29): a renewal's carries refreshTokenId, a sign-in's the login's
|
||||
// refreshToken too, an account's renewal a refreshToken in its request.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
const HOOK = read('extension/vest-relay.js');
|
||||
const BACKGROUND = read('extension/background.js');
|
||||
|
||||
const GATEWAY = 'https://api-gateway.hz.vestmarkets.com';
|
||||
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
||||
const answer = (extra) => JSON.stringify({ accessToken: 'eyJ.login.sig', accessExpiresAtMs: 1_800_000_000_000, primaryAddr: WALLET, userId: 'u', ...extra });
|
||||
|
||||
/** A Vest page with the hook run in it (after `before`, a hook already
|
||||
* there): `xhr(url, body, status, text)` and `fetch(...)` play one of the
|
||||
* app's requests through it. */
|
||||
function page(before = {}) {
|
||||
class FakeXHR {
|
||||
constructor() { this.listeners = []; this.responseType = ''; }
|
||||
open(method, url) { this.url = url; }
|
||||
send() {}
|
||||
addEventListener(type, fn) { if (type === 'loadend') this.listeners.push(fn); }
|
||||
}
|
||||
const answers = [];
|
||||
const sandbox = {
|
||||
XMLHttpRequest: FakeXHR, URL,
|
||||
fetch: async () => answers.shift(),
|
||||
...before,
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(HOOK, sandbox);
|
||||
return {
|
||||
sandbox,
|
||||
run: () => vm.runInContext(HOOK, sandbox),
|
||||
xhr(url, body, status, text) {
|
||||
const x = new sandbox.XMLHttpRequest();
|
||||
x.open('POST', url);
|
||||
x.send(body);
|
||||
x.status = status;
|
||||
x.responseText = text;
|
||||
for (const fn of x.listeners) fn();
|
||||
},
|
||||
async fetch(url, body, status, text) {
|
||||
answers.push({ status, clone: () => ({ text: async () => text }) });
|
||||
await sandbox.window.fetch(url, { method: 'POST', body });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("the hook keeps the login's renewed token with its session id, and an account's never", async () => {
|
||||
const p = page();
|
||||
assert.equal(p.sandbox.__afVestHook, 2);
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r1', rememberMe: true }), 200, answer({ refreshTokenId: 'rt-1' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-1');
|
||||
assert.equal(p.sandbox.__afVestLogin.primaryAddr, WALLET);
|
||||
|
||||
// An account's renewal, by its request, then by its answer alone.
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ refreshToken: 'acct-rt', requestId: 'r2' }), 200, answer({ accessToken: 'eyJ.acct.sig', refreshTokenId: 'rt-a' }));
|
||||
await p.fetch(`${GATEWAY}/v3/auth/refresh`, undefined, 200, answer({ accessToken: 'eyJ.acct2.sig', refreshToken: 'acct-rt', refreshTokenId: 'rt-b' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||
|
||||
// The login's next renewal, through fetch; a refused one leaves it be.
|
||||
await p.fetch(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r3' }), 200, answer({ accessToken: 'eyJ.login2.sig', refreshTokenId: 'rt-2' }));
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r4' }), 401, answer({ accessToken: 'eyJ.no.sig', refreshTokenId: 'rt-x' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login2.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-2');
|
||||
});
|
||||
|
||||
test("a sign-in's token is taken, though its answer carries the login's refresh token", () => {
|
||||
const p = page();
|
||||
p.xhr(`${GATEWAY}/v3/auth/register`, JSON.stringify({ primaryAddr: WALLET }), 200, answer({ accessToken: 'eyJ.signin.sig', refreshToken: 'login-rt', refreshTokenId: 'rt-s' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.signin.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-s');
|
||||
});
|
||||
|
||||
test('the hook installs once, and beside a version-1 hook', () => {
|
||||
const p = page({ __afVestHook: true, __afVestSession: { accessToken: 'eyJ.old.sig', expiresAt: 1 } });
|
||||
assert.equal(p.sandbox.__afVestHook, 2);
|
||||
const send = p.sandbox.XMLHttpRequest.prototype.send;
|
||||
p.run();
|
||||
assert.equal(p.sandbox.XMLHttpRequest.prototype.send, send);
|
||||
});
|
||||
|
||||
// The background's own functions, out of its source.
|
||||
function lift(name) {
|
||||
const m = BACKGROUND.match(new RegExp(`^function ${name}\\([\\s\\S]*?^}`, 'm'));
|
||||
assert.ok(m, `${name} not found in background.js`);
|
||||
return m[0];
|
||||
}
|
||||
const { isCurrentVestSession, pickVestSession } = new Function(`${lift('isCurrentVestSession')}\n${lift('pickVestSession')}\nreturn { isCurrentVestSession, pickVestSession };`)();
|
||||
const pageVestSession = (window, stored) => new Function('window', 'localStorage', `${lift('pageVestSession')}\nreturn pageVestSession();`)(
|
||||
window, { getItem: (key) => (key === 'vest-auth-session' ? stored : null) },
|
||||
);
|
||||
|
||||
const later = (min) => Date.now() + min * 60_000;
|
||||
const session = (token, expiresAt, refreshTokenId) => ({ accessToken: token, expiresAt, primaryAddr: WALLET, userId: 'u', ...(refreshTokenId !== undefined ? { refreshTokenId } : {}) });
|
||||
const record = (refreshTokenId, accessTokenExpiresAt) => JSON.stringify({ refreshTokenId, accessTokenExpiresAt, refreshTokenExpiresAt: later(43200), rememberMe: true });
|
||||
|
||||
test("a tab reports its live token (version 2's, else version 1's) and the app's current session", () => {
|
||||
const expiresAt = later(12);
|
||||
let out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', expiresAt, 'rt-2'), __afVestSession: session('eyJ.v1.sig', expiresAt) }, record('rt-2', expiresAt));
|
||||
assert.equal(out.hooked, true);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||
assert.deepEqual(out.current, { refreshTokenId: 'rt-2', accessTokenExpiresAt: expiresAt });
|
||||
|
||||
// Hooked by version 1 only: its token, and the tab asks for version 2.
|
||||
out = pageVestSession({ __afVestHook: true, __afVestSession: session('eyJ.v1.sig', later(9)) }, record('rt-3', later(14)));
|
||||
assert.equal(out.hooked, false);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v1.sig');
|
||||
|
||||
// A 0.17.0 hook kept an account's renewal too: version 2's login token
|
||||
// wins over it, though it expires sooner.
|
||||
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', later(5), 'rt-1'), __afVestSession: session('eyJ.acct.sig', later(13)) }, null);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||
assert.equal(out.current, null);
|
||||
|
||||
// Nearly expired, or a record that doesn't read.
|
||||
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', Date.now() + 20_000, 'rt-1') }, '{nope');
|
||||
assert.equal(out.session, null);
|
||||
assert.equal(out.current, null);
|
||||
});
|
||||
|
||||
test("the token relayed is the one the current session holds, whichever tab has it — not the first tab's", () => {
|
||||
// Tab 1 renewed first; tab 2 renewed after it, so the session is tab 2's.
|
||||
const tab1 = { hooked: true, session: session('eyJ.tab1.sig', later(6), 'rt-1'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||
const tab2 = { hooked: true, session: session('eyJ.tab2.sig', later(13), 'rt-2'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||
assert.deepEqual(pickVestSession([tab1, tab2]), { session: tab2.session, current: true });
|
||||
assert.equal(isCurrentVestSession(tab1.session, tab1.current), false);
|
||||
|
||||
// Tab 1 renews again: now its token is the current one.
|
||||
const rt3 = { refreshTokenId: 'rt-3', accessTokenExpiresAt: later(15) };
|
||||
const tab1b = { ...tab1, session: session('eyJ.tab1b.sig', later(15), 'rt-3'), current: rt3 };
|
||||
assert.equal(pickVestSession([{ ...tab2, current: rt3 }, tab1b]).session.accessToken, 'eyJ.tab1b.sig');
|
||||
|
||||
// Version-1 hooks keep no id: matched by the expiry the app keeps with it.
|
||||
const v1a = { hooked: false, session: session('eyJ.v1a.sig', later(4)), current: { refreshTokenId: 'rt-9', accessTokenExpiresAt: later(11) } };
|
||||
const v1b = { hooked: false, session: session('eyJ.v1b.sig', v1a.current.accessTokenExpiresAt), current: v1a.current };
|
||||
assert.deepEqual(pickVestSession([v1a, v1b]), { session: v1b.session, current: true });
|
||||
});
|
||||
|
||||
test('with no tab holding the current session the newest is relayed, and said; with no record, unjudged', () => {
|
||||
const current = { refreshTokenId: 'rt-5', accessTokenExpiresAt: later(14) };
|
||||
const a = { hooked: true, session: session('eyJ.a.sig', later(3), 'rt-3'), current };
|
||||
const b = { hooked: true, session: session('eyJ.b.sig', later(8), 'rt-4'), current };
|
||||
assert.deepEqual(pickVestSession([a, b]), { session: b.session, current: false });
|
||||
assert.deepEqual(pickVestSession([{ ...a, current: null }, { ...b, current: null }]), { session: b.session, current: null });
|
||||
// A tab with no token yet still shows the record.
|
||||
assert.equal(pickVestSession([{ hooked: true, session: null, current }, { ...b, current: null }]).current, false);
|
||||
assert.equal(pickVestSession([{ hooked: true, session: null, current }]), null);
|
||||
assert.equal(pickVestSession([]), null);
|
||||
});
|
||||
@@ -3,7 +3,9 @@
|
||||
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
||||
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
||||
// trade beside an open of unknown outcome, and the client's day at 8 PM ET;
|
||||
// the completed cycles a Vest day's claims end, named at each claim.
|
||||
// the completed cycles a Vest day's claims end, named at each claim; a
|
||||
// refused account-token mint, a new account's history read again within the
|
||||
// minute, and exits that wait out a lapse in the session.
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
@@ -46,12 +48,17 @@ after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const { default: axios } = await import('../lib/http-client.ts');
|
||||
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
|
||||
const { VestClient } = await import('../lib/vest-class.ts');
|
||||
// The client's own transport, for the checks of what it does with an answer.
|
||||
const realRequest = VestClient.prototype.request;
|
||||
let gateway = null;
|
||||
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
||||
// Calls on an account's token reach the gateway here without one, so its
|
||||
// mint is answered here too.
|
||||
if (opts.mint) return { data: { accessToken: `eyJ.${opts.body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 } };
|
||||
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
return gateway(method, path, opts);
|
||||
};
|
||||
const { putVestSession } = await import('../lib/vest-session.ts');
|
||||
const { putVestSession, vestSessionFor, dropVestSession } = await import('../lib/vest-session.ts');
|
||||
const {
|
||||
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
|
||||
} = await import('../lib/db.ts');
|
||||
@@ -529,3 +536,139 @@ test('each claim of a Vest day names the completed cycle it ends, the second and
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused account-token mint fails only that account: the login's session stays, and the next account's reads go out", async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const client = testClient(unanswered, [A, B]);
|
||||
await client.syncRunning;
|
||||
// The client's own transport, on a gateway that refuses acc-1's mint as
|
||||
// Vest did all night on 2026-10-28 (401, code 1002).
|
||||
client.request = realRequest;
|
||||
const adapter = axios.defaults.adapter;
|
||||
const sent = [];
|
||||
axios.defaults.adapter = async (config) => {
|
||||
const path = config.url.replace(/^https:\/\/[^/]+/, '');
|
||||
const body = typeof config.data === 'string' ? JSON.parse(config.data) : undefined;
|
||||
sent.push(`${config.method.toUpperCase()} ${path}${body?.accountId ? ` ${body.accountId}` : ''}`);
|
||||
const reply = (status, data) => ({ status, statusText: '', headers: {}, config, data });
|
||||
if (path === '/v3/auth/account-token') {
|
||||
return body.accountId === 'acc-1'
|
||||
? reply(401, { detail: { code: 1002, msg: 'Unauthorized' } })
|
||||
: reply(200, { accessToken: `eyJ.${body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 });
|
||||
}
|
||||
if (path === '/v3/transactions') return reply(200, { transactions: [] });
|
||||
if (path === '/v3/positions') return reply(200, { positions: [] });
|
||||
return reply(404, {});
|
||||
};
|
||||
try {
|
||||
const { failedAccounts } = await client.fetchDaysTraded([A, B]);
|
||||
assert.equal(failedAccounts, 1);
|
||||
assert.notEqual(vestSessionFor(WALLET), null);
|
||||
assert.equal(client.historyLoadedIds.has(A), false);
|
||||
assert.equal(client.historyLoadedIds.has(B), true);
|
||||
assert.match(client.lastFetchErrors['V50K-F-D3-1'], /code 1002/);
|
||||
assert.ok(sent.includes('POST /v3/auth/account-token acc-2'));
|
||||
assert.ok(sent.includes('GET /v3/transactions'));
|
||||
} finally {
|
||||
axios.defaults.adapter = adapter;
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('an active account with no history is read again within a minute, while a whole pass waits out its retry', async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const C = vestAccountId('acc-3');
|
||||
const client = testClient(unanswered, [A, B, C]);
|
||||
await client.syncRunning;
|
||||
// acc-3 isn't active: it waits for a whole pass.
|
||||
client.capital.set(C, { status: 3 });
|
||||
client.accountList[2].active = false;
|
||||
client.historyLoaded = true;
|
||||
client.historyLoadedIds = new Set([A]);
|
||||
client.lastDaysFetchStartedAt = Date.now() - 120_000;
|
||||
// A whole pass failed: its retry is four minutes off.
|
||||
client.nextDaysFetchRetryAt = Date.now() + 4 * 60_000;
|
||||
const reads = [];
|
||||
client.fetchDaysTraded = async (ids) => {
|
||||
reads.push(ids);
|
||||
return { failedAccounts: ids ? ids.length : 0 };
|
||||
};
|
||||
const settled = () => new Promise((resolve) => setImmediate(resolve));
|
||||
try {
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B]]);
|
||||
// It failed again: not before the minute is up, and the whole pass's
|
||||
// retry stays where it was.
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B]]);
|
||||
clockAt('2026-10-29T05:42:01Z');
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B], [B]]);
|
||||
clockAt('2026-10-29T05:45:01Z');
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B], [B], undefined]);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a pass reads the accounts with no history first and the inactive last, and an inactive one's failure isn't the pass's", async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const C = vestAccountId('acc-3');
|
||||
const client = testClient(unanswered, [A, B, C]);
|
||||
await client.syncRunning;
|
||||
client.capital.set(C, { status: 3 });
|
||||
client.accountList[2].active = false;
|
||||
client.historyLoadedIds = new Set([A]);
|
||||
const order = [];
|
||||
client.fetchTxns = async (uuid) => {
|
||||
order.push(uuid);
|
||||
if (uuid === 'acc-3') throw new Error('/v3/transactions not sent — no account token: Unauthorized (HTTP 401, code 1002)');
|
||||
return [];
|
||||
};
|
||||
client.fetchTrades = async () => {};
|
||||
try {
|
||||
const { failedAccounts } = await client.fetchDaysTraded([A, B, C]);
|
||||
assert.deepEqual(order, ['acc-2', 'acc-1', 'acc-3']);
|
||||
assert.equal(failedAccounts, 0);
|
||||
assert.match(client.lastFetchErrors['V50K-F-D3-3'], /code 1002/);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('exits wait for the login session to be relayed again; an open goes now or not at all', async () => {
|
||||
clockAt('2026-10-29T06:00:00Z');
|
||||
const legs = [];
|
||||
const client = testClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions: [rawPosition('9101', 'acc-1', iso('2026-10-29T05:59:00Z'))] } };
|
||||
if (method === 'POST' && (path === '/v3/positions/stop-loss' || path === '/v3/positions/take-profit')) {
|
||||
legs.push(`${path.slice('/v3/positions/'.length)} @ ${opts.body.triggerPrice}`);
|
||||
return { data: {} };
|
||||
}
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
}, [A, B]);
|
||||
try {
|
||||
// The session lapses (Vest refused it) just after an entry filled.
|
||||
dropVestSession(WALLET, vestSessionFor(WALLET).accessToken);
|
||||
assert.equal(vestSessionFor(WALLET), null);
|
||||
const opening = await client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
|
||||
assert.equal(isOrderNotPlaced(opening), true);
|
||||
assert.match(opening.message, /no Vest session/);
|
||||
|
||||
const exits = client.placeOcoExit(A, NDX, 500, 'Sell', 20100, 19900);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
assert.deepEqual(legs, []);
|
||||
// The extension relays it again: the exits go, the stop first.
|
||||
tokens += 1;
|
||||
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
||||
await exits;
|
||||
assert.deepEqual(legs, ['stop-loss @ 19900', 'take-profit @ 20100']);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user