@forgedevv/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/swap.js CHANGED
@@ -5,7 +5,8 @@
5
5
  * One transaction contains, in this order:
6
6
  *
7
7
  * 1. compute budget: `setComputeUnitLimit`, then `setComputeUnitPrice` (only when a priority fee
8
- * is given);
8
+ * is given: a fixed `priorityFeeMicroLamports`, or a `priority` setting whose price is read
9
+ * from the recent fees of the swap's writable accounts and bounded, see priority-fee.ts);
9
10
  * 2. token accounts: the user's wrapped SOL ATA is always created (idempotent) and, on a buy, the
10
11
  * user's coin ATA when it does not exist yet;
11
12
  * 3. buy only: SOL transfer of `amountInLamports` into the wrapped SOL ATA + `SyncNative` (wrap);
@@ -27,19 +28,26 @@
27
28
  * Refusals (typed `SwapError`, never a silent fallback):
28
29
  * - the pool is migrated or its curve is complete (`POOL_GRADUATED`): the template trades through
29
30
  * Jupiter in that case;
31
+ * - the pool's config is not in the caller's `allowedConfigs` (`POOL_NOT_ALLOWED`): a launchpad
32
+ * site only trades its own coins;
30
33
  * - the platform fee wallet does not exist or is not rent-exempt
31
34
  * (`PLATFORM_FEE_WALLET_NOT_RENT_EXEMPT`): the whole swap would fail on-chain (devnet finding);
32
- * - the referral account is not an initialized wrapped SOL token account (`REFERRAL_ACCOUNT_INVALID`);
35
+ * - the platform fee wallet is not exactly `FORGE_PLATFORM_FEE_WALLET`, or not a system account,
36
+ * or `FORGE_PLATFORM_FEE_WALLET` is not the revenue wallet when the environment has
37
+ * `FORGE_REVENUE_WALLET` (`PLATFORM_FEE_WALLET_MISMATCH`);
38
+ * - the referral account is not an initialized wrapped SOL token account, or is not held by
39
+ * `FORGE_REVENUE_WALLET` when the environment has it (`REFERRAL_ACCOUNT_INVALID`);
33
40
  * - the pool is not a plain SOL-quoted DBC pool (`UNSUPPORTED_POOL`; transfer-hook pools excluded);
34
41
  * - the serialized transaction exceeds 1,232 bytes (`TRANSACTION_TOO_LARGE`).
35
42
  *
36
43
  * Transaction size: measured on devnet (scripts/devnet-tests/reports/core-swap.md) at 819 bytes for a
37
44
  * buy and 797 bytes for a sell with this exact layout, far below the 1,232-byte limit (a buy that
38
- * also creates the coin ATA adds 10 bytes). If a future layout does not fit, the builders throw
39
- * `TRANSACTION_TOO_LARGE` unless the caller passes `addressLookupTables` (already fetched
40
- * `AddressLookupTableAccount`s, e.g. one holding the DBC program, the pool authority, the event
41
- * authority, the token programs, the wrapped SOL mint and the two FORGE fee accounts); the message
42
- * is then compiled against those tables and checked again.
45
+ * also creates the coin ATA adds 10 bytes, a priority fee 12 bytes for `setComputeUnitPrice`). If
46
+ * a future layout does not fit, the builders throw `TRANSACTION_TOO_LARGE` unless the caller
47
+ * passes `addressLookupTables` (already fetched `AddressLookupTableAccount`s, e.g. one holding the
48
+ * DBC program, the pool authority, the event authority, the token programs, the wrapped SOL mint
49
+ * and the two FORGE fee accounts); the message is then compiled against those tables and checked
50
+ * again.
43
51
  *
44
52
  * Layering: `fetchSwapContext` does all the network reads (with retries); everything else is pure
45
53
  * and unit-tested without network (`buildSwapTransaction`, `computeSwapQuote`, fee math).
