@reefclaw/openclaw-plugin 0.1.11 → 0.1.13
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.
- package/bridge/gateway/event-parser.d.ts +42 -0
- package/bridge/gateway/event-parser.js +88 -0
- package/bridge/gateway/heartbeat-cron.d.ts +1 -0
- package/bridge/gateway/heartbeat-cron.js +22 -1
- package/bridge/providers/gateway.d.ts +49 -0
- package/bridge/providers/gateway.js +167 -13
- package/bridge/types.d.ts +34 -0
- package/bridge/utils/identity-name.d.ts +24 -0
- package/bridge/utils/identity-name.js +54 -0
- package/ccxt/binance-public.d.ts +4 -2
- package/ccxt/binance-public.js +39 -3
- package/live/proposal-decision-listener.d.ts +20 -0
- package/live/proposal-decision-listener.js +211 -48
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/tools/create-order.d.ts +11 -0
- package/tools/create-order.js +23 -2
- package/tools/get-risk-summary.d.ts +4 -0
- package/tools/get-risk-summary.js +62 -23
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +4 -0
- package/venues/hyperliquid/hl-bracket-coordinator.js +2 -2
- package/venues/hyperliquid/hl-live-adapter.d.ts +12 -0
- package/venues/hyperliquid/hl-live-adapter.js +69 -6
- package/venues/hyperliquid/hl-order.d.ts +35 -0
- package/venues/hyperliquid/hl-order.js +123 -0
- package/venues/hyperliquid/hl-position.d.ts +36 -0
- package/venues/hyperliquid/hl-position.js +127 -0
- package/venues/hyperliquid/hl-private.d.ts +20 -3
- package/venues/hyperliquid/hl-private.js +37 -6
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the agent's display name out of an OpenClaw workspace `IDENTITY.md`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from GatewayProvider so the placeholder rules are unit-testable
|
|
5
|
+
* against the real template text — a placeholder that slips through is shown
|
|
6
|
+
* to the user AS the agent's name, which is exactly the bug this guards
|
|
7
|
+
* (2026-07-26: a dashboard header read `(pick something you like)` while the
|
|
8
|
+
* file said `Rook`). See docs/CLAUDE/agent-runtime.md §6.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Extract `- **Name:** <X>` from IDENTITY.md markdown.
|
|
12
|
+
*
|
|
13
|
+
* Returns `undefined` when the file has no name, the value is an unfilled
|
|
14
|
+
* template placeholder, or it looks like an id rather than a name — callers
|
|
15
|
+
* fail open to their own UI placeholder.
|
|
16
|
+
*
|
|
17
|
+
* The value may sit on the line BELOW the label (the template's own layout):
|
|
18
|
+
*
|
|
19
|
+
* - **Name:**
|
|
20
|
+
* Rook
|
|
21
|
+
*
|
|
22
|
+
* which the `\s*` between `**` and the capture spans.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseIdentityName(markdown: string): string | undefined;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the agent's display name out of an OpenClaw workspace `IDENTITY.md`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from GatewayProvider so the placeholder rules are unit-testable
|
|
5
|
+
* against the real template text — a placeholder that slips through is shown
|
|
6
|
+
* to the user AS the agent's name, which is exactly the bug this guards
|
|
7
|
+
* (2026-07-26: a dashboard header read `(pick something you like)` while the
|
|
8
|
+
* file said `Rook`). See docs/CLAUDE/agent-runtime.md §6.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Values the template ships (or a half-filled file leaves behind) that must
|
|
12
|
+
* never reach the UI. Matched AFTER markdown emphasis is stripped.
|
|
13
|
+
*/
|
|
14
|
+
const PLACEHOLDER_PATTERNS = [
|
|
15
|
+
/\btbd\b/i,
|
|
16
|
+
/fill this in/i,
|
|
17
|
+
// OpenClaw's own template values are parenthesised prompts, e.g.
|
|
18
|
+
// _(pick something you like)_
|
|
19
|
+
// _(AI? robot? familiar? ghost in the machine? something weirder?)_
|
|
20
|
+
// _(your signature — pick one that feels right)_
|
|
21
|
+
// Any value that is ENTIRELY a bracketed aside is a prompt, not a name.
|
|
22
|
+
/^\(.*\)$/s,
|
|
23
|
+
/^\[.*\]$/s,
|
|
24
|
+
/^<.*>$/s,
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Extract `- **Name:** <X>` from IDENTITY.md markdown.
|
|
28
|
+
*
|
|
29
|
+
* Returns `undefined` when the file has no name, the value is an unfilled
|
|
30
|
+
* template placeholder, or it looks like an id rather than a name — callers
|
|
31
|
+
* fail open to their own UI placeholder.
|
|
32
|
+
*
|
|
33
|
+
* The value may sit on the line BELOW the label (the template's own layout):
|
|
34
|
+
*
|
|
35
|
+
* - **Name:**
|
|
36
|
+
* Rook
|
|
37
|
+
*
|
|
38
|
+
* which the `\s*` between `**` and the capture spans.
|
|
39
|
+
*/
|
|
40
|
+
export function parseIdentityName(markdown) {
|
|
41
|
+
const m = markdown.match(/\*\*\s*Name\s*:?\s*\*\*\s*:?\s*(.+)/i);
|
|
42
|
+
let name = m?.[1]?.trim();
|
|
43
|
+
if (!name)
|
|
44
|
+
return undefined;
|
|
45
|
+
// Strip stray markdown emphasis (the template wraps placeholders in `_`).
|
|
46
|
+
name = name.replace(/^[_*`]+|[_*`]+$/g, '').trim();
|
|
47
|
+
if (!name)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (/^\d+$/.test(name))
|
|
50
|
+
return undefined; // an id, not a name
|
|
51
|
+
if (PLACEHOLDER_PATTERNS.some((re) => re.test(name)))
|
|
52
|
+
return undefined;
|
|
53
|
+
return name;
|
|
54
|
+
}
|
package/ccxt/binance-public.d.ts
CHANGED
|
@@ -18,8 +18,10 @@ export declare class BinancePublicApi implements PublicMarketDataApi {
|
|
|
18
18
|
fetchOHLCV(symbol: string, timeframe?: string, limit?: number): Promise<CcxtOHLCV[] | null>;
|
|
19
19
|
/** Probe Binance USD-M FUTURES reachability from this host — the readiness
|
|
20
20
|
* gate's core signal. Calls the futures-explicit implicit method so it hits
|
|
21
|
-
* `fapi.binance.com`
|
|
22
|
-
*
|
|
21
|
+
* `fapi.binance.com` regardless of how the instance is configured. Keep it
|
|
22
|
+
* explicit even though the client now sets `defaultType: 'future'`: this
|
|
23
|
+
* probe must detect a futures-specific 451 geo-block, and it must not start
|
|
24
|
+
* silently probing spot if that option is ever changed.
|
|
23
25
|
* HTTP 451 = Binance geo-restriction; the ban gate does NOT classify 451, so
|
|
24
26
|
* we inspect the message here. Ban-gate compliant (assertNotBanned/noteSuccess/
|
|
25
27
|
* noteBinanceError). Outcomes:
|
package/ccxt/binance-public.js
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
// Thin wrapper around CCXT for Binance public API endpoints.
|
|
2
2
|
// No API keys required — only uses public market data.
|
|
3
|
+
//
|
|
4
|
+
// ★ USD-M FUTURES, NOT SPOT. ReefClaw trades Binance USD-M perpetuals, so every
|
|
5
|
+
// price this client serves must come from `fapi.binance.com`. `ccxt.binance` is
|
|
6
|
+
// the combined spot+futures class and defaults to SPOT, so without an explicit
|
|
7
|
+
// `defaultType: 'future'` the symbol-resolving reads (fetchTicker / fetchOHLCV /
|
|
8
|
+
// fetchOrderBook) silently resolved to the spot market. That was wrong in two
|
|
9
|
+
// ways at once:
|
|
10
|
+
// 1. SILENT: every pair with a spot twin (BTC/USDT, ETH/USDT, …) returned
|
|
11
|
+
// SPOT prices on a perp product — charts, paper marks and paper fills all
|
|
12
|
+
// priced off the wrong book, diverging from the perp by the basis.
|
|
13
|
+
// 2. LOUD: the 17 futures-ONLY contracts (1000FLOKI, 1000PEPE, 1000SHIB,
|
|
14
|
+
// 1000BONK, 1000SATS, 1000RATS, 1000LUNC, 1000XEC, 1000CAT, …) have no
|
|
15
|
+
// spot market, so every fetch threw `binance does not have market symbol`.
|
|
16
|
+
// A user holding one got a dead feed: 100% ticker failure, marks frozen at
|
|
17
|
+
// entry, unrealized stuck at 0, and the frozen NAV then fed a phantom
|
|
18
|
+
// RED-zone drawdown -> auto-flatten. Observed on a live customer box
|
|
19
|
+
// 2026-07-25 (1000FLOKI/USDT, 120/120 failed polls).
|
|
20
|
+
// `fetchFundingRate` / `fetchOpenInterest` were always futures-correct (ccxt
|
|
21
|
+
// routes them to fapi regardless) and `probeReachability` calls the futures
|
|
22
|
+
// endpoint explicitly, so only the three symbol-resolving reads change.
|
|
23
|
+
//
|
|
24
|
+
// Weights are unchanged and already futures-calibrated in binance-ban-gate.ts
|
|
25
|
+
// (ceiling 2000/2400 = the USD-M IP cap). Doc-verified against
|
|
26
|
+
// https://developers.binance.com/docs/derivatives/usds-margined-futures/market-data/rest-api
|
|
27
|
+
// (2026-07-25): host `fapi.binance.com`; /fapi/v1/ticker/24hr weight 1 for a
|
|
28
|
+
// single symbol (gate charges 2), /fapi/v1/klines weight 1-10 by limit (gate
|
|
29
|
+
// charges 2), /fapi/v1/depth weight 2-20 by limit (gate charges 5) — the gate
|
|
30
|
+
// stays conservative on all three, so this never under-counts. It does mean the
|
|
31
|
+
// calls now genuinely consume the futures budget the gate was already charging
|
|
32
|
+
// them against (spot and futures have separate IP weight pools).
|
|
3
33
|
import { createRequire } from 'node:module';
|
|
4
34
|
import { logger } from '../logger.js';
|
|
5
35
|
import { assertNotBanned, noteBinanceError, noteSuccess, BinanceBannedError } from './binance-ban-gate.js';
|
|
@@ -26,8 +56,12 @@ export class BinancePublicApi {
|
|
|
26
56
|
}
|
|
27
57
|
this.exchange = new BinanceClass({
|
|
28
58
|
enableRateLimit: true,
|
|
59
|
+
// ★ Load-bearing — see the file header. Without this ccxt.binance serves
|
|
60
|
+
// SPOT, which mispriced every perp read and hard-failed the futures-only
|
|
61
|
+
// 1000x contracts. Pinned by binance-public.test.ts.
|
|
62
|
+
options: { defaultType: 'future' },
|
|
29
63
|
});
|
|
30
|
-
logger.info(TAG, 'Binance public API initialized (no auth)');
|
|
64
|
+
logger.info(TAG, 'Binance public API initialized (no auth, USD-M futures)');
|
|
31
65
|
}
|
|
32
66
|
/** Fetch raw CCXT ticker (includes funding rate, OI, info). Returns null on error. */
|
|
33
67
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -146,8 +180,10 @@ export class BinancePublicApi {
|
|
|
146
180
|
}
|
|
147
181
|
/** Probe Binance USD-M FUTURES reachability from this host — the readiness
|
|
148
182
|
* gate's core signal. Calls the futures-explicit implicit method so it hits
|
|
149
|
-
* `fapi.binance.com`
|
|
150
|
-
*
|
|
183
|
+
* `fapi.binance.com` regardless of how the instance is configured. Keep it
|
|
184
|
+
* explicit even though the client now sets `defaultType: 'future'`: this
|
|
185
|
+
* probe must detect a futures-specific 451 geo-block, and it must not start
|
|
186
|
+
* silently probing spot if that option is ever changed.
|
|
151
187
|
* HTTP 451 = Binance geo-restriction; the ban gate does NOT classify 451, so
|
|
152
188
|
* we inspect the message here. Ban-gate compliant (assertNotBanned/noteSuccess/
|
|
153
189
|
* noteBinanceError). Outcomes:
|
|
@@ -35,6 +35,9 @@ export interface ProposalDecisionListenerHealth {
|
|
|
35
35
|
running: boolean;
|
|
36
36
|
ticksTotal: number;
|
|
37
37
|
ticksWithFires: number;
|
|
38
|
+
claimsAcquired: number;
|
|
39
|
+
claimConflicts: number;
|
|
40
|
+
claimFailures: number;
|
|
38
41
|
firesAttempted: number;
|
|
39
42
|
firesSucceeded: number;
|
|
40
43
|
firesAbandonedDrift: number;
|
|
@@ -53,6 +56,21 @@ export declare class ProposalDecisionListener {
|
|
|
53
56
|
* that finds ≥1 pending. */
|
|
54
57
|
private currentIntervalMs;
|
|
55
58
|
private health;
|
|
59
|
+
/** Claim tokens are process-local by design. A new process must never steal
|
|
60
|
+
* an old process's durable claim, because it cannot know whether the
|
|
61
|
+
* exchange accepted an order just before the crash. */
|
|
62
|
+
private readonly claimTokens;
|
|
63
|
+
/** In-memory work queue for claims whose POST or fire-result response was
|
|
64
|
+
* lost. The DB poll excludes claimed rows, so only the original process can
|
|
65
|
+
* retry its exact token/outcome; a restarted process cannot steal it. */
|
|
66
|
+
private readonly claimedCandidates;
|
|
67
|
+
private readonly acquiredClaims;
|
|
68
|
+
/** Once present, this process must never invoke createOrderTool for the
|
|
69
|
+
* proposal again. Any subsequent work is result persistence only. */
|
|
70
|
+
private readonly mutationsStarted;
|
|
71
|
+
/** Completed outcomes awaiting a successful fire-result response. Retrying
|
|
72
|
+
* this PATCH is safe; retrying the exchange mutation is not. */
|
|
73
|
+
private readonly pendingResults;
|
|
56
74
|
constructor(options: ProposalDecisionListenerOptions);
|
|
57
75
|
/** Begin the poll loop. Idempotent — subsequent calls are no-ops while
|
|
58
76
|
* the listener is already running. */
|
|
@@ -65,6 +83,8 @@ export declare class ProposalDecisionListener {
|
|
|
65
83
|
private runTick;
|
|
66
84
|
private tick;
|
|
67
85
|
private fetchPending;
|
|
86
|
+
private claimPending;
|
|
87
|
+
private forgetClaim;
|
|
68
88
|
private firePending;
|
|
69
89
|
private patchResult;
|
|
70
90
|
}
|
|
@@ -3,29 +3,31 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Phase B implementation: polls GET /api/internal/proposed_orders/pending-decisions
|
|
5
5
|
// at config.pollIntervalMs (default 3 sec). For each approved-but-unfired row:
|
|
6
|
-
// 1.
|
|
6
|
+
// 1. POST /claim — atomically acquire non-transferable DB ownership before
|
|
7
|
+
// any exchange side effect. A second listener cannot acquire the row.
|
|
8
|
+
// 2. Drift abandon — if mark price has moved more than ±0.3 R from the
|
|
7
9
|
// proposed entry since the proposal was created, fail with a transparent
|
|
8
10
|
// `fireError` rather than firing into a different setup.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// fired_order_id IS NULL filter).
|
|
11
|
+
// 3. Fire via createOrderTool with a proposal-derived exchange CID — same
|
|
12
|
+
// backend function the toggle-off path calls directly. Once this boundary
|
|
13
|
+
// starts, only result persistence may retry.
|
|
14
|
+
// 4. PATCH /fire-result with the claim token and either firedOrderId or
|
|
15
|
+
// fireError. An exact lost-response retry is idempotently acknowledged.
|
|
15
16
|
//
|
|
16
17
|
// Started by index.ts only when config.approval.mode === 'per_trade' AND
|
|
17
18
|
// ingest credentials are present. Drained at shutdown so an in-flight fire
|
|
18
|
-
//
|
|
19
|
+
// gets one final opportunity to complete its PATCH before the process exits.
|
|
19
20
|
//
|
|
20
21
|
// Why polling and not relay events: simpler — no skill <-> plugin event-bus
|
|
21
22
|
// changes needed. 3-sec poll is well under the latency a human approval flow
|
|
22
23
|
// tolerates. Refactor to SSE/relay-push only if measurement shows the poll
|
|
23
|
-
// load is meaningful (very unlikely — ~
|
|
24
|
+
// load is meaningful (very unlikely — ~0.33 req/s per active user is small).
|
|
24
25
|
//
|
|
25
26
|
// See docs/APPROVAL_MODE_DESIGN.md §2 (architecture) + §7.3 (re-validation) +
|
|
26
27
|
// §10 failure matrix.
|
|
28
|
+
import { randomUUID } from 'node:crypto';
|
|
27
29
|
import { logger, formatError } from '../logger.js';
|
|
28
|
-
import { createOrderTool } from '../tools/create-order.js';
|
|
30
|
+
import { createOrderTool, proposalEntryClientOrderId, } from '../tools/create-order.js';
|
|
29
31
|
const TAG = 'proposal-decision-listener';
|
|
30
32
|
export class ProposalDecisionListener {
|
|
31
33
|
opts;
|
|
@@ -40,6 +42,9 @@ export class ProposalDecisionListener {
|
|
|
40
42
|
running: false,
|
|
41
43
|
ticksTotal: 0,
|
|
42
44
|
ticksWithFires: 0,
|
|
45
|
+
claimsAcquired: 0,
|
|
46
|
+
claimConflicts: 0,
|
|
47
|
+
claimFailures: 0,
|
|
43
48
|
firesAttempted: 0,
|
|
44
49
|
firesSucceeded: 0,
|
|
45
50
|
firesAbandonedDrift: 0,
|
|
@@ -48,6 +53,21 @@ export class ProposalDecisionListener {
|
|
|
48
53
|
pollFailures: 0,
|
|
49
54
|
lastTickAt: null,
|
|
50
55
|
};
|
|
56
|
+
/** Claim tokens are process-local by design. A new process must never steal
|
|
57
|
+
* an old process's durable claim, because it cannot know whether the
|
|
58
|
+
* exchange accepted an order just before the crash. */
|
|
59
|
+
claimTokens = new Map();
|
|
60
|
+
/** In-memory work queue for claims whose POST or fire-result response was
|
|
61
|
+
* lost. The DB poll excludes claimed rows, so only the original process can
|
|
62
|
+
* retry its exact token/outcome; a restarted process cannot steal it. */
|
|
63
|
+
claimedCandidates = new Map();
|
|
64
|
+
acquiredClaims = new Set();
|
|
65
|
+
/** Once present, this process must never invoke createOrderTool for the
|
|
66
|
+
* proposal again. Any subsequent work is result persistence only. */
|
|
67
|
+
mutationsStarted = new Set();
|
|
68
|
+
/** Completed outcomes awaiting a successful fire-result response. Retrying
|
|
69
|
+
* this PATCH is safe; retrying the exchange mutation is not. */
|
|
70
|
+
pendingResults = new Map();
|
|
51
71
|
constructor(options) {
|
|
52
72
|
this.opts = {
|
|
53
73
|
baseUrl: options.baseUrl.replace(/\/+$/, ''),
|
|
@@ -119,7 +139,14 @@ export class ProposalDecisionListener {
|
|
|
119
139
|
async tick() {
|
|
120
140
|
this.health.ticksTotal++;
|
|
121
141
|
this.health.lastTickAt = new Date().toISOString();
|
|
122
|
-
const
|
|
142
|
+
const fetched = await this.fetchPending();
|
|
143
|
+
// Claimed rows disappear from the DB poll. Keep only this process's exact
|
|
144
|
+
// token/candidate in a private queue so lost claim and result responses
|
|
145
|
+
// can be retried without making claims stealable after restart.
|
|
146
|
+
const pendingById = new Map(this.claimedCandidates);
|
|
147
|
+
for (const proposal of fetched)
|
|
148
|
+
pendingById.set(proposal.id, proposal);
|
|
149
|
+
const pending = [...pendingById.values()];
|
|
123
150
|
if (pending.length === 0) {
|
|
124
151
|
// Idle backoff — schedule next tick at the longer idle interval until
|
|
125
152
|
// the queue becomes non-empty. This is the dominant cost-reduction lever
|
|
@@ -137,12 +164,10 @@ export class ProposalDecisionListener {
|
|
|
137
164
|
await this.firePending(p);
|
|
138
165
|
}
|
|
139
166
|
catch (err) {
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
// is still NULL until a successful PATCH writes it).
|
|
145
|
-
logger.error(TAG, `Unhandled exception firing proposal ${p.id} (will retry next tick): ${formatError(err)}`);
|
|
167
|
+
// Before the mutation latch, the original token/candidate may retry.
|
|
168
|
+
// After the latch, firePending refuses another exchange call and the
|
|
169
|
+
// durable claim/CID require manual reconciliation if no result exists.
|
|
170
|
+
logger.error(TAG, `Unhandled exception processing claimed proposal ${p.id}: ${formatError(err)}`);
|
|
146
171
|
}
|
|
147
172
|
}
|
|
148
173
|
}
|
|
@@ -158,6 +183,7 @@ export class ProposalDecisionListener {
|
|
|
158
183
|
headers: {
|
|
159
184
|
authorization: `Bearer ${this.opts.ingestToken}`,
|
|
160
185
|
'x-user-id': this.opts.userId,
|
|
186
|
+
'x-reefclaw-proposal-claim-version': '1',
|
|
161
187
|
},
|
|
162
188
|
signal: ac.signal,
|
|
163
189
|
});
|
|
@@ -179,8 +205,97 @@ export class ProposalDecisionListener {
|
|
|
179
205
|
return [];
|
|
180
206
|
}
|
|
181
207
|
}
|
|
208
|
+
async claimPending(proposalId, claimToken) {
|
|
209
|
+
const url = `${this.opts.baseUrl}/api/internal/proposed_orders/${proposalId}/claim`;
|
|
210
|
+
try {
|
|
211
|
+
const ac = new AbortController();
|
|
212
|
+
const tid = setTimeout(() => ac.abort(), this.opts.requestTimeoutMs);
|
|
213
|
+
let res;
|
|
214
|
+
try {
|
|
215
|
+
res = await this.opts.fetchImpl(url, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: {
|
|
218
|
+
'content-type': 'application/json',
|
|
219
|
+
authorization: `Bearer ${this.opts.ingestToken}`,
|
|
220
|
+
'x-user-id': this.opts.userId,
|
|
221
|
+
},
|
|
222
|
+
body: JSON.stringify({ claimToken }),
|
|
223
|
+
signal: ac.signal,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
clearTimeout(tid);
|
|
228
|
+
}
|
|
229
|
+
if (res.status === 409) {
|
|
230
|
+
this.health.claimConflicts++;
|
|
231
|
+
logger.info(TAG, `Proposal ${proposalId} was claimed by another listener`);
|
|
232
|
+
return 'conflict';
|
|
233
|
+
}
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
this.health.claimFailures++;
|
|
236
|
+
logger.warn(TAG, `POST ${url} returned ${res.status}`);
|
|
237
|
+
return 'unknown';
|
|
238
|
+
}
|
|
239
|
+
const body = await res.json();
|
|
240
|
+
if (body.ok !== true
|
|
241
|
+
|| body.id !== proposalId
|
|
242
|
+
|| body.claimToken !== claimToken) {
|
|
243
|
+
this.health.claimFailures++;
|
|
244
|
+
logger.warn(TAG, `POST ${url} returned a malformed claim acknowledgement`);
|
|
245
|
+
return 'unknown';
|
|
246
|
+
}
|
|
247
|
+
return 'acquired';
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
this.health.claimFailures++;
|
|
251
|
+
logger.warn(TAG, `POST ${url} threw: ${formatError(err)}`);
|
|
252
|
+
return 'unknown';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
forgetClaim(proposalId) {
|
|
256
|
+
this.claimTokens.delete(proposalId);
|
|
257
|
+
this.claimedCandidates.delete(proposalId);
|
|
258
|
+
this.acquiredClaims.delete(proposalId);
|
|
259
|
+
this.mutationsStarted.delete(proposalId);
|
|
260
|
+
this.pendingResults.delete(proposalId);
|
|
261
|
+
}
|
|
182
262
|
async firePending(p) {
|
|
263
|
+
let claimToken = this.claimTokens.get(p.id);
|
|
264
|
+
if (!claimToken) {
|
|
265
|
+
claimToken = randomUUID();
|
|
266
|
+
this.claimTokens.set(p.id, claimToken);
|
|
267
|
+
this.claimedCandidates.set(p.id, p);
|
|
268
|
+
}
|
|
269
|
+
if (!this.acquiredClaims.has(p.id)) {
|
|
270
|
+
const claimStatus = await this.claimPending(p.id, claimToken);
|
|
271
|
+
if (claimStatus !== 'acquired') {
|
|
272
|
+
if (claimStatus === 'conflict')
|
|
273
|
+
this.forgetClaim(p.id);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
this.acquiredClaims.add(p.id);
|
|
277
|
+
this.health.claimsAcquired++;
|
|
278
|
+
}
|
|
279
|
+
const awaitingPersistence = this.pendingResults.get(p.id);
|
|
280
|
+
if (awaitingPersistence) {
|
|
281
|
+
if (await this.patchResult(p.id, awaitingPersistence)
|
|
282
|
+
&& awaitingPersistence.firedOrderId) {
|
|
283
|
+
this.health.firesSucceeded++;
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (this.mutationsStarted.has(p.id)) {
|
|
288
|
+
logger.error(TAG, `Proposal ${p.id} has an ambiguous claimed mutation; refusing automatic re-fire`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
183
291
|
this.health.firesAttempted++;
|
|
292
|
+
if (Date.now() >= new Date(p.hardExpiresAt).getTime()) {
|
|
293
|
+
await this.patchResult(p.id, {
|
|
294
|
+
fireError: `hard_expired before fire (expired ${p.hardExpiresAt})`,
|
|
295
|
+
});
|
|
296
|
+
this.health.firesFailedTrading++;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
184
299
|
// ---- 1. Drift abandon (Locked Decision #6) ----
|
|
185
300
|
// Skip if we can't read a current price — better to let the trading path
|
|
186
301
|
// surface the error than to silently abandon on a transient ticker miss.
|
|
@@ -198,35 +313,60 @@ export class ProposalDecisionListener {
|
|
|
198
313
|
}
|
|
199
314
|
}
|
|
200
315
|
}
|
|
316
|
+
// Validation can cross the hard-expiry boundary. Claim ownership alone is
|
|
317
|
+
// not authorization to submit after the deadline.
|
|
318
|
+
if (Date.now() >= new Date(p.hardExpiresAt).getTime()) {
|
|
319
|
+
await this.patchResult(p.id, {
|
|
320
|
+
fireError: `hard_expired before exchange mutation (expired ${p.hardExpiresAt})`,
|
|
321
|
+
});
|
|
322
|
+
this.health.firesFailedTrading++;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
201
325
|
// ---- 2. Fire via createOrderTool (same path toggle-off mode uses) ----
|
|
202
326
|
// CRITICAL: do NOT pass proposalManager + userId here. The listener IS the
|
|
203
327
|
// consumer of approved proposals; passing proposalManager would recursively
|
|
204
328
|
// dual-write another proposal row for the same intent. The agent's path
|
|
205
329
|
// dual-writes; the listener's path fires the underlying order.
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
330
|
+
const entryClientOrderId = proposalEntryClientOrderId(this.opts.adapter, p.proposalUuid);
|
|
331
|
+
// This latch is set immediately before the function that can touch the
|
|
332
|
+
// exchange. No path clears it until fire-result is durably acknowledged.
|
|
333
|
+
this.mutationsStarted.add(p.id);
|
|
334
|
+
let result;
|
|
335
|
+
try {
|
|
336
|
+
result = await createOrderTool({
|
|
337
|
+
symbol: p.symbol,
|
|
338
|
+
side: p.side,
|
|
339
|
+
type: p.orderType,
|
|
340
|
+
amount: p.size,
|
|
341
|
+
// For limit orders we honour the original proposed entry. For market,
|
|
342
|
+
// the create_order tool reads the live ticker (per existing semantics).
|
|
343
|
+
price: p.orderType === 'limit' ? p.proposedEntry : undefined,
|
|
344
|
+
stopPrice: p.stopPrice,
|
|
345
|
+
target_price: p.targetPrice,
|
|
346
|
+
setup_type: p.setupType,
|
|
347
|
+
thesis: p.thesis,
|
|
348
|
+
regime: p.regime,
|
|
349
|
+
regime_confidence: p.regimeConfidence,
|
|
350
|
+
scorecard_verdict: p.scorecardVerdict,
|
|
351
|
+
confluence_score: p.confluenceScore,
|
|
352
|
+
}, {
|
|
353
|
+
binanceApi: this.opts.binanceApi,
|
|
354
|
+
adapter: this.opts.adapter,
|
|
355
|
+
autoCapture: this.opts.autoCapture,
|
|
356
|
+
operationLock: this.opts.operationLock,
|
|
357
|
+
checkWave9LiveSymbolOwnership: this.opts.checkWave9LiveSymbolOwnership,
|
|
358
|
+
entryClientOrderId,
|
|
359
|
+
// proposalManager + userId deliberately omitted — see above.
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
await this.patchResult(p.id, {
|
|
364
|
+
fireError: `order_outcome_ambiguous: create_order threw after mutation boundary ` +
|
|
365
|
+
`(clientOrderId=${entryClientOrderId}): ${formatError(err)}`,
|
|
366
|
+
});
|
|
367
|
+
this.health.firesFailedTrading++;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
230
370
|
if ('error' in result) {
|
|
231
371
|
// Trading-path rejection (risk gate, bracket attach failure, exchange
|
|
232
372
|
// rejection, etc.). Surface to the operator via the proposal row.
|
|
@@ -246,12 +386,30 @@ export class ProposalDecisionListener {
|
|
|
246
386
|
this.health.firesFailedTrading++;
|
|
247
387
|
return;
|
|
248
388
|
}
|
|
249
|
-
|
|
389
|
+
if (typeof result.id !== 'string' || result.id.length === 0) {
|
|
390
|
+
await this.patchResult(p.id, {
|
|
391
|
+
fireError: `order_outcome_ambiguous: exchange acknowledgement had no order id ` +
|
|
392
|
+
`(clientOrderId=${entryClientOrderId})`,
|
|
393
|
+
});
|
|
394
|
+
this.health.firesFailedTrading++;
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const firedOrderId = result.id;
|
|
250
398
|
logger.info(TAG, `Proposal ${p.id} fired → order ${firedOrderId}`);
|
|
251
|
-
await this.patchResult(p.id, { firedOrderId })
|
|
252
|
-
|
|
399
|
+
if (await this.patchResult(p.id, { firedOrderId })) {
|
|
400
|
+
this.health.firesSucceeded++;
|
|
401
|
+
}
|
|
253
402
|
}
|
|
254
403
|
async patchResult(proposalId, body) {
|
|
404
|
+
const claimToken = this.claimTokens.get(proposalId);
|
|
405
|
+
if (!claimToken) {
|
|
406
|
+
this.health.patchFailures++;
|
|
407
|
+
logger.error(TAG, `Refusing fire-result for unclaimed proposal ${proposalId}`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
// Persist locally before the HTTP attempt. Any lost/failed response retries
|
|
411
|
+
// only this PATCH; createOrderTool is never invoked again.
|
|
412
|
+
this.pendingResults.set(proposalId, body);
|
|
255
413
|
const url = `${this.opts.baseUrl}/api/internal/proposed_orders/${proposalId}/fire-result`;
|
|
256
414
|
try {
|
|
257
415
|
const ac = new AbortController();
|
|
@@ -265,7 +423,7 @@ export class ProposalDecisionListener {
|
|
|
265
423
|
authorization: `Bearer ${this.opts.ingestToken}`,
|
|
266
424
|
'x-user-id': this.opts.userId,
|
|
267
425
|
},
|
|
268
|
-
body: JSON.stringify(body),
|
|
426
|
+
body: JSON.stringify({ claimToken, ...body }),
|
|
269
427
|
signal: ac.signal,
|
|
270
428
|
});
|
|
271
429
|
}
|
|
@@ -273,9 +431,12 @@ export class ProposalDecisionListener {
|
|
|
273
431
|
clearTimeout(tid);
|
|
274
432
|
}
|
|
275
433
|
if (res.status === 409) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
434
|
+
this.health.patchFailures++;
|
|
435
|
+
logger.error(TAG, `Proposal ${proposalId} fire-result claim mismatch (409)`);
|
|
436
|
+
// Never retry the exchange mutation. Exact lost-response retries are
|
|
437
|
+
// 200; a 409 means this process cannot commit the result and the
|
|
438
|
+
// durable claim must be reconciled manually.
|
|
439
|
+
this.forgetClaim(proposalId);
|
|
279
440
|
return;
|
|
280
441
|
}
|
|
281
442
|
if (!res.ok) {
|
|
@@ -283,6 +444,8 @@ export class ProposalDecisionListener {
|
|
|
283
444
|
logger.warn(TAG, `PATCH ${url} → ${res.status}`);
|
|
284
445
|
return;
|
|
285
446
|
}
|
|
447
|
+
this.forgetClaim(proposalId);
|
|
448
|
+
return true;
|
|
286
449
|
}
|
|
287
450
|
catch (err) {
|
|
288
451
|
this.health.patchFailures++;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "reefclaw-paper-trading",
|
|
3
3
|
"name": "ReefClaw Trading",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.13",
|
|
5
5
|
"description": "Supervised trading plugin for the ReefClaw dashboard: paper trading with real market data (no API keys required), and optional live trading on Binance or Hyperliquid behind explicit operator opt-in, exchange API credentials, and always-on protective stop brackets. Includes the dashboard connector bridge, heartbeat automation, and remote SKILL.md instruction updates from the ReefClaw webapp.",
|
|
6
6
|
"author": "ReefClaw",
|
|
7
7
|
"activation": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "ReefClaw supervised trading plugin for OpenClaw \u2014 paper trading with real market data, optional live trading on Binance or Hyperliquid (operator opt-in, API keys, always-on protective brackets), plus the ReefClaw dashboard connector with heartbeat automation and remote SKILL.md updates from the ReefClaw webapp. Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/tools/create-order.d.ts
CHANGED
|
@@ -17,6 +17,14 @@ type Wave9LiveResidualProtector = (adapter: IExchangeAdapter, ledger: Wave9LiveE
|
|
|
17
17
|
* buildHlOrderCloid() mints the 0x0d… ORDER prefix, which the bracket parser
|
|
18
18
|
* deliberately never recognises (cross-scheme cancels are destructive). */
|
|
19
19
|
export declare function mintEntryStashCid(adapter: IExchangeAdapter): string;
|
|
20
|
+
/** Stable exchange idempotency key for one approved proposal.
|
|
21
|
+
*
|
|
22
|
+
* A listener can crash after exchange acceptance but before fire-result is
|
|
23
|
+
* persisted. Reconciliation must query the exact same client-order ID instead
|
|
24
|
+
* of submitting with a freshly generated one. Binance permits 36-character
|
|
25
|
+
* ASCII IDs; Hyperliquid requires exactly 128 bits of hex.
|
|
26
|
+
*/
|
|
27
|
+
export declare function proposalEntryClientOrderId(adapter: IExchangeAdapter, proposalUuid: string): string;
|
|
20
28
|
export declare function createOrderTool(args: {
|
|
21
29
|
symbol: string;
|
|
22
30
|
side: string;
|
|
@@ -61,6 +69,9 @@ export declare function createOrderTool(args: {
|
|
|
61
69
|
proposalManager?: ProposalManager;
|
|
62
70
|
userId?: string;
|
|
63
71
|
approvalMode?: 'off' | 'shadow' | 'per_trade';
|
|
72
|
+
/** Internal listener-only override. Keeps one approved proposal bound to a
|
|
73
|
+
* deterministic exchange idempotency key across reconciliation/restart. */
|
|
74
|
+
entryClientOrderId?: string;
|
|
64
75
|
wave9AdmissionGuard?: Wave9PaperAdmissionGuard;
|
|
65
76
|
wave9ActivationCheck?: () => Promise<{
|
|
66
77
|
available: boolean;
|
package/tools/create-order.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tool: create_order — order execution with real price data + pre-trade risk gate
|
|
2
2
|
// Readiness gate: BLOCKED unless adapter.readiness === 'READY'.
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { formatError } from '../logger.js';
|
|
5
5
|
import { getQuoteBalance, getQuoteWalletBalance } from '../balance-utils.js';
|
|
6
6
|
import { fetchCurrentPrice, fetchOrderBook, isError } from './helpers.js';
|
|
@@ -242,6 +242,27 @@ function candidateBoundFoundOrder(resolution, clientOrderId, symbol, side) {
|
|
|
242
242
|
export function mintEntryStashCid(adapter) {
|
|
243
243
|
return adapter instanceof HyperliquidLiveAdapter ? buildHlOrderCloid() : randomUUID();
|
|
244
244
|
}
|
|
245
|
+
/** Stable exchange idempotency key for one approved proposal.
|
|
246
|
+
*
|
|
247
|
+
* A listener can crash after exchange acceptance but before fire-result is
|
|
248
|
+
* persisted. Reconciliation must query the exact same client-order ID instead
|
|
249
|
+
* of submitting with a freshly generated one. Binance permits 36-character
|
|
250
|
+
* ASCII IDs; Hyperliquid requires exactly 128 bits of hex.
|
|
251
|
+
*/
|
|
252
|
+
export function proposalEntryClientOrderId(adapter, proposalUuid) {
|
|
253
|
+
const hex = proposalUuid.replace(/-/g, '').toLowerCase();
|
|
254
|
+
if (!/^[0-9a-f]{32}$/.test(hex)) {
|
|
255
|
+
throw new Error('proposalUuid must be a UUID');
|
|
256
|
+
}
|
|
257
|
+
if (adapter instanceof HyperliquidLiveAdapter) {
|
|
258
|
+
// Keep deterministic entries in the same 0x0d plain-order namespace as
|
|
259
|
+
// buildHlOrderCloid(). Using the UUID bytes directly could begin with
|
|
260
|
+
// bc7[e57] and be misclassified as a managed bracket leg.
|
|
261
|
+
const digest = createHash('sha256').update(hex).digest('hex');
|
|
262
|
+
return `0x0d${digest.slice(0, 30)}`;
|
|
263
|
+
}
|
|
264
|
+
return `rcp-${hex}`;
|
|
265
|
+
}
|
|
245
266
|
async function resolveWave9EntryCid(adapter, clientOrderId, symbol) {
|
|
246
267
|
if (!adapter.resolveOrderByClientId) {
|
|
247
268
|
return { status: 'unknown', detail: 'deterministic client-order resolver is unavailable' };
|
|
@@ -1142,7 +1163,7 @@ export async function createOrderTool(args, deps) {
|
|
|
1142
1163
|
// throws, the unused stash entry simply expires (24h TTL, pruned).
|
|
1143
1164
|
let stashCid = wave9Claimed && initialWave9Mode === 'LIVE'
|
|
1144
1165
|
? wave9ClientOrderId(args.candidate_id)
|
|
1145
|
-
:
|
|
1166
|
+
: deps.entryClientOrderId;
|
|
1146
1167
|
if (deps.autoCapture?.pendingEntries && metadata) {
|
|
1147
1168
|
stashCid ??= mintEntryStashCid(deps.adapter);
|
|
1148
1169
|
deps.autoCapture.pendingEntries.put({
|