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.
- package/.dockerignore +4 -0
- package/.env.example +30 -0
- package/AGENTS.md +212 -0
- package/BOOTSTRAP.md +55 -0
- package/Dockerfile +52 -0
- package/HEARTBEAT.md +7 -0
- package/IDENTITY.md +23 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SOUL.md +202 -0
- package/SUBMISSION.md +128 -0
- package/TOOLS.md +40 -0
- package/USER.md +17 -0
- package/acp-seller/bin/acp.ts +807 -0
- package/acp-seller/config.json +34 -0
- package/acp-seller/package.json +55 -0
- package/acp-seller/src/commands/agent.ts +328 -0
- package/acp-seller/src/commands/bounty.ts +1189 -0
- package/acp-seller/src/commands/deploy.ts +414 -0
- package/acp-seller/src/commands/job.ts +217 -0
- package/acp-seller/src/commands/profile.ts +71 -0
- package/acp-seller/src/commands/resource.ts +91 -0
- package/acp-seller/src/commands/search.ts +327 -0
- package/acp-seller/src/commands/sell.ts +883 -0
- package/acp-seller/src/commands/serve.ts +258 -0
- package/acp-seller/src/commands/setup.ts +399 -0
- package/acp-seller/src/commands/token.ts +88 -0
- package/acp-seller/src/commands/wallet.ts +123 -0
- package/acp-seller/src/lib/api.ts +118 -0
- package/acp-seller/src/lib/auth.ts +291 -0
- package/acp-seller/src/lib/bounty.ts +257 -0
- package/acp-seller/src/lib/client.ts +42 -0
- package/acp-seller/src/lib/config.ts +240 -0
- package/acp-seller/src/lib/open.ts +41 -0
- package/acp-seller/src/lib/openclawCron.ts +138 -0
- package/acp-seller/src/lib/output.ts +104 -0
- package/acp-seller/src/lib/wallet.ts +81 -0
- package/acp-seller/src/seller/offerings/_shared/preTransactionScan.ts +127 -0
- package/acp-seller/src/seller/offerings/canonical-catalog.ts +221 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/handlers.ts +20 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/offering.json +18 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_translate/handlers.ts +21 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_translate/offering.json +22 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/handlers.ts +20 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/offering.json +18 -0
- package/acp-seller/src/seller/runtime/acpSocket.ts +413 -0
- package/acp-seller/src/seller/runtime/logger.ts +36 -0
- package/acp-seller/src/seller/runtime/offeringTypes.ts +52 -0
- package/acp-seller/src/seller/runtime/offerings.ts +277 -0
- package/acp-seller/src/seller/runtime/paymentVerification.test.ts +207 -0
- package/acp-seller/src/seller/runtime/paymentVerification.ts +363 -0
- package/acp-seller/src/seller/runtime/seller.onchain.test.ts +220 -0
- package/acp-seller/src/seller/runtime/seller.test.ts +823 -0
- package/acp-seller/src/seller/runtime/seller.ts +1041 -0
- package/acp-seller/src/seller/runtime/sellerApi.ts +71 -0
- package/acp-seller/src/seller/runtime/startup.ts +270 -0
- package/acp-seller/src/seller/runtime/types.ts +62 -0
- package/acp-seller/tsconfig.json +20 -0
- package/bin/spendos.js +23 -0
- package/contracts/SpendOSAudit.sol +29 -0
- package/dist/mcp-server.mjs +153 -0
- package/jobs/translate.json +7 -0
- package/jobs/tweet-gen.json +7 -0
- package/openclaw.json +41 -0
- package/package.json +49 -0
- package/plugins/spendos-events/index.ts +78 -0
- package/plugins/spendos-events/package.json +14 -0
- package/policies/enforce-bounds.mjs +71 -0
- package/public/index.html +509 -0
- package/public/landing.html +241 -0
- package/railway.json +12 -0
- package/railway.toml +12 -0
- package/scripts/deploy.ts +48 -0
- package/scripts/test-x402-mainnet.ts +30 -0
- package/scripts/xmtp-listener.ts +61 -0
- package/setup.sh +278 -0
- package/skills/spendos/skill.md +26 -0
- package/src/agent.ts +152 -0
- package/src/audit.ts +166 -0
- package/src/governance.ts +367 -0
- package/src/job-registry.ts +306 -0
- package/src/mcp-public.ts +145 -0
- package/src/mcp-server.ts +171 -0
- package/src/opportunity-scanner.ts +138 -0
- package/src/server.ts +870 -0
- package/src/venice-x402.ts +234 -0
- package/src/xmtp.ts +109 -0
- package/src/zerion.ts +58 -0
- package/start.sh +168 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
createWallet,
|
|
5
|
+
importWalletMnemonic,
|
|
6
|
+
getWallet,
|
|
7
|
+
listWallets,
|
|
8
|
+
createPolicy,
|
|
9
|
+
deletePolicy,
|
|
10
|
+
createApiKey,
|
|
11
|
+
revokeApiKey,
|
|
12
|
+
deleteWallet,
|
|
13
|
+
exportWallet,
|
|
14
|
+
type WalletInfo,
|
|
15
|
+
type ApiKeyResult,
|
|
16
|
+
} from '@open-wallet-standard/core';
|
|
17
|
+
|
|
18
|
+
// ── Types ──────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface DelegationRequest {
|
|
21
|
+
id: string;
|
|
22
|
+
agentAddress: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
chains: string[]; // CAIP-2 chain IDs e.g. ["eip155:8453"]
|
|
25
|
+
operations: string[]; // "sign_message" | "sign_tx"
|
|
26
|
+
maxAmountPerAction: string; // e.g. "100.00"
|
|
27
|
+
totalBudget: string; // e.g. "500.00"
|
|
28
|
+
allowedRecipients?: string[]; // vendor allowlist — approved contract/wallet addresses
|
|
29
|
+
expiresAt: string; // ISO 8601
|
|
30
|
+
status: 'pending' | 'approved' | 'rejected' | 'expired' | 'revoked';
|
|
31
|
+
createdAt: string;
|
|
32
|
+
decidedAt?: string;
|
|
33
|
+
sessionKeyId?: string;
|
|
34
|
+
sessionKeyToken?: string;
|
|
35
|
+
policyId?: string;
|
|
36
|
+
opsUsed: number;
|
|
37
|
+
zerionPortfolio?: WalletPortfolio;
|
|
38
|
+
aiInterpretation?: AiInterpretation;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WalletPortfolio {
|
|
42
|
+
totalValueUsd: number;
|
|
43
|
+
tokens: { symbol: string; balance: string; valueUsd: number }[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AiInterpretation {
|
|
47
|
+
summary: string;
|
|
48
|
+
riskLevel: 'low' | 'medium' | 'high';
|
|
49
|
+
warnings: string[];
|
|
50
|
+
suggestedBounds?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AgentPnL {
|
|
54
|
+
totalEarned: number;
|
|
55
|
+
totalSpent: number;
|
|
56
|
+
profit: number;
|
|
57
|
+
queryCount: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Config ─────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export const OWS_VAULT = process.env.OWS_VAULT_PATH ?? undefined; // undefined = OWS default (~/.ows/)
|
|
63
|
+
const DATA_DIR = process.env.SPENDOS_DATA_DIR ?? './data';
|
|
64
|
+
|
|
65
|
+
// ── State (persisted to disk) ──────────────────────────
|
|
66
|
+
|
|
67
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
68
|
+
|
|
69
|
+
const DELEGATIONS_FILE = `${DATA_DIR}/delegations.json`;
|
|
70
|
+
const PNL_FILE = `${DATA_DIR}/pnl.json`;
|
|
71
|
+
|
|
72
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
73
|
+
|
|
74
|
+
function loadDelegations(): Map<string, DelegationRequest> {
|
|
75
|
+
if (!existsSync(DELEGATIONS_FILE)) return new Map();
|
|
76
|
+
try {
|
|
77
|
+
const arr: DelegationRequest[] = JSON.parse(readFileSync(DELEGATIONS_FILE, 'utf-8'));
|
|
78
|
+
return new Map(arr.map(d => [d.id, d]));
|
|
79
|
+
} catch { return new Map(); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function saveDelegations(): void {
|
|
83
|
+
writeFileSync(DELEGATIONS_FILE, JSON.stringify(Array.from(delegations.values()), null, 2));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function loadPnL(): AgentPnL {
|
|
87
|
+
if (!existsSync(PNL_FILE)) return { totalEarned: 0, totalSpent: 0, profit: 0, queryCount: 0 };
|
|
88
|
+
try { return JSON.parse(readFileSync(PNL_FILE, 'utf-8')); } catch { return { totalEarned: 0, totalSpent: 0, profit: 0, queryCount: 0 }; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function savePnL(): void {
|
|
92
|
+
writeFileSync(PNL_FILE, JSON.stringify(pnl, null, 2));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const delegations = loadDelegations();
|
|
96
|
+
const pnl: AgentPnL = loadPnL();
|
|
97
|
+
|
|
98
|
+
console.log(`[SpendOS] Loaded ${delegations.size} delegations, P&L: $${pnl.totalEarned.toFixed(3)} earned`);
|
|
99
|
+
|
|
100
|
+
const WALLET_NAME = 'spendos-treasury';
|
|
101
|
+
const PASSPHRASE = process.env.OWS_PASSPHRASE ?? 'spendos-demo';
|
|
102
|
+
|
|
103
|
+
let treasuryWallet: WalletInfo | null = null;
|
|
104
|
+
|
|
105
|
+
// ── Wallet Init ────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export function initWallet(): WalletInfo {
|
|
108
|
+
if (OWS_VAULT) console.log(`[SpendOS] OWS vault: ${OWS_VAULT}`);
|
|
109
|
+
|
|
110
|
+
const importMnemonic = process.env.OWS_IMPORT_MNEMONIC;
|
|
111
|
+
const forceReimport = process.env.OWS_FORCE_REIMPORT === 'true';
|
|
112
|
+
|
|
113
|
+
// If force reimport, delete existing wallet first
|
|
114
|
+
if (forceReimport && importMnemonic) {
|
|
115
|
+
try {
|
|
116
|
+
deleteWallet(WALLET_NAME, OWS_VAULT);
|
|
117
|
+
console.log(`[SpendOS] Deleted existing wallet for reimport`);
|
|
118
|
+
} catch { /* didn't exist */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
treasuryWallet = getWallet(WALLET_NAME, OWS_VAULT);
|
|
123
|
+
console.log(`[SpendOS] Loaded existing wallet: ${WALLET_NAME}`);
|
|
124
|
+
} catch {
|
|
125
|
+
if (importMnemonic) {
|
|
126
|
+
treasuryWallet = importWalletMnemonic(WALLET_NAME, importMnemonic, PASSPHRASE, undefined, OWS_VAULT);
|
|
127
|
+
console.log(`[SpendOS] Imported wallet from OWS_IMPORT_MNEMONIC`);
|
|
128
|
+
} else {
|
|
129
|
+
treasuryWallet = createWallet(WALLET_NAME, PASSPHRASE, undefined, OWS_VAULT);
|
|
130
|
+
console.log(`[SpendOS] Created new wallet: ${WALLET_NAME}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const evmAccount = treasuryWallet.accounts.find(a => a.chainId.startsWith('eip155:'));
|
|
134
|
+
console.log(`[SpendOS] EVM address: ${evmAccount?.address ?? 'none'}`);
|
|
135
|
+
return treasuryWallet;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getTreasuryWallet(): WalletInfo {
|
|
139
|
+
if (!treasuryWallet) throw new Error('Wallet not initialized');
|
|
140
|
+
return treasuryWallet;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function getTreasuryAddress(): string {
|
|
144
|
+
const w = getTreasuryWallet();
|
|
145
|
+
return w.accounts.find(a => a.chainId.startsWith('eip155:'))?.address ?? '';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Delegation Management ──────────────────────────────
|
|
149
|
+
|
|
150
|
+
export function createDelegation(
|
|
151
|
+
req: Omit<DelegationRequest, 'id' | 'status' | 'createdAt' | 'opsUsed'>
|
|
152
|
+
): DelegationRequest {
|
|
153
|
+
const delegation: DelegationRequest = {
|
|
154
|
+
...req,
|
|
155
|
+
id: randomUUID(),
|
|
156
|
+
status: 'pending',
|
|
157
|
+
createdAt: new Date().toISOString(),
|
|
158
|
+
opsUsed: 0,
|
|
159
|
+
};
|
|
160
|
+
delegations.set(delegation.id, delegation);
|
|
161
|
+
saveDelegations();
|
|
162
|
+
console.log(`[SpendOS] New delegation request: ${delegation.id} — ${delegation.reason}`);
|
|
163
|
+
return delegation;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function approveDelegation(id: string): DelegationRequest {
|
|
167
|
+
const d = delegations.get(id);
|
|
168
|
+
if (!d) throw new Error(`Delegation ${id} not found`);
|
|
169
|
+
if (d.status !== 'pending') throw new Error(`Delegation ${id} is ${d.status}, not pending`);
|
|
170
|
+
|
|
171
|
+
// Check expiry
|
|
172
|
+
if (new Date(d.expiresAt) <= new Date()) {
|
|
173
|
+
d.status = 'expired';
|
|
174
|
+
d.decidedAt = new Date().toISOString();
|
|
175
|
+
throw new Error(`Delegation ${id} has already expired`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 1. Build OWS policy with declarative rules + optional executable
|
|
179
|
+
const policyId = `spendos-${d.id}`;
|
|
180
|
+
const policyExecPath = resolve(process.cwd(), 'policies', 'enforce-bounds.mjs');
|
|
181
|
+
const hasExecutable = existsSync(policyExecPath);
|
|
182
|
+
|
|
183
|
+
const policyJson = JSON.stringify({
|
|
184
|
+
id: policyId,
|
|
185
|
+
name: `SpendOS: ${d.reason}`,
|
|
186
|
+
version: 1,
|
|
187
|
+
created_at: new Date().toISOString(),
|
|
188
|
+
rules: [
|
|
189
|
+
{ type: 'allowed_chains', chain_ids: d.chains },
|
|
190
|
+
{ type: 'expires_at', timestamp: d.expiresAt },
|
|
191
|
+
],
|
|
192
|
+
executable: hasExecutable ? policyExecPath : null,
|
|
193
|
+
config: hasExecutable ? {
|
|
194
|
+
allowed_recipients: d.allowedRecipients ?? [],
|
|
195
|
+
max_native_value_wei: '100000000000000000', // 0.1 ETH default cap
|
|
196
|
+
max_daily_total_wei: '500000000000000000', // 0.5 ETH daily cap
|
|
197
|
+
} : null,
|
|
198
|
+
action: 'deny',
|
|
199
|
+
});
|
|
200
|
+
createPolicy(policyJson, OWS_VAULT);
|
|
201
|
+
|
|
202
|
+
// 2. Create API key scoped to wallet + policy (rollback policy on failure)
|
|
203
|
+
const wallet = getTreasuryWallet();
|
|
204
|
+
let apiKey: ApiKeyResult;
|
|
205
|
+
try {
|
|
206
|
+
apiKey = createApiKey(
|
|
207
|
+
`spendos-session-${d.id}`,
|
|
208
|
+
[wallet.id],
|
|
209
|
+
[policyId],
|
|
210
|
+
PASSPHRASE,
|
|
211
|
+
d.expiresAt,
|
|
212
|
+
OWS_VAULT,
|
|
213
|
+
);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
deletePolicy(policyId, OWS_VAULT); // Rollback on failure
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 3. Update delegation
|
|
220
|
+
d.status = 'approved';
|
|
221
|
+
d.decidedAt = new Date().toISOString();
|
|
222
|
+
d.sessionKeyId = apiKey.id;
|
|
223
|
+
d.sessionKeyToken = apiKey.token;
|
|
224
|
+
d.policyId = policyId;
|
|
225
|
+
|
|
226
|
+
saveDelegations();
|
|
227
|
+
console.log(`[SpendOS] Approved delegation ${id} — session key: ${apiKey.id}`);
|
|
228
|
+
return d;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function rejectDelegation(id: string): DelegationRequest {
|
|
232
|
+
const d = delegations.get(id);
|
|
233
|
+
if (!d) throw new Error(`Delegation ${id} not found`);
|
|
234
|
+
if (d.status !== 'pending') throw new Error(`Delegation ${id} is ${d.status}, not pending`);
|
|
235
|
+
|
|
236
|
+
d.status = 'rejected';
|
|
237
|
+
d.decidedAt = new Date().toISOString();
|
|
238
|
+
saveDelegations();
|
|
239
|
+
console.log(`[SpendOS] Rejected delegation ${id}`);
|
|
240
|
+
return d;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function revokeDelegation(id: string): DelegationRequest {
|
|
244
|
+
const d = delegations.get(id);
|
|
245
|
+
if (!d) throw new Error(`Delegation ${id} not found`);
|
|
246
|
+
if (d.status !== 'approved') throw new Error(`Delegation ${id} is ${d.status}, not approved`);
|
|
247
|
+
|
|
248
|
+
// Revoke OWS API key + delete policy
|
|
249
|
+
if (d.sessionKeyId) revokeApiKey(d.sessionKeyId, OWS_VAULT);
|
|
250
|
+
if (d.policyId) deletePolicy(d.policyId, OWS_VAULT);
|
|
251
|
+
|
|
252
|
+
d.status = 'revoked';
|
|
253
|
+
d.decidedAt = new Date().toISOString();
|
|
254
|
+
saveDelegations();
|
|
255
|
+
console.log(`[SpendOS] Revoked delegation ${id}`);
|
|
256
|
+
return d;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Expiry Check ───────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
export function expireStale(): void {
|
|
262
|
+
const now = new Date();
|
|
263
|
+
const approvedCount = Array.from(delegations.values()).filter(d => d.status === 'approved').length;
|
|
264
|
+
let changed = false;
|
|
265
|
+
for (const d of delegations.values()) {
|
|
266
|
+
if (d.status === 'approved' && new Date(d.expiresAt) <= now) {
|
|
267
|
+
try {
|
|
268
|
+
if (d.sessionKeyId) revokeApiKey(d.sessionKeyId, OWS_VAULT);
|
|
269
|
+
if (d.policyId) deletePolicy(d.policyId, OWS_VAULT);
|
|
270
|
+
} catch {
|
|
271
|
+
// Key may already be revoked
|
|
272
|
+
}
|
|
273
|
+
d.status = 'expired';
|
|
274
|
+
d.decidedAt = now.toISOString();
|
|
275
|
+
console.log(`[SpendOS] Auto-expired delegation ${d.id} (dead man's switch)`);
|
|
276
|
+
changed = true;
|
|
277
|
+
}
|
|
278
|
+
if (d.status === 'pending' && new Date(d.expiresAt) <= now) {
|
|
279
|
+
d.status = 'expired';
|
|
280
|
+
d.decidedAt = now.toISOString();
|
|
281
|
+
console.log(`[SpendOS] Expired pending delegation ${d.id}`);
|
|
282
|
+
changed = true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (changed) saveDelegations();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Queries ────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
export function getDelegations(): DelegationRequest[] {
|
|
291
|
+
return Array.from(delegations.values()).sort(
|
|
292
|
+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function getActiveDelegations(): DelegationRequest[] {
|
|
297
|
+
return getDelegations().filter(d => d.status === 'approved');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function getPendingDelegations(): DelegationRequest[] {
|
|
301
|
+
return getDelegations().filter(d => d.status === 'pending');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function getDelegation(id: string): DelegationRequest | undefined {
|
|
305
|
+
return delegations.get(id);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── P&L Tracking ───────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
export function recordEarning(amount: number): void {
|
|
311
|
+
pnl.totalEarned += amount;
|
|
312
|
+
pnl.profit = pnl.totalEarned - pnl.totalSpent;
|
|
313
|
+
pnl.queryCount++;
|
|
314
|
+
savePnL();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function recordSpending(amount: number): void {
|
|
318
|
+
pnl.totalSpent += amount;
|
|
319
|
+
pnl.profit = pnl.totalEarned - pnl.totalSpent;
|
|
320
|
+
savePnL();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function getPnL(): AgentPnL {
|
|
324
|
+
return { ...pnl };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function resetPnL(): void {
|
|
328
|
+
pnl.totalEarned = 0;
|
|
329
|
+
pnl.totalSpent = 0;
|
|
330
|
+
pnl.profit = 0;
|
|
331
|
+
pnl.queryCount = 0;
|
|
332
|
+
savePnL();
|
|
333
|
+
console.log('[SpendOS] P&L reset to zero');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Heuristic AI Interpretation ────────────────────────
|
|
337
|
+
|
|
338
|
+
export function generateHeuristicInterpretation(d: DelegationRequest): AiInterpretation {
|
|
339
|
+
const warnings: string[] = [];
|
|
340
|
+
let riskLevel: 'low' | 'medium' | 'high' = 'low';
|
|
341
|
+
|
|
342
|
+
// Check for long delegation
|
|
343
|
+
const expiresInMs = new Date(d.expiresAt).getTime() - Date.now();
|
|
344
|
+
if (expiresInMs > 60 * 60 * 1000) {
|
|
345
|
+
warnings.push('Delegation lifetime exceeds one hour.');
|
|
346
|
+
riskLevel = 'medium';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Check for multi-chain
|
|
350
|
+
if (d.chains.length > 1) {
|
|
351
|
+
warnings.push('Multi-chain delegation requested.');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Check budget
|
|
355
|
+
const budget = parseFloat(d.totalBudget);
|
|
356
|
+
if (budget > 100) {
|
|
357
|
+
warnings.push(`Budget of $${d.totalBudget} exceeds $100.`);
|
|
358
|
+
riskLevel = 'high';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const summary = `Agent wants to ${d.reason.toLowerCase()}. ` +
|
|
362
|
+
`Chains: ${d.chains.join(', ')}. ` +
|
|
363
|
+
`Operations: ${d.operations.join(', ')}. ` +
|
|
364
|
+
`Budget: $${d.totalBudget}, expires in ${Math.round(expiresInMs / 60000)}m.`;
|
|
365
|
+
|
|
366
|
+
return { summary, riskLevel, warnings };
|
|
367
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpendOS Job Registry
|
|
3
|
+
*
|
|
4
|
+
* The agent creates paid AI endpoints by dropping JSON files in /app/jobs/.
|
|
5
|
+
* External customers pay via x402 at /api/jobs/:name.
|
|
6
|
+
* Agent tests internally at /api/internal/jobs/:name (free).
|
|
7
|
+
*/
|
|
8
|
+
import { readdirSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { Express, Request, Response } from "express";
|
|
11
|
+
import {
|
|
12
|
+
recordEarning,
|
|
13
|
+
recordSpending,
|
|
14
|
+
getTreasuryAddress,
|
|
15
|
+
} from "./governance.js";
|
|
16
|
+
|
|
17
|
+
export interface JobDefinition {
|
|
18
|
+
name: string;
|
|
19
|
+
endpoint: string;
|
|
20
|
+
price: string;
|
|
21
|
+
prompt: string;
|
|
22
|
+
inputs: string[];
|
|
23
|
+
description?: string;
|
|
24
|
+
model?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const JOBS_DIR = "/app/jobs";
|
|
28
|
+
const jobs: Map<string, JobDefinition> = new Map();
|
|
29
|
+
|
|
30
|
+
export function loadJobs(): JobDefinition[] {
|
|
31
|
+
jobs.clear();
|
|
32
|
+
if (!existsSync(JOBS_DIR)) {
|
|
33
|
+
mkdirSync(JOBS_DIR, { recursive: true });
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const files = readdirSync(JOBS_DIR).filter((f) => f.endsWith(".json"));
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = readFileSync(join(JOBS_DIR, file), "utf-8");
|
|
41
|
+
const job: JobDefinition = JSON.parse(raw);
|
|
42
|
+
if (!job.name || !job.prompt || !job.inputs) continue;
|
|
43
|
+
job.endpoint = `/api/jobs/${job.name}`;
|
|
44
|
+
job.price = job.price ?? "$0.01";
|
|
45
|
+
jobs.set(job.name, job);
|
|
46
|
+
console.log(
|
|
47
|
+
`[Jobs] Loaded: ${job.name} (${job.price}) → ${job.endpoint}`,
|
|
48
|
+
);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.log(`[Jobs] Failed to load ${file}: ${err}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return Array.from(jobs.values());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Execute a job (shared logic for both external and internal)
|
|
57
|
+
async function executeJob(
|
|
58
|
+
job: JobDefinition,
|
|
59
|
+
body: Record<string, any>,
|
|
60
|
+
): Promise<{
|
|
61
|
+
result: string;
|
|
62
|
+
cost: { earned: number; inference: number; profit: number };
|
|
63
|
+
}> {
|
|
64
|
+
const missing = job.inputs.filter((i) => !body?.[i]);
|
|
65
|
+
if (missing.length > 0)
|
|
66
|
+
throw new Error(`Missing inputs: ${missing.join(", ")}`);
|
|
67
|
+
|
|
68
|
+
let prompt = job.prompt;
|
|
69
|
+
for (const input of job.inputs) {
|
|
70
|
+
prompt = prompt.replace(
|
|
71
|
+
new RegExp(`\\{\\{${input}\\}\\}`, "g"),
|
|
72
|
+
body[input],
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { buildSiweHeader } = await import("./venice-x402.js");
|
|
77
|
+
const uri = "https://outerface.venice.ai/api/v1/chat/completions";
|
|
78
|
+
const siweHeader = await buildSiweHeader(uri);
|
|
79
|
+
|
|
80
|
+
const veniceRes = await fetch(
|
|
81
|
+
"https://api.venice.ai/api/v1/chat/completions",
|
|
82
|
+
{
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
"X-Sign-In-With-X": siweHeader,
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
model: job.model ?? "kimi-k2-5",
|
|
90
|
+
messages: [{ role: "user", content: prompt }],
|
|
91
|
+
max_tokens: 2048,
|
|
92
|
+
stream: false,
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (!veniceRes.ok) {
|
|
98
|
+
const text = await veniceRes.text();
|
|
99
|
+
throw new Error(`Venice ${veniceRes.status}: ${text.slice(0, 200)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const data = (await veniceRes.json()) as any;
|
|
103
|
+
const result = data.choices?.[0]?.message?.content ?? "";
|
|
104
|
+
const priceNum = parseFloat(job.price.replace("$", "")) || 0.01;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
result,
|
|
108
|
+
cost: { earned: priceNum, inference: 0.002, profit: priceNum - 0.002 },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function registerJobRoutes(app: Express): void {
|
|
113
|
+
// List all jobs (public)
|
|
114
|
+
app.get("/api/jobs", (_req: Request, res: Response) => {
|
|
115
|
+
res.json(
|
|
116
|
+
Array.from(jobs.values()).map((j) => ({
|
|
117
|
+
name: j.name,
|
|
118
|
+
endpoint: j.endpoint,
|
|
119
|
+
price: j.price,
|
|
120
|
+
inputs: j.inputs,
|
|
121
|
+
description: j.description,
|
|
122
|
+
})),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Reload jobs from disk + sync to ACP if configured
|
|
127
|
+
app.post("/api/jobs/reload", async (_req: Request, res: Response) => {
|
|
128
|
+
const loaded = loadJobs();
|
|
129
|
+
const acpResults = await syncJobsToAcp(loaded);
|
|
130
|
+
res.json({
|
|
131
|
+
ok: true,
|
|
132
|
+
jobs: loaded.length,
|
|
133
|
+
names: loaded.map((j) => j.name),
|
|
134
|
+
acp: acpResults,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// EXTERNAL: x402-gated job execution (customers pay)
|
|
139
|
+
app.post("/api/jobs/:name", async (req: Request, res: Response) => {
|
|
140
|
+
const job = jobs.get(req.params.name);
|
|
141
|
+
if (!job) {
|
|
142
|
+
res.status(404).json({ error: `Job not found: ${req.params.name}` });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Manual x402 gate — return 402 with payment requirements
|
|
147
|
+
const paymentSig = req.headers["payment-signature"];
|
|
148
|
+
if (!paymentSig) {
|
|
149
|
+
const priceNum = parseFloat(job.price.replace("$", "")) || 0.01;
|
|
150
|
+
const amount = Math.round(priceNum * 1e6).toString(); // USDC 6 decimals
|
|
151
|
+
const payTo = getTreasuryAddress();
|
|
152
|
+
const requirements = {
|
|
153
|
+
x402Version: 2,
|
|
154
|
+
error: "Payment required",
|
|
155
|
+
resource: {
|
|
156
|
+
url: `https://spendos.xyz${job.endpoint}`,
|
|
157
|
+
description: job.description ?? job.name,
|
|
158
|
+
mimeType: "application/json",
|
|
159
|
+
},
|
|
160
|
+
accepts: [
|
|
161
|
+
{
|
|
162
|
+
scheme: "exact",
|
|
163
|
+
network: process.env.X402_NETWORK ?? "eip155:8453",
|
|
164
|
+
amount,
|
|
165
|
+
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
166
|
+
payTo,
|
|
167
|
+
maxTimeoutSeconds: 300,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
};
|
|
171
|
+
res.setHeader(
|
|
172
|
+
"payment-required",
|
|
173
|
+
Buffer.from(JSON.stringify(requirements)).toString("base64"),
|
|
174
|
+
);
|
|
175
|
+
res.status(402).json({});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// If payment header present, execute (facilitator would have verified upstream)
|
|
180
|
+
try {
|
|
181
|
+
const { result, cost } = await executeJob(job, req.body);
|
|
182
|
+
recordEarning(cost.earned);
|
|
183
|
+
recordSpending(cost.inference);
|
|
184
|
+
res.json({ job: job.name, result, cost });
|
|
185
|
+
} catch (err: any) {
|
|
186
|
+
res
|
|
187
|
+
.status(err.message?.includes("Missing inputs") ? 400 : 500)
|
|
188
|
+
.json({ error: err.message });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// INTERNAL: free job execution (agent self-testing)
|
|
193
|
+
app.post("/api/internal/jobs/:name", async (req: Request, res: Response) => {
|
|
194
|
+
const job = jobs.get(req.params.name);
|
|
195
|
+
if (!job) {
|
|
196
|
+
res.status(404).json({ error: `Job not found: ${req.params.name}` });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const { result, cost } = await executeJob(job, req.body);
|
|
202
|
+
recordSpending(cost.inference); // track cost but no earnings (internal)
|
|
203
|
+
res.json({ job: job.name, result, cost: { inference: cost.inference } });
|
|
204
|
+
} catch (err: any) {
|
|
205
|
+
res
|
|
206
|
+
.status(err.message?.includes("Missing inputs") ? 400 : 500)
|
|
207
|
+
.json({ error: err.message });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function getJobs(): JobDefinition[] {
|
|
213
|
+
return Array.from(jobs.values());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── ACP Sync ──────────────────────────────────────────
|
|
217
|
+
// When new jobs are created, auto-register them as ACP offerings
|
|
218
|
+
// so other agents can discover and pay for them.
|
|
219
|
+
|
|
220
|
+
const ACP_API = process.env.ACP_API_URL ?? "https://claw-api.virtuals.io";
|
|
221
|
+
const ACP_AUTH = process.env.ACP_AUTH_URL ?? "https://acpx.virtuals.io";
|
|
222
|
+
|
|
223
|
+
async function syncJobsToAcp(allJobs: JobDefinition[]): Promise<string[]> {
|
|
224
|
+
const apiKey = process.env.LITE_AGENT_API_KEY;
|
|
225
|
+
if (!apiKey) return ["skipped — no LITE_AGENT_API_KEY"];
|
|
226
|
+
|
|
227
|
+
// Get agent wallet for auth
|
|
228
|
+
let walletAddress: string;
|
|
229
|
+
try {
|
|
230
|
+
const whoRes = await fetch(`${ACP_AUTH}/api/agents/me`, {
|
|
231
|
+
headers: { "x-api-key": apiKey },
|
|
232
|
+
});
|
|
233
|
+
if (!whoRes.ok) return ["skipped — auth failed"];
|
|
234
|
+
const me = (await whoRes.json()) as any;
|
|
235
|
+
walletAddress = me.walletAddress;
|
|
236
|
+
} catch {
|
|
237
|
+
return ["skipped — ACP unreachable"];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Get existing offerings
|
|
241
|
+
let existingNames: Set<string>;
|
|
242
|
+
try {
|
|
243
|
+
const offerRes = await fetch(
|
|
244
|
+
`${ACP_API}/acp/agents/${walletAddress}/offerings`,
|
|
245
|
+
{
|
|
246
|
+
headers: { "x-api-key": apiKey },
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
const offerings = (await offerRes.json()) as any[];
|
|
250
|
+
existingNames = new Set(offerings.map((o: any) => o.name));
|
|
251
|
+
} catch {
|
|
252
|
+
existingNames = new Set();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const results: string[] = [];
|
|
256
|
+
|
|
257
|
+
for (const job of allJobs) {
|
|
258
|
+
const acpName = `spendos_${job.name}`;
|
|
259
|
+
if (existingNames.has(acpName)) {
|
|
260
|
+
results.push(`${acpName}: exists`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Auto-register new offering
|
|
265
|
+
try {
|
|
266
|
+
const priceNum = parseFloat(job.price.replace("$", ""));
|
|
267
|
+
const body = {
|
|
268
|
+
name: acpName,
|
|
269
|
+
description: job.description ?? `SpendOS job: ${job.name}`,
|
|
270
|
+
fee: priceNum,
|
|
271
|
+
feeType: "fixed",
|
|
272
|
+
requiresFunds: false,
|
|
273
|
+
requirement: {
|
|
274
|
+
type: "object",
|
|
275
|
+
properties: Object.fromEntries(
|
|
276
|
+
job.inputs.map((i) => [i, { type: "string" }]),
|
|
277
|
+
),
|
|
278
|
+
required: job.inputs,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const createRes = await fetch(`${ACP_API}/acp/job-offerings`, {
|
|
283
|
+
method: "POST",
|
|
284
|
+
headers: { "Content-Type": "application/json", "x-api-key": apiKey },
|
|
285
|
+
body: JSON.stringify(body),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (createRes.ok) {
|
|
289
|
+
results.push(`${acpName}: registered`);
|
|
290
|
+
console.log(
|
|
291
|
+
`[ACP] Auto-registered offering: ${acpName} ($${priceNum})`,
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
const err = await createRes.text();
|
|
295
|
+
results.push(`${acpName}: failed (${createRes.status})`);
|
|
296
|
+
console.log(
|
|
297
|
+
`[ACP] Failed to register ${acpName}: ${err.slice(0, 100)}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
} catch (err) {
|
|
301
|
+
results.push(`${acpName}: error`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return results;
|
|
306
|
+
}
|