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:
Brandon Li
2026-10-29 10:55:01 -05:00
co-authored by Claude Opus 5.5
parent bf7e147507
commit 0eb56b3949
8 changed files with 529 additions and 37 deletions
+145 -2
View File
@@ -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();
}
});