@d20dao/vrf-sdk 0.3.2 → 0.3.3

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 CHANGED
@@ -1,33 +1,61 @@
1
1
  # d20dao consumer-agent guide
2
2
 
3
- Use this guide when integrating @d20dao/vrf-sdk into an application or interpreting its public evidence. Install with `npm install @d20dao/vrf-sdk`. The package provides a general randomness interface; dice and mining contracts are examples. Read installed declarations for exact types and match the packaged PROTOCOL-PROVENANCE.json to the deployment being used.
3
+ Use this guide when integrating @d20dao/vrf-sdk into an application or interpreting its public evidence. Install with `npm install @d20dao/vrf-sdk` (0.3.3 or newer). The package provides a general randomness interface; dice and mining contracts are examples. Read installed declarations for exact types and match the packaged PROTOCOL-PROVENANCE.json to the deployment being used.
4
+
5
+ ## Networks and toolchain
6
+
7
+ Arc Mainnet: chain 5042, live service, RPC https://rpc.mainnet.arc.io, explorer https://explorer.arc.io, D20DAO explorer https://arc.d20dao.org, manifest https://d20dao.org/deployments/arc-mainnet.json. Arc Testnet: chain 5042002, development, RPC https://rpc.testnet.arc.io, explorer https://testnet.arcscan.app, D20DAO explorer https://arc-testnet.d20dao.org, manifest https://d20dao.org/deployments/arc-testnet.json. Native USDC (18 decimals) pays gas and request fees; test USDC comes from the faucet linked at https://docs.arc.io/arc/references/connect-to-arc. Use Node 22.13+, solc 0.8.28 and evmVersion cancun. Hardhat resolves `@d20dao/vrf-sdk/contracts/...` from node_modules; Foundry needs the remapping `@d20dao/vrf-sdk/=node_modules/@d20dao/vrf-sdk/`. Consumer sources need no OpenZeppelin. Website guides: https://d20dao.org/docs, index https://d20dao.org/llms.txt. Public protocol source: the SDK repository's protocol/ folder (https://github.com/d20dao/d20-sdk/tree/main/protocol); the keeper repository named in PROTOCOL-PROVENANCE.json is not public.
4
8
 
5
9
  ## Public interfaces
6
10
 
7
11
  Import builtins, mapRandomness, decodeEvidencePacket, replayCoordinator and quoteRequestFee from @d20dao/vrf-sdk. Epoch helpers and MAX_ATTESTATION_AGE also have an /epoch entrypoint. Import coordinatorAbi and epochEntropyAbi from /abi. Solidity consumers use D20VRFConsumer, ID20VRF, D20VRFRequests and RandomnessMapping under /contracts with compiler 0.8.28. ID20VRF exposes quoteFee(callbackGasLimit), quoteFeeAt(callbackGasLimit, baseFee), requestRandomness(clientSeed, callbackGasLimit, refundAddress), requestMappedRandomness(..., spec) and getMappedResult(requestId).
8
12
 
13
+ The package is ESM only, for Node and bundlers; bundle it (Vite, webpack, esbuild) for browsers. quoteRequestFee expects an ethers v6 provider (or getBlock/call with ethers-v6 shapes); with other clients read the latest header baseFeePerGas, add a buffer and call quoteFeeAt. Never quote with quoteFee through eth_call, which reports a base fee of 0.
14
+
9
15
  RequestContext binds chainId, effective coordinator proxy, keyHash, requestId, consumer, clientSeed, mapping, requestBlock, targetBlock, blockHash, epochId and epochHash. Consult Parameters<typeof replayCoordinator>[0] for the complete trusted replay input. configuration.feeRecipient uses the initialized initialFeeRecipient and configuration.initialMinFee the initialize fee argument (getter initialMinFee), not the live payout address or pricing.
10
16
 
