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