@upshiftfinance/sdk 0.4.0 → 0.5.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.
@@ -33,9 +33,14 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_SUI_SLIPPAGE_BPS = void 0;
37
+ exports.suiMinOut = suiMinOut;
36
38
  exports.suiExecutionErrorToSdkError = suiExecutionErrorToSdkError;
37
39
  exports.buildSuiDepositTransaction = buildSuiDepositTransaction;
38
40
  exports.buildSuiRedeemTransaction = buildSuiRedeemTransaction;
41
+ exports.buildSuiRequestRedeemTransaction = buildSuiRequestRedeemTransaction;
42
+ exports.buildSuiCancelRedeemRequestTransaction = buildSuiCancelRedeemRequestTransaction;
43
+ exports.buildSuiClaimWithdrawalTransaction = buildSuiClaimWithdrawalTransaction;
39
44
  exports.submitSuiTransaction = submitSuiTransaction;
40
45
  const errors_1 = require("../../core/errors");
41
46
  const chain_address_1 = require("../../core/helpers/chain-address");
@@ -45,6 +50,31 @@ const constants_1 = require("./constants");
45
50
  const getters_1 = require("./getters");
46
51
  const targets_1 = require("./targets");
47
52
  const U64_MAX = 2n ** 64n - 1n;
53
+ /**
54
+ * Default tolerance below the previewed output: 10 basis points (0.1%).
55
+ *
56
+ * Between the dry run and execution the price moves only by rounding (a unit
57
+ * as other deposits and redemptions land) and by management fee accruing for
58
+ * a few seconds; 0.1% absorbs both. An operator AUM report can move it by up
59
+ * to the vault's report bound (percent-level), which this still rejects with
60
+ * `slippage` rather than fill at a price the user did not see.
61
+ */
62
+ exports.DEFAULT_SUI_SLIPPAGE_BPS = 10;
63
+ /**
64
+ * The minimum output for a previewed amount: `expected` less `slippageBps`,
65
+ * rounded down.
66
+ * @internal
67
+ */
68
+ function suiMinOut(expected, slippageBps) {
69
+ return (expected * BigInt(10_000 - slippageBps)) / 10000n;
70
+ }
71
+ function assertSlippage(bps, method) {
72
+ if (bps === undefined)
73
+ return;
74
+ if (!Number.isInteger(bps) || bps < 0 || bps > 10_000) {
75
+ throw new errors_1.UpshiftValidationError('INVALID_INPUT', `${method}: slippageBps must be an integer from 0 to 10000.`);
76
+ }
77
+ }
48
78
  /** Contract errors with a stable SDK reason, keyed `module::constant`. */
