@reefclaw/openclaw-plugin 0.1.1 → 0.1.3

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.
@@ -0,0 +1,137 @@
1
+ // EntitlementGate — the plugin-side enforcement point for account-level
2
+ // (billing) restrictions: when the server-resolved entitlement says the user
3
+ // has no paid access (trial expired past its end, subscription lapsed past
4
+ // the 7-day grace), NEW ENTRIES pause. Everything else keeps working.
5
+ //
6
+ // ★ SAFETY ANALYSIS (why this can never add risk to an existing book):
7
+ // the gate blocks ONLY the names in ENTITLEMENT_GATED_TOOLS — today exactly
8
+ // `create_order`, the one tool that ADDS risk and the one thing our servers
9
+ // cannot 403 (it goes plugin → Binance directly). Every risk-REDUCING and
10
+ // safety path (close_position, modify_stop, attach_brackets,
11
+ // audit_bracket_protection, record_position_reviews, the operator-control
12
+ // tools, kill/flatten/pause via the skill) is untouched by construction: it
13
+ // is not in the gated set, and a pin test asserts the set can never grow into
14
+ // the safety floor. This mirrors what an auto-pause on loss limits already
15
+ // does (no new entries, existing positions fully manageable) — an operation
16
+ // the product already considers safe.
17
+ //
18
+ // ★ THREAT MODEL HONESTY: the plugin runs on the trader's own hardware, so
19
+ // this gate is the COURTESY layer — it makes the agent stop cleanly with an
20
+ // actionable message instead of burning failed calls. The HARD enforcement is
21
+ // server-side: webapp Pro routes and the intelligence API both 403 on
22
+ // paidAccess=false, which removes the data the agent trades on.
23
+ //
24
+ // Fail direction: no entitlement data (older webapp, outage, garbage payload,
25
+ // no token) → ALLOW. Billing enforcement must never brick a paying customer
26
+ // because our config channel hiccuped; last-known-good semantics come from
27
+ // the agent-config cache like every other centrally-delivered value.
28
+ //
29
+ // Kill-switch: RC_ENTITLEMENT_GATE=off (never blocks) | shadow (log-only)
30
+ // | default enforce — same convention as RC_TOOL_GATE / RC_CENTRAL_GATES.
31
+ import { logger } from '../logger.js';
32
+ const TAG = 'entitlement-gate';
33
+ /** The ONLY tools an expired entitlement may pause. Deliberately a denylist
34
+ * (not "everything except safety"): a tool added tomorrow is un-gated by
35
+ * default, which is the conservative failure mode in a trading product.
36
+ * Intel/data tools are NOT here — the intelligence service and webapp 403
37
+ * those server-side with the same actionable message. */
38
+ export const ENTITLEMENT_GATED_TOOLS = new Set(['create_order']);
39
+ export function resolveEntitlementGateMode() {
40
+ const raw = (process.env.RC_ENTITLEMENT_GATE ?? '').toLowerCase();
41
+ if (raw === 'off')
42
+ return 'off';
43
+ if (raw === 'shadow')
44
+ return 'shadow';
45
+ return 'enforce';
46
+ }
47
+ /** Structured, agent-visible result for a blocked call. Worded so the agent
48
+ * stops cleanly (no retry loop), keeps managing the existing book, and tells
49
+ * the operator exactly how to restore service. */
50
+ export function entitlementBlockedResult(name) {
51
+ return {
52
+ ok: false,
53
+ error: 'entitlement_expired',
54
+ tool: name,
55
+ message: `New entries are paused: the ReefClaw subscription for this connection has expired ` +
56
+ `(trial ended or payment lapsed). Do not retry this call. Existing positions remain fully ` +
57
+ `manageable — close_position, modify_stop, attach_brackets, and all emergency/operator ` +
58
+ `controls work normally, and protective brackets stay enforced by the exchange. ` +
59
+ `Tell your operator to renew at https://reefclaw.com (Settings → Billing) to resume trading.`,
60
+ };
61
+ }
62
+ export class EntitlementGate {
63
+ mode;
64
+ entitlement;
65
+ /** Avoids log spam: one line per state transition, not per blocked call. */
66
+ lastLoggedKey = '';
67
+ constructor(mode = resolveEntitlementGateMode()) {
68
+ this.mode = mode;
69
+ if (mode !== 'enforce')
70
+ logger.info(TAG, `mode=${mode}`);
71
+ }
72
+ getMode() {
73
+ return this.mode;
74
+ }
75
+ getEntitlement() {
76
+ return this.entitlement;
77
+ }
78
+ /** Apply the entitlement slice of a validated config (poller path).
79
+ * `undefined` (server omitted it / validation dropped it) CLEARS the held
80
+ * verdict — the gate then fails open rather than enforcing a stale one
81
+ * the server no longer asserts. */
82
+ apply(entitlement) {
83
+ const key = entitlement ? `${entitlement.state}:${entitlement.paidAccess}` : '(none)';
84
+ if (key !== this.lastLoggedKey) {
85
+ this.lastLoggedKey = key;
86
+ logger.info(TAG, entitlement
87
+ ? `entitlement: state=${entitlement.state} paidAccess=${entitlement.paidAccess}` +
88
+ (entitlement.graceUntil ? ` graceUntil=${entitlement.graceUntil}` : '')
89
+ : 'entitlement: no server verdict — gate inactive (fail-open)');
90
+ }
91
+ this.entitlement = entitlement;
92
+ }
93
+ /** Should this call be blocked? Non-gated names short-circuit FIRST (the
94
+ * safety set can never be touched, in any mode, with any payload); then
95
+ * only an explicit server verdict of paidAccess=false blocks. */
96
+ isBlocked(name) {
97
+ if (this.mode === 'off')
98
+ return false;
99
+ if (!ENTITLEMENT_GATED_TOOLS.has(name))
100
+ return false;
101
+ if (!this.entitlement)
102
+ return false; // unknown → allow
103
+ if (this.entitlement.paidAccess !== false)
104
+ return false;
105
+ if (this.mode === 'shadow') {
106
+ logger.info(TAG, `SHADOW: would block ${name} (state=${this.entitlement.state})`);
107
+ return false;
108
+ }
109
+ return true;
110
+ }
111
+ /** Wrap a tool's execute with the gate check — same hot-apply pattern as
112
+ * ToolGate.wrapTool (state is consulted per call, not at registration). */
113
+ wrapTool(tool, jsonResult) {
114
+ if (!ENTITLEMENT_GATED_TOOLS.has(tool.name))
115
+ return tool;
116
+ const originalExecute = tool.execute.bind(tool);
117
+ return {
118
+ ...tool,
119
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
120
+ execute: (...args) => {
121
+ if (this.isBlocked(tool.name)) {
122
+ logger.info(TAG, `blocked ${tool.name} (entitlement state=${this.entitlement?.state ?? 'unknown'})`);
123
+ return Promise.resolve(jsonResult(entitlementBlockedResult(tool.name)));
124
+ }
125
+ return originalExecute(...args);
126
+ },
127
+ };
128
+ }
129
+ /** Test-only. */
130
+ __reset() {
131
+ this.entitlement = undefined;
132
+ this.lastLoggedKey = '';
133
+ }
134
+ }
135
+ /** Module singleton — one gate per plugin process, shared by the poller
136
+ * (writer) and the tool-wrap in index.ts (consumer), mirroring gateStore. */
137
+ export const entitlementGate = new EntitlementGate();
package/index.js CHANGED
@@ -29,6 +29,7 @@ import { PositionStateStore } from './live/position-state-store.js';
29
29
  import { PendingEntryStore } from './ingest/pending-entry-metadata.js';
