Vest: a claim counts on the fresh day only once Vest dates it there, and a day's second cycle gets its own key

A claim's stage time is now taken before the request goes out (sentAt):
Vest dates it no earlier, so a claim answered just after 20:00 ET no longer
puts the fresh Vest day on stage 2 before the history read shows its
WITHDRAW. One Vest dates after the turn reads as the day before until then,
the cautious way round. Claims saved without it count from their answer.

Completions count one per payout on both paths (completionPlace): the
catch-up's stage no longer counts a Vest day's two claims as one, and a
cycle a day's second claim started carries cycleSeq, so it can be told from
that day's first. The master ignores it until it keys on it; old masters and
old instances are unchanged.

bookHistory's claim bookkeeping (WITHDRAWs, lapse, fund rows, the balance
the books check expects) is now the pure readClaims, tested on the case
that held an account for good: a claim that never went in, read an hour on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-27 14:39:13 -05:00
co-authored by Claude Opus 5.5
parent 9fe7fc0ea3
commit 35512cd504
9 changed files with 245 additions and 79 deletions
+50 -16
View File
@@ -553,10 +553,12 @@ export const CLAIM_MATCH_MS = 30 * 60_000;
* 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 claim this client sent: the amount, when (at = once it was answered;
* sentAt = before it went out, absent on claims saved before it was kept),
* 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; sentAt?: number; amount: number; base: number; unknown?: true }
/** A WITHDRAW transaction: when, and its (negative) amount. */
export interface VestWithdrawal { at: number; amount: number }
@@ -613,23 +615,55 @@ export function claimFundRows(o: {
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);
/**
* What a history read makes of this client's claims (VestClient.bookHistory):
* Vest's WITHDRAWs; the claims that lapse and those still outstanding
* (settleClaims); the fund rows and the stored days to take back
* (claimFundRows); and the balance the transactions add up to — `opening`
* (the capital of an account Vest funded without a deposit row) included,
* the claims they don't show yet taken out — that the books check holds the
* account to. A lapsed claim is out of it: counted, one that never went in
* would hold the account for good.
*/
export function readClaims(o: {
txns: VestTxn[]; funds: Map<string, number>; claims: OwnClaim[]; stored: { date: string }[]; readAt: number; opening: number;
}): {
withdrawals: VestWithdrawal[]; lapsed: OwnClaim[]; outstanding: OwnClaim[];
rows: { date: string; amount: number }[]; dropped: string[]; expected: number;
} {
const withdrawals = o.txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
const { pending, lapsed } = settleClaims(o.claims, withdrawals, o.readAt);
const { rows, dropped } = claimFundRows({ funds: o.funds, claims: o.claims.filter((c) => !lapsed.includes(c)), withdrawals, stored: o.stored });
const unbooked = pending.reduce((s, c) => s + c.amount, 0);
const expected = o.txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + o.opening - unbooked;
return { withdrawals, lapsed, outstanding: pending, rows, dropped, expected };
}
/** Each payout taken from the account, by its Vest day, one entry per
* payout: every WITHDRAW Vest's transactions show (claims made by hand
* among them), and each claim of this client's they don't show yet, on the
* day it was booked — a fund row can't say how many, it holds a whole Vest
* day's. A day whose fund rows net negative with neither still counts once,
* as it did before. Sorted. */
export function payoutDaysOf(withdrawals: VestWithdrawal[], claims: OwnClaim[], fundRows: { date: string; amount: number }[]): string[] {
const days = [
...withdrawals.map((w) => vestDayOf(w.at)),
...claims.filter((c) => !c.unknown && !claimWithdrawal(withdrawals, c)).map((c) => vestDayOf(c.at)),
];
for (const f of fundRows) if (f.amount < 0 && !days.includes(f.date)) days.push(f.date);
return days.sort();
}
/** 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). */
* counts on, and until then at when it was sent — or the WITHDRAWs Vest
* shows, whichever counts more (a claim made by hand shows only there).
* Sent, not answered: Vest dates it no earlier (clocks in sync), so a
* claim answered just after 20:00 ET can't put the fresh day on a payout
* stage before Vest says it's that day's; one Vest dates after the turn
* reads as the day before until then, the cautious way round. */
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 mine = claims.filter((c) => !c.unknown && (claimWithdrawal(withdrawals, c)?.at ?? c.sentAt ?? c.at) >= since).length;
const seen = withdrawals.filter((w) => w.at >= since).length;
return Math.max(mine, seen);
}