@kasufinance/kasu-sdk 2.7.1 → 2.10.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.
Files changed (88) hide show
  1. package/README.md +162 -7
  2. package/dist/bundle.cjs.js +2292 -449
  3. package/dist/bundle.esm.js +2253 -451
  4. package/dist/domain/batch-proof.d.ts +210 -0
  5. package/dist/domain/batch-proof.js +384 -0
  6. package/dist/domain/batch-proof.js.map +1 -0
  7. package/dist/domain/explorer-urls.d.ts +33 -0
  8. package/dist/domain/explorer-urls.js +38 -0
  9. package/dist/domain/explorer-urls.js.map +1 -0
  10. package/dist/domain/fixed-term.d.ts +74 -0
  11. package/dist/domain/fixed-term.js +86 -0
  12. package/dist/domain/fixed-term.js.map +1 -0
  13. package/dist/domain/index.d.ts +12 -2
  14. package/dist/domain/index.js +7 -2
  15. package/dist/domain/index.js.map +1 -1
  16. package/dist/domain/partners.d.ts +29 -0
  17. package/dist/domain/partners.js +20 -0
  18. package/dist/domain/partners.js.map +1 -1
  19. package/dist/domain/requests.d.ts +24 -0
  20. package/dist/domain/requests.js +33 -0
  21. package/dist/domain/requests.js.map +1 -1
  22. package/dist/domain/sponsored-calls.d.ts +74 -0
  23. package/dist/domain/sponsored-calls.js +156 -0
  24. package/dist/domain/sponsored-calls.js.map +1 -0
  25. package/dist/domain/yields.d.ts +121 -0
  26. package/dist/domain/yields.js +151 -0
  27. package/dist/domain/yields.js.map +1 -0
  28. package/dist/facade/calls.d.ts +59 -0
  29. package/dist/facade/calls.js +89 -0
  30. package/dist/facade/calls.js.map +1 -0
  31. package/dist/facade/chain-configs.js +5 -0
  32. package/dist/facade/chain-configs.js.map +1 -1
  33. package/dist/facade/flows.d.ts +40 -3
  34. package/dist/facade/flows.js +36 -3
  35. package/dist/facade/flows.js.map +1 -1
  36. package/dist/facade/index.d.ts +3 -1
  37. package/dist/facade/index.js +1 -0
  38. package/dist/facade/index.js.map +1 -1
  39. package/dist/facade/kasu.d.ts +8 -2
  40. package/dist/facade/kasu.js +17 -4
  41. package/dist/facade/kasu.js.map +1 -1
  42. package/dist/facade/strategies.d.ts +43 -2
  43. package/dist/facade/strategies.js +80 -4
  44. package/dist/facade/strategies.js.map +1 -1
  45. package/dist/facade/types.d.ts +18 -0
  46. package/dist/flows/batch-errors.d.ts +74 -0
  47. package/dist/flows/batch-errors.js +83 -0
  48. package/dist/flows/batch-errors.js.map +1 -0
  49. package/dist/flows/batch-submission.d.ts +223 -0
  50. package/dist/flows/batch-submission.js +321 -0
  51. package/dist/flows/batch-submission.js.map +1 -0
  52. package/dist/flows/deposit-flow.d.ts +128 -4
  53. package/dist/flows/deposit-flow.js +233 -4
  54. package/dist/flows/deposit-flow.js.map +1 -1
  55. package/dist/flows/index.d.ts +6 -2
  56. package/dist/flows/index.js +3 -1
  57. package/dist/flows/index.js.map +1 -1
  58. package/dist/flows/withdraw-flow.d.ts +95 -4
  59. package/dist/flows/withdraw-flow.js +107 -2
  60. package/dist/flows/withdraw-flow.js.map +1 -1
  61. package/dist/services/DataService/queries.js +1 -0
  62. package/dist/services/DataService/queries.js.map +1 -1
  63. package/dist/services/UserLending/user-lending.d.ts +13 -0
  64. package/dist/services/UserLending/user-lending.js +25 -4
  65. package/dist/services/UserLending/user-lending.js.map +1 -1
  66. package/package.json +1 -1
  67. package/src/domain/batch-proof.ts +576 -0
  68. package/src/domain/explorer-urls.ts +74 -0
  69. package/src/domain/fixed-term.ts +115 -0
  70. package/src/domain/index.ts +56 -0
  71. package/src/domain/partners.ts +35 -0
  72. package/src/domain/requests.ts +37 -0
  73. package/src/domain/sponsored-calls.ts +213 -0
  74. package/src/domain/yields.ts +189 -0
  75. package/src/facade/calls.ts +135 -0
  76. package/src/facade/chain-configs.ts +5 -0
  77. package/src/facade/flows.ts +35 -2
  78. package/src/facade/index.ts +3 -0
  79. package/src/facade/kasu.ts +32 -2
  80. package/src/facade/strategies.ts +98 -7
  81. package/src/facade/types.ts +19 -0
  82. package/src/flows/batch-errors.ts +118 -0
  83. package/src/flows/batch-submission.ts +511 -0
  84. package/src/flows/deposit-flow.ts +379 -7
  85. package/src/flows/index.ts +34 -1
  86. package/src/flows/withdraw-flow.ts +219 -10
  87. package/src/services/DataService/queries.ts +1 -0
  88. package/src/services/UserLending/user-lending.ts +38 -10
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Forward-looking interest on a lender's CURRENT balances — the figures a
3
+ * portfolio shows as "weekly", "daily" and the moving part of "lifetime"
4
+ * interest, and the "≈ $Y a year" line beside a lend amount.
5
+ *
6
+ * Every figure is NET of the platform performance fee, the way the chain
7
+ * credits it: `LendingPool._applyTrancheInterest` mints the lender
8
+ * `interest × (1 − performanceFee)` at each cycle close, and only that net
9
+ * amount ever reaches the balance. A gross projection would out-run what the
10
+ * next close actually credits.
11
+ *
12
+ * Units: balances and results are display units of the deployment's stable
13
+ * asset (not base units); rates are 0..1 fractions; `feePercent` is a
14
+ * PERCENTAGE IN 0..100 — see `netEffectiveApy`.
15
+ */
16
+
17
+ /**
18
+ * Seconds in one epoch's nominal week. The per-epoch rate covers exactly this
19
+ * window, and the accrual spreads an epoch's interest across it.
20
+ */
21
+ export const SECONDS_PER_WEEK = 604_800;
22
+
23
+ /** One slice of a position that earns at a single rate. */
24
+ export interface InterestSlice {
25
+ /** The lender's current balance in this slice, in stable display units. */
26
+ balance: number;
27
+ /**
28
+ * The RAW per-epoch rate the chain applies to this slice at each close,
29
+ * as a 0..1 fraction (`0.00285` ≈ 0.285% an epoch) — the subgraph's
30
+ * `TrancheData.interestRate` or a fixed-term config's
31
+ * `epochInterestRate`. Never derived from an APY.
32
+ *
33
+ * NON-FINITE means UNKNOWN, not zero. A zero is a genuinely zero
34
+ * configured rate and contributes a real 0; the two must stay
35
+ * distinguishable.
36
+ */
37
+ epochRate: number;
38
+ }
39
+
40
+ /** One pool's contribution to the current cycle's accrual. */
41
+ export interface PoolAccrual {
42
+ /** That pool's projected net interest for one epoch (`weeklyInterest`). */
43
+ weeklyInterest: number;
44
+ /** Unix SECONDS at which THAT pool last cleared a cycle. */
45
+ clearingTimestamp: number;
46
+ }
47
+
48
+ function isUsableFeePercent(feePercent: number | null): feePercent is number {
49
+ return (
50
+ feePercent !== null &&
51
+ Number.isFinite(feePercent) &&
52
+ feePercent >= 0 &&
53
+ feePercent <= 100
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Interest the slices accrue over ONE epoch, net of the fee.
59
+ *
60
+ * ```
61
+ * weekly = Σ(slice.balance × slice.epochRate) × (1 − feePercent / 100)
62
+ * ```
63
+ *
64
+ * **The rate is the raw per-epoch one, never the APY.** An APY is that rate
65
+ * already compounded over `EPOCHS_IN_YEAR`; dividing it by 365 charges a day
66
+ * of compounded annual growth to a single day and overstates it.
67
+ *
68
+ * FAILS CLOSED, returning `null` rather than a wrong number:
69
+ * - a `null`, non-finite or out-of-domain fee — a fee-less projection dressed
70
+ * up as the lender's net earnings is the failure this guards;
71
+ * - any slice with a POSITIVE balance and a non-finite rate — skipping it
72
+ * would return a silently partial total, quietly missing that slice.
73
+ *
74
+ * Non-finite and non-positive BALANCES are skipped: a slice with nothing in it
75
+ * earns nothing whatever its rate, so it cannot hide earnings. A portfolio
76
+ * with nothing lent is therefore a legitimate `0`, never `null`.
77
+ */
78
+ export function weeklyInterest(
79
+ slices: readonly InterestSlice[],
80
+ feePercent: number | null,
81
+ ): number | null {
82
+ if (!isUsableFeePercent(feePercent)) return null;
83
+ let gross = 0;
84
+ for (const slice of slices) {
85
+ if (!Number.isFinite(slice.balance) || slice.balance <= 0) continue;
86
+ if (!Number.isFinite(slice.epochRate)) return null;
87
+ gross += slice.balance * slice.epochRate;
88
+ }
89
+ return gross * (1 - feePercent / 100);
90
+ }
91
+
92
+ /**
93
+ * A day's share of the epoch's net interest: a seventh of it, NOT a
94
+ * de-compounded daily rate. The chain mints interest once per epoch, so
95
+ * nothing compounds within the cycle and the accrual is linear across it.
96
+ *
97
+ * Fails closed with its input: `null` or non-finite → `null`.
98
+ */
99
+ export function dailyInterest(weekly: number | null): number | null {
100
+ if (weekly === null || !Number.isFinite(weekly)) return null;
101
+ return weekly / 7;
102
+ }
103
+
104
+ /**
105
+ * Interest accrued since each pool's OWN last cycle close — the moving part
106
+ * of lifetime interest (lifetime = settled on chain + this).
107
+ *
108
+ * ```
109
+ * accrued = Σ_pool weekly_pool × clamp(now − clearing_pool, 0, 604 800) / 604 800
110
+ * ```
111
+ *
112
+ * **Per pool, because pools clear independently.** One shared anchor accrues
113
+ * a stale pool's share from a close it never had, and when another pool
114
+ * clears it drops the total by the stale pool's whole weekly share. Anchored
115
+ * per pool, the total is monotonic across any single pool's clearing: that
116
+ * pool swaps its accrued share for a capitalised one, and no other term moves.
117
+ *
118
+ * **The one-epoch cap is load-bearing.** Clearings are not exactly weekly,
119
+ * and `weeklyInterest` is per EPOCH, so extrapolating past seven days would
120
+ * promise interest the close will not credit; a late clearing under-reports
121
+ * instead. The low clamp makes a close ahead of the local clock zero, never
122
+ * negative.
123
+ *
124
+ * A non-finite term contributes nothing; a non-finite `nowSeconds` accrues
125
+ * nothing. Callers fail closed upstream by not offering an accrual for a pool
126
+ * whose weekly interest or anchor is unknown.
127
+ */
128
+ export function accruedInterest(
129
+ accruals: readonly PoolAccrual[],
130
+ nowSeconds: number,
131
+ ): number {
132
+ if (!Number.isFinite(nowSeconds)) return 0;
133
+ let accrued = 0;
134
+ for (const { weeklyInterest: weekly, clearingTimestamp } of accruals) {
135
+ if (!Number.isFinite(weekly) || !Number.isFinite(clearingTimestamp)) {
136
+ continue;
137
+ }
138
+ const elapsed = Math.min(
139
+ Math.max(nowSeconds - clearingTimestamp, 0),
140
+ SECONDS_PER_WEEK,
141
+ );
142
+ accrued += (weekly * elapsed) / SECONDS_PER_WEEK;
143
+ }
144
+ return accrued;
145
+ }
146
+
147
+ /**
148
+ * What `amount` earns in a year at a NET rate — the "≈ $Y a year" line.
149
+ * Feed it `netEffectiveApy(...)`, never a gross APY: the result is a lender's
150
+ * earnings, so the fee must already be off the rate.
151
+ *
152
+ * FAILS CLOSED: a non-finite or negative amount or rate returns `NaN`, which
153
+ * the consumer renders as "no figure" — exactly what `netEffectiveApy`
154
+ * returns when the fee is unknown, so that state carries straight through.
155
+ *
156
+ * @param amount display units of the stable asset.
157
+ * @param netApy the net effective annual rate as a 0..1 fraction.
158
+ */
159
+ export function projectedAnnualEarnings(
160
+ amount: number,
161
+ netApy: number,
162
+ ): number {
163
+ if (!Number.isFinite(amount) || amount < 0) return NaN;
164
+ if (!Number.isFinite(netApy) || netApy < 0) return NaN;
165
+ return amount * netApy;
166
+ }
167
+
168
+ /**
169
+ * Balance-weighted average APY across slices, skipping non-finite and
170
+ * non-positive balances and non-finite rates; `0` when nothing carries a
171
+ * positive balance. Whatever basis goes in comes out — feed it NET rates for
172
+ * an average effective (net) rate.
173
+ */
174
+ export function weightedAverageApy(
175
+ slices: readonly { balance: number; apy: number }[],
176
+ ): number {
177
+ let totalBalance = 0;
178
+ let weighted = 0;
179
+ for (const slice of slices) {
180
+ if (!Number.isFinite(slice.balance) || !Number.isFinite(slice.apy)) {
181
+ continue;
182
+ }
183
+ if (slice.balance <= 0) continue;
184
+ totalBalance += slice.balance;
185
+ weighted += slice.balance * slice.apy;
186
+ }
187
+ if (totalBalance <= 0) return 0;
188
+ return weighted / totalBalance;
189
+ }
@@ -0,0 +1,135 @@
1
+ import { BigNumber, BigNumberish } from 'ethers';
2
+
3
+ import {
4
+ encodeCancelDepositCall,
5
+ encodeCancelWithdrawalCall,
6
+ encodeRequestWithdrawalCall,
7
+ encodeWithdrawAtExpiryCall,
8
+ SponsoredCall,
9
+ SponsoredCallInputError,
10
+ } from '../domain/sponsored-calls';
11
+ import { UserLending } from '../services/UserLending/user-lending';
12
+
13
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
14
+
15
+ /** What `kasu.calls.requestWithdrawal` takes — `WithdrawFlowInput`'s shape. */
16
+ export interface RequestWithdrawalCallParams {
17
+ poolId: string;
18
+ trancheId: string;
19
+ /**
20
+ * Base units of the stable asset, or `'max'` for every share the lender
21
+ * holds in the tranche. Same code as `WithdrawFlowInput.amount`.
22
+ */
23
+ amount: BigNumber | 'max';
24
+ /** The lender — whose shares `'max'` reads. The relay sends as them. */
25
+ userAddress: string;
26
+ }
27
+
28
+ /**
29
+ * Encoded, ready-to-sponsor `LendingPoolManager` calls — read, then encode,
30
+ * NEVER send. Each returns `{ kind, call }`: post `kind` and `[call]` to a
31
+ * sponsored relay (or `submitSponsoredCall`), unchanged.
32
+ *
33
+ * ```ts
34
+ * const { kind, call } = kasu.calls.cancelDepositRequest(poolId, dNftId);
35
+ * ```
36
+ *
37
+ * `call.to` is always THIS chain's `LendingPoolManager`, from the chain
38
+ * config — the same address the deposit flow approves and calls — so no app
39
+ * supplies the one argument whose wrong value the relay refuses outright.
40
+ *
41
+ * Works on a read-only instance: the only chain access is the share read a
42
+ * withdrawal needs, and nothing is signed or sent here.
43
+ */
44
+ export class CallsFacade {
45
+ constructor(
46
+ private readonly _userLending: UserLending,
47
+ private readonly _lendingPoolManager: string,
48
+ ) {}
49
+
50
+ /**
51
+ * `requestWithdrawal(pool, tranche, shares)` for an asset amount or
52
+ * `'max'`, converted into shares exactly as the direct path does it
53
+ * (`UserLending.requestWithdrawalInAsset` / `requestWithdrawalMax`): the
54
+ * tranche's `convertToShares(amount)`, or the lender's whole
55
+ * `balanceOf(userAddress)`, read now.
56
+ *
57
+ * Rejects with `SponsoredCallInputError` — before anything is sent — for
58
+ * a non-positive amount, a malformed address, or a conversion that comes
59
+ * to ZERO shares (an empty position, or dust below one share), which the
60
+ * contract would revert and the relay refuses.
61
+ */
62
+ async requestWithdrawal(
63
+ params: RequestWithdrawalCallParams,
64
+ ): Promise<SponsoredCall> {
65
+ const { poolId, trancheId, amount, userAddress } = params;
66
+ if (typeof userAddress !== 'string' || !ADDRESS_RE.test(userAddress)) {
67
+ throw new SponsoredCallInputError('user');
68
+ }
69
+ if (typeof poolId !== 'string' || !ADDRESS_RE.test(poolId)) {
70
+ throw new SponsoredCallInputError('pool');
71
+ }
72
+ if (typeof trancheId !== 'string' || !ADDRESS_RE.test(trancheId)) {
73
+ throw new SponsoredCallInputError('tranche');
74
+ }
75
+ let shares: BigNumber;
76
+ if (amount === 'max') {
77
+ // Lowercased, as `WithdrawFlow` hands `withdrawMax` the address.
78
+ shares = await this._userLending.getWithdrawalSharesMax(
79
+ trancheId,
80
+ userAddress.toLowerCase(),
81
+ );
82
+ } else {
83
+ if (!BigNumber.isBigNumber(amount) || amount.lte(0)) {
84
+ throw new SponsoredCallInputError('non-positive-amount');
85
+ }
86
+ shares = await this._userLending.getWithdrawalSharesForAssets(
87
+ trancheId,
88
+ amount,
89
+ );
90
+ }
91
+ return encodeRequestWithdrawalCall(
92
+ this._lendingPoolManager,
93
+ poolId,
94
+ trancheId,
95
+ shares,
96
+ );
97
+ }
98
+
99
+ /** `cancelDepositRequest(pool, dNftId)` — the pending deposit's NFT id. */
100
+ cancelDepositRequest(poolId: string, dNftId: BigNumberish): SponsoredCall {
101
+ return encodeCancelDepositCall(
102
+ this._lendingPoolManager,
103
+ poolId,
104
+ dNftId,
105
+ );
106
+ }
107
+
108
+ /** `cancelWithdrawalRequest(pool, wNftId)` — the pending withdrawal's NFT id. */
109
+ cancelWithdrawalRequest(
110
+ poolId: string,
111
+ wNftId: BigNumberish,
112
+ ): SponsoredCall {
113
+ return encodeCancelWithdrawalCall(
114
+ this._lendingPoolManager,
115
+ poolId,
116
+ wNftId,
117
+ );
118
+ }
119
+
120
+ /**
121
+ * `requestFixedTermDepositWithdrawal(pool, fixedTermDepositId)` — what
122
+ * `UserLending.withdrawAtExpiry(pool, lockId)` sends: the pool and the
123
+ * LOCK id, no tranche.
124
+ */
125
+ withdrawAtExpiry(
126
+ poolId: string,
127
+ fixedTermDepositId: BigNumberish,
128
+ ): SponsoredCall {
129
+ return encodeWithdrawAtExpiryCall(
130
+ this._lendingPoolManager,
131
+ poolId,
132
+ fixedTermDepositId,
133
+ );
134
+ }
135
+ }
@@ -59,6 +59,7 @@ export const CHAIN_CONFIGS: Record<'base' | 'xdc' | 'xdc-usdc' | 'plume', ChainC
59
59
  'https://base-rpc.publicnode.com',
60
60
  'https://mainnet.base.org',
61
61
  ],
62
+ blockExplorerUrl: 'https://basescan.org',
62
63
  },
