diff --git a/.changeset/strip-contract-deploy.md b/.changeset/strip-contract-deploy.md new file mode 100644 index 0000000..e735d90 --- /dev/null +++ b/.changeset/strip-contract-deploy.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +Move contract deployment out of `dot deploy` and add CDM-backed `dot contract deploy/install` commands. `dot contract deploy` now calls CDM's deploy pipeline with dot's signer and Bulletin allowance signer, uses CDM's current registry defaults from `@dotdm/env`, renders a CDM-style Ink progress table using dot's shared TUI primitives, and `dot contract install` delegates to CDM's installer. diff --git a/CLAUDE.md b/CLAUDE.md index f07f1f5..ee8c03b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ These aren't self-evident from reading the code and have bitten us before. Treat ### Dependency pins / lockfile - **Import from `@parity/product-sdk-*`, never `@polkadot-apps/*`.** The CLI runtime is fully on product-sdk. `@polkadot-apps/*` is gone from the lockfile and CI's `Format` job runs `grep -rnE "['\"]@polkadot-apps/" src/ e2e/ scripts/ tools/` as a guard. Product-sdk uses caret ranges (`^0.x.y`); on a 0.x line `^` only widens patches, so a true breaking change still needs an explicit `package.json` bump. -- **`@dotdm/contracts` is on `^2.0.3`.** The 2.0 line ships `resolveTargetRegistryAddress` and re-exports `REGISTRY_ADDRESS` from `@dotdm/utils`, both used by `src/config.ts::CDM_REGISTRY_ADDRESS`. The legacy `1.1.1` stable still depends on `@polkadot-apps/*` + PAPI 1.x — do NOT downgrade. +- **`@dotdm/contracts` tracks the `^3.x` line.** The legacy `1.1.1` stable still depends on `@polkadot-apps/*` + PAPI 1.x — do NOT downgrade. - **`@novasamatech/*` packages are forced to `0.7.9-4` via `pnpm.overrides`.** They're transitive (via `@parity/product-sdk-terminal`'s `^0.7.7` ranges) and pnpm won't bump transitives across patches. The override aligns the tree on the latest published Novasama line including RFC-0010 `requestResourceAllocation`. Drop the override once product-sdk-terminal bumps its caret natively. - **`@polkadot-api/json-rpc-provider: ^0.2.0` override is load-bearing.** Removing it splits the lockfile across three versions of `json-rpc-provider` (`0.0.1`/`0.0.4`/`0.2.0`) — different PAPI 2.x transitive consumers ask for different versions. Forcing everyone onto `0.2.0` avoids subtle wire-shape divergence and reduces bundle/process memory. - **`@parity/dotns-cli@0.6.1` ships a broken publish manifest** declaring `"@polkadot-api/descriptors": "file:.papi/descriptors"` — a workspace path missing from the tarball. pnpm refuses; we redirect that sub-dep to `stubs/papi-descriptors-stub/` (an empty `{}` export). dotns-cli's `dist/cli.js` is a fully-bundled Bun build, so the stub is functionally correct. Remove the override + stub when `@parity/dotns-cli` republishes a clean manifest. diff --git a/README.md b/README.md index 7e2bb2b..f122250 100644 --- a/README.md +++ b/README.md @@ -55,15 +55,13 @@ Flags: - `--domain ` — DotNS label (with or without the `.dot` suffix). Interactive prompt if omitted. - `--buildDir ` — directory holding the built artifacts (default `dist/`). Interactive prompt if omitted. - `--no-build` — skip the frontend build step and deploy whatever is already in `--buildDir`. -- `--contracts` — also compile and deploy the foundry / hardhat / cdm contract project at the project root (interactive prompt if a contract project is detected and the flag is omitted; skipped automatically when no project is detected). -- `--no-contract-build` — skip the contract compile step (`forge build --resolc`, `cargo-contract build`, `npx hardhat compile`) and deploy pre-built artifacts. Requires `--contracts` and headless mode (i.e. all of `--signer`, `--domain`, `--buildDir`, `--playground`). - `--playground` — publish to the playground registry so the app appears under "my apps". Interactive prompt (default: no) if omitted. - `--private` — publish to the playground with private (owner-only) visibility. Requires `--playground`. Not interactively prompted; pass the flag to opt in. - `--moddable` / `--no-moddable` — publish the source repo URL alongside the deploy so others can `dot mod` it. Requires `--playground`. Interactive prompt (default: no) if omitted. The CLI reads your existing `origin` and records its URL in the Bulletin metadata; it never creates a repo or pushes for you. The deploy fails with an actionable message if `origin` is unset, points to a private repo, or points to anything other than GitHub (since `dot mod` only fetches from `codeload.github.com`). Set up the repo yourself before re-running: create a public repo on GitHub, then `git remote add origin https://github.com//` followed by `git push -u origin main`. (If you happen to have `gh` installed, `gh repo create my-app --public --source=. --push` does both in one shot — `dot` does not require `gh`.) - `--suri ` — override signer with a dev secret URI (e.g. `//Alice`). Useful for CI. - `--env ` — target environment. Defaults to `paseo-next-v2` (the only one fully wired today). Accepts the bulletin-deploy env IDs (`preview`, `paseo-next`, `paseo-review`, `paseo-next-v2`, `polkadot`, `kusama`) plus the legacy `testnet`/`mainnet` aliases — `testnet` maps to `paseo-next-v2`, `mainnet` to `polkadot`. Any env other than `paseo-next-v2` throws "not supported" until its entry is wired up in `src/config.ts::CONFIGS`. -Passing all four of `--signer`, `--domain`, `--buildDir`, and `--playground` runs in fully non-interactive mode. Any absent flag is filled in by the TUI prompt. `--moddable`, `--private`, and `--contracts` are independently optional in both modes — their absence means a non-moddable, public, frontend-only deploy. +Passing all four of `--signer`, `--domain`, `--buildDir`, and `--playground` runs in fully non-interactive mode. Any absent flag is filled in by the TUI prompt. `--moddable` and `--private` are independently optional in both modes — their absence means a non-moddable, public deploy. **Requirement**: the `ipfs` CLI (Kubo) must be on `PATH`. `dot init` installs it; if you skipped init you can install it manually (`brew install ipfs` or follow [docs.ipfs.tech/install](https://docs.ipfs.tech/install/)). This is a temporary requirement while `bulletin-deploy`'s pure-JS merkleizer has a bug that makes the browser fallback unusable. @@ -75,10 +73,18 @@ For fully non-interactive (CI) runs, combine `--signer`, `--domain`, `--buildDir - `--suri //Alice` — required with `--signer dev` so the dev signer has a known keypair (works with any dev name or full BIP-39 mnemonic). - `--no-build` — reuse pre-built frontend assets in `--buildDir`. -- `--contracts` + `--no-contract-build` — reuse pre-built contract artefacts in `out/` / `target/.release.polkavm` / `artifacts/contracts/` (skips `forge build --resolc`, `cargo-contract build`, or `npx hardhat compile`). - `--no-moddable` — explicitly skip source publishing even if `--moddable` would otherwise apply. - `--private` — publish to the playground with owner-only visibility. +### `dot contract` + +CDM-backed workflows for contracts: + +- `dot contract deploy` builds, deploys, and registers CDM contracts with dot's logged-in signer by default. Pass `--suri //Alice` for local/dev signing. +- `dot contract deploy --features ` forwards Cargo feature flags into CDM's build pipeline. +- `dot contract deploy --registry-address
` targets a specific CDM registry. +- `dot contract install [libraries...]` runs `cdm install [libraries...]`; CDM still owns dependency installation and post-install hooks. + ### `dot mod` Pull a moddable playground app's source into a fresh local project so you can customise and re-deploy it. The interactive picker only shows apps that opted into moddable at deploy time; non-moddable apps surface a clear "this app is not moddable" error if you target them by domain. @@ -216,7 +222,7 @@ The first two are also enforced in CI; running them locally catches the failure ## Dependency Notes - `@parity/product-sdk-*` packages use caret ranges (`^0.x.y`) so upstream patch and minor releases auto-resolve on a fresh `pnpm install`. With pre-1.0 versions, `^` only widens patches within the current 0.x line — a 0.x → 0.(x+1) bump still requires an intentional `package.json` change. CI's `Format` job runs a grep guard that fails the build on any direct `@polkadot-apps/*` import in `src/`, `e2e/`, `scripts/`, or `tools/`. -- `@dotdm/contracts` is on the `^2.0.x` caret. The 2.0 line is the first to consume `@parity/product-sdk-*` directly; the legacy `1.1.1` stable still pulls `@polkadot-apps/*` + `polkadot-api@1.x` and must NOT be downgraded to. Patch bumps within 2.x are safe. +- `@dotdm/contracts` is on the `^3.x` caret. The legacy `1.1.1` stable still pulls `@polkadot-apps/*` + `polkadot-api@1.x` and must NOT be downgraded to. - `@novasamatech/*` packages are forced to `0.7.9-4` via `pnpm.overrides`. They come in transitively from `@parity/product-sdk-terminal` whose `^0.7.7` caret doesn't auto-widen across patches in lockfile updates; the override aligns the tree on the latest published Novasama line (including RFC-0010 `requestResourceAllocation` on `UserSession`). Drop the override once product-sdk-terminal widens its own caret. - `polkadot-api` is on `^2.1.x` and `@polkadot-api/sdk-ink` on `^0.7.0`. The lockfile contains a stale `polkadot-api@1.x` only because `@parity/dotns-cli`'s declared dep references it; that CLI ships as a single bundled `dist/cli.js` with all deps inlined, so the 1.x decl is never resolved at runtime. Effectively the runtime is PAPI 2.x-only. - `bulletin-deploy` is pinned to an explicit version — not `latest`. Currently `0.7.24`. Previously `latest` pointed at 0.6.8 which had a WebSocket heartbeat bug (40s default < 60s chunk timeout) that tore chunk uploads down as `WS halt (3)`; keeping the pin explicit avoids ever sliding back onto that. When bumping, check the release notes for any changes to `deploy()` / `DotNS` APIs we rely on (`jsMerkle`, `signer`, `signerAddress`, `mnemonic`, `rpc`, `attributes`). diff --git a/e2e/cli/deploy.test.ts b/e2e/cli/deploy.test.ts index 912114f..ee26f00 100644 --- a/e2e/cli/deploy.test.ts +++ b/e2e/cli/deploy.test.ts @@ -22,10 +22,6 @@ * All headless deploys require: --signer, --domain, --buildDir, --playground * to trigger the non-interactive path (see isFullySpecified() in deploy/index.ts). * - * Developer-requested priorities: - * - Projects with multiple contracts (multi-contract fixture) - * - EVM (Foundry/Hardhat) vs PVM (Rust/CDM) backends - * - The --contracts flag */ import { describe, test, expect } from "vitest"; @@ -45,59 +41,12 @@ function extractMetadataCid(stdout: string): string | null { } const frontendOnly = fixturePath("frontend-only"); -const foundry = fixturePath("foundry"); -const hardhat = fixturePath("hardhat"); -const rustCdm = fixturePath("rust-cdm"); -const multiContract = fixturePath("multi-contract"); /** buildDir must be absolute — it's resolved relative to cwd, not --dir */ function absBuildDir(fixture: string, dir = "dist"): string { return resolve(fixture, dir); } -/** - * Shared helper for contract-deploy end-to-end tests. - * - * `--no-contract-build` skips the toolchain subprocess (forge / npx hardhat - * compile / cargo-contract) so the CI runner doesn't need the EVM/Rust - * toolchain installed. Each fixture ships pre-built bytecode in its out/ or - * artifacts/ directory. - */ -interface ContractDeployTestConfig { - /** describe-block discriminator: "foundry", "hardhat", "multi" */ - name: string; - /** E2E_DOMAINS. */ - domain: string; - /** fixturePath() result */ - fixture: string; -} - -function runContractDeployTest(cfg: ContractDeployTestConfig): void { - describe(`dot deploy — ${cfg.name} (requires Paseo + IPFS)`, () => { - test(`${cfg.name} deploy completes end-to-end`, { timeout: 450_000 }, async () => { - const result = await dot([ - "deploy", - "--signer", "dev", - "--domain", cfg.domain, - "--buildDir", absBuildDir(cfg.fixture), - "--contracts", - "--no-contract-build", - "--playground", - "--private", - "--suri", SIGNER.suri, - "--dir", cfg.fixture, - ], { timeout: 400_000 }); - - expect( - result.exitCode, - `${cfg.name} deploy failed: ${result.stdout}\n${result.stderr}`, - ).toBe(0); - expect(result.stdout).toContain("Deploy complete"); - expect(result.stdout).toContain(cfg.domain); - }); - }); -} - /** * Assertion notes for the preflight tests below: * - "Checking availability" is printed by `src/commands/deploy/index.ts` ONLY @@ -134,113 +83,6 @@ describe("dot deploy — preflight and validation", () => { expect(output).toContain("--env paseo-next-v2"); }); - test("detects foundry contracts type in project", async () => { - const result = await dot([ - "deploy", - "--signer", "dev", - "--domain", E2E_DOMAINS.preflight, - "--buildDir", absBuildDir(foundry), - "--no-build", - "--contracts", - "--playground", - "--private", - "--suri", SIGNER.suri, - "--dir", foundry, - ]); - const output = result.stdout + result.stderr; - // foundry.toml present → should not complain about missing contract project - expect(output).not.toContain("no foundry/hardhat/cdm project was detected"); - // Real checkpoint: only printed after preflight succeeds. - expect( - output, - `expected to reach availability check\n${output}`, - ).toContain("Checking availability"); - }); - - test("detects hardhat contracts type in project", async () => { - const result = await dot([ - "deploy", - "--signer", "dev", - "--domain", E2E_DOMAINS.preflight, - "--buildDir", absBuildDir(hardhat), - "--no-build", - "--contracts", - "--playground", - "--private", - "--suri", SIGNER.suri, - "--dir", hardhat, - ]); - const output = result.stdout + result.stderr; - expect(output).not.toContain("no foundry/hardhat/cdm project was detected"); - expect( - output, - `expected to reach availability check\n${output}`, - ).toContain("Checking availability"); - }); - - test("detects CDM/Rust contracts type in project", async () => { - const result = await dot([ - "deploy", - "--signer", "dev", - "--domain", E2E_DOMAINS.preflight, - "--buildDir", absBuildDir(rustCdm), - "--no-build", - "--contracts", - "--playground", - "--private", - "--suri", SIGNER.suri, - "--dir", rustCdm, - ]); - const output = result.stdout + result.stderr; - expect(output).not.toContain("no foundry/hardhat/cdm project was detected"); - expect( - output, - `expected to reach availability check\n${output}`, - ).toContain("Checking availability"); - }); - - test("detects multiple contracts in multi-contract project", async () => { - const result = await dot([ - "deploy", - "--signer", "dev", - "--domain", E2E_DOMAINS.preflight, - "--buildDir", absBuildDir(multiContract), - "--no-build", - "--contracts", - "--playground", - "--private", - "--suri", SIGNER.suri, - "--dir", multiContract, - ]); - const output = result.stdout + result.stderr; - expect(output).not.toContain("no foundry/hardhat/cdm project was detected"); - expect( - output, - `expected to reach availability check\n${output}`, - ).toContain("Checking availability"); - }); - - test("--contracts reports error when no contract project detected", async () => { - const result = await dot([ - "deploy", - "--signer", "dev", - "--domain", E2E_DOMAINS.preflight, - "--buildDir", absBuildDir(frontendOnly), - "--no-build", - "--contracts", - "--playground", - "--private", - "--suri", SIGNER.suri, - "--dir", frontendOnly, - ], { timeout: 400_000 }); - const output = result.stdout + result.stderr; - expect( - result.exitCode, - `expected non-zero exit when --contracts has no project\n${output}`, - ).not.toBe(0); - expect(output).toContain("no foundry/hardhat/cdm project was detected"); - }); - test("domain availability check runs before build/upload", { timeout: 300_000 }, async () => { const domain = E2E_DOMAINS.preflight; const result = await dot([ @@ -409,60 +251,3 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () expect(output.toLowerCase()).toMatch(/revert|taken|registered|owned|unavailable|already/); }); }); - -// Contract-deploy tests — parametrized via runContractDeployTest -runContractDeployTest({ name: "foundry", domain: E2E_DOMAINS.foundry, fixture: foundry }); -runContractDeployTest({ name: "hardhat", domain: E2E_DOMAINS.hardhat, fixture: hardhat }); -// Multi-contract foundry project — exercises the contracts-batch publish path -// (TokenA.sol + TokenB.sol deployed in a single --contracts run). -runContractDeployTest({ name: "multi", domain: E2E_DOMAINS.multi, fixture: multiContract }); - -// Rejection test — does NOT require Paseo or IPFS; exits before any chain mutation. -describe("dot deploy — rejects --no-contract-build with no artefacts", () => { - test("foundry project with --no-contract-build but no out/ → clear error", { timeout: 120_000 }, async () => { - const constructorArgs = fixturePath("constructor-args"); - const result = await dot([ - "deploy", - "--signer", "dev", - "--domain", E2E_DOMAINS.preflight, - "--buildDir", absBuildDir(constructorArgs), - "--contracts", - "--no-contract-build", - "--playground", - "--private", - "--suri", SIGNER.suri, - "--dir", constructorArgs, - ]); - const output = result.stdout + result.stderr; - expect(result.exitCode).not.toBe(0); - expect(output).toMatch(/no pre-built contract artifacts found/i); - expect(output).toMatch(/--no-contract-build/); - }); -}); - -// CDM follows the same CI shape as foundry/hardhat: deploy pre-built artifacts -// committed with the fixture, without requiring the Rust/PVM toolchain on CI. -describe("dot deploy — cdm (requires Paseo + IPFS)", () => { - test("CDM deploy completes end-to-end", { timeout: 450_000 }, async () => { - const domain = E2E_DOMAINS.cdm; - const result = await dot([ - "deploy", - "--signer", "dev", - "--domain", domain, - "--buildDir", absBuildDir(rustCdm), - "--contracts", - "--no-contract-build", - "--playground", - "--private", - "--suri", SIGNER.suri, - "--dir", rustCdm, - ], { timeout: 400_000 }); - - expect( - result.exitCode, - `CDM deploy failed: ${result.stdout}\n${result.stderr}`, - ).toBe(0); - expect(result.stdout).toContain("Deploy complete"); - expect(result.stdout).toContain(domain); - }); -}); diff --git a/package.json b/package.json index c14585a..3c8a21d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "test:e2e:nightly": "tools/e2e-local.sh nightly" }, "dependencies": { - "@dotdm/contracts": "^2.0.3", + "@dotdm/contracts": "^3.0.0", + "@dotdm/env": "^2.0.0", "@parity/dotns-cli": "0.6.1", "@parity/product-sdk-address": "^0.1.1", "@parity/product-sdk-bulletin": "^0.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b323298..28ed90e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,11 @@ importers: .: dependencies: '@dotdm/contracts': - specifier: ^2.0.3 - version: 2.0.3(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3) + specifier: ^3.0.0 + version: 3.0.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3) + '@dotdm/env': + specifier: ^2.0.0 + version: 2.0.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) '@parity/dotns-cli': specifier: 0.6.1 version: 0.6.1(@polkadot/util@14.0.3)(postcss@8.5.10)(react-native@0.85.2(@babel/core@7.29.0)(@types/react@18.3.28)(react@18.3.1))(rxjs@7.8.2)(typescript@5.9.3)(yaml@2.8.4) @@ -347,14 +350,14 @@ packages: resolution: {integrity: sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew==} engines: {node: '>=6'} - '@dotdm/contracts@2.0.3': - resolution: {integrity: sha512-SnAlr/9QmUsM9S69P8/2+3xaQt2cfgzV0q2k1qsCfC1NuOqoTvyoz+jRZwAa9jl6aPFyiySpqxxRe7xEfs5C5w==} + '@dotdm/contracts@3.0.0': + resolution: {integrity: sha512-MumLFq8z+cP/EbeuiWtYVUU5qEEsKxvNZRCYU8hX7haAt7lFfqegeQKeZeXP0WWRS4Tn6ftKtSmCh9TJeXYP+g==} - '@dotdm/env@1.0.4': - resolution: {integrity: sha512-Sr2cYKRQ5JSDL3rnjjsGWSpDtN7y+C5013zApNi/WW3wcKbwbmc5e5eXJoE+lpbm2KlOXBBCuFW3XHE3VybzwQ==} + '@dotdm/env@2.0.0': + resolution: {integrity: sha512-oFsCUlYgi2r6F9t1+y+P2S53t8ZmYZ6kuen0deZjROElJTCOl+bhs7iIf/qDHemkkmRXsmxuoJGM1lcNhzyGCw==} - '@dotdm/utils@0.3.1': - resolution: {integrity: sha512-pT7E+6opUlulMNJ2ZxdXg7EOB+DtIWgQc7BQ6wYrUUbKKsa1Oy1lrrdrHPmTethuJM2y96BJArmdW1Ma2nPy/Q==} + '@dotdm/utils@0.4.0': + resolution: {integrity: sha512-+Vgao6WtF1m1JbeZjSnhrWxCu7dRS7tYJNmzMoP0q0a9yaQBbIaKBrrR7ZLpo7uC2wKqPDCSMsuTN2UQ2+jstA==} '@ensdomains/content-hash@3.0.0': resolution: {integrity: sha512-5ongDPX9qDkemcYZ2rPXsRzCRDneoR2xujm2Tq3u0ZefQa49ZAURVKGqbJdjoL0VIZDfceLkBXekYKqkfrR5Ww==} @@ -1182,9 +1185,6 @@ packages: '@parity/product-sdk-chain-client@0.4.2': resolution: {integrity: sha512-RuPECyfE+BI0s+HiTeGa17zV3UPnbhtcbkFNoRcg6PDVM9anLD/W/hPDC3dBYuCG4AhksHBgogbbo154iP9VVA==} - '@parity/product-sdk-contracts@0.4.0': - resolution: {integrity: sha512-D12u3c/tg7r1J2J2w6tSKtiiC7PKxuiFQ3UZucLL6Pehmpfk/czSnPrlvwvbJdpTo1V9ckG83NqR1B1Boqiipw==} - '@parity/product-sdk-contracts@0.5.1': resolution: {integrity: sha512-eM8nQjSf7T1iRQ1Vpfdj+LxBjIHR3geIIw3tF6lp+MkA4Eshf1UZBN1aCYugQUDh/qQxH+mblJJ/94ODjJnctA==} @@ -1194,8 +1194,8 @@ packages: '@parity/product-sdk-descriptors@0.4.1': resolution: {integrity: sha512-7OKTRy+116aUTkTZyGFmD0C4slKoiayx03MBUDFPLRjQvDDt4rJ6Id10MigxK0Dyj57ptImYnAfOnwv7pvZn5A==} - '@parity/product-sdk-host@0.2.2': - resolution: {integrity: sha512-MUHu9FB/7i/pRakhYULzAzcYISjn0nIlVa7YI9ioqMWlaFBwsmWyaC6RaDNOtDFJyW58z6MwTJyx8FdVUMAW0w==} + '@parity/product-sdk-host@0.3.0': + resolution: {integrity: sha512-c4nmD1VQqMlbzqF4vHBRB0YQWLWvmdIS2QgchcDCvlGQbxiL/iz9iLMW9Mz34K8BzHea0bc0mtpYH9HDeYYUZA==} peerDependencies: '@novasamatech/host-api': 0.7.9-4 '@novasamatech/product-sdk': 0.7.9-4 @@ -1216,33 +1216,21 @@ packages: '@novasamatech/product-sdk': optional: true - '@parity/product-sdk-keys@0.2.2': - resolution: {integrity: sha512-1+dvQlBrjCC5nTntwtkKX/hTvL4oBCchTpAnddeSdQZVcsrgn6jHIePfhKpMOA33BLXPEzS0XYC7LggHIAJ7Mw==} - '@parity/product-sdk-keys@0.3.0': resolution: {integrity: sha512-GkFAyqITyy9BLQaFpdyf7hQtUtUfaqAVhp0/0Hg6ayMXA5kSjorAHcTj5Off1/2cK8Lw74U/K6O7Z0jkwnUb7g==} '@parity/product-sdk-logger@0.1.1': resolution: {integrity: sha512-AiSV3TTNlMZJftLQsO78BZsEymGFuJtGMSpGrJ+vUtqaZavWaW/Hc6MICBLnEYgeCrdNpv7QBso3dRsTfnAZXQ==} - '@parity/product-sdk-signer@0.2.3': - resolution: {integrity: sha512-t2FGGuhDSFpTgr8j6S7sKcoKVwlF5chUO0PfruloUwTQXdMR9JlA2e9fqBhIvN5JzO7HYa2ZIFL0uFy4tXYZ9Q==} - '@parity/product-sdk-signer@0.3.0': resolution: {integrity: sha512-baeqdXUZ8PiPKv8jdgyKkKQp7CggeuPqVZ9EOohjW+MZid8/etuZj0UdXh62iFzYtovCBVhfWvf5wIHPoh2SpQ==} - '@parity/product-sdk-storage@0.1.3': - resolution: {integrity: sha512-kIkQw2MVhev0ZZYtc0dOd5wBnW8P0Av6MFpHboGYmQwzzTS35m/hykffaxadZQtg0BPwaxAtxgyttqcksI9R/A==} - '@parity/product-sdk-storage@0.1.5': resolution: {integrity: sha512-TKW7HesTCihDtR1usKg/xMhKiO1kVPJej0bDhXXKTHCDfVWkdPR1A//oG9b+pQtswVLFm4k+FanFOt8lAQMBDA==} '@parity/product-sdk-terminal@0.2.1': resolution: {integrity: sha512-xcuKoOMHETwHBefEeNSMDqzL+AQFoTmK3i6JBwsvGhpijwJ8QDdoEMhilYiMKnmoyDml9nu/VCcPUKw3Am0zow==} - '@parity/product-sdk-tx@0.2.2': - resolution: {integrity: sha512-MHkSsB1FovYElYPGdo4szXt5SVNzGK8HE+2OjWJPapsKDl2UqAEVvG+QnC6nhkny1qL+sVnSncJh6cVEbT8sJA==} - '@parity/product-sdk-tx@0.2.4': resolution: {integrity: sha512-Kp/giVb30/B/z97JE+iGFmz3nZkHSUnaGy4o+6Ed0i2arUuJFY2GsuxEI8+8DvyhmT1HP//GundZSLoJHPTk6Q==} @@ -5207,13 +5195,13 @@ snapshots: '@leichtgewicht/ip-codec': 2.0.5 utf8-codec: 1.0.0 - '@dotdm/contracts@2.0.3(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3)': + '@dotdm/contracts@3.0.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3)': dependencies: - '@dotdm/env': 1.0.4(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) - '@dotdm/utils': 0.3.1 + '@dotdm/env': 2.0.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) + '@dotdm/utils': 0.4.0 '@noble/hashes': 2.2.0 '@parity/product-sdk-bulletin': 0.4.2(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) - '@parity/product-sdk-contracts': 0.4.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3) + '@parity/product-sdk-contracts': 0.5.1(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3) '@parity/product-sdk-descriptors': 0.4.1(esbuild@0.27.7)(rxjs@7.8.2) '@parity/product-sdk-tx': 0.2.4(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) multiformats: 13.4.2 @@ -5231,11 +5219,11 @@ snapshots: - utf-8-validate - zod - '@dotdm/env@1.0.4(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2)': + '@dotdm/env@2.0.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2)': dependencies: - '@dotdm/utils': 0.3.1 + '@dotdm/utils': 0.4.0 '@parity/product-sdk-descriptors': 0.4.1(esbuild@0.27.7)(rxjs@7.8.2) - '@parity/product-sdk-host': 0.2.2(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) + '@parity/product-sdk-host': 0.3.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) '@polkadot-labs/hdkd': 0.0.26 '@polkadot-labs/hdkd-helpers': 0.0.27 polkadot-api: 2.1.3(esbuild@0.27.7)(rxjs@7.8.2) @@ -5249,7 +5237,7 @@ snapshots: - supports-color - utf-8-validate - '@dotdm/utils@0.3.1': + '@dotdm/utils@0.4.0': dependencies: '@polkadot-labs/hdkd': 0.0.26 '@polkadot-labs/hdkd-helpers': 0.0.27 @@ -6621,29 +6609,6 @@ snapshots: - supports-color - utf-8-validate - '@parity/product-sdk-contracts@0.4.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3)': - dependencies: - '@parity/product-sdk-address': 0.1.1 - '@parity/product-sdk-keys': 0.2.2(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) - '@parity/product-sdk-logger': 0.1.1 - '@parity/product-sdk-signer': 0.2.3(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2) - '@parity/product-sdk-tx': 0.2.2(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) - '@polkadot-labs/hdkd-helpers': 0.0.27 - polkadot-api: 2.1.3(esbuild@0.27.7)(rxjs@7.8.2) - viem: 2.48.0(typescript@5.9.3) - transitivePeerDependencies: - - '@novasamatech/host-api' - - '@novasamatech/product-sdk' - - '@polkadot/api' - - '@polkadot/util' - - bufferutil - - esbuild - - rxjs - - supports-color - - typescript - - utf-8-validate - - zod - '@parity/product-sdk-contracts@0.5.1(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3)': dependencies: '@parity/product-sdk-address': 0.1.1 @@ -6684,7 +6649,7 @@ snapshots: - supports-color - utf-8-validate - '@parity/product-sdk-host@0.2.2(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2)': + '@parity/product-sdk-host@0.3.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2)': dependencies: '@parity/product-sdk-logger': 0.1.1 polkadot-api: 2.1.3(esbuild@0.27.7)(rxjs@7.8.2) @@ -6712,23 +6677,6 @@ snapshots: - supports-color - utf-8-validate - '@parity/product-sdk-keys@0.2.2(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2)': - dependencies: - '@parity/product-sdk-address': 0.1.1 - '@parity/product-sdk-crypto': 0.1.1 - '@parity/product-sdk-storage': 0.1.3(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) - '@polkadot-labs/hdkd': 0.0.28 - '@polkadot-labs/hdkd-helpers': 0.0.10 - polkadot-api: 2.1.3(esbuild@0.27.7)(rxjs@7.8.2) - transitivePeerDependencies: - - '@novasamatech/host-api' - - '@novasamatech/product-sdk' - - bufferutil - - esbuild - - rxjs - - supports-color - - utf-8-validate - '@parity/product-sdk-keys@0.3.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2)': dependencies: '@parity/product-sdk-address': 0.1.1 @@ -6750,25 +6698,6 @@ snapshots: '@parity/product-sdk-logger@0.1.1': {} - '@parity/product-sdk-signer@0.2.3(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)': - dependencies: - '@parity/product-sdk-address': 0.1.1 - '@parity/product-sdk-host': 0.2.2(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) - '@parity/product-sdk-keys': 0.2.2(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) - '@parity/product-sdk-logger': 0.1.1 - polkadot-api: 2.1.3(esbuild@0.27.7)(rxjs@7.8.2) - optionalDependencies: - '@novasamatech/host-api': 0.7.9-4 - '@novasamatech/product-sdk': 0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2) - transitivePeerDependencies: - - '@polkadot/api' - - '@polkadot/util' - - bufferutil - - esbuild - - rxjs - - supports-color - - utf-8-validate - '@parity/product-sdk-signer@0.3.0(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)': dependencies: '@parity/product-sdk-address': 0.1.1 @@ -6788,19 +6717,6 @@ snapshots: - supports-color - utf-8-validate - '@parity/product-sdk-storage@0.1.3(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2)': - dependencies: - '@parity/product-sdk-host': 0.2.2(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) - '@parity/product-sdk-logger': 0.1.1 - transitivePeerDependencies: - - '@novasamatech/host-api' - - '@novasamatech/product-sdk' - - bufferutil - - esbuild - - rxjs - - supports-color - - utf-8-validate - '@parity/product-sdk-storage@0.1.5(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2)': dependencies: '@parity/product-sdk-host': 0.4.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) @@ -6838,21 +6754,6 @@ snapshots: - supports-color - utf-8-validate - '@parity/product-sdk-tx@0.2.2(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2)': - dependencies: - '@parity/product-sdk-keys': 0.2.2(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) - '@parity/product-sdk-logger': 0.1.1 - '@polkadot-labs/hdkd-helpers': 0.0.10 - polkadot-api: 2.1.3(esbuild@0.27.7)(rxjs@7.8.2) - transitivePeerDependencies: - - '@novasamatech/host-api' - - '@novasamatech/product-sdk' - - bufferutil - - esbuild - - rxjs - - supports-color - - utf-8-validate - '@parity/product-sdk-tx@0.2.4(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2)': dependencies: '@parity/product-sdk-keys': 0.3.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) diff --git a/src/commands/contract.test.ts b/src/commands/contract.test.ts new file mode 100644 index 0000000..9bd238e --- /dev/null +++ b/src/commands/contract.test.ts @@ -0,0 +1,134 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { getRegistryAddress } from "@dotdm/env"; +import { DEFAULT_MNEMONIC as BULLETIN_DEPLOY_DEFAULT_MNEMONIC } from "bulletin-deploy"; +import { describe, expect, it } from "vitest"; +import { getChainConfig } from "../config.js"; +import { + cdmPassthroughArgs, + resolveContractDeployTarget, + resolveContractSignerOptions, +} from "./contract.js"; + +describe("cdmPassthroughArgs", () => { + it("returns arguments after the contract subcommand", () => { + expect( + cdmPassthroughArgs( + ["node", "dot", "contract", "install", "@polkadot/reputation", "--name", "paseo"], + "install", + ), + ).toEqual(["@polkadot/reputation", "--name", "paseo"]); + }); + + it("handles the install alias", () => { + expect( + cdmPassthroughArgs( + ["node", "dot", "contract", "i", "@polkadot/reputation:3"], + "install", + ["i"], + ), + ).toEqual(["@polkadot/reputation:3"]); + }); + + it("falls back to the first matching subcommand without a contract parent", () => { + expect(cdmPassthroughArgs(["node", "dot", "deploy", "--features", "ci"], "deploy")).toEqual( + ["--features", "ci"], + ); + }); +}); + +describe("resolveContractDeployTarget", () => { + it("uses the active playground chain by default", () => { + const cfg = getChainConfig(); + expect(resolveContractDeployTarget({})).toEqual({ + assethubUrl: cfg.assetHubRpc, + bulletinUrl: cfg.bulletinRpc, + bulletinUrls: [cfg.bulletinRpc, ...cfg.bulletinRpcFallbacks], + registryAddress: getRegistryAddress(cfg.env), + }); + }); + + it("accepts explicit endpoint and registry overrides", () => { + expect( + resolveContractDeployTarget({ + assethubUrl: "wss://asset.example", + bulletinUrl: "wss://bulletin.example", + registryAddress: "0x1111111111111111111111111111111111111111", + }), + ).toEqual({ + assethubUrl: "wss://asset.example", + bulletinUrl: "wss://bulletin.example", + bulletinUrls: ["wss://bulletin.example"], + registryAddress: "0x1111111111111111111111111111111111111111", + }); + }); + + it("rejects non-H160 registry addresses", () => { + expect(() => resolveContractDeployTarget({ registryAddress: "0x1234" })).toThrow( + "Registry address must be a 20-byte hex address", + ); + }); +}); + +describe("resolveContractSignerOptions", () => { + it("preserves the default contract signer behavior", () => { + expect(resolveContractSignerOptions({})).toEqual({ suri: undefined }); + }); + + it("uses the explicit SURI when no signer mode is selected", () => { + expect(resolveContractSignerOptions({ suri: "//Bob" })).toEqual({ suri: "//Bob" }); + }); + + it("uses bulletin-deploy's default dev mnemonic by default", () => { + expect(resolveContractSignerOptions({ signer: "dev" })).toEqual({ + suri: BULLETIN_DEPLOY_DEFAULT_MNEMONIC, + }); + }); + + it("honors bulletin-deploy mnemonic environment overrides", () => { + const previousDotnsMnemonic = process.env.DOTNS_MNEMONIC; + const previousMnemonic = process.env.MNEMONIC; + try { + process.env.DOTNS_MNEMONIC = "dotns env mnemonic"; + process.env.MNEMONIC = "plain env mnemonic"; + expect(resolveContractSignerOptions({ signer: "dev" })).toEqual({ + suri: "dotns env mnemonic", + }); + + delete process.env.DOTNS_MNEMONIC; + expect(resolveContractSignerOptions({ signer: "dev" })).toEqual({ + suri: "plain env mnemonic", + }); + } finally { + if (previousDotnsMnemonic === undefined) delete process.env.DOTNS_MNEMONIC; + else process.env.DOTNS_MNEMONIC = previousDotnsMnemonic; + if (previousMnemonic === undefined) delete process.env.MNEMONIC; + else process.env.MNEMONIC = previousMnemonic; + } + }); + + it("allows a custom local signer in dev mode", () => { + expect(resolveContractSignerOptions({ signer: "dev", suri: "//Charlie" })).toEqual({ + suri: "//Charlie", + }); + }); + + it("rejects SURI with phone mode to avoid silently using a local signer", () => { + expect(() => resolveContractSignerOptions({ signer: "phone", suri: "//Alice" })).toThrow( + "--suri cannot be used with --signer phone", + ); + }); +}); diff --git a/src/commands/contract.ts b/src/commands/contract.ts new file mode 100644 index 0000000..fad731a --- /dev/null +++ b/src/commands/contract.ts @@ -0,0 +1,283 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { resolveFeatures, type PipelineChainClient } from "@dotdm/contracts"; +import { getRegistryAddress } from "@dotdm/env"; +import { paseo_asset_hub } from "@parity/product-sdk-descriptors/paseo-asset-hub"; +import { paseo_bulletin } from "@parity/product-sdk-descriptors/paseo-bulletin"; +import { DEFAULT_MNEMONIC as BULLETIN_DEPLOY_DEFAULT_MNEMONIC } from "bulletin-deploy"; +import { Command, Option } from "commander"; +import { createClient, type HexString, type SS58String } from "polkadot-api"; +import { getWsProvider } from "polkadot-api/ws"; +import { runCliCommand } from "../cli-runtime.js"; +import { getChainConfig } from "../config.js"; +import { getBulletinAllowanceSigner } from "../utils/allowances/bulletin.js"; +import { ensureSmartContractAllowance } from "../utils/allowances/smartContracts.js"; +import { BULLETIN_WS_HEARTBEAT_MS } from "../utils/bulletinWs.js"; +import type { SignerMode } from "../utils/deploy/signerMode.js"; +import { onProcessShutdown } from "../utils/process-guard.js"; +import { resolveSigner, type ResolvedSigner, type SignerOptions } from "../utils/signer.js"; +import { runContractDeployWithUI } from "./contractDeployUi.js"; + +type CdmSubcommand = "deploy" | "install"; + +interface ContractDeployOpts { + assethubUrl?: string; + bulletinUrl?: string; + registryAddress?: string; + signer?: SignerMode; + suri?: string; + features?: string; +} + +interface ContractInstallOpts { + assethubUrl?: string; + name?: string; + ipfsGatewayUrl?: string; + registryAddress?: string; +} + +interface ContractDeployTarget { + assethubUrl: string; + bulletinUrl: string; + bulletinUrls: string[]; + registryAddress: HexString; +} + +type ContractChainClient = PipelineChainClient & { destroy(): void }; + +export function cdmPassthroughArgs( + argv: string[], + subcommand: CdmSubcommand, + aliases: string[] = [], +): string[] { + const contractIndex = argv.indexOf("contract"); + const startAt = contractIndex === -1 ? 0 : contractIndex + 1; + const subcommandNames = new Set([subcommand, ...aliases]); + const subcommandIndex = argv.findIndex( + (arg, index) => index >= startAt && subcommandNames.has(arg), + ); + return subcommandIndex === -1 ? [] : argv.slice(subcommandIndex + 1); +} + +async function runCdmSubprocess(subcommand: CdmSubcommand, args: string[]): Promise { + await new Promise((resolve, reject) => { + const child = spawn("cdm", [subcommand, ...args], { + stdio: "inherit", + env: process.env, + }); + + child.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") { + reject(new Error('cdm is not installed. Run "dot init" or install CDM manually.')); + return; + } + reject(err); + }); + + child.on("close", (code, signal) => { + if (signal) { + process.exitCode = signal === "SIGINT" ? 130 : 1; + resolve(); + return; + } + process.exitCode = code ?? 1; + resolve(); + }); + }); +} + +function assertHexAddress(value: string, label: string): HexString { + if (!/^0x[0-9a-fA-F]{40}$/.test(value)) { + throw new Error(`${label} must be a 20-byte hex address`); + } + return value as HexString; +} + +export function resolveContractSignerOptions(opts: ContractDeployOpts): SignerOptions { + if (opts.signer === "dev") { + return { + suri: + opts.suri ?? + process.env.DOTNS_MNEMONIC ?? + process.env.MNEMONIC ?? + BULLETIN_DEPLOY_DEFAULT_MNEMONIC, + }; + } + if (opts.signer === "phone") { + if (opts.suri) { + throw new Error( + "--suri cannot be used with --signer phone. Use --signer dev --suri for local signing.", + ); + } + return {}; + } + return { suri: opts.suri }; +} + +export function resolveContractDeployTarget(opts: ContractDeployOpts): ContractDeployTarget { + const cfg = getChainConfig(); + const bulletinUrl = opts.bulletinUrl ?? cfg.bulletinRpc; + return { + assethubUrl: opts.assethubUrl ?? cfg.assetHubRpc, + bulletinUrl, + bulletinUrls: opts.bulletinUrl + ? [opts.bulletinUrl] + : [bulletinUrl, ...cfg.bulletinRpcFallbacks], + registryAddress: assertHexAddress( + opts.registryAddress ?? getRegistryAddress(cfg.env), + "Registry address", + ), + }; +} + +async function createContractChainClient( + target: ContractDeployTarget, +): Promise { + const raw = { + assetHub: createClient(getWsProvider([target.assethubUrl])), + bulletin: createClient( + getWsProvider(target.bulletinUrls, { heartbeatTimeout: BULLETIN_WS_HEARTBEAT_MS }), + ), + }; + let destroyed = false; + const destroy = () => { + if (destroyed) return; + destroyed = true; + raw.assetHub.destroy(); + raw.bulletin.destroy(); + }; + + try { + await Promise.all([raw.assetHub.getChainSpecData(), raw.bulletin.getChainSpecData()]); + } catch (err) { + destroy(); + throw err; + } + + return { + assetHub: raw.assetHub.getTypedApi(paseo_asset_hub), + bulletin: raw.bulletin.getTypedApi(paseo_bulletin), + raw, + descriptors: { + assetHub: paseo_asset_hub, + bulletin: paseo_bulletin, + }, + destroy, + }; +} + +async function runContractDeploy(opts: ContractDeployOpts): Promise { + const target = resolveContractDeployTarget(opts); + const cfg = getChainConfig(); + const rootDir = resolve(process.cwd()); + const features = resolveFeatures(opts.features, rootDir); + + let signer: ResolvedSigner | null = null; + let client: ContractChainClient | null = null; + const cleanupOnce = (() => { + let ran = false; + return () => { + if (ran) return; + ran = true; + try { + signer?.destroy(); + } catch {} + try { + client?.destroy(); + } catch {} + }; + })(); + onProcessShutdown(cleanupOnce); + + try { + signer = await resolveSigner(resolveContractSignerOptions(opts)); + await ensureSmartContractAllowance({ + env: cfg.env, + ownerAddress: signer.address, + deploySigner: signer, + }); + client = await createContractChainClient(target); + const metadataSigner = await getBulletinAllowanceSigner({ + env: cfg.env, + ownerAddress: signer.address, + publishSigner: signer, + bulletinApi: client.bulletin, + }); + + const result = await runContractDeployWithUI({ + rootDir, + features, + client, + signer: signer.signer, + origin: signer.address as SS58String, + registryAddress: target.registryAddress, + metadataSigner, + assethubUrl: target.assethubUrl, + bulletinUrl: target.bulletinUrl, + ipfsGatewayUrl: cfg.bulletinGateway, + signerAddress: signer.address, + signerRequiresApproval: signer.source === "session", + }); + if (!result.success) process.exitCode = 1; + } finally { + cleanupOnce(); + } +} + +function makeDeployCommand(): Command { + return new Command("deploy") + .description("Build, deploy, and register CDM contracts with the dot signer") + .addOption(new Option("--signer ", "Signer mode").choices(["dev", "phone"])) + .option("--assethub-url ", "Override the Asset Hub WebSocket URL") + .option("--bulletin-url ", "Override the Bulletin WebSocket URL") + .option("--registry-address
", "Registry contract address") + .option( + "--suri ", + "Secret URI for local signing; defaults to bulletin-deploy's dev mnemonic when --signer dev", + ) + .option("--features ", "Cargo feature flags to pass to the build") + .action(async (opts: ContractDeployOpts) => + runCliCommand("contract", { watchdog: true, hardExit: true }, () => + runContractDeploy(opts), + ), + ); +} + +function makeInstallCommand(): Command { + return new Command("install") + .alias("i") + .description("Install CDM contract libraries to ~/.cdm/") + .argument( + "[libraries...]", + 'CDM libraries (e.g., "@polkadot/reputation" or "@polkadot/reputation:3"). Omit to install all from cdm.json.', + ) + .option("--assethub-url ", "WebSocket URL for Asset Hub chain") + .option("-n, --name ", "Chain preset name (polkadot, paseo, preview-net, local)") + .option("--ipfs-gateway-url ", "IPFS gateway URL for fetching metadata") + .option("--registry-address
", "Registry contract address") + .action(async (_libraries: string[], _opts: ContractInstallOpts) => + runCliCommand("contract", { watchdog: true, hardExit: true }, () => + runCdmSubprocess("install", cdmPassthroughArgs(process.argv, "install", ["i"])), + ), + ); +} + +export const contractCommand = new Command("contract") + .description("Run CDM contract workflows") + .addCommand(makeDeployCommand()) + .addCommand(makeInstallCommand()); diff --git a/src/commands/contractDeployUi.tsx b/src/commands/contractDeployUi.tsx new file mode 100644 index 0000000..6da096c --- /dev/null +++ b/src/commands/contractDeployUi.tsx @@ -0,0 +1,489 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useEffect, useState, type ReactNode } from "react"; +import { Box, Text, render } from "ink"; +import { + deployContracts, + detectBuildOrder, + type DeployContractsOptions, + type DeploySummary, +} from "@dotdm/contracts"; +import { getNetworkLabel } from "../config.js"; +import { + COLOR, + Callout, + GLYPH, + Header, + LogTail, + Mark, + PhoneApprovalCallout, + Row, + Section, + TIMING, +} from "../utils/ui/theme/index.js"; +import { createSigningCounter, wrapSignerWithEvents } from "../utils/deploy/signingProxy.js"; +import { VERSION_LABEL } from "../utils/version.js"; +import { ContractPipelineStatusAdapter, type ContractStatus } from "./contractPipelineStatus.js"; + +const COL_CONTRACT = 22; +const COL_BUILD = 18; +const COL_REGISTRY = 8; +const COL_METADATA = 8; +const BAR_WIDTH = 12; + +export interface ContractDeployUiOptions extends Omit { + assethubUrl: string; + bulletinUrl: string; + ipfsGatewayUrl: string; + signerAddress: string; + signerRequiresApproval?: boolean; +} + +export interface ContractDeployUiResult { + summary: DeploySummary; + success: boolean; +} + +export async function runContractDeployWithUI( + opts: ContractDeployUiOptions, +): Promise { + const { + assethubUrl, + bulletinUrl, + ipfsGatewayUrl, + signerAddress, + signerRequiresApproval = false, + ...deployOpts + } = opts; + const { crates, displayNames } = precomputeDisplay(deployOpts.rootDir, deployOpts.contracts); + const adapter = new ContractPipelineStatusAdapter({ + onCdmPackageDetected: (crate, pkg) => displayNames.set(crate, pkg), + }); + + const app = render( + , + ); + const signingCounter = createSigningCounter(1); + const signer = signerRequiresApproval + ? wrapSignerWithEvents(deployOpts.signer, { + label: "Deploy and register contracts", + counter: signingCounter, + onEvent: adapter.handleSigningEvent, + }) + : deployOpts.signer; + + let summary: DeploySummary; + try { + summary = await deployContracts({ + ...deployOpts, + signer, + onEvent: adapter.handleDeployEvent, + }); + } finally { + await new Promise((resolve) => setTimeout(resolve, 200)); + app.unmount(); + } + + return { + summary, + success: summary.contracts.every((contract) => contract.status !== "error"), + }; +} + +function precomputeDisplay(rootDir: string, contracts: string[] | undefined) { + const order = detectBuildOrder(rootDir, contracts); + const displayNames = new Map(); + for (const contract of order.contracts) { + displayNames.set( + contract.name, + contract.cdmPackage ?? contract.displayName ?? contract.name, + ); + } + return { crates: order.layers.flat(), displayNames }; +} + +function ContractDeployScreen({ + adapter, + crates, + displayNames, + signerAddress, + registryAddress, + assethubUrl, + bulletinUrl, + ipfsGatewayUrl, +}: { + adapter: ContractPipelineStatusAdapter; + crates: string[]; + displayNames: Map; + signerAddress: string; + registryAddress: string; + assethubUrl: string; + bulletinUrl: string; + ipfsGatewayUrl: string; +}) { + const [tick, setTick] = useState(0); + + useEffect(() => { + const timer = setInterval(() => setTick((current) => current + 1), TIMING.spinnerMs); + return () => clearInterval(timer); + }, []); + + return ( + +
+
+ + +
+ + {adapter.signingPrompt && ( + + )} + {adapter.signingError && ( + + {adapter.signingError} + + )} + 0 ? crates : adapter.crates} + logLines={adapter.logLines} + assethubUrl={assethubUrl} + ipfsGatewayUrl={ipfsGatewayUrl} + tick={tick} + /> + + + ); +} + +function DeployTable({ + statuses, + displayNames, + crates, + logLines, + assethubUrl, + ipfsGatewayUrl, + tick, +}: { + statuses: Map; + displayNames: Map; + crates: string[]; + logLines: string[]; + assethubUrl: string; + ipfsGatewayUrl: string; + tick: number; +}) { + const rowCrates = [ + ...crates, + ...Array.from(statuses.keys()).filter((crate) => !crates.includes(crate)), + ]; + const errors = errorGroups(rowCrates, statuses, displayNames); + + return ( + + + {rowCrates.map((crate) => ( + + ))} + {rowCrates.length === 0 && } + {logLines.length > 0 && ( + + + + )} + {errors.length > 0 && ( + + {errors.map(({ names, error }) => ( + + ))} + + )} + + ); +} + +function HeaderRow() { + return ( + + + contract + + + build + + + registry + + + metadata + + + address + + + ); +} + +function ContractRow({ + name, + status, + assethubUrl, + ipfsGatewayUrl, + tick, +}: { + name: string; + status: ContractStatus | undefined; + assethubUrl: string; + ipfsGatewayUrl: string; + tick: number; +}) { + const state = status?.state ?? "waiting"; + + return ( + + + + {name} + + + {buildCell(status, tick)} + {registryCell(status, state, assethubUrl, tick)} + {metadataCell(status, state, ipfsGatewayUrl, tick)} + {status?.address ? {status.address} : } + + ); +} + +function buildCell(status: ContractStatus | undefined, tick: number) { + const state = status?.state ?? "waiting"; + if (state === "building" && status?.buildProgress) { + return ( + + ); + } + if (state === "building") return ; + if (state === "error" && errorPhase(status) === "build") return ; + if (state === "waiting") return ; + if (status?.bytecodeSize) { + return ; + } + return ; +} + +function registryCell( + status: ContractStatus | undefined, + state: ContractStatus["state"], + assethubUrl: string, + tick: number, +) { + if (state === "checking" || status?.deployInProgress || status?.registerInProgress) { + return ; + } + if ( + state === "error" && + (errorPhase(status) === "deploy" || errorPhase(status) === "register") + ) { + return ; + } + if (state === "cached") return ; + if (status?.deployTxHash && status.deployBlockHash) { + return ( + + ); + } + if (state === "done") return ; + return ; +} + +function metadataCell( + status: ContractStatus | undefined, + state: ContractStatus["state"], + ipfsGatewayUrl: string, + tick: number, +) { + if (status?.publishInProgress) return ; + if (state === "error" && errorPhase(status) === "metadata") return ; + if (state === "cached") return ; + if (status?.cid) { + return ; + } + return ; +} + +function ProgressBar({ + current, + total, + tail, +}: { + current: number; + total: number; + tail?: string; +}) { + const filled = total > 0 ? Math.round((current / total) * BAR_WIDTH) : 0; + return ( + + {GLYPH.cursorBlock.repeat(filled)} + {GLYPH.progressEmpty.repeat(BAR_WIDTH - filled)} + {tail ?? `${current}/${total}`} + + ); +} + +function EmptyBar() { + return {GLYPH.progressEmpty.repeat(BAR_WIDTH)}; +} + +function Spinner({ tick }: { tick: number }) { + return {GLYPH.spinner[tick % GLYPH.spinner.length]}; +} + +function Cached() { + return {GLYPH.cached}; +} + +function Idle() { + return {GLYPH.pending}; +} + +function HashText({ value, url }: { value: string; url?: string }) { + const label = {shortHash(value)}; + return url ? {label} : label; +} + +function Cell({ children, width }: { children: ReactNode; width?: number }) { + return ( + + {children} + + ); +} + +function errorPhase( + status: ContractStatus | undefined, +): "build" | "deploy" | "metadata" | "register" { + if (!status) return "build"; + if (status.address && status.publishTxHash) return "register"; + if (status.address && !status.publishTxHash && status.cid) return "metadata"; + if ( + status.buildProgress && + status.buildProgress.compiled === status.buildProgress.total && + status.buildProgress.total > 0 + ) { + return "deploy"; + } + return "build"; +} + +function errorGroups( + crates: string[], + statuses: Map, + displayNames: Map, +) { + const groups = new Map(); + for (const crate of crates) { + const status = statuses.get(crate); + if (status?.state !== "error" || !status.error) continue; + const names = groups.get(status.error) ?? []; + names.push(displayNames.get(crate) ?? crate); + groups.set(status.error, names); + } + return [...groups].map(([error, names]) => ({ error, names })); +} + +function formatErrorNames(names: string[]): string { + if (names.length === 1) return names[0] ?? "contract"; + const preview = names.slice(0, 3).join(", "); + const suffix = names.length > 3 ? ", ..." : ""; + return `${names.length} contracts (${preview}${suffix})`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1000) return `${bytes}B`; + if (bytes < 1_000_000) return `${(bytes / 1000).toFixed(1)}KB`; + return `${(bytes / 1_000_000).toFixed(1)}MB`; +} + +function truncateMiddle(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + const prefix = Math.max(4, Math.floor((maxLength - 3) * 0.6)); + const suffix = Math.max(4, maxLength - 3 - prefix); + return `${value.slice(0, prefix)}...${value.slice(-suffix)}`; +} + +function shortHash(value: string): string { + if (value.startsWith("0x")) return value.slice(2, 6); + return value.slice(-4); +} + +function Link({ url, children }: { url: string; children: ReactNode }) { + return ( + + {`\x1b]8;;${url}\x07`} + {children} + {"\x1b]8;;\x07"} + + ); +} + +function pjsExplorerUrl(rpcUrl: string, blockHash: string): string { + return `https://polkadot.js.org/apps/?rpc=${encodeURIComponent(rpcUrl)}#/explorer/query/${blockHash}`; +} + +function ipfsUrl(gatewayUrl: string, cid: string): string { + return `${gatewayUrl.replace(/\/+$/, "")}/${cid.replace(/^\/+/, "")}`; +} diff --git a/src/commands/contractPipelineStatus.test.ts b/src/commands/contractPipelineStatus.test.ts new file mode 100644 index 0000000..47f6b61 --- /dev/null +++ b/src/commands/contractPipelineStatus.test.ts @@ -0,0 +1,155 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, expect, it } from "vitest"; +import { ContractPipelineStatusAdapter } from "./contractPipelineStatus.js"; + +describe("ContractPipelineStatusAdapter", () => { + it("tracks build, deploy, publish, and register status for CDM events", () => { + const displayNames = new Map(); + const adapter = new ContractPipelineStatusAdapter({ + onCdmPackageDetected: (crate, pkg) => displayNames.set(crate, pkg), + }); + + adapter.handleDeployEvent({ + type: "detect", + layers: [["reputation"]], + contracts: [ + { + name: "reputation", + cdmPackage: "@polkadot/reputation", + description: null, + authors: [], + homepage: null, + repository: null, + readmePath: null, + path: "/tmp/reputation", + dependsOnCrates: [], + }, + ], + }); + adapter.handleDeployEvent({ type: "build-start", crate: "reputation" }); + adapter.handleDeployEvent({ + type: "build-progress", + crate: "reputation", + compiled: 4, + total: 8, + }); + adapter.handleDeployEvent({ + type: "build-done", + crate: "reputation", + durationMs: 1200, + bytecodeSize: 42_000, + }); + adapter.handleDeployEvent({ + type: "deploy-register-start", + crates: ["reputation"], + }); + adapter.handleDeployEvent({ + type: "publish-start", + crates: ["reputation"], + }); + adapter.handleDeployEvent({ + type: "deploy-register-done", + addresses: { reputation: "0x1111111111111111111111111111111111111111" }, + txHash: "0xabc", + blockHash: "0xdef", + durationMs: 2500, + }); + adapter.handleDeployEvent({ + type: "publish-done", + cids: { reputation: "bafy1234" }, + txHash: "0xpub", + durationMs: 500, + }); + + expect(displayNames.get("reputation")).toBe("@polkadot/reputation"); + expect(adapter.statuses.get("reputation")).toMatchObject({ + state: "done", + address: "0x1111111111111111111111111111111111111111", + cid: "bafy1234", + deployInProgress: false, + publishInProgress: false, + registerInProgress: false, + deployTxHash: "0xabc", + publishTxHash: "0xpub", + bytecodeSize: 42_000, + }); + }); + + it("retains only a bounded sanitized log tail", () => { + const adapter = new ContractPipelineStatusAdapter(); + + for (let i = 0; i < 10; i++) { + adapter.handleDeployEvent({ + type: "log", + line: `\u001b[32mline ${i}\u001b[0m\r`, + }); + } + + expect(adapter.logLines).toEqual(["line 5", "line 6", "line 7", "line 8", "line 9"]); + }); + + it("ignores planned dry-run addresses until deployment submits", () => { + const adapter = new ContractPipelineStatusAdapter(); + adapter.handleDeployEvent({ + type: "build-done", + crate: "counter", + durationMs: 1200, + bytecodeSize: 4200, + }); + + adapter.handleDeployEvent({ + type: "check-needs-deploy", + crate: "counter", + address: "0x1111111111111111111111111111111111111111", + }); + + expect(adapter.statuses.get("counter")?.state).toBe("built"); + expect(adapter.statuses.get("counter")?.address).toBeUndefined(); + }); + + it("surfaces raw signer errors over normalized deploy errors", () => { + const adapter = new ContractPipelineStatusAdapter(); + + adapter.handleSigningEvent({ + kind: "sign-request", + label: "Deploy and register contracts", + step: 1, + total: 1, + }); + expect(adapter.signingPrompt?.label).toBe("Deploy and register contracts"); + + adapter.handleSigningEvent({ + kind: "sign-error", + label: "Deploy and register contracts", + step: 1, + total: 1, + message: "Mobile signing failed: unsupported payload", + }); + adapter.handleDeployEvent({ + type: "deploy-register-error", + crates: ["counter"], + error: "[AssetHub deploy+register chunk 1/1] Transaction signing was rejected.", + }); + + expect(adapter.signingPrompt).toBeNull(); + expect(adapter.signingError).toBe("Mobile signing failed: unsupported payload"); + expect(adapter.statuses.get("counter")).toMatchObject({ + state: "error", + error: "Mobile signing failed: unsupported payload", + }); + }); +}); diff --git a/src/commands/contractPipelineStatus.ts b/src/commands/contractPipelineStatus.ts new file mode 100644 index 0000000..688f47a --- /dev/null +++ b/src/commands/contractPipelineStatus.ts @@ -0,0 +1,270 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { BuildEvent, ContractInfo, DeployEvent } from "@dotdm/contracts"; +import type { SigningEvent } from "../utils/deploy/signingProxy.js"; + +export type ContractState = + | "waiting" + | "building" + | "built" + | "checking" + | "cached" + | "deploying" + | "done" + | "error"; + +export interface ContractStatus { + crateName: string; + state: ContractState; + error?: string; + address?: string; + cid?: string; + deployTxHash?: string; + deployBlockHash?: string; + publishTxHash?: string; + durationMs?: number; + buildProgress?: { compiled: number; total: number }; + bytecodeSize?: number; + deployInProgress?: boolean; + publishInProgress?: boolean; + registerInProgress?: boolean; +} + +export interface PhaseInfo { + name: + | "connecting-registry" + | "checking-versions" + | "precomputing-addresses" + | "preparing-metadata" + | "deploying" + | "publishing" + | "done"; + description: string; + layer?: number; +} + +interface AdapterOptions { + onCdmPackageDetected?: (crateName: string, cdmPackage: string) => void; +} + +export class ContractPipelineStatusAdapter { + static readonly LOG_TAIL_LINES = 5; + + readonly statuses = new Map(); + readonly logLines: string[] = []; + crates: string[] = []; + layers: string[][] = []; + contracts: ContractInfo[] = []; + cdmPackageMap = new Map(); + phase: PhaseInfo | null = null; + signingPrompt: Extract | null = null; + signingError: string | null = null; + + constructor(private opts: AdapterOptions = {}) {} + + handleSigningEvent = (event: SigningEvent) => { + switch (event.kind) { + case "sign-request": + this.signingPrompt = event; + this.signingError = null; + return; + case "sign-complete": + this.signingPrompt = null; + return; + case "sign-error": + this.signingPrompt = null; + this.signingError = event.message; + return; + } + }; + + handleDeployEvent = (event: DeployEvent) => { + switch (event.type) { + case "detect": + case "log": + case "build-start": + case "build-progress": + case "build-done": + case "build-error": + this.handleBuildEvent(event as BuildEvent); + return; + case "check-cached": + this.update(event.crate, "cached", { address: event.address }); + return; + case "check-needs-deploy": + return; + case "phase": + this.phase = { + name: event.name, + description: event.description, + layer: event.layer, + }; + return; + case "sign-request": + case "deploy-plan": + return; + case "deploy-register-start": + this.signingError = null; + for (const crate of event.crates) { + this.update(crate, "deploying", { + deployInProgress: true, + registerInProgress: this.cdmPackageMap.has(crate), + }); + } + return; + case "publish-start": + for (const crate of event.crates) { + this.update(crate, "deploying", { publishInProgress: true }); + } + return; + case "deploy-register-done": + for (const crate of Object.keys(event.addresses)) { + const address = event.addresses[crate]; + if (!address) continue; + this.update(crate, "done", { + address, + deployInProgress: false, + registerInProgress: false, + deployTxHash: event.txHash, + deployBlockHash: event.blockHash, + durationMs: event.durationMs, + }); + } + return; + case "publish-done": + for (const crate of Object.keys(event.cids)) { + const cid = event.cids[crate]; + const current = this.statuses.get(crate); + this.update(crate, current?.state ?? "done", { + cid, + publishInProgress: false, + publishTxHash: event.txHash, + }); + } + return; + case "deploy-register-error": + const error = this.signingError ?? event.error; + for (const crate of event.crates) { + this.update(crate, "error", { + error, + deployInProgress: false, + publishInProgress: false, + registerInProgress: false, + }); + } + return; + case "pipeline-done": + this.phase = { + name: "done", + description: "Pipeline complete", + }; + if (event.summary.contracts.every((contract) => contract.status !== "error")) { + this.clearLogs(); + } + return; + case "pipeline-error": + this.phase = { + name: "done", + description: "Pipeline failed", + }; + return; + } + }; + + private handleBuildEvent(event: BuildEvent) { + switch (event.type) { + case "log": + this.appendLog(event.line); + return; + case "detect": + this.contracts = event.contracts; + this.layers = event.layers; + this.crates = event.layers.flat(); + for (const contract of event.contracts) { + if (contract.cdmPackage) { + this.cdmPackageMap.set(contract.name, contract.cdmPackage); + this.opts.onCdmPackageDetected?.(contract.name, contract.cdmPackage); + } else if (contract.displayName && contract.displayName !== contract.name) { + this.opts.onCdmPackageDetected?.(contract.name, contract.displayName); + } + } + for (const crate of this.crates) { + if (!this.statuses.has(crate)) { + this.statuses.set(crate, { crateName: crate, state: "waiting" }); + } + } + return; + case "build-start": + this.update(event.crate, "building"); + return; + case "build-progress": + this.update(event.crate, "building", { + buildProgress: { + compiled: event.compiled, + total: event.total, + }, + }); + return; + case "build-done": + this.update(event.crate, "built", { + durationMs: event.durationMs, + bytecodeSize: event.bytecodeSize, + buildProgress: { compiled: 1, total: 1 }, + }); + return; + case "build-error": + this.update(event.crate, "error", { error: event.error }); + return; + case "pipeline-done": + return; + case "pipeline-error": + this.phase = { + name: "done", + description: "Pipeline failed", + }; + return; + } + } + + private update(crateName: string, state: ContractState, extra?: Partial) { + const current = this.statuses.get(crateName) ?? { crateName, state: "waiting" }; + this.statuses.set(crateName, { ...current, state, ...extra }); + } + + private appendLog(rawLine: string) { + const line = cleanLogLine(rawLine); + if (!line) return; + this.logLines.push(line); + if (this.logLines.length > ContractPipelineStatusAdapter.LOG_TAIL_LINES) { + this.logLines.splice( + 0, + this.logLines.length - ContractPipelineStatusAdapter.LOG_TAIL_LINES, + ); + } + } + + private clearLogs() { + this.logLines.splice(0); + } +} + +const ANSI_PATTERN = + // biome-ignore lint/suspicious/noControlCharactersInRegex: terminal log sanitization. + /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g; + +function cleanLogLine(line: string): string { + return line.replace(ANSI_PATTERN, "").replace(/\r/g, "").trimEnd(); +} diff --git a/src/commands/deploy/DeployScreen.test.ts b/src/commands/deploy/DeployScreen.test.ts index 16cbf6e..624c3a4 100644 --- a/src/commands/deploy/DeployScreen.test.ts +++ b/src/commands/deploy/DeployScreen.test.ts @@ -25,7 +25,6 @@ describe("pickNextStage", () => { "dist", "tw33d3r.dot", true, - false, true, "git@github.com:charlesHetterich/tw33d3r", ), @@ -33,8 +32,8 @@ describe("pickNextStage", () => { }); it("enters moddable preflight when moddable is true and no repository URL is resolved yet", () => { - expect( - pickNextStage(false, "phone", "dist", "tw33d3r.dot", true, false, true, null), - ).toEqual({ kind: "moddable-preflight" }); + expect(pickNextStage(false, "phone", "dist", "tw33d3r.dot", true, true, null)).toEqual({ + kind: "moddable-preflight", + }); }); }); diff --git a/src/commands/deploy/DeployScreen.tsx b/src/commands/deploy/DeployScreen.tsx index 684529f..df68bad 100644 --- a/src/commands/deploy/DeployScreen.tsx +++ b/src/commands/deploy/DeployScreen.tsx @@ -21,11 +21,10 @@ import { Section, Hint, Callout, + PhoneApprovalCallout, Sparkline, Select, Input, - Mark, - LAYOUT, setWindowTitle, type MarkKind, } from "../../utils/ui/theme/index.js"; @@ -47,15 +46,10 @@ import { buildSummaryView } from "./summary.js"; import { initialRunningState, runningReducer, - type ContractsSectionState, type FrontendSectionState, type StepStatus, } from "./runningState.js"; -import { readSessionAccount, SESSION_MIN_BALANCE } from "../../utils/deploy/session-account.js"; -import { checkBalance } from "../../utils/account/funding.js"; -import { getConnection } from "../../utils/connection.js"; import type { ResolvedSigner } from "../../utils/signer.js"; -import type { ContractsType } from "../../utils/build/detect.js"; import { DEFAULT_BUILD_DIR, getNetworkLabel } from "../../config.js"; import { VERSION_LABEL } from "../../utils/version.js"; import { ensureGitInstalled, resolveRepositoryUrl } from "../../utils/deploy/moddable.js"; @@ -69,10 +63,6 @@ export interface DeployScreenInputs { /** Publish to the playground with private visibility. Not interactively prompted — set via `--private`. */ playgroundPrivate: boolean; skipBuild: boolean | null; - /** Contract-project kind at `projectDir`, or null if none detected. */ - contractsType: ContractsType | null; - /** Whether to deploy the project's contracts. null = ask the user. */ - deployContracts: boolean | null; /** Pre-set moddable from `--moddable` / `--no-moddable`. null = ask. */ moddable: boolean | null; userSigner: ResolvedSigner | null; @@ -89,7 +79,6 @@ export type Stage = | { kind: "prompt-moddable" } | { kind: "moddable-preflight" } | { kind: "moddable-error"; message: string } - | { kind: "prompt-contracts" } | { kind: "confirm" } | { kind: "running" } | { kind: "done"; outcome: DeployOutcome } @@ -101,7 +90,6 @@ interface Resolved { domain: string; publishToPlayground: boolean; skipBuild: boolean; - deployContracts: boolean; moddable: boolean; repositoryUrl: string | null; } @@ -114,8 +102,6 @@ export function DeployScreen({ publishToPlayground: initialPublish, playgroundPrivate, skipBuild: initialSkipBuild, - contractsType, - deployContracts: initialDeployContracts, moddable: initialModdable, userSigner, onDone, @@ -125,10 +111,6 @@ export function DeployScreen({ const [domain, setDomain] = useState(initialDomain); const [publishToPlayground, setPublishToPlayground] = useState(initialPublish); const [skipBuild, setSkipBuild] = useState(initialSkipBuild); - // null → ask; false short-circuits the prompt when no contracts exist. - const [deployContracts, setDeployContracts] = useState( - contractsType === null ? false : initialDeployContracts, - ); const [moddable, setModdable] = useState(initialModdable); const [repositoryUrl, setRepositoryUrl] = useState(null); const [domainError, setDomainError] = useState(null); @@ -143,7 +125,6 @@ export function DeployScreen({ initialBuildDir, initialDomain, initialPublish, - contractsType === null ? false : initialDeployContracts, initialModdable, null, ), @@ -160,7 +141,6 @@ export function DeployScreen({ nextBuildDir: string | null = buildDir, nextDomain: string | null = domain, nextPublish: boolean | null = publishToPlayground, - nextDeployContracts: boolean | null = deployContracts, nextModdable: boolean | null = moddable, nextRepoUrl: string | null = repositoryUrl, ) => { @@ -170,7 +150,6 @@ export function DeployScreen({ nextBuildDir, nextDomain, nextPublish, - nextDeployContracts, nextModdable, nextRepoUrl, ); @@ -184,7 +163,6 @@ export function DeployScreen({ domain === null || publishToPlayground === null || skipBuild === null || - deployContracts === null || moddable === null ) return null; @@ -194,20 +172,10 @@ export function DeployScreen({ domain, publishToPlayground, skipBuild, - deployContracts, moddable, repositoryUrl, }; - }, [ - mode, - buildDir, - domain, - publishToPlayground, - skipBuild, - deployContracts, - moddable, - repositoryUrl, - ]); + }, [mode, buildDir, domain, publishToPlayground, skipBuild, moddable, repositoryUrl]); // Dynamic terminal tab title: subtitle becomes the domain once we know it. const headerSubtitle = resolved?.domain ?? domain ?? undefined; @@ -322,15 +290,7 @@ export function DeployScreen({ onSelect={(yes) => { setPublishToPlayground(yes); if (!yes) setModdable(false); - advance( - skipBuild, - mode, - buildDir, - domain, - yes, - deployContracts, - yes ? moddable : false, - ); + advance(skipBuild, mode, buildDir, domain, yes, yes ? moddable : false); }} /> )} @@ -352,15 +312,7 @@ export function DeployScreen({ if (yes) { setStage({ kind: "moddable-preflight" }); } else { - advance( - skipBuild, - mode, - buildDir, - domain, - publishToPlayground, - deployContracts, - false, - ); + advance(skipBuild, mode, buildDir, domain, publishToPlayground, false); } }} /> @@ -371,16 +323,7 @@ export function DeployScreen({ projectDir={projectDir} onResolved={(url) => { setRepositoryUrl(url); - advance( - skipBuild, - mode, - buildDir, - domain, - publishToPlayground, - deployContracts, - true, - url, - ); + advance(skipBuild, mode, buildDir, domain, publishToPlayground, true, url); }} onError={(msg) => { setStage({ kind: "moddable-error", message: msg }); @@ -392,30 +335,10 @@ export function DeployScreen({ onDone(null)} /> )} - {stage.kind === "prompt-contracts" && contractsType !== null && ( - - label={`deploy ${contractsType} contracts?`} - options={[ - { value: false, label: "no", hint: "skip the contracts phase" }, - { - value: true, - label: "yes", - hint: `compile & deploy via ${contractsType}`, - }, - ]} - initialIndex={0} - onSelect={(yes) => { - setDeployContracts(yes); - advance(skipBuild, mode, buildDir, domain, publishToPlayground, yes); - }} - /> - )} - {stage.kind === "confirm" && resolved && ( setStage({ kind: "running" })} @@ -470,20 +393,10 @@ function pickInitialStage( buildDir: string | null, domain: string | null, publish: boolean | null, - deployContracts: boolean | null, moddable: boolean | null, repositoryUrl: string | null, ): Stage { - return pickNextStage( - skipBuild, - mode, - buildDir, - domain, - publish, - deployContracts, - moddable, - repositoryUrl, - ); + return pickNextStage(skipBuild, mode, buildDir, domain, publish, moddable, repositoryUrl); } export function pickNextStage( @@ -492,7 +405,6 @@ export function pickNextStage( buildDir: string | null, domain: string | null, publish: boolean | null, - deployContracts: boolean | null, moddable: boolean | null, repositoryUrl: string | null, ): Stage { @@ -506,7 +418,6 @@ export function pickNextStage( if (publish && moddable === true && repositoryUrl === null) { return { kind: "moddable-preflight" }; } - if (deployContracts === null) return { kind: "prompt-contracts" }; return { kind: "confirm" }; } @@ -650,7 +561,6 @@ function ValidateDomainStage({ function ConfirmStage({ projectDir, inputs, - contractsType, userSigner, plan, onProceed, @@ -658,40 +568,11 @@ function ConfirmStage({ }: { projectDir: string; inputs: Resolved; - contractsType: ContractsType | null; userSigner: ResolvedSigner | null; plan: DeployPlan | null; onProceed: () => void; onCancel: () => void; }) { - // Start pessimistic so the approvals list populates immediately; a - // balance query refines it. Over-estimating one tap is better than - // under-counting. - const needsSessionFunding = inputs.deployContracts && userSigner?.source === "session"; - const [contractsFundingNeeded, setContractsFundingNeeded] = - useState(needsSessionFunding); - - useEffect(() => { - if (!needsSessionFunding) return; - let cancelled = false; - (async () => { - try { - const session = await readSessionAccount(); - if (session === null) return; - const client = await getConnection(); - const { sufficient } = await checkBalance( - client, - session.account.ss58Address, - SESSION_MIN_BALANCE, - ); - if (!cancelled) setContractsFundingNeeded(!sufficient); - } catch {} - })(); - return () => { - cancelled = true; - }; - }, [needsSessionFunding]); - const setup = useMemo(() => { try { return resolveSignerSetup({ @@ -699,7 +580,6 @@ function ConfirmStage({ userSigner, publishToPlayground: inputs.publishToPlayground, plan: plan ?? undefined, - contractsFundingNeeded, }); } catch (err) { return { @@ -707,7 +587,7 @@ function ConfirmStage({ error: err instanceof Error ? err.message : String(err), }; } - }, [inputs, userSigner, plan, contractsFundingNeeded]); + }, [inputs, userSigner, plan]); // Only warn on the oversized branch — silent when README is absent or // within the cap, per the product decision to inline tacitly and speak @@ -727,9 +607,6 @@ function ConfirmStage({ moddable: inputs.moddable, repositoryUrl: inputs.repositoryUrl, approvals: "approvals" in setup ? setup.approvals : [], - contracts: contractsType - ? { type: contractsType, deploy: inputs.deployContracts } - : undefined, // Phone mode always signs as the user's session account. Dev-with-SURI // signs as the SURI-derived address. Pure dev mode falls back to // bulletin-deploy's built-in DEFAULT_MNEMONIC, which we can't show @@ -833,12 +710,10 @@ function RunningStage({ }) { const [runningState, setRunningState] = useState(() => initialRunningState({ - deployContracts: inputs.deployContracts, skipBuild: inputs.skipBuild, publishToPlayground: inputs.publishToPlayground, }), ); - const contractsState = runningState.contracts; const frontendState = runningState.frontend; const playgroundState = runningState.playground; const [signingPrompt, setSigningPrompt] = useState(null); @@ -852,27 +727,8 @@ function RunningStage({ // "Throttle TUI info updates" for the incident that made this mandatory. const INFO_THROTTLE_MS = 100; const INFO_MAX_LEN = 160; - const contractsPendingRef = useRef(null); - const contractsTimerRef = useRef(null); const frontendPendingRef = useRef(null); const frontendTimerRef = useRef(null); - const queueContractsLog = (line: string) => { - const truncated = line.length > INFO_MAX_LEN ? `${line.slice(0, INFO_MAX_LEN - 1)}…` : line; - contractsPendingRef.current = truncated; - if (contractsTimerRef.current === null) { - contractsTimerRef.current = setTimeout(() => { - if (contractsPendingRef.current !== null) { - const v = contractsPendingRef.current; - contractsPendingRef.current = null; - setRunningState((s) => ({ - ...s, - contracts: { ...s.contracts, latestLog: v }, - })); - } - contractsTimerRef.current = null; - }, INFO_THROTTLE_MS); - } - }; const queueFrontendLog = (line: string) => { const truncated = line.length > INFO_MAX_LEN ? `${line.slice(0, INFO_MAX_LEN - 1)}…` : line; frontendPendingRef.current = truncated; @@ -910,9 +766,6 @@ function RunningStage({ playgroundPrivate, moddable: inputs.moddable, repositoryUrl: inputs.repositoryUrl, - deployContracts: inputs.deployContracts, - contractsFundingNeeded: - inputs.deployContracts && userSigner?.source === "session", userSigner, plan: plan ?? undefined, onEvent: (event) => handleEvent(event), @@ -931,8 +784,6 @@ function RunningStage({ if (event.kind === "phase-start") { if (event.phase === "build") { setWindowTitle(`dot deploy · ${inputs.domain} · building`); - } else if (event.phase === "contracts") { - setWindowTitle(`dot deploy · ${inputs.domain} · contracts`); } else if (event.phase === "storage-and-dotns") { setWindowTitle(`dot deploy · ${inputs.domain} · uploading`); } else if (event.phase === "playground") { @@ -942,13 +793,6 @@ function RunningStage({ queueFrontendLog(event.line); } else if (event.kind === "build-detected") { queueFrontendLog(`> ${event.config.description}`); - } else if (event.kind === "contracts-event") { - const e = event.event; - if (e.kind === "info") queueContractsLog(e.message); - else if (e.kind === "compile-log") queueContractsLog(e.line); - else if (e.kind === "deploy-chunk") { - queueContractsLog(`deploying chunk ${e.chunk}/${e.total}`); - } } else if (event.kind === "storage-event") { if (event.event.kind === "chunk-progress") { const now = performance.now(); @@ -968,17 +812,13 @@ function RunningStage({ setSigningPrompt(null); } else if (event.event.kind === "sign-error") { setSigningPrompt(null); - queueFrontendLog(`signing rejected: ${event.event.message}`); + queueFrontendLog(`signing failed: ${event.event.message}`); } } } return () => { cancelled = true; - if (contractsTimerRef.current !== null) { - clearTimeout(contractsTimerRef.current); - contractsTimerRef.current = null; - } if (frontendTimerRef.current !== null) { clearTimeout(frontendTimerRef.current); frontendTimerRef.current = null; @@ -987,10 +827,8 @@ function RunningStage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const contractsVisible = contractsState.buildStatus !== "skipped"; return ( - {contractsVisible && } {playgroundState.status !== "skipped" && ( @@ -1003,60 +841,16 @@ function RunningStage({ )} {signingPrompt && signingPrompt.kind === "sign-request" && ( - - - approve step {signingPrompt.step} of {signingPrompt.total}:{" "} - {signingPrompt.label} - - + )} ); } -function ContractsSectionView({ state }: { state: ContractsSectionState }) { - const running = - state.buildStatus === "running" || - state.deployStatus === "running" || - state.contracts.some((c) => c.status === "running"); - return ( -
- - - {state.contracts.length > 0 && ( - - {state.contracts.map((c) => ( - - - - - - {c.name} - - {c.address && ( - - - {c.address} - - - )} - - ))} - - )} - {running && state.latestLog && {truncate(state.latestLog, 120)}} -
- ); -} - function FrontendSectionView({ state }: { state: FrontendSectionState }) { const running = state.buildStatus === "running" || state.uploadStatus === "running"; return ( diff --git a/src/commands/deploy/index.test.ts b/src/commands/deploy/index.test.ts index 57d494f..66226e2 100644 --- a/src/commands/deploy/index.test.ts +++ b/src/commands/deploy/index.test.ts @@ -13,32 +13,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { describe, it, expect } from "vitest"; -const mockReadSessionAccount = vi.fn(); -const mockCheckBalance = vi.fn(); -const mockGetConnection = vi.fn(); - -vi.mock("../../utils/deploy/session-account.js", () => ({ - readSessionAccount: (...args: unknown[]) => mockReadSessionAccount(...args), - SESSION_MIN_BALANCE: 5_000_000_000n, - getOrCreateSessionAccount: vi.fn(), -})); - -vi.mock("../../utils/account/funding.js", () => ({ - checkBalance: (...args: unknown[]) => mockCheckBalance(...args), -})); - -vi.mock("../../utils/connection.js", () => ({ - getConnection: (...args: unknown[]) => mockGetConnection(...args), - destroyConnection: vi.fn(), -})); - -const { safeDetectContractsType, computeContractsFundingNeeded, shouldResolveUserSigner } = - await import("./index.js"); +import { shouldResolveUserSigner } from "./index.js"; describe("shouldResolveUserSigner", () => { it("skips signer lookup for pure dev deploys", () => { @@ -57,157 +34,3 @@ describe("shouldResolveUserSigner", () => { expect(shouldResolveUserSigner({ mode: "dev", suri: "//Alice" })).toBe(true); }); }); - -describe("safeDetectContractsType", () => { - let tmp: string; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "pg-deploy-detect-")); - }); - - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - }); - - it("returns null for an empty project directory", () => { - expect(safeDetectContractsType(tmp)).toBeNull(); - }); - - it("returns null when the project directory does not exist", () => { - // `loadDetectInput` throws on a missing dir — the `safe-` prefix - // exists precisely so we can swallow that and move on. - const missing = join(tmp, "does-not-exist"); - expect(safeDetectContractsType(missing)).toBeNull(); - }); - - it("detects foundry via foundry.toml", () => { - writeFileSync(join(tmp, "foundry.toml"), "[profile.default]\n"); - expect(safeDetectContractsType(tmp)).toBe("foundry"); - }); - - it("detects hardhat via hardhat.config.ts", () => { - writeFileSync(join(tmp, "hardhat.config.ts"), "export default {};\n"); - expect(safeDetectContractsType(tmp)).toBe("hardhat"); - }); - - it("detects cdm via pvm_contract in Cargo.toml", () => { - writeFileSync( - join(tmp, "Cargo.toml"), - `[package]\nname = "demo"\nversion = "0.1.0"\n\n[dependencies]\npvm_contract = "0.1"\n`, - ); - expect(safeDetectContractsType(tmp)).toBe("cdm"); - }); - - it("returns null for a Cargo.toml without a pvm_contract dep", () => { - writeFileSync( - join(tmp, "Cargo.toml"), - `[package]\nname = "demo"\nversion = "0.1.0"\n\n[dependencies]\nserde = "1.0"\n`, - ); - expect(safeDetectContractsType(tmp)).toBeNull(); - }); -}); - -// Minimal shapes — we only exercise the branches that `computeContractsFundingNeeded` -// inspects (`source`). Everything else is load-bearing only inside the real deploy. -const devSigner: any = { source: "dev", address: "5Dev", signer: {}, destroy: () => {} }; -const sessionSigner: any = { - source: "session", - address: "5Ses", - signer: {}, - destroy: () => {}, -}; - -describe("computeContractsFundingNeeded", () => { - beforeEach(() => { - mockReadSessionAccount.mockReset(); - mockCheckBalance.mockReset(); - mockGetConnection.mockReset(); - // Default: any code path that reaches the chain gets a dummy client. - mockGetConnection.mockResolvedValue({ __dummy: true }); - }); - - it("returns false when deployContracts is false without touching chain or disk", async () => { - const result = await computeContractsFundingNeeded({ - deployContracts: false, - userSigner: sessionSigner, - }); - expect(result).toBe(false); - expect(mockReadSessionAccount).not.toHaveBeenCalled(); - expect(mockCheckBalance).not.toHaveBeenCalled(); - expect(mockGetConnection).not.toHaveBeenCalled(); - }); - - it("returns false when userSigner is null without touching chain or disk", async () => { - const result = await computeContractsFundingNeeded({ - deployContracts: true, - userSigner: null, - }); - expect(result).toBe(false); - expect(mockReadSessionAccount).not.toHaveBeenCalled(); - expect(mockCheckBalance).not.toHaveBeenCalled(); - expect(mockGetConnection).not.toHaveBeenCalled(); - }); - - it("returns false for a dev signer without touching chain or disk", async () => { - const result = await computeContractsFundingNeeded({ - deployContracts: true, - userSigner: devSigner, - }); - expect(result).toBe(false); - expect(mockReadSessionAccount).not.toHaveBeenCalled(); - expect(mockCheckBalance).not.toHaveBeenCalled(); - expect(mockGetConnection).not.toHaveBeenCalled(); - }); - - it("returns true for a session signer when no key is persisted yet", async () => { - mockReadSessionAccount.mockResolvedValue(null); - const result = await computeContractsFundingNeeded({ - deployContracts: true, - userSigner: sessionSigner, - }); - expect(result).toBe(true); - expect(mockReadSessionAccount).toHaveBeenCalledTimes(1); - expect(mockCheckBalance).not.toHaveBeenCalled(); - expect(mockGetConnection).not.toHaveBeenCalled(); - }); - - it("returns false when the session key has sufficient balance", async () => { - mockReadSessionAccount.mockResolvedValue({ - account: { ss58Address: "5Ses" }, - }); - mockCheckBalance.mockResolvedValue({ sufficient: true }); - - const result = await computeContractsFundingNeeded({ - deployContracts: true, - userSigner: sessionSigner, - }); - expect(result).toBe(false); - expect(mockCheckBalance).toHaveBeenCalledWith({ __dummy: true }, "5Ses", 5_000_000_000n); - }); - - it("returns true when the session key balance is insufficient", async () => { - mockReadSessionAccount.mockResolvedValue({ - account: { ss58Address: "5Ses" }, - }); - mockCheckBalance.mockResolvedValue({ sufficient: false }); - - const result = await computeContractsFundingNeeded({ - deployContracts: true, - userSigner: sessionSigner, - }); - expect(result).toBe(true); - }); - - it("returns true (pessimistic fallback) when the balance query throws", async () => { - mockReadSessionAccount.mockResolvedValue({ - account: { ss58Address: "5Ses" }, - }); - mockCheckBalance.mockRejectedValue(new Error("RPC went away")); - - const result = await computeContractsFundingNeeded({ - deployContracts: true, - userSigner: sessionSigner, - }); - expect(result).toBe(true); - }); -}); diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 44257fd..cd84475 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -36,10 +36,6 @@ import { } from "../../utils/deploy/availability.js"; import type { DeployOutcome, DeployEvent } from "../../utils/deploy/run.js"; import { buildSummaryView } from "./summary.js"; -import { detectContractsType, type ContractsType } from "../../utils/build/detect.js"; -import { loadDetectInput } from "../../utils/build/runner.js"; -import { readSessionAccount, SESSION_MIN_BALANCE } from "../../utils/deploy/session-account.js"; -import { checkBalance } from "../../utils/account/funding.js"; import { DEFAULT_BUILD_DIR, type Env, resolveLegacyEnv } from "../../config.js"; import { ensureGitInstalled, resolveRepositoryUrl } from "../../utils/deploy/moddable.js"; @@ -57,15 +53,6 @@ interface DeployOpts { * a `--no-foo` option is declared. */ build?: boolean; - /** - * Commander's auto-negated boolean: defaults to `true`; `--no-contract-build` flips it to `false`. - * When false, the contract compile step (forge/hardhat/cargo-contract) is skipped and - * pre-existing artifacts on disk are used instead. CI-friendly for environments without - * the contract toolchains installed. - */ - contractBuild?: boolean; - /** Deploy the project's contracts alongside the frontend. Defaults to false. */ - contracts?: boolean; /** Publish the source repo so others can `dot mod` it. Commander auto-negates: `--no-moddable` ⇒ false. */ moddable?: boolean; env?: Env; @@ -84,14 +71,6 @@ export const deployCommand = new Command("deploy") `Directory containing build artifacts (default: ${DEFAULT_BUILD_DIR})`, ) .option("--no-build", "Skip the build step and deploy existing artifacts in buildDir") - .option( - "--contracts", - "Also deploy any contracts detected in the project (foundry/hardhat/cdm)", - ) - .option( - "--no-contract-build", - "Skip the contract compile step (forge/hardhat/cargo-contract) and deploy existing pre-built artifacts. Requires --contracts. Useful for CI environments without the contract toolchains installed.", - ) .option("--playground", "Publish to the playground registry") .option( "--private", @@ -178,12 +157,6 @@ export const deployCommand = new Command("deploy") try { const nonInteractive = isFullySpecified(opts); - if (opts.contractBuild === false && opts.contracts && !nonInteractive) { - throw new Error( - "--no-contract-build requires headless mode (combine with --signer, --domain, --buildDir, --playground).", - ); - } - if (nonInteractive) { await runHeadless({ projectDir, env, userSigner, opts }); } else { @@ -294,14 +267,6 @@ async function runHeadless(ctx: { const domain = ctx.opts.domain as string; const buildDir = ctx.opts.buildDir as string; const skipBuild = ctx.opts.build === false; - const deployContracts = Boolean(ctx.opts.contracts); - const skipContractBuild = ctx.opts.contractBuild === false; - const contractsType = safeDetectContractsType(ctx.projectDir); - if (deployContracts && contractsType === null) { - throw new Error( - "--contracts was passed but no foundry/hardhat/cdm project was detected at the root.", - ); - } // Check availability BEFORE we build + upload, so CI fails fast on a // Reserved / already-taken name without wasting a chunk upload. @@ -355,23 +320,11 @@ async function runHeadless(ctx: { ); } - const contractsFundingNeeded = await withSpan( - "cli.deploy.contracts-funding-check", - "check contracts session funding", - { "cli.deploy.contracts": deployContracts ? "true" : "false" }, - () => - computeContractsFundingNeeded({ - deployContracts, - userSigner: ctx.userSigner, - }), - ); - const setup = resolveSignerSetup({ mode, userSigner: ctx.userSigner, publishToPlayground, plan: availability.plan, - contractsFundingNeeded, }); const view = buildSummaryView({ mode, @@ -399,7 +352,6 @@ async function runHeadless(ctx: { "cli.deploy.mode": mode, "cli.deploy.playground": publishToPlayground ? "true" : "false", "cli.deploy.moddable": moddable ? "true" : "false", - "cli.deploy.contracts": deployContracts ? "true" : "false", }, async () => { const { runDeploy } = await import("../../utils/deploy/run.js"); @@ -413,9 +365,6 @@ async function runHeadless(ctx: { playgroundPrivate: Boolean(ctx.opts.private), moddable, repositoryUrl, - deployContracts, - skipContractBuild, - contractsFundingNeeded, userSigner: ctx.userSigner, plan: availability.plan, env: ctx.env, @@ -427,44 +376,12 @@ async function runHeadless(ctx: { printFinalResult(outcome); } -/** Best-effort contract-project detection; null on any I/O error. */ -export function safeDetectContractsType(projectDir: string): ContractsType | null { - try { - return detectContractsType(loadDetectInput(projectDir)); - } catch { - return null; - } -} - -/** Whether the contracts phase will need a phone tap to top up the session key. */ -export async function computeContractsFundingNeeded(args: { - deployContracts: boolean; - userSigner: ResolvedSigner | null; -}): Promise { - if (!args.deployContracts) return false; - if (args.userSigner?.source !== "session") return false; - try { - const session = await readSessionAccount(); - if (session === null) return true; - const client = await getConnection(); - const { sufficient } = await checkBalance( - client, - session.account.ss58Address, - SESSION_MIN_BALANCE, - ); - return !sufficient; - } catch { - return true; - } -} - function runInteractive(ctx: { projectDir: string; env: Env; userSigner: ResolvedSigner | null; opts: DeployOpts; }): Promise { - const contractsType = safeDetectContractsType(ctx.projectDir); return new Promise((resolvePromise, rejectPromise) => { let settled = false; let app: ReturnType | null = null; @@ -482,9 +399,6 @@ function runInteractive(ctx: { // Only pre-fill when the user explicitly asked to skip via `--no-build`; // otherwise show the prompt so they can hit Enter on the default "yes". skipBuild: ctx.opts.build === false ? true : null, - contractsType, - deployContracts: - ctx.opts.contracts !== undefined ? ctx.opts.contracts : null, moddable: ctx.opts.moddable === true ? true diff --git a/src/commands/deploy/runningState.test.ts b/src/commands/deploy/runningState.test.ts index 973f7a5..f661ebd 100644 --- a/src/commands/deploy/runningState.test.ts +++ b/src/commands/deploy/runningState.test.ts @@ -18,14 +18,12 @@ import { initialRunningState, runningReducer, type RunningState } from "./runnin import type { DeployEvent } from "../../utils/deploy/index.js"; const FULL_INPUTS = { - deployContracts: true, skipBuild: false, publishToPlayground: true, }; function baseState( overrides: Partial<{ - deployContracts: boolean; skipBuild: boolean; publishToPlayground: boolean; }> = {}, @@ -34,29 +32,19 @@ function baseState( } describe("initialRunningState", () => { - it("deployContracts: false → contracts buildStatus & deployStatus skipped, rows empty", () => { - const s = baseState({ deployContracts: false }); - expect(s.contracts.buildStatus).toBe("skipped"); - expect(s.contracts.deployStatus).toBe("skipped"); - expect(s.contracts.contracts).toEqual([]); - expect(s.contracts.latestLog).toBeNull(); - }); - - it("skipBuild: true → frontend.buildStatus is skipped, upload stays pending", () => { + it("skipBuild: true -> frontend.buildStatus is skipped, upload stays pending", () => { const s = baseState({ skipBuild: true }); expect(s.frontend.buildStatus).toBe("skipped"); expect(s.frontend.uploadStatus).toBe("pending"); }); - it("publishToPlayground: false → playground status skipped", () => { + it("publishToPlayground: false -> playground status skipped", () => { const s = baseState({ publishToPlayground: false }); expect(s.playground.status).toBe("skipped"); }); - it("fully-enabled inputs → every slot starts pending", () => { + it("fully-enabled inputs -> deploy slots start pending", () => { const s = baseState(); - expect(s.contracts.buildStatus).toBe("pending"); - expect(s.contracts.deployStatus).toBe("pending"); expect(s.frontend.buildStatus).toBe("pending"); expect(s.frontend.uploadStatus).toBe("pending"); expect(s.playground.status).toBe("pending"); @@ -64,17 +52,12 @@ describe("initialRunningState", () => { }); describe("runningReducer — phase-start", () => { - it("build → frontend.buildStatus running", () => { + it("build -> frontend.buildStatus running", () => { const s = runningReducer(baseState(), { kind: "phase-start", phase: "build" }); expect(s.frontend.buildStatus).toBe("running"); }); - it("contracts → contracts.buildStatus running", () => { - const s = runningReducer(baseState(), { kind: "phase-start", phase: "contracts" }); - expect(s.contracts.buildStatus).toBe("running"); - }); - - it("storage-and-dotns → frontend.uploadStatus running", () => { + it("storage-and-dotns -> frontend.uploadStatus running", () => { const s = runningReducer(baseState(), { kind: "phase-start", phase: "storage-and-dotns", @@ -82,34 +65,19 @@ describe("runningReducer — phase-start", () => { expect(s.frontend.uploadStatus).toBe("running"); }); - it("playground → playground.status running", () => { + it("playground -> playground.status running", () => { const s = runningReducer(baseState(), { kind: "phase-start", phase: "playground" }); expect(s.playground.status).toBe("running"); }); }); describe("runningReducer — phase-complete", () => { - it("build → frontend.buildStatus complete", () => { + it("build -> frontend.buildStatus complete", () => { const s = runningReducer(baseState(), { kind: "phase-complete", phase: "build" }); expect(s.frontend.buildStatus).toBe("complete"); }); - it("contracts → both sub-statuses complete", () => { - const s = runningReducer(baseState(), { kind: "phase-complete", phase: "contracts" }); - expect(s.contracts.buildStatus).toBe("complete"); - expect(s.contracts.deployStatus).toBe("complete"); - }); - - it("contracts → buildStatus stays skipped when it was skipped", () => { - const s = runningReducer(baseState({ deployContracts: false }), { - kind: "phase-complete", - phase: "contracts", - }); - expect(s.contracts.buildStatus).toBe("skipped"); - expect(s.contracts.deployStatus).toBe("complete"); - }); - - it("storage-and-dotns → uploadStatus complete", () => { + it("storage-and-dotns -> uploadStatus complete", () => { const s = runningReducer(baseState(), { kind: "phase-complete", phase: "storage-and-dotns", @@ -117,24 +85,14 @@ describe("runningReducer — phase-complete", () => { expect(s.frontend.uploadStatus).toBe("complete"); }); - it("playground → playground.status complete", () => { + it("playground -> playground.status complete", () => { const s = runningReducer(baseState(), { kind: "phase-complete", phase: "playground" }); expect(s.playground.status).toBe("complete"); }); }); describe("runningReducer — phase-skipped", () => { - it("contracts → both sub-statuses skipped", () => { - const s = runningReducer(baseState(), { - kind: "phase-skipped", - phase: "contracts", - reason: "no contracts", - }); - expect(s.contracts.buildStatus).toBe("skipped"); - expect(s.contracts.deployStatus).toBe("skipped"); - }); - - it("build → frontend.buildStatus skipped", () => { + it("build -> frontend.buildStatus skipped", () => { const s = runningReducer(baseState(), { kind: "phase-skipped", phase: "build", @@ -143,7 +101,7 @@ describe("runningReducer — phase-skipped", () => { expect(s.frontend.buildStatus).toBe("skipped"); }); - it("storage-and-dotns → uploadStatus skipped", () => { + it("storage-and-dotns -> uploadStatus skipped", () => { const s = runningReducer(baseState(), { kind: "phase-skipped", phase: "storage-and-dotns", @@ -152,7 +110,7 @@ describe("runningReducer — phase-skipped", () => { expect(s.frontend.uploadStatus).toBe("skipped"); }); - it("playground → playground.status skipped", () => { + it("playground -> playground.status skipped", () => { const s = runningReducer(baseState(), { kind: "phase-skipped", phase: "playground", @@ -162,109 +120,8 @@ describe("runningReducer — phase-skipped", () => { }); }); -describe("runningReducer — contracts-event", () => { - it("compile-detected → buildStatus complete, deployStatus running, contracts populated as running", () => { - const s = runningReducer(baseState(), { - kind: "contracts-event", - event: { kind: "compile-detected", contracts: ["Flipper", "Counter"] }, - }); - expect(s.contracts.buildStatus).toBe("complete"); - expect(s.contracts.deployStatus).toBe("running"); - expect(s.contracts.contracts).toEqual([ - { name: "Flipper", status: "running" }, - { name: "Counter", status: "running" }, - ]); - }); - - it("deploy-chunk → named contracts complete w/ addresses, others untouched", () => { - const s0 = runningReducer(baseState(), { - kind: "contracts-event", - event: { kind: "compile-detected", contracts: ["Flipper", "Counter", "Storage"] }, - }); - const s = runningReducer(s0, { - kind: "contracts-event", - event: { - kind: "deploy-chunk", - chunk: 1, - total: 2, - contracts: [ - { name: "Flipper", address: "0xaaa" as `0x${string}` }, - { name: "Counter", address: "0xbbb" as `0x${string}` }, - ], - }, - }); - const byName = Object.fromEntries(s.contracts.contracts.map((c) => [c.name, c])); - expect(byName.Flipper).toEqual({ - name: "Flipper", - status: "complete", - address: "0xaaa", - }); - expect(byName.Counter).toEqual({ - name: "Counter", - status: "complete", - address: "0xbbb", - }); - // Storage was not in the chunk — stays running, no address. - expect(byName.Storage).toEqual({ name: "Storage", status: "running" }); - // Overall deploy still running until deploy-done fires. - expect(s.contracts.deployStatus).toBe("running"); - }); - - it("deploy-done → all contracts complete with addresses, deployStatus complete", () => { - const s0 = runningReducer(baseState(), { - kind: "contracts-event", - event: { kind: "compile-detected", contracts: ["Flipper", "Counter"] }, - }); - const s = runningReducer(s0, { - kind: "contracts-event", - event: { - kind: "deploy-done", - addresses: [ - { name: "Flipper", address: "0xaaa" as `0x${string}` }, - { name: "Counter", address: "0xbbb" as `0x${string}` }, - ], - }, - }); - expect(s.contracts.deployStatus).toBe("complete"); - expect(s.contracts.contracts).toEqual([ - { name: "Flipper", status: "complete", address: "0xaaa" }, - { name: "Counter", status: "complete", address: "0xbbb" }, - ]); - }); - - it("deploy-done preserves addresses from earlier deploy-chunk when deploy-done omits a name", () => { - let s = runningReducer(baseState(), { - kind: "contracts-event", - event: { kind: "compile-detected", contracts: ["Flipper", "Counter"] }, - }); - s = runningReducer(s, { - kind: "contracts-event", - event: { - kind: "deploy-chunk", - chunk: 1, - total: 1, - contracts: [{ name: "Flipper", address: "0xaaa" as `0x${string}` }], - }, - }); - // deploy-done with only one name — the other should fall back to the - // address the chunk event already stamped on the row. - s = runningReducer(s, { - kind: "contracts-event", - event: { - kind: "deploy-done", - addresses: [{ name: "Counter", address: "0xbbb" as `0x${string}` }], - }, - }); - const byName = Object.fromEntries(s.contracts.contracts.map((c) => [c.name, c])); - expect(byName.Flipper.address).toBe("0xaaa"); - expect(byName.Counter.address).toBe("0xbbb"); - expect(byName.Flipper.status).toBe("complete"); - expect(byName.Counter.status).toBe("complete"); - }); -}); - describe("runningReducer — error", () => { - it("phase: build → frontend.buildStatus error + error set", () => { + it("phase: build -> frontend.buildStatus error + error set", () => { const s = runningReducer(baseState(), { kind: "error", phase: "build", @@ -274,17 +131,7 @@ describe("runningReducer — error", () => { expect(s.frontend.error).toBe("vite exploded"); }); - it("phase: contracts → contracts.deployStatus error + error set", () => { - const s = runningReducer(baseState(), { - kind: "error", - phase: "contracts", - message: "revert", - }); - expect(s.contracts.deployStatus).toBe("error"); - expect(s.contracts.error).toBe("revert"); - }); - - it("phase: storage-and-dotns → frontend.uploadStatus error + error set", () => { + it("phase: storage-and-dotns -> frontend.uploadStatus error + error set", () => { const s = runningReducer(baseState(), { kind: "error", phase: "storage-and-dotns", @@ -294,7 +141,7 @@ describe("runningReducer — error", () => { expect(s.frontend.error).toBe("ws halt"); }); - it("phase: playground → playground.status error + error set", () => { + it("phase: playground -> playground.status error + error set", () => { const s = runningReducer(baseState(), { kind: "error", phase: "playground", @@ -308,17 +155,17 @@ describe("runningReducer — error", () => { describe("runningReducer — log/signing/plan events are no-ops", () => { const s0 = baseState(); - it("plan → state unchanged (ref equality)", () => { + it("plan -> state unchanged", () => { const s = runningReducer(s0, { kind: "plan", approvals: [] }); expect(s).toBe(s0); }); - it("build-log → state unchanged (ref equality)", () => { + it("build-log -> state unchanged", () => { const s = runningReducer(s0, { kind: "build-log", line: "hello" }); expect(s).toBe(s0); }); - it("signing sign-request → state unchanged", () => { + it("signing sign-request -> state unchanged", () => { const s = runningReducer(s0, { kind: "signing", event: { kind: "sign-request", step: 1, total: 3, label: "DotNS register" }, @@ -326,65 +173,23 @@ describe("runningReducer — log/signing/plan events are no-ops", () => { expect(s).toBe(s0); }); - it("storage-event chunk-progress → state unchanged", () => { + it("storage-event chunk-progress -> state unchanged", () => { const s = runningReducer(s0, { kind: "storage-event", event: { kind: "chunk-progress", current: 3, total: 10 }, }); expect(s).toBe(s0); }); - - it("contracts-event info / compile-log → state unchanged (deep equality)", () => { - const a = runningReducer(s0, { - kind: "contracts-event", - event: { kind: "info", message: "pinging" }, - }); - const b = runningReducer(s0, { - kind: "contracts-event", - event: { kind: "compile-log", line: "cargo build..." }, - }); - expect(a).toBe(s0); - expect(b).toBe(s0); - }); }); describe("runningReducer — full happy-path sequence", () => { - it("realistic deploy with contracts, frontend build + upload, and playground publish", () => { + it("tracks frontend build, upload, and playground publish", () => { let s = initialRunningState({ - deployContracts: true, skipBuild: false, publishToPlayground: true, }); const events: DeployEvent[] = [ - { kind: "phase-start", phase: "contracts" }, - { - kind: "contracts-event", - event: { kind: "compile-detected", contracts: ["Flipper", "Counter"] }, - }, - { - kind: "contracts-event", - event: { - kind: "deploy-chunk", - chunk: 1, - total: 1, - contracts: [ - { name: "Flipper", address: "0xaaa" as `0x${string}` }, - { name: "Counter", address: "0xbbb" as `0x${string}` }, - ], - }, - }, - { - kind: "contracts-event", - event: { - kind: "deploy-done", - addresses: [ - { name: "Flipper", address: "0xaaa" as `0x${string}` }, - { name: "Counter", address: "0xbbb" as `0x${string}` }, - ], - }, - }, - { kind: "phase-complete", phase: "contracts" }, { kind: "phase-start", phase: "build" }, { kind: "phase-complete", phase: "build" }, { kind: "phase-start", phase: "storage-and-dotns" }, @@ -395,14 +200,6 @@ describe("runningReducer — full happy-path sequence", () => { for (const e of events) s = runningReducer(s, e); - expect(s.contracts.buildStatus).toBe("complete"); - expect(s.contracts.deployStatus).toBe("complete"); - expect(s.contracts.contracts).toEqual([ - { name: "Flipper", status: "complete", address: "0xaaa" }, - { name: "Counter", status: "complete", address: "0xbbb" }, - ]); - expect(s.contracts.error).toBeUndefined(); - expect(s.frontend.buildStatus).toBe("complete"); expect(s.frontend.uploadStatus).toBe("complete"); expect(s.frontend.error).toBeUndefined(); diff --git a/src/commands/deploy/runningState.ts b/src/commands/deploy/runningState.ts index 4d7aae7..67f031a 100644 --- a/src/commands/deploy/runningState.ts +++ b/src/commands/deploy/runningState.ts @@ -23,20 +23,6 @@ import type { DeployEvent } from "../../utils/deploy/index.js"; export type StepStatus = "pending" | "running" | "complete" | "error" | "skipped"; -export interface ContractRowState { - name: string; - status: StepStatus; - address?: string; -} - -export interface ContractsSectionState { - buildStatus: StepStatus; - deployStatus: StepStatus; - contracts: ContractRowState[]; - error?: string; - latestLog: string | null; -} - export interface FrontendSectionState { buildStatus: StepStatus; uploadStatus: StepStatus; @@ -50,25 +36,17 @@ export interface PlaygroundRowState { } export interface RunningState { - contracts: ContractsSectionState; frontend: FrontendSectionState; playground: PlaygroundRowState; } export interface RunningStateInputs { - deployContracts: boolean; skipBuild: boolean; publishToPlayground: boolean; } export function initialRunningState(inputs: RunningStateInputs): RunningState { return { - contracts: { - buildStatus: inputs.deployContracts ? "pending" : "skipped", - deployStatus: inputs.deployContracts ? "pending" : "skipped", - contracts: [], - latestLog: null, - }, frontend: { buildStatus: inputs.skipBuild ? "skipped" : "pending", uploadStatus: "pending", @@ -89,12 +67,6 @@ export function runningReducer(state: RunningState, event: DeployEvent): Running frontend: { ...state.frontend, buildStatus: "running" }, }; } - if (event.phase === "contracts") { - return { - ...state, - contracts: { ...state.contracts, buildStatus: "running" }, - }; - } if (event.phase === "storage-and-dotns") { return { ...state, @@ -113,17 +85,6 @@ export function runningReducer(state: RunningState, event: DeployEvent): Running frontend: { ...state.frontend, buildStatus: "complete" }, }; } - if (event.phase === "contracts") { - return { - ...state, - contracts: { - ...state.contracts, - buildStatus: - state.contracts.buildStatus === "skipped" ? "skipped" : "complete", - deployStatus: "complete", - }, - }; - } if (event.phase === "storage-and-dotns") { return { ...state, @@ -136,16 +97,6 @@ export function runningReducer(state: RunningState, event: DeployEvent): Running return state; } case "phase-skipped": { - if (event.phase === "contracts") { - return { - ...state, - contracts: { - ...state.contracts, - buildStatus: "skipped", - deployStatus: "skipped", - }, - }; - } if (event.phase === "build") { return { ...state, @@ -163,53 +114,6 @@ export function runningReducer(state: RunningState, event: DeployEvent): Running } return state; } - case "contracts-event": { - const e = event.event; - if (e.kind === "compile-detected") { - // Mark rows running up-front: cdm can take 10–20s between - // compile-detected and the first deploy-chunk, during which - // idle rows make the UI look frozen. - return { - ...state, - contracts: { - ...state.contracts, - buildStatus: "complete", - deployStatus: "running", - contracts: e.contracts.map((name) => ({ name, status: "running" })), - }, - }; - } - if (e.kind === "deploy-chunk") { - const byName = new Map(e.contracts.map((c) => [c.name, c.address])); - return { - ...state, - contracts: { - ...state.contracts, - contracts: state.contracts.contracts.map((c) => - byName.has(c.name) - ? { ...c, status: "complete", address: byName.get(c.name) } - : c, - ), - }, - }; - } - if (e.kind === "deploy-done") { - const byName = new Map(e.addresses.map((a) => [a.name, a.address])); - return { - ...state, - contracts: { - ...state.contracts, - deployStatus: "complete", - contracts: state.contracts.contracts.map((c) => ({ - ...c, - status: "complete", - address: byName.get(c.name) ?? c.address, - })), - }, - }; - } - return state; - } case "error": { const msg = event.message; if (event.phase === "build") { @@ -218,12 +122,6 @@ export function runningReducer(state: RunningState, event: DeployEvent): Running frontend: { ...state.frontend, buildStatus: "error", error: msg }, }; } - if (event.phase === "contracts") { - return { - ...state, - contracts: { ...state.contracts, deployStatus: "error", error: msg }, - }; - } if (event.phase === "storage-and-dotns") { return { ...state, diff --git a/src/commands/deploy/summary.test.ts b/src/commands/deploy/summary.test.ts index cbac825..f13c556 100644 --- a/src/commands/deploy/summary.test.ts +++ b/src/commands/deploy/summary.test.ts @@ -89,7 +89,7 @@ describe("buildSummaryView", () => { expect(skip.rows.find((r) => r.label === "Build")?.value).toBe("skip (use existing)"); }); - it("omits Contracts row when contracts is undefined", () => { + it("rows stay limited to deploy-owned concerns", () => { const view = buildSummaryView({ mode: "dev", domain: "my-app.dot", @@ -98,65 +98,7 @@ describe("buildSummaryView", () => { publishToPlayground: false, approvals: [], }); - expect(view.rows.find((r) => r.label === "Contracts")).toBeUndefined(); - }); - - it("Contracts row shows 'deploy (foundry)' for foundry + deploy true", () => { - const view = buildSummaryView({ - mode: "dev", - domain: "my-app.dot", - buildDir: "dist", - skipBuild: false, - publishToPlayground: false, - approvals: [], - contracts: { type: "foundry", deploy: true }, - }); - expect(view.rows.find((r) => r.label === "Contracts")?.value).toBe("deploy (foundry)"); - }); - - it("Contracts row shows 'skip' when deploy is false regardless of type", () => { - const view = buildSummaryView({ - mode: "dev", - domain: "my-app.dot", - buildDir: "dist", - skipBuild: false, - publishToPlayground: false, - approvals: [], - contracts: { type: "hardhat", deploy: false }, - }); - expect(view.rows.find((r) => r.label === "Contracts")?.value).toBe("skip"); - }); - - it("Contracts row shows 'deploy (cdm)' for cdm + deploy true", () => { - const view = buildSummaryView({ - mode: "dev", - domain: "my-app.dot", - buildDir: "dist", - skipBuild: false, - publishToPlayground: false, - approvals: [], - contracts: { type: "cdm", deploy: true }, - }); - expect(view.rows.find((r) => r.label === "Contracts")?.value).toBe("deploy (cdm)"); - }); - - it("Contracts row is appended after signer/build/buildDir/publish", () => { - const view = buildSummaryView({ - mode: "dev", - domain: "my-app.dot", - buildDir: "dist", - skipBuild: false, - publishToPlayground: false, - approvals: [], - contracts: { type: "foundry", deploy: true }, - }); - expect(view.rows.map((r) => r.label)).toEqual([ - "Signer", - "Build", - "Build dir", - "Publish", - "Contracts", - ]); + expect(view.rows.map((r) => r.label)).toEqual(["Signer", "Build", "Build dir", "Publish"]); }); }); @@ -175,22 +117,6 @@ describe("renderSummaryText", () => { expect(text).toContain("No phone approvals required."); }); - it("includes Contracts row in rendered text when present", () => { - const text = renderSummaryText( - buildSummaryView({ - mode: "dev", - domain: "my-app.dot", - buildDir: "dist", - skipBuild: false, - publishToPlayground: false, - approvals: [], - contracts: { type: "foundry", deploy: true }, - }), - ); - expect(text).toContain("Contracts"); - expect(text).toContain("deploy (foundry)"); - }); - it("lists numbered approvals when non-empty", () => { const text = renderSummaryText( buildSummaryView({ diff --git a/src/commands/deploy/summary.ts b/src/commands/deploy/summary.ts index 9d0a533..42e9055 100644 --- a/src/commands/deploy/summary.ts +++ b/src/commands/deploy/summary.ts @@ -20,7 +20,6 @@ */ import type { SignerMode, DeployApproval } from "../../utils/deploy/index.js"; -import type { ContractsType } from "../../utils/build/detect.js"; export interface SummaryInputs { mode: SignerMode; @@ -31,8 +30,6 @@ export interface SummaryInputs { moddable?: boolean; repositoryUrl?: string | null; approvals: DeployApproval[]; - /** Contract project kind + user's yes/no. Omit when no contracts were detected. */ - contracts?: { type: ContractsType; deploy: boolean }; /** * SS58 of the account that will sign this deploy. Surfaced in the summary * so the user can verify it matches what `dot init` set up (the product @@ -76,12 +73,6 @@ export function buildSummaryView(input: SummaryInputs): SummaryView { value: input.moddable ? `yes — ${input.repositoryUrl}` : "no", }); } - if (input.contracts) { - rows.push({ - label: "Contracts", - value: input.contracts.deploy ? `deploy (${input.contracts.type})` : "skip", - }); - } return { headline: `Deploying ${input.domain}`, rows, diff --git a/src/commands/init/AccountSetup.tsx b/src/commands/init/AccountSetup.tsx index 12fc398..2a22b33 100644 --- a/src/commands/init/AccountSetup.tsx +++ b/src/commands/init/AccountSetup.tsx @@ -13,9 +13,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Box, Text } from "ink"; +import { Box } from "ink"; import { useState, useEffect } from "react"; -import { Row, Section, Callout, type MarkKind } from "../../utils/ui/theme/index.js"; +import { Row, Section, PhoneApprovalCallout, type MarkKind } from "../../utils/ui/theme/index.js"; import { getConnection } from "../../utils/connection.js"; import { getSessionSigner, type SessionHandle } from "../../utils/auth.js"; import { topUpFromBulletinDev } from "../../utils/account/bulletinTopUp.js"; @@ -328,12 +328,11 @@ export function AccountSetup({ ))} {phonePrompt && ( - - - approve step {phonePrompt.step} of {phonePrompt.total}:{" "} - {phonePrompt.label} - - + )}
); diff --git a/src/index.ts b/src/index.ts index c023a45..012b0e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import pkg from "../package.json" with { type: "json" }; import { initCommand } from "./commands/init/index.js"; import { modCommand } from "./commands/mod/index.js"; import { buildCommand } from "./commands/build.js"; +import { contractCommand } from "./commands/contract.js"; import { logoutCommand } from "./commands/logout/index.js"; import { updateCommand } from "./commands/update.js"; import { captureWarning, closeTelemetry, flushTelemetry, initTelemetry } from "./telemetry.js"; @@ -126,6 +127,7 @@ const program = new Command() program.addCommand(initCommand); program.addCommand(modCommand); program.addCommand(buildCommand); +program.addCommand(contractCommand); program.addCommand(await createDeployCommand()); program.addCommand(logoutCommand); program.addCommand(updateCommand); diff --git a/src/telemetry-config.ts b/src/telemetry-config.ts index 94b08f8..c1c126a 100644 --- a/src/telemetry-config.ts +++ b/src/telemetry-config.ts @@ -46,7 +46,7 @@ export interface InternalContextSignals { branch?: string; } -export type CliCommandName = "init" | "deploy" | "mod" | "build" | "update" | "logout"; +export type CliCommandName = "init" | "deploy" | "contract" | "mod" | "build" | "update" | "logout"; export type TelemetryAttribute = string | number | boolean | undefined; type EnvLike = Record; diff --git a/src/telemetry.test.ts b/src/telemetry.test.ts index 54cb1bb..bca89c6 100644 --- a/src/telemetry.test.ts +++ b/src/telemetry.test.ts @@ -63,7 +63,6 @@ describe("expected CLI errors", () => { isExpectedCliError("foo/bar is private or does not exist — moddable apps must use…"), ).toBe(true); expect(isExpectedCliError('Invalid domain "bad_domain"')).toBe(true); - expect(isExpectedCliError("No foundry/hardhat/cdm project was detected")).toBe(true); expect(isExpectedCliError("BadRegistryLookup: CDM registry unavailable")).toBe(true); }); diff --git a/src/telemetry.ts b/src/telemetry.ts index 4b4eca8..cc48369 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -107,7 +107,7 @@ export function sanitizeSentryTransaction>(eve } export function isExpectedCliError(message: string): boolean { - return /badregistrylookup|signer.*not available|run "dot init"|account is not mapped|storage allowance is exhausted|invalid domain|already owned|reserved|insufficient balance|no github origin configured|must use a public github repository|private or does not exist|no foundry\/hardhat\/cdm project was detected|github api returned|download failed/i.test( + return /badregistrylookup|signer.*not available|run "dot init"|account is not mapped|storage allowance is exhausted|invalid domain|already owned|reserved|insufficient balance|no github origin configured|must use a public github repository|private or does not exist|github api returned|download failed/i.test( message, ); } diff --git a/src/utils/account/mapping.test.ts b/src/utils/account/mapping.test.ts index 6d50ce3..8fa3e8d 100644 --- a/src/utils/account/mapping.test.ts +++ b/src/utils/account/mapping.test.ts @@ -137,11 +137,11 @@ describe("ensureMapped", () => { it("bubbles up signing errors (e.g. user rejected on phone)", async () => { mockCreateInkSdk.mockReturnValue({ addressIsMapped: vi.fn() }); mockEnsureAccountMapped.mockRejectedValue( - new Error("Mobile signing rejected: user rejected"), + new Error("Mobile signing failed: user rejected"), ); await expect( ensureMapped(makeClient({ address: FAKE_H160, original: null }), "5F...", makeSigner()), - ).rejects.toThrow(/Mobile signing rejected/); + ).rejects.toThrow(/Mobile signing failed/); }); }); diff --git a/src/utils/allowances/marker.ts b/src/utils/allowances/marker.ts index 6a96562..d5eb11f 100644 --- a/src/utils/allowances/marker.ts +++ b/src/utils/allowances/marker.ts @@ -53,14 +53,16 @@ interface MarkerFile { envs: Partial>>>>; } -const EMPTY: MarkerFile = { version: 1, envs: {} }; +function emptyFile(): MarkerFile { + return { version: 1, envs: {} }; +} async function loadFile(): Promise { let raw: string; try { raw = await fs.readFile(getMarkerPath(), "utf8"); } catch (err: unknown) { - if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return { ...EMPTY }; + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return emptyFile(); throw err; } try { @@ -73,7 +75,7 @@ async function loadFile(): Promise { // will overwrite. We intentionally don't surface the parse error // because the marker is best-effort UX, not load-bearing state. } - return { ...EMPTY }; + return emptyFile(); } async function saveFile(file: MarkerFile): Promise { diff --git a/src/utils/allowances/smartContracts.test.ts b/src/utils/allowances/smartContracts.test.ts new file mode 100644 index 0000000..d57e29a --- /dev/null +++ b/src/utils/allowances/smartContracts.test.ts @@ -0,0 +1,116 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedSigner } from "../signer.js"; +import { hasAllowance, markAllowance } from "./marker.js"; +import { ensureSmartContractAllowance } from "./smartContracts.js"; + +const ENV = "paseo-next-v2"; +const OWNER = "5Owner"; + +let root: string | null = null; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "playground-cli-smart-contract-allowance-")); + process.env.POLKADOT_ROOT = root; +}); + +afterEach(async () => { + delete process.env.POLKADOT_ROOT; + if (root) await rm(root, { recursive: true, force: true }); + root = null; +}); + +function makeSigner(requestResourceAllocation?: ReturnType): ResolvedSigner { + return { + source: "session", + address: OWNER, + signer: {} as any, + userSession: requestResourceAllocation ? ({ requestResourceAllocation } as any) : undefined, + destroy() {}, + }; +} + +describe("ensureSmartContractAllowance", () => { + it("skips local dev signers", async () => { + const deploySigner: ResolvedSigner = { + source: "dev", + address: OWNER, + signer: {} as any, + destroy() {}, + }; + + await expect( + ensureSmartContractAllowance({ env: ENV, ownerAddress: OWNER, deploySigner }), + ).resolves.toBeUndefined(); + }); + + it("uses an existing allowance marker without prompting mobile", async () => { + const requestResourceAllocation = vi.fn(); + await markAllowance(ENV, OWNER, "SmartContractAllowance"); + + await ensureSmartContractAllowance({ + env: ENV, + ownerAddress: OWNER, + deploySigner: makeSigner(requestResourceAllocation), + }); + + expect(requestResourceAllocation).not.toHaveBeenCalled(); + }); + + it("requests and marks a missing mobile smart-contract allowance", async () => { + const requestResourceAllocation = vi.fn(async () => ({ + isErr: () => false, + value: [ + { + tag: "Allocated", + value: { tag: "SmartContractAllowance", value: undefined }, + }, + ], + })); + + await ensureSmartContractAllowance({ + env: ENV, + ownerAddress: OWNER, + deploySigner: makeSigner(requestResourceAllocation), + }); + + expect(requestResourceAllocation).toHaveBeenCalledWith({ + callingProductId: "playground.dot", + resources: [{ tag: "SmartContractAllowance", value: 0 }], + onExisting: "Ignore", + }); + await expect(hasAllowance(ENV, OWNER, "SmartContractAllowance")).resolves.toBe(true); + }); + + it("throws an actionable error when mobile denies the allowance", async () => { + const requestResourceAllocation = vi.fn(async () => ({ + isErr: () => false, + value: [{ tag: "Rejected", value: undefined }], + })); + + await expect( + ensureSmartContractAllowance({ + env: ENV, + ownerAddress: OWNER, + deploySigner: makeSigner(requestResourceAllocation), + }), + ).rejects.toThrow(/Smart-contract gas allowance allocation Rejected/); + }); +}); diff --git a/src/utils/allowances/smartContracts.ts b/src/utils/allowances/smartContracts.ts new file mode 100644 index 0000000..25c6e7c --- /dev/null +++ b/src/utils/allowances/smartContracts.ts @@ -0,0 +1,63 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PLAYGROUND_PRODUCT_ID, type Env } from "../../config.js"; +import type { ResolvedSigner } from "../signer.js"; +import { requestResourceAllocation, summarizeOutcomes, type AllocatableResource } from "./host.js"; +import { hasAllowance, markAllowance } from "./marker.js"; + +const SMART_CONTRACT_ALLOWANCE: AllocatableResource = { + tag: "SmartContractAllowance", + value: 0, +}; + +export interface SmartContractAllowanceOptions { + env: Env; + ownerAddress: string; + deploySigner: ResolvedSigner; +} + +export async function ensureSmartContractAllowance({ + env, + ownerAddress, + deploySigner, +}: SmartContractAllowanceOptions): Promise { + if (deploySigner.source === "dev") return; + + if (await hasAllowance(env, ownerAddress, "SmartContractAllowance")) return; + + if (!deploySigner.userSession) { + throw new Error( + 'No smart-contract gas allowance cached. Run "dot init" to grant allowances.', + ); + } + + const outcomes = await requestResourceAllocation( + deploySigner.userSession, + PLAYGROUND_PRODUCT_ID, + [SMART_CONTRACT_ALLOWANCE], + ); + const summary = summarizeOutcomes(outcomes, [SMART_CONTRACT_ALLOWANCE]); + + if (summary.granted.some((resource) => resource.tag === "SmartContractAllowance")) { + await markAllowance(env, ownerAddress, "SmartContractAllowance", "host"); + return; + } + + const outcome = outcomes[0]?.tag ?? "returned no outcome"; + throw new Error( + `Smart-contract gas allowance allocation ${outcome}. Re-run \`dot init\` and approve on your phone.`, + ); +} diff --git a/src/utils/build/detect.test.ts b/src/utils/build/detect.test.ts index 5e08969..3ebfb46 100644 --- a/src/utils/build/detect.test.ts +++ b/src/utils/build/detect.test.ts @@ -16,7 +16,6 @@ import { describe, it, expect } from "vitest"; import { detectBuildConfig, - detectContractsType, detectInstallConfig, detectPackageManager, BuildDetectError, @@ -28,7 +27,6 @@ function input(overrides: Partial = {}): DetectInput { packageJson: null, lockfiles: new Set(), configFiles: new Set(), - cargoToml: null, ...overrides, }; } @@ -216,52 +214,3 @@ describe("detectInstallConfig", () => { ).toEqual({ cmd: "yarn", args: ["install"], description: "yarn install" }); }); }); - -describe("detectContractsType", () => { - it("returns null for an empty project", () => { - expect(detectContractsType(input())).toBeNull(); - }); - - it("detects foundry via foundry.toml", () => { - expect(detectContractsType(input({ configFiles: new Set(["foundry.toml"]) }))).toBe( - "foundry", - ); - }); - - it("detects hardhat via any hardhat.config.* variant", () => { - for (const name of [ - "hardhat.config.ts", - "hardhat.config.js", - "hardhat.config.cjs", - "hardhat.config.mjs", - ]) { - expect(detectContractsType(input({ configFiles: new Set([name]) }))).toBe("hardhat"); - } - }); - - it("detects cdm via pvm_contract in Cargo.toml", () => { - const cargoToml = `[dependencies]\npvm_contract = "0.1"\n`; - expect(detectContractsType(input({ cargoToml }))).toBe("cdm"); - }); - - it("detects cdm via the kebab-case dep name too", () => { - const cargoToml = `[workspace.dependencies]\npvm-contract = { version = "0.1" }\n`; - expect(detectContractsType(input({ cargoToml }))).toBe("cdm"); - }); - - it("ignores Cargo.toml without a pvm_contract dep", () => { - const cargoToml = `[dependencies]\nserde = "1.0"\n`; - expect(detectContractsType(input({ cargoToml }))).toBeNull(); - }); - - it("prefers foundry when both foundry.toml and a hardhat config exist", () => { - // Mixed projects happen during tooling migrations; foundry wins the - // tie-break because its config is stricter (a bare hardhat.config.ts - // sometimes lingers in non-hardhat projects). - expect( - detectContractsType( - input({ configFiles: new Set(["foundry.toml", "hardhat.config.ts"]) }), - ), - ).toBe("foundry"); - }); -}); diff --git a/src/utils/build/detect.ts b/src/utils/build/detect.ts index e4c9456..bf1622b 100644 --- a/src/utils/build/detect.ts +++ b/src/utils/build/detect.ts @@ -61,13 +61,8 @@ export interface DetectInput { lockfiles: Set; /** Set of additional config-file basenames (e.g. vite.config.ts). */ configFiles: Set; - /** Raw Cargo.toml contents (used to gate the cdm contract flow). Null when absent. */ - cargoToml: string | null; } -/** Kind of contract project we found at the root, or null if none. */ -export type ContractsType = "foundry" | "hardhat" | "cdm"; - export class BuildDetectError extends Error { constructor(message: string) { super(message); @@ -145,29 +140,6 @@ const PM_INSTALL: Record = { npm: { cmd: "npm", args: ["install"], description: "npm install" }, }; -/** Hardhat config file variants (TS, CJS, MJS, plain JS). */ -const HARDHAT_CONFIGS = [ - "hardhat.config.ts", - "hardhat.config.js", - "hardhat.config.cjs", - "hardhat.config.mjs", -] as const; - -/** - * Detect which contract toolchain is in use at the project root: - * foundry → `foundry.toml` - * hardhat → `hardhat.config.{ts,js,cjs,mjs}` - * cdm → `Cargo.toml` mentioning `pvm_contract` (snake or kebab case) - */ -export function detectContractsType(input: DetectInput): ContractsType | null { - if (input.configFiles.has("foundry.toml")) return "foundry"; - for (const name of HARDHAT_CONFIGS) { - if (input.configFiles.has(name)) return "hardhat"; - } - if (input.cargoToml && /\bpvm[_-]contract\b/.test(input.cargoToml)) return "cdm"; - return null; -} - /** * Decide whether we need to run an install step before building. Returns the * install command whenever the project declares any dependencies, otherwise diff --git a/src/utils/build/index.ts b/src/utils/build/index.ts index fc62556..2f61bca 100644 --- a/src/utils/build/index.ts +++ b/src/utils/build/index.ts @@ -22,13 +22,11 @@ export { detectBuildConfig, - detectContractsType, detectInstallConfig, detectPackageManager, BuildDetectError, PM_LOCKFILES, type BuildConfig, - type ContractsType, type DetectInput, type InstallConfig, type PackageManager, diff --git a/src/utils/build/runner.ts b/src/utils/build/runner.ts index 8d68447..e84e926 100644 --- a/src/utils/build/runner.ts +++ b/src/utils/build/runner.ts @@ -30,7 +30,7 @@ import { type InstallConfig, } from "./detect.js"; -/** Files whose presence alters build or contract-flow strategy (read once at detect time). */ +/** Files whose presence alters build strategy (read once at detect time). */ const CONFIG_PROBES = [ "vite.config.ts", "vite.config.js", @@ -39,11 +39,6 @@ const CONFIG_PROBES = [ "next.config.js", "next.config.mjs", "tsconfig.json", - "foundry.toml", - "hardhat.config.ts", - "hardhat.config.js", - "hardhat.config.cjs", - "hardhat.config.mjs", ] as const; /** Read just enough of the project root to drive `detectBuildConfig`. */ @@ -69,14 +64,10 @@ export function loadDetectInput(projectDir: string): DetectInput { if (existsSync(join(root, name))) configFiles.add(name); } - const cargoPath = join(root, "Cargo.toml"); - const cargoToml = existsSync(cargoPath) ? readFileSync(cargoPath, "utf8") : null; - return { packageJson, lockfiles, configFiles, - cargoToml, }; } diff --git a/src/utils/bulletinWs.ts b/src/utils/bulletinWs.ts new file mode 100644 index 0000000..c724651 --- /dev/null +++ b/src/utils/bulletinWs.ts @@ -0,0 +1,21 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * `polkadot-api`'s default is 40s, which is shorter than a single Bulletin + * `TransactionStorage.store` submission can take. Keep metadata upload clients + * on the same long heartbeat used by bulletin-deploy. + */ +export const BULLETIN_WS_HEARTBEAT_MS = 300_000; diff --git a/src/utils/contractManifest.test.ts b/src/utils/contractManifest.test.ts index 4d03926..26e10db 100644 --- a/src/utils/contractManifest.test.ts +++ b/src/utils/contractManifest.test.ts @@ -15,7 +15,7 @@ import { describe, expect, it, beforeEach, vi } from "vitest"; import type { CdmJson } from "@parity/product-sdk-contracts"; -import { REGISTRY_ADDRESS } from "@dotdm/contracts"; +import { getRegistryAddress } from "@dotdm/env"; const { createContractFromClientMock, getAddressQueryMock } = vi.hoisted(() => ({ createContractFromClientMock: vi.fn(), @@ -167,7 +167,7 @@ describe("resolveLiveContractAddresses", () => { expect(createContractFromClientMock).toHaveBeenCalledWith( {}, { genesis: "0xasset" }, - REGISTRY_ADDRESS, + getRegistryAddress(), expect.any(Array), expect.any(Object), ); diff --git a/src/utils/contractManifest.ts b/src/utils/contractManifest.ts index 24a11d2..d4fc327 100644 --- a/src/utils/contractManifest.ts +++ b/src/utils/contractManifest.ts @@ -19,7 +19,8 @@ import { type CdmJson, } from "@parity/product-sdk-contracts"; import { paseo_asset_hub } from "@parity/product-sdk-descriptors/paseo-asset-hub"; -import { REGISTRY_ADDRESS, resolveTargetRegistryAddress } from "@dotdm/contracts"; +import { resolveTargetRegistryAddress } from "@dotdm/contracts"; +import { getRegistryAddress } from "@dotdm/env"; import type { HexString, PolkadotClient } from "polkadot-api"; export const PLAYGROUND_REGISTRY_CONTRACT = "@w3s/playground-registry"; @@ -149,7 +150,7 @@ export async function resolveLiveContractAddresses( libraries: readonly string[] = LIVE_CONTRACTS, options: LiveContractLookupOptions = {}, ): Promise> { - const registryAddress = options.registryAddress ?? (REGISTRY_ADDRESS as HexString); + const registryAddress = options.registryAddress ?? (getRegistryAddress() as HexString); const registry = await createContractFromClient( assetHub, paseo_asset_hub, diff --git a/src/utils/deploy/contracts.test.ts b/src/utils/deploy/contracts.test.ts deleted file mode 100644 index c87c2cc..0000000 --- a/src/utils/deploy/contracts.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { Buffer } from "node:buffer"; -import { extractFoundryBytecode, extractHardhatBytecode, hexToBytes } from "./contracts.js"; - -// ── skipBuild mocks ────────────────────────────────────────────────────────── -// -// These mocks exist purely for the skipBuild integration tests at the bottom of -// this file. The pure-helper tests above do not need them. - -const { runStreamedMock, deployBatchMock } = vi.hoisted(() => ({ - runStreamedMock: vi.fn(async () => {}), - deployBatchMock: vi.fn(async () => ({ - addresses: ["0xdeadbeef"] as `0x${string}`[], - chunkCount: 1, - })), -})); - -vi.mock("../process.js", () => ({ - runStreamed: runStreamedMock, -})); - -vi.mock("@dotdm/contracts", async (importOriginal) => { - const real = await importOriginal(); - return { - ...real, - buildContracts: vi.fn(async () => ({ - contracts: [{ crate: "flipper", pvmPath: "/tmp/flipper.polkavm" }], - totalDurationMs: 1, - })), - detectContracts: vi.fn(() => [{ name: "flipper" }]), - ContractDeployer: class MockContractDeployer { - deployBatch = deployBatchMock; - }, - }; -}); - -describe("hexToBytes", () => { - it("decodes a 0x-prefixed hex string", () => { - expect(hexToBytes("0x50564d00")).toEqual(new Uint8Array([0x50, 0x56, 0x4d, 0x00])); - }); - - it("decodes a bare hex string without the 0x prefix", () => { - expect(hexToBytes("deadbeef")).toEqual(new Uint8Array([0xde, 0xad, 0xbe, 0xef])); - }); - - it("treats an empty '0x' as a zero-length byte array", () => { - // Abstract Solidity contracts compile to bytecode "0x"; the caller - // uses length === 0 as the skip signal, so decoding must succeed. - const out = hexToBytes("0x"); - expect(out).toBeInstanceOf(Uint8Array); - expect(out.length).toBe(0); - }); - - it("treats a completely empty string as a zero-length byte array", () => { - const out = hexToBytes(""); - expect(out).toBeInstanceOf(Uint8Array); - expect(out.length).toBe(0); - }); - - it("throws a clear error on odd-length input", () => { - expect(() => hexToBytes("0xabc")).toThrow(/invalid hex string \(odd length\)/); - expect(() => hexToBytes("abc")).toThrow(/invalid hex string \(odd length\)/); - }); - - it("round-trips random byte sequences via Buffer.toString('hex')", () => { - // Spot-check against Node's Buffer implementation to guard against - // off-by-one bugs in the slice/parseInt loop. - const samples: Uint8Array[] = [ - new Uint8Array([0]), - new Uint8Array([0xff]), - new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xfe, 0xff]), - new Uint8Array(Array.from({ length: 64 }, (_, i) => (i * 31) & 0xff)), - new Uint8Array(Array.from({ length: 257 }, (_, i) => i & 0xff)), - ]; - for (const bytes of samples) { - const hex = `0x${Buffer.from(bytes).toString("hex")}`; - expect(hexToBytes(hex)).toEqual(bytes); - // No-prefix variant should match too. - expect(hexToBytes(hex.slice(2))).toEqual(bytes); - } - }); -}); - -describe("extractFoundryBytecode", () => { - it("returns the hex under bytecode.object for a well-formed artifact", () => { - expect(extractFoundryBytecode({ bytecode: { object: "0x60806040" } })).toBe("0x60806040"); - }); - - it("returns null for an empty '0x' placeholder", () => { - // forge emits "0x" for interfaces / abstract contracts; we must skip, - // not attempt to deploy a zero-byte blob. - expect(extractFoundryBytecode({ bytecode: { object: "0x" } })).toBeNull(); - }); - - it("returns null when bytecode.object is missing", () => { - expect(extractFoundryBytecode({ bytecode: {} })).toBeNull(); - }); - - it("returns null when the top-level bytecode field is missing", () => { - expect(extractFoundryBytecode({})).toBeNull(); - }); - - it("returns null for non-object inputs", () => { - // JSON.parse can legitimately produce these for junk files we'd - // rather skip than crash on. - expect(extractFoundryBytecode(null)).toBeNull(); - expect(extractFoundryBytecode(undefined)).toBeNull(); - expect(extractFoundryBytecode("0x60806040")).toBeNull(); - expect(extractFoundryBytecode(42)).toBeNull(); - }); - - it("returns null when bytecode.object is not a string", () => { - expect(extractFoundryBytecode({ bytecode: { object: 42 } })).toBeNull(); - expect(extractFoundryBytecode({ bytecode: { object: null } })).toBeNull(); - }); -}); - -describe("extractHardhatBytecode", () => { - it("returns the plain-string bytecode field", () => { - expect(extractHardhatBytecode({ bytecode: "0x60806040" })).toBe("0x60806040"); - }); - - it("returns null for an empty '0x' placeholder", () => { - expect(extractHardhatBytecode({ bytecode: "0x" })).toBeNull(); - }); - - it("returns null when the bytecode field is missing", () => { - expect(extractHardhatBytecode({})).toBeNull(); - }); - - it("refuses the Foundry { object } shape", () => { - // Hardhat artifacts store bytecode as a plain string. A misrouted - // artifact with the Foundry nested shape should fail loudly (null → - // skip) rather than silently feed the wrong bytes into a deploy. - expect(extractHardhatBytecode({ bytecode: { object: "0x60806040" } })).toBeNull(); - }); - - it("returns null for non-object inputs", () => { - expect(extractHardhatBytecode(null)).toBeNull(); - expect(extractHardhatBytecode(undefined)).toBeNull(); - expect(extractHardhatBytecode("0x60806040")).toBeNull(); - expect(extractHardhatBytecode(42)).toBeNull(); - }); -}); - -// ── skipBuild integration tests ─────────────────────────────────────────────── -// -// These tests verify that the compile step (runStreamed) is NOT called when -// skipBuild is true, and that the discovery loop reads pre-existing artifacts. -// They use a real tmp dir so the fs checks in compileFoundry/compileHardhat -// work correctly; ContractDeployer.deployBatch is mocked to avoid chain I/O. - -import { runContractsPhase } from "./contracts.js"; - -function makeOpts(projectDir: string, contractsType: "foundry" | "hardhat", skipBuild: boolean) { - return { - projectDir, - contractsType, - skipBuild, - // ContractDeployer is mocked — provide the minimal shape that the - // constructor call `new ContractDeployer(signer, origin, raw.assetHub, - // assetHub)` accesses before handing off to the mock. - client: { raw: { assetHub: {} }, assetHub: {} } as any, - signer: {} as any, - origin: "5FakeAddress" as any, - onEvent: () => {}, - }; -} - -describe("runContractsPhase skipBuild=true (foundry)", () => { - let dir: string; - - beforeEach(() => { - runStreamedMock.mockClear(); - deployBatchMock.mockClear(); - // Fresh tmp dir per test so artifact presence/absence is controlled. - dir = mkdtempSync(join(tmpdir(), "contracts-test-foundry-")); - }); - - it("uses existing foundry artifacts without spawning forge", async () => { - // Set up out/Counter.sol/Counter.json with real bytecode. - const solDir = join(dir, "out", "Counter.sol"); - mkdirSync(solDir, { recursive: true }); - writeFileSync( - join(solDir, "Counter.json"), - JSON.stringify({ bytecode: { object: "0x6080604052" } }), - ); - - const result = await runContractsPhase(makeOpts(dir, "foundry", true)); - - expect(runStreamedMock).not.toHaveBeenCalled(); - expect(result.deployed).toHaveLength(1); - expect(result.deployed[0].name).toBe("Counter"); - }); - - it("throws with a clear message when out/ is missing", async () => { - // No out/ directory — simulates a project where forge was never run. - mkdirSync(dir, { recursive: true }); - - await expect(runContractsPhase(makeOpts(dir, "foundry", true))).rejects.toThrow( - /no pre-built contract artifacts found at/, - ); - expect(runStreamedMock).not.toHaveBeenCalled(); - }); - - it("throws with a clear message when out/ exists but has no valid artifacts", async () => { - // out/ exists but is empty — forge ran but produced nothing usable. - mkdirSync(join(dir, "out"), { recursive: true }); - - await expect(runContractsPhase(makeOpts(dir, "foundry", true))).rejects.toThrow( - /no pre-built contract artifacts found at/, - ); - }); -}); - -describe("runContractsPhase skipBuild=true (hardhat)", () => { - let dir: string; - - beforeEach(() => { - runStreamedMock.mockClear(); - deployBatchMock.mockClear(); - dir = mkdtempSync(join(tmpdir(), "contracts-test-hardhat-")); - }); - - it("uses existing hardhat artifacts without spawning npx hardhat compile", async () => { - // Set up artifacts/contracts/Lock.sol/Lock.json with real bytecode. - const solDir = join(dir, "artifacts", "contracts", "Lock.sol"); - mkdirSync(solDir, { recursive: true }); - writeFileSync(join(solDir, "Lock.json"), JSON.stringify({ bytecode: "0x6080604052" })); - - const result = await runContractsPhase(makeOpts(dir, "hardhat", true)); - - expect(runStreamedMock).not.toHaveBeenCalled(); - expect(result.deployed).toHaveLength(1); - expect(result.deployed[0].name).toBe("Lock"); - }); - - it("throws with a clear message when artifacts/contracts/ is missing", async () => { - mkdirSync(dir, { recursive: true }); - - await expect(runContractsPhase(makeOpts(dir, "hardhat", true))).rejects.toThrow( - /no pre-built contract artifacts found at/, - ); - expect(runStreamedMock).not.toHaveBeenCalled(); - }); - - it("throws with a clear message when artifacts/contracts/ has no valid artifacts", async () => { - // Directory exists but contains no .sol subdirs with valid JSON. - mkdirSync(join(dir, "artifacts", "contracts"), { recursive: true }); - - await expect(runContractsPhase(makeOpts(dir, "hardhat", true))).rejects.toThrow( - /no pre-built contract artifacts found at/, - ); - }); -}); diff --git a/src/utils/deploy/contracts.ts b/src/utils/deploy/contracts.ts deleted file mode 100644 index d351530..0000000 --- a/src/utils/deploy/contracts.ts +++ /dev/null @@ -1,412 +0,0 @@ -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * Compile + deploy contracts for the `dot deploy` flow. - * - * Supports three project kinds (detected via `detectContractsType` in the - * build-detect module): - * - `cdm` — Rust/PVM, compiled with `@dotdm/contracts` `buildContracts`. - * - `foundry` — Solidity, compiled with `forge build --resolc` (the polkadot - * fork's PolkaVM path). - * - `hardhat` — Solidity, compiled with `npx hardhat compile`, which runs - * resolc under the hood when `@parity/hardhat-polkadot` is - * loaded in the user's config. - * - * All three paths converge on a list of bytecode files on disk that we hand - * to `ContractDeployer.deployBatch` for weight-aware batching via - * `Utility.batch_all`. The bytes can be PVM or EVM — `Revive.instantiate_with_code` - * polymorphically accepts both when the chain has `AllowEVMBytecode = true` - * (Asset Hub paseo + hub do). No registry registration, no metadata publish - * in v1 — that's opt-in future work. - * - * Kept free of React / Ink imports so RevX can consume this from a - * WebContainer — see the "SDK surface" note in CLAUDE.md. - */ - -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { tmpdir } from "node:os"; -import { runStreamed } from "../process.js"; -import type { BuildEvent as CdmBuildEvent, PipelineChainClient } from "@dotdm/contracts"; -import type { HexString, PolkadotSigner, SS58String } from "polkadot-api"; -import type { ContractsType } from "../build/detect.js"; - -// ── Events ─────────────────────────────────────────────────────────────────── - -export type ContractsPhaseEvent = - | { kind: "info"; message: string } - /** Per-line stdout/stderr from the compile step (forge/hardhat) or cdm builder. */ - | { kind: "compile-log"; line: string } - | { kind: "compile-detected"; contracts: string[] } - | { - kind: "deploy-chunk"; - chunk: number; - total: number; - /** Contracts landed in this chunk, paired with their on-chain addresses. */ - contracts: Array<{ name: string; address: HexString }>; - } - | { kind: "deploy-done"; addresses: Array<{ name: string; address: HexString }> }; - -export interface RunContractsPhaseOptions { - projectDir: string; - contractsType: ContractsType; - client: PipelineChainClient; - signer: PolkadotSigner; - /** SS58-encoded address of `signer`. Used as the deployer origin for dry-runs. */ - origin: SS58String; - onEvent: (event: ContractsPhaseEvent) => void; - /** - * If true, skip the contract compile step (forge/hardhat/cargo-contract) - * and use pre-existing artifacts on disk. CI-friendly for environments - * without the contract toolchain installed. Throws if no artifacts found. - */ - skipBuild?: boolean; -} - -export interface ContractsPhaseResult { - deployed: Array<{ name: string; address: HexString }>; -} - -let dotdmContractsPromise: Promise | null = null; - -function loadDotdmContracts(): Promise { - dotdmContractsPromise ??= import("@dotdm/contracts"); - return dotdmContractsPromise; -} - -// ── Entry point ────────────────────────────────────────────────────────────── - -export async function runContractsPhase( - opts: RunContractsPhaseOptions, -): Promise { - const artifacts = await compileContracts(opts); - - if (artifacts.length === 0) { - opts.onEvent({ - kind: "info", - message: "no contracts found to deploy", - }); - return { deployed: [] }; - } - - opts.onEvent({ - kind: "compile-detected", - contracts: artifacts.map((a) => a.name), - }); - - const { ContractDeployer } = await loadDotdmContracts(); - const deployer = new ContractDeployer( - opts.signer, - opts.origin, - opts.client.raw.assetHub, - opts.client.assetHub, - ); - - const pvmPaths = artifacts.map((a) => a.pvmPath); - let chunkOffset = 0; - const batchResult = await deployer.deployBatch(pvmPaths, undefined, (chunkResult) => { - const base = chunkOffset; - chunkOffset += chunkResult.addresses.length; - opts.onEvent({ - kind: "deploy-chunk", - chunk: chunkResult.chunkIndex + 1, - total: chunkResult.totalChunks, - // cdm preserves input order across chunks, so the Nth address - // of a chunk maps to `artifacts[base + N]`. - contracts: chunkResult.addresses.map((addr, i) => ({ - name: artifacts[base + i]?.name ?? `contract-${base + i}`, - address: addr as HexString, - })), - }); - }); - - const deployed = artifacts.map((a, i) => ({ - name: a.name, - address: batchResult.addresses[i] as HexString, - })); - opts.onEvent({ kind: "deploy-done", addresses: deployed }); - return { deployed }; -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function throwMissingArtifacts(dir: string, manualCmd: string): never { - throw new Error( - `no pre-built contract artifacts found at ${dir}; remove --no-contract-build or run \`${manualCmd}\` manually first`, - ); -} - -// ── Compile dispatch ───────────────────────────────────────────────────────── - -interface CompiledArtifact { - /** Crate name for cdm, contract name for Solidity. */ - name: string; - /** Absolute path to the bytecode file (PVM or EVM bytes). */ - pvmPath: string; -} - -async function compileContracts(opts: RunContractsPhaseOptions): Promise { - switch (opts.contractsType) { - case "cdm": - return compileCdm(opts); - case "foundry": - return compileFoundry(opts); - case "hardhat": - return compileHardhat(opts); - } -} - -// ── cdm compile ────────────────────────────────────────────────────────────── - -async function compileCdm(opts: RunContractsPhaseOptions): Promise { - if (opts.skipBuild) { - return compileCdmSkipBuild(opts); - } - - const { buildContracts } = await loadDotdmContracts(); - const summary = await buildContracts({ - rootDir: opts.projectDir, - onEvent: (event: CdmBuildEvent) => { - relayCdmBuildEvent(event, opts.onEvent); - }, - }); - - const artifacts: CompiledArtifact[] = []; - for (const c of summary.contracts) { - if (c.error) { - throw new Error(`cdm build failed for ${c.crate}: ${c.error}`); - } - if (!c.pvmPath) { - throw new Error(`cdm build produced no bytecode path for ${c.crate}`); - } - artifacts.push({ name: c.crate, pvmPath: c.pvmPath }); - } - return artifacts; -} - -/** - * CDM skip-build path: use `detectContracts` to enumerate crates from - * Cargo metadata (no build required), then resolve the expected - * `target/.release.polkavm` path that `buildContracts` would have - * produced. Throws with a clear message if any artifact is missing. - */ -async function compileCdmSkipBuild(opts: RunContractsPhaseOptions): Promise { - const { detectContracts } = await loadDotdmContracts(); - const projectDir = resolve(opts.projectDir); - const contracts = detectContracts(projectDir); - - if (contracts.length === 0) { - throwMissingArtifacts(projectDir, "cargo-contract build"); - } - - opts.onEvent({ - kind: "compile-detected", - contracts: contracts.map((c) => c.name), - }); - - const artifacts: CompiledArtifact[] = []; - for (const contract of contracts) { - const pvmPath = join(projectDir, `target/${contract.name}.release.polkavm`); - if (!existsSync(pvmPath)) { - throwMissingArtifacts(pvmPath, "cargo-contract build"); - } - artifacts.push({ name: contract.name, pvmPath }); - } - return artifacts; -} - -function relayCdmBuildEvent(event: CdmBuildEvent, emit: (e: ContractsPhaseEvent) => void) { - if (event.type === "detect") { - const crates = event.contracts.map((c) => c.name); - emit({ kind: "compile-detected", contracts: crates }); - } else if (event.type === "build-start") { - emit({ kind: "compile-log", line: `compiling ${event.crate}…` }); - } else if (event.type === "build-done") { - emit({ kind: "compile-log", line: `compiled ${event.crate} (${event.bytecodeSize}B)` }); - } else if (event.type === "build-error") { - emit({ kind: "compile-log", line: `error: ${event.crate} — ${event.error}` }); - } -} - -// ── Foundry compile ────────────────────────────────────────────────────────── - -async function compileFoundry(opts: RunContractsPhaseOptions): Promise { - const projectDir = resolve(opts.projectDir); - - if (!opts.skipBuild) { - // `--resolc` forces PolkaVM codegen regardless of `foundry.toml`; plain - // `forge build` defaults to EVM even on the polkadot fork. - await runStreamed({ - cmd: "forge", - args: ["build", "--resolc"], - cwd: projectDir, - description: "forge build --resolc", - failurePrefix: "forge build failed", - onData: (line) => opts.onEvent({ kind: "compile-log", line }), - }); - } - - const outDir = join(projectDir, "out"); - let foundryEntries: import("node:fs").Dirent[]; - try { - foundryEntries = readdirSync(outDir, { withFileTypes: true }); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - if (opts.skipBuild) throwMissingArtifacts(outDir, "forge build --resolc"); - throw new Error(`forge build did not produce an out/ directory at ${outDir}`); - } - throw err; - } - - const artifacts: CompiledArtifact[] = []; - for (const entry of foundryEntries) { - if (!entry.isDirectory()) continue; - if (entry.name.endsWith(".t.sol") || entry.name.endsWith(".s.sol")) continue; - if (!entry.name.endsWith(".sol")) continue; - - const contractName = entry.name.slice(0, -".sol".length); - const artifactPath = join(outDir, entry.name, `${contractName}.json`); - if (!existsSync(artifactPath)) continue; - - const hex = extractFoundryBytecode(JSON.parse(readFileSync(artifactPath, "utf8"))); - if (hex === null) continue; - const bytes = hexToBytes(hex); - if (bytes.length === 0) continue; - - artifacts.push({ - name: contractName, - pvmPath: writeTmpBytecode(`foundry-${contractName}`, bytes), - }); - } - - if (opts.skipBuild && artifacts.length === 0) { - throwMissingArtifacts(outDir, "forge build --resolc"); - } - - return artifacts; -} - -// ── Hardhat compile ────────────────────────────────────────────────────────── - -async function compileHardhat(opts: RunContractsPhaseOptions): Promise { - const projectDir = resolve(opts.projectDir); - - if (!opts.skipBuild) { - await runStreamed({ - cmd: "npx", - args: ["hardhat", "compile"], - cwd: projectDir, - description: "npx hardhat compile", - failurePrefix: "hardhat compile failed", - onData: (line) => opts.onEvent({ kind: "compile-log", line }), - }); - } - - const artifactsRoot = join(projectDir, "artifacts", "contracts"); - let hardhatEntries: import("node:fs").Dirent[]; - try { - hardhatEntries = readdirSync(artifactsRoot, { withFileTypes: true }); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - if (opts.skipBuild) throwMissingArtifacts(artifactsRoot, "npx hardhat compile"); - throw new Error( - `hardhat compile did not produce artifacts/contracts/ at ${artifactsRoot} — did you load "@parity/hardhat-polkadot" in your hardhat.config?`, - ); - } - throw err; - } - - const artifacts: CompiledArtifact[] = []; - for (const entry of hardhatEntries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith(".sol")) continue; - - const dir = join(artifactsRoot, entry.name); - for (const sub of readdirSync(dir)) { - if (!sub.endsWith(".json") || sub.endsWith(".dbg.json")) continue; - - const contractName = sub.slice(0, -".json".length); - const hex = extractHardhatBytecode(JSON.parse(readFileSync(join(dir, sub), "utf8"))); - if (hex === null) continue; - const bytes = hexToBytes(hex); - if (bytes.length === 0) continue; - - artifacts.push({ - name: contractName, - pvmPath: writeTmpBytecode(`hardhat-${contractName}`, bytes), - }); - } - } - - if (opts.skipBuild && artifacts.length === 0) { - throwMissingArtifacts(artifactsRoot, "npx hardhat compile"); - } - - return artifacts; -} - -// ── Shared helpers ─────────────────────────────────────────────────────────── - -/** Read `bytecode.object` from a Foundry artifact; null for "0x"/missing. */ -export function extractFoundryBytecode(artifactJson: unknown): string | null { - if (typeof artifactJson !== "object" || artifactJson === null) return null; - const bytecode = (artifactJson as { bytecode?: unknown }).bytecode; - if (typeof bytecode !== "object" || bytecode === null) return null; - const hex = (bytecode as { object?: unknown }).object; - if (typeof hex !== "string") return null; - if (hex === "0x" || hex === "") return null; - return hex; -} - -/** - * Read `bytecode` from a Hardhat artifact; null for "0x"/missing. Refuses - * the Foundry `{ object }` shape so misrouted artifacts fail loudly. - */ -export function extractHardhatBytecode(artifactJson: unknown): string | null { - if (typeof artifactJson !== "object" || artifactJson === null) return null; - const hex = (artifactJson as { bytecode?: unknown }).bytecode; - if (typeof hex !== "string") return null; - if (hex === "0x" || hex === "") return null; - return hex; -} - -export function hexToBytes(hex: string): Uint8Array { - const clean = hex.startsWith("0x") ? hex.slice(2) : hex; - if (clean.length % 2 !== 0) { - throw new Error(`invalid hex string (odd length): ${hex.slice(0, 20)}…`); - } - const out = new Uint8Array(clean.length / 2); - for (let i = 0; i < out.length; i++) { - out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16); - } - return out; -} - -let tmpDirForSession: string | null = null; -/** Allocate a per-session tmp dir so a failed deploy's artifacts stick around for inspection. */ -function sessionTmpDir(): string { - if (tmpDirForSession !== null) return tmpDirForSession; - const dir = join(tmpdir(), `dot-contracts-${process.pid}-${Date.now()}`); - mkdirSync(dir, { recursive: true }); - tmpDirForSession = dir; - return dir; -} - -function writeTmpBytecode(stem: string, bytes: Uint8Array): string { - const path = join(sessionTmpDir(), `${stem}.bin`); - writeFileSync(path, bytes); - return path; -} diff --git a/src/utils/deploy/playground.ts b/src/utils/deploy/playground.ts index 3ab12a0..2a97748 100644 --- a/src/utils/deploy/playground.ts +++ b/src/utils/deploy/playground.ts @@ -43,18 +43,10 @@ import { getConnection } from "../connection.js"; import { getChainConfig, type Env } from "../../config.js"; import { captureWarning, withSpan, errorMessage } from "../../telemetry.js"; import { getBulletinAllowanceSigner, isInvalidPaymentError } from "../allowances/bulletin.js"; +import { BULLETIN_WS_HEARTBEAT_MS } from "../bulletinWs.js"; import type { ResolvedSigner } from "../signer.js"; import type { DeployLogEvent } from "./progress.js"; -/** - * Heartbeat we force on the Bulletin WebSocket for the metadata upload. - * `polkadot-api`'s default is 40 s, which is shorter than the time a single - * `TransactionStorage.store` submission can take (finalization wait + chain - * round-trips), so the transport tears down mid-tx as `WS halt (3)`. - * Matches what `bulletin-deploy` does for its own clients. See CLAUDE.md. - */ -const BULLETIN_WS_HEARTBEAT_MS = 300_000; - const MAX_REGISTRY_RETRIES = 3; const REGISTRY_RETRY_DELAY_MS = 6_000; diff --git a/src/utils/deploy/run.test.ts b/src/utils/deploy/run.test.ts index 31a367e..f4f1f1d 100644 --- a/src/utils/deploy/run.test.ts +++ b/src/utils/deploy/run.test.ts @@ -24,13 +24,6 @@ const { runBuildMock, detectBuildConfigMock, loadDetectInputMock, - detectContractsTypeMock, - runContractsPhaseMock, - getOrCreateSessionAccountMock, - getConnectionMock, - checkBalanceMock, - pickFunderMock, - submitAndWatchMock, withSpanMock, } = vi.hoisted(() => ({ runStorageDeploy: vi.fn< @@ -62,43 +55,7 @@ const { packageJson: { scripts: { build: "vite build" } }, lockfiles: new Set(), configFiles: new Set(), - cargoToml: null, })), - detectContractsTypeMock: vi.fn<() => "foundry" | "hardhat" | "cdm" | null>(() => null), - runContractsPhaseMock: vi.fn< - (arg: any) => Promise<{ - deployed: Array<{ name: string; address: `0x${string}` }>; - }> - >(async () => ({ deployed: [{ name: "Counter", address: "0xdeadbeef" }] })), - getOrCreateSessionAccountMock: vi.fn(async () => ({ - info: { - account: { - ss58Address: "5SessionAddr", - signer: { __sessionSigner: true }, - }, - }, - created: false, - })), - getConnectionMock: vi.fn(), - checkBalanceMock: vi.fn< - ( - client: unknown, - address: string, - min?: bigint, - ) => Promise<{ - free: bigint; - sufficient: boolean; - }> - >(async () => ({ free: 100_000_000_000n, sufficient: true })), - submitAndWatchMock: vi.fn<(tx: unknown, signer: unknown) => Promise>(async () => ({ - ok: true, - })), - pickFunderMock: vi.fn< - ( - client: unknown, - required: bigint, - ) => Promise<{ name: string; address: string; signer: unknown } | null> - >(async () => ({ name: "Alice", address: "5Alice", signer: { __funder: "Alice" } })), withSpanMock: vi.fn(async (_op: string, _name: string, _attrs: any, fn: any) => fn()), })); @@ -114,30 +71,6 @@ vi.mock("../build/index.js", () => ({ runBuild: runBuildMock, loadDetectInput: loadDetectInputMock, detectBuildConfig: detectBuildConfigMock, - detectContractsType: detectContractsTypeMock, -})); -vi.mock("./contracts.js", () => ({ - runContractsPhase: runContractsPhaseMock, -})); -vi.mock("./session-account.js", () => ({ - getOrCreateSessionAccount: getOrCreateSessionAccountMock, - persistSessionAccount: vi.fn(async () => {}), - SESSION_MIN_BALANCE: 5_000_000_000n, - SESSION_FUND_AMOUNT: 50_000_000_000n, -})); -vi.mock("../connection.js", () => ({ - getConnection: getConnectionMock, -})); -vi.mock("../account/funding.js", () => ({ - checkBalance: checkBalanceMock, - pickFunder: (...args: unknown[]) => pickFunderMock(args[0], args[1] as bigint), - FUNDER_FEE_BUFFER: 1_000_000_000n, -})); -vi.mock("../account/funder.js", () => ({ - FAUCET_URL: "https://faucet.polkadot.io/?network=pah", -})); -vi.mock("@parity/product-sdk-tx", () => ({ - submitAndWatch: (...args: unknown[]) => submitAndWatchMock(args[0], args[1]), })); vi.mock("../../telemetry.js", () => ({ withSpan: (...args: unknown[]) => @@ -159,75 +92,16 @@ const fakeUserSigner: ResolvedSigner = { destroy: vi.fn(), }; -const fakeDevSigner: ResolvedSigner = { - signer: { - publicKey: new Uint8Array(32).fill(1), - signTx: vi.fn(), - signBytes: vi.fn(), - }, - address: "5Dev", - source: "dev", - destroy: vi.fn(), -}; - function collectEvents(): { events: DeployEvent[]; push: (e: DeployEvent) => void } { const events: DeployEvent[] = []; return { events, push: (e) => events.push(e) }; } -/** - * Build a fake `PaseoClient`-ish object that exposes the exact tx factories - * `maybeRunContracts` / `ensureSessionFunded` call (`Balances.transfer_keep_alive`, - * `Revive.map_account`). Returns `transferFactory` and `mapAccountFactory` so - * callers can assert what `submitAndWatch` was handed. - */ -function makeFakeClient() { - const transferFactory = vi - .fn() - .mockImplementation((args: unknown) => ({ __kind: "transfer_keep_alive", args })); - const mapAccountFactory = vi.fn().mockReturnValue({ __kind: "map_account" }); - return { - client: { - assetHub: { - tx: { - Balances: { transfer_keep_alive: transferFactory }, - Revive: { map_account: mapAccountFactory }, - }, - }, - } as any, - transferFactory, - mapAccountFactory, - }; -} - beforeEach(() => { runStorageDeploy.mockClear(); publishToPlaygroundMock.mockClear(); runBuildMock.mockClear(); - runContractsPhaseMock.mockClear(); - detectContractsTypeMock.mockReset(); - detectContractsTypeMock.mockReturnValue(null); - getOrCreateSessionAccountMock.mockClear(); - getOrCreateSessionAccountMock.mockImplementation(async () => ({ - info: { - account: { - ss58Address: "5SessionAddr", - signer: { __sessionSigner: true }, - }, - }, - created: false, - })); - getConnectionMock.mockReset(); - checkBalanceMock.mockReset(); - checkBalanceMock.mockResolvedValue({ free: 100_000_000_000n, sufficient: true }); - submitAndWatchMock.mockClear(); withSpanMock.mockClear(); - pickFunderMock.mockReset(); - pickFunderMock.mockResolvedValue({ - name: "Alice", - address: "5Alice", - signer: { __funder: "Alice" }, - }); }); describe("runDeploy", () => { @@ -448,401 +322,3 @@ describe("runDeploy", () => { expect(ops).toContain("cli.deploy.playground"); }); }); - -// ── Contracts-phase orchestration ──────────────────────────────────────────── - -describe("runDeploy — contracts phase", () => { - it("runs contracts and build concurrently (both invoked before either resolves)", async () => { - detectContractsTypeMock.mockReturnValue("foundry"); - const { client } = makeFakeClient(); - getConnectionMock.mockResolvedValue(client); - - // Gate each mock on an explicit resolver so we can observe that both - // were *entered* before either has settled. If the orchestrator were - // accidentally sequential (await-contracts → await-build), only the - // first mock would ever be called within the assertion window. - let resolveContracts!: () => void; - const contractsGate = new Promise((r) => { - resolveContracts = r; - }); - runContractsPhaseMock.mockImplementationOnce(async () => { - await contractsGate; - return { deployed: [{ name: "Counter", address: "0xabc" }] }; - }); - - let resolveBuild!: () => void; - const buildGate = new Promise((r) => { - resolveBuild = r; - }); - runBuildMock.mockImplementationOnce(async () => { - await buildGate; - return { config: {} as any, outputDir: "/tmp/dist" }; - }); - - const { push } = collectEvents(); - const deployPromise = runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - domain: "my-app", - mode: "dev", - publishToPlayground: false, - userSigner: null, - deployContracts: true, - onEvent: push, - }); - - // Give microtasks + the Promise.all scheduler a window to invoke - // both branches. Neither branch has resolved yet. - await new Promise((r) => setTimeout(r, 20)); - expect(runContractsPhaseMock).toHaveBeenCalled(); - expect(runBuildMock).toHaveBeenCalled(); - - resolveContracts(); - resolveBuild(); - const outcome = await deployPromise; - expect(outcome.contracts).toEqual([{ name: "Counter", address: "0xabc" }]); - }); - - it("storage-and-dotns waits for BOTH contracts and build before starting", async () => { - detectContractsTypeMock.mockReturnValue("foundry"); - const { client } = makeFakeClient(); - getConnectionMock.mockResolvedValue(client); - - let resolveContracts!: () => void; - const contractsGate = new Promise((r) => { - resolveContracts = r; - }); - runContractsPhaseMock.mockImplementationOnce(async () => { - await contractsGate; - return { deployed: [] }; - }); - - let resolveBuild!: () => void; - const buildGate = new Promise((r) => { - resolveBuild = r; - }); - runBuildMock.mockImplementationOnce(async () => { - await buildGate; - return { config: {} as any, outputDir: "/tmp/dist" }; - }); - - const { push } = collectEvents(); - const deployPromise = runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - domain: "my-app", - mode: "dev", - publishToPlayground: false, - userSigner: null, - deployContracts: true, - onEvent: push, - }); - - // Resolve only contracts — storage must still be dormant because - // build hasn't completed yet. Note: build precedes storage in the - // frontend branch, so storage is always gated by build too. - resolveContracts(); - await new Promise((r) => setTimeout(r, 20)); - expect(runStorageDeploy).not.toHaveBeenCalled(); - - resolveBuild(); - await deployPromise; - expect(runStorageDeploy).toHaveBeenCalledTimes(1); - }); - - it("deployContracts: false → phase-skipped with 'not requested' reason", async () => { - const { events, push } = collectEvents(); - await runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - skipBuild: true, - domain: "my-app", - mode: "dev", - publishToPlayground: false, - userSigner: null, - onEvent: push, - }); - - const skipped = events.find((e) => e.kind === "phase-skipped" && e.phase === "contracts"); - expect(skipped).toMatchObject({ - kind: "phase-skipped", - phase: "contracts", - reason: expect.stringMatching(/contracts deploy not requested/), - }); - expect(runContractsPhaseMock).not.toHaveBeenCalled(); - }); - - it("deployContracts: true but no contracts project → phase-skipped with 'no foundry/hardhat/cdm' reason", async () => { - detectContractsTypeMock.mockReturnValue(null); - const { events, push } = collectEvents(); - await runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - skipBuild: true, - domain: "my-app", - mode: "dev", - publishToPlayground: false, - userSigner: null, - deployContracts: true, - onEvent: push, - }); - - const skipped = events.find((e) => e.kind === "phase-skipped" && e.phase === "contracts"); - expect(skipped).toMatchObject({ - reason: expect.stringMatching(/no foundry\/hardhat\/cdm/), - }); - expect(runContractsPhaseMock).not.toHaveBeenCalled(); - }); - - it("ensureSessionFunded: already funded → no transfer submitted, contracts still run", async () => { - detectContractsTypeMock.mockReturnValue("foundry"); - const { client } = makeFakeClient(); - getConnectionMock.mockResolvedValue(client); - checkBalanceMock.mockResolvedValue({ free: 50_000_000_000n, sufficient: true }); - - const { push } = collectEvents(); - await runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - skipBuild: true, - domain: "my-app", - mode: "dev", - publishToPlayground: false, - userSigner: null, - deployContracts: true, - onEvent: push, - }); - - // `submitAndWatch` must not have been called for the transfer. - // (It's also not called for map_account because `created: false`.) - expect(submitAndWatchMock).not.toHaveBeenCalled(); - expect(runContractsPhaseMock).toHaveBeenCalledTimes(1); - }); - - it("ensureSessionFunded: underfunded + phone signer → wraps user signer, transfers SESSION_FUND_AMOUNT", async () => { - detectContractsTypeMock.mockReturnValue("foundry"); - const { client, transferFactory } = makeFakeClient(); - getConnectionMock.mockResolvedValue(client); - checkBalanceMock.mockResolvedValue({ free: 0n, sufficient: false }); - - const { push } = collectEvents(); - await runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - skipBuild: true, - domain: "my-app", - mode: "phone", - publishToPlayground: false, - userSigner: fakeUserSigner, - deployContracts: true, - onEvent: push, - }); - - // Transfer was submitted with `value = SESSION_FUND_AMOUNT` (50 PAS). - const transferArg = transferFactory.mock.calls[0][0] as { value: bigint }; - expect(transferArg.value).toBe(50_000_000_000n); - - // The first `submitAndWatch` call is the transfer. Its signer is the - // `wrapSignerWithEvents` proxy, which exposes `signTx`/`signBytes` - // (so we check identity-by-shape rather than drilling into internals). - const [, firstSigner] = submitAndWatchMock.mock.calls[0]; - expect(firstSigner).toHaveProperty("signTx"); - expect(firstSigner).toHaveProperty("signBytes"); - // Dev-mode funder-chain lookup must NOT have fired. - expect(pickFunderMock).not.toHaveBeenCalled(); - }); - - it("ensureSessionFunded: underfunded + dev signer (source='dev') → uses signer directly, no phone events", async () => { - detectContractsTypeMock.mockReturnValue("foundry"); - const { client, transferFactory } = makeFakeClient(); - getConnectionMock.mockResolvedValue(client); - checkBalanceMock.mockResolvedValue({ free: 0n, sufficient: false }); - - const { events, push } = collectEvents(); - await runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - skipBuild: true, - domain: "my-app", - mode: "dev", - publishToPlayground: false, - userSigner: fakeDevSigner, - deployContracts: true, - onEvent: push, - }); - - // Transfer was submitted — the dev signer funded the session key. - const transferArg = transferFactory.mock.calls[0][0] as { value: bigint }; - expect(transferArg.value).toBe(50_000_000_000n); - - // The signer passed to submitAndWatch must be the raw dev signer — NOT - // a wrapSignerWithEvents proxy. Assert exact object identity. - const [, usedFunder] = submitAndWatchMock.mock.calls[0]; - expect(usedFunder).toBe(fakeDevSigner.signer); - - // No phone-tap lifecycle events should have been emitted. - const signingEvents = events.filter((e) => e.kind === "signing"); - expect(signingEvents).toHaveLength(0); - - // Funder-chain lookup must NOT have been consulted. - expect(pickFunderMock).not.toHaveBeenCalled(); - }); - - it("ensureSessionFunded: underfunded + pure dev mode → picks a funder from the chain", async () => { - detectContractsTypeMock.mockReturnValue("foundry"); - const { client } = makeFakeClient(); - getConnectionMock.mockResolvedValue(client); - checkBalanceMock.mockResolvedValue({ free: 0n, sufficient: false }); - - const dedicatedSigner = { __funder: "dedicated" }; - pickFunderMock.mockResolvedValueOnce({ - name: "dedicated", - address: "5Dedicated", - signer: dedicatedSigner, - }); - - const { push } = collectEvents(); - await runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - skipBuild: true, - domain: "my-app", - mode: "dev", - publishToPlayground: false, - userSigner: null, - deployContracts: true, - onEvent: push, - }); - - // Funder-chain was consulted for `SESSION_FUND_AMOUNT + FUNDER_FEE_BUFFER`. - expect(pickFunderMock).toHaveBeenCalledTimes(1); - const required = pickFunderMock.mock.calls[0][1]; - expect(required).toBe(50_000_000_000n + 1_000_000_000n); - - // Transfer was submitted with exactly the signer returned by pickFunder. - const [, funder] = submitAndWatchMock.mock.calls[0]; - expect(funder).toBe(dedicatedSigner); - }); - - it("ensureSessionFunded: underfunded + dev mode + every funder drained → throws with faucet link", async () => { - detectContractsTypeMock.mockReturnValue("foundry"); - const { client } = makeFakeClient(); - getConnectionMock.mockResolvedValue(client); - checkBalanceMock.mockResolvedValue({ free: 0n, sufficient: false }); - pickFunderMock.mockResolvedValueOnce(null); - - const { events, push } = collectEvents(); - await expect( - runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - skipBuild: true, - domain: "my-app", - mode: "dev", - publishToPlayground: false, - userSigner: null, - deployContracts: true, - onEvent: push, - }), - ).rejects.toThrow( - /Dev account balance low\..*mobile signer.*faucet.*https:\/\/faucet\.polkadot\.io/s, - ); - - // No transfer should have been attempted. - expect(submitAndWatchMock).not.toHaveBeenCalled(); - // The error should have been surfaced as a contracts-phase error event. - const err = events.find((e) => e.kind === "error" && e.phase === "contracts"); - expect(err).toBeDefined(); - }); - - it("session-key mapping fires Revive.map_account only when created: true", async () => { - detectContractsTypeMock.mockReturnValue("foundry"); - const { client, mapAccountFactory } = makeFakeClient(); - getConnectionMock.mockResolvedValue(client); - getOrCreateSessionAccountMock.mockResolvedValue({ - info: { - account: { - ss58Address: "5SessionAddr", - signer: { __sessionSigner: true }, - }, - }, - created: true, - }); - - const { push } = collectEvents(); - await runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - skipBuild: true, - domain: "my-app", - mode: "dev", - publishToPlayground: false, - userSigner: null, - deployContracts: true, - onEvent: push, - }); - - expect(mapAccountFactory).toHaveBeenCalledTimes(1); - }); - - it("session-key mapping does NOT fire when created: false", async () => { - detectContractsTypeMock.mockReturnValue("foundry"); - const { client, mapAccountFactory } = makeFakeClient(); - getConnectionMock.mockResolvedValue(client); - getOrCreateSessionAccountMock.mockResolvedValue({ - info: { - account: { - ss58Address: "5SessionAddr", - signer: { __sessionSigner: true }, - }, - }, - created: false, - }); - - const { push } = collectEvents(); - await runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - skipBuild: true, - domain: "my-app", - mode: "dev", - publishToPlayground: false, - userSigner: null, - deployContracts: true, - onEvent: push, - }); - - expect(mapAccountFactory).not.toHaveBeenCalled(); - }); - - it("contracts phase error → runDeploy rejects AND emits a contracts error event", async () => { - detectContractsTypeMock.mockReturnValue("foundry"); - const { client } = makeFakeClient(); - getConnectionMock.mockResolvedValue(client); - runContractsPhaseMock.mockImplementationOnce(async () => { - throw new Error("forge blew up"); - }); - - const { events, push } = collectEvents(); - await expect( - runDeploy({ - projectDir: "/tmp/proj", - buildDir: "/tmp/proj/dist", - skipBuild: true, - domain: "my-app", - mode: "dev", - publishToPlayground: false, - userSigner: null, - deployContracts: true, - onEvent: push, - }), - ).rejects.toThrow(/forge blew up/); - - const err = events.find((e) => e.kind === "error" && e.phase === "contracts"); - expect(err).toMatchObject({ - kind: "error", - phase: "contracts", - message: "forge blew up", - }); - }); -}); diff --git a/src/utils/deploy/run.ts b/src/utils/deploy/run.ts index c2763a0..4e73f08 100644 --- a/src/utils/deploy/run.ts +++ b/src/utils/deploy/run.ts @@ -22,22 +22,8 @@ * off the same events. */ -import { - runBuild, - loadDetectInput, - detectBuildConfig, - detectContractsType, - type BuildConfig, - type ContractsType, -} from "../build/index.js"; +import { runBuild, loadDetectInput, detectBuildConfig, type BuildConfig } from "../build/index.js"; import { publishToPlayground, normalizeDomain } from "./playground.js"; -import { runContractsPhase, type ContractsPhaseEvent } from "./contracts.js"; -import { - getOrCreateSessionAccount, - persistSessionAccount, - SESSION_FUND_AMOUNT, - SESSION_MIN_BALANCE, -} from "./session-account.js"; import { resolveSignerSetup, type SignerMode, type DeployApproval } from "./signerMode.js"; import { wrapSignerWithEvents, @@ -46,20 +32,14 @@ import { type SigningEvent, } from "./signingProxy.js"; import type { DeployLogEvent } from "./progress.js"; -import { checkBalance, pickFunder, FUNDER_FEE_BUFFER } from "../account/funding.js"; -import { FAUCET_URL } from "../account/funder.js"; -import { Enum, type PolkadotSigner } from "polkadot-api"; -import { submitAndWatch } from "@parity/product-sdk-tx"; import { withDeployPhase } from "./phase.js"; import type { ResolvedSigner } from "../signer.js"; -import { getConnection } from "../connection.js"; import type { Env } from "../../config.js"; import type { DeployPlan } from "./availability.js"; -import type { HexString } from "polkadot-api"; // ── Events ─────────────────────────────────────────────────────────────────── -export type DeployPhase = "build" | "contracts" | "storage-and-dotns" | "playground" | "done"; +export type DeployPhase = "build" | "storage-and-dotns" | "playground" | "done"; export type DeployEvent = | { kind: "plan"; approvals: DeployApproval[] } @@ -68,7 +48,6 @@ export type DeployEvent = | { kind: "phase-skipped"; phase: DeployPhase; reason: string } | { kind: "build-log"; line: string } | { kind: "build-detected"; config: BuildConfig } - | { kind: "contracts-event"; event: ContractsPhaseEvent } | { kind: "storage-event"; event: DeployLogEvent } | { kind: "signing"; event: SigningEvent } | { kind: "error"; phase: DeployPhase; message: string }; @@ -94,14 +73,6 @@ export interface RunDeployOptions { moddable?: boolean; /** Resolved public repository URL to record in metadata (moddable=true) or `null` (moddable=false). */ repositoryUrl?: string | null; - /** Compile + deploy foundry/hardhat/cdm contracts alongside the frontend. */ - deployContracts?: boolean; - /** - * Skip the contract compile step (forge/hardhat/cargo-contract) and use - * pre-existing artifacts on disk. CI-friendly for environments without the - * contract toolchain installed. Throws if no artifacts are found. - */ - skipContractBuild?: boolean; /** The logged-in phone signer. Required for `mode === "phone"` or `publishToPlayground`. */ userSigner: ResolvedSigner | null; /** Event sink — consumed by the TUI / RevX. */ @@ -114,8 +85,6 @@ export interface RunDeployOptions { * (3 DotNS taps) if absent and auto-corrects at runtime. */ plan?: DeployPlan; - /** Whether the contracts phase needs a phone tap to top up its session key. */ - contractsFundingNeeded?: boolean; } export interface DeployOutcome { @@ -131,8 +100,6 @@ export interface DeployOutcome { approvalsRequested: DeployApproval[]; /** URL the user can visit to view their deployed app. */ appUrl: string; - /** Contract addresses deployed this run (empty when contracts phase was skipped). */ - contracts: Array<{ name: string; address: HexString }>; } // ── Orchestrator ───────────────────────────────────────────────────────────── @@ -145,19 +112,14 @@ export async function runDeploy(options: RunDeployOptions): Promise { if (!options.skipBuild) { await withDeployPhase("build", "cli.deploy.build", {}, options.onEvent, async () => { @@ -195,10 +157,7 @@ export async function runDeploy(options: RunDeployOptions): Promise { - if (!options.deployContracts) { - options.onEvent({ - kind: "phase-skipped", - phase: "contracts", - reason: "contracts deploy not requested", - }); - return []; - } - - const contractsType: ContractsType | null = detectContractsType( - loadDetectInput(options.projectDir), - ); - if (contractsType === null) { - options.onEvent({ - kind: "phase-skipped", - phase: "contracts", - reason: "no foundry/hardhat/cdm project detected at the root", - }); - return []; - } - - return await withDeployPhase( - "contracts", - "cli.deploy.contracts", - { "cli.deploy.contracts_type": contractsType }, - options.onEvent, - async () => { - const { info: session, created } = await getOrCreateSessionAccount(); - const client = await getConnection(); - - await ensureSessionFunded({ - client, - sessionAddress: session.account.ss58Address, - userSigner: options.userSigner, - counter, - onEvent: options.onEvent, - }); - if (created) { - await submitAndWatch( - client.assetHub.tx.Revive.map_account(), - session.account.signer, - ); - await persistSessionAccount(session); - } - - const result = await runContractsPhase({ - projectDir: options.projectDir, - contractsType, - skipBuild: options.skipContractBuild, - // cdm's PipelineChainClient is a structural subset of our - // ChainClient — cast keeps the extra `individuality` field out - // of the SDK-surface type without affecting runtime behaviour. - client: client as unknown as Parameters[0]["client"], - signer: session.account.signer, - origin: session.account.ss58Address, - onEvent: (event) => options.onEvent({ kind: "contracts-event", event }), - }); - - return result.deployed; - }, - ); -} - -/** Top up the contracts session key if it's below `SESSION_MIN_BALANCE`. */ -async function ensureSessionFunded(opts: { - client: Awaited>; - sessionAddress: string; - userSigner: ResolvedSigner | null; - counter: SigningCounter; - onEvent: RunDeployOptions["onEvent"]; -}): Promise { - const emitInfo = (message: string) => - opts.onEvent({ kind: "contracts-event", event: { kind: "info", message } }); - - const balance = await checkBalance(opts.client, opts.sessionAddress, SESSION_MIN_BALANCE); - if (balance.sufficient) { - emitInfo(`session key funded (${opts.sessionAddress})`); - return; - } - - emitInfo(`funding session key ${opts.sessionAddress}…`); - - // Three-way branch based on who's funding the session key top-up: - // - // source === "session" Phone signer: user pays on-device. Wrap with - // lifecycle events so the TUI can number the tap - // and show "📱 Approve on your phone". - // - // source === "dev" Dev-with-SURI: a local keypair (--suri //Alice - // or a BIP-39 mnemonic) pretending to be the user. - // Signs immediately in-process — no human in the - // loop — so wrapping with phone-tap events would - // be misleading. Sign directly. - // - // null Pure dev mode (no --suri, no session): pick the - // first funder in the chain that has enough PAS. - // If every dev funder is drained, tell the user to - // switch to a mobile signer rather than silently - // falling back to anything that might race the drainer. - let funder: PolkadotSigner; - if (opts.userSigner?.source === "session") { - funder = wrapSignerWithEvents(opts.userSigner.signer, { - label: "Fund contract deploy session key", - counter: opts.counter, - onEvent: (event) => opts.onEvent({ kind: "signing", event }), - }); - } else if (opts.userSigner) { - // Dev-with-SURI: sign directly, no lifecycle events. - funder = opts.userSigner.signer; - } else { - const picked = await pickFunder(opts.client, SESSION_FUND_AMOUNT + FUNDER_FEE_BUFFER); - if (!picked) { - throw new Error( - `Dev account balance low. Please deploy with mobile signer. To top up funds in your mobile signer, go to the faucet at: ${FAUCET_URL}`, - ); - } - funder = picked.signer; - } - - await submitAndWatch( - opts.client.assetHub.tx.Balances.transfer_keep_alive({ - dest: Enum("Id", opts.sessionAddress), - value: SESSION_FUND_AMOUNT, - }), - funder, - ); - - emitInfo("session key funded"); -} - // ── Helpers ────────────────────────────────────────────────────────────────── /** diff --git a/src/utils/deploy/session-account.test.ts b/src/utils/deploy/session-account.test.ts deleted file mode 100644 index 0c728ed..0000000 --- a/src/utils/deploy/session-account.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - getOrCreateSessionAccount, - persistSessionAccount, - readSessionAccount, -} from "./session-account.js"; - -// ── getOrCreateSessionAccount ──────────────────────────────────────────────── - -describe("getOrCreateSessionAccount", () => { - let tmp: string; - let originalRoot: string | undefined; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "pg-session-account-")); - originalRoot = process.env.POLKADOT_ROOT; - process.env.POLKADOT_ROOT = tmp; - }); - - afterEach(() => { - if (originalRoot === undefined) { - delete process.env.POLKADOT_ROOT; - } else { - process.env.POLKADOT_ROOT = originalRoot; - } - rmSync(tmp, { recursive: true, force: true }); - }); - - it("returns created=true and a valid key on first call", async () => { - const { info, created } = await getOrCreateSessionAccount(); - - expect(created).toBe(true); - expect(info.mnemonic.split(" ").length).toBeGreaterThanOrEqual(12); - expect(info.account.ss58Address).toMatch(/^[1-9A-HJ-NP-Za-km-z]+$/); - expect(info.account.h160Address).toMatch(/^0x[0-9a-fA-F]{40}$/); - expect(typeof info.account.signer.signTx).toBe("function"); - }); - - it("does NOT write accounts.json before persistSessionAccount is called", async () => { - await getOrCreateSessionAccount(); - - const path = join(tmp, "accounts.json"); - expect(existsSync(path)).toBe(false); - }); - - it("returns created=false and existing key when file already has a key", async () => { - // Seed the file via persist on the first creation. - const first = await getOrCreateSessionAccount(); - await persistSessionAccount(first.info); - - const second = await getOrCreateSessionAccount(); - - expect(second.created).toBe(false); - expect(second.info.mnemonic).toBe(first.info.mnemonic); - expect(second.info.account.ss58Address).toBe(first.info.account.ss58Address); - }); - - it("returns created=true (new key) on retry when file was never written (map_account failed)", async () => { - // Simulate: first create, map_account throws → no persist → retry. - const first = await getOrCreateSessionAccount(); - expect(first.created).toBe(true); - - // map_account failed; we never called persistSessionAccount. - // On retry getOrCreateSessionAccount should mint a fresh key. - const retry = await getOrCreateSessionAccount(); - expect(retry.created).toBe(true); - // A fresh mnemonic is generated each time. - expect(retry.info.mnemonic).not.toBe(first.info.mnemonic); - }); - - it("ignores garbage in the store and returns a fresh key", async () => { - const path = join(tmp, "accounts.json"); - // Write garbage into the file to simulate corruption. - const { writeFileSync, mkdirSync } = await import("node:fs"); - const { dirname } = await import("node:path"); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify({ default: { not: "a string" } })); - - const { info, created } = await getOrCreateSessionAccount(); - expect(created).toBe(true); - expect(info.mnemonic.split(" ").length).toBeGreaterThanOrEqual(12); - - // The garbage file is still there; it should not have been overwritten. - const stored = JSON.parse(readFileSync(path, "utf8")); - expect(typeof stored.default).not.toBe("string"); - }); -}); - -// ── persistSessionAccount ──────────────────────────────────────────────────── - -describe("persistSessionAccount", () => { - let tmp: string; - let originalRoot: string | undefined; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "pg-session-persist-")); - originalRoot = process.env.POLKADOT_ROOT; - process.env.POLKADOT_ROOT = tmp; - }); - - afterEach(() => { - if (originalRoot === undefined) { - delete process.env.POLKADOT_ROOT; - } else { - process.env.POLKADOT_ROOT = originalRoot; - } - rmSync(tmp, { recursive: true, force: true }); - }); - - it("writes accounts.json with the mnemonic under the 'default' key", async () => { - const { info } = await getOrCreateSessionAccount(); - await persistSessionAccount(info); - - const path = join(tmp, "accounts.json"); - expect(existsSync(path)).toBe(true); - const stored = JSON.parse(readFileSync(path, "utf8")); - expect(stored.default).toBe(info.mnemonic); - }); - - it("after persist, getOrCreateSessionAccount returns created=false", async () => { - const { info } = await getOrCreateSessionAccount(); - await persistSessionAccount(info); - - const second = await getOrCreateSessionAccount(); - expect(second.created).toBe(false); - expect(second.info.mnemonic).toBe(info.mnemonic); - }); - - it("map_account-fails scenario: no persist → retry mints new key and re-maps", async () => { - // Step 1: create key in memory (map_account would be attempted here). - const attempt1 = await getOrCreateSessionAccount(); - expect(attempt1.created).toBe(true); - - // Step 2: map_account throws — do NOT call persistSessionAccount. - // (No action needed — file is untouched.) - - // Step 3: retry — should produce a fresh key with created=true. - const attempt2 = await getOrCreateSessionAccount(); - expect(attempt2.created).toBe(true); - // It's a different key, so the retry path would re-attempt map_account. - expect(attempt2.info.mnemonic).not.toBe(attempt1.info.mnemonic); - - // Step 4: map_account succeeds — now persist. - await persistSessionAccount(attempt2.info); - - // Step 5: subsequent call loads from disk. - const loaded = await getOrCreateSessionAccount(); - expect(loaded.created).toBe(false); - expect(loaded.info.mnemonic).toBe(attempt2.info.mnemonic); - }); -}); - -// ── readSessionAccount ──────────────────────────────────────────────────────── - -describe("readSessionAccount", () => { - let tmp: string; - let originalRoot: string | undefined; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "pg-session-read-")); - originalRoot = process.env.POLKADOT_ROOT; - process.env.POLKADOT_ROOT = tmp; - }); - - afterEach(() => { - if (originalRoot === undefined) { - delete process.env.POLKADOT_ROOT; - } else { - process.env.POLKADOT_ROOT = originalRoot; - } - rmSync(tmp, { recursive: true, force: true }); - }); - - it("returns null when no key is persisted", async () => { - expect(await readSessionAccount()).toBeNull(); - }); - - it("returns the persisted key without creating a new one", async () => { - const { info } = await getOrCreateSessionAccount(); - await persistSessionAccount(info); - - const loaded = await readSessionAccount(); - expect(loaded?.mnemonic).toBe(info.mnemonic); - expect(loaded?.account.ss58Address).toBe(info.account.ss58Address); - }); - - it("does not create a file when the store is empty (read-only)", async () => { - await readSessionAccount(); - const path = join(tmp, "accounts.json"); - expect(existsSync(path)).toBe(false); - }); -}); diff --git a/src/utils/deploy/session-account.ts b/src/utils/deploy/session-account.ts deleted file mode 100644 index 768a445..0000000 --- a/src/utils/deploy/session-account.ts +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * On-disk session key used to sign contracts-phase extrinsics. - * Persisted at `$POLKADOT_ROOT/accounts.json` (default `~/.polkadot/accounts.json`) - * with mode 0600 under a 0700 parent so the BIP39 phrase isn't world-readable. - */ - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, resolve } from "node:path"; -import { SessionKeyManager, type SessionKeyInfo } from "@parity/product-sdk-keys"; -import type { KvStore } from "@parity/product-sdk-storage"; - -export type { SessionKeyInfo }; - -/** 0.5 PAS — below this the contracts session key needs a top-up. */ -export const SESSION_MIN_BALANCE = 5_000_000_000n; - -/** 5 PAS — amount sent to top the session key up. */ -export const SESSION_FUND_AMOUNT = 50_000_000_000n; - -/** Root directory for playground-cli user state. Override with `$POLKADOT_ROOT`. */ -export function defaultRoot(): string { - return process.env.POLKADOT_ROOT ?? resolve(homedir(), ".polkadot"); -} - -function accountsPath(root = defaultRoot()): string { - return resolve(root, "accounts.json"); -} - -/** Filesystem-backed KvStore for SessionKeyManager. */ -class FileKvStore implements KvStore { - constructor(private readonly path: string) {} - - private readAll(): Record { - if (!existsSync(this.path)) return {}; - try { - const parsed = JSON.parse(readFileSync(this.path, "utf8")); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - const out: Record = {}; - for (const [k, v] of Object.entries(parsed)) { - if (typeof v === "string") out[k] = v; - } - return out; - } - } catch {} - return {}; - } - - private writeAll(obj: Record): void { - mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); - writeFileSync(this.path, `${JSON.stringify(obj, null, 2)}\n`, { mode: 0o600 }); - } - - async get(key: string): Promise { - return this.readAll()[key] ?? null; - } - - async set(key: string, value: string): Promise { - const obj = this.readAll(); - obj[key] = value; - this.writeAll(obj); - } - - async remove(key: string): Promise { - const obj = this.readAll(); - delete obj[key]; - this.writeAll(obj); - } - - async getJSON(key: string): Promise { - const raw = await this.get(key); - return raw === null ? null : (JSON.parse(raw) as T); - } - - async setJSON(key: string, value: unknown): Promise { - await this.set(key, JSON.stringify(value)); - } -} - -/** In-memory KvStore — used to mint a session key without touching disk. */ -class InMemoryKvStore implements KvStore { - private readonly data: Record = {}; - - async get(key: string): Promise { - return this.data[key] ?? null; - } - - async set(key: string, value: string): Promise { - this.data[key] = value; - } - - async remove(key: string): Promise { - delete this.data[key]; - } - - async getJSON(key: string): Promise { - const raw = await this.get(key); - return raw === null ? null : (JSON.parse(raw) as T); - } - - async setJSON(key: string, value: unknown): Promise { - await this.set(key, JSON.stringify(value)); - } -} - -/** Read the persisted session key; returns null on a miss (does not mint). */ -export async function readSessionAccount(): Promise { - const store = new FileKvStore(accountsPath()); - const manager = new SessionKeyManager({ store }); - return manager.get(); -} - -/** - * Load the persisted session key, or mint a fresh one in memory on first call. - * - * `created` is true only on the minting call — callers MUST: - * 1. Submit `Revive.map_account` on-chain (gated by `created === true`). - * 2. Call `persistSessionAccount(info)` ONLY AFTER the extrinsic is confirmed. - * - * Keeping persist separate from create prevents the file from recording a key - * whose on-chain mapping was never established. If `map_account` fails, the - * file is untouched so the next retry mints a fresh key and re-attempts mapping. - */ -export async function getOrCreateSessionAccount(): Promise<{ - info: SessionKeyInfo; - created: boolean; -}> { - const fileStore = new FileKvStore(accountsPath()); - const fileManager = new SessionKeyManager({ store: fileStore }); - const existing = await fileManager.get(); - if (existing) return { info: existing, created: false }; - - // Mint in memory — do NOT write to disk yet. The caller must call - // `persistSessionAccount` after `map_account` succeeds. - const memManager = new SessionKeyManager({ store: new InMemoryKvStore() }); - return { info: await memManager.create(), created: true }; -} - -/** - * Write a session key to disk. Call this ONLY after the on-chain - * `Revive.map_account` extrinsic for `info` has been confirmed. - */ -export async function persistSessionAccount(info: SessionKeyInfo): Promise { - const store = new FileKvStore(accountsPath()); - await store.set("default", info.mnemonic); -} diff --git a/src/utils/deploy/signerMode.test.ts b/src/utils/deploy/signerMode.test.ts index e1168fe..68afc73 100644 --- a/src/utils/deploy/signerMode.test.ts +++ b/src/utils/deploy/signerMode.test.ts @@ -145,73 +145,3 @@ describe("resolveSignerSetup — phone mode", () => { expect(result.publishSigner).toBe(user); }); }); - -describe("resolveSignerSetup — contracts funding", () => { - it("phone mode + session userSigner + fundingNeeded → contracts-fund is FIRST", () => { - const user = fakeSigner("session"); - const result = resolveSignerSetup({ - mode: "phone", - userSigner: user, - publishToPlayground: true, - contractsFundingNeeded: true, - }); - expect(result.approvals[0]).toEqual({ - phase: "contracts-fund", - label: "Fund contract deploy session key", - }); - // Remaining entries stay in their DotNS → playground order. - expect(result.approvals.slice(1)).toEqual([ - { phase: "dotns", label: "Reserve domain (DotNS commitment)" }, - { phase: "dotns", label: "Finalize domain (DotNS register)" }, - { phase: "dotns", label: "Link content (DotNS setContenthash)" }, - { phase: "playground", label: "Publish to Playground registry" }, - ]); - }); - - it("dev mode + session userSigner (dev-suri variant) + fundingNeeded → contracts-fund still added", () => { - // Dev mode with a session-sourced user signer is the --suri - // shape: the top-up still needs a tap. - const user = fakeSigner("session"); - const result = resolveSignerSetup({ - mode: "dev", - userSigner: user, - publishToPlayground: false, - contractsFundingNeeded: true, - }); - expect(result.approvals).toEqual([ - { phase: "contracts-fund", label: "Fund contract deploy session key" }, - ]); - }); - - it("dev-source userSigner + fundingNeeded → NO contracts-fund approval (local-key funding, no human)", () => { - const user = fakeSigner("dev"); - const result = resolveSignerSetup({ - mode: "dev", - userSigner: user, - publishToPlayground: false, - contractsFundingNeeded: true, - }); - expect(result.approvals).toEqual([]); - }); - - it("null userSigner + fundingNeeded → NO contracts-fund approval (pure-dev path)", () => { - const result = resolveSignerSetup({ - mode: "dev", - userSigner: null, - publishToPlayground: false, - contractsFundingNeeded: true, - }); - expect(result.approvals).toEqual([]); - }); - - it("contractsFundingNeeded=false never adds the contracts-fund approval", () => { - const user = fakeSigner("session"); - const result = resolveSignerSetup({ - mode: "phone", - userSigner: user, - publishToPlayground: true, - contractsFundingNeeded: false, - }); - expect(result.approvals.some((a) => a.phase === "contracts-fund")).toBe(false); - }); -}); diff --git a/src/utils/deploy/signerMode.ts b/src/utils/deploy/signerMode.ts index 1ce1a36..8a289a5 100644 --- a/src/utils/deploy/signerMode.ts +++ b/src/utils/deploy/signerMode.ts @@ -61,7 +61,7 @@ export interface DeploySignerSetup { } export interface DeployApproval { - phase: "dotns" | "playground" | "contracts-fund"; + phase: "dotns" | "playground"; label: string; } @@ -79,15 +79,6 @@ export interface ResolveOptions { * we under-estimated, so users never see "step 5 of 4" even on this path. */ plan?: DeployPlan; - /** - * Whether the contracts phase will top up its session key before deploy. - * When true and the user signer is a real phone session, the top-up - * `Balances.transfer_keep_alive` needs a phone tap — surfaced here so - * the confirm page's approval count matches what the user is about to - * experience. When the session is already funded, or the funder is a - * local dev key (pure dev mode, no session), no extra approval is added. - */ - contractsFundingNeeded?: boolean; } /** @@ -130,15 +121,6 @@ export function resolveSignerSetup(opts: ResolveOptions): DeploySignerSetup { let bulletinDeployAuthOptions: DeploySignerSetup["bulletinDeployAuthOptions"] = {}; - // Contract session-key top-up — only counts as a phone tap when the - // funder is a live session signer. Dev-suri and pure dev (Alice) - // funding happen in-process with no human in the loop. Listed FIRST so - // the numbered order on the confirm page matches the runtime firing - // order: the contracts phase runs before `storage-and-dotns` + playground. - if (opts.contractsFundingNeeded && opts.userSigner?.source === "session") { - approvals.push({ phase: "contracts-fund", label: "Fund contract deploy session key" }); - } - if (opts.mode === "phone") { if (!opts.userSigner) { throw new Error( diff --git a/src/utils/process.ts b/src/utils/process.ts index 4884dad..8dc0476 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -14,8 +14,8 @@ // limitations under the License. /** - * Streamed child-process helper. Consolidates the copies that used to live - * in `build/runner.ts`, `deploy/contracts.ts`, and `toolchain.ts`. + * Streamed child-process helper. Consolidates process spawning for build and + * toolchain operations. */ import { spawn } from "node:child_process"; diff --git a/src/utils/sessionSigner.ts b/src/utils/sessionSigner.ts index 9d80a52..b24afc8 100644 --- a/src/utils/sessionSigner.ts +++ b/src/utils/sessionSigner.ts @@ -173,7 +173,7 @@ export function createPlaygroundSessionSigner( withSignedTransaction: pjs.withSignedTransaction, }); if (result.isErr()) { - throw new Error(`Mobile signing rejected: ${result.error.message}`); + throw new Error(`Mobile signing failed: ${result.error.message}`); } const data = result.value; return { @@ -191,7 +191,7 @@ export function createPlaygroundSessionSigner( data: { tag: "Bytes", value: fromHex(payload.data as `0x${string}`) }, }); if (result.isErr()) { - throw new Error(`Mobile signing rejected: ${result.error.message}`); + throw new Error(`Mobile signing failed: ${result.error.message}`); } return { id: 0, signature: toHex(result.value.signature) }; }; diff --git a/src/utils/toolchain.ts b/src/utils/toolchain.ts index 014d044..e007dcc 100644 --- a/src/utils/toolchain.ts +++ b/src/utils/toolchain.ts @@ -77,14 +77,6 @@ async function hasCdm(): Promise { return (await commandExists("cdm")) && (await commandExists("cargo-pvm-contract")); } -async function hasFoundryPolkadot(): Promise { - // Stock `forge` lacks `--resolc`, which we need for PolkaVM codegen; - // `foundryup-polkadot` wires in the polkadot fork. - const home = homedir(); - const foundryupPolkadot = resolve(home, ".foundry/bin/foundryup-polkadot"); - return (await commandExists("forge")) && existsSync(foundryupPolkadot); -} - function isIpfsInitialized(): boolean { return existsSync(resolve(homedir(), ".ipfs")); } @@ -172,15 +164,4 @@ export const TOOL_STEPS: ToolStep[] = [ }, manualHint: "https://git-scm.com/downloads", }, - { - name: "foundry (polkadot)", - check: () => hasFoundryPolkadot(), - install: (onData) => - runPiped( - "curl -L https://raw.githubusercontent.com/paritytech/foundry-polkadot/refs/heads/master/foundryup/install | bash && $HOME/.foundry/bin/foundryup-polkadot", - onData, - ), - manualHint: - "curl -L https://raw.githubusercontent.com/paritytech/foundry-polkadot/refs/heads/master/foundryup/install | bash && ~/.foundry/bin/foundryup-polkadot", - }, ]; diff --git a/src/utils/ui/theme/PhoneApprovalCallout.tsx b/src/utils/ui/theme/PhoneApprovalCallout.tsx new file mode 100644 index 0000000..2c6a2d2 --- /dev/null +++ b/src/utils/ui/theme/PhoneApprovalCallout.tsx @@ -0,0 +1,33 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Text } from "ink"; +import { Callout } from "./Callout.js"; + +export interface PhoneApprovalCalloutProps { + step: number; + total: number; + label: string; +} + +export function PhoneApprovalCallout({ step, total, label }: PhoneApprovalCalloutProps) { + return ( + + + approve step {step} of {total}: {label} + + + ); +} diff --git a/src/utils/ui/theme/index.tsx b/src/utils/ui/theme/index.tsx index 5826f80..4ce7506 100644 --- a/src/utils/ui/theme/index.tsx +++ b/src/utils/ui/theme/index.tsx @@ -40,5 +40,9 @@ export { Select, type SelectOption, type SelectProps } from "./Select.js"; export { Input, type InputProps } from "./Input.js"; export { LogTail, type LogTailProps } from "./LogTail.js"; export { Callout } from "./Callout.js"; +export { + PhoneApprovalCallout, + type PhoneApprovalCalloutProps, +} from "./PhoneApprovalCallout.js"; export { setWindowTitle, clearWindowTitle } from "./window-title.js"; export { COLOR, GLYPH, LAYOUT, TIMING } from "./tokens.js"; diff --git a/src/utils/ui/theme/tokens.ts b/src/utils/ui/theme/tokens.ts index c806903..39f0272 100644 --- a/src/utils/ui/theme/tokens.ts +++ b/src/utils/ui/theme/tokens.ts @@ -45,6 +45,8 @@ export const GLYPH = { separator: "·", rule: "─", cursorBlock: "█", + progressEmpty: "░", + cached: "~", spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const, bars: ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const, } as const;