@reefclaw/openclaw-plugin 0.1.13 → 0.1.15

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 (71) hide show
  1. package/bridge/bridge.d.ts +20 -5
  2. package/bridge/bridge.js +29 -14
  3. package/bridge/config.js +6 -0
  4. package/bridge/gateway/gateway-config.d.ts +16 -5
  5. package/bridge/gateway/gateway-config.js +68 -12
  6. package/bridge/gateway/gateway-ws-client.d.ts +4 -1
  7. package/bridge/gateway/gateway-ws-client.js +41 -11
  8. package/bridge/gateway/poller.js +18 -8
  9. package/bridge/providers/emergency-commands.d.ts +9 -1
  10. package/bridge/providers/emergency-commands.js +38 -1
  11. package/bridge/providers/gateway.d.ts +51 -1
  12. package/bridge/providers/gateway.js +209 -22
  13. package/bridge/providers/onboarding-commands.d.ts +11 -0
  14. package/bridge/providers/onboarding-commands.js +5 -5
  15. package/bridge/providers/risk-calculator.d.ts +61 -2
  16. package/bridge/providers/risk-calculator.js +92 -20
  17. package/bridge/utils/skill-signing.js +8 -3
  18. package/ccxt/binance-public.d.ts +17 -5
  19. package/ccxt/binance-public.js +31 -3
  20. package/config/operator-provenance.d.ts +6 -0
  21. package/config/operator-provenance.js +50 -0
  22. package/config/plugin-config-io.d.ts +15 -1
  23. package/config/plugin-config-io.js +29 -0
  24. package/exchange-adapter.d.ts +13 -0
  25. package/index.js +230 -176
  26. package/ingest/event-loop-monitor.d.ts +22 -0
  27. package/ingest/event-loop-monitor.js +190 -0
  28. package/ingest/position-auto-capture.d.ts +5 -0
  29. package/ingest/position-auto-capture.js +14 -5
  30. package/ingest/readiness-reporter.d.ts +26 -6
  31. package/ingest/readiness-reporter.js +137 -9
  32. package/ingest/skill-version-reader.d.ts +16 -0
  33. package/ingest/skill-version-reader.js +64 -0
  34. package/live/approval-lifecycle.d.ts +30 -0
  35. package/live/approval-lifecycle.js +80 -0
  36. package/live/bracket-types.d.ts +9 -0
  37. package/live/live-adapter.d.ts +0 -1
  38. package/live/user-data-stream.js +10 -2
  39. package/onboarding/runtime.d.ts +34 -1
  40. package/onboarding/runtime.js +56 -5
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +6 -5
  43. package/risk/pre-trade-check.js +18 -5
  44. package/simulator/exchange-simulator.d.ts +45 -2
  45. package/simulator/exchange-simulator.js +96 -4
  46. package/simulator/types.d.ts +17 -0
  47. package/skills/reefclaw/SKILL.md +6 -11
  48. package/strategy/condition-registry.js +9 -2
  49. package/strategy/evaluator.d.ts +5 -0
  50. package/tools/attach-brackets.js +50 -1
  51. package/tools/cancel-all-orders.js +9 -1
  52. package/tools/create-order.js +18 -1
  53. package/tools/get-bracket-config.d.ts +21 -2
  54. package/tools/get-bracket-config.js +18 -2
  55. package/tools/set-trading-mode.js +6 -3
  56. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +25 -1
  57. package/venues/hyperliquid/hl-bracket-coordinator.js +57 -0
  58. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  59. package/venues/hyperliquid/hl-brackets.js +45 -13
  60. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  61. package/venues/hyperliquid/hl-fill-ingest.js +88 -0
  62. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  63. package/venues/hyperliquid/hl-live-adapter.js +116 -7
  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
  68. package/venues/registry.js +8 -7
  69. package/wave9/paper-admission-guard.d.ts +12 -1
  70. package/wave9/paper-admission-guard.js +12 -1
  71. package/scripts/assemble.mjs +0 -130
