Adding a Chainlink Oracle to our Patreon contracts
Intro
In this post I’ll show you how I extended the basic Patreon contracts
from my previous post
with a new feature: for each subscription period we’ll waive the subscription fee
for one lucky subscriber. When the Patreon owner charges the subscription for that period,
one subscriber won’t be charged. Specifically, if we have n
subscribers then each
subscriber will have their fee waived with probability 1/n
.
We’ll need an oracle to introduce randomness to our contracts. I chose Chainlink’s VRF Oracle because it’s the most popular and well documented.
Background on Chainlink VRF
VRFv2 is a Chainlink oracle for delivering random numbers to your smart contract.
Request -> Callback model
The oracle follows the request/callback model. Your consumer calls requestRandomWords
on the oracle contract. After a predetermined number of block confirmations the oracle
will call your contract’s fulfillRandomWords
function with the random numbers you requested.
In our case the Consumer is a Patreon contract and the Oracle is VRFCoordinatorV2 contract deployed on Rinkeby.
VRF Subscriptions
We pay for our usage of the oracle with LINK tokens. Chainlink has a concept of a subscription which holds the LINK funds and manages your consumers. When your consumer calls the Oracle the funds are drawn from the subscription. The easiest way to create a subscription is to go to https://vrf.chain.link/rinkeby. Your subscription will have an associated id. As the subscription’s owner you can add and remove consumers. Although you can use their UI to add consumers, we’re going to add consumers programmatically from our updated patreon registry contract.
Contract updates
We’ll create v2 versions of Patreon.sol
and PatreonRegistry.sol
to hold our upgrades.
PatreonV2.sol
The key change for this contract is the chargeSubscriptions
function. The charge occurs
across separate transactions now because we have to wait for the oracle to deliver the random
numbers. We have to process the charge in the following order:
- Initiate the charge by requesting random numbers from the oracle
- Wait for the oracle to supply the random numbers
- Once we have the random numbers then execute the charge
We’ll introduce an enum and some additional variables to keep track of the new state and the order of actions
enum ChargeStatus {
INITIATE_CHARGE,
PENDING_RANDOM_WORDS,
EXECUTE_CHARGE
}
ChargeStatus public chargeStatus = ChargeStatus.INITIATE_CHARGE;
uint256 public _requestId;
uint256[] public _randomWords;
address[] public subscribersToCharge;
_requestId
is to keep track of which VRF request we’re waiting for_randomWords
is where we’ll store the random numbers given to us by the oraclesubscribersToCharge
is where we’ll store the addresses of the subscribers we’re going to process in the third and finalEXECUTE_CHARGE
step
Now we’ll update the chargeSubscriptions
function to perform different actions
depending on the state
function chargeSubscription(address[] calldata subscribers)
external
override
onlyOwner
{
require(subscriberCount > 0, "You need subscribers first lolz");
require(chargeStatus == ChargeStatus.INITIATE_CHARGE || chargeStatus == ChargeStatus.EXECUTE_CHARGE, "Invalid chargeStatus");
if (chargeStatus == ChargeStatus.INITIATE_CHARGE) {
initateCharge(subscribers);
} else if (chargeStatus == ChargeStatus.EXECUTE_CHARGE) {
executeCharge();
}
}
If the state is INITATE_CHARGE
we’ll make our request to the oracle and store
the appropriate variables for later
function initateCharge(address[] memory subscribers) private {
assert(chargeStatus == ChargeStatus.INITIATE_CHARGE);
require(subscribers.length <= 500, "Exceeded VRFCoordinatorV2.MAX_NUM_WORDS");
subscribersToCharge = subscribers;
chargeStatus = ChargeStatus.PENDING_RANDOM_WORDS;
_requestId = COORDINATOR.requestRandomWords(
keyHash,
chainlinkSubscriptionId,
3,
callbackGasLimit,
uint32(subscribers.length)
);
}
Note we are requesting a random number for each subscriber to be charged. This means
we can’t exceed the oracle’s limit, which is 500 random numbers. After initiating the
charge we must wait for the oracle to call fulfillRandomWords
. In our implementation
we’ll simply store the random words to be used for later.
function fulfillRandomWords(
uint256 requestId,
uint256[] memory randomWords
)
internal
override
{
require(chargeStatus == ChargeStatus.PENDING_RANDOM_WORDS, "Invalid chargeStatus");
require(requestId == _requestId);
_randomWords = randomWords;
chargeStatus = ChargeStatus.EXECUTE_CHARGE;
}
Now that our status is EXECUTE_CHARGE
we can call chargeSubscriptions
again and
complete the charge. The executeCharge
function is similar to the original
chargeSubscriptions
implementation except for a new condition in the for loop to waive
the subscriber’s fee:
function executeCharge() private {
assert(chargeStatus == ChargeStatus.EXECUTE_CHARGE);
assert(subscribersToCharge.length == _randomWords.length);
for (uint i = 0; i < subscribersToCharge.length; i++) {
Subscriber storage subscriber = _subscribers[subscribersToCharge[i]];
// We can charge a subscriber iff `isSubscribed` == true && `lastChargedAt` + `subscriptionPeriod` >= `block.timestamp`.abi
// Simply ignore addresses that don't match this criteria
if (!subscriber.isSubscribed || subscriber.lastChargedAt + subscriptionPeriod > block.timestamp)
continue;
// Waive the subscription fee with 1/numSubscribers probability. On average 1 subscriber
// per period will have their subscription waived. Note the edge case with 1 subscriber
// Based on our formula they will have a 100% chance of having the fee waived. So instead
// we make it a 50% chance.
if (
subscriberCount == 1 && _randomWords[i] % 2 == 0 ||
subscriberCount > 1 && _randomWords[i] % subscriberCount == 0
)
{
// Fee is waived for this subscriber
emit FeeWaived(subscribersToCharge[i]);
continue;
}
uint subscriptionBalanceBeforeCharge = subscriber.balance;
if (subscriber.balance < subscriptionFee) {
// Subscriber has insufficient funds so we transfer their balance to the owner and cancel
// the subscription
ownerBalance += subscriptionBalanceBeforeCharge;
subscriber.balance = 0;
subscriber.isSubscribed = false;
subscriber.lastChargedAt = block.timestamp;
subscriberCount -= 1;
emit SubscriptionCanceled(subscribersToCharge[i], subscriptionBalanceBeforeCharge, block.timestamp);
} else {
// Subscriber has sufficient funds so we allocate the fee amount to the owner balance and
// decrement it from the subscription balance.
ownerBalance += subscriptionFee;
subscriber.balance -= subscriptionFee;
subscriber.lastChargedAt = block.timestamp;
}
emit Charged(subscribersToCharge[i], subscriptionBalanceBeforeCharge, block.timestamp);
}
chargeStatus = ChargeStatus.INITIATE_CHARGE;
}
Understanding the fee waiver formula
I wanted to go into more detail on this snippet
// Waive the subscription fee with 1/numSubscribers probability. On average 1 subscriber
// per period will have their subscription waived. Note the edge case with 1 subscriber
// Based on our formula they will have a 100% chance of having the fee waived. So instead
// we make it a 50% chance.
if (
subscriberCount == 1 && _randomWords[i] % 2 == 0 ||
subscriberCount > 1 && _randomWords[i] % subscriberCount == 0
)
{
// Fee is waived for this subscriber
emit FeeWaived(subscribersToCharge[i]);
continue;
}
Remember subscriberCount
represents the total number of subscribers,
which could be higher than subscribersToCharge.length
. When we compute
x = _randomWords[i] % subscriberCount
, there’s approximately 1 / subscriberCount
probability that x
is 0
(I say approximately because I haven’t accounted
for modulo bias, which is an easy “gotcha” to fall for when attempting to restrict
some random number to a desired range).
You may be confused about this method for selecting whose fee to waive.
Why don’t we just randomly select an index in the subscribersToCharge
array and call it a day? Why do we take this probability approach where
we cna’t even guarantee exactly one subscriber fee is waived per period?
The main problem is when the total number of subscribers is very large.
Suppose a Patreon contract has so many subscribers that it’s impossible to
process all the subscriptions in a single transaction because it exceeds the block
gas limit. The owner has to process subscriptions in chunks. Selecting
a random index from the subscribersToProcess
array when the array is just
a chunk of the total is a guarantee that more than one subscriber will
have their fee waved.
Now that we’ve seen PatreonV2
let’s draw our attention to PatreonRegistryV2
.
PatreonRegistryV2.sol
The oracle will reject requestRandomWords
requests from PatreonV2
instances unless
it’s registered as a consumer for our subscription. When the Patreon registry creates
a new PatreonV2
instance we’d like to programmatically add it as a consumer. But
only the subscription owner can do that, which is currently the EOA we used to create
the subscription on the Chainlink UI. Thankfully the VRF coordinator has functions
for transferring ownership.
We’ll add the following functions to our new registry, PatreonRegistryV2
, to accept
ownership of the subscription and also return ownership when we’re done.
contract PatreonRegistryV2 is Ownable, PatreonRegistry {
function acceptSubscriptionOwnerTransfer(uint64 _chainlinkSubscriptionId) external onlyOwner {
// Once this contract has accepted subscription ownership it can programatically
// make new PatreonV2 contracts consumers. Before calling this function, owner() must
// call COORDINATOR.requestSubscriptionOwnerTransfer specifying this contract as the
// recipient
COORDINATOR.acceptSubscriptionOwnerTransfer(_chainlinkSubscriptionId);
}
function returnSubscriptionToOwner(uint64 _chainlinkSubscriptionId) external onlyOwner {
// Optionally the owner can transfer subscription ownership back to themselves by
// calling this function and then calling COORDINATOR.acceptSubscriptionOwnerTransfer
COORDINATOR.requestSubscriptionOwnerTransfer(_chainlinkSubscriptionId, owner());
}
}
To execute acceptSubscriptionOwnerTransfer
on our registry we have to first request to transfer
ownership with the coordinator. I did this by visiting the coordinator contract on etherscan
and executing the requestSubscriptionOwnerTransfer
function. The newOwner
parameter should
be the registry instance address, and you should sign the transaction with the same EOA you
used to create the VRF subscription in the Chainlink UI. So the full steps are
- Create a new subscription at https://vrf.chain.link/rinkeby
- Deploy an instance of
PatreonRegistryV2
and copy the address - Call
requestSubscriptionOwnerTransfer
on the VRF v2 Coordinator contract at https://rinkeby.etherscan.io/address/0x6168499c0cFfCaCD319c818142124B7A15E857ab - Call
acceptSubscriptionOwnerTransfer
on yourPatreonRegistryV2
instance, completing the transfer.
Now all that’s left is to update our createPatreon
function to add the newly created patreon instance as a consumer.
function createPatreon(
uint _subscriptionFee,
uint _subscriptionPeriod,
string memory _description
)
external
override
returns (address)
{
PatreonV2 patreon = new PatreonV2(
address(this),
_subscriptionFee,
_subscriptionPeriod,
_description,
chainlinkSubscriptionId
);
address patreonAddress = address(patreon);
COORDINATOR.addConsumer(chainlinkSubscriptionId, patreonAddress);
isPatreonContract[patreonAddress] = true;
address[] storage patreons = ownerToPatreons[msg.sender];
patreons.push(patreonAddress);
numPatreons += 1;
patreon.transferOwnership(msg.sender);
emit CreatePatreon(msg.sender, patreons[patreons.length-1], block.timestamp, _description);
return patreonAddress;
}
And that’s everything we need! I tested the contracts on Rinkeby. My Patreon instance had once subscriber and I charged them twice. The first time their fee was waived, and the second time it was charged! That was a fun learning experience
Conclusion
If you’re serious about web3 development then understanding oracles and how to integrate them with your contracts is crucial. The greatest value web3 can provide is when we can bridge on chain action to real world data.
Resources
- Full source code on GitHub (see
PatreonV2.sol
andPatreonRegistryV2.sol
contracts): https://github.com/daltyboy11/solidity-patreon-challenge - Chainlink VRF docs: https://docs.chain.link/docs/chainlink-vrf/