@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/README.md
CHANGED
|
@@ -1,32 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# D20DAO VRF SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[Verifiable randomness for onchain apps](https://d20dao.org), currently deployed on Arc Mainnet and Arc Testnet. [Get started](https://d20dao.org/docs/getting-started) · [Integration guide](https://d20dao.org/docs/integration) · [Public proof replay](https://d20dao.org/docs/verification).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Randomness your users can check.** Your contract asks the coordinator for a random result and pays a fee. A few seconds later the coordinator calls your contract back with a word taken from a VRF proof it verified on chain. Nobody picks the answer, nobody gets a second attempt, and anyone can replay the proof afterwards with this package.
|
|
6
|
+
|
|
7
|
+
`@d20dao/vrf-sdk` is everything you need on the application side: the Solidity base contract your consumer inherits, the coordinator ABI, an off-chain fee quote for your front end, and the replay code that re-derives a published result from public evidence. It holds no keys and runs no service.
|
|
8
|
+
|
|
9
|
+
The service is live on **Arc Mainnet** (chain 5042); develop against **Arc Testnet** (chain 5042002). Requests are permissionless — no allowlist, no subscription, no upfront deposit — but they must come from a contract, so a wallet or backend pays through its own consumer. Each request pays a fee quoted from the current base fee, a fraction of a USDC at typical gas prices (see [Pricing](#pricing)), and is served within 60 seconds or can be refunded.
|
|
10
|
+
|
|
11
|
+
## Start here
|
|
6
12
|
|
|
7
13
|
```sh
|
|
8
14
|
npm install @d20dao/vrf-sdk
|
|
9
15
|
```
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
1. **Copy an example.** [`examples/DiceConsumer.sol`](examples/DiceConsumer.sol) rolls a d20, [`examples/RaffleConsumer.sol`](examples/RaffleConsumer.sol) picks one winner from a list, [`examples/LootDropConsumer.sol`](examples/LootDropConsumer.sol) makes a weighted drop. Each is sixty to seventy lines and stands alone.
|
|
18
|
+
2. **Compile it.** Node 22.13+, solc 0.8.28, `evmVersion: cancun`. Hardhat and Foundry settings are in [Compiler setup](#compiler-setup).
|
|
19
|
+
3. **Deploy it** against the coordinator proxy for your chain, from [Deployments](#deployments). There is no default network; configure the address explicitly.
|
|
20
|
+
4. **Request and read.** Quote the fee off-chain, send it through your consumer, take `requestId` from the receipt and poll until `fulfilled` — or until the 60-second deadline passes and you refund. See [Paying for a request](#paying-for-a-request) and [Reading results](#reading-results).
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
Then read [Best practices](#best-practices): ten rules that cover most of what goes wrong. [API.md](API.md) lists every coordinator and registry function, event and error with its selector, caller and, for errors, what to do about it. [d20dao/randomizer-demo](https://github.com/d20dao/randomizer-demo) is a complete dapp built this way, and [CHANGELOG.md](CHANGELOG.md) records what changed in this release.
|
|
14
23
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
1. **Network.** Choose the chain in [Networks](#networks) and take its coordinator proxy from [Deployments](#deployments). Wallet parameters are in [Frontend and backend use](#frontend-and-backend-use).
|
|
18
|
-
2. **Install and compile.** Install the package as above and configure Hardhat or Foundry in [Compiler setup](#compiler-setup).
|
|
19
|
-
3. **Consumer.** Start from [Recommended payment pattern](#recommended-payment-pattern), choose a result type in [Randomness options](#randomness-options) and size the [callback gas limit](#callback-gas-limit).
|
|
20
|
-
4. **Request.** Quote off-chain and send the quoted value through your consumer: [Wallets and backends that pay through a consumer](#wallets-and-backends-that-pay-through-a-consumer).
|
|
21
|
-
5. **Wait and read.** Take `requestId` from the receipt and poll until `fulfilled` or the deadline passes: [Reading results](#reading-results), [Timing](#timing).
|
|
22
|
-
6. **Expiry and recovery.** Refund expired requests and retry failed callbacks with enough gas: [Expiry and refunds](#expiry-and-refunds), [Gas for refund and retry calls](#gas-for-refund-and-retry-calls).
|
|
23
|
-
|
|
24
|
-
[API.md](API.md) lists every coordinator and registry function, event and error with its selector, caller and, for errors, what to do. [d20dao/randomizer-demo](https://github.com/d20dao/randomizer-demo) is a complete dapp that follows these steps.
|
|
24
|
+
For agent-assisted integration, give your agent the installed `AGENTS.md`, `API.md` and `PROTOCOL-PROVENANCE.json`, plus the [integration skills](https://github.com/d20dao/skills). The website guides are on d20dao.org: [guides](https://d20dao.org/docs) including [Getting started](https://d20dao.org/docs/getting-started) with its Copy prompt action, the guide index [d20dao.org/llms.txt](https://d20dao.org/llms.txt), the full text [d20dao.org/llms-full.txt](https://d20dao.org/llms-full.txt) and [d20dao.org/agents.md](https://d20dao.org/agents.md).
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
## Best practices
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
1. **Quote with `quoteFeeAt(callbackGasLimit, block.baseFeePerGas)` plus a buffer, never through `eth_call`.** `eth_call` reports a base fee of 0, so `quoteFee` collapses to the minimum fee and the real transaction reverts with `IncorrectFee`. Inside the requesting transaction `quoteFee(callbackGasLimit)` is exact; off-chain, use `quoteRequestFee` or `quoteFeeAt` with the latest header's base fee and a buffer for the movement until inclusion.
|
|
29
|
+
2. **Requests come from a contract, never an EOA.** A wallet call reverts with `ContractConsumerRequired`. The consumer is what the coordinator calls back, so it has to exist before the request.
|
|
30
|
+
3. **Keep the callback small: store the result, do the work later.** It runs inside `callbackGasLimit`. If it reverts or runs out of gas, its state changes are rolled back while the request stays served and paid, and anyone can redeliver the same accepted word with `retryCallback(requestId, gasLimit)`, with more gas if needed. A delivery that succeeded is never repeated, but still refuse a request id you have already finished: the check costs one read and does not depend on the coordinator.
|
|
31
|
+
4. **Map each request id to your own context, and reject anything else.** Record who asked and what for when you request, then in the callback refuse a request id you never issued and one you have already finished.
|
|
32
|
+
5. **Derive many values from one word instead of making many requests.** One request buys 256 bits. Ask for `diceRoll(sides, count)`, `chooseMany` or `shuffle` and the coordinator maps them for you; for application-specific values, `keccak256(abi.encode(word, i))` gives an independent value per `i`. A second request costs a second fee and a second wait.
|
|
33
|
+
6. **Handle expiry, and choose the refund address deliberately.** If no proof is accepted within 60 seconds the request expires: nothing is fulfilled late, and anyone may call `refundRequest(requestId)`. The refund is pushed to the address fixed at request time, so pick one that can receive a plain native transfer or call `withdrawRefundCredit` — usually the paying user.
|
|
34
|
+
7. **Never re-roll a result you dislike.** The word is final once `fulfilled` is true. Re-requesting after seeing an outcome is the one thing verifiable randomness cannot protect your users from, and the evidence trail makes it visible.
|
|
35
|
+
8. **Never use `blockhash` or `block.timestamp` as randomness.** Both are chosen by whoever builds the block, and `blockhash` is only available for the last 256 blocks. That is the problem this service exists to solve.
|
|
36
|
+
9. **Withdraw the refund credit your fee buffer leaves behind.** Anything above the escrowed quote is credited to the refund address (`FeeOverpaymentCredited`), readable with `refundCredits(address)` and pulled with `withdrawRefundCredit(recipient)`. Returning the change in the requesting transaction, as `DiceConsumer` does, avoids the second transaction entirely.
|
|
37
|
+
10. **Close bets and entries when you request.** No one can predict the word before the keeper submits it, but the pending fulfillment transaction reveals it about one block before it lands. Anything the result decides — stakes, entries, choices — must be fixed in the requesting transaction and unchangeable until the callback, as `RaffleConsumer` does when it closes entries at the draw.
|
|
38
|
+
11. **Freeze any list before you request an index into it.** `chooseOne`, `chooseMany` and `shuffle` answer with indices. Commit the list — hashing it into `clientSeed` puts the commitment in the request log, as `RaffleConsumer` does.
|
|
30
39
|
|
|
31
40
|
## Networks
|
|
32
41
|
|
|
@@ -49,14 +58,11 @@ Solidity imports require compiler 0.8.28 and your compiler's npm resolver:
|
|
|
49
58
|
- `@d20dao/vrf-sdk/contracts/interfaces/ID20VRF.sol`
|
|
50
59
|
- `@d20dao/vrf-sdk/contracts/libraries/D20VRFRequests.sol`
|
|
51
60
|
- `@d20dao/vrf-sdk/contracts/libraries/RandomnessMapping.sol`
|
|
52
|
-
- `@d20dao/vrf-sdk/contracts/examples/MiningRandomnessConsumer.sol`
|
|
53
61
|
|
|
54
62
|
These sources import only each other; no OpenZeppelin installation is needed for a consumer.
|
|
55
63
|
|
|
56
64
|
`D20VRFConsumer` authenticates the coordinator proxy. Verify the expected request in the callback and store the word with minimal work. Pin the effective coordinator proxy address, initialized configuration and implementation history of both service proxies. A constructor code-length check, SDK installation or permissionless request acceptance does not guarantee service.
|
|
57
65
|
|
|
58
|
-
A complete consumer following the recommended payment pattern is shown in [Recommended payment pattern](#recommended-payment-pattern).
|
|
59
|
-
|
|
60
66
|
### Compiler setup
|
|
61
67
|
|
|
62
68
|
Hardhat resolves `@d20dao/vrf-sdk/...` imports from `node_modules` without remappings:
|
|
@@ -86,10 +92,16 @@ With either tool, `import {D20VRFConsumer} from "@d20dao/vrf-sdk/contracts/D20VR
|
|
|
86
92
|
|
|
87
93
|
### Examples
|
|
88
94
|
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
Three complete consumers ship in the package and install as `@d20dao/vrf-sdk/examples/<name>.sol`. Each is sixty to seventy lines, deals with one idea and is meant to be copied and edited rather than imported. All three take the coordinator proxy in their constructor, authenticate the callback through `D20VRFConsumer`, refuse a request id they did not issue or have already finished, and read their outcome from `getMappedResult` instead of recomputing it.
|
|
96
|
+
|
|
97
|
+
| Example | What it shows |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| [`DiceConsumer.sol`](examples/DiceConsumer.sol) | One d20 per player. Reads the exact `quoteFee` inside the requesting transaction, pays it, returns the change and names the player as the refund address. |
|
|
100
|
+
| [`RaffleConsumer.sol`](examples/RaffleConsumer.sol) | One winner from a list. Closes entry before requesting, hashes the frozen list into `clientSeed` as an on-chain commitment, maps the winning index with `ChooseOne` and, through `_onRefund`, lets the draw be sent again only after an expired request was refunded. |
|
|
101
|
+
| [`LootDropConsumer.sol`](examples/LootDropConsumer.sol) | A weighted drop. Asks for a `NumberRange` draw over the total weight instead of reducing the word itself, stores the word in the callback and walks the weights on read. |
|
|
102
|
+
|
|
91
103
|
- [d20dao/randomizer-demo](https://github.com/d20dao/randomizer-demo) is a complete dapp: a consumer with one function per randomness option that stores the latest results onchain, and a single-page UI that requests, waits for and displays them. It runs on Arc Mainnet at https://mainnet-demo.d20dao.org.
|
|
92
|
-
- `skills/d20-consumer/assets/RandomnessConsumer.sol` in [d20dao/skills](https://github.com/d20dao/skills) shows raw, mapped and shuffle requests
|
|
104
|
+
- `skills/d20-consumer/assets/RandomnessConsumer.sol` in [d20dao/skills](https://github.com/d20dao/skills) shows raw, mapped and shuffle requests in one contract, with refund notification and refund-credit withdrawal.
|
|
93
105
|
|
|
94
106
|
### Client seed
|
|
95
107
|
|
|
@@ -117,6 +129,8 @@ Every option is a `RandomnessMapping.Spec` `(operation, lower, upper, count, pop
|
|
|
117
129
|
|
|
118
130
|
TypeScript bounds (`sides`, `min`, `max`) are `bigint`; `count`, `size` and `population` are integer `number` values. Invalid parameters throw in TypeScript and revert the request with `InvalidMapping` onchain. Results are `uint256[]` from `getMappedResult` and the coordinator's `mapRandomness`, and `bigint[]` from the SDK's `mapRandomness`. Sampling rejects the short residue range instead of taking a biased modulo. Choice and shuffle results are zero-based indices into a list the application must fix before requesting. Callbacks always receive the raw `bytes32` word, including for mapped requests.
|
|
119
131
|
|
|
132
|
+
`RandomnessMapping.Spec` is the Solidity struct `(operation, lower, upper, count, population)` stored with a request. The TypeScript `MappingSpec` that `builtins` return has the same fields as an object, with `lower` and `upper` as `bigint`; ethers encodes it for the struct unchanged, and `hashMapping(spec)` equals the request's `mappingHash`. "Mapping" here means a randomness mapping, not a Solidity `mapping`.
|
|
133
|
+
|
|
120
134
|
## Pricing
|
|
121
135
|
|
|
122
136
|
The coordinator prices every request from the base fee of the transaction that creates it:
|
|
@@ -125,23 +139,26 @@ The coordinator prices every request from the base fee of the transaction that c
|
|
|
125
139
|
fee = max(minFee, feeMultiplier × baseFee × (fulfillGasOverhead + callbackGasLimit))
|
|
126
140
|
```
|
|
127
141
|
|
|
128
|
-
`pricing()` returns the live `(minFee, feeMultiplier, fulfillGasOverhead)`. The owner can move them with `setPricing(minFee, multiplier, overhead)` (event `PricingChanged`) only within fixed bounds: `minFee` at most 10 USDC (`10e18` wei; native USDC on Arc uses 18 decimals), `feeMultiplier` 0 to 20 where 0 means a flat `minFee`, `fulfillGasOverhead` 100,000 to 2,000,000 gas. Both Arc deployments were initialized with a 0.08 USDC minimum fee (`initialMinFee()`), multiplier 5 and overhead 300,000 gas, together with a 50% keeper share (`keeperFeeBps` 5000) and a 100% refund ratio.
|
|
142
|
+
`pricing()` returns the live `(minFee, feeMultiplier, fulfillGasOverhead)`. The owner can move them with `setPricing(minFee, multiplier, overhead)` (event `PricingChanged`) only within fixed bounds: `minFee` at most 10 USDC (`10e18` wei; native USDC on Arc uses 18 decimals), `feeMultiplier` 0 to 20 where 0 means a flat `minFee`, `fulfillGasOverhead` 100,000 to 2,000,000 gas. Both Arc deployments were initialized with a 0.08 USDC minimum fee (`initialMinFee()`), multiplier 5 and overhead 300,000 gas, together with a 50% keeper share (`keeperFeeBps` 5000) and a 100% refund ratio. Arc Testnet still uses these values. Since 2026-09-18 Arc Mainnet charges a 0.02 USDC minimum fee, multiplier 3 and overhead 300,000 gas, with a 60% keeper share (`keeperFeeBps` 6000). Prices are not fixed: read the live values instead of hard-coding them. A pricing change never touches requests that are already open, because each request settles from the fee it escrowed.
|
|
129
143
|
|
|
130
|
-
Labelled examples
|
|
144
|
+
Labelled examples. A to C use the initialization values (multiplier 5, overhead 300,000 gas, 0.08 USDC minimum fee); D uses Arc Mainnet pricing.
|
|
131
145
|
|
|
132
146
|
- **A, 176 gwei base fee, 100,000 callback gas.** Dynamic part 5 × 176 gwei × 400,000 = 0.352 USDC, above the minimum, so the fee is 0.352 USDC.
|
|
133
147
|
- **B, 20 gwei base fee, 100,000 callback gas.** Dynamic part 5 × 20 gwei × 400,000 = 0.04 USDC, below the minimum, so the fee is 0.08 USDC.
|
|
134
148
|
- **C, multiplier set to 0.** The fee is `minFee` at any base fee.
|
|
149
|
+
- **D, Arc Mainnet, 20 gwei base fee, 100,000 callback gas.** Dynamic part 3 × 20 gwei × 400,000 = 0.024 USDC, above the 0.02 USDC minimum, so the fee is 0.024 USDC.
|
|
135
150
|
|
|
136
151
|
`quoteFeeAt(callbackGasLimit, baseFee)` evaluates the formula for a base fee you supply; `quoteFee(callbackGasLimit)` evaluates it for `block.basefee`. Quotes above the `uint96` escrow limit revert with `FeeOverflow` rather than truncating.
|
|
137
152
|
|
|
153
|
+
Three fee names are easy to confuse. `pricing()` and `quoteFee` price *future* requests; `requestFeePaid(requestId)` is what *one* request escrowed and settles from. `refundBps()` is the current refund ratio, copied into each new request, while `requestRefundBps(requestId)` is the ratio that request copied at creation and is refunded at. `keeperFeeBps()` has no per-request copy at all: it is read when a proof is accepted.
|
|
154
|
+
|
|
138
155
|
## Paying for a request
|
|
139
156
|
|
|
140
157
|
`requestRandomness(clientSeed, callbackGasLimit, refundAddress)` and `requestMappedRandomness(..., spec)` accept `msg.value >= fee`, where `fee` is the quote computed inside that transaction. Less reverts with `IncorrectFee(expected, actual)`. Exactly `fee` is escrowed and stored as `requestFeePaid(requestId)`; `RandomnessRequested` emits that charged fee as `feePaid`, not `msg.value`. Anything above it is not revenue: it is credited to the request's `refundAddress` as refund credit (`FeeOverpaymentCredited(requestId, refundAddress, amount)`, readable through `refundCredits(address)`) and is withdrawn by that address calling `withdrawRefundCredit(recipient)`. Choose a refund address that can make that call, or that can receive a plain native transfer for expiry refunds; a contract that can do neither strands its credit.
|
|
141
158
|
|
|
142
159
|
### Contracts that pay in the same transaction
|
|
143
160
|
|
|
144
|
-
`quoteFee(callbackGasLimit)` is exact inside the requesting transaction. `D20VRFRequests` helpers
|
|
161
|
+
`quoteFee(callbackGasLimit)` is exact inside the requesting transaction, so a contract can read the price and pay it in one go. The `D20VRFRequests` helpers do exactly that from the calling contract's balance:
|
|
145
162
|
|
|
146
163
|
```solidity
|
|
147
164
|
uint256 fee = rng.quoteFee(callbackGasLimit);
|
|
@@ -156,67 +173,19 @@ Do not call `quoteFee` through `eth_call`: it prices with `block.basefee`, which
|
|
|
156
173
|
import { quoteRequestFee } from '@d20dao/vrf-sdk';
|
|
157
174
|
// provider: ethers Provider; coordinator: coordinator proxy address; 100_000: callbackGasLimit
|
|
158
175
|
const { fee, value, baseFee } = await quoteRequestFee(provider, coordinator, 100_000, { bufferBps: 3000 });
|
|
159
|
-
await dice.roll(
|
|
176
|
+
await dice.roll({ value }); // DiceConsumer pays the exact quote and returns the rest
|
|
160
177
|
```
|
|
161
178
|
|
|
162
179
|
`fee` is `quoteFeeAt(callbackGasLimit, baseFee)` for the block's actual base fee. `value` is the same quote recomputed at a base fee `bufferBps` higher (default 3000, 30%: an EIP-1559 base fee can rise 12.5% per block), so the request still pays if the base fee rises by up to that much before inclusion. When the minimum fee dominates even at the buffered base fee, `value` equals `fee` and nothing extra is sent. In example A, `value` is 5 × 228.8 gwei × 400,000 = 0.4576 USDC; a request included at 176 gwei escrows 0.352 USDC, and the remaining 0.1056 USDC is either returned by the consumer or credited to the refund address, depending on the payment pattern below. The helper never uses `quoteFee`, needs only `getBlock` and `call`, and throws if the block has no `baseFeePerGas`. If the base fee outruns the buffer or pricing changes in between, the transaction reverts with `IncorrectFee`; quote again and resend.
|
|
163
180
|
|
|
164
|
-
###
|
|
181
|
+
### Choosing a payment pattern
|
|
165
182
|
|
|
166
|
-
|
|
183
|
+
Two patterns cover almost every consumer, and the examples ship both.
|
|
167
184
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
pragma solidity 0.8.28;
|
|
171
|
-
|
|
172
|
-
import {D20VRFConsumer} from "@d20dao/vrf-sdk/contracts/D20VRFConsumer.sol";
|
|
173
|
-
import {ID20VRF} from "@d20dao/vrf-sdk/contracts/interfaces/ID20VRF.sol";
|
|
174
|
-
import {D20VRFRequests} from "@d20dao/vrf-sdk/contracts/libraries/D20VRFRequests.sol";
|
|
175
|
-
|
|
176
|
-
contract D20Game is D20VRFConsumer {
|
|
177
|
-
using D20VRFRequests for ID20VRF;
|
|
178
|
-
|
|
179
|
-
uint32 public constant CALLBACK_GAS = 100_000;
|
|
180
|
-
mapping(uint256 => address) public playerOf;
|
|
181
|
-
mapping(uint256 => bytes32) public wordOf;
|
|
182
|
-
mapping(uint256 => bool) public ready;
|
|
183
|
-
error Underpaid(uint256 fee, uint256 sent);
|
|
184
|
-
error ChangeFailed();
|
|
185
|
-
error UnexpectedCallback();
|
|
186
|
-
|
|
187
|
-
constructor(address coordinator) D20VRFConsumer(coordinator) {}
|
|
188
|
-
|
|
189
|
-
function roll(bytes32 operationId) external payable returns (uint256 requestId) {
|
|
190
|
-
ID20VRF rng = ID20VRF(vrfCoordinator);
|
|
191
|
-
uint256 fee = rng.quoteFee(CALLBACK_GAS); // exact inside this transaction
|
|
192
|
-
if (msg.value < fee) revert Underpaid(fee, msg.value);
|
|
193
|
-
// The helper pays the same quoteFee from this contract's balance, which msg.value just funded.
|
|
194
|
-
// The player is the refund address: an expiry refund goes straight back to them.
|
|
195
|
-
requestId = rng.d20(D20VRFRequests.Options(keccak256(abi.encode(msg.sender, operationId)), CALLBACK_GAS, msg.sender));
|
|
196
|
-
playerOf[requestId] = msg.sender;
|
|
197
|
-
if (msg.value > fee) {
|
|
198
|
-
(bool ok,) = payable(msg.sender).call{value: msg.value - fee}("");
|
|
199
|
-
if (!ok) revert ChangeFailed();
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function _fulfillRandomness(uint256 requestId, bytes32 randomness) internal override {
|
|
204
|
-
if (playerOf[requestId] == address(0) || ready[requestId]) revert UnexpectedCallback();
|
|
205
|
-
wordOf[requestId] = randomness;
|
|
206
|
-
ready[requestId] = true;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/// 1 to 20 once ready.
|
|
210
|
-
function result(uint256 requestId) external view returns (uint256) {
|
|
211
|
-
return ID20VRF(vrfCoordinator).getMappedResult(requestId)[0];
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
```
|
|
185
|
+
- **Pay the exact quote and return the change** ([`DiceConsumer.sol`](examples/DiceConsumer.sol)). Read `fee = quoteFee(callbackGasLimit)`, require `msg.value >= fee`, pay exactly `fee` and send `msg.value - fee` back to the caller. The front end sends `value` from `quoteRequestFee`, the unused buffer returns immediately, nothing accumulates as refund credit and the contract never holds user funds. Name the paying user as the refund address — any wallet can receive a native transfer or call `withdrawRefundCredit` — so an expiry refund goes back to whoever paid.
|
|
186
|
+
- **Forward `msg.value`** ([`RaffleConsumer.sol`](examples/RaffleConsumer.sol), [`LootDropConsumer.sol`](examples/LootDropConsumer.sol)). Fewer lines: the coordinator escrows its own quote, reverts `IncorrectFee` when that is more than arrived, and credits everything above it to the refund address. With a buffered off-chain quote most requests leave some credit, which that address pulls later with `withdrawRefundCredit`.
|
|
215
187
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
- **Forward `msg.value`** (`examples/DiceConsumer.sol`). The simplest code: the coordinator escrows the quote and credits everything above it to the refund address as refund credit. With a buffered off-chain quote most requests leave some credit, which the refund address must withdraw in a separate `withdrawRefundCredit` transaction.
|
|
219
|
-
- **Pay from the contract balance** (`D20VRFRequests` helpers without returning change, `MiningRandomnessConsumer`). The application funds the contract and charges users under its own rules; it needs its own funding and withdrawal policy, and the refund address decides who receives expiry refunds.
|
|
188
|
+
A contract that funds requests from its own balance uses the first form without the change transfer. It then needs its own funding and withdrawal policy, and its refund address decides who receives expiry refunds.
|
|
220
189
|
|
|
221
190
|
## Reading results
|
|
222
191
|
|
|
@@ -241,14 +210,14 @@ Views on the coordinator (all in `coordinatorAbi`; only `getMappedResult` is par
|
|
|
241
210
|
|
|
242
211
|
[API.md](API.md) documents every view, event and error, including the order of events in a receipt.
|
|
243
212
|
|
|
244
|
-
Polling with ethers 6, after sending the request through a consumer such as `
|
|
213
|
+
Polling with ethers 6, after sending the request through a consumer such as `DiceConsumer`:
|
|
245
214
|
|
|
246
215
|
```js
|
|
247
216
|
import { Contract } from 'ethers';
|
|
248
217
|
import { coordinatorAbi } from '@d20dao/vrf-sdk/abi';
|
|
249
218
|
|
|
250
219
|
const coordinator = new Contract(coordinatorAddress, coordinatorAbi, provider);
|
|
251
|
-
const receipt = await (await
|
|
220
|
+
const receipt = await (await dice.roll({ value })).wait();
|
|
252
221
|
const requestId = receipt.logs
|
|
253
222
|
.filter((log) => log.address.toLowerCase() === coordinatorAddress.toLowerCase())
|
|
254
223
|
.map((log) => coordinator.interface.parseLog(log))
|
|
@@ -270,9 +239,8 @@ for (;;) {
|
|
|
270
239
|
|
|
271
240
|
- The package is ESM only (`"type": "module"`, `import` export conditions) for Node 22.13+ and bundlers. In a browser application, import it through a bundler such as Vite, webpack or esbuild; the test suite bundles the root and `/abi` entries for the browser platform with esbuild. Import `@d20dao/vrf-sdk/abi` alone when only ABIs are needed.
|
|
272
241
|
- `quoteRequestFee(provider, coordinator, callbackGasLimit, options)` expects an ethers v6 provider such as `JsonRpcProvider` or `BrowserProvider`, or any object with ethers-v6-shaped `getBlock(tag)` (with `baseFeePerGas` as `bigint`) and `call(tx)`. With viem or another client, repeat its steps: read the latest block's `baseFeePerGas`, add the buffer and call `quoteFeeAt(callbackGasLimit, bufferedBaseFee)`.
|
|
273
|
-
- Quote from the block header base fee plus a buffer, never with `quoteFee` through `eth_call`, because `eth_call` reports a base fee of 0 (see [Wallets and backends that pay through a consumer](#wallets-and-backends-that-pay-through-a-consumer)).
|
|
274
|
-
- Send the transaction to your consumer contract; the coordinator rejects requests from wallets with `ContractConsumerRequired`.
|
|
275
242
|
- The SDK does not wrap viem or other clients; its ABIs are plain JSON and work with any library.
|
|
243
|
+
- Beacon verification uses the BN254 curve of `@noble/curves`, which takes about 45 ms to evaluate. It is read only inside the functions that verify a round (`verifyBeaconRound`, `beaconRoundMessage`, `verifyEpochAttestation`, `replayEpochCommitment`, `replayCoordinator`), so a bundler leaves it out of a bundle that reaches none of them; importing the package in Node loads it.
|
|
276
244
|
- To add Arc to a browser wallet, use `wallet_addEthereumChain` with the values from [Networks](#networks). The native currency uses 18 decimals:
|
|
277
245
|
|
|
278
246
|
```js
|
|
@@ -289,20 +257,20 @@ for (;;) {
|
|
|
289
257
|
```
|
|
290
258
|
- Reverts from the coordinator are custom errors, and `coordinatorAbi` includes all of them. Decode revert data with `coordinator.interface.parseError(data)` in ethers, or add the coordinator ABI next to your consumer ABI so the wallet or library can name the error. The ones a consumer meets most often: `IncorrectFee(expected, actual)` (re-quote and resend), `InvalidCallbackGas`, `InvalidMapping` (from `RandomnessMapping`), `ContractConsumerRequired`, `UnknownRequest`, `NotFulfilled`, `RefundNotAvailable` (not yet past the deadline, already served or already refunded), `RequestRefunded` and `InsufficientCallbackGas` (raise the transaction gas limit; see [Gas for refund and retry calls](#gas-for-refund-and-retry-calls)). `OnlyCoordinator` comes from `D20VRFConsumer` and is only in your consumer's ABI. [API.md](API.md) gives every error's selector, the calls that raise it and what to do.
|
|
291
259
|
- ethers v6 returns structs as `Result` objects that are also arrays. A field named like an `Array` or `Result` member, such as `values`, `length` or `map`, is shadowed; read it with `result.getValue('values')`, by position or from `result.toObject()`, or choose another field name.
|
|
292
|
-
-
|
|
293
|
-
-
|
|
260
|
+
- Every public Arc RPC endpoint is on `*.arc.io`, and common browser ad-block filter lists block that domain, so a page that reads the chain directly fails with `net::ERR_BLOCKED_BY_CLIENT` for a share of users. Read through the connected wallet's EIP-1193 provider when there is one (check its chain ID first), or through a read-only relay you serve from your own origin; the [randomizer-demo](https://github.com/d20dao/randomizer-demo) does the latter for read methods only and leaves transactions to the wallet.
|
|
261
|
+
- Some RPC endpoints reject or rate-limit large JSON-RPC batches, and limits differ between providers and plans. ethers `JsonRpcProvider` batches up to 100 calls by default; lower `batchMaxCount` (`new JsonRpcProvider(url, 5042, { staticNetwork: true, batchMaxCount: 1 })`) when you meet one. Poll no faster than you need and cache what cannot change: once `fulfilled` is true, `randomness` and the mapped result are final.
|
|
294
262
|
|
|
295
263
|
## Request lifecycle
|
|
296
264
|
|
|
297
|
-
Epochs last 200 blocks.
|
|
265
|
+
Epochs last 200 blocks. Each epoch uses the catalog in force for it: 1 to 10 ordered sources, each a registered recipe with its signer (see [Recipes](#recipes)). The keeper selects a source using the canonical block hash at epoch start minus one and prepares its first valid snapshot locally: a signed API record, or for a beacon recipe a drand round (see [Beacon epochs](#beacon-epochs)). If the selected source yields no valid packet, the next source in catalog order can be committed instead, one source per 20-block window (attempts 1 to count − 1); a catalog of one source, such as the drand catalog, has no fallback. A saved signed record is never refreshed or resampled; a saved drand round that has grown too old to be accepted is replaced by a current round, and only while no commit of its epoch has been sent. The registry committer publishes, or a backup committer the owner allowed so that a second keeper can take over. Idle preparation publishes no transaction. An unused local snapshot can be retained for 50 epochs (10,000 blocks), subject to live-demand and unresolved-transaction protection.
|
|
298
266
|
|
|
299
267
|
A request escrows its quoted fee even when its epoch packet is not published yet, and fixes its original block, epoch, client seed, mapping, refund address, `feePaid`, `refundBps` and 60-second deadline. The keeper publishes the saved packet only for live paid demand. The randomness target becomes `max(requestBlock, committedBlock + 1)`, so its hash is unknown at publication; before publication the request has no usable target or VRF seed. Multiple requests share the packet, and timely requests can settle across epoch boundaries without changing their epoch.
|
|
300
268
|
|
|
301
|
-
Timely service is onchain proof acceptance at or before `requestedAt + 60` seconds; a pending transaction is not acceptance. At acceptance the keeper share, `keeperFeeBps` of `feePaid`, is paid to the registry
|
|
269
|
+
Timely service is onchain proof acceptance at or before `requestedAt + 60` seconds; a pending transaction is not acceptance. At acceptance the keeper share, `keeperFeeBps` of `feePaid`, is paid to the submitting wallet when the registry authorizes it as its committer or one of its backup committers, and to the configured committer for any other submitter (a failed transfer becomes keeper credit) and the remainder becomes withdrawable protocol fees. With a 50% share, example A pays 0.176 USDC to the keeper and 0.176 USDC to the treasury. Callback failure still earns the fee; `retryCallback(requestId, gasLimit)` redelivers only the same accepted result and never pays a second share.
|
|
302
270
|
|
|
303
271
|
### Timing
|
|
304
272
|
|
|
305
|
-
Each request's deadline is its block timestamp plus 60 seconds (`RESPONSE_TIMEOUT`). A proof accepted onchain at or before the deadline serves the request; after it the request can only be refunded. On Arc, a single request is normally fulfilled within a few seconds.
|
|
273
|
+
Each request's deadline is its block timestamp plus 60 seconds (`RESPONSE_TIMEOUT`). A proof accepted onchain at or before the deadline serves the request; after it the request can only be refunded. On Arc, a single request is normally fulfilled within a few seconds. Under load, 200 simultaneous requests were all delivered within 36 seconds, with a median of 19 seconds. Measured timings are not an SLA: wait up to the deadline, as in [Reading results](#reading-results), and handle expiry. If the keeper does not publish the request's epoch packet or a proof in time, for any reason, the request simply expires: nothing is fulfilled late, and the fee can be refunded as described below. Your application only needs to treat the request as expired.
|
|
306
274
|
|
|
307
275
|
### Expiry and refunds
|
|
308
276
|
|
|
@@ -326,21 +294,109 @@ The keeper may fulfill up to 16 prepared requests in one transaction with `fulfi
|
|
|
326
294
|
|
|
327
295
|
## Optional refund notification
|
|
328
296
|
|
|
329
|
-
After `refundRequest` has paid the fixed refund address or recorded its refund credit, the coordinator calls `onRefund(requestId)` on the original consumer. Extend `D20VRFConsumer` and override `_onRefund(uint256 requestId)` to update application state; the base authenticates the coordinator. The callback only carries the request ID and does not imply that the consumer itself received money. Application assets and fees remain the application's responsibility.
|
|
297
|
+
After `refundRequest` has paid the fixed refund address or recorded its refund credit, the coordinator calls `onRefund(requestId)` on the original consumer. Extend `D20VRFConsumer` and override `_onRefund(uint256 requestId)` to update application state, as `RaffleConsumer` does to allow a new draw; the base authenticates the coordinator. The callback only carries the request ID and does not imply that the consumer itself received money. Application assets and fees remain the application's responsibility.
|
|
330
298
|
|
|
331
299
|
The first attempt forwards 100,000 gas. A reverting or gas-exhausting hook cannot undo the fee settlement. After failure, `retryRefundCallback(requestId, gasLimit)` retries the notification without another payment; successful delivery is recorded by `refundCallbackDelivered(requestId)`. Refund/retry needs sufficient outer gas (see [Gas for refund and retry calls](#gas-for-refund-and-retry-calls)). Never request new randomness from within either callback; use a separate application transaction.
|
|
332
300
|
|
|
333
301
|
## Replay and verification
|
|
334
302
|
|
|
335
|
-
Use independently trusted successful receipts and state. Decode the registry `EpochCommitted` packet with `decodeEpochEvidencePacket` and verify with `replayEpochCommitment`, using the original source anchor, exact packet, commit block/time,
|
|
303
|
+
Use independently trusted successful receipts and state. Decode the registry `EpochCommitted` packet with `decodeEpochEvidencePacket` and verify with `replayEpochCommitment`, using the original source anchor, exact packet, commit block/time, the epoch's catalog with its recipe definitions and the registry identity. It checks both record types: the signed record of an API recipe against the catalog's signer, and the round of a beacon recipe against the beacon registration in the recipe book ([Beacon epochs](#beacon-epochs)). Decode the coordinator `FulfillmentEvidence` packet with `decodeEvidencePacket`, then call `replayCoordinator` with its exported input type (`Parameters<typeof replayCoordinator>[0]`).
|
|
336
304
|
|
|
337
305
|
`RequestContext` binds both `requestBlock` and `targetBlock`. Validate the epoch from the original request block, reconstruct the target from the actual publication block, and compare the event and stored transcript. Proof evidence is 416 bytes; fulfillment calldata is 452 bytes. Neither evidence packet has a version prefix. Choose the decoder from trusted emitter/event context. Decoding and mapping alone are not proof verification; replay does not authenticate RPC or establish receipt inclusion.
|
|
338
306
|
|
|
339
307
|
`EpochProtocolConfiguration` is the initialized configuration: `feeRecipient` from `initialFeeRecipient()`, `initialMinFee` from `initialMinFee()` (the `initialize` fee argument), `catalogHash` from `catalogHash()`. Live `pricing()`, `feeRecipient()` and scheduled catalogs never change `protocolConfigurationHash`.
|
|
340
308
|
|
|
341
|
-
|
|
309
|
+
Catalogs are per epoch. `catalogAt(epochId)` returns the hash, recipe ids and signers an epoch selects and commits with, and `Epoch.catalogHash` records that hash at publication. A registry starts with its initial catalog, recipes 0 to 3 with the four signers given at initialization, whose hash `catalogHash()` is bound into the configuration hash; `catalogHash()` and the initial signer getters never change. The owner replaces the catalog for epochs at least two ahead with `scheduleCatalog(recipes, signers, fromEpoch)`: 1 to 10 distinct registered recipes with one signer each, hashed as `keccak256(abi.encode(RECIPE_DOMAIN, recipes, signers))` (event `CatalogScheduled(fromEpoch, catalogHash, recipes, signers)`). A new schedule replaces a pending version, one that takes effect two or more epochs ahead; the version that takes effect at the next epoch is kept, as is every active one, so the current and next epoch, prepared snapshots and open requests keep their catalog. For replay, build `epoch.catalog` from the epoch's `catalogAt` view with `resolveEpochCatalog(base, view)`, which recognizes the initial catalog by its hash (or from the `CatalogScheduled` history without replaced versions), while `configuration.catalogHash` stays the initial `catalogHash()`; `replayEpochCommitment` binds the supplied catalog to `record.catalogHash`.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
import { readEpochRecipes, resolveEpochCatalog } from '@d20dao/vrf-sdk/epoch';
|
|
313
|
+
|
|
314
|
+
const [hash, recipes, signers] = await registry.catalogAt(epochId); // registry: ethers Contract with epochEntropyAbi
|
|
315
|
+
const recipeBook = await readEpochRecipes(provider, registryAddress, recipes.map(Number));
|
|
316
|
+
const catalog = resolveEpochCatalog({ registry: registryAddress, chainId, firstEpochStart, recipeBook }, { hash, recipes, signers });
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
At publication an attestation, a signed record or a beacon round at its scheduled time, may be at most 240 seconds old and never future-dated (`MAX_ATTESTATION_AGE`, exported from `/epoch`); `replayEpochCommitment` enforces the same bound against the commit timestamp.
|
|
320
|
+
|
|
321
|
+
### Recipes
|
|
322
|
+
|
|
323
|
+
Epoch sources are recipes in an owner-managed, append-only registry in `EpochEntropy`. A recipe is its canonical request, whose `keccak256` is the query hash the signer signs; a data template that fixes the exact signed bytes the registry accepts ([Data templates](#data-templates)); and the body keepers send: the JSON they post to the provider gateway, or a beacon's canonical request. `registerRecipe(canonicalRequest, template, body)` appends the next id (0 to 255) and emits `RecipeRegistered` with the full definition; `registerBeacon` appends a beacon recipe ([Beacon epochs](#beacon-epochs)). A registered recipe never changes, so a changed listing becomes a new id. `recipeCount()` and `getRecipe(id)` read the registry. Every registry registers six built-in recipes itself, at initialization or in its recipe-registry upgrade:
|
|
324
|
+
|
|
325
|
+
| Recipe | Provider | Query | Exact signed record |
|
|
326
|
+
| --- | --- | --- | --- |
|
|
327
|
+
| 0 | Hyperliquid | `metaAndAssetCtxs`, dex `""`, projection symbol `/0/universe/0/name` and value `/1/0/dayNtlVlm` | `{"symbol":"BTC","value":"<decimal>"}` |
|
|
328
|
+
| 1 | dRPC | `jsonRpc` on Ethereum mainnet: `eth_call` of Multicall3 `getLastBlockHash()` at `latest` | `{"id":null,"jsonrpc":"2.0","result":"0x<64 lowercase hex>"}` |
|
|
329
|
+
| 2 | TickerLayer | `lastTrade`, crypto, BTCUSD | `{"symbol":"BTCUSD","price":<number>,"size":<number>,"timestamp":<1 to 16 digits>}` |
|
|
330
|
+
| 3 | TickerLayer | `lastTrade`, crypto, ETHUSD | as recipe 2 with ETHUSD |
|
|
331
|
+
| 4 | Nodary | `latestFeeds`, name ETH/USD | `{"ETH/USD":{"value":<number>,"timestamp":<13 digits>,"category":"crypto"}}` |
|
|
332
|
+
| 5 | dRPC | as recipe 1 on Base | as recipe 1 |
|
|
333
|
+
|
|
334
|
+
The owner registered more recipes after these. Recipes 6 to 10 are the passthrough form of built-in recipes 0, 1, 2, 4 and 5: the same listings reached through the gateway's `/api` path, answered with the same signed records under other request hashes, so they keep the built-in templates. Their canonical request is also their body, and `passthroughEpochRecipe(builtinId)` builds it. Recipe 11 is the drand evmnet beacon, registered on both networks.
|
|
342
335
|
|
|
343
|
-
|
|
336
|
+
Each network's catalog changed over time. Read the catalog an epoch actually used from `catalogAt(epochId)` rather than assuming one:
|
|
337
|
+
|
|
338
|
+
| Network | From epoch | Catalog | Date (UTC) |
|
|
339
|
+
| --- | --- | --- | --- |
|
|
340
|
+
| Arc Testnet | 966 | `[0, 1, 2, 4, 5]`: Hyperliquid BTC day volume, dRPC Ethereum block hash, TickerLayer BTCUSD, Nodary ETH/USD, dRPC Base block hash | 2026-09-17 |
|
|
341
|
+
| Arc Testnet | 10108 | `[6, 7, 8, 9, 10]`: the same five listings through the passthrough path | 2026-09-28 |
|
|
342
|
+
| Arc Testnet | 11319 | `[11]`: drand evmnet only | 2026-09-30 |
|
|
343
|
+
| Arc Mainnet | 848 | `[0, 1, 2, 4, 5]` | 2026-09-18 |
|
|
344
|
+
| Arc Mainnet | 10070 | `[6, 7, 8, 9, 10]` | 2026-09-28 |
|
|
345
|
+
| Arc Mainnet | 12448 | `[11]`: drand evmnet only | 2026-10-01 |
|
|
346
|
+
|
|
347
|
+
Before those catalogs each registry used its initial catalog, recipes 0 to 3.
|
|
348
|
+
|
|
349
|
+
`BUILTIN_EPOCH_RECIPES` (from `@d20dao/vrf-sdk/epoch`) holds the built-in definitions, and replay uses them unless the catalog carries a `recipeBook`. For any other recipe, put its definition in `epoch.catalog.recipeBook`: `readEpochRecipes(provider, registry, ids)` reads `getRecipe` and checks each query hash, and for a beacon recipe reads its registration from `beaconOf`; or rebuild them from `RecipeRegistered` and `BeaconRegistered` logs. Replay checks every definition it uses: the committed packet must carry the recipe's canonical request and the record must match its template.
|
|
350
|
+
|
|
351
|
+
Registry implementations before variable catalogs hardcoded ANU random numbers as recipe 1. Neither public registry ever committed an epoch from it. Older evidence that did use ANU replays when its definition is supplied in `recipeBook` under id 1: canonical request `["randomNumbers",[["length",4],["size",8],["type","hex8"]]]`, body `{"operation":"randomNumbers","parameters":{"type":"hex8","length":4,"size":8}}` and the template described in [Data templates](#data-templates).
|
|
352
|
+
|
|
353
|
+
### Beacon epochs
|
|
354
|
+
|
|
355
|
+
A beacon recipe commits one round of a public randomness beacon instead of a signed API record. Arc uses [drand](https://drand.love)'s evmnet (`bls-bn254-unchained-on-g1`: BN254, a round every 3 seconds, chain hash `0x04f1e9062b8a81f848fded9c12306733282b2727ecced50032187751166ec8c3`), exported as `DRAND_EVMNET`. The owner registers a beacon with `registerBeacon(verifier, chainHash, publicKey, genesis, period, sampleRound, sampleSignature)`. The `verifier` is a contract such as `D20BeaconVerifier` that checks a round's signature under `publicKey`; the registry accepts the registration only if the verifier accepts a past `sampleRound`, under the same gas allowance a publication gets. A registration never changes. Its recipe has the canonical request `["drand","<chainHash>"]`, which is also its body, and a template that accepts one round number: 1 to 19 digits, no leading zero.
|
|
356
|
+
|
|
357
|
+
An epoch published from a beacon recipe commits round `r` as the data, its scheduled time `genesis + (r − 1) × period` as the timestamp and the beacon's 64-byte signature (`x ‖ y` of a BN254 G1 point) as the signature. The registry requires the timestamp to be that scheduled time and, as for any attestation, not in the future and at most `MAX_ATTESTATION_AGE` old, and the registered verifier to accept the signature. It calls the verifier with a fixed allowance of `BEACON_VERIFY_GAS` and reverts `BeaconGasTooLow`, not `InvalidSigner`, when the transaction's gas cannot give it that allowance. The signer a catalog lists for a beacon slot is `slotSigner(recipe)`, an identity derived from the registration and not a key, and `scheduleCatalog` accepts no other. Any round scheduled within the 240 seconds before the publication block is valid, so the committer chooses which one an epoch commits, and one round can serve several consecutive epochs: their epoch hashes differ, but their data hashes and signatures can repeat, so key an epoch on its epoch ID or epoch hash, never on its round. Otherwise an epoch works as before: the anchor selects the slot, and every request's target is a block after publication.
|
|
358
|
+
|
|
359
|
+
Replay needs the registration in the epoch's recipe book. `readEpochRecipes(provider, registry, ids)` reads `beaconOf` for every recipe whose canonical request names drand and adds it as `beacon` (`verifier`, `chainHash`, `publicKey`, `genesis`, `period`). With `{ blockTag }` it reads recipes and registrations as of that block, for a registry that has since moved to an implementation without `beaconOf`; that needs an RPC that serves the state of past blocks. `replayEpochCommitment` then checks a beacon epoch's round number against the template, its timestamp against the schedule, its signature with `verifyBeaconRound` and the catalog's signer against `beaconSlotSigner(beacon)`; `replayCoordinator` does the same inside a full request replay. `verifyBeaconRound(publicKey, round, signature)` is the pairing check of `D20BeaconVerifier` computed in TypeScript with `@noble/curves`; it returns false for malformed input instead of throwing. Replay does not call the registered verifier, so the verifier's address and runtime code are chain context to check against the reviewed `D20BeaconVerifier` ([Deployments](#deployments) lists its code hash).
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
import { readEpochRecipes, resolveEpochCatalog, replayEpochCommitment } from '@d20dao/vrf-sdk/epoch';
|
|
363
|
+
|
|
364
|
+
const [hash, recipes, signers] = await registry.catalogAt(epochId); // registry: ethers Contract with epochEntropyAbi
|
|
365
|
+
const recipeBook = await readEpochRecipes(provider, registryAddress, recipes.map(Number)); // adds `beacon` to a beacon recipe
|
|
366
|
+
const catalog = resolveEpochCatalog({ registry: registryAddress, chainId, firstEpochStart, recipeBook }, { hash, recipes, signers });
|
|
367
|
+
replayEpochCommitment({ catalog, epochId, record, commitTimestamp, packet }); // throws unless round, time, signature and signer check out
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The root entry exports `DRAND_EVMNET`, `BEACON_TEMPLATE`, `BEACON_DST`, `BEACON_DOMAIN`, `beaconRoundTime(registration, round)`, `beaconRoundAt(registration, timestamp)`, `encodeBeaconRound`, `decodeBeaconRound`, `beaconCanonicalRequest(chainHash)`, `beaconSlotSigner(registration)`, `beaconRoundMessage(round)` (the hash-to-curve point a round's signature signs), `verifyBeaconRound` and the `BeaconRegistration` type.
|
|
371
|
+
|
|
372
|
+
### Data templates
|
|
373
|
+
|
|
374
|
+
A template is a byte string of segments, each an opcode and its operands:
|
|
375
|
+
|
|
376
|
+
| Opcode | Segment | Operands | Matches |
|
|
377
|
+
| --- | --- | --- | --- |
|
|
378
|
+
| `0x01` | LITERAL | length n (1 to 128), then n bytes | exactly those bytes |
|
|
379
|
+
| `0x02` | HEX | n (1 to 128) | exactly n characters `0-9a-f` |
|
|
380
|
+
| `0x03` | DECIMAL | flags (0 to 3) | `0` or a nonzero digit followed by digits, then an optional fraction `.digits` when flags & 1 and an optional exponent `(e\|E)(+\|-)?digits` when flags & 2 |
|
|
381
|
+
| `0x04` | INTEGER | min, max (1 ≤ min ≤ max ≤ 128) | a nonzero digit followed by digits, min to max digits in total |
|
|
382
|
+
|
|
383
|
+
Variable segments are greedy and never backtrack. Signed data is accepted only when the segments consume it exactly, with no trailing bytes, and it is at most 128 bytes (`MAX_DATA_BYTES`). A well-formed template is at most 256 bytes (`MAX_TEMPLATE_BYTES`), contains at least one variable segment and has a shortest possible match of at most 128 bytes. Records therefore have exactly the bytes a template describes: no whitespace, extra fields, reordered keys, escaped characters, uppercase hex or other lengths.
|
|
384
|
+
|
|
385
|
+
The TypeScript helpers produce and check the same bytes as the contract:
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
import { encodeDataTemplate, decodeDataTemplate, matchesDataTemplate } from '@d20dao/vrf-sdk';
|
|
389
|
+
|
|
390
|
+
const template = encodeDataTemplate([
|
|
391
|
+
{ literal: '{"symbol":"BTCUSD","price":' }, { decimal: { fraction: true, exponent: true } },
|
|
392
|
+
{ literal: ',"size":' }, { decimal: { fraction: true, exponent: true } },
|
|
393
|
+
{ literal: ',"timestamp":' }, { integer: { minDigits: 1, maxDigits: 16 } }, { literal: '}' },
|
|
394
|
+
]); // equals BUILTIN_EPOCH_RECIPES[2].template
|
|
395
|
+
matchesDataTemplate(template, '0x' + Buffer.from('{"symbol":"BTCUSD","price":117000.5,"size":0.01,"timestamp":1789503538000}').toString('hex')); // true
|
|
396
|
+
decodeDataTemplate(template)[1]; // { decimal: { fraction: true, exponent: true } }
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
`encodeDataTemplate` throws with the rule a template breaks; `isValidDataTemplate` and `validateDataTemplate` check an encoded template. The ANU record `{"success":true,"type":"hex8","length":"4","data":["<16 hex>","<16 hex>","<16 hex>","<16 hex>"]}` is the literal `{"success":true,"type":"hex8","length":"4","data":["`, then HEX 16 and the literal `","` alternating, and the literal `"]}`.
|
|
344
400
|
|
|
345
401
|
## Security and trust
|
|
346
402
|
|
|
@@ -349,39 +405,42 @@ The contracts have not had an external security audit. The coordinator source ca
|
|
|
349
405
|
Trust model:
|
|
350
406
|
|
|
351
407
|
- **Owner.** On Arc Mainnet both service proxies are owned by the DAO treasury Safe `0xB57f656149749eff6b496dF090336491f977E744`, which is also the fee recipient; each manifest records the owner for its network. The owner can upgrade either implementation, which can change any behavior. Ownership moves only through a two-step transfer, and `renounceOwnership` reverts.
|
|
352
|
-
- **Owner settings without an upgrade.** Coordinator: fee recipient, keeper share (0–100%), pricing within the bounds in [Pricing](#pricing), and the refund ratio for future requests (50–100%). Registry: committer and
|
|
408
|
+
- **Owner settings without an upgrade.** Coordinator: fee recipient, keeper share (0–100%), pricing within the bounds in [Pricing](#pricing), and the refund ratio for future requests (50–100%). Registry: committer, up to four backup committers, recipe and beacon registration (append-only) and catalogs for epochs at least two ahead. Open requests keep their escrowed fee and refund ratio.
|
|
409
|
+
- **Publishers.** The committer and each backup committer can publish an epoch from any valid record of its selected source, a signed record or a beacon round, under the same rules. A backup committer has no other role, and earns the keeper share of the requests whose proofs it submits itself.
|
|
410
|
+
- **Recipes.** A signature establishes what a provider's gateway signed for a recipe's request, not that the upstream value is unbiased. The owner decides which recipes and signers future epochs use; a lax template accepts more signed records for a publisher to choose from.
|
|
411
|
+
- **Beacons.** The registry trusts the yes or no that a registered verifier gives within its gas allowance, and `registerBeacon` accepts any contract as verifier. It does not check that the chain hash names the network of the key or that the verifier's code is the reviewed one: the owner vouches for a registration, which `BeaconRegistered` publishes. `D20BeaconVerifier` is stateless, has no owner and is not behind a proxy, so its behavior cannot change after registration; a different verifier is a different registration and so a different `slotSigner`. A beacon signature shows what the beacon's key signed for a round, not that the beacon is unbiased or independent of its operators, and the committer chooses which round, among those scheduled within `MAX_ATTESTATION_AGE` of the publication block, an epoch commits.
|
|
353
412
|
- **Keeper.** The VRF key holder can withhold a proof but cannot substitute a different result for a request's fixed seed. A request that is not served within 60 seconds is refundable at its snapshotted ratio.
|
|
354
413
|
|
|
355
|
-
|
|
414
|
+
Both proxies are atomically initialized ERC1967 endpoints with owner-authorized UUPS upgrades, and the implementations behind them are locked against initialization. No setter rewrites a request, a published epoch or the VRF key, but upgrade authority can change code and is an explicit trust assumption. Stable proxy addresses alone do not identify executed code, so verify the implementation history of **both** proxies at the relevant receipts when you integrate, and again whenever the deployment manifest records an upgrade or a proxy emits `Upgraded(implementation)`. A mismatch with the manifest means stop and review before sending more requests.
|
|
356
415
|
|
|
357
416
|
## Use locally
|
|
358
417
|
|
|
359
|
-
For SDK development, run `npm ci` and `npm test` from this repository. The test builds, checks that `API.md` matches the reference that `scripts/api-reference.mjs` generates from the built ABIs and the curated `scripts/api-descriptions.mjs` (regenerate with `npm run build && npm run api-reference`), packs and installs a real tarball in an isolated consumer, replays the recipe fixtures, type-checks a strict consumer, exercises `quoteRequestFee` against a mock provider and compiles the
|
|
418
|
+
For SDK development, run `npm ci` and `npm test` from this repository. The test builds, checks that `API.md` matches the reference that `scripts/api-reference.mjs` generates from the built ABIs and the curated `scripts/api-descriptions.mjs` (regenerate with `npm run build && npm run api-reference`), packs and installs a real tarball in an isolated consumer, replays the recipe fixtures and real Arc epochs (signed records and drand beacon epochs of both networks, with tampered-signature, wrong-round, wrong-signer and missing-registration cases), type-checks a strict consumer, exercises `quoteRequestFee` against a mock provider, and compiles all three examples from the installed package and checks the outcomes they publish. `npm pack` also produces an installable local artifact.
|
|
360
419
|
|
|
361
420
|
```js
|
|
362
421
|
import { builtins, mapRandomness, replayCoordinator, quoteRequestFee } from '@d20dao/vrf-sdk';
|
|
363
|
-
import { coordinatorAbi, epochEntropyAbi } from '@d20dao/vrf-sdk/abi';
|
|
422
|
+
import { coordinatorAbi, epochEntropyAbi, beaconVerifierAbi } from '@d20dao/vrf-sdk/abi';
|
|
364
423
|
const mapping = builtins.d20();
|
|
365
424
|
// Use only an independently verified accepted word for real outcomes.
|
|
366
425
|
```
|
|
367
426
|
|
|
368
|
-
The root exports ESM and TypeScript declarations, including `quoteRequestFee`, `DEFAULT_FEE_BUFFER_BPS` and the `FeeQuote`, `FeeQuoteOptions` and `FeeQuoteProvider` types; `/epoch` exports epoch helpers and `MAX_ATTESTATION_AGE
|
|
369
|
-
|
|
370
|
-
## Operational and release boundary
|
|
427
|
+
The root exports ESM and TypeScript declarations, including `quoteRequestFee`, `DEFAULT_FEE_BUFFER_BPS` and the `FeeQuote`, `FeeQuoteOptions` and `FeeQuoteProvider` types; `/epoch` exports epoch helpers, `BUILTIN_EPOCH_RECIPES`, `readEpochRecipes`, `resolveEpochCatalog` and `MAX_ATTESTATION_AGE`, and the root also exports the data-template helpers (`encodeDataTemplate`, `decodeDataTemplate`, `matchesDataTemplate`, `isValidDataTemplate`, `validateDataTemplate`), the passthrough-recipe helpers (`PASSTHROUGH_EPOCH_REQUESTS`, `passthroughEpochRecipe`, `canonicalPassthroughRequest`, `parsePassthroughRequest`, `passthroughUrl`) and the beacon helpers ([Beacon epochs](#beacon-epochs)). `/abi` exports `coordinatorAbi`, `epochEntropyAbi` and `beaconVerifierAbi`, with JSON forms `D20VRFCoordinator.json`, `EpochEntropy.json` and `D20BeaconVerifier.json`, all described in the installed `API.md` (`@d20dao/vrf-sdk/API.md`). The service implementations have locked empty constructors and explicit initializers. Registry initialization takes `address[4]`; it is not a four-address constructor deployment.
|
|
371
428
|
|
|
372
|
-
|
|
429
|
+
## What this package is not
|
|
373
430
|
|
|
374
|
-
|
|
431
|
+
This SDK contains no keeper service, API fetching, proof generation, signer secrets or deployment automation. Installing it neither authorizes nor performs anything on chain.
|
|
375
432
|
|
|
376
|
-
The
|
|
433
|
+
The protocol source is this repository's [`protocol/`](https://github.com/d20dao/d20-sdk/tree/main/protocol) folder, a byte-for-byte copy of the keeper source at the commit `PROTOCOL-PROVENANCE.json` names; every build checks each file against that file's SHA-256 list. The public keeper repository, [d20dao/keeper](https://github.com/d20dao/keeper), publishes release snapshots, and that commit is ahead of its latest release. `src/fees.ts` (the fee-quoting helper) is SDK-owned rather than vendored; BUILD-MANIFEST.json records it under `packageSources` next to the protocol source, dependency-lock and imported OpenZeppelin hashes. The UUPS build uses OpenZeppelin contracts and contracts-upgradeable 5.6.1. Consumer source is copied exactly; service implementations, test fixtures and provers are excluded from the tarball.
|
|
377
434
|
|
|
378
|
-
|
|
435
|
+
Each replay fixture set records how it was produced, so a real capture is never mistaken for a test signature. The Arc sets are accepted requests recorded from the public RPCs with `scripts/record-live-fixture.mjs`: signed-record and drand beacon epochs of both networks. Real drand rounds with hash-to-curve points computed by another library check the beacon code independently. Fixtures are not included in the package. The browser-target bundle is executed under Node, not in an actual browser; independently trusted chain context is still required for real verification.
|
|
379
436
|
|
|
380
|
-
SDK installation provides consumer and verification tooling. Chain availability, provider quotas, upgrade administration and application settlement remain separate concerns
|
|
437
|
+
SDK installation provides consumer and verification tooling. Chain availability, provider quotas, upgrade administration and application settlement remain separate concerns, and none of them guarantees a particular request's timely fulfillment.
|
|
381
438
|
|
|
382
439
|
## Deployments
|
|
383
440
|
|
|
384
|
-
|
|
441
|
+
Both networks run the recipe registry in `EpochEntropy` and the coordinator that pays the keeper share to the authorized wallet which submitted the accepted proof, behind the same proxy addresses as before. Since 2026-09-22 the coordinator also budgets every served member's callback gas before `fulfillRandomnessBatch` reveals any result, and `setPricing` rejects a zero minimum fee with a zero multiplier. The registry implementation also carries beacon recipes (`registerBeacon`, `beaconOf`, `slotSigner`, `verifyBeacon`), whose drand rounds the stateless `D20BeaconVerifier` checks. It is live behind the Arc Testnet proxy since block 64712965 (transaction `0x751797af884715214dd4a4e339dbd3355c935b64afe7a6b1eaf38e7e18895140`) and behind the Arc Mainnet proxy since block 23724929 (transaction `0x5a7a2fa8f15eefccee99f6bd363ce7717e65c5571c8c76396337fd8a1261a7cb`), so the two chains run the same implementation addresses and the same verifier. Their ABIs and storage layouts are those of the source in `protocol/`. Code hashes (`keccak256` of the runtime code): registry implementation `0xb5e125e3b0f63ffe516d781266c1cbacebe3d131148b69f8736d3ca78bcddb12`, coordinator implementation `0x3dda400d8360d7e03b8dacd8ba1ffad7ad672e07628bee7de4542d754dd5348c`, beacon verifier `0x4388250d26298224c4a39d030263550655390ca831ab44c4124f8b0be1f65351`. Epochs published before an upgrade still replay with this SDK: built-in recipes with their built-in definitions, later recipes with the definitions read from the registry.
|
|
442
|
+
|
|
443
|
+
Obtain proxy addresses, implementation addresses and independently checked code hashes from the public deployment manifests, [arc-mainnet.json](https://d20dao.org/deployments/arc-mainnet.json) and [arc-testnet.json](https://d20dao.org/deployments/arc-testnet.json). The addresses below are copied from them and are only valid together with the manifest revision they came from, because implementations move through owner-authorized upgrades. Before relying on this SDK's interface, check that the implementation at your chain's proxy matches the manifest entry.
|
|
385
444
|
|
|
386
445
|
### Arc Mainnet
|
|
387
446
|
|
|
@@ -392,11 +451,12 @@ Chain ID: **5042**. The live service; use the **coordinator proxy** when constru
|
|
|
392
451
|
| D20VRFCoordinator | Consumer entry point / proxy | [`0xd20da057469C45928912d983F45790C41e290571`](https://explorer.arc.io/address/0xd20da057469C45928912d983F45790C41e290571) |
|
|
393
452
|
| EpochEntropy | Epoch registry / proxy | [`0xd20Da048C1A68fa3Bc0B5f5Bc454D1530062C82D`](https://explorer.arc.io/address/0xd20Da048C1A68fa3Bc0B5f5Bc454D1530062C82D) |
|
|
394
453
|
| D20CostClient | Restricted cost client / proxy | [`0xD20da0048aED2BBb9f0e7078Bc452815D626D29d`](https://explorer.arc.io/address/0xD20da0048aED2BBb9f0e7078Bc452815D626D29d) |
|
|
395
|
-
| D20VRFCoordinator | Implementation | [`
|
|
396
|
-
| EpochEntropy | Implementation | [`
|
|
454
|
+
| D20VRFCoordinator | Implementation | [`0xD20da000125643B4db5A6A36A3b853c17745DF44`](https://explorer.arc.io/address/0xD20da000125643B4db5A6A36A3b853c17745DF44) |
|
|
455
|
+
| EpochEntropy | Implementation | [`0xD20dA0853a6f894c0cdc9018fD4F8F67Eac15704`](https://explorer.arc.io/address/0xD20dA0853a6f894c0cdc9018fD4F8F67Eac15704) |
|
|
456
|
+
| D20BeaconVerifier | Beacon verifier, stateless, not behind a proxy | [`0xd20dA01Aa16AeD6b77Cd8DDb869151802599100a`](https://explorer.arc.io/address/0xd20dA01Aa16AeD6b77Cd8DDb869151802599100a) |
|
|
397
457
|
| D20CostClient | Implementation | [`0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b`](https://explorer.arc.io/address/0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b) |
|
|
398
458
|
|
|
399
|
-
Addresses are copied from the [Arc Mainnet deployment manifest](https://d20dao.org/deployments/arc-mainnet.json).
|
|
459
|
+
Addresses are copied from the [Arc Mainnet deployment manifest](https://d20dao.org/deployments/arc-mainnet.json). The EpochEntropy implementation above is live behind the proxy from block 23724929 (transaction `0x5a7a2fa8f15eefccee99f6bd363ce7717e65c5571c8c76396337fd8a1261a7cb`); before it the proxy ran `0xd20dA048C969e5aDcC703Dfdf8220cc9dCB2f865`.
|
|
400
460
|
|
|
401
461
|
### Arc Testnet
|
|
402
462
|
|
|
@@ -407,8 +467,9 @@ Chain ID: **5042002**. For development and testing. Use the **coordinator proxy*
|
|
|
407
467
|
| D20VRFCoordinator | Consumer entry point / proxy | [`0xd20DA0FF9087d053f0291524Eac12abA1ADBd945`](https://testnet.arcscan.app/address/0xd20DA0FF9087d053f0291524Eac12abA1ADBd945) |
|
|
408
468
|
| EpochEntropy | Epoch registry / proxy | [`0xD20Da00B47A7cD2211dC4683E306913b05903756`](https://testnet.arcscan.app/address/0xD20Da00B47A7cD2211dC4683E306913b05903756) |
|
|
409
469
|
| D20CostClient | Restricted cost client / proxy | [`0xD20da026090B8472579a2B93030F1fC4c94807F1`](https://testnet.arcscan.app/address/0xD20da026090B8472579a2B93030F1fC4c94807F1) |
|
|
410
|
-
| D20VRFCoordinator | Implementation | [`
|
|
411
|
-
| EpochEntropy | Implementation | [`
|
|
470
|
+
| D20VRFCoordinator | Implementation | [`0xD20da000125643B4db5A6A36A3b853c17745DF44`](https://testnet.arcscan.app/address/0xD20da000125643B4db5A6A36A3b853c17745DF44) |
|
|
471
|
+
| EpochEntropy | Implementation | [`0xD20dA0853a6f894c0cdc9018fD4F8F67Eac15704`](https://testnet.arcscan.app/address/0xD20dA0853a6f894c0cdc9018fD4F8F67Eac15704) |
|
|
472
|
+
| D20BeaconVerifier | Beacon verifier, stateless, not behind a proxy | [`0xd20dA01Aa16AeD6b77Cd8DDb869151802599100a`](https://testnet.arcscan.app/address/0xd20dA01Aa16AeD6b77Cd8DDb869151802599100a) |
|
|
412
473
|
| D20CostClient | Implementation | [`0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b`](https://testnet.arcscan.app/address/0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b) |
|
|
413
474
|
|
|
414
|
-
Addresses are copied from the [Arc Testnet deployment manifest](https://d20dao.org/deployments/arc-testnet.json).
|
|
475
|
+
Addresses are copied from the [Arc Testnet deployment manifest](https://d20dao.org/deployments/arc-testnet.json). The EpochEntropy implementation above is live behind the proxy from block 64712965; before it the proxy ran `0xd20dA048C969e5aDcC703Dfdf8220cc9dCB2f865`. Explorer links identify addresses; they do not assert explorer source-code verification. The cost client is internal tooling, not a shared application entry point.
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# Third-party attribution
|
|
2
2
|
|
|
3
|
-
The redistributed consumer Solidity files and public TypeScript helpers are from this repository under its MIT LICENSE: the protocol sources are
|
|
3
|
+
The redistributed consumer Solidity files and public TypeScript helpers are from this repository under its MIT LICENSE: the protocol sources are copied byte for byte from the keeper source at the commit recorded in PROTOCOL-PROVENANCE.json, and the fee-quoting helper (`src/fees.ts`) is maintained here. The coordinator, registry and beacon verifier ABIs are generated, not hand-maintained. The coordinator implementation, the beacon verifier and the Chainlink Solidity verifier are not distributed as SDK runtime or import sources.
|
|
4
4
|
|
|
5
5
|
Public proof verification implements compatibility with the pinned Chainlink secp256k1/Keccak construction. Its upstream provenance and full preserved root license are included in `notices/PROVENANCE.md` and `notices/CHAINLINK-LICENSE`. The provenance path describes the original repository, not a bundled verifier. This is not a Chainlink service or an extension of an upstream audit.
|
|
6
6
|
|
|
7
|
+
Beacon verification implements, in TypeScript, the hash-to-curve and point checks of the kevincharm/bls-bn254 library (MIT), on which the `D20BeaconVerifier` contract is built. Its upstream provenance is in `notices/PROVENANCE.md` and its preserved MIT license in `notices/BLS-BN254-LICENSE`; the library source is not distributed as SDK runtime or import source. This is not a drand or League of Entropy service or an extension of any upstream review.
|
|
8
|
+
|
|
7
9
|
`ethers` and `@noble/curves` are runtime npm dependencies, not copied/bundled source. Their distributions carry their own licenses and transitive dependency notices. Build-only Solidity compilation uses OpenZeppelin 5.6.1 and solc 0.8.28; neither is bundled into the public JavaScript.
|
|
8
10
|
|
|
9
11
|
The repository and isolated smoke consumer override solc's build-only `tmp` dependency to 0.2.7 to address GHSA-52f5-9888-hmc6, GHSA-ph9p-34f9-6g65 and GHSA-7c78-jf6q-g5cm while preserving compiler 0.8.28. npm overrides apply only at a project's root; this does not impose an override on SDK consumers, and solc is not an SDK runtime dependency.
|