opmsec 0.1.4 → 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.
- package/.env.example +1 -0
- package/README.md +71 -275
- package/bun.lock +3 -3
- package/docs/architecture/agents.mdx +11 -59
- package/docs/architecture/benchmarks.mdx +20 -46
- package/docs/architecture/overview.mdx +31 -38
- package/docs/architecture/scanner.mdx +11 -37
- package/docs/cli/audit.mdx +9 -12
- package/docs/cli/check.mdx +12 -26
- package/docs/cli/fix.mdx +10 -30
- package/docs/cli/info.mdx +12 -19
- package/docs/cli/install.mdx +27 -39
- package/docs/cli/push.mdx +40 -57
- package/docs/cli/register-agent.mdx +21 -53
- package/docs/cli/view.mdx +12 -29
- package/docs/concepts/ens-records.mdx +44 -0
- package/docs/concepts/multi-agent-consensus.mdx +18 -36
- package/docs/concepts/on-chain-registry.mdx +22 -49
- package/docs/concepts/security-model.mdx +20 -52
- package/docs/concepts/zk-agent-verification.mdx +26 -64
- package/docs/contract/events.mdx +13 -74
- package/docs/contract/functions.mdx +40 -126
- package/docs/contract/overview.mdx +17 -36
- package/docs/introduction.mdx +22 -25
- package/docs/mint.json +1 -0
- package/docs/quickstart.mdx +34 -70
- package/docs/system-design.png +0 -0
- package/package.json +5 -5
- package/packages/cli/src/commands/author-view.tsx +87 -2
- package/packages/cli/src/commands/check.tsx +18 -5
- package/packages/cli/src/commands/fix.tsx +25 -12
- package/packages/cli/src/commands/info.tsx +92 -4
- package/packages/cli/src/commands/install.tsx +54 -8
- package/packages/cli/src/commands/push.tsx +112 -0
- package/packages/cli/src/commands/register-agent.tsx +72 -31
- package/packages/cli/src/index.tsx +4 -4
- package/packages/cli/src/services/ens-records.ts +525 -0
- package/packages/core/src/benchmarks.ts +116 -0
- package/packages/core/src/constants.ts +18 -6
- package/packages/core/src/model-rankings.ts +40 -15
- package/packages/core/src/types.ts +10 -0
- package/packages/core/src/utils.ts +3 -3
- package/packages/scanner/src/index.ts +2 -1
- package/packages/scanner/src/queue/memory-queue.ts +7 -2
- package/packages/scanner/src/services/benchmark-runner.ts +86 -1
- package/packages/scanner/src/services/fileverse.ts +61 -12
- package/packages/web/.next/BUILD_ID +1 -1
- package/packages/web/.next/app-build-manifest.json +7 -18
- package/packages/web/.next/build-manifest.json +6 -19
- package/packages/web/.next/images-manifest.json +1 -1
- package/packages/web/.next/required-server-files.json +2 -2
- package/packages/web/.next/server/app/_not-found/page.js +2 -2
- package/packages/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/packages/web/.next/server/app/_not-found.html +1 -1
- package/packages/web/.next/server/app/_not-found.rsc +4 -2
- package/packages/web/.next/server/app/index.html +6 -1
- package/packages/web/.next/server/app/index.rsc +5 -3
- package/packages/web/.next/server/app/page.js +272 -2
- package/packages/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/packages/web/.next/server/app-paths-manifest.json +0 -1
- package/packages/web/.next/server/middleware-build-manifest.js +22 -1
- package/packages/web/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/packages/web/.next/server/next-font-manifest.js +1 -1
- package/packages/web/.next/server/next-font-manifest.json +1 -1
- package/packages/web/.next/server/pages/404.html +1 -1
- package/packages/web/.next/server/pages/500.html +1 -1
- package/packages/web/.next/server/pages-manifest.json +1 -6
- package/packages/web/.next/server/server-reference-manifest.js +1 -1
- package/packages/web/.next/server/server-reference-manifest.json +5 -1
- package/packages/web/.next/server/webpack-runtime.js +209 -1
- package/packages/web/.next/static/chunks/174-5b5efcb3b8efcc01.js +1 -0
- package/packages/web/.next/static/chunks/app/layout-de8e841104500505.js +1 -0
- package/packages/web/.next/static/chunks/app/layout.js +69 -0
- package/packages/web/.next/static/chunks/app/page-7e086379698b9fb0.js +1 -0
- package/packages/web/.next/static/chunks/app/page.js +357 -0
- package/packages/web/.next/static/chunks/{main-ee293fa6aa18bdd1.js → main-4e8d71b5ef7ee7e3.js} +1 -1
- package/packages/web/.next/static/chunks/webpack-0dcd67569eb46132.js +1 -0
- package/packages/web/.next/static/chunks/webpack.js +1393 -0
- package/packages/web/.next/static/css/102562cf2d0ae9b0.css +3 -0
- package/packages/web/.next/static/development/_buildManifest.js +1 -0
- package/packages/web/.next/static/development/_ssgManifest.js +1 -0
- package/packages/web/.next/static/media/4cf2300e9c8272f7-s.p.woff2 +0 -0
- package/packages/web/.next/static/media/747892c23ea88013-s.woff2 +0 -0
- package/packages/web/.next/static/media/8d697b304b401681-s.woff2 +0 -0
- package/packages/web/.next/static/media/93f479601ee12b01-s.p.woff2 +0 -0
- package/packages/web/.next/static/media/9610d9e46709d722-s.woff2 +0 -0
- package/packages/web/.next/static/media/ba015fad6dcf6784-s.woff2 +0 -0
- package/packages/web/.next/static/webpack/16f18baa938a434c.webpack.hot-update.json +1 -0
- package/packages/web/.next/static/webpack/5fe9fe8578f9c3d2.webpack.hot-update.json +1 -0
- package/packages/web/.next/static/webpack/653e365406c0d9ac.webpack.hot-update.json +1 -0
- package/packages/web/.next/static/webpack/6800169a899e3a8b.webpack.hot-update.json +1 -0
- package/packages/web/.next/static/webpack/73c7d02260cc80e4.webpack.hot-update.json +1 -0
- package/packages/web/.next/static/webpack/a2d85d19aa028de1.webpack.hot-update.json +1 -0
- package/packages/web/.next/static/webpack/app/layout.16f18baa938a434c.hot-update.js +22 -0
- package/packages/web/.next/static/webpack/app/layout.5fe9fe8578f9c3d2.hot-update.js +22 -0
- package/packages/web/.next/static/webpack/app/layout.653e365406c0d9ac.hot-update.js +22 -0
- package/packages/web/.next/static/webpack/app/layout.6800169a899e3a8b.hot-update.js +22 -0
- package/packages/web/.next/static/webpack/app/layout.73c7d02260cc80e4.hot-update.js +22 -0
- package/packages/web/.next/static/webpack/app/layout.a2d85d19aa028de1.hot-update.js +22 -0
- package/packages/web/.next/static/webpack/app/page.653e365406c0d9ac.hot-update.js +22 -0
- package/packages/web/.next/static/webpack/app/page.6800169a899e3a8b.hot-update.js +22 -0
- package/packages/web/.next/static/webpack/app/page.73c7d02260cc80e4.hot-update.js +22 -0
- package/packages/web/.next/static/webpack/app/page.a2d85d19aa028de1.hot-update.js +22 -0
- package/packages/web/.next/static/webpack/webpack.16f18baa938a434c.hot-update.js +12 -0
- package/packages/web/.next/static/webpack/webpack.5fe9fe8578f9c3d2.hot-update.js +12 -0
- package/packages/web/.next/static/webpack/webpack.653e365406c0d9ac.hot-update.js +12 -0
- package/packages/web/.next/static/webpack/webpack.6800169a899e3a8b.hot-update.js +12 -0
- package/packages/web/.next/static/webpack/webpack.73c7d02260cc80e4.hot-update.js +12 -0
- package/packages/web/.next/static/webpack/webpack.a2d85d19aa028de1.hot-update.js +12 -0
- package/packages/web/.next/trace +2 -2
- package/packages/web/app/globals.css +197 -51
- package/packages/web/app/layout.tsx +6 -3
- package/packages/web/app/page.tsx +791 -312
- package/packages/web/bun.lock +66 -105
- package/packages/web/next.config.ts +8 -1
- package/packages/web/package.json +5 -2
- package/packages/web/postcss.config.mjs +2 -2
- package/packages/web/public/apple-icon.png +1 -0
- package/packages/web/public/dependency-bottleneck.png +0 -0
- package/packages/web/public/icon-dark-32x32.png +1 -0
- package/packages/web/public/icon-light-32x32.png +1 -0
- package/packages/web/public/icon.svg +1 -0
- package/packages/web/public/nextjs-cve-announcement.png +0 -0
- package/packages/web/public/phantomraven-npm-attack.png +0 -0
- package/packages/web/public/placeholder-logo.png +1 -0
- package/packages/web/public/placeholder-logo.svg +1 -0
- package/packages/web/public/placeholder-user.jpg +1 -0
- package/packages/web/public/placeholder.jpg +1 -0
- package/packages/web/public/placeholder.svg +1 -0
- package/packages/web/public/react-cve-meme.png +0 -0
- package/packages/web/public/wallet-drain-exploit.png +0 -0
- package/packages/web/styles/globals.css +125 -0
- package/packages/web/.next/static/chunks/app/layout-28a489fb4398663f.js +0 -1
- package/packages/web/.next/static/chunks/app/page-e58ccdb78625bce6.js +0 -1
- package/packages/web/.next/static/chunks/webpack-e1ae44446e7f7355.js +0 -1
- package/packages/web/.next/static/css/21d69157e271f2ab.css +0 -3
- package/packages/web/tailwind.config.ts +0 -48
- /package/packages/web/.next/static/{2XIFCTTKVZwN_RsNE-Rrr → 0esGzFBCzREfVwijEGDfL}/_buildManifest.js +0 -0
- /package/packages/web/.next/static/{2XIFCTTKVZwN_RsNE-Rrr → 0esGzFBCzREfVwijEGDfL}/_ssgManifest.js +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
|
+
}
|
|
@@ -448,3 +448,119 @@ export function evaluateBenchmark(
|
|
|
448
448
|
reason: passed ? 'Correctly classified' : reason,
|
|
449
449
|
};
|
|
450
450
|
}
|
|
451
|
+
|
|
452
|
+
/* ─── Batch (single-call) benchmark for fast agent registration ─── */
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Response-format-only instructions appended after the candidate's own system prompt.
|
|
456
|
+
* This tells the model HOW to respond, but the candidate's prompt determines
|
|
457
|
+
* the actual analysis behaviour.
|
|
458
|
+
*/
|
|
459
|
+
export const BATCH_RESPONSE_FORMAT_INSTRUCTIONS = `
|
|
460
|
+
|
|
461
|
+
For this benchmark you will be given a series of npm packages (labelled Case 1 through Case N). For each package you must decide whether it should be FLAGGED or SAFE.
|
|
462
|
+
|
|
463
|
+
You MUST respond with a valid JSON object matching this exact schema — no markdown, no explanation outside the JSON:
|
|
464
|
+
|
|
465
|
+
{
|
|
466
|
+
"answers": [
|
|
467
|
+
{ "case": 1, "flagged": <boolean> },
|
|
468
|
+
{ "case": 2, "flagged": <boolean> },
|
|
469
|
+
...
|
|
470
|
+
]
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
FLAGGED = true means the package is malicious, dangerous, or high/critical risk.
|
|
474
|
+
SAFE = false means the package is benign and low risk.`;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Build the full system prompt used during batch benchmarking.
|
|
478
|
+
* If the candidate supplied a custom system prompt it is used as-is,
|
|
479
|
+
* with the response-format instructions appended.
|
|
480
|
+
* If no custom prompt is provided, fall back to the default OPM security
|
|
481
|
+
* auditor prompt so the model still knows its role.
|
|
482
|
+
*/
|
|
483
|
+
export function buildBatchSystemPrompt(candidateSystemPrompt?: string): string {
|
|
484
|
+
const base = candidateSystemPrompt || SYSTEM_PROMPT;
|
|
485
|
+
return base + BATCH_RESPONSE_FORMAT_INSTRUCTIONS;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export interface BatchBenchmarkAnswer {
|
|
489
|
+
case: number;
|
|
490
|
+
flagged: boolean;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export interface BatchBenchmarkResponse {
|
|
494
|
+
answers: BatchBenchmarkAnswer[];
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export interface BatchBenchmarkResult {
|
|
498
|
+
caseId: string;
|
|
499
|
+
category: string;
|
|
500
|
+
expectedFlagged: boolean;
|
|
501
|
+
actualFlagged: boolean;
|
|
502
|
+
passed: boolean;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Determine the expected "flagged" label for a benchmark case.
|
|
507
|
+
* HIGH / CRITICAL → flagged=true, LOW / MEDIUM → flagged=false.
|
|
508
|
+
*/
|
|
509
|
+
export function expectedFlagForCase(bench: BenchmarkCase): boolean {
|
|
510
|
+
return bench.expected.risk_level === 'HIGH' || bench.expected.risk_level === 'CRITICAL';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Build a single prompt containing all benchmark cases so the agent
|
|
515
|
+
* can return all answers in one LLM call.
|
|
516
|
+
*/
|
|
517
|
+
export function buildBatchBenchmarkPrompt(cases: BenchmarkCase[]): string {
|
|
518
|
+
const sections = cases.map((bench, i) => {
|
|
519
|
+
const depsStr = Object.entries(bench.metadata.dependencies || {})
|
|
520
|
+
.map(([k, v]) => `${k}@${v}`)
|
|
521
|
+
.join(', ') || 'none';
|
|
522
|
+
|
|
523
|
+
const scriptsStr = ['preinstall', 'postinstall', 'prepare']
|
|
524
|
+
.map((s) => `${s}: ${(bench.metadata.scripts as Record<string, string>)?.[s] || 'none'}`)
|
|
525
|
+
.join(', ');
|
|
526
|
+
|
|
527
|
+
const codeStr = bench.sourceFiles
|
|
528
|
+
.map((f) => ` File: ${f.path} (${f.size} bytes)\n \`\`\`\n ${f.content}\n \`\`\``)
|
|
529
|
+
.join('\n');
|
|
530
|
+
|
|
531
|
+
const cveStr = bench.knownCVEs.length > 0
|
|
532
|
+
? ` Known CVEs: ${bench.knownCVEs.map((c) => `${c.id}: ${c.summary}`).join('; ')}\n`
|
|
533
|
+
: '';
|
|
534
|
+
|
|
535
|
+
return `### Case ${i + 1}
|
|
536
|
+
- Name: ${bench.metadata.name}@${bench.metadata.version}
|
|
537
|
+
- Author: ${bench.metadata.author || 'unknown'}
|
|
538
|
+
- License: ${bench.metadata.license || 'none'}
|
|
539
|
+
- Dependencies: ${depsStr}
|
|
540
|
+
- Install scripts: ${scriptsStr}
|
|
541
|
+
${cveStr}${codeStr}`;
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
return `Analyze each of the following ${cases.length} npm packages and decide if it should be FLAGGED or SAFE. Respond with the JSON schema from your system instructions.\n\n${sections.join('\n\n')}`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Evaluate batch answers against ground-truth labels.
|
|
549
|
+
*/
|
|
550
|
+
export function evaluateBatchBenchmark(
|
|
551
|
+
cases: BenchmarkCase[],
|
|
552
|
+
answers: BatchBenchmarkAnswer[],
|
|
553
|
+
): BatchBenchmarkResult[] {
|
|
554
|
+
return cases.map((bench, i) => {
|
|
555
|
+
const expected = expectedFlagForCase(bench);
|
|
556
|
+
const answer = answers.find((a) => a.case === i + 1);
|
|
557
|
+
const actual = answer?.flagged ?? false;
|
|
558
|
+
return {
|
|
559
|
+
caseId: bench.id,
|
|
560
|
+
category: bench.category,
|
|
561
|
+
expectedFlagged: expected,
|
|
562
|
+
actualFlagged: actual,
|
|
563
|
+
passed: expected === actual,
|
|
564
|
+
};
|
|
565
|
+
});
|
|
566
|
+
}
|
|
@@ -2,15 +2,15 @@ export const HIGH_RISK_THRESHOLD = 70;
|
|
|
2
2
|
export const MEDIUM_RISK_THRESHOLD = 40;
|
|
3
3
|
|
|
4
4
|
export const OPENROUTER_MODELS = {
|
|
5
|
-
agent1: 'anthropic/claude-
|
|
6
|
-
agent2: 'google/gemini-
|
|
7
|
-
agent3: 'deepseek/deepseek-
|
|
5
|
+
agent1: 'anthropic/claude-opus-4.6',
|
|
6
|
+
agent2: 'google/gemini-3.1-pro-preview',
|
|
7
|
+
agent3: 'deepseek/deepseek-v3.2',
|
|
8
8
|
} as const;
|
|
9
9
|
|
|
10
10
|
export const OPENAI_MODELS = {
|
|
11
|
-
agent1: 'gpt-4
|
|
12
|
-
agent2: 'gpt-
|
|
13
|
-
agent3: 'gpt-
|
|
11
|
+
agent1: 'gpt-5.4',
|
|
12
|
+
agent2: 'gpt-5.3-codex',
|
|
13
|
+
agent3: 'gpt-5.2',
|
|
14
14
|
} as const;
|
|
15
15
|
|
|
16
16
|
export const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
@@ -48,3 +48,15 @@ export const SCANNABLE_EXTENSIONS = ['.js', '.ts', '.mjs', '.cjs', '.json'];
|
|
|
48
48
|
export const MAX_FILE_SIZE_BYTES = 100_000;
|
|
49
49
|
export const MAX_TOTAL_CODE_CHARS = 200_000;
|
|
50
50
|
export const VERSION_LOOKBACK = 3;
|
|
51
|
+
|
|
52
|
+
export const ENS_REGISTRY_ADDRESS = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e';
|
|
53
|
+
|
|
54
|
+
export const OPM_ENS_KEYS = {
|
|
55
|
+
version: 'opm.version',
|
|
56
|
+
checksum: 'opm.checksum',
|
|
57
|
+
fileverse: 'opm.fileverse',
|
|
58
|
+
riskScore: 'opm.risk_score',
|
|
59
|
+
packages: 'opm.packages',
|
|
60
|
+
signature: 'opm.signature',
|
|
61
|
+
contract: 'opm.contract',
|
|
62
|
+
} as const;
|