[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
This commit is contained in:
Jayant Krishnamurthy 2023-11-29 14:37:42 -08:00 committed by GitHub
parent f1bec26581
commit b2e4d56d36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 330 additions and 99 deletions

View File

@ -89,7 +89,7 @@ A gas report should have a couple of tables like this:
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
for the first time in `setUp` use significantly more gas. For example, in the above table, there are two calls to `updatePriceFeeds`. The first
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
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
minimum value which is around 380k gas.
If you like to optimize the contract and measure the gas optimization you can get gas snapshots using `forge snapshot` and evaluate your

View File

@ -6,6 +6,7 @@ import "@pythnetwork/entropy-sdk-solidity/EntropyStructs.sol";
import "@pythnetwork/entropy-sdk-solidity/EntropyErrors.sol";
import "@pythnetwork/entropy-sdk-solidity/EntropyEvents.sol";
import "@pythnetwork/entropy-sdk-solidity/IEntropy.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "./EntropyState.sol";
// Entropy implements a secure 2-party random number generation procedure. The protocol
@ -74,10 +75,26 @@ import "./EntropyState.sol";
// cases where the user chooses not to reveal.
contract Entropy is IEntropy, EntropyState {
// TODO: Use an upgradeable proxy
constructor(uint pythFeeInWei, address defaultProvider) {
constructor(
uint128 pythFeeInWei,
address defaultProvider,
bool prefillRequestStorage
) {
_state.accruedPythFeesInWei = 0;
_state.pythFeeInWei = pythFeeInWei;
_state.defaultProvider = defaultProvider;
if (prefillRequestStorage) {
// Write some data to every storage slot in the requests array such that new requests
// use a more consistent amount of gas.
// Note that these requests are not live because their sequenceNumber is 0.
for (uint8 i = 0; i < NUM_REQUESTS; i++) {
EntropyStructs.Request storage req = _state.requests[i];
req.provider = address(1);
req.blockNumber = 1234;
req.commitment = hex"0123";
}
}
}
// Register msg.sender as a randomness provider. The arguments are the provider's configuration parameters
@ -86,7 +103,7 @@ contract Entropy is IEntropy, EntropyState {
//
// chainLength is the number of values in the hash chain *including* the commitment, that is, chainLength >= 1.
function register(
uint feeInWei,
uint128 feeInWei,
bytes32 commitment,
bytes calldata commitmentMetadata,
uint64 chainLength,
@ -121,7 +138,7 @@ contract Entropy is IEntropy, EntropyState {
// Withdraw a portion of the accumulated fees for the provider msg.sender.
// Calling this function will transfer `amount` wei to the caller (provided that they have accrued a sufficient
// balance of fees in the contract).
function withdraw(uint256 amount) public override {
function withdraw(uint128 amount) public override {
EntropyStructs.ProviderInfo storage providerInfo = _state.providers[
msg.sender
];
@ -166,24 +183,33 @@ contract Entropy is IEntropy, EntropyState {
providerInfo.sequenceNumber += 1;
// Check that fees were paid and increment the pyth / provider balances.
uint requiredFee = getFee(provider);
uint128 requiredFee = getFee(provider);
if (msg.value < requiredFee) revert EntropyErrors.InsufficientFee();
providerInfo.accruedFeesInWei += providerInfo.feeInWei;
_state.accruedPythFeesInWei += (msg.value - providerInfo.feeInWei);
_state.accruedPythFeesInWei += (SafeCast.toUint128(msg.value) -
providerInfo.feeInWei);
// Store the user's commitment so that we can fulfill the request later.
EntropyStructs.Request storage req = _state.requests[
requestKey(provider, assignedSequenceNumber)
];
// Warning: this code needs to overwrite *every* field in the request, because the returned request can be
// filled with arbitrary data.
EntropyStructs.Request storage req = allocRequest(
provider,
assignedSequenceNumber
);
req.provider = provider;
req.sequenceNumber = assignedSequenceNumber;
req.userCommitment = userCommitment;
req.providerCommitment = providerInfo.currentCommitment;
req.providerCommitmentSequenceNumber = providerInfo
.currentCommitmentSequenceNumber;
req.numHashes = SafeCast.toUint32(
assignedSequenceNumber -
providerInfo.currentCommitmentSequenceNumber
);
req.commitment = keccak256(
bytes.concat(userCommitment, providerInfo.currentCommitment)
);
if (useBlockHash) {
req.blockNumber = block.number;
req.blockNumber = SafeCast.toUint96(block.number);
} else {
req.blockNumber = 0;
}
emit Requested(req);
@ -202,24 +228,24 @@ contract Entropy is IEntropy, EntropyState {
bytes32 userRandomness,
bytes32 providerRevelation
) public override returns (bytes32 randomNumber) {
// TODO: do we need to check that this request exists?
// TODO: this method may need to be authenticated to prevent griefing
bytes32 key = requestKey(provider, sequenceNumber);
EntropyStructs.Request storage req = _state.requests[key];
// This invariant should be guaranteed to hold by the key construction procedure above, but check it
// explicitly to be extra cautious.
if (req.sequenceNumber != sequenceNumber)
revert EntropyErrors.AssertionFailure();
EntropyStructs.Request storage req = findRequest(
provider,
sequenceNumber
);
// Check that there is a request for the given provider / sequence number.
if (req.provider != provider || req.sequenceNumber != sequenceNumber)
revert EntropyErrors.NoSuchRequest();
bool valid = isProofValid(
req.providerCommitmentSequenceNumber,
req.providerCommitment,
sequenceNumber,
bytes32 providerCommitment = constructProviderCommitment(
req.numHashes,
providerRevelation
);
if (!valid) revert EntropyErrors.IncorrectProviderRevelation();
if (constructUserCommitment(userRandomness) != req.userCommitment)
revert EntropyErrors.IncorrectUserRevelation();
bytes32 userCommitment = constructUserCommitment(userRandomness);
if (
keccak256(bytes.concat(userCommitment, providerCommitment)) !=
req.commitment
) revert EntropyErrors.IncorrectRevelation();
bytes32 blockHash = bytes32(uint256(0));
if (req.blockNumber != 0) {
@ -240,7 +266,7 @@ contract Entropy is IEntropy, EntropyState {
randomNumber
);
delete _state.requests[key];
clearRequest(provider, sequenceNumber);
EntropyStructs.ProviderInfo storage providerInfo = _state.providers[
provider
@ -270,13 +296,12 @@ contract Entropy is IEntropy, EntropyState {
address provider,
uint64 sequenceNumber
) public view override returns (EntropyStructs.Request memory req) {
bytes32 key = requestKey(provider, sequenceNumber);
req = _state.requests[key];
req = findRequest(provider, sequenceNumber);
}
function getFee(
address provider
) public view override returns (uint feeAmount) {
) public view override returns (uint128 feeAmount) {
return _state.providers[provider].feeInWei + _state.pythFeeInWei;
}
@ -284,7 +309,7 @@ contract Entropy is IEntropy, EntropyState {
public
view
override
returns (uint accruedPythFeesInWei)
returns (uint128 accruedPythFeesInWei)
{
return _state.accruedPythFeesInWei;
}
@ -305,31 +330,90 @@ contract Entropy is IEntropy, EntropyState {
);
}
// Create a unique key for an in-flight randomness request (to store it in the contract state)
// Create a unique key for an in-flight randomness request. Returns both a long key for use in the requestsOverflow
// mapping and a short key for use in the requests array.
function requestKey(
address provider,
uint64 sequenceNumber
) internal pure returns (bytes32 hash) {
) internal pure returns (bytes32 hash, uint8 shortHash) {
hash = keccak256(abi.encodePacked(provider, sequenceNumber));
shortHash = uint8(hash[0] & NUM_REQUESTS_MASK);
}
// Validate that revelation at sequenceNumber is the correct value in the hash chain for a provider whose
// last known revealed random number was lastRevelation at lastSequenceNumber.
function isProofValid(
uint64 lastSequenceNumber,
bytes32 lastRevelation,
uint64 sequenceNumber,
// Construct a provider's commitment given their revealed random number and the distance in the hash chain
// between the commitment and the revealed random number.
function constructProviderCommitment(
uint64 numHashes,
bytes32 revelation
) internal pure returns (bool valid) {
if (sequenceNumber <= lastSequenceNumber)
revert EntropyErrors.AssertionFailure();
bytes32 currentHash = revelation;
while (sequenceNumber > lastSequenceNumber) {
) internal pure returns (bytes32 currentHash) {
currentHash = revelation;
while (numHashes > 0) {
currentHash = keccak256(bytes.concat(currentHash));
sequenceNumber -= 1;
numHashes -= 1;
}
}
valid = currentHash == lastRevelation;
// Find an in-flight request.
// Note that this method can return requests that are not currently active. The caller is responsible for checking
// that the returned request is active (if they care).
function findRequest(
address provider,
uint64 sequenceNumber
) internal view returns (EntropyStructs.Request storage req) {
(bytes32 key, uint8 shortKey) = requestKey(provider, sequenceNumber);
req = _state.requests[shortKey];
if (req.provider == provider && req.sequenceNumber == sequenceNumber) {
return req;
} else {
req = _state.requestsOverflow[key];
}
}
// Clear the storage for an in-flight request, deleting it from the hash table.
function clearRequest(address provider, uint64 sequenceNumber) internal {
(bytes32 key, uint8 shortKey) = requestKey(provider, sequenceNumber);
EntropyStructs.Request storage req = _state.requests[shortKey];
if (req.provider == provider && req.sequenceNumber == sequenceNumber) {
req.sequenceNumber = 0;
} else {
delete _state.requestsOverflow[key];
}
}
// Allocate storage space for a new in-flight request. This method returns a pointer to a storage slot
// that the caller should overwrite with the new request. Note that the memory at this storage slot may
// -- and will -- be filled with arbitrary values, so the caller *must* overwrite every field of the returned
// struct.
function allocRequest(
address provider,
uint64 sequenceNumber
) internal returns (EntropyStructs.Request storage req) {
(, uint8 shortKey) = requestKey(provider, sequenceNumber);
req = _state.requests[shortKey];
if (isActive(req)) {
// There's already a prior active request in the storage slot we want to use.
// Overflow the prior request to the requestsOverflow mapping.
// It is important that this code overflows the *prior* request to the mapping, and not the new request.
// There is a chance that some requests never get revealed and remain active forever. We do not want such
// requests to fill up all of the space in the array and cause all new requests to incur the higher gas cost
// of the mapping.
//
// This operation is expensive, but should be rare. If overflow happens frequently, increase
// the size of the requests array to support more concurrent active requests.
(bytes32 reqKey, ) = requestKey(req.provider, req.sequenceNumber);
_state.requestsOverflow[reqKey] = req;
}
}
// Returns true if a request is active, i.e., its corresponding random value has not yet been revealed.
function isActive(
EntropyStructs.Request storage req
) internal view returns (bool) {
// Note that a provider's initial registration occupies sequence number 0, so there is no way to construct
// a randomness request with sequence number 0.
return req.sequenceNumber != 0;
}
}

View File

@ -6,14 +6,39 @@ import "@pythnetwork/entropy-sdk-solidity/EntropyStructs.sol";
contract EntropyInternalStructs {
struct State {
uint pythFeeInWei;
uint accruedPythFeesInWei;
// Fee charged by the pyth protocol in wei.
uint128 pythFeeInWei;
// Total quantity of fees (in wei) earned by the pyth protocol that are currently stored in the contract.
// This quantity is incremented when fees are paid and decremented when fees are withdrawn.
// Note that u128 can store up to ~10^36 wei, which is ~10^18 in native base tokens, which should be plenty.
uint128 accruedPythFeesInWei;
// The protocol sets a provider as default to simplify integration for developers.
address defaultProvider;
// Hash table for storing in-flight requests. Table keys are hash(provider, sequenceNumber), and the value is
// the current request (if one is currently in-flight).
//
// Due to the vagaries of EVM opcode costs, it is inefficient to simply use a mapping here. Overwriting zero-valued
// storage slots with non-zero values is expensive in EVM (21k gas). Using a mapping, each new request starts
// from all-zero values, and thus incurs a substantial write cost. Deleting non-zero values does refund gas, but
// unfortunately the refund is not substantial enough to matter.
//
// This data structure is a two-level hash table. It first tries to store new requests in the requests array at
// an index determined by a few bits of the request's key. If that slot in the array is already occupied by a
// prior request, the prior request is evicted into the requestsOverflow mapping. Requests in the array are
// considered active if their sequenceNumber is > 0.
//
// WARNING: the number of requests must be kept in sync with the constants below
EntropyStructs.Request[32] requests;
mapping(bytes32 => EntropyStructs.Request) requestsOverflow;
// Mapping from randomness providers to information about each them.
mapping(address => EntropyStructs.ProviderInfo) providers;
mapping(bytes32 => EntropyStructs.Request) requests;
}
}
contract EntropyState {
// The size of the requests hash table. Must be a power of 2.
uint8 public constant NUM_REQUESTS = 32;
bytes1 public constant NUM_REQUESTS_MASK = 0x1f;
EntropyInternalStructs.State _state;
}

View File

@ -5,37 +5,36 @@ pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "@pythnetwork/entropy-sdk-solidity/EntropyStructs.sol";
import "../contracts/entropy/Entropy.sol";
import "./utils/EntropyTestUtils.t.sol";
// TODO
// - what's the impact of # of in-flight requests on gas usage? More requests => more hashes to
// verify the provider's value.
// - fuzz test?
contract EntropyTest is Test {
contract EntropyTest is Test, EntropyTestUtils {
Entropy public random;
uint pythFeeInWei = 7;
uint128 pythFeeInWei = 7;
address public provider1 = address(1);
bytes32[] provider1Proofs;
uint provider1FeeInWei = 8;
uint128 provider1FeeInWei = 8;
uint64 provider1ChainLength = 100;
bytes provider1Uri = bytes("https://foo.com");
bytes provider1CommitmentMetadata = hex"0100";
address public provider2 = address(2);
bytes32[] provider2Proofs;
uint provider2FeeInWei = 20;
uint128 provider2FeeInWei = 20;
bytes provider2Uri = bytes("https://bar.com");
address public user1 = address(3);
address public user2 = address(4);
address public unregisteredProvider = address(7);
uint256 MAX_UINT256 = 2 ** 256 - 1;
uint128 MAX_UINT128 = 2 ** 128 - 1;
bytes32 ALL_ZEROS = bytes32(uint256(0));
function setUp() public {
random = new Entropy(pythFeeInWei, provider1);
random = new Entropy(pythFeeInWei, provider1, false);
bytes32[] memory hashChain1 = generateHashChain(
provider1,
@ -64,21 +63,6 @@ contract EntropyTest is Test {
);
}
function generateHashChain(
address provider,
uint64 startSequenceNumber,
uint64 size
) public pure returns (bytes32[] memory hashChain) {
bytes32 initialValue = keccak256(
abi.encodePacked(provider, startSequenceNumber)
);
hashChain = new bytes32[](size);
for (uint64 i = 0; i < size; i++) {
hashChain[size - (i + 1)] = initialValue;
initialValue = keccak256(bytes.concat(initialValue));
}
}
// Test helper method for requesting a random value as user from provider.
function request(
address user,
@ -431,7 +415,7 @@ contract EntropyTest is Test {
// Check that overflowing the fee arithmetic causes the transaction to revert.
vm.prank(provider1);
random.register(
MAX_UINT256,
MAX_UINT128,
provider1Proofs[0],
hex"0100",
100,
@ -441,6 +425,20 @@ contract EntropyTest is Test {
random.getFee(provider1);
}
function testOverflow() public {
// msg.value overflows the uint128 fee variable
assertRequestReverts(2 ** 128, provider1, 42, false);
// block number is too large
vm.roll(2 ** 96);
assertRequestReverts(
pythFeeInWei + provider1FeeInWei,
provider1,
42,
true
);
}
function testFees() public {
// Insufficient fees causes a revert
assertRequestReverts(0, provider1, 42, false);
@ -497,7 +495,7 @@ contract EntropyTest is Test {
assertRequestReverts(pythFeeInWei + 12345 - 1, provider1, 42, false);
requestWithFee(user2, pythFeeInWei + 12345, provider1, 42, false);
uint providerOneBalance = provider1FeeInWei * 3 + 12345;
uint128 providerOneBalance = provider1FeeInWei * 3 + 12345;
assertEq(
random.getProviderInfo(provider1).accruedFeesInWei,
providerOneBalance

View File

@ -0,0 +1,99 @@
// SPDX-License-Identifier: Apache 2
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "@pythnetwork/entropy-sdk-solidity/EntropyStructs.sol";
import "../contracts/entropy/Entropy.sol";
import "./utils/EntropyTestUtils.t.sol";
// TODO
// - what's the impact of # of in-flight requests on gas usage? More requests => more hashes to
// verify the provider's value.
contract EntropyGasBenchmark is Test, EntropyTestUtils {
Entropy public random;
uint128 pythFeeInWei = 7;
address public provider1 = address(1);
bytes32[] provider1Proofs;
uint128 provider1FeeInWei = 8;
uint64 provider1ChainLength = 100;
address public user1 = address(3);
function setUp() public {
random = new Entropy(pythFeeInWei, provider1, true);
bytes32[] memory hashChain1 = generateHashChain(
provider1,
0,
provider1ChainLength
);
provider1Proofs = hashChain1;
vm.prank(provider1);
random.register(
provider1FeeInWei,
provider1Proofs[0],
hex"0100",
provider1ChainLength,
""
);
// Register twice so the commitment sequence number is nonzero. Zero values can be misleading
// when gas benchmarking.
vm.prank(provider1);
random.register(
provider1FeeInWei,
provider1Proofs[0],
hex"0100",
provider1ChainLength,
""
);
assert(
random.getProviderInfo(provider1).currentCommitmentSequenceNumber !=
0
);
}
// Test helper method for requesting a random value as user from provider.
function requestHelper(
address user,
uint randomNumber,
bool useBlockhash
) public returns (uint64 sequenceNumber) {
uint fee = random.getFee(provider1);
vm.deal(user, fee);
vm.prank(user);
sequenceNumber = random.request{value: fee}(
provider1,
random.constructUserCommitment(bytes32(randomNumber)),
useBlockhash
);
}
function revealHelper(
uint64 sequenceNumber,
uint userRandom
) public returns (bytes32 randomNumber) {
randomNumber = random.reveal(
provider1,
sequenceNumber,
bytes32(userRandom),
provider1Proofs[
sequenceNumber -
random
.getProviderInfo(provider1)
.originalCommitmentSequenceNumber
]
);
}
function testBasicFlow() public {
uint userRandom = 42;
uint64 sequenceNumber = requestHelper(user1, userRandom, true);
revealHelper(sequenceNumber, userRandom);
}
}

View File

@ -0,0 +1,24 @@
// SPDX-License-Identifier: Apache 2
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "@pythnetwork/entropy-sdk-solidity/EntropyStructs.sol";
abstract contract EntropyTestUtils is Test {
// Generate a hash chain for a provider that can be used for test purposes.
function generateHashChain(
address provider,
uint64 startSequenceNumber,
uint64 size
) public pure returns (bytes32[] memory hashChain) {
bytes32 initialValue = keccak256(
abi.encodePacked(provider, startSequenceNumber)
);
hashChain = new bytes32[](size);
for (uint64 i = 0; i < size; i++) {
hashChain[size - (i + 1)] = initialValue;
initialValue = keccak256(bytes.concat(initialValue));
}
}
}

View File

@ -6,17 +6,16 @@ library EntropyErrors {
// An invariant of the contract failed to hold. This error indicates a software logic bug.
error AssertionFailure();
// The provider being registered has already registered
// Signature: TODO
error ProviderAlreadyRegistered();
// The requested provider does not exist.
error NoSuchProvider();
// The specified request does not exist.
error NoSuchRequest();
// The randomness provider is out of commited random numbers. The provider needs to
// rotate their on-chain commitment to resolve this error.
error OutOfRandomness();
// The transaction fee was not sufficient
error InsufficientFee();
// The user's revealed random value did not match their commitment.
error IncorrectUserRevelation();
// The provider's revealed random value did not match their commitment.
error IncorrectProviderRevelation();
// Either the user's or the provider's revealed random values did not match their commitment.
error IncorrectRevelation();
}

View File

@ -3,16 +3,9 @@
pragma solidity ^0.8.0;
contract EntropyStructs {
struct State {
uint pythFeeInWei;
uint accruedPythFeesInWei;
mapping(address => ProviderInfo) providers;
mapping(bytes32 => Request) requests;
}
struct ProviderInfo {
uint feeInWei;
uint accruedFeesInWei;
uint128 feeInWei;
uint128 accruedFeesInWei;
// The commitment that the provider posted to the blockchain, and the sequence number
// where they committed to this. This value is not advanced after the provider commits,
// and instead is stored to help providers track where they are in the hash chain.
@ -42,12 +35,21 @@ contract EntropyStructs {
}
struct Request {
// Storage slot 1 //
address provider;
uint64 sequenceNumber;
bytes32 userCommitment;
bytes32 providerCommitment;
uint64 providerCommitmentSequenceNumber;
// The number of hashes required to verify the provider revelation.
uint32 numHashes;
// Storage slot 2 //
// The commitment is keccak256(userCommitment, providerCommitment). Storing the hash instead of both saves 20k gas by
// eliminating 1 store.
bytes32 commitment;
// Storage slot 3 //
// If nonzero, the randomness requester wants the blockhash of this block to be incorporated into the random number.
uint256 blockNumber;
// Note that we're using a uint96 such that we have an additional 20 bytes of storage afterward for an address.
// Although block.number returns a uint256, 96 bits should be plenty to index all of the blocks ever generated.
uint96 blockNumber;
// TODO: store the calling contract address here and authenticate the reveal method
}
}

View File

@ -10,7 +10,7 @@ interface IEntropy is EntropyEvents {
//
// chainLength is the number of values in the hash chain *including* the commitment, that is, chainLength >= 1.
function register(
uint feeInWei,
uint128 feeInWei,
bytes32 commitment,
bytes calldata commitmentMetadata,
uint64 chainLength,
@ -20,7 +20,7 @@ interface IEntropy is EntropyEvents {
// Withdraw a portion of the accumulated fees for the provider msg.sender.
// Calling this function will transfer `amount` wei to the caller (provided that they have accrued a sufficient
// balance of fees in the contract).
function withdraw(uint256 amount) external;
function withdraw(uint128 amount) external;
// As a user, request a random number from `provider`. Prior to calling this method, the user should
// generate a random number x and keep it secret. The user should then compute hash(x) and pass that
@ -63,12 +63,12 @@ interface IEntropy is EntropyEvents {
uint64 sequenceNumber
) external view returns (EntropyStructs.Request memory req);
function getFee(address provider) external view returns (uint feeAmount);
function getFee(address provider) external view returns (uint128 feeAmount);
function getAccruedPythFees()
external
view
returns (uint accruedPythFeesInWei);
returns (uint128 accruedPythFeesInWei);
function constructUserCommitment(
bytes32 userRandomness