@ember-finance/sdk 2.40.0 → 2.42.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.
@@ -32,6 +32,9 @@ export declare const SOLANA_SEEDS: {
32
32
  readonly parkingLot: "parking_lot";
33
33
  readonly parkingLotEscrow: "parking_lot_escrow";
34
34
  readonly depositRequest: "deposit_request";
35
+ readonly depositUser: "deposit_user";
36
+ readonly withdrawalUser: "withdrawal_user";
37
+ readonly parkingLotUser: "parking_lot_user";
35
38
  };
36
39
  /** A cluster the deployment manifest can describe. */
37
40
  export type SolanaCluster = "localnet" | "devnet" | "testnet" | "mainnet";
@@ -204,8 +207,13 @@ export interface ISolanaVaultState {
204
207
  sequenceNumber: bigint;
205
208
  /** `PublicKey.default` when no lot is bound. */
206
209
  parkingLot: PublicKey;
207
- depositUserList: PublicKey[];
208
- withdrawalUserList: PublicKey[];
210
+ /**
211
+ * Sizes of the two allow lists, not their members: each list is unbounded
212
+ * and lives in one PDA per account. 0 means open to all. Test one account
213
+ * with `deriveDepositUser` / `deriveWithdrawalUser`.
214
+ */
215
+ depositUserCount: bigint;
216
+ withdrawalUserCount: bigint;
209
217
  withdrawalFee: ISolanaWithdrawalFee;
210
218
  }
211
219
  /** `state::WithdrawalRequest`, the entry stored in the queue. */
@@ -241,6 +249,62 @@ export interface ISolanaWithdrawalQueue {
241
249
  head: bigint;
242
250
  tail: bigint;
243
251
  }
252
+ /**
253
+ * `state::DepositParkingLot`: where async deposits wait to be forwarded.
254
+ *
255
+ * A FIFO addressed by sequence number rather than by position: `sequenceNumber`
256
+ * is the last handed out (the tail) and `queueStartIndex` the last settled (the
257
+ * head). Neither is ever reset, so `head === tail` means drained, not empty.
258
+ */
259
+ export interface ISolanaParkingLotState {
260
+ address: PublicKey;
261
+ bump: number;
262
+ /** The bound vault. Immutable after construction. */
263
+ vault: PublicKey;
264
+ /** Runs `process_deposits` and the allow list. Rotated by the vault admin. */
265
+ operator: PublicKey;
266
+ /** Blocks new parks. Processing and cancellation stay live. */
267
+ depositsPaused: boolean;
268
+ /** Blocks cancellation. Parking and processing stay live. */
269
+ cancellationsPaused: boolean;
270
+ /** FIFO tail: the last sequence number handed out. */
271
+ sequenceNumber: bigint;
272
+ /** FIFO head: the last sequence number settled. Never reset. */
273
+ queueStartIndex: bigint;
274
+ /** Collateral escrowed for requests still pending. */
275
+ totalPendingAssets: bigint;
276
+ /**
277
+ * Solana-only: collateral owed to depositors whose refund could not be
278
+ * delivered, awaiting `claim_refund`. The escrow token account holds
279
+ * `totalPendingAssets + totalClaimableAssets`.
280
+ */
281
+ totalClaimableAssets: bigint;
282
+ /** Empty means open to all; otherwise only these may park. */
283
+ /**
284
+ * How many accounts the deposit allow list holds, not the accounts
285
+ * themselves: the list is unbounded and lives in one PDA per member, so
286
+ * there is nothing bounded to put here. 0 means the lot is open to all.
287
+ * Use `deriveParkingLotUser` to test one account.
288
+ */
289
+ depositAllowListCount: bigint;
290
+ }
291
+ /** `state::DepositRequest`: one parked deposit. */
292
+ export interface ISolanaDepositRequest {
293
+ address: PublicKey;
294
+ bump: number;
295
+ parkingLot: PublicKey;
296
+ /** Its place in the lot's FIFO, and the seed of this account. */
297
+ sequenceNumber: bigint;
298
+ /** Escrowed the collateral; receives refunds and the rent. */
299
+ depositor: PublicKey;
300
+ /** Receives the minted vault shares. */
301
+ receiver: PublicKey;
302
+ assets: bigint;
303
+ /** Park time in ms. Informational, as in Move. */
304
+ timestamp: bigint;
305
+ /** Settled, but its refund is waiting to be claimed. */
306
+ claimable: boolean;
307
+ }
244
308
  /** `state::ProtocolConfig`, decoded. */
