@augustdigital/sdk 8.17.0 → 8.19.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.
@@ -75,11 +75,84 @@ export { explorerLink } from './explorer-link';
75
75
  /**
76
76
  * Fetch token decimals from contract or Solana mint.
77
77
  * Results are cached to minimize RPC calls.
78
+ *
79
+ * **Never throws** — a failed read logs at error level and resolves
80
+ * `undefined`. Callers that must not silently proceed on an unknown scale (any
81
+ * path that encodes an amount) should use {@link getDecimalsOrThrow} instead:
82
+ * feeding `undefined` into `toNormalizedBn` silently defaults to 18 decimals.
83
+ *
78
84
  * @param provider Web3 provider
79
85
  * @param address Token contract address or Solana mint
80
- * @returns Number of decimals for the token
86
+ * @param isVault Resolve `address` as a vault (evm-2 vaults redirect to the
87
+ * receipt token) rather than reading it as a plain ERC-20. Defaults to `true`.
88
+ * @returns Number of decimals for the token, or `undefined` when the read failed.
81
89
  */
82
90
  export declare const getDecimals: (provider: IContractRunner, address: IAddress, isVault?: boolean) => Promise<number>;
91
+ /**
92
+ * Read a token's `decimals()` with the **same cache and stampede protection as
93
+ * {@link getDecimals}**, but surfacing failures instead of swallowing them, and
94
+ * retrying the transient ones.
95
+ *
96
+ * Two reasons this exists rather than a flag on `getDecimals`:
97
+ *
98
+ * 1. **`getDecimals` must keep returning `undefined` on failure** — a dozen
99
+ * read paths depend on that. Amount-encoding paths need the opposite: an
100
+ * `undefined` reaching `toNormalizedBn` silently means 18 decimals, which
101
+ * misencodes the transaction. Here the original error propagates, so the
102
+ * caller's `AugustSDKError` keeps its cause and its Sentry grouping.
103
+ * 2. **Retry.** Providers intermittently return an empty response to
104
+ * `decimals()`, which ethers reports as
105
+ * `missing revert data (action="call", data="0x313ce567", …)` — a shape a
106
+ * deployed ERC-20 cannot legitimately produce, since `decimals()` takes no
107
+ * arguments. That is retried here via {@link isEmptyViewResponse} scoped to
108
+ * this exact selector, alongside ordinary transport faults
109
+ * ({@link isRetryableRpcError}). The empty-view widening applies **only**
110
+ * here; everywhere else the strict transport definition stands. A genuine
111
+ * revert (`CALL_EXCEPTION` carrying revert data) is never retried.
112
+ *
113
+ * Cache is shared with `getDecimals`, so a read-then-write flow against the
114
+ * same token costs one `decimals()` RPC in total, and N concurrent callers for
115
+ * an uncached token collapse to one.
116
+ *
117
+ * **Exception — an unresolvable chain scope bypasses the cache entirely.** When
118
+ * {@link resolveProviderScope} cannot determine the chain (a runner with no
119
+ * resolved network and no connection URL, e.g. a browser provider before its
120
+ * first request), the only available key is the shared `unknown` bucket. Read
121
+ * paths happily use that bucket, and this function deliberately does not: the
122
+ * two paths have different blast radii. A wrong cached `decimals` on a read
123
+ * renders a wrong number on screen; on a write it misencodes an amount the user
124
+ * then *signs*. The collision needed — the same address being a different token
125
+ * with different decimals on two chains, within one process, across a
126
+ * mid-session chain switch — is narrow, but "no worse than the read path" is
127
+ * not the bar for a value that ends up in a transaction. In that case the read
128
+ * is neither served from nor written to the cache, and it also skips the
129
+ * in-flight dedup map, since joining another caller under an untrusted key
130
+ * would reintroduce exactly the collision being avoided. Retries still apply;
131
+ * the only thing forgone is the RPC saving.
132
+ *
133
+ * RPC cost: 1 on a cache miss, 0 on a hit, up to 3 on a miss that keeps
134
+ * faulting (~750ms of added latency in the worst case before it gives up).
135
+ * Always ≥1 when the chain scope is unresolvable.
136
+ *
137
+ * @param runner - Provider or signer to read through. A signer is unwrapped to
138
+ * its provider for cache scoping, so it shares entries with reads on the
139
+ * same chain.
140
+ * @param address - Token address. Read directly as an ERC-20 — pass the
141
+ * receipt-token address yourself for an `evm-2` vault's share scale.
142
+ * @param tag - Low-cardinality label for retry breadcrumbs (e.g.
143
+ * `'vaultDeposit:poolDecimals'`).
144
+ * @returns The token's decimals.
145
+ * @throws The underlying read error once retries are exhausted, or immediately
146
+ * when the failure is neither a transport fault nor an empty view response.
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * // Amount encoding must not proceed on a guessed scale.
151
+ * const decimals = await getDecimalsOrThrow(signer, token, 'deposit:decimals');
152
+ * const amount = toNormalizedBn(userInput, decimals);
153
+ * ```
154
+ */
155
+ export declare const getDecimalsOrThrow: (runner: IContractRunner, address: IAddress, tag: string) => Promise<number>;
83
156
  /**
84
157
  * Fetch the whitelisted deposit-asset addresses from a vault's whitelist
85
158
  * contract. Cached with a 5-minute TTL; a fresh fetch fans out into N
@@ -87,12 +160,89 @@ export declare const getDecimals: (provider: IContractRunner, address: IAddress,
87
160
  * @internal
88
161
  */
