From 9ddd943802597ed99c48fe2179d6cbda81a2a24a Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 13 Feb 2026 09:05:46 +0400 Subject: [PATCH 1/7] Update the style --- src/lib/components/AwaitButton.svelte | 36 ++- src/lib/components/GetQuote.svelte | 18 +- src/lib/components/InputTokenModal.svelte | 184 ++++++----- src/lib/components/OutputTokenModal.svelte | 120 +++---- src/lib/components/ui/FieldRow.svelte | 33 ++ src/lib/components/ui/FormControl.svelte | 57 ++++ src/lib/components/ui/InlineMetaField.svelte | 27 ++ src/lib/components/ui/SegmentedControl.svelte | 38 +++ src/lib/screens/ConnectWallet.svelte | 6 +- src/lib/screens/FillIntent.svelte | 4 +- src/lib/screens/Finalise.svelte | 10 +- src/lib/screens/IntentDescription.svelte | 7 +- src/lib/screens/IntentList.svelte | 6 +- src/lib/screens/IssueIntent.svelte | 299 +++++++++--------- src/lib/screens/ManageDeposit.svelte | 101 +++--- src/lib/screens/ReceiveMessage.svelte | 4 +- src/routes/+page.svelte | 2 +- 17 files changed, 582 insertions(+), 370 deletions(-) create mode 100644 src/lib/components/ui/FieldRow.svelte create mode 100644 src/lib/components/ui/FormControl.svelte create mode 100644 src/lib/components/ui/InlineMetaField.svelte create mode 100644 src/lib/components/ui/SegmentedControl.svelte diff --git a/src/lib/components/AwaitButton.svelte b/src/lib/components/AwaitButton.svelte index efac501..1837075 100644 --- a/src/lib/components/AwaitButton.svelte +++ b/src/lib/components/AwaitButton.svelte @@ -5,41 +5,51 @@ 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", + baseClass = [], + hoverClass = [], + lazyClass = [] }: { name: Snippet; awaiting: Snippet; - buttonFunction: () => Promise; + buttonFunction: () => Promise; + size?: "sm" | "md"; 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 defaultBase = $derived([ + sizeClass, + "rounded border border-gray-200 bg-white font-semibold text-gray-700" + ]); + const defaultHover = ["hover:border-sky-300 hover:text-sky-700"]; + const defaultLazy = ["cursor-not-allowed text-gray-400"]; + let buttonPromise: Promise | undefined = $state(); {#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..440fdd5 100644 --- a/src/lib/components/GetQuote.svelte +++ b/src/lib/components/GetQuote.svelte @@ -100,27 +100,31 @@ 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..4b353f8 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..4b8d5ac 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/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/FormControl.svelte b/src/lib/components/ui/FormControl.svelte new file mode 100644 index 0000000..e789796 --- /dev/null +++ b/src/lib/components/ui/FormControl.svelte @@ -0,0 +1,57 @@ + + +{#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/SegmentedControl.svelte b/src/lib/components/ui/SegmentedControl.svelte new file mode 100644 index 0000000..82a81d4 --- /dev/null +++ b/src/lib/components/ui/SegmentedControl.svelte @@ -0,0 +1,38 @@ + + +
+ {#each options as option, i (option.value)} + + {/each} +
diff --git a/src/lib/screens/ConnectWallet.svelte b/src/lib/screens/ConnectWallet.svelte index a405dab..67133f5 100644 --- a/src/lib/screens/ConnectWallet.svelte +++ b/src/lib/screens/ConnectWallet.svelte @@ -5,7 +5,11 @@
-
+

Connect Wallet

+

+ Connect your wallet to continue with intent issuance and filling. +

+
onboard.connectWallet()} baseClass={["h-full w-full"]}> {#snippet name()} Connect Wallet diff --git a/src/lib/screens/FillIntent.svelte b/src/lib/screens/FillIntent.svelte index d05cabe..c6c83d9 100644 --- a/src/lib/screens/FillIntent.svelte +++ b/src/lib/screens/FillIntent.svelte @@ -120,8 +120,8 @@
-

Fill Intent

-

+

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.

diff --git a/src/lib/screens/Finalise.svelte b/src/lib/screens/Finalise.svelte index 183f387..1a67504 100644 --- a/src/lib/screens/Finalise.svelte +++ b/src/lib/screens/Finalise.svelte @@ -93,8 +93,10 @@
-

Finalise Intent

-

Finalise the order to receive the inputs.

+

Finalise Intent

+

+ Finalise the order to receive the input assets. +

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

@@ -105,7 +107,7 @@ {#await isClaimed(inputChain, orderContainer, refreshClaimed)} - {/each} - {#if numInputChains > 1} -
Multichain!
- {/if} - {#if sameChain} -
SameChain!
- {/if} -

-
-
-
In
-
exchange
-
for
+
+
+
Intent pair
+
+
-
-

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} +
+
+
+
+
+
+ Verifier + {#if sameChain} + + + + {:else} + + + + + {/if} +
+
+
+ Exclusive + +
+
-
- -
- -
-
- {#if sameChain} - Verified by - - {:else} - Verified by - - {/if} -
-
- Exclusive For - -
- -
- {#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..f3fd7a8 100644 --- a/src/lib/screens/ManageDeposit.svelte +++ b/src/lib/screens/ManageDeposit.svelte @@ -2,20 +2,15 @@ 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 { CompactLib } from "$lib/libraries/compactLib"; import { toBigIntWithDecimals } from "$lib/utils/convert"; import store from "$lib/state.svelte"; @@ -54,77 +49,53 @@
-

Assets Management

-

+

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

- - + (store.mainnet = v === "mainnet")} + />

Input Type

- - + (store.intentType = v as "compact" | "escrow")} + />
{#if store.intentType === "compact"} -
+

Allocator

- - + (store.allocatorId = v as typeof store.allocatorId)} + />
- - + + of {#if (manageAssetAction === "withdraw" ? store.compactBalances : store.balances)[token.chain]} {/if} - +
@@ -195,7 +166,7 @@ {/if}
-
+
{:else}

diff --git a/src/lib/screens/ReceiveMessage.svelte b/src/lib/screens/ReceiveMessage.svelte index 6c2a53c..6ac1c4d 100644 --- a/src/lib/screens/ReceiveMessage.svelte +++ b/src/lib/screens/ReceiveMessage.svelte @@ -146,8 +146,8 @@

-

Submit Proof of Fill

-

+

Submit Proof of Fill

+

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

diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 80cfbbc..8ba645d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -124,7 +124,7 @@
-

+

Resource lock intents using OIF

Date: Fri, 13 Feb 2026 10:26:07 +0400 Subject: [PATCH 2/7] automated tests by Codex --- .github/workflows/test.yml | 53 ++++++++++++++ .gitignore | 2 + README.md | 18 +++++ bun.lock | 9 +++ package.json | 8 ++- playwright.config.ts | 29 ++++++++ src/lib/components/GetQuote.svelte | 3 + src/lib/components/InputTokenModal.svelte | 3 + src/lib/components/OutputTokenModal.svelte | 5 ++ src/lib/components/ui/SegmentedControl.svelte | 5 +- src/lib/screens/IssueIntent.svelte | 4 +- src/lib/screens/ManageDeposit.svelte | 3 + tests/e2e/helpers/bootstrap.ts | 13 ++++ tests/e2e/issuance.spec.ts | 48 +++++++++++++ tests/fixtures/mockQuote.ts | 24 +++++++ tests/unit/assetSelection.test.ts | 26 +++++++ tests/unit/intentList.test.ts | 69 +++++++++++++++++++ tests/unit/orderLib.test.ts | 58 ++++++++++++++++ tsconfig.json | 3 +- 19 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 playwright.config.ts create mode 100644 tests/e2e/helpers/bootstrap.ts create mode 100644 tests/e2e/issuance.spec.ts create mode 100644 tests/fixtures/mockQuote.ts create mode 100644 tests/unit/assetSelection.test.ts create mode 100644 tests/unit/intentList.test.ts create mode 100644 tests/unit/orderLib.test.ts 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/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/GetQuote.svelte b/src/lib/components/GetQuote.svelte index 440fdd5..a15c382 100644 --- a/src/lib/components/GetQuote.svelte +++ b/src/lib/components/GetQuote.svelte @@ -103,6 +103,7 @@
{#await quoteRequest}
Quote @@ -115,6 +116,7 @@ style="width: {width}%" >
@@ -124,6 +126,7 @@ style="width: 100%" >
diff --git a/src/lib/components/InputTokenModal.svelte b/src/lib/components/InputTokenModal.svelte index 4b353f8..8719d8e 100644 --- a/src/lib/components/InputTokenModal.svelte +++ b/src/lib/components/InputTokenModal.svelte @@ -168,6 +168,7 @@
@@ -177,6 +178,7 @@

Choose token amount distribution across chains.

diff --git a/src/lib/components/OutputTokenModal.svelte b/src/lib/components/OutputTokenModal.svelte index 4b8d5ac..b84697a 100644 --- a/src/lib/components/OutputTokenModal.svelte +++ b/src/lib/components/OutputTokenModal.svelte @@ -68,6 +68,7 @@
@@ -77,6 +78,7 @@

Configure one or more destination token outputs.

{:then} {:catch}
-
+
Chain
Amount / Balance
diff --git a/src/lib/components/OutputTokenModal.svelte b/src/lib/components/OutputTokenModal.svelte index b84697a..80f3102 100644 --- a/src/lib/components/OutputTokenModal.svelte +++ b/src/lib/components/OutputTokenModal.svelte @@ -89,7 +89,7 @@
-
+
Chain
Amount
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/FormControl.svelte b/src/lib/components/ui/FormControl.svelte index e789796..1553054 100644 --- a/src/lib/components/ui/FormControl.svelte +++ b/src/lib/components/ui/FormControl.svelte @@ -6,6 +6,7 @@ value = $bindable(), type = "text", size = "md", + density, state = "default", className = "", children, @@ -15,13 +16,15 @@ value?: string | number | null; type?: string; size?: "sm" | "md"; + density?: "sm" | "md"; state?: "default" | "disabled" | "error"; className?: string; children?: Snippet; [key: string]: unknown; } = $props(); - const sizeClass = $derived(size === "sm" ? "h-7 px-2 text-xs" : "h-8 px-2 text-sm"); + const effectiveSize = $derived(density ?? size); + const sizeClass = $derived(effectiveSize === "sm" ? "h-7 px-2 text-xs" : "h-8 px-2 text-sm"); const stateClass = $derived.by(() => { if (state === "error") return "border-rose-300 text-rose-700"; if (state === "disabled") return "cursor-not-allowed text-gray-400 bg-gray-50"; 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 index 0cddcc3..959d745 100644 --- a/src/lib/components/ui/SegmentedControl.svelte +++ b/src/lib/components/ui/SegmentedControl.svelte @@ -8,13 +8,17 @@ options, value = $bindable(), onChange, - testIdPrefix + testIdPrefix, + size = "md" }: { options: Option[]; value: string; onChange?: (value: string) => void; testIdPrefix?: string; + size?: "sm" | "md"; } = $props(); + + const sizeClass = $derived(size === "sm" ? "h-7 px-2 text-xs" : "h-8 px-3 text-sm");
@@ -23,8 +27,9 @@ type="button" data-testid={testIdPrefix ? `${testIdPrefix}-${option.value}` : undefined} class={[ - "h-8 px-3 text-sm transition-colors", + sizeClass, i > 0 ? "border-l border-gray-200" : "", + "transition-colors", value === option.value ? "bg-gray-100 font-semibold text-gray-800" : "bg-white text-gray-700 hover:bg-gray-50" 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/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..a17568d 100644 --- a/src/lib/libraries/solver.ts +++ b/src/lib/libraries/solver.ts @@ -22,6 +22,13 @@ import { compactTypes } from "$lib/utils/typedMessage"; * @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)); + } + static fill( walletClient: WC, args: { @@ -44,7 +51,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 +82,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], @@ -119,81 +134,114 @@ 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 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. + 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 + }); + 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 + }); + 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({ @@ -202,22 +250,16 @@ export class Solver { 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 +285,43 @@ 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({ + fillTransactionHashes.map((fth, i) => { + const outputChain = getChainName(order.outputs[i].chainId); + return clients[outputChain].getTransactionReceipt({ hash: 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 +337,24 @@ 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 } + ); + } if (postHook) await postHook(); return result; }; diff --git a/src/lib/screens/ConnectWallet.svelte b/src/lib/screens/ConnectWallet.svelte index 67133f5..f41d17a 100644 --- a/src/lib/screens/ConnectWallet.svelte +++ b/src/lib/screens/ConnectWallet.svelte @@ -1,23 +1,23 @@ -
-

Connect Wallet

-

- Connect your wallet to continue with intent issuance and filling. -

-
- 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 c6c83d9..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 1a67504..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 input assets. -

- {#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/IntentList.svelte b/src/lib/screens/IntentList.svelte index 6f117af..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,13 +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 1899ecd..d20598f 100644 --- a/src/lib/screens/IssueIntent.svelte +++ b/src/lib/screens/IssueIntent.svelte @@ -2,6 +2,8 @@ import AwaitButton from "$lib/components/AwaitButton.svelte"; import GetQuote from "$lib/components/GetQuote.svelte"; import FormControl from "$lib/components/ui/FormControl.svelte"; + import ScreenFrame from "$lib/components/ui/ScreenFrame.svelte"; + import SectionCard from "$lib/components/ui/SectionCard.svelte"; import { POLYMER_ALLOCATOR, formatTokenAmount, type chain } from "$lib/config"; import { IntentFactory, escrowApprove } from "$lib/libraries/intentFactory"; import { CompactLib } from "$lib/libraries/compactLib"; @@ -114,7 +116,6 @@ decimals: number; chains: string[]; }[] = []; - // Get all unique tokens. const allUniqueNames = [ ...new Set( store.inputTokens.map((v) => { @@ -146,8 +147,6 @@ const sameChain = $derived.by(() => { if (numInputChains > 1) return false; - - // Only 1 input chain is used. const inputChain = store.inputTokens[0].token.chain; const outputChains = store.outputTokens.map((o) => o.token.chain); const numOutputChains = [...new Set(outputChains)].length; @@ -157,11 +156,12 @@ }); -
-

Intent Issuance

-

- Configure assets and execution settings, then issue your intent. -

+ {#if inputTokenSelectorActive} {/if} -
-
-
Intent pair
-
- -
-
-
+ +
+ + {#snippet headerRight()} +
+ +
+ {/snippet}

You Pay

@@ -234,9 +234,7 @@ >
-
- {formatTokenAmount(outputToken.amount, outputToken.token.decimals)} -
+
{formatTokenAmount(outputToken.amount, outputToken.token.decimals)}
{outputToken.token.name.toUpperCase()}
@@ -249,8 +247,9 @@ {/each}
-
-
+ + +
Verifier @@ -277,9 +276,8 @@ />
-
+ -
{#if !allowanceCheck} @@ -340,4 +338,4 @@

{/if}
-
+ diff --git a/src/lib/screens/ManageDeposit.svelte b/src/lib/screens/ManageDeposit.svelte index 2b73fc3..283a72c 100644 --- a/src/lib/screens/ManageDeposit.svelte +++ b/src/lib/screens/ManageDeposit.svelte @@ -11,6 +11,8 @@ 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"; @@ -48,134 +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

- (store.mainnet = v === "mainnet")} - /> -
-
-

Input Type

- (store.intentType = v as "compact" | "escrow")} - /> -
- {#if store.intentType === "compact"} -
-
-

Allocator

+ +
+ +
+

Network

(store.allocatorId = v as typeof store.allocatorId)} + value={store.mainnet ? "mainnet" : "testnet"} + onChange={(v) => (store.mainnet = v === "mainnet")} />
-
- - - - - - 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} + + +
+

Input Type

+ (store.intentType = v as "compact" | "escrow")} + />
-
- {: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 6ac1c4d..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..582a2cd 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 @@ -25,6 +23,7 @@ import { initDb, db } from "./db"; import { intents, fillTransactions as fillTransactionsTable } from "./schema"; import { eq } from "drizzle-orm"; import { orderToIntent } from "./libraries/intent"; +import { getOrFetchRpc, invalidateRpcPrefix } from "./libraries/rpcCache"; export type TokenContext = { token: Token; @@ -35,7 +34,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 +55,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 +83,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 +109,126 @@ class Store { } } - // --- Wallet --- // 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}` }>({}); + 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 +246,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 +273,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,11 +303,9 @@ 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)), From 31ab339e2f2360ae972c702480df385553882503 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 13 Feb 2026 11:51:26 +0400 Subject: [PATCH 5/7] Show fill bar --- src/lib/components/ui/FlowProgressList.svelte | 82 +++++ src/lib/libraries/flowProgress.ts | 219 ++++++++++++ src/routes/+page.svelte | 333 +++++++++++++++--- 3 files changed, 584 insertions(+), 50 deletions(-) create mode 100644 src/lib/components/ui/FlowProgressList.svelte create mode 100644 src/lib/libraries/flowProgress.ts 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/libraries/flowProgress.ts b/src/lib/libraries/flowProgress.ts new file mode 100644 index 0000000..90b28be --- /dev/null +++ b/src/lib/libraries/flowProgress.ts @@ -0,0 +1,219 @@ +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"; + +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 receipt = await getOrFetchRpc( + `progress:receipt:${output.chainId.toString()}:${fillTransactionHash}`, + async () => { + const outputClient = getClient(output.chainId); + return outputClient.getTransactionReceipt({ + hash: fillTransactionHash + }); + }, + { ttlMs: PROGRESS_TTL_MS } + ); + + 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/routes/+page.svelte b/src/routes/+page.svelte index 8ba645d..e7437fd 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -12,8 +12,10 @@ import ReceiveMessage from "$lib/screens/ReceiveMessage.svelte"; import Finalise from "$lib/screens/Finalise.svelte"; import ConnectWallet from "$lib/screens/ConnectWallet.svelte"; + import FlowProgressList, { type FlowStep } from "$lib/components/ui/FlowProgressList.svelte"; import store from "$lib/state.svelte"; import { orderToIntent } from "$lib/libraries/intent"; + import { getOrderProgressChecks, getOutputStorageKey } from "$lib/libraries/flowProgress"; // Fix bigint so we can json serialize it: (BigInt.prototype as any).toJSON = function () { @@ -102,25 +104,239 @@ const account = () => store.connectedAccount?.address!; let selectedOrder = $state(undefined); + let currentScreenIndex = $state(0); + let scrollStepProgress = $state(0); + let progressRefreshTick = $state(0); + let flowChecksRun = 0; + let flowChecks = $state({ + allFilled: false, + allValidated: false, + allFinalised: false + }); 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); }; } + + const selectedOutputFillHashSignature = $derived.by(() => { + if (!selectedOrder) return ""; + return selectedOrder.order.outputs + .map((output) => store.fillTransactions[getOutputStorageKey(output)] ?? "") + .join("|"); + }); + + $effect(() => { + const interval = setInterval(() => { + progressRefreshTick += 1; + }, 30_000); + return () => clearInterval(interval); + }); + + $effect(() => { + progressRefreshTick; + store.connectedAccount; + store.walletClient; + selectedOrder; + selectedOutputFillHashSignature; + + if (!store.connectedAccount || !store.walletClient || !selectedOrder) { + flowChecks = { + allFilled: false, + allValidated: false, + allFinalised: false + }; + return; + } + + const currentRun = ++flowChecksRun; + getOrderProgressChecks(selectedOrder, store.fillTransactions) + .then((checks) => { + if (currentRun !== flowChecksRun) return; + flowChecks = checks; + }) + .catch((error) => { + console.warn("flow progress update failed", error); + if (currentRun !== flowChecksRun) return; + flowChecks = { + allFilled: false, + allValidated: false, + allFinalised: false + }; + }); + }); + + const progressSteps = $derived.by(() => { + const connected = !!store.connectedAccount && !!store.walletClient; + if (!connected) { + return [ + { + id: "connect", + label: "Connect Wallet", + status: "active", + clickable: true, + targetIndex: 0 + }, + { + id: "assets", + label: "Assets Management", + status: "locked", + clickable: false + }, + { + id: "issue", + label: "Intent Issuance", + status: "locked", + clickable: false + }, + { + id: "select", + label: "Select Intent", + status: "locked", + clickable: false + }, + { + id: "fill", + label: "Fill Intent", + status: "locked", + clickable: false + }, + { + id: "proof", + label: "Submit Proof", + status: "locked", + clickable: false + }, + { + id: "finalise", + label: "Finalise Intent", + status: "locked", + clickable: false + } + ] as FlowStep[]; + } + + const selected = selectedOrder !== undefined; + const activeByIndex = ["assets", "issue", "select", "fill", "proof", "finalise"]; + const activeStep = + activeByIndex[Math.max(0, Math.min(currentScreenIndex, activeByIndex.length - 1))]; + + const assetsDone = currentScreenIndex > 0; + const issueDone = currentScreenIndex > 1; + const selectDone = selected; + + return [ + { + id: "assets", + label: "Asset", + status: activeStep === "assets" ? "active" : assetsDone ? "completed" : "pending", + clickable: true, + targetIndex: 0 + }, + { + id: "issue", + label: "Issue", + status: activeStep === "issue" ? "active" : issueDone ? "completed" : "pending", + clickable: true, + targetIndex: 1 + }, + { + id: "select", + label: "Fetch", + status: activeStep === "select" ? "active" : selectDone ? "completed" : "pending", + clickable: true, + targetIndex: 2 + }, + { + id: "fill", + label: "Fill", + status: !selected + ? "locked" + : activeStep === "fill" + ? "active" + : flowChecks.allFilled + ? "completed" + : "pending", + clickable: selected, + targetIndex: 3 + }, + { + id: "proof", + label: "Prove", + status: !selected + ? "locked" + : activeStep === "proof" + ? "active" + : flowChecks.allValidated + ? "completed" + : "pending", + clickable: selected, + targetIndex: 4 + }, + { + id: "finalise", + label: "Claim", + status: !selected + ? "locked" + : activeStep === "finalise" + ? "active" + : flowChecks.allFinalised + ? "completed" + : "pending", + clickable: selected, + targetIndex: 5 + } + ] as FlowStep[]; + }); + + const progressConnectorPosition = $derived.by(() => { + if (!store.connectedAccount || !store.walletClient) return 0; + const maxIndex = Math.max(progressSteps.length - 1, 0); + return Math.max(0, Math.min(scrollStepProgress, maxIndex)); + });
@@ -131,55 +347,72 @@ class="mx-auto flex flex-col-reverse items-center px-4 pt-2 md:max-w-[80rem] md:flex-row md:items-start md:px-10 md:pt-3" > -
- - -
- - +
+ + +
- 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); + }} + />
From 4528fa5e7b82a70931419e38e013bc787e25d785 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 13 Feb 2026 12:08:07 +0400 Subject: [PATCH 6/7] Improve step tracker UX and persist receipts to reduce RPC load --- drizzle/0004_store_transaction_receipts.sql | 7 + src/lib/components/ui/FlowStepTracker.svelte | 230 +++++++++++++++++++ src/lib/libraries/flowProgress.ts | 34 ++- src/lib/libraries/solver.ts | 79 +++++-- src/lib/migrations.json | 8 + src/lib/schema.ts | 10 +- src/lib/state.svelte.ts | 78 ++++++- src/routes/+page.svelte | 193 +--------------- 8 files changed, 418 insertions(+), 221 deletions(-) create mode 100644 drizzle/0004_store_transaction_receipts.sql create mode 100644 src/lib/components/ui/FlowStepTracker.svelte 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/src/lib/components/ui/FlowStepTracker.svelte b/src/lib/components/ui/FlowStepTracker.svelte new file mode 100644 index 0000000..2c517d0 --- /dev/null +++ b/src/lib/components/ui/FlowStepTracker.svelte @@ -0,0 +1,230 @@ + + + diff --git a/src/lib/libraries/flowProgress.ts b/src/lib/libraries/flowProgress.ts index 90b28be..ae0c7cf 100644 --- a/src/lib/libraries/flowProgress.ts +++ b/src/lib/libraries/flowProgress.ts @@ -18,6 +18,7 @@ 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; @@ -68,16 +69,29 @@ async function isOutputValidatedOnChain( fillTransactionHash: `0x${string}` ) { const outputKey = getOutputStorageKey(output); - const receipt = await getOrFetchRpc( - `progress:receipt:${output.chainId.toString()}:${fillTransactionHash}`, - async () => { - const outputClient = getClient(output.chainId); - return outputClient.getTransactionReceipt({ - hash: fillTransactionHash - }); - }, - { ttlMs: PROGRESS_TTL_MS } - ); + 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}`, diff --git a/src/lib/libraries/solver.ts b/src/lib/libraries/solver.ts index a17568d..c3e775a 100644 --- a/src/lib/libraries/solver.ts +++ b/src/lib/libraries/solver.ts @@ -17,6 +17,7 @@ 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. @@ -29,6 +30,33 @@ export class Solver { 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: { @@ -91,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); } } @@ -106,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; @@ -159,9 +189,10 @@ export class Solver { } // Get the output filled event. - const transactionReceipt = await clients[outputChain].getTransactionReceipt({ - hash: fillTransactionHash as `0x${string}` - }); + const transactionReceipt = await Solver.getReceiptCachedOrRpc( + output.chainId, + fillTransactionHash as `0x${string}` + ); const logs = parseEventLogs({ abi: COIN_FILLER_ABI, @@ -190,13 +221,17 @@ export class Solver { 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 - }); + 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; @@ -224,8 +259,11 @@ export class Solver { }); 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; } @@ -245,8 +283,11 @@ export class Solver { }); 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; } @@ -297,12 +338,9 @@ export class Solver { } } const transactionReceipts = await Promise.all( - fillTransactionHashes.map((fth, i) => { - const outputChain = getChainName(order.outputs[i].chainId); - return 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, i) => { @@ -355,6 +393,7 @@ export class Solver { { 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/state.svelte.ts b/src/lib/state.svelte.ts index 582a2cd..37048e6 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -20,8 +20,12 @@ 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"; @@ -109,6 +113,72 @@ class Store { } } + 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]); @@ -121,6 +191,7 @@ class Store { inputTokens = $state([]); outputTokens = $state([]); fillTransactions = $state<{ [outputId: string]: `0x${string}` }>({}); + transactionReceipts = $state>({}); refreshEpoch = $state(0); rpcRefreshMs = 45_000; @@ -311,6 +382,9 @@ class Store { 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 e7437fd..9a7d169 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -12,10 +12,9 @@ import ReceiveMessage from "$lib/screens/ReceiveMessage.svelte"; import Finalise from "$lib/screens/Finalise.svelte"; import ConnectWallet from "$lib/screens/ConnectWallet.svelte"; - import FlowProgressList, { type FlowStep } from "$lib/components/ui/FlowProgressList.svelte"; + import FlowStepTracker from "$lib/components/ui/FlowStepTracker.svelte"; import store from "$lib/state.svelte"; import { orderToIntent } from "$lib/libraries/intent"; - import { getOrderProgressChecks, getOutputStorageKey } from "$lib/libraries/flowProgress"; // Fix bigint so we can json serialize it: (BigInt.prototype as any).toJSON = function () { @@ -106,13 +105,6 @@ let selectedOrder = $state(undefined); let currentScreenIndex = $state(0); let scrollStepProgress = $state(0); - let progressRefreshTick = $state(0); - let flowChecksRun = 0; - let flowChecks = $state({ - allFilled: false, - allValidated: false, - allFinalised: false - }); let snapContainer: HTMLDivElement; @@ -161,182 +153,6 @@ goToScreen(targetScreenIndex); }; } - - const selectedOutputFillHashSignature = $derived.by(() => { - if (!selectedOrder) return ""; - return selectedOrder.order.outputs - .map((output) => store.fillTransactions[getOutputStorageKey(output)] ?? "") - .join("|"); - }); - - $effect(() => { - const interval = setInterval(() => { - progressRefreshTick += 1; - }, 30_000); - return () => clearInterval(interval); - }); - - $effect(() => { - progressRefreshTick; - store.connectedAccount; - store.walletClient; - selectedOrder; - selectedOutputFillHashSignature; - - if (!store.connectedAccount || !store.walletClient || !selectedOrder) { - flowChecks = { - allFilled: false, - allValidated: false, - allFinalised: false - }; - return; - } - - const currentRun = ++flowChecksRun; - getOrderProgressChecks(selectedOrder, store.fillTransactions) - .then((checks) => { - if (currentRun !== flowChecksRun) return; - flowChecks = checks; - }) - .catch((error) => { - console.warn("flow progress update failed", error); - if (currentRun !== flowChecksRun) return; - flowChecks = { - allFilled: false, - allValidated: false, - allFinalised: false - }; - }); - }); - - const progressSteps = $derived.by(() => { - const connected = !!store.connectedAccount && !!store.walletClient; - if (!connected) { - return [ - { - id: "connect", - label: "Connect Wallet", - status: "active", - clickable: true, - targetIndex: 0 - }, - { - id: "assets", - label: "Assets Management", - status: "locked", - clickable: false - }, - { - id: "issue", - label: "Intent Issuance", - status: "locked", - clickable: false - }, - { - id: "select", - label: "Select Intent", - status: "locked", - clickable: false - }, - { - id: "fill", - label: "Fill Intent", - status: "locked", - clickable: false - }, - { - id: "proof", - label: "Submit Proof", - status: "locked", - clickable: false - }, - { - id: "finalise", - label: "Finalise Intent", - status: "locked", - clickable: false - } - ] as FlowStep[]; - } - - const selected = selectedOrder !== undefined; - const activeByIndex = ["assets", "issue", "select", "fill", "proof", "finalise"]; - const activeStep = - activeByIndex[Math.max(0, Math.min(currentScreenIndex, activeByIndex.length - 1))]; - - const assetsDone = currentScreenIndex > 0; - const issueDone = currentScreenIndex > 1; - const selectDone = selected; - - return [ - { - id: "assets", - label: "Asset", - status: activeStep === "assets" ? "active" : assetsDone ? "completed" : "pending", - clickable: true, - targetIndex: 0 - }, - { - id: "issue", - label: "Issue", - status: activeStep === "issue" ? "active" : issueDone ? "completed" : "pending", - clickable: true, - targetIndex: 1 - }, - { - id: "select", - label: "Fetch", - status: activeStep === "select" ? "active" : selectDone ? "completed" : "pending", - clickable: true, - targetIndex: 2 - }, - { - id: "fill", - label: "Fill", - status: !selected - ? "locked" - : activeStep === "fill" - ? "active" - : flowChecks.allFilled - ? "completed" - : "pending", - clickable: selected, - targetIndex: 3 - }, - { - id: "proof", - label: "Prove", - status: !selected - ? "locked" - : activeStep === "proof" - ? "active" - : flowChecks.allValidated - ? "completed" - : "pending", - clickable: selected, - targetIndex: 4 - }, - { - id: "finalise", - label: "Claim", - status: !selected - ? "locked" - : activeStep === "finalise" - ? "active" - : flowChecks.allFinalised - ? "completed" - : "pending", - clickable: selected, - targetIndex: 5 - } - ] as FlowStep[]; - }); - - const progressConnectorPosition = $derived.by(() => { - if (!store.connectedAccount || !store.walletClient) return 0; - const maxIndex = Math.max(progressSteps.length - 1, 0); - return Math.max(0, Math.min(scrollStepProgress, maxIndex)); - });
@@ -404,10 +220,11 @@
- { if (step.targetIndex === undefined) return; goToScreen(step.targetIndex); From 417417159f1ee911e6a7a3b4b0fc8795bad16bb9 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 13 Feb 2026 13:42:46 +0400 Subject: [PATCH 7/7] Fix layout of step tracker --- src/lib/components/ui/FlowStepTracker.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/components/ui/FlowStepTracker.svelte b/src/lib/components/ui/FlowStepTracker.svelte index 2c517d0..31f7e79 100644 --- a/src/lib/components/ui/FlowStepTracker.svelte +++ b/src/lib/components/ui/FlowStepTracker.svelte @@ -163,9 +163,9 @@ status: stepStatus({ active: activeStep === "issue", done: issueDone, - unlocked: assetsDone || activeStep === "issue" + unlocked: true }), - clickable: assetsDone || activeStep === "issue", + clickable: true, targetIndex: 1 }, { @@ -174,9 +174,9 @@ status: stepStatus({ active: activeStep === "select", done: fetchDone, - unlocked: issueDone || activeStep === "select" + unlocked: true }), - clickable: issueDone || activeStep === "select", + clickable: true, targetIndex: 2 }, {