@gearbox-protocol/sdk 17.2.0-next.1 → 17.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/cjs/onchain/accounts/intents/maxBorrow.js +10 -12
  2. package/dist/cjs/onchain/accounts/intents/testing/sdk-mock.js +0 -1
  3. package/dist/cjs/onchain/chain/chains.js +1 -0
  4. package/dist/cjs/onchain/market/credit/CreditSuite.js +3 -43
  5. package/dist/cjs/onchain/market/pool/PoolQuotaKeeperV310Contract.js +1 -4
  6. package/dist/cjs/rewards/RewardsService.js +48 -0
  7. package/dist/cjs/rewards/errors.js +15 -4
  8. package/dist/cjs/rewards/helpers.js +88 -0
  9. package/dist/cjs/rewards/index.js +3 -4
  10. package/dist/cjs/rewards/merkl-api.js +14 -25
  11. package/dist/cjs/rewards/toMerklRewards.js +21 -76
  12. package/dist/cjs/rewards/toTurtleRewards.js +61 -0
  13. package/dist/cjs/rewards/turtle-api.js +67 -0
  14. package/dist/cjs/rewards/types.js +1 -0
  15. package/dist/esm/onchain/accounts/intents/maxBorrow.js +10 -12
  16. package/dist/esm/onchain/accounts/intents/testing/sdk-mock.js +0 -1
  17. package/dist/esm/onchain/chain/chains.js +1 -0
  18. package/dist/esm/onchain/market/credit/CreditSuite.js +3 -43
  19. package/dist/esm/onchain/market/pool/PoolQuotaKeeperV310Contract.js +1 -4
  20. package/dist/esm/rewards/RewardsService.js +47 -0
  21. package/dist/esm/rewards/errors.js +15 -5
  22. package/dist/esm/rewards/helpers.js +83 -0
  23. package/dist/esm/rewards/index.js +3 -4
  24. package/dist/esm/rewards/merkl-api.js +15 -25
  25. package/dist/esm/rewards/toMerklRewards.js +22 -77
  26. package/dist/esm/rewards/toTurtleRewards.js +60 -0
  27. package/dist/esm/rewards/turtle-api.js +64 -0
  28. package/dist/esm/rewards/types.js +1 -0
  29. package/dist/types/model/errors/operation-errors.d.ts +1 -6
  30. package/dist/types/onchain/accounts/intents/maxBorrow.d.ts +9 -11
  31. package/dist/types/onchain/market/credit/CreditSuite.d.ts +1 -20
  32. package/dist/types/onchain/market/pool/PoolQuotaKeeperV310Contract.d.ts +0 -2
  33. package/dist/types/rewards/RewardsService.d.ts +29 -0
  34. package/dist/types/rewards/errors.d.ts +8 -3
  35. package/dist/types/rewards/helpers.d.ts +44 -0
  36. package/dist/types/rewards/index.d.ts +6 -4
  37. package/dist/types/rewards/merkl-api.d.ts +5 -10
  38. package/dist/types/rewards/toMerklRewards.d.ts +6 -37
  39. package/dist/types/rewards/toTurtleRewards.d.ts +10 -0
  40. package/dist/types/rewards/turtle-api.d.ts +66 -0
  41. package/dist/types/rewards/types.d.ts +41 -0
  42. package/package.json +1 -1
  43. package/dist/cjs/rewards/multichain.js +0 -40
  44. package/dist/esm/rewards/multichain.js +0 -39
  45. package/dist/types/rewards/multichain.d.ts +0 -33
@@ -20,18 +20,16 @@ import { collateralValuation } from "./collateral-valuation.js";
20
20
  * prices, under its liquidation threshold, capped by the quota the borrow
21
21
  * buys for it, all of which is {@link collateralValuation}'s business. The ceiling
22
22
  * is then held to what the market will actually lend: the pool's free
23
- * liquidity, the manager's own allowance, the facade's `maxDebt` and the
24
- * remaining quota of the strategy target collateral, whichever binds first.
23
+ * liquidity, the manager's own allowance and the facade's `maxDebt`, whichever
24
+ * binds first.
25
25
  *
