@forgedevv/core 0.2.0 → 0.3.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,184 @@
1
+ /**
2
+ * Meteora referral account setup (docs/METEORA.md "The Meteora referral", docs/DECISIONS.md D34).
3
+ *
4
+ * The Meteora referral of every curve swap core builds is a wrapped-SOL token account held by
5
+ * the FORGE revenue wallet (`FORGE_METEORA_REFERRAL_ACCOUNT`). It must be a STANDALONE token
6
+ * account, never the wallet's associated token account: the Jupiter referral claims land in the
7
+ * associated account, and the revenue job books the two separately (`meteora_referral` /
8
+ * `jupiter_referral`).
9
+ *
10
+ * The account lives at an address derived from the fee payer and a fixed seed
11
+ * (`createAccountWithSeed`: no extra keypair to generate or keep, and the address can be
12
+ * re-derived at any time), 165 bytes owned by the SPL Token program, then `InitializeAccount3`
13
+ * sets its mint (wrapped SOL) and its owner (the revenue wallet) from the instruction data. The
14
+ * only signer is the payer: neither the owner nor the new account signs, so the revenue key never
15
+ * has to sign its own setup.
16
+ *
17
+ * Pure builders (byte-for-byte tested) and pure inspection of what the chain holds; the only
18
+ * network call is the optional `readMeteoraReferralAccount`. Any cluster: a WSOL token account
19
+ * exists on devnet too (the devnet proof of the revenue job uses one).
20
+ */
21
+ import { sha256 } from '@noble/hashes/sha2';
22
+ import { ACCOUNT_SIZE, NATIVE_MINT, TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync, unpackAccount, } from '@solana/spl-token';
23
+ import { PublicKey, SystemProgram, Transaction, TransactionInstruction, } from '@solana/web3.js';
24
+ import { priorityFeeInstructions } from './priority-fee.js';
25
+ import { retryRpc } from './reads.js';
26
+ // ---------------------------------------------------------------------------------------------
27
+ // Constants
28
+ // ---------------------------------------------------------------------------------------------
29
+ /** Default seed of the referral account address (`createWithSeed(payer, seed, SPL Token)`). */
30
+ export const METEORA_REFERRAL_ACCOUNT_SEED = 'forge-meteora-referral';
31
+ /** Longest seed `createWithSeed` accepts, in bytes (System program limit). */
32
+ export const MAX_METEORA_REFERRAL_SEED_BYTES = 32;
33
+ /**
34
+ * Compute unit limit of the setup transaction with a priority fee: one `createAccountWithSeed`
35
+ * and one `InitializeAccount3` (a few thousand units each), with a wide margin.
36
+ */
37
+ export const METEORA_REFERRAL_SETUP_COMPUTE_UNITS = 30_000;
38
+ /** SPL Token `InitializeAccount3` instruction tag (u8), followed by the 32-byte owner. */
39
+ export const TOKEN_INITIALIZE_ACCOUNT3_TAG = 18;
40
+ export class MeteoraReferralError extends Error {
41
+ code;
42
+ constructor(code, message, options) {
43
+ super(message, options);
44
+ this.name = 'MeteoraReferralError';
45
+ this.code = code;
46
+ }
47
+ }
48
+ function invalid(message) {
49
+ throw new MeteoraReferralError('invalid_params', message);
50
+ }
51
+ // ---------------------------------------------------------------------------------------------
52
+ // Address
53
+ // ---------------------------------------------------------------------------------------------
54
+ /** Checks a `createWithSeed` seed: 1 to 32 UTF-8 bytes, no control character. */
55
+ export function assertMeteoraReferralSeed(seed) {
56
+ if (typeof seed !== 'string' || seed.length === 0)
57
+ invalid('the seed must be a non-empty string');
58
+ if (/\p{Cc}/u.test(seed))
59
+ invalid('the seed must not contain control characters');
60
+ const bytes = Buffer.byteLength(seed, 'utf8');
61
+ if (bytes > MAX_METEORA_REFERRAL_SEED_BYTES) {
62
+ invalid(`the seed is ${bytes} bytes, at most ${MAX_METEORA_REFERRAL_SEED_BYTES} are allowed`);
63
+ }
64
+ }
65
+ /**
66
+ * Address of the referral account created by `buildCreateMeteoraReferralAccountTx` for `payer`
67
+ * and `seed`: `createWithSeed(payer, seed, SPL Token program)`, i.e.
68
+ * `sha256(payer || seed || program)`, the same derivation as `PublicKey.createWithSeed`
69
+ * (synchronous here, so the builders stay pure).
70
+ */
71
+ export function deriveMeteoraReferralAccount(payer, seed = METEORA_REFERRAL_ACCOUNT_SEED) {
72
+ assertMeteoraReferralSeed(seed);
73
+ const digest = sha256(Buffer.concat([payer.toBuffer(), Buffer.from(seed, 'utf8'), TOKEN_PROGRAM_ID.toBuffer()]));
74
+ return new PublicKey(digest);
75
+ }
76
+ /**
77
+ * The two instructions creating the standalone wrapped-SOL token account of `owner`:
78
+ * System `createAccountWithSeed` (from = base = payer; exactly the rent of 165 bytes; owner
79
+ * program = SPL Token) and SPL Token `InitializeAccount3` (account, wrapped SOL mint; owner in
80
+ * the data, no owner signature). Refuses an owner equal to the payer, a zero rent, and a seed
81
+ * whose address is the owner's associated wrapped-SOL account (impossible in practice: the
82
+ * associated account is a PDA, this address is a hash; checked all the same).
83
+ */
84
+ export function buildCreateMeteoraReferralAccountIxs(p) {
85
+ const seed = p.seed ?? METEORA_REFERRAL_ACCOUNT_SEED;
86
+ if (p.payer.equals(p.owner))
87
+ invalid('the payer must not be the owner of the referral account');
88
+ if (p.owner.equals(PublicKey.default))
89
+ invalid('the owner must not be the default key');
90
+ if (typeof p.rentLamports !== 'bigint' || p.rentLamports <= 0n) {
91
+ invalid('rentLamports must be a positive bigint');
92
+ }
93
+ if (p.rentLamports > BigInt(Number.MAX_SAFE_INTEGER))
94
+ invalid('rentLamports is too large');
95
+ const account = deriveMeteoraReferralAccount(p.payer, seed);
96
+ if (account.equals(getAssociatedTokenAddressSync(NATIVE_MINT, p.owner, true))) {
97
+ invalid('the derived address is the associated wrapped-SOL account of the owner');
98
+ }
99
+ const create = SystemProgram.createAccountWithSeed({
100
+ fromPubkey: p.payer,
101
+ basePubkey: p.payer,
102
+ seed,
103
+ newAccountPubkey: account,
104
+ lamports: Number(p.rentLamports),
105
+ space: ACCOUNT_SIZE,
106
+ programId: TOKEN_PROGRAM_ID,
107
+ });
108
+ const initialize = new TransactionInstruction({
109
+ programId: TOKEN_PROGRAM_ID,
110
+ keys: [
111
+ { pubkey: account, isSigner: false, isWritable: true },
112
+ { pubkey: NATIVE_MINT, isSigner: false, isWritable: false },
113
+ ],
114
+ data: Buffer.concat([Buffer.from([TOKEN_INITIALIZE_ACCOUNT3_TAG]), p.owner.toBuffer()]),
115
+ });
116
+ return { account, seed, instructions: [create, initialize] };
117
+ }
118
+ /**
119
+ * Unsigned transaction creating the Meteora referral account (`buildCreateMeteoraReferralAccountIxs`
120
+ * after the optional compute budget). It does not check that the address is free: inspect it
121
+ * first (`readMeteoraReferralAccount`); creating it twice fails on-chain, nothing is lost but
122
+ * the fee.
123
+ */
124
+ export function buildCreateMeteoraReferralAccountTx(p) {
125
+ if (typeof p.blockhash !== 'string' || p.blockhash.length === 0)
126
+ invalid('blockhash required');
127
+ if (!Number.isInteger(p.lastValidBlockHeight) || p.lastValidBlockHeight < 0) {
128
+ invalid('lastValidBlockHeight must be a non-negative integer');
129
+ }
130
+ const built = buildCreateMeteoraReferralAccountIxs(p);
131
+ const transaction = new Transaction({
132
+ feePayer: p.payer,
133
+ blockhash: p.blockhash,
134
+ lastValidBlockHeight: p.lastValidBlockHeight,
135
+ });
136
+ if (p.priorityFee && p.priorityFee.microLamports > 0) {
137
+ transaction.add(...priorityFeeInstructions(p.priorityFee));
138
+ }
139
+ transaction.add(...built.instructions);
140
+ return { ...built, transaction, lastValidBlockHeight: p.lastValidBlockHeight };
141
+ }
142
+ /**
143
+ * What `address` holds, against the expected shape of the Meteora referral account: absent,
144
+ * ready (a standalone wrapped-SOL token account of `owner`, neither frozen, delegated nor
145
+ * closable by someone else), or a mismatch with its reason.
146
+ */
147
+ export function inspectMeteoraReferralAccount(address, info, expected) {
148
+ if (!info)
149
+ return { status: 'absent' };
150
+ const mismatch = (reason, detail) => ({ status: 'mismatch', reason, detail });
151
+ if (!info.owner.equals(TOKEN_PROGRAM_ID)) {
152
+ return mismatch('not_a_token_account', `the address is in use by an account owned by ${info.owner.toBase58()}`);
153
+ }
154
+ let account;
155
+ try {
156
+ account = unpackAccount(address, info, TOKEN_PROGRAM_ID);
157
+ }
158
+ catch {
159
+ return mismatch('not_a_token_account', 'the account data is not an SPL Token account');
160
+ }
161
+ if (!account.mint.equals(NATIVE_MINT)) {
162
+ return mismatch('wrong_mint', `the token account mint is ${account.mint.toBase58()}`);
163
+ }
164
+ if (!account.owner.equals(expected.owner)) {
165
+ return mismatch('wrong_owner', `the token account owner is ${account.owner.toBase58()}`);
166
+ }
167
+ if (address.equals(getAssociatedTokenAddressSync(NATIVE_MINT, expected.owner, true))) {
168
+ return mismatch('associated_account', 'the account is the associated wrapped-SOL account of the owner, not a standalone one');
169
+ }
170
+ if (account.isFrozen)
171
+ return mismatch('frozen', 'the token account is frozen');
172
+ if (account.delegate !== null) {
173
+ return mismatch('delegated', `the token account has a delegate ${account.delegate.toBase58()}`);
174
+ }
175
+ if (account.closeAuthority !== null && !account.closeAuthority.equals(expected.owner)) {
176
+ return mismatch('close_authority', `the token account can be closed by ${account.closeAuthority.toBase58()}`);
177
+ }
178
+ return { status: 'ready', lamports: BigInt(info.lamports), amount: account.amount };
179
+ }
180
+ /** Reads `address` and inspects it (`inspectMeteoraReferralAccount`). Any cluster. */
181
+ export async function readMeteoraReferralAccount(p) {
182
+ const info = await retryRpc(() => p.connection.getAccountInfo(p.address, p.commitment ?? 'confirmed'), p.retry ?? {});
183
+ return inspectMeteoraReferralAccount(p.address, info, { owner: p.owner });
184
+ }
@@ -5,6 +5,9 @@
5
5
  * 1. a System Program transfer of `lamports` from the payer to `FORGE_CASHBOX_WALLET`;
