Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -413,21 +413,96 @@
--filter @origintrail-official/dkg-adapter-openclaw \
run test

# ------------------------------------------------------------------
# ABI freshness gate — guards the contract `auto-update.ts` deliberately
# never invokes `hardhat compile` on node hosts (would OOM small VPS,
# cold-solc on ARM64 trips the build timeout). It relies on the
# COMMITTED `packages/evm-module/abi/*.json` being the runtime
# contract surface (consumed by `packages/chain` via require()).
#
# This job catches the only way that contract can break: a contributor
# changes a `.sol` file and forgets to commit the regenerated ABIs.
# Runs only when `changes.outputs.contracts == 'true'` so PRs that
# don't touch contracts pay zero cost. On failure prints an explicit
# remediation command so the fix is one copy-paste away.
# ------------------------------------------------------------------
abi-freshness:
name: "Tornado: ABI freshness (committed abi/ vs hardhat compile)"
needs: changes
runs-on: ubuntu-latest
timeout-minutes: 10
if: needs.changes.outputs.contracts == 'true'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: This new gate only runs when changes.outputs.contracts is true, but that filter does not include packages/evm-module/abi/**. Because the updater now trusts the committed ABI JSONs at runtime, a PR can modify abi/*.json directly and completely bypass this freshness check. Include abi/** in the contracts paths filter (or make this job self-detect ABI changes) so runtime ABI drift cannot merge unchecked.

steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Hardhat artifacts
uses: actions/cache@v4
with:
path: |
packages/evm-module/artifacts
packages/evm-module/cache
packages/evm-module/typechain
# Hash both configs because abi/ regen depends on hardhat.config.ts
# (which is what loads hardhat-abi-exporter; see the next step).
key: hardhat-${{ runner.os }}-abi-${{ hashFiles('packages/evm-module/contracts/**/*.sol', 'packages/evm-module/hardhat.config.ts', 'packages/evm-module/hardhat.node.config.ts') }}
restore-keys: |
hardhat-${{ runner.os }}-abi-
hardhat-${{ runner.os }}-
- name: Compile contracts with the default config (regenerates abi/*.json)
# Subtle: `pnpm --filter dkg-evm-module build` ultimately calls
# `hardhat compile --config hardhat.node.config.ts`, which does NOT
# have `hardhat-abi-exporter` wired in. Only the default
# `hardhat.config.ts` imports the plugin and sets `abiExporter:
# { runOnCompile: true, ... }`. Running `npx hardhat compile`
# without a `--config` flag (so it picks up the default config)
# is what actually regenerates `abi/*.json`. Without this, the
# diff check below would always pass even with stale ABIs.
run: npx hardhat compile
working-directory: packages/evm-module
- name: Verify committed abi/ matches regenerated abi/
# `git diff --exit-code` returns 1 if anything under abi/ changed
# after compile. If so, the contributor forgot to commit the
# regenerated ABIs; the auto-updater would then activate code
# that loads stale ABIs against new contracts. Block the PR.
run: |
set -euo pipefail
if ! git diff --exit-code -- packages/evm-module/abi/; then
echo "::error title=ABI drift::Committed packages/evm-module/abi/*.json does not match the output of \`hardhat compile\` (default config)."
echo ""
echo "The auto-updater no longer runs hardhat compile on node hosts (see comment in packages/cli/src/daemon/auto-update.ts), so committed ABIs ARE the runtime contract surface. Drift here would silently activate stale ABIs against new contracts on every node."
echo ""
echo "To fix: regenerate and commit the ABIs locally:"
echo ""
echo " cd packages/evm-module && npx hardhat compile && cd -"
echo " git add packages/evm-module/abi/"
echo " git commit --amend --no-edit # or a fresh commit, your call"
echo " git push --force-with-lease"
echo ""
exit 1
fi
echo "OK: committed packages/evm-module/abi/ matches hardhat output."

# ------------------------------------------------------------------
# Tornado Solidity lane — `packages/evm-module` is TORNADO-tier
# (`dkgv10-spec/CRITICALITY_CATEGORIZATION.md` §1): bugs in the
# contracts mean financial loss or consensus divergence.
# - PR + no contract changes : no-op fast pass (keeps the required
# check green without spinning up Hardhat).
# - PR + contract changes : 4-way sharded hardhat test, no
# coverage (~5-6 min per shard in
# parallel; previously ~20 min single).
# - push (main / v10-rc / …) : full coverage + ratchet safety net,
# single job (coverage reports don't
# compose across shards, so we keep
# the safety-net job unsharded).
# ------------------------------------------------------------------
solidity:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: "Tornado: Solidity [${{ matrix.shard }}/4]"
needs: changes
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions RELEASE_PROCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ dkg update 9.0.0-beta.2 --allow-prerelease --no-verify-tag

Git-based blue-green updates run runtime packages and the Node UI static bundle as separate timed build steps, then verify `packages/node-ui/dist-ui/index.html` before activation. `build:runtime` remains a UI-inclusive compatibility wrapper so nodes updating from an older updater still prepare the UI through the target ref's build script.

> **Note on EVM contracts**: nodes never run `hardhat compile` during install or auto-update. The committed `packages/evm-module/abi/*.json` files are the runtime contract surface (consumed by `packages/chain` via `require()`). The `abi-freshness` CI job (`.github/workflows/ci.yml`) blocks any PR that changes Solidity sources without committing the regenerated ABIs, so by the time a tag exists on `main` the committed ABIs are guaranteed to match. **Release implication**: when contract source changes are part of a release, the contributor MUST regenerate ABIs (`pnpm --filter @origintrail-official/dkg-evm-module build && git add packages/evm-module/abi/`) and commit them with the source change. CI enforces this; releases cut from `main` cannot ship with stale ABIs.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: The command in this note will not regenerate packages/evm-module/abi/*.json. @origintrail-official/dkg-evm-module's build script compiles with hardhat.node.config.ts, which does not load hardhat-abi-exporter, so contributors following this release doc will do the wrong thing. Point the remediation here at cd packages/evm-module && npx hardhat compile instead.


After each update:

```bash
Expand Down
44 changes: 40 additions & 4 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,34 @@ slot_ready() {
[ -d "$slot_path/.git" ] && [ -f "$entry_path" ]
}

# Mirror packages/cli/src/node-ui-static.ts:runtimeBuildCommandFromPackageJson.
# Honour `dkgBuild.releaseRuntimeBuildScript` first, then fall back through
# `build:runtime` and `build:runtime:packages`, finally `build`. This keeps
# the install path compatible with arbitrary $DKG_BRANCH / $DKG_REPO targets
# (e.g. older tags that predate the runtime-build split).
runtime_build_script() {
slot_path="$1"
node -e "
try {
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('$slot_path/package.json', 'utf-8'));
const isSafe = (s) => typeof s === 'string' && /^[A-Za-z0-9:_-]+\$/.test(s);
const rrbs = pkg.dkgBuild && pkg.dkgBuild.releaseRuntimeBuildScript;
if (isSafe(rrbs) && pkg.scripts && typeof pkg.scripts[rrbs] === 'string') {
process.stdout.write(rrbs);
} else if (pkg.scripts && typeof pkg.scripts['build:runtime'] === 'string') {
process.stdout.write('build:runtime');
} else if (pkg.scripts && typeof pkg.scripts['build:runtime:packages'] === 'string') {
process.stdout.write('build:runtime:packages');
} else {
process.stdout.write('build');
}
} catch (e) {
process.stdout.write('build');
}
"
}

stage_markitdown() {
slot_path="$1"
slot_name="$2"
Expand Down Expand Up @@ -76,8 +104,15 @@ else
git clone --branch "$BRANCH" "$REPO_URL" "$SLOT_A"
info "Installing dependencies in slot a ..."
(cd "$SLOT_A" && pnpm install --frozen-lockfile)
info "Building slot a ..."
(cd "$SLOT_A" && pnpm build)
# Runtime build — picks the best available script in the target ref
# (mirrors the auto-updater's fallback so older tags still install
# correctly). On current main this resolves to `build:runtime:packages`
# via `dkgBuild.releaseRuntimeBuildScript`, which skips evm-module's
# hardhat compile. The committed `packages/evm-module/abi/*.json` files
# are the runtime contract surface; CI enforces they stay in sync.
SLOT_A_BUILD_SCRIPT=$(runtime_build_script "$SLOT_A")
info "Building slot a (pnpm run $SLOT_A_BUILD_SCRIPT) ..."
(cd "$SLOT_A" && pnpm run "$SLOT_A_BUILD_SCRIPT")
fi
stage_markitdown "$SLOT_A" "a"

Expand All @@ -89,8 +124,9 @@ else
git clone --reference "$SLOT_A" --dissociate --branch "$BRANCH" "$REPO_URL" "$SLOT_B"
info "Installing dependencies in slot b ..."
(cd "$SLOT_B" && pnpm install --frozen-lockfile)
info "Building slot b ..."
(cd "$SLOT_B" && pnpm build)
SLOT_B_BUILD_SCRIPT=$(runtime_build_script "$SLOT_B")
info "Building slot b (pnpm run $SLOT_B_BUILD_SCRIPT) ..."
(cd "$SLOT_B" && pnpm run "$SLOT_B_BUILD_SCRIPT")
fi
stage_markitdown "$SLOT_B" "b"

Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ export interface AutoUpdateBuildTimeouts {
install?: number;
/** `pnpm build:runtime` / `pnpm build` (default 180_000). */
build?: number;
/** `pnpm --filter dkg-evm-module build` (default 300_000; bump to 900_000 on ARM64). */
/**
* @deprecated Ignored since the auto-updater stopped invoking `hardhat
* compile` on node hosts. Committed `packages/evm-module/abi/*.json` are
* now the runtime contract surface, and CI enforces freshness (see
* `abi-freshness` job in `.github/workflows/ci.yml`). The field is
* retained on the type so existing user configs don't fail to parse.
*/
contracts?: number;
/** MarkItDown bundling step (default 900_000). */
markitdown?: number;
Expand Down
159 changes: 10 additions & 149 deletions packages/cli/src/daemon/auto-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,84 +909,6 @@ async function cleanGeneratedOutputs(
}
}

