feat: add guarded Base Sepolia encore
This commit is contained in:
@@ -137,6 +137,8 @@ export async function finalizeUpgrade({ root = process.cwd(), rpc }) {
|
||||
if (matching.length !== 1) throw new Error(`expected exactly one live upgrade transaction to proxy, found ${matching.length}`);
|
||||
const receipt = await rpc("eth_getTransactionReceipt", [matching[0].hash]);
|
||||
if (!receipt || !isSuccessfulReceipt(receipt.status)) throw new Error("upgrade receipt was not successful");
|
||||
const upgradeBlock = parseRpcQuantity(receipt.blockNumber, "upgrade receipt block number");
|
||||
if (upgradeBlock === 0) throw new Error("upgrade receipt block number must be nonzero");
|
||||
if (!Array.isArray(receipt.logs) || !receipt.logs.some((log) => isUpgradeLog(log, active.proxy, pending.implementation))) {
|
||||
throw new Error("successful upgrade receipt is missing the expected Upgraded event");
|
||||
}
|
||||
@@ -148,7 +150,7 @@ export async function finalizeUpgrade({ root = process.cwd(), rpc }) {
|
||||
await atomicWriteJson(canonicalPath, updated);
|
||||
await atomicWriteJson(activePath, updated);
|
||||
await rm(pendingPath);
|
||||
return { mode: "upgrade", path: canonicalPath, manifest: updated };
|
||||
return { mode: "upgrade", path: canonicalPath, manifest: updated, upgradeBlock };
|
||||
}
|
||||
|
||||
function validateNoopMarker(marker, active) {
|
||||
@@ -377,6 +379,11 @@ function assertPublicUrl(value, field) {
|
||||
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) {
|
||||
throw new Error(`manifest ${field} must be a public URL without credentials`);
|
||||
}
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (/(key|token|secret|password|credential)/i.test(key)) {
|
||||
throw new Error(`manifest ${field} must not contain credential query parameters`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertActorConfiguration(manifest) {
|
||||
@@ -390,8 +397,13 @@ function assertActorConfiguration(manifest) {
|
||||
if (actors.length !== expected.length || actors.some((actor, index) => actor.label !== expected[index][0] || actor.address.toLowerCase() !== expected[index][1].toLowerCase())) {
|
||||
throw new Error("manifest anvil actors must match the documented local actor configuration");
|
||||
}
|
||||
} else if (actors.length !== 1 || actors[0].label !== "owner") {
|
||||
throw new Error("manifest baseSepolia must contain only the owner actor");
|
||||
} else {
|
||||
const configured = actors.length === 2 && actors[0].label === "Presenter" && actors[1].label === "Recipient";
|
||||
const legacy = actors.length === 1 && actors[0].label === "owner"
|
||||
&& !Object.hasOwn(manifest, "rpcUrl") && !Object.hasOwn(manifest, "explorerBaseUrl");
|
||||
if (!configured && !legacy) {
|
||||
throw new Error("manifest baseSepolia actors must be Presenter and Recipient");
|
||||
}
|
||||
}
|
||||
if (actors[0].address.toLowerCase() !== owner.toLowerCase()) throw new Error("manifest owner must be actor zero");
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ export function validatePublicManifest(manifest) {
|
||||
for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field);
|
||||
assertActors(manifest.actors);
|
||||
for (const field of optionalFields) if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field);
|
||||
if (manifest.network === "baseSepolia") {
|
||||
if (Object.hasOwn(manifest, "rpcUrl") && new URL(manifest.rpcUrl).protocol !== "https:") {
|
||||
throw new Error("manifest Base Sepolia rpcUrl must use HTTPS");
|
||||
}
|
||||
if (Object.hasOwn(manifest, "explorerBaseUrl") && manifest.explorerBaseUrl !== "https://sepolia.basescan.org") {
|
||||
throw new Error("manifest Base Sepolia explorerBaseUrl must use BaseScan");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
case ${1-} in
|
||||
deploy|upgrade|transfer) ;;
|
||||
*) fail 'usage: require-base-config.sh <deploy|upgrade|transfer>' ;;
|
||||
esac
|
||||
|
||||
for variable in \
|
||||
BASE_SEPOLIA_RPC_URL \
|
||||
BASE_SEPOLIA_PUBLIC_RPC_URL \
|
||||
BASE_SEPOLIA_ACCOUNT \
|
||||
BASE_SEPOLIA_SENDER \
|
||||
BASE_SEPOLIA_RECIPIENT; do
|
||||
[[ -n ${!variable-} ]] || fail "$variable is required"
|
||||
done
|
||||
|
||||
[[ $BASE_SEPOLIA_ACCOUNT =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]] || \
|
||||
fail 'BASE_SEPOLIA_ACCOUNT must be a conservative Foundry account identifier'
|
||||
|
||||
address_pattern='^0x[[:xdigit:]]{40}$'
|
||||
[[ $BASE_SEPOLIA_SENDER =~ $address_pattern ]] || fail 'BASE_SEPOLIA_SENDER must be an address'
|
||||
[[ $BASE_SEPOLIA_RECIPIENT =~ $address_pattern ]] || fail 'BASE_SEPOLIA_RECIPIENT must be an address'
|
||||
[[ ${BASE_SEPOLIA_SENDER,,} != 0x0000000000000000000000000000000000000000 ]] || \
|
||||
fail 'BASE_SEPOLIA_SENDER must be nonzero'
|
||||
[[ ${BASE_SEPOLIA_RECIPIENT,,} != 0x0000000000000000000000000000000000000000 ]] || \
|
||||
fail 'BASE_SEPOLIA_RECIPIENT must be nonzero'
|
||||
[[ ${BASE_SEPOLIA_SENDER,,} != "${BASE_SEPOLIA_RECIPIENT,,}" ]] || \
|
||||
fail 'BASE_SEPOLIA_SENDER and BASE_SEPOLIA_RECIPIENT must differ'
|
||||
|
||||
[[ $BASE_SEPOLIA_RPC_URL =~ ^https://[^[:space:]]+$ ]] || \
|
||||
fail 'BASE_SEPOLIA_RPC_URL must use HTTPS'
|
||||
[[ $BASE_SEPOLIA_PUBLIC_RPC_URL =~ ^https://[^[:space:]]+$ ]] || \
|
||||
fail 'BASE_SEPOLIA_PUBLIC_RPC_URL must use HTTPS'
|
||||
|
||||
public_remainder=${BASE_SEPOLIA_PUBLIC_RPC_URL#https://}
|
||||
public_authority=${public_remainder%%[/?#]*}
|
||||
[[ -n $public_authority && $public_authority != *@* ]] || \
|
||||
fail 'BASE_SEPOLIA_PUBLIC_RPC_URL must not contain user info'
|
||||
|
||||
if [[ $BASE_SEPOLIA_PUBLIC_RPC_URL == *\?* ]]; then
|
||||
public_query=${BASE_SEPOLIA_PUBLIC_RPC_URL#*\?}
|
||||
public_query=${public_query%%#*}
|
||||
IFS='&' read -r -a parameters <<<"$public_query"
|
||||
for parameter in "${parameters[@]}"; do
|
||||
key=${parameter%%=*}
|
||||
if [[ ${key,,} =~ (key|token|secret|password|credential) ]]; then
|
||||
fail 'BASE_SEPOLIA_PUBLIC_RPC_URL must not contain credential query parameters'
|
||||
fi
|
||||
done
|
||||
fi
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { access, readFile, rename } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
@@ -14,9 +14,33 @@ export async function selectManifest({ root = process.cwd(), network }) {
|
||||
return { source, active };
|
||||
}
|
||||
|
||||
export async function archiveManifest({ root = process.cwd(), network, now = new Date() }) {
|
||||
if (network !== "baseSepolia") throw new Error("only baseSepolia may be archived");
|
||||
const spec = networkSpec(network);
|
||||
const source = join(root, "deployments", spec.canonical);
|
||||
await readManifest(source);
|
||||
if (!(now instanceof Date) || Number.isNaN(now.valueOf())) throw new Error("archive timestamp is invalid");
|
||||
const timestamp = now.toISOString().replace(/[-:.]/g, "");
|
||||
if (!/^\d{8}T\d{9}Z$/.test(timestamp)) throw new Error("archive timestamp is invalid");
|
||||
const target = join(root, "deployments", `base-sepolia.${timestamp}.json`);
|
||||
try {
|
||||
await access(target);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
await rename(source, target);
|
||||
return { source, path: target };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error("refusing to overwrite an existing Base Sepolia archive");
|
||||
}
|
||||
|
||||
export async function runSelectCli(argv, { root = process.cwd(), log = console.log } = {}) {
|
||||
log(EDUCATIONAL_WARNING);
|
||||
if (argv.length !== 1) throw new Error("usage: select-manifest.mjs <anvil|baseSepolia>");
|
||||
if (argv.length === 2 && argv[0] === "archive" && argv[1] === "baseSepolia") {
|
||||
return archiveManifest({ root, network: argv[1] });
|
||||
}
|
||||
if (argv.length !== 1) throw new Error("usage: select-manifest.mjs <anvil|baseSepolia> | archive baseSepolia");
|
||||
return selectManifest({ root, network: argv[0] });
|
||||
}
|
||||
|
||||
|
||||
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repository_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
guard="$repository_root/tools/require-base-config.sh"
|
||||
terminal_url=https://terminal.invalid/rpc
|
||||
public_url=https://public.invalid/rpc
|
||||
account=demo-account
|
||||
sender=0x1111111111111111111111111111111111111111
|
||||
recipient=0x2222222222222222222222222222222222222222
|
||||
|
||||
run_guard() {
|
||||
local mode=${1-deploy}
|
||||
shift || true
|
||||
env -i PATH="$PATH" \
|
||||
BASE_SEPOLIA_RPC_URL="$terminal_url" \
|
||||
BASE_SEPOLIA_PUBLIC_RPC_URL="$public_url" \
|
||||
BASE_SEPOLIA_ACCOUNT="$account" \
|
||||
BASE_SEPOLIA_SENDER="$sender" \
|
||||
BASE_SEPOLIA_RECIPIENT="$recipient" \
|
||||
"$@" bash "$guard" "$mode"
|
||||
}
|
||||
|
||||
assert_no_config_values() {
|
||||
local output=$1
|
||||
shift
|
||||
local value
|
||||
for value in "$terminal_url" "$public_url" "$account" "$sender" "$recipient" "$@"; do
|
||||
[[ -n $value ]] || continue
|
||||
if [[ $output == *"$value"* ]]; then
|
||||
echo "configuration guard leaked a configured value" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
expect_rejected() {
|
||||
local assignment=$1
|
||||
local output
|
||||
if output=$(run_guard deploy "$assignment" 2>&1); then
|
||||
echo "configuration guard accepted invalid fixture: ${assignment%%=*}" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_config_values "$output" "${assignment#*=}"
|
||||
}
|
||||
|
||||
for mode in deploy upgrade transfer; do
|
||||
output=$(run_guard "$mode" 2>&1)
|
||||
[[ -z $output ]] || { echo "configuration guard produced output for valid fixtures" >&2; exit 1; }
|
||||
done
|
||||
|
||||
for assignment in \
|
||||
BASE_SEPOLIA_RPC_URL= \
|
||||
BASE_SEPOLIA_PUBLIC_RPC_URL= \
|
||||
BASE_SEPOLIA_ACCOUNT= \
|
||||
BASE_SEPOLIA_SENDER= \
|
||||
BASE_SEPOLIA_RECIPIENT= \
|
||||
BASE_SEPOLIA_ACCOUNT='bad account' \
|
||||
BASE_SEPOLIA_SENDER=0x1234 \
|
||||
BASE_SEPOLIA_RECIPIENT=0x0000000000000000000000000000000000000000 \
|
||||
BASE_SEPOLIA_RECIPIENT="$sender" \
|
||||
BASE_SEPOLIA_RPC_URL=http://terminal.invalid \
|
||||
BASE_SEPOLIA_PUBLIC_RPC_URL=http://public.invalid \
|
||||
BASE_SEPOLIA_PUBLIC_RPC_URL=https://user@public.invalid \
|
||||
BASE_SEPOLIA_PUBLIC_RPC_URL='https://public.invalid/?api_key=fixture' \
|
||||
BASE_SEPOLIA_PUBLIC_RPC_URL='https://public.invalid/?token=fixture'; do
|
||||
expect_rejected "$assignment"
|
||||
done
|
||||
|
||||
private_key_name=PRIVATE_$(printf KEY)
|
||||
mnemonic_name=MNEM$(printf ONIC)
|
||||
fake_key=0x$(printf 'a%.0s' {1..64})
|
||||
output=$(
|
||||
env -i PATH="$PATH" "$private_key_name=$fake_key" "$mnemonic_name=fixture words only" \
|
||||
bash "$guard" deploy 2>&1 || true
|
||||
)
|
||||
if [[ $output != *BASE_SEPOLIA_RPC_URL* ]]; then
|
||||
echo "configuration guard unexpectedly accepted a raw-key or mnemonic fallback" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_config_values "$output"
|
||||
|
||||
dry_run=$(
|
||||
make -n -C "$repository_root" deploy-base-sepolia \
|
||||
BASE_SEPOLIA_RPC_URL=https://terminal.invalid \
|
||||
BASE_SEPOLIA_PUBLIC_RPC_URL=https://public.invalid \
|
||||
BASE_SEPOLIA_ACCOUNT=demo \
|
||||
BASE_SEPOLIA_SENDER=0x1111111111111111111111111111111111111111 \
|
||||
BASE_SEPOLIA_RECIPIENT=0x2222222222222222222222222222222222222222
|
||||
)
|
||||
[[ $dry_run == *'--account "demo"'* ]] || { echo "Base dry run omitted --account" >&2; exit 1; }
|
||||
[[ $dry_run == *'--sender "0x1111111111111111111111111111111111111111"'* ]] || \
|
||||
{ echo "Base dry run omitted --sender" >&2; exit 1; }
|
||||
[[ $dry_run == *'--slow'* ]] || { echo "Base dry run omitted --slow" >&2; exit 1; }
|
||||
if [[ ${dry_run,,} == *private-key* || ${dry_run,,} == *mnemonic* ]]; then
|
||||
echo "Base dry run exposed a raw signing option" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node --input-type=module - "$repository_root" <<'NODE'
|
||||
import assert from "node:assert/strict";
|
||||
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const repositoryRoot = process.argv[2];
|
||||
const { archiveManifest, selectManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/select-manifest.mjs")));
|
||||
const { publishManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/publish-web-manifest.mjs")));
|
||||
const root = await mkdtemp(join(tmpdir(), "uups-base-config-"));
|
||||
const deployments = join(root, "deployments");
|
||||
const webManifest = join(root, "web/public/deployment.json");
|
||||
const owner = "0x1111111111111111111111111111111111111111";
|
||||
const recipient = "0x2222222222222222222222222222222222222222";
|
||||
const base = {
|
||||
schemaVersion: 1,
|
||||
network: "baseSepolia",
|
||||
chainId: 84532,
|
||||
deploymentBlock: 99,
|
||||
rpcUrl: "https://public.invalid/rpc",
|
||||
explorerBaseUrl: "https://sepolia.basescan.org",
|
||||
token: "0x3333333333333333333333333333333333333333",
|
||||
proxy: "0x4444444444444444444444444444444444444444",
|
||||
implementation: "0x5555555555555555555555555555555555555555",
|
||||
owner,
|
||||
actors: [
|
||||
{ label: "Presenter", address: owner },
|
||||
{ label: "Recipient", address: recipient },
|
||||
],
|
||||
};
|
||||
const anvil = {
|
||||
...base,
|
||||
network: "anvil",
|
||||
chainId: 31337,
|
||||
rpcUrl: "http://127.0.0.1:8545",
|
||||
explorerBaseUrl: undefined,
|
||||
actors: [
|
||||
{ label: "owner", address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" },
|
||||
{ label: "Alice", address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" },
|
||||
{ label: "Bob", address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" },
|
||||
],
|
||||
owner: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
|
||||
};
|
||||
delete anvil.explorerBaseUrl;
|
||||
|
||||
try {
|
||||
await mkdir(deployments, { recursive: true });
|
||||
await writeFile(join(deployments, "base-sepolia.json"), `${JSON.stringify(base)}\n`);
|
||||
await writeFile(join(deployments, "anvil.json"), `${JSON.stringify(anvil)}\n`);
|
||||
await selectManifest({ root, network: "baseSepolia" });
|
||||
const activeBefore = await readFile(join(deployments, "active.json"));
|
||||
const anvilBefore = await readFile(join(deployments, "anvil.json"));
|
||||
const published = await publishManifest({
|
||||
activePath: join(deployments, "active.json"),
|
||||
outputPath: webManifest,
|
||||
});
|
||||
assert.equal(published.rpcUrl, "https://public.invalid/rpc");
|
||||
assert.equal(published.explorerBaseUrl, "https://sepolia.basescan.org");
|
||||
assert.equal((await readFile(webManifest, "utf8")).includes("terminal.invalid"), false);
|
||||
for (const invalid of [
|
||||
{ ...base, rpcUrl: "http://public.invalid/rpc" },
|
||||
{ ...base, explorerBaseUrl: "https://example.invalid" },
|
||||
]) {
|
||||
await writeFile(join(deployments, "active.json"), `${JSON.stringify(invalid)}\n`);
|
||||
await assert.rejects(
|
||||
() => publishManifest({ activePath: join(deployments, "active.json"), outputPath: webManifest }),
|
||||
/HTTPS|BaseScan/,
|
||||
);
|
||||
}
|
||||
await writeFile(join(deployments, "active.json"), activeBefore);
|
||||
|
||||
const archived = await archiveManifest({
|
||||
root,
|
||||
network: "baseSepolia",
|
||||
now: new Date("2026-08-21T12:34:56.789Z"),
|
||||
});
|
||||
assert.equal(archived.path, join(deployments, "base-sepolia.20260821T123456789Z.json"));
|
||||
await access(archived.path);
|
||||
await assert.rejects(() => access(join(deployments, "base-sepolia.json")));
|
||||
assert.deepEqual(await readFile(join(deployments, "active.json")), activeBefore);
|
||||
assert.deepEqual(await readFile(join(deployments, "anvil.json")), anvilBefore);
|
||||
await assert.rejects(() => archiveManifest({ root, network: "anvil" }), /baseSepolia/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
NODE
|
||||
|
||||
echo "Base configuration and manifest filesystem tests passed"
|
||||
Reference in New Issue
Block a user