Vest: the relayed login binds itself to an unset Vest firm

A Vest firm's credential is the wallet of the login it trades. It no longer
has to be typed or clicked in: when the extension relays a Vest tab's
session and a Vest firm here has no credential, that wallet is saved as the
credential (lib/vest-wallet.ts), the client starts, and the backup is
pushed.

- A saved wallet is never replaced. When the tab is signed in to another
  login, the firm now says which one and how to switch: sign in as the
  saved wallet, or remove the credential.
- With two logins relayed at once nothing binds itself; the one-click form
  decides.
- Extension 0.17.1 re-posts the session every minute even when it hasn't
  changed. The dashboard holds it in memory only, so a restart there was
  unauthenticated until the tab's next renewal (up to ~15 min); now it is
  back within a minute, and a firm created later binds within a minute too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-26 14:42:01 -05:00
co-authored by Claude Opus 5.5
parent 96fdd9ca23
commit 769d96883f
8 changed files with 79 additions and 15 deletions
+6 -2
View File
@@ -223,8 +223,12 @@ instance cannot log in by itself. Instead:
2. The extension relays the tab's short-lived access token to this dashboard
(`POST /api/vest/session`, held in memory only) every time the Vest app
renews it. If no Vest tab is open, the extension opens one in the background.
3. On the dashboard, the Vest firm's credential is the **wallet address** of
that login. The credential form offers the relayed wallet with one click.
3. The Vest firm's credential on this dashboard is the **wallet address** of
that login, and it binds itself: the first relayed session fills it in when
the firm has none. A saved wallet is never replaced. If the tab is signed in
to a different login, the firm says so; remove its credential to switch.
With two logins relayed at once, nothing binds by itself, and the credential
form offers each relayed wallet with one click.
With no live session for the wallet, the client sits unauthenticated and says
so; it never tries to log in.
+7 -2
View File
@@ -1,5 +1,6 @@
import { NextRequest } from 'next/server';
import { putVestSession, listVestSessions } from '@/lib/vest-session';
import { autoBindVestWallet } from '@/lib/vest-wallet';
import { getTradableFirms } from '@/lib/firm-catalog';
import { getClients } from '@/lib/clients';
import { corsJson, corsPreflight } from '../../autobuyer/cors';
@@ -15,7 +16,10 @@ function wantedWallets(): string[] {
/**
* The extension's Vest relay (lib/vest-session.ts): POST the access token the
* signed-in Vest tab just renewed — { accessToken, expiresAt, primaryAddr,
* userId } — and it is held in memory for this machine's Vest client. GET
* userId } — and it is held in memory for this machine's Vest client. A Vest
* firm here with no credential yet is bound to that login's wallet
* (lib/vest-wallet.ts). The extension posts again every minute, so a restart
* here is back on the session within a minute. GET
* lists what has been relayed (never the tokens) and which wallets this
* machine wants, so the extension knows whether to keep a Vest tab open and
* the dashboard can offer the address as the firm's credential.
@@ -38,6 +42,7 @@ export async function POST(req: NextRequest) {
});
if (refused) return corsJson({ ok: false, reason: refused }, { status: 400 });
const bound = autoBindVestWallet();
const wanted = wantedWallets();
const addr = String(body.primaryAddr).toLowerCase();
// A client waiting on this wallet's session syncs now, not at its next poll.
@@ -45,7 +50,7 @@ export async function POST(req: NextRequest) {
const vest = client as { onVestSession?: (wallet: string) => void };
vest.onVestSession?.(addr);
}
return corsJson({ ok: true, used: wanted.includes(addr), wanted });
return corsJson({ ok: true, used: wanted.includes(addr), wanted, bound });
}
export async function GET() {
+6 -4
View File
@@ -209,9 +209,11 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
return <LoginForm firm={firm} onSaved={onSaved} />;
}
/** A Vest firm's credential: the wallet its Vest login belongs to. The
* extension has relayed the session of any Vest tab signed in on this
* machine, so those wallets are offered with one click. */
/** A Vest firm's credential: the wallet its Vest login belongs to. It binds
* itself as soon as the extension relays a Vest tab signed in on this
* machine (lib/vest-wallet.ts); this form is for when it can't — two logins
* relayed at once, or none yet — and offers the relayed wallets with one
* click. */
function VestWalletForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
const [wallet, setWallet] = useState('');
const [seen, setSeen] = useState<string[]>([]);
@@ -260,7 +262,7 @@ function VestWalletForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
))}
<input
className="border border-slate-200 rounded-md px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 w-72 font-mono"
placeholder={seen.length > 0 ? 'or another wallet 0x…' : 'Vest wallet 0x… (sign in to Vest in Chrome first)'}
placeholder={seen.length > 0 ? 'or another wallet 0x…' : 'Sign in to Vest in Chrome: it binds itself (or paste 0x…)'}
value={wallet}
onChange={(e) => setWallet(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && valid) void save(wallet); }}
+3 -2
View File
@@ -87,8 +87,9 @@ log in to Vest by itself. The extension relays the browser's session instead:
```
vest-relay.js (Vest tab, MAIN world) keeps the access token the Vest app last renewed
background.js relayVestSession lifts it every 10s, posts it when it changes
dashboard POST /api/vest/session holds it in memory for the firm's Vest client
background.js relayVestSession lifts it every 10s, posts it when it changes (and every minute)
dashboard POST /api/vest/session holds it in memory for the firm's Vest client,
and binds an unset Vest firm to the login's wallet
```
- The hook only reads the Vest app's own `/v3/auth/refresh` and `/register`
+9 -2
View File
@@ -1208,11 +1208,15 @@ async function tick() {
const VEST_PATTERN = 'https://next.vestmarkets.com/*';
const VEST_URL = 'https://next.vestmarkets.com/portfolio';
const VEST_EVERY_MS = 10_000;
/** The session is posted again this often even unchanged: the dashboard
* keeps it in memory only, so a restart there needs it back. */
const VEST_REPOST_MS = 60_000;
/** A Vest tab opened for the relay isn't opened again for this long. */
const VEST_REOPEN_MS = 10 * 60_000;
let vestLastCheck = 0;
let vestLastRelayed = '';
let vestLastPostAt = 0;
let vestOpenedAt = 0;
/** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while
@@ -1276,7 +1280,7 @@ async function relayVestSession(cfg) {
}
const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`;
if (fingerprint === vestLastRelayed) return;
if (fingerprint === vestLastRelayed && now - vestLastPostAt < VEST_REPOST_MS) return;
const res = await fetch(`${cfg.apiBase}/api/vest/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1288,9 +1292,12 @@ async function relayVestSession(cfg) {
if (res.status === 404) throw new Error('this dashboard is too old for the Vest relay');
if (!res.ok) throw new Error(`dashboard refused the session: ${body.reason || body.error || `HTTP ${res.status}`}`);
vestLastRelayed = fingerprint;
vestLastPostAt = now;
const short = `${found.primaryAddr.slice(0, 6)}…${found.primaryAddr.slice(-4)}`;
const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000));
await setState({ vest: `Relayed ${short}${body.used ? '' : ' (no Vest firm here uses this wallet)'} · good ${mins}m` });
const note = Array.isArray(body.bound) && body.bound.length > 0 ? ` · bound to ${body.bound.join(', ')}`
: body.used ? '' : ' (no Vest firm here uses this wallet)';
await setState({ vest: `Relayed ${short}${note} · good ${mins}m` });
}
// ── 2FA: the person's authenticator code for the firm site you are on ───────
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "AutoFirmer Capture",
"version": "0.17.0",
"version": "0.17.1",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
"update_url": "https://ext.juicerroom.com/updates.xml",
+5 -2
View File
@@ -49,7 +49,7 @@ 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 } from './vest-session';
import { vestSessionFor, dropVestSession, listVestSessions } from './vest-session';
import {
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal,
@@ -231,7 +231,10 @@ export class VestClient implements PlatformClient {
private userToken(): string | null {
const session = vestSessionFor(this.wallet, Date.now(), USER_TOKEN_MIN_MS);
if (session) return session.accessToken;
const msg = `no Vest session for ${this.wallet} — keep a Vest tab signed in to that wallet open in Chrome with the AutoFirmer extension`;
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`;
if (this.lastFetchErrors['login'] !== msg) console.warn(`${this.tag} ${msg}`);
this.lastAuthError = this.lastFetchErrors['login'] = msg;
return null;
+42
View File
@@ -0,0 +1,42 @@
/**
* Binds the Vest login the extension relays to this machine's Vest firm, so
* nobody has to copy a wallet address into the dashboard.
*
* A Vest firm's only credential here is the wallet of the login whose session
* it trades (lib/vest-session.ts). One person per machine, and Vest allows one
* profile per person, so the Vest tab signed in on this machine is the one to
* trade. It is bound automatically when:
* - the firm has no credential yet. A saved one is never replaced: someone
* chose it, and a different login in the tab is a mistake to flag (the
* client says so), not one to follow;
* - exactly one wallet has a live relayed session. Two logins in two tabs
* can't be told apart, so the dashboard's one-click form decides.
*/
import { getCachedCatalog } from './firm-catalog';
import { getCredentials, setCredentials } from './db';
import { listVestSessions } from './vest-session';
import { reconcileClients } from './clients';
import { scheduleBackupPush } from './backup-sync';
/** Bind the relayed wallet to every Vest firm here that has no credential.
* Returns the names of the firms it bound. */
export function autoBindVestWallet(now = Date.now()): string[] {
const live = [...new Set(listVestSessions().filter((s) => s.expiresAt > now).map((s) => s.primaryAddr))];
if (live.length !== 1) return [];
const wallet = live[0];
const bound: string[] = [];
for (const firm of getCachedCatalog()) {
if (firm.platform !== 'vest') continue;
const creds = getCredentials(firm.id);
if (creds && (creds.apiKey || creds.username || creds.password)) continue;
setCredentials(firm.id, firm.name, '', '', wallet);
bound.push(firm.name);
console.log(`[vest] ${firm.name}: bound to wallet ${wallet}, the Vest login signed in on this machine`);
}
if (bound.length > 0) {
reconcileClients();
scheduleBackupPush();
}
return bound;
}