@gibs/quotes 1.13.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 +15 -0
- package/README.md +45 -0
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +137 -0
- package/dist/carriers/calibration.d.ts +115 -0
- package/dist/carriers/calibration.js +83 -0
- package/dist/carriers/lifi.d.ts +63 -0
- package/dist/carriers/lifi.js +217 -0
- package/dist/carriers/near-intents.d.ts +91 -0
- package/dist/carriers/near-intents.js +249 -0
- package/dist/carriers/price-catalogue.d.ts +186 -0
- package/dist/carriers/price-catalogue.js +191 -0
- package/dist/carriers/relay.d.ts +97 -0
- package/dist/carriers/relay.js +242 -0
- package/dist/chain-lists.d.ts +144 -0
- package/dist/chain-lists.js +169 -0
- package/dist/client.d.ts +102 -0
- package/dist/client.js +402 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +18 -0
- package/dist/quote-guards.d.ts +128 -0
- package/dist/quote-guards.js +240 -0
- package/dist/quote-refusals.d.ts +180 -0
- package/dist/quote-refusals.js +284 -0
- package/dist/quote-service-contract.d.ts +2240 -0
- package/dist/quote-service-contract.js +247 -0
- package/dist/search/capabilities.d.ts +68 -0
- package/dist/search/capabilities.js +28 -0
- package/dist/search/enumerate.d.ts +162 -0
- package/dist/search/enumerate.js +313 -0
- package/dist/search/index.d.ts +17 -0
- package/dist/search/index.js +17 -0
- package/dist/search/serve.d.ts +178 -0
- package/dist/search/serve.js +142 -0
- package/dist/search/waves.d.ts +481 -0
- package/dist/search/waves.js +939 -0
- package/dist/single-flight.d.ts +90 -0
- package/dist/single-flight.js +142 -0
- package/dist/types.d.ts +502 -0
- package/dist/types.js +1 -0
- package/dist/upstream.d.ts +126 -0
- package/dist/upstream.js +343 -0
- package/llms.txt +217 -0
- package/package.json +142 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gibs Finance
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @gibs/quotes
|
|
2
|
+
|
|
3
|
+
An isomorphic core for asking cross-chain bridge aggregators (LI.FI, Relay, NEAR Intents) for display and execution quotes. It runs unchanged in a browser or in Node.
|
|
4
|
+
|
|
5
|
+
The package holds the wire contract, the refusal reader, the carriers, and the guards a host wraps around every upstream call. For the full integration guide, read `llms.txt` in this package.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @gibs/quotes viem
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`viem` is a peer dependency. The supported range is `>=2.37.0 <3`.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Node 20 or later, or a modern browser.
|
|
18
|
+
- The package is ESM only. Use `import`, not `require`.
|
|
19
|
+
- `@gibs/bridge-sdk` is a dependency, and it reaches TON code. A browser bundle needs a global `Buffer`. Install the `buffer` package and set `globalThis.Buffer` before you import this package. Node has it already.
|
|
20
|
+
|
|
21
|
+
## Example
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createQuoteClient, type QuoteClientConfig } from '@gibs/quotes'
|
|
25
|
+
|
|
26
|
+
const config: QuoteClientConfig = {
|
|
27
|
+
// 'direct' lets the client ask the aggregators itself when no service answers.
|
|
28
|
+
fallback: 'direct',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const client = createQuoteClient(config)
|
|
32
|
+
|
|
33
|
+
// A snapshot of the client's own counters.
|
|
34
|
+
console.log(client.statsSnapshot())
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
To ask for quotes, call `client.displayQuotes(request)`. The request shape is `DisplayQuoteRequest`, and `llms.txt` describes it.
|
|
38
|
+
|
|
39
|
+
## Subpath exports
|
|
40
|
+
|
|
41
|
+
The root entry re-exports most modules. Subpaths such as `@gibs/quotes/quote-guards`, `@gibs/quotes/upstream`, `@gibs/quotes/carriers/relay`, and `@gibs/quotes/search` let you load one module alone.
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
ISC
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type RefusedDisplayAnswer } from './quote-service-contract.js';
|
|
2
|
+
import type { AnswerCache, AnswerCacheEntry } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The display-quote endpoint's shared, single-flight, least-recently-used
|
|
5
|
+
* answer cache — `types.ts`'s {@link AnswerCache}, built exactly to
|
|
6
|
+
* `docs/quote-service.md`'s "Cache" section: 30 s for `quoted`/`no-quote`
|
|
7
|
+
* answers, 5 s for `could-not-ask`, and a `cannot-carry` answer that lives
|
|
8
|
+
* until the host's own chain-list refresh generation changes rather than for
|
|
9
|
+
* a fixed duration.
|
|
10
|
+
*
|
|
11
|
+
* ENTRIES ALREADY STORE THEIR OWN `askedAmount` — NO EXTRA BOOKKEEPING
|
|
12
|
+
* NEEDED. `AnswerCacheEntry.answer` is a {@link DisplayProviderSlot}, and
|
|
13
|
+
* every `quoted` slot already carries its own `askedAmount` field (see
|
|
14
|
+
* `quote-service-contract.ts`'s `quotedDisplayAnswerCommonShape`). A caller
|
|
15
|
+
* deciding whether a cache hit — recorded for a DIFFERENT literal amount in
|
|
16
|
+
* the same two-significant-figure bucket ({@link displayQuoteCacheKey}
|
|
17
|
+
* collapses the bucket into the key itself) — may still answer a fresh ask
|
|
18
|
+
* reads that field directly off the entry; see `client.ts`'s own cache-hit
|
|
19
|
+
* adaptation for where that read happens.
|
|
20
|
+
*/
|
|
21
|
+
/** The 30 s lifetime `docs/quote-service.md`'s "Cache" section gives `quoted` and `no-quote` entries. */
|
|
22
|
+
export declare const DEFAULT_ANSWER_TTL_MS = 30000;
|
|
23
|
+
/** The 5 s lifetime `docs/quote-service.md`'s "Cache" section gives `could-not-ask` entries. */
|
|
24
|
+
export declare const DEFAULT_COULD_NOT_ASK_TTL_MS = 5000;
|
|
25
|
+
/** The "about 10,000 entries" least-recently-used capacity `docs/quote-service.md`'s "Cache" section names. */
|
|
26
|
+
export declare const DEFAULT_MAX_CACHE_ENTRIES = 10000;
|
|
27
|
+
/** How a host builds an {@link AnswerCache} from this module. */
|
|
28
|
+
export type AnswerCacheConfig = {
|
|
29
|
+
/** Overrides {@link DEFAULT_ANSWER_TTL_MS}, applied to `quoted` and `no-quote` entries. */
|
|
30
|
+
readonly ttlMs?: number;
|
|
31
|
+
/** Overrides {@link DEFAULT_COULD_NOT_ASK_TTL_MS}, applied to `could-not-ask` and `invalid-api-key` entries. */
|
|
32
|
+
readonly couldNotAskTtlMs?: number;
|
|
33
|
+
/** Overrides {@link DEFAULT_MAX_CACHE_ENTRIES}. */
|
|
34
|
+
readonly maxEntries?: number;
|
|
35
|
+
/** The clock every expiry check reads. Defaults to `Date.now`. */
|
|
36
|
+
readonly now?: () => number;
|
|
37
|
+
/**
|
|
38
|
+
* Reads the host's current chain-list refresh generation — any value that
|
|
39
|
+
* changes exactly when the host's chain lists are next reloaded. A
|
|
40
|
+
* `cannot-carry` entry recorded under one generation is treated as expired
|
|
41
|
+
* the moment this reads a different one, per `docs/quote-service.md`'s
|
|
42
|
+
* "Cache" section: "`cannot-carry` follows the chain-list refresh."
|
|
43
|
+
*
|
|
44
|
+
* Defaults to a constant function, so a caller with no chain-list-refresh
|
|
45
|
+
* concept wired up yet simply never expires a `cannot-carry` entry by
|
|
46
|
+
* generation (it still evicts under least-recently-used pressure like any
|
|
47
|
+
* other entry). `client.ts` wires this to a real chain-list registry's own
|
|
48
|
+
* refresh stamp once one is registered.
|
|
49
|
+
*/
|
|
50
|
+
readonly chainListRefreshStamp?: () => string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Builds a fresh, empty {@link AnswerCache}.
|
|
54
|
+
*
|
|
55
|
+
* @param config - see {@link AnswerCacheConfig}
|
|
56
|
+
* @returns the cache
|
|
57
|
+
*/
|
|
58
|
+
export declare const createAnswerCache: (config?: AnswerCacheConfig) => AnswerCache;
|
|
59
|
+
/**
|
|
60
|
+
* Whether a cached `no-quote` refusal naming a minimum may still answer a
|
|
61
|
+
* DIFFERENT literal amount than the one it was recorded for —
|
|
62
|
+
* `docs/quote-service.md`'s "Cache" section: "A `no-quote` naming a minimum
|
|
63
|
+
* is reused for another amount only when that amount is also below the
|
|
64
|
+
* minimum." A `no-quote` naming no minimum, and every other refusal kind, is
|
|
65
|
+
* reused unconditionally for any amount in its bucket; this function only
|
|
66
|
+
* ever returns `false` for the one case the rule singles out.
|
|
67
|
+
*
|
|
68
|
+
* @param answer - the cached refusal
|
|
69
|
+
* @param amount - the amount actually being asked now, in base units
|
|
70
|
+
* @returns whether the cached refusal still applies to `amount`
|
|
71
|
+
*/
|
|
72
|
+
export declare const isNoQuoteMinimumReusableForAmount: (answer: RefusedDisplayAnswer, amount: bigint) => boolean;
|
|
73
|
+
export type { AnswerCache, AnswerCacheEntry };
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { decodeBaseUnitsAmount } from './quote-service-contract.js';
|
|
2
|
+
/**
|
|
3
|
+
* The display-quote endpoint's shared, single-flight, least-recently-used
|
|
4
|
+
* answer cache — `types.ts`'s {@link AnswerCache}, built exactly to
|
|
5
|
+
* `docs/quote-service.md`'s "Cache" section: 30 s for `quoted`/`no-quote`
|
|
6
|
+
* answers, 5 s for `could-not-ask`, and a `cannot-carry` answer that lives
|
|
7
|
+
* until the host's own chain-list refresh generation changes rather than for
|
|
8
|
+
* a fixed duration.
|
|
9
|
+
*
|
|
10
|
+
* ENTRIES ALREADY STORE THEIR OWN `askedAmount` — NO EXTRA BOOKKEEPING
|
|
11
|
+
* NEEDED. `AnswerCacheEntry.answer` is a {@link DisplayProviderSlot}, and
|
|
12
|
+
* every `quoted` slot already carries its own `askedAmount` field (see
|
|
13
|
+
* `quote-service-contract.ts`'s `quotedDisplayAnswerCommonShape`). A caller
|
|
14
|
+
* deciding whether a cache hit — recorded for a DIFFERENT literal amount in
|
|
15
|
+
* the same two-significant-figure bucket ({@link displayQuoteCacheKey}
|
|
16
|
+
* collapses the bucket into the key itself) — may still answer a fresh ask
|
|
17
|
+
* reads that field directly off the entry; see `client.ts`'s own cache-hit
|
|
18
|
+
* adaptation for where that read happens.
|
|
19
|
+
*/
|
|
20
|
+
/** The 30 s lifetime `docs/quote-service.md`'s "Cache" section gives `quoted` and `no-quote` entries. */
|
|
21
|
+
export const DEFAULT_ANSWER_TTL_MS = 30_000;
|
|
22
|
+
/** The 5 s lifetime `docs/quote-service.md`'s "Cache" section gives `could-not-ask` entries. */
|
|
23
|
+
export const DEFAULT_COULD_NOT_ASK_TTL_MS = 5_000;
|
|
24
|
+
/** The "about 10,000 entries" least-recently-used capacity `docs/quote-service.md`'s "Cache" section names. */
|
|
25
|
+
export const DEFAULT_MAX_CACHE_ENTRIES = 10_000;
|
|
26
|
+
const isRefused = (answer) => answer.status === 'refused';
|
|
27
|
+
/**
|
|
28
|
+
* The fixed-duration lifetime for one answer, or null when its lifetime is
|
|
29
|
+
* NOT a duration at all — a `cannot-carry` refusal, governed instead by the
|
|
30
|
+
* chain-list refresh stamp (see {@link AnswerCacheConfig.chainListRefreshStamp}).
|
|
31
|
+
*
|
|
32
|
+
* A `pending` slot is never meant to reach {@link AnswerCache.set} at all —
|
|
33
|
+
* it is a live placeholder `client.ts` synthesizes when a deadline elapses,
|
|
34
|
+
* never a recorded answer — but this reads it with the shortest lifetime as
|
|
35
|
+
* a defensive fallback rather than assuming that invariant holds forever.
|
|
36
|
+
*/
|
|
37
|
+
const fixedTtlMsForAnswer = (answer, ttls) => {
|
|
38
|
+
if (answer.status === 'quoted')
|
|
39
|
+
return ttls.ttlMs;
|
|
40
|
+
if (!isRefused(answer))
|
|
41
|
+
return ttls.couldNotAskTtlMs;
|
|
42
|
+
if (answer.kind === 'cannot-carry')
|
|
43
|
+
return null;
|
|
44
|
+
if (answer.kind === 'no-quote')
|
|
45
|
+
return ttls.ttlMs;
|
|
46
|
+
return ttls.couldNotAskTtlMs;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Builds a fresh, empty {@link AnswerCache}.
|
|
50
|
+
*
|
|
51
|
+
* @param config - see {@link AnswerCacheConfig}
|
|
52
|
+
* @returns the cache
|
|
53
|
+
*/
|
|
54
|
+
export const createAnswerCache = (config = {}) => {
|
|
55
|
+
const ttlMs = config.ttlMs ?? DEFAULT_ANSWER_TTL_MS;
|
|
56
|
+
const couldNotAskTtlMs = config.couldNotAskTtlMs ?? DEFAULT_COULD_NOT_ASK_TTL_MS;
|
|
57
|
+
const maxEntries = config.maxEntries ?? DEFAULT_MAX_CACHE_ENTRIES;
|
|
58
|
+
const now = config.now ?? Date.now;
|
|
59
|
+
const chainListRefreshStamp = config.chainListRefreshStamp ?? (() => 'static');
|
|
60
|
+
// A `Map` iterates in insertion order, so "delete then re-set on every
|
|
61
|
+
// access" keeps the map's own order equal to recency — the oldest key is
|
|
62
|
+
// always whatever `.keys().next()` yields, with no separate bookkeeping.
|
|
63
|
+
const store = new Map();
|
|
64
|
+
const inFlight = new Map();
|
|
65
|
+
const touch = (key, entry) => {
|
|
66
|
+
store.delete(key);
|
|
67
|
+
store.set(key, entry);
|
|
68
|
+
};
|
|
69
|
+
const evictLeastRecentlyUsedIfFull = (key) => {
|
|
70
|
+
if (store.has(key) || store.size < maxEntries)
|
|
71
|
+
return;
|
|
72
|
+
const oldestKey = store.keys().next().value;
|
|
73
|
+
if (oldestKey !== undefined)
|
|
74
|
+
store.delete(oldestKey);
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
get: (key) => {
|
|
78
|
+
const entry = store.get(key);
|
|
79
|
+
if (entry === undefined)
|
|
80
|
+
return null;
|
|
81
|
+
if (isRefused(entry.answer) && entry.answer.kind === 'cannot-carry') {
|
|
82
|
+
if (entry.chainListStampAtRecord !== chainListRefreshStamp()) {
|
|
83
|
+
store.delete(key);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
touch(key, entry);
|
|
87
|
+
return { answer: entry.answer, recordedAtMs: entry.recordedAtMs };
|
|
88
|
+
}
|
|
89
|
+
const ttl = fixedTtlMsForAnswer(entry.answer, { ttlMs, couldNotAskTtlMs });
|
|
90
|
+
if (ttl !== null && now() - entry.recordedAtMs >= ttl) {
|
|
91
|
+
store.delete(key);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
touch(key, entry);
|
|
95
|
+
return { answer: entry.answer, recordedAtMs: entry.recordedAtMs };
|
|
96
|
+
},
|
|
97
|
+
set: (key, answer, recordedAtMs) => {
|
|
98
|
+
evictLeastRecentlyUsedIfFull(key);
|
|
99
|
+
touch(key, { answer, recordedAtMs, chainListStampAtRecord: chainListRefreshStamp() });
|
|
100
|
+
},
|
|
101
|
+
joinOrRun: async (key, task) => {
|
|
102
|
+
const existing = inFlight.get(key);
|
|
103
|
+
if (existing !== undefined) {
|
|
104
|
+
const { answer } = await existing;
|
|
105
|
+
return { answer, joined: true };
|
|
106
|
+
}
|
|
107
|
+
const promise = task().then((answer) => ({ answer, joined: false }));
|
|
108
|
+
inFlight.set(key, promise);
|
|
109
|
+
try {
|
|
110
|
+
return await promise;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
// "Removed on settle": a fresh ask under this key after this point
|
|
114
|
+
// starts its own call rather than joining a call that already ended.
|
|
115
|
+
inFlight.delete(key);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Whether a cached `no-quote` refusal naming a minimum may still answer a
|
|
122
|
+
* DIFFERENT literal amount than the one it was recorded for —
|
|
123
|
+
* `docs/quote-service.md`'s "Cache" section: "A `no-quote` naming a minimum
|
|
124
|
+
* is reused for another amount only when that amount is also below the
|
|
125
|
+
* minimum." A `no-quote` naming no minimum, and every other refusal kind, is
|
|
126
|
+
* reused unconditionally for any amount in its bucket; this function only
|
|
127
|
+
* ever returns `false` for the one case the rule singles out.
|
|
128
|
+
*
|
|
129
|
+
* @param answer - the cached refusal
|
|
130
|
+
* @param amount - the amount actually being asked now, in base units
|
|
131
|
+
* @returns whether the cached refusal still applies to `amount`
|
|
132
|
+
*/
|
|
133
|
+
export const isNoQuoteMinimumReusableForAmount = (answer, amount) => {
|
|
134
|
+
if (answer.kind !== 'no-quote' || answer.minimumAmount === undefined)
|
|
135
|
+
return true;
|
|
136
|
+
return amount < decodeBaseUnitsAmount(answer.minimumAmount);
|
|
137
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { CarrierId } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* How well a built-in carrier's own {@link EstimatedHopResult} tracks what it
|
|
4
|
+
* actually delivers, per directed chain pair — the calibration factor the
|
|
5
|
+
* owner asked for on top of the price-and-fee model: "it's custom for each
|
|
6
|
+
* provider... gather the logic around how much a user is about to be
|
|
7
|
+
* charged and do the equations." Each carrier's `estimate()` (`lifi.ts`,
|
|
8
|
+
* `relay.ts`, `near-intents.ts`) can only price what is DOCUMENTED and
|
|
9
|
+
* STANDARD — an integrator fee, a published tier, a flat charge. What is not
|
|
10
|
+
* standard (LI.FI's own per-bridge tool fee, Relay's swap impact and
|
|
11
|
+
* unclassified pair tiers, NEAR Intents' live `withdrawFee`) shows up as a
|
|
12
|
+
* gap between the model and a real quote, and because that gap is mostly a
|
|
13
|
+
* property of the ROUTE (which underlying bridge or pool a chain pair
|
|
14
|
+
* resolves through), not the amount, it stays roughly stable across
|
|
15
|
+
* requests for the SAME (provider, fromChain, toChain) triple. This module
|
|
16
|
+
* tracks the ratio between a confirmed quote and this package's own prior
|
|
17
|
+
* estimate for it, and lets `estimate()` correct for the gap.
|
|
18
|
+
*
|
|
19
|
+
* A PURE STORE. Nothing here fetches or schedules; the only side effect is
|
|
20
|
+
* {@link CalibrationPersistence.save}, and a caller supplies that — the
|
|
21
|
+
* default keeps samples in memory only, for a host that wants nothing else.
|
|
22
|
+
*/
|
|
23
|
+
/** Identifies one calibration series: one provider, one directed chain pair. */
|
|
24
|
+
export type CalibrationKey = {
|
|
25
|
+
readonly providerId: CarrierId;
|
|
26
|
+
readonly fromChainId: number;
|
|
27
|
+
readonly toChainId: number;
|
|
28
|
+
};
|
|
29
|
+
/** One observed ratio between a confirmed live quote and this package's own prior estimate for the same edge and amount. */
|
|
30
|
+
export type CalibrationSample = {
|
|
31
|
+
/** `quotedAmount ÷ estimatedAmount` — see {@link calibrationRatio}. */
|
|
32
|
+
readonly ratio: number;
|
|
33
|
+
readonly recordedAtMs: number;
|
|
34
|
+
};
|
|
35
|
+
/** How many recent samples one series keeps — "the last 50 confirmed quotes," the owner's own figure. */
|
|
36
|
+
export declare const MAX_CALIBRATION_SAMPLES = 50;
|
|
37
|
+
/**
|
|
38
|
+
* Below this many samples, {@link CalibrationStore.factor} answers 1 (no
|
|
39
|
+
* correction) rather than a median of too few points to trust — a single
|
|
40
|
+
* noisy quote should not swing a route's ranking.
|
|
41
|
+
*/
|
|
42
|
+
export declare const MIN_CALIBRATION_SAMPLES = 5;
|
|
43
|
+
/** Renders a {@link CalibrationKey} into the flat string a persistence layer stores it under. */
|
|
44
|
+
export declare const calibrationKeyString: ({ providerId, fromChainId, toChainId }: CalibrationKey) => string;
|
|
45
|
+
/**
|
|
46
|
+
* Computes one sample's ratio from a confirmed live quote and this
|
|
47
|
+
* package's own prior estimate for the same edge and input amount — both
|
|
48
|
+
* already in the SAME delivered token's base units, so decimals cancel and
|
|
49
|
+
* no conversion is needed here.
|
|
50
|
+
*
|
|
51
|
+
* @param options.quotedAmount - what a live, confirmed `ask()` actually delivered, in the destination token's base units
|
|
52
|
+
* @param options.estimatedAmount - what `estimate()` predicted for the SAME edge and input amount, in the same units
|
|
53
|
+
* @returns the ratio, or null when `estimatedAmount` is non-positive and the ratio would be meaningless
|
|
54
|
+
*/
|
|
55
|
+
export declare const calibrationRatio: ({ quotedAmount, estimatedAmount, }: {
|
|
56
|
+
readonly quotedAmount: bigint;
|
|
57
|
+
readonly estimatedAmount: bigint;
|
|
58
|
+
}) => number | null;
|
|
59
|
+
/**
|
|
60
|
+
* Where a {@link CalibrationStore} keeps its samples between calls — a plain
|
|
61
|
+
* key/value read and write, so a host can back it with memory, a file, or a
|
|
62
|
+
* database, and this module never has to know which.
|
|
63
|
+
*/
|
|
64
|
+
export type CalibrationPersistence = {
|
|
65
|
+
readonly load: (key: string) => readonly CalibrationSample[];
|
|
66
|
+
readonly save: (key: string, samples: readonly CalibrationSample[]) => void;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* An in-memory {@link CalibrationPersistence} — the default a
|
|
70
|
+
* {@link createCalibrationStore} caller gets when it supplies none. Samples
|
|
71
|
+
* live only as long as this object does; a host that wants them to survive a
|
|
72
|
+
* process restart supplies its own persistence instead.
|
|
73
|
+
*/
|
|
74
|
+
export declare const createInMemoryCalibrationPersistence: () => CalibrationPersistence;
|
|
75
|
+
/**
|
|
76
|
+
* The running median of `quoted ÷ estimated`, per (provider, fromChain,
|
|
77
|
+
* toChain), that a carrier's `estimate()` reads to correct its own
|
|
78
|
+
* price-and-fee figure — see this module's head note.
|
|
79
|
+
*/
|
|
80
|
+
export type CalibrationStore = {
|
|
81
|
+
/**
|
|
82
|
+
* The calibration factor to multiply an `estimate()` figure by for `key` —
|
|
83
|
+
* the median of its recorded ratios, or 1 (no correction) when fewer than
|
|
84
|
+
* {@link MIN_CALIBRATION_SAMPLES} have been recorded.
|
|
85
|
+
*
|
|
86
|
+
* @param key - the provider and directed chain pair
|
|
87
|
+
* @returns the factor
|
|
88
|
+
*/
|
|
89
|
+
readonly factor: (key: CalibrationKey) => number;
|
|
90
|
+
/**
|
|
91
|
+
* Records one confirmed quote against `key`'s series, keeping at most the
|
|
92
|
+
* {@link MAX_CALIBRATION_SAMPLES} most recent samples, and persists the
|
|
93
|
+
* result through {@link CalibrationPersistence.save}. A ratio that cannot
|
|
94
|
+
* be computed (see {@link calibrationRatio}) records nothing.
|
|
95
|
+
*
|
|
96
|
+
* @param key - the provider and directed chain pair
|
|
97
|
+
* @param quotedAmount - what a live `ask()` actually delivered
|
|
98
|
+
* @param estimatedAmount - what `estimate()` predicted for the same edge and input amount
|
|
99
|
+
*/
|
|
100
|
+
readonly record: (key: CalibrationKey, quotedAmount: bigint, estimatedAmount: bigint) => void;
|
|
101
|
+
};
|
|
102
|
+
/** What {@link createCalibrationStore} needs beyond the key it is asked about. */
|
|
103
|
+
export type CreateCalibrationStoreOptions = {
|
|
104
|
+
/** Where samples are kept between calls. Defaults to {@link createInMemoryCalibrationPersistence}. */
|
|
105
|
+
readonly persistence?: CalibrationPersistence;
|
|
106
|
+
/** The clock a recorded sample's `recordedAtMs` reads. Defaults to `Date.now`; a test supplies its own. */
|
|
107
|
+
readonly now?: () => number;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Builds a {@link CalibrationStore}.
|
|
111
|
+
*
|
|
112
|
+
* @param options - see {@link CreateCalibrationStoreOptions}
|
|
113
|
+
* @returns the store
|
|
114
|
+
*/
|
|
115
|
+
export declare const createCalibrationStore: (options?: CreateCalibrationStoreOptions) => CalibrationStore;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** How many recent samples one series keeps — "the last 50 confirmed quotes," the owner's own figure. */
|
|
2
|
+
export const MAX_CALIBRATION_SAMPLES = 50;
|
|
3
|
+
/**
|
|
4
|
+
* Below this many samples, {@link CalibrationStore.factor} answers 1 (no
|
|
5
|
+
* correction) rather than a median of too few points to trust — a single
|
|
6
|
+
* noisy quote should not swing a route's ranking.
|
|
7
|
+
*/
|
|
8
|
+
export const MIN_CALIBRATION_SAMPLES = 5;
|
|
9
|
+
/** Renders a {@link CalibrationKey} into the flat string a persistence layer stores it under. */
|
|
10
|
+
export const calibrationKeyString = ({ providerId, fromChainId, toChainId }) => `${providerId}:${fromChainId}:${toChainId}`;
|
|
11
|
+
/**
|
|
12
|
+
* Computes one sample's ratio from a confirmed live quote and this
|
|
13
|
+
* package's own prior estimate for the same edge and input amount — both
|
|
14
|
+
* already in the SAME delivered token's base units, so decimals cancel and
|
|
15
|
+
* no conversion is needed here.
|
|
16
|
+
*
|
|
17
|
+
* @param options.quotedAmount - what a live, confirmed `ask()` actually delivered, in the destination token's base units
|
|
18
|
+
* @param options.estimatedAmount - what `estimate()` predicted for the SAME edge and input amount, in the same units
|
|
19
|
+
* @returns the ratio, or null when `estimatedAmount` is non-positive and the ratio would be meaningless
|
|
20
|
+
*/
|
|
21
|
+
export const calibrationRatio = ({ quotedAmount, estimatedAmount, }) => {
|
|
22
|
+
if (estimatedAmount <= 0n) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
// Both bigints already share one token's base units, so this division is a
|
|
26
|
+
// dimensionless ratio, not a currency figure — `Number(bigint)`'s usual
|
|
27
|
+
// 2^53 precision ceiling only blurs the ratio's own last few digits here,
|
|
28
|
+
// never the correctness of which sample was recorded.
|
|
29
|
+
return Number(quotedAmount) / Number(estimatedAmount);
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* An in-memory {@link CalibrationPersistence} — the default a
|
|
33
|
+
* {@link createCalibrationStore} caller gets when it supplies none. Samples
|
|
34
|
+
* live only as long as this object does; a host that wants them to survive a
|
|
35
|
+
* process restart supplies its own persistence instead.
|
|
36
|
+
*/
|
|
37
|
+
export const createInMemoryCalibrationPersistence = () => {
|
|
38
|
+
const samplesByKey = new Map();
|
|
39
|
+
return {
|
|
40
|
+
load: (key) => samplesByKey.get(key) ?? [],
|
|
41
|
+
save: (key, samples) => {
|
|
42
|
+
samplesByKey.set(key, samples);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
/** The median of a non-empty array of numbers. */
|
|
47
|
+
const median = (values) => {
|
|
48
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
49
|
+
const middleIndex = Math.floor(sorted.length / 2);
|
|
50
|
+
if (sorted.length % 2 === 1) {
|
|
51
|
+
return sorted[middleIndex];
|
|
52
|
+
}
|
|
53
|
+
return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Builds a {@link CalibrationStore}.
|
|
57
|
+
*
|
|
58
|
+
* @param options - see {@link CreateCalibrationStoreOptions}
|
|
59
|
+
* @returns the store
|
|
60
|
+
*/
|
|
61
|
+
export const createCalibrationStore = (options = {}) => {
|
|
62
|
+
const persistence = options.persistence ?? createInMemoryCalibrationPersistence();
|
|
63
|
+
const now = options.now ?? Date.now;
|
|
64
|
+
return {
|
|
65
|
+
factor: (key) => {
|
|
66
|
+
const samples = persistence.load(calibrationKeyString(key));
|
|
67
|
+
if (samples.length < MIN_CALIBRATION_SAMPLES) {
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
return median(samples.map((sample) => sample.ratio));
|
|
71
|
+
},
|
|
72
|
+
record: (key, quotedAmount, estimatedAmount) => {
|
|
73
|
+
const ratio = calibrationRatio({ quotedAmount, estimatedAmount });
|
|
74
|
+
if (ratio === null) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const keyString = calibrationKeyString(key);
|
|
78
|
+
const existing = persistence.load(keyString);
|
|
79
|
+
const next = [...existing, { ratio, recordedAtMs: now() }].slice(-MAX_CALIBRATION_SAMPLES);
|
|
80
|
+
persistence.save(keyString, next);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Ecosystem } from '@gibs/bridge-sdk/ecosystems';
|
|
2
|
+
import type { ChainListRegistry } from '../chain-lists.js';
|
|
3
|
+
import { type CalibrationStore } from './calibration.js';
|
|
4
|
+
import { type LifiPriceCatalogue } from './price-catalogue.js';
|
|
5
|
+
import type { LifiProviderConfig, ProviderAsker } from '../types.js';
|
|
6
|
+
/**
|
|
7
|
+
* LI.FI as a built-in {@link ProviderAsker}: a plain, forward-solving
|
|
8
|
+
* `GET /v1/quote` pre-quote, ported from
|
|
9
|
+
* `packages/ui/src/lib/state/route-search-quotes.ts`'s `askLifiEdge` — with
|
|
10
|
+
* every `gibs.finance` fact (the integrator string, the browser's
|
|
11
|
+
* never-send-a-key header rule) replaced by a value this carrier reads from
|
|
12
|
+
* its own {@link CreateLifiCarrierOptions}, never a constant it supplies
|
|
13
|
+
* itself.
|
|
14
|
+
*
|
|
15
|
+
* NO NETWORK CALL HERE IS UNGUARDED. This module never imports `fetch`
|
|
16
|
+
* itself as a default beyond the runtime global — a host wires `fetchImpl`
|
|
17
|
+
* to `UpstreamFunnel`'s `guardedFetch` (`upstream.ts`), bound to `'lifi'`,
|
|
18
|
+
* so the concurrency cap, the Retry-After cooldown, the invalid-key breaker
|
|
19
|
+
* and the chain-id tripwire all run before this carrier's request ever
|
|
20
|
+
* leaves the process. Passing no `fetchImpl` (the global `fetch` directly)
|
|
21
|
+
* is a valid choice ONLY for a test or a caller that has its own guards.
|
|
22
|
+
*/
|
|
23
|
+
/** What {@link createLifiCarrier} needs beyond the edge and amount it is asked about. */
|
|
24
|
+
export type CreateLifiCarrierOptions = {
|
|
25
|
+
/** LI.FI's own configuration — the host's api key, integrator string, base url override, and referral fee. Every field optional; an absent one is simply never sent. */
|
|
26
|
+
readonly config?: LifiProviderConfig;
|
|
27
|
+
/**
|
|
28
|
+
* A server-held address, per ecosystem, this carrier names as its own
|
|
29
|
+
* `fromAddress`/`recipient` on a probe — see `QuoteClientConfig.probeAddresses`.
|
|
30
|
+
* An ecosystem with no address configured here refuses every ask for that
|
|
31
|
+
* ecosystem as `cannot-carry`, per `docs/quote-service.md`'s "Endpoint" section.
|
|
32
|
+
*/
|
|
33
|
+
readonly probeAddresses?: Readonly<Partial<Record<Ecosystem, string>>>;
|
|
34
|
+
/** The chain-list registry `chain-lists.ts` builds — read under the `'lifi'` key. */
|
|
35
|
+
readonly chainList: Pick<ChainListRegistry, 'chainIds'>;
|
|
36
|
+
/**
|
|
37
|
+
* The `fetch` this carrier's own request goes through. See this module's
|
|
38
|
+
* head note: a host should bind this to `UpstreamFunnel.guardedFetch('lifi', ...)`
|
|
39
|
+
* so every guard applies. Defaults to the runtime global `fetch`.
|
|
40
|
+
*/
|
|
41
|
+
readonly fetchImpl?: typeof fetch;
|
|
42
|
+
/**
|
|
43
|
+
* This carrier's own {@link LifiPriceCatalogue}, backing {@link Carrier.estimate}.
|
|
44
|
+
* Defaults to `createLifiPriceCatalogue({ baseUrl: config?.baseUrl, fetchImpl })`
|
|
45
|
+
* — a caller supplies its own only to inject a stub in a test, or to share
|
|
46
|
+
* one catalogue across more than one carrier instance.
|
|
47
|
+
*/
|
|
48
|
+
readonly priceCatalogue?: LifiPriceCatalogue;
|
|
49
|
+
/**
|
|
50
|
+
* This carrier's own {@link CalibrationStore}, correcting {@link Carrier.estimate}
|
|
51
|
+
* against confirmed `ask()` results. Defaults to an in-memory
|
|
52
|
+
* `createCalibrationStore()` — a caller supplies its own to persist
|
|
53
|
+
* calibration across process restarts.
|
|
54
|
+
*/
|
|
55
|
+
readonly calibrationStore?: CalibrationStore;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Builds LI.FI as a {@link ProviderAsker}.
|
|
59
|
+
*
|
|
60
|
+
* @param options - see {@link CreateLifiCarrierOptions}
|
|
61
|
+
* @returns the carrier
|
|
62
|
+
*/
|
|
63
|
+
export declare const createLifiCarrier: (options: CreateLifiCarrierOptions) => ProviderAsker;
|