spendos 0.1.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.
Files changed (90) hide show
  1. package/.dockerignore +4 -0
  2. package/.env.example +30 -0
  3. package/AGENTS.md +212 -0
  4. package/BOOTSTRAP.md +55 -0
  5. package/Dockerfile +52 -0
  6. package/HEARTBEAT.md +7 -0
  7. package/IDENTITY.md +23 -0
  8. package/LICENSE +21 -0
  9. package/README.md +162 -0
  10. package/SOUL.md +202 -0
  11. package/SUBMISSION.md +128 -0
  12. package/TOOLS.md +40 -0
  13. package/USER.md +17 -0
  14. package/acp-seller/bin/acp.ts +807 -0
  15. package/acp-seller/config.json +34 -0
  16. package/acp-seller/package.json +55 -0
  17. package/acp-seller/src/commands/agent.ts +328 -0
  18. package/acp-seller/src/commands/bounty.ts +1189 -0
  19. package/acp-seller/src/commands/deploy.ts +414 -0
  20. package/acp-seller/src/commands/job.ts +217 -0
  21. package/acp-seller/src/commands/profile.ts +71 -0
  22. package/acp-seller/src/commands/resource.ts +91 -0
  23. package/acp-seller/src/commands/search.ts +327 -0
  24. package/acp-seller/src/commands/sell.ts +883 -0
  25. package/acp-seller/src/commands/serve.ts +258 -0
  26. package/acp-seller/src/commands/setup.ts +399 -0
  27. package/acp-seller/src/commands/token.ts +88 -0
  28. package/acp-seller/src/commands/wallet.ts +123 -0
  29. package/acp-seller/src/lib/api.ts +118 -0
  30. package/acp-seller/src/lib/auth.ts +291 -0
  31. package/acp-seller/src/lib/bounty.ts +257 -0
  32. package/acp-seller/src/lib/client.ts +42 -0
  33. package/acp-seller/src/lib/config.ts +240 -0
  34. package/acp-seller/src/lib/open.ts +41 -0
  35. package/acp-seller/src/lib/openclawCron.ts +138 -0
  36. package/acp-seller/src/lib/output.ts +104 -0
  37. package/acp-seller/src/lib/wallet.ts +81 -0
  38. package/acp-seller/src/seller/offerings/_shared/preTransactionScan.ts +127 -0
  39. package/acp-seller/src/seller/offerings/canonical-catalog.ts +221 -0
  40. package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/handlers.ts +20 -0
  41. package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/offering.json +18 -0
  42. package/acp-seller/src/seller/offerings/spendos/spendos_translate/handlers.ts +21 -0
  43. package/acp-seller/src/seller/offerings/spendos/spendos_translate/offering.json +22 -0
  44. package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/handlers.ts +20 -0
  45. package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/offering.json +18 -0
  46. package/acp-seller/src/seller/runtime/acpSocket.ts +413 -0
  47. package/acp-seller/src/seller/runtime/logger.ts +36 -0
  48. package/acp-seller/src/seller/runtime/offeringTypes.ts +52 -0
  49. package/acp-seller/src/seller/runtime/offerings.ts +277 -0
  50. package/acp-seller/src/seller/runtime/paymentVerification.test.ts +207 -0
  51. package/acp-seller/src/seller/runtime/paymentVerification.ts +363 -0
  52. package/acp-seller/src/seller/runtime/seller.onchain.test.ts +220 -0
  53. package/acp-seller/src/seller/runtime/seller.test.ts +823 -0
  54. package/acp-seller/src/seller/runtime/seller.ts +1041 -0
  55. package/acp-seller/src/seller/runtime/sellerApi.ts +71 -0
  56. package/acp-seller/src/seller/runtime/startup.ts +270 -0
  57. package/acp-seller/src/seller/runtime/types.ts +62 -0
  58. package/acp-seller/tsconfig.json +20 -0
  59. package/bin/spendos.js +23 -0
  60. package/contracts/SpendOSAudit.sol +29 -0
  61. package/dist/mcp-server.mjs +153 -0
  62. package/jobs/translate.json +7 -0
  63. package/jobs/tweet-gen.json +7 -0
  64. package/openclaw.json +41 -0
  65. package/package.json +49 -0
  66. package/plugins/spendos-events/index.ts +78 -0
  67. package/plugins/spendos-events/package.json +14 -0
  68. package/policies/enforce-bounds.mjs +71 -0
  69. package/public/index.html +509 -0
  70. package/public/landing.html +241 -0
  71. package/railway.json +12 -0
  72. package/railway.toml +12 -0
  73. package/scripts/deploy.ts +48 -0
  74. package/scripts/test-x402-mainnet.ts +30 -0
  75. package/scripts/xmtp-listener.ts +61 -0
  76. package/setup.sh +278 -0
  77. package/skills/spendos/skill.md +26 -0
  78. package/src/agent.ts +152 -0
  79. package/src/audit.ts +166 -0
  80. package/src/governance.ts +367 -0
  81. package/src/job-registry.ts +306 -0
  82. package/src/mcp-public.ts +145 -0
  83. package/src/mcp-server.ts +171 -0
  84. package/src/opportunity-scanner.ts +138 -0
  85. package/src/server.ts +870 -0
  86. package/src/venice-x402.ts +234 -0
  87. package/src/xmtp.ts +109 -0
  88. package/src/zerion.ts +58 -0
  89. package/start.sh +168 -0
  90. package/tsconfig.json +14 -0
