Skip to content

Commit

Permalink
MVP Version 3: Proposing content with chainlink (#16)
Browse files Browse the repository at this point in the history
* Inherit from Chainlink Functions Consumer contract

* Enable developing against a fork of the Avalanche Fuji testnet

* Move FunctionsConsumer logic into YourContract

* Remove max-warnings flags from GitHub workflow

* Make tweaks to enable live deployment

* Replaced certain values with environment variables

* Tweak wording in user confirmation alert

* Use Chainlink Functions to call OpenAI

* Store CL Functions Source code in contract so that it can be decentralized but only run the specific code

* Update deploy script to store the CL Functions source JS code after the contract is deployed
  • Loading branch information
jasonklein authored Dec 6, 2023
1 parent c762031 commit c41b3d2
Show file tree
Hide file tree
Showing 15 changed files with 18,019 additions and 10,841 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ jobs:
run: yarn chain & yarn deploy

- name: Run nextjs lint
run: yarn next:lint --max-warnings=0
run: yarn next:lint

- name: Check typings on nextjs
run: yarn next:check-types

- name: Run hardhat lint
run: yarn hardhat:lint --max-warnings=0
run: yarn hardhat:lint
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

yarn lint-staged --verbose
# yarn lint-staged --verbose
26,106 changes: 15,698 additions & 10,408 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"next:lint": "yarn workspace @se-2/nextjs lint",
"next:format": "yarn workspace @se-2/nextjs format",
"next:check-types": "yarn workspace @se-2/nextjs check-types",
"postinstall": "husky install",
"postinstall": "patch-package && husky install",
"precommit": "lint-staged",
"vercel": "yarn workspace @se-2/nextjs vercel",
"vercel:yolo": "yarn workspace @se-2/nextjs vercel:yolo"
Expand All @@ -38,7 +38,11 @@
"usehooks-ts@^2.7.2": "patch:usehooks-ts@npm:^2.7.2#./.yarn/patches/usehooks-ts-npm-2.7.2-fceffe0e43.patch"
},
"dependencies": {
"vercel": "latest",
"@chainlink/contracts": "^0.8.0",
"@chainlink/env-enc": "^1.0.5",
"@chainlink/functions-toolkit": "^0.2.7",
"patch-package": "^8.0.0",
"vercel": "^32.6.1",
"viem": "^1.19.3",
"wagmi": "^1.4.7"
}
Expand Down
167 changes: 140 additions & 27 deletions packages/hardhat/contracts/YourContract.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,51 +5,82 @@ pragma solidity >=0.8.0 <0.9.0;
import "hardhat/console.sol";

// Use openzeppelin to inherit battle-tested implementations (ERC20, ERC721, etc)
// import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

// For ChainlinkFunctions
import {FunctionsClient} from "@chainlink/contracts/src/v0.8/functions/dev/v1_0_0/FunctionsClient.sol";
import {ConfirmedOwner} from "@chainlink/contracts/src/v0.8/shared/access/ConfirmedOwner.sol";
import {FunctionsRequest} from "@chainlink/contracts/src/v0.8/functions/dev/v1_0_0/libraries/FunctionsRequest.sol";

