From ec7aba6f5ae78cf503f70fc5a62863bdfb69f32b Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 17 Feb 2026 15:28:45 +0400 Subject: [PATCH] Manually fill intents via input box --- src/lib/libraries/orderServer.ts | 181 +++++++++++++++++++++++++++++- src/lib/screens/IntentList.svelte | 180 ++++++++++++++++++++++------- src/lib/state.svelte.ts | 53 ++++++--- src/routes/+page.svelte | 25 ++++- tests/e2e/issuance.spec.ts | 48 ++++++++ tests/unit/orderServer.test.ts | 49 ++++++++ 6 files changed, 479 insertions(+), 57 deletions(-) create mode 100644 tests/unit/orderServer.test.ts diff --git a/src/lib/libraries/orderServer.ts b/src/lib/libraries/orderServer.ts index c16c1e3..74b4972 100644 --- a/src/lib/libraries/orderServer.ts +++ b/src/lib/libraries/orderServer.ts @@ -1,5 +1,12 @@ import axios from "axios"; -import type { NoSignature, OrderContainer, Quote, Signature, StandardOrder } from "../../types"; +import type { + MultichainOrder, + NoSignature, + OrderContainer, + Quote, + Signature, + StandardOrder +} from "../../types"; import { type chain, chainMap } from "$lib/config"; import { getInteropableAddress } from "../utils/interopableAddresses"; import { validateOrder } from "$lib/utils/orderLib"; @@ -92,6 +99,156 @@ type GetQuoteResponse = { }[]; }; +type OrderEnvelope = { + order: unknown; + inputSettler: unknown; + sponsorSignature?: unknown; + allocatorSignature?: unknown; +}; + +function toHexString(value: unknown, field: string): `0x${string}` { + if (typeof value !== "string" || !value.startsWith("0x")) { + throw new Error(`Order payload invalid: ${field}`); + } + return value as `0x${string}`; +} + +function toBigIntValue(value: unknown, field: string): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isFinite(value)) return BigInt(value); + if (typeof value === "string" && value.length > 0) return BigInt(value); + throw new Error(`Order payload invalid: ${field}`); +} + +function toNumberValue(value: unknown, field: string): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string" && value.length > 0) return Number(value); + throw new Error(`Order payload invalid: ${field}`); +} + +function normalizeSignature(value: unknown): Signature | NoSignature { + if (!value) return { type: "None", payload: "0x" }; + return { + type: "ECDSA", + payload: toHexString(value, "signature") + }; +} + +function normalizeOutputs(value: unknown) { + if (!Array.isArray(value)) throw new Error("Order payload invalid: outputs"); + return value.map((output, index) => { + if (!output || typeof output !== "object") { + throw new Error(`Order payload invalid: outputs[${index}]`); + } + const o = output as Record; + return { + oracle: toHexString(o.oracle, `outputs[${index}].oracle`), + settler: toHexString(o.settler, `outputs[${index}].settler`), + chainId: toBigIntValue(o.chainId, `outputs[${index}].chainId`), + token: toHexString(o.token, `outputs[${index}].token`), + amount: toBigIntValue(o.amount, `outputs[${index}].amount`), + recipient: toHexString(o.recipient, `outputs[${index}].recipient`), + callbackData: toHexString(o.callbackData ?? "0x", `outputs[${index}].callbackData`), + context: toHexString(o.context ?? "0x", `outputs[${index}].context`) + }; + }); +} + +function normalizeStandardOrder(order: Record): StandardOrder { + if (!Array.isArray(order.inputs)) throw new Error("Order payload invalid: inputs"); + return { + user: toHexString(order.user, "order.user"), + nonce: toBigIntValue(order.nonce, "order.nonce"), + originChainId: toBigIntValue(order.originChainId, "order.originChainId"), + expires: toNumberValue(order.expires, "order.expires"), + fillDeadline: toNumberValue(order.fillDeadline, "order.fillDeadline"), + inputOracle: toHexString(order.inputOracle, "order.inputOracle"), + inputs: order.inputs.map((input, index) => { + if (!Array.isArray(input) || input.length !== 2) { + throw new Error(`Order payload invalid: inputs[${index}]`); + } + return [ + toBigIntValue(input[0], `inputs[${index}][0]`), + toBigIntValue(input[1], `inputs[${index}][1]`) + ]; + }), + outputs: normalizeOutputs(order.outputs) + }; +} + +function normalizeMultichainOrder(order: Record): MultichainOrder { + if (!Array.isArray(order.inputs)) throw new Error("Order payload invalid: inputs"); + return { + user: toHexString(order.user, "order.user"), + nonce: toBigIntValue(order.nonce, "order.nonce"), + expires: toNumberValue(order.expires, "order.expires"), + fillDeadline: toNumberValue(order.fillDeadline, "order.fillDeadline"), + inputOracle: toHexString(order.inputOracle, "order.inputOracle"), + outputs: normalizeOutputs(order.outputs), + inputs: order.inputs.map((input, index) => { + if (!input || typeof input !== "object") { + throw new Error(`Order payload invalid: inputs[${index}]`); + } + const i = input as Record; + if (!Array.isArray(i.inputs)) { + throw new Error(`Order payload invalid: inputs[${index}].inputs`); + } + return { + chainId: toBigIntValue(i.chainId, `inputs[${index}].chainId`), + inputs: i.inputs.map((tuple, tupleIndex) => { + if (!Array.isArray(tuple) || tuple.length !== 2) { + throw new Error(`Order payload invalid: inputs[${index}].inputs[${tupleIndex}]`); + } + return [ + toBigIntValue(tuple[0], `inputs[${index}].inputs[${tupleIndex}][0]`), + toBigIntValue(tuple[1], `inputs[${index}].inputs[${tupleIndex}][1]`) + ]; + }) + }; + }) + }; +} + +function extractOrderEnvelope(payload: unknown): OrderEnvelope { + const root = + payload && typeof payload === "object" && "data" in payload + ? (payload as Record).data + : payload; + const candidateRaw = Array.isArray(root) ? root[0] : root; + if (!candidateRaw || typeof candidateRaw !== "object") { + throw new Error("Order payload invalid: data"); + } + const candidate = candidateRaw as Record; + const c = + candidate.intent && typeof candidate.intent === "object" + ? (candidate.intent as Record) + : candidate; + if (!("order" in c) || !("inputSettler" in c)) { + throw new Error("Order payload invalid: missing order fields"); + } + return c as OrderEnvelope; +} + +export function parseOrderStatusPayload(payload: unknown): OrderContainer { + const envelope = extractOrderEnvelope(payload); + const rawOrder = envelope.order as Record; + if (!rawOrder || typeof rawOrder !== "object") { + throw new Error("Order payload invalid: order"); + } + const order = + "originChainId" in rawOrder + ? normalizeStandardOrder(rawOrder) + : normalizeMultichainOrder(rawOrder); + + return { + inputSettler: toHexString(envelope.inputSettler, "inputSettler"), + order, + sponsorSignature: normalizeSignature(envelope.sponsorSignature), + allocatorSignature: normalizeSignature(envelope.allocatorSignature) + }; +} + export class OrderServer { baseUrl: string; websocketUrl: string; @@ -192,6 +349,28 @@ export class OrderServer { } } + /** + * @notice Gets an order by on-chain order id. + * @param orderId On-chain order id (0x-prefixed hash) + */ + async getOrderByOnChainOrderId(orderId: `0x${string}`): Promise { + try { + const response = await this.api.get("/orders/status/", { + params: { onChainOrderId: orderId } + }); + return parseOrderStatusPayload(response.data); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + throw new Error("Order not found"); + } + if (error instanceof Error && error.message.startsWith("Order payload invalid")) { + throw error; + } + console.error("Error getting order by id:", error); + throw new Error("Failed to fetch order"); + } + } + /** * @notice Fetch an intent quote for a set of inputs and outputs. * @param options The intent specifications diff --git a/src/lib/screens/IntentList.svelte b/src/lib/screens/IntentList.svelte index a8e71c6..4b292ab 100644 --- a/src/lib/screens/IntentList.svelte +++ b/src/lib/screens/IntentList.svelte @@ -17,13 +17,58 @@ let { scroll, selectedOrder = $bindable(), - orderContainers + orderContainers, + onImportOrder, + onDeleteOrder }: { scroll: (direction: boolean | number) => () => void; selectedOrder: OrderContainer | undefined; orderContainers: OrderContainer[]; + onImportOrder: (orderId: `0x${string}`) => Promise<"inserted" | "updated">; + onDeleteOrder: (orderId: `0x${string}`) => Promise; } = $props(); + let importOrderId = $state(""); + let importState = $state<"idle" | "loading" | "success" | "error">("idle"); + let importMessage = $state(""); + let deletingOrderId = $state(undefined); + + async function handleImport() { + const orderId = importOrderId.trim(); + if (!/^0x[a-fA-F0-9]{64}$/.test(orderId)) { + importState = "error"; + importMessage = "Order id must be a 32-byte hash (0x + 64 hex chars)."; + return; + } + importState = "loading"; + importMessage = "Looking up order..."; + try { + const result = await onImportOrder(orderId as `0x${string}`); + importState = "success"; + importMessage = result === "updated" ? "Order updated in list." : "Order imported."; + importOrderId = ""; + } catch (error) { + importState = "error"; + importMessage = error instanceof Error ? error.message : "Failed to import order."; + } + } + + async function handleDelete(orderId: `0x${string}`) { + deletingOrderId = orderId; + try { + await onDeleteOrder(orderId); + if (importState !== "error") { + importState = "idle"; + importMessage = ""; + } + } catch (error) { + importState = "error"; + importMessage = error instanceof Error ? error.message : "Failed to delete order."; + } finally { + deletingOrderId = undefined; + } + } + function getActiveRightBadge(row: TimedIntentRow, selectedOrderId: string | undefined) { return selectedOrderId === row.orderId ? "Active" : "Expires"; } @@ -68,55 +113,112 @@ description="Click any row to open it in the fill flow." bodyClass="mt-2 flex h-[22rem] flex-col overflow-y-auto align-middle pr-1" > +
+ { + if (event.key === "Enter") handleImport(); + }} + /> + +
+ {#if importMessage} +
+ {importMessage} +
+ {/if}
{#each activeRows as row (row.orderId)} - +
+ + +
{/each}
{#each expiredRows as row (row.orderId)} -
+ {/if} + + {#if expandedExpiredOrderId === row.orderId} + {/if} - + {/each}
diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index 8df7dca..59b0ded 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -49,25 +49,46 @@ class Store { async saveOrderToDb(order: OrderContainer) { if (!browser) return; if (!db) await initDb(); - if (!db) return; const orderId = orderToIntent(order).orderId(); - const now = Date.now(); + const now = Math.floor(Date.now() / 1000); const id = (order as any).id ?? (typeof crypto !== "undefined" ? crypto.randomUUID() : String(now)); const intentType = (order as any).intentType ?? "escrow"; - await db! - .insert(intents) - .values({ - id, - orderId, - intentType, - data: JSON.stringify(order), - createdAt: now - }) - .onConflictDoUpdate({ - target: intents.orderId, - set: { intentType, data: JSON.stringify(order) } - }); + const data = JSON.stringify(order); + if (db) { + try { + try { + await db + .insert(intents) + .values({ + id, + orderId, + intentType, + data, + createdAt: now + }) + .onConflictDoUpdate({ + target: intents.orderId, + set: { intentType, data } + }); + } catch (_error) { + const existing = await db.select().from(intents).where(eq(intents.orderId, orderId)); + if (existing.length > 0) { + await db.update(intents).set({ intentType, data }).where(eq(intents.orderId, orderId)); + } else { + await db.insert(intents).values({ + id, + orderId, + intentType, + data, + createdAt: now + }); + } + } + } catch (error) { + console.warn("saveOrderToDb db write failed", { orderId, error }); + } + } const idx = this.orders.findIndex((o) => orderToIntent(o).orderId() === orderId); if (idx >= 0) this.orders[idx] = order; else this.orders.push(order); @@ -158,7 +179,7 @@ class Store { chainId: chainIdNumber, txHash, receipt: serializedReceipt, - createdAt: Date.now() + createdAt: Math.floor(Date.now() / 1000) }); } this.transactionReceipts[`${chainIdNumber}:${txHash}`] = serializedReceipt; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9a7d169..bd1b075 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -105,6 +105,23 @@ let selectedOrder = $state(undefined); let currentScreenIndex = $state(0); let scrollStepProgress = $state(0); + async function importOrderById(orderId: `0x${string}`): Promise<"inserted" | "updated"> { + const importedOrder = await orderServer.getOrderByOnChainOrderId(orderId); + const importedOrderId = orderToIntent(importedOrder).orderId(); + const existingIndex = store.orders.findIndex( + (o) => orderToIntent(o).orderId() === importedOrderId + ); + await store.saveOrderToDb(importedOrder); + selectedOrder = + store.orders.find((o) => orderToIntent(o).orderId() === importedOrderId) ?? importedOrder; + return existingIndex >= 0 ? "updated" : "inserted"; + } + async function deleteOrderById(orderId: `0x${string}`): Promise { + await store.deleteOrderFromDb(orderId); + if (selectedOrder && orderToIntent(selectedOrder).orderId() === orderId) { + selectedOrder = undefined; + } + } let snapContainer: HTMLDivElement; @@ -202,7 +219,13 @@ {:else} - + {#if selectedOrder !== undefined} { body: JSON.stringify(mockQuoteResponse) }); }); + await page.route("**/orders/status/*", async (route) => { + const now = Math.floor(Date.now() / 1000); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + order: { + user: "0x1111111111111111111111111111111111111111", + nonce: "123", + originChainId: "8453", + expires: now + 3600, + fillDeadline: now + 1800, + inputOracle: "0x0000000000000000000000000000000000000001", + inputs: [["1", "1000000"]], + outputs: [ + { + oracle: "0x0000000000000000000000000000000000000000000000000000000000000001", + settler: "0x0000000000000000000000000000000000000000000000000000000000000001", + chainId: "42161", + token: "0x0000000000000000000000000000000000000000000000000000000000000001", + amount: "1000000", + recipient: "0x0000000000000000000000000000000000000000000000000000000000000001", + callbackData: "0x", + context: "0x" + } + ] + }, + inputSettler: "0x000025c3226C00B2Cdc200005a1600509f4e00C0", + sponsorSignature: null, + allocatorSignature: null + } + }) + }); + }); await page.goto("/"); await bootstrapConnectedWallet(page); @@ -46,3 +81,16 @@ test("input/output modals open and save in issuance screen", async ({ page }) => await page.getByTestId("quote-button").click(); await expect(page.getByTestId("quote-button")).toBeVisible(); }); + +test("imports order by order id into intent list", async ({ page }) => { + await page.getByRole("button", { name: "→" }).click(); + await page.getByRole("button", { name: "→" }).click(); + await expect(page.getByRole("heading", { name: "Select Intent To Solve" })).toBeVisible(); + + await page + .getByTestId("intent-import-order-id") + .fill("0x1111111111111111111111111111111111111111111111111111111111111111"); + await page.getByTestId("intent-import-order-submit").click(); + + await expect(page.getByTestId("intent-import-order-id")).toHaveValue(""); +}); diff --git a/tests/unit/orderServer.test.ts b/tests/unit/orderServer.test.ts new file mode 100644 index 0000000..738080a --- /dev/null +++ b/tests/unit/orderServer.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { parseOrderStatusPayload } from "../../src/lib/libraries/orderServer"; + +const BYTES32_ONE = "0x0000000000000000000000000000000000000000000000000000000000000001" as const; + +describe("parseOrderStatusPayload", () => { + it("parses a status payload into an OrderContainer", () => { + const payload = { + data: { + order: { + user: "0x1111111111111111111111111111111111111111", + nonce: "123", + originChainId: "8453", + expires: Math.floor(Date.now() / 1000) + 3600, + fillDeadline: Math.floor(Date.now() / 1000) + 1800, + inputOracle: "0x0000000000000000000000000000000000000001", + inputs: [["1", "1000000"]], + outputs: [ + { + oracle: BYTES32_ONE, + settler: BYTES32_ONE, + chainId: "42161", + token: BYTES32_ONE, + amount: "1000000", + recipient: BYTES32_ONE, + callbackData: "0x", + context: "0x" + } + ] + }, + inputSettler: "0x000025c3226C00B2Cdc200005a1600509f4e00C0", + sponsorSignature: null, + allocatorSignature: "0x1234" + } + }; + + const parsed = parseOrderStatusPayload(payload); + + expect(parsed.inputSettler).toBe("0x000025c3226C00B2Cdc200005a1600509f4e00C0"); + expect(parsed.order.nonce).toBe(123n); + expect("originChainId" in parsed.order && parsed.order.originChainId).toBe(8453n); + expect(parsed.sponsorSignature).toEqual({ type: "None", payload: "0x" }); + expect(parsed.allocatorSignature).toEqual({ type: "ECDSA", payload: "0x1234" }); + }); + + it("throws for invalid payload", () => { + expect(() => parseOrderStatusPayload({ data: {} })).toThrow(); + }); +});