From b424cde29c0f5bc78c50dbb00cfbbc4cc9aeb4cc Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:20:03 -0400 Subject: [PATCH 1/2] feat: add @hashpower/collateral-abi npm package with auto-publish pipeline Publishes viem-ready vault/margin-engine/points ABIs plus a testnet/mainnet deployments manifest (addresses verified on-chain). CI computes the semver bump by diffing the ABI surface against the last published version and publishes via npm Trusted Publishing (OIDC, tokenless) with Slack announce. v1.0.0 published manually to bootstrap the trust. Co-authored-by: Cursor --- .github/workflows/publish-collateral-abi.yml | 139 +++++++++++++++++++ collateral-abi/.gitignore | 5 + collateral-abi/README.md | 40 ++++++ collateral-abi/deployments.json | 22 +++ collateral-abi/package.json | 39 ++++++ collateral-abi/pnpm-lock.yaml | 24 ++++ collateral-abi/scripts/build.mjs | 37 +++++ collateral-abi/scripts/semver-diff.mjs | 69 +++++++++ collateral-abi/tsconfig.json | 13 ++ 9 files changed, 388 insertions(+) create mode 100644 .github/workflows/publish-collateral-abi.yml create mode 100644 collateral-abi/.gitignore create mode 100644 collateral-abi/README.md create mode 100644 collateral-abi/deployments.json create mode 100644 collateral-abi/package.json create mode 100644 collateral-abi/pnpm-lock.yaml create mode 100644 collateral-abi/scripts/build.mjs create mode 100644 collateral-abi/scripts/semver-diff.mjs create mode 100644 collateral-abi/tsconfig.json diff --git a/.github/workflows/publish-collateral-abi.yml b/.github/workflows/publish-collateral-abi.yml new file mode 100644 index 0000000..17d52a1 --- /dev/null +++ b/.github/workflows/publish-collateral-abi.yml @@ -0,0 +1,139 @@ +name: Publish @hashpower/collateral-abi + +# Publishes the ABI package to npm whenever the generated ABIs or the +# deployments manifest change on main. Versioning is automatic and +# semver-correct: the ABI *is* the package's public API, so CI diffs the +# built ABI surface against the last published version to compute the bump +# (removed/changed entry -> major, added entry -> minor, metadata -> patch) +# and commits the new version back before publishing. +# +# Auth uses npm Trusted Publishing (OIDC) — no NPM_TOKEN secret. +# NOTE: the very first release must be published manually +# (`cd collateral-abi && pnpm build && npm publish --access public`), +# then configure this repo+workflow as a Trusted Publisher in the +# package settings on npmjs.com. CI handles every release after that. + +on: + push: + branches: + # NOTE: publishing off `dev` while the repo operates there. + # At GA, change this to `main` (and update the `npm-publish` + # environment's deployment-branch rule to match) so npm only + # updates on mainline releases. + - dev + paths: + - "contracts/abi/**" + - "collateral-abi/**" + - ".github/workflows/publish-collateral-abi.yml" + # Manual runs from any branch for testing the pipeline + workflow_dispatch: + +permissions: + contents: write # push the automated version bump + id-token: write # npm provenance / trusted publishing + +concurrency: + group: publish-collateral-abi + cancel-in-progress: false + +jobs: + publish: + name: 📦 Build & publish to npm + runs-on: ubuntu-latest + # Must match the Trusted Publisher environment configured on npmjs.com + environment: npm-publish + defaults: + run: + working-directory: ./collateral-abi + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: collateral-abi/package.json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + cache: "pnpm" + cache-dependency-path: collateral-abi/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build package + run: pnpm build + + - name: Compute semver bump from ABI diff + id: bump + run: | + PKG=$(node -p "require('./package.json').name") + PUBLISHED=$(npm view "$PKG" version 2>/dev/null || echo "none") + + if [ "$PUBLISHED" = "none" ]; then + echo "First publish — using version from package.json as-is" + echo "level=first" >> "$GITHUB_OUTPUT" + echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Fetch the published tarball and diff its ABI surface against the fresh build + TARBALL=$(npm pack "$PKG@$PUBLISHED" --silent | tail -1) + mkdir -p /tmp/published + tar -xzf "$TARBALL" -C /tmp/published + rm "$TARBALL" + + LEVEL=$(node scripts/semver-diff.mjs /tmp/published/package .) + echo "Published: $PUBLISHED — ABI diff requires: $LEVEL" + echo "level=$LEVEL" >> "$GITHUB_OUTPUT" + + if [ "$LEVEL" = "none" ]; then + echo "No ABI or metadata changes — skipping publish" + echo "version=$PUBLISHED" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Baseline on the published version, then apply the computed bump + npm version "$PUBLISHED" --no-git-tag-version --allow-same-version + npm version "$LEVEL" --no-git-tag-version + NEW=$(node -p "require('./package.json').version") + + if ! git diff --quiet -- package.json; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json + git commit -m "chore(collateral-abi): v$NEW ($LEVEL) [skip ci]" + git push + fi + echo "version=$NEW" >> "$GITHUB_OUTPUT" + echo "Version: $NEW ($LEVEL bump from $PUBLISHED)" + + - name: Publish + if: steps.bump.outputs.level != 'none' + run: npm publish --access public --provenance + + - name: Summary + if: steps.bump.outputs.level != 'none' + run: | + PKG=$(node -p "require('./package.json').name") + VERSION=$(node -p "require('./package.json').version") + echo "## 📦 Published $PKG@$VERSION" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "https://www.npmjs.com/package/$PKG/v/$VERSION" >> $GITHUB_STEP_SUMMARY + + - name: Send Slack notification + if: always() && steps.bump.outputs.level != 'none' && steps.bump.outputs.level != '' + uses: ./.github/actions/slack-notify + with: + status: ${{ job.status }} + environment: "npm" + service_name: "@hashpower/collateral-abi" + version: ${{ steps.bump.outputs.version || 'unknown' }} + slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} + github_token: ${{ secrets.GITHUB_TOKEN }} + additional_info: "${{ format('*Semver bump:* `{0}` • *Package:* ', steps.bump.outputs.level, steps.bump.outputs.version) }}" diff --git a/collateral-abi/.gitignore b/collateral-abi/.gitignore new file mode 100644 index 0000000..3fc495e --- /dev/null +++ b/collateral-abi/.gitignore @@ -0,0 +1,5 @@ +# Generated by scripts/build.mjs — source of truth is ../contracts/abi +src/ +dist/ +json/ +node_modules/ diff --git a/collateral-abi/README.md b/collateral-abi/README.md new file mode 100644 index 0000000..c6fb5ca --- /dev/null +++ b/collateral-abi/README.md @@ -0,0 +1,40 @@ +# @hashpower/collateral-abi + +ABIs and deployment addresses for the Hashpower unified collateral system on Base: + +| Contract | Purpose | +| --- | --- | +| `CollateralVault` | Shared USDC custody — `deposit`, `withdraw` (IM-gated), `balanceOf` | +| `PortfolioMarginEngine` | Cross-product portfolio margin — `computePortfolioIM/MM`, `isHealthy`, `canPlaceOrder` | +| `Points` | Rewards ledger | + +Collateral is unified across all Hashpower trading venues (futures, perps): deposit once to the vault, trade everywhere. Withdrawals are gated by portfolio initial margin. + +## Usage + +```ts +import { CollateralVaultAbi, PortfolioMarginEngineAbi } from "@hashpower/collateral-abi"; +import deployments from "@hashpower/collateral-abi/deployments.json" with { type: "json" }; + +// "testnet" (Base Sepolia) or "mainnet" (Base) +const env = process.env.HASHPOWER_ENV ?? "testnet"; +const { contracts } = deployments.environments[env]; + +const im = await client.readContract({ + address: contracts.PortfolioMarginEngine, + abi: PortfolioMarginEngineAbi, + functionName: "computePortfolioIM", + args: [account], +}); +``` + +Raw JSON ABIs (for subgraphs and non-TypeScript consumers) are available under `@hashpower/collateral-abi/json/.json`. + +## How this package is built + +Contents are generated — do not edit by hand: + +- `src/` is copied from `../contracts/abi` (the Hardhat codegen output) by `scripts/build.mjs`, then compiled to `dist/`. +- `deployments.json` is the canonical address manifest for this repo; it is updated when contracts are (re)deployed. + +Publishing happens automatically from CI when ABIs or the manifest change (see `.github/workflows/publish-collateral-abi.yml`). diff --git a/collateral-abi/deployments.json b/collateral-abi/deployments.json new file mode 100644 index 0000000..e12561f --- /dev/null +++ b/collateral-abi/deployments.json @@ -0,0 +1,22 @@ +{ + "package": "@hashpower/collateral-abi", + "environments": { + "testnet": { + "chainId": 84532, + "network": "base-sepolia", + "contracts": { + "CollateralVault": "0x54A79e2a5C60ACe37b280eBbCda51b4E903d25F0", + "PortfolioMarginEngine": "0x3899e429Ef47140eC46c6E23F04253C24F221b69", + "Points": "0x153F6cb4386d717AD94791E6Ee8ae37f80315972", + "CollateralToken": "0xDd15eED84065A58c9E9ff9E95fb996be0fff22AA" + }, + "subgraphs": {} + }, + "mainnet": { + "chainId": 8453, + "network": "base", + "contracts": {}, + "subgraphs": {} + } + } +} diff --git a/collateral-abi/package.json b/collateral-abi/package.json new file mode 100644 index 0000000..604d7f5 --- /dev/null +++ b/collateral-abi/package.json @@ -0,0 +1,39 @@ +{ + "name": "@hashpower/collateral-abi", + "version": "1.0.0", + "description": "ABIs and deployment addresses for the Hashpower unified collateral system (CollateralVault, PortfolioMarginEngine, Points) on Base", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/Lumerin-protocol/collateral-margin.git", + "directory": "collateral-abi" + }, + "keywords": ["hashpower", "collateral", "margin", "vault", "abi", "base", "viem"], + "files": ["dist", "json", "deployments.json", "README.md"], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./deployments.json": "./deployments.json", + "./json/*.json": "./json/*.json" + }, + "scripts": { + "build": "node scripts/build.mjs && tsc -p tsconfig.json", + "clean": "rm -rf src dist json", + "prepublishOnly": "pnpm build" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=22" + }, + "packageManager": "pnpm@10.28.1", + "publishConfig": { + "access": "public" + } +} diff --git a/collateral-abi/pnpm-lock.yaml b/collateral-abi/pnpm-lock.yaml new file mode 100644 index 0000000..eb1820e --- /dev/null +++ b/collateral-abi/pnpm-lock.yaml @@ -0,0 +1,24 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.9.3 + +packages: + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + typescript@5.9.3: {} diff --git a/collateral-abi/scripts/build.mjs b/collateral-abi/scripts/build.mjs new file mode 100644 index 0000000..041b1d2 --- /dev/null +++ b/collateral-abi/scripts/build.mjs @@ -0,0 +1,37 @@ +// Copies the codegen output from contracts/abi into this package: +// *.ts -> src/ (compiled to dist/ by tsc) +// *.json -> json/ (shipped raw for non-TS consumers, e.g. subgraphs) +// and generates src/index.ts re-exporting everything. +import { copyFileSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const abiDir = path.resolve(pkgRoot, "../contracts/abi"); +const srcDir = path.join(pkgRoot, "src"); +const jsonDir = path.join(pkgRoot, "json"); + +// Test-only mocks are not part of the public package +const EXCLUDE = new Set([]); + +rmSync(srcDir, { recursive: true, force: true }); +rmSync(jsonDir, { recursive: true, force: true }); +mkdirSync(srcDir, { recursive: true }); +mkdirSync(jsonDir, { recursive: true }); + +const modules = []; +for (const file of readdirSync(abiDir).sort()) { + const base = file.replace(/\.(ts|json)$/, ""); + if (EXCLUDE.has(base)) continue; + if (file.endsWith(".ts")) { + copyFileSync(path.join(abiDir, file), path.join(srcDir, file)); + modules.push(base); + } else if (file.endsWith(".json")) { + copyFileSync(path.join(abiDir, file), path.join(jsonDir, file)); + } +} + +const index = modules.map((name) => `export * from "./${name}.js";`).join("\n"); +writeFileSync(path.join(srcDir, "index.ts"), `${index}\n`); + +console.log(`Copied ${modules.length} ABI modules from contracts/abi`); diff --git a/collateral-abi/scripts/semver-diff.mjs b/collateral-abi/scripts/semver-diff.mjs new file mode 100644 index 0000000..0d5f263 --- /dev/null +++ b/collateral-abi/scripts/semver-diff.mjs @@ -0,0 +1,69 @@ +// Computes the required semver bump by diffing the ABI surface of the +// last-published package against the freshly built one. +// +// Usage: node scripts/semver-diff.mjs +// Prints one of: major | minor | patch | none +// +// Rules — the ABI *is* the public API, so the level is computable: +// - ABI entry removed or modified, or a contract file removed -> major +// - New ABI entry or new contract file -> minor +// - Only metadata changed (deployments.json, README, ...) -> patch +// - Nothing changed -> none +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; + +const [publishedRoot, currentRoot] = process.argv.slice(2); +if (!publishedRoot || !currentRoot) { + console.error("Usage: semver-diff.mjs "); + process.exit(1); +} + +// Canonical stringify (sorted keys) so formatting differences don't matter +function canon(value) { + if (Array.isArray(value)) return `[${value.map(canon).join(",")}]`; + if (value && typeof value === "object") { + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canon(value[k])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +const abiEntries = (file) => new Set(JSON.parse(readFileSync(file, "utf8")).map(canon)); +const listJson = (dir) => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".json")).sort() : []); + +const oldDir = path.join(publishedRoot, "json"); +const newDir = path.join(currentRoot, "json"); +const oldFiles = listJson(oldDir); +const newFiles = listJson(newDir); + +let removedOrChanged = false; +let added = false; + +for (const file of oldFiles) { + if (!newFiles.includes(file)) { + removedOrChanged = true; + continue; + } + const oldSet = abiEntries(path.join(oldDir, file)); + const newSet = abiEntries(path.join(newDir, file)); + for (const entry of oldSet) if (!newSet.has(entry)) removedOrChanged = true; + for (const entry of newSet) if (!oldSet.has(entry)) added = true; +} +for (const file of newFiles) { + if (!oldFiles.includes(file)) added = true; +} + +if (removedOrChanged) { + console.log("major"); +} else if (added) { + console.log("minor"); +} else { + // ABI surface identical — check whether package metadata changed + const metaChanged = ["deployments.json", "README.md"].some((file) => { + const oldPath = path.join(publishedRoot, file); + const newPath = path.join(currentRoot, file); + if (!existsSync(oldPath) || !existsSync(newPath)) return true; + return readFileSync(oldPath, "utf8") !== readFileSync(newPath, "utf8"); + }); + console.log(metaChanged ? "patch" : "none"); +} diff --git a/collateral-abi/tsconfig.json b/collateral-abi/tsconfig.json new file mode 100644 index 0000000..ffca7cc --- /dev/null +++ b/collateral-abi/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src"] +} From a7f184ac40dac9ea99fe1e8936d7c0cb508bffef Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:03:43 -0400 Subject: [PATCH 2/2] ci: add ABI drift check (compile-and-compare on contract changes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guarantees committed ABIs in contracts/abi/ always match compiled output — the publish pipeline and semver classifier depend on them being truthful. Ported from hashprice-oracle. Co-authored-by: Cursor --- .github/workflows/check-contracts-abi.yml | 86 +++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/check-contracts-abi.yml diff --git a/.github/workflows/check-contracts-abi.yml b/.github/workflows/check-contracts-abi.yml new file mode 100644 index 0000000..d20778b --- /dev/null +++ b/.github/workflows/check-contracts-abi.yml @@ -0,0 +1,86 @@ +name: Check ABI Up-to-Date + +on: + pull_request: + paths: + - "contracts/**" + - ".github/workflows/check-contracts-abi.yml" + push: + branches: + - dev + - main + paths: + - "contracts/**" + - ".github/workflows/check-contracts-abi.yml" + +jobs: + check-abi: + name: 🔍 Verify ABIs are up-to-date + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./contracts + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: contracts/package.json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: contracts/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Compile contracts and generate ABIs + run: pnpm compile + + - name: Check for ABI differences + run: | + if [ -n "$(git diff --name-only -- abi/)" ]; then + echo "::error::Generated ABIs do not match committed ABIs. Please run 'pnpm compile' in contracts/ and commit the updated ABI files." + echo "" + echo "Changed files:" + git diff --name-only -- abi/ + echo "" + echo "Diff:" + git diff -- abi/ + exit 1 + fi + + if [ -n "$(git ls-files --others --exclude-standard -- abi/)" ]; then + echo "::error::New ABI files were generated but not committed. Please run 'pnpm compile' in contracts/ and commit the new ABI files." + echo "" + echo "Untracked files:" + git ls-files --others --exclude-standard -- abi/ + exit 1 + fi + + echo "✅ All ABIs are up-to-date." + + - name: Summary + if: failure() + run: | + echo "## ❌ ABI Check Failed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The committed ABI files do not match what the contracts generate." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**To fix:** Run the following locally and commit the changes:" >> $GITHUB_STEP_SUMMARY + echo '```bash' >> $GITHUB_STEP_SUMMARY + echo "cd contracts && pnpm compile" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: Summary (success) + if: success() + run: | + echo "## ✅ ABI Check Passed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "All generated ABIs match the committed versions." >> $GITHUB_STEP_SUMMARY