-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathonTrigger.ts
More file actions
175 lines (167 loc) · 8.89 KB
/
Copy pathonTrigger.ts
File metadata and controls
175 lines (167 loc) · 8.89 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
/**
* ════════════════════════════════════════════════════════════════════════════
* onTrigger.ts — THIS IS YOUR HOOK
* ════════════════════════════════════════════════════════════════════════════
*
* The canary fired: the market traded through your level and the on-chain order
* account closed. `onTrigger` is called once with everything about that fill —
* price, timestamp, the order account, the fill signature, the direction. This
* is the single place to wire whatever you want to happen next.
*
* Out of the box it ships three safe-by-default actions, picked with `TRIGGER_ACTION`:
*
* • log (default) — just print the fill. No side effects, no funds move.
* • webhook — POST the fill JSON to TRIGGER_WEBHOOK_URL
* (Slack / Discord / Telegram-friendly: sends `text`,
* `content`, and the raw `event`).
* • swap — a REAL, small Jupiter spot swap (buy a little more
* BTC). Gated behind I_UNDERSTAND_REAL_MONEY=true and
* capped (SWAP_USD_CAP). This moves real money.
*
* To add your own behavior: edit the `switch` in onTrigger(), or replace a branch.
* Everything you need is on `event`; everything you're allowed to touch is on `ctx`.
*
* ── Why there's no leveraged/perp action here ──────────────────────────────
* A public clone-and-run demo must not ship code that opens leveraged positions:
* anyone who runs it would inherit that behavior, and a misconfigured key + an
* auto-firing trigger is a great way to lose money fast. The perp integration is
* left as a clearly-marked, non-functional stub below (see openLeveragedPosition).
*/
import type { Keypair } from "@solana/web3.js";
import type { Hub } from "./hub";
import { USDC_MINT, USDC_DECIMALS } from "./config";
import { toAtomic } from "./jupiter";
import { marketBuyExactIn } from "./swap";
import { shortAddr, explorerTx, round2 } from "./util";
import type { ActionResult, Direction, Mode, TriggerActionKind } from "../shared/events";
/** Everything about the fill. This is what your strategy reacts to. */
export interface TriggerEvent {
priceUsd: number | null;
at: number;
orderAccount: string | null;
/** the fill transaction signature, if known */
signature: string | null;
direction: Direction | null;
sizeUsd: number;
mode: Mode;
}
/** What the hook is allowed to use: the configured action + the deps it needs. */
export interface TriggerContext {
hub: Hub;
action: TriggerActionKind;
webhookUrl: string | null;
swapUsd: number;
realMoney: boolean;
// for the `swap` action:
rpcUrl: string | null;
swapBase: string;
apiKey: string | null;
keypair: Keypair | null;
btcMint: string;
/** replay → describe the action, never execute real side effects */
simulate?: boolean;
}
/** The entry point. Returns a structured result that drives the banner's action chip. */
export async function onTrigger(ctx: TriggerContext, event: TriggerEvent): Promise<ActionResult> {
switch (ctx.action) {
case "webhook":
return runWebhook(ctx, event);
case "swap":
return runSwap(ctx, event);
case "log":
default:
return runLog(ctx, event);
}
}
const priceLabel = (p: number | null): string => (p != null ? `$${round2(p).toFixed(2)}` : "your level");
// ── log (default) — no side effects ───────────────────────────────────────────
function runLog(ctx: TriggerContext, e: TriggerEvent): ActionResult {
ctx.hub.strategy(
`Strategy hook (log): canary filled — BTC ${priceLabel(e.priceUsd)}` +
(e.signature ? ` · fill ${shortAddr(e.signature)}` : "") +
". No action taken.",
);
ctx.hub.strategy("Wire your own logic in src/server/onTrigger.ts — or set TRIGGER_ACTION=webhook | swap.");
return { action: "log", ok: true, label: ctx.simulate ? "logged (replay)" : "logged", simulated: ctx.simulate };
}
// ── webhook — POST the fill JSON somewhere ─────────────────────────────────────
async function runWebhook(ctx: TriggerContext, e: TriggerEvent): Promise<ActionResult> {
if (ctx.simulate) {
ctx.hub.strategy("Strategy hook (webhook): would POST the fill to your webhook (replay — not sent).");
return { action: "webhook", ok: true, label: "would webhook", simulated: true };
}
if (!ctx.webhookUrl) {
ctx.hub.log("error", "Webhook action: TRIGGER_WEBHOOK_URL is not set.");
return { action: "webhook", ok: false, label: "webhook: no URL" };
}
// Shape the body so Slack (`text`), Discord (`content`) and bare consumers (`event`) are all happy.
const summary = `🐤 canary triggered — BTC ${priceLabel(e.priceUsd)} (${e.mode})`;
const body = { text: summary, content: summary, event: e };
try {
const res = await fetch(ctx.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
ctx.hub.log("warn", `Webhook returned HTTP ${res.status}.`);
return { action: "webhook", ok: false, label: `webhook: HTTP ${res.status}` };
}
ctx.hub.strategy("Strategy hook (webhook): fill POSTed to your webhook ✓");
return { action: "webhook", ok: true, label: "webhook sent" };
} catch (err) {
ctx.hub.log("error", `Webhook failed: ${(err as Error).message}`);
return { action: "webhook", ok: false, label: "webhook failed" };
}
}
// ── swap — a REAL, small spot buy (gated + capped) ─────────────────────────────
async function runSwap(ctx: TriggerContext, _e: TriggerEvent): Promise<ActionResult> {
const usd = ctx.swapUsd;
if (ctx.simulate) {
ctx.hub.strategy(`Strategy hook (swap): would market-buy ~$${usd.toFixed(2)} more BTC (replay — no trade).`);
return { action: "swap", ok: true, label: `would swap $${usd.toFixed(2)}`, simulated: true };
}
// Belt-and-suspenders: config validation already enforces these for armed/live.
if (!ctx.realMoney) {
ctx.hub.log("warn", "Swap action requires I_UNDERSTAND_REAL_MONEY=true — skipping.");
return { action: "swap", ok: false, label: "swap disabled" };
}
if (!ctx.keypair || !ctx.rpcUrl) {
ctx.hub.log("error", "Swap action needs a keypair + RPC.");
return { action: "swap", ok: false, label: "swap: no RPC" };
}
ctx.hub.strategy(`Strategy hook (swap): market-buying ~$${usd.toFixed(2)} more BTC (REAL)…`);
try {
const { signature } = await marketBuyExactIn({
rpcUrl: ctx.rpcUrl,
swapBase: ctx.swapBase,
apiKey: ctx.apiKey,
keypair: ctx.keypair,
inputMint: USDC_MINT,
outputMint: ctx.btcMint,
amountInAtomic: toAtomic(usd, USDC_DECIMALS),
slippageBps: 100,
});
ctx.hub.strategy(`Strategy hook (swap): bought BTC for $${usd.toFixed(2)}. tx ${shortAddr(signature)} — ${explorerTx(signature)}`);
return { action: "swap", ok: true, label: `swapped $${usd.toFixed(2)}`, txSignature: signature };
} catch (err) {
ctx.hub.log("error", `Swap failed: ${(err as Error).message}`);
return { action: "swap", ok: false, label: "swap failed" };
}
}
// ─────────────────────────────────────────────────────────────────────────────
// STUB — intentionally NOT implemented. This is where a perp/leverage hook would
// go (e.g. open a 4× long on a perps venue when the canary fires). It is left
// non-functional on purpose: shipping working leverage execution in a public
// demo means everyone who clones it inherits the ability to auto-open leveraged
// positions from a resting trigger — too easy to lose real money by accident.
// Implement it yourself, behind your own explicit opt-in, if you need it.
//
// async function openLeveragedPosition(ctx: TriggerContext, e: TriggerEvent) {
// // 1) pick a perps venue + market (e.g. BTC-PERP)
// // 2) size from e.priceUsd / your risk model
// // 3) submit the order with the venue's SDK, signed by ctx.keypair
// // 4) return an ActionResult so the UI can show it
// throw new Error("not implemented — see the comment above");
// }
// ─────────────────────────────────────────────────────────────────────────────