17
+ ## Randomness options
18
+
19
+ Spec is (operation, lower, upper, count, population). Solidity helpers (`using D20VRFRequests for ID20VRF`, `o = D20VRFRequests.Options(clientSeed, callbackGasLimit, refundAddress)`) return the request ID and pay quoteFee from the contract balance; TypeScript builtins return the same spec.
20
+
21
+ - Raw (0): requestRandomness / builtins.raw(); result [uint256(word)].
22
+ - DiceRoll (1): rng.diceRoll(sides, count, o), dN(sides, o), d4/d6/d8/d10/d12/d20(o); builtins.diceRoll(sides, count), dN(sides), d4()…d20(); sides ≥ 2, count 1–128; each value 1–sides, repeats possible.
23
+ - CoinFlip (2): rng.coinFlip(o) / builtins.coinFlip(); 0 tails, 1 heads.
24
+ - NumberRange (3): rng.numberRange(min, max, o) / builtins.numberRange(min, max); min ≤ max over uint256; one value in [min, max].
25
+ - ChooseOne (4): rng.chooseOne(population, o) / builtins.chooseOne(size); population 1–256; one zero-based index.
26
+ - ChooseMany (5): rng.chooseMany(population, count, o) / builtins.chooseMany(size, count); count 1–population; distinct indices.
27
+ - Shuffle (6): rng.shuffle(population, o) / builtins.shuffle(size); population 1–256; a permutation of all indices.
28
+
29
+ Invalid specs revert with InvalidMapping (throw in TypeScript). Callbacks always receive the raw bytes32 word. Freeze any item list before a choice or shuffle.
30
+
11
31
  ## Pricing and payment
12
32
 
13
- fee = max(minFee, feeMultiplier × baseFee × (fulfillGasOverhead + callbackGasLimit)), evaluated with the base fee of the requesting transaction. pricing() returns the live (minFee, feeMultiplier, fulfillGasOverhead); the owner may change them within bounds (minFee at most 10 USDC in 18-decimal native units, multiplier 0–20 where 0 is a flat minFee, overhead 100,000–2,000,000 gas) and emits PricingChanged. Initialization sets multiplier 5 and overhead 300,000; the deployment configuration sets a 0.08 USDC minimum and a 50% keeper share (keeperFeeBps 5000). Examples at those parameters: at 176 gwei with 100,000 callback gas the fee is 5 × 176 gwei × 400,000 = 0.352 USDC; at 20 gwei the dynamic part is 0.04 USDC, so the 0.08 USDC minimum applies. Read live values; never hard-code a price.
33
+ fee = max(minFee, feeMultiplier × baseFee × (fulfillGasOverhead + callbackGasLimit)), evaluated with the base fee of the requesting transaction. pricing() returns the live (minFee, feeMultiplier, fulfillGasOverhead); the owner may change them within bounds (minFee at most 10 USDC in 18-decimal native units, multiplier 0–20 where 0 is a flat minFee, overhead 100,000–2,000,000 gas) and emits PricingChanged. Both Arc deployments were initialized with a 0.08 USDC minimum fee, multiplier 5, overhead 300,000 gas and a 50% keeper share (keeperFeeBps 5000); these are initialization values, not fixed prices. Examples at those parameters: at 176 gwei with 100,000 callback gas the fee is 5 × 176 gwei × 400,000 = 0.352 USDC; at 20 gwei the dynamic part is 0.04 USDC, so the 0.08 USDC minimum applies. Read live values; never hard-code a price.
14
34
 
15
35
  Send msg.value >= fee. Less reverts with IncorrectFee(expected, actual). Exactly the quote is escrowed (requestFeePaid, emitted as feePaid in RandomnessRequested); any excess is credited to the refund address as refund credit (FeeOverpaymentCredited, refundCredits) and only that address can pull it with withdrawRefundCredit(recipient). Choose a refund address that can call withdrawRefundCredit or receive a plain native transfer.
16
36
 
17
37
  A contract that requests in the same transaction pays quoteFee(callbackGasLimit), which is exact; D20VRFRequests helpers and MiningRandomnessConsumer do this from the contract balance. A wallet or backend must pay through a consumer contract (requests from EOAs revert) and must never quote quoteFee through eth_call: the base fee is commonly reported as 0 there (verified on Arc mainnet), the quote collapses to minFee and the transaction reverts. Quote with quoteFeeAt(callbackGasLimit, latestBlock.baseFeePerGas) plus a buffer and forward the whole amount. quoteRequestFee(provider, coordinator, callbackGasLimit, { bufferBps = 3000 }) does this with ethers 6: fee is the quote at the block's base fee, value is the quote at a base fee bufferBps higher (equal to fee when the minimum dominates); send value. The buffer covers base-fee movement until inclusion; the excess is refund credit, never revenue. On IncorrectFee, quote again and resend.
