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 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"] +}