@perspectivefi/addresses
v0.2.9
Published
Contract address registry for Spectra: one list of every address, each with a status. Prod only accepts `safe`.
Maintainers
Readme
@perspectivefi/addresses
The contract address registry for Spectra. One shared list of every contract address, where each address has a status. Production only accepts addresses marked safe.
It exists so that test contracts stop reaching prod. Addresses used to be copied by hand into every repo, and the copies drifted apart. Now there is one list, changes go through reviewed PRs, and CI checks them against the chain.
The registry decides whether something may go to prod, not whether it is listed. Existing curation (whitelists, API catalog filters) still decides what users see.
Token addresses stay in token-list. Pools, PTs and vaults are not listed one by one (see Pools inherit from factories).
What safe means and who may approve it: POLICY.md. What this package does and does not guarantee: Guarantees.
The four statuses
| Status | What it means | Allowed on QA | Allowed in prod |
| ------------- | ------------------------------------------------- | ------------- | --------------- |
| unsafe | Deployed, code not audited | yes | no |
| role-unsafe | Audited, but the deployer still holds admin roles | yes | no |
| safe | Audited, and admin roles handed to the DAO | yes | yes |
| blocked | Explicitly banned, e.g. a test pool | yes | no |
A contract moves up as it matures: unsafe -> role-unsafe -> safe. Each step is a small PR. For third-party contracts (Kyber, Pendle, Multicall3...) safe means "approved integration"; we don't audit them.
Data format
One file per chain in registry/: registry/<chainId>.json (e.g. 1.json, 8453.json) or registry/stellar.json.
{
"chain": 1,
"name": "Ethereum",
"governance": ["0x..."],
"entries": [
{
"key": "core.router",
"address": "0x...",
"kind": "contract",
"status": "unsafe",
"tags": ["core"],
"source": { "repo": "perspectivefi/...", "commit": "<40-hex sha>" },
"audit": "https://...",
"deployer": "0x...",
"inherit": true,
"approval": { "basis": "audit", "pr": 12, "note": "..." },
"notes": "..."
}
],
"denylist": [
{
"address": "0x...",
"reason": "test vault removed from the registry; must never be listed in prod"
}
]
}| Field | Notes |
| ------------ | ------------------------------------------------------------------------------------------------ |
| governance | DAO Safe / timelock addresses allowed to hold admin roles on this chain. |
| key | <domain>.<name>, lowercase, dots and dashes, unique per chain. This is what code asks for. |
| address | EVM: EIP-55 checksummed. Stellar: C... 56-character contract id. |
| kind | contract, factory, beacon, instance or external (third party). |
| source | Repo and commit the deployed code came from. Optional for unsafe and external. |
| audit | Link to the audit report. Optional for unsafe and external. |
| deployer | Optional deployer EOA. The nightly and PR role check make sure it holds no admin role on safe. |
| inherit | Only on factory / beacon: instances created by it inherit its status. |
| approval | { basis, pr, note? }. Required on every safe entry, see POLICY.md. |
| denylist | Per chain file, optional: { address, reason } items. These addresses are never acceptable. |
approval.basis is one of audit, prod-baseline, external-integration, governance; approval.pr is the registry PR that approved the entry. approval is not part of the runtime data, only of dist/registry.json.
Denylist
A blocked entry must never simply be deleted: once it is gone the address is just "unknown" (or, behind a safe factory, looks inherited-safe). Move it to the chain's denylist instead. A denylisted address:
- is
blockedforstatusOf(chain, address)even without an entry, andisDenied(chain, address)istrue; - is shipped in both the full and the
/proddatasets, so consumers can still refuse it; - fails the build check wherever it appears, on that chain;
- cannot also be an entry (validation error); addresses must be valid, unique and have a non-empty
reason.
CI enforces this: npm run validate -- --base origin/main fails when a blocked entry of the base branch has disappeared and is not in the denylist.
How to add an address
- Add an entry to
registry/<chain>.jsonwith statusunsafe(never straight tosafe: approval is a separate PR, see POLICY.md). Use the checksummed address. If the chain has no file yet, create it (name = chain id) and fillgovernance. - Run
npm run validate, andnpm run onchainif you want to see the chain check locally. - Open a PR. CI runs the checks below; you need 2 approvals.
Run npm run generate after editing a registry file and commit the files in src/generated/ as well. CI fails if it is stale.
How to promote a status
Each step is a PR that changes one status line (plus the fields it needs).
| Change | What you add | What CI checks |
| ------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| unsafe -> role-unsafe | source.repo + 40-hex source.commit, audit URL | The fields exist and are well-formed. The contract has bytecode on-chain. |
| role-unsafe -> safe | Hand admin roles to the DAO, then change status, add approval | Everything above, plus approval.pr equal to this PR's number, plus the on-chain role check: every owner / admin found is in governance, and the deployer (if set) holds no admin role. |
| anything -> blocked | A reason in notes | The contract has bytecode. |
source and audit are required for role-unsafe, and for safe unless the entry's approval.basis is something other than audit (prod-baseline, external-integration, governance; the evidence is then in the PR, see POLICY.md).
The on-chain role check reads owner(), the EIP-1967 admin slot (and the owner of that admin contract if it is one), and authority() for OpenZeppelin AccessManager (hasRole(0, governance[i]) must be true for at least one governance address, and hasRole(0, deployer) must be false). An owner or proxy-admin owner that is itself an AccessManager, and an AccessManaged proxy admin (it has authority() instead of owner()), are judged the same way. RPC failures on a safe entry fail the run.
Every other check, on every PR:
- JSON shape and types,
chainmatches the file name, keys are well-formed and unique per chain. - EVM addresses are EIP-55 checksummed, Stellar ids are valid,
governanceaddresses are valid. inheritonly on factories / beacons.approvalis well-formed and present on everysafeentry; in a PR, an entry that is newlysafemust carry this PR's number inapproval.pr.denylistis valid (see above) and no blocked entry was deleted compared with the base branch.- The same address under two keys gives a warning, not a failure.
- Every address has bytecode on-chain (Stellar is not checked yet).
Nightly: the on-chain run repeats daily. If a safe entry no longer passes (roles changed after approval), it opens an issue labelled registry-drift (or comments on the open one). Nothing is downgraded automatically; a human decides.
Pools inherit from factories
There are hundreds of pools, PTs and vaults and new ones appear all the time, so they are not listed. They inherit the status of the factory that created them: a pool made by a safe factory is safe. Mark a factory or beacon with "inherit": true to make that explicit.
If someone creates a test pool through a prod factory, add that single pool to the registry as blocked (kind: "instance"). A blocked entry, and a denylisted address, always win over the factory's status. Runtime data carries inherit: true on the factories / beacons that opted in; the package only exposes the data, consumers decide how to verify provenance.
Using it in an app
// QA, previews, API server: every address, whatever its status
import { address, requireAddress, isProdSafe } from '@perspectivefi/addresses'
// Production front-ends: only `safe` addresses exist in this bundle
import { address, requireAddress } from '@perspectivefi/addresses/prod'
address(1, 'otc.engine') // string | undefined (undefined in prod unless safe: hide the feature)
requireAddress(1, 'otc.engine') // throws "no address for key ... on chain 1" insteadPick the entry point at build time (alias @perspectivefi/addresses to .../prod in production builds). Both have the same API:
| Function | Returns |
| ----------------------------- | -------------------------------------------------------------- |
| address(chain, key) | the address or undefined |
| requireAddress(chain, key) | the address or throws an error naming chain and key |
| entry(chain, key) | the full Entry or undefined |
| findByAddress(chain, addr) | the Entry (EVM compare is case-insensitive) |
| statusOf(chain, addr) | Status, blocked for a denylisted address, else undefined |
| isDenied(chain, addr) | true when the address is on that chain's denylist |
| isProdSafe(chain, addr) | true only for safe |
| entries(chain?), chains() | all entries (optionally one chain), the chains present |
Types: Entry, Status, Kind, ChainRef (chain ids + 'stellar'), AddressKey (every key in the registry). chain also accepts a plain number, e.g. a runtime chain id.
The package has zero runtime dependencies. The /prod bundle contains no non-safe entry at all (a test checks dist/prod.*); the only non-safe addresses in it are the denylisted ones, which are there so they can be refused.
Per-key exports (tree-shakeable)
For front-ends, import only the keys you need. Unused keys are dropped by the bundler, so only those addresses end up in the bundle:
import { coreRouter } from '@perspectivefi/addresses/prod/keys' // safe addresses only (production)
import { coreRouter } from '@perspectivefi/addresses/keys' // every status (QA, previews)
coreRouter[1] // string | undefined, a frozen { chain -> address } recordOne export per registry key, named in camelCase: core.router -> coreRouter, external.zodiac.roles-mastercopy-v2.1.0 -> externalZodiacRolesMastercopyV2_1_0 (a segment that starts with a digit is joined with _). Generation fails if two keys would get the same name. In /prod/keys a key with no safe chain is an empty frozen object, so imports never break; check the chain you need and hide the feature when it is missing.
Each value is emitted as JSON.parse("{\"$spectraAddresses\":1,\"key\":\"core.router\",\"chains\":{...}}"). Minifiers never rewrite string literals, so the release check finds these blocks verbatim in the built bundle and checks every (chain, address) pair. The package declares sideEffects: false.
Release check (CLI)
spectra-addresses check scans a finished build and fails the release when it finds an address that is not approved for the chain it is used on.
// package.json of the app
"build": "next build && spectra-addresses check .next --allow token-list-addresses.json"spectra-addresses check <paths...> [--mode off|enforce] [--allow <json file>]... [--registry <url|file>] [--offline]- Mode:
--mode, else envADDRESS_GATE. There are two modes.offprints the full report and always exits 0.enforceexits 1 on any violation. IfVERCEL_ENV=productionand the mode is unset or invalid, the check exits 1 ("ADDRESS_GATE must be set to off or enforce in production"), so a missing setting can never silently disable protection. Outside production an unset or invalid mode meansoff.enforceis not downgraded on preview builds. - Tagged blocks first: every
$spectraAddressesblock (from/keys,/prod/keys, the runtime datasets and the runtime denylist) is found, also when its JSON is escaped inside a JS string. For each(chain, address)in a block, the exact entry for that chain must besafein the latest registry and not denylisted. The report names key and chain. A block that cannot be parsed, or carries anything besides the fields we emit, is a violation. The runtime denylist block is accepted only for addresses the registry itself denylists. - Then bare addresses: the blocks are cut out and the rest of the file is scanned for EVM addresses and Stellar contract ids. A bare address passes only if it is on an
--allowlist, or every registry entry with that address (on any chain) issafeand it is denylisted on no chain. Otherwise it is a violation, and the report says which chain made it fail (blocked,unsafe,denylisted, ornot in registry). A denylisted address fails even if it is on an--allowlist. - Latest registry: the check does not use the version your lockfile pins. jsDelivr and unpkg cache
@latestURLs for hours, so it first asks npm for the exact latest version (https://registry.npmjs.org/@perspectivefi/addresses/latest, printed aslatest published version: x.y.z) and then fetchesdist/registry.jsonof that version from jsDelivr (https://cdn.jsdelivr.net/npm/@perspectivefi/addresses@<version>/dist/registry.json), then from unpkg (https://unpkg.com/@perspectivefi/addresses@<version>/dist/registry.json), 10 second timeout each. If the npm lookup fails it prints a warning and uses the@latestCDN URLs instead.--registry <url-or-file>or envADDRESS_REGISTRY_URLreplaces all of this (the flag wins; no npm lookup). The output says which dataset was used. - If no source loads: in
offthe check prints aWARNINGand uses the dataset bundled in the installed package. Inenforceit exits 1 ("cannot verify against the latest registry").--offlineskips the fetch and uses the bundled dataset on purpose; inenforceit prints a loud warning that the run is only as fresh as the installed version. - Scans files recursively (skips
*.map,cacheandnode_modulesdirs, and binary files over 5 MB). - Always allowed: the zero address and
0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE.--allowtakes JSON files with an array of address strings (or objects with anaddressfield). Use it for token-list addresses. It applies to bare addresses only. - Exit codes:
0ok / off,1violations inenforce, an unset mode in production, or an unverifiable registry inenforce,2bad usage.
dist/registry.json is { "entries": [...], "denylist": [...] } (every field, every status). Versions before 0.2.0 published a bare array; their CLI cannot read the new shape and falls back to its bundled data, so upgrade the CLI together with the first release that uses this one.
ESLint preset (no hardcoded addresses)
@perspectivefi/addresses/eslint is a flat-config object (plain JS, no ESLint dependency). It errors on any string or template literal that is an EVM address (zero address allowed) or a Stellar contract id, and on address() / requireAddress() calls whose 2nd argument is not a string literal.
// eslint.config.mjs
import addresses from '@perspectivefi/addresses/eslint'
export default [
// ...your other configs
addresses,
{
// tests and fixtures may contain raw addresses
files: ['**/*.test.{ts,tsx}', '**/__tests__/**', '**/fixtures/**', '**/mocks/**'],
rules: { 'no-restricted-syntax': 'off' },
},
]ESLint keeps one no-restricted-syntax setting per file, so the preset replaces yours. If you already use that rule, merge the selectors instead:
import addresses from '@perspectivefi/addresses/eslint'
rules: {
'no-restricted-syntax': ['error', ...addresses.restrictedSyntax, /* your own */],
}(restrictedSyntax, hardcodedAddressRules and literalKeyRules are available as properties of the default export.)
Guarantees
What this package guarantees, and what it does not.
Covered
- Lookups are per chain.
address,statusOf,findByAddress,isDeniedand the per-key records never mix chains. An address approved on one chain says nothing about the same address on another. - Production build scan.
spectra-addresses checkruns against the latest published registry (not the pinned one) and fails the release when a built bundle contains an address that is notsafeon its chain: tagged per-key blocks are checked per chain, and any other address-looking literal (the "bare-address rule") must besafeon every chain it is listed on, denylisted nowhere, or be on an--allowlist. Unknown addresses fail. With the mode set toenforcethis blocks the release; in production the mode must be set explicitly. - Removed means refused, not forgotten. Deleting a blocked entry requires moving it to the denylist, and the denylist is in the prod dataset too.
- Every
safeentry has an approval record (basis and registry PR), and in CI a newly safe entry must be approved by the PR that introduces it. - Nightly on-chain check that
safeentries still have their roles with the DAO. It opens an issue; nothing is downgraded automatically.
Not covered here
- The API runtime gate lives in the consumers. This package supplies data (
statusOf,isDenied,inherit, entries); the API decides what to serve, and how to verify that a pool or vault really comes from an approved factory. - No RPC in consumers. The package never calls a chain. On-chain checks (bytecode, roles) run only in this repo's CI and nightly job.
- App addresses change only with a release. A frontend bundle contains the addresses it was built with. A status change in the registry reaches an app through its next release, where the build scan checks it against the latest registry; it does not change a bundle that is already deployed. Likewise the API only sees registry changes when it is rebuilt with a newer package.
- Addresses assembled at runtime (concatenated strings, fetched from elsewhere) cannot be found by the scan. Tokens come from
token-list. - The runtime datasets are chain-aware too.
/prod(and the full dataset) is emitted as a tagged block of(chain, key, address)entries, so a prod build that usesaddress(1, 'core.access-manager')is judged on chain 1 only, even if the same address is a different, unapproved contract on another chain.
Slack announcements
After each successful publish, the workflow posts the registry changes to Slack (#nt-deployment-addresses-announcements), most important first:
- ✅ Approved for prod: entries whose status changed to
safe(or that were added assafe), with the entry'sapprovalreason (string, orapproval.basis) when it has one. - ⛔ Blocked: status changes to
blocked, additions that areblocked(these are not repeated under the additions), and denylist additions and removals (denylist + 0x…/denylist - 0x…, with the reason). An entry that moved to the denylist is reported here, not as a removal. - New addresses grouped by chain and domain.
- Address changes, other status changes, and removals.
Changes to tags, notes, source or audit only are not announced, and nothing is posted when nothing relevant changed. Large changes are summarised as one line per chain and domain (ETHEREUM · Legacy 29) plus a link to the full list: more than 50 additions, or more than 20 removals. Messages stay under about 3500 characters and are split between blocks when needed.
Setup: a Slack admin creates an incoming webhook for the channel, and it is stored as the repository secret SLACK_ADDRESSES_WEBHOOK_URL. If the secret is missing or Slack is down, the step prints a ::warning:: and the publish still succeeds.
Preview locally, without posting anything:
npx tsx scripts/announce.ts --from HEAD~1 --to HEAD --dry-runDevelopment
npm ci
npm run generate # registry/*.json -> src/generated/{registry,keys,keys-prod}.ts (commit the result)
npm run validate # static checks on registry/*.json
npm run validate -- --base origin/main # also compare with the base branch (CI does this on PRs)
npm run typecheck
npm test # generates, builds, then runs vitest
npm run build # dist/index, dist/prod, dist/keys, dist/prod-keys, dist/cli.js, dist/registry.json
npm run onchain [-- --report out.json]npm run onchain uses RPC_URL_<chainId> when set, otherwise viem's default public RPC for the chain. Stellar entries are skipped for now ("not supported yet"). ONCHAIN_CONCURRENCY (default 4) limits parallel RPC work.
Merging to main publishes a new version to npm (trusted publishing). The version is <major>.<minor> from package.json plus the workflow run number, and is never committed back (main is protected); bump major or minor in a PR.
Branch protection to apply on main
Apply this in GitHub (Settings -> Rules / Branches) on perspectivefi/spectra-addresses:
- Require a pull request before merging.
- Required approvals: 2.
- Dismiss stale approvals when new commits are pushed.
- Require status checks to pass:
validate(the job in.github/workflows/validate.yml); require branches to be up to date. - No bypass for anyone, including admins: enable "Do not allow bypassing the above settings" / leave the bypass list empty, and turn on "Include administrators" if using classic branch protection.
- Block force pushes and branch deletion.