89
162
  export declare const getWhitelistedAssets: (provider: IContractRunner, whitelistAddress: IAddress) => Promise<IAddress[]>;
163
+ /**
164
+ * Read an `evm-2` vault's receipt (LP) token address via `lpTokenAddress()`,
165
+ * retrying only the transient transport faults and surfacing everything else
166
+ * unchanged.
167
+ *
168
+ * Why this exists: `lpTokenAddress()` sits immediately before the receipt-token
169
+ * `decimals()` read on every `evm-2` write path (approve / deposit / request
170
+ * redeem / swap-router deposit). Those `decimals()` reads were hardened against
171
+ * provider blips ({@link getDecimalsOrThrow}); the `lpTokenAddress()` read one
172
+ * line above them was not, so a single truncated `eth_call` response still
173
+ * failed the whole write with
174
+ * `missing revert data (action="call", data="0xf5ae497a", …)` — observed in
175
+ * production against a mainnet vault whose `lpTokenAddress()` demonstrably
176
+ * returns a real address when the provider is healthy.
177
+ *
178
+ * **This must never hide a misrouted vault, and does not.** `lpTokenAddress()`
179
+ * exists *only* on `evm-2` vaults. A vault wrongly classified as `evm-2` returns
180
+ * empty returndata for it, deterministically and forever, and that empty
181
+ * response is byte-identical to the transient one — it is the *only* signal the
182
+ * SDK gets that its version routing was wrong. So the retry here is deliberately
183
+ * shaped to preserve that signal:
184
+ *
185
+ * - it is **bounded** (3 attempts, ~750ms of backoff in total — see
186
+ * `retryOnTransientRpc`), never open-ended;
187
+ * - on exhaustion it **rethrows the original error object**, with its identity,
188
+ * message, `code` and `transaction.data` intact, so the caller's
189
+ * `AugustSDKError` cause and Sentry grouping are exactly what they are today;
190
+ * - it has **no fallback**: it never substitutes another address, never resolves
191
+ * the vault address itself, and never resolves `null`/`undefined`. It either
192
+ * returns a real receipt-token address or throws.
193
+ *
194
+ * Net effect: a blip costs a retry, a misroute costs three `eth_call`s and then
195
+ * fails exactly as loudly as before.
196
+ *
197
+ * **Deliberately not cached.** Unlike `decimals()`, the vault→receipt-token
198
+ * mapping is not memoized here. Caching it would make a misroute's first
199
+ * (failed) probe and every subsequent one diverge, and would put a
200
+ * vault-identity mapping in the cache on the money path. The lenient, cached
201
+ * reader is {@link getReceiptTokenAddress} — use that on read paths that can
202
+ * tolerate `undefined`.
203
+ *
204
+ * RPC cost: exactly 1 `eth_call` on success; at most 3 when the provider keeps
205
+ * faulting.
206
+ *
207
+ * @param runner - Provider or signer to read through.
208
+ * @param vault - Address of the `evm-2` tokenized vault.
209
+ * @param tag - Low-cardinality label for retry breadcrumbs (e.g.
210
+ * `'vaultRequestRedeem:receiptToken'`).
211
+ * @returns The vault's receipt (LP) token address. Never `null`/`undefined`.
212
+ * @throws The underlying read error, unmodified, once the bounded retries are
213
+ * exhausted — or immediately when the failure is neither a transport fault nor
214
+ * an empty response to this exact selector (e.g. a genuine revert carrying
215
+ * revert data).
216
+ *
217
+ * @example
218
+ * ```ts
219
+ * const receiptToken = await getReceiptTokenAddressOrThrow(
220
+ * signer,
221
+ * vault,
222
+ * 'vaultRequestRedeem:receiptToken',
223
+ * );
224
+ * const decimals = await getDecimalsOrThrow(signer, receiptToken, 'tag');
225
+ * ```
226
+ */
227
+ export declare const getReceiptTokenAddressOrThrow: (runner: IContractRunner, vault: IAddress, tag: string) => Promise<IAddress>;
90
228
  /**
91
229
  * Fetch receipt token address from tokenized vault contract.
92
230
  * Results are cached to minimize RPC calls.
231
+ *
232
+ * **Never throws** — a failed read logs at error level and resolves
233
+ * `undefined`, which several read paths depend on. The underlying
234
+ * `lpTokenAddress()` call is made through
235
+ * {@link getReceiptTokenAddressOrThrow}, so a transient provider blip is
236
+ * absorbed by a bounded retry before that happens; a deterministic failure (a
237
+ * vault that has no `lpTokenAddress()` because it is not `evm-2`) still resolves
238
+ * `undefined` after the attempts are spent, exactly as before. Callers on a
239
+ * money path must use {@link getReceiptTokenAddressOrThrow} directly instead —
240
+ * an `undefined` receipt-token address there would silently mis-address a
241
+ * `decimals()` read.
242
+ *
93
243
  * @param provider Web3 provider
94
244
  * @param address Tokenized vault contract address
95
- * @returns Receipt token address
245
+ * @returns Receipt token address, or `undefined` when the read failed.
96
246
  */