63
64
 
64
65
  xdc: {
@@ -99,6 +100,7 @@ export const CHAIN_CONFIGS: Record<'base' | 'xdc' | 'xdc-usdc' | 'plume', ChainC
99
100
  currencyCode: 'AUD',
100
101
  },
101
102
  rpcUrls: XDC_RPC_URLS,
103
+ blockExplorerUrl: 'https://xdcscan.com',
102
104
  },
103
105
 
104
106
  'xdc-usdc': {
@@ -132,6 +134,7 @@ export const CHAIN_CONFIGS: Record<'base' | 'xdc' | 'xdc-usdc' | 'plume', ChainC
132
134
  currencyCode: 'USD',
133
135
  },
134
136
  rpcUrls: XDC_RPC_URLS,
137
+ blockExplorerUrl: 'https://xdcscan.com',
135
138
  },
136
139
 
137
140
  plume: {
@@ -174,6 +177,8 @@ export const CHAIN_CONFIGS: Record<'base' | 'xdc' | 'xdc-usdc' | 'plume', ChainC
174
177
  // pointing at an endpoint nobody maintains. Pass your own
175
178
  // `signerOrProvider` to read the frozen history.
176
179
  rpcUrls: [],
180
+ // The explorer still serves the frozen history.
181
+ blockExplorerUrl: 'https://explorer.plume.org',
177
182
  retired: true,
178
183
  },
