@forgedevv/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * DAMM v2 position fee claims of a migrated DBC pool (docs/METEORA.md "Migration", DECISIONS.md
3
+ * D6 and D27, devnet test 2).
4
+ *
5
+ * When a launchpad coin graduates, `migrateToDammV2` creates the DAMM v2 pool and two locked
6
+ * positions: the partner's and the creator's. The creator's position (a FORGE creator wallet for
7
+ * a launchpad coin, permanently locked) keeps earning the migrated pool's trading fees; nothing
8
+ * but a `claim_position_fee` signed by the position owner moves them. This module:
9
+ * - finds the migrated DAMM v2 pool of a DBC pool (pure, from the decoded DBC state) and the
10
+ * creator's position(s) on it (the position NFT is held by the creator);
11
+ * - builds the UNSIGNED claim of their fees to a receiver (`@meteora-ag/cp-amm-sdk`, pinned
12
+ * 1.5.1), the creator signing, with an optional fee payer; shaped like `buildCreatorClaimTx`:
13
+ * transaction, summary, costs, skip reasons.
14
+ *
15
+ * Transaction (SOL-quoted pools only):
16
+ * 1. create-idempotent the receiver's coin account (the payer funds its rent, once per pool);
17
+ * 2. create-idempotent the creator's WSOL associated account (temporary, rent from the payer);
18
+ * 3. one `claim_position_fee` per position: coin fees to the receiver's coin account, SOL fees
19
+ * to the creator's temporary WSOL account;
20
+ * 4. close the temporary WSOL account to the receiver: the fees AND the account's rent reach
21
+ * the receiver as lamports. `claim_position_fee` has no amount cap and fees keep accruing
22
+ * until the transaction executes, so the exact forward of the DBC claim is not possible
23
+ * here: the rent (~0.002 SOL) moves from the payer to the receiver with every claim.
24
+ * The transaction never removes liquidity, never closes, locks, splits or transfers the position
25
+ * or its NFT, and contains no other program.
26
+ */
27
+ import { ACCOUNT_SIZE, NATIVE_MINT, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, createAssociatedTokenAccountIdempotentInstruction, createCloseAccountInstruction, getAssociatedTokenAddressSync, unpackAccount, } from '@solana/spl-token';
28
+ import { PublicKey, Transaction, } from '@solana/web3.js';
29
+ import { DAMM_V2_MIGRATION_FEE_ADDRESS, MigrationOption, deriveDammV2PoolAddress, } from '@meteora-ag/dynamic-bonding-curve-sdk';
30
+ import { CP_AMM_PROGRAM_ID, CpAmm, derivePositionNftAccount, getUnClaimLpFee, } from '@meteora-ag/cp-amm-sdk';
31
+ import { getMultisigVault } from './addresses.js';
32
+ import { CLAIM_LAMPORTS_PER_SIGNATURE, ClaimError, assertClaimReceiver, assertSolQuotedPool, } from './claims.js';
33
+ import { READ_COMMITMENT, getDbcPoolState, graduationStatus, retryRpc, toBigint, } from './reads.js';
34
+ /** DAMM v2 (cp-amm) program id, from the pinned SDK. */
35
+ export const DAMM_V2_PROGRAM_ID = CP_AMM_PROGRAM_ID;
36
+ /** Anchor discriminator of `claim_position_fee` (sha256("global:claim_position_fee")[0..8]). */
37
+ export const CLAIM_POSITION_FEE_DISCRIMINATOR = Buffer.from([180, 38, 154, 17, 133, 33, 162, 211]);
38
+ /** Account indexes of `claim_position_fee` (cp-amm SDK 1.5.1 IDL). */
39
+ export const CLAIM_POSITION_FEE_ACCOUNTS = {
40
+ poolAuthority: 0,
41
+ pool: 1,
42
+ position: 2,
43
+ tokenAAccount: 3,
44
+ tokenBAccount: 4,
45
+ tokenAVault: 5,
46
+ tokenBVault: 6,
47
+ tokenAMint: 7,
48
+ tokenBMint: 8,
49
+ positionNftAccount: 9,
50
+ owner: 10,
51
+ tokenAProgram: 11,
52
+ tokenBProgram: 12,
53
+ eventAuthority: 13,
54
+ program: 14,
55
+ };
56
+ /** `tokenAFlag` / `tokenBFlag` of a DAMM v2 pool: 0 = SPL Token, 1 = Token-2022. */
57
+ export function dammV2TokenProgram(flag) {
58
+ return flag === 1 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID;
59
+ }
60
+ /**
61
+ * Pure: the DAMM v2 pool of a migrated DBC pool, derived like the SDK's `migrateToDammV2`
62
+ * (`deriveDammV2PoolAddress(dammConfig, baseMint, quoteMint)` with the DAMM v2 config the SDK
63
+ * pins for the config's `migrationFeeOption`, `DAMM_V2_MIGRATION_FEE_ADDRESS`). Null when the
64
+ * pool has not migrated yet, migrates to DAMM v1, or has a migration fee option the pinned SDK
65
+ * does not know (FORGE configs use `FixedBps25`, D27).
66
+ */
67
+ export function migratedDammV2Pool(state) {
68
+ if (graduationStatus(state) !== 'migrated')
69
+ return null;
70
+ if (state.config.migrationOption !== MigrationOption.MET_DAMM_V2)
71
+ return null;
72
+ const dammConfig = DAMM_V2_MIGRATION_FEE_ADDRESS[state.config.migrationFeeOption];
73
+ if (!dammConfig)
74
+ return null;
75
+ const p = state.virtualPool.poolState;
76
+ return {
77
+ dammPool: deriveDammV2PoolAddress(dammConfig, p.baseMint, state.config.quoteMint),
78
+ dammConfig,
79
+ };
80
+ }
81
+ /** Pure: unclaimed fees of a position (SDK `getUnClaimLpFee`), as bigint. */
82
+ export function positionPendingFees(poolState, positionState) {
83
+ const fees = getUnClaimLpFee(poolState, positionState);
84
+ return { base: toBigint(fees.feeTokenA), quote: toBigint(fees.feeTokenB) };
85
+ }
86
+ /** Pure: summary of one position of `poolState`. */
87
+ export function summarizePosition(poolState, ref, positionState) {
88
+ return {
89
+ ...ref,
90
+ dammPool: positionState.pool,
91
+ liquidity: {
92
+ unlocked: toBigint(positionState.unlockedLiquidity),
93
+ vested: toBigint(positionState.vestedLiquidity),
94
+ permanentLocked: toBigint(positionState.permanentLockedLiquidity),
95
+ },
96
+ pendingFees: positionPendingFees(poolState, positionState),
97
+ };
98
+ }
99
+ /** Pure: sum of the pending fees of `positions`. */
100
+ export function sumPendingFees(positions) {
101
+ let base = 0n;
102
+ let quote = 0n;
103
+ for (const p of positions) {
104
+ base += p.pendingFees.base;
105
+ quote += p.pendingFees.quote;
106
+ }
107
+ return { base, quote };
108
+ }
109
+ // ---------------------------------------------------------------------------------------------
110
+ // Reads
111
+ // ---------------------------------------------------------------------------------------------
112
+ function cpAmm(connection) {
113
+ return new CpAmm(connection);
114
+ }
115
+ /** Fetches a DAMM v2 pool. @throws ClaimError `damm_pool_not_found` when the account is missing. */
116
+ export async function getDammV2PoolState(connection, dammPool, options = {}) {
117
+ try {
118
+ return await retryRpc(() => cpAmm(connection).fetchPoolState(dammPool), options.retry);
119
+ }
120
+ catch (err) {
121
+ if (err instanceof Error && /not found/i.test(err.message)) {
122
+ throw new ClaimError('damm_pool_not_found', `DAMM v2 pool not found: ${dammPool.toBase58()}`);
123
+ }
124
+ throw err;
125
+ }
126
+ }
127
+ /**
128
+ * Resolves and checks positions: each must belong to `dammPool`, and its NFT token account
129
+ * (Token-2022) must be owned by `owner` and hold the NFT. Sorted by position address.
130
+ * @throws ClaimError `position_not_found`, `position_mismatch` or `position_not_owned`.
131
+ */
132
+ async function resolvePositions(connection, amm, dammPool, poolState, owner, candidates, options) {
133
+ const commitment = options.commitment ?? READ_COMMITMENT;
134
+ const unique = new Map();
135
+ for (const c of candidates)
136
+ unique.set(c.position.toBase58(), c);
137
+ const ordered = [...unique.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
138
+ if (ordered.length === 0)
139
+ return [];
140
+ const resolved = [];
141
+ for (const [id, candidate] of ordered) {
142
+ let state = candidate.positionState;
143
+ if (!state) {
144
+ try {
145
+ state = await retryRpc(() => amm.fetchPositionState(candidate.position), options.retry);
146
+ }
147
+ catch (err) {
148
+ if (err instanceof Error && /not found/i.test(err.message)) {
149
+ throw new ClaimError('position_not_found', `DAMM v2 position not found: ${id}`);
150
+ }
151
+ throw err;
152
+ }
153
+ }
154
+ if (!state.pool.equals(dammPool)) {
155
+ throw new ClaimError('position_mismatch', `Position ${id} belongs to pool ${state.pool.toBase58()}, not to ${dammPool.toBase58()}`);
156
+ }
157
+ resolved.push({
158
+ ref: {
159
+ position: candidate.position,
160
+ nftMint: state.nftMint,
161
+ positionNftAccount: candidate.positionNftAccount ?? derivePositionNftAccount(state.nftMint),
162
+ },
163
+ state,
164
+ });
165
+ }
166
+ const nftInfos = await retryRpc(() => connection.getMultipleAccountsInfo(resolved.map((r) => r.ref.positionNftAccount), commitment), options.retry);
167
+ resolved.forEach(({ ref }, i) => {
168
+ const info = nftInfos[i];
169
+ const problem = nftOwnershipProblem(ref, info ?? null, owner);
170
+ if (problem) {
171
+ throw new ClaimError('position_not_owned', `Position ${ref.position.toBase58()} is not held by ${owner.toBase58()}: ${problem}`);
172
+ }
173
+ });
174
+ return resolved.map(({ ref, state }) => summarizePosition(poolState, ref, state));
175
+ }
176
+ /** Why `info` is not a Token-2022 account of `owner` holding the position NFT; null when it is. */
177
+ function nftOwnershipProblem(ref, info, owner) {
178
+ if (!info)
179
+ return 'position NFT token account missing';
180
+ let account;
181
+ try {
182
+ account = unpackAccount(ref.positionNftAccount, info, TOKEN_2022_PROGRAM_ID);
183
+ }
184
+ catch {
185
+ return 'position NFT token account is not a Token-2022 account';
186
+ }
187
+ if (!account.mint.equals(ref.nftMint))
188
+ return 'token account mint is not the position NFT';
189
+ if (account.amount !== 1n)
190
+ return 'token account does not hold the position NFT';
191
+ if (!account.owner.equals(owner))
192
+ return `held by ${account.owner.toBase58()}`;
193
+ return null;
194
+ }
195
+ /**
196
+ * Finds the creator's positions on the migrated DAMM v2 pool of a DBC pool (SDK
197
+ * `getUserPositionByPool`: the Token-2022 accounts of the creator holding one NFT, then the
198
+ * position of each), checked again (pool, NFT held by the creator) and sorted by address.
199
+ * Returns null when the pool has not migrated to DAMM v2.
200
+ * @throws ClaimError `not_pool_creator`, `damm_pool_not_found`, `damm_pool_mismatch`.
201
+ * @throws DbcReadError when the DBC pool or its config does not exist.
202
+ */
203
+ export async function findCreatorDammV2Positions(connection, params, options = {}) {
204
+ const state = params.pool instanceof PublicKey
205
+ ? await getDbcPoolState(connection, params.pool, options)
206
+ : params.pool;
207
+ const poolCreator = state.virtualPool.poolState.creator;
208
+ if (!poolCreator.equals(params.creator)) {
209
+ throw new ClaimError('not_pool_creator', `${params.creator.toBase58()} is not the creator of pool ${state.address.toBase58()}`);
210
+ }
211
+ const migrated = migratedDammV2Pool(state);
212
+ if (!migrated)
213
+ return null;
214
+ const amm = cpAmm(connection);
215
+ const poolState = await getDammV2PoolState(connection, migrated.dammPool, options);
216
+ assertDammPoolMatches(state, migrated.dammPool, poolState);
217
+ const found = await retryRpc(() => amm.getUserPositionByPool(migrated.dammPool, params.creator), options.retry);
218
+ const positions = await resolvePositions(connection, amm, migrated.dammPool, poolState, params.creator, found.map((f) => ({
219
+ position: f.position,
220
+ positionNftAccount: f.positionNftAccount,
221
+ positionState: f.positionState,
222
+ })), options);
223
+ return {
224
+ pool: state.address,
225
+ dammPool: migrated.dammPool,
226
+ dammConfig: migrated.dammConfig,
227
+ creator: params.creator,
228
+ positions,
229
+ claimable: sumPendingFees(positions),
230
+ };
231
+ }
232
+ function assertDammPoolMatches(state, dammPool, poolState) {
233
+ const baseMint = state.virtualPool.poolState.baseMint;
234
+ if (!poolState.tokenAMint.equals(baseMint) || !poolState.tokenBMint.equals(state.config.quoteMint)) {
235
+ throw new ClaimError('damm_pool_mismatch', `DAMM v2 pool ${dammPool.toBase58()} is not the ${baseMint.toBase58()} / SOL pool of DBC pool ${state.address.toBase58()}`);
236
+ }
237
+ }
238
+ const ZERO_COSTS = {
239
+ networkFeeLamports: 0n,
240
+ receiverTokenAccountRentLamports: 0n,
241
+ unwrapAccountRentLamports: 0n,
242
+ payerTotalLamports: 0n,
243
+ payerRequiredBalanceLamports: 0n,
244
+ };
245
+ /** True when the claim collects more SOL than it costs, by more than `minNetLamports`. */
246
+ export function isPositionClaimWorthwhile(summary, minNetLamports = 0n) {
247
+ return summary.claimable.quote > 0n && summary.netLamports > minNetLamports;
248
+ }
249
+ /**
250
+ * Builds the claim of the creator's DAMM v2 position fees of a migrated DBC pool to `receiver`
251
+ * (see the module comment for the transaction).
252
+ * @throws ClaimError when `creator` is not the pool creator, the pool is not SOL-quoted or not
253
+ * migrated to DAMM v2 (`not_migrated`), the receiver is invalid or differs from
254
+ * `expectedReceiver`, a position is missing, on another pool or not held by the creator, or
255
+ * the creator's WSOL account holds WSOL while the receiver is someone else.
256
+ * @throws DbcReadError when the DBC pool or its config does not exist.
257
+ */
258
+ export async function buildCreatorPositionFeeClaimTx(connection, params, options = {}) {
259
+ const commitment = options.commitment ?? READ_COMMITMENT;
260
+ const readOptions = { commitment, retry: options.retry };
261
+ assertClaimReceiver(params.receiver);
262
+ if (params.expectedReceiver && !params.expectedReceiver.equals(params.receiver)) {
263
+ throw new ClaimError('receiver_mismatch', `Position fees of pool ${params.pool.toBase58()} must go to ${params.expectedReceiver.toBase58()}, not ${params.receiver.toBase58()}`);
264
+ }
265
+ const state = await getDbcPoolState(connection, params.pool, readOptions);
266
+ assertSolQuotedPool(state);
267
+ const poolCreator = state.virtualPool.poolState.creator;
268
+ if (!poolCreator.equals(params.creator)) {
269
+ throw new ClaimError('not_pool_creator', `${params.creator.toBase58()} is not the creator of pool ${params.pool.toBase58()}`);
270
+ }
271
+ const migrated = migratedDammV2Pool(state);
272
+ if (!migrated) {
273
+ throw new ClaimError('not_migrated', `Pool ${params.pool.toBase58()} has not migrated to DAMM v2: no position to claim`);
274
+ }
275
+ const { dammPool, dammConfig } = migrated;
276
+ const amm = cpAmm(connection);
277
+ const poolState = await getDammV2PoolState(connection, dammPool, readOptions);
278
+ assertDammPoolMatches(state, dammPool, poolState);
279
+ let candidates;
280
+ if (params.positions) {
281
+ candidates = params.positions.map((position) => ({
282
+ position,
283
+ positionNftAccount: null,
284
+ positionState: null,
285
+ }));
286
+ }
287
+ else {
288
+ const found = await retryRpc(() => amm.getUserPositionByPool(dammPool, params.creator), options.retry);
289
+ candidates = found.map((f) => ({
290
+ position: f.position,
291
+ positionNftAccount: f.positionNftAccount,
292
+ positionState: f.positionState,
293
+ }));
294
+ }
295
+ const positions = await resolvePositions(connection, amm, dammPool, poolState, params.creator, candidates, readOptions);
296
+ const payer = params.payer ?? params.creator;
297
+ const receiver = params.receiver;
298
+ const baseMint = state.virtualPool.poolState.baseMint;
299
+ const claimable = sumPendingFees(positions);
300
+ const baseSummary = {
301
+ kind: 'position',
302
+ pool: state.address,
303
+ dammPool,
304
+ dammConfig,
305
+ baseMint,
306
+ claimer: params.creator,
307
+ receiver,
308
+ payer,
309
+ positions,
310
+ claimable,
311
+ quoteIsExact: false,
312
+ };
313
+ const skip = (skipReason) => ({
314
+ transaction: null,
315
+ lastValidBlockHeight: null,
316
+ skipReason,
317
+ summary: {
318
+ ...baseSummary,
319
+ receiverQuoteLamports: 0n,
320
+ existingWsolLamports: 0n,
321
+ costs: ZERO_COSTS,
322
+ netLamports: 0n,
323
+ },
324
+ });
325
+ if (positions.length === 0)
326
+ return skip('no_position');
327
+ if (claimable.base === 0n && claimable.quote === 0n)
328
+ return skip('nothing_to_claim');
329
+ // 1. The cp-amm claim instructions, built by the SDK (accounts: receiver's coin account,
330
+ // creator's temporary WSOL account). Its pre/post instructions are replaced below.
331
+ const tokenAProgram = dammV2TokenProgram(poolState.tokenAFlag);
332
+ const tokenBProgram = dammV2TokenProgram(poolState.tokenBFlag);
333
+ if (!tokenBProgram.equals(TOKEN_PROGRAM_ID)) {
334
+ throw new ClaimError('unsupported_quote_mint', 'The SOL side of the pool is not SPL Token WSOL');
335
+ }
336
+ const receiverBase = getAssociatedTokenAddressSync(baseMint, receiver, true, tokenAProgram);
337
+ const creatorWsol = getAssociatedTokenAddressSync(NATIVE_MINT, params.creator, true, TOKEN_PROGRAM_ID);
338
+ const claimIxs = [];
339
+ for (const p of positions) {
340
+ const sdkTx = await retryRpc(() => amm.claimPositionFee({
341
+ owner: params.creator,
342
+ position: p.position,
343
+ pool: dammPool,
344
+ positionNftAccount: p.positionNftAccount,
345
+ tokenAMint: poolState.tokenAMint,
346
+ tokenBMint: poolState.tokenBMint,
347
+ tokenAVault: poolState.tokenAVault,
348
+ tokenBVault: poolState.tokenBVault,
349
+ tokenAProgram,
350
+ tokenBProgram,
351
+ receiver,
352
+ feePayer: payer,
353
+ tempWSolAccount: params.creator,
354
+ }), options.retry);
355
+ const ixs = sdkTx.instructions.filter((ix) => ix.programId.equals(DAMM_V2_PROGRAM_ID));
356
+ const ix = ixs[0];
357
+ const at = (i) => ix?.keys[i]?.pubkey;
358
+ if (ixs.length !== 1 ||
359
+ !ix ||
360
+ !CLAIM_POSITION_FEE_DISCRIMINATOR.equals(Buffer.from(ix.data).subarray(0, 8)) ||
361
+ !at(CLAIM_POSITION_FEE_ACCOUNTS.pool)?.equals(dammPool) ||
362
+ !at(CLAIM_POSITION_FEE_ACCOUNTS.position)?.equals(p.position) ||
363
+ !at(CLAIM_POSITION_FEE_ACCOUNTS.tokenAAccount)?.equals(receiverBase) ||
364
+ !at(CLAIM_POSITION_FEE_ACCOUNTS.tokenBAccount)?.equals(creatorWsol) ||
365
+ !at(CLAIM_POSITION_FEE_ACCOUNTS.positionNftAccount)?.equals(p.positionNftAccount) ||
366
+ !at(CLAIM_POSITION_FEE_ACCOUNTS.owner)?.equals(params.creator)) {
367
+ throw new ClaimError('unexpected_sdk_instructions', 'The SDK position fee claim does not have the expected claim instruction');
368
+ }
369
+ claimIxs.push(ix);
370
+ }
371
+ // 2. On-chain state: the receiver's coin account, the creator's WSOL account.
372
+ const [infos, tokenAccountRent] = await Promise.all([
373
+ retryRpc(() => connection.getMultipleAccountsInfo([receiverBase, creatorWsol], commitment), options.retry),
374
+ retryRpc(() => connection.getMinimumBalanceForRentExemption(ACCOUNT_SIZE, commitment), options.retry),
375
+ ]);
376
+ const [receiverBaseInfo, creatorWsolInfo] = infos;
377
+ // The temporary account is closed to the receiver: WSOL it already holds (anyone can send
378
+ // some to it, 1 lamport is enough) simply goes with it, counted as received. Refusing it
379
+ // would let anyone block a pool's claims for the price of a lamport.
380
+ let existingWsolLamports = 0n;
381
+ if (creatorWsolInfo && creatorWsolInfo.owner.equals(TOKEN_PROGRAM_ID)) {
382
+ try {
383
+ existingWsolLamports = unpackAccount(creatorWsol, creatorWsolInfo, TOKEN_PROGRAM_ID).amount;
384
+ }
385
+ catch {
386
+ // Not a token account (e.g. lamports sent to the address): the idempotent creation takes it.
387
+ existingWsolLamports = 0n;
388
+ }
389
+ }
390
+ // 3. Instructions.
391
+ const instructions = [
392
+ createAssociatedTokenAccountIdempotentInstruction(payer, receiverBase, receiver, baseMint, tokenAProgram),
393
+ createAssociatedTokenAccountIdempotentInstruction(payer, creatorWsol, params.creator, NATIVE_MINT, TOKEN_PROGRAM_ID),
394
+ ...claimIxs,
395
+ createCloseAccountInstruction(creatorWsol, receiver, params.creator),
396
+ ];
397
+ const { blockhash, lastValidBlockHeight } = await retryRpc(() => connection.getLatestBlockhash(commitment), options.retry);
398
+ const transaction = new Transaction({ feePayer: payer, blockhash, lastValidBlockHeight });
399
+ transaction.add(...instructions);
400
+ // 4. Costs and net.
401
+ const signatures = BigInt(transaction.compileMessage().header.numRequiredSignatures);
402
+ const networkFeeLamports = signatures * CLAIM_LAMPORTS_PER_SIGNATURE;
403
+ const receiverTokenAccountRentLamports = receiverBaseInfo ? 0n : BigInt(tokenAccountRent);
404
+ const unwrapAccountRentLamports = creatorWsolInfo ? 0n : BigInt(tokenAccountRent);
405
+ const payerTotalLamports = networkFeeLamports + receiverTokenAccountRentLamports + unwrapAccountRentLamports;
406
+ const costs = {
407
+ networkFeeLamports,
408
+ receiverTokenAccountRentLamports,
409
+ unwrapAccountRentLamports,
410
+ payerTotalLamports,
411
+ payerRequiredBalanceLamports: payerTotalLamports,
412
+ };
413
+ // The closed account hands the receiver its whole balance: its rent (new or existing).
414
+ const closedLamports = creatorWsolInfo
415
+ ? BigInt(creatorWsolInfo.lamports)
416
+ : unwrapAccountRentLamports;
417
+ const receiverQuoteLamports = claimable.quote + closedLamports;
418
+ const summary = {
419
+ ...baseSummary,
420
+ receiverQuoteLamports,
421
+ existingWsolLamports,
422
+ costs,
423
+ netLamports: receiverQuoteLamports - payerTotalLamports,
424
+ };
425
+ if (options.minNetLamports !== undefined &&
426
+ !isPositionClaimWorthwhile(summary, options.minNetLamports)) {
427
+ return { transaction: null, lastValidBlockHeight: null, skipReason: 'not_worthwhile', summary };
428
+ }
429
+ return { transaction, lastValidBlockHeight, skipReason: null, summary };
430
+ }
431
+ /**
432
+ * The position fee claim of a FORGE creator wallet: the receiver is always the FORGE multisig
433
+ * vault (`FORGE_MULTISIG_VAULT`, SECURITY.md §5), read from `env` (defaults to `process.env`).
434
+ * @throws ForgeAddressError when `FORGE_MULTISIG_VAULT` is missing or invalid; as
435
+ * `buildCreatorPositionFeeClaimTx` otherwise.
436
+ */
437
+ export async function buildForgeCreatorPositionFeeClaimTx(connection, params, options = {}) {
438
+ const vault = getMultisigVault(options.env);
439
+ return buildCreatorPositionFeeClaimTx(connection, { ...params, receiver: vault, expectedReceiver: vault }, options);
440
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The fixed settings of every FORGE config (docs/METEORA.md, "Settings for the two configs").
3
+ *
4
+ * They live in their own module so that both the builders (config.ts) and the checks
5
+ * (validate.ts, the on-chain comparison in config.ts) read the same constants without an import
6
+ * cycle. `config.ts` re-exports them: they stay part of its public surface.
7
+ */
8
+ import { ActivationType, BaseFeeMode, MigrationFeeOption, MigrationOption, TokenAuthorityOption, TokenDecimal, TokenType, type BaseFee } from '@meteora-ag/dynamic-bonding-curve-sdk';
9
+ import type BN from 'bn.js';
10
+ /** Token of every coin: SPL token, 1,000,000,000 tokens with 6 decimals, immutable metadata. */
11
+ export declare const FORGE_TOKEN_SETTINGS: {
12
+ readonly tokenType: TokenType.SPLToken;
13
+ readonly tokenBaseDecimal: TokenDecimal.SIX;
14
+ readonly tokenQuoteDecimal: 9;
15
+ readonly tokenAuthorityOption: TokenAuthorityOption.Immutable;
16
+ readonly totalTokenSupply: 1000000000;
17
+ readonly leftover: 0;
18
+ };
19
+ /**
20
+ * Anti-sniper fee schedule (`spec.antiSniper`): the fee starts at `startingFeeBps` and decreases
21
+ * linearly, in `numberOfPeriod` equal steps over `totalDurationSeconds`, to `spec.tradingFeeBps`.
22
+ * validate.ts requires exactly this schedule (or none when `spec.antiSniper` is off).
23
+ */
24
+ export declare const ANTI_SNIPER_FEE_SCHEDULE: {
25
+ readonly baseFeeMode: BaseFeeMode.FeeSchedulerLinear;
26
+ readonly startingFeeBps: 9900;
27
+ readonly numberOfPeriod: 10;
28
+ readonly totalDurationSeconds: 60;
29
+ };
30
+ /** Migration: DAMM v2, migrated pool fee 0.25%, no migration fee, 20% of the supply migrated. */
31
+ export declare const FORGE_MIGRATION_SETTINGS: {
32
+ readonly migrationOption: MigrationOption.MET_DAMM_V2;
33
+ readonly migrationFeeOption: MigrationFeeOption.FixedBps25;
34
+ readonly migrationFee: {
35
+ readonly feePercentage: 0;
36
+ readonly creatorFeePercentage: 0;
37
+ };
38
+ readonly percentageSupplyOnMigration: 20;
39
+ };
40
+ /** Fee scheduler periods are counted in seconds. */
41
+ export declare const FORGE_ACTIVATION_TYPE = ActivationType.Timestamp;
42
+ /** Total supply of every coin in raw base units (1,000,000,000 tokens x 10^6). */
43
+ export declare const FORGE_TOKEN_SUPPLY_RAW: bigint;
44
+ /** The SDK `feeSchedulerParam` of a FORGE config (anti-sniper schedule, or a flat fee). */
45
+ export declare function forgeFeeSchedulerParam(tradingFeeBps: number, antiSniper: boolean): {
46
+ startingFeeBps: number;
47
+ endingFeeBps: number;
48
+ numberOfPeriod: number;
49
+ totalDuration: number;
50
+ };
51
+ /**
52
+ * The base fee a FORGE config must carry for this trading fee and anti-sniper flag, computed by
53
+ * the SDK exactly as `buildCurve` does (cliff, number of periods, period, reduction factor, mode).
54
+ *
55
+ * @throws when the SDK refuses the fee (out of its bounds).
56
+ */
57
+ export declare function forgeBaseFee(tradingFeeBps: number, antiSniper: boolean): BaseFee;
58
+ /** Start price and curve shared by every FORGE config (they only depend on the settings above). */
59
+ export interface ForgeReferenceCurve {
60
+ sqrtStartPrice: BN;
61
+ curve: readonly {
62
+ sqrtPrice: BN;
63
+ liquidity: BN;
64
+ }[];
65
+ }
66
+ /**
67
+ * The curve and start price of every FORGE config. They follow from the token settings, the
68
+ * migration settings and the 10 SOL threshold only (the SDK's `buildCurve` does not read the
69
+ * fees or the liquidity split for them), so one reference fits both configs of every launchpad.
70
+ * The curve decides how the supply is split between the swap, the migration and the leftover
71
+ * (sent to the leftover receiver): pinning it pins the leftover. Computed once, then cached.
72
+ */
73
+ export declare function forgeReferenceCurve(): ForgeReferenceCurve;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The fixed settings of every FORGE config (docs/METEORA.md, "Settings for the two configs").
3
+ *
4
+ * They live in their own module so that both the builders (config.ts) and the checks
5
+ * (validate.ts, the on-chain comparison in config.ts) read the same constants without an import
6
+ * cycle. `config.ts` re-exports them: they stay part of its public surface.
7
+ */
8
+ import { ActivationType, BaseFeeMode, CollectFeeMode, MigrationFeeOption, MigrationOption, TokenAuthorityOption, TokenDecimal, TokenType, buildCurve, getBaseFeeParams, } from '@meteora-ag/dynamic-bonding-curve-sdk';
9
+ import { CLIENT_BOUNDS, MAX_ANTI_SNIPER_STARTING_FEE_BPS, SOL_DECIMALS } from './constants.js';
10
+ /** Token of every coin: SPL token, 1,000,000,000 tokens with 6 decimals, immutable metadata. */
11
+ export const FORGE_TOKEN_SETTINGS = {
12
+ tokenType: TokenType.SPLToken,
13
+ tokenBaseDecimal: TokenDecimal.SIX,
14
+ tokenQuoteDecimal: SOL_DECIMALS,
15
+ tokenAuthorityOption: TokenAuthorityOption.Immutable,
16
+ totalTokenSupply: 1_000_000_000,
17
+ leftover: 0,
18
+ };
19
+ /**
20
+ * Anti-sniper fee schedule (`spec.antiSniper`): the fee starts at `startingFeeBps` and decreases
21
+ * linearly, in `numberOfPeriod` equal steps over `totalDurationSeconds`, to `spec.tradingFeeBps`.
22
+ * validate.ts requires exactly this schedule (or none when `spec.antiSniper` is off).
23
+ */
24
+ export const ANTI_SNIPER_FEE_SCHEDULE = {
25
+ baseFeeMode: BaseFeeMode.FeeSchedulerLinear,
26
+ startingFeeBps: MAX_ANTI_SNIPER_STARTING_FEE_BPS,
27
+ numberOfPeriod: 10,
28
+ totalDurationSeconds: 60,
29
+ };
30
+ /** Migration: DAMM v2, migrated pool fee 0.25%, no migration fee, 20% of the supply migrated. */
31
+ export const FORGE_MIGRATION_SETTINGS = {
32
+ migrationOption: MigrationOption.MET_DAMM_V2,
33
+ migrationFeeOption: MigrationFeeOption.FixedBps25,
34
+ migrationFee: { feePercentage: 0, creatorFeePercentage: 0 },
35
+ percentageSupplyOnMigration: 20,
36
+ };
37
+ /** Fee scheduler periods are counted in seconds. */
38
+ export const FORGE_ACTIVATION_TYPE = ActivationType.Timestamp;
39
+ /** Total supply of every coin in raw base units (1,000,000,000 tokens x 10^6). */
40
+ export const FORGE_TOKEN_SUPPLY_RAW = BigInt(FORGE_TOKEN_SETTINGS.totalTokenSupply) *
41
+ 10n ** BigInt(FORGE_TOKEN_SETTINGS.tokenBaseDecimal);
42
+ /** The SDK `feeSchedulerParam` of a FORGE config (anti-sniper schedule, or a flat fee). */
43
+ export function forgeFeeSchedulerParam(tradingFeeBps, antiSniper) {
44
+ const schedule = ANTI_SNIPER_FEE_SCHEDULE;
45
+ return antiSniper
46
+ ? {
47
+ startingFeeBps: schedule.startingFeeBps,
48
+ endingFeeBps: tradingFeeBps,
49
+ numberOfPeriod: schedule.numberOfPeriod,
50
+ totalDuration: schedule.totalDurationSeconds,
51
+ }
52
+ : {
53
+ startingFeeBps: tradingFeeBps,
54
+ endingFeeBps: tradingFeeBps,
55
+ numberOfPeriod: 0,
56
+ totalDuration: 0,
57
+ };
58
+ }
59
+ /**
60
+ * The base fee a FORGE config must carry for this trading fee and anti-sniper flag, computed by
61
+ * the SDK exactly as `buildCurve` does (cliff, number of periods, period, reduction factor, mode).
62
+ *
63
+ * @throws when the SDK refuses the fee (out of its bounds).
64
+ */
65
+ export function forgeBaseFee(tradingFeeBps, antiSniper) {
66
+ return getBaseFeeParams({
67
+ baseFeeMode: ANTI_SNIPER_FEE_SCHEDULE.baseFeeMode,
68
+ feeSchedulerParam: forgeFeeSchedulerParam(tradingFeeBps, antiSniper),
69
+ });
70
+ }
71
+ let referenceCurve = null;
72
+ /**
73
+ * The curve and start price of every FORGE config. They follow from the token settings, the
74
+ * migration settings and the 10 SOL threshold only (the SDK's `buildCurve` does not read the
75
+ * fees or the liquidity split for them), so one reference fits both configs of every launchpad.
76
+ * The curve decides how the supply is split between the swap, the migration and the leftover
77
+ * (sent to the leftover receiver): pinning it pins the leftover. Computed once, then cached.
78
+ */
79
+ export function forgeReferenceCurve() {
80
+ if (referenceCurve)
81
+ return referenceCurve;
82
+ const built = buildCurve({
83
+ token: { ...FORGE_TOKEN_SETTINGS },
84
+ fee: {
85
+ baseFeeParams: {
86
+ baseFeeMode: ANTI_SNIPER_FEE_SCHEDULE.baseFeeMode,
87
+ feeSchedulerParam: forgeFeeSchedulerParam(CLIENT_BOUNDS.tradingFeeBps.min, false),
88
+ },
89
+ dynamicFeeEnabled: false,
90
+ collectFeeMode: CollectFeeMode.QuoteToken,
91
+ creatorTradingFeePercentage: 0,
92
+ poolCreationFee: 0,
93
+ enableFirstSwapWithMinFee: true,
94
+ },
95
+ migration: {
96
+ migrationOption: FORGE_MIGRATION_SETTINGS.migrationOption,
97
+ migrationFeeOption: FORGE_MIGRATION_SETTINGS.migrationFeeOption,
98
+ migrationFee: { ...FORGE_MIGRATION_SETTINGS.migrationFee },
99
+ },
100
+ liquidityDistribution: {
101
+ partnerPermanentLockedLiquidityPercentage: 100,
102
+ partnerLiquidityPercentage: 0,
103
+ creatorPermanentLockedLiquidityPercentage: 0,
104
+ creatorLiquidityPercentage: 0,
105
+ },
106
+ lockedVesting: {
107
+ totalLockedVestingAmount: 0,
108
+ numberOfVestingPeriod: 0,
109
+ cliffUnlockAmount: 0,
110
+ totalVestingDuration: 0,
111
+ cliffDurationFromMigrationTime: 0,
112
+ },
113
+ activationType: FORGE_ACTIVATION_TYPE,
114
+ percentageSupplyOnMigration: FORGE_MIGRATION_SETTINGS.percentageSupplyOnMigration,
115
+ migrationQuoteThreshold: CLIENT_BOUNDS.migrationThresholdSol,
116
+ });
117
+ referenceCurve = { sqrtStartPrice: built.sqrtStartPrice, curve: built.curve };
118
+ return referenceCurve;
119
+ }