@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.
- package/lib/adapters/solana/getters.js +8 -1
- package/lib/adapters/solana/utils.js +16 -2
- package/lib/core/analytics/sentry.js +38 -5
- package/lib/core/analytics/version.d.ts +1 -1
- package/lib/core/analytics/version.js +1 -1
- package/lib/modules/vaults/getters.js +15 -1
- package/lib/sdk.d.ts +10 -0
- package/lib/services/untangled/types.d.ts +3 -2
- package/lib/services/untangled/utils.d.ts +41 -5
- package/lib/services/untangled/utils.js +209 -27
- package/lib/types/vaults.d.ts +10 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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
|
|
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
82
|
-
*
|
|
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
|
-
*
|
|
87
|
-
*
|
|
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 =
|
|
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 (
|
|
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
|
|
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:
|
|
315
|
+
asset_usd_value: positionUsd,
|
|
134
316
|
debt_usd_value: 0,
|
|
135
|
-
net_usd_value:
|
|
317
|
+
net_usd_value: positionUsd,
|
|
136
318
|
},
|
|
137
319
|
detail: {
|
|
138
320
|
supply_token_list: [
|
|
139
|
-
toDebankToken(
|
|
321
|
+
toDebankToken(positionSymbol, chain, positionUsd, 'supply', entry.protocolId),
|
|
140
322
|
],
|
|
141
323
|
borrow_token_list: [],
|
|
142
324
|
},
|
package/lib/types/vaults.d.ts
CHANGED
|
@@ -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[];
|