Skip to content
Merged
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
33 changes: 33 additions & 0 deletions .changeset/v0_6_0-subpath-exports-erc20-chainlink-events.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ coverage/
# npm
*.tgz
.npmrc

# Review-script artifacts (from ~/.claude/scripts/get-pr-comments.sh)
pr-comments-*.json
28 changes: 27 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": [
Expand All @@ -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",
Expand Down
30 changes: 29 additions & 1 deletion src/protocols/chainlink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
});
101 changes: 85 additions & 16 deletions src/protocols/erc20.ts
Original file line number Diff line number Diff line change
@@ -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,
});
23 changes: 23 additions & 0 deletions src/tokens/base-sepolia.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, TokenChainEntry>> = buildChainTokenMap(
data as ReadonlyArray<TokenDataRow>,
"src/tokens/data/base-sepolia.json",
);

export default tokens;
23 changes: 23 additions & 0 deletions src/tokens/base.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, TokenChainEntry>> = buildChainTokenMap(
data as ReadonlyArray<TokenDataRow>,
"src/tokens/data/base.json",
);

export default tokens;
23 changes: 23 additions & 0 deletions src/tokens/bnb-mainnet.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, TokenChainEntry>> = buildChainTokenMap(
data as ReadonlyArray<TokenDataRow>,
"src/tokens/data/bnb-mainnet.json",
);

export default tokens;
23 changes: 23 additions & 0 deletions src/tokens/ethereum.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, TokenChainEntry>> = buildChainTokenMap(
data as ReadonlyArray<TokenDataRow>,
"src/tokens/data/ethereum.json",
);

export default tokens;
12 changes: 10 additions & 2 deletions src/tokens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,19 @@ const CHAIN_DATA: ReadonlyArray<readonly [number, ReadonlyArray<TokenDataRow>]>
* one-off process startup error pointing straight at the duplicate.
*/
function buildTokensFromData(): Record<string, TokenByChain> {
const merged: Record<string, Record<number, TokenChainEntry>> = {};
// 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<string, Record<number, TokenChainEntry>> = 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}" ` +
Expand Down
Loading
Loading