@augustdigital/sdk 9.2.1 → 9.3.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.
@@ -48,7 +48,14 @@ const utils_2 = require("../../modules/vaults/utils");
48
48
  * @returns Formatted vault object with Solana-specific fields
49
49
  */
50
50
  const getSolanaVault = async (tokenizedVault, options) => {
51
- const solanaRpcUrl = options.rpcUrl;
51
+ // `options.rpcUrl` is resolved upstream as `providers[vault.chain]`, and that
52
+ // map is keyed by EVM chain id — Solana's synthetic id is never in it, so the
53
+ // field arrives undefined for every Solana vault however well the app has
54
+ // configured its Solana RPC. The connection built from that RPC *is* passed
55
+ // (`solanaService`), so take the endpoint off it rather than fetching against
56
+ // `undefined`.
57
+ const solanaRpcUrl = (options.rpcUrl ??
58
+ options.solanaService?.connection?.rpcEndpoint);
52
59
  const solanaMetadata = tokenizedVault.solana_vault_metadata;
53
60
  const programId = utils_1.SolanaUtils.resolveProgramId(tokenizedVault.address, solanaMetadata);
54
61
  // Fallback decimals: prefer backend metadata, then the application default
@@ -333,6 +333,17 @@ async function getVaultMints({ vaultProgramId, vaultAddress, connection, idl, })
333
333
  }
334
334
  }