179
184
  };
@@ -10,6 +10,7 @@ import {
10
10
  WithdrawPorts,
11
11
  } from '../flows';
12
12
 
13
+ import { CallsFacade } from './calls';
13
14
  import { DepositsFacade } from './deposits';
14
15
  import { READ_ONLY_MESSAGE } from './read-only';
15
16
  import { KycParams } from './types';
@@ -30,8 +31,9 @@ export type DepositFlowPortOverrides = Pick<
30
31
  Partial<DepositPorts>;
31
32
 
32
33
  /**
33
- * Every withdraw port has an SDK default except `getKycSignature`, which has
34
- * no default at all and which turns the KYC pre-check on by being supplied.
34
+ * Every withdraw port has an SDK default except `getKycSignature` and
35
+ * `submitCall`, which have no default at all: supplying `getKycSignature`
36
+ * turns the KYC pre-check on, supplying `submitCall` the sponsored path.
35
37
  */
36
38
  export type WithdrawFlowPortOverrides = Partial<WithdrawPorts>;
37
39
 
@@ -75,6 +77,19 @@ export class FlowsFacade {
75
77
  * contract.
76
78
  */
77
79
  private readonly _spender: string,
80
+ /**
81
+ * This chain's stable token address — the target of the batch path's
82
+ * approve. `undefined` for a hand-built config without `stableAsset`,
83
+ * in which case a run never batches: it does not guess a token.
84
+ */
85
+ private readonly _stableAsset?: string,
86
+ /**
87
+ * This instance's call encoders — the sponsored withdraw path's
88
+ * default `encodeWithdrawal`. Optional so a hand-built facade keeps
89
+ * constructing; without it a sponsored run fails (`failed`, nothing
90
+ * sent) unless the consumer passes `encodeWithdrawal` itself.
91
+ */
92
+ private readonly _calls?: CallsFacade,
78
93
  ) {}
