@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/AGENTS.md +29 -19
- package/API.md +574 -223
- package/BUILD-MANIFEST.json +40 -26
- package/CHANGELOG.md +97 -0
- package/PROTOCOL-PROVENANCE.json +17 -10
- package/README.md +174 -113
- package/THIRD_PARTY_NOTICES.md +3 -1
- package/abi/D20BeaconVerifier.json +165 -0
- package/abi/EpochEntropy.json +605 -26
- package/dist/abi.d.ts +593 -22
- package/dist/abi.js +2 -1
- package/dist/beacon.d.ts +28 -0
- package/dist/beacon.js +133 -0
- package/dist/epoch.d.ts +50 -78
- package/dist/epoch.js +184 -29
- package/dist/index.d.ts +8 -4
- package/dist/index.js +4 -2
- package/dist/sources.d.ts +11 -0
- package/dist/sources.js +74 -0
- package/dist/templates.d.ts +22 -0
- package/dist/templates.js +220 -0
- package/examples/DiceConsumer.sol +37 -29
- package/examples/LootDropConsumer.sol +60 -0
- package/examples/RaffleConsumer.sol +71 -0
- package/notices/BLS-BN254-LICENSE +21 -0
- package/notices/PROVENANCE.md +12 -0
- package/package.json +31 -4
- package/contracts/examples/MiningRandomnessConsumer.sol +0 -59
package/dist/epoch.js
CHANGED
|
@@ -1,13 +1,162 @@
|
|
|
1
|
-
import { AbiCoder, id, keccak256, getBytes, toUtf8Bytes,
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 +
|
|
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
|
|
99
|
-
///
|
|
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 {
|
|
10
|
-
export type {
|
|
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 {
|
|
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;
|