97
247
  export declare const getReceiptTokenAddress: (provider: IContractRunner, address: IAddress) => Promise<`0x${string}`>;
98
248
  /**
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getManagementFeePercent = exports.getSymbol = exports.getReceiptTokenAddress = exports.getWhitelistedAssets = exports.getDecimals = exports.explorerLink = exports.getInfuraProvider = exports.createProvider = exports.getChainId = exports.determineBlockSkipInternal = exports.determineSecondsPerBlock = exports.determineBlockCutoff = void 0;
3
+ exports.getManagementFeePercent = exports.getSymbol = exports.getReceiptTokenAddress = exports.getReceiptTokenAddressOrThrow = exports.getWhitelistedAssets = exports.getDecimalsOrThrow = exports.getDecimals = exports.explorerLink = exports.getInfuraProvider = exports.createProvider = exports.getChainId = exports.determineBlockSkipInternal = exports.determineSecondsPerBlock = exports.determineBlockCutoff = void 0;
4
4
  exports.createContract = createContract;
5
5
  exports.getTokenMetadata = getTokenMetadata;
6
6
  exports.simulateTransaction = simulateTransaction;
@@ -15,6 +15,7 @@ const logger_1 = require("../logger");
15
15
  const web3_1 = require("../constants/web3");
16
16
  const fetcher_1 = require("../fetcher");
17
17
  const chain_address_1 = require("./chain-address");
18
+ const chain_error_1 = require("./chain-error");
18
19
  const constants_1 = require("../../adapters/solana/constants");
19
20
  const abis_1 = require("../../abis");
20
21
  const TokenizedVaultV2_1 = require("../../abis/TokenizedVaultV2");
@@ -199,8 +200,9 @@ exports.getInfuraProvider = getInfuraProvider;
199
200
  var explorer_link_1 = require("./explorer-link");
200
201
  Object.defineProperty(exports, "explorerLink", { enumerable: true, get: function () { return explorer_link_1.explorerLink; } });
201
202
  /**
202
- * Stable per-provider identity for cache keys prevents cross-chain collisions
203
- * when the same address represents different tokens on different networks.
203
+ * Stable per-provider identity for cache keys, or `null` when the chain cannot
204
+ * be determined at all — the distinction callers need to decide whether a cache
205
+ * key is trustworthy.
204
206
  *
205
207
  * `provider._network` is a *getter* in ethers v6 that throws
206
208
  * `network is not available yet (NETWORK_ERROR)` until the provider has
@@ -210,8 +212,19 @@ Object.defineProperty(exports, "explorerLink", { enumerable: true, get: function
210
212
  * `getReceiptTokenAddress`, …) and surface as a confusing TVL failure on
211
213
  * fresh provider instances. We swallow the throw and fall back to the
212
214
  * connection URL — slightly less precise as a cache key but always safe.
215
+ *
216
+ * The runner is unwrapped to its provider first. An `IContractRunner` is either
217
+ * a `Provider` or a `Signer`, and a `Signer` carries the network on
218
+ * `signer.provider`, not on itself — without the unwrap every signer-backed
219
+ * read scoped to `'unknown'` and could never share a cache entry with the
220
+ * provider-backed read of the same token. The unwrap is a **no-op for
221
+ * providers**: ethers' `AbstractProvider` defines `get provider() { return
222
+ * this; }`, so read callers resolve to exactly the scope they did before.
223
+ *
224
+ * @returns `chain:<id>`, `url:<endpoint>`, or `null` when neither is available.
213
225
  */
