├── .gitattributes ├── .gitignore ├── .vscode ├── extensions.json └── launch.json ├── LICENSE ├── README.md ├── astro.config.mjs ├── example.env ├── package-lock.json ├── package.json ├── public └── favicon.svg ├── ref └── newsArchive.sol ├── src ├── env.d.ts ├── lib │ ├── newsArchive.js │ └── newsArchiveABI.json └── pages │ └── index.astro └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 SmartContract 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Based On: Astro Starter Kit: Minimal 2 | 3 | ```sh 4 | npm create astro@latest -- --template minimal 5 | ``` 6 | 7 | ## 🚀 Project Structure 8 | 9 | Inside of your project, you'll see the following folders and files: 10 | 11 | ```text 12 | / 13 | ├── public/ 14 | ├── ref/ 15 | └── newsArchive.sol 16 | ├── src/ 17 | └── lib/ 18 | └── newsArchive.js 19 | └── newsArchiveABI.json 20 | └── newsArchive.sol 21 | │ └── pages/ 22 | │ └── index.astro 23 | └── package.json 24 | ``` 25 | 26 | Files in the `ref/` directory are intended as reference. In this example `newArchive.sol` will need to be deployed separately. 27 | 28 | Files in the `src/lib/` directory are used as imports. 29 | 30 | Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. 31 | 32 | ## ⚙️ Setup 33 | 34 | To run this project locally please clone it to your machine. 35 | Next, `npm install` and install dependencies. 36 | Rename `example.env` to `.env` and fill in the contract address. 37 | **NOTE** The RPC url provided in `example.env` is for Etherum Sepolia, if you are using a different network you will need to change this value. 38 | 39 | ## 🧞 Commands 40 | 41 | All commands are run from the root of the project, from a terminal: 42 | 43 | | Command | Action | 44 | | :------------ | :------------------------------------------ | 45 | | `npm install` | Installs dependencies | 46 | | `npm run dev` | Starts local dev server at `localhost:4321` | 47 | 48 | ## DISCLAIMER 49 | 50 | This tutorial represents an educational example to use a Chainlink system, product, or service and is provided to demonstrate how to interact with Chainlink’s systems, products, and services to integrate them into your own. This template is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, it has not been audited, and it may be missing key checks or error handling to make the usage of the system, product or service more clear. Do not use the code in this example in a production environment without completing your own audits and application of best practices. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for unintended outputs that are generated due to errors in code. 51 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | 3 | // https://astro.build/config 4 | export default defineConfig({}); 5 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | # Rename this file to .env 2 | 3 | # RPC for sepolia 4 | PROVIDER_URL="https://rpc.sepolia.org" 5 | CONTRACT_ADDRESS="YOUR_CONTRACT_ADDRESS" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "workshop-distributed-news", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "astro": "^4.0.2", 14 | "dotenv": "^16.3.1", 15 | "ethers": "^6.8.1", 16 | "jsdom": "^23.0.1", 17 | "node-fetch": "^3.3.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /ref/newsArchive.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.19; 3 | 4 | import {FunctionsClient} from "@chainlink/contracts/src/v0.8/functions/dev/v1_0_0/FunctionsClient.sol"; 5 | import {ConfirmedOwner} from "@chainlink/contracts/src/v0.8/shared/access/ConfirmedOwner.sol"; 6 | import {FunctionsRequest} from "@chainlink/contracts/src/v0.8/functions/dev/v1_0_0/libraries/FunctionsRequest.sol"; 7 | 8 | contract FunctionsConsumerExample is FunctionsClient, ConfirmedOwner { 9 | using FunctionsRequest for FunctionsRequest.Request; 10 | 11 | bytes32 public s_lastRequestId; 12 | bytes public s_lastResponse; 13 | bytes public s_lastError; 14 | 15 | error UnexpectedRequestID(bytes32 requestId); 16 | 17 | struct Article { 18 | bytes url; 19 | uint256 publishedDate; 20 | } 21 | 22 | Article[] private articles; 23 | 24 | event Response(bytes32 indexed requestId, bytes response, bytes err); 25 | event ArticleAdded(bytes url, uint256 publishedDate); 26 | 27 | // CUSTOM PARAMS - START 28 | // @Dev TODO check the params are right for your chosen network. 29 | 30 | //Sepolia Router address; 31 | // Additional Routers can be found at 32 | // https://docs.chain.link/chainlink-functions/supported-networks 33 | address router = 0xb83E47C2bC239B3bf370bc41e1459A34b41238D0; 34 | 35 | //Functions Subscription ID 36 | uint64 subscriptionId = 9999; // Fill this in with your subscription ID 37 | 38 | //Gas limit for callback tx do not change 39 | uint32 gasLimit = 300000; 40 | 41 | //DoN ID for Sepolia, from supported networks in the docs 42 | // Additional DoN IDs can be found at 43 | // https://docs.chain.link/chainlink-functions/supported-networks 44 | bytes32 donId = 45 | 0x66756e2d657468657265756d2d7365706f6c69612d3100000000000000000000; 46 | 47 | // Source JavaScript to run 48 | string source = 49 | // handles errors where HN returns an object without a URL property. 50 | "const url = `https://hacker-news.firebaseio.com/v0/newstories.json`; const newRequest = Functions.makeHttpRequest({ url }); const newResponse = await newRequest; if (newResponse.error) { throw Error(`Error fetching news`);} let itemIdx = 0; let done = false; let storyUrl; while (!done) { const latestStory = newResponse.data[itemIdx];const latestStoryURL = `https://hacker-news.firebaseio.com/v0/item/${latestStory}.json`; const storyRequest = Functions.makeHttpRequest({ url: latestStoryURL }); const storyResponse = await storyRequest; if (!storyResponse.data.url) {console.log(`\nReturned object missing URL property. Retrying...`); itemIdx += 1; continue;} storyUrl = storyResponse.data.url;done = true;}return Functions.encodeString(storyUrl);"; 51 | 52 | // No error handling in below source. 53 | // "const url = `https://hacker-news.firebaseio.com/v0/newstories.json`; const newRequest = Functions.makeHttpRequest({ url }); const newResponse = await newRequest; if (newResponse.error) { throw Error(`Error fetching news`); } const latestStory = newResponse.data[0]; const latestStoryURL = `https://hacker-news.firebaseio.com/v0/item/${latestStory}.json`; const storyRequest = Functions.makeHttpRequest({ url: latestStoryURL }); const storyResponse = await storyRequest; return Functions.encodeString(storyResponse.data.url);"; 54 | 55 | // CUSTOM PARAMS - END 56 | 57 | constructor() FunctionsClient(router) ConfirmedOwner(msg.sender) {} 58 | 59 | function sendRequest() external onlyOwner returns (bytes32 requestId) { 60 | FunctionsRequest.Request memory req; 61 | req.initializeRequestForInlineJavaScript(source); 62 | 63 | s_lastRequestId = _sendRequest( 64 | req.encodeCBOR(), 65 | subscriptionId, 66 | gasLimit, 67 | donId 68 | ); 69 | 70 | return s_lastRequestId; 71 | } 72 | 73 | function fulfillRequest( 74 | bytes32 requestId, 75 | bytes memory response, 76 | bytes memory err 77 | ) internal override { 78 | if (s_lastRequestId != requestId) { 79 | revert UnexpectedRequestID(requestId); 80 | } 81 | articles.push(Article(response, block.timestamp)); 82 | emit ArticleAdded(response, block.timestamp); 83 | 84 | s_lastResponse = response; 85 | 86 | s_lastError = err; 87 | emit Response(requestId, s_lastResponse, s_lastError); 88 | } 89 | 90 | // Function to return all articles 91 | function getAllArticles() public view returns (string[] memory) { 92 | string[] memory allArticles = new string[](articles.length); 93 | for (uint i = 0; i < articles.length; i++) { 94 | allArticles[i] = string(articles[i].url); 95 | } 96 | return allArticles; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/lib/newsArchive.js: -------------------------------------------------------------------------------- 1 | // Import necessary modules from ethers library 2 | import { ethers, JsonRpcProvider } from "ethers"; 3 | // Import the dotenv module to load environment variables 4 | import { config } from "dotenv"; 5 | 6 | // Import the ABI of the NewsArchive contract 7 | import NewsArchiveABI from "./newsArchiveABI.json"; 8 | 9 | // Load environment variables from .env file 10 | config(); 11 | 12 | // Get provider URL and contract address from environment variables 13 | const providerUrl = process.env.PROVIDER_URL; 14 | const contractAddress = process.env.CONTRACT_ADDRESS; 15 | 16 | // Create a new JSON-RPC provider 17 | const provider = new JsonRpcProvider(providerUrl); 18 | 19 | // Create a new contract instance with the NewsArchive ABI 20 | const newsArchiveContract = new ethers.Contract( 21 | contractAddress, 22 | NewsArchiveABI, 23 | provider 24 | ); 25 | 26 | // Define an async function to get all articles from the contract 27 | async function getAllArticles() { 28 | try { 29 | // Call the getAllArticles function of the contract 30 | const articles = await newsArchiveContract.getAllArticles(); 31 | // Return the articles 32 | return articles; 33 | } catch (error) { 34 | // Log the error and return an empty array 35 | console.error("Error fetching articles:", error); 36 | return []; 37 | } 38 | } 39 | 40 | // Export the getAllArticles function as the default export 41 | export default getAllArticles; 42 | -------------------------------------------------------------------------------- /src/lib/newsArchiveABI.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "inputs": [], 4 | "name": "acceptOwnership", 5 | "outputs": [], 6 | "stateMutability": "nonpayable", 7 | "type": "function" 8 | }, 9 | { 10 | "inputs": [], 11 | "stateMutability": "nonpayable", 12 | "type": "constructor" 13 | }, 14 | { 15 | "inputs": [], 16 | "name": "EmptyArgs", 17 | "type": "error" 18 | }, 19 | { 20 | "inputs": [], 21 | "name": "EmptySource", 22 | "type": "error" 23 | }, 24 | { 25 | "inputs": [], 26 | "name": "NoInlineSecrets", 27 | "type": "error" 28 | }, 29 | { 30 | "inputs": [], 31 | "name": "OnlyRouterCanFulfill", 32 | "type": "error" 33 | }, 34 | { 35 | "inputs": [ 36 | { 37 | "internalType": "bytes32", 38 | "name": "requestId", 39 | "type": "bytes32" 40 | } 41 | ], 42 | "name": "UnexpectedRequestID", 43 | "type": "error" 44 | }, 45 | { 46 | "anonymous": false, 47 | "inputs": [ 48 | { 49 | "indexed": false, 50 | "internalType": "string", 51 | "name": "headline", 52 | "type": "string" 53 | }, 54 | { 55 | "indexed": false, 56 | "internalType": "uint256", 57 | "name": "publishedDate", 58 | "type": "uint256" 59 | } 60 | ], 61 | "name": "ArticleAdded", 62 | "type": "event" 63 | }, 64 | { 65 | "inputs": [ 66 | { 67 | "internalType": "bytes32", 68 | "name": "requestId", 69 | "type": "bytes32" 70 | }, 71 | { 72 | "internalType": "bytes", 73 | "name": "response", 74 | "type": "bytes" 75 | }, 76 | { 77 | "internalType": "bytes", 78 | "name": "err", 79 | "type": "bytes" 80 | } 81 | ], 82 | "name": "handleOracleFulfillment", 83 | "outputs": [], 84 | "stateMutability": "nonpayable", 85 | "type": "function" 86 | }, 87 | { 88 | "anonymous": false, 89 | "inputs": [ 90 | { 91 | "indexed": true, 92 | "internalType": "address", 93 | "name": "from", 94 | "type": "address" 95 | }, 96 | { 97 | "indexed": true, 98 | "internalType": "address", 99 | "name": "to", 100 | "type": "address" 101 | } 102 | ], 103 | "name": "OwnershipTransferRequested", 104 | "type": "event" 105 | }, 106 | { 107 | "anonymous": false, 108 | "inputs": [ 109 | { 110 | "indexed": true, 111 | "internalType": "address", 112 | "name": "from", 113 | "type": "address" 114 | }, 115 | { 116 | "indexed": true, 117 | "internalType": "address", 118 | "name": "to", 119 | "type": "address" 120 | } 121 | ], 122 | "name": "OwnershipTransferred", 123 | "type": "event" 124 | }, 125 | { 126 | "anonymous": false, 127 | "inputs": [ 128 | { 129 | "indexed": true, 130 | "internalType": "bytes32", 131 | "name": "id", 132 | "type": "bytes32" 133 | } 134 | ], 135 | "name": "RequestFulfilled", 136 | "type": "event" 137 | }, 138 | { 139 | "anonymous": false, 140 | "inputs": [ 141 | { 142 | "indexed": true, 143 | "internalType": "bytes32", 144 | "name": "id", 145 | "type": "bytes32" 146 | } 147 | ], 148 | "name": "RequestSent", 149 | "type": "event" 150 | }, 151 | { 152 | "anonymous": false, 153 | "inputs": [ 154 | { 155 | "indexed": true, 156 | "internalType": "bytes32", 157 | "name": "requestId", 158 | "type": "bytes32" 159 | }, 160 | { 161 | "indexed": false, 162 | "internalType": "bytes", 163 | "name": "response", 164 | "type": "bytes" 165 | }, 166 | { 167 | "indexed": false, 168 | "internalType": "bytes", 169 | "name": "err", 170 | "type": "bytes" 171 | } 172 | ], 173 | "name": "Response", 174 | "type": "event" 175 | }, 176 | { 177 | "inputs": [], 178 | "name": "sendRequest", 179 | "outputs": [ 180 | { 181 | "internalType": "bytes32", 182 | "name": "requestId", 183 | "type": "bytes32" 184 | } 185 | ], 186 | "stateMutability": "nonpayable", 187 | "type": "function" 188 | }, 189 | { 190 | "inputs": [ 191 | { 192 | "internalType": "address", 193 | "name": "to", 194 | "type": "address" 195 | } 196 | ], 197 | "name": "transferOwnership", 198 | "outputs": [], 199 | "stateMutability": "nonpayable", 200 | "type": "function" 201 | }, 202 | { 203 | "inputs": [], 204 | "name": "character", 205 | "outputs": [ 206 | { 207 | "internalType": "string", 208 | "name": "", 209 | "type": "string" 210 | } 211 | ], 212 | "stateMutability": "view", 213 | "type": "function" 214 | }, 215 | { 216 | "inputs": [], 217 | "name": "getAllArticles", 218 | "outputs": [ 219 | { 220 | "components": [ 221 | { 222 | "internalType": "string", 223 | "name": "headline", 224 | "type": "string" 225 | }, 226 | { 227 | "internalType": "uint256", 228 | "name": "publishedDate", 229 | "type": "uint256" 230 | } 231 | ], 232 | "internalType": "struct FunctionsConsumerExample.Article[]", 233 | "name": "", 234 | "type": "tuple[]" 235 | } 236 | ], 237 | "stateMutability": "view", 238 | "type": "function" 239 | }, 240 | { 241 | "inputs": [], 242 | "name": "owner", 243 | "outputs": [ 244 | { 245 | "internalType": "address", 246 | "name": "", 247 | "type": "address" 248 | } 249 | ], 250 | "stateMutability": "view", 251 | "type": "function" 252 | }, 253 | { 254 | "inputs": [], 255 | "name": "s_lastError", 256 | "outputs": [ 257 | { 258 | "internalType": "bytes", 259 | "name": "", 260 | "type": "bytes" 261 | } 262 | ], 263 | "stateMutability": "view", 264 | "type": "function" 265 | }, 266 | { 267 | "inputs": [], 268 | "name": "s_lastRequestId", 269 | "outputs": [ 270 | { 271 | "internalType": "bytes32", 272 | "name": "", 273 | "type": "bytes32" 274 | } 275 | ], 276 | "stateMutability": "view", 277 | "type": "function" 278 | }, 279 | { 280 | "inputs": [], 281 | "name": "s_lastResponse", 282 | "outputs": [ 283 | { 284 | "internalType": "bytes", 285 | "name": "", 286 | "type": "bytes" 287 | } 288 | ], 289 | "stateMutability": "view", 290 | "type": "function" 291 | } 292 | ] -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import fetch from "node-fetch"; 3 | import { JSDOM } from "jsdom"; 4 | // Import the getAllArticles function from the newsArchive.js file 5 | import getAllArticles from "../lib/newsArchive.js"; 6 | 7 | // Initialize articles variable and error flag 8 | let articles; 9 | let errorOccurred = false; 10 | 11 | try { 12 | // Try to fetch all articles 13 | articles = await getAllArticles(); 14 | } catch (error) { 15 | // If an error occurs, log it and set the error flag 16 | console.error("Failed to get articles:", error); 17 | articles = []; 18 | errorOccurred = true; 19 | } 20 | async function fetchOGData(url) { 21 | try { 22 | const response = await fetch(url); 23 | const html = await response.text(); 24 | const dom = new JSDOM(html); 25 | const doc = dom.window.document; 26 | let ogTitle = 27 | doc.querySelector('meta[property="og:title"]')?.content || url; 28 | const ogImage = doc.querySelector('meta[property="og:image"]')?.content; 29 | return { ogTitle, ogImage, url }; 30 | } catch (error) { 31 | console.error("Error fetching OG data:", error); 32 | return { ogTitle: "No title", ogImage: "No image", url }; 33 | } 34 | } 35 | 36 | async function articlesWithOGData(articles) { 37 | return await Promise.all(articles.map(fetchOGData)); 38 | } 39 | let allArticles = await articlesWithOGData(articles); 40 | --- 41 | 42 | 43 | 44 |

News Articles

45 | { 46 | // If an error occurred, display an error message 47 | // Otherwise, display the list of articles 48 | errorOccurred ? ( 49 |

Sorry, we're unable to fetch articles at the moment.

50 | ) : ( 51 | 63 | ) 64 | } 65 | 66 | 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/base" 3 | } 4 | --------------------------------------------------------------------------------