18
38
 
39
+ Recommended consumer pattern: in the requesting function read fee = quoteFee(callbackGasLimit), require msg.value >= fee, pay exactly fee, return msg.value - fee to the caller and use the paying user as refund address. Forwarding msg.value instead (examples/DiceConsumer.sol) leaves the buffer as refund credit that the refund address must withdraw separately; paying from the contract balance needs the application's own funding policy.
40
+
41
+ clientSeed need not be unique or secret: the VRF seed also binds chain, coordinator, key hash, the incrementing request ID, consumer, mapping, request/target block, target hash and epoch. Use it to bind application context, for example keccak256(abi.encode(msg.sender, operationId)) or an item-list commitment.
42
+
19
43
  ## Request lifecycle
20
44
 
21
45
  Epochs last 200 blocks. The keeper prepares the first validated API3 snapshot locally using the source anchor at epochStart-1. Idle preparation causes no publication transaction. Unused snapshots may remain locally for 50 epochs/10,000 blocks, with live-request and unresolved-transaction protection.
22
46
 
23
47
  A request escrows its quoted fee even if its epoch is unpublished and fixes its request block, epoch, client seed, mapping, refund address, feePaid, refundBps and 60-second deadline. Live paid demand triggers publication of the saved packet. The target becomes max(requestBlock, committedBlock+1); no usable VRF seed exists until that future hash is known. Older-epoch demand can settle across a boundary without changing its packet.
24
48
 
25
- D20VRFConsumer authenticates the coordinator proxy; verify the expected request and store the raw callback word with minimal work. Mapped requests still callback with bytes32; use getMappedResult or canonical mapping. Keep application actions and payments separate from the callback.
49
+ D20VRFConsumer authenticates the coordinator proxy; verify the expected request and store the raw callback word with minimal work. callbackGasLimit is 30,000–1,000,000 (InvalidCallbackGas otherwise) and is forwarded exactly to rawFulfillRandomness. Mapped requests still callback with bytes32; use getMappedResult or canonical mapping. Keep application actions and payments separate from the callback.
26
50
 
27
- Valid onchain acceptance at or before requestedAt+60 seconds is timely. A pending transaction is not acceptance. At acceptance keeperFeeBps of feePaid goes to the configured registry committer, not the proof submitter (a failed transfer becomes keeper credit), and the remainder becomes protocol fees. Callback failure still earns the fee; retryCallback redelivers only the same accepted result and cannot pay a second share.
51
+ Valid onchain acceptance at or before requestedAt+60 seconds is timely. A pending transaction is not acceptance. Single requests are normally fulfilled within a few seconds on Arc; in a stress test 200 simultaneous requests were delivered within 36 seconds (median 19 seconds). Timings are not an SLA. At acceptance keeperFeeBps of feePaid goes to the configured registry committer, not the proof submitter (a failed transfer becomes keeper credit), and the remainder becomes protocol fees. Callback failure still earns the fee; retryCallback redelivers only the same accepted result and cannot pay a second share.
52
+
53
+ Read results from coordinator events (RandomnessRequested carries requestId; RandomnessFulfilled, CallbackAttempted, RequestRefundedTo, RefundCallbackAttempted) or poll getRequest(requestId): fields consumer, callbackGasLimit, requestBlock, targetBlock, deadline, refundAddress, clientSeed, mappingHash, blockHash, randomness, proofHash, transcriptHash, fulfilled, delivered, refunded, epochId, epochHash. getMapping(requestId) returns the stored Spec; getMappedResult(requestId) returns uint256[] and reverts NotFulfilled before acceptance; mapRandomness(randomness, spec) is a pure mapping of any word. Only getMappedResult is in ID20VRF; the rest are in coordinatorAbi. A request unfulfilled after its deadline can only be refunded.
28
54
 
29
55
  After the deadline, anyone may call refundRequest(requestId). It pays feePaid × requestRefundBps / 10000 using the ratio snapshotted at request time (default 100%; the owner may lower it to no less than 50% for future requests only, event RefundBpsChanged) to the fixed refund address, or records it as that address's refund credit if the 30,000-gas transfer fails; the remainder is retained as protocol fees. Gas and application payments are separate.
