@xpr-agents/openclaw 0.3.2 → 0.4.1
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/README.md +153 -29
- package/openclaw.plugin.json +17 -2
- package/package.json +7 -4
- package/skills/code-sandbox/SKILL.md +30 -0
- package/skills/code-sandbox/skill.json +13 -0
- package/skills/code-sandbox/src/index.ts +212 -0
- package/skills/creative/SKILL.md +32 -0
- package/skills/creative/skill.json +13 -0
- package/skills/creative/src/index.ts +679 -0
- package/skills/defi/SKILL.md +123 -0
- package/skills/defi/dist/index.js +1 -0
- package/skills/defi/skill.json +44 -0
- package/skills/defi/src/index.ts +1788 -0
- package/skills/defi/test-read.mjs +281 -0
- package/skills/governance/SKILL.md +69 -0
- package/skills/governance/dist/index.js +632 -0
- package/skills/governance/skill.json +21 -0
- package/skills/governance/src/index.ts +656 -0
- package/skills/governance/test-read.mjs +176 -0
- package/skills/lending/SKILL.md +63 -0
- package/skills/lending/dist/index.js +1039 -0
- package/skills/lending/skill.json +29 -0
- package/skills/lending/src/index.ts +1105 -0
- package/skills/lending/test-read.mjs +156 -0
- package/skills/nft/SKILL.md +95 -0
- package/skills/nft/dist/index.js +4 -10
- package/skills/nft/skill.json +37 -0
- package/skills/nft/src/index.ts +1539 -0
- package/skills/shellbook/SKILL.md +59 -0
- package/skills/shellbook/skill.json +29 -0
- package/skills/shellbook/src/index.ts +391 -0
- package/skills/shellbook/tsconfig.json +14 -0
- package/skills/smart-contracts/SKILL.md +128 -0
- package/skills/smart-contracts/skill.json +25 -0
- package/skills/smart-contracts/src/index.ts +1327 -0
- package/skills/smart-contracts/tsconfig.json +14 -0
- package/skills/structured-data/SKILL.md +36 -0
- package/skills/structured-data/dist/index.js +501 -0
- package/skills/structured-data/skill.json +13 -0
- package/skills/structured-data/src/index.ts +597 -0
- package/skills/tax/SKILL.md +109 -0
- package/skills/tax/dist/index.js +216 -32
- package/skills/tax/skill.json +20 -0
- package/skills/tax/src/index.ts +1985 -0
- package/skills/web-scraping/SKILL.md +29 -0
- package/skills/web-scraping/dist/index.js +311 -0
- package/skills/web-scraping/skill.json +13 -0
- package/skills/web-scraping/src/index.ts +371 -0
- package/skills/xmd/SKILL.md +52 -0
- package/skills/xmd/dist/index.js +596 -0
- package/skills/xmd/skill.json +22 -0
- package/skills/xmd/src/index.ts +635 -0
- package/skills/xmd/test-read.mjs +178 -0
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Contracts Skill — Chain inspection, code scaffolding, and auditing
|
|
3
|
+
*
|
|
4
|
+
* 6 chain inspection tools (read-only, RPC + Hyperion)
|
|
5
|
+
* 4 code generation tools (return source code strings)
|
|
6
|
+
* 1 audit tool (static analysis with 17 rules)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ── Types ────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
interface ToolDef {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
parameters: { type: 'object'; required?: string[]; properties: Record<string, unknown> };
|
|
15
|
+
handler: (params: any) => Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SkillApi {
|
|
19
|
+
registerTool(tool: ToolDef): void;
|
|
20
|
+
getConfig(): Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── RPC Helper ──────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const API_TIMEOUT = 15000;
|
|
26
|
+
|
|
27
|
+
async function rpcPost(endpoint: string, path: string, body: unknown): Promise<any> {
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const timer = setTimeout(() => controller.abort(), API_TIMEOUT);
|
|
30
|
+
try {
|
|
31
|
+
const resp = await fetch(`${endpoint}${path}`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
});
|
|
37
|
+
if (!resp.ok) {
|
|
38
|
+
const text = await resp.text().catch(() => '');
|
|
39
|
+
throw new Error(`RPC ${path} failed (${resp.status}): ${text.slice(0, 300)}`);
|
|
40
|
+
}
|
|
41
|
+
return await resp.json();
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function rpcGet(endpoint: string, path: string): Promise<any> {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timer = setTimeout(() => controller.abort(), API_TIMEOUT);
|
|
50
|
+
try {
|
|
51
|
+
const resp = await fetch(`${endpoint}${path}`, {
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
});
|
|
54
|
+
if (!resp.ok) {
|
|
55
|
+
const text = await resp.text().catch(() => '');
|
|
56
|
+
throw new Error(`RPC GET ${path} failed (${resp.status}): ${text.slice(0, 300)}`);
|
|
57
|
+
}
|
|
58
|
+
return await resp.json();
|
|
59
|
+
} finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Hyperion endpoint resolution ────────────────
|
|
65
|
+
|
|
66
|
+
function getHyperionEndpoint(rpcEndpoint: string): string {
|
|
67
|
+
// Map known RPC endpoints to their Hyperion equivalents
|
|
68
|
+
if (rpcEndpoint.includes('proton-testnet') || rpcEndpoint.includes('proton-test')) {
|
|
69
|
+
return 'https://proton-testnet.eosusa.io';
|
|
70
|
+
}
|
|
71
|
+
if (rpcEndpoint.includes('proton.eosusa.io') || rpcEndpoint.includes('proton.greymass.com')) {
|
|
72
|
+
return 'https://proton.eosusa.io';
|
|
73
|
+
}
|
|
74
|
+
// Default: assume the RPC endpoint also serves Hyperion
|
|
75
|
+
return rpcEndpoint;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Skill Entry Point ───────────────────────────
|
|
79
|
+
|
|
80
|
+
export default function smartContractsSkill(api: SkillApi): void {
|
|
81
|
+
const config = api.getConfig();
|
|
82
|
+
const rpcEndpoint = (config.rpcEndpoint as string) || process.env.XPR_RPC_ENDPOINT || '';
|
|
83
|
+
const network = (config.network as string) || process.env.XPR_NETWORK || 'testnet';
|
|
84
|
+
|
|
85
|
+
// ════════════════════════════════════════════════
|
|
86
|
+
// CHAIN INSPECTION TOOLS (6 read-only)
|
|
87
|
+
// ════════════════════════════════════════════════
|
|
88
|
+
|
|
89
|
+
// ── 1. sc_get_abi ──
|
|
90
|
+
api.registerTool({
|
|
91
|
+
name: 'sc_get_abi',
|
|
92
|
+
description: 'Fetch the ABI for a deployed contract. Returns all tables, actions, structs, and types. Equivalent to `proton contract:abi ACCOUNT`.',
|
|
93
|
+
parameters: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
required: ['account'],
|
|
96
|
+
properties: {
|
|
97
|
+
account: { type: 'string', description: 'Contract account name (e.g. "agentcore", "eosio.token")' },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
handler: async ({ account }: { account: string }) => {
|
|
101
|
+
if (!account) return { error: 'account is required' };
|
|
102
|
+
try {
|
|
103
|
+
const result = await rpcPost(rpcEndpoint, '/v1/chain/get_abi', { account_name: account });
|
|
104
|
+
if (!result.abi) return { error: `No contract deployed on account "${account}"` };
|
|
105
|
+
const abi = result.abi;
|
|
106
|
+
return {
|
|
107
|
+
account,
|
|
108
|
+
tables: (abi.tables || []).map((t: any) => ({
|
|
109
|
+
name: t.name,
|
|
110
|
+
type: t.type,
|
|
111
|
+
index_type: t.index_type,
|
|
112
|
+
key_names: t.key_names,
|
|
113
|
+
key_types: t.key_types,
|
|
114
|
+
})),
|
|
115
|
+
actions: (abi.actions || []).map((a: any) => ({
|
|
116
|
+
name: a.name,
|
|
117
|
+
type: a.type,
|
|
118
|
+
ricardian_contract: a.ricardian_contract ? '(has ricardian)' : '',
|
|
119
|
+
})),
|
|
120
|
+
structs: (abi.structs || []).map((s: any) => ({
|
|
121
|
+
name: s.name,
|
|
122
|
+
base: s.base || undefined,
|
|
123
|
+
fields: s.fields,
|
|
124
|
+
})),
|
|
125
|
+
types: abi.types || [],
|
|
126
|
+
version: abi.version,
|
|
127
|
+
};
|
|
128
|
+
} catch (err: any) {
|
|
129
|
+
return { error: `Failed to get ABI: ${err.message}` };
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── 2. sc_get_table_schema ──
|
|
135
|
+
api.registerTool({
|
|
136
|
+
name: 'sc_get_table_schema',
|
|
137
|
+
description: 'Extract table field names and types from a contract ABI. Useful for understanding table structure before reading data.',
|
|
138
|
+
parameters: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
required: ['account', 'table'],
|
|
141
|
+
properties: {
|
|
142
|
+
account: { type: 'string', description: 'Contract account name' },
|
|
143
|
+
table: { type: 'string', description: 'Table name' },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
handler: async ({ account, table }: { account: string; table: string }) => {
|
|
147
|
+
if (!account || !table) return { error: 'account and table are required' };
|
|
148
|
+
try {
|
|
149
|
+
const result = await rpcPost(rpcEndpoint, '/v1/chain/get_abi', { account_name: account });
|
|
150
|
+
if (!result.abi) return { error: `No contract deployed on account "${account}"` };
|
|
151
|
+
const abi = result.abi;
|
|
152
|
+
|
|
153
|
+
// Find the table definition
|
|
154
|
+
const tableDef = (abi.tables || []).find((t: any) => t.name === table);
|
|
155
|
+
if (!tableDef) {
|
|
156
|
+
const available = (abi.tables || []).map((t: any) => t.name);
|
|
157
|
+
return { error: `Table "${table}" not found. Available tables: ${available.join(', ')}` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Find the struct that defines this table's row type
|
|
161
|
+
const structDef = (abi.structs || []).find((s: any) => s.name === tableDef.type);
|
|
162
|
+
if (!structDef) return { error: `Struct "${tableDef.type}" not found in ABI` };
|
|
163
|
+
|
|
164
|
+
// Resolve base struct fields (inheritance)
|
|
165
|
+
const fields: Array<{ name: string; type: string }> = [];
|
|
166
|
+
if (structDef.base) {
|
|
167
|
+
const baseDef = (abi.structs || []).find((s: any) => s.name === structDef.base);
|
|
168
|
+
if (baseDef) {
|
|
169
|
+
fields.push(...baseDef.fields);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
fields.push(...structDef.fields);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
account,
|
|
176
|
+
table,
|
|
177
|
+
struct_name: tableDef.type,
|
|
178
|
+
index_type: tableDef.index_type,
|
|
179
|
+
fields,
|
|
180
|
+
};
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
return { error: `Failed to get table schema: ${err.message}` };
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── 3. sc_read_table ──
|
|
188
|
+
api.registerTool({
|
|
189
|
+
name: 'sc_read_table',
|
|
190
|
+
description: 'Read table rows from a deployed contract. Equivalent to `proton table CODE TABLE [SCOPE]`. Returns JSON rows with field names.',
|
|
191
|
+
parameters: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
required: ['code', 'table'],
|
|
194
|
+
properties: {
|
|
195
|
+
code: { type: 'string', description: 'Contract account that owns the table' },
|
|
196
|
+
table: { type: 'string', description: 'Table name' },
|
|
197
|
+
scope: { type: 'string', description: 'Table scope (defaults to code account)' },
|
|
198
|
+
lower_bound: { type: 'string', description: 'Lower bound filter (primary key or index value)' },
|
|
199
|
+
upper_bound: { type: 'string', description: 'Upper bound filter' },
|
|
200
|
+
limit: { type: 'number', description: 'Max rows to return (default 10, max 100)' },
|
|
201
|
+
index_position: { type: 'string', description: 'Index to query: "1" (primary), "2" (secondary), etc.' },
|
|
202
|
+
key_type: { type: 'string', description: 'Key type for index: "i64", "name", "i128", etc.' },
|
|
203
|
+
reverse: { type: 'boolean', description: 'Reverse order (default false)' },
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
handler: async ({ code, table, scope, lower_bound, upper_bound, limit, index_position, key_type, reverse }: {
|
|
207
|
+
code: string; table: string; scope?: string; lower_bound?: string; upper_bound?: string;
|
|
208
|
+
limit?: number; index_position?: string; key_type?: string; reverse?: boolean;
|
|
209
|
+
}) => {
|
|
210
|
+
if (!code || !table) return { error: 'code and table are required' };
|
|
211
|
+
try {
|
|
212
|
+
const result = await rpcPost(rpcEndpoint, '/v1/chain/get_table_rows', {
|
|
213
|
+
json: true,
|
|
214
|
+
code,
|
|
215
|
+
scope: scope || code,
|
|
216
|
+
table,
|
|
217
|
+
lower_bound: lower_bound || undefined,
|
|
218
|
+
upper_bound: upper_bound || undefined,
|
|
219
|
+
limit: Math.min(Math.max(limit || 10, 1), 100),
|
|
220
|
+
index_position: index_position || undefined,
|
|
221
|
+
key_type: key_type || undefined,
|
|
222
|
+
reverse: reverse || false,
|
|
223
|
+
});
|
|
224
|
+
return {
|
|
225
|
+
code,
|
|
226
|
+
table,
|
|
227
|
+
scope: scope || code,
|
|
228
|
+
rows: result.rows || [],
|
|
229
|
+
more: result.more || false,
|
|
230
|
+
next_key: result.next_key || undefined,
|
|
231
|
+
};
|
|
232
|
+
} catch (err: any) {
|
|
233
|
+
return { error: `Failed to read table: ${err.message}` };
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ── 4. sc_get_account_info ──
|
|
239
|
+
api.registerTool({
|
|
240
|
+
name: 'sc_get_account_info',
|
|
241
|
+
description: 'Get account details: permissions, RAM usage, contract deployment status, and resource limits. Equivalent to `proton account ACCOUNT`.',
|
|
242
|
+
parameters: {
|
|
243
|
+
type: 'object',
|
|
244
|
+
required: ['account'],
|
|
245
|
+
properties: {
|
|
246
|
+
account: { type: 'string', description: 'Account name to inspect' },
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
handler: async ({ account }: { account: string }) => {
|
|
250
|
+
if (!account) return { error: 'account is required' };
|
|
251
|
+
try {
|
|
252
|
+
const result = await rpcPost(rpcEndpoint, '/v1/chain/get_account', { account_name: account });
|
|
253
|
+
return {
|
|
254
|
+
account_name: result.account_name,
|
|
255
|
+
created: result.created,
|
|
256
|
+
has_contract: !!(result.last_code_update && result.last_code_update !== '1970-01-01T00:00:00.000'),
|
|
257
|
+
last_code_update: result.last_code_update,
|
|
258
|
+
ram_quota: result.ram_quota,
|
|
259
|
+
ram_usage: result.ram_usage,
|
|
260
|
+
ram_free: (result.ram_quota || 0) - (result.ram_usage || 0),
|
|
261
|
+
net_weight: result.net_weight,
|
|
262
|
+
cpu_weight: result.cpu_weight,
|
|
263
|
+
permissions: (result.permissions || []).map((p: any) => ({
|
|
264
|
+
perm_name: p.perm_name,
|
|
265
|
+
parent: p.parent,
|
|
266
|
+
threshold: p.required_auth?.threshold,
|
|
267
|
+
keys: (p.required_auth?.keys || []).map((k: any) => ({
|
|
268
|
+
key: k.key,
|
|
269
|
+
weight: k.weight,
|
|
270
|
+
})),
|
|
271
|
+
accounts: (p.required_auth?.accounts || []).map((a: any) => ({
|
|
272
|
+
actor: a.permission?.actor,
|
|
273
|
+
permission: a.permission?.permission,
|
|
274
|
+
weight: a.weight,
|
|
275
|
+
})),
|
|
276
|
+
})),
|
|
277
|
+
};
|
|
278
|
+
} catch (err: any) {
|
|
279
|
+
return { error: `Failed to get account info: ${err.message}` };
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── 5. sc_get_chain_info ──
|
|
285
|
+
api.registerTool({
|
|
286
|
+
name: 'sc_get_chain_info',
|
|
287
|
+
description: 'Get chain info: head block number, chain ID, server version, and block time. Equivalent to `proton chain:info`.',
|
|
288
|
+
parameters: {
|
|
289
|
+
type: 'object',
|
|
290
|
+
properties: {},
|
|
291
|
+
},
|
|
292
|
+
handler: async () => {
|
|
293
|
+
try {
|
|
294
|
+
const result = await rpcPost(rpcEndpoint, '/v1/chain/get_info', {});
|
|
295
|
+
return {
|
|
296
|
+
chain_id: result.chain_id,
|
|
297
|
+
head_block_num: result.head_block_num,
|
|
298
|
+
head_block_time: result.head_block_time,
|
|
299
|
+
last_irreversible_block_num: result.last_irreversible_block_num,
|
|
300
|
+
server_version_string: result.server_version_string,
|
|
301
|
+
fork_db_head_block_num: result.fork_db_head_block_num,
|
|
302
|
+
network: result.chain_id === '384da888112027f0321850a169f737c33e53b388aad48b5adace4bab97f437e0' ? 'mainnet' : 'testnet',
|
|
303
|
+
};
|
|
304
|
+
} catch (err: any) {
|
|
305
|
+
return { error: `Failed to get chain info: ${err.message}` };
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ── 6. sc_get_action_history ──
|
|
311
|
+
api.registerTool({
|
|
312
|
+
name: 'sc_get_action_history',
|
|
313
|
+
description: 'Query Hyperion for action history of an account. Returns recent actions with data, timestamps, and transaction IDs.',
|
|
314
|
+
parameters: {
|
|
315
|
+
type: 'object',
|
|
316
|
+
required: ['account'],
|
|
317
|
+
properties: {
|
|
318
|
+
account: { type: 'string', description: 'Account to query actions for' },
|
|
319
|
+
filter: { type: 'string', description: 'Filter by contract:action (e.g. "eosio.token:transfer", "agentcore:*")' },
|
|
320
|
+
limit: { type: 'number', description: 'Max actions to return (default 20, max 100)' },
|
|
321
|
+
skip: { type: 'number', description: 'Number of actions to skip (for pagination)' },
|
|
322
|
+
sort: { type: 'string', description: 'Sort order: "asc" or "desc" (default "desc" = newest first)' },
|
|
323
|
+
after: { type: 'string', description: 'Only actions after this ISO date (e.g. "2026-01-01T00:00:00Z")' },
|
|
324
|
+
before: { type: 'string', description: 'Only actions before this ISO date' },
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
handler: async ({ account, filter, limit, skip, sort, after, before }: {
|
|
328
|
+
account: string; filter?: string; limit?: number; skip?: number;
|
|
329
|
+
sort?: string; after?: string; before?: string;
|
|
330
|
+
}) => {
|
|
331
|
+
if (!account) return { error: 'account is required' };
|
|
332
|
+
try {
|
|
333
|
+
const hyperion = getHyperionEndpoint(rpcEndpoint);
|
|
334
|
+
const params = new URLSearchParams();
|
|
335
|
+
params.set('account', account);
|
|
336
|
+
params.set('limit', String(Math.min(Math.max(limit || 20, 1), 100)));
|
|
337
|
+
if (filter) params.set('filter', filter);
|
|
338
|
+
if (skip) params.set('skip', String(skip));
|
|
339
|
+
if (sort) params.set('sort', sort);
|
|
340
|
+
if (after) params.set('after', after);
|
|
341
|
+
if (before) params.set('before', before);
|
|
342
|
+
|
|
343
|
+
const result = await rpcGet(hyperion, `/v2/history/get_actions?${params.toString()}`);
|
|
344
|
+
const actions = result.actions || [];
|
|
345
|
+
return {
|
|
346
|
+
account,
|
|
347
|
+
total: result.total?.value || actions.length,
|
|
348
|
+
actions: actions.map((a: any) => ({
|
|
349
|
+
timestamp: a.timestamp || a['@timestamp'],
|
|
350
|
+
block_num: a.block_num,
|
|
351
|
+
trx_id: a.trx_id,
|
|
352
|
+
contract: a.act?.account,
|
|
353
|
+
action: a.act?.name,
|
|
354
|
+
data: a.act?.data,
|
|
355
|
+
authorization: a.act?.authorization,
|
|
356
|
+
})),
|
|
357
|
+
};
|
|
358
|
+
} catch (err: any) {
|
|
359
|
+
return { error: `Failed to get action history: ${err.message}` };
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ════════════════════════════════════════════════
|
|
365
|
+
// CODE GENERATION TOOLS (4)
|
|
366
|
+
// ════════════════════════════════════════════════
|
|
367
|
+
|
|
368
|
+
// ── 7. sc_scaffold_contract ──
|
|
369
|
+
api.registerTool({
|
|
370
|
+
name: 'sc_scaffold_contract',
|
|
371
|
+
description: 'Generate a full contract project: contract.ts with tables and actions, package.json, tsconfig.json, and a basic test file. Returns all files as an object.',
|
|
372
|
+
parameters: {
|
|
373
|
+
type: 'object',
|
|
374
|
+
required: ['name'],
|
|
375
|
+
properties: {
|
|
376
|
+
name: { type: 'string', description: 'Contract name (1-12 chars, a-z1-5). Used for filename and class name.' },
|
|
377
|
+
description: { type: 'string', description: 'Contract description (added as comment)' },
|
|
378
|
+
tables: {
|
|
379
|
+
type: 'array',
|
|
380
|
+
description: 'Table definitions: [{name, fields: [{name, type}], singleton?: bool}]',
|
|
381
|
+
},
|
|
382
|
+
actions: {
|
|
383
|
+
type: 'array',
|
|
384
|
+
description: 'Action definitions: [{name, params: [{name, type}], auth?: "user"|"self"|"both", notify?: bool}]',
|
|
385
|
+
},
|
|
386
|
+
has_token_handler: { type: 'boolean', description: 'Include a transfer notify handler for incoming token payments (default false)' },
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
handler: async ({ name, description, tables, actions, has_token_handler }: {
|
|
390
|
+
name: string; description?: string; tables?: any[]; actions?: any[]; has_token_handler?: boolean;
|
|
391
|
+
}) => {
|
|
392
|
+
if (!name || name.length > 12 || !/^[a-z1-5]+$/.test(name)) {
|
|
393
|
+
return { error: 'Invalid contract name. Must be 1-12 characters, a-z and 1-5 only.' };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const className = name.charAt(0).toUpperCase() + name.slice(1) + 'Contract';
|
|
397
|
+
const imports = new Set(['Contract', 'Name', 'check', 'requireAuth', 'currentTimeSec']);
|
|
398
|
+
const tableClasses: string[] = [];
|
|
399
|
+
const tableDeclarations: string[] = [];
|
|
400
|
+
const actionMethods: string[] = [];
|
|
401
|
+
|
|
402
|
+
// Generate table classes
|
|
403
|
+
if (tables && tables.length > 0) {
|
|
404
|
+
imports.add('Table');
|
|
405
|
+
imports.add('TableStore');
|
|
406
|
+
for (const t of tables) {
|
|
407
|
+
if (t.singleton) {
|
|
408
|
+
imports.add('Singleton');
|
|
409
|
+
tableClasses.push(generateSingletonClass(t));
|
|
410
|
+
tableDeclarations.push(` ${t.name}Singleton: Singleton<${pascalCase(t.name)}> = new Singleton<${pascalCase(t.name)}>(this.receiver);`);
|
|
411
|
+
} else {
|
|
412
|
+
tableClasses.push(generateTableClass(t));
|
|
413
|
+
tableDeclarations.push(` ${t.name}Table: TableStore<${pascalCase(t.name)}> = new TableStore<${pascalCase(t.name)}>(this.receiver);`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Generate action methods
|
|
419
|
+
if (actions && actions.length > 0) {
|
|
420
|
+
for (const a of actions) {
|
|
421
|
+
if (a.notify) imports.add('Asset');
|
|
422
|
+
actionMethods.push(generateActionMethod(a, imports));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Add init action if not already present
|
|
427
|
+
const hasInit = actions?.some(a => a.name === 'init');
|
|
428
|
+
if (!hasInit && tables?.some(t => t.singleton)) {
|
|
429
|
+
actionMethods.unshift(generateInitAction(tables.filter(t => t.singleton), imports));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Add token handler
|
|
433
|
+
if (has_token_handler) {
|
|
434
|
+
imports.add('Asset');
|
|
435
|
+
actionMethods.push(generateTokenHandler());
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Build contract source
|
|
439
|
+
const contractSource = [
|
|
440
|
+
`// ${name}.contract.ts`,
|
|
441
|
+
description ? `// ${description}` : '',
|
|
442
|
+
'',
|
|
443
|
+
`import {`,
|
|
444
|
+
` ${[...imports].join(', ')}`,
|
|
445
|
+
`} from 'proton-tsc';`,
|
|
446
|
+
'',
|
|
447
|
+
...tableClasses,
|
|
448
|
+
`@contract`,
|
|
449
|
+
`export class ${className} extends Contract {`,
|
|
450
|
+
...tableDeclarations,
|
|
451
|
+
'',
|
|
452
|
+
...actionMethods,
|
|
453
|
+
`}`,
|
|
454
|
+
'',
|
|
455
|
+
].filter(line => line !== undefined).join('\n');
|
|
456
|
+
|
|
457
|
+
// Build package.json
|
|
458
|
+
const packageJson = JSON.stringify({
|
|
459
|
+
name,
|
|
460
|
+
version: '1.0.0',
|
|
461
|
+
description: description || `${name} smart contract`,
|
|
462
|
+
scripts: {
|
|
463
|
+
build: `npx proton-asc ./assembly/${name}.contract.ts`,
|
|
464
|
+
test: 'npx ts-mocha tests/**/*.spec.ts --timeout 30000',
|
|
465
|
+
},
|
|
466
|
+
dependencies: {
|
|
467
|
+
'proton-tsc': '^0.12.0',
|
|
468
|
+
},
|
|
469
|
+
devDependencies: {
|
|
470
|
+
'@proton/vert': '^0.2.0',
|
|
471
|
+
'chai': '^4.3.10',
|
|
472
|
+
'mocha': '^10.2.0',
|
|
473
|
+
'ts-mocha': '^10.0.0',
|
|
474
|
+
'typescript': '^5.0.0',
|
|
475
|
+
},
|
|
476
|
+
}, null, 2);
|
|
477
|
+
|
|
478
|
+
// Build tsconfig.json
|
|
479
|
+
const tsConfig = JSON.stringify({
|
|
480
|
+
compilerOptions: {
|
|
481
|
+
target: 'ES2020',
|
|
482
|
+
module: 'commonjs',
|
|
483
|
+
strict: true,
|
|
484
|
+
esModuleInterop: true,
|
|
485
|
+
skipLibCheck: true,
|
|
486
|
+
outDir: './dist',
|
|
487
|
+
},
|
|
488
|
+
include: ['tests/**/*.ts'],
|
|
489
|
+
}, null, 2);
|
|
490
|
+
|
|
491
|
+
// Build test file
|
|
492
|
+
const testSource = generateTestFile(name, className, tables || [], actions || []);
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
files: {
|
|
496
|
+
[`assembly/${name}.contract.ts`]: contractSource,
|
|
497
|
+
'package.json': packageJson,
|
|
498
|
+
'tsconfig.json': tsConfig,
|
|
499
|
+
[`tests/${name}.spec.ts`]: testSource,
|
|
500
|
+
},
|
|
501
|
+
note: `Generated ${name} contract with ${tables?.length || 0} table(s) and ${(actions?.length || 0) + (hasInit ? 0 : tables?.some(t => t.singleton) ? 1 : 0)} action(s). Run \`npm install && npm run build\` to compile.`,
|
|
502
|
+
};
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// ── 8. sc_scaffold_table ──
|
|
507
|
+
api.registerTool({
|
|
508
|
+
name: 'sc_scaffold_table',
|
|
509
|
+
description: 'Generate a single table class with primary key and optional secondary indexes. Returns AssemblyScript source code.',
|
|
510
|
+
parameters: {
|
|
511
|
+
type: 'object',
|
|
512
|
+
required: ['name', 'fields'],
|
|
513
|
+
properties: {
|
|
514
|
+
name: { type: 'string', description: 'Table name (1-12 chars, a-z1-5)' },
|
|
515
|
+
fields: {
|
|
516
|
+
type: 'array',
|
|
517
|
+
description: 'Array of {name, type} field definitions. Types: u8, u16, u32, u64, i64, string, Name, boolean, Asset',
|
|
518
|
+
},
|
|
519
|
+
singleton: { type: 'boolean', description: 'Generate a singleton table (default false)' },
|
|
520
|
+
secondary_indexes: {
|
|
521
|
+
type: 'array',
|
|
522
|
+
description: 'Field names to add secondary indexes on (must be u64 or Name type)',
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
handler: async ({ name, fields, singleton, secondary_indexes }: {
|
|
527
|
+
name: string; fields: Array<{ name: string; type: string }>;
|
|
528
|
+
singleton?: boolean; secondary_indexes?: string[];
|
|
529
|
+
}) => {
|
|
530
|
+
if (!name || name.length > 12 || !/^[a-z1-5.]*$/.test(name)) {
|
|
531
|
+
return { error: 'Invalid table name. Must be 1-12 characters, a-z, 1-5, and dots only.' };
|
|
532
|
+
}
|
|
533
|
+
if (!fields || fields.length === 0) {
|
|
534
|
+
return { error: 'At least one field is required' };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const table = { name, fields, singleton, secondary_indexes };
|
|
538
|
+
const code = singleton ? generateSingletonClass(table) : generateTableClass(table);
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
code,
|
|
542
|
+
imports: singleton
|
|
543
|
+
? "import { Table, Singleton, Name } from 'proton-tsc';"
|
|
544
|
+
: "import { Table, TableStore, Name } from 'proton-tsc';",
|
|
545
|
+
usage: singleton
|
|
546
|
+
? `// In contract class:\n${name}Singleton: Singleton<${pascalCase(name)}> = new Singleton<${pascalCase(name)}>(this.receiver);`
|
|
547
|
+
: `// In contract class:\n${name}Table: TableStore<${pascalCase(name)}> = new TableStore<${pascalCase(name)}>(this.receiver);`,
|
|
548
|
+
};
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// ── 9. sc_scaffold_action ──
|
|
553
|
+
api.registerTool({
|
|
554
|
+
name: 'sc_scaffold_action',
|
|
555
|
+
description: 'Generate a single action method with proper authorization checks. Returns AssemblyScript source code to paste into a contract class.',
|
|
556
|
+
parameters: {
|
|
557
|
+
type: 'object',
|
|
558
|
+
required: ['name'],
|
|
559
|
+
properties: {
|
|
560
|
+
name: { type: 'string', description: 'Action name (1-12 chars)' },
|
|
561
|
+
params: {
|
|
562
|
+
type: 'array',
|
|
563
|
+
description: 'Action parameters: [{name, type}]. First Name-type param used for requireAuth.',
|
|
564
|
+
},
|
|
565
|
+
auth: { type: 'string', description: 'Authorization: "user" (requireAuth on first Name param), "self" (requireAuth on this.receiver), "both" (default "user")' },
|
|
566
|
+
notify: { type: 'boolean', description: 'Generate as a notify handler (e.g. for incoming transfers)' },
|
|
567
|
+
description: { type: 'string', description: 'Action description (added as comment)' },
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
handler: async ({ name, params, auth, notify, description }: {
|
|
571
|
+
name: string; params?: Array<{ name: string; type: string }>;
|
|
572
|
+
auth?: string; notify?: boolean; description?: string;
|
|
573
|
+
}) => {
|
|
574
|
+
if (!name) return { error: 'Action name is required' };
|
|
575
|
+
|
|
576
|
+
const imports = new Set<string>();
|
|
577
|
+
const action = { name, params: params || [], auth: auth || 'user', notify, description };
|
|
578
|
+
const code = generateActionMethod(action, imports);
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
code,
|
|
582
|
+
imports: imports.size > 0 ? `import { ${[...imports].join(', ')} } from 'proton-tsc';` : '',
|
|
583
|
+
note: notify
|
|
584
|
+
? 'This is a notify handler. It will fire when the matching action occurs on another contract.'
|
|
585
|
+
: `Paste this method inside your contract class.`,
|
|
586
|
+
};
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// ── 10. sc_scaffold_test ──
|
|
591
|
+
api.registerTool({
|
|
592
|
+
name: 'sc_scaffold_test',
|
|
593
|
+
description: 'Generate a @proton/vert test file for a contract. Returns TypeScript test source with mocha/chai structure.',
|
|
594
|
+
parameters: {
|
|
595
|
+
type: 'object',
|
|
596
|
+
required: ['contract_name'],
|
|
597
|
+
properties: {
|
|
598
|
+
contract_name: { type: 'string', description: 'Contract name (matches the .contract.ts filename)' },
|
|
599
|
+
actions: {
|
|
600
|
+
type: 'array',
|
|
601
|
+
description: 'Actions to generate test cases for: [{name, params: [{name, type, example_value}]}]',
|
|
602
|
+
},
|
|
603
|
+
tables: {
|
|
604
|
+
type: 'array',
|
|
605
|
+
description: 'Tables to generate read test cases for: [{name}]',
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
handler: async ({ contract_name, actions, tables }: {
|
|
610
|
+
contract_name: string; actions?: any[]; tables?: any[];
|
|
611
|
+
}) => {
|
|
612
|
+
if (!contract_name) return { error: 'contract_name is required' };
|
|
613
|
+
|
|
614
|
+
const className = contract_name.charAt(0).toUpperCase() + contract_name.slice(1) + 'Contract';
|
|
615
|
+
const code = generateTestFile(contract_name, className, tables || [], actions || []);
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
code,
|
|
619
|
+
note: `Run with: npx ts-mocha tests/${contract_name}.spec.ts --timeout 30000`,
|
|
620
|
+
};
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// ════════════════════════════════════════════════
|
|
625
|
+
// ANALYSIS TOOL (1)
|
|
626
|
+
// ════════════════════════════════════════════════
|
|
627
|
+
|
|
628
|
+
// ── 11. sc_audit_contract ──
|
|
629
|
+
api.registerTool({
|
|
630
|
+
name: 'sc_audit_contract',
|
|
631
|
+
description: 'Scan contract source code for 17 known XPR/EOSIO/AssemblyScript pitfalls. Returns findings sorted by severity (critical, warning, info).',
|
|
632
|
+
parameters: {
|
|
633
|
+
type: 'object',
|
|
634
|
+
required: ['source_code'],
|
|
635
|
+
properties: {
|
|
636
|
+
source_code: { type: 'string', description: 'Full contract source code (AssemblyScript .contract.ts content)' },
|
|
637
|
+
contract_name: { type: 'string', description: 'Contract name (for better reporting)' },
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
handler: async ({ source_code, contract_name }: { source_code: string; contract_name?: string }) => {
|
|
641
|
+
if (!source_code) return { error: 'source_code is required' };
|
|
642
|
+
|
|
643
|
+
const findings = auditContract(source_code);
|
|
644
|
+
const bySeverity = {
|
|
645
|
+
critical: findings.filter(f => f.severity === 'critical'),
|
|
646
|
+
warning: findings.filter(f => f.severity === 'warning'),
|
|
647
|
+
info: findings.filter(f => f.severity === 'info'),
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
return {
|
|
651
|
+
contract: contract_name || '(unnamed)',
|
|
652
|
+
total_findings: findings.length,
|
|
653
|
+
summary: {
|
|
654
|
+
critical: bySeverity.critical.length,
|
|
655
|
+
warning: bySeverity.warning.length,
|
|
656
|
+
info: bySeverity.info.length,
|
|
657
|
+
},
|
|
658
|
+
findings,
|
|
659
|
+
verdict: bySeverity.critical.length > 0
|
|
660
|
+
? 'FAIL — critical issues must be fixed before deployment'
|
|
661
|
+
: bySeverity.warning.length > 0
|
|
662
|
+
? 'WARN — review warnings before deployment'
|
|
663
|
+
: 'PASS — no major issues detected',
|
|
664
|
+
};
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ════════════════════════════════════════════════════
|
|
670
|
+
// Code Generation Helpers
|
|
671
|
+
// ════════════════════════════════════════════════════
|
|
672
|
+
|
|
673
|
+
function pascalCase(s: string): string {
|
|
674
|
+
return s.split(/[_.]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function defaultValue(type: string): string {
|
|
678
|
+
switch (type.toLowerCase()) {
|
|
679
|
+
case 'u8': case 'u16': case 'u32': case 'u64':
|
|
680
|
+
case 'i8': case 'i16': case 'i32': case 'i64':
|
|
681
|
+
case 'f32': case 'f64':
|
|
682
|
+
return '0';
|
|
683
|
+
case 'string': return '""';
|
|
684
|
+
case 'name': return 'new Name()';
|
|
685
|
+
case 'boolean': case 'bool': return 'false';
|
|
686
|
+
case 'asset': return 'new Asset()';
|
|
687
|
+
default: return '0';
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function asType(type: string): string {
|
|
692
|
+
const map: Record<string, string> = {
|
|
693
|
+
'string': 'string',
|
|
694
|
+
'name': 'Name',
|
|
695
|
+
'boolean': 'boolean',
|
|
696
|
+
'bool': 'boolean',
|
|
697
|
+
'asset': 'Asset',
|
|
698
|
+
};
|
|
699
|
+
return map[type.toLowerCase()] || type;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function generateTableClass(table: any): string {
|
|
703
|
+
const className = pascalCase(table.name);
|
|
704
|
+
const fields = table.fields || [];
|
|
705
|
+
const secondaryIndexes = table.secondary_indexes || [];
|
|
706
|
+
|
|
707
|
+
// Find primary key: prefer 'id' field, then first u64 field, then first Name field, then first field
|
|
708
|
+
const primaryField =
|
|
709
|
+
fields.find((f: any) => f.name === 'id') ||
|
|
710
|
+
fields.find((f: any) => f.type === 'u64') ||
|
|
711
|
+
fields.find((f: any) => f.type.toLowerCase() === 'name') ||
|
|
712
|
+
fields[0];
|
|
713
|
+
|
|
714
|
+
const constructorParams = fields.map((f: any) =>
|
|
715
|
+
` public ${f.name}: ${asType(f.type)} = ${defaultValue(f.type)}`
|
|
716
|
+
).join(',\n');
|
|
717
|
+
|
|
718
|
+
const secondaryGetters = secondaryIndexes
|
|
719
|
+
.filter((name: string) => fields.some((f: any) => f.name === name))
|
|
720
|
+
.map((name: string) => {
|
|
721
|
+
const field = fields.find((f: any) => f.name === name);
|
|
722
|
+
const isName = field && (field.type.toLowerCase() === 'name');
|
|
723
|
+
return [
|
|
724
|
+
'',
|
|
725
|
+
` @secondary`,
|
|
726
|
+
` get by${pascalCase(name)}(): u64 { return ${isName ? `this.${name}.N` : `this.${name}`}; }`,
|
|
727
|
+
].join('\n');
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
return [
|
|
731
|
+
`@table("${table.name}")`,
|
|
732
|
+
`export class ${className} extends Table {`,
|
|
733
|
+
` constructor(`,
|
|
734
|
+
constructorParams,
|
|
735
|
+
` ) {`,
|
|
736
|
+
` super();`,
|
|
737
|
+
` }`,
|
|
738
|
+
'',
|
|
739
|
+
` @primary`,
|
|
740
|
+
` get primary(): u64 { return ${primaryField.type.toLowerCase() === 'name' ? `this.${primaryField.name}.N` : `this.${primaryField.name}`}; }`,
|
|
741
|
+
...secondaryGetters,
|
|
742
|
+
`}`,
|
|
743
|
+
'',
|
|
744
|
+
].join('\n');
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function generateSingletonClass(table: any): string {
|
|
748
|
+
const className = pascalCase(table.name);
|
|
749
|
+
const fields = table.fields || [];
|
|
750
|
+
|
|
751
|
+
const constructorParams = fields.map((f: any) =>
|
|
752
|
+
` public ${f.name}: ${asType(f.type)} = ${defaultValue(f.type)}`
|
|
753
|
+
).join(',\n');
|
|
754
|
+
|
|
755
|
+
return [
|
|
756
|
+
`@table("${table.name}", singleton)`,
|
|
757
|
+
`export class ${className} extends Table {`,
|
|
758
|
+
` constructor(`,
|
|
759
|
+
constructorParams,
|
|
760
|
+
` ) {`,
|
|
761
|
+
` super();`,
|
|
762
|
+
` }`,
|
|
763
|
+
`}`,
|
|
764
|
+
'',
|
|
765
|
+
].join('\n');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function generateActionMethod(action: any, imports: Set<string>): string {
|
|
769
|
+
const params = action.params || [];
|
|
770
|
+
const isNotify = action.notify;
|
|
771
|
+
const auth = action.auth || 'user';
|
|
772
|
+
|
|
773
|
+
const paramStr = params.map((p: any) => `${p.name}: ${asType(p.type)}`).join(', ');
|
|
774
|
+
|
|
775
|
+
const lines: string[] = [];
|
|
776
|
+
if (action.description) {
|
|
777
|
+
lines.push(` // ${action.description}`);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (isNotify) {
|
|
781
|
+
imports.add('Name');
|
|
782
|
+
lines.push(` @action("${action.name}", notify)`);
|
|
783
|
+
lines.push(` on${pascalCase(action.name)}(${paramStr}): void {`);
|
|
784
|
+
// Add firstReceiver check for notify handlers
|
|
785
|
+
lines.push(` // SECURITY: Only accept from the real contract`);
|
|
786
|
+
lines.push(` if (this.firstReceiver != Name.fromString("eosio.token")) return;`);
|
|
787
|
+
if (params.some((p: any) => p.name === 'to')) {
|
|
788
|
+
lines.push(` // Only process transfers TO this contract`);
|
|
789
|
+
lines.push(` if (to != this.receiver) return;`);
|
|
790
|
+
}
|
|
791
|
+
} else {
|
|
792
|
+
lines.push(` @action("${action.name}")`);
|
|
793
|
+
lines.push(` ${action.name}(${paramStr}): void {`);
|
|
794
|
+
|
|
795
|
+
// Add auth checks
|
|
796
|
+
if (auth === 'self' || auth === 'both') {
|
|
797
|
+
imports.add('requireAuth');
|
|
798
|
+
lines.push(` requireAuth(this.receiver);`);
|
|
799
|
+
}
|
|
800
|
+
if (auth === 'user' || auth === 'both') {
|
|
801
|
+
const nameParam = params.find((p: any) => p.type.toLowerCase() === 'name');
|
|
802
|
+
if (nameParam) {
|
|
803
|
+
imports.add('requireAuth');
|
|
804
|
+
lines.push(` requireAuth(${nameParam.name});`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
lines.push('');
|
|
810
|
+
lines.push(' // TODO: Implement action logic');
|
|
811
|
+
lines.push(' }');
|
|
812
|
+
|
|
813
|
+
return lines.join('\n');
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function generateInitAction(singletons: any[], imports: Set<string>): string {
|
|
817
|
+
imports.add('requireAuth');
|
|
818
|
+
imports.add('check');
|
|
819
|
+
imports.add('Name');
|
|
820
|
+
|
|
821
|
+
const lines = [
|
|
822
|
+
' @action("init")',
|
|
823
|
+
' init(owner: Name): void {',
|
|
824
|
+
' requireAuth(this.receiver);',
|
|
825
|
+
'',
|
|
826
|
+
' // Re-init guard — prevent overwriting existing config',
|
|
827
|
+
];
|
|
828
|
+
|
|
829
|
+
if (singletons.length > 0) {
|
|
830
|
+
const s = singletons[0];
|
|
831
|
+
const className = pascalCase(s.name);
|
|
832
|
+
lines.push(` const existing = this.${s.name}Singleton.get();`);
|
|
833
|
+
lines.push(` check(existing === null, "Already initialized");`);
|
|
834
|
+
lines.push('');
|
|
835
|
+
lines.push(` const config = new ${className}();`);
|
|
836
|
+
lines.push(` config.owner = owner;`);
|
|
837
|
+
lines.push(` this.${s.name}Singleton.set(config, this.receiver);`);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
lines.push(' }');
|
|
841
|
+
return lines.join('\n');
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function generateTokenHandler(): string {
|
|
845
|
+
return [
|
|
846
|
+
'',
|
|
847
|
+
' @action("transfer", notify)',
|
|
848
|
+
' onTransfer(from: Name, to: Name, quantity: Asset, memo: string): void {',
|
|
849
|
+
' // Only process transfers TO this contract',
|
|
850
|
+
' if (to != this.receiver) return;',
|
|
851
|
+
'',
|
|
852
|
+
' // SECURITY: Only accept from eosio.token',
|
|
853
|
+
' if (this.firstReceiver != Name.fromString("eosio.token")) return;',
|
|
854
|
+
'',
|
|
855
|
+
' // Parse memo and handle payment',
|
|
856
|
+
' if (memo.startsWith("deposit:")) {',
|
|
857
|
+
' // TODO: Handle deposit',
|
|
858
|
+
' }',
|
|
859
|
+
' }',
|
|
860
|
+
].join('\n');
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function generateTestFile(contractName: string, className: string, tables: any[], actions: any[]): string {
|
|
864
|
+
const lines = [
|
|
865
|
+
`import { expect } from 'chai';`,
|
|
866
|
+
`import { Blockchain, nameToBigInt, mintTokens, expectToThrow } from '@proton/vert';`,
|
|
867
|
+
'',
|
|
868
|
+
`// Initialize blockchain`,
|
|
869
|
+
`const blockchain = new Blockchain();`,
|
|
870
|
+
'',
|
|
871
|
+
`// Load contract`,
|
|
872
|
+
`const contract = blockchain.createContract('${contractName}', 'assembly/target/${contractName}.contract');`,
|
|
873
|
+
'',
|
|
874
|
+
`// Create test accounts`,
|
|
875
|
+
`const [alice, bob] = blockchain.createAccounts('alice', 'bob');`,
|
|
876
|
+
'',
|
|
877
|
+
`describe('${className}', () => {`,
|
|
878
|
+
` beforeEach(async () => {`,
|
|
879
|
+
` blockchain.resetTables();`,
|
|
880
|
+
` });`,
|
|
881
|
+
'',
|
|
882
|
+
];
|
|
883
|
+
|
|
884
|
+
// Generate init test if there are singletons
|
|
885
|
+
const hasSingleton = tables.some(t => t.singleton);
|
|
886
|
+
if (hasSingleton) {
|
|
887
|
+
lines.push(` describe('init', () => {`);
|
|
888
|
+
lines.push(` it('should initialize config', async () => {`);
|
|
889
|
+
lines.push(` await contract.actions.init(['${contractName}']).send('${contractName}@active');`);
|
|
890
|
+
lines.push(` });`);
|
|
891
|
+
lines.push('');
|
|
892
|
+
lines.push(` it('should prevent re-initialization', async () => {`);
|
|
893
|
+
lines.push(` await contract.actions.init(['${contractName}']).send('${contractName}@active');`);
|
|
894
|
+
lines.push(` await expectToThrow(`);
|
|
895
|
+
lines.push(` contract.actions.init(['${contractName}']).send('${contractName}@active'),`);
|
|
896
|
+
lines.push(` 'Already initialized'`);
|
|
897
|
+
lines.push(` );`);
|
|
898
|
+
lines.push(` });`);
|
|
899
|
+
lines.push(` });`);
|
|
900
|
+
lines.push('');
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Generate tests for each action
|
|
904
|
+
for (const action of actions) {
|
|
905
|
+
if (action.name === 'init') continue;
|
|
906
|
+
const params = action.params || [];
|
|
907
|
+
const exampleArgs = params.map((p: any) => {
|
|
908
|
+
if (p.example_value !== undefined) return JSON.stringify(p.example_value);
|
|
909
|
+
switch (p.type?.toLowerCase()) {
|
|
910
|
+
case 'name': return "'alice'";
|
|
911
|
+
case 'string': return "'test'";
|
|
912
|
+
case 'u64': case 'u32': case 'u16': case 'u8': return '100';
|
|
913
|
+
case 'boolean': case 'bool': return 'true';
|
|
914
|
+
default: return "'test'";
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
lines.push(` describe('${action.name}', () => {`);
|
|
919
|
+
lines.push(` it('should execute ${action.name}', async () => {`);
|
|
920
|
+
lines.push(` await contract.actions.${action.name}([${exampleArgs.join(', ')}]).send('alice@active');`);
|
|
921
|
+
lines.push(` });`);
|
|
922
|
+
lines.push('');
|
|
923
|
+
lines.push(` it('should require auth', async () => {`);
|
|
924
|
+
lines.push(` await expectToThrow(`);
|
|
925
|
+
lines.push(` contract.actions.${action.name}([${exampleArgs.join(', ')}]).send('bob@active'),`);
|
|
926
|
+
lines.push(` 'Missing required authority'`);
|
|
927
|
+
lines.push(` );`);
|
|
928
|
+
lines.push(` });`);
|
|
929
|
+
lines.push(` });`);
|
|
930
|
+
lines.push('');
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Generate table read tests
|
|
934
|
+
for (const table of tables) {
|
|
935
|
+
if (table.singleton) continue;
|
|
936
|
+
lines.push(` describe('${table.name} table', () => {`);
|
|
937
|
+
lines.push(` it('should read ${table.name} rows', async () => {`);
|
|
938
|
+
lines.push(` const rows = contract.tables.${table.name}().getTableRows();`);
|
|
939
|
+
lines.push(` expect(rows).to.be.an('array');`);
|
|
940
|
+
lines.push(` });`);
|
|
941
|
+
lines.push(` });`);
|
|
942
|
+
lines.push('');
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
lines.push(`});`);
|
|
946
|
+
lines.push('');
|
|
947
|
+
|
|
948
|
+
return lines.join('\n');
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ════════════════════════════════════════════════════
|
|
952
|
+
// Audit Engine (17 rules)
|
|
953
|
+
// ════════════════════════════════════════════════════
|
|
954
|
+
|
|
955
|
+
interface AuditFinding {
|
|
956
|
+
id: string;
|
|
957
|
+
severity: 'critical' | 'warning' | 'info';
|
|
958
|
+
title: string;
|
|
959
|
+
description: string;
|
|
960
|
+
line?: number;
|
|
961
|
+
suggestion: string;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function auditContract(source: string): AuditFinding[] {
|
|
965
|
+
const findings: AuditFinding[] = [];
|
|
966
|
+
const lines = source.split('\n');
|
|
967
|
+
|
|
968
|
+
// Track which actions have requireAuth
|
|
969
|
+
const actionLines: Array<{ name: string; lineNum: number; hasAuth: boolean; isSelf: boolean }> = [];
|
|
970
|
+
let inAction = false;
|
|
971
|
+
let currentAction = '';
|
|
972
|
+
let currentActionLine = 0;
|
|
973
|
+
let hasAuthInAction = false;
|
|
974
|
+
let isSelfAuth = false;
|
|
975
|
+
let braceDepth = 0;
|
|
976
|
+
|
|
977
|
+
for (let i = 0; i < lines.length; i++) {
|
|
978
|
+
const line = lines[i];
|
|
979
|
+
const trimmed = line.trim();
|
|
980
|
+
|
|
981
|
+
// Track action boundaries
|
|
982
|
+
const actionMatch = trimmed.match(/@action\("([^"]+)"\)/);
|
|
983
|
+
if (actionMatch && !trimmed.includes('notify')) {
|
|
984
|
+
if (inAction) {
|
|
985
|
+
actionLines.push({ name: currentAction, lineNum: currentActionLine, hasAuth: hasAuthInAction, isSelf: isSelfAuth });
|
|
986
|
+
}
|
|
987
|
+
currentAction = actionMatch[1];
|
|
988
|
+
currentActionLine = i + 1;
|
|
989
|
+
hasAuthInAction = false;
|
|
990
|
+
isSelfAuth = false;
|
|
991
|
+
inAction = true;
|
|
992
|
+
braceDepth = 0;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (inAction) {
|
|
996
|
+
braceDepth += (line.match(/{/g) || []).length;
|
|
997
|
+
braceDepth -= (line.match(/}/g) || []).length;
|
|
998
|
+
|
|
999
|
+
if (trimmed.includes('requireAuth(')) {
|
|
1000
|
+
hasAuthInAction = true;
|
|
1001
|
+
if (trimmed.includes('this.receiver')) isSelfAuth = true;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (braceDepth <= 0 && i > currentActionLine) {
|
|
1005
|
+
actionLines.push({ name: currentAction, lineNum: currentActionLine, hasAuth: hasAuthInAction, isSelf: isSelfAuth });
|
|
1006
|
+
inAction = false;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
// Flush last action if file ends inside one
|
|
1011
|
+
if (inAction) {
|
|
1012
|
+
actionLines.push({ name: currentAction, lineNum: currentActionLine, hasAuth: hasAuthInAction, isSelf: isSelfAuth });
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// AUTH01: Action missing requireAuth (critical)
|
|
1016
|
+
for (const action of actionLines) {
|
|
1017
|
+
if (!action.hasAuth) {
|
|
1018
|
+
findings.push({
|
|
1019
|
+
id: 'AUTH01',
|
|
1020
|
+
severity: 'critical',
|
|
1021
|
+
title: `Action "${action.name}" missing requireAuth`,
|
|
1022
|
+
description: `The action at line ${action.lineNum} has no requireAuth() call. Any account can execute this action.`,
|
|
1023
|
+
line: action.lineNum,
|
|
1024
|
+
suggestion: 'Add requireAuth(actor) with the appropriate authority check.',
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// AUTH02: Notify handler missing firstReceiver check (warning)
|
|
1030
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1031
|
+
if (lines[i].includes('@action(') && lines[i].includes('notify')) {
|
|
1032
|
+
// Scan next 15 lines for firstReceiver check
|
|
1033
|
+
let hasCheck = false;
|
|
1034
|
+
for (let j = i + 1; j < Math.min(i + 15, lines.length); j++) {
|
|
1035
|
+
if (lines[j].includes('firstReceiver')) {
|
|
1036
|
+
hasCheck = true;
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (!hasCheck) {
|
|
1041
|
+
findings.push({
|
|
1042
|
+
id: 'AUTH02',
|
|
1043
|
+
severity: 'warning',
|
|
1044
|
+
title: 'Notify handler missing firstReceiver check',
|
|
1045
|
+
description: `Notify handler at line ${i + 1} does not check this.firstReceiver. Malicious contracts could send spoofed notifications.`,
|
|
1046
|
+
line: i + 1,
|
|
1047
|
+
suggestion: 'Add: if (this.firstReceiver != Name.fromString("eosio.token")) return;',
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// AUTH03: Admin action missing self-auth (info)
|
|
1054
|
+
const adminKeywords = ['init', 'setconfig', 'setpaused', 'pause', 'unpause', 'admin', 'setfee', 'setowner'];
|
|
1055
|
+
for (const action of actionLines) {
|
|
1056
|
+
if (adminKeywords.some(k => action.name.toLowerCase().includes(k)) && !action.isSelf) {
|
|
1057
|
+
findings.push({
|
|
1058
|
+
id: 'AUTH03',
|
|
1059
|
+
severity: 'info',
|
|
1060
|
+
title: `Admin action "${action.name}" missing this.receiver auth`,
|
|
1061
|
+
description: `Action "${action.name}" appears to be admin-only but doesn't require contract self-authorization.`,
|
|
1062
|
+
line: action.lineNum,
|
|
1063
|
+
suggestion: 'Add: requireAuth(this.receiver); for admin-only actions.',
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// TABLE02: Invalid table name (warning)
|
|
1069
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1070
|
+
const tableMatch = lines[i].match(/@table\("([^"]+)"/);
|
|
1071
|
+
if (tableMatch) {
|
|
1072
|
+
const tableName = tableMatch[1];
|
|
1073
|
+
if (tableName.length > 12 || !/^[a-z1-5.]+$/.test(tableName)) {
|
|
1074
|
+
findings.push({
|
|
1075
|
+
id: 'TABLE02',
|
|
1076
|
+
severity: 'warning',
|
|
1077
|
+
title: `Invalid table name "${tableName}"`,
|
|
1078
|
+
description: `Table name at line ${i + 1} uses invalid characters or exceeds 12 chars. Must be a-z, 1-5, and dots only.`,
|
|
1079
|
+
line: i + 1,
|
|
1080
|
+
suggestion: 'Rename to a valid EOSIO name (1-12 chars, a-z1-5. only).',
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// TABLE03: Missing secondary indexes on Name fields (info)
|
|
1087
|
+
let inTable = false;
|
|
1088
|
+
let tableHasSecondary = false;
|
|
1089
|
+
let tableNameFields: Array<{ name: string; line: number }> = [];
|
|
1090
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1091
|
+
const trimmed = lines[i].trim();
|
|
1092
|
+
if (trimmed.startsWith('@table(') && !trimmed.includes('singleton')) {
|
|
1093
|
+
inTable = true;
|
|
1094
|
+
tableHasSecondary = false;
|
|
1095
|
+
tableNameFields = [];
|
|
1096
|
+
}
|
|
1097
|
+
if (inTable) {
|
|
1098
|
+
if (trimmed.includes('@secondary')) tableHasSecondary = true;
|
|
1099
|
+
const fieldMatch = trimmed.match(/public\s+(\w+)\s*:\s*Name\s*=/);
|
|
1100
|
+
if (fieldMatch && fieldMatch[1] !== 'account' && !trimmed.includes('@primary')) {
|
|
1101
|
+
tableNameFields.push({ name: fieldMatch[1], line: i + 1 });
|
|
1102
|
+
}
|
|
1103
|
+
// End of class
|
|
1104
|
+
if (trimmed === '}' && !trimmed.includes('{')) {
|
|
1105
|
+
if (!tableHasSecondary && tableNameFields.length > 0) {
|
|
1106
|
+
for (const field of tableNameFields) {
|
|
1107
|
+
findings.push({
|
|
1108
|
+
id: 'TABLE03',
|
|
1109
|
+
severity: 'info',
|
|
1110
|
+
title: `Name field "${field.name}" has no secondary index`,
|
|
1111
|
+
description: `Consider adding a @secondary getter for "${field.name}" to enable efficient lookups.`,
|
|
1112
|
+
line: field.line,
|
|
1113
|
+
suggestion: `Add: @secondary\nget by${field.name.charAt(0).toUpperCase() + field.name.slice(1)}(): u64 { return this.${field.name}.N; }`,
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
inTable = false;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// INIT01: init() without re-init guard (critical)
|
|
1123
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1124
|
+
const trimmed = lines[i].trim();
|
|
1125
|
+
if (trimmed.includes('@action("init")') && !trimmed.includes('notify')) {
|
|
1126
|
+
// Scan next 20 lines for re-init guard patterns
|
|
1127
|
+
let hasGuard = false;
|
|
1128
|
+
for (let j = i + 1; j < Math.min(i + 20, lines.length); j++) {
|
|
1129
|
+
const checkLine = lines[j];
|
|
1130
|
+
if (checkLine.includes('Already initialized') ||
|
|
1131
|
+
checkLine.includes('already init') ||
|
|
1132
|
+
checkLine.includes('EMPTY_NAME') ||
|
|
1133
|
+
(checkLine.includes('.get()') && lines[j + 1]?.includes('check(') && lines[j + 1]?.includes('null'))) {
|
|
1134
|
+
hasGuard = true;
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if (!hasGuard) {
|
|
1139
|
+
findings.push({
|
|
1140
|
+
id: 'INIT01',
|
|
1141
|
+
severity: 'critical',
|
|
1142
|
+
title: 'init() action missing re-initialization guard',
|
|
1143
|
+
description: `The init() action at line ${i + 1} can be called multiple times, potentially overwriting config.`,
|
|
1144
|
+
line: i + 1,
|
|
1145
|
+
suggestion: 'Add: const existing = this.configSingleton.get(); check(existing === null, "Already initialized");',
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// REVERT01: check(false) after inline token transfer (critical)
|
|
1152
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1153
|
+
if (lines[i].includes('InlineAction') || lines[i].includes('.send(')) {
|
|
1154
|
+
// Look for check(false) in the next 10 lines
|
|
1155
|
+
for (let j = i + 1; j < Math.min(i + 10, lines.length); j++) {
|
|
1156
|
+
if (lines[j].includes('check(false')) {
|
|
1157
|
+
findings.push({
|
|
1158
|
+
id: 'REVERT01',
|
|
1159
|
+
severity: 'critical',
|
|
1160
|
+
title: 'check(false) after inline action — reverts the transfer!',
|
|
1161
|
+
description: `check(false) at line ${j + 1} will revert the entire transaction including the inline action at line ${i + 1}.`,
|
|
1162
|
+
line: j + 1,
|
|
1163
|
+
suggestion: 'Use return instead of check(false) after inline actions you want to keep.',
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// AS01: === string comparison (warning)
|
|
1171
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1172
|
+
// Match === with string-like operands (quotes or string variables)
|
|
1173
|
+
if (lines[i].includes('===') && (lines[i].includes('"') || lines[i].includes("'"))) {
|
|
1174
|
+
findings.push({
|
|
1175
|
+
id: 'AS01',
|
|
1176
|
+
severity: 'warning',
|
|
1177
|
+
title: '=== with string operands — compares references, not content',
|
|
1178
|
+
description: `Line ${i + 1} uses === which checks reference equality in AssemblyScript. Use == for string content comparison.`,
|
|
1179
|
+
line: i + 1,
|
|
1180
|
+
suggestion: 'Replace === with == for string comparisons.',
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// AS02: Arrow functions in .filter/.map/.reduce (warning)
|
|
1186
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1187
|
+
if (/\.(filter|map|reduce)\s*\(/.test(lines[i]) && lines[i].includes('=>')) {
|
|
1188
|
+
findings.push({
|
|
1189
|
+
id: 'AS02',
|
|
1190
|
+
severity: 'warning',
|
|
1191
|
+
title: 'Arrow function closure in .filter/.map/.reduce',
|
|
1192
|
+
description: `Line ${i + 1} uses arrow functions which don't support closures in AssemblyScript. Variables from outer scope won't be captured.`,
|
|
1193
|
+
line: i + 1,
|
|
1194
|
+
suggestion: 'Use a for loop instead of .filter/.map/.reduce with closures.',
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// AS03: try/catch (warning)
|
|
1200
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1201
|
+
if (/\btry\s*\{/.test(lines[i].trim())) {
|
|
1202
|
+
findings.push({
|
|
1203
|
+
id: 'AS03',
|
|
1204
|
+
severity: 'warning',
|
|
1205
|
+
title: 'try/catch not supported in AssemblyScript',
|
|
1206
|
+
description: `Line ${i + 1} uses try/catch which is not available in AssemblyScript. Use check() for validation.`,
|
|
1207
|
+
line: i + 1,
|
|
1208
|
+
suggestion: 'Remove try/catch and use check() assertions for error handling.',
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// AS04: : any type annotation (warning)
|
|
1214
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1215
|
+
if (/:\s*any\b/.test(lines[i])) {
|
|
1216
|
+
findings.push({
|
|
1217
|
+
id: 'AS04',
|
|
1218
|
+
severity: 'warning',
|
|
1219
|
+
title: '"any" type not supported in AssemblyScript',
|
|
1220
|
+
description: `Line ${i + 1} uses the "any" type which does not exist in AssemblyScript. All values must be typed.`,
|
|
1221
|
+
line: i + 1,
|
|
1222
|
+
suggestion: 'Replace with a concrete type (u64, string, Name, etc.) or use generics.',
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// AS05: undefined used as value (warning)
|
|
1228
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1229
|
+
if (/\bundefined\b/.test(lines[i]) && !lines[i].trim().startsWith('//') && !lines[i].trim().startsWith('*')) {
|
|
1230
|
+
findings.push({
|
|
1231
|
+
id: 'AS05',
|
|
1232
|
+
severity: 'warning',
|
|
1233
|
+
title: '"undefined" not available in AssemblyScript',
|
|
1234
|
+
description: `Line ${i + 1} uses "undefined" which does not exist as a value in AssemblyScript. Use null or default values.`,
|
|
1235
|
+
line: i + 1,
|
|
1236
|
+
suggestion: 'Replace "undefined" with null or a typed default value.',
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// FIN01: Floating point for financial calculations (warning)
|
|
1242
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1243
|
+
const trimmed = lines[i].trim();
|
|
1244
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
|
|
1245
|
+
if (/:\s*(f32|f64)\b/.test(lines[i]) || /\bFloat\b/.test(lines[i])) {
|
|
1246
|
+
// Check if it looks like financial context
|
|
1247
|
+
const context = lines.slice(Math.max(0, i - 3), i + 4).join(' ').toLowerCase();
|
|
1248
|
+
if (/balance|amount|price|fee|payment|cost|total|stake|reward/.test(context)) {
|
|
1249
|
+
findings.push({
|
|
1250
|
+
id: 'FIN01',
|
|
1251
|
+
severity: 'warning',
|
|
1252
|
+
title: 'Floating point used in financial context',
|
|
1253
|
+
description: `Line ${i + 1} uses floating point (f32/f64) near financial terms. Floating point causes rounding errors.`,
|
|
1254
|
+
line: i + 1,
|
|
1255
|
+
suggestion: 'Use u64 with fixed decimal places (e.g. 10000 = 1.0000 with 4 decimals).',
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// SEC01: Hardcoded private key pattern (warning)
|
|
1262
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1263
|
+
if (/PVT_K1_|5[HJK][1-9A-HJ-NP-Za-km-z]{49}/.test(lines[i])) {
|
|
1264
|
+
findings.push({
|
|
1265
|
+
id: 'SEC01',
|
|
1266
|
+
severity: 'warning',
|
|
1267
|
+
title: 'Possible hardcoded private key',
|
|
1268
|
+
description: `Line ${i + 1} appears to contain a private key. Never hardcode keys in contract source.`,
|
|
1269
|
+
line: i + 1,
|
|
1270
|
+
suggestion: 'Remove the private key immediately and rotate it.',
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// ITER01: Unbounded getAll() without limit (info)
|
|
1276
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1277
|
+
if (lines[i].includes('.getAll()') || lines[i].includes('.getAll(')) {
|
|
1278
|
+
findings.push({
|
|
1279
|
+
id: 'ITER01',
|
|
1280
|
+
severity: 'info',
|
|
1281
|
+
title: 'Unbounded table iteration with getAll()',
|
|
1282
|
+
description: `Line ${i + 1} reads all rows from a table. Large tables can exceed transaction CPU limits.`,
|
|
1283
|
+
line: i + 1,
|
|
1284
|
+
suggestion: 'Consider paginated reads or use cursor-based iteration with limits.',
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// SINGLE01: Singleton .get() without null check (warning)
|
|
1290
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1291
|
+
if (/Singleton.*\.get\(\)/.test(lines[i]) || /singleton.*\.get\(\)/.test(lines[i])) {
|
|
1292
|
+
// Check if next line has null/check
|
|
1293
|
+
const nextLines = lines.slice(i, Math.min(i + 3, lines.length)).join(' ');
|
|
1294
|
+
if (!nextLines.includes('null') && !nextLines.includes('check(') && !nextLines.includes('!== null') && !nextLines.includes('!= null')) {
|
|
1295
|
+
findings.push({
|
|
1296
|
+
id: 'SINGLE01',
|
|
1297
|
+
severity: 'warning',
|
|
1298
|
+
title: 'Singleton .get() without null check',
|
|
1299
|
+
description: `Line ${i + 1} reads a singleton without checking for null. Returns null if not initialized.`,
|
|
1300
|
+
line: i + 1,
|
|
1301
|
+
suggestion: 'Add: const config = this.singleton.get(); check(config !== null, "Not initialized");',
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// SCOPE01: Cross-contract table read (info)
|
|
1308
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1309
|
+
const match = lines[i].match(/@table\("([^"]+)",\s*"([^"]+)"\)/);
|
|
1310
|
+
if (match) {
|
|
1311
|
+
findings.push({
|
|
1312
|
+
id: 'SCOPE01',
|
|
1313
|
+
severity: 'info',
|
|
1314
|
+
title: `Cross-contract table read: "${match[1]}" from "${match[2]}"`,
|
|
1315
|
+
description: `Line ${i + 1} reads a table from another contract. Binary serialization requires fields to match exactly.`,
|
|
1316
|
+
line: i + 1,
|
|
1317
|
+
suggestion: 'Verify field order and types match the source contract exactly. Missing/reordered fields cause silent data corruption.',
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Sort: critical first, then warning, then info
|
|
1323
|
+
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
|
1324
|
+
findings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
1325
|
+
|
|
1326
|
+
return findings;
|
|
1327
|
+
}
|