26
- * The facade's `minDebt` is not applied to the collateral's own ceiling. It
27
- * is a floor, and a ceiling answered as `0n` because the collateral is too
28
- * small for this market would tell a form nothing about what it is holding —
29
- * the number a user needs to see is the one they are short of. Collateral
30
- * that carries something therefore answers with it, whether or not the market
31
- * would lend that little; a loan under the floor is refused by `borrow`
32
- * itself, with `debtOutOfRange` naming both ends. A market whose own capacity
33
- * is under `minDebt` is different: `maxStrategyBorrowAmount` answers `0n`,
34
- * because no loan of any size exists there.
26
+ * The facade's `minDebt` is deliberately not applied. It is a floor, and a
27
+ * ceiling answered as `0n` because the collateral is too small for this market
28
+ * would tell a form nothing about what it is holding — the number a user needs
29
+ * to see is the one they are short of. Collateral that carries something
30
+ * therefore answers with it, whether or not the market would lend that little;
31
+ * a loan under the floor is refused by `borrow` itself, with `debtOutOfRange`
32
+ * naming both ends.
35
33
  *
36
34
  * Nothing is fetched or simulated — the account does not exist yet and every
37
35
  * input is loaded market state, so a form can call this on each keystroke.
@@ -81,7 +79,7 @@ function maxBorrow(props) {
81
79
  const weighted = valuation.checkedUsd(holding) * valuation.lt(collateralToken);
82
80
  const backed = quotas.some((q) => eq(q.token, collateralToken)) ? BigIntMath.min(valuation.quotaValue(holding), weighted) : weighted;
83
81
  if (backed <= 0n) return 0n;
84
- const ceiling = BigIntMath.min(priceOracle.safeConvertFromUSD(underlying, backed / targetHF).value, suite.maxStrategyBorrowAmount().amount.value);
82
+ const ceiling = BigIntMath.min(priceOracle.safeConvertFromUSD(underlying, backed / targetHF).value, suite.maxBorrowAmount().amount.value);
85
83
  const unwrapsPayout = !!rwaAsset && eq(borrowToken, rwaAsset);
86
84
  return eq(borrowToken, underlying) ? ceiling : unwrapsPayout ? toTargetDecimals(ceiling, underlying, borrowToken, sdk) : priceOracle.safeConvert(underlying, borrowToken, ceiling).value;
87
85
  }