6
6
  * 2. an SPL Memo (v2) instruction, signed by the payer, whose UTF-8 data is
7
7
  * `forge:job:<jobId>` (`jobId` = the job UUID, lowercase). See `paymentMemo`.
8
+ * With a `priority` option, two compute budget instructions come first (`setComputeUnitLimit`
9
+ * of `PAYMENT_COMPUTE_UNITS`, then `setComputeUnitPrice`, priced from the recent fees of the
10
+ * payer and the cashbox and bounded: priority-fee.ts), so a payment still lands under congestion.
8
11
  *
9
12
  * `verifyPayment` checks a transaction on-chain and returns a typed result; it never throws on a
10
13
  * transaction that is "not there yet" (it returns `pending`). It throws only on configuration
@@ -18,6 +21,7 @@
18
21
  */
19
22
  import { PublicKey, Transaction, TransactionInstruction, type Commitment, type Connection, type Finality, type VersionedTransactionResponse } from '@solana/web3.js';
20
23
  import { type AddressEnv } from './addresses.js';
24
+ import { type PriorityFeeQuote, type PriorityFeeSetting } from './priority-fee.js';
21
25
  import { type RetryOptions } from './reads.js';
22
26
  /** SPL Memo program v2 (the one wallets and explorers decode). */
23
27
  export declare const MEMO_PROGRAM_ID: PublicKey;
