@nodiusxyz/solana-agent-kit 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +123 -2
- package/dist/index.d.ts +280 -0
- package/dist/index.js +276 -0
- package/dist/internal/app.d.ts +6 -0
- package/dist/internal/app.js +387 -0
- package/dist/internal/config.d.ts +10 -0
- package/dist/internal/config.js +106 -0
- package/dist/internal/journal.d.ts +13 -0
- package/dist/internal/journal.js +129 -0
- package/dist/internal/monitor.d.ts +12 -0
- package/dist/internal/monitor.js +122 -0
- package/dist/internal/safety.d.ts +37 -0
- package/dist/internal/safety.js +175 -0
- package/dist/internal/transport.d.ts +23 -0
- package/dist/internal/transport.js +96 -0
- package/dist/internal/v2-policy.d.ts +19 -0
- package/dist/internal/v2-policy.js +168 -0
- package/examples/readonly.ts +42 -0
- package/package.json +61 -4
- package/src/index.ts +296 -0
- package/src/internal/app.ts +466 -0
- package/src/internal/config.ts +107 -0
- package/src/internal/journal.ts +140 -0
- package/src/internal/monitor.ts +141 -0
- package/src/internal/safety.ts +231 -0
- package/src/internal/transport.ts +111 -0
- package/src/internal/v2-policy.ts +315 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
export function fingerprint(value: unknown): string {
|
|
5
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
export function outcome(status: any): "confirmed" | "failed" | "unknown" {
|
|
8
|
+
if (status?.err != null) return "failed";
|
|
9
|
+
if (
|
|
10
|
+
status?.confirmationStatus === "confirmed" ||
|
|
11
|
+
status?.confirmationStatus === "finalized"
|
|
12
|
+
)
|
|
13
|
+
return "confirmed";
|
|
14
|
+
return "unknown";
|
|
15
|
+
}
|
|
16
|
+
export class Journal {
|
|
17
|
+
constructor(
|
|
18
|
+
readonly directory: string,
|
|
19
|
+
private readonly credential?: string,
|
|
20
|
+
) {}
|
|
21
|
+
private encode(value: unknown): string {
|
|
22
|
+
const scrub = (v: any): any => {
|
|
23
|
+
const text = (s: string) =>
|
|
24
|
+
this.credential ? s.split(this.credential).join("[REDACTED]") : s;
|
|
25
|
+
if (typeof v === "string") return text(v);
|
|
26
|
+
if (Array.isArray(v)) return v.map(scrub);
|
|
27
|
+
if (v && typeof v === "object")
|
|
28
|
+
return Object.fromEntries(
|
|
29
|
+
Object.entries(v).map(([k, item]) => [text(k), scrub(item)]),
|
|
30
|
+
);
|
|
31
|
+
return v;
|
|
32
|
+
};
|
|
33
|
+
return JSON.stringify(scrub(value));
|
|
34
|
+
}
|
|
35
|
+
private async reserve(kind: string, key: string, value: unknown) {
|
|
36
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
37
|
+
const path = join(this.directory, kind + "-" + fingerprint(key) + ".json");
|
|
38
|
+
const file = await open(path, "wx", 0o600);
|
|
39
|
+
try {
|
|
40
|
+
await file.writeFile(this.encode(value) + "\n");
|
|
41
|
+
await file.sync();
|
|
42
|
+
} finally {
|
|
43
|
+
await file.close();
|
|
44
|
+
}
|
|
45
|
+
const dir = await open(this.directory, "r");
|
|
46
|
+
try {
|
|
47
|
+
await dir.sync();
|
|
48
|
+
} finally {
|
|
49
|
+
await dir.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async begin(id: string, hash: string, metadata: unknown) {
|
|
53
|
+
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id))
|
|
54
|
+
throw new Error("Invalid operation id");
|
|
55
|
+
await this.reserve("operation", id, {
|
|
56
|
+
id,
|
|
57
|
+
metadata,
|
|
58
|
+
state: "unknown",
|
|
59
|
+
phase: "reserved",
|
|
60
|
+
});
|
|
61
|
+
const lockPath = join(this.directory, "intent-lock-" + fingerprint(hash));
|
|
62
|
+
const lock = await open(lockPath, "wx", 0o600);
|
|
63
|
+
try {
|
|
64
|
+
const path = join(
|
|
65
|
+
this.directory,
|
|
66
|
+
"fingerprint-" + fingerprint(hash) + ".json",
|
|
67
|
+
);
|
|
68
|
+
let existing: any;
|
|
69
|
+
try {
|
|
70
|
+
existing = JSON.parse(await readFile(path, "utf8"));
|
|
71
|
+
} catch (e: any) {
|
|
72
|
+
if (e.code !== "ENOENT") throw e;
|
|
73
|
+
}
|
|
74
|
+
if (existing) {
|
|
75
|
+
const previous = await this.read(existing.id);
|
|
76
|
+
if (
|
|
77
|
+
!["confirmed", "failed"].includes(previous.state) ||
|
|
78
|
+
!previous.signature
|
|
79
|
+
)
|
|
80
|
+
throw new Error(
|
|
81
|
+
"Previous identical intent is unresolved; use status, never force/rebuild",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const f = await open(path, "w", 0o600);
|
|
85
|
+
try {
|
|
86
|
+
await f.writeFile(this.encode({ id }) + "\n");
|
|
87
|
+
await f.sync();
|
|
88
|
+
} finally {
|
|
89
|
+
await f.close();
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
await lock.close();
|
|
93
|
+
await unlink(lockPath);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async reserveSignature(signature: string) {
|
|
97
|
+
await this.reserve("signature", signature, { signature });
|
|
98
|
+
}
|
|
99
|
+
async event(id: string, event: Record<string, unknown>) {
|
|
100
|
+
const f = await open(
|
|
101
|
+
join(this.directory, "events-" + fingerprint(id) + ".jsonl"),
|
|
102
|
+
"a",
|
|
103
|
+
0o600,
|
|
104
|
+
);
|
|
105
|
+
try {
|
|
106
|
+
await f.writeFile(
|
|
107
|
+
this.encode({ ...event, at: new Date().toISOString() }) + "\n",
|
|
108
|
+
);
|
|
109
|
+
await f.sync();
|
|
110
|
+
} finally {
|
|
111
|
+
await f.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async read(id: string): Promise<any> {
|
|
115
|
+
const start = JSON.parse(
|
|
116
|
+
await readFile(
|
|
117
|
+
join(this.directory, "operation-" + fingerprint(id) + ".json"),
|
|
118
|
+
"utf8",
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
let text = "";
|
|
122
|
+
try {
|
|
123
|
+
text = await readFile(
|
|
124
|
+
join(this.directory, "events-" + fingerprint(id) + ".jsonl"),
|
|
125
|
+
"utf8",
|
|
126
|
+
);
|
|
127
|
+
} catch (e: any) {
|
|
128
|
+
if (e.code !== "ENOENT") throw e;
|
|
129
|
+
}
|
|
130
|
+
for (const line of text.split("\n").filter(Boolean)) {
|
|
131
|
+
try {
|
|
132
|
+
Object.assign(start, JSON.parse(line));
|
|
133
|
+
} catch {
|
|
134
|
+
start.state = "unknown";
|
|
135
|
+
start.phase = "corrupt-journal";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return start;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { wsUrlFor } from "@nodiusxyz/sdk";
|
|
3
|
+
import { Transport, finite } from "./transport.js";
|
|
4
|
+
export interface MonitorOptions {
|
|
5
|
+
seconds: number;
|
|
6
|
+
maxFeeCredits?: number;
|
|
7
|
+
maxEvents: number;
|
|
8
|
+
filters: Record<string, unknown>;
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
}
|
|
11
|
+
export async function monitor(
|
|
12
|
+
origin: string,
|
|
13
|
+
apiKey: string,
|
|
14
|
+
o: MonitorOptions,
|
|
15
|
+
emit: (v: any) => void,
|
|
16
|
+
) {
|
|
17
|
+
if (
|
|
18
|
+
!apiKey ||
|
|
19
|
+
!Number.isFinite(o.seconds) ||
|
|
20
|
+
o.seconds <= 0 ||
|
|
21
|
+
o.seconds > 300 ||
|
|
22
|
+
!Number.isSafeInteger(o.maxEvents) ||
|
|
23
|
+
o.maxEvents <= 0 ||
|
|
24
|
+
o.maxEvents > 1000
|
|
25
|
+
)
|
|
26
|
+
throw new Error("Monitor requires auth, <=300 seconds and <=1000 events");
|
|
27
|
+
const price = await new Transport(origin, apiKey).pricing();
|
|
28
|
+
const m = price.methods;
|
|
29
|
+
const upper =
|
|
30
|
+
finite(m.subscribeSignal) +
|
|
31
|
+
finite(m.signalNotification) * o.maxEvents +
|
|
32
|
+
finite(m.signalUnsubscribe);
|
|
33
|
+
if (
|
|
34
|
+
!Number.isSafeInteger(upper) ||
|
|
35
|
+
(o.maxFeeCredits !== undefined &&
|
|
36
|
+
(!Number.isSafeInteger(o.maxFeeCredits) ||
|
|
37
|
+
o.maxFeeCredits < 0 ||
|
|
38
|
+
upper > o.maxFeeCredits))
|
|
39
|
+
)
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Monitor live estimate exceeds fee budget or unsafe pricing",
|
|
42
|
+
);
|
|
43
|
+
emit({
|
|
44
|
+
state: "monitor-plan",
|
|
45
|
+
seconds: o.seconds,
|
|
46
|
+
max_events: o.maxEvents,
|
|
47
|
+
estimated_stream_credits: upper,
|
|
48
|
+
note: "Server may bill queued events beyond locally received count; not a strict billing cap.",
|
|
49
|
+
});
|
|
50
|
+
const ws = new WebSocket(wsUrlFor(origin), {
|
|
51
|
+
headers: { "X-Api-Key": apiKey },
|
|
52
|
+
handshakeTimeout: Math.min(o.seconds * 1000, 10000),
|
|
53
|
+
});
|
|
54
|
+
let next = 0,
|
|
55
|
+
count = 0,
|
|
56
|
+
sub: string | undefined;
|
|
57
|
+
const pending = new Map<
|
|
58
|
+
number,
|
|
59
|
+
{ resolve: (v: any) => void; reject: (e: Error) => void }
|
|
60
|
+
>();
|
|
61
|
+
let end!: () => void;
|
|
62
|
+
const done = new Promise<void>((r) => (end = r));
|
|
63
|
+
let deadlineReject!: (e: Error) => void;
|
|
64
|
+
const deadline = new Promise<never>((_, r) => (deadlineReject = r));
|
|
65
|
+
deadline.catch(() => {});
|
|
66
|
+
const expire = () => {
|
|
67
|
+
end();
|
|
68
|
+
deadlineReject(new Error("Monitor deadline/cancellation"));
|
|
69
|
+
};
|
|
70
|
+
const timer = setTimeout(expire, o.seconds * 1000);
|
|
71
|
+
o.signal?.addEventListener("abort", expire, { once: true });
|
|
72
|
+
ws.on("message", (b) => {
|
|
73
|
+
let q: any;
|
|
74
|
+
try {
|
|
75
|
+
q = JSON.parse(b.toString());
|
|
76
|
+
} catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (pending.has(q.id)) {
|
|
80
|
+
const p = pending.get(q.id)!;
|
|
81
|
+
pending.delete(q.id);
|
|
82
|
+
if (q.error) p.reject(new Error("Signals RPC rejected"));
|
|
83
|
+
else p.resolve(q.result);
|
|
84
|
+
} else if (
|
|
85
|
+
sub &&
|
|
86
|
+
q.method === "signalNotification" &&
|
|
87
|
+
q.params?.subscription === sub &&
|
|
88
|
+
count < o.maxEvents
|
|
89
|
+
) {
|
|
90
|
+
count++;
|
|
91
|
+
emit(q.params.result);
|
|
92
|
+
if (count === o.maxEvents) end();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
ws.on("error", () => expire());
|
|
96
|
+
ws.on("close", () => expire());
|
|
97
|
+
const call = (method: string, params: unknown[]) =>
|
|
98
|
+
new Promise<any>((resolve, reject) => {
|
|
99
|
+
const id = ++next;
|
|
100
|
+
pending.set(id, { resolve, reject });
|
|
101
|
+
ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }), (e) => {
|
|
102
|
+
if (e) {
|
|
103
|
+
pending.delete(id);
|
|
104
|
+
reject(new Error("Signals send failed"));
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
await Promise.race([
|
|
110
|
+
new Promise<void>((resolve, reject) => {
|
|
111
|
+
ws.once("open", resolve);
|
|
112
|
+
ws.once("error", () => reject(new Error("Signals connection failed")));
|
|
113
|
+
}),
|
|
114
|
+
deadline,
|
|
115
|
+
]);
|
|
116
|
+
sub = String(
|
|
117
|
+
await Promise.race([call("subscribeSignal", [o.filters]), deadline]),
|
|
118
|
+
);
|
|
119
|
+
await done;
|
|
120
|
+
return { state: "monitor-complete", events: count, subscription: sub };
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
o.signal?.removeEventListener("abort", expire);
|
|
124
|
+
if (sub && ws.readyState === WebSocket.OPEN) {
|
|
125
|
+
let cleanup: NodeJS.Timeout | undefined;
|
|
126
|
+
try {
|
|
127
|
+
await Promise.race([
|
|
128
|
+
call("signalUnsubscribe", [sub]),
|
|
129
|
+
new Promise<void>((r) => (cleanup = setTimeout(r, 500))),
|
|
130
|
+
]);
|
|
131
|
+
} catch {
|
|
132
|
+
/* best effort unsubscribe, close still guaranteed */
|
|
133
|
+
} finally {
|
|
134
|
+
if (cleanup) clearTimeout(cleanup);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
ws.terminate();
|
|
138
|
+
for (const p of pending.values()) p.reject(new Error("Monitor closed"));
|
|
139
|
+
pending.clear();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PublicKey,
|
|
3
|
+
VersionedTransaction,
|
|
4
|
+
VersionedMessage,
|
|
5
|
+
TransactionMessage,
|
|
6
|
+
ComputeBudgetProgram,
|
|
7
|
+
} from "@solana/web3.js";
|
|
8
|
+
import bs58 from "bs58";
|
|
9
|
+
import { verifyV2 } from "./v2-policy.js";
|
|
10
|
+
import nacl from "tweetnacl";
|
|
11
|
+
export const PUMP = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P";
|
|
12
|
+
const ATA = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
|
|
13
|
+
const TOKEN = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
|
14
|
+
const TOKEN2022 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
|
15
|
+
export function decodeBase64(s: string): Buffer {
|
|
16
|
+
const b = Buffer.from(s, "base64");
|
|
17
|
+
if (!s || b.toString("base64") !== s) throw new Error("Noncanonical base64");
|
|
18
|
+
return b;
|
|
19
|
+
}
|
|
20
|
+
export function decodeBuilderUnsigned(encoded: string): VersionedTransaction {
|
|
21
|
+
const bytes = decodeBase64(encoded);
|
|
22
|
+
if (bytes.length > 1232)
|
|
23
|
+
throw new Error("Builder transaction exceeds packet limit");
|
|
24
|
+
if (bytes[0] === 0) {
|
|
25
|
+
// A canonical zero-count signature shortvec is one byte. Never relax the
|
|
26
|
+
// already-signed send parser or normalize any nonzero signature count.
|
|
27
|
+
const message = VersionedMessage.deserialize(bytes.subarray(1));
|
|
28
|
+
if (
|
|
29
|
+
message.header.numRequiredSignatures !== 1 ||
|
|
30
|
+
!Buffer.from(message.serialize()).equals(bytes.subarray(1))
|
|
31
|
+
)
|
|
32
|
+
throw new Error("Noncanonical unsigned message or signer count");
|
|
33
|
+
return new VersionedTransaction(message);
|
|
34
|
+
}
|
|
35
|
+
const tx = VersionedTransaction.deserialize(bytes);
|
|
36
|
+
if (!Buffer.from(tx.serialize()).equals(bytes))
|
|
37
|
+
throw new Error("Noncanonical builder transaction");
|
|
38
|
+
return tx;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function inspectEnvelope(tx: VersionedTransaction, wallet: PublicKey) {
|
|
42
|
+
if (tx.message.version !== 0 || tx.message.addressTableLookups.length)
|
|
43
|
+
throw new Error("Unsigned inspection requires v0 without lookup tables");
|
|
44
|
+
if (
|
|
45
|
+
tx.message.header.numRequiredSignatures !== 1 ||
|
|
46
|
+
!tx.message.staticAccountKeys[0]?.equals(wallet)
|
|
47
|
+
)
|
|
48
|
+
throw new Error("Unsigned builder payer/signer mismatch");
|
|
49
|
+
if (
|
|
50
|
+
tx.signatures.length > 1 ||
|
|
51
|
+
tx.signatures.some((s) => s.some((b) => b !== 0))
|
|
52
|
+
)
|
|
53
|
+
throw new Error("Builder must have zero or one empty signature");
|
|
54
|
+
// Server builders may omit all signatures. A zero placeholder only normalizes
|
|
55
|
+
// the unsigned envelope; this never signs or authorizes instruction semantics.
|
|
56
|
+
if (tx.signatures.length === 0) tx.signatures = [new Uint8Array(64)];
|
|
57
|
+
if (tx.serialize().length > 1232)
|
|
58
|
+
throw new Error("Transaction exceeds packet limit");
|
|
59
|
+
const message = TransactionMessage.decompile(tx.message);
|
|
60
|
+
return {
|
|
61
|
+
signing_authorized: false,
|
|
62
|
+
instruction_count: message.instructions.length,
|
|
63
|
+
instructions: message.instructions.map((ix) => ({
|
|
64
|
+
program: ix.programId.toBase58(),
|
|
65
|
+
accounts: ix.keys.map((k) => ({
|
|
66
|
+
address: k.pubkey.toBase58(),
|
|
67
|
+
signer: k.isSigner,
|
|
68
|
+
writable: k.isWritable,
|
|
69
|
+
})),
|
|
70
|
+
data_hex: Buffer.from(ix.data).toString("hex"),
|
|
71
|
+
})),
|
|
72
|
+
note: "Unsigned envelope inspection only; instruction semantics are not authorized for signing.",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Policy {
|
|
77
|
+
side: "buy" | "sell";
|
|
78
|
+
mint: PublicKey;
|
|
79
|
+
maxSol: bigint;
|
|
80
|
+
amountRaw?: bigint;
|
|
81
|
+
maxPriority: bigint;
|
|
82
|
+
maxPriorityLamports?: bigint;
|
|
83
|
+
requestedPriority?: bigint;
|
|
84
|
+
tokensOut?: bigint;
|
|
85
|
+
minOutput?: bigint;
|
|
86
|
+
reportedCost: bigint;
|
|
87
|
+
}
|
|
88
|
+
export function verifyUnsigned(
|
|
89
|
+
tx: VersionedTransaction,
|
|
90
|
+
wallet: PublicKey,
|
|
91
|
+
p: Policy,
|
|
92
|
+
) {
|
|
93
|
+
inspectEnvelope(tx, wallet);
|
|
94
|
+
if (
|
|
95
|
+
tx.message.header.numRequiredSignatures !== 1 ||
|
|
96
|
+
!tx.message.staticAccountKeys[0].equals(wallet)
|
|
97
|
+
)
|
|
98
|
+
throw new Error("Fee payer or signer mismatch");
|
|
99
|
+
if (tx.signatures.some((s) => s.some((b) => b !== 0)))
|
|
100
|
+
throw new Error("Builder must be unsigned");
|
|
101
|
+
if (tx.message.version === 0 && tx.message.addressTableLookups.length)
|
|
102
|
+
throw new Error("Address lookup tables unsupported by policy");
|
|
103
|
+
const msg = TransactionMessage.decompile(tx.message);
|
|
104
|
+
if (
|
|
105
|
+
msg.instructions.some(
|
|
106
|
+
(ix) =>
|
|
107
|
+
ix.programId.toBase58() === PUMP &&
|
|
108
|
+
["b817ee6167c5d33d", "5df6823ce7e940b2"].includes(
|
|
109
|
+
Buffer.from(ix.data).subarray(0, 8).toString("hex"),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
) {
|
|
113
|
+
return verifyV2(tx, wallet, p);
|
|
114
|
+
}
|
|
115
|
+
let trades = 0,
|
|
116
|
+
atas = 0,
|
|
117
|
+
tradeCost = 0n;
|
|
118
|
+
const instructions: any[] = [];
|
|
119
|
+
const seenCompute = new Set<number>();
|
|
120
|
+
let price = 0n,
|
|
121
|
+
limit = 1400000n;
|
|
122
|
+
for (const ix of msg.instructions) {
|
|
123
|
+
const program = ix.programId.toBase58(),
|
|
124
|
+
data = Buffer.from(ix.data);
|
|
125
|
+
instructions.push({
|
|
126
|
+
program,
|
|
127
|
+
accounts: ix.keys.map((k) => ({
|
|
128
|
+
address: k.pubkey.toBase58(),
|
|
129
|
+
writable: k.isWritable,
|
|
130
|
+
signer: k.isSigner,
|
|
131
|
+
})),
|
|
132
|
+
data_hex: data.toString("hex"),
|
|
133
|
+
});
|
|
134
|
+
if (program === ComputeBudgetProgram.programId.toBase58()) {
|
|
135
|
+
if (ix.keys.length || seenCompute.has(data[0]))
|
|
136
|
+
throw new Error("Malformed/duplicate compute instruction");
|
|
137
|
+
seenCompute.add(data[0]);
|
|
138
|
+
if (data[0] === 3 && data.length === 9) {
|
|
139
|
+
price = data.readBigUInt64LE(1);
|
|
140
|
+
if (price > p.maxPriority) throw new Error("Priority fee exceeds cap");
|
|
141
|
+
} else if (data[0] === 2 && (data.length === 5 || data.length === 9)) {
|
|
142
|
+
if (data.length === 9 && data.readUInt32LE(5) !== 0)
|
|
143
|
+
throw new Error("Legacy CU upper bytes must be zero");
|
|
144
|
+
limit = BigInt(data.readUInt32LE(1));
|
|
145
|
+
if (limit <= 0n || limit > 1400000n)
|
|
146
|
+
throw new Error("Compute unit limit exceeds runtime cap");
|
|
147
|
+
} else throw new Error("Unsupported compute instruction");
|
|
148
|
+
} else if (program === ATA && p.side === "buy") {
|
|
149
|
+
if (
|
|
150
|
+
++atas > 1 ||
|
|
151
|
+
data.length !== 1 ||
|
|
152
|
+
data[0] !== 1 ||
|
|
153
|
+
ix.keys.length !== 6 ||
|
|
154
|
+
!ix.keys[0].pubkey.equals(wallet) ||
|
|
155
|
+
!ix.keys[2].pubkey.equals(wallet) ||
|
|
156
|
+
!ix.keys[3].pubkey.equals(p.mint) ||
|
|
157
|
+
ix.keys[4].pubkey.toBase58() !== "11111111111111111111111111111111" ||
|
|
158
|
+
![TOKEN, TOKEN2022].includes(ix.keys[5].pubkey.toBase58())
|
|
159
|
+
)
|
|
160
|
+
throw new Error("ATA policy mismatch");
|
|
161
|
+
const expected = PublicKey.findProgramAddressSync(
|
|
162
|
+
[wallet.toBuffer(), ix.keys[5].pubkey.toBuffer(), p.mint.toBuffer()],
|
|
163
|
+
new PublicKey(ATA),
|
|
164
|
+
)[0];
|
|
165
|
+
if (!ix.keys[1].pubkey.equals(expected))
|
|
166
|
+
throw new Error("ATA derivation mismatch");
|
|
167
|
+
} else if (program === PUMP) {
|
|
168
|
+
if (
|
|
169
|
+
++trades !== 1 ||
|
|
170
|
+
!ix.keys[2]?.pubkey.equals(p.mint) ||
|
|
171
|
+
!ix.keys[6]?.pubkey.equals(wallet)
|
|
172
|
+
)
|
|
173
|
+
throw new Error("Pump mint/user policy mismatch");
|
|
174
|
+
const disc = Buffer.from(
|
|
175
|
+
p.side === "buy"
|
|
176
|
+
? [102, 6, 61, 18, 1, 218, 235, 234]
|
|
177
|
+
: [51, 230, 133, 164, 1, 127, 131, 173],
|
|
178
|
+
);
|
|
179
|
+
if (
|
|
180
|
+
!data.subarray(0, 8).equals(disc) ||
|
|
181
|
+
data.length !== (p.side === "buy" ? 25 : 24) ||
|
|
182
|
+
ix.keys.length !== (p.side === "buy" ? 18 : 16)
|
|
183
|
+
)
|
|
184
|
+
throw new Error(
|
|
185
|
+
"Only tested classic pump layout supported (v2/cashback rejected)",
|
|
186
|
+
);
|
|
187
|
+
const amount = data.readBigUInt64LE(8),
|
|
188
|
+
bound = data.readBigUInt64LE(16);
|
|
189
|
+
if (amount === 0n || bound === 0n)
|
|
190
|
+
throw new Error("Zero trade/slippage guard");
|
|
191
|
+
if (p.side === "buy") {
|
|
192
|
+
tradeCost = bound;
|
|
193
|
+
if (bound > p.maxSol || bound !== p.reportedCost)
|
|
194
|
+
throw new Error(
|
|
195
|
+
"Decoded SOL amount exceeds cap or differs from response",
|
|
196
|
+
);
|
|
197
|
+
} else if (amount !== p.amountRaw || bound !== p.reportedCost)
|
|
198
|
+
throw new Error("Sell amount/min-output mismatch");
|
|
199
|
+
} else throw new Error("Unexpected instruction program: " + program);
|
|
200
|
+
}
|
|
201
|
+
if (trades !== 1) throw new Error("Exactly one trade required");
|
|
202
|
+
if (p.requestedPriority !== undefined && price !== p.requestedPriority)
|
|
203
|
+
throw new Error("Priority differs from requested price");
|
|
204
|
+
if (
|
|
205
|
+
p.maxPriorityLamports !== undefined &&
|
|
206
|
+
(price * limit + 999999n) / 1000000n > p.maxPriorityLamports
|
|
207
|
+
)
|
|
208
|
+
throw new Error("Total priority exceeds lamport cap");
|
|
209
|
+
return {
|
|
210
|
+
tradeCost,
|
|
211
|
+
ataCount: atas,
|
|
212
|
+
instructions,
|
|
213
|
+
limitations:
|
|
214
|
+
"Partial policy: does not validate all pump PDAs, fee recipients, program state or CPI behavior. Not a complete safety proof.",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
export function checkSignature(tx: VersionedTransaction): string {
|
|
218
|
+
const count = tx.message.header.numRequiredSignatures;
|
|
219
|
+
if (count < 1 || tx.signatures.length !== count)
|
|
220
|
+
throw new Error("Missing signatures");
|
|
221
|
+
for (let i = 0; i < count; i++)
|
|
222
|
+
if (
|
|
223
|
+
!nacl.sign.detached.verify(
|
|
224
|
+
tx.message.serialize(),
|
|
225
|
+
tx.signatures[i],
|
|
226
|
+
tx.message.staticAccountKeys[i].toBytes(),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
throw new Error("Invalid transaction signature");
|
|
230
|
+
return bs58.encode(tx.signatures[0]);
|
|
231
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { NodiusClient } from "@nodiusxyz/sdk";
|
|
2
|
+
import { Connection } from "@solana/web3.js";
|
|
3
|
+
import { endpoint, rawAmount } from "./config.js";
|
|
4
|
+
export interface Pricing {
|
|
5
|
+
ok?: boolean;
|
|
6
|
+
credits_per_usdc: number;
|
|
7
|
+
sol_usdc: { price: number; age_secs: number } | null;
|
|
8
|
+
methods: Record<string, any>;
|
|
9
|
+
}
|
|
10
|
+
export function finite(n: unknown): number {
|
|
11
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n < 0)
|
|
12
|
+
throw new Error("Invalid live numeric field");
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
export function estimateBuy(p: Pricing, lamports: bigint): number {
|
|
16
|
+
const c = finite(p.credits_per_usdc),
|
|
17
|
+
q = p.sol_usdc,
|
|
18
|
+
m = p.methods?.executeSnipe;
|
|
19
|
+
if (!q || finite(q.age_secs) > 15 || finite(q.price) <= 0 || !m || c <= 0)
|
|
20
|
+
throw new Error("Fresh authoritative buy pricing unavailable");
|
|
21
|
+
const v =
|
|
22
|
+
finite(m.build_fee_credits) +
|
|
23
|
+
Math.ceil(
|
|
24
|
+
((Number(lamports) / 1e9) * q.price * c * finite(m.flow_fee_bps)) / 10000,
|
|
25
|
+
);
|
|
26
|
+
if (!Number.isSafeInteger(v) || v <= 0 || !Number.isSafeInteger(c) || c <= 0)
|
|
27
|
+
throw new Error("Unsafe pricing estimate/rate");
|
|
28
|
+
return v;
|
|
29
|
+
}
|
|
30
|
+
export function sendFee(p: Pricing): number {
|
|
31
|
+
const m = p.methods?.sendAndConfirm;
|
|
32
|
+
const fee = finite(m?.base_credits) + finite(m?.landed_credits);
|
|
33
|
+
if (!Number.isSafeInteger(fee) || fee <= 0)
|
|
34
|
+
throw new Error("Invalid sendAndConfirm live fee");
|
|
35
|
+
return fee;
|
|
36
|
+
}
|
|
37
|
+
export class Transport {
|
|
38
|
+
readonly sdk: NodiusClient;
|
|
39
|
+
readonly connection: Connection;
|
|
40
|
+
readonly origin: string;
|
|
41
|
+
constructor(
|
|
42
|
+
origin: string,
|
|
43
|
+
private readonly apiKey?: string,
|
|
44
|
+
) {
|
|
45
|
+
this.origin = endpoint(origin);
|
|
46
|
+
this.sdk = new NodiusClient(this.origin, {
|
|
47
|
+
apiKey,
|
|
48
|
+
autoPay: false,
|
|
49
|
+
maxRetries: 0,
|
|
50
|
+
timeout: 10000,
|
|
51
|
+
});
|
|
52
|
+
this.connection = new Connection(this.origin + "/rpc", {
|
|
53
|
+
commitment: "confirmed",
|
|
54
|
+
httpHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
|
|
55
|
+
disableRetryOnRateLimit: true,
|
|
56
|
+
fetch: (url, init) =>
|
|
57
|
+
fetch(url, {
|
|
58
|
+
...init,
|
|
59
|
+
signal: AbortSignal.timeout(10000),
|
|
60
|
+
redirect: "error",
|
|
61
|
+
cache: "no-store",
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async pricing(): Promise<Pricing> {
|
|
66
|
+
const r = await fetch(this.origin + "/pricing", {
|
|
67
|
+
signal: AbortSignal.timeout(10000),
|
|
68
|
+
cache: "no-store",
|
|
69
|
+
redirect: "error",
|
|
70
|
+
});
|
|
71
|
+
if (!r.ok) throw new Error("Pricing HTTP failure");
|
|
72
|
+
const p = (await r.json()) as Pricing;
|
|
73
|
+
if (p.ok !== true) throw new Error("Invalid pricing response");
|
|
74
|
+
finite(p.credits_per_usdc);
|
|
75
|
+
return p;
|
|
76
|
+
}
|
|
77
|
+
// The published SDK converts bigint to a JSON STRING, but rpc.rs requires as_u64().
|
|
78
|
+
// Emit validated digits as an unquoted numeric JSON literal, never through Number.
|
|
79
|
+
async sell(
|
|
80
|
+
mint: string,
|
|
81
|
+
amount: string,
|
|
82
|
+
slippage: number,
|
|
83
|
+
priority: number,
|
|
84
|
+
): Promise<any> {
|
|
85
|
+
rawAmount(amount);
|
|
86
|
+
const params = JSON.stringify({
|
|
87
|
+
mint,
|
|
88
|
+
slippage_bps: slippage,
|
|
89
|
+
priority_fee_micro_lamports: priority,
|
|
90
|
+
closeAccount: false,
|
|
91
|
+
});
|
|
92
|
+
const body = `{"jsonrpc":"2.0","id":1,"method":"executeSell","params":[${params.slice(0, -1)},"token_amount":${amount}}]}`;
|
|
93
|
+
const r = await fetch(this.origin + "/rpc", {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: {
|
|
96
|
+
"Content-Type": "application/json",
|
|
97
|
+
...(this.apiKey ? { "X-Api-Key": this.apiKey } : {}),
|
|
98
|
+
},
|
|
99
|
+
body,
|
|
100
|
+
signal: AbortSignal.timeout(10000),
|
|
101
|
+
redirect: "error",
|
|
102
|
+
});
|
|
103
|
+
if (!r.ok)
|
|
104
|
+
throw new Error("Sell build HTTP failure (charge may be uncertain)");
|
|
105
|
+
const v = (await r.json()) as any;
|
|
106
|
+
if (v.error)
|
|
107
|
+
throw new Error("Sell builder rejected; consult account ledger");
|
|
108
|
+
if (!v.result) throw new Error("Malformed sell build result");
|
|
109
|
+
return v.result;
|
|
110
|
+
}
|
|
111
|
+
}
|