@augustdigital/sdk 9.8.0 → 9.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +57 -0
  2. package/lib/adapters/evm/index.d.ts +23 -5
  3. package/lib/adapters/evm/index.js +25 -4
  4. package/lib/core/analytics/method-taxonomy.js +2 -0
  5. package/lib/core/analytics/version.d.ts +1 -1
  6. package/lib/core/analytics/version.js +1 -1
  7. package/lib/core/attribution.d.ts +37 -5
  8. package/lib/core/attribution.js +39 -3
  9. package/lib/core/base.class.d.ts +6 -0
  10. package/lib/core/constants/core.d.ts +1 -0
  11. package/lib/core/constants/core.js +1 -0
  12. package/lib/core/constants/swap-router.d.ts +21 -4
  13. package/lib/core/constants/swap-router.js +22 -5
  14. package/lib/core/helpers/swap-router.d.ts +66 -5
  15. package/lib/core/helpers/swap-router.js +158 -7
  16. package/lib/main.d.ts +21 -3
  17. package/lib/main.js +20 -3
  18. package/lib/modules/vaults/getters.d.ts +287 -14
  19. package/lib/modules/vaults/getters.js +458 -48
  20. package/lib/modules/vaults/main.d.ts +18 -1
  21. package/lib/modules/vaults/main.js +23 -0
  22. package/lib/modules/vaults/write.actions.d.ts +119 -29
  23. package/lib/modules/vaults/write.actions.js +384 -77
  24. package/lib/sdk.d.ts +11675 -11107
  25. package/lib/services/layerzero/deposits.d.ts +6 -0
  26. package/lib/services/layerzero/deposits.js +8 -0
  27. package/lib/services/octavfi/fetcher.d.ts +30 -6
  28. package/lib/services/octavfi/fetcher.js +27 -91
  29. package/lib/services/subgraph/schema.d.ts +5 -2
  30. package/lib/services/subgraph/schema.js +22 -10
  31. package/lib/services/subgraph/vaults.d.ts +23 -1
  32. package/lib/services/subgraph/vaults.js +65 -0
  33. package/lib/types/subgraph.d.ts +10 -0
  34. package/lib/types/vaults.d.ts +38 -6
  35. package/package.json +1 -1
@@ -58,6 +58,11 @@ exports.assertPnlTokenPrice = assertPnlTokenPrice;
58
58
  exports.sumDepositsAtFaceValue = sumDepositsAtFaceValue;
59
59
  exports.selectPeerTransfers = selectPeerTransfers;
60
60
  exports.transferCostBasis = transferCostBasis;
61
+ exports.historyCoversMints = historyCoversMints;
62
+ exports.selectReserveDeposits = selectReserveDeposits;
63
+ exports.countWithdrawalRequestsOnce = countWithdrawalRequestsOnce;
64
+ exports.settleV2Redemptions = settleV2Redemptions;
65
+ exports.pnlCostBasis = pnlCostBasis;
61
66
  exports.getVaultUserLifetimePnl = getVaultUserLifetimePnl;
62
67
  exports.getVaultPnl = getVaultPnl;
63
68
  exports.getVaultHistoricalTimeseries = getVaultHistoricalTimeseries;
