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
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Tick the matches your change and paste the outcome below. Commands mirror the ma
- [ ] `cd soroban && stellar contract build && cargo test --release`
- [ ] `(cd contracts && forge test --match-path "test/foundry/*" -v)` (Solidity fuzz / invariant)
- [ ] `pnpm test:e2e` (cross-chain differential harness)
- [ ] `pnpm validate:deployments` — required if `deployments.*.json` changes
- [ ] `node scripts/verify-addresses.mjs` — required if addresses, configs, or `env.example` change
- [ ] `node scripts/check-evidence-links.mjs` — advisory; required if docs links change

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
- name: Install
run: pnpm install --frozen-lockfile=false

- name: Validate deployment JSON
run: pnpm validate:deployments

- name: Build SDK
run: pnpm --filter @oversync/sdk build

Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pnpm --filter @oversync/frontend dev
Run the checks that match your change before opening a PR:

```bash
pnpm validate:deployments
pnpm --filter @oversync/sdk build
pnpm --filter @oversync/sdk exec tsc --noEmit
pnpm --filter @oversync/coordinator exec tsc --noEmit
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"load-test": "pnpm --filter @oversync/e2e load-test",
"load-test:live": "LOAD_TEST_LIVE=true pnpm --filter @oversync/e2e load-test",
"scf:testnet-metrics": "node scripts/scf-testnet-metrics.mjs",
"validate:deployments": "node scripts/validate-deployments.mjs",
"lint": "pnpm -r lint",
"clean": "pnpm -r clean && rm -rf node_modules",
"contracts:compile": "pnpm --filter contracts compile",
Expand Down
227 changes: 227 additions & 0 deletions scripts/validate-deployments.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { readdir, readFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
const DEPLOYMENT_FILE = /^deployments\.[a-z0-9-]+\.json$/i;
const NETWORKS = new Set(["testnet", "mainnet"]);
const EVM_ADDRESS = /^0x[a-fA-F0-9]{40}$/;
const STELLAR_ACCOUNT_ID = /^G[A-Z2-7]{55}$/;
const SOROBAN_CONTRACT_ID = /^C[A-Z2-7]{55}$/;
const HEX_64 = /^[a-fA-F0-9]{64}$/;

function valueType(value) {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}

class Validator {
constructor(file) {
this.file = file;
this.errors = [];
}

fail(path, message) {
this.errors.push(`${this.file}:${path} ${message}`);
}

object(value, path) {
if (valueType(value) !== "object") {
this.fail(path, `must be an object, got ${valueType(value)}`);
return false;
}
return true;
}

string(value, path) {
if (typeof value !== "string" || value.trim() === "") {
this.fail(path, `must be a non-empty string, got ${valueType(value)}`);
return null;
}
return value;
}

optionalString(value, path) {
if (value == null) return null;
return this.string(value, path);
}

number(value, path) {
if (typeof value !== "number" || !Number.isFinite(value)) {
this.fail(path, `must be a finite number, got ${valueType(value)}`);
return null;
}
return value;
}

match(value, path, pattern, label) {
const string = this.string(value, path);
if (string != null && !pattern.test(string)) {
this.fail(path, `must be a valid ${label}, got ${JSON.stringify(string)}`);
return null;
}
return string;
}

url(value, path) {
const string = this.string(value, path);
if (string == null) return null;
try {
const parsed = new URL(string);
if (!["http:", "https:"].includes(parsed.protocol)) {
this.fail(path, "must use http or https");
}
} catch {
this.fail(path, `must be a valid URL, got ${JSON.stringify(string)}`);
}
return string;
}

date(value, path) {
const string = this.string(value, path);
if (string != null && !/^\d{4}-\d{2}-\d{2}$/.test(string)) {
this.fail(path, `must be YYYY-MM-DD, got ${JSON.stringify(string)}`);
}
return string;
}
}

async function deploymentFiles() {
const entries = await readdir(repoRoot, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && DEPLOYMENT_FILE.test(entry.name))
.map((entry) => entry.name)
.sort();
}

function validateEthereum(v, ethereum, network) {
if (!v.object(ethereum, "$.ethereum")) return;
const chainId = v.number(ethereum.chainId, "$.ethereum.chainId");
const name = v.string(ethereum.name, "$.ethereum.name");
v.url(ethereum.rpcUrl, "$.ethereum.rpcUrl");
v.date(ethereum.deployedAt, "$.ethereum.deployedAt");
v.match(ethereum.deployer, "$.ethereum.deployer", EVM_ADDRESS, "EVM address");

if (network === "testnet") {
if (chainId !== 11155111) v.fail("$.ethereum.chainId", "must be 11155111 for testnet deployments");
if (name !== "Sepolia") v.fail("$.ethereum.name", 'must be "Sepolia" for testnet deployments');
}
if (network === "mainnet") {
if (chainId !== 1) v.fail("$.ethereum.chainId", "must be 1 for mainnet deployments");
}

if (!v.object(ethereum.contracts, "$.ethereum.contracts")) return;
v.match(ethereum.contracts.HTLCEscrow, "$.ethereum.contracts.HTLCEscrow", EVM_ADDRESS, "EVM address");
v.match(
ethereum.contracts.ResolverRegistry,
"$.ethereum.contracts.ResolverRegistry",
EVM_ADDRESS,
"EVM address"
);
}

function validateStellar(v, stellar, network) {
if (!v.object(stellar, "$.stellar")) return;
v.string(stellar.passphrase, "$.stellar.passphrase");
v.url(stellar.horizon, "$.stellar.horizon");
v.url(stellar.rpc, "$.stellar.rpc");
v.date(stellar.deployedAt, "$.stellar.deployedAt");
v.match(stellar.deployer, "$.stellar.deployer", STELLAR_ACCOUNT_ID, "Stellar account ID");

if (network === "testnet" && stellar.passphrase !== "Test SDF Network ; September 2015") {
v.fail("$.stellar.passphrase", 'must be "Test SDF Network ; September 2015" for testnet deployments');
}
if (network === "mainnet" && stellar.passphrase !== "Public Global Stellar Network ; September 2015") {
v.fail("$.stellar.passphrase", 'must be "Public Global Stellar Network ; September 2015" for mainnet deployments');
}

if (!v.object(stellar.contracts, "$.stellar.contracts")) return;
v.match(stellar.contracts.HTLC, "$.stellar.contracts.HTLC", SOROBAN_CONTRACT_ID, "Soroban contract ID");
v.match(
stellar.contracts.ResolverRegistry,
"$.stellar.contracts.ResolverRegistry",
SOROBAN_CONTRACT_ID,
"Soroban contract ID"
);

if (stellar.deployTransactions != null && v.object(stellar.deployTransactions, "$.stellar.deployTransactions")) {
for (const [key, txHash] of Object.entries(stellar.deployTransactions)) {
v.match(txHash, `$.stellar.deployTransactions.${key}`, HEX_64, "64-character hex transaction hash");
}
}

if (stellar.resolverRegistryConfig != null && v.object(stellar.resolverRegistryConfig, "$.stellar.resolverRegistryConfig")) {
const cfg = stellar.resolverRegistryConfig;
v.match(cfg.admin, "$.stellar.resolverRegistryConfig.admin", STELLAR_ACCOUNT_ID, "Stellar account ID");
v.match(cfg.stakeAsset, "$.stellar.resolverRegistryConfig.stakeAsset", SOROBAN_CONTRACT_ID, "Soroban contract ID");
v.optionalString(cfg.stakeAssetName, "$.stellar.resolverRegistryConfig.stakeAssetName");
v.match(
cfg.slashBeneficiary,
"$.stellar.resolverRegistryConfig.slashBeneficiary",
STELLAR_ACCOUNT_ID,
"Stellar account ID"
);
const minStake = v.string(cfg.minStake, "$.stellar.resolverRegistryConfig.minStake");
if (minStake != null && !/^[0-9]+$/.test(minStake)) {
v.fail("$.stellar.resolverRegistryConfig.minStake", "must be a base-unit integer string");
}
const minStakeXLM = v.string(cfg.minStakeXLM, "$.stellar.resolverRegistryConfig.minStakeXLM");
if (minStakeXLM != null && !/^[0-9]+(\.[0-9]+)?$/.test(minStakeXLM)) {
v.fail("$.stellar.resolverRegistryConfig.minStakeXLM", "must be a decimal string");
}
}
}

async function validateFile(fileName) {
const v = new Validator(fileName);
let parsed;
try {
parsed = JSON.parse(await readFile(resolve(repoRoot, fileName), "utf8"));
} catch (err) {
return [`${fileName}:$ must be valid JSON: ${err.message}`];
}

if (!v.object(parsed, "$")) return v.errors;
const network = v.string(parsed.network, "$.network");
if (network != null && !NETWORKS.has(network)) {
v.fail("$.network", `must be one of ${Array.from(NETWORKS).join(", ")}, got ${JSON.stringify(network)}`);
}

const fileNetwork = basename(fileName, ".json").replace(/^deployments\./, "");
if (network != null && NETWORKS.has(network) && fileNetwork !== network) {
v.fail("$.network", `must match file name deployments.${network}.json`);
}

validateEthereum(v, parsed.ethereum, network);
validateStellar(v, parsed.stellar, network);
return v.errors;
}

async function main() {
const files = await deploymentFiles();
if (files.length === 0) {
console.error("No deployment files found. Expected files like deployments.testnet.json.");
process.exitCode = 1;
return;
}

const errors = [];
for (const file of files) {
errors.push(...(await validateFile(file)));
}

if (errors.length > 0) {
console.error("Deployment validation failed:");
for (const error of errors) console.error(`- ${error}`);
process.exitCode = 1;
return;
}

console.log(`Validated ${files.length} deployment file(s): ${files.join(", ")}`);
}

main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
Loading