@@ -0,0 +1,509 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>SpendOS</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ tailwind.config = {
10
+ darkMode: 'class',
11
+ theme: { extend: { colors: {
12
+ bg: '#0a0a0a', surface: '#141414', border: '#262626',
13
+ accent: '#22c55e', danger: '#ef4444', warn: '#f59e0b',
14
+ }}}
15
+ }
16
+ </script>
17
+ <style>
18
+ body { font-family: 'SF Mono', 'Fira Code', monospace; }
19
+ .countdown { font-variant-numeric: tabular-nums; }
20
+ @keyframes pulse-dot { 0%,100% { opacity:1 } 50% { opacity:0.4 } }
21
+ .pulse-dot { animation: pulse-dot 2s infinite; }
22
+ </style>
23
+ </head>
24
+ <body class="bg-bg text-gray-200 min-h-screen">
25
+
26
+ <!-- Header -->
27
+ <header class="border-b border-border px-6 py-4 flex items-center justify-between">
28
+ <div class="flex items-center gap-3">
29
+ <div class="w-3 h-3 rounded-full bg-accent pulse-dot"></div>
30
+ <h1 class="text-xl font-bold text-white tracking-tight">SpendOS</h1>
31
+ <span class="text-xs text-gray-500">agent spend governance</span>
32
+ </div>
33
+ <div id="wallet-badge" class="text-xs text-gray-500 font-mono"></div>
34
+ </header>
35
+
36
+ <main class="max-w-5xl mx-auto px-6 py-6 space-y-6">
37
+
38
+ <!-- P&L Panel -->
39
+ <section class="bg-surface border border-border rounded-lg p-5">
40
+ <h2 class="text-sm font-semibold text-gray-400 mb-3 uppercase tracking-wider">Agent P&L</h2>
41
+ <div class="grid grid-cols-2 md:grid-cols-5 gap-4">
42
+ <div>
43
+ <div class="text-xs text-gray-500">Earned</div>
44
+ <div id="pnl-earned" class="text-lg font-bold text-accent">$0.00</div>
45
+ </div>
46
+ <div>
47
+ <div class="text-xs text-gray-500">Spent</div>
48
+ <div id="pnl-spent" class="text-lg font-bold text-danger">$0.00</div>
49
+ </div>
50
+ <div>
51
+ <div class="text-xs text-gray-500">Profit</div>
52
+ <div id="pnl-profit" class="text-lg font-bold text-white">$0.00</div>
53
+ </div>
54
+ <div>
55
+ <div class="text-xs text-gray-500">Queries</div>
56
+ <div id="pnl-queries" class="text-lg font-bold text-white">0</div>
57
+ </div>
58
+ <div>
59
+ <div class="text-xs text-gray-500">Margin</div>
60
+ <div id="pnl-margin" class="text-lg font-bold text-gray-400">—</div>
61
+ </div>
62
+ </div>
63
+ </section>
64
+
65
+ <!-- Pending Delegations -->
66
+ <section>
67
+ <h2 class="text-sm font-semibold text-gray-400 mb-3 uppercase tracking-wider">Delegation Requests</h2>
68
+ <div id="pending-cards" class="space-y-4">
69
+ <div class="text-gray-600 text-sm">No pending requests</div>
70
+ </div>
71
+ </section>
72
+
73
+ <!-- Active Delegations -->
74
+ <section>
75
+ <h2 class="text-sm font-semibold text-gray-400 mb-3 uppercase tracking-wider">Active Delegations</h2>
76
+ <div id="active-table" class="text-gray-600 text-sm">No active delegations</div>
77
+ </section>
78
+
79
+ <!-- Audit Log -->
80
+ <section>
81
+ <h2 class="text-sm font-semibold text-gray-400 mb-3 uppercase tracking-wider">Audit Log</h2>
82
+ <div id="audit-log" class="bg-surface border border-border rounded-lg overflow-hidden">
83
+ <table class="w-full text-xs">
84
+ <thead class="bg-bg">
85
+ <tr class="text-gray-500">
86
+ <th class="text-left px-3 py-2">Time</th>
87
+ <th class="text-left px-3 py-2">Action</th>
88
+ <th class="text-left px-3 py-2">Details</th>
89
+ <th class="text-right px-3 py-2">Amount</th>
90
+ <th class="text-right px-3 py-2">Tx</th>
91
+ </tr>
92
+ </thead>
93
+ <tbody id="audit-tbody"></tbody>
94
+ </table>
95
+ </div>
96
+ </section>
97
+
98
+ <!-- Agent Chat -->
99
+ <section>
100
+ <h2 class="text-sm font-semibold text-gray-400 mb-3 uppercase tracking-wider">Chat with Agent</h2>
101
+ <div class="bg-surface border border-border rounded-lg overflow-hidden">
102
+ <div id="chat-messages" class="p-4 h-48 overflow-y-auto text-sm space-y-2">
103
+ <div class="text-gray-600">Send a message to talk to the SpendOS agent...</div>
104
+ </div>
105
+ <div class="border-t border-border p-3 flex gap-2">
106
+ <input id="chat-input" type="text" placeholder="Ask the agent anything..."
107
+ class="flex-1 bg-bg border border-border rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-accent"
108
+ onkeydown="if(event.key==='Enter')sendChat()">
109
+ <button onclick="sendChat()"
110
+ class="px-4 py-2 bg-accent/20 text-accent rounded text-sm font-semibold hover:bg-accent/30 cursor-pointer">
111
+ Send
112
+ </button>
113
+ </div>
114
+ </div>
115
+ </section>
116
+
117
+ </main>
118
+
119
+ <script>
120
+ // Load persistent chat history on page load
121
+ async function loadHistory() {
122
+ try {
123
+ const res = await fetch(`${API}/api/chat/history`);
124
+ const history = await res.json();
125
+ const msgs = document.getElementById('chat-messages');
126
+ for (const msg of history.slice(-30)) { // show last 30 on load
127
+ if (msg.role === 'user') {
128
+ msgs.innerHTML += `<div class="text-white"><span class="text-accent font-bold">You:</span> ${esc(msg.content)}</div>`;
129
+ } else if (msg.role === 'assistant') {
130
+ msgs.innerHTML += `<div><span class="text-warn font-bold">Agent:</span> <span class="text-gray-300">${esc(msg.content.slice(0, 500))}${msg.content.length > 500 ? '...' : ''}</span></div>`;
131
+ }
132
+ }
133
+ msgs.scrollTop = msgs.scrollHeight;
134
+ } catch {}
135
+ }
136
+ loadHistory();
137
+
138
+ async function sendChat() {
139
+ const input = document.getElementById('chat-input');
140
+ const msg = input.value.trim();
141
+ if (!msg) return;
142
+ input.value = '';
143
+
144
+ const msgs = document.getElementById('chat-messages');
145
+ msgs.innerHTML += `<div class="text-white"><span class="text-accent font-bold">You:</span> ${esc(msg)}</div>`;
146
+
147
+ const agentDiv = document.createElement('div');
148
+ agentDiv.innerHTML = '<span class="text-warn font-bold">Agent:</span> <span class="agent-reply text-gray-300"><span class="text-gray-600 italic">thinking...</span></span>';
149
+ msgs.appendChild(agentDiv);
150
+ msgs.scrollTop = msgs.scrollHeight;
151
+
152
+ const replySpan = agentDiv.querySelector('.agent-reply');
153
+ let gotContent = false;
154
+
155
+ // Timeout: if no content after 30s, show error
156
+ const timeout = setTimeout(() => {
157
+ if (!gotContent) replySpan.textContent = 'Connection lost (server may be restarting). Try again.';
158
+ }, 30000);
159
+
160
+ try {
161
+ // Route through /api/chat which goes to OpenClaw (has MCP tools, memory, SOUL.md)
162
+ const res = await fetch(`${API}/api/chat`, {
163
+ method: 'POST',
164
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${TOKEN}` },
165
+ body: JSON.stringify({ message: msg })
166
+ });
167
+
168
+ const reader = res.body.getReader();
169
+ const decoder = new TextDecoder();
170
+ let buffer = '';
171
+
172
+ while (true) {
173
+ const { done, value } = await reader.read();
174
+ if (done) break;
175
+ buffer += decoder.decode(value, { stream: true });
176
+
177
+ const lines = buffer.split('\n');
178
+ buffer = lines.pop();
179
+
180
+ for (const line of lines) {
181
+ if (!line.startsWith('data: ')) continue;
182
+ const data = line.slice(6);
183
+ if (data === '[DONE]') continue;
184
+ try {
185
+ const chunk = JSON.parse(data);
186
+ const content = chunk.choices?.[0]?.delta?.content;
187
+ if (content) {
188
+ if (!gotContent) { replySpan.textContent = ''; gotContent = true; clearTimeout(timeout); }
189
+ replySpan.textContent += content;
190
+ msgs.scrollTop = msgs.scrollHeight;
191
+ }
192
+ } catch {}
193
+ }
194
+ }
195
+ } catch (err) {
196
+ replySpan.textContent = 'Error: ' + err.message;
197
+ }
198
+ }
199
+ </script>
200
+
201
+ <script>
202
+ const API = '';
203
+ // Admin token from URL param: ?token=xxx
204
+ const TOKEN = new URLSearchParams(window.location.search).get('token') || '';
205
+ let USER_ROLE = 'none'; // 'admin', 'demo', or 'none'
206
+
207
+ // Check auth level on load
208
+ fetch(`${API}/api/auth`, { headers: { 'Authorization': `Bearer ${TOKEN}` } })
209
+ .then(r => r.json()).then(d => { USER_ROLE = d.role || 'none'; })
210
+ .catch(() => {});
211
+
212
+ // ── Polling ──────────────────────────────────────
213
+
214
+ const authHeaders = TOKEN ? { 'Authorization': `Bearer ${TOKEN}` } : {};
215
+
216
+ async function poll() {
217
+ try {
218
+ const [pnl, requests, audit, wallet] = await Promise.all([
219
+ fetch(`${API}/api/pnl`).then(r => r.json()),
220
+ fetch(`${API}/api/requests`, { headers: authHeaders }).then(r => r.ok ? r.json() : []),
221
+ fetch(`${API}/api/audit`).then(r => r.json()),
222
+ fetch(`${API}/api/wallet`).then(r => r.json()),
223
+ ]);
224
+ renderPnL(pnl);
225
+ renderRequests(requests);
226
+ renderAudit(audit);
227
+ renderWallet(wallet);
228
+ } catch (err) {
229
+ console.error('Poll error:', err);
230
+ }
231
+ }
232
+
233
+ let lastPendingCount = 0;
234
+ setInterval(poll, 1500);
235
+ poll();
236
+
237
+ // ── Renderers ────────────────────────────────────
238
+
239
+ function renderPnL(pnl) {
240
+ document.getElementById('pnl-earned').textContent = `$${pnl.totalEarned.toFixed(3)}`;
241
+ document.getElementById('pnl-spent').textContent = `$${pnl.totalSpent.toFixed(3)}`;
242
+ document.getElementById('pnl-profit').textContent = `$${pnl.profit.toFixed(3)}`;
243
+ document.getElementById('pnl-queries').textContent = pnl.queryCount;
244
+ const margin = pnl.totalEarned > 0
245
+ ? ((pnl.profit / pnl.totalEarned) * 100).toFixed(0) + '%'
246
+ : '—';
247
+ document.getElementById('pnl-margin').textContent = margin;
248
+ }
249
+
250
+ function renderWallet(wallet) {
251
+ const addr = wallet.address;
252
+ const short = addr ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : 'loading...';
253
+ const mode = wallet.inferenceMode === 'x402' ? 'x402 wallet' : wallet.inferenceMode === 'api_key' ? 'API key' : 'none';
254
+ const bal = wallet.veniceBalance;
255
+ const balText = bal ? ` · Venice: $${bal.balanceUsd.toFixed(2)}` : '';
256
+ document.getElementById('wallet-badge').innerHTML =
257
+ `<span class="text-white">${short}</span>` +
258
+ `<span class="text-gray-600 mx-1">·</span>` +
259
+ `<span class="text-accent text-[10px]">${mode}</span>` +
260
+ `<span class="text-gray-500">${balText}</span>`;
261
+ }
262
+
263
+ function renderRequests(requests) {
264
+ const pending = requests.filter(r => r.status === 'pending');
265
+ const active = requests.filter(r => r.status === 'approved');
266
+ const decided = requests.filter(r => ['rejected', 'expired', 'revoked'].includes(r.status));
267
+
268
+ // Pending cards — flash if new delegation arrived
269
+ const pendingEl = document.getElementById('pending-cards');
270
+ if (pending.length > lastPendingCount && lastPendingCount >= 0) {
271
+ pendingEl.style.transition = 'none';
272
+ pendingEl.style.boxShadow = '0 0 30px #22c55e44';
273
+ setTimeout(() => { pendingEl.style.transition = 'box-shadow 2s'; pendingEl.style.boxShadow = 'none'; }, 100);
274
+ }
275
+ lastPendingCount = pending.length;
276
+ if (pending.length === 0) {
277
+ pendingEl.innerHTML = '<div class="text-gray-600 text-sm">No pending requests</div>';
278
+ } else {
279
+ pendingEl.innerHTML = pending.map(d => renderCard(d)).join('');
280
+ }
281
+
282
+ // Active table
283
+ const activeEl = document.getElementById('active-table');
284
+ if (active.length === 0) {
285
+ activeEl.innerHTML = '<div class="text-gray-600 text-sm">No active delegations</div>';
286
+ } else {
287
+ activeEl.innerHTML = `
288
+ <div class="bg-surface border border-border rounded-lg overflow-hidden">
289
+ <table class="w-full text-xs">
290
+ <thead class="bg-bg">
291
+ <tr class="text-gray-500">
292
+ <th class="text-left px-3 py-2">Agent</th>
293
+ <th class="text-left px-3 py-2">Reason</th>
294
+ <th class="text-left px-3 py-2">Chains</th>
295
+ <th class="text-right px-3 py-2">Budget</th>
296
+ <th class="text-right px-3 py-2">Expires</th>
297
+ <th class="text-right px-3 py-2">Actions</th>
298
+ </tr>
299
+ </thead>
300
+ <tbody>
301
+ ${active.map(d => `
302
+ <tr class="border-t border-border">
303
+ <td class="px-3 py-2 font-mono">${shortAddr(d.agentAddress)}</td>
304
+ <td class="px-3 py-2">${esc(d.reason)}</td>
305
+ <td class="px-3 py-2">${d.chains.join(', ')}</td>
306
+ <td class="px-3 py-2 text-right">$${d.totalBudget}</td>
307
+ <td class="px-3 py-2 text-right countdown">${countdown(d.expiresAt)}</td>
308
+ <td class="px-3 py-2 text-right">
309
+ <button onclick="revokeD('${d.id}')"
310
+ class="px-2 py-1 bg-danger/20 text-danger rounded text-xs hover:bg-danger/30 cursor-pointer">
311
+ Revoke
312
+ </button>
313
+ </td>
314
+ </tr>
315
+ `).join('')}
316
+ </tbody>
317
+ </table>
318
+ </div>
319
+ `;
320
+ }
321
+ }
322
+
323
+ function renderCard(d) {
324
+ return `
325
+ <div class="bg-surface border border-border rounded-lg p-5">
326
+ <div class="flex items-center justify-between mb-3">
327
+ <span class="text-xs font-semibold text-warn uppercase tracking-wider">Delegation Request</span>
328
+ <span class="text-xs text-gray-500 countdown">${countdown(d.expiresAt)}</span>
329
+ </div>
330
+
331
+ <div class="space-y-2 text-sm mb-4">
332
+ <div class="flex justify-between">
333
+ <span class="text-gray-500">Agent</span>
334
+ <span class="font-mono text-white">${shortAddr(d.agentAddress)}</span>
335
+ </div>
336
+ <div class="flex justify-between">
337
+ <span class="text-gray-500">Reason</span>
338
+ <span class="text-white">${esc(d.reason)}</span>
339
+ </div>
340
+ </div>
341
+
342
+ ${d.aiInterpretation ? `
343
+ <div class="bg-bg border border-border rounded p-3 mb-4 text-xs">
344
+ <div class="flex items-center gap-2 mb-2">
345
+ <span class="text-gray-500 font-semibold uppercase tracking-wider">AI Assessment</span>
346
+ <span class="px-1.5 py-0.5 rounded text-[10px] font-bold ${
347
+ d.aiInterpretation.riskLevel === 'high' ? 'bg-danger/20 text-danger' :
348
+ d.aiInterpretation.riskLevel === 'medium' ? 'bg-warn/20 text-warn' :
349
+ 'bg-accent/20 text-accent'
350
+ }">${d.aiInterpretation.riskLevel.toUpperCase()}</span>
351
+ </div>
352
+ <p class="text-gray-300 mb-1">${esc(d.aiInterpretation.summary)}</p>
353
+ ${d.aiInterpretation.warnings.length ? `
354
+ <div class="mt-2 space-y-1">
355
+ ${d.aiInterpretation.warnings.map(w => `
356
+ <div class="text-warn">⚠ ${esc(w)}</div>
357
+ `).join('')}
358
+ </div>
359
+ ` : ''}
360
+ </div>
361
+ ` : ''}
362
+
363
+ <div class="bg-bg border border-border rounded p-3 mb-4 space-y-1 text-xs">
364
+ <div class="text-gray-500 font-semibold uppercase tracking-wider mb-1">Policy Bounds</div>
365
+ <div class="flex justify-between">
366
+ <span class="text-gray-500">Chains</span>
367
+ <span>${d.chains.join(', ')}</span>
368
+ </div>
369
+ <div class="flex justify-between">
370
+ <span class="text-gray-500">Operations</span>
371
+ <span>${d.operations.join(', ')}</span>
372
+ </div>
373
+ <div class="flex justify-between">
374
+ <span class="text-gray-500">Max / Action</span>
375
+ <span>$${d.maxAmountPerAction}</span>
376
+ </div>
377
+ <div class="flex justify-between">
378
+ <span class="text-gray-500">Total Budget</span>
379
+ <span>$${d.totalBudget}</span>
380
+ </div>
381
+ ${d.allowedRecipients?.length ? `
382
+ <div class="flex justify-between">
383
+ <span class="text-gray-500">Vendors</span>
384
+ <span>${d.allowedRecipients.map(r => r.slice(0,6)+'...'+r.slice(-4)).join(', ')}</span>
385
+ </div>
386
+ ` : ''}
387
+ <div class="flex justify-between">
388
+ <span class="text-gray-500">Expires</span>
389
+ <span class="countdown">${countdown(d.expiresAt)}</span>
390
+ </div>
391
+ </div>
392
+
393
+ ${USER_ROLE === 'admin' ? `
394
+ <div class="flex gap-3">
395
+ <button onclick="approveD('${d.id}')"
396
+ class="flex-1 py-2 bg-accent/20 text-accent rounded font-semibold text-sm hover:bg-accent/30 cursor-pointer">
397
+ Approve Delegation
398
+ </button>
399
+ <button onclick="rejectD('${d.id}')"
400
+ class="flex-1 py-2 bg-danger/20 text-danger rounded font-semibold text-sm hover:bg-danger/30 cursor-pointer">
401
+ Reject
402
+ </button>
403
+ </div>
404
+ ` : `
405
+ <a href="https://github.com/consensus-hq/spendos" target="_blank"
406
+ class="block text-center py-2 text-gray-500 text-xs border border-border rounded hover:border-accent hover:text-accent transition cursor-pointer">
407
+ Demo mode — <span class="underline">deploy your own SpendOS</span> to approve delegations
408
+ </a>
409
+ `}
410
+ </div>
411
+ `;
412
+ }
413
+
414
+ function renderAudit(entries) {
415
+ const tbody = document.getElementById('audit-tbody');
416
+ if (!entries.length) {
417
+ tbody.innerHTML = '<tr><td colspan="5" class="px-3 py-4 text-center text-gray-600">No audit events yet</td></tr>';
418
+ return;
419
+ }
420
+ const showAll = tbody.dataset.showAll === 'true';
421
+ // Filter out noisy "requested" entries — show governance decisions + earnings
422
+ const meaningful = entries.filter(e => e.action !== 'requested');
423
+ const visible = showAll ? meaningful : meaningful.slice(0, 10);
424
+ tbody.innerHTML = visible.map(e => {
425
+ const actionColor = {
426
+ earned: 'text-accent', approved: 'text-accent',
427
+ rejected: 'text-danger', revoked: 'text-danger',
428
+ expired: 'text-warn', spent: 'text-warn',
429
+ requested: 'text-gray-400',
430
+ }[e.action] ?? 'text-gray-400';
431
+
432
+ const txLink = e.txHash
433
+ ? `<a href="https://basescan.org/tx/${e.txHash}" target="_blank" class="text-blue-400 hover:underline">${e.txHash.slice(0, 8)}...</a>`
434
+ : '<span class="text-gray-600">local</span>';
435
+
436
+ return `
437
+ <tr class="border-t border-border hover:bg-bg/50">
438
+ <td class="px-3 py-2 text-gray-500">${new Date(e.timestamp).toLocaleTimeString()}</td>
439
+ <td class="px-3 py-2 ${actionColor} font-semibold">${e.action}</td>
440
+ <td class="px-3 py-2 truncate max-w-[200px]">${esc(e.details)}</td>
441
+ <td class="px-3 py-2 text-right">${e.amount > 0 ? '$' + e.amount.toFixed(3) : '—'}</td>
442
+ <td class="px-3 py-2 text-right">${txLink}</td>
443
+ </tr>
444
+ `;
445
+ }).join('');
446
+ if (meaningful.length > 10 && !showAll) {
447
+ tbody.innerHTML += `<tr><td colspan="5" class="px-3 py-2 text-center">
448
+ <button onclick="document.getElementById('audit-tbody').dataset.showAll='true'; poll();"
449
+ class="text-accent hover:underline cursor-pointer text-xs">Show all ${meaningful.length} entries</button>
450
+ </td></tr>`;
451
+ }
452
+ }
453
+
454
+ // ── Actions ──────────────────────────────────────
455
+
456
+ const pendingActions = new Set();
457
+
458
+ async function approveD(id) {
459
+ if (pendingActions.has(id)) return;
460
+ pendingActions.add(id);
461
+ document.querySelectorAll(`[onclick*="${id}"]`).forEach(b => { b.disabled = true; b.textContent = 'Approving...'; b.classList.add('opacity-50'); });
462
+ await fetch(`${API}/api/requests/${id}/approve?token=${TOKEN}`, { method: 'POST' });
463
+ pendingActions.delete(id);
464
+ poll();
465
+ }
466
+
467
+ async function rejectD(id) {
468
+ if (pendingActions.has(id)) return;
469
+ pendingActions.add(id);
470
+ document.querySelectorAll(`[onclick*="${id}"]`).forEach(b => { b.disabled = true; b.classList.add('opacity-50'); });
471
+ await fetch(`${API}/api/requests/${id}/reject?token=${TOKEN}`, { method: 'POST' });
472
+ pendingActions.delete(id);
473
+ poll();
474
+ }
475
+
476
+ async function revokeD(id) {
477
+ if (pendingActions.has(id)) return;
478
+ pendingActions.add(id);
479
+ await fetch(`${API}/api/requests/${id}/revoke`, {
480
+ method: 'POST',
481
+ headers: { 'Authorization': `Bearer ${TOKEN}` },
482
+ });
483
+ pendingActions.delete(id);
484
+ poll();
485
+ }
486
+
487
+ // ── Helpers ──────────────────────────────────────
488
+
489
+ function shortAddr(addr) {
490
+ if (!addr) return '—';
491
+ return addr.slice(0, 6) + '...' + addr.slice(-4);
492
+ }
493
+
494
+ function esc(s) {
495
+ const d = document.createElement('div');
496
+ d.textContent = s ?? '';
497
+ return d.innerHTML;
498
+ }
499
+
500
+ function countdown(iso) {
501
+ const diff = new Date(iso) - Date.now();
502
+ if (diff <= 0) return 'expired';
503
+ const m = Math.floor(diff / 60000);
504
+ const s = Math.floor((diff % 60000) / 1000);
505
+ return `${m}m ${s}s`;
506
+ }
507
+ </script>
508
+ </body>
509
+ </html>