diff --git a/scripts/lint-no-bare-catch.mts b/scripts/lint-no-bare-catch.mts index a764ed232..cc7e78dd9 100644 --- a/scripts/lint-no-bare-catch.mts +++ b/scripts/lint-no-bare-catch.mts @@ -13,7 +13,7 @@ type CatchFinding = { const repositoryRoot = path.dirname(url.fileURLToPath(import.meta.url)) const projectRoot = path.join(repositoryRoot, '..') const sourceFileExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']) -const ignoredPathPrefixes = ['.git', 'node_modules', 'ui/node_modules', 'ui/dist', 'ui/vendor', 'ui/js', 'shared/js', 'solidity/artifacts', 'solidity/js', 'solidity/node_modules', 'coverage'] +const ignoredPathPrefixes = ['.git', 'node_modules', 'ui/node_modules', 'ui/dist', 'ui/vendor', 'ui/js', 'shared/js', 'solidity/artifacts', 'solidity/js', 'solidity/node_modules', 'trading/artifacts', 'trading/ts/artifacts', 'trading/ui/dist', 'trading/ui/ts/generated', 'coverage'] const ignoredFiles = new Set(['solidity/ts/testSupport/simulator/types/wire-types.js']) function shouldIgnore(relativePath: string): boolean { diff --git a/trading/Dockerfile b/trading/Dockerfile index a722046fd..1019120d1 100644 --- a/trading/Dockerfile +++ b/trading/Dockerfile @@ -9,6 +9,7 @@ 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 diff --git a/trading/Dockerfile.dockerignore b/trading/Dockerfile.dockerignore index 76bfd8a6e..d544c43a2 100644 --- a/trading/Dockerfile.dockerignore +++ b/trading/Dockerfile.dockerignore @@ -12,6 +12,9 @@ !solidity/ !solidity/contracts/ !solidity/contracts/** +!docs/ +!docs/mainnet-deployment-addresses.json +!docs/sepolia-deployment-addresses.json !trading/ !trading/package.json !trading/bun.lock diff --git a/trading/README.md b/trading/README.md index fa9d650c1..63e33015b 100644 --- a/trading/README.md +++ b/trading/README.md @@ -16,42 +16,42 @@ 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 network inspect zoltar >/dev/null 2>&1 || docker network create zoltar +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 demo UI from this directory: +### Local development and demo ```bash -docker network inspect zoltar >/dev/null 2>&1 || docker network create zoltar -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. - -Then open `http://localhost:4163/?demo=1#/markets`. The final image runs as an unprivileged user and exposes a health check at `/`. +Open `http://localhost:4163/?demo=1#/markets`. Demo mode is prominently labeled and makes no live-chain claims. -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. +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. -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 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 docker network inspect zoltar >/dev/null 2>&1 || docker network create zoltar -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 -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 @@ -59,15 +59,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/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/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..82858158a 100644 --- a/trading/docs/how-to/deploy.md +++ b/trading/docs/how-to/deploy.md @@ -1,5 +1,46 @@ # 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`. +## Use an installed public-network core -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. +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 network inspect zoltar >/dev/null 2>&1 || docker network create zoltar +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 network inspect zoltar >/dev/null 2>&1 || docker network create zoltar +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/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..8661e4fff 100644 --- a/trading/scripts/browser-qa.mts +++ b/trading/scripts/browser-qa.mts @@ -87,19 +87,57 @@ 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.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', + 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: 'deployment-setup-pending-hydration-desktop', + width: 1440, + height: 900, + path: '/?qaDeployment=pending#/deploy', + evaluate: `(() => { const select = document.querySelector('.deployment-setup select'); const rpc = document.querySelector('.deployment-setup input[type="url"]'); if (!(select instanceof HTMLSelectElement) || !(rpc instanceof HTMLInputElement)) return false; select.value = '1'; select.dispatchEvent(new Event('change', { bubbles: true })); rpc.value = 'http://127.0.0.1:8545'; rpc.dispatchEvent(new Event('input', { bubbles: true })); return true })()`, + evaluateWaitMs: 300, + clickSelector: '.deployment-setup .primary-action', + clickWaitMs: 800, + assertExpression: `(() => { const checks = { pending: document.body.textContent?.includes('Deployment in progress') === true, actionBusy: document.querySelector('.deployment-setup .primary-action')?.getAttribute('aria-busy') === 'true', controlsDisabled: [...document.querySelectorAll('.deployment-setup select, .deployment-setup input, .deployment-setup button')].every(control => control.disabled), navigationDisabled: [...document.querySelectorAll('.site-header a')].every(link => link.getAttribute('aria-disabled') === 'true'), fits: document.documentElement.scrollWidth <= document.documentElement.clientWidth }; if (Object.values(checks).some(value => !value)) throw new Error(JSON.stringify({ ...checks, status: document.querySelector('.deployment-setup__status')?.textContent, action: document.querySelector('.deployment-setup .primary-action')?.textContent, error: document.querySelector('[role="alert"]')?.textContent })); return true })()`, + }, + { + name: 'deployment-setup-pending-hydration-mobile', + width: 390, + height: 844, + path: '/?qaDeployment=pending#/deploy', + evaluate: `(() => { const select = document.querySelector('.deployment-setup select'); const rpc = document.querySelector('.deployment-setup input[type="url"]'); if (!(select instanceof HTMLSelectElement) || !(rpc instanceof HTMLInputElement)) return false; select.value = '1'; select.dispatchEvent(new Event('change', { bubbles: true })); rpc.value = 'http://127.0.0.1:8545'; rpc.dispatchEvent(new Event('input', { bubbles: true })); return true })()`, + evaluateWaitMs: 300, + clickSelector: '.deployment-setup .primary-action', + clickWaitMs: 800, + assertExpression: `(() => { const checks = { pending: document.body.textContent?.includes('Deployment in progress') === true, actionBusy: document.querySelector('.deployment-setup .primary-action')?.getAttribute('aria-busy') === 'true', controlsDisabled: [...document.querySelectorAll('.deployment-setup select, .deployment-setup input, .deployment-setup button')].every(control => control.disabled), navigationDisabled: [...document.querySelectorAll('.site-header a')].every(link => link.getAttribute('aria-disabled') === 'true'), fits: document.documentElement.scrollWidth <= document.documentElement.clientWidth }; if (Object.values(checks).some(value => !value)) throw new Error(JSON.stringify(checks)); return true })()`, + scrollY: 420, + postScrollAssertExpression: `(() => { const action = document.querySelector('.deployment-setup .primary-action')?.getBoundingClientRect(); return action !== undefined && action.top >= 0 && action.bottom <= innerHeight && document.documentElement.scrollWidth <= document.documentElement.clientWidth })()`, + }, { name: 'disconnected-market-list', 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` }, { @@ -107,7 +145,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 }, @@ -250,7 +288,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 }, @@ -505,7 +543,7 @@ try { const evaluated = await command('Runtime.evaluate', { expression: scenario.evaluate, returnByValue: true }) const result = evaluated.result if (typeof result !== 'object' || result === null || !('value' in result) || result.value !== true) throw new Error(`Setup expression failed for ${scenario.name}`) - await Bun.sleep(100) + await Bun.sleep('evaluateWaitMs' in scenario ? scenario.evaluateWaitMs : 100) } if ('clickSelector' in scenario) { await command('Runtime.evaluate', { expression: `document.querySelector(${JSON.stringify(scenario.clickSelector)})?.click()` }) 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/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/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/ts/tests/dockerCompose.test.ts b/trading/ts/tests/dockerCompose.test.ts index 0123415fe..b939939d7 100644 --- a/trading/ts/tests/dockerCompose.test.ts +++ b/trading/ts/tests/dockerCompose.test.ts @@ -3,6 +3,8 @@ 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 dockerignore = join(import.meta.dir, '..', '..', 'Dockerfile.dockerignore') const windowsLauncher = join(import.meta.dir, '..', '..', 'start.bat') describe('standalone Docker Compose packaging', () => { @@ -10,9 +12,19 @@ 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:-}') expect(source).toContain('127.0.0.1:4163:4163') }) + test('includes canonical core deployments for browser wallet setup', async () => { + const source = await readFile(dockerfile, 'utf8') + const ignoredContext = await readFile(dockerignore, 'utf8') + expect(source).toContain('COPY docs/mainnet-deployment-addresses.json docs/sepolia-deployment-addresses.json ./docs/') + expect(ignoredContext).toContain('!docs/\n!docs/mainnet-deployment-addresses.json\n!docs/sepolia-deployment-addresses.json') + expect(source).toContain('ARG TRADING_UI_DEPLOYMENT') + expect(source).toContain('if [ -n "${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"') 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..1d0a7550b 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,47 @@ 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__fields .field { + margin: 0; +} +.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 +1459,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..1ee7a9ec5 100644 --- a/trading/ui/ts/app/App.tsx +++ b/trading/ui/ts/app/App.tsx @@ -3,11 +3,14 @@ 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 { loadCoreDeployments } from '../protocol/coreDeployments.ts' +import { validateStoredTradingDeployment } from '../protocol/deployment.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,9 +95,11 @@ 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.') - await validateLiveDeployment(createTradingPublicClient(loaded), loaded) - return loaded + if (loaded === undefined) throw new Error('No bundled or wallet-deployed trading configuration was found.') + const client = createTradingPublicClient(loaded.configuration) + if (loaded.source === 'stored') await validateStoredTradingDeployment(client, loaded.configuration, await loadCoreDeployments()) + else await validateLiveDeployment(client, loaded.configuration) + return loaded.configuration } function networkLabel(scenario: string, demo: boolean, liveDeploymentStatus: LiveDeploymentStatus) { @@ -219,7 +224,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 +265,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 +308,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' && (demo || liveDeploymentStatus !== 'unavailable') const walletSummary = demo ? demoWalletSummary(scenario, market.universeId, demoWalletRetrySucceeded) : walletSummaryForUniverse(liveWalletSummary, selectedUniverseId) const retryWalletSummary = () => { if (demo) setDemoWalletRetrySucceeded(true) @@ -318,6 +323,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 +373,19 @@ 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 + {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 new file mode 100644 index 000000000..eac39b00a --- /dev/null +++ b/trading/ui/ts/features/TradingDeploymentSetup.tsx @@ -0,0 +1,388 @@ +import { createPublicClient, http, type Hash, type PublicClient } from '@zoltar/shared/ethereum' +import { useEffect, useMemo, useRef, 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, type TradingDeploymentStep } 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 + deployStep?(publicClient: PublicClient, plan: TradingDeploymentPlan, step: TradingDeploymentStep, onSubmitted: (hash: Hash) => void): Promise + loadCoreDeployments(): Promise + saveConfiguration(configuration: DeploymentConfiguration): void +}> + +const defaultServices: TradingDeploymentSetupServices = { + createPublicClient: rpcUrl => createPublicClient({ transport: http(rpcUrl) }), + deployStep: async (publicClient, plan, step, onSubmitted) => { + 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, step, onSubmitted, async () => { + validateRpcChainId(await publicClient.getChainId(), plan.core.chainId) + if (getInjectedEthereum() !== provider || (await walletChainId(provider)) !== plan.core.chainId || (await connectWallet(provider)) !== account) throw new Error('Wallet context changed before deployment; no transaction was submitted') + }) + 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') + }, + loadCoreDeployments, + 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) { + 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', { busy, deploymentComplete, registryError, registryLoading }: Readonly<{ busy: boolean; deploymentComplete: boolean; registryError: boolean; registryLoading: boolean }>) { + if (registryLoading) return { label: 'Loading networks', tone: 'neutral' as const } + if (registryError) return { label: 'Networks unavailable', tone: 'warn' as const } + if (busy) return { label: 'Deployment in progress', tone: 'neutral' as const } + if (deploymentComplete) return { label: 'Deployment complete', tone: 'good' as const } + 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, status: DeploymentStatus | undefined) { + if (busy) return `Deploying ${nextStep?.label ?? 'contract'}…` + if (status?.factory === true && status.router) return 'Deployment complete' + if (nextStep === undefined) return 'Deploy trading contracts' + return `Deploy ${nextStep.label}` +} + +export function TradingDeploymentSetup({ + configurationError, + currentConfiguration, + onComplete, + onRetryConfiguration, + onWorkflowLockChange = () => undefined, + services = defaultServices, +}: { + configurationError: string | undefined + currentConfiguration?: DeploymentConfiguration + onComplete(configuration: DeploymentConfiguration): void + onRetryConfiguration?(): void + onWorkflowLockChange?(locked: boolean): void + services?: TradingDeploymentSetupServices +}) { + const [coreDeployments, setCoreDeployments] = useState([]) + const [registryLoading, setRegistryLoading] = useState(true) + 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 [actionError, setActionError] = useState(false) + const [retryNonce, setRetryNonce] = useState(0) + const [inspectedRevision, setInspectedRevision] = useState() + const inputRevision = useRef(0) + useEffect(() => { + if (busy || currentConfiguration === undefined) return + inputRevision.current += 1 + setChainId(currentConfiguration.chainId.toString()) + setRpcUrl(currentConfiguration.rpcUrl) + setFeeBps(currentConfiguration.feeBps.toString()) + }, [busy, currentConfiguration]) + 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 + setRegistryLoading(true) + setRegistryError(undefined) + setCoreDeployments([]) + void (async () => { + try { + const deployments = await services.loadCoreDeployments() + if (!active) return + setCoreDeployments(deployments) + } catch (error) { + if (!active) return + setRegistryError(publicErrorMessage(error, 'Unable to load canonical core deployments')) + } finally { + if (active) setRegistryLoading(false) + } + })() + return () => { + active = false + } + }, [retryNonce, services]) + + useEffect(() => { + const revision = inputRevision.current + setPlan(undefined) + setPublicClient(undefined) + setDeploymentStatus(undefined) + setActionMessage(undefined) + setActionError(false) + setInspectedRevision(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 || revision !== inputRevision.current) return + setPublicClient(client) + setPlan(nextPlan) + setDeploymentStatus(status) + setInspectedRevision(revision) + 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, retryNonce, rpcUrl, selectedCore, services]) + + const nextStep = plan === undefined || deploymentStatus === undefined ? undefined : nextTradingDeploymentStep(plan, deploymentStatus) + const deploymentComplete = deploymentStatus?.factory === true && deploymentStatus.router + const inspectionIsCurrent = inspectedRevision === inputRevision.current + const inspection = inspectionPresentation(inspectionState, { busy, deploymentComplete, registryError: registryError !== undefined, registryLoading }) + 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 || registryLoading || registryError !== undefined || inspectedRevision !== inputRevision.current || plan === undefined || publicClient === undefined || deploymentStatus === undefined || nextStep === undefined) return + setBusy(true) + onWorkflowLockChange(true) + setActionMessage(undefined) + setActionError(false) + let broadcastHash: Hash | undefined + try { + const deployStep = services.deployStep ?? defaultServices.deployStep + if (deployStep === undefined) throw new Error('Trading deployment service is unavailable') + await deployStep(publicClient, plan, nextStep, hash => { + broadcastHash = hash + }) + const status = await loadTradingDeploymentStatus(publicClient, plan) + setDeploymentStatus(status) + 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) { + setActionError(true) + let detail = publicErrorMessage(error, `Failed to deploy ${nextStep.label}`) + try { + const status = await loadTradingDeploymentStatus(publicClient, plan) + setDeploymentStatus(status) + if (status[nextStep.id]) { + if (status.factory && status.router) { + const input = parseDeploymentSetupInput({ chainId, feeBps, rpcUrl }) + const configuration = deploymentConfigurationForPlan(plan, input.rpcUrl) + services.saveConfiguration(configuration) + onComplete(configuration) + return + } + setActionError(false) + setActionMessage(`${nextStep.label} is already installed. Continue with ${nextTradingDeploymentStep(plan, status)?.label ?? 'the next contract'}.`) + return + } + } catch (recoveryError) { + detail = `${detail} Unable to verify deployment status: ${publicErrorMessage(recoveryError, 'Unknown recovery error')}` + } + setActionMessage(broadcastHash === undefined ? detail : `Transaction ${broadcastHash} was broadcast but setup did not finish. Verify it in your wallet before retrying. ${detail}`) + } finally { + setBusy(false) + onWorkflowLockChange(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} +

+ )} +
+ {deploymentComplete ? null : ( + + )} + {retryAction} +
+
+
+ ) +} diff --git a/trading/ui/ts/index.tsx b/trading/ui/ts/index.tsx index 472f81a25..a7d8a0021 100644 --- a/trading/ui/ts/index.tsx +++ b/trading/ui/ts/index.tsx @@ -1,6 +1,52 @@ import { render } from 'preact' +import { createPublicClient, custom, getAddress } from '@zoltar/shared/ethereum' import { App } from './app/App.tsx' +import type { TradingDeploymentSetupServices } from './features/TradingDeploymentSetup.tsx' +import { CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE, deploymentConfigurationForPlan, getTradingDeploymentPlan } from './protocol/deployment.ts' + +const qaDeploymentPending = ['127.0.0.1', 'localhost'].includes(window.location.hostname) && new URLSearchParams(window.location.search).get('qaDeployment') === 'pending' +const qaCore = { + chainId: 1, + chainName: 'Ethereum Mainnet', + id: 'mainnet', + proxyDeployer: getAddress('0x7A0D94F55792C434d74a40883C6ed8545E406D12'), + securityPoolFactory: getAddress('0x5dae4d3F03A59a51F01e52920a76Cb4013D15c70'), +} + +function pendingDeploymentFixture() { + const client = createPublicClient({ + transport: custom({ + request: async ({ method, params }) => { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_getCode' && Array.isArray(params)) { + const address = params[0] + if (typeof address === 'string' && address.toLowerCase() === qaCore.proxyDeployer.toLowerCase()) return CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE + if (typeof address === 'string' && address.toLowerCase() === qaCore.securityPoolFactory.toLowerCase()) return '0x01' + return '0x' + } + throw new Error(`Unexpected QA RPC method ${method}`) + }, + }), + }) + const services: TradingDeploymentSetupServices = { + createPublicClient: () => client, + deployStep: async (_publicClient, _plan, _step, onSubmitted) => { + onSubmitted(`0x${'ab'.repeat(32)}`) + await new Promise(() => undefined) + }, + loadCoreDeployments: async () => [qaCore], + saveConfiguration: () => undefined, + } + const configuration = deploymentConfigurationForPlan(getTradingDeploymentPlan(qaCore, 30), 'http://127.0.0.1:8545/') + return { + deploymentSetupServices: services, + loadLiveDeployment: async () => { + await new Promise(resolve => setTimeout(resolve, 1_200)) + return configuration + }, + } +} const root = document.querySelector('#app') if (!(root instanceof HTMLElement)) throw new Error('Application root is missing') -render(, root) +render(qaDeploymentPending ? : , root) diff --git a/trading/ui/ts/protocol/config.ts b/trading/ui/ts/protocol/config.ts index 7a934f911..401a7f1f5 100644 --- a/trading/ui/ts/protocol/config.ts +++ b/trading/ui/ts/protocol/config.ts @@ -10,6 +10,14 @@ export type DeploymentConfiguration = Readonly<{ feeBps: number }> +export type LoadedDeploymentConfiguration = Readonly<{ + configuration: DeploymentConfiguration + source: 'bundled' | 'stored' +}> + +type ConfigurationStorage = Pick +const deploymentStorageKey = 'zoltar.trading.deployment.v1' + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } @@ -25,20 +33,30 @@ function requiredNumber(value: unknown, label: string) { } function requiredRpcUrl(value: unknown) { - const rpcUrl = requiredString(value, 'rpcUrl') + const rpcUrl = requiredString(value, 'RPC URL') let parsed: URL try { parsed = new URL(rpcUrl) } catch (error) { - if (error instanceof TypeError) throw new Error('rpcUrl must be a valid URL') + if (error instanceof TypeError) throw new Error('RPC URL must be a valid URL') throw error } const loopback = parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' - if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) throw new Error('rpcUrl must use HTTPS or loopback HTTP') - if (parsed.username !== '' || parsed.password !== '') throw new Error('rpcUrl must not contain embedded credentials') + if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) throw new Error('RPC URL must use HTTPS or loopback HTTP') + if (parsed.username !== '' || parsed.password !== '') throw new Error('RPC URL must not contain embedded credentials') 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`) @@ -65,10 +83,25 @@ export function parseDeploymentConfiguration(candidate: unknown): DeploymentConf } } -export async function loadDeploymentConfiguration(): Promise { +export async function loadDeploymentConfiguration(): Promise { const response = await fetch('./deployment.json', { cache: 'no-store' }) - if (response.status === 404) return undefined - if (!response.ok) throw new Error(`Deployment configuration failed with HTTP ${response.status}`) - const candidate: unknown = await response.json() - return candidate === null ? undefined : parseDeploymentConfiguration(candidate) + if (response.status !== 404) { + if (!response.ok) throw new Error(`Deployment configuration failed with HTTP ${response.status}`) + const candidate: unknown = await response.json() + if (candidate !== null) return { configuration: parseDeploymentConfiguration(candidate), source: 'bundled' } + } + if (typeof window === 'undefined') return undefined + const configuration = loadStoredDeploymentConfiguration(window.localStorage) + return configuration === undefined ? undefined : { configuration, source: 'stored' } +} + +export function loadStoredDeploymentConfiguration(storage: Pick): 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..8b717c25e --- /dev/null +++ b/trading/ui/ts/protocol/deployment.ts @@ -0,0 +1,161 @@ +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' + +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 +}> + +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 }) +const rpcStateRetryDelaysMilliseconds = [250, 500, 1_000, 2_000, 4_000] as const +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 async function validateStoredTradingDeployment(client: Pick, configuration: DeploymentConfiguration, coreDeployments: readonly CoreDeployment[]) { + const rpcChainId = await client.getChainId() + if (rpcChainId !== configuration.chainId) throw new Error(`Stored trading deployment chain ${configuration.chainId.toString()} does not match RPC chain ${rpcChainId.toString()}`) + const core = coreDeployments.find(deployment => deployment.chainId === configuration.chainId) + if (core === undefined) throw new Error(`Stored trading deployment uses unsupported chain ${configuration.chainId.toString()}`) + if (core.securityPoolFactory !== configuration.securityPoolFactory) throw new Error('Stored trading deployment references a noncanonical SecurityPoolFactory') + const plan = getTradingDeploymentPlan(core, configuration.feeBps) + if (plan.factory.address !== configuration.factory || plan.router.address !== configuration.router) throw new Error('Stored trading deployment addresses do not match the current deterministic contracts') + const status = await loadTradingDeploymentStatus(client, plan) + if (!status.factory || !status.router) throw new Error('Stored trading deployment is incomplete') +} + +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 +} + +async function waitForInstalledTradingStep(publicClient: Pick, plan: TradingDeploymentPlan, step: TradingDeploymentStep, wait: (milliseconds: number) => Promise = async milliseconds => await new Promise(resolve => setTimeout(resolve, milliseconds))) { + let status = await loadTradingDeploymentStatus(publicClient, plan) + for (const delayMilliseconds of rpcStateRetryDelaysMilliseconds) { + if (status[step.id]) return status + await wait(delayMilliseconds) + status = await loadTradingDeploymentStatus(publicClient, plan) + } + return status +} + +export async function deployTradingStep( + walletClient: TradingDeploymentWallet, + publicClient: Pick, + plan: TradingDeploymentPlan, + step: TradingDeploymentStep, + onSubmitted: (hash: Hash) => void = () => undefined, + beforeSend: () => Promise = async () => undefined, + waitForRpcState?: (milliseconds: number) => Promise, +): 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`) + } + await beforeSend() + 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 waitForInstalledTradingStep(publicClient, plan, step, waitForRpcState) + 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..4f731578f 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)}` @@ -24,9 +25,10 @@ describe('trading UI deployment configuration', () => { test('rejects unsafe RPC URLs and nonpositive chain IDs', () => { expect(() => parseDeploymentConfiguration({ chainId: 0, chainName: 'Zero', rpcUrl: 'https://example.test', securityPoolFactory: core, factory, router, feeBps: 30 })).toThrow('chainId must be positive') - expect(() => parseDeploymentConfiguration({ chainId: 1, chainName: 'One', rpcUrl: 'http://example.test', securityPoolFactory: core, factory, router, feeBps: 30 })).toThrow('HTTPS or loopback HTTP') + expect(() => parseDeploymentConfiguration({ chainId: 1, chainName: 'One', rpcUrl: 'not a URL', securityPoolFactory: core, factory, router, feeBps: 30 })).toThrow('RPC URL must be a valid URL') + expect(() => parseDeploymentConfiguration({ chainId: 1, chainName: 'One', rpcUrl: 'http://example.test', securityPoolFactory: core, factory, router, feeBps: 30 })).toThrow('RPC URL must use HTTPS or loopback HTTP') expect(() => parseDeploymentConfiguration({ chainId: 1, chainName: 'One', rpcUrl: 'http://[::1]:8545', securityPoolFactory: core, factory, router, feeBps: 30 })).toThrow('HTTPS or loopback HTTP') - expect(() => parseDeploymentConfiguration({ chainId: 1, chainName: 'One', rpcUrl: 'https://user:secret@example.test', securityPoolFactory: core, factory, router, feeBps: 30 })).toThrow('embedded credentials') + expect(() => parseDeploymentConfiguration({ chainId: 1, chainName: 'One', rpcUrl: 'https://user:secret@example.test', securityPoolFactory: core, factory, router, feeBps: 30 })).toThrow('RPC URL must not contain embedded credentials') }) test('rejects an RPC chain that differs from the manifest', () => { @@ -34,6 +36,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..28f07bdd9 --- /dev/null +++ b/trading/ui/ts/tests/deployment-flow.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from 'bun:test' +import { createPublicClient, custom, encodeAbiParameters, getAddress, type Address, type Hash } from '@zoltar/shared/ethereum' +import { CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE, deploymentConfigurationForPlan, deployTradingStep, getTradingDeploymentPlan, loadTradingDeploymentStatus, nextTradingDeploymentStep, validateStoredTradingDeployment } 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 = 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') + }) + + 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 + let delayedFactoryCodeReads = 2 + 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) { + if (delayedFactoryCodeReads > 0) { + delayedFactoryCodeReads -= 1 + return '0x' + } + 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') + 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, undefined, undefined, async () => undefined)).toBe(hash) + expect(transactions).toEqual([{ data: plan.factory.data, to: plan.core.proxyDeployer }]) + }) + + test('aborts before broadcast when the wallet context changes during preflight', async () => { + const plan = examplePlan() + let sendCount = 0 + const publicClient = createPublicClient({ + transport: custom({ + request: async ({ method, params }) => { + if (method !== 'eth_getCode' || !Array.isArray(params)) throw new Error(`Unexpected RPC method ${method}`) + const address = params[0] + if (typeof address === 'string' && address.toLowerCase() === plan.core.proxyDeployer.toLowerCase()) return CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE + return typeof address === 'string' && address.toLowerCase() === plan.core.securityPoolFactory.toLowerCase() ? '0x01' : '0x' + }, + }), + }) + const walletClient = { + sendTransaction: async () => { + sendCount += 1 + return `0x${'ab'.repeat(32)}` satisfies Hash + }, + waitForTransactionReceipt: async () => ({ status: 'success' as const }), + } + + await expect(deployTradingStep(walletClient, publicClient, plan, plan.factory, undefined, async () => await Promise.reject(new Error('Wallet context changed before deployment')))).rejects.toThrow('Wallet context changed before deployment') + expect(sendCount).toBe(0) + }) + + test('rejects getter-compatible stored addresses that are not current deterministic deployments', async () => { + const plan = examplePlan() + const client = createPublicClient({ + transport: custom({ + request: async ({ method }) => { + if (method === 'eth_chainId') return '0xaa36a7' + return '0x' + }, + }), + }) + await expect( + validateStoredTradingDeployment( + client, + { + chainId: plan.core.chainId, + chainName: plan.core.chainName, + factory: getAddress(`0x${'56'.repeat(20)}`), + feeBps: plan.feeBps, + router: getAddress(`0x${'78'.repeat(20)}`), + rpcUrl: 'https://rpc.example/', + securityPoolFactory: plan.core.securityPoolFactory, + }, + [plan.core], + ), + ).rejects.toThrow('do not match the current deterministic contracts') + }) + + test('rejects stored deployment state served by a different RPC chain', async () => { + const plan = examplePlan() + const client = createPublicClient({ + transport: custom({ + request: async ({ method }) => { + if (method === 'eth_chainId') return '0x1' + throw new Error(`Deployment state must not be read after wrong chain response: ${method}`) + }, + }), + }) + await expect(validateStoredTradingDeployment(client, deploymentConfigurationForPlan(plan, 'https://rpc.example/'), [plan.core])).rejects.toThrow('does not match RPC chain 1') + }) +}) 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..63b6ba1ad --- /dev/null +++ b/trading/ui/ts/tests/deployment-setup.test.tsx @@ -0,0 +1,395 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { createPublicClient, custom, encodeAbiParameters, getAddress } from '@zoltar/shared/ethereum' +import { act } from 'preact/test-utils' +import { installDomEnvironment } from '../../../../ui/ts/tests/testUtils/domEnvironment.ts' +import { App } from '../app/App.tsx' +import { TradingDeploymentSetup, type TradingDeploymentSetupServices } from '../features/TradingDeploymentSetup.tsx' +import { CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE, deploymentConfigurationForPlan, getTradingDeploymentPlan } from '../protocol/deployment.ts' +import { renderIntoDocument } from './test-support/renderIntoDocument.tsx' + +const core = { + chainId: 11_155_111, + chainName: 'Sepolia', + id: 'sepolia', + proxyDeployer: getAddress(`0x${'12'.repeat(20)}`), + securityPoolFactory: getAddress(`0x${'34'.repeat(20)}`), +} + +function deploymentClient(rpcAvailable: () => boolean = () => true) { + return createPublicClient({ + transport: custom( + { + request: async ({ method, params }) => { + if (!rpcAvailable()) throw new Error('RPC unavailable') + 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}`) + }, + }, + { retryCount: 0 }, + ), + }) +} + +async function waitForText(text: string) { + for (let attempt = 0; attempt < 100; attempt++) { + await act(async () => { + await Bun.sleep(10) + }) + if (document.body.textContent?.includes(text)) return + } + throw new Error(`Timed out waiting for ${text}`) +} + +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 client = deploymentClient() + const services: TradingDeploymentSetupServices = { + createPublicClient: () => client, + loadCoreDeployments: async () => [core], + saveConfiguration: () => undefined, + } + const rendered = await renderIntoDocument( 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('Immutable trading fee') + expect(rendered.container.textContent).toContain('Deploy Two-way trading factory') + expect(rendered.container.textContent).toContain('0 / 2') + }) + + test('announces registry loading and clears its error while retrying', async () => { + let rejectInitial: ((reason: Error) => void) | undefined + let resolveRetry: ((deployments: readonly (typeof core)[]) => void) | undefined + const initialLoad = new Promise((_resolve, reject) => { + rejectInitial = reject + }) + const retryLoad = new Promise(resolve => { + resolveRetry = resolve + }) + let loadCount = 0 + const services: TradingDeploymentSetupServices = { + createPublicClient: () => deploymentClient(), + loadCoreDeployments: async () => await (++loadCount === 1 ? initialLoad : retryLoad), + saveConfiguration: () => undefined, + } + const rendered = await renderIntoDocument( undefined} services={services} />) + cleanupRendered = rendered.cleanup + await waitForText('Loading networks') + const select = rendered.container.querySelector('select') + if (select === null) throw new Error('Deployment network field is unavailable') + expect(select.disabled).toBe(true) + if (rejectInitial === undefined) throw new Error('Initial registry rejection is unavailable') + rejectInitial(new Error('Registry unavailable')) + await waitForText('Registry unavailable') + const failedStatus = rendered.container.querySelector('.deployment-setup__status .status') + expect(failedStatus?.textContent).toContain('Networks unavailable') + expect(failedStatus?.classList.contains('status--warn')).toBe(true) + const retry = Array.from(rendered.container.querySelectorAll('button')).find(button => button.textContent?.trim() === 'Retry checks') + if (!(retry instanceof HTMLButtonElement)) throw new Error('Retry checks button is unavailable') + await act(async () => { + retry.click() + }) + expect(rendered.container.textContent).not.toContain('Registry unavailable') + expect(rendered.container.textContent).toContain('Loading networks') + expect(Array.from(rendered.container.querySelectorAll('button')).some(button => button.textContent?.trim() === 'Retry checks' && !button.disabled)).toBe(false) + if (resolveRetry === undefined) throw new Error('Retry registry resolver is unavailable') + resolveRetry([core]) + await waitForText('Enter network settings') + }) + + test('removes stale registry data and deployment actions when a registry refresh fails', async () => { + let rpcAvailable = false + let registryLoads = 0 + const services: TradingDeploymentSetupServices = { + createPublicClient: () => deploymentClient(() => rpcAvailable), + loadCoreDeployments: async () => { + registryLoads += 1 + if (registryLoads > 1) throw new Error('Registry refresh failed') + return [core] + }, + saveConfiguration: () => undefined, + } + const rendered = await renderIntoDocument( 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 waitForText('RPC unavailable') + const retry = Array.from(rendered.container.querySelectorAll('button')).find(button => button.textContent?.trim() === 'Retry checks') + if (!(retry instanceof HTMLButtonElement)) throw new Error('Retry checks button is unavailable') + rpcAvailable = true + await act(async () => { + retry.click() + }) + await waitForText('Registry refresh failed') + expect(rendered.container.textContent).toContain('Networks unavailable') + expect(rendered.container.textContent).not.toContain('SecurityPoolFactory') + expect(rendered.container.textContent).not.toContain('Deploy Two-way trading factory') + expect(select.disabled).toBe(true) + expect(rpcInput.value).toBe('https://rpc.example') + }) + + test('retries a failed automatic RPC inspection without losing the selected settings', async () => { + let rpcAvailable = false + const services: TradingDeploymentSetupServices = { + createPublicClient: () => deploymentClient(() => rpcAvailable), + loadCoreDeployments: async () => [core], + saveConfiguration: () => undefined, + } + const rendered = await renderIntoDocument( 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 waitForText('RPC unavailable') + expect(rendered.container.textContent).toContain('RPC unavailable') + const retry = Array.from(rendered.container.querySelectorAll('button')).find(button => button.textContent?.trim() === 'Retry checks') + if (!(retry instanceof HTMLButtonElement)) throw new Error('Retry checks button is unavailable') + rpcAvailable = true + await act(async () => { + retry.click() + }) + await waitForText('Ready to deploy') + expect(select.value).toBe(core.chainId.toString()) + expect(rpcInput.value).toBe('https://rpc.example') + expect(rendered.container.textContent).toContain('Ready to deploy') + }) + + test('invalidates a verified plan synchronously when deployment inputs change', async () => { + let deployCount = 0 + let rpcAvailable = true + const services: TradingDeploymentSetupServices = { + createPublicClient: () => deploymentClient(() => rpcAvailable), + deployStep: async () => { + deployCount += 1 + }, + loadCoreDeployments: async () => [core], + saveConfiguration: () => undefined, + } + const rendered = await renderIntoDocument( 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"]') + const feeInput = rendered.container.querySelector('.amount-input input') + if (select === null || rpcInput === null || feeInput === 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 waitForText('Ready to deploy') + const action = Array.from(rendered.container.querySelectorAll('button')).find(button => button.textContent?.includes('Deploy Two-way trading factory') === true) + if (!(action instanceof HTMLButtonElement)) throw new Error('Factory deployment action is unavailable') + await act(async () => { + rpcAvailable = false + feeInput.value = '31' + feeInput.dispatchEvent(new Event('input', { bubbles: true })) + action.click() + }) + expect(deployCount).toBe(0) + }) + + test('reports a failed recovery read without hiding the deployment error', async () => { + let rpcAvailable = true + const services: TradingDeploymentSetupServices = { + createPublicClient: () => deploymentClient(() => rpcAvailable), + deployStep: async () => { + rpcAvailable = false + throw new Error('Deployment submission failed') + }, + loadCoreDeployments: async () => [core], + saveConfiguration: () => undefined, + } + const rendered = await renderIntoDocument( 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 waitForText('Ready to deploy') + const action = Array.from(rendered.container.querySelectorAll('button')).find(button => button.textContent?.includes('Deploy Two-way trading factory') === true) + if (!(action instanceof HTMLButtonElement)) throw new Error('Factory deployment action is unavailable') + await act(async () => { + action.click() + }) + await waitForText('Deployment submission failed') + expect(rendered.container.textContent).toContain('Unable to verify deployment status: RPC unavailable') + const feedback = Array.from(rendered.container.querySelectorAll('[role="alert"]')).find(element => element.textContent?.includes('Deployment submission failed') === true) + expect(feedback?.classList.contains('error')).toBe(true) + }) + + test('keeps the app route locked while a deployment transaction is pending', async () => { + window.history.replaceState(undefined, '', '/#/deploy') + const loadedConfiguration = deploymentConfigurationForPlan(getTradingDeploymentPlan(core, 30), 'https://rpc.example/') + let resolveConfiguration: ((configuration: typeof loadedConfiguration) => void) | undefined + const configurationPending = new Promise(resolve => { + resolveConfiguration = resolve + }) + let resolveDeployment: (() => void) | undefined + const deploymentPending = new Promise(resolve => { + resolveDeployment = resolve + }) + let deployCount = 0 + const services: TradingDeploymentSetupServices = { + createPublicClient: () => deploymentClient(), + deployStep: async (_client, _plan, _step, onSubmitted) => { + deployCount += 1 + onSubmitted(`0x${'ab'.repeat(32)}`) + await deploymentPending + }, + loadCoreDeployments: async () => [core], + saveConfiguration: () => undefined, + } + const rendered = await renderIntoDocument( await configurationPending} />) + cleanupRendered = rendered.cleanup + await waitForText('Enter network settings') + const select = rendered.container.querySelector('.deployment-setup select') + const rpcInput = rendered.container.querySelector('.deployment-setup 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 waitForText('Ready to deploy') + const action = Array.from(rendered.container.querySelectorAll('button')).find(button => button.textContent?.includes('Deploy Two-way trading factory') === true) + if (!(action instanceof HTMLButtonElement)) throw new Error('Factory deployment action is unavailable') + await act(async () => { + action.click() + await Bun.sleep(0) + }) + expect(deployCount).toBe(1) + const pendingStatus = rendered.container.querySelector('.deployment-setup__status .status') + expect(pendingStatus?.textContent).toContain('Deployment in progress') + expect(pendingStatus?.classList.contains('status--neutral')).toBe(true) + if (resolveConfiguration === undefined) throw new Error('Configuration resolver is unavailable') + resolveConfiguration(loadedConfiguration) + await act(async () => await Bun.sleep(20)) + expect(rendered.container.textContent).toContain('Deployment in progress') + const pendingAction = Array.from(rendered.container.querySelectorAll('button')).find(button => button.textContent?.includes('Deploying Two-way trading factory') === true) + if (!(pendingAction instanceof HTMLButtonElement)) throw new Error('Pending factory deployment action is unavailable') + expect(pendingAction.disabled).toBe(true) + await act(async () => { + pendingAction.click() + }) + expect(deployCount).toBe(1) + await act(async () => { + window.location.hash = '#/help' + window.dispatchEvent(new Event('hashchange')) + }) + expect(window.location.hash).toBe('#/deploy') + expect(deployCount).toBe(1) + if (resolveDeployment === undefined) throw new Error('Deployment resolver is unavailable') + resolveDeployment() + await act(async () => await Bun.sleep(0)) + }) + + test('hydrates the deploy route from asynchronously resolved configuration', async () => { + window.history.replaceState(undefined, '', '/#/deploy') + const plan = getTradingDeploymentPlan(core, 47) + const configuration = deploymentConfigurationForPlan(plan, 'https://rpc.example/') + let contractReadCount = 0 + 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') throw new Error('Missing code address') + if (address.toLowerCase() === core.proxyDeployer.toLowerCase()) return CANONICAL_PROXY_DEPLOYER_RUNTIME_CODE + if ([core.securityPoolFactory, plan.factory.address, plan.router.address].some(expected => expected.toLowerCase() === address.toLowerCase())) return '0x01' + return '0x' + } + if (method === 'eth_call') { + contractReadCount += 1 + if (contractReadCount % 3 === 1) return encodeAbiParameters([{ type: 'address' }], [core.securityPoolFactory]) + if (contractReadCount % 3 === 2) return encodeAbiParameters([{ type: 'uint16' }], [plan.feeBps]) + return encodeAbiParameters([{ type: 'address' }], [plan.factory.address]) + } + throw new Error(`Unexpected RPC method ${method}`) + }, + }), + }) + let resolveConfiguration: ((value: typeof configuration) => void) | undefined + const configurationPending = new Promise(resolve => { + resolveConfiguration = resolve + }) + const services: TradingDeploymentSetupServices = { + createPublicClient: () => client, + loadCoreDeployments: async () => [core], + saveConfiguration: () => undefined, + } + const rendered = await renderIntoDocument( await configurationPending} />) + cleanupRendered = rendered.cleanup + if (resolveConfiguration === undefined) throw new Error('Configuration resolver is unavailable') + resolveConfiguration(configuration) + await waitForText('Deployment complete') + const select = rendered.container.querySelector('.deployment-setup select') + const rpcInput = rendered.container.querySelector('.deployment-setup input[type="url"]') + const feeInput = rendered.container.querySelector('.deployment-setup .amount-input input') + expect(select?.value).toBe(core.chainId.toString()) + expect(rpcInput?.value).toBe(configuration.rpcUrl) + expect(feeInput?.value).toBe('47') + expect(rendered.container.textContent).toContain('2 / 2') + expect(rendered.container.textContent).not.toContain('Ready to deploy') + expect(Array.from(rendered.container.querySelectorAll('button')).some(button => button.textContent?.trim() === 'Deployment complete')).toBe(false) + }) +}) diff --git a/trading/ui/ts/tests/essential-copy.test.tsx b/trading/ui/ts/tests/essential-copy.test.tsx index cc5cf95b3..19f2319af 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 live-only deployment out of demo navigation 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).not.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') diff --git a/trading/ui/ts/tests/universe-selector.test.tsx b/trading/ui/ts/tests/universe-selector.test.tsx index a1273ff7e..0b62f29fc 100644 --- a/trading/ui/ts/tests/universe-selector.test.tsx +++ b/trading/ui/ts/tests/universe-selector.test.tsx @@ -244,7 +244,7 @@ describe('universe selector', () => { await act(async () => { await Bun.sleep(10) }) - const retry = Array.from(rendered.container.querySelectorAll('button')).find(candidate => candidate.textContent?.trim() === 'Retry deployment') + const retry = Array.from(rendered.container.querySelectorAll('button')).find(candidate => candidate.textContent?.trim() === 'Retry configuration') expect(rendered.container.querySelector('[role="alert"]')?.textContent).toContain('deployment RPC unavailable') if (!(retry instanceof HTMLButtonElement)) throw new Error('Deployment retry control is unavailable') await act(async () => { @@ -256,6 +256,6 @@ describe('universe selector', () => { }) expect(attempts).toBe(2) expect(rendered.container.textContent).toContain('Verified live deployment') - expect(rendered.container.textContent).not.toContain('Retry deployment') + expect(rendered.container.textContent).not.toContain('Retry configuration') }) })