30
56
 
57
+ Recovery calls revert with InsufficientCallbackGas instead of forwarding less gas. Measured minimum transaction gas limits: refundRequest 302,558–357,517 (use 400,000); retryCallback about 1.032 × gasLimit + 184,300 (use gasLimit + 250,000); retryRefundCallback about 1.032 × gasLimit + 89,800 with gasLimit 100,000–1,000,000 (use gasLimit + 150,000). eth_estimateGas finds these minimums.
58
+
31
59
  Keepers may fulfill up to 16 requests in one fulfillRandomnessBatch transaction. Each served request emits the same per-request events and evidence as a single fulfillment and settles from its own feePaid; members already fulfilled, refunded or past their deadline emit FulfillmentSkipped(requestId, reason) with reason 1, 2 or 3, and any proof or readiness failure reverts the batch. Consumers see no difference. Indexers must rely on per-request events, not transaction calldata.
32
60
 
33
61
  ## Verification and trust
@@ -40,14 +68,20 @@ Decode epoch evidence using its trusted registry/event context and decodeEvidenc
40
68
 
41
69
  Both service contracts use atomically initialized D20Proxy endpoints with owner-authorized UUPS upgrades and two-step ownership; renounceOwnership reverts on both. Implementations are locked against initialization. The owner can rotate committer and fee recipient, adjust the keeper share, tune bounded pricing, lower the refund ratio for future requests and schedule future catalogs; no setter rewrites a request, a published epoch or the VRF key. Upgrade authority is trusted. Verify the implementation history of BOTH coordinator and registry; stable proxy addresses alone do not identify executed code. Operator pins stop processing on unreviewed changes while preserving recovery data.
42
70
 
71
+ The contracts have not had an external security audit; the coordinator's "Prototype: not audited or validated on Arc" source comment is unchanged because the source is part of deployed bytecode metadata, and the service is live on Arc Mainnet. On Arc Mainnet the owner of both proxies is the DAO treasury Safe 0xB57f656149749eff6b496dF090336491f977E744. The VRF key holder can withhold a proof, which leads to a refund, but cannot substitute another result for a fixed seed.
72
+
43
73
  ## Service boundaries
44
74
 
45
- Always configure the actual chain explicitly; there is no implicit Arc network default. Take proxy addresses and code hashes from the keeper's deployment manifest for that chain and confirm the coordinator implementation exposes quoteFee/quoteFeeAt before live requests. Healthy process status does not guarantee a particular request's timely fulfillment.
75
+ Always configure the actual chain explicitly; there is no implicit Arc network default. Take proxy addresses and code hashes from the public deployment manifest for that chain (https://d20dao.org/deployments/arc-mainnet.json or https://d20dao.org/deployments/arc-testnet.json) and confirm the coordinator implementation exposes quoteFee/quoteFeeAt before live requests. Healthy process status does not guarantee a particular request's timely fulfillment.
46
76
 
47
77
  This SDK holds no signer or bot keys, runs no keeper/prover and exposes no operator API. Optional Telegram access is disabled by default and limited to read-only /status and /keeper in the configured operator chat. Those commands cannot alter configuration or send transactions. Docker provisioning, upgrades, funding and publishing are separate operator actions, not consequences of SDK integration.
48
78
 
79
+ ## Examples
80
+
81
+ examples/DiceConsumer.sol (installed at @d20dao/vrf-sdk/examples/DiceConsumer.sol): player-paid d20 request forwarding msg.value, player as refund address, refund hook. contracts/examples/MiningRandomnessConsumer.sol (source protocol/contracts/examples/): abstract claim consumer paying quoteFee from its balance, one request per claim, candidate seeds from the stored word. The README's Recommended payment pattern shows D20Game, which pays the exact quote and returns change.
82
+
49
83
  ## Optional refund notification
50
84
 
51
85
  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.
52
86
 
53
- 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.
87
+ 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 the recovery gas figures above). Never request new randomness from within either callback; use a separate application transaction.
@@ -26,7 +26,7 @@
26
26
  "contracts/D20Proxy.sol": "83dcef3b72e2a0a4809c2d8df0d083d2a0f659fe2b3b01fadb1b2eaf8afb7286"