/**
* Decide whether to rebuild Solidity contracts. Same semantics as the original
* inline check (skip on terminal diff failure) plus one robustness improvement:
* if the parent commit isn't reachable in the slot's pack files (most common
* cause is a shallow clone or upstream force-push rebase), try a single
* `git fetch --depth=1 origin <currentCommit>` and retry the diff once before
* giving up. We've never observed an ABI/JS mismatch from this skipping in
* practice, so we err toward "less work" rather than "build to be safe".
*/
async function shouldRebuildContracts(args: {
au: ResolvedAutoUpdateConfig;
fetchUrl: string;
currentCommit: string;
checkedOutCommit: string;
targetDir: string;
execFileAsync: (file: string, args: string[], opts: any) => Promise<any>;
log: (m: string) => void;
}): Promise<boolean> {
const { au, fetchUrl, currentCommit, checkedOutCommit, targetDir, execFileAsync, log } = args;
if (
!/^[0-9a-f]{6,40}$/i.test(currentCommit) ||
!/^[0-9a-f]{6,40}$/i.test(checkedOutCommit)
) {
log('Auto-update: contract-change check skipped (commit SHAs invalid); skipping contract build.');
return false;
}
const tryDiff = async (): Promise<{ ok: boolean; stdout?: string; err?: any }> => {
try {
const result = await execFileAsync(
'git',
['diff', '--name-only', `${currentCommit}..${checkedOutCommit}`],
{ cwd: targetDir, encoding: 'utf-8', timeout: 30_000 },
);
return { ok: true, stdout: String(result?.stdout ?? '') };
} catch (err: any) {
return { ok: false, err };
}
};
let diff = await tryDiff();
if (!diff.ok) {
// Most common cause: the parent commit isn't in the slot's pack files.
// Fetch it explicitly (depth=1 on the SHA), then retry once. The slots
// are initialized with bare `git init` and fetched via direct URL — no
// `origin` remote is configured — so we must mirror the main fetch and
// pass the URL + auth args explicitly. Best-effort: if the fetch itself
// errors, skip the build (legacy behaviour); we've never observed a
// real ABI/JS mismatch from this path.
try {
log(`Auto-update: contract-diff failed; fetching parent commit ${currentCommit.slice(0, 8)} to retry.`);
await execFileAsync(
'git',
[...gitCommandArgs(fetchUrl, au), 'fetch', '--depth=1', fetchUrl, currentCommit],
{
cwd: targetDir,
encoding: 'utf-8',
timeout: 30_000,
env: gitCommandEnv(au),
},
);
diff = await tryDiff();
} catch (fetchErr: any) {
log(`Auto-update: parent-commit fetch failed (${fetchErr?.message ?? fetchErr}); skipping contract build.`);
return false;
}
}
if (!diff.ok) {
log(
`Auto-update: contract-change check failed (${diff.err?.message ?? diff.err}); skipping contract build.`,
);
return false;
}
const changedPaths = String(diff.stdout ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return changedPaths.some((p) => p.startsWith('packages/evm-module/contracts/'));
}

/**
* Core blue-green update logic. Builds the new version in the inactive slot,
* then atomically swaps the `releases/current` symlink.
Expand Down Expand Up @@ -1252,7 +1174,6 @@ async function _performUpdateInner(
label: "pnpm install",
log,
});
let usedFullBuildFallback = false;
let runtimeBuildCommand = FULL_BUILD_COMMAND;
try {
const rootPkgRaw = await readFile(
Expand Down Expand Up @@ -1281,78 +1202,18 @@ async function _performUpdateInner(
label: FULL_BUILD_COMMAND,
log,
});
usedFullBuildFallback = true;
}

if (usedFullBuildFallback) {
log(
"Auto-update: contract build check skipped (full build fallback already executed).",
);
} else {
const shouldBuildContracts = await shouldRebuildContracts({
au,
fetchUrl,
currentCommit,
checkedOutCommit,
targetDir,
execFileAsync,
log,
});

if (shouldBuildContracts) {
log(
"Auto-update: contract folder changes detected; building @origintrail-official/dkg-evm-module...",
);
// Run `hardhat clean` first so stale artifacts/, abi/, and typechain
// outputs from a deleted/renamed contract don't survive into the
// inactive slot. We deliberately scope this to the
// `shouldBuildContracts` branch:
// - the no-change branch keeps the Hardhat compile cache intact,
// which is what saves us from the cold-solc / ARM64 build
// timeout that the rest of this helper exists to prevent;
// - when contract sources actually changed we're already paying
// for a recompile, so wiping the cache here is essentially free
// and guarantees the swap doesn't activate ghost ABIs/types.
// Best-effort: a clean failure must not abort an otherwise-valid
// contract rebuild — `hardhat compile` will still recreate every
// artifact that the new source tree references; only stale outputs
// for *deleted* contracts would be missed, which is a strict
// improvement over today's behaviour anyway.
try {
await runBuildStep(
execAsync,
"pnpm --filter @origintrail-official/dkg-evm-module clean",
{
cwd: targetDir,
timeoutMs: timeouts.contracts,
label: "pnpm --filter dkg-evm-module clean",
log,
},
);
} catch (cleanErr: any) {
log(
`Auto-update: hardhat clean failed (${cleanErr?.message ?? String(cleanErr)}); proceeding with rebuild — stale artifacts for renamed/deleted contracts may persist.`,
);
}
await runBuildStep(
execAsync,
"pnpm --filter @origintrail-official/dkg-evm-module build",
{
cwd: targetDir,
timeoutMs: timeouts.contracts,
label: "pnpm --filter dkg-evm-module build",
log,
},
);
log(
"Auto-update: @origintrail-official/dkg-evm-module build completed.",
);
} else {
log(
"Auto-update: no contract folder changes detected; skipping @origintrail-official/dkg-evm-module build.",
);
}
}
// NOTE: the auto-updater intentionally never invokes `hardhat compile` on
// node hosts. The committed `packages/evm-module/abi/*.json` files are the
// runtime contract surface (consumed by `packages/chain` via require()),
// and a CI gate (`abi-freshness` job in ci.yml) runs `npx hardhat compile`
// (default config, the one that loads `hardhat-abi-exporter`) on every
// contract-touching PR and blocks merge if the regenerated `abi/` differs
// from what was committed. This removes the single most failure-prone
// step from the update flow — hardhat compile routinely OOMs / times out
// on resource-constrained nodes (cold solc on ARM64, in particular) and
// any failure here would abort the slot swap.

let nodeUiPackageNames = NODE_UI_PACKAGE_NAME_FALLBACKS;
try {
Expand Down
Loading
Loading