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