Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/lib/components/IntentListDetailRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,14 @@
<span class="rounded bg-gray-100 px-1.5 py-0.5"
>{row.inputCount} inputs • {row.outputCount} outputs</span
>
<span
class="rounded px-1.5 py-0.5"
class:bg-emerald-100={row.validationPassed}
class:text-emerald-800={row.validationPassed}
class:bg-rose-100={!row.validationPassed}
class:text-rose-800={!row.validationPassed}
title={row.validationReason}
>
{row.validationPassed ? "Validation Pass" : `Invalid: ${row.validationReason}`}
</span>
</div>
9 changes: 8 additions & 1 deletion src/lib/libraries/intentList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,6 +39,8 @@ export type BaseIntentRow = {
inputOverflow: number;
outputChips: Chip[];
outputOverflow: number;
validationPassed: boolean;
validationReason: string;
};

export type TimedIntentRow = BaseIntentRow & {
Expand Down Expand Up @@ -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,
Expand All @@ -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
};
}

Expand Down
77 changes: 37 additions & 40 deletions src/lib/libraries/orderServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 };
});
}
}
}
Expand Down
206 changes: 165 additions & 41 deletions src/lib/utils/orderLib.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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;
}
4 changes: 3 additions & 1 deletion tests/unit/intentList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading