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.
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2648 -0
- package/package.json +45 -0
- package/protoscan.config.json +20 -0
- package/src/chains.ts +38 -0
- package/src/config.ts +36 -0
- package/src/diff.ts +113 -0
- package/src/index.ts +289 -0
- package/src/protocols/common.ts +66 -0
- package/src/protocols/index.ts +2 -0
- package/src/protocols/proxy.ts +50 -0
- package/src/registry/aave-v3.ts +242 -0
- package/src/registry/compound-v3.ts +238 -0
- package/src/registry/eigenlayer.ts +190 -0
- package/src/registry/index.ts +38 -0
- package/src/registry/lido.ts +557 -0
- package/src/registry/maker.ts +176 -0
- package/src/registry/morpho-blue.ts +347 -0
- package/src/registry/pendle.ts +120 -0
- package/src/registry/schema.ts +40 -0
- package/src/registry/uniswap-v3.ts +198 -0
- package/src/scanner.ts +181 -0
- package/src/snapshot.ts +70 -0
- package/src/types.ts +67 -0
- package/tsconfig.json +17 -0
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "protoscan",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "On-chain protocol state tracker and diff tool for DeFi teams",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"protoscan": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
11
|
+
"dev": "tsx src/index.ts",
|
|
12
|
+
"lint": "eslint src/",
|
|
13
|
+
"prepublishOnly": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"defi",
|
|
17
|
+
"protocol",
|
|
18
|
+
"ethereum",
|
|
19
|
+
"smart-contract",
|
|
20
|
+
"proxy",
|
|
21
|
+
"upgrade",
|
|
22
|
+
"monitoring",
|
|
23
|
+
"snapshot",
|
|
24
|
+
"diff",
|
|
25
|
+
"multi-chain",
|
|
26
|
+
"governance",
|
|
27
|
+
"admin"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"viem": "^2.21.0",
|
|
35
|
+
"commander": "^12.1.0",
|
|
36
|
+
"chalk": "^5.3.0",
|
|
37
|
+
"ora": "^8.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"tsup": "^8.3.0",
|
|
41
|
+
"tsx": "^4.19.0",
|
|
42
|
+
"typescript": "^5.6.0",
|
|
43
|
+
"@types/node": "^22.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"chains": {
|
|
3
|
+
"ethereum": {
|
|
4
|
+
"rpc": "https://ethereum-rpc.publicnode.com",
|
|
5
|
+
"name": "Ethereum Mainnet"
|
|
6
|
+
}
|
|
7
|
+
},
|
|
8
|
+
"contracts": {
|
|
9
|
+
"AaveV3Pool": {
|
|
10
|
+
"address": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2",
|
|
11
|
+
"chains": ["ethereum"],
|
|
12
|
+
"type": "proxy"
|
|
13
|
+
},
|
|
14
|
+
"AaveV3PoolConfigurator": {
|
|
15
|
+
"address": "0x64b761D848206f447Fe2dd461b0c635Ec39EbB27",
|
|
16
|
+
"chains": ["ethereum"],
|
|
17
|
+
"type": "proxy"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/chains.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mainnet,
|
|
3
|
+
arbitrum,
|
|
4
|
+
optimism,
|
|
5
|
+
polygon,
|
|
6
|
+
base,
|
|
7
|
+
avalanche,
|
|
8
|
+
bsc,
|
|
9
|
+
gnosis,
|
|
10
|
+
scroll,
|
|
11
|
+
linea,
|
|
12
|
+
blast,
|
|
13
|
+
zkSync,
|
|
14
|
+
type Chain,
|
|
15
|
+
} from 'viem/chains';
|
|
16
|
+
|
|
17
|
+
const chainMap: Record<string, Chain> = {
|
|
18
|
+
ethereum: mainnet,
|
|
19
|
+
arbitrum,
|
|
20
|
+
optimism,
|
|
21
|
+
polygon,
|
|
22
|
+
base,
|
|
23
|
+
avalanche,
|
|
24
|
+
bsc,
|
|
25
|
+
gnosis,
|
|
26
|
+
scroll,
|
|
27
|
+
linea,
|
|
28
|
+
blast,
|
|
29
|
+
zksync: zkSync,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function resolveChain(name: string): Chain | undefined {
|
|
33
|
+
return chainMap[name.toLowerCase()];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function listSupportedChains(): string[] {
|
|
37
|
+
return Object.keys(chainMap);
|
|
38
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import type { ProtoscanConfig } from './types.js';
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILE = 'protoscan.config.json';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CONFIG: ProtoscanConfig = {
|
|
7
|
+
chains: {
|
|
8
|
+
ethereum: {
|
|
9
|
+
rpc: 'https://eth.llamarpc.com',
|
|
10
|
+
name: 'Ethereum Mainnet',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
contracts: {},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function loadConfig(): ProtoscanConfig {
|
|
17
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`No ${CONFIG_FILE} found. Run "protoscan init" to create one.`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const raw = readFileSync(CONFIG_FILE, 'utf-8');
|
|
23
|
+
return JSON.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function initConfig(): string {
|
|
27
|
+
if (existsSync(CONFIG_FILE)) {
|
|
28
|
+
throw new Error(`${CONFIG_FILE} already exists.`);
|
|
29
|
+
}
|
|
30
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2), 'utf-8');
|
|
31
|
+
return CONFIG_FILE;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function configExists(): boolean {
|
|
35
|
+
return existsSync(CONFIG_FILE);
|
|
36
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Snapshot, SnapshotDiff, DiffEntry } from './types.js';
|
|
2
|
+
|
|
3
|
+
const CRITICAL_FIELDS = new Set([
|
|
4
|
+
'erc1967.implementation',
|
|
5
|
+
'erc1967.admin',
|
|
6
|
+
'owner',
|
|
7
|
+
'admin',
|
|
8
|
+
'guardian',
|
|
9
|
+
'governance',
|
|
10
|
+
'timelock',
|
|
11
|
+
'pendingOwner',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const WARNING_FIELDS = new Set([
|
|
15
|
+
'paused',
|
|
16
|
+
'erc1967.beacon',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function severity(field: string): DiffEntry['severity'] {
|
|
20
|
+
if (CRITICAL_FIELDS.has(field)) return 'critical';
|
|
21
|
+
if (WARNING_FIELDS.has(field)) return 'warning';
|
|
22
|
+
return 'info';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function diffSnapshots(from: Snapshot, to: Snapshot): SnapshotDiff {
|
|
26
|
+
const entries: DiffEntry[] = [];
|
|
27
|
+
|
|
28
|
+
const allContracts = new Set([
|
|
29
|
+
...Object.keys(from.contracts),
|
|
30
|
+
...Object.keys(to.contracts),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
for (const name of allContracts) {
|
|
34
|
+
const fromContract = from.contracts[name];
|
|
35
|
+
const toContract = to.contracts[name];
|
|
36
|
+
|
|
37
|
+
if (!fromContract && toContract) {
|
|
38
|
+
entries.push({
|
|
39
|
+
contract: name,
|
|
40
|
+
field: '_contract',
|
|
41
|
+
before: '(not present)',
|
|
42
|
+
after: toContract.address,
|
|
43
|
+
severity: 'warning',
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (fromContract && !toContract) {
|
|
49
|
+
entries.push({
|
|
50
|
+
contract: name,
|
|
51
|
+
field: '_contract',
|
|
52
|
+
before: fromContract.address,
|
|
53
|
+
after: '(removed)',
|
|
54
|
+
severity: 'critical',
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!fromContract || !toContract) continue;
|
|
60
|
+
|
|
61
|
+
// proxy info diff
|
|
62
|
+
if (fromContract.proxyInfo || toContract.proxyInfo) {
|
|
63
|
+
const fp = fromContract.proxyInfo || { implementation: null, admin: null, beacon: null };
|
|
64
|
+
const tp = toContract.proxyInfo || { implementation: null, admin: null, beacon: null };
|
|
65
|
+
|
|
66
|
+
for (const key of ['implementation', 'admin', 'beacon'] as const) {
|
|
67
|
+
const fromVal = fp[key] || '(none)';
|
|
68
|
+
const toVal = tp[key] || '(none)';
|
|
69
|
+
if (fromVal !== toVal) {
|
|
70
|
+
entries.push({
|
|
71
|
+
contract: name,
|
|
72
|
+
field: `erc1967.${key}`,
|
|
73
|
+
before: fromVal,
|
|
74
|
+
after: toVal,
|
|
75
|
+
severity: severity(`erc1967.${key}`),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// public reads diff
|
|
82
|
+
const allKeys = new Set([
|
|
83
|
+
...Object.keys(fromContract.publicReads),
|
|
84
|
+
...Object.keys(toContract.publicReads),
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
for (const key of allKeys) {
|
|
88
|
+
const fromVal = fromContract.publicReads[key] ?? '(none)';
|
|
89
|
+
const toVal = toContract.publicReads[key] ?? '(none)';
|
|
90
|
+
if (fromVal !== toVal) {
|
|
91
|
+
entries.push({
|
|
92
|
+
contract: name,
|
|
93
|
+
field: key,
|
|
94
|
+
before: fromVal,
|
|
95
|
+
after: toVal,
|
|
96
|
+
severity: severity(key),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
entries.sort((a, b) => {
|
|
103
|
+
const sev = { critical: 0, warning: 1, info: 2 };
|
|
104
|
+
return sev[a.severity] - sev[b.severity];
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
from: from.createdAt,
|
|
109
|
+
to: to.createdAt,
|
|
110
|
+
chain: to.chain,
|
|
111
|
+
entries,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { loadConfig, initConfig } from './config.js';
|
|
7
|
+
import { createClient, scanContract, scanProtocol } from './scanner.js';
|
|
8
|
+
import { saveSnapshot, loadSnapshot, listSnapshots, resolveSnapshotFile } from './snapshot.js';
|
|
9
|
+
import { diffSnapshots } from './diff.js';
|
|
10
|
+
import { listProtocols, getProtocol } from './registry/index.js';
|
|
11
|
+
import type { ContractSnapshot } from './types.js';
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('protoscan')
|
|
17
|
+
.description('On-chain protocol state tracker and diff tool for DeFi teams')
|
|
18
|
+
.version('0.1.0');
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.command('init')
|
|
22
|
+
.description('Initialize protoscan config in current directory')
|
|
23
|
+
.action(() => {
|
|
24
|
+
try {
|
|
25
|
+
const path = initConfig();
|
|
26
|
+
console.log(chalk.green(`Created ${path}`));
|
|
27
|
+
console.log(chalk.dim('Edit it to add your chains and contracts.\n'));
|
|
28
|
+
console.log('Or use built-in protocol definitions:');
|
|
29
|
+
console.log(chalk.cyan(' protoscan protocols # list supported protocols'));
|
|
30
|
+
console.log(chalk.cyan(' protoscan scan aave-v3 -c ethereum # scan Aave V3'));
|
|
31
|
+
} catch (e: any) {
|
|
32
|
+
console.error(chalk.red(e.message));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── Built-in protocol registry commands ──
|
|
38
|
+
|
|
39
|
+
program
|
|
40
|
+
.command('protocols')
|
|
41
|
+
.description('List supported protocols in the built-in registry')
|
|
42
|
+
.action(() => {
|
|
43
|
+
const protos = listProtocols();
|
|
44
|
+
console.log(chalk.bold('\nSupported protocols:\n'));
|
|
45
|
+
for (const p of protos) {
|
|
46
|
+
const chains = Object.keys(p.deployments).join(', ');
|
|
47
|
+
const paramCount = p.params.length;
|
|
48
|
+
const accessCount = p.access.length;
|
|
49
|
+
console.log(` ${chalk.cyan(p.id.padEnd(16))} ${p.name} v${p.version}`);
|
|
50
|
+
console.log(chalk.dim(`${''.padEnd(18)}chains: ${chains}`));
|
|
51
|
+
console.log(chalk.dim(`${''.padEnd(18)}${paramCount} params, ${accessCount} access roles, ${Object.keys(p.contracts).length} contracts\n`));
|
|
52
|
+
}
|
|
53
|
+
console.log(chalk.dim('Usage: protoscan scan <protocol-id> -c <chain>'));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
program
|
|
57
|
+
.command('scan')
|
|
58
|
+
.description('Scan a known protocol using built-in definitions')
|
|
59
|
+
.argument('<protocol>', 'Protocol ID (e.g. aave-v3, compound-v3)')
|
|
60
|
+
.option('-c, --chain <chain>', 'Chain to scan', 'ethereum')
|
|
61
|
+
.option('-l, --label <label>', 'Label for saved snapshot')
|
|
62
|
+
.action(async (protocolId, opts) => {
|
|
63
|
+
const proto = getProtocol(protocolId);
|
|
64
|
+
if (!proto) {
|
|
65
|
+
console.error(chalk.red(`Unknown protocol: ${protocolId}`));
|
|
66
|
+
console.log(chalk.dim('Run "protoscan protocols" to see available protocols.'));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const chainName = opts.chain;
|
|
71
|
+
if (!proto.deployments[chainName]) {
|
|
72
|
+
console.error(chalk.red(`${proto.name} is not deployed on ${chainName}`));
|
|
73
|
+
console.log(chalk.dim(`Available: ${Object.keys(proto.deployments).join(', ')}`));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const config = loadConfig();
|
|
78
|
+
const chainConfig = config.chains[chainName];
|
|
79
|
+
if (!chainConfig) {
|
|
80
|
+
console.error(chalk.red(`Chain "${chainName}" not in config. Add an RPC URL first.`));
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const spinner = ora(`Scanning ${proto.name} on ${chainName}...`).start();
|
|
85
|
+
const client = createClient(chainName, chainConfig);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const result = await scanProtocol(client, protocolId, chainName);
|
|
89
|
+
if (!result) {
|
|
90
|
+
spinner.fail('Scan returned no results');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
spinner.succeed(`${proto.name} on ${chainName} @ block ${result.blockNumber}\n`);
|
|
95
|
+
|
|
96
|
+
// Contracts
|
|
97
|
+
console.log(chalk.bold.underline('Contracts'));
|
|
98
|
+
for (const [key, info] of Object.entries(result.contracts)) {
|
|
99
|
+
const proxyTag = info.proxyInfo ? chalk.dim(' (proxy)') : '';
|
|
100
|
+
console.log(` ${chalk.bold(key)}${proxyTag}: ${info.address}`);
|
|
101
|
+
console.log(chalk.dim(` ${info.role}`));
|
|
102
|
+
if (info.proxyInfo?.implementation) {
|
|
103
|
+
console.log(` impl: ${chalk.cyan(info.proxyInfo.implementation)}`);
|
|
104
|
+
}
|
|
105
|
+
if (info.proxyInfo?.admin) {
|
|
106
|
+
console.log(` admin: ${chalk.yellow(info.proxyInfo.admin)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Access control
|
|
111
|
+
if (result.access.length > 0) {
|
|
112
|
+
console.log(chalk.bold.underline('\nAccess Control'));
|
|
113
|
+
for (const a of result.access) {
|
|
114
|
+
const icon = a.severity === 'critical' ? chalk.red('✗') : chalk.yellow('!');
|
|
115
|
+
console.log(` ${icon} ${chalk.bold(a.role)}`);
|
|
116
|
+
console.log(` holder: ${chalk.yellow(a.holder)}`);
|
|
117
|
+
console.log(chalk.dim(` ${a.description}`));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Parameters
|
|
122
|
+
const categories = ['risk', 'rate', 'config', 'state', 'access'];
|
|
123
|
+
for (const cat of categories) {
|
|
124
|
+
const catParams = Object.entries(result.params).filter(([_, v]) => v.category === cat);
|
|
125
|
+
if (catParams.length === 0) continue;
|
|
126
|
+
|
|
127
|
+
console.log(chalk.bold.underline(`\n${cat.charAt(0).toUpperCase() + cat.slice(1)} Parameters`));
|
|
128
|
+
for (const [name, info] of catParams) {
|
|
129
|
+
const icon = info.severity === 'critical' ? chalk.red('✗')
|
|
130
|
+
: info.severity === 'warning' ? chalk.yellow('!') : chalk.dim('~');
|
|
131
|
+
console.log(` ${icon} ${name}: ${chalk.cyan(info.value)}`);
|
|
132
|
+
console.log(chalk.dim(` ${info.description}`));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Save as snapshot
|
|
137
|
+
const snapData: Record<string, any> = {
|
|
138
|
+
_protocol: { id: protocolId, name: proto.name, chain: chainName },
|
|
139
|
+
_block: result.blockNumber.toString(),
|
|
140
|
+
_timestamp: result.timestamp,
|
|
141
|
+
contracts: result.contracts,
|
|
142
|
+
params: result.params,
|
|
143
|
+
access: result.access,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const label = opts.label || new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
147
|
+
const { writeFileSync, mkdirSync, existsSync } = await import('node:fs');
|
|
148
|
+
const dir = '.protoscan';
|
|
149
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
150
|
+
const filePath = `${dir}/${protocolId}_${chainName}_${label}.json`;
|
|
151
|
+
writeFileSync(filePath, JSON.stringify(snapData, null, 2), 'utf-8');
|
|
152
|
+
console.log(chalk.dim(`\nSaved: ${filePath}`));
|
|
153
|
+
|
|
154
|
+
} catch (e: any) {
|
|
155
|
+
spinner.fail(`Scan failed: ${e.message}`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── Custom contract commands (existing) ──
|
|
161
|
+
|
|
162
|
+
program
|
|
163
|
+
.command('snapshot')
|
|
164
|
+
.description('Snapshot custom contracts from config')
|
|
165
|
+
.option('-c, --chain <chain>', 'Chain to snapshot (default: all)')
|
|
166
|
+
.option('-l, --label <label>', 'Snapshot label')
|
|
167
|
+
.action(async (opts) => {
|
|
168
|
+
const config = loadConfig();
|
|
169
|
+
const targetChains = opts.chain ? [opts.chain] : Object.keys(config.chains);
|
|
170
|
+
|
|
171
|
+
for (const chainName of targetChains) {
|
|
172
|
+
const chainConfig = config.chains[chainName];
|
|
173
|
+
if (!chainConfig) {
|
|
174
|
+
console.error(chalk.red(`Chain "${chainName}" not in config.`));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const spinner = ora(`Scanning ${chainName}...`).start();
|
|
179
|
+
const client = createClient(chainName, chainConfig);
|
|
180
|
+
|
|
181
|
+
const results: Record<string, ContractSnapshot> = {};
|
|
182
|
+
const contractEntries = Object.entries(config.contracts).filter(
|
|
183
|
+
([_, entry]) => entry.chains.includes(chainName),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (contractEntries.length === 0) {
|
|
187
|
+
spinner.warn(`No contracts configured for ${chainName}`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const [name, entry] of contractEntries) {
|
|
192
|
+
spinner.text = `Scanning ${chainName} / ${name}...`;
|
|
193
|
+
try {
|
|
194
|
+
results[name] = await scanContract(client, chainName, name, entry);
|
|
195
|
+
} catch (e: any) {
|
|
196
|
+
spinner.warn(`Failed to scan ${name}: ${e.message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const filePath = saveSnapshot(results, chainName, opts.label);
|
|
201
|
+
spinner.succeed(`${chainName}: ${Object.keys(results).length} contracts → ${filePath}`);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
program
|
|
206
|
+
.command('diff')
|
|
207
|
+
.description('Compare two snapshots')
|
|
208
|
+
.argument('<from>', 'First snapshot')
|
|
209
|
+
.argument('<to>', 'Second snapshot')
|
|
210
|
+
.option('-c, --chain <chain>', 'Chain filter')
|
|
211
|
+
.action((fromArg, toArg, opts) => {
|
|
212
|
+
try {
|
|
213
|
+
const fromFile = resolveSnapshotFile(fromArg, opts.chain);
|
|
214
|
+
const toFile = resolveSnapshotFile(toArg, opts.chain);
|
|
215
|
+
const fromSnap = loadSnapshot(fromFile);
|
|
216
|
+
const toSnap = loadSnapshot(toFile);
|
|
217
|
+
const result = diffSnapshots(fromSnap, toSnap);
|
|
218
|
+
|
|
219
|
+
if (result.entries.length === 0) {
|
|
220
|
+
console.log(chalk.green('No changes detected.'));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
console.log(chalk.bold(`\nChanges: ${result.from} → ${result.to} (${result.chain})\n`));
|
|
225
|
+
|
|
226
|
+
for (const entry of result.entries) {
|
|
227
|
+
const icon = entry.severity === 'critical' ? chalk.red('✗')
|
|
228
|
+
: entry.severity === 'warning' ? chalk.yellow('!') : chalk.dim('~');
|
|
229
|
+
console.log(` ${icon} ${chalk.bold(`${entry.contract}.${entry.field}`)}`);
|
|
230
|
+
console.log(chalk.dim(` ${entry.before}`));
|
|
231
|
+
console.log(chalk.green(` ${entry.after}`));
|
|
232
|
+
console.log();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const critCount = result.entries.filter((e) => e.severity === 'critical').length;
|
|
236
|
+
const warnCount = result.entries.filter((e) => e.severity === 'warning').length;
|
|
237
|
+
const infoCount = result.entries.filter((e) => e.severity === 'info').length;
|
|
238
|
+
console.log(`${chalk.red(`${critCount} critical`)} | ${chalk.yellow(`${warnCount} warning`)} | ${chalk.dim(`${infoCount} info`)}`);
|
|
239
|
+
} catch (e: any) {
|
|
240
|
+
console.error(chalk.red(e.message));
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
program
|
|
246
|
+
.command('list')
|
|
247
|
+
.description('List saved snapshots')
|
|
248
|
+
.option('-c, --chain <chain>', 'Filter by chain')
|
|
249
|
+
.action((opts) => {
|
|
250
|
+
const snapshots = listSnapshots(opts.chain);
|
|
251
|
+
if (snapshots.length === 0) {
|
|
252
|
+
console.log(chalk.dim('No snapshots found.'));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
for (const s of snapshots) console.log(` ${s}`);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
program
|
|
259
|
+
.command('show')
|
|
260
|
+
.description('Display a snapshot')
|
|
261
|
+
.argument('<snapshot>', 'Snapshot file or label')
|
|
262
|
+
.option('-c, --chain <chain>', 'Chain filter')
|
|
263
|
+
.action((snapArg, opts) => {
|
|
264
|
+
try {
|
|
265
|
+
const file = resolveSnapshotFile(snapArg, opts.chain);
|
|
266
|
+
const snap = loadSnapshot(file);
|
|
267
|
+
|
|
268
|
+
console.log(chalk.bold(`\nSnapshot: ${snap.chain} @ block ${snap.blockNumber}`));
|
|
269
|
+
console.log(chalk.dim(`Created: ${snap.createdAt}\n`));
|
|
270
|
+
|
|
271
|
+
for (const [name, contract] of Object.entries(snap.contracts)) {
|
|
272
|
+
console.log(chalk.bold.underline(name));
|
|
273
|
+
console.log(` address: ${contract.address}`);
|
|
274
|
+
if (contract.proxyInfo) {
|
|
275
|
+
if (contract.proxyInfo.implementation) console.log(` implementation: ${chalk.cyan(contract.proxyInfo.implementation)}`);
|
|
276
|
+
if (contract.proxyInfo.admin) console.log(` proxy admin: ${chalk.yellow(contract.proxyInfo.admin)}`);
|
|
277
|
+
}
|
|
278
|
+
for (const [key, value] of Object.entries(contract.publicReads)) {
|
|
279
|
+
console.log(` ${key}: ${value}`);
|
|
280
|
+
}
|
|
281
|
+
console.log();
|
|
282
|
+
}
|
|
283
|
+
} catch (e: any) {
|
|
284
|
+
console.error(chalk.red(e.message));
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
program.parse();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type PublicClient, type Address, parseAbi } from 'viem';
|
|
2
|
+
|
|
3
|
+
const ownershipAbi = parseAbi([
|
|
4
|
+
'function owner() view returns (address)',
|
|
5
|
+
'function admin() view returns (address)',
|
|
6
|
+
'function guardian() view returns (address)',
|
|
7
|
+
'function pendingOwner() view returns (address)',
|
|
8
|
+
'function paused() view returns (bool)',
|
|
9
|
+
'function timelock() view returns (address)',
|
|
10
|
+
'function governance() view returns (address)',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const tokenAbi = parseAbi([
|
|
14
|
+
'function name() view returns (string)',
|
|
15
|
+
'function symbol() view returns (string)',
|
|
16
|
+
'function decimals() view returns (uint8)',
|
|
17
|
+
'function totalSupply() view returns (uint256)',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
interface ReadResult {
|
|
21
|
+
key: string;
|
|
22
|
+
value: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function tryRead(
|
|
26
|
+
client: PublicClient,
|
|
27
|
+
address: Address,
|
|
28
|
+
abi: readonly any[],
|
|
29
|
+
functionName: string,
|
|
30
|
+
): Promise<ReadResult | null> {
|
|
31
|
+
try {
|
|
32
|
+
const result = await client.readContract({
|
|
33
|
+
address,
|
|
34
|
+
abi,
|
|
35
|
+
functionName,
|
|
36
|
+
});
|
|
37
|
+
return { key: functionName, value: String(result) };
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readCommonState(
|
|
44
|
+
client: PublicClient,
|
|
45
|
+
address: Address,
|
|
46
|
+
): Promise<Record<string, string>> {
|
|
47
|
+
const reads = await Promise.all([
|
|
48
|
+
tryRead(client, address, ownershipAbi, 'owner'),
|
|
49
|
+
tryRead(client, address, ownershipAbi, 'admin'),
|
|
50
|
+
tryRead(client, address, ownershipAbi, 'guardian'),
|
|
51
|
+
tryRead(client, address, ownershipAbi, 'pendingOwner'),
|
|
52
|
+
tryRead(client, address, ownershipAbi, 'paused'),
|
|
53
|
+
tryRead(client, address, ownershipAbi, 'timelock'),
|
|
54
|
+
tryRead(client, address, ownershipAbi, 'governance'),
|
|
55
|
+
tryRead(client, address, tokenAbi, 'name'),
|
|
56
|
+
tryRead(client, address, tokenAbi, 'symbol'),
|
|
57
|
+
tryRead(client, address, tokenAbi, 'decimals'),
|
|
58
|
+
tryRead(client, address, tokenAbi, 'totalSupply'),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const result: Record<string, string> = {};
|
|
62
|
+
for (const read of reads) {
|
|
63
|
+
if (read) result[read.key] = read.value;
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type PublicClient, type Address, type Hex, getAddress, pad, trim } from 'viem';
|
|
2
|
+
import type { ProxyInfo } from '../types.js';
|
|
3
|
+
|
|
4
|
+
// ERC-1967 storage slots
|
|
5
|
+
const IMPLEMENTATION_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc' as Hex;
|
|
6
|
+
const ADMIN_SLOT = '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103' as Hex;
|
|
7
|
+
const BEACON_SLOT = '0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50' as Hex;
|
|
8
|
+
|
|
9
|
+
// OpenZeppelin TransparentUpgradeableProxy (older pattern)
|
|
10
|
+
const OZ_IMPL_SLOT = '0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3' as Hex;
|
|
11
|
+
|
|
12
|
+
async function readSlotAsAddress(client: PublicClient, address: Address, slot: Hex): Promise<Address | null> {
|
|
13
|
+
try {
|
|
14
|
+
const value = await client.getStorageAt({ address, slot });
|
|
15
|
+
if (!value || value === '0x0000000000000000000000000000000000000000000000000000000000000000') {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const trimmed = trim(value);
|
|
19
|
+
if (trimmed === '0x' || trimmed === '0x0') return null;
|
|
20
|
+
return getAddress(pad(trimmed, { size: 20 }));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function detectProxy(client: PublicClient, address: Address): Promise<ProxyInfo | null> {
|
|
27
|
+
const [implementation, admin, beacon] = await Promise.all([
|
|
28
|
+
readSlotAsAddress(client, address, IMPLEMENTATION_SLOT),
|
|
29
|
+
readSlotAsAddress(client, address, ADMIN_SLOT),
|
|
30
|
+
readSlotAsAddress(client, address, BEACON_SLOT),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
if (!implementation && !admin && !beacon) {
|
|
34
|
+
const ozImpl = await readSlotAsAddress(client, address, OZ_IMPL_SLOT);
|
|
35
|
+
if (ozImpl) {
|
|
36
|
+
return { implementation: ozImpl, admin: null, beacon: null };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { implementation, admin, beacon };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getProxySlots(): Array<{ slot: Hex; label: string }> {
|
|
45
|
+
return [
|
|
46
|
+
{ slot: IMPLEMENTATION_SLOT, label: 'erc1967.implementation' },
|
|
47
|
+
{ slot: ADMIN_SLOT, label: 'erc1967.admin' },
|
|
48
|
+
{ slot: BEACON_SLOT, label: 'erc1967.beacon' },
|
|
49
|
+
];
|
|
50
|
+
}
|