opmsec 0.1.3 → 0.1.5

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.
Files changed (166) hide show
  1. package/.env.example +1 -0
  2. package/.husky/pre-commit +1 -0
  3. package/README.md +71 -275
  4. package/bun.lock +5 -5
  5. package/docs/architecture/agents.mdx +11 -59
  6. package/docs/architecture/benchmarks.mdx +20 -46
  7. package/docs/architecture/overview.mdx +31 -38
  8. package/docs/architecture/scanner.mdx +11 -37
  9. package/docs/cli/audit.mdx +9 -12
  10. package/docs/cli/check.mdx +12 -26
  11. package/docs/cli/fix.mdx +10 -30
  12. package/docs/cli/info.mdx +12 -19
  13. package/docs/cli/install.mdx +27 -39
  14. package/docs/cli/push.mdx +40 -57
  15. package/docs/cli/register-agent.mdx +21 -53
  16. package/docs/cli/view.mdx +12 -29
  17. package/docs/concepts/ens-records.mdx +44 -0
  18. package/docs/concepts/multi-agent-consensus.mdx +18 -36
  19. package/docs/concepts/on-chain-registry.mdx +22 -49
  20. package/docs/concepts/security-model.mdx +20 -52
  21. package/docs/concepts/zk-agent-verification.mdx +26 -64
  22. package/docs/contract/events.mdx +13 -74
  23. package/docs/contract/functions.mdx +40 -126
  24. package/docs/contract/overview.mdx +17 -36
  25. package/docs/introduction.mdx +22 -25
  26. package/docs/mint.json +3 -2
  27. package/docs/quickstart.mdx +34 -70
  28. package/docs/system-design.png +0 -0
  29. package/package.json +7 -6
  30. package/packages/cli/src/commands/author-view.tsx +87 -2
  31. package/packages/cli/src/commands/check.tsx +18 -5
  32. package/packages/cli/src/commands/fix.tsx +25 -12
  33. package/packages/cli/src/commands/info.tsx +92 -4
  34. package/packages/cli/src/commands/install.tsx +327 -23
  35. package/packages/cli/src/commands/push.tsx +112 -0
  36. package/packages/cli/src/commands/register-agent.tsx +72 -31
  37. package/packages/cli/src/index.tsx +7 -5
  38. package/packages/cli/src/services/ens-records.ts +525 -0
  39. package/packages/cli/src/services/version.ts +156 -5
  40. package/packages/core/src/benchmarks.ts +116 -0
  41. package/packages/core/src/constants.ts +18 -6
  42. package/packages/core/src/model-rankings.ts +40 -15
  43. package/packages/core/src/types.ts +10 -0
  44. package/packages/core/src/utils.ts +136 -1
  45. package/packages/scanner/src/index.ts +2 -1
  46. package/packages/scanner/src/queue/memory-queue.ts +7 -2
  47. package/packages/scanner/src/services/benchmark-runner.ts +86 -1
  48. package/packages/scanner/src/services/fileverse.ts +61 -12
  49. package/packages/scanner/src/services/openrouter.ts +18 -7
  50. package/packages/web/.next/BUILD_ID +1 -0
  51. package/packages/web/.next/app-path-routes-manifest.json +4 -0
  52. package/packages/web/.next/diagnostics/build-diagnostics.json +6 -0
  53. package/packages/web/.next/diagnostics/framework.json +1 -0
  54. package/packages/web/.next/export-marker.json +6 -0
  55. package/packages/web/.next/images-manifest.json +58 -0
  56. package/packages/web/.next/next-minimal-server.js.nft.json +1 -0
  57. package/packages/web/.next/next-server.js.nft.json +1 -0
  58. package/packages/web/.next/prerender-manifest.json +54 -4
  59. package/packages/web/.next/required-server-files.json +320 -0
  60. package/packages/web/.next/routes-manifest.json +53 -1
  61. package/packages/web/.next/server/app/_not-found/page.js +2 -0
  62. package/packages/web/.next/server/app/_not-found/page.js.nft.json +1 -0
  63. package/packages/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  64. package/packages/web/.next/server/app/_not-found.html +1 -0
  65. package/packages/web/.next/server/app/_not-found.meta +8 -0
  66. package/packages/web/.next/server/app/_not-found.rsc +18 -0
  67. package/packages/web/.next/server/app/index.html +6 -0
  68. package/packages/web/.next/server/app/index.meta +7 -0
  69. package/packages/web/.next/server/app/index.rsc +22 -0
  70. package/packages/web/.next/server/app/page.js +24 -24
  71. package/packages/web/.next/server/app/page.js.nft.json +1 -0
  72. package/packages/web/.next/server/app/page_client-reference-manifest.js +1 -1
  73. package/packages/web/.next/server/chunks/611.js +6 -0
  74. package/packages/web/.next/server/chunks/778.js +30 -0
  75. package/packages/web/.next/server/functions-config-manifest.json +4 -0
  76. package/packages/web/.next/server/interception-route-rewrite-manifest.js +1 -1
  77. package/packages/web/.next/server/next-font-manifest.js +1 -1
  78. package/packages/web/.next/server/next-font-manifest.json +1 -1
  79. package/packages/web/.next/server/pages/404.html +1 -0
  80. package/packages/web/.next/server/pages/500.html +1 -0
  81. package/packages/web/.next/server/pages/_app.js +1 -0
  82. package/packages/web/.next/server/pages/_app.js.nft.json +1 -0
  83. package/packages/web/.next/server/pages/_document.js +1 -0
  84. package/packages/web/.next/server/pages/_document.js.nft.json +1 -0
  85. package/packages/web/.next/server/pages/_error.js +19 -0
  86. package/packages/web/.next/server/pages/_error.js.nft.json +1 -0
  87. package/packages/web/.next/server/webpack-runtime.js +2 -2
  88. package/packages/web/.next/static/0esGzFBCzREfVwijEGDfL/_buildManifest.js +1 -0
  89. package/packages/web/.next/static/0esGzFBCzREfVwijEGDfL/_ssgManifest.js +1 -0
  90. package/packages/web/.next/static/chunks/174-5b5efcb3b8efcc01.js +1 -0
  91. package/packages/web/.next/static/chunks/255-0dc49b7a6e8e5c05.js +1 -0
  92. package/packages/web/.next/static/chunks/4bd1b696-382748cc942d8a14.js +1 -0
  93. package/packages/web/.next/static/chunks/app/_not-found/page-0da542be7eb33a64.js +1 -0
  94. package/packages/web/.next/static/chunks/app/layout-de8e841104500505.js +1 -0
  95. package/packages/web/.next/static/chunks/app/layout.js +37 -7
  96. package/packages/web/.next/static/chunks/app/page-7e086379698b9fb0.js +1 -0
  97. package/packages/web/.next/static/chunks/app/page.js +297 -1
  98. package/packages/web/.next/static/chunks/framework-ac73abd125e371fe.js +1 -0
  99. package/packages/web/.next/static/chunks/main-4e8d71b5ef7ee7e3.js +1 -0
  100. package/packages/web/.next/static/chunks/main-app-dd261207182e5a23.js +1 -0
  101. package/packages/web/.next/static/chunks/pages/_app-7d307437aca18ad4.js +1 -0
  102. package/packages/web/.next/static/chunks/pages/_error-cb2a52f75f2162e2.js +1 -0
  103. package/packages/web/.next/static/chunks/webpack-0dcd67569eb46132.js +1 -0
  104. package/packages/web/.next/static/chunks/webpack.js +2 -2
  105. package/packages/web/.next/static/css/102562cf2d0ae9b0.css +3 -0
  106. package/packages/web/.next/static/media/4cf2300e9c8272f7-s.p.woff2 +0 -0
  107. package/packages/web/.next/static/media/747892c23ea88013-s.woff2 +0 -0
  108. package/packages/web/.next/static/media/8d697b304b401681-s.woff2 +0 -0
  109. package/packages/web/.next/static/media/93f479601ee12b01-s.p.woff2 +0 -0
  110. package/packages/web/.next/static/media/9610d9e46709d722-s.woff2 +0 -0
  111. package/packages/web/.next/static/media/ba015fad6dcf6784-s.woff2 +0 -0
  112. package/packages/web/.next/static/webpack/16f18baa938a434c.webpack.hot-update.json +1 -0
  113. package/packages/web/.next/static/webpack/5fe9fe8578f9c3d2.webpack.hot-update.json +1 -0
  114. package/packages/web/.next/static/webpack/73c7d02260cc80e4.webpack.hot-update.json +1 -0
  115. package/packages/web/.next/static/webpack/a2d85d19aa028de1.webpack.hot-update.json +1 -0
  116. package/packages/web/.next/static/webpack/app/{layout.73e341375c8d429e.hot-update.js → layout.16f18baa938a434c.hot-update.js} +1 -1
  117. package/packages/web/.next/static/webpack/app/{layout.6fee6306e0f98869.hot-update.js → layout.5fe9fe8578f9c3d2.hot-update.js} +1 -1
  118. package/packages/web/.next/static/webpack/app/layout.653e365406c0d9ac.hot-update.js +22 -0
  119. package/packages/web/.next/static/webpack/app/layout.6800169a899e3a8b.hot-update.js +22 -0
  120. package/packages/web/.next/static/webpack/app/layout.73c7d02260cc80e4.hot-update.js +22 -0
  121. package/packages/web/.next/static/webpack/app/layout.a2d85d19aa028de1.hot-update.js +22 -0
  122. package/packages/web/.next/static/webpack/app/page.653e365406c0d9ac.hot-update.js +22 -0
  123. package/packages/web/.next/static/webpack/app/page.6800169a899e3a8b.hot-update.js +22 -0
  124. package/packages/web/.next/static/webpack/app/page.73c7d02260cc80e4.hot-update.js +22 -0
  125. package/packages/web/.next/static/webpack/app/page.a2d85d19aa028de1.hot-update.js +22 -0
  126. package/packages/web/.next/static/webpack/{webpack.6fee6306e0f98869.hot-update.js → webpack.16f18baa938a434c.hot-update.js} +2 -2
  127. package/packages/web/.next/static/webpack/{webpack.73e341375c8d429e.hot-update.js → webpack.5fe9fe8578f9c3d2.hot-update.js} +2 -2
  128. package/packages/web/.next/static/webpack/webpack.653e365406c0d9ac.hot-update.js +12 -0
  129. package/packages/web/.next/static/webpack/webpack.6800169a899e3a8b.hot-update.js +12 -0
  130. package/packages/web/.next/static/webpack/webpack.73c7d02260cc80e4.hot-update.js +12 -0
  131. package/packages/web/.next/static/webpack/webpack.a2d85d19aa028de1.hot-update.js +12 -0
  132. package/packages/web/.next/trace +2 -5
  133. package/packages/web/app/globals.css +197 -51
  134. package/packages/web/app/layout.tsx +6 -3
  135. package/packages/web/app/page.tsx +791 -309
  136. package/packages/web/bun.lock +66 -105
  137. package/packages/web/next.config.ts +8 -1
  138. package/packages/web/package.json +5 -2
  139. package/packages/web/postcss.config.mjs +2 -2
  140. package/packages/web/public/apple-icon.png +1 -0
  141. package/packages/web/public/dependency-bottleneck.png +0 -0
  142. package/packages/web/public/icon-dark-32x32.png +1 -0
  143. package/packages/web/public/icon-light-32x32.png +1 -0
  144. package/packages/web/public/icon.svg +1 -0
  145. package/packages/web/public/nextjs-cve-announcement.png +0 -0
  146. package/packages/web/public/phantomraven-npm-attack.png +0 -0
  147. package/packages/web/public/placeholder-logo.png +1 -0
  148. package/packages/web/public/placeholder-logo.svg +1 -0
  149. package/packages/web/public/placeholder-user.jpg +1 -0
  150. package/packages/web/public/placeholder.jpg +1 -0
  151. package/packages/web/public/placeholder.svg +1 -0
  152. package/packages/web/public/react-cve-meme.png +0 -0
  153. package/packages/web/public/wallet-drain-exploit.png +0 -0
  154. package/packages/web/styles/globals.css +125 -0
  155. package/packages/web/.next/server/vendor-chunks/@swc.js +0 -55
  156. package/packages/web/.next/server/vendor-chunks/next.js +0 -3010
  157. package/packages/web/.next/static/chunks/app-pages-internals.js +0 -182
  158. package/packages/web/.next/static/chunks/main-app.js +0 -1882
  159. package/packages/web/.next/static/css/app/layout.css +0 -1237
  160. package/packages/web/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
  161. package/packages/web/.next/static/webpack/app/page.6fee6306e0f98869.hot-update.js +0 -22
  162. package/packages/web/.next/static/webpack/app/page.73e341375c8d429e.hot-update.js +0 -22
  163. package/packages/web/tailwind.config.ts +0 -48
  164. /package/packages/web/.next/static/chunks/{polyfills.js → polyfills-42372ed130431b0a.js} +0 -0
  165. /package/packages/web/.next/static/webpack/{6fee6306e0f98869.webpack.hot-update.json → 653e365406c0d9ac.webpack.hot-update.json} +0 -0
  166. /package/packages/web/.next/static/webpack/{73e341375c8d429e.webpack.hot-update.json → 6800169a899e3a8b.webpack.hot-update.json} +0 -0
