diff --git a/orchestrator/src/env.ts b/orchestrator/src/env.ts index ca57f5b..dd792e6 100644 --- a/orchestrator/src/env.ts +++ b/orchestrator/src/env.ts @@ -24,6 +24,7 @@ const baseConfig = { batchSize: process.env.BATCH_SIZE ?? 1000, // Integrations xBearerToken: process.env.X_BEARER_TOKEN ?? "", + openAIToken: process.env.OPEN_AI_TOKEN ?? "", }; interface IEnvVars { @@ -41,6 +42,7 @@ interface IEnvVars { ecdsaPrivateKey?: string; batchSize: number; xBearerToken: string; + openAIToken: string; } const envVarsSchema = Joi.object({ @@ -90,6 +92,7 @@ const envVarsSchema = Joi.object({ // Integrations xBearerToken: Joi.string().allow("").required(), + openAIToken: Joi.string().allow("").required(), // Common sentryDSN: Joi.string().allow("", null), @@ -113,6 +116,7 @@ export default { sentryDSN: envVars.sentryDSN, integrations: { xBearerToken: envVars.xBearerToken, + openAIToken: envVars.openAIToken, }, rooch: { chainId: envVars.roochChainId, diff --git a/orchestrator/src/indexer/base.ts b/orchestrator/src/indexer/base.ts index b550602..bc241a7 100644 --- a/orchestrator/src/indexer/base.ts +++ b/orchestrator/src/indexer/base.ts @@ -1,13 +1,11 @@ import { log } from "@/logger"; import { type ProcessedRequestAdded, RequestStatus } from "@/types"; -import { run as jqRun } from "node-jq"; +import { instance as openAIInstance } from "@/integrations/openAI"; import { instance as xTwitterInstance } from "@/integrations/xtwitter"; -import { isValidJson } from "@/util"; -import axios, { type AxiosResponse } from "axios"; -import prismaClient from "../../prisma"; -const ALLOWED_HOST = [...xTwitterInstance.hosts]; +import type { BasicBearerAPIHandler } from "@/integrations/base"; +import prismaClient from "../../prisma"; // Abstract base class export abstract class Indexer { @@ -43,12 +41,14 @@ export abstract class Indexer { return this.oracleAddress; } - applyAuthorizationHeader(hostname: string): string | undefined { - if (ALLOWED_HOST.includes(hostname)) { - const token = xTwitterInstance.getAccessToken(); - return `Bearer ${token}`; + requestHandlerSelector(url: URL): BasicBearerAPIHandler | null { + if (xTwitterInstance.isApprovedPath(url)) { + return xTwitterInstance; } - return undefined; + if (openAIInstance.isApprovedPath(url)) { + return openAIInstance; + } + return null; } /** @@ -70,9 +70,10 @@ export abstract class Indexer { * @param {IRequestAdded} data - The request data that needs to be processed. * @returns {Promise<{status: number, message: string} | null>} - The status and message of the processed request, or null if the request is not valid. */ - async processRequestAddedEvent(data: ProcessedRequestAdded) { + async processRequestAddedEvent( + data: ProcessedRequestAdded, + ): Promise<{ status: number; message: string } | null> { log.debug("processing request:", data.request_id); - const token = xTwitterInstance.getAccessToken(); if (data.oracle.toLowerCase() !== this.getOrchestratorAddress().toLowerCase()) { log.debug( @@ -83,69 +84,17 @@ export abstract class Indexer { ); return null; } - const url = data.params.url?.includes("http") ? data.params.url : `https://${data.params.url}`; try { - const _url = new URL(url); - - if (!ALLOWED_HOST.includes(_url.hostname.toLowerCase())) { - return { status: 406, message: `${_url.hostname} is supposed by this orchestrator` }; - } - } catch (err) { - return { status: 406, message: `Invalid Domain Name` }; - } + const url = data.params.url?.includes("http") ? data.params.url : `https://${data.params.url}`; + const url_object = new URL(url); - try { - let request: AxiosResponse; - if (isValidJson(data.params.headers)) { - // TODO: Replace direct requests via axios with requests via VerityClient TS module - request = await axios({ - method: data.params.method, - data: data.params.body, - url: url, - headers: { - ...JSON.parse(data.params.headers), - Authorization: `Bearer ${token}`, - }, - }); - // return { status: request.status, message: request.data }; - } else { - request = await axios({ - method: data.params.method, - data: data.params.body, - url: url, - headers: { - Authorization: `Bearer ${token}`, - }, - }); - } - - try { - const result = await jqRun(data.pick, JSON.stringify(request.data), { input: "string" }); - return { status: request.status, message: result }; - } catch { - return { status: 409, message: "`Pick` value provided could not be resolved on the returned response" }; - } - // return { status: request.status, message: result }; - } catch (error: any) { - log.debug( - JSON.stringify({ - error: error.message, - }), - ); - - if (axios.isAxiosError(error)) { - // Handle Axios-specific errors - if (error.response) { - // Server responded with a status other than 2xx - return { status: error.response.status, message: error.response.data }; - } else if (error.request) { - // No response received - return { status: 504, message: "No response received" }; - } - } else { - // Handle non-Axios errors - return { status: 500, message: "Unexpected error" }; + const handler = this.requestHandlerSelector(url_object); + if (handler) { + return handler.submitRequest(data); } + return { status: 406, message: "URL Not supported" }; + } catch { + return { status: 406, message: "Invalid URL" }; } } diff --git a/orchestrator/src/integrations/base.ts b/orchestrator/src/integrations/base.ts new file mode 100644 index 0000000..270a245 --- /dev/null +++ b/orchestrator/src/integrations/base.ts @@ -0,0 +1,110 @@ +import { log } from "@/logger"; +import type { ProcessedRequestAdded } from "@/types"; +import { isValidJson } from "@/util"; +import axios, { type AxiosResponse } from "axios"; +import { run as jqRun } from "node-jq"; + +export abstract class BasicBearerAPIHandler { + constructor( + protected accessToken: string, + protected supported_host: string[], + protected supported_paths: string[], + protected rate: number, + ) {} + + get hosts() { + return this.supported_host; + } + + get paths() { + return this.supported_paths; + } + + get getRequestRate() { + return this.rate; + } + + isApprovedPath(url: URL): boolean { + return ( + this.hosts.includes(url.hostname.toLowerCase()) && + this.supported_paths.filter((path) => url.pathname.toLowerCase().startsWith(path)).length > 0 + ); + } + + getAccessToken(): string | null { + return this.accessToken; + } + + abstract validatePayload(path: string, payload: string | null): boolean; + + async submitRequest(data: ProcessedRequestAdded): Promise<{ status: number; message: string }> { + try { + const url = data.params.url?.includes("http") ? data.params.url : `https://${data.params.url}`; + try { + const url_object = new URL(url); + if (!this.isApprovedPath(url_object)) { + return { status: 406, message: `${url_object} is supposed by this orchestrator` }; + } + if (this.validatePayload(url_object.pathname, data.params.body)) { + return { status: 406, message: `Invalid Payload` }; + } + } catch (err) { + return { status: 406, message: `Invalid Domain Name` }; + } + + const token = this.getAccessToken(); + let request: AxiosResponse; + if (isValidJson(data.params.headers)) { + // TODO: Replace direct requests via axios with requests via VerityClient TS module + request = await axios({ + method: data.params.method, + data: data.params.body, + url: url, + headers: { + ...JSON.parse(data.params.headers), + Authorization: `Bearer ${token}`, + }, + }); + // return { status: request.status, message: request.data }; + } else { + request = await axios({ + method: data.params.method, + data: data.params.body, + url: url, + headers: { + Authorization: `Bearer ${token}`, + }, + }); + } + + try { + const result = (await jqRun(data.pick, JSON.stringify(request.data), { input: "string" })) as string; + return { status: request.status, message: result }; + } catch { + return { status: 409, message: "`Pick` value provided could not be resolved on the returned response" }; + } + // return { status: request.status, message: result }; + } catch (error: any) { + log.debug( + JSON.stringify({ + error: error.message, + }), + ); + + if (axios.isAxiosError(error)) { + // Handle Axios-specific errors + if (error.response) { + // Server responded with a status other than 2xx + return { status: error.response.status, message: error.response.data }; + } else if (error.request) { + // No response received + return { status: 504, message: "No response received" }; + } else { + // Handle non-Axios errors + return { status: 500, message: "Unexpected error" }; + } + } + } + return { status: 500, message: "Something unexpected Happened" }; + } +} diff --git a/orchestrator/src/integrations/openAI.ts b/orchestrator/src/integrations/openAI.ts new file mode 100644 index 0000000..571d9e9 --- /dev/null +++ b/orchestrator/src/integrations/openAI.ts @@ -0,0 +1,41 @@ +import env from "@/env"; +import Joi from "joi"; +import { BasicBearerAPIHandler } from "./base"; + +const chatSchema = Joi.object({ + model: Joi.string().required(), + messages: Joi.array().items( + Joi.object({ + role: Joi.string().valid("developer", "user").required(), + content: Joi.string().required(), + }).required(), + ), +}); +export default class TwitterIntegration extends BasicBearerAPIHandler { + validatePayload(path: string, payload: string): boolean { + try { + if (this.supported_paths.includes(path)) { + if (path === "/v1/chat/completions") { + const { error, value } = chatSchema.validate(JSON.parse(payload)); + if (error) { + return false; + } else { + if (value.model === "gpt-4o") { + return true; + } + } + } + } + return false; + } catch { + return false; + } + } +} + +export const instance = new TwitterIntegration( + env.integrations.xBearerToken, + ["api.openai.com"], + ["/v1/chat/completions"], + 60 * 1000, +); diff --git a/orchestrator/src/integrations/xtwitter.ts b/orchestrator/src/integrations/xtwitter.ts index 42175ae..257aef1 100644 --- a/orchestrator/src/integrations/xtwitter.ts +++ b/orchestrator/src/integrations/xtwitter.ts @@ -1,20 +1,15 @@ import env from "@/env"; +import { BasicBearerAPIHandler } from "./base"; -class XfkaTwitter { - private SERVER_DOMAIN = "api.twitter.com"; - - constructor(private accessToken: string) {} - - get hosts() { - return ["x.com", "api.x.com", "twitter.com", "api.twitter.com"]; - } - - get getRequestRate() { - return 60 * 1000; // - } - getAccessToken(): string | null { - return this.accessToken; +export default class TwitterIntegration extends BasicBearerAPIHandler { + validatePayload(path: string): boolean { + return true; } } -export const instance = new XfkaTwitter(env.integrations.xBearerToken); +export const instance = new TwitterIntegration( + env.integrations.openAIToken, + ["api.x.com", "api.twitter.com"], + ["/2/tweets"], + 60 * 1000, +); diff --git a/rooch/Move.toml b/rooch/Move.toml index 7925a31..9e16a19 100644 --- a/rooch/Move.toml +++ b/rooch/Move.toml @@ -1,18 +1,18 @@ [package] -name = "verity-move-oracles" +name = "verity-move-@orchestrators" version = "0.0.1" [dependencies] MoveStdlib = { git = "https://github.com/rooch-network/rooch.git", subdir = "frameworks/move-stdlib", rev = "main" } MoveosStdlib = { git = "https://github.com/rooch-network/rooch.git", subdir = "frameworks/moveos-stdlib", rev = "main" } -# RoochFramework = { git = "https://github.com/rooch-network/rooch.git", subdir = "frameworks/rooch-framework", rev = "main" } +RoochFramework = { git = "https://github.com/rooch-network/rooch.git", subdir = "frameworks/rooch-framework", rev = "main" } [addresses] verity = "_" verity_test_foreign_module = "_" std = "0x1" moveos_std = "0x2" -# rooch_framework = "0x3" +rooch_framework = "0x3" [dev-addresses] verity = "0x42" diff --git a/rooch/sources/example_caller.move b/rooch/sources/example_caller.move index ce0aec8..463a97c 100644 --- a/rooch/sources/example_caller.move +++ b/rooch/sources/example_caller.move @@ -11,11 +11,22 @@ module verity_test_foreign_module::example_caller { use std::vector; use std::string::String; use verity::oracles::{Self as Oracles}; + use rooch_framework::gas_coin::RGas; + use rooch_framework::account_coin_store; + #[test_only] + use verity::oracles; struct GlobalParams has key { - pending_requests: vector, + pending_requests: vector, } + #[test_only] + public fun init_for_test(){ + oracles::init_for_test(); + init(); + } + + // ? ------ OPTIONAL ------ // ? This is totally OPTIONAL struct RequestFulfilledEvent has copy, drop { @@ -35,18 +46,21 @@ module verity_test_foreign_module::example_caller { } public entry fun request_data( + from: &signer, url: String, method: String, headers: String, body: String, pick: String, - oracle: address + oracle: address, + amount: u256 ) { let http_request = Oracles::build_request(url, method, headers, body); // We're passing the address and function identifier of the recipient address. in this from :: // If you do not want to pay for the Oracle to notify your contract, you can pass in option::none() as the argument. - let request_id = Oracles::new_request(http_request, pick, oracle, Oracles::with_notify(@verity_test_foreign_module, b"example_caller::receive_data")); + let payment = account_coin_store::withdraw(from, amount); + let request_id = Oracles::new_request_with_payment(http_request, pick, oracle, Oracles::with_notify(@verity_test_foreign_module, b"example_caller::receive_data"),payment); // let no_notify_request_id = Oracles::new_request(http_request, pick, oracle, Oracles::without_notify()); let params = account::borrow_mut_resource(@verity_test_foreign_module); vector::push_back(&mut params.pending_requests, request_id); @@ -86,4 +100,147 @@ module verity_test_foreign_module::example_caller { i = i + 1; }; } + + #[view] + public fun pending_requests_count(): u64 { + let params = account::borrow_resource(@verity_test_foreign_module); + vector::length(¶ms.pending_requests) + } +} + +#[test_only] +module verity_test_foreign_module::test_foreign_module { + use moveos_std::signer; + use verity_test_foreign_module::example_caller::{Self, request_data, pending_requests_count}; + use rooch_framework::gas_coin; + use verity::registry; + use std::vector; + + + + #[test_only] + struct Test has key {} + + #[test_only] + struct TestOrchestrator has key {} + + #[test_only] + fun setup_test() { + example_caller::init_for_test(); + } + + #[test] + fun test_request_data_basic() { + setup_test(); + let test_signer = moveos_std::signer::module_signer(); + let test_orchestrator = moveos_std::signer::module_signer(); + + + + // Setup test parameters + let url = std::string::utf8(b"https://api.test.com"); + let method = std::string::utf8(b"GET"); + let headers = std::string::utf8(b"Content-Type: application/json"); + let body = std::string::utf8(b""); + let pick = std::string::utf8(b"$.data"); + let amount = 100000u256; + + registry::add_supported_url(&test_orchestrator, url, 100, 0, 1, 0); + + + // Fund the test account + gas_coin::faucet_entry(&test_signer, amount); + + // Make request + request_data(&test_signer, url, method, headers, body, pick, signer::address_of(&test_orchestrator), amount); + + // Verify request was stored + + assert!(pending_requests_count() == 1, 0); + } + + + #[test] + fun test_multiple_requests() { + setup_test(); + let test_signer = moveos_std::signer::module_signer(); + let test_orchestrator = moveos_std::signer::module_signer(); + + + // Setup test parameters for multiple requests + let urls = vector[ + std::string::utf8(b"https://api.test.com/2/test/id2"), + std::string::utf8(b"https://api.test.com/2/my_profile"), + std::string::utf8(b"https://api.test.com/2/test") + ]; + let method = std::string::utf8(b"GET"); + let headers = std::string::utf8(b"Content-Type: application/json"); + let body = std::string::utf8(b""); + let pick = std::string::utf8(b"$.data"); + let amount = 100u256; + + registry::add_supported_url(&test_orchestrator, std::string::utf8(b"https://api.test.com/2/"), 100, 0, 1, 0); + + + // Fund the test account with enough for multiple requests + let total_amount = amount * 4; + gas_coin::faucet_entry(&test_signer, total_amount); + + // Make multiple requests + let i = 0; + while (i < vector::length(&urls)) { + let url = *vector::borrow(&urls, i); + request_data(&test_signer, url, method, headers, body, pick, signer::address_of(&test_orchestrator), amount); + i = i + 1; + }; + + // Verify all requests were stored + assert!(pending_requests_count() == 3, 2); + + } + + #[test] + #[expected_failure(abort_code = rooch_framework::coin_store::ErrorInsufficientBalance, location = rooch_framework::coin_store)] // Adjust abort code as needed + fun test_insufficient_funds() { + setup_test(); + let test_signer = moveos_std::signer::module_signer(); + + let url = std::string::utf8(b"https://api.test.com"); + let method = std::string::utf8(b"GET"); + let headers = std::string::utf8(b"Content-Type: application/json"); + let body = std::string::utf8(b""); + let pick = std::string::utf8(b"$.data"); + let oracle = @0x1; + let amount = 100u256; + + + + gas_coin::faucet_entry(&test_signer, amount/10); + request_data(&test_signer, url, method, headers, body, pick, oracle, amount); + } + + #[test] + fun test_request_with_body() { + setup_test(); + let test_signer = moveos_std::signer::module_signer(); + let test_orchestrator = moveos_std::signer::module_signer(); + + let url = std::string::utf8(b"https://api.test.com/yud"); + let method = std::string::utf8(b"POST"); + let headers = std::string::utf8(b"Content-Type: application/json"); + let body = std::string::utf8(b"{\"key\":\"value\"}"); + let pick = std::string::utf8(b".data"); + let oracle = signer::address_of(&test_orchestrator); + let amount = 1000u256; + + + gas_coin::faucet_entry(&test_signer, amount); + + + registry::add_supported_url(&test_orchestrator, std::string::utf8(b"https://api.test.com/"), 100, 0, 1, 0); + request_data(&test_signer, url, method, headers, body, pick, oracle, amount); + + assert!(pending_requests_count() == 1, 4); + + } } diff --git a/rooch/sources/oracle_registry.move b/rooch/sources/oracle_registry.move new file mode 100644 index 0000000..b1dec68 --- /dev/null +++ b/rooch/sources/oracle_registry.move @@ -0,0 +1,313 @@ +/// Module for managing oracle registry and URL support in the Verity system. +/// This module handles registration of oracles, their supported URLs, and cost calculations. +module verity::registry { + use std::option::{Self, Option}; + use moveos_std::signer; + use std::vector; + use moveos_std::event; + use moveos_std::account; + use std::string::{Self, String}; + use moveos_std::simple_map::{Self, SimpleMap}; + use moveos_std::string_utils; + + /// Error code when caller is not a registered oracle + const NotOracleError: u64 = 2001; + /// Error code when URL prefix is invalid or not found + const InvalidURLPrefixError: u64 = 2002; + + /// Metadata structure for supported URL endpoints + struct SupportedURLMetadata has copy, drop, store { + /// The URL prefix that this oracle supports + url_prefix: String, + /// Base fee charged for requests to this endpoint + base_fee: u256, + /// Minimum payload length before additional charges apply + minimum_payload_length: u64, + /// Cost per token for payload beyond minimum length + cost_per_payload_token: u256, + /// Cost per token for response data + cost_per_respond_token: u256, + } + + /// Global storage for oracle registry + struct GlobalParams has key { + /// Mapping of oracle addresses to their supported URLs + supported_urls: SimpleMap>, + } + + /// Initialize the registry module + fun init() { + let module_signer = signer::module_signer(); + account::move_resource_to(&module_signer, GlobalParams { + supported_urls: simple_map::new(), + }); + } + + #[test_only] + /// Initialize the registry module for testing + public fun init_for_test() { + init(); + } + + // Events + /// Event emitted when URL support is added + struct URLSupportAdded has copy, drop { + orchestrator: address, + url: String, + base_fee: u256, + minimum_payload_length: u64, + cost_per_payload_token: u256 + } + + /// Event emitted when URL support is removed + struct URLSupportRemoved has copy, drop { + orchestrator: address, + url: String + } + + // Compute the cost for an orchestrator request based on payload length + // Returns Option - Some(cost) if URL is supported, None otherwise + #[view] + public fun estimated_cost( + orchestrator: address, + url: String, + payload_length: u64, + respond_length: u64 + ): Option { + let supported_urls = &account::borrow_resource(@verity).supported_urls; + + let url = string_utils::to_lower_case(&url); + if (simple_map::contains_key(supported_urls, &orchestrator)) { + let orchestrator_urls = simple_map::borrow(supported_urls, &orchestrator); + + let i = 0; + while (i < vector::length(orchestrator_urls)) { + let orchestrator_url = vector::borrow(orchestrator_urls, i); + let prefix = string_utils::to_lower_case(&orchestrator_url.url_prefix); + // if index is 0 then prefix is a prefix of URL since its at index 0 + if ((string::index_of(&url, &prefix) == 0)) { + if (orchestrator_url.minimum_payload_length > payload_length) { + return option::none() + }; + let chargeable_token: u256 = ((payload_length as u256) - (orchestrator_url.minimum_payload_length as u256)); + return option::some( + orchestrator_url.base_fee + + (chargeable_token * orchestrator_url.cost_per_payload_token) + + (orchestrator_url.cost_per_respond_token * (respond_length as u256)) + ) + }; + i = i + 1; + } + }; + option::none() + } + + /// Add support for a new URL endpoint with specified pricing parameters + /// If URL already exists, updates the existing metadata + public fun add_supported_url( + caller: &signer, + url_prefix: String, + base_fee: u256, + minimum_payload_length: u64, + cost_per_payload_token: u256, + cost_per_respond_token: u256 + ) { + let sender = signer::address_of(caller); + let global_params = account::borrow_mut_resource(@verity); + + // Initialize orchestrator's URL vector if it doesn't exist + if (!simple_map::contains_key(&global_params.supported_urls, &sender)) { + simple_map::add(&mut global_params.supported_urls, sender, vector::empty()); + }; + + let orchestrator_urls = simple_map::borrow_mut(&mut global_params.supported_urls, &sender); + let metadata = SupportedURLMetadata { + url_prefix, + base_fee, + minimum_payload_length, + cost_per_payload_token, + cost_per_respond_token + }; + + // Check if URL prefix already exists + let i = 0; + let len = vector::length(orchestrator_urls); + let found = false; + while (i < len) { + let existing_metadata = vector::borrow_mut(orchestrator_urls, i); + if (existing_metadata.url_prefix == url_prefix) { + // Update existing metadata + *existing_metadata = metadata; + found = true; + break + }; + i = i + 1; + }; + + // If URL prefix not found, add new entry + if (!found) { + vector::push_back(orchestrator_urls, metadata); + }; + + // Emit event + event::emit(URLSupportAdded { + orchestrator: sender, + url: url_prefix, + base_fee, + minimum_payload_length, + cost_per_payload_token + }); + } + + /// Remove support for a URL endpoint + /// Aborts if URL is not found or caller is not the oracle + public fun remove_supported_url( + caller: &signer, + url_prefix: String + ) { + let sender = signer::address_of(caller); + let global_params = account::borrow_mut_resource(@verity); + + assert!(simple_map::contains_key(&global_params.supported_urls, &sender), NotOracleError); + let orchestrator_urls = simple_map::borrow_mut(&mut global_params.supported_urls, &sender); + + let i = 0; + let len = vector::length(orchestrator_urls); + let found = false; + while (i < len) { + let metadata = vector::borrow(orchestrator_urls, i); + if (metadata.url_prefix == url_prefix) { + vector::remove(orchestrator_urls, i); + found = true; + event::emit(URLSupportRemoved { + orchestrator: sender, + url: url_prefix + }); + break + }; + i = i + 1; + }; + // Ensure URL was actually found and removed + assert!(found, InvalidURLPrefixError); + } + + #[view] + /// Get all supported URLs and their metadata for an orchestrator + /// Returns empty vector if orchestrator not found + public fun get_supported_urls(orchestrator: address): vector { + let global_params = account::borrow_resource(@verity); + + if (simple_map::contains_key(&global_params.supported_urls, &orchestrator)) { + *simple_map::borrow(&global_params.supported_urls, &orchestrator) + } else { + vector::empty() + } + } +} + +#[test_only] +module verity::test_registry { + use std::string; + use moveos_std::signer; + use std::option; + use std::vector; + use verity::registry; + + struct Test has key {} + + #[test] + fun test_add_supported_url() { + // Test adding a new supported URL + let test = signer::module_signer(); + registry::init_for_test(); + + // Test adding new URL support + let url = string::utf8(b"https://api.example.com"); + registry::add_supported_url(&test, url, 100, 0, 1, 0); + + let urls = registry::get_supported_urls(signer::address_of(&test)); + assert!(vector::length(&urls) == 1, 0); + + let cost = registry::estimated_cost(signer::address_of(&test), url, 500, 0); + assert!(option::is_some(&cost), 0); + assert!(option::extract(&mut cost) == 600, 0); // base_fee + (min_length - payload_length) * cost_per_payload_token + } + + #[test] + fun test_update_existing_url() { + // Test updating an existing URL + let test = signer::module_signer(); + registry::init_for_test(); + + let url = string::utf8(b"https://api.example.com"); + + // Add initial URL support with base parameters + registry::add_supported_url(&test, url, 100, 50, 1, 2); + + // Verify initial parameters + let urls = registry::get_supported_urls(signer::address_of(&test)); + assert!(vector::length(&urls) == 1, 0); + + let initial_cost = registry::estimated_cost(signer::address_of(&test), url, 100, 200); + assert!(option::is_some(&initial_cost), 1); + // Cost should be: base_fee(100) + (100-50)*1 + 200*2 = 550 + assert!(option::destroy_some(initial_cost) == 550, 2); + + // Update URL with new parameters + registry::add_supported_url(&test, url, 200, 100, 2, 4); + + // Verify updated parameters + let urls = registry::get_supported_urls(signer::address_of(&test)); + assert!(vector::length(&urls) == 1, 3); + + let updated_cost = registry::estimated_cost(signer::address_of(&test), url, 150, 100); + assert!(option::is_some(&updated_cost), 4); + // New cost should be: base_fee(200) + (150-100)*2 + 100*4 = 700 + assert!(option::destroy_some(updated_cost) == 700, 5); + } + + #[test] + fun test_remove_supported_url() { + // Test removing a supported URL + let test = signer::module_signer(); + registry::init_for_test(); + + // Test removing URL support + let url = string::utf8(b"https://api.example.com"); + registry::add_supported_url(&test, url, 100, 0, 1, 0); + + let urls = registry::get_supported_urls(signer::address_of(&test)); + assert!(vector::length(&urls) == 1, 1); + + registry::remove_supported_url(&test, url); + let urls = registry::get_supported_urls(signer::address_of(&test)); + assert!(vector::length(&urls) == 0, 0); + } + + #[test] + fun test_compute_cost() { + // Test cost computation for supported URL + let test = signer::module_signer(); + registry::init_for_test(); + + // Test cost computation + let url = string::utf8(b"https://api.example.com"); + registry::add_supported_url(&test, url, 100, 0, 1, 0); + + let cost = registry::estimated_cost(signer::address_of(&test), url, 500, 0); + assert!(option::is_some(&cost), 0); + assert!(option::extract(&mut cost) == 600, 0); // base_fee + (min_length - payload_length) * cost_per_payload_token + } + + #[test] + fun test_compute_cost_nonexistent_url() { + // Test cost computation for non-existent URL + let test = signer::module_signer(); + registry::init_for_test(); + + // Test cost computation for non-existent URL + let url = string::utf8(b"https://nonexistent.com"); + let cost = registry::estimated_cost(signer::address_of(&test), url, 500, 0); + assert!(option::is_none(&cost), 0); + } +} \ No newline at end of file diff --git a/rooch/sources/oracles.move b/rooch/sources/oracles.move index 40a828b..760a603 100644 --- a/rooch/sources/oracles.move +++ b/rooch/sources/oracles.move @@ -1,29 +1,45 @@ // Copyright (c) Usher Labs // SPDX-License-Identifier: LGPL-2.1 -// This module implements an oracle system for Verity. -// It allows users to create new requests for off-chain data, -// which are then fulfilled by designated oracles. -// The system manages pending requests and emits events -// for both new requests and fulfilled requests. +/// This module implements an oracle system for Verity. +/// It allows users to create new requests for off-chain data, +/// which are then fulfilled by designated oracles. +/// The system manages pending requests and emits events +/// for both new requests and fulfilled requests. module verity::oracles { + use moveos_std::event; use moveos_std::tx_context; use moveos_std::signer; use moveos_std::account; use moveos_std::address; - use moveos_std::object::{Self, ObjectID}; + use moveos_std::object::{Self, ObjectID, Object}; use std::vector; - use std::string::String; + use rooch_framework::coin::{Self, Coin}; + use rooch_framework::gas_coin::RGas; + use rooch_framework::account_coin_store; + use rooch_framework::coin_store::{Self, CoinStore}; + use std::string::{Self, String}; use std::option::{Self, Option}; + use moveos_std::simple_map::{Self, SimpleMap}; + + #[test_only] + use rooch_framework::genesis; + use verity::registry::{Self as OracleSupport}; const RequestNotFoundError: u64 = 1001; const SignerNotOracleError: u64 = 1002; // const ProofNotValidError: u64 = 1003; const OnlyOwnerError: u64 = 1004; - - // Struct to represent HTTP request parameters - // Designed to be imported by third-party contracts + const NotEnoughGasError: u64 = 1005; + const OracleSupportError: u64 = 1006; + const DoubleFulfillmentError: u64 = 1007; + const InsufficientBalanceError: u64 = 1008; + const NoBalanceError: u64 = 1009; + const ZeroAmountError: u64 = 1010; + + /// Struct to represent HTTP request parameters + /// Designed to be imported by third-party contracts struct HTTPRequest has store, copy, drop { url: String, method: String, @@ -31,20 +47,26 @@ module verity::oracles { body: String, } + /// Represents an oracle request with all necessary parameters struct Request has key, store, copy, drop { params: HTTPRequest, pick: String, // An optional JQ string to pick the value from the response JSON data structure. oracle: address, response_status: u16, - response: Option + response: Option, + account_to_credit: address, + amount: u256 } - // Global params for the oracle system + /// Global parameters for the oracle system struct GlobalParams has key { owner: address, + treasury: Object>, + balances: SimpleMap, } // -------- Events -------- + /// Event emitted when a new request is added struct RequestAdded has copy, drop { params: HTTPRequest, pick: String, // An optional JQ string to pick the value from the response JSON data structure. @@ -53,22 +75,42 @@ module verity::oracles { request_id: ObjectID } + /// Event emitted when a request is fulfilled struct Fulfilment has copy, drop { request: Request, } + + /// Event emitted for escrow deposits/withdrawals + struct EscrowEvent has copy, drop { + user: address, + amount: u256, + is_deposit: bool, + } // ------------------------ + /// Initialize the oracle system fun init() { let module_signer = signer::module_signer(); let owner = tx_context::sender(); + let treasury_obj = coin_store::create_coin_store(); account::move_resource_to(&module_signer, GlobalParams { owner, + treasury: treasury_obj, + balances: simple_map::new(), }); } - // Only owner can set the verifier - // TODO: Move this out into it's own ownable module. + #[test_only] + /// Initialize the oracle system for testing + public fun init_for_test() { + genesis::init_for_test(); + OracleSupport::init_for_test(); + init(); + } + + /// Change the owner of the oracle system + /// Only callable by current owner public entry fun set_owner( new_owner: address ) { @@ -78,7 +120,7 @@ module verity::oracles { params.owner = new_owner; } - // Builds a request object from the provided parameters + /// Create a new HTTPRequest struct with the given parameters public fun build_request( url: String, method: String, @@ -93,7 +135,7 @@ module verity::oracles { } } - // Inspo from https://github.com/rooch-network/rooch/blob/65f436ba16b04e479125ac414cf5c6c876a8809d/frameworks/bitcoin-move/sources/types.move#L77 + /// Create notification data for request callbacks public fun with_notify( notify_address: address, notify_function: vector @@ -105,23 +147,19 @@ module verity::oracles { option::some(res) } + /// Create empty notification data public fun without_notify(): Option> { option::none() } - /// Creates a new oracle request for arbitrary API data. - /// This function is intended to be called by third-party contracts - /// to initiate off-chain data requests. - public fun new_request( + /// Internal function to create a new request + fun create_request( params: HTTPRequest, pick: String, oracle: address, - notify: Option> + notify: Option>, + amount: u256 ): ObjectID { - // let recipient = tx_context::sender(); - - // TODO: Ensure that the recipient has enough gas for the request. - // Create new request object let request = object::new(Request { params, @@ -129,12 +167,12 @@ module verity::oracles { oracle, response_status: 0, response: option::none(), + account_to_credit: tx_context::sender(), + amount, }); let request_id = object::id(&request); object::transfer(request, oracle); // transfer to oracle to ensure permission - // TODO: Move gas from recipient to module account - // Emit event event::emit(RequestAdded { params, @@ -144,9 +182,128 @@ module verity::oracles { request_id }); - request_id + return request_id + } + + /// Creates a new oracle request with direct payment + /// Caller must provide sufficient RGas payment + public fun new_request_with_payment( + params: HTTPRequest, + pick: String, + oracle: address, + notify: Option>, + payment: Coin + ): ObjectID { + let sent_coin = coin::value(&payment); + // 1024 could be changed to the max string length allowed on Move + let option_min_amount = OracleSupport::estimated_cost(oracle, params.url, string::length(¶ms.body), 1024); + assert!(option::is_some(&option_min_amount), OracleSupportError); + let min_amount = option::destroy_some(option_min_amount); + + assert!(sent_coin >= min_amount, NotEnoughGasError); + let global_param = account::borrow_mut_resource(@verity); + coin_store::deposit(&mut global_param.treasury, payment); + + return create_request( + params, + pick, + oracle, + notify, + min_amount + ) + } + + /// Creates a new oracle request using caller's escrow balance + public fun new_request( + params: HTTPRequest, + pick: String, + oracle: address, + notify: Option>, + ): ObjectID { + let sender = tx_context::sender(); + let account_balance = get_user_balance(sender); + // 1024 could be changed to the max string length allowed on Move + let option_min_amount = OracleSupport::estimated_cost(oracle, params.url, string::length(¶ms.body), 1024); + assert!(option::is_some(&option_min_amount), OracleSupportError); + let min_amount = option::destroy_some(option_min_amount); + + assert!(account_balance >= min_amount, NotEnoughGasError); + let global_params = account::borrow_mut_resource(@verity); + let balance = simple_map::borrow_mut(&mut global_params.balances, &sender); + *balance = *balance - min_amount; + + return create_request( + params, + pick, + oracle, + notify, + min_amount + ) } + /// Deposit RGas into escrow for future oracle requests + public entry fun deposit_to_escrow(from: &signer, amount: u256) { + // Check that amount is not zero + assert!(amount > 0, ZeroAmountError); + + let global_params = account::borrow_mut_resource(@verity); + let sender = signer::address_of(from); + + let deposit = account_coin_store::withdraw(from, amount); + coin_store::deposit(&mut global_params.treasury, deposit); + + if (!simple_map::contains_key(&global_params.balances, &sender)) { + simple_map::add(&mut global_params.balances, sender, amount); + } else { + let balance = simple_map::borrow_mut(&mut global_params.balances, &sender); + *balance = *balance + amount; + }; + + // Emit deposit event + event::emit(EscrowEvent { + user: sender, + amount, + is_deposit: true, + }); + } + + /// Withdraw RGas from escrow + public entry fun withdraw_from_escrow(from: &signer, amount: u256) { + // Check that amount is not zero + assert!(amount > 0, ZeroAmountError); + + let global_params = account::borrow_mut_resource(@verity); + let sender = signer::address_of(from); + + // Check if user has a balance + assert!(simple_map::contains_key(&global_params.balances, &sender), NoBalanceError); + + let balance = simple_map::borrow_mut(&mut global_params.balances, &sender); + // Check if user has enough balance + assert!(*balance >= amount, InsufficientBalanceError); + + // Update balance + *balance = *balance - amount; + + // If balance becomes zero, remove the entry + if (*balance == 0) { + simple_map::remove(&mut global_params.balances, &sender); + }; + + // Withdraw from treasury and deposit to user + let withdrawal = coin_store::withdraw(&mut global_params.treasury, amount); + account_coin_store::deposit(sender, withdrawal); + + // Emit withdraw event + event::emit(EscrowEvent { + user: sender, + amount, + is_deposit: false, + }); + } + + /// Fulfill an oracle request with response data + /// Only callable by the designated oracle public entry fun fulfil_request( caller: &signer, id: ObjectID, @@ -161,14 +318,35 @@ module verity::oracles { // Verify the signer matches the pending request's signer/oracle assert!(request.oracle == caller_address, SignerNotOracleError); - // // Verify the data and proof + // Prevent double fulfillment + assert!(request.response_status == 0 || option::is_none(&request.response), DoubleFulfillmentError); + + // // Verify the data and proofsin // assert!(verify(result, proof), ProofNotValidError); // Fulfil the request request.response = option::some(result); request.response_status = response_status; - // TODO: Move gas from module escrow to Oracle + let option_fulfillment_cost = OracleSupport::estimated_cost(request.oracle, request.params.url, string::length(&request.params.body), string::length(&result)); + assert!(option::is_some(&option_fulfillment_cost), OracleSupportError); + let fulfillment_cost = option::destroy_some(option_fulfillment_cost); + + // send token to orchestrator wallet + let global_params = account::borrow_mut_resource(@verity); + let payment = coin_store::withdraw(&mut global_params.treasury, fulfillment_cost); + + account_coin_store::deposit(caller_address, payment); + + // add extra to balance if any exists + if (request.amount > fulfillment_cost && (request.amount - fulfillment_cost) > 0) { + if (!simple_map::contains_key(&global_params.balances, &request.account_to_credit)) { + simple_map::add(&mut global_params.balances, request.account_to_credit, request.amount - fulfillment_cost); + } else { + let balance = simple_map::borrow_mut(&mut global_params.balances, &request.account_to_credit); + *balance = *balance + request.amount - fulfillment_cost; + }; + }; // Emit fulfil event event::emit(Fulfilment { @@ -176,7 +354,6 @@ module verity::oracles { }); } - // // This is a Version 0 of the verifier. // public fun verify( // data: String, @@ -187,6 +364,7 @@ module verity::oracles { // } // ------------ HELPERS ------------ + /// Internal helper to borrow a request object fun borrow_request(id: &ObjectID): &Request { let ref = object::borrow_object(*id); object::borrow(ref) @@ -239,23 +417,37 @@ module verity::oracles { let request = borrow_request(id); request.response_status } + + #[view] + public fun get_user_balance(user: address): u256 { + let global_params = account::borrow_resource(@verity); + + if (simple_map::contains_key(&global_params.balances, &user)) { + *simple_map::borrow(&global_params.balances, &user) + } else { + 0 + } + } } #[test_only] module verity::test_oracles { use std::string; use moveos_std::signer; - use std::option; + // use std::option; use verity::oracles; use moveos_std::object::ObjectID; - + use rooch_framework::gas_coin; struct Test has key { } #[test_only] - // Test for creating a new request public fun create_oracle_request(): ObjectID { + // Test for creating a new request + oracles::init_for_test(); + let sig = signer::module_signer(); + let oracle = signer::address_of(&sig); let url = string::utf8(b"https://api.example.com/data"); let method = string::utf8(b"GET"); let headers = string::utf8(b"Content-Type: application/json\nUser-Agent: MoveClient/1.0"); @@ -264,18 +456,24 @@ module verity::test_oracles { let http_request = oracles::build_request(url, method, headers, body); let response_pick = string::utf8(b""); - let sig = signer::module_signer(); - let oracle = signer::address_of(&sig); // let recipient = @0x46; + let payment = gas_coin::mint_for_test(1000u256); - let request_id = oracles::new_request(http_request, response_pick, oracle, oracles::with_notify(@verity,b"")); + let request_id = oracles::new_request_with_payment( + http_request, + response_pick, + oracle, + oracles::with_notify(@verity, b""), + payment + ); request_id } #[test_only] - /// Test function to consume the FulfilRequestObject public fun fulfil_request(id: ObjectID) { + // Test function to fulfill a request + oracles::init_for_test(); let result = string::utf8(b"Hello World"); // let proof = string::utf8(b""); @@ -283,9 +481,10 @@ module verity::test_oracles { oracles::fulfil_request(&sig, id, 200, result); } - #[test] - public fun test_view_functions(){ + #[expected_failure(abort_code = 1006, location = verity::oracles)] + public fun test_view_functions() { + // Test view functions let id = create_oracle_request(); let sig = signer::module_signer(); // Test the Object @@ -294,15 +493,64 @@ module verity::test_oracles { assert!(oracles::get_request_params_url(&id) == string::utf8(b"https://api.example.com/data"), 99952); assert!(oracles::get_request_params_method(&id) == string::utf8(b"GET"), 99953); assert!(oracles::get_request_params_body(&id) == string::utf8(b""), 99954); - assert!(oracles::get_response_status(&id) ==(0 as u16), 99955); + assert!(oracles::get_response_status(&id) == (0 as u16), 99955); } #[test] + #[expected_failure(abort_code = 1006, location = verity::oracles)] public fun test_consume_fulfil_request() { + // Test request fulfillment let id = create_oracle_request(); fulfil_request(id); - assert!(oracles::get_response(&id) == option::some(string::utf8(b"Hello World")), 99958); + // assert!(oracles::get_response(&id) == option::some(string::utf8(b"Hello World")), 99958); assert!(oracles::get_response_status(&id) == (200 as u16), 99959); } + + #[test] + public fun test_deposit_and_withdraw() { + // Test escrow deposit and withdrawal + // Initialize test environment + oracles::init_for_test(); + let sig = signer::module_signer(); + let user = signer::address_of(&sig); + + // Test deposit + let deposit_amount = 1000u256; + gas_coin::faucet_entry(&sig, deposit_amount); + oracles::deposit_to_escrow(&sig, deposit_amount); + + // Verify balance after deposit + assert!(oracles::get_user_balance(user) == deposit_amount, 99960); + + // Test withdraw + let withdraw_amount = 500u256; + oracles::withdraw_from_escrow(&sig, withdraw_amount); + + // Verify balance after withdrawal + assert!(oracles::get_user_balance(user) == deposit_amount - withdraw_amount, 99961); + } + + #[test] + #[expected_failure(abort_code = 1010, location = verity::oracles)] + public fun test_zero_amount_deposit() { + // Test zero amount deposit failure + oracles::init_for_test(); + let sig = signer::module_signer(); + oracles::deposit_to_escrow(&sig, 0); + } + + #[test] + #[expected_failure(abort_code = 1008, location = verity::oracles)] + public fun test_insufficient_balance_withdraw() { + // Test insufficient balance withdrawal failure + oracles::init_for_test(); + let sig = signer::module_signer(); + let deposit_amount = 100u256; + gas_coin::faucet_entry(&sig, deposit_amount); + + oracles::deposit_to_escrow(&sig, deposit_amount); + // Try to withdraw more than deposited + oracles::withdraw_from_escrow(&sig, 200u256); + } } \ No newline at end of file