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