← yescrow security review

Published copy of reports/AI pentest, sixth run (2026-11-06).md from yescrow's private repository, unedited apart from local file paths. File and test references point into that repository; the contracts' source is verified on Arbiscan (see the contracts). Download as Markdown.

AI penetration test, sixth run (2026-11-06)

A short targeted step 4 of docs/ai-pentest.md on commit d451174, the fixes for the fifth run (reports/AI pentest, fifth run (2026-11-06).md), as the launch gate asks for a change to a deploy script (docs/mainnet.md §1, gate 1). d451174 changed scripts/deploy-all.sh and scripts/rehearse-mainnet.sh (the pinned addresses instead of keys on command lines, a throwaway router key in the rehearsal), contracts/script/DeployAll.s.sol (a check of the attestor and promo keys before anything is sent), ops/yescrow-ctl (secret set keeps the attestor's address with its key; lockdown hides each account's processes in /proc) and the launch procedure. The contracts in contracts/src/ are unchanged since 1e08fee. One fresh attack agent on area F, aimed at those changes. On d451174 the attack agent re-ran the fifth run's proofs, its verifier's test and the build pins (12 of 12 pass), the R5-F-1 regression (no key on any command line) and the ops tool's tests (no failure); the whole suite, which no longer fits this server's memory in one run, runs in parts with this run's fixes (docs/audit.md §6e). A fresh verifier checked each finding. Proofs are under contracts/test/pentest/sixth/ and scripts/pentest/sixth/; the verifiers' are contracts/test/pentest/verify/V6_F-1_TryCatch.s.sol and scripts/pentest/sixth/verify/.

Part 1. Summary

Result

No Critical, High or Medium finding, and no finding needs a contract change. Both fifth-run fixes hold and are complete for what they claim: no mainnet key reaches a command line in the launch scripts or the documented commands (the regression logs every command the scripts run: none holds any of the five keys), DeployAll's key check runs before the first send in the deployment and in the rehearsal, secret set keeps the attestor's address with its key, and /proc on the server hides other accounts' processes since the admin ran lockdown during this round.

Two Low findings and one Info survived verification, all in the procedure around the contracts:

All findings