@@ -0,0 +1,525 @@
1
+ import { ethers } from 'ethers';
2
+ import {
3
+ getEnvOrDefault,
4
+ ETH_SEPOLIA_RPC,
5
+ ETH_MAINNET_RPC,
6
+ DEFAULT_CONTRACT_ADDRESS,
7
+ ENS_REGISTRY_ADDRESS,
8
+ OPM_ENS_KEYS,
9
+ } from '@opm/core';
10
+ import type { OPMENSRecords } from '@opm/core';
11
+
12
+ const RESOLVER_ABI = [
13
+ 'function setText(bytes32 node, string key, string value) external',
14
+ 'function text(bytes32 node, string key) view returns (string)',
15
+ 'function setContenthash(bytes32 node, bytes calldata hash) external',
16
+ 'function contenthash(bytes32 node) view returns (bytes)',
17
+ 'function multicall(bytes[] calldata data) external returns (bytes[] memory)',
18
+ ];
19
+
20
+ const REGISTRY_ABI = [
21
+ 'function resolver(bytes32 node) view returns (address)',
22
+ 'function owner(bytes32 node) view returns (address)',
23
+ 'function setSubnodeRecord(bytes32 node, bytes32 label, address owner, address resolver, uint64 ttl) external',
24
+ ];
25
+
26
+ const FILEVERSE_PORTAL_ABI = [
27
+ 'function files(uint256) view returns (string metadataIPFSHash, string contentIPFSHash, string gateIPFSHash, uint8 fileType, uint256 version)',
28
+ ];
29
+
30
+ const FILEVERSE_RPCS = [
31
+ 'https://rpc.gnosischain.com',
32
+ 'https://ethereum-sepolia-rpc.publicnode.com',
33
+ 'https://eth.llamarpc.com',
34
+ 'https://sepolia.base.org',
35
+ ];
36
+
37
+ type ChainLabel = 'sepolia' | 'mainnet';
38
+
39
+ function getProviders(): Array<{ label: ChainLabel; provider: ethers.JsonRpcProvider }> {
40
+ return [
41
+ {
42
+ label: 'sepolia',
43
+ provider: new ethers.JsonRpcProvider(
44
+ getEnvOrDefault('ETH_SEPOLIA_RPC_URL', ETH_SEPOLIA_RPC),
45
+ ),
46
+ },
47
+ {
48
+ label: 'mainnet',
49
+ provider: new ethers.JsonRpcProvider(
50
+ getEnvOrDefault('ETH_MAINNET_RPC_URL', ETH_MAINNET_RPC),
51
+ ),
52
+ },
53
+ ];
54
+ }
55
+
56
+ async function findResolver(ensName: string): Promise<{
57
+ provider: ethers.JsonRpcProvider;
58
+ resolverAddress: string;
59
+ chain: ChainLabel;
60
+ } | null> {
61
+ for (const { label, provider } of getProviders()) {
62
+ try {
63
+ const resolver = await provider.getResolver(ensName);
64
+ if (resolver) {
65
+ return { provider, resolverAddress: resolver.address, chain: label };
66
+ }
67
+ } catch { /* try next chain */ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function sanitizeLabel(name: string): string {
73
+ return name.replace(/[^a-zA-Z0-9-]/g, '-').replace(/^-+|-+$/g, '');
74
+ }
75
+
76
+ export function pkgRecordKey(packageName: string, field: string): string {
77
+ return `opm.pkg.${sanitizeLabel(packageName)}.${field}`;
78
+ }
79
+
80
+ /**
81
+ * Writes OPM text records to an ENS name's resolver.
82
+ * The signer (privateKey) must be the manager/owner of the ENS name.
83
+ */
84
+ export async function writeENSRecords(
85
+ ensName: string,
86
+ privateKey: string,
87
+ records: Record<string, string>,
88
+ onStatus?: (msg: string) => void,
89
+ ): Promise<{ txHash: string; chain: ChainLabel; recordCount: number } | null> {
90
+ const log = onStatus || (() => {});
91
+
92
+ const resolved = await findResolver(ensName);
93
+ if (!resolved) {
94
+ log(`No resolver found for ${ensName}`);
95
+ return null;
96
+ }
97
+
98
+ const { provider, resolverAddress, chain } = resolved;
99
+ const wallet = new ethers.Wallet(privateKey, provider);
100
+ const node = ethers.namehash(ensName);
101
+ const resolver = new ethers.Contract(resolverAddress, RESOLVER_ABI, wallet);
102
+
103
+ const balance = await provider.getBalance(wallet.address).catch(() => 0n);
104
+ const balanceEth = ethers.formatEther(balance);
105
+ log(`Signer ${wallet.address.slice(0, 6)}...${wallet.address.slice(-4)} has ${balanceEth} ETH on ${chain}`);
106
+
107
+ if (balance === 0n) {
108
+ log(`No ETH on ${chain} — fund this wallet on Ethereum ${chain} (not Base ${chain})`);
109
+ return null;
110
+ }
111
+
112
+ const entries = Object.entries(records).filter(([, v]) => v !== undefined && v !== '');
113
+ if (entries.length === 0) return null;
114
+
115
+ log(`Writing ${entries.length} record(s) to ${ensName} on ${chain}`);
116
+
117
+ try {
118
+ if (entries.length > 1) {
119
+ try {
120
+ const iface = new ethers.Interface(RESOLVER_ABI);
121
+ const calldata = entries.map(([key, value]) =>
122
+ iface.encodeFunctionData('setText', [node, key, value]),
123
+ );
124
+ const tx = await resolver.multicall(calldata);
125
+ const receipt = await tx.wait();
126
+ log(`${entries.length} records set via multicall`);
127
+ return { txHash: receipt.hash, chain, recordCount: entries.length };
128
+ } catch (mcErr: any) {
129
+ log(`Multicall failed, trying individual writes...`);
130
+ }
131
+ }
132
+
133
+ let lastHash = '';
134
+ for (const [key, value] of entries) {
135
+ const tx = await resolver.setText(node, key, value);
136
+ const receipt = await tx.wait();
137
+ lastHash = receipt.hash;
138
+ log(`Set ${key}`);
139
+ }
140
+ return { txHash: lastHash, chain, recordCount: entries.length };
141
+ } catch (err: any) {
142
+ const raw = err?.shortMessage || err?.message || 'unknown error';
143
+ log(`Error: ${raw.slice(0, 150)}`);
144
+ if (raw.includes('insufficient funds')) {
145
+ log(`Balance: ${balanceEth} ETH on ${chain} — may not cover gas for ${entries.length} record(s)`);
146
+ } else if (raw.includes('reverted')) {
147
+ log(`Resolver reverted — signer may not be the manager of ${ensName} (check if name is wrapped)`);
148
+ }
149
+ return null;
150
+ }
151
+ }
152
+
153
+ export async function readOPMRecords(ensName: string): Promise<OPMENSRecords> {
154
+ for (const { provider } of getProviders()) {
155
+ try {
156
+ const resolver = await provider.getResolver(ensName);
157
+ if (!resolver) continue;
158
+
159
+ const node = ethers.namehash(ensName);
160
+ const resolverContract = new ethers.Contract(
161
+ resolver.address, RESOLVER_ABI, provider,
162
+ );
163
+
164
+ const keys = Object.values(OPM_ENS_KEYS);
165
+ const results = await Promise.allSettled(
166
+ keys.map((key) => resolverContract.text(node, key)),
167
+ );
168
+
169
+ const records: OPMENSRecords = {};
170
+ const fieldNames = Object.keys(OPM_ENS_KEYS) as (keyof OPMENSRecords)[];
171
+
172
+ let hasAny = false;
173
+ for (let i = 0; i < results.length; i++) {
174
+ if (results[i].status === 'fulfilled') {
175
+ const value = (results[i] as PromiseFulfilledResult<string>).value;
176
+ if (value) {
177
+ records[fieldNames[i]] = value;
178
+ hasAny = true;
179
+ }
180
+ }
181
+ }
182
+
183
+ if (hasAny) return records;
184
+ } catch { /* try next chain */ }
185
+ }
186
+ return {};
187
+ }
188
+
189
+ export async function readPackageENSRecords(
190
+ ensName: string,
191
+ packageName: string,
192
+ ): Promise<OPMENSRecords> {
193
+ const fields = ['version', 'checksum', 'fileverse', 'risk_score', 'signature'];
194
+ const keys = fields.map((f) => pkgRecordKey(packageName, f));
195
+
196
+ for (const { provider } of getProviders()) {
197
+ try {
198
+ const resolver = await provider.getResolver(ensName);
199
+ if (!resolver) continue;
200
+
201
+ const node = ethers.namehash(ensName);
202
+ const resolverContract = new ethers.Contract(
203
+ resolver.address, RESOLVER_ABI, provider,
204
+ );
205
+
206
+ const results = await Promise.allSettled(
207
+ keys.map((key) => resolverContract.text(node, key)),
208
+ );
209
+
210
+ const mapped: (keyof OPMENSRecords)[] = [
211
+ 'version', 'checksum', 'fileverse', 'riskScore', 'signature',
212
+ ];
213
+ const records: OPMENSRecords = {};
214
+ let hasAny = false;
215
+
216
+ for (let i = 0; i < results.length; i++) {
217
+ if (results[i].status === 'fulfilled') {
218
+ const value = (results[i] as PromiseFulfilledResult<string>).value;
219
+ if (value) {
220
+ records[mapped[i]] = value;
221
+ hasAny = true;
222
+ }
223
+ }
224
+ }
225
+
226
+ if (hasAny) return records;
227
+ } catch { /* try next chain */ }
228
+ }
229
+ return {};
230
+ }
231
+
232
+ /**
233
+ * Creates a subname under a parent ENS name and writes OPM text records to it.
234
+ * Requires ownership of the parent name.
235
+ * Example: createPackageSubname('djpai.eth', 'express', key, records) → express.djpai.eth
236
+ */
237
+ export async function createPackageSubname(
238
+ parentName: string,
239
+ packageName: string,
240
+ privateKey: string,
241
+ records: Record<string, string>,
242
+ onStatus?: (msg: string) => void,
243
+ ): Promise<{ txHash: string; subname: string; chain: ChainLabel } | null> {
244
+ const log = onStatus || (() => {});
245
+
246
+ const label = sanitizeLabel(packageName);
247
+ const subname = `${label}.${parentName}`;
248
+
249
+ const resolved = await findResolver(parentName);
250
+ if (!resolved) {
251
+ log(`No resolver found for parent ${parentName}`);
252
+ return null;
253
+ }
254
+
255
+ const { provider, resolverAddress, chain } = resolved;
256
+ const wallet = new ethers.Wallet(privateKey, provider);
257
+
258
+ const registry = new ethers.Contract(ENS_REGISTRY_ADDRESS, REGISTRY_ABI, wallet);
259
+ const parentNode = ethers.namehash(parentName);
260
+ const labelHash = ethers.keccak256(ethers.toUtf8Bytes(label));
261
+
262
+ try {
263
+ const owner = await registry.owner(parentNode);
264
+ if (owner.toLowerCase() !== wallet.address.toLowerCase()) {
265
+ log(`Registry owner of ${parentName}: ${owner.slice(0, 10)}... (name may be wrapped via NameWrapper)`);
266
+ return null;
267
+ }
268
+
269
+ log(`Creating subname ${subname} on ${chain}`);
270
+ const tx = await registry.setSubnodeRecord(
271
+ parentNode,
272
+ labelHash,
273
+ wallet.address,
274
+ resolverAddress,
275
+ 0,
276
+ );
277
+ const receipt = await tx.wait();
278
+ log(`Subname created: ${subname}`);
279
+
280
+ if (Object.keys(records).length > 0) {
281
+ const writeResult = await writeENSRecords(subname, privateKey, records, log);
282
+ if (writeResult) {
283
+ return { txHash: writeResult.txHash, subname, chain };
284
+ }
285
+ }
286
+
287
+ return { txHash: receipt.hash, subname, chain };
288
+ } catch (err: any) {
289
+ const msg = err?.shortMessage || err?.message || 'unknown error';
290
+ log(`Subname failed: ${msg.slice(0, 120)}`);
291
+ return null;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Encodes an IPFS CID (v0 "Qm..." or v1 "bafy...") into ENS contenthash bytes.
297
+ * Format: 0xe301 (IPFS namespace varint) + CID bytes
298
+ */
299
+ export function encodeIPFSContenthash(cid: string): string {
300
+ const IPFS_NAMESPACE = 'e301';
301
+
302
+ if (cid.startsWith('Qm')) {
303
+ const decoded = ethers.decodeBase58(cid);
304
+ let hex = decoded.toString(16);
305
+ if (hex.length % 2 !== 0) hex = '0' + hex;
306
+ return '0x' + IPFS_NAMESPACE + '0170' + hex;
307
+ }
308
+
309
+ if (cid.startsWith('bafy')) {
310
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
311
+ const stripped = cid.slice(1); // remove multibase prefix 'b'
312
+ let bits = '';
313
+ for (const c of stripped) {
314
+ const val = alphabet.indexOf(c);
315
+ if (val === -1) throw new Error(`Invalid base32 character: ${c}`);
316
+ bits += val.toString(2).padStart(5, '0');
317
+ }
318
+ const bytes: number[] = [];
319
+ for (let i = 0; i + 8 <= bits.length; i += 8) {
320
+ bytes.push(parseInt(bits.slice(i, i + 8), 2));
321
+ }
322
+ const hex = bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
323
+ return '0x' + IPFS_NAMESPACE + hex;
324
+ }
325
+
326
+ throw new Error(`Unsupported CID format: ${cid.slice(0, 10)}...`);
327
+ }
328
+
329
+ /**
330
+ * Decodes an ENS contenthash back to a human-readable string.
331
+ * Returns the protocol and hash (e.g. "ipfs://Qm...")
332
+ */
333
+ export function decodeContenthash(hex: string): string | null {
334
+ if (!hex || hex === '0x') return null;
335
+ const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
336
+ if (clean.startsWith('e301')) {
337
+ const cidBytes = clean.slice(4);
338
+ // CIDv1 raw (0155) with sha256 (1220)
339
+ if (cidBytes.startsWith('0155') && cidBytes.length >= 72) {
340
+ const sha256Hex = cidBytes.slice(8);
341
+ return `ipfs://bafkrei...${sha256Hex.slice(0, 12)}`;
342
+ }
343
+ // CIDv1 dag-pb (0170) with sha256 (1220)
344
+ if (cidBytes.startsWith('0170') && cidBytes.length >= 72) {
345
+ const sha256Hex = cidBytes.slice(8);
346
+ return `ipfs://Qm...${sha256Hex.slice(0, 12)}`;
347
+ }
348
+ return `ipfs://${cidBytes.slice(0, 16)}...`;
349
+ }
350
+ if (clean.startsWith('e501')) {
351
+ return `ipns://${clean.slice(4, 20)}...`;
352
+ }
353
+ return null;
354
+ }
355
+
356
+ /**
357
+ * Computes an IPFS-compatible contenthash from arbitrary content.
358
+ * Uses SHA-256 with CIDv1 raw codec (0x55) — the same encoding as `ipfs add --raw-leaves`.
359
+ * Returns the contenthash hex string (0xe301...) for ENS.
360
+ */
361
+ export function computeContenthashFromContent(content: string): string {
362
+ const hash = ethers.sha256(ethers.toUtf8Bytes(content)).slice(2);
363
+ // CIDv1: version(01) + raw codec(55) + sha256 multihash(1220 + hash)
364
+ return '0xe3010155' + '1220' + hash;
365
+ }
366
+
367
+ /**
368
+ * Sets the ENS contenthash.
369
+ * Accepts either a raw IPFS CID (Qm.../bafy...) or pre-encoded contenthash bytes.
370
+ */
371
+ export async function setENSContenthash(
372
+ ensName: string,
373
+ privateKey: string,
374
+ contenthashOrCid: string,
375
+ onStatus?: (msg: string) => void,
376
+ ): Promise<{ txHash: string; chain: ChainLabel } | null> {
377
+ const log = onStatus || (() => {});
378
+
379
+ const resolved = await findResolver(ensName);
380
+ if (!resolved) {
381
+ log(`No resolver found for ${ensName}`);
382
+ return null;
383
+ }
384
+
385
+ const { provider, resolverAddress, chain } = resolved;
386
+ const wallet = new ethers.Wallet(privateKey, provider);
387
+ const node = ethers.namehash(ensName);
388
+ const resolver = new ethers.Contract(resolverAddress, RESOLVER_ABI, wallet);
389
+
390
+ try {
391
+ let cid = contenthashOrCid;
392
+ if (cid.startsWith('ipfs://')) cid = cid.slice(7);
393
+ if (cid.startsWith('/ipfs/')) cid = cid.slice(6);
394
+ const encoded = cid.startsWith('0x')
395
+ ? cid
396
+ : encodeIPFSContenthash(cid);
397
+ log(`Setting contenthash on ${ensName}...`);
398
+ const tx = await resolver.setContenthash(node, encoded);
399
+ const receipt = await tx.wait();
400
+ log(`Contenthash set on ${chain}`);
401
+ return { txHash: receipt.hash, chain };
402
+ } catch (err: any) {
403
+ const raw = err?.shortMessage || err?.message || 'unknown error';
404
+ log(`Contenthash error: ${raw.slice(0, 120)}`);
405
+ return null;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Reads the contenthash from an ENS name's resolver.
411
+ */
412
+ export async function readENSContenthash(ensName: string): Promise<string | null> {
413
+ for (const { provider } of getProviders()) {
414
+ try {
415
+ const resolver = await provider.getResolver(ensName);
416
+ if (!resolver) continue;
417
+
418
+ const node = ethers.namehash(ensName);
419
+ const resolverContract = new ethers.Contract(
420
+ resolver.address, RESOLVER_ABI, provider,
421
+ );
422
+
423
+ const raw: string = await resolverContract.contenthash(node);
424
+ if (raw && raw !== '0x') return raw;
425
+ } catch { /* try next chain */ }
426
+ }
427
+ return null;
428
+ }
429
+
430
+ /**
431
+ * Parses a Fileverse link into portal address and file ID.
432
+ * Example: https://docs.fileverse.io/0x05EfCD.../37#key=... → { portalAddress, fileId: 37 }
433
+ */
434
+ export function parseFileverseLink(link: string): { portalAddress: string; fileId: number } | null {
435
+ try {
436
+ const url = new URL(link);
437
+ const parts = url.pathname.split('/').filter(Boolean);
438
+ if (parts.length >= 2 && parts[0].startsWith('0x')) {
439
+ const id = parseInt(parts[1], 10);
440
+ if (!isNaN(id)) return { portalAddress: parts[0], fileId: id };
441
+ }
442
+ } catch { /* not a valid URL */ }
443
+ return null;
444
+ }
445
+
446
+ /**
447
+ * Reads the IPFS content hash from a Fileverse Portal smart contract.
448
+ * Fileverse stores metadataIPFSHash and contentIPFSHash on-chain for each document.
449
+ */
450
+ export async function readFileverseContentHash(
451
+ portalAddress: string,
452
+ fileId: number,
453
+ onStatus?: (msg: string) => void,
454
+ ): Promise<string | null> {
455
+ const log = onStatus || (() => {});
456
+
457
+ for (const rpc of FILEVERSE_RPCS) {
458
+ try {
459
+ const provider = new ethers.JsonRpcProvider(rpc);
460
+ const portal = new ethers.Contract(portalAddress, FILEVERSE_PORTAL_ABI, provider);
461
+ const file = await portal.files(fileId);
462
+ const hash = file.metadataIPFSHash || file.contentIPFSHash;
463
+ if (hash) {
464
+ log(`Fileverse IPFS hash: ${hash}`);
465
+ return hash;
466
+ }
467
+ } catch { /* try next chain */ }
468
+ }
469
+
470
+ log('Could not read IPFS hash from Fileverse portal contract');
471
+ return null;
472
+ }
473
+
474
+ /**
475
+ * Builds the full set of OPM text records for a package push.
476
+ * Includes both author-level (opm.*) and per-package (opm.pkg.<name>.*) records.
477
+ */
478
+ export function buildOPMRecords(data: {
479
+ packageName: string;
480
+ version: string;
481
+ checksum: string;
482
+ signature: string;
483
+ reportURI?: string;
484
+ riskScore?: number;
485
+ existingPackages?: string;
486
+ }): Record<string, string> {
487
+ const records: Record<string, string> = {};
488
+
489
+ records['url'] = `https://www.npmjs.com/package/${data.packageName}`;
490
+
491
+ records[OPM_ENS_KEYS.version] = data.version;
492
+ records[OPM_ENS_KEYS.checksum] = data.checksum;
493
+ records[OPM_ENS_KEYS.signature] = data.signature;
494
+ records[OPM_ENS_KEYS.contract] = DEFAULT_CONTRACT_ADDRESS;
495
+
496
+ if (data.reportURI && !data.reportURI.startsWith('local://')) {
497
+ records[OPM_ENS_KEYS.fileverse] = data.reportURI;
498
+ }
499
+
500
+ if (data.riskScore !== undefined) {
501
+ records[OPM_ENS_KEYS.riskScore] = String(data.riskScore);
502
+ }
503
+
504
+ const packagesList = data.existingPackages
505
+ ? [...new Set([
506
+ ...data.existingPackages.split(',').map((s) => s.trim()).filter(Boolean),
507
+ data.packageName,
508
+ ])].join(',')
509
+ : data.packageName;
510
+ records[OPM_ENS_KEYS.packages] = packagesList;
511
+
512
+ records[pkgRecordKey(data.packageName, 'version')] = data.version;
513
+ records[pkgRecordKey(data.packageName, 'checksum')] = data.checksum;
514
+ records[pkgRecordKey(data.packageName, 'signature')] = data.signature;
515
+
516
+ if (data.reportURI && !data.reportURI.startsWith('local://')) {
517
+ records[pkgRecordKey(data.packageName, 'fileverse')] = data.reportURI;
518
+ }
519
+
520
+ if (data.riskScore !== undefined) {
521
+ records[pkgRecordKey(data.packageName, 'risk_score')] = String(data.riskScore);
522
+ }
523
+
524
+ return records;
525
+ }
@@ -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) return versions[versions.length - 1];
35
+ if (versions.length > 0) {
36
+ return { version: versions[versions.length - 1], source: 'latest' };
37
+ }
8
38
  } catch { /* no versions on-chain */ }
9
- return version;
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
  }