From eeca71423aa8f04b6e373d6b20cc592a8df5b87d Mon Sep 17 00:00:00 2001 From: ShadowOfTime1 <129950615+ShadowOfTime1@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:28:04 +0000 Subject: [PATCH] fix(kuru): read MarketStateError as a market that is not trading The native-swap live test has failed since 2026-09-08 because one of the four verified MON/USDC markets (0x764b...29D5) reverts every quote probe with MarketStateError() while the other three, on the same template, fill. The adapter kept that revert as an unmeasured route, so swap refused the pair as an incomplete comparison. The market names itself as the cause and refuses at every size, which is an answer rather than a gap. Decode it against the vendored OrderBook ABI and price that leg at zero, so the route is measured and the comparison stays exhaustive. Any other revert, with or without data, is unchanged and still reported as unavailable. A market that answered this way is remembered for the rest of the request, so the reverse search does not pay to hear it again. The live native-swap test keeps its exhaustive default; two offline cases cover the decoded refusal (routed around even when it is the better-priced market) and the single call it costs the reverse search. --- .changeset/kuru-market-state-error.md | 11 +++ packages/protocols/kuru/src/kuru.ts | 81 ++++++++++++++++++++--- packages/protocols/kuru/test/kuru.test.ts | 75 +++++++++++++++++++++ 3 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 .changeset/kuru-market-state-error.md 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