@candledottv/mcp 0.10.4 → 0.10.6
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 +65 -19
- package/dist/index.js +1037 -67
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
|
|
7
7
|
// src/tools.ts
|
|
8
|
-
import { z } from "zod";
|
|
8
|
+
import { z as z2 } from "zod";
|
|
9
9
|
|
|
10
10
|
// src/client.ts
|
|
11
11
|
var DEFAULT_API_URL = "https://api.alpha.candle.tv";
|
|
@@ -106,7 +106,7 @@ function percentOfBalance(balanceRaw, percent) {
|
|
|
106
106
|
import { randomUUID } from "node:crypto";
|
|
107
107
|
|
|
108
108
|
// src/version.ts
|
|
109
|
-
var SERVER_VERSION = "0.10.
|
|
109
|
+
var SERVER_VERSION = "0.10.6";
|
|
110
110
|
|
|
111
111
|
// src/update-notice.ts
|
|
112
112
|
var PLAIN_VERSION = /^\d+\.\d+\.\d+$/;
|
|
@@ -551,6 +551,879 @@ async function executionStatus(cfg, doFetch) {
|
|
|
551
551
|
};
|
|
552
552
|
}
|
|
553
553
|
|
|
554
|
+
// src/perps.ts
|
|
555
|
+
import { createPrivateKey, sign } from "node:crypto";
|
|
556
|
+
import { readFile } from "node:fs/promises";
|
|
557
|
+
import { z } from "zod";
|
|
558
|
+
|
|
559
|
+
// src/hyperliquid.ts
|
|
560
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
561
|
+
var HYPERLIQUID_ALLOWED_ACTION_TYPES = [
|
|
562
|
+
"order",
|
|
563
|
+
"cancel",
|
|
564
|
+
"modify",
|
|
565
|
+
"updateLeverage",
|
|
566
|
+
"updateIsolatedMargin"
|
|
567
|
+
];
|
|
568
|
+
var HYPERLIQUID_EXCHANGE_URLS = {
|
|
569
|
+
mainnet: "https://api.hyperliquid.xyz/exchange",
|
|
570
|
+
testnet: "https://api.hyperliquid-testnet.xyz/exchange"
|
|
571
|
+
};
|
|
572
|
+
var CANDLE_HYPERLIQUID_BUILDER_ADDRESS = null;
|
|
573
|
+
var HYPERLIQUID_MAX_BUILDER_FEE_TENTHS_BPS = 100;
|
|
574
|
+
var HYPERLIQUID_MAX_BUILDER_FEE_RATE = "0.1%";
|
|
575
|
+
var HYPERLIQUID_SIGNATURE_CHAIN_ID = "0xa4b1";
|
|
576
|
+
var ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
|
577
|
+
var UINT64_MAX = (1n << 64n) - 1n;
|
|
578
|
+
var INT64_MIN = -(1n << 63n);
|
|
579
|
+
function pushUint(out, value, width) {
|
|
580
|
+
for (let i = width - 1;i >= 0; i--)
|
|
581
|
+
out.push(Number(value >> BigInt(i * 8) & 0xffn));
|
|
582
|
+
}
|
|
583
|
+
function packInteger(out, value) {
|
|
584
|
+
if (value >= 0n) {
|
|
585
|
+
if (value > UINT64_MAX)
|
|
586
|
+
throw new Error("msgpack: integer above uint64");
|
|
587
|
+
if (value < 0x80n)
|
|
588
|
+
out.push(Number(value));
|
|
589
|
+
else if (value <= 0xffn) {
|
|
590
|
+
out.push(204);
|
|
591
|
+
pushUint(out, value, 1);
|
|
592
|
+
} else if (value <= 0xffffn) {
|
|
593
|
+
out.push(205);
|
|
594
|
+
pushUint(out, value, 2);
|
|
595
|
+
} else if (value <= 0xffffffffn) {
|
|
596
|
+
out.push(206);
|
|
597
|
+
pushUint(out, value, 4);
|
|
598
|
+
} else {
|
|
599
|
+
out.push(207);
|
|
600
|
+
pushUint(out, value, 8);
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (value < INT64_MIN)
|
|
605
|
+
throw new Error("msgpack: integer below int64");
|
|
606
|
+
if (value >= -32n)
|
|
607
|
+
out.push(Number(value & 0xffn));
|
|
608
|
+
else if (value >= -128n) {
|
|
609
|
+
out.push(208);
|
|
610
|
+
pushUint(out, value & 0xffn, 1);
|
|
611
|
+
} else if (value >= -32768n) {
|
|
612
|
+
out.push(209);
|
|
613
|
+
pushUint(out, value & 0xffffn, 2);
|
|
614
|
+
} else if (value >= -2147483648n) {
|
|
615
|
+
out.push(210);
|
|
616
|
+
pushUint(out, value & 0xffffffffn, 4);
|
|
617
|
+
} else {
|
|
618
|
+
out.push(211);
|
|
619
|
+
pushUint(out, value & UINT64_MAX, 8);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function packHeader(out, length, fixBase, fixMax, codes) {
|
|
623
|
+
if (length <= fixMax) {
|
|
624
|
+
out.push(fixBase | length);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const [c8, c16, c32] = codes;
|
|
628
|
+
if (c8 !== undefined && c8 !== 0 && length <= 255) {
|
|
629
|
+
out.push(c8);
|
|
630
|
+
pushUint(out, BigInt(length), 1);
|
|
631
|
+
} else if (length <= 65535) {
|
|
632
|
+
out.push(c16);
|
|
633
|
+
pushUint(out, BigInt(length), 2);
|
|
634
|
+
} else {
|
|
635
|
+
out.push(c32);
|
|
636
|
+
pushUint(out, BigInt(length), 4);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function packValue(out, value) {
|
|
640
|
+
if (value === null)
|
|
641
|
+
out.push(192);
|
|
642
|
+
else if (value === false)
|
|
643
|
+
out.push(194);
|
|
644
|
+
else if (value === true)
|
|
645
|
+
out.push(195);
|
|
646
|
+
else if (typeof value === "bigint")
|
|
647
|
+
packInteger(out, value);
|
|
648
|
+
else if (typeof value === "number") {
|
|
649
|
+
if (!Number.isSafeInteger(value))
|
|
650
|
+
throw new Error(`msgpack: ${value} is not a safe integer`);
|
|
651
|
+
packInteger(out, BigInt(value));
|
|
652
|
+
} else if (typeof value === "string") {
|
|
653
|
+
const bytes = new TextEncoder().encode(value);
|
|
654
|
+
packHeader(out, bytes.length, 160, 31, [217, 218, 219]);
|
|
655
|
+
for (const b of bytes)
|
|
656
|
+
out.push(b);
|
|
657
|
+
} else if (Array.isArray(value)) {
|
|
658
|
+
packHeader(out, value.length, 144, 15, [0, 220, 221]);
|
|
659
|
+
for (const item of value)
|
|
660
|
+
packValue(out, item);
|
|
661
|
+
} else if (typeof value === "object") {
|
|
662
|
+
const entries = Object.entries(value).filter(([, v]) => v !== undefined);
|
|
663
|
+
for (const [key] of entries) {
|
|
664
|
+
if (/^(0|[1-9][0-9]*)$/.test(key))
|
|
665
|
+
throw new Error(`msgpack: integer-like key "${key}"`);
|
|
666
|
+
}
|
|
667
|
+
packHeader(out, entries.length, 128, 15, [0, 222, 223]);
|
|
668
|
+
for (const [key, item] of entries) {
|
|
669
|
+
packValue(out, key);
|
|
670
|
+
packValue(out, item);
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
throw new Error(`msgpack: cannot encode a ${typeof value}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
function msgpackEncode(value) {
|
|
677
|
+
const out = [];
|
|
678
|
+
packValue(out, value);
|
|
679
|
+
return Uint8Array.from(out);
|
|
680
|
+
}
|
|
681
|
+
function toHex(bytes) {
|
|
682
|
+
let out = "";
|
|
683
|
+
for (const b of bytes)
|
|
684
|
+
out += b.toString(16).padStart(2, "0");
|
|
685
|
+
return out;
|
|
686
|
+
}
|
|
687
|
+
function hyperliquidActionHash(action, nonce) {
|
|
688
|
+
const n = BigInt(nonce);
|
|
689
|
+
if (n < 0n || n > UINT64_MAX)
|
|
690
|
+
throw new Error("hyperliquidActionHash: nonce out of range");
|
|
691
|
+
const out = Array.from(msgpackEncode(action));
|
|
692
|
+
pushUint(out, n, 8);
|
|
693
|
+
out.push(0);
|
|
694
|
+
return `0x${toHex(keccak_256(Uint8Array.from(out)))}`;
|
|
695
|
+
}
|
|
696
|
+
function hyperliquidL1TypedData(connectionId, network) {
|
|
697
|
+
return {
|
|
698
|
+
domain: { name: "Exchange", version: "1", chainId: 1337, verifyingContract: ZERO_ADDRESS },
|
|
699
|
+
types: {
|
|
700
|
+
Agent: [
|
|
701
|
+
{ name: "source", type: "string" },
|
|
702
|
+
{ name: "connectionId", type: "bytes32" }
|
|
703
|
+
]
|
|
704
|
+
},
|
|
705
|
+
primary_type: "Agent",
|
|
706
|
+
message: { source: network === "mainnet" ? "a" : "b", connectionId }
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function hyperliquidApproveBuilderFeeTypedData(builder, nonce) {
|
|
710
|
+
return {
|
|
711
|
+
domain: { name: "HyperliquidSignTransaction", version: "1", chainId: 42161, verifyingContract: ZERO_ADDRESS },
|
|
712
|
+
types: {
|
|
713
|
+
"HyperliquidTransaction:ApproveBuilderFee": [
|
|
714
|
+
{ name: "hyperliquidChain", type: "string" },
|
|
715
|
+
{ name: "maxFeeRate", type: "string" },
|
|
716
|
+
{ name: "builder", type: "address" },
|
|
717
|
+
{ name: "nonce", type: "uint64" }
|
|
718
|
+
]
|
|
719
|
+
},
|
|
720
|
+
primary_type: "HyperliquidTransaction:ApproveBuilderFee",
|
|
721
|
+
message: {
|
|
722
|
+
hyperliquidChain: "Mainnet",
|
|
723
|
+
maxFeeRate: HYPERLIQUID_MAX_BUILDER_FEE_RATE,
|
|
724
|
+
builder: builder.toLowerCase(),
|
|
725
|
+
nonce
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
function hyperliquidCanonicalJson(value) {
|
|
730
|
+
return canonical(value);
|
|
731
|
+
}
|
|
732
|
+
function canonical(value) {
|
|
733
|
+
if (value === null || typeof value !== "object")
|
|
734
|
+
return JSON.stringify(value);
|
|
735
|
+
if (Array.isArray(value))
|
|
736
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
737
|
+
const record = value;
|
|
738
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`;
|
|
739
|
+
}
|
|
740
|
+
function sameValue(a, b) {
|
|
741
|
+
return canonical(a) === canonical(b);
|
|
742
|
+
}
|
|
743
|
+
var EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
744
|
+
function checkBuilderField(action, builder) {
|
|
745
|
+
if (!("builder" in action) || action.builder === undefined)
|
|
746
|
+
return null;
|
|
747
|
+
const field = action.builder;
|
|
748
|
+
if (typeof field !== "object" || field === null)
|
|
749
|
+
return "builder field is not an object";
|
|
750
|
+
if (typeof field.b !== "string" || field.b.toLowerCase() !== builder.toLowerCase()) {
|
|
751
|
+
return "the order names a builder that is not Candle's";
|
|
752
|
+
}
|
|
753
|
+
if (typeof field.f !== "number" || !Number.isInteger(field.f) || field.f <= 0 || field.f > HYPERLIQUID_MAX_BUILDER_FEE_TENTHS_BPS) {
|
|
754
|
+
return "the builder fee is outside Candle's 0.1% maximum";
|
|
755
|
+
}
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
function decimal(value) {
|
|
759
|
+
if (typeof value !== "string" || !/^-?(0|[1-9][0-9]*)(\.[0-9]{1,18})?$/.test(value))
|
|
760
|
+
return null;
|
|
761
|
+
const negative = value.startsWith("-");
|
|
762
|
+
const [whole = "0", frac = ""] = (negative ? value.slice(1) : value).split(".");
|
|
763
|
+
const n = BigInt(whole) * 10n ** 18n + BigInt(frac.padEnd(18, "0"));
|
|
764
|
+
return negative ? -n : n;
|
|
765
|
+
}
|
|
766
|
+
function checkActionIntent(action, intent) {
|
|
767
|
+
const mainAsset = (a) => typeof a === "number" && Number.isInteger(a) && a >= 0 && a < 1e4;
|
|
768
|
+
const orderList = action.type === "order" ? action.orders : action.type === "modify" ? [action.order] : [];
|
|
769
|
+
if (!Array.isArray(orderList))
|
|
770
|
+
return "invalid orders";
|
|
771
|
+
for (const raw of orderList) {
|
|
772
|
+
if (!raw || typeof raw !== "object")
|
|
773
|
+
return "invalid order";
|
|
774
|
+
const o = raw;
|
|
775
|
+
if (!mainAsset(o.a))
|
|
776
|
+
return "order asset is outside the main perp universe";
|
|
777
|
+
if (typeof o.b !== "boolean" || typeof o.r !== "boolean" || (decimal(o.s) ?? 0n) <= 0n || (decimal(o.p) ?? 0n) <= 0n)
|
|
778
|
+
return "invalid order side, size, price or reduce-only flag";
|
|
779
|
+
}
|
|
780
|
+
if (action.type === "cancel") {
|
|
781
|
+
if (!Array.isArray(action.cancels) || action.cancels.length !== 1 || !mainAsset(action.cancels[0]?.a))
|
|
782
|
+
return "invalid cancel asset";
|
|
783
|
+
}
|
|
784
|
+
if ((action.type === "updateLeverage" || action.type === "updateIsolatedMargin") && !mainAsset(action.asset))
|
|
785
|
+
return "invalid action asset";
|
|
786
|
+
if (!intent)
|
|
787
|
+
return null;
|
|
788
|
+
const { method, params: p } = intent;
|
|
789
|
+
const types = {
|
|
790
|
+
setup: "approveBuilderFee",
|
|
791
|
+
open: "order",
|
|
792
|
+
close: "order",
|
|
793
|
+
cancel: "cancel",
|
|
794
|
+
modify: "modify",
|
|
795
|
+
leverage: "updateLeverage",
|
|
796
|
+
margin: "updateIsolatedMargin"
|
|
797
|
+
};
|
|
798
|
+
if (action.type !== types[method])
|
|
799
|
+
return "action type does not match the requested method";
|
|
800
|
+
if (method === "leverage") {
|
|
801
|
+
if (action.leverage !== p.leverage || action.isCross !== (p.mode !== "isolated"))
|
|
802
|
+
return "leverage or mode does not match the request";
|
|
803
|
+
}
|
|
804
|
+
if (method === "margin") {
|
|
805
|
+
const amount = decimal(p.amount);
|
|
806
|
+
if (amount === null || amount % 10n ** 12n !== 0n || !Number.isSafeInteger(action.ntli) || BigInt(action.ntli) !== amount / 10n ** 12n || action.isBuy !== true)
|
|
807
|
+
return "isolated margin amount does not match the request";
|
|
808
|
+
}
|
|
809
|
+
if (method === "open" || method === "close" || method === "modify") {
|
|
810
|
+
const list = orderList;
|
|
811
|
+
const first = list[0];
|
|
812
|
+
if (!first)
|
|
813
|
+
return "missing requested order";
|
|
814
|
+
const typ = first.t;
|
|
815
|
+
if (!typ?.limit)
|
|
816
|
+
return "requested order is not a limit/IOC order";
|
|
817
|
+
if (method === "open") {
|
|
818
|
+
const buy = p.side === "long" || p.side === "buy";
|
|
819
|
+
if (first.b !== buy || first.r !== false || decimal(first.s) !== decimal(p.size))
|
|
820
|
+
return "order side, size or reduce-only flag does not match the request";
|
|
821
|
+
const tif = (p.type ?? (p.price === undefined ? "market" : "limit")) === "market" ? "Ioc" : p.tif ?? "Gtc";
|
|
822
|
+
if (typ.limit.tif !== tif)
|
|
823
|
+
return "order time in force does not match the request";
|
|
824
|
+
const triggers = [
|
|
825
|
+
["takeProfit", "tp"],
|
|
826
|
+
["stopLoss", "sl"]
|
|
827
|
+
].filter(([field]) => p[field] != null);
|
|
828
|
+
if (list.length !== 1 + triggers.length || action.grouping !== (triggers.length ? "normalTpsl" : "na"))
|
|
829
|
+
return "unexpected extra orders or grouping";
|
|
830
|
+
for (let i = 0;i < triggers.length; i++) {
|
|
831
|
+
const [field, kind] = triggers[i];
|
|
832
|
+
const leg = list[i + 1];
|
|
833
|
+
if (!leg)
|
|
834
|
+
return "missing trigger order";
|
|
835
|
+
const trigger = leg.t?.trigger;
|
|
836
|
+
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]))
|
|
837
|
+
return "trigger side, size, reduce-only flag or price does not match the request";
|
|
838
|
+
}
|
|
839
|
+
} else {
|
|
840
|
+
if (list.length !== 1)
|
|
841
|
+
return "unexpected extra orders";
|
|
842
|
+
if (method === "close" && (first.r !== true || typ.limit.tif !== "Ioc" || p.size != null && (decimal(first.s) ?? 0n) > (decimal(p.size) ?? 0n)))
|
|
843
|
+
return "close size or reduce-only flag does not match the request";
|
|
844
|
+
if (method === "modify" && p.size != null && decimal(first.s) !== decimal(p.size))
|
|
845
|
+
return "replacement size does not match the request";
|
|
846
|
+
}
|
|
847
|
+
if (p.price != null && decimal(first.p) !== decimal(p.price))
|
|
848
|
+
return "order price does not match the request";
|
|
849
|
+
}
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
async function hyperliquidCloseOrder(fetcher, network, address, params) {
|
|
853
|
+
const read = async (body) => {
|
|
854
|
+
const res = await fetcher(HYPERLIQUID_EXCHANGE_URLS[network].replace("/exchange", "/info"), {
|
|
855
|
+
method: "POST",
|
|
856
|
+
headers: { "content-type": "application/json" },
|
|
857
|
+
body: JSON.stringify(body)
|
|
858
|
+
});
|
|
859
|
+
if (!res.ok)
|
|
860
|
+
throw new Error("Cannot read the close position from Hyperliquid");
|
|
861
|
+
return JSON.parse(await res.text());
|
|
862
|
+
};
|
|
863
|
+
const [meta, state] = await Promise.all([read({ type: "meta" }), read({ type: "clearinghouseState", user: address })]);
|
|
864
|
+
const coin = String(params.coin).toUpperCase();
|
|
865
|
+
const asset = meta.universe.findIndex((m) => m.name.toUpperCase() === coin);
|
|
866
|
+
const szi = state.assetPositions.find((p) => p.position.coin.toUpperCase() === coin)?.position.szi;
|
|
867
|
+
const signed = decimal(szi);
|
|
868
|
+
if (asset < 0 || asset >= 1e4 || signed === null || signed === 0n)
|
|
869
|
+
throw new Error("No main-universe position to close");
|
|
870
|
+
const held = signed < 0n ? -signed : signed;
|
|
871
|
+
const asked = params.size == null ? held : decimal(params.size);
|
|
872
|
+
if (asked === null || asked <= 0n)
|
|
873
|
+
throw new Error("Invalid close size");
|
|
874
|
+
const size = asked < held ? String(params.size) : String(szi).replace(/^-/, "");
|
|
875
|
+
return { asset, isBuy: signed < 0n, size };
|
|
876
|
+
}
|
|
877
|
+
function verifyPerpsBuild(build, opts) {
|
|
878
|
+
try {
|
|
879
|
+
const network = opts.network ?? "mainnet";
|
|
880
|
+
if (!EVM_ADDRESS_RE.test(opts.builder))
|
|
881
|
+
return { ok: false, reason: "no valid Candle builder address to check against" };
|
|
882
|
+
if (build.network !== undefined && build.network !== network) {
|
|
883
|
+
return { ok: false, reason: `the build is for ${build.network}, this client trades ${network}` };
|
|
884
|
+
}
|
|
885
|
+
if (opts.address && build.address && build.address.toLowerCase() !== opts.address.toLowerCase()) {
|
|
886
|
+
return { ok: false, reason: "the build is for a different wallet" };
|
|
887
|
+
}
|
|
888
|
+
if (!Number.isSafeInteger(build.nonce) || build.nonce <= 0)
|
|
889
|
+
return { ok: false, reason: "invalid nonce" };
|
|
890
|
+
const typed = build.typedData;
|
|
891
|
+
if (!typed || typeof typed !== "object")
|
|
892
|
+
return { ok: false, reason: "no typed data" };
|
|
893
|
+
const action = build.action;
|
|
894
|
+
if (!action || typeof action !== "object")
|
|
895
|
+
return { ok: false, reason: "no action" };
|
|
896
|
+
const intentRefusal = checkActionIntent(action, opts.intent);
|
|
897
|
+
if (intentRefusal)
|
|
898
|
+
return { ok: false, reason: intentRefusal };
|
|
899
|
+
if (opts.closeOrder) {
|
|
900
|
+
const order = action.orders[0];
|
|
901
|
+
if (!order || order.a !== opts.closeOrder.asset || order.b !== opts.closeOrder.isBuy || decimal(order.s) !== decimal(opts.closeOrder.size) || order.r !== true)
|
|
902
|
+
return { ok: false, reason: "close side, asset or size does not match the requested position" };
|
|
903
|
+
}
|
|
904
|
+
if (typed.primary_type === "Agent") {
|
|
905
|
+
const type = action.type;
|
|
906
|
+
if (typeof type !== "string" || !HYPERLIQUID_ALLOWED_ACTION_TYPES.includes(type)) {
|
|
907
|
+
return { ok: false, reason: `action type ${String(type)} is not one Candle builds` };
|
|
908
|
+
}
|
|
909
|
+
if (type === "order") {
|
|
910
|
+
const refusal = checkBuilderField(action, opts.builder);
|
|
911
|
+
if (refusal)
|
|
912
|
+
return { ok: false, reason: refusal };
|
|
913
|
+
} else if ("builder" in action) {
|
|
914
|
+
return { ok: false, reason: `a ${type} action carries no builder` };
|
|
915
|
+
}
|
|
916
|
+
if ("vaultAddress" in action)
|
|
917
|
+
return { ok: false, reason: "the action names a vault" };
|
|
918
|
+
const expected = hyperliquidL1TypedData(hyperliquidActionHash(action, build.nonce), network);
|
|
919
|
+
if (!sameValue(typed, expected)) {
|
|
920
|
+
return { ok: false, reason: "the typed data does not commit to this action and nonce" };
|
|
921
|
+
}
|
|
922
|
+
return { ok: true, kind: "l1" };
|
|
923
|
+
}
|
|
924
|
+
if (typed.primary_type === "HyperliquidTransaction:ApproveBuilderFee") {
|
|
925
|
+
const expected = hyperliquidApproveBuilderFeeTypedData(opts.builder, build.nonce);
|
|
926
|
+
if (!sameValue(typed, expected)) {
|
|
927
|
+
return { ok: false, reason: "the builder approval is not Candle's builder at 0.1% on Mainnet" };
|
|
928
|
+
}
|
|
929
|
+
const expectedAction = {
|
|
930
|
+
type: "approveBuilderFee",
|
|
931
|
+
hyperliquidChain: "Mainnet",
|
|
932
|
+
signatureChainId: HYPERLIQUID_SIGNATURE_CHAIN_ID,
|
|
933
|
+
maxFeeRate: HYPERLIQUID_MAX_BUILDER_FEE_RATE,
|
|
934
|
+
builder: opts.builder.toLowerCase(),
|
|
935
|
+
nonce: build.nonce
|
|
936
|
+
};
|
|
937
|
+
if (!sameValue(action, expectedAction)) {
|
|
938
|
+
return { ok: false, reason: "the approval action does not match its typed data" };
|
|
939
|
+
}
|
|
940
|
+
return { ok: true, kind: "approveBuilderFee" };
|
|
941
|
+
}
|
|
942
|
+
return { ok: false, reason: `primary type ${String(typed.primary_type)} is not one the relay signs` };
|
|
943
|
+
} catch (err) {
|
|
944
|
+
return { ok: false, reason: err instanceof Error ? err.message : "unreadable build" };
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
function hyperliquidSplitSignature(signature) {
|
|
948
|
+
if (!/^0x[0-9a-fA-F]{130}$/.test(signature))
|
|
949
|
+
throw new Error("Not a 65-byte signature");
|
|
950
|
+
let v = Number.parseInt(signature.slice(130, 132), 16);
|
|
951
|
+
if (v < 27)
|
|
952
|
+
v += 27;
|
|
953
|
+
return { r: `0x${signature.slice(2, 66)}`, s: `0x${signature.slice(66, 130)}`, v };
|
|
954
|
+
}
|
|
955
|
+
function hyperliquidExchangeBody(action, nonce, signature) {
|
|
956
|
+
return { action, nonce, signature: hyperliquidSplitSignature(signature), vaultAddress: null, expiresAfter: null };
|
|
957
|
+
}
|
|
958
|
+
function hyperliquidRelayBody(typedData) {
|
|
959
|
+
return { method: "eth_signTypedData_v4", params: { typed_data: typedData } };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/perps.ts
|
|
963
|
+
var decimal2 = z.string().regex(/^(0|[1-9][0-9]*)(\.[0-9]+)?$/, "a plain decimal string");
|
|
964
|
+
var wallet = z.string().optional().describe("The EVM TEE wallet by id, address or label. Omit when the key has exactly one.");
|
|
965
|
+
var submit = z.boolean().optional().describe("false: build and check only, sign and submit nothing. Default true.");
|
|
966
|
+
var perpsShapes = {
|
|
967
|
+
candle_perps_setup: { wallet, submit },
|
|
968
|
+
candle_perps_open: {
|
|
969
|
+
coin: z.string().describe("A market on Hyperliquid's main perp exchange, e.g. BTC"),
|
|
970
|
+
side: z.enum(["long", "short"]),
|
|
971
|
+
size: decimal2.describe('Size in the coin, decimal (e.g. "0.01")'),
|
|
972
|
+
price: decimal2.optional().describe("Limit price. Omit for a market order (IOC within slippageBps of the mid)."),
|
|
973
|
+
tif: z.enum(["Gtc", "Alo", "Ioc"]).optional().describe("With price: time in force (default Gtc)"),
|
|
974
|
+
slippageBps: z.number().int().min(1).optional(),
|
|
975
|
+
takeProfit: decimal2.optional().describe("Reduce-only take-profit trigger price"),
|
|
976
|
+
stopLoss: decimal2.optional().describe("Reduce-only stop-loss trigger price"),
|
|
977
|
+
wallet,
|
|
978
|
+
submit
|
|
979
|
+
},
|
|
980
|
+
candle_perps_close: {
|
|
981
|
+
coin: z.string(),
|
|
982
|
+
size: decimal2.optional().describe("Part of the position to close; the whole position when omitted"),
|
|
983
|
+
slippageBps: z.number().int().min(1).optional(),
|
|
984
|
+
wallet,
|
|
985
|
+
submit
|
|
986
|
+
},
|
|
987
|
+
candle_perps_cancel: {
|
|
988
|
+
cloid: z.string().regex(/^0x[0-9a-fA-F]{32}$/).describe("The cloid of an order Candle built (an open, or one of its take-profit / stop-loss legs)"),
|
|
989
|
+
wallet,
|
|
990
|
+
submit
|
|
991
|
+
},
|
|
992
|
+
candle_perps_orders: { wallet, limit: z.number().int().min(1).max(200).optional() },
|
|
993
|
+
candle_perps_positions: { wallet },
|
|
994
|
+
candle_perps_leverage: {
|
|
995
|
+
coin: z.string(),
|
|
996
|
+
leverage: z.number().int().min(1),
|
|
997
|
+
mode: z.enum(["cross", "isolated"]).optional(),
|
|
998
|
+
wallet,
|
|
999
|
+
submit
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
var PATHS = {
|
|
1003
|
+
candle_perps_setup: "/api/v1/agent/perps/setup",
|
|
1004
|
+
candle_perps_open: "/api/v1/agent/perps/open",
|
|
1005
|
+
candle_perps_close: "/api/v1/agent/perps/close",
|
|
1006
|
+
candle_perps_cancel: "/api/v1/agent/perps/cancel",
|
|
1007
|
+
candle_perps_orders: "/api/v1/agent/perps/orders",
|
|
1008
|
+
candle_perps_positions: "/api/v1/agent/perps/positions",
|
|
1009
|
+
candle_perps_leverage: "/api/v1/agent/perps/leverage"
|
|
1010
|
+
};
|
|
1011
|
+
function fail(code, message, extra = {}) {
|
|
1012
|
+
return { text: JSON.stringify({ success: false, error: { code, message }, ...extra }), isError: true };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
class Refusal extends Error {
|
|
1016
|
+
tool;
|
|
1017
|
+
constructor(tool) {
|
|
1018
|
+
super("refused");
|
|
1019
|
+
this.tool = tool;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
async function call(cfg, fetch2, method, path, body) {
|
|
1023
|
+
const res = await fetch2(`${cfg.apiUrl.replace(/\/$/, "")}${path}`, {
|
|
1024
|
+
method,
|
|
1025
|
+
headers: { "Content-Type": "application/json", ...cfg.apiKey ? { "x-api-key": cfg.apiKey } : {} },
|
|
1026
|
+
...body !== undefined ? { body: JSON.stringify(body) } : {}
|
|
1027
|
+
});
|
|
1028
|
+
const text = await res.text();
|
|
1029
|
+
let parsed;
|
|
1030
|
+
try {
|
|
1031
|
+
parsed = JSON.parse(text);
|
|
1032
|
+
} catch {
|
|
1033
|
+
parsed = text;
|
|
1034
|
+
}
|
|
1035
|
+
if (!res.ok) {
|
|
1036
|
+
if (res.status === 404 && (typeof parsed !== "object" || parsed === null))
|
|
1037
|
+
throw new Refusal(fail("PERPS_NOT_ENABLED", "This Candle deployment does not serve perps routes."));
|
|
1038
|
+
throw new Refusal({ text: typeof parsed === "string" ? parsed : JSON.stringify(parsed), isError: true });
|
|
1039
|
+
}
|
|
1040
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
1041
|
+
throw new Refusal(fail("INVALID_RESPONSE", "Not a JSON object"));
|
|
1042
|
+
return parsed;
|
|
1043
|
+
}
|
|
1044
|
+
async function walletFor(cfg, fetch2, name, scope) {
|
|
1045
|
+
const listed = await call(cfg, fetch2, "GET", "/api/v1/agent/wallets/trading");
|
|
1046
|
+
if (scope && !(Array.isArray(listed.scopes) && listed.scopes.includes(scope)))
|
|
1047
|
+
throw new Refusal(fail("SCOPE_MISSING", `The key needs ${scope}.`));
|
|
1048
|
+
const rows = Array.isArray(listed.page) ? listed.page : [];
|
|
1049
|
+
const evm = rows.filter((row) => row.chain === "evm");
|
|
1050
|
+
const matches = name === undefined ? evm : evm.filter((row) => row.id === name || row.label === name || row.address.toLowerCase() === name.toLowerCase());
|
|
1051
|
+
if (matches.length !== 1)
|
|
1052
|
+
throw new Refusal(fail("TEE_WALLET_REQUIRED", evm.length === 0 ? "This key has no EVM TEE wallet bound to it." : `Name exactly one EVM TEE wallet: ${evm.map((row) => row.label ?? row.id).join(", ")}`));
|
|
1053
|
+
return { row: matches[0], appId: typeof listed.privyAppId === "string" ? listed.privyAppId : "" };
|
|
1054
|
+
}
|
|
1055
|
+
async function builderPin(cfg, fetch2, env) {
|
|
1056
|
+
const pinned = env.CANDLE_HYPERLIQUID_BUILDER?.trim() || CANDLE_HYPERLIQUID_BUILDER_ADDRESS;
|
|
1057
|
+
if (pinned)
|
|
1058
|
+
return pinned.toLowerCase();
|
|
1059
|
+
const config = await call(cfg, fetch2, "GET", "/api/v1/agent/perps/config");
|
|
1060
|
+
if (typeof config.builder !== "string")
|
|
1061
|
+
throw new Refusal(fail("PERPS_NOT_CONFIGURED", "The server reports no Hyperliquid builder address."));
|
|
1062
|
+
return config.builder.toLowerCase();
|
|
1063
|
+
}
|
|
1064
|
+
async function executePerps(tool, args, cfg, env, fetch2) {
|
|
1065
|
+
if (!cfg.apiKey)
|
|
1066
|
+
return fail("MCP_VALIDATION", "CANDLE_AGENT_API_KEY is required for this tool.");
|
|
1067
|
+
const network = env.CANDLE_HYPERLIQUID_NETWORK === "testnet" ? "testnet" : "mainnet";
|
|
1068
|
+
const name = typeof args.wallet === "string" ? args.wallet : undefined;
|
|
1069
|
+
try {
|
|
1070
|
+
if (tool === "candle_perps_orders" || tool === "candle_perps_positions") {
|
|
1071
|
+
const { row: row2 } = await walletFor(cfg, fetch2, name);
|
|
1072
|
+
const query = new URLSearchParams({ walletId: row2.id });
|
|
1073
|
+
if (typeof args.limit === "number")
|
|
1074
|
+
query.set("limit", String(args.limit));
|
|
1075
|
+
return { text: JSON.stringify(await call(cfg, fetch2, "GET", `${PATHS[tool]}?${query}`)) };
|
|
1076
|
+
}
|
|
1077
|
+
const willSubmit = args.submit !== false;
|
|
1078
|
+
const pemFile = env.CANDLE_KEY_SIGNER_PEM_FILE?.trim();
|
|
1079
|
+
let signerPem = null;
|
|
1080
|
+
if (willSubmit) {
|
|
1081
|
+
if (!pemFile)
|
|
1082
|
+
return fail("SIGNER_UNAVAILABLE", "Set CANDLE_KEY_SIGNER_PEM_FILE to the key signer PEM (candle tee signer new --out <pem>) to sign perps actions.");
|
|
1083
|
+
try {
|
|
1084
|
+
signerPem = await readFile(pemFile, "utf8");
|
|
1085
|
+
createPrivateKey(signerPem);
|
|
1086
|
+
} catch {
|
|
1087
|
+
return fail("SIGNER_UNAVAILABLE", "CANDLE_KEY_SIGNER_PEM_FILE does not hold a readable private key PEM.");
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const { row, appId } = await walletFor(cfg, fetch2, name, "perps:write");
|
|
1091
|
+
const builder = await builderPin(cfg, fetch2, env);
|
|
1092
|
+
const { wallet: _w, submit: _s, ...fields } = args;
|
|
1093
|
+
const build = await call(cfg, fetch2, "POST", PATHS[tool], {
|
|
1094
|
+
walletId: row.id,
|
|
1095
|
+
...fields,
|
|
1096
|
+
...tool === "candle_perps_open" ? { type: fields.price === undefined ? "market" : "limit" } : {}
|
|
1097
|
+
});
|
|
1098
|
+
if (build.ready === true)
|
|
1099
|
+
return { text: JSON.stringify(build) };
|
|
1100
|
+
const method = PATHS[tool].split("/").at(-1);
|
|
1101
|
+
const check = verifyPerpsBuild(build, {
|
|
1102
|
+
builder,
|
|
1103
|
+
network,
|
|
1104
|
+
address: row.address,
|
|
1105
|
+
intent: { method, params: fields }
|
|
1106
|
+
});
|
|
1107
|
+
if (!check.ok)
|
|
1108
|
+
return fail("PERPS_BUILD_REFUSED", `Refused to sign this build: ${check.reason}. Nothing was signed.`);
|
|
1109
|
+
if (method === "close") {
|
|
1110
|
+
const closeOrder = await hyperliquidCloseOrder(fetch2, network, row.address, fields);
|
|
1111
|
+
const closeCheck = verifyPerpsBuild(build, {
|
|
1112
|
+
builder,
|
|
1113
|
+
network,
|
|
1114
|
+
address: row.address,
|
|
1115
|
+
intent: { method, params: fields },
|
|
1116
|
+
closeOrder
|
|
1117
|
+
});
|
|
1118
|
+
if (!closeCheck.ok)
|
|
1119
|
+
return fail("PERPS_BUILD_REFUSED", `Refused to sign this build: ${closeCheck.reason}. Nothing was signed.`);
|
|
1120
|
+
}
|
|
1121
|
+
if (!willSubmit || signerPem === null)
|
|
1122
|
+
return { text: JSON.stringify({ ...build, signed: false, submitted: false }) };
|
|
1123
|
+
if (!appId || !row.privyWalletId)
|
|
1124
|
+
return fail("SIGNER_UNAVAILABLE", "The server did not return its relay identifiers.");
|
|
1125
|
+
const body = hyperliquidRelayBody(build.typedData);
|
|
1126
|
+
const payload = hyperliquidCanonicalJson({
|
|
1127
|
+
body,
|
|
1128
|
+
headers: { "privy-app-id": appId },
|
|
1129
|
+
method: "POST",
|
|
1130
|
+
url: `https://api.privy.io/v1/wallets/${row.privyWalletId}/rpc`,
|
|
1131
|
+
version: 1
|
|
1132
|
+
});
|
|
1133
|
+
const authorizationSignature = sign("sha256", Buffer.from(payload), signerPem).toString("base64");
|
|
1134
|
+
const relay = await call(cfg, fetch2, "POST", `/api/v1/agent/wallets/${encodeURIComponent(row.id)}/sign`, {
|
|
1135
|
+
authorizationSignature,
|
|
1136
|
+
body
|
|
1137
|
+
});
|
|
1138
|
+
const signature = relay.signature;
|
|
1139
|
+
if (typeof signature !== "string" || !/^0x[0-9a-fA-F]{130}$/.test(signature))
|
|
1140
|
+
return fail("INVALID_RESPONSE", "The relay did not return a signature.");
|
|
1141
|
+
const exchangeBody = hyperliquidExchangeBody(build.action, build.nonce, signature);
|
|
1142
|
+
let exchange;
|
|
1143
|
+
try {
|
|
1144
|
+
const res = await fetch2(HYPERLIQUID_EXCHANGE_URLS[network], {
|
|
1145
|
+
method: "POST",
|
|
1146
|
+
headers: { "Content-Type": "application/json" },
|
|
1147
|
+
body: JSON.stringify(exchangeBody)
|
|
1148
|
+
});
|
|
1149
|
+
const text = await res.text();
|
|
1150
|
+
if (!res.ok)
|
|
1151
|
+
throw new Error(`HTTP ${res.status}`);
|
|
1152
|
+
exchange = JSON.parse(text);
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
return fail("PERPS_SUBMIT_FAILED", `Signed, but the submit to Hyperliquid failed (${error instanceof Error ? error.message : "unknown"}). POST exchangeBody to Hyperliquid's /exchange to resubmit.`, { perpOrderId: build.perpOrderId, exchangeBody });
|
|
1155
|
+
}
|
|
1156
|
+
const accepted = typeof exchange === "object" && exchange !== null && exchange.status === "ok" && !(exchange.response?.data?.statuses ?? []).some((status) => typeof status === "object" && status !== null && ("error" in status));
|
|
1157
|
+
return {
|
|
1158
|
+
text: JSON.stringify({
|
|
1159
|
+
success: accepted,
|
|
1160
|
+
perpOrderId: build.perpOrderId,
|
|
1161
|
+
kind: build.kind,
|
|
1162
|
+
network: build.network,
|
|
1163
|
+
address: row.address,
|
|
1164
|
+
...build.cloid ? { cloid: build.cloid } : {},
|
|
1165
|
+
...build.childCloids ? { childCloids: build.childCloids } : {},
|
|
1166
|
+
nonce: build.nonce,
|
|
1167
|
+
signature,
|
|
1168
|
+
exchange
|
|
1169
|
+
}),
|
|
1170
|
+
...accepted ? {} : { isError: true }
|
|
1171
|
+
};
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
if (error instanceof Refusal)
|
|
1174
|
+
return error.tool;
|
|
1175
|
+
return fail("MCP_TRANSPORT", error instanceof Error ? error.message : "The perps call failed.");
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
var HYPERLIQUID_RELAY_CHAIN_ID = 1337;
|
|
1179
|
+
var HYPERLIQUID_RELAY_USDC = "0x00000000000000000000000000000000";
|
|
1180
|
+
var DEPOSIT_ASSETS = {
|
|
1181
|
+
SOL: { chain: "solana", decimals: 9 },
|
|
1182
|
+
USDC: { chain: "solana", decimals: 6 },
|
|
1183
|
+
ETH: { chain: "evm", decimals: 18 },
|
|
1184
|
+
USDG: { chain: "evm", decimals: 6 }
|
|
1185
|
+
};
|
|
1186
|
+
var perpsDepositShape = {
|
|
1187
|
+
asset: z.enum(["SOL", "USDC", "ETH", "USDG"]).describe("What to deposit. SOL or USDC pay from the key's Solana TEE wallet; ETH or USDG from its Hood one."),
|
|
1188
|
+
amount: decimal2.describe('Amount of the asset, decimal (e.g. "25")'),
|
|
1189
|
+
wallet: z.string().optional().describe("The paying TEE wallet by id, address or label. Omit when the key has exactly one on that chain."),
|
|
1190
|
+
perpsWallet: z.string().optional().describe("From Solana only: the EVM TEE wallet whose Hyperliquid account receives it. Omit when the key has one."),
|
|
1191
|
+
clientDepositId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/).optional().describe("Idempotency id; the same id answers the deposit it already built. Default: a new one."),
|
|
1192
|
+
maxSlippageBps: z.number().int().min(1).max(1000).optional().describe("Relay slippage (default 100)"),
|
|
1193
|
+
submit
|
|
1194
|
+
};
|
|
1195
|
+
function rawUnits(amount, decimals) {
|
|
1196
|
+
const [whole = "", fraction = ""] = amount.split(".");
|
|
1197
|
+
if (fraction.length > decimals)
|
|
1198
|
+
return null;
|
|
1199
|
+
const raw = BigInt(whole + fraction.padEnd(decimals, "0"));
|
|
1200
|
+
return raw > 0n ? raw.toString() : null;
|
|
1201
|
+
}
|
|
1202
|
+
function pickWallet(rows, chain, name, what) {
|
|
1203
|
+
const onChain = rows.filter((row) => row.chain === chain);
|
|
1204
|
+
const matches = name === undefined ? onChain : onChain.filter((row) => row.id === name || row.label === name || row.address.toLowerCase() === name.toLowerCase());
|
|
1205
|
+
if (matches.length !== 1)
|
|
1206
|
+
throw new Refusal(fail("TEE_WALLET_REQUIRED", onChain.length === 0 ? `This key has no ${what} bound to it.` : `Name exactly one ${what}: ${onChain.map((row) => row.label ?? row.id).join(", ")}`));
|
|
1207
|
+
return matches[0];
|
|
1208
|
+
}
|
|
1209
|
+
function depositProblem(build, expect) {
|
|
1210
|
+
const destination = build.destination ?? {};
|
|
1211
|
+
const fee = build.fee ?? {};
|
|
1212
|
+
const same = (a, b) => typeof a === "string" && a.toLowerCase() === b.toLowerCase();
|
|
1213
|
+
if (build.walletId !== expect.walletId)
|
|
1214
|
+
return "the build names another paying wallet";
|
|
1215
|
+
if (build.asset !== expect.asset || build.amountRaw !== expect.amountRaw)
|
|
1216
|
+
return "the build is for another amount";
|
|
1217
|
+
if (destination.chainId !== HYPERLIQUID_RELAY_CHAIN_ID)
|
|
1218
|
+
return "the build does not land on Hyperliquid";
|
|
1219
|
+
if (destination.currency !== HYPERLIQUID_RELAY_USDC)
|
|
1220
|
+
return "the build does not deliver Hyperliquid USDC";
|
|
1221
|
+
if (!same(destination.address, expect.account))
|
|
1222
|
+
return "the build lands on another account";
|
|
1223
|
+
if (fee.bps !== 0 || fee.feeRaw !== "0")
|
|
1224
|
+
return "the build carries a Candle fee";
|
|
1225
|
+
if (expect.hood !== (build.chain === "hood"))
|
|
1226
|
+
return "the build pays from the wrong chain";
|
|
1227
|
+
if (!expect.hood && !(Array.isArray(build.transactionsBase64) && build.transactionsBase64.length === 1))
|
|
1228
|
+
return "the build is not one Solana deposit";
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
async function executePerpsDeposit(args, cfg, env, fetch2) {
|
|
1232
|
+
if (!cfg.apiKey)
|
|
1233
|
+
return fail("MCP_VALIDATION", "CANDLE_AGENT_API_KEY is required for this tool.");
|
|
1234
|
+
const asset = args.asset;
|
|
1235
|
+
const spec = DEPOSIT_ASSETS[asset];
|
|
1236
|
+
if (!spec)
|
|
1237
|
+
return fail("MCP_VALIDATION", "asset must be SOL, USDC, ETH or USDG.");
|
|
1238
|
+
const amountRaw = typeof args.amount === "string" ? rawUnits(args.amount, spec.decimals) : null;
|
|
1239
|
+
if (!amountRaw)
|
|
1240
|
+
return fail("MCP_VALIDATION", `amount must be a positive ${asset} amount with at most ${spec.decimals} decimals.`);
|
|
1241
|
+
const hood = spec.chain === "evm";
|
|
1242
|
+
const clientDepositId = typeof args.clientDepositId === "string" ? args.clientDepositId : `deposit-${crypto.randomUUID()}`;
|
|
1243
|
+
const willSubmit = args.submit !== false;
|
|
1244
|
+
let signerPem = null;
|
|
1245
|
+
if (willSubmit) {
|
|
1246
|
+
const pemFile = env.CANDLE_KEY_SIGNER_PEM_FILE?.trim();
|
|
1247
|
+
if (!pemFile)
|
|
1248
|
+
return fail("SIGNER_UNAVAILABLE", "Set CANDLE_KEY_SIGNER_PEM_FILE to the key signer PEM (candle tee signer new --out <pem>) to sign a deposit.");
|
|
1249
|
+
try {
|
|
1250
|
+
signerPem = await readFile(pemFile, "utf8");
|
|
1251
|
+
createPrivateKey(signerPem);
|
|
1252
|
+
} catch {
|
|
1253
|
+
return fail("SIGNER_UNAVAILABLE", "CANDLE_KEY_SIGNER_PEM_FILE does not hold a readable private key PEM.");
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
try {
|
|
1257
|
+
const listed = await call(cfg, fetch2, "GET", "/api/v1/agent/wallets/trading");
|
|
1258
|
+
if (!(Array.isArray(listed.scopes) && listed.scopes.includes("swap:write")))
|
|
1259
|
+
throw new Refusal(fail("SCOPE_MISSING", "A deposit needs swap:write on the key."));
|
|
1260
|
+
const rows = Array.isArray(listed.page) ? listed.page : [];
|
|
1261
|
+
const appId = typeof listed.privyAppId === "string" ? listed.privyAppId : "";
|
|
1262
|
+
const name = typeof args.wallet === "string" ? args.wallet : undefined;
|
|
1263
|
+
const source = pickWallet(rows, spec.chain, name, `${hood ? "Hood" : "Solana"} TEE wallet`);
|
|
1264
|
+
if (hood && args.perpsWallet !== undefined)
|
|
1265
|
+
return fail("MCP_VALIDATION", "From a Hood TEE wallet the deposit lands on that wallet's own account; omit perpsWallet.");
|
|
1266
|
+
const account = hood ? source : pickWallet(rows, "evm", typeof args.perpsWallet === "string" ? args.perpsWallet : undefined, "EVM TEE wallet");
|
|
1267
|
+
const build = await call(cfg, fetch2, "POST", "/api/v1/agent/perps/deposit", {
|
|
1268
|
+
clientDepositId,
|
|
1269
|
+
walletId: source.id,
|
|
1270
|
+
asset,
|
|
1271
|
+
amountRaw,
|
|
1272
|
+
...typeof args.maxSlippageBps === "number" ? { maxSlippageBps: args.maxSlippageBps } : {},
|
|
1273
|
+
...hood ? {} : { perpsWalletId: account.id }
|
|
1274
|
+
});
|
|
1275
|
+
if (build.job !== undefined)
|
|
1276
|
+
return { text: JSON.stringify(build) };
|
|
1277
|
+
const problem = depositProblem(build, { walletId: source.id, asset, amountRaw, account: account.address, hood });
|
|
1278
|
+
if (problem)
|
|
1279
|
+
return fail("PERPS_BUILD_REFUSED", `Refused to sign this deposit: ${problem}. Nothing was signed.`);
|
|
1280
|
+
if (!willSubmit || signerPem === null)
|
|
1281
|
+
return { text: JSON.stringify({ ...build, signed: false, submitted: false }) };
|
|
1282
|
+
if (!appId || !source.privyWalletId)
|
|
1283
|
+
return fail("SIGNER_UNAVAILABLE", "The server did not return its relay identifiers.");
|
|
1284
|
+
const pem = signerPem;
|
|
1285
|
+
const relaySign = async (body2) => {
|
|
1286
|
+
const payload = hyperliquidCanonicalJson({
|
|
1287
|
+
body: body2,
|
|
1288
|
+
headers: { "privy-app-id": appId },
|
|
1289
|
+
method: "POST",
|
|
1290
|
+
url: `https://api.privy.io/v1/wallets/${source.privyWalletId}/rpc`,
|
|
1291
|
+
version: 1
|
|
1292
|
+
});
|
|
1293
|
+
const authorizationSignature = sign("sha256", Buffer.from(payload), pem).toString("base64");
|
|
1294
|
+
const relay = await call(cfg, fetch2, "POST", `/api/v1/agent/wallets/${encodeURIComponent(source.id)}/sign`, {
|
|
1295
|
+
authorizationSignature,
|
|
1296
|
+
body: body2
|
|
1297
|
+
});
|
|
1298
|
+
if (typeof relay.signedTransaction !== "string")
|
|
1299
|
+
throw new Refusal(fail("INVALID_RESPONSE", "The relay did not return a signed transaction."));
|
|
1300
|
+
return relay.signedTransaction;
|
|
1301
|
+
};
|
|
1302
|
+
const ids = { clientDepositId, depositId: build.depositId };
|
|
1303
|
+
const submitPath = "/api/v1/agent/perps/deposit/submit";
|
|
1304
|
+
if (!hood) {
|
|
1305
|
+
const transaction = build.transactionsBase64[0];
|
|
1306
|
+
const signed = await relaySign({ method: "signTransaction", params: { encoding: "base64", transaction } });
|
|
1307
|
+
return {
|
|
1308
|
+
text: JSON.stringify(await call(cfg, fetch2, "POST", submitPath, { ...ids, signedTransactionsBase64: [signed] }))
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
const allowed = asset === "USDG" ? ["approval", "bridgeDeposit"] : ["bridgeDeposit"];
|
|
1312
|
+
const planned = build.plannedLegCount;
|
|
1313
|
+
const hex = (value) => `0x${BigInt(String(value)).toString(16)}`;
|
|
1314
|
+
const signedKinds = new Set;
|
|
1315
|
+
let body = build;
|
|
1316
|
+
while (body.mode === "sequenced") {
|
|
1317
|
+
const leg = body.nextLeg;
|
|
1318
|
+
const kind = String(body.legKind);
|
|
1319
|
+
if (!leg || body.operationId !== build.operationId || body.plannedLegCount !== planned || typeof planned !== "number" || planned > allowed.length || !allowed.includes(kind) || signedKinds.has(kind) || leg.chainId !== 4663)
|
|
1320
|
+
return fail("PERPS_BUILD_REFUSED", `Refused to sign deposit leg ${kind}: it is not the plan this deposit was built with.`, { clientDepositId, operationId: build.operationId });
|
|
1321
|
+
signedKinds.add(kind);
|
|
1322
|
+
const signed = await relaySign({
|
|
1323
|
+
method: "eth_signTransaction",
|
|
1324
|
+
params: {
|
|
1325
|
+
transaction: {
|
|
1326
|
+
chain_id: leg.chainId,
|
|
1327
|
+
data: leg.data,
|
|
1328
|
+
from: source.address,
|
|
1329
|
+
gas_limit: hex(leg.gas),
|
|
1330
|
+
max_fee_per_gas: hex(leg.maxFeePerGas),
|
|
1331
|
+
max_priority_fee_per_gas: hex(leg.maxPriorityFeePerGas),
|
|
1332
|
+
nonce: leg.nonce,
|
|
1333
|
+
to: leg.to,
|
|
1334
|
+
type: 2,
|
|
1335
|
+
value: hex(leg.value)
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
body = await call(cfg, fetch2, "POST", submitPath, {
|
|
1340
|
+
...ids,
|
|
1341
|
+
operationId: build.operationId,
|
|
1342
|
+
signedTransaction: signed
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
return { text: JSON.stringify({ ...body, clientDepositId }) };
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
if (error instanceof Refusal)
|
|
1348
|
+
return error.tool;
|
|
1349
|
+
return fail("MCP_TRANSPORT", error instanceof Error ? error.message : "The deposit call failed.");
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// src/plans.ts
|
|
1354
|
+
var PLAN_CAPABILITY_NOTE = "A capability marked yes is what the plan allows. It is subject to the deployment's own switches (perps and own-wallet bridges each have one) and to the wallet, scopes and setup the feature needs.";
|
|
1355
|
+
var PLAN_CAPABILITY_LABELS = {
|
|
1356
|
+
tradeCandleTokens: "Trade Candle-launched tokens",
|
|
1357
|
+
tradeBaseAssets: "Trade base assets",
|
|
1358
|
+
freeBaseTransfers: "Base-pair swaps and own-wallet bridges (when enabled), no Candle fee",
|
|
1359
|
+
sellExternalTokens: "Sell tokens not launched on Candle",
|
|
1360
|
+
buyExternalTokens: "Buy tokens not launched on Candle",
|
|
1361
|
+
hyperliquidPerps: "Hyperliquid perps (when enabled)",
|
|
1362
|
+
selfLaunch: "Self-launch from a linked wallet",
|
|
1363
|
+
atomicLaunch: "Atomic launch with first buys",
|
|
1364
|
+
createLinkedWallets: "Create linked wallets",
|
|
1365
|
+
importLinkedWallets: "Import linked wallets",
|
|
1366
|
+
limitOrders: "Limit orders",
|
|
1367
|
+
quant: "Quant on Telegram"
|
|
1368
|
+
};
|
|
1369
|
+
var PLAN_LABELS = { free: "Free", believer: "Believer", pro: "Pro", max: "Max" };
|
|
1370
|
+
function planLabel(plan) {
|
|
1371
|
+
return Object.hasOwn(PLAN_LABELS, plan) ? PLAN_LABELS[plan] : plan;
|
|
1372
|
+
}
|
|
1373
|
+
function formatPlanBps(bps) {
|
|
1374
|
+
return bps === 0 ? "none" : `${Number((bps / 100).toFixed(4))}%`;
|
|
1375
|
+
}
|
|
1376
|
+
function count(n) {
|
|
1377
|
+
return n.toLocaleString("en-US");
|
|
1378
|
+
}
|
|
1379
|
+
function priceCell(entry) {
|
|
1380
|
+
if (entry.price)
|
|
1381
|
+
return `$${count(entry.price.pricePerMonthUsd)} a month`;
|
|
1382
|
+
return entry.plan === "free" ? "free" : "not sold";
|
|
1383
|
+
}
|
|
1384
|
+
function planTableRows(table) {
|
|
1385
|
+
const plans = table.plans;
|
|
1386
|
+
const row = (label, cell) => [label, ...plans.map(cell)];
|
|
1387
|
+
const rows = [
|
|
1388
|
+
row("Price", priceCell),
|
|
1389
|
+
row("Agent trade fee", (e) => formatPlanBps(e.feeBps)),
|
|
1390
|
+
row("Perps builder fee", (e) => formatPlanBps(e.perpFeeBps)),
|
|
1391
|
+
row("Requests per minute", (e) => count(e.limits.rateLimitPerMin)),
|
|
1392
|
+
row("Launches per day", (e) => count(e.limits.dailyLaunchCap)),
|
|
1393
|
+
row("Uploads per minute", (e) => count(e.limits.uploadsPerMin)),
|
|
1394
|
+
row("Linked wallets", (e) => count(e.limits.linkedWallets))
|
|
1395
|
+
];
|
|
1396
|
+
const known = Object.keys(PLAN_CAPABILITY_LABELS);
|
|
1397
|
+
const served = new Set;
|
|
1398
|
+
for (const entry of plans)
|
|
1399
|
+
for (const key of Object.keys(entry.capabilities ?? {}))
|
|
1400
|
+
served.add(key);
|
|
1401
|
+
const keys = [
|
|
1402
|
+
...known.filter((k) => served.has(k)),
|
|
1403
|
+
...[...served].filter((k) => !Object.hasOwn(PLAN_CAPABILITY_LABELS, k))
|
|
1404
|
+
];
|
|
1405
|
+
for (const key of keys) {
|
|
1406
|
+
const label = Object.hasOwn(PLAN_CAPABILITY_LABELS, key) ? PLAN_CAPABILITY_LABELS[key] : key;
|
|
1407
|
+
rows.push(row(label, (e) => {
|
|
1408
|
+
const value = e.capabilities?.[key];
|
|
1409
|
+
return value === true ? "yes" : value === false ? "no" : "-";
|
|
1410
|
+
}));
|
|
1411
|
+
}
|
|
1412
|
+
return { headers: ["", ...plans.map((e) => planLabel(e.plan))], rows };
|
|
1413
|
+
}
|
|
1414
|
+
function planPromotionLine(table) {
|
|
1415
|
+
if (!(table.promoMaxDays > 0))
|
|
1416
|
+
return null;
|
|
1417
|
+
const days = table.promoMaxDays === 1 ? "1 day" : `${table.promoMaxDays} days`;
|
|
1418
|
+
return `A first Pro purchase includes Max for its first ${days}, then continues on Pro.`;
|
|
1419
|
+
}
|
|
1420
|
+
function planTableMarkdown(table) {
|
|
1421
|
+
const { headers: headers2, rows } = planTableRows(table);
|
|
1422
|
+
const line = (cells) => `| ${cells.map((c) => c.replace(/\|/g, "\\|")).join(" | ")} |`;
|
|
1423
|
+
return [line(headers2), line(headers2.map(() => "---")), ...rows.map(line)].join(`
|
|
1424
|
+
`);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
554
1427
|
// src/tools.ts
|
|
555
1428
|
var TOOL_NAMES = [
|
|
556
1429
|
"candle_launch_token",
|
|
@@ -559,6 +1432,7 @@ var TOOL_NAMES = [
|
|
|
559
1432
|
"candle_token_forensics",
|
|
560
1433
|
"candle_report_activity",
|
|
561
1434
|
"candle_get_agent_profile",
|
|
1435
|
+
"candle_get_plans",
|
|
562
1436
|
"candle_trade",
|
|
563
1437
|
"candle_launch_and_seed",
|
|
564
1438
|
"candle_swap",
|
|
@@ -572,7 +1446,15 @@ var TOOL_NAMES = [
|
|
|
572
1446
|
"candle_get_portfolio",
|
|
573
1447
|
"candle_resolve_token",
|
|
574
1448
|
"candle_execution_status",
|
|
575
|
-
"candle_get_operation"
|
|
1449
|
+
"candle_get_operation",
|
|
1450
|
+
"candle_perps_setup",
|
|
1451
|
+
"candle_perps_open",
|
|
1452
|
+
"candle_perps_close",
|
|
1453
|
+
"candle_perps_cancel",
|
|
1454
|
+
"candle_perps_orders",
|
|
1455
|
+
"candle_perps_positions",
|
|
1456
|
+
"candle_perps_leverage",
|
|
1457
|
+
"candle_perps_deposit"
|
|
576
1458
|
];
|
|
577
1459
|
function resolveToolAllowlist(env) {
|
|
578
1460
|
const raw = env.CANDLE_MCP_TOOLS?.trim();
|
|
@@ -671,6 +1553,9 @@ function buildRequest(name, args, cfg) {
|
|
|
671
1553
|
init: { method: "GET", headers: jsonHeaders() }
|
|
672
1554
|
};
|
|
673
1555
|
}
|
|
1556
|
+
case "candle_get_plans": {
|
|
1557
|
+
return { url: `${base2}/api/v1/agent/plans`, init: { method: "GET", headers: jsonHeaders() } };
|
|
1558
|
+
}
|
|
674
1559
|
case "candle_swap": {
|
|
675
1560
|
const apiKey = requireApiKey2(cfg);
|
|
676
1561
|
return {
|
|
@@ -740,6 +1625,22 @@ function buildRequest(name, args, cfg) {
|
|
|
740
1625
|
}
|
|
741
1626
|
}
|
|
742
1627
|
}
|
|
1628
|
+
function plansMarkdown(body) {
|
|
1629
|
+
try {
|
|
1630
|
+
const table = JSON.parse(body);
|
|
1631
|
+
if (!Array.isArray(table.plans))
|
|
1632
|
+
return "";
|
|
1633
|
+
const promotion = planPromotionLine(table);
|
|
1634
|
+
const markdown = `${planTableMarkdown(table)}
|
|
1635
|
+
|
|
1636
|
+
${PLAN_CAPABILITY_NOTE}`;
|
|
1637
|
+
return promotion ? `${markdown}
|
|
1638
|
+
|
|
1639
|
+
${promotion}` : markdown;
|
|
1640
|
+
} catch {
|
|
1641
|
+
return "";
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
743
1644
|
async function callAndRelay(name, args, cfg) {
|
|
744
1645
|
const { url, init } = buildRequest(name, args, cfg);
|
|
745
1646
|
const res = await fetch(url, init);
|
|
@@ -751,102 +1652,102 @@ async function callAndRelay(name, args, cfg) {
|
|
|
751
1652
|
};
|
|
752
1653
|
}
|
|
753
1654
|
var launchTokenShape = {
|
|
754
|
-
clientLaunchId:
|
|
755
|
-
name:
|
|
756
|
-
symbol:
|
|
757
|
-
imageUrl:
|
|
758
|
-
bannerUrl:
|
|
759
|
-
chain:
|
|
760
|
-
quoteAsset:
|
|
761
|
-
mode:
|
|
762
|
-
stakerAllocationBps:
|
|
763
|
-
dexVersion:
|
|
764
|
-
buyAmount:
|
|
765
|
-
description:
|
|
766
|
-
socials:
|
|
767
|
-
visibility:
|
|
768
|
-
dryRun:
|
|
1655
|
+
clientLaunchId: z2.string().describe("Caller-chosen idempotency key, unique per account"),
|
|
1656
|
+
name: z2.string().describe("Token name"),
|
|
1657
|
+
symbol: z2.string().describe("Token symbol"),
|
|
1658
|
+
imageUrl: z2.string().describe("https URL to the token image. Must be roughly SQUARE (aspect ratio at most 1.5:1): it " + "renders as a small circle/square avatar everywhere. Share cards, OG images and banners " + "are rejected with IMAGE_WRONG_SHAPE -- pass those as bannerUrl instead"),
|
|
1659
|
+
bannerUrl: z2.string().optional().describe("Optional https URL to WIDE artwork for the token page's banner strip (wider than 1.5:1, " + "e.g. 1200x630). This is where a share card or OG image belongs. A square image here is " + "rejected with BANNER_WRONG_SHAPE. Omit it and the strip falls back to imageUrl"),
|
|
1660
|
+
chain: z2.string().optional().describe('"solana" or "hood"; defaults to solana'),
|
|
1661
|
+
quoteAsset: z2.string().optional().describe("Quote asset symbol; defaults per chain"),
|
|
1662
|
+
mode: z2.string().optional().describe('"open" or "exclusive"; defaults to open. "test-open" / "test-exclusive" launch the ' + "low-threshold test curves (~1/80 economics) where the API has ENABLE_TEST_CURVES on"),
|
|
1663
|
+
stakerAllocationBps: z2.number().optional().describe("Staker allocation in basis points"),
|
|
1664
|
+
dexVersion: z2.string().nullable().optional().describe('"v3" or "v4"; required for hood launches'),
|
|
1665
|
+
buyAmount: z2.union([z2.string(), z2.number()]).optional().describe("Initial buy, in quote base units (string or number)"),
|
|
1666
|
+
description: z2.string().optional(),
|
|
1667
|
+
socials: z2.record(z2.string()).optional().describe("Social links keyed by platform"),
|
|
1668
|
+
visibility: z2.string().optional().describe('"production", "test", "local", or "hidden"'),
|
|
1669
|
+
dryRun: z2.boolean().optional().describe("Validate without executing the launch")
|
|
769
1670
|
};
|
|
770
1671
|
var getMarketShape = {
|
|
771
|
-
chain:
|
|
772
|
-
mint:
|
|
1672
|
+
chain: z2.string().describe('"solana" or "hood"'),
|
|
1673
|
+
mint: z2.string().describe("Token mint (solana) or contract address (hood)")
|
|
773
1674
|
};
|
|
774
1675
|
var tokenForensicsShape = {
|
|
775
|
-
chain:
|
|
776
|
-
mint:
|
|
1676
|
+
chain: z2.string().describe('"solana" or "hood"'),
|
|
1677
|
+
mint: z2.string().describe("Token mint (solana) or contract address (hood)")
|
|
777
1678
|
};
|
|
778
1679
|
var getFeedShape = {
|
|
779
|
-
bucket:
|
|
780
|
-
chain:
|
|
781
|
-
where:
|
|
782
|
-
sort:
|
|
783
|
-
fields:
|
|
784
|
-
limit:
|
|
1680
|
+
bucket: z2.enum(["new", "graduated", "onfire", "bluechip"]),
|
|
1681
|
+
chain: z2.string().optional().describe("Optional chain filter"),
|
|
1682
|
+
where: z2.string().optional().describe('JSON filter, e.g. {"marketCap":{"lt":150000},"liquidityUsd":{"gte":25000},"mintAuthorityDisabled":{"eq":true}}. ' + "Comparators: eq, ne, lt, lte, gt, gte, present. An ABSENT field satisfies none of them except " + "present:false, so a filter for mintAuthorityDisabled eq true returns only tokens that actually say " + "true, never ones where the flag is simply missing. Use present:false to find the tokens with no data."),
|
|
1683
|
+
sort: z2.string().optional().describe('Sort as "field" or "field:asc" / "field:desc". A bare field means desc.'),
|
|
1684
|
+
fields: z2.string().optional().describe("Comma-separated fields to return, e.g. symbol,marketCap,liquidityUsd. chain, address and symbol always " + "ride along. Cuts a 135KB response to a couple of KB."),
|
|
1685
|
+
limit: z2.string().optional().describe("Max rows to return, 1-200.")
|
|
785
1686
|
};
|
|
786
1687
|
var reportActivityShape = {
|
|
787
|
-
chain:
|
|
788
|
-
signature:
|
|
1688
|
+
chain: z2.string().describe('"solana" or "hood"'),
|
|
1689
|
+
signature: z2.string().describe("Transaction signature/hash to verify and record")
|
|
789
1690
|
};
|
|
790
1691
|
var getAgentProfileShape = {
|
|
791
|
-
idOrWallet:
|
|
1692
|
+
idOrWallet: z2.string().describe("Candle username or wallet address")
|
|
792
1693
|
};
|
|
793
1694
|
var getOperationShape = {
|
|
794
|
-
clientId:
|
|
795
|
-
kind:
|
|
1695
|
+
clientId: z2.string().describe("The clientTradeId or clientLaunchId the write used, or that the tool echoed back to you"),
|
|
1696
|
+
kind: z2.enum(["trade", "launch"]).describe("Which rail the id belongs to. Required: ids are caller-chosen strings with no shape to dispatch on")
|
|
796
1697
|
};
|
|
797
1698
|
var resolveTokenShape = {
|
|
798
|
-
mint:
|
|
1699
|
+
mint: z2.string().describe("Token mint (Solana, base58) or contract address (Hood, 0x-prefixed)")
|
|
799
1700
|
};
|
|
800
1701
|
var profileWalletsShape = {
|
|
801
|
-
keyPrefix:
|
|
1702
|
+
keyPrefix: z2.string().describe("The profile's API key prefix, as listed by candle keys list or in the dashboard")
|
|
802
1703
|
};
|
|
803
1704
|
var profilePnlShape = {
|
|
804
|
-
keyPrefix:
|
|
1705
|
+
keyPrefix: z2.string().describe("The profile's API key prefix")
|
|
805
1706
|
};
|
|
806
1707
|
var profileTradesShape = {
|
|
807
|
-
keyPrefix:
|
|
808
|
-
limit:
|
|
1708
|
+
keyPrefix: z2.string().describe("The profile's API key prefix"),
|
|
1709
|
+
limit: z2.number().optional().describe("How many of the most recent trades to return. Default 200, max 1000.")
|
|
809
1710
|
};
|
|
810
1711
|
var setProfileWalletsShape = {
|
|
811
|
-
keyPrefix:
|
|
812
|
-
walletIds:
|
|
1712
|
+
keyPrefix: z2.string().describe("The profile's API key prefix"),
|
|
1713
|
+
walletIds: z2.array(z2.string()).describe("The linked-wallet ids the profile may spend from -- ids, not addresses. This REPLACES the " + "whole set: any wallet omitted loses access. An empty array assigns none.")
|
|
813
1714
|
};
|
|
814
1715
|
var swapShape = {
|
|
815
|
-
from:
|
|
816
|
-
to:
|
|
817
|
-
amount:
|
|
818
|
-
amountRaw:
|
|
819
|
-
maxSlippageBps:
|
|
820
|
-
clientSwapId:
|
|
1716
|
+
from: z2.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to spend"),
|
|
1717
|
+
to: z2.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to receive; must differ from `from`"),
|
|
1718
|
+
amount: z2.string().optional().describe('Decimal amount of `from` to spend, e.g. "0.5". Preferred. Pass exactly one of amount or amountRaw.'),
|
|
1719
|
+
amountRaw: z2.string().optional().describe("Raw base units of `from`, as a positive integer string. Kept for callers that already " + "compute raw units; new callers should use `amount`."),
|
|
1720
|
+
maxSlippageBps: z2.number().optional().describe("Slippage bound in bps, 0-10000. Server defaults to 100 (1%)"),
|
|
1721
|
+
clientSwapId: z2.string().optional().describe("Durable idempotency key. Same id + same from/to/amountRaw/effective slippage (omitted means " + "100 bps) replays the stored result, including an indeterminate SWAP_FAILED with the " + "signature in the message. A different body is rejected. Omit it and nothing is coalesced. " + "Pass one so a timeout retry returns that stored result.")
|
|
821
1722
|
};
|
|
822
1723
|
var tradeShape = {
|
|
823
|
-
mint:
|
|
824
|
-
side:
|
|
825
|
-
amount:
|
|
826
|
-
percent:
|
|
827
|
-
quoteAsset:
|
|
828
|
-
maxSlippageBps:
|
|
829
|
-
clientTradeId:
|
|
830
|
-
paper:
|
|
1724
|
+
mint: z2.string().describe("Token mint (solana) or contract address (hood)"),
|
|
1725
|
+
side: z2.enum(["buy", "sell"]),
|
|
1726
|
+
amount: z2.string().optional().describe("Decimal amount. Buys: how much of THIS TOKEN'S OWN quote asset to spend (SOL for a " + 'SOL-launched token, USDC for a USDC-quoted one, and so on: e.g. "0.5"). Sells: how many ' + "TOKENS to sell. Pass exactly one of amount or percent."),
|
|
1727
|
+
percent: z2.number().optional().describe("Sells only: sell this percent (integer 1-100) of the holding. Live trades size against the " + "embedded wallet. Paper trades (`paper: true`) size against this key's paper inventory -- " + "the position a previous paper buy credited -- because paper never moves the live wallet."),
|
|
1728
|
+
quoteAsset: z2.string().optional().describe('What the wallet spends on a buy or receives on a sell: "sol", "usdc" or "cndl" on Solana, ' + '"eth" or "usdg" on Hood. Echoing the `quoteAsset` a quote (POST /api/v1/trade/agent/quote) returned is safe. On Solana it applies only ' + "to an arbitrary mint Candle never launched (a buy needs Pro or Max; a sell works on any plan) and is ignored for a Candle token, " + "whose quote comes from the token itself. On Hood it is the settlement asset of a DEX " + "trade; a USDG buy adds an approval transaction an ETH buy does not. It is not the route: " + "the cheapest path to the asset is chosen separately. Defaults to sol / ETH settlement."),
|
|
1729
|
+
maxSlippageBps: z2.number().optional().describe("Max slippage in basis points; API default applies when omitted"),
|
|
1730
|
+
clientTradeId: z2.string().optional().describe("Idempotency key. Auto-generated when omitted and echoed in the result. Retrying with the " + "SAME id is safe (idempotent replay); a new id is a SECOND trade."),
|
|
1731
|
+
paper: z2.preprocess((value) => value === "true" || value === 1 || value === "1" ? true : value === "false" || value === 0 || value === "0" ? false : value, z2.boolean().optional().describe("Rehearse instead of trading. The request passes every admission rule a live trade passes " + "-- the same planner, spend gate, key cap and loss limits -- and records the quote, but " + "nothing is ever broadcast and no funds move. Use it to check that a strategy is admitted " + "before risking anything on it. A paper fill is optimistic by construction: it books the " + "quoted price, so the gap between a paper arm and a live one IS the execution cost. " + "A sell of a mint this key already paper-bought also closes that paper book when the " + "live wallet is empty, even if this flag is omitted."))
|
|
831
1732
|
};
|
|
832
1733
|
var { buyAmount: _rawBuyAmount, ...seedableLaunchShape } = launchTokenShape;
|
|
833
1734
|
var launchAndSeedShape = {
|
|
834
1735
|
...seedableLaunchShape,
|
|
835
|
-
clientLaunchId:
|
|
836
|
-
devBuy:
|
|
1736
|
+
clientLaunchId: z2.string().optional().describe("Idempotency key. Auto-generated when omitted and echoed in the result."),
|
|
1737
|
+
devBuy: z2.string().optional().describe('Seed buy in DECIMAL units of the quote asset this launch selects (e.g. "0.25" SOL, or ' + "ETH on hood), bundled into the launch transaction itself. Follows quoteAsset, which " + "defaults to sol on solana and eth on hood. Capped by the platform dev-buy ceiling; for " + "a larger seed, launch then follow with candle_trade.")
|
|
837
1738
|
};
|
|
838
1739
|
var transferShape = {
|
|
839
|
-
chain:
|
|
840
|
-
asset:
|
|
841
|
-
mint:
|
|
842
|
-
amountRaw:
|
|
843
|
-
to:
|
|
844
|
-
clientTransferId:
|
|
1740
|
+
chain: z2.enum(["solana", "hood"]).describe("Which chain the transfer executes on"),
|
|
1741
|
+
asset: z2.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).optional().describe("A base asset key. Pass exactly one of asset or mint."),
|
|
1742
|
+
mint: z2.string().optional().describe("An arbitrary token: SPL mint (Solana) or ERC-20 contract (Hood). Own-wallet destinations only; withdrawals to approved addresses are base-assets-only."),
|
|
1743
|
+
amountRaw: z2.string().describe('RAW base units as a decimal string (lamports, wei, token raw units), or "max" to sweep the spendable balance. NOT a human decimal: 1 SOL is "1000000000".'),
|
|
1744
|
+
to: z2.string().describe("Destination address. Must be one of the account's own wallets, or an address the OWNER pre-approved as a withdrawal address in the Candle console -- anything else is refused before signing."),
|
|
1745
|
+
clientTransferId: z2.string().optional().describe("Caller-chosen idempotency key for safe retries")
|
|
845
1746
|
};
|
|
846
1747
|
var sweepShape = {
|
|
847
|
-
chain:
|
|
848
|
-
to:
|
|
849
|
-
mints:
|
|
1748
|
+
chain: z2.enum(["solana", "hood"]).describe("Which chain to sweep"),
|
|
1749
|
+
to: z2.string().describe("Destination address, same rules as candle_transfer's `to`"),
|
|
1750
|
+
mints: z2.array(z2.string()).optional().describe("Extra token mints/contracts to sweep besides the chain's base assets (own-wallet destinations only).")
|
|
850
1751
|
};
|
|
851
1752
|
function registerTools(server, env = process.env) {
|
|
852
1753
|
const cfg = resolveConfig(env);
|
|
@@ -892,6 +1793,24 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
|
|
|
892
1793
|
description: "Read a Candle user's public agent profile: whether agent features are enabled and launch counts.",
|
|
893
1794
|
inputSchema: getAgentProfileShape
|
|
894
1795
|
}, async (args) => callAndRelay("candle_get_agent_profile", args, cfg));
|
|
1796
|
+
register("candle_get_plans", {
|
|
1797
|
+
title: "Plans: prices, fees, limits and what each can do",
|
|
1798
|
+
description: "The plan table this Candle deployment serves (GET /api/v1/agent/plans). No key needed. Reads only. For " + "each plan: price (null when not sold), feeBps (the agent fee charged on top of every trade or dev buy the " + "API builds), perpFeeBps (the Hyperliquid builder fee), limits for a new key (requests per minute, launches " + "per day, uploads per minute, linked wallets) and capabilities: buyExternalTokens, sellExternalTokens, " + "tradeBaseAssets, tradeCandleTokens, selfLaunch, atomicLaunch, createLinkedWallets, importLinkedWallets, " + "hyperliquidPerps, limitOrders, quant, freeBaseTransfers. promoMaxDays is the days of Max a first Pro " + "purchase includes (0: no promotion). Quote prices and fees from here, never from memory: they differ by " + "deployment and change at the three-plan launch (Free, Pro, Max). The account's own plan and fee are in " + "candle_execution_status's tier. A TIER_REQUIRED refusal names a capability this table shows the account's " + "plan lacks. A capability true here is plan eligibility, not deployment availability: perps and own-wallet bridges " + "are each behind a deployment switch and need their wallet, scopes and setup. Returns the server's JSON, then the same table as Markdown.",
|
|
1799
|
+
inputSchema: {}
|
|
1800
|
+
}, async () => {
|
|
1801
|
+
const { url, init } = buildRequest("candle_get_plans", {}, cfg);
|
|
1802
|
+
const res = await fetch(url, init);
|
|
1803
|
+
noteVersionHeaders(res);
|
|
1804
|
+
const text = await res.text();
|
|
1805
|
+
if (!res.ok)
|
|
1806
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
1807
|
+
return {
|
|
1808
|
+
content: [
|
|
1809
|
+
{ type: "text", text },
|
|
1810
|
+
{ type: "text", text: plansMarkdown(text) }
|
|
1811
|
+
]
|
|
1812
|
+
};
|
|
1813
|
+
});
|
|
895
1814
|
register("candle_get_operation", {
|
|
896
1815
|
title: "What happened to a write",
|
|
897
1816
|
description: "Look up a trade or launch by the id its write used, and find out whether it landed. " + `Reads only; moves nothing.
|
|
@@ -922,7 +1841,7 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
|
|
|
922
1841
|
}, async (args) => callAndRelay("candle_set_profile_wallets", args, cfg));
|
|
923
1842
|
register("candle_get_profile_pnl", {
|
|
924
1843
|
title: "Read an agent profile's P&L",
|
|
925
|
-
description: "
|
|
1844
|
+
description: "This profile's share of the account's P&L: realized profit on the sales it made, the Candle fees " + "charged against it, and the positions that belong to it (the wallet's bound key, else the key " + "whose buy opened them) with their cost basis, each MARKED at Candle's current price where one " + "exists: `markPriceUsd`, `marketValueUsd` and `unrealizedUsd` per position, and `unrealizedUsd` " + "overall. A position with no price is counted in `unmarkedPositions` and left out of unrealized, " + "never valued at zero; `oldestMarkAt` says how old the marks are. Deposits and withdrawals are " + "excluded: funding a wallet is not profit. Check `unvalued` and `truncated` before quoting the " + "number; they mean the total is partial. The figures come from the account's one P&L read, the " + "same as the console's, to the cent: `pnl.totalUsd` (realized net plus unrealized) is the " + "console's P&L. Positions live in wallets: each wallet is its own average-cost pool, the total " + "is the sum of `pnl.byWallet` (one row per wallet, main or linked, with the linked wallet's " + "label; `unknown:solana` or `unknown:hood` is the row for fills no record places in a wallet), " + "and one token held in two wallets is two positions, each with its `wallet`. A recorded move " + "between the account's wallets carries its cost and realizes nothing. Each position carries " + "`agent` (the key it belongs to) and `dust` when it is worth under one cent; dust stays listed " + "and counted, and `openPositionsExDust` leaves it out. `closed` gives the closed positions as " + "`madeUsd` + `lostUsd` + `partialSellsUsd` = `realizedNetUsd`, with `wins` and `losses`. " + "`lookback` and `truncated` are the account's activity bound, and `tradesConsidered` counts this " + "key's ledger fills. Since 2026-10-02 the total is the sum of wallets rather than one pool across " + "them, so realized figures can differ from earlier reads; a server that predates it omits " + "`totalUsd`, `closed`, `openPositionsExDust`, `wallet`, `agent` and `dust`. " + "Every open position and every `byWallet` row (and its positions) carries `chain` ('solana' or " + "'hood'), and `pnl.byChain` has the total's figures over each chain's fills alone, both chains " + "always present, with `openPositions` there a count (the positions are in `pnl.openPositions`, by " + "`chain`). The two chains sum to the total. A " + "server that predates per-chain P&L omits `chain` and `byChain`. While Hyperliquid is enabled, " + "`hyperliquid` separately reports main-perp realized gross, signed funding, fees (inclusive of builder fees), " + "and net = gross + funding - fees, for currently bound EVM TEE wallets since the current TEE binding. " + "Check `read` and `truncated`; historical bindings and unrealized perps are excluded. Reads only.",
|
|
926
1845
|
inputSchema: profilePnlShape
|
|
927
1846
|
}, async (args) => callAndRelay("candle_get_profile_pnl", args, cfg));
|
|
928
1847
|
register("candle_get_profile_trades", {
|
|
@@ -992,6 +1911,8 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
|
|
|
992
1911
|
|
|
993
1912
|
` + "Arguments: `mint` and `side` are required. Amounts are DECIMAL, never raw base units " + '(amount: "0.5", not lamports). Omitting the amount on a sell sells the whole ' + `position.
|
|
994
1913
|
|
|
1914
|
+
` + "Plans: every plan can buy and sell Candle-launched tokens and base assets, and SELL a token it holds that " + "Candle did not launch. BUYING such a token needs Pro or Max (TIER_REQUIRED otherwise). Each trade pays the " + `plan's agent fee on top (feeBps in candle_get_plans).
|
|
1915
|
+
|
|
995
1916
|
` + "Pass `paper: true` to rehearse: every admission rule runs and the quote is recorded, but " + "nothing broadcasts and no funds move. A paper buy credits this key's paper inventory, " + "including for external Solana mints routed through Jupiter. A later sell by amount or " + "percent closes that book without reading the live wallet and without MARKET_NOT_FOUND " + "-- including when `paper` is omitted on the exit, as long as the paper position exists. " + "Do this before the first live trade of a new strategy, and whenever you are unsure a " + `trade would be admitted at all.
|
|
996
1917
|
|
|
997
1918
|
` + `After the call:
|
|
@@ -1010,17 +1931,66 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
|
|
|
1010
1931
|
const result = await executeLaunchAndSeed(args, cfg, fetch);
|
|
1011
1932
|
return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
|
|
1012
1933
|
});
|
|
1934
|
+
const perpsTool = (tool) => async (args) => {
|
|
1935
|
+
const result = await executePerps(tool, args, cfg, env, fetch);
|
|
1936
|
+
return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
|
|
1937
|
+
};
|
|
1938
|
+
const perpsWrite = " MOVES REAL FUNDS on Hyperliquid unless submit is false. Candle builds the action within this key's limits " + "(USD window, maximum leverage, maximum position, slippage and price bands, main-exchange markets only); this " + "server recomputes its hash and checks the action type and Candle's builder before signing, then submits it to " + "Hyperliquid itself. Needs perps:write on the key and CANDLE_KEY_SIGNER_PEM_FILE. A refusal names the limit " + "that refused it (error.limit).";
|
|
1939
|
+
register("candle_perps_setup", {
|
|
1940
|
+
title: "Set up Hyperliquid perps",
|
|
1941
|
+
description: "Approve Candle's Hyperliquid builder fee (0.1%) once for the key's EVM TEE wallet, and report the " + "account's mode and balance. Idempotent: nothing is signed when the approval is already on Hyperliquid." + perpsWrite,
|
|
1942
|
+
inputSchema: perpsShapes.candle_perps_setup
|
|
1943
|
+
}, perpsTool("candle_perps_setup"));
|
|
1944
|
+
register("candle_perps_open", {
|
|
1945
|
+
title: "Open a perps position",
|
|
1946
|
+
description: "Open or add to a perpetual position on Hyperliquid's main perp exchange: a market order (IOC within " + "slippageBps of the mid) without `price`, a limit order with it, and optional reduce-only takeProfit and " + "stopLoss triggers. Every plan except Max pays Candle a builder fee on each order (0.1% today; perpFeeBps in " + "candle_get_plans); Max pays none." + perpsWrite,
|
|
1947
|
+
inputSchema: perpsShapes.candle_perps_open
|
|
1948
|
+
}, perpsTool("candle_perps_open"));
|
|
1949
|
+
register("candle_perps_close", {
|
|
1950
|
+
title: "Close a perps position",
|
|
1951
|
+
description: "Close all or part of a position with a reduce-only IOC order. It reserves nothing, so a key at its USD " + "cap can still close." + perpsWrite,
|
|
1952
|
+
inputSchema: perpsShapes.candle_perps_close
|
|
1953
|
+
}, perpsTool("candle_perps_close"));
|
|
1954
|
+
register("candle_perps_cancel", {
|
|
1955
|
+
title: "Cancel a perps order",
|
|
1956
|
+
description: "Cancel an order Candle built, by its cloid (from candle_perps_open or candle_perps_orders)." + perpsWrite,
|
|
1957
|
+
inputSchema: perpsShapes.candle_perps_cancel
|
|
1958
|
+
}, perpsTool("candle_perps_cancel"));
|
|
1959
|
+
register("candle_perps_orders", {
|
|
1960
|
+
title: "Perps orders",
|
|
1961
|
+
description: "Open orders on Hyperliquid for the key's EVM TEE wallet, and every action Candle built for it with what " + "the venue shows. Reads only; moves nothing.",
|
|
1962
|
+
inputSchema: perpsShapes.candle_perps_orders
|
|
1963
|
+
}, perpsTool("candle_perps_orders"));
|
|
1964
|
+
register("candle_perps_positions", {
|
|
1965
|
+
title: "Perps positions",
|
|
1966
|
+
description: "Positions, account value and withdrawable balance on Hyperliquid for the key's EVM TEE wallet, read " + "live by its address. Reads only; moves nothing.",
|
|
1967
|
+
inputSchema: perpsShapes.candle_perps_positions
|
|
1968
|
+
}, perpsTool("candle_perps_positions"));
|
|
1969
|
+
register("candle_perps_leverage", {
|
|
1970
|
+
title: "Set perps leverage",
|
|
1971
|
+
description: "Set a market's leverage and margin mode (cross or isolated) on the account, within the key's maximum leverage." + perpsWrite,
|
|
1972
|
+
inputSchema: perpsShapes.candle_perps_leverage
|
|
1973
|
+
}, perpsTool("candle_perps_leverage"));
|
|
1974
|
+
register("candle_perps_deposit", {
|
|
1975
|
+
title: "Deposit to Hyperliquid",
|
|
1976
|
+
description: "MOVES REAL FUNDS unless submit is false. Deposit onto the key's Hyperliquid account through Relay: SOL or " + "USDC from its Solana TEE wallet, or ETH or USDG from its Hood TEE wallet, credited as Hyperliquid perps " + "USDC at the key's EVM TEE wallet's own address (Candle resolves it; no recipient is taken). Refused below a " + "floor that covers Hyperliquid's 1 USDC first-deposit charge. No Candle fee. Needs swap:write and a raw cap " + "on the asset, and CANDLE_KEY_SIGNER_PEM_FILE to sign. There is no withdrawal from Hyperliquid yet.",
|
|
1977
|
+
inputSchema: perpsDepositShape
|
|
1978
|
+
}, async (args) => {
|
|
1979
|
+
const result = await executePerpsDeposit(args, cfg, env, fetch);
|
|
1980
|
+
return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
|
|
1981
|
+
});
|
|
1013
1982
|
}
|
|
1014
1983
|
|
|
1015
1984
|
// src/server.ts
|
|
1016
1985
|
var INSTRUCTIONS = `Candle is a trading and token-launch rail for agents. You hold a scoped API key, never a private key; signing and funding stay with the key owner's wallet.
|
|
1017
1986
|
|
|
1018
|
-
START HERE —
|
|
1987
|
+
START HERE — six tools need NO credential. Call these first to confirm the server is wired before asking anyone for anything:
|
|
1019
1988
|
candle_get_market price, market cap, volume, curve state for one token
|
|
1020
1989
|
candle_get_feed the roster: hot streak, new pairs, graduated, blue chip
|
|
1021
1990
|
candle_resolve_token a ticker or partial name -> mint address + chain
|
|
1022
1991
|
candle_token_forensics call this before quoting or buying. Returns the on-chain developer (never a launchpad shared authority; deployer.attribution names the launchpad or issuer when no developer is on chain), their went-to-zero rate and last coins, who bought in the deploy window (strangers in the same slot are the bundle signal), same-funder insider share, same-funder cluster, and safety.summary with six sourced flags. Refuse an unprompted buy when flagged; incomplete or unknown is not clearance. launch.deployerLaunches is an inclusive informational count, never a warning
|
|
1023
1992
|
candle_get_agent_profile your own tier, caps and verified activity
|
|
1993
|
+
candle_get_plans every plan's price, fees, limits and what it can do; quote these, never from memory
|
|
1024
1994
|
|
|
1025
1995
|
COVERAGE — read this before you treat an error as a broken server.
|
|
1026
1996
|
candle_get_feed indexes the wider market (pump.fun, pons.family and other external launchpads).
|