30
30
  import { onReconcilerObservedClose, reconcileStateStoreOnStartup } from './ingest/reconciler-cleanup.js';
31
31
  import { reconcileDbOpenVsExchange } from './ingest/reconcile-db-vs-exchange.js';
32
+ import { startReadinessReporter } from './ingest/readiness-reporter.js';
32
33
  import { IntelMicrostructureAssembler } from './live/microstructure-assembler.js';
33
34
  import { recordPositionReviewsTool } from './tools/record-position-reviews.js';
34
35
  import { getMyRecentReviewsTool } from './tools/get-my-recent-reviews.js';
@@ -43,6 +44,7 @@ import { readPluginConfig, readOpenClawConnection } from './config/plugin-config
43
44
  import { startConnectorSupervisor, hasBundledBridge } from './connector-supervisor.js';
44
45
  import { ToolGate } from './config/tool-gate.js';
45
46
  import { gateStore } from './config/gate-store.js';
47
+ import { entitlementGate } from './config/entitlement-gate.js';
46
48
  import { startAgentConfigPoller } from './config/agent-config-poller.js';
47
49
  import { PositionWatcher } from './live/stop-watcher.js';
48
50
  import { PaperMarketFeed } from './simulator/paper-market-feed.js';
@@ -1273,31 +1275,39 @@ const paperTradingPlugin = {
1273
1275
  // weight). getPositionsOrNull() returns null on a failed/weight-paced
1274
1276
  // fetch; getPositions() would collapse that to [] (null≠empty trap).
1275
1277
  const trusted = await liveAdapter.getPositionsOrNull();
1276
- const positions = trusted ?? [];
1277
- await reconcileStateStoreOnStartup({
1278
- decisionsClient: positionDecisionsClient,
1279
- stateStore: positionStateStore,
1280
- userId: positionDecisionsUserId,
1281
- lastPriceFn: (sym) => liveAdapter.getLastPrice(sym),
1282
- }, positions.map((p) => p.symbol));
1283
- // Seed remaining-contracts for positions still open on the exchange
1284
- // so the reduce-only-fill close handler can detect flat for entries
1285
- // that opened before this plugin instance started (or before the
1286
- // size-tracking field existed). seedRemainingContracts never clobbers
1287
- // live fill-tracked state.
1288
- for (const p of positions) {
1289
- const contracts = Math.abs(Number(p.contracts));
1290
- if (Number.isFinite(contracts) && contracts > 0) {
1291
- positionStateStore.seedRemainingContracts(p.symbol, contracts);
1292
- }
1293
- }
1294
- // DB-authoritative orphan close: close webapp `positions` rows that
1295
- // are status='open' but absent from the exchange (closed on-exchange
1296
- // while down / state-store wiped). ONLY on a TRUSTED snapshot — a
1297
- // null (failed) fetch must never be read as "flat" and close the book.
1298
1278
  if (trusted !== null) {
1279
+ // State-store sweep ONLY on a TRUSTED snapshot: this call treats
1280
+ // every state-store symbol absent from the list as closed and
1281
+ // removes it (incl. its webappPositionId journal linkage), so
1282
+ // collapsing a null (failed) fetch to [] would wipe the store
1283
+ // for every open position on a transient boot-time 429/paced
1284
+ // read — the null≠empty trap, with no self-healing retry.
1285
+ await reconcileStateStoreOnStartup({
1286
+ decisionsClient: positionDecisionsClient,
1287
+ stateStore: positionStateStore,
1288
+ userId: positionDecisionsUserId,
1289
+ lastPriceFn: (sym) => liveAdapter.getLastPrice(sym),
1290
+ }, trusted.map((p) => p.symbol));
1291
+ // Seed remaining-contracts for positions still open on the exchange
1292
+ // so the reduce-only-fill close handler can detect flat for entries
1293
+ // that opened before this plugin instance started (or before the
1294
+ // size-tracking field existed). seedRemainingContracts never clobbers
1295
+ // live fill-tracked state.
1296
+ for (const p of trusted) {
1297
+ const contracts = Math.abs(Number(p.contracts));
1298
+ if (Number.isFinite(contracts) && contracts > 0) {
1299
+ positionStateStore.seedRemainingContracts(p.symbol, contracts);
1300
+ }
1301
+ }
1302
+ // DB-authoritative orphan close: close webapp `positions` rows that
1303
+ // are status='open' but absent from the exchange (closed on-exchange
1304
+ // while down / state-store wiped). Same trusted-snapshot-only rule —
1305
+ // a null fetch must never be read as "flat" and close the book.
1299
1306
  await reconcileDbOpenVsExchange({ decisionsClient: positionDecisionsClient, userId: positionDecisionsUserId }, trusted.map((p) => p.symbol));
1300
1307
  }
1308
+ else {
1309
+ logger.warn(TAG, 'Startup reconciliation skipped — positions fetch untrusted (null); state-store left untouched. Runtime drift_detected + periodic reconcilers cover the gap.');
1310
+ }
1301
1311
  }
