@nodiusxyz/solana-agent-kit 0.0.0-stage → 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.
@@ -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
+ }
@@ -0,0 +1,509 @@
1
+ import { PublicKey, VersionedTransaction } from "@solana/web3.js";
2
+ import { Args, required, integer, rawAmount, solLamports } from "./config.js";
3
+ import { Transport, estimateBuy, finite, sendFee } from "./transport.js";
4
+ import { admitBuy } from "./admission.js";
5
+ import { monitor } from "./monitor.js";
6
+ import { Journal, fingerprint, outcome } from "./journal.js";
7
+ import {
8
+ decodeBase64,
9
+ verifyUnsigned,
10
+ checkSignature,
11
+ inspectEnvelope,
12
+ decodeBuilderUnsigned,
13
+ } from "./safety.js";
14
+ function safeLamports(n: unknown): bigint {
15
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 0)
16
+ throw new Error("Unsafe response lamports");
17
+ return BigInt(n);
18
+ }
19
+ function requireAuth(env: NodeJS.ProcessEnv) {
20
+ if (!env.NODIUS_API_KEY)
21
+ throw new Error("Set NODIUS_API_KEY in environment (not CLI)");
22
+ }
23
+ async function identity(t: Transport, wallet: PublicKey) {
24
+ const account = await t.sdk.getBillingAccount();
25
+ if (account.pubkey !== wallet.toBase58())
26
+ throw new Error("API key billing identity does not match wallet");
27
+ return account;
28
+ }
29
+ async function submit(
30
+ t: Transport,
31
+ j: Journal,
32
+ id: string,
33
+ tx: VersionedTransaction,
34
+ emit: (v: any) => void,
35
+ ) {
36
+ const signature = checkSignature(tx);
37
+ await j.reserveSignature(signature);
38
+ await j.event(id, {
39
+ state: "unknown",
40
+ phase: "submission-intent",
41
+ signature,
42
+ });
43
+ emit({ state: "unknown", phase: "submission-intent", signature });
44
+ let state: "confirmed" | "failed" | "unknown" = "unknown";
45
+ try {
46
+ // Exactly one SDK call. maxRetries:0 disables upstream retries, NOT the
47
+ // server's intentional bounded same-byte multi-leg resends.
48
+ const result: any = await t.sdk.call("sendAndConfirm", [
49
+ Buffer.from(tx.serialize()).toString("base64"),
50
+ {
51
+ encoding: "base64",
52
+ maxRetries: 0,
53
+ skipPreflight: false,
54
+ preflightCommitment: "confirmed",
55
+ },
56
+ ]);
57
+ if (result?.signature === signature) {
58
+ const chainError =
59
+ result.transaction_error ?? result.chain_err ?? result.chain_error;
60
+ const landedError =
61
+ Number.isSafeInteger(result.slot) &&
62
+ result.slot >= 0 &&
63
+ typeof result.landing_time_ms === "number" &&
64
+ Number.isFinite(result.landing_time_ms) &&
65
+ result.landing_time_ms >= 0 &&
66
+ typeof result.error === "string" &&
67
+ result.error.length > 0 &&
68
+ !/timed?\s*out|timeout|without confirmation/i.test(result.error);
69
+ const definitive =
70
+ result.status === "failed" ||
71
+ chainError != null ||
72
+ result.definitive_error === true ||
73
+ landedError ||
74
+ (typeof result.error === "string" &&
75
+ /^(Transaction failed on-chain:|Transaction failed:)/.test(
76
+ result.error,
77
+ ));
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";
96
+ }
97
+ }
98
+ await j.event(id, {
99
+ state,
100
+ phase: state === "unknown" ? "submission-uncertain" : "submission-result",
101
+ signature,
102
+ });
103
+ return {
104
+ state,
105
+ operation_id: id,
106
+ signature,
107
+ note:
108
+ state === "unknown"
109
+ ? "Do not rebuild/rebroadcast. Read status and account ledger; no automatic retry."
110
+ : undefined,
111
+ };
112
+ }
113
+ export async function run(
114
+ a: Args,
115
+ env: NodeJS.ProcessEnv = process.env,
116
+ emit: (v: any) => void = () => {},
117
+ bridge?: {
118
+ publicKey: PublicKey;
119
+ signTransaction(tx: VersionedTransaction): Promise<VersionedTransaction>;
120
+ },
121
+ ): Promise<any> {
122
+ const origin =
123
+ typeof a.endpoint === "string"
124
+ ? a.endpoint
125
+ : (env.NODIUS_ENDPOINT ?? "https://rpc.nodius.xyz");
126
+ if (a.command === "help")
127
+ return {
128
+ commands: [
129
+ "recent",
130
+ "monitor",
131
+ "inspect",
132
+ "migrate",
133
+ "buy",
134
+ "sell",
135
+ "send",
136
+ "status",
137
+ ],
138
+ note: "See README. Trading defaults to a read-only plan; monitoring is a paid stream.",
139
+ };
140
+ const t = new Transport(origin, env.NODIUS_API_KEY);
141
+ if (a.command === "monitor") {
142
+ requireAuth(env);
143
+ const filters: Record<string, unknown> = {};
144
+ if (a.mint) filters.mint = new PublicKey(required(a, "mint")).toBase58();
145
+ if (a.kinds) {
146
+ const kinds = required(a, "kinds").split(",");
147
+ if (
148
+ kinds.some(
149
+ (k) =>
150
+ !["newPair", "graduation", "walletActivity", "largeSwap"].includes(
151
+ k,
152
+ ),
153
+ ) ||
154
+ new Set(kinds).size !== kinds.length
155
+ )
156
+ throw new Error("Unsupported/duplicate signal kinds");
157
+ filters.kinds = kinds;
158
+ }
159
+ return monitor(
160
+ t.origin,
161
+ env.NODIUS_API_KEY!,
162
+ {
163
+ seconds: integer(String(a.seconds ?? "30"), 300),
164
+ maxEvents: integer(String(a["max-events"] ?? "100"), 1000),
165
+ maxFeeCredits:
166
+ a["max-fee-credits"] === undefined
167
+ ? undefined
168
+ : integer(required(a, "max-fee-credits")),
169
+ filters,
170
+ },
171
+ emit,
172
+ );
173
+ }
174
+ if (a.command === "recent") {
175
+ const limit = integer(String(a.limit ?? "20"), 50);
176
+ if (!limit) throw new Error("Positive limit required");
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;
189
+ }
190
+ if (a.command === "inspect" || a.command === "migrate") {
191
+ requireAuth(env);
192
+ const wallet = new PublicKey(required(a, "wallet"));
193
+ const [account, balance, onchain, version] = await Promise.all([
194
+ t.sdk.getBillingAccount(),
195
+ t.connection.getBalance(wallet),
196
+ t.connection.getAccountInfo(wallet),
197
+ t.connection.getVersion(),
198
+ ]);
199
+ return {
200
+ state: "read-only",
201
+ wallet: wallet.toBase58(),
202
+ billing_identity_matches: account.pubkey === wallet.toBase58(),
203
+ billing_account: account,
204
+ balance_lamports: balance,
205
+ onchain_account: onchain,
206
+ version,
207
+ migration:
208
+ "new Connection(origin + /rpc, {httpHeaders: {X-Api-Key: process.env.NODIUS_API_KEY}, disableRetryOnRateLimit: true})",
209
+ };
210
+ }
211
+ const j = new Journal(
212
+ typeof a.journal === "string"
213
+ ? a.journal
214
+ : (env.NODIUS_JOURNAL ?? ".nodius-journal"),
215
+ env.NODIUS_API_KEY,
216
+ );
217
+ if (a.command === "status") {
218
+ requireAuth(env);
219
+ const record = await j.read(required(a, "operation-id"));
220
+ if (!record.signature)
221
+ return {
222
+ ...record,
223
+ state: record.state === "built" ? "built" : "unknown",
224
+ note: "No known signature: check account ledger. No rebuild/resume performed.",
225
+ };
226
+ let chain: any = null;
227
+ try {
228
+ const status = (await t.sdk.getSignatureStatuses([
229
+ record.signature,
230
+ ])) as any;
231
+ chain = status?.value?.[0] ?? null;
232
+ } catch {
233
+ /* Read failure does not authorize another send. */
234
+ }
235
+ const observed = outcome(chain);
236
+ const state = ["confirmed", "failed"].includes(record.state)
237
+ ? record.state
238
+ : observed;
239
+ await j.event(record.id, {
240
+ state,
241
+ phase: "status-reconciled",
242
+ signature: record.signature,
243
+ });
244
+ return { ...record, state, chain_status: chain };
245
+ }
246
+ if (a.command === "send") {
247
+ const bytes = decodeBase64(required(a, "signed-tx-base64"));
248
+ if (bytes.length > 1232)
249
+ throw new Error("Signed transaction exceeds packet limit");
250
+ let tx: VersionedTransaction;
251
+ try {
252
+ tx = VersionedTransaction.deserialize(bytes);
253
+ } catch {
254
+ throw new Error("Malformed signed transaction");
255
+ }
256
+ if (!Buffer.from(tx.serialize()).equals(bytes))
257
+ throw new Error("Noncanonical signed transaction");
258
+ const signature = checkSignature(tx);
259
+ const wallet = new PublicKey(required(a, "wallet"));
260
+ if (
261
+ tx.message.header.numRequiredSignatures !== 1 ||
262
+ !tx.message.staticAccountKeys[0].equals(wallet)
263
+ )
264
+ throw new Error("Signed transaction payer/signer mismatch");
265
+ const maxFee = integer(required(a, "max-fee-credits"));
266
+ const p = await t.pricing(),
267
+ fee = sendFee(p);
268
+ if (fee > maxFee) throw new Error("Send fee exceeds cap");
269
+ if (!a.execute)
270
+ return {
271
+ state: "plan",
272
+ signature,
273
+ send_fee_credits: fee,
274
+ note: "Existing signed transaction must be independently reviewed; this command does not validate trade instructions.",
275
+ };
276
+ requireAuth(env);
277
+ await identity(t, wallet);
278
+ const id = required(a, "operation-id");
279
+ await j.begin(id, fingerprint({ origin, signature }), {
280
+ command: "send",
281
+ wallet: wallet.toBase58(),
282
+ });
283
+ return submit(t, j, id, tx, emit);
284
+ }
285
+ if (a.command !== "buy" && a.command !== "sell")
286
+ throw new Error("Unsupported command");
287
+ if (a["build-only"] && !a.execute)
288
+ throw new Error("--build-only requires --execute");
289
+ const side = a.command;
290
+ const mint = new PublicKey(required(a, "mint")),
291
+ wallet = new PublicKey(required(a, "wallet"));
292
+ const maxSol = solLamports(required(a, "max-sol"));
293
+ if (maxSol <= 0n || maxSol > BigInt(Number.MAX_SAFE_INTEGER))
294
+ throw new Error(
295
+ "Max SOL must be positive and safely representable in lamports",
296
+ );
297
+ const amount =
298
+ side === "buy"
299
+ ? solLamports(required(a, "amount-sol"))
300
+ : rawAmount(required(a, "amount-raw"));
301
+ if (
302
+ amount <= 0n ||
303
+ (side === "buy" &&
304
+ (amount > maxSol || amount > BigInt(Number.MAX_SAFE_INTEGER)))
305
+ )
306
+ throw new Error("Amount outside SOL budget");
307
+ const maxFee = integer(required(a, "max-fee-credits")),
308
+ slippage = integer(required(a, "slippage-bps"), 9999),
309
+ priority = integer(required(a, "priority-fee")),
310
+ priorityCap = integer(required(a, "max-priority-fee"));
311
+ if (priority > priorityCap) throw new Error("Priority exceeds cap");
312
+ if (priority > 0 && a["max-priority-lamports"] === undefined)
313
+ throw new Error("Nonzero priority requires --max-priority-lamports");
314
+ const maxPriorityLamports = BigInt(
315
+ integer(String(a["max-priority-lamports"] ?? "0")),
316
+ );
317
+ const p = await t.pricing();
318
+ const sendCredits = sendFee(p);
319
+ const estimate = side === "buy" ? estimateBuy(p, amount) : null;
320
+ const plan = {
321
+ state: "plan",
322
+ side,
323
+ wallet: wallet.toBase58(),
324
+ mint: mint.toBase58(),
325
+ amount_raw: side === "sell" ? amount.toString() : undefined,
326
+ amount_lamports: side === "buy" ? amount.toString() : undefined,
327
+ max_sol_lamports: maxSol.toString(),
328
+ estimated_build_fee_credits: estimate,
329
+ send_fee_credits: sendCredits,
330
+ live_pricing: p,
331
+ limits: {
332
+ max_fee_credits: maxFee,
333
+ slippage_bps: slippage,
334
+ priority_fee_micro_lamports: priority,
335
+ max_priority_fee_micro_lamports: priorityCap,
336
+ },
337
+ warning:
338
+ "Builder is charged BEFORE returning. Buy estimate is not a server-side billing cap. Sell build is unquoted; post-build cap cannot undo its charge. Send failure does not automatically refund a successful builder.",
339
+ };
340
+ if (!a.execute) return plan;
341
+ if (side === "sell" && !a["accept-unquoted-build-charge"])
342
+ throw new Error(
343
+ "Sell charge unquoted: explicit --accept-unquoted-build-charge required BEFORE builder",
344
+ );
345
+ if (
346
+ estimate !== null &&
347
+ estimate + (a["build-only"] ? 0 : sendCredits) > maxFee
348
+ )
349
+ throw new Error("Estimated builder/send credits exceed cap");
350
+ if (!a["build-only"] && !a["ack-incomplete-policy"])
351
+ throw new Error(
352
+ "Partial instruction policy: --ack-incomplete-policy required before signing",
353
+ );
354
+ requireAuth(env);
355
+ if (!a["build-only"] && (!bridge || !bridge.publicKey.equals(wallet)))
356
+ throw new Error("Framework wallet mismatch or missing bridge");
357
+ const account = await identity(t, wallet);
358
+ if (
359
+ finite(account.balance) <
360
+ (estimate ?? 0) + (a["build-only"] ? 0 : sendCredits)
361
+ )
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;
375
+ const id = required(a, "operation-id");
376
+ const intent = {
377
+ origin,
378
+ side,
379
+ mint: mint.toBase58(),
380
+ wallet: wallet.toBase58(),
381
+ amount: amount.toString(),
382
+ slippage,
383
+ priority,
384
+ };
385
+ await j.begin(
386
+ id,
387
+ fingerprint({
388
+ origin,
389
+ side,
390
+ mint: mint.toBase58(),
391
+ wallet: wallet.toBase58(),
392
+ amount: amount.toString(),
393
+ }),
394
+ intent,
395
+ );
396
+ await j.event(id, { state: "unknown", phase: "builder-intent" });
397
+ let result: any;
398
+ try {
399
+ result =
400
+ side === "buy"
401
+ ? await t.sdk.executeSnipe(
402
+ mint.toBase58(),
403
+ Number(amount) / 1e9,
404
+ slippage,
405
+ { priority_fee_micro_lamports: priority },
406
+ )
407
+ : await t.sell(mint.toBase58(), amount.toString(), slippage, priority);
408
+ } catch {
409
+ await j.event(id, { state: "unknown", phase: "builder-uncertain" });
410
+ return {
411
+ state: "unknown",
412
+ operation_id: id,
413
+ note: "Builder response failed. Check ledger for debit/refund; never auto-rebuild. No transaction signed/submitted.",
414
+ };
415
+ }
416
+ await j.event(id, {
417
+ state: "built",
418
+ phase: "builder-returned",
419
+ build_result: {
420
+ mint: new PublicKey(result.mint).toBase58(),
421
+ fee_credits: finite(result.fee_credits),
422
+ ...(side === "buy"
423
+ ? {
424
+ tokens_out: safeLamports(result.tokens_out).toString(),
425
+ max_sol_cost_lamports: safeLamports(
426
+ result.max_sol_cost_lamports,
427
+ ).toString(),
428
+ }
429
+ : {
430
+ min_sol_output_lamports: safeLamports(
431
+ result.min_sol_output_lamports,
432
+ ).toString(),
433
+ }),
434
+ },
435
+ });
436
+ const charged = finite(result.fee_credits);
437
+ if (charged + (a["build-only"] ? 0 : sendCredits) > maxFee)
438
+ throw new Error(
439
+ "Already-charged builder plus send exceeds max credits; NOT signing/submitting (no automatic builder refund)",
440
+ );
441
+ if (result.mint !== mint.toBase58()) throw new Error("Builder mint mismatch");
442
+ const cost = safeLamports(
443
+ side === "buy"
444
+ ? result.max_sol_cost_lamports
445
+ : result.min_sol_output_lamports,
446
+ );
447
+ if (side === "buy" && cost > maxSol)
448
+ throw new Error(
449
+ "Already-charged build exceeds returned SOL cap; NOT signing/submitting",
450
+ );
451
+ const tx = decodeBuilderUnsigned(result.unsigned_tx_base64);
452
+ if (a["build-only"]) {
453
+ const envelope = inspectEnvelope(tx, wallet);
454
+ emit({
455
+ state: "built",
456
+ operation_id: id,
457
+ fee_credits: charged,
458
+ policy: envelope,
459
+ });
460
+ return {
461
+ state: "built",
462
+ operation_id: id,
463
+ fee_credits: charged,
464
+ unsigned_tx_base64: result.unsigned_tx_base64,
465
+ admission,
466
+ policy: envelope,
467
+ note: "Charged unsigned construction only. No signing/submission; instruction semantics are not authorized. No automatic resume/rebuild.",
468
+ };
469
+ }
470
+ const inspected = verifyUnsigned(tx, wallet, {
471
+ side,
472
+ mint,
473
+ maxSol,
474
+ amountRaw: side === "sell" ? amount : undefined,
475
+ maxPriority: BigInt(priorityCap),
476
+ maxPriorityLamports,
477
+ requestedPriority: BigInt(priority),
478
+ tokensOut: side === "buy" ? safeLamports(result.tokens_out) : undefined,
479
+ reportedCost: cost,
480
+ });
481
+ emit({
482
+ state: "built",
483
+ operation_id: id,
484
+ fee_credits: charged,
485
+ policy: inspected,
486
+ });
487
+ // Network fees are queried against this exact message; rent is conservatively
488
+ // reserved for a new token ATA. Pump-side CPI rent remains outside this partial policy.
489
+ const fee = await t.connection.getFeeForMessage(tx.message);
490
+ if (fee.value === null) throw new Error("Message fee unavailable/expired");
491
+ let overhead = safeLamports(fee.value);
492
+ if (inspected.ataCount)
493
+ overhead +=
494
+ BigInt(inspected.ataCount) *
495
+ safeLamports(await t.connection.getMinimumBalanceForRentExemption(165));
496
+ if (inspected.tradeCost + overhead > maxSol)
497
+ throw new Error(
498
+ "Trade + known network/ATA rent exceeds max SOL; NOT signing",
499
+ );
500
+ const originalMessage = Buffer.from(tx.message.serialize());
501
+ const signed = await bridge!.signTransaction(tx);
502
+ if (
503
+ !signed ||
504
+ !Buffer.from(signed.message.serialize()).equals(originalMessage)
505
+ )
506
+ throw new Error("Wallet mutated approved transaction message");
507
+ checkSignature(signed);
508
+ return submit(t, j, id, signed, emit);
509
+ }