Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/kuru-market-state-error.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 71 additions & 10 deletions packages/protocols/kuru/src/kuru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
})
Expand All @@ -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.
Expand Down Expand Up @@ -1347,6 +1367,8 @@ type CallBudget = { left: number };
*/
type QuoteRequest = CallBudget & {
memo: Map<string, Promise<bigint>>;
/** Markets that answered MarketStateError during this request, by lowercased address. */
notTrading: Set<string>;
/**
* 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
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -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.
*
Expand All @@ -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;
Expand All @@ -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) &&
Expand Down
75 changes: 75 additions & 0 deletions packages/protocols/kuru/test/kuru.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { AUSD_ADDRESS, USDC_ADDRESS } from "@themoss/system";
import {
decodeFunctionData,
encodeAbiParameters,
encodeErrorResult,
encodeEventTopics,
encodeFunctionResult,
formatUnits,
Expand All @@ -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}`;
Expand Down Expand Up @@ -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
Expand Down
Loading