diff --git a/package-lock.json b/package-lock.json index 762c3e22..c0e902c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14785,7 +14785,6 @@ "hasInstallScript": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "node-addon-api": "^2.0.0", "node-gyp-build": "^4.2.0" @@ -15415,8 +15414,7 @@ "version": "2.0.2", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ganache-core/node_modules/node-fetch": { "version": "2.1.2", @@ -15432,7 +15430,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", diff --git a/src/core/modes/intra/simulation.ts b/src/core/modes/intra/simulation.ts index acb3a023..a5b4fa32 100644 --- a/src/core/modes/intra/simulation.ts +++ b/src/core/modes/intra/simulation.ts @@ -125,43 +125,59 @@ export class IntraOrderbookTradeSimulator extends TradeSimulatorBase { )!; // build clear function call data and withdraw tasks - const taskBytecodeResult = await getEnsureBountyTaskBytecode( - { - type: EnsureBountyTaskType.Internal, - botAddress: this.tradeArgs.signer.account.address, - inputToken: this.tradeArgs.orderDetails.buyToken, - outputToken: this.tradeArgs.orderDetails.sellToken, - orgInputBalance: this.tradeArgs.inputBalance, - orgOutputBalance: this.tradeArgs.outputBalance, - inputToEthPrice: parseUnits(this.tradeArgs.inputToEthPrice, 18), - outputToEthPrice: parseUnits(this.tradeArgs.outputToEthPrice, 18), - minimumExpected: params.minimumExpected, - sender: this.tradeArgs.signer.account.address, - }, - this.tradeArgs.solver.state.client, - addresses.dispair, - ); - if (taskBytecodeResult.isErr()) { - const errMsg = await errorSnapshot("", taskBytecodeResult.error); - this.spanAttributes["isNodeError"] = - taskBytecodeResult.error.type === EnsureBountyTaskErrorType.ParseError; - this.spanAttributes["error"] = errMsg; - const result = { - type: TradeType.IntraOrderbook, - spanAttributes: this.spanAttributes, - reason: SimulationHaltReason.FailedToGetTaskBytecode, + // When gasCoveragePercentage is "0", the bounty task isn't + // included in the withdraw call, so skip the expensive on-chain + // compilation that requires dispair addresses. + const noBounty = this.tradeArgs.solver.appOptions.gasCoveragePercentage === "0"; + let task: TaskType; + if (noBounty) { + task = { + evaluable: { + interpreter: "0x0000000000000000000000000000000000000000", + store: "0x0000000000000000000000000000000000000000", + bytecode: "0x", + }, + signedContext: [], + }; + } else { + const taskBytecodeResult = await getEnsureBountyTaskBytecode( + { + type: EnsureBountyTaskType.Internal, + botAddress: this.tradeArgs.signer.account.address, + inputToken: this.tradeArgs.orderDetails.buyToken, + outputToken: this.tradeArgs.orderDetails.sellToken, + orgInputBalance: this.tradeArgs.inputBalance, + orgOutputBalance: this.tradeArgs.outputBalance, + inputToEthPrice: parseUnits(this.tradeArgs.inputToEthPrice, 18), + outputToEthPrice: parseUnits(this.tradeArgs.outputToEthPrice, 18), + minimumExpected: params.minimumExpected, + sender: this.tradeArgs.signer.account.address, + }, + this.tradeArgs.solver.state.client, + addresses.dispair, + ); + if (taskBytecodeResult.isErr()) { + const errMsg = await errorSnapshot("", taskBytecodeResult.error); + this.spanAttributes["isNodeError"] = + taskBytecodeResult.error.type === EnsureBountyTaskErrorType.ParseError; + this.spanAttributes["error"] = errMsg; + const result = { + type: TradeType.IntraOrderbook, + spanAttributes: this.spanAttributes, + reason: SimulationHaltReason.FailedToGetTaskBytecode, + }; + this.spanAttributes["duration"] = performance.now() - this.startTime; + return Result.err(result); + } + task = { + evaluable: { + interpreter: addresses.dispair.interpreter as `0x${string}`, + store: addresses.dispair.store as `0x${string}`, + bytecode: taskBytecodeResult.value, + }, + signedContext: [], }; - this.spanAttributes["duration"] = performance.now() - this.startTime; - return Result.err(result); } - const task = { - evaluable: { - interpreter: addresses.dispair.interpreter as `0x${string}`, - store: addresses.dispair.store as `0x${string}`, - bytecode: taskBytecodeResult.value, - }, - signedContext: [], - }; params.rawtx.data = this.getCalldata(task); return Result.ok(void 0); @@ -253,8 +269,8 @@ export class IntraOrderbookTradeSimulator extends TradeSimulatorBase { aliceBountyVaultId: BigInt(this.inputBountyVaultId), bobBountyVaultId: BigInt(this.outputBountyVaultId), }, - [], - [], + this.tradeArgs.orderDetails.takeOrder.struct.signedContext, + this.tradeArgs.counterpartyOrderDetails.struct.signedContext, ], }); return encodeFunctionData({ @@ -311,8 +327,8 @@ export class IntraOrderbookTradeSimulator extends TradeSimulatorBase { aliceBountyVaultId: this.inputBountyVaultId, bobBountyVaultId: this.outputBountyVaultId, }, - [], - [], + this.tradeArgs.orderDetails.takeOrder.struct.signedContext, + this.tradeArgs.counterpartyOrderDetails.struct.signedContext, ], }); return encodeFunctionData({ diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts new file mode 100644 index 00000000..55fbdddd --- /dev/null +++ b/src/oracle/fetch.ts @@ -0,0 +1,41 @@ +import { Order, Pair } from "../order/types"; +import { SharedState } from "../state"; +import { Result } from "../common"; +import { fetchSignedContext } from "."; + +/** + * If the order has an oracle URL, fetch signed context and inject it + * into the takeOrder struct. Called with SharedState as `this` to access + * the oracle health map. + * + * Returns Result — callers decide how to handle failures. + */ +export async function fetchOracleContext( + this: SharedState, + orderDetails: Pair, +): Promise> { + const oracleUrl = orderDetails.oracleUrl; + if (!oracleUrl) return Result.ok(undefined); + + // Oracle signed context only supported for V4 orders + const order = orderDetails.takeOrder.struct.order; + if (order.type !== Order.Type.V4) return Result.ok(undefined); + + const result = await fetchSignedContext( + oracleUrl, + { + order: order as Order.V4, + inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, + counterparty: "0x0000000000000000000000000000000000000000", + }, + this.oracleHealth, + ); + + if (result.isErr()) { + return Result.err(result.error); + } + + orderDetails.takeOrder.struct.signedContext = [result.value]; + return Result.ok(undefined); +} diff --git a/src/oracle/index.ts b/src/oracle/index.ts new file mode 100644 index 00000000..05467f1b --- /dev/null +++ b/src/oracle/index.ts @@ -0,0 +1,246 @@ +import { encodeAbiParameters, hexToBytes } from "viem"; +import { Result } from "../common"; +import { Order } from "../order/types"; + +export { fetchOracleContext } from "./fetch"; + +/** + * Extract oracle URL from order meta bytes. + * + * Searches for the RaindexSignedContextOracleV1 CBOR item identified by + * magic number 0xff7a1507ba4419ca and extracts the URL payload. + * + * @param metaHex - Hex string of meta bytes (e.g. "0x1234...") + * @returns Oracle URL if found, null otherwise + */ +export function extractOracleUrl(metaHex: string): string | null { + if (!metaHex) return null; + const hex = metaHex.startsWith("0x") ? metaHex.slice(2) : metaHex; + + // RaindexSignedContextOracleV1 magic number + const magicHex = "ff7a1507ba4419ca"; + const magicIdx = hex.indexOf(magicHex); + if (magicIdx === -1) return null; + + // The URL is encoded as a CBOR byte string before the magic in the same + // CBOR map: a2 00 58 01 1b + // Find "https://" or "http://" in hex before the magic + const httpsHex = Buffer.from("https://").toString("hex"); + const httpHex = Buffer.from("http://").toString("hex"); + + const searchRegion = hex.substring(0, magicIdx); + let urlStartIdx = searchRegion.lastIndexOf(httpsHex); + if (urlStartIdx === -1) urlStartIdx = searchRegion.lastIndexOf(httpHex); + if (urlStartIdx === -1) return null; + + // URL ends before the "01 1b" marker (CBOR key 1, uint64 prefix) that precedes the magic + const endMarker = "011b"; + const endIdx = searchRegion.lastIndexOf(endMarker); + if (endIdx === -1 || endIdx < urlStartIdx) return null; + + const urlHex = hex.substring(urlStartIdx, endIdx); + try { + return Buffer.from(urlHex, "hex").toString("utf8"); + } catch { + return null; + } +} + +/** + * Oracle request entry — mirrors the spec's (OrderV4, uint256, uint256, address) tuple. + * Only V4 orders support oracle signed context. + */ +export interface OracleOrderRequest { + order: Order.V4; + inputIOIndex: number; + outputIOIndex: number; + counterparty: `0x${string}`; +} + +// --------------------------------------------------------------------------- +// Oracle health / cooloff +// --------------------------------------------------------------------------- + +/** Per-request timeout */ +export const ORACLE_TIMEOUT_MS = 5_000; +/** How long to skip a failing oracle (ms) */ +export const COOLOFF_DURATION_MS = 5 * 60 * 1_000; +/** Consecutive failures before entering cooloff */ +export const COOLOFF_THRESHOLD = 3; + +export type OracleHealthMap = Map; + +export function isInCooloff(healthMap: OracleHealthMap, url: string): boolean { + const state = healthMap.get(url); + if (!state || state.cooloffUntil === 0) return false; + if (Date.now() >= state.cooloffUntil) { + state.cooloffUntil = 0; + return false; + } + return true; +} + +export function recordOracleSuccess(healthMap: OracleHealthMap, url: string) { + healthMap.set(url, { consecutiveFailures: 0, cooloffUntil: 0 }); +} + +export function recordOracleFailure(healthMap: OracleHealthMap, url: string) { + const state = healthMap.get(url) ?? { consecutiveFailures: 0, cooloffUntil: 0 }; + state.consecutiveFailures++; + if (state.consecutiveFailures >= COOLOFF_THRESHOLD) { + state.cooloffUntil = Date.now() + COOLOFF_DURATION_MS; + console.warn( + `Oracle ${url} entered cooloff for ${COOLOFF_DURATION_MS / 1000}s ` + + `after ${state.consecutiveFailures} consecutive failures`, + ); + } + healthMap.set(url, state); +} + +// --------------------------------------------------------------------------- +// ABI encoding +// --------------------------------------------------------------------------- + +/** + * ABI parameter definition for a single oracle request body. + * + * The oracle server (rain.orderbook and st0x-oracle-server) decodes + * with alloy's `abi_decode()` which expects the body to be + * `abi.encode((OrderV4, uint256, uint256, address))` — a SINGLE + * wrapping tuple. viem's `encodeAbiParameters` with 4 top-level + * params produces `abi.encode(OrderV4, uint256, uint256, address)` + * (separate params, different byte layout). We fix the mismatch by + * wrapping all fields in a single tuple parameter. + */ +const oracleSingleAbiParams = [ + { + type: "tuple" as const, + components: [ + { + name: "order", + type: "tuple" as const, + components: [ + { name: "owner", type: "address" as const }, + { + name: "evaluable", + type: "tuple" as const, + components: [ + { name: "interpreter", type: "address" as const }, + { name: "store", type: "address" as const }, + { name: "bytecode", type: "bytes" as const }, + ], + }, + { + name: "validInputs", + type: "tuple[]" as const, + components: [ + { name: "token", type: "address" as const }, + { name: "vaultId", type: "bytes32" as const }, + ], + }, + { + name: "validOutputs", + type: "tuple[]" as const, + components: [ + { name: "token", type: "address" as const }, + { name: "vaultId", type: "bytes32" as const }, + ], + }, + { name: "nonce", type: "bytes32" as const }, + ], + }, + { name: "inputIOIndex", type: "uint256" as const }, + { name: "outputIOIndex", type: "uint256" as const }, + { name: "counterparty", type: "address" as const }, + ], + }, +] as const; + +// --------------------------------------------------------------------------- +// Fetch +// --------------------------------------------------------------------------- + +/** + * Fetch signed context from an oracle endpoint (single request format). + * + * POSTs abi.encode(OrderV4, uint256, uint256, address) and expects + * a JSON SignedContextV1 object back. + * + * Single attempt with a hard timeout — no retries, no in-loop delays. + * Uses the provided health map for cooloff tracking. + */ +export async function fetchSignedContext( + url: string, + request: OracleOrderRequest, + healthMap: OracleHealthMap, +): Promise> { + if (isInCooloff(healthMap, url)) { + return Result.err(`Oracle ${url} is in cooloff, skipping`); + } + + // Strip the internal `type` discriminant before ABI encoding + const { type: _type, ...orderStruct } = request.order; + const encoded = encodeAbiParameters(oracleSingleAbiParams, [ + { + order: orderStruct, + inputIOIndex: BigInt(request.inputIOIndex), + outputIOIndex: BigInt(request.outputIOIndex), + counterparty: request.counterparty, + }, + ]); + const body = hexToBytes(encoded); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ORACLE_TIMEOUT_MS); + + let json: unknown; + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body, + signal: controller.signal, + }); + + if (!response.ok) { + recordOracleFailure(healthMap, url); + return Result.err(`Oracle request failed: ${response.status} ${response.statusText}`); + } + + json = await response.json(); + } catch (err) { + recordOracleFailure(healthMap, url); + return Result.err( + `Oracle fetch error: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + clearTimeout(timeout); + } + + // The oracle server returns a JSON **array** of SignedContextV1 objects + // whose length matches the number of requests (we always send one). + // Extract the first element so callers get a single SignedContextV1. + let item: unknown = json; + if (Array.isArray(json)) { + if (json.length === 0) { + recordOracleFailure(healthMap, url); + return Result.err("Oracle returned empty array"); + } + item = json[0]; + } + + // Validate shape of single SignedContextV1 + if ( + typeof item !== "object" || + item === null || + typeof (item as any).signer !== "string" || + !Array.isArray((item as any).context) || + typeof (item as any).signature !== "string" + ) { + recordOracleFailure(healthMap, url); + return Result.err("Oracle response is not a valid SignedContextV1"); + } + + recordOracleSuccess(healthMap, url); + return Result.ok(item); +} diff --git a/src/order/index.ts b/src/order/index.ts index ac765c0e..0e961a3a 100644 --- a/src/order/index.ts +++ b/src/order/index.ts @@ -458,7 +458,13 @@ export class OrderManager { * @param blockNumber - Optional block number for the quote */ async quoteOrder(orderDetails: Pair, blockNumber?: bigint) { - return await quoteSingleOrder(orderDetails, this.state.client, blockNumber, this.quoteGas); + return await quoteSingleOrder( + orderDetails, + this.state.client, + this.state, + blockNumber, + this.quoteGas, + ); } /** diff --git a/src/order/quote.ts b/src/order/quote.ts index 0504f33f..c803edea 100644 --- a/src/order/quote.ts +++ b/src/order/quote.ts @@ -4,40 +4,47 @@ import { AppOptions } from "../config"; import { ABI, normalizeFloat } from "../common"; import { BundledOrders, Pair, TakeOrder } from "./types"; import { decodeFunctionResult, encodeFunctionData, PublicClient } from "viem"; +import { fetchOracleContext } from "../oracle"; /** * Quotes a single order * @param orderDetails - Order details to quote * @param viemClient - Viem client + * @param state - SharedState for oracle health tracking * @param blockNumber - Optional block number * @param gas - Optional read gas */ export async function quoteSingleOrder( orderDetails: Pair, viemClient: PublicClient, + state?: SharedState, blockNumber?: bigint, gas?: bigint, ) { if (Pair.isV3(orderDetails)) { - return quoteSingleOrderV3(orderDetails, viemClient, blockNumber, gas); + return quoteSingleOrderV3(orderDetails, viemClient, state, blockNumber, gas); } else { - return quoteSingleOrderV4(orderDetails, viemClient, blockNumber, gas); + return quoteSingleOrderV4(orderDetails, viemClient, state, blockNumber, gas); } } /** * Quotes a single order v3 - * @param orderDetails - Order details to quote - * @param viemClient - Viem client - * @param blockNumber - Optional block number - * @param gas - Optional read gas */ export async function quoteSingleOrderV3( orderDetails: Pair, viemClient: PublicClient, + state?: SharedState, blockNumber?: bigint, gas?: bigint, ) { + if (state) { + const oracleResult = await fetchOracleContext.call(state, orderDetails); + if (oracleResult.isErr()) { + console.warn("Failed to fetch oracle context:", oracleResult.error); + } + } + const { data } = await viemClient .call({ to: orderDetails.orderbook as `0x${string}`, @@ -71,17 +78,21 @@ export async function quoteSingleOrderV3( /** * Quotes a single order v4 - * @param orderDetails - Order details to quote - * @param viemClient - Viem client - * @param blockNumber - Optional block number - * @param gas - Optional read gas */ export async function quoteSingleOrderV4( orderDetails: Pair, viemClient: PublicClient, + state?: SharedState, blockNumber?: bigint, gas?: bigint, ) { + if (state) { + const oracleResult = await fetchOracleContext.call(state, orderDetails); + if (oracleResult.isErr()) { + console.warn("Failed to fetch oracle context:", oracleResult.error); + } + } + const { data } = await viemClient .call({ to: orderDetails.orderbook as `0x${string}`, diff --git a/src/order/types/index.ts b/src/order/types/index.ts index 6fad1e39..445ec743 100644 --- a/src/order/types/index.ts +++ b/src/order/types/index.ts @@ -144,6 +144,8 @@ export type PairBase = { sellTokenDecimals: number; sellTokenSymbol: string; sellTokenVaultBalance: bigint; + /** Oracle URL extracted from order meta, if present */ + oracleUrl?: string | null; }; export type Pair = PairV3 | PairV4; export namespace Pair { diff --git a/src/order/types/v3.ts b/src/order/types/v3.ts index bd310ddd..c9eccc5d 100644 --- a/src/order/types/v3.ts +++ b/src/order/types/v3.ts @@ -3,6 +3,7 @@ import { SgOrder } from "../../subgraph"; import { ABI, Result } from "../../common"; import { Order, PairBase, TakeOrderDetailsBase } from "."; import { decodeAbiParameters, DecodeAbiParametersErrorType } from "viem"; +import { extractOracleUrl } from "../../oracle"; // these types are used in orderbook v4 @@ -121,6 +122,7 @@ export namespace PairV3 { sellTokenSymbol: outputSymbol, sellTokenDecimals: outputDecimals, sellTokenVaultBalance: BigInt(outputBalance), + oracleUrl: orderDetails.meta ? extractOracleUrl(orderDetails.meta) : null, takeOrder: { id: orderHash, struct: { diff --git a/src/order/types/v4.ts b/src/order/types/v4.ts index d56f16bd..de96880c 100644 --- a/src/order/types/v4.ts +++ b/src/order/types/v4.ts @@ -3,6 +3,7 @@ import { SgOrder, SubgraphVersions } from "../../subgraph"; import { WasmEncodedError } from "@rainlanguage/float"; import { Order, PairBase, TakeOrderDetailsBase } from "."; import { ABI, normalizeFloat, Result } from "../../common"; +import { extractOracleUrl } from "../../oracle"; import { decodeAbiParameters, DecodeAbiParametersErrorType } from "viem"; // these types are used in orderbook v5 @@ -140,6 +141,7 @@ export namespace PairV4 { sellTokenSymbol: outputSymbol, sellTokenDecimals: outputDecimals, sellTokenVaultBalance: outputBalanceRes.value, + oracleUrl: orderDetails.meta ? extractOracleUrl(orderDetails.meta) : null, takeOrder: { id: orderHash, struct: { diff --git a/src/state/contracts.ts b/src/state/contracts.ts index beedbbaa..0b33fff8 100644 --- a/src/state/contracts.ts +++ b/src/state/contracts.ts @@ -78,26 +78,37 @@ export async function resolveVersionContracts( return undefined; } - const interpreter = await client + let interpreter = await client .readContract({ address: addresses.dispair, functionName: version === "v6" ? "I_INTERPRETER" : "iInterpreter", abi: version === "v6" ? ABI.Deployer.Primary.DeployerV6 : ABI.Deployer.Primary.Deployer, }) .catch(() => undefined); - if (!interpreter) { - return undefined; - } - const store = await client + let store = await client .readContract({ address: addresses.dispair, functionName: version === "v6" ? "I_STORE" : "iStore", abi: version === "v6" ? ABI.Deployer.Primary.DeployerV6 : ABI.Deployer.Primary.Deployer, }) .catch(() => undefined); - if (!store) { - return undefined; + + // In Rain V6, the "deployer" address from the rainlang registry may + // actually be the parser, which doesn't expose I_INTERPRETER/I_STORE. + // Fall back to using the dispair address itself for all three fields — + // the actual interpreter/store will be taken from the order's evaluable + // struct at execution time. This allows intra-orderbook clearing + // (which doesn't need the task deployer) to work without a "real" + // deployer address. + if (!interpreter || !store) { + console.warn( + `Could not read interpreter/store from dispair ${addresses.dispair} — ` + + `using fallback. Task bytecode generation will fail; set gasCoveragePercentage="0" ` + + `to skip bounty tasks.`, + ); + interpreter = addresses.dispair; + store = addresses.dispair; } const result: any = { diff --git a/src/state/index.ts b/src/state/index.ts index 3b792553..087336ae 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -224,6 +224,8 @@ export class SharedState { writeRpc?: RpcState; /** List of latest successful transactions gas costs */ gasCosts: bigint[] = []; + /** Oracle endpoint health tracking for cooloff */ + oracleHealth: Map = new Map(); constructor(config: SharedStateConfig) { this.appOptions = config.appOptions; diff --git a/src/subgraph/query.ts b/src/subgraph/query.ts index 8a4acb09..bf648c88 100644 --- a/src/subgraph/query.ts +++ b/src/subgraph/query.ts @@ -39,6 +39,7 @@ export function getQueryPaginated(skip: number, filters?: SgFilter): string { owner orderHash orderBytes + meta active nonce orderbook { @@ -108,6 +109,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?: owner orderHash orderBytes + meta active nonce orderbook { @@ -142,6 +144,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?: owner orderHash orderBytes + meta active nonce orderbook { diff --git a/src/subgraph/types.ts b/src/subgraph/types.ts index 481a8875..d882404e 100644 --- a/src/subgraph/types.ts +++ b/src/subgraph/types.ts @@ -5,6 +5,7 @@ export type SgOrder = { owner: string; orderHash: string; orderBytes: string; + meta?: string; active: boolean; nonce: string; orderbook: {