From 6441aef40c39cc3d09025862baa905c63efe34e5 Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:23:09 +0000 Subject: [PATCH 01/10] Require deployment manifest for trading Docker --- trading/Dockerfile | 4 ++-- trading/README.md | 28 ++++++++++++-------------- trading/compose.yaml | 2 +- trading/ts/tests/dockerCompose.test.ts | 8 ++++++++ 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/trading/Dockerfile b/trading/Dockerfile index a722046fd..4884d3cca 100644 --- a/trading/Dockerfile +++ b/trading/Dockerfile @@ -14,8 +14,8 @@ RUN bun install --frozen-lockfile RUN bun run shared:build RUN cd trading && bun install --frozen-lockfile -ARG TRADING_UI_DEPLOYMENT -RUN cd trading && if [ -n "${TRADING_UI_DEPLOYMENT}" ]; then TRADING_UI_DEPLOYMENT="${TRADING_UI_DEPLOYMENT}" bun run ui:build; else bun run ui:build; fi +ARG TRADING_UI_DEPLOYMENT=deployments/local.json +RUN cd trading && if [ ! -f "${TRADING_UI_DEPLOYMENT}" ]; then echo "Trading deployment manifest not found: ${TRADING_UI_DEPLOYMENT}. Run bun run deploy:local or pass --build-arg TRADING_UI_DEPLOYMENT=." >&2; exit 1; fi && TRADING_UI_DEPLOYMENT="${TRADING_UI_DEPLOYMENT}" bun run ui:build FROM oven/bun:${BUN_VERSION}-alpine AS runtime diff --git a/trading/README.md b/trading/README.md index c641dac13..a580dbdd9 100644 --- a/trading/README.md +++ b/trading/README.md @@ -29,7 +29,16 @@ Open `http://localhost:4163/?demo=1#/markets`. Demo mode is prominently labeled ### Docker -Build and serve the standalone demo UI from this directory: +The Docker image is live-configured by default and requires a reviewed deployment manifest. For a local deployment, first deploy Zoltar core to Anvil, then create the trading manifest: + +```bash +cp .env.example .env +ZOLTAR_DEPLOYMENT_MANIFEST=/absolute/path/to/core.json bun run deploy:local +``` + +`deploy:local` verifies that the configured core `SecurityPoolFactory` has bytecode on the selected chain, deploys a factory with an immutable fee and a router, and writes the default Docker input to `deployments/local.json`. + +Build and serve the standalone UI from this directory: ```bash docker compose up --build --force-recreate @@ -37,14 +46,12 @@ docker compose up --build --force-recreate On Windows, run `start.bat` from this directory to start the same Compose command. -Then open `http://localhost:4163/?demo=1#/markets`. The final image runs as an unprivileged user and exposes a health check at `/`. - -Without a deployment build argument, the image contains `deployment.json` set to `null` and supports demo mode only. Live use requires a build with a reviewed manifest. +Then open `http://localhost:4163/#/markets`. The final image runs as an unprivileged user and exposes a health check at `/`. The build fails instead of producing a demo-only image when the manifest is missing. -For live use, include a reviewed project-local deployment manifest at build time. The path is relative to `trading/` inside the build context: +To use a different reviewed project-local deployment manifest, set its path at build time. The path is relative to `trading/` inside the build context: ```bash -TRADING_UI_DEPLOYMENT=deployments/local.json docker compose up --build --force-recreate +TRADING_UI_DEPLOYMENT=deployments/reviewed.json docker compose up --build --force-recreate ``` ### Live deployment @@ -57,15 +64,6 @@ TRADING_UI_DEPLOYMENT=/absolute/path/to/trading/deployments/local.json bun run u The live client validates the manifest, discovers canonical SecurityPools in bounded pages, displays their exact pairs, settings, and status, and obtains authoritative simulations before entry, exit, liquidity, settlement, and explicit fork-migration transactions. Fork migration loads the fork question and supports labeled categorical branches or arbitrary scalar ticks, including multi-branch migration for each INVALID, YES, or NO source balance. Each simulation is pinned to a canonical block hash; the client rejects a quote when either its block number or hash changes, including a same-height block replacement, and re-simulates immediately before wallet submission. -For a local deployment, first deploy Zoltar core to Anvil, then: - -```bash -cp .env.example .env -ZOLTAR_DEPLOYMENT_MANIFEST=/absolute/path/to/core.json bun run deploy:local -``` - -The script verifies that the configured core `SecurityPoolFactory` has bytecode on the selected chain, deploys a factory with an immutable fee, deploys the router, and writes `deployments/local.json`. - ## Commands | Command | Purpose | diff --git a/trading/compose.yaml b/trading/compose.yaml index 75e00f14a..c893a6add 100644 --- a/trading/compose.yaml +++ b/trading/compose.yaml @@ -6,7 +6,7 @@ services: context: .. dockerfile: trading/Dockerfile args: - TRADING_UI_DEPLOYMENT: ${TRADING_UI_DEPLOYMENT:-} + TRADING_UI_DEPLOYMENT: ${TRADING_UI_DEPLOYMENT:-deployments/local.json} image: zoltar-trading ports: - 127.0.0.1:4163:4163 diff --git a/trading/ts/tests/dockerCompose.test.ts b/trading/ts/tests/dockerCompose.test.ts index 0123415fe..ec05fd82a 100644 --- a/trading/ts/tests/dockerCompose.test.ts +++ b/trading/ts/tests/dockerCompose.test.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' const composeFile = join(import.meta.dir, '..', '..', 'compose.yaml') +const dockerfile = join(import.meta.dir, '..', '..', 'Dockerfile') const windowsLauncher = join(import.meta.dir, '..', '..', 'start.bat') describe('standalone Docker Compose packaging', () => { @@ -10,9 +11,16 @@ describe('standalone Docker Compose packaging', () => { const source = await readFile(composeFile, 'utf8') expect(source).toContain('context: ..') expect(source).toContain('dockerfile: trading/Dockerfile') + expect(source).toContain('TRADING_UI_DEPLOYMENT: ${TRADING_UI_DEPLOYMENT:-deployments/local.json}') expect(source).toContain('127.0.0.1:4163:4163') }) + test('requires the default live deployment manifest', async () => { + const source = await readFile(dockerfile, 'utf8') + expect(source).toContain('ARG TRADING_UI_DEPLOYMENT=deployments/local.json') + expect(source).toContain('if [ ! -f "${TRADING_UI_DEPLOYMENT}" ]') + }) + test('provides a location-independent Windows launcher', async () => { const source = (await readFile(windowsLauncher, 'utf8')).replaceAll('\r\n', '\n') expect(source).toContain('pushd "%~dp0"') From 1709964161ffde77290f7037888474cd0f88c0c6 Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:07:53 +0000 Subject: [PATCH 02/10] Add wallet trading deployment flow --- trading/Dockerfile | 5 +- trading/README.md | 17 +- trading/compose.yaml | 2 +- trading/docs/how-to/configure-ui.md | 8 +- trading/docs/how-to/deploy.md | 6 +- trading/docs/reference/configuration.md | 2 + trading/scripts/browser-qa.mts | 14 + .../ts/tests/coreDeploymentRegistry.test.ts | 23 ++ trading/ts/tests/dockerCompose.test.ts | 9 +- trading/ui/build/build.mts | 3 +- trading/ui/build/core-deployments.mts | 47 +++ trading/ui/build/serve.mts | 4 +- trading/ui/build/serve.test.ts | 2 + trading/ui/css/app.css | 42 +++ trading/ui/ts/app/App.tsx | 30 +- .../ui/ts/features/TradingDeploymentSetup.tsx | 282 ++++++++++++++++++ trading/ui/ts/protocol/config.ts | 27 +- trading/ui/ts/protocol/coreDeployments.ts | 45 +++ trading/ui/ts/protocol/deployment.ts | 124 ++++++++ trading/ui/ts/tests/build-deployment.test.ts | 2 +- trading/ui/ts/tests/config.test.ts | 26 +- trading/ui/ts/tests/deployment-flow.test.ts | 106 +++++++ trading/ui/ts/tests/deployment-setup.test.tsx | 71 +++++ trading/ui/ts/tests/essential-copy.test.tsx | 4 +- 24 files changed, 863 insertions(+), 38 deletions(-) create mode 100644 trading/ts/tests/coreDeploymentRegistry.test.ts create mode 100644 trading/ui/build/core-deployments.mts create mode 100644 trading/ui/ts/features/TradingDeploymentSetup.tsx create mode 100644 trading/ui/ts/protocol/coreDeployments.ts create mode 100644 trading/ui/ts/protocol/deployment.ts create mode 100644 trading/ui/ts/tests/deployment-flow.test.ts create mode 100644 trading/ui/ts/tests/deployment-setup.test.tsx diff --git a/trading/Dockerfile b/trading/Dockerfile index 4884d3cca..1019120d1 100644 --- a/trading/Dockerfile +++ b/trading/Dockerfile @@ -9,13 +9,14 @@ COPY package.json bun.lock ./ COPY scripts ./scripts COPY shared ./shared COPY solidity/contracts ./solidity/contracts +COPY docs/mainnet-deployment-addresses.json docs/sepolia-deployment-addresses.json ./docs/ COPY trading ./trading RUN bun install --frozen-lockfile RUN bun run shared:build RUN cd trading && bun install --frozen-lockfile -ARG TRADING_UI_DEPLOYMENT=deployments/local.json -RUN cd trading && if [ ! -f "${TRADING_UI_DEPLOYMENT}" ]; then echo "Trading deployment manifest not found: ${TRADING_UI_DEPLOYMENT}. Run bun run deploy:local or pass --build-arg TRADING_UI_DEPLOYMENT=." >&2; exit 1; fi && TRADING_UI_DEPLOYMENT="${TRADING_UI_DEPLOYMENT}" bun run ui:build +ARG TRADING_UI_DEPLOYMENT +RUN cd trading && if [ -n "${TRADING_UI_DEPLOYMENT}" ]; then TRADING_UI_DEPLOYMENT="${TRADING_UI_DEPLOYMENT}" bun run ui:build; else bun run ui:build; fi FROM oven/bun:${BUN_VERSION}-alpine AS runtime diff --git a/trading/README.md b/trading/README.md index a580dbdd9..f0cb12613 100644 --- a/trading/README.md +++ b/trading/README.md @@ -29,26 +29,17 @@ Open `http://localhost:4163/?demo=1#/markets`. Demo mode is prominently labeled ### Docker -The Docker image is live-configured by default and requires a reviewed deployment manifest. For a local deployment, first deploy Zoltar core to Anvil, then create the trading manifest: - -```bash -cp .env.example .env -ZOLTAR_DEPLOYMENT_MANIFEST=/absolute/path/to/core.json bun run deploy:local -``` - -`deploy:local` verifies that the configured core `SecurityPoolFactory` has bytecode on the selected chain, deploys a factory with an immutable fee and a router, and writes the default Docker input to `deployments/local.json`. - Build and serve the standalone UI from this directory: ```bash docker compose up --build --force-recreate ``` -On Windows, run `start.bat` from this directory to start the same Compose command. +On Windows, run `start.bat` from this directory to start the same Compose command. The image copies the canonical mainnet and Sepolia core deployment addresses from the root documentation manifests. -Then open `http://localhost:4163/#/markets`. The final image runs as an unprivileged user and exposes a health check at `/`. The build fails instead of producing a demo-only image when the manifest is missing. +Then open `http://localhost:4163/#/markets`, select the core network and RPC URL, and connect a wallet. The UI uses the core deployment's deterministic proxy to deploy the two-way factory and router in two wallet transactions. It verifies the RPC chain, core contracts, deterministic addresses, immutable fee, and router-to-factory link before enabling trading. The final image runs as an unprivileged user and exposes a health check at `/`. -To use a different reviewed project-local deployment manifest, set its path at build time. The path is relative to `trading/` inside the build context: +To use an existing reviewed project-local trading deployment instead, set its path at build time. The path is relative to `trading/` inside the build context: ```bash TRADING_UI_DEPLOYMENT=deployments/reviewed.json docker compose up --build --force-recreate @@ -56,7 +47,7 @@ TRADING_UI_DEPLOYMENT=deployments/reviewed.json docker compose up --build --forc ### Live deployment -Without Docker, build with a reviewed deployment manifest and open the same routes without `?demo=1`: +Without Docker, `bun run ui:build` includes the same wallet deployment setup. To use an existing reviewed deployment manifest instead: ```bash TRADING_UI_DEPLOYMENT=/absolute/path/to/trading/deployments/local.json bun run ui:build diff --git a/trading/compose.yaml b/trading/compose.yaml index c893a6add..75e00f14a 100644 --- a/trading/compose.yaml +++ b/trading/compose.yaml @@ -6,7 +6,7 @@ services: context: .. dockerfile: trading/Dockerfile args: - TRADING_UI_DEPLOYMENT: ${TRADING_UI_DEPLOYMENT:-deployments/local.json} + TRADING_UI_DEPLOYMENT: ${TRADING_UI_DEPLOYMENT:-} image: zoltar-trading ports: - 127.0.0.1:4163:4163 diff --git a/trading/docs/how-to/configure-ui.md b/trading/docs/how-to/configure-ui.md index fdddbb9a1..e67d67b5d 100644 --- a/trading/docs/how-to/configure-ui.md +++ b/trading/docs/how-to/configure-ui.md @@ -1,13 +1,15 @@ # Configure the standalone UI -The standalone UI has two explicitly separated modes. `?demo=1` provides walletless visual fixtures and is always labeled simulated; do not use demo screens as evidence of live chain state. Without that query parameter, the application requires a deployment manifest and reads live RPC and wallet state. +The standalone UI has two explicitly separated modes. `?demo=1` provides walletless visual fixtures and is always labeled simulated; do not use demo screens as evidence of live chain state. Without that query parameter, the application reads live RPC and wallet state. -`deploy:local` emits the authoritative nested manifest under `network`, `core`, and `trading`. Copy it into the built application with: +The build copies the canonical mainnet and Sepolia core factory and deterministic proxy addresses from the root deployment manifests. When no complete trading deployment is bundled or saved, open the live UI, select a core network, enter its RPC URL, and keep or change the immutable fee. The UI verifies the RPC chain and core bytecode automatically. Connect a wallet to deploy the deterministic trading factory, then the router. After verifying both contracts, the browser saves the completed configuration locally and opens live trading. A different fee selects a different deterministic factory and router. + +For an existing local or reviewed trading deployment, `deploy:local` emits the authoritative nested manifest under `network`, `core`, and `trading`. Copy it into the built application with: ```bash TRADING_UI_DEPLOYMENT=/absolute/path/to/trading/deployments/local.json bun run ui:build ``` -The build copies it as untracked `ui/dist/deployment.json`. The parser also accepts the documented flat schema for deliberate hand-authored configurations. The live client validates required addresses and values, discovers pools from `SecurityPoolFactory` in bounded pages, isolates individual market-read failures, and never hard-codes market addresses. The wallet must report the manifest chain before any submission. Entry, exit, liquidity, settlement, and fork-migration calls are simulated through the actual contracts, rejected after a block change, and simulated again immediately before submission with explicit bounds where the call accepts them. +The build copies it as untracked `ui/dist/deployment.json`, which takes precedence over browser-saved configuration. The parser also accepts the documented flat schema for deliberate hand-authored configurations. The live client validates required addresses and values, discovers pools from `SecurityPoolFactory` in bounded pages, isolates individual market-read failures, and never hard-codes market addresses. The wallet must report the configured chain before any submission. Entry, exit, liquidity, settlement, and fork-migration calls are simulated through the actual contracts, rejected after a block change, and simulated again immediately before submission with explicit bounds where the call accepts them. Serve built assets from the same origin. Production code may connect only to the configured RPC, the wallet provider, and explicit explorer links. Demo mode is unmistakably labeled and must never be presented as live state. diff --git a/trading/docs/how-to/deploy.md b/trading/docs/how-to/deploy.md index c2657e387..f539c3fb3 100644 --- a/trading/docs/how-to/deploy.md +++ b/trading/docs/how-to/deploy.md @@ -1,5 +1,7 @@ # Deploy the trading contracts -Compile first with `bun run compile`. Set `ZOLTAR_DEPLOYMENT_MANIFEST` to a reviewed manifest for the same Anvil chain, optionally set `TRADING_RPC_URL`, `TRADING_DEPLOYER`, and `TRADING_FEE_BPS`, then run `bun run deploy:local`. +For mainnet or Sepolia, build and serve the UI, open the live markets route, select the core network and RPC URL, and connect a wallet. The UI verifies the canonical core deployment and submits the deterministic `TwoWayConstantProductFactory` and `TwoWayConstantProductRouter` deployments in dependency order. It resumes at the first missing contract when a deterministic deployment already exists. -The script verifies code at the configured core `SecurityPoolFactory`, deploys `TwoWayConstantProductFactory(coreFactory, feeBps)`, deploys `TwoWayConstantProductRouter(factory)`, and records chain ID, inputs, outputs, transaction hashes, compiler settings, and bytecode hashes. The fee is immutable and no economically optimal value is claimed. No mainnet or Sepolia address is assumed. +For a local Anvil chain, compile first with `bun run compile`. Set `ZOLTAR_DEPLOYMENT_MANIFEST` to a reviewed manifest for the same chain, optionally set `TRADING_RPC_URL`, `TRADING_DEPLOYER`, and `TRADING_FEE_BPS`, then run `bun run deploy:local`. + +The script verifies code at the configured core `SecurityPoolFactory`, deploys `TwoWayConstantProductFactory(coreFactory, feeBps)`, deploys `TwoWayConstantProductRouter(factory)`, and records chain ID, inputs, outputs, transaction hashes, compiler settings, and bytecode hashes. The fee is immutable and no economically optimal value is claimed. diff --git a/trading/docs/reference/configuration.md b/trading/docs/reference/configuration.md index bfcfd5321..faacbdc45 100644 --- a/trading/docs/reference/configuration.md +++ b/trading/docs/reference/configuration.md @@ -10,3 +10,5 @@ Environment variables used by local deployment: | `TRADING_FEE_BPS` | Immutable AMM fee | `30` | The live UI directly accepts the nested `deploy:local` manifest. It also accepts a flat JSON schema with numeric `chainId` and `feeBps`, string `chainName` and `rpcUrl`, and addresses `securityPoolFactory`, `factory`, and `router`. Set `TRADING_UI_DEPLOYMENT` while building to copy a reviewed manifest to untracked `ui/dist/deployment.json`; see [UI configuration](../how-to/configure-ui.md). Secrets do not belong in manifests or `.env.example`. + +Without `TRADING_UI_DEPLOYMENT`, the build writes `deployment.json` as `null` and writes `core-deployments.json` from the root mainnet and Sepolia deployment manifests. The live setup screen accepts a supported chain, an HTTPS or loopback HTTP RPC URL, and an immutable fee from 0 to 9999 basis points. It computes the trading factory and router through the core deployment's canonical CREATE2 proxy, verifies or deploys each contract through the wallet, and stores the completed configuration under `zoltar.trading.deployment.v1` in browser local storage. diff --git a/trading/scripts/browser-qa.mts b/trading/scripts/browser-qa.mts index 1f7d023f8..6eed0f275 100644 --- a/trading/scripts/browser-qa.mts +++ b/trading/scripts/browser-qa.mts @@ -87,6 +87,20 @@ const visibleHeaderContextAssertion = `(() => { const selectors = ['.demo-banner const expandedWalletInFlowAssertion = `(() => { const panel = document.querySelector('.wallet-summary__details')?.getBoundingClientRect(); const nav = document.querySelector('.site-header nav')?.getBoundingClientRect(); const main = document.querySelector('main')?.getBoundingClientRect(); return panel !== undefined && nav !== undefined && main !== undefined && panel.left >= 0 && panel.right <= innerWidth && panel.bottom <= nav.top && panel.bottom <= main.top })()` const transactionStateLayoutAssertion = `(() => { const panel = document.querySelector('.trade-panel')?.getBoundingClientRect(); const action = document.querySelector('.trade-action')?.getBoundingClientRect(); const status = document.querySelector('.transaction-message')?.getBoundingClientRect(); const hash = document.querySelector('.transaction-hash'); const hashBounds = hash?.getBoundingClientRect(); const hashCode = hash?.querySelector('code'); const hashFits = hash === null || (hashBounds !== undefined && hashBounds.left >= panel.left && hashBounds.right <= panel.right && hashCode?.textContent?.length === 66); return panel !== undefined && action !== undefined && status !== undefined && action.bottom <= status.top && hashFits && [...document.querySelectorAll('.trade-panel button, .trade-panel input')].every(control => control.disabled) && document.documentElement.scrollWidth <= document.documentElement.clientWidth })()` const scenarios = [ + { + name: 'deployment-setup-desktop', + width: 1440, + height: 900, + path: '/#/deploy', + assertExpression: `document.querySelector('.deployment-setup select')?.options.length === 3 && document.querySelector('.deployment-setup input[type="url"]') !== null && [...document.querySelectorAll('.deployment-setup button')].some(button => button.textContent?.trim() === 'Deployment ready' && button.disabled) && document.documentElement.scrollWidth <= document.documentElement.clientWidth`, + }, + { + name: 'deployment-setup-mobile', + width: 390, + height: 844, + path: '/#/deploy', + assertExpression: `(() => { const fields = document.querySelector('.deployment-setup__fields')?.getBoundingClientRect(); const controls = [...document.querySelectorAll('.deployment-setup select, .deployment-setup input, .deployment-setup button')].map(control => { const bounds = control.getBoundingClientRect(); return { name: control.getAttribute('name') ?? control.textContent?.trim() ?? control.tagName, left: bounds.left, right: bounds.right, height: bounds.height } }); const checks = { fields: fields === undefined ? undefined : { left: fields.left, right: fields.right }, controls, scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth }; if (fields === undefined || fields.left < 0 || fields.right > innerWidth || controls.some(control => control.left < 0 || control.right > innerWidth || control.height < 44) || checks.scrollWidth > checks.clientWidth) throw new Error(JSON.stringify(checks)); return true })()`, + }, { name: 'disconnected-market-list', width: 1440, diff --git a/trading/ts/tests/coreDeploymentRegistry.test.ts b/trading/ts/tests/coreDeploymentRegistry.test.ts new file mode 100644 index 000000000..a7172d3b3 --- /dev/null +++ b/trading/ts/tests/coreDeploymentRegistry.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' +import { getAddress } from '@zoltar/shared/ethereum' +import { coreDeploymentFromManifest } from '../../ui/build/core-deployments.mts' + +describe('trading core deployment registry', () => { + test('copies the canonical deployment proxy and SecurityPoolFactory from a Zoltar manifest', () => { + const proxyDeployer = getAddress(`0x${'12'.repeat(20)}`) + const securityPoolFactory = getAddress(`0x${'34'.repeat(20)}`) + expect( + coreDeploymentFromManifest({ + network: { chainId: 11_155_111, id: 'sepolia', name: 'Sepolia' }, + deploymentSteps: [ + { id: 'proxyDeployer', address: proxyDeployer }, + { id: 'securityPoolFactory', address: securityPoolFactory }, + ], + }), + ).toEqual({ chainId: 11_155_111, chainName: 'Sepolia', id: 'sepolia', proxyDeployer, securityPoolFactory }) + }) + + test('rejects a manifest without the required canonical deployment steps', () => { + expect(() => coreDeploymentFromManifest({ network: { chainId: 1, id: 'mainnet', name: 'Mainnet' }, deploymentSteps: [] })).toThrow('proxyDeployer') + }) +}) diff --git a/trading/ts/tests/dockerCompose.test.ts b/trading/ts/tests/dockerCompose.test.ts index ec05fd82a..415230c96 100644 --- a/trading/ts/tests/dockerCompose.test.ts +++ b/trading/ts/tests/dockerCompose.test.ts @@ -11,14 +11,15 @@ describe('standalone Docker Compose packaging', () => { const source = await readFile(composeFile, 'utf8') expect(source).toContain('context: ..') expect(source).toContain('dockerfile: trading/Dockerfile') - expect(source).toContain('TRADING_UI_DEPLOYMENT: ${TRADING_UI_DEPLOYMENT:-deployments/local.json}') + expect(source).toContain('TRADING_UI_DEPLOYMENT: ${TRADING_UI_DEPLOYMENT:-}') expect(source).toContain('127.0.0.1:4163:4163') }) - test('requires the default live deployment manifest', async () => { + test('includes canonical core deployments for browser wallet setup', async () => { const source = await readFile(dockerfile, 'utf8') - expect(source).toContain('ARG TRADING_UI_DEPLOYMENT=deployments/local.json') - expect(source).toContain('if [ ! -f "${TRADING_UI_DEPLOYMENT}" ]') + expect(source).toContain('COPY docs/mainnet-deployment-addresses.json docs/sepolia-deployment-addresses.json ./docs/') + expect(source).toContain('ARG TRADING_UI_DEPLOYMENT') + expect(source).toContain('if [ -n "${TRADING_UI_DEPLOYMENT}" ]') }) test('provides a location-independent Windows launcher', async () => { diff --git a/trading/ui/build/build.mts b/trading/ui/build/build.mts index fe8de5e01..b5c5dd76a 100644 --- a/trading/ui/build/build.mts +++ b/trading/ui/build/build.mts @@ -1,5 +1,6 @@ import { promises as fs } from 'node:fs' import path from 'node:path' +import { writeCoreDeploymentRegistry } from './core-deployments.mts' import { resolveDeploymentSource } from './deployment.mts' const uiRoot = path.resolve(import.meta.dir, '..') @@ -8,7 +9,7 @@ await fs.rm(output, { recursive: true, force: true }) await fs.mkdir(output, { recursive: true }) const result = await Bun.build({ entrypoints: [path.join(uiRoot, 'ts/index.tsx')], outdir: output, naming: 'app.js', target: 'browser', minify: false, sourcemap: 'linked' }) if (!result.success) throw new AggregateError(result.logs, 'Trading UI build failed') -await Promise.all([fs.copyFile(path.join(uiRoot, 'index.html'), path.join(output, 'index.html')), fs.copyFile(path.join(uiRoot, 'css/app.css'), path.join(output, 'app.css'))]) +await Promise.all([fs.copyFile(path.join(uiRoot, 'index.html'), path.join(output, 'index.html')), fs.copyFile(path.join(uiRoot, 'css/app.css'), path.join(output, 'app.css')), writeCoreDeploymentRegistry(path.join(output, 'core-deployments.json'))]) const deploymentSource = resolveDeploymentSource(process.env.TRADING_UI_DEPLOYMENT) if (deploymentSource === undefined) await fs.writeFile(path.join(output, 'deployment.json'), 'null\n') else await fs.copyFile(deploymentSource, path.join(output, 'deployment.json')) diff --git a/trading/ui/build/core-deployments.mts b/trading/ui/build/core-deployments.mts new file mode 100644 index 000000000..b6750d7ae --- /dev/null +++ b/trading/ui/build/core-deployments.mts @@ -0,0 +1,47 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { getAddress } from '@zoltar/shared/ethereum' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function requiredString(value: unknown, label: string) { + if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} is required`) + return value +} + +function requiredChainId(value: unknown) { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) throw new Error('network.chainId must be a positive safe integer') + return value +} + +function deploymentStepAddress(steps: unknown, id: string) { + if (!Array.isArray(steps)) throw new Error('deploymentSteps must be an array') + const step = steps.find(candidate => isRecord(candidate) && candidate.id === id) + if (!isRecord(step)) throw new Error(`deploymentSteps must contain ${id}`) + return getAddress(requiredString(step.address, `${id}.address`)) +} + +export function coreDeploymentFromManifest(candidate: unknown) { + if (!isRecord(candidate) || !isRecord(candidate.network)) throw new Error('Core deployment manifest network is required') + return { + chainId: requiredChainId(candidate.network.chainId), + chainName: requiredString(candidate.network.name, 'network.name'), + id: requiredString(candidate.network.id, 'network.id'), + proxyDeployer: deploymentStepAddress(candidate.deploymentSteps, 'proxyDeployer'), + securityPoolFactory: deploymentStepAddress(candidate.deploymentSteps, 'securityPoolFactory'), + } +} + +export async function writeCoreDeploymentRegistry(output: string) { + const repositoryRoot = path.resolve(import.meta.dir, '../../..') + const manifestPaths = [path.join(repositoryRoot, 'docs/mainnet-deployment-addresses.json'), path.join(repositoryRoot, 'docs/sepolia-deployment-addresses.json')] + const deployments = await Promise.all( + manifestPaths.map(async manifestPath => { + const candidate: unknown = JSON.parse(await fs.readFile(manifestPath, 'utf8')) + return coreDeploymentFromManifest(candidate) + }), + ) + await fs.writeFile(output, `${JSON.stringify(deployments, undefined, 2)}\n`) +} diff --git a/trading/ui/build/serve.mts b/trading/ui/build/serve.mts index 53e4c15a3..feb18d765 100644 --- a/trading/ui/build/serve.mts +++ b/trading/ui/build/serve.mts @@ -1,7 +1,7 @@ import path from 'node:path' const root = path.resolve(import.meta.dir, '../dist') -const files = { '/': 'index.html', '/index.html': 'index.html', '/app.js': 'app.js', '/app.js.map': 'app.js.map', '/app.css': 'app.css', '/deployment.json': 'deployment.json' } as const +const files = { '/': 'index.html', '/index.html': 'index.html', '/app.js': 'app.js', '/app.js.map': 'app.js.map', '/app.css': 'app.css', '/core-deployments.json': 'core-deployments.json', '/deployment.json': 'deployment.json' } as const const securityHeaders = { 'content-security-policy': "default-src 'self'; connect-src 'self' http://127.0.0.1:* http://localhost:* https:; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'", 'referrer-policy': 'no-referrer', @@ -12,7 +12,7 @@ export function serveTradingAsset(request: Request) { const pathname = new URL(request.url).pathname const file = files[pathname as keyof typeof files] if (file === undefined) return new Response('Not found', { status: 404, headers: securityHeaders }) - return new Response(Bun.file(path.join(root, file)), { headers: { ...securityHeaders, 'cache-control': file === 'deployment.json' || file === 'index.html' ? 'no-store' : 'no-cache' } }) + return new Response(Bun.file(path.join(root, file)), { headers: { ...securityHeaders, 'cache-control': file === 'core-deployments.json' || file === 'deployment.json' || file === 'index.html' ? 'no-store' : 'no-cache' } }) } if (import.meta.main) { diff --git a/trading/ui/build/serve.test.ts b/trading/ui/build/serve.test.ts index f9272932a..e148b5667 100644 --- a/trading/ui/build/serve.test.ts +++ b/trading/ui/build/serve.test.ts @@ -4,7 +4,9 @@ import { serveTradingAsset } from './serve.mts' describe('trading UI asset server', () => { test('protects responses and prevents stale deployment configuration', () => { const deployment = serveTradingAsset(new Request('http://localhost:4163/deployment.json')) + const coreDeployments = serveTradingAsset(new Request('http://localhost:4163/core-deployments.json')) expect(deployment.headers.get('cache-control')).toBe('no-store') + expect(coreDeployments.headers.get('cache-control')).toBe('no-store') expect(deployment.headers.get('content-security-policy')).toContain("default-src 'self'") expect(deployment.headers.get('x-content-type-options')).toBe('nosniff') diff --git a/trading/ui/css/app.css b/trading/ui/css/app.css index 5096ae2f9..b323c360a 100644 --- a/trading/ui/css/app.css +++ b/trading/ui/css/app.css @@ -611,6 +611,7 @@ nav a[aria-current="page"] { .field > input, .field > select { width: 100%; + min-height: 44px; min-width: 0; border: 1px solid var(--line-strong); padding: 0.75rem 0.85rem; @@ -625,6 +626,44 @@ nav a[aria-current="page"] { border-color: var(--accent); outline: 2px solid transparent; } +.deployment-setup { + display: grid; + gap: 1.25rem; +} +.deployment-setup__notice { + margin: 0; + color: var(--muted); +} +.deployment-setup__fields { + display: grid; + grid-template-columns: minmax(12rem, 1fr) minmax(16rem, 2fr) minmax(9rem, 0.7fr); + gap: 1rem; +} +.deployment-setup__contracts { + margin: 0; +} +.deployment-setup__status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--line); + background: var(--surface-raised); +} +.deployment-setup__status > div { + display: grid; + gap: 0.25rem; +} +.deployment-setup__status span:first-child { + color: var(--muted); + font-size: 0.82rem; +} +.deployment-setup__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} .error { color: var(--bad); overflow-wrap: anywhere; @@ -1417,6 +1456,9 @@ footer { .security-pool-link { height: 44px; } + .deployment-setup__fields { + grid-template-columns: 1fr; + } details:not(.wallet-summary) > summary::after { margin-left: 0.45rem; content: "▾"; diff --git a/trading/ui/ts/app/App.tsx b/trading/ui/ts/app/App.tsx index 73459bd4a..a0800e98a 100644 --- a/trading/ui/ts/app/App.tsx +++ b/trading/ui/ts/app/App.tsx @@ -3,11 +3,12 @@ import { demoMarket, demoWalletAccount, demoWalletEthAttoEth, demoWalletRepAttoR import { MarketDetail } from '../features/MarketDetail.tsx' import { Help, Liquidity, MarketList, Portfolio, SecurityPoolDetails } from '../features/Routes.tsx' import { LiveTrading, type WalletSummaryState } from '../features/LiveTrading.tsx' +import { TradingDeploymentSetup, type TradingDeploymentSetupServices } from '../features/TradingDeploymentSetup.tsx' import { loadDeploymentConfiguration, type DeploymentConfiguration } from '../protocol/config.ts' import { createTradingPublicClient, publicErrorMessage, validateLiveDeployment } from '../protocol/live.ts' import { formatUnits, shortAddress } from './format.ts' -const tradingRoutes = ['markets', 'market', 'liquidity', 'portfolio', 'help'] as const +const tradingRoutes = ['markets', 'market', 'liquidity', 'portfolio', 'deploy', 'help'] as const type TradingRoute = (typeof tradingRoutes)[number] | `security-pool/${string}` | 'not-found' export function currentRoute(): TradingRoute { @@ -92,7 +93,7 @@ type LiveDeploymentStatus = 'loading' | 'verified' | 'unavailable' async function resolveLiveDeployment() { const loaded = await loadDeploymentConfiguration() - if (loaded === undefined) throw new Error('Missing deployment.json. Build with a reviewed trading deployment manifest.') + if (loaded === undefined) throw new Error('No bundled or wallet-deployed trading configuration was found.') await validateLiveDeployment(createTradingPublicClient(loaded), loaded) return loaded } @@ -219,7 +220,7 @@ export function walletSummaryForUniverse(summary: WalletSummaryState, selectedUn } function routeOwnsLiveWallet(route: string) { - return route !== 'help' + return route !== 'deploy' && route !== 'help' } export function walletSummaryAfterRouteChange(summary: WalletSummaryState, previousRoute: string, nextRoute: string, selectedUniverseId: string | undefined): WalletSummaryState { @@ -260,7 +261,7 @@ function demoUniverseLabel(market: ReturnType, compactId: str return `Universe ${compactId}` } -export function App({ loadLiveDeployment = resolveLiveDeployment }: { loadLiveDeployment?: () => Promise } = {}) { +export function App({ deploymentSetupServices, loadLiveDeployment = resolveLiveDeployment }: { deploymentSetupServices?: TradingDeploymentSetupServices; loadLiveDeployment?: () => Promise } = {}) { const query = new URLSearchParams(window.location.search) const demo = query.get('demo') === '1' const scenario = query.get('scenario') ?? 'baseline' @@ -303,7 +304,7 @@ export function App({ loadLiveDeployment = resolveLiveDeployment }: { loadLiveDe }) const market = demoMarkets.find(choice => choice.universeId.toString() === selectedUniverseId) ?? initialDemoMarket const universeOptions = demo ? demoUniverseOptions : liveUniverseOptions - const showUniverseSelector = route !== 'help' + const showUniverseSelector = route !== 'deploy' && route !== 'help' const walletSummary = demo ? demoWalletSummary(scenario, market.universeId, demoWalletRetrySucceeded) : walletSummaryForUniverse(liveWalletSummary, selectedUniverseId) const retryWalletSummary = () => { if (demo) setDemoWalletRetrySucceeded(true) @@ -318,6 +319,11 @@ export function App({ loadLiveDeployment = resolveLiveDeployment }: { loadLiveDe setLiveDeploymentStatus('loading') setDeploymentRetryNonce(current => current + 1) } + const completeWalletDeployment = useCallback((configuration: DeploymentConfiguration) => { + setLiveConfiguration(configuration) + setLiveConfigurationError(undefined) + setLiveDeploymentStatus('verified') + }, []) useEffect(() => { const update = () => { if (workflowLockedRef.current) { @@ -363,6 +369,17 @@ export function App({ loadLiveDeployment = resolveLiveDeployment }: { loadLiveDe if (!demo) { if (route === 'not-found') content = resolvedContent else if (route === 'help') content = + else if (route === 'deploy') + content = ( + + ) + else if (liveDeploymentStatus === 'unavailable') content = else content = ( event.preventDefault() : undefined}> Portfolio + event.preventDefault() : undefined}> + Deploy + event.preventDefault() : undefined}> Help diff --git a/trading/ui/ts/features/TradingDeploymentSetup.tsx b/trading/ui/ts/features/TradingDeploymentSetup.tsx new file mode 100644 index 000000000..8b05e74d0 --- /dev/null +++ b/trading/ui/ts/features/TradingDeploymentSetup.tsx @@ -0,0 +1,282 @@ +import { createPublicClient, http, type Hash, type PublicClient } from '@zoltar/shared/ethereum' +import { useEffect, useMemo, useState } from 'preact/hooks' +import { shortAddress } from '../app/format.ts' +import { AddressValue, Status } from '../components/Status.tsx' +import { parseDeploymentSetupInput, saveDeploymentConfiguration, type DeploymentConfiguration } from '../protocol/config.ts' +import { loadCoreDeployments } from '../protocol/coreDeployments.ts' +import { deployTradingStep, deploymentConfigurationForPlan, getTradingDeploymentPlan, loadTradingDeploymentStatus, nextTradingDeploymentStep, type CoreDeployment, type TradingDeploymentPlan } from '../protocol/deployment.ts' +import { getInjectedEthereum } from '../protocol/injected.ts' +import { connectWallet, createTradingWalletClient, publicErrorMessage, switchWalletChain, validateRpcChainId, walletChainId } from '../protocol/live.ts' + +export type TradingDeploymentSetupServices = Readonly<{ + createPublicClient(rpcUrl: string): PublicClient + loadCoreDeployments(): Promise + saveConfiguration(configuration: DeploymentConfiguration): void +}> + +const defaultServices: TradingDeploymentSetupServices = { + createPublicClient: rpcUrl => createPublicClient({ transport: http(rpcUrl) }), + loadCoreDeployments, + saveConfiguration: configuration => saveDeploymentConfiguration(configuration), +} + +type DeploymentStatus = Readonly<{ factory: boolean; router: boolean }> + +function initialQueryValue(name: string) { + return new URLSearchParams(window.location.search).get(name) ?? '' +} + +function deploymentProgress(status: DeploymentStatus | undefined) { + if (status === undefined) return '—' + return `${Number(status.factory) + Number(status.router)} / 2` +} + +function inspectionPresentation(state: 'idle' | 'loading' | 'ready' | 'error') { + if (state === 'loading') return { label: 'Checking network', tone: 'neutral' as const } + if (state === 'ready') return { label: 'Ready to deploy', tone: 'good' as const } + if (state === 'error') return { label: 'Configuration unavailable', tone: 'warn' as const } + return { label: 'Enter network settings', tone: 'neutral' as const } +} + +function deploymentActionLabel(busy: boolean, nextStep: ReturnType) { + if (busy) return `Deploying ${nextStep?.label ?? 'contract'}…` + if (nextStep === undefined) return 'Deployment ready' + return `Deploy ${nextStep.label}` +} + +export function TradingDeploymentSetup({ + configurationError, + currentConfiguration, + onComplete, + onRetryManifest, + services = defaultServices, +}: { + configurationError: string | undefined + currentConfiguration?: DeploymentConfiguration + onComplete(configuration: DeploymentConfiguration): void + onRetryManifest(): void + services?: TradingDeploymentSetupServices +}) { + const [coreDeployments, setCoreDeployments] = useState([]) + const [registryError, setRegistryError] = useState() + const [chainId, setChainId] = useState(initialQueryValue('chainId') || currentConfiguration?.chainId.toString() || '') + const [rpcUrl, setRpcUrl] = useState(initialQueryValue('rpcUrl') || currentConfiguration?.rpcUrl || '') + const [feeBps, setFeeBps] = useState(initialQueryValue('feeBps') || currentConfiguration?.feeBps.toString() || '30') + const [inspectionState, setInspectionState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') + const [inspectionError, setInspectionError] = useState() + const [plan, setPlan] = useState() + const [publicClient, setPublicClient] = useState() + const [deploymentStatus, setDeploymentStatus] = useState() + const [busy, setBusy] = useState(false) + const [actionMessage, setActionMessage] = useState() + const [submittedHash, setSubmittedHash] = useState() + const selectedCore = useMemo(() => coreDeployments.find(deployment => deployment.chainId.toString() === chainId), [chainId, coreDeployments]) + let inputError: string | undefined + if (chainId !== '' && rpcUrl !== '' && feeBps !== '') { + try { + parseDeploymentSetupInput({ chainId, feeBps, rpcUrl }) + } catch (error) { + inputError = publicErrorMessage(error, 'Deployment settings are invalid') + } + } + + useEffect(() => { + let active = true + void services + .loadCoreDeployments() + .then(deployments => { + if (!active) return + setCoreDeployments(deployments) + setRegistryError(undefined) + }) + .catch(error => { + if (!active) return + setRegistryError(publicErrorMessage(error, 'Unable to load canonical core deployments')) + }) + return () => { + active = false + } + }, [services]) + + useEffect(() => { + setPlan(undefined) + setPublicClient(undefined) + setDeploymentStatus(undefined) + setActionMessage(undefined) + setSubmittedHash(undefined) + if (selectedCore === undefined || chainId === '' || rpcUrl === '' || feeBps === '' || inputError !== undefined) { + setInspectionState('idle') + setInspectionError(undefined) + return + } + let active = true + setInspectionState('loading') + setInspectionError(undefined) + void (async () => { + try { + const input = parseDeploymentSetupInput({ chainId, feeBps, rpcUrl }) + const client = services.createPublicClient(input.rpcUrl) + validateRpcChainId(await client.getChainId(), input.chainId) + const nextPlan = getTradingDeploymentPlan(selectedCore, input.feeBps) + const status = await loadTradingDeploymentStatus(client, nextPlan) + if (!active) return + setPublicClient(client) + setPlan(nextPlan) + setDeploymentStatus(status) + setInspectionState('ready') + if (status.factory && status.router) { + const configuration = deploymentConfigurationForPlan(nextPlan, input.rpcUrl) + services.saveConfiguration(configuration) + onComplete(configuration) + return + } + } catch (error) { + if (!active) return + setInspectionState('error') + setInspectionError(publicErrorMessage(error, 'Unable to inspect the selected deployment')) + } + })() + return () => { + active = false + } + }, [chainId, feeBps, inputError, onComplete, rpcUrl, selectedCore, services]) + + const nextStep = plan === undefined || deploymentStatus === undefined ? undefined : nextTradingDeploymentStep(plan, deploymentStatus) + const inspection = inspectionPresentation(inspectionState) + async function deployNext() { + if (busy || plan === undefined || publicClient === undefined || deploymentStatus === undefined || nextStep === undefined) return + setBusy(true) + setActionMessage(undefined) + setSubmittedHash(undefined) + let broadcastHash: Hash | undefined + try { + const provider = getInjectedEthereum() + if (provider === undefined) throw new Error('No injected wallet was found') + let currentChainId = await walletChainId(provider) + if (currentChainId !== plan.core.chainId) { + await switchWalletChain(provider, plan.core.chainId) + currentChainId = await walletChainId(provider) + } + if (currentChainId !== plan.core.chainId) throw new Error(`Wallet must use ${plan.core.chainName}`) + const account = await connectWallet(provider) + const walletClient = createTradingWalletClient(provider, account) + await deployTradingStep(walletClient, publicClient, plan, nextStep, hash => { + broadcastHash = hash + setSubmittedHash(hash) + }) + if (getInjectedEthereum() !== provider || (await walletChainId(provider)) !== plan.core.chainId || (await connectWallet(provider)) !== account) throw new Error('Wallet context changed during deployment; verify the transaction before continuing') + const status = await loadTradingDeploymentStatus(publicClient, plan) + setDeploymentStatus(status) + setSubmittedHash(undefined) + if (status.factory && status.router) { + const input = parseDeploymentSetupInput({ chainId, feeBps, rpcUrl }) + const configuration = deploymentConfigurationForPlan(plan, input.rpcUrl) + services.saveConfiguration(configuration) + onComplete(configuration) + return + } + setActionMessage(`${nextStep.label} deployed. Continue with ${nextTradingDeploymentStep(plan, status)?.label ?? 'the next contract'}.`) + } catch (error) { + const detail = publicErrorMessage(error, `Failed to deploy ${nextStep.label}`) + setActionMessage(broadcastHash === undefined ? detail : `Transaction ${broadcastHash} was broadcast but setup did not finish. Verify it in your wallet before retrying. ${detail}`) + } finally { + setBusy(false) + } + } + + return ( +
+
+
+ Standalone live client +

