diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a69d625 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,53 @@ +name: Test Suite + +on: + pull_request: + push: + branches: [main] + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Run unit tests + run: bun run test:unit + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage + if-no-files-found: ignore + + e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + - name: Run E2E tests + run: bun run test:e2e + - name: Upload Playwright artifacts + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-artifacts + path: | + test-results + playwright-report + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 5730568..63871d7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* +playwright-report +test-results diff --git a/README.md b/README.md index 0c61119..cd34bd1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,24 @@ To start development: 4. Install dependencies `bun install`. 5. Start `bun run dev`. +### Testing + +The project now uses a two-layer automated test suite: + +1. Unit and integration tests with `bun test` +2. Browser UI-state tests with Playwright + +Run: + +- `bun run test:unit` for library/unit/integration tests with coverage output +- `bun run test:e2e` for deterministic browser tests +- `bun run test:all` to run both + +For local Playwright setup: + +1. `bun install` +2. `bunx playwright install chromium` + ## Structure Lintent is built around a single page [/src/routes/+page.svelte](/src/routes/+page.svelte). diff --git a/bun.lock b/bun.lock index b766061..9b2f834 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "devDependencies": { "@eslint/compat": "^1.2.5", "@eslint/js": "^9.18.0", + "@playwright/test": "^1.55.0", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^6.1.1", "@tailwindcss/vite": "^4.0.0", @@ -239,6 +240,8 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="], @@ -955,6 +958,10 @@ "pino-std-serializers": ["pino-std-serializers@4.0.0", "", {}, "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -1281,6 +1288,8 @@ "ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "postcss-load-config/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], diff --git a/drizzle/0004_store_transaction_receipts.sql b/drizzle/0004_store_transaction_receipts.sql new file mode 100644 index 0000000..939ed95 --- /dev/null +++ b/drizzle/0004_store_transaction_receipts.sql @@ -0,0 +1,7 @@ +CREATE TABLE "transaction_receipts" ( + "id" text PRIMARY KEY NOT NULL, + "chain_id" bigint NOT NULL, + "tx_hash" text NOT NULL, + "receipt" text NOT NULL, + "created_at" bigint NOT NULL +); diff --git a/package.json b/package.json index 44ade6f..f92cc8a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,12 @@ "build": "vite build", "preview": "vite preview", "prepare": "husky", - "test": "bun test", + "test": "bun run test:all", + "test:unit": "bun test tests/unit tests/db.test.ts --coverage", + "test:e2e": "bunx playwright test", + "test:e2e:headed": "bunx playwright test --headed", + "test:e2e:ui": "bunx playwright test --ui", + "test:all": "bun run test:unit && bun run test:e2e", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", @@ -22,6 +27,7 @@ "@sveltejs/vite-plugin-svelte": "^6.1.1", "@tailwindcss/vite": "^4.0.0", "@types/bun": "^1.3.8", + "@playwright/test": "^1.55.0", "drizzle-kit": "^0.31.9", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6500ec0 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/e2e", + timeout: 45_000, + expect: { + timeout: 10_000 + }, + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + use: { + baseURL: "http://127.0.0.1:4173", + trace: "retain-on-failure", + video: "retain-on-failure", + screenshot: "only-on-failure" + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] } + } + ], + webServer: { + command: "bunx vite dev --host 127.0.0.1 --port 4173", + url: "http://127.0.0.1:4173", + reuseExistingServer: !process.env.CI, + timeout: 120_000 + } +}); diff --git a/src/lib/components/AwaitButton.svelte b/src/lib/components/AwaitButton.svelte index efac501..44f0b59 100644 --- a/src/lib/components/AwaitButton.svelte +++ b/src/lib/components/AwaitButton.svelte @@ -5,41 +5,78 @@ name, awaiting, buttonFunction, - baseClass = ["rounded border px-4 h-8 text-xl font-bold"], - hoverClass = ["text-gray-600 hover:text-blue-600"], - lazyClass = ["text-gray-300"] + size = "md", + variant = "default", + fullWidth = false, + baseClass = [], + hoverClass = [], + lazyClass = [] }: { name: Snippet; awaiting: Snippet; - buttonFunction: () => Promise; + buttonFunction: () => Promise; + size?: "sm" | "md"; + variant?: "default" | "success" | "warning" | "muted"; + fullWidth?: boolean; baseClass?: string[]; hoverClass?: string[]; lazyClass?: string[]; } = $props(); - let buttonPromise: Promise | undefined = $state(); + const sizeClass = $derived(size === "sm" ? "h-7 px-2 text-xs" : "h-8 px-3 text-sm"); + const variantBaseClass = $derived.by(() => { + if (variant === "success") return "border-emerald-200 bg-emerald-50 text-emerald-900"; + if (variant === "warning") return "border-amber-200 bg-amber-50 text-amber-900"; + if (variant === "muted") return "border-gray-200 bg-gray-100 text-gray-500"; + return "border-gray-200 bg-white text-gray-700"; + }); + const variantHoverClass = $derived.by(() => { + if (variant === "success") return "hover:border-emerald-300 hover:bg-emerald-100"; + if (variant === "warning") return "hover:border-amber-300 hover:bg-amber-100"; + if (variant === "muted") return ""; + return "hover:border-sky-300 hover:text-sky-700"; + }); + const defaultBase = $derived([ + sizeClass, + "rounded border font-semibold", + variantBaseClass, + fullWidth ? "w-full" : "" + ]); + const defaultHover = $derived(variantHoverClass ? [variantHoverClass] : []); + const defaultLazy = [ + "cursor-not-allowed", + variant === "muted" ? "text-gray-500" : "text-gray-400" + ]; + let buttonPromise: Promise | undefined = $state(); + const run = () => { + buttonPromise = buttonFunction().catch((error) => { + console.error("AwaitButton action failed", error); + throw error; + }); + }; {#await buttonPromise} - -{:then _} +{:then} -{:catch error} +{:catch} - {@html (() => { - console.error(error); - })()} {/await} diff --git a/src/lib/components/GetQuote.svelte b/src/lib/components/GetQuote.svelte index f8f0fda..8b68565 100644 --- a/src/lib/components/GetQuote.svelte +++ b/src/lib/components/GetQuote.svelte @@ -82,6 +82,13 @@ }, 1000); }); + $effect(() => { + if (typeof window === "undefined") return; + const onOnline = () => updateQuote(); + window.addEventListener("online", onOnline); + return () => window.removeEventListener("online", onOnline); + }); + $effect(() => { quoteExpires; if (quoteExpires === 0) { @@ -100,27 +107,34 @@ let quoteRequest: Promise = $state(Promise.resolve()); -
+
{#await quoteRequest} -
Fetch Quote
+
+ Quote +
{:then _} {#if quoteExpires !== 0}
Quote {:else}
{/if} diff --git a/src/lib/components/InputTokenModal.svelte b/src/lib/components/InputTokenModal.svelte index 42bc820..440120b 100644 --- a/src/lib/components/InputTokenModal.svelte +++ b/src/lib/components/InputTokenModal.svelte @@ -1,12 +1,16 @@
- - -
-

Select Input

-
- - - + x +
-
-
- {#each tokenSet as tkn} - {tkn.chain} - {/each} + +
+
+ + + + + + + {#each uniqueInputTokens as token} + + {/each} +
-
- {#each tokenSet as tkn} -
- - of - -
- {/each} + +
+ +
Chain
+
Amount / Balance
+
Use
+
+
+ {#each tokenSet as tkn, rowIndex} + {@const iaddr = iaddrFor(tkn)} + +
{tkn.chain}
+ {#await (store.intentType === "compact" ? store.compactBalances : store.balances)[tkn.chain][tkn.address]} + + {:then balance} + + {:catch _} + + {/await} +
+ +
+
+ {/each} +
+ +
-
diff --git a/src/lib/components/OutputTokenModal.svelte b/src/lib/components/OutputTokenModal.svelte index f053ad7..80f3102 100644 --- a/src/lib/components/OutputTokenModal.svelte +++ b/src/lib/components/OutputTokenModal.svelte @@ -1,6 +1,7 @@
- - -
-

Select Output

-
-
- {#each outputs as output} -
- - - -
- {/each} +
+
+
+

Select Output

+

Configure one or more destination token outputs.

-
-
- -
+ +
+
+ +
Chain
+
Amount
+
Token
+
+
+ {#each outputs as output, rowIndex} + + + {#each Object.values(chainList(store.mainnet)) as chain} + + {/each} + + + + {#each getTokensForChain(output.chain) as token} + + {/each} + + + {/each} +
+
+ +
+ + + +
+
diff --git a/src/lib/components/ui/ChainActionRow.svelte b/src/lib/components/ui/ChainActionRow.svelte new file mode 100644 index 0000000..4350a30 --- /dev/null +++ b/src/lib/components/ui/ChainActionRow.svelte @@ -0,0 +1,40 @@ + + +
+
+ {chainLabel} +
+
+
+ {@render action?.()} +
+
+
+ {@render chips?.()} +
+
+
+
diff --git a/src/lib/components/ui/FieldRow.svelte b/src/lib/components/ui/FieldRow.svelte new file mode 100644 index 0000000..413d829 --- /dev/null +++ b/src/lib/components/ui/FieldRow.svelte @@ -0,0 +1,33 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/FlowProgressList.svelte b/src/lib/components/ui/FlowProgressList.svelte new file mode 100644 index 0000000..6d9f76a --- /dev/null +++ b/src/lib/components/ui/FlowProgressList.svelte @@ -0,0 +1,82 @@ + + +
+
+ Progress +
+
+
+ {#each steps as step, i (step.id)} + + {#if i < steps.length - 1} +
+
+
+
+ {/if} + {/each} +
+
+
diff --git a/src/lib/components/ui/FlowStepTracker.svelte b/src/lib/components/ui/FlowStepTracker.svelte new file mode 100644 index 0000000..31f7e79 --- /dev/null +++ b/src/lib/components/ui/FlowStepTracker.svelte @@ -0,0 +1,230 @@ + + + diff --git a/src/lib/components/ui/FormControl.svelte b/src/lib/components/ui/FormControl.svelte new file mode 100644 index 0000000..1553054 --- /dev/null +++ b/src/lib/components/ui/FormControl.svelte @@ -0,0 +1,60 @@ + + +{#if as === "select"} + +{:else} + +{/if} diff --git a/src/lib/components/ui/InlineMetaField.svelte b/src/lib/components/ui/InlineMetaField.svelte new file mode 100644 index 0000000..c1adac7 --- /dev/null +++ b/src/lib/components/ui/InlineMetaField.svelte @@ -0,0 +1,27 @@ + + +
+ +
+ {metaPrefix} + {metaText} +
+
diff --git a/src/lib/components/ui/ScreenFrame.svelte b/src/lib/components/ui/ScreenFrame.svelte new file mode 100644 index 0000000..1cd9eef --- /dev/null +++ b/src/lib/components/ui/ScreenFrame.svelte @@ -0,0 +1,25 @@ + + +
+

{title}

+

{description}

+
+ {@render children?.()} +
+
diff --git a/src/lib/components/ui/SectionCard.svelte b/src/lib/components/ui/SectionCard.svelte new file mode 100644 index 0000000..ed08b64 --- /dev/null +++ b/src/lib/components/ui/SectionCard.svelte @@ -0,0 +1,40 @@ + + +
+ {#if title || headerRight} +
+ {#if title} +

{title}

+ {/if} + {#if headerRight} +
+ {@render headerRight()} +
+ {/if} +
+ {/if} +
+ {@render children?.()} +
+
diff --git a/src/lib/components/ui/SegmentedControl.svelte b/src/lib/components/ui/SegmentedControl.svelte new file mode 100644 index 0000000..959d745 --- /dev/null +++ b/src/lib/components/ui/SegmentedControl.svelte @@ -0,0 +1,45 @@ + + +
+ {#each options as option, i (option.value)} + + {/each} +
diff --git a/src/lib/components/ui/TokenAmountChip.svelte b/src/lib/components/ui/TokenAmountChip.svelte new file mode 100644 index 0000000..bf953f2 --- /dev/null +++ b/src/lib/components/ui/TokenAmountChip.svelte @@ -0,0 +1,34 @@ + + +
+ {amountText} + {symbol.toUpperCase()} +
diff --git a/src/lib/libraries/flowProgress.ts b/src/lib/libraries/flowProgress.ts new file mode 100644 index 0000000..ae0c7cf --- /dev/null +++ b/src/lib/libraries/flowProgress.ts @@ -0,0 +1,233 @@ +import { + BYTES32_ZERO, + COMPACT, + INPUT_SETTLER_COMPACT_LIFI, + INPUT_SETTLER_ESCROW_LIFI, + MULTICHAIN_INPUT_SETTLER_COMPACT, + MULTICHAIN_INPUT_SETTLER_ESCROW, + getClient +} from "$lib/config"; +import { COIN_FILLER_ABI } from "$lib/abi/outputsettler"; +import { POLYMER_ORACLE_ABI } from "$lib/abi/polymeroracle"; +import { SETTLER_ESCROW_ABI } from "$lib/abi/escrow"; +import { COMPACT_ABI } from "$lib/abi/compact"; +import { hashStruct, keccak256 } from "viem"; +import { compactTypes } from "$lib/utils/typedMessage"; +import { getOutputHash, encodeMandateOutput } from "$lib/utils/orderLib"; +import { addressToBytes32, bytes32ToAddress } from "$lib/utils/convert"; +import { orderToIntent } from "$lib/libraries/intent"; +import { getOrFetchRpc } from "$lib/libraries/rpcCache"; +import type { MandateOutput, OrderContainer } from "../../types"; +import store from "$lib/state.svelte"; + +const PROGRESS_TTL_MS = 30_000; +const OrderStatus_Claimed = 2; +const OrderStatus_Refunded = 3; + +export type FlowCheckState = { + allFilled: boolean; + allValidated: boolean; + allFinalised: boolean; +}; + +export function getOutputStorageKey(output: MandateOutput) { + return hashStruct({ + data: output, + types: compactTypes, + primaryType: "MandateOutput" + }); +} + +function isValidHash(hash: string | undefined): hash is `0x${string}` { + return !!hash && hash.startsWith("0x") && hash.length === 66; +} + +async function isOutputFilled(orderId: `0x${string}`, output: MandateOutput) { + const outputKey = getOutputStorageKey(output); + return getOrFetchRpc( + `progress:filled:${orderId}:${outputKey}`, + async () => { + const outputClient = getClient(output.chainId); + const outputHash = getOutputHash(output); + const result = await outputClient.readContract({ + address: bytes32ToAddress(output.settler), + abi: COIN_FILLER_ABI, + functionName: "getFillRecord", + args: [orderId, outputHash] + }); + return result !== BYTES32_ZERO; + }, + { ttlMs: PROGRESS_TTL_MS } + ); +} + +async function isOutputValidatedOnChain( + orderId: `0x${string}`, + inputChain: bigint, + orderContainer: OrderContainer, + output: MandateOutput, + fillTransactionHash: `0x${string}` +) { + const outputKey = getOutputStorageKey(output); + const cachedReceipt = store.getTransactionReceipt(output.chainId, fillTransactionHash); + const receipt = ( + cachedReceipt + ? cachedReceipt + : await getOrFetchRpc( + `progress:receipt:${output.chainId.toString()}:${fillTransactionHash}`, + async () => { + const outputClient = getClient(output.chainId); + return outputClient.getTransactionReceipt({ + hash: fillTransactionHash + }); + }, + { ttlMs: PROGRESS_TTL_MS } + ) + ) as { + blockHash: `0x${string}`; + from: `0x${string}`; + }; + if (!cachedReceipt) { + store + .saveTransactionReceipt(output.chainId, fillTransactionHash, receipt) + .catch((error) => console.warn("saveTransactionReceipt error", error)); + } + + const block = await getOrFetchRpc( + `progress:block:${output.chainId.toString()}:${receipt.blockHash}`, + async () => { + const outputClient = getClient(output.chainId); + return outputClient.getBlock({ blockHash: receipt.blockHash }); + }, + { ttlMs: PROGRESS_TTL_MS } + ); + + const encodedOutput = encodeMandateOutput( + addressToBytes32(receipt.from), + orderId, + Number(block.timestamp), + output + ); + const outputHash = keccak256(encodedOutput); + + return getOrFetchRpc( + `progress:proven:${orderId}:${inputChain.toString()}:${outputKey}:${fillTransactionHash}`, + async () => { + const sourceChainClient = getClient(inputChain); + return sourceChainClient.readContract({ + address: orderContainer.order.inputOracle, + abi: POLYMER_ORACLE_ABI, + functionName: "isProven", + args: [output.chainId, output.oracle, output.settler, outputHash] + }); + }, + { ttlMs: PROGRESS_TTL_MS } + ); +} + +async function isInputChainFinalised(chainId: bigint, container: OrderContainer) { + const { order, inputSettler } = container; + const inputChainClient = getClient(chainId); + const intent = orderToIntent(container); + const orderId = intent.orderId(); + + if ( + inputSettler === INPUT_SETTLER_ESCROW_LIFI || + inputSettler === MULTICHAIN_INPUT_SETTLER_ESCROW + ) { + return getOrFetchRpc( + `progress:finalised:escrow:${orderId}:${chainId.toString()}`, + async () => { + const orderStatus = await inputChainClient.readContract({ + address: inputSettler, + abi: SETTLER_ESCROW_ABI, + functionName: "orderStatus", + args: [orderId] + }); + return orderStatus === OrderStatus_Claimed || orderStatus === OrderStatus_Refunded; + }, + { ttlMs: PROGRESS_TTL_MS } + ); + } + + if ( + inputSettler === INPUT_SETTLER_COMPACT_LIFI || + inputSettler === MULTICHAIN_INPUT_SETTLER_COMPACT + ) { + const flattenedInputs = "originChainId" in order ? order.inputs : order.inputs[0]?.inputs; + if (!flattenedInputs || flattenedInputs.length === 0) return false; + + return getOrFetchRpc( + `progress:finalised:compact:${orderId}:${chainId.toString()}`, + async () => { + const [, allocator] = await inputChainClient.readContract({ + address: COMPACT, + abi: COMPACT_ABI, + functionName: "getLockDetails", + args: [flattenedInputs[0][0]] + }); + return inputChainClient.readContract({ + address: COMPACT, + abi: COMPACT_ABI, + functionName: "hasConsumedAllocatorNonce", + args: [order.nonce, allocator] + }); + }, + { ttlMs: PROGRESS_TTL_MS } + ); + } + + return false; +} + +export async function getOrderProgressChecks( + orderContainer: OrderContainer, + fillTransactions: Record +): Promise { + try { + const intent = orderToIntent(orderContainer); + const orderId = intent.orderId(); + const inputChains = intent.inputChains(); + const outputs = orderContainer.order.outputs; + + const filledStates = await Promise.all( + outputs.map((output) => isOutputFilled(orderId, output)) + ); + const allFilled = outputs.length > 0 && filledStates.every(Boolean); + + let allValidated = false; + if (allFilled && inputChains.length > 0) { + const validatedPairs = await Promise.all( + inputChains.flatMap((inputChain) => + outputs.map(async (output) => { + const fillHash = fillTransactions[getOutputStorageKey(output)]; + if (!isValidHash(fillHash)) return false; + return isOutputValidatedOnChain(orderId, inputChain, orderContainer, output, fillHash); + }) + ) + ); + allValidated = validatedPairs.length > 0 && validatedPairs.every(Boolean); + } + + let allFinalised = false; + if (allValidated && inputChains.length > 0) { + const finalisedStates = await Promise.all( + inputChains.map((chainId) => isInputChainFinalised(chainId, orderContainer)) + ); + allFinalised = finalisedStates.every(Boolean); + } + + return { + allFilled, + allValidated, + allFinalised + }; + } catch (error) { + console.warn("progress checks failed", error); + return { + allFilled: false, + allValidated: false, + allFinalised: false + }; + } +} diff --git a/src/lib/libraries/intent.ts b/src/lib/libraries/intent.ts index 487b4b5..5590f0f 100644 --- a/src/lib/libraries/intent.ts +++ b/src/lib/libraries/intent.ts @@ -869,12 +869,18 @@ export class MultichainOrderIntent { this.asMultichainBatchCompact(); const { sourceChain, account, walletClient, solveParams, signatures } = options; const actionChain = chainMap[sourceChain]; - if (actionChain.id in this.inputChains().map((v) => Number(v))) + const inputChainIds = this.inputChains().map((v) => Number(v)); + if (!inputChainIds.includes(actionChain.id)) throw new Error( - `Input chains and action ID does not match: ${this.inputChains()}, ${actionChain.id}` + `Action chain must be one of input chains for finalise: ${inputChainIds}, action=${actionChain.id}` ); // Get all components for our chain. - const components = this.asComponents().filter((c) => c.chainId === BigInt(actionChain.id)); + const components = this.asComponents().filter((c) => Number(c.chainId) === actionChain.id); + if (components.length === 0) { + throw new Error( + `No multichain order component found for action chain ${actionChain.id} (${sourceChain}).` + ); + } for (const { orderComponent, chainId } of components) { if (this.inputSettler.toLowerCase() === MULTICHAIN_INPUT_SETTLER_ESCROW.toLowerCase()) { @@ -912,5 +918,6 @@ export class MultichainOrderIntent { throw new Error(`Could not detect settler type ${this.inputSettler}`); } } + throw new Error(`Failed to finalise multichain order on chain ${sourceChain}.`); } } diff --git a/src/lib/libraries/orderServer.ts b/src/lib/libraries/orderServer.ts index ca66b3a..c4749c1 100644 --- a/src/lib/libraries/orderServer.ts +++ b/src/lib/libraries/orderServer.ts @@ -102,10 +102,56 @@ export class OrderServer { this.websocketUrl = OrderServer.getOrderServerWssUrl(mainnet); this.api = axios.create({ - baseURL: this.baseUrl + baseURL: this.baseUrl, + timeout: 15000 }); } + private static sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private static isNetworkError(error: unknown): boolean { + if (!axios.isAxiosError(error)) return false; + return error.code === "ERR_NETWORK" || error.code === "ECONNABORTED"; + } + + private async waitForOnline(maxWaitMs = 15000) { + if (typeof window === "undefined" || typeof navigator === "undefined") return; + if (navigator.onLine) return; + await Promise.race([ + new Promise((resolve) => { + const onOnline = () => { + window.removeEventListener("online", onOnline); + resolve(); + }; + window.addEventListener("online", onOnline, { once: true }); + }), + OrderServer.sleep(maxWaitMs) + ]); + } + + private async postWithRetry( + path: string, + body: unknown, + opts: { retries?: number; baseDelayMs?: number } = {} + ): Promise { + const retries = opts.retries ?? 2; + const baseDelayMs = opts.baseDelayMs ?? 500; + let attempt = 0; + while (true) { + try { + const response = await this.api.post(path, body); + return response.data as T; + } catch (error) { + if (!OrderServer.isNetworkError(error) || attempt >= retries) throw error; + await this.waitForOnline(); + await OrderServer.sleep(baseDelayMs * 2 ** attempt); + attempt += 1; + } + } + } + static getOrderServerUrl(mainnet: boolean) { return mainnet ? "https://order.li.fi" : "https://order-dev.li.fi"; } @@ -121,8 +167,7 @@ export class OrderServer { */ async submitOrder(request: SubmitOrderDto) { try { - const response = await this.api.post("/orders/submit", request); - return response.data; + return await this.postWithRetry("/orders/submit", request, { retries: 2, baseDelayMs: 600 }); } catch (error) { console.error("Error submitting order:", error); throw error; @@ -182,10 +227,12 @@ export class OrderServer { }; try { - const response = await this.api.post("/quote/request", rq); - return response.data; + return await this.postWithRetry("/quote/request", rq, { + retries: 3, + baseDelayMs: 700 + }); } catch (error) { - console.error("Error submitting order:", error); + console.error("Error fetching quote:", error); throw error; } } diff --git a/src/lib/libraries/rpcCache.ts b/src/lib/libraries/rpcCache.ts new file mode 100644 index 0000000..2790e8b --- /dev/null +++ b/src/lib/libraries/rpcCache.ts @@ -0,0 +1,71 @@ +type CacheEntry = { + value: T; + expiresAt: number; +}; + +const cache = new Map>(); +const inflight = new Map>(); + +const stats = { + hits: 0, + misses: 0, + inflightJoins: 0 +}; + +export function getRpcCacheStats() { + return { ...stats }; +} + +export function clearRpcCache() { + cache.clear(); + inflight.clear(); +} + +export function invalidateRpcKey(key: string) { + cache.delete(key); + inflight.delete(key); +} + +export function invalidateRpcPrefix(prefix: string) { + for (const key of cache.keys()) { + if (key.startsWith(prefix)) cache.delete(key); + } + for (const key of inflight.keys()) { + if (key.startsWith(prefix)) inflight.delete(key); + } +} + +export async function getOrFetchRpc( + key: string, + fetcher: () => Promise, + opts: { ttlMs: number; force?: boolean } +): Promise { + const { ttlMs, force = false } = opts; + const now = Date.now(); + + if (!force) { + const cached = cache.get(key) as CacheEntry | undefined; + if (cached && cached.expiresAt > now) { + stats.hits += 1; + return cached.value; + } + const pending = inflight.get(key) as Promise | undefined; + if (pending) { + stats.inflightJoins += 1; + return pending; + } + } + + stats.misses += 1; + const request = fetcher() + .then((value) => { + cache.set(key, { value, expiresAt: Date.now() + ttlMs }); + return value; + }) + .finally(() => { + inflight.delete(key); + }); + + inflight.set(key, request); + return request; +} diff --git a/src/lib/libraries/solver.ts b/src/lib/libraries/solver.ts index 7170770..c3e775a 100644 --- a/src/lib/libraries/solver.ts +++ b/src/lib/libraries/solver.ts @@ -17,11 +17,46 @@ import { COIN_FILLER_ABI } from "$lib/abi/outputsettler"; import { ERC20_ABI } from "$lib/abi/erc20"; import { orderToIntent } from "./intent"; import { compactTypes } from "$lib/utils/typedMessage"; +import store from "$lib/state.svelte"; /** * @notice Class for solving intents. Functions called by solvers. */ export class Solver { + private static validationInflight = new Map>(); + private static polymerRequestIndexByLog = new Map(); + + private static sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private static async persistReceipt( + chainId: number | bigint, + txHash: `0x${string}`, + receipt: unknown + ) { + try { + await store.saveTransactionReceipt(chainId, txHash, receipt); + } catch (error) { + console.warn("saveTransactionReceipt error", { chainId: Number(chainId), txHash, error }); + } + } + + private static async getReceiptCachedOrRpc(chainId: number | bigint, txHash: `0x${string}`) { + const cached = store.getTransactionReceipt(chainId, txHash); + if ( + cached && + typeof cached === "object" && + Array.isArray((cached as { logs?: unknown[] }).logs) && + (cached as { logs?: unknown[] }).logs!.length > 0 + ) + return cached as any; + const chainName = getChainName(chainId); + const receipt = await clients[chainName].getTransactionReceipt({ hash: txHash }); + await Solver.persistReceipt(chainId, txHash, receipt); + return receipt; + } + static fill( walletClient: WC, args: { @@ -44,7 +79,16 @@ export class Solver { const orderId = orderToIntent({ order, inputSettler }).orderId(); const outputChain = getChainName(outputs[0].chainId); - console.log({ outputChain }); + // Always attempt chain switch before fill, including native-token fills. + if (preHook) await preHook(outputChain); + const connectedChainId = await walletClient.getChainId(); + const expectedChainId = chainMap[outputChain].id; + if (connectedChainId !== expectedChainId) { + throw new Error( + `Wallet is on chain ${connectedChainId}, expected ${expectedChainId} (${outputChain})` + ); + } + let value = 0n; for (const output of outputs) { if (output.token === BYTES32_ZERO) { @@ -66,7 +110,6 @@ export class Solver { functionName: "allowance", args: [account(), bytes32ToAddress(output.settler)] }); - if (preHook) await preHook(outputChain); if (BigInt(allowance) < output.amount) { const approveTransaction = await walletClient.writeContract({ chain: chainMap[outputChain], @@ -76,9 +119,10 @@ export class Solver { functionName: "approve", args: [bytes32ToAddress(output.settler), maxUint256] }); - await clients[outputChain].waitForTransactionReceipt({ + const approveReceipt = await clients[outputChain].waitForTransactionReceipt({ hash: approveTransaction }); + await Solver.persistReceipt(outputs[0].chainId, approveTransaction, approveReceipt); } } @@ -91,9 +135,10 @@ export class Solver { functionName: "fillOrderOutputs", args: [orderId, outputs, order.fillDeadline, addressToBytes32(account())] }); - await clients[outputChain].waitForTransactionReceipt({ + const fillReceipt = await clients[outputChain].waitForTransactionReceipt({ hash: transactionHash }); + await Solver.persistReceipt(outputs[0].chainId, transactionHash, fillReceipt); // orderInputs.validate[index] = transactionHash; if (postHook) await postHook(); return transactionHash; @@ -119,105 +164,143 @@ export class Solver { const { preHook, postHook, account } = opts; const { output, - orderContainer: { order, inputSettler }, + orderContainer: { order }, fillTransactionHash, sourceChain, mainnet } = args; - const outputChain = getChainName(order.outputs[0].chainId); - - // Get the output filled event. - const transactionReceipt = await clients[outputChain].getTransactionReceipt({ - hash: fillTransactionHash as `0x${string}` - }); - - const logs = parseEventLogs({ - abi: COIN_FILLER_ABI, - eventName: "OutputFilled", - logs: transactionReceipt.logs - }); - // We need to search through each log until we find one matching our output. - console.log("logs", logs); - let logIndex = -1; const expectedOutputHash = hashStruct({ types: compactTypes, primaryType: "MandateOutput", data: output }); - for (const log of logs) { - const logOutput = log.args.output; - // TODO: Optimise by comparing the dicts. - const logOutputHash = hashStruct({ - types: compactTypes, - primaryType: "MandateOutput", - data: logOutput - }); - if (logOutputHash === expectedOutputHash) { - logIndex = log.logIndex; - break; + const validationKey = `${sourceChain}:${fillTransactionHash}:${expectedOutputHash}`; + const existingValidation = Solver.validationInflight.get(validationKey); + if (existingValidation) return existingValidation; + + const validationPromise = (async () => { + const outputChain = getChainName(output.chainId); + if ( + !fillTransactionHash || + !fillTransactionHash.startsWith("0x") || + fillTransactionHash.length !== 66 + ) { + throw new Error(`Invalid fill transaction hash: ${fillTransactionHash}`); } - } - if (logIndex === -1) throw Error(`Could not find matching log`); - if (order.inputOracle === getOracle("polymer", sourceChain)) { - let proof: string | undefined; - let polymerIndex: number | undefined; - for (let i = 0; i < 5; ++i) { - const response = await axios.post(`/polymer`, { - srcChainId: Number(order.outputs[0].chainId), - srcBlockNumber: Number(transactionReceipt.blockNumber), - globalLogIndex: Number(logIndex), - polymerIndex, - mainnet: mainnet + // Get the output filled event. + const transactionReceipt = await Solver.getReceiptCachedOrRpc( + output.chainId, + fillTransactionHash as `0x${string}` + ); + + const logs = parseEventLogs({ + abi: COIN_FILLER_ABI, + eventName: "OutputFilled", + logs: transactionReceipt.logs + }); + // We need to search through each log until we find one matching our output. + let logIndex = -1; + for (const log of logs) { + const logOutput = log.args.output; + // TODO: Optimise by comparing the dicts. + const logOutputHash = hashStruct({ + types: compactTypes, + primaryType: "MandateOutput", + data: logOutput }); - const dat = response.data as { - proof: undefined | string; - polymerIndex: number; - }; - polymerIndex = dat.polymerIndex; - console.log(dat); - if (dat.proof) { - proof = dat.proof; + if (logOutputHash === expectedOutputHash) { + logIndex = log.logIndex; break; } - // Wait while backing off before requesting again. - await new Promise((r) => setTimeout(r, i * 2 + 1000)); } - console.log({ logIndex, proof }); - if (proof) { - if (preHook) await preHook(sourceChain); + if (logIndex === -1) throw Error(`Could not find matching log`); + + if (order.inputOracle === getOracle("polymer", sourceChain)) { + let proof: string | undefined; + const polymerKey = `${Number(output.chainId)}:${Number(transactionReceipt.blockNumber)}:${Number(logIndex)}`; + let polymerIndex: number | undefined = Solver.polymerRequestIndexByLog.get(polymerKey); + for (const waitMs of [1000, 2000, 4000, 8000]) { + const response = await axios.post( + `/polymer`, + { + srcChainId: Number(output.chainId), + srcBlockNumber: Number(transactionReceipt.blockNumber), + globalLogIndex: Number(logIndex), + polymerIndex, + mainnet: mainnet + }, + { timeout: 15_000 } + ); + const dat = response.data as { + proof: undefined | string; + polymerIndex: number; + }; + polymerIndex = dat.polymerIndex; + if (polymerIndex !== undefined) { + Solver.polymerRequestIndexByLog.set(polymerKey, polymerIndex); + } + if (dat.proof) { + proof = dat.proof; + break; + } + await Solver.sleep(waitMs); + } + if (proof) { + if (preHook) await preHook(sourceChain); + + const transactionHash = await walletClient.writeContract({ + chain: chainMap[sourceChain], + account: account(), + address: order.inputOracle, + abi: POLYMER_ORACLE_ABI, + functionName: "receiveMessage", + args: [`0x${proof.replace("0x", "")}`] + }); + const result = await clients[sourceChain].waitForTransactionReceipt({ + hash: transactionHash, + timeout: 120_000, + pollingInterval: 2_000 + }); + await Solver.persistReceipt(chainMap[sourceChain].id, transactionHash, result); + if (postHook) await postHook(); + return result; + } + throw new Error( + `Polymer proof unavailable for output on ${outputChain}. Try again after the fill attestation is indexed.` + ); + } else if (order.inputOracle === COIN_FILLER) { + const log = logs.find((log) => log.logIndex === logIndex)!; + if (preHook) await preHook(sourceChain); const transactionHash = await walletClient.writeContract({ chain: chainMap[sourceChain], account: account(), address: order.inputOracle, - abi: POLYMER_ORACLE_ABI, - functionName: "receiveMessage", - args: [`0x${proof.replace("0x", "")}`] + abi: COIN_FILLER_ABI, + functionName: "setAttestation", + args: [log.args.orderId, log.args.solver, log.args.timestamp, log.args.output] }); const result = await clients[sourceChain].waitForTransactionReceipt({ - hash: transactionHash + hash: transactionHash, + timeout: 120_000, + pollingInterval: 2_000 }); + await Solver.persistReceipt(chainMap[sourceChain].id, transactionHash, result); if (postHook) await postHook(); return result; } - } else if (order.inputOracle === COIN_FILLER) { - const log = logs.find((log) => log.logIndex === logIndex)!; - const transactionHash = await walletClient.writeContract({ - chain: chainMap[sourceChain], - account: account(), - address: order.inputOracle, - abi: COIN_FILLER_ABI, - functionName: "setAttestation", - args: [log.args.orderId, log.args.solver, log.args.timestamp, log.args.output] - }); + throw new Error( + `Unsupported input oracle ${order.inputOracle} for source chain ${sourceChain}.` + ); + })(); - const result = await clients[sourceChain].waitForTransactionReceipt({ - hash: transactionHash - }); - if (postHook) await postHook(); - return result; + Solver.validationInflight.set(validationKey, validationPromise); + try { + return await validationPromise; + } finally { + Solver.validationInflight.delete(validationKey); } }; } @@ -243,25 +326,40 @@ export class Solver { inputSettler, order }); - - const outputChain = getChainName(order.outputs[0].chainId); + if (fillTransactionHashes.length !== order.outputs.length) { + throw new Error( + `Fill transaction hash count (${fillTransactionHashes.length}) does not match output count (${order.outputs.length}).` + ); + } + for (let i = 0; i < fillTransactionHashes.length; i++) { + const hash = fillTransactionHashes[i]; + if (!hash || !hash.startsWith("0x") || hash.length !== 66) { + throw new Error(`Invalid fill tx hash at index ${i}: ${hash}`); + } + } const transactionReceipts = await Promise.all( - fillTransactionHashes.map((fth) => - clients[outputChain].getTransactionReceipt({ - hash: fth as `0x${string}` - }) + fillTransactionHashes.map((fth, i) => + Solver.getReceiptCachedOrRpc(order.outputs[i].chainId, fth as `0x${string}`) ) ); const blocks = await Promise.all( - transactionReceipts.map((r) => - clients[outputChain].getBlock({ + transactionReceipts.map((r, i) => { + const outputChain = getChainName(order.outputs[i].chainId); + return clients[outputChain].getBlock({ blockHash: r.blockHash - }) - ) + }); + }) ); const fillTimestamps = blocks.map((b) => b.timestamp); if (preHook) await preHook(sourceChain); + const expectedChainId = chainMap[sourceChain].id; + const connectedChainId = await walletClient.getChainId(); + if (connectedChainId !== expectedChainId) { + throw new Error( + `Wallet is on chain ${connectedChainId}, expected ${expectedChainId} (${sourceChain}) before finalise` + ); + } const solveParams = fillTimestamps.map((fillTimestamp) => { return { @@ -277,9 +375,25 @@ export class Solver { solveParams, signatures: orderContainer }); - const result = await clients[sourceChain].waitForTransactionReceipt({ - hash: transactionHash! - }); + if (!transactionHash) { + throw new Error( + `Finalise did not return a transaction hash for source chain ${sourceChain}.` + ); + } + let result; + try { + result = await clients[sourceChain].waitForTransactionReceipt({ + hash: transactionHash, + timeout: 120_000, + pollingInterval: 2_000 + }); + } catch (error) { + throw new Error( + `Timed out waiting for finalise tx receipt on ${sourceChain} for hash ${transactionHash}.`, + { cause: error as Error } + ); + } + await Solver.persistReceipt(chainMap[sourceChain].id, transactionHash, result); if (postHook) await postHook(); return result; }; diff --git a/src/lib/migrations.json b/src/lib/migrations.json index d769f1d..bbd71d7 100644 --- a/src/lib/migrations.json +++ b/src/lib/migrations.json @@ -28,5 +28,13 @@ "bps": true, "folderMillis": 1770810000000, "hash": "387946018137748e3a5dd66fca8a1da71ddabc4191a835a3254fad26c6b6d412" + }, + { + "sql": [ + "CREATE TABLE \"transaction_receipts\" (\n\t\"id\" text PRIMARY KEY NOT NULL,\n\t\"chain_id\" bigint NOT NULL,\n\t\"tx_hash\" text NOT NULL,\n\t\"receipt\" text NOT NULL,\n\t\"created_at\" bigint NOT NULL\n);\n" + ], + "bps": true, + "folderMillis": 1771060000000, + "hash": "8f048c5d64c44d6c89c970f44a52e1fdbaf3de8f4052145cfd90f50822ab95f1" } ] diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 60230ce..fee5b44 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -14,4 +14,12 @@ export const fillTransactions = pgTable("fill_transactions", { txHash: text("tx_hash").notNull() }); -export const schema = { intents, fillTransactions }; +export const transactionReceipts = pgTable("transaction_receipts", { + id: text("id").primaryKey(), + chainId: bigint("chain_id", { mode: "number" }).notNull(), + txHash: text("tx_hash").notNull(), + receipt: text("receipt").notNull(), + createdAt: bigint("created_at", { mode: "number" }).notNull() +}); + +export const schema = { intents, fillTransactions, transactionReceipts }; diff --git a/src/lib/screens/ConnectWallet.svelte b/src/lib/screens/ConnectWallet.svelte index a405dab..f41d17a 100644 --- a/src/lib/screens/ConnectWallet.svelte +++ b/src/lib/screens/ConnectWallet.svelte @@ -1,19 +1,23 @@ -
-
- onboard.connectWallet()} baseClass={["h-full w-full"]}> - {#snippet name()} - Connect Wallet - {/snippet} - {#snippet awaiting()} - Waiting for wallet... - {/snippet} - -
-
-
+ + onboard.connectWallet()} fullWidth baseClass={["h-full"]}> + {#snippet name()} + Connect Wallet + {/snippet} + {#snippet awaiting()} + Waiting for wallet... + {/snippet} + +
+
diff --git a/src/lib/screens/FillIntent.svelte b/src/lib/screens/FillIntent.svelte index d05cabe..44484bc 100644 --- a/src/lib/screens/FillIntent.svelte +++ b/src/lib/screens/FillIntent.svelte @@ -5,8 +5,7 @@ getChainName, getClient, getCoin, - type chain, - type WC + type chain } from "$lib/config"; import { bytes32ToAddress } from "$lib/utils/convert"; import { getOutputHash } from "$lib/utils/orderLib"; @@ -14,8 +13,12 @@ import { Solver } from "$lib/libraries/solver"; import { COIN_FILLER_ABI } from "$lib/abi/outputsettler"; import AwaitButton from "$lib/components/AwaitButton.svelte"; + import ScreenFrame from "$lib/components/ui/ScreenFrame.svelte"; + import SectionCard from "$lib/components/ui/SectionCard.svelte"; + import ChainActionRow from "$lib/components/ui/ChainActionRow.svelte"; + import TokenAmountChip from "$lib/components/ui/TokenAmountChip.svelte"; import store from "$lib/state.svelte"; - import { Intent, orderToIntent } from "$lib/libraries/intent"; + import { orderToIntent } from "$lib/libraries/intent"; import { compactTypes } from "$lib/utils/typedMessage"; import { hashStruct } from "viem"; @@ -36,6 +39,7 @@ let refreshValidation = $state(0); let autoScrolledOrderId = $state<`0x${string}` | null>(null); let fillRun = 0; + let fillStatuses = $state>({}); const postHookScroll = async () => { await postHook(); refreshValidation += 1; @@ -70,15 +74,12 @@ } return arrMap; } - - const filledStatusPromises: [bigint, Promise<`0x${string}`>[]][] = $derived( - sortOutputsByChain(orderContainer).map(([c, outputs]) => [ - c, - outputs.map((output) => - isFilled(orderToIntent(orderContainer).orderId(), output, refreshValidation) - ) - ]) - ); + const outputKey = (output: MandateOutput) => + hashStruct({ + data: output, + types: compactTypes, + primaryType: "MandateOutput" + }); $effect(() => { refreshValidation; @@ -90,10 +91,15 @@ if (outputs.length === 0) return; const currentRun = ++fillRun; - Promise.all(outputs.map((output) => isFilled(orderId, output, refreshValidation))) - .then((fillResults) => { + Promise.all( + outputs.map(async (output) => [outputKey(output), await isFilled(orderId, output)] as const) + ) + .then((entries) => { if (currentRun !== fillRun) return; - if (!fillResults.every((result) => result !== BYTES32_ZERO)) return; + const nextStatuses: Record = {}; + for (const [key, status] of entries) nextStatuses[key] = status; + fillStatuses = nextStatuses; + if (!entries.every(([, result]) => result !== BYTES32_ZERO)) return; autoScrolledOrderId = orderId; scroll(4)(); }) @@ -119,98 +125,79 @@ }; -
-

Fill Intent

-

- Fill each chain once and continue to the right. If you refreshed the page provide your fill tx - hash in the input box. -

-
- {#each sortOutputsByChain(orderContainer) as chainIdAndOutputs, c} -

- {getChainName(chainIdAndOutputs[0])} -

-
-
- {#await Promise.all(filledStatusPromises[c][1])} - - {:then filledStatus} - v == BYTES32_ZERO) - ? fillWrapper( - chainIdAndOutputs[1], - Solver.fill( - store.walletClient, - { - orderContainer, - outputs: chainIdAndOutputs[1] - }, - { - preHook, - postHook: postHookScroll, - account - } - ) - ) - : async () => {}} - > - {#snippet name()} - Fill - {/snippet} - {#snippet awaiting()} - Waiting for transaction... - {/snippet} - - {/await} - {#each chainIdAndOutputs[1] as output, i} - {#await filledStatusPromises[c][1][i]} -
-
-
-
- {formatTokenAmount( - output.amount, - getCoin({ - address: output.token, - chain: getChainName(output.chainId) - }).decimals - )} -
-
- {getCoin({ address: output.token, chain: getChainName(output.chainId) }).name} -
-
-
-
- {:then filled} -
-
-
-
- {formatTokenAmount( - output.amount, - getCoin({ - address: output.token, - chain: getChainName(output.chainId) - }).decimals - )} -
-
- {getCoin({ address: output.token, chain: getChainName(output.chainId) }).name} -
-
-
-
- {/await} - {/each} -
+ +
+ {#each sortOutputsByChain(orderContainer) as chainIdAndOutputs} + + + {#snippet action()} + {@const chainStatuses = chainIdAndOutputs[1].map( + (output) => fillStatuses[outputKey(output)] + )} + {#if chainStatuses.some((status) => status === undefined)} + + {:else} + v == BYTES32_ZERO) ? "default" : "muted"} + buttonFunction={chainStatuses.every((v) => v == BYTES32_ZERO) + ? fillWrapper( + chainIdAndOutputs[1], + Solver.fill( + store.walletClient, + { + orderContainer, + outputs: chainIdAndOutputs[1] + }, + { + preHook, + postHook: postHookScroll, + account + } + ) + ) + : async () => {}} + > + {#snippet name()} + Fill + {/snippet} + {#snippet awaiting()} + Waiting for transaction... + {/snippet} + + {/if} + {/snippet} + {#snippet chips()} + {#each chainIdAndOutputs[1] as output} + {@const filled = fillStatuses[outputKey(output)]} + + {/each} + {/snippet} + + {/each}
-
+ diff --git a/src/lib/screens/Finalise.svelte b/src/lib/screens/Finalise.svelte index 183f387..1a0f522 100644 --- a/src/lib/screens/Finalise.svelte +++ b/src/lib/screens/Finalise.svelte @@ -1,8 +1,12 @@ -
-

Finalise Intent

-

Finalise the order to receive the inputs.

- {#each orderToIntent(orderContainer).inputChains() as inputChain} -
-

- {getChainName(inputChain)} -

-
-
- {#await isClaimed(inputChain, orderContainer, refreshClaimed)} - - {:then isClaimed} - {#if isClaimed} - - {:else} - - store.fillTransactions[ - hashStruct({ - data: output, - types: compactTypes, - primaryType: "MandateOutput" - }) - ] as string - ) - }, - { - account, - preHook, - postHook: postHookRefreshValidate - } - )} - > - {#snippet name()} - Claim - {/snippet} - {#snippet awaiting()} - Waiting for transaction... - {/snippet} - - {/if} - {:catch} - - store.fillTransactions[ - hashStruct({ - data: output, - types: compactTypes, - primaryType: "MandateOutput" - }) - ] as string - ) - }, - { - account, - preHook, - postHook: postHookRefreshValidate - } - )} - > - {#snippet name()} - Claim - {/snippet} - {#snippet awaiting()} - Waiting for transaction... - {/snippet} - - {/await} -
- {#if "originChainId" in orderContainer.order} - {#each orderContainer.order.inputs as input} -
-
-
-
- {formatTokenAmount( - input[1], - getCoin({ - address: idToToken(input[0]), - chain: getChainName(orderContainer.order.originChainId) - }).decimals - )} -
-
- {getCoin({ - address: idToToken(input[0]), - chain: getChainName(orderContainer.order.originChainId) - }).name} -
-
-
-
- {/each} - {:else} - {#each orderContainer.order.inputs.find((v) => v.chainId === inputChain)?.inputs ?? [] as input} -
-
-
-
- {formatTokenAmount( - input[1], - getCoin({ - address: idToToken(input[0]), - chain: getChainName(inputChain) - }).decimals - )} -
-
- {getCoin({ - address: idToToken(input[0]), - chain: getChainName(inputChain) - }).name} -
-
-
-
- {/each} - {/if} -
+ +
+ {#if allFinalised} + + {/if} + {#if allFinalised} +
+
All inputs finalised
+
Intent fully solved.
-
- {/each} -
+ {/if} + {#each inputChains as inputChain} + + + {#snippet action()} + {@const isClaimedStatus = claimedByChain[inputChain.toString()]} + {#if isClaimedStatus === undefined} + + {:else if isClaimedStatus} + + {:else} + + store.fillTransactions[ + hashStruct({ + data: output, + types: compactTypes, + primaryType: "MandateOutput" + }) + ] as string + ) + }, + { + account, + preHook, + postHook: postHookRefreshValidate + } + )} + > + {#snippet name()} + Claim + {/snippet} + {#snippet awaiting()} + Waiting for transaction... + {/snippet} + + {/if} + {/snippet} + {#snippet chips()} + {#if "originChainId" in orderContainer.order} + {#each orderContainer.order.inputs as input} + + {/each} + {:else} + {#each orderContainer.order.inputs.find((v) => v.chainId === inputChain)?.inputs ?? [] as input} + + {/each} + {/if} + {/snippet} + + + {/each} +
+ + + diff --git a/src/lib/screens/IntentDescription.svelte b/src/lib/screens/IntentDescription.svelte index b0a43ef..0c463f5 100644 --- a/src/lib/screens/IntentDescription.svelte +++ b/src/lib/screens/IntentDescription.svelte @@ -3,9 +3,10 @@
-

Intent Description

- To fill an intent, you need to execute up to 4 transactions. This intent requires 3 transactions to - fill. +

Intent Description

+

+ To fill an intent, you may need to execute up to 4 transactions. This intent requires 3. +


  1. diff --git a/src/lib/screens/IntentList.svelte b/src/lib/screens/IntentList.svelte index e78495d..a8e71c6 100644 --- a/src/lib/screens/IntentList.svelte +++ b/src/lib/screens/IntentList.svelte @@ -10,6 +10,8 @@ formatRemaining, type TimedIntentRow } from "$lib/libraries/intentList"; + import ScreenFrame from "$lib/components/ui/ScreenFrame.svelte"; + import SectionCard from "$lib/components/ui/SectionCard.svelte"; import type { OrderContainer } from "../../types"; let { @@ -61,11 +63,12 @@ ); -
    -

    Select Intent To Solve

    -

    Click any row to open it in the fill flow.

    -
    -
    Active intents ({activeRows.length})
    + +
    {#each activeRows as row (row.orderId)}
    - -
    - Expired intents ({expiredRows.length}) -
    +
    +
    {#each expiredRows as row (row.orderId)}
    -
    -
    + + diff --git a/src/lib/screens/IssueIntent.svelte b/src/lib/screens/IssueIntent.svelte index afcd33d..d20598f 100644 --- a/src/lib/screens/IssueIntent.svelte +++ b/src/lib/screens/IssueIntent.svelte @@ -1,14 +1,10 @@ -
    -

    Intent Issuance

    -

    - Select assets for your intent along with the verifier for the intent. Then choose your desired - style of execution. Your intent will be sent to the LI.FI dev order server. -

    + {#if inputTokenSelectorActive} {/if} -
    -
    -

    You Pay

    - {#each abstractInputs as input, i} - - {/each} - {#if numInputChains > 1} -
    Multichain!
    - {/if} - {#if sameChain} -
    SameChain!
    - {/if} -
    -
    -
    -
    In
    -
    exchange
    -
    for
    -
    -
    -
    -

    You Receive

    - {#each store.outputTokens as outputToken} -
    -
    {outputToken.token.chain}
    + + {/each} + {#if numInputChains > 1} +
    Multichain
    + {/if} + {#if sameChain} +
    Same chain
    + {/if} +
    +
    +
    +
    In
    +
    exchange
    +
    for
    - - {/each} -
    -
    +
+
+

You Receive

+ {#each store.outputTokens as outputToken, i (`${outputToken.token.chain}-${outputToken.token.address}-${i}`)} + + {/each} +
+
+ -
- -
-
- {#if sameChain} - Verified by - - {:else} - Verified by - - {/if} -
-
- Exclusive For - -
+ +
+
+ Verifier + {#if sameChain} + + + + {:else} + + + + + {/if} +
+
+
+ Exclusive + +
+
+
- -
- {#if !allowanceCheck} - - {#snippet name()} - Set allowance - {/snippet} - {#snippet awaiting()} - Waiting for transaction... - {/snippet} - - {:else} -
- {#if !balanceCheckWallet} - - {:else if store.intentType === "escrow"} - - {#snippet name()} - Execute Open - {/snippet} - {#snippet awaiting()} - Waiting for transaction... - {/snippet} - - {/if} - {#if store.intentType === "compact" && store.allocatorId !== POLYMER_ALLOCATOR} - {#if !balanceCheckCompact} +
+ {#if !allowanceCheck} + + {#snippet name()} + Set allowance + {/snippet} + {#snippet awaiting()} + Waiting for transaction... + {/snippet} + + {:else} +
+ {#if !balanceCheckWallet} - {:else} - + {:else if store.intentType === "escrow"} + {#snippet name()} - Sign Order + Execute Open {/snippet} {#snippet awaiting()} Waiting for transaction... {/snippet} {/if} - {/if} -
+ {#if store.intentType === "compact" && store.allocatorId !== POLYMER_ALLOCATOR} + {#if !balanceCheckCompact} + + {:else} + + {#snippet name()} + Sign Order + {/snippet} + {#snippet awaiting()} + Waiting for transaction... + {/snippet} + + {/if} + {/if} +
+ {/if} +
+ {#if numInputChains > 1 && store.intentType !== "compact"} +

+ You'll need to open the order on {numInputChains} chains. Be prepared and do not interrupt the + process. +

{/if}
- {#if numInputChains > 1 && store.intentType !== "compact"} -

- You'll need to open the order on {numInputChains} chains. Be prepared and do not interrupt the - process. -

- {/if} -
+ diff --git a/src/lib/screens/ManageDeposit.svelte b/src/lib/screens/ManageDeposit.svelte index 9a6a523..283a72c 100644 --- a/src/lib/screens/ManageDeposit.svelte +++ b/src/lib/screens/ManageDeposit.svelte @@ -2,20 +2,17 @@ import { ALWAYS_OK_ALLOCATOR, POLYMER_ALLOCATOR, - INPUT_SETTLER_ESCROW_LIFI, - INPUT_SETTLER_COMPACT_LIFI, type chain, - type WC, type Token, coinList, - printToken, - getIndexOf, - type availableAllocators, - type availableInputSettlers, - type balanceQuery + printToken } from "$lib/config"; import BalanceField from "$lib/components/BalanceField.svelte"; import AwaitButton from "$lib/components/AwaitButton.svelte"; + import FormControl from "$lib/components/ui/FormControl.svelte"; + import SegmentedControl from "$lib/components/ui/SegmentedControl.svelte"; + import ScreenFrame from "$lib/components/ui/ScreenFrame.svelte"; + import SectionCard from "$lib/components/ui/SectionCard.svelte"; import { CompactLib } from "$lib/libraries/compactLib"; import { toBigIntWithDecimals } from "$lib/utils/convert"; import store from "$lib/state.svelte"; @@ -53,155 +50,141 @@ }); -
-

Assets Management

-

- Select input type for your intent and manage deposited tokens. When done, continue to the right. - If you want to be using TheCompact with signatures, ensure your tokens are deposited before you - continue. -

-
-

Network

- - -
-
-

Input Type

- - -
- {#if store.intentType === "compact"} -
-
-

Allocator

- - + +
+ +
+

Network

+ (store.mainnet = v === "mainnet")} + />
-
- - - of - {#if (manageAssetAction === "withdraw" ? store.compactBalances : store.balances)[token.chain]} - - {/if} - + + +
+

Input Type

+ (store.intentType = v as "compact" | "escrow")} + />
- - -
- {#if manageAssetAction === "withdraw"} - - {#snippet name()} - Withdraw - {/snippet} - {#snippet awaiting()} - Waiting for transaction... - {/snippet} - - {:else if allowance < inputAmount} - - {#snippet name()} - Set allowance - {/snippet} - {#snippet awaiting()} - Waiting for transaction... - {/snippet} - - {:else} - - {#snippet name()} - Execute deposit - {/snippet} - {#snippet awaiting()} - Waiting for transaction... - {/snippet} - - {/if} -
- - {:else} -
-

- The Escrow Input Settler does not have any asset management. Skip to the next step to select - which assets to use. In the future, this place will be updated to show your pending intents. -

-
- {/if} -
+
+ {#if store.intentType === "compact"} + +
+
+

Allocator

+ (store.allocatorId = v as typeof store.allocatorId)} + /> +
+
+ + + + + + of + {#if (manageAssetAction === "withdraw" ? store.compactBalances : store.balances)[token.chain]} + + {/if} + + {#each coinList(store.mainnet) as tkn, i} + + {/each} + +
+
+ {#if manageAssetAction === "withdraw"} + + {#snippet name()} + Withdraw + {/snippet} + {#snippet awaiting()} + Waiting for transaction... + {/snippet} + + {:else if allowance < inputAmount} + + {#snippet name()} + Set allowance + {/snippet} + {#snippet awaiting()} + Waiting for transaction... + {/snippet} + + {:else} + + {#snippet name()} + Execute deposit + {/snippet} + {#snippet awaiting()} + Waiting for transaction... + {/snippet} + + {/if} +
+
+
+ {:else} + +

+ The Escrow Input Settler does not have any asset management. Skip to the next step to + select which assets to use. In the future, this place will be updated to show your pending + intents. +

+
+ {/if} +
+
diff --git a/src/lib/screens/ReceiveMessage.svelte b/src/lib/screens/ReceiveMessage.svelte index 6c2a53c..211dfc5 100644 --- a/src/lib/screens/ReceiveMessage.svelte +++ b/src/lib/screens/ReceiveMessage.svelte @@ -7,6 +7,10 @@ import { POLYMER_ORACLE_ABI } from "$lib/abi/polymeroracle"; import { Solver } from "$lib/libraries/solver"; import AwaitButton from "$lib/components/AwaitButton.svelte"; + import ScreenFrame from "$lib/components/ui/ScreenFrame.svelte"; + import SectionCard from "$lib/components/ui/SectionCard.svelte"; + import ChainActionRow from "$lib/components/ui/ChainActionRow.svelte"; + import TokenAmountChip from "$lib/components/ui/TokenAmountChip.svelte"; import store from "$lib/state.svelte"; import { orderToIntent } from "$lib/libraries/intent"; import { compactTypes } from "$lib/utils/typedMessage"; @@ -30,10 +34,19 @@ let refreshValidation = $state(0); let autoScrolledOrderId = $state<`0x${string}` | null>(null); let validationRun = 0; + let validationStatuses = $state>({}); const postHookRefreshValidate = async () => { await postHook(); refreshValidation += 1; }; + const outputKey = (output: MandateOutput) => + hashStruct({ + data: output, + types: compactTypes, + primaryType: "MandateOutput" + }); + const validationKey = (inputChain: bigint, output: MandateOutput) => + `${inputChain.toString()}:${outputKey(output)}`; async function isValidated( orderId: `0x${string}`, @@ -121,9 +134,10 @@ return; const currentRun = ++validationRun; - Promise.all( - inputChains.flatMap((inputChain) => - outputs.map((output, outputIndex) => + const pairs = inputChains.flatMap((inputChain) => + outputs.map((output, outputIndex) => ({ + key: validationKey(inputChain, output), + run: () => isValidated( orderId, inputChain, @@ -132,12 +146,15 @@ fillTxHashes[outputIndex] as `0x${string}`, refreshValidation ) - ) - ) - ) - .then((validationResults) => { + })) + ); + Promise.all(pairs.map(async (pair) => [pair.key, await pair.run()] as const)) + .then((entries) => { if (currentRun !== validationRun) return; - if (validationResults.length === 0 || !validationResults.every(Boolean)) return; + const nextStatuses: Record = {}; + for (const [key, validated] of entries) nextStatuses[key] = validated; + validationStatuses = nextStatuses; + if (entries.length === 0 || !entries.every(([, validated]) => validated)) return; autoScrolledOrderId = orderId; scroll(5)(); }) @@ -145,99 +162,81 @@ }); -
-

Submit Proof of Fill

-

- Click on each output and wait until they turn green. Polymer does not support batch validation. - Continue to the right. -

- {#each orderToIntent(orderContainer).inputChains() as inputChain} -
-

- {getChainName(inputChain)} -

-
-
- {#each orderContainer.order.outputs as output} - {#await isValidated(orderToIntent(orderContainer).orderId(), inputChain, orderContainer, output, store.fillTransactions[hashStruct( { data: output, types: compactTypes, primaryType: "MandateOutput" } )], refreshValidation)} -
-
-
-
- {formatTokenAmount( - output.amount, - getCoin({ address: output.token, chain: getChainName(output.chainId) }) - .decimals - )} -
-
- {getCoin({ address: output.token, chain: getChainName(output.chainId) }).name} -
-
-
-
- {:then validated} - - {#snippet name()} -
-
- {formatTokenAmount( - output.amount, - getCoin({ address: output.token, chain: getChainName(output.chainId) }) - .decimals - )} -
-
- {getCoin({ address: output.token, chain: getChainName(output.chainId) }).name} -
-
- {/snippet} - {#snippet awaiting()} -
-
+ +
+ {#each orderToIntent(orderContainer).inputChains() as inputChain} + + + {#snippet action()} +
Validate outputs
+ {/snippet} + {#snippet chips()} + {#each orderContainer.order.outputs as output} + {@const status = validationStatuses[validationKey(inputChain, output)]} + {#if status === undefined} + + {:else} + {} + : Solver.validate( + store.walletClient, + { + output, + orderContainer, + fillTransactionHash: + store.fillTransactions[ + hashStruct({ + data: output, + types: compactTypes, + primaryType: "MandateOutput" + }) + ], + sourceChain: getChainName(inputChain), + mainnet: store.mainnet + }, + { + preHook, + postHook: postHookRefreshValidate, + account + } + )} + > + {#snippet name()} {formatTokenAmount( output.amount, getCoin({ address: output.token, chain: getChainName(output.chainId) }) .decimals )} -
-
- {getCoin({ address: output.token, chain: getChainName(output.chainId) }).name} -
-
- {/snippet} - - {/await} - {/each} -
-
- {/each} -
+   + {getCoin({ + address: output.token, + chain: getChainName(output.chainId) + }).name.toUpperCase()} + {/snippet} + {#snippet awaiting()} + Validating... + {/snippet} + + {/if} + {/each} + {/snippet} + + + {/each} +
+ diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index 53e6af9..37048e6 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -10,9 +10,7 @@ import { INPUT_SETTLER_ESCROW_LIFI, MULTICHAIN_INPUT_SETTLER_COMPACT, MULTICHAIN_INPUT_SETTLER_ESCROW, - POLYMER_ALLOCATOR, type availableAllocators, - type availableInputSettlers, type chain, type Token, type Verifier @@ -22,9 +20,14 @@ import onboard from "./utils/web3-onboard"; import { createWalletClient, custom } from "viem"; import { browser } from "$app/environment"; import { initDb, db } from "./db"; -import { intents, fillTransactions as fillTransactionsTable } from "./schema"; -import { eq } from "drizzle-orm"; +import { + intents, + fillTransactions as fillTransactionsTable, + transactionReceipts as transactionReceiptsTable +} from "./schema"; +import { and, eq } from "drizzle-orm"; import { orderToIntent } from "./libraries/intent"; +import { getOrFetchRpc, invalidateRpcPrefix } from "./libraries/rpcCache"; export type TokenContext = { token: Token; @@ -35,7 +38,6 @@ class Store { mainnet = $state(true); orders = $state([]); - // Load orders from PGLite/Drizzle-backed DB instead of only keeping them in memory async loadOrdersFromDb() { if (!browser) return; if (!db) await initDb(); @@ -57,25 +59,18 @@ class Store { .insert(intents) .values({ id, - orderId: orderId, + orderId, intentType, data: JSON.stringify(order), createdAt: now }) .onConflictDoUpdate({ target: intents.orderId, - set: { - intentType, - data: JSON.stringify(order) - } + set: { intentType, data: JSON.stringify(order) } }); - // Update in-memory too const idx = this.orders.findIndex((o) => orderToIntent(o).orderId() === orderId); - if (idx >= 0) { - this.orders[idx] = order; - } else { - this.orders.push(order); - } + if (idx >= 0) this.orders[idx] = order; + else this.orders.push(order); } async deleteOrderFromDb(orderId: string) { @@ -92,9 +87,7 @@ class Store { if (!db) return; const rows = await db!.select().from(fillTransactionsTable); const loaded: { [outputId: string]: `0x${string}` } = {}; - for (const row of rows) { - loaded[row.outputHash] = row.txHash as `0x${string}`; - } + for (const row of rows) loaded[row.outputHash] = row.txHash as `0x${string}`; this.fillTransactions = loaded; } @@ -120,86 +113,193 @@ class Store { } } - // --- Wallet --- // + async loadTransactionReceiptsFromDb() { + if (!browser) return; + if (!db) await initDb(); + if (!db) return; + const rows = await db!.select().from(transactionReceiptsTable); + const loaded: Record = {}; + for (const row of rows) { + loaded[`${row.chainId}:${row.txHash}`] = row.receipt; + } + this.transactionReceipts = loaded; + } + + async saveTransactionReceipt(chainId: number | bigint, txHash: `0x${string}`, receipt: unknown) { + if (!browser) return; + if (!db) await initDb(); + if (!db) return; + const chainIdNumber = Number(chainId); + const serializedReceipt = JSON.stringify(receipt, (_key, value) => + typeof value === "bigint" ? value.toString() : value + ); + const existing = await db! + .select() + .from(transactionReceiptsTable) + .where( + and( + eq(transactionReceiptsTable.chainId, chainIdNumber), + eq(transactionReceiptsTable.txHash, txHash) + ) + ); + if (existing.length > 0) { + await db! + .update(transactionReceiptsTable) + .set({ receipt: serializedReceipt }) + .where( + and( + eq(transactionReceiptsTable.chainId, chainIdNumber), + eq(transactionReceiptsTable.txHash, txHash) + ) + ); + } else { + await db!.insert(transactionReceiptsTable).values({ + id: typeof crypto !== "undefined" ? crypto.randomUUID() : String(Date.now()), + chainId: chainIdNumber, + txHash, + receipt: serializedReceipt, + createdAt: Date.now() + }); + } + this.transactionReceipts[`${chainIdNumber}:${txHash}`] = serializedReceipt; + } + + getTransactionReceipt(chainId: number | bigint, txHash: `0x${string}`) { + const serialized = this.transactionReceipts[`${Number(chainId)}:${txHash}`]; + if (!serialized) return undefined; + try { + return JSON.parse(serialized) as unknown; + } catch (error) { + console.warn("parse cached transaction receipt failed", { + chainId: Number(chainId), + txHash, + error + }); + return undefined; + } + } + wallets = onboard.state.select("wallets"); activeWallet = $state<{ wallet?: WalletState }>({}); connectedAccount = $derived(this.activeWallet.wallet?.accounts?.[0]); walletClient = $derived( this.activeWallet?.wallet?.provider - ? createWalletClient({ - transport: custom(this.activeWallet?.wallet?.provider) - }) + ? createWalletClient({ transport: custom(this.activeWallet.wallet.provider) }) : undefined )!; - // --- Token --- // inputTokens = $state([]); outputTokens = $state([]); - - // inputTokens = $state([]); - // outputTokens = $state([]); - // inputAmounts = $state([1000000n]); - // outputAmounts = $state([1000000n]); - fillTransactions = $state<{ [outputId: string]: `0x${string}` }>({}); + transactionReceipts = $state>({}); + + refreshEpoch = $state(0); + rpcRefreshMs = 45_000; + _rpcRefreshHandle?: ReturnType; balances = $derived.by(() => { - return this.mapOverCoins(getBalance, this.mainnet, this.updatedDerived); + this.refreshEpoch; + const account = this.connectedAccount?.address; + return this.mapOverCoinsCached({ + bucket: "balance", + ttlMs: 30_000, + isMainnet: this.mainnet, + scopeKey: account ?? "none", + fetcher: (asset, client) => getBalance(account, asset, client) + }); }); + allowances = $derived.by(() => { - return this.mapOverCoins( - getAllowance( - this.inputSettler === INPUT_SETTLER_COMPACT_LIFI || - this.inputSettler === MULTICHAIN_INPUT_SETTLER_COMPACT - ? COMPACT - : this.inputSettler - ), - this.mainnet, - this.updatedDerived - ); + this.refreshEpoch; + const account = this.connectedAccount?.address; + const spender = + this.inputSettler === INPUT_SETTLER_COMPACT_LIFI || + this.inputSettler === MULTICHAIN_INPUT_SETTLER_COMPACT + ? COMPACT + : this.inputSettler; + return this.mapOverCoinsCached({ + bucket: "allowance", + ttlMs: 60_000, + isMainnet: this.mainnet, + scopeKey: `${account ?? "none"}:${spender}`, + fetcher: (asset, client) => getAllowance(spender)(account, asset, client) + }); }); + compactBalances = $derived.by(() => { - return this.mapOverCoins( - ( - user: `0x${string}` | undefined, - asset: `0x${string}`, - client: (typeof clients)[keyof typeof clients] - ) => getCompactBalance(user, asset, client, this.allocatorId), - this.mainnet, - this.updatedDerived - ); + this.refreshEpoch; + const account = this.connectedAccount?.address; + const allocatorId = this.allocatorId; + return this.mapOverCoinsCached({ + bucket: "compact", + ttlMs: 60_000, + isMainnet: this.mainnet, + scopeKey: `${account ?? "none"}:${allocatorId}`, + fetcher: (asset, client) => getCompactBalance(account, asset, client, allocatorId) + }); }); multichain = $derived([...new Set(this.inputTokens.map((i) => i.token.chain))].length > 1); - // --- Input Side --- // - // TODO: remove inputSettler = $derived.by(() => { if (this.intentType === "escrow" && !this.multichain) return INPUT_SETTLER_ESCROW_LIFI; if (this.intentType === "escrow" && this.multichain) return MULTICHAIN_INPUT_SETTLER_ESCROW; - if (this.intentType === "compact" && !this.multichain) return INPUT_SETTLER_COMPACT_LIFI; if (this.intentType === "compact" && this.multichain) return MULTICHAIN_INPUT_SETTLER_COMPACT; - return INPUT_SETTLER_ESCROW_LIFI; }); intentType = $state<"escrow" | "compact">("escrow"); allocatorId = $state(ALWAYS_OK_ALLOCATOR); - - // --- Oracle --- // verifier = $state("polymer"); - - // --- Output Side --- // exclusiveFor: string = $state(""); - // --- Misc --- // - updatedDerived = $state(0); + invalidateWalletReadCache(scope: "all" | "balance" | "allowance" | "compact" = "all") { + if (scope === "all" || scope === "balance") invalidateRpcPrefix("balance:"); + if (scope === "all" || scope === "allowance") invalidateRpcPrefix("allowance:"); + if (scope === "all" || scope === "compact") invalidateRpcPrefix("compact:"); + } + + refreshWalletReads(opts?: { + force?: boolean; + scope?: "all" | "balance" | "allowance" | "compact"; + }) { + const force = opts?.force ?? false; + const scope = opts?.scope ?? "all"; + if (force) this.invalidateWalletReadCache(scope); + this.refreshEpoch += 1; + } + + refreshTokenBalance(token: Token, force = true) { + if (force) { + invalidateRpcPrefix( + `balance:${this.mainnet ? "mainnet" : "testnet"}:${token.chain}:${token.address}:` + ); + } + this.refreshEpoch += 1; + } + + refreshTokenAllowance(token: Token, force = true) { + if (force) { + invalidateRpcPrefix( + `allowance:${this.mainnet ? "mainnet" : "testnet"}:${token.chain}:${token.address}:` + ); + } + this.refreshEpoch += 1; + } + + refreshCompactBalance(token: Token, force = true) { + if (force) { + invalidateRpcPrefix( + `compact:${this.mainnet ? "mainnet" : "testnet"}:${token.chain}:${token.address}:` + ); + } + this.refreshEpoch += 1; + } forceUpdate = () => { - this.updatedDerived += 1; + this.refreshWalletReads({ force: true, scope: "all" }); }; - // Background sync settings syncIntervalMs = 5000; _syncHandle?: ReturnType; @@ -217,6 +317,21 @@ class Store { } } + startRpcRefreshLoop(intervalMs?: number) { + if (!browser) return; + this.stopRpcRefreshLoop(); + this._rpcRefreshHandle = setInterval(() => { + this.refreshWalletReads(); + }, intervalMs ?? this.rpcRefreshMs); + } + + stopRpcRefreshLoop() { + if (this._rpcRefreshHandle) { + clearInterval(this._rpcRefreshHandle); + this._rpcRefreshHandle = undefined; + } + } + async setWalletToCorrectChain(chain: chain) { try { return await this.walletClient?.switchChain({ id: chainMap[chain].id }); @@ -229,23 +344,22 @@ class Store { } } - mapOverCoins( - func: ( - user: `0x${string}` | undefined, - asset: `0x${string}`, - client: (typeof clients)[keyof typeof clients] - ) => T, - isMainnet: boolean, - _: any - ) { - const resolved: Record> = {} as any; + mapOverCoinsCached(opts: { + bucket: "balance" | "allowance" | "compact"; + ttlMs: number; + isMainnet: boolean; + scopeKey: string; + fetcher: (asset: `0x${string}`, client: (typeof clients)[keyof typeof clients]) => Promise; + }) { + const { bucket, ttlMs, isMainnet, scopeKey, fetcher } = opts; + const resolved: Record>> = {} as any; for (const token of coinList(isMainnet)) { - // Check whether we have me the chain before. if (!resolved[token.chain as chain]) resolved[token.chain] = {}; - resolved[token.chain][token.address] = func( - this.connectedAccount?.address, - token.address, - clients[token.chain] + const key = `${bucket}:${isMainnet ? "mainnet" : "testnet"}:${token.chain}:${token.address}:${scopeKey}`; + resolved[token.chain][token.address] = getOrFetchRpc( + key, + () => fetcher(token.address, clients[token.chain]), + { ttlMs } ); } return resolved; @@ -260,16 +374,17 @@ class Store { this.wallets.subscribe((v) => { this.activeWallet.wallet = v?.[0]; }); - setInterval(() => { - this.updatedDerived += 1; - }, 10000); - // Initial load from DB — expose as a promise so callers can await readiness + this.startRpcRefreshLoop(); + this.dbReady = browser ? Promise.all([ this.loadOrdersFromDb().catch((e) => console.warn("loadOrdersFromDb error", e)), this.loadFillTransactionsFromDb().catch((e) => console.warn("loadFillTransactionsFromDb error", e) + ), + this.loadTransactionReceiptsFromDb().catch((e) => + console.warn("loadTransactionReceiptsFromDb error", e) ) ]).then(() => {}) : Promise.resolve(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 80cfbbc..9a7d169 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -12,6 +12,7 @@ import ReceiveMessage from "$lib/screens/ReceiveMessage.svelte"; import Finalise from "$lib/screens/Finalise.svelte"; import ConnectWallet from "$lib/screens/ConnectWallet.svelte"; + import FlowStepTracker from "$lib/components/ui/FlowStepTracker.svelte"; import store from "$lib/state.svelte"; import { orderToIntent } from "$lib/libraries/intent"; @@ -102,84 +103,133 @@ const account = () => store.connectedAccount?.address!; let selectedOrder = $state(undefined); + let currentScreenIndex = $state(0); + let scrollStepProgress = $state(0); let snapContainer: HTMLDivElement; + function getScreenWidth() { + if (!snapContainer) return 0; + return snapContainer.clientWidth + 1; + } + + function getMaxScreenIndex() { + if (!snapContainer) return 0; + const width = getScreenWidth(); + return Math.max(Math.ceil(snapContainer.scrollWidth / width) - 1, 0); + } + + function updateCurrentScreenIndex() { + if (!snapContainer) return; + const width = getScreenWidth(); + const maxScreenIndex = getMaxScreenIndex(); + const rawIndex = snapContainer.scrollLeft / width; + scrollStepProgress = Math.max(0, Math.min(rawIndex, maxScreenIndex)); + currentScreenIndex = Math.round(scrollStepProgress); + } + + function goToScreen(index: number) { + if (!snapContainer) return; + const width = getScreenWidth(); + const maxScreenIndex = getMaxScreenIndex(); + const targetScreenIndex = Math.max(0, Math.min(index, maxScreenIndex)); + currentScreenIndex = targetScreenIndex; + scrollStepProgress = targetScreenIndex; + snapContainer.scrollTo({ + left: targetScreenIndex * width, + behavior: "smooth" + }); + } + function scroll(next: boolean | number) { return () => { if (!snapContainer) return; - const width = snapContainer.clientWidth + 1; - const maxScreenIndex = Math.max(Math.ceil(snapContainer.scrollWidth / width) - 1, 0); - const currentScreenIndex = Math.round(snapContainer.scrollLeft / width); + updateCurrentScreenIndex(); + const maxScreenIndex = getMaxScreenIndex(); const targetScreenIndex = typeof next === "number" ? Math.max(0, Math.min(next, maxScreenIndex)) : Math.max(0, Math.min(currentScreenIndex + (next ? 1 : -1), maxScreenIndex)); - snapContainer.scrollTo({ - left: targetScreenIndex * width, - behavior: "smooth" - }); + goToScreen(targetScreenIndex); }; }
-

+

Resource lock intents using OIF

-
- - -
- - +
+ + +
- Preview by LI.FI - - - {#if !(!store.connectedAccount || !store.walletClient)} - - - - {/if} -
- {#if !store.connectedAccount || !store.walletClient} - - {:else} - - - - {#if selectedOrder !== undefined} - - - - - {/if} + Preview by LI.FI + + + {#if !(!store.connectedAccount || !store.walletClient)} + + + + {/if} +
+ {#if !store.connectedAccount || !store.walletClient} + + {:else} + + + + {#if selectedOrder !== undefined} + + + + + {/if} + {/if} +
+ { + if (step.targetIndex === undefined) return; + goToScreen(step.targetIndex); + }} + />
diff --git a/tests/e2e/helpers/bootstrap.ts b/tests/e2e/helpers/bootstrap.ts new file mode 100644 index 0000000..798e97c --- /dev/null +++ b/tests/e2e/helpers/bootstrap.ts @@ -0,0 +1,13 @@ +import type { Page } from "@playwright/test"; + +export async function bootstrapConnectedWallet(page: Page) { + await page.evaluate(async () => { + const { default: store } = await import("/src/lib/state.svelte.ts"); + (store as any).activeWallet.wallet = { + accounts: [{ address: "0x1111111111111111111111111111111111111111" }], + provider: { + request: async () => null + } + }; + }); +} diff --git a/tests/e2e/issuance.spec.ts b/tests/e2e/issuance.spec.ts new file mode 100644 index 0000000..1209982 --- /dev/null +++ b/tests/e2e/issuance.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test"; +import { mockQuoteResponse } from "../fixtures/mockQuote"; +import { bootstrapConnectedWallet } from "./helpers/bootstrap"; + +test.beforeEach(async ({ page }) => { + await page.route("**/quote/request", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mockQuoteResponse) + }); + }); + + await page.goto("/"); + await bootstrapConnectedWallet(page); +}); + +test("asset management controls remain interactive", async ({ page }) => { + await expect(page.getByRole("heading", { name: "Assets Management" })).toBeVisible(); + + await page.getByTestId("network-mainnet").click(); + await page.getByTestId("network-testnet").click(); + await page.getByTestId("intent-type-compact").click(); + await page.getByTestId("intent-type-escrow").click(); + await page.getByTestId("intent-type-compact").click(); + + await expect(page.getByTestId("allocator-116450367070547927622991121")).toBeVisible(); + await page.getByTestId("allocator-116450367070547927622991121").click(); +}); + +test("input/output modals open and save in issuance screen", async ({ page }) => { + await page.getByRole("button", { name: "→" }).click(); + await expect(page.getByRole("heading", { name: "Intent Issuance" })).toBeVisible(); + + await page.getByTestId("open-input-modal-0").click(); + await expect(page.getByTestId("input-token-modal")).toBeVisible(); + await page.getByTestId("input-token-modal-save").click(); + await expect(page.getByTestId("input-token-modal")).toBeHidden(); + + await page.getByTestId("open-output-modal-0").click(); + await expect(page.getByTestId("output-token-modal")).toBeVisible(); + await page.getByTestId("output-token-add").click(); + await page.getByTestId("output-token-modal-save").click(); + await expect(page.getByTestId("output-token-modal")).toBeHidden(); + + await page.getByTestId("quote-button").click(); + await expect(page.getByTestId("quote-button")).toBeVisible(); +}); diff --git a/tests/fixtures/mockQuote.ts b/tests/fixtures/mockQuote.ts new file mode 100644 index 0000000..458da7a --- /dev/null +++ b/tests/fixtures/mockQuote.ts @@ -0,0 +1,24 @@ +export const mockQuoteResponse = { + quotes: [ + { + order: null, + eta: null, + validUntil: Date.now() + 30_000, + quoteId: null, + metadata: { exclusiveFor: "0x0000000000000000000000000000000000000000" }, + preview: { + inputs: [], + outputs: [ + { + receiver: "0x0000000000000000000000000000000000000000", + asset: "0x0000000000000000000000000000000000000000", + amount: "1000000" + } + ] + }, + provider: null, + partialFill: false, + failureHandling: "refund-automatic" + } + ] +}; diff --git a/tests/unit/assetSelection.test.ts b/tests/unit/assetSelection.test.ts new file mode 100644 index 0000000..0fee6c5 --- /dev/null +++ b/tests/unit/assetSelection.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test"; +import { AssetSelection } from "../../src/lib/libraries/assetSelection"; + +describe("AssetSelection", () => { + it("picks largest-first values to satisfy goal", () => { + const selector = new AssetSelection({ + goal: 7n, + values: [4n, 4n, 3n] + }).largest(); + + expect(selector.asValues()).toEqual([4n, 3n, 0n]); + }); + + it("picks smallest-first values to satisfy goal", () => { + const selector = new AssetSelection({ + goal: 7n, + values: [4n, 4n, 3n] + }).smallest(); + + expect(selector.asValues()).toEqual([4n, 0n, 3n]); + }); + + it("throws when goal is infeasible", () => { + expect(() => new AssetSelection({ goal: 10n, values: [2n, 3n] })).toThrow(); + }); +}); diff --git a/tests/unit/intentList.test.ts b/tests/unit/intentList.test.ts new file mode 100644 index 0000000..2bdc519 --- /dev/null +++ b/tests/unit/intentList.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "bun:test"; +import { + EXPIRING_THRESHOLD_SECONDS, + formatRelativeDeadline, + formatRemaining, + withTiming, + type BaseIntentRow +} from "../../src/lib/libraries/intentList"; + +const baseRow: BaseIntentRow = { + orderContainer: { + inputSettler: "0x000025c3226C00B2Cdc200005a1600509f4e00C0", + order: { + user: "0x1111111111111111111111111111111111111111", + nonce: 1n, + originChainId: 8453n, + expires: Math.floor(Date.now() / 1000) + 3600, + fillDeadline: Math.floor(Date.now() / 1000) + 3600, + inputOracle: "0x0000003E06000007A224AeE90052fA6bb46d43C9", + inputs: [[1n, 1n]], + outputs: [ + { + oracle: "0x0000000000000000000000000000000000000000000000000000000000000001", + settler: "0x0000000000000000000000000000000000000000000000000000000000000002", + chainId: 42161n, + token: "0x0000000000000000000000000000000000000000000000000000000000000003", + amount: 1n, + recipient: "0x0000000000000000000000000000000000000004", + callbackData: "0x", + context: "0x00" + } + ] + }, + sponsorSignature: { type: "None", payload: "0x" }, + allocatorSignature: { type: "None", payload: "0x" } + }, + orderId: "0xabc", + orderIdShort: "0xabc", + userShort: "0x1111...1111", + fillDeadline: Math.floor(Date.now() / 1000) + 3600, + inputCount: 1, + outputCount: 1, + chainScope: "singlechain", + chainScopeBadge: "SingleChain", + inputChips: [], + inputOverflow: 0, + outputChips: [], + outputOverflow: 0 +}; + +describe("intentList timing and formatting", () => { + it("marks expired rows", () => { + const row = withTiming(baseRow, baseRow.fillDeadline + 1); + expect(row.status).toBe("expired"); + }); + + it("marks expiring rows", () => { + const now = baseRow.fillDeadline - EXPIRING_THRESHOLD_SECONDS + 1; + const row = withTiming(baseRow, now); + expect(row.status).toBe("expiring"); + }); + + it("formats remaining/relative deadline values", () => { + expect(formatRemaining(59)).toBe("59s"); + expect(formatRemaining(180)).toBe("3m"); + expect(formatRelativeDeadline(30)).toBe("in 30s"); + expect(formatRelativeDeadline(-30)).toBe("30s ago"); + }); +}); diff --git a/tests/unit/orderLib.test.ts b/tests/unit/orderLib.test.ts new file mode 100644 index 0000000..8649309 --- /dev/null +++ b/tests/unit/orderLib.test.ts @@ -0,0 +1,58 @@ +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"; + +const b32 = (byte: string) => `0x${byte.repeat(64)}` as `0x${string}`; + +const output: MandateOutput = { + oracle: b32("1"), + settler: b32("2"), + chainId: BigInt(chainMap.arbitrum.id), + token: b32("3"), + amount: 1n, + recipient: b32("4"), + callbackData: "0x", + context: "0x00" +}; + +function makeOrder(overrides: Partial = {}): StandardOrder { + return { + user: "0x1111111111111111111111111111111111111111", + nonce: 1n, + originChainId: BigInt(chainMap.ethereum.id), + expires: Math.floor(Date.now() / 1000) + 1000, + fillDeadline: Math.floor(Date.now() / 1000) + 1000, + inputOracle: "0x0000000000000000000000000000000000000001", + inputs: [[1n, 1n]], + outputs: [output], + ...overrides + }; +} + +describe("orderLib", () => { + it("produces stable output hashes", () => { + const h1 = getOutputHash(output); + const h2 = getOutputHash(output); + expect(h1).toBe(h2); + }); + + it("changes hash when output amount changes", () => { + const h1 = getOutputHash(output); + const h2 = getOutputHash({ ...output, amount: output.amount + 1n }); + 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 + }); + expect(validateOrder(expired)).toBe(false); + }); + + it("rejects orders with multiple outputs", () => { + const multiOutput = makeOrder({ outputs: [output, { ...output, amount: 2n }] }); + expect(validateOrder(multiOutput)).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 39d17a9..f7ccfe4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,8 @@ "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext" - } + }, + "exclude": ["tests/e2e", "playwright.config.ts"] // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files //