@wireio/stake 2.2.2 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireio/stake",
3
- "version": "2.2.2",
3
+ "version": "2.3.1",
4
4
  "description": "LIQ Staking Module for Wire Network",
5
5
  "homepage": "https://gitea.gitgo.app/Wire/sdk-stake",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -64,7 +64,7 @@
64
64
  "@types/node": "^18.19.0",
65
65
  "@typescript-eslint/eslint-plugin": "^5.60.0",
66
66
  "@typescript-eslint/parser": "^5.60.0",
67
- "@wireio/core": "^0.3.0",
67
+ "@wireio/core": "^0.3.1",
68
68
  "assert": "^2.0.0",
69
69
  "chai": "^4.3.6",
70
70
  "esbuild": "^0.25.8",
package/src/index.ts CHANGED
@@ -13,8 +13,8 @@ export * as SOL from './networks/solana/types';
13
13
  export * from './networks/solana/utils';
14
14
 
15
15
  // CLIENTS
16
- export * from './networks/solana/clients/deposit.client';
16
+ export * from './networks/solana/clients/convert.client';
17
17
  export * from './networks/solana/clients/distribution.client';
18
18
  export * from './networks/solana/clients/leaderboard.client';
19
19
  export * from './networks/solana/clients/outpost.client';
20
- export * from './networks/solana/clients/token.client';
20
+ export * from './networks/solana/clients/token.client';
@@ -1,7 +1,7 @@
1
1
  import { BigNumber } from "ethers";
2
- import { preLaunchReceipt, WithdrawReceipt } from "../types";
2
+ import { preLaunchReceipt } from "../types";
3
+ import { WithdrawReceipt, ReceiptNFTKind } from "../../../types";
3
4
  import { EthereumContractService } from "../contract";
4
- import { ReceiptNFTKind } from "../../../types";
5
5
 
