Skip to content

Commit

Permalink
[Functions] Subscription deposit (#10513)
Browse files Browse the repository at this point in the history
* (feat): Add Functions subscription deposit

* Reduce FunctionsRouter.sol contract size

* Changes from review, cut more contract size, & add getSubscriptionsInRange method

* Add tests & changes from review

* Changes from review

* Additional changes

* checkDepositRefundability logic reflects name

* Requested changes

* Update gas snapshot & natspec comments

* More changes from code review

* (fix): amend unreachable code

* (test): Amend hardhat test

* (test): Amend go integration test SubscriptionDepositMinimumRequests naming
  • Loading branch information
justinkaseman authored Sep 8, 2023
1 parent 4620df0 commit a4ae3d4
Show file tree
Hide file tree
Showing 15 changed files with 523 additions and 220 deletions.
223 changes: 114 additions & 109 deletions contracts/gas-snapshots/functions.gas-snapshot

Large diffs are not rendered by default.

15 changes: 5 additions & 10 deletions contracts/src/v0.8/functions/dev/1_0_0/FunctionsBilling.sol
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,14 @@ abstract contract FunctionsBilling is Routable, IFunctionsBilling {
) internal returns (FunctionsResponse.FulfillResult) {
FunctionsResponse.Commitment memory commitment = abi.decode(onchainMetadata, (FunctionsResponse.Commitment));

if (s_requestCommitments[requestId] != keccak256(abi.encode(commitment))) {
return FunctionsResponse.FulfillResult.INVALID_COMMITMENT;
}

if (s_requestCommitments[requestId] == bytes32(0)) {
return FunctionsResponse.FulfillResult.INVALID_REQUEST_ID;
}

if (s_requestCommitments[requestId] != keccak256(abi.encode(commitment))) {
return FunctionsResponse.FulfillResult.INVALID_COMMITMENT;
}

uint96 juelsPerGas = _getJuelsPerGas(tx.gasprice);
// Gas overhead without callback
uint96 gasOverheadJuels = juelsPerGas *
Expand Down Expand Up @@ -308,15 +308,10 @@ abstract contract FunctionsBilling is Routable, IFunctionsBilling {
// @inheritdoc IFunctionsBilling
// @dev Only callable by the Router
// @dev Used by FunctionsRouter.sol during timeout of a request
function deleteCommitment(bytes32 requestId) external override onlyRouter returns (bool) {
// Ensure that commitment exists
if (s_requestCommitments[requestId] == bytes32(0)) {
return false;
}
function deleteCommitment(bytes32 requestId) external override onlyRouter {
// Delete commitment
delete s_requestCommitments[requestId];
emit CommitmentDeleted(requestId);
return true;
}

// ================================================================
Expand Down
69 changes: 39 additions & 30 deletions contracts/src/v0.8/functions/dev/1_0_0/FunctionsRouter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,13 @@ contract FunctionsRouter is IFunctionsRouter, FunctionsSubscriptions, Pausable,
// | Configuration state |
// ================================================================
struct Config {
uint16 maxConsumersPerSubscription; // ══════╗ Maximum number of consumers which can be added to a single subscription. This bound ensures we are able to loop over all subscription consumers as needed, without exceeding gas limits. Should a user require more consumers, they can use multiple subscriptions.
uint72 adminFee; // ║ Flat fee (in Juels of LINK) that will be paid to the Router owner for operation of the network
bytes4 handleOracleFulfillmentSelector; // ║ The function selector that is used when calling back to the Client contract
uint16 gasForCallExactCheck; // ═════════════╝ Used during calling back to the client. Ensures we have at least enough gas to be able to revert if gasAmount > 63//64*gas available.
uint32[] maxCallbackGasLimits; // ═══════════╸ List of max callback gas limits used by flag with GAS_FLAG_INDEX
uint16 maxConsumersPerSubscription; // ═════════╗ Maximum number of consumers which can be added to a single subscription. This bound ensures we are able to loop over all subscription consumers as needed, without exceeding gas limits. Should a user require more consumers, they can use multiple subscriptions.
uint72 adminFee; // ║ Flat fee (in Juels of LINK) that will be paid to the Router owner for operation of the network
bytes4 handleOracleFulfillmentSelector; // ║ The function selector that is used when calling back to the Client contract
uint16 gasForCallExactCheck; // ════════════════╝ Used during calling back to the client. Ensures we have at least enough gas to be able to revert if gasAmount > 63//64*gas available.
uint32[] maxCallbackGasLimits; // ══════════════╸ List of max callback gas limits used by flag with GAS_FLAG_INDEX
uint16 subscriptionDepositMinimumRequests; //═══╗ Amount of requests that must be completed before the full subscription balance will be released when closing a subscription account.
uint72 subscriptionDepositJuels; // ════════════╝ Amount of subscription funds that are held as a deposit until Config.subscriptionDepositMinimumRequests are made using the subscription.
}

Config private s_config;
Expand Down Expand Up @@ -180,6 +182,11 @@ contract FunctionsRouter is IFunctionsRouter, FunctionsSubscriptions, Pausable,
return s_config.maxConsumersPerSubscription;
}

/// @dev Used within FunctionsSubscriptions.sol
function _getSubscriptionDepositDetails() internal view override returns (uint16, uint72) {
return (s_config.subscriptionDepositMinimumRequests, s_config.subscriptionDepositJuels);
}

// ================================================================
// | Requests |
// ================================================================
Expand Down Expand Up @@ -227,6 +234,7 @@ contract FunctionsRouter is IFunctionsRouter, FunctionsSubscriptions, Pausable,

Subscription memory subscription = getSubscription(subscriptionId);
Consumer memory consumer = getConsumer(msg.sender, subscriptionId);
uint72 adminFee = s_config.adminFee;

// Forward request to DON
FunctionsResponse.Commitment memory commitment = coordinator.startRequest(
Expand All @@ -237,7 +245,7 @@ contract FunctionsRouter is IFunctionsRouter, FunctionsSubscriptions, Pausable,
dataVersion: dataVersion,
flags: getFlags(subscriptionId),
callbackGasLimit: callbackGasLimit,
adminFee: s_config.adminFee,
adminFee: adminFee,
initiatedRequests: consumer.initiatedRequests,
completedRequests: consumer.completedRequests,
availableBalance: subscription.balance - subscription.blockedBalance,
Expand All @@ -254,7 +262,7 @@ contract FunctionsRouter is IFunctionsRouter, FunctionsSubscriptions, Pausable,
s_requestCommitments[commitment.requestId] = keccak256(
abi.encode(
FunctionsResponse.Commitment({
adminFee: s_config.adminFee,
adminFee: adminFee,
coordinator: address(coordinator),
client: msg.sender,
subscriptionId: subscriptionId,
Expand Down Expand Up @@ -306,23 +314,27 @@ contract FunctionsRouter is IFunctionsRouter, FunctionsSubscriptions, Pausable,
revert OnlyCallableFromCoordinator();
}

if (s_requestCommitments[commitment.requestId] == bytes32(0)) {
resultCode = FunctionsResponse.FulfillResult.INVALID_REQUEST_ID;
emit RequestNotProcessed(commitment.requestId, commitment.coordinator, transmitter, resultCode);
return (resultCode, 0);
}
{
bytes32 commitmentHash = s_requestCommitments[commitment.requestId];

if (keccak256(abi.encode(commitment)) != s_requestCommitments[commitment.requestId]) {
resultCode = FunctionsResponse.FulfillResult.INVALID_COMMITMENT;
emit RequestNotProcessed(commitment.requestId, commitment.coordinator, transmitter, resultCode);
return (resultCode, 0);
}
if (commitmentHash == bytes32(0)) {
resultCode = FunctionsResponse.FulfillResult.INVALID_REQUEST_ID;
emit RequestNotProcessed(commitment.requestId, commitment.coordinator, transmitter, resultCode);
return (resultCode, 0);
}

// Check that the transmitter has supplied enough gas for the callback to succeed
if (gasleft() < commitment.callbackGasLimit + commitment.gasOverheadAfterCallback) {
resultCode = FunctionsResponse.FulfillResult.INSUFFICIENT_GAS_PROVIDED;
emit RequestNotProcessed(commitment.requestId, commitment.coordinator, transmitter, resultCode);
return (resultCode, 0);
if (keccak256(abi.encode(commitment)) != commitmentHash) {
resultCode = FunctionsResponse.FulfillResult.INVALID_COMMITMENT;
emit RequestNotProcessed(commitment.requestId, commitment.coordinator, transmitter, resultCode);
return (resultCode, 0);
}

// Check that the transmitter has supplied enough gas for the callback to succeed
if (gasleft() < commitment.callbackGasLimit + commitment.gasOverheadAfterCallback) {
resultCode = FunctionsResponse.FulfillResult.INSUFFICIENT_GAS_PROVIDED;
emit RequestNotProcessed(commitment.requestId, commitment.coordinator, transmitter, resultCode);
return (resultCode, 0);
}
}

{
Expand Down Expand Up @@ -512,18 +524,15 @@ contract FunctionsRouter is IFunctionsRouter, FunctionsSubscriptions, Pausable,
) {
revert InvalidProposal();
}
}

s_proposedContractSet = ContractProposalSet({ids: proposedContractSetIds, to: proposedContractSetAddresses});

// NOTE: iterations of this loop will not exceed MAX_PROPOSAL_SET_LENGTH
for (uint256 i = 0; i < proposedContractSetIds.length; ++i) {
emit ContractProposed({
proposedContractSetId: proposedContractSetIds[i],
proposedContractSetFromAddress: s_route[proposedContractSetIds[i]],
proposedContractSetToAddress: proposedContractSetAddresses[i]
proposedContractSetId: id,
proposedContractSetFromAddress: s_route[id],
proposedContractSetToAddress: proposedContract
});
}

s_proposedContractSet = ContractProposalSet({ids: proposedContractSetIds, to: proposedContractSetAddresses});
}

// @inheritdoc IRouterBase
Expand Down
114 changes: 73 additions & 41 deletions contracts/src/v0.8/functions/dev/1_0_0/FunctionsSubscriptions.sol
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ abstract contract FunctionsSubscriptions is IFunctionsSubscriptions, IERC677Rece
// | Balance state |
// ================================================================
// link token address
address internal immutable i_linkToken;
IERC20 internal immutable i_linkToken;

// s_totalLinkBalance tracks the total LINK sent to/from
// this contract through onTokenTransfer, cancelSubscription and oracleWithdraw.
Expand All @@ -42,15 +42,15 @@ abstract contract FunctionsSubscriptions is IFunctionsSubscriptions, IERC677Rece
// loop through all the current subscriptions via .getSubscription().
uint64 private s_currentSubscriptionId;

mapping(uint64 subscriptionId => IFunctionsSubscriptions.Subscription) private s_subscriptions;
mapping(uint64 subscriptionId => Subscription) private s_subscriptions;

// Maintains the list of keys in s_consumers.
// We do this for 2 reasons:
// 1. To be able to clean up all keys from s_consumers when canceling a subscription.
// 2. To be able to return the list of all consumers in getSubscription.
// Note that we need the s_consumers map to be able to directly check if a
// consumer is valid without reading all the consumers from storage.
mapping(address consumer => mapping(uint64 subscriptionId => IFunctionsSubscriptions.Consumer)) private s_consumers;
mapping(address consumer => mapping(uint64 subscriptionId => Consumer)) private s_consumers;

event SubscriptionCreated(uint64 indexed subscriptionId, address owner);
event SubscriptionFunded(uint64 indexed subscriptionId, uint256 oldBalance, uint256 newBalance);
Expand All @@ -70,7 +70,6 @@ abstract contract FunctionsSubscriptions is IFunctionsSubscriptions, IERC677Rece
error MustBeSubscriptionOwner();
error TimeoutNotExceeded();
error MustBeProposedOwner(address proposedOwner);
error TotalBalanceInvariantViolated(uint256 totalBalance, uint256 deductionAttempt); // Should never happen
event FundsRecovered(address to, uint256 amount);

// ================================================================
Expand All @@ -90,7 +89,7 @@ abstract contract FunctionsSubscriptions is IFunctionsSubscriptions, IERC677Rece
// | Initialization |
// ================================================================
constructor(address link) {
i_linkToken = link;
i_linkToken = IERC20(link);
}

// ================================================================
Expand Down Expand Up @@ -121,16 +120,17 @@ abstract contract FunctionsSubscriptions is IFunctionsSubscriptions, IERC677Rece
uint96 callbackGasCostJuels = juelsPerGas * gasUsed;
uint96 totalCostJuels = costWithoutCallbackJuels + adminFee + callbackGasCostJuels;

// Charge the subscription
if (s_subscriptions[subscriptionId].balance < totalCostJuels) {
if (
s_subscriptions[subscriptionId].balance < totalCostJuels ||
s_subscriptions[subscriptionId].blockedBalance < estimatedTotalCostJuels
) {
revert InsufficientBalance(s_subscriptions[subscriptionId].balance);
}

// Charge the subscription
s_subscriptions[subscriptionId].balance -= totalCostJuels;

// Unblock earmarked funds
if (s_subscriptions[subscriptionId].blockedBalance < estimatedTotalCostJuels) {
revert InsufficientBalance(s_subscriptions[subscriptionId].balance);
}
s_subscriptions[subscriptionId].blockedBalance -= estimatedTotalCostJuels;

// Pay the DON's fees and gas reimbursement
Expand All @@ -153,17 +153,17 @@ abstract contract FunctionsSubscriptions is IFunctionsSubscriptions, IERC677Rece
function ownerCancelSubscription(uint64 subscriptionId) external override {
_onlyRouterOwner();
_isExistingSubscription(subscriptionId);
_cancelSubscriptionHelper(subscriptionId, s_subscriptions[subscriptionId].owner);
_cancelSubscriptionHelper(subscriptionId, s_subscriptions[subscriptionId].owner, false);
}

// @inheritdoc IFunctionsSubscriptions
function recoverFunds(address to) external override {
_onlyRouterOwner();
uint256 externalBalance = IERC20(i_linkToken).balanceOf(address(this));
uint256 externalBalance = i_linkToken.balanceOf(address(this));
uint256 internalBalance = uint256(s_totalLinkBalance);
if (internalBalance < externalBalance) {
uint256 amount = externalBalance - internalBalance;
IERC20(i_linkToken).safeTransfer(to, amount);
i_linkToken.safeTransfer(to, amount);
emit FundsRecovered(to, amount);
}
// If the balances are equal, nothing to be done.
Expand All @@ -184,12 +184,9 @@ abstract contract FunctionsSubscriptions is IFunctionsSubscriptions, IERC677Rece
if (currentBalance < amount) {
revert InsufficientBalance(currentBalance);
}
if (s_totalLinkBalance < amount) {
revert TotalBalanceInvariantViolated(s_totalLinkBalance, amount);
}
s_withdrawableTokens[msg.sender] -= amount;
s_totalLinkBalance -= amount;
IERC20(i_linkToken).safeTransfer(recipient, amount);
i_linkToken.safeTransfer(recipient, amount);
}

// @notice Owner withdraw LINK earned through admin fees
Expand All @@ -205,13 +202,10 @@ abstract contract FunctionsSubscriptions is IFunctionsSubscriptions, IERC677Rece
if (currentBalance < amount) {
revert InsufficientBalance(currentBalance);
}
if (s_totalLinkBalance < amount) {
revert TotalBalanceInvariantViolated(s_totalLinkBalance, amount);
}
s_withdrawableTokens[address(this)] -= amount;
s_totalLinkBalance -= amount;

IERC20(i_linkToken).safeTransfer(recipient, amount);
i_linkToken.safeTransfer(recipient, amount);
}

// ================================================================
Expand Down Expand Up @@ -264,6 +258,27 @@ abstract contract FunctionsSubscriptions is IFunctionsSubscriptions, IERC677Rece
return s_subscriptions[subscriptionId];
}

/// @inheritdoc IFunctionsSubscriptions
function getSubscriptionsInRange(
uint64 subscriptionIdStart,
uint64 subscriptionIdEnd
) external view override returns (Subscription[] memory subscriptions) {
if (
subscriptionIdStart > subscriptionIdEnd ||
subscriptionIdEnd > s_currentSubscriptionId ||
s_currentSubscriptionId == 0
) {
revert InvalidCalldata();
}

subscriptions = new Subscription[]((subscriptionIdEnd - subscriptionIdStart) + 1);
for (uint256 i = 0; i <= subscriptionIdEnd - subscriptionIdStart; ++i) {
subscriptions[i] = s_subscriptions[uint64(subscriptionIdStart + i)];
}

return subscriptions;
}

// @inheritdoc IFunctionsSubscriptions
function getConsumer(address client, uint64 subscriptionId) public view override returns (Consumer memory) {
return s_consumers[client][subscriptionId];
Expand Down Expand Up @@ -363,9 +378,7 @@ abstract contract FunctionsSubscriptions is IFunctionsSubscriptions, IERC677Rece
_onlySenderThatAcceptedToS();

Consumer memory consumerData = s_consumers[consumer][subscriptionId];
if (!consumerData.allowed) {
revert InvalidConsumer();
}
_isAllowedConsumer(consumer, subscriptionId);
if (consumerData.initiatedRequests != consumerData.completedRequests) {
revert CannotRemoveWithPendingRequests();
}
Expand Down Expand Up @@ -410,33 +423,52 @@ abstract contract FunctionsSubscriptions is IFunctionsSubscriptions, IERC677Rece
emit SubscriptionConsumerAdded(subscriptionId, consumer);
}

// @inheritdoc IFunctionsSubscriptions
function cancelSubscription(uint64 subscriptionId, address to) external override {
_whenNotPaused();
_onlySubscriptionOwner(subscriptionId);
_onlySenderThatAcceptedToS();
/// @dev Overriden in FunctionsRouter.sol
function _getSubscriptionDepositDetails() internal virtual returns (uint16, uint72);

if (pendingRequestExists(subscriptionId)) {
revert CannotRemoveWithPendingRequests();
}

_cancelSubscriptionHelper(subscriptionId, to);
}

function _cancelSubscriptionHelper(uint64 subscriptionId, address to) private {
function _cancelSubscriptionHelper(uint64 subscriptionId, address toAddress, bool checkDepositRefundability) private {
Subscription memory subscription = s_subscriptions[subscriptionId];
uint96 balance = subscription.balance;
uint64 completedRequests = 0;

// NOTE: loop iterations are bounded by config.maxConsumers
// If no consumers, does nothing.
for (uint256 i = 0; i < subscription.consumers.length; ++i) {
delete s_consumers[subscription.consumers[i]][subscriptionId];
address consumer = subscription.consumers[i];
completedRequests += s_consumers[consumer][subscriptionId].completedRequests;
delete s_consumers[consumer][subscriptionId];
}
delete s_subscriptions[subscriptionId];
s_totalLinkBalance -= balance;

IERC20(i_linkToken).safeTransfer(to, uint256(balance));
(uint16 subscriptionDepositMinimumRequests, uint72 subscriptionDepositJuels) = _getSubscriptionDepositDetails();

// If subscription has not made enough requests, deposit will be forfeited
if (checkDepositRefundability && completedRequests < subscriptionDepositMinimumRequests) {
uint96 deposit = subscriptionDepositJuels > balance ? balance : subscriptionDepositJuels;
if (deposit > 0) {
s_withdrawableTokens[address(this)] += deposit;
balance -= deposit;
}
}

if (balance > 0) {
s_totalLinkBalance -= balance;
i_linkToken.safeTransfer(toAddress, uint256(balance));
}
emit SubscriptionCanceled(subscriptionId, toAddress, balance);
}

/// @inheritdoc IFunctionsSubscriptions
function cancelSubscription(uint64 subscriptionId, address to) external override {
_whenNotPaused();
_onlySubscriptionOwner(subscriptionId);
_onlySenderThatAcceptedToS();

if (pendingRequestExists(subscriptionId)) {
revert CannotRemoveWithPendingRequests();
}

emit SubscriptionCanceled(subscriptionId, to, balance);
_cancelSubscriptionHelper(subscriptionId, to, true);
}

// @inheritdoc IFunctionsSubscriptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ interface IFunctionsBilling {

// @notice Remove a request commitment that the Router has determined to be stale
// @param requestId - The request ID to remove
function deleteCommitment(bytes32 requestId) external returns (bool);
function deleteCommitment(bytes32 requestId) external;

// @notice Oracle withdraw LINK earned through fulfilling requests
// @notice If amount is 0 the full balance will be withdrawn
Expand Down
Loading

0 comments on commit a4ae3d4

Please sign in to comment.