"Attacker" is the severity the attack agent claimed; "Verifier" is the fresh agent's verdict; "Final" is the verifier's severity. The rule (the user's): a finding whose proof runs and shows the claimed impact can't be dropped by argument alone.

ID Finding Attacker Verifier Final Where the fix goes
R6-F-1 forge prints a whole private key in its error and trace when a key value holds a character it escapes Low Confirmed, wider: the attestor, promo and relayer keys too, through the trace of a failing run; the suggested try/catch doesn't stop it; only the admin's own screen Low The shell: check that each set *_PRIVATE_KEY is 0x and 64 hex digits before forge runs, without printing it (deploy-all.sh, and the router command of §3 step 1)
R6-F-2 lockdown appends to /etc/fstab without a newline guard: the last entry is lost at the next boot Low Confirmed: the boot drops the line without a message; a root entry last would leave root read-only; not the case on this server; U-3 is broader Low hide_processes: newline guard, re-read before appending, a line-aware check of live /proc entries, the generated units compared before and after
R6-F-3 The testnet path of deploy-all.sh accepts the mainnet env file Info Confirmed, wider: other testnet scripts, ENV_FILE exported by §3 step 1, DeployAll doesn't refuse the mainnet deployer off mainnet Info DeployAll: refuse MAINNET_DEPLOYER on other chains; the testnet scripts refuse an env file made for Arbitrum One; §3 step 1 in a subshell

Six ideas stayed unconfirmed (U-1 to U-6 in the area report): the boot-time half of the /proc step, a warning before secret rotate replaces a pinned key, a commented-out /proc line taken for done (which the R6-F-2 verifier reproduced in a likelier form), what checkKeys can't see (the running containers' keys; the live proof of §4 covers the attestor's), RPC URLs with API keys on command lines, and the window before chmod 600 in §3 step 0.

Limits


Part 2. Area report, with each verification

Area F: deployment

AI penetration test, sixth run, Area F: deployment (commit d451174, the fifth run's fixes)

A short targeted round on what CHANGES-since-ab7980d.diff changed: the launch scripts' use of the pins, DeployAll.checkKeys, yescrow-ctl secret set and the new lockdown step hide_processes(), the rehearsal's throwaway key, and the live-proof step of docs/mainnet.md §4. Everything ran on a local copy, a local anvil or a fork of Arbitrum One (reads only); every key in the proofs is a throwaway one. The contracts in contracts/src/ are unchanged since 1e08fee.

Proofs: contracts/test/pentest/sixth/F6_KeyChecks.t.sol (4 tests, one on a fork), contracts/test/pentest/sixth/F6_KeyEcho.s.sol (a stand-in script the shell proof runs), scripts/pentest/sixth/F6_forge_output_no_keys.sh, scripts/pentest/sixth/F6_hide_processes.py. The fifth run's proofs and verifier test were re-run as regressions (12 of 12 pass; F5_fixed_no_keys_on_command_lines.sh passes: 0 keys in 14 logged commands and 69 watched command lines).

Result. The two fifth-run fixes hold and are complete for what they claim: no mainnet key reaches a command line in the launch scripts or the documented commands, checkKeys sits after check() and before the first send and is reached by the rehearsal, forge's traces redact what it reads, secret set keeps the attestor's address with its key, and /proc on this server is now mounted hidepid=invisible,gid=982 (the lockdown ran during this round: from the agent account only its own 17 processes are visible and /proc/1/cmdline does not exist). Three findings, none above Low, all in the procedure around the contracts; none needs a contract change.

Findings

R6-F-1. forge prints a whole private key in its error when an unparsable key value carries an escaped character

R6-F-2. hide_processes() appends its /proc line to /etc/fstab without a newline guard: an fstab whose last line has none loses that entry at the next boot

R6-F-3. The testnet path of deploy-all.sh accepts the mainnet env file: a slip puts the mainnet deployer and promo keys on cast command lines and asks forge to deploy on Sepolia with the mainnet deployer

Unconfirmed

Checked and found sound

Verifications for area F

Each by a fresh agent (Claude Opus 5.5, maximum reasoning effort) that tried to refute the finding and ran its proof again, under the user's rule.

R6-F-1: forge prints a key in its error and trace — Verdict: CONFIRMED, Low (wider than reported)

Verdict: Confirmed, at Low. The finding is real but wider than the report says, several of its details are wrong, and the Solidity half of its suggested fix does not work.

Severity: Low (unchanged). The proof passes. I also reproduced the leak through the real scripts/deploy-all.sh in rehearsal mode, so it can't be dropped. It stays Low rather than higher because none of the documented ways of writing a key line can produce a malformed one: - §3 step 0 appends the clean hex that cast wallet decrypt-keystore prints (docs/mainnet.md:127). - secret gen writes clean hex (ops/yescrow-ctl:363–365). - secret set strips leading and trailing whitespace, including CR, tab and the non-ASCII space U+00A0 (ops/yescrow-ctl:585), and fully checks the attestor key's format (:589).

So a leak needs a hand edit (sudoedit on the vault, or an editor on the deploy clone's .env.mainnet). Even then the key only reaches the admin's own SSH terminal. Sharing that output with Claude is in no procedure, and docs/secrets.md:85 forbids pasting keys; the key is plainly visible in the message. This is narrower than R5-F-1 (Low: every local account, every rehearsal). It stays Low rather than Info because the keys exposed are wider than the report says: the deployer, the relayer, the attestor and the promo key. A leaked attestor or deployer key before launch means new pins and another audit round; a leaked promo key costs fees for up to 92 days. Also, a pasted deployer key would never be flagged: claude-guard only runs on tool calls, not on what the user types (ops/managed-settings.json), and the guard and the nightly scan only know keys still in the vault (ops/yescrow-ctl:154–162, 203–220). After secret rm mainnet DEPLOYER_PRIVATE_KEY (docs/secrets.md:105), the deployer key is no longer there.

Proof result

The given command, FOUNDRY_DYNAMIC_TEST_LINKING=false systemd-run --user --scope -p MemoryMax=3300M -p MemorySwapMax=3G --quiet -- bash scripts/pentest/sixth/F6_forge_output_no_keys.sh, printed PASS: R6-F-1 reproduced …: - Leaks: for both unparsable() and unparsableOr(), a value with a CR, a tab followed by text, or a U+00A0 put the whole key in 3 lines. - No leak: a stray Z, a quoted key and two keys printed failed parsing $F6_BAD. - Successful reads: the traces were clean at default verbosity and at -vvvv.

My extra runs used throwaway keys and a plain local anvil with chain id 42161 (nothing left the machine):

Run Key value Lines with the whole key
Real deploy-all.sh arbitrum-one, rehearsal mode CR on the DEPLOYER_PRIVATE_KEY line 3
Same U+00A0 at the end 3
Same quoted value with a tab inside 3
Same trailing unquoted tab (bash drops it, the run went on) 0
Real DeployRouter.s.sol ROUTER_DEPLOYER_PRIVATE_KEY + CR 3
Same the DEPLOYER_PRIVATE_KEY fallback + CR 3
Real DeployAll.checkKeys (through the agent's F6Harness) ATTESTOR_PRIVATE_KEY + CR 1, a trace line VM::envUint("ATTESTOR_PRIVATE_KEY") … ← [Revert] vm.envUint: failed parsing "0x<64hex>\r", then the refusal
Value passed straight to forge trailing tab or space 0 (both parse as the key)
Same leading U+00A0, or CR then a space 3
cast wallet address --private-key malformed key 0: it prints only "Failed to decode private key"

Corrections to the finding - Attestor and promo keys: the report says they "are not affected" because the try/catch in checkKeys "prints nothing". That is wrong. The catch in keyAddress (contracts/script/DeployAll.s.sol:217–223) does swallow the error, but checkKeys (:205–214) then refuses, the script fails, and forge 1.8.5 prints a failing script's traces at default verbosity (no verbosity setting in foundry.toml). Those traces include the cheatcode call that failed inside the try, with the key. I proved this for the attestor key with the real code. The promo key goes through the same keyAddress; with throwaway keys the run can't get past the attestor check to reach it, so I showed it with an identical stand-in. - Wrong line numbers: DeployRouter.s.sol:212–213 is really :43–44, and :214 is :45 (the file has 54 lines). The same wrong :212 is in the proof script (scripts/pentest/sixth/F6_forge_output_no_keys.sh:36) and in the stand-in's comment (F6_KeyEcho.s.sol:25). - DeployRouter doesn't read the deployer key at launch: in the documented launch, ROUTER_DEPLOYER_PRIVATE_KEY is the relayer key (docs/mainnet.md:135); in the rehearsal it is a throwaway (scripts/rehearse-mainnet.sh:34). The deployer fallback at :44 is never reached by a documented command. The keys exposed are: - the deployer, read at DeployAll.s.sol:74 (this citation is correct); - the relayer, at DeployRouter.s.sol:43, in §3 step 1; - the attestor and promo keys, at DeployAll.s.sol:218. - Tab: a trailing tab parses as the key and does not leak. Only a tab followed by more text leaks, and through set -a; . file (deploy-all.sh:43, docs/mainnet.md:132) only inside quotes. Unquoted, bash runs the rest as a command and set -e stops the script. line() in yescrow-ctl (ops/yescrow-ctl:99–104) does write such quotes for any value that isn't plain. - "Three lines of the message": two are trace lines (the cheatcode's revert and the function's revert) and the third is Error: script failed:. "Traces … are clean" is true only for reads that succeed. - What secret set strips: more than CR and tab. Python's .strip() (ops/yescrow-ctl:585) removes all leading and trailing Unicode whitespace, U+00A0 included. Characters inside the value stay, and only ATTESTOR_PRIVATE_KEY gets a format check (:589). - Where the deployer line comes from: after the keystore import, the vault holds no DEPLOYER_PRIVATE_KEY (docs/secrets.md:105). For the deployer, the only path is a hand edit of the clone's .env.mainnet, not sudoedit on the vault. - Where the output goes: only the admin's terminal. "Checks the results" (docs/mainnet.md:10) refers to the deployments and broadcast records copied over at the end of §3, not to pasted terminal output. - I didn't check map_env_err against foundry's source. The output fits the explanation: the plain spelling is replaced by $F6_BAD, and the escaped spelling is not.

Fix assessment - The Solidity half doesn't work. Wrapping the read in a keyAddress-style try/catch followed by a require still leaks one line at default, -vv and -vvv (V6F1TryCatch.fixedShape). Any cheatcode that fails while holding the value puts it in the trace of a failing run, and the require then makes the run fail. Today's checkKeys leaks the same way. - Don't use vm.parseUint either. Reading the value as a string and parsing it with vm.parseUint prints the whole key in the trace even when the key is clean; parseUint's argument is not redacted (V6F1StringRead). - What works: - (a) A shell check after sourcing, before any forge call, that never prints the value. For example: [[ ${!v} =~ ^0x[0-9a-fA-F]{64}$ ]] for each of DEPLOYER_PRIVATE_KEY, ATTESTOR_PRIVATE_KEY, PROMO_SIGNER_PRIVATE_KEY and RELAYER_PRIVATE_KEY that is set. It belongs in deploy-all.sh after line 43, which also covers the rehearsal and the R6-F-3 testnet slip. §3 step 1 runs DeployRouter by hand outside the script, so it needs the same check or should become a script. - (b) Or in Solidity: read with vm.envOr(name, string("")), whose return is redacted in the trace. Check 0x plus 64 hex digits and convert to a number in plain Solidity, with no cheatcode, then call vm.addr. I didn't test (b) end to end. - The report's grep prints the key. grep -nP '^[A-Z_]*_PRIVATE_KEY=.*[^\x20-\x7e]' as written shows the matching line, which is the key, on the screen. It needs -q or -c. A positive pattern on the sourced variables is also safer than a negative pattern on file lines, which misses export and indented lines. - Other places checked: - cast doesn't echo a malformed key. - vm.addr and vm.startBroadcast are redacted. - Deploy.s.sol:46 and DeployTestUSDT.s.sol:12 have the same unwrapped read but are testnet-only. - Reporting it upstream is reasonable; mention the trace path as well as the error message.

Files I added: my stand-in, <scratchpad>/pentest6/F/contracts/test/pentest/sixth/V6_F1_TryCatch.s.sol (contracts V6F1TryCatch and V6F1StringRead), and my scripts in <scratchpad>/pentest6/F1-verify/ (show.sh, e2e.sh, trycatch.sh, realcheck.sh, strread.sh, forms.sh). I left them in place.

R6-F-2: lockdown's /etc/fstab append — Verdict: CONFIRMED, Low

Verdict: confirmed, at Low (the severity the report claims).

Severity: Low. The proof passes and shows the claimed effect. No attacker is involved, no money is at risk and no secret leaks: it is root's own tool losing one fstab entry, silently, at the reboot lockdown asks for right after (ops/yescrow-ctl:677–678). The precondition is narrow. Ubuntu cloud images, the installer and cloud-init all end fstab with a newline, and so do echo … | tee -a, nano 8.7.1 (unless run with -L/--nonewlines) and vim (its default fixeol). Even … 0 0 with a trailing space survives. It takes printf, echo -n, a script or an editor set up not to add the newline. On this server the precondition was false, and :693 now returns at once, so the step can't append again. I don't raise it for the worst variant (root entry last, below), because the admin is at the console for the reboot and it can be repaired by hand. I don't lower it to Info, because the docs tell the admin to re-run lockdown (docs/secrets.md:56, docs/mainnet.md:92), the tool is meant for any server, and nothing is logged when the entry is lost.

Proof result: python3 -I scripts/pentest/sixth/F6_hide_processes.py from the copy's root gives 0 failure(s), exit 0 (Python 3.14.4, systemd 259.5, util-linux 2.41.3). - Scenario 6 writes …umask=0077 0 1# yescrow-ctl lockdown: …. - The generator then produces only -.mount; boot-efi.mount is lost. - With a swap line last, it produces -.mount boot-efi.mount, so swapfile.swap is lost. - The control file with a newline does generate swapfile.swap.

My own runs (in <scratchpad>/verify-R6F2/, through harness.py and the generator): - This server's layout with /swapfile2 last loses only swapfile2.swap. - A root line last loses -.mount. - Last lines with 4 or 5 fields are dropped too. - A trailing space or tab, a comment as the last line, and CRLF endings are all harmless.

Corrections to the finding: - "libmount reports parse error at line 3 -- ignored" (R6-F-2 Steps) is wrong for the generator. systemd-fstab-generator says nothing about the dropped line, even at SYSTEMD_LOG_LEVEL=debug. It logs a "Found entry" line for each surviving entry and nothing for the lost one. That message is findmnt's, printed just before it segfaults (exit 139, with --verify and with a plain --tab-file). The proof's own output shows no message either (the script's line 185 prints none). So the boot leaves no trace in the journal, which strengthens "silently". - "Not a lockout: the root entry is read" holds only when root isn't the last line. If the root entry is last with no newline, the generator writes no -.mount. systemd-remount-fs then finds no root entry; its man page says it "executes no operation" in that case. This server boots with ro on its kernel command line (/proc/cmdline). So root would most likely stay read-only after the boot, and Docker and the stacks would not come up until someone repairs it by hand. I reasoned this, I did not boot it. sshd and sudo work from /run, so it is still not a lockout. - "This VPS added an 8 GB swap file by hand" is wrong. docs/runbook.md:217–218 lists zram (from zram-generator, not fstab) plus /swapfile and /swapfile2, 4 GB each. With that layout, only /swapfile2 would be lost; zram and /swapfile would stay up. - "Precondition unverifiable" is now settled. The real fstab ended with a newline, nothing was lost, and ops/yescrow-ctl:693 keeps the step from running again. - The cited lines are right: ops/yescrow-ctl:692 (read), :712–715 (the append), :693, :695 and :712 (the substring tests), :716 (the "kept" message).

Fix assessment: - The newline guard works. I patched a copy (<scratchpad>/verify-R6F2/fix1/ops/yescrow-ctl) with the report's prefix line. Scenario 6 then flips: boot-efi.mount and swapfile.swap are generated again. My variants (/swapfile2 last, root only) also keep all their units. The check should read /etc/fstab again just before the append: the read at :692 happens before the remount and before the admin opens a second login. - "Refuse on parse error" from the generator would never trigger. The generator is silent about dropped lines. A check that works is to generate units from the old and the new file and require the same set; the /proc line adds no unit, since /proc is an API file system. findmnt --verify crashes on the bad file, so it can't be the check. - "Write atomically as set_in_file does" needs care. set_in_file creates its temp file with mode 0o600 (ops/yescrow-ctl:128). Copied as it is, it would make /etc/fstab readable by root only; it needs 0o644 and root:root. An append with the guard is enough. - The fix doesn't cover U-3, and U-3 is broader than the report says. Scenario 7 passes. I also reproduced a more likely variant. The admin comments out the tool's own line (to undo it) and reboots, so /proc is no longer hidden. On the next lockdown, the tool remounts /proc, writes no fstab line (:712 finds the substring in the comment), and prints "kept: /etc/fstab mounts /proc that way at every boot" (:716). That message is false, and /proc is unhidden at the next boot. A live proc /proc proc defaults 0 0 line plus the options in a comment also gets past the die guard at :695, with the same false "kept". I'd move U-3 into the findings at Low or Info: hidepid is defence in depth now that d451174 keeps keys off command lines. - A complete fix uses one line-aware check at :693, :695 and :712. - Skip comment lines; take normpath(fields[1]) == '/proc', and require the options split on , to include hidepid=invisible and gid=proc. - Stop with an error if any live /proc entry lacks them, and append only when there is no live /proc entry. - This also covers proc /proc/ proc …, which the regex at :695 misses. The tool would then append a second /proc line; I didn't establish which of the two mount(8) applies at boot. - No other lockout path in hide_processes. Logins and sudo don't need to see other accounts' processes, systemd-logind and polkit get the proc group through their drop-ins, and the "no" answer and a dropped connection are handled (scenarios 3 and 4).

R6-F-3: the testnet path and the mainnet env file — Verdict: CONFIRMED, Info

Verdict: confirmed at Info.

Severity: Info. I agree with the attack agent. Under the rubric no party's money, deadline or fee is at stake, and the mistake takes two of the admin's own deviations from the documented procedure. The admin has to run the testnet network in the mainnet deploy clone, and a Sepolia RPC has to be in a shell that §3 says to start clean (env -i HOME PATH bash, docs/mainnet.md:118). No step tells the admin to run any Sepolia script there; testnet deploys run as teo with .env. With hidepid=invisible live, only root and the admin's own processes could read the two cast command lines. That protection holds only if the fstab line survives a reboot, which this run left unconfirmed (U-1, U-3, R6-F-2). It does not raise the severity, because the admin's mistake comes first. On-chain, a Sepolia deployment by the mainnet deployer cannot touch Arbitrum One: - Nonces are kept per chain. - Forge's signatures bind chain id 421614, so nothing replays on Arbitrum One. - deploy-all.sh:87 checks the chain id before any key is used (:89). - DeployAll's mainnet guards read Arbitrum One's state (DeployAll.s.sol:168). - §3 copies only the 42161 records (docs/mainnet.md:170).

Proof result - Part 3 run on its own and verbatim: bash <scratchpad>/pentest6/F/scripts/pentest/sixth/verify/F6_part3_verbatim.sh. It is lines 1–21 and 70–101 of the original; part 1 was not run, so forge never ran. - (a) With no Sepolia RPC it stops with no RPC for arbitrum-sepolia in .env.mainnet, and cast ran 0 times. - (b) With the Sepolia RPC in the shell it runs 5 commands: chain-id, 2 × wallet address --private-key, forge script script/DeployAll.s.sol:DeployAll --rpc-url … --broadcast --slow, then cast nonce. The attestor key stayed off the command line. Exit 0. Reproduced. - My extended copy: <scratchpad>/pentest6/F/scripts/pentest/sixth/verify/R6F3_part3.sh. Its forge stub also records forge's environment. - Case b, matched by key: 1 deployer key and 1 promo key on a command line, 0 attestor keys. Forge's environment held the mainnet file's DEPLOYER_PRIVATE_KEY, with YESCROW_ATTESTOR set to the pin 0x2164…82b8 and JUDGE to the Safe. This confirms the "mainnet deployer" claim, which the original stub could not show. - Case c: the shell as it is after §3 step 1 (set -a; . ./.env.mainnet), with ENV_FILE not typed. Same result. - Case d (control: arbitrum-one on the same file): 0 keys on a command line. - Read-only chain check of the mainnet deployer 0xA8C2…D14D on 2026-11-06: balance 0 and nonce 0 on both 421614 and 42161. The promised escrow addresses have no code on Sepolia.

Corrections to the finding - The precondition is wider than stated. Typing ENV_FILE is not needed. docs/mainnet.md:132 (§3 step 1, set -a; . ./.env.mainnet; set +a) exports the file's ENV_FILE=.env.mainnet (.env.mainnet.example:5) into the admin's shell. After step 1, a plain scripts/deploy-all.sh arbitrum-sepolia reads the mainnet file (case c). A Sepolia RPC is still needed. - "The vault file has none" was not checked. The vault is root-only, so only the template (.env.mainnet.example) is known to lack ARBITRUM_SEPOLIA_RPC_URL. If the vault was ever seeded from a testnet .env (.env.example:6 sets it), the wrong network name alone would be enough. - ".env.mainnet.example:5 does nothing there" is wrong. docker-compose.yml:73 fills in ${ENV_FILE:-.env} for the containers' env file, and the template's own command at .env.mainnet.example:3 relies on it. yescrow-ctl now sets ENV_FILE itself (ops/yescrow-ctl:250). The line also feeds the shell export in the point above. - The root cause stops at deploy-all.sh, but other testnet scripts that accept ENV_FILE do the same: - scripts/deploy-escrow.sh:30 puts ATTESTOR_PRIVATE_KEY on the command line before the RPC check at :32, and it ignores YESCROW_ATTESTOR. Reproduced with no Sepolia RPC: 1 attestor key on a command line, then exit at :32 (<scratchpad>/pentest6/F/scripts/pentest/sixth/verify/R6F3_other_scripts.sh). It also puts the promo key on the command line at :38. - scripts/fund-testnet-wallets.sh:11,15,21,23 puts the relayer key and the deployer key on the command line. :15 and :23 are cast send … --private-key "$DEPLOYER_PRIVATE_KEY" on Sepolia. This needs the Sepolia RPC (:9); I read it from the code and did not run it with an RPC. - scripts/deploy.sh:8–9,16 reads the env file the same way. - A second root cause is missing. DeployAll.s.sol:121–150 does not refuse Networks.MAINNET_DEPLOYER on chains other than mainnet, while DeployRouter.s.sol:45 refuses it on every chain. That gap is why the real forge would deploy on Sepolia. - "The deployer has no Sepolia ETH" is true today but protects nothing. Anyone can fund that address on Sepolia from a faucet. The slip would then deploy at Sepolia nonces 0–4, which are the mainnet-promised addresses (0xe13E…aBa, 0x3A73…fbB), with mainnet's judge, fee recipient, attestor pin and promo signer. This can confuse readers of explorers or the clone's deployments/arbitrum-sepolia.json, but does nothing on Arbitrum One. - The deployer half needs the §3 session's file. After the keystore import, the vault no longer holds DEPLOYER_PRIVATE_KEY (docs/secrets.md:105), so outside the session that starts at docs/mainnet.md:127 the script stops at deploy-all.sh:88, before :89. - Label error: F6_forge_output_no_keys.sh:13–14 calls the testnet-path result "R6-F-1, Info"; it is R6-F-3. - Aside, not this finding: the report cites DeployRouter.s.sol:212–213 and :214 (R6-F-1 and the "checked and found sound" list), but the file has 54 lines. The read is at :43–44 and the refusal at :45.

Fix assessment - "Refuse an ENV_FILE other than .env on a testnet unless TESTNET_ENV_FILE=1" works for cases b and c if it runs before deploy-all.sh:43. It is incomplete: it covers deploy-all.sh only, not deploy-escrow.sh, deploy.sh or fund-testnet-wallets.sh, and it misses a mainnet file copied as .env. - The alternative "refuse an env file that sets ARBITRUM_ONE_RPC_URL or CONFIRM_MAINNET" is flawed both ways: - The testnet .env is documented to carry ARBITRUM_ONE_RPC_URL for the mainnet fork tests (.env.example:9–10; contracts/test/fork/*), so this would block real testnet deploys. - CONFIRM_MAINNET is taken only from the caller (deploy-all.sh:31,49) and is never in the file, so checking for it catches nothing. - DEPLOYMENT=arbitrum-one (.env.mainnet.example:6, versus arbitrum-sepolia at .env.example:83) or NEXT_PUBLIC_CHAIN_ID=42161 would work as markers. - Dropping line 5 from the template narrows case c. It does not change the live vault file, which step 0 copies; that needs an edit there too. Fix the template's stale line-3 command at the same time. A cleaner option is to run §3 step 1 in a subshell, ( set -a; . ./.env.mainnet; set +a; cd contracts; … ), so neither the keys nor ENV_FILE stay in the admin's shell. - A complete fix needs two more changes: - In DeployAll's check(): if (!n.mainnet) require(deployer != Networks.MAINNET_DEPLOYER, …), mirroring DeployRouter.s.sol:45, and the same in Deploy.s.sol. This stops the Sepolia deployment inside forge, whichever script calls it. - In the testnet scripts: stop deriving addresses with cast wallet address --private-key. cast 1.8.5 has no environment variable for --private-key (cast wallet address --help), so derive them inside forge (vm.addr of vm.envOr keys), as mainnet already does. That removes the command-line half for any env file and meets docs/secrets.md:89. The env-file guard then becomes a second layer of defence.