src/rest/account.ts
v0.2.1 · 5.9 KB
// Fail-closed decoding of `GET /api/v1/account?by=index&value=<account_index>`
// (knowledge base: account-and-leverage.md §1-3).
//
// - `accounts[0]` is the account; an empty array is a READ ERROR, not an empty account;
// - the sign of a position lives in `sign` (+-1), `position` is the magnitude: `size = sign * |position|`;
// - equity = `total_asset_value`; there is no free spot balance outside it;
// - `initial_margin_fraction` of a position is PERCENT (`"50.00"` = 2x), divide by 100;
// - a broken record (no symbol, bad sign/position, duplicate symbol, NaN equity) makes the WHOLE read
// untrusted: a truncated read is indistinguishable from an empty account.
import { leverageFromAccountFraction } from '../numbers/margin.js';
import { LighterReadError } from './errors.js';
export interface LighterPosition {
symbol: string;
marketId: number;
/** Signed size: negative = short. */
size: number;
/** `sign` as sent (+1 / -1). */
sign: number;
/** `position` as sent (magnitude). */
magnitude: number;
entryPrice: number;
/** Absolute notional (`position_value`). */
positionValue: number;
unrealizedPnl: number;
/** Raw `initial_margin_fraction` string in PERCENT (`"50.00"`). */
initialMarginFractionPercent: string;
/** `round(100 / initial_margin_fraction)`; garbage -> 1. */
leverage: number;
/** `margin_mode` `"1"` = isolated, anything else = cross. Isolated was not verified live. */
marginMode: 'cross' | 'isolated';
}
export interface LighterAccount {
/** `index` = account_index. */
index: number;
/** `l1_address`, lower-cased. Compare with the expected owner at startup; a mismatch is trading on someone else's account. */
l1Address: string;
/** Equity. */
totalAssetValue: number;
collateral: number;
availableBalance: number;
crossInitialMarginRequirement: number;
totalOrderCount: number;
/** Open (non-zero) positions by symbol. */
positions: Map<string, LighterPosition>;
}
const num = (v: unknown): number => {
if (typeof v === 'number') return v;
if (typeof v === 'string' && v.trim() !== '') return Number(v);
return Number.NaN;
};
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/** `sign * |position|`; NaN when either is not a finite number. */
export function signedPosition(sign: unknown, position: unknown): number {
const s = num(sign);
const p = num(position);
if (!Number.isFinite(s) || !Number.isFinite(p)) return Number.NaN;
return s < 0 ? -Math.abs(p) : Math.abs(p);
}
/**
* Decodes an account response. Throws {@link LighterReadError} on anything that makes the read
* untrustworthy; never returns a partial account.
*/
export function parseAccount(payload: unknown): LighterAccount {
const accounts = isRecord(payload) ? payload.accounts : undefined;
if (!Array.isArray(accounts) || accounts.length === 0) {
throw new LighterReadError('account: empty or missing accounts[] (a read error, not an empty account)');
}
const a = accounts[0];
if (!isRecord(a)) throw new LighterReadError('account: accounts[0] is not an object');
const totalAssetValue = num(a.total_asset_value);
if (!Number.isFinite(totalAssetValue)) throw new LighterReadError('account: total_asset_value is not a number');
const index = num(a.index);
const positions = new Map<string, LighterPosition>();
const raw = a.positions;
if (raw !== undefined && !Array.isArray(raw)) throw new LighterReadError('account: positions is not an array');
for (const p of Array.isArray(raw) ? raw : []) {
if (!isRecord(p)) throw new LighterReadError('account: position is not an object');
const symbol = typeof p.symbol === 'string' ? p.symbol.trim() : '';
if (!symbol) throw new LighterReadError('account: position without symbol');
const size = signedPosition(p.sign, p.position);
if (!Number.isFinite(size)) throw new LighterReadError(`account: position ${symbol} has broken sign/position`);
if (size === 0) continue;
if (positions.has(symbol)) throw new LighterReadError(`account: duplicate position ${symbol}`);
const imf =
typeof p.initial_margin_fraction === 'string'
? p.initial_margin_fraction
: String(p.initial_margin_fraction ?? '');
const entry = num(p.avg_entry_price);
const value = num(p.position_value);
const upnl = num(p.unrealized_pnl);
const marketId = num(p.market_id);
positions.set(symbol, {
symbol,
marketId: Number.isInteger(marketId) ? marketId : -1,
size,
sign: size < 0 ? -1 : 1,
magnitude: Math.abs(size),
entryPrice: Number.isFinite(entry) ? entry : 0,
positionValue: Number.isFinite(value) ? Math.abs(value) : 0,
unrealizedPnl: Number.isFinite(upnl) ? upnl : 0,
initialMarginFractionPercent: imf,
leverage: leverageFromAccountFraction(imf),
marginMode: String(p.margin_mode ?? '') === '1' ? 'isolated' : 'cross',
});
}
const finiteOr = (v: unknown, fallback: number) => {
const n = num(v);
return Number.isFinite(n) ? n : fallback;
};
return {
index: Number.isInteger(index) ? index : -1,
l1Address: typeof a.l1_address === 'string' ? a.l1_address.trim().toLowerCase() : '',
totalAssetValue,
collateral: finiteOr(a.collateral, 0),
availableBalance: finiteOr(a.available_balance, 0),
crossInitialMarginRequirement: finiteOr(a.cross_initial_margin_requirement, 0),
totalOrderCount: finiteOr(a.total_order_count, 0),
positions,
};
}
/**
* Preflight check of the account owner: the configured `account_index` must belong to the address the
* process believes it trades for. A mismatch is a startup refusal, not a warning.
*/
export function accountOwnerMatches(account: Pick<LighterAccount, 'l1Address'>, expectedAddress: string): boolean {
const expected = expectedAddress.trim().toLowerCase();
return expected !== '' && account.l1Address === expected;
}