335
335
  async function getToken({ mintAddress, endpoint, connection, }) {
336
+ // `endpoint` is optional on ISolanaConnectionOptions, so a caller with no
337
+ // Solana RPC configured reaches `fetch(undefined, ...)`, which throws
338
+ // "Cannot read properties of undefined (reading 'toString')" from inside the
339
+ // runtime's fetch wrapper — an unactionable error masquerading as a token
340
+ // failure. Bail before the request instead.
341
+ if (!endpoint) {
342
+ core_1.Logger.log.warn('getToken', 'No Solana RPC endpoint configured', {
343
+ mintAddress: String(mintAddress),
344
+ });
345
+ return null;
346
+ }
336
347
  try {
337
348
  const cacheKey = `solana_token-${mintAddress}`;
338
349
  const cachedData = await (0, core_1.getSafeCache)(cacheKey);
@@ -425,8 +436,11 @@ async function getToken({ mintAddress, endpoint, connection, }) {
425
436
  return null;
426
437
  }
427
438
  catch (error) {
428
- core_1.Logger.log.error('getToken', error, {
429
- message: 'Error fetching token metadata',
439
+ // Callers fall back to backend metadata when this returns null, so a failed
440
+ // lookup is degraded-but-handled — warn, don't raise it as an error.
441
+ core_1.Logger.log.warn('getToken', 'Error fetching token metadata', {
442
+ mintAddress: String(mintAddress),
443
+ error,
430
444
  });
431
445
  return null;
432
446
  }
@@ -143,13 +143,34 @@ const ERROR_DEDUPE_MAX_KEYS = 500;
143
143
  * true rate. `resetAnalytics()` clears this map for the whole process.
144
144
  */
145
145
  const errorDedupeState = new Map();
146
+ /**
147
+ * Strip the per-request variables out of an error message.
148
+ *
149
+ * Truncating at 120 characters is not enough on its own: the variable part is
150
+ * usually a vault or wallet address near the FRONT of the message
151
+ * (`Request timeout after 90s: /subaccount/0x3F13…/otc_positions`), so one
152
+ * failure mode still produced a distinct signature — and therefore its own
153
+ * private rate-limit allowance and its own Sentry issue — per address. That is
154
+ * how a single backend timeout became 5k events across a dozen issues in a day.
155
+ *
156
+ * Placeholders are kept structural (`/subaccount/<addr>/otc_positions`) so
157
+ * genuinely different endpoints and failure modes stay separate.
158
+ *
159
+ * @param message - The raw error message.
160
+ * @returns The message with addresses, numbers, and query strings masked.
161
+ */
162
+ function normalizeErrorMessage(message) {
163
+ return message
164
+ .replace(/0x[0-9a-fA-F]{6,}/g, '<addr>')
165
+ .replace(/\?[^\s]*/g, '')
166
+ .replace(/\b\d{4,}\b/g, '<n>');
167
+ }
146
168
  /**
147
169
  * Build a low-cardinality signature for an error.
148
170
  *
149
- * Message tails carry the variable parts (addresses, block ranges, response
150
- * bodies), so only the leading 120 characters participate — enough to separate
151
- * distinct failure modes without splitting one failure mode into thousands of
152
- * unique keys.
171
+ * The message is normalized (see {@link normalizeErrorMessage}) and then capped
172
+ * at 120 characters — enough to separate distinct failure modes without
173
+ * splitting one failure mode into thousands of unique keys.
153
174
  *
154
175
  * @param error - The normalized error being captured.
155
176
  * @param origin - The `sdk.origin` call-site tag, when present.
@@ -157,7 +178,8 @@ const errorDedupeState = new Map();
157
178
  */
158
179
  function errorSignature(error, origin) {
159
180
  const tag = typeof origin === 'string' ? origin : '';
160
- return `${tag}|${error.name}|${(error.message || '').slice(0, 120)}`;
181
+ const message = normalizeErrorMessage(error.message || '').slice(0, 120);
182
+ return `${tag}|${error.name}|${message}`;
161
183
  }
162
184
  /**
163
185
  * Decide whether an error should be sent to Sentry, applying a per-signature
@@ -265,6 +287,17 @@ function createSentrySink() {
265
287
  if (admission.suppressed > 0) {
266
288
  scope.setExtra('sdk.suppressed_since_last', admission.suppressed);
267
289
  }
290
+ // Group by the same normalized signature the rate limiter uses.
291
+ // Sentry's default grouping keys on the raw message, so one backend
292
+ // timeout fans out into an issue per vault/wallet address — the
293
+ // per-issue notification volume that flooded the on-call webhook.
294
+ // Addresses stay recoverable from the event message and tags.
295
+ scope.setFingerprint?.([
296
+ 'sdk',
297
+ typeof context?.tag === 'string' ? context.tag : 'untagged',
298
+ normalized.name,
299
+ normalizeErrorMessage(normalized.message || '').slice(0, 120),
300
+ ]);
268
301
  sdk.captureException(normalized);
269
302
  });
270
303
  }
@@ -3,4 +3,4 @@
3
3
  * Generated during publish from package.json version
4
4
  * This file is gitignored and created at publish time
5
5
  */
6
- export declare const SDK_VERSION = "9.2.1";
6
+ export declare const SDK_VERSION = "9.3.0";
@@ -6,5 +6,5 @@ exports.SDK_VERSION = void 0;
6
6
  * Generated during publish from package.json version
7
7
  * This file is gitignored and created at publish time
8
8
  */
9
- exports.SDK_VERSION = '9.2.1';
9
+ exports.SDK_VERSION = '9.3.0';
10
10
  //# sourceMappingURL=version.js.map
@@ -480,6 +480,16 @@ async function getVaultAllocations(vault, options) {
480
480
  const netValue = { value: 0 };
481
481
  let debankErr = false;
482
482
  let unfilteredTokens = [];
483
+ // Untangled publishes the vault's asset composition as its own decomposition of
484
+ // tvlUsd, with no join to `byProtocol`. It therefore cannot be accumulated into
485
+ // `tokenExposure` alongside the synthesized position tokens without
486
+ // double-counting, so it replaces those rows below for Stellar vaults.
487
+ let untangledAssets = null;
488
+ // How much of `tokenExposure` the Untangled parse produced. Only that prefix is
489
+ // replaced: the accumulator is shared with the per-borrower loop that runs
490
+ // afterwards, and a borrower on a chain with portfolio coverage would otherwise
491
+ // have its rows silently dropped rather than double-counted.
492
+ let untangledTokenCount = 0;
483
493
  const borrowerPortfolioFetchers = {
484
494
  solana: async (borrower) => {
485
495
  const portfolios = await (0, octavfi_1.fetchOctavfiPortfolios)([borrower]);
@@ -567,6 +577,8 @@ async function getVaultAllocations(vault, options) {
567
577
  const untangledRes = (0, untangled_1.transformUntangledToDebank)(untangledExposure);
568
578
  (0, debank_1.parseVaultLevelDebank)(untangledRes, protocolExposure, tokenExposure, vault, exposurePerCategory, netValue);
569
579
  unfilteredTokens = untangledRes.subaccount.tokens;
580
+ untangledAssets = (0, untangled_1.untangledAssetExposure)(untangledExposure);
581
+ untangledTokenCount = tokenExposure.length;
570
582
  defiPerBorrower[vault] = (0, debank_1.parseLoanLevelDebank)(untangledRes);
571
583
  }
572
584
  }
@@ -658,7 +670,9 @@ async function getVaultAllocations(vault, options) {
658
670
  defi: protocolExposure,
659
671
  cefi: cefiExposure,
660
672
  otc: otcPositions,
661
- tokens: tokenExposure,
673
+ tokens: untangledAssets?.length
674
+ ? [...untangledAssets, ...tokenExposure.slice(untangledTokenCount)]
675
+ : tokenExposure,
662
676
  defiPerBorrower,
663
677
  exposurePerCategory,
664
678
  netValue: netValue.value,
package/lib/sdk.d.ts CHANGED
@@ -23170,6 +23170,16 @@ declare type AsArray<T> = T extends readonly unknown[] ? T : never;
23170
23170
  */
23171
23171
  export declare interface IVaultAllocations {
23172
23172
  defi: IDebankProtocolExposure[];
23173
+ /**
23174
+ * The vault's asset composition, in USD (`amount` is a dollar value).
23175
+ *
23176
+ * On Stellar this is Untangled's `byAsset` breakdown, which decomposes the
23177
+ * same `tvlUsd` that `defi` does along a different axis — assets rather than
23178
+ * venues. The two do not join: which venue holds which asset is not published,
23179
+ * so this is the only place a Stellar vault's assets are stated, and reading a
23180
+ * per-venue asset split out of `exposurePerCategory` is not supported for
23181
+ * multi-asset Stellar vaults.
23182
+ */
23173
23183
  tokens: IDebankTokenExposure[];
23174
23184
  cefi: IWSSubaccountCefi[];
23175
23185
  unfilteredTokens: IDebankTokenExposure[];
@@ -15,8 +15,9 @@
15
15
  export interface IUntangledProtocolExposure {
16
16
  /**
17
17
  * Untangled protocol slug, e.g. `blend`, `templar`, `aquarius`. Two slugs are
18
- * not protocols: `stellar-wallet` (custody wallets) and `upshift` (the vault's
19
- * own idle buffer).
18
+ * special: `stellar-wallet` is custody wallet cash, and `upshift` is the
19
+ * vault's own idle buffer plus any position it holds in another Upshift
20
+ * vault — only the part beyond the `idle` location is deployed capital.
20
21
  */
21
22
  protocolId: string;
22
23
  /** USD value held in this protocol. */
@@ -1,3 +1,4 @@
1
+ import type { IDebankTokenExposure } from '../../types';
1
2
  import type { IDebankNormalizedResponse } from '../debank/utils';
2
3
  import type { IUntangledVaultExposure } from './types';
3
4
  /** Display metadata for an Untangled protocol slug. */
@@ -16,6 +17,31 @@ export interface IUntangledProtocolMetadata {
16
17
  * instead of silently vanishing from the breakdown.
17
18
  */
18
19
  export declare const UNTANGLED_PROTOCOL_REGISTRY: Record<string, IUntangledProtocolMetadata>;
20
+ /**
21
+ * The vault's asset composition, as vault-level token exposure.
22
+ *
23
+ * This is the one asset breakdown Untangled actually publishes, and it is
24
+ * authoritative: `byAsset` decomposes the same `tvlUsd` that `byProtocol` does.
25
+ * It is returned separately from {@link transformUntangledToDebank} because it
26
+ * cannot be folded into that structure — attributing an asset to a position
27
+ * would require a join the API does not provide, and adding these on top of the
28
+ * synthesized position tokens would double-count the vault.
29
+ *
30
+ * Negative rows are kept. A vault that has moved capital into another Upshift
31
+ * vault is reported holding the receipt token and owing the asset it spent —
32
+ * earnXLM carries `earnUSDC +$1,170,913` against `USDC -$1,170,271` — and those
33
+ * only sum back to `tvlUsd` together. Dropping the liability would overstate the
34
+ * vault's assets by the full size of the position.
35
+ *
36
+ * @param exposure - A resolved Untangled report, or `null`/`undefined` when the
37
+ * fetch failed.
38
+ * @returns One row per asset clearing `filterOutBySize` in absolute terms,
39
+ * largest first, with `amount` denominated in USD. Empty when `exposure` is
40
+ * absent. `id` carries the asset symbol rather than an address — Untangled
41
+ * reports assets by ticker and publishes no contract address for them — so it
42
+ * is safe as a display key and must not be read as an on-chain address.
43
+ */
44
+ export declare function untangledAssetExposure(exposure: IUntangledVaultExposure | null | undefined): IDebankTokenExposure[];
19
45
  /**
20
46
  * Transform an Untangled vault exposure report into the DeBank-normalized shape
21
47
  * that `parseVaultLevelDebank` and `parseLoanLevelDebank` consume.
@@ -29,14 +55,24 @@ export declare const UNTANGLED_PROTOCOL_REGISTRY: Record<string, IUntangledProto
29
55
  * Mapping:
30
56
  * - Each `byProtocol` entry becomes one position with a single supplying token,
31
57
  * valued in USD (`price: 1`), on the chain from
32
- * {@link UNTANGLED_PROTOCOL_REGISTRY}.
33
- * - `stellar-wallet` and `upshift` are summed into one wallet token, because
34
- * both are undeployed capital rather than protocol exposure.
58
+ * {@link UNTANGLED_PROTOCOL_REGISTRY}. The token is labelled with the vault's
59
+ * asset only when one asset covers essentially all of it (see
60
+ * {@link vaultAssetSymbol}); otherwise it carries the value with no symbol,
61
+ * and {@link untangledAssetExposure} supplies the composition separately.
62
+ * - `stellar-wallet` becomes a wallet token, because custody cash is undeployed
63
+ * capital rather than protocol exposure.
64
+ * - `upshift` is split: the vault's own idle buffer (the `idle` location) joins
65
+ * that wallet token, and whatever is left is a real position in another
66
+ * Upshift vault, named after its receipt token. Untangled reports one
67
+ * `upshift` slice however many vaults that is, so a vault holding receipts in
68
+ * two others emits one merged position under the generic name — the value is
69
+ * still right, only the label is coarser.
35
70
  * - Dust is left in place: `filterOutBySize` in the parser applies the same
36
71
  * threshold Stellar and EVM vaults share.
37
72
  *
38
- * `locations`, `asOf`, `stale` and `tvlUsd` are not consumed TVL already comes
39
- * from `getVaultTvl`, and freshness is not yet surfaced on `IVaultAllocations`.
73
+ * Only the `idle` entries of `locations` are consumed, to size the buffer inside
74
+ * `upshift`. `asOf`, `stale` and `tvlUsd` are not TVL already comes from
75
+ * `getVaultTvl`, and freshness is not yet surfaced on `IVaultAllocations`.
40
76
  *
41
77
  * @param exposure - A resolved Untangled report, or `null`/`undefined` when the
42
78
  * fetch failed.
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.UNTANGLED_PROTOCOL_REGISTRY = void 0;
4
+ exports.untangledAssetExposure = untangledAssetExposure;
4
5
  exports.transformUntangledToDebank = transformUntangledToDebank;
6
+ const core_1 = require("../../core/helpers/core");
5
7
  /**
6
8
  * Display metadata for the protocol slugs Untangled reports today.
7
9
  *
@@ -14,14 +16,98 @@ exports.UNTANGLED_PROTOCOL_REGISTRY = {
14
16
  blend: { name: 'Blend', chain: 'stellar' },
15
17
  templar: { name: 'Templar (NEAR)', chain: 'near' },
16
18
  aquarius: { name: 'Aquarius', chain: 'stellar' },
19
+ upshift: { name: 'Upshift', chain: 'stellar' },
17
20
  };
18
21
  /**
19
- * Slugs that are not protocol positions: `stellar-wallet` is custody wallet
20
- * cash and `upshift` is the vault's own idle buffer. Both are undeployed
21
- * capital, which the exposure breakdown renders as wallet holdings — the same
22
- * treatment EVM vaults get for funds sitting outside a protocol.
22
+ * Custody wallet cash undeployed capital, which the exposure breakdown renders
23
+ * as wallet holdings, the same treatment EVM vaults get for funds sitting
24
+ * outside a protocol.
23
25
  */
24
- const WALLET_PROTOCOL_IDS = new Set(['stellar-wallet', 'upshift']);
26
+ const CUSTODY_WALLET_PROTOCOL_ID = 'stellar-wallet';
27
+ /**
28
+ * Capital held inside Upshift itself. This slug is NOT purely idle: it is the
29
+ * vault's own idle buffer PLUS any receipt tokens the vault holds in another
30
+ * Upshift vault. Gami earnXLM on 2026-09-11 reported `upshift: $1,371,484` made
31
+ * up of a `$200,572` idle buffer and `$1,170,913` of earnUSDC shares — counting
32
+ * all of it as idle hid a third of the vault's deployed capital and overstated
33
+ * the idle bucket almost sixfold.
34
+ */
35
+ const UPSHIFT_PROTOCOL_ID = 'upshift';
36
+ /** `locations[].label` for the capital still sitting in the vault contract. */
37
+ const IDLE_LOCATION_LABEL = 'idle';
38
+ /** Tightness of the `byAsset` lookup in {@link matchingAssetSymbol}. */
39
+ const ASSET_MATCH_TOLERANCE = 0.001;
40
+ /**
41
+ * Smallest `upshift` remainder treated as a real position rather than as float
42
+ * disagreement between the `idle` location and the `byProtocol` slice. One cent
43
+ * sits orders of magnitude above double-precision noise on million-dollar
44
+ * values and orders of magnitude below any holding worth naming.
45
+ */
46
+ const MIN_POSITION_USD = 0.01;
47
+ /**
48
+ * Share of `tvlUsd` the largest `byAsset` entry must cover before a synthesized
49
+ * position may be labelled with it.
50
+ *
51
+ * `byProtocol` and `byAsset` are two orthogonal decompositions of the same TVL,
52
+ * and Untangled publishes no join between them, so which venue holds which asset
53
+ * is genuinely unknown. Tagging every position with the largest asset is only
54
+ * honest when there is effectively nothing else in the vault; below that, the
55
+ * label would assert a holding the report does not support.
56
+ */
57
+ const SINGLE_ASSET_SHARE = 0.99;
58
+ /**
59
+ * USD the vault holds in its own contract, summed from the `idle` locations.
60
+ *
61
+ * Returns `null` when no location carries that label, so a caller can tell
62
+ * "nothing is idle" from "this report does not say".
63
+ *
64
+ * The whole sum is charged against the `upshift` slice, which assumes idle
65
+ * capital never belongs to another protocol. That holds for the shape Untangled
66
+ * serves — the buffer sitting in the vault contract is exactly what it files
67
+ * under `upshift` — and the `Math.min` at the call site caps the subtraction, so
68
+ * a report that broke the assumption would understate the position rather than
69
+ * invent one.
70
+ */
71
+ function idleLocationUsd(exposure) {
72
+ let total = null;
73
+ (exposure.locations || []).forEach((location) => {
74
+ if (location?.label?.toLowerCase() !== IDLE_LOCATION_LABEL)
75
+ return;
76
+ total = (total ?? 0) + (Number(location.valueUsd) || 0);
77
+ });
78
+ return total;
79
+ }
80
+ /**
81
+ * The `byAsset` symbol whose value matches this position's, within
82
+ * {@link ASSET_MATCH_TOLERANCE}.
83
+ *
84
+ * Untangled splits assets vault-wide, so a position's asset can normally only be
85
+ * guessed. A vault's holding in another Upshift vault is the exception: the
86
+ * receipt token is its own `byAsset` entry carrying exactly that position's
87
+ * value, so it can be named instead of falling back to the vault's dominant
88
+ * asset.
89
+ *
90
+ * This only ever chooses a *label* — the USD value is taken from `byProtocol`
91
+ * either way — so it is built to abstain rather than guess. Two cases reach it
92
+ * legitimately and get no answer: a vault holding receipts in two other Upshift
93
+ * vaults, which Untangled reports as one `upshift` slice whose remainder matches
94
+ * neither entry on its own, and a remainder small enough that the relative
95
+ * window closes around rounding noise.
96
+ *
97
+ * @param valueUsd - The position's USD value. The caller has already cleared it
98
+ * past {@link MIN_POSITION_USD}, so the relative window is never taken around
99
+ * zero.
100
+ * @returns The matching symbol, or `null` when no entry matches or more than one
101
+ * does — the caller names the position generically instead.
102
+ */
103
+ function matchingAssetSymbol(exposure, valueUsd) {
104
+ const matches = (exposure.byAsset || []).filter((asset) => {
105
+ const assetUsd = Number(asset?.valueUsd) || 0;
106
+ return (assetUsd > 0 &&
107
+ Math.abs(assetUsd - valueUsd) <= valueUsd * ASSET_MATCH_TOLERANCE);
108
+ });
109
+ return matches.length === 1 ? matches[0].symbol || null : null;
110
+ }
25
111
  const DEFAULT_CHAIN = 'stellar';
26
112
  /**
27
113
  * Stellar's asset precision, carried only to satisfy the DeBank token shape.
@@ -34,14 +120,19 @@ const SYNTHETIC_TOKEN_DECIMALS = 7;
34
120
  *
35
121
  * `parseVaultLevelDebank` computes USD as `price * amount`, so a value already
36
122
  * denominated in dollars is expressed as `price: 1, amount: <usd>`.
123
+ *
124
+ * @param symbol - The asset this slice holds, or `null` when the report cannot
125
+ * say. A null symbol still produces a token — the value has to reach
126
+ * `netValue` and the protocol totals either way — but carries no asset label,
127
+ * so consumers render the slice's value without naming an asset.
37
128
  */
38
129
  function toDebankToken(symbol, chain, valueUsd, exposureType, protocolId) {
39
130
  return {
40
- id: symbol,
131
+ id: symbol ?? '',
41
132
  chain,
42
- name: symbol,
43
- symbol,
44
- optimized_symbol: symbol,
133
+ name: symbol ?? '',
134
+ symbol: symbol ?? '',
135
+ optimized_symbol: symbol ?? '',
45
136
  decimals: SYNTHETIC_TOKEN_DECIMALS,
46
137
  logo_url: '',
47
138
  amount: valueUsd,
@@ -51,18 +142,71 @@ function toDebankToken(symbol, chain, valueUsd, exposureType, protocolId) {
51
142
  };
52
143
  }
53
144
  /**
54
- * The asset holding most of the vault's value.
145
+ * The symbol every synthesized position may be tagged with, or `null` when the
146
+ * report cannot support tagging them at all.
55
147
  *
56
- * Untangled splits assets vault-wide rather than per protocol, so every
57
- * synthesized position is tagged with this one symbol. For the Gami vaults it is
58
- * an exact description (earnUSDC is ~100% USDC); an exact per-protocol split
59
- * needs `byProtocol[].assets[]` from Untangled.
148
+ * Untangled splits assets vault-wide rather than per protocol, so the only
149
+ * defensible label is the dominant asset, and only while it covers essentially
150
+ * the whole vault ({@link SINGLE_ASSET_SHARE}). Gami earnXLM qualifies (99.98%
151
+ * XLM); earnUSDC does not (84.18% USDC, 15.82% deJTRSY), and labelling its
152
+ * positions USDC claimed the whole $25.3M was USDC while $4.0M of deJTRSY went
153
+ * unmentioned. Callers that get `null` emit no asset label and let
154
+ * {@link untangledAssetExposure} carry the composition instead.
155
+ *
156
+ * An exact per-protocol split needs `byProtocol[].assets[]` from Untangled.
60
157
  */
61
- function dominantAssetSymbol(exposure) {
158
+ function vaultAssetSymbol(exposure) {
62
159
  const dominant = (exposure.byAsset || []).reduce((best, asset) => !best || Number(asset?.valueUsd || 0) > Number(best.valueUsd || 0)
63
160
  ? asset
64
161
  : best, null);
65
- return dominant?.symbol || 'UNKNOWN';
162
+ if (!dominant?.symbol)
163
+ return null;
164
+ const tvlUsd = Number(exposure.tvlUsd) || 0;
165
+ if (tvlUsd <= 0)
166
+ return null;
167
+ const share = (Number(dominant.valueUsd) || 0) / tvlUsd;
168
+ return share >= SINGLE_ASSET_SHARE ? dominant.symbol : null;
169
+ }
170
+ /**
171
+ * The vault's asset composition, as vault-level token exposure.
172
+ *
173
+ * This is the one asset breakdown Untangled actually publishes, and it is
174
+ * authoritative: `byAsset` decomposes the same `tvlUsd` that `byProtocol` does.
175
+ * It is returned separately from {@link transformUntangledToDebank} because it
176
+ * cannot be folded into that structure — attributing an asset to a position
177
+ * would require a join the API does not provide, and adding these on top of the
178
+ * synthesized position tokens would double-count the vault.
179
+ *
180
+ * Negative rows are kept. A vault that has moved capital into another Upshift
181
+ * vault is reported holding the receipt token and owing the asset it spent —
182
+ * earnXLM carries `earnUSDC +$1,170,913` against `USDC -$1,170,271` — and those
183
+ * only sum back to `tvlUsd` together. Dropping the liability would overstate the
184
+ * vault's assets by the full size of the position.
185
+ *
186
+ * @param exposure - A resolved Untangled report, or `null`/`undefined` when the
187
+ * fetch failed.
188
+ * @returns One row per asset clearing `filterOutBySize` in absolute terms,
189
+ * largest first, with `amount` denominated in USD. Empty when `exposure` is
190
+ * absent. `id` carries the asset symbol rather than an address — Untangled
191
+ * reports assets by ticker and publishes no contract address for them — so it
192
+ * is safe as a display key and must not be read as an on-chain address.
193
+ */
194
+ function untangledAssetExposure(exposure) {
195
+ if (!exposure)
196
+ return [];
197
+ const chain = exposure.vault?.chain || DEFAULT_CHAIN;
198
+ return (exposure.byAsset || [])
199
+ .filter((asset) => asset?.symbol && (0, core_1.filterOutBySize)(Math.abs(Number(asset.valueUsd) || 0)))
200
+ .map((asset) => ({
201
+ amount: Number(asset.valueUsd) || 0,
202
+ chain,
203
+ decimals: SYNTHETIC_TOKEN_DECIMALS,
204
+ id: asset.symbol,
205
+ logoUrl: '',
206
+ name: asset.symbol,
207
+ symbol: asset.symbol,
208
+ }))
209
+ .sort((a, b) => b.amount - a.amount);
66
210
  }
67
211
  /**
68
212
  * Transform an Untangled vault exposure report into the DeBank-normalized shape
@@ -77,14 +221,24 @@ function dominantAssetSymbol(exposure) {
77
221
  * Mapping:
78
222
  * - Each `byProtocol` entry becomes one position with a single supplying token,
79
223
  * valued in USD (`price: 1`), on the chain from
80
- * {@link UNTANGLED_PROTOCOL_REGISTRY}.
81
- * - `stellar-wallet` and `upshift` are summed into one wallet token, because
82
- * both are undeployed capital rather than protocol exposure.
224
+ * {@link UNTANGLED_PROTOCOL_REGISTRY}. The token is labelled with the vault's
225
+ * asset only when one asset covers essentially all of it (see
226
+ * {@link vaultAssetSymbol}); otherwise it carries the value with no symbol,
227
+ * and {@link untangledAssetExposure} supplies the composition separately.
228
+ * - `stellar-wallet` becomes a wallet token, because custody cash is undeployed
229
+ * capital rather than protocol exposure.
230
+ * - `upshift` is split: the vault's own idle buffer (the `idle` location) joins
231
+ * that wallet token, and whatever is left is a real position in another
232
+ * Upshift vault, named after its receipt token. Untangled reports one
233
+ * `upshift` slice however many vaults that is, so a vault holding receipts in
234
+ * two others emits one merged position under the generic name — the value is
235
+ * still right, only the label is coarser.
83
236
  * - Dust is left in place: `filterOutBySize` in the parser applies the same
84
237
  * threshold Stellar and EVM vaults share.
85
238
  *
86
- * `locations`, `asOf`, `stale` and `tvlUsd` are not consumed TVL already comes
87
- * from `getVaultTvl`, and freshness is not yet surfaced on `IVaultAllocations`.
239
+ * Only the `idle` entries of `locations` are consumed, to size the buffer inside
240
+ * `upshift`. `asOf`, `stale` and `tvlUsd` are not TVL already comes from
241
+ * `getVaultTvl`, and freshness is not yet surfaced on `IVaultAllocations`.
88
242
  *
89
243
  * @param exposure - A resolved Untangled report, or `null`/`undefined` when the
90
244
  * fetch failed.
@@ -104,22 +258,50 @@ function transformUntangledToDebank(exposure) {
104
258
  if (!exposure) {
105
259
  return { subaccount: { positions: [], tokens: [] } };
106
260
  }
107
- const symbol = dominantAssetSymbol(exposure);
261
+ const symbol = vaultAssetSymbol(exposure);
108
262
  const vaultChain = exposure.vault?.chain || DEFAULT_CHAIN;
263
+ const idleUsd = idleLocationUsd(exposure);
109
264
  const positions = [];
110
265
  let walletValueUsd = 0;
111
266
  (exposure.byProtocol || []).forEach((entry) => {
112
267
  if (!entry?.protocolId)
113
268
  return;
114
269
  const valueUsd = Number(entry.valueUsd) || 0;
115
- if (WALLET_PROTOCOL_IDS.has(entry.protocolId)) {
270
+ if (entry.protocolId === CUSTODY_WALLET_PROTOCOL_ID) {
116
271
  walletValueUsd += valueUsd;
117
272
  return;
118
273
  }
274
+ let positionUsd = valueUsd;
275
+ let positionSymbol = symbol;
276
+ let receiptSymbol = null;
277
+ if (entry.protocolId === UPSHIFT_PROTOCOL_ID) {
278
+ // A report with no idle location cannot say how much of this is the
279
+ // buffer, so it claims the whole slice. That leaves a remainder of exactly
280
+ // zero, which the guard below turns back into the pre-split behaviour —
281
+ // the two are coupled, so don't change one without the other.
282
+ const idleShare = idleUsd === null ? valueUsd : Math.min(valueUsd, idleUsd);
283
+ positionUsd = valueUsd - idleShare;
284
+ // The buffer reaches us twice, from two different fields, so a vault that
285
+ // holds nothing in another Upshift vault can still leave a sub-cent
286
+ // remainder behind. That is disagreement between the fields, not a
287
+ // position: keep the slice whole in the wallet bucket rather than emit a
288
+ // phantom the parser's size filter would have to clean up. Genuine dust is
289
+ // still passed through for that filter to judge.
290
+ if (positionUsd < MIN_POSITION_USD) {
291
+ walletValueUsd += valueUsd;
292
+ return;
293
+ }
294
+ walletValueUsd += idleShare;
295
+ receiptSymbol = matchingAssetSymbol(exposure, positionUsd);
296
+ positionSymbol = receiptSymbol ?? symbol;
297
+ }
119
298
  const metadata = exports.UNTANGLED_PROTOCOL_REGISTRY[entry.protocolId];
120
299
  const chain = metadata?.chain || vaultChain;
121
- const name = metadata?.name ||
300
+ const baseName = metadata?.name ||
122
301
  entry.protocolId.charAt(0).toUpperCase() + entry.protocolId.slice(1);
302
+ // "Upshift" alone reads as the whole product inside Upshift's own UI, so the
303
+ // slice names the vault it sits in whenever the receipt token identifies it.
304
+ const name = receiptSymbol ? `${baseName} (${receiptSymbol})` : baseName;
123
305
  positions.push({
124
306
  id: entry.protocolId,
125
307
  chain,
@@ -130,13 +312,13 @@ function transformUntangledToDebank(exposure) {
130
312
  {
131
313
  name,
132
314
  stats: {
133
- asset_usd_value: valueUsd,
315
+ asset_usd_value: positionUsd,
134
316
  debt_usd_value: 0,
135
- net_usd_value: valueUsd,
317
+ net_usd_value: positionUsd,
136
318
  },
137
319
  detail: {
138
320
  supply_token_list: [
139
- toDebankToken(symbol, chain, valueUsd, 'supply', entry.protocolId),
321
+ toDebankToken(positionSymbol, chain, positionUsd, 'supply', entry.protocolId),
140
322
  ],
141
323
  borrow_token_list: [],
142
324
  },
@@ -143,6 +143,16 @@ export type IExposurePerCategory = {
143
143
  */
144
144
  export interface IVaultAllocations {
145
145
  defi: IDebankProtocolExposure[];
146
+ /**
147
+ * The vault's asset composition, in USD (`amount` is a dollar value).
148
+ *
149
+ * On Stellar this is Untangled's `byAsset` breakdown, which decomposes the
150
+ * same `tvlUsd` that `defi` does along a different axis — assets rather than
151
+ * venues. The two do not join: which venue holds which asset is not published,
152
+ * so this is the only place a Stellar vault's assets are stated, and reading a
153
+ * per-venue asset split out of `exposurePerCategory` is not supported for
154
+ * multi-asset Stellar vaults.
155
+ */
146
156
  tokens: IDebankTokenExposure[];
147
157
  cefi: IWSSubaccountCefi[];
148
158
  unfilteredTokens: IDebankTokenExposure[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augustdigital/sdk",
3
- "version": "9.2.1",
3
+ "version": "9.3.0",
4
4
  "main": "lib/index.js",
5
5
  "types": "lib/sdk.d.ts",
6
6
  "keywords": [