@@ -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,88 @@
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
+ // Log the DIAGNOSIS (which identity fields failed + what shape arrived),
44
+ // never the raw payload — fills carry account trading data that doesn't
45
+ // belong in journals.
46
+ const bad = [];
47
+ if (typeof fill.tid !== 'number' || !Number.isFinite(fill.tid))
48
+ bad.push('tid');
49
+ if (typeof fill.oid !== 'number' || !Number.isFinite(fill.oid))
50
+ bad.push('oid');
51
+ if (typeof fill.coin !== 'string' || fill.coin.length === 0)
52
+ bad.push('coin');
53
+ if (price === undefined)
54
+ bad.push('px');
55
+ if (quantity === undefined || quantity <= 0)
56
+ bad.push('sz');
57
+ if (time === undefined)
58
+ bad.push('time');
59
+ if (fill.side !== 'A' && fill.side !== 'B')
60
+ bad.push('side');
61
+ logger.warn(TAG, `Dropping unmappable HL fill (source=${source}): invalid=[${bad.join(',')}] ` +
62
+ `keys=[${Object.keys(fill).join(',')}]`);
63
+ return null;
64
+ }
65
+ return {
66
+ exchange: wiring.exchange,
67
+ exchangeTradeId: String(fill.tid),
68
+ exchangeOrderId: String(fill.oid),
69
+ clientOrderId: typeof fill.cloid === 'string' && fill.cloid.length > 0 ? fill.cloid : undefined,
70
+ source,
71
+ userId: wiring.userId,
72
+ symbol: hlCoinToCanonical(fill.coin),
73
+ side: fill.side === 'B' ? 'BUY' : 'SELL',
74
+ quantity,
75
+ price,
76
+ // HL `crossed` is the taker flag; maker is its inverse. Absent → unknown.
77
+ maker: typeof fill.crossed === 'boolean' ? !fill.crossed : undefined,
78
+ // `fee` is inclusive of builderFee, and we hard-disable the builder fee
79
+ // (options.builderFee:false, pinned by hl-private.test.ts) so this is the
80
+ // whole commission either way. HL fees settle in USDC unless feeToken says
81
+ // otherwise.
82
+ commission: num(fill.fee),
83
+ commissionAsset: typeof fill.feeToken === 'string' ? fill.feeToken : 'USDC',
84
+ realizedPnl: num(fill.closedPnl),
85
+ exchangeTime: time,
86
+ rawPayload: fill,
87
+ };
88
+ }
@@ -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;
@@ -25,11 +38,22 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
25
38
  * the exchange-side legs ARE the safety floor. Lazily constructed so that
26
39
  * merely constructing the adapter (registry tests) writes no ledger file. */
27
40
  private _coordinator;
41
+ /** Venue capability (see IExchangeAdapter): brackets are unconditional here,
42
+ * so every consumer of `brackets.mode` must read this instead of the
43
+ * Binance-only flag. */
44
+ readonly bracketsAlwaysEnforced = true;
28
45
  private userStream;
29
46
  private truthCheckTimer;
30
47
  private truthCheckRunning;
31
48
  private _readiness;
32
49
  private openOrdersUnavailableUntil;
50
+ /** MICRO_LIVE per-order notional cap (USD). null = LIVE, uncapped. */
51
+ private readonly maxPositionUsd;
52
+ /** Resolved audit-trail wiring (F26). undefined = ingest not configured. */
53
+ private readonly fillIngest?;
54
+ /** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
55
+ * low-water mark the reconnect gap backfill widens from. */
56
+ private lastFillIngestMs;
33
57
  /** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
34
58
  * income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
35
59
  * each balance fetch. Without this the skill self-computes a bogus anchor and
@@ -132,6 +156,10 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
132
156
  symbol: string;
133
157
  positionSide: 'long' | 'short';
134
158
  positionSize: number;
159
+ registeredPrices?: {
160
+ stop?: number;
161
+ target?: number;
162
+ };
135
163
  }): Promise<{
136
164
  resized: boolean;
137
165
  slCid?: string;
@@ -147,6 +175,14 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
147
175
  * cleanup before a fresh attach). THROWS when order state is unknown —
148
176
  * the caller logs and still attaches (protection beats hygiene). */
149
177
  cancelSymbolBracketLegs(symbol: string): Promise<number>;
