From c6107fa5a3a4e2a75443ddaf2769f41d7888226f Mon Sep 17 00:00:00 2001 From: chrisli30 Date: Sat, 6 Jun 2026 17:50:57 -0700 Subject: [PATCH 1/4] feat: per-chain token subpath exports + ERC-20/Chainlink event additions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `@avaprotocol/protocols/tokens/` subpath exports so consumers that only need one chain's tokens can import a single ~1-30 KB module instead of pulling all five chains via the root `Tokens` namespace (currently ~71 KB). Also declares `sideEffects: false` so bundlers can tree-shake unused protocol modules. Surface added: - `import { tokens, chainId } from "@avaprotocol/protocols/tokens/sepolia"` Available for ethereum, sepolia, base, base-sepolia, bnb-mainnet. Each module exports `chainId` (number) and a frozen, symbol-keyed `tokens: Record`. - `Protocols.erc20.transferAbi` / `symbolAbi` / `decimalsAbi` / `transferEventAbi` / `eventTopics.Transfer`. Lets downstream SDK tests and templates drop hand-rolled ABI fragments and topic hashes. - `Protocols.chainlink.answerUpdatedEventAbi` / `eventTopics.AnswerUpdated`. Same motivation — the AnswerUpdated event was being re-derived in ava-sdk-js's eventTrigger test. Implementation notes: - tsup migrated from CLI args to `tsup.config.ts` so the multi-entry build (6 entries) stays single-sourced and reviewable. - `tsc -p tsconfig.build.json` already emits per-file `.d.ts`, so every per-chain subpath gets typed. - Root `import { Tokens, lookupToken } from "@avaprotocol/protocols"` works unchanged — additive change, no consumer disruption. Bundle size per `dist/tokens/*.js` (ESM): sepolia 1.64 KB base-sepolia 1.42 KB bnb-mainnet 1.35 KB base 12.91 KB ethereum 26.71 KB vs. root index 71.24 KB Tests: 28 → 57 (added 26-test per-chain suite + 4 catalog assertions covering the new ABI/topic fields). All green. --- ...-subpath-exports-erc20-chainlink-events.md | 33 ++++++ package.json | 28 ++++- src/protocols/chainlink.ts | 30 +++++- src/protocols/erc20.ts | 101 +++++++++++++++--- src/tokens/base-sepolia.ts | 23 ++++ src/tokens/base.ts | 23 ++++ src/tokens/bnb-mainnet.ts | 23 ++++ src/tokens/ethereum.ts | 23 ++++ src/tokens/per-chain.ts | 42 ++++++++ src/tokens/sepolia.ts | 23 ++++ tests/catalog.test.ts | 26 +++++ tests/per-chain.test.ts | 72 +++++++++++++ tsup.config.ts | 27 +++++ 13 files changed, 456 insertions(+), 18 deletions(-) create mode 100644 .changeset/v0_6_0-subpath-exports-erc20-chainlink-events.md create mode 100644 src/tokens/base-sepolia.ts create mode 100644 src/tokens/base.ts create mode 100644 src/tokens/bnb-mainnet.ts create mode 100644 src/tokens/ethereum.ts create mode 100644 src/tokens/per-chain.ts create mode 100644 src/tokens/sepolia.ts create mode 100644 tests/per-chain.test.ts create mode 100644 tsup.config.ts diff --git a/.changeset/v0_6_0-subpath-exports-erc20-chainlink-events.md b/.changeset/v0_6_0-subpath-exports-erc20-chainlink-events.md new file mode 100644 index 0000000..def3243 --- /dev/null +++ b/.changeset/v0_6_0-subpath-exports-erc20-chainlink-events.md @@ -0,0 +1,33 @@ +--- +"@avaprotocol/protocols": minor +--- + +Per-chain token subpath exports + new ERC-20 / Chainlink ABI fragments and event topics. + +**Subpath exports.** Each chain's token catalog is now importable on its own — consumers that only need one chain's tokens get just that file in their bundle instead of all five chains' worth via the root `Tokens` namespace: + +```ts +import { tokens, chainId } from "@avaprotocol/protocols/tokens/sepolia"; +const usdc = tokens.USDC?.address; +``` + +Available subpaths: `./tokens/ethereum`, `./tokens/sepolia`, `./tokens/base`, `./tokens/base-sepolia`, `./tokens/bnb-mainnet`. Each module exports `chainId` and a frozen, symbol-keyed `tokens` map of the existing `TokenChainEntry` shape. + +The root `import { Tokens } from "@avaprotocol/protocols"` keeps working unchanged — the per-chain modules are additive. + +**`sideEffects: false`** declared in `package.json` so bundlers can tree-shake unused protocol modules. + +**ERC-20 additions** (`Protocols.erc20`): + +- `transferAbi` — single-fragment ABI for `transfer(address,uint256)` +- `symbolAbi` — single-fragment ABI for `symbol()` +- `decimalsAbi` — single-fragment ABI for `decimals()` +- `transferEventAbi` — single-fragment event ABI for `Transfer(address indexed, address indexed, uint256)` +- `eventTopics.Transfer` — pre-computed keccak256 of the canonical Transfer event signature + +**Chainlink additions** (`Protocols.chainlink`): + +- `answerUpdatedEventAbi` — single-fragment event ABI for `AnswerUpdated(int256 indexed, uint256 indexed, uint256)` +- `eventTopics.AnswerUpdated` — pre-computed keccak256 of the canonical AnswerUpdated event signature + +These let downstream consumers (templates, SDK tests, summarizers) drop hand-rolled ABI fragments and topic hashes that were re-derived across repos. diff --git a/package.json b/package.json index 33a7ccf..9c2776a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "license": "MIT", "author": "Ava Protocol", "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -36,6 +37,31 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, + "./tokens/ethereum": { + "types": "./dist/tokens/ethereum.d.ts", + "import": "./dist/tokens/ethereum.js", + "require": "./dist/tokens/ethereum.cjs" + }, + "./tokens/sepolia": { + "types": "./dist/tokens/sepolia.d.ts", + "import": "./dist/tokens/sepolia.js", + "require": "./dist/tokens/sepolia.cjs" + }, + "./tokens/base": { + "types": "./dist/tokens/base.d.ts", + "import": "./dist/tokens/base.js", + "require": "./dist/tokens/base.cjs" + }, + "./tokens/base-sepolia": { + "types": "./dist/tokens/base-sepolia.d.ts", + "import": "./dist/tokens/base-sepolia.js", + "require": "./dist/tokens/base-sepolia.cjs" + }, + "./tokens/bnb-mainnet": { + "types": "./dist/tokens/bnb-mainnet.d.ts", + "import": "./dist/tokens/bnb-mainnet.js", + "require": "./dist/tokens/bnb-mainnet.cjs" + }, "./package.json": "./package.json" }, "files": [ @@ -49,7 +75,7 @@ }, "scripts": { "build:declarations": "tsc -p tsconfig.build.json", - "build:js": "tsup src/index.ts --format cjs,esm --target es2020", + "build:js": "tsup", "build:tokens-sidecar": "tsx scripts/build-tokens-sidecar.ts", "build": "yarn clean:dist && yarn build:declarations && yarn build:js && yarn build:tokens-sidecar", "port:studio": "tsx scripts/port-from-studio.ts", diff --git a/src/protocols/chainlink.ts b/src/protocols/chainlink.ts index a73d08d..24ec9ad 100644 --- a/src/protocols/chainlink.ts +++ b/src/protocols/chainlink.ts @@ -11,7 +11,7 @@ import { Chains } from "../chains"; import { aggregatorV3Abi } from "./common"; -import { type AddressByChain } from "./types"; +import { type AbiFragment, type AddressByChain } from "./types"; const ethUsdFeed: AddressByChain = { [Chains.EthereumMainnet]: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419", @@ -33,10 +33,38 @@ const bnbUsdFeed: AddressByChain = { [Chains.BnbMainnet]: "0x0567F2323251f0Aab15c8dFb1967E4e8A7D42aeE", }; +/** + * Aggregator's `AnswerUpdated` event — fires whenever a feed posts a + * new round. Useful as an `eventTrigger` topic for price-watch + * templates that don't want to poll `latestRoundData` on a schedule. + */ +const answerUpdatedEventAbi: readonly AbiFragment[] = Object.freeze([ + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "int256", name: "current", type: "int256" }, + { indexed: true, internalType: "uint256", name: "roundId", type: "uint256" }, + { indexed: false, internalType: "uint256", name: "updatedAt", type: "uint256" }, + ], + name: "AnswerUpdated", + type: "event", + }, +]); + +/** + * Pre-computed keccak256 of canonical Chainlink event signatures. Match + * `topics[0]` on `eventTrigger` queries against these. + */ +const eventTopics = Object.freeze({ + AnswerUpdated: "0x0559884fd3a460db3073b7fc896cc77986f16e378210ded43186175bf646fc5f", +} as const); + export const chainlink = Object.freeze({ ethUsdFeed, btcUsdFeed, bnbUsdFeed, /** Shared AggregatorV3 ABI — works for any Chainlink feed. */ aggregatorV3Abi, + answerUpdatedEventAbi, + eventTopics, }); diff --git a/src/protocols/erc20.ts b/src/protocols/erc20.ts index 152e71c..bd31eb1 100644 --- a/src/protocols/erc20.ts +++ b/src/protocols/erc20.ts @@ -1,21 +1,90 @@ -// Minimal ERC-20 ABI fragments that templates routinely need but don't -// belong to any one protocol. `approveAbi` is the standard 2-arg ERC-20 -// approve — used by any DeFi flow that funnels tokens through a protocol's -// pool/router (AAVE.supply, Uniswap.swap, etc.). +// Minimal ERC-20 ABI fragments and event topic hashes that templates +// routinely need but don't belong to any one protocol. Tests across +// the SDK and aggregator all re-derive these — the catalog centralizes +// them so consumers can drop the inline copies. +// +// Covers: approve / transfer / symbol / decimals function fragments, +// the Transfer(address,address,uint256) event ABI + topic, and a +// pre-computed keccak256 for topics[0] filtering. import { type AbiFragment } from "./types"; +const approveAbi: readonly AbiFragment[] = Object.freeze([ + { + inputs: [ + { internalType: "address", name: "spender", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + ], + name: "approve", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function", + }, +]); + +const transferAbi: readonly AbiFragment[] = Object.freeze([ + { + inputs: [ + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + ], + name: "transfer", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function", + }, +]); + +const symbolAbi: readonly AbiFragment[] = Object.freeze([ + { + inputs: [], + name: "symbol", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, +]); + +const decimalsAbi: readonly AbiFragment[] = Object.freeze([ + { + inputs: [], + name: "decimals", + outputs: [{ internalType: "uint8", name: "", type: "uint8" }], + stateMutability: "view", + type: "function", + }, +]); + +/** + * Standard ERC-20 Transfer event ABI — used by `eventTrigger` queries + * to decode the indexed `from` / `to` and the value payload. + */ +const transferEventAbi: readonly AbiFragment[] = Object.freeze([ + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "from", type: "address" }, + { indexed: true, internalType: "address", name: "to", type: "address" }, + { indexed: false, internalType: "uint256", name: "value", type: "uint256" }, + ], + name: "Transfer", + type: "event", + }, +]); + +/** + * Pre-computed keccak256 of canonical ERC-20 event signatures. Match + * `topics[0]` on `eventTrigger` queries against these. + */ +const eventTopics = Object.freeze({ + Transfer: "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", +} as const); + export const erc20 = Object.freeze({ - approveAbi: Object.freeze([ - { - inputs: [ - { internalType: "address", name: "spender", type: "address" }, - { internalType: "uint256", name: "amount", type: "uint256" }, - ], - name: "approve", - outputs: [{ internalType: "bool", name: "", type: "bool" }], - stateMutability: "nonpayable", - type: "function", - }, - ]) as readonly AbiFragment[], + approveAbi, + transferAbi, + symbolAbi, + decimalsAbi, + transferEventAbi, + eventTopics, }); diff --git a/src/tokens/base-sepolia.ts b/src/tokens/base-sepolia.ts new file mode 100644 index 0000000..3e3d87f --- /dev/null +++ b/src/tokens/base-sepolia.ts @@ -0,0 +1,23 @@ +// Per-chain token catalog — Base Sepolia testnet. +// +// Available via the subpath export: +// import { tokens, chainId } from "@avaprotocol/protocols/tokens/base-sepolia"; +// const usdc = tokens.USDC?.address; +// +// Consumers that only need one chain's tokens get this single file in +// their bundle instead of all five chains' worth of data via the root +// `Tokens` namespace. + +import data from "./data/base-sepolia.json" with { type: "json" }; +import { Chains } from "../chains"; +import { buildChainTokenMap, type TokenDataRow } from "./per-chain"; +import type { TokenChainEntry } from "./types"; + +export const chainId = Chains.BaseSepolia; + +export const tokens: Readonly> = buildChainTokenMap( + data as ReadonlyArray, + "src/tokens/data/base-sepolia.json", +); + +export default tokens; diff --git a/src/tokens/base.ts b/src/tokens/base.ts new file mode 100644 index 0000000..3ac1c61 --- /dev/null +++ b/src/tokens/base.ts @@ -0,0 +1,23 @@ +// Per-chain token catalog — Base mainnet. +// +// Available via the subpath export: +// import { tokens, chainId } from "@avaprotocol/protocols/tokens/base"; +// const usdc = tokens.USDC?.address; +// +// Consumers that only need one chain's tokens get this single file in +// their bundle instead of all five chains' worth of data via the root +// `Tokens` namespace. + +import data from "./data/base.json" with { type: "json" }; +import { Chains } from "../chains"; +import { buildChainTokenMap, type TokenDataRow } from "./per-chain"; +import type { TokenChainEntry } from "./types"; + +export const chainId = Chains.BaseMainnet; + +export const tokens: Readonly> = buildChainTokenMap( + data as ReadonlyArray, + "src/tokens/data/base.json", +); + +export default tokens; diff --git a/src/tokens/bnb-mainnet.ts b/src/tokens/bnb-mainnet.ts new file mode 100644 index 0000000..b57b73f --- /dev/null +++ b/src/tokens/bnb-mainnet.ts @@ -0,0 +1,23 @@ +// Per-chain token catalog — BNB Smart Chain mainnet. +// +// Available via the subpath export: +// import { tokens, chainId } from "@avaprotocol/protocols/tokens/bnb-mainnet"; +// const usdc = tokens.USDC?.address; +// +// Consumers that only need one chain's tokens get this single file in +// their bundle instead of all five chains' worth of data via the root +// `Tokens` namespace. + +import data from "./data/bnb-mainnet.json" with { type: "json" }; +import { Chains } from "../chains"; +import { buildChainTokenMap, type TokenDataRow } from "./per-chain"; +import type { TokenChainEntry } from "./types"; + +export const chainId = Chains.BnbMainnet; + +export const tokens: Readonly> = buildChainTokenMap( + data as ReadonlyArray, + "src/tokens/data/bnb-mainnet.json", +); + +export default tokens; diff --git a/src/tokens/ethereum.ts b/src/tokens/ethereum.ts new file mode 100644 index 0000000..980df9a --- /dev/null +++ b/src/tokens/ethereum.ts @@ -0,0 +1,23 @@ +// Per-chain token catalog — Ethereum mainnet. +// +// Available via the subpath export: +// import { tokens, chainId } from "@avaprotocol/protocols/tokens/ethereum"; +// const usdc = tokens.USDC?.address; +// +// Consumers that only need one chain's tokens get this single file in +// their bundle instead of all five chains' worth of data via the root +// `Tokens` namespace. + +import data from "./data/ethereum.json" with { type: "json" }; +import { Chains } from "../chains"; +import { buildChainTokenMap, type TokenDataRow } from "./per-chain"; +import type { TokenChainEntry } from "./types"; + +export const chainId = Chains.EthereumMainnet; + +export const tokens: Readonly> = buildChainTokenMap( + data as ReadonlyArray, + "src/tokens/data/ethereum.json", +); + +export default tokens; diff --git a/src/tokens/per-chain.ts b/src/tokens/per-chain.ts new file mode 100644 index 0000000..279c550 --- /dev/null +++ b/src/tokens/per-chain.ts @@ -0,0 +1,42 @@ +// Shared helper for the per-chain token modules. Each +// `src/tokens/.ts` is a thin wrapper that reads its sibling +// JSON in `data/` and reshapes it into a symbol-keyed map — every +// chain does the exact same transform, so the loop and validation +// live here. + +import type { TokenChainEntry } from "./types"; + +/** + * Source-data row shape. Each per-chain JSON file is an array of + * these — `symbol` carries the lookup key; the rest is the per-chain + * entry surfaced to consumers. + */ +export interface TokenDataRow extends TokenChainEntry { + readonly symbol: string; +} + +/** + * Build the symbol → TokenChainEntry map for a single chain's data + * file. Throws on duplicate symbols so data-entry mistakes fail at + * module load rather than silently overwriting. + * + * `sourceLabel` shows up in the duplicate-symbol error so callers can + * point at the exact JSON file that needs cleaning up. + */ +export function buildChainTokenMap( + rows: ReadonlyArray, + sourceLabel: string, +): Readonly> { + const out: Record = {}; + for (const row of rows) { + const { symbol, ...entry } = row; + if (out[symbol]) { + throw new Error( + `[@avaprotocol/protocols] duplicate symbol "${symbol}" in ${sourceLabel} — ` + + `each symbol must appear once per chain file.`, + ); + } + out[symbol] = Object.freeze(entry); + } + return Object.freeze(out); +} diff --git a/src/tokens/sepolia.ts b/src/tokens/sepolia.ts new file mode 100644 index 0000000..bde4e14 --- /dev/null +++ b/src/tokens/sepolia.ts @@ -0,0 +1,23 @@ +// Per-chain token catalog — Sepolia testnet. +// +// Available via the subpath export: +// import { tokens, chainId } from "@avaprotocol/protocols/tokens/sepolia"; +// const usdc = tokens.USDC?.address; +// +// Consumers that only need one chain's tokens get this single file in +// their bundle instead of all five chains' worth of data via the root +// `Tokens` namespace. + +import data from "./data/sepolia.json" with { type: "json" }; +import { Chains } from "../chains"; +import { buildChainTokenMap, type TokenDataRow } from "./per-chain"; +import type { TokenChainEntry } from "./types"; + +export const chainId = Chains.Sepolia; + +export const tokens: Readonly> = buildChainTokenMap( + data as ReadonlyArray, + "src/tokens/data/sepolia.json", +); + +export default tokens; diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index 480185a..3aedd46 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -165,6 +165,32 @@ describe("Shared ABIs", () => { expect(Protocols.erc20.approveAbi).toHaveLength(1); expect(Protocols.erc20.approveAbi[0].name).toBe("approve"); }); + + it("ERC-20 ships transfer/symbol/decimals function fragments", () => { + expect(Protocols.erc20.transferAbi[0].name).toBe("transfer"); + expect(Protocols.erc20.symbolAbi[0].name).toBe("symbol"); + expect(Protocols.erc20.decimalsAbi[0].name).toBe("decimals"); + }); + + it("ERC-20 Transfer event ABI + topic[0] hash are in lockstep", () => { + const transferEvent = Protocols.erc20.transferEventAbi.find((f) => f.name === "Transfer"); + expect(transferEvent?.type).toBe("event"); + expect(Protocols.erc20.eventTopics.Transfer).toBe( + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + ); + expect(Protocols.erc20.eventTopics.Transfer).toMatch(TOPIC_RE); + }); + + it("Chainlink AnswerUpdated event ABI + topic[0] hash are in lockstep", () => { + const answerUpdated = Protocols.chainlink.answerUpdatedEventAbi.find( + (f) => f.name === "AnswerUpdated", + ); + expect(answerUpdated?.type).toBe("event"); + expect(Protocols.chainlink.eventTopics.AnswerUpdated).toBe( + "0x0559884fd3a460db3073b7fc896cc77986f16e378210ded43186175bf646fc5f", + ); + expect(Protocols.chainlink.eventTopics.AnswerUpdated).toMatch(TOPIC_RE); + }); }); describe("Chain coverage", () => { diff --git a/tests/per-chain.test.ts b/tests/per-chain.test.ts new file mode 100644 index 0000000..3cf842f --- /dev/null +++ b/tests/per-chain.test.ts @@ -0,0 +1,72 @@ +/** + * Per-chain token subpath modules. + * + * Covers the surface added by 0.6.0: `@avaprotocol/protocols/tokens/` + * shipping symbol-keyed maps so consumers can import a single chain + * without pulling all five via the root `Tokens` namespace. + */ +import { describe, it, expect } from "vitest"; +import { Chains, Tokens } from "../src"; +import * as Ethereum from "../src/tokens/ethereum"; +import * as Sepolia from "../src/tokens/sepolia"; +import * as Base from "../src/tokens/base"; +import * as BaseSepolia from "../src/tokens/base-sepolia"; +import * as BnbMainnet from "../src/tokens/bnb-mainnet"; + +const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; + +const modules = [ + { name: "ethereum", mod: Ethereum, expectedChainId: Chains.EthereumMainnet }, + { name: "sepolia", mod: Sepolia, expectedChainId: Chains.Sepolia }, + { name: "base", mod: Base, expectedChainId: Chains.BaseMainnet }, + { name: "base-sepolia", mod: BaseSepolia, expectedChainId: Chains.BaseSepolia }, + { name: "bnb-mainnet", mod: BnbMainnet, expectedChainId: Chains.BnbMainnet }, +]; + +describe("Per-chain token subpath modules", () => { + for (const { name, mod, expectedChainId } of modules) { + describe(name, () => { + it("exports chainId matching the chain", () => { + expect(mod.chainId).toBe(expectedChainId); + }); + + it("exports a non-empty tokens map", () => { + expect(Object.keys(mod.tokens).length).toBeGreaterThan(0); + }); + + it("every entry has a valid 0x address and non-negative decimals", () => { + for (const [symbol, entry] of Object.entries(mod.tokens)) { + expect(entry.address, `${name}/${symbol} bad address`).toMatch(ADDRESS_RE); + expect(entry.decimals, `${name}/${symbol} bad decimals`).toBeGreaterThanOrEqual(0); + } + }); + + it("agrees with the root `Tokens` namespace for every entry", () => { + // Source-of-truth check: the per-chain module and the + // aggregated namespace must surface the same address + + // decimals for the same (symbol, chain) — they read from the + // same JSON but go through different code paths, so a + // regression in either would diverge here. + for (const [symbol, entry] of Object.entries(mod.tokens)) { + const fromRoot = Tokens[symbol]?.[expectedChainId as keyof typeof Tokens[typeof symbol]]; + expect(fromRoot, `Tokens.${symbol}[${expectedChainId}] missing`).toBeDefined(); + expect(fromRoot?.address).toBe(entry.address); + expect(fromRoot?.decimals).toBe(entry.decimals); + } + }); + + it("tokens map is frozen so consumers can't mutate it", () => { + expect(Object.isFrozen(mod.tokens)).toBe(true); + }); + }); + } + + it("ethereum has the largest catalog (sanity)", () => { + // Largest in the seed today; if this trips, either Ethereum + // shrunk (unlikely) or another chain grew past it (update the + // sanity guard). + const sizes = modules.map(({ mod }) => Object.keys(mod.tokens).length); + const ethereumSize = Object.keys(Ethereum.tokens).length; + expect(ethereumSize).toBe(Math.max(...sizes)); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..b5f599c --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "tsup"; + +// Multi-entry build so per-chain token modules ship as their own +// bundle artifacts and can be reached via the package's subpath +// exports (`@avaprotocol/protocols/tokens/sepolia`, etc.). Consumers +// that only want one chain's tokens get just that file in their +// bundle instead of all five chains' worth via the root `Tokens` +// namespace. +// +// Type declarations are emitted separately by `tsc -p +// tsconfig.build.json` (`build:declarations`) so `dts: false` here. +export default defineConfig({ + entry: [ + "src/index.ts", + "src/tokens/ethereum.ts", + "src/tokens/sepolia.ts", + "src/tokens/base.ts", + "src/tokens/base-sepolia.ts", + "src/tokens/bnb-mainnet.ts", + ], + format: ["cjs", "esm"], + target: "es2020", + dts: false, + splitting: false, + clean: false, + sourcemap: false, +}); From 96c2c7f4ea2eb3f9198652907e21fcf7fb560cde Mon Sep 17 00:00:00 2001 From: chrisli30 Date: Sat, 6 Jun 2026 18:06:21 -0700 Subject: [PATCH 2/4] fix: harden token map builders against prototype-key collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review of #12 flagged `buildChainTokenMap` for using plain `{}` + truthy/`in` checks to detect duplicate symbols — a curated catalog shouldn't contain `__proto__`/`toString` symbols, but the defense is free and the data is parsed externally. - `src/tokens/per-chain.ts`: backing map is `Object.create(null)`; duplicate detection uses `Object.prototype.hasOwnProperty.call`. - `src/tokens/index.ts`: same hardening applied to the pre-existing `buildTokensFromData` aggregator — same construct, same risk. Tests unchanged (57 pass). --- pr-comments-12.json | 37 +++++++++++++++++++++++++++++++++++++ src/tokens/index.ts | 12 ++++++++++-- src/tokens/per-chain.ts | 15 +++++++++++++-- 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 pr-comments-12.json diff --git a/pr-comments-12.json b/pr-comments-12.json new file mode 100644 index 0000000..6e662f4 --- /dev/null +++ b/pr-comments-12.json @@ -0,0 +1,37 @@ +{ + "pr_number": "12", + "repository": "AvaProtocol/protocols", + "summary": { + "copilot": { + "review_comments_total": 1, + "review_comments_unresolved": 1, + "review_comments_resolved": 0, + "issue_pr_comments": 0 + }, + "claude": { + "review_comments_total": 0, + "review_comments_unresolved": 0, + "review_comments_resolved": 0, + "issue_pr_comments": 0 + } + }, + "copilot": { + "review_comments": [ + { + "resolved": false, + "id": "PRRC_kwDOSrtaY87Ix9Ok", + "path": "src/tokens/per-chain.ts", + "line": 40, + "diff_hunk": "@@ -0,0 +1,42 @@\n+// Shared helper for the per-chain token modules. Each\n+// `src/tokens/.ts` is a thin wrapper that reads its sibling\n+// JSON in `data/` and reshapes it into a symbol-keyed map — every\n+// chain does the exact same transform, so the loop and validation\n+// live here.\n+\n+import type { TokenChainEntry } from \"./types\";\n+\n+/**\n+ * Source-data row shape. Each per-chain JSON file is an array of\n+ * these — `symbol` carries the lookup key; the rest is the per-chain\n+ * entry surfaced to consumers.\n+ */\n+export interface TokenDataRow extends TokenChainEntry {\n+ readonly symbol: string;\n+}\n+\n+/**\n+ * Build the symbol → TokenChainEntry map for a single chain's data\n+ * file. Throws on duplicate symbols so data-entry mistakes fail at\n+ * module load rather than silently overwriting.\n+ *\n+ * `sourceLabel` shows up in the duplicate-symbol error so callers can\n+ * point at the exact JSON file that needs cleaning up.\n+ */\n+export function buildChainTokenMap(\n+ rows: ReadonlyArray,\n+ sourceLabel: string,\n+): Readonly> {\n+ const out: Record = {};\n+ for (const row of rows) {\n+ const { symbol, ...entry } = row;\n+ if (out[symbol]) {\n+ throw new Error(\n+ `[@avaprotocol/protocols] duplicate symbol \"${symbol}\" in ${sourceLabel} — ` +\n+ `each symbol must appear once per chain file.`,\n+ );\n+ }\n+ out[symbol] = Object.freeze(entry);\n+ }", + "body": "`buildChainTokenMap()` uses a plain object (`{}`) plus `if (out[symbol])` to detect duplicates. This can (a) mis-detect duplicates for symbols that collide with inherited keys like `toString`, and (b) allow prototype mutation if a symbol like `__proto__` ever appears in the JSON, because assigning `out[\"__proto__\"]` on a normal object changes the prototype.\n\nUsing a null-prototype map plus an own-property check avoids both issues without changing the public API.", + "created_at": "2026-06-07T01:04:13Z", + "user": "copilot-pull-request-reviewer" + } + ], + "issue_comments": [] + }, + "claude": { + "review_comments": [], + "issue_comments": [] + } +} diff --git a/src/tokens/index.ts b/src/tokens/index.ts index f202a06..a1478dc 100644 --- a/src/tokens/index.ts +++ b/src/tokens/index.ts @@ -71,11 +71,19 @@ const CHAIN_DATA: ReadonlyArray]> * one-off process startup error pointing straight at the duplicate. */ function buildTokensFromData(): Record { - const merged: Record> = {}; + // Null-prototype backing maps + own-property checks keep + // `Object.prototype` keys (`toString`, `__proto__`, …) from + // colliding with token symbols. Curated source files shouldn't + // contain such symbols, but the defense is free and the data is + // parsed externally. See the parallel construct in + // `./per-chain.ts:buildChainTokenMap` for the same rationale. + const merged: Record> = Object.create(null); for (const [chainId, rows] of CHAIN_DATA) { for (const row of rows) { const { symbol, ...entry } = row; - if (!merged[symbol]) merged[symbol] = {}; + if (!Object.prototype.hasOwnProperty.call(merged, symbol)) { + merged[symbol] = Object.create(null); + } if (merged[symbol][chainId] !== undefined) { throw new Error( `[@avaprotocol/protocols] duplicate token entry for symbol="${symbol}" ` + diff --git a/src/tokens/per-chain.ts b/src/tokens/per-chain.ts index 279c550..2c865dd 100644 --- a/src/tokens/per-chain.ts +++ b/src/tokens/per-chain.ts @@ -22,15 +22,26 @@ export interface TokenDataRow extends TokenChainEntry { * * `sourceLabel` shows up in the duplicate-symbol error so callers can * point at the exact JSON file that needs cleaning up. + * + * Implementation notes: + * - Backing map uses `Object.create(null)` so `Object.prototype` + * keys (`toString`, `hasOwnProperty`, etc.) can't be mistaken for + * existing entries when checking for duplicates. + * - Duplicate detection goes through `Object.prototype.hasOwnProperty.call` + * for the same reason — `in` would still match inherited keys + * even if our map happened to acquire a prototype. + * - Together these neutralize the `__proto__` prototype-pollution + * vector that a curated catalog file shouldn't have anyway, but + * the defense is free and the data is parsed externally. */ export function buildChainTokenMap( rows: ReadonlyArray, sourceLabel: string, ): Readonly> { - const out: Record = {}; + const out: Record = Object.create(null); for (const row of rows) { const { symbol, ...entry } = row; - if (out[symbol]) { + if (Object.prototype.hasOwnProperty.call(out, symbol)) { throw new Error( `[@avaprotocol/protocols] duplicate symbol "${symbol}" in ${sourceLabel} — ` + `each symbol must appear once per chain file.`, From 0c5f783e304daea723e6279c46d1f81aeb9954be Mon Sep 17 00:00:00 2001 From: chrisli30 Date: Sat, 6 Jun 2026 18:06:49 -0700 Subject: [PATCH 3/4] chore: drop accidentally-committed pr-comments-12.json + gitignore the pattern --- pr-comments-12.json | 37 ------------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 pr-comments-12.json diff --git a/pr-comments-12.json b/pr-comments-12.json deleted file mode 100644 index 6e662f4..0000000 --- a/pr-comments-12.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "pr_number": "12", - "repository": "AvaProtocol/protocols", - "summary": { - "copilot": { - "review_comments_total": 1, - "review_comments_unresolved": 1, - "review_comments_resolved": 0, - "issue_pr_comments": 0 - }, - "claude": { - "review_comments_total": 0, - "review_comments_unresolved": 0, - "review_comments_resolved": 0, - "issue_pr_comments": 0 - } - }, - "copilot": { - "review_comments": [ - { - "resolved": false, - "id": "PRRC_kwDOSrtaY87Ix9Ok", - "path": "src/tokens/per-chain.ts", - "line": 40, - "diff_hunk": "@@ -0,0 +1,42 @@\n+// Shared helper for the per-chain token modules. Each\n+// `src/tokens/.ts` is a thin wrapper that reads its sibling\n+// JSON in `data/` and reshapes it into a symbol-keyed map — every\n+// chain does the exact same transform, so the loop and validation\n+// live here.\n+\n+import type { TokenChainEntry } from \"./types\";\n+\n+/**\n+ * Source-data row shape. Each per-chain JSON file is an array of\n+ * these — `symbol` carries the lookup key; the rest is the per-chain\n+ * entry surfaced to consumers.\n+ */\n+export interface TokenDataRow extends TokenChainEntry {\n+ readonly symbol: string;\n+}\n+\n+/**\n+ * Build the symbol → TokenChainEntry map for a single chain's data\n+ * file. Throws on duplicate symbols so data-entry mistakes fail at\n+ * module load rather than silently overwriting.\n+ *\n+ * `sourceLabel` shows up in the duplicate-symbol error so callers can\n+ * point at the exact JSON file that needs cleaning up.\n+ */\n+export function buildChainTokenMap(\n+ rows: ReadonlyArray,\n+ sourceLabel: string,\n+): Readonly> {\n+ const out: Record = {};\n+ for (const row of rows) {\n+ const { symbol, ...entry } = row;\n+ if (out[symbol]) {\n+ throw new Error(\n+ `[@avaprotocol/protocols] duplicate symbol \"${symbol}\" in ${sourceLabel} — ` +\n+ `each symbol must appear once per chain file.`,\n+ );\n+ }\n+ out[symbol] = Object.freeze(entry);\n+ }", - "body": "`buildChainTokenMap()` uses a plain object (`{}`) plus `if (out[symbol])` to detect duplicates. This can (a) mis-detect duplicates for symbols that collide with inherited keys like `toString`, and (b) allow prototype mutation if a symbol like `__proto__` ever appears in the JSON, because assigning `out[\"__proto__\"]` on a normal object changes the prototype.\n\nUsing a null-prototype map plus an own-property check avoids both issues without changing the public API.", - "created_at": "2026-06-07T01:04:13Z", - "user": "copilot-pull-request-reviewer" - } - ], - "issue_comments": [] - }, - "claude": { - "review_comments": [], - "issue_comments": [] - } -} From aeed81be1398bd452e9106b2b7b2efb93ec0012e Mon Sep 17 00:00:00 2001 From: chrisli30 Date: Sat, 6 Jun 2026 18:07:01 -0700 Subject: [PATCH 4/4] chore: gitignore pr-comments-*.json review-script artifacts --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 996926c..ad36144 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ coverage/ # npm *.tgz .npmrc + +# Review-script artifacts (from ~/.claude/scripts/get-pr-comments.sh) +pr-comments-*.json