@wireio/stake 0.0.5 → 0.1.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.
Files changed (44) hide show
  1. package/README.md +203 -13
  2. package/lib/stake.browser.js +2803 -3331
  3. package/lib/stake.browser.js.map +1 -1
  4. package/lib/stake.d.ts +377 -6260
  5. package/lib/stake.js +2940 -3478
  6. package/lib/stake.js.map +1 -1
  7. package/lib/stake.m.js +2803 -3331
  8. package/lib/stake.m.js.map +1 -1
  9. package/package.json +2 -2
  10. package/src/assets/solana/idl/deposit.json +46 -10
  11. package/src/assets/solana/idl/distribution.json +40 -8
  12. package/src/assets/solana/idl/liq_sol_token.json +25 -2
  13. package/src/assets/solana/idl/mint_helper.json +110 -0
  14. package/src/assets/solana/idl/read_tracked_balance.json +140 -0
  15. package/src/assets/solana/idl/stake_controller.json +1141 -780
  16. package/src/assets/solana/idl/treasury.json +1 -227
  17. package/src/assets/solana/idl/validator_leaderboard.json +88 -47
  18. package/src/assets/solana/idl/validator_registry.json +115 -46
  19. package/src/assets/solana/idl/yield_oracle.json +1 -1
  20. package/src/assets/solana/types/deposit.ts +46 -10
  21. package/src/assets/solana/types/distribution.ts +40 -8
  22. package/src/assets/solana/types/liq_sol_token.ts +25 -2
  23. package/src/assets/solana/types/mint_helper.ts +116 -0
  24. package/src/assets/solana/types/read_tracked_balance.ts +146 -0
  25. package/src/assets/solana/types/stake_controller.ts +1141 -780
  26. package/src/assets/solana/types/treasury.ts +1 -227
  27. package/src/assets/solana/types/validator_leaderboard.ts +88 -47
  28. package/src/assets/solana/types/validator_registry.ts +115 -46
  29. package/src/assets/solana/types/yield_oracle.ts +1 -1
  30. package/src/index.ts +3 -4
  31. package/src/networks/ethereum/ethereum.ts +2 -2
  32. package/src/networks/solana/clients/deposit.client.ts +71 -80
  33. package/src/networks/solana/clients/distribution.client.ts +392 -141
  34. package/src/networks/solana/clients/leaderboard.client.ts +82 -107
  35. package/src/networks/solana/constants.ts +141 -56
  36. package/src/networks/solana/program.ts +36 -89
  37. package/src/networks/solana/solana.ts +173 -36
  38. package/src/networks/solana/types.ts +57 -0
  39. package/src/scripts/fetch-artifacts.sh +24 -0
  40. package/src/staker/staker.ts +32 -28
  41. package/src/staker/types.ts +25 -21
  42. package/src/assets/solana/idl/stake_registry.json +0 -435
  43. package/src/networks/solana/utils.ts +0 -122
  44. /package/src/{utils.ts → common/utils.ts} +0 -0
@@ -1,93 +1,134 @@
1
- // src/solana/clients/DistributionClient.ts
2
- import { AnchorProvider, Program, BN, web3 } from '@coral-xyz/anchor';
1
+ import { AnchorProvider, Program, BN } from '@coral-xyz/anchor';
3
2
  import {
4
3
  PublicKey,
4
+ SystemProgram,
5
+ SYSVAR_INSTRUCTIONS_PUBKEY,
5
6
  Transaction,
6
7
  TransactionInstruction,
7
- SystemProgram,
8
- VersionedTransaction,
9
8
  } from '@solana/web3.js';
10
9
  import {
11
- TOKEN_2022_PROGRAM_ID,
12
- ASSOCIATED_TOKEN_PROGRAM_ID,
10
+ getAccount,
13
11
  getAssociatedTokenAddressSync,
14
12
  } from '@solana/spl-token';
