@d20dao/vrf-sdk 0.3.4 → 0.5.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/dist/epoch.js CHANGED
@@ -1,13 +1,162 @@
1
- import { AbiCoder, id, keccak256, getBytes, toUtf8Bytes, toUtf8String, verifyMessage } from "ethers";
2
- import { canonicalApiRequest, attestationDigest, hashAttestation, validateApiSignatureEncoding } from "./sources.js";
1
+ import { AbiCoder, Contract, getAddress, hexlify, id, keccak256, getBytes, toUtf8Bytes, verifyMessage } from "ethers";
2
+ import { canonicalApiRequest, canonicalPassthroughRequest, parsePassthroughRequest, attestationDigest, hashAttestation, validateApiSignatureEncoding } from "./sources.js";
3
+ import { encodeDataTemplate, matchesDataTemplate, validateDataTemplate } from "./templates.js";
4
+ import { BEACON_TEMPLATE, beaconCanonicalRequest, beaconRoundTime, beaconSlotSigner, decodeBeaconRound, verifyBeaconRound } from "./beacon.js";
3
5
  import { deriveRequestSeed, hashPublicKey, hashProof, verifyVRFProof } from "./verification.js";
4
6
  import { hashMapping, mapRandomness } from "./mapping.js";
5
7
  const abi = AbiCoder.defaultAbiCoder();
6
8
  export const EPOCH_LENGTH = 200n;
7
9
  export const MAX_ATTESTATION_AGE = 240n;
8
10
  export const EPOCH_RECIPE_DOMAIN = id("D20_EPOCH_RECIPES");