178
+ /** Audit-trail ingest (F26): fire-and-forget POST of one fill. The client
179
+ * never blocks the WS hot path; the server-side (exchange, trade id) upsert
180
+ * makes WS/backfill double-delivery a no-op. */
181
+ private ingestFill;
182
+ /** Reconnect gap backfill (F26): the WS replays NOTHING (T-5), so fills that
183
+ * landed while the socket was down exist ONLY via REST. Over-fetch with a
184
+ * 60s overlap is harmless (idempotent upsert); under-fetch loses audit rows. */
185
+ private backfillFillGap;
150
186
  /** Entry fills drive attach (resting limits) / resize (partial-fill growth).
151
187
  * `startPosition` is the position BEFORE this fill — the WS-authoritative
152
188
  * way to know the after-fill total without an extra REST read. */
@@ -38,6 +38,7 @@ import { generateBracketId } from '../../live/bracket-id.js';
38
38
  import { validateStopDirection, validateTargetDirection } from '../../live/bracket-params.js';
39
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
@@ -65,11 +66,22 @@ export class HyperliquidLiveAdapter extends EventEmitter {
65
66
  * the exchange-side legs ARE the safety floor. Lazily constructed so that
66
67
  * merely constructing the adapter (registry tests) writes no ledger file. */
67
68
  _coordinator = null;
69
+ /** Venue capability (see IExchangeAdapter): brackets are unconditional here,
70
+ * so every consumer of `brackets.mode` must read this instead of the
71
+ * Binance-only flag. */
72
+ bracketsAlwaysEnforced = true;
68
73
  userStream = null;
69
74
  truthCheckTimer = null;
70
75
  truthCheckRunning = false;
71
76
  _readiness = 'INIT_PENDING';
72
77
  openOrdersUnavailableUntil = 0;
78
+ /** MICRO_LIVE per-order notional cap (USD). null = LIVE, uncapped. */
79
+ maxPositionUsd;
80
+ /** Resolved audit-trail wiring (F26). undefined = ingest not configured. */
81
+ fillIngest;
82
+ /** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
83
+ * low-water mark the reconnect gap backfill widens from. */
84
+ lastFillIngestMs = 0;
73
85
  /** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
74
86
  * income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
75
87
  * each balance fetch. Without this the skill self-computes a bogus anchor and
@@ -81,6 +93,23 @@ export class HyperliquidLiveAdapter extends EventEmitter {
81
93
  this.api = new HyperliquidPrivateApi(opts.credentials);
82
94
  this.publicApi = new HyperliquidPublicApi({ testnet: opts.credentials.testnet });
83
95
  this.slippagePct = clampSlippage(opts.marketSlippagePct ?? DEFAULT_MARKET_SLIPPAGE);
96
+ this.maxPositionUsd =
97
+ opts.mode === 'MICRO_LIVE' ? opts.microLive?.maxPositionUSDT ?? 50 : null;
98
+ if (opts.tradeIngest) {
99
+ if (opts.tradeIngest.exchange) {
100
+ this.fillIngest = {
101
+ client: opts.tradeIngest.client,
102
+ userId: opts.tradeIngest.userId,
103
+ exchange: opts.tradeIngest.exchange,
104
+ };
105
+ logger.info(TAG, 'Per-fill audit-trail ingest wired (WS + reconnect gap backfill)');
106
+ }
107
+ else {
108
+ // FILL_EXCHANGE_ID strings are frozen once rows exist — refusing beats
109
+ // minting rows under a guessed exchange id.
110
+ logger.warn(TAG, 'tradeIngest provided WITHOUT an exchange id — fill ingest disabled');
111
+ }
112
+ }
84
113
  this.infoCache = new HyperliquidInfoCache(async () => {
85
114
  // `meta` is a keyless info read — the public client owns it.
86
115
  return this.publicApi.fetchMeta();
@@ -109,7 +138,8 @@ export class HyperliquidLiveAdapter extends EventEmitter {
109
138
  const rules = await this.infoCache.load();
110
139
  if (markets && rules && this.infoCache.size > 0) {
111
140
  this._readiness = 'READY';
112
- logger.info(TAG, `HL live adapter READY (${this.infoCache.size} assets, mode=${this.opts.mode})`);
141
+ logger.info(TAG, `HL live adapter READY (${this.infoCache.size} assets, mode=${this.opts.mode}` +
142
+ `${this.maxPositionUsd != null ? `, micro cap $${this.maxPositionUsd}/order` : ''})`);
113
143
  }
114
144
  else {
115
145
  // DEGRADED, not BLOCKED: reads/exits still work; entries are refused
@@ -135,13 +165,17 @@ export class HyperliquidLiveAdapter extends EventEmitter {
135
165
  walletAddress: this.opts.credentials.walletAddress,
136
166
  testnet: this.opts.credentials.testnet,
137
167
  callbacks: {
138
- onFill: (fill) => this.onUserFill(fill),
168
+ onFill: (fill, meta) => this.onUserFill(fill, meta),
139
169
  onOrderUpdate: (update) => this.onUserOrderUpdate(update),
140
170
  onUserEvent: () => {
141
171
  /* liquidation/funding — liquidation fills also arrive via onFill */
142
172
  },
