diff --git a/.gitignore b/.gitignore index 7b9bdc4..d66cf6a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ **/node_modules !node/src/package.json !examples/node/package.json +!examples/chainlink/package.json +!examples/chainlink/abi/functionsClient.json # Rust # Generated by Cargo diff --git a/examples/chainlink/abi/functionsClient.json b/examples/chainlink/abi/functionsClient.json new file mode 100644 index 0000000..235ddaa --- /dev/null +++ b/examples/chainlink/abi/functionsClient.json @@ -0,0 +1,326 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "router", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "EmptyArgs", + "type": "error" + }, + { + "inputs": [], + "name": "EmptySecrets", + "type": "error" + }, + { + "inputs": [], + "name": "EmptySource", + "type": "error" + }, + { + "inputs": [], + "name": "NoInlineSecrets", + "type": "error" + }, + { + "inputs": [], + "name": "OnlyRouterCanFulfill", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "requestId", + "type": "bytes32" + } + ], + "name": "UnexpectedRequestID", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + } + ], + "name": "OwnershipTransferRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "id", + "type": "bytes32" + } + ], + "name": "RequestFulfilled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "id", + "type": "bytes32" + } + ], + "name": "RequestSent", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "requestId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "response", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "err", + "type": "bytes" + } + ], + "name": "Response", + "type": "event" + }, + { + "inputs": [], + "name": "acceptOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "requestId", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "response", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "err", + "type": "bytes" + } + ], + "name": "handleOracleFulfillment", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "s_lastError", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "s_lastRequestId", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "s_lastResponse", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "source", + "type": "string" + }, + { + "internalType": "bytes", + "name": "encryptedSecretsUrls", + "type": "bytes" + }, + { + "internalType": "uint8", + "name": "donHostedSecretsSlotID", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "donHostedSecretsVersion", + "type": "uint64" + }, + { + "internalType": "string[]", + "name": "args", + "type": "string[]" + }, + { + "internalType": "bytes[]", + "name": "bytesArgs", + "type": "bytes[]" + }, + { + "internalType": "uint64", + "name": "subscriptionId", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "gasLimit", + "type": "uint32" + }, + { + "internalType": "bytes32", + "name": "jobId", + "type": "bytes32" + } + ], + "name": "sendRequest", + "outputs": [ + { + "internalType": "bytes32", + "name": "requestId", + "type": "bytes32" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes", + "name": "request", + "type": "bytes" + }, + { + "internalType": "uint64", + "name": "subscriptionId", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "gasLimit", + "type": "uint32" + }, + { + "internalType": "bytes32", + "name": "jobId", + "type": "bytes32" + } + ], + "name": "sendRequestCBOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "requestId", + "type": "bytes32" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] \ No newline at end of file diff --git a/examples/chainlink/package.json b/examples/chainlink/package.json new file mode 100644 index 0000000..f137db1 --- /dev/null +++ b/examples/chainlink/package.json @@ -0,0 +1,18 @@ +{ + "name": "chainlink-example", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "dependencies": { + "@chainlink/env-enc": "^1.0.5", + "@chainlink/functions-toolkit": "^0.2.7", + "ethers": "^5.7.2" + }, + "devDependencies": { + "dotenv": "^16.4.5" + } +} \ No newline at end of file diff --git a/examples/chainlink/request.js b/examples/chainlink/request.js new file mode 100644 index 0000000..503f3ad --- /dev/null +++ b/examples/chainlink/request.js @@ -0,0 +1,247 @@ +const fs = require("fs"); +const path = require("path"); +const { + SubscriptionManager, + SecretsManager, + simulateScript, + ResponseListener, + ReturnType, + decodeResult, + FulfillmentCode, +} = require("@chainlink/functions-toolkit"); +const functionsConsumerAbi = require("./abi/functionsClient.json"); +const ethers = require("ethers"); +require("@chainlink/env-enc").config(); + +const consumerAddress = process.env.CONSUMER_ADDRESS; +const subscriptionId = process.env.SUBSCRIPTION_ID; +const secrets = { apiKey: process.env.SXT_API_KEY }; + +const makeRequestSepolia = async () => { + // hardcoded for Ethereum Sepolia + const routerAddress = "0xb83E47C2bC239B3bf370bc41e1459A34b41238D0"; + const linkTokenAddress = process.env.LINK_TOKEN_ADDRESS; + const donId = "fun-ethereum-sepolia-1"; + const explorerUrl = "https://sepolia.etherscan.io"; + const gatewayUrls = [ + "https://01.functions-gateway.testnet.chain.link/", + "https://02.functions-gateway.testnet.chain.link/", + ]; + + // Initialize functions settings + const source = fs + .readFileSync(path.resolve(__dirname, "source.js")) + .toString(); + + const args = []; + const slotIdNumber = 0; // slot ID where to upload the secrets + const expirationTimeMinutes = 15; // expiration time in minutes of the secrets + const gasLimit = 300000; + + // Initialize ethers signer and provider to interact with the contracts onchain + const privateKey = process.env.PRIVATE_KEY; // fetch PRIVATE_KEY + if (!privateKey) + throw new Error( + "private key not provided - check your environment variables" + ); + + const rpcUrl = process.env.ETHEREUM_SEPOLIA_RPC_URL; // fetch Sepolia RPC URL + + if (!rpcUrl) + throw new Error(`rpcUrl not provided - check your environment variables`); + + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + + const wallet = new ethers.Wallet(privateKey); + const signer = wallet.connect(provider); // create ethers signer for signing transactions + + ///////// START SIMULATION //////////// + + console.log("Start simulation..."); + + const response = await simulateScript({ + source: source, + args: args, + bytesArgs: [], // bytesArgs - arguments can be encoded off-chain to bytes. + secrets: secrets, + }); + + console.log("Simulation result", response); + const errorString = response.errorString; + if (errorString) { + console.log(`❌ Error during simulation: `, errorString); + } else { + const returnType = ReturnType.uint256; + const responseBytesHexstring = response.responseBytesHexstring; + if (ethers.utils.arrayify(responseBytesHexstring).length > 0) { + const decodedResponse = decodeResult( + response.responseBytesHexstring, + returnType + ); + console.log(`✅ Decoded response to ${returnType}: `, decodedResponse); + } + } + + //////// ESTIMATE REQUEST COSTS //////// + console.log("\nEstimate request costs..."); + // Initialize and return SubscriptionManager + const subscriptionManager = new SubscriptionManager({ + signer: signer, + linkTokenAddress: linkTokenAddress, + functionsRouterAddress: routerAddress, + }); + await subscriptionManager.initialize(); + + // estimate costs in Juels + + const gasPriceWei = await signer.getGasPrice(); // get gasPrice in wei + + const estimatedCostInJuels = + await subscriptionManager.estimateFunctionsRequestCost({ + donId: donId, // ID of the DON to which the Functions request will be sent + subscriptionId: subscriptionId, // Subscription ID + callbackGasLimit: gasLimit, // Total gas used by the consumer contract's callback + gasPriceWei: BigInt(gasPriceWei), // Gas price in gWei + }); + + console.log( + `Fulfillment cost estimated to ${ethers.utils.formatEther( + estimatedCostInJuels + )} LINK` + ); + + //////// MAKE REQUEST //////// + + console.log("\nMake request..."); + + // First encrypt secrets and upload the encrypted secrets to the DON + const secretsManager = new SecretsManager({ + signer: signer, + functionsRouterAddress: routerAddress, + donId: donId, + }); + await secretsManager.initialize(); + + // Encrypt secrets and upload to DON + const encryptedSecretsObj = await secretsManager.encryptSecrets(secrets); + + console.log( + `Upload encrypted secret to gateways ${gatewayUrls}. slotId ${slotIdNumber}. Expiration in minutes: ${expirationTimeMinutes}` + ); + // Upload secrets + const uploadResult = await secretsManager.uploadEncryptedSecretsToDON({ + encryptedSecretsHexstring: encryptedSecretsObj.encryptedSecrets, + gatewayUrls: gatewayUrls, + slotId: slotIdNumber, + minutesUntilExpiration: expirationTimeMinutes, + }); + + if (!uploadResult.success) + throw new Error(`Encrypted secrets not uploaded to ${gatewayUrls}`); + + console.log( + `\n✅ Secrets uploaded properly to gateways ${gatewayUrls}! Gateways response: `, + uploadResult + ); + + const donHostedSecretsVersion = parseInt(uploadResult.version); // fetch the reference of the encrypted secrets + + const functionsConsumer = new ethers.Contract( + consumerAddress, + functionsConsumerAbi, + signer + ); + + // Actual transaction call + const transaction = await functionsConsumer.sendRequest( + source, // source + "0x", // user hosted secrets - encryptedSecretsUrls - empty in this example + slotIdNumber, // slot ID of the encrypted secrets + donHostedSecretsVersion, // version of the encrypted secrets + args, + [], // bytesArgs - arguments can be encoded off-chain to bytes. + subscriptionId, + gasLimit, + ethers.utils.formatBytes32String(donId) // jobId is bytes32 representation of donId + ); + + // Log transaction details + console.log( + `\n✅ Functions request sent! Transaction hash ${transaction.hash}. Waiting for a response...` + ); + + console.log( + `See your request in the explorer ${explorerUrl}/tx/${transaction.hash}` + ); + + const responseListener = new ResponseListener({ + provider: provider, + functionsRouterAddress: routerAddress, + }); // Instantiate a ResponseListener object to wait for fulfillment. + (async () => { + try { + const response = await new Promise((resolve, reject) => { + responseListener + .listenForResponseFromTransaction(transaction.hash) + .then((response) => { + resolve(response); // Resolves once the request has been fulfilled. + }) + .catch((error) => { + reject(error); // Indicate that an error occurred while waiting for fulfillment. + }); + }); + + const fulfillmentCode = response.fulfillmentCode; + + if (fulfillmentCode === FulfillmentCode.FULFILLED) { + console.log( + `\n✅ Request ${response.requestId + } successfully fulfilled. Cost is ${ethers.utils.formatEther( + response.totalCostInJuels + )} LINK.Complete reponse: `, + response + ); + } else if (fulfillmentCode === FulfillmentCode.USER_CALLBACK_ERROR) { + console.log( + `\n⚠️ Request ${response.requestId + } fulfilled. However, the consumer contract callback failed. Cost is ${ethers.utils.formatEther( + response.totalCostInJuels + )} LINK.Complete reponse: `, + response + ); + } else { + console.log( + `\n❌ Request ${response.requestId + } not fulfilled. Code: ${fulfillmentCode}. Cost is ${ethers.utils.formatEther( + response.totalCostInJuels + )} LINK.Complete reponse: `, + response + ); + } + + const errorString = response.errorString; + if (errorString) { + console.log(`\n❌ Error during the execution: `, errorString); + } else { + const responseBytesHexstring = response.responseBytesHexstring; + if (ethers.utils.arrayify(responseBytesHexstring).length > 0) { + const decodedResponse = decodeResult( + response.responseBytesHexstring, + ReturnType.uint256 + ); + console.log( + `\n✅ Decoded response to ${ReturnType.uint256}: `, + decodedResponse + ); + } + } + } catch (error) { + console.error("Error listening for response:", error); + } + })(); +}; + +makeRequestSepolia().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/chainlink/source.js b/examples/chainlink/source.js new file mode 100644 index 0000000..3c0143b --- /dev/null +++ b/examples/chainlink/source.js @@ -0,0 +1,32 @@ +// Import the package +const SxTSDK = await import("npm:sxt-proof-of-sql-sdk"); + +// Define test parameters +const queryString = "SELECT SUM(TRANSACTION_COUNT) as t_count, COUNT(*) as b_count FROM ETHEREUM.BLOCKS"; +const table = "ETHEREUM.BLOCKS"; + +// Initialize the SxTProof instance +const proof = new SxTSDK.SxTClient( + "https://api.spaceandtime.dev/v1/prove", + "https://proxy.api.spaceandtime.dev/auth/apikey", + "https://rpc.testnet.sxt.network/", + secrets.apiKey, +); + +try { + // Kick off the proof and await execution + const result = await proof.queryAndVerify( + queryString, + table + ); + + let t_count = result.table.t_count.Int[0]; + let b_count = result.table.b_count.BigInt[0]; + console.log("Average eth transactions per block: ", t_count / b_count); + + console.log("Workflow completed successfully:", result); + return Functions.encodeString("Verified"); +} catch (error) { + console.log("Workflow failed: ", error); + return Functions.encodeString("Failed: ", error); +}