@d20dao/vrf-sdk 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -15
- package/API.md +2190 -0
- package/BUILD-MANIFEST.json +18 -16
- package/CHANGELOG.md +44 -0
- package/PROTOCOL-PROVENANCE.json +9 -8
- package/README.md +157 -97
- package/abi/EpochEntropy.json +372 -26
- package/dist/abi.d.ts +292 -24
- package/dist/abi.js +1 -1
- package/dist/epoch.d.ts +42 -78
- package/dist/epoch.js +121 -27
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/sources.js +2 -0
- package/dist/templates.d.ts +22 -0
- package/dist/templates.js +220 -0
- package/examples/DiceConsumer.sol +37 -29
- package/examples/LootDropConsumer.sol +60 -0
- package/examples/RaffleConsumer.sol +71 -0
- package/package.json +6 -2
- package/contracts/examples/MiningRandomnessConsumer.sol +0 -59
package/README.md
CHANGED
|
@@ -1,16 +1,38 @@
|
|
|
1
1
|
# d20dao VRF SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Randomness your users can check.** Your contract asks the coordinator for a random result and pays a fee. A few seconds later the coordinator calls your contract back with a word taken from a VRF proof it verified on chain. Nobody picks the answer, nobody gets a second attempt, and anyone can replay the proof afterwards with this package.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`@d20dao/vrf-sdk` is everything you need on the application side: the Solidity base contract your consumer inherits, the coordinator ABI, an off-chain fee quote for your front end, and the replay code that re-derives a published result from public evidence. It holds no keys and runs no service.
|
|
6
|
+
|
|
7
|
+
The service is live on **Arc Mainnet** (chain 5042); develop against **Arc Testnet** (chain 5042002). Requests are permissionless — no allowlist, no subscription, no upfront deposit — but they must come from a contract, so a wallet or backend pays through its own consumer. Each request pays a fee quoted from the current base fee, a fraction of a USDC at typical gas prices (see [Pricing](#pricing)), and is served within 60 seconds or can be refunded.
|
|
8
|
+
|
|
9
|
+
## Start here
|
|
6
10
|
|
|
7
11
|
```sh
|
|
8
12
|
npm install @d20dao/vrf-sdk
|
|
9
13
|
```
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
1. **Copy an example.** [`examples/DiceConsumer.sol`](examples/DiceConsumer.sol) rolls a d20, [`examples/RaffleConsumer.sol`](examples/RaffleConsumer.sol) picks one winner from a list, [`examples/LootDropConsumer.sol`](examples/LootDropConsumer.sol) makes a weighted drop. Each is sixty to seventy lines and stands alone.
|
|
16
|
+
2. **Compile it.** Node 22.13+, solc 0.8.28, `evmVersion: cancun`. Hardhat and Foundry settings are in [Compiler setup](#compiler-setup).
|
|
17
|
+
3. **Deploy it** against the coordinator proxy for your chain, from [Deployments](#deployments). There is no default network; configure the address explicitly.
|
|
18
|
+
4. **Request and read.** Quote the fee off-chain, send it through your consumer, take `requestId` from the receipt and poll until `fulfilled` — or until the 60-second deadline passes and you refund. See [Paying for a request](#paying-for-a-request) and [Reading results](#reading-results).
|
|
19
|
+
|
|
20
|
+
Then read [Best practices](#best-practices): ten rules that cover most of what goes wrong. [API.md](API.md) lists every coordinator and registry function, event and error with its selector, caller and, for errors, what to do about it. [d20dao/randomizer-demo](https://github.com/d20dao/randomizer-demo) is a complete dapp built this way, and [CHANGELOG.md](CHANGELOG.md) records what changed in this release.
|
|
12
21
|
|
|
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).
|
|
22
|
+
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).
|
|
23
|
+
|
|
24
|
+
## Best practices
|
|
25
|
+
|
|
26
|
+
1. **Quote with `quoteFeeAt(callbackGasLimit, block.baseFeePerGas)` plus a buffer, never through `eth_call`.** `eth_call` reports a base fee of 0, so `quoteFee` collapses to the minimum fee and the real transaction reverts with `IncorrectFee`. Inside the requesting transaction `quoteFee(callbackGasLimit)` is exact; off-chain, use `quoteRequestFee` or `quoteFeeAt` with the latest header's base fee and a buffer for the movement until inclusion.
|
|
27
|
+
2. **Requests come from a contract, never an EOA.** A wallet call reverts with `ContractConsumerRequired`. The consumer is what the coordinator calls back, so it has to exist before the request.
|
|
28
|
+
3. **Keep the callback small: store the result, do the work later.** It runs inside `callbackGasLimit`. If it reverts or runs out of gas, its state changes are rolled back while the request stays served and paid, and anyone can redeliver the same accepted word with `retryCallback(requestId, gasLimit)`, with more gas if needed. A delivery that succeeded is never repeated, but still refuse a request id you have already finished: the check costs one read and does not depend on the coordinator.
|
|
29
|
+
4. **Map each request id to your own context, and reject anything else.** Record who asked and what for when you request, then in the callback refuse a request id you never issued and one you have already finished.
|
|
30
|
+
5. **Derive many values from one word instead of making many requests.** One request buys 256 bits. Ask for `diceRoll(sides, count)`, `chooseMany` or `shuffle` and the coordinator maps them for you; for application-specific values, `keccak256(abi.encode(word, i))` gives an independent value per `i`. A second request costs a second fee and a second wait.
|
|
31
|
+
6. **Handle expiry, and choose the refund address deliberately.** If no proof is accepted within 60 seconds the request expires: nothing is fulfilled late, and anyone may call `refundRequest(requestId)`. The refund is pushed to the address fixed at request time, so pick one that can receive a plain native transfer or call `withdrawRefundCredit` — usually the paying user.
|
|
32
|
+
7. **Never re-roll a result you dislike.** The word is final once `fulfilled` is true. Re-requesting after seeing an outcome is the one thing verifiable randomness cannot protect your users from, and the evidence trail makes it visible.
|
|
33
|
+
8. **Never use `blockhash` or `block.timestamp` as randomness.** Both are chosen by whoever builds the block, and `blockhash` is only available for the last 256 blocks. That is the problem this service exists to solve.
|
|
34
|
+
9. **Withdraw the refund credit your fee buffer leaves behind.** Anything above the escrowed quote is credited to the refund address (`FeeOverpaymentCredited`), readable with `refundCredits(address)` and pulled with `withdrawRefundCredit(recipient)`. Returning the change in the requesting transaction, as `DiceConsumer` does, avoids the second transaction entirely.
|
|
35
|
+
10. **Freeze any list before you request an index into it.** `chooseOne`, `chooseMany` and `shuffle` answer with indices. Commit the list — hashing it into `clientSeed` puts the commitment in the request log, as `RaffleConsumer` does.
|
|
14
36
|
|
|
15
37
|
## Networks
|
|
16
38
|
|
|
@@ -33,14 +55,11 @@ Solidity imports require compiler 0.8.28 and your compiler's npm resolver:
|
|
|
33
55
|
- `@d20dao/vrf-sdk/contracts/interfaces/ID20VRF.sol`
|
|
34
56
|
- `@d20dao/vrf-sdk/contracts/libraries/D20VRFRequests.sol`
|
|
35
57
|
- `@d20dao/vrf-sdk/contracts/libraries/RandomnessMapping.sol`
|
|
36
|
-
- `@d20dao/vrf-sdk/contracts/examples/MiningRandomnessConsumer.sol`
|
|
37
58
|
|
|
38
59
|
These sources import only each other; no OpenZeppelin installation is needed for a consumer.
|
|
39
60
|
|
|
40
61
|
`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
62
|
|
|
42
|
-
A complete consumer following the recommended payment pattern is shown in [Recommended payment pattern](#recommended-payment-pattern).
|
|
43
|
-
|
|
44
63
|
### Compiler setup
|
|
45
64
|
|
|
46
65
|
Hardhat resolves `@d20dao/vrf-sdk/...` imports from `node_modules` without remappings:
|
|
@@ -70,9 +89,16 @@ With either tool, `import {D20VRFConsumer} from "@d20dao/vrf-sdk/contracts/D20VR
|
|
|
70
89
|
|
|
71
90
|
### Examples
|
|
72
91
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
Three complete consumers ship in the package and install as `@d20dao/vrf-sdk/examples/<name>.sol`. Each is sixty to seventy lines, deals with one idea and is meant to be copied and edited rather than imported. All three take the coordinator proxy in their constructor, authenticate the callback through `D20VRFConsumer`, refuse a request id they did not issue or have already finished, and read their outcome from `getMappedResult` instead of recomputing it.
|
|
93
|
+
|
|
94
|
+
| Example | What it shows |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| [`DiceConsumer.sol`](examples/DiceConsumer.sol) | One d20 per player. Reads the exact `quoteFee` inside the requesting transaction, pays it, returns the change and names the player as the refund address. |
|
|
97
|
+
| [`RaffleConsumer.sol`](examples/RaffleConsumer.sol) | One winner from a list. Closes entry before requesting, hashes the frozen list into `clientSeed` as an on-chain commitment, maps the winning index with `ChooseOne` and, through `_onRefund`, lets the draw be sent again only after an expired request was refunded. |
|
|
98
|
+
| [`LootDropConsumer.sol`](examples/LootDropConsumer.sol) | A weighted drop. Asks for a `NumberRange` draw over the total weight instead of reducing the word itself, stores the word in the callback and walks the weights on read. |
|
|
99
|
+
|
|
100
|
+
- [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.
|
|
101
|
+
- `skills/d20-consumer/assets/RandomnessConsumer.sol` in [d20dao/skills](https://github.com/d20dao/skills) shows raw, mapped and shuffle requests in one contract, with refund notification and refund-credit withdrawal.
|
|
76
102
|
|
|
77
103
|
### Client seed
|
|
78
104
|
|
|
@@ -80,7 +106,7 @@ With either tool, `import {D20VRFConsumer} from "@d20dao/vrf-sdk/contracts/D20VR
|
|
|
80
106
|
|
|
81
107
|
### Callback gas limit
|
|
82
108
|
|
|
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.
|
|
109
|
+
`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.
|
|
84
110
|
|
|
85
111
|
## Randomness options
|
|
86
112
|
|
|
@@ -100,6 +126,8 @@ Every option is a `RandomnessMapping.Spec` `(operation, lower, upper, count, pop
|
|
|
100
126
|
|
|
101
127
|
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.
|
|
102
128
|
|
|
129
|
+
`RandomnessMapping.Spec` is the Solidity struct `(operation, lower, upper, count, population)` stored with a request. The TypeScript `MappingSpec` that `builtins` return has the same fields as an object, with `lower` and `upper` as `bigint`; ethers encodes it for the struct unchanged, and `hashMapping(spec)` equals the request's `mappingHash`. "Mapping" here means a randomness mapping, not a Solidity `mapping`.
|
|
130
|
+
|
|
103
131
|
## Pricing
|
|
104
132
|
|
|
105
133
|
The coordinator prices every request from the base fee of the transaction that creates it:
|
|
@@ -118,13 +146,15 @@ Labelled examples with the initialization values (multiplier 5, overhead 300,000
|
|
|
118
146
|
|
|
119
147
|
`quoteFeeAt(callbackGasLimit, baseFee)` evaluates the formula for a base fee you supply; `quoteFee(callbackGasLimit)` evaluates it for `block.basefee`. Quotes above the `uint96` escrow limit revert with `FeeOverflow` rather than truncating.
|
|
120
148
|
|
|
149
|
+
Three fee names are easy to confuse. `pricing()` and `quoteFee` price *future* requests; `requestFeePaid(requestId)` is what *one* request escrowed and settles from. `refundBps()` is the current refund ratio, copied into each new request, while `requestRefundBps(requestId)` is the ratio that request copied at creation and is refunded at. `keeperFeeBps()` has no per-request copy at all: it is read when a proof is accepted.
|
|
150
|
+
|
|
121
151
|
## Paying for a request
|
|
122
152
|
|
|
123
153
|
`requestRandomness(clientSeed, callbackGasLimit, refundAddress)` and `requestMappedRandomness(..., spec)` accept `msg.value >= fee`, where `fee` is the quote computed inside that transaction. Less reverts with `IncorrectFee(expected, actual)`. Exactly `fee` is escrowed and stored as `requestFeePaid(requestId)`; `RandomnessRequested` emits that charged fee as `feePaid`, not `msg.value`. Anything above it is not revenue: it is credited to the request's `refundAddress` as refund credit (`FeeOverpaymentCredited(requestId, refundAddress, amount)`, readable through `refundCredits(address)`) and is withdrawn by that address calling `withdrawRefundCredit(recipient)`. Choose a refund address that can make that call, or that can receive a plain native transfer for expiry refunds; a contract that can do neither strands its credit.
|
|
124
154
|
|
|
125
155
|
### Contracts that pay in the same transaction
|
|
126
156
|
|
|
127
|
-
`quoteFee(callbackGasLimit)` is exact inside the requesting transaction. `D20VRFRequests` helpers
|
|
157
|
+
`quoteFee(callbackGasLimit)` is exact inside the requesting transaction, so a contract can read the price and pay it in one go. The `D20VRFRequests` helpers do exactly that from the calling contract's balance:
|
|
128
158
|
|
|
129
159
|
```solidity
|
|
130
160
|
uint256 fee = rng.quoteFee(callbackGasLimit);
|
|
@@ -139,67 +169,19 @@ Do not call `quoteFee` through `eth_call`: it prices with `block.basefee`, which
|
|
|
139
169
|
import { quoteRequestFee } from '@d20dao/vrf-sdk';
|
|
140
170
|
// provider: ethers Provider; coordinator: coordinator proxy address; 100_000: callbackGasLimit
|
|
141
171
|
const { fee, value, baseFee } = await quoteRequestFee(provider, coordinator, 100_000, { bufferBps: 3000 });
|
|
142
|
-
await dice.roll(
|
|
172
|
+
await dice.roll({ value }); // DiceConsumer pays the exact quote and returns the rest
|
|
143
173
|
```
|
|
144
174
|
|
|
145
175
|
`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
176
|
|
|
147
|
-
###
|
|
177
|
+
### Choosing a payment pattern
|
|
148
178
|
|
|
149
|
-
|
|
179
|
+
Two patterns cover almost every consumer, and the examples ship both.
|
|
150
180
|
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
```
|
|
181
|
+
- **Pay the exact quote and return the change** ([`DiceConsumer.sol`](examples/DiceConsumer.sol)). Read `fee = quoteFee(callbackGasLimit)`, require `msg.value >= fee`, pay exactly `fee` and send `msg.value - fee` back to the caller. The front end sends `value` from `quoteRequestFee`, the unused buffer returns immediately, nothing accumulates as refund credit and the contract never holds user funds. Name the paying user as the refund address — any wallet can receive a native transfer or call `withdrawRefundCredit` — so an expiry refund goes back to whoever paid.
|
|
182
|
+
- **Forward `msg.value`** ([`RaffleConsumer.sol`](examples/RaffleConsumer.sol), [`LootDropConsumer.sol`](examples/LootDropConsumer.sol)). Fewer lines: the coordinator escrows its own quote, reverts `IncorrectFee` when that is more than arrived, and credits everything above it to the refund address. With a buffered off-chain quote most requests leave some credit, which that address pulls later with `withdrawRefundCredit`.
|
|
198
183
|
|
|
199
|
-
|
|
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.
|
|
184
|
+
A contract that funds requests from its own balance uses the first form without the change transfer. It then needs its own funding and withdrawal policy, and its refund address decides who receives expiry refunds.
|
|
203
185
|
|
|
204
186
|
## Reading results
|
|
205
187
|
|
|
@@ -216,29 +198,32 @@ All events come from the coordinator proxy, with `requestId` as the first indexe
|
|
|
216
198
|
|
|
217
199
|
Views on the coordinator (all in `coordinatorAbi`; only `getMappedResult` is part of `ID20VRF`, so declare a local interface in Solidity for the others):
|
|
218
200
|
|
|
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.
|
|
201
|
+
- `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.
|
|
220
202
|
- `getMapping(uint256 requestId) returns (RandomnessMapping.Spec)`: the stored `(operation, lower, upper, count, population)`; all zero (Raw) for `requestRandomness`. Reverts `UnknownRequest`.
|
|
221
203
|
- `getMappedResult(uint256 requestId) returns (uint256[])`: the stored word mapped with the stored spec. Reverts `NotFulfilled` before acceptance.
|
|
222
204
|
- `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
205
|
- `requestFeePaid(requestId)`, `requestRefundBps(requestId)`, `refundCredits(address)` and `refundCallbackDelivered(requestId)` show settlement.
|
|
224
206
|
|
|
225
|
-
|
|
207
|
+
[API.md](API.md) documents every view, event and error, including the order of events in a receipt.
|
|
208
|
+
|
|
209
|
+
Polling with ethers 6, after sending the request through a consumer such as `DiceConsumer`:
|
|
226
210
|
|
|
227
211
|
```js
|
|
228
212
|
import { Contract } from 'ethers';
|
|
229
213
|
import { coordinatorAbi } from '@d20dao/vrf-sdk/abi';
|
|
230
214
|
|
|
231
215
|
const coordinator = new Contract(coordinatorAddress, coordinatorAbi, provider);
|
|
232
|
-
const receipt = await (await
|
|
216
|
+
const receipt = await (await dice.roll({ value })).wait();
|
|
233
217
|
const requestId = receipt.logs
|
|
234
218
|
.filter((log) => log.address.toLowerCase() === coordinatorAddress.toLowerCase())
|
|
235
219
|
.map((log) => coordinator.interface.parseLog(log))
|
|
236
220
|
.find((event) => event?.name === 'RandomnessRequested').args.requestId;
|
|
237
221
|
|
|
238
222
|
for (;;) {
|
|
223
|
+
// Read the block first, so a proof included up to that block is visible in getRequest.
|
|
224
|
+
const { timestamp } = await provider.getBlock('latest');
|
|
239
225
|
const request = await coordinator.getRequest(requestId);
|
|
240
226
|
if (request.fulfilled) { console.log(await coordinator.getMappedResult(requestId)); break; }
|
|
241
|
-
const { timestamp } = await provider.getBlock('latest');
|
|
242
227
|
if (BigInt(timestamp) > request.deadline) break; // expired: refundRequest(requestId) is available
|
|
243
228
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
244
229
|
}
|
|
@@ -250,20 +235,37 @@ for (;;) {
|
|
|
250
235
|
|
|
251
236
|
- 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
237
|
- `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
|
-
-
|
|
254
|
-
-
|
|
238
|
+
- The SDK does not wrap viem or other clients; its ABIs are plain JSON and work with any library.
|
|
239
|
+
- To add Arc to a browser wallet, use `wallet_addEthereumChain` with the values from [Networks](#networks). The native currency uses 18 decimals:
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
await window.ethereum.request({
|
|
243
|
+
method: 'wallet_addEthereumChain',
|
|
244
|
+
params: [{
|
|
245
|
+
chainId: '0x13b2', // 5042, Arc Mainnet; Arc Testnet is '0x4cef52' (5042002)
|
|
246
|
+
chainName: 'Arc Mainnet',
|
|
247
|
+
nativeCurrency: { name: 'USDC', symbol: 'USDC', decimals: 18 },
|
|
248
|
+
rpcUrls: ['https://rpc.mainnet.arc.io'],
|
|
249
|
+
blockExplorerUrls: ['https://explorer.arc.io'],
|
|
250
|
+
}],
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
- 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.
|
|
254
|
+
- 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.
|
|
255
|
+
- Every public Arc RPC endpoint is on `*.arc.io`, and common browser ad-block filter lists block that domain, so a page that reads the chain directly fails with `net::ERR_BLOCKED_BY_CLIENT` for a share of users. Read through the connected wallet's EIP-1193 provider when there is one (check its chain ID first), or through a read-only relay you serve from your own origin; the [randomizer-demo](https://github.com/d20dao/randomizer-demo) does the latter for read methods only and leaves transactions to the wallet.
|
|
256
|
+
- Some RPC endpoints reject or rate-limit large JSON-RPC batches, and limits differ between providers and plans. ethers `JsonRpcProvider` batches up to 100 calls by default; lower `batchMaxCount` (`new JsonRpcProvider(url, 5042, { staticNetwork: true, batchMaxCount: 1 })`) when you meet one. Poll no faster than you need and cache what cannot change: once `fulfilled` is true, `randomness` and the mapped result are final.
|
|
255
257
|
|
|
256
258
|
## Request lifecycle
|
|
257
259
|
|
|
258
|
-
Epochs last 200 blocks.
|
|
260
|
+
Epochs last 200 blocks. Each epoch uses the catalog in force for it: 1 to 10 ordered sources, each a registered recipe with its signer (see [Recipes](#recipes)). The keeper selects a source using the canonical block hash at epoch start minus one and prepares its first validated API3 snapshot locally. If the selected source yields no valid packet, the next source in catalog order can be committed instead, one source per 20-block window (attempts 1 to count − 1); a saved response is never refreshed or resampled. The registry committer publishes, or a backup committer the owner allowed so that a second keeper can take over. Idle preparation publishes no transaction. An unused local snapshot can be retained for 50 epochs (10,000 blocks), subject to live-demand and unresolved-transaction protection.
|
|
259
261
|
|
|
260
262
|
A request escrows its quoted fee even when its epoch packet is not published yet, and fixes its original block, epoch, client seed, mapping, refund address, `feePaid`, `refundBps` and 60-second deadline. The keeper publishes the saved packet only for live paid demand. The randomness target becomes `max(requestBlock, committedBlock + 1)`, so its hash is unknown at publication; before publication the request has no usable target or VRF seed. Multiple requests share the packet, and timely requests can settle across epoch boundaries without changing their epoch.
|
|
261
263
|
|
|
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
|
|
264
|
+
Timely service is onchain proof acceptance at or before `requestedAt + 60` seconds; a pending transaction is not acceptance. At acceptance the keeper share, `keeperFeeBps` of `feePaid`, is paid to the submitting wallet when the registry authorizes it as its committer or one of its backup committers, and to the configured committer for any other submitter (a failed transfer becomes keeper credit) and the remainder becomes withdrawable protocol fees. With a 50% share, example A pays 0.176 USDC to the keeper and 0.176 USDC to the treasury. Callback failure still earns the fee; `retryCallback(requestId, gasLimit)` redelivers only the same accepted result and never pays a second share.
|
|
263
265
|
|
|
264
266
|
### Timing
|
|
265
267
|
|
|
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.
|
|
268
|
+
Each request's deadline is its block timestamp plus 60 seconds (`RESPONSE_TIMEOUT`). A proof accepted onchain at or before the deadline serves the request; after it the request can only be refunded. On Arc, a single request is normally fulfilled within a few seconds. Under load, 200 simultaneous requests were all delivered within 36 seconds, with a median of 19 seconds. Measured timings are not an SLA: wait up to the deadline, as in [Reading results](#reading-results), and handle expiry. If the keeper does not publish the request's epoch packet or a proof in time, for any reason, the request simply expires: nothing is fulfilled late, and the fee can be refunded as described below. Your application only needs to treat the request as expired.
|
|
267
269
|
|
|
268
270
|
### Expiry and refunds
|
|
269
271
|
|
|
@@ -283,26 +285,82 @@ The minimums were measured with the unmodified protocol sources behind `D20Proxy
|
|
|
283
285
|
|
|
284
286
|
### Batched fulfillment
|
|
285
287
|
|
|
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
|
|
288
|
+
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.
|
|
287
289
|
|
|
288
290
|
## Optional refund notification
|
|
289
291
|
|
|
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.
|
|
292
|
+
After `refundRequest` has paid the fixed refund address or recorded its refund credit, the coordinator calls `onRefund(requestId)` on the original consumer. Extend `D20VRFConsumer` and override `_onRefund(uint256 requestId)` to update application state, as `RaffleConsumer` does to allow a new draw; the base authenticates the coordinator. The callback only carries the request ID and does not imply that the consumer itself received money. Application assets and fees remain the application's responsibility.
|
|
291
293
|
|
|
292
294
|
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.
|
|
293
295
|
|
|
294
296
|
## Replay and verification
|
|
295
297
|
|
|
296
|
-
Use independently trusted successful receipts and state. Decode the registry `EpochCommitted` packet with `decodeEpochEvidencePacket` and verify with `replayEpochCommitment`, using the original source anchor, exact packet, commit block/time,
|
|
298
|
+
Use independently trusted successful receipts and state. Decode the registry `EpochCommitted` packet with `decodeEpochEvidencePacket` and verify with `replayEpochCommitment`, using the original source anchor, exact packet, commit block/time, the epoch's catalog with its recipe definitions and the registry identity. Decode the coordinator `FulfillmentEvidence` packet with `decodeEvidencePacket`, then call `replayCoordinator` with its exported input type (`Parameters<typeof replayCoordinator>[0]`).
|
|
297
299
|
|
|
298
300
|
`RequestContext` binds both `requestBlock` and `targetBlock`. Validate the epoch from the original request block, reconstruct the target from the actual publication block, and compare the event and stored transcript. Proof evidence is 416 bytes; fulfillment calldata is 452 bytes. Neither evidence packet has a version prefix. Choose the decoder from trusted emitter/event context. Decoding and mapping alone are not proof verification; replay does not authenticate RPC or establish receipt inclusion.
|
|
299
301
|
|
|
300
302
|
`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`.
|
|
301
303
|
|
|
302
|
-
|
|
304
|
+
Catalogs are per epoch. `catalogAt(epochId)` returns the hash, recipe ids and signers an epoch selects and commits with, and `Epoch.catalogHash` records that hash at publication. A registry starts with its initial catalog, recipes 0 to 3 with the four signers given at initialization, whose hash `catalogHash()` is bound into the configuration hash; `catalogHash()` and the initial signer getters never change. The owner replaces the catalog for epochs at least two ahead with `scheduleCatalog(recipes, signers, fromEpoch)`: 1 to 10 distinct registered recipes with one signer each, hashed as `keccak256(abi.encode(RECIPE_DOMAIN, recipes, signers))` (event `CatalogScheduled(fromEpoch, catalogHash, recipes, signers)`). A new schedule replaces a 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 catalog. For replay, build `epoch.catalog` from the epoch's `catalogAt` view with `resolveEpochCatalog(base, view)`, which recognizes the initial catalog by its hash (or from the `CatalogScheduled` history without replaced versions), while `configuration.catalogHash` stays the initial `catalogHash()`; `replayEpochCommitment` binds the supplied catalog to `record.catalogHash`.
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
import { readEpochRecipes, resolveEpochCatalog } from '@d20dao/vrf-sdk/epoch';
|
|
308
|
+
|
|
309
|
+
const [hash, recipes, signers] = await registry.catalogAt(epochId); // registry: ethers Contract with epochEntropyAbi
|
|
310
|
+
const recipeBook = await readEpochRecipes(provider, registryAddress, recipes.map(Number));
|
|
311
|
+
const catalog = resolveEpochCatalog({ registry: registryAddress, chainId, firstEpochStart, recipeBook }, { hash, recipes, signers });
|
|
312
|
+
```
|
|
303
313
|
|
|
304
314
|
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.
|
|
305
315
|
|
|
316
|
+
### Recipes
|
|
317
|
+
|
|
318
|
+
Epoch sources are recipes in an owner-managed, append-only registry in `EpochEntropy`. A recipe is its canonical request, whose `keccak256` is the query hash the signer signs; a data template that fixes the exact signed bytes the registry accepts ([Data templates](#data-templates)); and the JSON body keepers post to the provider gateway. `registerRecipe(canonicalRequest, template, body)` appends the next id (0 to 255) and emits `RecipeRegistered` with the full definition. A registered recipe never changes, so a changed listing becomes a new id. `recipeCount()` and `getRecipe(id)` read the registry. Every registry registers six built-in recipes itself, at initialization or in its recipe-registry upgrade:
|
|
319
|
+
|
|
320
|
+
| Recipe | Provider | Query | Exact signed record |
|
|
321
|
+
| --- | --- | --- | --- |
|
|
322
|
+
| 0 | Hyperliquid | `metaAndAssetCtxs`, dex `""`, projection symbol `/0/universe/0/name` and value `/1/0/dayNtlVlm` | `{"symbol":"BTC","value":"<decimal>"}` |
|
|
323
|
+
| 1 | dRPC | `jsonRpc` on Ethereum mainnet: `eth_call` of Multicall3 `getLastBlockHash()` at `latest` | `{"id":null,"jsonrpc":"2.0","result":"0x<64 lowercase hex>"}` |
|
|
324
|
+
| 2 | TickerLayer | `lastTrade`, crypto, BTCUSD | `{"symbol":"BTCUSD","price":<number>,"size":<number>,"timestamp":<1 to 16 digits>}` |
|
|
325
|
+
| 3 | TickerLayer | `lastTrade`, crypto, ETHUSD | as recipe 2 with ETHUSD |
|
|
326
|
+
| 4 | Nodary | `latestFeeds`, name ETH/USD | `{"ETH/USD":{"value":<number>,"timestamp":<13 digits>,"category":"crypto"}}` |
|
|
327
|
+
| 5 | dRPC | as recipe 1 on Base | as recipe 1 |
|
|
328
|
+
|
|
329
|
+
Both networks now draw from a five-source catalog, `[0, 1, 2, 4, 5]`: Hyperliquid BTC day volume, the dRPC Ethereum block hash, TickerLayer BTCUSD, Nodary ETH/USD and the dRPC Base block hash: Arc Testnet from epoch 966 and Arc Mainnet from epoch 848. Read the catalog an epoch actually used from `catalogAt(epochId)` rather than assuming this one.
|
|
330
|
+
|
|
331
|
+
`BUILTIN_EPOCH_RECIPES` (from `@d20dao/vrf-sdk/epoch`) holds these definitions, and replay uses them unless the catalog carries a `recipeBook`. For any other recipe, put its definition in `epoch.catalog.recipeBook`: `readEpochRecipes(provider, registry, ids)` reads `getRecipe` and checks each query hash, or rebuild it from `RecipeRegistered` logs. Replay checks every definition it uses: the committed packet must carry the recipe's canonical request and the signed data must match its template.
|
|
332
|
+
|
|
333
|
+
Registry implementations before variable catalogs hardcoded ANU random numbers as recipe 1. Neither public registry ever committed an epoch from it, so every published Arc epoch replays with the built-in recipes. Older evidence that did use ANU replays when its definition is supplied in `recipeBook` under id 1: canonical request `["randomNumbers",[["length",4],["size",8],["type","hex8"]]]`, body `{"operation":"randomNumbers","parameters":{"type":"hex8","length":4,"size":8}}` and the template described in [Data templates](#data-templates).
|
|
334
|
+
|
|
335
|
+
### Data templates
|
|
336
|
+
|
|
337
|
+
A template is a byte string of segments, each an opcode and its operands:
|
|
338
|
+
|
|
339
|
+
| Opcode | Segment | Operands | Matches |
|
|
340
|
+
| --- | --- | --- | --- |
|
|
341
|
+
| `0x01` | LITERAL | length n (1 to 128), then n bytes | exactly those bytes |
|
|
342
|
+
| `0x02` | HEX | n (1 to 128) | exactly n characters `0-9a-f` |
|
|
343
|
+
| `0x03` | DECIMAL | flags (0 to 3) | `0` or a nonzero digit followed by digits, then an optional fraction `.digits` when flags & 1 and an optional exponent `(e\|E)(+\|-)?digits` when flags & 2 |
|
|
344
|
+
| `0x04` | INTEGER | min, max (1 ≤ min ≤ max ≤ 128) | a nonzero digit followed by digits, min to max digits in total |
|
|
345
|
+
|
|
346
|
+
Variable segments are greedy and never backtrack. Signed data is accepted only when the segments consume it exactly, with no trailing bytes, and it is at most 128 bytes (`MAX_DATA_BYTES`). A well-formed template is at most 256 bytes (`MAX_TEMPLATE_BYTES`), contains at least one variable segment and has a shortest possible match of at most 128 bytes. Records therefore have exactly the bytes a template describes: no whitespace, extra fields, reordered keys, escaped characters, uppercase hex or other lengths.
|
|
347
|
+
|
|
348
|
+
The TypeScript helpers produce and check the same bytes as the contract:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import { encodeDataTemplate, decodeDataTemplate, matchesDataTemplate } from '@d20dao/vrf-sdk';
|
|
352
|
+
|
|
353
|
+
const template = encodeDataTemplate([
|
|
354
|
+
{ literal: '{"symbol":"BTCUSD","price":' }, { decimal: { fraction: true, exponent: true } },
|
|
355
|
+
{ literal: ',"size":' }, { decimal: { fraction: true, exponent: true } },
|
|
356
|
+
{ literal: ',"timestamp":' }, { integer: { minDigits: 1, maxDigits: 16 } }, { literal: '}' },
|
|
357
|
+
]); // equals BUILTIN_EPOCH_RECIPES[2].template
|
|
358
|
+
matchesDataTemplate(template, '0x' + Buffer.from('{"symbol":"BTCUSD","price":117000.5,"size":0.01,"timestamp":1789503538000}').toString('hex')); // true
|
|
359
|
+
decodeDataTemplate(template)[1]; // { decimal: { fraction: true, exponent: true } }
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
`encodeDataTemplate` throws with the rule a template breaks; `isValidDataTemplate` and `validateDataTemplate` check an encoded template. The ANU record `{"success":true,"type":"hex8","length":"4","data":["<16 hex>","<16 hex>","<16 hex>","<16 hex>"]}` is the literal `{"success":true,"type":"hex8","length":"4","data":["`, then HEX 16 and the literal `","` alternating, and the literal `"]}`.
|
|
363
|
+
|
|
306
364
|
## Security and trust
|
|
307
365
|
|
|
308
366
|
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`).
|
|
@@ -310,14 +368,16 @@ The contracts have not had an external security audit. The coordinator source ca
|
|
|
310
368
|
Trust model:
|
|
311
369
|
|
|
312
370
|
- **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
|
|
371
|
+
- **Owner settings without an upgrade.** Coordinator: fee recipient, keeper share (0–100%), pricing within the bounds in [Pricing](#pricing), and the refund ratio for future requests (50–100%). Registry: committer, up to four backup committers, recipe registration (append-only) and catalogs for epochs at least two ahead. Open requests keep their escrowed fee and refund ratio.
|
|
372
|
+
- **Publishers.** The committer and each backup committer can publish an epoch from any valid signed record of its selected source, under the same rules. A backup committer has no other role, and earns the keeper share of the requests whose proofs it submits itself.
|
|
373
|
+
- **Recipes.** A signature establishes what a provider's gateway signed for a recipe's request, not that the upstream value is unbiased. The owner decides which recipes and signers future epochs use; a lax template accepts more signed records for a publisher to choose from.
|
|
314
374
|
- **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
375
|
|
|
316
|
-
|
|
376
|
+
Both proxies are atomically initialized ERC1967 endpoints with owner-authorized UUPS upgrades, and the implementations behind them are locked against initialization. No setter rewrites a request, a published epoch or the VRF key, but upgrade authority can change code and is an explicit trust assumption. Stable proxy addresses alone do not identify executed code, so verify the implementation history of **both** proxies at the relevant receipts when you integrate, and again whenever the deployment manifest records an upgrade or a proxy emits `Upgraded(implementation)`. A mismatch with the manifest means stop and review before sending more requests.
|
|
317
377
|
|
|
318
378
|
## Use locally
|
|
319
379
|
|
|
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
|
|
380
|
+
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 all three examples from the installed package and checks the outcomes they publish. `npm pack` also produces an installable local artifact.
|
|
321
381
|
|
|
322
382
|
```js
|
|
323
383
|
import { builtins, mapRandomness, replayCoordinator, quoteRequestFee } from '@d20dao/vrf-sdk';
|
|
@@ -326,23 +386,23 @@ const mapping = builtins.d20();
|
|
|
326
386
|
// Use only an independently verified accepted word for real outcomes.
|
|
327
387
|
```
|
|
328
388
|
|
|
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
|
|
389
|
+
The root exports ESM and TypeScript declarations, including `quoteRequestFee`, `DEFAULT_FEE_BUFFER_BPS` and the `FeeQuote`, `FeeQuoteOptions` and `FeeQuoteProvider` types; `/epoch` exports epoch helpers, `BUILTIN_EPOCH_RECIPES`, `readEpochRecipes`, `resolveEpochCatalog` and `MAX_ATTESTATION_AGE`, and the root also exports the data-template helpers (`encodeDataTemplate`, `decodeDataTemplate`, `matchesDataTemplate`, `isValidDataTemplate`, `validateDataTemplate`). `/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.
|
|
330
390
|
|
|
331
|
-
##
|
|
391
|
+
## What this package is not
|
|
332
392
|
|
|
333
|
-
This SDK contains no keeper service, API fetching, proof generation, signer secrets or deployment automation.
|
|
393
|
+
This SDK contains no keeper service, API fetching, proof generation, signer secrets or deployment automation. Installing it neither authorizes nor performs anything on chain.
|
|
334
394
|
|
|
335
|
-
|
|
395
|
+
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, test fixtures and provers are excluded from the tarball.
|
|
336
396
|
|
|
337
|
-
|
|
397
|
+
Each replay fixture set records how it was produced, so a real API3 capture is never mistaken for a test signature. Fixtures are not included in the package. The browser-target bundle is executed under Node, not in an actual browser; independently trusted chain context is still required for real verification.
|
|
338
398
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
SDK installation provides consumer and verification tooling. Chain availability, provider quotas, upgrade administration and application settlement remain separate concerns. A healthy process alone does not guarantee a particular request's timely fulfillment.
|
|
399
|
+
SDK installation provides consumer and verification tooling. Chain availability, provider quotas, upgrade administration and application settlement remain separate concerns, and none of them guarantees a particular request's timely fulfillment.
|
|
342
400
|
|
|
343
401
|
## Deployments
|
|
344
402
|
|
|
345
|
-
|
|
403
|
+
Both networks run the implementations this package describes, behind the same proxy addresses as before: the recipe registry in `EpochEntropy` and the coordinator that pays the keeper share to the authorized wallet which submitted the accepted proof. The two chains run the same implementation addresses. Epochs published before the upgrade still replay with this SDK's built-in recipes.
|
|
404
|
+
|
|
405
|
+
Obtain proxy addresses, implementation addresses and independently checked code hashes from the public deployment manifests, [arc-mainnet.json](https://d20dao.org/deployments/arc-mainnet.json) and [arc-testnet.json](https://d20dao.org/deployments/arc-testnet.json). The addresses below are copied from them and are only valid together with the manifest revision they came from, because implementations move through owner-authorized upgrades. Before relying on this SDK's interface, check that the implementation at your chain's proxy matches the manifest entry.
|
|
346
406
|
|
|
347
407
|
### Arc Mainnet
|
|
348
408
|
|
|
@@ -353,11 +413,11 @@ Chain ID: **5042**. The live service; use the **coordinator proxy** when constru
|
|
|
353
413
|
| D20VRFCoordinator | Consumer entry point / proxy | [`0xd20da057469C45928912d983F45790C41e290571`](https://explorer.arc.io/address/0xd20da057469C45928912d983F45790C41e290571) |
|
|
354
414
|
| EpochEntropy | Epoch registry / proxy | [`0xd20Da048C1A68fa3Bc0B5f5Bc454D1530062C82D`](https://explorer.arc.io/address/0xd20Da048C1A68fa3Bc0B5f5Bc454D1530062C82D) |
|
|
355
415
|
| D20CostClient | Restricted cost client / proxy | [`0xD20da0048aED2BBb9f0e7078Bc452815D626D29d`](https://explorer.arc.io/address/0xD20da0048aED2BBb9f0e7078Bc452815D626D29d) |
|
|
356
|
-
| D20VRFCoordinator | Implementation | [`
|
|
357
|
-
| EpochEntropy | Implementation | [`
|
|
416
|
+
| D20VRFCoordinator | Implementation | [`0xd20da0DADa4352A1a9722be43a2D85923443458c`](https://explorer.arc.io/address/0xd20da0DADa4352A1a9722be43a2D85923443458c) |
|
|
417
|
+
| EpochEntropy | Implementation | [`0xd20dA048C969e5aDcC703Dfdf8220cc9dCB2f865`](https://explorer.arc.io/address/0xd20dA048C969e5aDcC703Dfdf8220cc9dCB2f865) |
|
|
358
418
|
| D20CostClient | Implementation | [`0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b`](https://explorer.arc.io/address/0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b) |
|
|
359
419
|
|
|
360
|
-
Addresses are copied from the [Arc Mainnet deployment manifest](https://d20dao.org/deployments/arc-mainnet.json).
|
|
420
|
+
Addresses are copied from the [Arc Mainnet deployment manifest](https://d20dao.org/deployments/arc-mainnet.json). Both networks run the same coordinator and registry implementations.
|
|
361
421
|
|
|
362
422
|
### Arc Testnet
|
|
363
423
|
|
|
@@ -368,8 +428,8 @@ Chain ID: **5042002**. For development and testing. Use the **coordinator proxy*
|
|
|
368
428
|
| D20VRFCoordinator | Consumer entry point / proxy | [`0xd20DA0FF9087d053f0291524Eac12abA1ADBd945`](https://testnet.arcscan.app/address/0xd20DA0FF9087d053f0291524Eac12abA1ADBd945) |
|
|
369
429
|
| EpochEntropy | Epoch registry / proxy | [`0xD20Da00B47A7cD2211dC4683E306913b05903756`](https://testnet.arcscan.app/address/0xD20Da00B47A7cD2211dC4683E306913b05903756) |
|
|
370
430
|
| D20CostClient | Restricted cost client / proxy | [`0xD20da026090B8472579a2B93030F1fC4c94807F1`](https://testnet.arcscan.app/address/0xD20da026090B8472579a2B93030F1fC4c94807F1) |
|
|
371
|
-
| D20VRFCoordinator | Implementation | [`
|
|
372
|
-
| EpochEntropy | Implementation | [`
|
|
431
|
+
| D20VRFCoordinator | Implementation | [`0xd20da0DADa4352A1a9722be43a2D85923443458c`](https://testnet.arcscan.app/address/0xd20da0DADa4352A1a9722be43a2D85923443458c) |
|
|
432
|
+
| EpochEntropy | Implementation | [`0xd20dA048C969e5aDcC703Dfdf8220cc9dCB2f865`](https://testnet.arcscan.app/address/0xd20dA048C969e5aDcC703Dfdf8220cc9dCB2f865) |
|
|
373
433
|
| D20CostClient | Implementation | [`0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b`](https://testnet.arcscan.app/address/0xD20DA00A872acfDe3e4721Fc1051BD23CC84B66b) |
|
|
374
434
|
|
|
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.
|
|
435
|
+
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. The cost client is internal tooling, not a shared application entry point.
|