143
173
  onResyncNeeded: (window) => {
144
174
  void this.runTruthCheck(`ws_resync_blind_${Math.round(window.wasDisconnectedMs / 1000)}s`);
175
+ // Fills that landed inside the blind window exist only via REST
176
+ // (T-5: the WS replays nothing) — recover them for the audit
177
+ // trail (F26). Bracket state is healed by the truth-check above.
178
+ void this.backfillFillGap(window.sinceMs).catch((err) => logger.warn(TAG, `Fill gap backfill threw: ${msg(err)}`));
145
179
  },
146
180
  },
147
181
  });
@@ -183,6 +217,21 @@ export class HyperliquidLiveAdapter extends EventEmitter {
183
217
  }
184
218
  referencePrice = mark;
185
219
  }
220
+ // ---- Micro-live notional cap (audit 2026-07-26 F8) ----
221
+ // Same semantics as Binance's LiveAdapter: clamp NEW exposure to the cap;
222
+ // never touch risk-reducing orders (blocking a close is worse than an
223
+ // uncapped close, and reduce-only cannot increase the position). A
224
+ // reference price is always in hand by this point — market orders fetched
225
+ // the mark above, limit orders carry their own price.
226
+ let effectiveAmount = amount;
227
+ if (this.maxPositionUsd != null && !options?.reduceOnly) {
228
+ const maxAmount = this.maxPositionUsd / referencePrice;
229
+ if (effectiveAmount > maxAmount) {
230
+ logger.info(TAG, `Micro-live cap: reducing ${symbol} amount ${effectiveAmount} → ${maxAmount} ` +
231
+ `(max $${this.maxPositionUsd} notional at ref ${referencePrice})`);
232
+ effectiveAmount = maxAmount;
233
+ }
234
+ }
186
235
  const submitPrice = type === 'market'
