-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathjupiter.ts
More file actions
229 lines (210 loc) · 9.23 KB
/
Copy pathjupiter.ts
File metadata and controls
229 lines (210 loc) · 9.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
/**
* ════════════════════════════════════════════════════════════════════════════
* jupiter.ts — placing & reading the canary with Jupiter's Trigger Order API
* ════════════════════════════════════════════════════════════════════════════
*
* This is one of the two files worth reading to learn the pattern. The idea:
*
* "Let Jupiter answer 'did price hit X?' for you."
*
* A Trigger order is a resting limit order. You ask Jupiter to buy BTC with
* USDC, but only at (or better than) a price you name. Jupiter writes a tiny
* order account ON-CHAIN and its keepers fill it the instant the market trades
* through your level — at zero slippage by nature. That single on-chain account
* is the "canary": the one pubkey we then watch with Solana gRPC.
*
* The lifecycle of a Trigger order is three REST calls:
*
* 1. POST /createOrder → Jupiter builds an UNSIGNED transaction and tells
* you the on-chain `order` account it will create.
* 2. (you sign it) → with your wallet keypair, locally.
* 3. POST /execute → Jupiter lands the signed tx through its own
* retry/landing infra and returns the signature.
*
* To wind down you call /cancelOrder (same sign-then-execute dance), and to read
* fills you call /getTriggerOrders. Amounts are always raw atomic-unit strings.
*
* We default to the KEYLESS host (lite-api.jup.ag) which is plenty for one order;
* pass a JUP_API_KEY + the api.jup.ag host for production rate limits.
*
* NOTE ON VERSIONS: Jupiter's newer Trigger *V2* keeps orders off-chain in a
* custodial vault — there is no on-chain order account to subscribe to, which
* would defeat the whole "watch one account" premise. So this demo deliberately
* uses Trigger *V1*, the version that still exposes an on-chain order pubkey.
*/
import { Keypair, VersionedTransaction } from "@solana/web3.js";
/** Jupiter's keyless Price API (v3). Returns a USD price per mint. */
const PRICE_API = "https://lite-api.jup.ag/price/v3";
// ── Response shapes (only the fields we use) ───────────────────────────────────
export interface CreateOrderResult {
/** opaque id you echo back to /execute */
requestId: string;
/** base64 UNSIGNED VersionedTransaction built by Jupiter */
transaction: string;
/** the on-chain order account Jupiter will create — THIS is the canary */
order: string;
}
export interface ExecuteResult {
signature: string;
status: string; // "Success" | "Failed"
code?: number;
error?: string;
}
export interface TriggerOrder {
/** the on-chain order account address (matches CreateOrderResult.order) */
orderKey: string;
status: string; // e.g. "Open" | "Completed" | "Cancelled"
inputMint: string;
outputMint: string;
/** decimal-adjusted amounts for display */
makingAmount: string;
takingAmount: string;
/** atomic-unit remaining; "0" once fully filled */
rawRemainingMakingAmount?: string;
openTx?: string;
closeTx?: string;
trades?: Array<{
action: string; // "Fill"
txId?: string;
confirmedAt?: string;
inputAmount?: string;
outputAmount?: string;
}>;
}
export interface CancelOrderResult {
requestId: string;
/** base64 UNSIGNED cancel transaction */
transaction: string;
}
// ── Amount helpers ─────────────────────────────────────────────────────────────
/**
* Convert a human amount to a raw atomic-unit STRING (what Jupiter expects).
* e.g. 5 USDC (6dp) → "5000000"; 0.00007615 BTC (8dp) → "7615".
*/
export function toAtomic(amount: number, decimals: number): string {
return BigInt(Math.round(amount * 10 ** decimals)).toString();
}
/**
* Sign a base64 VersionedTransaction locally and return it re-encoded as base64.
* The keypair never leaves this process; only the signed bytes go back to Jupiter.
*/
export function signTransactionBase64(base64Tx: string, keypair: Keypair): string {
const tx = VersionedTransaction.deserialize(Buffer.from(base64Tx, "base64"));
tx.sign([keypair]);
return Buffer.from(tx.serialize()).toString("base64");
}
// ── The client ──────────────────────────────────────────────────────────────────
export class JupiterTrigger {
constructor(
private readonly triggerBase: string, // e.g. https://lite-api.jup.ag/trigger/v1
private readonly apiKey: string | null = null,
) {}
/** Shared fetch with auth header + meaningful error messages. */
private async request<T>(
path: string,
init: { method: "GET" | "POST"; body?: unknown } = { method: "GET" },
): Promise<T> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (this.apiKey) headers["x-api-key"] = this.apiKey;
const res = await fetch(`${this.triggerBase}${path}`, {
method: init.method,
headers,
body: init.body ? JSON.stringify(init.body) : undefined,
});
const text = await res.text();
let json: unknown;
try {
json = text ? JSON.parse(text) : {};
} catch {
json = { raw: text };
}
if (!res.ok) {
// Jupiter returns either { error: "..." } or { error: { issues: [...] } }
const err = (json as { error?: unknown }).error;
const detail =
typeof err === "string" ? err : err ? JSON.stringify(err) : `HTTP ${res.status}`;
throw new Error(`Jupiter ${path} failed: ${detail}`);
}
return json as T;
}
/** Current BTC price in USD (also handy for the dashboard ticker). */
async getPriceUsd(mint: string): Promise<number> {
const res = await fetch(`${PRICE_API}?ids=${mint}`);
if (!res.ok) throw new Error(`Price API HTTP ${res.status}`);
const data = (await res.json()) as Record<string, { usdPrice?: number } | undefined>;
const price = data[mint]?.usdPrice;
if (typeof price !== "number") throw new Error(`No usdPrice for ${mint}`);
return price;
}
/**
* Step 1: ask Jupiter to build the order transaction.
*
* makingAmount = how much of `inputMint` you put in (here: USDC you spend)
* takingAmount = how much of `outputMint` you want out (here: BTC received)
*
* The ratio of the two IS your limit price. Trigger orders fill at zero slippage,
* so you get exactly that price or better. Nothing is on-chain yet — this only
* returns an unsigned tx plus the `order` account that the tx will create.
*/
async createOrder(params: {
inputMint: string;
outputMint: string;
maker: string; // your wallet pubkey (maker === payer here)
makingAmount: string; // atomic units
takingAmount: string; // atomic units
}): Promise<CreateOrderResult> {
return this.request<CreateOrderResult>("/createOrder", {
method: "POST",
body: {
inputMint: params.inputMint,
outputMint: params.outputMint,
maker: params.maker,
payer: params.maker,
params: {
makingAmount: params.makingAmount,
takingAmount: params.takingAmount,
slippageBps: "0", // trigger orders are zero-slippage by design
},
// "auto" = Jupiter picks a priority fee around the 95th percentile so the
// order-creation tx lands promptly.
computeUnitPrice: "auto",
},
});
}
/**
* Step 3: hand the signed tx back to Jupiter, which submits it through its own
* landing infrastructure (more reliable than blindly sendRawTransaction-ing it
* yourself). Returns the confirmed signature.
*/
async execute(requestId: string, signedTransaction: string): Promise<ExecuteResult> {
return this.request<ExecuteResult>("/execute", {
method: "POST",
body: { requestId, signedTransaction },
});
}
/**
* Read a wallet's orders. orderStatus "active" = still resting; "history" =
* filled/cancelled. We use this two ways: as a backstop fill-detector and to
* enrich the fire moment with the real fill price + tx signature.
*
* A fully-filled order shows up under "history" with status "Completed" and a
* trades[] entry whose action is "Fill".
*/
async getOrders(user: string, orderStatus: "active" | "history"): Promise<TriggerOrder[]> {
const res = await this.request<{ orders?: TriggerOrder[] }>(
`/getTriggerOrders?user=${user}&orderStatus=${orderStatus}`,
);
return res.orders ?? [];
}
/**
* Build the cancel transaction for a resting order. Sign + /execute it just like
* createOrder. We call this on graceful shutdown so armed/live runs never leave
* a dust order resting on-chain.
*/
async cancelOrder(maker: string, order: string): Promise<CancelOrderResult> {
return this.request<CancelOrderResult>("/cancelOrder", {
method: "POST",
body: { maker, order, computeUnitPrice: "auto" },
});
}
}