@reefclaw/openclaw-plugin 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/bridge/gateway/event-parser.d.ts +19 -0
  2. package/bridge/gateway/event-parser.js +52 -0
  3. package/bridge/gateway/gateway-config.d.ts +16 -5
  4. package/bridge/gateway/gateway-config.js +68 -12
  5. package/bridge/gateway/heartbeat-cron.d.ts +1 -0
  6. package/bridge/gateway/heartbeat-cron.js +22 -1
  7. package/bridge/gateway/poller.js +18 -8
  8. package/bridge/providers/emergency-commands.d.ts +9 -1
  9. package/bridge/providers/emergency-commands.js +38 -1
  10. package/bridge/providers/gateway.d.ts +72 -1
  11. package/bridge/providers/gateway.js +241 -29
  12. package/bridge/providers/onboarding-commands.d.ts +8 -0
  13. package/bridge/providers/onboarding-commands.js +4 -4
  14. package/bridge/types.d.ts +30 -0
  15. package/bridge/utils/identity-name.d.ts +24 -0
  16. package/bridge/utils/identity-name.js +54 -0
  17. package/ccxt/binance-public.d.ts +21 -7
  18. package/ccxt/binance-public.js +70 -6
  19. package/config/operator-provenance.d.ts +6 -0
  20. package/config/operator-provenance.js +50 -0
  21. package/config/plugin-config-io.d.ts +15 -1
  22. package/config/plugin-config-io.js +24 -0
  23. package/index.js +216 -173
  24. package/ingest/event-loop-monitor.d.ts +11 -0
  25. package/ingest/event-loop-monitor.js +113 -0
  26. package/ingest/position-auto-capture.d.ts +5 -0
  27. package/ingest/position-auto-capture.js +14 -5
  28. package/ingest/readiness-reporter.d.ts +17 -6
  29. package/ingest/readiness-reporter.js +88 -9
  30. package/ingest/skill-version-reader.d.ts +16 -0
  31. package/ingest/skill-version-reader.js +64 -0
  32. package/live/approval-lifecycle.d.ts +30 -0
  33. package/live/approval-lifecycle.js +80 -0
  34. package/live/bracket-types.d.ts +9 -0
  35. package/live/live-adapter.d.ts +0 -1
  36. package/live/proposal-decision-listener.d.ts +20 -0
  37. package/live/proposal-decision-listener.js +211 -48
  38. package/onboarding/runtime.d.ts +34 -1
  39. package/onboarding/runtime.js +56 -5
  40. package/openclaw.plugin.json +1 -1
  41. package/package.json +2 -2
  42. package/simulator/exchange-simulator.d.ts +45 -2
  43. package/simulator/exchange-simulator.js +96 -4
  44. package/simulator/types.d.ts +17 -0
  45. package/tools/attach-brackets.js +50 -1
  46. package/tools/create-order.d.ts +11 -0
  47. package/tools/create-order.js +23 -2
  48. package/tools/get-risk-summary.d.ts +4 -0
  49. package/tools/get-risk-summary.js +62 -23
  50. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
  51. package/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
  52. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  53. package/venues/hyperliquid/hl-brackets.js +45 -13
  54. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  55. package/venues/hyperliquid/hl-fill-ingest.js +69 -0
  56. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  57. package/venues/hyperliquid/hl-live-adapter.js +155 -12
  58. package/venues/hyperliquid/hl-order.d.ts +35 -0
  59. package/venues/hyperliquid/hl-order.js +123 -0
  60. package/venues/hyperliquid/hl-position.d.ts +36 -0
  61. package/venues/hyperliquid/hl-position.js +127 -0
  62. package/venues/hyperliquid/hl-private.d.ts +20 -3
  63. package/venues/hyperliquid/hl-private.js +37 -6
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. package/venues/hyperliquid/hl-user-stream.js +4 -1
@@ -32,6 +32,10 @@ import { parseHlBracketCloid } from './hl-cloid.js';
32
32
  const TAG = 'hl-bracket-coordinator';
33
33
  const DEFAULT_MAX_ATTEMPTS = 3;
34
34
  const DEFAULT_BACKOFF = (attempt) => (attempt === 1 ? 1_000 : 2_000);
