diff --git a/.changeset/kuru-market-state-error.md b/.changeset/kuru-market-state-error.md new file mode 100644 index 00000000..07dd8d15 --- /dev/null +++ b/.changeset/kuru-market-state-error.md @@ -0,0 +1,11 @@ +--- +"@themoss/protocol-kuru": patch +--- + +Read a Kuru market that reverts with `MarketStateError()` as not trading rather than as an +unmeasured route. The market names itself as the cause and refuses at every size, so its leg now +prices zero: the route is measured, the comparison stays exhaustive, and `swap` no longer refuses +a pair because one of its verified markets has stopped accepting orders. The error is decoded +against the vendored OrderBook ABI; any other revert, with or without data, is still reported as +unavailable. A market that has answered this way is not asked again within the same request, so +the reverse search does not spend its allowance on the same answer. diff --git a/packages/protocols/kuru/src/kuru.ts b/packages/protocols/kuru/src/kuru.ts index bb3d99eb..cc6e6a73 100644 --- a/packages/protocols/kuru/src/kuru.ts +++ b/packages/protocols/kuru/src/kuru.ts @@ -24,7 +24,15 @@ import { TokenReference, } from "@themoss/core"; import { ERC20 } from "@themoss/erc"; -import { decodeEventLog, formatUnits, getAddress, isAddress, parseUnits } from "viem"; +import { + decodeErrorResult, + decodeEventLog, + formatUnits, + getAddress, + isAddress, + isHex, + parseUnits, +} from "viem"; import { KuruOrderbookAbi, KuruRouterAbi } from "./abis/kuru.js"; import type { KuruQuote, @@ -959,6 +967,10 @@ export class Kuru { 10n ** BigInt(leg.market.params.baseDecimals); // Nothing is asked of the chain, so nothing is charged. if (size <= 0n) return 0n; + // A market that already answered MarketStateError in this request prices nothing at any size, + // so the reverse search, which walks many sizes, does not pay to hear it again. + const market = leg.market.address.toLowerCase(); + if (request.notTrading.has(market)) return 0n; // The market, the side and the size are the whole question. Two routes asking it are asking // for the same answer, and the second one should not pay for it again — the budget counts @@ -970,7 +982,7 @@ export class Kuru { // Charged before the call, not after: a leg that priced and a later one that refused to // encode have both been paid for by then. spendCall(request, route); - const pending = leg.isBuy + const call = leg.isBuy ? leg.market.handle.call.placeAndExecuteMarketBuy([size, 0n, false, false], { from: KURU_NATIVE, }) @@ -980,6 +992,14 @@ export class Kuru { ? { value: amountIn, balance: amountIn } : { from: KURU_NATIVE }, ); + // The market saying it is not trading is an answer, not a gap: it fills nothing, so the leg + // prices zero and the route is measured. Every other failure keeps rejecting and stays an + // unmeasured route. + const pending = call.catch((error: unknown) => { + if (!isMarketNotTrading(error)) throw error; + request.notTrading.add(market); + return 0n; + }); // Remembered as the promise, so concurrent routes asking at the same moment share one call // rather than racing to make two. A rejection is remembered too: the same question put to the // same market in the same request has the same answer, and re-asking it would only re-spend. @@ -1347,6 +1367,8 @@ type CallBudget = { left: number }; */ type QuoteRequest = CallBudget & { memo: Map>; + /** Markets that answered MarketStateError during this request, by lowercased address. */ + notTrading: Set; /** * The side the caller actually asked for. A budget refusal is raised deep inside route * evaluation, where only the route is in scope, yet it answers this request — so the side has to @@ -1356,7 +1378,7 @@ type QuoteRequest = CallBudget & { }; function requestBudget(side: KuruQuote["amountSide"]): QuoteRequest { - return { left: MAX_CALLS_PER_REQUEST, memo: new Map(), side }; + return { left: MAX_CALLS_PER_REQUEST, memo: new Map(), notTrading: new Set(), side }; } /** @@ -1533,8 +1555,9 @@ function isUnsatisfiableTarget(error: Error): boolean { * would prove nothing. * * An on-chain revert is deliberately NOT accepted here. It looks similar and is not: `eth_call` - * reverts for a paused market, a failed require, or the provider's own gas cap, none of which - * say anything about the priceable range. Calling those "the target cannot be reached" would + * reverts for a failed require or the provider's own gas cap, neither of which says anything + * about the priceable range. (A market that names itself not trading with `MarketStateError` is + * answered earlier, in `#quoteFill`, as a zero fill.) Calling those "the target cannot be reached" would * state a definitive no from evidence that establishes nothing — the very failure this change * exists to prevent. They stay unavailable, which is the honest reading: we could not find out. * @@ -1549,6 +1572,48 @@ function isProbeBeyondEncodableSize(error: unknown): boolean { return false; } +/** + * The revert data viem attached to one level of an error chain. + * + * viem hands it back either as the hex itself or wrapped a level down, and its own + * `getRevertErrorData` unwraps exactly this shape. Reading only the string form would miss a real + * revert on whichever providers use the object one, and miss it silently. + */ +function revertData(error: Error): unknown { + const raw = (error as { data?: unknown }).data; + return typeof raw === "object" && raw !== null ? (raw as { data?: unknown }).data : raw; +} + +/** + * True when the market itself refused the probe with `MarketStateError()`. + * + * A bare revert attributes nothing to the market; this one does. The OrderBook reports that it is + * not in a state to accept orders, and it says so whatever the size: mainnet MON/USDC market + * 0x764b4c2AF968c97b4ae95490d264c14d955129D5 has answered every probe with it since 2026-09-08 + * while three markets on the same template kept filling (#194). Decoded against the vendored + * OrderBook ABI rather than matched on a hand-typed selector. Never throws: this runs on errors the + * adapter did not build, and an unreadable one is simply not this case. + */ +function isMarketNotTrading(error: unknown): boolean { + try { + for (let current = error, depth = 0; current instanceof Error && depth < 16; depth += 1) { + const data = revertData(current); + if (isHex(data) && data.length === 10) { + try { + const decoded = decodeErrorResult({ abi: KuruOrderbookAbi, data }); + if (decoded.errorName === "MarketStateError") return true; + } catch { + // Some other four-byte error: not this case. + } + } + current = current.cause; + } + } catch { + return false; + } + return false; +} + /** Solidity `Panic(uint256)` selector, followed by the code as a uint256. */ const PANIC_SELECTOR = "0x4e487b71"; const PANIC_ARITHMETIC_OVERFLOW = 0x11n; @@ -1566,11 +1631,7 @@ const PANIC_ARITHMETIC_OVERFLOW = 0x11n; */ function isMarketArithmeticOverflow(error: unknown): boolean { for (let current = error, depth = 0; current instanceof Error && depth < 16; depth += 1) { - // viem hands revert data back either as the hex itself or wrapped a level down, and its own - // `getRevertErrorData` unwraps exactly this shape. Reading only the string form would miss a - // real Panic on whichever providers use the object one, and miss it silently. - const raw = (current as { data?: unknown }).data; - const data = typeof raw === "object" && raw !== null ? (raw as { data?: unknown }).data : raw; + const data = revertData(current); if ( typeof data === "string" && data.startsWith(PANIC_SELECTOR) && diff --git a/packages/protocols/kuru/test/kuru.test.ts b/packages/protocols/kuru/test/kuru.test.ts index 64a103b2..193b8966 100644 --- a/packages/protocols/kuru/test/kuru.test.ts +++ b/packages/protocols/kuru/test/kuru.test.ts @@ -14,6 +14,7 @@ import { AUSD_ADDRESS, USDC_ADDRESS } from "@themoss/system"; import { decodeFunctionData, encodeAbiParameters, + encodeErrorResult, encodeEventTopics, encodeFunctionResult, formatUnits, @@ -31,6 +32,11 @@ const MON_USDC_WORSE = getAddress("0x2222222222222222222222222222222222222222"); const MON_AUSD = getAddress("0x3333333333333333333333333333333333333333"); const DIRECT_USDC_AUSD = getAddress("0x4444444444444444444444444444444444444444"); const DIRECT_USDC_AUSD_BETTER = getAddress("0x5555555555555555555555555555555555555555"); +/** What a Kuru market reverts with when it is not in a state to accept orders. */ +const MARKET_STATE_ERROR = encodeErrorResult({ + abi: KuruOrderbookAbi, + errorName: "MarketStateError", +}); type MockMarket = { address: `0x${string}`; @@ -1757,6 +1763,75 @@ describe("Kuru", () => { expect(built.kind).toBe("capability"); }); + it("reads a market that answers MarketStateError as not trading, not as a gap", async () => { + // Mainnet MON/USDC: of four verified markets, 0x764b…29D5 has answered every probe with + // MarketStateError since 2026-09-08 while the other three fill (#194). The market names itself + // as the cause, so its route is measured at zero and the swap stays exhaustive. A bare revert + // still refuses (the test above). The dead market is the better-priced one here, so the swap + // can only route around it because it is not trading, not because it lost the comparison. + const { registry } = offlineRegistry([ + market(MON_USDC, ZERO, USDC_ADDRESS, 18, 6, 1n, 1n), + { + ...market(MON_USDC_WORSE, ZERO, USDC_ADDRESS, 18, 6, 11n, 10n), + quoteFails: true, + quoteFailName: "CallExecutionError", + quoteFailData: MARKET_STATE_ERROR, + quoteFailDataNested: true, + quoteFailDepth: 2, + }, + ]); + const quote = await registry.action("kuru", "quote", ACCOUNT, { + tokenIn: NATIVE, + tokenOut: USDC_ADDRESS, + amountIn: "1", + }); + if (quote.kind !== "query") throw new Error("expected query"); + expect((quote.data as KuruQuote).unavailable).toEqual([]); + + const capability = await registry.action("kuru", "swap", ACCOUNT, { + tokenIn: NATIVE, + tokenOut: USDC_ADDRESS, + amountIn: "1", + }); + if (capability.kind !== "capability") throw new Error("expected capability"); + const swap = flattenCapabilityTree(capability).at(-1); + if (!swap) throw new Error("missing Kuru transaction"); + const decoded = decodeFunctionData({ abi: KuruRouterAbi, data: swap.transaction.data }); + expect(decoded.args[0]).toEqual([MON_USDC]); + }); + + it("does not pay twice to hear that a market is not trading", async () => { + // The reverse search walks many sizes. Once a market has said it is not trading, asking again at + // another size would only spend the request's allowance on the same answer. + const dead: MockMarket = { + ...market(MON_USDC, ZERO, USDC_ADDRESS, 18, 6, 1n, 1n), + quoteFails: true, + quoteFailName: "CallExecutionError", + quoteFailData: MARKET_STATE_ERROR, + }; + let calls = 0; + const { registry } = offlineRegistry([dead], undefined, () => { + calls += 1; + }); + const target = await quoteError(registry, { + tokenIn: NATIVE, + tokenOut: USDC_ADDRESS, + amountOut: "1", + }); + // Measured and out of reach, not unmeasured. + expect(target.code).toBe("TARGET_OUTPUT_UNSATISFIABLE"); + expect(target.unavailable).toEqual([]); + expect(calls).toBe(1); + + const input = await quoteError(registry, { + tokenIn: NATIVE, + tokenOut: USDC_ADDRESS, + amountIn: "1", + }); + expect(input.code).toBe("NO_POSITIVE_QUOTE"); + expect(input.unavailable).toEqual([]); + }); + it("reads a Panic that arrives wrapped, and nested under viem's outer errors", async () => { // viem hands revert data back either as the hex or a level down, and the transport error is // itself nested under a ContractFunctionExecutionError. The search authenticates the Panic to