245
309
  export interface ISolanaProtocolConfigState {
246
310
  admin: PublicKey;
@@ -30,7 +30,13 @@ export const SOLANA_SEEDS = {
30
30
  withdrawalRequest: "withdrawal_request",
31
31
  parkingLot: "parking_lot",
32
32
  parkingLotEscrow: "parking_lot_escrow",
33
- depositRequest: "deposit_request"
33
+ depositRequest: "deposit_request",
34
+ // One entry PDA per allow-listed account. The lists are unbounded, so
35
+ // membership is the existence of this account rather than a Vec on the
36
+ // vault; every gated instruction takes the caller's entry, present or not.
37
+ depositUser: "deposit_user",
38
+ withdrawalUser: "withdrawal_user",
39
+ parkingLotUser: "parking_lot_user"
34
40
  };
35
41
  // Call options and response types ship with the builders that take them, in
36
42
  // the phase that adds writes. Declaring them here first would be a public
@@ -59,4 +59,77 @@ export declare class SolanaTxBuilder {
59
59
  * that `vault` and `owner` are both read-only.
60
60
  */
61
61
  static cancelPendingWithdrawalRequest(ctx: ISolanaVaultContext, owner: PublicKey, sequenceNumber: bigint): TransactionInstruction;
62
+ /** The lot and its escrow, derived from the vault's share mint. */
63
+ private static lotAccounts;
64
+ /**
65
+ * `parking_lot_deposit`: escrows collateral and queues a request.
66
+ *
67
+ * `sequenceNumber` MUST be the lot's current `sequence_number + 1`, since
68
+ * the program increments before it writes. That makes this racy by
69
+ * construction — if another park lands first the address is taken and the
70
+ * transaction fails — so it is read fresh in SolanaUserCalls rather than
71
+ * cached.
72
+ *
73
+ * `receiver` takes the minted shares when the request is forwarded; the
74
+ * depositor keeps the refund and the rent.
75
+ */
76
+ static parkingLotDeposit(ctx: ISolanaVaultContext, depositor: PublicKey, assets: bigint, sequenceNumber: bigint, receiver: PublicKey): TransactionInstruction;
77
+ /**
78
+ * `parking_lot_cancel_deposit`: withdraws a request before it settles and
79
+ * returns the escrowed collateral, closing the account to refund its rent.
80
+ *
81
+ * Takes the request's OWN sequence number, not the lot's next one.
82
+ */
83
+ static parkingLotCancelDeposit(ctx: ISolanaVaultContext, depositor: PublicKey, sequenceNumber: bigint): TransactionInstruction;
84
+ /**
85
+ * `parking_lot_claim_refund`: collects a refund that settlement could not
86
+ * deliver, for a request whose `claimable` flag is set.
87
+ *
88
+ * Identical account list to cancel — same four accounts, same order — so
89
+ * the two differ only in their discriminator and in what the program will
90
+ * accept.
91
+ */
92
+ static parkingLotClaimRefund(ctx: ISolanaVaultContext, depositor: PublicKey, sequenceNumber: bigint): TransactionInstruction;
93
+ private static refundLike;
94
+ /**
95
+ * `parking_lot_process_deposits`: forwards up to `numRequests` from the
96
+ * head of the queue into the vault, minting shares to each receiver.
97
+ *
98
+ * Operator-signed. The per-request accounts are not listed here: the
99
+ * program walks the queue from `queue_start_index` and reads each request
100
+ * through remaining_accounts, which the caller appends.
101
+ */
102
+ static parkingLotProcessDeposits(ctx: ISolanaVaultContext, operator: PublicKey, numRequests: bigint, remaining?: TransactionInstruction["keys"]): TransactionInstruction;
103
+ /** `parking_lot_process_deposits_up_to`: the same, bounded by sequence number. */
104
+ static parkingLotProcessDepositsUpTo(ctx: ISolanaVaultContext, operator: PublicKey, upToSequenceNumber: bigint, remaining?: TransactionInstruction["keys"]): TransactionInstruction;
105
+ private static processLike;
106
+ /**
107
+ * The four admin settings, which share one account list: config, vault,
108
+ * the lot, and the signer. They do NOT share one authority — the program
109
+ * decides, and the shapes being identical hides that:
110
+ *
111
+ * - `set_operator` — the vault admin.
112
+ * - `set_deposits_paused`, `set_cancellations_paused` — the GUARDIAN, via
113
+ * `verify_guardian`, which also fails closed while no guardian is set.
114
+ * - `set_deposit_allow_list` — the lot's operator.
115
+ *
116
+ * Signing a pause with the vault admin key is rejected on chain.
117
+ */
118
+ private static lotSetting;
119
+ /** `parking_lot_set_operator`. */
120
+ static parkingLotSetOperator(ctx: ISolanaVaultContext, signer: PublicKey, newOperator: PublicKey): TransactionInstruction;
121
+ /** `parking_lot_set_deposits_paused`: blocks new parks only. */
122
+ static parkingLotSetDepositsPaused(ctx: ISolanaVaultContext, signer: PublicKey, paused: boolean): TransactionInstruction;
123
+ /** `parking_lot_set_cancellations_paused`: blocks cancellation only. */
124
+ static parkingLotSetCancellationsPaused(ctx: ISolanaVaultContext, signer: PublicKey, paused: boolean): TransactionInstruction;
125
+ /**
126
+ * `parking_lot_set_deposit_allow_list`: adds or removes one account.
127
+ * An empty list means the lot is open to everyone.
128
+ *
129
+ * Not built through `lotSetting`, unlike its three siblings: the list is
130
+ * unbounded, so adding an account CREATES its entry PDA and removing one
131
+ * closes it. That needs the entry and the system program, and makes the
132
+ * signer writable - it pays the rent, and is refunded on removal.
133
+ */
134
+ static parkingLotSetDepositAllowList(ctx: ISolanaVaultContext, signer: PublicKey, account: PublicKey, allowed: boolean): TransactionInstruction;
62
135
  }
@@ -1,5 +1,5 @@
1
1
  import { SystemProgram, TransactionInstruction } from "@solana/web3.js";
2
- import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, associatedTokenAddress, deriveProtocolConfig, deriveVaultAccount, deriveVaultPdas, deriveWithdrawalRequest, eventCpiAccounts, instructionDiscriminator, u128le, u64le } from "../utils/pdas.js";
2
+ import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, associatedTokenAddress, deriveDepositRequest, deriveDepositUser, deriveParkingLot, deriveParkingLotEscrow, deriveParkingLotUser, deriveProtocolConfig, deriveVaultAccount, deriveVaultPdas, deriveWithdrawalRequest, deriveWithdrawalUser, eventCpiAccounts, instructionDiscriminator, u128le, u64le } from "../utils/pdas.js";
3
3
  /**
4
4
  * Builds the program's user instructions. Pure: it derives addresses and
5
5
  * encodes data, and never touches the network or signs.
@@ -63,6 +63,15 @@ export class SolanaTxBuilder {
63
63
  isSigner: false,
64
64
  isWritable: true
65
65
  },
66
+ {
67
+ // The depositor's deposit-allow-list entry, passed whether
68
+ // or not it exists: the list is unbounded and lives in one
69
+ // PDA per member, so the program reads this account to
70
+ // decide. Read-only, and never created here.
71
+ pubkey: deriveDepositUser(ctx.programId, pdas.vault, depositor),
72
+ isSigner: false,
73
+ isWritable: false
74
+ },
66
75
  {
67
76
  pubkey: associatedTokenAddress(depositor, ctx.collateralMint, ctx.collateralProgram),
68
77
  isSigner: false,
@@ -136,6 +145,19 @@ export class SolanaTxBuilder {
136
145
  isSigner: false,
137
146
  isWritable: true
138
147
  },
148
+ {
149
+ // Both ends of the withdrawal allow list. Settlement
150
+ // re-checks them, so a request whose owner or receiver was
151
+ // de-listed meanwhile is skipped rather than paid.
152
+ pubkey: deriveWithdrawalUser(ctx.programId, pdas.vault, owner),
153
+ isSigner: false,
154
+ isWritable: false
155
+ },
156
+ {
157
+ pubkey: deriveWithdrawalUser(ctx.programId, pdas.vault, receiver),
158
+ isSigner: false,
159
+ isWritable: false
160
+ },
139
161
  { pubkey: ctx.shareProgram, isSigner: false, isWritable: false },
140
162
  { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
141
163
  ...eventCpiAccounts(ctx.programId)
@@ -187,4 +209,258 @@ export class SolanaTxBuilder {
187
209
  ])
188
210
  });
189
211
  }
212
+ // --- parking lot ------------------------------------------------------
213
+ //
214
+ // All nine instructions build here. The three user ones are wired into
215
+ // SolanaUserCalls; the operator and admin ones have no call class yet
216
+ // (phases 3-4) but are pure instructions, so a caller can sign and send
217
+ // them today rather than wait for a surface that only wraps them.
218
+ /** The lot and its escrow, derived from the vault's share mint. */
219
+ static lotAccounts(ctx) {
220
+ const { vault } = deriveVaultPdas(ctx.programId, ctx.shareMint);
221
+ const parkingLot = deriveParkingLot(ctx.programId, vault);
222
+ return {
223
+ vault,
224
+ parkingLot,
225
+ escrow: deriveParkingLotEscrow(ctx.programId, parkingLot)
226
+ };
227
+ }
228
+ /**
229
+ * `parking_lot_deposit`: escrows collateral and queues a request.
230
+ *
231
+ * `sequenceNumber` MUST be the lot's current `sequence_number + 1`, since
232
+ * the program increments before it writes. That makes this racy by
233
+ * construction — if another park lands first the address is taken and the
234
+ * transaction fails — so it is read fresh in SolanaUserCalls rather than
235
+ * cached.
236
+ *
237
+ * `receiver` takes the minted shares when the request is forwarded; the
238
+ * depositor keeps the refund and the rent.
239
+ */
240
+ static parkingLotDeposit(ctx, depositor, assets, sequenceNumber, receiver) {
241
+ const { vault, parkingLot, escrow } = SolanaTxBuilder.lotAccounts(ctx);
242
+ return new TransactionInstruction({
243
+ programId: ctx.programId,
244
+ keys: [
245
+ {
246
+ pubkey: deriveProtocolConfig(ctx.programId),
247
+ isSigner: false,
248
+ isWritable: false
249
+ },
250
+ { pubkey: vault, isSigner: false, isWritable: false },
251
+ { pubkey: parkingLot, isSigner: false, isWritable: true },
252
+ {
253
+ // The lot keeps its OWN allow list, separate from the
254
+ // vault's, but the entry is seeded by the vault.
255
+ pubkey: deriveParkingLotUser(ctx.programId, vault, depositor),
256
+ isSigner: false,
257
+ isWritable: false
258
+ },
259
+ { pubkey: escrow, isSigner: false, isWritable: true },
260
+ { pubkey: ctx.collateralMint, isSigner: false, isWritable: false },
261
+ {
262
+ pubkey: deriveDepositRequest(ctx.programId, parkingLot, sequenceNumber),
263
+ isSigner: false,
264
+ isWritable: true
265
+ },
266
+ { pubkey: depositor, isSigner: true, isWritable: true },
267
+ {
268
+ pubkey: deriveVaultAccount(ctx.programId, vault, depositor),
269
+ isSigner: false,
270
+ isWritable: true
271
+ },
272
+ {
273
+ pubkey: associatedTokenAddress(depositor, ctx.collateralMint, ctx.collateralProgram),
274
+ isSigner: false,
275
+ isWritable: true
276
+ },
277
+ { pubkey: ctx.collateralProgram, isSigner: false, isWritable: false },
278
+ { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
279
+ ...eventCpiAccounts(ctx.programId)
280
+ ],
281
+ data: Buffer.concat([
282
+ instructionDiscriminator("parking_lot_deposit"),
283
+ u64le(assets),
284
+ receiver.toBuffer()
285
+ ])
286
+ });
287
+ }
288
+ /**
289
+ * `parking_lot_cancel_deposit`: withdraws a request before it settles and
290
+ * returns the escrowed collateral, closing the account to refund its rent.
291
+ *
292
+ * Takes the request's OWN sequence number, not the lot's next one.
293
+ */
294
+ static parkingLotCancelDeposit(ctx, depositor, sequenceNumber) {
295
+ return SolanaTxBuilder.refundLike(ctx, depositor, sequenceNumber, "parking_lot_cancel_deposit");
296
+ }
297
+ /**
298
+ * `parking_lot_claim_refund`: collects a refund that settlement could not
299
+ * deliver, for a request whose `claimable` flag is set.
300
+ *
301
+ * Identical account list to cancel — same four accounts, same order — so
302
+ * the two differ only in their discriminator and in what the program will
303
+ * accept.
304
+ */
305
+ static parkingLotClaimRefund(ctx, depositor, sequenceNumber) {
306
+ return SolanaTxBuilder.refundLike(ctx, depositor, sequenceNumber, "parking_lot_claim_refund");
307
+ }
308
+ static refundLike(ctx, depositor, sequenceNumber, instruction) {
309
+ const { parkingLot, escrow } = SolanaTxBuilder.lotAccounts(ctx);
310
+ return new TransactionInstruction({
311
+ programId: ctx.programId,
312
+ keys: [
313
+ {
314
+ pubkey: deriveProtocolConfig(ctx.programId),
315
+ isSigner: false,
316
+ isWritable: false
317
+ },
318
+ { pubkey: parkingLot, isSigner: false, isWritable: true },
319
+ { pubkey: escrow, isSigner: false, isWritable: true },
320
+ { pubkey: ctx.collateralMint, isSigner: false, isWritable: false },
321
+ {
322
+ pubkey: deriveDepositRequest(ctx.programId, parkingLot, sequenceNumber),
323
+ isSigner: false,
324
+ isWritable: true
325
+ },
326
+ { pubkey: depositor, isSigner: true, isWritable: true },
327
+ {
328
+ pubkey: associatedTokenAddress(depositor, ctx.collateralMint, ctx.collateralProgram),
329
+ isSigner: false,
330
+ isWritable: true
331
+ },
332
+ { pubkey: ctx.collateralProgram, isSigner: false, isWritable: false },
333
+ ...eventCpiAccounts(ctx.programId)
334
+ ],
335
+ data: Buffer.concat([
336
+ instructionDiscriminator(instruction),
337
+ u128le(sequenceNumber)
338
+ ])
339
+ });
340
+ }
341
+ /**
342
+ * `parking_lot_process_deposits`: forwards up to `numRequests` from the
343
+ * head of the queue into the vault, minting shares to each receiver.
344
+ *
345
+ * Operator-signed. The per-request accounts are not listed here: the
346
+ * program walks the queue from `queue_start_index` and reads each request
347
+ * through remaining_accounts, which the caller appends.
348
+ */
349
+ static parkingLotProcessDeposits(ctx, operator, numRequests, remaining = []) {
350
+ return SolanaTxBuilder.processLike(ctx, operator, "parking_lot_process_deposits", u64le(numRequests), remaining);
351
+ }
352
+ /** `parking_lot_process_deposits_up_to`: the same, bounded by sequence number. */
353
+ static parkingLotProcessDepositsUpTo(ctx, operator, upToSequenceNumber, remaining = []) {
354
+ return SolanaTxBuilder.processLike(ctx, operator, "parking_lot_process_deposits_up_to", u128le(upToSequenceNumber), remaining);
355
+ }
356
+ static processLike(ctx, operator, instruction, arg, remaining) {
357
+ const { vault, parkingLot, escrow } = SolanaTxBuilder.lotAccounts(ctx);
358
+ const pdas = deriveVaultPdas(ctx.programId, ctx.shareMint);
359
+ return new TransactionInstruction({
360
+ programId: ctx.programId,
361
+ keys: [
362
+ {
363
+ pubkey: deriveProtocolConfig(ctx.programId),
364
+ isSigner: false,
365
+ isWritable: false
366
+ },
367
+ { pubkey: vault, isSigner: false, isWritable: true },
368
+ { pubkey: parkingLot, isSigner: false, isWritable: true },
369
+ { pubkey: escrow, isSigner: false, isWritable: true },
370
+ { pubkey: ctx.collateralMint, isSigner: false, isWritable: false },
371
+ { pubkey: ctx.shareMint, isSigner: false, isWritable: true },
372
+ { pubkey: pdas.vaultCollateral, isSigner: false, isWritable: true },
373
+ { pubkey: operator, isSigner: true, isWritable: false },
374
+ { pubkey: ctx.collateralProgram, isSigner: false, isWritable: false },
375
+ { pubkey: ctx.shareProgram, isSigner: false, isWritable: false },
376
+ ...eventCpiAccounts(ctx.programId),
377
+ ...remaining
378
+ ],
379
+ data: Buffer.concat([instructionDiscriminator(instruction), arg])
380
+ });
381
+ }
382
+ /**
383
+ * The four admin settings, which share one account list: config, vault,
384
+ * the lot, and the signer. They do NOT share one authority — the program
385
+ * decides, and the shapes being identical hides that:
386
+ *
387
+ * - `set_operator` — the vault admin.
388
+ * - `set_deposits_paused`, `set_cancellations_paused` — the GUARDIAN, via
389
+ * `verify_guardian`, which also fails closed while no guardian is set.
390
+ * - `set_deposit_allow_list` — the lot's operator.
391
+ *
392
+ * Signing a pause with the vault admin key is rejected on chain.
393
+ */
394
+ static lotSetting(ctx, signer, instruction, arg) {
395
+ const { vault, parkingLot } = SolanaTxBuilder.lotAccounts(ctx);
396
+ return new TransactionInstruction({
397
+ programId: ctx.programId,
398
+ keys: [
399
+ {
400
+ pubkey: deriveProtocolConfig(ctx.programId),
401
+ isSigner: false,
402
+ isWritable: false
403
+ },
404
+ { pubkey: vault, isSigner: false, isWritable: false },
405
+ { pubkey: parkingLot, isSigner: false, isWritable: true },
406
+ { pubkey: signer, isSigner: true, isWritable: false },
407
+ ...eventCpiAccounts(ctx.programId)
408
+ ],
409
+ data: Buffer.concat([instructionDiscriminator(instruction), arg])
410
+ });
411
+ }
412
+ /** `parking_lot_set_operator`. */
413
+ static parkingLotSetOperator(ctx, signer, newOperator) {
414
+ return SolanaTxBuilder.lotSetting(ctx, signer, "parking_lot_set_operator", newOperator.toBuffer());
415
+ }
416
+ /** `parking_lot_set_deposits_paused`: blocks new parks only. */
417
+ static parkingLotSetDepositsPaused(ctx, signer, paused) {
418
+ return SolanaTxBuilder.lotSetting(ctx, signer, "parking_lot_set_deposits_paused", Buffer.from([paused ? 1 : 0]));
419
+ }
420
+ /** `parking_lot_set_cancellations_paused`: blocks cancellation only. */
421
+ static parkingLotSetCancellationsPaused(ctx, signer, paused) {
422
+ return SolanaTxBuilder.lotSetting(ctx, signer, "parking_lot_set_cancellations_paused", Buffer.from([paused ? 1 : 0]));
423
+ }
424
+ /**
425
+ * `parking_lot_set_deposit_allow_list`: adds or removes one account.
426
+ * An empty list means the lot is open to everyone.
427
+ *
428
+ * Not built through `lotSetting`, unlike its three siblings: the list is
429
+ * unbounded, so adding an account CREATES its entry PDA and removing one
430
+ * closes it. That needs the entry and the system program, and makes the
431
+ * signer writable - it pays the rent, and is refunded on removal.
432
+ */
433
+ static parkingLotSetDepositAllowList(ctx, signer, account, allowed) {
434
+ const { vault, parkingLot } = SolanaTxBuilder.lotAccounts(ctx);
435
+ return new TransactionInstruction({
436
+ programId: ctx.programId,
437
+ keys: [
438
+ {
439
+ pubkey: deriveProtocolConfig(ctx.programId),
440
+ isSigner: false,
441
+ isWritable: false
442
+ },
443
+ { pubkey: vault, isSigner: false, isWritable: false },
444
+ { pubkey: parkingLot, isSigner: false, isWritable: true },
445
+ {
446
+ // The entry for the account being listed, not the signer.
447
+ pubkey: deriveParkingLotUser(ctx.programId, vault, account),
448
+ isSigner: false,
449
+ isWritable: true
450
+ },
451
+ { pubkey: signer, isSigner: true, isWritable: true },
452
+ {
453
+ pubkey: SystemProgram.programId,
454
+ isSigner: false,
455
+ isWritable: false
456
+ },
457
+ ...eventCpiAccounts(ctx.programId)
458
+ ],
459
+ data: Buffer.concat([
460
+ instructionDiscriminator("parking_lot_set_deposit_allow_list"),
461
+ account.toBuffer(),
462
+ Buffer.from([allowed ? 1 : 0])
463
+ ])
464
+ });
465
+ }
190
466
  }
