Skip to content

Commit b2e4d56

Browse files
authored
[entropy] Entropy gas benchmarks and optimizations (#1153)
* add gas benchmark * fix benchmark * fix benchmark * fix benchmark * optimization 1: remove provider * move to u128 for fees * update benchmark * comment * reduce commitment storage * optimize storage more * fix fee fields in state * hmm * ok * fix * cleanup * this got out of hand * test overflow conditions * fix bad merge * doc
1 parent f1bec26 commit b2e4d56

File tree

9 files changed

+331
-100
lines changed

9 files changed

+331
-100
lines changed

target_chains/ethereum/contracts/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ A gas report should have a couple of tables like this:
8989

9090
For most of the methods, the minimum gas usage is an indication of our desired gas usage. Because the calls that store something in the storage
9191
for the first time in `setUp` use significantly more gas. For example, in the above table, there are two calls to `updatePriceFeeds`. The first
92-
call has happend in the `setUp` method and costed over a million gas and is not intended for our Benchmark. So our desired value is the
92+
call has happened in the `setUp` method and costed over a million gas and is not intended for our Benchmark. So our desired value is the
9393
minimum value which is around 380k gas.
9494

9595
If you like to optimize the contract and measure the gas optimization you can get gas snapshots using `forge snapshot` and evaluate your

target_chains/ethereum/contracts/contracts/entropy/Entropy.sol

Lines changed: 133 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import "@pythnetwork/entropy-sdk-solidity/EntropyStructs.sol";
66
import "@pythnetwork/entropy-sdk-solidity/EntropyErrors.sol";
77
import "@pythnetwork/entropy-sdk-solidity/EntropyEvents.sol";
88
import "@pythnetwork/entropy-sdk-solidity/IEntropy.sol";
9+
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
910
import "./EntropyState.sol";
1011

1112
// Entropy implements a secure 2-party random number generation procedure. The protocol
@@ -74,10 +75,26 @@ import "./EntropyState.sol";
7475
// cases where the user chooses not to reveal.
7576
contract Entropy is IEntropy, EntropyState {
7677
// TODO: Use an upgradeable proxy
77-
constructor(uint pythFeeInWei, address defaultProvider) {
78+
constructor(
79+
uint128 pythFeeInWei,
80+
address defaultProvider,
81+
bool prefillRequestStorage
82+
) {
7883
_state.accruedPythFeesInWei = 0;
7984
_state.pythFeeInWei = pythFeeInWei;
8085
_state.defaultProvider = defaultProvider;
86+
87+
if (prefillRequestStorage) {
88+
// Write some data to every storage slot in the requests array such that new requests
89+
// use a more consistent amount of gas.
90+
// Note that these requests are not live because their sequenceNumber is 0.
91+
for (uint8 i = 0; i < NUM_REQUESTS; i++) {
92+
EntropyStructs.Request storage req = _state.requests[i];
93+
req.provider = address(1);
94+
req.blockNumber = 1234;
95+
req.commitment = hex"0123";
96+
}
97+
}
8198
}
8299

83100
// Register msg.sender as a randomness provider. The arguments are the provider's configuration parameters
@@ -86,7 +103,7 @@ contract Entropy is IEntropy, EntropyState {
86103
//
87104
// chainLength is the number of values in the hash chain *including* the commitment, that is, chainLength >= 1.
88105
function register(
89-
uint feeInWei,
106+
uint128 feeInWei,
90107
bytes32 commitment,
91108
bytes calldata commitmentMetadata,
92109
uint64 chainLength,
@@ -121,7 +138,7 @@ contract Entropy is IEntropy, EntropyState {
121138
// Withdraw a portion of the accumulated fees for the provider msg.sender.
122139
// Calling this function will transfer `amount` wei to the caller (provided that they have accrued a sufficient
123140
// balance of fees in the contract).
124-
function withdraw(uint256 amount) public override {
141+
function withdraw(uint128 amount) public override {
125142
EntropyStructs.ProviderInfo storage providerInfo = _state.providers[
126143
msg.sender
127144
];
@@ -166,24 +183,33 @@ contract Entropy is IEntropy, EntropyState {
166183
providerInfo.sequenceNumber += 1;
167184

168185
// Check that fees were paid and increment the pyth / provider balances.
169-
uint requiredFee = getFee(provider);
186+
uint128 requiredFee = getFee(provider);
170187
if (msg.value < requiredFee) revert EntropyErrors.InsufficientFee();
171188
providerInfo.accruedFeesInWei += providerInfo.feeInWei;
172-
_state.accruedPythFeesInWei += (msg.value - providerInfo.feeInWei);
189+
_state.accruedPythFeesInWei += (SafeCast.toUint128(msg.value) -
190+
providerInfo.feeInWei);
173191

174192
// Store the user's commitment so that we can fulfill the request later.
175-
EntropyStructs.Request storage req = _state.requests[
176-
requestKey(provider, assignedSequenceNumber)
177-
];
193+
// Warning: this code needs to overwrite *every* field in the request, because the returned request can be
194+
// filled with arbitrary data.
195+
EntropyStructs.Request storage req = allocRequest(
196+
provider,
197+
assignedSequenceNumber
198+
);
178199
req.provider = provider;
179200
req.sequenceNumber = assignedSequenceNumber;
180-
req.userCommitment = userCommitment;
181-
req.providerCommitment = providerInfo.currentCommitment;
182-
req.providerCommitmentSequenceNumber = providerInfo
183-
.currentCommitmentSequenceNumber;
201+
req.numHashes = SafeCast.toUint32(
202+
assignedSequenceNumber -
203+
providerInfo.currentCommitmentSequenceNumber
204+
);
205+
req.commitment = keccak256(
206+
bytes.concat(userCommitment, providerInfo.currentCommitment)
207+
);
184208

185209
if (useBlockHash) {
186-
req.blockNumber = block.number;
210+
req.blockNumber = SafeCast.toUint96(block.number);
211+
} else {
212+
req.blockNumber = 0;
187213
}
188214

189215
emit Requested(req);
@@ -202,24 +228,24 @@ contract Entropy is IEntropy, EntropyState {
202228
bytes32 userRandomness,
203229
bytes32 providerRevelation
204230
) public override returns (bytes32 randomNumber) {
205-
// TODO: do we need to check that this request exists?
206231
// TODO: this method may need to be authenticated to prevent griefing
207-
bytes32 key = requestKey(provider, sequenceNumber);
208-
EntropyStructs.Request storage req = _state.requests[key];
209-
// This invariant should be guaranteed to hold by the key construction procedure above, but check it
210-
// explicitly to be extra cautious.
211-
if (req.sequenceNumber != sequenceNumber)
212-
revert EntropyErrors.AssertionFailure();
213-
214-
bool valid = isProofValid(
215-
req.providerCommitmentSequenceNumber,
216-
req.providerCommitment,
217-
sequenceNumber,
232+
EntropyStructs.Request storage req = findRequest(
233+
provider,
234+
sequenceNumber
235+
);
236+
// Check that there is a request for the given provider / sequence number.
237+
if (req.provider != provider || req.sequenceNumber != sequenceNumber)
238+
revert EntropyErrors.NoSuchRequest();
239+
240+
bytes32 providerCommitment = constructProviderCommitment(
241+
req.numHashes,
218242
providerRevelation
219243
);
220-
if (!valid) revert EntropyErrors.IncorrectProviderRevelation();
221-
if (constructUserCommitment(userRandomness) != req.userCommitment)
222-
revert EntropyErrors.IncorrectUserRevelation();
244+
bytes32 userCommitment = constructUserCommitment(userRandomness);
245+
if (
246+
keccak256(bytes.concat(userCommitment, providerCommitment)) !=
247+
req.commitment
248+
) revert EntropyErrors.IncorrectRevelation();
223249

224250
bytes32 blockHash = bytes32(uint256(0));
225251
if (req.blockNumber != 0) {
@@ -240,7 +266,7 @@ contract Entropy is IEntropy, EntropyState {
240266
randomNumber
241267
);
242268

243-
delete _state.requests[key];
269+
clearRequest(provider, sequenceNumber);
244270

245271
EntropyStructs.ProviderInfo storage providerInfo = _state.providers[
246272
provider
@@ -270,21 +296,20 @@ contract Entropy is IEntropy, EntropyState {
270296
address provider,
271297
uint64 sequenceNumber
272298
) public view override returns (EntropyStructs.Request memory req) {
273-
bytes32 key = requestKey(provider, sequenceNumber);
274-
req = _state.requests[key];
299+
req = findRequest(provider, sequenceNumber);
275300
}
276301

277302
function getFee(
278303
address provider
279-
) public view override returns (uint feeAmount) {
304+
) public view override returns (uint128 feeAmount) {
280305
return _state.providers[provider].feeInWei + _state.pythFeeInWei;
281306
}
282307

283308
function getAccruedPythFees()
284309
public
285310
view
286311
override
287-
returns (uint accruedPythFeesInWei)
312+
returns (uint128 accruedPythFeesInWei)
288313
{
289314
return _state.accruedPythFeesInWei;
290315
}
@@ -305,31 +330,90 @@ contract Entropy is IEntropy, EntropyState {
305330
);
306331
}
307332

308-
// Create a unique key for an in-flight randomness request (to store it in the contract state)
333+
// Create a unique key for an in-flight randomness request. Returns both a long key for use in the requestsOverflow
334+
// mapping and a short key for use in the requests array.
309335
function requestKey(
310336
address provider,
311337
uint64 sequenceNumber
312-
) internal pure returns (bytes32 hash) {
338+
) internal pure returns (bytes32 hash, uint8 shortHash) {
313339
hash = keccak256(abi.encodePacked(provider, sequenceNumber));
340+
shortHash = uint8(hash[0] & NUM_REQUESTS_MASK);
314341
}
315342

316-
// Validate that revelation at sequenceNumber is the correct value in the hash chain for a provider whose
317-
// last known revealed random number was lastRevelation at lastSequenceNumber.
318-
function isProofValid(
319-
uint64 lastSequenceNumber,
320-
bytes32 lastRevelation,
321-
uint64 sequenceNumber,
343+
// Construct a provider's commitment given their revealed random number and the distance in the hash chain
344+
// between the commitment and the revealed random number.
345+
function constructProviderCommitment(
346+
uint64 numHashes,
322347
bytes32 revelation
323-
) internal pure returns (bool valid) {
324-
if (sequenceNumber <= lastSequenceNumber)
325-
revert EntropyErrors.AssertionFailure();
326-
327-
bytes32 currentHash = revelation;
328-
while (sequenceNumber > lastSequenceNumber) {
348+
) internal pure returns (bytes32 currentHash) {
349+
currentHash = revelation;
350+
while (numHashes > 0) {
329351
currentHash = keccak256(bytes.concat(currentHash));
330-
sequenceNumber -= 1;
352+
numHashes -= 1;
331353
}
354+
}
355+
356+
// Find an in-flight request.
357+
// Note that this method can return requests that are not currently active. The caller is responsible for checking
358+
// that the returned request is active (if they care).
359+
function findRequest(
360+
address provider,
361+
uint64 sequenceNumber
362+
) internal view returns (EntropyStructs.Request storage req) {
363+
(bytes32 key, uint8 shortKey) = requestKey(provider, sequenceNumber);
364+
365+
req = _state.requests[shortKey];
366+
if (req.provider == provider && req.sequenceNumber == sequenceNumber) {
367+
return req;
368+
} else {
369+
req = _state.requestsOverflow[key];
370+
}
371+
}
372+
373+
// Clear the storage for an in-flight request, deleting it from the hash table.
374+
function clearRequest(address provider, uint64 sequenceNumber) internal {
375+
(bytes32 key, uint8 shortKey) = requestKey(provider, sequenceNumber);
376+
377+
EntropyStructs.Request storage req = _state.requests[shortKey];
378+
if (req.provider == provider && req.sequenceNumber == sequenceNumber) {
379+
req.sequenceNumber = 0;
380+
} else {
381+
delete _state.requestsOverflow[key];
382+
}
383+
}
384+
385+
// Allocate storage space for a new in-flight request. This method returns a pointer to a storage slot
386+
// that the caller should overwrite with the new request. Note that the memory at this storage slot may
387+
// -- and will -- be filled with arbitrary values, so the caller *must* overwrite every field of the returned
388+
// struct.
389+
function allocRequest(
390+
address provider,
391+
uint64 sequenceNumber
392+
) internal returns (EntropyStructs.Request storage req) {
393+
(, uint8 shortKey) = requestKey(provider, sequenceNumber);
394+
395+
req = _state.requests[shortKey];
396+
if (isActive(req)) {
397+
// There's already a prior active request in the storage slot we want to use.
398+
// Overflow the prior request to the requestsOverflow mapping.
399+
// It is important that this code overflows the *prior* request to the mapping, and not the new request.
400+
// There is a chance that some requests never get revealed and remain active forever. We do not want such
401+
// requests to fill up all of the space in the array and cause all new requests to incur the higher gas cost
402+
// of the mapping.
403+
//
404+
// This operation is expensive, but should be rare. If overflow happens frequently, increase
405+
// the size of the requests array to support more concurrent active requests.
406+
(bytes32 reqKey, ) = requestKey(req.provider, req.sequenceNumber);
407+
_state.requestsOverflow[reqKey] = req;
408+
}
409+
}
332410

333-
valid = currentHash == lastRevelation;
411+
// Returns true if a request is active, i.e., its corresponding random value has not yet been revealed.
412+
function isActive(
413+
EntropyStructs.Request storage req
414+
) internal view returns (bool) {
415+
// Note that a provider's initial registration occupies sequence number 0, so there is no way to construct
416+
// a randomness request with sequence number 0.
417+
return req.sequenceNumber != 0;
334418
}
335419
}

target_chains/ethereum/contracts/contracts/entropy/EntropyState.sol

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,39 @@ import "@pythnetwork/entropy-sdk-solidity/EntropyStructs.sol";
66

77
contract EntropyInternalStructs {
88
struct State {
9-
uint pythFeeInWei;
10-
uint accruedPythFeesInWei;
9+
// Fee charged by the pyth protocol in wei.
10+
uint128 pythFeeInWei;
11+
// Total quantity of fees (in wei) earned by the pyth protocol that are currently stored in the contract.
12+
// This quantity is incremented when fees are paid and decremented when fees are withdrawn.
13+
// Note that u128 can store up to ~10^36 wei, which is ~10^18 in native base tokens, which should be plenty.
14+
uint128 accruedPythFeesInWei;
15+
// The protocol sets a provider as default to simplify integration for developers.
1116
address defaultProvider;
17+
// Hash table for storing in-flight requests. Table keys are hash(provider, sequenceNumber), and the value is
18+
// the current request (if one is currently in-flight).
19+
//
20+
// Due to the vagaries of EVM opcode costs, it is inefficient to simply use a mapping here. Overwriting zero-valued
21+
// storage slots with non-zero values is expensive in EVM (21k gas). Using a mapping, each new request starts
22+
// from all-zero values, and thus incurs a substantial write cost. Deleting non-zero values does refund gas, but
23+
// unfortunately the refund is not substantial enough to matter.
24+
//
25+
// This data structure is a two-level hash table. It first tries to store new requests in the requests array at
26+
// an index determined by a few bits of the request's key. If that slot in the array is already occupied by a
27+
// prior request, the prior request is evicted into the requestsOverflow mapping. Requests in the array are
28+
// considered active if their sequenceNumber is > 0.
29+
//
30+
// WARNING: the number of requests must be kept in sync with the constants below
31+
EntropyStructs.Request[32] requests;
32+
mapping(bytes32 => EntropyStructs.Request) requestsOverflow;
33+
// Mapping from randomness providers to information about each them.
1234
mapping(address => EntropyStructs.ProviderInfo) providers;
13-
mapping(bytes32 => EntropyStructs.Request) requests;
1435
}
1536
}
1637

1738
contract EntropyState {
39+
// The size of the requests hash table. Must be a power of 2.
40+
uint8 public constant NUM_REQUESTS = 32;
41+
bytes1 public constant NUM_REQUESTS_MASK = 0x1f;
42+
1843
EntropyInternalStructs.State _state;
1944
}

0 commit comments

Comments
 (0)