@gibs/common 1.12.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/dist/assert.d.ts +18 -0
- package/{src/assert.ts → dist/assert.js} +9 -10
- package/dist/cache.d.ts +14 -0
- package/dist/cache.js +44 -0
- package/dist/client.d.ts +48 -0
- package/dist/client.js +164 -0
- package/dist/erc20.d.ts +20 -0
- package/dist/erc20.js +38 -0
- package/dist/index.js +7 -0
- package/dist/multicall.d.ts +56 -0
- package/dist/multicall.js +213 -0
- package/dist/rpcPacer.d.ts +88 -0
- package/dist/rpcPacer.js +153 -0
- package/dist/serialize.d.ts +19 -0
- package/dist/serialize.js +42 -0
- package/{src/types.ts → dist/types.d.ts} +5 -7
- package/dist/types.js +1 -0
- package/llms.txt +70 -0
- package/package.json +83 -17
- package/src/assert.test.ts +0 -141
- package/src/cache.test.ts +0 -176
- package/src/cache.ts +0 -48
- package/src/client.test.ts +0 -205
- package/src/client.ts +0 -76
- package/src/erc20.test.ts +0 -140
- package/src/erc20.ts +0 -43
- package/src/multicall.test.ts +0 -263
- package/src/multicall.ts +0 -68
- package/src/serialize.test.ts +0 -128
- package/src/serialize.ts +0 -30
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -13
- /package/{src/index.ts → dist/index.d.ts} +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holds outgoing JSON-RPC requests back so no method crosses the endpoint's
|
|
3
|
+
* per-second ceiling.
|
|
4
|
+
*
|
|
5
|
+
* WHY BATCH SIZE WAS NOT ENOUGH, WHICH IS THE WHOLE POINT OF THIS FILE. The
|
|
6
|
+
* transport already caps a single post at the ceiling, and that stops one post
|
|
7
|
+
* being refused for being too big. It says nothing about how many posts leave
|
|
8
|
+
* per second. Measured on one page load: nine `eth_call` inside a single rolling
|
|
9
|
+
* second, spread across four posts of which none was individually too large, and
|
|
10
|
+
* the fourth came back `429`.
|
|
11
|
+
*
|
|
12
|
+
* A ROLLING WINDOW, NOT A BUCKET. The first measurement of this counted calls
|
|
13
|
+
* into whole seconds aligned to the page load and reported nothing over the
|
|
14
|
+
* ceiling while a refusal sat in the same run — six calls at 1.9 seconds and
|
|
15
|
+
* seven at 2.1 fall in different buckets and land 200 milliseconds apart. A rate
|
|
16
|
+
* limiter does not know when our page started, so neither does this.
|
|
17
|
+
*
|
|
18
|
+
* ONE LIMITER FOR THE WHOLE APPLICATION, not one per client — but keyed on the
|
|
19
|
+
* HOST as well as the method.
|
|
20
|
+
*
|
|
21
|
+
* The budget is the host's, and it is shared across chains: six
|
|
22
|
+
* `eth_blockNumber` on PulseChain plus six on Ethereum were answered exactly
|
|
23
|
+
* eight times, the same as twelve on one chain. So a limiter per client would
|
|
24
|
+
* let two chains spend one budget twice and neither would know.
|
|
25
|
+
*
|
|
26
|
+
* But two DIFFERENT hosts have two different budgets, and counting them
|
|
27
|
+
* together would throttle a spare endpoint for traffic it never received —
|
|
28
|
+
* which is exactly backwards once requests are spread across hosts on purpose.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* The most calls of ONE method the proxy answers in one second. MEASURED.
|
|
32
|
+
*
|
|
33
|
+
* `one.valve.city` refuses past this with HTTP 429 and the body
|
|
34
|
+
* `Rate limit exceeded (per method, per second)`. Probed 2026-09-12 at
|
|
35
|
+
* concurrency 1, 2, 3, 4, 6, 8, 12, 16 and 24: every request up to eight was
|
|
36
|
+
* answered and everything past eight was refused, with no variance across
|
|
37
|
+
* twenty rounds. The budget is shared across CHAINS — six calls on chain 369
|
|
38
|
+
* plus six on chain 1 were answered exactly eight times, the same as twelve on
|
|
39
|
+
* one chain — so it is keyed on the method, not the chain.
|
|
40
|
+
*
|
|
41
|
+
* A BATCH THAT CROSSES THE CEILING IS REFUSED WHOLE, which is why this bounds
|
|
42
|
+
* the batch size. One post carrying sixteen `eth_blockNumber` calls came back
|
|
43
|
+
* with all sixteen refused; the same sixteen sent as separate posts had eight
|
|
44
|
+
* answered. Coalescing past the ceiling therefore converts a partial refusal
|
|
45
|
+
* into a total one, and viem's default of thirty-two calls per post is four
|
|
46
|
+
* times over it.
|
|
47
|
+
*/
|
|
48
|
+
export declare const MAX_SAME_METHOD_CALLS_PER_SECOND = 8;
|
|
49
|
+
/** How the pacer reads the clock and waits, so a test can supply its own. */
|
|
50
|
+
export interface PacerClock {
|
|
51
|
+
/** Milliseconds, monotonic enough for a one-second window. */
|
|
52
|
+
now: () => number;
|
|
53
|
+
/** Resolves after roughly `ms`. */
|
|
54
|
+
sleep: (ms: number) => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
/** A paced sender. */
|
|
57
|
+
export interface RpcPacer {
|
|
58
|
+
/**
|
|
59
|
+
* Waits until `counts` can be spent without crossing the ceiling, then
|
|
60
|
+
* records them as spent.
|
|
61
|
+
*
|
|
62
|
+
* @param counts - how many calls of each method this request carries.
|
|
63
|
+
*/
|
|
64
|
+
reserve: (host: string, counts: ReadonlyMap<string, number>) => Promise<void>;
|
|
65
|
+
/** How many calls of a method are inside the window for a host. Testing aid. */
|
|
66
|
+
spent: (host: string, method: string) => number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Builds a pacer.
|
|
70
|
+
*
|
|
71
|
+
* @param options - the ceiling, and optionally a clock to drive it.
|
|
72
|
+
* @returns a pacer.
|
|
73
|
+
*/
|
|
74
|
+
export declare const createRpcPacer: ({ ceiling, clock, }: {
|
|
75
|
+
ceiling: number;
|
|
76
|
+
clock?: PacerClock;
|
|
77
|
+
}) => RpcPacer;
|
|
78
|
+
/**
|
|
79
|
+
* Counts the calls per method in a JSON-RPC body.
|
|
80
|
+
*
|
|
81
|
+
* A batch is an ARRAY and a single call is an object; both arrive here, and a
|
|
82
|
+
* body that cannot be read at all counts as nothing rather than throwing —
|
|
83
|
+
* pacing is an optimisation and must never be the reason a request fails.
|
|
84
|
+
*
|
|
85
|
+
* @param body - the request body, as text.
|
|
86
|
+
* @returns method to how many calls of it the body carries.
|
|
87
|
+
*/
|
|
88
|
+
export declare const countMethods: (body: string | null | undefined) => Map<string, number>;
|
package/dist/rpcPacer.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holds outgoing JSON-RPC requests back so no method crosses the endpoint's
|
|
3
|
+
* per-second ceiling.
|
|
4
|
+
*
|
|
5
|
+
* WHY BATCH SIZE WAS NOT ENOUGH, WHICH IS THE WHOLE POINT OF THIS FILE. The
|
|
6
|
+
* transport already caps a single post at the ceiling, and that stops one post
|
|
7
|
+
* being refused for being too big. It says nothing about how many posts leave
|
|
8
|
+
* per second. Measured on one page load: nine `eth_call` inside a single rolling
|
|
9
|
+
* second, spread across four posts of which none was individually too large, and
|
|
10
|
+
* the fourth came back `429`.
|
|
11
|
+
*
|
|
12
|
+
* A ROLLING WINDOW, NOT A BUCKET. The first measurement of this counted calls
|
|
13
|
+
* into whole seconds aligned to the page load and reported nothing over the
|
|
14
|
+
* ceiling while a refusal sat in the same run — six calls at 1.9 seconds and
|
|
15
|
+
* seven at 2.1 fall in different buckets and land 200 milliseconds apart. A rate
|
|
16
|
+
* limiter does not know when our page started, so neither does this.
|
|
17
|
+
*
|
|
18
|
+
* ONE LIMITER FOR THE WHOLE APPLICATION, not one per client — but keyed on the
|
|
19
|
+
* HOST as well as the method.
|
|
20
|
+
*
|
|
21
|
+
* The budget is the host's, and it is shared across chains: six
|
|
22
|
+
* `eth_blockNumber` on PulseChain plus six on Ethereum were answered exactly
|
|
23
|
+
* eight times, the same as twelve on one chain. So a limiter per client would
|
|
24
|
+
* let two chains spend one budget twice and neither would know.
|
|
25
|
+
*
|
|
26
|
+
* But two DIFFERENT hosts have two different budgets, and counting them
|
|
27
|
+
* together would throttle a spare endpoint for traffic it never received —
|
|
28
|
+
* which is exactly backwards once requests are spread across hosts on purpose.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* The most calls of ONE method the proxy answers in one second. MEASURED.
|
|
32
|
+
*
|
|
33
|
+
* `one.valve.city` refuses past this with HTTP 429 and the body
|
|
34
|
+
* `Rate limit exceeded (per method, per second)`. Probed 2026-09-12 at
|
|
35
|
+
* concurrency 1, 2, 3, 4, 6, 8, 12, 16 and 24: every request up to eight was
|
|
36
|
+
* answered and everything past eight was refused, with no variance across
|
|
37
|
+
* twenty rounds. The budget is shared across CHAINS — six calls on chain 369
|
|
38
|
+
* plus six on chain 1 were answered exactly eight times, the same as twelve on
|
|
39
|
+
* one chain — so it is keyed on the method, not the chain.
|
|
40
|
+
*
|
|
41
|
+
* A BATCH THAT CROSSES THE CEILING IS REFUSED WHOLE, which is why this bounds
|
|
42
|
+
* the batch size. One post carrying sixteen `eth_blockNumber` calls came back
|
|
43
|
+
* with all sixteen refused; the same sixteen sent as separate posts had eight
|
|
44
|
+
* answered. Coalescing past the ceiling therefore converts a partial refusal
|
|
45
|
+
* into a total one, and viem's default of thirty-two calls per post is four
|
|
46
|
+
* times over it.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_SAME_METHOD_CALLS_PER_SECOND = 8;
|
|
49
|
+
const systemClock = {
|
|
50
|
+
now: () => Date.now(),
|
|
51
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
52
|
+
};
|
|
53
|
+
/** The window the ceiling is counted over. */
|
|
54
|
+
const WINDOW_MS = 1_000;
|
|
55
|
+
/**
|
|
56
|
+
* Builds a pacer.
|
|
57
|
+
*
|
|
58
|
+
* @param options - the ceiling, and optionally a clock to drive it.
|
|
59
|
+
* @returns a pacer.
|
|
60
|
+
*/
|
|
61
|
+
export const createRpcPacer = ({ ceiling, clock = systemClock, }) => {
|
|
62
|
+
/** When each still-counting call was sent, per host-and-method, oldest first. */
|
|
63
|
+
const sent = new Map();
|
|
64
|
+
/**
|
|
65
|
+
* Reservations run ONE AT A TIME, chained. Two callers checking the window
|
|
66
|
+
* concurrently both see room and both take it — the classic check-then-act
|
|
67
|
+
* race, and the reason a limiter that looks right can still emit bursts. The
|
|
68
|
+
* chain makes "is there room" and "take it" a single step.
|
|
69
|
+
*/
|
|
70
|
+
let queue = Promise.resolve();
|
|
71
|
+
const forget = (method, now) => {
|
|
72
|
+
const times = sent.get(method);
|
|
73
|
+
if (!times)
|
|
74
|
+
return [];
|
|
75
|
+
// Times are appended in order, so everything expired is a prefix.
|
|
76
|
+
let keep = 0;
|
|
77
|
+
while (keep < times.length && now - times[keep] >= WINDOW_MS)
|
|
78
|
+
keep += 1;
|
|
79
|
+
const live = keep === 0 ? times : times.slice(keep);
|
|
80
|
+
sent.set(method, live);
|
|
81
|
+
return live;
|
|
82
|
+
};
|
|
83
|
+
const reserveOne = async (key, count) => {
|
|
84
|
+
// A request carrying MORE than the ceiling of one method cannot ever fit,
|
|
85
|
+
// and waiting for room that will never exist would hang the page forever.
|
|
86
|
+
// The transport caps a post at the ceiling, so this is a guard against a
|
|
87
|
+
// caller that bypassed it rather than an expected path: spend what there is
|
|
88
|
+
// and let the endpoint answer for the rest.
|
|
89
|
+
const want = Math.min(count, ceiling);
|
|
90
|
+
for (;;) {
|
|
91
|
+
const now = clock.now();
|
|
92
|
+
const live = forget(key, now);
|
|
93
|
+
if (live.length + want <= ceiling) {
|
|
94
|
+
for (let i = 0; i < count; i += 1)
|
|
95
|
+
live.push(now);
|
|
96
|
+
sent.set(key, live);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Wait exactly until the oldest call leaves the window — not a fixed
|
|
100
|
+
// retry interval, which would either spin or overshoot. The `+ 1` is so
|
|
101
|
+
// the comparison above is past the boundary rather than on it.
|
|
102
|
+
const oldest = live[0];
|
|
103
|
+
await clock.sleep(Math.max(1, oldest + WINDOW_MS - now + 1));
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
reserve: (host, counts) => {
|
|
108
|
+
const run = queue.then(async () => {
|
|
109
|
+
// Ordered so the same body always reserves its methods in the same
|
|
110
|
+
// sequence; two bodies reserving the same pair in opposite orders is
|
|
111
|
+
// how a queue deadlocks itself.
|
|
112
|
+
for (const method of [...counts.keys()].sort()) {
|
|
113
|
+
await reserveOne(`${host}|${method}`, counts.get(method) ?? 0);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
// The queue must continue even when a reservation throws, or one failure
|
|
117
|
+
// stops every later request on the page.
|
|
118
|
+
queue = run.catch(() => undefined);
|
|
119
|
+
return run;
|
|
120
|
+
},
|
|
121
|
+
spent: (host, method) => forget(`${host}|${method}`, clock.now()).length,
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Counts the calls per method in a JSON-RPC body.
|
|
126
|
+
*
|
|
127
|
+
* A batch is an ARRAY and a single call is an object; both arrive here, and a
|
|
128
|
+
* body that cannot be read at all counts as nothing rather than throwing —
|
|
129
|
+
* pacing is an optimisation and must never be the reason a request fails.
|
|
130
|
+
*
|
|
131
|
+
* @param body - the request body, as text.
|
|
132
|
+
* @returns method to how many calls of it the body carries.
|
|
133
|
+
*/
|
|
134
|
+
export const countMethods = (body) => {
|
|
135
|
+
const counts = new Map();
|
|
136
|
+
if (!body)
|
|
137
|
+
return counts;
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(body);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return counts;
|
|
144
|
+
}
|
|
145
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
const method = entry?.method;
|
|
148
|
+
if (typeof method !== 'string')
|
|
149
|
+
continue;
|
|
150
|
+
counts.set(method, (counts.get(method) ?? 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
return counts;
|
|
153
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
declare const BIGINT_TYPE: "bigint";
|
|
2
|
+
type SerializedBigInt = {
|
|
3
|
+
__type__: typeof BIGINT_TYPE;
|
|
4
|
+
value: string;
|
|
5
|
+
};
|
|
6
|
+
/** The json any stringify for the given key and value */
|
|
7
|
+
export declare const jsonAnyStringify: (_key: string, value: unknown) => unknown;
|
|
8
|
+
/** The is serialized big int for the given value */
|
|
9
|
+
export declare const isSerializedBigInt: (value: unknown) => value is SerializedBigInt;
|
|
10
|
+
/**
|
|
11
|
+
* The json any parse for the given key and value.
|
|
12
|
+
*
|
|
13
|
+
* Throws on a corrupt encoded value rather than returning a number for it. Every
|
|
14
|
+
* caller reads from local storage inside a try/catch and falls back to a
|
|
15
|
+
* default, so a throw discards one unusable record; a silent zero would survive
|
|
16
|
+
* into the arithmetic.
|
|
17
|
+
*/
|
|
18
|
+
export declare const jsonAnyParse: (_key: string, value: unknown) => unknown;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const BIGINT_TYPE = 'bigint';
|
|
2
|
+
/** The json any stringify for the given key and value */
|
|
3
|
+
export const jsonAnyStringify = (_key, value) => {
|
|
4
|
+
if (typeof value === BIGINT_TYPE) {
|
|
5
|
+
return {
|
|
6
|
+
__type__: BIGINT_TYPE,
|
|
7
|
+
value: value.toString(),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
};
|
|
12
|
+
/** The is serialized big int for the given value */
|
|
13
|
+
export const isSerializedBigInt = (value) => {
|
|
14
|
+
return (!!value && typeof value === 'object' && value.__type__ === BIGINT_TYPE);
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The only strings `jsonAnyStringify` ever writes: what `bigint.toString()`
|
|
18
|
+
* produces, which is an optional minus sign followed by decimal digits.
|
|
19
|
+
*
|
|
20
|
+
* Anything else in the `value` field means the record is corrupt, and `BigInt`
|
|
21
|
+
* is too permissive to notice. `BigInt('')` is 0n and `BigInt('0x10')` is 16n,
|
|
22
|
+
* so a truncated or garbled record parses into a plausible-looking balance
|
|
23
|
+
* instead of an error — and a wrong balance is spent, displayed and believed.
|
|
24
|
+
*/
|
|
25
|
+
const DECIMAL_BIG_INT = /^-?\d+$/;
|
|
26
|
+
/**
|
|
27
|
+
* The json any parse for the given key and value.
|
|
28
|
+
*
|
|
29
|
+
* Throws on a corrupt encoded value rather than returning a number for it. Every
|
|
30
|
+
* caller reads from local storage inside a try/catch and falls back to a
|
|
31
|
+
* default, so a throw discards one unusable record; a silent zero would survive
|
|
32
|
+
* into the arithmetic.
|
|
33
|
+
*/
|
|
34
|
+
export const jsonAnyParse = (_key, value) => {
|
|
35
|
+
if (isSerializedBigInt(value)) {
|
|
36
|
+
if (typeof value.value !== 'string' || !DECIMAL_BIG_INT.test(value.value)) {
|
|
37
|
+
throw new TypeError(`corrupt stored big integer: ${JSON.stringify(value.value)} is not a decimal integer`);
|
|
38
|
+
}
|
|
39
|
+
return BigInt(value.value);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import type { Abi, Hex } from 'viem';
|
|
2
|
-
|
|
3
2
|
/** The inputs for a multicall read */
|
|
4
3
|
export type Call = {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
allowFailure?: boolean;
|
|
5
|
+
functionName: string;
|
|
6
|
+
target?: Hex;
|
|
7
|
+
abi?: Abi;
|
|
8
|
+
args?: (bigint | Hex | Hex[] | bigint[] | string | string[])[];
|
|
10
9
|
};
|
|
11
|
-
|
|
12
10
|
/** The erc20 metadata */
|
|
13
11
|
export type Erc20Metadata = [string, string, number];
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/llms.txt
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @gibs/common
|
|
2
|
+
|
|
3
|
+
> Low-level blockchain helpers shared across the Gibs Finance packages:
|
|
4
|
+
> cached viem clients with fallback URLs, Multicall3 batching, ERC-20
|
|
5
|
+
> metadata reads, memoizing caches and bigint-safe JSON. Nothing in it is
|
|
6
|
+
> specific to the bridge.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
- Run `npm install @gibs/common viem`.
|
|
11
|
+
- The package is ESM only. Use `import`, not `require`.
|
|
12
|
+
- It needs Node 20 or later.
|
|
13
|
+
- viem is a peer dependency. The range is `>=2.37.0 <3`.
|
|
14
|
+
- Version note: 1.12.0 and older on npm are broken. 1.13.0 is the first usable version.
|
|
15
|
+
|
|
16
|
+
## Entry points
|
|
17
|
+
|
|
18
|
+
- `@gibs/common`: everything below.
|
|
19
|
+
- `@gibs/common/client`: `clientFromChain({ chain, urls })`, `chainKey`, `clientCache`.
|
|
20
|
+
- `@gibs/common/erc20`: `multicallErc20`, `erc20MetadataCalls`.
|
|
21
|
+
- `@gibs/common/multicall`: `multicallRead`.
|
|
22
|
+
- `@gibs/common/cache`: `maxMemoize`, `ttlMemoizeSingle`.
|
|
23
|
+
- `@gibs/common/serialize`: `jsonAnyStringify`, `jsonAnyParse`, `isSerializedBigInt`.
|
|
24
|
+
- `@gibs/common/types`: `Call`, `Erc20Metadata`.
|
|
25
|
+
|
|
26
|
+
## Read a token's name, symbol and decimals
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { clientFromChain } from '@gibs/common/client'
|
|
30
|
+
import { multicallErc20 } from '@gibs/common/erc20'
|
|
31
|
+
import { mainnet } from 'viem/chains'
|
|
32
|
+
|
|
33
|
+
const client = clientFromChain({ chain: mainnet, urls: ['https://ethereum-rpc.publicnode.com'] })
|
|
34
|
+
const [name, symbol, decimals] = await multicallErc20({
|
|
35
|
+
client,
|
|
36
|
+
chain: mainnet,
|
|
37
|
+
target: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Keep a bigint through JSON
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { jsonAnyParse, jsonAnyStringify } from '@gibs/common/serialize'
|
|
45
|
+
|
|
46
|
+
const text = JSON.stringify({ balance: 10n ** 18n }, jsonAnyStringify)
|
|
47
|
+
const restored = JSON.parse(text, jsonAnyParse)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Memoize
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { maxMemoize, ttlMemoizeSingle } from '@gibs/common/cache'
|
|
54
|
+
|
|
55
|
+
const square = maxMemoize((x: number) => x * x, 100)
|
|
56
|
+
const price = ttlMemoizeSingle(async (symbol: string) => symbol, 30_000)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Rules
|
|
60
|
+
|
|
61
|
+
- `multicallRead` and `multicallErc20` need a chain object that carries a Multicall3 deployment. A chain without one fails.
|
|
62
|
+
- `clientFromChain` caches by chain id and URL list. The same URLs in a different order make a different client.
|
|
63
|
+
- `@gibs/common/client` takes a viem chain and URLs. `@gibs/bridge-client/client` takes a chain id. They are different functions.
|
|
64
|
+
- `multicallErc20` memoizes its answers. A repeat call with the same inputs does not read the chain again.
|
|
65
|
+
- The `@gibs/common/test-utils/fuzz` subpath is for tests inside the source repository. It is not in the npm package.
|
|
66
|
+
|
|
67
|
+
## Links
|
|
68
|
+
|
|
69
|
+
- [README](https://github.com/gibsfinance/bridge/blob/staging/packages/common/README.md)
|
|
70
|
+
- [Gibs Finance llms.txt](https://next.gibs.finance/llms.txt): the whole platform.
|
package/package.json
CHANGED
|
@@ -1,36 +1,102 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gibs/common",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.13.1",
|
|
4
|
+
"description": "Low-level blockchain helpers shared across the Gibs Finance packages: caching, multicall, and serialization.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ethereum",
|
|
7
|
+
"pulsechain",
|
|
8
|
+
"viem",
|
|
9
|
+
"multicall",
|
|
10
|
+
"cache"
|
|
11
|
+
],
|
|
12
|
+
"license": "ISC",
|
|
13
|
+
"author": "Gibs Finance",
|
|
14
|
+
"homepage": "https://gibs.finance",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/gibsfinance/bridge.git",
|
|
18
|
+
"directory": "packages/common"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/gibsfinance/bridge/issues"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
6
26
|
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
7
30
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"./
|
|
14
|
-
|
|
31
|
+
".": {
|
|
32
|
+
"gibs-source": "./src/index.ts",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./erc20": {
|
|
37
|
+
"gibs-source": "./src/erc20.ts",
|
|
38
|
+
"types": "./dist/erc20.d.ts",
|
|
39
|
+
"default": "./dist/erc20.js"
|
|
40
|
+
},
|
|
41
|
+
"./cache": {
|
|
42
|
+
"gibs-source": "./src/cache.ts",
|
|
43
|
+
"types": "./dist/cache.d.ts",
|
|
44
|
+
"default": "./dist/cache.js"
|
|
45
|
+
},
|
|
46
|
+
"./serialize": {
|
|
47
|
+
"gibs-source": "./src/serialize.ts",
|
|
48
|
+
"types": "./dist/serialize.d.ts",
|
|
49
|
+
"default": "./dist/serialize.js"
|
|
50
|
+
},
|
|
51
|
+
"./types": {
|
|
52
|
+
"gibs-source": "./src/types.ts",
|
|
53
|
+
"types": "./dist/types.d.ts",
|
|
54
|
+
"default": "./dist/types.js"
|
|
55
|
+
},
|
|
56
|
+
"./multicall": {
|
|
57
|
+
"gibs-source": "./src/multicall.ts",
|
|
58
|
+
"types": "./dist/multicall.d.ts",
|
|
59
|
+
"default": "./dist/multicall.js"
|
|
60
|
+
},
|
|
61
|
+
"./client": {
|
|
62
|
+
"gibs-source": "./src/client.ts",
|
|
63
|
+
"types": "./dist/client.d.ts",
|
|
64
|
+
"default": "./dist/client.js"
|
|
65
|
+
},
|
|
66
|
+
"./test-utils/fuzz": {
|
|
67
|
+
"gibs-source": "./src/test-utils/fuzz.ts",
|
|
68
|
+
"default": "./src/test-utils/fuzz.ts"
|
|
69
|
+
}
|
|
15
70
|
},
|
|
71
|
+
"files": [
|
|
72
|
+
"dist",
|
|
73
|
+
"!dist/tsconfig.tsbuildinfo",
|
|
74
|
+
"!dist/**/*.test-d.*",
|
|
75
|
+
"!dist/test-utils",
|
|
76
|
+
"LICENSE",
|
|
77
|
+
"llms.txt"
|
|
78
|
+
],
|
|
16
79
|
"scripts": {
|
|
80
|
+
"prepublishOnly": "npx tsc",
|
|
17
81
|
"build": "npx tsc",
|
|
18
82
|
"test": "vitest run",
|
|
19
83
|
"test:watch": "vitest",
|
|
20
84
|
"test:coverage": "vitest run --coverage"
|
|
21
85
|
},
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
86
|
+
"dependencies": {
|
|
87
|
+
"lodash-es": "^4.17.21"
|
|
88
|
+
},
|
|
89
|
+
"peerDependencies": {
|
|
90
|
+
"viem": ">=2.37.0 <3"
|
|
91
|
+
},
|
|
26
92
|
"devDependencies": {
|
|
27
93
|
"@types/node": "^24",
|
|
28
94
|
"@vitest/coverage-v8": "^3.1.1",
|
|
29
95
|
"typescript": "~5.7.2",
|
|
96
|
+
"viem": "^2.46.3",
|
|
30
97
|
"vitest": "^3.1.1"
|
|
31
98
|
},
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"viem": "^2.46.3"
|
|
99
|
+
"publishConfig": {
|
|
100
|
+
"access": "public"
|
|
35
101
|
}
|
|
36
102
|
}
|
package/src/assert.test.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { assertDefined, ensureResult } from './assert';
|
|
3
|
-
|
|
4
|
-
describe('assert', () => {
|
|
5
|
-
describe('ensureResult', () => {
|
|
6
|
-
it('should return value when truthy', () => {
|
|
7
|
-
expect(ensureResult('hello', 'test')).toBe('hello');
|
|
8
|
-
expect(ensureResult(123, 'test')).toBe(123);
|
|
9
|
-
expect(ensureResult(true, 'test')).toBe(true);
|
|
10
|
-
expect(ensureResult({ foo: 'bar' }, 'test')).toEqual({ foo: 'bar' });
|
|
11
|
-
expect(ensureResult([1, 2, 3], 'test')).toEqual([1, 2, 3]);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it('should throw Error when value is null', () => {
|
|
15
|
-
expect(() => ensureResult(null, 'TestContext')).toThrow(
|
|
16
|
-
'TestContext: Expected result but got null',
|
|
17
|
-
);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('should throw Error when value is undefined', () => {
|
|
21
|
-
expect(() => ensureResult(undefined, 'TestContext')).toThrow(
|
|
22
|
-
'TestContext: Expected result but got undefined',
|
|
23
|
-
);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('should throw Error when value is false', () => {
|
|
27
|
-
expect(() => ensureResult(false, 'TestContext')).toThrow(
|
|
28
|
-
'TestContext: Expected result but got false',
|
|
29
|
-
);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('should throw Error when value is 0', () => {
|
|
33
|
-
expect(() => ensureResult(0, 'TestContext')).toThrow(
|
|
34
|
-
'TestContext: Expected result but got 0',
|
|
35
|
-
);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('should include context in error message', () => {
|
|
39
|
-
expect(() => ensureResult(null, 'Multicall aggregate3')).toThrow(
|
|
40
|
-
'Multicall aggregate3: Expected result but got null',
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
expect(() => ensureResult(undefined, 'Database query')).toThrow(
|
|
44
|
-
'Database query: Expected result but got undefined',
|
|
45
|
-
);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('should handle BigInt values', () => {
|
|
49
|
-
expect(ensureResult(123n, 'test')).toBe(123n);
|
|
50
|
-
// 0n is falsy just like 0, so it should throw
|
|
51
|
-
expect(() => ensureResult(0n, 'test')).toThrow('test: Expected result but got 0');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('should handle empty string as falsy', () => {
|
|
55
|
-
expect(() => ensureResult('', 'TestContext')).toThrow(
|
|
56
|
-
'TestContext: Expected result but got ',
|
|
57
|
-
);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('should handle empty array as truthy', () => {
|
|
61
|
-
expect(ensureResult([], 'test')).toEqual([]);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('should throw Error, not the value itself', () => {
|
|
65
|
-
try {
|
|
66
|
-
ensureResult(null, 'test');
|
|
67
|
-
expect.fail('Should have thrown');
|
|
68
|
-
} catch (err) {
|
|
69
|
-
expect(err).toBeInstanceOf(Error);
|
|
70
|
-
expect(err).not.toBe(null);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe('assertDefined', () => {
|
|
76
|
-
it('should not throw when value is defined', () => {
|
|
77
|
-
expect(() => assertDefined('hello', 'test')).not.toThrow();
|
|
78
|
-
expect(() => assertDefined(123, 'test')).not.toThrow();
|
|
79
|
-
expect(() => assertDefined(true, 'test')).not.toThrow();
|
|
80
|
-
expect(() => assertDefined(false, 'test')).not.toThrow();
|
|
81
|
-
expect(() => assertDefined(0, 'test')).not.toThrow();
|
|
82
|
-
expect(() => assertDefined({}, 'test')).not.toThrow();
|
|
83
|
-
expect(() => assertDefined([], 'test')).not.toThrow();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('should throw when value is null', () => {
|
|
87
|
-
expect(() => assertDefined(null, 'Value cannot be null')).toThrow('Value cannot be null');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('should throw when value is undefined', () => {
|
|
91
|
-
expect(() => assertDefined(undefined, 'Value cannot be undefined')).toThrow(
|
|
92
|
-
'Value cannot be undefined',
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('should not throw for falsy but defined values', () => {
|
|
97
|
-
expect(() => assertDefined(false, 'test')).not.toThrow();
|
|
98
|
-
expect(() => assertDefined(0, 'test')).not.toThrow();
|
|
99
|
-
expect(() => assertDefined('', 'test')).not.toThrow();
|
|
100
|
-
expect(() => assertDefined(0n, 'test')).not.toThrow();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('should include message in error', () => {
|
|
104
|
-
expect(() => assertDefined(null, 'Custom error message')).toThrow('Custom error message');
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('should throw Error object', () => {
|
|
108
|
-
try {
|
|
109
|
-
assertDefined(null, 'test');
|
|
110
|
-
expect.fail('Should have thrown');
|
|
111
|
-
} catch (err) {
|
|
112
|
-
expect(err).toBeInstanceOf(Error);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('should work with type narrowing', () => {
|
|
117
|
-
const value: string | null = 'hello';
|
|
118
|
-
assertDefined(value, 'test');
|
|
119
|
-
// TypeScript should now know value is string
|
|
120
|
-
const uppercased: string = value.toUpperCase();
|
|
121
|
-
expect(uppercased).toBe('HELLO');
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
describe('integration', () => {
|
|
126
|
-
it('should allow chaining ensureResult', () => {
|
|
127
|
-
const obj = { nested: { value: 42 } };
|
|
128
|
-
const result = ensureResult(ensureResult(obj, 'obj').nested, 'nested').value;
|
|
129
|
-
expect(result).toBe(42);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('should provide clear error messages in real-world scenarios', async () => {
|
|
133
|
-
const mockApiCall = async (): Promise<any[] | null> => null;
|
|
134
|
-
|
|
135
|
-
await expect(async () => {
|
|
136
|
-
const result = await mockApiCall();
|
|
137
|
-
ensureResult(result, 'API call');
|
|
138
|
-
}).rejects.toThrow('API call: Expected result but got null');
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
});
|