@@ -636,11 +641,14 @@ async function getVaultAllocations(vault, options, tokenizedVault) {
636
641
  // afterwards, and a borrower on a chain with portfolio coverage would otherwise
637
642
  // have its rows silently dropped rather than double-counted.
638
643
  let untangledTokenCount = 0;
644
+ let vaultOctav;
639
645
  const borrowerPortfolioFetchers = {
640
646
  solana: async (borrower) => {
641
- const portfolios = await (0, octavfi_1.fetchOctavfiPortfolios)([borrower]);
642
- return portfolios.length > 0
643
- ? (0, octavfi_1.transformOctavfiToDebank)(portfolios[0])
647
+ vaultOctav ??= (0, octavfi_1.fetchVaultOctavResponse)(vault, options?.headers);
648
+ const response = await vaultOctav;
649
+ const portfolio = response ? response[borrower] : undefined;
650
+ return portfolio && !('error' in portfolio)
651
+ ? (0, octavfi_1.transformOctavfiToDebank)(portfolio)
644
652
  : null;
645
653
  },
646
654
  };
@@ -2388,12 +2396,15 @@ function sumDepositsAtFaceValue(depositsByAsset, assetDecimals, vaultDecimals) {
2388
2396
  * another wallet. Mints (from the zero address), burns (to it) and transfers to
2389
2397
  * or from the vault itself are dropped: those are the deposit and withdrawal
2390
2398
  * legs the subgraph history already records, and counting them again would
2391
- * double the cost basis.
2399
+ * double the cost basis. Transfers to or from `exclude` are dropped too.
2392
2400
  *
2393
2401
  * @param transfers - Receipt-token transfers touching `wallet`, as returned by
2394
2402
  * the vault's transfer subgraph.
2395
2403
  * @param wallet - The wallet whose position is being valued.
2396
2404
  * @param vault - The vault contract address.
2405
+ * @param exclude - Other counterparties whose transfers are not peer
2406
+ * transfers, such as a LayerZero OVault's hub composer and share OFT
2407
+ * (`hubComposer` / `hubShareOFT` in `LAYERZERO_VAULTS`). Defaults to none.
2397
2408
  * @returns The peer-to-peer transfers, in input order. Addresses compare
2398
2409
  * case-insensitively.
2399
2410
  * @example
@@ -2401,9 +2412,9 @@ function sumDepositsAtFaceValue(depositsByAsset, assetDecimals, vaultDecimals) {
2401
2412
  * const peer = selectPeerTransfers(transfers, wallet, vault);
2402
2413
  * ```
2403
2414
  */
2404
- function selectPeerTransfers(transfers, wallet, vault) {
2415
+ function selectPeerTransfers(transfers, wallet, vault, exclude = []) {
2405
2416
  const self = wallet.toLowerCase();
2406
- const excluded = new Set([ethers_1.ZeroAddress.toLowerCase(), vault.toLowerCase()]);
2417
+ const excluded = new Set([ethers_1.ZeroAddress, vault, ...exclude].map((address) => address.toLowerCase()));
2407
2418
  return transfers.filter((transfer) => {
2408
2419
  const from = transfer.from.toLowerCase();
2409
2420
  const to = transfer.to.toLowerCase();
@@ -2423,9 +2434,11 @@ function selectPeerTransfers(transfers, wallet, vault) {
2423
2434
  * profit. A day with no usable ratio takes the latest earlier day's, and a
2424
2435
  * transfer older than the history takes the earliest day's.
2425
2436
  *
2426
- * Sent shares count only against a received basis. A wallet that sent shares
2427
- * but received none gets a zero basis, so the lifetime-PnL history guard still
2428
- * decides for it instead of reporting the sent shares as profit.
2437
+ * Sent shares count only against a received basis by default. A wallet that
2438
+ * sent shares but received none gets a zero basis, so the lifetime-PnL history
2439
+ * guard still decides for it instead of reporting the sent shares as profit.
2440
+ * `countSentWithoutReceipt: true` reports them anyway, for a caller that knows
2441
+ * the wallet also deposited.
2429
2442
  *
2430
2443
  * Pure and synchronous. Assumes, as the getter does, that shares and assets
2431
2444
  * share the vault's decimals.
@@ -2446,7 +2459,7 @@ function selectPeerTransfers(transfers, wallet, vault) {
2446
2459
  * });
2447
2460
  * ```
2448
2461
  */
2449
- function transferCostBasis({ transfers, wallet, vault, history, }) {
2462
+ function transferCostBasis({ transfers, wallet, vault, history, countSentWithoutReceipt = false, }) {
2450
2463
  const ratios = Object.entries(history)
2451
2464
  .filter(([, day]) => Number.isFinite(day.asset_share_ratio) &&
2452
2465
  (day.asset_share_ratio ?? 0) > 0)
@@ -2478,29 +2491,341 @@ function transferCostBasis({ transfers, wallet, vault, history, }) {
2478
2491
  sentRaw += assetsAt(shares, transfer.timestamp_);
2479
2492
  }
2480
2493
  }
2481
- if (receivedRaw === BigInt(0))
2494
+ if (receivedRaw === BigInt(0) && !countSentWithoutReceipt) {
2482
2495
  return { receivedRaw, sentRaw: BigInt(0) };
2496
+ }
2483
2497
  return { receivedRaw, sentRaw };
2484
2498
  }
2499
+ /**
2500
+ * Check that a wallet's indexed deposit history accounts for every share mint
2501
+ * its share-token ledger shows.
2502
+ *
2503
+ * A deposit mints shares to the wallet in the same transaction, so a mint with
2504
+ * no deposit of the same hash means the history is missing that deposit (an
2505
+ * older deposit the subgraph never indexed, or one a third party made for the
2506
+ * wallet). Folding peer transfers into such a history would credit the wallet
2507
+ * for shares it sent while missing what it paid for them, so
2508
+ * {@link getVaultUserLifetimePnl} only folds transfers for a depositor when
2509
+ * this holds.
2510
+ *
2511
+ * Pure and synchronous. Hashes and addresses compare case-insensitively.
2512
+ *
2513
+ * @param params - See {@link HistoryCoversMintsParams}.
2514
+ * @returns `true` when every mint to `wallet` has a matching deposit, including
2515
+ * when there are no mints.
2516
+ * @example
2517
+ * ```ts
2518
+ * const covered = historyCoversMints({
2519
+ * transfers,
2520
+ * wallet,
2521
+ * depositTransactionHashes: history
2522
+ * .filter((item) => item.type === 'deposit')
2523
+ * .map((item) => item.transactionHash_),
2524
+ * });
2525
+ * ```
2526
+ */
2527
+ function historyCoversMints({ transfers, wallet, depositTransactionHashes, }) {
2528
+ const self = wallet.toLowerCase();
2529
+ const zero = ethers_1.ZeroAddress.toLowerCase();
2530
+ const deposits = new Set(depositTransactionHashes.map((hash) => hash.toLowerCase()));
2531
+ return transfers.every((transfer) => transfer.from.toLowerCase() !== zero ||
2532
+ transfer.to.toLowerCase() !== self ||
2533
+ deposits.has(transfer.transactionHash_.toLowerCase()));
2534
+ }
2535
+ /**
2536
+ * Keep the `ReserveDeposit` events that explain a share mint the indexed
2537
+ * deposit history misses.
2538
+ *
2539
+ * High Growth ETH mints shares for deposits made through Kelp's gain adapter
2540
+ * with a `ReserveDeposit` event instead of an ERC-4626 `Deposit`, so those
2541
+ * deposits are absent from the user history and fail {@link historyCoversMints}.
2542
+ * An event counts only when its transaction mints shares to `wallet` and holds
2543
+ * no indexed deposit, so a deposit is never counted twice.
2544
+ *
2545
+ * Pure and synchronous. Hashes and addresses compare case-insensitively.
2546
+ *
2547
+ * @param params - See {@link SelectReserveDepositsParams}.
2548
+ * @returns The matching events, in input order.
2549
+ * @example
2550
+ * ```ts
2551
+ * const reserve = selectReserveDeposits({
2552
+ * reserveDeposits: await getSubgraphUserReserveDeposits(wallet, provider, vault),
2553
+ * transfers,
2554
+ * wallet,
2555
+ * depositTransactionHashes,
2556
+ * });
2557
+ * ```
2558
+ */
2559
+ function selectReserveDeposits({ reserveDeposits, transfers, wallet, depositTransactionHashes, }) {
2560
+ const self = wallet.toLowerCase();
2561
+ const zero = ethers_1.ZeroAddress.toLowerCase();
2562
+ const deposits = new Set(depositTransactionHashes.map((hash) => hash.toLowerCase()));
2563
+ const unmatchedMints = new Set(transfers
2564
+ .filter((transfer) => transfer.from.toLowerCase() === zero &&
2565
+ transfer.to.toLowerCase() === self &&
2566
+ !deposits.has(transfer.transactionHash_.toLowerCase()))
2567
+ .map((transfer) => transfer.transactionHash_.toLowerCase()));
2568
+ return reserveDeposits.filter((deposit) => deposit.account.toLowerCase() === self &&
2569
+ unmatchedMints.has(deposit.transactionHash_.toLowerCase()));
2570
+ }
2571
+ /**
2572
+ * Drop withdrawal-request rows the vault logged more than once for a single
2573
+ * request.
2574
+ *
2575
+ * A redemption requested through Kelp's gain adapter makes High Growth ETH emit
2576
+ * `WithdrawalRequested` twice for one share transfer, and the subgraph indexes
2577
+ * both. In a transaction where the share ledger shows the wallet sending shares
2578
+ * to the vault `n` times, at most `n` request rows are kept. A transaction the
2579
+ * ledger shows no such transfer for keeps every row, so a missing ledger leaves
2580
+ * the history unchanged.
2581
+ *
2582
+ * Pure and synchronous. Hashes and addresses compare case-insensitively.
2583
+ *
2584
+ * @param params - See {@link CountWithdrawalRequestsOnceParams}.
2585
+ * @returns The history without the surplus request rows, in input order.
2586
+ * @example
2587
+ * ```ts
2588
+ * const history = countWithdrawalRequestsOnce({ history: userHistory, transfers, wallet, vault });
2589
+ * ```
2590
+ */
2591
+ function countWithdrawalRequestsOnce({ history, transfers, wallet, vault, }) {
2592
+ const self = wallet.toLowerCase();
2593
+ const target = vault.toLowerCase();
2594
+ const remaining = new Map();
2595
+ for (const transfer of transfers) {
2596
+ if (transfer.from.toLowerCase() !== self ||
2597
+ transfer.to.toLowerCase() !== target)
2598
+ continue;
2599
+ const hash = transfer.transactionHash_.toLowerCase();
2600
+ remaining.set(hash, (remaining.get(hash) ?? 0) + 1);
2601
+ }
2602
+ return history.filter((item) => {
2603
+ if (item.type !== 'withdraw-request')
2604
+ return true;
2605
+ const hash = item.transactionHash_.toLowerCase();
2606
+ const left = remaining.get(hash);
2607
+ if (left === undefined)
2608
+ return true;
2609
+ if (left === 0)
2610
+ return false;
2611
+ remaining.set(hash, left - 1);
2612
+ return true;
2613
+ });
2614
+ }
2615
+ /**
2616
+ * Value a wallet's Tokenized Vault V2 redemption requests, which carry only
2617
+ * shares.
2618
+ *
2619
+ * Every processed withdrawal (`WithdrawalProcessed.assetsAmount`) counts at the
2620
+ * assets the vault paid. Its shares, estimated at the vault's
2621
+ * `asset_share_ratio` on the day it was processed, retire the oldest requests
2622
+ * first. Request shares left over count at the ratio on their claim date (see
2623
+ * `computeClaimableDate`) once claimable: the subgraph lacks some processed rows
2624
+ * (none for earnAUSD before 2026-02-01), and that ratio lands within about
2625
+ * 0.05% of what the vault paid. Shares not yet claimable are left to the
2626
+ * current share price. Matching by amount, not by claim date, keeps the result
2627
+ * right when a vault processes a request before the date today's `lagDuration`
2628
+ * implies. A leftover under 0.5% of a request that a later processed
2629
+ * withdrawal followed is rounding from the daily ratio, so it is dropped.
2630
+ *
2631
+ * Pure and synchronous. Assumes, as the getter does, that shares and assets
2632
+ * share the vault's decimals. A day with no usable ratio takes the latest
2633
+ * earlier day's, and a date older than the history takes the earliest day's.
2634
+ *
2635
+ * @param params - See {@link SettleV2RedemptionsParams}.
2636
+ * @returns The settled assets and the shares left to the current price; every
2637
+ * requested share is pending when `ratios` holds no usable day.
2638
+ * @example
2639
+ * ```ts
2640
+ * const { settledAssetsRaw, pendingShares } = settleV2Redemptions({
2641
+ * history,
2642
+ * lagDuration: 259_200,
2643
+ * ratios: (await fetchVaultHistoricalTimeseries(vault, nDays)).data,
2644
+ * now: Math.floor(Date.now() / 1000),
2645
+ * });
2646
+ * ```
2647
+ */
2648
+ function settleV2Redemptions({ history, lagDuration, ratios, now, }) {
2649
+ const requests = history
2650
+ .filter((item) => item.type === 'withdraw-request')
2651
+ .sort((a, b) => Number(a.timestamp_) - Number(b.timestamp_));
2652
+ const days = Object.entries(ratios)
2653
+ .filter(([, day]) => Number.isFinite(day.asset_share_ratio) &&
2654
+ (day.asset_share_ratio ?? 0) > 0)
2655
+ .map(([date, day]) => ({
2656
+ date,
2657
+ ratio: BigInt(Math.round(day.asset_share_ratio * 1e18)),
2658
+ }))
2659
+ .sort((a, b) => a.date.localeCompare(b.date));
2660
+ if (days.length === 0) {
2661
+ return {
2662
+ settledAssetsRaw: BigInt(0),
2663
+ pendingShares: requests.reduce((sum, item) => sum + BigInt(item.amount), BigInt(0)),
2664
+ };
2665
+ }
2666
+ const scale = BigInt(10) ** BigInt(18);
2667
+ const ratioOn = (date) => days.filter((day) => day.date <= date).pop()?.ratio ?? days[0].ratio;
2668
+ const utcDay = (timestamp) => new Date(Number(timestamp) * 1000).toISOString().slice(0, 10);
2669
+ let settledAssetsRaw = BigInt(0);
2670
+ let retiredShares = BigInt(0);
2671
+ let lastProcessedAt = Number.NEGATIVE_INFINITY;
2672
+ for (const item of history) {
2673
+ if (item.type !== 'withdraw-processed' || item.isInstant)
2674
+ continue;
2675
+ const assets = BigInt(item.amount);
2676
+ settledAssetsRaw += assets;
2677
+ retiredShares += (assets * scale) / ratioOn(utcDay(item.timestamp_));
2678
+ lastProcessedAt = Math.max(lastProcessedAt, Number(item.timestamp_));
2679
+ }
2680
+ let pendingShares = BigInt(0);
2681
+ for (const item of requests) {
2682
+ const shares = BigInt(item.amount);
2683
+ const retired = retiredShares < shares ? retiredShares : shares;
2684
+ retiredShares -= retired;
2685
+ const open = shares - retired;
2686
+ // A sliver left on a request processed later is ratio rounding.
2687
+ if (open === BigInt(0) ||
2688
+ (open * BigInt(200) <= shares &&
2689
+ lastProcessedAt >= Number(item.timestamp_)))
2690
+ continue;
2691
+ const claim = (0, date_utils_1.computeClaimableDate)(Number(item.timestamp_), lagDuration);
2692
+ if ((0, date_utils_1.isClaimableNow)(claim.epoch, now)) {
2693
+ const key = (0, date_utils_1.formatDateKey)(claim.year, claim.month, claim.day);
2694
+ settledAssetsRaw += (open * ratioOn(key)) / scale;
2695
+ }
2696
+ else {
2697
+ pendingShares += open;
2698
+ }
2699
+ }
2700
+ return { settledAssetsRaw, pendingShares };
2701
+ }
2702
+ /**
2703
+ * Fold peer share transfers into a wallet's lifetime-PnL cost basis.
2704
+ *
2705
+ * Shares a wallet received from another holder (a migration airdrop, a DEX
2706
+ * purchase, a transfer from another wallet) cost the wallet their value on
2707
+ * arrival, so they add to the deposited amount; shares it sent away count as
2708
+ * withdrawn. This applies whether or not the wallet also deposited, so a holder
2709
+ * who received shares and then topped up is not credited with the received
2710
+ * shares as profit, and a depositor who moved its shares elsewhere (a lending
2711
+ * market, a DEX) is not shown as having lost them. A wallet with no deposit and
2712
+ * no received shares keeps a zero basis, so the history guard still decides.
2713
+ *
2714
+ * A depositor whose history misses a mint ({@link historyCoversMints}) is the
2715
+ * exception: its transfers do not count, so the result matches what the getter
2716
+ * returned before transfers counted for depositors. On a LayerZero OVault the
2717
+ * caller leaves the hub composer and share OFT out of the transfers (see
2718
+ * {@link selectPeerTransfers}): the composer's deliveries are already counted
2719
+ * as LayerZero deposits, and shares bridged to a spoke chain still count in the
2720
+ * position.
2721
+ *
2722
+ * Pure and synchronous.
2723
+ *
2724
+ * @param params - See {@link PnlCostBasisParams}.
2725
+ * @returns The deposited and withdrawn amounts to compute PnL from, and the
2726
+ * transfer-priced part of the deposited amount (zero when transfers did not
2727
+ * count).
2728
+ * @example
2729
+ * ```ts
2730
+ * const basis = pnlCostBasis({
2731
+ * depositedRaw,
2732
+ * withdrawnAssetsRaw,
2733
+ * hasDeposits: depositedRaw > BigInt(0),
2734
+ * mintsMatchHistory: historyCoversMints({
2735
+ * transfers,
2736
+ * wallet,
2737
+ * depositTransactionHashes,
2738
+ * }),
2739
+ * transfers: transferCostBasis({
2740
+ * transfers,
2741
+ * wallet,
2742
+ * vault,
2743
+ * history,
2744
+ * countSentWithoutReceipt: true,
2745
+ * }),
2746
+ * });
2747
+ * ```
2748
+ */
2749
+ function pnlCostBasis({ depositedRaw, withdrawnAssetsRaw, hasDeposits, mintsMatchHistory, transfers, }) {
2750
+ if (hasDeposits && !mintsMatchHistory) {
2751
+ return {
2752
+ depositedRaw,
2753
+ withdrawnAssetsRaw,
2754
+ receivedByTransferRaw: BigInt(0),
2755
+ };
2756
+ }
2757
+ const sentRaw = hasDeposits || transfers.receivedRaw > BigInt(0)
2758
+ ? transfers.sentRaw
2759
+ : BigInt(0);
2760
+ return {
2761
+ depositedRaw: depositedRaw + transfers.receivedRaw,
2762
+ withdrawnAssetsRaw: withdrawnAssetsRaw + sentRaw,
2763
+ receivedByTransferRaw: transfers.receivedRaw,
2764
+ };
2765
+ }
2485
2766
  // One receipt-transfer subgraph query, plus one backend timeseries request when
2486
2767
  // peer transfers exist. Any failure yields a zero basis, which leaves the
2487
2768
  // getter exactly where it was without transfers (the history guard decides).
2488
- async function fetchTransferCostBasis(vault, wallet, provider, headers) {
2769
+ async function fetchTransferCostBasis(vault, wallet, provider, headers, exclude = []) {
2489
2770
  const none = { receivedRaw: BigInt(0), sentRaw: BigInt(0) };
2771
+ let shareTransfers = [];
2490
2772
  try {
2491
- const transfers = selectPeerTransfers(await (0, vaults_1.getSubgraphUserTransfers)(wallet, provider, vault, undefined, {
2492
- receiptSubgraph: true,
2493
- }), wallet, vault);
2773
+ shareTransfers = await (0, vaults_1.getSubgraphUserTransfers)(wallet, provider, vault, undefined, { receiptSubgraph: true });
2774
+ const transfers = selectPeerTransfers(shareTransfers, wallet, vault, exclude);
2494
2775
  if (transfers.length === 0)
2495
- return none;
2496
- const earliest = Math.min(...transfers.map((t) => Number(t.timestamp_)));
2497
- const nDays = Math.ceil((Date.now() / 1000 - earliest) / 86_400) + 1;
2498
- const { data } = await (0, core_1.fetchVaultHistoricalTimeseries)(vault, nDays, headers);
2499
- return transferCostBasis({ transfers, wallet, vault, history: data });
2776
+ return { basis: none, shareTransfers };
2777
+ const data = await fetchShareRatios(vault, Math.min(...transfers.map((t) => Number(t.timestamp_))), headers);
2778
+ return {
2779
+ basis: transferCostBasis({
2780
+ transfers,
2781
+ wallet,
2782
+ vault,
2783
+ history: data,
2784
+ countSentWithoutReceipt: true,
2785
+ }),
2786
+ shareTransfers,
2787
+ };
2500
2788
  }
2501
2789
  catch (e) {
2502
2790
  core_1.Logger.log.warn('getVaultUserLifetimePnl:transferCostBasis', `Failed to price shares received by transfer: ${e}`);
2503
- return none;
2791
+ return { basis: none, shareTransfers };
2792
+ }
2793
+ }
2794
+ // The vault's daily history from `since` (unix seconds) on. A vault whose
2795
+ // history stops before then (a closed vault) is read in full instead, so the
2796
+ // latest earlier day still prices it.
2797
+ async function fetchShareRatios(vault, since, headers) {
2798
+ const nDays = Math.ceil((Date.now() / 1000 - since) / 86_400) + 1;
2799
+ const { data } = await (0, core_1.fetchVaultHistoricalTimeseries)(vault, nDays, headers);
2800
+ const usable = Object.values(data ?? {}).some((day) => Number.isFinite(day.asset_share_ratio) &&
2801
+ (day.asset_share_ratio ?? 0) > 0);
2802
+ if (usable)
2803
+ return data;
2804
+ return (await (0, core_1.fetchVaultHistoricalTimeseries)(vault, 3650, headers)).data;
2805
+ }
2806
+ // Values a Tokenized Vault V2 wallet's redemption requests: one lagDuration()
2807
+ // read and one backend timeseries request. Undefined when either fails, so the
2808
+ // caller values every request at the current share price.
2809
+ async function readV2Redemptions(provider, vault, history, headers) {
2810
+ try {
2811
+ const contract = (0, core_1.createContract)({
2812
+ provider,
2813
+ address: vault,
2814
+ abi: TokenizedVaultV2_1.ABI_TOKENIZED_VAULT_V2,
2815
+ });
2816
+ const now = Math.floor(Date.now() / 1000);
2817
+ const earliest = Math.min(...history
2818
+ .filter((item) => item.type === 'withdraw-request')
2819
+ .map((item) => Number(item.timestamp_)));
2820
+ const [lagDuration, data] = await Promise.all([
2821
+ readLagDuration(contract, vault, 'getVaultUserLifetimePnl'),
2822
+ fetchShareRatios(vault, earliest, headers),
2823
+ ]);
2824
+ return settleV2Redemptions({ history, lagDuration, ratios: data, now });
2825
+ }
2826
+ catch (e) {
2827
+ core_1.Logger.log.warn('getVaultUserLifetimePnl:v2Redemptions', `Failed to value redemption requests: ${e}`);
2828
+ return undefined;
2504
2829
  }
2505
2830
  }
2506
2831
  /**
@@ -2524,13 +2849,39 @@ async function fetchTransferCostBasis(vault, wallet, provider, headers) {
2524
2849
  * from the price server. There is no stand-in price: when none resolves the
2525
2850
  * getter throws rather than returning USD values it cannot know.
2526
2851
  *
2527
- * Shares received by transfer: when history has no deposit, peer transfers of
2528
- * the share token (see {@link selectPeerTransfers}) are valued at the vault's
2529
- * daily share ratio ({@link transferCostBasis}) — received shares become
2530
- * `totalDeposited`, sent shares add to `totalWithdrawn`. PnL for such a wallet
2531
- * therefore runs from the day its shares arrived. This costs one extra
2532
- * subgraph query, plus one backend timeseries request when transfers exist,
2533
- * and only for wallets with no indexed deposit.
2852
+ * Shares moved by transfer: peer transfers of the share token (see
2853
+ * {@link selectPeerTransfers}) are valued at the vault's daily share ratio
2854
+ * ({@link transferCostBasis}) and folded in by {@link pnlCostBasis} — received
2855
+ * shares add to `totalDeposited` (and are reported again as
2856
+ * `receivedByTransfer`), sent shares add to `totalWithdrawn`. This holds
2857
+ * alongside indexed deposits, so a wallet that received shares and also
2858
+ * deposited is not credited with the received shares as profit. LayerZero
2859
+ * OVaults count transfers only when history holds no deposit, because their
2860
+ * composer delivers cross-chain deposits by transfer.
2861
+ *
2862
+ * Gaps in the indexed history: a share mint with no `Deposit` event counts as
2863
+ * a deposit of the assets in its `ReserveDeposit` event
2864
+ * ({@link selectReserveDeposits}; High Growth ETH emits it for Kelp gain-adapter
2865
+ * deposits), and a withdrawal request the vault logged twice counts once
2866
+ * ({@link countWithdrawalRequestsOnce}). Transfers fold in for a depositor only
2867
+ * once every mint is explained.
2868
+ *
2869
+ * Tokenized Vault V2 redemptions carry only shares, so a settled one counts at
2870
+ * the assets the vault paid, or at the share ratio on its claim date when the
2871
+ * subgraph has no processed row for it ({@link settleV2Redemptions}). Only a
2872
+ * request not yet claimable is valued at the current share price. When the lag
2873
+ * or the ratio history cannot be read, every request is valued at the current
2874
+ * share price instead.
2875
+ *
2876
+ * Request cost: one receipt-transfer subgraph query (and a `symbol()` RPC read)
2877
+ * per call, plus one backend timeseries request when peer transfers exist and
2878
+ * one `reserveDeposits` subgraph query when a mint has no indexed deposit, and
2879
+ * for a Tokenized Vault V2 wallet with redemption requests, a `lagDuration()`
2880
+ * read plus one backend timeseries request. A timeseries window with no usable
2881
+ * ratio (a closed vault whose history stopped earlier) is read again in full,
2882
+ * so the latest earlier day still prices it. For
2883
+ * non-LayerZero vaults the transfer query runs in parallel with the history
2884
+ * read; for LayerZero vaults it runs only when history holds no deposit.
2534
2885
  *
2535
2886
  * @param vault - Vault contract address
2536
2887
  * @param wallet - User wallet address
@@ -2554,6 +2905,17 @@ async function getVaultUserLifetimePnl({ vault, wallet, options, }) {
2554
2905
  if (!(0, ethers_1.isAddress)(wallet))
2555
2906
  throw new Error(`Wallet input parameter is not an address: ${String(wallet)}`);
2556
2907
  const provider = (0, core_1.createProvider)(options.rpcUrl);
2908
+ const lzVaultKey = (0, deposits_1.isLayerZeroVault)(vault);
2909
+ const headers = options?.headers;
2910
+ // Peer transfers always count (see pnlCostBasis), so start that read
2911
+ // alongside the history instead of after it. A LayerZero OVault's hub
2912
+ // composer and share OFT are not peers.
2913
+ const transferCostBasisPromise = fetchTransferCostBasis(vault, wallet, provider, headers, lzVaultKey
2914
+ ? [
2915
+ deposits_1.LAYERZERO_VAULTS[lzVaultKey].hubComposer,
2916
+ deposits_1.LAYERZERO_VAULTS[lzVaultKey].hubShareOFT,
2917
+ ]
2918
+ : []);
2557
2919
  // Parallelize independent operations
2558
2920
  const [decimals, userHistory, positions, tokenizedVaultResult] = await Promise.all([
2559
2921
  (0, core_1.getDecimals)(provider, vault),
@@ -2579,7 +2941,6 @@ async function getVaultUserLifetimePnl({ vault, wallet, options, }) {
2579
2941
  throw new Error(`Failed to resolve decimals for vault ${vault}`);
2580
2942
  }
2581
2943
  // Fetch LayerZero deposits/redeems for supported vaults
2582
- const lzVaultKey = (0, deposits_1.isLayerZeroVault)(vault);
2583
2944
  let lzDeposits = [];
2584
2945
  let lzRedeems = [];
2585
2946
  if (lzVaultKey) {
@@ -2639,19 +3000,25 @@ async function getVaultUserLifetimePnl({ vault, wallet, options, }) {
2639
3000
  }
2640
3001
  return { underlyingTokenSymbol, underlyingAssetAddress };
2641
3002
  })();
3003
+ const { basis: transfers, shareTransfers } = await transferCostBasisPromise;
2642
3004
  // Process history - count all withdrawal requests (both processed and pending)
2643
- // We don't count processed withdrawals separately to avoid double counting
3005
+ // We don't count processed withdrawals separately to avoid double counting,
3006
+ // except on Tokenized Vault V2 (below), whose requests carry only shares.
2644
3007
  // Group deposit amounts by asset address to handle different decimals
2645
3008
  const depositsByAsset = new Map();
2646
3009
  // Separate withdrawal tracking: shares need conversion, assets don't
2647
3010
  let totalWithdrawalsInShares = BigInt(0);
2648
3011
  let totalWithdrawalsInAssets = BigInt(0);
2649
- for (const historyItem of userHistory) {
3012
+ let requestedShares = BigInt(0);
3013
+ // ignore bad transactions when calculating pnl
3014
+ const history = countWithdrawalRequestsOnce({
3015
+ history: userHistory,
3016
+ transfers: shareTransfers,
3017
+ wallet,
3018
+ vault,
3019
+ }).filter((item) => !(0, core_1.isBadTransaction)(item.transactionHash_, tokenizedVault.chain));
3020
+ for (const historyItem of history) {
2650
3021
  const amount = BigInt(historyItem.amount);
2651
- // ignore bad transactions when calculating pnl
2652
- if ((0, core_1.isBadTransaction)(historyItem.transactionHash_, tokenizedVault.chain)) {
2653
- continue;
2654
- }
2655
3022
  if (historyItem.type === 'deposit') {
2656
3023
  // Use assetIn if available (V2 vaults), otherwise use a default key
2657
3024
  // Use proper type guard instead of type assertion
@@ -2663,7 +3030,7 @@ async function getVaultUserLifetimePnl({ vault, wallet, options, }) {
2663
3030
  }
2664
3031
  else if (historyItem.type === 'withdraw-request') {
2665
3032
  // Withdraw requests are in shares, need conversion to assets
2666
- totalWithdrawalsInShares += amount;
3033
+ requestedShares += amount;
2667
3034
  }
2668
3035
  else if (historyItem.type === 'withdraw-processed' &&
2669
3036
  historyItem.isInstant) {
@@ -2671,6 +3038,18 @@ async function getVaultUserLifetimePnl({ vault, wallet, options, }) {
2671
3038
  totalWithdrawalsInAssets += amount;
2672
3039
  }
2673
3040
  }
3041
+ // A V2 request is worth what the vault paid when it settled it, not the
3042
+ // current share price (see settleV2Redemptions).
3043
+ const v2Redemptions = version === 'evm-2' && requestedShares > BigInt(0)
3044
+ ? await readV2Redemptions(provider, vault, history, headers)
3045
+ : undefined;
3046
+ if (v2Redemptions) {
3047
+ totalWithdrawalsInShares += v2Redemptions.pendingShares;
3048
+ totalWithdrawalsInAssets += v2Redemptions.settledAssetsRaw;
3049
+ }
3050
+ else {
3051
+ totalWithdrawalsInShares += requestedShares;
3052
+ }
2674
3053
  // Include LayerZero cross-chain deposits and redeems
2675
3054
  if (lzVaultKey && lzDeposits.length > 0) {
2676
3055
  for (const lzDeposit of lzDeposits) {
@@ -2689,15 +3068,35 @@ async function getVaultUserLifetimePnl({ vault, wallet, options, }) {
2689
3068
  totalWithdrawalsInShares += BigInt(lzRedeem.shareAmt);
2690
3069
  }
2691
3070
  }
2692
- // A wallet can hold shares it never deposited (a migration airdrop, a DEX
2693
- // purchase). Only when history has no deposit at all do received shares
2694
- // become the cost basis, so a depositor's PnL is computed as before.
2695
- if (depositsByAsset.size === 0) {
2696
- const { receivedRaw, sentRaw } = await fetchTransferCostBasis(vault, wallet, provider, options?.headers);
2697
- if (receivedRaw > BigInt(0))
2698
- depositsByAsset.set('default', receivedRaw);
2699
- totalWithdrawalsInAssets += sentRaw;
3071
+ const depositTransactionHashes = userHistory
3072
+ .filter((item) => item.type === 'deposit')
3073
+ .map((item) => item.transactionHash_);
3074
+ let mintsMatchHistory = historyCoversMints({
3075
+ transfers: shareTransfers,
3076
+ wallet,
3077
+ depositTransactionHashes,
3078
+ });
3079
+ if (!mintsMatchHistory) {
3080
+ const reserveDeposits = selectReserveDeposits({
3081
+ reserveDeposits: await (0, vaults_1.getSubgraphUserReserveDeposits)(wallet, provider, vault),
3082
+ transfers: shareTransfers,
3083
+ wallet,
3084
+ depositTransactionHashes,
3085
+ }).filter((deposit) => !(0, core_1.isBadTransaction)(deposit.transactionHash_, tokenizedVault.chain));
3086
+ for (const deposit of reserveDeposits) {
3087
+ depositsByAsset.set('default', (depositsByAsset.get('default') ?? BigInt(0)) +
3088
+ BigInt(deposit.assets));
3089
+ }
3090
+ mintsMatchHistory = historyCoversMints({
3091
+ transfers: shareTransfers,
3092
+ wallet,
3093
+ depositTransactionHashes: [
3094
+ ...depositTransactionHashes,
3095
+ ...reserveDeposits.map((deposit) => deposit.transactionHash_),
3096
+ ],
3097
+ });
2700
3098
  }
3099
+ const hasDeposits = depositsByAsset.size > 0;
2701
3100
  // Fetch decimals for each unique asset in parallel
2702
3101
  const assetDecimals = new Map();
2703
3102
  await Promise.all(Array.from(depositsByAsset.keys()).map(async (assetAddress) => {
@@ -2845,7 +3244,14 @@ async function getVaultUserLifetimePnl({ vault, wallet, options, }) {
2845
3244
  // For non-evm-2 or when share price is 0, treat shares as assets
2846
3245
  totalWithdrawnRaw += totalWithdrawalsInShares;
2847
3246
  }
2848
- const totalWithdrawn = (0, core_1.toNormalizedBn)(totalWithdrawnRaw, decimals);
3247
+ const costBasis = pnlCostBasis({
3248
+ depositedRaw: totalDepositedNativeRaw,
3249
+ withdrawnAssetsRaw: totalWithdrawnRaw,
3250
+ hasDeposits,
3251
+ mintsMatchHistory,
3252
+ transfers,
3253
+ });
3254
+ const totalWithdrawn = (0, core_1.toNormalizedBn)(costBasis.withdrawnAssetsRaw, decimals);
2849
3255
  // Guard the 2026-07-09 over-inflation incident: a live position with no
2850
3256
  // deposits *and* no withdrawals means the history failed to load, and the
2851
3257
  // PnL formula would report the whole position as profit. Fail loudly with a
@@ -2853,8 +3259,8 @@ async function getVaultUserLifetimePnl({ vault, wallet, options, }) {
2853
3259
  assertPnlHistoryConsistent({
2854
3260
  vault,
2855
3261
  wallet,
2856
- hasDeposits: depositsByAsset.size > 0,
2857
- totalWithdrawnRaw,
3262
+ hasDeposits: hasDeposits || costBasis.receivedByTransferRaw > BigInt(0),
3263
+ totalWithdrawnRaw: costBasis.withdrawnAssetsRaw,
2858
3264
  currentPositionRaw: BigInt(currentPositionValue.raw),
2859
3265
  });
2860
3266
  const tokenPriceUsd = assertPnlTokenPrice({
@@ -2867,8 +3273,10 @@ async function getVaultUserLifetimePnl({ vault, wallet, options, }) {
2867
3273
  // computed above) — convert to USD with the same underlying price used
2868
3274
  // for the other two legs below, so all three legs share one conversion
2869
3275
  // factor instead of drifting independently against each other.
2870
- const totalDeposited = (0, core_1.toNormalizedBn)(totalDepositedNativeRaw, decimals);
3276
+ const totalDeposited = (0, core_1.toNormalizedBn)(costBasis.depositedRaw, decimals);
2871
3277
  const totalDepositedUsd = Number(totalDeposited.normalized) * tokenPriceUsd;
3278
+ const receivedByTransfer = (0, core_1.toNormalizedBn)(costBasis.receivedByTransferRaw, decimals);
3279
+ const receivedByTransferUsd = Number(receivedByTransfer.normalized) * tokenPriceUsd;
2872
3280
  const totalWithdrawnUsd = Number(totalWithdrawn.normalized) * tokenPriceUsd;
2873
3281
  const currentPositionValueUsd = Number(currentPositionValue.normalized) * tokenPriceUsd;
2874
3282
  // Calculate lifetimePnl in USD first (since deposits may be in different tokens with different prices)
@@ -2896,6 +3304,8 @@ async function getVaultUserLifetimePnl({ vault, wallet, options, }) {
2896
3304
  lifetimePnl,
2897
3305
  lifetimePnlUsd,
2898
3306
  pnlPercentage,
3307
+ receivedByTransfer,
3308
+ receivedByTransferUsd,
2899
3309
  };
2900
3310
  }
2901
3311
  catch (e) {