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);
});
});