214
- function providerScope(provider) {
226
+ function resolveProviderScope(runner) {
227
+ const provider = runner?.provider ?? runner;
215
228
  let chainId;
216
229
  try {
217
230
  const net = provider
@@ -228,16 +241,124 @@ function providerScope(provider) {
228
241
  const conn = provider._getConnection?.();
229
242
  if (conn?.url)
230
243
  return `url:${conn.url}`;
231
- return 'unknown';
244
+ return null;
232
245
  }
233
- /** @internal */
246
+ /**
247
+ * Scope string for cache keys, collapsing an unresolvable runner to the
248
+ * literal `'unknown'` bucket.
249
+ *
250
+ * This is the long-standing behaviour of every cached read in this module and
251
+ * is deliberately preserved: read callers keep keying exactly as they always
252
+ * have, `decimals-unknown-…` entries included. Only {@link getDecimalsOrThrow}
253
+ * treats an unresolvable scope specially — see the note there on why the write
254
+ * path holds itself to a higher bar than the read path.
255
+ */
256
+ function providerScope(runner) {
257
+ return resolveProviderScope(runner) ?? 'unknown';
258
+ }
259
+ /**
260
+ * Cache key for a token's `decimals()`, shared by **every** decimals reader in
261
+ * the SDK — the lenient {@link getDecimals} used by read paths and the strict
262
+ * {@link getDecimalsOrThrow} used by write paths. One namespace is the point:
263
+ * a page that reads a vault and then deposits into it must not pay the RPC
264
+ * twice (CLAUDE.md §4.1, "reuse results across functions").
265
+ *
266
+ * **What the entry holds is the *effective share-scale* decimals for `address`,
267
+ * not necessarily `address`'s own `decimals()`.** {@link fetchDecimals} with
268
+ * `isVault` redirects an `evm-2` vault to its receipt token, and the result is
269
+ * still stored under the vault's key — that redirect is the whole point of the
270
+ * flag, and every reader of a vault address wants the receipt token's scale.
271
+ * Consequence for future callers: `getDecimalsOrThrow(runner, evm2VaultAddr)`
272
+ * reads directly (`isVault: false`) but can be served the receipt token's value
273
+ * from this shared entry. Write paths therefore pass the receipt-token address
274
+ * explicitly for `evm-2` vaults; do not add a caller that expects an `evm-2`
275
+ * vault's own `decimals()` back out of this cache.
276
+ *
277
+ * @param scope - Result of {@link providerScope} / {@link resolveProviderScope}.
278
+ * @param address - Token address, or a vault address whose entry holds the
279
+ * share-scale token's decimals (see above).
280
+ */
281
+ function decimalsCacheKey(scope, address) {
282
+ return `decimals-${scope}-${address}`;
283
+ }
284
+ /**
285
+ * In-flight `decimals()` reads, keyed exactly as the cache. Collapses a
286
+ * stampede of concurrent callers for the same uncached token into one RPC.
287
+ *
288
+ * The stored promise **may reject** — strict callers need the original error.
289
+ * Lenient callers therefore must not return it raw; {@link getDecimals} awaits
290
+ * it inside a `try`.
291
+ * @internal
292
+ */
234
293
  const DECIMALS_REQUESTS = new Map();
294
+ /**
295
+ * Run (or join) the single in-flight `decimals()` read for `key`, caching the
296
+ * result on success. Failures are never cached, so a transient outage does not
297
+ * poison the entry.
298
+ *
299
+ * @param key - Cache key from {@link decimalsCacheKey}.
300
+ * @param fetch - Performs the actual read. Only invoked on a miss with nothing
301
+ * already in flight. Whichever caller registers the in-flight promise first
302
+ * supplies this closure — a concurrent {@link getDecimalsOrThrow} joining a
303
+ * {@link getDecimals}-initiated request inherits the lenient, no-retry read
304
+ * instead of its own `retryOnTransientRpc` wrap. Not a correctness bug (the
305
+ * strict caller still throws the original error), just a narrow window
306
+ * where the strict path's transient-retry guarantee is momentarily lost.
307
+ * @returns The decimals value.
308
+ * @throws Whatever `fetch` throws — callers that must not throw wrap this.
309
+ */
310
+ function sharedDecimalsRequest(key, fetch) {
311
+ const inflight = DECIMALS_REQUESTS.get(key);
312
+ if (inflight)
313
+ return inflight;
314
+ const request = (async () => {
315
+ const decimals = await fetch();
316
+ cache_1.CACHE.set(key, decimals);
317
+ return decimals;
318
+ })().finally(() => {
319
+ DECIMALS_REQUESTS.delete(key);
320
+ });
321
+ DECIMALS_REQUESTS.set(key, request);
322
+ return request;
323
+ }
324
+ /**
325
+ * Read `decimals()` off an ERC-20 at `address`, or — when `isVault` and the
326
+ * vault is `evm-2` — off its receipt token, which is where the share scale
327
+ * actually lives.
328
+ *
329
+ * @param runner - Provider or signer to read through.
330
+ * @param address - Token or vault address.
331
+ * @param isVault - Resolve `address` as a vault (may cost a backend metadata
332
+ * fetch plus a `lpTokenAddress()` read) rather than reading it directly.
333
+ * @returns The decimals value.
334
+ * @throws Whatever the underlying read throws.
335
+ */
336
+ async function fetchDecimals(runner, address, isVault) {
337
+ let realAddress = address;
338
+ if (isVault) {
339
+ const tokenizedVault = (await (0, fetcher_1.fetchTokenizedVault)(address))?.[0];
340
+ const version = (0, vault_version_1.getVaultVersionV2)(tokenizedVault);
341
+ if (version === 'evm-2') {
342
+ realAddress = await (0, exports.getReceiptTokenAddress)(runner, address);
343
+ }
344
+ }
345
+ const contract = new ethers_1.Contract(realAddress, [web3_1.MIN_ABIS.decimals], runner);
346
+ return Number(await contract.decimals());
347
+ }
235
348
  /**
236
349
  * Fetch token decimals from contract or Solana mint.
237
350
  * Results are cached to minimize RPC calls.
351
+ *
352
+ * **Never throws** — a failed read logs at error level and resolves
353
+ * `undefined`. Callers that must not silently proceed on an unknown scale (any
354
+ * path that encodes an amount) should use {@link getDecimalsOrThrow} instead:
355
+ * feeding `undefined` into `toNormalizedBn` silently defaults to 18 decimals.
356
+ *
238
357
  * @param provider Web3 provider
239
358
  * @param address Token contract address or Solana mint
240
- * @returns Number of decimals for the token
359
+ * @param isVault Resolve `address` as a vault (evm-2 vaults redirect to the
360
+ * receipt token) rather than reading it as a plain ERC-20. Defaults to `true`.
361
+ * @returns Number of decimals for the token, or `undefined` when the read failed.
241
362
  */
242
363
  const getDecimals = async (provider, address, isVault = true) => {
243
364
  if (address === ethers_1.ZeroAddress) {
@@ -248,45 +369,105 @@ const getDecimals = async (provider, address, isVault = true) => {
248
369
  return constants_1.fallbackDecimals;
249
370
  if (!(address && provider))
250
371
  return;
251
- const key = `decimals-${providerScope(provider)}-${address}`;
372
+ const key = decimalsCacheKey(providerScope(provider), address);
252
373
  if (cache_1.CACHE.has(key))
253
374
  return cache_1.CACHE.get(key);
254
- const inflight = DECIMALS_REQUESTS.get(key);
255
- if (inflight)
256
- return inflight;
257
- const fetchPromise = (async () => {
258
- try {
259
- if (isVault) {
260
- const tokenizedVault = (await (0, fetcher_1.fetchTokenizedVault)(address))?.[0];
261
- const version = (0, vault_version_1.getVaultVersionV2)(tokenizedVault);
262
- let realAddress = address;
263
- if (version === 'evm-2') {
264
- realAddress = await (0, exports.getReceiptTokenAddress)(provider, address);
265
- }
266
- const contract = new ethers_1.Contract(realAddress, [web3_1.MIN_ABIS.decimals], provider);
267
- const decimals = Number(await contract.decimals());
268
- cache_1.CACHE.set(key, decimals);
269
- return decimals;
270
- }
271
- else {
272
- const contract = new ethers_1.Contract(address, [web3_1.MIN_ABIS.decimals], provider);
273
- const decimals = Number(await contract.decimals());
274
- cache_1.CACHE.set(key, decimals);
275
- return decimals;
276
- }
277
- }
278
- catch (e) {
279
- logger_1.Logger.log.error('getDecimals', `${address}::${e}`);
280
- return undefined;
281
- }
282
- finally {
283
- DECIMALS_REQUESTS.delete(key);
284
- }
285
- })();
286
- DECIMALS_REQUESTS.set(key, fetchPromise);
287
- return fetchPromise;
375
+ try {
376
+ return await sharedDecimalsRequest(key, () => fetchDecimals(provider, address, isVault));
377
+ }
378
+ catch (e) {
379
+ logger_1.Logger.log.error('getDecimals', `${address}::${e}`);
380
+ return undefined;
381
+ }
288
382
  };
289
383
  exports.getDecimals = getDecimals;
384
+ /**
385
+ * `decimals()` — `keccak256("decimals()")[0..4]`. Scopes the empty-view-response
386
+ * retry to exactly the call this reader makes.
387
+ */
388
+ const DECIMALS_SELECTOR = '0x313ce567';
389
+ /**
390
+ * Read a token's `decimals()` with the **same cache and stampede protection as
391
+ * {@link getDecimals}**, but surfacing failures instead of swallowing them, and
392
+ * retrying the transient ones.
393
+ *
394
+ * Two reasons this exists rather than a flag on `getDecimals`:
395
+ *
396
+ * 1. **`getDecimals` must keep returning `undefined` on failure** — a dozen
397
+ * read paths depend on that. Amount-encoding paths need the opposite: an
398
+ * `undefined` reaching `toNormalizedBn` silently means 18 decimals, which
399
+ * misencodes the transaction. Here the original error propagates, so the
400
+ * caller's `AugustSDKError` keeps its cause and its Sentry grouping.
401
+ * 2. **Retry.** Providers intermittently return an empty response to
402
+ * `decimals()`, which ethers reports as
403
+ * `missing revert data (action="call", data="0x313ce567", …)` — a shape a
404
+ * deployed ERC-20 cannot legitimately produce, since `decimals()` takes no
405
+ * arguments. That is retried here via {@link isEmptyViewResponse} scoped to
406
+ * this exact selector, alongside ordinary transport faults
407
+ * ({@link isRetryableRpcError}). The empty-view widening applies **only**
408
+ * here; everywhere else the strict transport definition stands. A genuine
409
+ * revert (`CALL_EXCEPTION` carrying revert data) is never retried.
410
+ *
411
+ * Cache is shared with `getDecimals`, so a read-then-write flow against the
412
+ * same token costs one `decimals()` RPC in total, and N concurrent callers for
413
+ * an uncached token collapse to one.
414
+ *
415
+ * **Exception — an unresolvable chain scope bypasses the cache entirely.** When
416
+ * {@link resolveProviderScope} cannot determine the chain (a runner with no
417
+ * resolved network and no connection URL, e.g. a browser provider before its
418
+ * first request), the only available key is the shared `unknown` bucket. Read
419
+ * paths happily use that bucket, and this function deliberately does not: the
420
+ * two paths have different blast radii. A wrong cached `decimals` on a read
421
+ * renders a wrong number on screen; on a write it misencodes an amount the user
422
+ * then *signs*. The collision needed — the same address being a different token
423
+ * with different decimals on two chains, within one process, across a
424
+ * mid-session chain switch — is narrow, but "no worse than the read path" is
425
+ * not the bar for a value that ends up in a transaction. In that case the read
426
+ * is neither served from nor written to the cache, and it also skips the
427
+ * in-flight dedup map, since joining another caller under an untrusted key
428
+ * would reintroduce exactly the collision being avoided. Retries still apply;
429
+ * the only thing forgone is the RPC saving.
430
+ *
431
+ * RPC cost: 1 on a cache miss, 0 on a hit, up to 3 on a miss that keeps
432
+ * faulting (~750ms of added latency in the worst case before it gives up).
433
+ * Always ≥1 when the chain scope is unresolvable.
434
+ *
435
+ * @param runner - Provider or signer to read through. A signer is unwrapped to
436
+ * its provider for cache scoping, so it shares entries with reads on the
437
+ * same chain.
438
+ * @param address - Token address. Read directly as an ERC-20 — pass the
439
+ * receipt-token address yourself for an `evm-2` vault's share scale.
440
+ * @param tag - Low-cardinality label for retry breadcrumbs (e.g.
441
+ * `'vaultDeposit:poolDecimals'`).
442
+ * @returns The token's decimals.
443
+ * @throws The underlying read error once retries are exhausted, or immediately
444
+ * when the failure is neither a transport fault nor an empty view response.
445
+ *
446
+ * @example
447
+ * ```ts
448
+ * // Amount encoding must not proceed on a guessed scale.
449
+ * const decimals = await getDecimalsOrThrow(signer, token, 'deposit:decimals');
450
+ * const amount = toNormalizedBn(userInput, decimals);
451
+ * ```
452
+ */
453
+ const getDecimalsOrThrow = async (runner, address, tag) => {
454
+ const read = () => (0, chain_error_1.retryOnTransientRpc)(tag, () => fetchDecimals(runner, address, false), { address }, (error) => (0, chain_error_1.isRetryableRpcError)(error) ||
455
+ (0, chain_error_1.isEmptyViewResponse)(error, DECIMALS_SELECTOR));
456
+ const scope = resolveProviderScope(runner);
457
+ if (scope === null) {
458
+ // Untrusted key: no cache read, no cache write, no dedup. See the note
459
+ // above — a mis-scoped decimals value here gets signed into a transaction.
460
+ logger_1.Logger.log.warn(tag, 'decimals cache bypassed: chain scope unresolved', {
461
+ address,
462
+ });
463
+ return read();
464
+ }
465
+ const key = decimalsCacheKey(scope, address);
466
+ if (cache_1.CACHE.has(key))
467
+ return cache_1.CACHE.get(key);
468
+ return sharedDecimalsRequest(key, read);
469
+ };
470
+ exports.getDecimalsOrThrow = getDecimalsOrThrow;
290
471
  /** @internal */
291
472
  const WHITELISTED_ASSETS_REQUESTS = new Map();
292
473
  /**
@@ -320,14 +501,96 @@ const getWhitelistedAssets = async (provider, whitelistAddress) => {
320
501
  return fetchPromise;
321
502
  };
322
503
  exports.getWhitelistedAssets = getWhitelistedAssets;
504
+ /**
505
+ * Read an `evm-2` vault's receipt (LP) token address via `lpTokenAddress()`,
506
+ * retrying only the transient transport faults and surfacing everything else
507
+ * unchanged.
508
+ *
509
+ * Why this exists: `lpTokenAddress()` sits immediately before the receipt-token
510
+ * `decimals()` read on every `evm-2` write path (approve / deposit / request
511
+ * redeem / swap-router deposit). Those `decimals()` reads were hardened against
512
+ * provider blips ({@link getDecimalsOrThrow}); the `lpTokenAddress()` read one
513
+ * line above them was not, so a single truncated `eth_call` response still
514
+ * failed the whole write with
515
+ * `missing revert data (action="call", data="0xf5ae497a", …)` — observed in
516
+ * production against a mainnet vault whose `lpTokenAddress()` demonstrably
517
+ * returns a real address when the provider is healthy.
518
+ *
519
+ * **This must never hide a misrouted vault, and does not.** `lpTokenAddress()`
520
+ * exists *only* on `evm-2` vaults. A vault wrongly classified as `evm-2` returns
521
+ * empty returndata for it, deterministically and forever, and that empty
522
+ * response is byte-identical to the transient one — it is the *only* signal the
523
+ * SDK gets that its version routing was wrong. So the retry here is deliberately
524
+ * shaped to preserve that signal:
525
+ *
526
+ * - it is **bounded** (3 attempts, ~750ms of backoff in total — see
527
+ * `retryOnTransientRpc`), never open-ended;
528
+ * - on exhaustion it **rethrows the original error object**, with its identity,
529
+ * message, `code` and `transaction.data` intact, so the caller's
530
+ * `AugustSDKError` cause and Sentry grouping are exactly what they are today;
531
+ * - it has **no fallback**: it never substitutes another address, never resolves
532
+ * the vault address itself, and never resolves `null`/`undefined`. It either
533
+ * returns a real receipt-token address or throws.
534
+ *
535
+ * Net effect: a blip costs a retry, a misroute costs three `eth_call`s and then
536
+ * fails exactly as loudly as before.
537
+ *
538
+ * **Deliberately not cached.** Unlike `decimals()`, the vault→receipt-token
539
+ * mapping is not memoized here. Caching it would make a misroute's first
540
+ * (failed) probe and every subsequent one diverge, and would put a
541
+ * vault-identity mapping in the cache on the money path. The lenient, cached
542
+ * reader is {@link getReceiptTokenAddress} — use that on read paths that can
543
+ * tolerate `undefined`.
544
+ *
545
+ * RPC cost: exactly 1 `eth_call` on success; at most 3 when the provider keeps
546
+ * faulting.
547
+ *
548
+ * @param runner - Provider or signer to read through.
549
+ * @param vault - Address of the `evm-2` tokenized vault.
550
+ * @param tag - Low-cardinality label for retry breadcrumbs (e.g.
551
+ * `'vaultRequestRedeem:receiptToken'`).
552
+ * @returns The vault's receipt (LP) token address. Never `null`/`undefined`.
553
+ * @throws The underlying read error, unmodified, once the bounded retries are
554
+ * exhausted — or immediately when the failure is neither a transport fault nor
555
+ * an empty response to this exact selector (e.g. a genuine revert carrying
556
+ * revert data).
557
+ *
558
+ * @example
559
+ * ```ts
560
+ * const receiptToken = await getReceiptTokenAddressOrThrow(
561
+ * signer,
562
+ * vault,
563
+ * 'vaultRequestRedeem:receiptToken',
564
+ * );
565
+ * const decimals = await getDecimalsOrThrow(signer, receiptToken, 'tag');
566
+ * ```
567
+ */
568
+ const getReceiptTokenAddressOrThrow = async (runner, vault, tag) => {
569
+ const contract = new ethers_1.Contract(vault, TokenizedVaultV2_1.ABI_TOKENIZED_VAULT_V2, runner);
570
+ return (0, chain_error_1.retryOnTransientRpc)(tag, () => contract.lpTokenAddress(), { vault }, (error) => (0, chain_error_1.isRetryableRpcError)(error) ||
571
+ (0, chain_error_1.isEmptyViewResponse)(error, chain_error_1.LP_TOKEN_ADDRESS_SELECTOR));
572
+ };
573
+ exports.getReceiptTokenAddressOrThrow = getReceiptTokenAddressOrThrow;
323
574
  /** @internal */
324
575
  const RECEIPT_TOKEN_REQUESTS = new Map();
325
576
  /**
326
577
  * Fetch receipt token address from tokenized vault contract.
327
578
  * Results are cached to minimize RPC calls.
579
+ *
580
+ * **Never throws** — a failed read logs at error level and resolves
581
+ * `undefined`, which several read paths depend on. The underlying
582
+ * `lpTokenAddress()` call is made through
583
+ * {@link getReceiptTokenAddressOrThrow}, so a transient provider blip is
584
+ * absorbed by a bounded retry before that happens; a deterministic failure (a
585
+ * vault that has no `lpTokenAddress()` because it is not `evm-2`) still resolves
586
+ * `undefined` after the attempts are spent, exactly as before. Callers on a
587
+ * money path must use {@link getReceiptTokenAddressOrThrow} directly instead —
588
+ * an `undefined` receipt-token address there would silently mis-address a
589
+ * `decimals()` read.
590
+ *
328
591
  * @param provider Web3 provider
329
592
  * @param address Tokenized vault contract address
330
- * @returns Receipt token address
593
+ * @returns Receipt token address, or `undefined` when the read failed.
331
594
  */
332
595
  const getReceiptTokenAddress = async (provider, address) => {
333
596
  if (!(address && provider))
@@ -340,8 +603,7 @@ const getReceiptTokenAddress = async (provider, address) => {
340
603
  return inflight;
341
604
  const fetchPromise = (async () => {
342
605
  try {
343
- const vaultContract = new ethers_1.Contract(address, TokenizedVaultV2_1.ABI_TOKENIZED_VAULT_V2, provider);
344
- const receiptToken = (await vaultContract.lpTokenAddress());
606
+ const receiptToken = await (0, exports.getReceiptTokenAddressOrThrow)(provider, address, 'getReceiptTokenAddress');
345
607
  cache_1.CACHE.set(key, receiptToken);
346
608
  return receiptToken;
347
609
  }
@@ -1,5 +1,6 @@
1
1
  export * from './fetcher';
2
2
  export * from './base.class';
3
+ export * from './attribution';
3
4
  export * from './auth';
4
5
  export * from './logger';
5
6
  export * from './analytics';
package/lib/core/index.js CHANGED
@@ -16,6 +16,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./fetcher"), exports);
18
18
  __exportStar(require("./base.class"), exports);
19
+ __exportStar(require("./attribution"), exports);
19
20
  __exportStar(require("./auth"), exports);
20
21
  __exportStar(require("./logger"), exports);
21
22
  __exportStar(require("./analytics"), exports);
@@ -58,6 +58,7 @@ exports.isCrossChainOperation = isCrossChainOperation;
58
58
  exports.getAvailableChains = getAvailableChains;
59
59
  exports.formatCrossChainError = formatCrossChainError;
60
60
  exports.getLayerZeroScanUrl = getLayerZeroScanUrl;
61
+ const attribution_1 = require("../../core/attribution");
61
62
  const core_1 = require("../../core");
62
63
  const crossChain_1 = require("../types/crossChain");
63
64
  const OFT_1 = require("../../abis/OFT");
@@ -537,6 +538,7 @@ async function approveCrossChain(tokenAddress, spenderAddress, amount, walletCli
537
538
  abi: OFT_1.ABI_CROSS_CHAIN_ERC20,
538
539
  functionName: 'approve',
539
540
  args: [spenderAddress, amount],
541
+ dataSuffix: (0, attribution_1.getAttributionSuffix)(),
540
542
  });
541
543
  const receipt = await publicClient.waitForTransactionReceipt({
542
544
  hash: hash,
@@ -625,6 +627,7 @@ async function crossChainVaultDeposit(props) {
625
627
  args: txInputs.txArgs,
626
628
  value: txInputs.messageValue,
627
629
  gas: gasLimit,
630
+ dataSuffix: (0, attribution_1.getAttributionSuffix)(props.userChainId),
628
631
  });
629
632
  // 5. Wait for confirmation
630
633
  const receipt = await publicClient.waitForTransactionReceipt({
@@ -709,6 +712,7 @@ async function crossChainVaultRedeem(props) {
709
712
  args: txInputs.txArgs,
710
713
  value: txInputs.messageValue,
711
714
  gas: gasLimit,
715
+ dataSuffix: (0, attribution_1.getAttributionSuffix)(props.config.hubChainId),
712
716
  });
713
717
  // 6. Wait for confirmation
714
718
  const receipt = await publicClient.waitForTransactionReceipt({