@candledottv/agent-sdk 0.6.2 → 0.6.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.
package/dist/index.js CHANGED
@@ -445,7 +445,8 @@ var HYPERLIQUID_ALLOWED_ACTION_TYPES = [
445
445
  "cancel",
446
446
  "modify",
447
447
  "updateLeverage",
448
- "updateIsolatedMargin"
448
+ "updateIsolatedMargin",
449
+ "scheduleCancel"
449
450
  ];
450
451
  var HYPERLIQUID_EXCHANGE_URLS = {
451
452
  mainnet: "https://api.hyperliquid.xyz/exchange",
@@ -455,7 +456,13 @@ var HYPERLIQUID_INFO_URLS = {
455
456
  mainnet: "https://api.hyperliquid.xyz/info",
456
457
  testnet: "https://api.hyperliquid-testnet.xyz/info"
457
458
  };
458
- var CANDLE_HYPERLIQUID_BUILDER_ADDRESS = null;
459
+ var CANDLE_HYPERLIQUID_BUILDER_ADDRESS = "0xc43ff1f6ad9f93fc94558e07c2071b8458e7470b";
460
+ function resolveHyperliquidBuilder(override, releasePin = CANDLE_HYPERLIQUID_BUILDER_ADDRESS) {
461
+ const builder = override === undefined ? releasePin : override;
462
+ if (typeof builder !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(builder) || /^0x0{40}$/.test(builder))
463
+ throw new Error("PERPS_BUILD_REFUSED: no valid nonzero Hyperliquid builder address in the explicit override or release pin.");
464
+ return builder.toLowerCase();
465
+ }
459
466
  var HYPERLIQUID_MAX_BUILDER_FEE_TENTHS_BPS = 100;
460
467
  var HYPERLIQUID_MAX_BUILDER_FEE_RATE = "0.1%";
461
468
  var HYPERLIQUID_SIGNATURE_CHAIN_ID = "0xa4b1";
@@ -624,6 +631,43 @@ function sameValue(a, b) {
624
631
  return canonical(a) === canonical(b);
625
632
  }
626
633
  var EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
634
+ var HYPERLIQUID_CLOID_RE = /^0x[0-9a-f]{32}$/;
635
+ var HYPERLIQUID_STANDARD_ACCOUNT_MODES = ["default", "disabled"];
636
+ async function hyperliquidAccountMode(fetcher, network, address) {
637
+ const res = await fetcher(HYPERLIQUID_INFO_URLS[network], {
638
+ method: "POST",
639
+ headers: { "content-type": "application/json" },
640
+ body: JSON.stringify({ type: "userAbstraction", user: address.toLowerCase() })
641
+ });
642
+ if (!res.ok)
643
+ throw new Error("Cannot read the account mode from Hyperliquid");
644
+ let mode;
645
+ try {
646
+ mode = JSON.parse(await res.text());
647
+ } catch {
648
+ throw new Error("Hyperliquid returned an unreadable account mode");
649
+ }
650
+ if (typeof mode !== "string" || mode.length === 0 || mode.length > 64)
651
+ throw new Error("Hyperliquid returned an unreadable account mode");
652
+ return mode;
653
+ }
654
+ function perpsSetupStatusProblem(status, expect) {
655
+ if (!HYPERLIQUID_STANDARD_ACCOUNT_MODES.includes(expect.mode))
656
+ return `the account is in ${expect.mode} mode on Hyperliquid; Candle trades only a standard account`;
657
+ if (status.network !== expect.network)
658
+ return `the setup answer is for ${String(status.network)}`;
659
+ if (typeof status.address !== "string" || status.address.toLowerCase() !== expect.address.toLowerCase())
660
+ return "the setup answer is for a different wallet";
661
+ if (status.mode !== expect.mode)
662
+ return "the setup answer reports a mode Hyperliquid does not";
663
+ if (status.standardMode !== true)
664
+ return "the setup answer does not report standard mode";
665
+ if (typeof status.builder !== "string" || status.builder.toLowerCase() !== expect.builder.toLowerCase())
666
+ return "the setup answer names a builder that is not Candle's";
667
+ if (status.ready === true && (typeof status.approvedFeeTenthsBps !== "number" || status.approvedFeeTenthsBps < HYPERLIQUID_MAX_BUILDER_FEE_TENTHS_BPS))
668
+ return "the setup answer reports ready without Candle's builder approval";
669
+ return null;
670
+ }
627
671
  function checkBuilderField(action, builder) {
628
672
  if (!("builder" in action) || action.builder === undefined)
629
673
  return null;
@@ -646,7 +690,67 @@ function decimal(value) {
646
690
  const n = BigInt(whole) * 10n ** 18n + BigInt(frac.padEnd(18, "0"));
647
691
  return negative ? -n : n;
648
692
  }
649
- function checkActionIntent(action, intent) {
693
+ var ACTION_KEYS = {
694
+ order: { required: ["type", "orders", "grouping"], optional: ["builder"] },
695
+ cancel: { required: ["type", "cancels"], optional: [] },
696
+ modify: { required: ["type", "oid", "order"], optional: [] },
697
+ updateLeverage: { required: ["type", "asset", "isCross", "leverage"], optional: [] },
698
+ updateIsolatedMargin: { required: ["type", "asset", "isBuy", "ntli"], optional: [] },
699
+ scheduleCancel: { required: ["type"], optional: ["time"] }
700
+ };
701
+ function exactKeys(value, required, optional = []) {
702
+ if (!value || typeof value !== "object" || Array.isArray(value))
703
+ return false;
704
+ const keys = Object.keys(value);
705
+ return required.every((k) => keys.includes(k)) && keys.every((k) => required.includes(k) || optional.includes(k));
706
+ }
707
+ function actionShapeProblem(action) {
708
+ const shape = ACTION_KEYS[String(action.type)];
709
+ if (!shape)
710
+ return null;
711
+ if (!exactKeys(action, shape.required, shape.optional))
712
+ return `the ${String(action.type)} action carries unexpected or missing fields`;
713
+ if (action.type === "order") {
714
+ if (!["na", "normalTpsl", "positionTpsl"].includes(action.grouping))
715
+ return "unexpected order grouping";
716
+ if (action.builder !== undefined && !exactKeys(action.builder, ["b", "f"]))
717
+ return "builder field is not an object";
718
+ }
719
+ const orders = action.type === "order" ? action.orders : action.type === "modify" ? [action.order] : [];
720
+ if (!Array.isArray(orders))
721
+ return "invalid orders";
722
+ for (const raw of orders) {
723
+ if (!exactKeys(raw, ["a", "b", "p", "s", "r", "t"], ["c"]))
724
+ return "an order carries unexpected or missing fields";
725
+ const o = raw;
726
+ const t = o.t;
727
+ const limit = exactKeys(t, ["limit"]) && exactKeys(t.limit, ["tif"]);
728
+ const trigger = exactKeys(t, ["trigger"]) && exactKeys(t.trigger, ["isMarket", "triggerPx", "tpsl"]);
729
+ if (!limit && !trigger)
730
+ return "an order type carries unexpected or missing fields";
731
+ if (o.c !== undefined && (typeof o.c !== "string" || !HYPERLIQUID_CLOID_RE.test(o.c)))
732
+ return "an order carries a malformed client order id";
733
+ }
734
+ if (action.type === "cancel") {
735
+ const cancels = action.cancels;
736
+ if (!Array.isArray(cancels) || cancels.some((c) => !exactKeys(c, ["a", "o"]) || !Number.isSafeInteger(c.o) || c.o < 0))
737
+ return "a cancel carries unexpected or missing fields";
738
+ }
739
+ if (action.type === "modify") {
740
+ const oid = action.oid;
741
+ if (!(Number.isSafeInteger(oid) && oid >= 0) && !(typeof oid === "string" && HYPERLIQUID_CLOID_RE.test(oid)))
742
+ return "the modify names no order";
743
+ }
744
+ if (action.type === "scheduleCancel" && "time" in action && (!Number.isSafeInteger(action.time) || action.time <= 0))
745
+ return "the scheduled cancel carries an invalid time";
746
+ return null;
747
+ }
748
+ function withinBps(px, reference, isBuy, bps) {
749
+ const bound = BigInt(bps);
750
+ return isBuy ? px * 10000n <= reference * (10000n + bound) : px * 10000n >= reference * (10000n - bound);
751
+ }
752
+ function checkActionIntent(action, opts) {
753
+ const intent = opts.intent;
650
754
  const mainAsset = (a) => typeof a === "number" && Number.isInteger(a) && a >= 0 && a < 1e4;
651
755
  const orderList = action.type === "order" ? action.orders : action.type === "modify" ? [action.order] : [];
652
756
  if (!Array.isArray(orderList))
@@ -659,6 +763,8 @@ function checkActionIntent(action, intent) {
659
763
  return "order asset is outside the main perp universe";
660
764
  if (typeof o.b !== "boolean" || typeof o.r !== "boolean" || (decimal(o.s) ?? 0n) <= 0n || (decimal(o.p) ?? 0n) <= 0n)
661
765
  return "invalid order side, size, price or reduce-only flag";
766
+ if (opts.keyReduceOnly === true && o.r !== true)
767
+ return "this key is reduce-only and the build is not reducing";
662
768
  }
663
769
  if (action.type === "cancel") {
664
770
  if (!Array.isArray(action.cancels) || action.cancels.length !== 1 || !mainAsset(action.cancels[0]?.a))
@@ -676,24 +782,101 @@ function checkActionIntent(action, intent) {
676
782
  cancel: "cancel",
677
783
  modify: "modify",
678
784
  leverage: "updateLeverage",
679
- margin: "updateIsolatedMargin"
785
+ margin: "updateIsolatedMargin",
786
+ tpsl: "order",
787
+ scheduleCancel: "scheduleCancel"
680
788
  };
681
789
  if (action.type !== types[method])
682
790
  return "action type does not match the requested method";
791
+ if (opts.keyReduceOnly === true && method === "open")
792
+ return "this key is reduce-only and an open is not reducing";
793
+ if (method === "cancel" && opts.cancelTarget) {
794
+ const target = action.cancels[0];
795
+ if (target?.a !== opts.cancelTarget.asset || target?.o !== opts.cancelTarget.oid)
796
+ return "the cancel does not name the order Hyperliquid shows for this request";
797
+ }
683
798
  if (method === "leverage") {
684
- if (action.leverage !== p.leverage || action.isCross !== (p.mode !== "isolated"))
799
+ const wanted = p.leverage ?? opts.leverageTarget?.leverage;
800
+ const deferred = wanted === undefined && opts.awaitingVenue === true && Number.isSafeInteger(action.leverage);
801
+ if (!deferred && (typeof wanted !== "number" || action.leverage !== wanted) || action.isCross !== (p.mode !== "isolated"))
685
802
  return "leverage or mode does not match the request";
803
+ const target = opts.leverageTarget;
804
+ if (target) {
805
+ if (action.asset !== target.asset)
806
+ return "leverage asset is not the requested coin's on Hyperliquid";
807
+ const mode = action.isCross ? "cross" : "isolated";
808
+ if (mode !== target.mode && !target.flat)
809
+ return "a margin-mode change needs no position and no resting order in the coin; close and cancel first";
810
+ if (opts.keyReduceOnly === true && (mode !== target.mode || action.leverage > target.leverage))
811
+ return "this key is reduce-only: no leverage increase or margin-mode change";
812
+ }
686
813
  }
687
814
  if (method === "margin") {
688
815
  const amount = decimal(p.amount);
689
816
  if (amount === null || amount % 10n ** 12n !== 0n || !Number.isSafeInteger(action.ntli) || BigInt(action.ntli) !== amount / 10n ** 12n || action.isBuy !== true)
690
817
  return "isolated margin amount does not match the request";
818
+ if (opts.marginTarget && action.asset !== opts.marginTarget.asset)
819
+ return "margin asset is not the requested coin's on Hyperliquid";
820
+ if (opts.keyReduceOnly === true && action.ntli <= 0)
821
+ return "this key is reduce-only: margin may only be added";
822
+ }
823
+ if (method === "scheduleCancel") {
824
+ if (p.clear === true) {
825
+ if ("time" in action)
826
+ return "a clear must omit the time";
827
+ } else {
828
+ const seconds = p.seconds;
829
+ const time = action.time;
830
+ if (!Number.isSafeInteger(seconds) || seconds < 5)
831
+ return "a scheduled cancel needs at least 5 seconds";
832
+ if (!Number.isSafeInteger(time))
833
+ return "a scheduled cancel without a time";
834
+ if (opts.clock) {
835
+ const lead = time - opts.clock.now;
836
+ if (lead < HYPERLIQUID_SCHEDULE_CANCEL_MIN_LEAD_MS)
837
+ return "the scheduled time is less than 5 seconds ahead; build it again";
838
+ if (Math.abs(time - (opts.clock.now + seconds * 1000)) > HYPERLIQUID_SCHEDULE_CANCEL_DRIFT_MS)
839
+ return "the scheduled time is not the requested delay from now";
840
+ }
841
+ }
842
+ }
843
+ if (method === "tpsl") {
844
+ const list = orderList;
845
+ const leg = list[0];
846
+ if (list.length !== 1 || !leg || action.grouping !== "positionTpsl")
847
+ return "a take-profit / stop-loss attach is one positionTpsl order";
848
+ const trigger = leg.t?.trigger;
849
+ if (!trigger || leg.r !== true || trigger.isMarket !== true || trigger.tpsl !== p.leg || decimal(trigger.triggerPx) !== decimal(p.triggerPx))
850
+ return "trigger leg, reduce-only flag or price does not match the request";
851
+ if (typeof leg.c !== "string" || !HYPERLIQUID_CLOID_RE.test(leg.c))
852
+ return "the trigger leg carries no client order id";
853
+ const triggerPx = decimal(trigger.triggerPx) ?? 0n;
854
+ if (triggerPx <= 0n || !withinBps(decimal(leg.p) ?? 0n, triggerPx, leg.b === true, HYPERLIQUID_TPSL_SLIPPAGE_BPS + HYPERLIQUID_MID_DRIFT_BPS))
855
+ return "trigger order price is outside its slippage of the trigger price";
856
+ const target = opts.tpslTarget;
857
+ if (target) {
858
+ if (leg.a !== target.asset || leg.b !== target.isBuy || decimal(leg.s) !== decimal(target.size))
859
+ return "trigger side, asset or size does not match the position Hyperliquid shows";
860
+ const mark = decimal(target.markPx) ?? 0n;
861
+ const above = triggerPx > mark;
862
+ const ok = p.leg === "tp" === !target.isBuy ? above : triggerPx < mark;
863
+ if (mark <= 0n || !ok)
864
+ return "the trigger is already crossed or on the wrong side of the mark";
865
+ }
691
866
  }
692
867
  if (method === "open" || method === "close" || method === "modify") {
693
868
  const list = orderList;
694
869
  const first = list[0];
695
870
  if (!first)
696
871
  return "missing requested order";
872
+ if ((method === "open" || method === "close") && p.clientId != null) {
873
+ const clientId = typeof p.clientId === "string" ? p.clientId.toLowerCase() : null;
874
+ if (clientId === null || !HYPERLIQUID_CLOID_RE.test(clientId) || first.c !== clientId)
875
+ return "the order does not carry the requested client order id";
876
+ const ids = list.map((o) => o.c);
877
+ if (ids.some((c) => typeof c !== "string" || !HYPERLIQUID_CLOID_RE.test(c)) || new Set(ids).size !== ids.length)
878
+ return "the order legs do not carry distinct client order ids";
879
+ }
697
880
  const typ = first.t;
698
881
  if (!typ?.limit)
699
882
  return "requested order is not a limit/IOC order";
@@ -720,15 +903,15 @@ function checkActionIntent(action, intent) {
720
903
  return "trigger side, size, reduce-only flag or price does not match the request";
721
904
  const px = decimal(leg.p) ?? 0n;
722
905
  const triggerPx = decimal(trigger.triggerPx) ?? 0n;
723
- const bound = BigInt(HYPERLIQUID_TPSL_SLIPPAGE_BPS + HYPERLIQUID_MID_DRIFT_BPS);
724
- const within = leg.b === true ? px * 10000n <= triggerPx * (10000n + bound) : px * 10000n >= triggerPx * (10000n - bound);
725
- if (triggerPx <= 0n || !within)
906
+ if (triggerPx <= 0n || !withinBps(px, triggerPx, leg.b === true, HYPERLIQUID_TPSL_SLIPPAGE_BPS + HYPERLIQUID_MID_DRIFT_BPS))
726
907
  return "trigger order price is outside its slippage of the trigger price";
727
908
  }
728
909
  } else {
729
910
  if (list.length !== 1)
730
911
  return "unexpected extra orders";
731
- if (method === "close" && (first.r !== true || typ.limit.tif !== "Ioc" || p.size != null && (decimal(first.s) ?? 0n) > (decimal(p.size) ?? 0n)))
912
+ if (method === "close" && p.pct != null && p.size != null)
913
+ return "a close takes a size or a percentage, not both";
914
+ if (method === "close" && (first.r !== true || typ.limit.tif !== "Ioc" || action.grouping !== "na" || p.size != null && (decimal(first.s) ?? 0n) > (decimal(p.size) ?? 0n)))
732
915
  return "close size or reduce-only flag does not match the request";
733
916
  if (method === "modify" && p.size != null && decimal(first.s) !== decimal(p.size))
734
917
  return "replacement size does not match the request";
@@ -738,9 +921,38 @@ function checkActionIntent(action, intent) {
738
921
  }
739
922
  return null;
740
923
  }
924
+ function hyperliquidClosePct(value) {
925
+ if (typeof value !== "string" || !/^(0|[1-9][0-9]{0,2})(\.[0-9]{1,4})?$/.test(value))
926
+ return null;
927
+ const [whole = "0", frac = ""] = value.split(".");
928
+ const millionths = BigInt(whole) * 10000n + BigInt(frac.padEnd(4, "0"));
929
+ return millionths > 0n && millionths <= 1000000n ? millionths : null;
930
+ }
931
+ function hyperliquidPctSize(held, pct, szDecimals) {
932
+ const scaled = decimal(held);
933
+ if (scaled === null || scaled < 0n)
934
+ throw new Error("Invalid held size");
935
+ if (pct === 1000000n)
936
+ return held;
937
+ return hyperliquidFloorSize(formatScaled(scaled * pct / 1000000n), szDecimals);
938
+ }
939
+ function hyperliquidFloorSize(value, szDecimals) {
940
+ const scaled = decimal(value);
941
+ if (scaled === null || scaled < 0n)
942
+ throw new Error("Invalid size");
943
+ const unit = 10n ** BigInt(18 - Math.max(0, Math.min(18, szDecimals)));
944
+ return formatScaled(scaled / unit * unit);
945
+ }
946
+ function formatScaled(value) {
947
+ const negative = value < 0n;
948
+ const abs = negative ? -value : value;
949
+ const whole = abs / 10n ** 18n;
950
+ const frac = (abs % 10n ** 18n).toString().padStart(18, "0").replace(/0+$/, "");
951
+ return `${negative ? "-" : ""}${whole}${frac ? `.${frac}` : ""}`;
952
+ }
741
953
  async function hyperliquidCloseOrder(fetcher, network, address, params) {
742
954
  const read = async (body) => {
743
- const res = await fetcher(HYPERLIQUID_EXCHANGE_URLS[network].replace("/exchange", "/info"), {
955
+ const res = await fetcher(HYPERLIQUID_INFO_URLS[network], {
744
956
  method: "POST",
745
957
  headers: { "content-type": "application/json" },
746
958
  body: JSON.stringify(body)
@@ -751,16 +963,32 @@ async function hyperliquidCloseOrder(fetcher, network, address, params) {
751
963
  };
752
964
  const [meta, state] = await Promise.all([read({ type: "meta" }), read({ type: "clearinghouseState", user: address })]);
753
965
  const coin = String(params.coin).toUpperCase();
754
- const asset = meta.universe.findIndex((m) => m.name.toUpperCase() === coin);
966
+ const universe = meta.universe;
967
+ const asset = universe.findIndex((m) => m.name.toUpperCase() === coin);
755
968
  const szi = state.assetPositions.find((p) => p.position.coin.toUpperCase() === coin)?.position.szi;
756
969
  const signed = decimal(szi);
757
970
  if (asset < 0 || asset >= 1e4 || signed === null || signed === 0n)
758
971
  throw new Error("No main-universe position to close");
759
972
  const held = signed < 0n ? -signed : signed;
973
+ const heldSz = String(szi).replace(/^-/, "");
974
+ if (params.pct != null) {
975
+ if (params.size != null)
976
+ throw new Error("A close takes a size or a percentage, not both");
977
+ const pct = hyperliquidClosePct(params.pct);
978
+ const szDecimals = universe[asset]?.szDecimals;
979
+ if (pct === null)
980
+ throw new Error("pct must be above 0 and at most 100, with at most 4 decimals");
981
+ if (!Number.isInteger(szDecimals))
982
+ throw new Error("Hyperliquid did not report this market's size precision");
983
+ const size2 = hyperliquidPctSize(heldSz, pct, szDecimals);
984
+ if ((decimal(size2) ?? 0n) <= 0n)
985
+ throw new Error("This percentage of the position rounds to zero at the market's size step");
986
+ return { asset, isBuy: signed < 0n, size: size2 };
987
+ }
760
988
  const asked = params.size == null ? held : decimal(params.size);
761
989
  if (asked === null || asked <= 0n)
762
990
  throw new Error("Invalid close size");
763
- const size = asked < held ? String(params.size) : String(szi).replace(/^-/, "");
991
+ const size = asked < held ? String(params.size) : heldSz;
764
992
  return { asset, isBuy: signed < 0n, size };
765
993
  }
766
994
  var HYPERLIQUID_DEFAULT_SLIPPAGE_BPS = 100;
@@ -784,8 +1012,18 @@ async function hyperliquidOrderMarket(fetcher, network, address, method, params)
784
1012
  const resting = status?.order?.order ?? null;
785
1013
  if (!resting || typeof resting.coin !== "string" || decimal(resting.limitPx) === null)
786
1014
  throw new Error("No resting order with this cloid on Hyperliquid");
1015
+ if (resting.isTrigger === true)
1016
+ throw new Error("A take-profit or stop-loss leg cannot be modified; cancel it instead");
787
1017
  coin = resting.coin.toUpperCase();
788
- order = { isBuy: resting.side === "B", limitPx: String(resting.limitPx) };
1018
+ const tif = typeof resting.tif === "string" && ["Gtc", "Alo", "Ioc"].includes(resting.tif) ? resting.tif : "Gtc";
1019
+ order = {
1020
+ isBuy: resting.side === "B",
1021
+ limitPx: String(resting.limitPx),
1022
+ ...decimal(resting.sz) !== null ? { size: String(resting.sz) } : {},
1023
+ reduceOnly: resting.reduceOnly === true,
1024
+ tif,
1025
+ ...Number.isSafeInteger(resting.oid) ? { oid: resting.oid } : {}
1026
+ };
789
1027
  }
790
1028
  const [meta, contexts] = await read({ type: "metaAndAssetCtxs" });
791
1029
  const asset = meta.universe.findIndex((m) => m.name.toUpperCase() === coin);
@@ -822,13 +1060,24 @@ function checkOrderMarket(action, intent, market) {
822
1060
  return "modify changes the resting order's side";
823
1061
  if (p.price == null && decimal(first.p) !== decimal(market.order.limitPx))
824
1062
  return "modify changes the resting order's price";
1063
+ const requested = typeof p.cloid === "string" ? p.cloid.toLowerCase() : null;
1064
+ const byOid = market.order.oid !== undefined && action.oid === market.order.oid;
1065
+ const byCloid = requested !== null && typeof action.oid === "string" && action.oid.toLowerCase() === requested;
1066
+ if (!byOid && !byCloid)
1067
+ return "modify does not name the resting order Hyperliquid shows for this cloid";
1068
+ if (first.r !== market.order.reduceOnly)
1069
+ return "modify changes the resting order's reduce-only flag";
1070
+ if (first.t?.limit?.tif !== market.order.tif)
1071
+ return "modify changes the resting order's time in force";
1072
+ if (p.size == null && (market.order.size === undefined || decimal(first.s) !== decimal(market.order.size)))
1073
+ return "modify changes the resting order's size";
825
1074
  }
826
1075
  return null;
827
1076
  }
828
1077
  function verifyPerpsBuild(build, opts) {
829
1078
  try {
830
1079
  const network = opts.network ?? "mainnet";
831
- if (!EVM_ADDRESS_RE.test(opts.builder))
1080
+ if (!EVM_ADDRESS_RE.test(opts.builder) || opts.builder.toLowerCase() === ZERO_ADDRESS)
832
1081
  return { ok: false, reason: "no valid Candle builder address to check against" };
833
1082
  if (build.network !== undefined && build.network !== network) {
834
1083
  return { ok: false, reason: `the build is for ${build.network}, this client trades ${network}` };
@@ -844,7 +1093,7 @@ function verifyPerpsBuild(build, opts) {
844
1093
  const action = build.action;
845
1094
  if (!action || typeof action !== "object")
846
1095
  return { ok: false, reason: "no action" };
847
- const intentRefusal = checkActionIntent(action, opts.intent);
1096
+ const intentRefusal = checkActionIntent(action, opts);
848
1097
  if (intentRefusal)
849
1098
  return { ok: false, reason: intentRefusal };
850
1099
  if (opts.closeOrder) {
@@ -864,6 +1113,9 @@ function verifyPerpsBuild(build, opts) {
864
1113
  if (typeof type !== "string" || !HYPERLIQUID_ALLOWED_ACTION_TYPES.includes(type)) {
865
1114
  return { ok: false, reason: `action type ${String(type)} is not one Candle builds` };
866
1115
  }
1116
+ const shapeRefusal = actionShapeProblem(action);
1117
+ if (shapeRefusal)
1118
+ return { ok: false, reason: shapeRefusal };
867
1119
  if (type === "order") {
868
1120
  const refusal = checkBuilderField(action, opts.builder);
869
1121
  if (refusal)
@@ -884,37 +1136,2119 @@ function verifyPerpsBuild(build, opts) {
884
1136
  if (!sameValue(typed, expected)) {
885
1137
  return { ok: false, reason: "the builder approval is not Candle's builder at 0.1% on Mainnet" };
886
1138
  }
887
- const expectedAction = {
888
- type: "approveBuilderFee",
889
- hyperliquidChain: "Mainnet",
890
- signatureChainId: HYPERLIQUID_SIGNATURE_CHAIN_ID,
891
- maxFeeRate: HYPERLIQUID_MAX_BUILDER_FEE_RATE,
892
- builder: opts.builder.toLowerCase(),
893
- nonce: build.nonce
894
- };
895
- if (!sameValue(action, expectedAction)) {
896
- return { ok: false, reason: "the approval action does not match its typed data" };
1139
+ const expectedAction = {
1140
+ type: "approveBuilderFee",
1141
+ hyperliquidChain: "Mainnet",
1142
+ signatureChainId: HYPERLIQUID_SIGNATURE_CHAIN_ID,
1143
+ maxFeeRate: HYPERLIQUID_MAX_BUILDER_FEE_RATE,
1144
+ builder: opts.builder.toLowerCase(),
1145
+ nonce: build.nonce
1146
+ };
1147
+ if (!sameValue(action, expectedAction)) {
1148
+ return { ok: false, reason: "the approval action does not match its typed data" };
1149
+ }
1150
+ return { ok: true, kind: "approveBuilderFee" };
1151
+ }
1152
+ return { ok: false, reason: `primary type ${String(typed.primary_type)} is not one the relay signs` };
1153
+ } catch (err) {
1154
+ return { ok: false, reason: err instanceof Error ? err.message : "unreadable build" };
1155
+ }
1156
+ }
1157
+ var HYPERLIQUID_SCHEDULE_CANCEL_MIN_LEAD_MS = 5000;
1158
+ var HYPERLIQUID_SCHEDULE_CANCEL_DRIFT_MS = 60000;
1159
+ async function infoRead(fetcher, network, body) {
1160
+ const res = await fetcher(HYPERLIQUID_INFO_URLS[network], {
1161
+ method: "POST",
1162
+ headers: { "content-type": "application/json" },
1163
+ body: JSON.stringify(body)
1164
+ });
1165
+ if (!res.ok)
1166
+ throw new Error(`Hyperliquid ${String(body.type)} answered ${res.status ?? "an error"}`);
1167
+ try {
1168
+ return JSON.parse(await res.text());
1169
+ } catch {
1170
+ throw new Error(`Hyperliquid returned an unreadable ${String(body.type)}`);
1171
+ }
1172
+ }
1173
+ async function hyperliquidWalletSnapshot(fetcher, network, address, now = Date.now) {
1174
+ const user = address.toLowerCase();
1175
+ const [metaAndCtxs, state, open] = await Promise.all([
1176
+ infoRead(fetcher, network, { type: "metaAndAssetCtxs" }),
1177
+ infoRead(fetcher, network, { type: "clearinghouseState", user }),
1178
+ infoRead(fetcher, network, { type: "frontendOpenOrders", user })
1179
+ ]);
1180
+ const readAt = now();
1181
+ if (!Array.isArray(metaAndCtxs) || metaAndCtxs.length < 2 || !Array.isArray(metaAndCtxs[1]))
1182
+ throw new Error("Hyperliquid returned an unreadable market list");
1183
+ const rawUniverse = metaAndCtxs[0]?.universe;
1184
+ if (!Array.isArray(rawUniverse))
1185
+ throw new Error("Hyperliquid returned an unreadable market list");
1186
+ const universe = rawUniverse.map((m) => {
1187
+ const row = m;
1188
+ if (typeof row.name !== "string" || !Number.isInteger(row.szDecimals) || !Number.isInteger(row.maxLeverage))
1189
+ throw new Error("Hyperliquid returned an unreadable market");
1190
+ return {
1191
+ name: row.name,
1192
+ szDecimals: row.szDecimals,
1193
+ maxLeverage: row.maxLeverage,
1194
+ onlyIsolated: row.onlyIsolated === true
1195
+ };
1196
+ });
1197
+ const ctxs = metaAndCtxs[1];
1198
+ const marks = universe.map((_, i) => {
1199
+ const mark = ctxs[i]?.markPx;
1200
+ return typeof mark === "string" && (decimal(mark) ?? 0n) > 0n ? mark : null;
1201
+ });
1202
+ const assetOf = (coin) => universe.findIndex((m) => m.name === coin);
1203
+ const unsupported = [];
1204
+ const positionsRaw = state?.assetPositions;
1205
+ if (!Array.isArray(positionsRaw))
1206
+ throw new Error("Hyperliquid returned an unreadable account state");
1207
+ const positions = [];
1208
+ for (const entry of positionsRaw) {
1209
+ const p = entry?.position;
1210
+ const coin = p?.coin;
1211
+ const lev = p?.leverage;
1212
+ const szi = p?.szi;
1213
+ if (typeof coin !== "string" || typeof szi !== "string" || decimal(szi) === null) {
1214
+ unsupported.push(`an unreadable position (${JSON.stringify(coin ?? null)})`);
1215
+ continue;
1216
+ }
1217
+ const asset = assetOf(coin);
1218
+ if (asset < 0 || asset >= 1e4 || lev?.type !== "cross" && lev?.type !== "isolated" || !Number.isInteger(lev.value)) {
1219
+ unsupported.push(`position ${coin}`);
1220
+ continue;
1221
+ }
1222
+ if (decimal(szi) === 0n)
1223
+ continue;
1224
+ positions.push({ coin, asset, szi, leverage: { type: lev.type, value: lev.value } });
1225
+ }
1226
+ if (!Array.isArray(open))
1227
+ throw new Error("Hyperliquid returned an unreadable order list");
1228
+ const orders = [];
1229
+ for (const raw of open) {
1230
+ const o = raw;
1231
+ const coin = o?.coin;
1232
+ if (typeof coin !== "string") {
1233
+ unsupported.push("an unreadable order");
1234
+ continue;
1235
+ }
1236
+ const asset = assetOf(coin);
1237
+ const oid = o.oid;
1238
+ if (asset < 0 || asset >= 1e4 || !Number.isSafeInteger(oid) || o.side !== "A" && o.side !== "B" || decimal(o.sz) === null || decimal(o.limitPx) === null) {
1239
+ unsupported.push(`order ${coin}${Number.isSafeInteger(oid) ? ` ${oid}` : ""}`);
1240
+ continue;
1241
+ }
1242
+ const orderType = typeof o.orderType === "string" ? o.orderType : "";
1243
+ const isTrigger = o.isTrigger === true;
1244
+ const tpsl = isTrigger ? orderType.startsWith("Take Profit") ? "tp" : orderType.startsWith("Stop") ? "sl" : null : null;
1245
+ if (isTrigger && tpsl === null) {
1246
+ unsupported.push(`order ${coin} ${oid} (${orderType || "unknown trigger"})`);
1247
+ continue;
1248
+ }
1249
+ orders.push({
1250
+ coin,
1251
+ asset,
1252
+ oid,
1253
+ cloid: typeof o.cloid === "string" ? o.cloid.toLowerCase() : null,
1254
+ isBuy: o.side === "B",
1255
+ sz: o.sz,
1256
+ limitPx: o.limitPx,
1257
+ reduceOnly: o.reduceOnly === true,
1258
+ isTrigger,
1259
+ triggerPx: typeof o.triggerPx === "string" ? o.triggerPx : null,
1260
+ tpsl
1261
+ });
1262
+ }
1263
+ orders.sort((a, b) => a.asset - b.asset || a.oid - b.oid);
1264
+ positions.sort((a, b) => a.asset - b.asset);
1265
+ return { readAt, universe, marks, positions, orders, unsupported };
1266
+ }
1267
+ function snapshotPosition(snapshot, coin) {
1268
+ const upper = coin.toUpperCase();
1269
+ return snapshot.positions.find((p) => p.coin.toUpperCase() === upper) ?? null;
1270
+ }
1271
+ function hyperliquidTpslTarget(snapshot, coin) {
1272
+ const position = snapshotPosition(snapshot, coin);
1273
+ if (!position)
1274
+ throw new Error(`No ${coin} position on Hyperliquid to protect`);
1275
+ const mark = snapshot.marks[position.asset];
1276
+ const szDecimals = snapshot.universe[position.asset]?.szDecimals;
1277
+ if (!mark || szDecimals === undefined)
1278
+ throw new Error(`No Hyperliquid mark for ${position.coin}`);
1279
+ const held = position.szi.replace(/^-/, "");
1280
+ const size = hyperliquidFloorSize(held, szDecimals);
1281
+ return { asset: position.asset, isBuy: position.szi.startsWith("-"), size, markPx: mark };
1282
+ }
1283
+ async function venueEvidence(fetcher, network, address, intent, now) {
1284
+ const p = intent.params;
1285
+ switch (intent.method) {
1286
+ case "open":
1287
+ case "modify":
1288
+ return { market: await hyperliquidOrderMarket(fetcher, network, address, intent.method, p) };
1289
+ case "close":
1290
+ return { closeOrder: await hyperliquidCloseOrder(fetcher, network, address, p) };
1291
+ case "cancel": {
1292
+ if (p.oid !== undefined) {
1293
+ const snapshot = await hyperliquidWalletSnapshot(fetcher, network, address, now);
1294
+ const order = snapshot.orders.find((o) => o.oid === p.oid && (p.coin === undefined || o.coin.toUpperCase() === String(p.coin).toUpperCase()));
1295
+ if (!order)
1296
+ throw new Error("Hyperliquid shows no such open order on this wallet");
1297
+ return { cancelTarget: { asset: order.asset, oid: order.oid } };
1298
+ }
1299
+ const status = await infoRead(fetcher, network, {
1300
+ type: "orderStatus",
1301
+ user: address.toLowerCase(),
1302
+ oid: p.cloid
1303
+ });
1304
+ const resting = status?.order?.order;
1305
+ if (!resting || typeof resting.coin !== "string" || !Number.isSafeInteger(resting.oid))
1306
+ throw new Error("Hyperliquid shows no order with this cloid on this wallet");
1307
+ const meta = await infoRead(fetcher, network, { type: "meta" });
1308
+ const asset = (meta.universe ?? []).findIndex((m) => m.name === resting.coin);
1309
+ if (asset < 0 || asset >= 1e4)
1310
+ throw new Error("The order is not on Hyperliquid's main perp exchange");
1311
+ return { cancelTarget: { asset, oid: resting.oid } };
1312
+ }
1313
+ case "leverage": {
1314
+ const snapshot = await hyperliquidWalletSnapshot(fetcher, network, address, now);
1315
+ const coin = String(p.coin ?? "").toUpperCase();
1316
+ const asset = snapshot.universe.findIndex((m) => m.name.toUpperCase() === coin);
1317
+ if (asset < 0)
1318
+ throw new Error("No main-universe market for this coin on Hyperliquid");
1319
+ const name = snapshot.universe[asset]?.name;
1320
+ const data = await infoRead(fetcher, network, {
1321
+ type: "activeAssetData",
1322
+ user: address.toLowerCase(),
1323
+ coin: name
1324
+ });
1325
+ const lev = data?.leverage;
1326
+ if (lev?.type !== "cross" && lev?.type !== "isolated" || !Number.isInteger(lev.value))
1327
+ throw new Error("Hyperliquid did not report this market's leverage");
1328
+ const flat = !snapshot.positions.some((x) => x.asset === asset) && !snapshot.orders.some((o) => o.asset === asset);
1329
+ return { leverageTarget: { asset, leverage: lev.value, mode: lev.type, flat } };
1330
+ }
1331
+ case "margin": {
1332
+ const snapshot = await hyperliquidWalletSnapshot(fetcher, network, address, now);
1333
+ const position = snapshotPosition(snapshot, String(p.coin ?? ""));
1334
+ if (!position || position.leverage.type !== "isolated")
1335
+ throw new Error("Hyperliquid shows no isolated position in this coin");
1336
+ return { marginTarget: { asset: position.asset } };
1337
+ }
1338
+ case "tpsl": {
1339
+ const snapshot = await hyperliquidWalletSnapshot(fetcher, network, address, now);
1340
+ return { tpslTarget: hyperliquidTpslTarget(snapshot, String(p.coin ?? "")) };
1341
+ }
1342
+ case "scheduleCancel":
1343
+ return { clock: { now: now() } };
1344
+ default:
1345
+ return {};
1346
+ }
1347
+ }
1348
+ async function verifyPerpsBuildAgainstVenue(build, opts) {
1349
+ const { fetcher, now = Date.now, ...verify } = opts;
1350
+ const first = verifyPerpsBuild(build, { ...verify, awaitingVenue: true });
1351
+ if (!first.ok)
1352
+ return first;
1353
+ let evidence;
1354
+ try {
1355
+ evidence = await venueEvidence(fetcher, verify.network ?? "mainnet", opts.address, opts.intent, now);
1356
+ } catch (err) {
1357
+ return {
1358
+ ok: false,
1359
+ reason: `could not check it against Hyperliquid: ${err instanceof Error ? err.message : "read failed"}`
1360
+ };
1361
+ }
1362
+ return verifyPerpsBuild(build, { ...verify, ...evidence });
1363
+ }
1364
+ function hyperliquidExchangeOutcome(exchange) {
1365
+ if (!exchange || typeof exchange !== "object")
1366
+ return { status: "unknown", error: "Hyperliquid returned no answer" };
1367
+ const answer = exchange;
1368
+ if (answer.status === "err")
1369
+ return {
1370
+ status: "rejected",
1371
+ error: typeof answer.response === "string" ? answer.response : JSON.stringify(answer.response ?? null),
1372
+ legs: []
1373
+ };
1374
+ if (answer.status !== "ok")
1375
+ return { status: "unknown", error: "Hyperliquid's answer has no status" };
1376
+ const response = answer.response;
1377
+ const statuses = response?.data?.statuses;
1378
+ if (statuses === undefined) {
1379
+ return response?.type === "default" || response?.type === "scheduleCancel" || response?.type === "cancel" ? { status: "confirmed", legs: [] } : { status: "unknown", error: "Hyperliquid's answer is not one this client knows" };
1380
+ }
1381
+ if (!Array.isArray(statuses))
1382
+ return { status: "unknown", error: "Hyperliquid's answer has unreadable statuses" };
1383
+ const errors = statuses.filter((s) => s && typeof s === "object" && ("error" in s)).map((s) => String(s.error));
1384
+ if (errors.length > 0)
1385
+ return { status: "rejected", error: errors.join("; "), legs: statuses };
1386
+ return { status: "confirmed", legs: statuses };
1387
+ }
1388
+ function hyperliquidSplitSignature(signature) {
1389
+ if (!/^0x[0-9a-fA-F]{130}$/.test(signature))
1390
+ throw new Error("Not a 65-byte signature");
1391
+ let v = Number.parseInt(signature.slice(130, 132), 16);
1392
+ if (v < 27)
1393
+ v += 27;
1394
+ return { r: `0x${signature.slice(2, 66)}`, s: `0x${signature.slice(66, 130)}`, v };
1395
+ }
1396
+ function hyperliquidExchangeBody(action, nonce, signature) {
1397
+ return { action, nonce, signature: hyperliquidSplitSignature(signature), vaultAddress: null, expiresAfter: null };
1398
+ }
1399
+ function hyperliquidRelayBody(typedData) {
1400
+ return { method: "eth_signTypedData_v4", params: { typed_data: typedData } };
1401
+ }
1402
+
1403
+ // src/hyperliquid-manage.ts
1404
+ var PERPS_FLATTEN_MAX_STEPS = 100;
1405
+
1406
+ class PerpsWorkflowError extends Error {
1407
+ code;
1408
+ details;
1409
+ constructor(code, message, details = {}) {
1410
+ super(message);
1411
+ this.code = code;
1412
+ this.details = details;
1413
+ }
1414
+ }
1415
+ function errorCode(err) {
1416
+ const code = err?.code;
1417
+ return typeof code === "string" ? code : "REQUEST_FAILED";
1418
+ }
1419
+ function errorText(err) {
1420
+ return err instanceof Error ? err.message : String(err);
1421
+ }
1422
+ async function runStep(io, step, path, body, intent) {
1423
+ let build;
1424
+ try {
1425
+ build = await io.build(path, { walletId: io.walletId, ...body });
1426
+ } catch (err) {
1427
+ step.status = "rejected";
1428
+ step.detail = `${errorCode(err)}: ${errorText(err)}`;
1429
+ return false;
1430
+ }
1431
+ if (build.replayed === true) {
1432
+ step.status = "skipped";
1433
+ step.detail = "This client id already names an operation; nothing was signed";
1434
+ return false;
1435
+ }
1436
+ step.perpOrderId = typeof build.perpOrderId === "string" ? build.perpOrderId : null;
1437
+ step.nonce = typeof build.nonce === "number" ? build.nonce : null;
1438
+ if (typeof build.cloid === "string")
1439
+ step.cloid = build.cloid;
1440
+ const check = await verifyPerpsBuildAgainstVenue(build, {
1441
+ builder: io.builder,
1442
+ network: io.network,
1443
+ address: io.address,
1444
+ intent,
1445
+ fetcher: io.fetcher,
1446
+ now: io.now,
1447
+ keyReduceOnly: io.keyReduceOnly
1448
+ });
1449
+ if (!check.ok) {
1450
+ step.status = "rejected";
1451
+ step.detail = `Refused to sign this build: ${check.reason}. Nothing was signed.`;
1452
+ return false;
1453
+ }
1454
+ step.status = "built";
1455
+ if (io.mode === "no-submit")
1456
+ return false;
1457
+ let signature;
1458
+ try {
1459
+ signature = await io.sign(build.typedData);
1460
+ } catch (err) {
1461
+ step.status = "rejected";
1462
+ step.detail = `The relay did not sign (${errorCode(err)}: ${errorText(err)}). Nothing was submitted.`;
1463
+ return false;
1464
+ }
1465
+ step.signature = signature;
1466
+ const exchangeBody = hyperliquidExchangeBody(build.action, build.nonce, signature);
1467
+ if (io.mode === "sign-only") {
1468
+ step.exchangeBody = exchangeBody;
1469
+ return false;
1470
+ }
1471
+ let exchange;
1472
+ try {
1473
+ const res = await io.fetcher(HYPERLIQUID_EXCHANGE_URLS[io.network], {
1474
+ method: "POST",
1475
+ headers: { "content-type": "application/json" },
1476
+ body: JSON.stringify(exchangeBody)
1477
+ });
1478
+ const text = await res.text();
1479
+ if (!res.ok)
1480
+ throw new Error(`HTTP ${res.status ?? "error"}${res.status === 429 ? " (rate limited)" : ""}`);
1481
+ exchange = JSON.parse(text);
1482
+ } catch (err) {
1483
+ step.status = "unknown";
1484
+ step.detail = `Signed, but the submit to Hyperliquid did not answer (${errorText(err)}). Reconcile before acting again.`;
1485
+ step.exchangeBody = exchangeBody;
1486
+ return false;
1487
+ }
1488
+ step.exchange = exchange;
1489
+ const outcome = hyperliquidExchangeOutcome(exchange);
1490
+ if (outcome.status === "confirmed") {
1491
+ step.status = "confirmed";
1492
+ return true;
1493
+ }
1494
+ step.status = outcome.status;
1495
+ step.detail = outcome.error;
1496
+ if (outcome.status === "unknown")
1497
+ step.exchangeBody = exchangeBody;
1498
+ return false;
1499
+ }
1500
+ function blankStep(n, kind, coin, extra = {}) {
1501
+ return {
1502
+ step: n,
1503
+ kind,
1504
+ coin,
1505
+ target: null,
1506
+ perpOrderId: null,
1507
+ nonce: null,
1508
+ cloid: null,
1509
+ status: "planned",
1510
+ ...extra
1511
+ };
1512
+ }
1513
+ var DECIMAL_RE = /^(0|[1-9][0-9]*)(\.[0-9]+)?$/;
1514
+ var CLOID_RE = /^0x[0-9a-f]{32}$/;
1515
+ function perpsTpslParamsProblem(p) {
1516
+ if (typeof p.coin !== "string" || !/^[A-Za-z0-9]{1,20}$/.test(p.coin))
1517
+ return "coin must be a market name";
1518
+ for (const [field, value] of [
1519
+ ["takeProfit", p.takeProfit],
1520
+ ["stopLoss", p.stopLoss]
1521
+ ])
1522
+ if (value !== undefined && (typeof value !== "string" || !DECIMAL_RE.test(value) || /^0+(\.0+)?$/.test(value)))
1523
+ return `${field} must be a positive decimal`;
1524
+ if (p.takeProfit !== undefined && p.removeTp)
1525
+ return "cannot set and remove the take-profit together";
1526
+ if (p.stopLoss !== undefined && p.removeSl)
1527
+ return "cannot set and remove the stop-loss together";
1528
+ if (p.takeProfit === undefined && p.stopLoss === undefined && !p.removeTp && !p.removeSl)
1529
+ return "name at least one change: a take-profit, a stop-loss, or a removal";
1530
+ for (const [field, value] of [
1531
+ ["targetTp", p.targetTp],
1532
+ ["targetSl", p.targetSl]
1533
+ ])
1534
+ if (value !== undefined && (typeof value !== "string" || !CLOID_RE.test(value.toLowerCase())))
1535
+ return `${field} must be 0x followed by 32 hex digits`;
1536
+ if (p.targetTp !== undefined && p.takeProfit === undefined && !p.removeTp)
1537
+ return "targetTp needs a take-profit change";
1538
+ if (p.targetSl !== undefined && p.stopLoss === undefined && !p.removeSl)
1539
+ return "targetSl needs a stop-loss change";
1540
+ return null;
1541
+ }
1542
+ function sameDecimal(a, b) {
1543
+ if (typeof a !== "string" || typeof b !== "string")
1544
+ return false;
1545
+ const norm = (v) => (v.includes(".") ? v.replace(/0+$/, "").replace(/\.$/, "") : v).replace(/^0+(?=\d)/, "");
1546
+ return norm(a) === norm(b);
1547
+ }
1548
+ async function runPerpsTpsl(io, params) {
1549
+ const problem = perpsTpslParamsProblem(params);
1550
+ if (problem)
1551
+ throw new PerpsWorkflowError("VALIDATION_FAILED", problem);
1552
+ const coinQuery = encodeURIComponent(params.coin);
1553
+ const server = await io.read(`/api/v1/agent/perps/tpsl?walletId=${encodeURIComponent(io.walletId)}&coin=${coinQuery}`);
1554
+ let snapshot;
1555
+ try {
1556
+ snapshot = await hyperliquidWalletSnapshot(io.fetcher, io.network, io.address, io.now);
1557
+ } catch (err) {
1558
+ throw new PerpsWorkflowError("PERPS_UNAVAILABLE", `Cannot read the account from Hyperliquid: ${errorText(err)}`);
1559
+ }
1560
+ const coin = typeof server.coin === "string" ? server.coin : params.coin.toUpperCase();
1561
+ const venueLegs = snapshot.orders.filter((o) => o.coin === coin && o.tpsl !== null);
1562
+ const tracked = [];
1563
+ for (const raw of Array.isArray(server.tracked) ? server.tracked : []) {
1564
+ const seen = venueLegs.find((o) => o.oid === raw.oid && o.tpsl === raw.leg && (o.cloid ?? null) === (raw.cloid ?? null));
1565
+ if (seen)
1566
+ tracked.push({ leg: raw.leg, oid: seen.oid, cloid: seen.cloid, triggerPx: seen.triggerPx });
1567
+ }
1568
+ const trackedOids = new Set(tracked.map((l) => l.oid));
1569
+ const unowned = venueLegs.filter((o) => !trackedOids.has(o.oid)).map((o) => ({ leg: o.tpsl, oid: o.oid, cloid: o.cloid, triggerPx: o.triggerPx }));
1570
+ const position = snapshotPosition(snapshot, coin);
1571
+ const warnings = [];
1572
+ if (unowned.length > 0)
1573
+ warnings.push(`${unowned.length} take-profit / stop-loss leg(s) on ${coin} were not built by Candle; they are left untouched.`);
1574
+ const steps = [];
1575
+ const plans = [];
1576
+ for (const leg of ["tp", "sl"]) {
1577
+ const set = leg === "tp" ? params.takeProfit : params.stopLoss;
1578
+ const remove = leg === "tp" ? params.removeTp === true : params.removeSl === true;
1579
+ if (set === undefined && !remove)
1580
+ continue;
1581
+ const target = (leg === "tp" ? params.targetTp : params.targetSl)?.toLowerCase();
1582
+ let legs = tracked.filter((l) => l.leg === leg);
1583
+ if (target !== undefined) {
1584
+ legs = legs.filter((l) => l.cloid === target);
1585
+ if (legs.length === 0)
1586
+ throw new PerpsWorkflowError("PERPS_ORDER_NOT_FOUND", `No open ${coin} ${leg} leg Candle built carries ${target}`);
1587
+ }
1588
+ if (legs.length > 1)
1589
+ throw new PerpsWorkflowError("PERPS_TPSL_CONFLICT", `${legs.length} ${coin} ${leg === "tp" ? "take-profit" : "stop-loss"} legs match; name one with --target-${leg} <cloid>`, { candidates: legs });
1590
+ const existing = legs[0] ?? null;
1591
+ if (set !== undefined && existing && sameDecimal(existing.triggerPx, set)) {
1592
+ steps.push(blankStep(steps.length, "attach", coin, {
1593
+ leg,
1594
+ target: { oid: existing.oid, cloid: existing.cloid },
1595
+ status: "skipped",
1596
+ detail: "Already in place at this trigger"
1597
+ }));
1598
+ continue;
1599
+ }
1600
+ if (set !== undefined && !position)
1601
+ throw new PerpsWorkflowError("PERPS_NO_POSITION", `No ${coin} position on Hyperliquid to protect`);
1602
+ if (remove && !existing) {
1603
+ steps.push(blankStep(steps.length, "cancel", coin, { leg, status: "skipped", detail: "No tracked leg to remove" }));
1604
+ continue;
1605
+ }
1606
+ const cancel = existing ? blankStep(steps.length, "cancel", coin, { leg, target: { oid: existing.oid, cloid: existing.cloid } }) : null;
1607
+ if (cancel)
1608
+ steps.push(cancel);
1609
+ const attach = set !== undefined ? blankStep(steps.length, "attach", coin, { leg }) : null;
1610
+ if (attach)
1611
+ steps.push(attach);
1612
+ if (cancel && attach)
1613
+ warnings.push(`Changing the ${leg === "tp" ? "take-profit" : "stop-loss"} cancels the current leg first: ${coin} is unprotected on that side until the new one is placed.`);
1614
+ if (cancel && !attach)
1615
+ warnings.push(`Removing the ${leg === "tp" ? "take-profit" : "stop-loss"} leaves ${coin} without it.`);
1616
+ plans.push({ leg, cancel, attach, ...set !== undefined ? { triggerPx: set } : {} });
1617
+ }
1618
+ if (plans.some((p) => p.attach))
1619
+ warnings.push("New legs are sized to the position now and do not grow with later additions; triggering does not guarantee a fill.");
1620
+ const result = {
1621
+ operation: "tpsl",
1622
+ walletId: io.walletId,
1623
+ address: io.address,
1624
+ network: io.network,
1625
+ readAt: snapshot.readAt,
1626
+ complete: false,
1627
+ steps,
1628
+ remaining: [],
1629
+ warnings,
1630
+ unowned
1631
+ };
1632
+ const active = steps.filter((s) => s.status === "planned");
1633
+ if (active.length === 0) {
1634
+ result.complete = true;
1635
+ return result;
1636
+ }
1637
+ if (io.mode !== "no-submit" && io.confirm && !await io.confirm({ steps, warnings }))
1638
+ throw new PerpsWorkflowError("CANCELLED", "Nothing was signed.");
1639
+ const workflowId = `tpsl-${io.cloid().slice(2, 18)}`;
1640
+ let stopped = false;
1641
+ for (const plan of plans) {
1642
+ if (stopped)
1643
+ break;
1644
+ if (plan.cancel) {
1645
+ const step = plan.cancel;
1646
+ const ok = await runStep(io, step, "/api/v1/agent/perps/tpsl", {
1647
+ coin,
1648
+ operation: "cancel",
1649
+ leg: plan.leg,
1650
+ ...step.target?.cloid ? { targetCloid: step.target.cloid } : {},
1651
+ workflowId,
1652
+ workflowStep: step.step
1653
+ }, { method: "cancel", params: { coin, oid: step.target?.oid } });
1654
+ if (!ok) {
1655
+ stopped = true;
1656
+ break;
1657
+ }
1658
+ try {
1659
+ const after = await hyperliquidWalletSnapshot(io.fetcher, io.network, io.address, io.now);
1660
+ result.readAt = after.readAt;
1661
+ if (after.orders.some((o) => o.oid === step.target?.oid)) {
1662
+ step.status = "unknown";
1663
+ step.detail = "Hyperliquid accepted the cancel but still shows the leg open; no replacement was placed";
1664
+ stopped = true;
1665
+ break;
1666
+ }
1667
+ if (plan.attach && !snapshotPosition(after, coin)) {
1668
+ plan.attach.status = "skipped";
1669
+ plan.attach.detail = "The position closed meanwhile; no replacement was placed";
1670
+ continue;
1671
+ }
1672
+ } catch (err) {
1673
+ step.detail = `Canceled, but the follow-up read failed (${errorText(err)}); no replacement was placed`;
1674
+ stopped = true;
1675
+ break;
1676
+ }
1677
+ }
1678
+ if (plan.attach) {
1679
+ const ok = await runStep(io, plan.attach, "/api/v1/agent/perps/tpsl", {
1680
+ coin,
1681
+ operation: "attach",
1682
+ leg: plan.leg,
1683
+ triggerPx: plan.triggerPx,
1684
+ workflowId,
1685
+ workflowStep: plan.attach.step
1686
+ }, { method: "tpsl", params: { coin, leg: plan.leg, triggerPx: plan.triggerPx } });
1687
+ if (!ok)
1688
+ stopped = true;
1689
+ }
1690
+ }
1691
+ for (const s of steps) {
1692
+ if (s.status === "planned")
1693
+ result.remaining.push({ step: s.step, kind: s.kind, leg: s.leg, coin });
1694
+ if (s.kind === "cancel" && s.status === "confirmed" && plans.find((p) => p.cancel === s)?.attach?.status !== "confirmed")
1695
+ result.remaining.push({ leg: s.leg, coin, state: "old leg canceled, no new leg in place" });
1696
+ }
1697
+ result.complete = steps.every((s) => s.status === "confirmed" || s.status === "skipped") && !stopped;
1698
+ return result;
1699
+ }
1700
+ function orderTarget(o) {
1701
+ return { oid: o.oid, cloid: o.cloid };
1702
+ }
1703
+ function snapshotOrError(io) {
1704
+ return hyperliquidWalletSnapshot(io.fetcher, io.network, io.address, io.now);
1705
+ }
1706
+ async function runPerpsFlatten(io) {
1707
+ let snapshot;
1708
+ try {
1709
+ snapshot = await snapshotOrError(io);
1710
+ } catch (err) {
1711
+ throw new PerpsWorkflowError("PERPS_UNAVAILABLE", `Cannot read the account from Hyperliquid: ${errorText(err)}`);
1712
+ }
1713
+ if (snapshot.unsupported.length > 0)
1714
+ throw new PerpsWorkflowError("PERPS_MARKET_REFUSED", `Flatten reaches Hyperliquid's main perp exchange only, and this wallet holds rows it cannot classify there: ${snapshot.unsupported.slice(0, 5).join(", ")}. Nothing was built.`, { unsupported: snapshot.unsupported });
1715
+ const warnings = [
1716
+ "Scope: Hyperliquid's main perp exchange only. Spot, HIP-3 and vault exposure is not read or closed.",
1717
+ "Every open order is canceled first, take-profit and stop-loss protection included.",
1718
+ "The final state is an observed snapshot; another client can open exposure afterwards."
1719
+ ];
1720
+ const steps = [];
1721
+ for (const o of snapshot.orders)
1722
+ steps.push(blankStep(steps.length, "cancel", o.coin, { target: orderTarget(o) }));
1723
+ const closeIds = new Map;
1724
+ for (const p of snapshot.positions) {
1725
+ const id = io.cloid();
1726
+ closeIds.set(p.coin, id);
1727
+ steps.push(blankStep(steps.length, "close", p.coin, { cloid: id }));
1728
+ }
1729
+ const result = {
1730
+ operation: "flatten",
1731
+ walletId: io.walletId,
1732
+ address: io.address,
1733
+ network: io.network,
1734
+ readAt: snapshot.readAt,
1735
+ complete: false,
1736
+ steps,
1737
+ remaining: [],
1738
+ warnings,
1739
+ scope: "main-perps"
1740
+ };
1741
+ if (steps.length === 0) {
1742
+ result.complete = true;
1743
+ return result;
1744
+ }
1745
+ if (steps.length > PERPS_FLATTEN_MAX_STEPS)
1746
+ warnings.push(`${steps.length} steps planned; this run signs at most ${PERPS_FLATTEN_MAX_STEPS} and reports the rest as remaining. Run flatten again.`);
1747
+ if (io.mode !== "no-submit" && io.confirm && !await io.confirm({ steps, warnings }))
1748
+ throw new PerpsWorkflowError("CANCELLED", "Nothing was signed.");
1749
+ const workflowId = `flat-${io.cloid().slice(2, 18)}`;
1750
+ const cancelStep = (step) => runStep(io, step, "/api/v1/agent/perps/flatten/cancel", { coin: step.coin, oid: step.target?.oid, workflowId, workflowStep: step.step }, { method: "cancel", params: { coin: step.coin, oid: step.target?.oid } });
1751
+ const closeStep = (step) => {
1752
+ const clientId = closeIds.get(step.coin);
1753
+ return runStep(io, step, "/api/v1/agent/perps/close", { coin: step.coin, clientId, workflowId, workflowStep: step.step }, { method: "close", params: { coin: step.coin, clientId } });
1754
+ };
1755
+ if (io.mode !== "submit") {
1756
+ const first = steps[0];
1757
+ await (first.kind === "cancel" ? cancelStep(first) : closeStep(first));
1758
+ for (const s of steps)
1759
+ if (s.status === "planned")
1760
+ result.remaining.push({ step: s.step, kind: s.kind, coin: s.coin, target: s.target });
1761
+ return result;
1762
+ }
1763
+ let signed = 0;
1764
+ let stopped = false;
1765
+ const budgetLeft = () => signed < PERPS_FLATTEN_MAX_STEPS;
1766
+ for (const step of steps.filter((s) => s.kind === "cancel")) {
1767
+ if (stopped || !budgetLeft())
1768
+ break;
1769
+ signed++;
1770
+ if (await cancelStep(step))
1771
+ continue;
1772
+ if (step.status === "rejected" && step.detail?.startsWith("PERPS_ORDER_NOT_OPEN")) {
1773
+ step.status = "skipped";
1774
+ continue;
1775
+ }
1776
+ stopped = true;
1777
+ }
1778
+ if (!stopped && budgetLeft()) {
1779
+ let after = null;
1780
+ try {
1781
+ after = await snapshotOrError(io);
1782
+ result.readAt = after.readAt;
1783
+ } catch (err) {
1784
+ warnings.push(`The read after the cancels failed (${errorText(err)}); no position was closed.`);
1785
+ stopped = true;
1786
+ }
1787
+ if (after) {
1788
+ const fresh = after.orders.filter((o) => !steps.some((s) => s.kind === "cancel" && s.target?.oid === o.oid));
1789
+ if (fresh.length > 0)
1790
+ warnings.push(`${fresh.length} order(s) appeared during the cancels and were not part of this run's plan.`);
1791
+ for (const p of after.positions)
1792
+ if (!closeIds.has(p.coin)) {
1793
+ const id = io.cloid();
1794
+ closeIds.set(p.coin, id);
1795
+ steps.push(blankStep(steps.length, "close", p.coin, { cloid: id }));
1796
+ }
1797
+ for (const step of steps.filter((s) => s.kind === "close")) {
1798
+ if (stopped || !budgetLeft())
1799
+ break;
1800
+ let now;
1801
+ try {
1802
+ now = await snapshotOrError(io);
1803
+ result.readAt = now.readAt;
1804
+ } catch (err) {
1805
+ step.detail = `The read before this close failed (${errorText(err)})`;
1806
+ stopped = true;
1807
+ break;
1808
+ }
1809
+ const held = snapshotPosition(now, step.coin);
1810
+ if (!held) {
1811
+ step.status = "skipped";
1812
+ step.detail = "No position left to close";
1813
+ continue;
1814
+ }
1815
+ signed++;
1816
+ if (await closeStep(step)) {
1817
+ const leg = step.exchange?.response?.data?.statuses?.[0];
1818
+ const filled = typeof leg?.filled?.totalSz === "string" ? leg.filled.totalSz : null;
1819
+ const planned = held.szi.replace(/^-/, "");
1820
+ if (filled !== null) {
1821
+ step.filledSz = filled;
1822
+ if (!sameDecimal(filled, planned)) {
1823
+ step.remainingSz = `${planned} planned, ${filled} filled`;
1824
+ step.detail = "Partly filled; the rest of the position is still open";
1825
+ }
1826
+ }
1827
+ continue;
1828
+ }
1829
+ if (step.status !== "rejected" || /429|rate limit/i.test(step.detail ?? ""))
1830
+ stopped = true;
1831
+ }
1832
+ }
1833
+ }
1834
+ let final = null;
1835
+ try {
1836
+ final = await snapshotOrError(io);
1837
+ result.readAt = final.readAt;
1838
+ } catch (err) {
1839
+ warnings.push(`The final read failed (${errorText(err)}); the outcome is not known.`);
1840
+ }
1841
+ for (const s of steps)
1842
+ if (s.status === "planned")
1843
+ result.remaining.push({ step: s.step, kind: s.kind, coin: s.coin, target: s.target });
1844
+ if (final) {
1845
+ for (const p of final.positions)
1846
+ result.remaining.push({ kind: "position", coin: p.coin, szi: p.szi });
1847
+ for (const o of final.orders)
1848
+ result.remaining.push({ kind: "order", coin: o.coin, oid: o.oid });
1849
+ }
1850
+ result.complete = final !== null && final.positions.length === 0 && final.orders.length === 0 && final.unsupported.length === 0 && !stopped && steps.every((s) => s.status === "confirmed" || s.status === "skipped");
1851
+ return result;
1852
+ }
1853
+
1854
+ // src/hyperliquid-markets.ts
1855
+ var INFO_URLS = {
1856
+ mainnet: "https://api.hyperliquid.xyz/info",
1857
+ testnet: "https://api.hyperliquid-testnet.xyz/info"
1858
+ };
1859
+ var ONE = 10n ** 18n;
1860
+ var DECIMAL = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?$/;
1861
+
1862
+ class PerpsMarketError extends Error {
1863
+ code;
1864
+ retryable;
1865
+ constructor(code, message) {
1866
+ super(message);
1867
+ this.code = code;
1868
+ this.name = "PerpsMarketError";
1869
+ this.retryable = code === "PERPS_UNAVAILABLE";
1870
+ }
1871
+ }
1872
+ function unavailable() {
1873
+ throw new PerpsMarketError("PERPS_UNAVAILABLE", "Hyperliquid market data is unavailable; retry shortly.");
1874
+ }
1875
+ function usage(message) {
1876
+ throw new PerpsMarketError("PERPS_USAGE", message);
1877
+ }
1878
+ function object(value) {
1879
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1880
+ }
1881
+ function decimal2(value) {
1882
+ return typeof value === "string" && value.length <= 100 && DECIMAL.test(value) && (value.split(".")[1]?.length ?? 0) <= 18;
1883
+ }
1884
+ function scaled(value) {
1885
+ const negative = value.startsWith("-");
1886
+ const [whole = "0", fraction = ""] = (negative ? value.slice(1) : value).split(".");
1887
+ const result = BigInt(whole) * ONE + BigInt(fraction.padEnd(18, "0"));
1888
+ return negative ? -result : result;
1889
+ }
1890
+ function nonnegative(value) {
1891
+ return decimal2(value) && !value.startsWith("-") ? scaled(value) : null;
1892
+ }
1893
+ function fixed(value, places) {
1894
+ const negative = value < 0n;
1895
+ const abs = negative ? -value : value;
1896
+ const divisor = 10n ** BigInt(18 - places);
1897
+ const rounded = (abs + divisor / 2n) / divisor;
1898
+ const digits = rounded.toString().padStart(places + 1, "0");
1899
+ return `${negative && rounded !== 0n ? "-" : ""}${places ? `${digits.slice(0, -places)}.${digits.slice(-places)}` : digits}`;
1900
+ }
1901
+ function at(values, index) {
1902
+ const value = values[index];
1903
+ if (value === undefined)
1904
+ return unavailable();
1905
+ return value;
1906
+ }
1907
+ function integer(value) {
1908
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
1909
+ }
1910
+ function parseMarkets(value) {
1911
+ if (!Array.isArray(value) || value.length !== 2)
1912
+ return unavailable();
1913
+ const [meta, ctxs] = value;
1914
+ if (!object(meta) || !Array.isArray(meta.universe) || !Array.isArray(ctxs) || meta.universe.length !== ctxs.length)
1915
+ return unavailable();
1916
+ for (const row of meta.universe) {
1917
+ if (!object(row) || typeof row.name !== "string" || !integer(row.szDecimals) || row.szDecimals > 6 || !integer(row.maxLeverage))
1918
+ return unavailable();
1919
+ for (const key of ["isDelisted", "onlyIsolated"])
1920
+ if (row[key] !== undefined && typeof row[key] !== "boolean")
1921
+ return unavailable();
1922
+ if (row.marginTableId !== undefined && !integer(row.marginTableId))
1923
+ return unavailable();
1924
+ }
1925
+ for (const ctx of ctxs) {
1926
+ if (!object(ctx))
1927
+ return unavailable();
1928
+ for (const key of ["markPx", "midPx", "oraclePx", "openInterest", "funding", "dayNtlVlm", "prevDayPx", "premium"]) {
1929
+ if (ctx[key] !== undefined && ctx[key] !== null && !decimal2(ctx[key]))
1930
+ return unavailable();
1931
+ }
1932
+ if (ctx.impactPxs != null && (!Array.isArray(ctx.impactPxs) || ctx.impactPxs.length !== 2 || !ctx.impactPxs.every(decimal2)))
1933
+ return unavailable();
1934
+ }
1935
+ const marginTables = new Map;
1936
+ if (meta.marginTables !== undefined) {
1937
+ if (!Array.isArray(meta.marginTables))
1938
+ return unavailable();
1939
+ for (const entry of meta.marginTables) {
1940
+ if (!Array.isArray(entry) || !integer(entry[0]) || !object(entry[1]) || !Array.isArray(entry[1].marginTiers))
1941
+ return unavailable();
1942
+ const tiers = [];
1943
+ for (const tier of entry[1].marginTiers) {
1944
+ if (!object(tier) || nonnegative(tier.lowerBound) === null || !integer(tier.maxLeverage))
1945
+ return unavailable();
1946
+ tiers.push({ lowerBoundUsd: tier.lowerBound, maxLeverage: tier.maxLeverage });
1947
+ }
1948
+ marginTables.set(entry[0], tiers);
1949
+ }
1950
+ }
1951
+ return { universe: meta.universe, ctxs, marginTables };
1952
+ }
1953
+ async function info(fetcher, network, body) {
1954
+ try {
1955
+ const response = await fetcher(INFO_URLS[network], {
1956
+ method: "POST",
1957
+ headers: { "Content-Type": "application/json" },
1958
+ body: JSON.stringify(body),
1959
+ signal: AbortSignal.timeout(5000),
1960
+ redirect: "error"
1961
+ });
1962
+ if (!response.ok)
1963
+ return unavailable();
1964
+ return JSON.parse(await response.text());
1965
+ } catch {
1966
+ return unavailable();
1967
+ }
1968
+ }
1969
+ async function readHyperliquidMarkets(fetcher, network) {
1970
+ return parseMarkets(await info(fetcher, network, { type: "metaAndAssetCtxs" }));
1971
+ }
1972
+ function perpsMarketGate(meta, ctx, minOpenInterestUsd) {
1973
+ if (meta.isDelisted === true)
1974
+ return { tradeable: false, reason: "delisted" };
1975
+ const mark = nonnegative(ctx.markPx);
1976
+ const mid = nonnegative(typeof ctx.midPx === "string" ? ctx.midPx : ctx.markPx);
1977
+ const oi = nonnegative(ctx.openInterest);
1978
+ if (mark === null || mid === null || oi === null)
1979
+ return { tradeable: false, reason: "noPrice" };
1980
+ const divisor = 10n ** 30n;
1981
+ const micros = (oi * mark + divisor - 1n) / divisor;
1982
+ if (micros > BigInt(Number.MAX_SAFE_INTEGER))
1983
+ return unavailable();
1984
+ if (minOpenInterestUsd > 0 && micros < BigInt(minOpenInterestUsd) * 1000000n)
1985
+ return { tradeable: false, reason: "minOpenInterest" };
1986
+ return { tradeable: true, reason: null };
1987
+ }
1988
+ function resolve(snapshot, coin) {
1989
+ if (/^[A-Za-z0-9]{1,20}$/.test(coin)) {
1990
+ const exact = snapshot.universe.findIndex((row) => row.name === coin);
1991
+ if (exact !== -1)
1992
+ return exact;
1993
+ const matches = snapshot.universe.map((row, i) => ({ row, i })).filter(({ row }) => row.name.toLowerCase() === coin.toLowerCase());
1994
+ if (matches.length === 1)
1995
+ return at(matches, 0).i;
1996
+ }
1997
+ throw new PerpsMarketError("PERPS_MARKET_NOT_FOUND", `${coin} is not a market on Hyperliquid's main perp exchange.`);
1998
+ }
1999
+ var PERPS_CANDLE_INTERVALS = [
2000
+ "1m",
2001
+ "3m",
2002
+ "5m",
2003
+ "15m",
2004
+ "30m",
2005
+ "1h",
2006
+ "2h",
2007
+ "4h",
2008
+ "8h",
2009
+ "12h",
2010
+ "1d",
2011
+ "3d",
2012
+ "1w",
2013
+ "1M"
2014
+ ];
2015
+ var INTERVAL_MS = [
2016
+ 60000,
2017
+ 180000,
2018
+ 300000,
2019
+ 900000,
2020
+ 1800000,
2021
+ 3600000,
2022
+ 7200000,
2023
+ 14400000,
2024
+ 28800000,
2025
+ 43200000,
2026
+ 86400000,
2027
+ 259200000,
2028
+ 604800000,
2029
+ 2678400000
2030
+ ];
2031
+ function perpsCandleWindow(opts, now) {
2032
+ const interval = opts.interval ?? "1h";
2033
+ const index = PERPS_CANDLE_INTERVALS.indexOf(interval);
2034
+ if (index < 0)
2035
+ return usage(`interval must be one of ${PERPS_CANDLE_INTERVALS.join(" ")}.`);
2036
+ const count = bounded(opts.count, 100, 1, 5000, "count");
2037
+ const raw = opts.end;
2038
+ const endTime = raw === undefined ? now : typeof raw === "number" || /^\d+$/.test(raw) ? Number(raw) : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/.test(raw) ? Date.parse(raw) : NaN;
2039
+ const startTime = endTime - count * at(INTERVAL_MS, index);
2040
+ if (!Number.isSafeInteger(endTime) || endTime < 0 || endTime > now || startTime < 0)
2041
+ return usage("end must be an ISO 8601 time or epoch milliseconds, no later than now.");
2042
+ return { interval, count, startTime, endTime };
2043
+ }
2044
+ function bounded(value, fallback, min, max, name) {
2045
+ const n = value ?? fallback;
2046
+ if (!Number.isInteger(n) || n < min || n > max)
2047
+ return usage(`${name} must be an integer from ${min} to ${max}.`);
2048
+ return n;
2049
+ }
2050
+
2051
+ class PerpsMarketReader {
2052
+ opts;
2053
+ configPromise;
2054
+ coreCache;
2055
+ extrasCache;
2056
+ corePending;
2057
+ extrasPending;
2058
+ now;
2059
+ constructor(opts) {
2060
+ this.opts = opts;
2061
+ this.now = opts.now ?? Date.now;
2062
+ }
2063
+ key() {
2064
+ if (!this.opts.apiKey?.trim())
2065
+ throw new PerpsMarketError("PERPS_KEY_REQUIRED", "Configure a Candle agent key (any scope) to read perps markets.");
2066
+ }
2067
+ config() {
2068
+ this.key();
2069
+ this.configPromise ??= (async () => {
2070
+ try {
2071
+ const value = await this.opts.readConfig();
2072
+ if (!object(value) || !object(value.limits) || !integer(value.limits.minOpenInterestUsd) || value.network !== "mainnet" && value.network !== "testnet")
2073
+ throw new Error("config shape");
2074
+ return {
2075
+ minOpenInterestUsd: value.limits.minOpenInterestUsd,
2076
+ source: "server",
2077
+ network: value.network,
2078
+ warnings: []
2079
+ };
2080
+ } catch {
2081
+ return {
2082
+ minOpenInterestUsd: 1e6,
2083
+ source: "default",
2084
+ warnings: ["Candle config unavailable; using the release's default OI floor."]
2085
+ };
2086
+ }
2087
+ })();
2088
+ return this.configPromise;
2089
+ }
2090
+ core() {
2091
+ const ttl = this.opts.cacheMs ?? 5000;
2092
+ if (this.coreCache && ttl > 0 && this.now() - this.coreCache.at < ttl)
2093
+ return Promise.resolve(this.coreCache.value);
2094
+ this.corePending ??= readHyperliquidMarkets(this.opts.fetcher, this.opts.network).then((value) => {
2095
+ this.coreCache = { value, at: this.now() };
2096
+ return value;
2097
+ }).finally(() => {
2098
+ this.corePending = undefined;
2099
+ });
2100
+ return this.corePending;
2101
+ }
2102
+ extras() {
2103
+ const ttl = this.opts.cacheMs ?? 5000;
2104
+ if (this.extrasCache && ttl > 0 && this.now() - this.extrasCache.at < ttl)
2105
+ return Promise.resolve(this.extrasCache.value);
2106
+ this.extrasPending ??= (async () => {
2107
+ const warnings = [];
2108
+ const [predicted, cap] = await Promise.all([
2109
+ info(this.opts.fetcher, this.opts.network, { type: "predictedFundings" }).then((value2) => {
2110
+ if (!Array.isArray(value2))
2111
+ return unavailable();
2112
+ const map = new Map;
2113
+ for (const entry of value2) {
2114
+ if (!Array.isArray(entry) || typeof entry[0] !== "string" || !Array.isArray(entry[1]))
2115
+ return unavailable();
2116
+ for (const venue of entry[1]) {
2117
+ if (!Array.isArray(venue) || typeof venue[0] !== "string")
2118
+ return unavailable();
2119
+ if (venue[0] === "HlPerp" && venue[1] !== null) {
2120
+ if (!object(venue[1]) || !decimal2(venue[1].fundingRate) || !integer(venue[1].nextFundingTime))
2121
+ return unavailable();
2122
+ map.set(entry[0], { fundingRate: venue[1].fundingRate, nextFundingTime: venue[1].nextFundingTime });
2123
+ }
2124
+ }
2125
+ }
2126
+ return map;
2127
+ }).catch(() => {
2128
+ warnings.push("Predicted funding unavailable.");
2129
+ return null;
2130
+ }),
2131
+ info(this.opts.fetcher, this.opts.network, { type: "perpsAtOpenInterestCap" }).then((value2) => {
2132
+ if (!Array.isArray(value2) || !value2.every((v) => typeof v === "string"))
2133
+ return unavailable();
2134
+ return new Set(value2);
2135
+ }).catch(() => {
2136
+ warnings.push("Open-interest caps unavailable.");
2137
+ return null;
2138
+ })
2139
+ ]);
2140
+ const value = { predicted, cap, warnings: warnings.sort() };
2141
+ this.extrasCache = { value, at: this.now() };
2142
+ return value;
2143
+ })().finally(() => {
2144
+ this.extrasPending = undefined;
2145
+ });
2146
+ return this.extrasPending;
2147
+ }
2148
+ envelope(config, warnings = [], readAt = this.now()) {
2149
+ const mismatch = config.network !== undefined && config.network !== this.opts.network;
2150
+ return {
2151
+ success: true,
2152
+ network: this.opts.network,
2153
+ readAt,
2154
+ gate: { minOpenInterestUsd: config.minOpenInterestUsd, source: config.source },
2155
+ warnings: [
2156
+ ...config.warnings,
2157
+ ...warnings,
2158
+ ...mismatch ? ["Candle and client networks differ; tradeability is unknown."] : []
2159
+ ]
2160
+ };
2161
+ }
2162
+ row(core, extras, config, asset) {
2163
+ const meta = at(core.universe, asset);
2164
+ const ctx = at(core.ctxs, asset);
2165
+ const mark = ctx.markPx == null ? null : scaled(ctx.markPx);
2166
+ const oi = ctx.openInterest == null ? null : scaled(ctx.openInterest);
2167
+ const prev = ctx.prevDayPx == null ? null : scaled(ctx.prevDayPx);
2168
+ const predicted = extras.predicted?.get(meta.name);
2169
+ return {
2170
+ coin: meta.name,
2171
+ asset,
2172
+ markPx: ctx.markPx ?? null,
2173
+ oraclePx: ctx.oraclePx ?? null,
2174
+ midPx: ctx.midPx ?? null,
2175
+ fundingRate: ctx.funding ?? null,
2176
+ fundingAprPct: ctx.funding == null ? null : fixed(scaled(ctx.funding) * 876000n, 2),
2177
+ predictedFundingRate: predicted?.fundingRate ?? null,
2178
+ nextFundingTime: predicted?.nextFundingTime ?? null,
2179
+ openInterest: ctx.openInterest ?? null,
2180
+ openInterestUsd: oi === null || mark === null ? null : fixed(oi * mark / ONE, 2),
2181
+ dayNotionalVolumeUsd: ctx.dayNtlVlm ?? null,
2182
+ prevDayPx: ctx.prevDayPx ?? null,
2183
+ change24hPct: prev === null || prev === 0n || mark === null ? null : fixed((mark - prev) * 100n * ONE / prev, 1),
2184
+ maxLeverage: meta.maxLeverage,
2185
+ onlyIsolated: meta.onlyIsolated === true,
2186
+ isDelisted: meta.isDelisted === true,
2187
+ atOpenInterestCap: extras.cap?.has(meta.name) ?? null,
2188
+ candle: config.network !== undefined && config.network !== this.opts.network ? { tradeable: null, reason: "networkMismatch" } : perpsMarketGate(meta, ctx, config.minOpenInterestUsd)
2189
+ };
2190
+ }
2191
+ async markets(opts = {}) {
2192
+ this.key();
2193
+ const sort = opts.sort ?? "volume";
2194
+ if (!["volume", "oi", "funding", "change", "name"].includes(sort))
2195
+ return usage("sort must be volume, oi, funding, change or name.");
2196
+ const limit = opts.limit === undefined ? undefined : bounded(opts.limit, 500, 1, 500, "limit");
2197
+ const [config, core, extras] = await Promise.all([this.config(), this.core(), this.extras()]);
2198
+ const rows = core.universe.map((_, asset) => this.row(core, extras, config, asset)).filter((row) => opts.all || row.candle.tradeable !== false);
2199
+ const field = {
2200
+ volume: "dayNotionalVolumeUsd",
2201
+ oi: "openInterestUsd",
2202
+ funding: "fundingRate",
2203
+ change: "change24hPct"
2204
+ };
2205
+ rows.sort((a, b) => {
2206
+ if (sort !== "name") {
2207
+ const av = a[field[sort]];
2208
+ const bv = b[field[sort]];
2209
+ if (av === null && bv !== null)
2210
+ return 1;
2211
+ if (bv === null && av !== null)
2212
+ return -1;
2213
+ if (av !== null && bv !== null) {
2214
+ const diff = scaled(bv) - scaled(av);
2215
+ if (diff !== 0n)
2216
+ return diff > 0n ? 1 : -1;
2217
+ }
2218
+ }
2219
+ return a.coin < b.coin ? -1 : a.coin > b.coin ? 1 : 0;
2220
+ });
2221
+ return {
2222
+ ...this.envelope(config, extras.warnings, Math.max(this.coreCache?.at ?? 0, this.extrasCache?.at ?? 0)),
2223
+ markets: limit === undefined ? rows : rows.slice(0, limit)
2224
+ };
2225
+ }
2226
+ async market(coin) {
2227
+ this.key();
2228
+ const [config, core, extras] = await Promise.all([this.config(), this.core(), this.extras()]);
2229
+ const asset = resolve(core, coin);
2230
+ const meta = at(core.universe, asset);
2231
+ const ctx = at(core.ctxs, asset);
2232
+ return {
2233
+ ...this.envelope(config, extras.warnings, Math.max(this.coreCache?.at ?? 0, this.extrasCache?.at ?? 0)),
2234
+ market: {
2235
+ ...this.row(core, extras, config, asset),
2236
+ szDecimals: meta.szDecimals,
2237
+ maxPriceDecimals: 6 - meta.szDecimals,
2238
+ priceRule: "at most 5 significant figures",
2239
+ minOrderUsd: 10,
2240
+ impactBidPx: ctx.impactPxs?.[0] ?? null,
2241
+ impactAskPx: ctx.impactPxs?.[1] ?? null,
2242
+ premium: ctx.premium ?? null,
2243
+ marginTiers: core.marginTables.get(meta.marginTableId ?? -1) ?? [
2244
+ { lowerBoundUsd: "0", maxLeverage: meta.maxLeverage }
2245
+ ]
2246
+ }
2247
+ };
2248
+ }
2249
+ async book(coin, opts = {}) {
2250
+ this.key();
2251
+ const depth = bounded(opts.depth, 20, 1, 20, "depth");
2252
+ if (opts.sigFigs !== undefined)
2253
+ bounded(opts.sigFigs, 5, 2, 5, "sigFigs");
2254
+ const [config, core] = await Promise.all([this.config(), this.core()]);
2255
+ const canonical2 = at(core.universe, resolve(core, coin)).name;
2256
+ const value = await info(this.opts.fetcher, this.opts.network, {
2257
+ type: "l2Book",
2258
+ coin: canonical2,
2259
+ ...opts.sigFigs === undefined ? {} : { nSigFigs: opts.sigFigs }
2260
+ });
2261
+ if (!object(value) || value.coin !== canonical2 || !integer(value.time) || !Array.isArray(value.levels) || value.levels.length !== 2)
2262
+ return unavailable();
2263
+ const sides = [];
2264
+ for (const side of value.levels) {
2265
+ if (!Array.isArray(side))
2266
+ return unavailable();
2267
+ const levels = [];
2268
+ for (const level of side) {
2269
+ if (!object(level) || nonnegative(level.px) === null || nonnegative(level.sz) === null || !integer(level.n))
2270
+ return unavailable();
2271
+ levels.push({ px: level.px, sz: level.sz, n: level.n });
2272
+ }
2273
+ sides.push(levels);
2274
+ }
2275
+ const bids = at(sides, 0).sort((a, b) => scaled(a.px) > scaled(b.px) ? -1 : scaled(a.px) < scaled(b.px) ? 1 : 0).slice(0, depth);
2276
+ const asks = at(sides, 1).sort((a, b) => scaled(a.px) < scaled(b.px) ? -1 : scaled(a.px) > scaled(b.px) ? 1 : 0).slice(0, depth);
2277
+ const bid = bids[0];
2278
+ const ask = asks[0];
2279
+ const spread = bid && ask ? scaled(ask.px) - scaled(bid.px) : null;
2280
+ const mid = bid && ask ? (scaled(ask.px) + scaled(bid.px)) / 2n : null;
2281
+ return {
2282
+ ...this.envelope(config),
2283
+ coin: canonical2,
2284
+ time: value.time,
2285
+ bids,
2286
+ asks,
2287
+ spreadPx: spread === null ? null : fixed(spread, 18).replace(/\.?0+$/, ""),
2288
+ midPx: mid === null ? null : fixed(mid, 18).replace(/\.?0+$/, ""),
2289
+ spreadBps: spread === null || mid === null || mid === 0n ? null : fixed(spread * 10000n * ONE / mid, 2)
2290
+ };
2291
+ }
2292
+ async candles(coin, opts = {}) {
2293
+ this.key();
2294
+ const window = perpsCandleWindow(opts, this.now());
2295
+ const [config, core] = await Promise.all([this.config(), this.core()]);
2296
+ const canonical2 = at(core.universe, resolve(core, coin)).name;
2297
+ const value = await info(this.opts.fetcher, this.opts.network, {
2298
+ type: "candleSnapshot",
2299
+ req: { coin: canonical2, interval: window.interval, startTime: window.startTime, endTime: window.endTime }
2300
+ });
2301
+ if (!Array.isArray(value))
2302
+ return unavailable();
2303
+ const candles = [];
2304
+ for (const c of value) {
2305
+ if (!object(c) || !integer(c.t) || !integer(c.T) || !integer(c.n) || ![c.o, c.h, c.l, c.c, c.v].every((v) => nonnegative(v) !== null) || c.s !== canonical2 || c.i !== window.interval)
2306
+ return unavailable();
2307
+ if (c.t <= window.endTime && c.T >= window.startTime)
2308
+ candles.push({
2309
+ t: c.t,
2310
+ T: c.T,
2311
+ o: c.o,
2312
+ h: c.h,
2313
+ l: c.l,
2314
+ c: c.c,
2315
+ v: c.v,
2316
+ n: c.n
2317
+ });
2318
+ }
2319
+ candles.sort((a, b) => a.t - b.t);
2320
+ return {
2321
+ ...this.envelope(config),
2322
+ coin: canonical2,
2323
+ interval: window.interval,
2324
+ candles: candles.slice(-window.count)
2325
+ };
2326
+ }
2327
+ }
2328
+
2329
+ // src/hyperliquid-risk.ts
2330
+ var HYPERLIQUID_RISK_INFO_URLS = {
2331
+ mainnet: "https://api.hyperliquid.xyz/info",
2332
+ testnet: "https://api.hyperliquid-testnet.xyz/info"
2333
+ };
2334
+ var PERPS_RISK_FIELDS = [
2335
+ "warnDistanceBps",
2336
+ "criticalDistanceBps",
2337
+ "warnMarginRatioBps",
2338
+ "criticalMarginRatioBps",
2339
+ "clearBandBps"
2340
+ ];
2341
+ var HYPERLIQUID_RISK_DEFAULTS = {
2342
+ warnDistanceBps: 1500,
2343
+ criticalDistanceBps: 700,
2344
+ warnMarginRatioBps: 6000,
2345
+ criticalMarginRatioBps: 8000,
2346
+ clearBandBps: 200
2347
+ };
2348
+ function effectivePerpsRisk(stored) {
2349
+ const out = { ...HYPERLIQUID_RISK_DEFAULTS };
2350
+ for (const field of PERPS_RISK_FIELDS) {
2351
+ const value = stored?.[field];
2352
+ if (typeof value === "number")
2353
+ out[field] = value;
2354
+ }
2355
+ return out;
2356
+ }
2357
+ function perpsRiskProblem(t) {
2358
+ const ranges = {
2359
+ warnDistanceBps: [1, 9900],
2360
+ criticalDistanceBps: [1, 9900],
2361
+ warnMarginRatioBps: [1, 9999],
2362
+ criticalMarginRatioBps: [1, 9999],
2363
+ clearBandBps: [0, 1000]
2364
+ };
2365
+ for (const field of PERPS_RISK_FIELDS) {
2366
+ const value = t[field];
2367
+ const [min, max] = ranges[field];
2368
+ if (!Number.isInteger(value) || value < min || value > max)
2369
+ return `${field} must be a whole number of bps from ${min} to ${max}`;
2370
+ }
2371
+ if (t.criticalDistanceBps >= t.warnDistanceBps)
2372
+ return "criticalDistanceBps must be below warnDistanceBps";
2373
+ if (t.warnMarginRatioBps >= t.criticalMarginRatioBps)
2374
+ return "warnMarginRatioBps must be below criticalMarginRatioBps";
2375
+ return null;
2376
+ }
2377
+ function perpsRiskFromLimits(body) {
2378
+ const record = isRecord(body) ? body : {};
2379
+ const reported = "perpsRisk" in record || "perpsRiskEffective" in record;
2380
+ const stored = isRecord(record.perpsRisk) ? pickSettings(record.perpsRisk) : null;
2381
+ if (!reported)
2382
+ return { thresholds: { ...HYPERLIQUID_RISK_DEFAULTS, source: "defaults (server did not report)" }, stored };
2383
+ const effective = isRecord(record.perpsRiskEffective) ? effectivePerpsRisk(pickSettings(record.perpsRiskEffective)) : effectivePerpsRisk(stored);
2384
+ if (perpsRiskProblem(effective) !== null)
2385
+ return { thresholds: { ...HYPERLIQUID_RISK_DEFAULTS, source: "defaults (server did not report)" }, stored };
2386
+ const source = stored && Object.keys(stored).length > 0 ? "key" : "defaults";
2387
+ return { thresholds: { ...effective, source }, stored };
2388
+ }
2389
+ function pickSettings(raw) {
2390
+ const out = {};
2391
+ for (const field of PERPS_RISK_FIELDS)
2392
+ if (typeof raw[field] === "number")
2393
+ out[field] = raw[field];
2394
+ return out;
2395
+ }
2396
+
2397
+ class HyperliquidReadError extends Error {
2398
+ rateLimited;
2399
+ code = "PERPS_READ_FAILED";
2400
+ constructor(message, rateLimited = false) {
2401
+ super(message);
2402
+ this.rateLimited = rateLimited;
2403
+ this.name = "HyperliquidReadError";
2404
+ }
2405
+ }
2406
+ var INFO_TIMEOUT_MS = 5000;
2407
+ var RATE_LIMIT_RETRY_MS = 1000;
2408
+ async function hyperliquidInfo(fetcher, network, body, opts = {}) {
2409
+ const sleep2 = opts.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
2410
+ for (let attempt = 0;; attempt++) {
2411
+ const controller = new AbortController;
2412
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? INFO_TIMEOUT_MS);
2413
+ let res;
2414
+ let text;
2415
+ try {
2416
+ res = await fetcher(HYPERLIQUID_RISK_INFO_URLS[network], {
2417
+ method: "POST",
2418
+ headers: { "content-type": "application/json" },
2419
+ body: JSON.stringify(body),
2420
+ signal: controller.signal
2421
+ });
2422
+ text = res.ok ? await res.text() : "";
2423
+ } catch {
2424
+ throw new HyperliquidReadError(`Hyperliquid ${String(body.type)} read failed or timed out`);
2425
+ } finally {
2426
+ clearTimeout(timer);
2427
+ }
2428
+ if (res.status === 429) {
2429
+ if (attempt === 0) {
2430
+ await sleep2(RATE_LIMIT_RETRY_MS);
2431
+ continue;
2432
+ }
2433
+ throw new HyperliquidReadError(`Hyperliquid rate-limited the ${String(body.type)} read (HTTP 429)`, true);
2434
+ }
2435
+ if (!res.ok)
2436
+ throw new HyperliquidReadError(`Hyperliquid ${String(body.type)} answered HTTP ${res.status ?? "?"}`);
2437
+ try {
2438
+ return JSON.parse(text);
2439
+ } catch {
2440
+ throw new HyperliquidReadError(`Hyperliquid ${String(body.type)} answered with something other than JSON`);
2441
+ }
2442
+ }
2443
+ }
2444
+ async function readHyperliquidMarketSnapshot(fetcher, network, opts = {}) {
2445
+ const sleep2 = opts.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
2446
+ let limited = false;
2447
+ const watched = async (url, init) => {
2448
+ const res = await fetcher(url, init);
2449
+ limited = res.status === 429;
2450
+ return res;
2451
+ };
2452
+ try {
2453
+ return await readHyperliquidMarkets(watched, network);
2454
+ } catch {
2455
+ await sleep2(RATE_LIMIT_RETRY_MS);
2456
+ }
2457
+ try {
2458
+ return await readHyperliquidMarkets(watched, network);
2459
+ } catch (error) {
2460
+ throw new HyperliquidReadError(`Hyperliquid metaAndAssetCtxs read failed${error instanceof PerpsMarketError ? ` (${error.code})` : ""}`, limited);
2461
+ }
2462
+ }
2463
+ function hyperliquidMark(markets, coin) {
2464
+ const index = markets.universe.findIndex((m) => m.name === coin);
2465
+ const mark = index < 0 ? undefined : markets.ctxs[index]?.markPx;
2466
+ if (typeof mark !== "string")
2467
+ return null;
2468
+ const parsed = dec(mark);
2469
+ return parsed !== null && parsed > 0n ? mark : null;
2470
+ }
2471
+ async function readHyperliquidClearinghouse(fetcher, network, address, opts = {}) {
2472
+ return parseClearinghouse(await hyperliquidInfo(fetcher, network, { type: "clearinghouseState", user: address.toLowerCase() }, opts));
2473
+ }
2474
+ async function readHyperliquidAccountMode(fetcher, network, address, opts = {}) {
2475
+ const body = await hyperliquidInfo(fetcher, network, { type: "userAbstraction", user: address.toLowerCase() }, opts);
2476
+ if (typeof body !== "string" || body === "")
2477
+ throw new HyperliquidReadError("Unexpected userAbstraction shape");
2478
+ return body;
2479
+ }
2480
+ var HYPERLIQUID_STANDARD_MODES = ["default", "disabled"];
2481
+ function parseClearinghouse(body) {
2482
+ const bad = (what) => new HyperliquidReadError(`Unexpected clearinghouseState shape (${what})`);
2483
+ if (!isRecord(body))
2484
+ throw bad("not an object");
2485
+ const summary = (value, name) => {
2486
+ if (!isRecord(value) || !isDecString(value.accountValue) || !isDecString(value.totalMarginUsed))
2487
+ throw bad(name);
2488
+ return { accountValue: value.accountValue, totalMarginUsed: value.totalMarginUsed };
2489
+ };
2490
+ const marginSummary = summary(body.marginSummary, "marginSummary");
2491
+ const crossMarginSummary = summary(body.crossMarginSummary, "crossMarginSummary");
2492
+ if (!isDecString(body.crossMaintenanceMarginUsed))
2493
+ throw bad("crossMaintenanceMarginUsed");
2494
+ if (!isDecString(body.withdrawable))
2495
+ throw bad("withdrawable");
2496
+ if (!Array.isArray(body.assetPositions))
2497
+ throw bad("assetPositions");
2498
+ const positions = body.assetPositions.map((entry) => {
2499
+ const p = isRecord(entry) ? entry.position : undefined;
2500
+ if (!isRecord(p) || typeof p.coin !== "string" || p.coin === "")
2501
+ throw bad("position");
2502
+ for (const field of ["szi", "entryPx", "positionValue", "unrealizedPnl", "returnOnEquity", "marginUsed"])
2503
+ if (!isDecString(p[field]))
2504
+ throw bad(`position ${field}`);
2505
+ const leverage = p.leverage;
2506
+ if (!isRecord(leverage) || typeof leverage.type !== "string" || typeof leverage.value !== "number")
2507
+ throw bad("position leverage");
2508
+ if (p.liquidationPx !== null && p.liquidationPx !== undefined && !isDecString(p.liquidationPx))
2509
+ throw bad("position liquidationPx");
2510
+ const funding = p.cumFunding;
2511
+ if (!isRecord(funding) || !isDecString(funding.sinceOpen))
2512
+ throw bad("position cumFunding");
2513
+ return {
2514
+ coin: p.coin,
2515
+ szi: p.szi,
2516
+ entryPx: p.entryPx,
2517
+ positionValue: p.positionValue,
2518
+ unrealizedPnl: p.unrealizedPnl,
2519
+ returnOnEquity: p.returnOnEquity,
2520
+ leverage: { type: leverage.type, value: leverage.value },
2521
+ liquidationPx: p.liquidationPx ?? null,
2522
+ marginUsed: p.marginUsed,
2523
+ cumFunding: { sinceOpen: funding.sinceOpen }
2524
+ };
2525
+ });
2526
+ return {
2527
+ marginSummary,
2528
+ crossMarginSummary,
2529
+ crossMaintenanceMarginUsed: body.crossMaintenanceMarginUsed,
2530
+ withdrawable: body.withdrawable,
2531
+ positions
2532
+ };
2533
+ }
2534
+ var PERPS_RISK_NOTE = "Phase 1 has no server-side monitor: nothing warns you unless you poll this or run candle perps watch.";
2535
+ var RANK = { ok: 0, warn: 1, critical: 2 };
2536
+ function worse(a, b) {
2537
+ return RANK[a] >= RANK[b] ? a : b;
2538
+ }
2539
+ function hyperliquidRiskReport(state, market, mode, thresholds, now, identity = { walletId: "", address: "", network: "mainnet" }) {
2540
+ const base = {
2541
+ schema: "candle.perps.risk/1",
2542
+ walletId: identity.walletId,
2543
+ address: identity.address,
2544
+ network: identity.network,
2545
+ mode,
2546
+ readAt: new Date(now).toISOString(),
2547
+ thresholds,
2548
+ note: PERPS_RISK_NOTE
2549
+ };
2550
+ if (!HYPERLIQUID_STANDARD_MODES.includes(mode)) {
2551
+ return {
2552
+ ...base,
2553
+ level: "unknown",
2554
+ reason: "PERPS_ACCOUNT_MODE",
2555
+ drivers: [],
2556
+ closest: null,
2557
+ account: null,
2558
+ positions: []
2559
+ };
2560
+ }
2561
+ const ch = isParsedClearinghouse(state) ? state : parseClearinghouse(state);
2562
+ if (!market || !Array.isArray(market.universe) || !Array.isArray(market.ctxs))
2563
+ throw new HyperliquidReadError("Unexpected metaAndAssetCtxs shape");
2564
+ const markets = market;
2565
+ const t = thresholds;
2566
+ const drivers = [];
2567
+ const nearest = [];
2568
+ let level = "ok";
2569
+ let unrealized = 0n;
2570
+ const positions = ch.positions.map((p) => {
2571
+ const szi = dec(p.szi);
2572
+ unrealized += dec(p.unrealizedPnl);
2573
+ const markPx = hyperliquidMark(markets, p.coin);
2574
+ const distance = markPx !== null && p.liquidationPx !== null ? distanceParts(markPx, p.liquidationPx) : null;
2575
+ let rowLevel = "none";
2576
+ if (distance) {
2577
+ rowLevel = fracLess(distance.num, distance.den, t.criticalDistanceBps) ? "critical" : fracLess(distance.num, distance.den, t.warnDistanceBps) ? "warn" : "ok";
2578
+ }
2579
+ const liquidationDistance = distance ? ratioNumber(distance.num, distance.den) : null;
2580
+ if (markPx === null) {
2581
+ drivers.push({
2582
+ kind: "position",
2583
+ coin: p.coin,
2584
+ level: "warn",
2585
+ liquidationDistance: null,
2586
+ threshold: null,
2587
+ reason: "unmarked"
2588
+ });
2589
+ level = worse(level, "warn");
2590
+ } else if (rowLevel === "warn" || rowLevel === "critical") {
2591
+ drivers.push({
2592
+ kind: "position",
2593
+ coin: p.coin,
2594
+ level: rowLevel,
2595
+ liquidationDistance,
2596
+ threshold: (rowLevel === "critical" ? t.criticalDistanceBps : t.warnDistanceBps) / 1e4
2597
+ });
2598
+ level = worse(level, rowLevel);
2599
+ } else if (rowLevel === "ok" && liquidationDistance !== null) {
2600
+ nearest.push({
2601
+ driver: {
2602
+ kind: "position",
2603
+ coin: p.coin,
2604
+ level: "ok",
2605
+ liquidationDistance,
2606
+ threshold: t.warnDistanceBps / 1e4
2607
+ },
2608
+ headroom: liquidationDistance / (t.warnDistanceBps / 1e4) - 1
2609
+ });
2610
+ }
2611
+ return {
2612
+ coin: p.coin,
2613
+ side: szi < 0n ? "short" : "long",
2614
+ size: p.szi.replace(/^-/, ""),
2615
+ entryPx: p.entryPx,
2616
+ markPx,
2617
+ unrealizedPnlUsd: p.unrealizedPnl,
2618
+ returnOnEquity: p.returnOnEquity,
2619
+ leverage: { value: p.leverage.value, type: p.leverage.type },
2620
+ liquidationPx: p.liquidationPx,
2621
+ liquidationDistance,
2622
+ fundingSinceOpenUsd: p.cumFunding.sinceOpen,
2623
+ marginUsedUsd: p.marginUsed,
2624
+ positionValueUsd: p.positionValue,
2625
+ level: rowLevel
2626
+ };
2627
+ });
2628
+ const maint = dec(ch.crossMaintenanceMarginUsed);
2629
+ const crossValue = dec(ch.crossMarginSummary.accountValue);
2630
+ let marginRatio = 0;
2631
+ if (positions.length > 0 && maint > 0n) {
2632
+ if (crossValue <= 0n) {
2633
+ marginRatio = null;
2634
+ drivers.push({
2635
+ kind: "margin",
2636
+ level: "critical",
2637
+ marginRatio: null,
2638
+ threshold: t.criticalMarginRatioBps / 1e4,
2639
+ reason: "no equity"
2640
+ });
2641
+ level = "critical";
2642
+ } else {
2643
+ marginRatio = ratioNumber(maint, crossValue);
2644
+ const marginLevel = fracGreater(maint, crossValue, t.criticalMarginRatioBps) ? "critical" : fracGreater(maint, crossValue, t.warnMarginRatioBps) ? "warn" : "ok";
2645
+ if (marginLevel === "ok") {
2646
+ nearest.push({
2647
+ driver: { kind: "margin", level: "ok", marginRatio, threshold: t.warnMarginRatioBps / 1e4 },
2648
+ headroom: 1 - marginRatio / (t.warnMarginRatioBps / 1e4)
2649
+ });
2650
+ } else {
2651
+ drivers.push({
2652
+ kind: "margin",
2653
+ level: marginLevel,
2654
+ marginRatio,
2655
+ threshold: (marginLevel === "critical" ? t.criticalMarginRatioBps : t.warnMarginRatioBps) / 1e4
2656
+ });
2657
+ level = worse(level, marginLevel);
2658
+ }
2659
+ }
2660
+ }
2661
+ drivers.sort((a, b) => RANK[b.level] - RANK[a.level]);
2662
+ nearest.sort((a, b) => a.headroom - b.headroom);
2663
+ const accountValue = dec(ch.marginSummary.accountValue);
2664
+ const marginUsed = dec(ch.marginSummary.totalMarginUsed);
2665
+ const free = accountValue - marginUsed;
2666
+ const account = {
2667
+ accountValueUsd: ch.marginSummary.accountValue,
2668
+ marginUsedUsd: ch.marginSummary.totalMarginUsed,
2669
+ crossAccountValueUsd: ch.crossMarginSummary.accountValue,
2670
+ freeMarginUsd: formatDec(free > 0n ? free : 0n),
2671
+ withdrawableUsd: ch.withdrawable,
2672
+ crossMaintenanceMarginUsedUsd: ch.crossMaintenanceMarginUsed,
2673
+ marginRatio,
2674
+ unrealizedPnlUsd: formatDec(unrealized),
2675
+ positionCount: positions.length,
2676
+ mode,
2677
+ level
2678
+ };
2679
+ return {
2680
+ ...base,
2681
+ level,
2682
+ reason: null,
2683
+ drivers,
2684
+ closest: level === "ok" ? nearest[0]?.driver ?? null : null,
2685
+ account,
2686
+ positions
2687
+ };
2688
+ }
2689
+ function unknownRiskReport(identity, thresholds, reason, now, mode = null) {
2690
+ return {
2691
+ schema: "candle.perps.risk/1",
2692
+ ...identity,
2693
+ mode,
2694
+ readAt: new Date(now).toISOString(),
2695
+ level: "unknown",
2696
+ reason,
2697
+ drivers: [],
2698
+ closest: null,
2699
+ thresholds,
2700
+ account: null,
2701
+ positions: [],
2702
+ note: PERPS_RISK_NOTE
2703
+ };
2704
+ }
2705
+ var PERPS_RISK_STALE_MS = 30000;
2706
+ async function readPerpsRisk(opts) {
2707
+ const identity = { walletId: opts.walletId, address: opts.address, network: opts.network };
2708
+ const timed = async (read) => {
2709
+ const value = await read;
2710
+ return { value, at: opts.now() };
2711
+ };
2712
+ const [state, market, mode] = await Promise.all([
2713
+ timed(readHyperliquidClearinghouse(opts.fetcher, opts.network, opts.address, opts)),
2714
+ timed(readHyperliquidMarketSnapshot(opts.fetcher, opts.network, opts)),
2715
+ opts.mode !== undefined ? Promise.resolve({ value: opts.mode, at: Number.NaN }) : timed(readHyperliquidAccountMode(opts.fetcher, opts.network, opts.address, opts))
2716
+ ]);
2717
+ const times = [state.at, market.at, mode.at].filter((at2) => !Number.isNaN(at2));
2718
+ const earliest = Math.min(...times);
2719
+ if (Math.max(...times) - earliest > PERPS_RISK_STALE_MS)
2720
+ return unknownRiskReport(identity, opts.thresholds, "PERPS_READ_STALE", earliest, mode.value);
2721
+ return hyperliquidRiskReport(state.value, market.value, mode.value, opts.thresholds, earliest, identity);
2722
+ }
2723
+ function perpsRiskExitCode(level) {
2724
+ return level === "ok" ? 0 : level === "warn" ? 10 : level === "critical" ? 11 : 1;
2725
+ }
2726
+ var PERPS_FILLS_RESPONSE_CAP = 2000;
2727
+ var PERPS_FUNDING_RESPONSE_CAP = 500;
2728
+ var PERPS_RECENT_MAX_MS = 30 * 24 * 60 * 60 * 1000;
2729
+ function isMainPerpCoin(coin) {
2730
+ return typeof coin === "string" && coin !== "" && !coin.includes(":") && !coin.includes("/") && !coin.startsWith("@");
2731
+ }
2732
+ async function readPerpsRecent(fetcher, network, address, window, opts = {}) {
2733
+ const user = address.toLowerCase();
2734
+ const [fills, funding] = await Promise.all([
2735
+ hyperliquidInfo(fetcher, network, { type: "userFillsByTime", user, startTime: window.startTime, endTime: window.endTime }, opts),
2736
+ hyperliquidInfo(fetcher, network, { type: "userFunding", user, startTime: window.startTime, endTime: window.endTime }, opts)
2737
+ ]);
2738
+ return summarizePerpsRecent(fills, funding, window);
2739
+ }
2740
+ function summarizePerpsRecent(fills, funding, window) {
2741
+ if (!Array.isArray(fills))
2742
+ throw new HyperliquidReadError("Unexpected userFillsByTime shape");
2743
+ if (!Array.isArray(funding))
2744
+ throw new HyperliquidReadError("Unexpected userFunding shape");
2745
+ let realized = 0n;
2746
+ let fees = 0n;
2747
+ let paid = 0n;
2748
+ let fillCount = 0;
2749
+ let fundingCount = 0;
2750
+ for (const row of fills) {
2751
+ if (!isRecord(row))
2752
+ throw new HyperliquidReadError("Unexpected fill row");
2753
+ if (!isMainPerpCoin(row.coin))
2754
+ continue;
2755
+ const closed = dec(row.closedPnl);
2756
+ const fee = dec(row.fee);
2757
+ if (closed === null || fee === null)
2758
+ throw new HyperliquidReadError("Unexpected fill amount");
2759
+ realized += closed;
2760
+ fees += fee;
2761
+ fillCount++;
2762
+ }
2763
+ for (const row of funding) {
2764
+ const delta = isRecord(row) ? row.delta : undefined;
2765
+ if (!isRecord(delta))
2766
+ throw new HyperliquidReadError("Unexpected funding row");
2767
+ if (delta.type !== "funding" || !isMainPerpCoin(delta.coin))
2768
+ continue;
2769
+ const usdc = dec(delta.usdc);
2770
+ if (usdc === null)
2771
+ throw new HyperliquidReadError("Unexpected funding amount");
2772
+ paid += usdc;
2773
+ fundingCount++;
2774
+ }
2775
+ return {
2776
+ startTime: window.startTime,
2777
+ endTime: window.endTime,
2778
+ realizedPnlUsd: formatDec(realized),
2779
+ feesUsd: formatDec(fees),
2780
+ fundingUsd: formatDec(paid),
2781
+ fills: fillCount,
2782
+ fundingPayments: fundingCount,
2783
+ truncated: fills.length >= PERPS_FILLS_RESPONSE_CAP || funding.length >= PERPS_FUNDING_RESPONSE_CAP
2784
+ };
2785
+ }
2786
+ function perpsAccountReport(report, recent) {
2787
+ const { mode: _mode, level: _level, ...line } = report.account ?? {};
2788
+ return {
2789
+ schema: "candle.perps.account/1",
2790
+ walletId: report.walletId,
2791
+ address: report.address,
2792
+ network: report.network,
2793
+ mode: report.mode,
2794
+ readAt: report.readAt,
2795
+ ...line,
2796
+ recent,
2797
+ risk: { level: report.level, reason: report.reason, drivers: report.drivers }
2798
+ };
2799
+ }
2800
+ function perpsRiskClears(report, leaving) {
2801
+ const t = report.thresholds;
2802
+ const account = report.account;
2803
+ if (!account)
2804
+ return false;
2805
+ const distanceBps = (leaving === "warn" ? t.warnDistanceBps : t.criticalDistanceBps) + t.clearBandBps;
2806
+ const ratioBps = (leaving === "warn" ? t.warnMarginRatioBps : t.criticalMarginRatioBps) - t.clearBandBps;
2807
+ for (const p of report.positions) {
2808
+ if (p.markPx === null) {
2809
+ if (leaving === "warn")
2810
+ return false;
2811
+ continue;
2812
+ }
2813
+ if (p.liquidationPx === null)
2814
+ continue;
2815
+ const d = distanceParts(p.markPx, p.liquidationPx);
2816
+ if (d && fracLess(d.num, d.den, distanceBps))
2817
+ return false;
2818
+ }
2819
+ const maint = dec(account.crossMaintenanceMarginUsedUsd);
2820
+ if (report.positions.length > 0 && maint > 0n) {
2821
+ const crossValue = dec(account.crossAccountValueUsd);
2822
+ if (crossValue <= 0n)
2823
+ return false;
2824
+ if (fracGreater(maint, crossValue, ratioBps))
2825
+ return false;
2826
+ }
2827
+ return true;
2828
+ }
2829
+ function perpsRiskStep(previous, report) {
2830
+ const rule = report.level;
2831
+ if (rule === "unknown" || previous === "unknown")
2832
+ return rule;
2833
+ if (RANK[rule] >= RANK[previous])
2834
+ return rule;
2835
+ if (rule === "ok" && perpsRiskClears(report, "warn"))
2836
+ return "ok";
2837
+ if (previous === "critical" && perpsRiskClears(report, "critical"))
2838
+ return "warn";
2839
+ return previous;
2840
+ }
2841
+ var PERPS_WATCH_DEFAULT_INTERVAL_MS = 1e4;
2842
+ var PERPS_WATCH_MAX_INTERVAL_MS = 300000;
2843
+ var PERPS_WATCH_DEFAULT_HEARTBEAT_MS = 60000;
2844
+ var PERPS_WATCH_REFRESH_MS = 300000;
2845
+ var HYPERLIQUID_INFO_WEIGHTS = { clearinghouseState: 2, metaAndAssetCtxs: 20, userAbstraction: 20 };
2846
+ function perpsWatchWeightPerMinute(intervalMs) {
2847
+ const polls = 60000 / intervalMs;
2848
+ const modeReads = 60000 / PERPS_WATCH_REFRESH_MS;
2849
+ return Math.round(polls * (HYPERLIQUID_INFO_WEIGHTS.clearinghouseState + HYPERLIQUID_INFO_WEIGHTS.metaAndAssetCtxs) + modeReads * HYPERLIQUID_INFO_WEIGHTS.userAbstraction);
2850
+ }
2851
+ async function watchPerpsRisk(opts) {
2852
+ const baseInterval = opts.intervalMs ?? PERPS_WATCH_DEFAULT_INTERVAL_MS;
2853
+ const heartbeatMs = opts.heartbeatMs ?? PERPS_WATCH_DEFAULT_HEARTBEAT_MS;
2854
+ const identity = { walletId: opts.walletId, address: opts.address, network: opts.network };
2855
+ const sleep2 = (ms) => abortableSleep(opts.sleep, ms, opts.signal);
2856
+ const info2 = { sleep: opts.sleep };
2857
+ let thresholds;
2858
+ try {
2859
+ thresholds = await opts.thresholds();
2860
+ } catch {
2861
+ thresholds = { ...HYPERLIQUID_RISK_DEFAULTS, source: "defaults (server did not report)" };
2862
+ }
2863
+ let thresholdsAt = opts.now();
2864
+ let mode;
2865
+ let modeAt = Number.NEGATIVE_INFINITY;
2866
+ let level = "unknown";
2867
+ let lastGoodAt = null;
2868
+ let staleReported = false;
2869
+ let interval = baseInterval;
2870
+ let lastHeartbeat = opts.now();
2871
+ let established = false;
2872
+ await opts.onEvent({
2873
+ event: "start",
2874
+ ...identity,
2875
+ at: new Date(opts.now()).toISOString(),
2876
+ intervalSeconds: baseInterval / 1000,
2877
+ heartbeatSeconds: heartbeatMs / 1000,
2878
+ thresholds,
2879
+ weightPerMinute: perpsWatchWeightPerMinute(baseInterval)
2880
+ });
2881
+ while (!opts.signal?.aborted) {
2882
+ if (opts.now() - thresholdsAt >= PERPS_WATCH_REFRESH_MS) {
2883
+ thresholdsAt = opts.now();
2884
+ try {
2885
+ thresholds = await opts.thresholds();
2886
+ } catch {}
2887
+ }
2888
+ let report = null;
2889
+ try {
2890
+ if (mode === undefined || opts.now() - modeAt >= PERPS_WATCH_REFRESH_MS) {
2891
+ mode = await readHyperliquidAccountMode(opts.fetcher, opts.network, opts.address, info2);
2892
+ modeAt = opts.now();
2893
+ }
2894
+ report = await readPerpsRisk({ ...identity, fetcher: opts.fetcher, thresholds, now: opts.now, mode, ...info2 });
2895
+ interval = baseInterval;
2896
+ } catch (error) {
2897
+ const rateLimited = error instanceof HyperliquidReadError && error.rateLimited;
2898
+ if (rateLimited)
2899
+ interval = Math.min(interval * 2, PERPS_WATCH_MAX_INTERVAL_MS);
2900
+ await opts.onEvent({
2901
+ event: "error",
2902
+ at: new Date(opts.now()).toISOString(),
2903
+ code: error instanceof HyperliquidReadError ? error.code : "PERPS_READ_FAILED",
2904
+ message: error instanceof Error ? error.message : "Hyperliquid read failed",
2905
+ nextPollSeconds: interval / 1000
2906
+ });
2907
+ if (level !== "unknown" && !staleReported && lastGoodAt !== null && opts.now() - lastGoodAt > PERPS_RISK_STALE_MS) {
2908
+ const previous = level;
2909
+ level = "unknown";
2910
+ staleReported = true;
2911
+ const stale = unknownRiskReport(identity, thresholds, "PERPS_READ_STALE", opts.now(), mode ?? null);
2912
+ await opts.onEvent({ ...stale, event: "level", level, ruleLevel: "unknown", previous });
2913
+ if (opts.onceLevelChange && established)
2914
+ return { level };
2915
+ }
2916
+ }
2917
+ if (report) {
2918
+ const previous = level;
2919
+ const next = perpsRiskStep(previous, report);
2920
+ if (report.level !== "unknown") {
2921
+ lastGoodAt = opts.now();
2922
+ staleReported = false;
2923
+ }
2924
+ if (next !== previous) {
2925
+ level = next;
2926
+ const cleared = next === "ok" && (previous === "warn" || previous === "critical");
2927
+ await opts.onEvent({
2928
+ ...report,
2929
+ event: "level",
2930
+ level: next,
2931
+ ruleLevel: report.level,
2932
+ previous,
2933
+ ...cleared ? { cleared: true } : {}
2934
+ });
2935
+ if (opts.onceLevelChange && established)
2936
+ return { level };
2937
+ if (next !== "unknown")
2938
+ established = true;
2939
+ } else if (next !== "unknown") {
2940
+ established = true;
2941
+ }
2942
+ if (heartbeatMs > 0 && opts.now() - lastHeartbeat >= heartbeatMs) {
2943
+ lastHeartbeat = opts.now();
2944
+ await opts.onEvent({ ...report, event: "heartbeat", level, ruleLevel: report.level });
897
2945
  }
898
- return { ok: true, kind: "approveBuilderFee" };
899
2946
  }
900
- return { ok: false, reason: `primary type ${String(typed.primary_type)} is not one the relay signs` };
901
- } catch (err) {
902
- return { ok: false, reason: err instanceof Error ? err.message : "unreadable build" };
2947
+ if (opts.signal?.aborted)
2948
+ break;
2949
+ await sleep2(interval);
903
2950
  }
2951
+ return { level };
904
2952
  }
905
- function hyperliquidSplitSignature(signature) {
906
- if (!/^0x[0-9a-fA-F]{130}$/.test(signature))
907
- throw new Error("Not a 65-byte signature");
908
- let v = Number.parseInt(signature.slice(130, 132), 16);
909
- if (v < 27)
910
- v += 27;
911
- return { r: `0x${signature.slice(2, 66)}`, s: `0x${signature.slice(66, 130)}`, v };
2953
+ function abortableSleep(sleep2, ms, signal) {
2954
+ if (!signal)
2955
+ return sleep2(ms);
2956
+ if (signal.aborted)
2957
+ return Promise.resolve();
2958
+ return new Promise((resolve2) => {
2959
+ const done = () => {
2960
+ signal.removeEventListener("abort", done);
2961
+ resolve2();
2962
+ };
2963
+ signal.addEventListener("abort", done);
2964
+ sleep2(ms).then(done, done);
2965
+ });
912
2966
  }
913
- function hyperliquidExchangeBody(action, nonce, signature) {
914
- return { action, nonce, signature: hyperliquidSplitSignature(signature), vaultAddress: null, expiresAfter: null };
2967
+ var PERPS_PORTFOLIO_MAX_ACCOUNTS = 100;
2968
+ var PORTFOLIO_READS_IN_FLIGHT = 8;
2969
+ async function perpsPortfolioSection(fetcher, network, wallets, opts = {}) {
2970
+ const now = opts.now ?? Date.now;
2971
+ const considered = wallets.slice(0, PERPS_PORTFOLIO_MAX_ACCOUNTS);
2972
+ const unread = wallets.slice(PERPS_PORTFOLIO_MAX_ACCOUNTS).map((w) => ({ ...w, reason: `past the ${PERPS_PORTFOLIO_MAX_ACCOUNTS}-address cap` }));
2973
+ let markets = { universe: [], ctxs: [], marginTables: new Map };
2974
+ if (considered.length > 0) {
2975
+ try {
2976
+ markets = await readHyperliquidMarketSnapshot(fetcher, network, opts);
2977
+ } catch (error) {
2978
+ return { read: false, reason: error instanceof Error ? error.message : "Hyperliquid market read failed" };
2979
+ }
2980
+ }
2981
+ const thresholds = { ...HYPERLIQUID_RISK_DEFAULTS, source: "defaults" };
2982
+ const results = new Array(considered.length).fill(null);
2983
+ const failures = new Array(considered.length).fill(null);
2984
+ let next = 0;
2985
+ const worker = async () => {
2986
+ while (next < considered.length) {
2987
+ const at2 = next++;
2988
+ const wallet = considered[at2];
2989
+ try {
2990
+ const state = await readHyperliquidClearinghouse(fetcher, network, wallet.address, opts);
2991
+ const report = hyperliquidRiskReport(state, markets, "default", thresholds, now());
2992
+ results[at2] = {
2993
+ address: wallet.address,
2994
+ ...wallet.label !== undefined ? { label: wallet.label } : {},
2995
+ ...wallet.walletId !== undefined ? { walletId: wallet.walletId } : {},
2996
+ accountValueUsd: report.account?.accountValueUsd ?? "0",
2997
+ unrealizedPnlUsd: report.account?.unrealizedPnlUsd ?? "0",
2998
+ positionCount: report.positions.length,
2999
+ level: report.level
3000
+ };
3001
+ } catch (error) {
3002
+ failures[at2] = error instanceof Error ? error.message : "read failed";
3003
+ }
3004
+ }
3005
+ };
3006
+ await Promise.all(Array.from({ length: Math.min(PORTFOLIO_READS_IN_FLIGHT, considered.length) }, worker));
3007
+ const accounts = [];
3008
+ let empty = 0;
3009
+ let value = 0n;
3010
+ let pnl = 0n;
3011
+ considered.forEach((wallet, at2) => {
3012
+ const result = results[at2];
3013
+ if (!result) {
3014
+ unread.push({ ...wallet, reason: failures[at2] ?? "read failed" });
3015
+ return;
3016
+ }
3017
+ if (result.positionCount === 0 && dec(result.accountValueUsd) === 0n) {
3018
+ empty++;
3019
+ return;
3020
+ }
3021
+ accounts.push(result);
3022
+ value += dec(result.accountValueUsd);
3023
+ pnl += dec(result.unrealizedPnlUsd);
3024
+ });
3025
+ return {
3026
+ read: true,
3027
+ network,
3028
+ accounts,
3029
+ subtotal: { accountValueUsd: formatDec(value), unrealizedPnlUsd: formatDec(pnl) },
3030
+ empty,
3031
+ unread,
3032
+ thresholds: "defaults"
3033
+ };
915
3034
  }
916
- function hyperliquidRelayBody(typedData) {
917
- return { method: "eth_signTypedData_v4", params: { typed_data: typedData } };
3035
+ var SCALE = 18;
3036
+ var ONE2 = 10n ** BigInt(SCALE);
3037
+ var DEC_RE = /^-?(0|[1-9][0-9]*)(\.[0-9]{1,18})?$/;
3038
+ var LOOSE_DEC_RE = /^-?[0-9]+(\.[0-9]{1,18})?$/;
3039
+ function isDecString(value) {
3040
+ return typeof value === "string" && (DEC_RE.test(value) || LOOSE_DEC_RE.test(value));
3041
+ }
3042
+ function dec(value) {
3043
+ if (!isDecString(value))
3044
+ return null;
3045
+ const negative = value.startsWith("-");
3046
+ const [whole = "0", fraction = ""] = value.replace(/^-/, "").split(".");
3047
+ const scaled2 = BigInt(whole) * ONE2 + BigInt(fraction.padEnd(SCALE, "0"));
3048
+ return negative ? -scaled2 : scaled2;
3049
+ }
3050
+ function formatDec(value) {
3051
+ const negative = value < 0n;
3052
+ const abs = negative ? -value : value;
3053
+ const whole = abs / ONE2;
3054
+ const fraction = (abs % ONE2).toString().padStart(SCALE, "0").replace(/0+$/, "");
3055
+ const text = fraction ? `${whole}.${fraction}` : `${whole}`;
3056
+ return negative && text !== "0" ? `-${text}` : text;
3057
+ }
3058
+ function distanceParts(markPx, liquidationPx) {
3059
+ const mark = dec(markPx);
3060
+ const liq = dec(liquidationPx);
3061
+ if (mark === null || liq === null || mark <= 0n)
3062
+ return null;
3063
+ const diff = mark - liq;
3064
+ return { num: diff < 0n ? -diff : diff, den: mark };
3065
+ }
3066
+ function fracLess(num, den, bps) {
3067
+ return num * 10000n < BigInt(bps) * den;
3068
+ }
3069
+ function fracGreater(num, den, bps) {
3070
+ return num * 10000n > BigInt(bps) * den;
3071
+ }
3072
+ function ratioNumber(num, den) {
3073
+ const micro = num * 1000000000000n / den;
3074
+ return Number(micro) / 1000000000000;
3075
+ }
3076
+ function isRecord(value) {
3077
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3078
+ }
3079
+ function isParsedClearinghouse(value) {
3080
+ return isRecord(value) && Array.isArray(value.positions) && !("assetPositions" in value);
3081
+ }
3082
+
3083
+ // src/hyperliquid-withdraw.ts
3084
+ var CANDLE_PERPS_WITHDRAW_PINS = {
3085
+ token: "USDC:0x6d1e7cde53ba9467b783cb7c530ce054",
3086
+ destinationDex: "",
3087
+ depositories: ["0x66cf0aace1b4e562593bec10ec7868fba9932224"],
3088
+ amountForm: "fixed6"
3089
+ };
3090
+ var RELAY_API_URL = "https://api.relay.link";
3091
+ var ZERO = "0x0000000000000000000000000000000000000000";
3092
+ var EVM_ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
3093
+ var BYTES32_RE = /^0x[0-9a-fA-F]{64}$/;
3094
+ var RELAY_NONCE_MAPPING_TYPES = {
3095
+ NonceMapping: [
3096
+ { name: "chainId", type: "string" },
3097
+ { name: "wallet", type: "address" },
3098
+ { name: "depositor", type: "address" },
3099
+ { name: "id", type: "bytes32" },
3100
+ { name: "nonce", type: "uint256" }
3101
+ ]
3102
+ };
3103
+ var HYPERLIQUID_SEND_ASSET_TYPES = {
3104
+ "HyperliquidTransaction:SendAsset": [
3105
+ { name: "hyperliquidChain", type: "string" },
3106
+ { name: "destination", type: "string" },
3107
+ { name: "sourceDex", type: "string" },
3108
+ { name: "destinationDex", type: "string" },
3109
+ { name: "token", type: "string" },
3110
+ { name: "amount", type: "string" },
3111
+ { name: "fromSubAccount", type: "string" },
3112
+ { name: "nonce", type: "uint64" }
3113
+ ]
3114
+ };
3115
+ var SEND_ASSET_KEYS = HYPERLIQUID_SEND_ASSET_TYPES["HyperliquidTransaction:SendAsset"].map((field) => field.name);
3116
+ var REQUEST_AMOUNT_RE = /^(0|[1-9][0-9]{0,11})(\.[0-9]{1,6})?$/;
3117
+ var CANONICAL_RE = /^(0|[1-9][0-9]*)(\.[0-9]{0,5}[1-9])?$/;
3118
+ var FIXED6_RE = /^(0|[1-9][0-9]*)\.[0-9]{6}$/;
3119
+ function parseWithdrawAmount(value) {
3120
+ if (!REQUEST_AMOUNT_RE.test(value))
3121
+ return null;
3122
+ const [whole = "0", frac = ""] = value.split(".");
3123
+ const micros = BigInt(whole) * 1000000n + BigInt(frac.padEnd(6, "0"));
3124
+ return micros > 0n ? micros : null;
3125
+ }
3126
+ function parseSendAssetAmount(value, form) {
3127
+ if (typeof value !== "string" || !(form === "canonical" ? CANONICAL_RE : FIXED6_RE).test(value))
3128
+ return null;
3129
+ const [whole = "0", frac = ""] = value.split(".");
3130
+ if (frac.length > 6)
3131
+ return null;
3132
+ return BigInt(whole) * 1000000n + BigInt(frac.padEnd(6, "0"));
3133
+ }
3134
+ function isObject(value) {
3135
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3136
+ }
3137
+ function exactKeys2(value, keys) {
3138
+ const own = Object.keys(value);
3139
+ return own.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
3140
+ }
3141
+ function sameEvm(a, b) {
3142
+ return typeof a === "string" && EVM_ADDRESS_RE2.test(a) && a.toLowerCase() === b.toLowerCase();
3143
+ }
3144
+ function typesMatch(types, expected) {
3145
+ if (!isObject(types) || !exactKeys2(types, Object.keys(expected)))
3146
+ return false;
3147
+ return Object.entries(expected).every(([key, want]) => {
3148
+ const fields = types[key];
3149
+ return Array.isArray(fields) && fields.length === want.length && fields.every((field, i) => isObject(field) && exactKeys2(field, ["name", "type"]) && field.name === want[i]?.name && field.type === want[i]?.type);
3150
+ });
3151
+ }
3152
+ function domainMatches(domain, expected) {
3153
+ return isObject(domain) && exactKeys2(domain, ["name", "version", "chainId", "verifyingContract"]) && domain.name === expected.name && domain.version === expected.version && domain.chainId === expected.chainId && typeof domain.verifyingContract === "string" && domain.verifyingContract.toLowerCase() === ZERO;
3154
+ }
3155
+ function verifyWithdrawMapping(build, expect) {
3156
+ const refuse = (reason) => ({ ok: false, reason });
3157
+ if (expect.pins === null)
3158
+ return refuse("no withdrawal pins are set");
3159
+ if (build.id !== expect.id)
3160
+ return refuse("the build names another withdrawal id");
3161
+ if (!sameEvm(build.from?.address, expect.account))
3162
+ return refuse("the build withdraws from another account");
3163
+ if (build.to?.chain !== expect.to)
3164
+ return refuse(`the build lands on ${String(build.to?.chain)}, not ${expect.to}`);
3165
+ const destination = build.to.address;
3166
+ const own = expect.ownDestinations.some((address) => address.startsWith("0x") ? sameEvm(destination, address) : destination === address);
3167
+ if (!own)
3168
+ return refuse("the build lands on a wallet that is not this key's own");
3169
+ if (parseWithdrawAmount(build.amountUsdc) !== expect.amountMicros)
3170
+ return refuse("the build is for another amount");
3171
+ const typed = build.typedData;
3172
+ if (!isObject(typed) || typed.primary_type !== "NonceMapping")
3173
+ return refuse("the typed data is not a NonceMapping");
3174
+ if (!domainMatches(typed.domain, { name: "RelayNonceMapping", version: "2", chainId: 1 }))
3175
+ return refuse("the NonceMapping domain is not Relay's");
3176
+ if (!typesMatch(typed.types, RELAY_NONCE_MAPPING_TYPES))
3177
+ return refuse("the NonceMapping type list differs");
3178
+ const message = typed.message;
3179
+ if (!isObject(message) || !exactKeys2(message, ["chainId", "wallet", "depositor", "id", "nonce"]))
3180
+ return refuse("the NonceMapping message has other fields");
3181
+ if (message.chainId !== "hyperliquid")
3182
+ return refuse("the NonceMapping names another chain");
3183
+ if (!sameEvm(message.wallet, expect.account))
3184
+ return refuse("the NonceMapping wallet is not this key's account");
3185
+ if (!sameEvm(message.depositor, expect.account))
3186
+ return refuse("the NonceMapping depositor is not this key's account");
3187
+ if (typeof message.id !== "string" || !BYTES32_RE.test(message.id) || message.id !== build.relayRequestId)
3188
+ return refuse("the NonceMapping names another Relay request");
3189
+ if (message.nonce !== build.nonce || !Number.isSafeInteger(build.nonce) || build.nonce <= 0)
3190
+ return refuse("the NonceMapping nonce is not the build's");
3191
+ return { ok: true };
3192
+ }
3193
+ function verifyWithdrawSendAsset(authorization, expect) {
3194
+ const refuse = (reason) => ({ ok: false, reason });
3195
+ const pins = expect.pins;
3196
+ if (pins === null)
3197
+ return refuse("no withdrawal pins are set");
3198
+ const typed = authorization.typedData;
3199
+ if (!isObject(typed) || typed.primary_type !== "HyperliquidTransaction:SendAsset")
3200
+ return refuse("the typed data is not a SendAsset");
3201
+ if (!domainMatches(typed.domain, { name: "HyperliquidSignTransaction", version: "1", chainId: 42161 }))
3202
+ return refuse("the SendAsset domain is not Hyperliquid's user-signed domain");
3203
+ if (!typesMatch(typed.types, HYPERLIQUID_SEND_ASSET_TYPES))
3204
+ return refuse("the SendAsset type list differs");
3205
+ const m = typed.message;
3206
+ if (!isObject(m) || !exactKeys2(m, SEND_ASSET_KEYS))
3207
+ return refuse("the SendAsset message has other fields");
3208
+ if (m.hyperliquidChain !== "Mainnet")
3209
+ return refuse("the SendAsset is not on Mainnet");
3210
+ if (m.nonce !== expect.mappingNonce || authorization.nonce !== expect.mappingNonce)
3211
+ return refuse("the SendAsset nonce is not the NonceMapping's");
3212
+ if (parseSendAssetAmount(m.amount, pins.amountForm) !== expect.amountMicros)
3213
+ return refuse("the SendAsset amount is not the amount asked for");
3214
+ if (m.fromSubAccount !== "")
3215
+ return refuse("the SendAsset moves from a sub-account");
3216
+ if (m.sourceDex !== "")
3217
+ return refuse("the SendAsset moves from another dex");
3218
+ if (m.token !== pins.token)
3219
+ return refuse("the SendAsset moves another token");
3220
+ if (m.destinationDex !== pins.destinationDex)
3221
+ return refuse("the SendAsset names another destination dex");
3222
+ if (typeof m.destination !== "string" || !pins.depositories.includes(m.destination.toLowerCase()))
3223
+ return refuse("the SendAsset destination is not a Relay depository on the allow-list");
3224
+ const action = authorization.action;
3225
+ if (!isObject(action) || !exactKeys2(action, ["type", "signatureChainId", ...SEND_ASSET_KEYS]))
3226
+ return refuse("the action has other fields");
3227
+ if (action.type !== "sendAsset" || action.signatureChainId !== "0xa4b1")
3228
+ return refuse("the action is not a sendAsset");
3229
+ for (const key of SEND_ASSET_KEYS) {
3230
+ if (action[key] !== m[key])
3231
+ return refuse(`the action's ${key} differs from what is signed`);
3232
+ }
3233
+ return { ok: true };
3234
+ }
3235
+ async function relayRecipientProblem(fetchImpl, requestId, recipient, relayUrl = RELAY_API_URL) {
3236
+ let body;
3237
+ try {
3238
+ const res = await fetchImpl(`${relayUrl}/requests/v3?id=${encodeURIComponent(requestId)}`, { method: "GET" });
3239
+ if (!res.ok)
3240
+ return `Relay's record of the request could not be read (HTTP ${res.status})`;
3241
+ body = await res.json();
3242
+ } catch (error) {
3243
+ return `Relay's record of the request could not be read (${error instanceof Error ? error.message : "unknown"})`;
3244
+ }
3245
+ const requests = isObject(body) && Array.isArray(body.requests) ? body.requests : [];
3246
+ const match = requests.find((r) => isObject(r) && r.id === requestId);
3247
+ if (!isObject(match))
3248
+ return "Relay does not list this request";
3249
+ const named = match.recipient;
3250
+ const same = recipient.startsWith("0x") ? sameEvm(named, recipient) : named === recipient;
3251
+ return same ? null : `Relay's request pays ${String(named)}, not this key's wallet ${recipient}`;
918
3252
  }
919
3253
 
920
3254
  // src/internal/rpc-endpoint.ts
@@ -926,6 +3260,288 @@ function describeRpcEndpoint(url) {
926
3260
  }
927
3261
  }
928
3262
 
3263
+ // src/perps-history.ts
3264
+ var PERPS_HISTORY_INFO_URLS = {
3265
+ mainnet: "https://api.hyperliquid.xyz/info",
3266
+ testnet: "https://api.hyperliquid-testnet.xyz/info"
3267
+ };
3268
+ var PERPS_HISTORY_DEFAULT_LIMIT = 100;
3269
+ var PERPS_HISTORY_MAX_LIMIT = { fills: 2000, funding: 500 };
3270
+ var PERPS_HISTORY_DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
3271
+ var PERPS_HISTORY_REQUEST_BUDGET = 10;
3272
+ var PERPS_HISTORY_FILL_RETENTION = 1e4;
3273
+ var PERPS_HISTORY_MAX_CURSOR_LENGTH = 200000;
3274
+ var PERPS_HISTORY_TIMEOUT_MS = 15000;
3275
+
3276
+ class PerpsHistoryError extends Error {
3277
+ code;
3278
+ extra;
3279
+ constructor(code, message, extra = {}) {
3280
+ super(message);
3281
+ this.code = code;
3282
+ this.extra = extra;
3283
+ this.name = "PerpsHistoryError";
3284
+ }
3285
+ }
3286
+ var RETENTION_TEXT = "Hyperliquid serves only the 10,000 most recent fills for an account; older fills cannot be read here, and this history is not an all-time total.";
3287
+ var EVM_ADDRESS_RE3 = /^0x[0-9a-f]{40}$/;
3288
+ function isTime(value) {
3289
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
3290
+ }
3291
+ function invalid(field, message) {
3292
+ return new PerpsHistoryError("VALIDATION_FAILED", message, { field });
3293
+ }
3294
+ function unavailable2(message, retryAfterMs) {
3295
+ return new PerpsHistoryError("PERPS_UNAVAILABLE", message, {
3296
+ retryable: true,
3297
+ ...retryAfterMs !== undefined ? { retryAfterMs } : {}
3298
+ });
3299
+ }
3300
+ function base64UrlEncode(text) {
3301
+ const bytes = new TextEncoder().encode(text);
3302
+ let binary = "";
3303
+ for (const b of bytes)
3304
+ binary += String.fromCharCode(b);
3305
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3306
+ }
3307
+ function base64UrlDecode(text) {
3308
+ if (!/^[A-Za-z0-9_-]*$/.test(text))
3309
+ throw new Error("not base64url");
3310
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - text.length % 4) % 4);
3311
+ const binary = atob(padded);
3312
+ const bytes = new Uint8Array(binary.length);
3313
+ for (let i = 0;i < binary.length; i++)
3314
+ bytes[i] = binary.charCodeAt(i);
3315
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
3316
+ }
3317
+ function encodeCursor(state) {
3318
+ return `ph1.${base64UrlEncode(JSON.stringify(state))}`;
3319
+ }
3320
+ function decodeCursor(cursor) {
3321
+ if (cursor.length > PERPS_HISTORY_MAX_CURSOR_LENGTH || !cursor.startsWith("ph1."))
3322
+ throw invalid("cursor", "cursor is not one this client issued");
3323
+ let state;
3324
+ try {
3325
+ state = JSON.parse(base64UrlDecode(cursor.slice(4)));
3326
+ } catch {
3327
+ throw invalid("cursor", "cursor is not one this client issued");
3328
+ }
3329
+ const ok = state !== null && typeof state === "object" && state.v === 1 && (state.k === "fills" || state.k === "funding") && (state.n === "mainnet" || state.n === "testnet") && typeof state.w === "string" && EVM_ADDRESS_RE3.test(state.w) && isTime(state.s) && isTime(state.e) && state.s <= state.e && Number.isSafeInteger(state.l) && state.l >= 1 && state.l <= PERPS_HISTORY_MAX_LIMIT[state.k] && isTime(state.t) && state.t >= state.s && state.t <= state.e && Array.isArray(state.b) && state.b.every((id) => typeof id === "string" && id.length <= 512) && Number.isSafeInteger(state.c) && state.c >= 0;
3330
+ if (!ok)
3331
+ throw invalid("cursor", "cursor is not one this client issued");
3332
+ return state;
3333
+ }
3334
+ function canonical2(value) {
3335
+ if (value === null || typeof value !== "object")
3336
+ return JSON.stringify(value);
3337
+ if (Array.isArray(value))
3338
+ return `[${value.map(canonical2).join(",")}]`;
3339
+ const record = value;
3340
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical2(record[key])}`).join(",")}}`;
3341
+ }
3342
+ function isDecimalString(value) {
3343
+ return typeof value === "string" && /^-?[0-9]+(\.[0-9]+)?$/.test(value);
3344
+ }
3345
+ function asFill(raw) {
3346
+ const row = raw;
3347
+ if (!row || typeof row !== "object" || typeof row.coin !== "string" || !isTime(row.time) || !Number.isSafeInteger(row.tid) || !Number.isSafeInteger(row.oid) || !isDecimalString(row.px) || !isDecimalString(row.sz) || typeof row.side !== "string" || !isDecimalString(row.closedPnl) || !isDecimalString(row.fee) || row.builderFee !== undefined && !isDecimalString(row.builderFee))
3348
+ throw unavailable2("Hyperliquid returned a fill this client cannot read");
3349
+ return row;
3350
+ }
3351
+ function asFunding(raw) {
3352
+ const row = raw;
3353
+ const delta = row?.delta;
3354
+ if (!row || typeof row !== "object" || !isTime(row.time) || typeof row.hash !== "string" || !delta || typeof delta !== "object" || typeof delta.coin !== "string" || !isDecimalString(delta.usdc) || !isDecimalString(delta.fundingRate) || !isDecimalString(delta.szi))
3355
+ throw unavailable2("Hyperliquid returned a funding payment this client cannot read");
3356
+ return row;
3357
+ }
3358
+ function identity(kind, row) {
3359
+ if (kind === "fills") {
3360
+ const fill = row;
3361
+ return `${fill.coin}|${fill.tid}|${fill.oid}`;
3362
+ }
3363
+ const funding = row;
3364
+ return `${funding.hash}|${funding.time}|${funding.delta.coin}|${canonical2(funding.delta)}`;
3365
+ }
3366
+ function rowCoin(kind, row) {
3367
+ return kind === "fills" ? row.coin : row.delta.coin;
3368
+ }
3369
+ async function postInfo(req, body, what) {
3370
+ const controller = typeof AbortController === "function" ? new AbortController : null;
3371
+ const timer = controller ? setTimeout(() => controller.abort(), PERPS_HISTORY_TIMEOUT_MS) : null;
3372
+ let res;
3373
+ try {
3374
+ res = await req.fetch(PERPS_HISTORY_INFO_URLS[req.network], {
3375
+ method: "POST",
3376
+ headers: { "content-type": "application/json" },
3377
+ body: JSON.stringify(body),
3378
+ ...controller ? { signal: controller.signal } : {}
3379
+ });
3380
+ } catch (err) {
3381
+ const aborted = err instanceof Error && err.name === "AbortError";
3382
+ throw unavailable2(aborted ? `Hyperliquid did not answer the ${what} read in time; retry shortly` : `Hyperliquid could not be reached for the ${what} read; retry shortly`);
3383
+ } finally {
3384
+ if (timer)
3385
+ clearTimeout(timer);
3386
+ }
3387
+ if (!res.ok) {
3388
+ if (res.status === 429) {
3389
+ const header = res.headers?.get("retry-after") ?? null;
3390
+ const seconds = header !== null && /^[0-9]{1,6}$/.test(header.trim()) ? Number(header.trim()) : undefined;
3391
+ throw unavailable2(`Hyperliquid rate-limited the ${what} read${seconds !== undefined ? `; retry after ${seconds}s` : "; retry shortly"}`, seconds !== undefined ? seconds * 1000 : undefined);
3392
+ }
3393
+ throw unavailable2(`Hyperliquid answered the ${what} read with HTTP ${res.status}; retry shortly`);
3394
+ }
3395
+ try {
3396
+ return JSON.parse(await res.text());
3397
+ } catch {
3398
+ throw unavailable2(`Hyperliquid returned an unreadable ${what} response`);
3399
+ }
3400
+ }
3401
+ async function mainPerpCoins(req) {
3402
+ let universe;
3403
+ try {
3404
+ universe = (await readHyperliquidMarkets(req.fetch, req.network)).universe;
3405
+ } catch {
3406
+ throw unavailable2("Hyperliquid's market list could not be read; history is not reported without it");
3407
+ }
3408
+ if (universe.length === 0)
3409
+ throw unavailable2("Hyperliquid's market list could not be read; history is not reported without it");
3410
+ return new Set(universe.map((market) => market.name));
3411
+ }
3412
+ function resolveWindow(req) {
3413
+ const options = req.options ?? {};
3414
+ const kind = req.kind;
3415
+ const address = req.address.toLowerCase();
3416
+ if (!EVM_ADDRESS_RE3.test(address))
3417
+ throw invalid("wallet", "The wallet's Hyperliquid account is not an EVM address");
3418
+ for (const field of ["startTime", "endTime"]) {
3419
+ const value = options[field];
3420
+ if (value !== undefined && !isTime(value))
3421
+ throw invalid(field, `${field} must be a whole, non-negative number of epoch milliseconds`);
3422
+ }
3423
+ const max = PERPS_HISTORY_MAX_LIMIT[kind];
3424
+ if (options.limit !== undefined && (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > max))
3425
+ throw invalid("limit", `limit must be a whole number from 1 to ${max} for ${kind}`);
3426
+ if (options.cursor !== undefined) {
3427
+ if (typeof options.cursor !== "string" || options.cursor.length === 0)
3428
+ throw invalid("cursor", "cursor is not one this client issued");
3429
+ const state = decodeCursor(options.cursor);
3430
+ if (state.k !== kind)
3431
+ throw invalid("cursor", `This cursor pages ${state.k}, not ${kind}`);
3432
+ if (state.n !== req.network)
3433
+ throw invalid("cursor", `This cursor pages ${state.n}, not ${req.network}`);
3434
+ if (state.w !== address)
3435
+ throw invalid("cursor", "This cursor pages another wallet");
3436
+ if (options.startTime !== undefined && options.startTime !== state.s || options.endTime !== undefined && options.endTime !== state.e || options.limit !== undefined && options.limit !== state.l)
3437
+ throw invalid("cursor", "The window or page size changed; start again without a cursor");
3438
+ return { state };
3439
+ }
3440
+ const endTime = options.endTime ?? req.now();
3441
+ const startTime = options.startTime ?? Math.max(0, endTime - PERPS_HISTORY_DEFAULT_WINDOW_MS);
3442
+ if (!isTime(endTime))
3443
+ throw invalid("endTime", "endTime must be epoch milliseconds");
3444
+ if (startTime > endTime)
3445
+ throw invalid("startTime", "startTime must not be after endTime");
3446
+ return {
3447
+ state: {
3448
+ v: 1,
3449
+ k: kind,
3450
+ n: req.network,
3451
+ w: address,
3452
+ s: startTime,
3453
+ e: endTime,
3454
+ l: options.limit ?? PERPS_HISTORY_DEFAULT_LIMIT,
3455
+ t: startTime,
3456
+ b: [],
3457
+ c: 0
3458
+ }
3459
+ };
3460
+ }
3461
+ async function readPerpsHistoryPage(req) {
3462
+ const { state } = resolveWindow(req);
3463
+ const kind = req.kind;
3464
+ const max = PERPS_HISTORY_MAX_LIMIT[kind];
3465
+ const readAt = req.now();
3466
+ const coins = await mainPerpCoins(req);
3467
+ const page = [];
3468
+ const warnings = [];
3469
+ let t = state.t;
3470
+ let boundary = new Set(state.b);
3471
+ let consumed = state.c;
3472
+ let done = false;
3473
+ let truncated = false;
3474
+ let requests = 0;
3475
+ const stop = (code, message, ends) => {
3476
+ warnings.push({ code, message });
3477
+ if (ends) {
3478
+ done = true;
3479
+ truncated = true;
3480
+ }
3481
+ };
3482
+ walk:
3483
+ while (page.length < state.l && !done) {
3484
+ if (requests >= PERPS_HISTORY_REQUEST_BUDGET) {
3485
+ stop("PERPS_HISTORY_PAGE_BUDGET", `Stopped after ${PERPS_HISTORY_REQUEST_BUDGET} Hyperliquid reads; pass nextCursor to continue`, false);
3486
+ break;
3487
+ }
3488
+ const body = kind === "fills" ? { type: "userFillsByTime", user: state.w, startTime: t, endTime: state.e, aggregateByTime: false } : { type: "userFunding", user: state.w, startTime: t, endTime: state.e };
3489
+ const raw = await postInfo(req, body, kind === "fills" ? "fills" : "funding");
3490
+ requests++;
3491
+ if (!Array.isArray(raw))
3492
+ throw unavailable2(`Hyperliquid returned an unreadable ${kind} response`);
3493
+ const rows = raw.map((r) => kind === "fills" ? asFill(r) : asFunding(r));
3494
+ const keyed = rows.map((row) => ({ row, id: identity(kind, row) })).sort((a, b) => a.row.time - b.row.time || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
3495
+ const full = rows.length >= max;
3496
+ const startedAt = t;
3497
+ let previousId = null;
3498
+ for (const { row, id } of keyed) {
3499
+ if (id === previousId)
3500
+ continue;
3501
+ previousId = id;
3502
+ if (row.time < t || row.time > state.e || row.time === t && boundary.has(id))
3503
+ continue;
3504
+ if (page.length >= state.l)
3505
+ break walk;
3506
+ if (row.time > t) {
3507
+ t = row.time;
3508
+ boundary = new Set;
3509
+ }
3510
+ boundary.add(id);
3511
+ consumed++;
3512
+ if (coins.has(rowCoin(kind, row)))
3513
+ page.push(row);
3514
+ if (kind === "fills" && consumed >= PERPS_HISTORY_FILL_RETENTION) {
3515
+ stop("PERPS_HISTORY_RETENTION_LIMIT", "Reached Hyperliquid's 10,000-fill retention across this cursor chain; older fills are not available", true);
3516
+ break walk;
3517
+ }
3518
+ }
3519
+ if (!full) {
3520
+ done = true;
3521
+ } else if (t === startedAt) {
3522
+ stop("PERPS_HISTORY_BOUNDARY_LIMIT", `More than ${max} ${kind === "fills" ? "fills" : "funding rows"} share one millisecond (${t}); coverage stops there`, true);
3523
+ }
3524
+ }
3525
+ if (kind === "fills" && !warnings.some((w) => w.code === "PERPS_HISTORY_RETENTION_LIMIT"))
3526
+ warnings.unshift({ code: "PERPS_HISTORY_RETENTION_LIMIT", message: RETENTION_TEXT });
3527
+ const nextCursor = done ? null : encodeCursor({ ...state, t, b: [...boundary], c: consumed });
3528
+ const envelope = {
3529
+ success: true,
3530
+ walletId: req.walletId,
3531
+ address: state.w,
3532
+ network: state.n,
3533
+ source: "hyperliquid",
3534
+ readAt,
3535
+ startTime: state.s,
3536
+ endTime: state.e,
3537
+ nextCursor,
3538
+ hasMore: nextCursor !== null,
3539
+ truncated,
3540
+ warnings
3541
+ };
3542
+ return kind === "fills" ? { ...envelope, fills: page } : { ...envelope, funding: page };
3543
+ }
3544
+
929
3545
  // src/wallet-import.ts
930
3546
  import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
931
3547
  import { CipherSuite, DhkemP256HkdfSha256, HkdfSha256 } from "@hpke/core";
@@ -1012,6 +3628,32 @@ function historyTime(value) {
1012
3628
  }
1013
3629
  var HYPERLIQUID_RELAY_CHAIN_ID = 1337;
1014
3630
  var HYPERLIQUID_RELAY_USDC = "0x00000000000000000000000000000000";
3631
+ function perpsClientId(clientId) {
3632
+ if (clientId === undefined)
3633
+ return;
3634
+ const lower = typeof clientId === "string" ? clientId.toLowerCase() : "";
3635
+ if (!HYPERLIQUID_CLOID_RE.test(lower))
3636
+ throw new Error("perps: clientId must be 0x followed by 32 hex digits");
3637
+ return lower;
3638
+ }
3639
+ function perpsReplayOf(build, walletId, clientId) {
3640
+ const answer = build;
3641
+ if (answer.replayed === true) {
3642
+ if (clientId === undefined || answer.clientId !== clientId || answer.walletId !== walletId)
3643
+ throw new Error("perps: refused a replay that does not name this request's client id and wallet");
3644
+ return {
3645
+ replayed: true,
3646
+ replay: build,
3647
+ build: null,
3648
+ signature: null,
3649
+ submitted: false,
3650
+ exchange: null
3651
+ };
3652
+ }
3653
+ if (clientId !== undefined && (answer.clientId !== clientId || answer.cloid !== clientId))
3654
+ throw new Error("perps: PERPS_CLIENT_ID_UNSUPPORTED: this Candle API did not echo the client order id, so its order could not be deduplicated. Nothing was signed.");
3655
+ return null;
3656
+ }
1015
3657
  function perpsDepositProblem(build, params) {
1016
3658
  const same = (a, b) => typeof a === "string" && a.toLowerCase() === b.toLowerCase();
1017
3659
  if (build.walletId !== params.walletId)
@@ -1053,7 +3695,7 @@ function isRetryableLaunchFailure(error) {
1053
3695
  return error.status >= 500 || error.status === 409;
1054
3696
  }
1055
3697
  function sleep2(ms) {
1056
- return new Promise((resolve) => setTimeout(resolve, ms));
3698
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1057
3699
  }
1058
3700
  var MAX_BLOCKHASH_REBUILDS = 2;
1059
3701
  function isBlockhashExpiry(error) {
@@ -1203,7 +3845,29 @@ class CandleClient {
1203
3845
  solanaRpcUrl;
1204
3846
  evmRpcUrl;
1205
3847
  hyperliquidNetwork;
3848
+ perpsMarketReader;
1206
3849
  hyperliquidBuilder;
3850
+ perpsWithdrawPins;
3851
+ perps = {
3852
+ withdraw: {
3853
+ build: async (request) => {
3854
+ this.requireKey("perps.withdraw.build()");
3855
+ return this.requestJson("POST", "/api/v1/agent/perps/withdraw", request);
3856
+ },
3857
+ authorize: async (request) => {
3858
+ this.requireKey("perps.withdraw.authorize()");
3859
+ return this.requestJson("POST", "/api/v1/agent/perps/withdraw/authorize", request);
3860
+ },
3861
+ reportSubmitted: async (request) => {
3862
+ this.requireKey("perps.withdraw.reportSubmitted()");
3863
+ return this.requestJson("POST", "/api/v1/agent/perps/withdraw/submitted", request);
3864
+ },
3865
+ get: async (clientWithdrawalId) => {
3866
+ this.requireKey("perps.withdraw.get()");
3867
+ return this.requestJson("GET", `/api/v1/agent/perps/withdraw/${encodeURIComponent(clientWithdrawalId)}`);
3868
+ }
3869
+ }
3870
+ };
1207
3871
  wallets = {
1208
3872
  swapReceipt: async (hash) => {
1209
3873
  this.requireKey("wallets.swapReceipt()");
@@ -1277,9 +3941,9 @@ class CandleClient {
1277
3941
  if (opts.evmRpcUrl !== undefined)
1278
3942
  this.evmRpcUrl = opts.evmRpcUrl;
1279
3943
  this.hyperliquidNetwork = opts.hyperliquidNetwork ?? "mainnet";
1280
- const builder = opts.hyperliquidBuilder ?? CANDLE_HYPERLIQUID_BUILDER_ADDRESS;
1281
- if (builder)
1282
- this.hyperliquidBuilder = builder.toLowerCase();
3944
+ this.perpsWithdrawPins = opts.perpsWithdrawPins ?? CANDLE_PERPS_WITHDRAW_PINS;
3945
+ if (opts.hyperliquidBuilder !== undefined)
3946
+ this.hyperliquidBuilder = opts.hyperliquidBuilder;
1283
3947
  }
1284
3948
  async getQuotePairs(chain) {
1285
3949
  const query = chain ? `?chain=${chain}` : "";
@@ -1994,13 +4658,47 @@ class CandleClient {
1994
4658
  }
1995
4659
  return txHash;
1996
4660
  }
4661
+ marketReader() {
4662
+ this.perpsMarketReader ??= new PerpsMarketReader({
4663
+ apiKey: this.apiKey,
4664
+ network: this.hyperliquidNetwork,
4665
+ fetcher: this.fetchImpl,
4666
+ readConfig: () => this.perpsConfig()
4667
+ });
4668
+ return this.perpsMarketReader;
4669
+ }
4670
+ async perpsMarkets(opts = {}) {
4671
+ return this.marketReader().markets(opts);
4672
+ }
4673
+ async perpsMarket(coin) {
4674
+ return this.marketReader().market(coin);
4675
+ }
4676
+ async perpsBook(coin, opts = {}) {
4677
+ return this.marketReader().book(coin, opts);
4678
+ }
4679
+ async perpsCandles(coin, opts = {}) {
4680
+ return this.marketReader().candles(coin, opts);
4681
+ }
1997
4682
  async perpsConfig() {
1998
4683
  this.requireKey("perpsConfig()");
1999
4684
  return this.requestJson("GET", "/api/v1/agent/perps/config");
2000
4685
  }
2001
4686
  async perpsSetup(params) {
2002
4687
  this.requireKey("perpsSetup()");
4688
+ const builder = this.perpsBuilder();
4689
+ const address = await this.perpsWalletAddress(params.walletId);
4690
+ const mode = await hyperliquidAccountMode(this.fetchImpl, this.hyperliquidNetwork, address);
4691
+ if (!HYPERLIQUID_STANDARD_ACCOUNT_MODES.includes(mode))
4692
+ throw new Error(`perps: PERPS_ACCOUNT_MODE: the account is in ${mode} mode on Hyperliquid; Candle trades only a standard account. Nothing was requested or signed.`);
2003
4693
  const res = await this.requestJson("POST", "/api/v1/agent/perps/setup", { walletId: params.walletId });
4694
+ const problem = perpsSetupStatusProblem(res, {
4695
+ network: this.hyperliquidNetwork,
4696
+ address,
4697
+ builder,
4698
+ mode
4699
+ });
4700
+ if (problem)
4701
+ throw new Error(`perps: refused this setup answer: ${problem}. Nothing was signed.`);
2004
4702
  const status = {
2005
4703
  walletId: res.walletId,
2006
4704
  address: res.address,
@@ -2011,7 +4709,8 @@ class CandleClient {
2011
4709
  withdrawable: res.withdrawable,
2012
4710
  builder: res.builder,
2013
4711
  approvedFeeTenthsBps: res.approvedFeeTenthsBps,
2014
- ready: res.ready
4712
+ ready: res.ready,
4713
+ preview: { builderTrust: this.perpsBuilderTrust() }
2015
4714
  };
2016
4715
  if (res.ready)
2017
4716
  return status;
@@ -2019,18 +4718,39 @@ class CandleClient {
2019
4718
  }
2020
4719
  async perpsOpen(params) {
2021
4720
  this.requireKey("perpsOpen()");
4721
+ this.perpsBuilder();
2022
4722
  const { walletId, privyWalletId: _p, submit: _s, ...order } = params;
2023
- const build = await this.requestJson("POST", "/api/v1/agent/perps/open", { walletId, ...order });
2024
- return this.perpsComplete(build, params, "open");
4723
+ const clientId = perpsClientId(params.clientId);
4724
+ const build = await this.requestJson("POST", "/api/v1/agent/perps/open", {
4725
+ walletId,
4726
+ ...order,
4727
+ ...clientId !== undefined ? { clientId } : {}
4728
+ });
4729
+ const replay = perpsReplayOf(build, walletId, clientId);
4730
+ if (replay)
4731
+ return replay;
4732
+ return this.perpsComplete(build, { ...params, ...clientId ? { clientId } : {} }, "open");
2025
4733
  }
2026
4734
  async perpsClose(params) {
2027
4735
  this.requireKey("perpsClose()");
4736
+ if (params.pct !== undefined && params.size !== undefined)
4737
+ throw new Error("perpsClose(): give a size or a pct, not both");
4738
+ this.perpsBuilder();
2028
4739
  const { walletId, privyWalletId: _p, submit: _s, ...close } = params;
2029
- const build = await this.requestJson("POST", "/api/v1/agent/perps/close", { walletId, ...close });
2030
- return this.perpsComplete(build, params, "close");
4740
+ const clientId = perpsClientId(params.clientId);
4741
+ const build = await this.requestJson("POST", "/api/v1/agent/perps/close", {
4742
+ walletId,
4743
+ ...close,
4744
+ ...clientId !== undefined ? { clientId } : {}
4745
+ });
4746
+ const replay = perpsReplayOf(build, walletId, clientId);
4747
+ if (replay)
4748
+ return replay;
4749
+ return this.perpsComplete(build, { ...params, ...clientId ? { clientId } : {} }, "close");
2031
4750
  }
2032
4751
  async perpsCancel(params) {
2033
4752
  this.requireKey("perpsCancel()");
4753
+ this.perpsBuilder();
2034
4754
  const build = await this.requestJson("POST", "/api/v1/agent/perps/cancel", {
2035
4755
  walletId: params.walletId,
2036
4756
  cloid: params.cloid
@@ -2039,18 +4759,42 @@ class CandleClient {
2039
4759
  }
2040
4760
  async perpsModify(params) {
2041
4761
  this.requireKey("perpsModify()");
4762
+ this.perpsBuilder();
2042
4763
  const { walletId, privyWalletId: _p, submit: _s, ...modify } = params;
2043
4764
  const build = await this.requestJson("POST", "/api/v1/agent/perps/modify", { walletId, ...modify });
2044
4765
  return this.perpsComplete(build, params, "modify");
2045
4766
  }
2046
4767
  async perpsLeverage(params) {
2047
4768
  this.requireKey("perpsLeverage()");
4769
+ if (params.leverage === undefined && params.mode === undefined)
4770
+ throw new Error("perpsLeverage(): leverage is required unless mode names the margin mode");
4771
+ this.perpsBuilder();
2048
4772
  const { walletId, privyWalletId: _p, submit: _s, ...leverage } = params;
2049
4773
  const build = await this.requestJson("POST", "/api/v1/agent/perps/leverage", { walletId, ...leverage });
2050
- return this.perpsComplete(build, params, "leverage");
4774
+ const result = await this.perpsComplete(build, params, "leverage");
4775
+ if (params.mode === undefined || result.outcome?.status !== "confirmed")
4776
+ return result;
4777
+ let confirmedMode = null;
4778
+ try {
4779
+ const res = await this.fetchImpl(HYPERLIQUID_EXCHANGE_URLS[this.hyperliquidNetwork].replace("/exchange", "/info"), {
4780
+ method: "POST",
4781
+ headers: { "content-type": "application/json" },
4782
+ body: JSON.stringify({
4783
+ type: "activeAssetData",
4784
+ user: build.address,
4785
+ coin: String(build.preview.coin ?? params.coin)
4786
+ })
4787
+ });
4788
+ const type = JSON.parse(await res.text())?.leverage?.type;
4789
+ confirmedMode = type === "cross" || type === "isolated" ? type : null;
4790
+ } catch {
4791
+ confirmedMode = null;
4792
+ }
4793
+ return { ...result, requestedMode: params.mode, confirmedMode };
2051
4794
  }
2052
4795
  async perpsMargin(params) {
2053
4796
  this.requireKey("perpsMargin()");
4797
+ this.perpsBuilder();
2054
4798
  const build = await this.requestJson("POST", "/api/v1/agent/perps/margin", {
2055
4799
  walletId: params.walletId,
2056
4800
  coin: params.coin,
@@ -2060,7 +4804,22 @@ class CandleClient {
2060
4804
  }
2061
4805
  async perpsPositions(walletId) {
2062
4806
  this.requireKey("perpsPositions()");
2063
- return this.requestJson("GET", `/api/v1/agent/perps/positions?walletId=${encodeURIComponent(walletId)}`);
4807
+ const server = await this.requestJson("GET", `/api/v1/agent/perps/positions?walletId=${encodeURIComponent(walletId)}`);
4808
+ const { effective } = await this.perpsThresholdsOrDefaults();
4809
+ const identity2 = { walletId, address: server.address, network: this.hyperliquidNetwork };
4810
+ let report;
4811
+ try {
4812
+ report = await readPerpsRisk({
4813
+ ...identity2,
4814
+ fetcher: this.fetchImpl,
4815
+ thresholds: effective,
4816
+ now: Date.now
4817
+ });
4818
+ } catch {
4819
+ report = unknownRiskReport(identity2, effective, "PERPS_READ_FAILED", Date.now());
4820
+ }
4821
+ const { positions: rows, ...risk } = report;
4822
+ return { ...server, rows, accountLine: report.account, risk };
2064
4823
  }
2065
4824
  async perpsOrders(walletId, opts = {}) {
2066
4825
  this.requireKey("perpsOrders()");
@@ -2069,6 +4828,43 @@ class CandleClient {
2069
4828
  params.set("limit", String(opts.limit));
2070
4829
  return this.requestJson("GET", `/api/v1/agent/perps/orders?${params}`);
2071
4830
  }
4831
+ async perpsFillsPage(walletId, options = {}) {
4832
+ this.requireKey("perpsFillsPage()");
4833
+ return await this.perpsHistoryPage("fills", walletId, options);
4834
+ }
4835
+ async perpsFundingPage(walletId, options = {}) {
4836
+ this.requireKey("perpsFundingPage()");
4837
+ return await this.perpsHistoryPage("funding", walletId, options);
4838
+ }
4839
+ async perpsHistoryPage(kind, walletId, options) {
4840
+ const address = await this.perpsWalletAddress(walletId);
4841
+ return readPerpsHistoryPage({
4842
+ kind,
4843
+ network: this.hyperliquidNetwork,
4844
+ walletId,
4845
+ address,
4846
+ options,
4847
+ fetch: this.fetchImpl,
4848
+ now: Date.now
4849
+ });
4850
+ }
4851
+ async perpsWalletAddress(walletId) {
4852
+ let cursor = null;
4853
+ for (let page = 0;page < 10; page++) {
4854
+ const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
4855
+ const listed = await this.requestJson("GET", `/api/v1/agent/wallets/trading${query}`);
4856
+ const row = (listed.page ?? []).find((r) => r.id === walletId);
4857
+ if (row) {
4858
+ if (row.chain !== "evm" || !/^0x[0-9a-fA-F]{40}$/.test(row.address))
4859
+ throw new Error(`perps: wallet ${walletId} is not an EVM TEE wallet`);
4860
+ return row.address.toLowerCase();
4861
+ }
4862
+ if (listed.isDone !== false || !listed.continueCursor)
4863
+ break;
4864
+ cursor = listed.continueCursor;
4865
+ }
4866
+ throw new Error(`perps: wallet ${walletId} is not an EVM TEE wallet bound to this key`);
4867
+ }
2072
4868
  async perpsFills(walletId) {
2073
4869
  this.requireKey("perpsFills()");
2074
4870
  return this.requestJson("GET", `/api/v1/agent/perps/fills?walletId=${encodeURIComponent(walletId)}`);
@@ -2080,6 +4876,87 @@ class CandleClient {
2080
4876
  params.set("startTime", String(startTime));
2081
4877
  return this.requestJson("GET", `/api/v1/agent/perps/funding?${params}`);
2082
4878
  }
4879
+ async getPerpsRisk() {
4880
+ this.requireKey("getPerpsRisk()");
4881
+ const { thresholds, stored } = perpsRiskFromLimits(await this.requestJson("GET", "/api/v1/agent/keys/self/limits"));
4882
+ return { perpsRisk: stored, effective: thresholds };
4883
+ }
4884
+ async setPerpsRisk(fields) {
4885
+ this.requireKey("setPerpsRisk()");
4886
+ return this.requestJson("PUT", "/api/v1/agent/keys/self/perps-risk", { perpsRisk: fields });
4887
+ }
4888
+ async setKeyPerpsRisk(keyPrefix, fields, owner) {
4889
+ const res = await this.fetchImpl(`${this.apiUrl}/api/v1/agent/keys/${encodeURIComponent(keyPrefix)}/perps-risk`, {
4890
+ method: "PUT",
4891
+ headers: { "content-type": "application/json", authorization: `Bearer ${owner.bearerToken}` },
4892
+ body: JSON.stringify({ perpsRisk: fields })
4893
+ });
4894
+ return this.parseResponse(res);
4895
+ }
4896
+ async perpsRisk(params) {
4897
+ this.requireKey("perpsRisk()");
4898
+ const address = params.address ?? await this.perpsWalletAddress(params.walletId);
4899
+ const { effective } = await this.perpsThresholdsOrDefaults();
4900
+ return readPerpsRisk({
4901
+ fetcher: this.fetchImpl,
4902
+ network: this.hyperliquidNetwork,
4903
+ walletId: params.walletId,
4904
+ address,
4905
+ thresholds: effective,
4906
+ now: Date.now
4907
+ });
4908
+ }
4909
+ async perpsAccount(params) {
4910
+ this.requireKey("perpsAccount()");
4911
+ const now = Date.now();
4912
+ const startTime = params.since ?? now - 24 * 60 * 60 * 1000;
4913
+ if (!Number.isSafeInteger(startTime) || startTime >= now || now - startTime > PERPS_RECENT_MAX_MS)
4914
+ throw new Error("perpsAccount(): since must be an epoch-ms time within the last 30 days");
4915
+ const address = params.address ?? await this.perpsWalletAddress(params.walletId);
4916
+ const { effective } = await this.perpsThresholdsOrDefaults();
4917
+ const fetcher = this.fetchImpl;
4918
+ const [report, recent] = await Promise.all([
4919
+ readPerpsRisk({
4920
+ fetcher,
4921
+ network: this.hyperliquidNetwork,
4922
+ walletId: params.walletId,
4923
+ address,
4924
+ thresholds: effective,
4925
+ now: Date.now
4926
+ }),
4927
+ readPerpsRecent(fetcher, this.hyperliquidNetwork, address, { startTime, endTime: now })
4928
+ ]);
4929
+ return perpsAccountReport(report, recent);
4930
+ }
4931
+ async watchPerpsRisk(params) {
4932
+ this.requireKey("watchPerpsRisk()");
4933
+ const address = params.address ?? await this.perpsWalletAddress(params.walletId);
4934
+ return watchPerpsRisk({
4935
+ fetcher: this.fetchImpl,
4936
+ network: this.hyperliquidNetwork,
4937
+ walletId: params.walletId,
4938
+ address,
4939
+ thresholds: async () => (await this.getPerpsRisk()).effective,
4940
+ ...params.intervalMs !== undefined ? { intervalMs: params.intervalMs } : {},
4941
+ ...params.heartbeatMs !== undefined ? { heartbeatMs: params.heartbeatMs } : {},
4942
+ ...params.signal ? { signal: params.signal } : {},
4943
+ ...params.onceLevelChange ? { onceLevelChange: true } : {},
4944
+ now: Date.now,
4945
+ sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)),
4946
+ onEvent: params.onEvent
4947
+ });
4948
+ }
4949
+ async perpsPortfolioSection(wallets) {
4950
+ return perpsPortfolioSection(this.fetchImpl, this.hyperliquidNetwork, wallets);
4951
+ }
4952
+ async perpsThresholdsOrDefaults() {
4953
+ try {
4954
+ return await this.getPerpsRisk();
4955
+ } catch {
4956
+ const { thresholds, stored } = perpsRiskFromLimits(null);
4957
+ return { perpsRisk: stored, effective: thresholds };
4958
+ }
4959
+ }
2083
4960
  async perpsDeposit(params) {
2084
4961
  this.requireKey("perpsDeposit()");
2085
4962
  const build = await this.requestJson("POST", "/api/v1/agent/perps/deposit", {
@@ -2153,45 +5030,32 @@ class CandleClient {
2153
5030
  this.requireKey("perpsDepositStatus()");
2154
5031
  return this.requestJson("GET", `/api/v1/agent/perps/deposit/${encodeURIComponent(clientDepositId)}`);
2155
5032
  }
2156
- async perpsBuilder() {
2157
- if (this.hyperliquidBuilder)
2158
- return this.hyperliquidBuilder;
2159
- const config = await this.perpsConfig();
2160
- if (!config.builder)
2161
- throw new Error("perps: the server reports no Hyperliquid builder address");
2162
- this.hyperliquidBuilder = config.builder.toLowerCase();
2163
- return this.hyperliquidBuilder;
5033
+ perpsBuilder() {
5034
+ return resolveHyperliquidBuilder(this.hyperliquidBuilder);
5035
+ }
5036
+ perpsBuilderTrust() {
5037
+ return {
5038
+ address: this.perpsBuilder(),
5039
+ source: this.hyperliquidBuilder === undefined ? "release" : "override"
5040
+ };
2164
5041
  }
2165
5042
  async perpsComplete(build, ref, method) {
2166
- const check = verifyPerpsBuild(build, {
2167
- builder: await this.perpsBuilder(),
5043
+ build = { ...build, preview: { ...build.preview, builderTrust: this.perpsBuilderTrust() } };
5044
+ const params = ref;
5045
+ const opts = {
5046
+ builder: this.perpsBuilder(),
2168
5047
  network: this.hyperliquidNetwork,
2169
- intent: { method, params: ref }
5048
+ intent: { method, params },
5049
+ keyReduceOnly: build.perpsReduceOnly === true
5050
+ };
5051
+ const check = method === "setup" ? verifyPerpsBuild(build, opts) : await verifyPerpsBuildAgainstVenue(build, {
5052
+ ...opts,
5053
+ address: build.address,
5054
+ fetcher: this.fetchImpl,
5055
+ now: Date.now
2170
5056
  });
2171
5057
  if (!check.ok)
2172
5058
  throw new Error(`perps: refused to sign this build: ${check.reason}`);
2173
- if (method === "close") {
2174
- const closeOrder = await hyperliquidCloseOrder(this.fetchImpl, this.hyperliquidNetwork, build.address, ref);
2175
- const closeCheck = verifyPerpsBuild(build, {
2176
- builder: await this.perpsBuilder(),
2177
- network: this.hyperliquidNetwork,
2178
- intent: { method, params: ref },
2179
- closeOrder
2180
- });
2181
- if (!closeCheck.ok)
2182
- throw new Error(`perps: refused to sign this build: ${closeCheck.reason}`);
2183
- }
2184
- if (method === "open" || method === "modify") {
2185
- const market = await hyperliquidOrderMarket(this.fetchImpl, this.hyperliquidNetwork, build.address, method, ref);
2186
- const marketCheck = verifyPerpsBuild(build, {
2187
- builder: await this.perpsBuilder(),
2188
- network: this.hyperliquidNetwork,
2189
- intent: { method, params: ref },
2190
- market
2191
- });
2192
- if (!marketCheck.ok)
2193
- throw new Error(`perps: refused to sign this build: ${marketCheck.reason}`);
2194
- }
2195
5059
  if (ref.submit === false)
2196
5060
  return { build, signature: null, submitted: false, exchange: null };
2197
5061
  const signature = await this.signLinkedTypedData({
@@ -2209,7 +5073,8 @@ class CandleClient {
2209
5073
  if (!res.ok) {
2210
5074
  return { build, signature, submitted: false, exchange: null, submitError: `HTTP ${res.status}: ${text}` };
2211
5075
  }
2212
- return { build, signature, submitted: true, exchange: JSON.parse(text) };
5076
+ const exchange = JSON.parse(text);
5077
+ return { build, signature, submitted: true, exchange, outcome: hyperliquidExchangeOutcome(exchange) };
2213
5078
  } catch (err) {
2214
5079
  return {
2215
5080
  build,
@@ -2220,6 +5085,162 @@ class CandleClient {
2220
5085
  };
2221
5086
  }
2222
5087
  }
5088
+ async perpsScheduleCancel(params) {
5089
+ this.requireKey("perpsScheduleCancel()");
5090
+ const clear = params.clear === true;
5091
+ if (clear === (params.seconds !== undefined))
5092
+ throw new Error("perpsScheduleCancel(): give seconds, or clear: true");
5093
+ const build = await this.requestJson("POST", "/api/v1/agent/perps/schedule-cancel", {
5094
+ walletId: params.walletId,
5095
+ ...clear ? { clear: true } : { seconds: params.seconds }
5096
+ });
5097
+ const result = await this.perpsComplete(build, params, "scheduleCancel");
5098
+ if (result.outcome?.status === "rejected")
5099
+ return { ...result, submitError: `PERPS_SCHEDULE_CANCEL_REJECTED: ${result.outcome.error}` };
5100
+ if (result.outcome?.status !== "confirmed")
5101
+ return result;
5102
+ let acknowledged = false;
5103
+ try {
5104
+ const ack = await this.requestJson("POST", "/api/v1/agent/perps/schedule-cancel/ack", {
5105
+ walletId: params.walletId,
5106
+ perpOrderId: build.perpOrderId,
5107
+ exchange: result.exchange
5108
+ });
5109
+ acknowledged = ack.acknowledged === true;
5110
+ } catch {
5111
+ acknowledged = false;
5112
+ }
5113
+ return { ...result, acknowledged };
5114
+ }
5115
+ async perpsTpsl(params) {
5116
+ this.requireKey("perpsTpsl()");
5117
+ const { walletId, privyWalletId: _p, submit: _s, ...tpsl } = params;
5118
+ return runPerpsTpsl(await this.perpsWorkflowIo(params), tpsl);
5119
+ }
5120
+ async perpsFlatten(params) {
5121
+ this.requireKey("perpsFlatten()");
5122
+ return runPerpsFlatten(await this.perpsWorkflowIo(params));
5123
+ }
5124
+ async perpsWorkflowIo(ref) {
5125
+ const address = await this.perpsWalletAddress(ref.walletId);
5126
+ const builder = this.perpsBuilder();
5127
+ const reduceOnly = await this.getPerpsReduceOnly().catch(() => null);
5128
+ return {
5129
+ network: this.hyperliquidNetwork,
5130
+ walletId: ref.walletId,
5131
+ address,
5132
+ builder,
5133
+ keyReduceOnly: reduceOnly === null ? true : reduceOnly.perpsReduceOnly,
5134
+ fetcher: this.fetchImpl,
5135
+ now: Date.now,
5136
+ build: (path, body) => this.requestJson("POST", path, body),
5137
+ read: (path) => this.requestJson("GET", path),
5138
+ sign: (typedData) => this.signLinkedTypedData({ linkedWalletId: ref.walletId, privyWalletId: ref.privyWalletId, typedData }),
5139
+ mode: ref.submit === false ? "no-submit" : "submit",
5140
+ cloid: () => {
5141
+ const bytes = new Uint8Array(16);
5142
+ globalThis.crypto.getRandomValues(bytes);
5143
+ return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
5144
+ }
5145
+ };
5146
+ }
5147
+ async getPerpsReduceOnly() {
5148
+ this.requireKey("getPerpsReduceOnly()");
5149
+ return this.requestJson("GET", "/api/v1/agent/keys/self/perps-reduce-only");
5150
+ }
5151
+ async setPerpsReduceOnly(perpsReduceOnly) {
5152
+ this.requireKey("setPerpsReduceOnly()");
5153
+ return this.requestJson("PUT", "/api/v1/agent/keys/self/perps-reduce-only", {
5154
+ perpsReduceOnly
5155
+ });
5156
+ }
5157
+ async setKeyPerpsReduceOnly(keyPrefix, perpsReduceOnly, owner) {
5158
+ const res = await this.fetchImpl(`${this.apiUrl}/api/v1/agent/keys/${encodeURIComponent(keyPrefix)}/perps-reduce-only`, {
5159
+ method: "PUT",
5160
+ headers: { "content-type": "application/json", authorization: `Bearer ${owner.bearerToken}` },
5161
+ body: JSON.stringify({ perpsReduceOnly })
5162
+ });
5163
+ return this.parseResponse(res);
5164
+ }
5165
+ async perpsWithdraw(params) {
5166
+ this.requireKey("perpsWithdraw()");
5167
+ if (this.hyperliquidNetwork !== "mainnet")
5168
+ throw new Error("perpsWithdraw: withdrawals are mainnet only");
5169
+ if (this.perpsWithdrawPins === null) {
5170
+ throw new Error("perpsWithdraw: no withdrawal pins are set; nothing was built");
5171
+ }
5172
+ const amountMicros = parseWithdrawAmount(params.amountUsdc);
5173
+ if (amountMicros === null)
5174
+ throw new Error("perpsWithdraw: amountUsdc must be a positive decimal, at most 6 dp");
5175
+ const id = params.clientWithdrawalId ?? `withdraw-${crypto.randomUUID()}`;
5176
+ const build = await this.perps.withdraw.build({
5177
+ clientWithdrawalId: id,
5178
+ amountUsdc: params.amountUsdc,
5179
+ to: params.to,
5180
+ wallet: params.walletId
5181
+ });
5182
+ if (build.status !== "quoted") {
5183
+ throw new Error(`perpsWithdraw: withdrawal ${id} is already ${build.status}; read it with perps.withdraw.get()`);
5184
+ }
5185
+ const mapping = verifyWithdrawMapping(build, {
5186
+ id,
5187
+ account: params.account,
5188
+ to: params.to,
5189
+ amountMicros,
5190
+ ownDestinations: params.ownDestinations,
5191
+ pins: this.perpsWithdrawPins
5192
+ });
5193
+ if (!mapping.ok)
5194
+ throw new Error(`perpsWithdraw: refused to sign this withdrawal: ${mapping.reason}`);
5195
+ const recipient = await relayRecipientProblem(this.fetchImpl, build.relayRequestId, build.to.address);
5196
+ if (recipient)
5197
+ throw new Error(`perpsWithdraw: refused to sign this withdrawal: ${recipient}`);
5198
+ const mappingSignature = await this.signLinkedTypedData({
5199
+ linkedWalletId: params.walletId,
5200
+ privyWalletId: params.privyWalletId,
5201
+ typedData: build.typedData
5202
+ });
5203
+ const authorization = await this.perps.withdraw.authorize({ clientWithdrawalId: id, signature: mappingSignature });
5204
+ const send = verifyWithdrawSendAsset(authorization, {
5205
+ mappingNonce: build.nonce,
5206
+ amountMicros,
5207
+ pins: this.perpsWithdrawPins
5208
+ });
5209
+ if (!send.ok)
5210
+ throw new Error(`perpsWithdraw: refused to sign the transfer: ${send.reason}`);
5211
+ const signature = await this.signLinkedTypedData({
5212
+ linkedWalletId: params.walletId,
5213
+ privyWalletId: params.privyWalletId,
5214
+ typedData: authorization.typedData
5215
+ });
5216
+ let exchange = null;
5217
+ let submitError;
5218
+ try {
5219
+ const res = await this.fetchImpl(HYPERLIQUID_EXCHANGE_URLS.mainnet, {
5220
+ method: "POST",
5221
+ headers: { "content-type": "application/json" },
5222
+ body: JSON.stringify(hyperliquidExchangeBody(authorization.action, authorization.nonce, signature))
5223
+ });
5224
+ const text = await res.text();
5225
+ if (res.ok)
5226
+ exchange = JSON.parse(text);
5227
+ else
5228
+ submitError = `HTTP ${res.status}: ${text}`;
5229
+ } catch (err) {
5230
+ submitError = err instanceof Error ? err.message : String(err);
5231
+ }
5232
+ if (exchange !== null) {
5233
+ await this.perps.withdraw.reportSubmitted({ clientWithdrawalId: id, exchangeResponse: exchange }).catch(() => {});
5234
+ }
5235
+ return {
5236
+ build,
5237
+ authorization,
5238
+ signature,
5239
+ submitted: exchange !== null,
5240
+ exchange,
5241
+ ...submitError !== undefined ? { submitError } : {}
5242
+ };
5243
+ }
2223
5244
  async signLinkedTypedData(params) {
2224
5245
  if (!this.privyAppId) {
2225
5246
  throw new Error("perps signing requires privyAppId in CandleClientOptions (the relay's Privy app id)");
@@ -2431,7 +5452,7 @@ function generateClientLaunchId() {
2431
5452
  function generateClientTradeId() {
2432
5453
  return generateSdkId();
2433
5454
  }
2434
- var SDK_VERSION = "0.6.2";
5455
+ var SDK_VERSION = "0.6.3";
2435
5456
  var sdkUpdateWarned = false;
2436
5457
  function noteLatestSdkVersion(value) {
2437
5458
  if (sdkUpdateWarned || !value || !/^\d+\.\d+\.\d+$/.test(value))
@@ -2652,7 +5673,7 @@ async function withFileLock(target, fn) {
2652
5673
  if (Date.now() > deadline) {
2653
5674
  throw new Error(`Timed out waiting for ${lockPath}. Another candle process is writing; if none is running, delete that file.`);
2654
5675
  }
2655
- await new Promise((resolve) => setTimeout(resolve, RETRY_MS));
5676
+ await new Promise((resolve2) => setTimeout(resolve2, RETRY_MS));
2656
5677
  }
2657
5678
  }
2658
5679
  try {
@@ -2788,29 +5809,61 @@ function verifyWebhookSignature(secret, header, body, nowSec, toleranceSec = 300
2788
5809
  return timingSafeEqual(provided, expected);
2789
5810
  }
2790
5811
  export {
5812
+ watchPerpsRisk,
5813
+ verifyWithdrawSendAsset,
5814
+ verifyWithdrawMapping,
2791
5815
  verifyWebhookSignature,
5816
+ verifyPerpsBuildAgainstVenue,
2792
5817
  verifyPerpsBuild,
5818
+ relayRecipientProblem,
5819
+ readPerpsRisk,
5820
+ readPerpsHistoryPage,
5821
+ readHyperliquidMarkets,
2793
5822
  planTableRows,
2794
5823
  planTableMarkdown,
2795
5824
  planPromotionLine,
2796
5825
  planLabel,
5826
+ perpsSetupStatusProblem,
5827
+ perpsRiskStep,
5828
+ perpsRiskExitCode,
5829
+ perpsPortfolioSection,
5830
+ perpsMarketGate,
2797
5831
  perpsDepositProblem,
5832
+ perpsCandleWindow,
5833
+ parseWithdrawAmount,
5834
+ parseSendAssetAmount,
2798
5835
  operationIds,
2799
5836
  isSolanaRpcErrorData,
5837
+ hyperliquidWalletSnapshot,
5838
+ hyperliquidRiskReport,
2800
5839
  hyperliquidOrderMarket,
5840
+ hyperliquidExchangeOutcome,
2801
5841
  hyperliquidActionHash,
5842
+ hyperliquidAccountMode,
2802
5843
  generateSignerKeypair,
2803
5844
  formatPlanBps,
2804
5845
  encryptWalletKeyForImport,
5846
+ effectivePerpsRisk,
2805
5847
  TradeLegOutcomeError,
2806
5848
  TradeLandedUnconfirmedError,
2807
5849
  TradeLandedFeeLegError,
2808
5850
  SelfLaunchOutcomeError,
5851
+ PerpsWorkflowError,
5852
+ PerpsMarketReader,
5853
+ PerpsMarketError,
5854
+ PerpsHistoryError,
2809
5855
  PLAN_CAPABILITY_NOTE,
2810
5856
  PLAN_CAPABILITY_LABELS,
5857
+ PERPS_HISTORY_MAX_LIMIT,
5858
+ PERPS_HISTORY_DEFAULT_LIMIT,
5859
+ PERPS_FLATTEN_MAX_STEPS,
5860
+ PERPS_CANDLE_INTERVALS,
2811
5861
  KeychainSecretStore,
2812
5862
  JsonRpcError,
2813
5863
  InMemorySecretStore,
5864
+ HyperliquidReadError,
5865
+ HYPERLIQUID_STANDARD_ACCOUNT_MODES,
5866
+ HYPERLIQUID_RISK_DEFAULTS,
2814
5867
  HYPERLIQUID_EXCHANGE_URLS,
2815
5868
  HYPERLIQUID_ALLOWED_ACTION_TYPES,
2816
5869
  EncryptedFileSecretStore,
@@ -2818,6 +5871,7 @@ export {
2818
5871
  CandleOperationError,
2819
5872
  CandleClient,
2820
5873
  CandleApiError,
5874
+ CANDLE_PERPS_WITHDRAW_PINS,
2821
5875
  CANDLE_HYPERLIQUID_BUILDER_ADDRESS,
2822
5876
  BRIDGE_ERROR_CODES
2823
5877
  };