27
27
  }
28
28
  },
29
- "packageLockSha256": "c79d88d461128cdb8da152c6562e432979cb15c393ca026d9e8268a6859671f8",
29
+ "packageLockSha256": "968ebea1fa309fa4fb730d87937dd745f172f0eee96cefcd51e252803cdf6020",
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.3`.
4
4
 
5
5
  ## Getting started
6
6
 
@@ -8,9 +8,97 @@ 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.3 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` 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
+ ## Networks
16
+
17
+ | | Arc Mainnet | Arc Testnet |
18
+ | --- | --- | --- |
19
+ | Use | Live service, real USDC | Development and testing |
20
+ | Chain ID | `5042` | `5042002` |
21
+ | RPC | `https://rpc.mainnet.arc.io` | `https://rpc.testnet.arc.io` |
22
+ | Block explorer | https://explorer.arc.io | https://testnet.arcscan.app |
23
+ | D20DAO explorer | https://arc.d20dao.org | https://arc-testnet.d20dao.org |
24
+ | Deployment manifest | https://d20dao.org/deployments/arc-mainnet.json | https://d20dao.org/deployments/arc-testnet.json |
25
+
26
+ 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.
27
+
28
+ ## Integrate a consumer
29
+
30
+ Solidity imports require compiler 0.8.28 and your compiler's npm resolver:
31
+
32
+ - `@d20dao/vrf-sdk/contracts/D20VRFConsumer.sol`
33
+ - `@d20dao/vrf-sdk/contracts/interfaces/ID20VRF.sol`
34
+ - `@d20dao/vrf-sdk/contracts/libraries/D20VRFRequests.sol`
35
+ - `@d20dao/vrf-sdk/contracts/libraries/RandomnessMapping.sol`
36
+ - `@d20dao/vrf-sdk/contracts/examples/MiningRandomnessConsumer.sol`
37
+
38
+ These sources import only each other; no OpenZeppelin installation is needed for a consumer.
39
+
40
+ `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.
41
+
42
+ A complete consumer following the recommended payment pattern is shown in [Recommended payment pattern](#recommended-payment-pattern).
43
+
44
+ ### Compiler setup
45
+
46
+ Hardhat resolves `@d20dao/vrf-sdk/...` imports from `node_modules` without remappings:
47
+
48
+ ```ts
49
+ // hardhat.config.ts
50
+ export default {
51
+ solidity: {
52
+ version: "0.8.28",
53
+ settings: { evmVersion: "cancun", optimizer: { enabled: true, runs: 200 } },
54
+ },
55
+ };
56
+ ```
57
+
58
+ Foundry: run `npm install @d20dao/vrf-sdk` in the project root and map the import prefix to `node_modules`:
59
+
60
+ ```toml
61
+ # foundry.toml
62
+ [profile.default]
63
+ src = "src"
64
+ solc_version = "0.8.28"
65
+ evm_version = "cancun"
66
+ remappings = ["@d20dao/vrf-sdk/=node_modules/@d20dao/vrf-sdk/"]
67
+ ```
68
+
69
+ With either tool, `import {D20VRFConsumer} from "@d20dao/vrf-sdk/contracts/D20VRFConsumer.sol";` then compiles unchanged.
70
+
71
+ ### Examples
72
+
73
+ - `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.
74
+ - `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`.
75
+ - `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.
76
+
77
+ ### Client seed
78
+
79
+ `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.
80
+
81
+ ### Callback gas limit
82
+
83
+ `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. Computing a large mapping, such as a 256-item shuffle, inside the callback needs much more.
84
+
85
+ ## Randomness options
86
+
87
+ 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)`.
88
+
89
+ | Option | Solidity (`D20VRFRequests`) | TypeScript (`builtins`) | Spec | Valid parameters | Result |
90
+ | --- | --- | --- | --- | --- | --- |
91
+ | Raw word | none: `requestRandomness(clientSeed, callbackGasLimit, refundAddress)` | `raw()` | `(0 Raw, 0, 0, 0, 0)` | none | `[uint256(word)]` |
92
+ | 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 |
93
+ | Custom die | `rng.dN(sides, o)` | `dN(sides)` | `(1 DiceRoll, 0, sides, 1, 0)` | sides ≥ 2 | one value, 1–sides |
94
+ | 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 |
95
+ | Coin flip | `rng.coinFlip(o)` | `coinFlip()` | `(2 CoinFlip, 0, 0, 1, 0)` | none | one value: 0 tails, 1 heads |
96
+ | 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] |
97
+ | Choose one | `rng.chooseOne(population, o)` | `chooseOne(size)` | `(4 ChooseOne, 0, 0, 1, population)` | population 1–256 | one index, 0 to population−1 |
98
+ | 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 |
99
+ | Shuffle | `rng.shuffle(population, o)` | `shuffle(size)` | `(6 Shuffle, 0, 0, population, population)` | population 1–256 | every index 0 to population−1 exactly once |
100
+
101
+ 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
102
 
