From 4a451b054aa6f6968cb341c8b39a5212f9d67ff6 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 17 Feb 2026 15:59:27 +0400 Subject: [PATCH] Show explicit validation in chips --- src/lib/components/IntentListDetailRow.svelte | 10 + src/lib/libraries/intentList.ts | 9 +- src/lib/libraries/orderServer.ts | 77 ++++--- src/lib/utils/orderLib.ts | 206 ++++++++++++++---- tests/unit/intentList.test.ts | 4 +- tests/unit/orderLib.test.ts | 119 ++++++++-- 6 files changed, 329 insertions(+), 96 deletions(-) diff --git a/src/lib/components/IntentListDetailRow.svelte b/src/lib/components/IntentListDetailRow.svelte index 5066677..8763ada 100644 --- a/src/lib/components/IntentListDetailRow.svelte +++ b/src/lib/components/IntentListDetailRow.svelte @@ -61,4 +61,14 @@ {row.inputCount} inputs • {row.outputCount} outputs + + {row.validationPassed ? "Validation Pass" : `Invalid: ${row.validationReason}`} + diff --git a/src/lib/libraries/intentList.ts b/src/lib/libraries/intentList.ts index 415b7ae..6ff1267 100644 --- a/src/lib/libraries/intentList.ts +++ b/src/lib/libraries/intentList.ts @@ -11,6 +11,7 @@ import { import { orderToIntent } from "./intent"; import { bytes32ToAddress, idToToken } from "../utils/convert"; import type { OrderContainer, StandardOrder, MultichainOrder } from "../../types"; +import { validateOrderContainerWithReason } from "$lib/utils/orderLib"; export type Chip = { key: string; @@ -38,6 +39,8 @@ export type BaseIntentRow = { inputOverflow: number; outputChips: Chip[]; outputOverflow: number; + validationPassed: boolean; + validationReason: string; }; export type TimedIntentRow = BaseIntentRow & { @@ -200,6 +203,8 @@ export function buildBaseIntentRow(orderContainer: OrderContainer): BaseIntentRo const chainScope = getChainScope(order); const contextDetails = getContextDetails(orderContainer); + const validation = validateOrderContainerWithReason(orderContainer); + return { orderContainer, orderId, @@ -217,7 +222,9 @@ export function buildBaseIntentRow(orderContainer: OrderContainer): BaseIntentRo inputChips: inputChipsRaw.slice(0, MAX_CHIPS_PER_SIDE), inputOverflow: Math.max(0, inputChipsRaw.length - MAX_CHIPS_PER_SIDE), outputChips: outputChipsRaw.slice(0, MAX_CHIPS_PER_SIDE), - outputOverflow: Math.max(0, outputChipsRaw.length - MAX_CHIPS_PER_SIDE) + outputOverflow: Math.max(0, outputChipsRaw.length - MAX_CHIPS_PER_SIDE), + validationPassed: validation.passed, + validationReason: validation.reason }; } diff --git a/src/lib/libraries/orderServer.ts b/src/lib/libraries/orderServer.ts index 74b4972..25e4aa2 100644 --- a/src/lib/libraries/orderServer.ts +++ b/src/lib/libraries/orderServer.ts @@ -9,7 +9,6 @@ import type { } from "../../types"; import { type chain, chainMap } from "$lib/config"; import { getInteropableAddress } from "../utils/interopableAddresses"; -import { validateOrder } from "$lib/utils/orderLib"; type OrderStatus = "Signed" | "Delivered" | "Settled"; @@ -522,45 +521,43 @@ export class OrderServer { if (parsedOrders) { if (Array.isArray(parsedOrders)) { // For each order, if a field is string ending in n, convert it to bigint. - return parsedOrders - .filter((instance) => validateOrder(instance.order)) - .map((instance) => { - instance.order.nonce = BigInt(instance.order.nonce); - instance.order.originChainId = BigInt(instance.order.originChainId); - if (instance.order.inputs) { - instance.order.inputs = instance.order.inputs.map((input) => { - return [BigInt(input[0]), BigInt(input[1])]; - }); - } - if (instance.order.outputs) { - instance.order.outputs = instance.order.outputs.map((output) => { - return { - ...output, - chainId: BigInt(output.chainId), - amount: BigInt(output.amount) - }; - }); - } - const allocatorSignature = instance.allocatorSignature - ? ({ - type: "ECDSA", - payload: instance.allocatorSignature - } as Signature) - : ({ - type: "None", - payload: "0x" - } as NoSignature); - const sponsorSignature = instance.sponsorSignature - ? ({ - type: "ECDSA", - payload: instance.sponsorSignature - } as Signature) - : ({ - type: "None", - payload: "0x" - } as NoSignature); - return { ...instance, allocatorSignature, sponsorSignature }; - }); + return parsedOrders.map((instance) => { + instance.order.nonce = BigInt(instance.order.nonce); + instance.order.originChainId = BigInt(instance.order.originChainId); + if (instance.order.inputs) { + instance.order.inputs = instance.order.inputs.map((input) => { + return [BigInt(input[0]), BigInt(input[1])]; + }); + } + if (instance.order.outputs) { + instance.order.outputs = instance.order.outputs.map((output) => { + return { + ...output, + chainId: BigInt(output.chainId), + amount: BigInt(output.amount) + }; + }); + } + const allocatorSignature = instance.allocatorSignature + ? ({ + type: "ECDSA", + payload: instance.allocatorSignature + } as Signature) + : ({ + type: "None", + payload: "0x" + } as NoSignature); + const sponsorSignature = instance.sponsorSignature + ? ({ + type: "ECDSA", + payload: instance.sponsorSignature + } as Signature) + : ({ + type: "None", + payload: "0x" + } as NoSignature); + return { ...instance, allocatorSignature, sponsorSignature }; + }); } } } diff --git a/src/lib/utils/orderLib.ts b/src/lib/utils/orderLib.ts index 8fe8143..21b387d 100644 --- a/src/lib/utils/orderLib.ts +++ b/src/lib/utils/orderLib.ts @@ -1,6 +1,41 @@ import { encodeAbiParameters, encodePacked, keccak256, parseAbiParameters } from "viem"; -import type { MandateOutput, MultichainOrder, StandardOrder } from "../../types"; -import { type chain, chainMap, POLYMER_ORACLE, WORMHOLE_ORACLE } from "../config"; +import type { MandateOutput, MultichainOrder, OrderContainer, StandardOrder } from "../../types"; +import { + BYTES32_ZERO, + type chain, + chainMap, + COIN_FILLER, + INPUT_SETTLER_COMPACT_LIFI, + MULTICHAIN_INPUT_SETTLER_COMPACT, + POLYMER_ORACLE, + WORMHOLE_ORACLE +} from "../config"; +import { addressToBytes32 } from "./convert"; + +export type ValidationResult = { + passed: boolean; + reason: string; +}; + +export const VALIDATION_PASS_REASON = "Validation pass" as const; + +export const VALIDATION_ERRORS = { + FILL_DEADLINE_AFTER_EXPIRES: "fillDeadline > expires", + UNKNOWN_ORIGIN_CHAIN: "input chain", + INPUT_ORACLE_NOT_ALLOWED: "inputOracle", + NO_INPUTS: "inputs", + INVALID_INPUT_TUPLE: "inputs", + INPUT_AMOUNT_NON_POSITIVE: "input amount", + NO_OUTPUTS: "outputs", + UNKNOWN_OUTPUT_CHAIN: "output chain", + OUTPUT_AMOUNT_NON_POSITIVE: "output amount", + OUTPUT_ORACLE_ZERO: "output oracle", + OUTPUT_ORACLE_NOT_ALLOWED: "output oracle", + OUTPUT_SETTLER_ZERO: "output settler", + OUTPUT_SETTLER_NOT_ALLOWED: "output settler", + OUTPUT_TOKEN_ZERO: "output token", + OUTPUT_RECIPIENT_ZERO: "output recipient" +} as const; export function getOutputHash(output: MandateOutput) { return keccak256( @@ -67,48 +102,137 @@ export function encodeMandateOutput( ); } -/// https://docs.catalyst.exchange/solver/orderflow/#order-validation -export function validateOrder(order: StandardOrder): boolean { - const currentTime = Math.floor(Date.now() / 1000); - - // 1. Filldeadline - const isBeforeFilltime = currentTime < order.fillDeadline; - if (!isBeforeFilltime) return false; - // 2. Expires - const isBeforeExpiry = currentTime < order.expires; - if (!isBeforeExpiry) return false; - - // 3. Validation layer. - const inputChain = Object.entries(chainMap).find(([k, v]) => { - return v.id === Number(order.originChainId); - })?.[0] as chain | undefined; - if (!inputChain) return false; - // Polymer? - const isPolymer = - inputChain in POLYMER_ORACLE && - POLYMER_ORACLE[inputChain as keyof typeof POLYMER_ORACLE] !== order.inputOracle; - const isWormhole = - inputChain in WORMHOLE_ORACLE && - WORMHOLE_ORACLE[inputChain as keyof typeof WORMHOLE_ORACLE] !== order.inputOracle; - const whitelistedOracle = isPolymer || isWormhole; - if (!whitelistedOracle) return false; - - // 4. Check inputs. - // TODO: check the outputs. - // 5. Lockid of inputs. - // 6. reset period of inputs. - // 7. allocatorid - // 8. claim sig - // 9. Outputs +function findChainById(chainId: bigint): chain | undefined { + const numericChainId = Number(chainId); + if (!Number.isInteger(numericChainId) || numericChainId <= 0) return undefined; + return Object.entries(chainMap).find(([, value]) => value.id === numericChainId)?.[0] as + | chain + | undefined; +} + +function normalize(value: string) { + return value.toLowerCase(); +} + +function isZeroBytes32(value: string) { + return normalize(value) === normalize(BYTES32_ZERO); +} + +function getAllowedInputOracles(inputChain: chain, sameChainFill: boolean): string[] { + const allowed: string[] = []; + const polymer = POLYMER_ORACLE[inputChain as keyof typeof POLYMER_ORACLE]; + if (polymer) allowed.push(polymer); + const wormhole = WORMHOLE_ORACLE[inputChain as keyof typeof WORMHOLE_ORACLE]; + if (wormhole && normalize(wormhole) !== "0x0000000000000000000000000000000000000000") { + allowed.push(wormhole); + } + if (sameChainFill) allowed.push(COIN_FILLER); + return allowed.map(normalize); +} + +function getAllowedOutputOracles(outputChain: chain): string[] { + const allowed: string[] = [addressToBytes32(COIN_FILLER)]; + const polymer = POLYMER_ORACLE[outputChain as keyof typeof POLYMER_ORACLE]; + if (polymer) allowed.push(addressToBytes32(polymer)); + const wormhole = WORMHOLE_ORACLE[outputChain as keyof typeof WORMHOLE_ORACLE]; + if (wormhole && normalize(wormhole) !== "0x0000000000000000000000000000000000000000") { + allowed.push(addressToBytes32(wormhole)); + } + return allowed.map(normalize); +} + +function getAllowedOutputSettlers(): string[] { + return [addressToBytes32(COIN_FILLER)].map(normalize); +} + +function pass(): ValidationResult { + return { passed: true, reason: VALIDATION_PASS_REASON }; +} + +function fail(reason: string): ValidationResult { + return { passed: false, reason }; +} + +/// https://docs.li.fi/lifi-intents/for-solvers/orderflow#order-validation +export function validateOrderWithReason(order: StandardOrder): ValidationResult { + // 1-2. temporal consistency only + if (order.fillDeadline > order.expires) + return fail(VALIDATION_ERRORS.FILL_DEADLINE_AFTER_EXPIRES); + + // 3. validation layer + const inputChain = findChainById(order.originChainId); + if (!inputChain) return fail(VALIDATION_ERRORS.UNKNOWN_ORIGIN_CHAIN); + const sameChainFill = order.outputs.every((output) => output.chainId === order.originChainId); + const allowedInputOracles = getAllowedInputOracles(inputChain, sameChainFill); + if (!allowedInputOracles.includes(normalize(order.inputOracle))) { + return fail(VALIDATION_ERRORS.INPUT_ORACLE_NOT_ALLOWED); + } + + // 4. inputs + if (!Array.isArray(order.inputs) || order.inputs.length === 0) + return fail(VALIDATION_ERRORS.NO_INPUTS); + for (const input of order.inputs) { + if (!Array.isArray(input) || input.length !== 2) + return fail(VALIDATION_ERRORS.INVALID_INPUT_TUPLE); + const [, amount] = input; + if (amount <= 0n) return fail(VALIDATION_ERRORS.INPUT_AMOUNT_NON_POSITIVE); + } + + // 5. lock ID semantics are not validated yet. + // TODO: validate lock IDs for escrow/compact compatibility and token mapping. + // 6. reset period semantics are not validated yet. + // TODO: decode and validate reset period constraints from lock IDs. + // 7. allocator ID policy is not validated yet. + // TODO: enforce allocator policy rules for active environments. + // 8. claim/signature verification is not validated here. + // TODO: validate sponsor/allocator signatures when required. + + // 9. outputs + if (!Array.isArray(order.outputs) || order.outputs.length === 0) + return fail(VALIDATION_ERRORS.NO_OUTPUTS); for (const output of order.outputs) { - if (!output.oracle) return false; + const outputChain = findChainById(output.chainId); + if (!outputChain) return fail(VALIDATION_ERRORS.UNKNOWN_OUTPUT_CHAIN); + if (output.amount < 0n) return fail(VALIDATION_ERRORS.OUTPUT_AMOUNT_NON_POSITIVE); + if (isZeroBytes32(output.oracle)) return fail(VALIDATION_ERRORS.OUTPUT_ORACLE_ZERO); + const allowedOutputOracles = getAllowedOutputOracles(outputChain); + if (!allowedOutputOracles.includes(normalize(output.oracle))) { + return fail(VALIDATION_ERRORS.OUTPUT_ORACLE_NOT_ALLOWED); + } + if (isZeroBytes32(output.settler)) return fail(VALIDATION_ERRORS.OUTPUT_SETTLER_ZERO); + const allowedOutputSettlers = getAllowedOutputSettlers(); + if (!allowedOutputSettlers.includes(normalize(output.settler))) { + return fail(VALIDATION_ERRORS.OUTPUT_SETTLER_NOT_ALLOWED); + } + if (isZeroBytes32(output.token)) return fail(VALIDATION_ERRORS.OUTPUT_TOKEN_ZERO); + if (isZeroBytes32(output.recipient)) return fail(VALIDATION_ERRORS.OUTPUT_RECIPIENT_ZERO); } - // 10. Multiple outputs. - if (order.outputs.length > 1) return false; - // 11. Allocatordata + // 11. allocatorData is not validated yet. + // TODO: parse and validate allocatorData. + // 12. nonce freshness/replay checks require chain state. + // TODO: validate nonce freshness against chain state. + return pass(); +} - // 12. Nonce. +export function validateOrder(order: StandardOrder): boolean { + return validateOrderWithReason(order).passed; +} + +export function validateOrderContainerWithReason(orderContainer: OrderContainer): ValidationResult { + const compactSettlers = [INPUT_SETTLER_COMPACT_LIFI, MULTICHAIN_INPUT_SETTLER_COMPACT].map( + normalize + ); + if (compactSettlers.includes(normalize(orderContainer.inputSettler))) { + // TODO: implement compact validation from LI.FI orderflow docs. + return pass(); + } + + if ("originChainId" in orderContainer.order) return validateOrderWithReason(orderContainer.order); + + return pass(); +} - return true; +export function validateOrderContainer(orderContainer: OrderContainer): boolean { + return validateOrderContainerWithReason(orderContainer).passed; } diff --git a/tests/unit/intentList.test.ts b/tests/unit/intentList.test.ts index 2bdc519..b2a231a 100644 --- a/tests/unit/intentList.test.ts +++ b/tests/unit/intentList.test.ts @@ -45,7 +45,9 @@ const baseRow: BaseIntentRow = { inputChips: [], inputOverflow: 0, outputChips: [], - outputOverflow: 0 + outputOverflow: 0, + validationPassed: true, + validationReason: "Validation pass" }; describe("intentList timing and formatting", () => { diff --git a/tests/unit/orderLib.test.ts b/tests/unit/orderLib.test.ts index 8649309..bcbb65d 100644 --- a/tests/unit/orderLib.test.ts +++ b/tests/unit/orderLib.test.ts @@ -1,13 +1,25 @@ import { describe, expect, it } from "bun:test"; -import { chainMap } from "../../src/lib/config"; -import { getOutputHash, validateOrder } from "../../src/lib/utils/orderLib"; -import type { MandateOutput, StandardOrder } from "../../src/types"; +import { + COIN_FILLER, + INPUT_SETTLER_COMPACT_LIFI, + POLYMER_ORACLE, + chainMap +} from "../../src/lib/config"; +import { + getOutputHash, + validateOrder, + validateOrderContainer, + validateOrderWithReason, + VALIDATION_ERRORS +} from "../../src/lib/utils/orderLib"; +import { addressToBytes32 } from "../../src/lib/utils/convert"; +import type { MandateOutput, OrderContainer, StandardOrder } from "../../src/types"; const b32 = (byte: string) => `0x${byte.repeat(64)}` as `0x${string}`; const output: MandateOutput = { - oracle: b32("1"), - settler: b32("2"), + oracle: addressToBytes32(COIN_FILLER), + settler: addressToBytes32(COIN_FILLER), chainId: BigInt(chainMap.arbitrum.id), token: b32("3"), amount: 1n, @@ -23,7 +35,7 @@ function makeOrder(overrides: Partial = {}): StandardOrder { originChainId: BigInt(chainMap.ethereum.id), expires: Math.floor(Date.now() / 1000) + 1000, fillDeadline: Math.floor(Date.now() / 1000) + 1000, - inputOracle: "0x0000000000000000000000000000000000000001", + inputOracle: POLYMER_ORACLE.ethereum, inputs: [[1n, 1n]], outputs: [output], ...overrides @@ -43,16 +55,97 @@ describe("orderLib", () => { expect(h1).not.toBe(h2); }); - it("rejects expired orders", () => { - const expired = makeOrder({ - expires: Math.floor(Date.now() / 1000) - 1, - fillDeadline: Math.floor(Date.now() / 1000) - 1 + it("rejects orders where fillDeadline is later than expires", () => { + const invalidTiming = makeOrder({ + expires: Math.floor(Date.now() / 1000) + 1000, + fillDeadline: Math.floor(Date.now() / 1000) + 1001 }); - expect(validateOrder(expired)).toBe(false); + const result = validateOrderWithReason(invalidTiming); + expect(result.passed).toBe(false); + expect(result.reason).toBe(VALIDATION_ERRORS.FILL_DEADLINE_AFTER_EXPIRES); }); - it("rejects orders with multiple outputs", () => { + it("accepts orders with multiple outputs", () => { const multiOutput = makeOrder({ outputs: [output, { ...output, amount: 2n }] }); - expect(validateOrder(multiOutput)).toBe(false); + expect(validateOrder(multiOutput)).toBe(true); + }); + + it("rejects orders with unknown source oracle", () => { + const invalidOracle = makeOrder({ + inputOracle: "0x0000000000000000000000000000000000000001" + }); + const result = validateOrderWithReason(invalidOracle); + expect(result.passed).toBe(false); + expect(result.reason).toBe(VALIDATION_ERRORS.INPUT_ORACLE_NOT_ALLOWED); + }); + + it("accepts same-chain intents with COIN_FILLER as inputOracle", () => { + const sameChainCoinFiller = makeOrder({ + inputOracle: COIN_FILLER, + outputs: [{ ...output, chainId: BigInt(chainMap.ethereum.id) }] + }); + expect(validateOrder(sameChainCoinFiller)).toBe(true); + }); + + it("rejects orders with empty inputs", () => { + const emptyInputs = makeOrder({ inputs: [] }); + const result = validateOrderWithReason(emptyInputs); + expect(result.passed).toBe(false); + expect(result.reason).toBe(VALIDATION_ERRORS.NO_INPUTS); + }); + + it("accepts orders with zero output amount", () => { + const zeroOutputAmount = makeOrder({ + outputs: [{ ...output, amount: 0n }] + }); + expect(validateOrder(zeroOutputAmount)).toBe(true); + }); + + it("rejects orders with negative output amount", () => { + const negativeOutputAmount = makeOrder({ + outputs: [{ ...output, amount: -1n }] + }); + const result = validateOrderWithReason(negativeOutputAmount); + expect(result.passed).toBe(false); + expect(result.reason).toBe(VALIDATION_ERRORS.OUTPUT_AMOUNT_NON_POSITIVE); + }); + + it("rejects orders with unknown output chain", () => { + const badOutputChain = makeOrder({ + outputs: [{ ...output, chainId: 999999999n }] + }); + const result = validateOrderWithReason(badOutputChain); + expect(result.passed).toBe(false); + expect(result.reason).toBe(VALIDATION_ERRORS.UNKNOWN_OUTPUT_CHAIN); + }); + + it("rejects orders with non-whitelisted output oracle", () => { + const badOutputOracle = makeOrder({ + outputs: [{ ...output, oracle: b32("a") }] + }); + const result = validateOrderWithReason(badOutputOracle); + expect(result.passed).toBe(false); + expect(result.reason).toBe(VALIDATION_ERRORS.OUTPUT_ORACLE_NOT_ALLOWED); + }); + + it("rejects orders with non-whitelisted output settler", () => { + const badOutputSettler = makeOrder({ + outputs: [{ ...output, settler: b32("b") }] + }); + const result = validateOrderWithReason(badOutputSettler); + expect(result.passed).toBe(false); + expect(result.reason).toBe(VALIDATION_ERRORS.OUTPUT_SETTLER_NOT_ALLOWED); + }); + + it("treats compact intents as valid in container validator (TODO path)", () => { + const compactContainer: OrderContainer = { + inputSettler: INPUT_SETTLER_COMPACT_LIFI, + order: makeOrder({ + inputOracle: "0x0000000000000000000000000000000000000001" + }), + sponsorSignature: { type: "None", payload: "0x" }, + allocatorSignature: { type: "None", payload: "0x" } + }; + expect(validateOrderContainer(compactContainer)).toBe(true); }); });