@candledottv/agent-sdk 0.6.1 → 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";
@@ -718,11 +901,17 @@ function checkActionIntent(action, intent) {
718
901
  const trigger = leg.t?.trigger;
719
902
  if (leg.a !== first.a || leg.b !== !buy || leg.r !== true || decimal(leg.s) !== decimal(p.size) || trigger?.tpsl !== kind || trigger.isMarket !== true || decimal(trigger.triggerPx) !== decimal(p[field]))
720
903
  return "trigger side, size, reduce-only flag or price does not match the request";
904
+ const px = decimal(leg.p) ?? 0n;
905
+ const triggerPx = decimal(trigger.triggerPx) ?? 0n;
906
+ if (triggerPx <= 0n || !withinBps(px, triggerPx, leg.b === true, HYPERLIQUID_TPSL_SLIPPAGE_BPS + HYPERLIQUID_MID_DRIFT_BPS))
907
+ return "trigger order price is outside its slippage of the trigger price";
721
908
  }
722
909
  } else {
723
910
  if (list.length !== 1)
724
911
  return "unexpected extra orders";
725
- 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)))
726
915
  return "close size or reduce-only flag does not match the request";
727
916
  if (method === "modify" && p.size != null && decimal(first.s) !== decimal(p.size))
728
917
  return "replacement size does not match the request";
@@ -732,9 +921,38 @@ function checkActionIntent(action, intent) {
732
921
  }
733
922
  return null;
734
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
+ }
735
953
  async function hyperliquidCloseOrder(fetcher, network, address, params) {
736
954
  const read = async (body) => {
737
- const res = await fetcher(HYPERLIQUID_EXCHANGE_URLS[network].replace("/exchange", "/info"), {
955
+ const res = await fetcher(HYPERLIQUID_INFO_URLS[network], {
738
956
  method: "POST",
739
957
  headers: { "content-type": "application/json" },
740
958
  body: JSON.stringify(body)
@@ -745,20 +963,37 @@ async function hyperliquidCloseOrder(fetcher, network, address, params) {
745
963
  };
746
964
  const [meta, state] = await Promise.all([read({ type: "meta" }), read({ type: "clearinghouseState", user: address })]);
747
965
  const coin = String(params.coin).toUpperCase();
748
- 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);
749
968
  const szi = state.assetPositions.find((p) => p.position.coin.toUpperCase() === coin)?.position.szi;
750
969
  const signed = decimal(szi);
751
970
  if (asset < 0 || asset >= 1e4 || signed === null || signed === 0n)
752
971
  throw new Error("No main-universe position to close");
753
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
+ }
754
988
  const asked = params.size == null ? held : decimal(params.size);
755
989
  if (asked === null || asked <= 0n)
756
990
  throw new Error("Invalid close size");
757
- const size = asked < held ? String(params.size) : String(szi).replace(/^-/, "");
991
+ const size = asked < held ? String(params.size) : heldSz;
758
992
  return { asset, isBuy: signed < 0n, size };
759
993
  }
760
994
  var HYPERLIQUID_DEFAULT_SLIPPAGE_BPS = 100;
761
995
  var HYPERLIQUID_MID_DRIFT_BPS = 100;
996
+ var HYPERLIQUID_TPSL_SLIPPAGE_BPS = 500;
762
997
  async function hyperliquidOrderMarket(fetcher, network, address, method, params) {
763
998
  const read = async (body) => {
764
999
  const res = await fetcher(HYPERLIQUID_INFO_URLS[network], {
@@ -777,8 +1012,18 @@ async function hyperliquidOrderMarket(fetcher, network, address, method, params)
777
1012
  const resting = status?.order?.order ?? null;
778
1013
  if (!resting || typeof resting.coin !== "string" || decimal(resting.limitPx) === null)
779
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");
780
1017
  coin = resting.coin.toUpperCase();
781
- 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
+ };
782
1027
  }
783
1028
  const [meta, contexts] = await read({ type: "metaAndAssetCtxs" });
784
1029
  const asset = meta.universe.findIndex((m) => m.name.toUpperCase() === coin);
@@ -815,13 +1060,24 @@ function checkOrderMarket(action, intent, market) {
815
1060
  return "modify changes the resting order's side";
816
1061
  if (p.price == null && decimal(first.p) !== decimal(market.order.limitPx))
817
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";
818
1074
  }
819
1075
  return null;
820
1076
  }
821
1077
  function verifyPerpsBuild(build, opts) {
822
1078
  try {
823
1079
  const network = opts.network ?? "mainnet";
824
- if (!EVM_ADDRESS_RE.test(opts.builder))
1080
+ if (!EVM_ADDRESS_RE.test(opts.builder) || opts.builder.toLowerCase() === ZERO_ADDRESS)
825
1081
  return { ok: false, reason: "no valid Candle builder address to check against" };
826
1082
  if (build.network !== undefined && build.network !== network) {
827
1083
  return { ok: false, reason: `the build is for ${build.network}, this client trades ${network}` };
@@ -837,7 +1093,7 @@ function verifyPerpsBuild(build, opts) {
837
1093
  const action = build.action;
838
1094
  if (!action || typeof action !== "object")
839
1095
  return { ok: false, reason: "no action" };
840
- const intentRefusal = checkActionIntent(action, opts.intent);
1096
+ const intentRefusal = checkActionIntent(action, opts);
841
1097
  if (intentRefusal)
842
1098
  return { ok: false, reason: intentRefusal };
843
1099
  if (opts.closeOrder) {
@@ -857,6 +1113,9 @@ function verifyPerpsBuild(build, opts) {
857
1113
  if (typeof type !== "string" || !HYPERLIQUID_ALLOWED_ACTION_TYPES.includes(type)) {
858
1114
  return { ok: false, reason: `action type ${String(type)} is not one Candle builds` };
859
1115
  }
1116
+ const shapeRefusal = actionShapeProblem(action);
1117
+ if (shapeRefusal)
1118
+ return { ok: false, reason: shapeRefusal };
860
1119
  if (type === "order") {
861
1120
  const refusal = checkBuilderField(action, opts.builder);
862
1121
  if (refusal)
@@ -877,37 +1136,2119 @@ function verifyPerpsBuild(build, opts) {
877
1136
  if (!sameValue(typed, expected)) {
878
1137
  return { ok: false, reason: "the builder approval is not Candle's builder at 0.1% on Mainnet" };
879
1138
  }
880
- const expectedAction = {
881
- type: "approveBuilderFee",
882
- hyperliquidChain: "Mainnet",
883
- signatureChainId: HYPERLIQUID_SIGNATURE_CHAIN_ID,
884
- maxFeeRate: HYPERLIQUID_MAX_BUILDER_FEE_RATE,
885
- builder: opts.builder.toLowerCase(),
886
- nonce: build.nonce
887
- };
888
- if (!sameValue(action, expectedAction)) {
889
- 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 });
890
2945
  }
891
- return { ok: true, kind: "approveBuilderFee" };
892
2946
  }
893
- return { ok: false, reason: `primary type ${String(typed.primary_type)} is not one the relay signs` };
894
- } catch (err) {
895
- return { ok: false, reason: err instanceof Error ? err.message : "unreadable build" };
2947
+ if (opts.signal?.aborted)
2948
+ break;
2949
+ await sleep2(interval);
896
2950
  }
2951
+ return { level };
897
2952
  }
898
- function hyperliquidSplitSignature(signature) {
899
- if (!/^0x[0-9a-fA-F]{130}$/.test(signature))
900
- throw new Error("Not a 65-byte signature");
901
- let v = Number.parseInt(signature.slice(130, 132), 16);
902
- if (v < 27)
903
- v += 27;
904
- 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
+ });
905
2966
  }
906
- function hyperliquidExchangeBody(action, nonce, signature) {
907
- 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
+ };
908
3034
  }
909
- function hyperliquidRelayBody(typedData) {
910
- 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}`;
911
3252
  }
912
3253
 
913
3254
  // src/internal/rpc-endpoint.ts
@@ -919,6 +3260,288 @@ function describeRpcEndpoint(url) {
919
3260
  }
920
3261
  }
921
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
+
922
3545
  // src/wallet-import.ts
923
3546
  import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
924
3547
  import { CipherSuite, DhkemP256HkdfSha256, HkdfSha256 } from "@hpke/core";
@@ -1005,6 +3628,32 @@ function historyTime(value) {
1005
3628
  }
1006
3629
  var HYPERLIQUID_RELAY_CHAIN_ID = 1337;
1007
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
+ }
1008
3657
  function perpsDepositProblem(build, params) {
1009
3658
  const same = (a, b) => typeof a === "string" && a.toLowerCase() === b.toLowerCase();
1010
3659
  if (build.walletId !== params.walletId)
@@ -1046,7 +3695,7 @@ function isRetryableLaunchFailure(error) {
1046
3695
  return error.status >= 500 || error.status === 409;
1047
3696
  }
1048
3697
  function sleep2(ms) {
1049
- return new Promise((resolve) => setTimeout(resolve, ms));
3698
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1050
3699
  }
1051
3700
  var MAX_BLOCKHASH_REBUILDS = 2;
1052
3701
  function isBlockhashExpiry(error) {
@@ -1196,7 +3845,29 @@ class CandleClient {
1196
3845
  solanaRpcUrl;
1197
3846
  evmRpcUrl;
1198
3847
  hyperliquidNetwork;
3848
+ perpsMarketReader;
1199
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
+ };
1200
3871
  wallets = {
1201
3872
  swapReceipt: async (hash) => {
1202
3873
  this.requireKey("wallets.swapReceipt()");
@@ -1270,9 +3941,9 @@ class CandleClient {
1270
3941
  if (opts.evmRpcUrl !== undefined)
1271
3942
  this.evmRpcUrl = opts.evmRpcUrl;
1272
3943
  this.hyperliquidNetwork = opts.hyperliquidNetwork ?? "mainnet";
1273
- const builder = opts.hyperliquidBuilder ?? CANDLE_HYPERLIQUID_BUILDER_ADDRESS;
1274
- if (builder)
1275
- this.hyperliquidBuilder = builder.toLowerCase();
3944
+ this.perpsWithdrawPins = opts.perpsWithdrawPins ?? CANDLE_PERPS_WITHDRAW_PINS;
3945
+ if (opts.hyperliquidBuilder !== undefined)
3946
+ this.hyperliquidBuilder = opts.hyperliquidBuilder;
1276
3947
  }
1277
3948
  async getQuotePairs(chain) {
1278
3949
  const query = chain ? `?chain=${chain}` : "";
@@ -1987,13 +4658,47 @@ class CandleClient {
1987
4658
  }
1988
4659
  return txHash;
1989
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
+ }
1990
4682
  async perpsConfig() {
1991
4683
  this.requireKey("perpsConfig()");
1992
4684
  return this.requestJson("GET", "/api/v1/agent/perps/config");
1993
4685
  }
1994
4686
  async perpsSetup(params) {
1995
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.`);
1996
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.`);
1997
4702
  const status = {
1998
4703
  walletId: res.walletId,
1999
4704
  address: res.address,
@@ -2004,7 +4709,8 @@ class CandleClient {
2004
4709
  withdrawable: res.withdrawable,
2005
4710
  builder: res.builder,
2006
4711
  approvedFeeTenthsBps: res.approvedFeeTenthsBps,
2007
- ready: res.ready
4712
+ ready: res.ready,
4713
+ preview: { builderTrust: this.perpsBuilderTrust() }
2008
4714
  };
2009
4715
  if (res.ready)
2010
4716
  return status;
@@ -2012,18 +4718,39 @@ class CandleClient {
2012
4718
  }
2013
4719
  async perpsOpen(params) {
2014
4720
  this.requireKey("perpsOpen()");
4721
+ this.perpsBuilder();
2015
4722
  const { walletId, privyWalletId: _p, submit: _s, ...order } = params;
2016
- const build = await this.requestJson("POST", "/api/v1/agent/perps/open", { walletId, ...order });
2017
- 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");
2018
4733
  }
2019
4734
  async perpsClose(params) {
2020
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();
2021
4739
  const { walletId, privyWalletId: _p, submit: _s, ...close } = params;
2022
- const build = await this.requestJson("POST", "/api/v1/agent/perps/close", { walletId, ...close });
2023
- 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");
2024
4750
  }
2025
4751
  async perpsCancel(params) {
2026
4752
  this.requireKey("perpsCancel()");
4753
+ this.perpsBuilder();
2027
4754
  const build = await this.requestJson("POST", "/api/v1/agent/perps/cancel", {
2028
4755
  walletId: params.walletId,
2029
4756
  cloid: params.cloid
@@ -2032,18 +4759,42 @@ class CandleClient {
2032
4759
  }
2033
4760
  async perpsModify(params) {
2034
4761
  this.requireKey("perpsModify()");
4762
+ this.perpsBuilder();
2035
4763
  const { walletId, privyWalletId: _p, submit: _s, ...modify } = params;
2036
4764
  const build = await this.requestJson("POST", "/api/v1/agent/perps/modify", { walletId, ...modify });
2037
4765
  return this.perpsComplete(build, params, "modify");
2038
4766
  }
2039
4767
  async perpsLeverage(params) {
2040
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();
2041
4772
  const { walletId, privyWalletId: _p, submit: _s, ...leverage } = params;
2042
4773
  const build = await this.requestJson("POST", "/api/v1/agent/perps/leverage", { walletId, ...leverage });
2043
- 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 };
2044
4794
  }
2045
4795
  async perpsMargin(params) {
2046
4796
  this.requireKey("perpsMargin()");
4797
+ this.perpsBuilder();
2047
4798
  const build = await this.requestJson("POST", "/api/v1/agent/perps/margin", {
2048
4799
  walletId: params.walletId,
2049
4800
  coin: params.coin,
@@ -2053,7 +4804,22 @@ class CandleClient {
2053
4804
  }
2054
4805
  async perpsPositions(walletId) {
2055
4806
  this.requireKey("perpsPositions()");
2056
- 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 };
2057
4823
  }
2058
4824
  async perpsOrders(walletId, opts = {}) {
2059
4825
  this.requireKey("perpsOrders()");
@@ -2062,6 +4828,43 @@ class CandleClient {
2062
4828
  params.set("limit", String(opts.limit));
2063
4829
  return this.requestJson("GET", `/api/v1/agent/perps/orders?${params}`);
2064
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
+ }
2065
4868
  async perpsFills(walletId) {
2066
4869
  this.requireKey("perpsFills()");
2067
4870
  return this.requestJson("GET", `/api/v1/agent/perps/fills?walletId=${encodeURIComponent(walletId)}`);
@@ -2073,6 +4876,87 @@ class CandleClient {
2073
4876
  params.set("startTime", String(startTime));
2074
4877
  return this.requestJson("GET", `/api/v1/agent/perps/funding?${params}`);
2075
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
+ }
2076
4960
  async perpsDeposit(params) {
2077
4961
  this.requireKey("perpsDeposit()");
2078
4962
  const build = await this.requestJson("POST", "/api/v1/agent/perps/deposit", {
@@ -2146,45 +5030,32 @@ class CandleClient {
2146
5030
  this.requireKey("perpsDepositStatus()");
2147
5031
  return this.requestJson("GET", `/api/v1/agent/perps/deposit/${encodeURIComponent(clientDepositId)}`);
2148
5032
  }
2149
- async perpsBuilder() {
2150
- if (this.hyperliquidBuilder)
2151
- return this.hyperliquidBuilder;
2152
- const config = await this.perpsConfig();
2153
- if (!config.builder)
2154
- throw new Error("perps: the server reports no Hyperliquid builder address");
2155
- this.hyperliquidBuilder = config.builder.toLowerCase();
2156
- 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
+ };
2157
5041
  }
2158
5042
  async perpsComplete(build, ref, method) {
2159
- const check = verifyPerpsBuild(build, {
2160
- builder: await this.perpsBuilder(),
5043
+ build = { ...build, preview: { ...build.preview, builderTrust: this.perpsBuilderTrust() } };
5044
+ const params = ref;
5045
+ const opts = {
5046
+ builder: this.perpsBuilder(),
2161
5047
  network: this.hyperliquidNetwork,
2162
- 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
2163
5056
  });
2164
5057
  if (!check.ok)
2165
5058
  throw new Error(`perps: refused to sign this build: ${check.reason}`);
2166
- if (method === "close") {
2167
- const closeOrder = await hyperliquidCloseOrder(this.fetchImpl, this.hyperliquidNetwork, build.address, ref);
2168
- const closeCheck = verifyPerpsBuild(build, {
2169
- builder: await this.perpsBuilder(),
2170
- network: this.hyperliquidNetwork,
2171
- intent: { method, params: ref },
2172
- closeOrder
2173
- });
2174
- if (!closeCheck.ok)
2175
- throw new Error(`perps: refused to sign this build: ${closeCheck.reason}`);
2176
- }
2177
- if (method === "open" || method === "modify") {
2178
- const market = await hyperliquidOrderMarket(this.fetchImpl, this.hyperliquidNetwork, build.address, method, ref);
2179
- const marketCheck = verifyPerpsBuild(build, {
2180
- builder: await this.perpsBuilder(),
2181
- network: this.hyperliquidNetwork,
2182
- intent: { method, params: ref },
2183
- market
2184
- });
2185
- if (!marketCheck.ok)
2186
- throw new Error(`perps: refused to sign this build: ${marketCheck.reason}`);
2187
- }
2188
5059
  if (ref.submit === false)
2189
5060
  return { build, signature: null, submitted: false, exchange: null };
2190
5061
  const signature = await this.signLinkedTypedData({
@@ -2202,7 +5073,8 @@ class CandleClient {
2202
5073
  if (!res.ok) {
2203
5074
  return { build, signature, submitted: false, exchange: null, submitError: `HTTP ${res.status}: ${text}` };
2204
5075
  }
2205
- 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) };
2206
5078
  } catch (err) {
2207
5079
  return {
2208
5080
  build,
@@ -2213,6 +5085,162 @@ class CandleClient {
2213
5085
  };
2214
5086
  }
2215
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
+ }
2216
5244
  async signLinkedTypedData(params) {
2217
5245
  if (!this.privyAppId) {
2218
5246
  throw new Error("perps signing requires privyAppId in CandleClientOptions (the relay's Privy app id)");
@@ -2424,7 +5452,7 @@ function generateClientLaunchId() {
2424
5452
  function generateClientTradeId() {
2425
5453
  return generateSdkId();
2426
5454
  }
2427
- var SDK_VERSION = "0.6.1";
5455
+ var SDK_VERSION = "0.6.3";
2428
5456
  var sdkUpdateWarned = false;
2429
5457
  function noteLatestSdkVersion(value) {
2430
5458
  if (sdkUpdateWarned || !value || !/^\d+\.\d+\.\d+$/.test(value))
@@ -2645,7 +5673,7 @@ async function withFileLock(target, fn) {
2645
5673
  if (Date.now() > deadline) {
2646
5674
  throw new Error(`Timed out waiting for ${lockPath}. Another candle process is writing; if none is running, delete that file.`);
2647
5675
  }
2648
- await new Promise((resolve) => setTimeout(resolve, RETRY_MS));
5676
+ await new Promise((resolve2) => setTimeout(resolve2, RETRY_MS));
2649
5677
  }
2650
5678
  }
2651
5679
  try {
@@ -2781,29 +5809,61 @@ function verifyWebhookSignature(secret, header, body, nowSec, toleranceSec = 300
2781
5809
  return timingSafeEqual(provided, expected);
2782
5810
  }
2783
5811
  export {
5812
+ watchPerpsRisk,
5813
+ verifyWithdrawSendAsset,
5814
+ verifyWithdrawMapping,
2784
5815
  verifyWebhookSignature,
5816
+ verifyPerpsBuildAgainstVenue,
2785
5817
  verifyPerpsBuild,
5818
+ relayRecipientProblem,
5819
+ readPerpsRisk,
5820
+ readPerpsHistoryPage,
5821
+ readHyperliquidMarkets,
2786
5822
  planTableRows,
2787
5823
  planTableMarkdown,
2788
5824
  planPromotionLine,
2789
5825
  planLabel,
5826
+ perpsSetupStatusProblem,
5827
+ perpsRiskStep,
5828
+ perpsRiskExitCode,
5829
+ perpsPortfolioSection,
5830
+ perpsMarketGate,
2790
5831
  perpsDepositProblem,
5832
+ perpsCandleWindow,
5833
+ parseWithdrawAmount,
5834
+ parseSendAssetAmount,
2791
5835
  operationIds,
2792
5836
  isSolanaRpcErrorData,
5837
+ hyperliquidWalletSnapshot,
5838
+ hyperliquidRiskReport,
2793
5839
  hyperliquidOrderMarket,
5840
+ hyperliquidExchangeOutcome,
2794
5841
  hyperliquidActionHash,
5842
+ hyperliquidAccountMode,
2795
5843
  generateSignerKeypair,
2796
5844
  formatPlanBps,
2797
5845
  encryptWalletKeyForImport,
5846
+ effectivePerpsRisk,
2798
5847
  TradeLegOutcomeError,
2799
5848
  TradeLandedUnconfirmedError,
2800
5849
  TradeLandedFeeLegError,
2801
5850
  SelfLaunchOutcomeError,
5851
+ PerpsWorkflowError,
5852
+ PerpsMarketReader,
5853
+ PerpsMarketError,
5854
+ PerpsHistoryError,
2802
5855
  PLAN_CAPABILITY_NOTE,
2803
5856
  PLAN_CAPABILITY_LABELS,
5857
+ PERPS_HISTORY_MAX_LIMIT,
5858
+ PERPS_HISTORY_DEFAULT_LIMIT,
5859
+ PERPS_FLATTEN_MAX_STEPS,
5860
+ PERPS_CANDLE_INTERVALS,
2804
5861
  KeychainSecretStore,
2805
5862
  JsonRpcError,
2806
5863
  InMemorySecretStore,
5864
+ HyperliquidReadError,
5865
+ HYPERLIQUID_STANDARD_ACCOUNT_MODES,
5866
+ HYPERLIQUID_RISK_DEFAULTS,
2807
5867
  HYPERLIQUID_EXCHANGE_URLS,
2808
5868
  HYPERLIQUID_ALLOWED_ACTION_TYPES,
2809
5869
  EncryptedFileSecretStore,
@@ -2811,6 +5871,7 @@ export {
2811
5871
  CandleOperationError,
2812
5872
  CandleClient,
2813
5873
  CandleApiError,
5874
+ CANDLE_PERPS_WITHDRAW_PINS,
2814
5875
  CANDLE_HYPERLIQUID_BUILDER_ADDRESS,
2815
5876
  BRIDGE_ERROR_CODES
2816
5877
  };