79
94
 
80
95
  /**
@@ -88,6 +103,10 @@ export class FlowsFacade {
88
103
  * delete the default it was meant to keep, and
89
104
  * `approve: sponsoredGas ? sponsoredApprove : undefined` is exactly how a
90
105
  * consumer writes a conditional override.
106
+ *
107
+ * `depositBatch` has NO default: how several calls become one atomic
108
+ * transaction is the application's business. Supplying it lets a run
109
+ * whose allowance is short approve and deposit in one batch.
91
110
  */
92
111
  deposit(
93
112
  ports: DepositFlowPortOverrides,
@@ -122,6 +141,7 @@ export class FlowsFacade {
122
141
  ports.deposit ??
123
142
  ((params): Promise<WaitableTransaction> =>
124
143
  this._deposits.deposit(params)),
144
+ depositBatch: ports.depositBatch,
125
145
  now: ports.now,
126
146
  },
127
147
  {
@@ -129,6 +149,7 @@ export class FlowsFacade {
129
149
  // Per key here too, for the same reason the ports are: an
130
150
  // explicit `spender: undefined` must not delete the default.
131
151
  spender: opts?.spender ?? this._spender,
152
+ stableAsset: opts?.stableAsset ?? this._stableAsset,
132
153
  },
133
154
  );
134
155
  }
