diff --git a/orchestrator/src/config/network.ts b/orchestrator/src/config/network.ts new file mode 100644 index 0000000..db511d3 --- /dev/null +++ b/orchestrator/src/config/network.ts @@ -0,0 +1,21 @@ +import type { SuiNetwork } from "@/types"; +import { getFullnodeUrl } from "@mysten/sui/client"; + +export const networkConfig = { + localnet: { + url: getFullnodeUrl("localnet"), + }, + devnet: { + url: getFullnodeUrl("devnet"), + }, + testnet: { + url: getFullnodeUrl("testnet"), + }, + mainnet: { + url: getFullnodeUrl("mainnet"), + }, +} as const; + +export function getNetworkConfig(network: SuiNetwork) { + return networkConfig[network]; +} diff --git a/orchestrator/src/env.ts b/orchestrator/src/env.ts index 4631927..449c83b 100644 --- a/orchestrator/src/env.ts +++ b/orchestrator/src/env.ts @@ -25,6 +25,15 @@ const baseConfig = { // Integrations xBearerToken: process.env.X_BEARER_TOKEN ?? "", openAIToken: process.env.OPEN_AI_TOKEN ?? "", + // Add Sui configuration + sui: process.env.CHAINS?.includes("SUI") + ? { + chainId: process.env.SUI_CHAIN_ID as "mainnet" | "testnet" | "devnet", + oracleAddress: process.env.SUI_ORACLE_ADDRESS, + indexerCron: process.env.SUI_INDEXER_CRON, + privateKey: process.env.SUI_PRIVATE_KEY, + } + : undefined, }; interface IEnvVars { @@ -43,6 +52,12 @@ interface IEnvVars { batchSize: number; xBearerToken: string; openAIToken: string; + sui?: { + chainId: "mainnet" | "testnet" | "devnet"; + oracleAddress: string; + indexerCron: string; + privateKey: string; + }; } const envVarsSchema = Joi.object({ @@ -98,6 +113,20 @@ const envVarsSchema = Joi.object({ sentryDSN: Joi.string().allow("", null), ecdsaPrivateKey: Joi.string().allow("", null), batchSize: Joi.number().default(1000), + + // Add Sui validation + sui: Joi.object({ + chainId: Joi.string().valid("mainnet", "testnet", "devnet").insensitive(), + oracleAddress: Joi.string().custom((value, helper) => addressValidator(value, helper)), + indexerCron: Joi.string().default("*/5 * * * * *"), + privateKey: Joi.string().custom((value, helper) => { + return privateKeyValidator(value, helper); + }), + }).when("chains", { + is: Joi.array().items(Joi.string().valid(SupportedChain.SUI)), + then: Joi.required(), + otherwise: Joi.optional(), + }), }); const { value, error } = envVarsSchema.validate({ @@ -132,4 +161,5 @@ export default { privateKey: envVars.aptosPrivateKey, noditKey: envVars.aptosNoditKey, }, + sui: envVars.sui, }; diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 8b686bf..fb07f8f 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -3,6 +3,7 @@ import "dotenv/config"; import env from "./env"; import AptosIndexer from "./indexer/aptos"; import RoochIndexer from "./indexer/rooch"; +import SuiIndexer from "./indexer/sui"; import { log } from "./logger"; (async () => { @@ -45,4 +46,38 @@ import { log } from "./logger"; } else { log.info(`Skipping Aptos Indexer initialization...`); } + + // Add debug logs before the Sui check + console.log("Debug Sui values:", { + privateKey: !!env.sui?.privateKey, // just log if it exists + chainId: env.sui?.chainId, + oracleAddress: env.sui?.oracleAddress, + chains: env.chains, + includesSUI: env.chains.includes("SUI"), + }); + + if (env.sui?.privateKey && env.sui.chainId && env.sui.oracleAddress && env.chains.includes("SUI")) { + const suiIndexer = new SuiIndexer(env.sui.oracleAddress, env.sui.chainId, env.sui.privateKey); + + // Add immediate execution for testing + log.info("Running Sui indexer immediately..."); + await suiIndexer.run(); + + new CronJob( + env.sui.indexerCron, + async () => { + log.info("Running Sui indexer from cron..."); + await suiIndexer.run(); + }, + null, + true, + ); + } else { + log.info(`Skipping Sui Indexer initialization...`, { + hasPrivateKey: !!env.sui?.privateKey, + chainId: env.sui?.chainId, + oracleAddress: env.sui?.oracleAddress, + includesSUI: env.chains.includes("SUI"), + }); + } })(); diff --git a/orchestrator/src/indexer/base.ts b/orchestrator/src/indexer/base.ts index 175323b..c7cc998 100644 --- a/orchestrator/src/indexer/base.ts +++ b/orchestrator/src/indexer/base.ts @@ -7,6 +7,11 @@ import { instance as xTwitterInstance } from "@/integrations/xtwitter"; import type { BasicBearerAPIHandler } from "@/integrations/base"; import prismaClient from "../../prisma"; +type ChainEventData = { + id?: { txDigest: string }; + event_id?: { event_handle_id: string }; +}; + // Abstract base class export abstract class Indexer { constructor( @@ -75,15 +80,13 @@ export abstract class Indexer { async processRequestAddedEvent( data: ProcessedRequestAdded, ): Promise<{ status: number; message: string } | null> { - log.debug("processing request:", data.request_id); - - if (data.oracle.toLowerCase() !== this.getOrchestratorAddress().toLowerCase()) { - log.debug( - "skipping request as it's not for this Oracle:", - data.request_id, - this.getOrchestratorAddress().toLowerCase(), - data.oracle.toLowerCase(), - ); + log.debug(`processing request: ${data.request_id}`); + + const eventOracle = data.oracle.toLowerCase(); + const oracleAddress = this.getOracleAddress().toLowerCase(); + + if (eventOracle !== oracleAddress) { + log.debug(`skipping request as it's not for this Oracle: ${data.request_id} ${eventOracle} ${oracleAddress}`); return null; } try { @@ -95,67 +98,65 @@ export abstract class Indexer { return handler.submitRequest(data); } return { status: 406, message: "URL Not supported" }; - } catch { + } catch (error: any) { return { status: 406, message: "Invalid URL" }; } } async run() { - log.info(`${this.getChainId()} indexer running...`, Date.now()); - - const latestCommit = await prismaClient.events.findFirst({ + const latestSuccessfulEvent = await prismaClient.events.findFirst({ where: { chain: this.getChainId(), + status: RequestStatus.SUCCESS, }, orderBy: { eventSeq: "desc", - // indexedAt: "desc", // Order by date in descending order }, }); - // Fetch the latest events from the Aptos Oracles Contract - const newRequestsEvents = await this.fetchRequestAddedEvents(Number(latestCommit?.eventSeq ?? 0) ?? 0); - for (let i = 0; i < newRequestsEvents.length; i++) { + const cursor = latestSuccessfulEvent?.eventHandleId || null; + const newRequestsEvents = await this.fetchRequestAddedEvents(cursor); + + for (const event of newRequestsEvents) { try { - const event = newRequestsEvents[i]; - - if (!(await this.isPreviouslyExecuted(event))) { - const data = await this.processRequestAddedEvent(event); - - log.info({ data }); - - if (data) { - try { - await this.sendFulfillment(event, data.status, JSON.stringify(data.message)); - await this.save(event, data, RequestStatus.SUCCESS); - } catch (err: any) { - log.error({ err: err.message }); - await this.save(event, data, RequestStatus.FAILED); - } - } - } else { - log.debug({ message: `Request: ${event.request_id} as already been processed` }); - await this.save(event, {}, RequestStatus.SUCCESS); + // First check if request is already executed on-chain + if (await this.isPreviouslyExecuted(event)) { + log.debug(`Skipping already executed request: ${event.request_id}`); + continue; } - } catch (error) { - console.error(`Error processing event ${i}:`, error); + + // Then check our database for any previous processing attempts + const existingEvent = await prismaClient.events.findFirst({ + where: { + AND: [ + { chain: this.getChainId() }, + { + OR: [ + { eventHandleId: event.request_id }, + { eventHandleId: (event.fullData as ChainEventData)?.id?.txDigest }, + ], + }, + ], + }, + }); + + if (existingEvent) { + log.debug(`Skipping previously processed event: ${event.request_id}`); + continue; + } + + const data = await this.processRequestAddedEvent(event); + if (data && data.status === 200) { + await this.sendFulfillment(event, data.status, JSON.stringify(data.message)); + await this.save(event, data, RequestStatus.SUCCESS); + } + // Don't save failed events at all + } catch (error: any) { + log.error(`Error processing event ${event.request_id}:`, { + error: error instanceof Error ? error.message : String(error), + }); + // Don't save error events } } - - // await Promise.all( - // newRequestsEvents.map(async (event) => { - // const data = await this.processRequestAddedEvent(event); - // if (data) { - // try { - // await this.sendFulfillment(event, data.status, JSON.stringify(data.message)); - // // TODO: Use the notify parameter to send transaction to the contract and function to marked in the request event - // await this.save(event, data, RequestStatus.SUCCESS); - // } catch (err: any) { - // log.error({ err: err.message }); - // await this.save(event, data, RequestStatus.FAILED); - // } - // } - // }), - // ); } } diff --git a/orchestrator/src/indexer/sui.ts b/orchestrator/src/indexer/sui.ts new file mode 100644 index 0000000..294c786 --- /dev/null +++ b/orchestrator/src/indexer/sui.ts @@ -0,0 +1,243 @@ +import { getNetworkConfig } from "@/config/network"; +import { log } from "@/logger"; +import type { ProcessedRequestAdded, SuiNetwork, SuiRequestEvent } from "@/types"; +import { SuiClient } from "@mysten/sui/client"; +import { decodeSuiPrivateKey } from "@mysten/sui/cryptography"; +import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; +import { Transaction } from "@mysten/sui/transactions"; +import prismaClient from "../../prisma"; +import { Indexer } from "./base"; + +export default class SuiIndexer extends Indexer { + private client: SuiClient; + private keypair: Ed25519Keypair; + + constructor( + protected oracleAddress: string, + private network: SuiNetwork, + privateKey: string, + ) { + if (!privateKey.startsWith("suiprivkey")) { + throw new Error("Invalid private key format. Must be in Sui CLI format (suiprivkey...)"); + } + + // Use the SDK's built-in decoder + const decoded = decodeSuiPrivateKey(privateKey); + + // Access the private key bytes + const keyBytes = decoded.secretKey; + + log.debug("Key details:", { + keyLength: keyBytes.length, + hasKey: !!keyBytes, + }); + + const keypair = Ed25519Keypair.fromSecretKey(keyBytes); + const derivedAddress = keypair.getPublicKey().toSuiAddress(); + + if (derivedAddress.toLowerCase() !== oracleAddress.toLowerCase()) { + throw new Error(`Derived address ${derivedAddress} does not match expected address ${oracleAddress}`); + } + + super(oracleAddress, derivedAddress); + + this.keypair = keypair; + const config = getNetworkConfig(network); + this.client = new SuiClient({ url: config.url }); + + log.info(`Sui Indexer (${network.toUpperCase()})`, { + oracleAddress: this.oracleAddress, + walletAddress: derivedAddress, + match: true, + }); + } + + getChainId(): string { + return `SUI-${this.network.toUpperCase()}`; + } + + async fetchRequestAddedEvents(cursor: null | number | string = null): Promise[]> { + try { + const packageId = "0xc8bb9d3feb5315cf30099a2bfa66f5dbbb771876847434b3792a68be66b4ee4d"; + const eventTypes = [`${packageId}::oracles::RequestAdded`]; + + // Sui-specific cursor handling + const lastProcessedEvent = await prismaClient.events.findFirst({ + where: { + chain: this.getChainId(), + }, + orderBy: { + eventSeq: "desc", + }, + }); + + // Format cursor properly for Sui + const cursorObject = lastProcessedEvent + ? { + txDigest: lastProcessedEvent.eventHandleId, + eventSeq: lastProcessedEvent.eventSeq.toString(), + } + : null; + + log.debug("Querying Sui events", { + eventType: eventTypes[0], + cursor: cursorObject ? `${cursorObject.txDigest}:${cursorObject.eventSeq}` : "none", + }); + + const events = await this.client.queryEvents({ + query: { MoveEventType: eventTypes[0] }, + cursor: cursorObject, + limit: 50, + }); + + log.debug("Found Sui events", { + count: events.data.length, + hasMore: events.hasNextPage, + firstEvent: events.data[0]?.id, + lastEvent: events.data[events.data.length - 1]?.id, + }); + + return events.data.map((event) => { + const parsedJson = event.parsedJson as SuiRequestEvent; + return { + params: parsedJson.params, + fullData: event, + oracle: parsedJson.oracle, + pick: parsedJson.pick, + request_id: parsedJson.request_id, + notify: parsedJson.notify, + }; + }); + } catch (error: any) { + log.error("Sui event fetch failed", { + error: error instanceof Error ? error.message : String(error), + cursor: cursor, + }); + return []; + } + } + + async isPreviouslyExecuted(data: ProcessedRequestAdded): Promise { + try { + const txb = new Transaction(); + txb.moveCall({ + target: `0xc8bb9d3feb5315cf30099a2bfa66f5dbbb771876847434b3792a68be66b4ee4d::oracles::get_response_status`, + arguments: [txb.pure.string(data.request_id)], + }); + + const result = await this.client.devInspectTransactionBlock({ + sender: this.getOrchestratorAddress(), + transactionBlock: txb, + }); + + if (!result.results?.[0]?.returnValues?.[0]) { + return false; + } + + const status = result.results[0].returnValues[0]; + log.debug("Response status check:", { + requestId: data.request_id, + status: status[0][0], + isExecuted: status[0][0] !== 0, + }); + + return status[0][0] !== 0; + } catch (error: any) { + log.error("Error checking execution status:", { + error: error instanceof Error ? error.message : String(error), + requestId: data.request_id, + }); + return false; + } + } + + async sendFulfillment(data: ProcessedRequestAdded, status: number, result: string): Promise { + try { + const address = this.keypair.getPublicKey().toSuiAddress(); + const packageId = "0xc8bb9d3feb5315cf30099a2bfa66f5dbbb771876847434b3792a68be66b4ee4d"; + + const coins = await this.client.getCoins({ + owner: address, + coinType: "0x2::sui::SUI", + }); + + if (!coins.data.length) { + throw new Error(`No SUI coins found for address ${address}`); + } + + const tx = new Transaction(); + const resultStr = typeof result === "string" ? result : JSON.stringify(result); + + tx.moveCall({ + arguments: [tx.pure.string(data.request_id), tx.pure.u64(status), tx.pure.string(resultStr)], + target: `${packageId}::oracles::fulfil_request`, + }); + + // Explicitly set gas coin and budget + tx.setGasPayment([ + { + objectId: coins.data[0].coinObjectId, + version: coins.data[0].version, + digest: coins.data[0].digest, + }, + ]); + tx.setGasBudget(10000000); + + const response = await this.client.signAndExecuteTransaction({ + transaction: tx, + signer: this.keypair, + options: { + showEffects: true, + showEvents: true, + }, + }); + + log.info("Fulfillment transaction details:", { + digest: response.digest, + status: response.effects?.status, + events: response.events, + errors: response.effects?.status?.error, + }); + } catch (error) { + log.error("Error sending fulfillment:", error); + throw error; + } + } + + async save(event: ProcessedRequestAdded, data: any, status: number) { + try { + const dbEventData = { + eventHandleId: event.fullData.id.txDigest, + eventSeq: BigInt(event.fullData.timestampMs), + eventData: JSON.stringify(event.fullData), + eventType: `${this.oracleAddress}::oracles::RequestAdded`, + eventIndex: event.fullData.id.eventSeq, + decoded_event_data: JSON.stringify(event.fullData.parsedJson), + retries: 0, + response: JSON.stringify(data), + chain: this.getChainId(), + status, + }; + + log.debug("Saving event to database:", dbEventData); + + const savedEvent = await prismaClient.events.create({ + data: dbEventData, + }); + + log.info("Successfully saved event:", { + eventId: savedEvent.id, + status: savedEvent.status, + }); + + return savedEvent; + } catch (error) { + log.error("Failed to save event:", { + error: error instanceof Error ? error.message : String(error), + event, + status, + }); + throw error; + } + } +} diff --git a/orchestrator/src/integrations/base.ts b/orchestrator/src/integrations/base.ts index f59d26a..8c22e43 100644 --- a/orchestrator/src/integrations/base.ts +++ b/orchestrator/src/integrations/base.ts @@ -1,8 +1,6 @@ import { log } from "@/logger"; import type { ProcessedRequestAdded } from "@/types"; -import { isValidJson } from "@/util"; -import axios, { type AxiosResponse } from "axios"; -import jsonata from "jsonata"; +import axios from "axios"; export abstract class BasicBearerAPIHandler { protected last_executed = 0; @@ -26,10 +24,10 @@ export abstract class BasicBearerAPIHandler { } isApprovedPath(url: URL): boolean { - return ( - this.hosts.includes(url.hostname.toLowerCase()) && - this.supported_paths.filter((path) => url.pathname.toLowerCase().startsWith(path)).length > 0 - ); + const hostMatch = this.hosts.includes(url.hostname.toLowerCase()); + const pathMatch = this.supported_paths.filter((path) => url.pathname.toLowerCase().startsWith(path)).length > 0; + + return hostMatch && pathMatch; } getAccessToken(): string | null { @@ -50,79 +48,31 @@ export abstract class BasicBearerAPIHandler { this.last_executed = Date.now(); - 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 headers = + typeof data.params.headers === "string" ? JSON.parse(data.params.headers || "{}") : data.params.headers || {}; - const token = this.getAccessToken(); - let request: AxiosResponse; - if (isValidJson(data.params.headers) && isValidJson(data.params.body)) { - // TODO: Replace direct requests via axios with requests via VerityClient TS module - request = await axios({ - method: data.params.method, - data: JSON.parse(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}`, - }, - }); - } + const request = await axios({ + method: data.params.method, + url: data.params.url, + headers: { + Authorization: `Bearer ${this.accessToken}`, + ...headers, + }, + }); - try { - // const result = (await jqRun(data.pick, JSON.stringify(request.data), { input: "string" })) as string; - const expression = jsonata( - data.pick === "." ? "*" : data.pick.startsWith(".") ? data.pick.replace(".", "") : data.pick, - ); - const result = - data.pick === "." ? JSON.stringify(request.data) : JSON.stringify(await expression.evaluate(request.data)); - log.info({ status: request.status, message: result }); - 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 }; + return { + status: request.status, + message: JSON.stringify(request.data), + }; } 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" }; - } - } + log.error("API request failed:", { + error: error.message, + response: error.response?.data, + }); + return { + status: error.response?.status || 500, + message: JSON.stringify(error.response?.data || error.message), + }; } - return { status: 500, message: "Something unexpected Happened" }; } } diff --git a/orchestrator/src/integrations/xtwitter.ts b/orchestrator/src/integrations/xtwitter.ts index 6a7ef12..d83cb64 100644 --- a/orchestrator/src/integrations/xtwitter.ts +++ b/orchestrator/src/integrations/xtwitter.ts @@ -1,15 +1,33 @@ import env from "@/env"; +import { log } from "@/logger"; import { BasicBearerAPIHandler } from "./base"; -export default class TwitterIntegration extends BasicBearerAPIHandler { +export class XTwitterHandler extends BasicBearerAPIHandler { + constructor(accessToken: string) { + super(accessToken, ["api.x.com", "api.twitter.com"], ["/2/tweets", "/2/users/by/username", "/2/users/"], 15000); + } + + async submitRequest(data: any) { + // Add Twitter-specific headers + data.params.headers = { + ...data.params.headers, + "User-Agent": "v2TwitterBot", + Accept: "application/json", + }; + + log.debug("Twitter request params:", { + url: data.params.url, + method: data.params.method, + headers: "Present", // Don't log actual headers + }); + + return super.submitRequest(data); + } + validatePayload(path: string): boolean { + log.debug("Validating Twitter path:", path); return true; } } -export const instance = new TwitterIntegration( - env.integrations.xBearerToken, - ["api.x.com", "api.twitter.com"], - ["/2/tweets", "/2/users/"], - 60 * 1000, -); +export const instance = new XTwitterHandler(env.integrations.xBearerToken); diff --git a/orchestrator/src/types.ts b/orchestrator/src/types.ts index 401250a..73ce7cf 100644 --- a/orchestrator/src/types.ts +++ b/orchestrator/src/types.ts @@ -6,18 +6,23 @@ export type RoochEnv = { }; export const ALLOWED_HOST = ["x.com", "api.x.com", "twitter.com", "api.twitter.com"]; +// Network types export const RoochNetworkList = ["testnet", "devnet", "localnet", "pre-mainnet"] as const; - export const AptosNetworkList = ["testnet", "mainnet"] as const; +export const SuiNetworkList = ["localnet", "devnet", "testnet", "mainnet"] as const; -export const ChainList = ["ROOCH", "APTOS"] as const; +// Chain types +export const ChainList = ["ROOCH", "APTOS", "SUI"] as const; +// Network type definitions export type RoochNetwork = (typeof RoochNetworkList)[number]; - export type AptosNetwork = (typeof AptosNetworkList)[number]; +export type SuiNetwork = (typeof SuiNetworkList)[number]; +// Chain type definition export type SupportedChain = (typeof ChainList)[number]; +// Chain enum (for consistency) export const SupportedChain = ChainList.reduce( (acc, value) => { acc[value] = value; @@ -216,3 +221,11 @@ export interface AptosRequestEvent { pick: string; request_id: string; } + +export interface SuiRequestEvent { + params: any; + oracle: string; + pick: string; + request_id: string; + notify?: string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10d17b4..5d11b32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@aptos-labs/ts-sdk': specifier: ^1.29.1 version: 1.29.1 + '@mysten/sui': + specifier: ^1.21.2 + version: 1.21.2(typescript@5.5.4) '@prisma/client': specifier: 5.19.1 version: 5.19.1(prisma@5.19.1) @@ -102,6 +105,20 @@ importers: packages: + '@0no-co/graphql.web@1.1.1': + resolution: {integrity: sha512-F2i3xdycesw78QCOBHmpTn7eaD2iNXGwB2gkfwxcOfBbeauYpr8RBSyJOkDrFtKtVRMclg8Sg3n1ip0ACyUuag==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + graphql: + optional: true + + '@0no-co/graphqlsp@1.12.16': + resolution: {integrity: sha512-B5pyYVH93Etv7xjT6IfB7QtMBdaaC07yjbhN6v8H7KgFStMkPvi+oWYBTibMFRMY89qwc9H8YixXg8SXDVgYWw==} + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -488,6 +505,26 @@ packages: cpu: [x64] os: [win32] + '@gql.tada/cli-utils@1.6.3': + resolution: {integrity: sha512-jFFSY8OxYeBxdKi58UzeMXG1tdm4FVjXa8WHIi66Gzu9JWtCE6mqom3a8xkmSw+mVaybFW5EN2WXf1WztJVNyQ==} + peerDependencies: + '@0no-co/graphqlsp': ^1.12.13 + '@gql.tada/svelte-support': 1.0.1 + '@gql.tada/vue-support': 1.0.1 + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 + peerDependenciesMeta: + '@gql.tada/svelte-support': + optional: true + '@gql.tada/vue-support': + optional: true + + '@gql.tada/internal@1.0.8': + resolution: {integrity: sha512-XYdxJhtHC5WtZfdDqtKjcQ4d7R1s0d1rnlSs3OcBEUbYiPoJJfZU7tWsVXuv047Z6msvmr4ompJ7eLSK5Km57g==} + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 + '@graphql-typed-document-node/core@3.2.0': resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} peerDependencies: @@ -598,9 +635,19 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@mysten/bcs@0.11.1': + resolution: {integrity: sha512-xP85isNSYUCHd3O/g+TmZYmg4wK6cU8q/n/MebkIGP4CYVJZz2wU/G24xIZ3wI+0iTop4dfgA5kYrg/DQKCUzA==} + '@mysten/bcs@1.0.4': resolution: {integrity: sha512-6JoQi59GN/dVEBCNq8Rj4uOR0niDrJqDx/2gNQWXANwJakHIGH0AMniHrXP41B2dF+mZ3HVmh9Hi3otiEVQTrQ==} + '@mysten/bcs@1.4.0': + resolution: {integrity: sha512-YwDYspceLt8b7v6ohPvy8flQEi+smtfSG5d2A98CbUA48XBmOqTSPNmpw9wsZVVnrH2avr+BS5uVhDZT+EquYA==} + + '@mysten/sui@1.21.2': + resolution: {integrity: sha512-8AesvczokAUv796XiOo8af2+1IYA9bRon11Ra+rwehvqhz+sMRT8A+Cw5sDnlSc9/aQwM51JQKUnvMczNbpfYA==} + engines: {node: '>=18'} + '@noble/curves@1.4.2': resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} @@ -1251,6 +1298,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base-x@4.0.0: + resolution: {integrity: sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==} + base-x@5.0.0: resolution: {integrity: sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==} @@ -1283,6 +1333,9 @@ packages: resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} engines: {node: '>= 6'} + bs58@5.0.0: + resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} + bs58@6.0.0: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} @@ -1747,10 +1800,11 @@ packages: got@11.8.6: resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} engines: {node: '>=10.19.0'} - - got@14.4.6: - resolution: {integrity: sha512-rnhwfM/PhMNJ1i17k3DuDqgj0cKx3IHxBKVv/WX1uDKqrhi2Gv3l7rhPThR/Cc6uU++dD97W9c8Y0qyw9x0jag==} - engines: {node: '>=20'} + gql.tada@1.8.10: + resolution: {integrity: sha512-FrvSxgz838FYVPgZHGOSgbpOjhR+yq44rCzww3oOPJYi0OvBJjAgCiP6LEokZIYND2fUTXzQAyLgcvgw1yNP5A==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2078,6 +2132,9 @@ packages: joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + jose@5.9.6: + resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2746,6 +2803,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + superstruct@1.0.4: + resolution: {integrity: sha512-7JpaAoX2NGyoFlI9NBh66BQXGONc+uE+MRS5i2iOBKuS4e+ccgMDjATgZldkah+33DakBxDHiss9kvUcGAO8UQ==} + engines: {node: '>=14.0.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -2909,6 +2970,9 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + valibot@0.36.0: + resolution: {integrity: sha512-CjF1XN4sUce8sBK9TixrDqFM7RwNkuXdJu174/AwmQUB62QbCQADg5lLe8ldBalFgtj1uKj+pKwDJiNo4Mn+eQ==} + valibot@0.41.0: resolution: {integrity: sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng==} peerDependencies: @@ -3000,6 +3064,16 @@ packages: snapshots: + '@0no-co/graphql.web@1.1.1(graphql@16.9.0)': + optionalDependencies: + graphql: 16.9.0 + + '@0no-co/graphqlsp@1.12.16(graphql@16.9.0)(typescript@5.5.4)': + dependencies: + '@gql.tada/internal': 1.0.8(graphql@16.9.0)(typescript@5.5.4) + graphql: 16.9.0 + typescript: 5.5.4 + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -3342,6 +3416,19 @@ snapshots: '@esbuild/win32-x64@0.23.1': optional: true + '@gql.tada/cli-utils@1.6.3(@0no-co/graphqlsp@1.12.16(graphql@16.9.0)(typescript@5.5.4))(graphql@16.9.0)(typescript@5.5.4)': + dependencies: + '@0no-co/graphqlsp': 1.12.16(graphql@16.9.0)(typescript@5.5.4) + '@gql.tada/internal': 1.0.8(graphql@16.9.0)(typescript@5.5.4) + graphql: 16.9.0 + typescript: 5.5.4 + + '@gql.tada/internal@1.0.8(graphql@16.9.0)(typescript@5.5.4)': + dependencies: + '@0no-co/graphql.web': 1.1.1(graphql@16.9.0) + graphql: 16.9.0 + typescript: 5.5.4 + '@graphql-typed-document-node/core@3.2.0(graphql@16.9.0)': dependencies: graphql: 16.9.0 @@ -3555,10 +3642,38 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@mysten/bcs@0.11.1': + dependencies: + bs58: 5.0.0 + '@mysten/bcs@1.0.4': dependencies: bs58: 6.0.0 + '@mysten/bcs@1.4.0': + dependencies: + bs58: 6.0.0 + + '@mysten/sui@1.21.2(typescript@5.5.4)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) + '@mysten/bcs': 1.4.0 + '@noble/curves': 1.6.0 + '@noble/hashes': 1.5.0 + '@scure/bip32': 1.5.0 + '@scure/bip39': 1.4.0 + '@suchipi/femver': 1.0.0 + bech32: 2.0.0 + gql.tada: 1.8.10(graphql@16.9.0)(typescript@5.5.4) + graphql: 16.9.0 + jose: 5.9.6 + poseidon-lite: 0.2.1 + valibot: 0.36.0 + transitivePeerDependencies: + - '@gql.tada/svelte-support' + - '@gql.tada/vue-support' + - typescript + '@noble/curves@1.4.2': dependencies: '@noble/hashes': 1.4.0 @@ -4314,6 +4429,8 @@ snapshots: balanced-match@1.0.2: {} + base-x@4.0.0: {} + base-x@5.0.0: {} base64-js@1.5.1: {} @@ -4346,6 +4463,10 @@ snapshots: dependencies: fast-json-stable-stringify: 2.1.0 + bs58@5.0.0: + dependencies: + base-x: 4.0.0 + bs58@6.0.0: dependencies: base-x: 5.0.0 @@ -4834,19 +4955,17 @@ snapshots: p-cancelable: 2.1.1 responselike: 2.0.1 - got@14.4.6: + gql.tada@1.8.10(graphql@16.9.0)(typescript@5.5.4): dependencies: - '@sindresorhus/is': 7.0.1 - '@szmarczak/http-timer': 5.0.1 - cacheable-lookup: 7.0.0 - cacheable-request: 12.0.1 - decompress-response: 6.0.0 - form-data-encoder: 4.0.2 - http2-wrapper: 2.2.1 - lowercase-keys: 3.0.0 - p-cancelable: 4.0.1 - responselike: 3.0.0 - type-fest: 4.37.0 + '@0no-co/graphql.web': 1.1.1(graphql@16.9.0) + '@0no-co/graphqlsp': 1.12.16(graphql@16.9.0)(typescript@5.5.4) + '@gql.tada/cli-utils': 1.6.3(@0no-co/graphqlsp@1.12.16(graphql@16.9.0)(typescript@5.5.4))(graphql@16.9.0)(typescript@5.5.4) + '@gql.tada/internal': 1.0.8(graphql@16.9.0)(typescript@5.5.4) + typescript: 5.5.4 + transitivePeerDependencies: + - '@gql.tada/svelte-support' + - '@gql.tada/vue-support' + - graphql graceful-fs@4.2.11: {} @@ -5351,6 +5470,8 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + jose@5.9.6: {} + joycon@3.1.1: {} js-base64@3.7.7: {} @@ -5949,6 +6070,8 @@ snapshots: pirates: 4.0.6 ts-interface-checker: 0.1.13 + superstruct@1.0.4: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -6101,6 +6224,8 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + valibot@0.36.0: {} + valibot@0.41.0(typescript@5.5.4): optionalDependencies: typescript: 5.5.4 diff --git a/sui/Move.lock b/sui/Move.lock new file mode 100644 index 0000000..6a725fc --- /dev/null +++ b/sui/Move.lock @@ -0,0 +1,34 @@ +# @generated by Move, please check-in and do not edit manually. + +[move] +version = 3 +manifest_digest = "73C0E6E7E729CECE203D5AD3F44F838028F655E8B0B52986B86AF60004C00BE2" +deps_digest = "F8BBB0CCB2491CA29A3DF03D6F92277A4F3574266507ACD77214D37ECA3F3082" +dependencies = [ + { id = "Sui", name = "Sui" }, +] + +[[move.package]] +id = "MoveStdlib" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "mainnet", subdir = "crates\\sui-framework\\packages\\move-stdlib" } + +[[move.package]] +id = "Sui" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "mainnet", subdir = "crates/sui-framework/packages/sui-framework" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, +] + +[move.toolchain-version] +compiler-version = "1.40.0" +edition = "2024" +flavor = "sui" + +[env] + +[env.testnet] +chain-id = "4c78adac" +original-published-id = "0xc8bb9d3feb5315cf30099a2bfa66f5dbbb771876847434b3792a68be66b4ee4d" +latest-published-id = "0xc8bb9d3feb5315cf30099a2bfa66f5dbbb771876847434b3792a68be66b4ee4d" +published-version = "1" diff --git a/sui/Move.toml b/sui/Move.toml new file mode 100644 index 0000000..c85fada --- /dev/null +++ b/sui/Move.toml @@ -0,0 +1,10 @@ +[package] +name = "verity" +version = "0.0.1" + +[dependencies] +Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "mainnet" } + +[addresses] +verity = "0x0" +verity_test_foreign_module = "0x0" \ No newline at end of file diff --git a/sui/sources/example_caller.move b/sui/sources/example_caller.move new file mode 100644 index 0000000..1fb15d9 --- /dev/null +++ b/sui/sources/example_caller.move @@ -0,0 +1,122 @@ +// Copyright (c) Usher Labs +// SPDX-License-Identifier: LGPL-2.1 + +module verity_test_foreign_module::example_caller { + use sui::object::{Self, ID, UID}; + use sui::tx_context::TxContext; + use sui::transfer; + use std::vector; + use std::string::{Self, String}; + use std::option; + use verity::oracles::{Self as Oracles}; + + // Store pending requests + struct GlobalStore has key { + id: UID, + pending_requests: vector + } + + // Initialize the module with empty pending requests + fun init(ctx: &mut TxContext) { + let store = GlobalStore { + id: object::new(ctx), + pending_requests: vector::empty() + }; + transfer::share_object(store); + } + + public entry fun request_data( + store: &mut GlobalStore, + url: String, + method: String, + headers: String, + body: String, + pick: String, + oracle: address, + ctx: &mut TxContext + ) { + assert!( + method == string::utf8(b"GET") || + method == string::utf8(b"POST") || + method == string::utf8(b"PUT") || + method == string::utf8(b"DELETE"), + 0 + ); + + Oracles::new_request( + url, + method, + headers, + body, + pick, + oracle, + option::some(b"example_caller::receive_data"), + ctx + ); + } + + public entry fun receive_data(store: &mut GlobalStore) { + let i = 0; + while (i < vector::length(&store.pending_requests)) { + let _request_id = *vector::borrow(&store.pending_requests, i); + vector::remove(&mut store.pending_requests, i); + i = i + 1; + } + } + + public entry fun request_openai_chat( + store: &mut GlobalStore, + prompt: String, + model: String, + pick: String, + oracle: address, + ctx: &mut TxContext + ) { + let request = Oracles::create_openai_request(prompt, model); + + Oracles::new_request( + Oracles::get_request_url(&request), + Oracles::get_request_method(&request), + Oracles::get_request_headers(&request), + Oracles::get_request_body(&request), + pick, + oracle, + option::some(b"example_caller::receive_data"), + ctx + ); + } + + #[test] + fun test_request_data() { + use sui::test_scenario; + + let user = @0xCAFE; + let oracle = @0xDEAD; + + let scenario = test_scenario::begin(user); + { + let ctx = test_scenario::ctx(&mut scenario); + init(ctx); + }; + + test_scenario::next_tx(&mut scenario, user); + { + let store = test_scenario::take_shared(&scenario); + let ctx = test_scenario::ctx(&mut scenario); + + request_data( + &mut store, + string::utf8(b"https://api.test.com"), + string::utf8(b"GET"), + string::utf8(b""), + string::utf8(b""), + string::utf8(b"$.data"), + oracle, + ctx + ); + + test_scenario::return_shared(store); + }; + test_scenario::end(scenario); + } +} \ No newline at end of file diff --git a/sui/sources/oracles.move b/sui/sources/oracles.move new file mode 100644 index 0000000..8dfdf59 --- /dev/null +++ b/sui/sources/oracles.move @@ -0,0 +1,197 @@ +// Copyright (c) Usher Labs +// SPDX-License-Identifier: LGPL-2.1 + +module verity::oracles { + use sui::object::{Self, ID, UID}; + use sui::transfer; + use sui::tx_context::{Self, TxContext}; + use sui::event; + use std::string::{Self, String}; + use std::option::{Self, Option}; + use std::ascii::String as AsciiString; + + const ESignerNotOracle: u64 = 1002; + + struct RequestHeaders has store, copy, drop { + content_type: String, + additional_headers: String + } + + // Struct to represent HTTP request parameters + struct HTTPRequest has store, copy, drop { + url: String, + method: String, + headers: String, + body: String, + } + + struct Request has key { + id: UID, + params: HTTPRequest, + pick: String, + oracle: address, + response_status: u16, + response: Option, + notify: Option> + } + + // Events + struct RequestAdded has copy, drop { + request_id: ID, + creator: address, + params: HTTPRequest, + pick: String, + oracle: address, + notify: Option> + } + + struct Fulfilment has copy, drop { + request_id: ID, + status: u16, + result: String + } + + public fun create_headers( + content_type: String, + additional_headers: String + ): RequestHeaders { + RequestHeaders { + content_type, + additional_headers + } + } + + public fun create_openai_request( + prompt: String, + model: String + ): HTTPRequest { + let body = format_openai_body(prompt, model); + + HTTPRequest { + url: string::utf8(b"https://api.openai.com/v1/chat/completions"), + method: string::utf8(b"POST"), + headers: string::utf8(b"application/json"), + body + } + } + + fun format_openai_body(prompt: String, model: String): String { + // Create properly formatted JSON for OpenAI + let template = b"{\"model\":\"%\",\"messages\":[{\"role\":\"user\",\"content\":\"%\"}]}"; + // Replace % with model and prompt + // Note: In real implementation you'd need proper string manipulation + string::utf8(template) + } + + // Helper function to build request + public fun build_request( + url: String, + method: String, + headers: String, + body: String + ): HTTPRequest { + HTTPRequest { + url, + method, + headers, + body + } + } + + // Create a new request + public entry fun new_request( + url: String, + method: String, + headers: String, + body: String, + pick: String, + oracle: address, + notify: Option>, + ctx: &mut TxContext + ) { + let params = build_request(url, method, headers, body); + let request = Request { + id: object::new(ctx), + params, + pick, + oracle, + response_status: 0, + response: option::none(), + notify + }; + + let request_id = object::uid_to_inner(&request.id); + + event::emit(RequestAdded { + request_id, + creator: tx_context::sender(ctx), + params, + pick, + oracle, + notify + }); + + transfer::transfer(request, oracle); + } + + // Fulfill a request + public entry fun fulfil_request( + request: &mut Request, + status: u16, + result: String, + ctx: &TxContext + ) { + assert!(request.oracle == tx_context::sender(ctx), ESignerNotOracle); + + request.response_status = status; + request.response = option::some(result); + + event::emit(Fulfilment { + request_id: object::uid_to_inner(&request.id), + status, + result + }); + } + + // Add public accessors for HTTPRequest fields + public fun get_request_url(request: &HTTPRequest): String { + request.url + } + + public fun get_request_method(request: &HTTPRequest): String { + request.method + } + + public fun get_request_headers(request: &HTTPRequest): String { + request.headers + } + + public fun get_request_body(request: &HTTPRequest): String { + request.body + } + + #[test] + fun test_create_and_fulfill_request() { + use sui::test_scenario; + + let user = @0xCAFE; + let oracle = @0xDEAD; + + let scenario = test_scenario::begin(user); + { + let ctx = test_scenario::ctx(&mut scenario); + + new_request( + string::utf8(b"https://api.example.com"), + string::utf8(b"GET"), + string::utf8(b""), + string::utf8(b""), + string::utf8(b"$.data.price"), + oracle, + option::none(), + ctx + ); + }; + test_scenario::end(scenario); + } +} \ No newline at end of file