@kasufinance/kasu-sdk 2.4.2 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +189 -253
- package/dist/bundle.cjs.js +4775 -1794
- package/dist/bundle.esm.js +4764 -1816
- package/dist/domain/deposit-bounds.d.ts +62 -0
- package/dist/domain/deposit-bounds.js +84 -0
- package/dist/domain/deposit-bounds.js.map +1 -0
- package/dist/domain/deposit-bounds.test.js +135 -0
- package/dist/domain/deposit-bounds.test.js.map +1 -0
- package/dist/domain/index.d.ts +26 -0
- package/dist/domain/index.js +23 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/domain/partners.d.ts +34 -0
- package/dist/domain/partners.js +43 -0
- package/dist/domain/partners.js.map +1 -0
- package/dist/domain/partners.test.d.ts +1 -0
- package/dist/domain/partners.test.js +53 -0
- package/dist/domain/partners.test.js.map +1 -0
- package/dist/domain/pools.d.ts +70 -0
- package/dist/domain/pools.js +134 -0
- package/dist/domain/pools.js.map +1 -0
- package/dist/domain/pools.test.d.ts +1 -0
- package/dist/domain/pools.test.js +184 -0
- package/dist/domain/pools.test.js.map +1 -0
- package/dist/domain/rates.d.ts +83 -0
- package/dist/domain/rates.js +120 -0
- package/dist/domain/rates.js.map +1 -0
- package/dist/domain/rates.test.d.ts +1 -0
- package/dist/domain/rates.test.js +181 -0
- package/dist/domain/rates.test.js.map +1 -0
- package/dist/domain/tranche-display-name.d.ts +17 -0
- package/dist/domain/tranche-display-name.js +30 -0
- package/dist/domain/tranche-display-name.js.map +1 -0
- package/dist/domain/tranche-display-name.test.d.ts +1 -0
- package/dist/domain/tranche-display-name.test.js +58 -0
- package/dist/domain/tranche-display-name.test.js.map +1 -0
- package/dist/domain/tranches.d.ts +89 -0
- package/dist/domain/tranches.js +150 -0
- package/dist/domain/tranches.js.map +1 -0
- package/dist/domain/tranches.test.d.ts +1 -0
- package/dist/domain/tranches.test.js +206 -0
- package/dist/domain/tranches.test.js.map +1 -0
- package/dist/facade/chain-configs.js +54 -0
- package/dist/facade/chain-configs.js.map +1 -1
- package/dist/facade/config.test.d.ts +1 -0
- package/dist/facade/config.test.js +216 -0
- package/dist/facade/config.test.js.map +1 -0
- package/dist/facade/deposits.d.ts +22 -1
- package/dist/facade/deposits.js +24 -1
- package/dist/facade/deposits.js.map +1 -1
- package/dist/facade/facade.test.d.ts +1 -0
- package/dist/{tests → facade}/facade.test.js +10 -5
- package/dist/facade/facade.test.js.map +1 -0
- package/dist/facade/index.d.ts +2 -1
- package/dist/facade/index.js +2 -0
- package/dist/facade/index.js.map +1 -1
- package/dist/facade/kasu.d.ts +49 -8
- package/dist/facade/kasu.js +117 -13
- package/dist/facade/kasu.js.map +1 -1
- package/dist/facade/strategies.d.ts +25 -0
- package/dist/facade/strategies.js +36 -0
- package/dist/facade/strategies.js.map +1 -1
- package/dist/facade/types.d.ts +57 -2
- package/dist/facade/unused-pool-ids.d.ts +31 -0
- package/dist/facade/unused-pool-ids.js +57 -0
- package/dist/facade/unused-pool-ids.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/sdk-config.d.ts +11 -0
- package/dist/sdk-config.js +8 -1
- package/dist/sdk-config.js.map +1 -1
- package/dist/services/DataService/data-service.d.ts +5 -0
- package/dist/services/DataService/data-service.js +7 -2
- package/dist/services/DataService/data-service.js.map +1 -1
- package/dist/services/Locking/calculate-apy.test.d.ts +1 -0
- package/dist/services/Locking/calculate-apy.test.js +41 -0
- package/dist/services/Locking/calculate-apy.test.js.map +1 -0
- package/dist/services/Locking/locking.d.ts +14 -0
- package/dist/services/Locking/locking.js +16 -2
- package/dist/services/Locking/locking.js.map +1 -1
- package/dist/tests/sample.test.js +46 -25
- package/dist/tests/sample.test.js.map +1 -1
- package/package.json +43 -12
- package/src/domain/deposit-bounds.test.ts +200 -0
- package/src/domain/deposit-bounds.ts +118 -0
- package/src/domain/index.ts +70 -0
- package/src/domain/partners.test.ts +83 -0
- package/src/domain/partners.ts +66 -0
- package/src/domain/pools.test.ts +260 -0
- package/src/domain/pools.ts +150 -0
- package/src/domain/rates.test.ts +254 -0
- package/src/domain/rates.ts +120 -0
- package/src/domain/tranche-display-name.test.ts +96 -0
- package/src/domain/tranche-display-name.ts +34 -0
- package/src/domain/tranches.test.ts +300 -0
- package/src/domain/tranches.ts +177 -0
- package/src/facade/chain-configs.ts +55 -0
- package/src/facade/config.test.ts +265 -0
- package/src/facade/deposits.ts +25 -0
- package/src/{tests → facade}/facade.test.ts +10 -5
- package/src/facade/index.ts +4 -0
- package/src/facade/kasu.ts +155 -12
- package/src/facade/strategies.ts +36 -0
- package/src/facade/types.ts +60 -2
- package/src/facade/unused-pool-ids.ts +59 -0
- package/src/index.ts +6 -0
- package/src/sdk-config.ts +19 -1
- package/src/services/DataService/data-service.ts +7 -2
- package/src/services/Locking/calculate-apy.test.ts +52 -0
- package/src/services/Locking/locking.ts +16 -2
- package/src/tests/sample.test.ts +54 -33
- package/.eslintignore +0 -3
- package/.eslintrc.json +0 -90
- package/.github/workflows/ci.yml +0 -39
- package/.github/workflows/gitleaks.yml +0 -22
- package/.github/workflows/main.yml +0 -41
- package/.gitleaksignore +0 -19
- package/.prettierignore +0 -3
- package/.prettierrc +0 -28
- package/CLAUDE.md +0 -410
- package/copy-abi.ps1 +0 -15
- package/dist/tests/facade.test.js.map +0 -1
- package/index.ts +0 -2
- package/jest-config.ts +0 -9
- package/jest.config.js +0 -8
- package/rollup.config.js +0 -45
- package/tsconfig.json +0 -27
- /package/dist/{tests/facade.test.d.ts → domain/deposit-bounds.test.d.ts} +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { PoolOverview, TrancheData } from '../services/DataService/types';
|
|
2
|
+
|
|
3
|
+
import { netEffectiveApy } from './rates';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimum remaining capacity (in stable-asset units) for a tranche to be
|
|
7
|
+
* selectable. Mirrors `MIN_CAPACITY = toBigNumber('1')` in kasu-fe-next's
|
|
8
|
+
* lending modal after its `formatEther`. Anything below this is rounding
|
|
9
|
+
* noise — the SDK's own capacity arithmetic routinely leaves sub-cent dust
|
|
10
|
+
* behind.
|
|
11
|
+
*/
|
|
12
|
+
export const MIN_TRANCHE_CAPACITY = 1;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Remaining capacity as it reaches this layer. `TrancheData` (raw, from the
|
|
16
|
+
* data service) calls it `poolCapacity`; the facade's `StrategyTranche` calls
|
|
17
|
+
* the same number `availableCapacity`. Accepting both keeps ONE capacity gate
|
|
18
|
+
* for the whole SDK rather than a duplicate per shape.
|
|
19
|
+
*/
|
|
20
|
+
export type TrancheCapacitySignal =
|
|
21
|
+
| { poolCapacity: string }
|
|
22
|
+
| { availableCapacity: string };
|
|
23
|
+
|
|
24
|
+
export function trancheHasCapacity(tranche: TrancheCapacitySignal): boolean {
|
|
25
|
+
const raw =
|
|
26
|
+
'poolCapacity' in tranche
|
|
27
|
+
? tranche.poolCapacity
|
|
28
|
+
: tranche.availableCapacity;
|
|
29
|
+
const remaining = parseFloat(raw);
|
|
30
|
+
return Number.isFinite(remaining) && remaining >= MIN_TRANCHE_CAPACITY;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whole-strategy capacity gate: true when the pool has tranches but none of
|
|
35
|
+
* them has remaining capacity. Drives a lend form's full-capacity message and
|
|
36
|
+
* its step-counter suppression.
|
|
37
|
+
*/
|
|
38
|
+
export function poolAllTranchesFull(pool: PoolOverview): boolean {
|
|
39
|
+
return pool.tranches.length > 0 && !pool.tranches.some(trancheHasCapacity);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type PoolStatus = 'Live' | 'Full' | 'Coming soon';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The status CODE for a pool. A code, not copy: consumers map it to their own
|
|
46
|
+
* wording and locale.
|
|
47
|
+
*/
|
|
48
|
+
export function derivePoolStatus(pool: PoolOverview): PoolStatus {
|
|
49
|
+
if (!pool.enabled) return 'Coming soon';
|
|
50
|
+
if (poolAllTranchesFull(pool)) return 'Full';
|
|
51
|
+
return 'Live';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Seniority rank of a tranche by name — LOWER is less risky. The credit
|
|
56
|
+
* waterfall is Senior (repaid first, lowest risk) → Mezzanine → Junior
|
|
57
|
+
* (repaid last, highest risk). APY is NOT a reliable proxy (a pool can price
|
|
58
|
+
* its Senior tranche above its Mezzanine), so risk is ranked by name.
|
|
59
|
+
* Unrecognised names sort last, so a known-safe tranche always wins the
|
|
60
|
+
* default. Ranks by the RAW subgraph name — the Apxium "Upper Mezzanine"
|
|
61
|
+
* rename is display-only and must never reach here (see
|
|
62
|
+
* `tranche-display-name.ts`).
|
|
63
|
+
*/
|
|
64
|
+
const TRANCHE_RISK_RANK: Record<string, number | undefined> = {
|
|
65
|
+
senior: 0,
|
|
66
|
+
mezzanine: 1,
|
|
67
|
+
junior: 2,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// `name` is typed `string` on `TrancheData` but arrives from the subgraph, so
|
|
71
|
+
// it is taken as possibly missing here: an unnamed tranche ranks last rather
|
|
72
|
+
// than throwing on `.trim()`.
|
|
73
|
+
function riskRankOfName(name: string | undefined): number {
|
|
74
|
+
return (
|
|
75
|
+
TRANCHE_RISK_RANK[name?.trim().toLowerCase() ?? ''] ??
|
|
76
|
+
Number.POSITIVE_INFINITY
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function trancheRiskRank(tranche: TrancheData): number {
|
|
81
|
+
return riskRankOfName(tranche.name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Comparator that orders tranches SAFEST-FIRST (Senior → Mezzanine → Junior)
|
|
86
|
+
* by the same name-based risk rank the default-pick uses. Ranks by the RAW
|
|
87
|
+
* subgraph name, so the Apxium "Upper Mezzanine" display rename never reaches
|
|
88
|
+
* here. Ties (incl. unrecognised names, which both rank last) keep their input
|
|
89
|
+
* order under a stable sort. Used by a lend dropdown to list the least-risky
|
|
90
|
+
* option first.
|
|
91
|
+
*/
|
|
92
|
+
export function compareTrancheSeniority(a: TrancheData, b: TrancheData): number {
|
|
93
|
+
const ra = trancheRiskRank(a);
|
|
94
|
+
const rb = trancheRiskRank(b);
|
|
95
|
+
if (ra === rb) return 0;
|
|
96
|
+
return ra < rb ? -1 : 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Capacity-aware default tranche pick: the LOWEST-risk tranche that still has
|
|
101
|
+
* capacity, so a new lender lands on the safest available option. Riskier
|
|
102
|
+
* tranches remain a deliberate opt-in — we must never pre-select the
|
|
103
|
+
* highest-risk Junior (audit 3.2). When every tranche is full we fall back to
|
|
104
|
+
* the lowest-risk one regardless, so a form still mounts in a known state
|
|
105
|
+
* (the dropdown marks it full and the submit gate blocks progress).
|
|
106
|
+
*/
|
|
107
|
+
export function pickDefaultTrancheId(pool: PoolOverview): string {
|
|
108
|
+
const withCapacity = pool.tranches.filter(trancheHasCapacity);
|
|
109
|
+
const candidates = withCapacity.length > 0 ? withCapacity : pool.tranches;
|
|
110
|
+
if (candidates.length === 0) return '';
|
|
111
|
+
return candidates.reduce((best, t) =>
|
|
112
|
+
trancheRiskRank(t) < trancheRiskRank(best) ? t : best,
|
|
113
|
+
).id;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A closed APY range, both bounds as 0..1 fractions. */
|
|
117
|
+
export interface ApyBounds {
|
|
118
|
+
min: number;
|
|
119
|
+
max: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* GROSS APY range across a pool's tranches — the numeric core of kasu-ui's
|
|
124
|
+
* `formatTrancheApyRange`. Each `TrancheData` carries `minApy`/`maxApy`
|
|
125
|
+
* derived from the base rate plus any fixed-term configs (`data-service.ts`).
|
|
126
|
+
*
|
|
127
|
+
* Zero and negative values are skipped — an unset/missing rate (e.g. a tranche
|
|
128
|
+
* with no fixed-term config) must not drag the range down to `0–10.5%`.
|
|
129
|
+
* `null` when nothing usable remains, which is the caller's cue to render its
|
|
130
|
+
* "no rate" state rather than a confident zero.
|
|
131
|
+
*
|
|
132
|
+
* The min is taken over `minApy` and the max over `maxApy`, so a single
|
|
133
|
+
* unusable side collapses the whole range to `null` rather than half a range.
|
|
134
|
+
*
|
|
135
|
+
* Formatting is NOT here. The rule that two bounds printing the same figure
|
|
136
|
+
* collapse to one is decided on the rendered digits, so it belongs with the
|
|
137
|
+
* formatter that produces them (kasu-ui `format-tranche-apy.ts`).
|
|
138
|
+
*/
|
|
139
|
+
export function trancheApyBounds(tranches: TrancheData[]): ApyBounds | null {
|
|
140
|
+
if (tranches.length === 0) return null;
|
|
141
|
+
let lo = Number.POSITIVE_INFINITY;
|
|
142
|
+
let hi = Number.NEGATIVE_INFINITY;
|
|
143
|
+
for (const t of tranches) {
|
|
144
|
+
const min = parseFloat(t.minApy);
|
|
145
|
+
const max = parseFloat(t.maxApy);
|
|
146
|
+
if (Number.isFinite(min) && min > 0) lo = Math.min(lo, min);
|
|
147
|
+
if (Number.isFinite(max) && max > 0) hi = Math.max(hi, max);
|
|
148
|
+
}
|
|
149
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return null;
|
|
150
|
+
return { min: lo, max: hi };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The same range as NET Effective Interest Rates — each bound through
|
|
155
|
+
* `netEffectiveApy`.
|
|
156
|
+
*
|
|
157
|
+
* FAIL CLOSED: `null` when either bound comes back non-finite or ≤ 0, so an
|
|
158
|
+
* out-of-domain fee or a rate-less pool can never be rendered as a confident
|
|
159
|
+
* figure. A caller that does not yet know the fee must not substitute `0` —
|
|
160
|
+
* a fee-less rate overstates the figure by up to 3.4pp with nothing on screen
|
|
161
|
+
* saying so; it should skip the call and render its unavailable state.
|
|
162
|
+
*
|
|
163
|
+
* @param feePercent the chain's performance fee, 0..100 — never a fraction.
|
|
164
|
+
* See `netEffectiveApy` for why the units matter.
|
|
165
|
+
*/
|
|
166
|
+
export function netTrancheApyBounds(
|
|
167
|
+
tranches: TrancheData[],
|
|
168
|
+
feePercent: number,
|
|
169
|
+
): ApyBounds | null {
|
|
170
|
+
const gross = trancheApyBounds(tranches);
|
|
171
|
+
if (!gross) return null;
|
|
172
|
+
const min = netEffectiveApy(gross.min, feePercent);
|
|
173
|
+
const max = netEffectiveApy(gross.max, feePercent);
|
|
174
|
+
if (!Number.isFinite(min) || min <= 0) return null;
|
|
175
|
+
if (!Number.isFinite(max) || max <= 0) return null;
|
|
176
|
+
return { min, max };
|
|
177
|
+
}
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { ChainConfigEntry } from './types';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Browser-viable XDC RPCs only. `rpc.xdc.org` / `erpc.xdc.org` answer OPTIONS
|
|
5
|
+
* without `access-control-allow-origin`, so a browser call hangs on preflight
|
|
6
|
+
* — they must never appear here. The four below ship CORS `*` and are DISTINCT
|
|
7
|
+
* operators, so one vendor incident cannot exhaust the list. Order is a
|
|
8
|
+
* STARTING preference only; consumers are expected to fold the list into their
|
|
9
|
+
* own failover. Shared by both XDC deployments (same chain, same endpoints).
|
|
10
|
+
*/
|
|
11
|
+
const XDC_RPC_URLS: string[] = [
|
|
12
|
+
'https://rpc.ankr.com/xdc',
|
|
13
|
+
'https://rpc.xdcrpc.com',
|
|
14
|
+
'https://rpc.xinfin.network',
|
|
15
|
+
'https://rpc.primenumbers.xyz/',
|
|
16
|
+
];
|
|
17
|
+
|
|
3
18
|
/**
|
|
4
19
|
* Built-in chain configurations for Kasu-supported networks.
|
|
5
20
|
*
|
|
@@ -33,6 +48,17 @@ export const CHAIN_CONFIGS: Record<'base' | 'xdc' | 'xdc-usdc' | 'plume', ChainC
|
|
|
33
48
|
directusUrl: 'https://kasu-finance.directus.app/',
|
|
34
49
|
unusedPoolIds: [],
|
|
35
50
|
poolMetadataMapping: undefined,
|
|
51
|
+
stableAsset: {
|
|
52
|
+
address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
53
|
+
symbol: 'USDC',
|
|
54
|
+
name: 'USD Coin',
|
|
55
|
+
decimals: 6,
|
|
56
|
+
currencyCode: 'USD',
|
|
57
|
+
},
|
|
58
|
+
rpcUrls: [
|
|
59
|
+
'https://base-rpc.publicnode.com',
|
|
60
|
+
'https://mainnet.base.org',
|
|
61
|
+
],
|
|
36
62
|
},
|
|
37
63
|
|
|
38
64
|
xdc: {
|
|
@@ -65,6 +91,14 @@ export const CHAIN_CONFIGS: Record<'base' | 'xdc' | 'xdc-usdc' | 'plume', ChainC
|
|
|
65
91
|
'0xeda50c91a8c4ca8a83652b8542c0b3bd00a71fad':
|
|
66
92
|
'0xc347a9e4aec8c8d11a149d2907deb2bf23b81c6f',
|
|
67
93
|
},
|
|
94
|
+
stableAsset: {
|
|
95
|
+
address: '0x9fe4e6321eeb7c4bc537570f015e4734b15002b8',
|
|
96
|
+
symbol: 'AUDD',
|
|
97
|
+
name: 'Novatti Australian Dollar',
|
|
98
|
+
decimals: 6,
|
|
99
|
+
currencyCode: 'AUD',
|
|
100
|
+
},
|
|
101
|
+
rpcUrls: XDC_RPC_URLS,
|
|
68
102
|
},
|
|
69
103
|
|
|
70
104
|
'xdc-usdc': {
|
|
@@ -90,6 +124,14 @@ export const CHAIN_CONFIGS: Record<'base' | 'xdc' | 'xdc-usdc' | 'plume', ChainC
|
|
|
90
124
|
directusUrl: 'https://kasu-finance.directus.app/',
|
|
91
125
|
unusedPoolIds: [],
|
|
92
126
|
poolMetadataMapping: undefined,
|
|
127
|
+
stableAsset: {
|
|
128
|
+
address: '0xfa2958cb79b0491cc627c1557f441ef849ca8eb1',
|
|
129
|
+
symbol: 'USDC',
|
|
130
|
+
name: 'USD Coin',
|
|
131
|
+
decimals: 6,
|
|
132
|
+
currencyCode: 'USD',
|
|
133
|
+
},
|
|
134
|
+
rpcUrls: XDC_RPC_URLS,
|
|
93
135
|
},
|
|
94
136
|
|
|
95
137
|
plume: {
|
|
@@ -114,5 +156,18 @@ export const CHAIN_CONFIGS: Record<'base' | 'xdc' | 'xdc-usdc' | 'plume', ChainC
|
|
|
114
156
|
directusUrl: 'https://kasu-finance.directus.app/',
|
|
115
157
|
unusedPoolIds: [],
|
|
116
158
|
poolMetadataMapping: undefined,
|
|
159
|
+
stableAsset: {
|
|
160
|
+
address: '0xdddD73F5Df1F0DC31373357beAC77545dC5A6f3F',
|
|
161
|
+
symbol: 'pUSD',
|
|
162
|
+
name: 'Plume USD',
|
|
163
|
+
decimals: 6,
|
|
164
|
+
currencyCode: 'USD',
|
|
165
|
+
},
|
|
166
|
+
// Retired — drained and wound down. No default RPC, so a read-only
|
|
167
|
+
// `Kasu.create({ chain: 'plume' })` throws rather than quietly
|
|
168
|
+
// pointing at an endpoint nobody maintains. Pass your own
|
|
169
|
+
// `signerOrProvider` to read the frozen history.
|
|
170
|
+
rpcUrls: [],
|
|
171
|
+
retired: true,
|
|
117
172
|
},
|
|
118
173
|
};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { providers, Wallet } from 'ethers';
|
|
2
|
+
|
|
3
|
+
import { SdkConfig } from '../sdk-config';
|
|
4
|
+
|
|
5
|
+
import { CHAIN_CONFIGS } from './chain-configs';
|
|
6
|
+
import { Kasu } from './kasu';
|
|
7
|
+
import { ChainConfigEntry } from './types';
|
|
8
|
+
|
|
9
|
+
// Pure config assertions — no network, unlike `facade.test.ts`.
|
|
10
|
+
|
|
11
|
+
const BASE_CONTRACTS = CHAIN_CONFIGS.base.contracts;
|
|
12
|
+
|
|
13
|
+
/** A signer with a provider attached, offline — nothing here sends anything. */
|
|
14
|
+
function signer(): Wallet {
|
|
15
|
+
return Wallet.createRandom().connect(
|
|
16
|
+
new providers.StaticJsonRpcProvider(
|
|
17
|
+
CHAIN_CONFIGS.base.rpcUrls[0],
|
|
18
|
+
CHAIN_CONFIGS.base.chainId,
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('SdkConfig — UNUSED_LENDING_POOL_IDS normalisation', () => {
|
|
24
|
+
it("turns an empty exclusion list into the [''] sentinel", () => {
|
|
25
|
+
// `id_not_in: []` matches NOTHING in the subgraph: on Base it returns
|
|
26
|
+
// 0 pools where `['']` returns all 9 (verified live 2026-09-04).
|
|
27
|
+
const config = new SdkConfig({
|
|
28
|
+
subgraphUrl: 'https://example.invalid/subgraph',
|
|
29
|
+
contracts: BASE_CONTRACTS,
|
|
30
|
+
UNUSED_LENDING_POOL_IDS: [],
|
|
31
|
+
});
|
|
32
|
+
expect(config.UNUSED_LENDING_POOL_IDS).toEqual(['']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('leaves a caller-supplied exclusion list untouched', () => {
|
|
36
|
+
const config = new SdkConfig({
|
|
37
|
+
subgraphUrl: 'https://example.invalid/subgraph',
|
|
38
|
+
contracts: BASE_CONTRACTS,
|
|
39
|
+
UNUSED_LENDING_POOL_IDS: ['0xdead'],
|
|
40
|
+
});
|
|
41
|
+
expect(config.UNUSED_LENDING_POOL_IDS).toEqual(['0xdead']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('leaves a sentinel a consumer already passes untouched', () => {
|
|
45
|
+
// kasu-ui passes [''], kasu-mobile passes the zero address.
|
|
46
|
+
const zeroAddress = '0x0000000000000000000000000000000000000000';
|
|
47
|
+
for (const sentinel of [[''], [zeroAddress]]) {
|
|
48
|
+
const config = new SdkConfig({
|
|
49
|
+
subgraphUrl: 'https://example.invalid/subgraph',
|
|
50
|
+
contracts: BASE_CONTRACTS,
|
|
51
|
+
UNUSED_LENDING_POOL_IDS: sentinel,
|
|
52
|
+
});
|
|
53
|
+
expect(config.UNUSED_LENDING_POOL_IDS).toEqual(sentinel);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('CHAIN_CONFIGS — stable asset', () => {
|
|
59
|
+
it('gives every chain exactly one stable asset', () => {
|
|
60
|
+
for (const [chain, config] of Object.entries(CHAIN_CONFIGS)) {
|
|
61
|
+
expect(config.stableAsset.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
|
62
|
+
expect(config.stableAsset.symbol.length).toBeGreaterThan(0);
|
|
63
|
+
expect(config.stableAsset.decimals).toBeGreaterThan(0);
|
|
64
|
+
expect(config.stableAsset.currencyCode).toMatch(/^[A-Z]{3}$/);
|
|
65
|
+
expect(chain).toBeTruthy();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('carries the live token per deployment', () => {
|
|
70
|
+
expect(CHAIN_CONFIGS.base.stableAsset).toEqual({
|
|
71
|
+
address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
72
|
+
symbol: 'USDC',
|
|
73
|
+
name: 'USD Coin',
|
|
74
|
+
decimals: 6,
|
|
75
|
+
currencyCode: 'USD',
|
|
76
|
+
});
|
|
77
|
+
expect(CHAIN_CONFIGS.xdc.stableAsset.symbol).toBe('AUDD');
|
|
78
|
+
expect(CHAIN_CONFIGS.xdc.stableAsset.currencyCode).toBe('AUD');
|
|
79
|
+
expect(CHAIN_CONFIGS['xdc-usdc'].stableAsset.symbol).toBe('USDC');
|
|
80
|
+
expect(CHAIN_CONFIGS.plume.stableAsset.symbol).toBe('pUSD');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('CHAIN_CONFIGS — rpcUrls', () => {
|
|
85
|
+
it('gives every live deployment at least one default endpoint', () => {
|
|
86
|
+
for (const chain of ['base', 'xdc', 'xdc-usdc'] as const) {
|
|
87
|
+
expect(CHAIN_CONFIGS[chain].rpcUrls.length).toBeGreaterThan(0);
|
|
88
|
+
expect(CHAIN_CONFIGS[chain].retired).toBeUndefined();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('never lists an XDC endpoint that fails CORS preflight', () => {
|
|
93
|
+
// `rpc.xdc.org` / `erpc.xdc.org` answer OPTIONS without
|
|
94
|
+
// `access-control-allow-origin`, so a browser call hangs. House rule.
|
|
95
|
+
for (const config of Object.values(CHAIN_CONFIGS)) {
|
|
96
|
+
for (const url of config.rpcUrls) {
|
|
97
|
+
expect(url).not.toContain('rpc.xdc.org');
|
|
98
|
+
expect(url).not.toContain('erpc.xdc.org');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('marks the retired deployment and leaves it without a default RPC', () => {
|
|
104
|
+
expect(CHAIN_CONFIGS.plume.retired).toBe(true);
|
|
105
|
+
expect(CHAIN_CONFIGS.plume.rpcUrls).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('Kasu — read-only create and connect', () => {
|
|
110
|
+
it('creates a read-only instance with no signerOrProvider', () => {
|
|
111
|
+
const kasu = Kasu.create({ chain: 'base' });
|
|
112
|
+
expect(kasu.isReadOnly).toBe(true);
|
|
113
|
+
expect(kasu.provider).toBeInstanceOf(providers.StaticJsonRpcProvider);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('uses rpcUrls[0] and the config chain id, with no network detection', () => {
|
|
117
|
+
const kasu = Kasu.create({ chain: 'base' });
|
|
118
|
+
const provider = kasu.provider as providers.StaticJsonRpcProvider;
|
|
119
|
+
expect(provider.connection.url).toBe(CHAIN_CONFIGS.base.rpcUrls[0]);
|
|
120
|
+
expect(provider.network.chainId).toBe(CHAIN_CONFIGS.base.chainId);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('refuses a read-only create on a retired chain with no default RPC', () => {
|
|
124
|
+
expect(() => Kasu.create({ chain: 'plume' })).toThrow(
|
|
125
|
+
'Kasu.create: chain "plume" has no default RPC (retired); pass signerOrProvider',
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('still accepts an explicit provider for the retired chain', () => {
|
|
130
|
+
const provider = new providers.StaticJsonRpcProvider(
|
|
131
|
+
'https://example.invalid/plume',
|
|
132
|
+
CHAIN_CONFIGS.plume.chainId,
|
|
133
|
+
);
|
|
134
|
+
const kasu = Kasu.create({ chain: 'plume', signerOrProvider: provider });
|
|
135
|
+
expect(kasu.isReadOnly).toBe(true);
|
|
136
|
+
expect(kasu.provider).toBe(provider);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('is writable when created from a signer', () => {
|
|
140
|
+
const kasu = Kasu.create({ chain: 'base', signerOrProvider: signer() });
|
|
141
|
+
expect(kasu.isReadOnly).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('connect returns a NEW writable instance, leaving the original read-only', () => {
|
|
145
|
+
const readOnly = Kasu.create({ chain: 'base' });
|
|
146
|
+
const connected = readOnly.connect(signer());
|
|
147
|
+
expect(connected).not.toBe(readOnly);
|
|
148
|
+
expect(connected.isReadOnly).toBe(false);
|
|
149
|
+
expect(readOnly.isReadOnly).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('connect keeps the chain config and the configOverrides', () => {
|
|
153
|
+
const readOnly = Kasu.create({
|
|
154
|
+
chain: 'base',
|
|
155
|
+
configOverrides: { UNUSED_LENDING_POOL_IDS: ['0xhidden'] },
|
|
156
|
+
});
|
|
157
|
+
const connected = readOnly.connect(signer());
|
|
158
|
+
expect(connected.chainConfig).toBe(readOnly.chainConfig);
|
|
159
|
+
const configOf = (kasu: Kasu): SdkConfig =>
|
|
160
|
+
(kasu.services.DataService as unknown as { _kasuConfig: SdkConfig })
|
|
161
|
+
._kasuConfig;
|
|
162
|
+
expect(configOf(connected).UNUSED_LENDING_POOL_IDS).toEqual([
|
|
163
|
+
'0xhidden',
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('exposes the signer’s own provider on a connected instance', () => {
|
|
168
|
+
const wallet = signer();
|
|
169
|
+
const kasu = Kasu.create({ chain: 'base', signerOrProvider: wallet });
|
|
170
|
+
expect(kasu.provider).toBe(wallet.provider);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('throws rather than returning undefined for a provider-less signer', () => {
|
|
174
|
+
const kasu = Kasu.create({
|
|
175
|
+
chain: 'base',
|
|
176
|
+
signerOrProvider: Wallet.createRandom(),
|
|
177
|
+
});
|
|
178
|
+
expect(() => kasu.provider).toThrow('has no provider attached');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('DepositsFacade — read-only writes', () => {
|
|
183
|
+
const readOnly = (): Kasu => Kasu.create({ chain: 'base' });
|
|
184
|
+
const READ_ONLY_MESSAGE =
|
|
185
|
+
'Kasu: this instance is read-only; call kasu.connect(signer) first';
|
|
186
|
+
|
|
187
|
+
it('refuses deposit before touching the contract', async () => {
|
|
188
|
+
await expect(
|
|
189
|
+
readOnly().deposits.deposit({
|
|
190
|
+
poolId: '0xpool',
|
|
191
|
+
trancheId: '0xtranche',
|
|
192
|
+
amount: 1,
|
|
193
|
+
kycSignature: { blockExpiration: 0, signature: '0x' },
|
|
194
|
+
}),
|
|
195
|
+
).rejects.toThrow(READ_ONLY_MESSAGE);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('refuses withdraw', async () => {
|
|
199
|
+
await expect(
|
|
200
|
+
readOnly().deposits.withdraw({
|
|
201
|
+
poolId: '0xpool',
|
|
202
|
+
trancheId: '0xtranche',
|
|
203
|
+
amount: 1,
|
|
204
|
+
}),
|
|
205
|
+
).rejects.toThrow(READ_ONLY_MESSAGE);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('refuses withdrawMax', async () => {
|
|
209
|
+
await expect(
|
|
210
|
+
readOnly().deposits.withdrawMax('0xpool', '0xtranche', '0xuser'),
|
|
211
|
+
).rejects.toThrow(READ_ONLY_MESSAGE);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('lets a connected instance past the guard', () => {
|
|
215
|
+
const connected = readOnly().connect(signer());
|
|
216
|
+
// Reaches the contract call (and fails there on a fake address) rather
|
|
217
|
+
// than being refused up front. Probing the guard directly keeps the
|
|
218
|
+
// assertion off the network.
|
|
219
|
+
const guard = connected.deposits as unknown as {
|
|
220
|
+
assertWritable(): void;
|
|
221
|
+
};
|
|
222
|
+
expect(() => {
|
|
223
|
+
guard.assertWritable();
|
|
224
|
+
}).not.toThrow();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('Kasu.create — a ChainConfigEntry written before 2.5.0', () => {
|
|
229
|
+
// kasu-app-admin hand-builds one of these for base-sepolia. TypeScript will
|
|
230
|
+
// ask it for `stableAsset` and `rpcUrls` on bump; the runtime must not
|
|
231
|
+
// crash first.
|
|
232
|
+
const legacy = (): ChainConfigEntry =>
|
|
233
|
+
({
|
|
234
|
+
chainId: 84532,
|
|
235
|
+
name: 'base-sepolia',
|
|
236
|
+
isLiteDeployment: false,
|
|
237
|
+
contracts: BASE_CONTRACTS,
|
|
238
|
+
subgraphUrl: 'https://example.invalid/subgraph',
|
|
239
|
+
// Non-empty: `SdkConfig` documents `directusUrl` as optional, but
|
|
240
|
+
// `createDirectus('')` throws `TypeError: Invalid URL`. Pre-existing,
|
|
241
|
+
// and unrelated to what this test covers.
|
|
242
|
+
directusUrl: 'https://example.invalid/directus/',
|
|
243
|
+
unusedPoolIds: [''],
|
|
244
|
+
}) as unknown as ChainConfigEntry;
|
|
245
|
+
|
|
246
|
+
it('constructs with an explicit provider and falls back to 6 decimals', () => {
|
|
247
|
+
const kasu = Kasu.create({
|
|
248
|
+
chain: legacy(),
|
|
249
|
+
signerOrProvider: new providers.StaticJsonRpcProvider(
|
|
250
|
+
'https://example.invalid/rpc',
|
|
251
|
+
84532,
|
|
252
|
+
),
|
|
253
|
+
});
|
|
254
|
+
const config = (
|
|
255
|
+
kasu.services.DataService as unknown as { _kasuConfig: SdkConfig }
|
|
256
|
+
)._kasuConfig;
|
|
257
|
+
expect(config.stableAssetDecimals).toBe(6);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('refuses a read-only create with the same message a retired chain gives', () => {
|
|
261
|
+
expect(() => Kasu.create({ chain: legacy() })).toThrow(
|
|
262
|
+
'Kasu.create: chain "base-sepolia" has no default RPC (retired); pass signerOrProvider',
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
});
|
package/src/facade/deposits.ts
CHANGED
|
@@ -14,8 +14,30 @@ export class DepositsFacade {
|
|
|
14
14
|
constructor(
|
|
15
15
|
private _userLending: UserLending,
|
|
16
16
|
private _chainId: string,
|
|
17
|
+
/**
|
|
18
|
+
* True when the SDK holds a Provider rather than a Signer. Optional and
|
|
19
|
+
* defaulting to false so constructing this facade directly keeps
|
|
20
|
+
* working; `Kasu` always passes it.
|
|
21
|
+
*/
|
|
22
|
+
private _isReadOnly = false,
|
|
17
23
|
) {}
|
|
18
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Refuse a write BEFORE touching the contract.
|
|
27
|
+
*
|
|
28
|
+
* ethers would throw its own "sending a transaction requires a signer" a
|
|
29
|
+
* few frames deeper, after the params have been encoded — a message that
|
|
30
|
+
* says nothing about how to get a signer onto THIS object. Failing here
|
|
31
|
+
* names the fix.
|
|
32
|
+
*/
|
|
33
|
+
private assertWritable(): void {
|
|
34
|
+
if (this._isReadOnly) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'Kasu: this instance is read-only; call kasu.connect(signer) first',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
19
41
|
/**
|
|
20
42
|
* Submit a deposit request.
|
|
21
43
|
*
|
|
@@ -34,6 +56,7 @@ export class DepositsFacade {
|
|
|
34
56
|
* ```
|
|
35
57
|
*/
|
|
36
58
|
async deposit(params: DepositParams): Promise<ContractTransaction> {
|
|
59
|
+
this.assertWritable();
|
|
37
60
|
return await this._userLending.requestDepositWithKyc(
|
|
38
61
|
params.poolId,
|
|
39
62
|
params.trancheId,
|
|
@@ -50,6 +73,7 @@ export class DepositsFacade {
|
|
|
50
73
|
* Submit a withdrawal request for a specific stable asset amount.
|
|
51
74
|
*/
|
|
52
75
|
async withdraw(params: WithdrawParams): Promise<ContractTransaction> {
|
|
76
|
+
this.assertWritable();
|
|
53
77
|
return await this._userLending.requestWithdrawalInAsset(
|
|
54
78
|
params.poolId,
|
|
55
79
|
params.trancheId,
|
|
@@ -65,6 +89,7 @@ export class DepositsFacade {
|
|
|
65
89
|
trancheId: string,
|
|
66
90
|
userAddress: string,
|
|
67
91
|
): Promise<ContractTransaction> {
|
|
92
|
+
this.assertWritable();
|
|
68
93
|
return await this._userLending.requestWithdrawalMax(
|
|
69
94
|
poolId,
|
|
70
95
|
trancheId,
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Offline facade specs — a mock provider and pure helpers, no network. Moved
|
|
3
|
+
* here from `src/tests/` so it runs beside the code it covers, and so the
|
|
4
|
+
* opt-in `LIVE_TESTS` gate on `src/tests/` does not take it out of CI.
|
|
5
|
+
*/
|
|
6
|
+
import { CHAIN_CONFIGS } from './chain-configs';
|
|
7
|
+
import { DepositsFacade } from './deposits';
|
|
8
|
+
import { Kasu } from './kasu';
|
|
9
|
+
import { StrategiesFacade } from './strategies';
|
|
10
|
+
import { PortfolioFacade } from './user-portfolio';
|
|
6
11
|
|
|
7
12
|
// ---------------------------------------------------------------------------
|
|
8
13
|
// Chain configs
|
package/src/facade/index.ts
CHANGED
|
@@ -9,10 +9,14 @@ export { PortfolioFacade } from './user-portfolio';
|
|
|
9
9
|
// Chain configurations
|
|
10
10
|
export { CHAIN_CONFIGS } from './chain-configs';
|
|
11
11
|
|
|
12
|
+
// Directus-backed helpers (I/O — not part of `domain/`)
|
|
13
|
+
export { fetchUnusedPoolIds } from './unused-pool-ids';
|
|
14
|
+
|
|
12
15
|
// All facade types
|
|
13
16
|
export type {
|
|
14
17
|
SupportedChain,
|
|
15
18
|
ChainConfigEntry,
|
|
19
|
+
StableAsset,
|
|
16
20
|
KasuOptions,
|
|
17
21
|
Strategy,
|
|
18
22
|
StrategyTranche,
|