1302
1312
  catch (err) {
1303
1313
  logger.warn(TAG, `Startup state-store reconciliation failed: ${formatError(err)}`);
@@ -1958,7 +1968,13 @@ const paperTradingPlugin = {
1958
1968
  // config blob can never strip close_position/attach_brackets/etc.
1959
1969
  // Kill-switch: RC_TOOL_GATE=off. See docs/TOOL_DISTRIBUTION_ARCHITECTURE.md §5f.
1960
1970
  const toolGate = new ToolGate();
1961
- const gatedTools = tools.map((t) => toolGate.wrapTool(t, jsonResult));
1971
+ // Entitlement gate (account-level enforcement): wraps OUTSIDE the
1972
+ // ToolGate so an expired subscription's message wins over an operator
1973
+ // OFF-list message. It touches ONLY ENTITLEMENT_GATED_TOOLS (exactly
1974
+ // create_order — new risk); closes/stops/brackets/emergency are not in
1975
+ // the set by construction. No server verdict → fail-open. Kill-switch:
1976
+ // RC_ENTITLEMENT_GATE=off. See config/entitlement-gate.ts.
1977
+ const gatedTools = tools.map((t) => entitlementGate.wrapTool(toolGate.wrapTool(t, jsonResult), jsonResult));
1962
1978
  // Poll the central config (boot + every ~60s), keyed by the same rc_
1963
1979
  // token the ingest paths use (plugin-config connectionToken →
1964
1980
  // WEBAPP_INGEST_TOKEN env fallback). No token → poller no-ops → gate
@@ -1966,9 +1982,11 @@ const paperTradingPlugin = {
1966
1982
  // gateStore carries centrally-delivered operational gates (slice 2:
1967
1983
  // exitGate) — loadExitGateMode() consults it before the local file;
1968
1984
  // kill-switch RC_CENTRAL_GATES=off. See config/gate-store.ts.
1985
+ // entitlementGate receives the server-resolved billing verdict.
1969
1986
  startAgentConfigPoller({
1970
1987
  gate: toolGate,
1971
1988
  gateStore,
1989
+ entitlementGate,
1972
1990
  apiBaseUrl,
1973
1991
  token: resolveIngestToken({ connectionToken }),
1974
1992
  });
@@ -2003,6 +2021,19 @@ const paperTradingPlugin = {
2003
2021
  pluginToolNames = toolNames;
2004
2022
  pluginInitialised = true;
2005
2023
  logger.info(TAG, `Registered ${gatedTools.length} tools (gate mode=${toolGate.getMode()}): ${toolNames.join(', ')}. Plugin v3.8.0 (${runtime.mode} mode)`);
2024
+ // Agent-readiness reporter (docs/AGENT_READINESS_GATE_PLAN.md Phase 1):
2025
+ // probe host→Binance reachability (HTTP 451 geo-block) + clock drift on the
2026
+ // host and POST a plain-English report to the webapp, so a silently-broken
2027
+ // agent shows an actionable dashboard alert instead of a false green.
2028
+ // Advisory + fire-and-forget; no token → no-op. Runs here once (guarded by
2029
+ // the pluginInitialised early-return → once per process) + on an unref'd
2030
+ // interval inside the reporter.
2031
+ startReadinessReporter({
2032
+ apiBaseUrl,
2033
+ token: resolveIngestToken({ connectionToken }),
2034
+ binanceApi,
2035
+ toolCount: toolNames.length,
2036
+ });
2006
2037
  maybeStartConnectorSupervisor();
2007
2038
  },
2008
2039
  };
@@ -1,7 +1,13 @@
1
1
  import type { PositionMetadata } from '../simulator/types.js';
2
2
  export interface PendingEntry {
3
- /** Exchange-issued orderId the lookup key when the WS fill arrives. */
3
+ /** Primary lookup key. Initially the client order id (the stash happens
4
+ * BEFORE submission, when no exchange orderId exists yet); promote()
5
+ * re-keys it to the exchange-issued orderId once the REST ack returns. */
4
6
  orderId: string;
7
+ /** Client order id (idempotency key) — secondary lookup key. The WS
8
+ * ORDER_TRADE_UPDATE carries it (`o.c`), so a fill that arrives BEFORE
9
+ * the REST response (routine for market orders) still finds the stash. */
10
+ clientOrderId?: string;
5
11
  /** Canonical (un-suffixed) symbol. */
6
12
  symbol: string;
7
13
  /** Side the agent intended at create_order time. */
@@ -24,25 +30,41 @@ export declare class PendingEntryStore {
24
30
  private readonly now;
25
31
  private readonly ttlMs;
26
32
  private entries;
33
+ /** clientOrderId → primary key in `entries`. Lets WS fills that beat the
34
+ * REST ack (no exchange orderId known yet) find the stash by cid. */
35
+ private cidIndex;
27
36
  constructor(opts?: PendingEntryStoreOptions);
28
- /** Stash metadata until the WS fill for this orderId arrives. */
37
+ /** Stash metadata until the WS fill for this order arrives. Keyed by
38
+ * `orderId`; when `clientOrderId` is provided the entry is also findable
39
+ * by it (pre-submission stashes pass the cid as BOTH — see create_order). */
29
40
  put(input: {
30
41
  orderId: string;
42
+ clientOrderId?: string;
31
43
  symbol: string;
32
44
  side: 'buy' | 'sell';
33
45
  metadata: PositionMetadata;
34
46
  }): void;
35
- /** Retrieve metadata stashed for an orderId. Does NOT remove the entry —
36
- * callers should call `consume(orderId)` to remove on first match, since
37
- * multiple partial fills on the same limit order all share one orderId
38
- * and the FIRST fill is the entry; subsequent are scale-ins of an already-
39
- * registered position (state-store branch handles those). */
47
+ /** Re-key a pre-submission stash (keyed by clientOrderId) to the
48
+ * exchange-issued orderId once the REST ack returns. The cid alias keeps
49
+ * working. No-op when the entry is gone (already consumed by a WS fill
50
+ * that beat the REST response, or expired). */
51
+ promote(fromKey: string, exchangeOrderId: string): void;
52
+ /** Retrieve metadata stashed for an orderId (or clientOrderId alias).
53
+ * Does NOT remove the entry — callers should call `consume(orderId)` to
54
+ * remove on first match, since multiple partial fills on the same limit
55
+ * order all share one orderId and the FIRST fill is the entry; subsequent
56
+ * are scale-ins of an already-registered position (state-store branch
57
+ * handles those). */
40
58
  get(orderId: string): PendingEntry | undefined;
41
- /** Atomic get + remove. Use this when you've already consumed the metadata
42
- * to post an entry row and don't want it picked up again. */
59
+ /** Atomic get + remove (accepts orderId or clientOrderId). Use this when
60
+ * you've already consumed the metadata to post an entry row and don't
61
+ * want it picked up again. */
43
62
  consume(orderId: string): PendingEntry | undefined;
44
63
  /** Remove an entry without retrieval (e.g. on cancel). */
45
64
  drop(orderId: string): boolean;
65
+ /** Resolve a lookup key that may be an orderId or a clientOrderId alias. */
66
+ private resolveKey;
67
+ private deleteEntry;
46
68
  size(): number;
47
69
  getFilePath(): string;
48
70
  private pruneExpired;
@@ -33,6 +33,9 @@ export class PendingEntryStore {
33
33
  now;
34
34
  ttlMs;
35
35
  entries = new Map();
36
+ /** clientOrderId → primary key in `entries`. Lets WS fills that beat the
37
+ * REST ack (no exchange orderId known yet) find the stash by cid. */
38
+ cidIndex = new Map();
36
39
  constructor(opts) {
37
40
  const base = resolvePluginsBaseDir(opts?.basePath);
38
41
  this.dir = join(base, opts?.pluginId ?? DEFAULT_PLUGIN_ID);
@@ -43,45 +46,91 @@ export class PendingEntryStore {
43
46
  mkdirSync(this.dir, { recursive: true });
44
47
  this.loadOrInit();
45
48
  }
46
- /** Stash metadata until the WS fill for this orderId arrives. */
49
+ /** Stash metadata until the WS fill for this order arrives. Keyed by
50
+ * `orderId`; when `clientOrderId` is provided the entry is also findable
51
+ * by it (pre-submission stashes pass the cid as BOTH — see create_order). */
47
52
  put(input) {
48
53
  const ts = this.now();
49
54
  this.entries.set(input.orderId, {
50
55
  orderId: input.orderId,
56
+ ...(input.clientOrderId ? { clientOrderId: input.clientOrderId } : {}),
51
57
  symbol: input.symbol,
52
58
  side: input.side,
53
59
  metadata: input.metadata,
54
60
  createdAt: ts,
55
61
  expiresAt: ts + this.ttlMs,
56
62
  });
63
+ if (input.clientOrderId)
64
+ this.cidIndex.set(input.clientOrderId, input.orderId);
57
65
  this.persist();
58
66
  }
59
- /** Retrieve metadata stashed for an orderId. Does NOT remove the entry —
60
- * callers should call `consume(orderId)` to remove on first match, since
61
- * multiple partial fills on the same limit order all share one orderId
62
- * and the FIRST fill is the entry; subsequent are scale-ins of an already-
63
- * registered position (state-store branch handles those). */
67
+ /** Re-key a pre-submission stash (keyed by clientOrderId) to the
68
+ * exchange-issued orderId once the REST ack returns. The cid alias keeps
69
+ * working. No-op when the entry is gone (already consumed by a WS fill
70
+ * that beat the REST response, or expired). */
71
+ promote(fromKey, exchangeOrderId) {
72
+ const key = this.resolveKey(fromKey);
73
+ if (key === undefined || key === exchangeOrderId)
74
+ return;
75
+ const entry = this.entries.get(key);
76
+ if (!entry)
77
+ return;
78
+ this.entries.delete(key);
79
+ entry.orderId = exchangeOrderId;
80
+ this.entries.set(exchangeOrderId, entry);
81
+ if (entry.clientOrderId)
82
+ this.cidIndex.set(entry.clientOrderId, exchangeOrderId);
83
+ this.persist();
84
+ }
85
+ /** Retrieve metadata stashed for an orderId (or clientOrderId alias).
86
+ * Does NOT remove the entry — callers should call `consume(orderId)` to
87
+ * remove on first match, since multiple partial fills on the same limit
88
+ * order all share one orderId and the FIRST fill is the entry; subsequent
89
+ * are scale-ins of an already-registered position (state-store branch
90
+ * handles those). */
64
91
  get(orderId) {
65
92
  this.pruneExpired();
66
- return this.entries.get(orderId);
93
+ const key = this.resolveKey(orderId);
94
+ return key === undefined ? undefined : this.entries.get(key);
67
95
  }
68
- /** Atomic get + remove. Use this when you've already consumed the metadata
69
- * to post an entry row and don't want it picked up again. */
96
+ /** Atomic get + remove (accepts orderId or clientOrderId). Use this when
97
+ * you've already consumed the metadata to post an entry row and don't
98
+ * want it picked up again. */
70
99
  consume(orderId) {
71
100
  this.pruneExpired();
72
- const entry = this.entries.get(orderId);
101
+ const key = this.resolveKey(orderId);
102
+ if (key === undefined)
103
+ return undefined;
104
+ const entry = this.entries.get(key);
73
105
  if (!entry)
74
106
  return undefined;
75
- this.entries.delete(orderId);
107
+ this.deleteEntry(key, entry);
76
108
  this.persist();
77
109
  return entry;
78
110
  }
79
111
  /** Remove an entry without retrieval (e.g. on cancel). */
80
112
  drop(orderId) {
81
- const existed = this.entries.delete(orderId);
82
- if (existed)
83
- this.persist();
84
- return existed;
113
+ const key = this.resolveKey(orderId);
114
+ if (key === undefined)
115
+ return false;
116
+ const entry = this.entries.get(key);
117
+ if (!entry)
118
+ return false;
119
+ this.deleteEntry(key, entry);
120
+ this.persist();
121
+ return true;
122
+ }
123
+ /** Resolve a lookup key that may be an orderId or a clientOrderId alias. */
124
+ resolveKey(k) {
125
+ if (this.entries.has(k))
126
+ return k;
127
+ const viaCid = this.cidIndex.get(k);
128
+ return viaCid !== undefined && this.entries.has(viaCid) ? viaCid : undefined;
129
+ }
130
+ deleteEntry(key, entry) {
131
+ this.entries.delete(key);
132
+ if (entry.clientOrderId)
133
+ this.cidIndex.delete(entry.clientOrderId);
85
134
  }
86
135
  size() {
87
136
  return this.entries.size;
@@ -95,7 +144,7 @@ export class PendingEntryStore {
95
144
  let pruned = false;
96
145
  for (const [orderId, entry] of this.entries) {
97
146
  if (entry.expiresAt <= ts) {
98
- this.entries.delete(orderId);
147
+ this.deleteEntry(orderId, entry);
99
148
  pruned = true;
100
149
  }
101
150
  }
@@ -128,6 +177,8 @@ export class PendingEntryStore {
128
177
  for (const entry of file.entries) {
129
178
  if (entry.expiresAt > ts) {
130
179
  this.entries.set(entry.orderId, entry);
180
+ if (entry.clientOrderId)
181
+ this.cidIndex.set(entry.clientOrderId, entry.orderId);
131
182
  kept++;
132
183
  }
133
184
  else {
@@ -163,6 +214,9 @@ export class PendingEntryStore {
163
214
  if (typeof e.orderId !== 'string' || e.orderId.length === 0) {
164
215
  throw new Error('pending-entry-metadata: entry missing orderId');
165
216
  }
217
+ if (e.clientOrderId !== undefined && (typeof e.clientOrderId !== 'string' || e.clientOrderId.length === 0)) {
218
+ throw new Error('pending-entry-metadata: invalid clientOrderId');
219
+ }
166
220
  if (typeof e.symbol !== 'string' || e.symbol.length === 0) {
167
221
  throw new Error('pending-entry-metadata: entry missing symbol');
168
222
  }
@@ -173,6 +173,17 @@ export async function onClosePositionFilled(ctx, inputs, order) {
173
173
  ctx.stateStore.remove(inputs.symbol);
174
174
  logger.info(TAG, `close captured ${inputs.symbol} reason=${inputs.closeReason} (positionId=${stateEntry.webappPositionId.slice(0, 8)}…)`);
175
175
  }
176
+ /** Look up the metadata create_order stashed for this fill. Primary key is
177
+ * the exchange orderId (post-REST-ack `promote()`); the clientOrderId
178
+ * fallback covers the routine market-order race where the WS fill arrives
179
+ * BEFORE the REST response, i.e. before the promote — the pre-submission
180
+ * stash is keyed by the cid, which the WS event carries (`o.c`). */
181
+ function consumePendingEntry(ctx, fill) {
182
+ if (!ctx.pendingEntries)
183
+ return undefined;
184
+ return (ctx.pendingEntries.consume(fill.exchangeOrderId) ??
185
+ (fill.clientOrderId ? ctx.pendingEntries.consume(fill.clientOrderId) : undefined));
186
+ }
176
187
  export async function onWsFillObserved(ctx, fill) {
177
188
  if (!ctx.decisionsClient || !ctx.userId || !ctx.stateStore)
178
189
  return;
@@ -208,7 +219,7 @@ export async function onWsFillObserved(ctx, fill) {
208
219
  logger.warn(TAG, `onWsFillObserved ${fill.symbol}: scale-in observed but no webappPositionId yet — skipping (parent upsert may have failed; will retry next fill)`);
209
220
  return;
210
221
  }
211
- const pending = ctx.pendingEntries?.consume(fill.exchangeOrderId);
222
+ const pending = consumePendingEntry(ctx, fill);
212
223
  const entry = buildEntryPayload({
213
224
  positionId: stateEntry.webappPositionId,
214
225
  isScaleIn: true,
@@ -227,8 +238,8 @@ export async function onWsFillObserved(ctx, fill) {
227
238
  }
228
239
  // No state-store entry → NEW position via WS-driven fill. Replicates the
229
240
  // synchronous-market-order path but pulls metadata from the pending cache
230
- // (which create_order populated when the limit was placed).
231
- const pending = ctx.pendingEntries?.consume(fill.exchangeOrderId);
241
+ // (which create_order populated BEFORE submitting the order).
242
+ const pending = consumePendingEntry(ctx, fill);
232
243
  ctx.stateStore.upsert({
233
244
  symbol: fill.symbol,
234
245
  openedAt: ts,
@@ -0,0 +1,19 @@
1
+ import { type ReadinessReport } from '@reefclaw/shared';
2
+ import type { BinancePublicApi } from '../ccxt/binance-public.js';
3
+ export interface ReadinessReporterOptions {
4
+ apiBaseUrl: string;
5
+ token: string;
6
+ binanceApi: Pick<BinancePublicApi, 'probeReachability'>;
7
+ /** Number of trading tools registered (a health signal). */
8
+ toolCount: number;
9
+ fetchImpl?: typeof fetch;
10
+ intervalMs?: number;
11
+ requestTimeoutMs?: number;
12
+ }
13
+ /** Run every connect-phase check once and assemble the report. Exported for
14
+ * unit tests. */
15
+ export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'binanceApi' | 'toolCount'>): Promise<ReadinessReport>;
16
+ /** Test-only — reset the singleton guard between unit tests. */
17
+ export declare function __resetReadinessReporterForTests(): void;
18
+ /** Fire the readiness report once at boot then on an unref'd interval. */
19
+ export declare function startReadinessReporter(opts: ReadinessReporterOptions): void;