@@ -1,5 +1,5 @@
1
1
  import { type Connection, PublicKey, type TransactionInstruction } from "@solana/web3.js";
2
- import { type ISolanaWithdrawalRequest } from "../interfaces/index.js";
2
+ import { type ISolanaDepositRequest, type ISolanaWithdrawalRequest } from "../interfaces/index.js";
3
3
  import { type SolanaSigner } from "../signers/solana-signer.js";
4
4
  import { SolanaDeploymentParser } from "../utils/deployment-parser.js";
5
5
  /** What a write returns. */
@@ -113,6 +113,56 @@ export declare class SolanaUserCalls {
113
113
  * on every repeat deposit for nothing.
114
114
  */
115
115
  private ensureShareAccount;
116
+ /**
117
+ * Parks a deposit: escrows collateral and queues a request for the
118
+ * operator to forward into the vault later. No shares are minted here.
119
+ *
120
+ * THIS CALL IS RACY BY CONSTRUCTION. The request's account address is
121
+ * seeded with the lot's `sequence_number + 1`, and the program increments
122
+ * before it writes, so two parks landing in the same slot collide and the
123
+ * loser fails its address check. The lot is read immediately before
124
+ * building to keep the window small, but it cannot be closed from here —
125
+ * on `ParkingLotSequenceTaken` the caller should simply call again, which
126
+ * re-reads and takes the next slot.
127
+ *
128
+ * No automatic retry: a resend after a failed SEND (rather than a failed
129
+ * simulation) could double-park if the first transaction actually landed.
130
+ *
131
+ * `receiver` takes the minted shares when the request is forwarded. The
132
+ * depositor keeps the refund and the account's rent either way.
133
+ */
134
+ parkDeposit(shareMint: PublicKey | string, assets: SolanaAmount, options?: ISolanaRedeemOptions): Promise<ISolanaTxResponse & {
135
+ sequenceNumber: bigint;
136
+ }>;
137
+ /**
138
+ * Cancels a parked deposit before it settles, returning the escrowed
139
+ * collateral and the account's rent.
140
+ *
141
+ * Takes the request's own sequence number — from `getParkedDeposits` —
142
+ * not the lot's next one.
143
+ */
144
+ cancelParkedDeposit(shareMint: PublicKey | string, sequenceNumber: SolanaAmount, options?: ISolanaWriteOptions): Promise<ISolanaTxResponse>;
145
+ /**
146
+ * Claims a refund that settlement could not deliver.
147
+ *
148
+ * Only for a request whose `claimable` flag is set — the request settled
149
+ * but the collateral could not be returned, so it waits in escrow under
150
+ * the lot's `totalClaimableAssets`. `getParkedDeposits` separates these
151
+ * from the ones still pending.
152
+ */
153
+ claimParkedRefund(shareMint: PublicKey | string, sequenceNumber: SolanaAmount, options?: ISolanaWriteOptions): Promise<ISolanaTxResponse>;
154
+ /**
155
+ * This depositor's parked deposits, split into those still queued and
156
+ * those whose refund is waiting to be claimed.
157
+ *
158
+ * Found by filtering on the request accounts themselves rather than by
159
+ * walking the queue: a claimable request has already settled, so it sits
160
+ * BELOW the lot's head and no scan of the pending window would see it.
161
+ */
162
+ getParkedDeposits(shareMint: PublicKey | string, owner?: PublicKey | string): Promise<{
163
+ pending: ISolanaDepositRequest[];
164
+ claimable: ISolanaDepositRequest[];
165
+ }>;
116
166
  /**
117
167
  * Queues a withdrawal. This does NOT return collateral: the shares are
118
168
  * escrowed and a request is appended to the vault's queue, which the
@@ -205,15 +205,107 @@ export class SolanaUserCalls {
205
205
  * missing: including it unconditionally costs a few hundred compute units
206
206
  * on every repeat deposit for nothing.
207
207
  */
208
- async ensureShareAccount(ctx, owner) {
208
+ async ensureShareAccount(ctx, owner, payer = owner) {
209
209
  const ata = associatedTokenAddress(owner, ctx.shareMint, ctx.shareProgram);
210
210
  const existing = await this.connection.getAccountInfo(ata);
211
211
  if (existing !== null)
212
212
  return [];
213
213
  return [
214
- SolanaTxBuilder.createAtaIdempotent(owner, owner, ctx.shareMint, ctx.shareProgram)
214
+ SolanaTxBuilder.createAtaIdempotent(payer, owner, ctx.shareMint, ctx.shareProgram)
215
215
  ];
216
216
  }
217
+ // --- parking lot ---
218
+ /**
219
+ * Parks a deposit: escrows collateral and queues a request for the
220
+ * operator to forward into the vault later. No shares are minted here.
221
+ *
222
+ * THIS CALL IS RACY BY CONSTRUCTION. The request's account address is
223
+ * seeded with the lot's `sequence_number + 1`, and the program increments
224
+ * before it writes, so two parks landing in the same slot collide and the
225
+ * loser fails its address check. The lot is read immediately before
226
+ * building to keep the window small, but it cannot be closed from here —
227
+ * on `ParkingLotSequenceTaken` the caller should simply call again, which
228
+ * re-reads and takes the next slot.
229
+ *
230
+ * No automatic retry: a resend after a failed SEND (rather than a failed
231
+ * simulation) could double-park if the first transaction actually landed.
232
+ *
233
+ * `receiver` takes the minted shares when the request is forwarded. The
234
+ * depositor keeps the refund and the account's rent either way.
235
+ */
236
+ async parkDeposit(shareMint, assets, options) {
237
+ const assetsValue = toBigInt(assets, "assets");
238
+ const signer = this.resolveSigner(options?.signer);
239
+ const ctx = await this.vaultContext(shareMint);
240
+ const lot = await this.reader.getParkingLot(ctx.shareMint);
241
+ if (lot === null) {
242
+ throw new Error(`vault ${ctx.shareMint.toBase58()} has no parking lot; deposit directly instead`);
243
+ }
244
+ if (lot.depositsPaused) {
245
+ throw new Error("the parking lot is not accepting deposits");
246
+ }
247
+ // +1: the slot this park will take, because the program increments
248
+ // before it writes.
249
+ const sequenceNumber = lot.sequenceNumber + 1n;
250
+ const receiver = options?.receiver
251
+ ? this.key(options.receiver)
252
+ : signer.publicKey;
253
+ // The program only checks the receiver's share account when the request
254
+ // is FORWARDED, and a missing one makes it skip and refund instead
255
+ // (`parking_lot.rs` can_receive_mint / should_skip). A first-time
256
+ // receiver's park would otherwise succeed here and silently become a
257
+ // refund later, long after the caller stopped watching. Idempotent, so
258
+ // it is a no-op when the account already exists, and the signer pays
259
+ // because the receiver may never have signed anything.
260
+ const response = await this.send([
261
+ ...(await this.ensureShareAccount(ctx, receiver, signer.publicKey)),
262
+ SolanaTxBuilder.parkingLotDeposit(ctx, signer.publicKey, assetsValue, sequenceNumber, receiver)
263
+ ], signer, options);
264
+ return { ...response, sequenceNumber };
265
+ }
266
+ /**
267
+ * Cancels a parked deposit before it settles, returning the escrowed
268
+ * collateral and the account's rent.
269
+ *
270
+ * Takes the request's own sequence number — from `getParkedDeposits` —
271
+ * not the lot's next one.
272
+ */
273
+ async cancelParkedDeposit(shareMint, sequenceNumber, options) {
274
+ rejectReceiver(options, "cancelParkedDeposit");
275
+ const sequenceValue = toBigInt(sequenceNumber, "sequenceNumber");
276
+ const signer = this.resolveSigner(options?.signer);
277
+ const ctx = await this.vaultContext(shareMint);
278
+ return this.send([
279
+ SolanaTxBuilder.parkingLotCancelDeposit(ctx, signer.publicKey, sequenceValue)
280
+ ], signer, options);
281
+ }
282
+ /**
283
+ * Claims a refund that settlement could not deliver.
284
+ *
285
+ * Only for a request whose `claimable` flag is set — the request settled
286
+ * but the collateral could not be returned, so it waits in escrow under
287
+ * the lot's `totalClaimableAssets`. `getParkedDeposits` separates these
288
+ * from the ones still pending.
289
+ */
290
+ async claimParkedRefund(shareMint, sequenceNumber, options) {
291
+ rejectReceiver(options, "claimParkedRefund");
292
+ const sequenceValue = toBigInt(sequenceNumber, "sequenceNumber");
293
+ const signer = this.resolveSigner(options?.signer);
294
+ const ctx = await this.vaultContext(shareMint);
295
+ return this.send([SolanaTxBuilder.parkingLotClaimRefund(ctx, signer.publicKey, sequenceValue)], signer, options);
296
+ }
297
+ /**
298
+ * This depositor's parked deposits, split into those still queued and
299
+ * those whose refund is waiting to be claimed.
300
+ *
301
+ * Found by filtering on the request accounts themselves rather than by
302
+ * walking the queue: a claimable request has already settled, so it sits
303
+ * BELOW the lot's head and no scan of the pending window would see it.
304
+ */
305
+ async getParkedDeposits(shareMint, owner) {
306
+ const address = owner ? this.key(owner) : this.resolveSigner().publicKey;
307
+ return this.reader.getDepositsFor(shareMint, address);
308
+ }
217
309
  // --- withdrawals ---
218
310
  /**
219
311
  * Queues a withdrawal. This does NOT return collateral: the shares are
@@ -1,5 +1,5 @@
1
1
  import { type Connection, PublicKey } from "@solana/web3.js";
2
- import { type ISolanaProtocolConfigState, type ISolanaVaultAccount, type ISolanaVaultPdas, type ISolanaVaultState, type ISolanaWithdrawalRequestAccount } from "../interfaces/index.js";
2
+ import { type ISolanaDepositRequest, type ISolanaParkingLotState, type ISolanaProtocolConfigState, type ISolanaVaultAccount, type ISolanaVaultPdas, type ISolanaVaultState, type ISolanaWithdrawalRequestAccount } from "../interfaces/index.js";
3
3
  import { type IWithdrawalFeeBreakdown } from "../utils/math.js";
4
4
  import { SolanaDeploymentParser } from "../utils/deployment-parser.js";
5
5
  /**
@@ -22,6 +22,18 @@ export declare class SolanaVaultReader {
22
22
  updateDeployment(parser: SolanaDeploymentParser): void;
23
23
  get programId(): PublicKey;
24
24
  private key;
25
+ /**
26
+ * Whether an account is on one of the unbounded allow lists.
27
+ *
28
+ * The program's own test is `UserListEntry::exists`: owned by the program
29
+ * AND non-empty. A bare "the account exists" is weaker and wrong here -
30
+ * these addresses are public and derivable, so anyone can make
31
+ * `getAccountInfo` return non-null for one by sending it lamports. That
32
+ * system-owned, dataless account would read as listed and then be rejected
33
+ * on chain, which is a confusing false positive on a path users hit
34
+ * directly.
35
+ */
36
+ private isListed;
25
37
  /** The protocol config, or null when the protocol is not initialised. */
26
38
  getProtocolConfig(): Promise<ISolanaProtocolConfigState | null>;
27
39
  isProtocolPaused(): Promise<boolean>;
@@ -139,6 +151,73 @@ export declare class SolanaVaultReader {
139
151
  private canDeposit;
140
152
  /** `quote.rs::can_withdraw`. */
141
153
  private canWithdraw;
154
+ /** The lot's address for a vault, whether or not one exists. */
155
+ parkingLotAddress(shareMint: PublicKey | string): PublicKey;
156
+ /**
157
+ * A vault's parking lot, or null when it has none.
158
+ *
159
+ * Null is the ordinary case: a lot is opt-in per vault, so most vaults have
160
+ * no account at this address at all.
161
+ */
162
+ getParkingLot(shareMint: PublicKey | string): Promise<ISolanaParkingLotState | null>;
163
+ /** The lot's escrow token account. */
164
+ parkingLotEscrowAddress(shareMint: PublicKey | string): PublicKey;
165
+ /** One parked deposit by its sequence number, or null if it is gone. */
166
+ getDepositRequest(shareMint: PublicKey | string, sequenceNumber: bigint): Promise<ISolanaDepositRequest | null>;
167
+ /**
168
+ * A page of the lot's queue, oldest first, with an explicit cursor.
169
+ *
170
+ * `limit` counts SLOTS SCANNED, not requests returned, because a slot may
171
+ * be empty: a cancelled request's account is closed while its number stays
172
+ * in the range. `scannedTo` is therefore returned separately and is always
173
+ * advanced — a page of zero requests still tells the caller where to
174
+ * resume, which an empty array alone could not.
175
+ *
176
+ * Reads are batched at `MAX_MULTIPLE_ACCOUNTS`: `getMultipleAccounts`
177
+ * refuses more than 100 pubkeys per call, so a larger window is split
178
+ * rather than failing.
179
+ *
180
+ * This window is `(queueStartIndex, sequenceNumber]` — the UNSETTLED part
181
+ * of the queue. Requests whose refund is waiting to be claimed sit BELOW
182
+ * the head and are not here; use `getRequestsByDepositor` for those.
183
+ */
184
+ getPendingDeposits(shareMint: PublicKey | string, options?: {
185
+ limit?: number;
186
+ after?: bigint;
187
+ }): Promise<{
188
+ requests: ISolanaDepositRequest[];
189
+ /** The last slot examined. Pass back as `after` to continue. */
190
+ scannedTo: bigint;
191
+ hasMore: boolean;
192
+ }>;
193
+ /**
194
+ * Every surviving request belonging to one depositor, wherever it sits in
195
+ * the queue.
196
+ *
197
+ * Position-independent on purpose. A CLAIMABLE request — one that settled
198
+ * but whose refund could not be delivered — lives BELOW `queueStartIndex`
199
+ * (see parking_lot.rs: "below the head only claimable requests remain"),
200
+ * so no scan of the pending window can ever find one. Walking downwards
201
+ * instead is unbounded and mostly hits closed accounts.
202
+ *
203
+ * So this filters on chain by the account's own bytes: the request's size,
204
+ * its parking lot at offset 9 and its depositor at offset 57. Those
205
+ * offsets track `DepositRequest` in state.rs and are pinned by test.
206
+ *
207
+ * `getProgramAccounts` is an indexed scan and some public RPCs restrict
208
+ * it; a provider endpoint is the right place to run this.
209
+ */
210
+ getRequestsByDepositor(shareMint: PublicKey | string, depositor: PublicKey | string): Promise<ISolanaDepositRequest[]>;
211
+ /**
212
+ * A depositor's requests, split by what they can do with them.
213
+ *
214
+ * `pending` is still queued and can be cancelled; `claimable` has settled
215
+ * with an undeliverable refund and is collected with `claim_refund`.
216
+ */
217
+ getDepositsFor(shareMint: PublicKey | string, depositor: PublicKey | string): Promise<{
218
+ pending: ISolanaDepositRequest[];
219
+ claimable: ISolanaDepositRequest[];
220
+ }>;
142
221
  /** An owner's vault account, or null before their first deposit or redeem. */
143
222
  getAccountState(shareMint: PublicKey | string, owner: PublicKey | string): Promise<ISolanaVaultAccount | null>;
144
223
  /** Queue head and tail. Equal means drained. */
@@ -1,6 +1,6 @@
1
1
  import { PublicKey } from "@solana/web3.js";
2
- import { decodeMint, decodeProtocolConfig, decodeVault, decodeVaultAccount, decodeWithdrawalQueue, decodeWithdrawalRequestAccount } from "../utils/borsh.js";
3
- import { accountDiscriminator, associatedTokenAddress, deriveVaultAccount } from "../utils/pdas.js";
2
+ import { decodeDepositParkingLot, decodeDepositRequest, decodeMint, decodeProtocolConfig, decodeVault, decodeVaultAccount, decodeWithdrawalQueue, decodeWithdrawalRequestAccount } from "../utils/borsh.js";
3
+ import { DEPOSIT_REQUEST_OFFSETS, DEPOSIT_REQUEST_SIZE, MAX_MULTIPLE_ACCOUNTS, MAX_QUEUE_SCAN, accountDiscriminator, associatedTokenAddress, deriveDepositRequest, deriveDepositUser, deriveParkingLot, deriveParkingLotEscrow, deriveVaultAccount, deriveWithdrawalUser } from "../utils/pdas.js";
4
4
  import { amountFromShares, amountFromSharesFloor, maxDepositAmount, maxMintShares, sharesFromAmount, sharesFromAmountCeil, vaultTvl, withdrawalFee } from "../utils/math.js";
5
5
  /**
6
6
  * Reads vault state from the chain.
@@ -29,6 +29,22 @@ export class SolanaVaultReader {
29
29
  key(value) {
30
30
  return typeof value === "string" ? new PublicKey(value) : value;
31
31
  }
32
+ /**
33
+ * Whether an account is on one of the unbounded allow lists.
34
+ *
35
+ * The program's own test is `UserListEntry::exists`: owned by the program
36
+ * AND non-empty. A bare "the account exists" is weaker and wrong here -
37
+ * these addresses are public and derivable, so anyone can make
38
+ * `getAccountInfo` return non-null for one by sending it lamports. That
39
+ * system-owned, dataless account would read as listed and then be rejected
40
+ * on chain, which is a confusing false positive on a path users hit
41
+ * directly.
42
+ */
43
+ async isListed(derive, shareMint, account) {
44
+ const entry = derive(this.programId, this.getVaultPdas(shareMint).vault, this.key(account));
45
+ const info = await this.connection.getAccountInfo(entry);
46
+ return info !== null && info.owner.equals(this.programId) && info.data.length > 0;
47
+ }
32
48
  // --- raw state ---
33
49
  /** The protocol config, or null when the protocol is not initialised. */
34
50
  async getProtocolConfig() {
@@ -238,32 +254,35 @@ export class SolanaVaultReader {
238
254
  return sharesFromAmountCeil(assets, (await this.getVaultState(shareMint)).rate.value);
239
255
  }
240
256
  async maxDeposit(shareMint, owner) {
241
- const [vault, config, supply] = await Promise.all([
257
+ const [vault, config, supply, listed] = await Promise.all([
242
258
  this.getVaultState(shareMint),
243
259
  this.getProtocolConfig(),
244
- this.getTotalSupply(shareMint)
260
+ this.getTotalSupply(shareMint),
261
+ this.isListed(deriveDepositUser, shareMint, owner)
245
262
  ]);
246
- if (!this.canDeposit(vault, config, this.key(owner)))
263
+ if (!this.canDeposit(vault, config, this.key(owner), listed))
247
264
  return 0n;
248
265
  return maxDepositAmount(supply, vault.rate.value, vault.maxTvl);
249
266
  }
250
267
  async maxMint(shareMint, owner) {
251
- const [vault, config, supply] = await Promise.all([
268
+ const [vault, config, supply, listed] = await Promise.all([
252
269
  this.getVaultState(shareMint),
253
270
  this.getProtocolConfig(),
254
- this.getTotalSupply(shareMint)
271
+ this.getTotalSupply(shareMint),
272
+ this.isListed(deriveDepositUser, shareMint, owner)
255
273
  ]);
256
- if (!this.canDeposit(vault, config, this.key(owner)))
274
+ if (!this.canDeposit(vault, config, this.key(owner), listed))
257
275
  return 0n;
258
276
  return maxMintShares(supply, vault.rate.value, vault.maxTvl);
259
277
  }
260
278
  async maxRedeem(shareMint, owner) {
261
- const [vault, config, balance] = await Promise.all([
279
+ const [vault, config, balance, listed] = await Promise.all([
262
280
  this.getVaultState(shareMint),
263
281
  this.getProtocolConfig(),
264
- this.balanceOf(shareMint, owner)
282
+ this.balanceOf(shareMint, owner),
283
+ this.isListed(deriveWithdrawalUser, shareMint, owner)
265
284
  ]);
266
- if (!this.canWithdraw(vault, config, this.key(owner)))
285
+ if (!this.canWithdraw(vault, config, this.key(owner), listed))
267
286
  return 0n;
268
287
  // Below the minimum the whole balance is unredeemable, not merely capped.
269
288
  return balance < vault.minWithdrawalShares ? 0n : balance;
@@ -282,7 +301,7 @@ export class SolanaVaultReader {
282
301
  : amountFromSharesFloor(redeemable, vault.rate.value);
283
302
  }
284
303
  /** `quote.rs::can_deposit`. */
285
- canDeposit(vault, config, owner) {
304
+ canDeposit(vault, config, owner, listed) {
286
305
  if (config?.pauseNonAdminOperations)
287
306
  return false;
288
307
  if (vault.paused || vault.pausedDeposits)
@@ -293,22 +312,164 @@ export class SolanaVaultReader {
293
312
  return false;
294
313
  // A bound parking lot closes the direct path to everyone not listed;
295
314
  // with no lot, an empty list is open to all.
296
- if (!vault.parkingLot.equals(PublicKey.default)) {
297
- return vault.depositUserList.some(a => a.equals(owner));
298
- }
299
- return (vault.depositUserList.length === 0 ||
300
- vault.depositUserList.some(a => a.equals(owner)));
315
+ if (!vault.parkingLot.equals(PublicKey.default))
316
+ return listed;
317
+ return vault.depositUserCount === 0n || listed;
301
318
  }
302
319
  /** `quote.rs::can_withdraw`. */
303
- canWithdraw(vault, config, owner) {
320
+ canWithdraw(vault, config, owner, listed) {
304
321
  if (config?.pauseNonAdminOperations)
305
322
  return false;
306
323
  if (vault.paused || vault.pausedWithdrawals)
307
324
  return false;
308
325
  if (vault.blacklisted.some(a => a.equals(owner)))
309
326
  return false;
310
- return (vault.withdrawalUserList.length === 0 ||
311
- vault.withdrawalUserList.some(a => a.equals(owner)));
327
+ return vault.withdrawalUserCount === 0n || listed;
328
+ }
329
+ // --- parking lot ---
330
+ /** The lot's address for a vault, whether or not one exists. */
331
+ parkingLotAddress(shareMint) {
332
+ return deriveParkingLot(this.programId, this.getVaultPdas(shareMint).vault);
333
+ }
334
+ /**
335
+ * A vault's parking lot, or null when it has none.
336
+ *
337
+ * Null is the ordinary case: a lot is opt-in per vault, so most vaults have
338
+ * no account at this address at all.
339
+ */
340
+ async getParkingLot(shareMint) {
341
+ const address = this.parkingLotAddress(shareMint);
342
+ const account = await this.connection.getAccountInfo(address);
343
+ return account === null ? null : decodeDepositParkingLot(account.data, address);
344
+ }
345
+ /** The lot's escrow token account. */
346
+ parkingLotEscrowAddress(shareMint) {
347
+ return deriveParkingLotEscrow(this.programId, this.parkingLotAddress(shareMint));
348
+ }
349
+ /** One parked deposit by its sequence number, or null if it is gone. */
350
+ async getDepositRequest(shareMint, sequenceNumber) {
351
+ const address = deriveDepositRequest(this.programId, this.parkingLotAddress(shareMint), sequenceNumber);
352
+ const account = await this.connection.getAccountInfo(address);
353
+ return account === null ? null : decodeDepositRequest(account.data, address);
354
+ }
355
+ /**
356
+ * A page of the lot's queue, oldest first, with an explicit cursor.
357
+ *
358
+ * `limit` counts SLOTS SCANNED, not requests returned, because a slot may
359
+ * be empty: a cancelled request's account is closed while its number stays
360
+ * in the range. `scannedTo` is therefore returned separately and is always
361
+ * advanced — a page of zero requests still tells the caller where to
362
+ * resume, which an empty array alone could not.
363
+ *
364
+ * Reads are batched at `MAX_MULTIPLE_ACCOUNTS`: `getMultipleAccounts`
365
+ * refuses more than 100 pubkeys per call, so a larger window is split
366
+ * rather than failing.
367
+ *
368
+ * This window is `(queueStartIndex, sequenceNumber]` — the UNSETTLED part
369
+ * of the queue. Requests whose refund is waiting to be claimed sit BELOW
370
+ * the head and are not here; use `getRequestsByDepositor` for those.
371
+ */
372
+ async getPendingDeposits(shareMint, options) {
373
+ const lot = await this.getParkingLot(shareMint);
374
+ if (lot === null) {
375
+ return { requests: [], scannedTo: 0n, hasMore: false };
376
+ }
377
+ // NaN survives trunc/max/min, and `slots.length < NaN` is false, so the
378
+ // scan loop would never run and `slots[-1].seq` would throw below.
379
+ const rawLimit = options?.limit;
380
+ const limit = Number.isFinite(rawLimit)
381
+ ? Math.min(Math.max(Math.trunc(rawLimit), 1), MAX_QUEUE_SCAN)
382
+ : MAX_MULTIPLE_ACCOUNTS;
383
+ // Clamp up to the head. Below it only CLAIMABLE requests survive (the
384
+ // program settles the rest and the head only moves forward), so an
385
+ // `after` behind the head would walk settled slots and report their
386
+ // requests as pending. Clamping also keeps the cursor monotonic: an
387
+ // `after` past the tail resumes at itself rather than being dragged
388
+ // back to `sequenceNumber`.
389
+ const after = options?.after ?? lot.queueStartIndex;
390
+ const resume = after > lot.queueStartIndex ? after : lot.queueStartIndex;
391
+ const from = resume + 1n;
392
+ if (from > lot.sequenceNumber) {
393
+ return { requests: [], scannedTo: resume, hasMore: false };
394
+ }
395
+ const slots = [];
396
+ for (let seq = from; seq <= lot.sequenceNumber && slots.length < limit; seq++) {
397
+ slots.push({
398
+ seq,
399
+ address: deriveDepositRequest(this.programId, lot.address, seq)
400
+ });
401
+ }
402
+ const requests = [];
403
+ for (let i = 0; i < slots.length; i += MAX_MULTIPLE_ACCOUNTS) {
404
+ const batch = slots.slice(i, i + MAX_MULTIPLE_ACCOUNTS);
405
+ const accounts = await this.connection.getMultipleAccountsInfo(batch.map(b => b.address));
406
+ accounts.forEach((account, j) => {
407
+ if (account === null)
408
+ return; // cancelled or settled: slot closed
409
+ requests.push(decodeDepositRequest(account.data, batch[j].address));
410
+ });
411
+ }
412
+ const scannedTo = slots[slots.length - 1].seq;
413
+ return { requests, scannedTo, hasMore: scannedTo < lot.sequenceNumber };
414
+ }
415
+ /**
416
+ * Every surviving request belonging to one depositor, wherever it sits in
417
+ * the queue.
418
+ *
419
+ * Position-independent on purpose. A CLAIMABLE request — one that settled
420
+ * but whose refund could not be delivered — lives BELOW `queueStartIndex`
421
+ * (see parking_lot.rs: "below the head only claimable requests remain"),
422
+ * so no scan of the pending window can ever find one. Walking downwards
423
+ * instead is unbounded and mostly hits closed accounts.
424
+ *
425
+ * So this filters on chain by the account's own bytes: the request's size,
426
+ * its parking lot at offset 9 and its depositor at offset 57. Those
427
+ * offsets track `DepositRequest` in state.rs and are pinned by test.
428
+ *
429
+ * `getProgramAccounts` is an indexed scan and some public RPCs restrict
430
+ * it; a provider endpoint is the right place to run this.
431
+ */
432
+ async getRequestsByDepositor(shareMint, depositor) {
433
+ const lot = await this.getParkingLot(shareMint);
434
+ if (lot === null)
435
+ return [];
436
+ const accounts = await this.connection.getProgramAccounts(this.programId, {
437
+ filters: [
438
+ { dataSize: DEPOSIT_REQUEST_SIZE },
439
+ {
440
+ memcmp: {
441
+ offset: DEPOSIT_REQUEST_OFFSETS.parkingLot,
442
+ bytes: lot.address.toBase58()
443
+ }
444
+ },
445
+ {
446
+ memcmp: {
447
+ offset: DEPOSIT_REQUEST_OFFSETS.depositor,
448
+ bytes: this.key(depositor).toBase58()
449
+ }
450
+ }
451
+ ]
452
+ });
453
+ return accounts
454
+ .map(({ pubkey, account }) => decodeDepositRequest(account.data, pubkey))
455
+ .sort((a, b) => a.sequenceNumber < b.sequenceNumber
456
+ ? -1
457
+ : a.sequenceNumber > b.sequenceNumber
458
+ ? 1
459
+ : 0);
460
+ }
461
+ /**
462
+ * A depositor's requests, split by what they can do with them.
463
+ *
464
+ * `pending` is still queued and can be cancelled; `claimable` has settled
465
+ * with an undeliverable refund and is collected with `claim_refund`.
466
+ */
467
+ async getDepositsFor(shareMint, depositor) {
468
+ const mine = await this.getRequestsByDepositor(shareMint, depositor);
469
+ return {
470
+ pending: mine.filter(r => !r.claimable),
471
+ claimable: mine.filter(r => r.claimable)
472
+ };
312
473
  }
313
474
  // --- withdrawals ---
314
475
  /** An owner's vault account, or null before their first deposit or redeem. */
@@ -1,5 +1,5 @@
1
1
  import { PublicKey } from "@solana/web3.js";
2
- import type { ISolanaProtocolConfigState, ISolanaVaultAccount, ISolanaVaultState, ISolanaWithdrawalQueue, ISolanaWithdrawalRequestAccount } from "../interfaces/index.js";
2
+ import type { ISolanaDepositRequest, ISolanaParkingLotState, ISolanaProtocolConfigState, ISolanaVaultAccount, ISolanaVaultState, ISolanaWithdrawalQueue, ISolanaWithdrawalRequestAccount } from "../interfaces/index.js";
3
3
  /**
4
4
  * Borsh decoding for `ember_vaults` accounts.
5
5
  *
@@ -62,4 +62,15 @@ export interface ISplMint {
62
62
  * The classic 82-byte SPL mint layout. The first 82 bytes of a Token-2022
63
63
  * mint are identical, so this reads both; it just ignores any extensions.
64
64
  */
65
+ /**
66
+ * `state::DepositParkingLot`.
67
+ *
68
+ * Field order tracks the Rust struct and is load-bearing: a transposition
69
+ * decodes into plausible numbers rather than failing. `sequence_number` and
70
+ * `queue_start_index` are u128 - sixteen bytes each, not eight - so reading
71
+ * either as a u64 would silently shift every field after it.
72
+ */
73
+ export declare function decodeDepositParkingLot(data: Buffer, address: PublicKey): ISolanaParkingLotState;
74
+ /** `state::DepositRequest`, one parked deposit. */
75
+ export declare function decodeDepositRequest(data: Buffer, address: PublicKey): ISolanaDepositRequest;
65
76
  export declare function decodeMint(data: Buffer): ISplMint;
@@ -128,8 +128,11 @@ function decodeVaultInner(data) {
128
128
  const maxTvl = c.u64();
129
129
  const sequenceNumber = c.u128();
130
130
  const parkingLot = c.pubkey();
131
- const depositUserList = c.vec(x => x.pubkey());
132
- const withdrawalUserList = c.vec(x => x.pubkey());
131
+ // Counts, not members: both lists became one PDA per account, so the vault
132
+ // carries only their size. 0 means open to all; use deriveDepositUser /
133
+ // deriveWithdrawalUser to test one account.
134
+ const depositUserCount = c.u64();
135
+ const withdrawalUserCount = c.u64();
133
136
  // The tail the parking-lot change appended. Decoded rather than skipped:
134
137
  // a vault with fees configured would otherwise be indistinguishable from a
135
138
  // fee-free one.
@@ -161,8 +164,8 @@ function decodeVaultInner(data) {
161
164
  maxTvl,
162
165
  sequenceNumber,
163
166
  parkingLot,
164
- depositUserList,
165
- withdrawalUserList,
167
+ depositUserCount,
168
+ withdrawalUserCount,
166
169
  withdrawalFee
167
170
  };
168
171
  return { vault, position: c.position() };
@@ -225,6 +228,45 @@ export function decodeProtocolConfig(data) {
225
228
  * The classic 82-byte SPL mint layout. The first 82 bytes of a Token-2022
226
229
  * mint are identical, so this reads both; it just ignores any extensions.
227
230
  */
231
+ /**
232
+ * `state::DepositParkingLot`.
233
+ *
234
+ * Field order tracks the Rust struct and is load-bearing: a transposition
235
+ * decodes into plausible numbers rather than failing. `sequence_number` and
236
+ * `queue_start_index` are u128 - sixteen bytes each, not eight - so reading
237
+ * either as a u64 would silently shift every field after it.
238
+ */
239
+ export function decodeDepositParkingLot(data, address) {
240
+ const c = expect(data, "DepositParkingLot");
241
+ return {
242
+ address,
243
+ bump: c.u8(),
244
+ vault: c.pubkey(),
245
+ operator: c.pubkey(),
246
+ depositsPaused: c.bool(),
247
+ cancellationsPaused: c.bool(),
248
+ sequenceNumber: c.u128(),
249
+ queueStartIndex: c.u128(),
250
+ totalPendingAssets: c.u64(),
251
+ totalClaimableAssets: c.u64(),
252
+ depositAllowListCount: c.u64()
253
+ };
254
+ }
255
+ /** `state::DepositRequest`, one parked deposit. */
256
+ export function decodeDepositRequest(data, address) {
257
+ const c = expect(data, "DepositRequest");
258
+ return {
259
+ address,
260
+ bump: c.u8(),
261
+ parkingLot: c.pubkey(),
262
+ sequenceNumber: c.u128(),
263
+ depositor: c.pubkey(),
264
+ receiver: c.pubkey(),
265
+ assets: c.u64(),
266
+ timestamp: c.u64(),
267
+ claimable: c.bool()
268
+ };
269
+ }
228
270
  export function decodeMint(data) {
229
271
  const option = (o) => data.readUInt32LE(o) === 1 ? new PublicKey(data.subarray(o + 4, o + 36)) : null;
230
272
  return {
@@ -32,6 +32,67 @@ export declare const deriveVaultAccount: (programId: PublicKey, vault: PublicKey
32
32
  * nonce 0.
33
33
  */
34
34
  export declare const deriveWithdrawalRequest: (programId: PublicKey, vault: PublicKey, owner: PublicKey, nonce: bigint) => PublicKey;
35
+ /**
36
+ * `getMultipleAccounts` refuses more than 100 pubkeys per call, so any wider
37
+ * window has to be split rather than sent whole.
38
+ */
39
+ export declare const MAX_MULTIPLE_ACCOUNTS = 100;
40
+ /** An upper bound on one queue scan, so a caller cannot ask for an unbounded walk. */
41
+ export declare const MAX_QUEUE_SCAN = 1000;
42
+ /** `DepositRequest` is fixed width, which lets getProgramAccounts filter on it. */
43
+ export declare const DEPOSIT_REQUEST_SIZE = 138;
44
+ /**
45
+ * Byte offsets into a `DepositRequest` account, discriminator included.
46
+ *
47
+ * Used as memcmp filters, so they track state.rs exactly: an offset that
48
+ * drifts returns nothing rather than failing, which looks like "no requests".
49
+ */
50
+ export declare const DEPOSIT_REQUEST_OFFSETS: {
51
+ /** after discriminator(8) + bump(1) */
52
+ readonly parkingLot: 9;
53
+ /** after parking_lot(32) + sequence_number(16) */
54
+ readonly depositor: 57;
55
+ /** after receiver(32) + assets(8) + timestamp(8) */
56
+ readonly claimable: 137;
57
+ };
58
+ /** Membership of a vault's deposit allow list. Scoped to the vault. */
59
+ export declare const deriveDepositUser: (programId: PublicKey, vault: PublicKey, account: PublicKey) => PublicKey;
60
+ /** Membership of a vault's withdrawal allow list. Scoped to the vault. */
61
+ export declare const deriveWithdrawalUser: (programId: PublicKey, vault: PublicKey, account: PublicKey) => PublicKey;
62
+ /**
63
+ * Membership of a parking lot's own deposit allow list.
64
+ *
65
+ * Scoped to the VAULT, not the lot, matching the program's
66
+ * `[PARKING_LOT_USER_SEED, vault, account]`.
67
+ */
68
+ export declare const deriveParkingLotUser: (programId: PublicKey, vault: PublicKey, account: PublicKey) => PublicKey;
69
+ /**
70
+ * A vault's deposit parking lot: where async deposits wait to be forwarded.
71
+ *
72
+ * One lot per vault, so this is derivable from the vault alone. A vault with
73
+ * no lot simply has no account at this address.
74
+ */
75
+ export declare const deriveParkingLot: (programId: PublicKey, vault: PublicKey) => PublicKey;
76
+ /**
77
+ * The lot's escrow token account, holding collateral for pending requests.
78
+ *
79
+ * It holds `total_pending_assets + total_claimable_assets`: the second is
80
+ * Solana-only, owed to depositors whose refund could not be delivered.
81
+ */
82
+ export declare const deriveParkingLotEscrow: (programId: PublicKey, parkingLot: PublicKey) => PublicKey;
83
+ /**
84
+ * One parked deposit, keyed by its queue sequence number.
85
+ *
86
+ * The seed is a u128 (16 bytes LE), not the u64 the withdrawal request uses.
87
+ *
88
+ * WHEN PARKING, the sequence number is the lot's `sequence_number` PLUS ONE -
89
+ * the slot the next park will take, since the program increments before it
90
+ * writes. That makes a park racy by construction: if another lands first this
91
+ * address is already in use and the transaction fails its address check, and
92
+ * must be rebuilt against a fresh read. Cancelling or claiming an EXISTING
93
+ * request uses that request's own recorded sequence number instead.
94
+ */
95
+ export declare const deriveDepositRequest: (programId: PublicKey, parkingLot: PublicKey, sequenceNumber: bigint) => PublicKey;
35
96
  /**
36
97
  * The event authority `emit_cpi!` signs with. Anchor requires it and the
37
98
  * program id as the last two accounts of every instruction that emits.
@@ -76,11 +76,86 @@ export const deriveWithdrawalRequest = (programId, vault, owner, nonce) => Publi
76
76
  owner.toBuffer(),
77
77
  u64le(nonce)
78
78
  ], programId)[0];
79
- // The parking lot's PDAs - the lot, its escrow and a deposit request - are
80
- // derived in the phase that adds decoders for DepositParkingLot and
81
- // DepositRequest. Deriving an address nothing can read is a surface that
82
- // looks usable and is not. Note for then: a request's seed is the lot's
83
- // sequence_number + 1, the slot the next park will take.
79
+ /**
80
+ * `getMultipleAccounts` refuses more than 100 pubkeys per call, so any wider
81
+ * window has to be split rather than sent whole.
82
+ */
83
+ export const MAX_MULTIPLE_ACCOUNTS = 100;
84
+ /** An upper bound on one queue scan, so a caller cannot ask for an unbounded walk. */
85
+ export const MAX_QUEUE_SCAN = 1000;
86
+ /** `DepositRequest` is fixed width, which lets getProgramAccounts filter on it. */
87
+ export const DEPOSIT_REQUEST_SIZE = 138;
88
+ /**
89
+ * Byte offsets into a `DepositRequest` account, discriminator included.
90
+ *
91
+ * Used as memcmp filters, so they track state.rs exactly: an offset that
92
+ * drifts returns nothing rather than failing, which looks like "no requests".
93
+ */
94
+ export const DEPOSIT_REQUEST_OFFSETS = {
95
+ /** after discriminator(8) + bump(1) */
96
+ parkingLot: 9,
97
+ /** after parking_lot(32) + sequence_number(16) */
98
+ depositor: 57,
99
+ /** after receiver(32) + assets(8) + timestamp(8) */
100
+ claimable: 137
101
+ };
102
+ /**
103
+ * An allow-list entry: one account per listed member.
104
+ *
105
+ * The three lists are unbounded, so membership is not a `Vec` on the vault or
106
+ * the lot but the EXISTENCE of this PDA. The program's test is
107
+ * `UserListEntry::exists` - owned by the program and non-empty - which is
108
+ * stricter than "the account exists": these addresses are public and
109
+ * derivable, so anyone can make a bare `getAccountInfo` return non-null by
110
+ * sending lamports to one. A system-owned account with no data reads as
111
+ * listed to a naive check and is rejected on chain.
112
+ *
113
+ * Every gated instruction takes the caller's entry whether or not it exists,
114
+ * so these are always derivable without a network read.
115
+ */
116
+ const deriveUserEntry = (programId, seedName, scope, account) => PublicKey.findProgramAddressSync([seed(seedName), scope.toBuffer(), account.toBuffer()], programId)[0];
117
+ /** Membership of a vault's deposit allow list. Scoped to the vault. */
118
+ export const deriveDepositUser = (programId, vault, account) => deriveUserEntry(programId, SOLANA_SEEDS.depositUser, vault, account);
119
+ /** Membership of a vault's withdrawal allow list. Scoped to the vault. */
120
+ export const deriveWithdrawalUser = (programId, vault, account) => deriveUserEntry(programId, SOLANA_SEEDS.withdrawalUser, vault, account);
121
+ /**
122
+ * Membership of a parking lot's own deposit allow list.
123
+ *
124
+ * Scoped to the VAULT, not the lot, matching the program's
125
+ * `[PARKING_LOT_USER_SEED, vault, account]`.
126
+ */
127
+ export const deriveParkingLotUser = (programId, vault, account) => deriveUserEntry(programId, SOLANA_SEEDS.parkingLotUser, vault, account);
128
+ /**
129
+ * A vault's deposit parking lot: where async deposits wait to be forwarded.
130
+ *
131
+ * One lot per vault, so this is derivable from the vault alone. A vault with
132
+ * no lot simply has no account at this address.
133
+ */
134
+ export const deriveParkingLot = (programId, vault) => PublicKey.findProgramAddressSync([seed(SOLANA_SEEDS.parkingLot), vault.toBuffer()], programId)[0];
135
+ /**
136
+ * The lot's escrow token account, holding collateral for pending requests.
137
+ *
138
+ * It holds `total_pending_assets + total_claimable_assets`: the second is
139
+ * Solana-only, owed to depositors whose refund could not be delivered.
140
+ */
141
+ export const deriveParkingLotEscrow = (programId, parkingLot) => PublicKey.findProgramAddressSync([seed(SOLANA_SEEDS.parkingLotEscrow), parkingLot.toBuffer()], programId)[0];
142
+ /**
143
+ * One parked deposit, keyed by its queue sequence number.
144
+ *
145
+ * The seed is a u128 (16 bytes LE), not the u64 the withdrawal request uses.
146
+ *
147
+ * WHEN PARKING, the sequence number is the lot's `sequence_number` PLUS ONE -
148
+ * the slot the next park will take, since the program increments before it
149
+ * writes. That makes a park racy by construction: if another lands first this
150
+ * address is already in use and the transaction fails its address check, and
151
+ * must be rebuilt against a fresh read. Cancelling or claiming an EXISTING
152
+ * request uses that request's own recorded sequence number instead.
153
+ */
154
+ export const deriveDepositRequest = (programId, parkingLot, sequenceNumber) => PublicKey.findProgramAddressSync([
155
+ seed(SOLANA_SEEDS.depositRequest),
156
+ parkingLot.toBuffer(),
157
+ u128le(sequenceNumber)
158
+ ], programId)[0];
84
159
  /**
85
160
  * The event authority `emit_cpi!` signs with. Anchor requires it and the
86
161
  * program id as the last two accounts of every instruction that emits.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ember-finance/sdk",
3
3
  "description": "Ember Protocol SDK",
4
- "version": "2.40.0",
4
+ "version": "2.42.0",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
7
7
  "main": "./dist/index.js",