@d20dao/vrf-sdk 0.3.3 → 0.4.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 +31 -15
- package/API.md +2190 -0
- package/BUILD-MANIFEST.json +18 -16
- package/CHANGELOG.md +44 -0
- package/PROTOCOL-PROVENANCE.json +9 -8
- package/README.md +157 -97
- package/abi/EpochEntropy.json +372 -26
- package/dist/abi.d.ts +292 -24
- package/dist/abi.js +1 -1
- package/dist/epoch.d.ts +42 -78
- package/dist/epoch.js +121 -27
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/sources.js +2 -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/package.json +6 -2
- package/contracts/examples/MiningRandomnessConsumer.sol +0 -59
package/dist/epoch.js
CHANGED
|
@@ -1,13 +1,115 @@
|
|
|
1
|
-
import { AbiCoder, id, keccak256, getBytes, toUtf8Bytes,
|
|
1
|
+
import { AbiCoder, Contract, getAddress, hexlify, id, keccak256, getBytes, toUtf8Bytes, verifyMessage } from "ethers";
|
|
2
2
|
import { canonicalApiRequest, attestationDigest, hashAttestation, validateApiSignatureEncoding } from "./sources.js";
|
|
3
|
+
import { encodeDataTemplate, matchesDataTemplate, validateDataTemplate } from "./templates.js";
|
|
3
4
|
import { deriveRequestSeed, hashPublicKey, hashProof, verifyVRFProof } from "./verification.js";
|
|
4
5
|
import { hashMapping, mapRandomness } from "./mapping.js";
|
|
5
6
|
const abi = AbiCoder.defaultAbiCoder();
|
|
6
7
|
export const EPOCH_LENGTH = 200n;
|
|
7
8
|
export const MAX_ATTESTATION_AGE = 240n;
|
|
8
9
|
export const EPOCH_RECIPE_DOMAIN = id("D20_EPOCH_RECIPES");
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
/// A catalog lists 1 to MAX_EPOCH_SOURCES distinct recipes; attempt n of an epoch opens n × FALLBACK_DELAY_BLOCKS into it.
|
|
11
|
+
export const MAX_EPOCH_SOURCES = 10, FALLBACK_DELAY_BLOCKS = 20n;
|
|
12
|
+
function frozen(value) {
|
|
13
|
+
if (value !== null && typeof value === "object") {
|
|
14
|
+
for (const child of Object.values(value))
|
|
15
|
+
frozen(child);
|
|
16
|
+
Object.freeze(value);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11", GET_LAST_BLOCK_HASH = "0x27e86d6e";
|
|
21
|
+
/// Recipe ids are uint8; canonical requests and bodies are bounded so every EpochCommitted packet fits 2048 bytes.
|
|
22
|
+
export const MAX_EPOCH_RECIPES = 256, MAX_RECIPE_REQUEST_BYTES = 1024, MAX_RECIPE_BODY_BYTES = 2048;
|
|
23
|
+
function builtin(id, provider, description, request, template) {
|
|
24
|
+
return { id, provider, description, request, canonicalRequest: canonicalApiRequest(request), template: encodeDataTemplate(template), body: JSON.stringify(request) };
|
|
25
|
+
}
|
|
26
|
+
const blockHash = (network) => ({ operation: "jsonRpc", parameters: { network, method: "eth_call", params: [{ to: MULTICALL3, data: GET_LAST_BLOCK_HASH }, "latest"] } });
|
|
27
|
+
const lastTrade = (symbol) => ({ operation: "lastTrade", parameters: { assetClass: "crypto", symbol } });
|
|
28
|
+
const jsonNumber = { decimal: { fraction: true, exponent: true } };
|
|
29
|
+
const blockHashRecord = [{ literal: '{"id":null,"jsonrpc":"2.0","result":"0x' }, { hex: 64 }, { literal: '"}' }];
|
|
30
|
+
const tradeRecord = (symbol) => [{ literal: `{"symbol":"${symbol}","price":` }, jsonNumber, { literal: ',"size":' }, jsonNumber,
|
|
31
|
+
{ literal: ',"timestamp":' }, { integer: { minDigits: 1, maxDigits: 16 } }, { literal: "}" }];
|
|
32
|
+
/// The recipes EpochEntropy registers itself, at initialization or in its recipe-registry upgrade, with exactly these ids,
|
|
33
|
+
/// canonical requests, templates and bodies. Ids 0-3 form the initial catalog and keep their earlier canonical requests.
|
|
34
|
+
export const BUILTIN_EPOCH_RECIPES = frozen([
|
|
35
|
+
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: '"}' }]),
|
|
36
|
+
builtin(1, "drpc", "Ethereum mainnet block hash", blockHash("ethereum"), blockHashRecord),
|
|
37
|
+
builtin(2, "tickerlayer", "BTCUSD last trade", lastTrade("BTCUSD"), tradeRecord("BTCUSD")),
|
|
38
|
+
builtin(3, "tickerlayer", "ETHUSD last trade", lastTrade("ETHUSD"), tradeRecord("ETHUSD")),
|
|
39
|
+
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"}}' }]),
|
|
40
|
+
builtin(5, "drpc", "Base block hash", blockHash("base"), blockHashRecord),
|
|
41
|
+
]);
|
|
42
|
+
export const INITIAL_EPOCH_RECIPES = frozen([0, 1, 2, 3]);
|
|
43
|
+
const byteLength = (text) => toUtf8Bytes(text).length;
|
|
44
|
+
/// Throws unless a recipe satisfies the registry's registration rules: bounded sizes and a well-formed template.
|
|
45
|
+
export function validateEpochRecipe(recipe) {
|
|
46
|
+
if (typeof recipe?.canonicalRequest !== "string" || byteLength(recipe.canonicalRequest) === 0 || byteLength(recipe.canonicalRequest) > MAX_RECIPE_REQUEST_BYTES)
|
|
47
|
+
throw new Error(`A recipe canonical request must be 1 to ${MAX_RECIPE_REQUEST_BYTES} bytes`);
|
|
48
|
+
if (typeof recipe.body !== "string" || byteLength(recipe.body) === 0 || byteLength(recipe.body) > MAX_RECIPE_BODY_BYTES)
|
|
49
|
+
throw new Error(`A recipe body must be 1 to ${MAX_RECIPE_BODY_BYTES} bytes`);
|
|
50
|
+
validateDataTemplate(recipe.template);
|
|
51
|
+
}
|
|
52
|
+
/// The canonical request of a JSON gateway body: an object with a string operation, a parameters object and an
|
|
53
|
+
/// optional responseProjection object. Keepers refuse a recipe whose body does not canonicalize to its request.
|
|
54
|
+
export function canonicalRequestOfBody(body) {
|
|
55
|
+
const parsed = JSON.parse(body);
|
|
56
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
57
|
+
if (!isObject(parsed))
|
|
58
|
+
throw new Error("A recipe body must be a JSON object");
|
|
59
|
+
const request = parsed;
|
|
60
|
+
if (typeof request.operation !== "string" || !isObject(request.parameters) || (request.responseProjection !== undefined && !isObject(request.responseProjection)))
|
|
61
|
+
throw new Error("A recipe body needs a string operation, a parameters object and an optional responseProjection object");
|
|
62
|
+
return canonicalApiRequest(request);
|
|
63
|
+
}
|
|
64
|
+
const RECIPE_VIEW = ["function getRecipe(uint8 recipe) view returns (bytes32 queryHash,string canonicalRequest,bytes template,string body)"];
|
|
65
|
+
/// Read registered recipes from a registry. Each definition is checked against its query hash and the registration rules.
|
|
66
|
+
export async function readEpochRecipes(runner, registry, ids) {
|
|
67
|
+
const view = new Contract(registry, RECIPE_VIEW, runner), book = {};
|
|
68
|
+
for (const recipe of new Set(ids)) {
|
|
69
|
+
const [queryHash, canonicalRequest, template, body] = await view.getRecipe(recipe);
|
|
70
|
+
const entry = { canonicalRequest, template: hexlify(template), body };
|
|
71
|
+
if (keccak256(toUtf8Bytes(canonicalRequest)) !== queryHash)
|
|
72
|
+
throw new Error(`Recipe ${recipe} query hash mismatch`);
|
|
73
|
+
validateEpochRecipe(entry);
|
|
74
|
+
book[recipe] = entry;
|
|
75
|
+
}
|
|
76
|
+
return book;
|
|
77
|
+
}
|
|
78
|
+
/// The exact signed bytes EpochEntropy accepts for a recipe template: at most 128 bytes, never cropped or reformatted.
|
|
79
|
+
export function validateEpochData(template, data) {
|
|
80
|
+
if (!matchesDataTemplate(template, data))
|
|
81
|
+
throw new Error("Invalid exact epoch data");
|
|
82
|
+
}
|
|
83
|
+
/// The recipe ids of a catalog in slot order; throws unless it follows the registry's catalog rules.
|
|
84
|
+
export function epochCatalogRecipes(catalog) {
|
|
85
|
+
const recipes = catalog.recipes ?? INITIAL_EPOCH_RECIPES;
|
|
86
|
+
if (recipes.length < 1 || recipes.length > MAX_EPOCH_SOURCES || catalog.signers.length !== recipes.length || new Set(recipes).size !== recipes.length ||
|
|
87
|
+
recipes.some(recipe => !Number.isInteger(recipe) || recipe < 0 || recipe >= MAX_EPOCH_RECIPES) || catalog.signers.some(signer => BigInt(signer) === 0n))
|
|
88
|
+
throw new Error("Invalid epoch catalog");
|
|
89
|
+
return recipes;
|
|
90
|
+
}
|
|
91
|
+
/// A recipe definition from the catalog's recipe book, or the built-in recipe with that id, which every registry registers identically.
|
|
92
|
+
export function epochRecipe(catalog, recipe) {
|
|
93
|
+
const entry = catalog.recipeBook?.[recipe] ?? BUILTIN_EPOCH_RECIPES[recipe];
|
|
94
|
+
if (entry === undefined)
|
|
95
|
+
throw new Error(`Unknown epoch recipe ${recipe}: supply its registered definition, for example from readEpochRecipes`);
|
|
96
|
+
validateEpochRecipe(entry);
|
|
97
|
+
return entry;
|
|
98
|
+
}
|
|
99
|
+
export function epochCatalogHash(signers, recipes) {
|
|
100
|
+
epochCatalogRecipes({ signers, recipes });
|
|
101
|
+
return recipes === undefined ? keccak256(abi.encode(["bytes32", "address[4]"], [EPOCH_RECIPE_DOMAIN, signers]))
|
|
102
|
+
: keccak256(abi.encode(["bytes32", "uint8[]", "address[]"], [EPOCH_RECIPE_DOMAIN, recipes, signers]));
|
|
103
|
+
}
|
|
104
|
+
/// Build an epoch's catalog from the registry's catalogAt(epoch) view, identifying the initial catalog by its hash.
|
|
105
|
+
/// A recipeBook in base, for example from readEpochRecipes, is kept; without one the built-in recipes are used.
|
|
106
|
+
export function resolveEpochCatalog(base, view) {
|
|
107
|
+
const signers = view.signers.map(signer => getAddress(signer)), recipes = view.recipes.map(Number);
|
|
108
|
+
if (recipes.join() === INITIAL_EPOCH_RECIPES.join() && epochCatalogHash(signers) === view.hash)
|
|
109
|
+
return { ...base, signers };
|
|
110
|
+
if (epochCatalogHash(signers, recipes) !== view.hash)
|
|
111
|
+
throw new Error("Catalog hash mismatch");
|
|
112
|
+
return { ...base, signers, recipes };
|
|
11
113
|
}
|
|
12
114
|
export function epochStart(firstEpochStart, epochId) {
|
|
13
115
|
if (epochId < 1n)
|
|
@@ -16,44 +118,34 @@ export function epochStart(firstEpochStart, epochId) {
|
|
|
16
118
|
}
|
|
17
119
|
export function epochForBlock(firstEpochStart, block) { return block < firstEpochStart ? 0n : 1n + (block - firstEpochStart) / EPOCH_LENGTH; }
|
|
18
120
|
/// 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
121
|
export function fallbackOpensAt(firstEpochStart, epochId, attempt) {
|
|
21
|
-
if (!Number.isInteger(attempt) || attempt < 0 || attempt
|
|
122
|
+
if (!Number.isInteger(attempt) || attempt < 0 || attempt >= MAX_EPOCH_SOURCES)
|
|
22
123
|
throw new Error("Invalid fallback attempt");
|
|
23
124
|
return epochStart(firstEpochStart, epochId) + BigInt(attempt) * FALLBACK_DELAY_BLOCKS;
|
|
24
125
|
}
|
|
25
126
|
export function selectEpoch(catalog, epochId, anchorHash, attempt = 0) {
|
|
26
127
|
if (epochId < 1n || BigInt(anchorHash) === 0n)
|
|
27
128
|
throw new Error("Invalid epoch anchor");
|
|
28
|
-
|
|
129
|
+
const recipes = epochCatalogRecipes(catalog), count = recipes.length;
|
|
130
|
+
if (!Number.isInteger(attempt) || attempt < 0 || attempt >= count)
|
|
29
131
|
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 };
|
|
132
|
+
const selector = keccak256(abi.encode(["bytes32", "bytes32", "uint64", "bytes32"], [id("D20_EPOCH_SELECT"), epochCatalogHash(catalog.signers, catalog.recipes), epochId, anchorHash]));
|
|
133
|
+
const source = (Number(BigInt(selector) % BigInt(count)) + attempt) % count, recipe = recipes[source];
|
|
134
|
+
const { canonicalRequest, template, body } = epochRecipe(catalog, recipe);
|
|
135
|
+
return { source, recipe, attempt, airnode: catalog.signers[source], selector, canonicalRequest, queryHash: keccak256(toUtf8Bytes(canonicalRequest)), template, body };
|
|
38
136
|
}
|
|
39
137
|
export function verifyEpochAttestation(selected, a, commitTimestamp) {
|
|
40
138
|
validateApiSignatureEncoding(a.signature);
|
|
41
139
|
if (a.timestamp > commitTimestamp || commitTimestamp - a.timestamp > MAX_ATTESTATION_AGE)
|
|
42
140
|
throw new Error("Invalid epoch attestation time");
|
|
43
|
-
|
|
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");
|
|
141
|
+
validateEpochData(selected.template, a.data);
|
|
50
142
|
const signer = verifyMessage(getBytes(attestationDigest(selected.queryHash, a)), a.signature);
|
|
51
143
|
if (signer.toLowerCase() !== selected.airnode.toLowerCase())
|
|
52
144
|
throw new Error("Wrong epoch signer/query");
|
|
53
145
|
return { dataHash: keccak256(a.data), attestationHash: hashAttestation(selected.queryHash, a), signer };
|
|
54
146
|
}
|
|
55
147
|
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]));
|
|
148
|
+
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
149
|
}
|
|
58
150
|
const packetTypes = ["string", "tuple(uint256 timestamp,bytes data,bytes signature)"];
|
|
59
151
|
export function encodeEpochEvidencePacket(canonicalRequest, attestation) {
|
|
@@ -72,9 +164,11 @@ export function decodeEpochEvidencePacket(packet) {
|
|
|
72
164
|
return { canonicalRequest: canonicalRequest, attestation };
|
|
73
165
|
}
|
|
74
166
|
export function replayEpochCommitment(input) {
|
|
75
|
-
const { catalog, epochId, record } = input;
|
|
167
|
+
const { catalog, epochId, record } = input, count = epochCatalogRecipes(catalog).length;
|
|
168
|
+
if (!Number.isInteger(Number(record.source)) || Number(record.source) < 0 || Number(record.source) >= count)
|
|
169
|
+
throw new Error("Epoch record mismatch");
|
|
76
170
|
// 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 +
|
|
171
|
+
const attempt = (Number(record.source) - selectEpoch(catalog, epochId, record.anchorHash).source + count) % count;
|
|
78
172
|
if (record.committedBlock < fallbackOpensAt(catalog.firstEpochStart, epochId, attempt))
|
|
79
173
|
throw new Error("Invalid epoch commit block");
|
|
80
174
|
const selected = selectEpoch(catalog, epochId, record.anchorHash, attempt), evidence = decodeEpochEvidencePacket(input.packet);
|
|
@@ -82,7 +176,7 @@ export function replayEpochCommitment(input) {
|
|
|
82
176
|
throw new Error("Epoch recipe mismatch");
|
|
83
177
|
const verified = verifyEpochAttestation(selected, evidence.attestation, input.commitTimestamp);
|
|
84
178
|
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 ||
|
|
179
|
+
if (record.epochHash !== epochHash || record.catalogHash !== epochCatalogHash(catalog.signers, catalog.recipes) || Number(record.source) !== selected.source || record.queryHash !== selected.queryHash ||
|
|
86
180
|
record.dataHash !== verified.dataHash || record.attestationHash !== verified.attestationHash || record.signedAt !== evidence.attestation.timestamp)
|
|
87
181
|
throw new Error("Epoch record mismatch");
|
|
88
182
|
return { epochHash, selected, ...verified };
|
|
@@ -95,8 +189,8 @@ export function epochTranscriptHash(c, configurationHash, proofHash, randomness)
|
|
|
95
189
|
}
|
|
96
190
|
/// Canonical blocks, transaction inclusion/timestamps and the proxy implementation code active
|
|
97
191
|
/// at each receipt must be independently trusted chain context. A proxy code hash alone is insufficient.
|
|
98
|
-
/// The
|
|
99
|
-
///
|
|
192
|
+
/// The catalog is per epoch: epoch.catalog must be the catalog in force for context.epochId (catalogAt on the
|
|
193
|
+
/// registry, or CatalogScheduled events and the initial catalog); replayEpochCommitment binds it to record.catalogHash,
|
|
100
194
|
/// while configuration.catalogHash remains the initial catalog bound into protocolConfigurationHash.
|
|
101
195
|
export function replayEpochCoordinator(input) {
|
|
102
196
|
const c = input.context, cfg = input.configuration, e = input.epoch;
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,9 @@ export { canonicalApiRequest, attestationDigest, hashAttestation, validateApiSig
|
|
|
6
6
|
export type { ApiRequest, ApiAttestation } 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 { EPOCH_LENGTH, EPOCH_RECIPE_DOMAIN, BUILTIN_EPOCH_RECIPES, INITIAL_EPOCH_RECIPES, 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";
|
|
12
|
+
export type { EpochCatalog, EpochSigners, EpochRecord, EpochRecipe, EpochRecipeBook, BuiltinEpochRecipe, EpochProvider, EpochProtocolConfiguration } from "./epoch.js";
|
|
11
13
|
export { quoteRequestFee, DEFAULT_FEE_BUFFER_BPS } from "./fees.js";
|
|
12
14
|
export type { FeeQuote, FeeQuoteOptions, FeeQuoteProvider } from "./fees.js";
|
package/dist/index.js
CHANGED
|
@@ -3,5 +3,6 @@ export { deriveRequestSeed, hashPublicKey, hashProof, verifyVRFProof } from "./v
|
|
|
3
3
|
export { canonicalApiRequest, 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 { EPOCH_LENGTH, EPOCH_RECIPE_DOMAIN, BUILTIN_EPOCH_RECIPES, INITIAL_EPOCH_RECIPES, 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
8
|
export { quoteRequestFee, DEFAULT_FEE_BUFFER_BPS } from "./fees.js";
|
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);
|
|
@@ -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;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { getBytes, hexlify, toUtf8Bytes, toUtf8String } from "ethers";
|
|
2
|
+
/// Data templates: the exact signed-data grammar of an epoch recipe, with the verdicts of EpochEntropy's DataTemplate.
|
|
3
|
+
/// A template is a byte sequence of segments, each an opcode and its operands:
|
|
4
|
+
/// 0x01 LITERAL len bytes[len] exactly these bytes (1 <= len <= 128)
|
|
5
|
+
/// 0x02 HEX n exactly n characters 0-9 or a-f (1 <= n <= 128)
|
|
6
|
+
/// 0x03 DECIMAL flags unsigned JSON number: 0 or a nonzero digit then digits, then an optional fraction
|
|
7
|
+
/// when flags & 1 and an optional exponent when flags & 2 (flags <= 3)
|
|
8
|
+
/// 0x04 INTEGER min max a nonzero digit then digits, min <= digit count <= max (1 <= min <= max <= 128)
|
|
9
|
+
/// Variable segments are greedy and never backtrack; data matches when the segments consume it exactly. A well-formed
|
|
10
|
+
/// template has at most 256 bytes, at least one variable segment, and a shortest match of at most 128 bytes.
|
|
11
|
+
export const MAX_EPOCH_DATA_BYTES = 128, MAX_DATA_TEMPLATE_BYTES = 256;
|
|
12
|
+
const LITERAL = 0x01, HEX = 0x02, DECIMAL = 0x03, INTEGER = 0x04, FRACTION = 0x01, EXPONENT = 0x02;
|
|
13
|
+
const byteOperand = (value, name) => {
|
|
14
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_EPOCH_DATA_BYTES)
|
|
15
|
+
throw new Error(`Data template ${name} must be an integer from 1 to ${MAX_EPOCH_DATA_BYTES}`);
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
/// Encode readable segments as template bytes; throws with the rule a segment breaks.
|
|
19
|
+
export function encodeDataTemplate(segments) {
|
|
20
|
+
if (!Array.isArray(segments))
|
|
21
|
+
throw new Error("A data template is a list of segments");
|
|
22
|
+
const bytes = [];
|
|
23
|
+
segments.forEach((segment, index) => {
|
|
24
|
+
const keys = segment !== null && typeof segment === "object" ? Object.keys(segment) : [];
|
|
25
|
+
if (keys.length !== 1)
|
|
26
|
+
throw new Error(`Data template segment ${index} must have exactly one of literal, hex, decimal or integer`);
|
|
27
|
+
if ("literal" in segment) {
|
|
28
|
+
if (typeof segment.literal !== "string")
|
|
29
|
+
throw new Error(`Data template segment ${index}: literal must be a string`);
|
|
30
|
+
const text = toUtf8Bytes(segment.literal);
|
|
31
|
+
byteOperand(text.length, `segment ${index} literal length in bytes`);
|
|
32
|
+
bytes.push(LITERAL, text.length, ...text);
|
|
33
|
+
}
|
|
34
|
+
else if ("hex" in segment) {
|
|
35
|
+
bytes.push(HEX, byteOperand(segment.hex, `segment ${index} hex length`));
|
|
36
|
+
}
|
|
37
|
+
else if ("decimal" in segment) {
|
|
38
|
+
const { fraction, exponent } = segment.decimal ?? {};
|
|
39
|
+
if (typeof fraction !== "boolean" || typeof exponent !== "boolean" || Object.keys(segment.decimal).length !== 2)
|
|
40
|
+
throw new Error(`Data template segment ${index}: decimal needs boolean fraction and exponent`);
|
|
41
|
+
bytes.push(DECIMAL, (fraction ? FRACTION : 0) | (exponent ? EXPONENT : 0));
|
|
42
|
+
}
|
|
43
|
+
else if ("integer" in segment) {
|
|
44
|
+
const { minDigits, maxDigits } = segment.integer ?? {};
|
|
45
|
+
byteOperand(minDigits, `segment ${index} integer minDigits`);
|
|
46
|
+
byteOperand(maxDigits, `segment ${index} integer maxDigits`);
|
|
47
|
+
if (minDigits > maxDigits || Object.keys(segment.integer).length !== 2)
|
|
48
|
+
throw new Error(`Data template segment ${index}: integer needs minDigits <= maxDigits`);
|
|
49
|
+
bytes.push(INTEGER, minDigits, maxDigits);
|
|
50
|
+
}
|
|
51
|
+
else
|
|
52
|
+
throw new Error(`Data template segment ${index} must have exactly one of literal, hex, decimal or integer`);
|
|
53
|
+
});
|
|
54
|
+
const template = hexlify(Uint8Array.from(bytes));
|
|
55
|
+
const problem = templateProblem(Uint8Array.from(bytes));
|
|
56
|
+
if (problem)
|
|
57
|
+
throw new Error(`Invalid data template: ${problem}`);
|
|
58
|
+
return template;
|
|
59
|
+
}
|
|
60
|
+
/// Why a template is not well-formed, or undefined when it is. Same rules as DataTemplate.isValid.
|
|
61
|
+
function templateProblem(t) {
|
|
62
|
+
if (t.length === 0 || t.length > MAX_DATA_TEMPLATE_BYTES)
|
|
63
|
+
return `it must be 1 to ${MAX_DATA_TEMPLATE_BYTES} bytes`;
|
|
64
|
+
let at = 0, shortest = 0, variable = false;
|
|
65
|
+
while (at < t.length) {
|
|
66
|
+
const op = t[at];
|
|
67
|
+
if (op === LITERAL) {
|
|
68
|
+
if (at + 1 >= t.length)
|
|
69
|
+
return `truncated literal at byte ${at}`;
|
|
70
|
+
const n = t[at + 1];
|
|
71
|
+
if (n === 0 || n > MAX_EPOCH_DATA_BYTES || at + 2 + n > t.length)
|
|
72
|
+
return `invalid literal length at byte ${at}`;
|
|
73
|
+
shortest += n;
|
|
74
|
+
at += 2 + n;
|
|
75
|
+
}
|
|
76
|
+
else if (op === HEX) {
|
|
77
|
+
if (at + 1 >= t.length)
|
|
78
|
+
return `truncated hex segment at byte ${at}`;
|
|
79
|
+
const n = t[at + 1];
|
|
80
|
+
if (n === 0 || n > MAX_EPOCH_DATA_BYTES)
|
|
81
|
+
return `invalid hex length at byte ${at}`;
|
|
82
|
+
shortest += n;
|
|
83
|
+
at += 2;
|
|
84
|
+
variable = true;
|
|
85
|
+
}
|
|
86
|
+
else if (op === DECIMAL) {
|
|
87
|
+
if (at + 1 >= t.length || t[at + 1] > (FRACTION | EXPONENT))
|
|
88
|
+
return `invalid decimal flags at byte ${at}`;
|
|
89
|
+
shortest += 1;
|
|
90
|
+
at += 2;
|
|
91
|
+
variable = true;
|
|
92
|
+
}
|
|
93
|
+
else if (op === INTEGER) {
|
|
94
|
+
if (at + 2 >= t.length)
|
|
95
|
+
return `truncated integer segment at byte ${at}`;
|
|
96
|
+
const min = t[at + 1], max = t[at + 2];
|
|
97
|
+
if (min === 0 || min > max || max > MAX_EPOCH_DATA_BYTES)
|
|
98
|
+
return `invalid integer digit bounds at byte ${at}`;
|
|
99
|
+
shortest += min;
|
|
100
|
+
at += 3;
|
|
101
|
+
variable = true;
|
|
102
|
+
}
|
|
103
|
+
else
|
|
104
|
+
return `unknown segment opcode ${op} at byte ${at}`;
|
|
105
|
+
}
|
|
106
|
+
if (!variable)
|
|
107
|
+
return "it needs at least one hex, decimal or integer segment";
|
|
108
|
+
if (shortest > MAX_EPOCH_DATA_BYTES)
|
|
109
|
+
return `its shortest match exceeds ${MAX_EPOCH_DATA_BYTES} bytes`;
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
export function isValidDataTemplate(template) { return templateProblem(getBytes(template)) === undefined; }
|
|
113
|
+
/// Throws unless the template is well-formed.
|
|
114
|
+
export function validateDataTemplate(template) {
|
|
115
|
+
const problem = templateProblem(getBytes(template));
|
|
116
|
+
if (problem)
|
|
117
|
+
throw new Error(`Invalid data template: ${problem}`);
|
|
118
|
+
}
|
|
119
|
+
/// The readable segments of a well-formed template whose literals are UTF-8 text.
|
|
120
|
+
export function decodeDataTemplate(template) {
|
|
121
|
+
const t = getBytes(template);
|
|
122
|
+
validateDataTemplate(t);
|
|
123
|
+
const segments = [];
|
|
124
|
+
for (let at = 0; at < t.length;) {
|
|
125
|
+
const op = t[at];
|
|
126
|
+
if (op === LITERAL) {
|
|
127
|
+
segments.push({ literal: toUtf8String(t.slice(at + 2, at + 2 + t[at + 1])) });
|
|
128
|
+
at += 2 + t[at + 1];
|
|
129
|
+
}
|
|
130
|
+
else if (op === HEX) {
|
|
131
|
+
segments.push({ hex: t[at + 1] });
|
|
132
|
+
at += 2;
|
|
133
|
+
}
|
|
134
|
+
else if (op === DECIMAL) {
|
|
135
|
+
segments.push({ decimal: { fraction: (t[at + 1] & FRACTION) !== 0, exponent: (t[at + 1] & EXPONENT) !== 0 } });
|
|
136
|
+
at += 2;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
segments.push({ integer: { minDigits: t[at + 1], maxDigits: t[at + 2] } });
|
|
140
|
+
at += 3;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return segments;
|
|
144
|
+
}
|
|
145
|
+
const isDigit = (c) => c !== undefined && c >= 0x30 && c <= 0x39;
|
|
146
|
+
function digits(d, p) { while (isDigit(d[p]))
|
|
147
|
+
p++; return p; }
|
|
148
|
+
/// End of an unsigned JSON number starting at p, or -1.
|
|
149
|
+
function decimalEnd(d, p, fraction, exponent) {
|
|
150
|
+
if (!isDigit(d[p]))
|
|
151
|
+
return -1;
|
|
152
|
+
if (d[p] === 0x30) {
|
|
153
|
+
p++;
|
|
154
|
+
if (isDigit(d[p]))
|
|
155
|
+
return -1;
|
|
156
|
+
}
|
|
157
|
+
else
|
|
158
|
+
p = digits(d, p);
|
|
159
|
+
if (fraction && d[p] === 0x2e) {
|
|
160
|
+
const first = p + 1;
|
|
161
|
+
p = digits(d, first);
|
|
162
|
+
if (p === first)
|
|
163
|
+
return -1;
|
|
164
|
+
}
|
|
165
|
+
if (exponent && (d[p] === 0x65 || d[p] === 0x45)) {
|
|
166
|
+
p++;
|
|
167
|
+
if (d[p] === 0x2b || d[p] === 0x2d)
|
|
168
|
+
p++;
|
|
169
|
+
const first = p;
|
|
170
|
+
p = digits(d, first);
|
|
171
|
+
if (p === first)
|
|
172
|
+
return -1;
|
|
173
|
+
}
|
|
174
|
+
return p;
|
|
175
|
+
}
|
|
176
|
+
/// Whether data is exactly a record the template describes; false for a malformed template.
|
|
177
|
+
export function matchesDataTemplate(template, data) {
|
|
178
|
+
const t = getBytes(template), d = getBytes(data);
|
|
179
|
+
if (templateProblem(t) !== undefined || d.length === 0 || d.length > MAX_EPOCH_DATA_BYTES)
|
|
180
|
+
return false;
|
|
181
|
+
let p = 0;
|
|
182
|
+
for (let at = 0; at < t.length;) {
|
|
183
|
+
const op = t[at];
|
|
184
|
+
if (op === LITERAL) {
|
|
185
|
+
const n = t[at + 1];
|
|
186
|
+
if (p + n > d.length)
|
|
187
|
+
return false;
|
|
188
|
+
for (let i = 0; i < n; i++)
|
|
189
|
+
if (d[p + i] !== t[at + 2 + i])
|
|
190
|
+
return false;
|
|
191
|
+
p += n;
|
|
192
|
+
at += 2 + n;
|
|
193
|
+
}
|
|
194
|
+
else if (op === HEX) {
|
|
195
|
+
const end = p + t[at + 1];
|
|
196
|
+
if (end > d.length)
|
|
197
|
+
return false;
|
|
198
|
+
for (; p < end; p++)
|
|
199
|
+
if (!isDigit(d[p]) && (d[p] < 0x61 || d[p] > 0x66))
|
|
200
|
+
return false;
|
|
201
|
+
at += 2;
|
|
202
|
+
}
|
|
203
|
+
else if (op === DECIMAL) {
|
|
204
|
+
p = decimalEnd(d, p, (t[at + 1] & FRACTION) !== 0, (t[at + 1] & EXPONENT) !== 0);
|
|
205
|
+
if (p < 0)
|
|
206
|
+
return false;
|
|
207
|
+
at += 2;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
if (!isDigit(d[p]) || d[p] === 0x30)
|
|
211
|
+
return false;
|
|
212
|
+
const end = digits(d, p), count = end - p;
|
|
213
|
+
if (count < t[at + 1] || count > t[at + 2])
|
|
214
|
+
return false;
|
|
215
|
+
p = end;
|
|
216
|
+
at += 3;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return p === d.length;
|
|
220
|
+
}
|
|
@@ -3,48 +3,56 @@ pragma solidity 0.8.28;
|
|
|
3
3
|
|
|
4
4
|
import {D20VRFConsumer} from "@d20dao/vrf-sdk/contracts/D20VRFConsumer.sol";
|
|
5
5
|
import {ID20VRF} from "@d20dao/vrf-sdk/contracts/interfaces/ID20VRF.sol";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
/// @notice
|
|
9
|
-
/// @dev The player pays the request. A wallet cannot know the exact same-transaction quote in advance, so it quotes
|
|
10
|
-
/// off-chain with quoteFeeAt(callbackGasLimit, latestBlock.baseFeePerGas) plus a buffer (the SDK's quoteRequestFee
|
|
11
|
-
/// helper does this) and sends that value. The whole payment is forwarded: the coordinator escrows exactly
|
|
12
|
-
/// quoteFee(callbackGasLimit) and credits any excess to the player (the refund address) as refund credit that only
|
|
13
|
-
/// the player can pull with withdrawRefundCredit. Underpayment reverts inside the coordinator with
|
|
14
|
-
/// IncorrectFee(expected, actual). A contract that funds requests from its own balance would instead pay
|
|
15
|
-
/// rng.quoteFee(callbackGasLimit) in the same transaction, as the D20VRFRequests helpers do.
|
|
6
|
+
import {D20VRFRequests} from "@d20dao/vrf-sdk/contracts/libraries/D20VRFRequests.sol";
|
|
7
|
+
|
|
8
|
+
/// @notice One d20 per player, paid by the player. Swap `d20` for `d6` or `coinFlip` and nothing else moves.
|
|
16
9
|
contract DiceConsumer is D20VRFConsumer {
|
|
17
|
-
|
|
10
|
+
using D20VRFRequests for ID20VRF;
|
|
11
|
+
|
|
12
|
+
/// The callback does two storage writes and nothing else, and the fee grows with this number.
|
|
13
|
+
uint32 private constant CALLBACK_GAS = 100_000;
|
|
14
|
+
|
|
15
|
+
struct Roll { address player; bytes32 word; bool ready; }
|
|
18
16
|
mapping(uint256 => Roll) public rolls;
|
|
19
|
-
|
|
17
|
+
|
|
18
|
+
error Underpaid(uint256 quoted, uint256 sent);
|
|
19
|
+
error ChangeRefused();
|
|
20
20
|
error UnexpectedCallback();
|
|
21
21
|
error NotReady();
|
|
22
22
|
|
|
23
23
|
constructor(address coordinator) D20VRFConsumer(coordinator) {}
|
|
24
24
|
|
|
25
|
-
function roll(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
);
|
|
25
|
+
function roll() external payable returns (uint256 requestId) {
|
|
26
|
+
ID20VRF rng = ID20VRF(vrfCoordinator);
|
|
27
|
+
// quoteFee prices from block.basefee, so it is exact here and only here. A wallet cannot read it in
|
|
28
|
+
// advance: off-chain it quotes quoteFeeAt(gas, the latest header's baseFeePerGas) plus a buffer and
|
|
29
|
+
// sends that, never through eth_call, where the base fee is reported as 0.
|
|
30
|
+
uint256 fee = rng.quoteFee(CALLBACK_GAS);
|
|
31
|
+
if (msg.value < fee) revert Underpaid(fee, msg.value);
|
|
32
|
+
// The helper pays exactly `fee` out of this contract's balance, which msg.value just funded.
|
|
33
|
+
// The player is the refund address, so if the request expires, refundRequest returns the fee straight to them.
|
|
34
|
+
requestId = rng.d20(D20VRFRequests.Options(keccak256(abi.encode(msg.sender)), CALLBACK_GAS, msg.sender));
|
|
31
35
|
rolls[requestId] = Roll(msg.sender, bytes32(0), false);
|
|
36
|
+
// Hand the player's buffer back now. Left with the coordinator it becomes refund credit they would
|
|
37
|
+
// have to claim in a separate withdrawRefundCredit transaction.
|
|
38
|
+
if (msg.value > fee) {
|
|
39
|
+
(bool sent,) = payable(msg.sender).call{value: msg.value - fee}("");
|
|
40
|
+
if (!sent) revert ChangeRefused();
|
|
41
|
+
}
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
function _fulfillRandomness(uint256 requestId, bytes32 randomness) internal override {
|
|
35
|
-
Roll storage
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
Roll storage result = rolls[requestId];
|
|
44
|
-
if (result.player == address(0) || result.ready) revert UnexpectedCallback();
|
|
45
|
-
refunded[requestId] = true;
|
|
45
|
+
Roll storage entry = rolls[requestId];
|
|
46
|
+
// Refuse a request this contract never made, and a repeat of one it already holds. A failed delivery is
|
|
47
|
+
// retried by anyone with retryCallback and a successful one is never repeated; the check costs one read.
|
|
48
|
+
if (entry.player == address(0) || entry.ready) revert UnexpectedCallback();
|
|
49
|
+
// Store, do not compute. The callback runs inside CALLBACK_GAS; work that overruns it fails the
|
|
50
|
+
// delivery, and although the request stays served and paid, someone must retryCallback it.
|
|
51
|
+
entry.word = randomness;
|
|
52
|
+
entry.ready = true;
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
/// @return 1 to 20. The coordinator maps the stored word; a losing roll is never re-rolled.
|
|
48
56
|
function result(uint256 requestId) external view returns (uint256) {
|
|
49
57
|
if (!rolls[requestId].ready) revert NotReady();
|
|
50
58
|
return ID20VRF(vrfCoordinator).getMappedResult(requestId)[0];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {D20VRFConsumer} from "@d20dao/vrf-sdk/contracts/D20VRFConsumer.sol";
|
|
5
|
+
import {ID20VRF} from "@d20dao/vrf-sdk/contracts/interfaces/ID20VRF.sol";
|
|
6
|
+
import {RandomnessMapping} from "@d20dao/vrf-sdk/contracts/libraries/RandomnessMapping.sol";
|
|
7
|
+
|
|
8
|
+
/// @notice A weighted drop: common 60%, uncommon 25%, rare 13%, legendary 2%. Edit weights and TOTAL_WEIGHT
|
|
9
|
+
/// together to move the odds; nothing else changes.
|
|
10
|
+
contract LootDropConsumer is D20VRFConsumer {
|
|
11
|
+
uint32 private constant CALLBACK_GAS = 100_000;
|
|
12
|
+
uint256 private constant TOTAL_WEIGHT = 1000;
|
|
13
|
+
/// Tier 0 to 3, rarest last. Must sum to TOTAL_WEIGHT.
|
|
14
|
+
uint256[4] private weights = [uint256(600), 250, 130, 20];
|
|
15
|
+
|
|
16
|
+
struct Drop { address player; bytes32 word; bool ready; }
|
|
17
|
+
mapping(uint256 => Drop) public drops;
|
|
18
|
+
|
|
19
|
+
error UnexpectedCallback();
|
|
20
|
+
error NotReady();
|
|
21
|
+
|
|
22
|
+
constructor(address coordinator) D20VRFConsumer(coordinator) {}
|
|
23
|
+
|
|
24
|
+
function open() external payable returns (uint256 requestId) {
|
|
25
|
+
// One draw in [1, TOTAL_WEIGHT]. The coordinator samples the range without modulo bias and stores the
|
|
26
|
+
// mapping with the request, so the draw is read back from getMappedResult and anyone can replay it.
|
|
27
|
+
// Asking for the range you actually want is what makes the weights below mean what they say.
|
|
28
|
+
RandomnessMapping.Spec memory spec =
|
|
29
|
+
RandomnessMapping.Spec(RandomnessMapping.Operation.NumberRange, 1, TOTAL_WEIGHT, 1, 0);
|
|
30
|
+
// Forwards everything sent: the coordinator keeps exactly its quote for this transaction, reverts
|
|
31
|
+
// IncorrectFee if that is more than arrived, and credits the surplus to msg.sender as refund credit.
|
|
32
|
+
requestId = ID20VRF(vrfCoordinator).requestMappedRandomness{value: msg.value}(
|
|
33
|
+
keccak256(abi.encode(msg.sender, block.number)), CALLBACK_GAS, msg.sender, spec
|
|
34
|
+
);
|
|
35
|
+
drops[requestId] = Drop(msg.sender, bytes32(0), false);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _fulfillRandomness(uint256 requestId, bytes32 randomness) internal override {
|
|
39
|
+
Drop storage drop = drops[requestId];
|
|
40
|
+
// Unknown request, or a repeat of one already delivered: neither may touch a finished drop.
|
|
41
|
+
if (drop.player == address(0) || drop.ready) revert UnexpectedCallback();
|
|
42
|
+
// Store the word and stop. The tier walk below is cheap, but it belongs on the reading side: a
|
|
43
|
+
// callback that runs out of CALLBACK_GAS fails delivery, while a stored word can be read forever.
|
|
44
|
+
drop.word = randomness;
|
|
45
|
+
drop.ready = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// @return tier 0 common, 1 uncommon, 2 rare, 3 legendary. Read-only: one request, one answer, forever.
|
|
49
|
+
function tierOf(uint256 requestId) external view returns (uint256 tier) {
|
|
50
|
+
if (!drops[requestId].ready) revert NotReady();
|
|
51
|
+
uint256 draw = ID20VRF(vrfCoordinator).getMappedResult(requestId)[0];
|
|
52
|
+
uint256 cursor;
|
|
53
|
+
// Weights become adjacent ranges: 1-600 common, 601-850 uncommon, 851-980 rare, the rest legendary.
|
|
54
|
+
for (uint256 i; i + 1 < weights.length; ++i) {
|
|
55
|
+
cursor += weights[i];
|
|
56
|
+
if (draw <= cursor) return i;
|
|
57
|
+
}
|
|
58
|
+
return weights.length - 1;
|
|
59
|
+
}
|
|
60
|
+
}
|