13
+
14
+ import { SolanaProgramService } from '../program';
15
15
  import {
16
- DISTRIBUTION_PROGRAM_ID,
16
+ TOKEN_2022_PROGRAM_ID,
17
+ ASSOCIATED_TOKEN_PROGRAM_ID,
17
18
  STAKE_CONTROLLER_PROGRAM_ID,
18
- DistributionIDL,
19
- Distribution,
20
19
  YIELD_ORACLE_PROGRAM_ID,
21
- LIQSOL_TOKEN_PROGRAM_ID,
22
- } from '../constants';
23
- import {
24
20
  deriveDistributionStatePDA,
25
21
  deriveUserRecordPDA,
26
- deriveLiqsolMintAuthorityPDA,
27
- } from '../utils';
22
+ deriveStakeControllerStatePDA,
23
+ deriveBucketAuthorityPDA,
24
+ derivePayRateHistoryPDA,
25
+ deriveStakeControllerAuthorityPDA,
26
+ } from '../constants';
27
+ import { Distribution } from '../../../assets/solana/types/distribution';
28
+ import { MismatchCandidate, CorrectAndRegisterBuild, CorrectionPlan } from '../types';
28
29
 
29
30
  export class DistributionClient {
30
- constructor(private provider: AnchorProvider) { }
31
+ private programs: SolanaProgramService;
31
32
 
32
- /** Wrapped Anchor Program for Distribution */
33
+ constructor(private provider: AnchorProvider) {
34
+ this.programs = new SolanaProgramService(provider);
35
+ }
36
+
37
+ /** Anchor Program<Distribution> (address comes from SolanaProgramService) */
33
38
  private get program(): Program<Distribution> {
34
- const idlWithAddress = {
35
- ...JSON.parse(JSON.stringify(DistributionIDL)),
36
- address: DISTRIBUTION_PROGRAM_ID.toString(),
37
- };
38
- return new Program(idlWithAddress as any, this.provider) as Program<Distribution>;
39
+ return this.programs.getProgram('distribution') as Program<Distribution>;
39
40
  }
40
41
 
41
- /** Derive the PDA for global distribution state */
42
- deriveStatePDA(): PublicKey {
43
- return deriveDistributionStatePDA()[0];
42
+ // ───────────────────────────────────────────────────────────────────────────────
43
+ // BASIC READS
44
+ // ───────────────────────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Fetch Distribution global state.
48
+ */
49
+ async getDistributionState(): Promise<any> {
50
+ const [pda] = deriveDistributionStatePDA();
51
+ return this.program.account.distributionState.fetch(pda);
44
52
  }
45
53
 
46
- /** Derive the PDA for a user’s record */
47
- deriveUserRecordPDA(user: PublicKey): PublicKey {
48
- return deriveUserRecordPDA(user)[0];
54
+ /**
55
+ * Fetch a user’s UserRecord or null if it doesn’t exist yet.
56
+ */
57
+ async getUserRecord(user: PublicKey): Promise<any | null> {
58
+ const [pda] = deriveUserRecordPDA(user);
59
+ try { return await this.program.account.userRecord.fetch(pda); }
60
+ catch { return null; }
49
61
  }
50
62
 
51
- /** Fetch on-chain distribution state */
52
- async getState(): Promise<any> {
53
- return this.program.account.distributionState.fetch(this.deriveStatePDA());
63
+ /**
64
+ * Read protocol-tracked balance for a user (from userRecord).
65
+ * Returns (amount, decimals).
66
+ * @default decimals=9
67
+ */
68
+ async getTrackedBalance(user: PublicKey): Promise<{ amount: bigint; decimals: number }> {
69
+ const rec = await this.getUserRecord(user);
70
+ if (!rec) return { amount: ZERO, decimals: 9 };
71
+ const amountStr: string = rec.trackedBalance?.toString?.() ?? '0';
72
+ return { amount: BigInt(amountStr), decimals: 9 };
54
73
  }
55
74
 
56
- /** Fetch a user’s record or return null if it doesn’t exist */
57
- async getUserRecord(user: PublicKey): Promise<any | null> {
75
+ /**
76
+ * Read *actual* liqSOL token balance (ATA) for a user.
77
+ * If user has no ATA yet, returns 0.
78
+ * @returns { amount, decimals, ata }
79
+ */
80
+ async getActualBalance(user: PublicKey): Promise<{ amount: bigint; decimals: number; ata: PublicKey }> {
81
+ const state = await this.getDistributionState();
82
+ const liqsolMint: PublicKey = state.liqsolMint;
83
+ const ata = getAssociatedTokenAddressSync(
84
+ liqsolMint,
85
+ user,
86
+ false,
87
+ TOKEN_2022_PROGRAM_ID,
88
+ ASSOCIATED_TOKEN_PROGRAM_ID
89
+ );
58
90
  try {
59
- return await this.program.account.userRecord.fetch(
60
- this.deriveUserRecordPDA(user)
61
- );
91
+ const acc = await getAccount(this.provider.connection, ata, 'confirmed', TOKEN_2022_PROGRAM_ID);
92
+ // spl-token returns bigint amount
93
+ return { amount: acc.amount as unknown as bigint, decimals: 9, ata };
62
94
  } catch {
63
- return null;
95
+ return { amount: ZERO, decimals: 9, ata };
64
96
  }
65
97
  }
66
98
 
67
- /** Build an `initialize` transaction for the distribution program */
68
- async buildInitializeTransaction(user: PublicKey): Promise<Transaction> {
69
- const [statePda] = deriveDistributionStatePDA();
70
- const ix = await this.program.methods
71
- .initialize()
72
- .accounts({
73
- authority: user,
74
- distributionState: statePda,
75
- systemProgram: SystemProgram.programId,
76
- rent: web3.SYSVAR_RENT_PUBKEY,
77
- } as any)
78
- .instruction();
99
+ // ───────────────────────────────────────────────────────────────────────────────
100
+ // STATE-CHANGING ACTIONS (single-user)
101
+ // ───────────────────────────────────────────────────────────────────────────────
79
102
 
80
- return new Transaction().add(ix);
103
+ /**
104
+ * Register / Update a user’s record.
105
+ * - If updating someone else, pass their pubkey as `targetUser` and let the connected wallet be `authority`.
106
+ * - Optionally pass an explicit `authorityUser` if you need to override (must be the connected wallet).
107
+ * Returns the tx signature.
108
+ */
109
+ /**
110
+ * Register / Update a user’s record with the connected wallet as authority.
111
+ * Keeps a single source of truth via `prepareUpdateUser()`.
112
+ */
113
+ async updateUser(targetUser: PublicKey): Promise<string> {
114
+ const { builder } = await this.prepareUpdateUser(targetUser);
115
+ return builder.rpc();
81
116
  }
82
117
 
83
- /** Build an `updateUser` transaction (register or refresh a user record) */
84
- async buildUpdateUserTransaction(user: PublicKey): Promise<Transaction> {
85
- const statePda = this.deriveStatePDA();
86
- const userPda = this.deriveUserRecordPDA(user);
118
+ /** Build-only variant (returns the IX) so you can batch. */
119
+ async buildUpdateUserIx(targetUser: PublicKey): Promise<TransactionInstruction> {
120
+ const { builder } = await this.prepareUpdateUser(targetUser);
121
+ return builder.instruction();
122
+ }
87
123
 
88
- // Fetch state to get the liqSOL mint address
89
- const state: any = await this.getState();
124
+ /**
125
+ * Claim rewards for a user.
126
+ * @returns tx signature
127
+ */
128
+ async claimRewards(user: PublicKey): Promise<string> {
129
+ const state = await this.getDistributionState();
90
130
  const liqsolMint: PublicKey = state.liqsolMint;
131
+
91
132
  const userAta = getAssociatedTokenAddressSync(
92
133
  liqsolMint,
93
134
  user,
@@ -96,38 +137,49 @@ export class DistributionClient {
96
137
  ASSOCIATED_TOKEN_PROGRAM_ID
97
138
  );
98
139
 
99
- const [stakeControllerStatePda] = PublicKey.findProgramAddressSync(
100
- [Buffer.from('stake_controller')],
101
- STAKE_CONTROLLER_PROGRAM_ID
140
+ const [userRecordPDA] = deriveUserRecordPDA(user);
141
+ const [distributionStatePDA] = deriveDistributionStatePDA();
142
+ const [bucketAuthority] = deriveBucketAuthorityPDA();
143
+ const [payRateHistory] = derivePayRateHistoryPDA();
144
+
145
+ const bucketTokenAccount = getAssociatedTokenAddressSync(
146
+ liqsolMint,
147
+ bucketAuthority,
148
+ true,
149
+ TOKEN_2022_PROGRAM_ID,
150
+ ASSOCIATED_TOKEN_PROGRAM_ID
102
151
  );
103
152
 
104
- const ix = await this.program.methods
105
- .updateUser()
153
+ const sig = await this.program.methods
154
+ .claimRewards()
106
155
  .accounts({
107
156
  user,
157
+ // @ts-ignore
108
158
  userAta,
109
- userRecord: userPda,
110
- authority: user,
111
- payer: user,
112
- distributionState: statePda,
113
- stakeControllerState: stakeControllerStatePda,
159
+ userRecord: userRecordPDA,
160
+ distributionState: distributionStatePDA,
161
+ liqsolMint,
162
+ stakeControllerProgram: STAKE_CONTROLLER_PROGRAM_ID,
163
+ bucketAuthority,
164
+ bucketTokenAccount,
165
+ payRateHistory,
114
166
  tokenProgram: TOKEN_2022_PROGRAM_ID,
115
- yieldOracleProgram: YIELD_ORACLE_PROGRAM_ID,
167
+ associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
116
168
  systemProgram: SystemProgram.programId,
117
- } as any)
118
- .instruction();
169
+ })
170
+ .rpc();
119
171
 
120
- return new Transaction().add(ix);
172
+ return sig;
121
173
  }
122
174
 
123
- /** Build a `withdraw` transaction */
124
- async buildWithdrawTransaction(user: PublicKey, amount: number): Promise<Transaction> {
125
- const statePda = this.deriveStatePDA();
126
- const userPda = this.deriveUserRecordPDA(user);
127
-
128
- // Fetch state for the mint
129
- const state: any = await this.getState();
175
+ /**
176
+ * Withdraw liqSOL (amount in base units; e.g. 1e9 = 1 liqSOL).
177
+ * @returns tx signature
178
+ */
179
+ async withdraw(user: PublicKey, amount: bigint | number): Promise<string> {
180
+ const state = await this.getDistributionState();
130
181
  const liqsolMint: PublicKey = state.liqsolMint;
182
+
131
183
  const userAta = getAssociatedTokenAddressSync(
132
184
  liqsolMint,
133
185
  user,
@@ -136,95 +188,294 @@ export class DistributionClient {
136
188
  ASSOCIATED_TOKEN_PROGRAM_ID
137
189
  );
138
190
 
139
- const [stakeControllerStatePda] = PublicKey.findProgramAddressSync(
140
- [Buffer.from('stake_controller')],
141
- STAKE_CONTROLLER_PROGRAM_ID
142
- );
143
- const [controllerAuthPda] = PublicKey.findProgramAddressSync(
144
- [Buffer.from('stake_authority')],
145
- STAKE_CONTROLLER_PROGRAM_ID
146
- );
191
+ const [userRecordPDA] = deriveUserRecordPDA(user);
192
+ const [distributionStatePDA] = deriveDistributionStatePDA();
193
+ const [stakeControllerState] = deriveStakeControllerStatePDA();
194
+ const [controllerAuthority] = deriveStakeControllerAuthorityPDA();
147
195
 
148
- const ix = await this.program.methods
149
- .withdraw(new BN(amount))
196
+ const sig = await this.program.methods
197
+ .withdraw(new BN(amount.toString()))
150
198
  .accounts({
151
199
  user,
200
+ // @ts-ignore
152
201
  userAta,
153
- userRecord: userPda,
154
- distributionState: statePda,
202
+ userRecord: userRecordPDA,
203
+ distributionState: distributionStatePDA,
155
204
  liqsolMint,
156
205
  tokenProgram: TOKEN_2022_PROGRAM_ID,
157
206
  stakeControllerProgram: STAKE_CONTROLLER_PROGRAM_ID,
158
- stakeControllerState: stakeControllerStatePda,
159
- controllerAuthority: controllerAuthPda,
207
+ stakeControllerState,
208
+ controllerAuthority,
160
209
  yieldOracleProgram: YIELD_ORACLE_PROGRAM_ID,
161
210
  systemProgram: SystemProgram.programId,
162
- } as any)
163
- .instruction();
211
+ })
212
+ .rpc();
164
213
 
165
- return new Transaction().add(ix);
214
+ return sig;
166
215
  }
167
216
 
168
- /** Build a `claimRewards` transaction */
169
- async buildClaimRewardsTransaction(user: PublicKey): Promise<Transaction> {
170
- const statePda = this.deriveStatePDA();
171
- const userPda = this.deriveUserRecordPDA(user);
217
+ // ───────────────────────────────────────────────────────────────────────────────
218
+ // INSTRUCTION BUILDERS (for bundling into 1 tx)
219
+ // ───────────────────────────────────────────────────────────────────────────────
220
+
221
+ /** Single prep path used by both send & build. */
222
+ private async prepareUpdateUser(targetUser: PublicKey) {
223
+ const authority = this.provider.wallet?.publicKey;
224
+ if (!authority) throw new Error('Wallet not connected');
172
225
 
173
- // Fetch state for the mint
174
- const state: any = await this.getState();
226
+ const state = await this.getDistributionState();
175
227
  const liqsolMint: PublicKey = state.liqsolMint;
176
- const userAta = getAssociatedTokenAddressSync(
228
+
229
+ const targetUserAta = getAssociatedTokenAddressSync(
177
230
  liqsolMint,
178
- user,
231
+ targetUser,
179
232
  false,
180
233
  TOKEN_2022_PROGRAM_ID,
181
234
  ASSOCIATED_TOKEN_PROGRAM_ID
182
235
  );
183
- const [mintAuthPda] = deriveLiqsolMintAuthorityPDA();
184
236
 
185
- const ix = await this.program.methods
186
- .claimRewards()
187
- .accounts({
188
- user,
189
- userAta,
190
- userRecord: userPda,
191
- distributionState: statePda,
237
+ const [targetUserRecordPDA] = deriveUserRecordPDA(targetUser);
238
+ const [distributionStatePDA] = deriveDistributionStatePDA();
239
+ const [payRateHistory] = derivePayRateHistoryPDA();
240
+
241
+ const accounts = {
242
+ user: targetUser,
243
+ userAta: targetUserAta,
244
+ // @ts-ignore (account name casing differences)
245
+ userRecord: targetUserRecordPDA,
246
+ authority,
247
+ payer: authority,
248
+ distributionState: distributionStatePDA,
249
+ liqsolMint,
250
+ instructionsSysvar: SYSVAR_INSTRUCTIONS_PUBKEY,
251
+ tokenProgram: TOKEN_2022_PROGRAM_ID,
252
+ stakeControllerProgram: STAKE_CONTROLLER_PROGRAM_ID,
253
+ payRateHistory,
254
+ systemProgram: SystemProgram.programId,
255
+ };
256
+
257
+ const builder = this.program.methods.updateUser().accounts(accounts as any);
258
+ return { builder, accounts };
259
+ }
260
+
261
+ // ───────────────────────────────────────────────────────────────────────────────
262
+ // ONE-SHOT BUILDER: correct 0..N others, then register self
263
+ // ───────────────────────────────────────────────────────────────────────────────
264
+
265
+ /**
266
+ * Build a single transaction that:
267
+ * 1) If self mismatch < 0 → just correct self (single updateUser).
268
+ * 2) Else (mismatch > 0) → correct top candidates (if needed) to free “available” balance
269
+ * and then register self (updateUser).
270
+ *
271
+ * It refuses to build if it can’t free enough to cover the full positive untracked amount
272
+ * (since updateUser has no partial-amount arg).
273
+ */
274
+ async buildCorrectRegisterTx(opts?: {
275
+ /** optional override of computed mismatch; may be positive (register) or negative (self-correct) */
276
+ amount?: bigint;
277
+ /** optionally pass pre-fetched candidates to avoid re-reading on UI flows */
278
+ preloadCandidates?: MismatchCandidate[];
279
+ }): Promise<CorrectAndRegisterBuild> {
280
+ const self = this.provider.wallet?.publicKey;
281
+ if (!self) throw new Error('Wallet not connected');
282
+
283
+ const state = await this.getDistributionState();
284
+ const liqsolMint: PublicKey = state.liqsolMint;
285
+ const availableBefore = BigInt(state.availableBalance.toString());
286
+
287
+ // self balances
288
+ const [selfUserRecordPda] = deriveUserRecordPDA(self);
289
+ const { amount: selfTracked } = await this.getTrackedBalance(self);
290
+ const { amount: selfActual, ata: selfUserAta } = await this.getActualBalance(self);
291
+
292
+ // True on-chain mismatch (can be negative or positive)
293
+ const computedMismatch = selfActual - selfTracked; // + = need to register, - = need to correct self
294
+
295
+ // Allow caller to suggest an amount (mainly for planning); clamp to real mismatch
296
+ let desired = opts?.amount ?? computedMismatch;
297
+ if (computedMismatch >= ZERO) {
298
+ // clamp down to computedMismatch
299
+ if (desired < ZERO) desired = ZERO;
300
+ if (desired > computedMismatch) desired = computedMismatch;
301
+ } else {
302
+ // negative side: clamp up to computedMismatch (remember computedMismatch is negative)
303
+ if (desired > ZERO) desired = ZERO;
304
+ if (desired < computedMismatch) desired = computedMismatch;
305
+ }
306
+
307
+ // CASE A: Negative mismatch → just correct self
308
+ if (desired < ZERO) {
309
+ console.log('Building Correct transaction for self with amount:', desired);
310
+
311
+ const tx = new Transaction().add(await this.buildUpdateUserIx(self));
312
+ return {
313
+ canSucceed: true,
314
+ transaction: tx,
192
315
  liqsolMint,
193
- liqsolProgram: LIQSOL_TOKEN_PROGRAM_ID,
194
- liqsolMintAuthority: mintAuthPda,
195
- instructionsSysvar: web3.SYSVAR_INSTRUCTIONS_PUBKEY,
196
- yieldOracleProgram: YIELD_ORACLE_PROGRAM_ID,
197
- tokenProgram: TOKEN_2022_PROGRAM_ID,
198
- associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
199
- systemProgram: SystemProgram.programId,
200
- } as any)
201
- .instruction();
316
+ needToRegister: desired, // negative indicates self-correction
317
+ availableBefore,
318
+ candidates: [],
319
+ plan: { selected: [], willFree: ZERO, deficit: ZERO },
320
+ accounts: { selfUserRecordPda, selfUserAta },
321
+ };
322
+ }
323
+
324
+ // Reuse the existing “positive mismatch” path
325
+ const needToRegister = desired; // >= 0
326
+ if (needToRegister === ZERO) {
327
+ return {
328
+ canSucceed: false,
329
+ reason: 'No mismatch to resolve.',
330
+ liqsolMint,
331
+ needToRegister,
332
+ availableBefore,
333
+ candidates: [],
334
+ plan: { selected: [], willFree: ZERO, deficit: ZERO },
335
+ accounts: { selfUserRecordPda, selfUserAta },
336
+ };
337
+ }
202
338
 
203
- return new Transaction().add(ix);
339
+ // Already enough available → just register self
340
+ if (availableBefore >= needToRegister) {
341
+ console.log('Available balance', availableBefore, 'is enough to register self:', needToRegister);
342
+ const registerIx = await this.buildUpdateUserIx(self);
343
+ const tx = new Transaction().add(registerIx);
344
+ return {
345
+ canSucceed: true,
346
+ transaction: tx,
347
+ liqsolMint,
348
+ needToRegister,
349
+ availableBefore,
350
+ candidates: [],
351
+ plan: { selected: [], willFree: ZERO, deficit: ZERO },
352
+ accounts: { selfUserRecordPda, selfUserAta },
353
+ };
354
+ }
355
+
356
+ // Need to free more
357
+ const missing = needToRegister - availableBefore;
358
+ const candidates = opts?.preloadCandidates ?? (await this.fetchMismatchCandidates());
359
+
360
+ if (!candidates.length) {
361
+ return {
362
+ canSucceed: false,
363
+ reason: 'No candidates to free available balance.',
364
+ liqsolMint,
365
+ needToRegister,
366
+ availableBefore,
367
+ candidates: [],
368
+ plan: { selected: [], willFree: ZERO, deficit: missing },
369
+ accounts: { selfUserRecordPda, selfUserAta },
370
+ };
371
+ }
372
+
373
+ const plan = this.chooseCandidatesFor(missing, candidates);
374
+ if (plan.deficit > ZERO) {
375
+ return {
376
+ canSucceed: false,
377
+ reason: `Insufficient freeable balance (need ${missing}, can free ${plan.willFree}).`,
378
+ liqsolMint,
379
+ needToRegister,
380
+ availableBefore,
381
+ candidates,
382
+ plan,
383
+ accounts: { selfUserRecordPda, selfUserAta },
384
+ };
385
+ }
386
+
387
+ // Build: correct N others, then register self
388
+ const tx = new Transaction();
389
+ for (const c of plan.selected) {
390
+ tx.add(await this.buildUpdateUserIx(c.owner));
391
+ }
392
+ tx.add(await this.buildUpdateUserIx(self));
393
+
394
+ return {
395
+ canSucceed: true,
396
+ transaction: tx,
397
+ liqsolMint,
398
+ needToRegister,
399
+ availableBefore,
400
+ candidates,
401
+ plan,
402
+ accounts: { selfUserRecordPda, selfUserAta },
403
+ };
204
404
  }
205
405
 
206
- /** Send & confirm a single-IX transaction; returns the tx signature */
207
- async sendTransaction(
208
- tx: Transaction,
209
- signers: import('@solana/web3.js').Signer[] = []
210
- ): Promise<string> {
211
- tx.feePayer = this.provider.wallet.publicKey;
212
- const { blockhash } = await this.provider.connection.getLatestBlockhash();
213
- tx.recentBlockhash = blockhash;
214
- return this.provider.sendAndConfirm(tx, signers);
406
+
407
+ // ───────────────────────────────────────────────────────────────────────────────
408
+ // MISMATCH DISCOVERY
409
+ // ───────────────────────────────────────────────────────────────────────────────
410
+
411
+ /**
412
+ * Fetch all distribution user records and turn them into mismatch candidates:
413
+ * rows where `tracked > actual` (delta > 0). Sorted by largest delta first.
414
+ *
415
+ * NOTE: This reads each user’s token account (`getAccount`) to recover
416
+ * the owner and actual balance — simpler and reliable for Token-2022 ATAs.
417
+ */
418
+ async fetchMismatchCandidates(): Promise<MismatchCandidate[]> {
419
+ const state = await this.getDistributionState();
420
+ const liqsolMint: PublicKey = state.liqsolMint;
421
+
422
+ const records = await this.program.account.userRecord.all();
423
+ if (!records.length) return [];
424
+
425
+ const out: MismatchCandidate[] = [];
426
+
427
+ // For each userRecord, read its token account to recover owner + actual
428
+ // (small N is fine; for very large N you can batch or shard).
429
+ for (const rec of records) {
430
+ const userRecordPda = rec.publicKey;
431
+ const userAta: PublicKey = rec.account.userAta;
432
+ const tracked = BigInt(rec.account.trackedBalance.toString());
433
+
434
+ let actual = ZERO;
435
+ let owner: PublicKey | null = null;
436
+
437
+ try {
438
+ const acc = await getAccount(this.provider.connection, userAta, 'confirmed', TOKEN_2022_PROGRAM_ID);
439
+ actual = acc.amount as unknown as bigint;
440
+ owner = acc.owner as unknown as PublicKey;
441
+ } catch {
442
+ // missing/closed ATA → actual = 0
443
+ owner = null;
444
+ }
445
+
446
+ if (!owner) continue;
447
+
448
+ const delta = tracked - actual;
449
+ if (delta > ZERO) {
450
+ // we only care about freeable deltas
451
+ out.push({ owner, userRecordPda, userAta, tracked, actual, delta });
452
+ }
453
+ }
454
+
455
+ // Largest first
456
+ out.sort((a, b) => (a.delta === b.delta ? 0 : a.delta > b.delta ? -1 : 1));
457
+ return out;
215
458
  }
216
459
 
217
- /** Simulate a single-IX transaction; returns error (if any) + compute units used */
218
- async simulateTransaction(
219
- tx: Transaction
220
- ): Promise<{ err: any; unitsConsumed: number }> {
221
- tx.feePayer = this.provider.wallet.publicKey;
222
- const { blockhash } = await this.provider.connection.getLatestBlockhash();
223
- tx.recentBlockhash = blockhash;
224
- const versioned = new VersionedTransaction(tx.compileMessage());
225
- const sim = await this.provider.connection.simulateTransaction(versioned, {
226
- sigVerify: false,
227
- });
228
- return { err: sim.value.err, unitsConsumed: sim.value.unitsConsumed! };
460
+ /**
461
+ * Given a required amount to free (in base units), choose the
462
+ * smallest prefix of candidates (already sorted desc) that can cover it.
463
+ */
464
+ chooseCandidatesFor(required: bigint, candidates: MismatchCandidate[]): CorrectionPlan {
465
+ if (required <= ZERO) return { selected: [], willFree: ZERO, deficit: ZERO };
466
+
467
+ let willFree = ZERO;
468
+ const selected: MismatchCandidate[] = [];
469
+
470
+ for (const c of candidates) {
471
+ if (willFree >= required) break;
472
+ selected.push(c);
473
+ willFree += c.delta;
474
+ }
475
+
476
+ const deficit = willFree >= required ? ZERO : required - willFree;
477
+ return { selected, willFree, deficit };
229
478
  }
230
- }
479
+ }
480
+
481
+ const ZERO = BigInt(0);