@d20dao/vrf-sdk 0.3.2 → 0.3.4

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.
@@ -26,7 +26,7 @@
26
26
  "contracts/D20Proxy.sol": "83dcef3b72e2a0a4809c2d8df0d083d2a0f659fe2b3b01fadb1b2eaf8afb7286"
27
27
  }
28
28
  },
29
- "packageLockSha256": "c79d88d461128cdb8da152c6562e432979cb15c393ca026d9e8268a6859671f8",
29
+ "packageLockSha256": "98de32be2772a48ec81c52e1b3ae8221b14e47a133d29b9ac9f6dca361b49668",
30
30
  "buildDependencies": {
31
31
  "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol": "41a3040398d53999dea3251ff8906e11ec1a699362a1d8f4a55bfc7709cc00f3",
32
32
  "@openzeppelin/contracts/utils/ReentrancyGuard.sol": "94e409e8f6e3184236651a6cc2b1a6a3ea0f0a25eb85b71e524ad5791bb2fbc8",
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # d20dao VRF SDK
2
2
 
3
- Public replay, mapping, epoch evidence, off-chain fee quoting and Solidity consumer helpers for a general randomness service. Package: `@d20dao/vrf-sdk` `0.3.2`.
3
+ Public replay, mapping, epoch evidence, off-chain fee quoting and Solidity consumer helpers for a general randomness service. Package: `@d20dao/vrf-sdk` `0.3.4`.
4
4
 
5
5
  ## Getting started
6
6
 
@@ -8,9 +8,114 @@ Public replay, mapping, epoch evidence, off-chain fee quoting and Solidity consu
8
8
  npm install @d20dao/vrf-sdk
9
9
  ```
10
10
 
11
- Use Node 22.13 or newer and Solidity 0.8.28. Configure the coordinator proxy explicitly from the keeper's deployment manifest for the chain you use (Arc Testnet is chain 5042002; see [Deployments](#deployments)); there is no implicit network default. Any consumer contract can request randomness by paying at least the fee quoted for its transaction, without allowlisting. Requests must come from a contract; a wallet or backend pays through its own consumer contract.
11
+ Use `@d20dao/vrf-sdk` 0.3.4 or newer, Node 22.13 or newer and Solidity 0.8.28 with EVM version `cancun`. The service is live on Arc Mainnet (chain 5042); use Arc Testnet (chain 5042002) for development (see [Networks](#networks)). Configure the coordinator proxy explicitly from the public deployment manifest for the chain you use (see [Deployments](#deployments)); there is no implicit network default. Any consumer contract can request randomness by paying at least the fee quoted for its transaction, without allowlisting. Requests must come from a contract; a wallet or backend pays through its own consumer contract.
12
12
 
13
- For agent-assisted integration, give your agent the installed `AGENTS.md` and `PROTOCOL-PROVENANCE.json`, plus the [integration skills](https://github.com/d20dao/skills). Website guides include Getting started, Copy prompt, `/llms.txt`, `/llms-full.txt` and `/agents.md`.
13
+ 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).
14
+
15
+ ## Quick path
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.
25
+
26
+ Two pairs of names are easy to confuse:
27
+
28
+ - `refundBps()` is the current refund ratio, copied into each new request; `requestRefundBps(requestId)` is the ratio one request copied at creation and is refunded at. Likewise `pricing()` and `quoteFee` price future requests, while `requestFeePaid(requestId)` is what one request escrowed. `keeperFeeBps()` has no per-request copy: it is read when a proof is accepted.
29
+ - `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" means a randomness mapping, not a Solidity `mapping`.
30
+
31
+ ## Networks
32
+
33
+ | | Arc Mainnet | Arc Testnet |
34
+ | --- | --- | --- |
35
+ | Use | Live service, real USDC | Development and testing |
36
+ | Chain ID | `5042` | `5042002` |
37
+ | RPC | `https://rpc.mainnet.arc.io` | `https://rpc.testnet.arc.io` |
38
+ | Block explorer | https://explorer.arc.io | https://testnet.arcscan.app |
39
+ | D20DAO explorer | https://arc.d20dao.org | https://arc-testnet.d20dao.org |
40
+ | Deployment manifest | https://d20dao.org/deployments/arc-mainnet.json | https://d20dao.org/deployments/arc-testnet.json |
41
+
42
+ The native gas token on both networks is USDC with 18 decimals (`1e18` wei is 1 USDC); request fees are paid in it. Test USDC comes from the faucet linked in Arc's [Connect to Arc](https://docs.arc.io/arc/references/connect-to-arc) reference, which also lists alternative RPC providers. Compile with solc 0.8.28 and `evmVersion` `cancun`, the settings this SDK builds and tests with. The manifests record proxy and implementation addresses, implementation code hashes, owner, initialized pricing and deployment receipts. The [D20DAO explorer](https://d20dao.org/explorer) shows and replays requests on both networks.
43
+
44
+ ## Integrate a consumer
45
+
46
+ Solidity imports require compiler 0.8.28 and your compiler's npm resolver:
47
+
48
+ - `@d20dao/vrf-sdk/contracts/D20VRFConsumer.sol`
49
+ - `@d20dao/vrf-sdk/contracts/interfaces/ID20VRF.sol`
50
+ - `@d20dao/vrf-sdk/contracts/libraries/D20VRFRequests.sol`
51
+ - `@d20dao/vrf-sdk/contracts/libraries/RandomnessMapping.sol`
52
+ - `@d20dao/vrf-sdk/contracts/examples/MiningRandomnessConsumer.sol`
53
+
54
+ These sources import only each other; no OpenZeppelin installation is needed for a consumer.
55
+
56
+ `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
+
58
+ A complete consumer following the recommended payment pattern is shown in [Recommended payment pattern](#recommended-payment-pattern).
59
+
60
+ ### Compiler setup
61
+
62
+ Hardhat resolves `@d20dao/vrf-sdk/...` imports from `node_modules` without remappings:
63
+
64
+ ```ts
65
+ // hardhat.config.ts
66
+ export default {
67
+ solidity: {
68
+ version: "0.8.28",
69
+ settings: { evmVersion: "cancun", optimizer: { enabled: true, runs: 200 } },
70
+ },
71
+ };
72
+ ```
73
+
74
+ Foundry: run `npm install @d20dao/vrf-sdk` in the project root and map the import prefix to `node_modules`:
75
+
76
+ ```toml
77
+ # foundry.toml
78
+ [profile.default]
79
+ src = "src"
80
+ solc_version = "0.8.28"
81
+ evm_version = "cancun"
82
+ remappings = ["@d20dao/vrf-sdk/=node_modules/@d20dao/vrf-sdk/"]
83
+ ```
84
+
85
+ With either tool, `import {D20VRFConsumer} from "@d20dao/vrf-sdk/contracts/D20VRFConsumer.sol";` then compiles unchanged.
86
+
87
+ ### Examples
88
+
89
+ - `examples/DiceConsumer.sol` (installed as `@d20dao/vrf-sdk/examples/DiceConsumer.sol`) is one concrete consumer example for a player-paid request. It forwards the player's `msg.value` to `requestMappedRandomness`, so the coordinator escrows the exact same-transaction quote, credits any excess to the player as the fixed refund address and reverts underpayment with `IncorrectFee`. It stores the authenticated raw callback word; its mapped result is 1 through 20. Mapped callbacks still carry raw bytes32. It marks refunded rolls in `_onRefund`. Keep application actions separate from callbacks; the example does not implement application-payment refunds, claim locking or minting.
90
+ - `contracts/examples/MiningRandomnessConsumer.sol` (in the installed package; source in this repository's `protocol/contracts/examples/`) is an abstract building block. It pays `quoteFee` from the contract's own balance, requests raw randomness with `clientSeed = keccak256(abi.encode(claimId, lockedWork))`, maps each request to one claim, rejects unknown or repeated callbacks and derives three candidate seeds from the stored word. The application still validates work and locks payment before calling `_requestForClaim`.
91
+ - [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 that pay the exact quote and return change, with refund notification and refund-credit withdrawal.
93
+
94
+ ### Client seed
95
+
96
+ `clientSeed` does not need to be unique or secret. The coordinator derives each request's VRF input from the chain ID, coordinator address, key hash, request ID, consumer, client seed, mapping hash, request block, target block, target block hash, epoch ID and epoch hash. The request ID increments for every request, so two requests with the same client seed still have different inputs. Use the seed to bind application context into the request and its VRF seed, for example `keccak256(abi.encode(msg.sender, operationId))` or a hash that commits to an ordered item list before a choice or shuffle. It is emitted in `RandomnessRequested`; it is neither an entropy source nor private.
97
+
98
+ ### Callback gas limit
99
+
100
+ `callbackGasLimit` must be between 30,000 and 1,000,000 gas (`MIN_CALLBACK_GAS`, `MAX_CALLBACK_GAS`); other values revert with `InvalidCallbackGas`. The coordinator calls `rawFulfillRandomness(requestId, randomness)` with exactly that much gas, and the fee grows with it (see [Pricing](#pricing)). If the callback reverts or runs out of gas, the request is still served and paid (`CallbackAttempted(requestId, false, gasLimit)`, `delivered` stays false); anyone can call `retryCallback(requestId, gasLimit)` with a limit no lower than the original and at most 1,000,000. Keep the callback to authentication, a request check and a few storage writes (a new storage slot costs about 22,100 gas); the examples use 100,000. As a reference, a consumer that stores the word, the fulfillment block and time and two index entries per result measured about 51,000 gas per callback once its slots were in use and about 105,000 gas for its first result, when every slot was new. Measure your own callback with `eth_estimateGas` or a local test, add a margin, and remember that the fee grows with the limit. Computing a large mapping, such as a 256-item shuffle, inside the callback needs much more.
101
+
102
+ ## Randomness options
103
+
104
+ Every option is a `RandomnessMapping.Spec` `(operation, lower, upper, count, population)`. In Solidity, `D20VRFRequests` builds and pays for it: `using D20VRFRequests for ID20VRF;` with `o = D20VRFRequests.Options(clientSeed, callbackGasLimit, refundAddress)`. Each helper returns the request ID and pays `quoteFee(o.callbackGasLimit)` from the calling contract's balance. Without the library, pass the spec to `requestMappedRandomness(clientSeed, callbackGasLimit, refundAddress, spec)`. In TypeScript, `builtins` returns the same spec for `mapRandomness(word, spec)` and `hashMapping(spec)`.
105
+
106
+ | Option | Solidity (`D20VRFRequests`) | TypeScript (`builtins`) | Spec | Valid parameters | Result |
107
+ | --- | --- | --- | --- | --- | --- |
108
+ | Raw word | none: `requestRandomness(clientSeed, callbackGasLimit, refundAddress)` | `raw()` | `(0 Raw, 0, 0, 0, 0)` | none | `[uint256(word)]` |
109
+ | Dice | `rng.diceRoll(sides, count, o)` | `diceRoll(sides, count = 1)` | `(1 DiceRoll, 0, sides, count, 0)` | sides ≥ 2; count 1–128 | `count` values, each 1–sides; repeats possible |
110
+ | Custom die | `rng.dN(sides, o)` | `dN(sides)` | `(1 DiceRoll, 0, sides, 1, 0)` | sides ≥ 2 | one value, 1–sides |
111
+ | Dice presets | `rng.d4(o)`, `d6`, `d8`, `d10`, `d12`, `d20` | `d4()`, `d6()`, `d8()`, `d10()`, `d12()`, `d20()` | `(1 DiceRoll, 0, N, 1, 0)` | none | one value, 1–N |
112
+ | Coin flip | `rng.coinFlip(o)` | `coinFlip()` | `(2 CoinFlip, 0, 0, 1, 0)` | none | one value: 0 tails, 1 heads |
113
+ | Number range | `rng.numberRange(min, max, o)` | `numberRange(min, max)` | `(3 NumberRange, min, max, 1, 0)` | min ≤ max, any uint256; equal endpoints and the full 0 to 2^256−1 range allowed | one value in [min, max] |
114
+ | Choose one | `rng.chooseOne(population, o)` | `chooseOne(size)` | `(4 ChooseOne, 0, 0, 1, population)` | population 1–256 | one index, 0 to population−1 |
115
+ | Choose many | `rng.chooseMany(population, count, o)` | `chooseMany(size, count)` | `(5 ChooseMany, 0, 0, count, population)` | population 1–256; count 1 to population | `count` distinct indices, without replacement |
116
+ | Shuffle | `rng.shuffle(population, o)` | `shuffle(size)` | `(6 Shuffle, 0, 0, population, population)` | population 1–256 | every index 0 to population−1 exactly once |
117
+
118
+ 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.
14
119
 
15
120
  ## Pricing
16
121
 
@@ -20,9 +125,9 @@ The coordinator prices every request from the base fee of the transaction that c
20
125
  fee = max(minFee, feeMultiplier × baseFee × (fulfillGasOverhead + callbackGasLimit))
21
126
  ```
22
127
 
23
- `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. Initialization sets multiplier 5 and overhead 300,000; the keeper's deployment configuration (`config/service.json`) sets a 0.08 USDC minimum fee and a 50% keeper share (`keeperFeeBps` 5000). 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.
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. These are initialization values, not fixed prices: 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.
24
129
 
25
- Labelled examples with multiplier 5, overhead 300,000 and a 0.08 USDC minimum:
130
+ Labelled examples with the initialization values (multiplier 5, overhead 300,000 gas, 0.08 USDC minimum fee):
26
131
 
27
132
  - **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.
28
133
  - **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.
@@ -45,7 +150,7 @@ requestId = rng.requestRandomness{value: fee}(clientSeed, callbackGasLimit, refu
45
150
 
46
151
  ### Wallets and backends that pay through a consumer
47
152
 
48
- Do not call `quoteFee` through `eth_call`: it prices with `block.basefee`, which `eth_call` commonly reports as 0 (verified on Arc mainnet), so the answer collapses to `minFee` and the real transaction reverts with `IncorrectFee`. Quote with `quoteFeeAt(callbackGasLimit, latestBlock.baseFeePerGas)`, add a buffer for base-fee movement until inclusion, and forward the whole amount; the consumer example below does exactly that. The SDK helper wraps this for ethers 6:
153
+ Do not call `quoteFee` through `eth_call`: it prices with `block.basefee`, which `eth_call` commonly reports as 0 (verified on Arc mainnet), so the answer collapses to `minFee` and the real transaction reverts with `IncorrectFee`. Quote with `quoteFeeAt(callbackGasLimit, latestBlock.baseFeePerGas)`, add a buffer for base-fee movement until inclusion, and send that amount to your consumer. The SDK helper wraps this for ethers 6:
49
154
 
50
155
  ```js
51
156
  import { quoteRequestFee } from '@d20dao/vrf-sdk';
@@ -54,7 +159,138 @@ const { fee, value, baseFee } = await quoteRequestFee(provider, coordinator, 100
54
159
  await dice.roll(clientSeed, 100_000, { value });
55
160
  ```
56
161
 
57
- `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 credits 0.1056 USDC to the refund address. 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.
162
+ `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
+
164
+ ### Recommended payment pattern
165
+
166
+ For a consumer whose users pay per request, pay the exact quote and return the change in the same transaction: read `fee = quoteFee(callbackGasLimit)`, require `msg.value >= fee`, send exactly `fee` to the coordinator and return `msg.value - fee` to the caller. Use the paying user as the refund address when it can receive a native transfer or call `withdrawRefundCredit` (any wallet can), so an expiry refund goes straight back to the payer. The front end sends `value` from `quoteRequestFee`; the unused buffer comes back immediately, nothing accumulates as refund credit and the contract holds no user funds.
167
+
168
+ ```solidity
169
+ // SPDX-License-Identifier: MIT
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
+ ```
215
+
216
+ The other patterns behave as follows:
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.
220
+
221
+ ## Reading results
222
+
223
+ All events come from the coordinator proxy, with `requestId` as the first indexed topic:
224
+
225
+ | Event | Meaning |
226
+ | --- | --- |
227
+ | `RandomnessRequested(requestId, consumer, keyHash, clientSeed, requestBlock, callbackGasLimit, feePaid, refundAddress, deadline)` | Request created. Read `requestId` from this log in the request receipt. |
228
+ | `MappingRequested(requestId, mappingHash, spec)` | Mapping stored with the request (Raw for `requestRandomness`). |
229
+ | `RandomnessFulfilled(requestId, randomness, submitter)` | Proof accepted; `randomness` is final. |
230
+ | `CallbackAttempted(requestId, success, gasLimit)` | Result of calling `rawFulfillRandomness`, at fulfillment and at each `retryCallback`. |
231
+ | `RequestRefundedTo(requestId, refundAddress, amount, paid)` | Expired request refunded; `paid` false means the amount became refund credit. |
232
+ | `RefundCallbackAttempted(requestId, consumer, success, gasLimit)` | Result of the `onRefund` notification. |
233
+
234
+ Views on the coordinator (all in `coordinatorAbi`; only `getMappedResult` is part of `ID20VRF`, so declare a local interface in Solidity for the others):
235
+
236
+ - `getRequest(uint256 requestId) returns (Request)` with fields `consumer`, `callbackGasLimit`, `requestBlock`, `targetBlock` (0 until the epoch packet is published), `deadline` (Unix seconds, request time plus 60), `refundAddress`, `clientSeed`, `mappingHash`, `blockHash` (target block hash once stored), `randomness` (zero until fulfilled), `proofHash`, `transcriptHash`, `fulfilled`, `delivered` (callback succeeded), `refunded`, `epochId` (fixed at request time) and `epochHash` (zero until published). Reverts `UnknownRequest` for an unused ID.
237
+ - `getMapping(uint256 requestId) returns (RandomnessMapping.Spec)`: the stored `(operation, lower, upper, count, population)`; all zero (Raw) for `requestRandomness`. Reverts `UnknownRequest`.
238
+ - `getMappedResult(uint256 requestId) returns (uint256[])`: the stored word mapped with the stored spec. Reverts `NotFulfilled` before acceptance.
239
+ - `mapRandomness(bytes32 randomness, RandomnessMapping.Spec spec) returns (uint256[])`: pure mapping of any word and spec; it does not show that a request was fulfilled. The SDK's `mapRandomness(word, spec)` returns the same values off-chain.
240
+ - `requestFeePaid(requestId)`, `requestRefundBps(requestId)`, `refundCredits(address)` and `refundCallbackDelivered(requestId)` show settlement.
241
+
242
+ [API.md](API.md) documents every view, event and error, including the order of events in a receipt.
243
+
244
+ Polling with ethers 6, after sending the request through a consumer such as `D20Game`:
245
+
246
+ ```js
247
+ import { Contract } from 'ethers';
248
+ import { coordinatorAbi } from '@d20dao/vrf-sdk/abi';
249
+
250
+ const coordinator = new Contract(coordinatorAddress, coordinatorAbi, provider);
251
+ const receipt = await (await game.roll(operationId, { value })).wait();
252
+ const requestId = receipt.logs
253
+ .filter((log) => log.address.toLowerCase() === coordinatorAddress.toLowerCase())
254
+ .map((log) => coordinator.interface.parseLog(log))
255
+ .find((event) => event?.name === 'RandomnessRequested').args.requestId;
256
+
257
+ for (;;) {
258
+ // Read the block first, so a proof included up to that block is visible in getRequest.
259
+ const { timestamp } = await provider.getBlock('latest');
260
+ const request = await coordinator.getRequest(requestId);
261
+ if (request.fulfilled) { console.log(await coordinator.getMappedResult(requestId)); break; }
262
+ if (BigInt(timestamp) > request.deadline) break; // expired: refundRequest(requestId) is available
263
+ await new Promise((resolve) => setTimeout(resolve, 2000));
264
+ }
265
+ ```
266
+
267
+ `fulfilled` means the result is final; `delivered` only reports whether the consumer callback succeeded. Once the latest block timestamp is past `deadline` and `fulfilled` is false, the request can no longer be served. Reading `randomness` over RPC is not proof verification; see [Replay and verification](#replay-and-verification).
268
+
269
+ ## Frontend and backend use
270
+
271
+ - 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
+ - `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
+ - The SDK does not wrap viem or other clients; its ABIs are plain JSON and work with any library.
276
+ - To add Arc to a browser wallet, use `wallet_addEthereumChain` with the values from [Networks](#networks). The native currency uses 18 decimals:
277
+
278
+ ```js
279
+ await window.ethereum.request({
280
+ method: 'wallet_addEthereumChain',
281
+ params: [{
282
+ chainId: '0x13b2', // 5042, Arc Mainnet; Arc Testnet is '0x4cef52' (5042002)
283
+ chainName: 'Arc Mainnet',
284
+ nativeCurrency: { name: 'USDC', symbol: 'USDC', decimals: 18 },
285
+ rpcUrls: ['https://rpc.mainnet.arc.io'],
286
+ blockExplorerUrls: ['https://explorer.arc.io'],
287
+ }],
288
+ });
289
+ ```
290
+ - 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
+ - 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
+ - Observed on 2026-09-17 while deploying the demo at https://mainnet-demo.d20dao.org: every public Arc RPC endpoint is on `*.arc.io`, and common browser ad-block filter lists block that domain, so read-only pages failed with `net::ERR_BLOCKED_BY_CLIENT` for many users. Read through the connected wallet's EIP-1193 provider when there is one (check its chain ID first), or serve a same-origin read-only JSON-RPC relay; the demo's [worker/index.js](https://github.com/d20dao/randomizer-demo/blob/main/worker/index.js) forwards only read methods and leaves transactions to the wallet.
293
+ - Also observed on 2026-09-17, not a guarantee: `rpc.mainnet.arc.io` rate-limited batched JSON-RPC calls from shared Cloudflare egress addresses while `rpc.blockdaemon.mainnet.arc.io` accepted them, and the free plan of `rpc.drpc.*.arc.io` rejected batches of more than 3 calls. ethers `JsonRpcProvider` batches up to 100 calls by default; lower `batchMaxCount` in its options (`new JsonRpcProvider(url, 5042, { staticNetwork: true, batchMaxCount: 1 })`) for such endpoints. Poll no faster than you need and cache what cannot change: once `fulfilled` is true, `randomness` and the mapped result are final.
58
294
 
59
295
  ## Request lifecycle
60
296
 
@@ -64,40 +300,35 @@ A request escrows its quoted fee even when its epoch packet is not published yet
64
300
 
65
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's configured committer (never the proof 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.
66
302
 
67
- ### Expiry and refunds
303
+ ### Timing
68
304
 
69
- After the deadline passes without an accepted proof, anyone may call `refundRequest(requestId)`. It pays `feePaid × refundBps / 10000` using the ratio snapshotted into the request at creation (`requestRefundBps(requestId)`), and the remainder becomes protocol fees. The ratio defaults to 100%; the owner can lower it with `setRefundBps` (event `RefundBpsChanged`) to no less than 50%, which affects only requests created afterwards. The refund is pushed to the fixed refund address with a 30,000-gas transfer; if that fails, the amount stays as refund credit for that address (`RequestRefundedTo(requestId, refundAddress, amount, paid)`) and is withdrawn with `withdrawRefundCredit`. Gas and application payments are not part of the refund. See [Optional refund notification](#optional-refund-notification) for the consumer hook.
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. In a stress test, 200 simultaneous requests were all delivered within 36 seconds, with a median of 19 seconds; an earlier Arc Testnet run on 2026-09-16 served 68 paid requests within 2–4 chain seconds, 47 of them in batched fulfillments. 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.
70
306
 
71
- ### Batched fulfillment
307
+ ### Expiry and refunds
72
308
 
73
- The keeper may fulfill up to 16 prepared requests in one transaction with `fulfillRandomnessBatch(ids, proofs)`. Every served member runs exactly like `fulfillRandomness`: its own `BlockHashStored`, `RequestServed`, `ProofVerified`, `RandomnessFulfilled`, `FulfillmentEvidence`, `CallbackAttempted` and `KeeperFeePaid` events, settlement from its own `feePaid` and its own callback. Members already fulfilled, refunded or past their deadline are left untouched and marked with `FulfillmentSkipped(requestId, reason)` (1 fulfilled, 2 refunded, 3 past deadline); a wrong seed, invalid proof or unready member reverts the whole batch. Consumers see no difference. Indexers and verifiers must read per-request events and the request's stored state, not transaction calldata: only a single `fulfillRandomness` call is 452 bytes.
309
+ After the deadline passes without an accepted proof, anyone may call `refundRequest(requestId)`. It pays `feePaid × refundBps / 10000` using the ratio snapshotted into the request at creation (`requestRefundBps(requestId)`), and the remainder becomes protocol fees. The ratio defaults to 100%; the owner can lower it with `setRefundBps` (event `RefundBpsChanged`) to no less than 50%, which affects only requests created afterwards. The refund is pushed to the fixed refund address with a 30,000-gas transfer; if that fails, the amount stays as refund credit for that address (`RequestRefundedTo(requestId, refundAddress, amount, paid)`) and is withdrawn with `withdrawRefundCredit`. Gas and application payments are not part of the refund. See [Optional refund notification](#optional-refund-notification) for the consumer hook.
74
310
 
75
- ## Use locally
311
+ ### Gas for refund and retry calls
76
312
 
77
- For SDK development, run `npm ci` and `npm test` from this repository. The test builds, 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 Solidity sources. `npm pack` also produces an installable local artifact.
313
+ `refundRequest`, `retryCallback` and `retryRefundCallback` need no value or role, but they forward a fixed or caller-chosen amount of gas to the consumer and revert with `InsufficientCallbackGas` rather than forwarding less. The transaction gas limit must cover that amount, the coordinator's reserve, the storage work before the check, the 1/64 of gas the proxy keeps back at its `DELEGATECALL`, and intrinsic gas:
78
314
 
79
- ```js
80
- import { builtins, mapRandomness, replayCoordinator, quoteRequestFee } from '@d20dao/vrf-sdk';
81
- import { coordinatorAbi, epochEntropyAbi } from '@d20dao/vrf-sdk/abi';
82
- const mapping = builtins.d20();
83
- // Use only an independently verified accepted word for real outcomes.
84
- ```
315
+ | Call | Coordinator check before forwarding | Measured minimum transaction gas limit | Suggested gas limit |
316
+ | --- | --- | --- | --- |
317
+ | `refundRequest(requestId)` | `gasleft() ≥ 100,000 + 100,000/63 + 140,000` (241,587) after settlement, then `≥ 151,587` before `onRefund` | 302,558 to 357,517 | 400,000 |
318
+ | `retryCallback(requestId, gasLimit)` | `gasleft() ≥ gasLimit + gasLimit/63 + 140,000`; `gasLimit` 30,000–1,000,000 and not below the request's `callbackGasLimit` | about 1.032 × gasLimit + 184,300 (287,522 at 100,000; 1,216,321 at 1,000,000) | gasLimit + 250,000 |
319
+ | `retryRefundCallback(requestId, gasLimit)` | `gasleft() ≥ gasLimit + gasLimit/63 + 50,000`; `gasLimit` 100,000–1,000,000 | about 1.032 × gasLimit + 89,800 (193,038 at 100,000; 1,121,837 at 1,000,000) | gasLimit + 150,000 |
85
320
 
86
- 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`. `/abi` exports `coordinatorAbi` and `epochEntropyAbi`, with JSON forms `D20VRFCoordinator.json` and `EpochEntropy.json`. The service implementations have locked empty constructors and explicit initializers. Registry initialization takes `address[4]`; it is not a four-address constructor deployment.
321
+ The minimums were measured with the unmodified protocol sources behind `D20Proxy` on a local `cancun` EVM, with consumer hooks that consume all forwarded gas. The `refundRequest` range depends on whether the refund-credit, total-credit and earned-fee storage slots are written for the first time. `eth_estimateGas` finds these minimums because a lower limit reverts; add a margin to an estimate in case state changes before inclusion.
87
322
 
88
- ## Integrate a consumer
323
+ ### Batched fulfillment
89
324
 
90
- Solidity imports require compiler 0.8.28 and your compiler's npm resolver:
325
+ The keeper may fulfill up to 16 prepared requests in one transaction with `fulfillRandomnessBatch(ids, proofs)`. Every served member runs exactly like `fulfillRandomness`: its own `BlockHashStored` (unless `storeBlockHash` stored the hash earlier), `RequestServed`, `ProofVerified`, `RandomnessFulfilled`, `FulfillmentEvidence`, `CallbackAttempted` and `KeeperFeePaid` (when the keeper share is non-zero) events, settlement from its own `feePaid` and its own callback. Members already fulfilled, refunded or past their deadline are left untouched and marked with `FulfillmentSkipped(requestId, reason)` (1 fulfilled, 2 refunded, 3 past deadline); a wrong seed, invalid proof or unready member reverts the whole batch. Consumers see no difference. Indexers and verifiers must read per-request events and the request's stored state, not transaction calldata: only a single `fulfillRandomness` call is 452 bytes.
91
326
 
92
- - `@d20dao/vrf-sdk/contracts/D20VRFConsumer.sol`
93
- - `@d20dao/vrf-sdk/contracts/interfaces/ID20VRF.sol`
94
- - `@d20dao/vrf-sdk/contracts/libraries/D20VRFRequests.sol`
95
- - `@d20dao/vrf-sdk/contracts/libraries/RandomnessMapping.sol`
96
- - `@d20dao/vrf-sdk/contracts/examples/MiningRandomnessConsumer.sol`
327
+ ## Optional refund notification
97
328
 
98
- `examples/DiceConsumer.sol` is one concrete consumer example for a player-paid request. It forwards the player's `msg.value` to `requestMappedRandomness`, so the coordinator escrows the exact same-transaction quote, credits any excess to the player as the fixed refund address and reverts underpayment with `IncorrectFee`. It stores the authenticated raw callback word; its mapped result is 1 through 20. Mapped callbacks still carry raw bytes32. Keep application actions separate from callbacks; the example does not implement application-payment refunds, claim locking or minting.
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.
99
330
 
100
- `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.
331
+ 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.
101
332
 
102
333
  ## Replay and verification
103
334
 
@@ -107,11 +338,34 @@ Use independently trusted successful receipts and state. Decode the registry `Ep
107
338
 
108
339
  `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`.
109
340
 
110
- Signer catalogs are per epoch. The registry owner can schedule a replacement catalog with `scheduleCatalog(signers, fromEpoch)` for epochs at least two ahead (event `CatalogScheduled(fromEpoch, catalogHash, signers)`); the current and next epoch, prepared snapshots and open requests keep their signers. `catalogHashAt(epochId)` and `signersAt(epochId)` return the catalog in force for an epoch, and `Epoch.catalogHash` records it at commitment. For replay, `epoch.catalog.signers` must be that per-epoch catalog, taken from `signersAt` or the `CatalogScheduled` history, while `configuration.catalogHash` stays the initial catalog bound into the configuration hash; `replayEpochCommitment` binds the supplied signers to `record.catalogHash`. `catalogHash()` and the slot getters always return the initial catalog.
341
+ Signer catalogs are per epoch. The registry owner can schedule a replacement catalog with `scheduleCatalog(signers, fromEpoch)` for epochs at least two ahead (event `CatalogScheduled(fromEpoch, catalogHash, signers)`). A new schedule replaces a version that has not taken effect yet, which can return the next epoch to the previous catalog; the current epoch, prepared snapshots and open requests keep their signers. `catalogHashAt(epochId)` and `signersAt(epochId)` return the catalog in force for an epoch, and `Epoch.catalogHash` records it at commitment. For replay, `epoch.catalog.signers` must be that per-epoch catalog, taken from `signersAt` or the `CatalogScheduled` history without replaced versions, while `configuration.catalogHash` stays the initial catalog bound into the configuration hash; `replayEpochCommitment` binds the supplied signers to `record.catalogHash`. `catalogHash()` and the slot getters always return the initial catalog.
111
342
 
112
343
  At publication a signed attestation 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.
113
344
 
114
- D20VRFCoordinator and EpochEntropy use atomically initialized ERC1967 proxies with owner-authorized UUPS upgrades and two-step ownership transfers; `renounceOwnership` reverts on both, so upgrade authority can only move through an accepted transfer. The registry owner can change the committer and schedule future catalogs; the coordinator owner can change fee recipient, keeper share, bounded pricing and the refund ratio. No setter rewrites a request, a published epoch or the VRF key, but upgrade authority can change code and is an explicit trust assumption. Verify the implementation history of BOTH proxies at the relevant receipts; stable proxy addresses alone do not identify executed code. Operators pin the proxy code, initialized configuration and both implementation addresses/runtime hashes; the keeper fails closed on an unreviewed implementation change.
345
+ ## Security and trust
346
+
347
+ The contracts have not had an external security audit. The coordinator source carries the developer comment "Prototype: not audited or validated on Arc"; it is kept byte-for-byte because the source is part of the deployed bytecode metadata. The service is now live on Arc Mainnet, and the absence of an external audit still applies. Reusing the unmodified Chainlink VRF verifier does not extend any upstream audit to this coordinator (`notices/PROVENANCE.md`).
348
+
349
+ Trust model:
350
+
351
+ - **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 signer catalogs for epochs at least two ahead. Open requests keep their escrowed fee and refund ratio.
353
+ - **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
+
355
+ D20VRFCoordinator and EpochEntropy use atomically initialized ERC1967 proxies with owner-authorized UUPS upgrades and two-step ownership transfers; `renounceOwnership` reverts on both, so upgrade authority can only move through an accepted transfer. The registry owner can change the committer and schedule future catalogs; the coordinator owner can change fee recipient, keeper share, bounded pricing and the refund ratio. No setter rewrites a request, a published epoch or the VRF key, but upgrade authority can change code and is an explicit trust assumption. Verify the implementation history of BOTH proxies at the relevant receipts; stable proxy addresses alone do not identify executed code. In practice, check it when you integrate and again whenever the deployment manifest records an upgrade or a proxy emits `Upgraded(implementation)`; a mismatch with the manifest means you should stop and review before sending more requests. Operators pin the proxy code, initialized configuration and both implementation addresses/runtime hashes; the keeper fails closed on an unreviewed implementation change.
356
+
357
+ ## Use locally
358
+
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 Solidity sources. `npm pack` also produces an installable local artifact.
360
+
361
+ ```js
362
+ import { builtins, mapRandomness, replayCoordinator, quoteRequestFee } from '@d20dao/vrf-sdk';
363
+ import { coordinatorAbi, epochEntropyAbi } from '@d20dao/vrf-sdk/abi';
364
+ const mapping = builtins.d20();
365
+ // Use only an independently verified accepted word for real outcomes.
366
+ ```
367
+
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`. `/abi` exports `coordinatorAbi` and `epochEntropyAbi`, with JSON forms `D20VRFCoordinator.json` and `EpochEntropy.json`, both 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.
115
369
 
116
370
  ## Operational and release boundary
117
371
 
@@ -119,7 +373,7 @@ This SDK contains no keeper service, API fetching, proof generation, signer secr
119
373
 
120
374
  Optional Telegram access is disabled unless a bot token and numeric operator chat are explicitly configured. Only that chat can use read-only /status and /keeper commands. Commands never modify configuration or send transactions; notifications are best-effort observations, not chain evidence. This package neither reads bot credentials nor contacts Telegram.
121
375
 
122
- Builds use reviewed protocol Git blobs and verify every SHA-256 in PROTOCOL-PROVENANCE.json. `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, operator code, test fixtures and provers are excluded from the tarball.
376
+ The public protocol source is this repository's [`protocol/`](https://github.com/d20dao/d20-sdk/tree/main/protocol) folder. `PROTOCOL-PROVENANCE.json` names the keeper commit it was copied from and the SHA-256 of every file; that keeper repository is not public, so read the source in `protocol/`. Builds use these reviewed protocol Git blobs and verify every SHA-256 in PROTOCOL-PROVENANCE.json. `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, operator code, test fixtures and provers are excluded from the tarball.
123
377
 
124
378
  Fixture provenance distinguishes explicit CI signatures from actual API3 responses. Fixtures are not included in the package. The browser-target bundle is executed under Node, not an actual browser session; independently trusted chain context is still required for real verification.
125
379
 
@@ -127,7 +381,7 @@ SDK installation provides consumer and verification tooling. Chain availability,
127
381
 
128
382
  ## Deployments
129
383
 
130
- Obtain proxy addresses, implementation addresses and independently checked code hashes from the keeper's deployment manifests, and check that the coordinator implementation at your chain's proxy exposes `quoteFee`/`quoteFeeAt` (its code hash matches the manifest entry for this protocol version) before relying on this SDK's interface. The testnet upgrade to this protocol version and the Arc mainnet deployment are published there when confirmed.
384
+ 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), and check that the coordinator implementation at your chain's proxy exposes `quoteFee`/`quoteFeeAt` (its code hash matches the manifest entry for this protocol version) before relying on this SDK's interface.
131
385
 
132
386
  ### Arc Mainnet
133
387
 
@@ -142,7 +396,7 @@ Chain ID: **5042**. The live service; use the **coordinator proxy** when constru
142
396
  | EpochEntropy | Implementation | [`0xD20Da0cf7Ddc6123f9A87c0C210F8ECB934CA7D5`](https://explorer.arc.io/address/0xD20Da0cf7Ddc6123f9A87c0C210F8ECB934CA7D5) |
143
397
  | D20CostClient | Implementation | [`0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b`](https://explorer.arc.io/address/0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b) |
144
398
 
145
- Addresses are copied from the [Arc Mainnet deployment manifest](https://github.com/d20dao/keeper/blob/main/deployments/arc-mainnet.json). Mainnet and testnet run the same implementation code.
399
+ Addresses are copied from the [Arc Mainnet deployment manifest](https://d20dao.org/deployments/arc-mainnet.json). Mainnet and testnet run the same implementation code.
146
400
 
147
401
  ### Arc Testnet
148
402
 
@@ -157,10 +411,4 @@ Chain ID: **5042002**. For development and testing. Use the **coordinator proxy*
157
411
  | EpochEntropy | Implementation | [`0xD20Da0cf7Ddc6123f9A87c0C210F8ECB934CA7D5`](https://testnet.arcscan.app/address/0xD20Da0cf7Ddc6123f9A87c0C210F8ECB934CA7D5) |
158
412
  | D20CostClient | Implementation | [`0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b`](https://testnet.arcscan.app/address/0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b) |
159
413
 
160
- Addresses are copied from the [Arc Testnet deployment manifest](https://github.com/d20dao/keeper/blob/main/deployments/arc-testnet.json). Explorer links identify addresses; they do not assert explorer source-code verification. Implementation addresses change through owner-authorized upgrades, so the implementation rows and code hashes are only valid together with the manifest revision they came from. The pilot consumer is test tooling, not a shared application entry point. The [testnet stress run](https://github.com/d20dao/keeper/blob/main/docs/benchmarks/arc-testnet-stress-2026-09-16.json) served 68 paid requests within 2–4 chain seconds, 47 of them in batched fulfillments; measured timings are not an SLA.
161
-
162
- ## Optional refund notification
163
-
164
- 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.
165
-
166
- 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. Never request new randomness from within either callback; use a separate application transaction.
414
+ Addresses are copied from the [Arc Testnet deployment manifest](https://d20dao.org/deployments/arc-testnet.json). Explorer links identify addresses; they do not assert explorer source-code verification. Implementation addresses change through owner-authorized upgrades, so the implementation rows and code hashes are only valid together with the manifest revision they came from. The pilot consumer is test tooling, not a shared application entry point.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d20dao/vrf-sdk",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "D20DAO verifiable randomness: Solidity consumer helpers, contract ABIs and public proof replay",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -30,7 +30,8 @@
30
30
  "./abi/EpochEntropy.json": "./abi/EpochEntropy.json",
31
31
  "./contracts/*": "./contracts/*",
32
32
  "./examples/*": "./examples/*",
33
- "./AGENTS.md": "./AGENTS.md"
33
+ "./AGENTS.md": "./AGENTS.md",
34
+ "./API.md": "./API.md"
34
35
  },
35
36
  "files": [
36
37
  "dist/*.js",
@@ -38,6 +39,7 @@
38
39
  "contracts/**/*.sol",
39
40
  "examples/*.sol",
40
41
  "AGENTS.md",
42
+ "API.md",
41
43
  "LICENSE",
42
44
  "THIRD_PARTY_NOTICES.md",
43
45
  "notices/*",
@@ -48,6 +50,7 @@
48
50
  ],
49
51
  "scripts": {
50
52
  "build": "node scripts/build.mjs",
53
+ "api-reference": "node scripts/api-reference.mjs",
51
54
  "prepack": "npm run build",
52
55
  "prepublishOnly": "npm test",
53
56
  "test": "node scripts/smoke.mjs"