@@ -198,7 +198,6 @@ function buildMockSdk(args) {
198
198
  creditOperationMarket: CreditSuite.prototype.creditOperationMarket,
199
199
  isForbidden: CreditSuite.prototype.isForbidden,
200
200
  maxBorrowAmount: CreditSuite.prototype.maxBorrowAmount,
201
- maxStrategyBorrowAmount: CreditSuite.prototype.maxStrategyBorrowAmount,
202
201
  creditManager: {
203
202
  address: args.creditManager,
204
203
  liquidationThresholds,
@@ -68,6 +68,7 @@ const chains = {
68
68
  "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "ETH",
69
69
  "0x18084fbA666a33d37592fA2633fD49a74DD93a88": "BTC",
70
70
  "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": "Stable",
71
+ "0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29": "Stable",
71
72
  "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599": "BTC"
72
73
  }),
73
74
  rwaTokens: new AddressSet([
@@ -18,7 +18,7 @@ import { isAddressEqual } from "viem";
18
18
  /**
19
19
  * Amount of underlying seeded into each pool at market creation to protect
20
20
  * from inflation attacks, in raw token units. A suite whose
21
- * {@link CreditSuite.maxStrategyBorrowAmount} is at or below this is treated as
21
+ * {@link CreditSuite.maxBorrowAmount} is at or below this is treated as
22
22
  * having nothing left to lend.
23
23
  **/
24
24
  const MIN_STRATEGY_BORROW_AMOUNT = 100000n;
@@ -281,7 +281,7 @@ var CreditSuite = class extends SDKConstruct {
281
281
  return this.forbiddenTokens.some((f) => isAddressEqual(f, token));
282
282
  }
283
283
  /**
284
- * Largest debt this credit manager will hand out on one operation right now,
284
+ * Largest debt one new position can take from this credit manager right now,
285
285
  * and which limit set that number.
286
286
  *
287
287
  * Minimum of:
@@ -290,10 +290,6 @@ var CreditSuite = class extends SDKConstruct {
290
290
  * - the facade's per-account `maxDebt`.
291
291
  * While `maxDebtPerBlockMultiplier` is `0` the facade
292
292
  * takes no new debt at all, so the answer is `0`.
293
- *
294
- * These are the bounds every debt increase answers to, an existing account's
295
- * included, which is what the guards hold a simulation to. Opening a position
296
- * answers to two more — see {@link maxStrategyBorrowAmount}.
297
293
  */
298
294
  maxBorrowAmount() {
299
295
  const { pool } = this.market.pool;
@@ -323,42 +319,6 @@ var CreditSuite = class extends SDKConstruct {
323
319
  };
324
320
  }
325
321
  /**
326
- * Largest debt one new position can take from this credit manager right now,
327
- * and which limit set that number.
328
- *
329
- * {@link maxBorrowAmount} held to the two bounds only a position being opened
330
- * answers to: the remaining quota of the strategy target collateral, which
331
- * the position has to buy to be worth anything, and the facade's `minDebt`,
332
- * which a first debt cannot sit under. `amount` is `0` whenever no position
333
- * can be opened right now, and `limit` names why.
334
- *
335
- * An operation on an account that already exists is held to neither: its
336
- * quota is weighed against the token its own plan buys, and its debt is
337
- * already over the floor, so a top-up smaller than `minDebt` is legal.
338
- */
339
- maxStrategyBorrowAmount() {
340
- const lends = this.maxBorrowAmount();
341
- if (lends.limit === "debtPerBlockLimit") return lends;
342
- const collateral = this.strategyTargetCollateral;
343
- let value = lends.amount.value;
344
- let limit = lends.limit;
345
- if (collateral !== void 0) {
346
- const quota = this.market.pool.pqk.quotaAvailable(collateral);
347
- if (quota < value) {
348
- value = quota;
349
- limit = "quotaAvailable";
350
- }
351
- }
352
- if (value < this.creditFacade.minDebt) return {
353
- amount: this.market.toUnderlyingAmount(0n),
354
- limit: "minDebt"
355
- };
356
- return {
357
- amount: this.market.toUnderlyingAmount(value),
358
- limit
359
- };
360
- }
361
- /**
362
322
  * The single target collateral of this suite's strategy, or `undefined` when
363
323
  * none can be resolved.
364
324
  *
@@ -415,7 +375,7 @@ var CreditSuite = class extends SDKConstruct {
415
375
  * or `undefined` when credit suite does not offer a strategy opportunity.
416
376
  */
417
377
  strategyOpportunity() {
418
- const maxBorrowAmount = this.maxStrategyBorrowAmount().amount.value;
378
+ const maxBorrowAmount = this.maxBorrowAmount().amount.value;
419
379
  if (maxBorrowAmount <= MIN_STRATEGY_BORROW_AMOUNT) return;
420
380
  const collateral = this.strategyTargetCollateral;
421
381
  if (!collateral) return;
@@ -1,6 +1,5 @@
1
1
  import { iPoolQuotaKeeperV310Abi } from "../../../abi/310/generated.js";
2
2
  import { AddressMap } from "../../utils/AddressMap.js";
3
- import { BigIntMath } from "../../utils/bigint-math.js";
4
3
  import { formatBNvalue, percentFmt } from "../../utils/formatter.js";
5
4
  import "../../utils/index.js";
6
5
  import { BaseContract } from "../../base/BaseContract.js";
@@ -41,12 +40,10 @@ var PoolQuotaKeeperV310Contract = class extends BaseContract {
41
40
  /**
42
41
  * How much more quota the market will take for a token, in the underlying.
43
42
  * `0n` when the market has no quota entry; not the same as {@link hasActiveQuota}.
44
- * Never negative: a limit lowered under what is already quoted leaves no
45
- * room, not a debt.
46
43
  */
47
44
  quotaAvailable(token) {
48
45
  const quota = this.quotas.get(token);
49
- return quota ? BigIntMath.max(0n, quota.limit - quota.totalQuoted) : 0n;
46
+ return quota ? quota.limit - quota.totalQuoted : 0n;
50
47
  }
51
48
  /**
52
49
  * Annual quota rate paid on a quoted token, in basis points, or `0` when the
@@ -0,0 +1,47 @@
1
+ import { MultichainConstruct } from "../onchain/base/MultichainConstruct.js";
2
+ import "../onchain/index.js";
3
+ import { fetchMerklUserRewards } from "./merkl-api.js";
4
+ import { toMerklRewards } from "./toMerklRewards.js";
5
+ import { toTurtleRewards } from "./toTurtleRewards.js";
6
+ import { fetchTurtleWalletRewards, readTurtleClaimed } from "./turtle-api.js";
7
+ import { getAddress } from "viem";
8
+ //#region src/rewards/RewardsService.ts
9
+ var RewardsService = class extends MultichainConstruct {
10
+ #keys;
11
+ constructor(sdk, keys = {}) {
12
+ super(sdk);
13
+ this.#keys = keys;
14
+ }
15
+ /**
16
+ * Every claimable reward a wallet holds — Merkl campaigns and the Gearbox
17
+ * organisation's Turtle streams — across the chains the handle carries.
18
+ * A chain is `status: "error"` only when every source failed on it; a chain
19
+ * with nothing to claim is a `"success"` with no rewards.
20
+ **/
21
+ async list(wallet, chainIds) {
22
+ const { merklApiKey, turtleApiKey } = this.#keys;
23
+ const user = getAddress(wallet);
24
+ const turtle = turtleApiKey ? fetchTurtleWalletRewards(user, turtleApiKey) : void 0;
25
+ turtle?.catch(() => {});
26
+ return this.queryChains({
27
+ chainIds,
28
+ label: "list rewards",
29
+ block: "state",
30
+ run: async (sdk) => {
31
+ const sources = [fetchMerklUserRewards({
32
+ chainId: sdk.chainId,
33
+ user,
34
+ apiKey: merklApiKey
35
+ }).then((response) => toMerklRewards(sdk, response))];
36
+ if (turtle) sources.push(turtle.then(async (rewards) => toTurtleRewards(sdk, rewards, await readTurtleClaimed(sdk, user, rewards))));
37
+ const settled = await Promise.allSettled(sources);
38
+ const failed = settled.flatMap((r) => r.status === "rejected" ? [r.reason] : []);
39
+ if (failed.length === settled.length) throw failed.length === 1 ? failed[0] : new AggregateError(failed, "no rewards source answered");
40
+ for (const reason of failed) (sdk.logger ?? this.sdk.logger)?.warn(reason, `rewards source failed on chain ${sdk.chainId}`);
41
+ return settled.flatMap((r) => r.status === "fulfilled" ? r.value : []);
42
+ }
43
+ });
44
+ }
45
+ };
46
+ //#endregion
47
+ export { RewardsService };
@@ -1,7 +1,7 @@
1
1
  import { BaseError } from "viem";
2
2
  //#region src/rewards/errors.ts
3
3
  /**
4
- * Thrown when none of Merkl's domains answered for a chain.
4
+ * Thrown when Merkl did not answer for a chain.
5
5
  *
6
6
  * Everything a reader needs is in the message: this error travels to consumers
7
7
  * inside a chain's {@link ChainFailed} metadata, where it is typed `unknown`
@@ -10,10 +10,10 @@ import { BaseError } from "viem";
10
10
  var MerklRequestFailedError = class extends BaseError {
11
11
  name = "MerklRequestFailedError";
12
12
  chainId;
13
- constructor(chainId, path, attempts) {
13
+ constructor(chainId, path, cause) {
14
14
  super(`Merkl could not be reached for chain ${chainId}.`, {
15
- cause: attempts.find(([, c]) => c instanceof Error)?.[1],
16
- metaMessages: attempts.map(([domain, cause]) => `${domain}${path} — ${describe(cause)}`)
15
+ cause: cause instanceof Error ? cause : void 0,
16
+ metaMessages: [`${path} — ${describe(cause)}`]
17
17
  });
18
18
  this.chainId = chainId;
19
19
  }
@@ -22,5 +22,15 @@ function describe(cause) {
22
22
  if (cause instanceof Error) return cause.name === "TimeoutError" ? "timed out" : cause.message;
23
23
  return String(cause);
24
24
  }
25
+ /** Turtle answers for every chain at once, so this names no chain. */
26
+ var TurtleRequestFailedError = class extends BaseError {
27
+ name = "TurtleRequestFailedError";
28
+ constructor(path, cause) {
29
+ super("Turtle could not be reached.", {
30
+ cause: cause instanceof Error ? cause : void 0,
31
+ metaMessages: [`${path} — ${describe(cause)}`]
32
+ });
33
+ }
34
+ };
25
35
  //#endregion
26
- export { MerklRequestFailedError };
36
+ export { MerklRequestFailedError, TurtleRequestFailedError };
@@ -0,0 +1,83 @@
1
+ import { AddressMap } from "../onchain/utils/AddressMap.js";
2
+ import { toBigInt } from "../onchain/utils/formatter.js";
3
+ import "../onchain/index.js";
4
+ import { formatUnits, isAddress } from "viem";
5
+ //#region src/rewards/helpers.ts
6
+ /**
7
+ * The pool a source names, and its share token. A v3.1 pool is its own
8
+ * ERC-4626 share token, so its address is the only token a campaign can name;
9
+ * the `AddressMap` owns the casing a source writes it in.
10
+ */
11
+ function resolvePool(sdk) {
12
+ const pools = AddressMap.fromMappedArray(sdk.marketRegister.pools.map(({ pool }) => pool.address), (address) => address);
13
+ return (address) => {
14
+ if (!address || !isAddress(address, { strict: false })) return void 0;
15
+ const pool = pools.get(address);
16
+ const poolToken = pool && sdk.tokensMeta.getToken(pool);
17
+ return pool && poolToken ? {
18
+ pool,
19
+ poolToken
20
+ } : void 0;
21
+ };
22
+ }
23
+ /** Sources send amounts as free-form strings, which `toBigInt` throws on. */
24
+ function parseAmount(value) {
25
+ if (value === void 0) return void 0;
26
+ try {
27
+ return toBigInt(value || 0);
28
+ } catch {
29
+ return;
30
+ }
31
+ }
32
+ /** What is left of `total` after `claimed`, when anything is. */
33
+ function remaining(total, claimed) {
34
+ if (total === void 0 || claimed === void 0) return void 0;
35
+ return total > claimed ? total - claimed : void 0;
36
+ }
37
+ /**
38
+ * An incentive token is rarely protocol collateral, so the registry usually
39
+ * has no entry for it — and both sources always name it.
40
+ */
41
+ function toRewardToken(sdk, address, named) {
42
+ return sdk.tokensMeta.getToken(address) ?? {
43
+ chainId: sdk.chainId,
44
+ address,
45
+ symbol: named.symbol,
46
+ name: named.name ?? named.symbol,
47
+ decimals: named.decimals || 18
48
+ };
49
+ }
50
+ /** A missing or unparsable price is `null`. */
51
+ function toPricedAmount(token, value, price) {
52
+ const usd = Number(price ?? NaN);
53
+ return {
54
+ token,
55
+ value,
56
+ valueUsd: Number.isFinite(usd) ? Number(formatUnits(value, token.decimals)) * usd : null
57
+ };
58
+ }
59
+ /**
60
+ * One reward per pool and incentive token, at the last part's price. Amounts
61
+ * are summed raw and priced once at the end — pricing each part and adding the results would round
62
+ * every one of them.
63
+ */
64
+ function mergeRewards(sdk, source, parts) {
65
+ const merged = /* @__PURE__ */ new Map();
66
+ for (const part of parts) {
67
+ const key = `${part.pool}_${part.token.address}`;
68
+ const seen = merged.get(key);
69
+ merged.set(key, seen ? {
70
+ ...part,
71
+ value: seen.value + part.value
72
+ } : part);
73
+ }
74
+ return [...merged.values()].map(({ pool, poolToken, token, value, price }) => ({
75
+ source,
76
+ chainId: sdk.chainId,
77
+ pool,
78
+ poolToken,
79
+ amount: toPricedAmount(token, value, price)
80
+ }));
81
+ }
82
+ //#endregion
83
+ export { mergeRewards, parseAmount, remaining, resolvePool, toRewardToken };
@@ -1,4 +1,3 @@
1
- import { MerklRequestFailedError } from "./errors.js";
2
- import { toMerklRewards } from "./toMerklRewards.js";
3
- import { getMerklRewardsMultichain } from "./multichain.js";
4
- export { MerklRequestFailedError, getMerklRewardsMultichain, toMerklRewards };
1
+ import { MerklRequestFailedError, TurtleRequestFailedError } from "./errors.js";
2
+ import { RewardsService } from "./RewardsService.js";
3
+ export { MerklRequestFailedError, RewardsService, TurtleRequestFailedError };
@@ -1,42 +1,32 @@
1
1
  import { MerklRequestFailedError } from "./errors.js";
2
2
  //#region src/rewards/merkl-api.ts
3
- /**
4
- * Merkl's own host and the Angle mirror, tried in this order.
5
- */
6
- const MERKL_DOMAINS = ["https://api.merkl.xyz", "https://api-merkl.angle.money"];
3
+ const MERKL_API_URL = "https://api.merkl.xyz";
7
4
  const MERKL_API_KEY_HEADER = "X-API-Key";
8
5
  /**
9
- * Per-attempt budget. Merkl has no timeout of its own, and a hung connection
10
- * would otherwise stall its leg of a fan-out for as long as the socket lives.
6
+ * Merkl has no timeout of its own, and a hung connection would otherwise stall
7
+ * its leg of a fan-out for as long as the socket lives.
11
8
  */
12
- const ATTEMPT_TIMEOUT = 1e4;
9
+ const TIMEOUT = 1e4;
13
10
  /**
14
11
  * The wallet's raw Merkl rewards on one chain.
15
12
  *
16
- * Rejects with {@link MerklRequestFailedError} when neither domain answers, so
17
- * a caller can tell an unreachable Merkl from a wallet with nothing to claim.
18
- * A non-2xx counts as no answer and moves to the next domain: it carries no
19
- * rewards either way, and treating it as success would report emptiness that
20
- * was never established.
13
+ * Rejects with {@link MerklRequestFailedError} when Merkl does not answer, so a
14
+ * caller can tell an unreachable Merkl from a wallet with nothing to claim. A
15
+ * non-2xx counts as no answer: it carries no rewards either way, and treating
16
+ * it as success would report emptiness that was never established.
21
17
  */
22
18
  async function fetchMerklUserRewards({ chainId, user, apiKey }) {
23
19
  const path = `/v4/users/${user}/rewards?chainId=${chainId}`;
24
- const headers = apiKey ? { [MERKL_API_KEY_HEADER]: apiKey } : void 0;
25
- const attempts = [];
26
- for (const domain of MERKL_DOMAINS) try {
27
- const response = await fetch(`${domain}${path}`, {
28
- headers,
29
- signal: AbortSignal.timeout(ATTEMPT_TIMEOUT)
20
+ try {
21
+ const response = await fetch(`${MERKL_API_URL}${path}`, {
22
+ headers: apiKey ? { [MERKL_API_KEY_HEADER]: apiKey } : void 0,
23
+ signal: AbortSignal.timeout(TIMEOUT)
30
24
  });
31
- if (!response.ok) {
32
- attempts.push([domain, /* @__PURE__ */ new Error(`answered ${response.status}`)]);
33
- continue;
34
- }
25
+ if (!response.ok) throw new Error(`answered ${response.status}`);
35
26
  return await response.json();
36
27
  } catch (error) {
37
- attempts.push([domain, error]);
28
+ throw new MerklRequestFailedError(chainId, path, error);
38
29
  }
39
- throw new MerklRequestFailedError(chainId, path, attempts);
40
30
  }
41
31
  //#endregion
42
- export { MERKL_API_KEY_HEADER, MERKL_DOMAINS, fetchMerklUserRewards };
32
+ export { MERKL_API_KEY_HEADER, fetchMerklUserRewards };
@@ -1,84 +1,29 @@
1
- import { AddressMap } from "../onchain/utils/AddressMap.js";
2
- import { BigIntMath } from "../onchain/utils/bigint-math.js";
3
- import { toBigInt } from "../onchain/utils/formatter.js";
4
- import "../onchain/index.js";
5
- import { formatUnits, getAddress, isAddress } from "viem";
1
+ import { mergeRewards, parseAmount, remaining, resolvePool, toRewardToken } from "./helpers.js";
2
+ import { getAddress, isAddress } from "viem";
6
3
  //#region src/rewards/toMerklRewards.ts
7
4
  /**
8
- * Merkl's answer for one chain, turned into rows of the read model.
5
+ * Merkl's answer for one chain, one reward per pool and incentive token.
6
+ * A breakdown Merkl cannot be read from is skipped rather than thrown on: it
7
+ * must not sink the chain, which the caller would then be told is unreachable.
9
8
  */
10
9
  function toMerklRewards(sdk, response) {
11
- const poolByItsToken = AddressMap.fromMappedArray(sdk.marketRegister.pools.map(({ pool }) => pool.address), (address) => address);
12
- const claimable = /* @__PURE__ */ new Map();
13
- for (const chainRewards of response) for (const reward of chainRewards.rewards) {
14
- if (!isAddress(reward.token.address, { strict: false })) continue;
15
- const rewardTokenAddress = getAddress(reward.token.address);
16
- for (const reason of reward.breakdowns) {
17
- const poolTokenAddress = (reason.reason || "").split("_").find((part) => part.startsWith("0x")) ?? "";
18
- if (!isAddress(poolTokenAddress, { strict: false })) continue;
19
- const pool = poolByItsToken.get(poolTokenAddress);
20
- if (!pool) continue;
21
- const amounts = toAmounts(reason);
22
- if (!amounts) continue;
23
- const amount = BigIntMath.max(amounts.total - amounts.claimed, 0n);
24
- if (amount === 0n) continue;
25
- const key = `${pool}_${rewardTokenAddress}`;
26
- const seen = claimable.get(key);
27
- if (seen) {
28
- seen.value += amount;
29
- continue;
30
- }
31
- const poolToken = sdk.tokensMeta.getToken(pool);
32
- if (!poolToken) continue;
33
- claimable.set(key, {
34
- chainId: sdk.chainId,
35
- pool,
36
- poolToken,
37
- token: toRewardToken(sdk, rewardTokenAddress, reward.token),
38
- value: amount,
39
- price: reward.token.price
40
- });
41
- }
42
- }
43
- return [...claimable.values()].map(toReward);
44
- }
45
- /**
46
- * `toBigInt` throws on anything `BigInt()` cannot parse, and Merkl's amounts
47
- * are free-form strings.
48
- */
49
- function toAmounts(reason) {
50
- try {
51
- return {
52
- total: toBigInt(reason.amount || 0),
53
- claimed: toBigInt(reason.claimed || 0)
54
- };
55
- } catch {
56
- return;
57
- }
58
- }
59
- function toReward({ price, token, value, ...rest }) {
60
- return {
61
- ...rest,
62
- amount: {
63
- token,
64
- value,
65
- valueUsd: price === void 0 ? null : Number(formatUnits(value, token.decimals)) * price
66
- }
67
- };
68
- }
69
- /**
70
- * A campaign's incentive token is not protocol collateral, so the registry
71
- * usually has no entry for it — and Merkl always names it. The one place the
72
- * two sources are reconciled.
73
- */
74
- function toRewardToken(sdk, address, merkl) {
75
- return sdk.tokensMeta.getToken(address) ?? {
76
- chainId: sdk.chainId,
77
- address,
78
- symbol: merkl.symbol,
79
- name: merkl.symbol,
80
- decimals: merkl.decimals || 18
81
- };
10
+ const poolOf = resolvePool(sdk);
11
+ const parts = response.flatMap(({ rewards }) => rewards).flatMap(({ token, breakdowns }) => {
12
+ if (!isAddress(token.address, { strict: false })) return [];
13
+ const rewardToken = toRewardToken(sdk, getAddress(token.address), token);
14
+ return breakdowns.flatMap(({ reason, amount, claimed }) => {
15
+ const pool = poolOf(reason?.split("_").find((p) => p.startsWith("0x")));
16
+ const value = remaining(parseAmount(amount), parseAmount(claimed));
17
+ if (!pool || !value) return [];
18
+ return [{
19
+ ...pool,
20
+ token: rewardToken,
21
+ value,
22
+ price: token.price
23
+ }];
24
+ });
25
+ });
26
+ return mergeRewards(sdk, "merkl", parts);
82
27
  }
83
28
  //#endregion
84
29
  export { toMerklRewards };
@@ -0,0 +1,60 @@
1
+ import { mergeRewards, parseAmount, remaining, resolvePool, toRewardToken } from "./helpers.js";
2
+ import { formatUnits, getAddress, isAddress } from "viem";
3
+ //#region src/rewards/toTurtleRewards.ts
4
+ /**
5
+ * The streams targeting a pool on the SDK's chain, merged per pool and
6
+ * incentive. A token reward is what is committed and not yet claimed.
7
+ */
8
+ function toTurtleRewards(sdk, { streams, proofs }, claimed) {
9
+ const poolOf = resolvePool(sdk);
10
+ const committed = new Map(proofs.map((p) => [p.streamId, p.amount]));
11
+ const targeted = streams.flatMap((stream) => {
12
+ const target = stream.stream.customArgs.targetToken;
13
+ if (Number(target?.chain.chainId) !== sdk.chainId) return [];
14
+ const pool = poolOf(target?.address);
15
+ return pool ? [{
16
+ stream,
17
+ ...pool
18
+ }] : [];
19
+ });
20
+ const tokens = targeted.flatMap(({ stream, pool, poolToken }) => {
21
+ const { point, rewardToken, lastSnapshot } = stream.stream;
22
+ if (point || !rewardToken || !isAddress(rewardToken.address, { strict: false })) return [];
23
+ const value = remaining(parseAmount(committed.get(stream.streamId)), claimed.get(stream.streamId));
24
+ if (!value) return [];
25
+ return [{
26
+ pool,
27
+ poolToken,
28
+ token: toRewardToken(sdk, getAddress(rewardToken.address), rewardToken),
29
+ value,
30
+ price: lastSnapshot?.rewardTokenPrice
31
+ }];
32
+ });
33
+ return [...mergeRewards(sdk, "turtle", tokens), ...toPoints(sdk, targeted)];
34
+ }
35
+ /** Points accrue per snapshot; the last one holds the running total. */
36
+ function toPoints(sdk, targeted) {
37
+ const merged = /* @__PURE__ */ new Map();
38
+ for (const { stream, pool, poolToken } of targeted) {
39
+ const { point } = stream.stream;
40
+ const raw = parseAmount(stream.snapshots.at(-1)?.rewardsAccumulated);
41
+ if (!point || !raw) continue;
42
+ const key = `${pool}_${point.id}`;
43
+ const value = (merged.get(key)?.points.value ?? 0) + Number(formatUnits(raw, point.decimals));
44
+ merged.set(key, {
45
+ source: "turtle",
46
+ chainId: sdk.chainId,
47
+ pool,
48
+ poolToken,
49
+ points: {
50
+ id: point.id,
51
+ name: point.name,
52
+ multiplier: null,
53
+ value
54
+ }
55
+ });
56
+ }
57
+ return [...merged.values()];
58
+ }
59
+ //#endregion
60
+ export { toTurtleRewards };
@@ -0,0 +1,64 @@
1
+ import { TurtleRequestFailedError } from "./errors.js";
2
+ //#region src/rewards/turtle-api.ts
3
+ const TURTLE_API_URL = "https://earn.turtle.xyz";
4
+ const GEARBOX_ORG_ID = "d171ba3d-ff89-4e6c-8f13-d94840b06edd";
5
+ const TIMEOUT = 1e4;
6
+ const turtleStreamAbi = [{
7
+ type: "function",
8
+ name: "getClaimedRewards",
9
+ stateMutability: "view",
10
+ inputs: [{
11
+ name: "user",
12
+ type: "address"
13
+ }],
14
+ outputs: [{
15
+ name: "",
16
+ type: "uint256"
17
+ }]
18
+ }];
19
+ async function fetchTurtleWalletRewards(user, apiKey) {
20
+ const { streams: all } = await get(`/v2/streams/wallets/${user}`, apiKey);
21
+ const streams = all.filter((s) => s.stream.orgId === GEARBOX_ORG_ID);
22
+ const tokenStreamIds = streams.filter((s) => s.stream.rewardToken && s.stream.contractAddress).map((s) => s.streamId);
23
+ if (tokenStreamIds.length === 0) return {
24
+ streams,
25
+ proofs: []
26
+ };
27
+ const query = new URLSearchParams({ wallet: user });
28
+ for (const id of tokenStreamIds) query.append("streamIds", id);
29
+ const { proofs } = await get(`/v2/streams/merkle_proofs?${query}`, apiKey);
30
+ return {
31
+ streams,
32
+ proofs
33
+ };
34
+ }
35
+ async function get(path, apiKey) {
36
+ try {
37
+ const response = await fetch(`${TURTLE_API_URL}${path}`, {
38
+ headers: { "X-API-Key": apiKey },
39
+ signal: AbortSignal.timeout(TIMEOUT)
40
+ });
41
+ if (!response.ok) throw new Error(`answered ${response.status}`);
42
+ return await response.json();
43
+ } catch (error) {
44
+ throw new TurtleRequestFailedError(path, error);
45
+ }
46
+ }
47
+ /** What each of the wallet's streams on the chain has paid out, at the latest block. */
48
+ async function readTurtleClaimed(sdk, user, { proofs }) {
49
+ const onChain = proofs.filter((p) => p.chainId === sdk.chainId);
50
+ if (onChain.length === 0) return /* @__PURE__ */ new Map();
51
+ const claimed = await sdk.client.multicall({
52
+ contracts: onChain.map((p) => ({
53
+ address: p.contractAddress,
54
+ abi: turtleStreamAbi,
55
+ functionName: "getClaimedRewards",
56
+ args: [user]
57
+ })),
58
+ allowFailure: false,
59
+ blockTag: "latest"
60
+ });
61
+ return new Map(onChain.map((p, i) => [p.streamId, claimed[i]]));
62
+ }
63
+ //#endregion
64
+ export { fetchTurtleWalletRewards, readTurtleClaimed, turtleStreamAbi };
@@ -0,0 +1 @@
1
+ export {};
@@ -11,16 +11,11 @@ import { Address } from "viem";
11
11
  * - `poolAvailableLiquidity` — the pool's available liquidity
12
12
  * - `managerDebtAvailable` — this credit manager's remaining debt allowance
13
13
  * - `maxDebt` — the facade's per-account `debtLimits.maxDebt`
14
- * - `quotaAvailable` — remaining quota the market takes for the suite's
15
- * strategy target collateral (`limit - totalQuoted`, floored at 0); only an
16
- * account being opened is held to it
17
- * - `minDebt` — every other limit left less than the facade's `minDebt`, so
18
- * no account can be opened; the amount is 0
19
14
  * - `debtPerBlockLimit` — facade takes no new debt this block; in practice
20
15
  * `maxDebtPerBlockMultiplier == 0` after a with-loss liquidation
21
16
  * - `poolDebtLimit` — pool-wide debt cap; used on account-opening only
22
17
  **/
23
- type BorrowLimitCause = "poolAvailableLiquidity" | "managerDebtAvailable" | "maxDebt" | "quotaAvailable" | "minDebt" | "debtPerBlockLimit" | "poolDebtLimit";
18
+ type BorrowLimitCause = "poolAvailableLiquidity" | "managerDebtAvailable" | "maxDebt" | "debtPerBlockLimit" | "poolDebtLimit";
24
19
  /**
25
20
  * The credit manager is paused and takes no multicall at all.
26
21
  **/