@xpr-agents/openclaw 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -10
- package/openclaw.plugin.json +15 -1
- package/package.json +7 -4
- package/skills/code-sandbox/SKILL.md +30 -0
- package/skills/code-sandbox/dist/index.js +188 -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/dist/index.js +667 -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 +1745 -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 +1520 -0
- 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/dist/index.js +381 -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/dist/index.js +1225 -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 +1749 -0
- 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,656 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governance Skill — XPR Network governance (gov contract)
|
|
3
|
+
*
|
|
4
|
+
* Read-only tools use fetch-based RPC helpers (no signing).
|
|
5
|
+
* Write tools create a session from env vars for signing transactions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Types ────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
interface ToolDef {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
parameters: { type: 'object'; required?: string[]; properties: Record<string, unknown> };
|
|
14
|
+
handler: (params: any) => Promise<unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SkillApi {
|
|
18
|
+
registerTool(tool: ToolDef): void;
|
|
19
|
+
getConfig(): Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Constants ────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const GOV_CONTRACT = 'gov';
|
|
25
|
+
const GOV_API = 'https://gov.api.xprnetwork.org/api/v1/proposals';
|
|
26
|
+
const GOV_WEBSITE = 'https://gov.xprnetwork.org';
|
|
27
|
+
|
|
28
|
+
const MAINNET_RPC = 'https://xpr-mainnet-rpc.saltant.io';
|
|
29
|
+
|
|
30
|
+
// ── RPC Helper ───────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const RPC_TIMEOUT = 15000;
|
|
33
|
+
|
|
34
|
+
async function rpcPost(endpoint: string, path: string, body: unknown): Promise<any> {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => controller.abort(), RPC_TIMEOUT);
|
|
37
|
+
try {
|
|
38
|
+
const resp = await fetch(`${endpoint}${path}`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
});
|
|
44
|
+
if (!resp.ok) {
|
|
45
|
+
const text = await resp.text().catch(() => '');
|
|
46
|
+
throw new Error(`RPC ${path} failed (${resp.status}): ${text.slice(0, 200)}`);
|
|
47
|
+
}
|
|
48
|
+
return await resp.json();
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function getTableRows(endpoint: string, opts: {
|
|
55
|
+
code: string; scope: string; table: string;
|
|
56
|
+
lower_bound?: string | number; upper_bound?: string | number;
|
|
57
|
+
limit?: number; key_type?: string; index_position?: number;
|
|
58
|
+
json?: boolean; reverse?: boolean;
|
|
59
|
+
}): Promise<{ rows: any[]; more: boolean }> {
|
|
60
|
+
const result = await rpcPost(endpoint, '/v1/chain/get_table_rows', {
|
|
61
|
+
json: opts.json !== false,
|
|
62
|
+
code: opts.code,
|
|
63
|
+
scope: opts.scope,
|
|
64
|
+
table: opts.table,
|
|
65
|
+
lower_bound: opts.lower_bound,
|
|
66
|
+
upper_bound: opts.upper_bound,
|
|
67
|
+
limit: opts.limit || 100,
|
|
68
|
+
key_type: opts.key_type,
|
|
69
|
+
index_position: opts.index_position,
|
|
70
|
+
reverse: opts.reverse || false,
|
|
71
|
+
});
|
|
72
|
+
return { rows: result.rows || [], more: !!result.more };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Gov API Helper ───────────────────────────────
|
|
76
|
+
|
|
77
|
+
async function fetchGovApiProposal(contentId: string): Promise<any | null> {
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), RPC_TIMEOUT);
|
|
80
|
+
try {
|
|
81
|
+
const resp = await fetch(`${GOV_API}/${contentId}`, {
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
headers: { 'Accept': 'application/json' },
|
|
84
|
+
});
|
|
85
|
+
if (!resp.ok) return null;
|
|
86
|
+
return await resp.json();
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Session Factory ──────────────────────────────
|
|
95
|
+
|
|
96
|
+
// Session backed by the proton CLI — agent process never holds a private key.
|
|
97
|
+
|
|
98
|
+
let cachedSession: { api: any; account: string; permission: string } | null = null;
|
|
99
|
+
|
|
100
|
+
async function getGovSession(): Promise<{ api: any; account: string; permission: string }> {
|
|
101
|
+
if (cachedSession) return cachedSession;
|
|
102
|
+
|
|
103
|
+
const account = process.env.XPR_ACCOUNT;
|
|
104
|
+
const permission = process.env.XPR_PERMISSION || 'active';
|
|
105
|
+
|
|
106
|
+
if (!account) throw new Error('XPR_ACCOUNT is required for governance write operations');
|
|
107
|
+
|
|
108
|
+
// @ts-ignore — provided by host at runtime; not resolvable when building skills inside the openclaw package
|
|
109
|
+
|
|
110
|
+
const { createCliApi } = await import('@xpr-agents/openclaw');
|
|
111
|
+
cachedSession = createCliApi({ account, permission, rpcEndpoint: MAINNET_RPC });
|
|
112
|
+
return cachedSession;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Helpers ──────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function proposalStatus(proposal: any): string {
|
|
118
|
+
const now = Math.floor(Date.now() / 1000);
|
|
119
|
+
if (proposal.approve === 'Approved') return 'Approved';
|
|
120
|
+
if (proposal.approve === 'Declined') return 'Declined';
|
|
121
|
+
if (now < proposal.startTime) return 'Upcoming';
|
|
122
|
+
if (now <= proposal.endTime) return 'Active';
|
|
123
|
+
return 'Ended';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatTimestamp(ts: number): string {
|
|
127
|
+
return new Date(ts * 1000).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseQuantity(qty: string): { amount: number; symbol: string; precision: number } | null {
|
|
131
|
+
const parts = qty.trim().split(' ');
|
|
132
|
+
if (parts.length !== 2) return null;
|
|
133
|
+
const amount = parseFloat(parts[0]);
|
|
134
|
+
const symbol = parts[1];
|
|
135
|
+
const decParts = parts[0].split('.');
|
|
136
|
+
const precision = decParts.length > 1 ? decParts[1].length : 0;
|
|
137
|
+
return { amount, symbol, precision };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatAsset(amount: number, precision: number, symbol: string): string {
|
|
141
|
+
return `${amount.toFixed(precision)} ${symbol}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Skill Entry Point ────────────────────────────
|
|
145
|
+
|
|
146
|
+
export default function governanceSkill(api: SkillApi): void {
|
|
147
|
+
const config = api.getConfig();
|
|
148
|
+
const rpcEndpoint = MAINNET_RPC;
|
|
149
|
+
|
|
150
|
+
// ════════════════════════════════════════════════
|
|
151
|
+
// READ-ONLY TOOLS
|
|
152
|
+
// ════════════════════════════════════════════════
|
|
153
|
+
|
|
154
|
+
// ── 1. gov_list_communities ──
|
|
155
|
+
api.registerTool({
|
|
156
|
+
name: 'gov_list_communities',
|
|
157
|
+
description: 'List all XPR Network governance communities with their voting strategies, proposal fees, quorum requirements, and admins.',
|
|
158
|
+
parameters: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {},
|
|
161
|
+
},
|
|
162
|
+
handler: async () => {
|
|
163
|
+
try {
|
|
164
|
+
const { rows } = await getTableRows(rpcEndpoint, {
|
|
165
|
+
code: GOV_CONTRACT, scope: GOV_CONTRACT, table: 'communities', limit: 50,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
communities: rows.map((c: any) => {
|
|
170
|
+
const fee = parseQuantity(c.proposalFee?.quantity || '0 XPR');
|
|
171
|
+
return {
|
|
172
|
+
id: c.id,
|
|
173
|
+
name: c.name,
|
|
174
|
+
description: c.description,
|
|
175
|
+
controller: c.controller,
|
|
176
|
+
website: c.website,
|
|
177
|
+
strategies: c.strategies,
|
|
178
|
+
voting_systems: c.votingSystems,
|
|
179
|
+
proposal_fee: c.proposalFee?.quantity || 'unknown',
|
|
180
|
+
proposal_fee_contract: c.proposalFee?.contract || '',
|
|
181
|
+
min_proposal_time_seconds: c.minProposalTime,
|
|
182
|
+
quorum_basis_points: c.quorum,
|
|
183
|
+
quorum_pct: `${(c.quorum / 100).toFixed(2)}%`,
|
|
184
|
+
admins: c.admins,
|
|
185
|
+
approving_proposals: !!c.approvingProposal,
|
|
186
|
+
};
|
|
187
|
+
}),
|
|
188
|
+
total: rows.length,
|
|
189
|
+
};
|
|
190
|
+
} catch (err: any) {
|
|
191
|
+
return { error: `Failed to list communities: ${err.message}` };
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── 2. gov_list_proposals ──
|
|
197
|
+
api.registerTool({
|
|
198
|
+
name: 'gov_list_proposals',
|
|
199
|
+
description: 'List governance proposals. Can filter by community ID and status (Active, Upcoming, Ended, Approved, Declined). Returns most recent proposals first.',
|
|
200
|
+
parameters: {
|
|
201
|
+
type: 'object',
|
|
202
|
+
properties: {
|
|
203
|
+
community_id: { type: 'number', description: 'Filter by community ID (e.g. 3 for XPR Network)' },
|
|
204
|
+
status: { type: 'string', description: 'Filter by status: "Active", "Upcoming", "Ended", "Approved", "Declined"' },
|
|
205
|
+
limit: { type: 'number', description: 'Max proposals to return (default 20, max 100)' },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
handler: async ({ community_id, status, limit }: {
|
|
209
|
+
community_id?: number; status?: string; limit?: number;
|
|
210
|
+
}) => {
|
|
211
|
+
try {
|
|
212
|
+
const maxResults = Math.min(limit || 20, 100);
|
|
213
|
+
|
|
214
|
+
// Fetch proposals in reverse order (newest first)
|
|
215
|
+
const allProposals: any[] = [];
|
|
216
|
+
let more = true;
|
|
217
|
+
let fetchLimit = maxResults * 3; // over-fetch to account for filters
|
|
218
|
+
let attempts = 0;
|
|
219
|
+
|
|
220
|
+
// Paginate backwards from the latest proposals
|
|
221
|
+
const { rows: latestBatch } = await getTableRows(rpcEndpoint, {
|
|
222
|
+
code: GOV_CONTRACT, scope: GOV_CONTRACT, table: 'proposals',
|
|
223
|
+
limit: Math.min(fetchLimit, 100), reverse: true,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
for (const p of latestBatch) {
|
|
227
|
+
if (community_id !== undefined && p.communityId !== community_id) continue;
|
|
228
|
+
const pStatus = proposalStatus(p);
|
|
229
|
+
if (status && pStatus.toLowerCase() !== status.toLowerCase()) continue;
|
|
230
|
+
allProposals.push(p);
|
|
231
|
+
if (allProposals.length >= maxResults) break;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// If we need more and there are more rows, continue paginating
|
|
235
|
+
if (allProposals.length < maxResults && latestBatch.length === Math.min(fetchLimit, 100)) {
|
|
236
|
+
const lastId = latestBatch[latestBatch.length - 1]?.id;
|
|
237
|
+
if (lastId > 0) {
|
|
238
|
+
const { rows: nextBatch } = await getTableRows(rpcEndpoint, {
|
|
239
|
+
code: GOV_CONTRACT, scope: GOV_CONTRACT, table: 'proposals',
|
|
240
|
+
upper_bound: lastId - 1, limit: 100, reverse: true,
|
|
241
|
+
});
|
|
242
|
+
for (const p of nextBatch) {
|
|
243
|
+
if (community_id !== undefined && p.communityId !== community_id) continue;
|
|
244
|
+
const pStatus = proposalStatus(p);
|
|
245
|
+
if (status && pStatus.toLowerCase() !== status.toLowerCase()) continue;
|
|
246
|
+
allProposals.push(p);
|
|
247
|
+
if (allProposals.length >= maxResults) break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
proposals: allProposals.map((p: any) => ({
|
|
254
|
+
id: p.id,
|
|
255
|
+
author: p.author,
|
|
256
|
+
community_id: p.communityId,
|
|
257
|
+
content_id: p.content,
|
|
258
|
+
strategy: p.strategy,
|
|
259
|
+
voting_system: p.votingSystem,
|
|
260
|
+
candidates: p.candidates,
|
|
261
|
+
start_time: formatTimestamp(p.startTime),
|
|
262
|
+
end_time: formatTimestamp(p.endTime),
|
|
263
|
+
status: proposalStatus(p),
|
|
264
|
+
approve: p.approve || '',
|
|
265
|
+
url: `${GOV_WEBSITE}/communities/${p.communityId}/proposals/${p.id}`,
|
|
266
|
+
})),
|
|
267
|
+
total: allProposals.length,
|
|
268
|
+
filters: { community_id, status },
|
|
269
|
+
};
|
|
270
|
+
} catch (err: any) {
|
|
271
|
+
return { error: `Failed to list proposals: ${err.message}` };
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ── 3. gov_get_proposal ──
|
|
277
|
+
api.registerTool({
|
|
278
|
+
name: 'gov_get_proposal',
|
|
279
|
+
description: 'Get full details for a governance proposal by ID, including title and description from the Gov API and vote totals per candidate.',
|
|
280
|
+
parameters: {
|
|
281
|
+
type: 'object',
|
|
282
|
+
required: ['proposal_id'],
|
|
283
|
+
properties: {
|
|
284
|
+
proposal_id: { type: 'number', description: 'Proposal ID' },
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
handler: async ({ proposal_id }: { proposal_id: number }) => {
|
|
288
|
+
if (proposal_id === undefined || proposal_id === null) {
|
|
289
|
+
return { error: 'proposal_id is required' };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
// Fetch proposal from chain
|
|
294
|
+
const { rows } = await getTableRows(rpcEndpoint, {
|
|
295
|
+
code: GOV_CONTRACT, scope: GOV_CONTRACT, table: 'proposals',
|
|
296
|
+
lower_bound: proposal_id, upper_bound: proposal_id, limit: 1,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
if (rows.length === 0) {
|
|
300
|
+
return { error: `Proposal #${proposal_id} not found` };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const p = rows[0];
|
|
304
|
+
|
|
305
|
+
// Fetch title + vote data from Gov API
|
|
306
|
+
let govData: any = null;
|
|
307
|
+
if (p.content) {
|
|
308
|
+
govData = await fetchGovApiProposal(p.content);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Fetch community for context
|
|
312
|
+
const { rows: communities } = await getTableRows(rpcEndpoint, {
|
|
313
|
+
code: GOV_CONTRACT, scope: GOV_CONTRACT, table: 'communities',
|
|
314
|
+
lower_bound: p.communityId, upper_bound: p.communityId, limit: 1,
|
|
315
|
+
});
|
|
316
|
+
const community = communities[0] || null;
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
id: p.id,
|
|
320
|
+
author: p.author,
|
|
321
|
+
community_id: p.communityId,
|
|
322
|
+
community_name: community?.name || 'Unknown',
|
|
323
|
+
content_id: p.content,
|
|
324
|
+
title: govData?.title || '(title not available)',
|
|
325
|
+
description: govData?.description
|
|
326
|
+
? govData.description.replace(/<[^>]+>/g, '').slice(0, 2000)
|
|
327
|
+
: '(description not available)',
|
|
328
|
+
strategy: p.strategy,
|
|
329
|
+
voting_system: p.votingSystem,
|
|
330
|
+
candidates: (govData?.candidates || p.candidates).map((c: any) => ({
|
|
331
|
+
id: c.id,
|
|
332
|
+
name: c.name,
|
|
333
|
+
votes: c.tokenAmount ?? null,
|
|
334
|
+
})),
|
|
335
|
+
total_votes: govData?.tokenAmount ?? null,
|
|
336
|
+
quorum_pct: govData?.quorum !== undefined ? `${govData.quorum}%` : null,
|
|
337
|
+
community_quorum_pct: community ? `${(community.quorum / 100).toFixed(2)}%` : null,
|
|
338
|
+
start_time: formatTimestamp(p.startTime),
|
|
339
|
+
end_time: formatTimestamp(p.endTime),
|
|
340
|
+
status: proposalStatus(p),
|
|
341
|
+
approve: p.approve || '',
|
|
342
|
+
url: `${GOV_WEBSITE}/communities/${p.communityId}/proposals/${p.id}`,
|
|
343
|
+
};
|
|
344
|
+
} catch (err: any) {
|
|
345
|
+
return { error: `Failed to get proposal: ${err.message}` };
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ── 4. gov_get_votes ──
|
|
351
|
+
api.registerTool({
|
|
352
|
+
name: 'gov_get_votes',
|
|
353
|
+
description: 'Get individual votes cast on a governance proposal. Scans from most recent votes. May be slow for old proposals with many votes.',
|
|
354
|
+
parameters: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
required: ['proposal_id'],
|
|
357
|
+
properties: {
|
|
358
|
+
proposal_id: { type: 'number', description: 'Proposal ID' },
|
|
359
|
+
limit: { type: 'number', description: 'Max votes to return (default 50, max 200)' },
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
handler: async ({ proposal_id, limit }: { proposal_id: number; limit?: number }) => {
|
|
363
|
+
if (proposal_id === undefined || proposal_id === null) {
|
|
364
|
+
return { error: 'proposal_id is required' };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const maxVotes = Math.min(limit || 50, 200);
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
const matched: any[] = [];
|
|
371
|
+
let scanKey: number | undefined;
|
|
372
|
+
const MAX_SCAN_BATCHES = 20; // safety limit: scan at most 2000 rows
|
|
373
|
+
|
|
374
|
+
for (let batch = 0; batch < MAX_SCAN_BATCHES && matched.length < maxVotes; batch++) {
|
|
375
|
+
const opts: any = {
|
|
376
|
+
code: GOV_CONTRACT, scope: GOV_CONTRACT, table: 'votes',
|
|
377
|
+
limit: 100, reverse: true,
|
|
378
|
+
};
|
|
379
|
+
if (scanKey !== undefined) {
|
|
380
|
+
opts.upper_bound = scanKey;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const { rows, more } = await getTableRows(rpcEndpoint, opts);
|
|
384
|
+
|
|
385
|
+
if (rows.length === 0) break;
|
|
386
|
+
|
|
387
|
+
for (const v of rows) {
|
|
388
|
+
if (v.proposalId === proposal_id) {
|
|
389
|
+
matched.push({
|
|
390
|
+
vote_id: v.id,
|
|
391
|
+
voter: v.voter,
|
|
392
|
+
community_id: v.communityId,
|
|
393
|
+
winners: v.winners,
|
|
394
|
+
timestamp: formatTimestamp(v.timestamp),
|
|
395
|
+
});
|
|
396
|
+
if (matched.length >= maxVotes) break;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!more) break;
|
|
401
|
+
scanKey = rows[rows.length - 1].id - 1;
|
|
402
|
+
if (scanKey < 0) break;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const totalWeight = matched.reduce((sum, v) => {
|
|
406
|
+
return sum + v.winners.reduce((ws: number, w: any) => ws + (w.weight || 0), 0);
|
|
407
|
+
}, 0);
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
proposal_id,
|
|
411
|
+
votes: matched,
|
|
412
|
+
count: matched.length,
|
|
413
|
+
total_weight_scanned: totalWeight,
|
|
414
|
+
note: matched.length >= maxVotes
|
|
415
|
+
? `Returned first ${maxVotes} votes (most recent). Use Gov API for complete totals.`
|
|
416
|
+
: `Found ${matched.length} votes for this proposal.`,
|
|
417
|
+
};
|
|
418
|
+
} catch (err: any) {
|
|
419
|
+
return { error: `Failed to get votes: ${err.message}` };
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ── 5. gov_get_config ──
|
|
425
|
+
api.registerTool({
|
|
426
|
+
name: 'gov_get_config',
|
|
427
|
+
description: 'Get XPR Network governance global configuration — paused state, total communities, proposals, and votes.',
|
|
428
|
+
parameters: {
|
|
429
|
+
type: 'object',
|
|
430
|
+
properties: {},
|
|
431
|
+
},
|
|
432
|
+
handler: async () => {
|
|
433
|
+
try {
|
|
434
|
+
const { rows } = await getTableRows(rpcEndpoint, {
|
|
435
|
+
code: GOV_CONTRACT, scope: GOV_CONTRACT, table: 'govglobal', limit: 1,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
if (rows.length === 0) {
|
|
439
|
+
return { error: 'Governance global config not found' };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const cfg = rows[0];
|
|
443
|
+
return {
|
|
444
|
+
is_paused: !!cfg.isPaused,
|
|
445
|
+
next_community_id: cfg.communityId,
|
|
446
|
+
total_communities: cfg.communityId - 1, // IDs start at 1
|
|
447
|
+
next_proposal_id: cfg.proposalId,
|
|
448
|
+
total_proposals: cfg.proposalId,
|
|
449
|
+
next_vote_id: cfg.voteId,
|
|
450
|
+
total_votes: cfg.voteId,
|
|
451
|
+
};
|
|
452
|
+
} catch (err: any) {
|
|
453
|
+
return { error: `Failed to get config: ${err.message}` };
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// ════════════════════════════════════════════════
|
|
459
|
+
// WRITE TOOLS (require confirmation)
|
|
460
|
+
// ════════════════════════════════════════════════
|
|
461
|
+
|
|
462
|
+
// ── 6. gov_vote ──
|
|
463
|
+
api.registerTool({
|
|
464
|
+
name: 'gov_vote',
|
|
465
|
+
description: 'Vote on a governance proposal. For Yes/No proposals, use winners=[{id:0, weight:100}] for the first option or [{id:1, weight:100}] for the second. Check the proposal\'s candidates first.',
|
|
466
|
+
parameters: {
|
|
467
|
+
type: 'object',
|
|
468
|
+
required: ['community_id', 'proposal_id', 'winners', 'confirmed'],
|
|
469
|
+
properties: {
|
|
470
|
+
community_id: { type: 'number', description: 'Community ID (e.g. 3 for XPR Network)' },
|
|
471
|
+
proposal_id: { type: 'number', description: 'Proposal ID to vote on' },
|
|
472
|
+
winners: {
|
|
473
|
+
type: 'array',
|
|
474
|
+
description: 'Array of {id, weight} objects. id = candidate ID, weight = vote weight (typically 100 for full weight)',
|
|
475
|
+
},
|
|
476
|
+
confirmed: { type: 'boolean', description: 'Must be true to proceed' },
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
handler: async ({ community_id, proposal_id, winners, confirmed }: {
|
|
480
|
+
community_id: number; proposal_id: number; winners: { id: number; weight: number }[];
|
|
481
|
+
confirmed?: boolean;
|
|
482
|
+
}) => {
|
|
483
|
+
if (!confirmed) {
|
|
484
|
+
return {
|
|
485
|
+
error: 'Confirmation required. Set confirmed=true to cast your vote.',
|
|
486
|
+
community_id, proposal_id, winners,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
if (community_id === undefined) return { error: 'community_id is required' };
|
|
490
|
+
if (proposal_id === undefined) return { error: 'proposal_id is required' };
|
|
491
|
+
if (!Array.isArray(winners) || winners.length === 0) {
|
|
492
|
+
return { error: 'winners must be a non-empty array of {id, weight} objects' };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
const { api: eosApi, account, permission } = await getGovSession();
|
|
497
|
+
|
|
498
|
+
const result = await eosApi.transact({
|
|
499
|
+
actions: [{
|
|
500
|
+
account: GOV_CONTRACT,
|
|
501
|
+
name: 'vote',
|
|
502
|
+
authorization: [{ actor: account, permission }],
|
|
503
|
+
data: {
|
|
504
|
+
voter: account,
|
|
505
|
+
communityId: community_id,
|
|
506
|
+
proposalId: proposal_id,
|
|
507
|
+
winners: winners.map(w => ({ id: w.id, weight: w.weight })),
|
|
508
|
+
},
|
|
509
|
+
}],
|
|
510
|
+
}, { blocksBehind: 3, expireSeconds: 30 });
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
514
|
+
action: 'vote',
|
|
515
|
+
voter: account,
|
|
516
|
+
community_id,
|
|
517
|
+
proposal_id,
|
|
518
|
+
winners,
|
|
519
|
+
url: `${GOV_WEBSITE}/communities/${community_id}/proposals/${proposal_id}`,
|
|
520
|
+
};
|
|
521
|
+
} catch (err: any) {
|
|
522
|
+
return { error: `Failed to vote: ${err.message}` };
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ── 7. gov_post_proposal ──
|
|
528
|
+
api.registerTool({
|
|
529
|
+
name: 'gov_post_proposal',
|
|
530
|
+
description: 'Create a new governance proposal. Requires a content ID from the Gov API and pays the community\'s proposal fee. The fee is sent as a token transfer to the gov contract, followed by the postprop action.',
|
|
531
|
+
parameters: {
|
|
532
|
+
type: 'object',
|
|
533
|
+
required: ['community_id', 'content_id', 'strategy', 'voting_system', 'candidates', 'start_time', 'end_time', 'confirmed'],
|
|
534
|
+
properties: {
|
|
535
|
+
community_id: { type: 'number', description: 'Community ID to post in' },
|
|
536
|
+
content_id: { type: 'string', description: 'Content ID from Gov API (MongoDB ObjectId)' },
|
|
537
|
+
strategy: { type: 'string', description: 'Voting strategy (must match community strategies)' },
|
|
538
|
+
voting_system: { type: 'string', description: 'Voting system: "0"=single, "1"=multiple, "2"=ranked, "5"=approval' },
|
|
539
|
+
candidates: {
|
|
540
|
+
type: 'array',
|
|
541
|
+
description: 'Array of {id, name} candidates. e.g. [{id:0,name:"Yes"},{id:1,name:"No"}]',
|
|
542
|
+
},
|
|
543
|
+
start_time: { type: 'number', description: 'Unix timestamp (seconds) for voting start' },
|
|
544
|
+
end_time: { type: 'number', description: 'Unix timestamp (seconds) for voting end' },
|
|
545
|
+
confirmed: { type: 'boolean', description: 'Must be true to proceed' },
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
handler: async ({ community_id, content_id, strategy, voting_system, candidates, start_time, end_time, confirmed }: {
|
|
549
|
+
community_id: number; content_id: string; strategy: string; voting_system: string;
|
|
550
|
+
candidates: { id: number; name: string }[]; start_time: number; end_time: number;
|
|
551
|
+
confirmed?: boolean;
|
|
552
|
+
}) => {
|
|
553
|
+
if (!confirmed) {
|
|
554
|
+
return {
|
|
555
|
+
error: 'Confirmation required. Set confirmed=true to create this proposal. A proposal fee will be charged.',
|
|
556
|
+
community_id, content_id, strategy, voting_system, candidates, start_time, end_time,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
if (community_id === undefined) return { error: 'community_id is required' };
|
|
560
|
+
if (!content_id) return { error: 'content_id is required (from Gov API)' };
|
|
561
|
+
if (!strategy) return { error: 'strategy is required' };
|
|
562
|
+
if (!voting_system) return { error: 'voting_system is required' };
|
|
563
|
+
if (!Array.isArray(candidates) || candidates.length < 2) {
|
|
564
|
+
return { error: 'candidates must have at least 2 options' };
|
|
565
|
+
}
|
|
566
|
+
if (!start_time || !end_time) return { error: 'start_time and end_time are required (unix seconds)' };
|
|
567
|
+
if (end_time <= start_time) return { error: 'end_time must be after start_time' };
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
// Look up community to get proposal fee
|
|
571
|
+
const { rows: communities } = await getTableRows(rpcEndpoint, {
|
|
572
|
+
code: GOV_CONTRACT, scope: GOV_CONTRACT, table: 'communities',
|
|
573
|
+
lower_bound: community_id, upper_bound: community_id, limit: 1,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
if (communities.length === 0) {
|
|
577
|
+
return { error: `Community #${community_id} not found` };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const community = communities[0];
|
|
581
|
+
const fee = community.proposalFee;
|
|
582
|
+
|
|
583
|
+
if (!fee || !fee.quantity || !fee.contract) {
|
|
584
|
+
return { error: 'Could not determine proposal fee for this community' };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Validate strategy is allowed
|
|
588
|
+
if (!community.strategies.includes(strategy)) {
|
|
589
|
+
return {
|
|
590
|
+
error: `Strategy "${strategy}" not allowed for this community. Allowed: ${community.strategies.join(', ')}`,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Validate voting system is allowed
|
|
595
|
+
if (!community.votingSystems.includes(voting_system)) {
|
|
596
|
+
return {
|
|
597
|
+
error: `Voting system "${voting_system}" not allowed. Allowed: ${community.votingSystems.join(', ')}`,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const { api: eosApi, account, permission } = await getGovSession();
|
|
602
|
+
|
|
603
|
+
// Build transaction: fee transfer + postprop
|
|
604
|
+
const result = await eosApi.transact({
|
|
605
|
+
actions: [
|
|
606
|
+
// 1. Pay proposal fee
|
|
607
|
+
{
|
|
608
|
+
account: fee.contract,
|
|
609
|
+
name: 'transfer',
|
|
610
|
+
authorization: [{ actor: account, permission }],
|
|
611
|
+
data: {
|
|
612
|
+
from: account,
|
|
613
|
+
to: GOV_CONTRACT,
|
|
614
|
+
quantity: fee.quantity,
|
|
615
|
+
memo: `proposal fee for community ${community_id}`,
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
// 2. Post the proposal
|
|
619
|
+
{
|
|
620
|
+
account: GOV_CONTRACT,
|
|
621
|
+
name: 'postprop',
|
|
622
|
+
authorization: [{ actor: account, permission }],
|
|
623
|
+
data: {
|
|
624
|
+
author: account,
|
|
625
|
+
communityId: community_id,
|
|
626
|
+
content: content_id,
|
|
627
|
+
strategy,
|
|
628
|
+
votingSystem: voting_system,
|
|
629
|
+
candidates: candidates.map(c => ({ id: c.id, name: c.name })),
|
|
630
|
+
startTime: start_time,
|
|
631
|
+
endTime: end_time,
|
|
632
|
+
approve: '',
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
],
|
|
636
|
+
}, { blocksBehind: 3, expireSeconds: 30 });
|
|
637
|
+
|
|
638
|
+
return {
|
|
639
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
640
|
+
action: 'post_proposal',
|
|
641
|
+
author: account,
|
|
642
|
+
community_id,
|
|
643
|
+
community_name: community.name,
|
|
644
|
+
content_id,
|
|
645
|
+
fee_paid: fee.quantity,
|
|
646
|
+
candidates,
|
|
647
|
+
start_time: formatTimestamp(start_time),
|
|
648
|
+
end_time: formatTimestamp(end_time),
|
|
649
|
+
note: 'Proposal created. It will appear on the governance dashboard after admin approval.',
|
|
650
|
+
};
|
|
651
|
+
} catch (err: any) {
|
|
652
|
+
return { error: `Failed to create proposal: ${err.message}` };
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
}
|