15
103
  ## Pricing
16
104
 
@@ -20,9 +108,9 @@ The coordinator prices every request from the base fee of the transaction that c
20
108
  fee = max(minFee, feeMultiplier × baseFee × (fulfillGasOverhead + callbackGasLimit))
21
109
  ```
22
110
 
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.
111
+ `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
112
 
25
- Labelled examples with multiplier 5, overhead 300,000 and a 0.08 USDC minimum:
113
+ Labelled examples with the initialization values (multiplier 5, overhead 300,000 gas, 0.08 USDC minimum fee):
26
114
 
27
115
  - **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
116
  - **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 +133,7 @@ requestId = rng.requestRandomness{value: fee}(clientSeed, callbackGasLimit, refu
45
133
 
46
134
  ### Wallets and backends that pay through a consumer
47
135
 
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:
136
+ 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
137
 
50
138
  ```js
51
139
  import { quoteRequestFee } from '@d20dao/vrf-sdk';
@@ -54,7 +142,116 @@ const { fee, value, baseFee } = await quoteRequestFee(provider, coordinator, 100
54
142
  await dice.roll(clientSeed, 100_000, { value });
55
143
  ```
56
144
 
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.
145
+ `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.
146
+
147
+ ### Recommended payment pattern
148
+
149
+ 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.
150
+
151
+ ```solidity
152
+ // SPDX-License-Identifier: MIT
153
+ pragma solidity 0.8.28;
154
+
155
+ import {D20VRFConsumer} from "@d20dao/vrf-sdk/contracts/D20VRFConsumer.sol";
156
+ import {ID20VRF} from "@d20dao/vrf-sdk/contracts/interfaces/ID20VRF.sol";
157
+ import {D20VRFRequests} from "@d20dao/vrf-sdk/contracts/libraries/D20VRFRequests.sol";
158
+
159
+ contract D20Game is D20VRFConsumer {
160
+ using D20VRFRequests for ID20VRF;
161
+
162
+ uint32 public constant CALLBACK_GAS = 100_000;
163
+ mapping(uint256 => address) public playerOf;
164
+ mapping(uint256 => bytes32) public wordOf;
165
+ mapping(uint256 => bool) public ready;
166
+ error Underpaid(uint256 fee, uint256 sent);
167
+ error ChangeFailed();
168
+ error UnexpectedCallback();
169
+
170
+ constructor(address coordinator) D20VRFConsumer(coordinator) {}
171
+
172
+ function roll(bytes32 operationId) external payable returns (uint256 requestId) {
173
+ ID20VRF rng = ID20VRF(vrfCoordinator);
174
+ uint256 fee = rng.quoteFee(CALLBACK_GAS); // exact inside this transaction
175
+ if (msg.value < fee) revert Underpaid(fee, msg.value);
176
+ // The helper pays the same quoteFee from this contract's balance, which msg.value just funded.
177
+ // The player is the refund address: an expiry refund goes straight back to them.
178
+ requestId = rng.d20(D20VRFRequests.Options(keccak256(abi.encode(msg.sender, operationId)), CALLBACK_GAS, msg.sender));
179
+ playerOf[requestId] = msg.sender;
180
+ if (msg.value > fee) {
181
+ (bool ok,) = payable(msg.sender).call{value: msg.value - fee}("");
182
+ if (!ok) revert ChangeFailed();
183
+ }
184
+ }
185
+
186
+ function _fulfillRandomness(uint256 requestId, bytes32 randomness) internal override {
187
+ if (playerOf[requestId] == address(0) || ready[requestId]) revert UnexpectedCallback();
188
+ wordOf[requestId] = randomness;
189
+ ready[requestId] = true;
190
+ }
191
+
192
+ /// 1 to 20 once ready.
193
+ function result(uint256 requestId) external view returns (uint256) {
194
+ return ID20VRF(vrfCoordinator).getMappedResult(requestId)[0];
195
+ }
196
+ }
197
+ ```
198
+
199
+ The other patterns behave as follows:
200
+
201
+ - **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.
202
+ - **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.
203
+
204
+ ## Reading results
205
+
206
+ All events come from the coordinator proxy, with `requestId` as the first indexed topic:
207
+
208
+ | Event | Meaning |
209
+ | --- | --- |
210
+ | `RandomnessRequested(requestId, consumer, keyHash, clientSeed, requestBlock, callbackGasLimit, feePaid, refundAddress, deadline)` | Request created. Read `requestId` from this log in the request receipt. |
211
+ | `MappingRequested(requestId, mappingHash, spec)` | Mapping stored with the request (Raw for `requestRandomness`). |
212
+ | `RandomnessFulfilled(requestId, randomness, submitter)` | Proof accepted; `randomness` is final. |
213
+ | `CallbackAttempted(requestId, success, gasLimit)` | Result of calling `rawFulfillRandomness`, at fulfillment and at each `retryCallback`. |
214
+ | `RequestRefundedTo(requestId, refundAddress, amount, paid)` | Expired request refunded; `paid` false means the amount became refund credit. |
215
+ | `RefundCallbackAttempted(requestId, consumer, success, gasLimit)` | Result of the `onRefund` notification. |
216
+
217
+ Views on the coordinator (all in `coordinatorAbi`; only `getMappedResult` is part of `ID20VRF`, so declare a local interface in Solidity for the others):
218
+
219
+ - `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` and `epochHash` (zero until published). Reverts `UnknownRequest` for an unused ID.
220
+ - `getMapping(uint256 requestId) returns (RandomnessMapping.Spec)`: the stored `(operation, lower, upper, count, population)`; all zero (Raw) for `requestRandomness`. Reverts `UnknownRequest`.
221
+ - `getMappedResult(uint256 requestId) returns (uint256[])`: the stored word mapped with the stored spec. Reverts `NotFulfilled` before acceptance.
222
+ - `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.
223
+ - `requestFeePaid(requestId)`, `requestRefundBps(requestId)`, `refundCredits(address)` and `refundCallbackDelivered(requestId)` show settlement.
224
+
225
+ Polling with ethers 6, after sending the request through a consumer such as `D20Game`:
226
+
227
+ ```js
228
+ import { Contract } from 'ethers';
229
+ import { coordinatorAbi } from '@d20dao/vrf-sdk/abi';
230
+
231
+ const coordinator = new Contract(coordinatorAddress, coordinatorAbi, provider);
232
+ const receipt = await (await game.roll(operationId, { value })).wait();
233
+ const requestId = receipt.logs
234
+ .filter((log) => log.address.toLowerCase() === coordinatorAddress.toLowerCase())
235
+ .map((log) => coordinator.interface.parseLog(log))
236
+ .find((event) => event?.name === 'RandomnessRequested').args.requestId;
237
+
238
+ for (;;) {
239
+ const request = await coordinator.getRequest(requestId);
240
+ if (request.fulfilled) { console.log(await coordinator.getMappedResult(requestId)); break; }
241
+ const { timestamp } = await provider.getBlock('latest');
242
+ if (BigInt(timestamp) > request.deadline) break; // expired: refundRequest(requestId) is available
243
+ await new Promise((resolve) => setTimeout(resolve, 2000));
244
+ }
245
+ ```
246
+
247
+ `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).
248
+
249
+ ## Frontend and backend use
250
+
251
+ - 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.
252
+ - `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)`.
253
+ - 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)).
254
+ - Send the transaction to your consumer contract; the coordinator rejects requests from wallets with `ContractConsumerRequired`.
58
255
 
59
256
  ## Request lifecycle
60
257
 
@@ -64,40 +261,35 @@ A request escrows its quoted fee even when its epoch packet is not published yet
64
261
 
65
262
  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
263
 
67
- ### Expiry and refunds
264
+ ### Timing
68
265
 
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.
266
+ 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.
70
267
 
71
- ### Batched fulfillment
268
+ ### Expiry and refunds
72
269
 
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.
270
+ 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
271
 
75
- ## Use locally
272
+ ### Gas for refund and retry calls
76
273
 
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.
274
+ `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
275
 
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
- ```
276
+ | Call | Coordinator check before forwarding | Measured minimum transaction gas limit | Suggested gas limit |
277
+ | --- | --- | --- | --- |
278
+ | `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 |
279
+ | `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 |
280
+ | `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
281
 
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.
282
+ 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
283
 