9
- export function epochCatalogHash(signers) {
10
- return keccak256(abi.encode(["bytes32", "address[4]"], [EPOCH_RECIPE_DOMAIN, signers]));
11
+ /// A catalog lists 1 to MAX_EPOCH_SOURCES distinct recipes; attempt n of an epoch opens n × FALLBACK_DELAY_BLOCKS into it.
12
+ export const MAX_EPOCH_SOURCES = 10, FALLBACK_DELAY_BLOCKS = 20n;
13
+ function frozen(value) {
14
+ if (value !== null && typeof value === "object") {
15
+ for (const child of Object.values(value))
16
+ frozen(child);
17
+ Object.freeze(value);
18
+ }
19
+ return value;
20
+ }
21
+ const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11", GET_LAST_BLOCK_HASH = "0x27e86d6e";
22
+ /// Recipe ids are uint8; canonical requests and bodies are bounded so every EpochCommitted packet fits 2048 bytes.
23
+ export const MAX_EPOCH_RECIPES = 256, MAX_RECIPE_REQUEST_BYTES = 1024, MAX_RECIPE_BODY_BYTES = 2048;
24
+ function builtin(id, provider, description, request, template) {
25
+ return { id, provider, description, request, canonicalRequest: canonicalApiRequest(request), template: encodeDataTemplate(template), body: JSON.stringify(request) };
26
+ }
27
+ const blockHash = (network) => ({ operation: "jsonRpc", parameters: { network, method: "eth_call", params: [{ to: MULTICALL3, data: GET_LAST_BLOCK_HASH }, "latest"] } });
28
+ const lastTrade = (symbol) => ({ operation: "lastTrade", parameters: { assetClass: "crypto", symbol } });
29
+ const jsonNumber = { decimal: { fraction: true, exponent: true } };
30
+ const blockHashRecord = [{ literal: '{"id":null,"jsonrpc":"2.0","result":"0x' }, { hex: 64 }, { literal: '"}' }];
31
+ const tradeRecord = (symbol) => [{ literal: `{"symbol":"${symbol}","price":` }, jsonNumber, { literal: ',"size":' }, jsonNumber,
32
+ { literal: ',"timestamp":' }, { integer: { minDigits: 1, maxDigits: 16 } }, { literal: "}" }];
33
+ /// The recipes EpochEntropy registers itself, at initialization or in its recipe-registry upgrade, with exactly these ids,
34
+ /// canonical requests, templates and bodies. Ids 0-3 form the initial catalog and keep their earlier canonical requests.
35
+ export const BUILTIN_EPOCH_RECIPES = frozen([
36
+ builtin(0, "hyperliquid", "BTC daily notional volume", { operation: "metaAndAssetCtxs", parameters: { dex: "" }, responseProjection: { symbol: "/0/universe/0/name", value: "/1/0/dayNtlVlm" } }, [{ literal: '{"symbol":"BTC","value":"' }, { decimal: { fraction: true, exponent: false } }, { literal: '"}' }]),
37
+ builtin(1, "drpc", "Ethereum mainnet block hash", blockHash("ethereum"), blockHashRecord),
38
+ builtin(2, "tickerlayer", "BTCUSD last trade", lastTrade("BTCUSD"), tradeRecord("BTCUSD")),
39
+ builtin(3, "tickerlayer", "ETHUSD last trade", lastTrade("ETHUSD"), tradeRecord("ETHUSD")),
40
+ builtin(4, "nodary", "ETH/USD feed", { operation: "latestFeeds", parameters: { name: "ETH/USD" } }, [{ literal: '{"ETH/USD":{"value":' }, jsonNumber, { literal: ',"timestamp":' }, { integer: { minDigits: 13, maxDigits: 13 } }, { literal: ',"category":"crypto"}}' }]),
41
+ builtin(5, "drpc", "Base block hash", blockHash("base"), blockHashRecord),
42
+ ]);
43
+ export const INITIAL_EPOCH_RECIPES = frozen([0, 1, 2, 3]);
44
+ const jsonRpcCall = (network) => ({ method: "POST", path: "/ogrpc", query: { network },
45
+ body: JSON.stringify({ jsonrpc: "2.0", id: null, method: "eth_call", params: [{ to: MULTICALL3, data: GET_LAST_BLOCK_HASH }, "latest"] }) });
46
+ /// Each built-in recipe's listing reached through the gateway's passthrough (/api) instead of POST /: the same parameters
47
+ /// and projection, answered with the same signed record, so it keeps the built-in template. The gateway signs it under
48
+ /// another request hash, so it is a separate recipe the owner registers; EpochEntropy does not register it.
49
+ export const PASSTHROUGH_EPOCH_REQUESTS = frozen([
50
+ { method: "POST", path: "/info", body: '{"type":"metaAndAssetCtxs","dex":""}', projection: { symbol: "/0/universe/0/name", value: "/1/0/dayNtlVlm" } },
51
+ jsonRpcCall("ethereum"),
52
+ { method: "GET", path: "/crypto/trade/last/BTCUSD" },
53
+ { method: "GET", path: "/crypto/trade/last/ETHUSD" },
54
+ { method: "GET", path: "/feed/latest", query: { name: "ETH/USD" } },
55
+ jsonRpcCall("base"),
56
+ ]);
57
+ /// The passthrough recipe of a built-in recipe id: its canonical request is also its body.
58
+ export function passthroughEpochRecipe(builtinRecipe) {
59
+ const request = PASSTHROUGH_EPOCH_REQUESTS[builtinRecipe];
60
+ if (request === undefined)
61
+ throw new Error(`Built-in recipe ${builtinRecipe} has no passthrough form`);
62
+ const canonicalRequest = canonicalPassthroughRequest(request);
63
+ return { canonicalRequest, template: BUILTIN_EPOCH_RECIPES[builtinRecipe].template, body: canonicalRequest };
64
+ }
65
+ const byteLength = (text) => toUtf8Bytes(text).length;
66
+ /// Throws unless a recipe satisfies the registry's registration rules: bounded sizes and a well-formed template.
67
+ /// A beacon recipe must also be exactly what registerBeacon appends for its registration.
68
+ export function validateEpochRecipe(recipe) {
69
+ if (typeof recipe?.canonicalRequest !== "string" || byteLength(recipe.canonicalRequest) === 0 || byteLength(recipe.canonicalRequest) > MAX_RECIPE_REQUEST_BYTES)
70
+ throw new Error(`A recipe canonical request must be 1 to ${MAX_RECIPE_REQUEST_BYTES} bytes`);
71
+ if (typeof recipe.body !== "string" || byteLength(recipe.body) === 0 || byteLength(recipe.body) > MAX_RECIPE_BODY_BYTES)
72
+ throw new Error(`A recipe body must be 1 to ${MAX_RECIPE_BODY_BYTES} bytes`);
73
+ validateDataTemplate(recipe.template);
74
+ const { beacon } = recipe;
75
+ if (beacon === undefined)
76
+ return;
77
+ if (BigInt(beacon.verifier) === 0n || BigInt(beacon.chainHash) === 0n || beacon.genesis < 1n || beacon.period < 1n || getBytes(beacon.publicKey).length !== 128)
78
+ throw new Error("Invalid beacon registration");
79
+ const request = beaconCanonicalRequest(beacon.chainHash);
80
+ if (recipe.canonicalRequest !== request || recipe.body !== request || recipe.template !== BEACON_TEMPLATE)
81
+ throw new Error("A beacon recipe's canonical request and body name its chain hash, and its template is one round number");
82
+ }
83
+ /// The canonical request of a recipe body. An object is a POST / gateway body with a string operation, a parameters
84
+ /// object and an optional responseProjection object; an array is a passthrough request, which is its own canonical
85
+ /// request. Keepers refuse a recipe whose body does not canonicalize to its request.
86
+ export function canonicalRequestOfBody(body) {
87
+ const parsed = JSON.parse(body);
88
+ if (Array.isArray(parsed)) {
89
+ parsePassthroughRequest(body);
90
+ return body;
91
+ }
92
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
93
+ if (!isObject(parsed))
94
+ throw new Error("A recipe body must be a JSON object");
95
+ const request = parsed;
96
+ if (typeof request.operation !== "string" || !isObject(request.parameters) || (request.responseProjection !== undefined && !isObject(request.responseProjection)))
97
+ throw new Error("A recipe body needs a string operation, a parameters object and an optional responseProjection object");
98
+ return canonicalApiRequest(request);
99
+ }
100
+ const RECIPE_VIEW = ["function getRecipe(uint8 recipe) view returns (bytes32 queryHash,string canonicalRequest,bytes template,string body)",
101
+ "function beaconOf(uint8 recipe) view returns (tuple(address verifier,uint64 genesis,uint64 period,bytes32 chainHash,bytes publicKey))"];
102
+ /// Read registered recipes from a registry. Each definition is checked against its query hash and the registration rules.
103
+ /// A recipe whose canonical request names drand is asked for its beacon registration, which a signed recipe lacks.
104
+ /// With a blockTag the recipes and registrations are read as of that block instead of the latest one. A registry that was rolled back to an
105
+ /// implementation without beaconOf no longer answers it, so a beacon epoch published earlier replays from a block of the implementation
106
+ /// that had it, for example the epoch's commit block; that needs an RPC that serves the state of past blocks.
107
+ export async function readEpochRecipes(runner, registry, ids, options = {}) {
108
+ const view = new Contract(registry, RECIPE_VIEW, runner), book = {};
109
+ const overrides = options.blockTag === undefined ? {} : { blockTag: options.blockTag };
110
+ for (const recipe of new Set(ids)) {
111
+ const [queryHash, canonicalRequest, template, body] = await view.getRecipe(recipe, overrides);
112
+ const entry = { canonicalRequest, template: hexlify(template), body };
113
+ if (keccak256(toUtf8Bytes(canonicalRequest)) !== queryHash)
114
+ throw new Error(`Recipe ${recipe} query hash mismatch`);
115
+ if (canonicalRequest.startsWith('["drand","')) {
116
+ const { verifier, genesis, period, chainHash, publicKey } = await view.beaconOf(recipe, overrides);
117
+ if (BigInt(verifier) !== 0n)
118
+ entry.beacon = { verifier, chainHash, publicKey: hexlify(publicKey), genesis, period };
119
+ }
120
+ validateEpochRecipe(entry);
121
+ book[recipe] = entry;
122
+ }
123
+ return book;
124
+ }
125
+ /// The exact signed bytes EpochEntropy accepts for a recipe template: at most 128 bytes, never cropped or reformatted.
126
+ export function validateEpochData(template, data) {
127
+ if (!matchesDataTemplate(template, data))
128
+ throw new Error("Invalid exact epoch data");
129
+ }
130
+ /// The recipe ids of a catalog in slot order; throws unless it follows the registry's catalog rules.
131
+ export function epochCatalogRecipes(catalog) {
132
+ const recipes = catalog.recipes ?? INITIAL_EPOCH_RECIPES;
133
+ if (recipes.length < 1 || recipes.length > MAX_EPOCH_SOURCES || catalog.signers.length !== recipes.length || new Set(recipes).size !== recipes.length ||
134
+ recipes.some(recipe => !Number.isInteger(recipe) || recipe < 0 || recipe >= MAX_EPOCH_RECIPES) || catalog.signers.some(signer => BigInt(signer) === 0n))
135
+ throw new Error("Invalid epoch catalog");
136
+ return recipes;
137
+ }
138
+ /// A recipe definition from the catalog's recipe book, or the built-in recipe with that id, which every registry registers identically.
139
+ export function epochRecipe(catalog, recipe) {
140
+ const entry = catalog.recipeBook?.[recipe] ?? BUILTIN_EPOCH_RECIPES[recipe];
141
+ if (entry === undefined)
142
+ throw new Error(`Unknown epoch recipe ${recipe}: supply its registered definition, for example from readEpochRecipes`);
143
+ validateEpochRecipe(entry);
144
+ return entry;
145
+ }
146
+ export function epochCatalogHash(signers, recipes) {
147
+ epochCatalogRecipes({ signers, recipes });
148
+ return recipes === undefined ? keccak256(abi.encode(["bytes32", "address[4]"], [EPOCH_RECIPE_DOMAIN, signers]))
149
+ : keccak256(abi.encode(["bytes32", "uint8[]", "address[]"], [EPOCH_RECIPE_DOMAIN, recipes, signers]));
150
+ }
151
+ /// Build an epoch's catalog from the registry's catalogAt(epoch) view, identifying the initial catalog by its hash.
152
+ /// A recipeBook in base, for example from readEpochRecipes, is kept; without one the built-in recipes are used.
153
+ export function resolveEpochCatalog(base, view) {
154
+ const signers = view.signers.map(signer => getAddress(signer)), recipes = view.recipes.map(Number);
155
+ if (recipes.join() === INITIAL_EPOCH_RECIPES.join() && epochCatalogHash(signers) === view.hash)
156
+ return { ...base, signers };
157
+ if (epochCatalogHash(signers, recipes) !== view.hash)
158
+ throw new Error("Catalog hash mismatch");
159
+ return { ...base, signers, recipes };
11
160
  }
12
161
  export function epochStart(firstEpochStart, epochId) {
13
162
  if (epochId < 1n)
@@ -16,44 +165,48 @@ export function epochStart(firstEpochStart, epochId) {
16
165
  }
17
166
  export function epochForBlock(firstEpochStart, block) { return block < firstEpochStart ? 0n : 1n + (block - firstEpochStart) / EPOCH_LENGTH; }
18
167
  /// A fallback attempt n uses the source n slots after the selected one, from n × FALLBACK_DELAY_BLOCKS into the epoch.
19
- export const FALLBACK_DELAY_BLOCKS = 20n, MAX_FALLBACK_ATTEMPT = 3;
20
168
  export function fallbackOpensAt(firstEpochStart, epochId, attempt) {
21
- if (!Number.isInteger(attempt) || attempt < 0 || attempt > MAX_FALLBACK_ATTEMPT)
169
+ if (!Number.isInteger(attempt) || attempt < 0 || attempt >= MAX_EPOCH_SOURCES)
22
170
  throw new Error("Invalid fallback attempt");
23
171
  return epochStart(firstEpochStart, epochId) + BigInt(attempt) * FALLBACK_DELAY_BLOCKS;
24
172
  }
25
173
  export function selectEpoch(catalog, epochId, anchorHash, attempt = 0) {
26
174
  if (epochId < 1n || BigInt(anchorHash) === 0n)
27
175
  throw new Error("Invalid epoch anchor");
28
- if (!Number.isInteger(attempt) || attempt < 0 || attempt > MAX_FALLBACK_ATTEMPT)
176
+ const recipes = epochCatalogRecipes(catalog), count = recipes.length;
177
+ if (!Number.isInteger(attempt) || attempt < 0 || attempt >= count)
29
178
  throw new Error("Invalid fallback attempt");
30
- const catalogHash = epochCatalogHash(catalog.signers);
31
- const selector = keccak256(abi.encode(["bytes32", "bytes32", "uint64", "bytes32"], [id("D20_EPOCH_SELECT"), catalogHash, epochId, anchorHash]));
32
- const source = (Number(BigInt(selector) % 4n) + attempt) % 4;
33
- const request = source === 0 ? { operation: "metaAndAssetCtxs", parameters: { dex: "" }, responseProjection: { symbol: "/0/universe/0/name", value: "/1/0/dayNtlVlm" } }
34
- : source === 1 ? { operation: "randomNumbers", parameters: { type: "hex8", length: 4, size: 8 } }
35
- : { operation: "lastTrade", parameters: { assetClass: "crypto", symbol: source === 2 ? "BTCUSD" : "ETHUSD" } };
36
- const canonicalRequest = canonicalApiRequest(request);
37
- return { source, attempt, airnode: catalog.signers[source], selector, canonicalRequest, queryHash: keccak256(toUtf8Bytes(canonicalRequest)), request };
179
+ const selector = keccak256(abi.encode(["bytes32", "bytes32", "uint64", "bytes32"], [id("D20_EPOCH_SELECT"), epochCatalogHash(catalog.signers, catalog.recipes), epochId, anchorHash]));
180
+ const source = (Number(BigInt(selector) % BigInt(count)) + attempt) % count, recipe = recipes[source];
181
+ const { canonicalRequest, template, body, beacon } = epochRecipe(catalog, recipe);
182
+ return { source, recipe, attempt, airnode: catalog.signers[source], selector, canonicalRequest, queryHash: keccak256(toUtf8Bytes(canonicalRequest)), template, body, ...(beacon && { beacon }) };
38
183
  }
39
184
  export function verifyEpochAttestation(selected, a, commitTimestamp) {
40
- validateApiSignatureEncoding(a.signature);
185
+ const { beacon } = selected;
186
+ if (beacon === undefined)
187
+ validateApiSignatureEncoding(a.signature);
41
188
  if (a.timestamp > commitTimestamp || commitTimestamp - a.timestamp > MAX_ATTESTATION_AGE)
42
189
  throw new Error("Invalid epoch attestation time");
43
- const body = toUtf8String(a.data);
44
- const number = "(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?";
45
- const pattern = selected.source === 0 ? /^\{"symbol":"BTC","value":"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?"\}$/
46
- : selected.source === 1 ? /^\{"success":true,"type":"hex8","length":"4","data":\["[0-9a-f]{16}","[0-9a-f]{16}","[0-9a-f]{16}","[0-9a-f]{16}"\]\}$/
47
- : new RegExp(`^\\{"symbol":"${selected.source === 2 ? "BTCUSD" : "ETHUSD"}","price":${number},"size":${number},"timestamp":[1-9][0-9]{0,15}\\}$`);
48
- if (getBytes(a.data).length > 128 || !pattern.test(body))
49
- throw new Error("Invalid exact epoch data");
190
+ validateEpochData(selected.template, a.data);
191
+ if (beacon !== undefined) {
192
+ // A beacon round is scheduled, so its number fixes the timestamp; the verifier's check stands in for the signer's, and
193
+ // the catalog's signer must be the identity this registration derives.
194
+ const round = decodeBeaconRound(a.data);
195
+ if (a.timestamp !== beaconRoundTime(beacon, round))
196
+ throw new Error("Invalid epoch attestation time");
197
+ if (!verifyBeaconRound(beacon.publicKey, round, a.signature))
198
+ throw new Error("Invalid beacon signature");
199
+ if (selected.airnode.toLowerCase() !== beaconSlotSigner(beacon).toLowerCase())
200
+ throw new Error("Wrong epoch signer/query");
201
+ return { dataHash: keccak256(a.data), attestationHash: hashAttestation(selected.queryHash, a), signer: selected.airnode };
202
+ }
50
203
  const signer = verifyMessage(getBytes(attestationDigest(selected.queryHash, a)), a.signature);
51
204
  if (signer.toLowerCase() !== selected.airnode.toLowerCase())
52
205
  throw new Error("Wrong epoch signer/query");
53
206
  return { dataHash: keccak256(a.data), attestationHash: hashAttestation(selected.queryHash, a), signer };
54
207
  }
55
208
  export function epochCommitmentHash(catalog, epochId, anchorHash, source, queryHash, dataHash, attestationHash) {
56
- return keccak256(abi.encode(["bytes32", "uint256", "address", "bytes32", "uint64", "uint64", "bytes32", "uint8", "bytes32", "bytes32", "bytes32"], [id("D20_EPOCH"), catalog.chainId, catalog.registry, epochCatalogHash(catalog.signers), epochId, epochStart(catalog.firstEpochStart, epochId), anchorHash, source, queryHash, dataHash, attestationHash]));
209
+ return keccak256(abi.encode(["bytes32", "uint256", "address", "bytes32", "uint64", "uint64", "bytes32", "uint8", "bytes32", "bytes32", "bytes32"], [id("D20_EPOCH"), catalog.chainId, catalog.registry, epochCatalogHash(catalog.signers, catalog.recipes), epochId, epochStart(catalog.firstEpochStart, epochId), anchorHash, source, queryHash, dataHash, attestationHash]));
57
210
  }
58
211
  const packetTypes = ["string", "tuple(uint256 timestamp,bytes data,bytes signature)"];
59
212
  export function encodeEpochEvidencePacket(canonicalRequest, attestation) {
@@ -72,9 +225,11 @@ export function decodeEpochEvidencePacket(packet) {
72
225
  return { canonicalRequest: canonicalRequest, attestation };
73
226
  }
74
227
  export function replayEpochCommitment(input) {
75
- const { catalog, epochId, record } = input;
228
+ const { catalog, epochId, record } = input, count = epochCatalogRecipes(catalog).length;
229
+ if (!Number.isInteger(Number(record.source)) || Number(record.source) < 0 || Number(record.source) >= count)
230
+ throw new Error("Epoch record mismatch");
76
231
  // The committed source fixes the attempt; a fallback is valid only if it was committed after its window opened.
77
- const attempt = (Number(record.source) - selectEpoch(catalog, epochId, record.anchorHash).source + 4) % 4;
232
+ const attempt = (Number(record.source) - selectEpoch(catalog, epochId, record.anchorHash).source + count) % count;
78
233
  if (record.committedBlock < fallbackOpensAt(catalog.firstEpochStart, epochId, attempt))
79
234
  throw new Error("Invalid epoch commit block");
80
235
  const selected = selectEpoch(catalog, epochId, record.anchorHash, attempt), evidence = decodeEpochEvidencePacket(input.packet);
@@ -82,7 +237,7 @@ export function replayEpochCommitment(input) {
82
237
  throw new Error("Epoch recipe mismatch");
83
238
  const verified = verifyEpochAttestation(selected, evidence.attestation, input.commitTimestamp);
84
239
  const epochHash = epochCommitmentHash(catalog, epochId, record.anchorHash, selected.source, selected.queryHash, verified.dataHash, verified.attestationHash);
85
- if (record.epochHash !== epochHash || record.catalogHash !== epochCatalogHash(catalog.signers) || Number(record.source) !== selected.source || record.queryHash !== selected.queryHash ||
240
+ if (record.epochHash !== epochHash || record.catalogHash !== epochCatalogHash(catalog.signers, catalog.recipes) || Number(record.source) !== selected.source || record.queryHash !== selected.queryHash ||
86
241
  record.dataHash !== verified.dataHash || record.attestationHash !== verified.attestationHash || record.signedAt !== evidence.attestation.timestamp)
87
242
  throw new Error("Epoch record mismatch");
88
243
  return { epochHash, selected, ...verified };
@@ -95,8 +250,8 @@ export function epochTranscriptHash(c, configurationHash, proofHash, randomness)
95
250
  }
96
251
  /// Canonical blocks, transaction inclusion/timestamps and the proxy implementation code active
97
252
  /// at each receipt must be independently trusted chain context. A proxy code hash alone is insufficient.
98
- /// The signer catalog is per epoch: epoch.catalog.signers must be the catalog in force for context.epochId
99
- /// (CatalogScheduled events or signersAt on the registry); replayEpochCommitment binds them to record.catalogHash,
253
+ /// The catalog is per epoch: epoch.catalog must be the catalog in force for context.epochId (catalogAt on the
254
+ /// registry, or CatalogScheduled events and the initial catalog); replayEpochCommitment binds it to record.catalogHash,
100
255
  /// while configuration.catalogHash remains the initial catalog bound into protocolConfigurationHash.
101
256
  export function replayEpochCoordinator(input) {
102
257
  const c = input.context, cfg = input.configuration, e = input.epoch;
package/dist/index.d.ts CHANGED
@@ -2,11 +2,15 @@ export { builtins, Operation, validateMapping, hashMapping, mapRandomness } from
2
2
  export type { MappingSpec } from "./mapping.js";
3
3
  export { deriveRequestSeed, hashPublicKey, hashProof, verifyVRFProof } from "./verification.js";
4
4
  export type { RequestContext, VRFProof, XY } from "./verification.js";
5
- export { canonicalApiRequest, attestationDigest, hashAttestation, validateApiSignatureEncoding } from "./sources.js";
6
- export type { ApiRequest, ApiAttestation } from "./sources.js";
5
+ export { canonicalApiRequest, PASSTHROUGH, canonicalPassthroughRequest, parsePassthroughRequest, passthroughUrl, attestationDigest, hashAttestation, validateApiSignatureEncoding } from "./sources.js";
6
+ export type { ApiRequest, ApiAttestation, PassthroughRequest } from "./sources.js";
7
7
  export { replayCoordinator, transcriptHash } from "./replay.js";
8
8
  export { encodeEvidencePacket, decodeEvidencePacket, EVIDENCE_PACKET_BYTES } from "./evidence.js";
9
- export { EPOCH_LENGTH, EPOCH_RECIPE_DOMAIN, epochCatalogHash, epochStart, epochForBlock, selectEpoch, verifyEpochAttestation, epochCommitmentHash, encodeEpochEvidencePacket, decodeEpochEvidencePacket, replayEpochCommitment, epochProtocolConfigurationHash } from "./epoch.js";
10
- export type { EpochCatalog, EpochSigners, EpochRecord, EpochProtocolConfiguration } from "./epoch.js";
9
+ export { MAX_EPOCH_DATA_BYTES, MAX_DATA_TEMPLATE_BYTES, encodeDataTemplate, decodeDataTemplate, isValidDataTemplate, validateDataTemplate, matchesDataTemplate } from "./templates.js";
10
+ export type { DataTemplateSegment } from "./templates.js";
11
+ export { BEACON_DST, BEACON_DOMAIN, BEACON_TEMPLATE, DRAND_EVMNET, beaconCanonicalRequest, beaconSlotSigner, beaconRoundTime, beaconRoundAt, encodeBeaconRound, decodeBeaconRound, beaconRoundMessage, verifyBeaconRound } from "./beacon.js";
12
+ export type { BeaconRegistration } from "./beacon.js";
13
+ export { EPOCH_LENGTH, EPOCH_RECIPE_DOMAIN, BUILTIN_EPOCH_RECIPES, INITIAL_EPOCH_RECIPES, PASSTHROUGH_EPOCH_REQUESTS, passthroughEpochRecipe, MAX_EPOCH_SOURCES, MAX_EPOCH_RECIPES, MAX_RECIPE_REQUEST_BYTES, MAX_RECIPE_BODY_BYTES, FALLBACK_DELAY_BLOCKS, validateEpochRecipe, canonicalRequestOfBody, readEpochRecipes, epochRecipe, epochCatalogHash, epochCatalogRecipes, resolveEpochCatalog, epochStart, epochForBlock, fallbackOpensAt, selectEpoch, validateEpochData, verifyEpochAttestation, epochCommitmentHash, encodeEpochEvidencePacket, decodeEpochEvidencePacket, replayEpochCommitment, epochProtocolConfigurationHash } from "./epoch.js";
14
+ export type { EpochCatalog, EpochSigners, EpochRecord, EpochRecipe, EpochRecipeBook, BuiltinEpochRecipe, EpochProvider, EpochProtocolConfiguration } from "./epoch.js";
11
15
  export { quoteRequestFee, DEFAULT_FEE_BUFFER_BPS } from "./fees.js";
12
16
  export type { FeeQuote, FeeQuoteOptions, FeeQuoteProvider } from "./fees.js";
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export { builtins, Operation, validateMapping, hashMapping, mapRandomness } from "./mapping.js";
2
2
  export { deriveRequestSeed, hashPublicKey, hashProof, verifyVRFProof } from "./verification.js";
3
- export { canonicalApiRequest, attestationDigest, hashAttestation, validateApiSignatureEncoding } from "./sources.js";
3
+ export { canonicalApiRequest, PASSTHROUGH, canonicalPassthroughRequest, parsePassthroughRequest, passthroughUrl, attestationDigest, hashAttestation, validateApiSignatureEncoding } from "./sources.js";
4
4
  export { replayCoordinator, transcriptHash } from "./replay.js";
5
5
  export { encodeEvidencePacket, decodeEvidencePacket, EVIDENCE_PACKET_BYTES } from "./evidence.js";
6
- export { EPOCH_LENGTH, EPOCH_RECIPE_DOMAIN, epochCatalogHash, epochStart, epochForBlock, selectEpoch, verifyEpochAttestation, epochCommitmentHash, encodeEpochEvidencePacket, decodeEpochEvidencePacket, replayEpochCommitment, epochProtocolConfigurationHash } from "./epoch.js";
6
+ export { MAX_EPOCH_DATA_BYTES, MAX_DATA_TEMPLATE_BYTES, encodeDataTemplate, decodeDataTemplate, isValidDataTemplate, validateDataTemplate, matchesDataTemplate } from "./templates.js";
7
+ export { BEACON_DST, BEACON_DOMAIN, BEACON_TEMPLATE, DRAND_EVMNET, beaconCanonicalRequest, beaconSlotSigner, beaconRoundTime, beaconRoundAt, encodeBeaconRound, decodeBeaconRound, beaconRoundMessage, verifyBeaconRound } from "./beacon.js";
8
+ export { EPOCH_LENGTH, EPOCH_RECIPE_DOMAIN, BUILTIN_EPOCH_RECIPES, INITIAL_EPOCH_RECIPES, PASSTHROUGH_EPOCH_REQUESTS, passthroughEpochRecipe, MAX_EPOCH_SOURCES, MAX_EPOCH_RECIPES, MAX_RECIPE_REQUEST_BYTES, MAX_RECIPE_BODY_BYTES, FALLBACK_DELAY_BLOCKS, validateEpochRecipe, canonicalRequestOfBody, readEpochRecipes, epochRecipe, epochCatalogHash, epochCatalogRecipes, resolveEpochCatalog, epochStart, epochForBlock, fallbackOpensAt, selectEpoch, validateEpochData, verifyEpochAttestation, epochCommitmentHash, encodeEpochEvidencePacket, decodeEpochEvidencePacket, replayEpochCommitment, epochProtocolConfigurationHash } from "./epoch.js";
7
9
  export { quoteRequestFee, DEFAULT_FEE_BUFFER_BPS } from "./fees.js";
package/dist/sources.d.ts CHANGED
@@ -9,6 +9,17 @@ export interface ApiAttestation {
9
9
  signature: string;
10
10
  }
11
11
  export declare function canonicalApiRequest(request: ApiRequest): string;
12
+ export interface PassthroughRequest {
13
+ method: "GET" | "POST";
14
+ path: string;
15
+ query?: Record<string, string>;
16
+ body?: string;
17
+ projection?: Record<string, string>;
18
+ }
19
+ export declare const PASSTHROUGH = "passthrough";
20
+ export declare function canonicalPassthroughRequest(request: PassthroughRequest): string;
21
+ export declare function parsePassthroughRequest(text: string): PassthroughRequest;
22
+ export declare function passthroughUrl(gateway: string, request: PassthroughRequest): string;
12
23
  export declare function attestationDigest(requestHash: string, a: ApiAttestation): string;
13
24
  export declare function hashAttestation(requestHash: string, a: ApiAttestation): string;
14
25
  export declare function validateApiSignatureEncoding(signature: string): void;
package/dist/sources.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { AbiCoder, getBytes, hexlify, keccak256, solidityPacked } from "ethers";
2
2
  const abi = AbiCoder.defaultAbiCoder();
3
+ // AirnodeHub request canonicalization: every object, at any depth, becomes its [key, value] entries sorted by key,
4
+ // and arrays keep their order. The request hash is keccak256 of the UTF-8 JSON of [operation, parameters(, projection)].
3
5
  function canonical(value) {
4
6
  if (Array.isArray(value))
5
7
  return value.map(canonical);
@@ -13,6 +15,78 @@ export function canonicalApiRequest(request) {
13
15
  parts.push(canonical(request.responseProjection));
14
16
  return JSON.stringify(parts);
15
17
  }
18
+ export const PASSTHROUGH = "passthrough";
19
+ // Path segments, query names and projection aliases are unreserved characters, so they reach the gateway unambiguously.
20
+ const UNRESERVED = /^[A-Za-z0-9._~-]+$/;
21
+ const sortedEntries = (entries) => Object.entries(entries).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
22
+ export function canonicalPassthroughRequest(request) {
23
+ const parts = [PASSTHROUGH, request.method, request.path, sortedEntries(request.query ?? {}), request.body ?? ""];
24
+ if (request.projection !== undefined)
25
+ parts.push(sortedEntries(request.projection));
26
+ const text = JSON.stringify(parts);
27
+ parsePassthroughRequest(text);
28
+ return text;
29
+ }
30
+ function passthroughEntries(value, what) {
31
+ if (!Array.isArray(value))
32
+ throw new Error(`A passthrough ${what} is an array of [name, value] entries`);
33
+ return value.map((entry, index) => {
34
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string" || typeof entry[1] !== "string" || !UNRESERVED.test(entry[0]))
35
+ throw new Error(`Passthrough ${what} entry ${index} is not a [name, value] pair with an unreserved name`);
36
+ if (index > 0 && !(value[index - 1][0] < entry[0]))
37
+ throw new Error(`Passthrough ${what} entries must be sorted by name without repeats`);
38
+ return [entry[0], entry[1]];
39
+ });
40
+ }
41
+ /// The request a passthrough recipe's canonical request describes. Throws unless the text is exactly the compact JSON the
42
+ /// gateway hashes and names one request a keeper sends unambiguously: GET without a body or POST with a JSON body, a path
43
+ /// of unreserved segments, query names outside the gateway's own x-airnode- parameters, and projection JSON Pointers.
44
+ export function parsePassthroughRequest(text) {
45
+ const parts = JSON.parse(text);
46
+ if (!Array.isArray(parts) || (parts.length !== 5 && parts.length !== 6) || parts[0] !== PASSTHROUGH || JSON.stringify(parts) !== text)
47
+ throw new Error('A passthrough request is the compact JSON array ["passthrough", method, path, query, body(, projection)]');
48
+ const [, method, path, query, body, projection] = parts;
49
+ if (method !== "GET" && method !== "POST")
50
+ throw new Error("A passthrough method is GET or POST");
51
+ if (typeof path !== "string" || !path.startsWith("/") || path.slice(1).split("/").some(segment => !UNRESERVED.test(segment) || segment === "." || segment === ".."))
52
+ throw new Error("A passthrough path is / followed by segments of unreserved characters");
53
+ const queryEntries = passthroughEntries(query, "query");
54
+ if (queryEntries.some(([name]) => name.toLowerCase().startsWith("x-airnode-")))
55
+ throw new Error("x-airnode- query parameters belong to the gateway");
56
+ if (typeof body !== "string")
57
+ throw new Error("A passthrough body is a string");
58
+ if (method === "GET" && body !== "")
59
+ throw new Error("A passthrough GET has no body");
60
+ if (method === "POST") {
61
+ try {
62
+ JSON.parse(body);
63
+ }
64
+ catch {
65
+ throw new Error("A passthrough POST body is JSON text");
66
+ }
67
+ }
68
+ const request = { method, path };
69
+ if (queryEntries.length)
70
+ request.query = Object.fromEntries(queryEntries);
71
+ if (body !== "")
72
+ request.body = body;
73
+ if (parts.length === 6) {
74
+ const projectionEntries = passthroughEntries(projection, "projection");
75
+ if (!projectionEntries.length || projectionEntries.some(([, pointer]) => !/^\/(?:[^~]|~[01])*$/.test(pointer)))
76
+ throw new Error("A passthrough projection lists alias and JSON Pointer entries");
77
+ request.projection = Object.fromEntries(projectionEntries);
78
+ }
79
+ return request;
80
+ }
81
+ const percentEncode = (text) => Array.from(new TextEncoder().encode(text), byte => UNRESERVED.test(String.fromCharCode(byte)) ? String.fromCharCode(byte) : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join("");
82
+ /// Where a keeper sends a passthrough request: the gateway's /api and the path, then the query entries and one
83
+ /// x-airnode-project parameter per projection entry, all in canonical order, with names and values percent-encoded except
84
+ /// unreserved characters. The gateway decodes them before hashing; projected fields come back in the order sent.
85
+ export function passthroughUrl(gateway, request) {
86
+ const params = [...sortedEntries(request.query ?? {}).map(([name, value]) => `${percentEncode(name)}=${percentEncode(value)}`),
87
+ ...sortedEntries(request.projection ?? {}).map(([alias, pointer]) => `x-airnode-project=${percentEncode(`${alias}:${pointer}`)}`)];
88
+ return `${gateway.replace(/\/+$/, "")}/api${request.path}${params.length ? `?${params.join("&")}` : ""}`;
89
+ }
16
90
  export function attestationDigest(requestHash, a) {
17
91
  return keccak256(solidityPacked(["bytes32", "uint256", "bytes"], [requestHash, a.timestamp, a.data]));
18
92
  }
@@ -0,0 +1,22 @@
1
+ import { type BytesLike } from "ethers";
2
+ export declare const MAX_EPOCH_DATA_BYTES = 128, MAX_DATA_TEMPLATE_BYTES = 256;
3
+ export type DataTemplateSegment = {
4
+ literal: string;
5
+ } | {
6
+ hex: number;
7
+ } | {
8
+ decimal: {
9
+ fraction: boolean;
10
+ exponent: boolean;
11
+ };
12
+ } | {
13
+ integer: {
14
+ minDigits: number;
15
+ maxDigits: number;
16
+ };
17
+ };
18
+ export declare function encodeDataTemplate(segments: readonly DataTemplateSegment[]): string;
19
+ export declare function isValidDataTemplate(template: BytesLike): boolean;
20
+ export declare function validateDataTemplate(template: BytesLike): void;
21
+ export declare function decodeDataTemplate(template: BytesLike): DataTemplateSegment[];
22
+ export declare function matchesDataTemplate(template: BytesLike, data: BytesLike): boolean;