@lendwise/mcp 0.1.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/README.md +76 -0
- package/dist/bin/stdio.d.ts +2 -0
- package/dist/bin/stdio.js +18 -0
- package/dist/core/config.d.ts +18 -0
- package/dist/core/config.js +19 -0
- package/dist/core/graphql/client.d.ts +3 -0
- package/dist/core/graphql/client.js +14 -0
- package/dist/core/graphql/queries.d.ts +133 -0
- package/dist/core/graphql/queries.js +207 -0
- package/dist/core/http.d.ts +20 -0
- package/dist/core/http.js +72 -0
- package/dist/core/optimizer.d.ts +58 -0
- package/dist/core/optimizer.js +77 -0
- package/dist/core/server.d.ts +12 -0
- package/dist/core/server.js +115 -0
- package/dist/core/stats.d.ts +24 -0
- package/dist/core/stats.js +31 -0
- package/dist/core/tools/find-best-markets.d.ts +32 -0
- package/dist/core/tools/find-best-markets.js +57 -0
- package/dist/core/tools/get-market-details.d.ts +74 -0
- package/dist/core/tools/get-market-details.js +70 -0
- package/dist/core/tools/get-market-history.d.ts +44 -0
- package/dist/core/tools/get-market-history.js +58 -0
- package/dist/core/tools/list-market-universe.d.ts +31 -0
- package/dist/core/tools/list-market-universe.js +33 -0
- package/dist/core/tools/optimize-allocation.d.ts +42 -0
- package/dist/core/tools/optimize-allocation.js +96 -0
- package/dist/core/tools/shared.d.ts +28 -0
- package/dist/core/tools/shared.js +41 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @lendwise/mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [Lendwise](https://lendwise.fi) — compare DeFi supply markets across **Aave V3**, **Morpho** and **Compound V3** on 27 standardized chains (~700 products, ~120 assets).
|
|
4
|
+
|
|
5
|
+
It answers questions like *"I have $1,000 to place in DeFi for the next 6 months — what are the best markets?"* against real yield data, in about four tool calls.
|
|
6
|
+
|
|
7
|
+
Read-only. It recommends; it never signs a transaction.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```jsonc
|
|
12
|
+
// claude_desktop_config.json / .mcp.json
|
|
13
|
+
{
|
|
14
|
+
"mcpServers": {
|
|
15
|
+
"lendwise": {
|
|
16
|
+
"command": "npx",
|
|
17
|
+
"args": ["-y", "@lendwise/mcp"]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
No API key. The server holds no secrets — it speaks only HTTPS to the public Lendwise API.
|
|
24
|
+
|
|
25
|
+
## Tools
|
|
26
|
+
|
|
27
|
+
| tool | what it's for |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `list_market_universe` | Every asset, chain and protocol that actually exists, with counts. **Call this first** — it's what stops an agent guessing a filter value that isn't there. |
|
|
30
|
+
| `find_best_markets` | Current supply markets ranked by net APY. Filtering and sorting happen server-side. Defaults to ≥ $1M TVL. |
|
|
31
|
+
| `get_market_details` | One market in full: protocol metadata, collaterals, APY split into base / rewards / fees. |
|
|
32
|
+
| `get_market_history` | Daily net-APY series **plus mean / stddev / min / max** — the stability signal a long horizon needs. |
|
|
33
|
+
| `optimize_allocation` | Split an amount across markets at a target diversification. Returns per-market amounts, blended APY, projected 6-month yield. |
|
|
34
|
+
|
|
35
|
+
### Why the TVL floor exists
|
|
36
|
+
|
|
37
|
+
`find_best_markets` defaults to `minTvlUsd: 1_000_000`. In a thin market a headline APY is mostly noise, and steering someone with $1k into one is the most plausible real-world harm this server can do. Lower it deliberately, not by accident.
|
|
38
|
+
|
|
39
|
+
### Why `get_market_history` returns statistics, not just a series
|
|
40
|
+
|
|
41
|
+
A snapshot cannot tell a durable 6% from a 12% that is a reward programme ending next week. A 180-day standard deviation can. That is the number a 6-month decision actually turns on.
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
| env var | default | purpose |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| `LENDWISE_API_URL` | `https://lendwise.fi` | Point at `http://localhost:3000` to develop against a local `lendwise/web`. |
|
|
48
|
+
| `LENDWISE_INTEGRATION` | unset | Set to `1` to run the network integration tests. |
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pnpm install
|
|
54
|
+
pnpm typecheck
|
|
55
|
+
pnpm test # unit tests, hermetic
|
|
56
|
+
LENDWISE_INTEGRATION=1 pnpm test # + live API tests
|
|
57
|
+
pnpm build
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### The one invariant to not break
|
|
61
|
+
|
|
62
|
+
The optimizer's contract is **positional**: we send `apy: number[]`, it returns `vault_index` — an offset into the array we sent, not an id. If the array we build and the array we map back through ever disagree, the server confidently attributes a real allocation to the *wrong market*, and every number still looks plausible.
|
|
63
|
+
|
|
64
|
+
Order is therefore established exactly once, from the caller's `productIds`, and both directions run off that single array (`buildApyVector` → `mapAllocations` in `src/core/optimizer.ts`). It is pinned by unit tests in both directions. Do not "simplify" it into a lookup by APY value.
|
|
65
|
+
|
|
66
|
+
## Rate limits
|
|
67
|
+
|
|
68
|
+
The upstream API allows 60 GraphQL req/min/IP and 10 optimizer req/min/IP. A 429 is surfaced as an explicitly **retryable** error carrying `retryAfterSeconds` — back off, don't retry-storm.
|
|
69
|
+
|
|
70
|
+
## Not financial advice
|
|
71
|
+
|
|
72
|
+
Informational only. APYs are variable and historical yields do not predict future returns.
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { createServer } from '../core/server.js';
|
|
4
|
+
/**
|
|
5
|
+
* stdio entrypoint — what `npx lendwise-mcp` runs.
|
|
6
|
+
*
|
|
7
|
+
* Nothing may be written to stdout except MCP protocol frames: stdout IS the
|
|
8
|
+
* transport. Diagnostics go to stderr.
|
|
9
|
+
*/
|
|
10
|
+
async function main() {
|
|
11
|
+
const server = createServer();
|
|
12
|
+
await server.connect(new StdioServerTransport());
|
|
13
|
+
console.error('lendwise-mcp ready on stdio');
|
|
14
|
+
}
|
|
15
|
+
main().catch((error) => {
|
|
16
|
+
console.error('lendwise-mcp failed to start:', error);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP server holds no secrets. It speaks only HTTPS to the Lendwise public
|
|
3
|
+
* API, which is what makes this repo safe to be public — there is nothing here
|
|
4
|
+
* to leak. `LENDWISE_API_URL` is overridable so a local web checkout can be
|
|
5
|
+
* developed against.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LENDWISE_API_URL: string;
|
|
8
|
+
export declare const GRAPHQL_ENDPOINT: string;
|
|
9
|
+
export declare const OPTIMIZER_ENDPOINT: string;
|
|
10
|
+
/** Upstream is public and rate-limited; don't hang an agent on a slow request. */
|
|
11
|
+
export declare const FETCH_TIMEOUT_MS = 15000;
|
|
12
|
+
/**
|
|
13
|
+
* Below this TVL a headline APY is mostly noise. Steering someone with $1k into
|
|
14
|
+
* a thin market is the most plausible real-world harm this server can do, so the
|
|
15
|
+
* floor is on by default and must be lowered deliberately.
|
|
16
|
+
*/
|
|
17
|
+
export declare const DEFAULT_MIN_TVL_USD = 1000000;
|
|
18
|
+
export declare const NOT_ADVICE: string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP server holds no secrets. It speaks only HTTPS to the Lendwise public
|
|
3
|
+
* API, which is what makes this repo safe to be public — there is nothing here
|
|
4
|
+
* to leak. `LENDWISE_API_URL` is overridable so a local web checkout can be
|
|
5
|
+
* developed against.
|
|
6
|
+
*/
|
|
7
|
+
export const LENDWISE_API_URL = (process.env.LENDWISE_API_URL ?? 'https://lendwise.fi').replace(/\/$/, '');
|
|
8
|
+
export const GRAPHQL_ENDPOINT = `${LENDWISE_API_URL}/api/graphql`;
|
|
9
|
+
export const OPTIMIZER_ENDPOINT = `${LENDWISE_API_URL}/api/optimizer`;
|
|
10
|
+
/** Upstream is public and rate-limited; don't hang an agent on a slow request. */
|
|
11
|
+
export const FETCH_TIMEOUT_MS = 15_000;
|
|
12
|
+
/**
|
|
13
|
+
* Below this TVL a headline APY is mostly noise. Steering someone with $1k into
|
|
14
|
+
* a thin market is the most plausible real-world harm this server can do, so the
|
|
15
|
+
* floor is on by default and must be lowered deliberately.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_MIN_TVL_USD = 1_000_000;
|
|
18
|
+
export const NOT_ADVICE = 'Informational only, not financial advice. APYs are variable and historical ' +
|
|
19
|
+
'yields do not predict future returns. Verify on-chain before committing funds.';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { GRAPHQL_ENDPOINT } from '../config.js';
|
|
2
|
+
import { ApiError, postJson } from '../http.js';
|
|
3
|
+
export { ApiError, RateLimitedError } from '../http.js';
|
|
4
|
+
/** POST a GraphQL document and return `data`, or throw a typed error. */
|
|
5
|
+
export async function gql(query, variables = {}) {
|
|
6
|
+
const body = await postJson(GRAPHQL_ENDPOINT, { query, variables }, 'The Lendwise API');
|
|
7
|
+
// GraphQL reports errors in a 200 body, so this is the real error path.
|
|
8
|
+
if (body.errors?.length) {
|
|
9
|
+
throw new ApiError(body.errors.map((e) => e.message).join('; '));
|
|
10
|
+
}
|
|
11
|
+
if (!body.data)
|
|
12
|
+
throw new ApiError('Lendwise API returned no data.');
|
|
13
|
+
return body.data;
|
|
14
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four documents the tools need.
|
|
3
|
+
*
|
|
4
|
+
* Written by hand rather than generated. The spec called for graphql-codegen
|
|
5
|
+
* against LENDWISE_API_URL introspection, but that makes a live endpoint a
|
|
6
|
+
* *build-time* dependency of a public package — and the queries these five tools
|
|
7
|
+
* need are a fixed, small set. `pnpm build` here needs no network.
|
|
8
|
+
*
|
|
9
|
+
* Selection sets are kept tight on purpose: the API enforces a query cost limit
|
|
10
|
+
* that scales with `first`, so asking for fields we won't render can push a
|
|
11
|
+
* large page over the ceiling.
|
|
12
|
+
*/
|
|
13
|
+
export interface ApyBreakdown {
|
|
14
|
+
base: number;
|
|
15
|
+
rewards: number;
|
|
16
|
+
fees: number;
|
|
17
|
+
net: number;
|
|
18
|
+
rewardItems?: RewardItem[];
|
|
19
|
+
}
|
|
20
|
+
export interface RewardItem {
|
|
21
|
+
token: {
|
|
22
|
+
symbol: string;
|
|
23
|
+
};
|
|
24
|
+
apy: number;
|
|
25
|
+
source: string;
|
|
26
|
+
program: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface MarketState {
|
|
29
|
+
supplyAssetsUsd: number | null;
|
|
30
|
+
utilizationRate: number | null;
|
|
31
|
+
}
|
|
32
|
+
export interface Quality {
|
|
33
|
+
status: string;
|
|
34
|
+
count: number;
|
|
35
|
+
expectedCount: number;
|
|
36
|
+
}
|
|
37
|
+
export interface SnapshotRow {
|
|
38
|
+
hour: string;
|
|
39
|
+
productId: string;
|
|
40
|
+
asset: string;
|
|
41
|
+
chainId: number;
|
|
42
|
+
apy: ApyBreakdown;
|
|
43
|
+
market: MarketState;
|
|
44
|
+
quality: Quality;
|
|
45
|
+
product: {
|
|
46
|
+
protocol: {
|
|
47
|
+
provider: string;
|
|
48
|
+
name: string;
|
|
49
|
+
chain: {
|
|
50
|
+
id: number;
|
|
51
|
+
name: string;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
} | null;
|
|
55
|
+
}
|
|
56
|
+
export interface SnapshotResponse {
|
|
57
|
+
items: SnapshotRow[];
|
|
58
|
+
pagination: {
|
|
59
|
+
count: number;
|
|
60
|
+
countTotal: number;
|
|
61
|
+
limit: number;
|
|
62
|
+
skip: number;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export interface DailyRow {
|
|
66
|
+
date: string;
|
|
67
|
+
apy: {
|
|
68
|
+
net: number;
|
|
69
|
+
base: number;
|
|
70
|
+
rewards: number;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export interface FacetsResponse {
|
|
74
|
+
assets: {
|
|
75
|
+
symbol: string;
|
|
76
|
+
count: number;
|
|
77
|
+
}[];
|
|
78
|
+
chains: {
|
|
79
|
+
id: number;
|
|
80
|
+
name: string;
|
|
81
|
+
count: number;
|
|
82
|
+
}[];
|
|
83
|
+
protocols: {
|
|
84
|
+
name: string;
|
|
85
|
+
count: number;
|
|
86
|
+
}[];
|
|
87
|
+
}
|
|
88
|
+
export interface ProductRow {
|
|
89
|
+
id: string;
|
|
90
|
+
kind: string;
|
|
91
|
+
asset: {
|
|
92
|
+
symbol: string;
|
|
93
|
+
name: string;
|
|
94
|
+
address: string;
|
|
95
|
+
decimals: number;
|
|
96
|
+
};
|
|
97
|
+
protocol: {
|
|
98
|
+
provider: string;
|
|
99
|
+
type: string;
|
|
100
|
+
version: string;
|
|
101
|
+
name: string;
|
|
102
|
+
chain: {
|
|
103
|
+
id: number;
|
|
104
|
+
name: string;
|
|
105
|
+
};
|
|
106
|
+
address: string;
|
|
107
|
+
meta: unknown;
|
|
108
|
+
};
|
|
109
|
+
collaterals: {
|
|
110
|
+
symbol: string;
|
|
111
|
+
name: string;
|
|
112
|
+
ltv: number | null;
|
|
113
|
+
lltv: number;
|
|
114
|
+
canBeCollateral: boolean;
|
|
115
|
+
}[] | null;
|
|
116
|
+
}
|
|
117
|
+
export declare const PRODUCT_FACETS = "\n query ProductFacets($filters: ProductFilters) {\n productFacets(filters: $filters) {\n assets {\n symbol\n count\n }\n chains {\n id\n name\n count\n }\n protocols {\n name\n count\n }\n }\n }\n";
|
|
118
|
+
export declare const LATEST_SUPPLY_APY = "\n query LatestSupplyApy($filters: LatestFilters, $first: Int) {\n latestSupplyApy(\n filters: $filters\n first: $first\n orderBy: apyNet\n orderDirection: desc\n ) {\n \n items {\n hour\n productId\n asset\n chainId\n apy {\n base\n rewards\n fees\n net\n }\n market {\n supplyAssetsUsd\n utilizationRate\n }\n quality {\n status\n count\n expectedCount\n }\n product {\n protocol {\n provider\n name\n chain {\n id\n name\n }\n }\n }\n }\n pagination {\n count\n countTotal\n limit\n skip\n }\n\n }\n }\n";
|
|
119
|
+
/**
|
|
120
|
+
* Latest snapshot for an explicit set of products — the optimizer's APY source.
|
|
121
|
+
*
|
|
122
|
+
* One request for the whole batch via the server-side `productIds` filter. One
|
|
123
|
+
* request per product would burn up to 20 of the 60 req/min budget on a single
|
|
124
|
+
* optimize_allocation call.
|
|
125
|
+
*/
|
|
126
|
+
export declare const LATEST_SUPPLY_BY_PRODUCTS = "\n query LatestSupplyByProducts($productIds: [String!], $first: Int) {\n latestSupplyApy(filters: { productIds: $productIds }, first: $first) {\n \n items {\n hour\n productId\n asset\n chainId\n apy {\n base\n rewards\n fees\n net\n }\n market {\n supplyAssetsUsd\n utilizationRate\n }\n quality {\n status\n count\n expectedCount\n }\n product {\n protocol {\n provider\n name\n chain {\n id\n name\n }\n }\n }\n }\n pagination {\n count\n countTotal\n limit\n skip\n }\n\n }\n }\n";
|
|
127
|
+
/**
|
|
128
|
+
* A productId identifies a supply OR a borrow product, and the two live in
|
|
129
|
+
* different snapshot queries. Ask both and take whichever answers: querying only
|
|
130
|
+
* the supply side would report every healthy borrow market as a stalled pipeline.
|
|
131
|
+
*/
|
|
132
|
+
export declare const MARKET_DETAILS = "\n query MarketDetails($productId: String!) {\n products(filters: { productId: $productId }, first: 1) {\n items {\n id\n kind\n asset {\n symbol\n name\n address\n decimals\n }\n protocol {\n provider\n type\n version\n name\n chain {\n id\n name\n }\n address\n meta\n }\n collaterals {\n symbol\n name\n ltv\n lltv\n canBeCollateral\n }\n }\n }\n latestSupplyApy(filters: { productId: $productId }, first: 1) {\n \n items {\n hour\n productId\n asset\n chainId\n apy {\n base\n rewards\n fees\n net\n rewardItems {\n token {\n symbol\n }\n apy\n source\n program\n }\n }\n market {\n supplyAssetsUsd\n utilizationRate\n }\n quality {\n status\n count\n expectedCount\n }\n product {\n protocol {\n provider\n name\n chain {\n id\n name\n }\n }\n }\n }\n\n }\n latestBorrowApy(filters: { productId: $productId }, first: 1) {\n \n items {\n hour\n productId\n asset\n chainId\n apy {\n base\n rewards\n fees\n net\n rewardItems {\n token {\n symbol\n }\n apy\n source\n program\n }\n }\n market {\n supplyAssetsUsd\n utilizationRate\n }\n quality {\n status\n count\n expectedCount\n }\n product {\n protocol {\n provider\n name\n chain {\n id\n name\n }\n }\n }\n }\n\n }\n }\n";
|
|
133
|
+
export declare const MARKET_HISTORY = "\n query MarketHistory($productId: String!, $range: String!) {\n supplyApyDaily(\n filters: { productId: $productId, range: $range }\n first: 500\n orderBy: time\n orderDirection: asc\n ) {\n items {\n date\n apy {\n net\n base\n rewards\n }\n }\n pagination {\n count\n countTotal\n }\n }\n }\n";
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four documents the tools need.
|
|
3
|
+
*
|
|
4
|
+
* Written by hand rather than generated. The spec called for graphql-codegen
|
|
5
|
+
* against LENDWISE_API_URL introspection, but that makes a live endpoint a
|
|
6
|
+
* *build-time* dependency of a public package — and the queries these five tools
|
|
7
|
+
* need are a fixed, small set. `pnpm build` here needs no network.
|
|
8
|
+
*
|
|
9
|
+
* Selection sets are kept tight on purpose: the API enforces a query cost limit
|
|
10
|
+
* that scales with `first`, so asking for fields we won't render can push a
|
|
11
|
+
* large page over the ceiling.
|
|
12
|
+
*/
|
|
13
|
+
// ─── Documents ───────────────────────────────────────────────────────────────
|
|
14
|
+
export const PRODUCT_FACETS = /* GraphQL */ `
|
|
15
|
+
query ProductFacets($filters: ProductFilters) {
|
|
16
|
+
productFacets(filters: $filters) {
|
|
17
|
+
assets {
|
|
18
|
+
symbol
|
|
19
|
+
count
|
|
20
|
+
}
|
|
21
|
+
chains {
|
|
22
|
+
id
|
|
23
|
+
name
|
|
24
|
+
count
|
|
25
|
+
}
|
|
26
|
+
protocols {
|
|
27
|
+
name
|
|
28
|
+
count
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
const SNAPSHOT_FIELDS = /* GraphQL */ `
|
|
34
|
+
items {
|
|
35
|
+
hour
|
|
36
|
+
productId
|
|
37
|
+
asset
|
|
38
|
+
chainId
|
|
39
|
+
apy {
|
|
40
|
+
base
|
|
41
|
+
rewards
|
|
42
|
+
fees
|
|
43
|
+
net
|
|
44
|
+
}
|
|
45
|
+
market {
|
|
46
|
+
supplyAssetsUsd
|
|
47
|
+
utilizationRate
|
|
48
|
+
}
|
|
49
|
+
quality {
|
|
50
|
+
status
|
|
51
|
+
count
|
|
52
|
+
expectedCount
|
|
53
|
+
}
|
|
54
|
+
product {
|
|
55
|
+
protocol {
|
|
56
|
+
provider
|
|
57
|
+
name
|
|
58
|
+
chain {
|
|
59
|
+
id
|
|
60
|
+
name
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
pagination {
|
|
66
|
+
count
|
|
67
|
+
countTotal
|
|
68
|
+
limit
|
|
69
|
+
skip
|
|
70
|
+
}
|
|
71
|
+
`;
|
|
72
|
+
export const LATEST_SUPPLY_APY = /* GraphQL */ `
|
|
73
|
+
query LatestSupplyApy($filters: LatestFilters, $first: Int) {
|
|
74
|
+
latestSupplyApy(
|
|
75
|
+
filters: $filters
|
|
76
|
+
first: $first
|
|
77
|
+
orderBy: apyNet
|
|
78
|
+
orderDirection: desc
|
|
79
|
+
) {
|
|
80
|
+
${SNAPSHOT_FIELDS}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
/**
|
|
85
|
+
* Latest snapshot for an explicit set of products — the optimizer's APY source.
|
|
86
|
+
*
|
|
87
|
+
* One request for the whole batch via the server-side `productIds` filter. One
|
|
88
|
+
* request per product would burn up to 20 of the 60 req/min budget on a single
|
|
89
|
+
* optimize_allocation call.
|
|
90
|
+
*/
|
|
91
|
+
export const LATEST_SUPPLY_BY_PRODUCTS = /* GraphQL */ `
|
|
92
|
+
query LatestSupplyByProducts($productIds: [String!], $first: Int) {
|
|
93
|
+
latestSupplyApy(filters: { productIds: $productIds }, first: $first) {
|
|
94
|
+
${SNAPSHOT_FIELDS}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
const DETAIL_SNAPSHOT_FIELDS = /* GraphQL */ `
|
|
99
|
+
items {
|
|
100
|
+
hour
|
|
101
|
+
productId
|
|
102
|
+
asset
|
|
103
|
+
chainId
|
|
104
|
+
apy {
|
|
105
|
+
base
|
|
106
|
+
rewards
|
|
107
|
+
fees
|
|
108
|
+
net
|
|
109
|
+
rewardItems {
|
|
110
|
+
token {
|
|
111
|
+
symbol
|
|
112
|
+
}
|
|
113
|
+
apy
|
|
114
|
+
source
|
|
115
|
+
program
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
market {
|
|
119
|
+
supplyAssetsUsd
|
|
120
|
+
utilizationRate
|
|
121
|
+
}
|
|
122
|
+
quality {
|
|
123
|
+
status
|
|
124
|
+
count
|
|
125
|
+
expectedCount
|
|
126
|
+
}
|
|
127
|
+
product {
|
|
128
|
+
protocol {
|
|
129
|
+
provider
|
|
130
|
+
name
|
|
131
|
+
chain {
|
|
132
|
+
id
|
|
133
|
+
name
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
`;
|
|
139
|
+
/**
|
|
140
|
+
* A productId identifies a supply OR a borrow product, and the two live in
|
|
141
|
+
* different snapshot queries. Ask both and take whichever answers: querying only
|
|
142
|
+
* the supply side would report every healthy borrow market as a stalled pipeline.
|
|
143
|
+
*/
|
|
144
|
+
export const MARKET_DETAILS = /* GraphQL */ `
|
|
145
|
+
query MarketDetails($productId: String!) {
|
|
146
|
+
products(filters: { productId: $productId }, first: 1) {
|
|
147
|
+
items {
|
|
148
|
+
id
|
|
149
|
+
kind
|
|
150
|
+
asset {
|
|
151
|
+
symbol
|
|
152
|
+
name
|
|
153
|
+
address
|
|
154
|
+
decimals
|
|
155
|
+
}
|
|
156
|
+
protocol {
|
|
157
|
+
provider
|
|
158
|
+
type
|
|
159
|
+
version
|
|
160
|
+
name
|
|
161
|
+
chain {
|
|
162
|
+
id
|
|
163
|
+
name
|
|
164
|
+
}
|
|
165
|
+
address
|
|
166
|
+
meta
|
|
167
|
+
}
|
|
168
|
+
collaterals {
|
|
169
|
+
symbol
|
|
170
|
+
name
|
|
171
|
+
ltv
|
|
172
|
+
lltv
|
|
173
|
+
canBeCollateral
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
latestSupplyApy(filters: { productId: $productId }, first: 1) {
|
|
178
|
+
${DETAIL_SNAPSHOT_FIELDS}
|
|
179
|
+
}
|
|
180
|
+
latestBorrowApy(filters: { productId: $productId }, first: 1) {
|
|
181
|
+
${DETAIL_SNAPSHOT_FIELDS}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
`;
|
|
185
|
+
export const MARKET_HISTORY = /* GraphQL */ `
|
|
186
|
+
query MarketHistory($productId: String!, $range: String!) {
|
|
187
|
+
supplyApyDaily(
|
|
188
|
+
filters: { productId: $productId, range: $range }
|
|
189
|
+
first: 500
|
|
190
|
+
orderBy: time
|
|
191
|
+
orderDirection: asc
|
|
192
|
+
) {
|
|
193
|
+
items {
|
|
194
|
+
date
|
|
195
|
+
apy {
|
|
196
|
+
net
|
|
197
|
+
base
|
|
198
|
+
rewards
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
pagination {
|
|
202
|
+
count
|
|
203
|
+
countTotal
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
`;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A 429 from upstream. Surfaced as its own error type rather than swallowed:
|
|
3
|
+
* the agent must back off, not retry-storm the endpoint the rate limit exists to
|
|
4
|
+
* protect.
|
|
5
|
+
*/
|
|
6
|
+
export declare class RateLimitedError extends Error {
|
|
7
|
+
readonly retryAfterSeconds: number;
|
|
8
|
+
constructor(retryAfterSeconds: number);
|
|
9
|
+
}
|
|
10
|
+
export declare class ApiError extends Error {
|
|
11
|
+
constructor(message: string);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* POST JSON and return the parsed body.
|
|
15
|
+
*
|
|
16
|
+
* The single network path for this server: the GraphQL client and the optimizer
|
|
17
|
+
* client both go through it, so timeout handling, 429 semantics and error
|
|
18
|
+
* shaping cannot drift apart between them.
|
|
19
|
+
*/
|
|
20
|
+
export declare function postJson<T>(url: string, body: unknown, label: string): Promise<T>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { FETCH_TIMEOUT_MS } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* A 429 from upstream. Surfaced as its own error type rather than swallowed:
|
|
4
|
+
* the agent must back off, not retry-storm the endpoint the rate limit exists to
|
|
5
|
+
* protect.
|
|
6
|
+
*/
|
|
7
|
+
export class RateLimitedError extends Error {
|
|
8
|
+
retryAfterSeconds;
|
|
9
|
+
constructor(retryAfterSeconds) {
|
|
10
|
+
super(`Lendwise API rate limit exceeded. Retryable: wait ${retryAfterSeconds}s ` +
|
|
11
|
+
`before trying again. Do not retry immediately.`);
|
|
12
|
+
this.name = 'RateLimitedError';
|
|
13
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class ApiError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'ApiError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Default back-off when upstream sends a 429 without a usable Retry-After. */
|
|
23
|
+
const DEFAULT_RETRY_AFTER_SECONDS = 60;
|
|
24
|
+
/**
|
|
25
|
+
* POST JSON and return the parsed body.
|
|
26
|
+
*
|
|
27
|
+
* The single network path for this server: the GraphQL client and the optimizer
|
|
28
|
+
* client both go through it, so timeout handling, 429 semantics and error
|
|
29
|
+
* shaping cannot drift apart between them.
|
|
30
|
+
*/
|
|
31
|
+
export async function postJson(url, body, label) {
|
|
32
|
+
let response;
|
|
33
|
+
try {
|
|
34
|
+
response = await fetch(url, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify(body),
|
|
38
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
43
|
+
throw new ApiError(`${label} did not respond within ${FETCH_TIMEOUT_MS / 1000}s.`);
|
|
44
|
+
}
|
|
45
|
+
throw new ApiError(`Could not reach ${label}: ${error.message}`);
|
|
46
|
+
}
|
|
47
|
+
if (response.status === 429) {
|
|
48
|
+
const header = Number(response.headers.get('retry-after'));
|
|
49
|
+
throw new RateLimitedError(Number.isFinite(header) && header > 0 ? header : DEFAULT_RETRY_AFTER_SECONDS);
|
|
50
|
+
}
|
|
51
|
+
const raw = await response.text();
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
// A dead or misrouted upstream answers with HTML, not JSON. Parse only if it
|
|
54
|
+
// parses, so the real status is never masked by a JSON SyntaxError.
|
|
55
|
+
let detail = raw.slice(0, 200);
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
if (parsed.error)
|
|
59
|
+
detail = parsed.error;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* not JSON — fall back to the raw text */
|
|
63
|
+
}
|
|
64
|
+
throw new ApiError(`${label} returned HTTP ${response.status}: ${detail}`);
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(raw);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new ApiError(`${label} returned a non-JSON response.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optimizer contract is POSITIONAL, and that is the single most dangerous
|
|
3
|
+
* detail in this server.
|
|
4
|
+
*
|
|
5
|
+
* We send `apy: number[]`. It answers with `vault_index` — an offset into the
|
|
6
|
+
* array we sent, not an id. If the array we build and the array we map back
|
|
7
|
+
* through ever disagree on order, the agent confidently recommends a real
|
|
8
|
+
* allocation attached to the wrong market. Nothing downstream would catch it:
|
|
9
|
+
* the numbers all look plausible.
|
|
10
|
+
*
|
|
11
|
+
* So order is established exactly once, by the caller's productIds, and both
|
|
12
|
+
* directions are driven from that same array. See optimizer.test.ts.
|
|
13
|
+
*/
|
|
14
|
+
export interface VaultAllocationResult {
|
|
15
|
+
vault_index: number;
|
|
16
|
+
allocation: number;
|
|
17
|
+
allocation_percent: number;
|
|
18
|
+
}
|
|
19
|
+
export interface VaultAllocationResponse {
|
|
20
|
+
success: boolean;
|
|
21
|
+
allocations: VaultAllocationResult[];
|
|
22
|
+
resulting_diversification: number;
|
|
23
|
+
}
|
|
24
|
+
/** One market's share of the allocation, resolved back to its productId. */
|
|
25
|
+
export interface Allocation {
|
|
26
|
+
productId: string;
|
|
27
|
+
apy: number;
|
|
28
|
+
allocationPercent: number;
|
|
29
|
+
amountUsd: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Map the solver's positional result back onto the productIds it was built
|
|
33
|
+
* from. `apys[i]` must be the APY of `productIds[i]` — that invariant is the
|
|
34
|
+
* caller's to hold, and `buildApyVector` is the only supported way to get it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function mapAllocations(productIds: readonly string[], apys: readonly number[], allocations: readonly VaultAllocationResult[], amountUsd: number): Allocation[];
|
|
37
|
+
/**
|
|
38
|
+
* Build the positional APY vector for `productIds`, in that exact order.
|
|
39
|
+
* Products with no finite APY in the snapshot are reported as missing rather
|
|
40
|
+
* than defaulted to 0 — a 0 would silently tell the solver "this market is
|
|
41
|
+
* worthless" instead of "we don't know".
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildApyVector(productIds: readonly string[], latestApyByProduct: ReadonlyMap<string, number>): {
|
|
44
|
+
apys: number[];
|
|
45
|
+
found: string[];
|
|
46
|
+
missing: string[];
|
|
47
|
+
};
|
|
48
|
+
/** Blended APY of an allocation — the portfolio's weighted mean. */
|
|
49
|
+
export declare function blendedApy(allocations: readonly Allocation[]): number;
|
|
50
|
+
/**
|
|
51
|
+
* Call the solver through the Lendwise proxy — never optimizer.lendwise.fi directly.
|
|
52
|
+
*
|
|
53
|
+
* The response is VALIDATED, not cast. A solver that answers 200 with
|
|
54
|
+
* `success: false` (or without `allocations` at all) would otherwise blow up
|
|
55
|
+
* inside `mapAllocations`, or — worse — be reported to the user as a valid
|
|
56
|
+
* allocation of $0 across their markets.
|
|
57
|
+
*/
|
|
58
|
+
export declare function optimizeVaults(apy: readonly number[], diversification: number): Promise<VaultAllocationResponse>;
|