Vest: a claim that never reached Vest stops holding the account, and each claim counts once

A claim of unknown outcome now lapses where history is read (bookHistory),
not only in claimPending: its callers wait on historyReady, and the books
check held the account while it counted that claim, so one that never went
in held the account for good. The lapse is the same (no WITHDRAW within 30
min of it, a read an hour on) and is saved; an accepted claim still holds.

An account token that can't be minted means the request never went out:
an order, stop or take-profit reads as not placed, and a claim records no
unknown claim. Only the request's own failure may be "unknown".

A claim Vest dates on the other side of 20:00 ET than it was booked here
no longer leaves a second fund row: the history read takes the local day
back (and the latest fund date with it), and sameDayClaims counts the
claim on Vest's day. A claim Vest doesn't show yet stays on its day.

The report counts payouts one per claim (Vest's WITHDRAWs and the claims
they don't show yet, never fewer than the negative fund rows) for the
account list, payout states and the completion stage, where two claims on
one Vest day were one row. Same-day cycles still share a cycle key; that
needs the master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-27 14:14:38 -05:00
co-authored by Claude Opus 5.5
parent beddbdcf6c
commit 9fe7fc0ea3
7 changed files with 334 additions and 72 deletions
+133 -1
View File
@@ -1,6 +1,7 @@
/**
* Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's
* answers, naming accounts, Vest's trading day, market hours, volatility.
* answers and errors, naming accounts, Vest's trading day, profit-claim
* bookkeeping, market hours, volatility.
*
* Only Node built-ins are imported, so scripts/check-vest.mjs loads this file
* directly (node --experimental-strip-types).
@@ -52,6 +53,47 @@ export function formatDecimal(value: number, decimals: number): string {
return d > 0 ? fixed.replace(/\.?0+$/, '') || '0' : fixed;
}
// ── gateway errors ──────────────────────────────────────────────────────────
/** The gateway answered with an error status. */
export class VestApiError extends Error {
readonly status: number;
readonly code: string | undefined;
constructor(message: string, status: number, code: string | undefined) {
super(message);
this.name = 'VestApiError';
this.status = status;
this.code = code;
}
}
/** The request never went out: the account token it needed couldn't be had. */
export class VestNotSentError extends Error {
constructor(message: string) {
super(message);
this.name = 'VestNotSentError';
}
}
/** Whether a failed request proves nothing it asked for happened: it never
* went out, or the gateway refused it (a 4xx, but not a timeout). A
* transport failure or a 5xx may have gone in. */
export function nothingWentIn(err: unknown): boolean {
return err instanceof VestNotSentError
|| (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408);
}
/** The account token a request needs, from `mint`. Any failure to mint one —
* a timeout, a 5xx, an answer without a token — is the request not sent,
* never an outcome that could have gone in. */
export async function tokenForRequest(path: string, mint: () => Promise<string>): Promise<string> {
try {
return await mint();
} catch (err) {
throw new VestNotSentError(`${path} not sent — no account token: ${err instanceof Error ? err.message : String(err)}`);
}
}
// ── Vest's trading day ──────────────────────────────────────────────────────
const ET_PARTS = new Intl.DateTimeFormat('en-US', {
@@ -502,6 +544,96 @@ export function bookTxns(txns: VestTxn[]): { daily: Map<string, { pnl: number; f
return { daily, funds };
}
// ── profit claims ───────────────────────────────────────────────────────────
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
* within this long of it. */
export const CLAIM_MATCH_MS = 30 * 60_000;
/** A claim of unknown outcome (the request failed in transit) that no
* WITHDRAW matches in a history read this long after it never happened. */
export const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
/** A claim this client sent: the amount, when, and the balance it left.
* unknown = the request failed in transit, so it may or may not have gone
* in — it holds further claims, but nothing is booked on it. */
export interface OwnClaim { at: number; amount: number; base: number; unknown?: true }
/** A WITHDRAW transaction: when, and its (negative) amount. */
export interface VestWithdrawal { at: number; amount: number }
/** The WITHDRAW that shows the claim — its amount, within CLAIM_MATCH_MS,
* the nearest in time — or undefined while Vest's transactions don't. */
export function claimWithdrawal(withdrawals: VestWithdrawal[], c: OwnClaim): VestWithdrawal | undefined {
let best: VestWithdrawal | undefined;
for (const w of withdrawals) {
if (Math.abs(Math.abs(w.amount) - c.amount) >= 0.01 || Math.abs(w.at - c.at) >= CLAIM_MATCH_MS) continue;
if (!best || Math.abs(w.at - c.at) < Math.abs(best.at - c.at)) best = w;
}
return best;
}
/** The claims Vest's transactions, as read at `readAt`, don't show: those
* still pending, and those that lapse — of unknown outcome and still not
* shown an hour on, so never made. One Vest accepted never lapses. */
export function settleClaims(claims: OwnClaim[], withdrawals: VestWithdrawal[], readAt: number): { pending: OwnClaim[]; lapsed: OwnClaim[] } {
const pending: OwnClaim[] = [];
const lapsed: OwnClaim[] = [];
for (const c of claims) {
if (claimWithdrawal(withdrawals, c)) continue;
if (c.unknown && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS) lapsed.push(c);
else pending.push(c);
}
return { pending, lapsed };
}
/**
* The account's fund rows, one per Vest day: Vest's own (bookTxns' funds),
* plus each claim this client made that the transactions don't show yet, on
* the day it was booked. And the stored days to take back: a day a claim
* was booked on that Vest's transactions leave empty, its WITHDRAW being
* dated another day (the request spanned 20:00 ET, or the clocks differ) —
* kept, the payout would count twice.
*/
export function claimFundRows(o: {
funds: Map<string, number>; claims: OwnClaim[]; withdrawals: VestWithdrawal[]; stored: { date: string }[];
}): { rows: { date: string; amount: number }[]; dropped: string[] } {
const funds = new Map(o.funds);
const made = o.claims.filter((c) => !c.unknown);
for (const c of made) {
if (claimWithdrawal(o.withdrawals, c)) continue;
const day = vestDayOf(c.at);
funds.set(day, (funds.get(day) ?? 0) - c.amount);
}
const rows = [...funds.entries()]
.map(([date, amount]) => ({ date, amount: Math.round(amount * 100) / 100 }))
.filter((f) => f.amount !== 0)
.sort((x, y) => x.date.localeCompare(y.date));
const claimDays = new Set(made.map((c) => vestDayOf(c.at)));
const dropped = o.stored.map((f) => f.date).filter((d) => claimDays.has(d) && !rows.some((r) => r.date === d));
return { rows, dropped };
}
/** Payouts taken from the account, one per claim: every WITHDRAW Vest's
* transactions show (claims made by hand among them), and each claim of
* this client's they don't show yet — a fund row can't say, it holds a
* whole Vest day's. Never fewer than the negative fund rows, the count
* before: a day whose fund transactions net negative with no WITHDRAW
* still counts. */
export function payoutCount(withdrawals: VestWithdrawal[], claims: OwnClaim[], fundRows: { amount: number }[]): number {
const each = withdrawals.length + claims.filter((c) => !c.unknown && !claimWithdrawal(withdrawals, c)).length;
return Math.max(each, fundRows.filter((f) => f.amount < 0).length);
}
/** Payouts since `since` (the last 20:00 ET): this client's claims — each at
* its WITHDRAW's time once Vest shows it, Vest's day being the one it
* counts on — or the WITHDRAWs Vest shows, whichever counts more (a claim
* made by hand shows only there). */
export function payoutsSince(withdrawals: VestWithdrawal[], claims: OwnClaim[], since: number): number {
const mine = claims.filter((c) => !c.unknown && (claimWithdrawal(withdrawals, c)?.at ?? c.at) >= since).length;
const seen = withdrawals.filter((w) => w.at >= since).length;
return Math.max(mine, seen);
}
// ── market data ─────────────────────────────────────────────────────────────
/** Fewest hourly returns a volatility is measured from. */