diff --git a/deployments/README.md b/deployments/README.md index 4d54dc5..1337865 100644 --- a/deployments/README.md +++ b/deployments/README.md @@ -34,7 +34,8 @@ if the two disagree. "name": "ERC5564Announcer", // contract, crate or Anchor program name "id": "0x8AE6…", // address / contract ID / program ID / code hash "version": "0.1.0", // package or crate version, null if unknown - "sourceCommit": "…", // 40-hex git commit, null if unknown + "sourceCommit": "…", // full 40-hex git commit; null only when unverifiable + "provenance": { "status": "source-verified" }, // or { "status": "unverifiable", "reason": "…" } "artifactHash": { "algorithm": "keccak256", "value": "0x…" }, "abi": { "path": "evm/subgraph/abis/ERC5564Announcer.json", "sha256": "0x…" }, "deployment": { @@ -69,9 +70,35 @@ so formatting does not matter). Solana programs carry `solana.programData` and `external: true` marks third-party scripts Wraith depends on (ckb-auth). `recordedBy: "chain-query"` means the record was reconstructed from chain state after -the fact, so `sourceCommit` and `version` may be `null`. The current EVM, Solana and CKB -records are of this kind: they predate the manifest. Records written by a deploy script -must carry a source commit. +the fact. + +### Source provenance + +Every first-party contract carries `provenance`: + +- `source-verified`: `sourceCommit` is a full 40-hex commit and building it reproduces + `artifactHash`. `npm run verify-source` rebuilds and checks this (EVM via Hardhat, + Stellar via the `stellar/deploy.sh` build; contracts with Solidity immutables are + compared against the deployed code everywhere except the immutable slots). +- `unverifiable`: a historical record. `sourceCommit` is `null` and `reason` says why. + The current EVM, Solana and CKB records are of this kind, because they predate the + manifest and no build has been shown to reproduce them. Solana and CKB have no + reproducible build yet, so they cannot be source-verified. + +Records written by a deploy script must be `source-verified`: `record` rejects a missing +or malformed `--source-commit` (only `HEAD` or a full hash), a working tree with +uncommitted changes, and a commit whose build does not reproduce the artifact hash. +Re-recording unchanged bytecode keeps its existing provenance; changed bytecode resets +it to `unverifiable` unless a commit is supplied. + +To upgrade a historical record, prove the commit offline: + +```bash +npm run attest -- evm --network horizen-testnet --contract ERC5564Announcer --search +npm run attest -- evm --network horizen-testnet --contract ERC5564Announcer --commit +``` + +A record is only changed when a build reproduces the recorded hash. ## Consumers @@ -141,10 +168,12 @@ it names; `npm run deploy` and `npm run deploy-local` in `evm/subgraph` run it b ## Commands -| Command | What it does | -| ------------------ | ----------------------------------------------------------------------------------------------- | -| `npm run build` | Validate records, write `manifest.json`, regenerate the generated consumers. | -| `npm run check` | Offline validation of records, manifest and consumers. Runs in CI. | -| `npm run verify` | Re-read every deployment from its chain and compare IDs, heights and hashes. Runs weekly in CI. | -| `npm run subgraph` | Write `evm/subgraph/subgraph.yaml` for `--network `; `--check` verifies it. | -| `npm test` | Unit and end-to-end tests for the tool. | +| Command | What it does | +| ----------------------- | ----------------------------------------------------------------------------------------------- | +| `npm run build` | Validate records, write `manifest.json`, regenerate the generated consumers. | +| `npm run check` | Offline validation of records, manifest and consumers. Runs in CI. | +| `npm run verify-source` | Rebuild each source-verified contract from its commit and compare artifact hashes. | +| `npm run attest` | Backfill: mark a historical record source-verified once a commit reproduces it. | +| `npm run verify` | Re-read every deployment from its chain and compare IDs, heights and hashes. Runs weekly in CI. | +| `npm run subgraph` | Write `evm/subgraph/subgraph.yaml` for `--network `; `--check` verifies it. | +| `npm test` | Unit and end-to-end tests for the tool. | diff --git a/deployments/ckb/ckb-testnet.json b/deployments/ckb/ckb-testnet.json index d93d2e4..8ac9deb 100644 --- a/deployments/ckb/ckb-testnet.json +++ b/deployments/ckb/ckb-testnet.json @@ -10,6 +10,10 @@ "id": "0x31f6ab9c7e7a26ecba980b838ac3b5bd6c3a2f1b945e75b7cf7e6a46cb19cb87", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "blake2b-256", "value": "0x31f6ab9c7e7a26ecba980b838ac3b5bd6c3a2f1b945e75b7cf7e6a46cb19cb87" @@ -34,6 +38,10 @@ "id": "0xc133817d433f72ea16a2404adaf961524e9572c8378829a21968710d6182e20d", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "blake2b-256", "value": "0xc133817d433f72ea16a2404adaf961524e9572c8378829a21968710d6182e20d" diff --git a/deployments/evm/horizen-testnet.json b/deployments/evm/horizen-testnet.json index f5e2dbe..05bd852 100644 --- a/deployments/evm/horizen-testnet.json +++ b/deployments/evm/horizen-testnet.json @@ -10,6 +10,10 @@ "id": "0x8AE65c05E7eb48B9bA652781Bc0a3DBA09A484F3", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "keccak256", "value": "0x9b846a07b17d019277a06764e6d0281dac81741c9a64944ede50ddc23ef10be0" @@ -30,6 +34,10 @@ "id": "0x953E6cEdcdfAe321796e7637d33653F6Ce05c527", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "keccak256", "value": "0x81d5bdf8d1ccb3ced05fde681ae45ecd6ae51dde1e368fed331eeb1684a383e2" diff --git a/deployments/manifest.json b/deployments/manifest.json index 3afbde3..ec724e9 100644 --- a/deployments/manifest.json +++ b/deployments/manifest.json @@ -14,6 +14,10 @@ "id": "0x8AE65c05E7eb48B9bA652781Bc0a3DBA09A484F3", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "keccak256", "value": "0x9b846a07b17d019277a06764e6d0281dac81741c9a64944ede50ddc23ef10be0" @@ -34,6 +38,10 @@ "id": "0x953E6cEdcdfAe321796e7637d33653F6Ce05c527", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "keccak256", "value": "0x81d5bdf8d1ccb3ced05fde681ae45ecd6ae51dde1e368fed331eeb1684a383e2" @@ -63,6 +71,10 @@ "id": "9Ko7TuXHpLUH1ZsZWQEpeA9Tv7hX325ooWk5SD7Y9nuq", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "sha256", "value": "0x2be5d2b1e9d08926ab515829c7f72b16367f59a8b0fd4241318762dd184dec67" @@ -83,6 +95,10 @@ "id": "E6J7GBSTjKbYANWjfTo5HfnXZ4Tg3LAasN7NrvCwn5Dq", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "sha256", "value": "0xc77d61231a088a54ac6be18ea21b20f10d48c3b47e890bda517ee8e059a593b9" @@ -103,6 +119,10 @@ "id": "4JrrQh5aK7iLvx6MgtEQk7K7X3SsWfTLxVJu1jXEwNjD", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "sha256", "value": "0x2583b2cd9cfa7dafd4a1710f61654b34796ef1932bde1c4f44f706d3821a0ea4" @@ -132,6 +152,10 @@ "id": "0x31f6ab9c7e7a26ecba980b838ac3b5bd6c3a2f1b945e75b7cf7e6a46cb19cb87", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "blake2b-256", "value": "0x31f6ab9c7e7a26ecba980b838ac3b5bd6c3a2f1b945e75b7cf7e6a46cb19cb87" @@ -156,6 +180,10 @@ "id": "0xc133817d433f72ea16a2404adaf961524e9572c8378829a21968710d6182e20d", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "blake2b-256", "value": "0xc133817d433f72ea16a2404adaf961524e9572c8378829a21968710d6182e20d" diff --git a/deployments/schema/v1.json b/deployments/schema/v1.json index cf8e894..97c6a33 100644 --- a/deployments/schema/v1.json +++ b/deployments/schema/v1.json @@ -104,9 +104,19 @@ "pattern": "^\\d+\\.\\d+\\.\\d+([-+][0-9A-Za-z.-]+)?$" }, "sourceCommit": { - "description": "Git commit the artifact was built from, or null if unknown.", + "description": "Full 40-hex git commit the artifact was built from, or null if unknown. Non-null only together with provenance.status source-verified.", "oneOf": [{ "$ref": "#/$defs/commit" }, { "type": "null" }] }, + "provenance": { + "description": "Whether sourceCommit is proven. source-verified: building sourceCommit reproduces artifactHash (checked by `verify-source`). unverifiable: a historical record whose source commit cannot be proven; sourceCommit is null and reason says why. Required on every first-party (non-external) contract; enforced by the manifest tool.", + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { + "status": { "enum": ["source-verified", "unverifiable"] }, + "reason": { "type": "string", "minLength": 1 } + } + }, "external": { "description": "True for third-party code Wraith depends on but does not build (e.g. ckb-auth).", "type": "boolean" diff --git a/deployments/solana/solana-devnet.json b/deployments/solana/solana-devnet.json index e9c8b23..1710d74 100644 --- a/deployments/solana/solana-devnet.json +++ b/deployments/solana/solana-devnet.json @@ -10,6 +10,10 @@ "id": "9Ko7TuXHpLUH1ZsZWQEpeA9Tv7hX325ooWk5SD7Y9nuq", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "sha256", "value": "0x2be5d2b1e9d08926ab515829c7f72b16367f59a8b0fd4241318762dd184dec67" @@ -30,6 +34,10 @@ "id": "E6J7GBSTjKbYANWjfTo5HfnXZ4Tg3LAasN7NrvCwn5Dq", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "sha256", "value": "0xc77d61231a088a54ac6be18ea21b20f10d48c3b47e890bda517ee8e059a593b9" @@ -50,6 +58,10 @@ "id": "4JrrQh5aK7iLvx6MgtEQk7K7X3SsWfTLxVJu1jXEwNjD", "version": null, "sourceCommit": null, + "provenance": { + "status": "unverifiable", + "reason": "Historical record reconstructed from chain state; no source commit has been shown to reproduce the artifact hash." + }, "artifactHash": { "algorithm": "sha256", "value": "0x2583b2cd9cfa7dafd4a1710f61654b34796ef1932bde1c4f44f706d3821a0ea4" diff --git a/scripts/deployment-manifest/package.json b/scripts/deployment-manifest/package.json index 4e4ddcc..551cc7b 100644 --- a/scripts/deployment-manifest/package.json +++ b/scripts/deployment-manifest/package.json @@ -12,6 +12,8 @@ "check": "tsx src/cli.ts check", "verify": "tsx src/cli.ts verify", "record": "tsx src/cli.ts record", + "verify-source": "tsx src/cli.ts verify-source", + "attest": "tsx src/cli.ts attest", "subgraph": "tsx src/cli.ts subgraph", "typecheck": "tsc --noEmit", "test": "vitest run" diff --git a/scripts/deployment-manifest/src/cli.ts b/scripts/deployment-manifest/src/cli.ts index 05429b1..074bf5e 100644 --- a/scripts/deployment-manifest/src/cli.ts +++ b/scripts/deployment-manifest/src/cli.ts @@ -1,4 +1,3 @@ -import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { parseArgs } from 'node:util'; @@ -6,6 +5,7 @@ import { CONSUMERS } from './consumers.js'; import { buildManifest, loadRecords, + readRecord, readManifest, serialize, validateDeployment, @@ -15,6 +15,7 @@ import { type Deployment, } from './manifest.js'; import { MANIFEST_PATH, REPO_ROOT, recordPath, repoPath } from './paths.js'; +import { SourceSession, candidateCommits, dirtyFiles, resolveSourceCommit } from './provenance.js'; import { ckbNetworkId, recordCkbScript } from './record/ckb.js'; import { evmNetworkId, recordEvmContract } from './record/evm.js'; import { recordSolanaProgram, solanaNetworkId } from './record/solana.js'; @@ -58,6 +59,18 @@ const USAGE = `Usage: tsx src/cli.ts --version | --version name= (repeatable) --public-rpc stored as rpcUrl for \`verify\`. --rpc is never stored, so it may carry an API key. + --unverifiable-reason why no source commit is recorded + deploy-script records need --source-commit (HEAD or a full hash), a clean + working tree, and a build of that commit that reproduces the artifact hash. + verify-source [--chain ] [--network ] [--contract ] + Rebuild every source-verified contract from its recorded commit and fail + unless the build reproduces the recorded artifact hash. Needs the chain's + toolchain (and network access for EVM contracts with immutables). + attest --network --contract + (--commit | --search [--max ]) + Backfill: if building the commit reproduces the recorded artifact hash, + record it as source-verified. --search tries the commits that touched the + chain's sources before the deployment, newest first. `; const userPath = (p: string) => path.resolve(process.env.INIT_CWD ?? process.cwd(), p); @@ -106,12 +119,16 @@ function check(): void { } for (const consumer of CONSUMERS) problems.push(...consumer.check(manifest)); - const unknown = manifest.deployments.flatMap((d) => + const unverifiable = manifest.deployments.flatMap((d) => d.contracts - .filter((c) => !c.sourceCommit && !c.external) + .filter((c) => c.provenance?.status === 'unverifiable') .map((c) => `${d.network}/${c.name}`), ); - if (unknown.length) console.log(`ℹ source commit not recorded for: ${unknown.join(', ')}`); + if (unverifiable.length) { + console.log( + `ℹ unverifiable historical records (source commit unproven): ${unverifiable.join(', ')}`, + ); + } } if (problems.length) { console.error(`Deployment manifest check failed (${problems.length}):`); @@ -236,6 +253,7 @@ async function record(chain: Chain, argv: string[]): Promise { 'source-commit': { type: 'string' }, version: { type: 'string', multiple: true }, 'public-rpc': { type: 'string' }, + 'unverifiable-reason': { type: 'string' }, }, }); if (!values.network || !values.rpc) throw new Error('--network and --rpc are required'); @@ -251,20 +269,25 @@ async function record(chain: Chain, argv: string[]): Promise { ); } - let sourceCommit = values['source-commit'] ?? null; - if (sourceCommit === 'HEAD') { - sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: REPO_ROOT, - encoding: 'utf8', - }).trim(); - const dirty = execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { - cwd: REPO_ROOT, - encoding: 'utf8', - }); - if (dirty.trim()) - console.warn( - '⚠ working tree has uncommitted changes; sourceCommit may not match the deployed code', + const recordedBy = values['recorded-by'] as Deployment['recordedBy']; + if (recordedBy !== 'deploy-script' && recordedBy !== 'chain-query') { + throw new Error(`--recorded-by must be deploy-script or chain-query, got "${recordedBy}"`); + } + let sourceCommit: string | null = null; + if (values['source-commit'] !== undefined) { + sourceCommit = resolveSourceCommit(values['source-commit']); + } else if (recordedBy === 'deploy-script') { + throw new Error( + 'records written by a deploy script need --source-commit (HEAD or a full hash)', + ); + } + if (sourceCommit && recordedBy === 'deploy-script') { + const dirty = dirtyFiles(); + if (dirty.length) { + throw new Error( + `working tree has uncommitted changes, so ${sourceCommit} is not what was built:\n${dirty.join('\n')}`, ); + } } const versions = new Map(); @@ -276,15 +299,46 @@ async function record(chain: Chain, argv: string[]): Promise { const recorded = await recordContracts(chain, values.rpc, values, existing); const contracts = new Map((existing?.contracts ?? []).map((c) => [c.name, c])); - for (const { name, id, ...rest } of recorded) { - contracts.set(name, { - name, - id, - version: rest.external ? null : (versions.get(name) ?? defaultVersion), - sourceCommit: rest.external ? null : sourceCommit, - ...rest, - }); + const session = new SourceSession(); + const unproven: string[] = []; + try { + for (const { name, id, ...rest } of recorded) { + if (rest.external) { + contracts.set(name, { name, id, version: null, sourceCommit: null, ...rest }); + continue; + } + const previous = contracts.get(name); + const sameArtifact = previous?.artifactHash.value === rest.artifactHash.value; + const version = + versions.get(name) ?? defaultVersion ?? (sameArtifact ? previous!.version : null); + let commit = sourceCommit; + let provenance: Contract['provenance']; + if (commit) { + const why = await session.check( + chain, + { name, id, sourceCommit: commit, ...rest }, + values.rpc, + ); + if (why) unproven.push(`${name}: ${why}`); + provenance = { status: 'source-verified' }; + } else if (sameArtifact && previous?.provenance) { + // Same bytes as before: what was proven about them still holds. + commit = previous.sourceCommit; + provenance = previous.provenance; + } else { + provenance = { + status: 'unverifiable', + reason: + values['unverifiable-reason'] ?? + 'Recorded from chain state without a source commit; no build has been shown to reproduce the artifact hash.', + }; + } + contracts.set(name, { name, id, version, sourceCommit: commit, provenance, ...rest }); + } + } finally { + session.dispose(); } + if (unproven.length) fail(unproven.map((u) => `source commit does not reproduce: ${u}`)); const deployment: Deployment = { chain, @@ -293,7 +347,7 @@ async function record(chain: Chain, argv: string[]): Promise { ...((values['public-rpc'] ?? existing?.rpcUrl) ? { rpcUrl: values['public-rpc'] ?? existing?.rpcUrl } : {}), - recordedBy: values['recorded-by'] as Deployment['recordedBy'], + recordedBy, contracts: [...contracts.values()], }; const errors = validateDeployment(deployment); @@ -365,11 +419,115 @@ async function verify(): Promise { console.log('✓ every deployment matches its chain'); } +async function verifySource(argv: string[]): Promise { + const { values } = parseArgs({ + args: argv, + options: { + chain: { type: 'string' }, + network: { type: 'string' }, + contract: { type: 'string' }, + }, + }); + const manifest = readManifest(); + const session = new SourceSession(); + const problems: string[] = []; + let checked = 0; + try { + for (const d of manifest.deployments) { + if (values.chain && d.chain !== values.chain) continue; + if (values.network && d.network !== values.network) continue; + for (const c of d.contracts) { + if (c.external || (values.contract && c.name !== values.contract)) continue; + const where = `${d.network}/${c.name}`; + if (c.provenance?.status !== 'source-verified') { + console.log( + `- ${where}: unverifiable historical record, skipped (${c.provenance?.reason ?? 'no provenance'})`, + ); + continue; + } + checked++; + const why = await session.check(d.chain, c, d.rpcUrl); + if (why) problems.push(`${where}: ${why}`); + else console.log(`✓ ${where}: ${c.sourceCommit} reproduces ${c.artifactHash.value}`); + } + } + } finally { + session.dispose(); + } + if (problems.length) fail(problems); + console.log(`✓ ${checked} source-verified contract(s) reproduce their artifact hash`); +} + +async function attest(chain: Chain, argv: string[]): Promise { + const { values } = parseArgs({ + args: argv, + options: { + network: { type: 'string' }, + contract: { type: 'string' }, + commit: { type: 'string' }, + search: { type: 'boolean', default: false }, + max: { type: 'string', default: '20' }, + }, + }); + if (!values.network || !values.contract) throw new Error('--network and --contract are required'); + if (Boolean(values.commit) === values.search) + throw new Error('pass exactly one of --commit or --search'); + + const file = recordPath(chain, values.network); + const { deployment, errors } = readRecord(chain, file); + if (!deployment || errors.length) fail(errors); + const contract = deployment!.contracts.find((c) => c.name === values.contract); + if (!contract) throw new Error(`${values.contract} is not in ${path.relative(REPO_ROOT, file)}`); + if (contract.external) throw new Error(`${contract.name} is external: Wraith does not build it`); + + const candidates = values.commit + ? [resolveSourceCommit(values.commit)] + : candidateCommits(chain, contract.deployment.timestamp, Number(values.max)); + if (!candidates.length) + throw new Error('no candidate commits (shallow clone? try `git fetch --unshallow`)'); + + const session = new SourceSession(); + let proven: string | null = null; + const misses: string[] = []; + try { + for (const commit of candidates) { + console.log(`… trying ${commit}`); + const why = await session.check( + chain, + { ...contract, sourceCommit: commit }, + deployment!.rpcUrl, + ); + if (!why) { + proven = commit; + break; + } + misses.push(`${commit}: ${why}`); + } + } finally { + session.dispose(); + } + if (!proven) { + console.error(`No commit reproduces ${contract.name}; the record stays unverifiable.`); + fail(misses); + } + contract.sourceCommit = proven; + contract.provenance = { status: 'source-verified' }; + const bad = validateDeployment(deployment!); + if (bad.length) fail(bad); + fs.writeFileSync(file, serialize(deployment)); + console.log(`✓ ${contract.name} is source-verified at ${proven}`); + build(); +} + async function main(): Promise { const [command, ...rest] = process.argv.slice(2); if (command === 'build') return build(); if (command === 'check') return check(); if (command === 'verify') return verify(); + if (command === 'verify-source') return verifySource(rest); + if (command === 'attest' && ['evm', 'stellar', 'solana', 'ckb'].includes(rest[0])) { + return attest(rest[0] as Chain, rest.slice(1)); + } if (command === 'subgraph') return subgraph(rest); if (command === 'record' && ['evm', 'stellar', 'solana', 'ckb'].includes(rest[0])) { return record(rest[0] as Chain, rest.slice(1)); diff --git a/scripts/deployment-manifest/src/manifest.ts b/scripts/deployment-manifest/src/manifest.ts index cfe7fe7..559856e 100644 --- a/scripts/deployment-manifest/src/manifest.ts +++ b/scripts/deployment-manifest/src/manifest.ts @@ -7,11 +7,61 @@ import { DEPLOYMENTS_DIR, MANIFEST_PATH, REPO_ROOT, SCHEMA_PATH } from './paths. export const CHAINS = ['evm', 'stellar', 'solana', 'ckb'] as const; export type Chain = (typeof CHAINS)[number]; +export interface Provenance { + status: 'source-verified' | 'unverifiable'; + reason?: string; +} + +export const COMMIT_PATTERN = /^[0-9a-f]{40}$/; +const NULL_COMMIT = '0'.repeat(40); + +/** null when `value` is a usable full git commit hash, otherwise why it is not. */ +export function commitError(value: unknown): string | null { + if (typeof value !== 'string') return 'must be a 40-character lowercase hex git commit'; + if (!COMMIT_PATTERN.test(value)) + return `"${value}" is not a full 40-character lowercase hex git commit`; + if (value === NULL_COMMIT) return 'is the all-zero null commit'; + return null; +} + +/** + * Provenance rules JSON Schema cannot express. A first-party contract is either + * source-verified (a well-formed commit that `verify-source` reproduces) or an + * explicitly unverifiable historical record. Records written by a deploy script + * can only be the former. + */ +export function provenanceErrors(deployment: Deployment, contract: Contract): string[] { + if (contract.external) return []; + const { provenance, sourceCommit } = contract; + if (!provenance) { + return ['provenance is required on first-party contracts (source-verified or unverifiable)']; + } + const errors: string[] = []; + if (provenance.status === 'source-verified') { + if (sourceCommit === null || sourceCommit === undefined) { + errors.push('provenance source-verified requires a sourceCommit, got null'); + } else { + const problem = commitError(sourceCommit); + if (problem) errors.push(`sourceCommit ${problem}`); + } + } else { + if (deployment.recordedBy === 'deploy-script') { + errors.push('records written by a deploy script must be source-verified, not unverifiable'); + } + if (sourceCommit !== null) { + errors.push('provenance unverifiable contradicts a non-null sourceCommit'); + } + if (!provenance.reason) errors.push('provenance unverifiable requires a reason'); + } + return errors; +} + export interface Contract { name: string; id: string; version: string | null; sourceCommit: string | null; + provenance?: Provenance; external?: boolean; artifactHash: { algorithm: 'keccak256' | 'sha256' | 'blake2b-256'; value: string }; abi?: { path: string; sha256: string }; @@ -78,6 +128,7 @@ export function validateDeployment(deployment: Deployment): string[] { if (deployment.recordedBy === 'deploy-script' && !contract.sourceCommit && !contract.external) { errors.push(`${where} records written by a deploy script must carry sourceCommit`); } + errors.push(...provenanceErrors(deployment, contract).map((e) => `${where} ${e}`)); }); return errors; } diff --git a/scripts/deployment-manifest/src/provenance.ts b/scripts/deployment-manifest/src/provenance.ts new file mode 100644 index 0000000..2f6468c --- /dev/null +++ b/scripts/deployment-manifest/src/provenance.ts @@ -0,0 +1,324 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { ckbHashHex, fromHex, keccak256Hex, sha256Hex } from './hash.js'; +import { COMMIT_PATTERN, commitError, type Chain, type Contract } from './manifest.js'; +import { REPO_ROOT } from './paths.js'; +import { jsonRpc } from './record/rpc.js'; + +/* -------------------------------------------------------------------------- */ +/* Git helpers */ +/* -------------------------------------------------------------------------- */ + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +/** + * Turn `--source-commit` into a full 40-hex commit that exists in this repository. + * Only `HEAD` or a full hash is accepted: short hashes, branch names and tags are + * ambiguous or movable, so they are not provenance. + */ +export function resolveSourceCommit(input: string, repoRoot = REPO_ROOT): string { + let commit = input; + if (input === 'HEAD') { + commit = git(['rev-parse', 'HEAD'], repoRoot); + } else { + commit = input.toLowerCase(); + const problem = commitError(commit); + if (problem) throw new Error(`--source-commit ${problem} (or pass HEAD)`); + } + try { + git(['cat-file', '-e', `${commit}^{commit}`], repoRoot); + } catch { + throw new Error( + `--source-commit ${commit} is not a commit in this repository (shallow clone? try \`git fetch --unshallow\`)`, + ); + } + return commit; +} + +/** Tracked files with uncommitted changes. A dirty tree means HEAD is not what was built. */ +export function dirtyFiles(repoRoot = REPO_ROOT): string[] { + const out = git(['status', '--porcelain', '--untracked-files=no'], repoRoot); + return out ? out.split('\n') : []; +} + +/** Commits (newest first) that touched the chain's sources, optionally no later than `before`. */ +export function candidateCommits( + chain: Chain, + before: string | null | undefined, + max: number, + repoRoot = REPO_ROOT, +): string[] { + const args = ['log', '--format=%H', `--max-count=${max}`]; + if (before) args.push(`--before=${before}`); + args.push('--', ...SOURCE_PATHS[chain]); + const out = git(args, repoRoot); + return out ? out.split('\n').filter((c) => COMMIT_PATTERN.test(c)) : []; +} + +const SOURCE_PATHS: Record = { + evm: ['evm/contracts', 'evm/hardhat.config.ts', 'evm/package.json', 'evm/package-lock.json'], + stellar: ['stellar'], + solana: ['solana'], + ckb: ['ckb'], +}; + +/* -------------------------------------------------------------------------- */ +/* Builders */ +/* -------------------------------------------------------------------------- */ + +export interface Built { + /** The artifact exactly as it would be hashed for the manifest. */ + bytes: Uint8Array; + /** Byte ranges the compiler leaves empty and the deployment fills in (Solidity immutables). */ + immutableRanges?: Array<{ start: number; length: number }>; +} + +export interface Builder { + /** Build everything once for a checked-out commit (dir is a clean checkout). */ + prepare(dir: string): void | Promise; + /** Pick one contract's artifact out of the build. */ + collect(dir: string, name: string): Built | Promise; +} + +const SAFE_NAME = /^[A-Za-z0-9_.-]+$/; + +function run(cmd: string, args: string[], cwd: string): void { + // shell:true only on Windows, where npm/npx are .cmd shims. Arguments are fixed strings. + execFileSync(cmd, args, { + cwd, + stdio: ['ignore', 'inherit', 'inherit'], + shell: process.platform === 'win32', + }); +} + +function findFile(root: string, fileName: string): string[] { + const found: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name === fileName) found.push(full); + } + }; + if (fs.existsSync(root)) walk(root); + return found; +} + +/** Hardhat: runtime bytecode (`deployedBytecode`) as keccak256'd by the recorder (EXTCODEHASH). */ +export const evmBuilder: Builder = { + prepare(dir) { + const evm = path.join(dir, 'evm'); + run('npm', ['ci', '--ignore-scripts'], evm); + run('npx', ['hardhat', 'compile'], evm); + }, + collect(dir, name) { + if (!SAFE_NAME.test(name)) throw new Error(`unsafe contract name "${name}"`); + const artifacts = path.join(dir, 'evm', 'artifacts', 'contracts'); + const matches = findFile(artifacts, `${name}.json`); + if (matches.length !== 1) { + throw new Error(`expected one Hardhat artifact for ${name}, found ${matches.length}`); + } + const artifact = JSON.parse(fs.readFileSync(matches[0], 'utf8')) as { + sourceName: string; + contractName: string; + deployedBytecode: string; + }; + const bytes = fromHex(artifact.deployedBytecode); + + // Immutables are zero in the compiled code and filled in at deployment, so the + // compiler output is the only place that says where they are. + const dbg = JSON.parse(fs.readFileSync(matches[0].replace(/\.json$/, '.dbg.json'), 'utf8')) as { + buildInfo: string; + }; + const buildInfo = JSON.parse( + fs.readFileSync(path.resolve(path.dirname(matches[0]), dbg.buildInfo), 'utf8'), + ) as { + output: { + contracts: Record< + string, + Record< + string, + { + evm: { + deployedBytecode: { + immutableReferences?: Record>; + }; + }; + } + > + >; + }; + }; + const refs = + buildInfo.output.contracts[artifact.sourceName]?.[artifact.contractName]?.evm.deployedBytecode + .immutableReferences ?? {}; + const immutableRanges = Object.values(refs) + .flat() + .map(({ start, length }) => ({ start, length })); + return { bytes, immutableRanges }; + }, +}; + +/** Soroban: the optimized wasm, built exactly as stellar/deploy.sh builds it. */ +export const stellarBuilder: Builder = { + prepare(dir) { + // The whole workspace, as deploy.sh does: building one crate alone can unify + // dependency features differently and produce a different wasm. + run( + 'cargo', + ['build', '--target', 'wasm32-unknown-unknown', '--release', '--locked'], + path.join(dir, 'stellar'), + ); + }, + collect(dir, name) { + if (!SAFE_NAME.test(name)) throw new Error(`unsafe contract name "${name}"`); + const stellar = path.join(dir, 'stellar'); + const file = `${name.replace(/-/g, '_')}.wasm`; + const raw = path.join(stellar, 'target', 'wasm32-unknown-unknown', 'release', file); + const optimizedDir = path.join(stellar, 'target', 'optimized'); + fs.mkdirSync(optimizedDir, { recursive: true }); + const out = path.join(optimizedDir, file); + run('stellar', ['contract', 'optimize', '--wasm', raw, '--wasm-out', out], stellar); + return { bytes: fs.readFileSync(out) }; + }, +}; + +/** Solana and CKB have no deterministic in-tree build here yet, so nothing can be source-verified. */ +export const BUILDERS: Partial> = { + evm: evmBuilder, + stellar: stellarBuilder, +}; + +/* -------------------------------------------------------------------------- */ +/* Reproduction */ +/* -------------------------------------------------------------------------- */ + +export interface SessionDeps { + repoRoot?: string; + builders?: Partial>; + /** Runtime code at an address, for contracts with immutables. */ + fetchCode?: (rpcUrl: string, address: string) => Promise; +} + +const defaultFetchCode = (rpcUrl: string, address: string) => + jsonRpc(rpcUrl, 'eth_getCode', [address, 'latest']); + +/** + * Checks commits out into throwaway worktrees and builds each (chain, commit) + * once, however many contracts are checked against it. Call dispose() when done. + */ +export class SourceSession { + private repoRoot: string; + private builders: Partial>; + private fetchCode: (rpcUrl: string, address: string) => Promise; + private trees = new Map(); + private prepared = new Map>(); + + constructor(deps: SessionDeps = {}) { + this.repoRoot = deps.repoRoot ?? REPO_ROOT; + this.builders = deps.builders ?? BUILDERS; + this.fetchCode = deps.fetchCode ?? defaultFetchCode; + } + + private checkout(commit: string): string { + const existing = this.trees.get(commit); + if (existing) return existing; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wraith-src-')); + git(['worktree', 'add', '--detach', '--force', dir, commit], this.repoRoot); + this.trees.set(commit, dir); + return dir; + } + + /** + * null when building `contract.sourceCommit` reproduces `contract.artifactHash`, + * otherwise a sentence saying why not. + */ + async check( + chain: Chain, + contract: Pick, + rpcUrl?: string, + ): Promise { + const { sourceCommit, artifactHash } = contract; + const problem = commitError(sourceCommit); + if (problem) return `sourceCommit ${problem}`; + const builder = this.builders[chain]; + if (!builder) + return `no reproducible build exists for ${chain}, so its source cannot be verified`; + + let built: Built; + try { + const dir = this.checkout(sourceCommit!); + const key = `${chain}:${sourceCommit}`; + if (!this.prepared.has(key)) this.prepared.set(key, Promise.resolve(builder.prepare(dir))); + await this.prepared.get(key); + built = await builder.collect(dir, contract.name); + } catch (err) { + return `could not build ${sourceCommit} (${(err as Error).message.split('\n')[0]})`; + } + + const hashOf = (bytes: Uint8Array) => + artifactHash.algorithm === 'keccak256' + ? keccak256Hex(bytes) + : artifactHash.algorithm === 'sha256' + ? sha256Hex(bytes) + : ckbHashHex(bytes); + + if (!built.immutableRanges?.length) { + const actual = hashOf(built.bytes); + return actual === artifactHash.value + ? null + : `building ${sourceCommit} gives ${artifactHash.algorithm} ${actual}, recorded ${artifactHash.value}`; + } + + // The compiled code has holes where immutables go. It matches the deployment if + // it is identical everywhere else and the deployed code is what was recorded. + if (!rpcUrl) { + return 'has immutables, so the deployed code is needed to compare against (no rpcUrl recorded)'; + } + let deployed: Uint8Array; + try { + deployed = fromHex(await this.fetchCode(rpcUrl, contract.id)); + } catch (err) { + return `could not read deployed code (${(err as Error).message})`; + } + if (hashOf(deployed) !== artifactHash.value) { + return `deployed code hashes to ${hashOf(deployed)}, recorded ${artifactHash.value}`; + } + if (deployed.length !== built.bytes.length) { + return `building ${sourceCommit} gives ${built.bytes.length} bytes of code, deployed ${deployed.length}`; + } + const masked = new Uint8Array(deployed.length); + for (const { start, length } of built.immutableRanges) masked.fill(1, start, start + length); + for (let i = 0; i < deployed.length; i++) { + if (!masked[i] && deployed[i] !== built.bytes[i]) { + return `building ${sourceCommit} differs from the deployed code at byte ${i} (outside immutables)`; + } + } + return null; + } + + dispose(): void { + for (const dir of this.trees.values()) { + try { + git(['worktree', 'remove', '--force', dir], this.repoRoot); + } catch { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + try { + git(['worktree', 'prune'], this.repoRoot); + } catch { + /* best effort */ + } + this.trees.clear(); + this.prepared.clear(); + } +} diff --git a/scripts/deployment-manifest/test/check.e2e.test.ts b/scripts/deployment-manifest/test/check.e2e.test.ts index 48de853..f9450e8 100644 --- a/scripts/deployment-manifest/test/check.e2e.test.ts +++ b/scripts/deployment-manifest/test/check.e2e.test.ts @@ -170,6 +170,7 @@ describe('build', () => { id: 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC', version: '0.1.0', sourceCommit: 'a'.repeat(40), + provenance: { status: 'source-verified' }, artifactHash: { algorithm: 'sha256', value: `0x${'11'.repeat(32)}` }, deployment: { height: 123, txHash: null, deployer: null, timestamp: null }, }, @@ -238,6 +239,7 @@ describe('subgraph', () => { id, version: null, sourceCommit: null, + provenance: { status: 'unverifiable', reason: 'test fixture' }, artifactHash: { algorithm: 'keccak256', value: `0x${'00'.repeat(32)}` }, deployment: { height }, } as EvmRecord['contracts'][number]); diff --git a/scripts/deployment-manifest/test/provenance.test.ts b/scripts/deployment-manifest/test/provenance.test.ts new file mode 100644 index 0000000..409ed34 --- /dev/null +++ b/scripts/deployment-manifest/test/provenance.test.ts @@ -0,0 +1,237 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { keccak256Hex } from '../src/hash.js'; +import { validateDeployment, type Deployment } from '../src/manifest.js'; +import { + SourceSession, + candidateCommits, + dirtyFiles, + resolveSourceCommit, + type Builder, +} from '../src/provenance.js'; + +const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + +const bytesOf = (text: string) => new TextEncoder().encode(text); +const hashOf = (text: string) => keccak256Hex(bytesOf(text)); + +/** "Builds" by reading artifact.txt from the checkout. */ +const fileBuilder: Builder = { + prepare() {}, + collect: (dir) => ({ bytes: fs.readFileSync(path.join(dir, 'artifact.txt')) }), +}; + +let repo: string; +let first: string; +let second: string; + +beforeEach(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), 'wraith-prov-')); + git(repo, 'init', '-q'); + git(repo, 'config', 'user.email', 't@example.com'); + git(repo, 'config', 'user.name', 't'); + fs.mkdirSync(path.join(repo, 'evm', 'contracts'), { recursive: true }); + const commit = (text: string) => { + fs.writeFileSync(path.join(repo, 'artifact.txt'), text); + fs.writeFileSync(path.join(repo, 'evm', 'contracts', 'A.sol'), text); + git(repo, 'add', '-A'); + git(repo, 'commit', '-qm', text); + return git(repo, 'rev-parse', 'HEAD'); + }; + first = commit('one'); + second = commit('two'); +}); + +afterEach(() => fs.rmSync(repo, { recursive: true, force: true })); + +const contract = (commit: string | null, text: string) => ({ + name: 'A', + id: '0x8AE65c05E7eb48B9bA652781Bc0a3DBA09A484F3', + sourceCommit: commit, + artifactHash: { algorithm: 'keccak256' as const, value: hashOf(text) }, +}); + +describe('resolveSourceCommit', () => { + it('resolves HEAD and accepts a full hash', () => { + expect(resolveSourceCommit('HEAD', repo)).toBe(second); + expect(resolveSourceCommit(first.toUpperCase(), repo)).toBe(first); + }); + + it.each(['', 'main', 'abc1234', 'z'.repeat(40), '0'.repeat(40)])('rejects %j', (bad) => { + expect(() => resolveSourceCommit(bad, repo)).toThrow(/--source-commit/); + }); + + it('rejects a well-formed hash that is not in the repository', () => { + expect(() => resolveSourceCommit('b'.repeat(40), repo)).toThrow(/not a commit/); + }); +}); + +describe('dirtyFiles', () => { + it('reports uncommitted changes to tracked files only', () => { + expect(dirtyFiles(repo)).toEqual([]); + fs.writeFileSync(path.join(repo, 'untracked.txt'), 'x'); + expect(dirtyFiles(repo)).toEqual([]); + fs.writeFileSync(path.join(repo, 'artifact.txt'), 'changed'); + expect(dirtyFiles(repo).join()).toMatch(/artifact\.txt/); + }); +}); + +describe('SourceSession.check', () => { + const session = () => new SourceSession({ repoRoot: repo, builders: { evm: fileBuilder } }); + + it('accepts a commit whose build reproduces the artifact hash', async () => { + const s = session(); + try { + expect(await s.check('evm', contract(first, 'one'))).toBeNull(); + expect(await s.check('evm', contract(second, 'two'))).toBeNull(); + } finally { + s.dispose(); + } + }); + + it('rejects a commit whose build differs', async () => { + const s = session(); + try { + expect(await s.check('evm', contract(first, 'two'))).toMatch(/gives keccak256 0x/); + } finally { + s.dispose(); + } + }); + + it('rejects null, malformed and unknown commits', async () => { + const s = session(); + try { + expect(await s.check('evm', contract(null, 'one'))).toMatch(/sourceCommit/); + expect(await s.check('evm', contract('abc', 'one'))).toMatch(/sourceCommit/); + expect(await s.check('evm', contract('b'.repeat(40), 'one'))).toMatch(/could not build/); + } finally { + s.dispose(); + } + }); + + it('cannot verify a chain with no reproducible build', async () => { + const s = session(); + try { + expect(await s.check('solana', contract(first, 'one'))).toMatch(/no reproducible build/); + } finally { + s.dispose(); + } + }); + + it('leaves no worktrees behind', async () => { + const s = session(); + await s.check('evm', contract(first, 'one')); + s.dispose(); + expect(git(repo, 'worktree', 'list').split('\n')).toHaveLength(1); + }); + + it('compares around immutables against the deployed code', async () => { + const compiled = new Uint8Array([1, 2, 0, 0, 5]); + const withImmutables: Builder = { + prepare() {}, + collect: () => ({ bytes: compiled, immutableRanges: [{ start: 2, length: 2 }] }), + }; + const deployed = (bytes: number[]) => async () => `0x${Buffer.from(bytes).toString('hex')}`; + const c = (bytes: number[]) => ({ + ...contract(first, ''), + artifactHash: { + algorithm: 'keccak256' as const, + value: keccak256Hex(Uint8Array.from(bytes)), + }, + }); + const make = (bytes: number[]) => + new SourceSession({ + repoRoot: repo, + builders: { evm: withImmutables }, + fetchCode: deployed(bytes), + }); + + const ok = make([1, 2, 9, 9, 5]); + expect(await ok.check('evm', c([1, 2, 9, 9, 5]), 'https://rpc')).toBeNull(); + ok.dispose(); + + const bad = make([1, 3, 9, 9, 5]); + expect(await bad.check('evm', c([1, 3, 9, 9, 5]), 'https://rpc')).toMatch(/byte 1/); + bad.dispose(); + + const noRpc = make([1, 2, 9, 9, 5]); + expect(await noRpc.check('evm', c([1, 2, 9, 9, 5]))).toMatch(/no rpcUrl/); + noRpc.dispose(); + }); +}); + +describe('candidateCommits', () => { + it('lists commits that touched the chain sources, newest first', () => { + expect(candidateCommits('evm', null, 10, repo)).toEqual([second, first]); + expect(candidateCommits('evm', null, 1, repo)).toEqual([second]); + }); +}); + +describe('provenance validation', () => { + const base = (): Deployment => ({ + chain: 'evm', + network: 'test-net', + networkId: '1', + recordedBy: 'chain-query', + contracts: [ + { + ...contract(null, 'one'), + version: null, + provenance: { status: 'unverifiable', reason: 'historical' }, + deployment: { height: 1 }, + }, + ], + }); + + it('accepts an unverifiable historical record and a source-verified one', () => { + expect(validateDeployment(base())).toEqual([]); + const d = base(); + d.contracts[0].sourceCommit = 'a'.repeat(40); + d.contracts[0].provenance = { status: 'source-verified' }; + expect(validateDeployment(d)).toEqual([]); + }); + + it('requires provenance on first-party contracts', () => { + const d = base(); + delete d.contracts[0].provenance; + expect(validateDeployment(d).join()).toMatch(/provenance is required/); + }); + + it('rejects contradictions and malformed commits', () => { + const a = base(); + a.contracts[0].provenance = { status: 'source-verified' }; + expect(validateDeployment(a).join()).toMatch(/requires a sourceCommit/); + + const b = base(); + b.contracts[0].sourceCommit = 'a'.repeat(40); + expect(validateDeployment(b).join()).toMatch(/contradicts/); + + const c = base(); + c.contracts[0].provenance = { status: 'unverifiable' }; + expect(validateDeployment(c).join()).toMatch(/requires a reason/); + + for (const bad of ['abc', 'A'.repeat(40), '0'.repeat(40)]) { + const d = base(); + d.contracts[0].sourceCommit = bad; + d.contracts[0].provenance = { status: 'source-verified' }; + expect(validateDeployment(d).length).toBeGreaterThan(0); + } + }); + + it('never lets a deploy script write an unverifiable or null-commit record', () => { + const d = { ...base(), recordedBy: 'deploy-script' as const }; + expect(validateDeployment(d).join()).toMatch(/must carry sourceCommit/); + expect(validateDeployment(d).join()).toMatch(/must be source-verified/); + }); + + it('exempts external contracts', () => { + const d = base(); + d.contracts[0].external = true; + delete d.contracts[0].provenance; + expect(validateDeployment(d)).toEqual([]); + }); +}); diff --git a/scripts/deployment-manifest/test/units.test.ts b/scripts/deployment-manifest/test/units.test.ts index e5b1f2d..0610670 100644 --- a/scripts/deployment-manifest/test/units.test.ts +++ b/scripts/deployment-manifest/test/units.test.ts @@ -95,6 +95,7 @@ const evmDeployment = (): Deployment => ({ id: '0x8AE65c05E7eb48B9bA652781Bc0a3DBA09A484F3', version: null, sourceCommit: null, + provenance: { status: 'unverifiable', reason: 'historical' }, artifactHash: { algorithm: 'keccak256', value: `0x${'00'.repeat(32)}` }, deployment: { height: 1 }, }, @@ -124,6 +125,8 @@ describe('validation', () => { const d = { ...evmDeployment(), recordedBy: 'deploy-script' as const }; expect(validateDeployment(d).join()).toMatch(/sourceCommit/); d.contracts[0].sourceCommit = 'a'.repeat(40); + expect(validateDeployment(d).join()).toMatch(/must be source-verified/); + d.contracts[0].provenance = { status: 'source-verified' }; expect(validateDeployment(d)).toEqual([]); });