protoscan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ import { parseAbi } from 'viem';
2
+ import type { ProtocolDef } from './schema.js';
3
+
4
+ const factoryAbi = parseAbi([
5
+ 'function owner() view returns (address)',
6
+ 'function feeAmountTickSpacing(uint24 fee) view returns (int24)',
7
+ 'function getPool(address tokenA, address tokenB, uint24 fee) view returns (address pool)',
8
+ ]);
9
+
10
+ const swapRouterAbi = parseAbi([
11
+ 'function factory() view returns (address)',
12
+ 'function factoryV2() view returns (address)',
13
+ 'function positionManager() view returns (address)',
14
+ 'function WETH9() view returns (address)',
15
+ ]);
16
+
17
+ const positionManagerAbi = parseAbi([
18
+ 'function factory() view returns (address)',
19
+ 'function WETH9() view returns (address)',
20
+ ]);
21
+
22
+ const ownerAbi = parseAbi([
23
+ 'function owner() view returns (address)',
24
+ ]);
25
+
26
+ export const uniswapV3: ProtocolDef = {
27
+ id: 'uniswap-v3',
28
+ name: 'Uniswap V3',
29
+ version: '3.0',
30
+ website: 'https://uniswap.org',
31
+ description: 'Concentrated liquidity automated market maker protocol',
32
+
33
+ contracts: {
34
+ Factory: { name: 'UniswapV3Factory', role: 'Deploys pools, controls fee tiers and protocol fee switch — sole admin surface in the protocol', type: 'raw' },
35
+ SwapRouter: { name: 'SwapRouter02', role: 'Multicall swap router for exact-in/out swaps across V2 & V3 pools, immutable with no admin', type: 'raw' },
36
+ NonfungiblePositionManager: { name: 'NonfungiblePositionManager', role: 'ERC-721 wrapper for concentrated liquidity positions, immutable with no admin', type: 'raw' },
37
+ QuoterV2: { name: 'QuoterV2', role: 'Off-chain quote simulation via revert-based swap simulation, stateless', type: 'raw' },
38
+ },
39
+
40
+ deployments: {
41
+ ethereum: {
42
+ Factory: '0x1F98431c8aD98523631AE4a59f267346ea31F984',
43
+ SwapRouter: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
44
+ NonfungiblePositionManager: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88',
45
+ QuoterV2: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
46
+ },
47
+ arbitrum: {
48
+ Factory: '0x1F98431c8aD98523631AE4a59f267346ea31F984',
49
+ SwapRouter: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
50
+ NonfungiblePositionManager: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88',
51
+ QuoterV2: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
52
+ },
53
+ optimism: {
54
+ Factory: '0x1F98431c8aD98523631AE4a59f267346ea31F984',
55
+ SwapRouter: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
56
+ NonfungiblePositionManager: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88',
57
+ QuoterV2: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
58
+ },
59
+ polygon: {
60
+ Factory: '0x1F98431c8aD98523631AE4a59f267346ea31F984',
61
+ SwapRouter: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
62
+ NonfungiblePositionManager: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88',
63
+ QuoterV2: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
64
+ },
65
+ base: {
66
+ Factory: '0x33128a8fC17869897dcE68Ed026d694621f6FDfD',
67
+ SwapRouter: '0x2626664c2603336E57B271c5C0b26F421741e481',
68
+ NonfungiblePositionManager: '0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1',
69
+ QuoterV2: '0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a',
70
+ },
71
+ },
72
+
73
+ params: [
74
+ {
75
+ name: 'factoryOwner',
76
+ description: 'Factory owner — can enable fee tiers, transfer ownership, and set/collect protocol fee on any pool',
77
+ contract: 'Factory',
78
+ functionName: 'owner',
79
+ abi: factoryAbi,
80
+ decode: 'address',
81
+ category: 'access',
82
+ severity: 'critical',
83
+ },
84
+ {
85
+ name: 'feeTier100TickSpacing',
86
+ description: 'Tick spacing for the 0.01% fee tier (1 = enabled, stable-stable pairs)',
87
+ contract: 'Factory',
88
+ functionName: 'feeAmountTickSpacing',
89
+ args: [100],
90
+ abi: factoryAbi,
91
+ decode: 'uint',
92
+ category: 'config',
93
+ severity: 'info',
94
+ },
95
+ {
96
+ name: 'feeTier500TickSpacing',
97
+ description: 'Tick spacing for the 0.05% fee tier (10 = enabled, correlated pairs)',
98
+ contract: 'Factory',
99
+ functionName: 'feeAmountTickSpacing',
100
+ args: [500],
101
+ abi: factoryAbi,
102
+ decode: 'uint',
103
+ category: 'config',
104
+ severity: 'info',
105
+ },
106
+ {
107
+ name: 'feeTier3000TickSpacing',
108
+ description: 'Tick spacing for the 0.3% fee tier (60 = enabled, default general pairs)',
109
+ contract: 'Factory',
110
+ functionName: 'feeAmountTickSpacing',
111
+ args: [3000],
112
+ abi: factoryAbi,
113
+ decode: 'uint',
114
+ category: 'config',
115
+ severity: 'info',
116
+ },
117
+ {
118
+ name: 'feeTier10000TickSpacing',
119
+ description: 'Tick spacing for the 1% fee tier (200 = enabled, exotic pairs)',
120
+ contract: 'Factory',
121
+ functionName: 'feeAmountTickSpacing',
122
+ args: [10000],
123
+ abi: factoryAbi,
124
+ decode: 'uint',
125
+ category: 'config',
126
+ severity: 'info',
127
+ },
128
+ {
129
+ name: 'routerFactory',
130
+ description: 'Factory address the SwapRouter02 is wired to — must match canonical Factory or swaps route to a rogue deployment',
131
+ contract: 'SwapRouter',
132
+ functionName: 'factory',
133
+ abi: swapRouterAbi,
134
+ decode: 'address',
135
+ category: 'config',
136
+ severity: 'warning',
137
+ },
138
+ {
139
+ name: 'routerPositionManager',
140
+ description: 'NonfungiblePositionManager address referenced by SwapRouter02 (used for approve-and-call flows)',
141
+ contract: 'SwapRouter',
142
+ functionName: 'positionManager',
143
+ abi: swapRouterAbi,
144
+ decode: 'address',
145
+ category: 'config',
146
+ severity: 'info',
147
+ },
148
+ {
149
+ name: 'routerWETH9',
150
+ description: 'Wrapped native token used by SwapRouter02 for ETH-in/out swaps',
151
+ contract: 'SwapRouter',
152
+ functionName: 'WETH9',
153
+ abi: swapRouterAbi,
154
+ decode: 'address',
155
+ category: 'config',
156
+ severity: 'info',
157
+ },
158
+ {
159
+ name: 'positionManagerFactory',
160
+ description: 'Factory address the NonfungiblePositionManager is wired to — must match canonical Factory',
161
+ contract: 'NonfungiblePositionManager',
162
+ functionName: 'factory',
163
+ abi: positionManagerAbi,
164
+ decode: 'address',
165
+ category: 'config',
166
+ severity: 'warning',
167
+ },
168
+ {
169
+ name: 'positionManagerWETH9',
170
+ description: 'Wrapped native token used by the NonfungiblePositionManager for mint/increase/decrease liquidity in native ETH',
171
+ contract: 'NonfungiblePositionManager',
172
+ functionName: 'WETH9',
173
+ abi: positionManagerAbi,
174
+ decode: 'address',
175
+ category: 'config',
176
+ severity: 'info',
177
+ },
178
+ ],
179
+
180
+ access: [
181
+ {
182
+ role: 'Factory Owner — Fee Tier Governance',
183
+ description: 'Can permanently enable new fee tiers via enableFeeAmount (irreversible once set) and transfer factory ownership via setOwner',
184
+ contract: 'Factory',
185
+ functionName: 'owner',
186
+ abi: ownerAbi,
187
+ severity: 'critical',
188
+ },
189
+ {
190
+ role: 'Factory Owner — Protocol Fee Switch',
191
+ description: 'Can call the onlyFactoryOwner-gated setFeeProtocol and collectProtocol on ANY pool deployed by this factory — full economic control over the protocol-wide fee switch (up to 1/4 of LP swap fees) across every pool',
192
+ contract: 'Factory',
193
+ functionName: 'owner',
194
+ abi: ownerAbi,
195
+ severity: 'critical',
196
+ },
197
+ ],
198
+ };
package/src/scanner.ts ADDED
@@ -0,0 +1,181 @@
1
+ import { createPublicClient, http, type Address, type PublicClient } from 'viem';
2
+ import { resolveChain } from './chains.js';
3
+ import { detectProxy, readCommonState } from './protocols/index.js';
4
+ import { getProtocolForChain, type ProtocolDef, type ParamDef, type AccessDef } from './registry/index.js';
5
+ import type { ChainConfig, ContractEntry, ContractSnapshot } from './types.js';
6
+
7
+ export function createClient(chainName: string, config: ChainConfig): PublicClient {
8
+ const chain = resolveChain(chainName);
9
+ return createPublicClient({
10
+ chain,
11
+ transport: http(config.rpc),
12
+ batch: { multicall: true },
13
+ });
14
+ }
15
+
16
+ export async function scanContract(
17
+ client: PublicClient,
18
+ chainName: string,
19
+ name: string,
20
+ entry: ContractEntry,
21
+ ): Promise<ContractSnapshot> {
22
+ const address = entry.address as Address;
23
+
24
+ const [blockNumber, block, proxyInfo, publicReads] = await Promise.all([
25
+ client.getBlockNumber(),
26
+ client.getBlock({ blockTag: 'latest' }),
27
+ entry.type === 'proxy' ? detectProxy(client, address) : Promise.resolve(null),
28
+ readCommonState(client, address),
29
+ ]);
30
+
31
+ if (proxyInfo?.implementation) {
32
+ const implReads = await readCommonState(client, proxyInfo.implementation);
33
+ for (const [key, value] of Object.entries(implReads)) {
34
+ if (!(key in publicReads)) {
35
+ publicReads[`impl.${key}`] = value;
36
+ }
37
+ }
38
+ }
39
+
40
+ return {
41
+ address,
42
+ chain: chainName,
43
+ blockNumber,
44
+ timestamp: Number(block.timestamp),
45
+ proxyInfo,
46
+ storageSlots: [],
47
+ publicReads,
48
+ };
49
+ }
50
+
51
+ async function readParam(
52
+ client: PublicClient,
53
+ address: Address,
54
+ param: ParamDef,
55
+ ): Promise<{ name: string; value: string; description: string } | null> {
56
+ try {
57
+ const result = await client.readContract({
58
+ address,
59
+ abi: param.abi,
60
+ functionName: param.functionName,
61
+ args: param.args || [],
62
+ });
63
+
64
+ let value = String(result);
65
+
66
+ if (param.decode === 'bps' && typeof result === 'bigint') {
67
+ value = `${result} (${(Number(result) / 100).toFixed(2)}%)`;
68
+ } else if (param.decode === 'ray' && typeof result === 'bigint') {
69
+ value = `${(Number(result) / 1e27).toFixed(6)}`;
70
+ } else if (param.decode === 'wad' && typeof result === 'bigint') {
71
+ value = `${(Number(result) / 1e18).toFixed(6)}`;
72
+ } else if (param.decode === 'percent' && typeof result === 'bigint') {
73
+ value = `${Number(result)}%`;
74
+ }
75
+
76
+ return { name: param.name, value, description: param.description };
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ async function readAccess(
83
+ client: PublicClient,
84
+ address: Address,
85
+ access: AccessDef,
86
+ ): Promise<{ role: string; holder: string; description: string } | null> {
87
+ try {
88
+ const result = await client.readContract({
89
+ address,
90
+ abi: access.abi,
91
+ functionName: access.functionName,
92
+ });
93
+ return { role: access.role, holder: String(result), description: access.description };
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ export interface ProtocolScanResult {
100
+ protocolId: string;
101
+ protocolName: string;
102
+ chain: string;
103
+ blockNumber: bigint;
104
+ timestamp: number;
105
+ contracts: Record<string, {
106
+ address: string;
107
+ role: string;
108
+ proxyInfo: { implementation: string | null; admin: string | null } | null;
109
+ }>;
110
+ params: Record<string, { value: string; description: string; category: string; severity: string }>;
111
+ access: Array<{ role: string; holder: string; description: string; severity: string }>;
112
+ }
113
+
114
+ export async function scanProtocol(
115
+ client: PublicClient,
116
+ protocolId: string,
117
+ chainName: string,
118
+ ): Promise<ProtocolScanResult | null> {
119
+ const found = getProtocolForChain(protocolId, chainName);
120
+ if (!found) return null;
121
+
122
+ const { protocol, addresses } = found;
123
+
124
+ const [blockNumber, block] = await Promise.all([
125
+ client.getBlockNumber(),
126
+ client.getBlock({ blockTag: 'latest' }),
127
+ ]);
128
+
129
+ const contracts: ProtocolScanResult['contracts'] = {};
130
+ for (const [key, def] of Object.entries(protocol.contracts)) {
131
+ const addr = addresses[key];
132
+ if (!addr) continue;
133
+ const proxyInfo = def.type === 'proxy'
134
+ ? await detectProxy(client, addr as Address)
135
+ : null;
136
+ contracts[key] = {
137
+ address: addr,
138
+ role: def.role,
139
+ proxyInfo: proxyInfo ? {
140
+ implementation: proxyInfo.implementation,
141
+ admin: proxyInfo.admin,
142
+ } : null,
143
+ };
144
+ }
145
+
146
+ const params: ProtocolScanResult['params'] = {};
147
+ for (const paramDef of protocol.params) {
148
+ const contractAddr = addresses[paramDef.contract];
149
+ if (!contractAddr) continue;
150
+ const result = await readParam(client, contractAddr as Address, paramDef);
151
+ if (result) {
152
+ params[result.name] = {
153
+ value: result.value,
154
+ description: result.description,
155
+ category: paramDef.category,
156
+ severity: paramDef.severity,
157
+ };
158
+ }
159
+ }
160
+
161
+ const access: ProtocolScanResult['access'] = [];
162
+ for (const accessDef of protocol.access) {
163
+ const contractAddr = addresses[accessDef.contract];
164
+ if (!contractAddr) continue;
165
+ const result = await readAccess(client, contractAddr as Address, accessDef);
166
+ if (result) {
167
+ access.push({ ...result, severity: accessDef.severity });
168
+ }
169
+ }
170
+
171
+ return {
172
+ protocolId,
173
+ protocolName: protocol.name,
174
+ chain: chainName,
175
+ blockNumber,
176
+ timestamp: Number(block.timestamp),
177
+ contracts,
178
+ params,
179
+ access,
180
+ };
181
+ }
@@ -0,0 +1,70 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { Snapshot, ContractSnapshot } from './types.js';
4
+
5
+ const SNAPSHOT_DIR = '.protoscan';
6
+
7
+ function ensureDir(dir: string): void {
8
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
9
+ }
10
+
11
+ function snapshotPath(dir: string, chain: string, label: string): string {
12
+ return join(dir, `${chain}_${label}.json`);
13
+ }
14
+
15
+ function serializeSnapshot(snap: Snapshot): string {
16
+ return JSON.stringify(
17
+ snap,
18
+ (_, value) => (typeof value === 'bigint' ? value.toString() : value),
19
+ 2,
20
+ );
21
+ }
22
+
23
+ export function saveSnapshot(
24
+ contracts: Record<string, ContractSnapshot>,
25
+ chain: string,
26
+ label?: string,
27
+ ): string {
28
+ const dir = SNAPSHOT_DIR;
29
+ ensureDir(dir);
30
+
31
+ const now = new Date();
32
+ const autoLabel = label || now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
33
+
34
+ const firstContract = Object.values(contracts)[0];
35
+
36
+ const snapshot: Snapshot = {
37
+ version: '1',
38
+ createdAt: now.toISOString(),
39
+ chain,
40
+ blockNumber: firstContract ? firstContract.blockNumber.toString() : '0',
41
+ contracts: contracts as any,
42
+ };
43
+
44
+ const filePath = snapshotPath(dir, chain, autoLabel);
45
+ writeFileSync(filePath, serializeSnapshot(snapshot), 'utf-8');
46
+ return filePath;
47
+ }
48
+
49
+ export function loadSnapshot(filePath: string): Snapshot {
50
+ const raw = readFileSync(filePath, 'utf-8');
51
+ return JSON.parse(raw);
52
+ }
53
+
54
+ export function listSnapshots(chain?: string): string[] {
55
+ if (!existsSync(SNAPSHOT_DIR)) return [];
56
+ const files = readdirSync(SNAPSHOT_DIR).filter((f) => f.endsWith('.json'));
57
+ if (chain) return files.filter((f) => f.startsWith(`${chain}_`));
58
+ return files;
59
+ }
60
+
61
+ export function resolveSnapshotFile(nameOrPath: string, chain?: string): string {
62
+ if (existsSync(nameOrPath)) return nameOrPath;
63
+ const inDir = join(SNAPSHOT_DIR, nameOrPath);
64
+ if (existsSync(inDir)) return inDir;
65
+ if (chain) {
66
+ const withChain = join(SNAPSHOT_DIR, `${chain}_${nameOrPath}.json`);
67
+ if (existsSync(withChain)) return withChain;
68
+ }
69
+ throw new Error(`Snapshot not found: ${nameOrPath}`);
70
+ }
package/src/types.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { Address, Hex } from 'viem';
2
+
3
+ export interface ChainConfig {
4
+ rpc: string;
5
+ name?: string;
6
+ blockExplorer?: string;
7
+ }
8
+
9
+ export interface ContractEntry {
10
+ address: Address;
11
+ chains: string[];
12
+ type: 'proxy' | 'raw' | 'gnosis-safe';
13
+ abi?: string;
14
+ label?: string;
15
+ }
16
+
17
+ export interface ProtoscanConfig {
18
+ chains: Record<string, ChainConfig>;
19
+ contracts: Record<string, ContractEntry>;
20
+ snapshotDir?: string;
21
+ }
22
+
23
+ export interface SlotReading {
24
+ slot: string;
25
+ label: string;
26
+ value: Hex;
27
+ decoded?: string;
28
+ }
29
+
30
+ export interface ProxyInfo {
31
+ implementation: Address | null;
32
+ admin: Address | null;
33
+ beacon: Address | null;
34
+ }
35
+
36
+ export interface ContractSnapshot {
37
+ address: Address;
38
+ chain: string;
39
+ blockNumber: bigint;
40
+ timestamp: number;
41
+ proxyInfo: ProxyInfo | null;
42
+ storageSlots: SlotReading[];
43
+ publicReads: Record<string, string>;
44
+ }
45
+
46
+ export interface Snapshot {
47
+ version: string;
48
+ createdAt: string;
49
+ chain: string;
50
+ blockNumber: string;
51
+ contracts: Record<string, ContractSnapshot>;
52
+ }
53
+
54
+ export interface DiffEntry {
55
+ contract: string;
56
+ field: string;
57
+ before: string;
58
+ after: string;
59
+ severity: 'info' | 'warning' | 'critical';
60
+ }
61
+
62
+ export interface SnapshotDiff {
63
+ from: string;
64
+ to: string;
65
+ chain: string;
66
+ entries: DiffEntry[];
67
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "outDir": "dist",
9
+ "rootDir": "src",
10
+ "declaration": true,
11
+ "sourceMap": true,
12
+ "resolveJsonModule": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true
15
+ },
16
+ "include": ["src/**/*"]
17
+ }