Skip to content

Commit f841dfc

Browse files
authored
feat(entropy): Add callback failure states to the contract (#2546)
Currently, the Entropy keeper cannot invoke a callback if the underlying callback fails. This creates confusion for users, who can't distinguish between "the keeper is offline" and "my callback code reverts". This change creates a failure flow for callbacks that allows the keeper's transaction to succeed while explicitly reporting a failure of the underlying callback. Requests with failing callbacks remain on the blockchain and can be re-invoked by users. This feature allows users to gracefully recover in cases where the callback fails for transient reasons. (In the future, it will also allow recovery in the case where the callback uses too much gas.) Note that this PR doesn't cover the case where the callback uses too much gas. I will send a follow-up PR for that.
1 parent 9bb7a5a commit f841dfc

File tree

10 files changed

+433
-78
lines changed

10 files changed

+433
-78
lines changed

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import "@pythnetwork/entropy-sdk-solidity/EntropyEvents.sol";
88
import "@pythnetwork/entropy-sdk-solidity/IEntropy.sol";
99
import "@pythnetwork/entropy-sdk-solidity/IEntropyConsumer.sol";
1010
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
11+
import "@nomad-xyz/excessively-safe-call/src/ExcessivelySafeCall.sol";
1112
import "./EntropyState.sol";
13+
import "@pythnetwork/entropy-sdk-solidity/EntropyStatusConstants.sol";
1214

1315
// Entropy implements a secure 2-party random number generation procedure. The protocol
1416
// is an extension of a simple commit/reveal protocol. The original version has the following steps:
@@ -76,6 +78,8 @@ import "./EntropyState.sol";
7678
// the user is always incentivized to reveal their random number, and that the protocol has an escape hatch for
7779
// cases where the user chooses not to reveal.
7880
abstract contract Entropy is IEntropy, EntropyState {
81+
using ExcessivelySafeCall for address;
82+
7983
function _initialize(
8084
address admin,
8185
uint128 pythFeeInWei,
@@ -247,7 +251,9 @@ abstract contract Entropy is IEntropy, EntropyState {
247251

248252
req.blockNumber = SafeCast.toUint64(block.number);
249253
req.useBlockhash = useBlockhash;
250-
req.isRequestWithCallback = isRequestWithCallback;
254+
req.callbackStatus = isRequestWithCallback
255+
? EntropyStatusConstants.CALLBACK_NOT_STARTED
256+
: EntropyStatusConstants.CALLBACK_NOT_NECESSARY;
251257
}
252258

253259
// As a user, request a random number from `provider`. Prior to calling this method, the user should
@@ -403,7 +409,7 @@ abstract contract Entropy is IEntropy, EntropyState {
403409
}
404410

405411
// Fulfill a request for a random number. This method validates the provided userRandomness and provider's proof
406-
// against the corresponding commitments in the in-flight request. If both values are validated, this function returns
412+
// against the corresponding commitments in the in-flight request. If both values are validated, this method returns
407413
// the corresponding random number.
408414
//
409415
// Note that this function can only be called once per in-flight request. Calling this function deletes the stored
@@ -423,7 +429,9 @@ abstract contract Entropy is IEntropy, EntropyState {
423429
sequenceNumber
424430
);
425431

426-
if (req.isRequestWithCallback) {
432+
if (
433+
req.callbackStatus != EntropyStatusConstants.CALLBACK_NOT_NECESSARY
434+
) {
427435
revert EntropyErrors.InvalidRevealCall();
428436
}
429437

@@ -467,9 +475,14 @@ abstract contract Entropy is IEntropy, EntropyState {
467475
sequenceNumber
468476
);
469477

470-
if (!req.isRequestWithCallback) {
478+
if (
479+
!(req.callbackStatus ==
480+
EntropyStatusConstants.CALLBACK_NOT_STARTED ||
481+
req.callbackStatus == EntropyStatusConstants.CALLBACK_FAILED)
482+
) {
471483
revert EntropyErrors.InvalidRevealCall();
472484
}
485+
473486
bytes32 blockHash;
474487
bytes32 randomNumber;
475488
(randomNumber, blockHash) = revealHelper(
@@ -480,26 +493,75 @@ abstract contract Entropy is IEntropy, EntropyState {
480493

481494
address callAddress = req.requester;
482495

483-
emit RevealedWithCallback(
484-
req,
485-
userRandomNumber,
486-
providerRevelation,
487-
randomNumber
488-
);
489-
490-
clearRequest(provider, sequenceNumber);
491-
492-
// Check if the callAddress is a contract account.
493-
uint len;
494-
assembly {
495-
len := extcodesize(callAddress)
496-
}
497-
if (len != 0) {
498-
IEntropyConsumer(callAddress)._entropyCallback(
499-
sequenceNumber,
500-
provider,
496+
// Requests that haven't been invoked yet will be invoked safely (catching reverts), and
497+
// any reverts will be reported as an event. Any failing requests move to a failure state
498+
// at which point they can be recovered. The recovery flow invokes the callback directly
499+
// (no catching errors) which allows callers to easily see the revert reason.
500+
if (req.callbackStatus == EntropyStatusConstants.CALLBACK_NOT_STARTED) {
501+
req.callbackStatus = EntropyStatusConstants.CALLBACK_IN_PROGRESS;
502+
bool success;
503+
bytes memory ret;
504+
(success, ret) = callAddress.excessivelySafeCall(
505+
gasleft(), // TODO: providers need to be able to configure this in the future.
506+
256, // copy at most 256 bytes of the return value into ret.
507+
abi.encodeWithSelector(
508+
IEntropyConsumer._entropyCallback.selector,
509+
sequenceNumber,
510+
provider,
511+
randomNumber
512+
)
513+
);
514+
// Reset status to not started here in case the transaction reverts.
515+
req.callbackStatus = EntropyStatusConstants.CALLBACK_NOT_STARTED;
516+
517+
if (success) {
518+
emit RevealedWithCallback(
519+
req,
520+
userRandomNumber,
521+
providerRevelation,
522+
randomNumber
523+
);
524+
clearRequest(provider, sequenceNumber);
525+
} else if (ret.length > 0) {
526+
// Callback reverted for some reason that is *not* out-of-gas.
527+
emit CallbackFailed(
528+
provider,
529+
req.requester,
530+
sequenceNumber,
531+
userRandomNumber,
532+
providerRevelation,
533+
randomNumber,
534+
ret
535+
);
536+
req.callbackStatus = EntropyStatusConstants.CALLBACK_FAILED;
537+
} else {
538+
// The callback ran out of gas
539+
// TODO: this case will go away once we add provider gas limits, so we're not putting in a custom error type.
540+
require(false, "provider needs to send more gas");
541+
}
542+
} else {
543+
// This case uses the checks-effects-interactions pattern to avoid reentry attacks
544+
emit RevealedWithCallback(
545+
req,
546+
userRandomNumber,
547+
providerRevelation,
501548
randomNumber
502549
);
550+
551+
clearRequest(provider, sequenceNumber);
552+
553+
// Check if the callAddress is a contract account.
554+
uint len;
555+
assembly {
556+
len := extcodesize(callAddress)
557+
}
558+
if (len != 0) {
559+
IEntropyConsumer(callAddress)._entropyCallback(
560+
sequenceNumber,
561+
provider,
562+
randomNumber
563+
);
564+
}
503565
}
504566
}
505567

0 commit comments

Comments
 (0)