35
+ /** Cap on `extraEntryCids` so a position scaled many times cannot grow its
36
+ * ledger row without bound. Oldest dropped — a cid that old has either filled
37
+ * (row already resized) or been cancelled. */
38
+ const MAX_EXTRA_ENTRY_CIDS = 20;
35
39
  const TERMINAL_STATES = new Set([
36
40
  'triggered_sl',
37
41
  'triggered_tp',
@@ -46,10 +50,10 @@ const TRIGGERED_STATUSES = new Set(['filled', 'triggered']);
46
50
  /** A batch item HL rejected. ccxt types status as open|closed|canceled, but a
47
51
  * per-item rejection surfaces as an `error` entry in the raw batch response
48
52
  * (`info.error`) — check both shapes rather than trust the narrow type. */
49
- function isRejectedOrder(o) {
53
+ export function isRejectedOrder(o) {
50
54
  if (!o)
51
55
  return true;
52
- if (String(o.status ?? '') === 'rejected')
56
+ if (String(o.status ?? '').toLowerCase() === 'rejected')
53
57
  return true;
54
58
  const info = o.info;
55
59
  return Boolean(info && info.error);
@@ -109,6 +113,12 @@ export class HlBracketCoordinator extends EventEmitter {
109
113
  logger.warn(TAG, `registerEntry(${req.symbol}): non-terminal row exists (state=${existing.state}, ` +
110
114
  `bracketId=${existing.bracketId}) — duplicate fill signal, NOT clobbering ` +
111
115
  `(anti-clobber contract; scale-ins go through resizeToPosition)`);
116
+ // ★ F5: not clobbering the row is right — DROPPING the new order's cloid
117
+ // was not. A duplicate signal carrying a DIFFERENT cid is a second order
118
+ // against the same position (a resting scale-in, or a second entry while
119
+ // the first is still pending); its fill has to find this row or nothing
120
+ // attaches/resizes for it.
121
+ this.registerAdditionalEntryCid(req.symbol, entryCid);
112
122
  return;
113
123
  }
114
124
  if (existing)
@@ -129,6 +139,49 @@ export class HlBracketCoordinator extends EventEmitter {
129
139
  ts: new Date(this.now()).toISOString(),
130
140
  });
131
141
  }
142
+ /** ★ Audit 2026-07-26 F5 — track a SECOND entry order placed against a live
143
+ * bracket row (a scale-in, or another entry while the first still rests).
144
+ *
145
+ * Each submission gets a fresh cloid, but the row keeps the ORIGINAL
146
+ * `entryCid`. The user-stream fill handler matches fills to rows by cid, so
147
+ * an unrecorded cid meant the later fill matched NOTHING: no attach, no
148
+ * resize. On HL that is naked exposure, not cosmetic drift — legs are FIXED
149
+ * SIZE (T-2), so the added contracts stayed unprotected until the 60s
150
+ * truth-check sweep happened to catch them.
151
+ *
152
+ * Idempotent; a terminal/absent row or a repeat of the primary cid is a
153
+ * no-op. Recording is deliberately CHEAP and local — the sweep remains the
154
+ * backstop, this just stops it being the only line of defence. */
155
+ registerAdditionalEntryCid(symbol, entryCid) {
156
+ if (!entryCid)
157
+ return;
158
+ const row = this.ledger.getBySymbol(symbol);
159
+ if (!row || isTerminalBracketState(row.state))
160
+ return;
161
+ if (row.entryCid === entryCid)
162
+ return;
163
+ const current = row.extraEntryCids ?? [];
164
+ if (current.includes(entryCid))
165
+ return;
166
+ this.ledger.upsert({
167
+ ...row,
168
+ extraEntryCids: [...current, entryCid].slice(-MAX_EXTRA_ENTRY_CIDS),
169
+ });
170
+ logger.info(TAG, `${row.symbol}: tracking additional entry cid ${entryCid} against bracket ` +
171
+ `${row.bracketId} (state=${row.state}) — its fill must drive attach/resize (T-2)`);
172
+ }
173
+ /** The bracket row a user-stream fill belongs to: the primary `entryCid` OR
174
+ * any cid recorded by `registerAdditionalEntryCid`. Terminal rows never
175
+ * match. Single home for the matching rule so the adapter's fill handler
176
+ * and the ledger can never disagree about what "our entry" means. */
177
+ findRowByEntryCid(cloid) {
178
+ if (!cloid)
179
+ return undefined;
180
+ return this.ledger
181
+ .getAll()
182
+ .find((r) => !isTerminalBracketState(r.state) &&
183
+ (r.entryCid === cloid || r.extraEntryCids?.includes(cloid) === true));
184
+ }
132
185
  /** Attach both legs (ONE batched signed action) with retries. Idempotent on
133
186
  * a non-pending row. On exhaustion: ledger 'failed' + attach_failed event —
134
187
  * the ADAPTER escalates to auto-flatten (it owns closePosition). */
@@ -261,6 +314,10 @@ export class HlBracketCoordinator extends EventEmitter {
261
314
  symbol: entry.symbol,
262
315
  positionSide,
263
316
  positionSize: newPositionSize,
317
+ // F4: with the registered prices in hand, resize can REBUILD a leg that
318
+ // vanished entirely (e.g. a stripped stop) instead of no-oping while
319
+ // the coverage sweep warns forever.
320
+ registeredPrices: { stop: entry.stopPrice, target: entry.targetPrice },
264
321
  });
265
322
  if (res.resized) {
266
323
  this.ledger.markState(symbol, entry.state, {
@@ -87,6 +87,16 @@ export declare function planResize(args: {
87
87
  positionSide: 'long' | 'short';
88
88
  positionSize: number;
89
89
  liveLegs: LiveLeg[];
90
+ /** The prices REGISTERED at attach (the ledger row's stopPrice/targetPrice).
91
+ * ★ Audit 2026-07-26 F4: a role whose leg vanished entirely used to be
92
+ * "the attach path's job" — but no path owned it, so a stop-less position
93
+ * never self-healed. With the registered price in hand, resize REBUILDS
94
+ * the missing leg at the price the agent pinned; without one it cannot
95
+ * invent a level and leaves the role to the coverage alarms. */
96
+ registeredPrices?: {
97
+ stop?: number;
98
+ target?: number;
99
+ };
90
100
  }): ResizePlan;
91
101
  /** Is this exchange order one of OUR protective legs?
92
102
  *
@@ -84,6 +84,11 @@ export function bracketCoversPosition(args) {
84
84
  const legs = args.liveLegs.filter((l) => l.role === role);
85
85
  if (legs.length === 0) {
86
86
  missing.push(role);
87
+ // A role with no leg at all leaves the WHOLE position uncovered on that
88
+ // side — report the honest naked size. Leaving this at 0 was audit
89
+ // 2026-07-26 F4: the resize planner keyed on shortfall, saw zero, and
90
+ // no-oped forever while a stop-less position stayed naked.
91
+ shortfall[role] = args.positionSize;
87
92
  continue;
88
93
  }
89
94
  const coveredSize = legs.reduce((sum, l) => sum + l.size, 0);
@@ -91,12 +96,15 @@ export function bracketCoversPosition(args) {
91
96
  if (gap > COVERAGE_EPSILON)
92
97
  shortfall[role] = gap;
93
98
  }
94
- const covered = shortfall.stop <= COVERAGE_EPSILON &&
95
- shortfall.target <= COVERAGE_EPSILON &&
96
- // A missing STOP is never "covered" that is the naked-position case. A
97
- // missing TARGET is a policy choice (requireTakeProfit can be off), so it is
98
- // reported in `missing` but does not by itself fail coverage.
99
- !missing.includes('stop');
99
+ const covered =
100
+ // A missing STOP is never "covered" — that is the naked-position case
101
+ // (its shortfall is the full position size, so this arm also fails).
102
+ !missing.includes('stop') &&
103
+ shortfall.stop <= COVERAGE_EPSILON &&
104
+ // A missing TARGET is a policy choice (requireTakeProfit can be off): it
105
+ // is reported via `missing` + `shortfall` but does not by itself fail
106
+ // coverage. A PRESENT-but-undersized target still does.
107
+ (missing.includes('target') || shortfall.target <= COVERAGE_EPSILON);
100
108
  return { covered, shortfall, missing };
101
109
  }
102
110
  /** ★ Resize protective legs after a scale-in (or any position-size change).
@@ -120,20 +128,44 @@ export function planResize(args) {
120
128
  positionSize: args.positionSize,
121
129
  liveLegs: args.liveLegs,
122
130
  });
123
- // Nothing to do when every present leg already covers the position exactly.
131
+ const healable = ['stop', 'target'].filter((role) => {
132
+ const price = args.registeredPrices?.[role];
133
+ return (coverage.missing.includes(role) &&
134
+ typeof price === 'number' && Number.isFinite(price) && price > 0);
135
+ });
136
+ // Nothing to do when every present leg already covers the position exactly
137
+ // and no vanished-but-registered leg needs rebuilding. (A missing role's
138
+ // shortfall is the full position size, so present-leg sizing is checked
139
+ // against the PRESENT legs only.)
140
+ const presentShortfall = ['stop', 'target'].some((role) => !coverage.missing.includes(role) && coverage.shortfall[role] > COVERAGE_EPSILON);
124
141
  const oversized = args.liveLegs.some((l) => l.size - args.positionSize > COVERAGE_EPSILON);
125
- if (coverage.shortfall.stop <= COVERAGE_EPSILON &&
126
- coverage.shortfall.target <= COVERAGE_EPSILON &&
127
- !oversized) {
142
+ if (!presentShortfall && !oversized && healable.length === 0) {
128
143
  return { cancelCloids: [], submit: [], noop: true };
129
144
  }
130
- // Rebuild every PRESENT role at the correct size, preserving its trigger price.
145
+ // Rebuild every PRESENT role at the correct size (preserving its trigger
146
+ // price), plus every HEALABLE missing role at its registered price.
131
147
  const legs = [];
132
148
  const cancelCloids = [];
133
149
  for (const role of ['stop', 'target']) {
134
150
  const existing = args.liveLegs.filter((l) => l.role === role);
135
- if (existing.length === 0)
136
- continue; // absent leg = attach path's job, not resize's
151
+ if (existing.length === 0) {
152
+ if (!healable.includes(role))
153
+ continue; // no leg, no registered price — coverage alarms own it
154
+ legs.push({
155
+ role,
156
+ triggerPrice: args.registeredPrices[role],
157
+ size: args.positionSize,
158
+ cloid: buildHlBracketCloid(args.bracketId, role),
159
+ });
160
+ continue;
161
+ }
162
+ // A present role that already covers exactly (and isn't oversized) is left
163
+ // untouched — a heal of the OTHER role must not churn a healthy leg.
164
+ const coveredSize = existing.reduce((sum, l) => sum + l.size, 0);
165
+ const roleHealthy = args.positionSize - coveredSize <= COVERAGE_EPSILON &&
166
+ !existing.some((l) => l.size - args.positionSize > COVERAGE_EPSILON);
167
+ if (roleHealthy)
168
+ continue;
137
169
  // Trigger price is preserved from the live leg (the agent's pinned plan) —
138
170
  // resizing must NEVER silently move a stop.
139
171
  const triggerPrice = existing[0].triggerPrice;
@@ -0,0 +1,18 @@
1
+ import type { FillEvent } from '@reefclaw/shared';
2
+ import type { HlFillEvent } from './hl-user-stream.js';
3
+ /** Inverse of shared `toHyperliquidCoin`: every HL perp is USDC-quoted, so the
4
+ * canonical symbol is `<coin>/USDC` with the coin's case preserved (kPEPE). */
5
+ export declare function hlCoinToCanonical(coin: string): string;
6
+ /**
7
+ * Map one HL fill to the shared FillEvent contract.
8
+ *
9
+ * Returns null (with a warn) when the identity fields the audit trail's
10
+ * idempotency key needs are absent or garbled — a row we cannot key must be
11
+ * dropped, never fabricated. `exchange` MUST come from
12
+ * `fillExchangeId('hyperliquid')` (FILL_EXCHANGE_ID strings are frozen once
13
+ * rows exist) — this mapper trusts the wiring, it does not mint literals.
14
+ */
15
+ export declare function hlFillToFillEvent(fill: Partial<HlFillEvent>, wiring: {
16
+ userId: string;
17
+ exchange: string;
18
+ }, source: 'ws' | 'rest_reconcile'): FillEvent | null;
@@ -0,0 +1,69 @@
1
+ // Per-fill audit-trail ingest for the Hyperliquid venue (audit 2026-07-26 F26).
2
+ //
3
+ // Binance fills reach the `trades` table via ws-ingest (ORDER_TRADE_UPDATE →
4
+ // FillEvent → TradeStoreClient). HL's user stream had no equivalent: fills
5
+ // drove bracket wiring and were then DROPPED — a live book and P&L with no
6
+ // independent per-trade record, nothing to reconcile against when a number
7
+ // looks wrong, and fills during a WS gap lost forever. This module is the HL
8
+ // analog: a pure mapper from the WS fill shape (also satisfied by REST
9
+ // `userFillsByTime` rows) to the shared FillEvent contract. The adapter calls
10
+ // it from the WS hot path and from the reconnect gap backfill; idempotency is
11
+ // the server-side (exchange, exchange_trade_id) upsert, so double-delivery
12
+ // between the two paths is harmless by design.
13
+ import { logger } from '../../logger.js';
14
+ const TAG = 'hl-fill-ingest';
15
+ /** Inverse of shared `toHyperliquidCoin`: every HL perp is USDC-quoted, so the
16
+ * canonical symbol is `<coin>/USDC` with the coin's case preserved (kPEPE). */
17
+ export function hlCoinToCanonical(coin) {
18
+ return `${coin}/USDC`;
19
+ }
20
+ const num = (v) => {
21
+ const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : Number.NaN;
22
+ return Number.isFinite(n) ? n : undefined;
23
+ };
24
+ /**
25
+ * Map one HL fill to the shared FillEvent contract.
26
+ *
27
+ * Returns null (with a warn) when the identity fields the audit trail's
28
+ * idempotency key needs are absent or garbled — a row we cannot key must be
29
+ * dropped, never fabricated. `exchange` MUST come from
30
+ * `fillExchangeId('hyperliquid')` (FILL_EXCHANGE_ID strings are frozen once
31
+ * rows exist) — this mapper trusts the wiring, it does not mint literals.
32
+ */
33
+ export function hlFillToFillEvent(fill, wiring, source) {
34
+ const price = num(fill.px);
35
+ const quantity = num(fill.sz);
36
+ const time = num(fill.time);
37
+ if (typeof fill.tid !== 'number' || !Number.isFinite(fill.tid) ||
38
+ typeof fill.oid !== 'number' || !Number.isFinite(fill.oid) ||
39
+ typeof fill.coin !== 'string' || fill.coin.length === 0 ||
40
+ price === undefined || quantity === undefined || quantity <= 0 ||
41
+ time === undefined ||
42
+ (fill.side !== 'A' && fill.side !== 'B')) {
43
+ logger.warn(TAG, `Dropping unmappable HL fill (source=${source}): ${JSON.stringify(fill).slice(0, 200)}`);
44
+ return null;
45
+ }
46
+ return {
47
+ exchange: wiring.exchange,
48
+ exchangeTradeId: String(fill.tid),
49
+ exchangeOrderId: String(fill.oid),
50
+ clientOrderId: typeof fill.cloid === 'string' && fill.cloid.length > 0 ? fill.cloid : undefined,
51
+ source,
52
+ userId: wiring.userId,
53
+ symbol: hlCoinToCanonical(fill.coin),
54
+ side: fill.side === 'B' ? 'BUY' : 'SELL',
55
+ quantity,
56
+ price,
57
+ // HL `crossed` is the taker flag; maker is its inverse. Absent → unknown.
58
+ maker: typeof fill.crossed === 'boolean' ? !fill.crossed : undefined,
59
+ // `fee` is inclusive of builderFee, and we hard-disable the builder fee
60
+ // (options.builderFee:false, pinned by hl-private.test.ts) so this is the
61
+ // whole commission either way. HL fees settle in USDC unless feeToken says
62
+ // otherwise.
63
+ commission: num(fill.fee),
64
+ commissionAsset: typeof fill.feeToken === 'string' ? fill.feeToken : 'USDC',
65
+ realizedPnl: num(fill.closedPnl),
66
+ exchangeTime: time,
67
+ rawPayload: fill,
68
+ };
69
+ }
@@ -6,10 +6,23 @@ import { type HlCredentials } from './hl-private.js';
6
6
  import type { BracketId } from '../../live/bracket-types.js';
7
7
  import { BracketLedger } from '../../live/bracket-ledger.js';
8
8
  import { HlBracketCoordinator } from './hl-bracket-coordinator.js';
9
+ import type { TradeIngestWiring } from '../../live/live-adapter.js';
9
10
  export interface HlLiveAdapterOptions {
10
11
  credentials: HlCredentials;
11
12
  mode: TradingMode;
12
13
  marketSlippagePct?: number;
14
+ /** MICRO_LIVE per-order notional cap in quote-USD (USDC here). Defaults to
15
+ * $50 in MICRO_LIVE — the same default Binance's LiveAdapter has enforced
16
+ * since the mode existed. HL shipped Phase 3 without any cap, making
17
+ * "micro" a label (audit 2026-07-26 F8). Ignored in LIVE. */
18
+ microLive?: {
19
+ maxPositionUSDT?: number;
20
+ };
21
+ /** Audit-trail wiring (audit 2026-07-26 F26): when present, every user-stream
22
+ * fill is POSTed to /api/internal/trades and reconnect gaps are backfilled
23
+ * via userFillsByTime. Same object boot passes the Binance adapter —
24
+ * `exchange` MUST be fillExchangeId('hyperliquid'). */
25
+ tradeIngest?: TradeIngestWiring;
13
26
  /** Test seams. Production omits both. */
14
27
  bracketLedger?: BracketLedger;
15
28
  disableUserStream?: boolean;
@@ -30,6 +43,13 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
30
43
  private truthCheckRunning;
31
44
  private _readiness;
32
45
  private openOrdersUnavailableUntil;
46
+ /** MICRO_LIVE per-order notional cap (USD). null = LIVE, uncapped. */
47
+ private readonly maxPositionUsd;
48
+ /** Resolved audit-trail wiring (F26). undefined = ingest not configured. */
49
+ private readonly fillIngest?;
50
+ /** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
51
+ * low-water mark the reconnect gap backfill widens from. */
52
+ private lastFillIngestMs;
33
53
  /** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
34
54
  * income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
35
55
  * each balance fetch. Without this the skill self-computes a bogus anchor and
@@ -76,6 +96,10 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
76
96
  * ★ null ≠ empty: a FAILED position fetch throws (state unknown — closing
77
97
  * against an unknown book could open a NEW position in the opposite direction);
78
98
  * a CONFIRMED-flat account is a benign no-op.
99
+ *
100
+ * ★ Success requires CONFIRMED flat: brackets/ledger are only cleaned up after
101
+ * a post-close position read shows zero — a rejected/partial IOC throws with
102
+ * protection left in place.
79
103
  */
80
104
  closePosition(symbol: string, _closeReason?: CloseReason): Promise<CcxtOrder>;
81
105
  getBalance(): Promise<CcxtBalance>;
@@ -128,6 +152,10 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
128
152
  symbol: string;
129
153
  positionSide: 'long' | 'short';
130
154
  positionSize: number;
155
+ registeredPrices?: {
156
+ stop?: number;
157
+ target?: number;
158
+ };
131
159
  }): Promise<{
132
160
  resized: boolean;
133
161
  slCid?: string;
@@ -143,6 +171,14 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
143
171
  * cleanup before a fresh attach). THROWS when order state is unknown —
144
172
  * the caller logs and still attaches (protection beats hygiene). */
145
173
  cancelSymbolBracketLegs(symbol: string): Promise<number>;
174
+ /** Audit-trail ingest (F26): fire-and-forget POST of one fill. The client
175
+ * never blocks the WS hot path; the server-side (exchange, trade id) upsert
176
+ * makes WS/backfill double-delivery a no-op. */
177
+ private ingestFill;
178
+ /** Reconnect gap backfill (F26): the WS replays NOTHING (T-5), so fills that
179
+ * landed while the socket was down exist ONLY via REST. Over-fetch with a
180
+ * 60s overlap is harmless (idempotent upsert); under-fetch loses audit rows. */
181
+ private backfillFillGap;
146
182
  /** Entry fills drive attach (resting limits) / resize (partial-fill growth).
147
183
  * `startPosition` is the position BEFORE this fill — the WS-authoritative
148
184
  * way to know the after-fill total without an extra REST read. */
@@ -36,8 +36,9 @@ import { buildHlOrderCloid, parseHlBracketCloid } from './hl-cloid.js';
36
36
  import { BracketLedger } from '../../live/bracket-ledger.js';
37
37
  import { generateBracketId } from '../../live/bracket-id.js';
38
38
  import { validateStopDirection, validateTargetDirection } from '../../live/bracket-params.js';
39
- import { HlBracketCoordinator, isTerminalBracketState } from './hl-bracket-coordinator.js';
39
+ import { HlBracketCoordinator, isRejectedOrder, isTerminalBracketState, } from './hl-bracket-coordinator.js';
40
40
  import { HyperliquidUserStream } from './hl-user-stream.js';
41
+ import { hlFillToFillEvent } from './hl-fill-ingest.js';
41
42
  import { formatError } from '../../logger.js';
42
43
  const TAG = 'hl-live-adapter';
43
44
  /** Venue-distinct ledger storage — a venue switch on the same box must never
@@ -70,6 +71,13 @@ export class HyperliquidLiveAdapter extends EventEmitter {
70
71
  truthCheckRunning = false;
71
72
  _readiness = 'INIT_PENDING';
72
73
  openOrdersUnavailableUntil = 0;
74
+ /** MICRO_LIVE per-order notional cap (USD). null = LIVE, uncapped. */
75
+ maxPositionUsd;
76
+ /** Resolved audit-trail wiring (F26). undefined = ingest not configured. */
77
+ fillIngest;
78
+ /** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
79
+ * low-water mark the reconnect gap backfill widens from. */
80
+ lastFillIngestMs = 0;
73
81
  /** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
74
82
  * income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
75
83
  * each balance fetch. Without this the skill self-computes a bogus anchor and
@@ -81,6 +89,23 @@ export class HyperliquidLiveAdapter extends EventEmitter {
81
89
  this.api = new HyperliquidPrivateApi(opts.credentials);
82
90
  this.publicApi = new HyperliquidPublicApi({ testnet: opts.credentials.testnet });
83
91
  this.slippagePct = clampSlippage(opts.marketSlippagePct ?? DEFAULT_MARKET_SLIPPAGE);
92
+ this.maxPositionUsd =
93
+ opts.mode === 'MICRO_LIVE' ? opts.microLive?.maxPositionUSDT ?? 50 : null;
94
+ if (opts.tradeIngest) {
95
+ if (opts.tradeIngest.exchange) {
96
+ this.fillIngest = {
97
+ client: opts.tradeIngest.client,
98
+ userId: opts.tradeIngest.userId,
99
+ exchange: opts.tradeIngest.exchange,
100
+ };
101
+ logger.info(TAG, 'Per-fill audit-trail ingest wired (WS + reconnect gap backfill)');
102
+ }
103
+ else {
104
+ // FILL_EXCHANGE_ID strings are frozen once rows exist — refusing beats
105
+ // minting rows under a guessed exchange id.
106
+ logger.warn(TAG, 'tradeIngest provided WITHOUT an exchange id — fill ingest disabled');
107
+ }
108
+ }
84
109
  this.infoCache = new HyperliquidInfoCache(async () => {
85
110
  // `meta` is a keyless info read — the public client owns it.
86
111
  return this.publicApi.fetchMeta();
@@ -109,7 +134,8 @@ export class HyperliquidLiveAdapter extends EventEmitter {
109
134
  const rules = await this.infoCache.load();
110
135
  if (markets && rules && this.infoCache.size > 0) {
111
136
  this._readiness = 'READY';
112
- logger.info(TAG, `HL live adapter READY (${this.infoCache.size} assets, mode=${this.opts.mode})`);
137
+ logger.info(TAG, `HL live adapter READY (${this.infoCache.size} assets, mode=${this.opts.mode}` +
138
+ `${this.maxPositionUsd != null ? `, micro cap $${this.maxPositionUsd}/order` : ''})`);
113
139
  }
114
140
  else {
115
141
  // DEGRADED, not BLOCKED: reads/exits still work; entries are refused
@@ -135,13 +161,17 @@ export class HyperliquidLiveAdapter extends EventEmitter {
135
161
  walletAddress: this.opts.credentials.walletAddress,
136
162
  testnet: this.opts.credentials.testnet,
137
163
  callbacks: {
138
- onFill: (fill) => this.onUserFill(fill),
164
+ onFill: (fill, meta) => this.onUserFill(fill, meta),
139
165
  onOrderUpdate: (update) => this.onUserOrderUpdate(update),
140
166
  onUserEvent: () => {
141
167
  /* liquidation/funding — liquidation fills also arrive via onFill */
142
168
  },
143
169
  onResyncNeeded: (window) => {
144
170
  void this.runTruthCheck(`ws_resync_blind_${Math.round(window.wasDisconnectedMs / 1000)}s`);
171
+ // Fills that landed inside the blind window exist only via REST
172
+ // (T-5: the WS replays nothing) — recover them for the audit
173
+ // trail (F26). Bracket state is healed by the truth-check above.
174
+ void this.backfillFillGap(window.sinceMs).catch((err) => logger.warn(TAG, `Fill gap backfill threw: ${msg(err)}`));
145
175
  },
146
176
  },
147
177
  });
