diff --git a/package.json b/package.json index acd5162..6e514e7 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "vitest": "^4.1.9" }, "resolutions": { - "@midnightntwrk/ledger-v9": "1.0.0-rc.3", + "@midnightntwrk/ledger-v9": "1.0.0-rc.5", "glob": "^11.0.0", "undici": "^6.24.0", "uuid": "^13.0.0" diff --git a/packages/deployer/CHANGELOG.md b/packages/deployer/CHANGELOG.md index 3135025..74a6f5a 100644 --- a/packages/deployer/CHANGELOG.md +++ b/packages/deployer/CHANGELOG.md @@ -6,13 +6,15 @@ file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 0.3.0 (2026-09-24) ### Added -- Fragmented deploy for contracts too large for one block, driven by `--circuits-per-tx` / `[contracts.X].circuits_per_tx`, with resume from chain state and a strict verify before `confirmed`. See the README's "Large contracts" section -- `DeployResult` gains `fragments` and `circuits`; both appear in `--json`, alongside a failed fragmented deploy's `address`, `circuitsOnChain`, `circuitsPending`, and `txId` -- Exit codes `7` (`BlockLimitError`: the deploy tx was refused as too large at the configured or minimum fragment size) and `8` (`FragmentDeployError`: fragmented deploy incomplete and resumable) +- `[contracts]` pattern keys, so one entry covers many contracts: a name pattern (`"Mock*"`) or a directory pattern (`"**/test/mocks/*"`) matched under the new `[profile].src_dir`. `{name}` expands to the contract name and `artifact` defaults to it. See the README's "Patterns" section (#193) +- `initialPrivateState` on `DeployerOptions` and `RunDeployOptions` sets the initial private state in code and overrides `[contracts.X].init_private_state`. A `private_state_id` with no initial private state from either source fails with exit 2, and so does an in-code state with no `private_state_id` (#193) +- `witnesses` on `DeployerOptions` and `RunDeployOptions` sets the witness implementations in code and overrides `[contracts.X].witnesses` (#193) +- `record` on `DeployerOptions` and `RunDeployOptions`, default `true`. With `false` a deploy reads and writes no deployments ledger, so it never resumes and `deploymentsFile` is `''` (#193) +- With no fragment budget, a later deploy of the same artifact on the same network starts at the size an earlier halving in the process settled on, instead of every circuit (#193) ### Changed @@ -20,8 +22,21 @@ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** signing and verifying keys are tagged `{ tag, value }`, not hex. `SigningKey.ledgerKey` gives that form and `ChainSnapshot.committee` is `SignatureVerifyingKey[]`. `signing_key_file` on disk is unchanged (#192) - yarn and pnpm need one resolution, `@midnightntwrk/ledger-v9`, down from six (#192) - `proof_server = "auto"` boots `midnightntwrk/proof-server:9.0.0-rc.6` (#192) -- `DeploymentRecord` gains a `partial` member. A single-tx deploy still writes only `pending` then `confirmed`, so an exhaustive `switch` on `status` needs a new arm only if it reads records from a split deploy -- A deploy that fits one transaction is unchanged: same transaction, same `pending` then `confirmed` records, no chain read and no maintenance transaction +- `@midnightntwrk/ledger-v9` moves from 1.0.0-rc.3 to 1.0.0-rc.5. npm now needs the pin too, as `overrides`. Update the yarn / pnpm resolution to match (#193) +- **Breaking:** `CompactConfigData.contracts` holds partial entries, which `CompactConfig.contract(name)` merges and validates. `listContracts()` returns exact keys only; `listPatterns()` returns the pattern keys (#193) + +## 0.2.0 (2026-09-15) + +### Added + +- Fragmented deploy for contracts too large for one block, driven by `--circuits-per-tx` / `[contracts.X].circuits_per_tx`, with resume from chain state and a strict verify before `confirmed`. See the README's "Large contracts" section (#174) +- `DeployResult` gains `fragments` and `circuits`; both appear in `--json`, alongside a failed fragmented deploy's `address`, `circuitsOnChain`, `circuitsPending`, and `txId` (#174) +- Exit codes `7` (`BlockLimitError`: the deploy tx was refused as too large at the configured or minimum fragment size) and `8` (`FragmentDeployError`: fragmented deploy incomplete and resumable) (#174) + +### Changed + +- `DeploymentRecord` gains a `partial` member. A single-tx deploy still writes only `pending` then `confirmed`, so an exhaustive `switch` on `status` needs a new arm only if it reads records from a split deploy (#174) +- A deploy that fits one transaction is unchanged: same transaction, same `pending` then `confirmed` records, no chain read and no maintenance transaction (#174) ## 0.1.0 (2026-09-09) diff --git a/packages/deployer/README.md b/packages/deployer/README.md index ae36e6e..05b06c6 100644 --- a/packages/deployer/README.md +++ b/packages/deployer/README.md @@ -40,7 +40,7 @@ The deployer pins one Midnight stack, and artifacts have to be compiled against | Component | Version | |---|---| | `@midnight-ntwrk/compact-runtime` | 0.19.0 | -| `@midnightntwrk/ledger-v9` | 1.0.0-rc.3 | +| `@midnightntwrk/ledger-v9` | 1.0.0-rc.5 | | `@midnight-ntwrk/compact-js` | 2.5.5-rc.8 | | `@midnight-ntwrk/midnight-js-*` | 5.0.0-beta.7 | | `@midnight-ntwrk/testkit-js` | 5.0.0-beta.7 | @@ -51,15 +51,15 @@ Compile with `compact compile +0.34.0`. An older artifact fails at submit with ` The ledger and the wallet SDK sit under the `@midnightntwrk` scope, no hyphen. Everything else keeps `@midnight-ntwrk`. -yarn and pnpm need one pin, because `compact-js` declares the ledger as a range and a second ledger copy breaks every deploy: +Every package manager needs one pin, because the stack's packages ask for different ledger versions and a second ledger copy breaks every deploy. With npm: ```json -"resolutions": { - "@midnightntwrk/ledger-v9": "1.0.0-rc.3" +"overrides": { + "@midnightntwrk/ledger-v9": "1.0.0-rc.5" } ``` -(`pnpm.overrides` for pnpm.) Nothing else in the stack floats. +yarn takes the same entry under `resolutions`, and pnpm under `pnpm.overrides`. Nothing else in the stack floats. ## CLI @@ -96,6 +96,7 @@ The node rejects a deploy tx above the per-block extrinsic limit with `1010: Inv - Left unset, the deployer submits the largest batch it can and halves on a refusal, down to a single circuit. There is no pre-flight weight check. - Set at or above the circuit count, it pins a single-tx deploy and disables halving: a refusal is exit 7, never a split. +- A size found by halving is remembered in memory: later deploys of the same artifact on the same network, in the same process, start there. A set budget and a resume ignore it. - Fragments are ordered by sorted circuit name, so a rerun rebuilds the same plan. - One `deploy()` call does the deploy, every insert, and a byte-for-byte check of every on-chain key against the artifact. Only that check writes `confirmed`. - Between fragment 0 landing and the last insert the contract is live with a subset of its circuits, ordered by name rather than by dependency. Keep every circuit safe to call alone, or hold traffic until the deploy confirms. @@ -228,6 +229,28 @@ circuits_per_tx = 8 `"auto"` needs Docker and boots the `proof-server.yml` shipped in this package, pinning `midnightntwrk/proof-server:9.0.0-rc.6`. To boot a different image, put your own `proof-server.yml` in the directory you run `compact-deploy` from; it wins over the packaged one. +### Patterns + +One `[contracts]` entry can cover many contracts. A key is one of: + +- `Token`: exact, that contract only. +- `"Mock*"`: a name pattern (holds `*`, `?`, `[` or `{`), matched against the contract name. +- `"**/test/mocks/*"`: a directory pattern (holds `/`), matched against the contract's source path under `[profile].src_dir`, `.compact` included. The source of `` is the unique `/**/.compact`, skipping `node_modules` and dot-directories. A second match is an error. + +Every matching pattern applies in file order, a later one overriding an earlier one field by field. The exact entry applies last. `{name}` in any string value expands to the contract name, and `artifact` defaults to it. `runDeploy(Contract)` searches exact keys only. + +```toml +[profile] +src_dir = "contracts/src" + +[contracts."**/test/mocks/*"] +signing_key_file = "./deploy/{name}.signingkey" + +# Inherits signing_key_file from the pattern above. +[contracts.MockToken] +args = ["MyToken", "MTK", 18] +``` + ## Keystore format An Ethereum V3 JSON keystore (scrypt + AES-128-CTR) tagged `version: "midnight-1"`, so other tooling does not mis-read it as an Ethereum key. The encrypted secret is a 32-byte Midnight wallet seed (hex). @@ -246,7 +269,7 @@ An Ethereum V3 JSON keystore (scrypt + AES-128-CTR) tagged `version: "midnight-1 6. **The root `@midnightntwrk/ledger-v9` resolution is load-bearing.** `compact-js` declares it as a range, so without the pin yarn nests a second ledger copy and deploys fail. Do not drop it on a bump. -7. **`@midnight-ntwrk/compact-runtime` resolves to two copies**, `0.19.0-rc.0` under `compact-js` and `midnight-js-protocol` against `0.19.0` everywhere else. The integration suite deploys on that tree, including the pruned constructor path through `compact-js`. Forcing one copy is untested. +7. **`@midnight-ntwrk/compact-runtime` resolves to two copies**, `0.19.0-rc.0` under `compact-js` and `midnight-js-protocol` against `0.19.0` everywhere else. The integration suite deploys on that tree, including the pruned constructor path through `compact-js`. Forcing one `0.19.0` copy with a resolution also deploys, the split path included. ## Programmatic API @@ -293,3 +316,24 @@ console.log(result.address); ``` Every option has a `process.argv` default (`--network`, `--config`, `--dry-run`, …), so the same script works with flags at the call site. On failure `runDeploy` sets `process.exitCode` and rethrows; it never calls `process.exit`, so catch it if you want that code to reach the shell. + +Three options suit a caller that deploys many contracts from code, such as a test harness. `Deployer.prepare` and `runDeploy` both take them, with no argv flag: + +- `initialPrivateState` and `witnesses` override `[contracts.X].init_private_state` and `[contracts.X].witnesses`. A `private_state_id` and an initial private state come together: `prepare` throws `ConfigError` when one lacks the other. +- `record: false` skips the deployments ledger. Nothing is read or written, so every deploy is fresh and `deploymentsFile` is `''`. + +```ts +import { Deployer } from "@openzeppelin/compact-deployer/deployer"; + +await using deployer = await Deployer.prepare({ + contract: "MockToken", + network: "local", + logger, + walletProvider, // one wallet shared across deploys + privateStateProvider, // e.g. testkit-js inMemoryPrivateStateProvider() + initialPrivateState: { secretKey }, + witnesses, + record: false, +}); +const { address } = await deployer.deploy(); +``` diff --git a/packages/deployer/package.json b/packages/deployer/package.json index 203da8d..033cb71 100644 --- a/packages/deployer/package.json +++ b/packages/deployer/package.json @@ -117,7 +117,7 @@ "@midnight-ntwrk/midnight-js-types": "5.0.0-beta.7", "@midnight-ntwrk/midnight-js-utils": "5.0.0-beta.7", "@midnight-ntwrk/testkit-js": "5.0.0-beta.7", - "@midnightntwrk/ledger-v9": "1.0.0-rc.3", + "@midnightntwrk/ledger-v9": "1.0.0-rc.5", "@midnightntwrk/wallet-sdk-address-format": "4.0.0-beta.2", "@midnightntwrk/wallet-sdk-capabilities": "4.0.0-beta.2", "@midnightntwrk/wallet-sdk-dust-wallet": "5.0.0-beta.2", diff --git a/packages/deployer/src/config/compact-config.test.ts b/packages/deployer/src/config/compact-config.test.ts index 2ef37c6..2cd080b 100644 --- a/packages/deployer/src/config/compact-config.test.ts +++ b/packages/deployer/src/config/compact-config.test.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { ConfigError } from '../errors.ts'; import { CompactConfig } from './compact-config.ts'; @@ -150,3 +150,246 @@ signing_key_file = "./deploy/Vault.signingkey" ); }); }); + +describe('CompactConfig — patterns', () => { + // MIN_VALID with `src_dir = "contracts"`. + const WITH_SRC_DIR = MIN_VALID.replace( + 'default_network = "local"', + 'default_network = "local"\nsrc_dir = "contracts"', + ); + + function addSources(dir: string, files: string[]): void { + for (const file of files) { + mkdirSync(dirname(join(dir, 'contracts', file)), { recursive: true }); + writeFileSync(join(dir, 'contracts', file), ''); + } + } + + it('should resolve a contract that only a name pattern matches', async () => { + const dir = tmpRepo(`${MIN_VALID} +[contracts."Mock*"] +signing_key_file = "./deploy/{name}.signingkey" +`); + const config = await CompactConfig.load(undefined, dir); + expect(config.contract('MockToken')).toEqual({ + artifact: 'MockToken', + signing_key_file: './deploy/MockToken.signingkey', + }); + expect(config.hasContract('MockToken')).toBe(true); + expect(config.hasContract('Vault')).toBe(false); + }); + + it('should resolve a contract through a directory pattern on its source path', async () => { + const dir = tmpRepo(`${WITH_SRC_DIR} +[contracts."**/test/mocks/*"] +signing_key_file = "./deploy/mock.signingkey" +`); + addSources(dir, [ + 'token/test/mocks/MockToken.compact', + 'token/FungibleToken.compact', + ]); + const config = await CompactConfig.load(undefined, dir); + expect(config.contract('MockToken').signing_key_file).toBe( + './deploy/mock.signingkey', + ); + expect(() => config.contract('FungibleToken')).toThrow( + /^Contract "FungibleToken" not defined\. Available: Token, "\*\*\/test\/mocks\/\*"$/, + ); + }); + + it('should apply matching patterns in file order, whatever their kind', async () => { + const byName = ` +[contracts."Mock*"] +signing_key_file = "name.sk" +`; + const byDirectory = ` +[contracts."mocks/*"] +signing_key_file = "directory.sk" +private_state_id = "directoryState" +`; + const load = async (patterns: string) => { + const dir = tmpRepo(`${WITH_SRC_DIR}${patterns}`); + addSources(dir, ['mocks/MockToken.compact']); + return (await CompactConfig.load(undefined, dir)).contract('MockToken'); + }; + expect(await load(byDirectory + byName)).toEqual({ + artifact: 'MockToken', + signing_key_file: 'name.sk', + private_state_id: 'directoryState', + }); + expect((await load(byName + byDirectory)).signing_key_file).toBe( + 'directory.sk', + ); + }); + + it('should apply the exact entry last and fill its gaps from patterns', async () => { + const dir = tmpRepo(`${MIN_VALID} +[contracts."*"] +signing_key_file = "./deploy/{name}.signingkey" +args = [1] + +[contracts.Vault] +args = [2] +`); + const config = await CompactConfig.load(undefined, dir); + expect(config.contract('Token')).toEqual({ + artifact: 'src/artifacts/Token/Token', + signing_key_file: './deploy/Token.signingkey', + args: [1], + }); + expect(config.contract('Vault')).toEqual({ + artifact: 'Vault', + signing_key_file: './deploy/Vault.signingkey', + args: [2], + }); + }); + + it('should expand {name} in refs and private state fields', async () => { + const dir = tmpRepo(`${MIN_VALID} +[contracts."Mock*"] +signing_key_file = "./deploy/mock.signingkey" +private_state_id = "{name}State" +init_private_state = { file = "./state/{name}.json" } +witnesses = { module = "./witnesses/{name}.ts", export = "{name}Witnesses" } +`); + const config = await CompactConfig.load(undefined, dir); + expect(config.contract('MockToken')).toEqual({ + artifact: 'MockToken', + signing_key_file: './deploy/mock.signingkey', + private_state_id: 'MockTokenState', + init_private_state: { file: './state/MockToken.json' }, + witnesses: { + module: './witnesses/MockToken.ts', + export: 'MockTokenWitnesses', + }, + }); + }); + + it('should accept private_state_id without init_private_state', async () => { + const dir = tmpRepo(`${MIN_VALID} +[contracts.Vault] +signing_key_file = "./deploy/Vault.signingkey" +private_state_id = "vaultState" +`); + const config = await CompactConfig.load(undefined, dir); + expect(config.contract('Vault').private_state_id).toBe('vaultState'); + expect(config.contract('Vault').init_private_state).toBeUndefined(); + }); + + it('should reject a pattern-resolved init_private_state without private_state_id', async () => { + const dir = tmpRepo(`${MIN_VALID} +[contracts."Mock*"] +signing_key_file = "./deploy/mock.signingkey" +init_private_state = { file = "./state/{name}.json" } +`); + const config = await CompactConfig.load(undefined, dir); + expect(() => config.contract('MockToken')).toThrow(ConfigError); + expect(() => config.contract('MockToken')).toThrow( + /contracts\.MockToken: init_private_state needs private_state_id/, + ); + }); + + it('should reject a pattern-resolved contract missing signing_key_file', async () => { + const dir = tmpRepo(`${MIN_VALID} +[contracts."Mock*"] +args = [] +`); + const config = await CompactConfig.load(undefined, dir); + expect(() => config.contract('MockToken')).toThrow( + 'compact.toml validation failed for contract "MockToken" (from "Mock*"):\n - contracts.MockToken.signing_key_file: Required', + ); + }); + + it('should reject a directory pattern when src_dir is unset', async () => { + const dir = tmpRepo(`${MIN_VALID} +[contracts."**/mocks/*"] +signing_key_file = "./deploy/mock.signingkey" +`); + await expect(CompactConfig.load(undefined, dir)).rejects.toThrow( + ConfigError, + ); + await expect(CompactConfig.load(undefined, dir)).rejects.toThrow( + /profile\.src_dir: required by the directory pattern "\*\*\/mocks\/\*"/, + ); + }); + + it('should reject a src_dir that is not a directory', async () => { + const dir = tmpRepo(`${WITH_SRC_DIR} +[contracts."**/mocks/*"] +signing_key_file = "./deploy/mock.signingkey" +`); + await expect(CompactConfig.load(undefined, dir)).rejects.toThrow( + `[profile].src_dir is not a directory: ${join(dir, 'contracts')}`, + ); + }); + + it('should not read src_dir without a directory pattern', async () => { + const dir = tmpRepo(`${WITH_SRC_DIR} +[contracts."Mock*"] +signing_key_file = "./deploy/mock.signingkey" +`); + const config = await CompactConfig.load(undefined, dir); + expect(config.contract('MockToken').artifact).toBe('MockToken'); + }); + + it('should throw ConfigError naming both sources of an ambiguous contract', async () => { + const dir = tmpRepo(`${WITH_SRC_DIR} +[contracts."**/mocks/*"] +signing_key_file = "./deploy/mock.signingkey" +`); + addSources(dir, ['a/mocks/MockToken.compact', 'b/mocks/MockToken.compact']); + const config = await CompactConfig.load(undefined, dir); + expect(() => config.contract('MockToken')).toThrow(ConfigError); + expect(() => config.contract('MockToken')).toThrow( + `Contract "MockToken" matches 2 sources under ${join(dir, 'contracts')}: a/mocks/MockToken.compact, b/mocks/MockToken.compact`, + ); + }); + + it('should let an exact entry inherit from a directory pattern', async () => { + const dir = tmpRepo(`${WITH_SRC_DIR} +[contracts."**/mocks/*"] +signing_key_file = "./deploy/mock.signingkey" + +[contracts.MockVault] +args = [] +`); + addSources(dir, ['mocks/MockVault.compact']); + const config = await CompactConfig.load(undefined, dir); + expect(config.contract('MockVault')).toEqual({ + artifact: 'MockVault', + signing_key_file: './deploy/mock.signingkey', + args: [], + }); + }); + + it('should validate exact entries at load when a directory pattern exists', async () => { + const dir = tmpRepo(`${WITH_SRC_DIR} +[contracts."**/mocks/*"] +signing_key_file = "./deploy/mock.signingkey" + +[contracts.MockVault] +args = [] +`); + addSources(dir, ['vault/MockVault.compact']); + await expect(CompactConfig.load(undefined, dir)).rejects.toThrow( + /contracts\.MockVault\.signing_key_file: Required/, + ); + }); + + it('should list patterns apart from exact names and name both on a miss', async () => { + const dir = tmpRepo(`${WITH_SRC_DIR} +[contracts."Mock*"] +signing_key_file = "./deploy/mock.signingkey" + +[contracts."**/mocks/*"] +signing_key_file = "./deploy/mock.signingkey" +`); + addSources(dir, ['mocks/MockToken.compact']); + const config = await CompactConfig.load(undefined, dir); + expect(config.listContracts()).toEqual(['Token']); + expect(config.listPatterns()).toEqual(['Mock*', '**/mocks/*']); + expect(() => config.contract('Vault')).toThrow( + `Contract "Vault" not defined. Available: Token, "Mock*", "**/mocks/*" (no Vault.compact under ${join(dir, 'contracts')})`, + ); + }); +}); diff --git a/packages/deployer/src/config/compact-config.ts b/packages/deployer/src/config/compact-config.ts index 7696fbc..499aa53 100644 --- a/packages/deployer/src/config/compact-config.ts +++ b/packages/deployer/src/config/compact-config.ts @@ -2,11 +2,21 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { dirname, isAbsolute, resolve } from 'node:path'; import { parse as parseToml } from 'smol-toml'; +import type { ZodIssue } from 'zod'; import { ConfigError } from '../errors.ts'; +import { + indexSources, + keyKind, + type ResolvedEntry, + resolveEntry, + type SourceIndex, + sourceOf, +} from './patterns.ts'; import { type CompactConfigData, type ContractConfig, configSchema, + contractSchema, type NetworkConfig, type WalletConfig, } from './schema.ts'; @@ -27,11 +37,19 @@ export class CompactConfig { */ readonly rootDir: string; readonly #data: CompactConfigData; - - private constructor(data: CompactConfigData, configPath: string) { + /** Set when a directory pattern exists. */ + readonly #sources: SourceIndex | undefined; + readonly #resolved = new Map(); + + private constructor( + data: CompactConfigData, + configPath: string, + sources: SourceIndex | undefined, + ) { this.#data = data; this.configPath = configPath; this.rootDir = dirname(configPath); + this.#sources = sources; } /** Walks up from `cwd` Foundry-style when `explicitPath` is omitted. */ @@ -68,19 +86,24 @@ export class CompactConfig { const result = configSchema.safeParse(parsed); if (!result.success) { - const issues = result.error.issues - .map((i) => { - // A TOML document always parses to a table, so an issue can - // never land at the empty root path. - /* v8 ignore next */ - const at = i.path.join('.') || '(root)'; - return ` - ${at}: ${i.message}`; - }) - .join('\n'); - throw new ConfigError(`compact.toml validation failed:\n${issues}`); + throw new ConfigError( + `compact.toml validation failed:\n${formatIssues(result.error.issues)}`, + ); } - return new CompactConfig(result.data, configPath); + const { contracts, profile } = result.data; + const sources = + profile.src_dir !== undefined && + Object.keys(contracts).some((key) => keyKind(key) === 'directory') + ? await indexSources(resolve(dirname(configPath), profile.src_dir)) + : undefined; + const config = new CompactConfig(result.data, configPath, sources); + // With a directory pattern, `configSchema` cannot check exact entries: + // whether the pattern applies needs the source index. + if (sources) { + for (const name of config.listContracts()) config.contract(name); + } + return config; } get defaultNetwork(): string | undefined { @@ -103,16 +126,27 @@ export class CompactConfig { return Object.hasOwn(this.#data.networks, name); } + /** Whether any `[contracts]` key, exact or pattern, applies to `name`. */ hasContract(name: string): boolean { - return Object.hasOwn(this.#data.contracts, name); + return this.#resolve(name) !== undefined; } listNetworks(): string[] { return Object.keys(this.#data.networks); } + /** Names with an exact `[contracts.X]` key. */ listContracts(): string[] { - return Object.keys(this.#data.contracts); + return Object.keys(this.#data.contracts).filter( + (key) => keyKind(key) === 'exact', + ); + } + + /** Name and directory pattern keys, in file order. */ + listPatterns(): string[] { + return Object.keys(this.#data.contracts).filter( + (key) => keyKind(key) !== 'exact', + ); } network(name: string): NetworkConfig { @@ -125,15 +159,54 @@ export class CompactConfig { return n; } + /** Merges every `[contracts]` key that applies to `name`, then validates the result. */ contract(name: string): ContractConfig { - const c = this.#data.contracts[name]; - if (!c) { + const cached = this.#resolved.get(name); + if (cached) return cached; + const resolved = this.#resolve(name); + if (!resolved) { + const available = [ + ...this.listContracts(), + ...this.listPatterns().map((key) => `"${key}"`), + ].join(', '); + const noSource = + this.#sources && !this.#sources.byName.has(name) + ? ` (no ${name}.compact under ${this.#sources.dir})` + : ''; throw new ConfigError( - `Contract "${name}" not defined. Available: ${this.listContracts().join(', ')}`, + `Contract "${name}" not defined. Available: ${available}${noSource}`, ); } - return c; + const result = contractSchema.safeParse(resolved.entry); + if (!result.success) { + const keys = resolved.keys.map((key) => `"${key}"`).join(', '); + throw new ConfigError( + `compact.toml validation failed for contract "${name}" (from ${keys}):\n${formatIssues(result.error.issues, ['contracts', name])}`, + ); + } + this.#resolved.set(name, result.data); + return result.data; } + + #resolve(name: string): ResolvedEntry | undefined { + const source = this.#sources && sourceOf(name, this.#sources); + return resolveEntry(name, this.#data.contracts, source); + } +} + +function formatIssues( + issues: readonly ZodIssue[], + prefix: readonly (string | number)[] = [], +): string { + return issues + .map((i) => { + // A TOML document always parses to a table, so an issue can + // never land at the empty root path. + /* v8 ignore next */ + const at = [...prefix, ...i.path].join('.') || '(root)'; + return ` - ${at}: ${i.message}`; + }) + .join('\n'); } function resolveExplicit(p: string, cwd: string): string { diff --git a/packages/deployer/src/config/patterns.test.ts b/packages/deployer/src/config/patterns.test.ts new file mode 100644 index 0000000..0bedfb0 --- /dev/null +++ b/packages/deployer/src/config/patterns.test.ts @@ -0,0 +1,213 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ConfigError } from '../errors.ts'; +import { indexSources, keyKind, resolveEntry, sourceOf } from './patterns.ts'; +import type { ContractEntry } from './schema.ts'; + +function srcTree(files: string[]): string { + const dir = mkdtempSync(join(tmpdir(), 'compact-src-')); + for (const file of files) { + mkdirSync(dirname(join(dir, file)), { recursive: true }); + writeFileSync(join(dir, file), ''); + } + return dir; +} + +describe('keyKind', () => { + it.each(['Token', 'my-token', 'Token.v2'])( + 'should classify %s as exact', + (key) => { + expect(keyKind(key)).toBe('exact'); + }, + ); + + it.each(['Mock*', 'Mock?', 'Mock[AB]', '{Token,Vault}'])( + 'should classify %s as a name pattern', + (key) => { + expect(keyKind(key)).toBe('name'); + }, + ); + + it.each(['multisig/**', '**/test/mocks/*', 'token/Token.compact'])( + 'should classify %s as a directory pattern', + (key) => { + expect(keyKind(key)).toBe('directory'); + }, + ); +}); + +describe('resolveEntry', () => { + it('should return undefined when no key applies', () => { + expect( + resolveEntry('Vault', { Token: {}, 'Mock*': {} }, undefined), + ).toBeUndefined(); + }); + + it('should match a name pattern against the contract name', () => { + const contracts = { 'Mock*': {}, 'token/*': {} }; + expect( + resolveEntry('MockToken', contracts, 'access/MockToken.compact')?.keys, + ).toEqual(['Mock*']); + }); + + it('should match a directory pattern against the source path, extension included', () => { + const contracts = { + '**/mocks/*.compact': {}, + 'mocks/**': {}, + '**/*.ts': {}, + }; + expect( + resolveEntry('MockToken', contracts, 'token/mocks/MockToken.compact') + ?.keys, + ).toEqual(['**/mocks/*.compact']); + }); + + it('should ignore a leading ./ on a directory pattern', () => { + expect( + resolveEntry( + 'MockToken', + { './token/**': {} }, + 'token/mocks/MockToken.compact', + )?.keys, + ).toEqual(['./token/**']); + }); + + it('should skip directory patterns for a contract with no source', () => { + expect( + resolveEntry('MockToken', { '**/*': {} }, undefined), + ).toBeUndefined(); + }); + + it('should apply patterns in file order and the exact key last', () => { + const contracts: Record = { + Token: { args: [3] }, + '*': { signing_key_file: 'a.sk', args: [1], private_state_id: 'a' }, + 'Tok*': { signing_key_file: 'b.sk', args: [2] }, + }; + const resolved = resolveEntry('Token', contracts, undefined); + expect(resolved?.keys).toEqual(['*', 'Tok*', 'Token']); + expect(resolved?.entry).toEqual({ + artifact: 'Token', + signing_key_file: 'b.sk', + args: [3], + private_state_id: 'a', + }); + }); + + it('should replace a ref field whole', () => { + const resolved = resolveEntry( + 'Token', + { + '*': { witnesses: { module: 'witnesses.ts', export: 'witnesses' } }, + Token: { witnesses: { file: 'witnesses.json' } }, + }, + undefined, + ); + expect(resolved?.entry.witnesses).toEqual({ file: 'witnesses.json' }); + }); + + it('should never apply a pattern key as the exact key', () => { + expect( + resolveEntry('Mock*', { 'Mock*': { args: [1] } }, undefined)?.keys, + ).toEqual(['Mock*']); + }); + + it('should default artifact to the contract name', () => { + expect(resolveEntry('Token', { Token: {} }, undefined)?.entry).toEqual({ + artifact: 'Token', + }); + expect( + resolveEntry('Token', { Token: { artifact: 'out/Token' } }, undefined) + ?.entry.artifact, + ).toBe('out/Token'); + }); + + it('should expand {name} in every string value', () => { + const genesis = new Date(0); + const entry = resolveEntry( + 'Token', + { + '*': { + artifact: 'build/{name}', + signing_key_file: 'keys/{name}.sk', + private_state_id: '{name}State', + private_state_store_name: '{name}-store', + init_private_state: { file: 'state/{name}.json' }, + witnesses: { + module: 'witnesses/{name}.ts', + export: '{name}Witnesses', + }, + args: ['{name}', 18, { label: '{name}/{name}' }, genesis], + }, + }, + undefined, + )?.entry; + expect(entry).toEqual({ + artifact: 'build/Token', + signing_key_file: 'keys/Token.sk', + private_state_id: 'TokenState', + private_state_store_name: 'Token-store', + init_private_state: { file: 'state/Token.json' }, + witnesses: { module: 'witnesses/Token.ts', export: 'TokenWitnesses' }, + args: ['Token', 18, { label: 'Token/Token' }, genesis], + }); + expect((entry?.args as unknown[] | undefined)?.[3]).toBe(genesis); + }); + + it('should leave the input entries untouched', () => { + const contracts = { + '*': { signing_key_file: '{name}.sk', args: ['{name}'] }, + }; + resolveEntry('Token', contracts, undefined); + expect(contracts['*']).toEqual({ + signing_key_file: '{name}.sk', + args: ['{name}'], + }); + }); +}); + +describe('indexSources / sourceOf', () => { + it('should find a source by contract name at any depth', async () => { + const index = await indexSources( + srcTree(['token/test/mocks/MockToken.compact', 'Top.compact']), + ); + expect(sourceOf('MockToken', index)).toBe( + 'token/test/mocks/MockToken.compact', + ); + expect(sourceOf('Top', index)).toBe('Top.compact'); + expect(sourceOf('Vault', index)).toBeUndefined(); + }); + + it('should index only .compact files outside node_modules and dot-directories', async () => { + const index = await indexSources( + srcTree([ + 'token/Token.compact', + 'node_modules/dep/Dep.compact', + 'token/node_modules/Nested.compact', + '.cache/Hidden.compact', + 'Vault.compact.bak', + 'Vault.ts', + ]), + ); + expect([...index.byName]).toEqual([['Token', ['token/Token.compact']]]); + }); + + it('should throw ConfigError naming every source of an ambiguous name', async () => { + const dir = srcTree(['b/Token.compact', 'a/Token.compact']); + const index = await indexSources(dir); + expect(() => sourceOf('Token', index)).toThrow(ConfigError); + expect(() => sourceOf('Token', index)).toThrow( + `Contract "Token" matches 2 sources under ${dir}: a/Token.compact, b/Token.compact`, + ); + }); + + it('should throw ConfigError when the directory does not exist', async () => { + const missing = join(tmpdir(), `no-compact-src-${Date.now()}`); + await expect(indexSources(missing)).rejects.toThrow(ConfigError); + await expect(indexSources(missing)).rejects.toThrow( + `[profile].src_dir is not a directory: ${missing}`, + ); + }); +}); diff --git a/packages/deployer/src/config/patterns.ts b/packages/deployer/src/config/patterns.ts new file mode 100644 index 0000000..54ff37d --- /dev/null +++ b/packages/deployer/src/config/patterns.ts @@ -0,0 +1,101 @@ +import { glob, stat } from 'node:fs/promises'; +import { posix, sep } from 'node:path'; +import { ConfigError } from '../errors.ts'; +import type { ContractEntry } from './schema.ts'; + +export type KeyKind = 'exact' | 'name' | 'directory'; + +/** Every `[contracts]` key that applies to a contract, and their merged entry. */ +export interface ResolvedEntry { + keys: string[]; + entry: ContractEntry; +} + +/** `.compact` source paths under `dir`, relative and `/`-separated, by contract name. */ +export interface SourceIndex { + dir: string; + byName: ReadonlyMap; +} + +/** A key holding `/` is a directory pattern, one holding `*`, `?`, `[` or `{` a name pattern. */ +export function keyKind(key: string): KeyKind { + if (key.includes('/')) return 'directory'; + return /[*?[{]/.test(key) ? 'name' : 'exact'; +} + +/** Merges matching patterns in file order, then the exact key; a later key wins field by field. */ +export function resolveEntry( + name: string, + contracts: Readonly>, + source: string | undefined, +): ResolvedEntry | undefined { + const all = Object.keys(contracts); + const patterns = all.filter((key) => { + switch (keyKind(key)) { + case 'name': + return posix.matchesGlob(name, key); + case 'directory': + return ( + source !== undefined && + posix.matchesGlob(source, key.replace(/^(\.\/)+/, '')) + ); + default: + return false; + } + }); + const exact = all.filter((key) => key === name && keyKind(key) === 'exact'); + const keys = [...patterns, ...exact]; + if (keys.length === 0) return undefined; + const merged: ContractEntry = Object.assign( + { artifact: name }, + ...keys.map((key) => contracts[key]), + ); + return { keys, entry: expandName(merged, name) as ContractEntry }; +} + +/** Indexes the `.compact` sources under `dir`, skipping `node_modules` and dot-directories. */ +export async function indexSources(dir: string): Promise { + const isDir = await stat(dir).then( + (s) => s.isDirectory(), + () => false, + ); + if (!isDir) { + throw new ConfigError(`[profile].src_dir is not a directory: ${dir}`); + } + const byName = new Map(); + for await (const file of glob('**/*.compact', { + cwd: dir, + exclude: ['**/node_modules'], + })) { + const path = file.split(sep).join('/'); + const name = posix.basename(path, '.compact'); + byName.set(name, [...(byName.get(name) ?? []), path]); + } + return { dir, byName }; +} + +/** The unique source of `name`, if any; several throw a {@link ConfigError}. */ +export function sourceOf(name: string, index: SourceIndex): string | undefined { + const paths = index.byName.get(name) ?? []; + if (paths.length > 1) { + throw new ConfigError( + `Contract "${name}" matches ${paths.length} sources under ${index.dir}: ${[...paths].sort().join(', ')}`, + ); + } + return paths[0]; +} + +function expandName(value: unknown, name: string): unknown { + if (typeof value === 'string') return value.replaceAll('{name}', name); + if (Array.isArray(value)) return value.map((v) => expandName(v, name)); + if ( + typeof value === 'object' && + value !== null && + Object.getPrototypeOf(value) === Object.prototype + ) { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, expandName(v, name)]), + ); + } + return value; +} diff --git a/packages/deployer/src/config/schema.test.ts b/packages/deployer/src/config/schema.test.ts index 7a16980..2014ab6 100644 --- a/packages/deployer/src/config/schema.test.ts +++ b/packages/deployer/src/config/schema.test.ts @@ -199,15 +199,15 @@ describe('configSchema — contract refine (private state pairing)', () => { expect(() => configSchema.parse(baseConfig)).not.toThrow(); }); - it('should reject private_state_id without init_private_state', () => { - expect(() => - configSchema.parse({ - ...baseConfig, - contracts: { - Counter: { ...validContract, private_state_id: 'counter-ps' }, - }, - }), - ).toThrow(/private_state_id and init_private_state must be set together/); + it('should accept private_state_id without init_private_state', () => { + const parsed = configSchema.parse({ + ...baseConfig, + contracts: { + Counter: { ...validContract, private_state_id: 'counter-ps' }, + }, + }); + expect(parsed.contracts.Counter?.private_state_id).toBe('counter-ps'); + expect(parsed.contracts.Counter?.init_private_state).toBeUndefined(); }); it('should reject init_private_state without private_state_id', () => { @@ -221,7 +221,7 @@ describe('configSchema — contract refine (private state pairing)', () => { }, }, }), - ).toThrow(/private_state_id and init_private_state must be set together/); + ).toThrow(/init_private_state needs private_state_id/); }); }); @@ -337,3 +337,106 @@ describe('circuits_per_tx', () => { ).toThrow(); }); }); + +describe('configSchema — patterns', () => { + it('should accept an optional src_dir', () => { + expect(configSchema.parse(baseConfig).profile.src_dir).toBeUndefined(); + const parsed = configSchema.parse({ + ...baseConfig, + profile: { src_dir: 'contracts/src' }, + }); + expect(parsed.profile.src_dir).toBe('contracts/src'); + }); + + it('should accept a pattern entry with every field omitted', () => { + const parsed = configSchema.parse({ + ...baseConfig, + contracts: { ...baseConfig.contracts, 'Mock*': {} }, + }); + expect(parsed.contracts['Mock*']).toEqual({}); + }); + + it('should validate the fields a pattern entry sets', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + contracts: { 'Mock*': { circuits_per_tx: 0 } }, + }), + ).toThrow(); + }); + + it('should let an exact entry take signing_key_file from a name pattern', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + contracts: { + '*': { signing_key_file: 'keys/{name}.sk' }, + Counter: { args: [] }, + }, + }), + ).not.toThrow(); + }); + + it('should accept an exact entry without artifact', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + contracts: { Counter: { signing_key_file: 'keys/counter.signing' } }, + }), + ).not.toThrow(); + }); + + it('should report a merged exact entry missing signing_key_file at its path', () => { + const result = configSchema.safeParse({ + ...baseConfig, + contracts: { 'Mock*': { signing_key_file: 'mock.sk' }, Counter: {} }, + }); + expect(result.success).toBe(false); + expect(result.error?.issues.map((i) => i.path)).toEqual([ + ['contracts', 'Counter', 'signing_key_file'], + ]); + }); + + it('should reject a pattern init_private_state when the merged entry has no private_state_id', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + contracts: { + '*': { init_private_state: { file: 'state.json' } }, + Counter: validContract, + }, + }), + ).toThrow(/init_private_state needs private_state_id/); + }); + + it('should report a bad contract field once', () => { + const result = configSchema.safeParse({ + ...baseConfig, + contracts: { Counter: { ...validContract, circuits_per_tx: 0 } }, + }); + expect(result.error?.issues).toHaveLength(1); + }); + + it('should reject a directory pattern without src_dir', () => { + const result = configSchema.safeParse({ + ...baseConfig, + contracts: { 'test/mocks/*': { signing_key_file: 'mock.sk' } }, + }); + expect(result.error?.issues).toEqual([ + expect.objectContaining({ + path: ['profile', 'src_dir'], + message: 'required by the directory pattern "test/mocks/*"', + }), + ]); + }); + + it('should leave exact entries unchecked when a directory pattern exists', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + profile: { src_dir: 'src' }, + contracts: { 'test/mocks/*': {}, Counter: {} }, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/deployer/src/config/schema.ts b/packages/deployer/src/config/schema.ts index b865251..e9e0671 100644 --- a/packages/deployer/src/config/schema.ts +++ b/packages/deployer/src/config/schema.ts @@ -1,10 +1,11 @@ /** * Zod schema for `compact.toml`. Cross-field rules: `profile.default_network` - * must name a defined `[networks.X]`; `private_state_id` and - * `init_private_state` are both-or-neither. + * must name a defined `[networks.X]`; `init_private_state` needs + * `private_state_id`; a directory pattern needs `profile.src_dir`. */ import { z } from 'zod'; +import { keyKind, resolveEntry } from './patterns.ts'; const url = z.string().url(); @@ -13,6 +14,8 @@ const profileSchema = z default_network: z.string().optional(), artifacts_dir: z.string().default('src/artifacts'), deployments_dir: z.string().default('deployments/compact'), + // Directory patterns in `[contracts]` match source paths under it. + src_dir: z.string().optional(), }) .default({}); @@ -59,37 +62,39 @@ const fileOrModuleRefSchema = z.union([fileRefSchema, moduleRefSchema]); const argsSchema = z.union([z.array(z.unknown()), fileOrModuleRefSchema]); -const contractSchema = z - .object({ - artifact: z.string().min(1), - private_state_id: z.string().optional(), - init_private_state: fileOrModuleRefSchema.optional(), - private_state_store_name: z.string().optional(), - args: argsSchema.optional(), - witnesses: fileOrModuleRefSchema.optional(), - signing_key_file: z.string().min(1), - // Verifier keys per transaction. Set it for a contract too large to deploy - // in one tx: fragment 0 rides the deploy and the rest arrive as batched - // maintenance updates within the same `deploy()` call. `--circuits-per-tx` - // overrides it. - circuits_per_tx: z.number().int().positive().optional(), - }) +const contractFields = { + artifact: z.string().min(1), + private_state_id: z.string().optional(), + init_private_state: fileOrModuleRefSchema.optional(), + private_state_store_name: z.string().optional(), + args: argsSchema.optional(), + witnesses: fileOrModuleRefSchema.optional(), + signing_key_file: z.string().min(1), + // Verifier keys per transaction. Set it for a contract too large to deploy + // in one tx: fragment 0 rides the deploy and the rest arrive as batched + // maintenance updates within the same `deploy()` call. `--circuits-per-tx` + // overrides it. + circuits_per_tx: z.number().int().positive().optional(), +}; + +/** One `[contracts]` table; the keys matching a contract merge before validation. */ +const contractEntrySchema = z.object(contractFields).partial(); + +/** A contract once its matching `[contracts]` tables merge. */ +export const contractSchema = z + .object(contractFields) .refine( (c) => - (c.private_state_id === undefined) === - (c.init_private_state === undefined), - { - message: - 'private_state_id and init_private_state must be set together (or both omitted)', - }, + c.init_private_state === undefined || c.private_state_id !== undefined, + { message: 'init_private_state needs private_state_id' }, ); -export const configSchema = z +const rawConfigSchema = z .object({ profile: profileSchema, networks: z.record(z.string(), networkSchema), wallet: walletSchema, - contracts: z.record(z.string(), contractSchema), + contracts: z.record(z.string(), contractEntrySchema), }) .refine( (c) => @@ -102,8 +107,17 @@ export const configSchema = z }, ); +type RawConfig = z.infer; + +// Piped so merged entries are checked only once every field parses, which +// keeps a bad field from being reported twice. +export const configSchema = rawConfigSchema.pipe( + z.custom().superRefine(checkContracts), +); + export type CompactConfigData = z.infer; export type NetworkConfig = z.infer; +export type ContractEntry = z.infer; export type ContractConfig = z.infer; export type Profile = z.infer; export type WalletConfig = z.infer; @@ -118,3 +132,30 @@ export function isFileRef(v: unknown): v is FileRef { export function isModuleRef(v: unknown): v is ModuleRef { return typeof v === 'object' && v !== null && 'module' in v; } + +function checkContracts(c: RawConfig, ctx: z.RefinementCtx): void { + const keys = Object.keys(c.contracts); + const directory = keys.find((key) => keyKind(key) === 'directory'); + if (directory !== undefined) { + if (c.profile.src_dir === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['profile', 'src_dir'], + message: `required by the directory pattern "${directory}"`, + }); + } + // Exact entries wait for `CompactConfig.load`, which has the source index. + return; + } + for (const name of keys.filter((key) => keyKind(key) === 'exact')) { + const { entry } = resolveEntry(name, c.contracts, undefined)!; + const result = contractSchema.safeParse(entry); + for (const issue of result.error?.issues ?? []) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['contracts', name, ...issue.path], + message: issue.message, + }); + } + } +} diff --git a/packages/deployer/src/deployer.options.test.ts b/packages/deployer/src/deployer.options.test.ts new file mode 100644 index 0000000..a52fb95 --- /dev/null +++ b/packages/deployer/src/deployer.options.test.ts @@ -0,0 +1,685 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { submitTxAsync } from '@midnight-ntwrk/midnight-js-contracts'; +import type { MidnightWalletProvider } from '@midnight-ntwrk/testkit-js'; +import { + type MaintenanceUpdate, + signatureVerifyingKey, + type VerifierKeyInsert, +} from '@midnightntwrk/ledger-v9'; +import * as Rx from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type FakeProviders, + type Fixture, + fakeFinalized, + fakeProviders, + fakeUnsubmittedDeploy, + fakeVerifierKeys, + headPath, + recordingLogger, + silentLogger, +} from './deployer.testkit.ts'; +import { Deployer, type DeployerOptions } from './deployer.ts'; +import { Deployments } from './deployments.ts'; +import { + BlockLimitError, + ConfigError, + DeployTxFailedError, + FragmentDeployError, +} from './errors.ts'; +import { Artifact } from './loaders/artifact.ts'; +import { buildProviders } from './providers/build.ts'; +import { ProofServer } from './providers/proof-server.ts'; +import { submitDeploy } from './services/deploy-tx.ts'; +import { buildInsertUpdate } from './services/maintenance-tx.ts'; + +vi.mock('./loaders/artifact.ts', () => ({ + Artifact: { load: vi.fn() }, +})); + +vi.mock('./providers/proof-server.ts', () => ({ + ProofServer: { + start: vi.fn(async () => ({ + url: 'http://localhost:6300', + [Symbol.asyncDispose]: async () => undefined, + })), + }, +})); + +vi.mock('./providers/build.ts', () => ({ buildProviders: vi.fn() })); + +vi.mock('@midnight-ntwrk/midnight-js-contracts', () => ({ + createUnprovenDeployTx: vi.fn(), + submitTxAsync: vi.fn(), + verifierKeysEqual: (a: Uint8Array, b: Uint8Array) => + a.length === b.length && a.every((byte, i) => byte === b[i]), +})); + +// Real, spied so the fake insert can read which circuits the update carries. +vi.mock('./services/maintenance-tx.ts', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, buildInsertUpdate: vi.fn(actual.buildInsertUpdate) }; +}); + +vi.mock('./services/deploy-tx.ts', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, submitDeploy: vi.fn(actual.submitDeploy) }; +}); + +vi.mock('@midnight-ntwrk/midnight-js-network-id', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@midnight-ntwrk/midnight-js-network-id') + >(); + return { ...actual, getNetworkId: vi.fn(() => 'undeployed') }; +}); + +const CIRCUITS = ['approve', 'burn', 'charge', 'deposit', 'evict']; + +/** The ledger parses a contract address as hex, so the fake one has to be. */ +const ADDRESS = 'cd'.repeat(32); + +const SIGNING_KEY = 'aa'.repeat(32); +const VERIFYING_KEY = signatureVerifyingKey({ + tag: 'schnorr', + value: SIGNING_KEY, +}); + +/** A real compiler-emitted key: the ledger checks its header on every insert. */ +const VERIFIER_KEY = new Uint8Array( + readFileSync( + fileURLToPath( + new URL( + './services/fixtures/counter-increment.verifier', + import.meta.url, + ), + ), + ), +); + +const BLOCK_LIMIT_TEXT = + '1010: Invalid Transaction: Transaction would exhaust the block limits'; + +const LEDGER_METHODS = [ + 'getHead', + 'assertRecordable', + 'record', + 'confirm', + 'updatePartial', +] as const; + +const NETWORK_LINES = `network_id = "undeployed" +indexer = "http://localhost:8088/api/v1/graphql" +indexer_ws = "ws://localhost:8088/api/v1/graphql/ws" +node = "http://localhost:9944" +node_ws = "ws://localhost:9944" +proof_server = "http://localhost:6300"`; + +/** A project with two networks, `local` and `other`, and one `Counter` entry. */ +function writeProject(contractLines = ''): Fixture { + const rootDir = mkdtempSync(join(tmpdir(), 'deployer-options-')); + writeFileSync( + join(rootDir, 'compact.toml'), + ` +[profile] +artifacts_dir = "artifacts" +deployments_dir = "deployments" + +[networks.local] +${NETWORK_LINES} + +[networks.other] +${NETWORK_LINES} + +[contracts.Counter] +artifact = "Counter" +signing_key_file = "signing-key.hex" +${contractLines} +`, + ); + writeFileSync(join(rootDir, 'signing-key.hex'), `${SIGNING_KEY}\n`); + writeFileSync(join(rootDir, 'state.json'), '{"source":"toml"}'); + return { + rootDir, + configPath: join(rootDir, 'compact.toml'), + cleanup: () => rmSync(rootDir, { recursive: true, force: true }), + }; +} + +/** Ledger-shaped contract state over the fake chain's circuits. */ +function chainState(circuits: readonly string[], counter: bigint) { + return { + operations: () => [...circuits], + operation: (name: string) => + circuits.includes(name) ? { verifierKey: VERIFIER_KEY } : undefined, + maintenanceAuthority: { + committee: [VERIFYING_KEY], + threshold: 1, + counter, + }, + }; +} + +/** The dust indices `readDustTip` and `awaitDustSettled` read. */ +interface DustView { + tip: bigint; + applied: bigint; +} + +function injectedWallet(dust: DustView): MidnightWalletProvider { + return { + getCoinPublicKey: () => '0xDEPLOYER', + wallet: { + state: () => + Rx.of({ + dust: { + state: { + progress: { + highestRelevantWalletIndex: dust.tip, + appliedIndex: dust.applied, + }, + }, + }, + pending: { all: [] }, + }), + }, + } as unknown as MidnightWalletProvider; +} + +/** Circuit ids of the update just built, read off the signed update. */ +function lastInsertedCircuits(): string[] { + const results = vi.mocked(buildInsertUpdate).mock.results; + const update = results[results.length - 1]?.value as MaintenanceUpdate; + return update.updates.map( + (single) => (single as VerifierKeyInsert).operation as string, + ); +} + +let artifactCount = 0; + +/** Remembered sizes outlive a test, so each test deploys its own artifact. */ +function freshArtifactPath(): string { + artifactCount += 1; + return `/fake/artifact-${artifactCount}`; +} + +describe('Deployer options', () => { + let fx: Fixture; + let providers: FakeProviders; + /** Circuits the fake chain reports, mutated as transactions land. */ + let onChain: string[]; + /** Landed maintenance updates, which is exactly the on-chain CMA counter. */ + let landedInserts: number; + let dust: DustView; + /** Largest deploy tx the fake node admits, in circuits. */ + let deployLimit: number; + /** Largest insert the fake node admits, in circuits. */ + let insertLimit: number; + let artifactPath: string; + let circuits: readonly string[]; + /** Circuits per deploy tx submission, refused ones included. */ + let deploySizes: number[]; + /** Circuits per insert submission, refused ones included. */ + let insertSizes: number[]; + + function spendDust(): void { + dust.tip += 1n; + dust.applied = dust.tip; + } + + /** Every fake deploy lands at one address, so a later deploy needs a clean chain. */ + function resetChain(): void { + onChain = []; + landedInserts = 0; + } + + beforeEach(() => { + fx = writeProject(); + resetChain(); + dust = { tip: 0n, applied: 0n }; + deployLimit = Number.POSITIVE_INFINITY; + insertLimit = Number.POSITIVE_INFINITY; + artifactPath = freshArtifactPath(); + circuits = CIRCUITS; + deploySizes = []; + insertSizes = []; + providers = fakeProviders(); + providers.publicDataProvider.queryContractState = vi.fn(async () => + chainState(onChain, BigInt(landedInserts)), + ); + vi.mocked(buildProviders).mockImplementation(() => providers as never); + vi.mocked(Artifact.load).mockImplementation( + async () => + ({ + artifactPath, + zkConfigPath: artifactPath, + compiledContract: { fake: 'compiled' }, + circuitNames: circuits, + verifierKeys: fakeVerifierKeys(circuits, VERIFIER_KEY), + }) as never, + ); + // The constructor leaves private state as given, so the fake echoes it. + vi.mocked(submitDeploy).mockImplementation( + async ({ circuits: batch, initialPrivateState }) => { + deploySizes.push(batch.length); + if (batch.length > deployLimit) { + throw new BlockLimitError(BLOCK_LIMIT_TEXT); + } + onChain.push(...batch); + spendDust(); + const unsubmitted = fakeUnsubmittedDeploy(ADDRESS); + return { + address: ADDRESS, + txId: '0xTX', + unsubmitted: { + ...unsubmitted, + private: { ...unsubmitted.private, initialPrivateState }, + }, + } as never; + }, + ); + vi.mocked(submitTxAsync).mockImplementation(async () => { + const batch = lastInsertedCircuits(); + insertSizes.push(batch.length); + if (batch.length > insertLimit) throw new Error(BLOCK_LIMIT_TEXT); + onChain.push(...batch); + landedInserts += 1; + spendDust(); + return `0xINSERT${landedInserts}`; + }); + }); + + afterEach(() => { + fx.cleanup(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + function prepare(opts: Partial = {}): Promise { + return Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + walletProvider: injectedWallet(dust), + // Short enough that a stuck wait fails the test instead of hanging it. + txTimeoutMs: 200, + ...opts, + }); + } + + async function deploy(opts: Partial = {}) { + await using deployer = await prepare(opts); + return deployer.deploy(); + } + + function useProject(contractLines: string): void { + fx.cleanup(); + fx = writeProject(contractLines); + } + + describe('record: false', () => { + function spyOnLedger() { + return LEDGER_METHODS.map((method) => + vi.spyOn(Deployments.prototype, method), + ); + } + + it('should deploy a single-tx contract without touching the ledger', async () => { + circuits = ['approve']; + const ledger = spyOnLedger(); + + const result = await deploy({ record: false }); + + expect(result.fragments).toBe(1); + expect(result.circuits).toBe(1); + expect(result.deploymentsFile).toBe(''); + for (const spy of ledger) expect(spy).not.toHaveBeenCalled(); + expect(existsSync(join(fx.rootDir, 'deployments'))).toBe(false); + }); + + it('should deploy a split contract without touching the ledger', async () => { + const ledger = spyOnLedger(); + + const result = await deploy({ record: false, circuitsPerTx: 2 }); + + expect(deploySizes).toStrictEqual([2]); + expect(insertSizes).toStrictEqual([2, 1]); + expect(result.fragments).toBe(3); + expect(result.circuits).toBe(5); + expect(result.deploymentsFile).toBe(''); + for (const spy of ledger) expect(spy).not.toHaveBeenCalled(); + expect(existsSync(join(fx.rootDir, 'deployments'))).toBe(false); + }); + + it.each(['pending', 'partial'])( + 'should deploy fresh over a %s head it never reads', + async (status) => { + mkdirSync(join(fx.rootDir, 'deployments')); + const head = JSON.stringify({ + Counter: { + status, + address: ADDRESS, + txId: '0xOLD', + deployer: '0xDEPLOYER', + artifact: 'Counter', + circuitsOnChain: ['approve', 'burn'], + circuitsPending: ['charge', 'deposit', 'evict'], + submittedAt: '2026-09-01T00:00:00.000Z', + }, + }); + writeFileSync(headPath(fx.rootDir), head); + + const result = await deploy({ record: false, circuitsPerTx: 2 }); + + expect(submitDeploy).toHaveBeenCalledTimes(1); + expect(result.txId).toBe('0xTX'); + expect(readFileSync(headPath(fx.rootDir), 'utf8')).toBe(head); + expect(readdirSync(join(fx.rootDir, 'deployments'))).toStrictEqual([ + 'local.json', + ]); + }, + ); + + describe('failure messages', () => { + /** What a message must not point at when no record exists. */ + const RECORD_HINTS = /record|resum|--force/i; + + it('should name the address and txId of a single deploy tx that times out', async () => { + circuits = ['approve']; + providers.publicDataProvider.watchForTxData.mockImplementation( + () => new Promise(() => {}), + ); + + const thrown = await deploy({ record: false }).catch((e: unknown) => e); + + expect(thrown).toBeInstanceOf(DeployTxFailedError); + expect((thrown as Error).message).toBe( + `Deploy of "Counter" was submitted but not confirmed: no finalization within 200 ms. address ${ADDRESS}, txId 0xTX.`, + ); + }); + + it('should name the circuit lists of a split deploy tx that times out', async () => { + providers.publicDataProvider.watchForTxData.mockImplementation( + () => new Promise(() => {}), + ); + + const thrown = await deploy({ record: false, circuitsPerTx: 2 }).catch( + (e: unknown) => e, + ); + + expect(thrown).toBeInstanceOf(DeployTxFailedError); + const { message } = thrown as Error; + expect(message).toContain(`address ${ADDRESS}, txId 0xTX.`); + expect(message).toContain( + 'In the deploy tx: approve, burn. Not yet inserted: charge, deposit, evict.', + ); + expect(message).not.toMatch(RECORD_HINTS); + }); + + it('should name the deploy tx and both circuit lists when an insert fails', async () => { + vi.mocked(submitTxAsync).mockRejectedValue(new Error('out of dust')); + + const thrown = await deploy({ record: false, circuitsPerTx: 2 }).catch( + (e: unknown) => e, + ); + + expect(thrown).toBeInstanceOf(FragmentDeployError); + const error = thrown as FragmentDeployError; + expect(error.exitCode).toBe(8); + expect(error.address).toBe(ADDRESS); + expect(error.deployTxId).toBe('0xTX'); + expect(error.circuitsOnChain).toStrictEqual(['approve', 'burn']); + expect(error.circuitsPending).toStrictEqual([ + 'charge', + 'deposit', + 'evict', + ]); + expect(error.message).toContain('out of dust'); + expect(error.message).toContain('Deploy txId 0xTX.'); + expect(error.message).not.toMatch(RECORD_HINTS); + expect(error.cause).toBeInstanceOf(Error); + }); + + it('should name the failed insert and the deploy tx when an insert times out', async () => { + providers.publicDataProvider.watchForTxData.mockImplementation( + async (txId: string) => + txId === '0xTX' ? fakeFinalized() : new Promise(() => {}), + ); + + const thrown = await deploy({ record: false, circuitsPerTx: 2 }).catch( + (e: unknown) => e, + ); + + expect(thrown).toBeInstanceOf(FragmentDeployError); + const error = thrown as FragmentDeployError; + expect(error.txId).toBe('0xINSERT1'); + expect(error.timedOut).toBe(true); + expect(error.message).toContain( + 'Failed insert txId 0xINSERT1. Deploy txId 0xTX.', + ); + expect(error.message).not.toMatch(RECORD_HINTS); + }); + + it('should leave --force out of a committee refusal', async () => { + providers.publicDataProvider.queryContractState = vi.fn(async () => ({ + ...chainState(onChain, BigInt(landedInserts)), + maintenanceAuthority: { + committee: [{ tag: 'schnorr', value: 'someone-else' }], + threshold: 1, + counter: 0n, + }, + })); + + const thrown = await deploy({ record: false, circuitsPerTx: 2 }).catch( + (e: unknown) => e, + ); + + expect(thrown).toBeInstanceOf(ConfigError); + expect((thrown as Error).message).toBe( + `Contract ${ADDRESS} is not maintained by this signing key. Check signing_key_file.`, + ); + }); + }); + }); + + describe('in-code values', () => { + const TOML_SOURCES = `private_state_id = "counter-state" +init_private_state = { file = "state.json" } +witnesses = { module = "witnesses.mjs", export = "witnesses" }`; + + beforeEach(() => { + circuits = ['approve']; + }); + + it('should deploy with the in-code initialPrivateState over the TOML one', async () => { + useProject(TOML_SOURCES); + const state = { source: 'code' }; + + await deploy({ initialPrivateState: state }); + + expect( + vi.mocked(submitDeploy).mock.calls[0]?.[0].initialPrivateState, + ).toBe(state); + expect(providers.privateStateProvider.set).toHaveBeenCalledWith( + 'counter-state', + state, + ); + }); + + it('should deploy with the TOML init_private_state when no in-code value is given', async () => { + useProject(TOML_SOURCES); + + await deploy(); + + expect( + vi.mocked(submitDeploy).mock.calls[0]?.[0].initialPrivateState, + ).toStrictEqual({ source: 'toml' }); + }); + + it('should hand the in-code witnesses to Artifact.load', async () => { + useProject(TOML_SOURCES); + const witnesses = { secret: () => [undefined, 1n] }; + + await deploy({ witnesses }); + + expect(Artifact.load).toHaveBeenCalledWith( + expect.objectContaining({ witnessImpls: witnesses }), + ); + }); + + it('should take the initial private state from code alone', async () => { + useProject('private_state_id = "counter-state"'); + const state = { source: 'code' }; + + await deploy({ initialPrivateState: state }); + + expect(providers.privateStateProvider.set).toHaveBeenCalledWith( + 'counter-state', + state, + ); + }); + + it('should refuse a private_state_id with no initial private state before starting the proof server', async () => { + useProject('private_state_id = "counter-state"'); + + const thrown = await prepare().catch((e: unknown) => e); + + expect(thrown).toBeInstanceOf(ConfigError); + expect((thrown as Error).message).toContain( + '"Counter" sets private_state_id but no init_private_state', + ); + expect(ProofServer.start).not.toHaveBeenCalled(); + }); + + it('should refuse an in-code initialPrivateState with no private_state_id before starting the proof server', async () => { + const thrown = await prepare({ + initialPrivateState: { source: 'code' }, + }).catch((e: unknown) => e); + + expect(thrown).toBeInstanceOf(ConfigError); + expect((thrown as Error).message).toBe( + 'initialPrivateState needs private_state_id, which "Counter" does not set.', + ); + expect(ProofServer.start).not.toHaveBeenCalled(); + }); + }); + + describe('remembered fragment size', () => { + const STARTS_AT = 'Deploy tx starts at'; + + beforeEach(() => { + deployLimit = 2; + }); + + /** Deploy, then give the next deploy an empty chain. */ + async function deployThenReset(opts: Partial = {}) { + await deploy(opts); + resetChain(); + } + + function startsAtLines(info: ReturnType['info']) { + return info.mock.calls.filter( + ([message]) => + typeof message === 'string' && message.startsWith(STARTS_AT), + ); + } + + it('should start a later deploy of the same artifact at the size halving found', async () => { + const first = recordingLogger(); + await deployThenReset({ logger: first.logger }); + const second = recordingLogger(); + + await deploy({ logger: second.logger }); + + expect(deploySizes).toStrictEqual([5, 2, 2]); + expect(startsAtLines(first.info)).toStrictEqual([]); + expect(startsAtLines(second.info)).toStrictEqual([ + [ + `${STARTS_AT} 2 circuits per tx, the size an earlier deploy of this artifact settled on`, + ], + ]); + }); + + it('should remember the smaller size an insert settled on', async () => { + insertLimit = 1; + await deployThenReset(); + + await deploy(); + + // 5 refused, then 2 landed. The inserts halved to 1, where the second + // deploy starts. + expect(deploySizes).toStrictEqual([5, 2, 1]); + }); + + it('should keep a pinned budget over the remembered size', async () => { + await deployThenReset(); + deployLimit = 5; + const { logger, info } = recordingLogger(); + + await deploy({ circuitsPerTx: 3, logger }); + + expect(deploySizes).toStrictEqual([5, 2, 3]); + expect(startsAtLines(info)).toStrictEqual([]); + }); + + it('should plan a resume from the chain, never from the remembered size', async () => { + await deployThenReset(); + onChain = ['approve', 'burn']; + writeFileSync( + headPath(fx.rootDir), + JSON.stringify({ + Counter: { + status: 'partial', + address: ADDRESS, + txId: '0xTX', + deployer: '0xDEPLOYER', + artifact: 'Counter', + circuitsOnChain: ['approve', 'burn'], + circuitsPending: ['charge', 'deposit', 'evict'], + submittedAt: '2026-09-01T00:00:00.000Z', + txHash: '0xHASH', + blockHeight: 1234, + }, + }), + ); + insertSizes = []; + + const result = await deploy(); + + expect(deploySizes).toStrictEqual([5, 2]); + expect(insertSizes).toStrictEqual([3]); + expect(result.circuits).toBe(5); + }); + + it('should not share a remembered size with another artifact', async () => { + await deployThenReset(); + artifactPath = freshArtifactPath(); + + await deploy(); + + expect(deploySizes).toStrictEqual([5, 2, 5, 2]); + }); + + it('should not share a remembered size with another network', async () => { + await deployThenReset(); + + await deploy({ network: 'other' }); + + expect(deploySizes).toStrictEqual([5, 2, 5, 2]); + }); + }); +}); diff --git a/packages/deployer/src/deployer.ts b/packages/deployer/src/deployer.ts index c22d468..f03a9de 100644 --- a/packages/deployer/src/deployer.ts +++ b/packages/deployer/src/deployer.ts @@ -62,6 +62,7 @@ import { toPendingRecord, } from './services/deploy-tx.ts'; import { formatError } from './services/error-format.ts'; +import { rememberedSize, rememberSize } from './services/fragment-size.ts'; import { buildInsertTx, buildInsertUpdate, @@ -94,6 +95,10 @@ export interface DeployerOptions { * artifact's constructor signature. */ args?: readonly unknown[] | Record; + /** Initial private state. Overrides `[contracts.X].init_private_state`. */ + initialPrivateState?: unknown; + /** Witness implementations. Override `[contracts.X].witnesses`. */ + witnesses?: object; initPrivateStateOverride?: string; logger: Logger; promptPassphrase?: (path: string) => Promise; @@ -150,6 +155,11 @@ export interface DeployerOptions { * starts a new contract at a new address. Argv: `--force`. */ force?: boolean; + /** + * Keep the deployments ledger. Default `true`. With `false` no ledger file + * is read or written, so every deploy is fresh and nothing can resume it. + */ + record?: boolean; /** * Verifier keys per transaction. Set it when a contract is too large for one * deploy tx: fragment 0 rides the deploy and the rest arrive as batched @@ -170,6 +180,7 @@ export interface DeployResult { blockHeight: number; deployer: string; artifact: string; + /** The head ledger file, or `''` on a dry-run or with `record: false`. */ deploymentsFile: string; dryRun: boolean; /** `[networks.X].explorer` + `/contracts/0x
`, or empty when no explorer is configured / in dry-run. */ @@ -294,6 +305,7 @@ export class Deployer implements AsyncDisposable { // Checked here so a bad budget fails before the wallet sync, which // on a real network is tens of minutes. assertBudget(opts.circuitsPerTx); + assertPrivateStateSource(opts, contract); const budget = opts.circuitsPerTx ?? contract.circuits_per_tx; const signingKey = await SigningKey.load( rootDir, @@ -354,6 +366,7 @@ export class Deployer implements AsyncDisposable { artifact: contract.artifact, contractName: opts.contract, witnesses: contract.witnesses, + witnessImpls: opts.witnesses, }); logger.debug( `Artifact: ${artifact.artifactPath} (${artifact.circuitNames.length} circuits)`, @@ -372,6 +385,7 @@ export class Deployer implements AsyncDisposable { const initialPrivateState = await InitialPrivateState.load( contract.init_private_state, rootDir, + opts.initialPrivateState, ); let wallet: MidnightWalletProvider; @@ -458,7 +472,7 @@ export class Deployer implements AsyncDisposable { /** * Deploy the contract and leave a `confirmed` record under - * `deployments/.json`. + * `deployments/.json`, unless `record` is `false`. * * A contract that fits one tx takes the single-tx path unchanged. A larger * one is split: fragment 0's verifier keys ride the deploy tx and the rest @@ -474,11 +488,14 @@ export class Deployer implements AsyncDisposable { async deploy(): Promise { const s = this.#state; const contractName = s.opts.contract; - const deployments = new Deployments({ - rootDir: s.config.rootDir, - deploymentsDir: s.config.deploymentsDir, - network: s.networkName, - }); + const deployments: DeploymentsLedger = + s.opts.record === false + ? UNRECORDED + : new Deployments({ + rootDir: s.config.rootDir, + deploymentsDir: s.config.deploymentsDir, + network: s.networkName, + }); const force = s.opts.force === true; const head = await deployments.getHead(contractName); const providers = buildProviders({ @@ -549,23 +566,28 @@ export class Deployer implements AsyncDisposable { */ async #finishSplit(args: { providers: ContractProviders; - deployments: Deployments; + deployments: DeploymentsLedger; started: Exclude; keys: ArtifactKeys; txTimeoutMs: number; }): Promise<{ fragments: number; circuits: number }> { - const progress = await this.#insertRemaining(args); - // Strict verify gates the promotion to `confirmed`. - verifyState({ - address: args.started.address, - artifactKeys: args.keys, - snapshot: progress.snapshot, - authority: progress.authority, - }); - return { - fragments: progress.fragments, - circuits: progress.snapshot.circuits.length, - }; + try { + const progress = await this.#insertRemaining(args); + // Strict verify gates the promotion to `confirmed`. + verifyState({ + address: args.started.address, + artifactKeys: args.keys, + snapshot: progress.snapshot, + authority: progress.authority, + }); + return { + fragments: progress.fragments, + circuits: progress.snapshot.circuits.length, + }; + } catch (e) { + if (this.#state.opts.record !== false) throw e; + throw withoutRecord(e, args.started.txId); + } } /** @@ -574,7 +596,7 @@ export class Deployer implements AsyncDisposable { */ async #deployFragmentZero(args: { providers: ContractProviders; - deployments: Deployments; + deployments: DeploymentsLedger; txTimeoutMs: number; force: boolean; }): Promise { @@ -588,7 +610,7 @@ export class Deployer implements AsyncDisposable { const attempt = await this.#submitHalving({ circuits, - size: s.budget ?? Math.max(circuits.length, 1), + size: s.budget ?? this.#unbudgetedSize(circuits.length), what: 'Deploy tx', submit: (batch) => submitDeploy({ @@ -630,7 +652,13 @@ export class Deployer implements AsyncDisposable { contractName, submitted, txTimeoutMs, - recovery: record.status === 'partial' ? 'partial' : 'pending', + recovery: s.opts.record === false ? 'none' : record.status, + circuits: split + ? { + inDeployTx: attempt.batch, + notYetInserted: remaining(circuits, attempt.batch), + } + : undefined, }); // Order copied from midnight-js's own post-success path, and reached only // on `SucceedEntirely`. The signing key and the initial private @@ -664,6 +692,20 @@ export class Deployer implements AsyncDisposable { }; } + /** + * Fragment 0's first size with no budget: what an earlier halving of this + * artifact on this network settled on, else every circuit. + */ + #unbudgetedSize(circuitCount: number): number { + const s = this.#state; + const remembered = rememberedSize(s.artifact.artifactPath, s.networkName); + if (remembered === undefined) return Math.max(circuitCount, 1); + s.logger.info( + `Deploy tx starts at ${remembered} circuits per tx, the size an earlier deploy of this artifact settled on`, + ); + return remembered; + } + /** * Adopt a `partial` head after checking the recorded address really is our * unfinished contract. Nothing is submitted if any check fails. @@ -890,6 +932,7 @@ export class Deployer implements AsyncDisposable { * The only halving site. Strictly decreasing, floor one circuit, and * only when no explicit budget was given. Each attempt records the * dust index the wallet must pass before it can have seen that spend. + * A size reached by halving is remembered for later deploys of the artifact. */ async #submitHalving(args: { circuits: readonly string[]; @@ -908,17 +951,23 @@ export class Deployer implements AsyncDisposable { // The cap survives a short batch: a fragment with two circuits left must // not shrink the size every later fragment is allowed. let cap = Math.max(1, args.size); + let halved = false; for (;;) { const batch = args.circuits.slice(0, cap); const tip = await readDustTip(s.wallet); try { - return { batch, cap, tip, result: await args.submit(batch) }; + const result = await args.submit(batch); + if (halved) { + rememberSize(s.artifact.artifactPath, s.networkName, cap); + } + return { batch, cap, tip, result }; } catch (e) { const halvable = e instanceof BlockLimitError && s.budget === undefined && batch.length > 1; if (!halvable) throw e; + halved = true; cap = Math.floor(batch.length / 2); s.logger.info( `${args.what} too large at ${batch.length} circuits; retrying with ${cap} per tx`, @@ -936,7 +985,7 @@ export class Deployer implements AsyncDisposable { */ async #insertRemaining(args: { providers: ContractProviders; - deployments: Deployments; + deployments: DeploymentsLedger; started: Exclude; keys: ArtifactKeys; txTimeoutMs: number; @@ -986,6 +1035,7 @@ export class Deployer implements AsyncDisposable { address, snapshot, verifyingKey: verifyingKeyOf(s.signingKey.ledgerKey), + recorded: s.opts.record !== false, }); await this.#recordProgress({ deployments, @@ -1044,7 +1094,7 @@ export class Deployer implements AsyncDisposable { */ async #insertBatch(args: { providers: ContractProviders; - deployments: Deployments; + deployments: DeploymentsLedger; started: Exclude; keys: ArtifactKeys; all: readonly string[]; @@ -1155,7 +1205,7 @@ export class Deployer implements AsyncDisposable { /** Refresh the `partial` head from a chain snapshot. Reporting only. */ async #recordProgress(args: { - deployments: Deployments; + deployments: DeploymentsLedger; record: PartialDeploymentRecord; started: StartedDeployBase; snapshot: ChainSnapshot; @@ -1265,6 +1315,63 @@ function assertBudget(budget: number | undefined): void { } } +/** A private state and its `private_state_id` come together, from TOML or code. */ +function assertPrivateStateSource( + opts: DeployerOptions, + contract: ContractConfig, +): void { + const inCode = opts.initialPrivateState !== undefined; + if (contract.private_state_id === undefined) { + if (inCode) { + throw new ConfigError( + `initialPrivateState needs private_state_id, which "${opts.contract}" does not set.`, + ); + } + return; + } + if (inCode || contract.init_private_state !== undefined) return; + throw new ConfigError( + `"${opts.contract}" sets private_state_id but no init_private_state. Set init_private_state in compact.toml, or pass initialPrivateState.`, + ); +} + +/** The ledger calls `deploy()` makes. */ +type DeploymentsLedger = Pick< + Deployments, + 'getHead' | 'assertRecordable' | 'record' | 'confirm' | 'updatePartial' +>; + +const NO_PATHS: DeploymentsPaths = { head: '', history: '' }; + +/** The ledger under `record: false`: no head to resume, no file to write. */ +const UNRECORDED: DeploymentsLedger = { + getHead: async () => undefined, + assertRecordable: async () => undefined, + record: async () => NO_PATHS, + confirm: async () => NO_PATHS, + updatePartial: async () => NO_PATHS, +}; + +/** + * A stopped split under `record: false`. No record holds the deploy tx, so the + * error names it, and no record is left to resume from. + */ +function withoutRecord(e: unknown, deployTxId: string): unknown { + if (!(e instanceof FragmentDeployError)) return e; + return new FragmentDeployError( + { + address: e.address, + circuitsOnChain: e.circuitsOnChain, + circuitsPending: e.circuitsPending, + reason: e.reason, + txId: e.txId, + timedOut: e.timedOut, + deployTxId, + }, + e.cause !== undefined ? { cause: e.cause } : undefined, + ); +} + /** * Verifier key bytes for `circuitId`. `Artifact.verifierKeys` refuses a bundle * that is missing one, so every name in the plan has an entry. diff --git a/packages/deployer/src/errors.test.ts b/packages/deployer/src/errors.test.ts index 62e226f..f245e7d 100644 --- a/packages/deployer/src/errors.test.ts +++ b/packages/deployer/src/errors.test.ts @@ -147,6 +147,16 @@ describe('FragmentDeployError', () => { expect(message).toContain('re-run the same deploy to resume'); }); + it('should name the deploy tx in place of the resume hint when no record holds it', () => { + const e = new FragmentDeployError({ ...fields, deployTxId: '0xDEPLOY' }); + + expect(e.message).toBe( + 'Fragmented deploy of 0xADDR is incomplete: insert timed out. On chain: approve, burn. Pending: evict. Deploy txId 0xDEPLOY.', + ); + expect(e.deployTxId).toBe('0xDEPLOY'); + expect(e.reason).toBe('insert timed out'); + }); + it('should expose the fields for --json consumers', () => { const e = new FragmentDeployError({ ...fields, txId: '0xTX' }); diff --git a/packages/deployer/src/errors.ts b/packages/deployer/src/errors.ts index 883c475..ca2cd8a 100644 --- a/packages/deployer/src/errors.ts +++ b/packages/deployer/src/errors.ts @@ -117,6 +117,11 @@ export interface FragmentDeployErrorFields { * so `txId` may still land. */ timedOut?: boolean; + /** + * The deploy tx, set only when no deployments record holds it. The message + * then names it in place of the resume hint. + */ + deployTxId?: string; } /** @@ -124,19 +129,33 @@ export interface FragmentDeployErrorFields { * * The contract exists and is callable with a subset of its circuits. The fields * are the whole reconciliation surface, and re-running the same deploy command - * resumes from chain state. Carries no signing key. + * resumes from chain state unless the deploy kept no record. Carries no + * signing key. */ export class FragmentDeployError extends DeployError { readonly address: string; readonly circuitsOnChain: readonly string[]; readonly circuitsPending: readonly string[]; + readonly reason: string; readonly txId: string | undefined; readonly timedOut: boolean; + readonly deployTxId: string | undefined; constructor(fields: FragmentDeployErrorFields, options?: ErrorOptions) { - const { address, circuitsOnChain, circuitsPending, reason, txId } = fields; + const { + address, + circuitsOnChain, + circuitsPending, + reason, + txId, + deployTxId, + } = fields; + const next = + deployTxId === undefined + ? ' The partial record is left in place; re-run the same deploy to resume.' + : ` Deploy txId ${deployTxId}.`; super( - `Fragmented deploy of ${address} is incomplete: ${reason}. On chain: ${list(circuitsOnChain)}. Pending: ${list(circuitsPending)}.${txId ? ` Failed insert txId ${txId}.` : ''} The partial record is left in place; re-run the same deploy to resume.`, + `Fragmented deploy of ${address} is incomplete: ${reason}. On chain: ${list(circuitsOnChain)}. Pending: ${list(circuitsPending)}.${txId ? ` Failed insert txId ${txId}.` : ''}${next}`, 8, options, ); @@ -144,8 +163,10 @@ export class FragmentDeployError extends DeployError { this.address = address; this.circuitsOnChain = circuitsOnChain; this.circuitsPending = circuitsPending; + this.reason = reason; this.txId = txId; this.timedOut = fields.timedOut === true; + this.deployTxId = deployTxId; } } diff --git a/packages/deployer/src/loaders/artifact.test.ts b/packages/deployer/src/loaders/artifact.test.ts index 387b7fe..54e73f8 100644 --- a/packages/deployer/src/loaders/artifact.test.ts +++ b/packages/deployer/src/loaders/artifact.test.ts @@ -271,6 +271,22 @@ describe('Artifact.load — witnesses module ref', () => { }); expect(art.artifactPath).toBe(join(root, 'WithWitnesses')); }); + + it('should use in-code witnesses without importing the witnesses ref', async () => { + makeArtifactDir(root, 'InCodeWitnesses'); + const witnessImpls = { add: () => [undefined, 1] as [unknown, number] }; + const art = await Artifact.load({ + rootDir: root, + artifactsDir: 'src/artifacts', + artifact: 'InCodeWitnesses', + contractName: 'InCodeWitnesses', + witnesses: { module: 'missing.mjs', export: 'witnesses' }, + witnessImpls, + }); + expect((art.compiledContract as unknown as { w: unknown }).w).toBe( + witnessImpls, + ); + }); }); describe('Artifact.load — entry-file fallbacks', () => { diff --git a/packages/deployer/src/loaders/artifact.ts b/packages/deployer/src/loaders/artifact.ts index 0eb983e..64c261d 100644 --- a/packages/deployer/src/loaders/artifact.ts +++ b/packages/deployer/src/loaders/artifact.ts @@ -35,6 +35,8 @@ export interface LoadArtifactOptions { artifact: string; contractName: string; witnesses?: FileOrModuleRef; + /** In-code witnesses. They win, and {@link witnesses} is then never imported. */ + witnessImpls?: object; } /** Verifier key bytes per circuit, as read from `keys/.verifier`. */ @@ -111,9 +113,9 @@ export class Artifact { const circuitNames = collectCircuitNames(zkirDir); const Ctor = await importContractCtor(ctx, entry); - const witnessImpls = witnesses - ? await importWitnesses(ctx, witnesses) - : undefined; + const witnessImpls = + opts.witnessImpls ?? + (witnesses ? await importWitnesses(ctx, witnesses) : undefined); const compiledContract = buildCompiledContract({ contractName, @@ -149,7 +151,7 @@ async function importContractCtor( async function importWitnesses( ctx: LoaderContext, ref: FileOrModuleRef, -): Promise { +): Promise { if (isFileRef(ref)) { throw new ConfigError( 'witnesses must be a { module, export } reference; JSON file refs are not supported (witnesses are functions)', @@ -169,18 +171,20 @@ async function importWitnesses( `witnesses: module ${path} export "${ref.export}" must resolve to an object`, ); } - return resolved as AnyWitnesses; + return resolved; } function buildCompiledContract(input: { contractName: string; Ctor: Types.Ctor; - witnessImpls: AnyWitnesses | undefined; + witnessImpls: object | undefined; contractDir: string; }): AnyCompiledContract { const base = CompiledContract.make(input.contractName, input.Ctor); + // Only the caller knows the artifact's witness types, and an interface-typed + // set has no index signature to satisfy the SDK's `Witnesses`. const withWit = input.witnessImpls - ? CompiledContract.withWitnesses(base, input.witnessImpls) + ? CompiledContract.withWitnesses(base, input.witnessImpls as AnyWitnesses) : CompiledContract.withVacantWitnesses(base); return CompiledContract.withCompiledFileAssets(withWit, input.contractDir); } diff --git a/packages/deployer/src/loaders/contract-resolve.test.ts b/packages/deployer/src/loaders/contract-resolve.test.ts index 5c90569..4549dc7 100644 --- a/packages/deployer/src/loaders/contract-resolve.test.ts +++ b/packages/deployer/src/loaders/contract-resolve.test.ts @@ -216,4 +216,41 @@ signing_key_file = "Broken.sk" const config = await CompactConfig.load(join(root, 'compact.toml')); expect(await resolveContractName(Target, config, root)).toBe('Token'); }); + + it('should match an exact entry that takes its fields from a pattern', async () => { + class Target { + initialState() {} + } + const root = makeProject({ Token: { Contract: Target } }); + const toml = readFileSync(join(root, 'compact.toml'), 'utf8').replace( + 'artifact = "Token"\nsigning_key_file = "Token.sk"\n', + 'args = []\n', + ); + writeFileSync( + join(root, 'compact.toml'), + `${toml}\n[contracts."*"]\nsigning_key_file = "keys/{name}.sk"\n`, + ); + const config = await CompactConfig.load(join(root, 'compact.toml')); + expect(config.contract('Token').signing_key_file).toBe('keys/Token.sk'); + expect(await resolveContractName(Target, config, root)).toBe('Token'); + }); + + it('should not search a contract that only a pattern matches', async () => { + class Target { + initialState() {} + } + const root = makeProject({ Token: { Contract: Target } }); + const toml = readFileSync(join(root, 'compact.toml'), 'utf8').replace( + '[contracts.Token]', + '[contracts."Tok*"]', + ); + writeFileSync(join(root, 'compact.toml'), toml); + const config = await CompactConfig.load(join(root, 'compact.toml')); + await expect(resolveContractName(Target, config, root)).rejects.toThrow( + ConfigError, + ); + await expect(resolveContractName(Target, config, root)).rejects.toThrow( + /Pattern keys \(Tok\*\) are not searched: add an exact \[contracts\.X\] entry or use the string form/, + ); + }); }); diff --git a/packages/deployer/src/loaders/contract-resolve.ts b/packages/deployer/src/loaders/contract-resolve.ts index c84425e..3beaca1 100644 --- a/packages/deployer/src/loaders/contract-resolve.ts +++ b/packages/deployer/src/loaders/contract-resolve.ts @@ -4,11 +4,12 @@ import { findArtifactEntry, resolveUnderRoot } from './artifact-path.ts'; import { LoaderContext } from './context.ts'; /** - * Walks `compact.toml`'s `[contracts.X]` entries and returns the name + * Walks `compact.toml`'s exact `[contracts.X]` entries and returns the name * whose compiled `Contract` class is identity-equal to the one * imported by the caller's deploy script. Used by the curried * `runDeploy(Contract)(...)` form so the deploy script names the - * contract once. + * contract once. Pattern keys have no name to walk, so a contract that + * only a pattern matches needs the string form. * * Throws when: * - no entry resolves to the same Contract class (the script likely @@ -56,11 +57,16 @@ export async function resolveContractName( `Ambiguous Contract: matches ${matches.length} entries in compact.toml (${matches.join(', ')}). Use the string form: runDeploy({ contract: 'X' }).`, ); } + const patterns = config.listPatterns(); + const patternHint = + patterns.length > 0 + ? ` Pattern keys (${patterns.join(', ')}) are not searched: add an exact [contracts.X] entry or use the string form, runDeploy({ contract: 'X' }).` + : ''; const tail = tried.length > 0 ? `\nSkipped: ${tried.map((t) => `${t.name} (${t.reason})`).join('; ')}` : ''; throw new ConfigError( - `Contract class did not match any [contracts.X] entry in compact.toml. Make sure the import path resolves to the same artifact directory referenced by the TOML.${tail}`, + `Contract class did not match any [contracts.X] entry in compact.toml. Make sure the import path resolves to the same artifact directory referenced by the TOML.${patternHint}${tail}`, ); } diff --git a/packages/deployer/src/loaders/init-state.test.ts b/packages/deployer/src/loaders/init-state.test.ts index b8abd6d..b422cd3 100644 --- a/packages/deployer/src/loaders/init-state.test.ts +++ b/packages/deployer/src/loaders/init-state.test.ts @@ -44,6 +44,16 @@ describe('InitialPrivateState', () => { expect(state?.value).toEqual({ counter: 5n, name: 'from-mod' }); }); + it('should return an in-code value without reading the ref', async () => { + const inCode = { counter: 1n }; + const state = await InitialPrivateState.load( + { file: 'does-not-exist.json' }, + '/tmp', + inCode, + ); + expect(state?.value).toBe(inCode); + }); + it('should throw ConfigError when the module export is missing', async () => { const dir = mkdtempSync(join(tmpdir(), 'initstate-test-')); writeFileSync(join(dir, 'm.mjs'), 'export const present = 1;'); diff --git a/packages/deployer/src/loaders/init-state.ts b/packages/deployer/src/loaders/init-state.ts index ec6d04f..5d37e32 100644 --- a/packages/deployer/src/loaders/init-state.ts +++ b/packages/deployer/src/loaders/init-state.ts @@ -3,7 +3,7 @@ import { ConfigError } from '../errors.ts'; import { LoaderContext } from './context.ts'; import { RefResolver } from './ref-resolver.ts'; -/** Initial private state for the contract constructor. `load` returns `undefined` when omitted in TOML. */ +/** Initial private state for the contract constructor. `load` returns `undefined` when neither source gives one. */ export class InitialPrivateState { readonly value: unknown; @@ -11,11 +11,17 @@ export class InitialPrivateState { this.value = value; } - /** Source: `{ file }` (JSON with `"123n"` bigint strings) or `{ module, export }` (value or zero-arg function). */ + /** + * An `inCode` value wins, and `ref` is then never read. Source: `{ file }` + * (JSON with `"123n"` bigint strings) or `{ module, export }` (value or + * zero-arg function). + */ static async load( ref: FileOrModuleRef | undefined, rootDir: string, + inCode?: unknown, ): Promise { + if (inCode !== undefined) return new InitialPrivateState(inCode); if (!ref) return undefined; const resolver = new RefResolver( diff --git a/packages/deployer/src/runDeploy.test.ts b/packages/deployer/src/runDeploy.test.ts index e129c3c..f32ac08 100644 --- a/packages/deployer/src/runDeploy.test.ts +++ b/packages/deployer/src/runDeploy.test.ts @@ -538,6 +538,47 @@ describe('runDeploy', () => { expect(explorerCall).toBeDefined(); }); + it('should thread initialPrivateState, witnesses and record to Deployer.prepare', async () => { + process.argv = ['node', 'script.ts']; + const prepare = vi + .spyOn(deployerModule.Deployer, 'prepare') + .mockResolvedValue(fakeDeployer() as never); + const initialPrivateState = { seed: 1n }; + const witnesses = {}; + + await runDeploy({ + contract: 'X', + initialPrivateState, + witnesses, + record: false, + }); + + const callArgs = prepare.mock.calls[0]?.[0]; + expect(callArgs?.initialPrivateState).toBe(initialPrivateState); + expect(callArgs?.witnesses).toBe(witnesses); + expect(callArgs?.record).toBe(false); + }); + + it('should print no saved-to line for a deploy that wrote no ledger', async () => { + process.argv = ['node', 'script.ts']; + const deploy = vi.fn(async () => fakeDeployResult({ deploymentsFile: '' })); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDeployer({ deploy }) as never, + ); + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + await runDeploy({ contract: 'X', logger: logger as never }); + + const lines = logger.info.mock.calls.map((c) => String(c[0])); + expect(lines).toContainEqual(expect.stringContaining('blockHeight:')); + expect(lines).not.toContainEqual(expect.stringContaining('saved to:')); + }); + it('should log the stack trace in verbose mode when an Error throws', async () => { process.argv = ['node', 'script.ts', '--verbose']; const err = new Error('boom'); diff --git a/packages/deployer/src/runDeploy.ts b/packages/deployer/src/runDeploy.ts index 633361e..99d2a58 100644 --- a/packages/deployer/src/runDeploy.ts +++ b/packages/deployer/src/runDeploy.ts @@ -1,6 +1,10 @@ import pino, { type Logger } from 'pino'; import { CompactConfig } from './config/compact-config.ts'; -import { Deployer, type DeployResult } from './deployer.ts'; +import { + Deployer, + type DeployerOptions, + type DeployResult, +} from './deployer.ts'; import { DeployError, FragmentDeployError } from './errors.ts'; import { parseDeployArgv } from './loaders/argv.ts'; import { resolveContractName } from './loaders/contract-resolve.ts'; @@ -103,6 +107,10 @@ export interface RunDeployOptions< * Highest precedence: overrides `compact.toml`'s `args` field. */ args?: Args; + /** Initial private state. Overrides `[contracts.X].init_private_state`. */ + initialPrivateState?: unknown; + /** Witness implementations. Override `[contracts.X].witnesses`. */ + witnesses?: DeployerOptions['witnesses']; /** Path to `compact.toml`. Argv: `--config`. Default: walk up from cwd. */ configPath?: string; /** Network name. Argv: `--network`. Default: `[profile].default_network` from `compact.toml`. */ @@ -117,6 +125,8 @@ export interface RunDeployOptions< txTimeoutSec?: number; /** Replace a pending deploy record for this contract. Argv: `--force`. */ force?: boolean; + /** Keep the deployments ledger. Default `true`. See {@link DeployerOptions.record}. */ + record?: boolean; /** Dust/shielded sync batch size. Argv: `--sync-batch-size`. Default 5000. */ syncBatchSize?: number; /** @@ -254,10 +264,13 @@ async function runDeployImpl( seedFile: opts.seedFile ?? argv.seedFile, proofServer: opts.proofServer ?? argv.proofServer, args: opts.args, + initialPrivateState: opts.initialPrivateState, + witnesses: opts.witnesses, syncTimeoutMs: syncTimeoutSec !== undefined ? syncTimeoutSec * 1000 : undefined, txTimeoutMs: txTimeoutSec !== undefined ? txTimeoutSec * 1000 : undefined, force: opts.force ?? argv.force, + record: opts.record, syncBatchSize: opts.syncBatchSize ?? argv.syncBatchSize, circuitsPerTx: opts.circuitsPerTx ?? argv.circuitsPerTx, skipWalletCache: opts.skipWalletCache ?? argv.noCache, @@ -313,7 +326,9 @@ function printResult( opts.logger.info(` txId: ${result.txId}`); opts.logger.info(` txHash: ${result.txHash}`); opts.logger.info(` blockHeight: ${result.blockHeight}`); - opts.logger.info(` saved to: ${result.deploymentsFile}`); + if (result.deploymentsFile) { + opts.logger.info(` saved to: ${result.deploymentsFile}`); + } if (result.explorerUrl) { opts.logger.info(` explorer: ${result.explorerUrl}`); } diff --git a/packages/deployer/src/services/chain-state.test.ts b/packages/deployer/src/services/chain-state.test.ts index 0661984..6de29ad 100644 --- a/packages/deployer/src/services/chain-state.test.ts +++ b/packages/deployer/src/services/chain-state.test.ts @@ -302,6 +302,21 @@ describe('signerIndex', () => { 'not maintained by this signing key', ); }); + + it('should leave --force out of the refusal when no record exists', () => { + const thrown = catchThrown(() => + signerIndex({ + address: ADDRESS, + snapshot: snapshot({ committee: [vk('someone-else')] }), + verifyingKey: OUR_KEY, + recorded: false, + }), + ); + + expect((thrown as Error).message).toBe( + `Contract ${ADDRESS} is not maintained by this signing key. Check signing_key_file.`, + ); + }); }); describe('assertResumable', () => { diff --git a/packages/deployer/src/services/chain-state.ts b/packages/deployer/src/services/chain-state.ts index e4b2bdb..94dcfbc 100644 --- a/packages/deployer/src/services/chain-state.ts +++ b/packages/deployer/src/services/chain-state.ts @@ -153,11 +153,14 @@ export function signerIndex({ address, snapshot, verifyingKey, + recorded = true, }: { address: string; snapshot: ChainSnapshot; /** Public half of the loaded signing key. Never the signing key. */ verifyingKey: SignatureVerifyingKey; + /** A deployments record exists for `--force` to replace. Default `true`. */ + recorded?: boolean; }): number { if (snapshot.threshold !== 1) { throw new ConfigError( @@ -168,8 +171,9 @@ export function signerIndex({ signatureKeysEqual(key, verifyingKey), ); if (index < 0) { + const force = recorded ? ', or re-run with --force to deploy fresh' : ''; throw new ConfigError( - `Contract ${address} is not maintained by this signing key. Check signing_key_file, or re-run with --force to deploy fresh.`, + `Contract ${address} is not maintained by this signing key. Check signing_key_file${force}.`, ); } return index; diff --git a/packages/deployer/src/services/deploy-tx.ts b/packages/deployer/src/services/deploy-tx.ts index 70fd0e7..2f12e17 100644 --- a/packages/deployer/src/services/deploy-tx.ts +++ b/packages/deployer/src/services/deploy-tx.ts @@ -270,8 +270,19 @@ export interface AwaitDeployFinalizationArgs { contractName: string; submitted: SubmittedDeploy; txTimeoutMs: number; - /** The record this deploy wrote, which is what the operator has to act on. */ - recovery: 'pending' | 'partial'; + /** + * The record this deploy wrote, which is what the operator has to act on. + * `none` when it wrote none. + */ + recovery: 'pending' | 'partial' | 'none'; + /** A split's circuits, named in the message when no record holds them. */ + circuits?: SplitCircuits; +} + +/** How a split deploy divides the artifact's circuits. */ +export interface SplitCircuits { + inDeployTx: readonly string[]; + notYetInserted: readonly string[]; } /** @@ -352,13 +363,14 @@ async function settle( return finalized; } -/** Wait for the deploy tx to land. The pending record survives every failure. */ +/** Wait for the deploy tx to land. Its record, if any, survives every failure. */ export function awaitDeployFinalization({ providers, contractName, submitted, txTimeoutMs, recovery, + circuits, }: AwaitDeployFinalizationArgs): Promise { const { address, txId } = submitted; return awaitFinalization({ @@ -372,6 +384,7 @@ export function awaitDeployFinalization({ txId, reason, recovery, + circuits, cause: detail?.cause, }), }); @@ -619,7 +632,8 @@ const RECOVERY = { /** * The tx is out of our hands and its ledger record survives, so the message has - * to carry every identifier needed to reconcile by hand. + * to carry every identifier needed to reconcile by hand. With no record, it + * carries a split's circuits too. */ function unconfirmed({ contractName, @@ -627,17 +641,27 @@ function unconfirmed({ txId, reason, recovery, + circuits, cause, }: { contractName: string; address: string; txId: string; reason: string; - recovery: keyof typeof RECOVERY; + recovery: keyof typeof RECOVERY | 'none'; + circuits?: SplitCircuits; cause?: unknown; }): DeployTxFailedError { + const next = + recovery === 'none' ? describeSplit(circuits) : ` ${RECOVERY[recovery]}`; return new DeployTxFailedError( - `Deploy of "${contractName}" was submitted but not confirmed: ${reason}. address ${address}, txId ${txId}. ${RECOVERY[recovery]}`, + `Deploy of "${contractName}" was submitted but not confirmed: ${reason}. address ${address}, txId ${txId}.${next}`, cause !== undefined ? { cause } : undefined, ); } + +/** `''` for a single-tx deploy. */ +function describeSplit(circuits: SplitCircuits | undefined): string { + if (circuits === undefined) return ''; + return ` In the deploy tx: ${circuits.inDeployTx.join(', ')}. Not yet inserted: ${circuits.notYetInserted.join(', ')}.`; +} diff --git a/packages/deployer/src/services/fragment-size.ts b/packages/deployer/src/services/fragment-size.ts new file mode 100644 index 0000000..ff50288 --- /dev/null +++ b/packages/deployer/src/services/fragment-size.ts @@ -0,0 +1,29 @@ +/** + * Fragment sizes the node accepted after refusing a larger batch, per artifact + * and network. In memory only, for the life of the process. + */ + +const accepted = new Map(); + +/** The size halving settled on for `artifactPath` on `network`, if it ran. */ +export function rememberedSize( + artifactPath: string, + network: string, +): number | undefined { + return accepted.get(keyOf(artifactPath, network)); +} + +/** Remember `size`, keeping a smaller one already held. */ +export function rememberSize( + artifactPath: string, + network: string, + size: number, +): void { + const key = keyOf(artifactPath, network); + const previous = accepted.get(key); + accepted.set(key, previous === undefined ? size : Math.min(previous, size)); +} + +function keyOf(artifactPath: string, network: string): string { + return JSON.stringify([artifactPath, network]); +} diff --git a/tests/integrations/README.md b/tests/integrations/README.md index 9ad494e..635ce9f 100644 --- a/tests/integrations/README.md +++ b/tests/integrations/README.md @@ -11,6 +11,7 @@ tests/integrations/ tsconfig.json # Type-check config; run by the root `yarn types` package.json # Marks the tree as ESM; not a workspace member compact.toml # Deployer config; paths resolve to this dir + pattern-no-record.compact.toml # Pattern-only config for patternNoRecord.spec.ts _harness/ # Shared setup: walletPool, network, paths, … fixtures/ Counter.compact # Minimal one-circuit fixture @@ -21,7 +22,8 @@ tests/integrations/ artifacts/ # Output of compact-compiler (gitignored) specs/ deploy/ # deploy, dry-run, history rotation/isolation, - # proof-server auto, async-dispose, PrivateCounter + # proof-server auto, async-dispose, PrivateCounter, + # pattern + no record, witnesses type guard wallet/ # wallet pool, lifecycle, keystore+passphrase errors/ # config-error surface ``` @@ -85,6 +87,8 @@ make env-down # when you're done ite - **history isolation** — Counter and SecondaryCounter share an artifact but maintain independent head/history slots per contract name. - **keystore + passphrase** — `[wallet].keystore` configured in `compact.toml` resolves the seed via the `promptPassphrase` callback; wrong/missing passphrase fails with `WalletError`. - **PrivateCounter** — exercises the `init_private_state` and `witnesses = { module, export }` resolution paths end-to-end. +- **pattern, in-code state, no ledger** — deploys `Fragmented` by name through the `"Fragment*"` pattern in `pattern-no-record.compact.toml`, with in-code `initialPrivateState` and `witnesses` and `record: false`. Asserts no deployments file, the four-transaction split, byte-equal verifier keys, and the stored private state. +- **witnesses option types** — `specs/deploy/witnesses.type-test.ts` is compiled by `yarn types` and never run. It checks that an interface-typed witness set passes the `witnesses` option as is. ## Notes diff --git a/tests/integrations/pattern-no-record.compact.toml b/tests/integrations/pattern-no-record.compact.toml new file mode 100644 index 0000000..d6d9163 --- /dev/null +++ b/tests/integrations/pattern-no-record.compact.toml @@ -0,0 +1,27 @@ +# Config for specs/deploy/patternNoRecord.spec.ts. All paths resolve against +# this file's dir. No exact entry names a contract: every deploy resolves +# through the pattern below. + +[profile] +default_network = "local" +artifacts_dir = "fixtures/artifacts" +deployments_dir = "deployments/pattern-no-record" + +[networks.local] +network_id = "undeployed" +indexer = "http://127.0.0.1:8088/api/v4/graphql" +indexer_ws = "ws://127.0.0.1:8088/api/v4/graphql/ws" +node = "http://127.0.0.1:9944" +node_ws = "ws://127.0.0.1:9944" +proof_server = "http://127.0.0.1:6300" +wallet = { source = "local", index = 0 } + +# `artifact` defaults to the contract name. No `init_private_state`: the spec +# passes the state in code. +[contracts."Fragment*"] +signing_key_file = "fixtures/signingkeys/{name}.signingkey" +private_state_id = "{name}-private-state" +# Pinned so the deploy never halves. A size learned by halving would carry +# into fragmented.spec.ts's no-budget deploy: same artifact, network and +# process. +circuits_per_tx = 5 diff --git a/tests/integrations/specs/deploy/patternNoRecord.spec.ts b/tests/integrations/specs/deploy/patternNoRecord.spec.ts new file mode 100644 index 0000000..ebb6572 --- /dev/null +++ b/tests/integrations/specs/deploy/patternNoRecord.spec.ts @@ -0,0 +1,118 @@ +import { existsSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider'; +import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider'; +import { + inMemoryPrivateStateProvider, + syncWallet, +} from '@midnight-ntwrk/testkit-js'; +import { + Deployer, + type DeployResult, +} from '@openzeppelin/compact-deployer/deployer'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { testLogger } from '../../_harness/logger.ts'; +import { + localNetworkConfig, + setupLocalNetwork, +} from '../../_harness/network.ts'; +import { + ARTIFACTS_DIR, + ROOT_DIR, + requireArtifact, +} from '../../_harness/paths.ts'; +import { getSharedPool } from '../../_harness/walletPool.ts'; + +/** + * Spec: `Fragmented` deployed by name through a pattern entry, with its + * private state and witnesses passed in code and `record: false`. The pattern + * pins `circuits_per_tx = 5`, so the split is the one `fragmented.spec.ts` + * asserts. + */ + +const CONFIG_PATH = resolve(ROOT_DIR, 'pattern-no-record.compact.toml'); + +/** `[profile].deployments_dir` of that config. */ +const DEPLOYMENTS_DIR = resolve(ROOT_DIR, 'deployments/pattern-no-record'); + +/** The pattern's `private_state_id` after `{name}` expansion. */ +const PRIVATE_STATE_ID = 'Fragmented-private-state'; + +const IN_CODE_STATE = { source: 'in-code', seed: 42n }; + +/** Ceiling for the deploy tx plus three inserts. */ +const DEPLOY_BUDGET_MS = 600_000; + +/** Every circuit `Fragmented.compact` declares, sorted as the planner sorts. */ +const CIRCUITS = Array.from( + { length: 20 }, + (_unused, i) => `step${String(i + 1).padStart(2, '0')}`, +).sort(); + +describe('compact-deploy — pattern entry, in-code state, no ledger', () => { + const env = localNetworkConfig(); + const provider = indexerPublicDataProvider(env.indexer, env.indexerWS); + const zkConfig = new NodeZkConfigProvider( + resolve(ARTIFACTS_DIR, 'Fragmented'), + ); + const privateStateProvider = inMemoryPrivateStateProvider(); + let result: DeployResult; + + beforeAll(async () => { + requireArtifact('Fragmented'); + rmSync(DEPLOYMENTS_DIR, { recursive: true, force: true }); + setupLocalNetwork(); + const wallet = await getSharedPool().signerFor('DEPLOYER'); + await syncWallet(wallet.wallet); + await using deployer = await Deployer.prepare({ + contract: 'Fragmented', + network: 'local', + configPath: CONFIG_PATH, + logger: testLogger(), + walletProvider: wallet, + privateStateProvider, + initialPrivateState: IN_CODE_STATE, + // Fragmented declares no witnesses. + witnesses: {}, + record: false, + }); + result = await deployer.deploy(); + }, DEPLOY_BUDGET_MS); + + afterAll(() => { + rmSync(DEPLOYMENTS_DIR, { recursive: true, force: true }); + }); + + it('should write no deployments file', () => { + expect(result.deploymentsFile).toBe(''); + expect(existsSync(DEPLOYMENTS_DIR)).toBe(false); + }); + + it('should land the deploy plus one insert per further fragment', () => { + expect(result.artifact).toBe('Fragmented'); + expect(result.fragments).toBe(4); + expect(result.circuits).toBe(20); + }); + + it('should hold exactly the artifact circuits with byte-equal verifier keys', async () => { + const state = await provider.queryContractState(result.address); + if (state === null) throw new Error(`no contract at ${result.address}`); + const onChain = state + .operations() + .map((op) => (typeof op === 'string' ? op : Buffer.from(op).toString())) + .sort(); + + expect(onChain).toStrictEqual(CIRCUITS); + for (const name of CIRCUITS) { + expect(state.operation(name)?.verifierKey).toStrictEqual( + new Uint8Array(await zkConfig.getVerifierKey(name)), + ); + } + }); + + it('should store the in-code private state under the pattern id', async () => { + await expect( + privateStateProvider.get(PRIVATE_STATE_ID), + ).resolves.toStrictEqual(IN_CODE_STATE); + }); +}); diff --git a/tests/integrations/specs/deploy/witnesses.type-test.ts b/tests/integrations/specs/deploy/witnesses.type-test.ts new file mode 100644 index 0000000..86f2b86 --- /dev/null +++ b/tests/integrations/specs/deploy/witnesses.type-test.ts @@ -0,0 +1,24 @@ +/** + * Type-level guard for the `witnesses` option. Witness modules declare their + * sets as interfaces, which carry no index signature, and the option must take + * one as it is. + * + * Imported by nothing and never run: `tsc -p tests/integrations`, part of + * `yarn types`, compiles it against the built package. + */ +import type { DeployerOptions } from '@openzeppelin/compact-deployer/deployer'; +import type { RunDeployOptions } from '@openzeppelin/compact-deployer/run-deploy'; +import { + type IPrivateCounterWitnesses, + type PrivateCounterState, + PrivateCounterWitnesses, +} from '../../fixtures/witnesses/PrivateCounter.witness.ts'; + +const fromInterface: IPrivateCounterWitnesses = + PrivateCounterWitnesses(); + +const _deployer: DeployerOptions['witnesses'] = fromInterface; +const _runDeploy: RunDeployOptions['witnesses'] = fromInterface; + +// @ts-expect-error a witness set is an object +const _notAnObject: DeployerOptions['witnesses'] = 'witnesses'; diff --git a/yarn.lock b/yarn.lock index c6e2944..d19e5cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -594,10 +594,10 @@ __metadata: languageName: node linkType: hard -"@midnightntwrk/ledger-v9@npm:1.0.0-rc.3": - version: 1.0.0-rc.3 - resolution: "@midnightntwrk/ledger-v9@npm:1.0.0-rc.3" - checksum: 10/5d2c2ff808b6dcc8f8ba2b75f81467c9bc4904e625e246bea1f8c77be7eb6d06cac28b189d1f6b708a85559ddf133efe93147da12169db48f927c72f414d0fa7 +"@midnightntwrk/ledger-v9@npm:1.0.0-rc.5": + version: 1.0.0-rc.5 + resolution: "@midnightntwrk/ledger-v9@npm:1.0.0-rc.5" + checksum: 10/e40470c85cc4a74f5cec3cc5d4f7bbfd4e2b54cd5ac4a1b128d1c919219e1a6574ac74db5558f99521a280453cb07d778cccb38bcf842389506104103666bfc0 languageName: node linkType: hard @@ -1002,7 +1002,7 @@ __metadata: "@midnight-ntwrk/midnight-js-types": "npm:5.0.0-beta.7" "@midnight-ntwrk/midnight-js-utils": "npm:5.0.0-beta.7" "@midnight-ntwrk/testkit-js": "npm:5.0.0-beta.7" - "@midnightntwrk/ledger-v9": "npm:1.0.0-rc.3" + "@midnightntwrk/ledger-v9": "npm:1.0.0-rc.5" "@midnightntwrk/wallet-sdk-address-format": "npm:4.0.0-beta.2" "@midnightntwrk/wallet-sdk-capabilities": "npm:4.0.0-beta.2" "@midnightntwrk/wallet-sdk-dust-wallet": "npm:5.0.0-beta.2"