@hypelens/hypelens-agent-rail 0.1.20 → 0.1.22

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/src/exchange.js CHANGED
@@ -8,9 +8,26 @@ import { pretradeCheckFull, fullFeedConfigured, resolveCoin } from './core.js';
8
8
 
9
9
  const net = () => process.env.HYPELENS_NET || 'mainnet';
10
10
 
11
+ /** Master / sole-wallet key (OpenBroker): auto ApproveBuilderFee. Never the builder sink. */
12
+ function masterPk() {
13
+ return process.env.HYPELENS_MASTER_PK || process.env.HYPERLIQUID_PRIVATE_KEY || '';
14
+ }
15
+ /** Agent place key; falls back to HYPERLIQUID_PRIVATE_KEY for fresh-wallet OpenBroker mode. */
16
+ function placePk() {
17
+ return process.env.HYPELENS_AGENT_PK || process.env.HYPERLIQUID_PRIVATE_KEY || '';
18
+ }
19
+ const FORBIDDEN_BUILDER = '0x9548B8E9554a1968843B3C380431b10996247c88';
20
+ function assertNotBuilderWallet(addr, label) {
21
+ if (addr && addr.toLowerCase() === FORBIDDEN_BUILDER.toLowerCase()) {
22
+ throw new Error('Refusing to trade/farm ' + label + ' ' + FORBIDDEN_BUILDER + ' — builder fee sink only, never a trading wallet');
23
+ }
24
+ }
25
+
11
26
  export function status() {
12
27
  const { actions, signer } = loadShipped();
13
28
  const st = signer.selfTest();
29
+ const hasPlace = Boolean(placePk());
30
+ const hasMaster = Boolean(masterPk());
14
31
  return {
15
32
  net: net(),
16
33
  mainnetPlacementEnabled: actions.MAINNET_PLACEMENT_ENABLED,
@@ -18,54 +35,185 @@ export function status() {
18
35
  feeModel: '1bp on fills only — no subscription',
19
36
  signerReady: st.ok, signerError: st.ok ? null : st.error,
20
37
  hasAgentKey: Boolean(process.env.HYPELENS_AGENT_PK),
38
+ hasMasterKey: hasMaster,
39
+ hasPlaceKey: hasPlace,
21
40
  fullFeedConfigured: fullFeedConfigured(),
22
41
  first_success: 'mainnet place',
23
- nextTool: Boolean(process.env.HYPELENS_AGENT_PK) ? 'hl_approve_payloads' : 'hl_new_agent_wallet',
24
- next: Boolean(process.env.HYPELENS_AGENT_PK)
25
- ? 'hl_approve_payloads(agentAddress) then hl_balances — do not stop'
42
+ nextTool: hasPlace ? 'hl_approve_payloads' : 'hl_new_agent_wallet',
43
+ next: hasPlace
44
+ ? (hasMaster
45
+ ? 'hl_approve_payloads(agentAddress) — master key present: auto ApproveBuilderFee then hl_balances'
46
+ : 'hl_approve_payloads(agentAddress) then hl_balances — do not skip ApproveBuilderFee')
26
47
  : 'hl_new_agent_wallet',
27
- note: 'Call exactly ONE nextTool. Heat blocked until after first place.'
48
+ note: 'Call exactly ONE nextTool. Heat blocked until after first place. Place only on explicit trade intent.'
28
49
  };
29
50
  }
30
51
 
31
- const MISSING_PK = 'approve missing — hl_new_agent_wallet → export HYPELENS_AGENT_PK → hl_approve_payloads → hl_balances → hl_place_order({coin:"BTC", isBuy:true, sizeUsd:12, leverage:2}) (mainnet)';
52
+ const MISSING_PK = 'approve missing — hl_new_agent_wallet → export HYPELENS_AGENT_PK (or HYPERLIQUID_PRIVATE_KEY) → hl_approve_payloads → hl_balances → hl_place_order({coin:"BTC", isBuy:true, sizeUsd:12, leverage:2}) (mainnet)';
32
53
 
33
54
  function assertPlacementAllowed(actions) {
34
55
  if (net() === 'mainnet' && !actions.MAINNET_PLACEMENT_ENABLED) {
35
56
  throw new Error('wrong net — MAINNET_PLACEMENT_ENABLED false; set HYPELENS_NET=mainnet with placement enabled, or HYPELENS_NET=testnet to practice');
36
57
  }
37
- if (!process.env.HYPELENS_AGENT_PK) throw new Error(MISSING_PK);
58
+ if (!placePk()) throw new Error(MISSING_PK);
38
59
  }
39
60
 
40
61
  function agentAddress() {
41
62
  const { signer } = loadShipped();
42
- if (!process.env.HYPELENS_AGENT_PK) throw new Error(MISSING_PK);
43
- return signer.addressFromPrivateKey(process.env.HYPELENS_AGENT_PK);
63
+ if (!placePk()) throw new Error(MISSING_PK);
64
+ const addr = signer.addressFromPrivateKey(placePk());
65
+ assertNotBuilderWallet(addr, 'agent');
66
+ return addr;
44
67
  }
45
68
 
46
- export function approvePayloads(agentAddressArg) {
69
+ /** Account that must have ApproveBuilderFee (master when set, else place wallet). */
70
+ function feeAccountAddress() {
71
+ const { signer } = loadShipped();
72
+ const explicit = process.env.HYPERLIQUID_ACCOUNT_ADDRESS || process.env.HYPELENS_MASTER_ADDRESS || '';
73
+ if (explicit && /^0x[0-9a-fA-F]{40}$/.test(explicit)) {
74
+ assertNotBuilderWallet(explicit, 'account');
75
+ return explicit;
76
+ }
77
+ if (masterPk()) {
78
+ const a = signer.addressFromPrivateKey(masterPk());
79
+ assertNotBuilderWallet(a, 'master');
80
+ return a;
81
+ }
82
+ return agentAddress();
83
+ }
84
+
85
+ /** HL info maxBuilderFee — tenths of a bp. Need >= BUILDER_F (10) for 1bp attach. */
86
+ export async function checkMaxBuilderFee({ user } = {}) {
47
87
  const { actions } = loadShipped();
88
+ const u = user || feeAccountAddress();
89
+ const r = await fetch(actions.NET[net()].info, {
90
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ type: 'maxBuilderFee', user: u, builder: actions.BUILDER })
92
+ });
93
+ if (!r.ok) throw new Error('maxBuilderFee HTTP ' + r.status);
94
+ const raw = await r.json();
95
+ const maxTenthsBp = Number(raw);
96
+ const required = actions.BUILDER_F;
97
+ const approved = Number.isFinite(maxTenthsBp) && maxTenthsBp >= required;
98
+ return {
99
+ user: u,
100
+ builder: actions.BUILDER,
101
+ maxBuilderFeeTenthsBp: maxTenthsBp,
102
+ requiredTenthsBp: required,
103
+ maxFeeRate: actions.MAX_BUILDER_FEE_RATE,
104
+ approved,
105
+ net: net()
106
+ };
107
+ }
108
+
109
+ async function postUserSigned(built, privateKey) {
110
+ const { actions, signer } = loadShipped();
111
+ assertNotBuilderWallet(signer.addressFromPrivateKey(privateKey), 'signer');
112
+ const signed = await signer.signUserSigned(privateKey, built.action);
113
+ const res = await fetch(actions.NET[net()].exchange, {
114
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify({ action: signed.action, signature: signed.signature, nonce: signed.nonce })
116
+ });
117
+ const body = await res.json().catch(() => ({}));
118
+ const ok = res.ok && body && body.status === 'ok';
119
+ return { ok, net: net(), response: body, error: ok ? null : (typeof body.response === 'string' ? body.response : JSON.stringify(body)) };
120
+ }
121
+
122
+ /**
123
+ * OpenBroker pattern: if master/sole key present, sign+POST ApproveBuilderFee (and approveAgent when agent differs).
124
+ * If only agent key: return clear unsigned payloads + maxBuilderFee status — never silent $0.
125
+ */
126
+ export async function approvePayloads(agentAddressArg) {
127
+ const { actions, signer } = loadShipped();
128
+ if (!actions.isAddr(agentAddressArg)) throw new Error('bad agentAddress');
129
+ assertNotBuilderWallet(agentAddressArg, 'agent');
48
130
  const exchange = actions.NET[net()].exchange;
49
131
  const nextPlace = { coin: 'BTC', isBuy: true, sizeUsd: 12, leverage: 2 };
132
+ const approveAgent = actions.buildApproveAgent(net(), agentAddressArg);
133
+ const approveBuilderFee = actions.buildApproveBuilderFee(net());
134
+ const mk = masterPk();
135
+ let auto = null;
136
+ let maxCheck = null;
137
+ try {
138
+ maxCheck = await checkMaxBuilderFee({ user: feeAccountAddress() });
139
+ } catch (e) {
140
+ maxCheck = { error: String(e && e.message ? e.message : e), approved: false };
141
+ }
142
+
143
+ if (mk) {
144
+ const masterAddr = signer.addressFromPrivateKey(mk);
145
+ assertNotBuilderWallet(masterAddr, 'master');
146
+ const results = { master: masterAddr, approveBuilderFee: null, approveAgent: null };
147
+ // Always (re)approve builder fee once when master key available
148
+ results.approveBuilderFee = await postUserSigned(approveBuilderFee, mk);
149
+ // approveAgent only when agent is a distinct API wallet
150
+ if (masterAddr.toLowerCase() !== agentAddressArg.toLowerCase()) {
151
+ results.approveAgent = await postUserSigned(approveAgent, mk);
152
+ } else {
153
+ results.approveAgent = { ok: true, skipped: true, reason: 'agent_is_master_fresh_wallet' };
154
+ }
155
+ auto = results;
156
+ try { maxCheck = await checkMaxBuilderFee({ user: masterAddr }); } catch (_) { /* keep prior */ }
157
+ const autoOk = Boolean(results.approveBuilderFee && results.approveBuilderFee.ok);
158
+ return {
159
+ net: net(),
160
+ mode: 'auto_master',
161
+ autoApproved: autoOk,
162
+ auto,
163
+ maxBuilderFee: maxCheck,
164
+ approveAgent,
165
+ approveBuilderFee,
166
+ builder: actions.BUILDER,
167
+ maxFeeRate: actions.MAX_BUILDER_FEE_RATE,
168
+ fee: '1bp on fills (no sub)',
169
+ blocker: autoOk ? null : 'auto_approve_failed',
170
+ mustApproveBuilderFee: true,
171
+ funnel_step: autoOk ? 3 : 2,
172
+ steps: autoOk
173
+ ? ['MASTER auto ApproveBuilderFee POSTed', 'Call ONLY hl_balances', 'If equity=0: deposit USDC', 'When equity>0 + user trade intent: hl_place_order sizeUsd:12']
174
+ : ['Auto ApproveBuilderFee failed — inspect auto.approveBuilderFee.error', 'Retry hl_approve_payloads or MASTER-sign payloads below', 'Then hl_balances → place on intent'],
175
+ nextTool: 'hl_balances',
176
+ next: autoOk
177
+ ? 'ApproveBuilderFee auto-POSTed → hl_balances. If equity=0 show fundHow; when funded + user trade intent → hl_place_order.'
178
+ : 'Auto approve failed — fix master key / net, retry hl_approve_payloads. Skipping ApproveBuilderFee = $0 fees.',
179
+ nextCalls: ['hl_balances'],
180
+ nextPlace,
181
+ fundAfterApprove: {
182
+ depositAddress: agentAddressArg,
183
+ asset: 'USDC',
184
+ app: net() === 'testnet' ? 'https://app.hyperliquid-testnet.xyz' : 'https://app.hyperliquid.xyz',
185
+ minUsdSuggest: 15
186
+ },
187
+ note: 'OpenBroker: master key auto ApproveBuilderFee. Agent key places. Never trade ' + FORBIDDEN_BUILDER
188
+ };
189
+ }
190
+
191
+ // Agent-only: refuse silent $0 — clear payload + maxBuilderFee gate
50
192
  return {
51
193
  net: net(),
52
- approveAgent: actions.buildApproveAgent(net(), agentAddressArg),
53
- approveBuilderFee: actions.buildApproveBuilderFee(net()),
194
+ mode: 'agent_only_manual_master',
195
+ autoApproved: false,
196
+ maxBuilderFee: maxCheck,
197
+ approveAgent,
198
+ approveBuilderFee,
54
199
  builder: actions.BUILDER,
55
200
  maxFeeRate: actions.MAX_BUILDER_FEE_RATE,
56
201
  fee: '1bp on fills (no sub)',
57
- blocker: 'master_must_sign_and_post_both',
202
+ blocker: (maxCheck && maxCheck.approved) ? null : 'master_must_sign_and_post_ApproveBuilderFee',
58
203
  mustApproveBuilderFee: true,
59
- funnel_step: 3,
204
+ refusePlaceUntilApproved: !(maxCheck && maxCheck.approved),
205
+ funnel_step: 2,
60
206
  steps: [
61
- 'MASTER signs approveAgent + ApproveBuilderFee (EIP-712) — not agent pk',
207
+ 'MASTER signs approveAgent + ApproveBuilderFee (EIP-712) — not agent pk (set HYPERLIQUID_PRIVATE_KEY / HYPELENS_MASTER_PK to auto)',
62
208
  'POST both to ' + exchange,
209
+ 'Verify maxBuilderFee >= 10 tenths-bp for builder ' + actions.BUILDER,
63
210
  'Then call ONLY hl_balances',
64
- 'If equity=0: ask user to deposit USDC to agentAddress on Hyperliquid (app.hyperliquid.xyz), then hl_balances again',
65
211
  'When equity>0 and user asked to trade: hl_place_order sizeUsd:12'
66
212
  ],
67
- nextTool: 'hl_balances',
68
- next: 'After MASTER POST both → hl_balances. If equity=0 show depositAddress and ask user to fund USDC, then hl_balances again.',
213
+ nextTool: (maxCheck && maxCheck.approved) ? 'hl_balances' : 'hl_approve_payloads',
214
+ next: (maxCheck && maxCheck.approved)
215
+ ? 'Builder fee already approved on-chain → hl_balances → place on user trading intent'
216
+ : 'MASTER must POST ApproveBuilderFee (or export HYPERLIQUID_PRIVATE_KEY and re-call). Place with builder is REFUSED until maxBuilderFee>=10 — no silent $0.',
69
217
  nextCalls: ['hl_balances'],
70
218
  nextPlace,
71
219
  fundAfterApprove: {
@@ -74,20 +222,22 @@ export function approvePayloads(agentAddressArg) {
74
222
  app: net() === 'testnet' ? 'https://app.hyperliquid-testnet.xyz' : 'https://app.hyperliquid.xyz',
75
223
  minUsdSuggest: 15
76
224
  },
77
- note: 'Skipping ApproveBuilderFee = $0 fees. After POST: hl_balances → fund if equity=0 → place on ' + net() + ' only with user trading intent.'
225
+ note: 'Skipping ApproveBuilderFee = $0 fees. Agent-only mode cannot auto-approve. After POST: hl_balances → fund if equity=0 → place on ' + net() + ' only with user trading intent.'
78
226
  };
79
227
  }
80
228
 
81
229
  export function newAgentWallet() {
82
230
  const { signer, sdk } = loadShipped();
83
231
  const pk = sdk.randomPrivateKey();
232
+ const address = signer.addressFromPrivateKey(pk);
233
+ assertNotBuilderWallet(address, 'agent');
84
234
  return {
85
235
  privateKey: pk,
86
- address: signer.addressFromPrivateKey(pk),
236
+ address,
87
237
  funnel_step: 2,
88
238
  nextTool: 'hl_approve_payloads',
89
- next: 'export HYPELENS_AGENT_PK=<privateKey>; call hl_approve_payloads(address) ONLY — do not stop',
90
- note: 'Do not call heat tools. Next ONE tool = hl_approve_payloads.'
239
+ next: 'export HYPELENS_AGENT_PK=<privateKey>; optional export HYPERLIQUID_PRIVATE_KEY=<master> for auto ApproveBuilderFee; call hl_approve_payloads(address) — when user wants to trade',
240
+ note: 'Do not call heat tools. Next ONE tool = hl_approve_payloads. OpenBroker: master key auto-approves builder fee.'
91
241
  };
92
242
  }
93
243
 
@@ -123,23 +273,39 @@ async function assetMetaAndMark(coin) {
123
273
 
124
274
  /**
125
275
  * Resolve coin size + entryPx. Preferred first-place path: sizeUsd only
126
- * (fetches mark, size = sizeUsd/mark, rounds to szDecimals). Explicit size+entryPx still works.
276
+ * (fetches mark, size = sizeUsd/mark, rounds to szDecimals, IOC ~0.5% slip).
277
+ * Explicit entryPx = GTC limit intent (no slip).
278
+ * @param {{coin, size, entryPx, sizeUsd, isBuy}} opts
127
279
  */
128
- async function resolveSizeAndPx({ coin, size, entryPx, sizeUsd }) {
280
+ async function resolveSizeAndPx({ coin, size, entryPx, sizeUsd, isBuy = true }) {
129
281
  const { actions } = loadShipped();
130
- const needMark = entryPx == null || (size == null && sizeUsd != null);
282
+ const limitIntent = entryPx != null;
283
+ const needMark = !limitIntent || (size == null && sizeUsd != null);
131
284
  const meta = needMark ? await assetMetaAndMark(coin) : await assetMeta(coin);
132
- let px = entryPx != null ? Number(entryPx) : meta.markPx;
285
+ let markPx = meta.markPx ?? null;
286
+ let px;
287
+ let tif;
288
+ if (limitIntent) {
289
+ px = Number(entryPx);
290
+ tif = 'Gtc';
291
+ } else {
292
+ // Market/IOC path: same 0.5% slip through mark as closePosition
293
+ if (!(markPx > 0)) throw new Error('entryPx required (or omit and pass sizeUsd for auto mark)');
294
+ px = isBuy ? markPx * 1.005 : markPx * 0.995;
295
+ tif = 'Ioc';
296
+ }
133
297
  if (!(px > 0)) throw new Error('entryPx required (or omit and pass sizeUsd for auto mark)');
134
298
  let sz = size != null ? Number(size) : null;
135
299
  let usedSizeUsd = sizeUsd != null ? Number(sizeUsd) : null;
300
+ // Size from notional uses mark (true market size), not slipped px
301
+ const sizePx = (!limitIntent && markPx > 0) ? markPx : px;
136
302
  if (sz == null) {
137
303
  if (!(usedSizeUsd > 0)) {
138
- throw new Error('size or sizeUsd required — first place: {coin:"BTC", isBuy:true, sizeUsd:12, leverage:2} (auto mark + szDecimals round; entryPx optional)');
304
+ throw new Error('size or sizeUsd required — first place: {coin:"BTC", isBuy:true, sizeUsd:12, leverage:2} (IOC @ mark+0.5% slip; pass entryPx for GTC limit)');
139
305
  }
140
- sz = usedSizeUsd / px;
306
+ sz = usedSizeUsd / sizePx;
141
307
  } else if (usedSizeUsd == null) {
142
- usedSizeUsd = sz * px;
308
+ usedSizeUsd = sz * sizePx;
143
309
  }
144
310
  sz = actions.roundToDecimals(sz, meta.szDecimals);
145
311
  // Validate wire size will not collapse to 0
@@ -154,8 +320,10 @@ async function resolveSizeAndPx({ coin, size, entryPx, sizeUsd }) {
154
320
  size: sz,
155
321
  entryPx: px,
156
322
  sizeUsd: usedSizeUsd,
157
- markPx: meta.markPx ?? null,
158
- autoRounded: size == null || entryPx == null
323
+ markPx,
324
+ tif,
325
+ limitIntent,
326
+ autoRounded: size == null || !limitIntent
159
327
  };
160
328
  }
161
329
 
@@ -202,7 +370,7 @@ async function postL1(action) {
202
370
  const { actions, signer } = loadShipped();
203
371
  assertPlacementAllowed(actions);
204
372
  const nonce = actions.nonce();
205
- const signed = await signer.signL1(process.env.HYPELENS_AGENT_PK, action, nonce, net() === 'testnet', null);
373
+ const signed = await signer.signL1(placePk(), action, nonce, net() === 'testnet', null);
206
374
  const res = await fetch(actions.NET[net()].exchange, {
207
375
  method: 'POST', headers: { 'Content-Type': 'application/json' },
208
376
  body: JSON.stringify({ action: signed.action, signature: signed.signature, nonce: signed.nonce })
@@ -300,14 +468,54 @@ export async function getPositions({ user, coin } = {}) {
300
468
  next: positions.length
301
469
  ? 'optional hl_close_position / hl_cancel_order — or another ' + placeCall
302
470
  : 'no positions yet — call hl_balances then ' + placeCall + ' (do not open heat tools)',
303
- note: 'Paying path = mainnet place. Do not stop.'
471
+ note: 'Paying path = mainnet place on explicit user trading intent.'
304
472
  };
305
473
  }
306
474
 
307
- export async function placeOrder({ coin, isBuy, size = null, entryPx = null, sizeUsd = null, slPx = null, tpPx = null, leverage = null, override = false, skipRiskCheck = false }) {
475
+ export async function placeOrder({ coin, isBuy, size = null, entryPx = null, sizeUsd = null, slPx = null, tpPx = null, leverage = null, override = false, skipRiskCheck = false, confirmTrade = false, intent = null, skipBuilderFeeCheck = false }) {
308
476
  const { actions } = loadShipped();
309
477
  assertPlacementAllowed(actions);
310
- const resolved = await resolveSizeAndPx({ coin, size, entryPx, sizeUsd });
478
+ // Consent: place only on explicit trade intent (skill + confirmTrade/intent)
479
+ const intentOk = confirmTrade === true || ['trade', 'place', 'execute', 'buy', 'sell'].includes(String(intent || '').toLowerCase());
480
+ if (!intentOk) {
481
+ return {
482
+ placed: false,
483
+ refused: 'consent_required',
484
+ error: 'Place requires explicit trade intent — pass confirmTrade:true or intent:"trade"|"place"|"execute" (ClawScan-safe). Do not place unsolicited.',
485
+ builderFeeAttached: false,
486
+ next: 'hl_place_order({..., confirmTrade:true}) only after user asked to trade/place/execute'
487
+ };
488
+ }
489
+ // Hard fee gate: refuse place-with-builder until maxBuilderFee approved (no silent $0)
490
+ let feeGate = { approved: true, skipped: true };
491
+ if (!skipBuilderFeeCheck && process.env.HYPELENS_TEST_SKIP_FEE_GATE !== '1') {
492
+ try {
493
+ feeGate = await checkMaxBuilderFee();
494
+ } catch (e) {
495
+ return {
496
+ placed: false,
497
+ refused: 'maxBuilderFee_check_failed',
498
+ error: String(e && e.message ? e.message : e),
499
+ builderFeeAttached: false,
500
+ next: 'hl_approve_payloads → MASTER ApproveBuilderFee (or set HYPERLIQUID_PRIVATE_KEY for auto) → retry place'
501
+ };
502
+ }
503
+ if (!feeGate.approved) {
504
+ const payloads = await approvePayloads(agentAddress());
505
+ return {
506
+ placed: false,
507
+ refused: 'builder_fee_not_approved',
508
+ error: 'maxBuilderFee=' + feeGate.maxBuilderFeeTenthsBp + ' < required ' + feeGate.requiredTenthsBp + ' — refusing place-with-builder (would be $0 fees). MASTER must ApproveBuilderFee 0.01% for ' + actions.BUILDER,
509
+ maxBuilderFee: feeGate,
510
+ approvePayloads: payloads,
511
+ builderFeeAttached: false,
512
+ next: masterPk()
513
+ ? 'hl_approve_payloads(agentAddress) — master key will auto-POST ApproveBuilderFee, then retry place'
514
+ : 'Export HYPERLIQUID_PRIVATE_KEY / HYPELENS_MASTER_PK and call hl_approve_payloads, OR MASTER-sign approvePayloads.approveBuilderFee and POST — then retry. No silent $0.'
515
+ };
516
+ }
517
+ }
518
+ const resolved = await resolveSizeAndPx({ coin, size, entryPx, sizeUsd, isBuy });
311
519
  let risk = null;
312
520
  const lev = leverage != null ? Number(leverage) : null;
313
521
  if (!skipRiskCheck && lev) {
@@ -346,7 +554,8 @@ export async function placeOrder({ coin, isBuy, size = null, entryPx = null, siz
346
554
  entryPx: resolved.entryPx,
347
555
  size: resolved.size,
348
556
  slPx,
349
- tpPx
557
+ tpPx,
558
+ tif: resolved.tif
350
559
  });
351
560
  const posted = await postL1(action);
352
561
  const out = {
@@ -354,11 +563,16 @@ export async function placeOrder({ coin, isBuy, size = null, entryPx = null, siz
354
563
  net: posted.net,
355
564
  response: posted.response,
356
565
  risk,
357
- builderFeeAttached: posted.ok,
566
+ builder: { b: actions.BUILDER.toLowerCase(), f: actions.BUILDER_F },
567
+ builderFeeAttached: posted.ok && Boolean(action.builder && action.builder.f === actions.BUILDER_F),
568
+ maxBuilderFee: feeGate,
358
569
  coin: resolved.name,
359
570
  size: resolved.size,
360
571
  entryPx: resolved.entryPx,
361
572
  sizeUsd: resolved.sizeUsd,
573
+ markPx: resolved.markPx,
574
+ tif: resolved.tif,
575
+ limitIntent: resolved.limitIntent,
362
576
  autoRounded: resolved.autoRounded,
363
577
  sizingWarn,
364
578
  feeModel: '1bp on fills only — no subscription'
@@ -389,9 +603,19 @@ export async function cancelOrder({ coin, oid = null, cloid = null }) {
389
603
  }
390
604
 
391
605
  /** IOC reduce-only close for a coin (uses position size if size omitted). */
392
- export async function closePosition({ coin, size = null, px = null, user } = {}) {
606
+ export async function closePosition({ coin, size = null, px = null, user, confirmTrade = false, intent = null, skipBuilderFeeCheck = false } = {}) {
393
607
  const { actions } = loadShipped();
394
608
  assertPlacementAllowed(actions);
609
+ const intentOk = confirmTrade === true || ['trade', 'place', 'execute', 'close', 'cancel'].includes(String(intent || '').toLowerCase());
610
+ if (!intentOk) {
611
+ return { closed: false, refused: 'consent_required', error: 'Close requires explicit intent — pass confirmTrade:true', builderFeeAttached: false };
612
+ }
613
+ if (!skipBuilderFeeCheck && process.env.HYPELENS_TEST_SKIP_FEE_GATE !== '1') {
614
+ const feeGate = await checkMaxBuilderFee();
615
+ if (!feeGate.approved) {
616
+ return { closed: false, refused: 'builder_fee_not_approved', maxBuilderFee: feeGate, builderFeeAttached: false, error: 'ApproveBuilderFee required before close-with-builder' };
617
+ }
618
+ }
395
619
  const name = resolveCoin(coin).toUpperCase();
396
620
  const { positions } = await getPositions({ user, coin: name });
397
621
  const pos = positions[0];
@@ -0,0 +1,211 @@
1
+ // HypeLens Agent Rail — EXECUTION (builder-code monetized).
2
+ // Reuses Module 3: hl-actions.js builds actions (builder fee pinned on place/close),
3
+ // hl-signer.js signs through the vendored SDK. Default HYPELENS_NET=testnet.
4
+ // Fee model: 1bp (0.01%) on fills only — no subscription. BUILDER_F=10.
5
+ // Risk/heat advisory when feed missing; danger-wall refuses unless override.
6
+ import { loadShipped } from './load.js';
7
+ import { pretradeCheckFull, fullFeedConfigured, resolveCoin } from './core.js';
8
+
9
+ const net = () => process.env.HYPELENS_NET || 'testnet';
10
+
11
+ export function status() {
12
+ const { actions, signer } = loadShipped();
13
+ const st = signer.selfTest();
14
+ return {
15
+ net: net(),
16
+ mainnetPlacementEnabled: actions.MAINNET_PLACEMENT_ENABLED,
17
+ builder: actions.BUILDER, builderFeeTenthsBp: actions.BUILDER_F, maxFeeRate: actions.MAX_BUILDER_FEE_RATE,
18
+ feeModel: '1bp on fills only — no subscription',
19
+ signerReady: st.ok, signerError: st.ok ? null : st.error,
20
+ hasAgentKey: Boolean(process.env.HYPELENS_AGENT_PK),
21
+ fullFeedConfigured: fullFeedConfigured()
22
+ };
23
+ }
24
+
25
+ function assertPlacementAllowed(actions) {
26
+ if (net() === 'mainnet' && !actions.MAINNET_PLACEMENT_ENABLED) {
27
+ throw new Error('MAINNET PLACEMENT DISABLED — Set HYPELENS_NET=testnet.');
28
+ }
29
+ if (!process.env.HYPELENS_AGENT_PK) throw new Error('HYPELENS_AGENT_PK not set — run the approve flow first (see approvePayloads)');
30
+ }
31
+
32
+ function agentAddress() {
33
+ const { signer } = loadShipped();
34
+ if (!process.env.HYPELENS_AGENT_PK) throw new Error('HYPELENS_AGENT_PK not set');
35
+ return signer.addressFromPrivateKey(process.env.HYPELENS_AGENT_PK);
36
+ }
37
+
38
+ export function approvePayloads(agentAddressArg) {
39
+ const { actions } = loadShipped();
40
+ return {
41
+ approveAgent: actions.buildApproveAgent(net(), agentAddressArg),
42
+ approveBuilderFee: actions.buildApproveBuilderFee(net()),
43
+ note: 'Sign both with the MASTER wallet (EIP-712), POST each as {action, signature, nonce} to ' + actions.NET[net()].exchange
44
+ };
45
+ }
46
+
47
+ export function newAgentWallet() {
48
+ const { signer, sdk } = loadShipped();
49
+ const pk = sdk.randomPrivateKey();
50
+ return { privateKey: pk, address: signer.addressFromPrivateKey(pk), note: 'store as HYPELENS_AGENT_PK; approve via approvePayloads(address)' };
51
+ }
52
+
53
+ async function assetMeta(coin) {
54
+ const { actions } = loadShipped();
55
+ const r = await fetch(actions.NET[net()].info, {
56
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify({ type: 'meta' })
58
+ });
59
+ const meta = await r.json();
60
+ const name = resolveCoin(coin).toUpperCase();
61
+ const i = meta.universe.findIndex((u) => u.name === name);
62
+ if (i < 0) throw new Error('coin not on ' + net() + ': ' + coin);
63
+ return { assetIndex: i, szDecimals: meta.universe[i].szDecimals, name };
64
+ }
65
+
66
+ async function postL1(action) {
67
+ const { actions, signer } = loadShipped();
68
+ assertPlacementAllowed(actions);
69
+ const nonce = actions.nonce();
70
+ const signed = await signer.signL1(process.env.HYPELENS_AGENT_PK, action, nonce, net() === 'testnet', null);
71
+ const res = await fetch(actions.NET[net()].exchange, {
72
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({ action: signed.action, signature: signed.signature, nonce: signed.nonce })
74
+ });
75
+ const body = await res.json().catch(() => ({}));
76
+ return { ok: res.ok && body.status === 'ok', net: net(), response: body };
77
+ }
78
+
79
+ async function clearinghouse(user) {
80
+ const { actions } = loadShipped();
81
+ const r = await fetch(actions.NET[net()].info, {
82
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify({ type: 'clearinghouseState', user })
84
+ });
85
+ if (!r.ok) throw new Error('clearinghouseState HTTP ' + r.status);
86
+ return r.json();
87
+ }
88
+
89
+ /** Balances / margin summary for the agent (or explicit user). */
90
+ export async function getBalances({ user } = {}) {
91
+ const u = user || agentAddress();
92
+ const state = await clearinghouse(u);
93
+ const ms = state.marginSummary || {};
94
+ const equity = Number(ms.accountValue || 0);
95
+ return {
96
+ net: net(),
97
+ user: u,
98
+ marginSummary: ms,
99
+ withdrawable: state.withdrawable ?? null,
100
+ equity,
101
+ sizingWarnThresholdUsd: equity * 0.2,
102
+ sizing_rule: 'Warn if sizeUsd > 20% of account equity; size from balance; confirm mark before place.',
103
+ feeModel: '1bp on fills only — no subscription'
104
+ };
105
+ }
106
+
107
+ /** Open perp positions (assetPositions). */
108
+ export async function getPositions({ user, coin } = {}) {
109
+ const u = user || agentAddress();
110
+ const state = await clearinghouse(u);
111
+ let positions = (state.assetPositions || []).map((ap) => {
112
+ const p = ap.position || ap;
113
+ return {
114
+ coin: p.coin,
115
+ szi: Number(p.szi || 0),
116
+ entryPx: p.entryPx != null ? Number(p.entryPx) : null,
117
+ positionValue: p.positionValue != null ? Number(p.positionValue) : null,
118
+ unrealizedPnl: p.unrealizedPnl != null ? Number(p.unrealizedPnl) : null,
119
+ leverage: p.leverage || null,
120
+ liquidationPx: p.liquidationPx != null ? Number(p.liquidationPx) : null,
121
+ marginUsed: p.marginUsed != null ? Number(p.marginUsed) : null
122
+ };
123
+ }).filter((p) => p.szi !== 0);
124
+ if (coin) {
125
+ const c = resolveCoin(coin).toUpperCase();
126
+ positions = positions.filter((p) => (p.coin || '').toUpperCase() === c);
127
+ }
128
+ return { net: net(), user: u, positions };
129
+ }
130
+
131
+ export async function placeOrder({ coin, isBuy, size, entryPx, slPx = null, tpPx = null, leverage = null, override = false, skipRiskCheck = false }) {
132
+ const { actions } = loadShipped();
133
+ assertPlacementAllowed(actions);
134
+ let risk = null;
135
+ if (!skipRiskCheck && leverage) {
136
+ risk = await pretradeCheckFull({ coin, dir: isBuy ? 'long' : 'short', leverage, entryPx });
137
+ if (risk.refuseReason === 'full_feed_unconfigured') {
138
+ risk = { ...risk, advisory: true, feedAdvisory: true, note: (risk.note || '') + ' Place allowed without full feed; heat is advisory.' };
139
+ }
140
+ if (risk.verdict === 'danger' && risk.refuseReason !== 'full_feed_unconfigured' && !override) {
141
+ const wallSz = risk.wall && risk.wall.sizeUsd != null
142
+ ? Math.round(risk.wall.sizeUsd / 1e6)
143
+ : (risk.wall && risk.wall.sizeUsdCoarse != null ? Math.round(risk.wall.sizeUsdCoarse / 1e6) : '?');
144
+ return {
145
+ placed: false,
146
+ refused: 'liq price ' + risk.liqPx + ' lands inside a $' + wallSz + 'M wall — pass override:true to force',
147
+ risk,
148
+ builderFeeAttached: false
149
+ };
150
+ }
151
+ }
152
+ const { assetIndex, szDecimals } = await assetMeta(coin);
153
+ const action = actions.buildOrderAction({ assetIndex, szDecimals, isBuy, entryPx, size, slPx, tpPx });
154
+ const posted = await postL1(action);
155
+ return { placed: posted.ok, net: posted.net, response: posted.response, risk, builderFeeAttached: posted.ok };
156
+ }
157
+
158
+ /** Cancel by oid and/or cloid. */
159
+ export async function cancelOrder({ coin, oid = null, cloid = null }) {
160
+ const { actions } = loadShipped();
161
+ assertPlacementAllowed(actions);
162
+ const { assetIndex } = await assetMeta(coin);
163
+ let action;
164
+ if (cloid) action = actions.buildCancelByCloidAction([{ assetIndex, cloid }]);
165
+ else if (oid != null) action = actions.buildCancelAction([{ assetIndex, oid: Number(oid) }]);
166
+ else throw new Error('oid or cloid required');
167
+ const posted = await postL1(action);
168
+ return { cancelled: posted.ok, net: posted.net, response: posted.response, coin: resolveCoin(coin).toUpperCase(), oid, cloid };
169
+ }
170
+
171
+ /** IOC reduce-only close for a coin (uses position size if size omitted). */
172
+ export async function closePosition({ coin, size = null, px = null, user } = {}) {
173
+ const { actions } = loadShipped();
174
+ assertPlacementAllowed(actions);
175
+ const name = resolveCoin(coin).toUpperCase();
176
+ const { positions } = await getPositions({ user, coin: name });
177
+ const pos = positions[0];
178
+ if (!pos || !pos.szi) throw new Error('no open position for ' + name);
179
+ const absSz = Math.abs(pos.szi);
180
+ const closeSz = size != null ? Number(size) : absSz;
181
+ if (!(closeSz > 0) || closeSz > absSz + 1e-12) throw new Error('bad close size');
182
+ const isBuy = pos.szi < 0; // short -> buy to close
183
+ let entryPx = px;
184
+ if (entryPx == null) {
185
+ const { actions: a2 } = loadShipped();
186
+ const r = await fetch(a2.NET[net()].info, {
187
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
188
+ body: JSON.stringify({ type: 'metaAndAssetCtxs' })
189
+ });
190
+ const [meta, ctxs] = await r.json();
191
+ const i = meta.universe.findIndex((u) => u.name === name);
192
+ if (i < 0) throw new Error('coin not in meta: ' + name);
193
+ entryPx = Number(ctxs[i].markPx);
194
+ // slip 0.5% through for IOC fill
195
+ entryPx = isBuy ? entryPx * 1.005 : entryPx * 0.995;
196
+ }
197
+ const { assetIndex, szDecimals } = await assetMeta(name);
198
+ const action = actions.buildCloseAction({ assetIndex, szDecimals, isBuy, entryPx, size: closeSz });
199
+ const posted = await postL1(action);
200
+ return {
201
+ closed: posted.ok,
202
+ net: posted.net,
203
+ response: posted.response,
204
+ coin: name,
205
+ size: closeSz,
206
+ isBuy,
207
+ entryPx,
208
+ builderFeeAttached: posted.ok,
209
+ feeModel: '1bp on fills only — no subscription'
210
+ };
211
+ }
package/src/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export { walls, cascade, pretradeCheck, whaleBook, getFeed, getMeta, binWalls, resolveCoin, quickstart } from './core.js';
2
- export { status as exchangeStatus, placeOrder, approvePayloads, newAgentWallet, getBalances, getPositions, cancelOrder, closePosition } from './exchange.js';
2
+ export { status as exchangeStatus, placeOrder, approvePayloads, newAgentWallet, getBalances, getPositions, cancelOrder, closePosition, checkMaxBuilderFee } from './exchange.js';