@@ -183,6 +213,21 @@ export class HyperliquidLiveAdapter extends EventEmitter {
183
213
  }
184
214
  referencePrice = mark;
185
215
  }
216
+ // ---- Micro-live notional cap (audit 2026-07-26 F8) ----
217
+ // Same semantics as Binance's LiveAdapter: clamp NEW exposure to the cap;
218
+ // never touch risk-reducing orders (blocking a close is worse than an
219
+ // uncapped close, and reduce-only cannot increase the position). A
220
+ // reference price is always in hand by this point — market orders fetched
221
+ // the mark above, limit orders carry their own price.
222
+ let effectiveAmount = amount;
223
+ if (this.maxPositionUsd != null && !options?.reduceOnly) {
224
+ const maxAmount = this.maxPositionUsd / referencePrice;
225
+ if (effectiveAmount > maxAmount) {
226
+ logger.info(TAG, `Micro-live cap: reducing ${symbol} amount ${effectiveAmount} → ${maxAmount} ` +
227
+ `(max $${this.maxPositionUsd} notional at ref ${referencePrice})`);
228
+ effectiveAmount = maxAmount;
229
+ }
230
+ }
186
231
  const submitPrice = type === 'market'
187
232
  ? this.infoCache.marketPrice({
188
233
  symbol,
@@ -193,7 +238,7 @@ export class HyperliquidLiveAdapter extends EventEmitter {
193
238
  : this.infoCache.roundPrice(symbol, referencePrice, side === 'buy' ? 'down' : 'up');
194
239
  const check = this.infoCache.validate({
195
240
  symbol,
196
- amount,
241
+ amount: effectiveAmount,
197
242
  side,
198
243
  price: type === 'limit' ? submitPrice : undefined,
199
244
  referencePrice,
@@ -303,6 +348,13 @@ export class HyperliquidLiveAdapter extends EventEmitter {
303
348
  const ledger = this.getHlBracketCoordinator().getLedger();
304
349
  const existing = ledger.getBySymbol(symbol);
305
350
  if (existing && !isTerminalBracketState(existing.state) && existing.state !== 'pending_entry') {
351
+ // ★ F5: record THIS order's cloid against the live row FIRST. A scale-in
352
+ // submitted as a resting limit fills later, and `onUserFill` matches
353
+ // fills to rows by cid — without this its fill matched nothing, so no
354
+ // resize ever ran and the added contracts stayed NAKED (T-2: legs are
355
+ // fixed size) until the 60s truth-check sweep. Registered before the
356
+ // resize below so a fast fill can never race ahead of the bookkeeping.
357
+ this.getHlBracketCoordinator().registerAdditionalEntryCid(symbol, entryCloid);
306
358
  if (isFilled) {
307
359
  void this.resizeAfterScaleInAsync(symbol);
308
360
  }
@@ -420,6 +472,10 @@ export class HyperliquidLiveAdapter extends EventEmitter {
420
472
  * ★ null ≠ empty: a FAILED position fetch throws (state unknown — closing
421
473
  * against an unknown book could open a NEW position in the opposite direction);
422
474
  * a CONFIRMED-flat account is a benign no-op.
475
+ *
476
+ * ★ Success requires CONFIRMED flat: brackets/ledger are only cleaned up after
477
+ * a post-close position read shows zero — a rejected/partial IOC throws with
478
+ * protection left in place.
423
479
  */
424
480
  async closePosition(symbol, _closeReason) {
425
481
  const positions = await this.api.fetchPositions(symbol);
@@ -456,10 +512,34 @@ export class HyperliquidLiveAdapter extends EventEmitter {
456
512
  });
457
513
  if (!order)
458
514
  throw new Error(`closePosition(${symbol}) returned no order`);
459
- // Ledger hygiene: mark the bracket row terminal. T-1 auto-cancels the
460
- // exchange legs when the position goes flat, so this is bookkeeping (plus
461
- // a harmless defensive cancel), never load-bearing. Fire-and-forget — a
462
- // close must never fail on ledger cleanup.
515
+ if (isRejectedOrder(order)) {
516
+ throw new Error(`closePosition(${symbol}): close order REJECTED by the exchange — position still open, ` +
517
+ 'protection left in place. Retry the close.');
518
+ }
519
+ // ★ An IOC acknowledgement is NOT a fill: the slippage-bounded close can
520
+ // partially fill (or fill nothing) in a fast market. Confirm flat from
521
+ // exchange truth BEFORE touching protection — cancelling the brackets on a
522
+ // live residual would strip its only stop (no watcher fallback on HL live)
523
+ // AND terminalize the ledger row, hiding the residual from the truth-check
524
+ // sweep forever (audit 2026-07-26 F3).
525
+ const after = await this.api.fetchPositions(symbol);
526
+ if (after === null) {
527
+ logger.warn(TAG, `closePosition(${symbol}): post-close position read FAILED — flat UNCONFIRMED; leaving ` +
528
+ 'protection + ledger row in place (T-1 auto-cancels legs on flat; the truth-check ' +
529
+ 'sweep reconciles the row either way)');
530
+ return order;
531
+ }
532
+ const residualPos = after.find((p) => p.symbol?.startsWith(symbol.split(':')[0]));
533
+ const residual = Math.abs(Number(residualPos?.contracts ?? 0));
534
+ if (residual > 0) {
535
+ throw new Error(`closePosition(${symbol}): IOC close did NOT fully fill — residual ${residual} still open, ` +
536
+ 'protection left in place (the truth-check sweep resizes the legs to the residual). ' +
537
+ 'Retry the close.');
538
+ }
539
+ // Confirmed flat. Ledger hygiene: mark the bracket row terminal. T-1
540
+ // auto-cancels the exchange legs when the position goes flat, so this is
541
+ // bookkeeping (plus a harmless defensive cancel), never load-bearing.
542
+ // Fire-and-forget — a close must never fail on ledger cleanup.
463
543
  const row = this.getHlBracketCoordinator().getLedger().getBySymbol(symbol);
464
544
  if (row && !isTerminalBracketState(row.state)) {
465
545
  void this.getHlBracketCoordinator()
@@ -608,6 +688,16 @@ export class HyperliquidLiveAdapter extends EventEmitter {
608
688
  // added size stays naked until the caller retries (truth-check sweep).
609
689
  throw new Error(`resizeBrackets(${args.symbol}): submission returned nothing — old legs left in place`);
610
690
  }
691
+ const rejected = submitted.filter((order) => isRejectedOrder(order));
692
+ if (submitted.length !== plan.submit.length || rejected.length > 0) {
693
+ // A batch-level acknowledgement does not mean every leg was accepted:
694
+ // Hyperliquid reports individual failures in each order's raw
695
+ // `info.error`. Keep every known-good old leg live and leave the ledger
696
+ // untouched so the truth-check sweep can retry with fresh cloids.
697
+ throw new Error(`resizeBrackets(${args.symbol}): batch returned ${submitted.length}/${plan.submit.length} legs` +
698
+ (rejected.length > 0 ? ` (${rejected.length} rejected)` : '') +
699
+ ' — old legs left in place');
700
+ }
611
701
  for (const cloid of plan.cancelCloids) {
612
702
  // Register BEFORE the request: the WS 'canceled' event can beat the
613
703
  // ledger update and must not read as stripped protection (canary).
@@ -671,14 +761,67 @@ export class HyperliquidLiveAdapter extends EventEmitter {
671
761
  return cancelled;
672
762
  }
673
763
  // ---- User-stream handlers (issue #209 wiring) ----
764
+ /** Audit-trail ingest (F26): fire-and-forget POST of one fill. The client
765
+ * never blocks the WS hot path; the server-side (exchange, trade id) upsert
766
+ * makes WS/backfill double-delivery a no-op. */
767
+ ingestFill(fill, source) {
768
+ if (!this.fillIngest)
769
+ return false;
770
+ const event = hlFillToFillEvent(fill, this.fillIngest, source);
771
+ if (!event)
772
+ return false;
773
+ this.fillIngest.client.post(this.fillIngest.userId, event);
774
+ if (event.exchangeTime > this.lastFillIngestMs)
775
+ this.lastFillIngestMs = event.exchangeTime;
776
+ return true;
777
+ }
778
+ /** Reconnect gap backfill (F26): the WS replays NOTHING (T-5), so fills that
779
+ * landed while the socket was down exist ONLY via REST. Over-fetch with a
780
+ * 60s overlap is harmless (idempotent upsert); under-fetch loses audit rows. */
781
+ async backfillFillGap(sinceMs) {
782
+ if (!this.fillIngest)
783
+ return;
784
+ const floor = Date.now() - 24 * 3600_000; // never sweep more than a day
785
+ const from = Math.max(floor, Math.min(sinceMs, this.lastFillIngestMs > 0 ? this.lastFillIngestMs : Number.POSITIVE_INFINITY) - 60_000);
786
+ const fills = await this.api.fetchFillsSince(from);
787
+ if (fills === null) {
788
+ // null ≠ empty: the fetch FAILED — the gap stays open, the next resync
789
+ // (or the periodic truth-check path) retries. Never treat as "no fills".
790
+ logger.warn(TAG, `Fill gap backfill fetch FAILED (since=${new Date(from).toISOString()}) — retried on next resync`);
791
+ return;
792
+ }
793
+ let posted = 0;
794
+ for (const f of fills) {
795
+ if (this.ingestFill(f, 'rest_reconcile'))
796
+ posted++;
797
+ }
798
+ if (fills.length > 0) {
799
+ logger.info(TAG, `Fill gap backfill: posted ${posted}/${fills.length} fill(s) since ${new Date(from).toISOString()}`);
800
+ }
801
+ }
674
802
  /** Entry fills drive attach (resting limits) / resize (partial-fill growth).
675
803
  * `startPosition` is the position BEFORE this fill — the WS-authoritative
676
804
  * way to know the after-fill total without an extra REST read. */
677
- onUserFill(fill) {
805
+ onUserFill(fill, meta) {
806
+ // Audit trail first — bracket bookkeeping below must not gate the record.
807
+ // Snapshot fills ARE ingested on purpose: the ledger is idempotent on
808
+ // (exchange, exchange_trade_id), so replayed history upserts harmlessly and
809
+ // backfills rows we'd otherwise miss (F26).
810
+ this.ingestFill(fill, 'ws');
811
+ // ★ F44: but bracket bookkeeping is a STATE MUTATION, not an idempotent
812
+ // write. Every (re)subscribe ships a snapshot of recent fills, and acting on
813
+ // one recomputes the position as that old fill's `startPosition + sz` — a
814
+ // stale total — then resizes the LIVE protective legs down to it. Observed
815
+ // on the rig 2026-07-27: ETH legs cut 0.027 → 0.0231 thirteen seconds after
816
+ // a user-stream connect, leaving 0.0039 unprotected until the 60s sweep
817
+ // healed it, in a repeating flap. Exchange truth is the resync's job.
818
+ if (meta?.isSnapshot)
819
+ return;
678
820
  try {
679
- const ledger = this.getHlBracketCoordinator().getLedger();
680
- const rows = ledger.getAll().filter((r) => !isTerminalBracketState(r.state));
681
- const row = rows.find((r) => r.entryCid && fill.cloid && r.entryCid === fill.cloid);
821
+ // Matches the primary entry cid OR any additional cid recorded for a
822
+ // scale-in / second resting entry (F5) matching on `entryCid` alone
823
+ // silently dropped those fills.
824
+ const row = this.getHlBracketCoordinator().findRowByEntryCid(fill.cloid);
682
825
  if (!row)
683
826
  return;
684
827
  const sz = Math.abs(Number(fill.sz ?? 0));