@@ -30,6 +34,11 @@ export declare const PAYMENT_MEMO_PREFIX = "forge:job:";
30
34
  * against a unit mistake upstream.
31
35
  */
32
36
  export declare const MAX_PAYMENT_LAMPORTS: bigint;
37
+ /**
38
+ * Compute unit limit of a payment with a priority fee: a System transfer (150 CU) and a memo of
39
+ * about 46 bytes signed by the payer (a few thousand CU), with a wide margin.
40
+ */
41
+ export declare const PAYMENT_COMPUTE_UNITS = 50000;
33
42
  /** Genesis hashes of the Solana clusters FORGE uses (public network constants). */
34
43
  export declare const CLUSTER_GENESIS_HASH: {
35
44
  readonly devnet: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG";
@@ -61,6 +70,12 @@ export interface PaymentTxOptions {
61
70
  env?: AddressEnv;
62
71
  commitment?: Commitment;
63
72
  retry?: RetryOptions;
73
+ /**
74
+ * Priority fee read from the cluster for the payer and the cashbox, bounded by this setting
75
+ * (`{}` = `DEFAULT_PRIORITY_FEE_POLICY`, at most 0.0005 SOL). Without it the transaction holds
76
+ * the transfer and the memo only.
77
+ */
78
+ priority?: PriorityFeeSetting;
64
79
  }
65
80
  export interface PaymentTxResult {
66
81
  /** Unsigned transaction: fee payer = payer, recent blockhash set. The payer signs it. */
@@ -69,6 +84,8 @@ export interface PaymentTxResult {
69
84
  recipient: PublicKey;
70
85
  lamports: bigint;
71
86
  memo: string;
87
+ /** The priority fee (price, fee, cap), or null without a `priority` option. */
88
+ priorityFee: PriorityFeeQuote | null;
72
89
  }
73
90
  /**
74
91
  * Builds the payment transaction of `jobId`. Checks that the cashbox will be rent-exempt after
package/dist/payments.js CHANGED
@@ -5,6 +5,9 @@
5
5
  * 1. a System Program transfer of `lamports` from the payer to `FORGE_CASHBOX_WALLET`;
6
6
  * 2. an SPL Memo (v2) instruction, signed by the payer, whose UTF-8 data is
7
7
  * `forge:job:<jobId>` (`jobId` = the job UUID, lowercase). See `paymentMemo`.
8
+ * With a `priority` option, two compute budget instructions come first (`setComputeUnitLimit`
9
+ * of `PAYMENT_COMPUTE_UNITS`, then `setComputeUnitPrice`, priced from the recent fees of the
10
+ * payer and the cashbox and bounded: priority-fee.ts), so a payment still lands under congestion.
8
11
  *
9
12
  * `verifyPayment` checks a transaction on-chain and returns a typed result; it never throws on a
10
13
  * transaction that is "not there yet" (it returns `pending`). It throws only on configuration
@@ -20,6 +23,7 @@ import { PublicKey, SystemProgram, Transaction, TransactionInstruction, } from '
20
23
  import { Base58Signature, Uuid } from '@forge/shared';
21
24
  import { requireAddresses } from './addresses.js';
22
25
  import { LAMPORTS_PER_SOL_BIGINT } from './constants.js';
26
+ import { estimatePriorityFee, priorityFeeInstructions, } from './priority-fee.js';
23
27
  import { retryRpc } from './reads.js';
24
28
  import { encodeBase58 } from './tx.js';
25
29
  /** SPL Memo program v2 (the one wallets and explorers decode). */
@@ -33,6 +37,11 @@ export const PAYMENT_MEMO_PREFIX = 'forge:job:';
33
37
  * against a unit mistake upstream.
34
38
  */
35
39
  export const MAX_PAYMENT_LAMPORTS = 100n * LAMPORTS_PER_SOL_BIGINT;
40
+ /**
41
+ * Compute unit limit of a payment with a priority fee: a System transfer (150 CU) and a memo of
42
+ * about 46 bytes signed by the payer (a few thousand CU), with a wide margin.
43
+ */
44
+ export const PAYMENT_COMPUTE_UNITS = 50_000;
36
45
  /** Genesis hashes of the Solana clusters FORGE uses (public network constants). */
37
46
  export const CLUSTER_GENESIS_HASH = {
38
47
  devnet: 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG',
@@ -96,9 +105,17 @@ export async function buildPaymentTx(connection, params, options = {}) {
96
105
  const cashbox = resolveCashbox(options.env);
97
106
  const instructions = buildPaymentInstructions({ ...params, cashbox });
98
107
  const commitment = options.commitment ?? 'confirmed';
99
- const [rentExempt, cashboxBalance] = await Promise.all([
108
+ const [rentExempt, cashboxBalance, priorityFee] = await Promise.all([
100
109
  retryRpc(() => connection.getMinimumBalanceForRentExemption(0, commitment), options.retry),
101
110
  retryRpc(() => connection.getBalance(cashbox, commitment), options.retry),
111
+ options.priority === undefined
112
+ ? Promise.resolve(null)
113
+ : estimatePriorityFee({
114
+ connection,
115
+ writableAccounts: [params.payer, cashbox],
116
+ computeUnitLimit: PAYMENT_COMPUTE_UNITS,
117
+ setting: options.priority,
118
+ }),
102
119
  ]);
103
120
  if (BigInt(cashboxBalance) + params.lamports < BigInt(rentExempt)) {
104
121
  throw new PaymentError('cashbox_not_rent_exempt', 'The cashbox would not be rent-exempt after this payment: fund it first');
@@ -108,13 +125,14 @@ export async function buildPaymentTx(connection, params, options = {}) {
108
125
  feePayer: params.payer,
109
126
  blockhash,
110
127
  lastValidBlockHeight,
111
- }).add(...instructions);
128
+ }).add(...(priorityFee ? priorityFeeInstructions(priorityFee) : []), ...instructions);
112
129
  return {
113
130
  transaction,
114
131
  lastValidBlockHeight,
115
132
  recipient: cashbox,
116
133
  lamports: params.lamports,
117
134
  memo: paymentMemo(params.jobId),
135
+ priorityFee,
118
136
  };
119
137
  }
120
138
  const genesisCache = new WeakMap();
package/dist/pool.d.ts CHANGED
@@ -38,12 +38,23 @@
38
38
  *
39
39
  * Both builders compare the on-chain config with what core builds for it, field by field
40
40
  * (`onchainConfigMismatches` in config.ts), before building anything.
41
+ *
42
+ * Priority fee (`priority` setting, priority-fee.ts): the price is read from the recent fees of
43
+ * the launch's writable accounts and bounded by the setting. The compute budget instructions come
44
+ * right after the nonce advance: `setComputeUnitLimit` then `setComputeUnitPrice`. The launchpad
45
+ * coin launch is tight (`LAUNCH_METADATA_BUDGET_BYTES`): when both do not fit in 1,232 bytes, the
46
+ * price goes alone (4 bytes more than the limit it replaces; the fee is then bounded for
47
+ * `MAX_TRANSACTION_COMPUTE_UNITS`, the most the runtime can apply without a limit), and when even
48
+ * that does not fit, the launch keeps its shape without a price (`priorityFeePlacement`
49
+ * `omitted_for_size`): the metadata budget accepted at `/v1/configs` never shrinks. That launch
50
+ * uses a durable nonce, so it stays valid until it lands. The user coin launch always fits both.
41
51
  */
42
52
  import { type PoolConfig, type SwapQuoteConfig } from '@meteora-ag/dynamic-bonding-curve-sdk';
43
53
  import { PublicKey, Transaction, type Connection } from '@solana/web3.js';
44
54
  import type { LaunchpadSpec, OwnerTransactionSummary } from '@forge/shared';
45
55
  import { type AddressEnv } from './addresses.js';
46
56
  import { type OnchainConfigExpectation } from './config.js';
57
+ import { type PriorityFeeQuote, type PriorityFeeSetting } from './priority-fee.js';
47
58
  export { LAUNCH_METADATA_BUDGET_BYTES, LaunchCoinError, MAX_FIRST_BUY_LAMPORTS, METADATA_LIMITS, assertFirstBuyLamports, assertLaunchMetadata, assertSpecLaunchable, launchMetadataBytes, type LaunchCoinErrorCode, } from './launch-metadata.js';
48
59
  /**
49
60
  * Compute unit limit of the launch transaction: 190,840 CU measured on devnet with the durable
@@ -126,7 +137,19 @@ export interface BuildLaunchCoinTxParams {
126
137
  computeUnitLimit?: number;
127
138
  /** Priority fee, micro-lamports per compute unit (default 0). */
128
139
  computeUnitPriceMicroLamports?: number;
140
+ /**
141
+ * Priority fee read from the cluster and bounded by this setting (`{}` = the defaults, at most
142
+ * 0.0005 SOL), see the top of this file for its placement. Not with
143
+ * `computeUnitPriceMicroLamports`.
144
+ */
145
+ priority?: PriorityFeeSetting;
129
146
  }
147
+ /**
148
+ * Where the priority fee went: with the unit limit (`limit_and_price`, also the shape without a
149
+ * priority fee), alone because both did not fit (`price_only`), or left out because even the
150
+ * price alone did not fit (`omitted_for_size`).
151
+ */
152
+ export type PriorityFeePlacement = 'limit_and_price' | 'price_only' | 'omitted_for_size';
130
153
  /** Lamport amounts, as bigint. */
131
154
  export interface LaunchCoinCosts {
132
155
  /** SOL swapped into the pool by the first buy. */
@@ -195,8 +218,15 @@ export interface LaunchCoinSummary {
195
218
  recentBlockhash: string;
196
219
  /** Null with a durable nonce (it does not expire with block height). */
197
220
  lastValidBlockHeight: number | null;
221
+ /**
222
+ * Compute unit limit requested; the transaction sets it unless `priorityFeePlacement` is
223
+ * `price_only` (the runtime default then applies, bounded by `MAX_TRANSACTION_COMPUTE_UNITS`).
224
+ */
198
225
  computeUnitLimit: number;
199
226
  computeUnitPriceMicroLamports: number;
227
+ /** The priority fee (price, fee, cap; `fixed` without a `priority` setting). */
228
+ priorityFee: PriorityFeeQuote;
229
+ priorityFeePlacement: PriorityFeePlacement;
200
230
  /** Signers on the signer side (creator, mint, nonce authority if distinct), then the owner. */
201
231
  signerSideSigners: PublicKey[];
202
232
  ownerSigner: PublicKey;
@@ -275,6 +305,12 @@ export interface BuildUserCoinLaunchTxParams {
275
305
  computeUnitLimit?: number;
276
306
  /** Priority fee, micro-lamports per compute unit (default 0). */
277
307
  computeUnitPriceMicroLamports?: number;
308
+ /**
309
+ * Priority fee read from the cluster and bounded by this setting (`{}` = the defaults, at most
310
+ * 0.0005 SOL): `setComputeUnitLimit` then `setComputeUnitPrice`, first. Not with
311
+ * `computeUnitPriceMicroLamports`.
312
+ */
313
+ priority?: PriorityFeeSetting;
278
314
  }
279
315
  export interface UserCoinLaunchSummary {
280
316
  config: PublicKey;
@@ -305,6 +341,10 @@ export interface UserCoinLaunchSummary {
305
341
  lastValidBlockHeight: number;
306
342
  computeUnitLimit: number;
307
343
  computeUnitPriceMicroLamports: number;
344
+ /** The priority fee (price, fee, cap; `fixed` without a `priority` setting). */
345
+ priorityFee: PriorityFeeQuote;
346
+ /** `limit_and_price`, or `omitted_for_size` if a priced launch would not fit (it always does). */
347
+ priorityFeePlacement: PriorityFeePlacement;
308
348
  /** Required signers in message order: the wallet (fee payer) then the mint. */
309
349
  signers: PublicKey[];
310
350
  txBytes: number;
package/dist/pool.js CHANGED
@@ -38,6 +38,16 @@
38
38
  *
39
39
  * Both builders compare the on-chain config with what core builds for it, field by field
40
40
  * (`onchainConfigMismatches` in config.ts), before building anything.
41
+ *
42
+ * Priority fee (`priority` setting, priority-fee.ts): the price is read from the recent fees of
43
+ * the launch's writable accounts and bounded by the setting. The compute budget instructions come
44
+ * right after the nonce advance: `setComputeUnitLimit` then `setComputeUnitPrice`. The launchpad
45
+ * coin launch is tight (`LAUNCH_METADATA_BUDGET_BYTES`): when both do not fit in 1,232 bytes, the
46
+ * price goes alone (4 bytes more than the limit it replaces; the fee is then bounded for
47
+ * `MAX_TRANSACTION_COMPUTE_UNITS`, the most the runtime can apply without a limit), and when even
48
+ * that does not fit, the launch keeps its shape without a price (`priorityFeePlacement`
49
+ * `omitted_for_size`): the metadata budget accepted at `/v1/configs` never shrinks. That launch
50
+ * uses a durable nonce, so it stays valid until it lands. The user coin launch always fits both.
41
51
  */
42
52
  import { DYNAMIC_BONDING_CURVE_PROGRAM_ID, DynamicBondingCurveClient, MigrationOption, TokenType, deriveDbcPoolAddress, } from '@meteora-ag/dynamic-bonding-curve-sdk';
43
53
  import { ACCOUNT_SIZE, ASSOCIATED_TOKEN_PROGRAM_ID, MINT_SIZE, NATIVE_MINT, TOKEN_PROGRAM_ID, createAssociatedTokenAccountIdempotentInstruction, getAssociatedTokenAddressSync, } from '@solana/spl-token';
@@ -48,6 +58,7 @@ import { onchainConfigMismatches, quoteFirstBuyOnConfig, } from './config.js';
48
58
  import { FEES, MIGRATION_THRESHOLD_LAMPORTS, SOL_QUOTE_MINT } from './constants.js';
49
59
  import { PLATFORM_FEE_BPS, platformFeeLamports } from './swap.js';
50
60
  import { LaunchCoinError, assertFirstBuyLamports, assertLaunchMetadata, } from './launch-metadata.js';
61
+ import { MAX_TRANSACTION_COMPUTE_UNITS, computePriorityFee, fixedPriorityFee, priorityFeeInstructions, readRecentPriorityFees, resolvePriorityFeePolicy, writableAccountsOf, } from './priority-fee.js';
51
62
  import { MAX_TRANSACTION_BYTES, computeBudgetInstructions, priorityFeeLamports, readNonce, requiredSigners, transactionSize, } from './tx.js';
52
63
  // The metadata and first buy checks live in launch-metadata.ts (shared with config.ts); they
53
64
  // stay part of this module's public surface.
@@ -257,10 +268,10 @@ async function loadLaunchState(connection, accounts, checkConfig) {
257
268
  }
258
269
  /**
259
270
  * Quotes the first buy on the fresh pool at the minimum fee (exact: nothing else can trade before
260
- * it, the pool is created in the same transaction) and assembles the transaction: nonce advance,
261
- * compute budget, the buyer's WSOL account (idempotent), then the SDK instructions of
262
- * `createPoolWithFirstBuy` (proven call pattern of devnet test 3), without referral, then the
263
- * trailing instructions.
271
+ * it, the pool is created in the same transaction) and returns how to compose the transaction for
272
+ * given compute budget instructions: nonce advance, compute budget, the buyer's WSOL account
273
+ * (idempotent), then the SDK instructions of `createPoolWithFirstBuy` (proven call pattern of
274
+ * devnet test 3), without referral, then the trailing instructions.
264
275
  */
265
276
  async function assembleLaunch(input) {
266
277
  const { buyer, nonce } = input;
@@ -289,26 +300,98 @@ async function assembleLaunch(input) {
289
300
  }
290
301
  : {}),
291
302
  });
292
- const instructions = [];
303
+ const head = [];
293
304
  if (nonce) {
294
- instructions.push(SystemProgram.nonceAdvance({
305
+ head.push(SystemProgram.nonceAdvance({
295
306
  noncePubkey: nonce.nonceAccount,
296
307
  authorizedPubkey: nonce.authority,
297
308
  }));
298
309
  }
299
- instructions.push(...computeBudgetInstructions(input.budget));
310
+ const body = [];
300
311
  // The SDK adds the buyer's WSOL account creation only when it is missing at build time.
301
312
  const buyerWsol = getAssociatedTokenAddressSync(NATIVE_MINT, buyer, true, TOKEN_PROGRAM_ID);
302
313
  if (quote && !createsAssociatedAccount(sdkTx.instructions, buyerWsol)) {
303
- instructions.push(createAssociatedTokenAccountIdempotentInstruction(buyer, buyerWsol, buyer, NATIVE_MINT, TOKEN_PROGRAM_ID));
314
+ body.push(createAssociatedTokenAccountIdempotentInstruction(buyer, buyerWsol, buyer, NATIVE_MINT, TOKEN_PROGRAM_ID));
315
+ }
316
+ body.push(...sdkTx.instructions, ...(input.trailingInstructions ?? []));
317
+ const compose = (budget) => {
318
+ const transaction = new Transaction({
319
+ feePayer: buyer,
320
+ recentBlockhash: input.recentBlockhash,
321
+ });
322
+ if (input.lastValidBlockHeight !== null) {
323
+ transaction.lastValidBlockHeight = input.lastValidBlockHeight;
324
+ }
325
+ transaction.add(...head, ...budget, ...body);
326
+ return { transaction, txBytes: transactionSize(transaction) };
327
+ };
328
+ return { quote, compose };
329
+ }
330
+ /**
331
+ * The compute budget of a launch: the legacy shape (limit, fixed price if any) without a
332
+ * `priority` setting; otherwise the priced shape that fits (see the top of this file).
333
+ * `allowPriceOnly`: the price may go without the limit (launchpad coin launch only).
334
+ */
335
+ async function priceLaunch(input) {
336
+ const { compose, budget } = input;
337
+ const legacy = compose(computeBudgetInstructions(budget));
338
+ const fixed = fixedPriorityFee(budget.units, budget.microLamports);
339
+ if (input.priority === undefined) {
340
+ return { ...legacy, priorityFee: fixed, placement: 'limit_and_price', feeBudget: budget };
341
+ }
342
+ let recentFees;
343
+ try {
344
+ recentFees = await readRecentPriorityFees(input.connection, writableAccountsOf(legacy.transaction.instructions));
345
+ }
346
+ catch {
347
+ // Degraded, reported in the quote (`rpc_unavailable`): the floor still prices the launch.
348
+ recentFees = null;
349
+ }
350
+ const full = computePriorityFee({
351
+ recentFees,
352
+ computeUnitLimit: budget.units,
353
+ policy: input.priority,
354
+ });
355
+ const withLimit = compose(priorityFeeInstructions(full));
356
+ if (withLimit.txBytes <= MAX_TRANSACTION_BYTES) {
357
+ return {
358
+ ...withLimit,
359
+ priorityFee: full,
360
+ placement: 'limit_and_price',
361
+ feeBudget: { units: full.computeUnitLimit, microLamports: full.microLamports },
362
+ };
363
+ }
364
+ if (input.allowPriceOnly) {
365
+ const alone = computePriorityFee({
366
+ recentFees,
367
+ computeUnitLimit: MAX_TRANSACTION_COMPUTE_UNITS,
368
+ policy: input.priority,
369
+ });
370
+ const priceOnly = compose(priorityFeeInstructions(alone, { priceOnly: true }));
371
+ if (priceOnly.txBytes <= MAX_TRANSACTION_BYTES) {
372
+ return {
373
+ ...priceOnly,
374
+ priorityFee: alone,
375
+ placement: 'price_only',
376
+ feeBudget: { units: alone.computeUnitLimit, microLamports: alone.microLamports },
377
+ };
378
+ }
304
379
  }
305
- instructions.push(...sdkTx.instructions, ...(input.trailingInstructions ?? []));
306
- const transaction = new Transaction({ feePayer: buyer, recentBlockhash: input.recentBlockhash });
307
- if (input.lastValidBlockHeight !== null) {
308
- transaction.lastValidBlockHeight = input.lastValidBlockHeight;
380
+ return { ...legacy, priorityFee: fixed, placement: 'omitted_for_size', feeBudget: budget };
381
+ }
382
+ /** @throws LaunchCoinError `INVALID_INPUT` on a bad setting or one given with a fixed price. */
383
+ function assertPrioritySetting(priority, computeUnitPriceMicroLamports) {
384
+ if (priority === undefined)
385
+ return;
386
+ if (computeUnitPriceMicroLamports !== undefined) {
387
+ fail('INVALID_INPUT', 'pass either priority or computeUnitPriceMicroLamports, not both');
388
+ }
389
+ try {
390
+ resolvePriorityFeePolicy(priority);
391
+ }
392
+ catch (error) {
393
+ fail('INVALID_INPUT', `priority: ${error instanceof Error ? error.message : String(error)}`);
309
394
  }
310
- transaction.add(...instructions);
311
- return { transaction, quote, txBytes: transactionSize(transaction) };
312
395
  }
313
396
  /** The required signers, and whether they are exactly `expected` with `feePayer` first. */
314
397
  function checkSigners(transaction, expected, feePayer) {
@@ -420,6 +503,7 @@ export async function buildLaunchCoinTx(params) {
420
503
  fail('INVALID_INPUT', "rentPayer must be 'owner' or 'creator'");
421
504
  }
422
505
  assertNoFirstBuyReferral(params.referralTokenAccount);
506
+ assertPrioritySetting(params.priority, params.computeUnitPriceMicroLamports);
423
507
  const keys = [
424
508
  ['config', config],
425
509
  ['creator', creator],
@@ -458,8 +542,8 @@ export async function buildLaunchCoinTx(params) {
458
542
  recentBlockhash = latest.blockhash;
459
543
  lastValidBlockHeight = latest.lastValidBlockHeight;
460
544
  }
461
- // 4. Quote and instructions.
462
- const { transaction, quote, txBytes } = await assembleLaunch({
545
+ // 4. Quote and instructions, then the compute budget that fits.
546
+ const { quote, compose } = await assembleLaunch({
463
547
  client,
464
548
  poolConfig,
465
549
  config,
@@ -472,11 +556,18 @@ export async function buildLaunchCoinTx(params) {
472
556
  metadataUri: params.metadataUri,
473
557
  firstBuyLamports: params.firstBuyLamports,
474
558
  slippageBps,
475
- budget,
476
559
  nonce,
477
560
  recentBlockhash,
478
561
  lastValidBlockHeight,
479
562
  });
563
+ const priced = await priceLaunch({
564
+ connection,
565
+ compose,
566
+ budget,
567
+ priority: params.priority,
568
+ allowPriceOnly: true,
569
+ });
570
+ const { transaction, txBytes } = priced;
480
571
  // 5. Signers: exactly owner, creator, mint (+ the nonce authority). With the owner paying
481
572
  // the rent, the creator wallet must not be debitable at all (read-only signer).
482
573
  const expected = [owner, creator, mint];
@@ -498,7 +589,7 @@ export async function buildLaunchCoinTx(params) {
498
589
  transaction,
499
590
  signatureCount: signers.actual.length,
500
591
  nonce,
501
- budget,
592
+ budget: priced.feeBudget,
502
593
  quote,
503
594
  firstBuyLamports: params.firstBuyLamports,
504
595
  poolCreationFeeLamports,
@@ -535,7 +626,9 @@ export async function buildLaunchCoinTx(params) {
535
626
  recentBlockhash,
536
627
  lastValidBlockHeight,
537
628
  computeUnitLimit,
538
- computeUnitPriceMicroLamports,
629
+ computeUnitPriceMicroLamports: priced.priorityFee.microLamports,
630
+ priorityFee: priced.priorityFee,
631
+ priorityFeePlacement: priced.placement,
539
632
  signerSideSigners,
540
633
  ownerSigner: owner,
541
634
  txBytes,
@@ -613,6 +706,7 @@ export async function buildUserCoinLaunchTx(params) {
613
706
  ['wallet', wallet],
614
707
  ['mint', mint],
615
708
  ]);
709
+ assertPrioritySetting(params.priority, params.computeUnitPriceMicroLamports);
616
710
  const baseMint = mint;
617
711
  const pool = deriveDbcPoolAddress(SOL_QUOTE_MINT, baseMint, config);
618
712
  // FORGE platform fee on the first buy (rounded up), to FORGE_PLATFORM_FEE_WALLET only.
@@ -633,8 +727,8 @@ export async function buildUserCoinLaunchTx(params) {
633
727
  await assertPlatformFeeWalletLive(connection, platformFeeWallet);
634
728
  // 3. Recent blockhash: the user signs right away, no durable nonce.
635
729
  const latest = await connection.getLatestBlockhash('confirmed');
636
- // 4. Quote and instructions.
637
- const { transaction, quote, txBytes } = await assembleLaunch({
730
+ // 4. Quote and instructions, then the compute budget.
731
+ const { quote, compose } = await assembleLaunch({
638
732
  client,
639
733
  poolConfig,
640
734
  config,
@@ -647,7 +741,6 @@ export async function buildUserCoinLaunchTx(params) {
647
741
  metadataUri: params.metadataUri,
648
742
  firstBuyLamports: params.firstBuyLamports,
649
743
  slippageBps,
650
- budget,
651
744
  nonce: null,
652
745
  recentBlockhash: latest.blockhash,
653
746
  lastValidBlockHeight: latest.lastValidBlockHeight,
@@ -662,6 +755,15 @@ export async function buildUserCoinLaunchTx(params) {
662
755
  ]
663
756
  : [],
664
757
  });
758
+ // The browser verifier wants a limit with any price: never the price alone here.
759
+ const priced = await priceLaunch({
760
+ connection,
761
+ compose,
762
+ budget,
763
+ priority: params.priority,
764
+ allowPriceOnly: false,
765
+ });
766
+ const { transaction, txBytes } = priced;
665
767
  // 5. Signers: exactly the wallet (fee payer, first) and the mint.
666
768
  const signers = checkSigners(transaction, [wallet, baseMint], wallet);
667
769
  if (!signers.ok) {
@@ -676,7 +778,7 @@ export async function buildUserCoinLaunchTx(params) {
676
778
  transaction,
677
779
  signatureCount: signers.actual.length,
678
780
  nonce: null,
679
- budget,
781
+ budget: priced.feeBudget,
680
782
  quote,
681
783
  firstBuyLamports: params.firstBuyLamports,
682
784
  poolCreationFeeLamports,
@@ -703,7 +805,9 @@ export async function buildUserCoinLaunchTx(params) {
703
805
  recentBlockhash: latest.blockhash,
704
806
  lastValidBlockHeight: latest.lastValidBlockHeight,
705
807
  computeUnitLimit,
706
- computeUnitPriceMicroLamports,
808
+ computeUnitPriceMicroLamports: priced.priorityFee.microLamports,
809
+ priorityFee: priced.priorityFee,
810
+ priorityFeePlacement: priced.placement,
707
811
  signers: signers.actual,
708
812
  txBytes,
709
813
  },