@nodiusxyz/solana-agent-kit 0.1.0 → 0.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @nodiusxyz/solana-agent-kit
2
2
 
3
- An opt-in **Solana Agent Kit 2.0.10** plugin for nodius.xyz recent signals, bounded monitoring, buy/sell plans, guarded execution and persistent status recovery. Node.js **22+**, ESM. This is a library plugin, not a CLI trading pack. MIT; only the MIT integration client is included, no server source.
3
+ An opt-in **Solana Agent Kit 2.0.10** plugin for nodius.xyz recent signals, bounded monitoring, buy/sell plans, guarded execution and persistent status recovery. Node.js **20.20.2+**, ESM. This is a library plugin, not a CLI trading pack. MIT licensed.
4
4
 
5
5
  ## Install and drop in
6
6
 
@@ -32,7 +32,7 @@ console.log(await nodius.methods.nodiusRecent({ limit: 5 }));
32
32
 
33
33
  All five names are registered identically in `agent.methods` and `agent.actions`:
34
34
 
35
- - `nodiusRecent({ limit?: number })`: default 20, integer 1–50, recent server response.
35
+ - `nodiusRecent({ limit?: number })`: default 20, integer 1–50. Locally limits returned events even when an older server returns a larger preview; returned `count` matches those events.
36
36
  - `nodiusMonitor({ maxFeeCredits: number, seconds?: number, maxEvents?: number, mint?: string, kinds?: SignalKind[] })`: defaults 30 seconds / 100 events; bounds 1–300 seconds / 1–1000 events. Kinds: `newPair`, `graduation`, `walletActivity`, `largeSwap`, no duplicates. Authenticated **paid** stream. Requires a nonnegative safe-integer admission budget: live `subscribe + maxEvents × notification + unsubscribe` estimate must fit **before WS opens**. For example `{ seconds: 3, maxEvents: 3, maxFeeCredits: 100 }`. Returns `{ state: "monitor-complete", events, subscription, notifications, estimated_stream_credits, note }`; closes/unsubscribes in finally. This never triggers trades. Duration is the stream deadline; HTTP pricing lookup can add up to its 10-second timeout and cleanup up to 500ms.
37
37
  - `nodiusTrade(input)`: buy or sell; defaults a **read-only plan**, only fetching pricing. No builder, signature, funding or submission by default.
38
38
  - `nodiusStatus({ operationId: string })`: reads the configured persistent journal and, if a signature exists, reconciles chain status. Never rebuilds, resumes or rebroadcasts. A missing signature is not permission to retry; inspect the billing ledger separately.