@@ -136,6 +157,9 @@ export class FlowsFacade {
136
157
  /**
137
158
  * A withdrawal pipeline. Both write ports and `buildKycParams` default to
138
159
  * this instance; supplying `getKycSignature` turns the KYC pre-check on.
160
+ * Supplying `submitCall` turns the sponsored path on, and its encoder
161
+ * defaults to `kasu.calls.requestWithdrawal` on this instance — which
162
+ * reads, never writes, so it works on a read-only instance too.
139
163
  * Defaults are applied per key, for the reason `deposit()` gives.
140
164
  */
141
165
  withdraw(ports: WithdrawFlowPortOverrides = {}): WithdrawFlow {
@@ -161,9 +185,18 @@ export class FlowsFacade {
161
185
  userAddress: string,
162
186
  ): Promise<WaitableTransaction> =>
163
187
  this._deposits.withdrawMax(poolId, trancheId, userAddress)),
188
+ submitCall: ports.submitCall,
189
+ encodeWithdrawal: ports.encodeWithdrawal ?? this._defaultEncoder(),
164
190
  });
165
191
  }
166
192
 
193
+ /** `kasu.calls.requestWithdrawal`, when this facade was given the encoders. */
194
+ private _defaultEncoder(): WithdrawPorts['encodeWithdrawal'] {
195
+ const calls = this._calls;
196
+ if (!calls) return undefined;
197
+ return (params) => calls.requestWithdrawal(params);
198
+ }
199
+
167
200
  private _assertWritable(): void {
168
201
  if (this._isReadOnly) {
169
202
  throw new Error(READ_ONLY_MESSAGE);
@@ -6,6 +6,8 @@ export { StrategiesFacade } from './strategies';
6
6
  export { DepositsFacade } from './deposits';
7
7
  export { PortfolioFacade } from './user-portfolio';
8
8
  export { FlowsFacade } from './flows';
9
+ export { CallsFacade } from './calls';
10
+ export type { RequestWithdrawalCallParams } from './calls';
9
11
  export type {
10
12
  DepositFlowPortOverrides,
11
13
  WithdrawFlowPortOverrides,
@@ -35,4 +37,5 @@ export type {
35
37
  KycParams,
36
38
  UserPositions,
37
39
  PlatformStats,
40
+ StrategyRiskPerformance,
38
41
  } from './types';
@@ -6,6 +6,7 @@ import type { IERC20MetadataAbi } from '../contracts';
6
6
  import { KasuSdk } from '../kasu-sdk';
7
7
  import { SdkConfig, SdkConfigOptions } from '../sdk-config';
8
8
 
9
+ import { CallsFacade } from './calls';
9
10
  import { CHAIN_CONFIGS } from './chain-configs';
10
11
  import { DepositsFacade } from './deposits';
11
12
  import { FlowsFacade } from './flows';
@@ -21,8 +22,8 @@ import { PortfolioFacade } from './user-portfolio';
21
22
  /**
22
23
  * High-level entry point for external integrators.
23
24
  *
24
- * Provides four domain facades — `strategies`, `deposits`, `portfolio`,
25
- * `flows` — and exposes the underlying `KasuSdk` services via `.services` for
25
+ * Provides five domain facades — `strategies`, `deposits`, `portfolio`,
26
+ * `flows`, `calls` — and exposes the underlying `KasuSdk` services via `.services` for
26
27
  * power-users.
27
28
  *
28
29
  * ```ts
@@ -49,6 +50,11 @@ export class Kasu {
49
50
  public readonly portfolio: PortfolioFacade;
50
51
  /** Headless deposit / withdraw state machines. */
51
52
  public readonly flows: FlowsFacade;
53
+ /**
54
+ * Encoded `LendingPoolManager` calls for a sponsored relay — withdraw,
55
+ * cancel, withdraw at expiry. Read, then encode; never sends.
56
+ */
57
+ public readonly calls: CallsFacade;
52
58
 
53
59
  private readonly _sdk: KasuSdk;
54
60
  private readonly _chainConfig: ChainConfigEntry;
@@ -74,6 +80,10 @@ export class Kasu {
74
80
  this.strategies = new StrategiesFacade(
75
81
  sdk.DataService,
76
82
  sdk.UserLending,
83
+ normaliseMapping(
84
+ configOverrides.poolMetadataMapping ??
85
+ chainConfig.poolMetadataMapping,
86
+ ),
77
87
  );
78
88
 
79
89
  // Derived ONCE and handed to every facade that needs it. Each of them
@@ -93,11 +103,20 @@ export class Kasu {
93
103
  sdk.Portfolio,
94
104
  );
95
105
 
106
+ // The same `LendingPoolManager` the deposit flow approves and calls:
107
+ // the chain config's, which is what a relay allow-lists `to` against.
108
+ this.calls = new CallsFacade(
109
+ sdk.UserLending,
110
+ chainConfig.contracts.LendingPoolManager,
111
+ );
112
+
96
113
  this.flows = new FlowsFacade(
97
114
  this.deposits,
98
115
  () => erc20Of(chainConfig, signerOrProvider),
99
116
  isReadOnly,
100
117
  chainConfig.contracts.LendingPoolManager,
118
+ stableAssetOf(chainConfig)?.address,
119
+ this.calls,
101
120
  );
102
121
  }
103
122
 
@@ -325,3 +344,14 @@ function resolveChainConfig(
325
344
  }
326
345
  return chain;
327
346
  }
347
+
348
+ /** Lowercase both sides, so a lookup by any casing of a pool id matches. */
349
+ function normaliseMapping(
350
+ mapping: Record<string, string> | undefined,
351
+ ): Record<string, string> {
352
+ const out: Record<string, string> = {};
353
+ for (const [from, to] of Object.entries(mapping ?? {})) {
354
+ out[from.toLowerCase()] = to.toLowerCase();
355
+ }
356
+ return out;
357
+ }
@@ -1,6 +1,9 @@
1
1
  import { selectVisiblePools } from '../domain/pools';
2
2
  import { DataService } from '../services/DataService/data-service';
3
- import { PoolOverview } from '../services/DataService/types';
3
+ import {
4
+ PoolDelegateProfileAndHistory,
5
+ PoolOverview,
6
+ } from '../services/DataService/types';
4
7
  import { UserLending } from '../services/UserLending/user-lending';
5
8
 
6
9
  import {
@@ -8,6 +11,7 @@ import {
8
11
  FixedTermOption,
9
12
  PlatformStats,
10
13
  Strategy,
14
+ StrategyRiskPerformance,
11
15
  StrategyTranche,
12
16
  } from './types';
13
17
 
@@ -20,8 +24,82 @@ export class StrategiesFacade {
20
24
  constructor(
21
25
  private _dataService: DataService,
22
26
  private _userLending: UserLending,
27
+ /**
28
+ * Chain pool id → the pool id its CMS record is keyed by (XDC pools
29
+ * share their Base counterpart's editorial record). Lowercase keys.
30
+ */
31
+ private _poolMetadataMapping: Record<string, string> = {},
23
32
  ) {}
24
33
 
34
+ /**
35
+ * The pool's loss rate and first-loss capital, from its CMS
36
+ * risk-management record. `null` when the pool has no record.
37
+ *
38
+ * Each figure is a NUMBER or `null` — never a guess. The CMS returns
39
+ * "numeric" fields as a number, a numeric string, an empty string or
40
+ * null, and an empty string must not become a confident `0`: anything that
41
+ * is not a finite number reads as `null` (render "—"). `poolLossRate` is a
42
+ * 0..1 fraction.
43
+ *
44
+ * Requires `directusUrl`; without it the call rejects with
45
+ * `NO_DIRECTUS_URL_MESSAGE`, like every CMS-only read.
46
+ */
47
+ async getRiskPerformance(
48
+ poolId: string,
49
+ ): Promise<StrategyRiskPerformance | null> {
50
+ const metadataId = this._metadataIdOf(poolId);
51
+ const records = await this._dataService.getRiskManagement();
52
+ const record = records.find(
53
+ (r) => String(r.poolIdFK).toLowerCase() === metadataId,
54
+ );
55
+ if (!record) return null;
56
+ const performance = record.riskPerformance as
57
+ | Partial<Record<'poolLossRate' | 'firstLossCapital', unknown>>
58
+ | null
59
+ | undefined;
60
+ return {
61
+ poolLossRate: finiteOrNull(performance?.poolLossRate),
62
+ firstLossCapital: finiteOrNull(performance?.firstLossCapital),
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The credit originator's profile and lending history for a pool — the
68
+ * CMS record whose `otherKASUPools` lists it. `null` when none does.
69
+ *
70
+ * Matched on the chain pool id, then on its metadata id, case-
71
+ * insensitively. The record is returned as the service maps it; its
72
+ * numeric fields are CMS-authored, so read them defensively.
73
+ *
74
+ * The service filters `otherKASUPools` through the CURRENT chain's
75
+ * subgraph with the CMS record's (Base-keyed) ids, so on an XDC
76
+ * deployment every profile lists no pools and this resolves `null` —
77
+ * the same answer kasu-ui's own lookup gives there today. The metadata
78
+ * candidate becomes live once the service resolves the mapping.
79
+ */
80
+ async getOriginator(
81
+ poolId: string,
82
+ ): Promise<PoolDelegateProfileAndHistory | null> {
83
+ const candidates = new Set([
84
+ poolId.toLowerCase(),
85
+ this._metadataIdOf(poolId),
86
+ ]);
87
+ const profiles =
88
+ await this._dataService.getPoolDelegateProfileAndHistory();
89
+ return (
90
+ profiles.find((profile) =>
91
+ profile.otherKASUPools.some((pool) =>
92
+ candidates.has(String(pool.id).toLowerCase()),
93
+ ),
94
+ ) ?? null
95
+ );
96
+ }
97
+
98
+ private _metadataIdOf(poolId: string): string {
99
+ const lower = poolId.toLowerCase();
100
+ return (this._poolMetadataMapping[lower] ?? lower).toLowerCase();
101
+ }
102
+
25
103
  /**
26
104
  * Fetch all active lending strategies.
27
105
  *
@@ -96,13 +174,12 @@ export class StrategiesFacade {
96
174
  calculateDepositLimits(tranche: StrategyTranche): DepositLimits {
97
175
  const raw = tranche._raw;
98
176
  const min =
99
- parseFloat(raw.minimumDeposit) < 1
100
- ? '1'
101
- : raw.minimumDeposit;
177
+ parseFloat(raw.minimumDeposit) < 1 ? '1' : raw.minimumDeposit;
102
178
 
103
- const max = parseFloat(raw.maximumDeposit) > parseFloat(raw.poolCapacity)
104
- ? raw.poolCapacity
105
- : raw.maximumDeposit;
179
+ const max =
180
+ parseFloat(raw.maximumDeposit) > parseFloat(raw.poolCapacity)
181
+ ? raw.poolCapacity
182
+ : raw.maximumDeposit;
106
183
 
107
184
  return {
108
185
  min,
@@ -153,3 +230,17 @@ export class StrategiesFacade {
153
230
  };
154
231
  }
155
232
  }
233
+
234
+ /**
235
+ * A CMS "numeric" field as a finite number, or `null`. An empty or blank
236
+ * string is UNAUTHORED, not zero.
237
+ */
238
+ function finiteOrNull(value: unknown): number | null {
239
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
240
+ if (typeof value === 'string') {
241
+ if (value.trim() === '') return null;
242
+ const numeric = Number(value);
243
+ return Number.isFinite(numeric) ? numeric : null;
244
+ }
245
+ return null;
246
+ }