From 276841abdb004d06574a4b8b30b9045b371835b9 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 24 Sep 2026 10:14:26 +0200 Subject: [PATCH 1/9] build(deployer): move ledger-v9 to 1.0.0-rc.5 compact-contracts main already runs rc.5. With rc.3 pinned here, a consumer's resolution overrides the pin anyway, so the deployer would run on a ledger it was never tested against. --- package.json | 2 +- packages/deployer/CHANGELOG.md | 1 + packages/deployer/README.md | 4 ++-- packages/deployer/package.json | 2 +- yarn.lock | 10 +++++----- 5 files changed, 10 insertions(+), 9 deletions(-) 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..d9f616e 100644 --- a/packages/deployer/CHANGELOG.md +++ b/packages/deployer/CHANGELOG.md @@ -20,6 +20,7 @@ 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) +- `@midnightntwrk/ledger-v9` moves from 1.0.0-rc.3 to 1.0.0-rc.5. Update the yarn / pnpm resolution to match (#PR) - `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 diff --git a/packages/deployer/README.md b/packages/deployer/README.md index ae36e6e..fb43cd6 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 | @@ -55,7 +55,7 @@ yarn and pnpm need one pin, because `compact-js` declares the ledger as a range ```json "resolutions": { - "@midnightntwrk/ledger-v9": "1.0.0-rc.3" + "@midnightntwrk/ledger-v9": "1.0.0-rc.5" } ``` 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/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" From 48c231361a6699716b13e25ad40b391c0406b613 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 24 Sep 2026 10:14:26 +0200 Subject: [PATCH 2/9] docs(deployer): split out the 0.2.0 changelog The 0.2.0 release shipped the fragmented deploy (#174), but its entries stayed under Unreleased and would have been published again as 0.3.0 changes. --- packages/deployer/CHANGELOG.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/deployer/CHANGELOG.md b/packages/deployer/CHANGELOG.md index d9f616e..872dd82 100644 --- a/packages/deployer/CHANGELOG.md +++ b/packages/deployer/CHANGELOG.md @@ -8,12 +8,6 @@ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -### 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) - ### Changed - **Breaking:** the deploy stack moves to Ledger v9. Compile artifacts with `compact compile +0.34.0`; an older one fails at submit with `Version mismatch`. Versions in the README's "Supported stack" (#192) @@ -21,8 +15,19 @@ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) - `@midnightntwrk/ledger-v9` moves from 1.0.0-rc.3 to 1.0.0-rc.5. Update the yarn / pnpm resolution to match (#PR) -- `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 + +## 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) From 3b187f569eb6736c1eaf7cdf98dec8bca0e7bb23 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 24 Sep 2026 11:35:07 +0200 Subject: [PATCH 3/9] feat(deployer): match [contracts] keys by pattern A key with glob characters matches the contract name, and a key with "/" matches its .compact path under the new [profile].src_dir. Matching patterns apply in file order and the exact entry applies last, so one table can cover every test mock in a repo. --- .../src/config/compact-config.test.ts | 247 +++++++++++++++++- .../deployer/src/config/compact-config.ts | 111 ++++++-- packages/deployer/src/config/patterns.test.ts | 213 +++++++++++++++ packages/deployer/src/config/patterns.ts | 101 +++++++ packages/deployer/src/config/schema.test.ts | 123 ++++++++- packages/deployer/src/config/schema.ts | 91 +++++-- .../src/loaders/contract-resolve.test.ts | 37 +++ .../deployer/src/loaders/contract-resolve.ts | 12 +- 8 files changed, 876 insertions(+), 59 deletions(-) create mode 100644 packages/deployer/src/config/patterns.test.ts create mode 100644 packages/deployer/src/config/patterns.ts 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/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}`, ); } From 8bc24ebc14fcd80a254ab2272d5f46a79e1c2054 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 24 Sep 2026 11:37:06 +0200 Subject: [PATCH 4/9] feat(deployer): take per-deploy values from code A test harness deploys once per test from parallel workers, with private state and witnesses built in code. initialPrivateState and witnesses override the TOML entry, and record: false skips the deployments ledger and its lock. Failure messages under record: false name the address and txIds instead of a record to resume from. --- .../deployer/src/deployer.options.test.ts | 579 ++++++++++++++++++ packages/deployer/src/deployer.ts | 133 +++- packages/deployer/src/errors.test.ts | 10 + packages/deployer/src/errors.ts | 27 +- .../deployer/src/loaders/artifact.test.ts | 16 + packages/deployer/src/loaders/artifact.ts | 18 +- .../deployer/src/loaders/init-state.test.ts | 10 + packages/deployer/src/loaders/init-state.ts | 10 +- packages/deployer/src/runDeploy.test.ts | 41 ++ packages/deployer/src/runDeploy.ts | 19 +- .../deployer/src/services/chain-state.test.ts | 15 + packages/deployer/src/services/chain-state.ts | 6 +- packages/deployer/src/services/deploy-tx.ts | 36 +- 13 files changed, 875 insertions(+), 45 deletions(-) create mode 100644 packages/deployer/src/deployer.options.test.ts diff --git a/packages/deployer/src/deployer.options.test.ts b/packages/deployer/src/deployer.options.test.ts new file mode 100644 index 0000000..ce46263 --- /dev/null +++ b/packages/deployer/src/deployer.options.test.ts @@ -0,0 +1,579 @@ +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, + 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(); + }); + }); +}); diff --git a/packages/deployer/src/deployer.ts b/packages/deployer/src/deployer.ts index c22d468..aabf09f 100644 --- a/packages/deployer/src/deployer.ts +++ b/packages/deployer/src/deployer.ts @@ -94,6 +94,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 +154,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 +179,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 +304,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 +365,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 +384,7 @@ export class Deployer implements AsyncDisposable { const initialPrivateState = await InitialPrivateState.load( contract.init_private_state, rootDir, + opts.initialPrivateState, ); let wallet: MidnightWalletProvider; @@ -458,7 +471,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 +487,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 +565,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 +595,7 @@ export class Deployer implements AsyncDisposable { */ async #deployFragmentZero(args: { providers: ContractProviders; - deployments: Deployments; + deployments: DeploymentsLedger; txTimeoutMs: number; force: boolean; }): Promise { @@ -630,7 +651,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 @@ -936,7 +963,7 @@ export class Deployer implements AsyncDisposable { */ async #insertRemaining(args: { providers: ContractProviders; - deployments: Deployments; + deployments: DeploymentsLedger; started: Exclude; keys: ArtifactKeys; txTimeoutMs: number; @@ -986,6 +1013,7 @@ export class Deployer implements AsyncDisposable { address, snapshot, verifyingKey: verifyingKeyOf(s.signingKey.ledgerKey), + recorded: s.opts.record !== false, }); await this.#recordProgress({ deployments, @@ -1044,7 +1072,7 @@ export class Deployer implements AsyncDisposable { */ async #insertBatch(args: { providers: ContractProviders; - deployments: Deployments; + deployments: DeploymentsLedger; started: Exclude; keys: ArtifactKeys; all: readonly string[]; @@ -1155,7 +1183,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 +1293,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/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(', ')}.`; +} From 13e508000b607edbb4e528cd9b1391e14896ea67 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 24 Sep 2026 11:37:43 +0200 Subject: [PATCH 5/9] feat(deployer): remember the accepted fragment size With no budget set, a later deploy of the same artifact on the same network starts at the size an earlier halving in the process settled on, so only the first deploy pays for the refused attempt. The size lives in memory; a pinned budget and a resume ignore it. --- .../deployer/src/deployer.options.test.ts | 106 ++++++++++++++++++ packages/deployer/src/deployer.ts | 26 ++++- .../deployer/src/services/fragment-size.ts | 29 +++++ 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 packages/deployer/src/services/fragment-size.ts diff --git a/packages/deployer/src/deployer.options.test.ts b/packages/deployer/src/deployer.options.test.ts index ce46263..a52fb95 100644 --- a/packages/deployer/src/deployer.options.test.ts +++ b/packages/deployer/src/deployer.options.test.ts @@ -27,6 +27,7 @@ import { fakeUnsubmittedDeploy, fakeVerifierKeys, headPath, + recordingLogger, silentLogger, } from './deployer.testkit.ts'; import { Deployer, type DeployerOptions } from './deployer.ts'; @@ -576,4 +577,109 @@ witnesses = { module = "witnesses.mjs", export = "witnesses" }`; 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 aabf09f..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, @@ -609,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({ @@ -691,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. @@ -917,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[]; @@ -935,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`, 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]); +} From 6e07bbc5971e7fe2cd78ea949509fe3a4779013b Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 24 Sep 2026 11:38:16 +0200 Subject: [PATCH 6/9] test(integration): deploy Fragmented through a pattern The spec has its own compact.toml with a pinned budget, so no halving here can reach fragmented.spec.ts through the remembered size: the suite runs every spec in one process. --- tests/integrations/README.md | 6 +- .../pattern-no-record.compact.toml | 27 ++++ .../specs/deploy/patternNoRecord.spec.ts | 118 ++++++++++++++++++ .../specs/deploy/witnesses.type-test.ts | 24 ++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 tests/integrations/pattern-no-record.compact.toml create mode 100644 tests/integrations/specs/deploy/patternNoRecord.spec.ts create mode 100644 tests/integrations/specs/deploy/witnesses.type-test.ts 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'; From 33ef64bc8f548d3c4170a8293b348b06a691c43b Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 24 Sep 2026 11:38:56 +0200 Subject: [PATCH 7/9] docs(deployer): document patterns and new options Known issue 7 now records that one forced compact-runtime copy deploys: compact-contracts pins 0.19.0 and its live harness deployed a 24-circuit mock through the split path on that tree. --- packages/deployer/CHANGELOG.md | 11 +++++++- packages/deployer/README.md | 46 +++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/deployer/CHANGELOG.md b/packages/deployer/CHANGELOG.md index 872dd82..84e60ef 100644 --- a/packages/deployer/CHANGELOG.md +++ b/packages/deployer/CHANGELOG.md @@ -8,13 +8,22 @@ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- `[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 - **Breaking:** the deploy stack moves to Ledger v9. Compile artifacts with `compact compile +0.34.0`; an older one fails at submit with `Version mismatch`. Versions in the README's "Supported stack" (#192) - **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) -- `@midnightntwrk/ledger-v9` moves from 1.0.0-rc.3 to 1.0.0-rc.5. Update the yarn / pnpm resolution to match (#PR) +- `@midnightntwrk/ledger-v9` moves from 1.0.0-rc.3 to 1.0.0-rc.5. 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) diff --git a/packages/deployer/README.md b/packages/deployer/README.md index fb43cd6..cbf7819 100644 --- a/packages/deployer/README.md +++ b/packages/deployer/README.md @@ -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(); +``` From a472fc0cd75fdfb43b16d6f3236eb19ca6b95092 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 24 Sep 2026 11:39:04 +0200 Subject: [PATCH 8/9] docs(deployer): date the changelog for 0.3.0 --- packages/deployer/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/deployer/CHANGELOG.md b/packages/deployer/CHANGELOG.md index 84e60ef..ae3f640 100644 --- a/packages/deployer/CHANGELOG.md +++ b/packages/deployer/CHANGELOG.md @@ -6,7 +6,7 @@ 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 From 07487d8466f9532403bfc2f14764cc868a1f17df Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 24 Sep 2026 12:29:17 +0200 Subject: [PATCH 9/9] docs(deployer): add the npm ledger pin The wallet SDK and midnight-js-protocol pin ledger-v9 1.0.0-rc.3 exactly, so npm nests rc.3 copies next to the deployer's rc.5. The README only gave the yarn and pnpm forms of the pin. --- packages/deployer/CHANGELOG.md | 2 +- packages/deployer/README.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/deployer/CHANGELOG.md b/packages/deployer/CHANGELOG.md index ae3f640..74a6f5a 100644 --- a/packages/deployer/CHANGELOG.md +++ b/packages/deployer/CHANGELOG.md @@ -22,7 +22,7 @@ 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) -- `@midnightntwrk/ledger-v9` moves from 1.0.0-rc.3 to 1.0.0-rc.5. Update the yarn / pnpm resolution to match (#193) +- `@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) diff --git a/packages/deployer/README.md b/packages/deployer/README.md index cbf7819..05b06c6 100644 --- a/packages/deployer/README.md +++ b/packages/deployer/README.md @@ -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": { +"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