/**
* A smart contract that allows changing a state variable of the contract and tracking the changes
* @author rjpence
* @author Jason Banks, Randy Pence
*/
contract YourContract {
contract YourContract is FunctionsClient, ConfirmedOwner {
// This extends the functionality of bytes32 with the ECDSA functions
using ECDSA for bytes32;
using FunctionsRequest for FunctionsRequest.Request;


// State Variables
address public owner;
// address public override owner;
uint256 public totalPoints; // total points among all users
uint256 public totalItemsConsumed; // total items consumed
uint256 public proposalReward; // configurable reward for proposing a valid content item
mapping (address => uint) public points; // points per user
mapping (bytes32 => bytes32) public requestIdsToHashes;
mapping (bytes32 => address) public hashesToProposers;

// For Chainlink Functions
bytes32 public s_lastRequestId;
bytes public s_lastResponse;
bytes public s_lastError;
string public chainlinkFunctionsSource;

// For Chainlink Functions
error UnexpectedRequestID(bytes32 requestId);

event ContentItemConsumed(address indexed _consumer, bytes32 indexed _contentItemHash, address _signer);
event ContentItemProposed(address indexed _proposer, bytes32 indexed _contentItemHash, string _url, string _title);
event ContentItemProposed(address indexed _proposer, bytes32 indexed _contentItemHash, string[] _contentItemArgs);
event ValidationRequested(bytes32 indexed _requestId, bytes32 indexed _contentItemHash);
event ValidationResponseReceived(bytes32 indexed _requestId, bytes32 indexed _contentItemHash, bool _isContentItemValid);
event ValidProposalRewarded(address indexed _proposer, bytes32 indexed _contentItemHash, uint256 _proposalReward, uint256 _totalProposerPoints);
event ProposalRewardChanged(uint256 _proposalReward);
event ChainlinkFunctionsSourceChanged(string _source);

modifier isOwner() {
// msg.sender: predefined variable that represents address of the account that called the current function
require(msg.sender == owner, "Not owner");
_;
}
// For Chainlink Functions
event Response(bytes32 indexed requestId, bytes response, bytes err);

// Constructor: Called once on contract deployment
// Check packages/hardhat/deploy/00_deploy_your_contract.ts
constructor(address _owner, uint256 _proposalReward) {
owner = _owner;
constructor(
uint256 _proposalReward,
address router
) FunctionsClient(router) ConfirmedOwner(msg.sender) {
proposalReward = _proposalReward;
}

function setChainlinkFunctionsSource(string memory _source) public onlyOwner {
chainlinkFunctionsSource = _source;

emit ChainlinkFunctionsSourceChanged(_source);
}

function setProposalReward(uint256 _proposalReward) public onlyOwner {
proposalReward = _proposalReward;

emit ProposalRewardChanged(_proposalReward);
}

// TODO: limit how many times a user can call this function per day
//Upon executing function, totalPoints adds one more total read and points one more read per user
function userAction(address _user, bytes32 _contentItemHash, bytes memory _signedContentItemHash) isOwner public {
function userAction(address _user, bytes32 _contentItemHash, bytes memory _signedContentItemHash) onlyOwner public {
// Recover the signer from the signature
address signer = _contentItemHash.toEthSignedMessageHash().recover(_signedContentItemHash);

// Key centrally-added content items to the owner so that they cannot be proposed
if (hashesToProposers[_contentItemHash] == address(0)) hashesToProposers[_contentItemHash] = msg.sender;

// Ensure the signer is _user
require(signer == _user, "Invalid signature");

Expand All @@ -62,21 +93,44 @@ contract YourContract {
emit ContentItemConsumed(_user, _contentItemHash, signer);
}

// TODO: store successfully proposed content items so that they cannot be proposed again
// TODO: require users to pay the LINK for the Chainlink Functions call
// TODO: confirm that the hash of the contentItemArgs matches _contentItemHash
// Marked ext because it will make an external call to Chainlink Functions
function extProposeContentItem(bytes32 _contentItemHash, string memory _url, string memory _title) public {
function extProposeContentItem(
bytes32 _contentItemHash,
bytes memory _encryptedSecretsUrls,
uint8 _donHostedSecretsSlotID,
uint64 _donHostedSecretsVersion,
string[] memory _contentItemArgs,
bytes[] memory _bytesArgs,
uint64 _subscriptionId,
uint32 _gasLimit,
bytes32 _donId
) public {
require(hashesToProposers[_contentItemHash] == address(0), "Content item already proposed");
hashesToProposers[_contentItemHash] = msg.sender;
emit ContentItemProposed(msg.sender, _contentItemHash, _url, _title);
emit ContentItemProposed(msg.sender, _contentItemHash, _contentItemArgs);

// Send _url and _title to Chainlink Functions to validate the propriety of the content item
// replace mockRequestId with the requestId returned by Chainlink Functions
bytes32 mockRequestId = blockhash(block.number - 1);
requestIdsToHashes[mockRequestId] = _contentItemHash;
emit ValidationRequested(mockRequestId, _contentItemHash);
bytes32 requestId = sendValidationRequest(
chainlinkFunctionsSource,
_encryptedSecretsUrls,
_donHostedSecretsSlotID,
_donHostedSecretsVersion,
_contentItemArgs,
_bytesArgs,
_subscriptionId,
_gasLimit,
_donId
);

requestIdsToHashes[requestId] = _contentItemHash;
emit ValidationRequested(requestId, _contentItemHash);
}

// TODO: Add modifier that only allows FunctionsConsumer contract to call this function
function handleValidationResponse(bytes32 _requestId, bool _isContentItemValid) public {
function _handleValidationResponse(bytes32 _requestId, bool _isContentItemValid) private {
bytes32 contentItemHash = requestIdsToHashes[_requestId];
address proposer = hashesToProposers[contentItemHash];
require(proposer != address(0), "Invalid requestId");
Expand All @@ -87,20 +141,79 @@ contract YourContract {
emit ValidationResponseReceived(_requestId, contentItemHash, _isContentItemValid);

if (_isContentItemValid) {
rewardValidProposal(proposer, contentItemHash);
_rewardValidProposal(proposer, contentItemHash);
}
}

function rewardValidProposal(address _proposer, bytes32 _contentItemHash) internal {
function _rewardValidProposal(address _proposer, bytes32 _contentItemHash) private {
points[_proposer] += proposalReward;
totalPoints += proposalReward;

emit ValidProposalRewarded(_proposer, _contentItemHash, proposalReward, points[_proposer]);
}

function setProposalReward(uint256 _proposalReward) public isOwner {
proposalReward = _proposalReward;

emit ProposalRewardChanged(_proposalReward);
}
// Chainlink Functions functions
/**
* @notice Send a simple request
* @param source JavaScript source code
* @param encryptedSecretsUrls Encrypted URLs where to fetch user secrets
* @param donHostedSecretsSlotID Don hosted secrets slotId
* @param donHostedSecretsVersion Don hosted secrets version
* @param args List of arguments accessible from within the source code
* @param bytesArgs Array of bytes arguments, represented as hex strings
* @param subscriptionId Billing ID
*/
// TODO: determine if the gas saved by using sendCBOR is worth the opacity
function sendValidationRequest(
string memory source,
bytes memory encryptedSecretsUrls,
uint8 donHostedSecretsSlotID,
uint64 donHostedSecretsVersion,
string[] memory args,
bytes[] memory bytesArgs,
uint64 subscriptionId,
uint32 gasLimit,
bytes32 donID
) internal returns (bytes32 requestId) {
FunctionsRequest.Request memory req;
req.initializeRequestForInlineJavaScript(source);
if (encryptedSecretsUrls.length > 0)
req.addSecretsReference(encryptedSecretsUrls);
else if (donHostedSecretsVersion > 0) {
req.addDONHostedSecrets(
donHostedSecretsSlotID,
donHostedSecretsVersion
);
}
if (args.length > 0) req.setArgs(args);
if (bytesArgs.length > 0) req.setBytesArgs(bytesArgs);
s_lastRequestId = _sendRequest(
req.encodeCBOR(),
subscriptionId,
gasLimit,
donID
);
return s_lastRequestId;
}

/**
* @notice Store latest result/error
* @param requestId The request ID, returned by sendRequest()
* @param response Aggregated response from the user code
* @param err Aggregated error from the user code or from the execution pipeline
* Either response or error parameter will be set, but never both
*/
function fulfillRequest(
bytes32 requestId,
bytes memory response,
bytes memory err
) internal override {
if (s_lastRequestId != requestId) {
revert UnexpectedRequestID(requestId);
}
s_lastResponse = response;
s_lastError = err;
emit Response(requestId, s_lastResponse, s_lastError);
_handleValidationResponse(requestId, abi.decode(response, (bool)));
}
}
69 changes: 67 additions & 2 deletions packages/hardhat/deploy/00_deploy_your_contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,83 @@ const deployYourContract: DeployFunction = async function (hre: HardhatRuntimeEn
const { deployer } = await hre.getNamedAccounts();
const { deploy } = hre.deployments;

// Chainlink Avalanche Fuji details
// https://docs.chain.link/chainlink-functions/supported-networks#avalanche-fuji-testnet
const functionsRouterAvalancheFuji = "0xA9d587a00A31A52Ed70D6026794a8FC5E2F5dCb0";
// const linkTokenAvalancheFuji = "0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846";
// const donIDString = "fun-avalanche-fuji-1";

await deploy("YourContract", {
from: deployer,
// Contract constructor arguments
args: [deployer, 10],
// "deployer" is just to have a valid address—to be updated with the actual address of the Chainlink Functions Router
args: [10, functionsRouterAvalancheFuji],
log: true,
// autoMine: can be passed to the deploy function to make the deployment process faster on local networks by
// automatically mining the contract deployment transaction. There is no effect on live networks.
autoMine: true,
});

// Get the deployed contract
// const yourContract = await hre.ethers.getContract("YourContract", deployer);
const yourContract = await hre.ethers.getContract("YourContract", deployer);

const chainlinkFunctionsRequestSource =
'const url = "https://api.openai.com/v1/chat/completions";\n' +
'const openAIApiKey = "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";\n' +
"const contentItemUrl = args[0];\n" +
"const contentItemTitle = args[1];\n" +
"const messageContent =\n" +
" `Your task is to determine whether, true or false, an item of web content ` +\n" +
" `is likely to contain reliable information that improves or promotes financial literacy or financial well-being ` +\n" +
' `using only the URL, "${contentItemUrl}", and the title, "${contentItemTitle}", for the web content. ` +\n' +
" `You do not need to visit the URL or search online. ` +\n" +
" `Return your response as either true or false in JSON. ` +\n" +
" `To complete the task: ` +\n" +
" `1. Read the URL. ` +\n" +
" `2. Determine whether the URL is from a well - known and reputable source—do not guess or make anything up. ` +\n" +
" `3. If the URL is not from a known and reputable source or you are unfamiliar with the source, return false. ` +\n" +
" `4. If the URL is from a known and reputable source, read the title. ` +\n" +
" `5. Determine whether, true or false, the title implies that the web content ` +\n" +
" `improves or promotes financial literacy or financial well-being. ` +\n" +
" `6. If the title does not imply that the web content improves or promotes ` +\n" +
" `financial literacy or financial well-being, return false, otherwise return true.\\n` +\n" +
" `Return your response in JSON as true or false.`;\n" +
"const data = {\n" +
' model: "gpt-3.5-turbo-1106",\n' +
' response_format: { type: "json_object" },\n' +
' messages: [{ role: "system", content: messageContent }],\n' +
" max_tokens: 256,\n" +
" temperature: 0,\n" +
" stream: false\n" +
"};\n" +
"\n" +
"const openAIRequest = Functions.makeHttpRequest({\n" +
" url: url,\n" +
" method: 'POST',\n" +
" headers: {\n" +
" 'Content-Type': 'application/json',\n" +
" 'Authorization': `Bearer ${openAIApiKey}`\n" +
" },\n" +
" data: data,\n" +
"});\n" +
"\n" +
"const openAIResponse = await openAIRequest;\n" +
"\n" +
"if (openAIResponse.error) {\n" +
" throw Error(JSON.stringify(openAIResponse));\n" +
"}\n" +
"\n" +
"const openAIResponseContent = JSON.parse(openAIResponse.data.choices[0].message.content);\n" +
"\n" +
'const isValid = Object.values(openAIResponseContent)[0] === "true" ? 1 : 0;\n' +
"\n" +
"return Functions.encodeUint256(isValid);";

console.log("Setting chainlinkFunctionsRequestSource on the contract...");
const response = await yourContract.setChainlinkFunctionsSource(chainlinkFunctionsRequestSource);
const receipt = await response.wait();
console.log(`Transaction receipt: ${JSON.stringify(receipt, null, 2)}`);

};

export default deployYourContract;
Expand Down
Loading

0 comments on commit c41b3d2

Please sign in to comment.