Set up two-way trading

+

Select a canonical Zoltar deployment and submit the two deterministic trading contracts from your wallet.

+
+
+
+ {configurationError === undefined ? null : ( + + )} +
+ + + +
+ {registryError === undefined ? null : ( + + )} + {inputError === undefined ? null : ( + + )} + {selectedCore === undefined ? null : ( +
+
+
SecurityPoolFactory
+
+ +
+
+
+
Trading factory
+
{plan === undefined ? 'Calculated after RPC verification' : {shortAddress(plan.factory.address)}}
+
+
+
Router
+
{plan === undefined ? 'Calculated after RPC verification' : {shortAddress(plan.router.address)}}
+
+
+ )} +
+
+ Deployment progress + {deploymentProgress(deploymentStatus)} +
+ {inspection.label} +
+ {inspectionError === undefined ? null : ( + + )} + {actionMessage === undefined ? null : ( +

+ {actionMessage} +

+ )} +
+ + +
+
+
+ ) +} diff --git a/trading/ui/ts/protocol/config.ts b/trading/ui/ts/protocol/config.ts index 7a934f911..0ff8143dd 100644 --- a/trading/ui/ts/protocol/config.ts +++ b/trading/ui/ts/protocol/config.ts @@ -10,6 +10,9 @@ export type DeploymentConfiguration = Readonly<{ feeBps: number }> +type ConfigurationStorage = Pick +const deploymentStorageKey = 'zoltar.trading.deployment.v1' + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } @@ -39,6 +42,16 @@ function requiredRpcUrl(value: unknown) { return parsed.toString() } +export function parseDeploymentSetupInput(input: Readonly<{ chainId: string; feeBps: string; rpcUrl: string }>) { + if (!/^[1-9][0-9]*$/.test(input.chainId)) throw new Error('Chain ID must be a positive whole number') + const chainId = Number(input.chainId) + if (!Number.isSafeInteger(chainId)) throw new Error('Chain ID must be a positive safe integer') + if (!/^[0-9]+$/.test(input.feeBps)) throw new Error('Trading fee must be a whole number from 0 to 9999 basis points') + const feeBps = Number(input.feeBps) + if (!Number.isSafeInteger(feeBps) || feeBps >= 10_000) throw new Error('Trading fee must be a whole number from 0 to 9999 basis points') + return { chainId, feeBps, rpcUrl: requiredRpcUrl(input.rpcUrl) } +} + function requiredAddress(value: unknown, label: string) { const address = requiredString(value, label) if (!isAddress(address)) throw new Error(`${label} must be a valid address`) @@ -70,5 +83,17 @@ export async function loadDeploymentConfiguration(): Promise): DeploymentConfiguration | undefined { + const raw = storage.getItem(deploymentStorageKey) + if (raw === null) return undefined + const candidate: unknown = JSON.parse(raw) + return parseDeploymentConfiguration(candidate) +} + +export function saveDeploymentConfiguration(configuration: DeploymentConfiguration, storage: Pick = window.localStorage) { + storage.setItem(deploymentStorageKey, JSON.stringify(configuration)) } diff --git a/trading/ui/ts/protocol/coreDeployments.ts b/trading/ui/ts/protocol/coreDeployments.ts new file mode 100644 index 000000000..4f83a0a1a --- /dev/null +++ b/trading/ui/ts/protocol/coreDeployments.ts @@ -0,0 +1,45 @@ +import { getAddress, isAddress } from '@zoltar/shared/ethereum' +import type { CoreDeployment } from './deployment.ts' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function requiredString(value: unknown, label: string) { + if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} is required`) + return value +} + +function requiredAddress(value: unknown, label: string) { + const address = requiredString(value, label) + if (!isAddress(address)) throw new Error(`${label} must be a valid address`) + return getAddress(address) +} + +export function parseCoreDeployments(candidate: unknown): readonly CoreDeployment[] { + if (!Array.isArray(candidate) || candidate.length === 0) throw new Error('Core deployment registry must contain at least one network') + const deployments = candidate.map((value, index) => { + if (!isRecord(value)) throw new Error(`Core deployment ${index.toString()} must be an object`) + if (typeof value.chainId !== 'number' || !Number.isSafeInteger(value.chainId) || value.chainId <= 0) throw new Error(`Core deployment ${index.toString()} chainId must be a positive safe integer`) + return { + chainId: value.chainId, + chainName: requiredString(value.chainName, `Core deployment ${index.toString()} chainName`), + id: requiredString(value.id, `Core deployment ${index.toString()} id`), + proxyDeployer: requiredAddress(value.proxyDeployer, `Core deployment ${index.toString()} proxyDeployer`), + securityPoolFactory: requiredAddress(value.securityPoolFactory, `Core deployment ${index.toString()} securityPoolFactory`), + } + }) + const chainIds = new Set() + for (const deployment of deployments) { + if (chainIds.has(deployment.chainId)) throw new Error(`Core deployment registry repeats chain ${deployment.chainId.toString()}`) + chainIds.add(deployment.chainId) + } + return deployments +} + +export async function loadCoreDeployments() { + const response = await fetch('./core-deployments.json', { cache: 'no-store' }) + if (!response.ok) throw new Error(`Core deployment registry failed with HTTP ${response.status.toString()}`) + const candidate: unknown = await response.json() + return parseCoreDeployments(candidate) +} diff --git a/trading/ui/ts/protocol/deployment.ts b/trading/ui/ts/protocol/deployment.ts new file mode 100644 index 000000000..706220c23 --- /dev/null +++ b/trading/ui/ts/protocol/deployment.ts @@ -0,0 +1,124 @@ +import { encodeDeployData, getAddress, getCreate2Address, toHex, type Address, type Hash, type Hex, type PublicClient, type WalletClient } from '@zoltar/shared/ethereum' +import { tradingContracts } from '../generated/contractArtifact.ts' +import type { DeploymentConfiguration } from './config.ts' + +export type CoreDeployment = Readonly<{ + chainId: number + chainName: string + id: string + proxyDeployer: Address + securityPoolFactory: Address +}> + +export type TradingDeploymentStepId = 'factory' | 'router' + +export type TradingDeploymentStep = Readonly<{ + address: Address + data: Hex + dependencies: readonly TradingDeploymentStepId[] + id: TradingDeploymentStepId + label: string +}> + +export type TradingDeploymentPlan = Readonly<{ + core: CoreDeployment + factory: TradingDeploymentStep + feeBps: number + router: TradingDeploymentStep +}> + +const factoryContract = tradingContracts['trading/contracts/TwoWayConstantProductFactory.sol'].TwoWayConstantProductFactory +const routerContract = tradingContracts['trading/contracts/TwoWayConstantProductRouter.sol'].TwoWayConstantProductRouter +const zeroSalt = toHex(0, { size: 32 }) +export const CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE = '0x60003681823780368234f58015156014578182fd5b80825250506014600cf3' satisfies Hex + +function requireFeeBps(feeBps: number) { + if (!Number.isSafeInteger(feeBps) || feeBps < 0 || feeBps >= 10_000) throw new Error('Trading fee must be a whole number from 0 to 9999 basis points') + return feeBps +} + +export function getTradingDeploymentPlan(core: CoreDeployment, feeBps: number): TradingDeploymentPlan { + const checkedFeeBps = requireFeeBps(feeBps) + const factoryData = encodeDeployData({ + abi: factoryContract.abi, + bytecode: `0x${factoryContract.evm.bytecode.object}`, + args: [core.securityPoolFactory, BigInt(checkedFeeBps)], + }) + const factoryAddress = getCreate2Address({ bytecode: factoryData, from: core.proxyDeployer, salt: zeroSalt }) + const routerData = encodeDeployData({ + abi: routerContract.abi, + bytecode: `0x${routerContract.evm.bytecode.object}`, + args: [factoryAddress], + }) + const routerAddress = getCreate2Address({ bytecode: routerData, from: core.proxyDeployer, salt: zeroSalt }) + return { + core, + factory: { address: factoryAddress, data: factoryData, dependencies: [], id: 'factory', label: 'Two-way trading factory' }, + feeBps: checkedFeeBps, + router: { address: routerAddress, data: routerData, dependencies: ['factory'], id: 'router', label: 'Two-way trading router' }, + } +} + +export function deploymentConfigurationForPlan(plan: TradingDeploymentPlan, rpcUrl: string): DeploymentConfiguration { + return { + chainId: plan.core.chainId, + chainName: plan.core.chainName, + factory: plan.factory.address, + feeBps: plan.feeBps, + router: plan.router.address, + rpcUrl, + securityPoolFactory: plan.core.securityPoolFactory, + } +} + +async function requireCode(client: Pick, address: Address, label: string) { + const code = await client.getCode({ address }) + if (code === undefined || code === '0x') throw new Error(`${label} has no code at ${address}`) +} + +export async function validateTradingFactory(client: Pick, plan: TradingDeploymentPlan) { + const [securityPoolFactory, feeBps] = await Promise.all([client.readContract({ abi: factoryContract.abi, address: plan.factory.address, functionName: 'securityPoolFactory' }), client.readContract({ abi: factoryContract.abi, address: plan.factory.address, functionName: 'feeBps' })]) + if (getAddress(securityPoolFactory) !== plan.core.securityPoolFactory) throw new Error('Trading factory references a different SecurityPoolFactory') + if (feeBps !== BigInt(plan.feeBps)) throw new Error('Trading factory fee does not match the selected fee') +} + +export async function validateTradingRouter(client: Pick, plan: TradingDeploymentPlan) { + const factory = await client.readContract({ abi: routerContract.abi, address: plan.router.address, functionName: 'factory' }) + if (getAddress(factory) !== plan.factory.address) throw new Error('Trading router references a different factory') +} + +export async function loadTradingDeploymentStatus(client: Pick, plan: TradingDeploymentPlan) { + const [proxyCode] = await Promise.all([client.getCode({ address: plan.core.proxyDeployer }), requireCode(client, plan.core.securityPoolFactory, 'SecurityPoolFactory')]) + if (proxyCode === undefined || proxyCode.toLowerCase() !== CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE.toLowerCase()) throw new Error(`Canonical proxy deployer has unexpected code at ${plan.core.proxyDeployer}`) + const factoryCode = await client.getCode({ address: plan.factory.address }) + const factoryDeployed = factoryCode !== undefined && factoryCode !== '0x' + if (factoryDeployed) await validateTradingFactory(client, plan) + const routerCode = await client.getCode({ address: plan.router.address }) + const routerDeployed = routerCode !== undefined && routerCode !== '0x' + if (routerDeployed) { + if (!factoryDeployed) throw new Error('Trading router exists without its expected factory') + await validateTradingRouter(client, plan) + } + return { factory: factoryDeployed, router: routerDeployed } +} + +export function nextTradingDeploymentStep(plan: TradingDeploymentPlan, status: Readonly<{ factory: boolean; router: boolean }>) { + if (!status.factory) return plan.factory + if (!status.router) return plan.router + return undefined +} + +export async function deployTradingStep(walletClient: Pick, publicClient: Pick, plan: TradingDeploymentPlan, step: TradingDeploymentStep, onSubmitted: (hash: Hash) => void = () => undefined): Promise { + const status = await loadTradingDeploymentStatus(publicClient, plan) + if (status[step.id]) throw new Error(`${step.label} is already deployed`) + for (const dependency of step.dependencies) { + if (!status[dependency]) throw new Error(`Deploy ${plan[dependency].label} first`) + } + const hash = await walletClient.sendTransaction({ to: plan.core.proxyDeployer, data: step.data }) + onSubmitted(hash) + const receipt = await walletClient.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new Error(`${step.label} deployment reverted`) + const refreshed = await loadTradingDeploymentStatus(publicClient, plan) + if (!refreshed[step.id]) throw new Error(`${step.label} deployment confirmed without installing the expected contract`) + return hash +} diff --git a/trading/ui/ts/tests/build-deployment.test.ts b/trading/ui/ts/tests/build-deployment.test.ts index df68792b1..59c08d02b 100644 --- a/trading/ui/ts/tests/build-deployment.test.ts +++ b/trading/ui/ts/tests/build-deployment.test.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { resolveDeploymentSource } from '../../build/deployment.mts' describe('trading UI build deployment', () => { - test('uses demo mode when the deployment environment variable is empty', () => { + test('uses browser wallet setup when the deployment environment variable is empty', () => { expect(resolveDeploymentSource(undefined)).toBeUndefined() expect(resolveDeploymentSource('')).toBeUndefined() expect(resolveDeploymentSource(' ')).toBeUndefined() diff --git a/trading/ui/ts/tests/config.test.ts b/trading/ui/ts/tests/config.test.ts index 5b4b387c7..99e5cfbcd 100644 --- a/trading/ui/ts/tests/config.test.ts +++ b/trading/ui/ts/tests/config.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { createPublicClient, custom, encodeAbiParameters, getAddress } from '@zoltar/shared/ethereum' -import { parseDeploymentConfiguration } from '../protocol/config.ts' +import { loadStoredDeploymentConfiguration, parseDeploymentConfiguration, parseDeploymentSetupInput, saveDeploymentConfiguration } from '../protocol/config.ts' +import { parseCoreDeployments } from '../protocol/coreDeployments.ts' import { loadWalletHeaderBalances, validateRpcChainId } from '../protocol/live.ts' const factory = `0x${'12'.repeat(20)}` @@ -34,6 +35,29 @@ describe('trading UI deployment configuration', () => { expect(validateRpcChainId(1, 1)).toBeUndefined() }) + test('loads canonical core deployment choices copied from the root manifests', () => { + const deployments = parseCoreDeployments([{ chainId: 11_155_111, chainName: 'Sepolia', id: 'sepolia', proxyDeployer: `0x${'45'.repeat(20)}`, securityPoolFactory: core }]) + expect(deployments[0]?.chainId).toBe(11_155_111) + expect(deployments[0]?.securityPoolFactory.toLowerCase()).toBe(core) + }) + + test('persists a wallet-deployed trading configuration for reloads', () => { + const values = new Map() + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + } + const configuration = parseDeploymentConfiguration({ chainId: 1, chainName: 'Mainnet', rpcUrl: 'https://example.test', securityPoolFactory: core, factory, router, feeBps: 30 }) + saveDeploymentConfiguration(configuration, storage) + expect(loadStoredDeploymentConfiguration(storage)).toEqual(configuration) + }) + + test('validates user-selected chain, RPC, and fee settings together', () => { + expect(parseDeploymentSetupInput({ chainId: '11155111', feeBps: '30', rpcUrl: 'https://example.test' })).toEqual({ chainId: 11_155_111, feeBps: 30, rpcUrl: 'https://example.test/' }) + expect(() => parseDeploymentSetupInput({ chainId: '1.5', feeBps: '30', rpcUrl: 'https://example.test' })).toThrow('Chain ID') + expect(() => parseDeploymentSetupInput({ chainId: '1', feeBps: '10000', rpcUrl: 'https://example.test' })).toThrow('fee') + }) + test('loads ETH and REP from the selected SecurityPool universe', async () => { const account = getAddress(`0x${'45'.repeat(20)}`) const pool = getAddress(`0x${'56'.repeat(20)}`) diff --git a/trading/ui/ts/tests/deployment-flow.test.ts b/trading/ui/ts/tests/deployment-flow.test.ts new file mode 100644 index 000000000..0121cf923 --- /dev/null +++ b/trading/ui/ts/tests/deployment-flow.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'bun:test' +import { getAddress, type Address, type Hash } from '@zoltar/shared/ethereum' +import { CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE, deployTradingStep, getTradingDeploymentPlan, loadTradingDeploymentStatus, nextTradingDeploymentStep } from '../protocol/deployment.ts' + +function examplePlan() { + return getTradingDeploymentPlan( + { + chainId: 11_155_111, + chainName: 'Sepolia', + id: 'sepolia', + proxyDeployer: getAddress(`0x${'12'.repeat(20)}`), + securityPoolFactory: getAddress(`0x${'34'.repeat(20)}`), + }, + 30, + ) +} + +describe('wallet trading deployment plan', () => { + test('derives stable factory and router addresses from the canonical proxy', () => { + const core = { + chainId: 11_155_111, + chainName: 'Sepolia', + id: 'sepolia', + proxyDeployer: getAddress(`0x${'12'.repeat(20)}`), + securityPoolFactory: getAddress(`0x${'34'.repeat(20)}`), + } + const first = getTradingDeploymentPlan(core, 30) + const second = getTradingDeploymentPlan(core, 30) + + expect(first.factory.address).toBe(second.factory.address) + expect(first.router.address).toBe(second.router.address) + expect(first.factory.address).not.toBe(first.router.address) + expect(first.factory.dependencies).toEqual([]) + expect(first.router.dependencies).toEqual(['factory']) + }) + + test('changes the deterministic deployment when its immutable fee changes', () => { + const core = { + chainId: 1, + chainName: 'Ethereum Mainnet', + id: 'mainnet', + proxyDeployer: getAddress(`0x${'56'.repeat(20)}`), + securityPoolFactory: getAddress(`0x${'78'.repeat(20)}`), + } + expect(getTradingDeploymentPlan(core, 30).factory.address).not.toBe(getTradingDeploymentPlan(core, 25).factory.address) + }) + + test('resumes at the first missing dependency', () => { + const plan = getTradingDeploymentPlan( + { + chainId: 1, + chainName: 'Ethereum Mainnet', + id: 'mainnet', + proxyDeployer: getAddress(`0x${'9a'.repeat(20)}`), + securityPoolFactory: getAddress(`0x${'bc'.repeat(20)}`), + }, + 30, + ) + expect(nextTradingDeploymentStep(plan, { factory: false, router: false })?.id).toBe('factory') + expect(nextTradingDeploymentStep(plan, { factory: true, router: false })?.id).toBe('router') + expect(nextTradingDeploymentStep(plan, { factory: true, router: true })).toBeUndefined() + }) + + test('rejects a network without the exact canonical proxy deployer runtime', async () => { + const plan = examplePlan() + const client = { + getCode: async ({ address }: { address: Address }) => (address === plan.core.securityPoolFactory ? '0x01' : '0x02'), + readContract: async () => 0n, + } + await expect(loadTradingDeploymentStatus(client, plan)).rejects.toThrow('Canonical proxy deployer has unexpected code') + }) + + test('submits the factory init code through the canonical proxy and verifies the installed contract', async () => { + const plan = examplePlan() + const hash = `0x${'ab'.repeat(32)}` satisfies Hash + let factoryDeployed = false + const transactions: Array> = [] + const publicClient = { + getCode: async ({ address }: { address: Address }) => { + if (address === plan.core.proxyDeployer) return CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE + if (address === plan.core.securityPoolFactory) return '0x01' + if (address === plan.factory.address && factoryDeployed) return '0x01' + return '0x' + }, + readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === 'securityPoolFactory') return plan.core.securityPoolFactory + if (functionName === 'feeBps') return BigInt(plan.feeBps) + throw new Error(`Unexpected read ${functionName}`) + }, + } + const walletClient = { + sendTransaction: async (transaction: Readonly<{ data?: string; to?: Address }>) => { + if (transaction.data === undefined || transaction.to === undefined) throw new Error('Missing deployment transaction fields') + transactions.push({ data: transaction.data, to: transaction.to }) + return hash + }, + waitForTransactionReceipt: async () => { + factoryDeployed = true + return { status: 'success' as const } + }, + } + + expect(await deployTradingStep(walletClient, publicClient, plan, plan.factory)).toBe(hash) + expect(transactions).toEqual([{ data: plan.factory.data, to: plan.core.proxyDeployer }]) + }) +}) diff --git a/trading/ui/ts/tests/deployment-setup.test.tsx b/trading/ui/ts/tests/deployment-setup.test.tsx new file mode 100644 index 000000000..9dcfe18de --- /dev/null +++ b/trading/ui/ts/tests/deployment-setup.test.tsx @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { createPublicClient, custom, getAddress } from '@zoltar/shared/ethereum' +import { act } from 'preact/test-utils' +import { installDomEnvironment } from '../../../../ui/ts/tests/testUtils/domEnvironment.ts' +import { TradingDeploymentSetup, type TradingDeploymentSetupServices } from '../features/TradingDeploymentSetup.tsx' +import { CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE } from '../protocol/deployment.ts' +import { renderIntoDocument } from './test-support/renderIntoDocument.tsx' + +describe('trading deployment setup', () => { + let cleanupDom: (() => void) | undefined + let cleanupRendered: (() => Promise) | undefined + + beforeEach(() => { + cleanupDom = installDomEnvironment('http://localhost/#/markets').cleanup + }) + + afterEach(async () => { + await cleanupRendered?.() + cleanupRendered = undefined + cleanupDom?.() + cleanupDom = undefined + }) + + test('automatically verifies selected network settings and exposes the first deployment step', async () => { + const core = { + chainId: 11_155_111, + chainName: 'Sepolia', + id: 'sepolia', + proxyDeployer: getAddress(`0x${'12'.repeat(20)}`), + securityPoolFactory: getAddress(`0x${'34'.repeat(20)}`), + } + const client = createPublicClient({ + transport: custom({ + request: async ({ method, params }) => { + if (method === 'eth_chainId') return '0xaa36a7' + if (method === 'eth_getCode' && Array.isArray(params)) { + const address = params[0] + if (typeof address === 'string' && address.toLowerCase() === core.proxyDeployer.toLowerCase()) return CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE + return typeof address === 'string' && address.toLowerCase() === core.securityPoolFactory.toLowerCase() ? '0x01' : '0x' + } + throw new Error(`Unexpected RPC method ${method}`) + }, + }), + }) + const services: TradingDeploymentSetupServices = { + createPublicClient: () => client, + loadCoreDeployments: async () => [core], + saveConfiguration: () => undefined, + } + const rendered = await renderIntoDocument( undefined} onRetryManifest={() => undefined} services={services} />) + cleanupRendered = rendered.cleanup + await act(async () => { + await Bun.sleep(0) + }) + const select = rendered.container.querySelector('select') + const rpcInput = rendered.container.querySelector('input[type="url"]') + if (select === null || rpcInput === null) throw new Error('Deployment setup fields are unavailable') + await act(async () => { + select.value = core.chainId.toString() + select.dispatchEvent(new Event('change', { bubbles: true })) + rpcInput.value = 'https://rpc.example' + rpcInput.dispatchEvent(new Event('input', { bubbles: true })) + }) + await act(async () => { + await Bun.sleep(30) + }) + expect(rendered.container.textContent).toContain('Ready to deploy') + expect(rendered.container.textContent).toContain('Deploy Two-way trading factory') + expect(rendered.container.textContent).toContain('0 / 2') + }) +}) diff --git a/trading/ui/ts/tests/essential-copy.test.tsx b/trading/ui/ts/tests/essential-copy.test.tsx index cc5cf95b3..7ecb22c79 100644 --- a/trading/ui/ts/tests/essential-copy.test.tsx +++ b/trading/ui/ts/tests/essential-copy.test.tsx @@ -66,12 +66,12 @@ describe('essential trading copy', () => { expect(rendered.container.textContent).not.toContain('Fork continuation') }) - test('keeps deployment out of navigation and pool internals in the security pool view', async () => { + test('keeps wallet deployment available without exposing pool internals outside the security pool view', async () => { const market = demoMarket('baseline') const rendered = await renderIntoDocument() cleanupRendered = rendered.cleanup expect(rendered.container.querySelector('nav')?.textContent).not.toContain('Developer') - expect(rendered.container.querySelector('nav')?.textContent).not.toContain('Deployment') + expect(rendered.container.querySelector('nav')?.textContent).toContain('Deploy') expect(rendered.container.textContent).not.toContain('Outcome token IDs') expect(rendered.container.textContent).not.toContain('System state') expect(rendered.container.textContent).not.toContain('Security multiplier') From eec4de8095edf76f01596ebe989303ecb9b02475 Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:10:56 +0000 Subject: [PATCH 03/10] Tighten trading deployment client tests --- trading/ui/ts/protocol/deployment.ts | 9 +++- trading/ui/ts/tests/deployment-flow.test.ts | 49 +++++++++++++-------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/trading/ui/ts/protocol/deployment.ts b/trading/ui/ts/protocol/deployment.ts index 706220c23..a0ef0d46f 100644 --- a/trading/ui/ts/protocol/deployment.ts +++ b/trading/ui/ts/protocol/deployment.ts @@ -1,4 +1,4 @@ -import { encodeDeployData, getAddress, getCreate2Address, toHex, type Address, type Hash, type Hex, type PublicClient, type WalletClient } from '@zoltar/shared/ethereum' +import { encodeDeployData, getAddress, getCreate2Address, toHex, type Address, type Hash, type Hex, type PublicClient } from '@zoltar/shared/ethereum' import { tradingContracts } from '../generated/contractArtifact.ts' import type { DeploymentConfiguration } from './config.ts' @@ -27,6 +27,11 @@ export type TradingDeploymentPlan = Readonly<{ router: TradingDeploymentStep }> +type TradingDeploymentWallet = Readonly<{ + sendTransaction(transaction: Readonly<{ data: Hex; to: Address }>): Promise + waitForTransactionReceipt(parameters: Readonly<{ hash: Hash }>): Promise> +}> + const factoryContract = tradingContracts['trading/contracts/TwoWayConstantProductFactory.sol'].TwoWayConstantProductFactory const routerContract = tradingContracts['trading/contracts/TwoWayConstantProductRouter.sol'].TwoWayConstantProductRouter const zeroSalt = toHex(0, { size: 32 }) @@ -108,7 +113,7 @@ export function nextTradingDeploymentStep(plan: TradingDeploymentPlan, status: R return undefined } -export async function deployTradingStep(walletClient: Pick, publicClient: Pick, plan: TradingDeploymentPlan, step: TradingDeploymentStep, onSubmitted: (hash: Hash) => void = () => undefined): Promise { +export async function deployTradingStep(walletClient: TradingDeploymentWallet, publicClient: Pick, plan: TradingDeploymentPlan, step: TradingDeploymentStep, onSubmitted: (hash: Hash) => void = () => undefined): Promise { const status = await loadTradingDeploymentStatus(publicClient, plan) if (status[step.id]) throw new Error(`${step.label} is already deployed`) for (const dependency of step.dependencies) { diff --git a/trading/ui/ts/tests/deployment-flow.test.ts b/trading/ui/ts/tests/deployment-flow.test.ts index 0121cf923..fb022f0e3 100644 --- a/trading/ui/ts/tests/deployment-flow.test.ts +++ b/trading/ui/ts/tests/deployment-flow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { getAddress, type Address, type Hash } from '@zoltar/shared/ethereum' +import { createPublicClient, custom, encodeAbiParameters, getAddress, type Address, type Hash } from '@zoltar/shared/ethereum' import { CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE, deployTradingStep, getTradingDeploymentPlan, loadTradingDeploymentStatus, nextTradingDeploymentStep } from '../protocol/deployment.ts' function examplePlan() { @@ -63,10 +63,14 @@ describe('wallet trading deployment plan', () => { test('rejects a network without the exact canonical proxy deployer runtime', async () => { const plan = examplePlan() - const client = { - getCode: async ({ address }: { address: Address }) => (address === plan.core.securityPoolFactory ? '0x01' : '0x02'), - readContract: async () => 0n, - } + const client = createPublicClient({ + transport: custom({ + request: async ({ method, params }) => { + if (method !== 'eth_getCode' || !Array.isArray(params)) throw new Error(`Unexpected RPC method ${method}`) + return typeof params[0] === 'string' && params[0].toLowerCase() === plan.core.securityPoolFactory.toLowerCase() ? '0x01' : '0x02' + }, + }), + }) await expect(loadTradingDeploymentStatus(client, plan)).rejects.toThrow('Canonical proxy deployer has unexpected code') }) @@ -74,20 +78,27 @@ describe('wallet trading deployment plan', () => { const plan = examplePlan() const hash = `0x${'ab'.repeat(32)}` satisfies Hash let factoryDeployed = false - const transactions: Array> = [] - const publicClient = { - getCode: async ({ address }: { address: Address }) => { - if (address === plan.core.proxyDeployer) return CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE - if (address === plan.core.securityPoolFactory) return '0x01' - if (address === plan.factory.address && factoryDeployed) return '0x01' - return '0x' - }, - readContract: async ({ functionName }: { functionName: string }) => { - if (functionName === 'securityPoolFactory') return plan.core.securityPoolFactory - if (functionName === 'feeBps') return BigInt(plan.feeBps) - throw new Error(`Unexpected read ${functionName}`) - }, - } + let contractReadCount = 0 + const transactions: Array> = [] + const publicClient = createPublicClient({ + transport: custom({ + request: async ({ method, params }) => { + if (method === 'eth_getCode' && Array.isArray(params)) { + const address = params[0] + if (typeof address !== 'string') throw new Error('Missing code address') + if (address.toLowerCase() === plan.core.proxyDeployer.toLowerCase()) return CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE + if (address.toLowerCase() === plan.core.securityPoolFactory.toLowerCase()) return '0x01' + if (address.toLowerCase() === plan.factory.address.toLowerCase() && factoryDeployed) return '0x01' + return '0x' + } + if (method === 'eth_call') { + contractReadCount += 1 + return contractReadCount % 2 === 1 ? encodeAbiParameters([{ type: 'address' }], [plan.core.securityPoolFactory]) : encodeAbiParameters([{ type: 'uint16' }], [plan.feeBps]) + } + throw new Error(`Unexpected RPC method ${method}`) + }, + }), + }) const walletClient = { sendTransaction: async (transaction: Readonly<{ data?: string; to?: Address }>) => { if (transaction.data === undefined || transaction.to === undefined) throw new Error('Missing deployment transaction fields') From 6990fda00e2d0b8e39c1bc9ad83ec6adcb6278ba Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:53:40 +0000 Subject: [PATCH 04/10] Polish trading deployment setup --- trading/README.md | 28 +++--- trading/deployments/README.md | 2 +- trading/docs/explanation/limitations.md | 2 +- trading/docs/how-to/deploy.md | 41 +++++++- trading/scripts/browser-qa.mts | 10 +- trading/ts/deploy/manifest.ts | 5 + trading/ts/tests/deploymentManifest.test.ts | 13 +++ trading/ui/ts/app/App.tsx | 14 +-- .../ui/ts/features/TradingDeploymentSetup.tsx | 39 +++++--- trading/ui/ts/tests/deployment-setup.test.tsx | 97 +++++++++++++++---- trading/ui/ts/tests/essential-copy.test.tsx | 4 +- .../ui/ts/tests/universe-selector.test.tsx | 4 +- 12 files changed, 195 insertions(+), 64 deletions(-) diff --git a/trading/README.md b/trading/README.md index f0cb12613..e22ecd213 100644 --- a/trading/README.md +++ b/trading/README.md @@ -16,32 +16,34 @@ The pair trades only YES and NO. Every ETH entry creates a complete set, swaps t ## Quick setup +From this directory, build and start the live UI: + ```bash -cd trading -bun install --frozen-lockfile -bun run compile -bun run test -bun run ui:build -bun run ui:serve +docker compose up --build --force-recreate ``` -Open `http://localhost:4163/?demo=1#/markets`. Demo mode is prominently labeled and makes no live-chain claims. +Open `http://localhost:4163/#/markets`. Select a network whose canonical Zoltar core deployment is installed, enter its RPC URL, and connect a wallet. The repository's public-network manifests contain planned deterministic addresses; the UI verifies the required code before it offers a trading deployment transaction. -### Docker +On Windows, run `start.bat` from this directory to start the same Compose command. The final image runs as an unprivileged user and exposes a health check at `/`. -Build and serve the standalone UI from this directory: +### Local development and demo ```bash -docker compose up --build --force-recreate +bun install --frozen-lockfile +bun run compile +bun run test +bun run ui:build +bun run ui:serve ``` -On Windows, run `start.bat` from this directory to start the same Compose command. The image copies the canonical mainnet and Sepolia core deployment addresses from the root documentation manifests. +Open `http://localhost:4163/?demo=1#/markets`. Demo mode is prominently labeled and makes no live-chain claims. -Then open `http://localhost:4163/#/markets`, select the core network and RPC URL, and connect a wallet. The UI uses the core deployment's deterministic proxy to deploy the two-way factory and router in two wallet transactions. It verifies the RPC chain, core contracts, deterministic addresses, immutable fee, and router-to-factory link before enabling trading. The final image runs as an unprivileged user and exposes a health check at `/`. +The Docker image copies the canonical mainnet and Sepolia core deployment addresses from the root documentation manifests. The live UI uses the installed core deployment's deterministic proxy to deploy the two-way factory and router in two wallet transactions. It verifies the RPC chain, core contracts, deterministic addresses, immutable fee, and router-to-factory link before enabling trading. -To use an existing reviewed project-local trading deployment instead, set its path at build time. The path is relative to `trading/` inside the build context: +To use an existing reviewed trading deployment instead, first copy its manifest into `trading/deployments/`. Then set its project-local path at build time: ```bash +cp /absolute/path/to/reviewed.json deployments/reviewed.json TRADING_UI_DEPLOYMENT=deployments/reviewed.json docker compose up --build --force-recreate ``` diff --git a/trading/deployments/README.md b/trading/deployments/README.md index 70e82caf0..4227f8dd7 100644 --- a/trading/deployments/README.md +++ b/trading/deployments/README.md @@ -2,4 +2,4 @@ `deploy:local` writes `local.json` here. It records the chain, input core `SecurityPoolFactory`, immutable fee, deployed trading factory and router, compiler settings, bytecode hashes, and transaction hashes. `local.json` is ignored because local addresses are ephemeral. -No public-network address is bundled. Build the live standalone UI with `TRADING_UI_DEPLOYMENT=/absolute/path/to/local.json bun run ui:build`; the build copies the manifest to the untracked UI output, and the live client validates it at startup. The client then discovers canonical pools and pairs, simulates router calls, and submits through the connected wallet. See [UI configuration](../docs/how-to/configure-ui.md) for the complete schema and runtime requirements. +No completed public trading deployment manifest is bundled. The default build includes planned mainnet and Sepolia core addresses from the root manifests, then lets the wallet deploy and verify the trading factory and router when that core code is installed. To use an existing trading manifest instead, build with `TRADING_UI_DEPLOYMENT=/absolute/path/to/local.json bun run ui:build`; the build copies it to the untracked UI output, and the live client validates it at startup. See [Deploy the trading contracts](../docs/how-to/deploy.md) for the wallet path and [UI configuration](../docs/how-to/configure-ui.md) for the complete schema and runtime requirements. diff --git a/trading/docs/explanation/limitations.md b/trading/docs/explanation/limitations.md index 27d54f565..acd8b271f 100644 --- a/trading/docs/explanation/limitations.md +++ b/trading/docs/explanation/limitations.md @@ -2,6 +2,6 @@ This MVP intentionally does not implement a three-way invariant, INVALID trading, an invalidity-probability oracle, weighted reserves, quadratic solvers, flash swaps, a protocol fee, governance controls, upgradeable proxies, automatic branch selection, automatic LP migration, an insured-position NFT, or per-user on-chain position accounting. -It also has no TWAP, routing across markets, guaranteed deep liquidity, or mechanism to withdraw more early ETH than complete-set insurance and reserves permit. The UI’s local demo states are visual fixtures, not live-chain evidence; live mode requires an explicit deployment manifest. Public deployments require an independently reviewed manifest, gas benchmarks against a real-core funded lifecycle fixture, adversarial integration testing, and an external audit. +It also has no TWAP, routing across markets, guaranteed deep liquidity, or mechanism to withdraw more early ETH than complete-set insurance and reserves permit. The UI’s local demo states are visual fixtures, not live-chain evidence. Live mode requires either a verified wallet deployment on a supported canonical core network or an explicit reviewed trading manifest. Public deployments require gas benchmarks against a real-core funded lifecycle fixture, adversarial integration testing, and an external audit. Potential future work may add separate INVALID markets, safer oracle observations, routing, or bounded convenience flows. Those are distinct designs and must not weaken the invariant that this pair never accepts or holds INVALID. diff --git a/trading/docs/how-to/deploy.md b/trading/docs/how-to/deploy.md index f539c3fb3..e7af32f82 100644 --- a/trading/docs/how-to/deploy.md +++ b/trading/docs/how-to/deploy.md @@ -1,7 +1,44 @@ # Deploy the trading contracts -For mainnet or Sepolia, build and serve the UI, open the live markets route, select the core network and RPC URL, and connect a wallet. The UI verifies the canonical core deployment and submits the deterministic `TwoWayConstantProductFactory` and `TwoWayConstantProductRouter` deployments in dependency order. It resumes at the first missing contract when a deterministic deployment already exists. +## Use an installed public-network core -For a local Anvil chain, compile first with `bun run compile`. Set `ZOLTAR_DEPLOYMENT_MANIFEST` to a reviewed manifest for the same chain, optionally set `TRADING_RPC_URL`, `TRADING_DEPLOYER`, and `TRADING_FEE_BPS`, then run `bun run deploy:local`. +The root mainnet and Sepolia manifests describe planned deterministic addresses; they do not prove that the contracts are live. Continue only after the selected network has reviewed core code at those addresses. See the root [deployment-status reference](../../../docs/reference/deployment-status.html) for that distinction. + +From `trading/`, start the standalone UI: + +```bash +docker compose up --build --force-recreate +``` + +Open `http://localhost:4163/#/deploy`, select the network, enter an HTTPS RPC URL and the immutable fee, then connect a wallet. The UI checks the RPC chain, canonical proxy, and core factory before enabling the first transaction. Submit the factory transaction, then the router transaction. Progress reaches `2 / 2` after both deterministic contracts and their immutable links have been verified. The flow resumes at the first missing contract if you return later. + +## Use local Anvil + +Return to the repository root (`cd ..` if you are still in `trading/`) and complete the root setup. Start Anvil as chain ID 1 so it uses the mainnet deterministic-address profile: + +```bash +bun run anvil -- --chain-id 1 --block-base-fee-per-gas 0 --gas-price 0 --no-priority-fee +``` + +In another terminal, run `bun run app:serve`, open `http://localhost:12345/?rpcUrl=http://127.0.0.1:8545#/deploy`, connect an Anvil account, and use the root Zoltar deployment screen to install the core contracts. Wait until its deployment plan is complete. + +From the repository root, start the trading Docker UI: + +```bash +cd trading +docker compose up --build --force-recreate +``` + +Open `http://localhost:4163/#/deploy` and follow the public-network steps above with **Ethereum Mainnet · chain 1** and `http://127.0.0.1:8545`. The browser will deploy the trading factory and router through the same deterministic proxy. + +For a generated trading manifest instead of browser storage, open another terminal in the repository root, enter `trading/`, and run the local deployment script against the matching root manifest: + +```bash +cd trading +bun run compile +ZOLTAR_DEPLOYMENT_MANIFEST=../docs/mainnet-deployment-addresses.json \ +TRADING_RPC_URL=http://127.0.0.1:8545 \ +bun run deploy:local +``` The script verifies code at the configured core `SecurityPoolFactory`, deploys `TwoWayConstantProductFactory(coreFactory, feeBps)`, deploys `TwoWayConstantProductRouter(factory)`, and records chain ID, inputs, outputs, transaction hashes, compiler settings, and bytecode hashes. The fee is immutable and no economically optimal value is claimed. diff --git a/trading/scripts/browser-qa.mts b/trading/scripts/browser-qa.mts index 6eed0f275..55f6901b4 100644 --- a/trading/scripts/browser-qa.mts +++ b/trading/scripts/browser-qa.mts @@ -92,7 +92,7 @@ const scenarios = [ width: 1440, height: 900, path: '/#/deploy', - assertExpression: `document.querySelector('.deployment-setup select')?.options.length === 3 && document.querySelector('.deployment-setup input[type="url"]') !== null && [...document.querySelectorAll('.deployment-setup button')].some(button => button.textContent?.trim() === 'Deployment ready' && button.disabled) && document.documentElement.scrollWidth <= document.documentElement.clientWidth`, + assertExpression: `document.querySelector('.deployment-setup select')?.options.length === 3 && document.querySelector('.deployment-setup input[type="url"]') !== null && document.body.textContent?.includes('Immutable trading fee') === true && [...document.querySelectorAll('.deployment-setup button')].some(button => button.textContent?.trim() === 'Deploy trading contracts' && button.disabled) && document.documentElement.scrollWidth <= document.documentElement.clientWidth`, }, { name: 'deployment-setup-mobile', @@ -106,14 +106,14 @@ const scenarios = [ width: 1440, height: 900, path: '/#/markets', - assertExpression: `document.querySelector('.wallet-status') === null && document.body.textContent?.includes('Connect in market view') !== true && [...document.querySelectorAll('button')].some(button => button.textContent?.trim() === 'Retry deployment')`, + assertExpression: `document.querySelector('.wallet-status') === null && document.querySelector('.universe-selector') === null && document.body.textContent?.includes('Connect in market view') !== true && [...document.querySelectorAll('button')].some(button => button.textContent?.trim() === 'Deploy trading contracts' && button.disabled)`, }, { name: 'disconnected-market-list-mobile', width: 390, height: 844, path: '/#/markets', - assertExpression: `[...document.querySelectorAll('button')].some(button => button.textContent?.trim() === 'Retry deployment' && button.getBoundingClientRect().height >= 44) && document.documentElement.scrollWidth <= document.documentElement.clientWidth`, + assertExpression: `document.querySelector('.universe-selector') === null && [...document.querySelectorAll('button')].some(button => button.textContent?.trim() === 'Deploy trading contracts' && button.disabled && button.getBoundingClientRect().height >= 44) && document.documentElement.scrollWidth <= document.documentElement.clientWidth`, }, { name: 'wrong-network', width: 1440, height: 900, path: '/?demo=1&scenario=wrong-network#/markets', assertExpression: `document.documentElement.scrollWidth <= document.documentElement.clientWidth` }, { @@ -121,7 +121,7 @@ const scenarios = [ width: 1440, height: 900, path: '/?demo=1&scenario=baseline#/markets', - assertExpression: `(() => { const checks = { poolInternalsHidden: ${poolInternalsHiddenAssertion}, removedCopy: ${removedCopyAssertion}, universeSelector: ${universeSelectorAssertion}, walletSummary: ${genesisWalletSummaryAssertion} }; if (Object.values(checks).some(value => !value)) throw new Error(JSON.stringify(checks)); return true })()`, + assertExpression: `(() => { const checks = { deployHidden: document.querySelector('a[href="#/deploy"]') === null, poolInternalsHidden: ${poolInternalsHiddenAssertion}, removedCopy: ${removedCopyAssertion}, universeSelector: ${universeSelectorAssertion}, walletSummary: ${genesisWalletSummaryAssertion} }; if (Object.values(checks).some(value => !value)) throw new Error(JSON.stringify(checks)); return true })()`, }, { name: 'market-list-1280', width: 1280, height: 900, path: '/?demo=1&scenario=baseline#/markets', assertExpression: genesisWalletSummaryAssertion }, { name: 'market-list-1181', width: 1181, height: 900, path: '/?demo=1&scenario=baseline#/markets', assertExpression: genesisWalletSummaryAssertion }, @@ -264,7 +264,7 @@ const scenarios = [ width: 390, height: 844, path: '/?demo=1&scenario=baseline#/markets', - assertExpression: `(() => { const poolFacts = [...document.querySelectorAll('.market-row__pool div')]; const exactPoolVisible = poolFacts.some(fact => fact.querySelector('dt')?.textContent === 'Security pool' && fact.querySelector('.security-pool-link')?.textContent === '0x3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a'); const rowActionHeights = [...document.querySelectorAll('.row-action')].map(control => control.getBoundingClientRect().height); const poolLinkHeights = [...document.querySelectorAll('.security-pool-link')].map(control => control.getBoundingClientRect().height); const selectorBounds = document.querySelector('.universe-selector select')?.getBoundingClientRect(); const poolInternalsHidden = ${poolInternalsHiddenAssertion}; const walletSummaryVisible = ${genesisWalletSummaryAssertion}; if (!exactPoolVisible || !poolInternalsHidden || !walletSummaryVisible || !(${removedCopyAssertion}) || selectorBounds === undefined || selectorBounds.height < 44 || selectorBounds.width < 180 || rowActionHeights.some(height => height < 44) || poolLinkHeights.some(height => height < 44)) throw new Error(JSON.stringify({ exactPoolVisible, poolInternalsHidden, walletSummaryVisible, selectorBounds, rowActionHeights, poolLinkHeights, bodyText: document.body.textContent?.slice(0, 500) })); return true })()`, + assertExpression: `(() => { const poolFacts = [...document.querySelectorAll('.market-row__pool div')]; const exactPoolVisible = poolFacts.some(fact => fact.querySelector('dt')?.textContent === 'Security pool' && fact.querySelector('.security-pool-link')?.textContent === '0x3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a'); const rowActionHeights = [...document.querySelectorAll('.row-action')].map(control => control.getBoundingClientRect().height); const poolLinkHeights = [...document.querySelectorAll('.security-pool-link')].map(control => control.getBoundingClientRect().height); const selectorBounds = document.querySelector('.universe-selector select')?.getBoundingClientRect(); const deployHidden = document.querySelector('a[href="#/deploy"]') === null; const poolInternalsHidden = ${poolInternalsHiddenAssertion}; const walletSummaryVisible = ${genesisWalletSummaryAssertion}; if (!deployHidden || !exactPoolVisible || !poolInternalsHidden || !walletSummaryVisible || !(${removedCopyAssertion}) || selectorBounds === undefined || selectorBounds.height < 44 || selectorBounds.width < 180 || rowActionHeights.some(height => height < 44) || poolLinkHeights.some(height => height < 44)) throw new Error(JSON.stringify({ deployHidden, exactPoolVisible, poolInternalsHidden, walletSummaryVisible, selectorBounds, rowActionHeights, poolLinkHeights, bodyText: document.body.textContent?.slice(0, 500) })); return true })()`, }, { name: 'wrong-network-market', width: 1440, height: 900, path: '/?demo=1&scenario=wrong-network#/market', assertExpression: `document.documentElement.scrollWidth <= document.documentElement.clientWidth`, scrollY: 500 }, { name: 'wrong-network-market-mobile', width: 390, height: 844, path: '/?demo=1&scenario=wrong-network#/market', scrollY: 650 }, diff --git a/trading/ts/deploy/manifest.ts b/trading/ts/deploy/manifest.ts index a7bb25f02..d09f2b27a 100644 --- a/trading/ts/deploy/manifest.ts +++ b/trading/ts/deploy/manifest.ts @@ -20,6 +20,11 @@ function readManifestChainId(manifest: Record) { function readSecurityPoolFactory(manifest: Record) { const direct = manifest.securityPoolFactory if (direct !== undefined) return requireAddress(direct, 'securityPoolFactory') + const deploymentSteps = manifest.deploymentSteps + if (Array.isArray(deploymentSteps)) { + const factoryStep = deploymentSteps.find(step => isRecord(step) && step.id === 'securityPoolFactory') + if (isRecord(factoryStep)) return requireAddress(factoryStep.address, 'deploymentSteps.securityPoolFactory.address') + } const contracts = manifest.contracts if (isRecord(contracts)) { const candidate = contracts.SecurityPoolFactory ?? contracts.securityPoolFactory diff --git a/trading/ts/tests/deploymentManifest.test.ts b/trading/ts/tests/deploymentManifest.test.ts index 03f6d8d0d..8244001c9 100644 --- a/trading/ts/tests/deploymentManifest.test.ts +++ b/trading/ts/tests/deploymentManifest.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import type { Address } from '@zoltar/shared/ethereum' import { parseCoreDeploymentManifest, requireMatchingChain, requireReceiptBlockNumber, requireSafeChainId } from '../deploy/manifest.ts' describe('trading deployment manifest validation', () => { @@ -9,6 +10,18 @@ describe('trading deployment manifest validation', () => { expect(requireMatchingChain(parsed.chainId, 31_337n)).toBeUndefined() }) + test('reads SecurityPoolFactory from the canonical root deployment steps', () => { + const securityPoolFactory = `0x${'34'.repeat(20)}` satisfies Address + const parsed = parseCoreDeploymentManifest({ + network: { chainId: 1 }, + deploymentSteps: [ + { id: 'proxyDeployer', address: `0x${'12'.repeat(20)}` }, + { id: 'securityPoolFactory', address: securityPoolFactory }, + ], + }) + expect(parsed).toEqual({ chainId: 1n, securityPoolFactory }) + }) + test('rejects manifests without chain identity', () => { expect(() => parseCoreDeploymentManifest({ securityPoolFactory: `0x${'12'.repeat(20)}` })).toThrow('valid chain ID') }) diff --git a/trading/ui/ts/app/App.tsx b/trading/ui/ts/app/App.tsx index a0800e98a..55d362a73 100644 --- a/trading/ui/ts/app/App.tsx +++ b/trading/ui/ts/app/App.tsx @@ -304,7 +304,7 @@ export function App({ deploymentSetupServices, loadLiveDeployment = resolveLiveD }) const market = demoMarkets.find(choice => choice.universeId.toString() === selectedUniverseId) ?? initialDemoMarket const universeOptions = demo ? demoUniverseOptions : liveUniverseOptions - const showUniverseSelector = route !== 'deploy' && route !== 'help' + const showUniverseSelector = route !== 'deploy' && route !== 'help' && (demo || liveDeploymentStatus !== 'unavailable') const walletSummary = demo ? demoWalletSummary(scenario, market.universeId, demoWalletRetrySucceeded) : walletSummaryForUniverse(liveWalletSummary, selectedUniverseId) const retryWalletSummary = () => { if (demo) setDemoWalletRetrySucceeded(true) @@ -374,12 +374,12 @@ export function App({ deploymentSetupServices, loadLiveDeployment = resolveLiveD ) - else if (liveDeploymentStatus === 'unavailable') content = + else if (liveDeploymentStatus === 'unavailable') content = else content = ( event.preventDefault() : undefined}> Portfolio - event.preventDefault() : undefined}> - Deploy - + {demo ? null : ( + event.preventDefault() : undefined}> + Deploy + + )} event.preventDefault() : undefined}> Help diff --git a/trading/ui/ts/features/TradingDeploymentSetup.tsx b/trading/ui/ts/features/TradingDeploymentSetup.tsx index 8b05e74d0..7f5373a86 100644 --- a/trading/ui/ts/features/TradingDeploymentSetup.tsx +++ b/trading/ui/ts/features/TradingDeploymentSetup.tsx @@ -20,6 +20,8 @@ const defaultServices: TradingDeploymentSetupServices = { saveConfiguration: configuration => saveDeploymentConfiguration(configuration), } +const missingDeploymentConfigurationMessage = 'No bundled or wallet-deployed trading configuration was found.' + type DeploymentStatus = Readonly<{ factory: boolean; router: boolean }> function initialQueryValue(name: string) { @@ -38,9 +40,10 @@ function inspectionPresentation(state: 'idle' | 'loading' | 'ready' | 'error') { return { label: 'Enter network settings', tone: 'neutral' as const } } -function deploymentActionLabel(busy: boolean, nextStep: ReturnType) { +function deploymentActionLabel(busy: boolean, nextStep: ReturnType, status: DeploymentStatus | undefined) { if (busy) return `Deploying ${nextStep?.label ?? 'contract'}…` - if (nextStep === undefined) return 'Deployment ready' + if (status?.factory === true && status.router) return 'Deployment complete' + if (nextStep === undefined) return 'Deploy trading contracts' return `Deploy ${nextStep.label}` } @@ -48,13 +51,13 @@ export function TradingDeploymentSetup({ configurationError, currentConfiguration, onComplete, - onRetryManifest, + onRetryConfiguration, services = defaultServices, }: { configurationError: string | undefined currentConfiguration?: DeploymentConfiguration onComplete(configuration: DeploymentConfiguration): void - onRetryManifest(): void + onRetryConfiguration?(): void services?: TradingDeploymentSetupServices }) { const [coreDeployments, setCoreDeployments] = useState([]) @@ -70,6 +73,7 @@ export function TradingDeploymentSetup({ const [busy, setBusy] = useState(false) const [actionMessage, setActionMessage] = useState() const [submittedHash, setSubmittedHash] = useState() + const [retryNonce, setRetryNonce] = useState(0) const selectedCore = useMemo(() => coreDeployments.find(deployment => deployment.chainId.toString() === chainId), [chainId, coreDeployments]) let inputError: string | undefined if (chainId !== '' && rpcUrl !== '' && feeBps !== '') { @@ -96,7 +100,7 @@ export function TradingDeploymentSetup({ return () => { active = false } - }, [services]) + }, [retryNonce, services]) useEffect(() => { setPlan(undefined) @@ -139,10 +143,25 @@ export function TradingDeploymentSetup({ return () => { active = false } - }, [chainId, feeBps, inputError, onComplete, rpcUrl, selectedCore, services]) + }, [chainId, feeBps, inputError, onComplete, retryNonce, rpcUrl, selectedCore, services]) const nextStep = plan === undefined || deploymentStatus === undefined ? undefined : nextTradingDeploymentStep(plan, deploymentStatus) const inspection = inspectionPresentation(inspectionState) + const retryChecks = registryError !== undefined || inspectionState === 'error' + const retryConfiguration = !retryChecks && configurationError !== undefined && configurationError !== missingDeploymentConfigurationMessage && onRetryConfiguration !== undefined + let retryAction + if (retryChecks) + retryAction = ( + + ) + else if (retryConfiguration) + retryAction = ( + + ) async function deployNext() { if (busy || plan === undefined || publicClient === undefined || deploymentStatus === undefined || nextStep === undefined) return setBusy(true) @@ -216,7 +235,7 @@ export function TradingDeploymentSetup({ setRpcUrl(event.currentTarget.value)} />