@@ -81,10 +81,18 @@ For executed trades, billing identity must match the wallet. The SDK uses `maxRe
81
81
 
82
82
  This is a **partial policy**, not a complete on-chain proof: dynamic program state, fee-recipient governance, program upgrades, Token-2022 extensions and all CPI behavior/rent are not fully verified. `ackIncompletePolicy: true` explicitly accepts these limitations, not arbitrary instructions. Slippage arguments are forwarded and returned instruction bounds checked; they are not an independent market-price oracle. `maxSol` is not a guaranteed cap on every possible CPI effect.
83
83
 
84
- Pricing is fetched fresh from `/pricing`. Buy build fee is estimated with a SOL quote no older than 15 seconds. Builder fees are charged **before** the response arrives. The estimated buy cap is **not a server-side billing cap**; the post-build check cannot undo a builder debit. Sell construction is unquoted and requires the explicit charge acknowledgement. Send failure does not automatically refund a successful build. Signals billing may include events queued beyond the locally received count; duration/event limits are **not** hard server billing caps. Keep account credit balances bounded and review ledger/transaction details.
84
+ Pricing is fetched fresh from `/pricing`. Buy build fee is estimated from **`amountSol`**, not `maxSol`, with a SOL quote no older than 15 seconds. `maxSol` is the aggregate safety ceiling; raising it does not raise the requested trade size or its fee estimate. Before charged buy construction (including `buildOnly`), live mint/curve and buyer-associated account data plus live rent prices admit the slippage-bound trade, known missing/growing account rent, and a conservative network/priority allowance. Token-2022 account sizes use SPL extension sizing, including metadata-extended mints; v2 zero/WSOL quote layouts reserve the known WSOL and volume-associated accounts. Missing/unsupported curve layouts fail before construction. Admission failures report required lamports and trade/rent/network components; unsigned results include an `admission` breakdown. This is a conservative partial check, not an exact rent multiplier or complete CPI-spend guarantee. Send admission uses the live landed **total** rather than adding the base fee twice. Builder fees are charged **before** the response arrives. The estimated buy cap is **not a server-side billing cap**; the post-build check cannot undo a builder debit. Sell construction is unquoted and requires the explicit charge acknowledgement. Send failure does not automatically refund a successful build. Signals billing may include events queued beyond the locally received count; duration/event limits are **not** hard server billing caps. Keep account credit balances bounded and review ledger/transaction details.
85
85
 
86
86
  There is exactly one client `sendAndConfirm` invocation per submitted operation. The server can intentionally make bounded same-byte multi-leg resends; this is not an SDK retry. Journal logical economic intent is origin/wallet/side/mint/amount; changing slippage or priority cannot bypass an unresolved reservation (different amounts are different intents, not a global exactly-once guarantee). Operation/intent reservations precede charged construction, and signature reservations are permanent before submission. Transport failures produce `unknown`, never an automatic rebuild/rebroadcast. Preserve the journal across restarts; do not delete it or change directories to bypass guards. Prefer a private directory on durable local storage (created 0700, files 0600), never shared across unrelated wallets. Journal records store only allowlisted validated builder summaries, not arbitrary response fields or unsigned transaction payloads, and redact the current credential before writes. Journal records include intent/build details; protect them like financial records. Status reconciliation is observation, not an execution authorization. Reusing a transaction signature is always blocked, including after terminal outcomes.
87
87
 
88
+ ### Unsigned decoder and sell rent recovery
89
+
90
+ `decodeBuilderUnsigned(unsignedTxBase64)` is exported from the package root. It accepts the canonical provider zero-signature envelope and returns a `VersionedTransaction` with an empty signing placeholder. This decoding does **not** authorize signing or validate trade instruction semantics; independently inspect/review the result. Signed-send validation remains strict and does not normalize zero signatures.
91
+
92
+ Sell requests omit `closeAccount` and honor the server default: a full-balance sell can append own-base-ATA rent recovery; partial sells leave the account open. No close override is exposed. Classic and v2 signing policies permit only the expected derived base ATA, the correct token program, and exactly three close accounts (base ATA, wallet destination, wallet authority), with the close after the sell. Arbitrary accounts/destinations/authorities and reordered closes are rejected. Inventory eligibility is server/on-chain state, not an offline client proof.
93
+
94
+ `nodiusStatus` reports a missing local ID as `Operation not found`, without filesystem paths. Malformed signed wire data reports `Malformed signed transaction` rather than parser internals. Signature-bound definitive `status: failed`/transaction errors are terminal failures; explicit `status: unknown` remains uncertain regardless of diagnostic details. Status recovery recognizes on-chain `err` as failed without sending again.
95
+
88
96
  ## Framework tools without hosted LLM credentials
89
97
 
90
98
  Programmatic methods above need **no LLM API key**. If desired, convert registered actions offline using published framework helpers:
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { SolanaAgentKit } from "solana-agent-kit";
2
2
  import { z } from "zod";
3
+ export { decodeBuilderUnsigned } from "./internal/safety.js";
3
4
  export interface NodiusOptions {
4
5
  apiKey?: string;
5
6
  endpoint?: string;
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { run } from "./internal/app.js";
3
3
  import { endpoint as validateEndpoint } from "./internal/config.js";
4
+ export { decodeBuilderUnsigned } from "./internal/safety.js";
4
5
  const integer = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
5
6
  const decimal = z.string().regex(/^(0|[1-9]\d*)(\.\d{1,9})?$/);
6
7
  const base = {
@@ -0,0 +1,11 @@
1
+ import { PublicKey } from "@solana/web3.js";
2
+ import type { Transport } from "./transport.js";
3
+ /** Conservative admission, not a guarantee against dynamic CPI/fee changes. */
4
+ export declare function admitBuy(t: Transport, wallet: PublicKey, mint: PublicKey, amount: bigint, slippage: number, maxSol: bigint, priority: bigint): Promise<{
5
+ trade_bound_lamports: string;
6
+ account_rent_lamports: string;
7
+ rent_accounts: Record<string, string>;
8
+ network_fee_bound_lamports: string;
9
+ required_lamports: string;
10
+ note: string;
11
+ }>;
@@ -0,0 +1,96 @@
1
+ import { PublicKey } from "@solana/web3.js";
2
+ import { unpackMint, getAccountLenForMint, getAccountLen, getExtensionTypes, getAccountTypeOfMintType, ExtensionType, } from "@solana/spl-token";
3
+ const PUMP = new PublicKey("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P");
4
+ const ATA = new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
5
+ const TOKEN = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
6
+ const TOKEN22 = new PublicKey("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb");
7
+ const WSOL = new PublicKey("So11111111111111111111111111111111111111112");
8
+ function lamports(n) {
9
+ if (!Number.isSafeInteger(n) || n < 0)
10
+ throw Error("Unsafe admission lamports");
11
+ return BigInt(n);
12
+ }
13
+ /** Conservative admission, not a guarantee against dynamic CPI/fee changes. */
14
+ export async function admitBuy(t, wallet, mint, amount, slippage, maxSol, priority) {
15
+ const mintInfo = await t.connection.getAccountInfo(mint);
16
+ if (!mintInfo ||
17
+ mintInfo.executable ||
18
+ ![TOKEN, TOKEN22].some((p) => p.equals(mintInfo.owner)))
19
+ throw Error("Buy admission mint account unavailable/unsupported");
20
+ let baseSize;
21
+ try {
22
+ const parsed = unpackMint(mint, mintInfo, mintInfo.owner);
23
+ if (!parsed.isInitialized)
24
+ throw Error("uninitialized");
25
+ baseSize = getAccountLenForMint(parsed);
26
+ if (mintInfo.owner.equals(TOKEN22))
27
+ baseSize = Math.max(baseSize, getAccountLen([
28
+ ...getExtensionTypes(parsed.tlvData)
29
+ .map(getAccountTypeOfMintType)
30
+ .filter((t) => t !== ExtensionType.Uninitialized),
31
+ ExtensionType.ImmutableOwner,
32
+ ]));
33
+ }
34
+ catch {
35
+ throw Error("Buy admission mint layout/extensions unsupported before builder");
36
+ }
37
+ const ata = (owner, m, token) => PublicKey.findProgramAddressSync([owner.toBuffer(), token.toBuffer(), m.toBuffer()], ATA)[0];
38
+ const pda = (name, key) => PublicKey.findProgramAddressSync([Buffer.from(name), key.toBuffer()], PUMP)[0];
39
+ const uva = pda("user_volume_accumulator", wallet);
40
+ const keys = [
41
+ ata(wallet, mint, mintInfo.owner),
42
+ ata(wallet, WSOL, TOKEN),
43
+ uva,
44
+ ata(uva, WSOL, TOKEN),
45
+ pda("bonding-curve", mint),
46
+ ];
47
+ const accounts = await t.connection.getMultipleAccountsInfo(keys);
48
+ const curve = accounts[4];
49
+ if (!curve ||
50
+ !curve.owner.equals(PUMP) ||
51
+ curve.data.length < 81 ||
52
+ !curve.data
53
+ .subarray(0, 8)
54
+ .equals(Buffer.from([23, 183, 248, 55, 96, 216, 172, 96])))
55
+ throw Error("Buy admission curve unavailable/unsupported before builder");
56
+ const quote = curve.data.subarray(83, 115);
57
+ const v2 = curve.data.length >= 115 &&
58
+ (quote.equals(WSOL.toBuffer()) || quote.every((b) => b === 0));
59
+ // Current Pump IDL UserVolumeAccumulator serialized fields: discriminator8 +
60
+ // user32 + bool1 + four u64/i64 + bool1 + four cashback u64 = 106 bytes.
61
+ // Curve uses its live allocation; these builders do not emit extendAccount.
62
+ const sizes = [baseSize, 165, 106, 165, curve.data.length];
63
+ const names = [
64
+ "base_ata",
65
+ "wsol_ata",
66
+ "user_volume",
67
+ "user_volume_wsol_ata",
68
+ "curve",
69
+ ];
70
+ let rent = 0n;
71
+ const rentAccounts = {};
72
+ for (let i = 0; i < keys.length; i++) {
73
+ if (!v2 && (i === 1 || i === 3))
74
+ continue;
75
+ const info = accounts[i];
76
+ const size = Math.max(sizes[i], info?.data.length ?? 0);
77
+ const minimum = lamports(await t.connection.getMinimumBalanceForRentExemption(size));
78
+ const existing = info ? lamports(info.lamports) : 0n;
79
+ const deficit = minimum > existing ? minimum - existing : 0n;
80
+ rent += deficit;
81
+ rentAccounts[names[i]] = deficit.toString();
82
+ }
83
+ const trade = (amount * BigInt(10000 + slippage) + 9999n) / 10000n;
84
+ const required = trade + rent + 5000n + priority;
85
+ const breakdown = {
86
+ trade_bound_lamports: trade.toString(),
87
+ account_rent_lamports: rent.toString(),
88
+ rent_accounts: rentAccounts,
89
+ network_fee_bound_lamports: (5000n + priority).toString(),
90
+ required_lamports: required.toString(),
91
+ note: "Conservative pre-build admission only; dynamic fees/CPI and account races remain outside this partial policy.",
92
+ };
93
+ if (required > maxSol)
94
+ throw Error(`Trade + conservative network/account rent exceeds max SOL before builder; required ${required} lamports (trade ${trade}, rent ${rent}, network ${5000n + priority})`);
95
+ return breakdown;
96
+ }
@@ -1,6 +1,7 @@
1
1
  import { PublicKey, VersionedTransaction } from "@solana/web3.js";
2
2
  import { required, integer, rawAmount, solLamports } from "./config.js";
3
3
  import { Transport, estimateBuy, finite, sendFee } from "./transport.js";
4
+ import { admitBuy } from "./admission.js";
4
5
  import { monitor } from "./monitor.js";
5
6
  import { Journal, fingerprint, outcome } from "./journal.js";
6
7
  import { decodeBase64, verifyUnsigned, checkSignature, inspectEnvelope, decodeBuilderUnsigned, } from "./safety.js";
@@ -42,7 +43,7 @@ async function submit(t, j, id, tx, emit) {
42
43
  },
43
44
  ]);
44
45
  if (result?.signature === signature) {
45
- const chainError = result.chain_err ?? result.chain_error;
46
+ const chainError = result.transaction_error ?? result.chain_err ?? result.chain_error;
46
47
  const landedError = Number.isSafeInteger(result.slot) &&
47
48
  result.slot >= 0 &&
48
49
  typeof result.landing_time_ms === "number" &&
@@ -51,20 +52,32 @@ async function submit(t, j, id, tx, emit) {
51
52
  typeof result.error === "string" &&
52
53
  result.error.length > 0 &&
53
54
  !/timed?\s*out|timeout|without confirmation/i.test(result.error);
54
- const definitive = chainError != null ||
55
+ const definitive = result.status === "failed" ||
56
+ chainError != null ||
55
57
  result.definitive_error === true ||
56
58
  landedError ||
57
59
  (typeof result.error === "string" &&
58
60
  /^(Transaction failed on-chain:|Transaction failed:)/.test(result.error));
59
- state = definitive
60
- ? "failed"
61
- : result.confirmed === true
62
- ? "confirmed"
63
- : "unknown";
61
+ state =
62
+ result.status === "unknown"
63
+ ? "unknown"
64
+ : definitive
65
+ ? "failed"
66
+ : result.status !== "unknown" && result.confirmed === true
67
+ ? "confirmed"
68
+ : "unknown";
64
69
  }
65
70
  }
66
- catch {
67
- /* HTTP/RPC/timeout errors are ambiguous; no rebroadcast or rebuild. */
71
+ catch (error) {
72
+ // A signature-bound server rejection is definitive even when carried by
73
+ // HTTP 502. Never infer failure from a timeout/message alone, and never
74
+ // persist the SDK error body (it may contain transport details).
75
+ if (error && typeof error === "object" && "details" in error) {
76
+ const details = error.details;
77
+ const rejection = details?.error?.data ?? details;
78
+ if (rejection?.signature === signature && rejection?.status === "failed")
79
+ state = "failed";
80
+ }
68
81
  }
69
82
  await j.event(id, {
70
83
  state,
@@ -124,7 +137,19 @@ export async function run(a, env = process.env, emit = () => { }, bridge) {
124
137
  const limit = integer(String(a.limit ?? "20"), 50);
125
138
  if (!limit)
126
139
  throw new Error("Positive limit required");
127
- return t.sdk.signalsRecent(limit);
140
+ const result = await t.sdk.signalsRecent(limit);
141
+ // Some server versions return a fixed preview window despite the query.
142
+ if (Array.isArray(result))
143
+ return result.slice(0, limit);
144
+ if (Array.isArray(result?.events)) {
145
+ const events = result.events.slice(0, limit);
146
+ return {
147
+ ...result,
148
+ events,
149
+ ...(typeof result.count === "number" ? { count: events.length } : {}),
150
+ };
151
+ }
152
+ return result;
128
153
  }
129
154
  if (a.command === "inspect" || a.command === "migrate") {
130
155
  requireAuth(env);
@@ -183,7 +208,13 @@ export async function run(a, env = process.env, emit = () => { }, bridge) {
183
208
  const bytes = decodeBase64(required(a, "signed-tx-base64"));
184
209
  if (bytes.length > 1232)
185
210
  throw new Error("Signed transaction exceeds packet limit");
186
- const tx = VersionedTransaction.deserialize(bytes);
211
+ let tx;
212
+ try {
213
+ tx = VersionedTransaction.deserialize(bytes);
214
+ }
215
+ catch {
216
+ throw new Error("Malformed signed transaction");
217
+ }
187
218
  if (!Buffer.from(tx.serialize()).equals(bytes))
188
219
  throw new Error("Noncanonical signed transaction");
189
220
  const signature = checkSignature(tx);
@@ -235,7 +266,7 @@ export async function run(a, env = process.env, emit = () => { }, bridge) {
235
266
  const maxPriorityLamports = BigInt(integer(String(a["max-priority-lamports"] ?? "0")));
236
267
  const p = await t.pricing();
237
268
  const sendCredits = sendFee(p);
238
- const estimate = side === "buy" ? estimateBuy(p, maxSol) : null;
269
+ const estimate = side === "buy" ? estimateBuy(p, amount) : null;
239
270
  const plan = {
240
271
  state: "plan",
241
272
  side,
@@ -271,6 +302,9 @@ export async function run(a, env = process.env, emit = () => { }, bridge) {
271
302
  if (finite(account.balance) <
272
303
  (estimate ?? 0) + (a["build-only"] ? 0 : sendCredits))
273
304
  throw new Error("Insufficient billing balance");
305
+ const admission = side === "buy"
306
+ ? await admitBuy(t, wallet, mint, amount, slippage, maxSol, maxPriorityLamports)
307
+ : undefined;
274
308
  const id = required(a, "operation-id");
275
309
  const intent = {
276
310
  origin,
@@ -344,6 +378,7 @@ export async function run(a, env = process.env, emit = () => { }, bridge) {
344
378
  operation_id: id,
345
379
  fee_credits: charged,
346
380
  unsigned_tx_base64: result.unsigned_tx_base64,
381
+ admission,
347
382
  policy: envelope,
348
383
  note: "Charged unsigned construction only. No signing/submission; instruction semantics are not authorized. No automatic resume/rebuild.",
349
384
  };
@@ -106,7 +106,13 @@ export class Journal {
106
106
  }
107
107
  }
108
108
  async read(id) {
109
- const start = JSON.parse(await readFile(join(this.directory, "operation-" + fingerprint(id) + ".json"), "utf8"));
109
+ let start;
110
+ try {
111
+ start = JSON.parse(await readFile(join(this.directory, "operation-" + fingerprint(id) + ".json"), "utf8"));
112
+ }
113
+ catch {
114
+ throw new Error("Operation not found");
115
+ }
110
116
  let text = "";
111
117
  try {
112
118
  text = await readFile(join(this.directory, "events-" + fingerprint(id) + ".jsonl"), "utf8");
@@ -75,6 +75,8 @@ export function verifyUnsigned(tx, wallet, p) {
75
75
  ["b817ee6167c5d33d", "5df6823ce7e940b2"].includes(Buffer.from(ix.data).subarray(0, 8).toString("hex")))) {
76
76
  return verifyV2(tx, wallet, p);
77
77
  }
78
+ let sellToken, sellAta;
79
+ let closes = 0;
78
80
  let trades = 0, atas = 0, tradeCost = 0n;
79
81
  const instructions = [];
80
82
  const seenCompute = new Set();
@@ -146,6 +148,28 @@ export function verifyUnsigned(tx, wallet, p) {
146
148
  }
147
149
  else if (amount !== p.amountRaw || bound !== p.reportedCost)
148
150
  throw new Error("Sell amount/min-output mismatch");
151
+ if (p.side === "sell") {
152
+ sellToken = ix.keys[9].pubkey;
153
+ sellAta = ix.keys[5].pubkey;
154
+ }
155
+ }
156
+ else if (p.side === "sell" && [TOKEN, TOKEN2022].includes(program)) {
157
+ const expected = PublicKey.findProgramAddressSync([wallet.toBuffer(), ix.programId.toBuffer(), p.mint.toBuffer()], new PublicKey(ATA))[0];
158
+ if (++closes !== 1 ||
159
+ trades !== 1 ||
160
+ ix !== msg.instructions[msg.instructions.length - 1] ||
161
+ data.length !== 1 ||
162
+ data[0] !== 9 ||
163
+ ix.keys.length !== 3 ||
164
+ !sellToken?.equals(ix.programId) ||
165
+ !sellAta?.equals(expected) ||
166
+ !ix.keys[0].pubkey.equals(expected) ||
167
+ !ix.keys[0].isWritable ||
168
+ ix.keys[0].isSigner ||
169
+ ix.keys
170
+ .slice(1)
171
+ .some((k) => !k.pubkey.equals(wallet) || !k.isSigner || !k.isWritable))
172
+ throw Error("Sell close account policy mismatch");
149
173
  }
150
174
  else
151
175
  throw new Error("Unexpected instruction program: " + program);
@@ -18,7 +18,9 @@ export function estimateBuy(p, lamports) {
18
18
  }
19
19
  export function sendFee(p) {
20
20
  const m = p.methods?.sendAndConfirm;
21
- const fee = finite(m?.base_credits) + finite(m?.landed_credits);
21
+ const base = finite(m?.base_credits);
22
+ // landed_credits is the terminal TOTAL, not a surcharge on base_credits.
23
+ const fee = Math.max(base, finite(m?.landed_credits));
22
24
  if (!Number.isSafeInteger(fee) || fee <= 0)
23
25
  throw new Error("Invalid sendAndConfirm live fee");
24
26
  return fee;
@@ -71,7 +73,6 @@ export class Transport {
71
73
  mint,
72
74
  slippage_bps: slippage,
73
75
  priority_fee_micro_lamports: priority,
74
- closeAccount: false,
75
76
  });
76
77
  const body = `{"jsonrpc":"2.0","id":1,"method":"executeSell","params":[${params.slice(0, -1)},"token_amount":${amount}}]}`;
77
78
  const r = await fetch(this.origin + "/rpc", {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@nodiusxyz/solana-agent-kit",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Opt-in bounded nodius.xyz plugin for Solana Agent Kit",
6
6
  "license": "MIT",
7
7
  "engines": {
8
- "node": ">=22"
8
+ "node": ">=20.20.2"
9
9
  },
10
10
  "main": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts",
@@ -31,6 +31,7 @@
31
31
  "dependencies": {
32
32
  "@nodiusxyz/sdk": "0.2.15",
33
33
  "@solana/web3.js": "1.99.0",
34
+ "@solana/spl-token": "0.4.15",
34
35
  "bs58": "6.0.0",
35
36
  "tweetnacl": "1.0.3",
36
37
  "ws": "8.22.0",
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { SolanaAgentKit, Plugin } from "solana-agent-kit";
2
2
  import { z } from "zod";
3
3
  import { run } from "./internal/app.js";
4
4
  import { endpoint as validateEndpoint, type Args } from "./internal/config.js";
5
+ export { decodeBuilderUnsigned } from "./internal/safety.js";
5
6
  export interface NodiusOptions {
6
7
  apiKey?: string;
7
8
  endpoint?: string;
@@ -0,0 +1,131 @@
1
+ import { PublicKey } from "@solana/web3.js";
2
+ import {
3
+ unpackMint,
4
+ getAccountLenForMint,
5
+ getAccountLen,
6
+ getExtensionTypes,
7
+ getAccountTypeOfMintType,
8
+ ExtensionType,
9
+ } from "@solana/spl-token";
10
+ import type { Transport } from "./transport.js";
11
+ const PUMP = new PublicKey("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P");
12
+ const ATA = new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
13
+ const TOKEN = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
14
+ const TOKEN22 = new PublicKey("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb");
15
+ const WSOL = new PublicKey("So11111111111111111111111111111111111111112");
16
+ function lamports(n: number): bigint {
17
+ if (!Number.isSafeInteger(n) || n < 0)
18
+ throw Error("Unsafe admission lamports");
19
+ return BigInt(n);
20
+ }
21
+ /** Conservative admission, not a guarantee against dynamic CPI/fee changes. */
22
+ export async function admitBuy(
23
+ t: Transport,
24
+ wallet: PublicKey,
25
+ mint: PublicKey,
26
+ amount: bigint,
27
+ slippage: number,
28
+ maxSol: bigint,
29
+ priority: bigint,
30
+ ) {
31
+ const mintInfo = await t.connection.getAccountInfo(mint);
32
+ if (
33
+ !mintInfo ||
34
+ mintInfo.executable ||
35
+ ![TOKEN, TOKEN22].some((p) => p.equals(mintInfo.owner))
36
+ )
37
+ throw Error("Buy admission mint account unavailable/unsupported");
38
+ let baseSize: number;
39
+ try {
40
+ const parsed = unpackMint(mint, mintInfo, mintInfo.owner);
41
+ if (!parsed.isInitialized) throw Error("uninitialized");
42
+ baseSize = getAccountLenForMint(parsed);
43
+ if (mintInfo.owner.equals(TOKEN22))
44
+ baseSize = Math.max(
45
+ baseSize,
46
+ getAccountLen([
47
+ ...getExtensionTypes(parsed.tlvData)
48
+ .map(getAccountTypeOfMintType)
49
+ .filter((t) => t !== ExtensionType.Uninitialized),
50
+ ExtensionType.ImmutableOwner,
51
+ ]),
52
+ );
53
+ } catch {
54
+ throw Error(
55
+ "Buy admission mint layout/extensions unsupported before builder",
56
+ );
57
+ }
58
+ const ata = (owner: PublicKey, m: PublicKey, token: PublicKey) =>
59
+ PublicKey.findProgramAddressSync(
60
+ [owner.toBuffer(), token.toBuffer(), m.toBuffer()],
61
+ ATA,
62
+ )[0];
63
+ const pda = (name: string, key: PublicKey) =>
64
+ PublicKey.findProgramAddressSync(
65
+ [Buffer.from(name), key.toBuffer()],
66
+ PUMP,
67
+ )[0];
68
+ const uva = pda("user_volume_accumulator", wallet);
69
+ const keys = [
70
+ ata(wallet, mint, mintInfo.owner),
71
+ ata(wallet, WSOL, TOKEN),
72
+ uva,
73
+ ata(uva, WSOL, TOKEN),
74
+ pda("bonding-curve", mint),
75
+ ];
76
+ const accounts = await t.connection.getMultipleAccountsInfo(keys);
77
+ const curve = accounts[4];
78
+ if (
79
+ !curve ||
80
+ !curve.owner.equals(PUMP) ||
81
+ curve.data.length < 81 ||
82
+ !curve.data
83
+ .subarray(0, 8)
84
+ .equals(Buffer.from([23, 183, 248, 55, 96, 216, 172, 96]))
85
+ )
86
+ throw Error("Buy admission curve unavailable/unsupported before builder");
87
+ const quote = curve.data.subarray(83, 115);
88
+ const v2 =
89
+ curve.data.length >= 115 &&
90
+ (quote.equals(WSOL.toBuffer()) || quote.every((b) => b === 0));
91
+ // Current Pump IDL UserVolumeAccumulator serialized fields: discriminator8 +
92
+ // user32 + bool1 + four u64/i64 + bool1 + four cashback u64 = 106 bytes.
93
+ // Curve uses its live allocation; these builders do not emit extendAccount.
94
+ const sizes = [baseSize, 165, 106, 165, curve.data.length];
95
+ const names = [
96
+ "base_ata",
97
+ "wsol_ata",
98
+ "user_volume",
99
+ "user_volume_wsol_ata",
100
+ "curve",
101
+ ];
102
+ let rent = 0n;
103
+ const rentAccounts: Record<string, string> = {};
104
+ for (let i = 0; i < keys.length; i++) {
105
+ if (!v2 && (i === 1 || i === 3)) continue;
106
+ const info = accounts[i];
107
+ const size = Math.max(sizes[i], info?.data.length ?? 0);
108
+ const minimum = lamports(
109
+ await t.connection.getMinimumBalanceForRentExemption(size),
110
+ );
111
+ const existing = info ? lamports(info.lamports) : 0n;
112
+ const deficit = minimum > existing ? minimum - existing : 0n;
113
+ rent += deficit;
114
+ rentAccounts[names[i]] = deficit.toString();
115
+ }
116
+ const trade = (amount * BigInt(10000 + slippage) + 9999n) / 10000n;
117
+ const required = trade + rent + 5000n + priority;
118
+ const breakdown = {
119
+ trade_bound_lamports: trade.toString(),
120
+ account_rent_lamports: rent.toString(),
121
+ rent_accounts: rentAccounts,
122
+ network_fee_bound_lamports: (5000n + priority).toString(),
123
+ required_lamports: required.toString(),
124
+ note: "Conservative pre-build admission only; dynamic fees/CPI and account races remain outside this partial policy.",
125
+ };
126
+ if (required > maxSol)
127
+ throw Error(
128
+ `Trade + conservative network/account rent exceeds max SOL before builder; required ${required} lamports (trade ${trade}, rent ${rent}, network ${5000n + priority})`,
129
+ );
130
+ return breakdown;
131
+ }
@@ -1,6 +1,7 @@
1
1
  import { PublicKey, VersionedTransaction } from "@solana/web3.js";
2
2
  import { Args, required, integer, rawAmount, solLamports } from "./config.js";
3
3
  import { Transport, estimateBuy, finite, sendFee } from "./transport.js";
4
+ import { admitBuy } from "./admission.js";
4
5
  import { monitor } from "./monitor.js";
5
6
  import { Journal, fingerprint, outcome } from "./journal.js";
6
7
  import {
@@ -54,7 +55,8 @@ async function submit(
54
55
  },
55
56
  ]);
56
57
  if (result?.signature === signature) {
57
- const chainError = result.chain_err ?? result.chain_error;
58
+ const chainError =
59
+ result.transaction_error ?? result.chain_err ?? result.chain_error;
58
60
  const landedError =
59
61
  Number.isSafeInteger(result.slot) &&
60
62
  result.slot >= 0 &&
@@ -65,6 +67,7 @@ async function submit(
65
67
  result.error.length > 0 &&
66
68
  !/timed?\s*out|timeout|without confirmation/i.test(result.error);
67
69
  const definitive =
70
+ result.status === "failed" ||
68
71
  chainError != null ||
69
72
  result.definitive_error === true ||
70
73
  landedError ||
@@ -72,14 +75,25 @@ async function submit(
72
75
  /^(Transaction failed on-chain:|Transaction failed:)/.test(
73
76
  result.error,
74
77
  ));
75
- state = definitive
76
- ? "failed"
77
- : result.confirmed === true
78
- ? "confirmed"
79
- : "unknown";
78
+ state =
79
+ result.status === "unknown"
80
+ ? "unknown"
81
+ : definitive
82
+ ? "failed"
83
+ : result.status !== "unknown" && result.confirmed === true
84
+ ? "confirmed"
85
+ : "unknown";
86
+ }
87
+ } catch (error: unknown) {
88
+ // A signature-bound server rejection is definitive even when carried by
89
+ // HTTP 502. Never infer failure from a timeout/message alone, and never
90
+ // persist the SDK error body (it may contain transport details).
91
+ if (error && typeof error === "object" && "details" in error) {
92
+ const details: any = error.details;
93
+ const rejection = details?.error?.data ?? details;
94
+ if (rejection?.signature === signature && rejection?.status === "failed")
95
+ state = "failed";
80
96
  }
81
- } catch {
82
- /* HTTP/RPC/timeout errors are ambiguous; no rebroadcast or rebuild. */
83
97
  }
84
98
  await j.event(id, {
85
99
  state,
@@ -160,7 +174,18 @@ export async function run(
160
174
  if (a.command === "recent") {
161
175
  const limit = integer(String(a.limit ?? "20"), 50);
162
176
  if (!limit) throw new Error("Positive limit required");
163
- return t.sdk.signalsRecent(limit);
177
+ const result: any = await t.sdk.signalsRecent(limit);
178
+ // Some server versions return a fixed preview window despite the query.
179
+ if (Array.isArray(result)) return result.slice(0, limit);
180
+ if (Array.isArray(result?.events)) {
181
+ const events = result.events.slice(0, limit);
182
+ return {
183
+ ...result,
184
+ events,
185
+ ...(typeof result.count === "number" ? { count: events.length } : {}),
186
+ };
187
+ }
188
+ return result;
164
189
  }
165
190
  if (a.command === "inspect" || a.command === "migrate") {
166
191
  requireAuth(env);
@@ -222,7 +247,12 @@ export async function run(
222
247
  const bytes = decodeBase64(required(a, "signed-tx-base64"));
223
248
  if (bytes.length > 1232)
224
249
  throw new Error("Signed transaction exceeds packet limit");
225
- const tx = VersionedTransaction.deserialize(bytes);
250
+ let tx: VersionedTransaction;
251
+ try {
252
+ tx = VersionedTransaction.deserialize(bytes);
253
+ } catch {
254
+ throw new Error("Malformed signed transaction");
255
+ }
226
256
  if (!Buffer.from(tx.serialize()).equals(bytes))
227
257
  throw new Error("Noncanonical signed transaction");
228
258
  const signature = checkSignature(tx);
@@ -286,7 +316,7 @@ export async function run(
286
316
  );
287
317
  const p = await t.pricing();
288
318
  const sendCredits = sendFee(p);
289
- const estimate = side === "buy" ? estimateBuy(p, maxSol) : null;
319
+ const estimate = side === "buy" ? estimateBuy(p, amount) : null;
290
320
  const plan = {
291
321
  state: "plan",
292
322
  side,
@@ -330,6 +360,18 @@ export async function run(
330
360
  (estimate ?? 0) + (a["build-only"] ? 0 : sendCredits)
331
361
  )
332
362
  throw new Error("Insufficient billing balance");
363
+ const admission =
364
+ side === "buy"
365
+ ? await admitBuy(
366
+ t,
367
+ wallet,
368
+ mint,
369
+ amount,
370
+ slippage,
371
+ maxSol,
372
+ maxPriorityLamports,
373
+ )
374
+ : undefined;
333
375
  const id = required(a, "operation-id");
334
376
  const intent = {
335
377
  origin,
@@ -420,6 +462,7 @@ export async function run(
420
462
  operation_id: id,
421
463
  fee_credits: charged,
422
464
  unsigned_tx_base64: result.unsigned_tx_base64,
465
+ admission,
423
466
  policy: envelope,
424
467
  note: "Charged unsigned construction only. No signing/submission; instruction semantics are not authorized. No automatic resume/rebuild.",
425
468
  };
@@ -112,12 +112,17 @@ export class Journal {
112
112
  }
113
113
  }
114
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
- );
115
+ let start: any;
116
+ try {
117
+ start = JSON.parse(
118
+ await readFile(
119
+ join(this.directory, "operation-" + fingerprint(id) + ".json"),
120
+ "utf8",
121
+ ),
122
+ );
123
+ } catch {
124
+ throw new Error("Operation not found");
125
+ }
121
126
  let text = "";
122
127
  try {
123
128
  text = await readFile(
@@ -112,6 +112,8 @@ export function verifyUnsigned(
112
112
  ) {
113
113
  return verifyV2(tx, wallet, p);
114
114
  }
115
+ let sellToken: PublicKey | undefined, sellAta: PublicKey | undefined;
116
+ let closes = 0;
115
117
  let trades = 0,
116
118
  atas = 0,
117
119
  tradeCost = 0n;
@@ -196,6 +198,32 @@ export function verifyUnsigned(
196
198
  );
197
199
  } else if (amount !== p.amountRaw || bound !== p.reportedCost)
198
200
  throw new Error("Sell amount/min-output mismatch");
201
+ if (p.side === "sell") {
202
+ sellToken = ix.keys[9].pubkey;
203
+ sellAta = ix.keys[5].pubkey;
204
+ }
205
+ } else if (p.side === "sell" && [TOKEN, TOKEN2022].includes(program)) {
206
+ const expected = PublicKey.findProgramAddressSync(
207
+ [wallet.toBuffer(), ix.programId.toBuffer(), p.mint.toBuffer()],
208
+ new PublicKey(ATA),
209
+ )[0];
210
+ if (
211
+ ++closes !== 1 ||
212
+ trades !== 1 ||
213
+ ix !== msg.instructions[msg.instructions.length - 1] ||
214
+ data.length !== 1 ||
215
+ data[0] !== 9 ||
216
+ ix.keys.length !== 3 ||
217
+ !sellToken?.equals(ix.programId) ||
218
+ !sellAta?.equals(expected) ||
219
+ !ix.keys[0].pubkey.equals(expected) ||
220
+ !ix.keys[0].isWritable ||
221
+ ix.keys[0].isSigner ||
222
+ ix.keys
223
+ .slice(1)
224
+ .some((k) => !k.pubkey.equals(wallet) || !k.isSigner || !k.isWritable)
225
+ )
226
+ throw Error("Sell close account policy mismatch");
199
227
  } else throw new Error("Unexpected instruction program: " + program);
200
228
  }
201
229
  if (trades !== 1) throw new Error("Exactly one trade required");
@@ -29,7 +29,9 @@ export function estimateBuy(p: Pricing, lamports: bigint): number {
29
29
  }
30
30
  export function sendFee(p: Pricing): number {
31
31
  const m = p.methods?.sendAndConfirm;
32
- const fee = finite(m?.base_credits) + finite(m?.landed_credits);
32
+ const base = finite(m?.base_credits);
33
+ // landed_credits is the terminal TOTAL, not a surcharge on base_credits.
34
+ const fee = Math.max(base, finite(m?.landed_credits));
33
35
  if (!Number.isSafeInteger(fee) || fee <= 0)
34
36
  throw new Error("Invalid sendAndConfirm live fee");
35
37
  return fee;
@@ -87,7 +89,6 @@ export class Transport {
87
89
  mint,
88
90
  slippage_bps: slippage,
89
91
  priority_fee_micro_lamports: priority,
90
- closeAccount: false,
91
92
  });
92
93
  const body = `{"jsonrpc":"2.0","id":1,"method":"executeSell","params":[${params.slice(0, -1)},"token_amount":${amount}}]}`;
93
94
  const r = await fetch(this.origin + "/rpc", {