solid-drift 0.14.0 → 0.16.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 +61 -0
- package/dist/fun.d.ts +87 -0
- package/dist/fun.js +143 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/web3data.d.ts +323 -0
- package/dist/web3data.js +751 -0
- package/package.json +1 -1
package/dist/web3data.js
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import { createEffect, createSignal, onCleanup, } from "solid-js";
|
|
2
|
+
/**
|
|
3
|
+
* Backoff polling infrastructure for the data primitives.
|
|
4
|
+
*
|
|
5
|
+
* Fetches immediately (unless `immediate: false`), then on `interval`.
|
|
6
|
+
* On error the interval multiplies by `backoff` up to `maxInterval` and
|
|
7
|
+
* resets on the next success. Uses `setTimeout`, so background tabs get
|
|
8
|
+
* the browser's natural timer throttling instead of a busy rAF loop.
|
|
9
|
+
* SSR-safe: never fetches on the server.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* const { data, status, retry, abort } = createPoll(
|
|
13
|
+
* async (signal) => {
|
|
14
|
+
* const res = await fetch("https://api.example.com/price", { signal });
|
|
15
|
+
* return res.json();
|
|
16
|
+
* },
|
|
17
|
+
* { interval: 30000 },
|
|
18
|
+
* );
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function createPoll(fetcher, options = {}) {
|
|
22
|
+
const { interval = 30000, backoff = 2, maxInterval = 300000, immediate = true, } = options;
|
|
23
|
+
const [data, setData] = createSignal(undefined);
|
|
24
|
+
const [error, setError] = createSignal(null);
|
|
25
|
+
const [status, setStatus] = createSignal("idle");
|
|
26
|
+
let timer = null;
|
|
27
|
+
let aborter = null;
|
|
28
|
+
let delay = interval;
|
|
29
|
+
let stopped = false;
|
|
30
|
+
const clearTimer = () => {
|
|
31
|
+
if (timer !== null) {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
timer = null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const run = async () => {
|
|
37
|
+
if (stopped || typeof window === "undefined")
|
|
38
|
+
return;
|
|
39
|
+
clearTimer();
|
|
40
|
+
aborter?.abort();
|
|
41
|
+
aborter = new AbortController();
|
|
42
|
+
const signal = aborter.signal;
|
|
43
|
+
setStatus("loading");
|
|
44
|
+
try {
|
|
45
|
+
const value = await fetcher(signal);
|
|
46
|
+
if (signal.aborted || stopped)
|
|
47
|
+
return;
|
|
48
|
+
setData(() => value);
|
|
49
|
+
setError(null);
|
|
50
|
+
setStatus("success");
|
|
51
|
+
delay = interval;
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
if (signal.aborted || stopped)
|
|
55
|
+
return;
|
|
56
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
57
|
+
setStatus("error");
|
|
58
|
+
delay = Math.min(delay * backoff, maxInterval);
|
|
59
|
+
}
|
|
60
|
+
if (!stopped && typeof window !== "undefined") {
|
|
61
|
+
timer = setTimeout(() => void run(), delay);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const retry = () => {
|
|
65
|
+
stopped = false;
|
|
66
|
+
delay = interval;
|
|
67
|
+
void run();
|
|
68
|
+
};
|
|
69
|
+
const abort = () => {
|
|
70
|
+
stopped = true;
|
|
71
|
+
clearTimer();
|
|
72
|
+
aborter?.abort();
|
|
73
|
+
aborter = null;
|
|
74
|
+
};
|
|
75
|
+
if (typeof window !== "undefined" && immediate) {
|
|
76
|
+
void run();
|
|
77
|
+
}
|
|
78
|
+
onCleanup(abort);
|
|
79
|
+
return { data, error, status, retry, abort };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Shorten an EVM address: `0x1234567890abcdef...` becomes `0x1234…abcd`.
|
|
83
|
+
* Returns the input unchanged when it is not a valid address.
|
|
84
|
+
*/
|
|
85
|
+
export function shortenAddress(address, chars = 4) {
|
|
86
|
+
if (!isAddress(address))
|
|
87
|
+
return address;
|
|
88
|
+
return `${address.slice(0, 2 + chars)}…${address.slice(-chars)}`;
|
|
89
|
+
}
|
|
90
|
+
/** True for `0x` + 40 hex chars. Checksum-agnostic. */
|
|
91
|
+
export function isAddress(value) {
|
|
92
|
+
return /^0x[0-9a-fA-F]{40}$/.test(value);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Format a wei-style integer as a decimal string: `formatUnits(1500000000000000000n)`
|
|
96
|
+
* is `"1.5"`. Accepts bigint or integer strings. BigInt-safe, no floats.
|
|
97
|
+
*/
|
|
98
|
+
export function formatUnits(value, decimals = 18) {
|
|
99
|
+
const str = typeof value === "bigint" ? value.toString() : value;
|
|
100
|
+
if (!/^-?\d+$/.test(str)) {
|
|
101
|
+
throw new Error("formatUnits: value must be an integer string or bigint.");
|
|
102
|
+
}
|
|
103
|
+
const negative = str.startsWith("-");
|
|
104
|
+
const digits = negative ? str.slice(1) : str;
|
|
105
|
+
const padded = digits.padStart(decimals + 1, "0");
|
|
106
|
+
const int = decimals > 0 ? padded.slice(0, -decimals) : padded;
|
|
107
|
+
const frac = decimals > 0 ? padded.slice(-decimals).replace(/0+$/, "") : "";
|
|
108
|
+
const intClean = int.replace(/^0+(?=\d)/, "");
|
|
109
|
+
return (negative ? "-" : "") + intClean + (frac ? `.${frac}` : "");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Parse a decimal string into wei-style bigint: `parseUnits("1.5")` is
|
|
113
|
+
* `1500000000000000000n`. Throws on invalid input or too many decimals.
|
|
114
|
+
*/
|
|
115
|
+
export function parseUnits(value, decimals = 18) {
|
|
116
|
+
const m = /^(-?)(\d*)(?:\.(\d*))?$/.exec(value.trim());
|
|
117
|
+
if (!m || (m[2] === "" && m[3] === "")) {
|
|
118
|
+
throw new Error("parseUnits: invalid decimal string.");
|
|
119
|
+
}
|
|
120
|
+
const fracPart = m[3] ?? "";
|
|
121
|
+
if (fracPart.length > decimals) {
|
|
122
|
+
throw new Error("parseUnits: too many decimal places.");
|
|
123
|
+
}
|
|
124
|
+
const int = (m[2] === "" ? "0" : m[2]) + fracPart.padEnd(decimals, "0");
|
|
125
|
+
const clean = int.replace(/^0+(?=\d)/, "");
|
|
126
|
+
return BigInt(`${m[1]}${clean}`);
|
|
127
|
+
}
|
|
128
|
+
/** Registry of well-known EVM chains: id to name, currency, explorer, RPC. */
|
|
129
|
+
export const CHAINS = {
|
|
130
|
+
1: {
|
|
131
|
+
id: 1,
|
|
132
|
+
name: "Ethereum",
|
|
133
|
+
currency: "ETH",
|
|
134
|
+
decimals: 18,
|
|
135
|
+
explorer: "https://etherscan.io",
|
|
136
|
+
rpc: "https://ethereum.publicnode.com",
|
|
137
|
+
},
|
|
138
|
+
10: {
|
|
139
|
+
id: 10,
|
|
140
|
+
name: "Optimism",
|
|
141
|
+
currency: "ETH",
|
|
142
|
+
decimals: 18,
|
|
143
|
+
explorer: "https://optimistic.etherscan.io",
|
|
144
|
+
rpc: "https://optimism.publicnode.com",
|
|
145
|
+
},
|
|
146
|
+
56: {
|
|
147
|
+
id: 56,
|
|
148
|
+
name: "BNB Chain",
|
|
149
|
+
currency: "BNB",
|
|
150
|
+
decimals: 18,
|
|
151
|
+
explorer: "https://bscscan.com",
|
|
152
|
+
rpc: "https://bsc.publicnode.com",
|
|
153
|
+
},
|
|
154
|
+
137: {
|
|
155
|
+
id: 137,
|
|
156
|
+
name: "Polygon",
|
|
157
|
+
currency: "POL",
|
|
158
|
+
decimals: 18,
|
|
159
|
+
explorer: "https://polygonscan.com",
|
|
160
|
+
rpc: "https://polygon-bor.publicnode.com",
|
|
161
|
+
},
|
|
162
|
+
8453: {
|
|
163
|
+
id: 8453,
|
|
164
|
+
name: "Base",
|
|
165
|
+
currency: "ETH",
|
|
166
|
+
decimals: 18,
|
|
167
|
+
explorer: "https://basescan.org",
|
|
168
|
+
rpc: "https://base.publicnode.com",
|
|
169
|
+
},
|
|
170
|
+
42161: {
|
|
171
|
+
id: 42161,
|
|
172
|
+
name: "Arbitrum One",
|
|
173
|
+
currency: "ETH",
|
|
174
|
+
decimals: 18,
|
|
175
|
+
explorer: "https://arbiscan.io",
|
|
176
|
+
rpc: "https://arbitrum-one.publicnode.com",
|
|
177
|
+
},
|
|
178
|
+
11155111: {
|
|
179
|
+
id: 11155111,
|
|
180
|
+
name: "Sepolia",
|
|
181
|
+
currency: "ETH",
|
|
182
|
+
decimals: 18,
|
|
183
|
+
explorer: "https://sepolia.etherscan.io",
|
|
184
|
+
rpc: "https://ethereum-sepolia.publicnode.com",
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Look up a chain in `CHAINS` as an accessor. Unknown ids give `undefined`.
|
|
189
|
+
*
|
|
190
|
+
* ```ts
|
|
191
|
+
* const chain = createChain(() => 1)
|
|
192
|
+
* chain()?.explorer // "https://etherscan.io"
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
export function createChain(source) {
|
|
196
|
+
const get = typeof source === "function" ? source : () => source;
|
|
197
|
+
return () => CHAINS[get()];
|
|
198
|
+
}
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// keccak256 (for ENS namehash). Compact BigInt implementation, no dependencies.
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
const KECCAK_RC = [
|
|
203
|
+
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an,
|
|
204
|
+
0x8000000080008000n, 0x000000000000808bn, 0x0000000080000001n,
|
|
205
|
+
0x8000000080008081n, 0x8000000000008009n, 0x000000000000008an,
|
|
206
|
+
0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
|
|
207
|
+
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n,
|
|
208
|
+
0x8000000000008003n, 0x8000000000008002n, 0x8000000000000080n,
|
|
209
|
+
0x000000000000800an, 0x800000008000000an, 0x8000000080008081n,
|
|
210
|
+
0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
|
|
211
|
+
];
|
|
212
|
+
const KECCAK_ROT = [
|
|
213
|
+
[0, 36, 3, 41, 18],
|
|
214
|
+
[1, 44, 10, 45, 2],
|
|
215
|
+
[62, 6, 43, 15, 61],
|
|
216
|
+
[28, 55, 25, 21, 56],
|
|
217
|
+
[27, 20, 39, 8, 14],
|
|
218
|
+
];
|
|
219
|
+
const MASK64 = 0xffffffffffffffffn;
|
|
220
|
+
function rotl64(v, n) {
|
|
221
|
+
if (n === 0)
|
|
222
|
+
return v;
|
|
223
|
+
return (((v << BigInt(n)) | (v >> BigInt(64 - n))) & MASK64);
|
|
224
|
+
}
|
|
225
|
+
function keccakF(state) {
|
|
226
|
+
for (let round = 0; round < 24; round++) {
|
|
227
|
+
const c = [0n, 0n, 0n, 0n, 0n];
|
|
228
|
+
for (let x = 0; x < 5; x++) {
|
|
229
|
+
for (let y = 0; y < 5; y++)
|
|
230
|
+
c[x] ^= state[x + 5 * y];
|
|
231
|
+
}
|
|
232
|
+
const d = [0n, 0n, 0n, 0n, 0n];
|
|
233
|
+
for (let x = 0; x < 5; x++) {
|
|
234
|
+
d[x] = c[(x + 4) % 5] ^ rotl64(c[(x + 1) % 5], 1);
|
|
235
|
+
}
|
|
236
|
+
for (let x = 0; x < 5; x++) {
|
|
237
|
+
for (let y = 0; y < 5; y++)
|
|
238
|
+
state[x + 5 * y] ^= d[x];
|
|
239
|
+
}
|
|
240
|
+
const b = new Array(25);
|
|
241
|
+
for (let x = 0; x < 5; x++) {
|
|
242
|
+
for (let y = 0; y < 5; y++) {
|
|
243
|
+
b[y + 5 * ((2 * x + 3 * y) % 5)] = rotl64(state[x + 5 * y], KECCAK_ROT[x][y]);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (let x = 0; x < 5; x++) {
|
|
247
|
+
for (let y = 0; y < 5; y++) {
|
|
248
|
+
state[x + 5 * y] =
|
|
249
|
+
b[x + 5 * y] ^ ((~b[(x + 1) % 5 + 5 * y] & MASK64) & b[(x + 2) % 5 + 5 * y]);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
state[0] ^= KECCAK_RC[round];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* keccak256 of a byte array. Exported for tests; not part of the public API.
|
|
257
|
+
*/
|
|
258
|
+
export function keccak256(data) {
|
|
259
|
+
const RATE = 136;
|
|
260
|
+
let total = data.length + 1;
|
|
261
|
+
total = total % RATE === 0 ? total + RATE : total + (RATE - (total % RATE));
|
|
262
|
+
const padded = new Uint8Array(total);
|
|
263
|
+
padded.set(data, 0);
|
|
264
|
+
padded[data.length] = 0x01;
|
|
265
|
+
padded[total - 1] |= 0x80;
|
|
266
|
+
const state = new Array(25).fill(0n);
|
|
267
|
+
for (let off = 0; off < total; off += RATE) {
|
|
268
|
+
for (let i = 0; i < RATE / 8; i++) {
|
|
269
|
+
let lane = 0n;
|
|
270
|
+
for (let j = 0; j < 8; j++) {
|
|
271
|
+
lane |= BigInt(padded[off + i * 8 + j]) << BigInt(8 * j);
|
|
272
|
+
}
|
|
273
|
+
state[i] ^= lane;
|
|
274
|
+
}
|
|
275
|
+
keccakF(state);
|
|
276
|
+
}
|
|
277
|
+
const out = new Uint8Array(32);
|
|
278
|
+
for (let i = 0; i < 4; i++) {
|
|
279
|
+
for (let j = 0; j < 8; j++) {
|
|
280
|
+
out[i * 8 + j] = Number((state[i] >> BigInt(8 * j)) & 0xffn);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
function bytesToHex(bytes) {
|
|
286
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
287
|
+
}
|
|
288
|
+
function namehash(name) {
|
|
289
|
+
let node = new Uint8Array(32);
|
|
290
|
+
if (name) {
|
|
291
|
+
const labels = name.split(".");
|
|
292
|
+
for (let i = labels.length - 1; i >= 0; i--) {
|
|
293
|
+
const combined = new Uint8Array(64);
|
|
294
|
+
combined.set(node, 0);
|
|
295
|
+
combined.set(keccak256(new TextEncoder().encode(labels[i])), 32);
|
|
296
|
+
node = keccak256(combined);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return `0x${bytesToHex(node)}`;
|
|
300
|
+
}
|
|
301
|
+
const DEFAULT_ENDPOINT = "https://ethereum.publicnode.com";
|
|
302
|
+
async function rpc(endpoint, method, params, signal) {
|
|
303
|
+
const res = await fetch(endpoint, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: { "content-type": "application/json" },
|
|
306
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
307
|
+
signal,
|
|
308
|
+
});
|
|
309
|
+
if (!res.ok) {
|
|
310
|
+
throw new Error(`RPC ${method} failed with HTTP ${res.status}.`);
|
|
311
|
+
}
|
|
312
|
+
const json = (await res.json());
|
|
313
|
+
if (json.error) {
|
|
314
|
+
throw new Error(`RPC ${method} error: ${json.error.message ?? "unknown"}.`);
|
|
315
|
+
}
|
|
316
|
+
return json.result;
|
|
317
|
+
}
|
|
318
|
+
function parseAbiString(hex) {
|
|
319
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
320
|
+
const len = parseInt(clean.slice(64, 128), 16);
|
|
321
|
+
const dataHex = clean.slice(128, 128 + len * 2);
|
|
322
|
+
const bytes = new Uint8Array(dataHex.length / 2);
|
|
323
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
324
|
+
bytes[i] = parseInt(dataHex.slice(i * 2, i * 2 + 2), 16);
|
|
325
|
+
}
|
|
326
|
+
return new TextDecoder().decode(bytes);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Live token price as a signal, via CoinGecko's public API.
|
|
330
|
+
*
|
|
331
|
+
* The free endpoint is rate-limited; the default 60s interval is
|
|
332
|
+
* conservative on purpose. Pass your own `endpoint` (any base that
|
|
333
|
+
* answers `/simple/price?ids={id}&vs_currencies={vs}&include_24hr_change=true`).
|
|
334
|
+
*
|
|
335
|
+
* ```ts
|
|
336
|
+
* const { price, change24h, status } = createTokenPrice("ethereum");
|
|
337
|
+
* <Show when={status() === "success"}>
|
|
338
|
+
* ${(price() ?? 0).toFixed(2)} ({(change24h() ?? 0).toFixed(1)}%)
|
|
339
|
+
* </Show>
|
|
340
|
+
* ```
|
|
341
|
+
*/
|
|
342
|
+
export function createTokenPrice(tokenId, options = {}) {
|
|
343
|
+
const { vsCurrency = "usd", endpoint = "https://api.coingecko.com/api/v3", ...poll } = options;
|
|
344
|
+
const getId = typeof tokenId === "function" ? tokenId : () => tokenId;
|
|
345
|
+
const p = createPoll(async (signal) => {
|
|
346
|
+
const res = await fetch(`${endpoint}/simple/price?ids=${encodeURIComponent(getId())}&vs_currencies=${encodeURIComponent(vsCurrency)}&include_24hr_change=true`, { signal });
|
|
347
|
+
if (!res.ok) {
|
|
348
|
+
throw new Error(`Price fetch failed with HTTP ${res.status}.`);
|
|
349
|
+
}
|
|
350
|
+
const json = (await res.json());
|
|
351
|
+
const row = json[getId()];
|
|
352
|
+
if (!row || typeof row[vsCurrency] !== "number") {
|
|
353
|
+
throw new Error(`No price returned for "${getId()}".`);
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
price: row[vsCurrency],
|
|
357
|
+
change24h: row[`${vsCurrency}_24h_change`],
|
|
358
|
+
};
|
|
359
|
+
}, { interval: 60000, ...poll });
|
|
360
|
+
return {
|
|
361
|
+
...p,
|
|
362
|
+
price: () => p.data()?.price,
|
|
363
|
+
change24h: () => p.data()?.change24h,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Percent change of any numeric signal over a rolling window.
|
|
368
|
+
*
|
|
369
|
+
* Samples the source on each change and on `sampleMs`, keeps samples
|
|
370
|
+
* within `windowMs`, and reports `(last - first) / first * 100`.
|
|
371
|
+
* `undefined` until at least two samples exist. Signal-native, no network.
|
|
372
|
+
*
|
|
373
|
+
* ```ts
|
|
374
|
+
* const { price } = createTokenPrice("ethereum");
|
|
375
|
+
* const { change, reset } = createPriceChange(price);
|
|
376
|
+
* ```
|
|
377
|
+
*/
|
|
378
|
+
export function createPriceChange(source, options = {}) {
|
|
379
|
+
const { windowMs = 3600000, sampleMs = 60000 } = options;
|
|
380
|
+
const [samples, setSamples] = createSignal([]);
|
|
381
|
+
const take = () => {
|
|
382
|
+
const v = source();
|
|
383
|
+
if (v == null || typeof window === "undefined")
|
|
384
|
+
return;
|
|
385
|
+
const t = Date.now();
|
|
386
|
+
setSamples((prev) => [...prev.filter((s) => t - s.t <= windowMs), { t, v }]);
|
|
387
|
+
};
|
|
388
|
+
createEffect(() => {
|
|
389
|
+
source();
|
|
390
|
+
take();
|
|
391
|
+
});
|
|
392
|
+
if (typeof window !== "undefined") {
|
|
393
|
+
const id = setInterval(take, sampleMs);
|
|
394
|
+
onCleanup(() => clearInterval(id));
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
change: () => {
|
|
398
|
+
const list = samples();
|
|
399
|
+
if (list.length < 2)
|
|
400
|
+
return undefined;
|
|
401
|
+
const first = list[0].v;
|
|
402
|
+
const last = list[list.length - 1].v;
|
|
403
|
+
if (first === 0)
|
|
404
|
+
return undefined;
|
|
405
|
+
return ((last - first) / Math.abs(first)) * 100;
|
|
406
|
+
},
|
|
407
|
+
reset: () => setSamples([]),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Compare two numeric signals: their ratio, percent difference, and which
|
|
412
|
+
* is larger. Any side `undefined` makes everything `undefined` until both
|
|
413
|
+
* have values. Signal-native, no network.
|
|
414
|
+
*
|
|
415
|
+
* ```ts
|
|
416
|
+
* const { price: eth } = createTokenPrice("ethereum");
|
|
417
|
+
* const { price: btc } = createTokenPrice("bitcoin");
|
|
418
|
+
* const { ratio, diffPercent, leader } = createPriceCompare(eth, btc);
|
|
419
|
+
* ```
|
|
420
|
+
*/
|
|
421
|
+
export function createPriceCompare(a, b) {
|
|
422
|
+
return {
|
|
423
|
+
ratio: () => {
|
|
424
|
+
const x = a();
|
|
425
|
+
const y = b();
|
|
426
|
+
if (x == null || y == null || y === 0)
|
|
427
|
+
return undefined;
|
|
428
|
+
return x / y;
|
|
429
|
+
},
|
|
430
|
+
diffPercent: () => {
|
|
431
|
+
const x = a();
|
|
432
|
+
const y = b();
|
|
433
|
+
if (x == null || y == null || y === 0)
|
|
434
|
+
return undefined;
|
|
435
|
+
return ((x - y) / Math.abs(y)) * 100;
|
|
436
|
+
},
|
|
437
|
+
leader: () => {
|
|
438
|
+
const x = a();
|
|
439
|
+
const y = b();
|
|
440
|
+
if (x == null || y == null)
|
|
441
|
+
return undefined;
|
|
442
|
+
if (x === y)
|
|
443
|
+
return "tie";
|
|
444
|
+
return x > y ? "a" : "b";
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Current gas price over JSON-RPC (`eth_gasPrice`), as wei bigint and gwei.
|
|
450
|
+
* Default 15s polling. Swap `endpoint` for any chain.
|
|
451
|
+
*/
|
|
452
|
+
export function createGasPrice(options = {}) {
|
|
453
|
+
const { endpoint = DEFAULT_ENDPOINT, ...poll } = options;
|
|
454
|
+
const p = createPoll(async (signal) => {
|
|
455
|
+
const hex = await rpc(endpoint, "eth_gasPrice", [], signal);
|
|
456
|
+
const wei = BigInt(hex);
|
|
457
|
+
return { wei, gwei: Number(wei) / 1e9 };
|
|
458
|
+
}, { interval: 15000, ...poll });
|
|
459
|
+
return { ...p, wei: () => p.data()?.wei, gwei: () => p.data()?.gwei };
|
|
460
|
+
}
|
|
461
|
+
const BALANCE_OF_SELECTOR = "0x70a08231";
|
|
462
|
+
/**
|
|
463
|
+
* Token balance of an address: native (`eth_getBalance`) or ERC20
|
|
464
|
+
* (`balanceOf` via `eth_call`). Read-only; never signs.
|
|
465
|
+
*
|
|
466
|
+
* ```ts
|
|
467
|
+
* const { formatted } = createBalance("0xabc…", { token: "0xdef…" });
|
|
468
|
+
* ```
|
|
469
|
+
*/
|
|
470
|
+
export function createBalance(address, options = {}) {
|
|
471
|
+
const { endpoint = DEFAULT_ENDPOINT, token, decimals = 18, ...poll } = options;
|
|
472
|
+
if (!isAddress(address)) {
|
|
473
|
+
throw new Error("createBalance: invalid address.");
|
|
474
|
+
}
|
|
475
|
+
const p = createPoll(async (signal) => {
|
|
476
|
+
let hex;
|
|
477
|
+
if (token) {
|
|
478
|
+
if (!isAddress(token)) {
|
|
479
|
+
throw new Error("createBalance: invalid token address.");
|
|
480
|
+
}
|
|
481
|
+
const data = BALANCE_OF_SELECTOR + address.slice(2).toLowerCase().padStart(64, "0");
|
|
482
|
+
hex = await rpc(endpoint, "eth_call", [{ to: token, data }, "latest"], signal);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
hex = await rpc(endpoint, "eth_getBalance", [address, "latest"], signal);
|
|
486
|
+
}
|
|
487
|
+
const balance = BigInt(hex);
|
|
488
|
+
return { balance, formatted: formatUnits(balance, decimals) };
|
|
489
|
+
}, { interval: 20000, ...poll });
|
|
490
|
+
return {
|
|
491
|
+
...p,
|
|
492
|
+
balance: () => p.data()?.balance,
|
|
493
|
+
formatted: () => p.data()?.formatted,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Watch a transaction hash until its receipt lands. Polls every 4s and
|
|
498
|
+
* stops on its own once the receipt arrives; `mined()` mirrors that.
|
|
499
|
+
* Read-only confirmation; pairs with `createTxLifecycle` from web3.ts.
|
|
500
|
+
*/
|
|
501
|
+
export function createTxReceipt(hash, options = {}) {
|
|
502
|
+
const { endpoint = DEFAULT_ENDPOINT, ...poll } = options;
|
|
503
|
+
const p = createPoll(async (signal) => {
|
|
504
|
+
const raw = await rpc(endpoint, "eth_getTransactionReceipt", [hash], signal);
|
|
505
|
+
if (!raw)
|
|
506
|
+
return null;
|
|
507
|
+
return {
|
|
508
|
+
transactionHash: raw.transactionHash,
|
|
509
|
+
blockNumber: parseInt(raw.blockNumber, 16),
|
|
510
|
+
success: raw.status === "0x1",
|
|
511
|
+
gasUsed: BigInt(raw.gasUsed),
|
|
512
|
+
};
|
|
513
|
+
}, { interval: 4000, ...poll });
|
|
514
|
+
createEffect(() => {
|
|
515
|
+
if (p.data() != null)
|
|
516
|
+
p.abort();
|
|
517
|
+
});
|
|
518
|
+
return { ...p, receipt: p.data, mined: () => p.data() != null };
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Latest block number over JSON-RPC. Default 12s polling.
|
|
522
|
+
* Handy as a chain-health heartbeat and a cache-busting ticker.
|
|
523
|
+
*/
|
|
524
|
+
export function createBlockNumber(options = {}) {
|
|
525
|
+
const { endpoint = DEFAULT_ENDPOINT, ...poll } = options;
|
|
526
|
+
const p = createPoll(async (signal) => {
|
|
527
|
+
const hex = await rpc(endpoint, "eth_blockNumber", [], signal);
|
|
528
|
+
return parseInt(hex, 16);
|
|
529
|
+
}, { interval: 12000, ...poll });
|
|
530
|
+
return { ...p, blockNumber: p.data };
|
|
531
|
+
}
|
|
532
|
+
const CHAINLINK_DECIMALS_SELECTOR = "0x313ce567";
|
|
533
|
+
const CHAINLINK_LATEST_SELECTOR = "0xfeaf968c";
|
|
534
|
+
/**
|
|
535
|
+
* Read a Chainlink `AggregatorV3Interface` price feed on-chain:
|
|
536
|
+
* `decimals()` once, then `latestRoundData()` polled every 30s.
|
|
537
|
+
* Feed addresses live on the
|
|
538
|
+
* [Chainlink docs](https://docs.chain.link/data-feeds/price-feeds/addresses).
|
|
539
|
+
*
|
|
540
|
+
* ```ts
|
|
541
|
+
* // ETH / USD feed on mainnet
|
|
542
|
+
* const { price } = createChainlinkPrice("0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419");
|
|
543
|
+
* ```
|
|
544
|
+
*/
|
|
545
|
+
export function createChainlinkPrice(feed, options = {}) {
|
|
546
|
+
const { endpoint = DEFAULT_ENDPOINT, ...poll } = options;
|
|
547
|
+
if (!isAddress(feed)) {
|
|
548
|
+
throw new Error("createChainlinkPrice: invalid feed address.");
|
|
549
|
+
}
|
|
550
|
+
let decimals = null;
|
|
551
|
+
const p = createPoll(async (signal) => {
|
|
552
|
+
if (decimals === null) {
|
|
553
|
+
const raw = await rpc(endpoint, "eth_call", [{ to: feed, data: CHAINLINK_DECIMALS_SELECTOR }, "latest"], signal);
|
|
554
|
+
decimals = parseInt(raw, 16);
|
|
555
|
+
}
|
|
556
|
+
const raw = await rpc(endpoint, "eth_call", [{ to: feed, data: CHAINLINK_LATEST_SELECTOR }, "latest"], signal);
|
|
557
|
+
const answer = BigInt(`0x${raw.slice(2 + 64, 2 + 128)}`);
|
|
558
|
+
return Number(answer) / 10 ** decimals;
|
|
559
|
+
}, { interval: 30000, ...poll });
|
|
560
|
+
return { ...p, price: p.data };
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Fetch an NFT's `tokenURI` on-chain and resolve its JSON metadata
|
|
564
|
+
* (one-shot, with `retry`). `ipfs://` URIs are rewritten through the
|
|
565
|
+
* gateway. Returns the parsed fields plus `raw` for anything custom.
|
|
566
|
+
*
|
|
567
|
+
* ```ts
|
|
568
|
+
* const { metadata, image, status, retry } = createNFTMetadata(
|
|
569
|
+
* "0xcontract…",
|
|
570
|
+
* 42,
|
|
571
|
+
* );
|
|
572
|
+
* ```
|
|
573
|
+
*/
|
|
574
|
+
export function createNFTMetadata(contract, tokenId, options = {}) {
|
|
575
|
+
const { endpoint = DEFAULT_ENDPOINT, gateway = "https://ipfs.io" } = options;
|
|
576
|
+
if (!isAddress(contract)) {
|
|
577
|
+
throw new Error("createNFTMetadata: invalid contract address.");
|
|
578
|
+
}
|
|
579
|
+
const [data, setData] = createSignal(undefined);
|
|
580
|
+
const [error, setError] = createSignal(null);
|
|
581
|
+
const [status, setStatus] = createSignal("idle");
|
|
582
|
+
let aborter = null;
|
|
583
|
+
const toGateway = (uri) => uri.startsWith("ipfs://") ? `${gateway}/ipfs/${uri.slice(7)}` : uri;
|
|
584
|
+
const load = async () => {
|
|
585
|
+
if (typeof window === "undefined")
|
|
586
|
+
return;
|
|
587
|
+
aborter?.abort();
|
|
588
|
+
aborter = new AbortController();
|
|
589
|
+
const signal = aborter.signal;
|
|
590
|
+
setStatus("loading");
|
|
591
|
+
try {
|
|
592
|
+
const idHex = BigInt(tokenId).toString(16).padStart(64, "0");
|
|
593
|
+
const uriRaw = await rpc(endpoint, "eth_call", [{ to: contract, data: `0xc87b56dd${idHex}` }, "latest"], signal);
|
|
594
|
+
const uri = parseAbiString(uriRaw);
|
|
595
|
+
const res = await fetch(toGateway(uri), { signal });
|
|
596
|
+
if (!res.ok) {
|
|
597
|
+
throw new Error(`Metadata fetch failed with HTTP ${res.status}.`);
|
|
598
|
+
}
|
|
599
|
+
const json = (await res.json());
|
|
600
|
+
if (signal.aborted)
|
|
601
|
+
return;
|
|
602
|
+
setData({
|
|
603
|
+
name: typeof json.name === "string" ? json.name : undefined,
|
|
604
|
+
description: typeof json.description === "string" ? json.description : undefined,
|
|
605
|
+
image: typeof json.image === "string" ? toGateway(json.image) : undefined,
|
|
606
|
+
attributes: Array.isArray(json.attributes)
|
|
607
|
+
? json.attributes
|
|
608
|
+
: undefined,
|
|
609
|
+
raw: json,
|
|
610
|
+
});
|
|
611
|
+
setError(null);
|
|
612
|
+
setStatus("success");
|
|
613
|
+
}
|
|
614
|
+
catch (e) {
|
|
615
|
+
if (signal.aborted)
|
|
616
|
+
return;
|
|
617
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
618
|
+
setStatus("error");
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
if (typeof window !== "undefined") {
|
|
622
|
+
void load();
|
|
623
|
+
}
|
|
624
|
+
onCleanup(() => aborter?.abort());
|
|
625
|
+
return {
|
|
626
|
+
data,
|
|
627
|
+
error,
|
|
628
|
+
status,
|
|
629
|
+
retry: () => void load(),
|
|
630
|
+
abort: () => aborter?.abort(),
|
|
631
|
+
metadata: data,
|
|
632
|
+
image: () => data()?.image,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const ENS_REGISTRY = "0x00000000000C2C1F563e148400aA1D00d43e5D";
|
|
636
|
+
const ENS_RESOLVER_SELECTOR = "0x0178b8bf";
|
|
637
|
+
const ENS_NAME_SELECTOR = "0x691f3431";
|
|
638
|
+
/**
|
|
639
|
+
* Reverse-resolve an address to its ENS name via the public ENS registry
|
|
640
|
+
* (one-shot, with `retry`). `undefined` when the address has no name set;
|
|
641
|
+
* errors (network, RPC) surface on `error`.
|
|
642
|
+
*
|
|
643
|
+
* ```ts
|
|
644
|
+
* const { name, status } = createENS("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045");
|
|
645
|
+
* ```
|
|
646
|
+
*/
|
|
647
|
+
export function createENS(address, options = {}) {
|
|
648
|
+
const { endpoint = DEFAULT_ENDPOINT } = options;
|
|
649
|
+
if (!isAddress(address)) {
|
|
650
|
+
throw new Error("createENS: invalid address.");
|
|
651
|
+
}
|
|
652
|
+
const [data, setData] = createSignal(undefined);
|
|
653
|
+
const [error, setError] = createSignal(null);
|
|
654
|
+
const [status, setStatus] = createSignal("idle");
|
|
655
|
+
let aborter = null;
|
|
656
|
+
const load = async () => {
|
|
657
|
+
if (typeof window === "undefined")
|
|
658
|
+
return;
|
|
659
|
+
aborter?.abort();
|
|
660
|
+
aborter = new AbortController();
|
|
661
|
+
const signal = aborter.signal;
|
|
662
|
+
setStatus("loading");
|
|
663
|
+
try {
|
|
664
|
+
const node = namehash(`${address.slice(2).toLowerCase()}.addr.reverse`);
|
|
665
|
+
const resolverRaw = await rpc(endpoint, "eth_call", [
|
|
666
|
+
{ to: ENS_REGISTRY, data: `${ENS_RESOLVER_SELECTOR}${node.slice(2)}` },
|
|
667
|
+
"latest",
|
|
668
|
+
], signal);
|
|
669
|
+
const resolver = `0x${resolverRaw.slice(-40)}`;
|
|
670
|
+
if (/^0x0+$/.test(resolver)) {
|
|
671
|
+
if (!signal.aborted) {
|
|
672
|
+
setData(undefined);
|
|
673
|
+
setError(null);
|
|
674
|
+
setStatus("success");
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const nameRaw = await rpc(endpoint, "eth_call", [
|
|
679
|
+
{ to: resolver, data: `${ENS_NAME_SELECTOR}${node.slice(2)}` },
|
|
680
|
+
"latest",
|
|
681
|
+
], signal);
|
|
682
|
+
const name = parseAbiString(nameRaw);
|
|
683
|
+
if (signal.aborted)
|
|
684
|
+
return;
|
|
685
|
+
setData(name || undefined);
|
|
686
|
+
setError(null);
|
|
687
|
+
setStatus("success");
|
|
688
|
+
}
|
|
689
|
+
catch (e) {
|
|
690
|
+
if (signal.aborted)
|
|
691
|
+
return;
|
|
692
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
693
|
+
setStatus("error");
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
if (typeof window !== "undefined") {
|
|
697
|
+
void load();
|
|
698
|
+
}
|
|
699
|
+
onCleanup(() => aborter?.abort());
|
|
700
|
+
return {
|
|
701
|
+
data,
|
|
702
|
+
error,
|
|
703
|
+
status,
|
|
704
|
+
retry: () => void load(),
|
|
705
|
+
abort: () => aborter?.abort(),
|
|
706
|
+
name: data,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Deterministic identicon avatar for any address as a data URI: a mirrored
|
|
711
|
+
* random-walk grid in SVG, so the same address always renders the same
|
|
712
|
+
* image. Pure computation, works on the server, no network.
|
|
713
|
+
*
|
|
714
|
+
* ```tsx
|
|
715
|
+
* const avatar = createIdenticon("0xabc…");
|
|
716
|
+
* <img src={avatar()} alt="avatar" width={64} height={64} />;
|
|
717
|
+
* ```
|
|
718
|
+
*/
|
|
719
|
+
export function createIdenticon(address, options = {}) {
|
|
720
|
+
const { size = 64, cells = 8, background = "#f0f0f0" } = options;
|
|
721
|
+
const get = typeof address === "function" ? address : () => address;
|
|
722
|
+
return () => {
|
|
723
|
+
const seed = keccak256(new TextEncoder().encode(get().toLowerCase()));
|
|
724
|
+
let s = (seed[0] << 24) | (seed[1] << 16) | (seed[2] << 8) | seed[3];
|
|
725
|
+
const rand = () => {
|
|
726
|
+
s |= 0;
|
|
727
|
+
s = (s + 0x6d2b79f5) | 0;
|
|
728
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
729
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
730
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
731
|
+
};
|
|
732
|
+
const color = `hsl(${Math.floor(rand() * 360)}, 65%, 55%)`;
|
|
733
|
+
const half = Math.ceil(cells / 2);
|
|
734
|
+
const unit = size / cells;
|
|
735
|
+
let rects = "";
|
|
736
|
+
for (let y = 0; y < cells; y++) {
|
|
737
|
+
for (let x = 0; x < half; x++) {
|
|
738
|
+
if (rand() > 0.5) {
|
|
739
|
+
const mirrorX = cells - 1 - x;
|
|
740
|
+
const mk = (rx) => `<rect x="${(rx * unit).toFixed(2)}" y="${(y * unit).toFixed(2)}" width="${unit.toFixed(2)}" height="${unit.toFixed(2)}" fill="${color}"/>`;
|
|
741
|
+
rects += mk(x);
|
|
742
|
+
if (mirrorX !== x)
|
|
743
|
+
rects += mk(mirrorX);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">` +
|
|
748
|
+
`<rect width="${size}" height="${size}" fill="${background}"/>${rects}</svg>`;
|
|
749
|
+
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
750
|
+
};
|
|
751
|
+
}
|