88
- ## Integrate a consumer
284
+ ### Batched fulfillment
89
285
 
90
- Solidity imports require compiler 0.8.28 and your compiler's npm resolver:
286
+ 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.
91
287
 
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`
288
+ ## Optional refund notification
97
289
 
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.
290
+ 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
291
 
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.
292
+ 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
293
 
102
294
  ## Replay and verification
103
295
 
@@ -111,15 +303,38 @@ Signer catalogs are per epoch. The registry owner can schedule a replacement cat
111
303
 
112
304
  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
305
 
306
+ ## Security and trust
307
+
308
+ 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`).
309
+
310
+ Trust model:
311
+
312
+ - **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.
313
+ - **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.
314
+ - **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.
315
+
114
316
  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.
115
317
 
318
+ ## Use locally
319
+
320
+ 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.
321
+
322
+ ```js
323
+ import { builtins, mapRandomness, replayCoordinator, quoteRequestFee } from '@d20dao/vrf-sdk';
324
+ import { coordinatorAbi, epochEntropyAbi } from '@d20dao/vrf-sdk/abi';
325
+ const mapping = builtins.d20();
326
+ // Use only an independently verified accepted word for real outcomes.
327
+ ```
328
+
329
+ 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.
330
+
116
331
  ## Operational and release boundary
