@verify-network/protocol 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +147 -2
- package/dist/domain-CXJsoNRU.d.ts +9 -0
- package/dist/ecvrf.d.ts +69 -0
- package/dist/ecvrf.js +174 -0
- package/dist/event.d.ts +101 -0
- package/dist/event.js +53 -0
- package/dist/fee.d.ts +142 -0
- package/dist/fee.js +142 -0
- package/dist/fulfillment.d.ts +32 -0
- package/dist/fulfillment.js +28 -0
- package/dist/index.d.ts +708 -0
- package/dist/index.js +872 -0
- package/dist/request.d.ts +60 -0
- package/dist/request.js +61 -0
- package/package.json +56 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import { HASH_TO_CURVE_MAX_ITERATIONS, SENTINEL_PROOF, decodeProof, deriveRandomness, encodeProof, hashToCurveIterations, isFulfillable, isSentinel, proofCommitment, prove, publicKeyPoint, verify } from "./ecvrf.js";
|
|
2
|
+
import { MAX_CALLBACK_DATA_BYTES, SERVICE_VRF, STALE_DELAY, isLate, remainingDelay, requestId, staleAt } from "./request.js";
|
|
3
|
+
import { CALLDATA_GAS_PER_BYTE, DEFAULT_FEE_FLOOR, INTRINSIC_GAS, WHOLE_PREMIUM, ceiling, chargedGas, computeFees, fee, fulfillmentItemGas, lockedGasPrice, premiumShare, settlement, settlementAtShare } from "./fee.js";
|
|
4
|
+
import { decodeRandomnessFulfilled, decodeRandomnessRequested, randomnessFulfilledEvent, randomnessRequestedEvent } from "./event.js";
|
|
5
|
+
import { fulfillmentDigest, fulfillmentTypedData } from "./fulfillment.js";
|
|
6
|
+
import { bytesToHex, encodeAbiParameters, getAddress, hashTypedData, hexToBigInt, isAddressEqual, keccak256, parseAbi, parseAbiItem, slice, toEventSelector } from "viem";
|
|
7
|
+
import { Clock, Deferred, Duration, Effect, Exit, Layer, Option, PubSub, Result, Schema, Scope } from "effect";
|
|
8
|
+
import { Socket } from "effect/socket";
|
|
9
|
+
//#region src/attestation.ts
|
|
10
|
+
/** Splits a 48-byte PCR the way the Nitro verifier takes it. */
|
|
11
|
+
const bytes48 = (pcr) => ({
|
|
12
|
+
first: slice(pcr, 0, 32),
|
|
13
|
+
second: slice(pcr, 32, 48)
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* Mirrors `NitroAttestationVerifier._hasExpectedNonce`, the Nitro verifier's check: the nonce an
|
|
17
|
+
* enclave must put in its attestation for this operator to register this key on this router
|
|
18
|
+
* before the deadline (seconds since the epoch), so an attestation registers nowhere else.
|
|
19
|
+
*/
|
|
20
|
+
const registrationNonce = ({ chainId, router, operator, publicKey, deadline }) => keccak256(encodeAbiParameters([
|
|
21
|
+
{ type: "uint256" },
|
|
22
|
+
{ type: "address" },
|
|
23
|
+
{ type: "address" },
|
|
24
|
+
{ type: "uint256[2]" },
|
|
25
|
+
{ type: "uint64" }
|
|
26
|
+
], [
|
|
27
|
+
BigInt(chainId),
|
|
28
|
+
router,
|
|
29
|
+
operator,
|
|
30
|
+
[publicKey[0], publicKey[1]],
|
|
31
|
+
deadline
|
|
32
|
+
]));
|
|
33
|
+
/**
|
|
34
|
+
* The attestation `registerNode` takes when the Nitro verifier is configured: the SP1 journal
|
|
35
|
+
* and proof of the enclave's attestation document, and the deadline its nonce was bound to, as
|
|
36
|
+
* `NitroAttestationVerifier.EnclaveRegistrationProof`.
|
|
37
|
+
*/
|
|
38
|
+
const encodeAttestation = ({ journal, proof, deadline }) => encodeAbiParameters([{
|
|
39
|
+
type: "tuple",
|
|
40
|
+
components: [
|
|
41
|
+
{
|
|
42
|
+
name: "journal",
|
|
43
|
+
type: "bytes"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "proof",
|
|
47
|
+
type: "bytes"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "deadline",
|
|
51
|
+
type: "uint64"
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}], [{
|
|
55
|
+
journal,
|
|
56
|
+
proof,
|
|
57
|
+
deadline
|
|
58
|
+
}]);
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/schemas.ts
|
|
61
|
+
/** A `0x`-prefixed hex string of any length, as viem spells quantities, data, and keys. */
|
|
62
|
+
const HexString = Schema.TemplateLiteral(["0x", Schema.String]).pipe(Schema.check(Schema.isPattern(/^0x[0-9a-fA-F]*$/)));
|
|
63
|
+
/** Whole bytes in hex: exactly `size` of them, or any number when no size is given. */
|
|
64
|
+
const hexBytes = (size) => HexString.pipe(Schema.check(Schema.isPattern(size === void 0 ? /^0x([0-9a-fA-F]{2})*$/ : new RegExp(`^0x[0-9a-fA-F]{${2 * size}}$`))));
|
|
65
|
+
const Address = hexBytes(20);
|
|
66
|
+
const Bytes32 = hexBytes(32);
|
|
67
|
+
/** A whole number that fits `bits` bits, decoded as viem does: `number` up to 48 bits. */
|
|
68
|
+
const uintNumber = (bits) => Schema.Number.check(Schema.isInt(), Schema.isBetween({
|
|
69
|
+
minimum: 0,
|
|
70
|
+
maximum: 2 ** bits - 1
|
|
71
|
+
}));
|
|
72
|
+
/** A whole number that fits `bits` bits, as a `bigint`: what viem decodes anything past 48 bits to. */
|
|
73
|
+
const uintBigInt = (bits) => Schema.BigInt.check(Schema.isBetweenBigInt({
|
|
74
|
+
minimum: 0n,
|
|
75
|
+
maximum: 2n ** bits - 1n
|
|
76
|
+
}));
|
|
77
|
+
const uint32 = uintNumber(32);
|
|
78
|
+
const uint40 = uintNumber(40);
|
|
79
|
+
const uint64 = uintBigInt(64n);
|
|
80
|
+
const uint96 = uintBigInt(96n);
|
|
81
|
+
/** The schema of a `Request` (request.ts), which `request.test.ts` holds to the same type. */
|
|
82
|
+
const Request = Schema.Struct({
|
|
83
|
+
consumer: Address,
|
|
84
|
+
sequence: uint64,
|
|
85
|
+
fulfiller: Address,
|
|
86
|
+
assignedAt: uint40,
|
|
87
|
+
fee: uint96,
|
|
88
|
+
gasPrice: uint96,
|
|
89
|
+
callbackGasLimit: uint32,
|
|
90
|
+
callbackData: hexBytes()
|
|
91
|
+
});
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/directRequest.ts
|
|
94
|
+
/**
|
|
95
|
+
* An authorization: what a signer signs to make a direct request (ADR 0026, 0027, 0028). The
|
|
96
|
+
* signer is not a field; the router recovers it from the signature. `node` is the one node that
|
|
97
|
+
* may fulfill it, `deadline` the last second the router takes it, `nonce` unordered and used at
|
|
98
|
+
* most once per signer, `maxGasPrice` the most the charge is priced at.
|
|
99
|
+
*/
|
|
100
|
+
const DirectRequest = Schema.Struct({
|
|
101
|
+
consumer: Address,
|
|
102
|
+
node: Address,
|
|
103
|
+
nonce: uintBigInt(256n),
|
|
104
|
+
deadline: uintBigInt(64n),
|
|
105
|
+
callbackGasLimit: uintNumber(32),
|
|
106
|
+
maxGasPrice: uintBigInt(256n),
|
|
107
|
+
callbackData: hexBytes()
|
|
108
|
+
});
|
|
109
|
+
const directRequestTypes = { DirectRequest: [
|
|
110
|
+
{
|
|
111
|
+
name: "consumer",
|
|
112
|
+
type: "address"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "node",
|
|
116
|
+
type: "address"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "nonce",
|
|
120
|
+
type: "uint256"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "deadline",
|
|
124
|
+
type: "uint64"
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "callbackGasLimit",
|
|
128
|
+
type: "uint32"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "maxGasPrice",
|
|
132
|
+
type: "uint256"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "callbackData",
|
|
136
|
+
type: "bytes"
|
|
137
|
+
}
|
|
138
|
+
] };
|
|
139
|
+
/**
|
|
140
|
+
* The typed data a signer signs, as viem's `signTypedData` takes it: the fulfillment's domain
|
|
141
|
+
* (`VerifyNetworkVRF` version 2, the chain, the router) over the `DirectRequest` struct.
|
|
142
|
+
*/
|
|
143
|
+
const directRequestTypedData = ({ chainId, router, authorization }) => ({
|
|
144
|
+
domain: {
|
|
145
|
+
name: "VerifyNetworkVRF",
|
|
146
|
+
version: "2",
|
|
147
|
+
chainId,
|
|
148
|
+
verifyingContract: router
|
|
149
|
+
},
|
|
150
|
+
types: directRequestTypes,
|
|
151
|
+
primaryType: "DirectRequest",
|
|
152
|
+
message: authorization
|
|
153
|
+
});
|
|
154
|
+
/**
|
|
155
|
+
* The authorization's EIP-712 digest, which is the direct request's id and the VRF input; the
|
|
156
|
+
* router computes the same (ticket 02) and the contracts parity test holds it to this. The domain binds the chain and the router;
|
|
157
|
+
* the type hash keeps it apart from every on-chain request id.
|
|
158
|
+
*/
|
|
159
|
+
const directRequestId = (authorization) => hashTypedData(directRequestTypedData(authorization));
|
|
160
|
+
/**
|
|
161
|
+
* Signs an authorization with a viem account that signs locally (a private key or a session
|
|
162
|
+
* key): the strict low-`s` signature the router recovers the signer from over `directRequestId`.
|
|
163
|
+
*/
|
|
164
|
+
const signDirectRequest = ({ account, ...input }) => account.signTypedData(directRequestTypedData(input));
|
|
165
|
+
/**
|
|
166
|
+
* `IDirectVrfReceiver.onDirectRandomness`: what a consumer implements to receive a direct
|
|
167
|
+
* request's randomness. A different selector from the on-chain callback, so a consumer that never
|
|
168
|
+
* opted in cannot mistake one for a request it made; `signer` is who paid and who the consumer
|
|
169
|
+
* decides about.
|
|
170
|
+
*/
|
|
171
|
+
const onDirectRandomness = parseAbiItem("function onDirectRandomness(bytes32 requestId, address signer, bytes32 randomness, bytes callbackData)");
|
|
172
|
+
/**
|
|
173
|
+
* `VrfFacet.DirectRequestFulfilled`: emitted beside `RandomnessFulfilled` on a direct fulfillment,
|
|
174
|
+
* with the id, the signer and the node indexed. Spelled out here because the protocol package does
|
|
175
|
+
* not depend on the contracts package; the contracts package holds it to the real log once the
|
|
176
|
+
* router emits it (ticket 02 of the direct request).
|
|
177
|
+
*/
|
|
178
|
+
const directRequestFulfilledEvent = parseAbiItem("event DirectRequestFulfilled(bytes32 indexed requestId, address indexed signer, address indexed node, address consumer)");
|
|
179
|
+
/**
|
|
180
|
+
* `NodesFacet.NodeEndpointSet`: an operator published or changed the endpoint a node serves
|
|
181
|
+
* direct requests at. Empty means the node serves none. Held to the real log as the event above,
|
|
182
|
+
* once the router has the setter (ticket 03).
|
|
183
|
+
*/
|
|
184
|
+
const nodeEndpointSetEvent = parseAbiItem("event NodeEndpointSet(address indexed node, address indexed operator, string endpoint)");
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/directApi.ts
|
|
187
|
+
/** A 65-byte secp256k1 signature, as a signer's over an authorization or the enclave's over a fulfillment. */
|
|
188
|
+
const Signature = hexBytes(65);
|
|
189
|
+
/**
|
|
190
|
+
* `POST /direct`: the authorization as the signer signed it (`DirectRequest`, whose numbers are
|
|
191
|
+
* decimal strings in JSON, as `GET /` spells the public key) and the signer's signature over it.
|
|
192
|
+
*/
|
|
193
|
+
const DirectRequestBody = Schema.Struct({
|
|
194
|
+
...DirectRequest.fields,
|
|
195
|
+
signature: Signature
|
|
196
|
+
});
|
|
197
|
+
/**
|
|
198
|
+
* What `POST /direct` returns once the node's source has seen the fulfillment land: the id, the
|
|
199
|
+
* randomness the consumer received, the evidence a challenge takes, and the transaction.
|
|
200
|
+
*/
|
|
201
|
+
const DirectResponse = Schema.Struct({
|
|
202
|
+
requestId: Bytes32,
|
|
203
|
+
randomness: Bytes32,
|
|
204
|
+
proof: Schema.Tuple([
|
|
205
|
+
Schema.BigInt,
|
|
206
|
+
Schema.BigInt,
|
|
207
|
+
Schema.BigInt,
|
|
208
|
+
Schema.BigInt
|
|
209
|
+
]),
|
|
210
|
+
enclaveSignature: Signature,
|
|
211
|
+
hash: Bytes32
|
|
212
|
+
});
|
|
213
|
+
/**
|
|
214
|
+
* `GET /` as a signer reads it: the node the endpoint serves and its public key. The enclave's
|
|
215
|
+
* status carries more (the chain, the runtime's counters); a client needs these two to know the
|
|
216
|
+
* endpoint is the node's the router named, and which key its proofs verify under.
|
|
217
|
+
*/
|
|
218
|
+
const NodeIdentity = Schema.Struct({
|
|
219
|
+
node: hexBytes(20),
|
|
220
|
+
publicKey: Schema.Tuple([Schema.BigInt, Schema.BigInt])
|
|
221
|
+
});
|
|
222
|
+
/**
|
|
223
|
+
* A 400: why `POST /direct` refused an authorization, cheapest check first (ADR 0026, 0028):
|
|
224
|
+
* the callback data is over the router's cap; the named node is another; the deadline has
|
|
225
|
+
* passed or is past the horizon; the maximum gas price is under what the sender would bid now;
|
|
226
|
+
* the callback gas limit is over the router's maximum; the signer's signature recovers nobody;
|
|
227
|
+
* the id has no curve point under the enclave key, so the signer picks another nonce; or the
|
|
228
|
+
* signer's balance does not cover the ceiling.
|
|
229
|
+
*/
|
|
230
|
+
var DirectRequestRefused = class extends Schema.TaggedError()("DirectRequestRefused", { reason: Schema.Literals([
|
|
231
|
+
"too-long",
|
|
232
|
+
"wrong-node",
|
|
233
|
+
"deadline",
|
|
234
|
+
"max-gas-price",
|
|
235
|
+
"callback-gas-limit",
|
|
236
|
+
"bad-signature",
|
|
237
|
+
"unfulfillable",
|
|
238
|
+
"insufficient-balance"
|
|
239
|
+
]) }) {};
|
|
240
|
+
/** A 409: this node already took up, or saw land, an authorization under `signer` and `nonce`. */
|
|
241
|
+
var NonceTaken = class extends Schema.TaggedError()("NonceTaken", {
|
|
242
|
+
signer: Schema.String,
|
|
243
|
+
nonce: Schema.BigInt
|
|
244
|
+
}) {};
|
|
245
|
+
/**
|
|
246
|
+
* A 429: the node is past a bound (as many direct requests in flight as it takes at once, as
|
|
247
|
+
* many under this signer, or a block on the caller's address for the incorrect authorizations it
|
|
248
|
+
* sent), or the edge in front of it refused the flood with no body of the node's. Nothing was
|
|
249
|
+
* checked or proven; the signer may try another node, or this one again later.
|
|
250
|
+
*/
|
|
251
|
+
var TooManyRequests = class extends Schema.TaggedError()("TooManyRequests", { reason: Schema.Literals([
|
|
252
|
+
"in-flight",
|
|
253
|
+
"signer",
|
|
254
|
+
"ip",
|
|
255
|
+
"edge"
|
|
256
|
+
]) }) {};
|
|
257
|
+
/**
|
|
258
|
+
* A 503: the node serves no direct request right now: it is not set up; it is not in the active
|
|
259
|
+
* set (exiting, ejected, forgotten, or stopped); its request source reports no connection, so it
|
|
260
|
+
* could not see the fulfillment land; or the chain could not be read.
|
|
261
|
+
*/
|
|
262
|
+
var NotServing = class extends Schema.TaggedError()("NotServing", { reason: Schema.Literals([
|
|
263
|
+
"not-set-up",
|
|
264
|
+
"not-active",
|
|
265
|
+
"disconnected",
|
|
266
|
+
"chain-unreachable"
|
|
267
|
+
]) }) {};
|
|
268
|
+
/**
|
|
269
|
+
* A 504: the authorization's deadline passed before the source saw the fulfillment land: it can
|
|
270
|
+
* no longer land, so the signer may make an on-chain request instead (ADR 0026).
|
|
271
|
+
*/
|
|
272
|
+
var DeadlinePassed = class extends Schema.TaggedError()("DeadlinePassed", { deadline: Schema.BigInt }) {};
|
|
273
|
+
/** Every refusal `POST /direct` answers with, as its body spells it. */
|
|
274
|
+
const DirectRefusal = Schema.Union([
|
|
275
|
+
DirectRequestRefused,
|
|
276
|
+
NonceTaken,
|
|
277
|
+
TooManyRequests,
|
|
278
|
+
NotServing,
|
|
279
|
+
DeadlinePassed
|
|
280
|
+
]);
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/quote.ts
|
|
283
|
+
/** How old the base fee read may be before the next question reads it again. */
|
|
284
|
+
const BASE_FEE_MAX_AGE = "30 seconds";
|
|
285
|
+
const routerPricing = parseAbi(["function pricing() view returns ((uint16 premiumPercent, uint32 fulfillmentOverheadGas, uint32 fulfillmentSettlementGas, uint32 defaultCallbackGasLimit, uint32 maxCallbackGasLimit))"]);
|
|
286
|
+
/** The router could not be read; `operation` names the viem call. */
|
|
287
|
+
var RouterUnreadable = class extends Schema.TaggedError()("RouterUnreadable", {
|
|
288
|
+
operation: Schema.String,
|
|
289
|
+
cause: Schema.Defect()
|
|
290
|
+
}) {};
|
|
291
|
+
const readRouter$1 = (operation, run) => Effect.tryPromise({
|
|
292
|
+
try: run,
|
|
293
|
+
catch: (cause) => new RouterUnreadable({
|
|
294
|
+
operation,
|
|
295
|
+
cause
|
|
296
|
+
})
|
|
297
|
+
});
|
|
298
|
+
const Quote = { make: Effect.fn("Quote.make")(function* ({ publicClient, router }) {
|
|
299
|
+
const pricing = yield* readRouter$1("pricing", () => publicClient.readContract({
|
|
300
|
+
address: router,
|
|
301
|
+
abi: routerPricing,
|
|
302
|
+
functionName: "pricing"
|
|
303
|
+
}));
|
|
304
|
+
let read;
|
|
305
|
+
const baseFee = Effect.gen(function* () {
|
|
306
|
+
const now = yield* Clock.currentTimeMillis;
|
|
307
|
+
if (read !== void 0 && now - read.at < Duration.toMillis(BASE_FEE_MAX_AGE)) return read.baseFeePerGas;
|
|
308
|
+
const baseFeePerGas = (yield* readRouter$1("getBlock", () => publicClient.getBlock({ blockTag: "latest" }))).baseFeePerGas ?? 0n;
|
|
309
|
+
read = {
|
|
310
|
+
baseFeePerGas,
|
|
311
|
+
at: now
|
|
312
|
+
};
|
|
313
|
+
return baseFeePerGas;
|
|
314
|
+
});
|
|
315
|
+
yield* baseFee;
|
|
316
|
+
const gasLimit = (input) => input.callbackGasLimit ?? pricing.defaultCallbackGasLimit;
|
|
317
|
+
return {
|
|
318
|
+
pricing,
|
|
319
|
+
bid: Effect.map(baseFee, (baseFeePerGas) => computeFees(baseFeePerGas)),
|
|
320
|
+
reservation: (input) => Effect.map(baseFee, (baseFeePerGas) => fee({
|
|
321
|
+
callbackGasLimit: gasLimit(input),
|
|
322
|
+
fulfillmentOverheadGas: pricing.fulfillmentOverheadGas,
|
|
323
|
+
callbackDataLength: input.callbackDataLength,
|
|
324
|
+
gasPrice: input.gasPrice,
|
|
325
|
+
baseFeePerGas,
|
|
326
|
+
premiumPercent: pricing.premiumPercent
|
|
327
|
+
})),
|
|
328
|
+
ceiling: (input) => ceiling({
|
|
329
|
+
callbackGasLimit: gasLimit(input),
|
|
330
|
+
fulfillmentOverheadGas: pricing.fulfillmentOverheadGas,
|
|
331
|
+
callbackDataLength: input.callbackDataLength,
|
|
332
|
+
maxGasPrice: input.maxGasPrice,
|
|
333
|
+
premiumPercent: pricing.premiumPercent
|
|
334
|
+
}),
|
|
335
|
+
charge: (input) => settlement({
|
|
336
|
+
gasUsed: input.gasUsed,
|
|
337
|
+
requestGasPrice: input.lockedGasPrice,
|
|
338
|
+
premiumPercent: pricing.premiumPercent,
|
|
339
|
+
fee: input.reservation,
|
|
340
|
+
assignedAt: input.assignedAt,
|
|
341
|
+
settledAt: input.settledAt
|
|
342
|
+
})
|
|
343
|
+
};
|
|
344
|
+
}) };
|
|
345
|
+
//#endregion
|
|
346
|
+
//#region src/directClient.ts
|
|
347
|
+
/** How long an authorization is good for when the caller names no deadline. */
|
|
348
|
+
const DEFAULT_DEADLINE_SECONDS = 5;
|
|
349
|
+
/**
|
|
350
|
+
* Headroom over the bid the node's sender would make now: the maximum gas price an
|
|
351
|
+
* authorization names by default. The charge is at the fulfillment's own gas price under it
|
|
352
|
+
* (ADR 0028), so the headroom bounds the ceiling debited and refunded, not what is paid.
|
|
353
|
+
*/
|
|
354
|
+
const MAX_GAS_PRICE_HEADROOM = 2n;
|
|
355
|
+
/** How long a `GET /` has to answer before the node is dropped from the measurement. */
|
|
356
|
+
const MEASURE_TIMEOUT = "3 seconds";
|
|
357
|
+
/** How long past the deadline a `POST /direct` is waited for before it is given up as unanswered. */
|
|
358
|
+
const POST_GRACE = "3 seconds";
|
|
359
|
+
const routerViews = parseAbi(["function activeNodes() view returns (address[])", "function endpointOf(address node) view returns (string)"]);
|
|
360
|
+
/**
|
|
361
|
+
* No node to post to: of `active` nodes, `published` had an endpoint the client trusts the shape
|
|
362
|
+
* of, and none of those answered `GET /` as itself in time.
|
|
363
|
+
*/
|
|
364
|
+
var NoNodeServing = class extends Schema.TaggedError()("NoNodeServing", {
|
|
365
|
+
active: Schema.Int,
|
|
366
|
+
published: Schema.Int
|
|
367
|
+
}) {};
|
|
368
|
+
/** The account did not sign the authorization: a wallet refused, or the key could not. */
|
|
369
|
+
var SigningFailed = class extends Schema.TaggedError()("SigningFailed", { cause: Schema.Defect() }) {};
|
|
370
|
+
/**
|
|
371
|
+
* The node answered something `POST /direct` does not: neither a landing nor a refusal it
|
|
372
|
+
* spells, under a status that says the authorization was not taken up (a 4xx of the edge's, a
|
|
373
|
+
* landing whose body cannot be read). Not a signal to fall back on: nothing here says whether
|
|
374
|
+
* the fulfillment landed.
|
|
375
|
+
*/
|
|
376
|
+
var UnexpectedResponse = class extends Schema.TaggedError()("UnexpectedResponse", {
|
|
377
|
+
status: Schema.Int,
|
|
378
|
+
body: Schema.String
|
|
379
|
+
}) {};
|
|
380
|
+
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
|
|
381
|
+
/**
|
|
382
|
+
* An endpoint as the client will use it, or nothing when it is not one the client connects to:
|
|
383
|
+
* `https` only, a hostname rather than an IP literal, no credentials, no query. A trailing path
|
|
384
|
+
* is kept, with a slash after it, so the routes resolve under it. A backend that fetched an
|
|
385
|
+
* unvalidated URL from the chain would be an SSRF vector (ADR 0027).
|
|
386
|
+
*/
|
|
387
|
+
const validateEndpoint = (endpoint) => {
|
|
388
|
+
let url;
|
|
389
|
+
try {
|
|
390
|
+
url = new URL(endpoint);
|
|
391
|
+
} catch {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (url.protocol !== "https:") return void 0;
|
|
395
|
+
if (url.hostname === "" || url.hostname.startsWith("[") || IPV4.test(url.hostname)) return;
|
|
396
|
+
if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return;
|
|
397
|
+
return withTrailingSlash(url);
|
|
398
|
+
};
|
|
399
|
+
const withTrailingSlash = (url) => {
|
|
400
|
+
if (!url.pathname.endsWith("/")) url.pathname += "/";
|
|
401
|
+
return url;
|
|
402
|
+
};
|
|
403
|
+
const hasMulticall = (publicClient) => publicClient.chain?.contracts?.multicall3 !== void 0;
|
|
404
|
+
const readRouter = (operation, run) => Effect.tryPromise({
|
|
405
|
+
try: run,
|
|
406
|
+
catch: (cause) => new RouterUnreadable({
|
|
407
|
+
operation,
|
|
408
|
+
cause
|
|
409
|
+
})
|
|
410
|
+
});
|
|
411
|
+
const readActive = (publicClient, router) => readRouter("activeNodes", () => publicClient.readContract({
|
|
412
|
+
address: router,
|
|
413
|
+
abi: routerViews,
|
|
414
|
+
functionName: "activeNodes"
|
|
415
|
+
}));
|
|
416
|
+
/** `endpointOf` for every node, in one multicall where the chain has one. */
|
|
417
|
+
const readEndpoints = (publicClient, router, nodes) => {
|
|
418
|
+
const contracts = nodes.map((node) => ({
|
|
419
|
+
address: router,
|
|
420
|
+
abi: routerViews,
|
|
421
|
+
functionName: "endpointOf",
|
|
422
|
+
args: [node]
|
|
423
|
+
}));
|
|
424
|
+
return readRouter("endpointOf", () => hasMulticall(publicClient) ? publicClient.multicall({
|
|
425
|
+
contracts,
|
|
426
|
+
allowFailure: false
|
|
427
|
+
}) : Promise.all(contracts.map((contract) => publicClient.readContract(contract))));
|
|
428
|
+
};
|
|
429
|
+
/**
|
|
430
|
+
* The active set with the endpoints the client will connect to: the router's `activeNodes`,
|
|
431
|
+
* then the endpoints in one multicall, and every endpoint that is not `https` to a hostname
|
|
432
|
+
* dropped (ADR 0027). `active` is the whole set, for telling "no node publishes an endpoint"
|
|
433
|
+
* from "no node at all".
|
|
434
|
+
*/
|
|
435
|
+
const discoverNodes = Effect.fn("discoverNodes")(function* ({ publicClient, router }) {
|
|
436
|
+
const active = yield* readActive(publicClient, router);
|
|
437
|
+
const endpoints = active.length === 0 ? [] : yield* readEndpoints(publicClient, router, active);
|
|
438
|
+
const nodes = [];
|
|
439
|
+
active.forEach((node, i) => {
|
|
440
|
+
const url = validateEndpoint(endpoints[i] ?? "");
|
|
441
|
+
if (url !== void 0) nodes.push({
|
|
442
|
+
node: getAddress(node),
|
|
443
|
+
url
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
return {
|
|
447
|
+
active: active.length,
|
|
448
|
+
nodes
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
const decodeIdentity = Schema.decodeUnknownOption(Schema.toCodecJson(NodeIdentity));
|
|
452
|
+
const decodeResponse = Schema.decodeUnknownOption(Schema.toCodecJson(DirectResponse));
|
|
453
|
+
const decodeRefusal = Schema.decodeUnknownOption(Schema.toCodecJson(DirectRefusal));
|
|
454
|
+
const encodeBody = Schema.encodeSync(Schema.toCodecJson(DirectRequestBody));
|
|
455
|
+
const parseJson = (text) => {
|
|
456
|
+
try {
|
|
457
|
+
return JSON.parse(text);
|
|
458
|
+
} catch {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
/**
|
|
463
|
+
* `GET /` on a candidate, timed: the node it reports must be the one the router named (an
|
|
464
|
+
* endpoint answering as another node would refuse every authorization as `wrong-node`), and a
|
|
465
|
+
* redirect, an error, or a slow answer drops it. The connection this opens is the warm one the
|
|
466
|
+
* first request rides.
|
|
467
|
+
*/
|
|
468
|
+
const measure = (candidate, fetch) => Effect.promise(async (signal) => {
|
|
469
|
+
const startedAt = performance.now();
|
|
470
|
+
try {
|
|
471
|
+
const response = await fetch(candidate.url.href, {
|
|
472
|
+
method: "GET",
|
|
473
|
+
redirect: "manual",
|
|
474
|
+
signal
|
|
475
|
+
});
|
|
476
|
+
const text = await response.text();
|
|
477
|
+
const latencyMs = performance.now() - startedAt;
|
|
478
|
+
if (response.status !== 200) return void 0;
|
|
479
|
+
const identity = decodeIdentity(parseJson(text));
|
|
480
|
+
if (Option.isNone(identity)) return void 0;
|
|
481
|
+
if (candidate.node !== void 0 && !isAddressEqual(identity.value.node, candidate.node)) return;
|
|
482
|
+
return {
|
|
483
|
+
node: getAddress(identity.value.node),
|
|
484
|
+
url: candidate.url,
|
|
485
|
+
publicKey: identity.value.publicKey,
|
|
486
|
+
latencyMs
|
|
487
|
+
};
|
|
488
|
+
} catch {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}).pipe(Effect.timeoutOption(MEASURE_TIMEOUT), Effect.map(Option.getOrUndefined));
|
|
492
|
+
const randomNonce = () => BigInt(bytesToHex(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32))));
|
|
493
|
+
const DirectClient = { connect: Effect.fn("DirectClient.connect")(function* (options) {
|
|
494
|
+
const { publicClient, router, chainId, account } = options;
|
|
495
|
+
const fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
496
|
+
const domain = {
|
|
497
|
+
chainId,
|
|
498
|
+
router
|
|
499
|
+
};
|
|
500
|
+
let active;
|
|
501
|
+
let candidates;
|
|
502
|
+
if (options.endpoint === void 0) {
|
|
503
|
+
const discovered = yield* discoverNodes({
|
|
504
|
+
publicClient,
|
|
505
|
+
router
|
|
506
|
+
});
|
|
507
|
+
active = discovered.active;
|
|
508
|
+
candidates = discovered.nodes;
|
|
509
|
+
} else {
|
|
510
|
+
const url = yield* Effect.try({
|
|
511
|
+
try: () => withTrailingSlash(new URL(options.endpoint)),
|
|
512
|
+
catch: () => new NoNodeServing({
|
|
513
|
+
active: 0,
|
|
514
|
+
published: 0
|
|
515
|
+
})
|
|
516
|
+
});
|
|
517
|
+
active = 1;
|
|
518
|
+
candidates = [{ url }];
|
|
519
|
+
}
|
|
520
|
+
const answering = (yield* Effect.forEach(candidates, (candidate) => measure(candidate, fetch), { concurrency: "unbounded" })).filter((m) => m !== void 0);
|
|
521
|
+
if (answering.length === 0) return yield* new NoNodeServing({
|
|
522
|
+
active,
|
|
523
|
+
published: candidates.length
|
|
524
|
+
});
|
|
525
|
+
const node = answering.reduce((best, m) => m.latencyMs < best.latencyMs ? m : best);
|
|
526
|
+
const directUrl = new URL("direct", node.url).href;
|
|
527
|
+
const quote = yield* Quote.make({
|
|
528
|
+
publicClient,
|
|
529
|
+
router
|
|
530
|
+
});
|
|
531
|
+
const maxGasPrice = Effect.map(quote.bid, (bid) => bid.maxFeePerGas * MAX_GAS_PRICE_HEADROOM);
|
|
532
|
+
let clock;
|
|
533
|
+
const latestBlock = Effect.gen(function* () {
|
|
534
|
+
const block = yield* readRouter("getBlock", () => publicClient.getBlock({ blockTag: "latest" }));
|
|
535
|
+
const seconds = Number(block.timestamp);
|
|
536
|
+
if (Number.isFinite(seconds)) clock = {
|
|
537
|
+
seconds,
|
|
538
|
+
readAt: yield* Clock.currentTimeMillis
|
|
539
|
+
};
|
|
540
|
+
return block;
|
|
541
|
+
});
|
|
542
|
+
/** `now` on the chain's clock, in milliseconds, as far as the client has seen it: a lower bound. */
|
|
543
|
+
const chainNow = (now) => clock === void 0 ? now : clock.seconds * 1e3 + (now - clock.readAt);
|
|
544
|
+
/**
|
|
545
|
+
* The local millisecond at which the chain's clock has passed the deadline's second, the last
|
|
546
|
+
* one a fulfillment can land in. Never early, since the chain's clock held is a lower bound.
|
|
547
|
+
*/
|
|
548
|
+
const impossibleAt = (deadline, now) => now + (Number(deadline) + 1) * 1e3 - chainNow(now);
|
|
549
|
+
/** How long from `now` until `impossibleAt`; nothing once it is behind. */
|
|
550
|
+
const untilImpossible = (deadline, now) => Duration.millis(Math.max(0, impossibleAt(deadline, now) - now));
|
|
551
|
+
const deadlineSeconds = options.deadlineSeconds ?? 5;
|
|
552
|
+
yield* latestBlock;
|
|
553
|
+
return {
|
|
554
|
+
node,
|
|
555
|
+
quote,
|
|
556
|
+
maxGasPrice,
|
|
557
|
+
warm: measure(node, fetch).pipe(Effect.asVoid),
|
|
558
|
+
request: Effect.fn("DirectClient.request")(function* (input) {
|
|
559
|
+
const price = input.maxGasPrice ?? (yield* maxGasPrice);
|
|
560
|
+
const datedAt = yield* Clock.currentTimeMillis;
|
|
561
|
+
const deadline = input.deadline ?? BigInt(Math.floor(Math.max(datedAt, chainNow(datedAt)) / 1e3) + deadlineSeconds);
|
|
562
|
+
const authorize = (nonce) => ({
|
|
563
|
+
consumer: input.consumer,
|
|
564
|
+
node: node.node,
|
|
565
|
+
nonce,
|
|
566
|
+
deadline,
|
|
567
|
+
callbackGasLimit: input.callbackGasLimit ?? quote.pricing.defaultCallbackGasLimit,
|
|
568
|
+
maxGasPrice: price,
|
|
569
|
+
callbackData: input.callbackData
|
|
570
|
+
});
|
|
571
|
+
let authorization = authorize(input.nonce ?? randomNonce());
|
|
572
|
+
let requestId = directRequestId({
|
|
573
|
+
...domain,
|
|
574
|
+
authorization
|
|
575
|
+
});
|
|
576
|
+
while (input.nonce === void 0 && !isFulfillable(node.publicKey, requestId)) {
|
|
577
|
+
authorization = authorize(randomNonce());
|
|
578
|
+
requestId = directRequestId({
|
|
579
|
+
...domain,
|
|
580
|
+
authorization
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
const signature = yield* Effect.tryPromise({
|
|
584
|
+
try: () => signDirectRequest({
|
|
585
|
+
...domain,
|
|
586
|
+
authorization,
|
|
587
|
+
account: input.account ?? account
|
|
588
|
+
}),
|
|
589
|
+
catch: (cause) => new SigningFailed({ cause })
|
|
590
|
+
});
|
|
591
|
+
const body = JSON.stringify(encodeBody({
|
|
592
|
+
...authorization,
|
|
593
|
+
signature
|
|
594
|
+
}));
|
|
595
|
+
const outcome = (reason, elapsedMs) => Effect.gen(function* () {
|
|
596
|
+
yield* Effect.ignore(latestBlock);
|
|
597
|
+
yield* Effect.sleep(untilImpossible(deadline, yield* Clock.currentTimeMillis));
|
|
598
|
+
return {
|
|
599
|
+
_tag: "NotLanded",
|
|
600
|
+
node: node.node,
|
|
601
|
+
requestId,
|
|
602
|
+
authorization,
|
|
603
|
+
reason,
|
|
604
|
+
elapsedMs
|
|
605
|
+
};
|
|
606
|
+
});
|
|
607
|
+
const sentAt = performance.now();
|
|
608
|
+
const answered = yield* Effect.tryPromise((signal) => fetch(directUrl, {
|
|
609
|
+
method: "POST",
|
|
610
|
+
headers: { "content-type": "application/json" },
|
|
611
|
+
body,
|
|
612
|
+
redirect: "manual",
|
|
613
|
+
signal
|
|
614
|
+
}).then(async (response) => ({
|
|
615
|
+
status: response.status,
|
|
616
|
+
text: await response.text()
|
|
617
|
+
}))).pipe(Effect.timeoutOption(Duration.millis(Duration.toMillis(untilImpossible(deadline, datedAt)) + Duration.toMillis(POST_GRACE))), Effect.result);
|
|
618
|
+
const elapsedMs = performance.now() - sentAt;
|
|
619
|
+
if (Result.isFailure(answered) || Option.isNone(answered.success)) return yield* outcome("unreachable", elapsedMs);
|
|
620
|
+
const { status, text } = answered.success.value;
|
|
621
|
+
const json = parseJson(text);
|
|
622
|
+
if (status === 200) {
|
|
623
|
+
const response = decodeResponse(json);
|
|
624
|
+
if (Option.isNone(response)) return yield* new UnexpectedResponse({
|
|
625
|
+
status,
|
|
626
|
+
body: text.slice(0, 256)
|
|
627
|
+
});
|
|
628
|
+
if (response.value.requestId.toLowerCase() !== requestId.toLowerCase()) return yield* outcome("mismatched-answer", elapsedMs);
|
|
629
|
+
return {
|
|
630
|
+
_tag: "Landed",
|
|
631
|
+
node: node.node,
|
|
632
|
+
requestId,
|
|
633
|
+
authorization,
|
|
634
|
+
randomness: response.value.randomness,
|
|
635
|
+
proof: response.value.proof,
|
|
636
|
+
enclaveSignature: response.value.enclaveSignature,
|
|
637
|
+
hash: response.value.hash,
|
|
638
|
+
elapsedMs
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
const refusal = Option.getOrUndefined(decodeRefusal(json));
|
|
642
|
+
if (status === 504 || refusal instanceof DeadlinePassed) return yield* outcome("deadline-passed", elapsedMs);
|
|
643
|
+
if (refusal !== void 0) return yield* refusal;
|
|
644
|
+
if (status === 429) return yield* new TooManyRequests({ reason: "edge" });
|
|
645
|
+
if (status >= 500) return yield* outcome("unreachable", elapsedMs);
|
|
646
|
+
return yield* new UnexpectedResponse({
|
|
647
|
+
status,
|
|
648
|
+
body: text.slice(0, 256)
|
|
649
|
+
});
|
|
650
|
+
})
|
|
651
|
+
};
|
|
652
|
+
}) };
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region src/routerWatch.ts
|
|
655
|
+
/** A JSON-RPC request as a text frame. */
|
|
656
|
+
const requestFrame = (id, method, params) => JSON.stringify({
|
|
657
|
+
jsonrpc: "2.0",
|
|
658
|
+
id,
|
|
659
|
+
method,
|
|
660
|
+
params
|
|
661
|
+
});
|
|
662
|
+
/** The `eth_subscribe` of `pendingLogs` under `filter`, as a text frame. */
|
|
663
|
+
const pendingLogsSubscribe = (id, filter) => requestFrame(id, "eth_subscribe", ["pendingLogs", filter]);
|
|
664
|
+
/** The reply to a request of ours: the `eth_subscribe`, or anything else sent under an id. */
|
|
665
|
+
const SubscriptionReply = Schema.Struct({
|
|
666
|
+
id: Schema.Finite,
|
|
667
|
+
result: Schema.optionalKey(Schema.String),
|
|
668
|
+
error: Schema.optionalKey(Schema.Struct({
|
|
669
|
+
code: Schema.Finite,
|
|
670
|
+
message: Schema.String
|
|
671
|
+
}))
|
|
672
|
+
});
|
|
673
|
+
/** A notification on a subscription; `result` is whatever the subscription carries. */
|
|
674
|
+
const SubscriptionNotification = Schema.Struct({
|
|
675
|
+
method: Schema.Literal("eth_subscription"),
|
|
676
|
+
params: Schema.Struct({
|
|
677
|
+
subscription: Schema.String,
|
|
678
|
+
result: Schema.Unknown
|
|
679
|
+
})
|
|
680
|
+
});
|
|
681
|
+
/** What comes down the socket: one or the other. */
|
|
682
|
+
const SubscriptionFrame = Schema.Union([SubscriptionReply, SubscriptionNotification]);
|
|
683
|
+
const decodeSubscriptionFrame = Schema.decodeUnknownResult(SubscriptionFrame);
|
|
684
|
+
/** A log as a `pendingLogs` notification carries it, as far as anything here reads it. */
|
|
685
|
+
const PendingLog = Schema.Struct({
|
|
686
|
+
address: HexString,
|
|
687
|
+
topics: Schema.Array(HexString),
|
|
688
|
+
data: HexString,
|
|
689
|
+
blockNumber: HexString,
|
|
690
|
+
/** Named by the endpoints that carry it; what a sender matches its own request by. */
|
|
691
|
+
transactionHash: Schema.optionalKey(HexString),
|
|
692
|
+
removed: Schema.optionalKey(Schema.Boolean)
|
|
693
|
+
});
|
|
694
|
+
const decodePendingLog = Schema.decodeUnknownResult(PendingLog);
|
|
695
|
+
/** The text of a frame as JSON, or nothing when it is not JSON. */
|
|
696
|
+
const parseFrame = (text) => {
|
|
697
|
+
try {
|
|
698
|
+
return JSON.parse(text);
|
|
699
|
+
} catch {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
const REQUESTED_TOPIC = toEventSelector(randomnessRequestedEvent);
|
|
704
|
+
const FULFILLED_TOPIC = toEventSelector(randomnessFulfilledEvent);
|
|
705
|
+
/**
|
|
706
|
+
* The observation a pending log carries, if it is one of the router's two the watch is for;
|
|
707
|
+
* `at` is when its frame arrived. A log of another emitter, another event, or one the endpoint
|
|
708
|
+
* withdrew is nothing.
|
|
709
|
+
*/
|
|
710
|
+
const seenIn = (router, result, at) => {
|
|
711
|
+
const log = Result.getOrUndefined(decodePendingLog(result));
|
|
712
|
+
if (log === void 0 || log.removed === true || !isAddressEqual(log.address, router)) return;
|
|
713
|
+
const topics = log.topics;
|
|
714
|
+
const observed = {
|
|
715
|
+
blockNumber: hexToBigInt(log.blockNumber),
|
|
716
|
+
transactionHash: log.transactionHash,
|
|
717
|
+
at
|
|
718
|
+
};
|
|
719
|
+
try {
|
|
720
|
+
if (topics[0] === REQUESTED_TOPIC) {
|
|
721
|
+
const { requestId, request } = decodeRandomnessRequested({
|
|
722
|
+
data: log.data,
|
|
723
|
+
topics
|
|
724
|
+
});
|
|
725
|
+
return {
|
|
726
|
+
event: "RandomnessRequested",
|
|
727
|
+
requestId,
|
|
728
|
+
request,
|
|
729
|
+
...observed
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
if (topics[0] === FULFILLED_TOPIC) return {
|
|
733
|
+
event: "RandomnessFulfilled",
|
|
734
|
+
...decodeRandomnessFulfilled({
|
|
735
|
+
data: log.data,
|
|
736
|
+
topics
|
|
737
|
+
}),
|
|
738
|
+
...observed
|
|
739
|
+
};
|
|
740
|
+
} catch {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
/** The endpoint refused the router's log subscription, or never confirmed it. */
|
|
745
|
+
var SubscriptionRefused = class extends Schema.TaggedError()("SubscriptionRefused", {
|
|
746
|
+
url: Schema.String,
|
|
747
|
+
reason: Schema.String
|
|
748
|
+
}) {
|
|
749
|
+
get message() {
|
|
750
|
+
return `${this.url} did not serve the router's logs: ${this.reason}`;
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
/** The socket carrying the router's logs ended while something was still waiting on it. */
|
|
754
|
+
var WatchEnded = class extends Schema.TaggedError()("WatchEnded", { url: Schema.String }) {
|
|
755
|
+
get message() {
|
|
756
|
+
return `${this.url} ended the socket carrying the router's logs`;
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
const SUBSCRIBE_ID = 1;
|
|
760
|
+
const DEFAULT_SUBSCRIBE_TIMEOUT = "20 seconds";
|
|
761
|
+
const DEFAULT_OPEN_TIMEOUT = "20 seconds";
|
|
762
|
+
/**
|
|
763
|
+
* Opens the socket, subscribes to the router's request and fulfillment logs, and returns once
|
|
764
|
+
* the endpoint has confirmed the subscription, so nothing is fired at the chain before the watch
|
|
765
|
+
* meant to see it is in place. The WebSocket comes from the `Socket.WebSocketConstructor` in
|
|
766
|
+
* context (`Socket.layerWebSocketConstructorGlobal` where there is a global one). Scoped: the
|
|
767
|
+
* socket closes with the scope.
|
|
768
|
+
*/
|
|
769
|
+
const watchRouter = Effect.fn("watchRouter")(function* (options) {
|
|
770
|
+
const { url, router } = options;
|
|
771
|
+
const seen = yield* PubSub.unbounded();
|
|
772
|
+
const subscribed = yield* Deferred.make();
|
|
773
|
+
const ended = yield* Deferred.make();
|
|
774
|
+
const own = yield* PubSub.subscribe(seen);
|
|
775
|
+
const socket = yield* Socket.makeWebSocket(url, { openTimeout: options.openTimeout ?? DEFAULT_OPEN_TIMEOUT });
|
|
776
|
+
const { write } = yield* socket.writer;
|
|
777
|
+
let subscription;
|
|
778
|
+
const handle = (text) => {
|
|
779
|
+
const at = performance.now();
|
|
780
|
+
return Effect.gen(function* () {
|
|
781
|
+
const frame = Result.getOrUndefined(decodeSubscriptionFrame(parseFrame(text)));
|
|
782
|
+
if (frame === void 0) return;
|
|
783
|
+
if ("id" in frame) {
|
|
784
|
+
if (frame.id !== SUBSCRIBE_ID) return;
|
|
785
|
+
if (frame.error !== void 0 || frame.result === void 0) {
|
|
786
|
+
const reason = frame.error?.message ?? "the endpoint confirmed no subscription";
|
|
787
|
+
return yield* Deferred.fail(subscribed, new SubscriptionRefused({
|
|
788
|
+
url,
|
|
789
|
+
reason
|
|
790
|
+
}));
|
|
791
|
+
}
|
|
792
|
+
subscription = frame.result;
|
|
793
|
+
return yield* Deferred.succeed(subscribed, frame.result);
|
|
794
|
+
}
|
|
795
|
+
if (frame.params.subscription !== subscription) return;
|
|
796
|
+
const observed = seenIn(router, frame.params.result, at);
|
|
797
|
+
if (observed !== void 0) yield* PubSub.publish(seen, observed);
|
|
798
|
+
}).pipe(Effect.catchDefect((defect) => Effect.logWarning("routerWatch: a frame was not handled", { defect })));
|
|
799
|
+
};
|
|
800
|
+
yield* Effect.gen(function* () {
|
|
801
|
+
const pull = yield* Socket.readerString(socket);
|
|
802
|
+
for (;;) for (const text of yield* pull) yield* handle(text);
|
|
803
|
+
}).pipe(Effect.ensuring(Deferred.fail(ended, new WatchEnded({ url }))), Effect.forkScoped);
|
|
804
|
+
yield* write(pendingLogsSubscribe(SUBSCRIBE_ID, {
|
|
805
|
+
address: router,
|
|
806
|
+
topics: [[REQUESTED_TOPIC, FULFILLED_TOPIC]]
|
|
807
|
+
}));
|
|
808
|
+
const confirmed = yield* Effect.timeoutOption(Effect.raceFirst(Deferred.await(subscribed), Deferred.await(ended)), options.subscribeTimeout ?? DEFAULT_SUBSCRIBE_TIMEOUT);
|
|
809
|
+
if (Option.isNone(confirmed)) return yield* new SubscriptionRefused({
|
|
810
|
+
url,
|
|
811
|
+
reason: "the endpoint did not confirm the subscription in time"
|
|
812
|
+
});
|
|
813
|
+
const watchOver = (taken) => ({
|
|
814
|
+
next: Effect.raceFirst(PubSub.take(taken), Deferred.await(ended)),
|
|
815
|
+
branch: Effect.map(PubSub.subscribe(seen), watchOver)
|
|
816
|
+
});
|
|
817
|
+
return watchOver(own);
|
|
818
|
+
});
|
|
819
|
+
/**
|
|
820
|
+
* The next observation `match` accepts, or `None` if `timeout` passes first. Observations before
|
|
821
|
+
* it are dropped: they belong to other consumers on the same router.
|
|
822
|
+
*/
|
|
823
|
+
const nextSeen = Effect.fn("nextSeen")(function* (watch, match, timeout) {
|
|
824
|
+
const taking = Effect.gen(function* () {
|
|
825
|
+
for (;;) {
|
|
826
|
+
const matched = match(yield* watch.next);
|
|
827
|
+
if (matched !== void 0) return matched;
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
return yield* Effect.timeoutOption(taking, timeout);
|
|
831
|
+
});
|
|
832
|
+
/**
|
|
833
|
+
* The watch as callbacks, for code without Effect, through the package's `run`: opens and
|
|
834
|
+
* subscribes with `webSocket`, succeeds once the subscription is confirmed with the function
|
|
835
|
+
* that closes it, and from then on hands `onSeen` every observation until the socket ends, which
|
|
836
|
+
* `onEnded` learns of. Fails as `watchRouter` does.
|
|
837
|
+
*
|
|
838
|
+
* const close = await run(observeRouter({ url, router, webSocket: WebSocket, onSeen }), signal);
|
|
839
|
+
*/
|
|
840
|
+
const observeRouter = Effect.fn("observeRouter")(function* (options) {
|
|
841
|
+
const scope = yield* Scope.make();
|
|
842
|
+
const runIn = Effect.runForkWith(yield* Effect.context());
|
|
843
|
+
const close = () => void runIn(Scope.close(scope, Exit.void));
|
|
844
|
+
yield* (yield* watchRouter(options).pipe(Effect.provide(Layer.succeed(Socket.WebSocketConstructor)((url) => new options.webSocket(url))), Scope.provide(scope), Effect.onError(() => Scope.close(scope, Exit.void)))).next.pipe(Effect.flatMap((seen) => Effect.sync(() => options.onSeen(seen))), Effect.forever, Effect.catch((ended) => Effect.sync(() => options.onEnded?.(ended))), Effect.forkIn(scope));
|
|
845
|
+
return close;
|
|
846
|
+
});
|
|
847
|
+
//#endregion
|
|
848
|
+
//#region src/run.ts
|
|
849
|
+
/**
|
|
850
|
+
* The package's one binding for code that runs no Effect: any protocol effect, as a promise.
|
|
851
|
+
* It resolves with what the effect succeeds with and rejects with the very error it fails with
|
|
852
|
+
* (the typed classes, so `instanceof` works on them). `signal` aborting interrupts the effect
|
|
853
|
+
* and rejects with the signal's reason. Effect stays beneath the package: a caller needs none.
|
|
854
|
+
*
|
|
855
|
+
* const client = await run(DirectClient.connect(options));
|
|
856
|
+
* const result = await run(client.request(input), signal);
|
|
857
|
+
*/
|
|
858
|
+
const run = (effect, signal) => {
|
|
859
|
+
const settled = Effect.runPromise(Effect.result(effect), signal === void 0 ? void 0 : { signal }).then((result) => {
|
|
860
|
+
if (Result.isFailure(result)) throw result.failure;
|
|
861
|
+
return result.success;
|
|
862
|
+
});
|
|
863
|
+
if (signal === void 0) return settled;
|
|
864
|
+
return new Promise((resolve, reject) => {
|
|
865
|
+
const abort = () => reject(signal.reason);
|
|
866
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
867
|
+
settled.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
868
|
+
if (signal.aborted) abort();
|
|
869
|
+
});
|
|
870
|
+
};
|
|
871
|
+
//#endregion
|
|
872
|
+
export { Address, Bytes32, CALLDATA_GAS_PER_BYTE, DEFAULT_DEADLINE_SECONDS, DEFAULT_FEE_FLOOR, DeadlinePassed, DirectClient, DirectRefusal, DirectRequest, DirectRequestBody, DirectRequestRefused, DirectResponse, HASH_TO_CURVE_MAX_ITERATIONS, HexString, INTRINSIC_GAS, MAX_CALLBACK_DATA_BYTES, MAX_GAS_PRICE_HEADROOM, NoNodeServing, NodeIdentity, NonceTaken, NotServing, PendingLog, Quote, Request, RouterUnreadable, SENTINEL_PROOF, SERVICE_VRF, STALE_DELAY, SigningFailed, SubscriptionFrame, SubscriptionNotification, SubscriptionRefused, SubscriptionReply, TooManyRequests, UnexpectedResponse, WHOLE_PREMIUM, WatchEnded, bytes48, ceiling, chargedGas, computeFees, decodePendingLog, decodeProof, decodeRandomnessFulfilled, decodeRandomnessRequested, decodeSubscriptionFrame, deriveRandomness, directRequestFulfilledEvent, directRequestId, directRequestTypedData, discoverNodes, encodeAttestation, encodeProof, fee, fulfillmentDigest, fulfillmentItemGas, fulfillmentTypedData, hashToCurveIterations, hexBytes, isFulfillable, isLate, isSentinel, lockedGasPrice, nextSeen, nodeEndpointSetEvent, observeRouter, onDirectRandomness, pendingLogsSubscribe, premiumShare, proofCommitment, prove, publicKeyPoint, randomnessFulfilledEvent, randomnessRequestedEvent, registrationNonce, remainingDelay, requestFrame, requestId, run, seenIn, settlement, settlementAtShare, signDirectRequest, staleAt, validateEndpoint, verify, watchRouter };
|