187
236
  ? this.infoCache.marketPrice({
188
237
  symbol,
@@ -193,7 +242,7 @@ export class HyperliquidLiveAdapter extends EventEmitter {
193
242
  : this.infoCache.roundPrice(symbol, referencePrice, side === 'buy' ? 'down' : 'up');
194
243
  const check = this.infoCache.validate({
195
244
  symbol,
196
- amount,
245
+ amount: effectiveAmount,
197
246
  side,
198
247
  price: type === 'limit' ? submitPrice : undefined,
199
248
  referencePrice,
@@ -303,6 +352,13 @@ export class HyperliquidLiveAdapter extends EventEmitter {
303
352
  const ledger = this.getHlBracketCoordinator().getLedger();
304
353
  const existing = ledger.getBySymbol(symbol);
305
354
  if (existing && !isTerminalBracketState(existing.state) && existing.state !== 'pending_entry') {
355
+ // ★ F5: record THIS order's cloid against the live row FIRST. A scale-in
356
+ // submitted as a resting limit fills later, and `onUserFill` matches
357
+ // fills to rows by cid — without this its fill matched nothing, so no
358
+ // resize ever ran and the added contracts stayed NAKED (T-2: legs are
359
+ // fixed size) until the 60s truth-check sweep. Registered before the
360
+ // resize below so a fast fill can never race ahead of the bookkeeping.
361
+ this.getHlBracketCoordinator().registerAdditionalEntryCid(symbol, entryCloid);
306
362
  if (isFilled) {
307
363
  void this.resizeAfterScaleInAsync(symbol);
308
364
  }
@@ -709,14 +765,67 @@ export class HyperliquidLiveAdapter extends EventEmitter {
709
765
  return cancelled;
710
766
  }
711
767
  // ---- User-stream handlers (issue #209 wiring) ----
768
+ /** Audit-trail ingest (F26): fire-and-forget POST of one fill. The client
769
+ * never blocks the WS hot path; the server-side (exchange, trade id) upsert
770
+ * makes WS/backfill double-delivery a no-op. */
771
+ ingestFill(fill, source) {
772
+ if (!this.fillIngest)
773
+ return false;
774
+ const event = hlFillToFillEvent(fill, this.fillIngest, source);
775
+ if (!event)
776
+ return false;
777
+ this.fillIngest.client.post(this.fillIngest.userId, event);
778
+ if (event.exchangeTime > this.lastFillIngestMs)
779
+ this.lastFillIngestMs = event.exchangeTime;
780
+ return true;
781
+ }
782
+ /** Reconnect gap backfill (F26): the WS replays NOTHING (T-5), so fills that
783
+ * landed while the socket was down exist ONLY via REST. Over-fetch with a
784
+ * 60s overlap is harmless (idempotent upsert); under-fetch loses audit rows. */
785
+ async backfillFillGap(sinceMs) {
786
+ if (!this.fillIngest)
787
+ return;
788
+ const floor = Date.now() - 24 * 3600_000; // never sweep more than a day
789
+ const from = Math.max(floor, Math.min(sinceMs, this.lastFillIngestMs > 0 ? this.lastFillIngestMs : Number.POSITIVE_INFINITY) - 60_000);
790
+ const fills = await this.api.fetchFillsSince(from);
791
+ if (fills === null) {
792
+ // null ≠ empty: the fetch FAILED — the gap stays open, the next resync
793
+ // (or the periodic truth-check path) retries. Never treat as "no fills".
794
+ logger.warn(TAG, `Fill gap backfill fetch FAILED (since=${new Date(from).toISOString()}) — retried on next resync`);
795
+ return;
796
+ }
797
+ let posted = 0;
798
+ for (const f of fills) {
799
+ if (this.ingestFill(f, 'rest_reconcile'))
800
+ posted++;
801
+ }
802
+ if (fills.length > 0) {
803
+ logger.info(TAG, `Fill gap backfill: posted ${posted}/${fills.length} fill(s) since ${new Date(from).toISOString()}`);
804
+ }
805
+ }
712
806
  /** Entry fills drive attach (resting limits) / resize (partial-fill growth).
713
807
  * `startPosition` is the position BEFORE this fill — the WS-authoritative
714
808
  * way to know the after-fill total without an extra REST read. */
715
- onUserFill(fill) {
809
+ onUserFill(fill, meta) {
810
+ // Audit trail first — bracket bookkeeping below must not gate the record.
811
+ // Snapshot fills ARE ingested on purpose: the ledger is idempotent on
812
+ // (exchange, exchange_trade_id), so replayed history upserts harmlessly and
813
+ // backfills rows we'd otherwise miss (F26).
814
+ this.ingestFill(fill, 'ws');
815
+ // ★ F44: but bracket bookkeeping is a STATE MUTATION, not an idempotent
816
+ // write. Every (re)subscribe ships a snapshot of recent fills, and acting on
817
+ // one recomputes the position as that old fill's `startPosition + sz` — a
818
+ // stale total — then resizes the LIVE protective legs down to it. Observed
819
+ // on the rig 2026-07-27: ETH legs cut 0.027 → 0.0231 thirteen seconds after
820
+ // a user-stream connect, leaving 0.0039 unprotected until the 60s sweep
821
+ // healed it, in a repeating flap. Exchange truth is the resync's job.
822
+ if (meta?.isSnapshot)
823
+ return;
716
824
  try {
717
- const ledger = this.getHlBracketCoordinator().getLedger();
718
- const rows = ledger.getAll().filter((r) => !isTerminalBracketState(r.state));
719
- const row = rows.find((r) => r.entryCid && fill.cloid && r.entryCid === fill.cloid);
825
+ // Matches the primary entry cid OR any additional cid recorded for a
826
+ // scale-in / second resting entry (F5) matching on `entryCid` alone
827
+ // silently dropped those fills.
828
+ const row = this.getHlBracketCoordinator().findRowByEntryCid(fill.cloid);
720
829
  if (!row)
721
830
  return;
722
831
  const sz = Math.abs(Number(fill.sz ?? 0));
@@ -1,6 +1,7 @@
1
1
  import type { CcxtTicker, CcxtOHLCV } from '../../types.js';
2
2
  import type { OrderBookDepth } from '../../simulator/types.js';
3
3
  import type { PublicMarketDataApi } from '../../ccxt/public-market-data-api.js';
4
+ import { type VenueReachabilityResult } from '@reefclaw/shared';
4
5
  export interface HyperliquidPublicApiOptions {
5
6
  testnet?: boolean;
6
7
  /** Test seam — injected ccxt exchange instance. */
@@ -60,11 +61,17 @@ export declare class HyperliquidPublicApi implements PublicMarketDataApi {
60
61
  * (verified live 2026-07-11: `{"specialStatuses":null,"time":…}`) doubles
61
62
  * as the clock-drift source. Geo classification is best-effort — HL's
62
63
  * API-level geo behavior is UNVERIFIED (plan §3.9); a 403/451 maps to
63
- * geo_blocked, anything else non-2xx/network maps to unreachable. */
64
- probeReachability(): Promise<{
65
- outcome: 'reachable' | 'geo_blocked' | 'unreachable' | 'unknown';
66
- driftMs: number | null;
67
- }>;
64
+ * geo_blocked, anything else non-2xx/network maps to unreachable.
65
+ *
66
+ * ★ Self-stall detection (issue #265): the failure path is wall-clock timed.
67
+ * A throw is only evidence about the VENUE if our own abort timer fired
68
+ * roughly when it was set for. On a starved host the loop stops running —
69
+ * observed live on the HL rig: this 10s timer landed 86s late while the
70
+ * agent was trading on HL perfectly well — and blaming the network then
71
+ * produces a confident, wrong "check DNS/firewall/region" banner. Past
72
+ * STALL_FACTOR× the budget we report `stalled`, which the reporter renders
73
+ * as `unknown`: we genuinely did not learn whether HL is reachable. */
74
+ probeReachability(): Promise<VenueReachabilityResult>;
68
75
  /** `meta` — the asset universe (szDecimals, maxLeverage, positional assetIndex).
69
76
  * Keyless info read, weight 20. Feeds HyperliquidInfoCache. Returns null on any
70
77
  * failure (never a partial universe — a half-loaded rules table would silently
@@ -32,6 +32,7 @@
32
32
  // on this venue — logged clearly, null returned (never silently translated).
33
33
  import { createRequire } from 'node:module';
34
34
  import { logger } from '../../logger.js';
35
+ import { REACHABILITY_STALL_FACTOR } from '@reefclaw/shared';
35
36
  import { toCcxtSymbol, toHyperliquidCoin } from '../symbols.js';
36
37
  const TAG = 'hl-public';
37
38
  // Load ccxt via CJS require — OpenClaw's ESM loader gives wrong module shape
@@ -55,6 +56,10 @@ const DEFAULT_TICKER_TTL_MS = 4_000;
55
56
  * snapshot's ccxt call re-runs the whole fetchMarkets pipeline (weight ~60,
56
57
  * ~13s on a slow host) and nothing price-critical reads these fields. */
57
58
  const FULL_SNAPSHOT_REFRESH_MS = 300_000;
59
+ /** Reachability-probe request budget. Also the yardstick for self-stall
60
+ * detection — see probeReachability. */
61
+ const PROBE_TIMEOUT_MS = 10_000;
62
+ const STALL_FACTOR = REACHABILITY_STALL_FACTOR;
58
63
  function resolveTickerTtlMs() {
59
64
  const raw = Number(process.env.RC_HL_TICKER_TTL_MS);
60
65
  if (!Number.isFinite(raw) || raw < 500)
@@ -343,10 +348,20 @@ export class HyperliquidPublicApi {
343
348
  * (verified live 2026-07-11: `{"specialStatuses":null,"time":…}`) doubles
344
349
  * as the clock-drift source. Geo classification is best-effort — HL's
345
350
  * API-level geo behavior is UNVERIFIED (plan §3.9); a 403/451 maps to
346
- * geo_blocked, anything else non-2xx/network maps to unreachable. */
351
+ * geo_blocked, anything else non-2xx/network maps to unreachable.
352
+ *
353
+ * ★ Self-stall detection (issue #265): the failure path is wall-clock timed.
354
+ * A throw is only evidence about the VENUE if our own abort timer fired
355
+ * roughly when it was set for. On a starved host the loop stops running —
356
+ * observed live on the HL rig: this 10s timer landed 86s late while the
357
+ * agent was trading on HL perfectly well — and blaming the network then
358
+ * produces a confident, wrong "check DNS/firewall/region" banner. Past
359
+ * STALL_FACTOR× the budget we report `stalled`, which the reporter renders
360
+ * as `unknown`: we genuinely did not learn whether HL is reachable. */
347
361
  async probeReachability() {
348
362
  const ac = new AbortController();
349
- const tid = setTimeout(() => ac.abort(), 10_000);
363
+ const tid = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
364
+ const startedAt = Date.now();
350
365
  try {
351
366
  const res = await this.fetchImpl(`${this.baseUrl()}/info`, {
352
367
  method: 'POST',
@@ -368,7 +383,13 @@ export class HyperliquidPublicApi {
368
383
  return { outcome: 'reachable', driftMs };
369
384
  }
370
385
  catch (err) {
371
- logger.warn(TAG, `probeReachability failed: ${err instanceof Error ? err.message : String(err)}`);
386
+ const elapsedMs = Date.now() - startedAt;
387
+ const msg = err instanceof Error ? err.message : String(err);
388
+ if (elapsedMs > PROBE_TIMEOUT_MS * STALL_FACTOR) {
389
+ logger.warn(TAG, `probeReachability inconclusive: this process was starved — a ${PROBE_TIMEOUT_MS}ms probe took ${Math.round(elapsedMs / 1000)}s (${msg}). Reporting reachability as unknown, NOT as a Hyperliquid failure.`);
390
+ return { outcome: 'stalled', driftMs: null, stallMs: elapsedMs };
391
+ }
392
+ logger.warn(TAG, `probeReachability failed: ${msg}`);
372
393
  return { outcome: 'unreachable', driftMs: null };
373
394
  }
374
395
  finally {
@@ -41,7 +41,19 @@ export interface HlOrderUpdateEvent {
41
41
  statusTimestamp: number;
42
42
  }
43
43
  export interface HlUserStreamCallbacks {
44
- onFill: (fill: HlFillEvent) => void;
44
+ /** ★ `meta.isSnapshot` marks a fill from the subscription SNAPSHOT — the batch
45
+ * of recent history HL sends on every (re)subscribe — not a live execution.
46
+ *
47
+ * Do NOT confuse this with the T-5 gap rule below: HL not replaying the fills
48
+ * you MISSED while disconnected is true and unrelated. It still ships a
49
+ * snapshot of recent history on subscribe, and consumers must tell them apart.
50
+ * Idempotent consumers (the audit-trail ingest, keyed on exchange trade id)
51
+ * should take snapshot fills; state MUTATORS (bracket resize/attach) must not
52
+ * — replaying an old fill's `startPosition + sz` resizes live protective legs
53
+ * to a stale historical size (audit 2026-07-27 F44). */
54
+ onFill: (fill: HlFillEvent, meta: {
55
+ isSnapshot: boolean;
56
+ }) => void;
45
57
  onOrderUpdate: (update: HlOrderUpdateEvent) => void;
46
58
  /** userEvents: liquidation / funding / non-user-cancel — the close-bypass feed. */
47
59
  onUserEvent: (event: Record<string, unknown>) => void;
@@ -137,9 +137,12 @@ export class HyperliquidUserStream {
137
137
  switch (channel) {
138
138
  case 'userFills': {
139
139
  const payload = data;
140
+ // `isSnapshot` was typed here from the start but never read — so every
141
+ // (re)connect replayed recent history as if it were live (F44).
142
+ const isSnapshot = payload?.isSnapshot === true;
140
143
  for (const fill of payload?.fills ?? []) {
141
144
  this.lastEventAt = Math.max(this.lastEventAt, fill.time ?? Date.now());
142
- this.opts.callbacks.onFill(fill);
145
+ this.opts.callbacks.onFill(fill, { isSnapshot });
143
146
  }
144
147
  break;
145
148
  }
@@ -1,12 +1,13 @@
1
1
  // Venue registry — the single seam where a trading venue's LIVE adapter is
2
- // constructed (Phase 0 of docs/HYPERLIQUID_INTEGRATION_PLAN.md §5.1/§7.1).
2
+ // constructed (docs/HYPERLIQUID_INTEGRATION_PLAN.md §5.1/§7.1).
3
3
  //
4
- // Phase 0 scope: Binance is the ONLY venue this build can trade live;
5
- // 'hyperliquid' is a recognised-but-unsupported config value that boot handles
6
- // by falling back to PAPER (never by crashing register() — OpenClaw treats a
7
- // throwing register as "ignored" and the agent silently loses every tool).
8
- // Phase 3 adds the HyperliquidLiveAdapter arm HERE and nowhere else, so the
9
- // boot path never grows a second venue branch.
4
+ // This build trades live on BOTH supported venues: Binance (LiveAdapter) and
5
+ // Hyperliquid (HyperliquidLiveAdapter, Phase 3 brackets always enforced by
6
+ // HlBracketCoordinator). A venue with missing/invalid credentials falls back
7
+ // to PAPER at boot (never by crashing register() OpenClaw treats a throwing
8
+ // register as "ignored" and the agent silently loses every tool). Any future
9
+ // venue adds its arm HERE and nowhere else, so the boot path never grows a
10
+ // second venue branch.
10
11
  //
11
12
  // Venue is LOCAL mechanism (TOOL_DISTRIBUTION_ARCHITECTURE.md §2 decision
12
13
  // rule: it holds keys + is part of the safety floor) — it is read from
@@ -1,7 +1,18 @@
1
1
  import type { IExchangeAdapter } from '../exchange-adapter.js';
2
2
  /** One catalog toggle represents both frozen Wave 9 strategy legs. */
3
3
  export declare const WAVE9_BUNDLE_SETUP_TYPE = "wave9_28d_momentum_reversal";
4
+ /** TOOL-level close reason: what close_position args + beginExit redemption
5
+ * requests must carry. The `wave9_` namespace keeps it from colliding with
6
+ * generic CloseReason values. */
4
7
  export declare const WAVE9_MECHANICAL_EXIT_REASON = "wave9_signal_reversal";
8
+ /** DECISION-level exit cause: what the authorization decision
9
+ * (Wave9PaperExitTokenDecision.reason) records at issuance. Deliberately a
10
+ * DIFFERENT string from WAVE9_MECHANICAL_EXIT_REASON — the decision names
11
+ * WHY the strategy exits (its only exit cause is a signal reversal), the
12
+ * tool reason names WHICH namespaced close path redeems it. issueExitBatch
13
+ * validates this constant; beginExit validates the tool constant. Two fields
14
+ * on two layers, each checked against its own value — not an asymmetry. */
15
+ export declare const WAVE9_EXIT_DECISION_REASON = "signal_reversal";
5
16
  export type Wave9ExecutionMode = 'PAPER' | 'LIVE';
6
17
  /** Conservative shared identity check for current, legacy, or Wave 9-mission positions. */
7
18
  export declare function isWave9ManagedPosition(position: {
@@ -93,7 +104,7 @@ export interface Wave9PaperExitTokenDecision {
93
104
  missionId: string;
94
105
  symbol: string;
95
106
  positionSide: 'long' | 'short';
96
- reason: 'signal_reversal';
107
+ reason: typeof WAVE9_EXIT_DECISION_REASON;
97
108
  notBeforeMs: number;
98
109
  deadlineMs: number;
99
110
  positionFingerprint: string;