117
332
 
118
333
  This SDK contains no keeper service, API fetching, proof generation, signer secrets or deployment automation. The canonical keeper has a Docker install wrapper that builds, provisions separately supplied key files and starts from reviewed configuration; inspect its platform-specific guide before use. No deployment or funding is authorized by SDK installation.
119
334
 
120
335
  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
336
 
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.
337
+ 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
338
 
124
339
  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
340
 
@@ -127,7 +342,7 @@ SDK installation provides consumer and verification tooling. Chain availability,
127
342
 
128
343
  ## Deployments
129
344
 
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.
345
+ 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
346
 
132
347
  ### Arc Mainnet
133
348
 
@@ -142,7 +357,7 @@ Chain ID: **5042**. The live service; use the **coordinator proxy** when constru
142
357
  | EpochEntropy | Implementation | [`0xD20Da0cf7Ddc6123f9A87c0C210F8ECB934CA7D5`](https://explorer.arc.io/address/0xD20Da0cf7Ddc6123f9A87c0C210F8ECB934CA7D5) |
143
358
  | D20CostClient | Implementation | [`0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b`](https://explorer.arc.io/address/0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b) |
144
359
 
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.
360
+ 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
361
 
147
362
  ### Arc Testnet
148
363
 
@@ -157,10 +372,4 @@ Chain ID: **5042002**. For development and testing. Use the **coordinator proxy*
157
372
  | EpochEntropy | Implementation | [`0xD20Da0cf7Ddc6123f9A87c0C210F8ECB934CA7D5`](https://testnet.arcscan.app/address/0xD20Da0cf7Ddc6123f9A87c0C210F8ECB934CA7D5) |
158
373
  | D20CostClient | Implementation | [`0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b`](https://testnet.arcscan.app/address/0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b) |
159
374
 
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.
375
+ 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.3",
4
4
  "description": "D20DAO verifiable randomness: Solidity consumer helpers, contract ABIs and public proof replay",
5
5
  "publishConfig": {
6
6
  "access": "public",