6
6
  export class ReceiptClient {
7
7
 
@@ -13,8 +13,7 @@ export class ReceiptClient {
13
13
  this.contractService = contract;
14
14
  }
15
15
 
16
-
17
-
16
+ // NOTE: Stake/pretoken receipts intentionally ignored for withdraw view.
18
17
  async allReceipts(address: string): Promise<preLaunchReceipt[]> {
19
18
  return this.fetchPreLaunchReceipts(address);
20
19
  }
@@ -23,9 +22,7 @@ export class ReceiptClient {
23
22
  try {
24
23
  const receipts = await this.fetchPreLaunchReceipts(address, ReceiptNFTKind.STAKE);
25
24
  return receipts;
26
- }
27
- catch (err) {
28
- // console.log('Error fetching stake receipts:', err);
25
+ } catch (err) {
29
26
  return [];
30
27
  }
31
28
  }
@@ -34,28 +31,20 @@ export class ReceiptClient {
34
31
  return this.fetchPreLaunchReceipts(address, ReceiptNFTKind.PRETOKEN_PURCHASE);
35
32
  }
36
33
 
37
-
38
-
39
34
  /**
40
- *
41
- * @param address (string) to fetch receipts for
42
- * @returns preLaunchReceipt[]
35
+ * Fetch ReceiptNFT data (stake/pretoken) for an address, optionally filtered by kind.
43
36
  */
44
37
  async fetchPreLaunchReceipts(address: string, type?: ReceiptNFTKind): Promise<preLaunchReceipt[]> {
45
38
  const receiptContract = this.contract.ReceiptNFT;
46
39
 
47
- // first figure out which tokenIds this address owns, from events
48
40
  const tokenIds = await this.getOwnedReceiptNFTsFor(address);
49
-
50
41
  const results: preLaunchReceipt[] = [];
51
42
 
52
- // next fetch on-chain receipt data just for those ids
53
43
  for (const idBN of tokenIds) {
54
44
  try {
55
45
  const receiptData = await receiptContract.getReceipt(idBN);
56
46
 
57
- //skip any receipt not of the requested type
58
- if(type !== undefined && receiptData.kind !== type) continue;
47
+ if (type !== undefined && receiptData.kind !== type) continue;
59
48
 
60
49
  results.push({
61
50
  tokenId: idBN.toBigInt(),
@@ -78,7 +67,6 @@ export class ReceiptClient {
78
67
  }
79
68
  });
80
69
  } catch (err) {
81
- // in case of any mismatch or race, just skip this id
82
70
  console.warn(`Failed to load receipt for tokenId=${idBN.toString()}`, err);
83
71
  continue;
84
72
  }
@@ -88,50 +76,6 @@ export class ReceiptClient {
88
76
  }
89
77
 
90
78
 
91
-
92
-
93
- private async getOwnedReceiptNFTsFor(
94
- owner: string,
95
- fromBlock = 0,
96
- toBlock: number | string = "latest"
97
- ): Promise<BigNumber[]> {
98
- const receiptContract = this.contract.ReceiptNFT;
99
-
100
- // Logs where address received tokens
101
- const toLogs = await receiptContract.queryFilter(
102
- receiptContract.filters.Transfer(null, owner),
103
- fromBlock,
104
- toBlock
105
- );
106
-
107
- // Logs where address sent tokens (including burns from owner → 0)
108
- const fromLogs = await receiptContract.queryFilter(
109
- receiptContract.filters.Transfer(owner, null),
110
- fromBlock,
111
- toBlock
112
- );
113
-
114
- const owned = new Set<string>();
115
-
116
- // Add all received tokenIds
117
- for (const e of toLogs) {
118
- const tokenId = e.args?.tokenId;
119
- if (!tokenId) continue;
120
- owned.add(tokenId.toString());
121
- }
122
-
123
- // Remove all sent tokenIds
124
- for (const e of fromLogs) {
125
- const tokenId = e.args?.tokenId;
126
- if (!tokenId) continue;
127
- owned.delete(tokenId.toString());
128
- }
129
-
130
- // Convert to BigNumbers
131
- return Array.from(owned).map((id) => BigNumber.from(id));
132
- }
133
-
134
-
135
79
  /**
136
80
  *
137
81
  * @param address (string) to fetch receipts for
@@ -147,16 +91,21 @@ export class ReceiptClient {
147
91
  try {
148
92
  const receiptData = await this.contract.WithdrawalQueue.info(idBN);
149
93
 
150
- results.push({
151
- tokenId: idBN.toBigInt(),
94
+ // status: ready if readyAt <= now; otherwise queued
95
+ const readyAtMs = Number(receiptData.readyAt) * 1000;
96
+ const status = readyAtMs <= Date.now() ? 'ready' : 'queued';
97
+
98
+ results.push({
99
+ tokenId: idBN.toBigInt(),
152
100
  receipt: {
153
- ethAmount: receiptData.ethAmount,
154
- ethBalance: {
101
+ amount: {
155
102
  amount: receiptData.ethAmount.toBigInt(),
156
103
  decimals: 18,
157
- symbol: "ETH"
104
+ symbol: "ETH",
158
105
  },
159
- readyAt: new Date(Number(receiptData.readyAt.toString()) * 1000).valueOf(),
106
+ readyAt: readyAtMs,
107
+ chain: 'ETH',
108
+ status,
160
109
  }
161
110
  });
162
111
  } catch (err) {
@@ -211,4 +160,40 @@ export class ReceiptClient {
211
160
  // Convert to BigNumbers
212
161
  return Array.from(owned).map((id) => BigNumber.from(id));
213
162
  }
214
- }
163
+
164
+ private async getOwnedReceiptNFTsFor(
165
+ owner: string,
166
+ fromBlock = 0,
167
+ toBlock: number | string = "latest"
168
+ ): Promise<BigNumber[]> {
169
+ const receiptContract = this.contract.ReceiptNFT;
170
+
171
+ const toLogs = await receiptContract.queryFilter(
172
+ receiptContract.filters.Transfer(null, owner),
173
+ fromBlock,
174
+ toBlock
175
+ );
176
+
177
+ const fromLogs = await receiptContract.queryFilter(
178
+ receiptContract.filters.Transfer(owner, null),
179
+ fromBlock,
180
+ toBlock
181
+ );
182
+
183
+ const owned = new Set<string>();
184
+
185
+ for (const e of toLogs) {
186
+ const tokenId = e.args?.tokenId;
187
+ if (!tokenId) continue;
188
+ owned.add(tokenId.toString());
189
+ }
190
+
191
+ for (const e of fromLogs) {
192
+ const tokenId = e.args?.tokenId;
193
+ if (!tokenId) continue;
194
+ owned.delete(tokenId.toString());
195
+ }
196
+
197
+ return Array.from(owned).map((id) => BigNumber.from(id));
198
+ }
199
+ }
@@ -32,9 +32,9 @@ export class EthereumStakingClient implements IStakingClient {
32
32
  private receiptClient: ReceiptClient;
33
33
  private validatorClient: ValidatorClient;
34
34
 
35
-
36
35
  get contract() { return this.contractService.contract; }
37
36
  get network() { return this.config.network; }
37
+ get address() { return this.signer?.getAddress(); }
38
38
 
39
39
  constructor(private config: StakerConfig) {
40
40
  try {
@@ -95,7 +95,7 @@ export class EthereumStakingClient implements IStakingClient {
95
95
  async withdraw(amount: bigint): Promise<string> {
96
96
  this.ensureUser();
97
97
 
98
- const address = await this.signer!.getAddress();
98
+ const address = await this.address!;
99
99
  const amountWei = BigNumber.from(amount);
100
100
 
101
101
  const result = await this.convertClient.performWithdraw(address, amountWei)
@@ -107,9 +107,9 @@ export class EthereumStakingClient implements IStakingClient {
107
107
  * @param amount Amount in wei (or something convertible to BigNumber).
108
108
  * @returns transaction hash
109
109
  */
110
- async loadPendingWithdraws(): Promise<WithdrawReceipt[]> {
110
+ async getPendingWithdraws(): Promise<WithdrawReceipt[]> {
111
111
  this.ensureUser();
112
- const address = await this.signer!.getAddress();
112
+ const address = await this.address!;
113
113
 
114
114
  return await this.receiptClient.fetchWithdrawReceipts(address);
115
115
  }
@@ -136,7 +136,7 @@ export class EthereumStakingClient implements IStakingClient {
136
136
  async stake(amount: bigint): Promise<string> {
137
137
  this.ensureUser();
138
138
 
139
- const walletAddress = await this.signer!.getAddress();
139
+ const walletAddress = await this.address!;
140
140
  const amountWei = BigNumber.from(amount);
141
141
 
142
142
  const result = await this.stakeClient.performStake(amountWei, walletAddress);
@@ -168,7 +168,7 @@ export class EthereumStakingClient implements IStakingClient {
168
168
  async buy(amount: bigint): Promise<string> {
169
169
  this.ensureUser();
170
170
 
171
- const buyer = await this.signer!.getAddress();
171
+ const buyer = await this.address!;
172
172
 
173
173
  // ! Hoodi only - check if the mock aggregator price is stale, and if so, update it before submitting the buy request
174
174
  // const network = await this.provider.getNetwork();
@@ -208,7 +208,7 @@ export class EthereumStakingClient implements IStakingClient {
208
208
  try {
209
209
  if (!this.signer) return Promise.resolve(null);
210
210
 
211
- const walletAddress = await this.signer!.getAddress();
211
+ const walletAddress = await this.address!;
212
212
 
213
213
  // 1) Native ETH balance
214
214
  const nativeBalance = await this.provider.getBalance(walletAddress);
@@ -314,7 +314,7 @@ export class EthereumStakingClient implements IStakingClient {
314
314
  async fetchPrelaunchReceipts(address?: string): Promise<preLaunchReceipt[]> {
315
315
  this.ensureUser();
316
316
 
317
- if (address === undefined) address = await this.signer!.getAddress();
317
+ if (address === undefined) address = await this.address!;
318
318
 
319
319
  //default to stake receipts
320
320
  return await this.receiptClient.stakeReceipts(address);
@@ -323,7 +323,7 @@ export class EthereumStakingClient implements IStakingClient {
323
323
  async getOPPMessages(address?: string): Promise<OPPAssertion[]> {
324
324
  this.ensureUser();
325
325
 
326
- if (!address) address = await this.signer!.getAddress();
326
+ if (!address) address = await this.address!;
327
327
 
328
328
  return await this.oppClient.getMessages(address);
329
329
  }
@@ -460,7 +460,7 @@ export class EthereumStakingClient implements IStakingClient {
460
460
  }): Promise<bigint> {
461
461
  this.ensureUser();
462
462
 
463
- const walletAddress = await this.signer!.getAddress();
463
+ const walletAddress = await this.address!;
464
464
 
465
465
  // 1) Estimate a baseline gas usage using a simple self-transfer.
466
466
  // This is cheap and doesn't depend on your contract ABI at all.
@@ -1,5 +1,5 @@
1
1
  import { BigNumber, ethers } from 'ethers';
2
- import { BalanceView } from '../../types';
2
+ import { BalanceView, ChainSymbol, WithdrawReceipt as UnifiedWithdrawReceipt } from '../../types';
3
3
 
4
4
  export const CONTRACT_NAMES = [
5
5
  // LiqETH contracts
@@ -99,17 +99,20 @@ export interface SharesBurnedEvent {
99
99
  tokenValue: BigNumber;
100
100
  }
101
101
 
102
+ /**
103
+ * Legacy stake/pretoken receipt (kept for compatibility; not used in withdraw UI).
104
+ */
102
105
  export interface preLaunchReceipt {
103
106
  tokenId: bigint;
104
107
  receipt: {
105
- account: string,
106
- currency: number,
107
- kind: number,
108
- indexAtMint: BalanceView,
109
- principal: BalanceView,
110
- shares: BalanceView,
111
- timestamp: string,
112
- }
108
+ account: string;
109
+ currency: number;
110
+ kind: number;
111
+ indexAtMint: BalanceView;
112
+ principal: BalanceView;
113
+ shares: BalanceView;
114
+ timestamp: string;
115
+ };
113
116
  }
114
117
 
115
118
  export interface ClaimedEvent {
@@ -117,14 +120,8 @@ export interface ClaimedEvent {
117
120
  amount: BigNumber;
118
121
  }
119
122
 
120
- export interface WithdrawReceipt {
121
- tokenId: bigint;
122
- receipt: {
123
- ethAmount: BigNumber;
124
- ethBalance: BalanceView;
125
- readyAt: number;
126
- }
127
- }
123
+ // Unified withdraw receipt type is defined centrally in src/types.ts
124
+ export type WithdrawReceipt = UnifiedWithdrawReceipt;
128
125
 
129
126
 
130
127
 
@@ -0,0 +1,339 @@
1
+ import { AnchorProvider, BN, Program } from '@coral-xyz/anchor';
2
+ import {
3
+ SystemProgram,
4
+ TransactionInstruction,
5
+ StakeProgram,
6
+ SYSVAR_INSTRUCTIONS_PUBKEY,
7
+ SYSVAR_CLOCK_PUBKEY,
8
+ SYSVAR_RENT_PUBKEY,
9
+ SYSVAR_STAKE_HISTORY_PUBKEY,
10
+ Connection,
11
+ PublicKey,
12
+ } from '@solana/web3.js';
13
+
14
+ import {
15
+ TOKEN_2022_PROGRAM_ID,
16
+ ASSOCIATED_TOKEN_PROGRAM_ID,
17
+ getAssociatedTokenAddressSync,
18
+ } from '@solana/spl-token';
19
+
20
+ import { LiqsolCoreClientIdl, SolanaProgramService } from '../program';
21
+ import { GlobalAccount, ReceiptData, WalletLike } from '../types';
22
+ import { BalanceView, WithdrawReceipt } from '../../../types';
23
+ import { normalizeToBigInt } from '../utils';
24
+
25
+ /**
26
+ * ConvertClient (Solana):
27
+ * - deposit SOL -> liqSOL
28
+ * - request withdraw (liqSOL burn -> NFT receipt)
29
+ * - list withdrawal receipts owned by a user
30
+ * - build claim_withdraw instruction
31
+ */
32
+ export class ConvertClient {
33
+ private program: Program<LiqsolCoreClientIdl>;
34
+
35
+ get connection(): Connection {
36
+ return this.provider.connection;
37
+ }
38
+
39
+ get wallet(): WalletLike {
40
+ return this.provider.wallet;
41
+ }
42
+
43
+ constructor(
44
+ private readonly provider: AnchorProvider,
45
+ private readonly pgs: SolanaProgramService,
46
+ ) {
47
+ this.program = pgs.getProgram('liqsolCore');
48
+ }
49
+
50
+ /**
51
+ * Build a deposit instruction (SOL -> liqSOL).
52
+ */
53
+ async buildDepositTx(
54
+ amount: bigint,
55
+ user = this.wallet.publicKey,
56
+ ): Promise<TransactionInstruction> {
57
+ if (!user) throw new Error('ConvertClient.buildDepositTx: wallet not connected');
58
+ if (!amount || amount <= BigInt(0))
59
+ throw new Error('ConvertClient.buildDepositTx: amount must be greater than zero.');
60
+
61
+ const depositAuthority = this.pgs.deriveDepositAuthorityPda();
62
+ const liqsolMint = this.pgs.deriveLiqsolMintPda();
63
+ const liqsolMintAuthority = this.pgs.deriveLiqsolMintAuthorityPda();
64
+ const reservePool = this.pgs.deriveReservePoolPda();
65
+ const vault = this.pgs.deriveVaultPda();
66
+ const controllerState = this.pgs.deriveStakeControllerStatePda();
67
+ const payoutState = this.pgs.derivePayoutStatePda();
68
+ const bucketAuthority = this.pgs.deriveBucketAuthorityPda();
69
+ const payRateHistory = this.pgs.derivePayRateHistoryPda();
70
+ const globalConfig = this.pgs.deriveGlobalConfigPda();
71
+
72
+ const userAta = getAssociatedTokenAddressSync(liqsolMint, user, true, TOKEN_2022_PROGRAM_ID);
73
+ const distributionState = this.pgs.deriveDistributionStatePda();
74
+ const userRecord = this.pgs.deriveUserRecordPda(userAta);
75
+ const bucketTokenAccount = getAssociatedTokenAddressSync(
76
+ liqsolMint,
77
+ bucketAuthority,
78
+ true,
79
+ TOKEN_2022_PROGRAM_ID,
80
+ );
81
+
82
+ const seed = Math.floor(Math.random() * 2 ** 32);
83
+ const ephemeralStake = await this.pgs.deriveEphemeralStakeAddress(user, seed);
84
+
85
+ return await this.program.methods
86
+ .deposit(new BN(amount.toString()), seed)
87
+ .accounts({
88
+ user,
89
+ depositAuthority,
90
+ systemProgram: SystemProgram.programId,
91
+ tokenProgram: TOKEN_2022_PROGRAM_ID,
92
+ associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
93
+ liqsolProgram: this.pgs.PROGRAM_IDS.LIQSOL_TOKEN,
94
+ stakeProgram: StakeProgram.programId,
95
+ liqsolMint,
96
+ userAta,
97
+ liqsolMintAuthority,
98
+ reservePool,
99
+ vault,
100
+ ephemeralStake,
101
+ controllerState,
102
+ payoutState,
103
+ bucketAuthority,
104
+ bucketTokenAccount,
105
+ userRecord,
106
+ distributionState,
107
+ payRateHistory,
108
+ instructionsSysvar: SYSVAR_INSTRUCTIONS_PUBKEY,
109
+ clock: SYSVAR_CLOCK_PUBKEY,
110
+ stakeHistory: SYSVAR_STAKE_HISTORY_PUBKEY,
111
+ rent: SYSVAR_RENT_PUBKEY,
112
+ globalConfig,
113
+ })
114
+ .instruction();
115
+ }
116
+
117
+ /**
118
+ * Build a withdraw-request instruction (liqSOL burn -> NFT receipt).
119
+ */
120
+ async buildWithdrawTx(
121
+ amount: bigint,
122
+ user = this.wallet.publicKey,
123
+ ): Promise<TransactionInstruction> {
124
+ if (!user) throw new Error('ConvertClient.buildWithdrawTx: wallet not connected');
125
+ if (!amount || amount <= BigInt(0))
126
+ throw new Error('ConvertClient.buildWithdrawTx: amount must be greater than zero.');
127
+
128
+ const liqsolMint = this.pgs.deriveLiqsolMintPda();
129
+ const userAta = getAssociatedTokenAddressSync(liqsolMint, user, true, TOKEN_2022_PROGRAM_ID);
130
+ const userRecord = this.pgs.deriveUserRecordPda(userAta);
131
+ const distributionState = this.pgs.deriveDistributionStatePda();
132
+
133
+ const global = this.pgs.deriveWithdrawGlobalPda();
134
+ const reservePool = this.pgs.deriveReservePoolPda();
135
+ const stakeAllocationState = this.pgs.deriveStakeAllocationStatePda();
136
+ const stakeMetrics = this.pgs.deriveStakeMetricsPda();
137
+ const maintenanceLedger = this.pgs.deriveMaintenanceLedgerPda();
138
+ const globalConfig = this.pgs.deriveGlobalConfigPda();
139
+
140
+ const globalAcct: GlobalAccount = await this.program.account.global.fetch(global);
141
+ const rawId = globalAcct.nextReceiptId;
142
+ const receiptId = normalizeToBigInt(rawId);
143
+
144
+ const mintAuthority = this.pgs.deriveWithdrawMintAuthorityPda();
145
+ const metadata = this.pgs.deriveWithdrawMintMetadataPda();
146
+ const nftMint = this.pgs.deriveWithdrawNftMintPda(receiptId);
147
+ const receiptData = this.pgs.deriveLiqReceiptDataPda(nftMint);
148
+ const owner = user;
149
+ const nftAta = getAssociatedTokenAddressSync(nftMint, owner, true, TOKEN_2022_PROGRAM_ID);
150
+
151
+ const bucketAuthority = this.pgs.deriveBucketAuthorityPda();
152
+ const bucketTokenAccount = getAssociatedTokenAddressSync(
153
+ liqsolMint,
154
+ bucketAuthority,
155
+ true,
156
+ TOKEN_2022_PROGRAM_ID,
157
+ );
158
+
159
+ return await this.program.methods
160
+ .requestWithdraw(new BN(amount.toString()))
161
+ .accounts({
162
+ user,
163
+ owner,
164
+ global,
165
+ liqsolMint,
166
+ userAta,
167
+ userRecord,
168
+ reservePool,
169
+ stakeAllocationState,
170
+ stakeMetrics,
171
+ maintenanceLedger,
172
+ clock: SYSVAR_CLOCK_PUBKEY,
173
+ mintAuthority,
174
+ receiptData,
175
+ metadata,
176
+ nftMint,
177
+ nftAta,
178
+ distributionState,
179
+ bucketTokenAccount,
180
+ tokenProgram: TOKEN_2022_PROGRAM_ID,
181
+ tokenInterface: TOKEN_2022_PROGRAM_ID,
182
+ associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
183
+ systemProgram: SystemProgram.programId,
184
+ rent: SYSVAR_RENT_PUBKEY,
185
+ globalConfig,
186
+ })
187
+ .instruction();
188
+ }
189
+
190
+ /**
191
+ * Enumerate withdrawal receipt NFTs owned by `owner`.
192
+ */
193
+ async fetchWithdrawReceipts(owner: PublicKey): Promise<WithdrawReceipt[]> {
194
+ const globalPda = this.pgs.deriveWithdrawGlobalPda();
195
+ const globalAcct: GlobalAccount = await this.program.account.global.fetch(globalPda);
196
+ const nextId = normalizeToBigInt(globalAcct.nextReceiptId);
197
+
198
+ const mintToId = new Map<string, bigint>();
199
+ for (let i = BigInt(0); i < nextId; i++) {
200
+ const mint = this.pgs.deriveWithdrawNftMintPda(i);
201
+ mintToId.set(mint.toBase58(), i);
202
+ }
203
+
204
+ const tokenAccounts = await this.connection.getParsedTokenAccountsByOwner(owner, {
205
+ programId: TOKEN_2022_PROGRAM_ID,
206
+ });
207
+
208
+ const receipts: WithdrawReceipt[] = [];
209
+
210
+ for (const { pubkey, account } of tokenAccounts.value) {
211
+ const info: any = account.data?.['parsed']?.info;
212
+ if (!info) continue;
213
+ const amount = info.tokenAmount;
214
+ const decimals = Number(amount?.decimals ?? 0);
215
+ const uiAmount = Number(amount?.uiAmount ?? 0);
216
+ if (decimals !== 0 || uiAmount !== 1) continue;
217
+
218
+ const mintStr: string | undefined = info.mint;
219
+ if (!mintStr) continue;
220
+ const receiptId = mintToId.get(mintStr);
221
+ if (receiptId === undefined) continue;
222
+
223
+ const mintKey = new PublicKey(mintStr);
224
+ const receiptDataPda = this.pgs.deriveLiqReceiptDataPda(mintKey);
225
+
226
+ let receiptData: ReceiptData;
227
+ try {
228
+ const raw = await this.program.account.liqReceiptData.fetch(receiptDataPda);
229
+ receiptData = {
230
+ receiptId: normalizeToBigInt(raw.receiptId),
231
+ liqports: normalizeToBigInt(raw.liqports),
232
+ epoch: normalizeToBigInt(raw.epoch),
233
+ fulfilled: Boolean(raw.fulfilled),
234
+ };
235
+ } catch (err) {
236
+ console.warn(`ConvertClient: failed to fetch receipt data for mint ${mintStr}`, err);
237
+ continue;
238
+ }
239
+
240
+ const { etaMs, readyAtMs } = await this.estimateEpochEta(receiptData.epoch);
241
+ const status = receiptData.fulfilled ? 'claimed' : etaMs <= 0 ? 'ready' : 'queued';
242
+
243
+ const amountView: BalanceView = {
244
+ amount: receiptData.liqports,
245
+ decimals: 9,
246
+ symbol: 'SOL',
247
+ };
248
+
249
+ receipts.push({
250
+ tokenId: receiptData.receiptId,
251
+ receipt: {
252
+ amount: amountView,
253
+ readyAt: readyAtMs,
254
+ chain: 'SOL',
255
+ epoch: receiptData.epoch,
256
+ status,
257
+ mint: mintKey.toBase58(),
258
+ ownerAta: pubkey.toBase58(),
259
+ },
260
+ });
261
+ }
262
+
263
+ return receipts;
264
+ }
265
+
266
+ /**
267
+ * Build the claim_withdraw instruction for a given receiptId.
268
+ */
269
+ async buildClaimWithdrawTx(
270
+ receiptId: bigint,
271
+ user: PublicKey,
272
+ ): Promise<TransactionInstruction> {
273
+ const mintAccount = this.pgs.deriveWithdrawNftMintPda(receiptId);
274
+ const receiptData = this.pgs.deriveLiqReceiptDataPda(mintAccount);
275
+ const ownerAta = getAssociatedTokenAddressSync(
276
+ mintAccount,
277
+ user,
278
+ true,
279
+ TOKEN_2022_PROGRAM_ID,
280
+ );
281
+
282
+ const accounts = {
283
+ user,
284
+ global: this.pgs.deriveWithdrawGlobalPda(),
285
+ mintAuthority: this.pgs.deriveWithdrawMintAuthorityPda(),
286
+ receiptData,
287
+ mintAccount,
288
+ ownerAta,
289
+ reservePool: this.pgs.deriveReservePoolPda(),
290
+ vault: this.pgs.deriveVaultPda(),
291
+ clock: SYSVAR_CLOCK_PUBKEY,
292
+ stakeHistory: SYSVAR_STAKE_HISTORY_PUBKEY,
293
+ globalConfig: this.pgs.deriveGlobalConfigPda(),
294
+ tokenProgram: TOKEN_2022_PROGRAM_ID,
295
+ stakeProgram: StakeProgram.programId,
296
+ systemProgram: SystemProgram.programId,
297
+ associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
298
+ };
299
+
300
+ return this.program.methods.claimWithdraw().accounts(accounts).instruction();
301
+ }
302
+
303
+ /**
304
+ * Estimate ready time for target epoch using recent slot time.
305
+ */
306
+ private async estimateEpochEta(targetEpoch: bigint): Promise<{ etaMs: number; readyAtMs: number }> {
307
+ const conn = this.connection;
308
+ const epochInfo = await conn.getEpochInfo();
309
+ const schedule = await conn.getEpochSchedule();
310
+ const currentEpoch = BigInt(epochInfo.epoch);
311
+
312
+ if (targetEpoch <= currentEpoch) {
313
+ const now = Date.now();
314
+ return { etaMs: 0, readyAtMs: now };
315
+ }
316
+
317
+ let slotTimeSec = 0.4;
318
+ try {
319
+ const samples = await conn.getRecentPerformanceSamples(1);
320
+ if (samples?.length) {
321
+ const s = samples[0];
322
+ slotTimeSec = s.numSlots > 0 ? s.samplePeriodSecs / s.numSlots : slotTimeSec;
323
+ }
324
+ } catch (_) {
325
+ // ignore
326
+ }
327
+
328
+ const slotsPerEpoch = BigInt(schedule.slotsPerEpoch);
329
+ const slotsRemainingInCurrent = slotsPerEpoch - BigInt(epochInfo.slotIndex);
330
+ const epochsRemaining = targetEpoch - currentEpoch - BigInt(1);
331
+ const slotsRemaining =
332
+ (epochsRemaining > 0 ? epochsRemaining * slotsPerEpoch : BigInt(0)) +
333
+ slotsRemainingInCurrent;
334
+
335
+ const etaSeconds = Number(slotsRemaining) * slotTimeSec;
336
+ const etaMs = Math.max(0, Math.round(etaSeconds * 1000));
337
+ return { etaMs, readyAtMs: Date.now() + etaMs };
338
+ }
339
+ }