@@ -48,15 +56,16 @@ import { ComputeBudgetProgram, PACKET_DATA_SIZE, PublicKey, SYSVAR_INSTRUCTIONS_
48
56
  import { ACCOUNT_SIZE, ExtensionType, NATIVE_MINT, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, createAssociatedTokenAccountIdempotentInstruction, createCloseAccountInstruction, createSyncNativeInstruction, getAccountLen, getAssociatedTokenAddressSync, unpackAccount, } from '@solana/spl-token';
49
57
  import BN from 'bn.js';
50
58
  import { ActivationType, BaseFeeMode, DYNAMIC_BONDING_CURVE_PROGRAM_ID, DynamicBondingCurveIdl, TradeDirection, createDbcProgram, deriveDbcEventAuthority, deriveDbcPoolAuthority, getFeeMode, getTokenProgram, isRateLimiterApplied, swapQuote, } from '@meteora-ag/dynamic-bonding-curve-sdk';
51
- import { requireAddresses } from './addresses.js';
59
+ import { readOptionalAddress, requireAddresses } from './addresses.js';
52
60
  import { FEES } from './constants.js';
61
+ import { estimatePriorityFee, fixedPriorityFee, resolvePriorityFeePolicy, } from './priority-fee.js';
53
62
  // ---------------------------------------------------------------------------------------------
54
63
  // Constants
55
64
  // ---------------------------------------------------------------------------------------------
56
65
  /** Platform fee on swaps built by @forge/core, in bps (`FEES.platformFeeBps` from @forge/shared). */
57
66
  export const PLATFORM_FEE_BPS = FEES.platformFeeBps;
58
- /** Highest slippage tolerance accepted by the builders (50%), whether given in bps or as a minimum. */
59
- export const MAX_SWAP_SLIPPAGE_BPS = 5_000;
67
+ /** Highest slippage tolerance accepted by the builders (25%), whether given in bps or as a minimum. */
68
+ export const MAX_SWAP_SLIPPAGE_BPS = 2_500;
60
69
  /**
61
70
  * Default compute unit limit. Measured on devnet: ~51,000 CU for a buy (~66,500 when it also creates
62
71
  * the coin ATA), ~49,000 CU for a sell; the default leaves about 2x headroom (fee scheduler,
@@ -83,7 +92,11 @@ export const SWAP_ERROR_CODES = [
83
92
  'POOL_NOT_FOUND',
84
93
  'UNSUPPORTED_POOL',
85
94
  'POOL_GRADUATED',
95
+ /** The pool's config is not in the caller's `allowedConfigs` (e.g. another launchpad's coin). */
96
+ 'POOL_NOT_ALLOWED',
86
97
  'PLATFORM_FEE_WALLET_NOT_RENT_EXEMPT',
98
+ /** The platform fee wallet is not FORGE_PLATFORM_FEE_WALLET, or not a plain system account. */
99
+ 'PLATFORM_FEE_WALLET_MISMATCH',
87
100
  'REFERRAL_ACCOUNT_INVALID',
88
101
  'INSUFFICIENT_LIQUIDITY',
89
102
  'QUOTE_FAILED',
@@ -419,8 +432,19 @@ function coinAtaSize(tokenProgram) {
419
432
  ? getAccountLen([ExtensionType.ImmutableOwner])
420
433
  : ACCOUNT_SIZE;
421
434
  }
422
- /** Throws unless the platform fee wallet exists and holds at least its rent-exempt minimum. */
423
- export function assertPlatformFeeWalletReady(wallet, account, rentExemptMinimum) {
435
+ /**
436
+ * Throws unless the platform fee wallet exists and holds at least its rent-exempt minimum. With
437
+ * `expectedWallet` (FORGE_PLATFORM_FEE_WALLET, as `buildBuyTx` / `buildSellTx` pass it), the
438
+ * wallet must be exactly that key and a plain system account: lamports sent to an account owned by
439
+ * another program could not be spent by FORGE (`PLATFORM_FEE_WALLET_MISMATCH`).
440
+ */
441
+ export function assertPlatformFeeWalletReady(wallet, account, rentExemptMinimum, expectedWallet) {
442
+ if (expectedWallet && !wallet.equals(expectedWallet)) {
443
+ throw new SwapError('PLATFORM_FEE_WALLET_MISMATCH', 'The platform fee must go to FORGE_PLATFORM_FEE_WALLET and nowhere else', { details: { platformFeeWallet: wallet.toBase58() } });
444
+ }
445
+ if (expectedWallet && account && !account.owner.equals(SystemProgram.programId)) {
446
+ throw new SwapError('PLATFORM_FEE_WALLET_MISMATCH', 'FORGE_PLATFORM_FEE_WALLET must be a system account (a wallet)', { details: { platformFeeWallet: wallet.toBase58(), owner: account.owner.toBase58() } });
447
+ }
424
448
  const lamports = account?.lamports ?? 0n;
425
449
  if (!account || lamports < rentExemptMinimum) {
426
450
  throw new SwapError('PLATFORM_FEE_WALLET_NOT_RENT_EXEMPT', 'FORGE_PLATFORM_FEE_WALLET must exist and be rent-exempt, otherwise every swap fails on-chain', {
@@ -432,8 +456,12 @@ export function assertPlatformFeeWalletReady(wallet, account, rentExemptMinimum)
432
456
  });
433
457
  }
434
458
  }
435
- /** Throws unless the referral account is an initialized wrapped SOL token account (SPL Token). */
436
- export function assertReferralAccountReady(address, account) {
459
+ /**
460
+ * Throws unless the referral account is an initialized wrapped SOL token account (SPL Token).
461
+ * With `expectedOwner` (FORGE_REVENUE_WALLET, when the environment has it), the token account must
462
+ * be held by that key: otherwise the referral share would be paid to someone else.
463
+ */
464
+ export function assertReferralAccountReady(address, account, expectedOwner) {
437
465
  const fail = (reason, cause) => new SwapError('REFERRAL_ACCOUNT_INVALID', `FORGE_METEORA_REFERRAL_ACCOUNT ${reason}`, {
438
466
  details: { referralTokenAccount: address.toBase58() },
439
467
  cause,
@@ -453,6 +481,27 @@ export function assertReferralAccountReady(address, account) {
453
481
  throw fail('is not a wrapped SOL account');
454
482
  if (!token.isInitialized || token.isFrozen)
455
483
  throw fail('is not usable (uninitialized or frozen)');
484
+ if (expectedOwner && !token.owner.equals(expectedOwner)) {
485
+ throw fail('is not held by FORGE_REVENUE_WALLET');
486
+ }
487
+ }
488
+ function toConfigKeys(allowedConfigs) {
489
+ if (!Array.isArray(allowedConfigs))
490
+ throw invalid('allowedConfigs must be an array');
491
+ return allowedConfigs.map((config, i) => toPublicKey(`allowedConfigs[${i}]`, config));
492
+ }
493
+ /**
494
+ * Throws `POOL_NOT_ALLOWED` unless the pool's config is one of `allowedConfigs`. Without a list
495
+ * (`undefined`), any config is accepted; an empty list accepts none.
496
+ */
497
+ export function assertPoolConfigAllowed(pool, config, allowedConfigs) {
498
+ if (allowedConfigs === undefined)
499
+ return;
500
+ if (!toConfigKeys(allowedConfigs).some((allowed) => allowed.equals(config))) {
501
+ throw new SwapError('POOL_NOT_ALLOWED', 'This pool does not belong to this launchpad', {
502
+ details: { pool: pool.toBase58(), config: config.toBase58() },
503
+ });
504
+ }
456
505
  }
457
506
  function tokenBalance(address, account, tokenProgram) {
458
507
  if (!account || !account.owner.equals(tokenProgram))
@@ -484,6 +533,21 @@ function readBuildOptions(o) {
484
533
  throw invalid('addressLookupTables must be an array');
485
534
  return { computeUnitLimit, priorityFeeMicroLamports, addressLookupTables };
486
535
  }
536
+ /** Checks a `priority` setting (INVALID_PARAMS), and that no fixed price is given with it. */
537
+ function readPrioritySetting(o) {
538
+ if (o.priority === undefined)
539
+ return undefined;
540
+ if (o.priorityFeeMicroLamports !== undefined) {
541
+ throw invalid('Pass either priority or priorityFeeMicroLamports, not both');
542
+ }
543
+ try {
544
+ resolvePriorityFeePolicy(o.priority);
545
+ }
546
+ catch (error) {
547
+ throw invalid(`priority: ${error instanceof Error ? error.message : String(error)}`);
548
+ }
549
+ return o.priority;
550
+ }
487
551
  /**
488
552
  * Compiles the instructions into an unsigned v0 transaction paid by `payer` and checks its
489
553
  * serialized size (signature placeholders included) against 1,232 bytes.
@@ -523,13 +587,18 @@ export function compileSwapTransaction(args) {
523
587
  return { transaction, sizeBytes };
524
588
  }
525
589
  /**
526
- * Pure transaction builder: validates the context (pool on its curve, fee accounts ready, balances),
527
- * quotes, and assembles the instructions in the order documented at the top of this file.
590
+ * Pure transaction builder: validates the context (pool on its curve, fee accounts ready and, with
591
+ * `expected`, exactly FORGE's), quotes, and assembles the instructions in the order documented at
592
+ * the top of this file.
528
593
  */
529
- export function buildSwapTransaction(ctx, request) {
594
+ export function buildSwapTransaction(ctx, request, expected = {}) {
595
+ if (request.priority !== undefined) {
596
+ // The price of a `priority` setting is read from the cluster: buildBuyTx / buildSellTx.
597
+ throw invalid('buildSwapTransaction takes a fixed priorityFeeMicroLamports, not priority');
598
+ }
530
599
  const options = readBuildOptions(request);
531
- assertPlatformFeeWalletReady(ctx.platformFeeWallet, ctx.platformFeeWalletAccount, ctx.platformFeeWalletRentExemptMinimum);
532
- assertReferralAccountReady(ctx.referralTokenAccount, ctx.referralTokenAccountInfo);
600
+ assertPlatformFeeWalletReady(ctx.platformFeeWallet, ctx.platformFeeWalletAccount, ctx.platformFeeWalletRentExemptMinimum, expected.platformFeeWallet);
601
+ assertReferralAccountReady(ctx.referralTokenAccount, ctx.referralTokenAccountInfo, expected.referralOwner);
533
602
  const quote = computeSwapQuote(ctx, request);
534
603
  const side = request.side;
535
604
  const coinProgram = coinTokenProgram(ctx.virtualPool);
@@ -623,6 +692,7 @@ export function buildSwapTransaction(ctx, request) {
623
692
  sizeBytes,
624
693
  computeUnitLimit: options.computeUnitLimit,
625
694
  priorityFeeMicroLamports: options.priorityFeeMicroLamports,
695
+ priorityFee: fixedPriorityFee(options.computeUnitLimit, options.priorityFeeMicroLamports),
626
696
  estimatedNetworkFeeLamports,
627
697
  newCoinAccountRentLamports,
628
698
  requiredLamports,
@@ -707,13 +777,20 @@ async function getCurrentPointRobust(connection, activationType, commitment) {
707
777
  // Only used for the quote (fee scheduler); the program uses its own clock.
708
778
  return new BN(blockTime ?? Math.floor(Date.now() / 1000));
709
779
  }
710
- /** Reads and decodes a pool and its config, plus the current point. */
711
- export async function fetchSwapPoolState(connection, poolAddress, commitment = DEFAULT_COMMITMENT) {
780
+ /**
781
+ * Reads and decodes a pool and its config, plus the current point. With
782
+ * `options.allowedConfigs`, a pool whose config is not in the list is refused (`POOL_NOT_ALLOWED`)
783
+ * before its config is read.
784
+ */
785
+ export async function fetchSwapPoolState(connection, poolAddress, commitment = DEFAULT_COMMITMENT, options = {}) {
712
786
  const pool = toPublicKey('pool', poolAddress);
787
+ if (options.allowedConfigs !== undefined)
788
+ toConfigKeys(options.allowedConfigs);
713
789
  const program = dbcProgram(connection);
714
790
  const poolInfo = await rpc('getAccountInfo(pool)', () => connection.getAccountInfo(pool, commitment));
715
791
  const virtualPool = decodeDbcAccount(program, 'virtualPool', pool, snapshot(poolInfo));
716
792
  const configAddress = virtualPool.poolState.config;
793
+ assertPoolConfigAllowed(pool, configAddress, options.allowedConfigs);
717
794
  const configInfo = await rpc('getAccountInfo(config)', () => connection.getAccountInfo(configAddress, commitment));
718
795
  const config = decodeDbcAccount(program, 'poolConfig', configAddress, snapshot(configInfo));
719
796
  const currentPoint = await getCurrentPointRobust(connection, config.activationType, commitment);
@@ -725,6 +802,8 @@ export async function fetchSwapContext(p) {
725
802
  const commitment = p.commitment ?? DEFAULT_COMMITMENT;
726
803
  const user = toPublicKey('user', p.user);
727
804
  const pool = toPublicKey('pool', p.pool);
805
+ if (p.allowedConfigs !== undefined)
806
+ toConfigKeys(p.allowedConfigs);
728
807
  const program = dbcProgram(connection);
729
808
  const [first, latest, slot] = await Promise.all([
730
809
  rpc('getMultipleAccountsInfo', () => connection.getMultipleAccountsInfo([pool, p.platformFeeWallet, p.referralTokenAccount, user], commitment)),
@@ -734,6 +813,7 @@ export async function fetchSwapContext(p) {
734
813
  const [poolInfo, platformInfo, referralInfo, userInfo] = first.map(snapshot);
735
814
  const virtualPool = decodeDbcAccount(program, 'virtualPool', pool, poolInfo ?? null);
736
815
  const configAddress = virtualPool.poolState.config;
816
+ assertPoolConfigAllowed(pool, configAddress, p.allowedConfigs);
737
817
  const coinProgram = coinTokenProgram(virtualPool);
738
818
  const userCoinAta = getAssociatedTokenAddressSync(virtualPool.poolState.baseMint, user, true, coinProgram);
739
819
  const userWrappedSolAta = getAssociatedTokenAddressSync(NATIVE_MINT, user, true, TOKEN_PROGRAM_ID);
@@ -775,12 +855,23 @@ export async function fetchSwapContext(p) {
775
855
  lastValidBlockHeight: latest.lastValidBlockHeight,
776
856
  };
777
857
  }
778
- /** FORGE fee accounts used by the swap, resolved through addresses.ts (never hard-coded). */
858
+ /**
859
+ * FORGE fee accounts used by the swap, resolved through addresses.ts (never hard-coded), and the
860
+ * revenue wallet that must hold the referral account (`null` when the environment does not have
861
+ * `FORGE_REVENUE_WALLET`: the owner check is then skipped). When the environment has it, the
862
+ * platform fee wallet must be that same wallet (D34: the platform fee is revenue too), otherwise
863
+ * `PLATFORM_FEE_WALLET_MISMATCH`.
864
+ */
779
865
  export function swapFeeAddresses(env) {
780
866
  const a = requireAddresses(['FORGE_PLATFORM_FEE_WALLET', 'FORGE_METEORA_REFERRAL_ACCOUNT'], env);
867
+ const revenueWallet = readOptionalAddress('FORGE_REVENUE_WALLET', env);
868
+ if (revenueWallet && !a.FORGE_PLATFORM_FEE_WALLET.equals(revenueWallet)) {
869
+ throw new SwapError('PLATFORM_FEE_WALLET_MISMATCH', 'FORGE_PLATFORM_FEE_WALLET must be the revenue wallet (FORGE_REVENUE_WALLET)', { details: { platformFeeWallet: a.FORGE_PLATFORM_FEE_WALLET.toBase58() } });
870
+ }
781
871
  return {
782
872
  platformFeeWallet: a.FORGE_PLATFORM_FEE_WALLET,
783
873
  referralTokenAccount: a.FORGE_METEORA_REFERRAL_ACCOUNT,
874
+ revenueWallet,
784
875
  };
785
876
  }
786
877
  function slippageOf(p) {
@@ -791,7 +882,10 @@ async function buildSide(side, amountIn, p) {
791
882
  assertU64(side === 'buy' ? 'amountInLamports' : 'amountInTokens', amountIn, 1n);
792
883
  const slippage = readSlippage(slippageOf(p));
793
884
  const options = readBuildOptions(p);
794
- const { platformFeeWallet, referralTokenAccount } = swapFeeAddresses(p.env);
885
+ const priority = readPrioritySetting(p);
886
+ const { platformFeeWallet, referralTokenAccount, revenueWallet } = swapFeeAddresses(p.env);
887
+ if (p.allowedConfigs !== undefined)
888
+ toConfigKeys(p.allowedConfigs);
795
889
  const ctx = await fetchSwapContext({
796
890
  connection: p.connection,
797
891
  pool: p.pool,
@@ -799,8 +893,27 @@ async function buildSide(side, amountIn, p) {
799
893
  platformFeeWallet,
800
894
  referralTokenAccount,
801
895
  ...(p.commitment ? { commitment: p.commitment } : {}),
896
+ ...(p.allowedConfigs !== undefined ? { allowedConfigs: p.allowedConfigs } : {}),
802
897
  });
803
- return buildSwapTransaction(ctx, { side, amountIn, slippage, ...options });
898
+ const expected = { platformFeeWallet, referralOwner: revenueWallet };
899
+ const built = buildSwapTransaction(ctx, { side, amountIn, slippage, ...options }, expected);
900
+ if (priority === undefined)
901
+ return built;
902
+ // Same swap with its price: read for the accounts it write-locks (the pool, its vaults and the
903
+ // FORGE fee accounts are shared by every trade), bounded by the setting.
904
+ const quote = await estimatePriorityFee({
905
+ connection: p.connection,
906
+ writableAccounts: writableStaticKeys(built.transaction),
907
+ computeUnitLimit: options.computeUnitLimit,
908
+ setting: priority,
909
+ });
910
+ const priced = buildSwapTransaction(ctx, { side, amountIn, slippage, ...options, priorityFeeMicroLamports: quote.microLamports }, expected);
911
+ return { ...priced, priorityFee: quote };
912
+ }
913
+ /** Writable static account keys of a compiled v0 transaction, in message order. */
914
+ function writableStaticKeys(transaction) {
915
+ const message = transaction.message;
916
+ return message.staticAccountKeys.filter((_, index) => message.isAccountWritable(index));
804
917
  }
805
918
  /** Unsigned buy transaction (SOL -> coin) with the FORGE referral and the platform fee. */
806
919
  export async function buildBuyTx(params) {
@@ -819,18 +932,19 @@ export async function quoteSwap(params) {
819
932
  const slippage = params.slippageBps === undefined && params.minAmountOut === undefined
820
933
  ? { slippageBps: 0 }
821
934
  : readSlippage(slippageOf(params));
822
- const state = await fetchSwapPoolState(params.connection, params.pool, params.commitment);
935
+ const state = await fetchSwapPoolState(params.connection, params.pool, params.commitment, params.allowedConfigs !== undefined ? { allowedConfigs: params.allowedConfigs } : {});
823
936
  return computeSwapQuote(state, { side: params.side, amountIn: params.amountIn, slippage });
824
937
  }
825
938
  /**
826
- * Startup / pre-build check of the FORGE fee accounts: the platform fee wallet must be rent-exempt
827
- * and the referral account must be a wrapped SOL token account. Throws `SwapError`.
939
+ * Startup / pre-build check of the FORGE fee accounts: the platform fee wallet must be a
940
+ * rent-exempt system account and the referral account must be a wrapped SOL token account, held by
941
+ * `FORGE_REVENUE_WALLET` when the environment has it. Throws `SwapError`.
828
942
  */
829
943
  export async function assertSwapFeeAccountsReady(params) {
830
944
  const commitment = params.commitment ?? DEFAULT_COMMITMENT;
831
- const { platformFeeWallet, referralTokenAccount } = swapFeeAddresses(params.env);
945
+ const { platformFeeWallet, referralTokenAccount, revenueWallet } = swapFeeAddresses(params.env);
832
946
  const [platformInfo, referralInfo] = (await rpc('getMultipleAccountsInfo', () => params.connection.getMultipleAccountsInfo([platformFeeWallet, referralTokenAccount], commitment))).map(snapshot);
833
947
  const rentMinimum = BigInt(await rpc('getMinimumBalanceForRentExemption', () => params.connection.getMinimumBalanceForRentExemption(platformInfo?.data.length ?? 0, commitment)));
834
- assertPlatformFeeWalletReady(platformFeeWallet, platformInfo ?? null, rentMinimum);
835
- assertReferralAccountReady(referralTokenAccount, referralInfo ?? null);
948
+ assertPlatformFeeWalletReady(platformFeeWallet, platformInfo ?? null, rentMinimum, platformFeeWallet);
949
+ assertReferralAccountReady(referralTokenAccount, referralInfo ?? null, revenueWallet);
836
950
  }
package/dist/tx.d.ts CHANGED
@@ -182,6 +182,8 @@ export declare function readNonce(connection: Pick<Connection, 'getAccountInfo'>
182
182
  expectedAuthority?: PublicKey;
183
183
  commitment?: Commitment;
184
184
  }): Promise<NonceInfo>;
185
+ /** Compute unit limit of the nonce account creations when a priority fee is set. */
186
+ export declare const CREATE_NONCE_ACCOUNT_COMPUTE_UNITS = 10000;
185
187
  export interface BuildCreateNonceAccountTxParams {
186
188
  connection: Pick<Connection, 'getMinimumBalanceForRentExemption' | 'getLatestBlockhash'>;
187
189
  /** Pays the rent and the fee; signs. */
package/dist/tx.js CHANGED
@@ -390,6 +390,8 @@ export async function readNonce(connection, nonceAccount, options = {}) {
390
390
  lamportsPerSignature: BigInt(decoded.feeCalculator.lamportsPerSignature),
391
391
  };
392
392
  }
393
+ /** Compute unit limit of the nonce account creations when a priority fee is set. */
394
+ export const CREATE_NONCE_ACCOUNT_COMPUTE_UNITS = 10_000;
393
395
  /**
394
396
  * Unsigned transaction creating and initializing a durable nonce account
395
397
  * (`SystemProgram.createNonceAccount`). The rent is read from the cluster.
@@ -404,7 +406,7 @@ export async function buildCreateNonceAccountTx(params) {
404
406
  const transaction = new Transaction({ feePayer: params.payer, blockhash, lastValidBlockHeight });
405
407
  if (params.computeUnitPriceMicroLamports !== undefined) {
406
408
  transaction.add(...computeBudgetInstructions({
407
- units: 10_000,
409
+ units: CREATE_NONCE_ACCOUNT_COMPUTE_UNITS,
408
410
  microLamports: params.computeUnitPriceMicroLamports,
409
411
  }));
410
412
  }
@@ -475,7 +477,7 @@ export async function buildCreateNonceAccountWithSeedTx(params) {
475
477
  const nonceAccount = await deriveSeededNonceAccount(base, seed);
476
478
  const budget = params.computeUnitPriceMicroLamports !== undefined
477
479
  ? computeBudgetInstructions({
478
- units: 10_000,
480
+ units: CREATE_NONCE_ACCOUNT_COMPUTE_UNITS,
479
481
  microLamports: params.computeUnitPriceMicroLamports,
480
482
  })
481
483
  : [];
@@ -8,6 +8,11 @@
8
8
  * pool creation fee 0 or 0.001..100 SOL, ...), using the SDK constants and helpers;
9
9
  * - FORGE's stricter bounds (client trading fee 50..200 bps, pool creation fee 0 or 0.001..1 SOL,
10
10
  * migration threshold exactly 10 SOL, SOL quote, immutable metadata, ...);
11
+ * - FORGE's fixed settings, for every field that moves value: no migration fee, no locked
12
+ * vesting, the token supply of `FORGE_TOKEN_SETTINGS` (fixed, nothing minted later), FORGE's
13
+ * curve and start price (they decide the leftover sent to the leftover receiver), timestamp
14
+ * activation, and the anti-sniper schedule exactly `ANTI_SNIPER_FEE_SCHEDULE` (or a flat fee
15
+ * when `spec.antiSniper` is off);
11
16
  * - that the parameters match the spec they were built from;
12
17
  * - finally, the SDK's own `validateConfigParameters`, as a catch-all for curve-level rules.
13
18
  * Lamport amounts are compared as `bigint`.
@@ -15,7 +20,15 @@
15
20
  import { type ConfigParameters } from '@meteora-ag/dynamic-bonding-curve-sdk';
16
21
  import { PublicKey } from '@solana/web3.js';
17
22
  import type { LaunchpadSpec } from '@forge/shared';
18
- export type ViolationCode = 'INVALID_ADDRESS' | 'QUOTE_NOT_SOL' | 'FEE_CLAIMER_MISMATCH' | 'LEFTOVER_RECEIVER_MISMATCH' | 'FEE_MODE_NOT_ALLOWED' | 'FEE_OUT_OF_SDK_BOUNDS' | 'FEE_OUT_OF_CLIENT_BOUNDS' | 'FEE_MISMATCH' | 'FEE_SCHEDULER_INVALID' | 'FEE_SCHEDULER_TOO_LONG' | 'ANTI_SNIPER_MISMATCH' | 'DYNAMIC_FEE_NOT_ALLOWED' | 'COLLECT_FEE_MODE_NOT_ALLOWED' | 'MIGRATION_OPTION_NOT_ALLOWED' | 'MIGRATION_FEE_OPTION_INVALID' | 'MIGRATION_FEE_INVALID' | 'MIGRATED_POOL_FEE_OUT_OF_BOUNDS' | 'MIGRATED_POOL_FEE_INVALID' | 'MIGRATION_THRESHOLD_MISMATCH' | 'POOL_CREATION_FEE_OUT_OF_SDK_BOUNDS' | 'POOL_CREATION_FEE_OUT_OF_CLIENT_BOUNDS' | 'POOL_CREATION_FEE_MISMATCH' | 'CREATOR_TRADING_FEE_OUT_OF_BOUNDS' | 'CREATOR_TRADING_FEE_MISMATCH' | 'LP_PERCENTAGES_INVALID' | 'LIQUIDITY_VESTING_INVALID' | 'LOCKED_LIQUIDITY_TOO_LOW' | 'CREATOR_LOCKED_LIQUIDITY_REQUIRED' | 'TOKEN_UPDATE_AUTHORITY_NOT_IMMUTABLE' | 'TOKEN_TYPE_INVALID' | 'TOKEN_DECIMAL_INVALID' | 'ACTIVATION_TYPE_INVALID' | 'FIRST_SWAP_MIN_FEE_REQUIRED'
23
+ export type ViolationCode = 'INVALID_ADDRESS' | 'QUOTE_NOT_SOL' | 'FEE_CLAIMER_MISMATCH' | 'LEFTOVER_RECEIVER_MISMATCH' | 'FEE_MODE_NOT_ALLOWED' | 'FEE_OUT_OF_SDK_BOUNDS' | 'FEE_OUT_OF_CLIENT_BOUNDS' | 'FEE_MISMATCH' | 'FEE_SCHEDULER_INVALID' | 'FEE_SCHEDULER_TOO_LONG' | 'ANTI_SNIPER_MISMATCH' | 'DYNAMIC_FEE_NOT_ALLOWED' | 'COLLECT_FEE_MODE_NOT_ALLOWED' | 'MIGRATION_OPTION_NOT_ALLOWED' | 'MIGRATION_FEE_OPTION_INVALID' | 'MIGRATION_FEE_INVALID'
24
+ /** A migration fee (to the partner or the creator) is set: FORGE configs take none. */
25
+ | 'MIGRATION_FEE_NOT_ALLOWED' | 'MIGRATED_POOL_FEE_OUT_OF_BOUNDS' | 'MIGRATED_POOL_FEE_INVALID' | 'MIGRATION_THRESHOLD_MISMATCH' | 'POOL_CREATION_FEE_OUT_OF_SDK_BOUNDS' | 'POOL_CREATION_FEE_OUT_OF_CLIENT_BOUNDS' | 'POOL_CREATION_FEE_MISMATCH' | 'CREATOR_TRADING_FEE_OUT_OF_BOUNDS' | 'CREATOR_TRADING_FEE_MISMATCH' | 'LP_PERCENTAGES_INVALID' | 'LIQUIDITY_VESTING_INVALID' | 'LOCKED_LIQUIDITY_TOO_LOW' | 'CREATOR_LOCKED_LIQUIDITY_REQUIRED' | 'TOKEN_UPDATE_AUTHORITY_NOT_IMMUTABLE'
26
+ /** Locked vesting of base tokens is set: FORGE configs vest nothing. */
27
+ | 'LOCKED_VESTING_NOT_ALLOWED'
28
+ /** Token supply differs from `FORGE_TOKEN_SETTINGS` (1,000,000,000 tokens, 6 decimals, fixed). */
29
+ | 'TOKEN_SUPPLY_MISMATCH'
30
+ /** Start price or curve differs from FORGE's (they decide the swap / migration / leftover split). */
31
+ | 'CURVE_MISMATCH' | 'TOKEN_TYPE_INVALID' | 'TOKEN_DECIMAL_INVALID' | 'ACTIVATION_TYPE_INVALID' | 'FIRST_SWAP_MIN_FEE_REQUIRED'
19
32
  /** Coin name + symbol + metadata URI exceed the launch transaction budget. */
20
33
  | 'LAUNCH_METADATA_TOO_LONG' | 'SPEC_INVALID' | 'SDK_REJECTED';
21
34
  export interface Violation {
package/dist/validate.js CHANGED
@@ -8,6 +8,11 @@
8
8
  * pool creation fee 0 or 0.001..100 SOL, ...), using the SDK constants and helpers;
9
9
  * - FORGE's stricter bounds (client trading fee 50..200 bps, pool creation fee 0 or 0.001..1 SOL,
10
10
  * migration threshold exactly 10 SOL, SOL quote, immutable metadata, ...);
11
+ * - FORGE's fixed settings, for every field that moves value: no migration fee, no locked
12
+ * vesting, the token supply of `FORGE_TOKEN_SETTINGS` (fixed, nothing minted later), FORGE's
13
+ * curve and start price (they decide the leftover sent to the leftover receiver), timestamp
14
+ * activation, and the anti-sniper schedule exactly `ANTI_SNIPER_FEE_SCHEDULE` (or a flat fee
15
+ * when `spec.antiSniper` is off);
11
16
  * - that the parameters match the spec they were built from;
12
17
  * - finally, the SDK's own `validateConfigParameters`, as a catch-all for curve-level rules.
13
18
  * Lamport amounts are compared as `bigint`.
@@ -16,6 +21,7 @@ import { ActivationType, BaseFeeMode, CollectFeeMode, MigrationFeeOption, Migrat
16
21
  import { PublicKey } from '@solana/web3.js';
17
22
  import BN from 'bn.js';
18
23
  import { CLIENT_BOUNDS, CLIENT_POOL_CREATION_FEE_LAMPORTS, FEE_DENOMINATOR, FEES, MAX_ANTI_SNIPER_DURATION_SECONDS, MAX_ANTI_SNIPER_DURATION_SLOTS, MAX_ANTI_SNIPER_STARTING_FEE_BPS, MAX_BASIS_POINT, MAX_FEE_BPS, MAX_FEE_NUMERATOR, MAX_FIRST_BUY_SOL, MAX_MIGRATED_POOL_FEE_BPS, MAX_POOL_CREATION_FEE, MIGRATION_THRESHOLD_LAMPORTS, MIN_FEE_BPS, MIN_FEE_NUMERATOR, MIN_LOCKED_LIQUIDITY_BPS, MIN_MIGRATED_POOL_FEE_BPS, MIN_POOL_CREATION_FEE, SECONDS_PER_DAY, SOL_QUOTE_MINT, solToLamports, } from './constants.js';
24
+ import { ANTI_SNIPER_FEE_SCHEDULE, FORGE_ACTIVATION_TYPE, FORGE_TOKEN_SETTINGS, FORGE_TOKEN_SUPPLY_RAW, forgeBaseFee, forgeReferenceCurve, } from './settings.js';
19
25
  /** Thrown by `assertValid` when a result has violations. */
20
26
  export class ValidationError extends Error {
21
27
  violations;
@@ -95,6 +101,15 @@ const FEE_NUMERATOR_PER_BPS = BigInt(FEE_DENOMINATOR / MAX_BASIS_POINT);
95
101
  function bpsToNumerator(bps) {
96
102
  return BigInt(bpsToFeeNumerator(bps).toString());
97
103
  }
104
+ /** FORGE's base fee for this fee and anti-sniper flag, or null when the SDK refuses the fee. */
105
+ function expectedBaseFee(tradingFeeBps, antiSniper) {
106
+ try {
107
+ return forgeBaseFee(tradingFeeBps, antiSniper);
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
98
113
  /** Fee numerator to bps, rounded to the nearest bps (for messages and the bounds check). */
99
114
  function numeratorToRoundedBps(numerator) {
100
115
  return Number((numerator + FEE_NUMERATOR_PER_BPS / 2n) / FEE_NUMERATOR_PER_BPS);
@@ -212,6 +227,7 @@ function validateConfig(config, spec, expected) {
212
227
  collectTradingFee(config, spec, c);
213
228
  collectModes(config, c);
214
229
  collectMigration(config, c);
230
+ collectSupply(config, c);
215
231
  collectPoolCreationFee(config, spec, expected, c);
216
232
  collectCreatorTradingFee(config, expected, c);
217
233
  collectLiquidity(config, expected, c);
@@ -343,6 +359,26 @@ function collectTradingFee(config, spec, c) {
343
359
  else if (spec?.antiSniper === false && !schedulerOff) {
344
360
  c.add(field, 'ANTI_SNIPER_MISMATCH', 'spec.antiSniper is off: the fee scheduler must be disabled');
345
361
  }
362
+ // FORGE's schedule exactly, not just one within the bounds: a single long period at 99% (or an
363
+ // exponential curve) would keep traders paying the starting fee far longer than announced.
364
+ const reported = c.violations.some((v) => v.field === field && (v.code === 'ANTI_SNIPER_MISMATCH' || v.code === 'FEE_MISMATCH'));
365
+ if (!reported &&
366
+ typeof spec?.antiSniper === 'boolean' &&
367
+ isIntInRange(spec.tradingFeeBps, MIN_FEE_BPS, MAX_FEE_BPS)) {
368
+ const forge = expectedBaseFee(spec.tradingFeeBps, spec.antiSniper);
369
+ const same = forge !== null &&
370
+ toBigInt(forge.cliffFeeNumerator) === cliff &&
371
+ forge.firstFactor === numberOfPeriod &&
372
+ toBigInt(forge.secondFactor) === periodFrequency &&
373
+ toBigInt(forge.thirdFactor) === reductionFactor &&
374
+ forge.baseFeeMode === mode;
375
+ if (!same) {
376
+ const schedule = ANTI_SNIPER_FEE_SCHEDULE;
377
+ c.add(field, spec.antiSniper ? 'ANTI_SNIPER_MISMATCH' : 'FEE_MISMATCH', spec.antiSniper
378
+ ? `the anti-sniper schedule must be exactly ANTI_SNIPER_FEE_SCHEDULE: linear from ${schedule.startingFeeBps} bps to spec.tradingFeeBps in ${schedule.numberOfPeriod} periods over ${schedule.totalDurationSeconds} s`
379
+ : 'spec.antiSniper is off: the base fee must be a flat spec.tradingFeeBps in FeeSchedulerLinear mode');
380
+ }
381
+ }
346
382
  if (config.poolFees.dynamicFee !== null && config.poolFees.dynamicFee !== undefined) {
347
383
  c.add('poolFees.dynamicFee', 'DYNAMIC_FEE_NOT_ALLOWED', 'dynamic fee must be disabled (it would raise the fee above the client bounds)');
348
384
  }
@@ -351,14 +387,53 @@ function collectModes(config, c) {
351
387
  if (config.collectFeeMode !== CollectFeeMode.QuoteToken) {
352
388
  c.add('collectFeeMode', 'COLLECT_FEE_MODE_NOT_ALLOWED', 'collectFeeMode must be QuoteToken (fees and the referral are paid in SOL)');
353
389
  }
354
- if (!enumValues(ActivationType).includes(config.activationType)) {
355
- c.add('activationType', 'ACTIVATION_TYPE_INVALID', 'activationType must be Slot or Timestamp');
390
+ // The anti-sniper periods are counted in seconds: a slot-based config would run them in slots.
391
+ if (!enumValues(ActivationType).includes(config.activationType) ||
392
+ config.activationType !== FORGE_ACTIVATION_TYPE) {
393
+ c.add('activationType', 'ACTIVATION_TYPE_INVALID', 'activationType must be Timestamp (FORGE_ACTIVATION_TYPE)');
394
+ }
395
+ if (!enumValues(TokenType).includes(config.tokenType) ||
396
+ config.tokenType !== FORGE_TOKEN_SETTINGS.tokenType) {
397
+ c.add('tokenType', 'TOKEN_TYPE_INVALID', 'tokenType must be SPLToken (FORGE_TOKEN_SETTINGS)');
356
398
  }
357
- if (!enumValues(TokenType).includes(config.tokenType)) {
358
- c.add('tokenType', 'TOKEN_TYPE_INVALID', 'tokenType must be SPLToken or Token2022');
399
+ if (!enumValues(TokenDecimal).includes(config.tokenDecimal) ||
400
+ config.tokenDecimal !== FORGE_TOKEN_SETTINGS.tokenBaseDecimal) {
401
+ c.add('tokenDecimal', 'TOKEN_DECIMAL_INVALID', `tokenDecimal must be ${FORGE_TOKEN_SETTINGS.tokenBaseDecimal} (FORGE_TOKEN_SETTINGS)`);
359
402
  }
360
- if (!enumValues(TokenDecimal).includes(config.tokenDecimal)) {
361
- c.add('tokenDecimal', 'TOKEN_DECIMAL_INVALID', 'tokenDecimal must be between 6 and 9');
403
+ }
404
+ /**
405
+ * Supply side (every field decides where tokens go): no locked vesting, FORGE's fixed token
406
+ * supply, and FORGE's curve and start price (with a fixed supply they decide how much is swapped,
407
+ * migrated and left over to the leftover receiver).
408
+ */
409
+ function collectSupply(config, c) {
410
+ const vesting = config.lockedVesting;
411
+ const vestingFields = vesting
412
+ ? [
413
+ vesting.amountPerPeriod,
414
+ vesting.cliffDurationFromMigrationTime,
415
+ vesting.frequency,
416
+ vesting.numberOfPeriod,
417
+ vesting.cliffUnlockAmount,
418
+ ]
419
+ : [];
420
+ if (!vesting || vestingFields.some((value) => toBigInt(value) !== 0n)) {
421
+ c.add('lockedVesting', 'LOCKED_VESTING_NOT_ALLOWED', 'lockedVesting must be all zero (FORGE configs vest no base token)');
422
+ }
423
+ const supply = config.tokenSupply;
424
+ if (!supply ||
425
+ toBigInt(supply.preMigrationTokenSupply) !== FORGE_TOKEN_SUPPLY_RAW ||
426
+ toBigInt(supply.postMigrationTokenSupply) !== FORGE_TOKEN_SUPPLY_RAW) {
427
+ c.add('tokenSupply', 'TOKEN_SUPPLY_MISMATCH', `tokenSupply must be fixed at ${FORGE_TOKEN_SUPPLY_RAW} raw units before and after migration (FORGE_TOKEN_SETTINGS)`);
428
+ }
429
+ const reference = forgeReferenceCurve();
430
+ const curve = Array.isArray(config.curve) ? config.curve : [];
431
+ const sameCurve = toBigInt(config.sqrtStartPrice) === toBigInt(reference.sqrtStartPrice) &&
432
+ curve.length === reference.curve.length &&
433
+ reference.curve.every((point, i) => toBigInt(curve[i]?.sqrtPrice) === toBigInt(point.sqrtPrice) &&
434
+ toBigInt(curve[i]?.liquidity) === toBigInt(point.liquidity));
435
+ if (!sameCurve) {
436
+ c.add('curve', 'CURVE_MISMATCH', "sqrtStartPrice and curve must be FORGE's (FORGE_TOKEN_SETTINGS, FORGE_MIGRATION_SETTINGS, 10 SOL threshold)");
362
437
  }
363
438
  }
364
439
  function collectMigration(config, c) {
@@ -379,6 +454,11 @@ function collectMigration(config, c) {
379
454
  if (feeReason !== null) {
380
455
  c.add('migrationFee', 'MIGRATION_FEE_INVALID', `migrationFee: ${feeReason}`);
381
456
  }
457
+ else if (config.migrationFee.feePercentage !== 0 ||
458
+ config.migrationFee.creatorFeePercentage !== 0) {
459
+ // The SDK allows up to 99% / 100% of the migrated quote: on a FORGE config it is always 0.
460
+ c.add('migrationFee', 'MIGRATION_FEE_NOT_ALLOWED', 'migrationFee.feePercentage and migrationFee.creatorFeePercentage must be 0');
461
+ }
382
462
  const pool = config.migratedPoolFee;
383
463
  if (!pool) {
384
464
  c.add('migratedPoolFee', 'MIGRATED_POOL_FEE_INVALID', 'migratedPoolFee is required');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgedevv/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "FORGE transaction core: Meteora Dynamic Bonding Curve configs, pools, swaps, claims and on-chain checks. Published on npm as @forgedevv/core.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -47,7 +47,8 @@
47
47
  "registry": "https://registry.npmjs.org/"
48
48
  },
49
49
  "dependencies": {
50
- "@forge/shared": "npm:@forgedevv/shared@0.1.0",
50
+ "@forge/shared": "npm:@forgedevv/shared@0.3.0",
51
+ "@meteora-ag/cp-amm-sdk": "1.5.1",
51
52
  "@meteora-ag/dynamic-bonding-curve-sdk": "1.5.13",
52
53
  "@solana/spl-token": "0.4.15",
53
54
  "@solana/web3.js": "1.99.0",