@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.
- package/CHANGELOG.md +152 -2
- package/dist/addresses.d.ts +25 -6
- package/dist/addresses.js +27 -5
- package/dist/claims.d.ts +31 -4
- package/dist/claims.js +46 -5
- package/dist/config.d.ts +8 -0
- package/dist/config.js +17 -5
- package/dist/constants.d.ts +16 -0
- package/dist/constants.js +20 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.js +12 -6
- package/dist/jupiter-referral.d.ts +219 -4
- package/dist/jupiter-referral.js +384 -13
- package/dist/launch-metadata.d.ts +2 -0
- package/dist/launch-metadata.js +2 -0
- package/dist/meteora-referral.d.ts +119 -0
- package/dist/meteora-referral.js +184 -0
- package/dist/payments.d.ts +17 -0
- package/dist/payments.js +20 -2
- package/dist/pool.d.ts +40 -0
- package/dist/pool.js +128 -24
- package/dist/position-claims.d.ts +9 -1
- package/dist/position-claims.js +21 -4
- package/dist/priority-fee.d.ts +202 -0
- package/dist/priority-fee.js +322 -0
- package/dist/reads.d.ts +20 -0
- package/dist/reads.js +39 -2
- package/dist/swap.d.ts +35 -16
- package/dist/swap.js +68 -19
- package/dist/tx.d.ts +2 -0
- package/dist/tx.js +4 -2
- package/package.json +3 -2
|
@@ -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
|
+
}
|
package/dist/payments.d.ts
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
|
|
@@ -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
|
|
261
|
-
* compute budget, the buyer's WSOL account
|
|
262
|
-
* `createPoolWithFirstBuy` (proven call pattern of
|
|
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
|
|
303
|
+
const head = [];
|
|
293
304
|
if (nonce) {
|
|
294
|
-
|
|
305
|
+
head.push(SystemProgram.nonceAdvance({
|
|
295
306
|
noncePubkey: nonce.nonceAccount,
|
|
296
307
|
authorizedPubkey: nonce.authority,
|
|
297
308
|
}));
|
|
298
309
|
}
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 {
|
|
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 {
|
|
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
|
},
|