49
79
  const ABORT_REASONS = {
50
80
  'vault::EPaused': 'paused',
@@ -58,16 +88,37 @@ const ABORT_REASONS = {
58
88
  'vault::EZeroShares': 'zero_output',
59
89
  'vault::EZeroAssets': 'zero_output',
60
90
  'versioning::EVersionRetired': 'version_retired',
91
+ 'vault::EQueueNotAttached': 'queue_not_attached',
92
+ 'vault::EInvalidPayout': 'invalid_payout',
93
+ 'vault::ENotRequestOwner': 'not_request_owner',
94
+ 'vault::ENotFinalizer': 'not_finalizer',
95
+ 'vault::ENotEligibleYet': 'not_eligible_yet',
96
+ 'vault::ERequestExpired': 'request_expired',
97
+ // The request ID was used before: its derived object ID is already claimed.
98
+ 'derived_object::EObjectAlreadyExists': 'request_id_in_use',
61
99
  };
62
100
  /**
63
101
  * Map a failed Sui execution to an SDK error: a contract abort becomes an
64
102
  * {@link UpshiftVaultAbortError} with a stable reason, anything else an
65
103
  * {@link UpshiftSDKError} (`UNKNOWN`).
66
104
  *
105
+ * The SDK applies this itself to its dry runs and to `executeTransaction`.
106
+ * Use it when the wallet builds, signs or executes a transaction and fails,
107
+ * e.g. because the vault changed after the SDK built it.
108
+ *
67
109
  * @param executionError - `effects.status.error` of a failed transaction, or
68
110
  * the `executionError` of a `SimulationError`.
69
111
  * @param context - Structured context for the error (e.g. network, digest).
70
- * @internal
112
+ * @returns The typed error; throw it or branch on its `reason`.
113
+ * @example
114
+ * ```ts
115
+ * try {
116
+ * await dAppKit.signTransaction({ transaction });
117
+ * } catch (err) {
118
+ * if (err?.executionError) throw Sui.actions.suiExecutionErrorToSdkError(err.executionError);
119
+ * throw err;
120
+ * }
121
+ * ```
71
122
  */
72
123
  function suiExecutionErrorToSdkError(executionError, context = {}) {
73
124
  const abort = executionError?.MoveAbort;
@@ -146,7 +197,11 @@ async function dryRun(config, build, method, onAbort) {
146
197
  *
147
198
  * RPC cost: one dry run (plus the coin lookup it needs).
148
199
  *
149
- * @param params - Vault, amount, sender and optional minimum shares.
200
+ * The minimum is the dry run's preview less `slippageBps` (default 0.1%),
201
+ * unless `minSharesOut` is given.
202
+ *
203
+ * @param params - Vault, amount, sender, and optional `minSharesOut` or
204
+ * `slippageBps`.
150
205
  * @param sui - The SDK's `sui` config; applied only for `params.vault.network`.
151
206
  * @returns The unsigned transaction, the shares the dry run minted and the
152
207
  * minimum it enforces.
@@ -163,18 +218,23 @@ async function dryRun(config, build, method, onAbort) {
163
218
  * { vault: ref, amount: 1_000_000n, sender: account.address },
164
219
  * sdkSuiConfig,
165
220
  * );
166
- * const { digest } = await dAppKit.signAndExecuteTransaction({ transaction });
221
+ * const digest = await submitSuiTransaction(
222
+ * await dAppKit.signTransaction({ transaction }),
223
+ * 'testnet',
224
+ * sdkSuiConfig,
225
+ * );
167
226
  * ```
168
227
  */
169
228
  async function buildSuiDepositTransaction(params, sui) {
170
229
  const method = 'buildSuiDepositTransaction';
171
- const { vault: ref, amount, sender, minSharesOut } = params;
230
+ const { vault: ref, amount, sender, minSharesOut, slippageBps } = params;
172
231
  assertSender(sender, method);
173
232
  if (!(0, chain_address_1.isSuiAddress)(ref?.vaultId)) {
174
233
  throw new errors_1.UpshiftValidationError('INVALID_ADDRESS', `${method}: vault.vaultId must be a Sui object ID.`);
175
234
  }
176
235
  assertAmount(amount, method, 'amount', true);
177
236
  assertAmount(minSharesOut, method, 'minSharesOut', false);
237
+ assertSlippage(slippageBps, method);
178
238
  const config = (0, getters_1.suiConfigForNetwork)(ref.network, sui);
179
239
  const contracts = (0, targets_1.resolveSuiVaultContracts)(config);
180
240
  const { Transaction: SuiTransaction, coinWithBalance } = await Promise.resolve().then(() => __importStar(require('@mysten/sui/transactions')));
@@ -193,7 +253,8 @@ async function buildSuiDepositTransaction(params, sui) {
193
253
  // The preview runs after the fee charge, so it matches the deposit exactly.
194
254
  const results = await dryRun(config, async () => compose(0n, true), method);
195
255
  const expectedShares = (0, getters_1.decodeSuiUint)(results[1][0], 8);
196
- const min = minSharesOut ?? expectedShares;
256
+ const min = minSharesOut ??
257
+ suiMinOut(expectedShares, slippageBps ?? exports.DEFAULT_SUI_SLIPPAGE_BPS);
197
258
  return {
198
259
  transaction: compose(min, false),
199
260
  expectedShares,
@@ -201,8 +262,9 @@ async function buildSuiDepositTransaction(params, sui) {
201
262
  };
202
263
  }
203
264
  /**
204
- * Build an unsigned instant redemption: redeem shares with a minimum net
205
- * asset amount and send the assets to the sender — one transaction.
265
+ * Build an unsigned instant redemption: charge the management fee, redeem
266
+ * shares with a minimum net asset amount and send the assets to the sender —
267
+ * one transaction.
206
268
  *
207
269
  * The shares are `coinWithBalance(Share)`. A vault with a withdrawal queue
208
270
  * attached has instant redemption off; that fails here with `queue_attached`.
@@ -213,7 +275,11 @@ async function buildSuiDepositTransaction(params, sui) {
213
275
  *
214
276
  * RPC cost: one dry run (plus the coin lookup it needs).
215
277
  *
216
- * @param params - Vault, shares, sender and optional minimum assets.
278
+ * The minimum is the dry run's net preview less `slippageBps` (default 0.1%),
279
+ * unless `minAssetsOut` is given.
280
+ *
281
+ * @param params - Vault, shares, sender, and optional `minAssetsOut` or
282
+ * `slippageBps`.
217
283
  * @param sui - The SDK's `sui` config; applied only for `params.vault.network`.
218
284
  * @returns The unsigned transaction, the dry run's gross and net assets and
219
285
  * the minimum it enforces.
@@ -227,13 +293,14 @@ async function buildSuiDepositTransaction(params, sui) {
227
293
  */
228
294
  async function buildSuiRedeemTransaction(params, sui) {
229
295
  const method = 'buildSuiRedeemTransaction';
230
- const { vault: ref, shares, sender, minAssetsOut } = params;
296
+ const { vault: ref, shares, sender, minAssetsOut, slippageBps } = params;
231
297
  assertSender(sender, method);
232
298
  if (!(0, chain_address_1.isSuiAddress)(ref?.vaultId)) {
233
299
  throw new errors_1.UpshiftValidationError('INVALID_ADDRESS', `${method}: vault.vaultId must be a Sui object ID.`);
234
300
  }
235
301
  assertAmount(shares, method, 'shares', true);
236
302
  assertAmount(minAssetsOut, method, 'minAssetsOut', false);
303
+ assertSlippage(slippageBps, method);
237
304
  const config = (0, getters_1.suiConfigForNetwork)(ref.network, sui);
238
305
  const contracts = (0, targets_1.resolveSuiVaultContracts)(config);
239
306
  const { Transaction: SuiTransaction, coinWithBalance } = await Promise.resolve().then(() => __importStar(require('@mysten/sui/transactions')));
@@ -241,6 +308,9 @@ async function buildSuiRedeemTransaction(params, sui) {
241
308
  const tx = new SuiTransaction();
242
309
  tx.setSender(sender);
243
310
  const call = vaultCalls(tx, ref, contracts);
311
+ // As on deposit: charge the accrued management fee first, so the redeemer
312
+ // pays their share of it instead of leaving it to the remaining holders.
313
+ call('chargeManagementFee', [tx.object(constants_1.SUI_CLOCK_ID)]);
244
314
  if (withPreview) {
245
315
  call('previewRedemption', [tx.pure.u64(shares), tx.pure.bool(true)]);
246
316
  }
@@ -258,9 +328,11 @@ async function buildSuiRedeemTransaction(params, sui) {
258
328
  sharesRaw: shares.toString(),
259
329
  error: err.message,
260
330
  }));
261
- const expectedGrossAssets = (0, getters_1.decodeSuiUint)(results[0][0], 8);
262
- const expectedAssets = (0, getters_1.decodeSuiUint)(results[0][1], 8);
263
- const min = minAssetsOut ?? expectedAssets;
331
+ // results[0] is the fee charge; the preview runs after it.
332
+ const expectedGrossAssets = (0, getters_1.decodeSuiUint)(results[1][0], 8);
333
+ const expectedAssets = (0, getters_1.decodeSuiUint)(results[1][1], 8);
334
+ const min = minAssetsOut ??
335
+ suiMinOut(expectedAssets, slippageBps ?? exports.DEFAULT_SUI_SLIPPAGE_BPS);
264
336
  return {
265
337
  transaction: compose(min, false),
266
338
  expectedGrossAssets,
@@ -268,6 +340,247 @@ async function buildSuiRedeemTransaction(params, sui) {
268
340
  minAssetsOut: min,
269
341
  };
270
342
  }
343
+ function assertVaultId(ref, method) {
344
+ if (!(0, chain_address_1.isSuiAddress)(ref?.vaultId)) {
345
+ throw new errors_1.UpshiftValidationError('INVALID_ADDRESS', `${method}: vault.vaultId must be a Sui object ID.`);
346
+ }
347
+ }
348
+ function assertAddress(value, method, name) {
349
+ if (value !== undefined && value !== null && !(0, chain_address_1.isSuiAddress)(value)) {
350
+ throw new errors_1.UpshiftValidationError('INVALID_ADDRESS', `${method}: ${name} must be a Sui address (0x + 64 hex chars).`);
351
+ }
352
+ }
353
+ /**
354
+ * Build an unsigned withdrawal request for a vault with a withdrawal queue:
355
+ * escrow the shares in a new request object and share it — one transaction.
356
+ *
357
+ * The request is priced when it is claimed, not now. Once the vault's cooldown
358
+ * has run, claim it with {@link buildSuiClaimWithdrawalTransaction}; it can be
359
+ * cancelled at any time with {@link buildSuiCancelRedeemRequestTransaction}.
360
+ *
361
+ * The request ID is the lowest the wallet has never used on this vault
362
+ * (`0, 1, 2, …`), which is how `getSuiWithdrawalRequests` finds requests
363
+ * without an event index. Two requests built before either lands get the same
364
+ * ID; the second then fails (the node refuses its stale coins, or it aborts
365
+ * with `request_id_in_use`) — build it again.
366
+ *
367
+ * The finalizer defaults to the owner: only the owner can claim during the
368
+ * first day the request is claimable, then anyone can (e.g. a keeper).
369
+ *
370
+ * RPC cost: one object read for the next ID (more after 23 earlier requests),
371
+ * then one dry run (plus the coin lookup it needs).
372
+ *
373
+ * @param params - Vault, shares, sender, and optional `payout` and `finalizer`.
374
+ * @param sui - The SDK's `sui` config; applied only for `params.vault.network`.
375
+ * @returns The unsigned transaction, the request ID and the request's object ID.
376
+ * @throws {@link UpshiftValidationError} for a malformed address, an
377
+ * out-of-range amount, unknown contract IDs, or a transaction that cannot be
378
+ * built (e.g. the sender lacks the shares).
379
+ * @throws {@link UpshiftVaultAbortError} when the vault would reject it
380
+ * (`queue_not_attached`, `paused`, `above_max_withdrawal`, `invalid_payout`,
381
+ * `request_id_in_use`, `version_retired`), or the shares would pay nothing
382
+ * (`zero_output`).
383
+ * @throws {@link UpshiftNetworkError} when every Sui endpoint fails.
384
+ * @example
385
+ * ```ts
386
+ * const { transaction, request } = await buildSuiRequestRedeemTransaction(
387
+ * { vault: ref, shares: 1_000_000n, sender: account.address },
388
+ * sdkSuiConfig,
389
+ * );
390
+ * ```
391
+ */
392
+ async function buildSuiRequestRedeemTransaction(params, sui) {
393
+ const method = 'buildSuiRequestRedeemTransaction';
394
+ const { vault: ref, shares, sender, payout, finalizer } = params;
395
+ assertSender(sender, method);
396
+ assertVaultId(ref, method);
397
+ assertAmount(shares, method, 'shares', true);
398
+ assertAddress(payout, method, 'payout');
399
+ assertAddress(finalizer, method, 'finalizer');
400
+ const config = (0, getters_1.suiConfigForNetwork)(ref.network, sui);
401
+ const contracts = (0, targets_1.resolveSuiVaultContracts)(config);
402
+ const { Transaction: SuiTransaction, coinWithBalance } = await Promise.resolve().then(() => __importStar(require('@mysten/sui/transactions')));
403
+ const requestId = await (0, getters_1.nextSuiWithdrawalRequestId)(ref, sender, sui);
404
+ const compose = (withPreview) => {
405
+ const tx = new SuiTransaction();
406
+ tx.setSender(sender);
407
+ const call = vaultCalls(tx, ref, contracts);
408
+ // Priced on a current NAV (fee charged first, in the dry run only).
409
+ // Queued withdrawals pay no instant fee, so gross is what a claim pays.
410
+ if (withPreview) {
411
+ call('chargeManagementFee', [tx.object(constants_1.SUI_CLOCK_ID)]);
412
+ call('previewRedemption', [tx.pure.u64(shares), tx.pure.bool(false)]);
413
+ }
414
+ const escrow = tx.add(coinWithBalance({ type: ref.shareCoinType, balance: shares }));
415
+ const request = call('requestWithdrawal', [
416
+ escrow,
417
+ tx.pure.u64(requestId),
418
+ tx.pure.address(payout ?? sender),
419
+ tx.pure.option('address', finalizer === undefined ? sender : finalizer),
420
+ tx.object(constants_1.SUI_CLOCK_ID),
421
+ ]);
422
+ // The contract returns the request unshared; it must be shared in the
423
+ // same transaction.
424
+ tx.moveCall({
425
+ target: (0, targets_1.suiMoveCallTarget)(contracts, 'shareWithdrawalRequest'),
426
+ typeArguments: [ref.shareCoinType],
427
+ arguments: [request],
428
+ });
429
+ return tx;
430
+ };
431
+ const results = await dryRun(config, async () => compose(true), method);
432
+ // results[0] is the fee charge.
433
+ const expectedAssets = (0, getters_1.decodeSuiUint)(results[1][0], 8);
434
+ // The contract accepts a request whose claim would pay nothing (shares worth
435
+ // less than one asset unit); it could then only be cancelled.
436
+ if (expectedAssets === 0n) {
437
+ throw new errors_1.UpshiftVaultAbortError('zero_output', `${method}: ${shares} shares pay no assets at the current price; request more.`, {}, { context: { network: ref.network, method } });
438
+ }
439
+ return {
440
+ transaction: compose(false),
441
+ requestId,
442
+ expectedAssets,
443
+ request: await (0, getters_1.deriveSuiWithdrawalRequestId)(ref.vaultId, sender, requestId, contracts.originalPackageId),
444
+ };
445
+ }
446
+ /**
447
+ * Build an unsigned cancellation of a pending withdrawal request: the escrowed
448
+ * shares go back to the sender — one transaction.
449
+ *
450
+ * Allowed at any time while the request exists, also while the vault is
451
+ * paused or after the request expired. Only the wallet that made it may.
452
+ *
453
+ * RPC cost: one dry run.
454
+ *
455
+ * @param params - Vault, request object ID and sender.
456
+ * @param sui - The SDK's `sui` config; applied only for `params.vault.network`.
457
+ * @returns The unsigned transaction and the shares it returns.
458
+ * @throws {@link UpshiftValidationError} for a malformed address, unknown
459
+ * contract IDs, or a transaction that cannot be built (e.g. the request no
460
+ * longer exists).
461
+ * @throws {@link UpshiftVaultAbortError} when the vault would reject it
462
+ * (`not_request_owner`, `version_retired`).
463
+ * @throws {@link UpshiftNetworkError} when every Sui endpoint fails.
464
+ * @example
465
+ * ```ts
466
+ * const { transaction } = await buildSuiCancelRedeemRequestTransaction(
467
+ * { vault: ref, request, sender: account.address },
468
+ * sdkSuiConfig,
469
+ * );
470
+ * ```
471
+ */
472
+ async function buildSuiCancelRedeemRequestTransaction(params, sui) {
473
+ const method = 'buildSuiCancelRedeemRequestTransaction';
474
+ const { vault: ref, request, sender } = params;
475
+ assertSender(sender, method);
476
+ assertVaultId(ref, method);
477
+ if (!(0, chain_address_1.isSuiAddress)(request)) {
478
+ throw new errors_1.UpshiftValidationError('INVALID_ADDRESS', `${method}: request must be a Sui object ID.`);
479
+ }
480
+ const config = (0, getters_1.suiConfigForNetwork)(ref.network, sui);
481
+ const contracts = (0, targets_1.resolveSuiVaultContracts)(config);
482
+ const { Transaction: SuiTransaction } = await Promise.resolve().then(() => __importStar(require('@mysten/sui/transactions')));
483
+ const compose = () => {
484
+ const tx = new SuiTransaction();
485
+ tx.setSender(sender);
486
+ const call = vaultCalls(tx, ref, contracts);
487
+ const shares = call('cancelWithdrawal', [tx.object(request)]);
488
+ tx.transferObjects([shares], sender);
489
+ return tx;
490
+ };
491
+ const results = await dryRun(config, async () => compose(), method);
492
+ // The returned Coin<Share> is a 32-byte UID followed by its u64 balance.
493
+ const coin = results[0][0];
494
+ return {
495
+ transaction: compose(),
496
+ shares: (0, getters_1.decodeSuiUint)(coin.subarray(32), 8),
497
+ };
498
+ }
499
+ /**
500
+ * Build an unsigned claim of a withdrawal request: charge the management fee,
501
+ * then burn the escrowed shares and pay their current value to the request's
502
+ * payout, with a minimum — one transaction.
503
+ *
504
+ * The payout lands in the payout address's **address balance**, not as a
505
+ * coin object. Balance reads (`getBalance`) include it; wallets that only
506
+ * list coin objects may not show it.
507
+ *
508
+ * The request is priced now, at the vault's current share price, without the
509
+ * instant-redemption fee. It can be claimed from `eligibleAtMs` until
510
+ * `expiresAtMs`, by its owner, its finalizer (anyone when it names none), and
511
+ * by anyone from one day after it became eligible.
512
+ *
513
+ * RPC cost: one dry run.
514
+ *
515
+ * @param params - Vault, request object ID, sender, and optional
516
+ * `minAssetsOut` or `slippageBps`.
517
+ * @param sui - The SDK's `sui` config; applied only for `params.vault.network`.
518
+ * @returns The unsigned transaction, the shares it burns, the dry run's
519
+ * payout and the minimum it enforces.
520
+ * @throws {@link UpshiftValidationError} for a malformed address, an
521
+ * out-of-range minimum, unknown contract IDs, or a transaction that cannot
522
+ * be built (e.g. the request no longer exists).
523
+ * @throws {@link UpshiftVaultAbortError} when the vault would reject it
524
+ * (`not_eligible_yet`, `request_expired`, `not_finalizer`, `paused`,
525
+ * `above_max_withdrawal`, `insufficient_liquidity`, `zero_output`,
526
+ * `slippage`, `version_retired`).
527
+ * @throws {@link UpshiftNetworkError} when every Sui endpoint fails.
528
+ * @example
529
+ * ```ts
530
+ * const { transaction, expectedAssets } = await buildSuiClaimWithdrawalTransaction(
531
+ * { vault: ref, request, sender: account.address },
532
+ * sdkSuiConfig,
533
+ * );
534
+ * ```
535
+ */
536
+ async function buildSuiClaimWithdrawalTransaction(params, sui) {
537
+ const method = 'buildSuiClaimWithdrawalTransaction';
538
+ const { vault: ref, request, sender, minAssetsOut, slippageBps } = params;
539
+ assertSender(sender, method);
540
+ assertVaultId(ref, method);
541
+ if (!(0, chain_address_1.isSuiAddress)(request)) {
542
+ throw new errors_1.UpshiftValidationError('INVALID_ADDRESS', `${method}: request must be a Sui object ID.`);
543
+ }
544
+ assertAmount(minAssetsOut, method, 'minAssetsOut', false);
545
+ assertSlippage(slippageBps, method);
546
+ const config = (0, getters_1.suiConfigForNetwork)(ref.network, sui);
547
+ const contracts = (0, targets_1.resolveSuiVaultContracts)(config);
548
+ const { Transaction: SuiTransaction } = await Promise.resolve().then(() => __importStar(require('@mysten/sui/transactions')));
549
+ const compose = (min, withPreview) => {
550
+ const tx = new SuiTransaction();
551
+ tx.setSender(sender);
552
+ const call = vaultCalls(tx, ref, contracts);
553
+ // Charge the accrued management fee first, as on deposit and redeem, so
554
+ // the claim is priced on the current NAV.
555
+ call('chargeManagementFee', [tx.object(constants_1.SUI_CLOCK_ID)]);
556
+ if (withPreview) {
557
+ const shares = tx.moveCall({
558
+ target: (0, targets_1.suiMoveCallTarget)(contracts, 'withdrawalRequestShares'),
559
+ typeArguments: [ref.shareCoinType],
560
+ arguments: [tx.object(request)],
561
+ });
562
+ call('previewRedemption', [shares, tx.pure.bool(false)]);
563
+ }
564
+ call('finalizeWithdrawalChecked', [
565
+ tx.object(request),
566
+ tx.pure.u64(min),
567
+ tx.object(constants_1.SUI_CLOCK_ID),
568
+ ]);
569
+ return tx;
570
+ };
571
+ // results[0] is the fee charge, [1] the escrowed shares, [2] the preview.
572
+ const results = await dryRun(config, async () => compose(0n, true), method);
573
+ const shares = (0, getters_1.decodeSuiUint)(results[1][0], 8);
574
+ const expectedAssets = (0, getters_1.decodeSuiUint)(results[2][1], 8);
575
+ const min = minAssetsOut ??
576
+ suiMinOut(expectedAssets, slippageBps ?? exports.DEFAULT_SUI_SLIPPAGE_BPS);
577
+ return {
578
+ transaction: compose(min, false),
579
+ shares,
580
+ expectedAssets,
581
+ minAssetsOut: min,
582
+ };
583
+ }
271
584
  /**
272
585
  * The vault, sender and share amount of an instant redemption in a built
273
586
  * transaction, for the curator alert; `null` when it is not one.
@@ -331,7 +644,13 @@ async function submitSuiTransaction(signed, network, sui) {
331
644
  const networkConfig = (0, client_1.resolveSuiNetworkConfig)(config);
332
645
  const { Transaction: SuiTransaction } = await Promise.resolve().then(() => __importStar(require('@mysten/sui/transactions')));
333
646
  const { fromBase64 } = await Promise.resolve().then(() => __importStar(require('@mysten/sui/utils')));
334
- const bytes = fromBase64(signed.bytes);
647
+ let bytes;
648
+ try {
649
+ bytes = fromBase64(signed.bytes);
650
+ }
651
+ catch (err) {
652
+ throw new errors_1.UpshiftValidationError('INVALID_INPUT', 'submitSuiTransaction: bytes is not valid base64.', { cause: err });
653
+ }
335
654
  // Executing the same signed bytes twice is idempotent, so endpoint failover is safe.
336
655
  const result = await (0, client_1.withSuiClient)(networkConfig, (client) => client.core.executeTransaction({
337
656
  transaction: bytes,
@@ -43,7 +43,7 @@ export declare function suiEndpointHost(url: string): string;
43
43
  *
44
44
  * @param err - What the call rejected with.
45
45
  * @returns `true` for transport failures, gRPC statuses in
46
- * {@link RETRYABLE_GRPC_CODES}, a bare `NOT_FOUND` (the endpoint has no Sui
46
+ * `RETRYABLE_GRPC_CODES`, a bare `NOT_FOUND` (the endpoint has no Sui
47
47
  * gRPC service at that path) and the SDK's own call timeout.
48
48
  * @internal
49
49
  */
@@ -58,8 +58,8 @@ export declare function setSuiClientFactoryForTests(factory: SuiClientFactory |
58
58
  * Run a Sui call against the first endpoint that answers.
59
59
  *
60
60
  * Endpoints are tried in order, starting with the one that last worked (for
61
- * {@link PREFERRED_ENDPOINT_TTL_MS}). An endpoint failure — see
62
- * {@link isRetryableSuiError} — moves on to the next endpoint; any other error
61
+ * `PREFERRED_ENDPOINT_TTL_MS`). An endpoint failure — see
62
+ * `isRetryableSuiError` — moves on to the next endpoint; any other error
63
63
  * is thrown at once. Each attempt is bounded by a 15 s timeout.
64
64
  *
65
65
  * Worst case: one attempt per endpoint (two with a consumer `rpcUrl`).
@@ -99,7 +99,7 @@ function suiEndpointHost(url) {
99
99
  *
100
100
  * @param err - What the call rejected with.
101
101
  * @returns `true` for transport failures, gRPC statuses in
102
- * {@link RETRYABLE_GRPC_CODES}, a bare `NOT_FOUND` (the endpoint has no Sui
102
+ * `RETRYABLE_GRPC_CODES`, a bare `NOT_FOUND` (the endpoint has no Sui
103
103
  * gRPC service at that path) and the SDK's own call timeout.
104
104
  * @internal
105
105
  */
@@ -115,14 +115,29 @@ function isRetryableSuiError(err) {
115
115
  return false;
116
116
  if (RETRYABLE_GRPC_CODES.has(code))
117
117
  return true;
118
- // A path with no gRPC service answers HTTP 404, which the transport reports
119
- // as NOT_FOUND with no message; a missing object is a different error type.
120
- return code === 'NOT_FOUND' && err.message === '';
118
+ // An endpoint with no Sui gRPC service at that path answers a bare HTTP 404
119
+ // or 400. The transport reports those as NOT_FOUND / INVALID_ARGUMENT whose
120
+ // message is the HTTP status text ('' over HTTP/2, the reason phrase over
121
+ // HTTP/1.1). Errors from the Sui service itself carry their own message.
122
+ return ((code === 'NOT_FOUND' &&
123
+ (err.message === '' || err.message === 'Not Found')) ||
124
+ (code === 'INVALID_ARGUMENT' && err.message === 'Bad Request'));
121
125
  }
122
- /** Default factory: loads `@mysten/sui/grpc` on first use. */
126
+ /**
127
+ * Default factory: loads `@mysten/sui/grpc` on first use. A failure to load
128
+ * the module is about the runtime, not the endpoint, so it is thrown as an
129
+ * SDK error that {@link withSuiClient} does not retry: in Node.js, requiring
130
+ * the ESM-only package needs `^20.19 || >=22.12`.
131
+ */
123
132
  const defaultClientFactory = async (baseUrl, network) => {
124
- const { SuiGrpcClient } = await Promise.resolve().then(() => __importStar(require('@mysten/sui/grpc')));
125
- return new SuiGrpcClient({ baseUrl, network });
133
+ let grpc;
134
+ try {
135
+ grpc = await Promise.resolve().then(() => __importStar(require('@mysten/sui/grpc')));
136
+ }
137
+ catch (err) {
138
+ throw new errors_1.UpshiftSDKError('UNKNOWN', `Sui support could not load @mysten/sui — in Node.js it needs version ^20.19 or >=22.12: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
139
+ }
140
+ return new grpc.SuiGrpcClient({ baseUrl, network });
126
141
  };
127
142
  let clientFactory = defaultClientFactory;
128
143
  const clientCache = new Map();
@@ -151,8 +166,8 @@ function clientFor(url, network) {
151
166
  * Run a Sui call against the first endpoint that answers.
152
167
  *
153
168
  * Endpoints are tried in order, starting with the one that last worked (for
154
- * {@link PREFERRED_ENDPOINT_TTL_MS}). An endpoint failure — see
155
- * {@link isRetryableSuiError} — moves on to the next endpoint; any other error
169
+ * `PREFERRED_ENDPOINT_TTL_MS`). An endpoint failure — see
170
+ * `isRetryableSuiError` — moves on to the next endpoint; any other error
156
171
  * is thrown at once. Each attempt is bounded by a 15 s timeout.
157
172
  *
158
173
  * Worst case: one attempt per endpoint (two with a consumer `rpcUrl`).
@@ -185,6 +200,9 @@ async function withSuiClient(config, call) {
185
200
  client = await clientFor(url, config.network);
186
201
  }
187
202
  catch (err) {
203
+ // The SDK could not load its Sui client at all: no endpoint can help.
204
+ if (err instanceof errors_1.UpshiftSDKError)
205
+ throw err;
188
206
  // A malformed endpoint (e.g. a typo'd consumer rpcUrl) must not disable
189
207
  // the endpoints behind it.
190
208
  lastError = err;
@@ -1,4 +1,4 @@
1
- import type { ISuiConfig, ISuiNetwork, ITokenizedVault, IVault } from '../../types';
1
+ import { type ISuiConfig, type ISuiNetwork, type ITokenizedVault, type IVault, type IVaultAvailableRedemption } from '../../types';
2
2
  import type { IVaultBaseOptions } from '../../modules/vaults/types';
3
3
  /** The vault a read is for: its object ID, coin types and network. */
4
4
  export interface ISuiVaultRef {
@@ -134,6 +134,108 @@ export declare function previewSuiRedemption(ref: ISuiVaultRef, shares: bigint,
134
134
  * @throws {@link UpshiftNetworkError} when every Sui endpoint fails.
135
135
  */
136
136
  export declare function getSuiShareBalance(owner: string, shareCoinType: string, network: ISuiNetwork, sui?: ISuiConfig): Promise<bigint>;
137
+ /**
138
+ * A pending withdrawal request: shares escrowed in a queued vault, waiting to
139
+ * be claimed or cancelled. Times are Unix milliseconds from the chain's clock.
140
+ */
141
+ export interface ISuiWithdrawalRequest {
142
+ /** Request object ID; pass it as `request` to claim or cancel. */
143
+ request: string;
144
+ /** The wallet's numeric ID for the request on this vault (`0, 1, 2, …`). */
145
+ requestId: bigint;
146
+ /** Wallet that made the request; the only one that may cancel it. */
147
+ owner: string;
148
+ /** Escrowed shares, in share base units. Priced when the request is claimed. */
149
+ shares: bigint;
150
+ /** Address the claim pays, into its address balance. */
151
+ payout: string;
152
+ /**
153
+ * The one address besides the owner that may claim, or `null` when anyone
154
+ * may. From one day after `eligibleAtMs` anyone may claim either way.
155
+ */
156
+ finalizer: string | null;
157
+ /** When the request was made. */
158
+ requestedAtMs: number;
159
+ /** When the cooldown ends and the request can be claimed. */
160
+ eligibleAtMs: number;
161
+ /**
162
+ * When the request stops being claimable (it can still be cancelled), or
163
+ * `null` when it never expires.
164
+ */
165
+ expiresAtMs: number | null;
166
+ }
167
+ /**
168
+ * A wallet's pending withdrawal requests on a vault, oldest first.
169
+ *
170
+ * Finds the requests the SDK made (see `buildSuiRequestRedeemTransaction`),
171
+ * however old: requests made outside the SDK with non-sequential IDs are not
172
+ * listed. Claimed and cancelled requests no longer exist and are left out.
173
+ *
174
+ * RPC cost: one object read for a wallet that has made up to 23 requests on
175
+ * the vault (most wallets), one more per further 25.
176
+ *
177
+ * @param ref - The vault and its coin types and network.
178
+ * @param owner - Wallet that made the requests (`0x` + 64 hex chars).
179
+ * @param sui - The SDK's `sui` config; applied only for `ref.network`.
180
+ * @returns The pending requests; empty when there are none.
181
+ * @throws {@link UpshiftValidationError} (`INVALID_ADDRESS`) for a malformed
182
+ * vault ID or owner, (`INVALID_INPUT`) for unknown contract IDs, or a vault
183
+ * ID that is not a vault of the configured `sui.originalPackageId`.
184
+ * @throws {@link UpshiftNetworkError} when every Sui endpoint fails, or the
185
+ * node cannot answer for one of the objects.
186
+ * @example
187
+ * ```ts
188
+ * const pending = await getSuiWithdrawalRequests(ref, account.address, sdkSuiConfig);
189
+ * const now = Date.now();
190
+ * const claimable = pending.filter(
191
+ * (r) => r.eligibleAtMs <= now && (r.expiresAtMs === null || now < r.expiresAtMs),
192
+ * );
193
+ * ```
194
+ */
195
+ export declare function getSuiWithdrawalRequests(ref: ISuiVaultRef, owner: string, sui?: ISuiConfig): Promise<ISuiWithdrawalRequest[]>;
196
+ /**
197
+ * Split a wallet's withdrawal requests into position redemptions, the way
198
+ * the EVM and Solana paths do: `availableRedemptions` holds requests still in
199
+ * their cooldown or past their window (cancel-only), `pendingRedemptions` the
200
+ * ones claimable now. Each entry's `amount` is the escrowed shares; `date` is
201
+ * when it becomes claimable, `timestamp` when it was made (Unix seconds).
202
+ * @internal
203
+ */
204
+ export declare function suiPositionRedemptions(requests: readonly ISuiWithdrawalRequest[], vault: string, decimals: number, nowMs: number): {
205
+ availableRedemptions: IVaultAvailableRedemption[];
206
+ pendingRedemptions: IVaultAvailableRedemption[];
207
+ };
208
+ /**
209
+ * A wallet's withdrawal requests on a vault as position redemptions (see
210
+ * {@link suiPositionRedemptions}). Empty when the SDK has no contract IDs for
211
+ * the network: the share balance can still be read without them, so a
212
+ * position should not fail for that.
213
+ *
214
+ * RPC cost: as {@link getSuiWithdrawalRequests}; none without contract IDs.
215
+ * @internal
216
+ */
217
+ export declare function getSuiPositionRedemptions(ref: ISuiVaultRef, owner: string, decimals: number, sui?: ISuiConfig): Promise<ReturnType<typeof suiPositionRedemptions>>;
218
+ /**
219
+ * The request ID a wallet's next withdrawal request on a vault takes: the
220
+ * lowest it has never used.
221
+ *
222
+ * RPC cost: as {@link getSuiWithdrawalRequests}.
223
+ * @internal
224
+ */
225
+ export declare function nextSuiWithdrawalRequestId(ref: ISuiVaultRef, owner: string, sui?: ISuiConfig): Promise<bigint>;
226
+ /**
227
+ * The object ID of a wallet's withdrawal request: derived from the vault, the
228
+ * owner and the request ID, as the contract derives it.
229
+ *
230
+ * @param vaultId - Vault object ID.
231
+ * @param owner - Wallet that made the request.
232
+ * @param requestId - The wallet's numeric request ID on the vault.
233
+ * @param originalPackageId - The original `upshift_vault` package, which
234
+ * names the derivation key type.
235
+ * @returns The request object ID.
236
+ * @internal
237
+ */
238
+ export declare function deriveSuiWithdrawalRequestId(vaultId: string, owner: string, requestId: bigint, originalPackageId: string): Promise<string>;
137
239
  /**
138
240
  * The vault reference for a backend row, or `null` when the row has no usable
139
241
  * `sui_vault_metadata`.
@@ -153,7 +255,7 @@ export declare function buildSuiVault(row: ITokenizedVault, state: ISuiVaultStat
153
255
  *
154
256
  * A failed read (unknown contract IDs, every endpoint down, an aborted
155
257
  * simulation) does not fail the call: the vault is built from the backend row
156
- * alone (see {@link buildSuiVault}) and a warning is logged.
258
+ * alone (see `buildSuiVault`) and a warning is logged.
157
259
  *
158
260
  * RPC cost: as {@link readSuiVaultState}.
159
261
  *