opmsec 0.1.0 → 0.1.4
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/.env.example +23 -13
- package/.husky/pre-commit +1 -0
- package/README.md +256 -173
- package/bun.lock +4 -4
- package/docs/architecture/agents.mdx +77 -0
- package/docs/architecture/benchmarks.mdx +65 -0
- package/docs/architecture/overview.mdx +58 -0
- package/docs/architecture/scanner.mdx +53 -0
- package/docs/cli/audit.mdx +35 -0
- package/docs/cli/check.mdx +44 -0
- package/docs/cli/fix.mdx +49 -0
- package/docs/cli/info.mdx +44 -0
- package/docs/cli/install.mdx +71 -0
- package/docs/cli/push.mdx +99 -0
- package/docs/cli/register-agent.mdx +80 -0
- package/docs/cli/view.mdx +52 -0
- package/docs/concepts/multi-agent-consensus.mdx +58 -0
- package/docs/concepts/on-chain-registry.mdx +74 -0
- package/docs/concepts/security-model.mdx +76 -0
- package/docs/concepts/zk-agent-verification.mdx +82 -0
- package/docs/configuration.mdx +82 -0
- package/docs/contract/deployment.mdx +57 -0
- package/docs/contract/events.mdx +115 -0
- package/docs/contract/functions.mdx +220 -0
- package/docs/contract/overview.mdx +58 -0
- package/docs/favicon.svg +5 -0
- package/docs/introduction.mdx +43 -0
- package/docs/logo/dark.svg +5 -0
- package/docs/logo/light.svg +5 -0
- package/docs/mint.json +106 -0
- package/docs/quickstart.mdx +133 -0
- package/package.json +7 -6
- package/packages/cli/src/commands/author-view.tsx +9 -1
- package/packages/cli/src/commands/check.tsx +318 -0
- package/packages/cli/src/commands/fix.tsx +294 -0
- package/packages/cli/src/commands/install.tsx +501 -47
- package/packages/cli/src/commands/push.tsx +53 -22
- package/packages/cli/src/commands/register-agent.tsx +227 -0
- package/packages/cli/src/components/AgentScores.tsx +20 -6
- package/packages/cli/src/components/Hyperlink.tsx +30 -0
- package/packages/cli/src/components/ScanReport.tsx +3 -2
- package/packages/cli/src/index.tsx +44 -6
- package/packages/cli/src/services/avatar.ts +43 -6
- package/packages/cli/src/services/chainpatrol.ts +20 -17
- package/packages/cli/src/services/contract.ts +41 -8
- package/packages/cli/src/services/ens.ts +3 -5
- package/packages/cli/src/services/fileverse.ts +12 -13
- package/packages/cli/src/services/typosquat.ts +166 -0
- package/packages/cli/src/services/version.ts +156 -5
- package/packages/contracts/circuits/accuracy_verifier.circom +101 -0
- package/packages/contracts/contracts/OPMRegistry.sol +63 -0
- package/packages/contracts/scripts/deploy.ts +22 -3
- package/packages/core/src/abi.ts +221 -0
- package/packages/core/src/benchmarks.ts +450 -0
- package/packages/core/src/constants.ts +20 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/model-rankings.ts +115 -0
- package/packages/core/src/prompt.ts +58 -0
- package/packages/core/src/types.ts +41 -0
- package/packages/core/src/utils.ts +142 -3
- package/packages/scanner/src/agents/base-agent.ts +13 -3
- package/packages/scanner/src/index.ts +5 -2
- package/packages/scanner/src/queue/memory-queue.ts +8 -3
- package/packages/scanner/src/services/benchmark-runner.ts +114 -0
- package/packages/scanner/src/services/contract-writer.ts +2 -3
- package/packages/scanner/src/services/fileverse.ts +26 -7
- package/packages/scanner/src/services/openrouter.ts +61 -4
- package/packages/scanner/src/services/report-formatter.ts +122 -3
- package/packages/scanner/src/services/zk-verifier.ts +118 -0
- package/packages/web/.next/BUILD_ID +1 -0
- package/packages/web/.next/app-build-manifest.json +26 -0
- package/packages/web/.next/app-path-routes-manifest.json +4 -0
- package/packages/web/.next/build-manifest.json +33 -0
- package/packages/web/.next/diagnostics/build-diagnostics.json +6 -0
- package/packages/web/.next/diagnostics/framework.json +1 -0
- package/packages/web/.next/export-marker.json +6 -0
- package/packages/web/.next/images-manifest.json +58 -0
- package/packages/web/.next/next-minimal-server.js.nft.json +1 -0
- package/packages/web/.next/next-server.js.nft.json +1 -0
- package/packages/web/.next/package.json +1 -0
- package/packages/web/.next/prerender-manifest.json +61 -0
- package/packages/web/.next/react-loadable-manifest.json +1 -0
- package/packages/web/.next/required-server-files.json +320 -0
- package/packages/web/.next/routes-manifest.json +53 -0
- package/packages/web/.next/server/app/_not-found/page.js +2 -0
- package/packages/web/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/packages/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/packages/web/.next/server/app/_not-found.html +1 -0
- package/packages/web/.next/server/app/_not-found.meta +8 -0
- package/packages/web/.next/server/app/_not-found.rsc +16 -0
- package/packages/web/.next/server/app/index.html +1 -0
- package/packages/web/.next/server/app/index.meta +7 -0
- package/packages/web/.next/server/app/index.rsc +20 -0
- package/packages/web/.next/server/app/page.js +2 -0
- package/packages/web/.next/server/app/page.js.nft.json +1 -0
- package/packages/web/.next/server/app/page_client-reference-manifest.js +1 -0
- package/packages/web/.next/server/app-paths-manifest.json +4 -0
- package/packages/web/.next/server/chunks/611.js +6 -0
- package/packages/web/.next/server/chunks/778.js +30 -0
- package/packages/web/.next/server/functions-config-manifest.json +4 -0
- package/packages/web/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/packages/web/.next/server/middleware-build-manifest.js +1 -0
- package/packages/web/.next/server/middleware-manifest.json +6 -0
- package/packages/web/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/packages/web/.next/server/next-font-manifest.js +1 -0
- package/packages/web/.next/server/next-font-manifest.json +1 -0
- package/packages/web/.next/server/pages/404.html +1 -0
- package/packages/web/.next/server/pages/500.html +1 -0
- package/packages/web/.next/server/pages/_app.js +1 -0
- package/packages/web/.next/server/pages/_app.js.nft.json +1 -0
- package/packages/web/.next/server/pages/_document.js +1 -0
- package/packages/web/.next/server/pages/_document.js.nft.json +1 -0
- package/packages/web/.next/server/pages/_error.js +19 -0
- package/packages/web/.next/server/pages/_error.js.nft.json +1 -0
- package/packages/web/.next/server/pages-manifest.json +6 -0
- package/packages/web/.next/server/server-reference-manifest.js +1 -0
- package/packages/web/.next/server/server-reference-manifest.json +1 -0
- package/packages/web/.next/server/webpack-runtime.js +1 -0
- package/packages/web/.next/static/2XIFCTTKVZwN_RsNE-Rrr/_buildManifest.js +1 -0
- package/packages/web/.next/static/2XIFCTTKVZwN_RsNE-Rrr/_ssgManifest.js +1 -0
- package/packages/web/.next/static/chunks/255-0dc49b7a6e8e5c05.js +1 -0
- package/packages/web/.next/static/chunks/4bd1b696-382748cc942d8a14.js +1 -0
- package/packages/web/.next/static/chunks/app/_not-found/page-0da542be7eb33a64.js +1 -0
- package/packages/web/.next/static/chunks/app/layout-28a489fb4398663f.js +1 -0
- package/packages/web/.next/static/chunks/app/page-e58ccdb78625bce6.js +1 -0
- package/packages/web/.next/static/chunks/framework-ac73abd125e371fe.js +1 -0
- package/packages/web/.next/static/chunks/main-app-dd261207182e5a23.js +1 -0
- package/packages/web/.next/static/chunks/main-ee293fa6aa18bdd1.js +1 -0
- package/packages/web/.next/static/chunks/pages/_app-7d307437aca18ad4.js +1 -0
- package/packages/web/.next/static/chunks/pages/_error-cb2a52f75f2162e2.js +1 -0
- package/packages/web/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/packages/web/.next/static/chunks/webpack-e1ae44446e7f7355.js +1 -0
- package/packages/web/.next/static/css/21d69157e271f2ab.css +3 -0
- package/packages/web/.next/trace +2 -0
- package/packages/web/.next/types/app/layout.ts +84 -0
- package/packages/web/.next/types/app/page.ts +84 -0
- package/packages/web/.next/types/cache-life.d.ts +141 -0
- package/packages/web/.next/types/package.json +1 -0
- package/packages/web/.next/types/routes.d.ts +57 -0
- package/packages/web/.next/types/validator.ts +61 -0
- package/packages/web/app/globals.css +75 -0
- package/packages/web/app/layout.tsx +26 -0
- package/packages/web/app/page.tsx +361 -0
- package/packages/web/bun.lock +300 -0
- package/packages/web/next-env.d.ts +6 -0
- package/packages/web/next.config.ts +5 -0
- package/packages/web/package.json +26 -0
- package/packages/web/postcss.config.mjs +8 -0
- package/packages/web/public/favicon.svg +5 -0
- package/packages/web/public/logo.svg +7 -0
- package/packages/web/tailwind.config.ts +48 -0
- package/packages/web/tsconfig.json +21 -0
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import { ethers } from 'ethers';
|
|
2
|
-
import { OPM_REGISTRY_ABI, getEnvOrThrow, getEnvOrDefault, BASE_SEPOLIA_RPC } from '@opm/core';
|
|
2
|
+
import { OPM_REGISTRY_ABI, getEnvOrThrow, getEnvOrDefault, BASE_SEPOLIA_RPC, DEFAULT_CONTRACT_ADDRESS } from '@opm/core';
|
|
3
3
|
import type { OnChainPackageInfo, AuthorProfile } from '@opm/core';
|
|
4
4
|
|
|
5
|
+
function getContractAddress(): string {
|
|
6
|
+
return getEnvOrDefault('CONTRACT_ADDRESS', DEFAULT_CONTRACT_ADDRESS);
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
function getReadContract() {
|
|
6
10
|
const rpc = getEnvOrDefault('BASE_SEPOLIA_RPC_URL', BASE_SEPOLIA_RPC);
|
|
7
11
|
const provider = new ethers.JsonRpcProvider(rpc);
|
|
8
|
-
|
|
9
|
-
return new ethers.Contract(address, OPM_REGISTRY_ABI, provider);
|
|
12
|
+
return new ethers.Contract(getContractAddress(), OPM_REGISTRY_ABI, provider);
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
function getWriteContract() {
|
|
13
16
|
const rpc = getEnvOrDefault('BASE_SEPOLIA_RPC_URL', BASE_SEPOLIA_RPC);
|
|
14
17
|
const provider = new ethers.JsonRpcProvider(rpc);
|
|
15
|
-
const wallet = new ethers.Wallet(getEnvOrThrow('OPM_PRIVATE_KEY'), provider);
|
|
16
|
-
|
|
17
|
-
return new ethers.Contract(address, OPM_REGISTRY_ABI, wallet);
|
|
18
|
+
const wallet = new ethers.Wallet(getEnvOrThrow('OPM_SIGNING_KEY', 'OPM_PRIVATE_KEY'), provider);
|
|
19
|
+
return new ethers.Contract(getContractAddress(), OPM_REGISTRY_ABI, wallet);
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export async function getPackageInfo(name: string, version: string): Promise<OnChainPackageInfo> {
|
|
@@ -105,8 +107,7 @@ export interface AuthorPackageSummary {
|
|
|
105
107
|
export async function getPackagesByAuthor(authorAddress: string): Promise<AuthorPackageSummary[]> {
|
|
106
108
|
const rpc = getEnvOrDefault('BASE_SEPOLIA_RPC_URL', BASE_SEPOLIA_RPC);
|
|
107
109
|
const provider = new ethers.JsonRpcProvider(rpc);
|
|
108
|
-
const
|
|
109
|
-
const contract = new ethers.Contract(contractAddr, OPM_REGISTRY_ABI, provider);
|
|
110
|
+
const contract = new ethers.Contract(getContractAddress(), OPM_REGISTRY_ABI, provider);
|
|
110
111
|
|
|
111
112
|
const packageMap = new Map<string, { name: string; version: string }>();
|
|
112
113
|
|
|
@@ -154,6 +155,38 @@ export async function getPackagesByAuthor(authorAddress: string): Promise<Author
|
|
|
154
155
|
return results;
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
export async function registerAgentOnChain(
|
|
159
|
+
name: string,
|
|
160
|
+
model: string,
|
|
161
|
+
systemPromptHash: string,
|
|
162
|
+
proofHash: string,
|
|
163
|
+
): Promise<string> {
|
|
164
|
+
const rpc = getEnvOrDefault('BASE_SEPOLIA_RPC_URL', BASE_SEPOLIA_RPC);
|
|
165
|
+
const provider = new ethers.JsonRpcProvider(rpc);
|
|
166
|
+
const wallet = new ethers.Wallet(getEnvOrThrow('AGENT_PRIVATE_KEY'), provider);
|
|
167
|
+
const contract = new ethers.Contract(getContractAddress(), OPM_REGISTRY_ABI, wallet);
|
|
168
|
+
|
|
169
|
+
const tx = await contract.registerAgent(
|
|
170
|
+
name,
|
|
171
|
+
model,
|
|
172
|
+
ethers.keccak256(ethers.toUtf8Bytes(systemPromptHash)),
|
|
173
|
+
ethers.keccak256(ethers.toUtf8Bytes(proofHash)),
|
|
174
|
+
);
|
|
175
|
+
const receipt = await tx.wait();
|
|
176
|
+
return receipt.hash;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function getRegisteredAgent(agentAddress: string): Promise<any> {
|
|
180
|
+
const contract = getReadContract();
|
|
181
|
+
return contract.getRegisteredAgent(agentAddress);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function getAgentCount(): Promise<number> {
|
|
185
|
+
const contract = getReadContract();
|
|
186
|
+
const count = await contract.getAgentCount();
|
|
187
|
+
return Number(count);
|
|
188
|
+
}
|
|
189
|
+
|
|
157
190
|
export async function getPackagesByAuthorDirect(
|
|
158
191
|
authorAddress: string,
|
|
159
192
|
knownPackageNames: string[],
|
|
@@ -4,21 +4,19 @@ import { addEnsContracts } from '@ensdomains/ensjs';
|
|
|
4
4
|
import { getName } from '@ensdomains/ensjs/public';
|
|
5
5
|
import { getRecords } from '@ensdomains/ensjs/public';
|
|
6
6
|
import { getAddressRecord } from '@ensdomains/ensjs/public';
|
|
7
|
-
import { getEnvOrDefault } from '@opm/core';
|
|
7
|
+
import { getEnvOrDefault, ETH_SEPOLIA_RPC, ETH_MAINNET_RPC } from '@opm/core';
|
|
8
8
|
|
|
9
9
|
function getSepoliaClient() {
|
|
10
|
-
const rpc = getEnvOrDefault('ETH_SEPOLIA_RPC_URL', 'https://ethereum-sepolia-rpc.publicnode.com');
|
|
11
10
|
return createPublicClient({
|
|
12
11
|
chain: addEnsContracts(sepolia),
|
|
13
|
-
transport: http(
|
|
12
|
+
transport: http(getEnvOrDefault('ETH_SEPOLIA_RPC_URL', ETH_SEPOLIA_RPC)),
|
|
14
13
|
});
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
function getMainnetClient() {
|
|
18
|
-
const rpc = getEnvOrDefault('ETH_MAINNET_RPC_URL', 'https://eth.llamarpc.com');
|
|
19
17
|
return createPublicClient({
|
|
20
18
|
chain: addEnsContracts(mainnet),
|
|
21
|
-
transport: http(
|
|
19
|
+
transport: http(getEnvOrDefault('ETH_MAINNET_RPC_URL', ETH_MAINNET_RPC)),
|
|
22
20
|
});
|
|
23
21
|
}
|
|
24
22
|
|
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
import type { ScanReport } from '@opm/core';
|
|
2
|
-
import { getEnvOrDefault, safeJsonParse } from '@opm/core';
|
|
3
|
-
|
|
4
|
-
const DEFAULT_API_URL = 'http://localhost:8001';
|
|
2
|
+
import { getEnvOrDefault, safeJsonParse, FILEVERSE_DEFAULT_URL } from '@opm/core';
|
|
5
3
|
|
|
6
4
|
export async function fetchReportFromFileverse(reportURI: string): Promise<ScanReport | null> {
|
|
7
5
|
if (!reportURI || reportURI.startsWith('local://')) return null;
|
|
8
6
|
|
|
9
7
|
const apiKey = process.env.FILEVERSE_API_KEY;
|
|
10
|
-
|
|
8
|
+
if (!apiKey) return null;
|
|
11
9
|
|
|
10
|
+
const apiUrl = getEnvOrDefault('FILEVERSE_API_URL', FILEVERSE_DEFAULT_URL);
|
|
12
11
|
const ddocId = extractDdocId(reportURI);
|
|
13
|
-
if (ddocId
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
}
|
|
12
|
+
if (!ddocId) return null;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`${apiUrl}/api/ddocs/${ddocId}?apiKey=${encodeURIComponent(apiKey)}`);
|
|
16
|
+
if (res.ok) {
|
|
17
|
+
const doc = await res.json() as { content: string };
|
|
18
|
+
return safeJsonParse<ScanReport>(doc.content);
|
|
19
|
+
}
|
|
20
|
+
} catch { /* local API not running */ }
|
|
22
21
|
|
|
23
22
|
return null;
|
|
24
23
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const NPM_BULK_DL = 'https://api.npmjs.org/downloads/point/last-week';
|
|
2
|
+
const NPM_SEARCH = 'https://registry.npmjs.org/-/v1/search';
|
|
3
|
+
|
|
4
|
+
export interface TyposquatResult {
|
|
5
|
+
suspect: string;
|
|
6
|
+
likelyTarget: string | null;
|
|
7
|
+
confidence: 'high' | 'medium' | 'low' | 'none';
|
|
8
|
+
reason: string;
|
|
9
|
+
targetDownloads: number;
|
|
10
|
+
suspectDownloads: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function detectTyposquatBatch(names: string[]): Promise<TyposquatResult[]> {
|
|
14
|
+
if (names.length === 0) return [];
|
|
15
|
+
|
|
16
|
+
const dlMap = await fetchBulkDownloads(names);
|
|
17
|
+
|
|
18
|
+
const searchResults = await Promise.all(
|
|
19
|
+
names.map((n) => searchSimilar(stripScope(n)).catch(() => [] as SearchHit[])),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const candidateNames = new Set<string>();
|
|
23
|
+
for (const hits of searchResults) {
|
|
24
|
+
for (const h of hits) candidateNames.add(h.name);
|
|
25
|
+
}
|
|
26
|
+
const extraNames = [...candidateNames].filter((n) => !dlMap.has(n));
|
|
27
|
+
if (extraNames.length > 0) {
|
|
28
|
+
const extra = await fetchBulkDownloads(extraNames);
|
|
29
|
+
for (const [k, v] of extra) dlMap.set(k, v);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return names.map((name, idx) => {
|
|
33
|
+
const bare = stripScope(name);
|
|
34
|
+
const suspectDl = dlMap.get(name) || 0;
|
|
35
|
+
const hits = searchResults[idx] || [];
|
|
36
|
+
let best: TyposquatResult = {
|
|
37
|
+
suspect: name, likelyTarget: null, confidence: 'none',
|
|
38
|
+
reason: '', targetDownloads: 0, suspectDownloads: suspectDl,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for (const h of hits) {
|
|
42
|
+
if (h.name === name || h.name === bare) continue;
|
|
43
|
+
const cBare = stripScope(h.name);
|
|
44
|
+
const d = levenshtein(bare, cBare);
|
|
45
|
+
if (d === 0 || d > 2) continue;
|
|
46
|
+
if (d === 2 && bare.length < 5) continue;
|
|
47
|
+
|
|
48
|
+
const cDl = dlMap.get(h.name) || 0;
|
|
49
|
+
const ratio = cDl / Math.max(suspectDl, 1);
|
|
50
|
+
|
|
51
|
+
if (d === 1 && ratio >= 100) {
|
|
52
|
+
return {
|
|
53
|
+
suspect: name, likelyTarget: h.name, confidence: 'high' as const,
|
|
54
|
+
reason: `${h.name} has ${fmt(cDl)} weekly downloads vs ${fmt(suspectDl)}`,
|
|
55
|
+
targetDownloads: cDl, suspectDownloads: suspectDl,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (d <= 2 && ratio >= 500 && rankHigher(best, 'medium')) {
|
|
59
|
+
best = {
|
|
60
|
+
suspect: name, likelyTarget: h.name, confidence: 'medium',
|
|
61
|
+
reason: `similar to ${h.name} (${fmt(cDl)} weekly downloads)`,
|
|
62
|
+
targetDownloads: cDl, suspectDownloads: suspectDl,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (d === 1 && ratio >= 10 && rankHigher(best, 'low')) {
|
|
66
|
+
best = {
|
|
67
|
+
suspect: name, likelyTarget: h.name, confidence: 'low',
|
|
68
|
+
reason: `name close to ${h.name}`,
|
|
69
|
+
targetDownloads: cDl, suspectDownloads: suspectDl,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (detectSeparatorTrick(bare, cBare) && ratio >= 50 && rankHigher(best, 'medium')) {
|
|
73
|
+
best = {
|
|
74
|
+
suspect: name, likelyTarget: h.name, confidence: 'medium',
|
|
75
|
+
reason: `separator variation of ${h.name} (${fmt(cDl)} downloads)`,
|
|
76
|
+
targetDownloads: cDl, suspectDownloads: suspectDl,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return best;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface SearchHit { name: string }
|
|
85
|
+
|
|
86
|
+
async function searchSimilar(name: string): Promise<SearchHit[]> {
|
|
87
|
+
const res = await fetch(`${NPM_SEARCH}?text=${encodeURIComponent(name)}&size=10`);
|
|
88
|
+
if (!res.ok) return [];
|
|
89
|
+
const data = await res.json() as {
|
|
90
|
+
objects: Array<{ package: { name: string } }>;
|
|
91
|
+
};
|
|
92
|
+
return data.objects.map((o) => ({ name: o.package.name }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function fetchBulkDownloads(names: string[]): Promise<Map<string, number>> {
|
|
96
|
+
const map = new Map<string, number>();
|
|
97
|
+
const scoped: string[] = [];
|
|
98
|
+
const unscoped: string[] = [];
|
|
99
|
+
for (const n of names) (n.startsWith('@') ? scoped : unscoped).push(n);
|
|
100
|
+
|
|
101
|
+
if (unscoped.length > 0) {
|
|
102
|
+
const chunks = chunkArray(unscoped, 128);
|
|
103
|
+
const fetches = await Promise.allSettled(
|
|
104
|
+
chunks.map(async (chunk) => {
|
|
105
|
+
const res = await fetch(`${NPM_BULK_DL}/${chunk.join(',')}`);
|
|
106
|
+
if (!res.ok) return;
|
|
107
|
+
const data = await res.json() as Record<string, { downloads: number } | null>;
|
|
108
|
+
for (const [pkg, info] of Object.entries(data)) {
|
|
109
|
+
if (info?.downloads) map.set(pkg, info.downloads);
|
|
110
|
+
}
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const scopedFetches = await Promise.allSettled(
|
|
116
|
+
scoped.map(async (n) => {
|
|
117
|
+
const res = await fetch(`${NPM_BULK_DL}/${encodeURIComponent(n)}`);
|
|
118
|
+
if (!res.ok) return;
|
|
119
|
+
const data = await res.json() as { downloads: number };
|
|
120
|
+
if (data.downloads) map.set(n, data.downloads);
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return map;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function chunkArray<T>(arr: T[], size: number): T[][] {
|
|
128
|
+
const result: T[][] = [];
|
|
129
|
+
for (let i = 0; i < arr.length; i += size) result.push(arr.slice(i, i + size));
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function stripScope(name: string): string {
|
|
134
|
+
return name.startsWith('@') ? name.split('/').pop() || name : name;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function levenshtein(a: string, b: string): number {
|
|
138
|
+
const m = a.length, n = b.length;
|
|
139
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
140
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
141
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
142
|
+
for (let i = 1; i <= m; i++) {
|
|
143
|
+
for (let j = 1; j <= n; j++) {
|
|
144
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
145
|
+
? dp[i - 1][j - 1]
|
|
146
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return dp[m][n];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function detectSeparatorTrick(a: string, b: string): boolean {
|
|
153
|
+
const norm = (s: string) => s.replace(/[-_.]/g, '');
|
|
154
|
+
return norm(a) === norm(b) && a !== b;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const RANK = { none: 0, low: 1, medium: 2, high: 3 } as const;
|
|
158
|
+
function rankHigher(current: TyposquatResult, level: TyposquatResult['confidence']): boolean {
|
|
159
|
+
return RANK[level] > RANK[current.confidence];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function fmt(n: number): string {
|
|
163
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
164
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
165
|
+
return String(n);
|
|
166
|
+
}
|
|
@@ -1,10 +1,161 @@
|
|
|
1
|
-
import { getVersions } from './contract';
|
|
1
|
+
import { getVersions, getSafestVersion, getAuthorByENS } from './contract';
|
|
2
|
+
import { resolveAddress } from './ens';
|
|
3
|
+
import { queryOSV, getFixedVersion, type OSVVulnerability } from './osv';
|
|
4
|
+
|
|
5
|
+
export interface ResolvedVersion {
|
|
6
|
+
version: string;
|
|
7
|
+
source: 'explicit' | 'latest' | 'ens' | 'auto-bumped';
|
|
8
|
+
ensName?: string;
|
|
9
|
+
authorAddress?: string;
|
|
10
|
+
reason?: string;
|
|
11
|
+
originalVersion?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isENSVersion(version: string): boolean {
|
|
15
|
+
return version.endsWith('.eth');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function resolveVersion(
|
|
19
|
+
name: string,
|
|
20
|
+
version: string,
|
|
21
|
+
onStatus?: (msg: string) => void,
|
|
22
|
+
): Promise<ResolvedVersion> {
|
|
23
|
+
const log = onStatus || (() => {});
|
|
24
|
+
|
|
25
|
+
if (isENSVersion(version)) {
|
|
26
|
+
return resolveENSVersion(name, version, log);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (version && version !== 'latest') {
|
|
30
|
+
return { version, source: 'explicit' };
|
|
31
|
+
}
|
|
2
32
|
|
|
3
|
-
export async function resolveVersion(name: string, version: string): Promise<string> {
|
|
4
|
-
if (version && version !== 'latest') return version;
|
|
5
33
|
try {
|
|
6
34
|
const versions = await getVersions(name);
|
|
7
|
-
if (versions.length > 0)
|
|
35
|
+
if (versions.length > 0) {
|
|
36
|
+
return { version: versions[versions.length - 1], source: 'latest' };
|
|
37
|
+
}
|
|
8
38
|
} catch { /* no versions on-chain */ }
|
|
9
|
-
|
|
39
|
+
|
|
40
|
+
const npmVersion = await resolveNpmLatest(name);
|
|
41
|
+
return { version: npmVersion, source: 'latest' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function resolveENSVersion(
|
|
45
|
+
packageName: string,
|
|
46
|
+
ensName: string,
|
|
47
|
+
log: (msg: string) => void,
|
|
48
|
+
): Promise<ResolvedVersion> {
|
|
49
|
+
log(`Resolving ENS: ${ensName}`);
|
|
50
|
+
const authorAddr = await resolveAddress(ensName);
|
|
51
|
+
if (!authorAddr) {
|
|
52
|
+
throw new Error(`Cannot resolve ENS name: ${ensName}`);
|
|
53
|
+
}
|
|
54
|
+
log(`Address: ${authorAddr.slice(0, 6)}...${authorAddr.slice(-4)}`);
|
|
55
|
+
|
|
56
|
+
let authorOnChain = false;
|
|
57
|
+
try {
|
|
58
|
+
const profile = await getAuthorByENS(ensName);
|
|
59
|
+
authorOnChain = profile.addr !== '0x0000000000000000000000000000000000000000';
|
|
60
|
+
} catch { /* not registered */ }
|
|
61
|
+
|
|
62
|
+
if (!authorOnChain) {
|
|
63
|
+
throw new Error(`Author ${ensName} (${authorAddr.slice(0, 10)}...) is not registered on-chain`);
|
|
64
|
+
}
|
|
65
|
+
log(`Author verified on-chain ✓`);
|
|
66
|
+
|
|
67
|
+
let safestVersion: string | null = null;
|
|
68
|
+
try {
|
|
69
|
+
safestVersion = await getSafestVersion(packageName);
|
|
70
|
+
} catch { /* no on-chain versions */ }
|
|
71
|
+
|
|
72
|
+
if (safestVersion) {
|
|
73
|
+
log(`Safest on-chain version: ${safestVersion}`);
|
|
74
|
+
return {
|
|
75
|
+
version: safestVersion,
|
|
76
|
+
source: 'ens',
|
|
77
|
+
ensName,
|
|
78
|
+
authorAddress: authorAddr,
|
|
79
|
+
reason: `Safest on-chain version, author ${ensName} verified`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
log(`No on-chain scores, resolving latest from npm`);
|
|
84
|
+
const npmVersion = await resolveNpmLatest(packageName);
|
|
85
|
+
log(`Latest npm version: ${npmVersion}`);
|
|
86
|
+
return {
|
|
87
|
+
version: npmVersion,
|
|
88
|
+
source: 'ens',
|
|
89
|
+
ensName,
|
|
90
|
+
authorAddress: authorAddr,
|
|
91
|
+
reason: `Author ${ensName} verified, latest npm version (no on-chain scores)`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function findSafeVersion(
|
|
96
|
+
name: string,
|
|
97
|
+
unsafeVersion: string,
|
|
98
|
+
cves: OSVVulnerability[],
|
|
99
|
+
): Promise<ResolvedVersion | null> {
|
|
100
|
+
let safeVer: string | null = null;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
safeVer = await getSafestVersion(name);
|
|
104
|
+
} catch { /* no on-chain data */ }
|
|
105
|
+
|
|
106
|
+
if (safeVer && safeVer !== unsafeVersion) {
|
|
107
|
+
const safeCves = await queryOSV(name, safeVer).catch(() => []);
|
|
108
|
+
const hasCritical = safeCves.some((c) => {
|
|
109
|
+
const sev = c.database_specific?.severity || '';
|
|
110
|
+
return sev === 'CRITICAL' || sev === 'HIGH';
|
|
111
|
+
});
|
|
112
|
+
if (!hasCritical) {
|
|
113
|
+
return {
|
|
114
|
+
version: safeVer,
|
|
115
|
+
source: 'auto-bumped',
|
|
116
|
+
originalVersion: unsafeVersion,
|
|
117
|
+
reason: `On-chain safest version (original ${unsafeVersion} has vulnerabilities)`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let bestFix: string | null = null;
|
|
123
|
+
for (const cve of cves) {
|
|
124
|
+
const fix = getFixedVersion(cve, unsafeVersion);
|
|
125
|
+
if (fix && (!bestFix || compareSemver(fix, bestFix) > 0)) {
|
|
126
|
+
bestFix = fix;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (bestFix) {
|
|
131
|
+
return {
|
|
132
|
+
version: bestFix,
|
|
133
|
+
source: 'auto-bumped',
|
|
134
|
+
originalVersion: unsafeVersion,
|
|
135
|
+
reason: `Upgraded from ${unsafeVersion} to fix ${cves.length} CVE(s)`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function resolveNpmLatest(name: string): Promise<string> {
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`);
|
|
145
|
+
if (res.ok) {
|
|
146
|
+
const data = await res.json() as { version?: string };
|
|
147
|
+
if (data.version) return data.version;
|
|
148
|
+
}
|
|
149
|
+
} catch { /* npm registry unreachable */ }
|
|
150
|
+
return 'latest';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function compareSemver(a: string, b: string): number {
|
|
154
|
+
const pa = a.replace(/^v/, '').split('.').map(Number);
|
|
155
|
+
const pb = b.replace(/^v/, '').split('.').map(Number);
|
|
156
|
+
for (let i = 0; i < 3; i++) {
|
|
157
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
158
|
+
if (diff !== 0) return diff;
|
|
159
|
+
}
|
|
160
|
+
return 0;
|
|
10
161
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
pragma circom 2.1.0;
|
|
2
|
+
|
|
3
|
+
include "node_modules/circomlib/circuits/comparators.circom";
|
|
4
|
+
include "node_modules/circomlib/circuits/poseidon.circom";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AccuracyVerifier circuit for OPM agent registration.
|
|
8
|
+
*
|
|
9
|
+
* Proves that a candidate agent achieved 100% accuracy on a
|
|
10
|
+
* benchmark suite without revealing the individual test results
|
|
11
|
+
* or the expected outputs.
|
|
12
|
+
*
|
|
13
|
+
* Private inputs:
|
|
14
|
+
* - expected[N]: expected risk level ordinals (0=LOW, 1=MEDIUM, 2=HIGH, 3=CRITICAL)
|
|
15
|
+
* - actual[N]: actual risk level ordinals from the candidate agent
|
|
16
|
+
* - salt: random blinding factor for the commitment
|
|
17
|
+
*
|
|
18
|
+
* Public inputs:
|
|
19
|
+
* - commitmentHash: Poseidon hash of (salt, expected[0..N-1])
|
|
20
|
+
*
|
|
21
|
+
* Public outputs:
|
|
22
|
+
* - passed: 1 if all expected[i] == actual[i], 0 otherwise
|
|
23
|
+
* - proofHash: Poseidon hash binding the result to the commitment
|
|
24
|
+
*
|
|
25
|
+
* Compilation:
|
|
26
|
+
* circom accuracy_verifier.circom --r1cs --wasm --sym -o build/
|
|
27
|
+
*
|
|
28
|
+
* Trusted setup:
|
|
29
|
+
* snarkjs groth16 setup build/accuracy_verifier.r1cs pot12_final.ptau build/accuracy_verifier_0000.zkey
|
|
30
|
+
* snarkjs zkey contribute build/accuracy_verifier_0000.zkey build/accuracy_verifier_final.zkey --name="opm-ceremony"
|
|
31
|
+
* snarkjs zkey export verificationkey build/accuracy_verifier_final.zkey build/verification_key.json
|
|
32
|
+
*
|
|
33
|
+
* Prove:
|
|
34
|
+
* snarkjs groth16 prove build/accuracy_verifier_final.zkey build/accuracy_verifier_js/accuracy_verifier.wasm input.json build/proof.json build/public.json
|
|
35
|
+
*
|
|
36
|
+
* Verify:
|
|
37
|
+
* snarkjs groth16 verify build/verification_key.json build/public.json build/proof.json
|
|
38
|
+
*
|
|
39
|
+
* Export Solidity verifier:
|
|
40
|
+
* snarkjs zkey export solidityverifier build/accuracy_verifier_final.zkey contracts/AccuracyVerifier.sol
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
template AccuracyVerifier(N) {
|
|
44
|
+
// Private inputs
|
|
45
|
+
signal input expected[N];
|
|
46
|
+
signal input actual[N];
|
|
47
|
+
signal input salt;
|
|
48
|
+
|
|
49
|
+
// Public inputs
|
|
50
|
+
signal input commitmentHash;
|
|
51
|
+
|
|
52
|
+
// Public outputs
|
|
53
|
+
signal output passed;
|
|
54
|
+
signal output proofHash;
|
|
55
|
+
|
|
56
|
+
// Step 1: Verify commitment — hash(salt, expected[0..N-1]) must equal commitmentHash
|
|
57
|
+
// We chain Poseidon hashes since Poseidon has a limited arity
|
|
58
|
+
component commitHashers[N];
|
|
59
|
+
signal commitChain[N + 1];
|
|
60
|
+
commitChain[0] <== salt;
|
|
61
|
+
|
|
62
|
+
for (var i = 0; i < N; i++) {
|
|
63
|
+
commitHashers[i] = Poseidon(2);
|
|
64
|
+
commitHashers[i].inputs[0] <== commitChain[i];
|
|
65
|
+
commitHashers[i].inputs[1] <== expected[i];
|
|
66
|
+
commitChain[i + 1] <== commitHashers[i].out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Verify the commitment matches
|
|
70
|
+
commitChain[N] === commitmentHash;
|
|
71
|
+
|
|
72
|
+
// Step 2: Check equality for each test case
|
|
73
|
+
component isEq[N];
|
|
74
|
+
signal matchBits[N];
|
|
75
|
+
|
|
76
|
+
for (var i = 0; i < N; i++) {
|
|
77
|
+
isEq[i] = IsEqual();
|
|
78
|
+
isEq[i].in[0] <== expected[i];
|
|
79
|
+
isEq[i].in[1] <== actual[i];
|
|
80
|
+
matchBits[i] <== isEq[i].out; // 1 if match, 0 if mismatch
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Step 3: Compute product of all match bits (1 iff all match)
|
|
84
|
+
signal product[N];
|
|
85
|
+
product[0] <== matchBits[0];
|
|
86
|
+
for (var i = 1; i < N; i++) {
|
|
87
|
+
product[i] <== product[i - 1] * matchBits[i];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
passed <== product[N - 1];
|
|
91
|
+
|
|
92
|
+
// Step 4: Compute proof hash binding result to commitment
|
|
93
|
+
component proofHasher = Poseidon(3);
|
|
94
|
+
proofHasher.inputs[0] <== commitmentHash;
|
|
95
|
+
proofHasher.inputs[1] <== passed;
|
|
96
|
+
proofHasher.inputs[2] <== salt;
|
|
97
|
+
proofHash <== proofHasher.out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Instantiate with 10 benchmark test cases
|
|
101
|
+
component main { public [commitmentHash] } = AccuracyVerifier(10);
|
|
@@ -47,6 +47,27 @@ contract OPMRegistry {
|
|
|
47
47
|
event ReportURISet(string name, string version, string uri);
|
|
48
48
|
event AuthorRegistered(address addr, string ensName);
|
|
49
49
|
event AgentAuthorized(address agent, bool status);
|
|
50
|
+
event AgentRegistered(
|
|
51
|
+
address indexed agent,
|
|
52
|
+
string name,
|
|
53
|
+
string model,
|
|
54
|
+
bytes32 systemPromptHash,
|
|
55
|
+
bytes32 proofHash,
|
|
56
|
+
uint256 timestamp
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
struct RegisteredAgent {
|
|
60
|
+
address agentAddress;
|
|
61
|
+
string name;
|
|
62
|
+
string model;
|
|
63
|
+
bytes32 systemPromptHash;
|
|
64
|
+
bytes32 proofHash;
|
|
65
|
+
uint256 registeredAt;
|
|
66
|
+
bool active;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
mapping(address => RegisteredAgent) public registeredAgents;
|
|
70
|
+
address[] public agentRegistry;
|
|
50
71
|
|
|
51
72
|
modifier onlyOwner() {
|
|
52
73
|
require(msg.sender == owner, "Not owner");
|
|
@@ -250,4 +271,46 @@ contract OPMRegistry {
|
|
|
250
271
|
if (a.reputationCount == 0) return 0;
|
|
251
272
|
return a.reputationTotal / a.reputationCount;
|
|
252
273
|
}
|
|
274
|
+
|
|
275
|
+
function registerAgent(
|
|
276
|
+
string calldata name,
|
|
277
|
+
string calldata model,
|
|
278
|
+
bytes32 systemPromptHash,
|
|
279
|
+
bytes32 proofHash
|
|
280
|
+
) external {
|
|
281
|
+
require(!authorizedAgents[msg.sender], "Agent already authorized");
|
|
282
|
+
require(registeredAgents[msg.sender].agentAddress == address(0), "Agent already registered");
|
|
283
|
+
require(proofHash != bytes32(0), "Invalid proof");
|
|
284
|
+
|
|
285
|
+
registeredAgents[msg.sender] = RegisteredAgent({
|
|
286
|
+
agentAddress: msg.sender,
|
|
287
|
+
name: name,
|
|
288
|
+
model: model,
|
|
289
|
+
systemPromptHash: systemPromptHash,
|
|
290
|
+
proofHash: proofHash,
|
|
291
|
+
registeredAt: block.timestamp,
|
|
292
|
+
active: true
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
agentRegistry.push(msg.sender);
|
|
296
|
+
authorizedAgents[msg.sender] = true;
|
|
297
|
+
|
|
298
|
+
emit AgentRegistered(msg.sender, name, model, systemPromptHash, proofHash, block.timestamp);
|
|
299
|
+
emit AgentAuthorized(msg.sender, true);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function getRegisteredAgent(address agent) external view returns (RegisteredAgent memory) {
|
|
303
|
+
return registeredAgents[agent];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function getAgentCount() external view returns (uint256) {
|
|
307
|
+
return agentRegistry.length;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function revokeAgent(address agent) external onlyOwner {
|
|
311
|
+
require(registeredAgents[agent].active, "Agent not active");
|
|
312
|
+
registeredAgents[agent].active = false;
|
|
313
|
+
authorizedAgents[agent] = false;
|
|
314
|
+
emit AgentAuthorized(agent, false);
|
|
315
|
+
}
|
|
253
316
|
}
|
|
@@ -6,6 +6,11 @@ async function main() {
|
|
|
6
6
|
|
|
7
7
|
const OPMRegistry = await ethers.getContractFactory("OPMRegistry");
|
|
8
8
|
const registry = await OPMRegistry.deploy();
|
|
9
|
+
const deployTx = registry.deploymentTransaction();
|
|
10
|
+
if (deployTx) {
|
|
11
|
+
console.log("Deploy tx:", deployTx.hash);
|
|
12
|
+
await deployTx.wait(2);
|
|
13
|
+
}
|
|
9
14
|
await registry.waitForDeployment();
|
|
10
15
|
|
|
11
16
|
const address = await registry.getAddress();
|
|
@@ -14,9 +19,23 @@ async function main() {
|
|
|
14
19
|
const agentKey = process.env.AGENT_PRIVATE_KEY;
|
|
15
20
|
if (agentKey) {
|
|
16
21
|
const agentWallet = new ethers.Wallet(agentKey);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
console.log("Authorizing agent:", agentWallet.address);
|
|
23
|
+
|
|
24
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
25
|
+
try {
|
|
26
|
+
const tx = await registry.setAgent(agentWallet.address, true);
|
|
27
|
+
await tx.wait(2);
|
|
28
|
+
console.log("Authorized agent:", agentWallet.address);
|
|
29
|
+
break;
|
|
30
|
+
} catch (err: any) {
|
|
31
|
+
if (attempt < 2 && err?.message?.includes("nonce")) {
|
|
32
|
+
console.log(`Nonce conflict, retrying (${attempt + 1}/3)...`);
|
|
33
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
34
|
+
} else {
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
console.log("\nAdd to .env:\nCONTRACT_ADDRESS=" + address);
|